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,508 @@
#!/usr/bin/env python3
"""
AVE lipsync — Kling LipSync 集成
功能:
Audio→Video: 输入视频 + 语音音频 → 输出口型对齐的视频
Text→Video: 输入视频 + 文本 → 输出口型对齐的视频 (TTS自动)
API:
fal.ai endpoint (Kling LipSync 托管在 fal.ai)
成本: $0.014/5s ≈ ¥0.10/秒 ≈ ¥0.50/5秒镜头
用法:
python lipsync.py --video clip.mp4 --audio voice.wav --output synced.mp4
python lipsync.py --video clip.mp4 --text "你好世界" --output synced.mp4
集成:
作为口播策略后处理钩子, 在 video_factory.py 中调用
"""
import sys
import os
import json
import hashlib
import time
from pathlib import Path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from lib.config import load_config
from lib.cost_tracker import get_tracker
from lib.logger import get_logger
logger = get_logger("lipsync")
# ── 缓存 ──
CACHE_DIR = Path(os.environ.get("AVE_CACHE_DIR",
str(Path.home() / "workbuddy-agent-os/agent-local/tools/ave/cache/lipsync")))
CACHE_DIR.mkdir(parents=True, exist_ok=True)
# ── fal.ai 端点 ──
# Kling LipSync 通过 fal.ai 提供
FAL_BASE = "https://fal.run"
ENDPOINTS = {
"audio_to_video": f"{FAL_BASE}/fal-ai/kling-video/lipsync/audio-to-video",
"text_to_video": f"{FAL_BASE}/fal-ai/kling-video/lipsync/text-to-video",
}
# ═══════════════════════════════════════════════════════════
# 核心 API
# ═══════════════════════════════════════════════════════════
def lipsync_audio_to_video(
video_path: str,
audio_path: str,
output_path: str = "",
force: bool = False,
api_key: str = "",
) -> str:
"""
Audio→Video 唇形同步
参数:
video_path: 输入视频路径 (2-10s, 720p/1080p, ≤100MB)
audio_path: 输入音频路径 (.mp3/.wav/.m4a, ≤5MB)
output_path: 输出路径
force: 强制重新生成
api_key: fal.ai API Key
返回:
本地输出视频路径
"""
if not output_path:
joint = hashlib.md5(
(video_path + audio_path).encode()
).hexdigest()[:16]
output_path = str(CACHE_DIR / f"lipsync_{joint}.mp4")
# 缓存
if os.path.exists(output_path) and not force:
logger.info(f"缓存命中: {output_path}")
return output_path
cfg = load_config()
api_key = api_key or cfg.get("fal", {}).get("api_key", "")
if not api_key:
raise ValueError("缺少 fal.ai API Key (config fal.api_key)")
logger.info(f"LipSync Audio→Video:")
logger.info(f" video: {video_path}")
logger.info(f" audio: {audio_path}")
# 上传文件或传URL (fal.ai 支持直接传base64或文件URL)
# 方案: 使用 fal.ai 的 upload 端点
video_url = _upload_to_fal(video_path, api_key)
audio_url = _upload_to_fal(audio_path, api_key)
if not video_url or not audio_url:
raise RuntimeError("上传文件到 fal.ai 失败")
logger.info(f" video_url: {video_url[:60]}...")
logger.info(f" audio_url: {audio_url[:60]}...")
# 提交任务
result = _submit_and_poll(
endpoint=ENDPOINTS["audio_to_video"],
payload={
"video_url": video_url,
"audio_url": audio_url,
},
api_key=api_key,
timeout_sec=120,
)
# 下载结果
video_url_res = result.get("video", {}).get("url", "") or result.get("output", "")
if not video_url_res:
raise RuntimeError(f"fal.ai 返回无视频URL: {json.dumps(result, ensure_ascii=False)[:200]}")
logger.info(f" 下载结果中...")
_download(video_url_res, output_path)
# 费用
tracker = get_tracker()
try:
dur = _get_media_duration(output_path)
except Exception:
dur = 5 # 默认5秒
tracker.log("KlingLipSync", duration=dur, note=f"audio-to-video ¥0.014×{dur}s")
sz_mb = os.path.getsize(output_path) / 1024 / 1024
logger.info(f" ✅ LipSync完成: {output_path} ({sz_mb:.1f}MB, {dur:.1f}s)")
return output_path
def lipsync_text_to_video(
video_path: str,
text: str,
voice_id: str = "default",
output_path: str = "",
force: bool = False,
api_key: str = "",
) -> str:
"""
Text→Video 唇形同步 (自动 TTS + 口型对齐, ≤120字)
参数:
video_path: 输入视频路径
text: 口播文本 (≤120字)
voice_id: 音色ID
output_path: 输出路径
force: 强制重新生成
返回:
本地输出视频路径
"""
if len(text) > 120:
logger.warning(f"文本过长 ({len(text)}字), 截断至120字")
text = text[:120]
if not output_path:
joint = hashlib.md5(
(video_path + text).encode()
).hexdigest()[:16]
output_path = str(CACHE_DIR / f"lipsync_txt_{joint}.mp4")
if os.path.exists(output_path) and not force:
logger.info(f"缓存命中: {output_path}")
return output_path
cfg = load_config()
api_key = api_key or cfg.get("fal", {}).get("api_key", "")
if not api_key:
raise ValueError("缺少 fal.ai API Key")
logger.info(f"LipSync Text→Video: text={text[:40]}...")
video_url = _upload_to_fal(video_path, api_key)
if not video_url:
raise RuntimeError("上传视频到 fal.ai 失败")
result = _submit_and_poll(
endpoint=ENDPOINTS["text_to_video"],
payload={
"video_url": video_url,
"text": text,
"voice_id": voice_id,
},
api_key=api_key,
timeout_sec=180,
)
video_url_res = result.get("video", {}).get("url", "") or result.get("output", "")
if not video_url_res:
raise RuntimeError(f"fal.ai 返回无视频URL")
_download(video_url_res, output_path)
sz_mb = os.path.getsize(output_path) / 1024 / 1024
logger.info(f" ✅ LipSync Text→Video完成: {output_path} ({sz_mb:.1f}MB)")
return output_path
# ═══════════════════════════════════════════════════════════
# 内部工具
# ═══════════════════════════════════════════════════════════
def _upload_to_fal(file_path: str, api_key: str) -> str:
"""上传文件到 fal.ai, 返回可访问的 URL"""
import httpx
if not os.path.exists(file_path):
raise FileNotFoundError(f"文件不存在: {file_path}")
with open(file_path, "rb") as f:
resp = httpx.post(
f"{FAL_BASE}/fal-ai/upload",
headers={
"Authorization": f"Key {api_key}",
},
files={"file": f},
timeout=120,
)
resp.raise_for_status()
data = resp.json()
return data.get("url", "")
def _submit_and_poll(
endpoint: str,
payload: dict,
api_key: str,
timeout_sec: int = 120,
) -> dict:
"""提交 fal.ai 任务并轮询, 返回结果"""
import httpx
headers = {
"Authorization": f"Key {api_key}",
"Content-Type": "application/json",
}
# 提交
resp = httpx.post(
endpoint.replace("/run", ""), # 提交到非 /run 端点
headers=headers,
json=payload,
timeout=30,
)
# fal.ai 的 submit API
submit_url = endpoint
resp = httpx.post(submit_url, headers=headers, json=payload, timeout=30)
resp.raise_for_status()
data = resp.json()
# 检查是否直接返回结果(fal.ai 同步模式)
if data.get("status") == "completed" or data.get("video"):
return data
# 获取请求ID进行轮询
request_id = data.get("request_id", "")
if not request_id:
# 有的接口直接返回结果
if data.get("video", {}).get("url"):
return data
raise RuntimeError(f"fal.ai 无 request_id: {json.dumps(data, ensure_ascii=False)[:200]}")
status_url = f"{endpoint}/requests/{request_id}"
logger.info(f" 轮询: {request_id}")
start = time.time()
for i in range(60):
# 指数退避
if i < 5:
time.sleep(3)
elif i < 15:
time.sleep(5)
else:
time.sleep(10)
elapsed = time.time() - start
if elapsed > timeout_sec:
raise TimeoutError(f"fal.ai 超时 ({timeout_sec}s)")
try:
q = httpx.get(status_url, headers=headers, timeout=15)
q.raise_for_status()
status_data = q.json()
status = status_data.get("status", "")
if status == "COMPLETED":
return status_data
elif status in ("FAILED", "ERROR"):
raise RuntimeError(f"fal.ai 失败: {json.dumps(status_data, ensure_ascii=False)[:200]}")
logger.debug(f" [{i}] status={status}")
except httpx.HTTPError as e:
logger.debug(f" [{i}] 查询失败: {e}")
time.sleep(5)
raise TimeoutError("fal.ai 轮询超时")
def _download(url: str, path: str):
"""下载文件"""
import httpx
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
with httpx.stream("GET", url, timeout=600) as r:
r.raise_for_status()
with open(path, "wb") as f:
for chunk in r.iter_bytes(8192):
f.write(chunk)
def _get_media_duration(path: str) -> float:
"""获取媒体时长 (秒) —— 用 lib.ffmpeg(本机 ffprobe 实为 ffmpeg 符号链接)"""
from lib.ffmpeg import probe_duration
return probe_duration(path)
# ═══════════════════════════════════════════════════════════
# CLI
# ═══════════════════════════════════════════════════════════
def cli(args: list[str] | None = None):
import argparse
parser = argparse.ArgumentParser(description="Kling LipSync 唇形同步")
sub = parser.add_subparsers(dest="mode", required=True)
# audio → video
p_av = sub.add_parser("audio-to-video", help="音频驱动唇形同步")
p_av.add_argument("--video", required=True, help="输入视频")
p_av.add_argument("--audio", required=True, help="输入音频")
p_av.add_argument("--output", default="", help="输出路径")
p_av.add_argument("--force", action="store_true", help="强制重新生成")
# text → video
p_tv = sub.add_parser("text-to-video", help="文本驱动唇形同步")
p_tv.add_argument("--video", required=True, help="输入视频")
p_tv.add_argument("--text", required=True, help="口播文本 (≤120字)")
p_tv.add_argument("--voice-id", default="default", help="音色ID")
p_tv.add_argument("--output", default="", help="输出路径")
p_tv.add_argument("--force", action="store_true", help="强制重新生成")
parsed = parser.parse_args(args)
if parsed.mode == "audio-to-video":
out = lipsync_audio_to_video(
video_path=parsed.video,
audio_path=parsed.audio,
output_path=parsed.output,
force=parsed.force,
)
print(f"\n✅ LipSync 完成: {out}")
elif parsed.mode == "text-to-video":
out = lipsync_text_to_video(
video_path=parsed.video,
text=parsed.text,
voice_id=parsed.voice_id,
output_path=parsed.output,
force=parsed.force,
)
print(f"\n✅ LipSync 完成: {out}")
if __name__ == "__main__":
cli()
# ════════════════════════════════════════════════════════════
# ★ 百炼 VideoRetalk(推荐实现,2026-10-03 实测探明)
# 文档:https://help.aliyun.com/zh/model-studio/videoretalk-api
# 优势:用现有百炼 key(无需 fal.ai)· ¥0.08/秒 · 1800 秒免费额度
# 上传复用 AVE 已有的两步式(getPolicy → OSS 直传 → oss://)
# ════════════════════════════════════════════════════════════
DASHSCOPE_BASE = "https://dashscope.aliyuncs.com"
VR_SUBMIT_URL = f"{DASHSCOPE_BASE}/api/v1/services/aigc/image2video/video-synthesis"
VR_TASK_URL = f"{DASHSCOPE_BASE}/api/v1/tasks/{{tid}}"
VIDEORETALK_MODEL = "videoretalk"
VR_PRICE_PER_SEC = 0.08 # 元/秒(官方后付费单价)
VR_FREE_QUOTA_SEC = 1800 # 官方免费额度(秒)
def vr_estimate_cost(duration_sec: float) -> float:
"""对口型成本估算(元)—— 实际优先消耗 1800 秒免费额度"""
return round(max(0.0, float(duration_sec)) * VR_PRICE_PER_SEC, 2)
def _vr_upload(path: str, api_key: str) -> str:
"""两步式上传 → oss:// URL(与 person_swap 同一套;oss:// 需配 Resolve header)"""
import httpx
p = Path(path)
r = httpx.get(f"{DASHSCOPE_BASE}/api/v1/uploads?action=getPolicy&model={VIDEORETALK_MODEL}",
headers={"Authorization": f"Bearer {api_key}"}, timeout=30)
if r.status_code != 200:
raise RuntimeError(f"获取上传凭证失败 HTTP {r.status_code}: {r.text[:200]}")
d = r.json().get("data") or {}
key = f"{d.get('upload_dir', '')}/{p.name}"
form = {
"OSSAccessKeyId": d.get("oss_access_key_id", ""),
"policy": d.get("policy", ""),
"signature": d.get("signature", ""),
"key": key,
"x-oss-object-acl": d.get("x_oss_object_acl", "private"),
"x-oss-forbid-overwrite": "true",
}
with open(p, "rb") as f:
r2 = httpx.post(d["upload_host"], data=form,
files={"file": (p.name, f, "application/octet-stream")}, timeout=300,
trust_env=False)
if r2.status_code not in (200, 201, 204):
raise RuntimeError(f"OSS 直传失败 HTTP {r2.status_code}: {r2.text[:200]}")
return f"oss://{key}"
def lipsync_videoretalk(video_path: str, audio_path: str, output_path: str,
api_key: str = "", ref_image_path: str = "",
video_extension: bool = False,
timeout: int = 1800, progress_cb=None) -> str:
"""人物视频 + 人声 → **口型匹配**的新视频(百炼 VideoRetalk)
· 成本 ¥0.08/秒(有 1800 秒免费额度)
· 视频:正面近景、2~120s、mp4/mov、15~60fps、边长 640~2048
· 音频:干净人声、2~120s(TTS 产物天然满足)
· ref_image_path:角色参考图(可选,提升人脸匹配;我们正好有)
返回输出视频本地路径。
"""
import httpx
api_key = api_key or (load_config().get("aliyun", {}) or {}).get("api_key", "")
if not api_key:
raise ValueError("VideoRetalk 需要百炼 API Key")
_cb = progress_cb or (lambda p, n: None)
vdur = _get_media_duration(video_path)
if vdur and not (2.0 < vdur < 120.0):
raise ValueError(f"视频时长 {vdur:.1f}s 超出 VideoRetalk 要求(2~120 秒)")
_cb(5, "上传视频")
vurl = _vr_upload(video_path, api_key)
_cb(15, "上传音频")
aurl = _vr_upload(audio_path, api_key)
rurl = ""
if ref_image_path and Path(ref_image_path).exists():
try:
_cb(20, "上传人脸参考图")
rurl = _vr_upload(ref_image_path, api_key)
except Exception as e:
logger.warning("参考图上传失败(继续):%s", e)
inp = {"video_url": vurl, "audio_url": aurl}
if rurl:
inp["ref_image_url"] = rurl
payload = {
"model": VIDEORETALK_MODEL,
"input": inp,
"parameters": {"video_extension": bool(video_extension)},
}
hdr = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
"X-DashScope-Async": "enable",
"X-DashScope-OssResourceResolve": "enable", # ★ 用 oss:// 时必须
}
_cb(25, "提交对口型任务")
r = httpx.post(VR_SUBMIT_URL, headers=hdr, json=payload, timeout=60, trust_env=False)
if r.status_code != 200:
raise RuntimeError(f"提交失败 HTTP {r.status_code}: {r.text[:300]}")
tid = (r.json().get("output") or {}).get("task_id", "")
if not tid:
raise RuntimeError(f"提交返回无 task_id: {r.text[:300]}")
logger.info(" VideoRetalk 任务已提交: %s(预估 ¥%.2f)", tid, vr_estimate_cost(vdur))
# 轮询
deadline = time.time() + timeout
last = ""
while time.time() < deadline:
rr = httpx.get(VR_TASK_URL.format(tid=tid),
headers={"Authorization": f"Bearer {api_key}"}, timeout=30, trust_env=False)
out = (rr.json() or {}).get("output") or {}
st = out.get("task_status", "")
if st == "SUCCEEDED":
url = out.get("video_url") or ""
if not url:
results = out.get("results") or []
url = (results[0].get("video_url") or results[0].get("url")) if results else ""
if not url:
raise RuntimeError(f"成功但无视频 URL: {str(out)[:200]}")
_cb(95, "下载结果")
_download(url, output_path)
_cb(100, "完成")
logger.info(" ✅ 对口型完成: %s", Path(output_path).name)
return output_path
if st in ("FAILED", "CANCELED", "UNKNOWN"):
raise RuntimeError(f"任务 {st}: {(out.get('message') or out.get('code') or '')[:200]}")
if st != last:
last = st
logger.info(" [%s] 对口型任务状态: %s", tid[:8], st)
_cb(30, f"处理中({st})")
time.sleep(10)
raise RuntimeError(f"对口型超时(>{timeout}s)")
# ── CLI 扩展:--provider videoretalk ──
def cli_videoretalk(video: str, audio: str, output: str,
ref_image: str = "", video_extension: bool = False):
def _p(p, n):
print(f" {p}% {n}")
out = lipsync_videoretalk(video, audio, output, ref_image_path=ref_image,
video_extension=video_extension, progress_cb=_p)
print(f"\n✅ 输出: {out}")