mirror of
https://github.com/vibe-existing/pyfragment.git
synced 2026-07-24 22:44:31 +00:00
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:
+7
-2
@@ -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
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<h1 style="margin-top: 24px;">Fragment API</h1>
|
||||
|
||||
<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>
|
||||
|
||||
[](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
|
||||
|
||||
@@ -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())
|
||||
@@ -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".
|
||||
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}")
|
||||
@@ -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",
|
||||
|
||||
+72
-13
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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.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
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
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]:
|
||||
"""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 ``<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
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"> #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"> #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)
|
||||
Reference in New Issue
Block a user