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/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/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. 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..aae6efe 100644 --- a/pyfragment/core/transport.py +++ b/pyfragment/core/transport.py @@ -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.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], 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..44684d2 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 = ["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"] @@ -74,7 +74,6 @@ exclude = ["^examples/", "^logs/", "^tests/"] [[tool.mypy.overrides]] module = [ "rookiepy", - "httpx", "ton_core", "tonutils", "tonutils.*", 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"):