feat(monitor): 抖音 Web 接口客户端 —— 绕开爬虫子进程,直接发 HTTP
Deploy VitePress site to Pages / build (push) Canceled after 0s
Deploy VitePress site to Pages / Deploy (push) Canceled after 0s

移植自 mac-agent-os 的 mediacrawler_adapter:不起子进程、不开页面,用浏览器里那份
登录态直接调抖音 Web 接口。产物键名照抄 store/douyin,所以 ingest 那条链路一个字不用改。

**目前能用的(真环境实测,非推断)**:

    profile/other : 200, 7075 字节  —— 博主主页指标(粉丝/获赞/作品数/昵称)
    aweme/detail  : 200, 45425 字节 —— 单条作品详情(含点赞/评论/收藏/分享)

**目前不能用的:作品列表 `aweme/post`。** 两个互相独立的原因:
  1. 这个接口被抖音单独升级成了真校验:不带 x-tt-argus 回 403「Uifid Not Found」,
     带上 dummy 值回 200 + **空 body**。也就是说「头在不在」骗得过,「真校验」过不了。
     同一套头打 profile/other 和 aweme/detail 都是通的 —— 抖音是挑着接口加保护的,
     挑中的恰好是「批量拉作品列表」这个最敏感的动作。
  2. 改走页面截获也不行:CDP 浏览器打开博主主页会落到「验证码中间页」(当天大量探测的
     代价,过几小时要重测)。

  所以现在的边界是:**已知作品的指标刷新能做,自动发现新作品做不了**。

**排查中控住变量后得到的两条事实**(都写进注释了):
  · `Accept` / `Accept-Language` / `Referer` 才是主页接口能返回真数据的原因 —— 只有
    UA+client hints+Cookie 时是 200 但仅 121 字节的空壳,补上这三个头变 7074 字节。
    (我先前猜的 sec-ch-ua 不是关键。)
  · 因此 UA 与 client hints 必须**成套地取自同一个浏览器**,所以 BrowserIdentity 一次
    从 CDP 取齐 cookie + UA + hints,而不是各自写死。

「200 + 空 body 必须当场报错」也是刻意写死的:放过去它会在下游变成「这个博主没作品」,
把一次失败伪装成一条正常结果 —— 爬虫那条路正是这么栽的,还被翻译成「账号被封」。

