侧边栏在「监控」右边加了「运营」:账号列表 → 点进二级详情看该账号的数据。 【为什么是独立模块而不是监控的子视图】两者形状不同:监控是公开数据(点赞/收藏/评论/分享)的每轮快照+差分;运营是创作者后台按日期给出的曝光/观看/完播率/涨粉。凭据不同、采集方式也不同 —— 那边要浏览器登录态,这边是纯请求。硬塞进同一个模型会同时污染两边。 【扫码登录的关键差异】监控的扫码把登录态写进浏览器默认 profile(爬虫要复用)。运营要的是 cookie 字符串(纯请求够用),所以每次登录开一个**临时上下文**,扫完取出 cookie 就丢弃 —— 登第二个账号不会把第一个顶掉,也不影响监控那个登录态,十个账号互不干扰。 【决策依据】tools/probe_creator_api.py 的 Phase 0 实测:签名可自造(XYW_:MD5 → base64 → AES-128-CBC,与 xhshow 内置实现常量逐字节一致);主站 cookie 即可认证创作者后台;接口与参数已与真实页面对齐。 后端: - api/creator/models.py: creator_account / creator_note_stat。**复用 MonitorBase**,这样 create_all 与上一轮改成元数据驱动的 _ensure_columns 会自动覆盖新表 - api/creator/signing.py: XYW_ 签名,带三条实测结论(url= 前缀、appId=ugc、401 与 406 的区别) - api/creator/client.py: 纯 httpx 客户端。字段名尚未亲眼验证过,所以写成多别名匹配;解析不出来存 None 而非 0 - api/creator/service.py: 账号 CRUD 与同步。cookie 绝不进入对外结构,只给 has_cookie - api/creator/login.py: 临时上下文的扫码登录 - api/routers/creator.py: 8 条路由,全部带鉴权 前端: - 侧边栏「运营」+ OperationView(账号列表 → 二级详情)+ AddAccountDialog - 权限状态显眼呈现:pending 时照抄后台原话「已为您申请数据权限,次日可查看」,并说明此时同步返回 0 条是正常的,不是采集失败 测试:tests/test_creator_client.py 新增 48 例,含「cookie 不得出现在对外结构里」这条不变量,以及权限未生效时空壳响应的处理。
108 lines
4.1 KiB
Python
108 lines
4.1 KiB
Python
# -*- coding: utf-8 -*-
|
||
# Copyright (c) 2025 [email protected]
|
||
#
|
||
# 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}"
|