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,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
|
||||
Reference in New Issue
Block a user