移植自 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 带网关原话 / 正常返回)。
152 lines
5.4 KiB
Python
152 lines
5.4 KiB
Python
"""
|
||
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()),
|
||
}
|