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,350 @@
"""百炼(DashScope)视频生成 —— 首尾帧 KF2V / 多图参考 R2V
为什么单独一个模块:可灵走 `kling_video.py`(channel=kling),
百炼走这里(channel=dashscope)—— 两家接口完全不同,不能混。
价格对比(`config/video_models.yaml`):
wan2.2-kf2v-flash ¥0.1/秒(480P) ← 首尾帧专用,最省
wan2.6-r2v-flash ¥0.3/秒(720P) ← 多图参考(角色+场景+首帧)
kling-v3 ¥0.6/秒(720P) ← 现行默认
→ 同样 4 镜 5 秒:可灵 ¥12,kf2v ¥2。
调用范式(与 `material_producer/wan2_2/wan2_2.py` 一致,四步):
① 上传图片 → oss:// URL(每张都要)
② POST video-synthesis(X-DashScope-Async: enable)
③ GET /api/v1/tasks/{task_id} 轮询
④ 下载 video_url
字段(来自千问AI平台 OpenAPI spec,见 docs/pipeline_research/03):
KF2V: input.first_frame_url + input.last_frame_url + input.prompt
R2V : input.media[] = [{type: first_frame|reference_image|reference_video, url}]
+ input.prompt
"""
from __future__ import annotations
import base64
import json
import logging
import os
import time
from pathlib import Path
import httpx
import yaml
log = logging.getLogger("ave.dashscope_video")
# ── 端点 ────────────────────────────────────────────────────
# ⚠️ 两个模型走**不同端点**(据官方 OpenAPI 规格,见 docs/pipeline_research/03):
# KF2V(首尾帧) → /services/aigc/image2video/video-synthesis
# R2V (多图参考)→ /services/aigc/video-generation/video-synthesis
BASE = "https://dashscope.aliyuncs.com"
UPLOAD_API = f"{BASE}/api/v1/uploads"
SUBMIT_API_KF2V = f"{BASE}/api/v1/services/aigc/image2video/video-synthesis"
SUBMIT_API_R2V = f"{BASE}/api/v1/services/aigc/video-generation/video-synthesis"
SUBMIT_API = SUBMIT_API_R2V # 兼容旧引用
TASK_API = f"{BASE}/api/v1/tasks/{{task_id}}"
# ⚠️ 所有 httpx 调用都要 trust_env=False —— macOS 系统代理会让
# 国内 API 时通时不通(本项目踩过多次,见 docs/参数速查.md)
_LOCAL_YAML = Path.home() / "workbuddy-agent-os" / "agent-local" / "tools" / "ave" / "config" / "local.yaml"
def api_key() -> str:
"""读百炼 api_key(agent-local,不进 git)"""
env = os.environ.get("DASHSCOPE_API_KEY")
if env:
return env
try:
d = yaml.safe_load(_LOCAL_YAML.read_text(encoding="utf-8")) or {}
return str((d.get("aliyun") or {}).get("api_key") or "")
except Exception as e:
log.warning("读 local.yaml 失败: %s", e)
return ""
def available() -> bool:
return bool(api_key())
# ── ① 上传 ──────────────────────────────────────────────────
def _mime(p: str) -> str:
return {"png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg",
"webp": "image/webp", "bmp": "image/bmp",
"mp4": "video/mp4",
"mp3": "audio/mpeg", "wav": "audio/wav", "m4a": "audio/mp4"}.get(
Path(p).suffix.lower().lstrip("."), "application/octet-stream")
def upload_file(path: str, model: str, key: str = "") -> str:
"""上传到百炼临时存储 → oss:// URL
已上传过(同名 + 同模型)会复用,避免重复上传。
"""
key = key or api_key()
p = Path(path)
if not p.exists():
raise FileNotFoundError(f"文件不存在: {path}")
r = httpx.get(UPLOAD_API, params={"action": "getPolicy", "model": model},
headers={"Authorization": f"Bearer {key}"}, timeout=20, trust_env=False)
r.raise_for_status()
d = r.json()["data"]
oss_key = f"{d['upload_dir']}/{p.name}"
with open(p, "rb") as f:
files = {
"OSSAccessKeyId": (None, d["oss_access_key_id"]),
"policy": (None, d["policy"]),
"Signature": (None, d["signature"]),
"key": (None, oss_key),
"x-oss-object-acl": (None, d["x_oss_object_acl"]),
"x-oss-forbid-overwrite": (None, d["x_oss_forbid_overwrite"]),
"success_action_status": (None, "200"),
"file": (p.name, f, _mime(str(p))),
}
up = httpx.post(d["upload_host"], files=files, timeout=180, trust_env=False)
up.raise_for_status()
return f"oss://{oss_key}"
def _as_url(v: str, model: str, key: str) -> str:
"""本地路径 → oss:// ;已是 http(s)/oss:// /data: 则原样返回"""
if not v:
return ""
s = str(v)
if s.startswith(("http://", "https://", "oss://", "data:")):
return s
return upload_file(s, model, key)
# ── ② 提交 ──────────────────────────────────────────────────
def _submit(payload: dict, key: str, endpoint: str = "") -> str:
headers = {
"Authorization": f"Bearer {key}",
"X-DashScope-Async": "enable",
"X-DashScope-OssResourceResolve": "enable",
"Content-Type": "application/json",
}
r = httpx.post(endpoint or SUBMIT_API_R2V, headers=headers, json=payload,
timeout=40, trust_env=False)
if r.status_code >= 400:
raise RuntimeError(f"百炼提交失败 HTTP {r.status_code}: {r.text[:300]}")
out = (r.json() or {}).get("output") or {}
tid = out.get("task_id")
if not tid:
raise RuntimeError(f"百炼未返回 task_id: {json.dumps(r.json(), ensure_ascii=False)[:300]}")
return tid
def submit_kf2v(first_frame: str, last_frame: str, prompt: str = "", *,
model: str = "wan2.2-kf2v-flash", resolution: str = "480P",
negative_prompt: str = "", prompt_extend: bool = True,
seed: int | None = None, template: str = "",
key: str = "") -> str:
"""首尾帧生视频 —— 给「首帧 + 尾帧」,模型在两帧之间插值
⚠️ 官方规格(docs/pipeline_research/03 §二):
· 端点 = /services/aigc/image2video/video-synthesis(**不是** video-generation)
· duration **固定 5 秒,不可修改** → 不传该参数
· 输出宽高比跟随 first_frame_url
· 用了 template(特效模板)时只需传 first_frame_url
"""
key = key or api_key()
if not key:
raise RuntimeError("未配置百炼 key(agent-local/tools/ave/config/local.yaml → aliyun.api_key)")
if not last_frame:
raise ValueError("KF2V 必须同时给首帧和尾帧")
inp = {"first_frame_url": _as_url(first_frame, model, key)}
if not template:
inp["last_frame_url"] = _as_url(last_frame, model, key)
if prompt:
inp["prompt"] = prompt
if negative_prompt:
inp["negative_prompt"] = negative_prompt[:500]
params: dict = {"resolution": resolution, "prompt_extend": bool(prompt_extend)}
if template:
params["template"] = template
if seed is not None:
params["seed"] = int(seed)
log.info("KF2V 提交:%s · %s · 固定5s · %s", model, resolution,
"模板" if template else "首尾帧")
return _submit({"model": model, "input": inp, "parameters": params}, key, SUBMIT_API_KF2V)
def submit_r2v(media: list[dict], prompt: str = "", *,
model: str = "wan2.6-r2v-flash", resolution: str = "720P",
duration: int = 5, key: str = "") -> str:
"""多图参考生视频
media: [{"type": "first_frame"|"reference_image"|"reference_video", "url": 本地路径或URL}]
最多 5 个(config 里的 limits.max_reference_images)
"""
key = key or api_key()
if not key:
raise RuntimeError("未配置百炼 key")
if not media:
raise ValueError("R2V 至少要给 1 个 media 元素")
if not (prompt or "").strip():
# 官方规格:prompt 为 ★必填(「期望视频内容」)—— 不传会直接失败
raise ValueError("R2V 的 prompt 是必填项(描述期望的视频内容),不能为空")
items = []
for m in media[:5]:
u = _as_url(m.get("url") or "", model, key)
if not u:
continue
it = {"type": m.get("type") or "reference_image", "url": u}
if m.get("reference_voice"):
it["reference_voice"] = m["reference_voice"]
items.append(it)
if not items:
raise ValueError("media 里没有可用的 url")
payload = {
"model": model,
"input": {"media": items},
"parameters": {"resolution": resolution, "duration": duration},
}
if prompt:
payload["input"]["prompt"] = prompt
log.info("R2V 提交:%s · %s · %d 个参考", model, resolution, len(items))
return _submit(payload, key)
# ── ③ 轮询 ──────────────────────────────────────────────────
def poll(task_id: str, *, interval: int = 10, timeout: int = 900,
key: str = "", progress_cb=None) -> str:
"""轮询直到完成 → 返回视频 URL
兼容两种结果结构:
output.video_url (KF2V / R2V 新结构)
output.results.video_url (wan2.2-s2v 旧结构)
"""
key = key or api_key()
headers = {"Authorization": f"Bearer {key}", "X-DashScope-OssResourceResolve": "enable"}
start = time.time()
while time.time() - start < timeout:
r = httpx.get(TASK_API.format(task_id=task_id), headers=headers,
timeout=40, trust_env=False)
r.raise_for_status()
out = (r.json() or {}).get("output") or {}
st = out.get("task_status")
el = int(time.time() - start)
if st == "SUCCEEDED":
url = (out.get("video_url")
or (out.get("results") or {}).get("video_url")
or ((out.get("results") or {}).get("videos") or [{}])[0].get("url"))
if not url:
raise RuntimeError(f"任务成功但没有视频 URL:{json.dumps(out, ensure_ascii=False)[:300]}")
if progress_cb:
progress_cb(f"✅ 生成完成({el}s)")
return url
if st in ("FAILED", "CANCELED", "UNKNOWN"):
code = out.get("code") or "?"
msg = out.get("message") or "?"
raise RuntimeError(f"百炼任务失败 [{code}]: {msg}")
if progress_cb:
progress_cb(f"⏳ 百炼生成中… {el}s({st})")
time.sleep(interval)
raise TimeoutError(f"百炼任务超时(>{timeout}s)")
# ── ④ 下载 ──────────────────────────────────────────────────
def download(url: str, dest: str) -> str:
Path(dest).parent.mkdir(parents=True, exist_ok=True)
with httpx.stream("GET", url, timeout=300, trust_env=False,
follow_redirects=True) as r:
r.raise_for_status()
with open(dest, "wb") as f:
for chunk in r.iter_bytes(1 << 16):
f.write(chunk)
return dest
# ── 一步到位 ────────────────────────────────────────────────
def generate_kf2v(first_frame: str, last_frame: str, dest: str, prompt: str = "",
*, model: str = "wan2.2-kf2v-flash", resolution: str = "480P",
progress_cb=None) -> str:
"""首尾帧一步到位(提交 → 轮询 → 下载)
⚠️ duration 固定 5 秒(官方规格),所以这里不提供 duration 参数。
"""
tid = submit_kf2v(first_frame, last_frame, prompt, model=model, resolution=resolution)
if progress_cb:
progress_cb(f"📤 已提交(task {tid})")
url = poll(tid, progress_cb=progress_cb)
download(url, dest)
return dest
def generate_r2v(media: list[dict], dest: str, prompt: str = "",
*, model: str = "wan2.6-r2v-flash", resolution: str = "720P",
duration: int = 5, progress_cb=None) -> str:
tid = submit_r2v(media, prompt, model=model, resolution=resolution, duration=duration)
if progress_cb:
progress_cb(f"📤 已提交(task {tid})")
url = poll(tid, progress_cb=progress_cb)
download(url, dest)
return dest
# ══════════════════════════════════════════════════════════
# LivePortrait(灵动人像)—— 数字人播报,¥0.02/秒
#
# 人物肖像图 + 语音音频 → 口型同步的播报视频
# ★ 输入正好是我们的现有资产:角色定妆照 + TTS 配音
# ⚠️ 只适合「口播/播报」类镜头(talking_head),有动作的镜头不适用
# ══════════════════════════════════════════════════════════
LIVEPORTRAIT_MODEL = "liveportrait"
def submit_liveportrait(image: str, audio: str, *, template_id: str = "calm",
eye_move_freq: float | None = None,
model: str = LIVEPORTRAIT_MODEL, key: str = "") -> str:
"""数字人播报任务提交
image : 人物肖像图(本地路径或 URL)—— 建议用角色定妆照
audio : 驱动音频(本地路径或 URL)—— TTS 配音;1 秒 ~ 3 分钟
template_id : normal(中等幅度)| calm(小幅度·推荐播报)| active(大幅度·演唱)
"""
key = key or api_key()
if not key:
raise RuntimeError("未配置百炼 key")
if not (image and audio):
raise ValueError("liveportrait 必须同时给 image 和 audio")
inp = {
"image_url": _as_url(image, model, key),
"audio_url": _as_url(audio, model, key),
}
params: dict = {"template_id": template_id if template_id in
("normal", "calm", "active") else "calm"}
if eye_move_freq is not None:
params["eye_move_freq"] = float(eye_move_freq)
log.info("LivePortrait 提交:模板=%s", params["template_id"])
# 与 KF2V 同端点(官方文档:image2video/video-synthesis)
return _submit({"model": model, "input": inp, "parameters": params},
key, SUBMIT_API_KF2V)
def generate_liveportrait(image: str, audio: str, dest: str, *,
template_id: str = "calm", progress_cb=None) -> str:
"""数字人播报一步到位(提交 → 轮询 → 下载)
⚠️ 输出时长由**音频时长**决定(不是固定 5 秒)
"""
tid = submit_liveportrait(image, audio, template_id=template_id)
if progress_cb:
progress_cb(f"📤 已提交(task {tid})")
url = poll(tid, progress_cb=progress_cb)
download(url, dest)
return dest
def estimate_cost(seconds: float, model: str = "wan2.2-kf2v-flash") -> float:
if model == LIVEPORTRAIT_MODEL:
return round(seconds * 0.02, 3)
rate = 0.1 if "kf2v" in model else 0.3
return round(seconds * rate, 2)