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.
This commit is contained in:
bohd4nx
2026-03-25 18:01:22 +02:00
parent f37a4ab985
commit 8163ed1a9d
20 changed files with 877 additions and 179 deletions
+7 -2
View File
@@ -21,8 +21,10 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI
- `AdsRechargeResult` result type - `AdsRechargeResult` result type
**Marketplace** **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` - `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`
- `AuctionsResult` result type - `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** **Raw API access**
- `FragmentClient.call(method, data, *, page_url)` — send a raw request to any Fragment API method without waiting for a library update - `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/call.py` — raw API call via `client.call()`
- `examples/anonymous_number.py` — login code fetch and session termination with `AnonymousNumberError` handling - `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/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 ### Changed
- All result types (`PremiumResult`, `StarsResult`, `StarsGiveawayResult`, `PremiumGiveawayResult`) now use a single unified `amount` field instead of `months`, `stars` — consistent API across every method - All result types (`PremiumResult`, `StarsResult`, `StarsGiveawayResult`, `PremiumGiveawayResult`) now use a single unified `amount` field instead of `months`, `stars` — consistent API across every method
+3 -3
View File
@@ -4,7 +4,7 @@
<h1 style="margin-top: 24px;">Fragment API</h1> <h1 style="margin-top: 24px;">Fragment API</h1>
<p style="font-size: 18px; margin-bottom: 24px;"> <p style="font-size: 18px; margin-bottom: 24px;">
<b>Async Python client for the Fragment.com API — buy Telegram Stars and Premium, run giveaways, top up TON Ads balance, and manage anonymous numbers.</b> <b>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.</b>
</p> </p>
[![PyPI version](https://img.shields.io/pypi/v/pyfragment?style=flat&color=blue)](https://pypi.org/project/pyfragment/) [![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: try:
# Purchase 6 months of Telegram Premium # Purchase 6 months of Telegram Premium
result = await client.purchase_premium("@username", months=6) 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 # Purchase 500 Stars
result = await client.purchase_stars("@username", amount=500) 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 # Top up 10 TON to Telegram balance
# wallet must hold at least amount + ~0.056 TON for gas # wallet must hold at least amount + ~0.056 TON for gas
+43
View File
@@ -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())
+41
View File
@@ -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())
@@ -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". sort can be "price_desc", "price_asc", "listed", or "ending".
filter can be "", "auction", "sale", or "sold". filter can be "", "auction", "sale", or "sold".
Use next_offset_id for pagination. Use next_offset_id for pagination.
@@ -9,7 +8,7 @@ Use next_offset_id for pagination.
import asyncio import asyncio
from pyfragment import AuctionsResult, FragmentClient from pyfragment import FragmentClient, UsernamesResult
SEED = "word1 word2 ... word24" SEED = "word1 word2 ... word24"
API_KEY = "YOUR_TONAPI_KEY" API_KEY = "YOUR_TONAPI_KEY"
@@ -21,19 +20,18 @@ COOKIES = {
} }
QUERY = "durov" # search term QUERY = "durov" # search term
TYPE = "usernames" # "usernames", "numbers", or "collectibles" — or omit
SORT = "price_desc" # "price_desc", "price_asc", "listed", "ending" — or omit SORT = "price_desc" # "price_desc", "price_asc", "listed", "ending" — or omit
FILTER = "auction" # "", "auction", "sale", "sold" — or omit FILTER = "auction" # "", "auction", "sale", "sold" — or omit
async def main() -> None: async def main() -> None:
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client: 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):") print(f"Found {len(result.items)} result(s):")
for item in result.items: for item in result.items:
price = f"{item['price']} TON" if item["price"] else "n/a" 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: if result.next_offset_id:
print(f"\nMore results available — next page offset: {result.next_offset_id}") print(f"\nMore results available — next page offset: {result.next_offset_id}")
+6 -2
View File
@@ -10,14 +10,15 @@ from pyfragment.types import (
AdsRechargeResult, AdsRechargeResult,
AdsTopupResult, AdsTopupResult,
AnonymousNumberError, AnonymousNumberError,
AuctionsResult,
ClientError, ClientError,
ConfigurationError, ConfigurationError,
CookieError, CookieError,
FragmentAPIError, FragmentAPIError,
FragmentError, FragmentError,
FragmentPageError, FragmentPageError,
GiftsResult,
LoginCodeResult, LoginCodeResult,
NumbersResult,
OperationError, OperationError,
ParseError, ParseError,
PremiumGiveawayResult, PremiumGiveawayResult,
@@ -27,6 +28,7 @@ from pyfragment.types import (
TerminateSessionsResult, TerminateSessionsResult,
TransactionError, TransactionError,
UnexpectedError, UnexpectedError,
UsernamesResult,
UserNotFoundError, UserNotFoundError,
VerificationError, VerificationError,
WalletError, WalletError,
@@ -40,13 +42,15 @@ __all__ = [
"FragmentClient", "FragmentClient",
"AdsRechargeResult", "AdsRechargeResult",
"AdsTopupResult", "AdsTopupResult",
"AuctionsResult", "GiftsResult",
"LoginCodeResult", "LoginCodeResult",
"NumbersResult",
"PremiumGiveawayResult", "PremiumGiveawayResult",
"PremiumResult", "PremiumResult",
"StarsGiveawayResult", "StarsGiveawayResult",
"StarsResult", "StarsResult",
"TerminateSessionsResult", "TerminateSessionsResult",
"UsernamesResult",
"WalletInfo", "WalletInfo",
"ClientError", "ClientError",
"ConfigurationError", "ConfigurationError",
+72 -13
View File
@@ -9,18 +9,22 @@ from pyfragment.methods.giveaway_stars import giveaway_stars
from pyfragment.methods.purchase_premium import purchase_premium from pyfragment.methods.purchase_premium import purchase_premium
from pyfragment.methods.purchase_stars import purchase_stars from pyfragment.methods.purchase_stars import purchase_stars
from pyfragment.methods.recharge_ads import recharge_ads 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.methods.topup_ton import topup_ton
from pyfragment.types import ( from pyfragment.types import (
AdsRechargeResult, AdsRechargeResult,
AdsTopupResult, AdsTopupResult,
AuctionsResult,
ConfigurationError, ConfigurationError,
CookieError, CookieError,
GiftsResult,
LoginCodeResult, LoginCodeResult,
NumbersResult,
PremiumResult, PremiumResult,
StarsResult, StarsResult,
TerminateSessionsResult, TerminateSessionsResult,
UsernamesResult,
WalletInfo, WalletInfo,
) )
from pyfragment.types.constants import ( from pyfragment.types.constants import (
@@ -252,32 +256,87 @@ class FragmentClient:
""" """
return await terminate_sessions(self, number) return await terminate_sessions(self, number)
async def search_auctions( async def search_usernames(
self, self,
query: str, query: str = "",
type: str | None = None,
sort: str | None = None, sort: str | None = None,
filter: str | None = None, filter: str | None = None,
offset_id: str | None = None, offset_id: str | None = None,
) -> AuctionsResult: ) -> UsernamesResult:
"""Search the Fragment marketplace for auction listings. """Search the Fragment marketplace for Telegram usernames.
Args: Args:
query: Search text (e.g. ``"durov"`` or ``"888"``). query: Search text (e.g. ``"durov"``). Omit or pass ``""`` to browse all.
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 sort: Sort order — ``"price_desc"``, ``"price_asc"``, ``"listed"``, or
``"ending"``. Omit to use Fragment's default ordering. ``"ending"``. Omit to use Fragment's default ordering.
filter: Filter results — ``"auction"``, ``"sale"``, ``"sold"``, or filter: Filter results — ``"auction"``, ``"sale"``, ``"sold"``, or
``""`` (available items). Omit to return all. ``""`` (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. from a previous result to fetch the next page.
Returns: 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). 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( async def call(
self, method: str, data: dict[str, Any] | None = None, *, page_url: str = FRAGMENT_BASE_URL self, method: str, data: dict[str, Any] | None = None, *, page_url: str = FRAGMENT_BASE_URL
+6 -2
View File
@@ -4,7 +4,9 @@ from pyfragment.methods.giveaway_stars import giveaway_stars
from pyfragment.methods.purchase_premium import purchase_premium from pyfragment.methods.purchase_premium import purchase_premium
from pyfragment.methods.purchase_stars import purchase_stars from pyfragment.methods.purchase_stars import purchase_stars
from pyfragment.methods.recharge_ads import recharge_ads 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.methods.topup_ton import topup_ton
__all__ = [ __all__ = [
@@ -14,7 +16,9 @@ __all__ = [
"purchase_premium", "purchase_premium",
"purchase_stars", "purchase_stars",
"recharge_ads", "recharge_ads",
"search_auctions", "search_gifts",
"search_numbers",
"search_usernames",
"terminate_sessions", "terminate_sessions",
"toggle_login_codes", "toggle_login_codes",
"topup_ton", "topup_ton",
+80
View File
@@ -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
+67
View File
@@ -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
@@ -4,7 +4,7 @@ import httpx
from pyfragment.types import FragmentAPIError, FragmentError, UnexpectedError from pyfragment.types import FragmentAPIError, FragmentError, UnexpectedError
from pyfragment.types.constants import FRAGMENT_BASE_URL 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 from pyfragment.utils import fragment_request, get_fragment_hash, make_headers, parse_auction_rows
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -13,39 +13,34 @@ if TYPE_CHECKING:
HEADERS: dict[str, str] = make_headers(FRAGMENT_BASE_URL) HEADERS: dict[str, str] = make_headers(FRAGMENT_BASE_URL)
async def search_auctions( async def search_usernames(
client: "FragmentClient", client: "FragmentClient",
query: str, query: str = "",
type: str | None = None,
sort: str | None = None, sort: str | None = None,
filter: str | None = None, filter: str | None = None,
offset_id: str | None = None, offset_id: str | None = None,
) -> AuctionsResult: ) -> UsernamesResult:
"""Search the Fragment marketplace for usernames, numbers, or collectibles. """Search the Fragment marketplace for Telegram usernames.
Args: Args:
client: Authenticated :class:`FragmentClient` instance. client: Authenticated :class:`FragmentClient` instance.
query: Search text (e.g. ``"durov"`` or ``"888"``). query: Search text (e.g. ``"durov"``). Omit or pass ``""`` to browse all.
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 sort: Sort order ``"price_desc"``, ``"price_asc"``, ``"listed"``, or
``"ending"``. Omit to use Fragment's default ordering. ``"ending"``. Omit to use Fragment's default ordering.
filter: Filter results ``"auction"``, ``"sale"``, ``"sold"``, or ``""`` filter: Filter results ``"auction"``, ``"sale"``, ``"sold"``, or ``""``
(available items). Omit to return all. (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. Pass ``next_offset_id`` to fetch the next page.
Returns: 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). ``next_offset_id`` (``None`` when there are no more pages).
Raises: Raises:
FragmentAPIError: If the Fragment API returns an error. FragmentAPIError: If the Fragment API returns an error.
UnexpectedError: For any other unexpected failure. UnexpectedError: For any other unexpected failure.
""" """
data: dict[str, Any] = {"method": "searchAuctions", "query": query} data: dict[str, Any] = {"method": "searchAuctions", "type": "usernames", "query": query}
if type is not None:
data["type"] = type
if sort is not None: if sort is not None:
data["sort"] = sort data["sort"] = sort
if filter is not None: if filter is not None:
@@ -64,7 +59,7 @@ async def search_auctions(
items = parse_auction_rows(result.get("html") or "") items = parse_auction_rows(result.get("html") or "")
raw_noi = result.get("next_offset_id") raw_noi = result.get("next_offset_id")
next_offset_id = str(raw_noi) if raw_noi else None 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: except FragmentError:
raise raise
+6 -2
View File
@@ -17,13 +17,15 @@ from pyfragment.types.exceptions import (
from pyfragment.types.results import ( from pyfragment.types.results import (
AdsRechargeResult, AdsRechargeResult,
AdsTopupResult, AdsTopupResult,
AuctionsResult, GiftsResult,
LoginCodeResult, LoginCodeResult,
NumbersResult,
PremiumGiveawayResult, PremiumGiveawayResult,
PremiumResult, PremiumResult,
StarsGiveawayResult, StarsGiveawayResult,
StarsResult, StarsResult,
TerminateSessionsResult, TerminateSessionsResult,
UsernamesResult,
WalletInfo, WalletInfo,
) )
@@ -47,12 +49,14 @@ __all__ = [
# result types # result types
"AdsRechargeResult", "AdsRechargeResult",
"AdsTopupResult", "AdsTopupResult",
"AuctionsResult", "GiftsResult",
"LoginCodeResult", "LoginCodeResult",
"NumbersResult",
"PremiumGiveawayResult", "PremiumGiveawayResult",
"PremiumResult", "PremiumResult",
"StarsGiveawayResult", "StarsGiveawayResult",
"StarsResult", "StarsResult",
"TerminateSessionsResult", "TerminateSessionsResult",
"UsernamesResult",
"WalletInfo", "WalletInfo",
] ]
+1
View File
@@ -27,6 +27,7 @@ PREMIUM_PAGE: str = f"{FRAGMENT_BASE_URL}/premium/gift"
PREMIUM_GIVEAWAY_PAGE: str = f"{FRAGMENT_BASE_URL}/premium/giveaway" PREMIUM_GIVEAWAY_PAGE: str = f"{FRAGMENT_BASE_URL}/premium/giveaway"
ADS_TOPUP_PAGE: str = f"{FRAGMENT_BASE_URL}/ads/topup" ADS_TOPUP_PAGE: str = f"{FRAGMENT_BASE_URL}/ads/topup"
NUMBERS_PAGE: str = f"{FRAGMENT_BASE_URL}/numbers" 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. # Tonkeeper device fingerprint — serialized once, reused in every tx_data payload.
DEVICE: str = json.dumps( DEVICE: str = json.dumps(
+51 -5
View File
@@ -118,8 +118,8 @@ class TerminateSessionsResult:
@dataclass @dataclass
class AuctionsResult: class UsernamesResult:
"""Result of :meth:`FragmentClient.search_auctions`. """Result of :meth:`FragmentClient.search_usernames`.
Each dict in ``items`` has the keys: Each dict in ``items`` has the keys:
@@ -127,7 +127,7 @@ class AuctionsResult:
- ``name`` — display value (e.g. ``"@durov"``). - ``name`` — display value (e.g. ``"@durov"``).
- ``status`` — human-readable Fragment label (e.g. ``"On auction"``, ``"For sale"``). - ``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``. - ``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. Use ``next_offset_id`` to paginate to the next page of results.
""" """
@@ -136,18 +136,64 @@ class AuctionsResult:
next_offset_id: str | None next_offset_id: str | None
def __repr__(self) -> str: 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__ = [ __all__ = [
"AdsRechargeResult", "AdsRechargeResult",
"AdsTopupResult", "AdsTopupResult",
"AuctionsResult", "GiftsResult",
"LoginCodeResult", "LoginCodeResult",
"NumbersResult",
"PremiumGiveawayResult", "PremiumGiveawayResult",
"PremiumResult", "PremiumResult",
"StarsGiveawayResult", "StarsGiveawayResult",
"StarsResult", "StarsResult",
"TerminateSessionsResult", "TerminateSessionsResult",
"UsernamesResult",
"WalletInfo", "WalletInfo",
] ]
+2 -1
View File
@@ -1,5 +1,5 @@
from pyfragment.utils.decoder import clean_decode 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 ( from pyfragment.utils.http import (
execute_transaction_request, execute_transaction_request,
fragment_request, fragment_request,
@@ -12,6 +12,7 @@ from pyfragment.utils.wallet import get_account_info, process_transaction
__all__ = [ __all__ = [
"clean_decode", "clean_decode",
"parse_auction_rows", "parse_auction_rows",
"parse_gift_items",
"parse_login_code", "parse_login_code",
"execute_transaction_request", "execute_transaction_request",
"fragment_request", "fragment_request",
+68 -4
View File
@@ -16,6 +16,15 @@ DATETIME_SHORT_RE = re.compile(r'<time[^>]+datetime="([^"]+)"[^>]*data-relative=
# Matches numeric-only values (plain integers, formatted prices like "150,492", phone numbers like "+888 0088 8888") # Matches numeric-only values (plain integers, formatted prices like "150,492", phone numbers like "+888 0088 8888")
NUMERIC_RE = re.compile(r"^\+?[\d,. ]+$") NUMERIC_RE = re.compile(r"^\+?[\d,. ]+$")
# Gift grid item parsing
GRID_ITEM_RE = re.compile(r'<a\b[^>]*class="[^"]*tm-grid-item[^"]*"[^>]*>(.*?)</a>', 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'<time[^>]+datetime="([^"]+)"')
def parse_login_code(html: str) -> tuple[str | None, int]: 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. """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"``). - ``status`` — human-readable Fragment label (e.g. ``"On auction"``, ``"For sale"``).
- ``price`` — price in TON formatted to two decimal places (e.g. ``"7.00"``), - ``price`` — price in TON formatted to two decimal places (e.g. ``"7.00"``),
or ``None`` if not listed. 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: Returns:
List of item dicts, one per table row. List of item dicts, one per table row.
@@ -81,9 +90,9 @@ def parse_auction_rows(html: str) -> list[dict[str, Any]]:
except ValueError: except ValueError:
price = raw_price 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) 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( items.append(
{ {
@@ -91,7 +100,62 @@ def parse_auction_rows(html: str) -> list[dict[str, Any]]:
"name": name, "name": name,
"status": status, "status": status,
"price": price, "price": price,
"ends_at": ends_at, "date": date,
} }
) )
return items 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 ``<a class="tm-grid-item">`` 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
+111
View File
@@ -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 = """
<tr class="tm-row-selectable">
<td><a href="/username/coolname" class="table-cell">
<div class="table-cell-value tm-value">@coolname</div>
<div class="table-cell-status-thin thin-only tm-status-avail">On auction</div>
</a></td>
<td><div class="table-cell-value tm-value icon-before icon-ton">5</div>
<time datetime="2026-06-01T12:00:00+00:00" data-relative="text">2 days</time>
</td>
<td><div class="table-cell-value tm-value tm-status-avail">On auction</div></td>
</tr>
"""
@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"
+111
View File
@@ -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 = """
<tr class="tm-row-selectable">
<td><a href="/number/8880000888" class="table-cell">
<div class="table-cell-value tm-value">+888 0000 888</div>
<div class="table-cell-status-thin thin-only tm-status-avail">For sale</div>
</a></td>
<td><div class="table-cell-value tm-value icon-before icon-ton">150</div>
<time datetime="2026-05-15T10:00:00+00:00" data-relative="short-text">May 15</time>
</td>
<td><div class="table-cell-value tm-value tm-status-avail">For sale</div></td>
</tr>
"""
@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"
-124
View File
@@ -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 = """
<tr class="tm-row-selectable">
<td><a href="/username/coolname" class="table-cell">
<div class="table-cell-value tm-value">@coolname</div>
<div class="table-cell-status-thin thin-only tm-status-avail">On auction</div>
</a></td>
<td><div class="table-cell-value tm-value icon-before icon-ton">5</div>
<time datetime="2026-06-01T12:00:00+00:00" data-relative="text">2 days</time>
</td>
<td><div class="table-cell-value tm-value tm-status-avail">On auction</div></td>
</tr>
"""
@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"
+188
View File
@@ -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 = """
<div class="tm-catalog-grid">
<a href="/gift/plushpepe-1821?collection=all" class="tm-grid-item">
<div class="tm-grid-item-thumb">
<img src="https://nft.fragment.com/gift/plushpepe-1821.medium.jpg" class="tm-grid-thumb"/>
</div>
<div class="tm-grid-item-content">
<div class="tm-grid-item-name wide-only">
<span class="item-name">Plush Pepe</span>
<span class="item-num">&nbsp;#1821</span>
</div>
<div class="tm-grid-item-desc wide-only">
<time datetime="2026-02-05T14:41:27+00:00" class="short">Feb 5 at 16:41</time>
</div>
<div class="tm-grid-item-values">
<div class="tm-grid-item-value tm-value icon-before icon-ton">88,888</div>
<div class="tm-grid-item-status tm-status-unavail">Sold</div>
</div>
</div>
</a>
<a href="/gift/swisswatch-7799?collection=all" class="tm-grid-item">
<div class="tm-grid-item-content">
<div class="tm-grid-item-name wide-only">
<span class="item-name">Swiss Watch</span>
<span class="item-num">&nbsp;#7799</span>
</div>
<div class="tm-grid-item-desc wide-only">
<time datetime="2026-01-10T04:52:59+00:00" class="short">Jan 10 at 06:52</time>
</div>
<div class="tm-grid-item-values">
<div class="tm-grid-item-value tm-value icon-before icon-ton">13,588</div>
<div class="tm-grid-item-status tm-status-unavail">Sold</div>
</div>
</div>
</a>
<a class="tm-catalog-grid-more js-load-more" data-next-offset="60">Show more</a>
</div>
"""
@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)