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,457 @@
"""
AVE hybrid — 口播+卡点融合管线
功能:
将口播的语音锚点驱动画面切换 + 卡点的BGM能量驱动变速结合起来。
人声决定"什么时候切画面", BGM决定"画面怎么动"。
流程:
1. 检测人声锚点 (静音段 → 段边界)
2. 检测 BGM 节拍 + 能量特征
3. 混音 (人声+BGM, BGM 音量避让)
4. 为每段分配素材 + 能量驱动变速
5. 渲染每段 + 字幕叠加
6. 帧锁定拼接
用法:
from composer.hybrid import compose_hybrid
compose_hybrid(
voice_path="narration.wav",
bgm_path="bgm.wav",
output_path="hybrid.mp4",
texts=["第一段文案", "第二段文案", ...],
)
"""
import sys, os; sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import json
import os
import shutil
import subprocess
import tempfile
from pathlib import Path
from typing import Optional
from lib.logger import get_logger
from lib.ffmpeg import FFMPEG # noqa: E402 (FFmpeg 绝对路径)
logger = get_logger("hybrid")
def compose_hybrid(
voice_path: str,
bgm_path: str,
output_path: str = "hybrid.mp4",
material_clips: list[str] | None = None,
resolution: str = "1080x1920",
texts: list[str] | None = None,
pexels_api_key: str = "",
pexels_search: str = "",
# 口播锚点参数
min_silence_sec: float = 0.15,
voice_volume: float = 1.0,
bgm_volume: float = 0.35,
# 卡点速度参数
enable_speed_ramp: bool = True,
base_speed: float = 1.0,
high_speed: float = 1.5,
low_speed: float = 0.7,
# 节拍分组
group_size: int = 4,
) -> str:
"""
口播+卡点融合管线
参数:
voice_path: 人声 WAV 路径
bgm_path: BGM 音频路径
output_path: 输出视频路径
material_clips: 素材视频/图片列表 (可选)
resolution: 输出分辨率
texts: 每段文字 (用于字幕, 可选)
pexels_api_key: Pexels API Key (素材不够时补充)
pexels_search: Pexels 搜索关键词
min_silence_sec: 最小静音时长 (秒), 用于分割段
voice_volume: 人声音量 (默认 1.0)
bgm_volume: BGM 音量 (默认 0.35)
enable_speed_ramp: 是否启用能量驱动变速
base_speed: 基础速度
high_speed: 高能段速度
low_speed: 低能段速度
group_size: BGM 节拍分组数
返回: output_path
"""
logger.info("=== 口播+卡点融合模式 ===")
logger.info(f"人声: {voice_path}")
logger.info(f"BGM: {bgm_path}")
# ── 1. 检测人声锚点 ──
from anchor_extractor.extractor import get_silence_periods
silences = get_silence_periods(voice_path, min_silence_sec=min_silence_sec)
# 获取人声总时长
voice_duration = _get_duration(voice_path)
# 从静音段反推活跃段 (说话段)
voice_segments = _silences_to_segments(silences, voice_duration)
logger.info(f" 锚点检测: {len(silences)} 个静音 → {len(voice_segments)} 个说话段")
# ── 2. 检测 BGM 节拍 (含能量) ──
from composer.beat_sync import detect_beats
beat_info = detect_beats(bgm_path, group_size=group_size, enable_energy=enable_speed_ramp)
bgm_segments = beat_info["segments"]
logger.info(f" BPM: {beat_info['bpm']:.0f}, {beat_info['beat_count']}拍, {len(bgm_segments)}组")
# ── 3. 混音 (人声+BGM, 避让) ──
temp_dir = tempfile.mkdtemp(prefix="ave_hybrid_")
mixed_audio = os.path.join(temp_dir, "mixed.wav")
# BGM 避让: 说话时压低BGM
mixed_audio = _duck_and_mix(
voice_path, bgm_path, voice_segments, mixed_audio,
voice_volume=voice_volume, bgm_volume=bgm_volume,
)
# ── 4. 准备素材 ──
clips = list(material_clips) if material_clips else []
if len(clips) < len(voice_segments) and pexels_api_key:
from material_producer.pexels.search import search_videos
needed = len(voice_segments) - len(clips)
logger.info(f" 素材不足, 从 Pexels 搜索 {needed} 个...")
kw = pexels_search or "background video"
found = search_videos(kw, count=needed, api_key=pexels_api_key, orientation="portrait")
for f in found:
clips.append(f["path"])
logger.info(f" 共 {len(clips)} 个素材")
if not clips:
raise ValueError("无可用素材")
# ── 5. 为每段分配素材 + 计算速度 ──
# 将 BGM 节拍能量映射到每个说话段上
fps = 30
seg_plans = [] # [(clip_path, adjusted_dur, speed), ...]
for idx, vseg in enumerate(voice_segments):
clip = clips[idx % len(clips)]
v_start = vseg["start"]
v_end = vseg["end"]
v_dur = v_end - v_start
if v_dur < 0.5:
continue
# 在该时间段内计算 BGM 平均能量
avg_energy = _average_energy_in_range(bgm_segments, v_start, v_end)
speed = base_speed
if enable_speed_ramp:
if avg_energy > 0.6:
speed = base_speed + (high_speed - base_speed) * min((avg_energy - 0.6) / 0.4, 1.0)
elif avg_energy < 0.3:
speed = low_speed + (base_speed - low_speed) * (avg_energy / 0.3)
speed = max(0.5, min(2.0, speed))
# 帧锁定时长
frames = round(v_dur * fps)
dur_aligned = frames / fps
seg_plans.append({
"clip": clip,
"start": v_start,
"end": v_end,
"duration": dur_aligned,
"speed": speed,
"energy": avg_energy,
})
logger.info(f" 分配 {len(seg_plans)} 段素材"
+ (f", 变速范围 {low_speed:.1f}~{high_speed:.1f}x" if enable_speed_ramp else ""))
# ── 6. 渲染每段 ──
seg_files = []
for idx, plan in enumerate(seg_plans):
clip = plan["clip"]
dur = plan["duration"]
speed = plan["speed"]
mt = dur / max(speed, 0.01) # 素材需要的时间长度
seg_file = os.path.join(temp_dir, f"seg_{idx:03d}.mp4")
# 从素材中截取一段 (考虑变速)
clip_dur = _get_duration(clip)
offset = (plan["start"] * 17) % max(clip_dur - mt, 0.1) if clip_dur > mt else 0
raw_seg = os.path.join(temp_dir, f"seg_{idx:03d}_raw.mp4")
subprocess.run([
FFMPEG, "-y",
"-ss", str(offset),
"-i", clip,
"-t", str(mt),
"-vf", f"scale={resolution.replace('x',':')},setsar=1,fps={fps}",
"-c:v", "libx264", "-preset", "fast", "-crf", "23",
"-an", raw_seg,
], capture_output=True, timeout=120, check=True)
if speed != 1.0 and 0.5 <= speed <= 2.0:
subprocess.run([
FFMPEG, "-y", "-i", raw_seg,
"-filter_complex", f"setpts={1/speed}*PTS[v]",
"-map", "[v]",
"-c:v", "libx264", "-preset", "fast", "-crf", "23",
"-pix_fmt", "yuv420p", seg_file,
], capture_output=True, timeout=120, check=True)
else:
os.replace(raw_seg, seg_file)
seg_files.append(seg_file)
if not seg_files:
raise RuntimeError("渲染失败: 无有效段")
# ── 7. 拼接 ──
if len(seg_files) == 1:
cmd = [FFMPEG, "-y", "-i", seg_files[0], "-i", mixed_audio,
"-shortest", "-c:v", "copy", "-c:a", "aac", "-b:a", "192k", output_path]
subprocess.run(cmd, capture_output=True, timeout=120, check=True)
else:
_concat_hybrid_segments(seg_files, mixed_audio, output_path, seg_plans, fps)
# ── 8. 字幕叠加 ──
if texts and len(texts) > 0 and seg_plans:
_add_text_subtitles(output_path, texts, seg_plans, resolution)
# ── 9. 清理 ──
shutil.rmtree(temp_dir, ignore_errors=True)
final_size = os.path.getsize(output_path) / 1024 / 1024
logger.info(f"✅ 融合完成: {output_path} ({final_size:.1f}MB, "
f"{len(seg_files)} 段{', 能量变速' if enable_speed_ramp else ''})")
return output_path
# ── 内部辅助函数 ──────────────────────────────────────────
def _silences_to_segments(silences: list[dict], total_duration: float) -> list[dict]:
"""从静音段反推人声活跃段"""
if not silences:
return [{"start": 0.0, "end": total_duration}]
segments = []
prev_end = 0.0
for s in silences:
s_start = s.get("start", 0)
s_end = s.get("end", 0)
if s_start > prev_end + 0.1:
segments.append({"start": prev_end, "end": s_start})
prev_end = s_end
if prev_end < total_duration - 0.3:
segments.append({"start": prev_end, "end": total_duration})
if not segments:
return [{"start": 0.0, "end": total_duration}]
return segments
def _average_energy_in_range(bgm_segments: list[dict], start: float, end: float) -> float:
"""计算时间范围 [start, end] 内 BGM 的平均能量"""
energies = []
for seg in bgm_segments:
seg_start = seg["start"]
seg_end = seg["end"]
# 检查重叠
if seg_end < start or seg_start > end:
continue
energy = seg.get("energy", 0.5)
overlap = min(seg_end, end) - max(seg_start, start)
if overlap > 0:
energies.append((energy, overlap))
if not energies:
return 0.5
# 加权平均 (按重叠时长)
total_weight = sum(w for _, w in energies) or 1.0
avg = sum(e * w for e, w in energies) / total_weight
return max(0.0, min(1.0, avg))
def _duck_and_mix(
voice_path: str,
bgm_path: str,
voice_segments: list[dict],
output_path: str,
voice_volume: float = 1.0,
bgm_volume: float = 0.35,
) -> str:
"""
BGM 音量避让混音:
- 说话时: BGM 音量降到 bgm_volume * 0.4
- 间隙时: BGM 恢复到 bgm_volume
"""
import numpy as np
import soundfile as sf
# 加载音频
voice, sr_v = sf.read(voice_path)
bgm, sr_b = sf.read(bgm_path)
# 重采样 BGM 到相同 sr
if sr_b != sr_v:
import librosa
bgm = librosa.resample(bgm, orig_sr=sr_b, target_sr=sr_v)
# 补齐 BGM 到人声长度
if len(bgm) < len(voice):
repeats = int(np.ceil(len(voice) / len(bgm)))
bgm = np.tile(bgm, repeats)
bgm = bgm[:len(voice)]
# 创建音量包络: 说话段压低 BGM
bgm_envelope = np.ones(len(voice))
for seg in voice_segments:
s = int(seg["start"] * sr_v)
e = int(seg["end"] * sr_v)
bgm_envelope[s:e] = 0.4 # 说话时 BGM 降到 40%
# 平滑过渡 (0.05s fade)
fade_samples = int(0.05 * sr_v)
for i in range(1, len(bgm_envelope) - fade_samples * 2):
if bgm_envelope[i] != bgm_envelope[i - 1]:
# 在变化点做淡变
start = max(0, i - fade_samples)
end = min(len(bgm_envelope), i + fade_samples)
for j in range(start, end):
frac = (j - start) / max(end - start, 1)
bgm_envelope[j] = bgm_envelope[start] + frac * (bgm_envelope[end - 1] - bgm_envelope[start])
# 混音
voice_float = voice.astype(np.float32)
bgm_float = bgm.astype(np.float32)
# 归一化到 [-1, 1]
voice_float /= max(np.max(np.abs(voice_float)), 0.001)
bgm_float /= max(np.max(np.abs(bgm_float)), 0.001)
mixed = voice_float * voice_volume + bgm_float * bgm_volume * bgm_envelope
# 限制防止爆音
peak = np.max(np.abs(mixed))
if peak > 0.99:
mixed /= peak * 1.02
sf.write(output_path, mixed, sr_v)
logger.debug(f" 混音完成: {output_path} ({sr_v}Hz, {len(voice)/sr_v:.1f}s)")
return output_path
def _concat_hybrid_segments(
seg_files: list[str],
mixed_audio: str,
output_path: str,
seg_plans: list[dict],
fps: int = 30,
) -> str:
"""帧锁定拼接混合音频"""
if len(seg_files) == 1:
cmd = [FFMPEG, "-y", "-i", seg_files[0], "-i", mixed_audio,
"-shortest", "-c:v", "copy", "-c:a", "aac", "-b:a", "192k", output_path]
subprocess.run(cmd, capture_output=True, timeout=120, check=True)
return output_path
# concat demuxer 拼接视频
concat_file = tempfile.NamedTemporaryFile(suffix=".txt", delete=False, mode="w").name
with open(concat_file, "w") as f:
for sf in seg_files:
f.write(f"file '{os.path.abspath(sf)}'\n")
video_only = output_path.replace(".mp4", "_novideo.mp4")
cmd = [FFMPEG, "-y", "-f", "concat", "-safe", "0", "-i", concat_file,
"-c:v", "libx264", "-preset", "fast", "-crf", "23", "-an", video_only]
subprocess.run(cmd, capture_output=True, timeout=300, check=True)
# 叠加混合音频
cmd2 = [FFMPEG, "-y", "-i", video_only, "-i", mixed_audio,
"-shortest", "-c:v", "copy", "-c:a", "aac", "-b:a", "192k", output_path]
subprocess.run(cmd2, capture_output=True, timeout=120, check=True)
os.unlink(concat_file)
if os.path.exists(video_only):
os.unlink(video_only)
logger.info(f" ✅ 拼接完成: {len(seg_files)} 段")
return output_path
def _add_text_subtitles(
video_path: str,
texts: list[str],
seg_plans: list[dict],
resolution: str,
):
"""为每段叠加文字字幕 (ASS 格式)"""
width, height = (int(x) for x in resolution.split("x"))
font_size = max(48, int(height * 0.05))
margin_v = int(height * 0.3)
lines = [
"[Script Info]", "ScriptType: v4.00+",
f"PlayResX: {width}", f"PlayResY: {height}",
"ScaledBorderAndShadow: yes", "",
"[V4+ Styles]",
"Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding",
f"Style: Default,Noto Sans SC,{font_size},&H00FFFFFF,&H0000FFFF,&H00000000,&H80000000,-1,0,0,0,100,100,0,0,1,3,1,2,100,100,{margin_v},1",
"", "[Events]",
"Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text",
]
for i, text in enumerate(texts):
if i >= len(seg_plans):
break
start = seg_plans[i]["start"]
dur = seg_plans[i]["duration"]
end = start + dur
def _fmt_ts(sec: float) -> str:
h = int(sec // 3600)
m = int((sec % 3600) // 60)
s = sec % 60
return f"{h}:{m:02d}:{s:05.2f}"
lines.append(
f"Dialogue: 0,{_fmt_ts(start)},{_fmt_ts(end)},Default,,0,0,0,,{text}"
)
ass_path = video_path.replace(".mp4", "_hybrid_text.ass")
with open(ass_path, "w", encoding="utf-8") as f:
f.write("\n".join(lines))
tmp = video_path.replace(".mp4", "_tmp.mp4")
subprocess.run([
FFMPEG, "-y", "-i", video_path,
"-vf", f"subtitles={ass_path}",
"-c:v", "libx264", "-preset", "fast", "-crf", "23",
"-c:a", "copy", "-pix_fmt", "yuv420p", tmp,
], capture_output=True, timeout=120, check=True)
os.replace(tmp, video_path)
os.remove(ass_path)
logger.info(f" ✅ 字幕叠加: {len(texts)} 段")
def _get_duration(path: str) -> float:
"""获取媒体文件时长(秒)
用 lib.ffmpeg.probe_duration(ffmpeg -i 解析)——
本机 ffprobe 实为 ffmpeg 的符号链接,不支持 -show_entries(会静默失败)
"""
try:
from lib.ffmpeg import probe_duration
d = probe_duration(path)
return d if d > 0 else 30.0
except Exception:
return 30.0