diff --git a/examples/call.py b/examples/call.py new file mode 100644 index 0000000..e0a6a22 --- /dev/null +++ b/examples/call.py @@ -0,0 +1,36 @@ +""" +Example: send a raw request to any Fragment API method. + +Use client.call() when you need to access a method that is not yet +wrapped by the library, or to inspect raw API responses directly. + +page_url must match the Fragment page the method belongs to — Fragment +requires a hash derived from each specific page. +""" + +import asyncio + +from pyfragment import FragmentClient + +SEED = "word1 word2 ... word24" +API_KEY = "YOUR_TONAPI_KEY" +COOKIES = { + "stel_ssid": "YOUR_STEL_SSID", + "stel_dt": "YOUR_STEL_DT", + "stel_token": "YOUR_STEL_TOKEN", + "stel_ton_token": "YOUR_STEL_TON_TOKEN", +} + +METHOD = "anyFragmentMethod" # replace with the actual method name +DATA = {"key": "value"} # replace with the actual request payload +PAGE_URL = "https://fragment.com/stars/buy" # replace with the matching Fragment page + + +async def main() -> None: + async with FragmentClient(seed=SEED, api_key=API_KEY, cookies=COOKIES) as client: + result = await client.call(METHOD, DATA, page_url=PAGE_URL) + print(result) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/pyfragment/client.py b/pyfragment/client.py index ee6cb33..4fe9ecc 100644 --- a/pyfragment/client.py +++ b/pyfragment/client.py @@ -1,5 +1,7 @@ import json -from typing import cast +from typing import Any, cast + +import httpx from pyfragment.methods.giveaway_premium import giveaway_premium from pyfragment.methods.giveaway_stars import giveaway_stars @@ -14,8 +16,15 @@ from pyfragment.types import ( StarsResult, WalletInfo, ) -from pyfragment.types.constants import DEFAULT_TIMEOUT, REQUIRED_COOKIE_KEYS, SUPPORTED_WALLET_VERSIONS, WalletVersion +from pyfragment.types.constants import ( + DEFAULT_TIMEOUT, + FRAGMENT_BASE_URL, + REQUIRED_COOKIE_KEYS, + SUPPORTED_WALLET_VERSIONS, + WalletVersion, +) from pyfragment.types.results import PremiumGiveawayResult, StarsGiveawayResult +from pyfragment.utils.http import fragment_request, get_fragment_hash, make_headers from pyfragment.utils.wallet import get_wallet_info @@ -187,3 +196,33 @@ class FragmentClient: ``winners``, and ``amount``. """ return await giveaway_premium(self, channel, winners, months) + + async def call( + self, method: str, data: dict[str, Any] | None = None, *, page_url: str = FRAGMENT_BASE_URL + ) -> dict[str, Any]: + """Send a raw request to the Fragment API. + + Useful for accessing undocumented or future Fragment API methods + without waiting for a library update. + + Args: + method: Fragment API method name, e.g. ``"searchPremiumGiftRecipient"``. + data: Additional form-data fields to include in the request body. + page_url: Fragment page URL used to derive the API hash and headers. + Defaults to ``FRAGMENT_BASE_URL`` (``"https://fragment.com"``). + + Returns: + Raw parsed JSON response as a dict. + + Example:: + + result = await client.call( + "searchPremiumGiftRecipient", + {"query": "@username", "months": 3}, + page_url="https://fragment.com/premium/gift", + ) + """ + headers = make_headers(page_url) + async with httpx.AsyncClient(cookies=self.cookies, timeout=self.timeout) as session: + fragment_hash = await get_fragment_hash(self.cookies, headers, page_url, self.timeout) + return await fragment_request(session, fragment_hash, headers, {"method": method, **(data or {})}) diff --git a/pyfragment/types/constants.py b/pyfragment/types/constants.py index dd118bc..b5f64fe 100644 --- a/pyfragment/types/constants.py +++ b/pyfragment/types/constants.py @@ -20,11 +20,12 @@ DEFAULT_TIMEOUT: float = 30.0 REQUIRED_COOKIE_KEYS: tuple[str, ...] = ("stel_ssid", "stel_dt", "stel_token", "stel_ton_token") # Fragment page URLs -STARS_PAGE: str = "https://fragment.com/stars/buy" -STARS_GIVEAWAY_PAGE: str = "https://fragment.com/stars/giveaway" -PREMIUM_PAGE: str = "https://fragment.com/premium/gift" -PREMIUM_GIVEAWAY_PAGE: str = "https://fragment.com/premium/giveaway" -TON_PAGE: str = "https://fragment.com/ads/topup" +FRAGMENT_BASE_URL: str = "https://fragment.com" +STARS_PAGE: str = f"{FRAGMENT_BASE_URL}/stars/buy" +STARS_GIVEAWAY_PAGE: str = f"{FRAGMENT_BASE_URL}/stars/giveaway" +PREMIUM_PAGE: str = f"{FRAGMENT_BASE_URL}/premium/gift" +PREMIUM_GIVEAWAY_PAGE: str = f"{FRAGMENT_BASE_URL}/premium/giveaway" +TON_PAGE: str = f"{FRAGMENT_BASE_URL}/ads/topup" # Tonkeeper device fingerprint — serialized once, reused in every tx_data payload. DEVICE: str = json.dumps( @@ -47,7 +48,7 @@ BASE_HEADERS: dict[str, str] = { "accept": "application/json, text/javascript, */*; q=0.01", "accept-language": "en-US,en;q=0.9,uk;q=0.8,ru;q=0.7", "content-type": "application/x-www-form-urlencoded; charset=UTF-8", - "origin": "https://fragment.com", + "origin": FRAGMENT_BASE_URL, "priority": "u=1, i", "sec-fetch-dest": "empty", "sec-fetch-mode": "cors", diff --git a/pyfragment/utils/http.py b/pyfragment/utils/http.py index 02cb3a6..d21702e 100644 --- a/pyfragment/utils/http.py +++ b/pyfragment/utils/http.py @@ -4,10 +4,10 @@ from typing import Any import httpx from pyfragment.types import FragmentPageError, ParseError, VerificationError -from pyfragment.types.constants import BASE_HEADERS, DEFAULT_TIMEOUT +from pyfragment.types.constants import BASE_HEADERS, DEFAULT_TIMEOUT, FRAGMENT_BASE_URL -def make_headers(page_url: str) -> dict[str, str]: +def make_headers(page_url: str = FRAGMENT_BASE_URL) -> dict[str, str]: return {**BASE_HEADERS, "referer": page_url, "x-aj-referer": page_url} @@ -44,7 +44,7 @@ async def get_fragment_hash( page_headers.update( { "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", - "referer": "https://fragment.com/", + "referer": f"{FRAGMENT_BASE_URL}/", "sec-fetch-dest": "document", "sec-fetch-mode": "navigate", "upgrade-insecure-requests": "1", @@ -105,7 +105,7 @@ async def fragment_request( Parsed API response as a dict. """ resp = await session.post( - f"https://fragment.com/api?hash={fragment_hash}", + f"{FRAGMENT_BASE_URL}/api?hash={fragment_hash}", headers=headers, data=data, ) diff --git a/tests/009_test_call.py b/tests/009_test_call.py new file mode 100644 index 0000000..42be933 --- /dev/null +++ b/tests/009_test_call.py @@ -0,0 +1,66 @@ +"""Unit tests for client.call() — raw Fragment API request.""" + +from unittest.mock import AsyncMock, patch + +import pytest + +from pyfragment import FragmentClient +from tests.shared import FAKE_HASH + +FAKE_RESPONSE = {"status": "ok", "data": {"value": 42}} + + +# client.call() mocked tests + + +@pytest.mark.asyncio +async def test_call_returns_api_response(client: FragmentClient) -> None: + with ( + patch("pyfragment.client.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch("pyfragment.client.fragment_request", AsyncMock(return_value=FAKE_RESPONSE)), + ): + result = await client.call("anyMethod", {"key": "value"}) + + assert result == FAKE_RESPONSE + + +@pytest.mark.asyncio +async def test_call_default_page_url(client: FragmentClient) -> None: + """call() works without explicitly passing page_url (defaults to FRAGMENT_BASE_URL).""" + with ( + patch("pyfragment.client.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch("pyfragment.client.fragment_request", AsyncMock(return_value=FAKE_RESPONSE)), + ): + result = await client.call("anyMethod") + + assert result == FAKE_RESPONSE + + +@pytest.mark.asyncio +async def test_call_no_data(client: FragmentClient) -> None: + """call() with no extra data passes only the method field.""" + mock_request = AsyncMock(return_value={}) + + with ( + patch("pyfragment.client.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch("pyfragment.client.fragment_request", mock_request), + ): + await client.call("anyMethod") + + _, _, _, sent_data = mock_request.call_args.args + assert sent_data == {"method": "anyMethod"} + + +@pytest.mark.asyncio +async def test_call_merges_extra_data(client: FragmentClient) -> None: + """call() merges caller-supplied data with the method field.""" + mock_request = AsyncMock(return_value={}) + + with ( + patch("pyfragment.client.get_fragment_hash", AsyncMock(return_value=FAKE_HASH)), + patch("pyfragment.client.fragment_request", mock_request), + ): + await client.call("anyMethod", {"key": "value", "num": 7}) + + _, _, _, sent_data = mock_request.call_args.args + assert sent_data == {"method": "anyMethod", "key": "value", "num": 7}