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,382 @@
"""
capability_matcher — 技术优选层(创作意图 → 技术方案 + 缺口报告)
职责:
1. 读分镜(Shot 列表,含 tech_requirements 技术需求声明)
2. 对照能力目录(capabilities.yaml)与可行性规则库(feasibility_rules.yaml)
3. 产出每个镜头的「技术方案」(用什么能力/引擎/参数)与「缺口清单」
4. 计算能力目录指纹(版本+内容 hash),供「能力更新后自动升级方案」使用
设计原则:
· 导演只管创作表达,不指定工具;本层负责把需求翻译成技术方案
· 缺口是显性产物:无能力满足的需求 → 缺口清单 → 驱动能力建设
· 能力目录更新 → 指纹变化 → 自动标记「方案可升级 / 缺口已解决」
用法:
from capability_matcher import match_storyboard, catalog_fingerprint, check_upgradable
plan = match_storyboard(storyboard_dict)
fp = catalog_fingerprint()
up = check_upgradable(old_plan)
"""
from __future__ import annotations
import hashlib
import sys
from datetime import datetime, timezone
from pathlib import Path
import yaml
_SCRIPTS = Path(__file__).resolve().parent
if str(_SCRIPTS) not in sys.path:
sys.path.insert(0, str(_SCRIPTS))
from lib.logger import get_logger # noqa: E402
log = get_logger("capability_matcher")
_AVE_DIR = _SCRIPTS.parent
_CAPABILITIES = _AVE_DIR / "config" / "capabilities.yaml"
_RULES = _AVE_DIR / "config" / "feasibility_rules.yaml"
# 生成能力 → 能力目录 id 映射(分镜 generation_capability / type → capability id)
_CAP_MAP = {
"text_to_video": "text_to_video",
"image_to_video": "image_to_video",
"vfx": "vfx_apply",
"comfyui": "comfyui_workflow",
"3d_character": "character_3d",
}
def _now() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
# ════════════════════════════════════════════════════════════
# 目录加载与指纹
# ════════════════════════════════════════════════════════════
def load_capabilities() -> dict:
try:
return yaml.safe_load(_CAPABILITIES.read_text(encoding="utf-8")) or {}
except Exception as e:
log.warning("能力目录读取失败: %s", e)
return {}
def load_rules() -> dict:
try:
return yaml.safe_load(_RULES.read_text(encoding="utf-8")) or {}
except Exception as e:
log.warning("规则库读取失败: %s", e)
return {}
def catalog_fingerprint() -> dict:
"""能力目录 + 规则库的内容指纹(版本号 + hash,两者任一变化都能检测)"""
def _h(p: Path) -> str:
try:
return hashlib.sha256(p.read_bytes()).hexdigest()[:16]
except Exception:
return ""
caps = load_capabilities()
rules = load_rules()
return {
"caps_version": caps.get("version", ""),
"caps_updated": caps.get("updated", ""),
"caps_hash": _h(_CAPABILITIES),
"rules_version": rules.get("version", ""),
"rules_hash": _h(_RULES),
}
def _cap_index() -> dict:
"""能力 id → 能力定义"""
caps = load_capabilities().get("capabilities", []) or []
return {c.get("id"): c for c in caps if c.get("id")}
# ════════════════════════════════════════════════════════════
# 规则引擎
# ════════════════════════════════════════════════════════════
def _rule_hit(rule: dict, shot: dict) -> bool:
"""判断单条规则是否命中该镜头"""
tr = shot.get("tech_requirements") or {}
cont = (tr.get("continuity") or {})
tg = rule.get("trigger") or {}
t = tg.get("type", "")
if t == "shot_duration_over":
try:
return float(shot.get("duration") or 0) > float(tg.get("value") or 0)
except Exception:
return False
if t == "duration_and_motion":
try:
over = float(shot.get("duration") or 0) > float(tg.get("duration_over") or 0)
except Exception:
over = False
return over and (tr.get("motion_level") in (tg.get("motion_in") or []))
if t == "text_fit":
text = shot.get("text") or ""
if not text:
return False
try:
need = len(str(text).strip()) / float(tg.get("chars_per_sec") or 4.5)
return need > float(shot.get("duration") or 0) + 0.3 # 容差
except Exception:
return False
if t == "tech_flag":
return tr.get(tg.get("field", "")) == tg.get("value")
if t == "tech_continuity":
return cont.get(tg.get("field", "")) in (tg.get("values") or [])
if t == "shot_or_need":
if shot.get("type") == tg.get("shot_type"):
return True
kws = tg.get("need_keywords") or []
blob = " ".join([str(shot.get("visual_description") or ""), str(shot.get("action") or "")]
+ [str(n) for n in (tr.get("needs") or [])])
return any(k in blob for k in kws)
if t == "need_keywords":
blob = " ".join([str(shot.get("visual_description") or ""), str(shot.get("action") or "")]
+ [str(n) for n in (tr.get("needs") or [])])
return any(k in blob for k in (tg.get("keywords") or []))
if t == "manual_check":
return False # 人工核对项,不在自动匹配中命中(由前端提示)
return False
# ════════════════════════════════════════════════════════════
# 基础能力判定(镜头 → 用哪个生成能力)
# ════════════════════════════════════════════════════════════
def base_capability(shot: dict) -> str:
"""镜头的生成能力:显式指定 > 按类型推导
talking_head → image_to_video(绑角色)或 text_to_video
vfx → vfx_apply;comfyui → comfyui_workflow;3d_character → character_3d
其他 → image_to_video(有角色锚点)或 text_to_video
"""
explicit = (shot.get("generation_capability") or "").strip()
if explicit:
return _CAP_MAP.get(explicit, explicit)
t = shot.get("type") or "action"
if t in ("vfx",):
return "vfx_apply"
if t == "comfyui":
return "comfyui_workflow"
if t == "3d_character":
return "character_3d"
# 绑了角色(含多角色演员表 cast_slots)或口播 → 图生视频
_slots = [s for s in (shot.get("cast_slots") or []) if s]
if (shot.get("character_id") or "").strip() or _slots or t == "talking_head":
return "image_to_video"
return "text_to_video"
# ════════════════════════════════════════════════════════════
# 主入口:匹配
# ════════════════════════════════════════════════════════════
def match_storyboard(storyboard: dict | list, project_id: str = "") -> dict:
"""分镜 → 技术方案 + 缺口报告
参数 storyboard: Storyboard.to_dict() 或 Shot 列表
返回:
{
project_id, generated_at, catalog: {...指纹},
shots: [ {shot_id, capability_id, capability_name, provider, maturity,
plan_status, issues:[{rule_id,name,level,problem,action}], duration_slot } ],
gaps: [ {need_key, name, status, problem, action, shot_ids:[...]} ],
summary: {total, ok, downgraded, blocked, gap_count}
}
"""
shots = storyboard.get("shots", []) if isinstance(storyboard, dict) else (storyboard or [])
if isinstance(storyboard, dict) and not project_id:
project_id = storyboard.get("project_id", "")
caps = _cap_index()
rules = (load_rules().get("rules") or [])
needs_map = load_rules().get("capability_needs") or {}
shot_plans: list[dict] = []
gap_acc: dict[str, dict] = {}
for s in shots:
sid = s.get("shot_id", "")
cap_id = base_capability(s)
cap = caps.get(cap_id) or {}
maturity = cap.get("status", "unknown")
providers = cap.get("providers") or []
provider = ""
if providers and isinstance(providers[0], dict):
provider = providers[0].get("name", "")
# 跑规则
issues = []
for r in rules:
if r.get("level") == "info":
continue # info 不进问题列表(可另作提示)
if _rule_hit(r, s):
issues.append({
"rule_id": r.get("id"), "name": r.get("name"),
"level": r.get("level"), "nature": r.get("nature", ""),
"problem": r.get("problem", ""),
"action": r.get("action", ""),
})
nk = r.get("need")
if nk:
g = gap_acc.setdefault(nk, {
"need_key": nk,
"name": (needs_map.get(nk) or {}).get("name", nk),
"status": (needs_map.get(nk) or {}).get("status", "unknown"),
"note": (needs_map.get(nk) or {}).get("note", ""),
"problem": r.get("problem", ""),
"action": r.get("action", ""),
"shot_ids": [],
})
if sid and sid not in g["shot_ids"]:
g["shot_ids"].append(sid)
# 方案状态
if any(i["level"] == "blocking" for i in issues):
status = "blocked"
elif issues:
status = "downgraded"
elif maturity in ("planned", "missing"):
status = "blocked"
else:
status = "ok"
# 时长档位映射(生成能力档位:5s / 10s)
try:
dur = float(s.get("duration") or 0)
except Exception:
dur = 0.0
slot = 10 if dur > 5 else 5
if dur > 10:
slot = None # 超限,需切条/接续
shot_plans.append({
"shot_id": sid,
"capability_id": cap_id,
"capability_name": cap.get("name", cap_id),
"provider": provider,
"maturity": maturity,
"duration_slot": slot,
"plan_status": status,
"issues": issues,
"manual_override": bool(s.get("tech_plan_manual")),
})
gaps = sorted(gap_acc.values(), key=lambda g: g["need_key"])
summary = {
"total": len(shot_plans),
"ok": sum(1 for p in shot_plans if p["plan_status"] == "ok"),
"downgraded": sum(1 for p in shot_plans if p["plan_status"] == "downgraded"),
"blocked": sum(1 for p in shot_plans if p["plan_status"] == "blocked"),
"gap_count": len(gaps),
}
return {
"project_id": project_id,
"generated_at": _now(),
"catalog": catalog_fingerprint(),
"shots": shot_plans,
"gaps": gaps,
"summary": summary,
}
# ════════════════════════════════════════════════════════════
# 能力更新 → 自动升级检测
# ════════════════════════════════════════════════════════════
def check_upgradable(old_plan: dict) -> dict:
"""对比旧技术方案的能力目录指纹与当前指纹
返回:
{
outdated: bool,
reason: str,
resolved_gaps: [ {need_key, name, was_status, now_status} ], # 缺口已解决
affected_shots: [shot_id], # 需重算的镜头
summary: {resolved, still_open}
}
"""
if not old_plan:
return {"outdated": False, "reason": "无历史方案", "resolved_gaps": [],
"affected_shots": [], "summary": {"resolved": 0, "still_open": 0}}
old_fp = old_plan.get("catalog") or {}
now_fp = catalog_fingerprint()
changed = (old_fp.get("caps_hash") != now_fp.get("caps_hash")
or old_fp.get("rules_hash") != now_fp.get("rules_hash"))
needs_map = load_rules().get("capability_needs") or {}
resolved, still_open, affected = [], [], []
for g in (old_plan.get("gaps") or []):
nk = g.get("need_key")
was = g.get("status")
now = (needs_map.get(nk) or {}).get("status", "missing")
if was in ("missing", "planned") and now in ("ready", "partial"):
resolved.append({"need_key": nk, "name": g.get("name", nk),
"was_status": was, "now_status": now})
affected.extend(g.get("shot_ids") or [])
else:
still_open.append(nk)
if not changed:
reason = "能力目录未变化"
elif resolved:
reason = f"能力目录已更新:{len(resolved)} 项缺口已解决,可重新生成方案"
else:
reason = "能力目录已更新(无缺口变化)"
return {
"outdated": changed,
"reason": reason,
"resolved_gaps": resolved,
"affected_shots": sorted(set(affected)),
"summary": {"resolved": len(resolved), "still_open": len(still_open)},
}
# ── CLI 自测 ──
if __name__ == "__main__":
import json
fp = catalog_fingerprint()
print("目录指纹:", json.dumps(fp, ensure_ascii=False))
demo = {
"project_id": "demo",
"shots": [
{"shot_id": "shot_01", "duration": 4, "type": "talking_head",
"text": "你好,这是测试台词。", "character_id": "c1",
"tech_requirements": {"continuity": {"character": "must", "expression": "prefer"},
"chain_from_prev": False, "motion_level": "small",
"camera_difficulty": "fixed", "needs": []}},
{"shot_id": "shot_02", "duration": 8, "type": "action",
"tech_requirements": {"continuity": {"location": "must"}, "chain_from_prev": True,
"motion_level": "large", "camera_difficulty": "complex",
"needs": ["子弹时间"]}},
{"shot_id": "shot_03", "duration": 12, "type": "action", "text": "",
"tech_requirements": {"needs": ["手部特写"]}},
],
}
plan = match_storyboard(demo)
print("\n方案摘要:", json.dumps(plan["summary"], ensure_ascii=False))
for p in plan["shots"]:
print(f" {p['shot_id']}: {p['capability_name']} slot={p['duration_slot']} status={p['plan_status']} issues={[i['rule_id'] for i in p['issues']]}")
print("\n缺口:")
for g in plan["gaps"]:
print(f" [{g['status']}] {g['name']} ← {g['shot_ids']}")