From 8163ed1a9d220d71e7a40b32cc2917839c56230b Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Wed, 25 Mar 2026 18:01:22 +0200 Subject: [PATCH] feat: Add gift and number search functionality to FragmentClient - Implemented `search_gifts` method for searching gifts in the Fragment marketplace. - Implemented `search_numbers` method for searching anonymous Telegram numbers. - Added new example scripts for searching gifts, numbers, and usernames. - Updated HTML parsing logic to handle gift items and number listings. - Added unit tests for the new search methods and updated existing tests for usernames and numbers. - Removed obsolete test file for auction searches. --- CHANGELOG.md | 9 +- README.md | 6 +- examples/search_gifts.py | 43 ++++ examples/search_numbers.py | 41 ++++ ...search_auctions.py => search_usernames.py} | 10 +- pyfragment/__init__.py | 8 +- pyfragment/client.py | 85 ++++++-- pyfragment/methods/__init__.py | 8 +- pyfragment/methods/search_gifts.py | 80 ++++++++ pyfragment/methods/search_numbers.py | 67 +++++++ ...search_auctions.py => search_usernames.py} | 25 +-- pyfragment/types/__init__.py | 8 +- pyfragment/types/constants.py | 1 + pyfragment/types/results.py | 56 +++++- pyfragment/utils/__init__.py | 3 +- pyfragment/utils/html.py | 72 ++++++- tests/013_test_usernames.py | 111 +++++++++++ tests/014_test_numbers.py | 111 +++++++++++ tests/014_test_search.py | 124 ------------ tests/015_test_gifts.py | 188 ++++++++++++++++++ 20 files changed, 877 insertions(+), 179 deletions(-) create mode 100644 examples/search_gifts.py create mode 100644 examples/search_numbers.py rename examples/{search_auctions.py => search_usernames.py} (73%) create mode 100644 pyfragment/methods/search_gifts.py create mode 100644 pyfragment/methods/search_numbers.py rename pyfragment/methods/{search_auctions.py => search_usernames.py} (72%) create mode 100644 tests/013_test_usernames.py create mode 100644 tests/014_test_numbers.py delete mode 100644 tests/014_test_search.py create mode 100644 tests/015_test_gifts.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 527c182..d1a0934 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,8 +21,10 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI - `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 +- `search_usernames(query?, sort?, filter?, offset_id?)` — search the Fragment marketplace for Telegram usernames; `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`, `date` +- `search_numbers(query?, sort?, filter?, offset_id?)` — search the Fragment marketplace for anonymous Telegram numbers; same `sort` / `filter` / `offset_id` semantics as `search_usernames` +- `search_gifts(query?, collection?, sort?, filter?, view?, attr?, offset?)` — search the Fragment gifts marketplace; `collection` is the gift collection slug (e.g. `"artisanbrick"`); `attr` accepts trait filters as `{"Model": ["Foosball"], "Backdrop": ["Celtic Blue"]}`; supports integer-based pagination via `offset`; returns parsed item dicts with `slug`, `name`, `status`, `price`, `date` +- `UsernamesResult`, `NumbersResult`, `GiftsResult` result types **Raw API access** - `FragmentClient.call(method, data, *, page_url)` — send a raw request to any Fragment API method without waiting for a library update @@ -41,6 +43,9 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI - `examples/call.py` — raw API call via `client.call()` - `examples/anonymous_number.py` — login code fetch and session termination with `AnonymousNumberError` handling - `examples/recharge_ads.py` — self-service Ads recharge with `ConfigurationError` / `WalletError` handling +- `examples/search_usernames.py` — username marketplace search with pagination +- `examples/search_numbers.py` — number marketplace search with pagination +- `examples/search_gifts.py` — gifts marketplace search with collection, attr, and pagination ### Changed - All result types (`PremiumResult`, `StarsResult`, `StarsGiveawayResult`, `PremiumGiveawayResult`) now use a single unified `amount` field instead of `months`, `stars` — consistent API across every method diff --git a/README.md b/README.md index 490b3a8..adc6204 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@

Fragment API

- Async Python client for the Fragment.com API — buy Telegram Stars and Premium, run giveaways, top up TON Ads balance, and manage anonymous numbers. + Async Python client for the Fragment.com API — buy Telegram Stars and Premium, run giveaways, top up TON, Ads balance, manage anonymous numbers, and search the marketplace for usernames, numbers, and gifts.

[![PyPI version](https://img.shields.io/pypi/v/pyfragment?style=flat&color=blue)](https://pypi.org/project/pyfragment/) @@ -90,11 +90,11 @@ async def main() -> None: try: # Purchase 6 months of Telegram Premium result = await client.purchase_premium("@username", months=6) - print(f"{result.months} months of Premium successfully sent to {result.username} | tx: {result.transaction_id}") + print(f"{result.amount} months of Premium successfully sent to {result.username} | tx: {result.transaction_id}") # Purchase 500 Stars result = await client.purchase_stars("@username", amount=500) - print(f"{result.stars} Stars successfully sent to {result.username} | tx: {result.transaction_id}") + print(f"{result.amount} Stars successfully sent to {result.username} | tx: {result.transaction_id}") # Top up 10 TON to Telegram balance # wallet must hold at least amount + ~0.056 TON for gas diff --git a/examples/search_gifts.py b/examples/search_gifts.py new file mode 100644 index 0000000..a6d63a9 --- /dev/null +++ b/examples/search_gifts.py @@ -0,0 +1,43 @@ +""" +Example: search the Fragment gifts marketplace. + +collection filters by gift type slug (e.g. "plushpepe", "swisswatch"). +sort can be "price_desc", "price_asc", "listed", or "ending". +filter can be "", "auction", "sale", or "sold". +Use next_offset for pagination. +""" + +import asyncio + +from pyfragment import FragmentClient, GiftsResult + +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 = "" # search text — or omit for all +COLLECTION = "plushpepe" # gift collection slug — or omit for all +SORT = "price_desc" # "price_desc", "price_asc", "listed", "ending" — or omit +FILTER = "" # "", "auction", "sale", "sold" — or omit + + +async def main() -> None: + async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client: + result: GiftsResult = await client.search_gifts(QUERY, collection=COLLECTION, 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']:30s} {item['status'] or '':15s} {price:12s} {item['date'] or '—'}") + + if result.next_offset: + print(f"\nMore results available — next page offset: {result.next_offset}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/search_numbers.py b/examples/search_numbers.py new file mode 100644 index 0000000..c24a18e --- /dev/null +++ b/examples/search_numbers.py @@ -0,0 +1,41 @@ +""" +Example: search the Fragment marketplace for anonymous Telegram numbers. + +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 FragmentClient, NumbersResult + +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 = "888" # search term — or omit for all +SORT = "price_asc" # "price_desc", "price_asc", "listed", "ending" — or omit +FILTER = "" # "", "auction", "sale", "sold" — or omit + + +async def main() -> None: + async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client: + result: NumbersResult = await client.search_numbers(QUERY, 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['date'] 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/search_auctions.py b/examples/search_usernames.py similarity index 73% rename from examples/search_auctions.py rename to examples/search_usernames.py index fa026f2..c139785 100644 --- a/examples/search_auctions.py +++ b/examples/search_usernames.py @@ -1,7 +1,6 @@ """ -Example: search the Fragment marketplace for auction listings. +Example: search the Fragment marketplace for Telegram usernames. -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. @@ -9,7 +8,7 @@ Use next_offset_id for pagination. import asyncio -from pyfragment import AuctionsResult, FragmentClient +from pyfragment import FragmentClient, UsernamesResult SEED = "word1 word2 ... word24" API_KEY = "YOUR_TONAPI_KEY" @@ -21,19 +20,18 @@ COOKIES = { } 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) + result: UsernamesResult = await client.search_usernames(QUERY, 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 '—'}") + print(f" {item['name']:20s} {item['status'] or '':15s} {price:10s} {item['date'] or '—'}") if result.next_offset_id: print(f"\nMore results available — next page offset: {result.next_offset_id}") diff --git a/pyfragment/__init__.py b/pyfragment/__init__.py index e3eec8c..495897d 100644 --- a/pyfragment/__init__.py +++ b/pyfragment/__init__.py @@ -10,14 +10,15 @@ from pyfragment.types import ( AdsRechargeResult, AdsTopupResult, AnonymousNumberError, - AuctionsResult, ClientError, ConfigurationError, CookieError, FragmentAPIError, FragmentError, FragmentPageError, + GiftsResult, LoginCodeResult, + NumbersResult, OperationError, ParseError, PremiumGiveawayResult, @@ -27,6 +28,7 @@ from pyfragment.types import ( TerminateSessionsResult, TransactionError, UnexpectedError, + UsernamesResult, UserNotFoundError, VerificationError, WalletError, @@ -40,13 +42,15 @@ __all__ = [ "FragmentClient", "AdsRechargeResult", "AdsTopupResult", - "AuctionsResult", + "GiftsResult", "LoginCodeResult", + "NumbersResult", "PremiumGiveawayResult", "PremiumResult", "StarsGiveawayResult", "StarsResult", "TerminateSessionsResult", + "UsernamesResult", "WalletInfo", "ClientError", "ConfigurationError", diff --git a/pyfragment/client.py b/pyfragment/client.py index 3d0d4db..08bd9ab 100644 --- a/pyfragment/client.py +++ b/pyfragment/client.py @@ -9,18 +9,22 @@ 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.search_gifts import search_gifts +from pyfragment.methods.search_numbers import search_numbers +from pyfragment.methods.search_usernames import search_usernames from pyfragment.methods.topup_ton import topup_ton from pyfragment.types import ( AdsRechargeResult, AdsTopupResult, - AuctionsResult, ConfigurationError, CookieError, + GiftsResult, LoginCodeResult, + NumbersResult, PremiumResult, StarsResult, TerminateSessionsResult, + UsernamesResult, WalletInfo, ) from pyfragment.types.constants import ( @@ -252,32 +256,87 @@ class FragmentClient: """ return await terminate_sessions(self, number) - async def search_auctions( + async def search_usernames( self, - query: str, - type: str | None = None, + query: str = "", sort: str | None = None, filter: str | None = None, offset_id: str | None = None, - ) -> AuctionsResult: - """Search the Fragment marketplace for auction listings. + ) -> UsernamesResult: + """Search the Fragment marketplace for Telegram usernames. 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. + query: Search text (e.g. ``"durov"``). Omit or pass ``""`` to browse all. 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` + offset_id: Pagination cursor — pass :attr:`UsernamesResult.next_offset_id` from a previous result to fetch the next page. Returns: - :class:`AuctionsResult` with ``items`` (parsed list of item dicts) + :class:`UsernamesResult` 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) + return await search_usernames(self, query, sort=sort, filter=filter, offset_id=offset_id) + + async def search_numbers( + self, + query: str = "", + sort: str | None = None, + filter: str | None = None, + offset_id: str | None = None, + ) -> NumbersResult: + """Search the Fragment marketplace for anonymous Telegram numbers. + + Args: + query: Search text (e.g. ``"888"``). Omit or pass ``""`` to browse all. + 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:`NumbersResult.next_offset_id` + from a previous result to fetch the next page. + + Returns: + :class:`NumbersResult` with ``items`` (parsed list of item dicts) + and ``next_offset_id`` (``None`` on the last page). + """ + return await search_numbers(self, query, sort=sort, filter=filter, offset_id=offset_id) + + async def search_gifts( + self, + query: str = "", + collection: str | None = None, + sort: str | None = None, + filter: str | None = None, + view: str | None = None, + attr: dict[str, list[str]] | None = None, + offset: int | None = None, + ) -> GiftsResult: + """Search the Fragment gifts marketplace. + + Args: + query: Search text. Omit or pass ``""`` to browse without filtering by name. + collection: Filter by gift collection slug (e.g. ``"artisanbrick"``). Omit for all. + 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. + view: Active attribute tab name (e.g. ``"Model"``, ``"Backdrop"``). Omit for default. + attr: Attribute filters — mapping of trait name to accepted values, e.g. + ``{"Model": ["Foosball"], "Backdrop": ["Celtic Blue", "Orange"]}``. + Each key is sent as ``attr[Key]`` with its list of values. + offset: Integer page offset from a previous :class:`GiftsResult`. + Pass ``next_offset`` to fetch the next page. + + Returns: + :class:`GiftsResult` with ``items`` (parsed list of item dicts) + and ``next_offset`` (``None`` on the last page). + """ + return await search_gifts( + self, query, collection=collection, sort=sort, filter=filter, view=view, attr=attr, offset=offset + ) async def call( self, method: str, data: dict[str, Any] | None = None, *, page_url: str = FRAGMENT_BASE_URL diff --git a/pyfragment/methods/__init__.py b/pyfragment/methods/__init__.py index 98b3326..9714024 100644 --- a/pyfragment/methods/__init__.py +++ b/pyfragment/methods/__init__.py @@ -4,7 +4,9 @@ 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.search_gifts import search_gifts +from pyfragment.methods.search_numbers import search_numbers +from pyfragment.methods.search_usernames import search_usernames from pyfragment.methods.topup_ton import topup_ton __all__ = [ @@ -14,7 +16,9 @@ __all__ = [ "purchase_premium", "purchase_stars", "recharge_ads", - "search_auctions", + "search_gifts", + "search_numbers", + "search_usernames", "terminate_sessions", "toggle_login_codes", "topup_ton", diff --git a/pyfragment/methods/search_gifts.py b/pyfragment/methods/search_gifts.py new file mode 100644 index 0000000..d1276b4 --- /dev/null +++ b/pyfragment/methods/search_gifts.py @@ -0,0 +1,80 @@ +from typing import TYPE_CHECKING, Any + +import httpx + +from pyfragment.types import FragmentAPIError, FragmentError, UnexpectedError +from pyfragment.types.constants import GIFTS_PAGE +from pyfragment.types.results import GiftsResult +from pyfragment.utils import fragment_request, get_fragment_hash, make_headers, parse_gift_items + +if TYPE_CHECKING: + from pyfragment.client import FragmentClient + +HEADERS: dict[str, str] = make_headers(GIFTS_PAGE) + + +async def search_gifts( + client: "FragmentClient", + query: str = "", + collection: str | None = None, + sort: str | None = None, + filter: str | None = None, + view: str | None = None, + attr: dict[str, list[str]] | None = None, + offset: int | None = None, +) -> GiftsResult: + """Search the Fragment gifts marketplace. + + Args: + client: Authenticated :class:`FragmentClient` instance. + query: Search text. Omit or pass ``""`` to browse without filtering by name. + collection: Filter by gift collection slug (e.g. ``"artisanbrick"``). Omit for all. + 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. + view: Active attribute tab name (e.g. ``"Model"``, ``"Backdrop"``). Omit for default. + attr: Attribute filters as a mapping of trait name to list of accepted values, e.g. + ``{"Model": ["Foosball"], "Backdrop": ["Celtic Blue", "Orange"]}``. + Each key is sent as ``attr[Key]`` with its list of values. + offset: Integer page offset from a previous :class:`GiftsResult`. + Pass ``next_offset`` to fetch the next page. + + Returns: + :class:`GiftsResult` with ``items`` (parsed list of item dicts) and + ``next_offset`` (``None`` on the last page). + + Raises: + FragmentAPIError: If the Fragment API returns an error. + UnexpectedError: For any other unexpected failure. + """ + data: dict[str, Any] = {"method": "searchAuctions", "type": "gifts", "query": query} + if collection is not None: + data["collection"] = collection + if sort is not None: + data["sort"] = sort + if filter is not None: + data["filter"] = filter + if view is not None: + data["view"] = view + if attr is not None: + for trait, values in attr.items(): + data[f"attr[{trait}]"] = values + if offset is not None: + data["offset"] = offset + + try: + fragment_hash = await get_fragment_hash(client.cookies, HEADERS, GIFTS_PAGE, 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, next_offset = parse_gift_items(result.get("html") or "") + return GiftsResult(items=items, next_offset=next_offset) + + except FragmentError: + raise + except Exception as exc: + raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc diff --git a/pyfragment/methods/search_numbers.py b/pyfragment/methods/search_numbers.py new file mode 100644 index 0000000..e6f459a --- /dev/null +++ b/pyfragment/methods/search_numbers.py @@ -0,0 +1,67 @@ +from typing import TYPE_CHECKING, Any + +import httpx + +from pyfragment.types import FragmentAPIError, FragmentError, UnexpectedError +from pyfragment.types.constants import NUMBERS_PAGE +from pyfragment.types.results import NumbersResult +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(NUMBERS_PAGE) + + +async def search_numbers( + client: "FragmentClient", + query: str = "", + sort: str | None = None, + filter: str | None = None, + offset_id: str | None = None, +) -> NumbersResult: + """Search the Fragment marketplace for anonymous Telegram numbers. + + Args: + client: Authenticated :class:`FragmentClient` instance. + query: Search text (e.g. ``"888"``). Omit or pass ``""`` to browse all. + 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:`NumbersResult`. + Pass ``next_offset_id`` to fetch the next page. + + Returns: + :class:`NumbersResult` 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", "type": "numbers", "query": query} + 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, NUMBERS_PAGE, 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 NumbersResult(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/search_auctions.py b/pyfragment/methods/search_usernames.py similarity index 72% rename from pyfragment/methods/search_auctions.py rename to pyfragment/methods/search_usernames.py index fdcd2cd..23af084 100644 --- a/pyfragment/methods/search_auctions.py +++ b/pyfragment/methods/search_usernames.py @@ -4,7 +4,7 @@ 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.types.results import UsernamesResult from pyfragment.utils import fragment_request, get_fragment_hash, make_headers, parse_auction_rows if TYPE_CHECKING: @@ -13,39 +13,34 @@ if TYPE_CHECKING: HEADERS: dict[str, str] = make_headers(FRAGMENT_BASE_URL) -async def search_auctions( +async def search_usernames( client: "FragmentClient", - query: str, - type: str | None = None, + query: str = "", sort: str | None = None, filter: str | None = None, offset_id: str | None = None, -) -> AuctionsResult: - """Search the Fragment marketplace for usernames, numbers, or collectibles. +) -> UsernamesResult: + """Search the Fragment marketplace for Telegram usernames. 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. + query: Search text (e.g. ``"durov"``). Omit or pass ``""`` to browse all. 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`. + offset_id: Pagination cursor from a previous :class:`UsernamesResult`. Pass ``next_offset_id`` to fetch the next page. Returns: - :class:`AuctionsResult` with ``items`` (parsed list of item dicts) and + :class:`UsernamesResult` 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 + data: dict[str, Any] = {"method": "searchAuctions", "type": "usernames", "query": query} if sort is not None: data["sort"] = sort if filter is not None: @@ -64,7 +59,7 @@ async def search_auctions( 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) + return UsernamesResult(items=items, next_offset_id=next_offset_id) except FragmentError: raise diff --git a/pyfragment/types/__init__.py b/pyfragment/types/__init__.py index 5544706..0563be0 100644 --- a/pyfragment/types/__init__.py +++ b/pyfragment/types/__init__.py @@ -17,13 +17,15 @@ from pyfragment.types.exceptions import ( from pyfragment.types.results import ( AdsRechargeResult, AdsTopupResult, - AuctionsResult, + GiftsResult, LoginCodeResult, + NumbersResult, PremiumGiveawayResult, PremiumResult, StarsGiveawayResult, StarsResult, TerminateSessionsResult, + UsernamesResult, WalletInfo, ) @@ -47,12 +49,14 @@ __all__ = [ # result types "AdsRechargeResult", "AdsTopupResult", - "AuctionsResult", + "GiftsResult", "LoginCodeResult", + "NumbersResult", "PremiumGiveawayResult", "PremiumResult", "StarsGiveawayResult", "StarsResult", "TerminateSessionsResult", + "UsernamesResult", "WalletInfo", ] diff --git a/pyfragment/types/constants.py b/pyfragment/types/constants.py index 29896d3..fee850a 100644 --- a/pyfragment/types/constants.py +++ b/pyfragment/types/constants.py @@ -27,6 +27,7 @@ PREMIUM_PAGE: str = f"{FRAGMENT_BASE_URL}/premium/gift" PREMIUM_GIVEAWAY_PAGE: str = f"{FRAGMENT_BASE_URL}/premium/giveaway" ADS_TOPUP_PAGE: str = f"{FRAGMENT_BASE_URL}/ads/topup" NUMBERS_PAGE: str = f"{FRAGMENT_BASE_URL}/numbers" +GIFTS_PAGE: str = f"{FRAGMENT_BASE_URL}/gifts" # Tonkeeper device fingerprint — serialized once, reused in every tx_data payload. DEVICE: str = json.dumps( diff --git a/pyfragment/types/results.py b/pyfragment/types/results.py index c8128fd..89eb403 100644 --- a/pyfragment/types/results.py +++ b/pyfragment/types/results.py @@ -118,8 +118,8 @@ class TerminateSessionsResult: @dataclass -class AuctionsResult: - """Result of :meth:`FragmentClient.search_auctions`. +class UsernamesResult: + """Result of :meth:`FragmentClient.search_usernames`. Each dict in ``items`` has the keys: @@ -127,7 +127,7 @@ class AuctionsResult: - ``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``. + - ``date`` — ISO 8601 datetime: auction end date, sale date, or listing date, or ``None``. Use ``next_offset_id`` to paginate to the next page of results. """ @@ -136,18 +136,64 @@ class AuctionsResult: next_offset_id: str | None def __repr__(self) -> str: - return f"AuctionsResult(items={len(self.items)}, next_offset_id={self.next_offset_id!r})" + return f"UsernamesResult(items={len(self.items)}, next_offset_id={self.next_offset_id!r})" + + +@dataclass +class NumbersResult: + """Result of :meth:`FragmentClient.search_numbers`. + + Each dict in ``items`` has the keys: + + - ``slug`` — URL path (e.g. ``"number/8880000111"``). + - ``name`` — display value (e.g. ``"+888 0000 111"``). + - ``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``. + - ``date`` — ISO 8601 datetime: auction end date, sale date, or listing date, 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"NumbersResult(items={len(self.items)}, next_offset_id={self.next_offset_id!r})" + + +@dataclass +class GiftsResult: + """Result of :meth:`FragmentClient.search_gifts`. + + Each dict in ``items`` has the keys: + + - ``slug`` — URL path (e.g. ``"gift/plushpepe-1821"``). + - ``name`` — display name with number (e.g. ``"Plush Pepe #1821"``). + - ``status`` — human-readable Fragment label (e.g. ``"Sold"``, ``"For sale"``). + - ``price`` — price in TON formatted to two decimal places (e.g. ``"88888.00"``), or ``None``. + - ``date`` — ISO 8601 datetime of the sale/listing, or ``None``. + + Use ``next_offset`` to paginate to the next page of results. + """ + + items: list[dict[str, Any]] + next_offset: int | None + + def __repr__(self) -> str: + return f"GiftsResult(items={len(self.items)}, next_offset={self.next_offset!r})" __all__ = [ "AdsRechargeResult", "AdsTopupResult", - "AuctionsResult", + "GiftsResult", "LoginCodeResult", + "NumbersResult", "PremiumGiveawayResult", "PremiumResult", "StarsGiveawayResult", "StarsResult", "TerminateSessionsResult", + "UsernamesResult", "WalletInfo", ] diff --git a/pyfragment/utils/__init__.py b/pyfragment/utils/__init__.py index 538252b..a52cf8b 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_auction_rows, parse_login_code +from pyfragment.utils.html import parse_auction_rows, parse_gift_items, parse_login_code from pyfragment.utils.http import ( execute_transaction_request, fragment_request, @@ -12,6 +12,7 @@ from pyfragment.utils.wallet import get_account_info, process_transaction __all__ = [ "clean_decode", "parse_auction_rows", + "parse_gift_items", "parse_login_code", "execute_transaction_request", "fragment_request", diff --git a/pyfragment/utils/html.py b/pyfragment/utils/html.py index d7e9813..f3a82e5 100644 --- a/pyfragment/utils/html.py +++ b/pyfragment/utils/html.py @@ -16,6 +16,15 @@ DATETIME_SHORT_RE = re.compile(r']+datetime="([^"]+)"[^>]*data-relative= # Matches numeric-only values (plain integers, formatted prices like "150,492", phone numbers like "+888 0088 8888") NUMERIC_RE = re.compile(r"^\+?[\d,. ]+$") +# Gift grid item parsing +GRID_ITEM_RE = re.compile(r']*class="[^"]*tm-grid-item[^"]*"[^>]*>(.*?)', re.DOTALL) +GRID_HREF_RE = re.compile(r'href="(/gift/([^?"]+))') +GRID_NAME_RE = re.compile(r'class="item-name">([^<]+)<') +GRID_NUM_RE = re.compile(r'class="item-num">[^#]*#(\w+)<') +GRID_PRICE_RE = re.compile(r'class="[^"]*tm-grid-item-value[^"]*icon-ton[^"]*"[^>]*>\s*([0-9][^<]*?)\s*<') +GRID_STATUS_RE = re.compile(r'class="[^"]*tm-grid-item-status[^"]*"[^>]*>\s*([^<]+?)\s*<') +GRID_DATETIME_RE = re.compile(r']+datetime="([^"]+)"') + 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. @@ -45,7 +54,7 @@ def parse_auction_rows(html: str) -> list[dict[str, Any]]: - ``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``. + - ``date`` — ISO 8601 datetime string: auction end date, sale date, or listing date, or ``None``. Returns: List of item dicts, one per table row. @@ -81,9 +90,9 @@ def parse_auction_rows(html: str) -> list[dict[str, Any]]: except ValueError: price = raw_price - # Auction end datetime (ISO 8601) + # Datetime (ISO 8601) — auction end, sale date, or listing date. 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 + date: str | None = time_m.group(1) if time_m else None items.append( { @@ -91,7 +100,62 @@ def parse_auction_rows(html: str) -> list[dict[str, Any]]: "name": name, "status": status, "price": price, - "ends_at": ends_at, + "date": date, } ) return items + + +def parse_gift_items(html: str) -> tuple[list[dict[str, Any]], int | None]: + """Parse Fragment gifts grid HTML into structured item dicts. + + Extracts each ```` block and returns a list of dicts + with the following keys: + + - ``slug`` — URL path segment (e.g. ``"gift/plushpepe-1821"``). + - ``name`` — display name with number (e.g. ``"Plush Pepe #1821"``). + - ``status`` — human-readable Fragment label (e.g. ``"Sold"``, ``"For sale"``). + - ``price`` — price in TON formatted to two decimal places, or ``None``. + - ``date`` — ISO 8601 datetime of the sale/listing, or ``None``. + + Returns: + Tuple of ``(items, next_offset)`` where ``next_offset`` is an integer + page offset from ``data-next-offset``, or ``None`` on the last page. + """ + items: list[dict[str, Any]] = [] + for item_match in GRID_ITEM_RE.finditer(html): + block = item_match.group(0) + + href_m = GRID_HREF_RE.search(block) + if not href_m: + continue + slug = href_m.group(1).lstrip("/") # e.g. "gift/plushpepe-1821" + + name_m = GRID_NAME_RE.search(block) + num_m = GRID_NUM_RE.search(block) + item_name = name_m.group(1).strip() if name_m else slug + item_num = f" #{num_m.group(1)}" if num_m else "" + name = f"{item_name}{item_num}" + + status_m = GRID_STATUS_RE.search(block) + status: str | None = status_m.group(1).strip() if status_m else None + + price_m = GRID_PRICE_RE.search(block) + 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 + + time_m = GRID_DATETIME_RE.search(block) + date: str | None = time_m.group(1) if time_m else None + + items.append({"slug": slug, "name": name, "status": status, "price": price, "date": date}) + + # Pagination offset from data-next-offset attribute + next_offset_m = re.search(r'data-next-offset="(\d+)"', html) + next_offset = int(next_offset_m.group(1)) if next_offset_m else None + + return items, next_offset diff --git a/tests/013_test_usernames.py b/tests/013_test_usernames.py new file mode 100644 index 0000000..7f482ff --- /dev/null +++ b/tests/013_test_usernames.py @@ -0,0 +1,111 @@ +"""Unit tests for search_usernames — Fragment marketplace username search.""" + +from unittest.mock import AsyncMock, patch + +import pytest + +from pyfragment import FragmentClient +from pyfragment.types import UsernamesResult +from tests.shared import FAKE_HASH + +FAKE_HTML = """ + + +
@coolname
+
On auction
+
+
5
+ + +
On auction
+ +""" + + +@pytest.mark.asyncio +async def test_search_usernames_basic(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.search_usernames.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch( + "pyfragment.methods.search_usernames.fragment_request", + AsyncMock(return_value={"ok": True, "html": FAKE_HTML}), + ), + ): + result = await client.search_usernames("coolname") + + assert isinstance(result, UsernamesResult) + assert len(result.items) == 1 + assert result.items[0]["slug"] == "username/coolname" + assert result.items[0]["name"] == "@coolname" + assert result.items[0]["date"] == "2026-06-01T12:00:00+00:00" + assert result.next_offset_id is None + + +@pytest.mark.asyncio +async def test_search_usernames_empty_html(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.search_usernames.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch( + "pyfragment.methods.search_usernames.fragment_request", + AsyncMock(return_value={"ok": True}), + ), + ): + result = await client.search_usernames("zzz_no_results") + + assert isinstance(result, UsernamesResult) + assert result.items == [] + assert result.next_offset_id is None + + +@pytest.mark.asyncio +async def test_search_usernames_with_sort_and_filter(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.search_usernames.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch( + "pyfragment.methods.search_usernames.fragment_request", + AsyncMock(return_value={"ok": True, "html": FAKE_HTML}), + ) as mock_request, + ): + result = await client.search_usernames("durov", sort="price_desc", filter="auction") + + assert isinstance(result, UsernamesResult) + call_data = mock_request.call_args[0][3] + assert call_data["type"] == "usernames" + assert call_data["method"] == "searchAuctions" + assert call_data["sort"] == "price_desc" + assert call_data["filter"] == "auction" + assert call_data["query"] == "durov" + + +@pytest.mark.asyncio +async def test_search_usernames_with_offset_id(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.search_usernames.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch( + "pyfragment.methods.search_usernames.fragment_request", + AsyncMock(return_value={"ok": True, "html": FAKE_HTML, "next_offset_id": "offset_99"}), + ) as mock_request, + ): + result = await client.search_usernames("durov", offset_id="offset_10") + + assert isinstance(result, UsernamesResult) + assert result.next_offset_id == "offset_99" + call_data = mock_request.call_args[0][3] + assert call_data["offset_id"] == "offset_10" + + +@pytest.mark.asyncio +async def test_search_usernames_default_query(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.search_usernames.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch( + "pyfragment.methods.search_usernames.fragment_request", + AsyncMock(return_value={"ok": True, "html": FAKE_HTML}), + ) as mock_request, + ): + result = await client.search_usernames() + + assert isinstance(result, UsernamesResult) + call_data = mock_request.call_args[0][3] + assert call_data["query"] == "" + assert call_data["type"] == "usernames" diff --git a/tests/014_test_numbers.py b/tests/014_test_numbers.py new file mode 100644 index 0000000..62d6fd5 --- /dev/null +++ b/tests/014_test_numbers.py @@ -0,0 +1,111 @@ +"""Unit tests for search_numbers — Fragment marketplace number search.""" + +from unittest.mock import AsyncMock, patch + +import pytest + +from pyfragment import FragmentClient +from pyfragment.types import NumbersResult +from tests.shared import FAKE_HASH + +FAKE_HTML = """ + + +
+888 0000 888
+
For sale
+
+
150
+ + +
For sale
+ +""" + + +@pytest.mark.asyncio +async def test_search_numbers_basic(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.search_numbers.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch( + "pyfragment.methods.search_numbers.fragment_request", + AsyncMock(return_value={"ok": True, "html": FAKE_HTML}), + ), + ): + result = await client.search_numbers("888") + + assert isinstance(result, NumbersResult) + assert len(result.items) == 1 + assert result.items[0]["slug"] == "number/8880000888" + assert result.items[0]["name"] == "+888 0000 888" + assert result.items[0]["date"] == "2026-05-15T10:00:00+00:00" + assert result.next_offset_id is None + + +@pytest.mark.asyncio +async def test_search_numbers_empty_html(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.search_numbers.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch( + "pyfragment.methods.search_numbers.fragment_request", + AsyncMock(return_value={"ok": True}), + ), + ): + result = await client.search_numbers("zzz_no_results") + + assert isinstance(result, NumbersResult) + assert result.items == [] + assert result.next_offset_id is None + + +@pytest.mark.asyncio +async def test_search_numbers_with_sort_and_filter(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.search_numbers.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch( + "pyfragment.methods.search_numbers.fragment_request", + AsyncMock(return_value={"ok": True, "html": FAKE_HTML}), + ) as mock_request, + ): + result = await client.search_numbers("888", sort="price_asc", filter="sale") + + assert isinstance(result, NumbersResult) + call_data = mock_request.call_args[0][3] + assert call_data["type"] == "numbers" + assert call_data["method"] == "searchAuctions" + assert call_data["sort"] == "price_asc" + assert call_data["filter"] == "sale" + assert call_data["query"] == "888" + + +@pytest.mark.asyncio +async def test_search_numbers_with_offset_id(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.search_numbers.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch( + "pyfragment.methods.search_numbers.fragment_request", + AsyncMock(return_value={"ok": True, "html": FAKE_HTML, "next_offset_id": "offset_50"}), + ) as mock_request, + ): + result = await client.search_numbers("888", offset_id="offset_50") + + assert isinstance(result, NumbersResult) + assert result.next_offset_id == "offset_50" + call_data = mock_request.call_args[0][3] + assert call_data["offset_id"] == "offset_50" + + +@pytest.mark.asyncio +async def test_search_numbers_default_query(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.search_numbers.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch( + "pyfragment.methods.search_numbers.fragment_request", + AsyncMock(return_value={"ok": True, "html": FAKE_HTML}), + ) as mock_request, + ): + result = await client.search_numbers() + + assert isinstance(result, NumbersResult) + call_data = mock_request.call_args[0][3] + assert call_data["query"] == "" + assert call_data["type"] == "numbers" diff --git a/tests/014_test_search.py b/tests/014_test_search.py deleted file mode 100644 index 5e837af..0000000 --- a/tests/014_test_search.py +++ /dev/null @@ -1,124 +0,0 @@ -"""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" diff --git a/tests/015_test_gifts.py b/tests/015_test_gifts.py new file mode 100644 index 0000000..475fb00 --- /dev/null +++ b/tests/015_test_gifts.py @@ -0,0 +1,188 @@ +"""Unit tests for search_gifts — Fragment gifts marketplace search.""" + +from unittest.mock import AsyncMock, patch + +import pytest + +from pyfragment import FragmentClient +from pyfragment.types import GiftsResult +from tests.shared import FAKE_HASH + +FAKE_GIFTS_HTML = """ + +""" + + +@pytest.mark.asyncio +async def test_search_gifts_basic(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.search_gifts.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch( + "pyfragment.methods.search_gifts.fragment_request", + AsyncMock(return_value={"ok": True, "html": FAKE_GIFTS_HTML}), + ), + ): + result = await client.search_gifts() + + assert isinstance(result, GiftsResult) + assert len(result.items) == 2 + assert result.items[0]["slug"] == "gift/plushpepe-1821" + assert result.items[0]["name"] == "Plush Pepe #1821" + assert result.items[0]["status"] == "Sold" + assert result.items[0]["price"] == "88888.00" + assert result.items[0]["date"] == "2026-02-05T14:41:27+00:00" + assert result.items[1]["slug"] == "gift/swisswatch-7799" + assert result.items[1]["price"] == "13588.00" + assert result.next_offset == 60 + + +@pytest.mark.asyncio +async def test_search_gifts_empty(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.search_gifts.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch( + "pyfragment.methods.search_gifts.fragment_request", + AsyncMock(return_value={"ok": True}), + ), + ): + result = await client.search_gifts(query="zzz_no_results") + + assert isinstance(result, GiftsResult) + assert result.items == [] + assert result.next_offset is None + + +@pytest.mark.asyncio +async def test_search_gifts_with_collection_and_sort(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.search_gifts.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch( + "pyfragment.methods.search_gifts.fragment_request", + AsyncMock(return_value={"ok": True, "html": FAKE_GIFTS_HTML}), + ) as mock_request, + ): + result = await client.search_gifts(collection="plushpepe", sort="price_desc", filter="sold") + + assert isinstance(result, GiftsResult) + call_data = mock_request.call_args[0][3] + assert call_data["collection"] == "plushpepe" + assert call_data["sort"] == "price_desc" + assert call_data["filter"] == "sold" + assert call_data["type"] == "gifts" + assert call_data["method"] == "searchAuctions" + + +@pytest.mark.asyncio +async def test_search_gifts_with_offset(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.search_gifts.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch( + "pyfragment.methods.search_gifts.fragment_request", + AsyncMock(return_value={"ok": True, "html": FAKE_GIFTS_HTML}), + ) as mock_request, + ): + result = await client.search_gifts(offset=60) + + assert isinstance(result, GiftsResult) + call_data = mock_request.call_args[0][3] + assert call_data["offset"] == 60 + + +@pytest.mark.asyncio +async def test_search_gifts_with_view(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.search_gifts.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch( + "pyfragment.methods.search_gifts.fragment_request", + AsyncMock(return_value={"ok": True, "html": FAKE_GIFTS_HTML}), + ) as mock_request, + ): + result = await client.search_gifts(collection="artisanbrick", view="Model") + + assert isinstance(result, GiftsResult) + call_data = mock_request.call_args[0][3] + assert call_data["view"] == "Model" + assert call_data["collection"] == "artisanbrick" + + +@pytest.mark.asyncio +async def test_search_gifts_with_attr(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.search_gifts.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch( + "pyfragment.methods.search_gifts.fragment_request", + AsyncMock(return_value={"ok": True, "html": FAKE_GIFTS_HTML}), + ) as mock_request, + ): + result = await client.search_gifts( + collection="artisanbrick", + sort="listed", + filter="auction", + attr={ + "Model": ["Delicate Wash", "Foosball", "Chocolate"], + "Backdrop": ["Celtic Blue", "Carrot Juice", "Orange"], + "Symbol": ["Crystal Ball", "Tetsubin", "Acorn"], + }, + ) + + assert isinstance(result, GiftsResult) + call_data = mock_request.call_args[0][3] + assert call_data["attr[Model]"] == ["Delicate Wash", "Foosball", "Chocolate"] + assert call_data["attr[Backdrop]"] == ["Celtic Blue", "Carrot Juice", "Orange"] + assert call_data["attr[Symbol]"] == ["Crystal Ball", "Tetsubin", "Acorn"] + assert call_data["collection"] == "artisanbrick" + assert call_data["sort"] == "listed" + assert call_data["filter"] == "auction" + assert call_data["type"] == "gifts" + + +@pytest.mark.asyncio +async def test_search_gifts_attr_not_in_data_when_none(client: FragmentClient) -> None: + with ( + patch("pyfragment.methods.search_gifts.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch( + "pyfragment.methods.search_gifts.fragment_request", + AsyncMock(return_value={"ok": True, "html": FAKE_GIFTS_HTML}), + ) as mock_request, + ): + result = await client.search_gifts() + + assert isinstance(result, GiftsResult) + call_data = mock_request.call_args[0][3] + assert "view" not in call_data + assert not any(k.startswith("attr[") for k in call_data)