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,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
|
||||
Reference in New Issue
Block a user