diff --git a/media_platform/xhs/playwright_sign.py b/media_platform/xhs/playwright_sign.py index 6b28cab..0815d3e 100644 --- a/media_platform/xhs/playwright_sign.py +++ b/media_platform/xhs/playwright_sign.py @@ -21,95 +21,15 @@ # 致谢:本签名实现依赖 xhshow 开源库, 由 Cloxl 提供 # 仓库地址: https://github.com/Cloxl/xhshow # 许可协议: MIT License +# +# 依赖 xhshow>=0.2.0: 该版本原生修复了 GET 请求 a3_hash 计算的 bug +# (https://github.com/Cloxl/xhshow/issues/104), 不再需要在本地打 monkey-patch。 -import hashlib -import json -import time from typing import Any, Dict, Optional, Union -from urllib.parse import quote from .xhs_sign import get_trace_id -def _patch_xhshow_a3_hash(): - """ - 修复 xhshow 库 build_payload_array 中 a3_hash 计算的 bug。 - xhshow 原实现对所有请求使用 MD5(extract_api_path(content_string)) 计算 a3_hash, - 其中 extract_api_path 会同时去掉 "?" 后的查询参数和 "{" 后的 JSON body。 - 但浏览器的实际行为是: - - POST: a3 使用 MD5(api_path), 即去掉 JSON body 后的路径 → 原实现正确 - - GET: a3 使用 MD5(完整 URL + 查询参数) → 原实现错误, 因为也去掉了查询参数 - 修复方式: 对 GET 请求(content_string 不含 "{"), 使用完整 content_string 的 MD5; - 对 POST 请求(content_string 含 "{"), 保持原始行为。 - 相关 issue: https://github.com/Cloxl/xhshow/issues/104 - """ - from xhshow.core.crypto import CryptoProcessor - - _original_build = CryptoProcessor.build_payload_array - - def _patched_build(self, hex_parameter, a1_value, app_identifier="xhs-pc-web", - string_param="", timestamp=None, sign_state=None): - payload = _original_build(self, hex_parameter, a1_value, app_identifier, - string_param, timestamp, sign_state) - # 仅当 content_string 不含 "{" 时修复 (即 GET 请求) - if "{" not in string_param: - correct_md5_hex = hashlib.md5(string_param.encode("utf-8")).hexdigest() - correct_md5_bytes = [int(correct_md5_hex[i:i + 2], 16) for i in range(0, 32, 2)] - seed_byte = payload[4] - ts_bytes = payload[8:16] - correct_a3_hash = self._custom_hash_v2(list(ts_bytes) + correct_md5_bytes) - for i in range(16): - payload[128 + i] = correct_a3_hash[i] ^ seed_byte - return payload - - CryptoProcessor.build_payload_array = _patched_build - - -# 启动时应用 monkey-patch -_patch_xhshow_a3_hash() - - -def _build_sign_string(uri: str, data: Optional[Union[Dict, str]] = None, method: str = "POST") -> str: - """Build content string to be signed - - Args: - uri: API path - data: Request data - method: Request method (GET or POST) - - Returns: - Content string for signing - """ - if method.upper() == "POST": - c = uri - if data is not None: - if isinstance(data, dict): - c += json.dumps(data, separators=(",", ":"), ensure_ascii=False) - elif isinstance(data, str): - c += data - return c - else: - if not data or (isinstance(data, dict) and len(data) == 0): - return uri - if isinstance(data, dict): - params = [] - for key in data.keys(): - value = data[key] - if isinstance(value, list): - value_str = ",".join(str(v) for v in value) - elif value is not None: - value_str = str(value) - else: - value_str = "" - # URL encoding: preserve commas to match browser behavior - value_str = quote(value_str, safe=',') - params.append(f"{key}={value_str}") - return f"{uri}?{'&'.join(params)}" - elif isinstance(data, str): - return f"{uri}?{data}" - return uri - - def sign_with_xhshow( uri: str, data: Optional[Union[Dict, str]] = None, @@ -131,42 +51,18 @@ def sign_with_xhshow( from xhshow import Xhshow xhshow_client = Xhshow() - is_post = method.upper() == "POST" - - if is_post: + if method.upper() == "POST": headers = xhshow_client.sign_headers_post( uri=uri, cookies=cookie_str, payload=data if isinstance(data, dict) else {}, ) else: - # GET 请求: 构建完整的 content_string 用于签名 - content_string = _build_sign_string(uri, data, method) - cookie_dict = xhshow_client._parse_cookies(cookie_str) - a1_value = cookie_dict.get("a1", "") - - ts = time.time() - d_value = hashlib.md5(content_string.encode("utf-8")).hexdigest() - - payload_array = xhshow_client.crypto_processor.build_payload_array( - d_value, a1_value, "xhs-pc-web", content_string, ts + headers = xhshow_client.sign_headers_get( + uri=uri, + cookies=cookie_str, + params=data if isinstance(data, dict) else {}, ) - xor_result = xhshow_client.crypto_processor.bit_ops.xor_transform_array(payload_array) - config = xhshow_client.config - x3_b64 = xhshow_client.crypto_processor.b64encoder.encode_x3( - xor_result[:config.PAYLOAD_LENGTH] - ) - sig_data = config.SIGNATURE_DATA_TEMPLATE.copy() - sig_data["x3"] = config.X3_PREFIX + x3_b64 - x_s = config.XYS_PREFIX + xhshow_client.crypto_processor.b64encoder.encode( - json.dumps(sig_data, separators=(",", ":"), ensure_ascii=False) - ) - headers = { - "x-s": x_s, - "x-s-common": xhshow_client.sign_xs_common(cookie_dict), - "x-t": str(xhshow_client.get_x_t(ts)), - "x-b3-traceid": xhshow_client.get_b3_trace_id(), - } return { "x-s": headers.get("x-s", ""), diff --git a/pyproject.toml b/pyproject.toml index d68dec6..6e9b05c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,7 @@ dependencies = [ "pytest-asyncio>=0.21.0", "websockets>=15.0.1", "asyncpg>=0.31.0", - "xhshow>=0.1.9", + "xhshow>=0.2.0", ] [[tool.uv.index]] diff --git a/requirements.txt b/requirements.txt index 5940f54..747cce7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -28,4 +28,4 @@ motor>=3.3.0 openpyxl>=3.1.2 pytest>=7.4.0 pytest-asyncio>=0.21.0 -xhshow>=0.1.9 \ No newline at end of file +xhshow>=0.2.0 \ No newline at end of file diff --git a/uv.lock b/uv.lock index 56d2b74..47fb3eb 100644 --- a/uv.lock +++ b/uv.lock @@ -986,7 +986,7 @@ requires-dist = [ { name = "uvicorn", specifier = "==0.29.0" }, { name = "websockets", specifier = ">=15.0.1" }, { name = "wordcloud", specifier = ">=1.9.6" }, - { name = "xhshow", specifier = ">=0.1.9" }, + { name = "xhshow", specifier = ">=0.2.0" }, ] [[package]] @@ -2001,12 +2001,12 @@ wheels = [ [[package]] name = "xhshow" -version = "0.1.9" +version = "0.2.0" source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } dependencies = [ { name = "pycryptodome" }, ] -sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e5/9a/ed544bea5b6a3702fc232eb774817d7f03a678b5f2ac3d67d3c7698695e6/xhshow-0.1.9.tar.gz", hash = "sha256:5a17cb510e9ab3ca61ef8ce00bd4cae6cbce39bb7b5f8ef38a5a980011883245", size = 55126 } +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/17/16/4305d7a372e41b0cf24e9315f1e9a49a3c9ca7fe1229b7da5737e9096a68/xhshow-0.2.0.tar.gz", hash = "sha256:1ae45ce889d0041d57b6eff5dbcbcc31a06f3df351c80b4a50bac983a0e1fe44", size = 65626 } wheels = [ - { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7f/c0/2783314af317b7207422e94f74302dbe32c5c9a1641bcfe11c4c073b0b04/xhshow-0.1.9-py3-none-any.whl", hash = "sha256:c4b0d38f4746b8a3f9c08fcfe4628f81c887b27c76b7ba73fa61ff990d1839dc", size = 31289 }, + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8f/6a/4e227e0724606b1965b0036d9558fe25073e46f4ba11b47721e125b3caa3/xhshow-0.2.0-py3-none-any.whl", hash = "sha256:4de6f632ff911621b55335f7772187dcd4eed414142057120a37bfd46ca6d4bb", size = 40918 }, ]