From fd31342d0306da6d9e0a5fe3849b012fa731865f Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Sun, 5 Jul 2026 16:13:16 +0300 Subject: [PATCH 1/6] Refactor: Replace httpx with curl-cffi for HTTP requests and update dependencies --- pyfragment/core/constants.py | 5 ++--- pyfragment/core/transport.py | 36 +++++++++++++----------------------- pyfragment/domains/base.py | 6 +++--- pyproject.toml | 2 +- tests/008_test_call.py | 6 +++--- 5 files changed, 22 insertions(+), 33 deletions(-) diff --git a/pyfragment/core/constants.py b/pyfragment/core/constants.py index 40253fb..0a8f81f 100644 --- a/pyfragment/core/constants.py +++ b/pyfragment/core/constants.py @@ -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", } diff --git a/pyfragment/core/transport.py b/pyfragment/core/transport.py index 58cfac7..3e4258d 100644 --- a/pyfragment/core/transport.py +++ b/pyfragment/core/transport.py @@ -5,35 +5,25 @@ 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.core.constants import FRAGMENT_BASE_URL from pyfragment.exceptions import FragmentPageError, ParseError async def get_fragment_hash( - cookies: dict[str, Any], + session: AsyncSession[Any], # type: ignore[type-arg] 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 + + 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)) @@ -45,15 +35,15 @@ async def get_fragment_hash( 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], # type: ignore[type-arg] fragment_hash: str, headers: dict[str, str], data: dict[str, Any], diff --git a/pyfragment/domains/base.py b/pyfragment/domains/base.py index 2e02aa7..0b73137 100644 --- a/pyfragment/domains/base.py +++ b/pyfragment/domains/base.py @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 4af9914..334088a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,7 @@ classifiers = [ "Topic :: Software Development :: Libraries :: Python Modules", "Typing :: Typed", ] -dependencies = ["httpx>=0.25", "tonutils>=2.0.1"] +dependencies = ["curl-cffi>=0.7", "tonutils>=2.0.1"] [project.optional-dependencies] dev = ["pytest", "pytest-asyncio", "pytest-mock", "mypy", "ruff"] diff --git a/tests/008_test_call.py b/tests/008_test_call.py index 17c42a1..4297526 100644 --- a/tests/008_test_call.py +++ b/tests/008_test_call.py @@ -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"): From 2bd8cc177ac040f07acabbbd437d4ac1965cb525 Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Sun, 5 Jul 2026 16:16:14 +0300 Subject: [PATCH 2/6] chore: update version to 2026.3.3 and dependencies; improve string formatting in transport functions --- README.md | 6 +++--- pyfragment/core/transport.py | 4 ++-- pyproject.toml | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 35de889..c0fd664 100644 --- a/README.md +++ b/README.md @@ -46,13 +46,13 @@ async def main() -> None: 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)) 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) - 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()) diff --git a/pyfragment/core/transport.py b/pyfragment/core/transport.py index 3e4258d..4a6fe83 100644 --- a/pyfragment/core/transport.py +++ b/pyfragment/core/transport.py @@ -12,7 +12,7 @@ from pyfragment.exceptions import FragmentPageError, ParseError async def get_fragment_hash( - session: AsyncSession[Any], # type: ignore[type-arg] + session: AsyncSession[Any], headers: dict[str, str], page_url: str, ) -> str: @@ -43,7 +43,7 @@ def parse_json_response(response: Response, context: str) -> dict[str, Any]: async def fragment_request( - session: AsyncSession[Any], # type: ignore[type-arg] + session: AsyncSession[Any], fragment_hash: str, headers: dict[str, str], data: dict[str, Any], diff --git a/pyproject.toml b/pyproject.toml index 334088a..0addc3f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "pyfragment" -version = "2026.3.2" +version = "2026.3.3" description = "Async Python client for the Fragment.com marketplace API." readme = "README.md" license = { text = "MIT" } @@ -34,7 +34,7 @@ classifiers = [ "Topic :: Software Development :: Libraries :: Python Modules", "Typing :: Typed", ] -dependencies = ["curl-cffi>=0.7", "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"] From f46551c905f5420d698204419dedd996a6b99741 Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Sun, 5 Jul 2026 16:18:40 +0300 Subject: [PATCH 3/6] Refactor: remove httpx from mypy overrides in pyproject.toml --- pyproject.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 0addc3f..44684d2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,7 +74,6 @@ exclude = ["^examples/", "^logs/", "^tests/"] [[tool.mypy.overrides]] module = [ "rookiepy", - "httpx", "ton_core", "tonutils", "tonutils.*", From 0a361c8c3a20b15f5c15605bde1fa0e31c659ee1 Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Sun, 5 Jul 2026 16:25:23 +0300 Subject: [PATCH 4/6] chore: add CONTRIBUTING and SECURITY guidelines to improve project documentation --- CONTRIBUTING.md | 58 +++++++++++++++++++++++++++++++++++++++++++++++++ SECURITY.md | 19 ++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 CONTRIBUTING.md create mode 100644 SECURITY.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..3387819 --- /dev/null +++ b/CONTRIBUTING.md @@ -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. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..1800853 --- /dev/null +++ b/SECURITY.md @@ -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. From b11b9f299b200c9281ebecce2257a22763d81c90 Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Sun, 5 Jul 2026 16:47:26 +0300 Subject: [PATCH 5/6] fix: correct regex pattern in get_fragment_hash function to properly match fragment hash --- pyfragment/core/transport.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyfragment/core/transport.py b/pyfragment/core/transport.py index 4a6fe83..b52051c 100644 --- a/pyfragment/core/transport.py +++ b/pyfragment/core/transport.py @@ -28,7 +28,7 @@ async def get_fragment_hash( 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)) From 27ce28fd32a806786e57e001083049b831291228 Mon Sep 17 00:00:00 2001 From: bohd4nx Date: Mon, 6 Jul 2026 11:27:10 +0300 Subject: [PATCH 6/6] fix: correct regex pattern in get_fragment_hash function to properly escape backslashes --- pyfragment/core/transport.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pyfragment/core/transport.py b/pyfragment/core/transport.py index b52051c..aae6efe 100644 --- a/pyfragment/core/transport.py +++ b/pyfragment/core/transport.py @@ -22,13 +22,15 @@ async def get_fragment_hash( 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))