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,237 @@
"""
AVE 05_material_producer/kling — 可灵 Kling AI 视频生成
功能:
文生视频、图生视频,对接 Kling API(官方或阿里云百炼)
API 文档:
https://klingai.com/document-api/apiReference/overview
认证: JWT (HS256), AccessKey + SecretKey → Bearer Token
用法:
from kling import text2video
path = text2video("一只橘猫在窗台上晒太阳", api_key="xxx", secret_key="xxx")
"""
import sys, os; sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import hashlib, json, os, time
from pathlib import Path
import jwt
import httpx
from lib.logger import get_logger
logger = get_logger("kling")
# ── 缓存 ──
CACHE_DIR = Path(os.environ.get("AVE_CACHE_DIR",
str(Path.home() / "workbuddy-agent-os/agent-local/tools/ave/cache/kling")))
CACHE_DIR.mkdir(parents=True, exist_ok=True)
# ── API 配置 ──
# 官方 Kling API (中国区)
KLING_BASE = "https://api-beijing.klingai.com"
# 也可通过阿里云百炼调用 (需开通服务)
DASHSCOPE_BASE = "https://dashscope.aliyuncs.com"
# ── 模型与成本 (最低→最高) ──
MODELS = {
"turbo": {"id": "kling-v2.5-turbo", "cost_per_s": 0.04, "desc": "最快, 最便宜"},
"std": {"id": "kling-v2.6-std", "cost_per_s": 0.05, "desc": "快速, 含音频"},
"pro": {"id": "kling-v2.6-pro", "cost_per_s": 0.07, "desc": "最佳质量, 含音频+运动控制"},
"o1": {"id": "kling-video-o1", "cost_per_s": 0.08, "desc": "统一多模态"},
}
def text2video(
prompt: str,
output_path: str = "",
model: str = "turbo",
duration: int = 5,
aspect_ratio: str = "9:16",
access_key: str = "",
secret_key: str = "",
use_dashscope: bool = False,
dashscope_api_key: str = "",
) -> str:
"""
文生视频
参数:
prompt: 提示词
output_path: 输出视频路径 (默认自动生成)
model: 模型名 (turbo/std/pro/o1)
duration: 视频时长 (5或10秒)
aspect_ratio: 比例 (9:16竖屏 / 16:9横屏 / 1:1)
access_key: Kling Access Key
secret_key: Kling Secret Key
use_dashscope: 是否走阿里云百炼 (替代直接调用 Kling API)
dashscope_api_key: 阿里云百炼 API Key
返回:
本地视频文件路径
"""
if use_dashscope:
return _text2video_dashscope(prompt, output_path, model, duration, aspect_ratio, dashscope_api_key)
else:
return _text2video_kling(prompt, output_path, model, duration, aspect_ratio, access_key, secret_key)
def _text2video_kling(prompt, output_path, model, duration, aspect_ratio, access_key, secret_key):
"""通过 Kling 官方 API 生成"""
if not output_path:
output_path = str(CACHE_DIR / f"kling_{hashlib.md5(prompt.encode()).hexdigest()[:12]}.mp4")
# 缓存检查
if os.path.exists(output_path):
logger.info(f"缓存命中: {output_path}")
return output_path
model_id = MODELS.get(model, MODELS["turbo"])["id"]
token = _generate_jwt(access_key, secret_key)
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
}
payload = {
"model": model_id,
"prompt": prompt,
"duration": duration,
"aspect_ratio": aspect_ratio,
}
logger.info(f"Kling 文生视频: model={model_id}, prompt={prompt[:40]}..., duration={duration}s")
resp = httpx.post(f"{KLING_BASE}/v1/videos/text2video", headers=headers, json=payload, timeout=30, trust_env=False)
resp.raise_for_status()
data = resp.json()
task_id = data.get("data", {}).get("task_id", "")
if not task_id:
raise RuntimeError(f"Kling 提交失败: {data}")
logger.info(f" 任务提交成功: {task_id}")
logger.info(f" 轮询等待结果... (预计30-60s)")
# 轮询
for i in range(60):
time.sleep(5)
q = httpx.get(f"{KLING_BASE}/v1/videos/{task_id}", headers=headers, timeout=15, trust_env=False)
q.raise_for_status()
status_data = q.json().get("data", {})
task_status = status_data.get("task_status", "")
if task_status == "succeed":
video_url = status_data.get("video", {}).get("url", "")
if video_url:
logger.info(f" 生成成功! 下载中...")
_download(video_url, output_path)
logger.info(f" ✅ 已保存: {output_path}")
return output_path
elif task_status in ("failed",):
raise RuntimeError(f"Kling 生成失败: {status_data.get('fail_reason', '未知错误')}")
logger.debug(f" [{i*5}s] 状态: {task_status}")
raise TimeoutError("Kling 生成超时")
def _text2video_dashscope(prompt, output_path, model, duration, aspect_ratio, api_key):
"""通过阿里云百炼调用 Kling"""
if not output_path:
output_path = str(CACHE_DIR / f"dashscope_{hashlib.md5(prompt.encode()).hexdigest()[:12]}.mp4")
if os.path.exists(output_path):
logger.info(f"缓存命中: {output_path}")
return output_path
model_map = {"turbo": "kling/kling-v3-video-generation", "pro": "kling/kling-v3-omni-video-generation"}
model_id = model_map.get(model, model_map["turbo"])
size_map = {"9:16": "1080*1920", "16:9": "1920*1080", "1:1": "1080*1080"}
size = size_map.get(aspect_ratio, "1080*1920")
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
"X-DashScope-Async": "enable",
}
payload = {
"model": model_id,
"input": {"prompt": prompt, "duration": duration},
"parameters": {"size": size, "n": 1},
}
resp = httpx.post(f"{DASHSCOPE_BASE}/api/v1/services/aigc/video-generation/video-synthesis",
headers=headers, json=payload, timeout=30, trust_env=False)
resp.raise_for_status()
task_id = resp.json()["output"]["task_id"]
# 轮询
for i in range(40):
time.sleep(15)
q = httpx.get(f"{DASHSCOPE_BASE}/api/v1/tasks/{task_id}",
headers={"Authorization": f"Bearer {api_key}"}, timeout=15, trust_env=False)
status = q.json().get("output", {}).get("task_status", "")
if status == "SUCCEEDED":
out = q.json().get("output", {})
video_url = out.get("video_url", "")
if not video_url:
# fallback: results array
results = out.get("results", [])
if isinstance(results, list) and len(results) > 0:
video_url = results[0].get("video_url", "")
elif isinstance(results, dict):
video_url = results.get("video_url", "")
if not video_url:
raise RuntimeError(f"百炼 Kling 返回成功但无 video_url: {q.json()}")
_download(video_url, output_path)
return output_path
elif status in ("FAILED",):
raise RuntimeError(f"百炼 Kling 失败: {q.json().get('output',{}).get('message','')}")
raise TimeoutError("百炼 Kling 超时")
def _generate_jwt(access_key: str, secret_key: str) -> str:
"""生成 Kling API 的 JWT Bearer Token (HS256)"""
now = int(time.time())
payload = {
"iss": access_key,
"exp": now + 1800, # 30分钟
"nbf": now - 5,
}
return jwt.encode(payload, secret_key, algorithm="HS256")
def _download(url: str, output_path: str):
"""下载文件"""
os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
with httpx.stream("GET", url, timeout=600) as resp:
resp.raise_for_status()
with open(output_path, "wb") as f:
for chunk in resp.iter_bytes(chunk_size=8192):
f.write(chunk)
# ── CLI 测试 ──
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="Kling 文生视频测试")
parser.add_argument("--prompt", default="一只橘猫在窗台上晒太阳")
parser.add_argument("--model", default="turbo", choices=list(MODELS.keys()))
parser.add_argument("--duration", type=int, default=5)
parser.add_argument("--output", default="")
parser.add_argument("--access-key", default=os.environ.get("KLING_ACCESS_KEY", ""))
parser.add_argument("--secret-key", default=os.environ.get("KLING_SECRET_KEY", ""))
args = parser.parse_args()
path = text2video(
prompt=args.prompt,
output_path=args.output,
model=args.model,
duration=args.duration,
access_key=args.access_key,
secret_key=args.secret_key,
)
print(f"\n✅ 视频: {path}")