feat(creator): Phase 0 探针 —— 创作者后台数据可以纯请求拿到
结论:签名可自造、主站 cookie 即可认证、接口与参数已与真实页面对齐。不需要浏览器、不需要独立的创作者登录。 - tools/probe_creator_api.py: 纯 HTTP 探针。用 XYW_ 算法自签(MD5 → base64 → AES-128-CBC, 密钥与 IV 与 xhshow/config/config.py 逐字节一致),对 note/analyze/list 发请求 - tools/probe_creator_page.py: 打开真实数据分析页,记录页面自己发的请求,作为地面真相 Phase 0 的三条实测结论: 1. 签名可伪造。三种写法里只有「url= + 路径 + 查询串」被接受(200);仅路径、或裸路径都 406。 并且不带 cookie 时返回的是应用层 401「无登录信息」而非网关 406 —— 说明签名每次都已通过 2. 主站 .xiaohongshu.com 的 cookie 就能认证创作者后台,不需要单独的创作者会话 3. 当前账号 dfg 返回空数据不是技术问题:permission/query 的 tip_msg 是 「已为您申请数据权限,次日可查看」,display/status 均为 0,即权限尚未生效 关键佐证:真实页面调 note/analyze/list 用的查询串与本探针生成的完全一致,且拿到同一份空响应。
This commit is contained in:
@@ -0,0 +1,210 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Phase 0 probe: can the creator backend be reached with a plain signed request?
|
||||||
|
|
||||||
|
The question this answers, and why it is worth a whole script: two sources
|
||||||
|
disagree. The `xhshow` library ships `sign_xyw()` whose docstring says it exists
|
||||||
|
because the creator data APIs *reject* the main-site signature with HTTP 406 -- but
|
||||||
|
a field report claims the creator gateway rejects *any* self-made request with 406
|
||||||
|
regardless of signature. Only a live request settles it.
|
||||||
|
|
||||||
|
The response is read as a three-way verdict, because a bare "it failed" is not
|
||||||
|
useful here:
|
||||||
|
|
||||||
|
406 -> the gateway rejected the signature. The browser-interception route is
|
||||||
|
the only way forward.
|
||||||
|
401 / not-logged-in
|
||||||
|
-> the signature PASSED and only the creator session is missing. That is
|
||||||
|
good news: it means Phase 1 is pure-request after all.
|
||||||
|
200 -> we are through, and the payload is captured for field mapping.
|
||||||
|
|
||||||
|
Cookies are read out of the browser over CDP and never printed -- they are
|
||||||
|
credentials, and this script has no reason to echo them.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import urllib.parse
|
||||||
|
from datetime import datetime, time, timedelta
|
||||||
|
|
||||||
|
# The XYW_ scheme, matching both xhshow/config/config.py and the independent
|
||||||
|
# reverse-engineering in xiaohongshu-cli. Constants are byte-identical in both.
|
||||||
|
XYW_AES_KEY = b"7cc4adla5ay0701v"
|
||||||
|
XYW_AES_IV = b"4uzjr7mbsibcaldp"
|
||||||
|
XYW_ENV_FLAGS = "0|0|0|1|0|0|1|0|0|0|1|0|0|0|0|1|0|0|0"
|
||||||
|
|
||||||
|
CREATOR_ORIGIN = "https://creator.xiaohongshu.com"
|
||||||
|
# The data-analysis note list. This is the page the creator console itself calls,
|
||||||
|
# and it is where exposure/views live.
|
||||||
|
NOTE_LIST_PATH = "/api/galaxy/creator/datacenter/note/analyze/list"
|
||||||
|
|
||||||
|
USER_AGENT = (
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||||
|
"(KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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 = "ugc", data: dict | None = None) -> dict[str, str]:
|
||||||
|
"""Mirror of xiaohongshu-cli's creator_signing.sign_creator.
|
||||||
|
|
||||||
|
Written out rather than imported so the probe can vary ``api`` and ``app_id``
|
||||||
|
independently -- figuring out which combination the gateway accepts is the
|
||||||
|
entire point of running it.
|
||||||
|
"""
|
||||||
|
content = api
|
||||||
|
if data is not None:
|
||||||
|
content += json.dumps(data, separators=(",", ":"), ensure_ascii=False)
|
||||||
|
|
||||||
|
digest = hashlib.md5(content.encode("utf-8")).hexdigest()
|
||||||
|
timestamp_ms = int(datetime.now().timestamp() * 1000)
|
||||||
|
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": "56",
|
||||||
|
"signType": "x2",
|
||||||
|
"appId": app_id,
|
||||||
|
"signVersion": "1",
|
||||||
|
"payload": _aes_encrypt_hex(encoded),
|
||||||
|
}
|
||||||
|
x_s = "XYW_" + base64.b64encode(
|
||||||
|
json.dumps(envelope, separators=(",", ":")).encode("utf-8")
|
||||||
|
).decode("utf-8")
|
||||||
|
return {"x-s": x_s, "x-t": str(timestamp_ms)}
|
||||||
|
|
||||||
|
|
||||||
|
def build_query(start_days_ago: int, end_days_ago: int, page_size: int = 10) -> str:
|
||||||
|
"""Query string exactly as the working collector builds it: epoch millis."""
|
||||||
|
today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
|
||||||
|
def ms(days_ago: int, at_end: bool) -> int:
|
||||||
|
day = (today - timedelta(days=days_ago)).date()
|
||||||
|
clock = time(23, 59, 59) if at_end else time(0, 0, 0)
|
||||||
|
return int(datetime.combine(day, clock).timestamp() * 1000)
|
||||||
|
|
||||||
|
return urllib.parse.urlencode(
|
||||||
|
{
|
||||||
|
"post_begin_time": ms(start_days_ago, False),
|
||||||
|
"post_end_time": ms(end_days_ago, True),
|
||||||
|
"type": 0,
|
||||||
|
"page_size": page_size,
|
||||||
|
"page_num": 1,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def cookies_from_cdp() -> dict[str, str]:
|
||||||
|
"""Session cookies for creator.xiaohongshu.com, read out of the live browser."""
|
||||||
|
from playwright.async_api import async_playwright
|
||||||
|
|
||||||
|
playwright = await async_playwright().start()
|
||||||
|
try:
|
||||||
|
browser = await playwright.chromium.connect_over_cdp(
|
||||||
|
"http://127.0.0.1:9222", timeout=15000
|
||||||
|
)
|
||||||
|
jars = {}
|
||||||
|
for context in browser.contexts:
|
||||||
|
for cookie in await context.cookies():
|
||||||
|
jars[cookie["name"]] = cookie["value"]
|
||||||
|
return jars
|
||||||
|
finally:
|
||||||
|
await playwright.stop()
|
||||||
|
|
||||||
|
|
||||||
|
def classify(status: int, body: str) -> str:
|
||||||
|
if status == 406:
|
||||||
|
return "406 —— 网关拒了签名(回退浏览器拦截路线)"
|
||||||
|
if status == 200:
|
||||||
|
return "200 —— 通了,可以考虑解析数据"
|
||||||
|
if status in (401, 403):
|
||||||
|
return f"{status} —— 签名过了,只差创作者会话(好消息)"
|
||||||
|
return f"{status} —— 未知,需要看响应体"
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> int:
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--start-days-ago", type=int, default=30)
|
||||||
|
ap.add_argument("--end-days-ago", type=int, default=0)
|
||||||
|
ap.add_argument("--app-id", default="ugc", help="参考实现用 ugc;xhshow 默认 xhs-pc-web")
|
||||||
|
ap.add_argument("--cookie", default="", help="留空则从 CDP 浏览器读取")
|
||||||
|
ap.add_argument("--show-body", action="store_true", help="打印响应前 800 字符")
|
||||||
|
ap.add_argument(
|
||||||
|
"--no-cookie-header",
|
||||||
|
action="store_true",
|
||||||
|
help="签名照签(仍需 a1)但不发 cookie 头,用来分清"
|
||||||
|
"「空数据是缺会话」还是「接口本身就这样」",
|
||||||
|
)
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
if args.cookie:
|
||||||
|
cookies = dict(
|
||||||
|
pair.split("=", 1) for pair in args.cookie.split("; ") if "=" in pair
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cookies = await cookies_from_cdp()
|
||||||
|
|
||||||
|
print(f" cookie 条数 {len(cookies)},名字: {sorted(cookies)}")
|
||||||
|
if not cookies.get("a1"):
|
||||||
|
print(" ✗ 没有 a1 —— 签名必须用它,无法继续")
|
||||||
|
return 2
|
||||||
|
|
||||||
|
query = build_query(args.start_days_ago, args.end_days_ago)
|
||||||
|
cookie_header = "; ".join(f"{k}={v}" for k, v in cookies.items())
|
||||||
|
headers_common = {
|
||||||
|
"user-agent": USER_AGENT,
|
||||||
|
"accept": "application/json, text/plain, */*",
|
||||||
|
"origin": CREATOR_ORIGIN,
|
||||||
|
"referer": f"{CREATOR_ORIGIN}/statistics/data-analysis",
|
||||||
|
"accept-language": "zh-CN,zh;q=0.9",
|
||||||
|
}
|
||||||
|
if not args.no_cookie_header:
|
||||||
|
headers_common["cookie"] = cookie_header
|
||||||
|
|
||||||
|
# Three signing variants: the reference bakes the query into the signed string,
|
||||||
|
# but the exact form is not documented beyond an example with no query at all.
|
||||||
|
variants = {
|
||||||
|
"path+q(参考实现写法)": f"url={NOTE_LIST_PATH}?{query}",
|
||||||
|
"path only": f"url={NOTE_LIST_PATH}",
|
||||||
|
"裸 path+query(无 url= 前缀)": f"{NOTE_LIST_PATH}?{query}",
|
||||||
|
}
|
||||||
|
|
||||||
|
url = f"{CREATOR_ORIGIN}{NOTE_LIST_PATH}?{query}"
|
||||||
|
async with httpx.AsyncClient(timeout=25, follow_redirects=False) as client:
|
||||||
|
for label, api in variants.items():
|
||||||
|
signature = sign_xyw(api, cookies["a1"], app_id=args.app_id)
|
||||||
|
headers = {**headers_common, **signature}
|
||||||
|
try:
|
||||||
|
response = await client.get(url, headers=headers)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
print(f" [{label}] 请求异常: {exc.__class__.__name__}: {exc}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
print(f"\n [{label}]")
|
||||||
|
print(f" HTTP {response.status_code} {classify(response.status_code, response.text)}")
|
||||||
|
body = response.text or ""
|
||||||
|
if body:
|
||||||
|
print(f" 响应前 160 字符: {body[:160]!r}")
|
||||||
|
if args.show_body and body:
|
||||||
|
print(f" 完整响应: {body[:800]}")
|
||||||
|
|
||||||
|
print("\n 提示:若三种都返回 406,再试 --app-id xhs-pc-web。")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
sys.exit(asyncio.run(main()))
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Phase 0, second half: read the real request off the real page.
|
||||||
|
|
||||||
|
The signed-request probe proved the signature is forgeable and that the main-site
|
||||||
|
cookie authenticates the creator backend -- but the endpoint it called returned an
|
||||||
|
envelope with no payload, which no real list endpoint does. That path came from a
|
||||||
|
third-party repo and may simply be stale.
|
||||||
|
|
||||||
|
So stop guessing at paths and watch the page. This opens the data-analysis page in
|
||||||
|
the browser that is already signed in, records every creator API call the page
|
||||||
|
itself makes, and prints each request's URL, method and the *shape* of the
|
||||||
|
response. The page's own requests are the ground truth.
|
||||||
|
|
||||||
|
Read-only: it navigates and observes. Nothing is submitted.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
DEFAULT_URL = "https://creator.xiaohongshu.com/statistics/data-analysis"
|
||||||
|
|
||||||
|
|
||||||
|
def shape(value, depth: int = 0) -> str:
|
||||||
|
"""Describe a JSON value's structure without dumping its data."""
|
||||||
|
if depth > 3:
|
||||||
|
return "…"
|
||||||
|
if isinstance(value, dict):
|
||||||
|
if not value:
|
||||||
|
return "{}"
|
||||||
|
inner = ", ".join(f"{k}: {shape(v, depth + 1)}" for k, v in list(value.items())[:12])
|
||||||
|
return "{" + inner + "}"
|
||||||
|
if isinstance(value, list):
|
||||||
|
if not value:
|
||||||
|
return "[]"
|
||||||
|
return f"[{len(value)} × {shape(value[0], depth + 1)}]"
|
||||||
|
if isinstance(value, str):
|
||||||
|
# Short values are shown as themselves -- the interesting ones here are
|
||||||
|
# status codes, roles and permission names, and "str(14)" tells you
|
||||||
|
# nothing. Long ones are almost always ids or urls, so only their length.
|
||||||
|
return repr(value) if len(value) <= 40 else f"str({len(value)})"
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return str(value)
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
return str(value)
|
||||||
|
return type(value).__name__
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> int:
|
||||||
|
from playwright.async_api import async_playwright
|
||||||
|
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--url", default=DEFAULT_URL)
|
||||||
|
ap.add_argument("--wait", type=int, default=25, help="观察窗口(秒)")
|
||||||
|
ap.add_argument("--filter", default="/api/galaxy", help="只记录 URL 含此串的请求")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
playwright = await async_playwright().start()
|
||||||
|
seen: Counter[str] = Counter()
|
||||||
|
details: list[str] = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
browser = await playwright.chromium.connect_over_cdp(
|
||||||
|
"http://127.0.0.1:9222", timeout=15000
|
||||||
|
)
|
||||||
|
context = browser.contexts[0]
|
||||||
|
|
||||||
|
async def on_response(response):
|
||||||
|
url = response.url
|
||||||
|
if args.filter not in url:
|
||||||
|
return
|
||||||
|
key = url.split("?")[0]
|
||||||
|
seen[key] += 1
|
||||||
|
if seen[key] > 1:
|
||||||
|
return
|
||||||
|
|
||||||
|
request = response.request
|
||||||
|
line = [f"\n {request.method} {key}"]
|
||||||
|
line.append(f" HTTP {response.status}")
|
||||||
|
|
||||||
|
query = url.split("?", 1)[1] if "?" in url else ""
|
||||||
|
if query:
|
||||||
|
line.append(f" 查询串: {query[:300]}")
|
||||||
|
|
||||||
|
post = request.post_data
|
||||||
|
if post:
|
||||||
|
line.append(f" POST body: {post[:300]}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = await response.json()
|
||||||
|
line.append(f" 响应结构: {shape(payload)[:600]}")
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
text = await response.text()
|
||||||
|
line.append(f" 响应(非JSON)前 200: {text[:200]!r}")
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
line.append(f" 响应不可读: {exc.__class__.__name__}")
|
||||||
|
details.append("\n".join(line))
|
||||||
|
|
||||||
|
context.on("response", on_response)
|
||||||
|
|
||||||
|
page = await context.new_page()
|
||||||
|
try:
|
||||||
|
await page.goto(args.url, wait_until="domcontentloaded", timeout=45000)
|
||||||
|
print(f" 落地 URL: {page.url[:120]}")
|
||||||
|
title = await page.title()
|
||||||
|
print(f" 标题: {title[:80]!r}")
|
||||||
|
# A creator console that is genuinely reachable renders its shell; a
|
||||||
|
# login gate does not. This is the cheapest "are we in?" signal.
|
||||||
|
for label, selector in (
|
||||||
|
("登录表单", "//input[@type='password']"),
|
||||||
|
("扫码登录", "//*[contains(@class,'qrcode') or contains(@class,'qr-code')]"),
|
||||||
|
):
|
||||||
|
if await page.locator(selector).count() > 0:
|
||||||
|
print(f" ★ 页面上出现「{label}」—— 这个账号似乎没有创作者后台会话")
|
||||||
|
|
||||||
|
print(f"\n 观察 {args.wait} 秒,记录页面自己发的请求…")
|
||||||
|
await asyncio.sleep(args.wait)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
await page.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
context.remove_listener("response", on_response)
|
||||||
|
|
||||||
|
if not details:
|
||||||
|
print("\n ★ 没有捕获到任何匹配的请求 —— 页面很可能停在登录页,没有发出数据请求")
|
||||||
|
for block in details:
|
||||||
|
print(block)
|
||||||
|
|
||||||
|
print(f"\n 捕获到的接口(去重): {len(seen)}")
|
||||||
|
for key, count in seen.most_common():
|
||||||
|
print(f" ×{count} {key}")
|
||||||
|
finally:
|
||||||
|
await playwright.stop()
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(asyncio.run(main()))
|
||||||
Reference in New Issue
Block a user