Files
MediaCrawler/mac-agent-os-main/05_tools/10_dashboard/routes/ave_scene.py
T
butubb 2112c1a870
Deploy VitePress site to Pages / build (push) Canceled after 0s
Deploy VitePress site to Pages / Deploy (push) Canceled after 0s
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 带网关原话 / 正常返回)。
2026-10-10 17:12:25 +08:00

212 lines
9.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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