feat(monitor): 抖音 Web 接口客户端 —— 绕开爬虫子进程,直接发 HTTP
移植自 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:
@@ -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']}")
|
||||
Reference in New Issue
Block a user