移植自 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 带网关原话 / 正常返回)。
351 lines
15 KiB
Python
351 lines
15 KiB
Python
"""百炼(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)
|