feat: implement search_auctions method for Fragment marketplace; add AuctionsResult type; enhance topup_ton example and tests

This commit is contained in:
bohd4nx
2026-03-25 17:40:02 +02:00
parent dfe7bb4140
commit f37a4ab985
13 changed files with 386 additions and 7 deletions
+2
View File
@@ -10,6 +10,7 @@ from pyfragment.types import (
AdsRechargeResult,
AdsTopupResult,
AnonymousNumberError,
AuctionsResult,
ClientError,
ConfigurationError,
CookieError,
@@ -39,6 +40,7 @@ __all__ = [
"FragmentClient",
"AdsRechargeResult",
"AdsTopupResult",
"AuctionsResult",
"LoginCodeResult",
"PremiumGiveawayResult",
"PremiumResult",
+31 -2
View File
@@ -9,10 +9,12 @@ from pyfragment.methods.giveaway_stars import giveaway_stars
from pyfragment.methods.purchase_premium import purchase_premium
from pyfragment.methods.purchase_stars import purchase_stars
from pyfragment.methods.recharge_ads import recharge_ads
from pyfragment.methods.search_auctions import search_auctions
from pyfragment.methods.topup_ton import topup_ton
from pyfragment.types import (
AdsRechargeResult,
AdsTopupResult,
AuctionsResult,
ConfigurationError,
CookieError,
LoginCodeResult,
@@ -143,10 +145,10 @@ class FragmentClient:
return await purchase_stars(self, username, amount, show_sender)
async def topup_ton(self, username: str, amount: int, show_sender: bool = True) -> AdsTopupResult:
"""Top up Telegram Ads balance with TON.
"""Topup ton to recipient's Telegram balance.
Args:
username: Ads account username (with or without ``@``).
username: Recipient's Telegram username (with or without ``@``).
amount: Amount in TON — integer from ``1`` to ``1 000 000 000``.
show_sender: Show your name as the sender. Defaults to ``True``.
@@ -250,6 +252,33 @@ class FragmentClient:
"""
return await terminate_sessions(self, number)
async def search_auctions(
self,
query: str,
type: str | None = None,
sort: str | None = None,
filter: str | None = None,
offset_id: str | None = None,
) -> AuctionsResult:
"""Search the Fragment marketplace for auction listings.
Args:
query: Search text (e.g. ``"durov"`` or ``"888"``).
type: Narrow results by item type — ``"usernames"``, ``"numbers"``, or
``"collectibles"``. Omit to search across all types.
sort: Sort order — ``"price_desc"``, ``"price_asc"``, ``"listed"``, or
``"ending"``. Omit to use Fragment's default ordering.
filter: Filter results — ``"auction"``, ``"sale"``, ``"sold"``, or
``""`` (available items). Omit to return all.
offset_id: Pagination cursor — pass :attr:`AuctionsResult.next_offset_id`
from a previous result to fetch the next page.
Returns:
:class:`AuctionsResult` with ``items`` (parsed list of item dicts)
and ``next_offset_id`` (``None`` on the last page).
"""
return await search_auctions(self, query, type=type, sort=sort, filter=filter, offset_id=offset_id)
async def call(
self, method: str, data: dict[str, Any] | None = None, *, page_url: str = FRAGMENT_BASE_URL
) -> dict[str, Any]:
+2
View File
@@ -4,6 +4,7 @@ from pyfragment.methods.giveaway_stars import giveaway_stars
from pyfragment.methods.purchase_premium import purchase_premium
from pyfragment.methods.purchase_stars import purchase_stars
from pyfragment.methods.recharge_ads import recharge_ads
from pyfragment.methods.search_auctions import search_auctions
from pyfragment.methods.topup_ton import topup_ton
__all__ = [
@@ -13,6 +14,7 @@ __all__ = [
"purchase_premium",
"purchase_stars",
"recharge_ads",
"search_auctions",
"terminate_sessions",
"toggle_login_codes",
"topup_ton",
+72
View File
@@ -0,0 +1,72 @@
from typing import TYPE_CHECKING, Any
import httpx
from pyfragment.types import FragmentAPIError, FragmentError, UnexpectedError
from pyfragment.types.constants import FRAGMENT_BASE_URL
from pyfragment.types.results import AuctionsResult
from pyfragment.utils import fragment_request, get_fragment_hash, make_headers, parse_auction_rows
if TYPE_CHECKING:
from pyfragment.client import FragmentClient
HEADERS: dict[str, str] = make_headers(FRAGMENT_BASE_URL)
async def search_auctions(
client: "FragmentClient",
query: str,
type: str | None = None,
sort: str | None = None,
filter: str | None = None,
offset_id: str | None = None,
) -> AuctionsResult:
"""Search the Fragment marketplace for usernames, numbers, or collectibles.
Args:
client: Authenticated :class:`FragmentClient` instance.
query: Search text (e.g. ``"durov"`` or ``"888"``).
type: Narrow results by item type — ``"usernames"``, ``"numbers"``, or
``"collectibles"``. Omit to search across all types.
sort: Sort order — ``"price_desc"``, ``"price_asc"``, ``"listed"``, or
``"ending"``. Omit to use Fragment's default ordering.
filter: Filter results — ``"auction"``, ``"sale"``, ``"sold"``, or ``""``
(available items). Omit to return all.
offset_id: Pagination cursor from a previous :class:`AuctionsResult`.
Pass ``next_offset_id`` to fetch the next page.
Returns:
:class:`AuctionsResult` with ``items`` (parsed list of item dicts) and
``next_offset_id`` (``None`` when there are no more pages).
Raises:
FragmentAPIError: If the Fragment API returns an error.
UnexpectedError: For any other unexpected failure.
"""
data: dict[str, Any] = {"method": "searchAuctions", "query": query}
if type is not None:
data["type"] = type
if sort is not None:
data["sort"] = sort
if filter is not None:
data["filter"] = filter
if offset_id is not None:
data["offset_id"] = offset_id
try:
fragment_hash = await get_fragment_hash(client.cookies, HEADERS, FRAGMENT_BASE_URL, client.timeout)
async with httpx.AsyncClient(cookies=client.cookies, timeout=client.timeout) as session:
result = await fragment_request(session, fragment_hash, HEADERS, data)
if result.get("error"):
raise FragmentAPIError(result["error"])
items = parse_auction_rows(result.get("html") or "")
raw_noi = result.get("next_offset_id")
next_offset_id = str(raw_noi) if raw_noi else None
return AuctionsResult(items=items, next_offset_id=next_offset_id)
except FragmentError:
raise
except Exception as exc:
raise UnexpectedError(UnexpectedError.UNEXPECTED.format(exc=exc)) from exc
+3 -3
View File
@@ -71,11 +71,11 @@ async def _init_request(
async def topup_ton(client: "FragmentClient", username: str, amount: int, show_sender: bool = True) -> AdsTopupResult:
"""Top up a Telegram Ads account balance with TON.
"""Topup ton to recipient's Telegram balance.
Args:
client: Authenticated :class:`FragmentClient` instance.
username: Ads account username (with or without ``@``).
username: Recipient's Telegram username (with or without ``@``).
amount: Amount in TON — integer from ``1`` to ``1 000 000 000``.
show_sender: Show your name as the sender. Defaults to ``True``.
@@ -84,7 +84,7 @@ async def topup_ton(client: "FragmentClient", username: str, amount: int, show_s
Raises:
ConfigurationError: If ``amount`` is not an integer between 1 and 1 000 000 000.
UserNotFoundError: If the user is not found on Fragment.
UserNotFoundError: If the recipient is not found on Fragment.
FragmentAPIError: If the Fragment API returns an error.
UnexpectedError: For any other unexpected failure.
"""
+2
View File
@@ -17,6 +17,7 @@ from pyfragment.types.exceptions import (
from pyfragment.types.results import (
AdsRechargeResult,
AdsTopupResult,
AuctionsResult,
LoginCodeResult,
PremiumGiveawayResult,
PremiumResult,
@@ -46,6 +47,7 @@ __all__ = [
# result types
"AdsRechargeResult",
"AdsTopupResult",
"AuctionsResult",
"LoginCodeResult",
"PremiumGiveawayResult",
"PremiumResult",
+24
View File
@@ -1,4 +1,5 @@
from dataclasses import dataclass
from typing import Any
@dataclass
@@ -116,9 +117,32 @@ class TerminateSessionsResult:
return f"TerminateSessionsResult(number='{self.number}', message={self.message!r})"
@dataclass
class AuctionsResult:
"""Result of :meth:`FragmentClient.search_auctions`.
Each dict in ``items`` has the keys:
- ``slug`` — URL path (e.g. ``"username/durov"``).
- ``name`` — display value (e.g. ``"@durov"``).
- ``status`` — human-readable Fragment label (e.g. ``"On auction"``, ``"For sale"``).
- ``price`` — price in TON formatted to two decimal places (e.g. ``"7.00"``), or ``None``.
- ``ends_at`` — ISO 8601 auction-end datetime, or ``None``.
Use ``next_offset_id`` to paginate to the next page of results.
"""
items: list[dict[str, Any]]
next_offset_id: str | None
def __repr__(self) -> str:
return f"AuctionsResult(items={len(self.items)}, next_offset_id={self.next_offset_id!r})"
__all__ = [
"AdsRechargeResult",
"AdsTopupResult",
"AuctionsResult",
"LoginCodeResult",
"PremiumGiveawayResult",
"PremiumResult",
+2 -1
View File
@@ -1,5 +1,5 @@
from pyfragment.utils.decoder import clean_decode
from pyfragment.utils.html import parse_login_code
from pyfragment.utils.html import parse_auction_rows, parse_login_code
from pyfragment.utils.http import (
execute_transaction_request,
fragment_request,
@@ -11,6 +11,7 @@ from pyfragment.utils.wallet import get_account_info, process_transaction
__all__ = [
"clean_decode",
"parse_auction_rows",
"parse_login_code",
"execute_transaction_request",
"fragment_request",
+74
View File
@@ -1,10 +1,21 @@
import re
from typing import Any
# Matches the login code inside a table-cell-value element.
CODE_RE = re.compile(r'class="[^"]*table-cell-value[^"]*"[^>]*>([^<]+)<')
# Counts active session rows in the HTML table.
ROW_RE = re.compile(r"<tr[\s>]")
# Auction table row parsing
ROW_BLOCK_RE = re.compile(r'<tr\b[^>]*class="[^"]*tm-row-selectable[^"]*"[^>]*>(.*?)</tr>', re.DOTALL)
HREF_RE = re.compile(r'href="(/(?:username|number|nft)/([^"]+))"')
VALUE_RE = re.compile(r'class="[^"]*tm-value[^"]*"[^>]*>\s*([^<]+?)\s*<')
PRICE_RE = re.compile(r"icon-before\s+icon-ton[^>]*>\s*([0-9][^<]*?)\s*<")
DATETIME_RE = re.compile(r'<time[^>]+datetime="([^"]+)"[^>]*data-relative="text"[^>]*>')
DATETIME_SHORT_RE = re.compile(r'<time[^>]+datetime="([^"]+)"[^>]*data-relative="short-text"[^>]*>')
# Matches numeric-only values (plain integers, formatted prices like "150,492", phone numbers like "+888 0088 8888")
NUMERIC_RE = re.compile(r"^\+?[\d,. ]+$")
def parse_login_code(html: str) -> tuple[str | None, int]:
"""Extract the pending login code and active session count from a Fragment numbers page HTML snippet.
@@ -21,3 +32,66 @@ def parse_login_code(html: str) -> tuple[str | None, int]:
code = match.group(1).strip() if match else None
active_sessions = len(ROW_RE.findall(html))
return code, active_sessions
def parse_auction_rows(html: str) -> list[dict[str, Any]]:
"""Parse Fragment marketplace HTML into structured item dicts.
Extracts each ``<tr class="tm-row-selectable">`` and returns a list of dicts
with the following keys:
- ``slug`` — URL path segment (e.g. ``"username/durov"``).
- ``name`` — display value (e.g. ``"@durov"`` or ``"+888..."``)
- ``status`` — human-readable Fragment label (e.g. ``"On auction"``, ``"For sale"``).
- ``price`` — price in TON formatted to two decimal places (e.g. ``"7.00"``),
or ``None`` if not listed.
- ``ends_at`` — ISO 8601 datetime string of auction end, or ``None``.
Returns:
List of item dicts, one per table row.
"""
items: list[dict[str, Any]] = []
for row_match in ROW_BLOCK_RE.finditer(html):
row = row_match.group(1)
href_m = HREF_RE.search(row)
if not href_m:
continue
slug = href_m.group(1).lstrip("/") # e.g. "username/durov"
# All tm-value spans in the row — first is the display name
values = [m.group(1).strip() for m in VALUE_RE.finditer(row)]
name = values[0] if values else slug
# Status: find the human-readable label from subsequent tm-value spans.
# Skip usernames (@), numeric-only values (prices like "150,492", phone numbers like "+888 0088 8888").
status: str | None = None
for v in values[1:]:
if v and v not in ("Unknown",) and not v.startswith("@") and not NUMERIC_RE.match(v):
status = v
break
# Price — look for icon-ton pattern, format as two decimal places
price_m = PRICE_RE.search(row)
price: str | None = None
if price_m:
raw_price = price_m.group(1).strip().replace(",", "")
try:
price = f"{float(raw_price):.2f}"
except ValueError:
price = raw_price
# Auction end datetime (ISO 8601)
time_m = DATETIME_RE.search(row) or DATETIME_SHORT_RE.search(row)
ends_at: str | None = time_m.group(1) if time_m else None
items.append(
{
"slug": slug,
"name": name,
"status": status,
"price": price,
"ends_at": ends_at,
}
)
return items