# -*- coding: utf-8 -*- # Copyright (c) 2025 relakkes@gmail.com # # This file is part of MediaCrawler project. # Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/api/creator/signing.py # GitHub: https://github.com/NanmiCoder # Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1 # # 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则: # 1. 不得用于任何商业用途。 # 2. 使用时应遵守目标平台的使用条款和robots.txt规则。 # 3. 不得进行大规模爬取或对平台造成运营干扰。 # 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。 # 5. 不得用于任何非法或不当的用途。 # # 详细许可条款请参阅项目根目录下的LICENSE文件。 # 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。 """创作者后台的请求签名(XYW_ 方案)。 主站与创作者后台用的是**两套不同的签名**:主站是 VMP 的 `XYS_`,创作者后台是 `XYW_`。后者简单得多 —— MD5 → base64 → AES-128-CBC,密钥与 IV 都是硬编码常量, 纯 Python 可算,不需要浏览器。 常量与 `xhshow/config/config.py` 逐字节一致(该库也据此实现了 `sign_xyw`), 并与独立的逆向实现 xiaohongshu-cli/creator_signing.py 互相印证。 **三条实测结论**(tools/probe_creator_api.py 的 Phase 0 输出): 1. 待签字符串必须是 `url=` + 路径 + 查询串 的形式。只给路径、或去掉 `url=` 前缀, 网关一律返回 **406**;写法正确时签名通过。 2. `appId` 用 `ugc`(创作者平台的取值),不是主站的 `xhs-pc-web`。 3. 不带 cookie 时返回的是应用层的 401「无登录信息」而非 406 —— 说明签名每次都过了, 认证是独立的一层。 """ import base64 import hashlib import json from datetime import datetime XYW_AES_KEY = b"7cc4adla5ay0701v" XYW_AES_IV = b"4uzjr7mbsibcaldp" # 与 xhshow 的 XYW_ENV_FLAGS_DEFAULT 一致。含义未知,但改了签名就不被接受。 XYW_ENV_FLAGS = "0|0|0|1|0|0|1|0|0|0|1|0|0|0|0|1|0|0|0" XYW_PREFIX = "XYW_" XYW_SIGN_SVN = "56" XYW_SIGN_TYPE = "x2" XYW_SIGN_VERSION = "1" # 创作者平台的 appId。用主站的 xhs-pc-web 会被拒。 CREATOR_APP_ID = "ugc" def _aes_encrypt_hex(plaintext: str) -> str: from Crypto.Cipher import AES from Crypto.Util.Padding import pad cipher = AES.new(XYW_AES_KEY, AES.MODE_CBC, XYW_AES_IV) return cipher.encrypt(pad(plaintext.encode("utf-8"), AES.block_size)).hex() def sign_xyw( api: str, a1: str, app_id: str = CREATOR_APP_ID, body: dict | None = None, timestamp_ms: int | None = None, ) -> dict[str, str]: """为一次创作者后台请求生成 ``x-s`` / ``x-t`` 请求头。 ``api`` 必须是待签的完整字符串:``url=`` 加路径,GET 请求还要带上查询串。 POST 的 JSON body 追加在其后(紧凑分隔符、不转义非 ASCII),与参考实现一致。 """ content = api if body is not None: content += json.dumps(body, separators=(",", ":"), ensure_ascii=False) if timestamp_ms is None: timestamp_ms = int(datetime.now().timestamp() * 1000) digest = hashlib.md5(content.encode("utf-8")).hexdigest() plaintext = f"x1={digest};x2={XYW_ENV_FLAGS};x3={a1};x4={timestamp_ms};" encoded = base64.b64encode(plaintext.encode("utf-8")).decode("utf-8") envelope = { "signSvn": XYW_SIGN_SVN, "signType": XYW_SIGN_TYPE, "appId": app_id, "signVersion": XYW_SIGN_VERSION, "payload": _aes_encrypt_hex(encoded), } x_s = XYW_PREFIX + base64.b64encode( json.dumps(envelope, separators=(",", ":")).encode("utf-8") ).decode("utf-8") return {"x-s": x_s, "x-t": str(timestamp_ms)} def signed_api(url_path: str, query: str = "") -> str: """把路径与查询串拼成待签字符串。 单独抽出来是因为这个格式**没有文档**,只能靠实测固定下来 —— 写错就是 406, 而 406 的响应体 ``{"code":-1,"success":false}`` 完全看不出错在哪。 """ return f"url={url_path}?{query}" if query else f"url={url_path}"