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,262 @@
"""
routes/ave_creatives.py — 创意库 API(GapEngine 落地)
前缀:/api/ave/creatives
组成:
· 创意卡 CRUD(创意库)
· 参数库读写(创意工坊 ①六层参数)
· 矩阵配置读写(②负面矩阵 / ③正向矩阵 / ④道具场景)
· 候选生成(⑤生成&筛选,LLM 出 30 条)
· 缺口试算(Tab① 实时反馈)
⚠️ 路由顺序:静态段(stats/library/matrix/generate/calculate)必须定义在
`/{cid}` 通配之前,否则会被吞掉。
"""
import logging
import os
import sys
from pathlib import Path
import yaml
from fastapi import APIRouter, HTTPException
logger = logging.getLogger("dashboard.ave_creatives")
_SCRIPTS = Path(os.environ.get(
"AGENT_SYNC", Path(__file__).resolve().parent.parent.parent)) / "05_tools" / "09_ave" / "scripts"
if str(_SCRIPTS) not in sys.path:
sys.path.insert(0, str(_SCRIPTS))
router = APIRouter(prefix="/api/ave/creatives", tags=["ave_creatives"])
def _ge():
"""惰性导入 gap_engine(避免启动顺序问题)"""
try:
import gap_engine
return gap_engine
except Exception as e:
raise HTTPException(500, detail=f"创意引擎不可用: {e}")
# ═══════════════════════════════════════════════════════════
# 参数库(① 六层参数)
# ═══════════════════════════════════════════════════════════
@router.get("/library")
def api_library():
"""六层参数库全量(Tab① 平铺展示用)"""
ge = _ge()
lib = ge.load_library()
layers = []
for L in lib.layers:
layers.append({
"id": L.id, "name": L.name,
"params": [{
"id": p.id, "name": p.name,
"values": [{"value": v.value, "intensity": v.intensity, "cue": v.cue}
for v in p.values],
} for p in L.params],
})
n_param = sum(len(L.params) for L in lib.layers)
n_val = sum(len(p.values) for L in lib.layers for p in L.params)
return {"layers": layers, "stats": {"layers": len(layers), "params": n_param, "values": n_val}}
@router.put("/library")
def api_library_save(data: dict):
"""保存参数库(整份 YAML 文本;供高级编辑)"""
ge = _ge()
text = (data or {}).get("yaml_text", "")
if not text:
raise HTTPException(400, detail="需要 yaml_text")
try:
return {"status": "ok", "stats": ge.save_library_text(text)}
except Exception as e:
raise HTTPException(400, detail=str(e))
# ═══════════════════════════════════════════════════════════
# 矩阵配置(②③④)
# ═══════════════════════════════════════════════════════════
@router.get("/matrix")
def api_matrix():
"""负面/正向/道具场景 + 生成参数"""
return _ge().load_matrix()
@router.put("/matrix")
def api_matrix_save(data: dict):
"""保存矩阵(②负面勾选 / ③正向勾选 / ④道具场景 + presets)"""
body = (data or {}).get("matrix") or data
if not isinstance(body, dict) or "negative" not in body:
raise HTTPException(400, detail="矩阵格式错误:需要 negative 字段")
return {"status": "ok", "matrix": _ge().save_matrix(body)}
# ═══════════════════════════════════════════════════════════
# 生成与试算(⑤)
# ═══════════════════════════════════════════════════════════
@router.post("/generate")
def api_generate(data: dict):
"""生成候选创意(LLM 出 N 条 → 缺口分 → 过滤加权 → 排序)
Body: { brief: str, n?: int, matrix?: dict,
selections?: [{layer_id,param_id,value}] } # 参数框架("按当前参数生成")
selections 为空 = 完全随机模式(AI 自由配参数)
"""
ge = _ge()
brief = (data or {}).get("brief", "").strip()
if not brief:
raise HTTPException(400, detail="需要 brief(1-2 句话场景诉求)")
try:
return {"status": "ok", **ge.generate_candidates(
brief, n=int((data or {}).get("n") or 0),
matrix=(data or {}).get("matrix") or None,
constraint_selections=(data or {}).get("selections") or None)}
except Exception as e:
logger.warning("生成候选失败: %s", e)
raise HTTPException(500, detail=str(e))
@router.post("/preselect")
def api_preselect(data: dict):
"""AI 参数预选(Tab① 用):按场景 + 矩阵给 N 套七层参数基础搭配
Body: { brief: str, n_schemes?: int, matrix?: dict }
返回每套含 selections + 缺口分 + 思路,供前端一键填入 Tab① 后微调。
"""
ge = _ge()
brief = (data or {}).get("brief", "").strip()
if not brief:
raise HTTPException(400, detail="需要 brief(1-2 句话场景诉求)")
try:
return {"status": "ok", **ge.preselect_params(
brief, n_schemes=int((data or {}).get("n_schemes") or 3),
matrix=(data or {}).get("matrix") or None)}
except Exception as e:
logger.warning("AI 预选失败: %s", e)
raise HTTPException(500, detail=str(e))
@router.post("/calculate")
def api_calculate(data: dict):
"""缺口试算(Tab① 手动挑参数 → 即时看缺口分/联想卡,零费用不调 LLM)
Body: { selections: [{layer_id, param_id, value}] }
"""
ge = _ge()
lib = ge.load_library()
raw = (data or {}).get("selections") or []
sels = []
for rs in raw:
lid, pid, val = rs.get("layer_id", ""), rs.get("param_id", ""), rs.get("value", "")
pv = lib.find_value(lid, pid, val)
if pv is None:
continue
sels.append(ge.Selection(layer_id=lid, param_id=pid, param_name=lib.param_name(lid, pid),
value=pv.value, intensity=pv.intensity, cue=pv.cue))
if len(sels) < 2:
return {"status": "ok", "insufficient": True,
"hint": "至少选 2 个参数才能计算缺口(照原文校验)", "count": len(sels)}
g = ge.calc_gap(sels)
return {"status": "ok", "insufficient": False, **g.model_dump()}
# ═══════════════════════════════════════════════════════════
# 创意卡 CRUD(创意库)
# ═══════════════════════════════════════════════════════════
@router.get("/stats")
def api_stats():
return {"status": "ok", **_ge().store.stats()}
@router.get("")
@router.get("/")
def api_list(q: str = "", status: str = "", inject_mode: str = "",
sort: str = "updated_desc", limit: int = 200):
"""创意卡列表"""
ge = _ge()
cards = ge.store.list_cards(q=q, status=status, inject_mode=inject_mode,
sort=sort, limit=min(int(limit), 1000))
return {"status": "ok", "total": len(cards), "cards": cards}
@router.post("")
@router.post("/")
def api_create(data: dict):
"""新建创意卡
两种来源:
1) { candidate: {...}, brief, matrix, inject_mode } ← 从生成结果保存
2) { card: {...} } ← 手动/编辑后保存
"""
ge = _ge()
try:
if (data or {}).get("candidate"):
cand = ge.ComboCandidate(**(data["candidate"]))
card = ge.candidate_to_card(
cand, brief=(data or {}).get("brief", ""),
title=(data or {}).get("title", ""),
matrix=(data or {}).get("matrix") or None,
inject_mode=(data or {}).get("inject_mode", "reference"))
return {"status": "ok", "card": ge.store.create(card)}
if (data or {}).get("card"):
card = ge.CreativeCard(**(data["card"]))
return {"status": "ok", "card": ge.store.create(card)}
raise HTTPException(400, detail="需要 candidate 或 card")
except HTTPException:
raise
except Exception as e:
raise HTTPException(400, detail=f"创建失败: {e}")
@router.post("/save-batch")
def api_save_batch(data: dict):
"""批量保存(筛选后一次入库)
Body: { cards: [{candidate, brief, inject_mode, title}], matrix? }
"""
ge = _ge()
items = (data or {}).get("cards") or []
if not items:
raise HTTPException(400, detail="需要 cards 数组")
saved = []
for it in items:
try:
cand = ge.ComboCandidate(**(it.get("candidate") or {}))
card = ge.candidate_to_card(
cand, brief=it.get("brief", ""), title=it.get("title", ""),
matrix=(data or {}).get("matrix") or None,
inject_mode=it.get("inject_mode", "reference"))
saved.append(ge.store.create(card))
except Exception as e:
logger.warning("批量保存单条失败: %s", e)
return {"status": "ok", "saved": len(saved), "cards": saved}
@router.get("/{cid}")
def api_get(cid: str):
card = _ge().store.get(cid)
if not card:
raise HTTPException(404, detail="创意卡不存在")
return {"status": "ok", "card": card}
@router.patch("/{cid}")
def api_update(cid: str, data: dict):
"""更新创意卡(含 inject_mode / status / title 等)"""
card = _ge().store.update(cid, data or {})
if not card:
raise HTTPException(404, detail="创意卡不存在")
return {"status": "ok", "card": card}
@router.delete("/{cid}")
def api_delete(cid: str):
if not _ge().store.delete(cid):
raise HTTPException(404, detail="创意卡不存在")
return {"status": "ok", "deleted": cid}