Files
MediaCrawler/mac-agent-os-main/05_tools/09_ave/scripts/capabilities/dashscope_video.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

351 lines
15 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.
"""百炼(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)