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,211 @@
"""
routes/ave_scene.py — 场景生成器(分层字段 → 提示词 → 图像)
与角色生成器对称的完整能力:
· GET /scene-schema 字段定义(10 层)
· GET /scene-views 视角定义(5 个)
· POST /scene/preview 提示词预览(**零费用**)
· POST /scene/generate 生成(单张 / 套装;**需 confirm_cost**)
· GET /scene/jobs/{job_id} 进度
"""
from __future__ import annotations
import sys
import threading
import uuid
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, HTTPException
from routes.ave_assets import _AVE_SCRIPTS, _store, logger
router = APIRouter()
_SCENE_JOBS: dict = {}
@router.get("/schema")
def api_scene_schema():
"""场景分层字段定义(10 层,供前端渲染表单)"""
try:
if str(_AVE_SCRIPTS) not in sys.path:
sys.path.insert(0, str(_AVE_SCRIPTS))
from scene_generator.prompt_assembler import (
SCENE_LAYERS, SCENE_VIEWS, scene_layer_summary, parse_prompt_schema)
except Exception as e:
raise HTTPException(500, detail=f"场景模块加载失败: {e}")
return {"status": "ok", "layers": SCENE_LAYERS, "views": SCENE_VIEWS,
"summary": scene_layer_summary(), "parse_schema": parse_prompt_schema()}
@router.get("/presets")
def api_scene_presets():
"""场景预设(成套提示词,**零费用**)
返回 `config/scene_presets.yaml` —— 前端可一键载入各机位提示词。
"""
from pathlib import Path as _P
import yaml as _yaml
try:
from routes.ave_assets import _AVE_CONFIG
cands = [_P(_AVE_CONFIG) / "scene_presets.yaml"]
except Exception:
cands = []
cands.append(_P(__file__).resolve().parents[2] / "09_ave" / "config" / "scene_presets.yaml")
p = next((c for c in cands if c.exists()), None)
if not p:
return {"status": "ok", "presets": {}, "note": "预设文件不存在"}
try:
d = _yaml.safe_load(p.read_text(encoding="utf-8")) or {}
except Exception as e:
raise HTTPException(500, detail=f"预设解析失败: {e}")
return {"status": "ok", "presets": d.get("presets") or {}, "source": str(p)}
@router.post("/preview")
def api_scene_preview(data: dict):
"""组装场景提示词(**纯计算,零费用**)
Body: { layers: {...}, view?: "wide"|"front"|..., extra?: str, set?: bool }
· set=true → 返回 5 视角完整套装提示词
"""
try:
if str(_AVE_SCRIPTS) not in sys.path:
sys.path.insert(0, str(_AVE_SCRIPTS))
from scene_generator.prompt_assembler import (
build_scene_prompt, build_scene_view_prompt, build_scene_set)
except Exception as e:
raise HTTPException(500, detail=f"场景模块加载失败: {e}")
data = data or {}
layers = data.get("layers") or {}
prompts = data.get("prompts") or {}
preset_id = (data.get("preset_id") or "").strip()
# ★ 预设模式:从 config/scene_presets.yaml 取该预设的各机位成品提示词
if preset_id and not prompts:
try:
from pathlib import Path as _P
import yaml as _yaml
from routes.ave_assets import _AVE_CONFIG
pp = _P(_AVE_CONFIG) / "scene_presets.yaml"
if pp.exists():
_d = _yaml.safe_load(pp.read_text(encoding="utf-8")) or {}
prompts = ((_d.get("presets") or {}).get(preset_id) or {}).get("slots") or {}
except Exception as e:
raise HTTPException(400, detail=f"预设读取失败: {e}")
if not layers and not prompts:
raise HTTPException(400, detail="需要 layers(场景字段)、prompts(各机位成品提示词)或 preset_id(预设)之一")
extra = (data.get("extra") or "").strip()
if data.get("set"):
return {"status": "ok", "set": build_scene_set(layers, prompts=prompts, extra=extra)}
view = (data.get("view") or "").strip()
# ★ 优先用成品提示词(用户自带 / 预设)
if prompts and str(prompts.get(view) or "").strip():
prompt = str(prompts[view]).strip()
elif view:
prompt = build_scene_view_prompt(view, layers, extra=extra)
else:
prompt = build_scene_prompt(layers, extra=extra)
return {"status": "ok", "view": view or "(主图)", "prompt": prompt}
@router.post("/generate")
def api_scene_generate(data: dict):
"""生成场景图(**真实扣费**,约 ¥0.2/张;套装 5 张 ≈ ¥1)
Body: {
layers: {...}, scene_name?: str, view?: str, # 单张模式
set?: bool, # 套装模式(5 视角)
anchor_image?: str, # 复用已有主图
confirm_cost: true
}
"""
if not data.get("confirm_cost"):
raise HTTPException(400, detail="⛔ 生成场景图会真实扣费(约 ¥0.2/张),需显式确认(confirm_cost=true)")
layers = data.get("layers") or {}
prompts = data.get("prompts") or {}
preset_id = (data.get("preset_id") or "").strip()
# ★ 预设模式:从 config/scene_presets.yaml 取该预设的各机位成品提示词
if preset_id and not prompts:
try:
from pathlib import Path as _P
import yaml as _yaml
from routes.ave_assets import _AVE_CONFIG
pp = _P(_AVE_CONFIG) / "scene_presets.yaml"
if pp.exists():
_d = _yaml.safe_load(pp.read_text(encoding="utf-8")) or {}
prompts = ((_d.get("presets") or {}).get(preset_id) or {}).get("slots") or {}
except Exception as e:
raise HTTPException(400, detail=f"预设读取失败: {e}")
if not layers and not prompts:
raise HTTPException(400, detail="需要 layers(场景字段)、prompts(各机位成品提示词)或 preset_id(预设)之一")
job_id = uuid.uuid4().hex[:12]
_SCENE_JOBS[job_id] = {"job_id": job_id, "status": "running", "progress": 0,
"note": "准备中", "result": None, "error": None}
def _run():
def _cb(p, n):
j = _SCENE_JOBS.get(job_id)
if j:
j["progress"] = p
j["note"] = n
try:
if str(_AVE_SCRIPTS) not in sys.path:
sys.path.insert(0, str(_AVE_SCRIPTS))
from scene_generator.generator import build_scene_set_images, generate_scene_image
from scene_generator.prompt_assembler import build_scene_prompt, build_scene_view_prompt
name = (data.get("scene_name") or "").strip()
extra = (data.get("extra") or "").strip()
if data.get("set"):
out = build_scene_set_images(
layers, scene_name=name,
anchor_image=(data.get("anchor_image") or "").strip(),
extra=extra, prompts=prompts, progress_cb=_cb)
else:
# 单张模式
from character_sheet import CACHE_DIR
view = (data.get("view") or "wide").strip()
# ★ 优先用成品提示词(用户自带 / 预设)
if prompts and str(prompts.get(view) or "").strip():
prompt = str(prompts[view]).strip()
elif view != "wide":
prompt = build_scene_view_prompt(view, layers, extra=extra)
else:
prompt = build_scene_prompt(layers, extra=extra)
base = CACHE_DIR.parent / "scene_sheet" / (name or "scene")
base.mkdir(parents=True, exist_ok=True)
dst = base / f"{view}.png"
_cb(30, "生成中")
r = generate_scene_image(prompt, str(dst),
ref_image=(data.get("anchor_image") or "").strip())
out = {"views": {view: {"path": r.get("path", ""), "ok": r.get("ok"),
"error": r.get("error", "")}},
"done": 1 if r.get("ok") else 0, "cost_estimate": 0.2,
"note": "单张场景图完成" if r.get("ok") else f"失败: {r.get('error','')}"}
if r.get("ok") and name:
try:
from scene_generator.generator import _save_scene_to_assets
out["saved_to"] = _save_scene_to_assets(name, out, base)
except Exception as e:
out["save_error"] = str(e)[:200]
_SCENE_JOBS[job_id].update({"status": "done", "progress": 100,
"note": out.get("note", "完成"), "result": out})
except Exception as e:
logger.warning("场景生成失败 %s: %s", job_id, e)
_SCENE_JOBS[job_id].update({"status": "failed", "error": str(e)[:400]})
threading.Thread(target=_run, daemon=True).start()
return {"status": "ok", "job_id": job_id,
"message": "已开始生成。用 GET /api/ave/scene/jobs/{job_id} 查进度。"}
@router.get("/jobs/{job_id}")
def api_scene_job(job_id: str):
j = _SCENE_JOBS.get(job_id)
if not j:
raise HTTPException(404, detail="任务不存在")
return j