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,244 @@
|
||||
"""
|
||||
person_swap/preprocess.py — 素材合规化与预处理 (ffmpeg)
|
||||
|
||||
职责:
|
||||
1. probe_video — 探测源视频(时长/分辨率/编码/fps)
|
||||
2. check_image — 校验人物参考图(可解码/尺寸/非动图)
|
||||
3. prep_source_video — 源视频合规化: 转码 h264 + 竖屏 9:16 裁切 + 限长截断
|
||||
4. prep_reference_image — 参考图归一化(缩放至模型常用尺寸上限)
|
||||
5. extract_preview_frame — 抽帧预览(供前端展示)
|
||||
|
||||
⚠️ 本机 ~/.local/bin/ffprobe 是软链到 ffmpeg 的假 ffprobe(v6.0 单二进制),
|
||||
_find_bin 会用 `-version` 验证真实身份; 无真 ffprobe 时走 ffmpeg stderr 解析兜底。
|
||||
|
||||
输出目录约定: agent-local/runtime/person_swap/uploads/{task_id}/
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
# ── ffmpeg / ffprobe 路径解析 ──
|
||||
def _find_bin(name: str) -> str:
|
||||
"""查找真实二进制: 候选目录 + PATH, 且 -version 首行必须含目标名"""
|
||||
cands = [
|
||||
os.environ.get("FFMPEG_DIR", ""),
|
||||
str(Path.home() / ".local" / "bin"),
|
||||
"/opt/homebrew/bin",
|
||||
"/usr/local/bin",
|
||||
"/usr/bin",
|
||||
]
|
||||
seen = []
|
||||
for d in cands:
|
||||
if d:
|
||||
p = Path(d) / name
|
||||
if p.exists():
|
||||
seen.append(str(p))
|
||||
w = shutil.which(name)
|
||||
if w:
|
||||
seen.append(w)
|
||||
for cand in seen:
|
||||
try:
|
||||
r = subprocess.run([cand, "-version"], capture_output=True, text=True, timeout=10)
|
||||
if r.returncode == 0 and r.stdout.splitlines() and name in r.stdout.splitlines()[0]:
|
||||
return cand
|
||||
except Exception:
|
||||
continue
|
||||
return name # 找不到就交给系统 PATH, 调用时会自然报错
|
||||
|
||||
|
||||
# ⚠️ 统一用 lib.ffmpeg 的解析:它会**自动查找真正的 ffprobe**
|
||||
# (本机 ~/.local/bin/ffprobe 是 ffmpeg 的符号链接,假货的 -version 首行不含 "ffprobe",
|
||||
# 一旦没找到真 ffprobe,_find_bin 会兜底返回 "ffprobe" → 仍会踩到假货)
|
||||
try:
|
||||
os.sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
from lib.ffmpeg import FFMPEG, FFPROBE # noqa: E402
|
||||
except Exception:
|
||||
FFMPEG = _find_bin("ffmpeg")
|
||||
FFPROBE = _find_bin("ffprobe")
|
||||
|
||||
# ── 常用约束 (可被 person_swap 配置覆盖) ──
|
||||
DEFAULT_MAX_DURATION = 10 # 单条源视频最长秒数(超长提示分段)
|
||||
DEFAULT_ORIENTATION = "9:16" # 输出竖屏
|
||||
DEFAULT_IMAGE_MAX = 2048 # 参考图长边上限
|
||||
|
||||
_SCRIPT_ROOT = Path(__file__).resolve().parent.parent
|
||||
if str(_SCRIPT_ROOT) not in os.sys.path:
|
||||
os.sys.path.insert(0, str(_SCRIPT_ROOT))
|
||||
|
||||
from lib.logger import get_logger
|
||||
from lib.config import load_config
|
||||
|
||||
logger = get_logger("person_swap")
|
||||
|
||||
|
||||
def _cfg() -> dict:
|
||||
cfg = load_config()
|
||||
return cfg.get("person_swap", {}) or {}
|
||||
|
||||
|
||||
def _run(cmd: list, timeout: int = 120) -> subprocess.CompletedProcess:
|
||||
logger.debug("执行: %s", " ".join(str(c) for c in cmd))
|
||||
return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 探测与校验
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
def _parse_ffmpeg_info(path: str) -> dict:
|
||||
"""ffmpeg -i stderr 解析兜底 (无真 ffprobe 时使用)"""
|
||||
r = subprocess.run([FFMPEG, "-hide_banner", "-i", path],
|
||||
capture_output=True, text=True, timeout=30)
|
||||
text = r.stderr
|
||||
dur_s = 0.0
|
||||
m = re.search(r"Duration:\s*(\d+):(\d+):([\d.]+)", text)
|
||||
if m:
|
||||
h, mi, s = int(m.group(1)), int(m.group(2)), float(m.group(3))
|
||||
dur_s = h * 3600 + mi * 60 + s
|
||||
# 分辨率组首位 1-9: 排除 0x31637661(avc1 十六进制) 这类误匹配
|
||||
m = re.search(r"Stream #0[^\n]*?Video:\s*([\w-]+)[^\n]*?([1-9]\d{1,4})x([1-9]\d{1,4})", text)
|
||||
if not m:
|
||||
raise ValueError(f"无法识别媒体流: {text.strip()[-200:]}")
|
||||
codec, w, h = m.group(1), int(m.group(2)), int(m.group(3))
|
||||
fps = ""
|
||||
mf = re.search(r"(\d+(?:\.\d+)?)\s*fps", text)
|
||||
if mf:
|
||||
fps = mf.group(1)
|
||||
return {"duration_sec": round(dur_s, 2), "width": w, "height": h,
|
||||
"codec": codec, "fps": fps}
|
||||
|
||||
|
||||
def probe_video(path: str) -> dict:
|
||||
"""探测视频元信息, 失败抛 ValueError; 优先 ffprobe JSON, 兜底 ffmpeg 解析"""
|
||||
if FFPROBE != "ffprobe":
|
||||
r = _run([FFPROBE, "-v", "error", "-print_format", "json", "-show_format", "-show_streams", path])
|
||||
if r.returncode == 0:
|
||||
try:
|
||||
d = json.loads(r.stdout)
|
||||
streams = d.get("streams", [])
|
||||
v = next((s for s in streams if s.get("codec_type") == "video"), None)
|
||||
if not v:
|
||||
raise ValueError("视频无视频流")
|
||||
fmt = d.get("format", {})
|
||||
dur = float(fmt.get("duration", 0) or 0)
|
||||
if dur <= 0:
|
||||
dur = float(v.get("duration", 0) or 0)
|
||||
return {
|
||||
"duration_sec": round(dur, 2),
|
||||
"width": int(v.get("width", 0)),
|
||||
"height": int(v.get("height", 0)),
|
||||
"codec": v.get("codec_name", ""),
|
||||
"fps": v.get("avg_frame_rate", ""),
|
||||
"size_bytes": int(fmt.get("size", 0) or 0),
|
||||
}
|
||||
except Exception:
|
||||
pass # 解析失败落到 ffmpeg 兜底
|
||||
info = _parse_ffmpeg_info(path)
|
||||
info["size_bytes"] = os.path.getsize(path)
|
||||
return info
|
||||
|
||||
|
||||
def check_image(path: str) -> dict:
|
||||
"""校验人物参考图: 可解码 + 尺寸, 返回 {width,height,ok,error}"""
|
||||
try:
|
||||
info = _parse_ffmpeg_info(path)
|
||||
w, h = info["width"], info["height"]
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": f"图片无法解码: {e}"}
|
||||
if w < 256 or h < 256:
|
||||
return {"ok": False, "error": f"参考图过小({w}x{h}), 至少 256px"}
|
||||
return {"ok": True, "width": w, "height": h, "codec": info["codec"], "error": ""}
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 预处理(生成合规中间文件)
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
def prep_source_video(src_path: str, out_path: str,
|
||||
max_duration: int = 0, orientation: str = "") -> dict:
|
||||
"""源视频合规化 → h264 + 竖屏裁切 + 限长
|
||||
|
||||
返回 {out_path, duration_sec, width, height, trimmed: bool}
|
||||
超长时截取前 max_duration 秒(并返回 trimmed=True)
|
||||
"""
|
||||
info = probe_video(src_path)
|
||||
max_duration = max_duration or int(_cfg().get("max_duration", DEFAULT_MAX_DURATION))
|
||||
orientation = orientation or _cfg().get("orientation", DEFAULT_ORIENTATION)
|
||||
|
||||
dur = info["duration_sec"]
|
||||
duration_arg = []
|
||||
if dur > max_duration:
|
||||
duration_arg = ["-t", str(max_duration)]
|
||||
logger.info(" 源视频 %.1fs 超限, 截取前 %ds", dur, max_duration)
|
||||
|
||||
# 裁切到竖屏: 保持画面内容, 按 9:16 裁掉左右 (16:9 横屏 → 切中间竖条)
|
||||
vf = []
|
||||
if orientation == "9:16":
|
||||
vf.append("scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920")
|
||||
else:
|
||||
vf.append("scale=1920:1080:force_original_aspect_ratio=increase,crop=1920:1080")
|
||||
|
||||
cmd = [FFMPEG, "-y", "-i", src_path, *duration_arg,
|
||||
"-vf", ",".join(vf),
|
||||
"-c:v", "libx264", "-preset", "veryfast", "-crf", "20",
|
||||
"-c:a", "aac", "-b:a", "128k", "-movflags", "+faststart",
|
||||
out_path]
|
||||
r = _run(cmd, timeout=300)
|
||||
if r.returncode != 0:
|
||||
raise ValueError(f"视频预处理失败: {r.stderr.strip()[-300:]}")
|
||||
|
||||
out_info = probe_video(out_path)
|
||||
return {**out_info, "out_path": out_path, "trimmed": dur > max_duration}
|
||||
|
||||
|
||||
def prep_reference_image(src_path: str, out_path: str, max_side: int = 0) -> dict:
|
||||
"""参考图归一化: 转 jpg + 长边上限, 返回 {out_path, width, height}"""
|
||||
chk = check_image(src_path)
|
||||
if not chk["ok"]:
|
||||
raise ValueError(chk["error"])
|
||||
max_side = max_side or int(_cfg().get("image_max_side", DEFAULT_IMAGE_MAX))
|
||||
|
||||
vf = f"scale='min({max_side},iw)':'min({max_side},ih)':force_original_aspect_ratio=decrease"
|
||||
out = Path(out_path)
|
||||
if out.suffix.lower() not in (".jpg", ".jpeg"):
|
||||
out = out.with_suffix(".jpg")
|
||||
cmd = [FFMPEG, "-y", "-i", src_path, "-vf", vf, "-q:v", "2", str(out)]
|
||||
r = _run(cmd, timeout=120)
|
||||
if r.returncode != 0:
|
||||
raise ValueError(f"参考图处理失败: {r.stderr.strip()[-300:]}")
|
||||
return {"out_path": str(out), "width": chk["width"], "height": chk["height"]}
|
||||
|
||||
|
||||
def extract_preview_frame(video_path: str, out_path: str) -> str:
|
||||
"""抽取视频中帧作为预览图(jpg)"""
|
||||
out = Path(out_path)
|
||||
if out.suffix.lower() not in (".jpg", ".jpeg"):
|
||||
out = out.with_suffix(".jpg")
|
||||
cmd = [FFMPEG, "-y", "-i", video_path, "-frames:v", "1", "-q:v", "3", str(out)]
|
||||
r = _run(cmd, timeout=60)
|
||||
if r.returncode != 0:
|
||||
raise ValueError(f"抽帧失败: {r.stderr.strip()[-200:]}")
|
||||
return str(out)
|
||||
|
||||
|
||||
# ── CLI 自测 ──
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
ap = argparse.ArgumentParser(description="person_swap 预处理自测")
|
||||
ap.add_argument("video", help="源视频路径")
|
||||
ap.add_argument("--image", default="", help="参考图路径(可选, 测试图片处理)")
|
||||
args = ap.parse_args()
|
||||
|
||||
print(f"FFMPEG={FFMPEG} FFPROBE={FFPROBE}")
|
||||
info = probe_video(args.video)
|
||||
print("源视频:", info)
|
||||
out = "/tmp/ps_prep_test.mp4"
|
||||
res = prep_source_video(args.video, out)
|
||||
print("预处理:", res["out_path"], f"{res['width']}x{res['height']}", f"{res['duration_sec']}s", "trimmed" if res["trimmed"] else "")
|
||||
if args.image:
|
||||
print("参考图检查:", check_image(args.image))
|
||||
r = prep_reference_image(args.image, "/tmp/ps_ref_test.jpg")
|
||||
print("参考图处理:", r["out_path"])
|
||||
Reference in New Issue
Block a user