测试 +11:cookie 解析、请求头成套性(含 uifid 缺失/回退)、产物键名与 store 对齐、
以及 _get 的三条失败路径(空 body / 403 带网关原话 / 正常返回)。
This commit is contained in:
2026-10-10 17:12:25 +08:00
parent 21ff01b894
commit 2112c1a870
1601 changed files with 367425 additions and 0 deletions
@@ -0,0 +1,151 @@
"""
lib.kling_webhook — 可灵 Webhook 回调验签(按官方规范)
=========================================================
官方文档:https://klingai.com/document-api/api/get-started/callbacks
回调请求头(3 个):
webhook-id 任务/事件 ID(参与签名)
webhook-timestamp Unix 秒(参与签名,±5 分钟防重放)
webhook-signature 形如 "v1,<base64>" 或 "v1,<a> v1,<b>"(可能多个)
验签规则:
signedContent = f"{webhook-id}.{webhook-timestamp}.{rawBody}"
key = base64Decode(secret.removeprefix("whsec_"))
expected = base64(HMAC-SHA256(key, signedContent))
与每个 "v1,<sig>" 做**常量时间比较**,任一匹配即通过
安全要点:
· secret 只从配置读取(agent-local,600 权限,不进 git),不写日志
· 常量时间比较(hmac.compare_digest)防时序攻击
· 时间窗校验防重放
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import logging
import os
import time
from pathlib import Path
from typing import Any, Optional
log = logging.getLogger("kling.webhook")
TOLERANCE_SECONDS = 5 * 60 # 官方建议 ±5 分钟
def load_webhook_secret() -> str:
"""读取 webhook secret(配置来源:环境变量 > agent-local local.yaml → kling.webhook_secret)"""
if os.environ.get("KLING_WEBHOOK_SECRET"):
return os.environ["KLING_WEBHOOK_SECRET"].strip()
try:
import yaml
base = Path(os.environ.get("AGENT_LOCAL")
or (Path.home() / "workbuddy-agent-os" / "agent-local"))
p = base / "tools" / "ave" / "config" / "local.yaml"
if p.exists():
d = yaml.safe_load(p.read_text(encoding="utf-8")) or {}
return ((d.get("kling") or {}).get("webhook_secret") or "").strip()
except Exception as e:
log.warning("读取 webhook secret 失败: %s", e)
return ""
def _decode_key(secret: str) -> bytes:
"""去掉 whsec_ 前缀后 Base64 解码(官方要求)"""
raw = secret.strip()
if raw.startswith("whsec_"):
raw = raw[len("whsec_"):]
# 补齐 padding(部分 secret 可能被截断/省略 =)
pad = (-len(raw)) % 4
return base64.b64decode(raw + "=" * pad)
def sign(secret: str, webhook_id: str, timestamp: str, raw_body: bytes) -> str:
"""按官方规范生成签名(供自测/回放用)"""
signed = f"{webhook_id}.{timestamp}.".encode("utf-8") + raw_body
digest = hmac.new(_decode_key(secret), signed, hashlib.sha256).digest()
return base64.b64encode(digest).decode("utf-8")
def verify(secret: str, raw_body: bytes, webhook_id: str,
timestamp: str, signature_header: str,
tolerance: int = TOLERANCE_SECONDS) -> tuple[bool, str]:
"""验签(返回 (通过与否, 原因))
失败原因便于排查(但**不泄露签名/密钥内容**)。
"""
if not secret:
return False, "未配置 webhook_secret"
for name, v in (("webhook-id", webhook_id), ("webhook-timestamp", timestamp),
("webhook-signature", signature_header)):
if not v:
return False, f"缺少请求头 {name}"
# ① 时间窗(防重放)
try:
ts = int(float(timestamp))
except Exception:
return False, "webhook-timestamp 格式错误"
drift = abs(int(time.time()) - ts)
if tolerance and drift > tolerance:
return False, f"时间戳偏差过大({drift}s > {tolerance}s)"
# ② 期望签名
try:
expected = sign(secret, webhook_id, timestamp, raw_body)
except Exception as e:
return False, f"签名计算失败: {e}"
# ③ 逐个 "v1,<sig>" 常量时间比较
parts = [p for p in (signature_header or "").split(" ") if p]
for item in parts:
ver, _, sig = item.partition(",")
if ver.strip() != "v1" or not sig:
continue
if hmac.compare_digest(sig.strip(), expected):
return True, "ok"
return False, "签名不匹配"
def parse_callback(body: dict) -> dict:
"""解析回调 body → 统一结构(供业务处理)
官方字段:
id / status(submitted|processing|succeeded|failed) / message /
create_time / update_time / external_id / outputs[]
"""
if not isinstance(body, dict):
return {"ok": False, "reason": "body 不是 JSON 对象"}
outputs = body.get("outputs") or []
files: list[dict] = []
for o in outputs:
if not isinstance(o, dict):
continue
t = o.get("type")
url = o.get("url") or o.get("mp3_url") or o.get("wav_url") or ""
if not url:
continue
files.append({
"type": t, "url": url,
"watermark_url": o.get("watermark_url") or "",
"duration": o.get("duration") or o.get("mp3_duration") or "",
"id": o.get("id") or "",
})
status = (body.get("status") or "").lower()
return {
"ok": True,
"task_id": body.get("id") or "",
"external_id": body.get("external_id") or "",
"status": status, # submitted|processing|succeeded|failed
"succeeded": status == "succeeded",
"failed": status == "failed",
"message": body.get("message") or "",
"create_time": body.get("create_time"),
"update_time": body.get("update_time"),
"files": files,
"raw_keys": sorted(body.keys()),
}