mirror of
https://github.com/bohd4nx/FragmentAPI.git
synced 2026-07-25 06:14:29 +00:00
@@ -0,0 +1,58 @@
|
|||||||
|
# Contributing to pyfragment
|
||||||
|
|
||||||
|
## Development setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/bohd4nx/pyfragment.git
|
||||||
|
cd pyfragment
|
||||||
|
pip install -e ".[dev]"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running checks
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Lint and format
|
||||||
|
ruff check . --fix && ruff format .
|
||||||
|
|
||||||
|
# Type check
|
||||||
|
mypy . --explicit-package-bases
|
||||||
|
|
||||||
|
# Tests
|
||||||
|
pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
All three must pass before opening a PR.
|
||||||
|
|
||||||
|
## Project structure
|
||||||
|
|
||||||
|
```
|
||||||
|
pyfragment/
|
||||||
|
client.py — FragmentClient (public entry point)
|
||||||
|
enums.py — ApiProvider, PaymentMethod, WalletVersion
|
||||||
|
exceptions.py — exception hierarchy
|
||||||
|
core/ — constants, validation helpers
|
||||||
|
domains/ — one package per feature domain
|
||||||
|
ads/ — recharge_ads, topup_gram
|
||||||
|
anonymous_numbers/— get_login_code, toggle_login_codes, terminate_sessions
|
||||||
|
giveaways/ — giveaway_stars, giveaway_premium
|
||||||
|
marketplace/ — search_usernames, search_numbers, search_gifts
|
||||||
|
purchases/ — purchase_stars, purchase_premium
|
||||||
|
services/ — shared infrastructure services
|
||||||
|
cookies/ — browser cookie extraction (models + service)
|
||||||
|
tonapi/ — wallet info, transaction signing (tonapi/toncenter)
|
||||||
|
tests/ — unit tests (pytest)
|
||||||
|
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 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`.
|
||||||
|
|
||||||
|
## Pull requests
|
||||||
|
|
||||||
|
- Keep PRs focused — one feature or fix per PR.
|
||||||
|
- Update `CHANGELOG.md` under `[Unreleased]`.
|
||||||
|
- Add or update tests for any changed behaviour.
|
||||||
@@ -46,13 +46,13 @@ async def main() -> None:
|
|||||||
cookies={"stel_ssid": "...", "stel_dt": "...", "stel_token": "...", "stel_ton_token": "..."},
|
cookies={"stel_ssid": "...", "stel_dt": "...", "stel_token": "...", "stel_ton_token": "..."},
|
||||||
) as client:
|
) as client:
|
||||||
wallet = await client.get_wallet()
|
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))
|
||||||
|
|
||||||
stars = await client.purchase_stars("@username", amount=500, payment_method=PaymentMethod.USDT_GRAM)
|
stars = await client.purchase_stars("@username", amount=500, payment_method=PaymentMethod.USDT_GRAM)
|
||||||
print(f"Sent {stars.amount} Stars to {stars.username} | tx: {stars.transaction_id}")
|
print("Sent %s Stars to %s | tx: %s" % (stars.amount, stars.username, stars.transaction_id))
|
||||||
|
|
||||||
premium = await client.purchase_premium("@username", months=6, payment_method=PaymentMethod.GRAM)
|
premium = await client.purchase_premium("@username", months=6, payment_method=PaymentMethod.GRAM)
|
||||||
print(f"Sent Premium {premium.amount}m to {premium.username} | tx: {premium.transaction_id}")
|
print("Sent Premium %sm to %s | tx: %s" % (premium.amount, premium.username, premium.transaction_id))
|
||||||
|
|
||||||
|
|
||||||
asyncio.run(main())
|
asyncio.run(main())
|
||||||
|
|||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
# Security Policy
|
||||||
|
|
||||||
|
## Reporting a vulnerability
|
||||||
|
|
||||||
|
Please **do not** open a public GitHub issue for security vulnerabilities.
|
||||||
|
|
||||||
|
Report them privately via GitHub's [Security Advisory](https://github.com/bohd4nx/pyfragment/security/advisories/new) feature, or contact the maintainer directly at [@bohd4nx](https://t.me/bohd4nx) on Telegram.
|
||||||
|
|
||||||
|
Include:
|
||||||
|
|
||||||
|
- A description of the vulnerability and its potential impact.
|
||||||
|
- Steps to reproduce or a proof-of-concept.
|
||||||
|
- Affected versions.
|
||||||
|
|
||||||
|
You will receive a response within 72 hours. Once the fix is released, the advisory will be published.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This library handles sensitive credentials (GRAM (ex TON) seed phrases, Fragment session cookies, Tonapi keys). Please treat any finding that could expose or misuse these credentials as high severity.
|
||||||
@@ -24,15 +24,14 @@ BASE_HEADERS: dict[str, str] = {
|
|||||||
"content-type": "application/x-www-form-urlencoded; charset=UTF-8",
|
"content-type": "application/x-www-form-urlencoded; charset=UTF-8",
|
||||||
"origin": FRAGMENT_BASE_URL,
|
"origin": FRAGMENT_BASE_URL,
|
||||||
"priority": "u=1, i",
|
"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-mobile": "?1",
|
||||||
"sec-ch-ua-platform": '"Android"',
|
"sec-ch-ua-platform": '"Android"',
|
||||||
"sec-fetch-dest": "empty",
|
"sec-fetch-dest": "empty",
|
||||||
"sec-fetch-mode": "cors",
|
"sec-fetch-mode": "cors",
|
||||||
"sec-fetch-site": "same-origin",
|
"sec-fetch-site": "same-origin",
|
||||||
"user-agent": (
|
"user-agent": (
|
||||||
"Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) "
|
"Mozilla/5.0 (Linux; Android 15; Pixel 9) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36"
|
||||||
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Mobile Safari/537.36"
|
|
||||||
),
|
),
|
||||||
"x-requested-with": "XMLHttpRequest",
|
"x-requested-with": "XMLHttpRequest",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,55 +5,47 @@ import random
|
|||||||
import re
|
import re
|
||||||
from typing import Any, cast
|
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.core.constants import FRAGMENT_BASE_URL
|
||||||
from pyfragment.exceptions import FragmentPageError, ParseError
|
from pyfragment.exceptions import FragmentPageError, ParseError
|
||||||
|
|
||||||
|
|
||||||
async def get_fragment_hash(
|
async def get_fragment_hash(
|
||||||
cookies: dict[str, Any],
|
session: AsyncSession[Any],
|
||||||
headers: dict[str, str],
|
headers: dict[str, str],
|
||||||
page_url: str,
|
page_url: str,
|
||||||
timeout: float = DEFAULT_TIMEOUT,
|
|
||||||
) -> str:
|
) -> str:
|
||||||
page_headers = {
|
# Derive the natural referer: strip the last path segment (e.g. /stars/buy → /stars)
|
||||||
k: v
|
parent_url = page_url.rsplit("/", 1)[0] or FRAGMENT_BASE_URL
|
||||||
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",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
async with httpx.AsyncClient(cookies=cookies, timeout=timeout) as session:
|
page_headers = {k: v for k, v in headers.items() if k not in ("content-type", "origin")}
|
||||||
response = await session.get(page_url, headers=page_headers)
|
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:
|
if response.status_code != 200:
|
||||||
raise FragmentPageError(FragmentPageError.BAD_STATUS.format(status=response.status_code, url=page_url))
|
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:
|
if not match:
|
||||||
raise FragmentPageError(FragmentPageError.NOT_FOUND.format(url=page_url))
|
raise FragmentPageError(FragmentPageError.NOT_FOUND.format(url=page_url))
|
||||||
|
|
||||||
return match.group(1)
|
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:
|
try:
|
||||||
return cast(dict[str, Any], response.json())
|
return cast(dict[str, Any], response.json()) # type: ignore[no-untyped-call]
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise ParseError(ParseError.UNPARSEABLE.format(context=context, exc=exc)) from exc
|
raise ParseError(ParseError.UNPARSEABLE.format(context=context, exc=exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
async def fragment_request(
|
async def fragment_request(
|
||||||
session: httpx.AsyncClient,
|
session: AsyncSession[Any],
|
||||||
fragment_hash: str,
|
fragment_hash: str,
|
||||||
headers: dict[str, str],
|
headers: dict[str, str],
|
||||||
data: dict[str, Any],
|
data: dict[str, Any],
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
import httpx
|
from curl_cffi.requests import AsyncSession
|
||||||
|
|
||||||
from pyfragment.core.constants import BASE_HEADERS
|
from pyfragment.core.constants import BASE_HEADERS
|
||||||
from pyfragment.core.transport import fragment_request, get_fragment_hash
|
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}
|
call_headers = {**base, "referer": page_url, "x-aj-referer": page_url}
|
||||||
logger.debug("Starting Fragment API call '%s' on %s", method, page_url)
|
logger.debug("Starting Fragment API call '%s' on %s", method, page_url)
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(cookies=cookies, timeout=timeout) as session:
|
async with AsyncSession(cookies=cookies, timeout=timeout, impersonate="chrome") as session:
|
||||||
fragment_hash = await get_fragment_hash(cookies, call_headers, page_url, timeout)
|
fragment_hash = await get_fragment_hash(session, call_headers, page_url)
|
||||||
response = await fragment_request(session, fragment_hash, call_headers, payload)
|
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()))
|
logger.debug("Completed Fragment API call '%s' with response keys: %s", method, sorted(response.keys()))
|
||||||
return response
|
return response
|
||||||
|
|||||||
+2
-3
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "pyfragment"
|
name = "pyfragment"
|
||||||
version = "2026.3.2"
|
version = "2026.3.3"
|
||||||
description = "Async Python client for the Fragment.com marketplace API."
|
description = "Async Python client for the Fragment.com marketplace API."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = { text = "MIT" }
|
license = { text = "MIT" }
|
||||||
@@ -34,7 +34,7 @@ classifiers = [
|
|||||||
"Topic :: Software Development :: Libraries :: Python Modules",
|
"Topic :: Software Development :: Libraries :: Python Modules",
|
||||||
"Typing :: Typed",
|
"Typing :: Typed",
|
||||||
]
|
]
|
||||||
dependencies = ["httpx>=0.25", "tonutils>=2.0.1"]
|
dependencies = ["curl-cffi>=0.14.0", "tonutils>=2.0.1"]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = ["pytest", "pytest-asyncio", "pytest-mock", "mypy", "ruff"]
|
dev = ["pytest", "pytest-asyncio", "pytest-mock", "mypy", "ruff"]
|
||||||
@@ -74,7 +74,6 @@ exclude = ["^examples/", "^logs/", "^tests/"]
|
|||||||
[[tool.mypy.overrides]]
|
[[tool.mypy.overrides]]
|
||||||
module = [
|
module = [
|
||||||
"rookiepy",
|
"rookiepy",
|
||||||
"httpx",
|
|
||||||
"ton_core",
|
"ton_core",
|
||||||
"tonutils",
|
"tonutils",
|
||||||
"tonutils.*",
|
"tonutils.*",
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import httpx
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from curl_cffi.requests import AsyncSession, Response
|
||||||
|
|
||||||
from pyfragment import FragmentClient, FragmentPageError
|
from pyfragment import FragmentClient, FragmentPageError
|
||||||
from pyfragment.core.transport import fragment_request
|
from pyfragment.core.transport import fragment_request
|
||||||
@@ -67,10 +67,10 @@ async def test_call_merges_extra_data(client: FragmentClient) -> None:
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_fragment_request_non_200_raises() -> None:
|
async def test_fragment_request_non_200_raises() -> None:
|
||||||
response = MagicMock(spec=httpx.Response)
|
response = MagicMock(spec=Response)
|
||||||
response.status_code = 429
|
response.status_code = 429
|
||||||
|
|
||||||
session = AsyncMock(spec=httpx.AsyncClient)
|
session = AsyncMock(spec=AsyncSession)
|
||||||
session.post = AsyncMock(return_value=response)
|
session.post = AsyncMock(return_value=response)
|
||||||
|
|
||||||
with pytest.raises(FragmentPageError, match="429"):
|
with pytest.raises(FragmentPageError, match="429"):
|
||||||
|
|||||||
Reference in New Issue
Block a user