mirror of
https://github.com/bohd4nx/FragmentAPI.git
synced 2026-07-26 06:41:42 +00:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f7225b9d16 | |||
| 27ce28fd32 | |||
| b11b9f299b | |||
| 0a361c8c3a | |||
| f46551c905 | |||
| 2bd8cc177a | |||
| fd31342d03 | |||
| d78be012a3 | |||
| ef831d12a0 |
@@ -12,11 +12,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7.0.1
|
||||
|
||||
- uses: actions/setup-python@v6
|
||||
- uses: actions/setup-python@v7.0.0
|
||||
with:
|
||||
python-version: "3.10"
|
||||
python-version: "3.11"
|
||||
cache: pip
|
||||
|
||||
- run: pip install ".[dev]"
|
||||
@@ -30,12 +30,12 @@ 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
|
||||
- uses: actions/checkout@v7.0.1
|
||||
|
||||
- uses: actions/setup-python@v6
|
||||
- uses: actions/setup-python@v7.0.0
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: pip
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
is-new: ${{ steps.tag.outputs.is-new }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -42,17 +42,17 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7.0.1
|
||||
|
||||
- uses: actions/setup-python@v6
|
||||
- uses: actions/setup-python@v7.0.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- uses: astral-sh/setup-uv@v8.2.0
|
||||
- uses: astral-sh/setup-uv@v9.0.0
|
||||
|
||||
- run: uv build
|
||||
|
||||
- uses: actions/upload-artifact@v7
|
||||
- uses: actions/upload-artifact@v7.0.1
|
||||
with:
|
||||
name: dist
|
||||
path: dist/*
|
||||
@@ -68,7 +68,7 @@ jobs:
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- uses: actions/download-artifact@v8
|
||||
- uses: actions/download-artifact@v8.0.1
|
||||
with:
|
||||
name: dist
|
||||
path: dist
|
||||
@@ -83,15 +83,25 @@ jobs:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- uses: actions/download-artifact@v8
|
||||
- uses: actions/checkout@v7.0.1
|
||||
|
||||
- uses: actions/download-artifact@v8.0.1
|
||||
with:
|
||||
name: dist
|
||||
path: dist
|
||||
|
||||
- uses: softprops/action-gh-release@v3
|
||||
- name: Extract latest changelog entry
|
||||
id: changelog
|
||||
run: |
|
||||
body=$(awk '/^## \[/{if(found) exit; found=1; next} found{print}' CHANGELOG.md)
|
||||
echo "body<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "$body" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- uses: softprops/action-gh-release@v3.0.2
|
||||
with:
|
||||
tag_name: v${{ needs.version-check.outputs.version }}
|
||||
name: v${{ needs.version-check.outputs.version }}
|
||||
files: dist/*
|
||||
generate_release_notes: true
|
||||
body: ${{ steps.changelog.outputs.body }}
|
||||
make_latest: true
|
||||
|
||||
+29
-29
@@ -1,43 +1,43 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# Virtual environments
|
||||
.venv/
|
||||
# Virtual Environment
|
||||
venv/
|
||||
.venv/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
|
||||
# System files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Testing & tooling artifacts
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
systests/
|
||||
|
||||
# Build & distribution
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
uv.lock
|
||||
|
||||
#docs
|
||||
*-docs/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
uv.lock
|
||||
|
||||
+1
-1
@@ -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`.
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<div align="center">
|
||||
<img src="https://www.bohd4n.dev/assets/projects/pyfragment.svg" alt="pyfragment" width="96" height="96" style="border-radius: 20px;"><br><br>
|
||||
<img src="icon.svg" alt="pyfragment" width="96" height="96" style="border-radius: 20px;"><br><br>
|
||||
|
||||
# pyfragment
|
||||
|
||||
@@ -9,18 +9,14 @@
|
||||
[](https://github.com/bohd4nx/pyfragment/actions)
|
||||
[](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>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 512 512" width="512" height="512" style="width: 100%; height: 100%; transform: translate3d(0,0,0); content-visibility: visible;" preserveAspectRatio="xMidYMid meet"><defs><clipPath id="__lottie_element_2"><rect width="512" height="512" x="0" y="0"></rect></clipPath><clipPath id="__lottie_element_4"><path d="M0,0 L100,0 L100,100 L0,100z"></path></clipPath></defs><g clip-path="url(#__lottie_element_2)"><g clip-path="url(#__lottie_element_4)" style="display: block;" transform="matrix(5.119999885559082,0,0,5.119999885559082,0,0)" opacity="1"><g style="display: block;" transform="matrix(1.333299994468689,0,0,1.333299994468689,0,0)" opacity="1"><g opacity="1" transform="matrix(1,0,0,1,0,0)"><g opacity="1" transform="matrix(1,0,0,1,0,0)"><path fill="rgb(30,40,51)" fill-opacity="1" d=" M47.31999969482422,5.619999885559082 C47.31999969482422,5.619999885559082 27.68000030517578,5.619999885559082 27.68000030517578,5.619999885559082 C15.5,5.619999885559082 5.619999885559082,15.5 5.619999885559082,27.68000030517578 C5.619999885559082,27.68000030517578 5.619999885559082,47.31999969482422 5.619999885559082,47.31999969482422 C5.619999885559082,59.5 15.5,69.37999725341797 27.68000030517578,69.37999725341797 C27.68000030517578,69.37999725341797 47.31999969482422,69.37999725341797 47.31999969482422,69.37999725341797 C59.5,69.37999725341797 69.37999725341797,59.5 69.37999725341797,47.31999969482422 C69.37999725341797,47.31999969482422 69.37999725341797,27.68000030517578 69.37999725341797,27.68000030517578 C69.37999725341797,15.5 59.5,5.619999885559082 47.31999969482422,5.619999885559082 C47.31999969482422,5.619999885559082 47.31999969482422,5.619999885559082 47.31999969482422,5.619999885559082z"></path></g><g opacity="1" transform="matrix(1,0,0,1,0,0)"><path fill="rgb(255,255,255)" fill-opacity="1" d=" M36.349998474121094,32.79999923706055 C36.349998474121094,32.79999923706055 21.1299991607666,25.940000534057617 21.1299991607666,25.940000534057617 C20.020000457763672,25.450000762939453 20.389999389648438,23.790000915527344 21.600000381469727,23.790000915527344 C21.600000381469727,23.790000915527344 53.41999816894531,23.790000915527344 53.41999816894531,23.790000915527344 C54.630001068115234,23.790000915527344 54.9900016784668,25.440000534057617 53.880001068115234,25.940000534057617 C53.880001068115234,25.940000534057617 38.66999816894531,32.79999923706055 38.66999816894531,32.79999923706055 C37.939998626708984,33.130001068115234 37.09000015258789,33.130001068115234 36.36000061035156,32.79999923706055 C36.36000061035156,32.79999923706055 36.349998474121094,32.79999923706055 36.349998474121094,32.79999923706055z M56.81999969482422,30.06999969482422 C57.43000030517578,29.1200008392334 56.43000030517578,27.979999542236328 55.400001525878906,28.440000534057617 C55.400001525878906,28.440000534057617 40.72999954223633,35.13999938964844 40.72999954223633,35.13999938964844 C39.72999954223633,35.599998474121094 39.09000015258789,36.61000061035156 39.09000015258789,37.70000076293945 C39.09000015258789,37.70000076293945 39.09000015258789,53.810001373291016 39.09000015258789,53.810001373291016 C39.09000015258789,54.93000030517578 40.54999923706055,55.36000061035156 41.15999984741211,54.41999816894531 C41.15999984741211,54.41999816894531 56.810001373291016,30.06999969482422 56.810001373291016,30.06999969482422 C56.810001373291016,30.06999969482422 56.81999969482422,30.06999969482422 56.81999969482422,30.06999969482422z M19.600000381469727,28.440000534057617 C18.579999923706055,27.979999542236328 17.56999969482422,29.1200008392334 18.18000030517578,30.06999969482422 C18.18000030517578,30.06999969482422 33.84000015258789,54.43000030517578 33.84000015258789,54.43000030517578 C34.45000076293945,55.380001068115234 35.90999984741211,54.939998626708984 35.90999984741211,53.81999969482422 C35.90999984741211,53.81999969482422 35.90999984741211,37.70000076293945 35.90999984741211,37.70000076293945 C35.90999984741211,36.599998474121094 35.27000045776367,35.599998474121094 34.27000045776367,35.13999938964844 C34.27000045776367,35.13999938964844 19.59000015258789,28.450000762939453 19.59000015258789,28.450000762939453 C19.59000015258789,28.450000762939453 19.600000381469727,28.440000534057617 19.600000381469727,28.440000534057617z"></path></g></g></g></g></g></svg>
|
||||
|
After Width: | Height: | Size: 4.3 KiB |
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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,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
@@ -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"
|
||||
|
||||
@@ -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:,}."
|
||||
)
|
||||
|
||||
@@ -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
@@ -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.*",
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user