mirror of
https://github.com/bohd4nx/FragmentAPI.git
synced 2026-07-25 06:14:29 +00:00
feat: implement search_auctions method for Fragment marketplace; add AuctionsResult type; enhance topup_ton example and tests
This commit is contained in:
@@ -20,6 +20,10 @@ and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MINOR.MI
|
|||||||
- `recharge_ads(account, amount)` — add funds to your own Telegram Ads account; `account` is the channel or bot username linked to your Ads account; raises `ConfigurationError` if `amount` is out of range (1–1 000 000 000 TON)
|
- `recharge_ads(account, amount)` — add funds to your own Telegram Ads account; `account` is the channel or bot username linked to your Ads account; raises `ConfigurationError` if `amount` is out of range (1–1 000 000 000 TON)
|
||||||
- `AdsRechargeResult` result type
|
- `AdsRechargeResult` result type
|
||||||
|
|
||||||
|
**Marketplace**
|
||||||
|
- `search_auctions(query, type?, sort?, filter?, offset_id?)` — search the Fragment marketplace for usernames, numbers, or collectibles; `type` is one of `"usernames"`, `"numbers"`, `"collectibles"` (optional); `sort` is one of `"price_desc"`, `"price_asc"`, `"listed"`, `"ending"`; `filter` is one of `""`, `"auction"`, `"sale"`, `"sold"`; supports pagination via `offset_id`; returns parsed item dicts with `slug`, `name`, `status`, `price`, `ends_at`
|
||||||
|
- `AuctionsResult` result type
|
||||||
|
|
||||||
**Raw API access**
|
**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
|
||||||
- `FRAGMENT_BASE_URL` constant — single source of truth for the Fragment base URL used across all page constants and headers
|
- `FRAGMENT_BASE_URL` constant — single source of truth for the Fragment base URL used across all page constants and headers
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"""
|
||||||
|
Example: search the Fragment marketplace for auction listings.
|
||||||
|
|
||||||
|
type can be "usernames", "numbers", or "collectibles".
|
||||||
|
sort can be "price_desc", "price_asc", "listed", or "ending".
|
||||||
|
filter can be "", "auction", "sale", or "sold".
|
||||||
|
Use next_offset_id for pagination.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from pyfragment import AuctionsResult, FragmentClient
|
||||||
|
|
||||||
|
SEED = "word1 word2 ... word24"
|
||||||
|
API_KEY = "YOUR_TONAPI_KEY"
|
||||||
|
COOKIES = {
|
||||||
|
"stel_ssid": "YOUR_STEL_SSID",
|
||||||
|
"stel_dt": "YOUR_STEL_DT",
|
||||||
|
"stel_token": "YOUR_STEL_TOKEN",
|
||||||
|
"stel_ton_token": "YOUR_STEL_TON_TOKEN",
|
||||||
|
}
|
||||||
|
|
||||||
|
QUERY = "durov" # search term
|
||||||
|
TYPE = "usernames" # "usernames", "numbers", or "collectibles" — or omit
|
||||||
|
SORT = "price_desc" # "price_desc", "price_asc", "listed", "ending" — or omit
|
||||||
|
FILTER = "auction" # "", "auction", "sale", "sold" — or omit
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client:
|
||||||
|
result: AuctionsResult = await client.search_auctions(QUERY, type=TYPE, sort=SORT, filter=FILTER)
|
||||||
|
|
||||||
|
print(f"Found {len(result.items)} result(s):")
|
||||||
|
for item in result.items:
|
||||||
|
price = f"{item['price']} TON" if item["price"] else "n/a"
|
||||||
|
print(f" {item['name']:20s} {item['status'] or '':15s} {price:10s} {item['ends_at'] or '—'}")
|
||||||
|
|
||||||
|
if result.next_offset_id:
|
||||||
|
print(f"\nMore results available — next page offset: {result.next_offset_id}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Example: top up a Telegram Ads account with TON.
|
Example: Topup ton to recipient's Telegram balance.
|
||||||
|
|
||||||
|
For adding TON to Telegram Ads account, use recharge_ads() instead.
|
||||||
|
|
||||||
Amount must be an integer between 1 and 1 000 000 000 TON.
|
Amount must be an integer between 1 and 1 000 000 000 TON.
|
||||||
Your wallet must hold at least the topup amount + ~0.056 TON for gas.
|
Your wallet must hold at least the topup amount + ~0.056 TON for gas.
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from pyfragment.types import (
|
|||||||
AdsRechargeResult,
|
AdsRechargeResult,
|
||||||
AdsTopupResult,
|
AdsTopupResult,
|
||||||
AnonymousNumberError,
|
AnonymousNumberError,
|
||||||
|
AuctionsResult,
|
||||||
ClientError,
|
ClientError,
|
||||||
ConfigurationError,
|
ConfigurationError,
|
||||||
CookieError,
|
CookieError,
|
||||||
@@ -39,6 +40,7 @@ __all__ = [
|
|||||||
"FragmentClient",
|
"FragmentClient",
|
||||||
"AdsRechargeResult",
|
"AdsRechargeResult",
|
||||||
"AdsTopupResult",
|
"AdsTopupResult",
|
||||||
|
"AuctionsResult",
|
||||||
"LoginCodeResult",
|
"LoginCodeResult",
|
||||||
"PremiumGiveawayResult",
|
"PremiumGiveawayResult",
|
||||||
"PremiumResult",
|
"PremiumResult",
|
||||||
|
|||||||
+31
-2
@@ -9,10 +9,12 @@ 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.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,
|
||||||
LoginCodeResult,
|
LoginCodeResult,
|
||||||
@@ -143,10 +145,10 @@ class FragmentClient:
|
|||||||
return await purchase_stars(self, username, amount, show_sender)
|
return await purchase_stars(self, username, amount, show_sender)
|
||||||
|
|
||||||
async def topup_ton(self, username: str, amount: int, show_sender: bool = True) -> AdsTopupResult:
|
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:
|
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``.
|
amount: Amount in TON — integer from ``1`` to ``1 000 000 000``.
|
||||||
show_sender: Show your name as the sender. Defaults to ``True``.
|
show_sender: Show your name as the sender. Defaults to ``True``.
|
||||||
|
|
||||||
@@ -250,6 +252,33 @@ class FragmentClient:
|
|||||||
"""
|
"""
|
||||||
return await terminate_sessions(self, number)
|
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(
|
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
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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.topup_ton import topup_ton
|
from pyfragment.methods.topup_ton import topup_ton
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -13,6 +14,7 @@ __all__ = [
|
|||||||
"purchase_premium",
|
"purchase_premium",
|
||||||
"purchase_stars",
|
"purchase_stars",
|
||||||
"recharge_ads",
|
"recharge_ads",
|
||||||
|
"search_auctions",
|
||||||
"terminate_sessions",
|
"terminate_sessions",
|
||||||
"toggle_login_codes",
|
"toggle_login_codes",
|
||||||
"topup_ton",
|
"topup_ton",
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -71,11 +71,11 @@ async def _init_request(
|
|||||||
|
|
||||||
|
|
||||||
async def topup_ton(client: "FragmentClient", username: str, amount: int, show_sender: bool = True) -> AdsTopupResult:
|
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:
|
Args:
|
||||||
client: Authenticated :class:`FragmentClient` instance.
|
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``.
|
amount: Amount in TON — integer from ``1`` to ``1 000 000 000``.
|
||||||
show_sender: Show your name as the sender. Defaults to ``True``.
|
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:
|
Raises:
|
||||||
ConfigurationError: If ``amount`` is not an integer between 1 and 1 000 000 000.
|
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.
|
FragmentAPIError: If the Fragment API returns an error.
|
||||||
UnexpectedError: For any other unexpected failure.
|
UnexpectedError: For any other unexpected failure.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from pyfragment.types.exceptions import (
|
|||||||
from pyfragment.types.results import (
|
from pyfragment.types.results import (
|
||||||
AdsRechargeResult,
|
AdsRechargeResult,
|
||||||
AdsTopupResult,
|
AdsTopupResult,
|
||||||
|
AuctionsResult,
|
||||||
LoginCodeResult,
|
LoginCodeResult,
|
||||||
PremiumGiveawayResult,
|
PremiumGiveawayResult,
|
||||||
PremiumResult,
|
PremiumResult,
|
||||||
@@ -46,6 +47,7 @@ __all__ = [
|
|||||||
# result types
|
# result types
|
||||||
"AdsRechargeResult",
|
"AdsRechargeResult",
|
||||||
"AdsTopupResult",
|
"AdsTopupResult",
|
||||||
|
"AuctionsResult",
|
||||||
"LoginCodeResult",
|
"LoginCodeResult",
|
||||||
"PremiumGiveawayResult",
|
"PremiumGiveawayResult",
|
||||||
"PremiumResult",
|
"PremiumResult",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -116,9 +117,32 @@ class TerminateSessionsResult:
|
|||||||
return f"TerminateSessionsResult(number='{self.number}', message={self.message!r})"
|
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__ = [
|
__all__ = [
|
||||||
"AdsRechargeResult",
|
"AdsRechargeResult",
|
||||||
"AdsTopupResult",
|
"AdsTopupResult",
|
||||||
|
"AuctionsResult",
|
||||||
"LoginCodeResult",
|
"LoginCodeResult",
|
||||||
"PremiumGiveawayResult",
|
"PremiumGiveawayResult",
|
||||||
"PremiumResult",
|
"PremiumResult",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from pyfragment.utils.decoder import clean_decode
|
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 (
|
from pyfragment.utils.http import (
|
||||||
execute_transaction_request,
|
execute_transaction_request,
|
||||||
fragment_request,
|
fragment_request,
|
||||||
@@ -11,6 +11,7 @@ from pyfragment.utils.wallet import get_account_info, process_transaction
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"clean_decode",
|
"clean_decode",
|
||||||
|
"parse_auction_rows",
|
||||||
"parse_login_code",
|
"parse_login_code",
|
||||||
"execute_transaction_request",
|
"execute_transaction_request",
|
||||||
"fragment_request",
|
"fragment_request",
|
||||||
|
|||||||
@@ -1,10 +1,21 @@
|
|||||||
import re
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
# Matches the login code inside a table-cell-value element.
|
# Matches the login code inside a table-cell-value element.
|
||||||
CODE_RE = re.compile(r'class="[^"]*table-cell-value[^"]*"[^>]*>([^<]+)<')
|
CODE_RE = re.compile(r'class="[^"]*table-cell-value[^"]*"[^>]*>([^<]+)<')
|
||||||
# Counts active session rows in the HTML table.
|
# Counts active session rows in the HTML table.
|
||||||
ROW_RE = re.compile(r"<tr[\s>]")
|
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]:
|
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.
|
||||||
@@ -21,3 +32,66 @@ def parse_login_code(html: str) -> tuple[str | None, int]:
|
|||||||
code = match.group(1).strip() if match else None
|
code = match.group(1).strip() if match else None
|
||||||
active_sessions = len(ROW_RE.findall(html))
|
active_sessions = len(ROW_RE.findall(html))
|
||||||
return code, active_sessions
|
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
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
"""Unit tests for search_auctions — Fragment marketplace search."""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from pyfragment import FragmentClient
|
||||||
|
from pyfragment.types import AuctionsResult
|
||||||
|
from tests.shared import FAKE_HASH
|
||||||
|
|
||||||
|
FAKE_HTML = """
|
||||||
|
<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"
|
||||||
Reference in New Issue
Block a user