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,181 @@
|
||||
"""
|
||||
composer.de_ai — 去 AI 化处理链(DCS 第八节)
|
||||
================================================
|
||||
|
||||
目的:消除 AI 生成视频的"塑料感/锐利边缘/过饱和",做出接近真人实拍(伪纪录片)的质感。
|
||||
|
||||
处理链(按 DCS 文档顺序):
|
||||
① gblur 轻微模糊 —— 消除 AI 边缘过度锐利
|
||||
② noise 胶片颗粒 —— 添加真实传感器噪点
|
||||
③ eq saturation 降饱和 —— 消 AI 味的艳丽
|
||||
④ curves S 曲线 —— 拉反差,更像实拍
|
||||
⑤ colortemperature 色温 —— 偏移到 5300K(日光偏暖)
|
||||
|
||||
默认参数(DCS 默认,可被分镜的 post_process / color 覆盖):
|
||||
模糊 sigma 0.4 | 颗粒 12 | 饱和 -0.12 | 色温 5300K
|
||||
|
||||
用法:
|
||||
from composer.de_ai import DeAIProcessor
|
||||
DeAIProcessor().process("in.mp4", "out.mp4") # 默认参数
|
||||
DeAIProcessor().process("in.mp4", "out.mp4", blur_sigma=0.6, grain=20)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
log = logging.getLogger("composer.de_ai")
|
||||
|
||||
# FFmpeg 绝对路径(本机装在 ~/.local/bin,不在默认 PATH)
|
||||
_FFMPEG_CANDIDATES = [
|
||||
os.path.expanduser("~/.local/bin/ffmpeg"),
|
||||
"/usr/local/bin/ffmpeg",
|
||||
"/opt/homebrew/bin/ffmpeg",
|
||||
"ffmpeg",
|
||||
]
|
||||
|
||||
|
||||
def find_ffmpeg() -> str:
|
||||
for c in _FFMPEG_CANDIDATES:
|
||||
if c == "ffmpeg" or Path(c).exists():
|
||||
if c == "ffmpeg" and not shutil.which("ffmpeg"):
|
||||
continue
|
||||
return c
|
||||
raise FileNotFoundError("未找到 ffmpeg(请在 ~/.local/bin 或 PATH 中安装)")
|
||||
|
||||
|
||||
class DeAIProcessor:
|
||||
"""去 AI 化处理器"""
|
||||
|
||||
# DCS 默认参数
|
||||
DEFAULT_BLUR_SIGMA = 0.4
|
||||
DEFAULT_GRAIN = 12
|
||||
DEFAULT_SATURATION = -0.12 # 偏移量(实际 saturation = 1 + offset = 0.88)
|
||||
DEFAULT_TEMPERATURE = 5300 # 色温 K
|
||||
DEFAULT_CRF = 23 # 画质(未显式指定时按**源码率自适应**,见 _adaptive_crf)
|
||||
DEFAULT_PRESET = "medium"
|
||||
|
||||
def __init__(self, ffmpeg: str | None = None):
|
||||
self.ffmpeg = ffmpeg or find_ffmpeg()
|
||||
|
||||
def _adaptive_crf(self, video_path: str) -> int:
|
||||
"""按**源码率**自适应 CRF —— 避免对已压缩素材重编码后体积膨胀
|
||||
|
||||
实测:规范化后的素材(约 1.5 Mbps)用 CRF 23 重编码 → 体积膨胀到 2.7 倍;
|
||||
改用 CRF 27 后回到合理区间(视觉几乎无差)。
|
||||
"""
|
||||
import re
|
||||
import subprocess
|
||||
kbps = 0.0
|
||||
try:
|
||||
# 用 `ffmpeg -i` 解析(本机 ffprobe 不支持 -select_streams / -show_entries,
|
||||
# 用它取码率会静默失败 → 一律落兜底值)
|
||||
r = subprocess.run([self.ffmpeg, "-i", str(video_path)],
|
||||
capture_output=True, text=True, timeout=20)
|
||||
info = (r.stderr or "") + (r.stdout or "")
|
||||
m = re.search(r"bitrate:\s*(\d+)\s*kb/s", info)
|
||||
if m:
|
||||
kbps = float(m.group(1))
|
||||
if not kbps:
|
||||
# 兜底:用「文件大小 / 时长」估算
|
||||
dm = re.search(r"Duration:\s*(\d+):(\d+):([\d.]+)", info)
|
||||
if dm:
|
||||
dur = int(dm.group(1)) * 3600 + int(dm.group(2)) * 60 + float(dm.group(3))
|
||||
if dur > 0:
|
||||
kbps = Path(video_path).stat().st_size * 8 / dur / 1000
|
||||
except Exception:
|
||||
kbps = 0.0
|
||||
if 0 < kbps < 3000:
|
||||
return 27 # 已压缩素材(含规范化后的)→ 不再加码率
|
||||
if kbps and kbps < 6000:
|
||||
return 25
|
||||
return self.DEFAULT_CRF
|
||||
|
||||
def build_filter(self, blur_sigma: float = DEFAULT_BLUR_SIGMA,
|
||||
grain: int = DEFAULT_GRAIN,
|
||||
saturation_offset: float = DEFAULT_SATURATION,
|
||||
temperature: int = DEFAULT_TEMPERATURE,
|
||||
enable_curves: bool = True) -> str:
|
||||
"""构造 FFmpeg 滤镜链(可单独测试)"""
|
||||
parts = []
|
||||
if blur_sigma and blur_sigma > 0:
|
||||
parts.append(f"gblur=sigma={blur_sigma:.2f}")
|
||||
if grain and grain > 0:
|
||||
parts.append(f"noise=alls={int(grain)}:allf=t+u")
|
||||
sat = max(0.0, 1.0 + float(saturation_offset))
|
||||
if abs(saturation_offset) > 1e-6:
|
||||
parts.append(f"eq=saturation={sat:.3f}")
|
||||
if enable_curves:
|
||||
parts.append("curves=all='0/0 0.5/0.55 1/1'")
|
||||
if temperature and int(temperature) != 6500:
|
||||
parts.append(f"colortemperature=temperature={int(temperature)}")
|
||||
return ",".join(parts)
|
||||
|
||||
def process(self, input_path: str, output_path: str,
|
||||
blur_sigma: float | None = None,
|
||||
grain: int | None = None,
|
||||
saturation_offset: float | None = None,
|
||||
temperature: int | None = None,
|
||||
crf: int | None = None,
|
||||
preset: str | None = None,
|
||||
keep_audio: bool = True) -> str:
|
||||
"""执行去 AI 化处理 → 返回输出路径(失败抛异常)"""
|
||||
inp = Path(input_path)
|
||||
if not inp.exists():
|
||||
raise FileNotFoundError(f"输入视频不存在: {input_path}")
|
||||
out = Path(output_path)
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
vf = self.build_filter(
|
||||
blur_sigma=self.DEFAULT_BLUR_SIGMA if blur_sigma is None else blur_sigma,
|
||||
grain=self.DEFAULT_GRAIN if grain is None else grain,
|
||||
saturation_offset=self.DEFAULT_SATURATION if saturation_offset is None else saturation_offset,
|
||||
temperature=self.DEFAULT_TEMPERATURE if temperature is None else temperature,
|
||||
)
|
||||
cmd = [
|
||||
self.ffmpeg, "-y", "-i", str(inp),
|
||||
"-vf", vf,
|
||||
"-c:v", "libx264",
|
||||
"-preset", preset or self.DEFAULT_PRESET,
|
||||
"-crf", str(crf if crf is not None else self._adaptive_crf(input_path)),
|
||||
# ⚠️ 必须显式压回 yuv420p:滤镜链中的 curves / colortemperature 在 RGB 域
|
||||
# 运算后会把像素格式提升为 yuv444p,libx264 无此参数时会保持 444p 输出
|
||||
# (High 4:4:4 Predictive profile)→ 成片在微信/抖音/iOS/多数安卓上无法播放。
|
||||
"-pix_fmt", "yuv420p",
|
||||
]
|
||||
cmd += ["-c:a", "copy"] if keep_audio else ["-an"]
|
||||
cmd.append(str(out))
|
||||
|
||||
log.info(" 🎞️ 去 AI 化: %s → %s", inp.name, out.name)
|
||||
log.debug(" 滤镜: %s", vf)
|
||||
r = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if r.returncode != 0:
|
||||
raise RuntimeError(f"ffmpeg 失败: {(r.stderr or '')[-300:]}")
|
||||
if not out.exists() or out.stat().st_size == 0:
|
||||
raise RuntimeError("ffmpeg 输出为空")
|
||||
log.info(" ✅ 去 AI 化完成: %.1f MB", out.stat().st_size / 1024 / 1024)
|
||||
return str(out)
|
||||
|
||||
def apply_to_storyboard(self, video_path: str, output_path: str,
|
||||
storyboard=None) -> str:
|
||||
"""按分镜的 post_process / color 配置处理(第一期分镜的设置)"""
|
||||
kw: dict = {}
|
||||
if storyboard is not None and getattr(storyboard, "shots", None):
|
||||
pp = storyboard.shots[0].post_process
|
||||
cc = storyboard.shots[0].color
|
||||
kw = {
|
||||
"blur_sigma": pp.blur_sigma,
|
||||
"grain": pp.grain_intensity,
|
||||
"saturation_offset": cc.saturation_offset,
|
||||
"temperature": 6500 + int(cc.temperature_shift * 1000), # 偏移 → 色温
|
||||
}
|
||||
return self.process(video_path, output_path, **kw)
|
||||
|
||||
|
||||
def deai_filter_preview() -> str:
|
||||
"""给前端展示用的默认滤镜链"""
|
||||
return DeAIProcessor().build_filter()
|
||||
Reference in New Issue
Block a user