From f37a4ab98534888c58e9d7339e28ce64bbe059ee Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Wed, 25 Mar 2026 17:40:02 +0200 Subject: [PATCH] feat: implement search_auctions method for Fragment marketplace; add AuctionsResult type; enhance topup_ton example and tests --- CHANGELOG.md | 4 + examples/search_auctions.py | 43 +++++++++ examples/topup_ton.py | 4 +- pyfragment/__init__.py | 2 + pyfragment/client.py | 33 ++++++- pyfragment/methods/__init__.py | 2 + pyfragment/methods/search_auctions.py | 72 +++++++++++++++ pyfragment/methods/topup_ton.py | 6 +- pyfragment/types/__init__.py | 2 + pyfragment/types/results.py | 24 +++++ pyfragment/utils/__init__.py | 3 +- pyfragment/utils/html.py | 74 +++++++++++++++ tests/014_test_search.py | 124 ++++++++++++++++++++++++++ 13 files changed, 386 insertions(+), 7 deletions(-) create mode 100644 examples/search_auctions.py create mode 100644 pyfragment/methods/search_auctions.py create mode 100644 tests/014_test_search.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 80a2dd0..527c182 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,10 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI - `recharge_ads(account, amount)` — add funds to your own Telegram Ads account; `account` is the channel or bot username linked to your Ads account; raises `ConfigurationError` if `amount` is out of range (1–1 000 000 000 TON) - `AdsRechargeResult` result type +**Marketplace** +- `search_auctions(query, type?, sort?, filter?, offset_id?)` — search the Fragment marketplace for usernames, numbers, or collectibles; `type` is one of `"usernames"`, `"numbers"`, `"collectibles"` (optional); `sort` is one of `"price_desc"`, `"price_asc"`, `"listed"`, `"ending"`; `filter` is one of `""`, `"auction"`, `"sale"`, `"sold"`; supports pagination via `offset_id`; returns parsed item dicts with `slug`, `name`, `status`, `price`, `ends_at` +- `AuctionsResult` result type + **Raw API access** - `FragmentClient.call(method, data, *, page_url)` — send a raw request to any Fragment API method without waiting for a library update - `FRAGMENT_BASE_URL` constant — single source of truth for the Fragment base URL used across all page constants and headers diff --git a/examples/search_auctions.py b/examples/search_auctions.py new file mode 100644 index 0000000..fa026f2 --- /dev/null +++ b/examples/search_auctions.py @@ -0,0 +1,43 @@ +""" +Example: search the Fragment marketplace for auction listings. + +type can be "usernames", "numbers", or "collectibles". +sort can be "price_desc", "price_asc", "listed", or "ending". +filter can be "", "auction", "sale", or "sold". +Use next_offset_id for pagination. +""" + +import asyncio + +from pyfragment import AuctionsResult, FragmentClient + +SEED = "word1 word2 ... word24" +API_KEY = "YOUR_TONAPI_KEY" +COOKIES = { + "stel_ssid": "YOUR_STEL_SSID", + "stel_dt": "YOUR_STEL_DT", + "stel_token": "YOUR_STEL_TOKEN", + "stel_ton_token": "YOUR_STEL_TON_TOKEN", +} + +QUERY = "durov" # search term +TYPE = "usernames" # "usernames", "numbers", or "collectibles" — or omit +SORT = "price_desc" # "price_desc", "price_asc", "listed", "ending" — or omit +FILTER = "auction" # "", "auction", "sale", "sold" — or omit + + +async def main() -> None: + async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client: + result: AuctionsResult = await client.search_auctions(QUERY, type=TYPE, sort=SORT, filter=FILTER) + + print(f"Found {len(result.items)} result(s):") + for item in result.items: + price = f"{item['price']} TON" if item["price"] else "n/a" + print(f" {item['name']:20s} {item['status'] or '':15s} {price:10s} {item['ends_at'] or '—'}") + + if result.next_offset_id: + print(f"\nMore results available — next page offset: {result.next_offset_id}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/topup_ton.py b/examples/topup_ton.py index f7730c8..e614d58 100644 --- a/examples/topup_ton.py +++ b/examples/topup_ton.py @@ -1,5 +1,7 @@ """ -Example: top up a Telegram Ads account with TON. +Example: Topup ton to recipient's Telegram balance. + +For adding TON to Telegram Ads account, use recharge_ads() instead. Amount must be an integer between 1 and 1 000 000 000 TON. Your wallet must hold at least the topup amount + ~0.056 TON for gas. diff --git a/pyfragment/__init__.py b/pyfragment/__init__.py index ac2af4f..e3eec8c 100644 --- a/pyfragment/__init__.py +++ b/pyfragment/__init__.py @@ -10,6 +10,7 @@ from pyfragment.types import ( AdsRechargeResult, AdsTopupResult, AnonymousNumberError, + AuctionsResult, ClientError, ConfigurationError, CookieError, @@ -39,6 +40,7 @@ __all__ = [ "FragmentClient", "AdsRechargeResult", "AdsTopupResult", + "AuctionsResult", "LoginCodeResult", "PremiumGiveawayResult", "PremiumResult", diff --git a/pyfragment/client.py b/pyfragment/client.py index 66a1fe3..3d0d4db 100644 --- a/pyfragment/client.py +++ b/pyfragment/client.py @@ -9,10 +9,12 @@ from pyfragment.methods.giveaway_stars import giveaway_stars from pyfragment.methods.purchase_premium import purchase_premium from pyfragment.methods.purchase_stars import purchase_stars from pyfragment.methods.recharge_ads import recharge_ads +from pyfragment.methods.search_auctions import search_auctions from pyfragment.methods.topup_ton import topup_ton from pyfragment.types import ( AdsRechargeResult, AdsTopupResult, + AuctionsResult, ConfigurationError, CookieError, LoginCodeResult, @@ -143,10 +145,10 @@ class FragmentClient: return await purchase_stars(self, username, amount, show_sender) async def topup_ton(self, username: str, amount: int, show_sender: bool = True) -> AdsTopupResult: - """Top up Telegram Ads balance with TON. + """Topup ton to recipient's Telegram balance. Args: - username: Ads account username (with or without ``@``). + username: Recipient's Telegram username (with or without ``@``). amount: Amount in TON — integer from ``1`` to ``1 000 000 000``. show_sender: Show your name as the sender. Defaults to ``True``. @@ -250,6 +252,33 @@ class FragmentClient: """ return await terminate_sessions(self, number) + async def search_auctions( + self, + query: str, + type: str | None = None, + sort: str | None = None, + filter: str | None = None, + offset_id: str | None = None, + ) -> AuctionsResult: + """Search the Fragment marketplace for auction listings. + + Args: + query: Search text (e.g. ``"durov"`` or ``"888"``). + type: Narrow results by item type — ``"usernames"``, ``"numbers"``, or + ``"collectibles"``. Omit to search across all types. + sort: Sort order — ``"price_desc"``, ``"price_asc"``, ``"listed"``, or + ``"ending"``. Omit to use Fragment's default ordering. + filter: Filter results — ``"auction"``, ``"sale"``, ``"sold"``, or + ``""`` (available items). Omit to return all. + offset_id: Pagination cursor — pass :attr:`AuctionsResult.next_offset_id` + from a previous result to fetch the next page. + + Returns: + :class:`AuctionsResult` with ``items`` (parsed list of item dicts) + and ``next_offset_id`` (``None`` on the last page). + """ + return await search_auctions(self, query, type=type, sort=sort, filter=filter, offset_id=offset_id) + async def call( self, method: str, data: dict[str, Any] | None = None, *, page_url: str = FRAGMENT_BASE_URL ) -> dict[str, Any]: diff --git a/pyfragment/methods/__init__.py b/pyfragment/methods/__init__.py index 719e8d5..98b3326 100644 --- a/pyfragment/methods/__init__.py +++ b/pyfragment/methods/__init__.py @@ -4,6 +4,7 @@ from pyfragment.methods.giveaway_stars import giveaway_stars from pyfragment.methods.purchase_premium import purchase_premium from pyfragment.methods.purchase_stars import purchase_stars from pyfragment.methods.recharge_ads import recharge_ads +from pyfragment.methods.search_auctions import search_auctions from pyfragment.methods.topup_ton import topup_ton __all__ = [ @@ -13,6 +14,7 @@ __all__ = [ "purchase_premium", "purchase_stars", "recharge_ads", + "search_auctions", "terminate_sessions", "toggle_login_codes", "topup_ton", diff --git a/pyfragment/methods/search_auctions.py b/pyfragment/methods/search_auctions.py new file mode 100644 index 0000000..fdcd2cd --- /dev/null +++ b/pyfragment/methods/search_auctions.py @@ -0,0 +1,72 @@ +from typing import TYPE_CHECKING, Any + +import httpx + +from pyfragment.types import FragmentAPIError, FragmentError, UnexpectedError +from pyfragment.types.constants import FRAGMENT_BASE_URL +from pyfragment.types.results import AuctionsResult +from pyfragment.utils import fragment_request, get_fragment_hash, make_headers, parse_auction_rows + +if TYPE_CHECKING: + from pyfragment.client import FragmentClient + +HEADERS: dict[str, str] = make_headers(FRAGMENT_BASE_URL) + + +async def search_auctions( + client: "FragmentClient", + query: str, + type: str | None = None, + sort: str | None = None, + filter: str | None = None, + offset_id: str | None = None, +) -> AuctionsResult: + """Search the Fragment marketplace for usernames, numbers, or collectibles. + + Args: + client: Authenticated :class:`FragmentClient` instance. + query: Search text (e.g. ``"durov"`` or ``"888"``). + type: Narrow results by item type — ``"usernames"``, ``"numbers"``, or + ``"collectibles"``. Omit to search across all types. + sort: Sort order — ``"price_desc"``, ``"price_asc"``, ``"listed"``, or + ``"ending"``. Omit to use Fragment's default ordering. + filter: Filter results — ``"auction"``, ``"sale"``, ``"sold"``, or ``""`` + (available items). Omit to return all. + offset_id: Pagination cursor from a previous :class:`AuctionsResult`. + Pass ``next_offset_id`` to fetch the next page. + + Returns: + :class:`AuctionsResult` with ``items`` (parsed list of item dicts) and + ``next_offset_id`` (``None`` when there are no more pages). + + Raises: + FragmentAPIError: If the Fragment API returns an error. + UnexpectedError: For any other unexpected failure. + """ + data: dict[str, Any] = {"method": "searchAuctions", "query": query} + if type is not None: + data["type"] = type + if sort is not None: + data["sort"] = sort + if filter is not None: + data["filter"] = filter + if offset_id is not None: + data["offset_id"] = offset_id + + try: + fragment_hash = await get_fragment_hash(client.cookies, HEADERS, FRAGMENT_BASE_URL, client.timeout) + async with httpx.AsyncClient(cookies=client.cookies, timeout=client.timeout) as session: + result = await fragment_request(session, fragment_hash, HEADERS, data) + + if result.get("error"): + raise FragmentAPIError(result["error"]) + + items = parse_auction_rows(result.get("html") or "") + raw_noi = result.get("next_offset_id") + next_offset_id = str(raw_noi) if raw_noi else None + return AuctionsResult(items=items, next_offset_id=next_offset_id) + + except FragmentError: + raise + except Exception as exc: + raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc diff --git a/pyfragment/methods/topup_ton.py b/pyfragment/methods/topup_ton.py index cebe7c3..117ea87 100644 --- a/pyfragment/methods/topup_ton.py +++ b/pyfragment/methods/topup_ton.py @@ -71,11 +71,11 @@ async def _init_request( async def topup_ton(client: "FragmentClient", username: str, amount: int, show_sender: bool = True) -> AdsTopupResult: - """Top up a Telegram Ads account balance with TON. + """Topup ton to recipient's Telegram balance. Args: client: Authenticated :class:`FragmentClient` instance. - username: Ads account username (with or without ``@``). + username: Recipient's Telegram username (with or without ``@``). amount: Amount in TON — integer from ``1`` to ``1 000 000 000``. show_sender: Show your name as the sender. Defaults to ``True``. @@ -84,7 +84,7 @@ async def topup_ton(client: "FragmentClient", username: str, amount: int, show_s Raises: ConfigurationError: If ``amount`` is not an integer between 1 and 1 000 000 000. - UserNotFoundError: If the user is not found on Fragment. + UserNotFoundError: If the recipient is not found on Fragment. FragmentAPIError: If the Fragment API returns an error. UnexpectedError: For any other unexpected failure. """ diff --git a/pyfragment/types/__init__.py b/pyfragment/types/__init__.py index 6d77339..5544706 100644 --- a/pyfragment/types/__init__.py +++ b/pyfragment/types/__init__.py @@ -17,6 +17,7 @@ from pyfragment.types.exceptions import ( from pyfragment.types.results import ( AdsRechargeResult, AdsTopupResult, + AuctionsResult, LoginCodeResult, PremiumGiveawayResult, PremiumResult, @@ -46,6 +47,7 @@ __all__ = [ # result types "AdsRechargeResult", "AdsTopupResult", + "AuctionsResult", "LoginCodeResult", "PremiumGiveawayResult", "PremiumResult", diff --git a/pyfragment/types/results.py b/pyfragment/types/results.py index f73d5a7..c8128fd 100644 --- a/pyfragment/types/results.py +++ b/pyfragment/types/results.py @@ -1,4 +1,5 @@ from dataclasses import dataclass +from typing import Any @dataclass @@ -116,9 +117,32 @@ class TerminateSessionsResult: return f"TerminateSessionsResult(number='{self.number}', message={self.message!r})" +@dataclass +class AuctionsResult: + """Result of :meth:`FragmentClient.search_auctions`. + + Each dict in ``items`` has the keys: + + - ``slug`` — URL path (e.g. ``"username/durov"``). + - ``name`` — display value (e.g. ``"@durov"``). + - ``status`` — human-readable Fragment label (e.g. ``"On auction"``, ``"For sale"``). + - ``price`` — price in TON formatted to two decimal places (e.g. ``"7.00"``), or ``None``. + - ``ends_at`` — ISO 8601 auction-end datetime, or ``None``. + + Use ``next_offset_id`` to paginate to the next page of results. + """ + + items: list[dict[str, Any]] + next_offset_id: str | None + + def __repr__(self) -> str: + return f"AuctionsResult(items={len(self.items)}, next_offset_id={self.next_offset_id!r})" + + __all__ = [ "AdsRechargeResult", "AdsTopupResult", + "AuctionsResult", "LoginCodeResult", "PremiumGiveawayResult", "PremiumResult", diff --git a/pyfragment/utils/__init__.py b/pyfragment/utils/__init__.py index 4c092bb..538252b 100644 --- a/pyfragment/utils/__init__.py +++ b/pyfragment/utils/__init__.py @@ -1,5 +1,5 @@ from pyfragment.utils.decoder import clean_decode -from pyfragment.utils.html import parse_login_code +from pyfragment.utils.html import parse_auction_rows, parse_login_code from pyfragment.utils.http import ( execute_transaction_request, fragment_request, @@ -11,6 +11,7 @@ from pyfragment.utils.wallet import get_account_info, process_transaction __all__ = [ "clean_decode", + "parse_auction_rows", "parse_login_code", "execute_transaction_request", "fragment_request", diff --git a/pyfragment/utils/html.py b/pyfragment/utils/html.py index 30004f4..d7e9813 100644 --- a/pyfragment/utils/html.py +++ b/pyfragment/utils/html.py @@ -1,10 +1,21 @@ import re +from typing import Any # Matches the login code inside a table-cell-value element. CODE_RE = re.compile(r'class="[^"]*table-cell-value[^"]*"[^>]*>([^<]+)<') # Counts active session rows in the HTML table. ROW_RE = re.compile(r"]") +# Auction table row parsing +ROW_BLOCK_RE = re.compile(r']*class="[^"]*tm-row-selectable[^"]*"[^>]*>(.*?)', re.DOTALL) +HREF_RE = re.compile(r'href="(/(?:username|number|nft)/([^"]+))"') +VALUE_RE = re.compile(r'class="[^"]*tm-value[^"]*"[^>]*>\s*([^<]+?)\s*<') +PRICE_RE = re.compile(r"icon-before\s+icon-ton[^>]*>\s*([0-9][^<]*?)\s*<") +DATETIME_RE = re.compile(r']+datetime="([^"]+)"[^>]*data-relative="text"[^>]*>') +DATETIME_SHORT_RE = re.compile(r']+datetime="([^"]+)"[^>]*data-relative="short-text"[^>]*>') +# Matches numeric-only values (plain integers, formatted prices like "150,492", phone numbers like "+888 0088 8888") +NUMERIC_RE = re.compile(r"^\+?[\d,. ]+$") + def parse_login_code(html: str) -> tuple[str | None, int]: """Extract the pending login code and active session count from a Fragment numbers page HTML snippet. @@ -21,3 +32,66 @@ def parse_login_code(html: str) -> tuple[str | None, int]: code = match.group(1).strip() if match else None active_sessions = len(ROW_RE.findall(html)) return code, active_sessions + + +def parse_auction_rows(html: str) -> list[dict[str, Any]]: + """Parse Fragment marketplace HTML into structured item dicts. + + Extracts each ```` and returns a list of dicts + with the following keys: + + - ``slug`` — URL path segment (e.g. ``"username/durov"``). + - ``name`` — display value (e.g. ``"@durov"`` or ``"+888..."``) + - ``status`` — human-readable Fragment label (e.g. ``"On auction"``, ``"For sale"``). + - ``price`` — price in TON formatted to two decimal places (e.g. ``"7.00"``), + or ``None`` if not listed. + - ``ends_at`` — ISO 8601 datetime string of auction end, or ``None``. + + Returns: + List of item dicts, one per table row. + """ + items: list[dict[str, Any]] = [] + for row_match in ROW_BLOCK_RE.finditer(html): + row = row_match.group(1) + + href_m = HREF_RE.search(row) + if not href_m: + continue + slug = href_m.group(1).lstrip("/") # e.g. "username/durov" + + # All tm-value spans in the row — first is the display name + values = [m.group(1).strip() for m in VALUE_RE.finditer(row)] + name = values[0] if values else slug + + # Status: find the human-readable label from subsequent tm-value spans. + # Skip usernames (@), numeric-only values (prices like "150,492", phone numbers like "+888 0088 8888"). + status: str | None = None + for v in values[1:]: + if v and v not in ("Unknown",) and not v.startswith("@") and not NUMERIC_RE.match(v): + status = v + break + + # Price — look for icon-ton pattern, format as two decimal places + price_m = PRICE_RE.search(row) + price: str | None = None + if price_m: + raw_price = price_m.group(1).strip().replace(",", "") + try: + price = f"{float(raw_price):.2f}" + except ValueError: + price = raw_price + + # Auction end datetime (ISO 8601) + time_m = DATETIME_RE.search(row) or DATETIME_SHORT_RE.search(row) + ends_at: str | None = time_m.group(1) if time_m else None + + items.append( + { + "slug": slug, + "name": name, + "status": status, + "price": price, + "ends_at": ends_at, + } + ) + return items diff --git a/tests/014_test_search.py b/tests/014_test_search.py new file mode 100644 index 0000000..5e837af --- /dev/null +++ b/tests/014_test_search.py @@ -0,0 +1,124 @@ +"""Unit tests for search_auctions — Fragment marketplace search.""" + +from unittest.mock import AsyncMock, patch + +import pytest + +from pyfragment import FragmentClient +from pyfragment.types import AuctionsResult +from tests.shared import FAKE_HASH + +FAKE_HTML = """ + + +
@coolname
+
On auction
+
+
5
+ + +
On auction
+ +""" + + +@pytest.mark.asyncio +async def test_search_auctions_basic(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.search_auctions.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch( + "pyfragment.methods.search_auctions.fragment_request", + AsyncMock(return_value={"ok": True, "html": FAKE_HTML}), + ), + ): + result = await client.search_auctions("coolname") + + assert isinstance(result, AuctionsResult) + assert len(result.items) == 1 + assert result.items[0]["slug"] == "username/coolname" + assert result.items[0]["name"] == "@coolname" + assert result.next_offset_id is None + + +@pytest.mark.asyncio +async def test_search_auctions_with_type(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.search_auctions.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch( + "pyfragment.methods.search_auctions.fragment_request", + AsyncMock(return_value={"ok": True, "html": FAKE_HTML, "next_offset_id": "offset_99"}), + ), + ): + result = await client.search_auctions("coolname", type="usernames") + + assert isinstance(result, AuctionsResult) + assert len(result.items) == 1 + assert result.next_offset_id == "offset_99" + + +@pytest.mark.asyncio +async def test_search_auctions_numbers(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.search_auctions.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch( + "pyfragment.methods.search_auctions.fragment_request", + AsyncMock(return_value={"ok": True, "html": FAKE_HTML}), + ), + ): + result = await client.search_auctions("888", type="numbers") + + assert isinstance(result, AuctionsResult) + assert isinstance(result.items, list) + + +@pytest.mark.asyncio +async def test_search_auctions_empty_html(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.search_auctions.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch( + "pyfragment.methods.search_auctions.fragment_request", + AsyncMock(return_value={"ok": True}), + ), + ): + result = await client.search_auctions("zzz_no_results") + + assert isinstance(result, AuctionsResult) + assert result.items == [] + assert result.next_offset_id is None + + +@pytest.mark.asyncio +async def test_search_auctions_with_sort_and_filter(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.search_auctions.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch( + "pyfragment.methods.search_auctions.fragment_request", + AsyncMock(return_value={"ok": True, "html": FAKE_HTML}), + ) as mock_request, + ): + result = await client.search_auctions("durov", type="usernames", sort="price_desc", filter="auction") + + assert isinstance(result, AuctionsResult) + call_data = mock_request.call_args[0][3] + assert call_data["sort"] == "price_desc" + assert call_data["filter"] == "auction" + assert call_data["type"] == "usernames" + assert call_data["query"] == "durov" + + +@pytest.mark.asyncio +async def test_search_auctions_with_offset_id(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.search_auctions.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch( + "pyfragment.methods.search_auctions.fragment_request", + AsyncMock(return_value={"ok": True, "html": FAKE_HTML}), + ) as mock_request, + ): + result = await client.search_auctions("durov", type="usernames", offset_id="offset_10") + + assert isinstance(result, AuctionsResult) + call_data = mock_request.call_args[0][3] + assert call_data["offset_id"] == "offset_10" + assert call_data["type"] == "usernames" + assert call_data["query"] == "durov"