diff --git a/README.md b/README.md index a102dcb..8defa5c 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ pip install xhshow ## 使用方法 -### 基本用法 +### 基本用法(推荐) ```python from xhshow import Xhshow @@ -31,21 +31,71 @@ import requests client = Xhshow() -# GET请求签名 -signature = client.sign_xs_get( - uri="https://edith.xiaohongshu.com/api/sns/web/v1/user_posted", # v0.1.3及后续版本支持自动提取uri - # uri="/api/sns/web/v1/user_posted" # v0.1.2及以前版本需要主动提取uri - a1_value="your_a1_cookie_value", +# 准备 cookies(支持字典或字符串格式) +cookies = { + "a1": "your_a1_value", + "web_session": "your_web_session", + "webId": "your_web_id" +} +# 或使用 Cookie 字符串格式: +# cookies = "a1=your_a1_value; web_session=your_web_session; webId=your_web_id" + +# 注意: uri 参数可以传递完整 URL 或 URI 路径,会自动提取 URI +headers = client.sign_headers_get( + uri="https://edith.xiaohongshu.com/api/sns/web/v1/user_posted", # 完整 URL(推荐) + # uri="/api/sns/web/v1/user_posted", # 或者只传 URI 路径 + cookies=cookies, # 传入完整 cookies params={"num": "30", "cursor": "", "user_id": "123"} ) -# POST请求签名 -signature = client.sign_xs_post( +# 返回的 headers 包含以下字段: +# { +# "x-s": "XYS_...", +# "x-s-common": "...", +# "x-t": "1234567890", +# "x-b3-traceid": "...", +# "x-xray-traceid": "..." +# } + +# 方式1: 使用 update 方法更新现有 headers(推荐) +base_headers = { + "User-Agent": "Mozilla/5.0...", + "Content-Type": "application/json" +} +base_headers.update(headers) +response = requests.get( + "https://edith.xiaohongshu.com/api/sns/web/v1/user_posted", + params={"num": "30", "cursor": "", "user_id": "123"}, + headers=base_headers, + cookies=cookies +) + +# 方式2: 使用 ** 解包创建新 headers +response = requests.get( + "https://edith.xiaohongshu.com/api/sns/web/v1/user_posted", + params={"num": "30", "cursor": "", "user_id": "123"}, + headers={ + "User-Agent": "Mozilla/5.0...", + "Content-Type": "application/json", + **headers # 解包签名 headers(会创建新字典) + }, + cookies=cookies +) + +# POST 请求示例:使用 sign_headers_post +headers_post = client.sign_headers_post( uri="https://edith.xiaohongshu.com/api/sns/web/v1/login", - a1_value="your_a1_cookie_value", + cookies=cookies, payload={"username": "test", "password": "123456"} ) +response = requests.post( + "https://edith.xiaohongshu.com/api/sns/web/v1/login", + json={"username": "test", "password": "123456"}, + headers={**base_headers, **headers_post}, + cookies=cookies +) + # 构建符合xhs平台的GET请求链接 full_url = client.build_url( base_url="https://edith.xiaohongshu.com/api/sns/web/v1/user_posted", @@ -60,6 +110,95 @@ json_body = client.build_json_body( response = requests.post(url, data=json_body, headers=headers, cookies=cookies) ``` +
+传统方法(单独生成各个字段) + +```python +from xhshow import Xhshow +import requests + +client = Xhshow() + +# 注意: sign_headers_* 系列方法使用 cookies 参数 +# 而 sign_xs_* 系列方法使用 a1_value 参数 + +# 使用统一方法 sign_headers(需要手动指定 method) +cookies = {"a1": "your_a1_value", "web_session": "..."} +headers = client.sign_headers( + method="GET", # 或 "POST" + uri="https://edith.xiaohongshu.com/api/sns/web/v1/user_posted", + cookies=cookies, # sign_headers 使用 cookies 参数 + params={"num": "30"} # GET 请求使用 params,POST 请求使用 payload +) + +# GET 请求签名(使用 sign_xs_get - 只需要 a1_value) +x_s = client.sign_xs_get( + uri="https://edith.xiaohongshu.com/api/sns/web/v1/user_posted", # v0.1.3及后续版本支持自动提取uri + # uri="/api/sns/web/v1/user_posted" # v0.1.2及以前版本需要主动提取uri + a1_value="your_a1_cookie_value", # sign_xs_* 系列使用 a1_value + params={"num": "30", "cursor": "", "user_id": "123"} +) + +# POST 请求签名(使用 sign_xs_post - 只需要 a1_value) +x_s = client.sign_xs_post( + uri="https://edith.xiaohongshu.com/api/sns/web/v1/login", + a1_value="your_a1_cookie_value", # sign_xs_* 系列使用 a1_value + payload={"username": "test", "password": "123456"} +) + +# 生成其他 headers 字段 +x_t = client.get_x_t() # 时间戳(毫秒) +x_b3_traceid = client.get_b3_trace_id() # 16位随机 trace id +x_xray_traceid = client.get_xray_trace_id() # 32位 trace id + +# 手动构建 headers +headers = { + "x-s": x_s, + "x-t": str(x_t), + "x-b3-traceid": x_b3_traceid, + "x-xray-traceid": x_xray_traceid +} + +# 使用统一时间戳(可选,确保所有字段使用相同时间) +import time +timestamp = time.time() + +x_s = client.sign_xs_get( + uri="/api/sns/web/v1/user_posted", + a1_value="your_a1_cookie_value", + params={"num": "30"}, + timestamp=timestamp # 传入统一时间戳 +) +x_t = client.get_x_t(timestamp=timestamp) +x_xray_traceid = client.get_xray_trace_id(timestamp=int(timestamp * 1000)) + +# 生成 x-s-common 签名 +# x-s-common 签名需要完整的 cookies +cookies = { + "a1": "your_a1_value", + "web_session": "your_web_session", + "webId": "your_web_id" +} + +# 方式1: 使用 sign_xsc 别名方法(推荐) +xs_common = client.sign_xsc(cookie_dict=cookies) + +# 方式2: 使用完整方法名 +xs_common = client.sign_xs_common(cookie_dict=cookies) + +# 方式3: 支持 Cookie 字符串格式 +cookie_string = "a1=your_a1_value; web_session=your_web_session; webId=your_web_id" +xs_common = client.sign_xsc(cookie_dict=cookie_string) + +# 使用在请求中 +headers = { + "x-s-common": xs_common, + # ... 其他 headers +} +``` + +
+ ### 解密签名 ```python @@ -87,10 +226,25 @@ client = Xhshow(config=custom_config) ## 参数说明 -- `uri`: 请求URI(去除https域名和查询参数) -- `a1_value`: cookie中的a1值 +### **sign_headers** 系列方法(推荐使用) +- `uri`: 请求 URI 或完整 URL(会自动提取 URI) +- `cookies`: 完整的 cookie 字典或 cookie 字符串 + - 字典格式: `{"a1": "...", "web_session": "...", "webId": "..."}` + - 字符串格式: `"a1=...; web_session=...; webId=..."` +- `xsec_appid`: 应用标识符,默认为 `xhs-pc-web` +- `params`: GET 请求参数(仅在 method="GET" 时使用) +- `payload`: POST 请求参数(仅在 method="POST" 时使用) +- `timestamp`: 可选的统一时间戳(秒) + +### **sign_xs** 系列方法 +- `uri`: 请求 URI 或完整 URL +- `a1_value`: cookie 中的 a1 值(字符串) - `xsec_appid`: 应用标识符,默认为 `xhs-pc-web` -- `params/payload`: 请求参数(GET用params,POST用payload) +- `params/payload`: 请求参数(GET 用 params,POST 用 payload) +- `timestamp`: 可选的统一时间戳(秒) + +### **sign_xsc** 系列方法 +- `cookie_dict`: 完整的 cookie 字典或 cookie 字符串 ## 开发环境 diff --git a/pyproject.toml b/pyproject.toml index f33adce..6495970 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,9 @@ classifiers = [ "Topic :: Security :: Cryptography", "Typing :: Typed", ] -dependencies = [] +dependencies = [ + "pycryptodome>=3.23.0", +] [project.optional-dependencies] dev = [ diff --git a/src/xhshow/client.py b/src/xhshow/client.py index e813e72..139881f 100644 --- a/src/xhshow/client.py +++ b/src/xhshow/client.py @@ -1,14 +1,18 @@ import hashlib import json +import time from typing import Any, Literal from .config import CryptoConfig +from .core.common_sign import XsCommonSigner from .core.crypto import CryptoProcessor +from .utils.random_gen import RandomGenerator from .utils.url_utils import build_url, extract_uri from .utils.validators import ( validate_get_signature_params, validate_post_signature_params, validate_signature_params, + validate_xs_common_params, ) __all__ = ["Xhshow"] @@ -20,6 +24,7 @@ class Xhshow: def __init__(self, config: CryptoConfig | None = None): self.config = config or CryptoConfig() self.crypto_processor = CryptoProcessor(self.config) + self.random_generator = RandomGenerator() def _build_content_string(self, method: str, uri: str, payload: dict[str, Any] | None = None) -> str: """ @@ -67,6 +72,7 @@ def _build_signature( a1_value: str, xsec_appid: str = "xhs-pc-web", string_param: str = "", + timestamp: float | None = None, ) -> str: """ Build signature @@ -76,11 +82,14 @@ def _build_signature( a1_value: a1 value from cookies xsec_appid: Application identifier string_param: String parameter + timestamp: Unix timestamp in seconds (defaults to current time) Returns: str: Base64 encoded signature """ - payload_array = self.crypto_processor.build_payload_array(d_value, a1_value, xsec_appid, string_param) + payload_array = self.crypto_processor.build_payload_array( + d_value, a1_value, xsec_appid, string_param, timestamp + ) xor_result = self.crypto_processor.bit_ops.xor_transform_array(payload_array) @@ -94,6 +103,7 @@ def sign_xs( a1_value: str, xsec_appid: str = "xhs-pc-web", payload: dict[str, Any] | None = None, + timestamp: float | None = None, ) -> str: """ Generate request signature (supports GET and POST) @@ -109,6 +119,7 @@ def sign_xs( payload: Request parameters - GET request: params value - POST request: payload value + timestamp: Unix timestamp in seconds (defaults to current time) Returns: str: Complete signature string @@ -125,12 +136,29 @@ def sign_xs( d_value = self._generate_d_value(content_string) signature_data["x3"] = self.crypto_processor.config.X3_PREFIX + self._build_signature( - d_value, a1_value, xsec_appid, content_string + d_value, a1_value, xsec_appid, content_string, timestamp ) return self.crypto_processor.config.XYS_PREFIX + self.crypto_processor.b64encoder.encode( json.dumps(signature_data, separators=(",", ":"), ensure_ascii=False) ) + def sign_xs_common( + self, + cookie_dict: dict[str, Any] | str, + ) -> str: + """ + Generate x-s-common signature + + Args: + cookie_dict: Complete cookie dictionary or cookie string + + Returns: + Encoded x-s-common signature string + """ + parsed_cookies = self._parse_cookies(cookie_dict) + signer = XsCommonSigner(self.config) + return signer.sign(parsed_cookies) + @validate_get_signature_params def sign_xs_get( self, @@ -138,6 +166,7 @@ def sign_xs_get( a1_value: str, xsec_appid: str = "xhs-pc-web", params: dict[str, Any] | None = None, + timestamp: float | None = None, ) -> str: """ Generate GET request signature (convenience method) @@ -149,6 +178,7 @@ def sign_xs_get( a1_value: a1 value from cookies xsec_appid: Application identifier, defaults to `xhs-pc-web` params: GET request parameters + timestamp: Unix timestamp in seconds (defaults to current time) Returns: str: Complete signature string @@ -157,7 +187,7 @@ def sign_xs_get( TypeError: Parameter type error ValueError: Parameter value error """ - return self.sign_xs("GET", uri, a1_value, xsec_appid, params) + return self.sign_xs("GET", uri, a1_value, xsec_appid, payload=params, timestamp=timestamp) @validate_post_signature_params def sign_xs_post( @@ -166,6 +196,7 @@ def sign_xs_post( a1_value: str, xsec_appid: str = "xhs-pc-web", payload: dict[str, Any] | None = None, + timestamp: float | None = None, ) -> str: """ Generate POST request signature (convenience method) @@ -177,6 +208,7 @@ def sign_xs_post( a1_value: a1 value from cookies xsec_appid: Application identifier, defaults to `xhs-pc-web` payload: POST request body data + timestamp: Unix timestamp in seconds (defaults to current time) Returns: str: Complete signature string @@ -185,7 +217,27 @@ def sign_xs_post( TypeError: Parameter type error ValueError: Parameter value error """ - return self.sign_xs("POST", uri, a1_value, xsec_appid, payload) + return self.sign_xs("POST", uri, a1_value, xsec_appid, payload=payload, timestamp=timestamp) + + @validate_xs_common_params + def sign_xsc( + self, + cookie_dict: dict[str, Any] | str, + ) -> str: + """ + Convenience wrapper to generate the `x-s-common` signature. + + Args: + cookie_dict: Enter your complete cookie dictionary + + Returns: + Encoded signature string suitable for the `x-s-common` header. + + Raises: + TypeError: Parameter type error + ValueError: Parameter value error + """ + return self.sign_xs_common(cookie_dict) def decode_x3(self, x3_signature: str) -> bytearray: """ @@ -264,3 +316,222 @@ def build_json_body(self, payload: dict[str, Any]) -> str: '{"username":"test","password":"123456"}' """ return json.dumps(payload, separators=(",", ":"), ensure_ascii=False) + + def get_b3_trace_id(self) -> str: + """ + Generate x-b3-traceid for HTTP request headers + + Returns: + str: 16-character hexadecimal trace ID + + Examples: + >>> client = Xhshow() + >>> client.get_b3_trace_id() + '63cd207ddeb2e360' + """ + return self.random_generator.generate_b3_trace_id() + + def get_xray_trace_id(self, timestamp: int | None = None, seq: int | None = None) -> str: + """ + Generate x-xray-traceid for HTTP request headers + + Args: + timestamp: Unix timestamp in milliseconds (defaults to current time) + seq: Sequence number 0 to 2^23-1 (defaults to random value) + + Returns: + str: 32-character hexadecimal trace ID + + Examples: + >>> client = Xhshow() + >>> client.get_xray_trace_id() + 'cd7621e82d9c24e90bfd937d92bbbd1b' + >>> client.get_xray_trace_id(timestamp=1764896636081, seq=5) + 'cd7604be588000051a7fb8ae74496a76' + """ + return self.random_generator.generate_xray_trace_id(timestamp, seq) + + def get_x_t(self, timestamp: float | None = None) -> int: + """ + Generate x-t header value (Unix timestamp in milliseconds) + + Args: + timestamp: Unix timestamp in seconds (defaults to current time) + + Returns: + int: Unix timestamp in milliseconds + + Examples: + >>> client = Xhshow() + >>> client.get_x_t() + 1764902784843 + >>> client.get_x_t(timestamp=1764896636.081) + 1764896636081 + """ + if timestamp is None: + timestamp = time.time() + return int(timestamp * 1000) + + def _parse_cookies(self, cookies: dict[str, Any] | str) -> dict[str, Any]: + """ + Parse cookies to dict format + + Args: + cookies: Cookie dictionary or cookie string + + Returns: + dict: Parsed cookie dictionary + """ + if isinstance(cookies, str): + from http.cookies import SimpleCookie + + ck = SimpleCookie() + ck.load(cookies) + return {k: morsel.value for k, morsel in ck.items()} + return cookies + + def sign_headers( + self, + method: Literal["GET", "POST"], + uri: str, + cookies: dict[str, Any] | str, + xsec_appid: str = "xhs-pc-web", + params: dict[str, Any] | None = None, + payload: dict[str, Any] | None = None, + timestamp: float | None = None, + ) -> dict[str, str]: + """ + Generate complete request headers with signature and trace IDs + + Args: + method: Request method ("GET" or "POST") + uri: Request URI or full URL + cookies: Complete cookie dictionary or cookie string + xsec_appid: Application identifier, defaults to `xhs-pc-web` + params: GET request parameters (only used when method="GET") + payload: POST request body data (only used when method="POST") + timestamp: Unix timestamp in seconds (defaults to current time) + + Returns: + dict: Complete headers including x-s, x-s-common, x-t, x-b3-traceid, x-xray-traceid + + Examples: + >>> client = Xhshow() + >>> cookies = {"a1": "your_a1_value", "web_session": "..."} + >>> # GET request + >>> headers = client.sign_headers( + ... method="GET", + ... uri="/api/sns/web/v1/user_posted", + ... cookies=cookies, + ... params={"num": "30"} + ... ) + >>> # POST request + >>> headers = client.sign_headers( + ... method="POST", + ... uri="/api/sns/web/v1/login", + ... cookies=cookies, + ... payload={"username": "test"} + ... ) + >>> headers.keys() + dict_keys(['x-s', 'x-s-common', 'x-t', 'x-b3-traceid', 'x-xray-traceid']) + """ + if timestamp is None: + timestamp = time.time() + + method_upper = method.upper() + + # Validate method and parameters + if method_upper == "GET": + if payload is not None: + raise ValueError("GET requests must use 'params', not 'payload'") + request_data = params + elif method_upper == "POST": + if params is not None: + raise ValueError("POST requests must use 'payload', not 'params'") + request_data = payload + else: + raise ValueError(f"Unsupported method: {method}") + + cookie_dict = self._parse_cookies(cookies) + + a1_value = cookie_dict.get("a1") + if not a1_value: + raise ValueError("Missing 'a1' in cookies") + + x_s = self.sign_xs(method_upper, uri, a1_value, xsec_appid, request_data, timestamp) + x_s_common = self.sign_xs_common(cookie_dict) + x_t = self.get_x_t(timestamp) + x_b3_traceid = self.get_b3_trace_id() + x_xray_traceid = self.get_xray_trace_id(timestamp=int(timestamp * 1000)) + + return { + "x-s": x_s, + "x-s-common": x_s_common, + "x-t": str(x_t), + "x-b3-traceid": x_b3_traceid, + "x-xray-traceid": x_xray_traceid, + } + + def sign_headers_get( + self, + uri: str, + cookies: dict[str, Any] | str, + xsec_appid: str = "xhs-pc-web", + params: dict[str, Any] | None = None, + timestamp: float | None = None, + ) -> dict[str, str]: + """ + Generate complete request headers for GET request (convenience method) + + Args: + uri: Request URI or full URL + cookies: Complete cookie dictionary or cookie string + xsec_appid: Application identifier, defaults to `xhs-pc-web` + params: GET request parameters + timestamp: Unix timestamp in seconds (defaults to current time) + + Returns: + dict: Complete headers including x-s, x-s-common, x-t, x-b3-traceid, x-xray-traceid + + Examples: + >>> client = Xhshow() + >>> cookies = {"a1": "your_a1_value", "web_session": "..."} + >>> headers = client.sign_headers_get( + ... uri="/api/sns/web/v1/user_posted", + ... cookies=cookies, + ... params={"num": "30"} + ... ) + """ + return self.sign_headers("GET", uri, cookies, xsec_appid, params=params, timestamp=timestamp) + + def sign_headers_post( + self, + uri: str, + cookies: dict[str, Any] | str, + xsec_appid: str = "xhs-pc-web", + payload: dict[str, Any] | None = None, + timestamp: float | None = None, + ) -> dict[str, str]: + """ + Generate complete request headers for POST request (convenience method) + + Args: + uri: Request URI or full URL + cookies: Complete cookie dictionary or cookie string + xsec_appid: Application identifier, defaults to `xhs-pc-web` + payload: POST request body data + timestamp: Unix timestamp in seconds (defaults to current time) + + Returns: + dict: Complete headers including x-s, x-s-common, x-t, x-b3-traceid, x-xray-traceid + + Examples: + >>> client = Xhshow() + >>> cookies = {"a1": "your_a1_value", "web_session": "..."} + >>> headers = client.sign_headers_post( + ... uri="/api/sns/web/v1/login", + ... cookies=cookies, + ... payload={"username": "test", "password": "123456"} + ... ) + """ + return self.sign_headers("POST", uri, cookies, xsec_appid, payload=payload, timestamp=timestamp) diff --git a/src/xhshow/config/config.py b/src/xhshow/config/config.py index 7af6954..0ec2ec0 100644 --- a/src/xhshow/config/config.py +++ b/src/xhshow/config/config.py @@ -8,9 +8,18 @@ class CryptoConfig: """Configuration constants for cryptographic operations""" + # Gid encrypt parameters + DES_KEY = "zbp30y86" + GID_URL = "https://as.xiaohongshu.com/api/sec/v1/shield/webprofile" + DATA_PALTFORM = "Windows" + DATA_SVN = "2" + DATA_SDK_VERSION = "4.2.6" + DATA_webBuild = "5.0.3" + # Bitwise operation constants MAX_32BIT: int = 0xFFFFFFFF MAX_SIGNED_32BIT: int = 0x7FFFFFFF + MAX_BYTE: int = 255 # Base64 encoding constants STANDARD_BASE64_ALPHABET: str = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" @@ -61,6 +70,41 @@ class CryptoConfig: X3_PREFIX: str = "mns0301_" XYS_PREFIX: str = "XYS_" + # Trace ID generation constants + HEX_CHARS: str = "abcdef0123456789" + XRAY_TRACE_ID_SEQ_MAX: int = 8388607 # 2^23-1 + XRAY_TRACE_ID_TIMESTAMP_SHIFT: int = 23 + XRAY_TRACE_ID_PART1_LENGTH: int = 16 + XRAY_TRACE_ID_PART2_LENGTH: int = 16 + B3_TRACE_ID_LENGTH: int = 16 + + # b1 secret key + B1_SECRET_KEY: str = "xhswebmplfbt" + + SIGNATURE_XSCOMMON_TEMPLATE: dict[str, Any] = field( + default_factory=lambda: { + "s0": 5, + "s1": "", + "x0": "1", + "x1": "4.2.6", + "x2": "Windows", + "x3": "xhs-pc-web", + "x4": "4.86.0", + "x5": "", + "x6": "", + "x7": "", + "x8": "", + "x9": -596800761, + "x10": 0, + "x11": "normal", + } + ) + + PUBLIC_USERAGENT: str = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/142.0.0.0 Safari/537.36 Edg/142.0.0.0" + ) + def with_overrides(self, **kwargs: Any) -> "CryptoConfig": """ Create a new config instance with overridden values diff --git a/src/xhshow/core/common_sign.py b/src/xhshow/core/common_sign.py new file mode 100644 index 0000000..2787a09 --- /dev/null +++ b/src/xhshow/core/common_sign.py @@ -0,0 +1,49 @@ +"""x-s-common signature generation""" + +import json +from typing import Any + +from ..config import CryptoConfig +from ..core.crc32_encrypt import CRC32 +from ..generators.fingerprint import FingerprintGenerator +from ..utils.encoder import Base64Encoder + +__all__ = ["XsCommonSigner"] + + +class XsCommonSigner: + """Generate x-s-common signatures""" + + def __init__(self, config: CryptoConfig | None = None): + self.config = config or CryptoConfig() + self._fp_generator = FingerprintGenerator(self.config) + self._encoder = Base64Encoder(self.config) + + def sign(self, cookie_dict: dict[str, Any]) -> str: + """ + Generate x-s-common signature + + Args: + cookie_dict: Cookie dictionary (must be dict, not string) + + Returns: + x-s-common signature string + + Raises: + KeyError: If 'a1' cookie is missing + """ + a1_value = cookie_dict["a1"] + fingerprint = self._fp_generator.generate(cookies=cookie_dict, user_agent=self.config.PUBLIC_USERAGENT) + b1 = self._fp_generator.generate_b1(fingerprint) + + x9 = CRC32.crc32_js_int(b1) + + sign_struct = dict(self.config.SIGNATURE_XSCOMMON_TEMPLATE) + sign_struct["x5"] = a1_value + sign_struct["x8"] = b1 + sign_struct["x9"] = x9 + + sign_json = json.dumps(sign_struct, separators=(",", ":"), ensure_ascii=False) + xs_common = self._encoder.encode(sign_json) + + return xs_common diff --git a/src/xhshow/core/crc32_encrypt.py b/src/xhshow/core/crc32_encrypt.py new file mode 100644 index 0000000..e74650f --- /dev/null +++ b/src/xhshow/core/crc32_encrypt.py @@ -0,0 +1,124 @@ +""" +Custom CRC32 helper wrapped in a class. + +Main entry: + CRC32.crc32_js_int(data) + +This implements a JavaScript-style CRC32 variant compatible with: + + (-1 ^ c ^ 0xEDB88320) >>> 0 + +where `c` is the intermediate CRC state produced by the core CRC32 loop. +""" + +from __future__ import annotations + +from collections.abc import Iterable + +DataLike = str | bytes | bytearray | memoryview | Iterable[int] +__all__ = ["CRC32"] + + +class CRC32: + """CRC32 calculator with a JS-compatible static entry.""" + + MASK32: int = 0xFFFFFFFF + POLY: int = 0xEDB88320 + _TABLE: list[int] | None = None + + @classmethod + def _ensure_table(cls) -> None: + """Lazy-init CRC32 lookup table once.""" + if cls._TABLE is not None: + return + + tbl = [0] * 256 + for d in range(256): + r = d + for _ in range(8): + r = ((r >> 1) ^ cls.POLY) if (r & 1) else (r >> 1) + r &= cls.MASK32 + tbl[d] = r + cls._TABLE = tbl + + @classmethod + def _crc32_core(cls, data: DataLike, *, string_mode: str = "js") -> int: + """ + Core CRC32 state update (no final NOT/XOR). + + Args: + data: + - str: interpreted depending on ``string_mode``: + * "js": lower 8 bits of ord(char) (JS charCodeAt behavior) + * "utf8": UTF-8 encode string to bytes first + - bytes / bytearray / memoryview: used as-is + - Iterable[int]: each value will be ``& 0xFF`` and treated as a byte + string_mode: How to handle string input ("js" or "utf8"). + + Returns: + Intermediate CRC state `c` (before final bitwise NOT / XOR). + """ + cls._ensure_table() + assert cls._TABLE is not None # for type checkers + + c = cls.MASK32 + + if isinstance(data, bytes | bytearray | memoryview): + it = bytes(data) + elif isinstance(data, str): + if string_mode.lower() == "utf8": + it = data.encode("utf-8") + else: # "js" mode: charCodeAt & 0xFF + it = (ord(ch) & 0xFF for ch in data) + else: + it = ((int(b) & 0xFF) for b in data) + + for b in it: + c = (cls._TABLE[((c & 0xFF) ^ b) & 0xFF] ^ (c >> 8)) & cls.MASK32 + + return c + + @staticmethod + def _to_signed32(u: int) -> int: + """ + Convert an unsigned 32-bit int to signed 32-bit representation. + + Args: + u: Unsigned 32-bit integer (0..0xFFFFFFFF). + + Returns: + Signed 32-bit integer in range [-2^31, 2^31-1]. + """ + return u - 0x100000000 if (u & 0x80000000) else u + + @classmethod + def crc32_js_int( + cls, + data: DataLike, + *, + string_mode: str = "js", + signed: bool = True, + ) -> int: + """ + JavaScript-style CRC32 public entry. + + This matches the JS expression: + + (-1 ^ c ^ 0xEDB88320) >>> 0 + + where `c` is the intermediate CRC state from `_crc32_core`. + + Args: + data: Input data (str/bytes/iterable of ints). + string_mode: How to treat string input ("js" or "utf8"). + signed: + If True, return signed 32-bit integer; + If False, return unsigned 32-bit integer (0..0xFFFFFFFF). + + Returns: + CRC32 value as 32-bit integer (signed or unsigned). + """ + c = cls._crc32_core(data, string_mode=string_mode) + a = cls.POLY + u = ((cls.MASK32 ^ c) ^ a) & cls.MASK32 # (-1 ^ c ^ a) >>> 0 + return cls._to_signed32(u) if signed else u diff --git a/src/xhshow/core/crypto.py b/src/xhshow/core/crypto.py index 7693cd2..8d3be5e 100644 --- a/src/xhshow/core/crypto.py +++ b/src/xhshow/core/crypto.py @@ -56,6 +56,7 @@ def build_payload_array( a1_value: str, app_identifier: str = "xhs-pc-web", string_param: str = "", + timestamp: float | None = None, ) -> list[int]: """ Build payload array (t.js version - exact match) @@ -65,6 +66,7 @@ def build_payload_array( a1_value (str): a1 value from cookies app_identifier (str): Application identifier, default "xhs-pc-web" string_param (str): String parameter (used for URI length calculation) + timestamp (float | None): Unix timestamp in seconds (defaults to current time) Returns: list[int]: Complete payload byte array (124 bytes) @@ -78,7 +80,8 @@ def build_payload_array( payload.extend(seed_bytes) seed_byte_0 = seed_bytes[0] - timestamp = time.time() + if timestamp is None: + timestamp = time.time() payload.extend(self.env_fingerprint_a(int(timestamp * 1000), self.config.ENV_FINGERPRINT_XOR_KEY)) time_offset = self.random_gen.generate_random_byte_in_range( diff --git a/src/xhshow/data/__init__.py b/src/xhshow/data/__init__.py new file mode 100644 index 0000000..9dd696c --- /dev/null +++ b/src/xhshow/data/__init__.py @@ -0,0 +1,25 @@ +"""Data constants module""" + +from .fingerprint_data import ( + BROWSER_PLUGINS, + CANVAS_HASH, + COLOR_DEPTH_OPTIONS, + CORE_OPTIONS, + DEVICE_MEMORY_OPTIONS, + FONTS, + GPU_VENDORS, + SCREEN_RESOLUTIONS, + VOICE_HASH_OPTIONS, +) + +__all__ = [ + "GPU_VENDORS", + "SCREEN_RESOLUTIONS", + "COLOR_DEPTH_OPTIONS", + "DEVICE_MEMORY_OPTIONS", + "CORE_OPTIONS", + "BROWSER_PLUGINS", + "CANVAS_HASH", + "VOICE_HASH_OPTIONS", + "FONTS", +] diff --git a/src/xhshow/data/fingerprint_data.py b/src/xhshow/data/fingerprint_data.py new file mode 100644 index 0000000..a3197ed --- /dev/null +++ b/src/xhshow/data/fingerprint_data.py @@ -0,0 +1,133 @@ +"""Browser fingerprint data constants""" + +from typing import Final + +__all__ = [ + "GPU_VENDORS", + "SCREEN_RESOLUTIONS", + "COLOR_DEPTH_OPTIONS", + "DEVICE_MEMORY_OPTIONS", + "CORE_OPTIONS", + "BROWSER_PLUGINS", + "CANVAS_HASH", + "VOICE_HASH_OPTIONS", + "FONTS", +] + +GPU_VENDORS: Final[list[str]] = [ + "Google Inc. (Intel)|ANGLE (Intel, Intel(R) HD Graphics 400 (0x00000166) Direct3D11 vs_5_0 ps_5_0, D3D11)", + "Google Inc. (Intel)|ANGLE (Intel, Intel(R) HD Graphics 4400 (0x00001112) Direct3D11 vs_5_0 ps_5_0, D3D11)", + "Google Inc. (Intel)|ANGLE (Intel, Intel(R) HD Graphics 4600 (0x00000412) Direct3D11 vs_5_0 ps_5_0, D3D11)", + "Google Inc. (Intel)|ANGLE (Intel, Intel(R) HD Graphics 520 (0x1912) Direct3D11 vs_5_0 ps_5_0, D3D11)", + "Google Inc. (Intel)|ANGLE (Intel, Intel(R) HD Graphics 530 (0x00001912) Direct3D11 vs_5_0 ps_5_0, D3D11)", + "Google Inc. (Intel)|ANGLE (Intel, Intel(R) HD Graphics 550 (0x00001512) Direct3D11 vs_5_0 ps_5_0, D3D11)", + "Google Inc. (Intel)|ANGLE (Intel, Intel(R) HD Graphics 6000 (0x1606) Direct3D11 vs_5_0 ps_5_0, D3D11)", + "Google Inc. (Intel)|ANGLE (Intel, Intel(R) Iris(TM) Graphics 540 (0x1912) Direct3D11 vs_5_0 ps_5_0, D3D11)", # noqa: E501 + "Google Inc. (Intel)|ANGLE (Intel, Intel(R) Iris(TM) Graphics 550 (0x1913) Direct3D11 vs_5_0 ps_5_0, D3D11)", # noqa: E501 + "Google Inc. (Intel)|ANGLE (Intel, Intel(R) Iris(TM) Plus Graphics 640 (0x161C) Direct3D11 vs_5_0 ps_5_0, D3D11)", # noqa: E501 + "Google Inc. (Intel)|ANGLE (Intel, Intel(R) UHD Graphics 600 (0x3E80) Direct3D11 vs_5_0 ps_5_0, D3D11)", + "Google Inc. (Intel)|ANGLE (Intel, Intel(R) UHD Graphics 620 (0x00003EA0) Direct3D11 vs_5_0 ps_5_0, D3D11)", + "Google Inc. (Intel)|ANGLE (Intel, Intel(R) UHD Graphics 630 (0x00003E9B) Direct3D11 vs_5_0 ps_5_0, D3D11)", + "Google Inc. (Intel)|ANGLE (Intel, Intel(R) UHD Graphics 655 (0x00009BC8) Direct3D11 vs_5_0 ps_5_0, D3D11)", + "Google Inc. (Intel)|ANGLE (Intel, Intel(R) Iris(R) Xe Graphics (0x000046A8) Direct3D11 vs_5_0 ps_5_0, D3D11)", # noqa: E501 + "Google Inc. (Intel)|ANGLE (Intel, Intel(R) Iris(R) Xe Graphics (0x00009A49) Direct3D11 vs_5_0 ps_5_0, D3D11)", # noqa: E501 + "Google Inc. (Intel)|ANGLE (Intel, Intel(R) Iris(R) Xe MAX Graphics (0x00009BC0) Direct3D11 vs_5_0 ps_5_0, D3D11)", # noqa: E501 + "Google Inc. (Intel)|ANGLE (Intel, Intel Arc A370M (0x0000AF51) Direct3D11 vs_5_0 ps_5_0, D3D11)", + "Google Inc. (Intel)|ANGLE (Intel, Intel Arc A380 (0x0000AF41) Direct3D11 vs_5_0 ps_5_0, D3D11)", + "Google Inc. (Intel)|ANGLE (Intel, Intel Arc A380M (0x0000AF5E) Direct3D11 vs_5_0 ps_5_0, D3D11)", + "Google Inc. (Intel)|ANGLE (Intel, Intel Arc A550 (0x0000AF42) Direct3D11 vs_5_0 ps_5_0, D3D11)", + "Google Inc. (Intel)|ANGLE (Intel, Intel Arc A770 (0x0000AF43) Direct3D11 vs_5_0 ps_5_0, D3D11)", + "Google Inc. (Intel)|ANGLE (Intel, Intel Arc A770M (0x0000AF50) Direct3D11 vs_5_0 ps_5_0, D3D11)", + "Google Inc. (Intel)|ANGLE (Intel, Mesa Intel(R) Graphics (RPL‑P GT1) (0x0000A702) OpenGL 4.6)", + "Google Inc. (Intel)|ANGLE (Intel, Mesa Intel(R) UHD Graphics 770 (0x00004680) OpenGL 4.6)", + "Google Inc. (Intel)|ANGLE (Intel, Mesa Intel(R) HD Graphics 4400 (0x00001122) OpenGL 4.6)", + "Google Inc. (Intel)|ANGLE (Intel, Mesa Intel(R) Graphics (ADL‑S GT1) (0x0000A0A1) OpenGL 4.6)", + "Google Inc. (Intel)|ANGLE (Intel, Mesa Intel(R) Graphics (RKL GT1) (0x0000A9A1) OpenGL 4.6)", + "Google Inc. (Intel)|ANGLE (Intel, Mesa Intel(R) UHD Graphics (CML GT2) (0x00009A14) OpenGL 4.6)", + "Google Inc. (Intel)|ANGLE (Intel, Intel(R) HD Graphics 3000 (0x00001022) Direct3D9Ex vs_3_0 ps_3_0, igdumd64.dll)", # noqa: E501 + "Google Inc. (Intel)|ANGLE (Intel, Intel(R) HD Graphics Family (0x00000A16) Direct3D11 vs_5_0 ps_5_0, D3D11)", # noqa: E501 + "Google Inc. (Intel)|ANGLE (Intel, Intel(R) Iris Pro OpenGL Engine, OpenGL 4.1)", + "Google Inc. (Intel)|ANGLE (Intel, Intel(R) Iris(TM) Plus Graphics 645 (0x1616) Direct3D11 vs_5_0 ps_5_0, D3D11)", # noqa: E501 + "Google Inc. (Intel)|ANGLE (Intel, Intel(R) Iris(TM) Plus Graphics 655 (0x161E) Direct3D11 vs_5_0 ps_5_0, D3D11)", # noqa: E501 + "Google Inc. (Intel)|ANGLE (Intel, Intel(R) UHD Graphics 730 (0x0000A100) Direct3D11 vs_5_0 ps_5_0, D3D11)", + "Google Inc. (Intel)|ANGLE (Intel, Intel(R) UHD Graphics 805 (0x0000B0A0) Direct3D11 vs_5_0 ps_5_0, D3D11)", + "Google Inc. (AMD)|ANGLE (AMD, AMD Radeon Vega 3 Graphics (0x000015E0) Direct3D11 vs_5_0 ps_5_0, D3D11)", + "Google Inc. (AMD)|ANGLE (AMD, AMD Radeon Vega 8 Graphics (0x000015D8) Direct3D11 vs_5_0 ps_5_0, D3D11)", + "Google Inc. (AMD)|ANGLE (AMD, AMD Radeon Vega 11 Graphics (0x000015DD) Direct3D11 vs_5_0 ps_5_0, D3D11)", + "Google Inc. (AMD)|ANGLE (AMD, AMD Radeon Graphics (0x00001636) Direct3D11 vs_5_0 ps_5_0, D3D11)", + "Google Inc. (AMD)|ANGLE (AMD, AMD Radeon RX 5500 XT Direct3D11 vs_5_0 ps_5_0, D3D11)", + "Google Inc. (AMD)|ANGLE (AMD, AMD Radeon RX 560 (0x000067EF) Direct3D11 vs_5_0 ps_5_0, D3D11)", + "Google Inc. (AMD)|ANGLE (AMD, AMD Radeon RX 570 (0x000067DF) Direct3D11 vs_5_0 ps_5_0, D3D11)", + "Google Inc. (AMD)|ANGLE (AMD, AMD Radeon RX 580 2048SP (0x00006FDF) Direct3D11 vs_5_0 ps_5_0, D3D11)", + "Google Inc. (AMD)|ANGLE (AMD, AMD Radeon RX 590 (0x000067FF) Direct3D11 vs_5_0 ps_5_0, D3D11)", + "Google Inc. (AMD)|ANGLE (AMD, AMD Radeon RX 6600 (0x000073FF) Direct3D11 vs_5_0 ps_5_0, D3D11)", + "Google Inc. (AMD)|ANGLE (AMD, AMD Radeon RX 6600 XT (0x000073FF) Direct3D11 vs_5_0 ps_5_0, D3D11)", + "Google Inc. (AMD)|ANGLE (AMD, AMD Radeon RX 6650 XT Direct3D11 vs_5_0 ps_5_0, D3D11)", + "Google Inc. (AMD)|ANGLE (AMD, AMD Radeon RX 6700 XT (0x000073DF) Direct3D11 vs_5_0 ps_5_0, D3D11)", + "Google Inc. (AMD)|ANGLE (AMD, AMD Radeon RX 6800 (0x000073BF) Direct3D11 vs_5_0 ps_5_0, D3D11)", + "Google Inc. (AMD)|ANGLE (AMD, AMD Radeon RX 6900 XT (0x000073C2) Direct3D11 vs_5_0 ps_5_0, D3D11)", + "Google Inc. (AMD)|ANGLE (AMD, AMD Radeon RX 7700 XT Direct3D11 vs_5_0 ps_5_0, D3D11)", + "Google Inc. (AMD)|ANGLE (AMD, AMD Radeon Pro 5300M OpenGL Engine, OpenGL 4.1)", + "Google Inc. (AMD)|ANGLE (AMD, AMD Radeon Pro 5500 XT OpenGL Engine, OpenGL 4.1)", + "Google Inc. (AMD)|ANGLE (AMD, AMD Radeon R7 370 Series (0x00006811) Direct3D11 vs_5_0 ps_5_0, D3D11)", + "Google Inc. (AMD)|ANGLE (AMD, ATI Technologies Inc. AMD Radeon RX Vega 64 OpenGL Engine, OpenGL 4.1)", + "Google Inc. (NVIDIA)|ANGLE (NVIDIA, NVIDIA GeForce GTX 1050 (0x00001C81) Direct3D11 vs_5_0 ps_5_0, D3D11)", + "Google Inc. (NVIDIA)|ANGLE (NVIDIA, NVIDIA GeForce GTX 1050 Ti (0x00001C8C) Direct3D11 vs_5_0 ps_5_0, D3D11)", # noqa: E501 + "Google Inc. (NVIDIA)|ANGLE (NVIDIA, NVIDIA GeForce GTX 1060 6GB (0x000010DE) Direct3D11 vs_5_0 ps_5_0, D3D11)", # noqa: E501 + "Google Inc. (NVIDIA)|ANGLE (NVIDIA, NVIDIA GeForce GTX 1070 (0x00001B81) Direct3D11 vs_5_0 ps_5_0, D3D11)", + "Google Inc. (NVIDIA)|ANGLE (NVIDIA, NVIDIA GeForce GTX 1080 (0x00001B80) Direct3D11 vs_5_0 ps_5_0, D3D11)", + "Google Inc. (NVIDIA)|ANGLE (NVIDIA, NVIDIA GeForce RTX 2060 (0x00001F06) Direct3D11 vs_5_0 ps_5_0, D3D11)", + "Google Inc. (NVIDIA)|ANGLE (NVIDIA, NVIDIA GeForce RTX 2060 SUPER (0x00001F06) Direct3D11 vs_5_0 ps_5_0, D3D11)", # noqa: E501 + "Google Inc. (NVIDIA)|ANGLE (NVIDIA, NVIDIA GeForce RTX 2070 (0x00001F10) Direct3D11 vs_5_0 ps_5_0, D3D11)", + "Google Inc. (NVIDIA)|ANGLE (NVIDIA, NVIDIA GeForce RTX 2070 SUPER (0x00001F10) Direct3D11 vs_5_0 ps_5_0, D3D11)", # noqa: E501 + "Google Inc. (NVIDIA)|ANGLE (NVIDIA, NVIDIA GeForce RTX 3060 (0x0000250F) Direct3D11 vs_5_0 ps_5_0, D3D11)", + "Google Inc. (NVIDIA)|ANGLE (NVIDIA, NVIDIA GeForce RTX 3060 Ti (0x00002489) Direct3D11 vs_5_0 ps_5_0, D3D11)", # noqa: E501 + "Google Inc. (NVIDIA)|ANGLE (NVIDIA, NVIDIA GeForce RTX 3070 (0x00002488) Direct3D11 vs_5_0 ps_5_0, D3D11)", + "Google Inc. (NVIDIA)|ANGLE (NVIDIA, NVIDIA GeForce RTX 3070 Ti (0x000028A5) Direct3D11 vs_5_0 ps_5_0, D3D11)", # noqa: E501 + "Google Inc. (NVIDIA)|ANGLE (NVIDIA, NVIDIA GeForce RTX 3080 (0x00002206) Direct3D11 vs_5_0 ps_5_0, D3D11)", + "Google Inc. (NVIDIA)|ANGLE (NVIDIA, NVIDIA GeForce RTX 3080 Ti (0x00002208) Direct3D11 vs_5_0 ps_5_0, D3D11)", # noqa: E501 + "Google Inc. (NVIDIA)|ANGLE (NVIDIA, NVIDIA GeForce RTX 3090 (0x00002204) Direct3D11 vs_5_0 ps_5_0, D3D11)", + "Google Inc. (NVIDIA)|ANGLE (NVIDIA, NVIDIA GeForce RTX 4060 (0x00002882) Direct3D11 vs_5_0 ps_5_0, D3D11)", + "Google Inc. (NVIDIA)|ANGLE (NVIDIA, NVIDIA GeForce RTX 4060 Ti (0x00002803) Direct3D11 vs_5_0 ps_5_0, D3D11)", # noqa: E501 + "Google Inc. (NVIDIA)|ANGLE (NVIDIA, NVIDIA GeForce RTX 4070 (0x00002786) Direct3D11 vs_5_0 ps_5_0, D3D11)", + "Google Inc. (NVIDIA)|ANGLE (NVIDIA, NVIDIA GeForce RTX 4070 Ti (0x00002857) Direct3D11 vs_5_0 ps_5_0, D3D11)", # noqa: E501 + "Google Inc. (NVIDIA)|ANGLE (NVIDIA, NVIDIA GeForce RTX 4080 (0x00002819) Direct3D11 vs_5_0 ps_5_0, D3D11)", + "Google Inc. (NVIDIA)|ANGLE (NVIDIA, NVIDIA GeForce RTX 4090 (0x00002684) Direct3D11 vs_5_0 ps_5_0, D3D11)", + "Google Inc. (NVIDIA)|ANGLE (NVIDIA, NVIDIA Quadro RTX 5000 Ada Generation (0x000026B2) Direct3D11 vs_5_0 ps_5_0, D3D11)", # noqa: E501 + "Google Inc. (NVIDIA)|ANGLE (NVIDIA, NVIDIA Quadro P400 (0x00001CB3) Direct3D11 vs_5_0 ps_5_0, D3D11)", + "Google Inc. (Google)|ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver)", # noqa: E501 + "Google Inc. (Google)|ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero)), SwiftShader driver)", + "Google Inc. (Google)|ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device), SwiftShader driver)", +] + +SCREEN_RESOLUTIONS: Final[dict[str, list]] = { + "resolutions": ["1366;768", "1600;900", "1920;1080", "2560;1440", "3840;2160", "7680;4320"], + "weights": [0.25, 0.15, 0.35, 0.15, 0.08, 0.02], +} + +COLOR_DEPTH_OPTIONS: Final[dict[str, list]] = { + "values": [16, 24, 30, 32], + "weights": [0.05, 0.6, 0.05, 0.3], +} + +DEVICE_MEMORY_OPTIONS: Final[dict[str, list]] = { + "values": [1, 2, 4, 8, 12, 16], + "weights": [0.10, 0.25, 0.4, 0.2, 0.03, 0.01], +} + +CORE_OPTIONS: Final[dict[str, list]] = { + "values": [2, 4, 6, 8, 12, 16, 24, 32], + "weights": [0.1, 0.4, 0.2, 0.15, 0.08, 0.04, 0.02, 0.01], +} + +BROWSER_PLUGINS: Final[str] = ( + "PDF Viewer,Chrome PDF Viewer,Chromium PDF Viewer,Microsoft Edge PDF Viewer,WebKit built-in PDF" +) + +CANVAS_HASH: Final[str] = "742cc32c" + +VOICE_HASH_OPTIONS: Final[str] = "10311144241322244122" + +FONTS: Final[str] = ( + 'system-ui, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji", -apple-system, "Segoe UI", Roboto, Ubuntu, Cantarell, "Noto Sans", sans-serif, BlinkMacSystemFont, "Helvetica Neue", Arial, "PingFang SC", "PingFang TC", "PingFang HK", "Microsoft Yahei", "Microsoft JhengHei"' # noqa: E501 +) diff --git a/src/xhshow/generators/__init__.py b/src/xhshow/generators/__init__.py new file mode 100644 index 0000000..8f8e2ee --- /dev/null +++ b/src/xhshow/generators/__init__.py @@ -0,0 +1,5 @@ +"""Generators module""" + +from .fingerprint import FingerprintGenerator + +__all__ = ["FingerprintGenerator"] diff --git a/src/xhshow/generators/fingerprint.py b/src/xhshow/generators/fingerprint.py new file mode 100644 index 0000000..db2b967 --- /dev/null +++ b/src/xhshow/generators/fingerprint.py @@ -0,0 +1,217 @@ +"""Browser fingerprint generator""" + +import hashlib +import json +import random +import secrets +import time +import urllib.parse + +from Crypto.Cipher import ARC4 + +from ..config import CryptoConfig +from ..data import fingerprint_data as FPData +from ..utils import encoder +from . import fingerprint_helpers as helpers + +__all__ = ["FingerprintGenerator"] + + +class FingerprintGenerator: + """XHS Fingerprint generation function""" + + def __init__(self, config: CryptoConfig): + self.config = config + self._b1_key = self.config.B1_SECRET_KEY.encode() + self._encoder = encoder.Base64Encoder(self.config) + + def generate_b1(self, fp: dict) -> str: + """ + Generate b1 parameter from fingerprint + + Args: + fp: Fingerprint dictionary + + Returns: + Base64 encoded b1 string + """ + b1_fp = { + "x33": fp["x33"], + "x34": fp["x34"], + "x35": fp["x35"], + "x36": fp["x36"], + "x37": fp["x37"], + "x38": fp["x38"], + "x39": fp["x39"], + "x42": fp["x42"], + "x43": fp["x43"], + "x44": fp["x44"], + "x45": fp["x45"], + "x46": fp["x46"], + "x48": fp["x48"], + "x49": fp["x49"], + "x50": fp["x50"], + "x51": fp["x51"], + "x52": fp["x52"], + "x82": fp["x82"], + } + b1_json = json.dumps(b1_fp, separators=(",", ":"), ensure_ascii=False) + cipher = ARC4.new(self._b1_key) + ciphertext = cipher.encrypt(b1_json.encode("utf-8")).decode("latin1") + encoded_url = urllib.parse.quote(ciphertext, safe="!*'()~_-") + b = [] + for c in encoded_url.split("%")[1:]: + chars = list(c) + b.append(int("".join(chars[:2]), 16)) + [b.append(ord(j)) for j in chars[2:]] + + b1 = self._encoder.encode(json.dumps(b, separators=(",", ":"))) + + return b1 + + def generate(self, cookies: dict, user_agent: str) -> dict: + """ + Generate browser fingerprint + + Args: + cookies: Cookie dictionary + user_agent: User agent string + + Returns: + Complete fingerprint dictionary + """ + cookie_string = "; ".join(f"{k}={v}" for k, v in cookies.items()) + + screen_config = helpers.get_screen_config() + is_incognito_mode = helpers.weighted_random_choice(["true", "false"], [0.95, 0.05]) + vendor, renderer = helpers.get_renderer_info() + + x78_y = random.randint(2350, 2450) + fp = { + "x1": user_agent, + "x2": "false", + "x3": "zh-CN", + "x4": helpers.weighted_random_choice( + FPData.COLOR_DEPTH_OPTIONS["values"], + FPData.COLOR_DEPTH_OPTIONS["weights"], + ), + "x5": helpers.weighted_random_choice( + FPData.DEVICE_MEMORY_OPTIONS["values"], + FPData.DEVICE_MEMORY_OPTIONS["weights"], + ), + "x6": "24", + "x7": f"{vendor},{renderer}", + "x8": helpers.weighted_random_choice(FPData.CORE_OPTIONS["values"], FPData.CORE_OPTIONS["weights"]), + "x9": f"{screen_config['width']};{screen_config['height']}", + "x10": f"{screen_config['availWidth']};{screen_config['availHeight']}", + "x11": "-480", + "x12": "Asia/Shanghai", + "x13": is_incognito_mode, + "x14": is_incognito_mode, + "x15": is_incognito_mode, + "x16": "false", + "x17": "false", + "x18": "un", + "x19": "Win32", + "x20": "", + "x21": FPData.BROWSER_PLUGINS, + "x22": helpers.generate_webgl_hash(), + "x23": "false", + "x24": "false", + "x25": "false", + "x26": "false", + "x27": "false", + "x28": "0,false,false", + "x29": "4,7,8", + "x30": "swf object not loaded", + "x33": "0", + "x34": "0", + "x35": "0", + "x36": f"{random.randint(1, 20)}", + "x37": "0|0|0|0|0|0|0|0|0|1|0|0|0|0|0|0|0|0|1|0|0|0|0|0", + "x38": "0|0|1|0|1|0|0|0|0|0|1|0|1|0|1|0|0|0|0|0|0|0|0|0|0|0|0|0|0|0|0|0|0|0|0|0|0|0|0", + "x39": 0, + "x40": "0", + "x41": "0", + "x42": "3.4.4", + "x43": helpers.generate_canvas_hash(), + "x44": f"{int(time.time() * 1000)}", + "x45": "__SEC_CAV__1-1-1-1-1|__SEC_WSA__|", + "x46": "false", + "x47": "1|0|0|0|0|0", + "x48": "", + "x49": "{list:[],type:}", + "x50": "", + "x51": "", + "x52": "", + "x55": "380,380,360,400,380,400,420,380,400,400,360,360,440,420", + "x56": f"{vendor}|{renderer}|{helpers.generate_webgl_hash()}|35", + "x57": cookie_string, + "x58": "180", + "x59": "2", + "x60": "63", + "x61": "1291", + "x62": "2047", + "x63": "0", + "x64": "0", + "x65": "0", + "x66": { + "referer": "", + "location": "https://www.xiaohongshu.com/explore", + "frame": 0, + }, + "x67": "1|0", + "x68": "0", + "x69": "326|1292|30", + "x70": ["location"], + "x71": "true", + "x72": "complete", + "x73": "1191", + "x74": "0|0|0", + "x75": "Google Inc.", + "x76": "true", + "x77": "1|1|1|1|1|1|1|1|1|1", + "x78": { + "x": 0, + "y": x78_y, + "left": 0, + "right": 290.828125, + "bottom": x78_y + 18, + "height": 18, + "top": x78_y, + "width": 290.828125, + "font": FPData.FONTS, + }, + "x82": "_0x17a2|_0x1954", + "x31": "124.04347527516074", + "x79": "144|599565058866", + "x53": hashlib.md5(secrets.token_bytes(32)).hexdigest(), + "x54": FPData.VOICE_HASH_OPTIONS, + "x80": "1|[object FileSystemDirectoryHandle]", + } + + return fp + + def update(self, fp: dict, cookies: dict, url: str) -> None: + """ + Update fingerprint with new cookies and URL + + Args: + fp: Fingerprint dictionary to update + cookies: Updated cookie dictionary + url: Current URL + """ + cookie_string = "; ".join(f"{k}={v}" for k, v in cookies.items()) + + fp.update( + { + "x39": 0, + "x44": f"{time.time() * 1000}", + "x57": cookie_string, + "x66": { + "referer": "https://www.xiaohongshu.com/explore", + "location": url, + "frame": 0, + }, + } + ) diff --git a/src/xhshow/generators/fingerprint_helpers.py b/src/xhshow/generators/fingerprint_helpers.py new file mode 100644 index 0000000..b8fede4 --- /dev/null +++ b/src/xhshow/generators/fingerprint_helpers.py @@ -0,0 +1,92 @@ +"""Fingerprint generation helper functions""" + +import hashlib +import random +import secrets +from typing import Any + +from ..data import fingerprint_data as FPData + +__all__ = [ + "weighted_random_choice", + "get_renderer_info", + "get_screen_config", + "generate_canvas_hash", + "generate_webgl_hash", +] + + +def weighted_random_choice(options: list, weights: list) -> Any: + """ + Random choice a value from list according to the given weights + + Args: + options: Option list + weights: Weight list mapping the option list (without normalization) + + Returns: + Randomly chosen value from options + """ + return f"{random.choices(options, weights=weights, k=1)[0]}" + + +def get_renderer_info() -> tuple[str, str]: + """ + Get random GPU renderer information + + Returns: + Tuple of (vendor, renderer) + """ + renderer_str = random.choice(FPData.GPU_VENDORS) + vendor, renderer = renderer_str.split("|") + return vendor, renderer + + +def get_screen_config() -> dict[str, Any]: + """ + Get random screen configuration with width, height, and available dimensions + + Returns: + Dictionary containing screen configuration + """ + width_str, height_str = weighted_random_choice( + FPData.SCREEN_RESOLUTIONS["resolutions"], + FPData.SCREEN_RESOLUTIONS["weights"], + ).split(";") + + width = int(width_str) + height = int(height_str) + + if random.choice([True, False]): + avail_width = width - int(weighted_random_choice([0, 30, 60, 80], [0.1, 0.4, 0.3, 0.2])) + avail_height = height + else: + avail_width = width + avail_height = height - int(weighted_random_choice([30, 60, 80, 100], [0.2, 0.5, 0.2, 0.1])) + + return { + "width": width, + "height": height, + "availWidth": avail_width, + "availHeight": avail_height, + } + + +def generate_canvas_hash() -> str: + """ + Generate canvas fingerprint hash + + Returns: + Canvas hash string + """ + return FPData.CANVAS_HASH + + +def generate_webgl_hash() -> str: + """ + Generate WebGL fingerprint hash + + Returns: + WebGL hash (MD5 hex string) + """ + return hashlib.md5(secrets.token_bytes(32)).hexdigest() diff --git a/src/xhshow/utils/encoder.py b/src/xhshow/utils/encoder.py index 8395c7e..0d105c9 100644 --- a/src/xhshow/utils/encoder.py +++ b/src/xhshow/utils/encoder.py @@ -11,6 +11,23 @@ class Base64Encoder: def __init__(self, config: CryptoConfig): self.config = config + # Cache translation tables for better performance + self._custom_encode_table = str.maketrans( + config.STANDARD_BASE64_ALPHABET, + config.CUSTOM_BASE64_ALPHABET, + ) + self._custom_decode_table = str.maketrans( + config.CUSTOM_BASE64_ALPHABET, + config.STANDARD_BASE64_ALPHABET, + ) + self._x3_encode_table = str.maketrans( + config.STANDARD_BASE64_ALPHABET, + config.X3_BASE64_ALPHABET, + ) + self._x3_decode_table = str.maketrans( + config.X3_BASE64_ALPHABET, + config.STANDARD_BASE64_ALPHABET, + ) def encode(self, data_to_encode: str) -> str: """ @@ -26,9 +43,7 @@ def encode(self, data_to_encode: str) -> str: standard_encoded_bytes = base64.b64encode(data_bytes) standard_encoded_string = standard_encoded_bytes.decode("utf-8") - translation_table = str.maketrans(self.config.STANDARD_BASE64_ALPHABET, self.config.CUSTOM_BASE64_ALPHABET) - - return standard_encoded_string.translate(translation_table) + return standard_encoded_string.translate(self._custom_encode_table) def decode(self, encoded_string: str) -> str: """ @@ -43,11 +58,8 @@ def decode(self, encoded_string: str) -> str: Raises: ValueError: Base64 decoding failed """ - reverse_translation_table = str.maketrans( - self.config.CUSTOM_BASE64_ALPHABET, self.config.STANDARD_BASE64_ALPHABET - ) + standard_encoded_string = encoded_string.translate(self._custom_decode_table) - standard_encoded_string = encoded_string.translate(reverse_translation_table) try: decoded_bytes = base64.b64decode(standard_encoded_string) except (binascii.Error, ValueError) as e: @@ -67,9 +79,8 @@ def decode_x3(self, encoded_string: str) -> bytes: Raises: ValueError: Base64 decoding failed """ - reverse_translation_table = str.maketrans(self.config.X3_BASE64_ALPHABET, self.config.STANDARD_BASE64_ALPHABET) + standard_encoded_string = encoded_string.translate(self._x3_decode_table) - standard_encoded_string = encoded_string.translate(reverse_translation_table) try: decoded_bytes = base64.b64decode(standard_encoded_string) except (binascii.Error, ValueError) as e: @@ -89,6 +100,4 @@ def encode_x3(self, input_bytes: bytes | bytearray) -> str: standard_encoded_bytes = base64.b64encode(input_bytes) standard_encoded_string = standard_encoded_bytes.decode("utf-8") - translation_table = str.maketrans(self.config.STANDARD_BASE64_ALPHABET, self.config.X3_BASE64_ALPHABET) - - return standard_encoded_string.translate(translation_table) + return standard_encoded_string.translate(self._x3_encode_table) diff --git a/src/xhshow/utils/random_gen.py b/src/xhshow/utils/random_gen.py index a6965c6..0359cc9 100644 --- a/src/xhshow/utils/random_gen.py +++ b/src/xhshow/utils/random_gen.py @@ -1,4 +1,5 @@ import random +import time from ..config import CryptoConfig @@ -21,7 +22,7 @@ def generate_random_bytes(self, byte_count: int) -> list[int]: Returns: list[int]: Random byte array """ - return [random.randint(0, 255) for _ in range(byte_count)] + return [random.randint(0, self.config.MAX_BYTE) for _ in range(byte_count)] def generate_random_byte_in_range(self, min_val: int, max_val: int) -> int: """ @@ -44,3 +45,38 @@ def generate_random_int(self) -> int: int: Random 32-bit integer """ return random.randint(0, self.config.MAX_32BIT) + + def generate_b3_trace_id(self) -> str: + """ + Generate x-b3-traceid (16 random hex characters) + + Returns: + str: 16-character hexadecimal trace ID + """ + return "".join(random.choice(self.config.HEX_CHARS) for _ in range(self.config.B3_TRACE_ID_LENGTH)) + + def generate_xray_trace_id(self, timestamp: int | None = None, seq: int | None = None) -> str: + """ + Generate x-xray-traceid (32 characters: 16 timestamp+seq + 16 random) + + Args: + timestamp: Unix timestamp in milliseconds (defaults to current time) + seq: Sequence number 0 to 2^23-1 (defaults to random value) + + Returns: + str: 32-character hexadecimal trace ID + """ + if timestamp is None: + timestamp = int(time.time() * 1000) + if seq is None: + seq = random.randint(0, self.config.XRAY_TRACE_ID_SEQ_MAX) + + # First 16 chars: XHS xray parameter uses timestamp bit operations + part1 = format( + ((timestamp << self.config.XRAY_TRACE_ID_TIMESTAMP_SHIFT) | seq), + f"0{self.config.XRAY_TRACE_ID_PART1_LENGTH}x", + ) + # Last 16 chars: completely random, untraceable, can be simplified + part2 = "".join(random.choice(self.config.HEX_CHARS) for _ in range(self.config.XRAY_TRACE_ID_PART2_LENGTH)) + + return part1 + part2 diff --git a/src/xhshow/utils/validators.py b/src/xhshow/utils/validators.py index d585b1a..6570dce 100644 --- a/src/xhshow/utils/validators.py +++ b/src/xhshow/utils/validators.py @@ -12,6 +12,7 @@ "validate_signature_params", "validate_get_signature_params", "validate_post_signature_params", + "validate_xs_common_params", ] @@ -76,6 +77,18 @@ def validate_payload(payload: Any) -> dict[str, Any] | None: return payload + @staticmethod + def validate_cookie(cookie: Any) -> dict[str, Any] | str: + """Validate cookie parameter""" + if cookie is not None and not (isinstance(cookie, dict) or isinstance(cookie, str)): + raise TypeError(f"payload must be dict or None, got {type(cookie).__name__}") + # detect cookie dict validation + if cookie is not None and isinstance(cookie, dict): + for key in cookie.keys(): + if not isinstance(key, str): + raise TypeError(f"payload keys must be str, got {type(key).__name__} for key '{key}'") + return cookie + def validate_signature_params(func: F) -> F: # type: ignore # noqa: UP047 """ @@ -96,6 +109,7 @@ def wrapper( a1_value: Any, xsec_appid: Any = "xhs-pc-web", payload: Any = None, + timestamp: float | None = None, ): validator = RequestSignatureValidator() @@ -112,6 +126,7 @@ def wrapper( validated_a1_value, validated_xsec_appid, validated_payload, + timestamp, ) return wrapper # type: ignore @@ -135,6 +150,7 @@ def wrapper( a1_value: Any, xsec_appid: Any = "xhs-pc-web", params: Any = None, + timestamp: float | None = None, ): validator = RequestSignatureValidator() @@ -149,6 +165,7 @@ def wrapper( validated_a1_value, validated_xsec_appid, validated_params, + timestamp, ) return wrapper # type: ignore @@ -172,6 +189,7 @@ def wrapper( a1_value: Any, xsec_appid: Any = "xhs-pc-web", payload: Any = None, + timestamp: float | None = None, ): validator = RequestSignatureValidator() @@ -186,6 +204,39 @@ def wrapper( validated_a1_value, validated_xsec_appid, validated_payload, + timestamp, + ) + + return wrapper # type: ignore + + +def validate_xs_common_params(func: F) -> F: # type: ignore[misc] # noqa: UP047 + """ + Parameter validation decorator for the `sign_xsc` method. + + This wrapper normalizes and validates the arguments before delegating to + the underlying signing implementation. + + Args: + func: Method to be decorated. + + Returns: + Wrapped method with validated parameters. + """ + + @wraps(func) + def wrapper( + self, + cookie_dict: dict[str, Any] | None = None, + ) -> str: + validator = RequestSignatureValidator() + + # Reuse existing validators where possible + validated_cookie_dict = validator.validate_cookie(cookie_dict) + + return func( + self, + validated_cookie_dict, ) return wrapper # type: ignore diff --git a/tests/test_cookie_parsing.py b/tests/test_cookie_parsing.py new file mode 100644 index 0000000..532df01 --- /dev/null +++ b/tests/test_cookie_parsing.py @@ -0,0 +1,425 @@ +"""Tests for cookie parsing and sign_headers functionality""" + +import pytest + +from xhshow import Xhshow +from xhshow.core.common_sign import XsCommonSigner + + +class TestCookieParsing: + """测试 Cookie 解析功能""" + + def setup_method(self): + self.client = Xhshow() + + def test_parse_cookies_from_dict(self): + """测试从字典解析 cookies""" + cookies_dict = { + "a1": "test_a1_value", + "web_session": "test_session", + "webId": "test_web_id", + } + + result = self.client._parse_cookies(cookies_dict) + + assert isinstance(result, dict) + assert result == cookies_dict + assert result is cookies_dict # Should return the same object for dict input + + def test_parse_cookies_from_string(self): + """测试从字符串解析 cookies""" + cookie_string = "a1=test_a1_value; web_session=test_session; webId=test_web_id" + + result = self.client._parse_cookies(cookie_string) + + assert isinstance(result, dict) + assert "a1" in result + assert "web_session" in result + assert "webId" in result + assert result["a1"] == "test_a1_value" + assert result["web_session"] == "test_session" + assert result["webId"] == "test_web_id" + + def test_parse_cookies_from_string_with_spaces(self): + """测试解析带空格的 cookie 字符串""" + cookie_string = "a1=value1; web_session=value2 ;webId=value3" + + result = self.client._parse_cookies(cookie_string) + + assert isinstance(result, dict) + assert "a1" in result + assert "web_session" in result + assert "webId" in result + + def test_parse_cookies_from_string_with_special_chars(self): + """测试解析包含特殊字符的 cookie""" + cookie_string = 'a1=abc123_-.; web_session="quoted value"; key=value=' + + result = self.client._parse_cookies(cookie_string) + + assert isinstance(result, dict) + assert "a1" in result + assert "web_session" in result + + def test_parse_cookies_empty_dict(self): + """测试空字典解析""" + result = self.client._parse_cookies({}) + + assert isinstance(result, dict) + assert len(result) == 0 + + def test_parse_cookies_empty_string(self): + """测试空字符串解析""" + result = self.client._parse_cookies("") + + assert isinstance(result, dict) + assert len(result) == 0 + + +class TestSignXsCommon: + """测试 x-s-common 签名功能""" + + def setup_method(self): + self.client = Xhshow() + + def test_sign_xs_common_with_dict(self): + """测试使用字典生成 x-s-common""" + cookies = { + "a1": "test_a1_value", + "web_session": "test_session", + "webId": "test_web_id", + } + + result = self.client.sign_xs_common(cookies) + + assert isinstance(result, str) + assert len(result) > 0 + + def test_sign_xs_common_with_string(self): + """测试使用字符串生成 x-s-common""" + cookie_string = "a1=test_a1_value; web_session=test_session; webId=test_web_id" + + result = self.client.sign_xs_common(cookie_string) + + assert isinstance(result, str) + assert len(result) > 0 + + def test_sign_xs_common_consistency(self): + """测试字典和字符串输入的一致性""" + cookies_dict = { + "a1": "test_a1_value", + "web_session": "test_session", + "webId": "test_web_id", + } + cookie_string = "a1=test_a1_value; web_session=test_session; webId=test_web_id" + + result_dict = self.client.sign_xs_common(cookies_dict) + result_string = self.client.sign_xs_common(cookie_string) + + # Both should produce valid results + assert isinstance(result_dict, str) + assert isinstance(result_string, str) + assert len(result_dict) > 0 + assert len(result_string) > 0 + + def test_sign_xs_common_missing_a1(self): + """测试缺少 a1 时的异常处理""" + cookies = { + "web_session": "test_session", + "webId": "test_web_id", + } + + with pytest.raises(KeyError, match="a1"): + self.client.sign_xs_common(cookies) + + def test_sign_xsc_alias(self): + """测试 sign_xsc 别名方法""" + cookies = { + "a1": "test_a1_value", + "web_session": "test_session", + } + + result = self.client.sign_xsc(cookies) + + assert isinstance(result, str) + assert len(result) > 0 + + +class TestXsCommonSigner: + """测试 XsCommonSigner 类""" + + def setup_method(self): + self.signer = XsCommonSigner() + + def test_sign_with_dict_only(self): + """测试只接受字典参数""" + cookies = { + "a1": "test_a1_value", + "web_session": "test_session", + "webId": "test_web_id", + } + + result = self.signer.sign(cookies) + + assert isinstance(result, str) + assert len(result) > 0 + + def test_sign_missing_a1(self): + """测试缺少 a1 的异常""" + cookies = { + "web_session": "test_session", + } + + with pytest.raises(KeyError, match="a1"): + self.signer.sign(cookies) + + def test_sign_reproducibility(self): + """测试签名的可重现性""" + cookies = { + "a1": "test_a1_value", + "web_session": "test_session", + } + + result1 = self.signer.sign(cookies) + result2 = self.signer.sign(cookies) + + # Note: May contain random elements, so just check format + assert isinstance(result1, str) + assert isinstance(result2, str) + assert len(result1) > 0 + assert len(result2) > 0 + + +class TestSignHeaders: + """测试 sign_headers 系列方法""" + + def setup_method(self): + self.client = Xhshow() + + def test_sign_headers_get_with_dict(self): + """测试 GET 请求使用字典 cookies""" + cookies = { + "a1": "test_a1_value", + "web_session": "test_session", + } + + headers = self.client.sign_headers( + method="GET", + uri="/api/sns/web/v1/user_posted", + cookies=cookies, + params={"num": "30"}, + ) + + assert isinstance(headers, dict) + assert "x-s" in headers + assert "x-s-common" in headers + assert "x-t" in headers + assert "x-b3-traceid" in headers + assert "x-xray-traceid" in headers + + assert headers["x-s"].startswith("XYS_") + assert len(headers["x-s-common"]) > 0 + assert headers["x-t"].isdigit() + assert len(headers["x-b3-traceid"]) == 16 + assert len(headers["x-xray-traceid"]) == 32 + + def test_sign_headers_get_with_string(self): + """测试 GET 请求使用字符串 cookies""" + cookie_string = "a1=test_a1_value; web_session=test_session; webId=test_web_id" + + headers = self.client.sign_headers( + method="GET", + uri="/api/sns/web/v1/user_posted", + cookies=cookie_string, + params={"num": "30"}, + ) + + assert isinstance(headers, dict) + assert "x-s" in headers + assert "x-s-common" in headers + assert headers["x-s"].startswith("XYS_") + assert len(headers["x-s-common"]) > 0 + + def test_sign_headers_post_with_dict(self): + """测试 POST 请求使用字典 cookies""" + cookies = { + "a1": "test_a1_value", + "web_session": "test_session", + } + + headers = self.client.sign_headers( + method="POST", + uri="/api/sns/web/v1/login", + cookies=cookies, + payload={"username": "test", "password": "123456"}, + ) + + assert isinstance(headers, dict) + assert all(k in headers for k in ["x-s", "x-s-common", "x-t", "x-b3-traceid", "x-xray-traceid"]) + assert headers["x-s"].startswith("XYS_") + assert len(headers["x-s-common"]) > 0 + + def test_sign_headers_post_with_string(self): + """测试 POST 请求使用字符串 cookies""" + cookie_string = "a1=test_a1_value; web_session=test_session" + + headers = self.client.sign_headers( + method="POST", + uri="/api/sns/web/v1/login", + cookies=cookie_string, + payload={"username": "test"}, + ) + + assert isinstance(headers, dict) + assert "x-s" in headers + assert "x-s-common" in headers + assert headers["x-s"].startswith("XYS_") + + def test_sign_headers_missing_a1(self): + """测试缺少 a1 cookie 的异常处理""" + cookies = { + "web_session": "test_session", + } + + with pytest.raises(ValueError, match="Missing 'a1' in cookies"): + self.client.sign_headers( + method="GET", + uri="/api/test", + cookies=cookies, + params={"key": "value"}, + ) + + def test_sign_headers_get_convenience(self): + """测试 sign_headers_get 便捷方法""" + cookies = { + "a1": "test_a1_value", + "web_session": "test_session", + } + + headers = self.client.sign_headers_get( + uri="/api/sns/web/v1/user_posted", + cookies=cookies, + params={"num": "30"}, + ) + + assert isinstance(headers, dict) + assert all(k in headers for k in ["x-s", "x-s-common", "x-t", "x-b3-traceid", "x-xray-traceid"]) + + def test_sign_headers_post_convenience(self): + """测试 sign_headers_post 便捷方法""" + cookies = { + "a1": "test_a1_value", + "web_session": "test_session", + } + + headers = self.client.sign_headers_post( + uri="/api/sns/web/v1/login", + cookies=cookies, + payload={"username": "test"}, + ) + + assert isinstance(headers, dict) + assert all(k in headers for k in ["x-s", "x-s-common", "x-t", "x-b3-traceid", "x-xray-traceid"]) + + def test_sign_headers_with_timestamp(self): + """测试使用自定义时间戳""" + import time + + cookies = { + "a1": "test_a1_value", + "web_session": "test_session", + } + custom_ts = time.time() + + headers = self.client.sign_headers( + method="GET", + uri="/api/test", + cookies=cookies, + params={"key": "value"}, + timestamp=custom_ts, + ) + + assert headers["x-t"] == str(int(custom_ts * 1000)) + + def test_cookie_parsing_only_once(self): + """测试 cookie 只被解析一次(性能测试)""" + # This is more of a design verification test + # We can't directly test parse count, but we verify the result is correct + + cookie_string = "a1=test_a1_value; web_session=test_session; webId=test_web_id" + + headers = self.client.sign_headers( + method="GET", + uri="/api/test", + cookies=cookie_string, + params={"key": "value"}, + ) + + # Both x-s and x-s-common should work correctly + assert headers["x-s"].startswith("XYS_") + assert len(headers["x-s-common"]) > 0 + + # Verify all expected headers are present + assert all(k in headers for k in ["x-s", "x-s-common", "x-t", "x-b3-traceid", "x-xray-traceid"]) + + +class TestCookieParsingEdgeCases: + """测试 Cookie 解析的边界情况""" + + def setup_method(self): + self.client = Xhshow() + + def test_cookie_with_equals_in_value(self): + """测试 cookie 值中包含等号""" + cookie_string = "a1=abc=def=ghi; web_session=test" + + result = self.client._parse_cookies(cookie_string) + + assert isinstance(result, dict) + assert "a1" in result + # SimpleCookie handles this - value should be everything after first = + assert "=" in result["a1"] or result["a1"] == "abc" + + def test_cookie_with_semicolon_in_quoted_value(self): + """测试引号内包含分号的 cookie""" + cookie_string = 'a1="value;with;semicolons"; web_session=test' + + result = self.client._parse_cookies(cookie_string) + + assert isinstance(result, dict) + assert "a1" in result + assert "web_session" in result + + def test_cookie_with_unicode(self): + """测试包含 Unicode 字符的 cookie""" + cookies_dict = { + "a1": "test_a1_值", + "web_session": "测试", + } + + result = self.client._parse_cookies(cookies_dict) + + assert result == cookies_dict + assert result["a1"] == "test_a1_值" + assert result["web_session"] == "测试" + + def test_cookie_string_only_one_cookie(self): + """测试只有一个 cookie 的字符串""" + cookie_string = "a1=test_value" + + result = self.client._parse_cookies(cookie_string) + + assert isinstance(result, dict) + assert len(result) == 1 + assert result["a1"] == "test_value" + + def test_cookie_with_path_and_domain(self): + """测试带有 path 和 domain 的 cookie 字符串""" + # SimpleCookie can handle full cookie attributes + cookie_string = "a1=value1; Path=/; Domain=.example.com; web_session=value2" + + result = self.client._parse_cookies(cookie_string) + + assert isinstance(result, dict) + # Should extract cookie names and values, ignoring attributes + assert "a1" in result or "Path" in result # SimpleCookie behavior diff --git a/tests/test_crypto.py b/tests/test_crypto.py index dda704d..5bad44c 100644 --- a/tests/test_crypto.py +++ b/tests/test_crypto.py @@ -1,6 +1,7 @@ import pytest from xhshow import CryptoProcessor, Xhshow +from xhshow.core.crc32_encrypt import CRC32 class TestCryptoProcessor: @@ -387,3 +388,319 @@ def test_sign_xs_parameter_validation(self): result = client.sign_xs(" get ", " /api/test ", " test_a1 ") # type: ignore assert isinstance(result, str) assert result.startswith("XYS_") + + def test_timestamp_parameter_support(self): + """测试时间戳参数支持""" + import time + + client = Xhshow() + uri = "/api/sns/web/v1/user_posted" + a1_value = "test_a1_value" + params = {"num": "30"} + + # Test with default timestamp + sig1 = client.sign_xs_get(uri, a1_value, params=params) + assert isinstance(sig1, str) + assert sig1.startswith("XYS_") + + # Test with custom timestamp + custom_ts = time.time() + sig2 = client.sign_xs_get(uri, a1_value, params=params, timestamp=custom_ts) + assert isinstance(sig2, str) + assert sig2.startswith("XYS_") + + # Test POST with timestamp + sig3 = client.sign_xs_post( + uri="/api/sns/web/v1/login", + a1_value=a1_value, + payload={"user": "test"}, + timestamp=custom_ts, + ) + assert isinstance(sig3, str) + assert sig3.startswith("XYS_") + + # Test sign_xs with timestamp + sig4 = client.sign_xs(method="GET", uri=uri, a1_value=a1_value, payload=params, timestamp=custom_ts) + assert isinstance(sig4, str) + assert sig4.startswith("XYS_") + + def test_trace_id_generation(self): + """测试 Trace ID 生成""" + import time + + client = Xhshow() + + # Test b3 trace id + b3_id = client.get_b3_trace_id() + assert isinstance(b3_id, str) + assert len(b3_id) == 16 + assert all(c in "0123456789abcdef" for c in b3_id) + + # Test xray trace id with default timestamp + xray_id1 = client.get_xray_trace_id() + assert isinstance(xray_id1, str) + assert len(xray_id1) == 32 + assert all(c in "0123456789abcdef" for c in xray_id1) + + # Test xray trace id with custom timestamp + custom_ts = int(time.time() * 1000) + xray_id2 = client.get_xray_trace_id(timestamp=custom_ts) + assert isinstance(xray_id2, str) + assert len(xray_id2) == 32 + + # Test xray trace id with custom timestamp and seq + xray_id3 = client.get_xray_trace_id(timestamp=custom_ts, seq=12345) + assert isinstance(xray_id3, str) + assert len(xray_id3) == 32 + + def test_unified_timestamp_usage(self): + """测试统一时间戳使用场景""" + import time + + client = Xhshow() + unified_ts = time.time() + + # Use same timestamp for signature and trace IDs + signature = client.sign_xs_post( + uri="/api/sns/web/v1/login", + a1_value="test_a1", + payload={"user": "test"}, + timestamp=unified_ts, + ) + + b3_id = client.get_b3_trace_id() + xray_id = client.get_xray_trace_id(timestamp=int(unified_ts * 1000)) + + assert isinstance(signature, str) + assert signature.startswith("XYS_") + assert isinstance(b3_id, str) + assert len(b3_id) == 16 + assert isinstance(xray_id, str) + assert len(xray_id) == 32 + + def test_get_x_t(self): + """测试 x-t header 生成""" + import time + + client = Xhshow() + + # Test with default timestamp + x_t1 = client.get_x_t() + assert isinstance(x_t1, int) + assert x_t1 > 0 + + # Test with custom timestamp + custom_ts = time.time() + x_t2 = client.get_x_t(timestamp=custom_ts) + assert isinstance(x_t2, int) + assert x_t2 == int(custom_ts * 1000) + + # Test unified timestamp with all headers + unified_ts = time.time() + x_t = client.get_x_t(timestamp=unified_ts) + signature = client.sign_xs_get( + uri="/api/test", a1_value="test_a1", params={"key": "value"}, timestamp=unified_ts + ) + b3_id = client.get_b3_trace_id() + xray_id = client.get_xray_trace_id(timestamp=int(unified_ts * 1000)) + + assert isinstance(x_t, int) + assert x_t == int(unified_ts * 1000) + assert isinstance(signature, str) + assert isinstance(b3_id, str) + assert isinstance(xray_id, str) + + def test_sign_headers(self): + """测试统一 headers 生成""" + import time + + client = Xhshow() + + # Test GET request headers with params + cookies = {"a1": "test_a1_value", "web_session": "test_session"} + headers = client.sign_headers( + method="GET", + uri="/api/sns/web/v1/user_posted", + cookies=cookies, + params={"num": "30", "cursor": "", "user_id": "123"}, + ) + + assert isinstance(headers, dict) + assert "x-s" in headers + assert "x-s-common" in headers + assert "x-t" in headers + assert "x-b3-traceid" in headers + assert "x-xray-traceid" in headers + + assert headers["x-s"].startswith("XYS_") + assert headers["x-t"].isdigit() + assert len(headers["x-b3-traceid"]) == 16 + assert len(headers["x-xray-traceid"]) == 32 + + # Test POST request headers with payload + headers_post = client.sign_headers( + method="POST", + uri="/api/sns/web/v1/login", + cookies=cookies, + payload={"username": "test", "password": "123456"}, + ) + + assert isinstance(headers_post, dict) + assert all(k in headers_post for k in ["x-s", "x-s-common", "x-t", "x-b3-traceid", "x-xray-traceid"]) + + # Test with custom timestamp + custom_ts = time.time() + headers_custom = client.sign_headers( + method="GET", + uri="/api/test", + cookies=cookies, + params={"key": "value"}, + timestamp=custom_ts, + ) + + assert isinstance(headers_custom, dict) + assert headers_custom["x-t"] == str(int(custom_ts * 1000)) + + # Test all values are strings + assert all(isinstance(v, str) for v in headers.values()) + assert all(isinstance(v, str) for v in headers_post.values()) + assert all(isinstance(v, str) for v in headers_custom.values()) + + def test_sign_headers_get(self): + """测试 GET 请求 headers 便捷方法""" + import time + + client = Xhshow() + + # Test basic usage + cookies = {"a1": "test_a1_value", "web_session": "test_session"} + headers = client.sign_headers_get( + uri="/api/sns/web/v1/user_posted", + cookies=cookies, + params={"num": "30", "cursor": "", "user_id": "123"}, + ) + + assert isinstance(headers, dict) + assert all(k in headers for k in ["x-s", "x-s-common", "x-t", "x-b3-traceid", "x-xray-traceid"]) + assert headers["x-s"].startswith("XYS_") + assert all(isinstance(v, str) for v in headers.values()) + + # Test with custom timestamp + custom_ts = time.time() + headers_ts = client.sign_headers_get( + uri="/api/test", cookies=cookies, params={"key": "value"}, timestamp=custom_ts + ) + + assert headers_ts["x-t"] == str(int(custom_ts * 1000)) + + def test_sign_headers_post(self): + """测试 POST 请求 headers 便捷方法""" + import time + + client = Xhshow() + + # Test basic usage + cookies = {"a1": "test_a1_value", "web_session": "test_session"} + headers = client.sign_headers_post( + uri="/api/sns/web/v1/login", + cookies=cookies, + payload={"username": "test", "password": "123456"}, + ) + + assert isinstance(headers, dict) + assert all(k in headers for k in ["x-s", "x-s-common", "x-t", "x-b3-traceid", "x-xray-traceid"]) + assert headers["x-s"].startswith("XYS_") + assert all(isinstance(v, str) for v in headers.values()) + + # Test with custom timestamp + custom_ts = time.time() + headers_ts = client.sign_headers_post( + uri="/api/test", cookies=cookies, payload={"key": "value"}, timestamp=custom_ts + ) + + assert headers_ts["x-t"] == str(int(custom_ts * 1000)) + + def test_sign_headers_parameter_validation(self): + """测试 sign_headers 参数验证""" + client = Xhshow() + + cookies = {"a1": "test_a1", "web_session": "test_session"} + + # Test GET request with payload should raise error + with pytest.raises(ValueError, match="GET requests must use 'params', not 'payload'"): + client.sign_headers( + method="GET", + uri="/api/test", + cookies=cookies, + payload={"key": "value"}, + ) + + # Test POST request with params should raise error + with pytest.raises(ValueError, match="POST requests must use 'payload', not 'params'"): + client.sign_headers( + method="POST", + uri="/api/test", + cookies=cookies, + params={"key": "value"}, + ) + + # Test unsupported method should raise error + with pytest.raises(ValueError, match="Unsupported method"): + client.sign_headers( + method="PUT", + uri="/api/test", + cookies=cookies, + params={"key": "value"}, + ) + + +class TestCRC32: + """测试 CRC32 加密功能""" + + def test_crc32_js_int_basic(self): + """测试基本的 CRC32 计算""" + test_string = ( + "I38rHdgsjopgIvesdVwgIC+oIELmBZ5e3VwXLgFTIxS3bqwErFeexd0ekncAzMFYnqthIhJeSBMDKutRI3KsYorWHPtGrbV0P9W" + ) + result = CRC32.crc32_js_int(test_string) + + assert isinstance(result, int) + assert result == 679790455 + + def test_crc32_signed_unsigned(self): + """测试有符号和无符号结果""" + test_data = "test_data" + + signed_result = CRC32.crc32_js_int(test_data, signed=True) + unsigned_result = CRC32.crc32_js_int(test_data, signed=False) + + assert isinstance(signed_result, int) + assert isinstance(unsigned_result, int) + assert -2147483648 <= signed_result <= 2147483647 + assert 0 <= unsigned_result <= 0xFFFFFFFF + + def test_crc32_string_modes(self): + """测试不同的字符串模式""" + test_string = "测试中文" + + js_result = CRC32.crc32_js_int(test_string, string_mode="js") + utf8_result = CRC32.crc32_js_int(test_string, string_mode="utf8") + + assert isinstance(js_result, int) + assert isinstance(utf8_result, int) + # JS mode 和 UTF8 mode 对中文的处理应该不同 + assert js_result != utf8_result + + def test_crc32_bytes_input(self): + """测试字节输入""" + test_bytes = b"test_bytes" + result = CRC32.crc32_js_int(test_bytes) + + assert isinstance(result, int) + + def test_crc32_iterable_input(self): + """测试可迭代输入""" + test_list = [72, 101, 108, 108, 111] # "Hello" + result = CRC32.crc32_js_int(test_list) + + assert isinstance(result, int) diff --git a/uv.lock b/uv.lock index dc4b790..4bf4396 100644 --- a/uv.lock +++ b/uv.lock @@ -132,6 +132,41 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "pycryptodome" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276, upload-time = "2025-05-17T17:21:45.242Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/5d/bdb09489b63cd34a976cc9e2a8d938114f7a53a74d3dd4f125ffa49dce82/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4", size = 2495152, upload-time = "2025-05-17T17:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/a7/ce/7840250ed4cc0039c433cd41715536f926d6e86ce84e904068eb3244b6a6/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae", size = 1639348, upload-time = "2025-05-17T17:20:23.171Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f0/991da24c55c1f688d6a3b5a11940567353f74590734ee4a64294834ae472/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477", size = 2184033, upload-time = "2025-05-17T17:20:25.424Z" }, + { url = "https://files.pythonhosted.org/packages/54/16/0e11882deddf00f68b68dd4e8e442ddc30641f31afeb2bc25588124ac8de/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7", size = 2270142, upload-time = "2025-05-17T17:20:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/d5/fc/4347fea23a3f95ffb931f383ff28b3f7b1fe868739182cb76718c0da86a1/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446", size = 2309384, upload-time = "2025-05-17T17:20:30.765Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d9/c5261780b69ce66d8cfab25d2797bd6e82ba0241804694cd48be41add5eb/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265", size = 2183237, upload-time = "2025-05-17T17:20:33.736Z" }, + { url = "https://files.pythonhosted.org/packages/5a/6f/3af2ffedd5cfa08c631f89452c6648c4d779e7772dfc388c77c920ca6bbf/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b", size = 2343898, upload-time = "2025-05-17T17:20:36.086Z" }, + { url = "https://files.pythonhosted.org/packages/9a/dc/9060d807039ee5de6e2f260f72f3d70ac213993a804f5e67e0a73a56dd2f/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d", size = 2269197, upload-time = "2025-05-17T17:20:38.414Z" }, + { url = "https://files.pythonhosted.org/packages/f9/34/e6c8ca177cb29dcc4967fef73f5de445912f93bd0343c9c33c8e5bf8cde8/pycryptodome-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a", size = 1768600, upload-time = "2025-05-17T17:20:40.688Z" }, + { url = "https://files.pythonhosted.org/packages/e4/1d/89756b8d7ff623ad0160f4539da571d1f594d21ee6d68be130a6eccb39a4/pycryptodome-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625", size = 1799740, upload-time = "2025-05-17T17:20:42.413Z" }, + { url = "https://files.pythonhosted.org/packages/5d/61/35a64f0feaea9fd07f0d91209e7be91726eb48c0f1bfc6720647194071e4/pycryptodome-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39", size = 1703685, upload-time = "2025-05-17T17:20:44.388Z" }, + { url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627, upload-time = "2025-05-17T17:20:47.139Z" }, + { url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362, upload-time = "2025-05-17T17:20:50.392Z" }, + { url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625, upload-time = "2025-05-17T17:20:52.866Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954, upload-time = "2025-05-17T17:20:55.027Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534, upload-time = "2025-05-17T17:20:57.279Z" }, + { url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853, upload-time = "2025-05-17T17:20:59.322Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465, upload-time = "2025-05-17T17:21:03.83Z" }, + { url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414, upload-time = "2025-05-17T17:21:06.72Z" }, + { url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484, upload-time = "2025-05-17T17:21:08.535Z" }, + { url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636, upload-time = "2025-05-17T17:21:10.393Z" }, + { url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" }, + { url = "https://files.pythonhosted.org/packages/d9/12/e33935a0709c07de084d7d58d330ec3f4daf7910a18e77937affdb728452/pycryptodome-3.23.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ddb95b49df036ddd264a0ad246d1be5b672000f12d6961ea2c267083a5e19379", size = 1623886, upload-time = "2025-05-17T17:21:20.614Z" }, + { url = "https://files.pythonhosted.org/packages/22/0b/aa8f9419f25870889bebf0b26b223c6986652bdf071f000623df11212c90/pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e95564beb8782abfd9e431c974e14563a794a4944c29d6d3b7b5ea042110b4", size = 1672151, upload-time = "2025-05-17T17:21:22.666Z" }, + { url = "https://files.pythonhosted.org/packages/d4/5e/63f5cbde2342b7f70a39e591dbe75d9809d6338ce0b07c10406f1a140cdc/pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14e15c081e912c4b0d75632acd8382dfce45b258667aa3c67caf7a4d4c13f630", size = 1664461, upload-time = "2025-05-17T17:21:25.225Z" }, + { url = "https://files.pythonhosted.org/packages/d6/92/608fbdad566ebe499297a86aae5f2a5263818ceeecd16733006f1600403c/pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7fc76bf273353dc7e5207d172b83f569540fc9a28d63171061c42e361d22353", size = 1702440, upload-time = "2025-05-17T17:21:27.991Z" }, + { url = "https://files.pythonhosted.org/packages/d1/92/2eadd1341abd2989cce2e2740b4423608ee2014acb8110438244ee97d7ff/pycryptodome-3.23.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:45c69ad715ca1a94f778215a11e66b7ff989d792a4d63b68dc586a1da1392ff5", size = 1803005, upload-time = "2025-05-17T17:21:31.37Z" }, +] + [[package]] name = "pygments" version = "2.19.2" @@ -248,6 +283,9 @@ wheels = [ [[package]] name = "xhshow" source = { editable = "." } +dependencies = [ + { name = "pycryptodome" }, +] [package.optional-dependencies] dev = [ @@ -267,6 +305,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "black", marker = "extra == 'dev'", specifier = ">=23.0.0" }, + { name = "pycryptodome", specifier = ">=3.23.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.21.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0" },