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 带网关原话 / 正常返回)。
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user