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,188 @@
"""
口播素材系统 - 媒体下载脚本
用法:python downloader.py [--limit N] [--all]
功能:
1. 查询所有 download_status='pending' 的素材
2. 使用 yt-dlp 下载视频/音频
3. 保存到 ~/workbuddy-agent-os/agent-local/materials/video/ 或 audio/
4. 更新数据库中的 local_files 和 download_status
前置条件:
- yt-dlp 已安装:pip install yt-dlp
- ffmpeg 已安装(音频提取需要)
注意:
- 抖音/小红书链接有时效性,采集后尽快下载
- 下载速率限制默认 1MB/s,防止 IP 封禁
"""
import os
import sys
import json
import argparse
from datetime import datetime
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from utils import load_config, get_db, init_db, setup_logger, truncate_text, PROJECT_ROOT
def download_one(material: dict, config: dict, logger) -> dict:
"""
下载单条素材的视频/音频
返回:{"video": "path", "audio": "path"} 或 None
"""
url = material.get("url", "")
if not url:
logger.warning(f"素材 {material['id']} 无 URL,跳过")
return None
try:
from yt_dlp import YoutubeDL
except ImportError:
logger.error("yt-dlp 未安装,请执行:pip install yt-dlp")
return None
platform = material["platform"]
title = material.get("title", "untitled")
author = material.get("author", "unknown")
# 确定输出路径
media_dir = os.path.expanduser(config["storage"].get("media_dir", "~/workbuddy-agent-os/agent-local/materials/video"))
os.makedirs(media_dir, exist_ok=True)
# 文件名
safe_title = truncate_text(title, 20)
safe_author = truncate_text(author, 15)
date_str = datetime.now().strftime("%Y%m%d")
filename = f"{platform}_{safe_author}_{safe_title}_{date_str}"
outtmpl = os.path.join(media_dir, filename + ".%(ext)s")
# yt-dlp 配置
ydl_opts = {
"outtmpl": outtmpl,
"format": "best[ext=mp4]/best", # 优先 mp4
"max_filesize": 500 * 1024 * 1024, # 500MB 上限
"retries": config["download"].get("retries", 3),
"quiet": True,
"no_warnings": True,
"extract_flat": False,
# 速率限制
"ratelimit": 1024 * 1024, # 1MB/s
}
result_files = {"video": None, "audio": None}
try:
with YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(url, download=True)
if info:
downloaded_file = ydl.prepare_filename(info)
# yt-dlp 可能修改了扩展名
if os.path.exists(downloaded_file):
result_files["video"] = downloaded_file
logger.info(f"下载成功: {os.path.basename(downloaded_file)}")
else:
# 查找实际下载的文件
for ext in ["mp4", "flv", "webm", "mkv"]:
candidate = os.path.join(media_dir, f"{filename}.{ext}")
if os.path.exists(candidate):
result_files["video"] = candidate
break
return result_files if result_files["video"] else None
except Exception as e:
logger.error(f"下载失败 [{material['id']}]: {e}")
return None
def main():
parser = argparse.ArgumentParser(description="口播素材媒体下载")
parser.add_argument("--limit", "-n", type=int, default=0, help="下载数量上限,0=全部")
parser.add_argument("--all", action="store_true", help="下载所有 worth_downloading=yes 的素材")
parser.add_argument("--url", "-u", help="手动指定 URL 下载(不查数据库)")
args = parser.parse_args()
config = load_config()
logger = setup_logger("download")
db_path = init_db()
db = get_db(db_path)
if args.url:
# 手动 URL 下载
material = {
"id": 0,
"url": args.url,
"platform": "manual",
"title": "手动下载",
"author": "manual",
}
result = download_one(material, config, logger)
if result:
print(f"[OK] 下载完成: {result}")
else:
print("[FAIL] 下载失败")
db.close()
return
# 查询待下载记录
if args.all:
# 下载所有 AI 推荐的
query = """
SELECT m.* FROM materials m
JOIN analysis a ON m.id = a.material_id
WHERE a.worth_downloading = 'yes' AND m.download_status = 'none'
"""
else:
query = "SELECT * FROM materials WHERE download_status = 'pending'"
if args.limit > 0:
query += f" LIMIT {args.limit}"
materials = db.execute(query).fetchall()
materials = [dict(m) for m in materials]
if not materials:
print("[OK] 没有待下载的素材")
db.close()
return
print(f"[INFO] 待下载: {len(materials)} 条")
success = 0
failed = 0
for i, material in enumerate(materials, 1):
print(f"[{i}/{len(materials)}] 下载: {material['title'][:40]}")
# 标记为下载中
db.execute("UPDATE materials SET download_status = 'downloading' WHERE id = ?", (material["id"],))
db.commit()
result = download_one(material, config, logger)
if result and result.get("video"):
db.execute("""
UPDATE materials
SET download_status = 'done', local_files = ?
WHERE id = ?
""", (json.dumps(result, ensure_ascii=False), material["id"]))
db.commit()
success += 1
else:
db.execute("UPDATE materials SET download_status = 'failed' WHERE id = ?", (material["id"],))
db.commit()
failed += 1
db.close()
print(f"\n[DONE] 下载完成: 成功 {success}, 失败 {failed}, 共 {len(materials)} 条")
if __name__ == "__main__":
main()