feat: enhance cookie handling by introducing CookieResult and updating get_cookies_from_browser to return structured results

This commit is contained in:
bohd4nx
2026-04-14 01:25:25 +03:00
parent 269551da2f
commit 391d15221b
17 changed files with 99 additions and 37 deletions
-1
View File
@@ -29,7 +29,6 @@ jobs:
runs-on: ubuntu-latest
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
COOKIES_JSON: ${{ secrets.COOKIES_JSON }}
strategy:
fail-fast: false
+2
View File
@@ -13,6 +13,7 @@ from pyfragment.types import (
ClientError,
ConfigurationError,
CookieError,
CookieResult,
FragmentAPIError,
FragmentError,
FragmentPageError,
@@ -55,6 +56,7 @@ __all__ = [
"ClientError",
"ConfigurationError",
"CookieError",
"CookieResult",
"FragmentAPIError",
"FragmentError",
"FragmentPageError",
+2 -1
View File
@@ -21,7 +21,9 @@ from pyfragment.types import (
GiftsResult,
LoginCodeResult,
NumbersResult,
PremiumGiveawayResult,
PremiumResult,
StarsGiveawayResult,
StarsResult,
TerminateSessionsResult,
UsernamesResult,
@@ -34,7 +36,6 @@ from pyfragment.types.constants import (
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
+8 -2
View File
@@ -1,9 +1,15 @@
import html
from typing import TYPE_CHECKING
from pyfragment.types import AnonymousNumberError, FragmentAPIError, FragmentError, UnexpectedError
from pyfragment.types import (
AnonymousNumberError,
FragmentAPIError,
FragmentError,
LoginCodeResult,
TerminateSessionsResult,
UnexpectedError,
)
from pyfragment.types.constants import NUMBERS_PAGE
from pyfragment.types.results import LoginCodeResult, TerminateSessionsResult
from pyfragment.utils import parse_login_code
if TYPE_CHECKING:
+1 -1
View File
@@ -5,12 +5,12 @@ from pyfragment.types import (
ConfigurationError,
FragmentAPIError,
FragmentError,
PremiumGiveawayResult,
UnexpectedError,
UserNotFoundError,
VerificationError,
)
from pyfragment.types.constants import DEVICE, PREMIUM_GIVEAWAY_PAGE
from pyfragment.types.results import PremiumGiveawayResult
from pyfragment.utils import get_account_info, process_transaction
if TYPE_CHECKING:
+1 -1
View File
@@ -5,12 +5,12 @@ from pyfragment.types import (
ConfigurationError,
FragmentAPIError,
FragmentError,
StarsGiveawayResult,
UnexpectedError,
UserNotFoundError,
VerificationError,
)
from pyfragment.types.constants import DEVICE, STARS_GIVEAWAY_PAGE
from pyfragment.types.results import StarsGiveawayResult
from pyfragment.utils import get_account_info, process_transaction
if TYPE_CHECKING:
+1 -1
View File
@@ -2,6 +2,7 @@ import json
from typing import TYPE_CHECKING
from pyfragment.types import (
AdsRechargeResult,
ConfigurationError,
FragmentAPIError,
FragmentError,
@@ -9,7 +10,6 @@ from pyfragment.types import (
VerificationError,
)
from pyfragment.types.constants import ADS_TOPUP_PAGE, DEVICE
from pyfragment.types.results import AdsRechargeResult
from pyfragment.utils import get_account_info, process_transaction
if TYPE_CHECKING:
+1 -2
View File
@@ -1,8 +1,7 @@
from typing import TYPE_CHECKING, Any
from pyfragment.types import FragmentAPIError, FragmentError, UnexpectedError
from pyfragment.types import FragmentAPIError, FragmentError, GiftsResult, UnexpectedError
from pyfragment.types.constants import GIFTS_PAGE
from pyfragment.types.results import GiftsResult
from pyfragment.utils import parse_gift_items
if TYPE_CHECKING:
+1 -2
View File
@@ -1,8 +1,7 @@
from typing import TYPE_CHECKING, Any
from pyfragment.types import FragmentAPIError, FragmentError, UnexpectedError
from pyfragment.types import FragmentAPIError, FragmentError, NumbersResult, UnexpectedError
from pyfragment.types.constants import NUMBERS_PAGE
from pyfragment.types.results import NumbersResult
from pyfragment.utils import parse_auction_rows
if TYPE_CHECKING:
+1 -2
View File
@@ -1,8 +1,7 @@
from typing import TYPE_CHECKING, Any
from pyfragment.types import FragmentAPIError, FragmentError, UnexpectedError
from pyfragment.types import FragmentAPIError, FragmentError, UnexpectedError, UsernamesResult
from pyfragment.types.constants import FRAGMENT_BASE_URL
from pyfragment.types.results import UsernamesResult
from pyfragment.utils import parse_auction_rows
if TYPE_CHECKING:
+2
View File
@@ -17,6 +17,7 @@ from pyfragment.types.exceptions import (
from pyfragment.types.results import (
AdsRechargeResult,
AdsTopupResult,
CookieResult,
GiftsResult,
LoginCodeResult,
NumbersResult,
@@ -49,6 +50,7 @@ __all__ = [
# result types
"AdsRechargeResult",
"AdsTopupResult",
"CookieResult",
"GiftsResult",
"LoginCodeResult",
"NumbersResult",
+1 -1
View File
@@ -54,7 +54,7 @@ DEVICE: str = json.dumps(
{
"platform": "iphone",
"appName": "Tonkeeper",
"appVersion": "5.5.2",
"appVersion": "26.04.0",
"maxProtocolVersion": 2,
"features": [
"SendTransaction",
+17
View File
@@ -2,6 +2,23 @@ from dataclasses import dataclass
from typing import Any
@dataclass
class CookieResult:
"""Result returned by :func:`~pyfragment.utils.get_cookies_from_browser`.
Attributes:
cookies: Dict with the four required Fragment cookie keys.
expires: Expiry of the ``stel_ssid`` session cookie in ISO 8601 format (UTC),
or ``None`` for session cookies.
"""
cookies: dict[str, str]
expires: str | None
def __repr__(self) -> str:
return f"CookieResult(expires={self.expires!r})"
@dataclass
class WalletInfo:
"""Wallet state returned by :meth:`FragmentClient.get_wallet`."""
+2 -1
View File
@@ -1,4 +1,4 @@
from pyfragment.utils.cookies import get_cookies_from_browser
from pyfragment.utils.cookies import CookieResult, get_cookies_from_browser
from pyfragment.utils.decoder import clean_decode
from pyfragment.utils.html import parse_auction_rows, parse_gift_items, parse_login_code
from pyfragment.utils.http import (
@@ -12,6 +12,7 @@ from pyfragment.utils.wallet import get_account_info, process_transaction
__all__ = [
"clean_decode",
"CookieResult",
"get_cookies_from_browser",
"parse_auction_rows",
"parse_gift_items",
+26 -6
View File
@@ -1,16 +1,19 @@
from __future__ import annotations
from datetime import datetime, timezone
import rookiepy
from pyfragment.types import CookieError
from pyfragment.types import CookieError, CookieResult
from pyfragment.types.constants import FRAGMENT_BASE_URL, FRAGMENT_DOMAIN, REQUIRED_COOKIE_KEYS, SUPPORTED_BROWSERS
def get_cookies_from_browser(browser: str = "chrome") -> dict[str, str]:
def get_cookies_from_browser(browser: str = "chrome") -> CookieResult:
"""Extract Fragment session cookies directly from an installed browser.
Reads the browser's on-disk cookie store (no extension required) and
returns the four cookies required by :class:`~pyfragment.FragmentClient`.
returns the four cookies required by :class:`~pyfragment.FragmentClient`
along with the session expiry timestamp.
Args:
browser: Browser name to read cookies from — case-insensitive. Supported values:
@@ -19,8 +22,7 @@ def get_cookies_from_browser(browser: str = "chrome") -> dict[str, str]:
``"firefox_based"``, ``"vivaldi"``, ``"librewolf"``, ``"safari"``.
Returns:
A dict with the four required Fragment cookie keys:
``stel_ssid``, ``stel_dt``, ``stel_token``, ``stel_ton_token``.
:class:`CookieResult` with ``.cookies`` (dict) and ``.expires`` (ISO 8601 string or ``None``).
Raises:
CookieError: If the browser is not supported, cookies cannot be read,
@@ -42,4 +44,22 @@ def get_cookies_from_browser(browser: str = "chrome") -> dict[str, str]:
if missing:
raise CookieError(CookieError.MISSING_BROWSER_KEYS.format(browser=browser, keys=missing, url=FRAGMENT_BASE_URL))
return {k: cookie_map[k] for k in REQUIRED_COOKIE_KEYS}
expires_iso: str | None = None
for cookie in jar:
if cookie.get("name") == "stel_ssid":
raw = cookie.get("expires")
if isinstance(raw, (int, float)):
expires_iso = datetime.fromtimestamp(raw, tz=timezone.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()
break
except ValueError:
continue
break
return CookieResult(
cookies={k: cookie_map[k] for k in REQUIRED_COOKIE_KEYS},
expires=expires_iso,
)
+1 -2
View File
@@ -7,9 +7,8 @@ from ton_core import NetworkGlobalID
from tonutils.clients import TonapiClient
from tonutils.exceptions import ProviderResponseError
from pyfragment.types import TransactionError, WalletError
from pyfragment.types import TransactionError, WalletError, WalletInfo
from pyfragment.types.constants import MIN_TON_BALANCE, WALLET_CLASSES
from pyfragment.types.results import WalletInfo
from pyfragment.utils.decoder import clean_decode
if TYPE_CHECKING:
+32 -14
View File
@@ -9,7 +9,7 @@ from pyfragment.types.constants import REQUIRED_COOKIE_KEYS
from pyfragment.utils import get_cookies_from_browser
FAKE_JAR = [
{"name": "stel_ssid", "value": "abc123", "domain": "fragment.com"},
{"name": "stel_ssid", "value": "abc123", "domain": "fragment.com", "expires": "2027-04-03T20:52:16.375Z"},
{"name": "stel_dt", "value": "-120", "domain": "fragment.com"},
{"name": "stel_token", "value": "tok_xyz", "domain": "fragment.com"},
{"name": "stel_ton_token", "value": "ton_xyz", "domain": "fragment.com"},
@@ -23,6 +23,9 @@ def _mock_rookiepy(jar: list[dict] | None = None) -> MagicMock:
return mock
PATCH = "pyfragment.utils.cookies.rookiepy"
# unsupported browser tests
@@ -35,20 +38,35 @@ def test_unsupported_browser_raises() -> None:
def test_returns_required_keys_only() -> None:
with patch.dict("sys.modules", {"rookiepy": _mock_rookiepy()}):
with patch(PATCH, _mock_rookiepy()):
result = get_cookies_from_browser("chrome")
assert set(result.keys()) == set(REQUIRED_COOKIE_KEYS)
assert result["stel_ssid"] == "abc123"
assert result["stel_dt"] == "-120"
assert result["stel_token"] == "tok_xyz"
assert result["stel_ton_token"] == "ton_xyz"
assert "unrelated" not in result
assert set(result.cookies.keys()) == set(REQUIRED_COOKIE_KEYS)
assert result.cookies["stel_ssid"] == "abc123"
assert result.cookies["stel_dt"] == "-120"
assert result.cookies["stel_token"] == "tok_xyz"
assert result.cookies["stel_ton_token"] == "ton_xyz"
assert "unrelated" not in result.cookies
def test_returns_expiry() -> None:
with patch(PATCH, _mock_rookiepy()):
result = get_cookies_from_browser("chrome")
assert result.expires == "2027-04-03T20:52:16.375000+00:00"
def test_returns_none_expiry_when_missing() -> None:
jar = [{"name": k, "value": "v"} for k in REQUIRED_COOKIE_KEYS]
with patch(PATCH, _mock_rookiepy(jar)):
result = get_cookies_from_browser("chrome")
assert result.expires is None
def test_default_browser_is_chrome() -> None:
mock_rp = _mock_rookiepy()
with patch.dict("sys.modules", {"rookiepy": mock_rp}):
with patch(PATCH, mock_rp):
get_cookies_from_browser()
mock_rp.chrome.assert_called_once_with(["fragment.com"])
@@ -56,10 +74,10 @@ def test_default_browser_is_chrome() -> None:
def test_browser_name_is_case_insensitive() -> None:
mock_rp = _mock_rookiepy()
with patch.dict("sys.modules", {"rookiepy": mock_rp}):
with patch(PATCH, mock_rp):
result = get_cookies_from_browser("Chrome")
assert result["stel_ssid"] == "abc123"
assert result.cookies["stel_ssid"] == "abc123"
# missing cookies tests
@@ -71,7 +89,7 @@ def test_missing_cookies_raises() -> None:
{"name": "stel_dt", "value": "-120"},
# stel_token and stel_ton_token missing
]
with patch.dict("sys.modules", {"rookiepy": _mock_rookiepy(partial_jar)}):
with patch(PATCH, _mock_rookiepy(partial_jar)):
with pytest.raises(CookieError, match="Fragment cookies not found in chrome"):
get_cookies_from_browser("chrome")
@@ -83,7 +101,7 @@ def test_empty_cookie_value_treated_as_missing() -> None:
{"name": "stel_token", "value": "tok_xyz"},
{"name": "stel_ton_token", "value": "ton_xyz"},
]
with patch.dict("sys.modules", {"rookiepy": _mock_rookiepy(jar_with_empty)}):
with patch(PATCH, _mock_rookiepy(jar_with_empty)):
with pytest.raises(CookieError, match="Fragment cookies not found in chrome"):
get_cookies_from_browser("chrome")
@@ -94,6 +112,6 @@ def test_empty_cookie_value_treated_as_missing() -> None:
def test_browser_read_error_raises() -> None:
mock_rp = MagicMock()
mock_rp.chrome.side_effect = PermissionError("locked")
with patch.dict("sys.modules", {"rookiepy": mock_rp}):
with patch(PATCH, mock_rp):
with pytest.raises(CookieError, match="Failed to read chrome cookies"):
get_cookies_from_browser("chrome")