10 Commits

12 changed files with 50 additions and 212 deletions
+2 -2
View File
@@ -16,7 +16,7 @@ jobs:
- uses: actions/setup-python@v6
with:
python-version: "3.10"
python-version: "3.11"
cache: pip
- run: pip install ".[dev]"
@@ -30,7 +30,7 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: [ "3.10", "3.11", "3.12", "3.13", "3.14"]
python-version: ["3.11", "3.12", "3.13", "3.14"]
steps:
- uses: actions/checkout@v6
+1 -1
View File
@@ -47,7 +47,7 @@ examples/ — runnable usage examples (excluded from CI)
## Conventions
- All public async methods live on `FragmentClient` and delegate to a domain service.
- Domain functions receive a `FragmentClient` instance, never raw httpx clients.
- Domain functions receive a `FragmentClient` instance, never raw HTTP clients.
- Patch targets in tests use the module where the name is **defined**, e.g. `pyfragment.services.tonapi.transaction._make_ton_client`.
- Versioning follows [CalVer](https://calver.org/): `YYYY.MINOR.MICRO`. Bump in `pyproject.toml`; tag as `vYYYY.MINOR.MICRO`.
+10 -110
View File
@@ -9,18 +9,14 @@
[![Tests](https://img.shields.io/github/actions/workflow/status/bohd4nx/pyfragment/ci.yml?style=flat&label=Tests&logo=github)](https://github.com/bohd4nx/pyfragment/actions)
[![License](https://img.shields.io/github/license/bohd4nx/pyfragment?style=flat&color=blue&label=License)](LICENSE)
Async Python client for the **[Fragment](https://fragment.com)** marketplace API.
Buy Stars & Premium, run giveaways, top up GRAM (ex TON) and Ads balances,
manage anonymous numbers, and search Fragment listings.
Async Python client for the **[Fragment.com](https://fragment.com)** marketplace API.
**[Documentation](https://bohd4nx.gitbook.io/pyfragment/)** · **[Examples](https://github.com/bohd4nx/pyfragment/tree/master/examples)** · **[Changelog](CHANGELOG.md)** · **[Donate GRAM](https://app.tonkeeper.com/transfer/UQCppfw5DxWgdVHf3zkmZS8k1mt9oAUYxQLwq2fz3nhO8No5)**
**[Documentation](https://bohd4nx.gitbook.io/pyfragment/)** · **[Examples](https://github.com/bohd4nx/pyfragment/tree/master/examples)**
</div>
> **Disclaimer:** This project is not affiliated with [Fragment](https://fragment.com) or [Telegram](https://telegram.org).
---
## Installation
```bash
@@ -46,120 +42,24 @@ from pyfragment.enums import PaymentMethod
async def main() -> None:
async with FragmentClient(
seed="word1 word2 ... word24",
api_key="YOUR_API_KEY", # tonconsole.com (tonapi, default) or t.me/toncenter
cookies={
"stel_ssid": "...",
"stel_dt": "...",
"stel_token": "...",
"stel_ton_token": "...",
},
wallet_version="V5R1", # or "V4R2", "HighloadV2", "HighloadV3R1"
api_provider="tonapi", # or "toncenter"
api_key="YOUR_API_KEY",
cookies={"stel_ssid": "...", "stel_dt": "...", "stel_token": "...", "stel_ton_token": "..."},
) as client:
wallet = await client.get_wallet()
print(f"GRAM: {wallet.gram_balance} | USDT: {wallet.usdt_balance}")
print("GRAM: %s | USDT: %s" % (wallet.gram_balance, wallet.usdt_balance))
recipient = "https://t.me/username" # also: @username, username
stars = await client.purchase_stars("@username", amount=500, payment_method=PaymentMethod.USDT_GRAM)
print("Sent %s Stars to %s | tx: %s" % (stars.amount, stars.username, stars.transaction_id))
stars = await client.purchase_stars(recipient, amount=500, payment_method=PaymentMethod.USDT_GRAM)
print(f"Sent {stars.amount} Stars to {stars.username} | tx: {stars.transaction_id}")
premium = await client.purchase_premium(recipient, months=6, payment_method=PaymentMethod.GRAM)
print(f"Sent Premium {premium.amount}m to {premium.username} | tx: {premium.transaction_id}")
premium = await client.purchase_premium("@username", months=6, payment_method=PaymentMethod.GRAM)
print("Sent Premium %sm to %s | tx: %s" % (premium.amount, premium.username, premium.transaction_id))
asyncio.run(main())
```
---
## Configuration
| Parameter | Type | Default | Description |
| ---------------- | ------------- | ---------- | ----------------------------------------------------------------------------------------------------------------- |
| `seed` | `str` | — | 12- or 24-word GRAM (ex TON) wallet mnemonic |
| `api_key` | `str` | — | API key for the chosen provider (see `api_provider`) |
| `cookies` | `dict \| str` | — | Fragment session cookies |
| `wallet_version` | `str` | `"V5R1"` | `"V4R2"` or `"V5R1"` — also accepts `WalletVersion` literal |
| `api_provider` | `str` | `"tonapi"` | `"tonapi"` ([tonconsole.com](https://tonconsole.com)) or `"toncenter"` ([t.me/toncenter](https://t.me/toncenter)) |
| `timeout` | `float` | `30.0` | HTTP request timeout in seconds |
---
## Credentials
### Fragment cookies
Log in to [fragment.com](https://fragment.com) and connect your GRAM (ex TON) wallet.
**Automatically** (recommended) — reads directly from your browser, no extension needed:
```bash
pip install "pyfragment[browser]"
```
```python
from pyfragment import get_cookies_from_browser
result = get_cookies_from_browser("chrome") # firefox, edge, brave, ...
# result.cookies — dict[str, str] ready to pass to FragmentClient
# result.expires — ISO 8601 expiry of stel_ssid, or None for session cookies
```
**Manually** — use [Cookie Editor](https://chromewebstore.google.com/detail/cookie-editor/hlkenndednhfkekhgcdicdfddnkalmdm) and export: `stel_ssid`, `stel_dt`, `stel_token`, `stel_ton_token`. Pass as a `dict` or JSON string.
Refresh cookies when you get authentication errors.
### Tonapi key
Generate at [tonconsole.com](https://tonconsole.com).
### Seed phrase
12- or 24-word mnemonic from your GRAM (ex TON) wallet (**Tonkeeper → Settings → Backup**). Never share it.
---
## Logging
`pyfragment` uses the standard `logging` module under the `pyfragment` namespace and is silent by default:
```python
import logging
logging.basicConfig(level=logging.INFO)
logging.getLogger("pyfragment").setLevel(logging.DEBUG) # for detailed request logs
```
---
## Error Handling
All exceptions inherit from `FragmentError`:
```python
from pyfragment import (
ConfigurationError, # invalid arguments (amount, months, payment_method…)
UserNotFoundError, # recipient not found on Fragment
WalletError, # insufficient GRAM (ex TON) or USDT balance
TransactionError, # broadcast failed, duplicate seqno, invalid payload
FragmentAPIError, # Fragment API returned an error response
FragmentPageError, # page fetch or hash extraction failed
AnonymousNumberError, # number not owned, wrong state, login code issues
CookieError, # missing or malformed session cookies
ParseError, # failed to decode Fragment payload
VerificationError, # on-chain verification step failed
OperationError, # generic operation-level failure
UnexpectedError, # unexpected API response structure
)
```
---
<div align="center">
Made with ❤️ by [@bohd4nx](https://t.me/bohd4nx) · [Contributing](CONTRIBUTING.md) · [Security](SECURITY.md)
**Star ⭐ if you found it useful**
[Changelog](CHANGELOG.md) · [Contributing](CONTRIBUTING.md) · [Security](SECURITY.md)
</div>
+2 -3
View File
@@ -24,15 +24,14 @@ BASE_HEADERS: dict[str, str] = {
"content-type": "application/x-www-form-urlencoded; charset=UTF-8",
"origin": FRAGMENT_BASE_URL,
"priority": "u=1, i",
"sec-ch-ua": '"Google Chrome";v="147", "Not.A/Brand";v="8", "Chromium";v="147"',
"sec-ch-ua": '"Not;A=Brand";v="8", "Chromium";v="150", "Google Chrome";v="150"',
"sec-ch-ua-mobile": "?1",
"sec-ch-ua-platform": '"Android"',
"sec-fetch-dest": "empty",
"sec-fetch-mode": "cors",
"sec-fetch-site": "same-origin",
"user-agent": (
"Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Mobile Safari/537.36"
"Mozilla/5.0 (Linux; Android 15; Pixel 9) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36"
),
"x-requested-with": "XMLHttpRequest",
}
+17 -39
View File
@@ -5,55 +5,47 @@ import random
import re
from typing import Any, cast
import httpx
from curl_cffi.requests import AsyncSession, Response
from pyfragment.core.constants import DEFAULT_TIMEOUT, FRAGMENT_BASE_URL
from pyfragment.exceptions import FragmentPageError, ParseError, VerificationError
from pyfragment.core.constants import FRAGMENT_BASE_URL
from pyfragment.exceptions import FragmentPageError, ParseError
async def get_fragment_hash(
cookies: dict[str, Any],
session: AsyncSession[Any],
headers: dict[str, str],
page_url: str,
timeout: float = DEFAULT_TIMEOUT,
) -> str:
page_headers = {
k: v
for k, v in headers.items()
if k not in ("accept", "accept-encoding", "content-type", "x-requested-with", "x-aj-referer")
}
page_headers.update(
{
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"referer": f"{FRAGMENT_BASE_URL}/",
"sec-fetch-dest": "document",
"sec-fetch-mode": "navigate",
"upgrade-insecure-requests": "1",
}
)
# Derive the natural referer: strip the last path segment (e.g. /stars/buy → /stars)
parent_url = page_url.rsplit("/", 1)[0] or FRAGMENT_BASE_URL
async with httpx.AsyncClient(cookies=cookies, timeout=timeout) as session:
response = await session.get(page_url, headers=page_headers)
page_headers = {k: v for k, v in headers.items() if k not in ("content-type", "origin")}
page_headers["referer"] = parent_url
page_headers["x-aj-referer"] = parent_url
page_headers.pop("x-aj-referer", None)
page_headers.pop("x-requested-with", None)
response = await session.get(page_url, headers=page_headers)
if response.status_code != 200:
raise FragmentPageError(FragmentPageError.BAD_STATUS.format(status=response.status_code, url=page_url))
match = re.search(r"(?:https://fragment\.com)?/api\?hash=([a-f0-9]+)", response.text)
match = re.search(r"(?:https://fragment\.com)?\\\\?/api\?hash=([a-f0-9]+)", response.text)
if not match:
raise FragmentPageError(FragmentPageError.NOT_FOUND.format(url=page_url))
return match.group(1)
def parse_json_response(response: httpx.Response, context: str) -> dict[str, Any]:
def parse_json_response(response: Response, context: str) -> dict[str, Any]:
try:
return cast(dict[str, Any], response.json())
return cast(dict[str, Any], response.json()) # type: ignore[no-untyped-call]
except Exception as exc:
raise ParseError(ParseError.UNPARSEABLE.format(context=context, exc=exc)) from exc
async def fragment_request(
session: httpx.AsyncClient,
session: AsyncSession[Any],
fragment_hash: str,
headers: dict[str, str],
data: dict[str, Any],
@@ -73,17 +65,3 @@ async def fragment_request(
)
return parse_json_response(resp, data.get("method", "request"))
raise FragmentPageError(FragmentPageError.BAD_STATUS.format(status=429, url=f"{FRAGMENT_BASE_URL}/api"))
async def execute_transaction_request(
session: httpx.AsyncClient,
headers: dict[str, str],
tx_data: dict[str, Any],
fragment_hash: str,
) -> dict[str, Any]:
transaction = await fragment_request(session, fragment_hash, headers, tx_data)
if transaction.get("need_verify"):
raise VerificationError(VerificationError.KYC_REQUIRED)
return transaction
+3 -3
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any
import httpx
from curl_cffi.requests import AsyncSession
from pyfragment.core.constants import BASE_HEADERS
from pyfragment.core.transport import fragment_request, get_fragment_hash
@@ -27,8 +27,8 @@ async def raw_api_call(
call_headers = {**base, "referer": page_url, "x-aj-referer": page_url}
logger.debug("Starting Fragment API call '%s' on %s", method, page_url)
try:
async with httpx.AsyncClient(cookies=cookies, timeout=timeout) as session:
fragment_hash = await get_fragment_hash(cookies, call_headers, page_url, timeout)
async with AsyncSession(cookies=cookies, timeout=timeout, impersonate="chrome") as session:
fragment_hash = await get_fragment_hash(session, call_headers, page_url)
response = await fragment_request(session, fragment_hash, call_headers, payload)
logger.debug("Completed Fragment API call '%s' with response keys: %s", method, sorted(response.keys()))
return response
+1 -9
View File
@@ -1,18 +1,10 @@
from __future__ import annotations
import sys
from enum import Enum
from enum import StrEnum
from typing import Any
from tonutils.contracts.wallet import WalletHighloadV2, WalletHighloadV3R1, WalletV4R2, WalletV5R1
if sys.version_info >= (3, 11):
from enum import StrEnum
else:
class StrEnum(str, Enum): # noqa: F811
pass
class PaymentMethod(StrEnum):
GRAM = "ton"
-4
View File
@@ -36,10 +36,6 @@ class ConfigurationError(ClientError):
f"Invalid Stars amount: must be an integer between {STARS_PURCHASE_MIN:,} and {STARS_PURCHASE_MAX:,}."
)
INVALID_GRAM_AMOUNT = f"Invalid GRAM (ex TON) amount: must be an integer between {GRAM_TOPUP_MIN:,} and {GRAM_TOPUP_MAX:,}."
INVALID_USERNAME = (
"Invalid username '{username}'. "
"Must be 5-32 characters and contain only letters (A-Z, a-z), digits (0-9), or underscores (_)."
)
INVALID_WINNERS_STARS = (
f"Invalid winners count: must be an integer between {STARS_WINNERS_MIN:,} and {STARS_WINNERS_MAX:,}."
)
+4 -4
View File
@@ -1,7 +1,7 @@
from __future__ import annotations
import importlib
from datetime import datetime, timezone
from datetime import UTC, datetime
from typing import Any
from pyfragment.core.constants import FRAGMENT_BASE_URL, FRAGMENT_DOMAIN, REQUIRED_COOKIE_KEYS
@@ -42,11 +42,11 @@ def get_cookies_from_browser(browser: str = "chrome") -> CookieResult:
if cookie.get("name") == "stel_ssid":
raw = cookie.get("expires")
if isinstance(raw, (int, float)):
expires_iso = datetime.fromtimestamp(raw, tz=timezone.utc).isoformat()
expires_iso = datetime.fromtimestamp(raw, tz=UTC).isoformat()
elif isinstance(raw, str) and raw:
for fmt in ("%Y-%m-%dT%H:%M:%S.%fZ", "%Y-%m-%dT%H:%M:%SZ"):
try:
expires_iso = datetime.strptime(raw, fmt).replace(tzinfo=timezone.utc).isoformat()
expires_iso = datetime.strptime(raw, fmt).replace(tzinfo=UTC).isoformat()
break
except ValueError:
continue
@@ -54,7 +54,7 @@ def get_cookies_from_browser(browser: str = "chrome") -> CookieResult:
if expires_iso:
expires_dt = datetime.fromisoformat(expires_iso)
if expires_dt < datetime.now(timezone.utc):
if expires_dt < datetime.now(UTC):
raise CookieError(CookieError.EXPIRED.format(expires=expires_iso))
return CookieResult(cookies={k: cookie_map[k] for k in REQUIRED_COOKIE_KEYS}, expires=expires_iso)
+7 -30
View File
@@ -4,62 +4,43 @@ build-backend = "hatchling.build"
[project]
name = "pyfragment"
version = "2026.3.2"
description = "Async Python client for the Fragment API. Buy Stars and Premium, top up GRAM (ex TON) and Ads balances, run giveaways, manage anonymous numbers, and search Fragment listings."
version = "2026.3.3"
description = "Async Python client for the Fragment.com marketplace API."
readme = "README.md"
license = { text = "MIT" }
requires-python = ">=3.10"
requires-python = ">=3.11"
authors = [{ name = "bohd4nx" }]
keywords = [
"fragment",
"fragment-api",
"telegram",
"telegram-api",
"telegram-stars",
"telegram-premium",
"telegram-giveaway",
"telegram-ads",
"ton",
"ton-blockchain",
"tonapi",
"anonymous-numbers",
"username-auctions",
"gift-marketplace",
"crypto-payments",
"nft-marketplace",
"web3",
"python-client",
"typed",
"asyncio",
]
classifiers = [
"Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Natural Language :: English",
"Operating System :: OS Independent",
"Programming Language :: Python",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Framework :: AsyncIO",
"Topic :: Software Development :: Libraries",
"Topic :: Software Development :: Libraries :: Python Modules",
"Topic :: Internet",
"Topic :: Internet :: WWW/HTTP",
"Typing :: Typed",
]
dependencies = ["httpx>=0.25", "tonutils>=2.0.1"]
dependencies = ["curl-cffi>=0.14.0", "tonutils>=2.0.1"]
[project.optional-dependencies]
dev = ["pytest", "pytest-asyncio", "pytest-mock", "mypy", "ruff"]
browser = ["rookiepy>=0.5.6"]
[project.urls]
Homepage = "https://github.com/bohd4nx/pyfragment"
Repository = "https://github.com/bohd4nx/pyfragment"
Documentation = "https://bohd4nx.gitbook.io/pyfragment/"
Issues = "https://github.com/bohd4nx/pyfragment/issues"
@@ -76,27 +57,23 @@ addopts = "-v --tb=short"
[tool.ruff]
line-length = 128
target-version = "py310"
target-version = "py311"
[tool.ruff.lint]
# E — pycodestyle errors, F — pyflakes, W — warnings, I — isort, UP — pyupgrade
select = ["E", "F", "W", "I", "UP"]
# E501 — line too long (covered by line-length above)
# UP017 — use datetime.UTC (only available in Python 3.11+, we support 3.10)
ignore = ["E501", "UP017"]
ignore = ["E501"]
[tool.ruff.lint.per-file-ignores]
"tests/*" = ["E402"]
[tool.mypy]
python_version = "3.10"
python_version = "3.11"
strict = true
exclude = ["^examples/", "^logs/", "^tests/"]
[[tool.mypy.overrides]]
module = [
"rookiepy",
"httpx",
"ton_core",
"tonutils",
"tonutils.*",
+3 -3
View File
@@ -2,8 +2,8 @@
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
from curl_cffi.requests import AsyncSession, Response
from pyfragment import FragmentClient, FragmentPageError
from pyfragment.core.transport import fragment_request
@@ -67,10 +67,10 @@ async def test_call_merges_extra_data(client: FragmentClient) -> None:
@pytest.mark.asyncio
async def test_fragment_request_non_200_raises() -> None:
response = MagicMock(spec=httpx.Response)
response = MagicMock(spec=Response)
response.status_code = 429
session = AsyncMock(spec=httpx.AsyncClient)
session = AsyncMock(spec=AsyncSession)
session.post = AsyncMock(return_value=response)
with pytest.raises(FragmentPageError, match="429"):
-4
View File
@@ -36,10 +36,6 @@ FAKE_BALANCE_NANOGRAM: int = 1_500_000_000 # 1.5 GRAM (ex TON)
# recharge_ads
FAKE_ADS_ACCOUNT: str = "@mychannel"
# Revenue withdrawals
FAKE_WITHDRAWAL_WALLET: str = "EQDxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
FAKE_REVENUE_TX: str = "revenue_tx_abc123"
# Anonymous number
FAKE_HTML_WITH_CODE: str = """
<table>