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,136 @@
"""
composer/align.py — 画面跟随音频对齐("剪画面"策略)
用户洞察(2026-10-03):
音画不同步时,**不该动声音**(变速会变调、裁剪会丢字),
而应该**动画面** —— 画面变速几乎没有质量损失(atempo 保音高、setpts 改时长)。
适用范围(严格遵守):
· 有对口型的镜头 **不动**(口型已与音频绑定,变速会破坏)
· 无音频的镜头不动
· 变速比超出安全范围(默认 0.67~1.5 倍)→ 不动并告警(避免明显快进/慢放感)
用法:
from composer.align import align_clips_to_audio
new_clips = align_clips_to_audio(work_dir, sb, clips, lipsync_indexes={2,3})
"""
from pathlib import Path
from lib.logger import get_logger
log = get_logger("composer.align")
def _dur(p: str) -> float:
try:
from lib.ffmpeg import probe_duration
return float(probe_duration(str(p)) or 0)
except Exception:
return 0.0
def _has_audio(p: str) -> bool:
try:
from lib.ffmpeg import has_audio_stream
return bool(has_audio_stream(str(p)))
except Exception:
return False
def align_clips_to_audio(work_dir, sb, clips: list,
lipsync_indexes: set | None = None,
max_speed: float = 1.5,
min_delta: float = 0.15,
progress_cb=None) -> list:
"""把「有台词音频、但没做对口型」的镜头**变速**到音频长度
work_dir : 工作目录(音频在 {work_dir}/pre_audio/{shot_id}.mp3)
clips : 当前镜头片段路径列表(与 sb.shots 同序)
lipsync_indexes: 已做对口型的镜头序号(1-based)——这些不动
max_speed : 允许的最大变速倍率(默认 1.5,atempo 安全区 0.5~2.0)
min_delta : 小于该时长差(秒)视为已对齐,不动
返回:新的片段路径列表(失败的镜头保持原样)
"""
import subprocess
from lib.ffmpeg import FFMPEG
work = Path(work_dir)
pre_dir = work / "pre_audio"
skip = set(lipsync_indexes or ())
out = list(clips)
done = 0
for idx, shot in enumerate(list(getattr(sb, "shots", []) or []), 1):
if idx - 1 >= len(out):
break
if idx in skip:
continue # 对口型镜头不动
sid = getattr(shot, "shot_id", f"shot_{idx:02d}")
audio = pre_dir / f"{sid}.mp3"
if not audio.exists():
continue # 无音频 → 不动
adur = _dur(str(audio))
clip = Path(str(out[idx - 1]))
if not clip.exists() or adur <= 0:
continue
vdur = _dur(str(clip))
if vdur <= 0:
continue
delta = vdur - adur
if abs(delta) < min_delta:
continue # 已对齐
ratio = vdur / adur # >1 = 画面偏长,需加速
# 超出安全区**不再跳过**(跳过 = 音画依然不同步)——
# 改为:变速到安全极限 + 裁剪剩余(画面比音频长时裁掉尾部,内容损失最小)
if ratio > max_speed:
applied = max_speed
need_trim = True
elif ratio < 1.0 / max_speed:
applied = 1.0 / max_speed
need_trim = False # 画面太短:变速到极限即可(不要补黑)
else:
applied = ratio
need_trim = abs(delta) > min_delta
dst = work / f"aligned_{idx:02d}.mp4"
if progress_cb:
progress_cb("align", sid, "running", f"画面变速 {applied:.2f}x")
# 视频变速用 setpts;音频(若存在)用 atempo 同步变速
vf = f"[0:v]setpts=PTS/{applied:.4f}[v]"
if _has_audio(str(clip)):
ap = min(max(applied, 0.5), 2.0) # atempo 安全区 0.5~2.0
fc = f"{vf};[0:a]atempo={ap:.4f}[a]"
maps = ["-map", "[v]", "-map", "[a]", "-c:a", "aac", "-b:a", "192k"]
else:
fc = vf
maps = ["-map", "[v]"]
cmd = [FFMPEG, "-y", "-i", str(clip),
"-filter_complex", fc, *maps,
"-c:v", "libx264", "-preset", "fast", "-crf", "20",
"-pix_fmt", "yuv420p"]
if need_trim and applied > 1.0:
# 变速后仍偏长 → 裁到音频长度(保留开头,丢尾部多余部分)
cmd += ["-t", f"{adur:.3f}"]
cmd.append(str(dst))
r = subprocess.run(cmd, capture_output=True, text=True, timeout=600)
if r.returncode != 0 or not dst.exists() or dst.stat().st_size < 1_000:
log.warning(" ⚠️ %s 变速失败,保留原片段: %s", sid, (r.stderr or "")[-160:])
continue
new_dur = _dur(str(dst))
out[idx - 1] = str(dst)
done += 1
_note = f"{applied:.2f}x" + ("+裁尾" if (need_trim and applied > 1.0) else "")
log.info(" ✂️ %s 画面对齐:%.2fs → %.2fs(%s,音频 %.2fs)",
sid, vdur, new_dur, _note, adur)
if progress_cb:
progress_cb("align", sid, "done", f"{vdur:.1f}s → {new_dur:.1f}s({_note})")
if done:
log.info(" ✂️ 画面跟随音频对齐完成:%d 个镜头", done)
return out