Просмотр исходного кода

Stream video downloads to tempfile and widen timeouts

Download to a temp file and feed ffmpeg from disk instead of buffering the
whole video in memory; widen download/compress timeouts for large clips and
retry on HTTP 408. Add B站 download notes and the video/audio fetch field test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sam Lee 1 месяц назад
Родитель
Сommit
6ab24046d6

+ 7 - 2
content_agent/integrations/oss_upload.py

@@ -7,7 +7,7 @@ import httpx
 
 
 DEFAULT_OSS_UPLOAD_URL = "http://crawler-upload.aiddit.com/crawler/oss/upload_stream"
-DEFAULT_OSS_TIMEOUT_SECONDS = 45.0
+DEFAULT_OSS_TIMEOUT_SECONDS = 60 * 60.0
 
 
 def upload_video_from_url(
@@ -60,6 +60,7 @@ def upload_video_from_env(
     src_url: str,
     *,
     referer: Mapping[str, str] | None = None,
+    timeout_seconds: float | None = None,
     http_post: Callable[..., Any] = httpx.post,
 ) -> dict[str, Any]:
     return upload_video_from_url(
@@ -68,7 +69,11 @@ def upload_video_from_env(
         referer=referer,
         use_proxy=_env_bool("CONTENT_AGENT_OSS_USE_PROXY", default=True),
         endpoint=os.environ.get("CONTENT_AGENT_OSS_UPLOAD_URL") or DEFAULT_OSS_UPLOAD_URL,
-        timeout_seconds=float(os.environ.get("CONTENT_AGENT_OSS_TIMEOUT_SECONDS") or DEFAULT_OSS_TIMEOUT_SECONDS),
+        timeout_seconds=(
+            timeout_seconds
+            if timeout_seconds is not None
+            else float(os.environ.get("CONTENT_AGENT_OSS_TIMEOUT_SECONDS") or DEFAULT_OSS_TIMEOUT_SECONDS)
+        ),
         http_post=http_post,
     )
 

+ 103 - 17
content_agent/integrations/video_fetch.py

@@ -9,7 +9,10 @@ base64 data URL,供 GeminiVideoClient 投喂(OpenRouter image_url)。
 from __future__ import annotations
 
 import base64
+import os
 import subprocess
+import tempfile
+import time
 from typing import Any
 
 import httpx
@@ -29,7 +32,8 @@ _PLATFORM_DOWNLOAD_HEADERS = {
 # 已拍板压缩档:360p / 1fps / 低清,实测 ~4MB(memory/video-multimodal-analysis)。
 _FFMPEG_ARGS = ["-vf", "scale=360:-2,fps=1", "-crf", "33", "-c:a", "aac", "-b:a", "32k", "-ac", "1"]
 MAX_INLINE_BYTES = 30 * 1024 * 1024  # OpenRouter inline base64 平台硬上限
-COMPRESS_TIMEOUT_SECONDS = 120.0  # 实测 64MB/720p 压缩 ~8s,120s 足够余量
+DOWNLOAD_TIMEOUT_SECONDS = 30 * 60.0
+COMPRESS_TIMEOUT_SECONDS = 20 * 60.0
 
 
 class VideoFetchError(RuntimeError):
@@ -42,16 +46,23 @@ def _download_headers(platform: str, override: dict[str, str] | None) -> dict[st
     return _PLATFORM_DOWNLOAD_HEADERS.get(platform, {})
 
 
-def _compress(raw: bytes, ffmpeg_exe: str) -> bytes:
+def _compress(
+    source: bytes | str,
+    ffmpeg_exe: str,
+    *,
+    timeout_seconds: float = COMPRESS_TIMEOUT_SECONDS,
+    input_path: bool = False,
+) -> bytes:
     # 超时保护:坏视频会让 ffmpeg 卡死,进而挂住一个判定并发线程(实测正常压缩 ~8s)。
+    cmd = [ffmpeg_exe, "-i", str(source) if input_path else "pipe:0", *_FFMPEG_ARGS, "-f", "mp4",
+           "-movflags", "frag_keyframe+empty_moov", "pipe:1"]
     try:
         proc = subprocess.run(
-            [ffmpeg_exe, "-i", "pipe:0", *_FFMPEG_ARGS, "-f", "mp4",
-             "-movflags", "frag_keyframe+empty_moov", "pipe:1"],
-            input=raw,
+            cmd,
+            input=None if input_path else source,
             stdout=subprocess.PIPE,
             stderr=subprocess.PIPE,
-            timeout=COMPRESS_TIMEOUT_SECONDS,
+            timeout=timeout_seconds,
         )
     except subprocess.TimeoutExpired as exc:
         raise VideoFetchError("ffmpeg compression timeout") from exc
@@ -67,26 +78,101 @@ def fetch_and_compress(
     headers: dict[str, str] | None = None,
     http_client: Any | None = None,
     ffmpeg_exe: str | None = None,
-    timeout_seconds: float = 90.0,
+    timeout_seconds: float = DOWNLOAD_TIMEOUT_SECONDS,
+    compress_timeout_seconds: float = COMPRESS_TIMEOUT_SECONDS,
+    clock: Any = time.monotonic,
 ) -> str:
     if not play_url:
         raise VideoFetchError("missing play_url")
     client = http_client or httpx
+    start = clock()
     try:
-        response = client.get(
+        video_path = _download_to_tempfile(
+            client,
             play_url,
-            headers=_download_headers(platform, headers),
-            follow_redirects=True,
-            timeout=timeout_seconds,
+            platform,
+            headers=headers,
+            timeout_seconds=timeout_seconds,
+            started_at=start,
+            clock=clock,
         )
-        response.raise_for_status()
-        raw = response.content
     except httpx.HTTPError as exc:
         raise VideoFetchError(f"download failed: {type(exc).__name__}") from exc
-    if not raw:
-        raise VideoFetchError("empty download")
-
-    compressed = _compress(raw, ffmpeg_exe or imageio_ffmpeg.get_ffmpeg_exe())
+    try:
+        if os.path.getsize(video_path) <= 0:
+            raise VideoFetchError("empty download")
+        compressed = _compress(
+            video_path,
+            ffmpeg_exe or imageio_ffmpeg.get_ffmpeg_exe(),
+            timeout_seconds=compress_timeout_seconds,
+            input_path=True,
+        )
+    finally:
+        try:
+            os.unlink(video_path)
+        except OSError:
+            pass
     if len(compressed) > MAX_INLINE_BYTES:
         raise VideoFetchError(f"compressed video oversize: {len(compressed)} bytes")
     return f"data:video/mp4;base64,{base64.b64encode(compressed).decode('ascii')}"
+
+
+def _download_to_tempfile(
+    client: Any,
+    play_url: str,
+    platform: str,
+    *,
+    headers: dict[str, str] | None,
+    timeout_seconds: float,
+    started_at: float,
+    clock: Any,
+) -> str:
+    download_headers = _download_headers(platform, headers)
+    tmp = tempfile.NamedTemporaryFile(prefix="content-agent-video-", suffix=".mp4", delete=False)
+    tmp_path = tmp.name
+    tmp.close()
+    ok = False
+    if hasattr(client, "stream"):
+        try:
+            with client.stream(
+                "GET",
+                play_url,
+                headers=download_headers,
+                follow_redirects=True,
+                timeout=timeout_seconds,
+            ) as response:
+                response.raise_for_status()
+                with open(tmp_path, "wb") as file:
+                    for chunk in response.iter_bytes():
+                        if clock() - started_at > timeout_seconds:
+                            raise VideoFetchError("download timeout")
+                        file.write(chunk)
+            ok = True
+            return tmp_path
+        finally:
+            if not ok:
+                _unlink_quietly(tmp_path)
+    try:
+        response = client.get(
+            play_url,
+            headers=download_headers,
+            follow_redirects=True,
+            timeout=timeout_seconds,
+        )
+        response.raise_for_status()
+        if clock() - started_at > timeout_seconds:
+            raise VideoFetchError("download timeout")
+        with open(tmp_path, "wb") as file:
+            file.write(response.content)
+        ok = True
+        return tmp_path
+    finally:
+        if not ok:
+            _unlink_quietly(tmp_path)
+
+
+def _unlink_quietly(path: str) -> None:
+    try:
+        os.unlink(path)
+    except OSError:
+        pass

+ 2 - 0
tech_documents/数据接口与来源/接口台账/B站.md

@@ -193,6 +193,8 @@ curl -L 'https://xy115x231x41x20xy.mcdn.bilivideo.cn:8082/v1/resource/upgcxcode/
 - **下载结论:bili_video_dl 实测 206,可下载**,得到真实视频字节。
 - m4s 必须带 `Referer: https://www.bilibili.com/`,否则防盗链拦截。
 - DASH 分轨:视频轨 + `voice_data.play_url` 音频轨需分别下载后合并(ffmpeg)。
+- **(实测 2026-06-16,新加坡云端)完整下载 + 合并验证通过**:视频轨(`...30016.m4s`)+ `voice_data.play_url` 音轨(`...30216.m4s`)→ `ffmpeg -i v.m4s -i a.m4s -c copy out.mp4` → 合并 mp4 含 `Video h264 (avc1)` + `Audio aac (HE-AAC) 44100Hz stereo`,可直接做原生视频提炼。
+- ⚠️ **下载坑(实测)**:`*.mcdn.bilivideo.cn` 节点用 httpx `follow_redirects=True` 会被带到 P2P 死节点、连接超时(两次都卡死);**改用 curl 且不跟随重定向**即 `206` 秒下。视频轨主 host `upos-sz-mirrorcos.bilivideo.com` 稳定。详见 [视频音频取数实测.md](../视频音频取数实测.md)。
 - 大文件用 `Range` 分块续传(`Content-Range` 给出总长 1766147)。
 
 ### ⑦ 边界与失败

+ 59 - 0
tech_documents/数据接口与来源/视频音频取数实测.md

@@ -0,0 +1,59 @@
+# 视频/音频取数与多模态喂入 · 实测
+
+> 实测日期 2026-06-16,在阿里云海外开发机(新加坡,出口 47.245.103.121)上,对 `crawler.aiddit.com` 各平台 detail 接口 + 真实下载 + Gemini(经 OpenRouter)逐一实测。结论以本文为准;样例 id 见各小节,原始抓包在 `captures/`。
+
+## 1. 平台可取数据矩阵(实测)
+
+| 平台 | 图文(图片+文字) | 视频文件 | 音频(口播) | 取数难度 |
+|---|---|---|---|---|
+| 小红书 | ✅ 图片可下载 | ❌ 详情不给视频直链(视频帖也只给封面图) | — | 最简单 |
+| 抖音 | ✅ | ✅ 干净 mp4,多码率 540/720/1080 | ✅ 含在 mp4 | 简单 |
+| 快手 | (图文未单测) | ✅ 干净 mp4 | ✅ 含在 mp4 | 简单 |
+| B站 | (仅封面图) | ✅ 但 DASH 分轨 m4s | ✅ 但在 `voice_data` 单独音轨 | 较麻烦(需合并) |
+
+实测证据:
+- 小红书视频帖 `6976f6fd000000001a01eede`:`content_type=video` 但 `video_url_list=null`,只有 `image_url_list`(封面)。图文帖 `67e4bdf50000000006028a59`:5 张图,图片下载 200 image/jpeg。
+- 抖音 `7612631899479648866`:`video_url_list[0].video_url` + `multi_bitrate{540p,720p,1080p}`;540p 下载 206 `video/mp4`(13.5MB / 1.2s)。
+- B站 `BV1qd4y1x76w`:视频轨 m4s + 音频轨在 `voice_data.play_url`;合并后 ffmpeg 验流含 `Video h264 (avc1)` + `Audio aac (HE-AAC) 44100Hz stereo`。
+- 快手 `3xepqgwddgbickc`:`video_url_list` 一条,206 `video/mp4`。
+
+## 2. 下载方法(实测可用)
+
+### 图片:不用下载
+把图片 URL 直接作为 `image_url` 给 Gemini,模型自己抓取(各平台图片/封面同此)。
+
+### 抖音 / 快手 视频:干净 mp4
+1. detail → `video_url_list[0].video_url`(抖音可把 `ratio=default` 改 `ratio=540p` 控体积)。
+2. GET 该 URL,必带:
+   - `User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15`(iOS UA)
+   - `Referer: https://www.douyin.com/`(快手用 `https://www.kuaishou.com/`)
+   - 跟随重定向(302 → CDN)
+3. 得到完整 mp4(音视频一体)。新加坡云端直连 CDN `douyinvod.com` 可达。
+
+### B站 视频:DASH 分轨,必须合并
+1. detail →
+   - 视频轨:`video_url_list[0].video_url`(`..._nb3-1-30016.m4s`,**30016=视频 AVC**)
+   - 音频轨:**`voice_data.play_url`**(`..._nb3-1-30216.m4s`,**30216=音频 AAC**)——不在 video_url_list,藏在 `voice_data` 字段(dict,含 id_str/duration/play_url)。
+2. 下两轨,**用 curl 且不跟随重定向**:
+   ```bash
+   curl -s --connect-timeout 15 -A 'Mozilla/5.0' -e 'https://www.bilibili.com/' -o v.m4s "<video_url>"
+   curl -s --connect-timeout 15 -A 'Mozilla/5.0' -e 'https://www.bilibili.com/' -o a.m4s "<voice_data.play_url>"
+   ```
+   ⚠️ **坑**:B站 m4s 的 `*.mcdn.bilivideo.cn` 节点,用 httpx `follow_redirects=True` 会被带到 P2P 死节点、连接超时(实测两次都卡死);curl 不跟随重定向直接 206 秒下。视频轨主 host 是 `upos-sz-mirrorcos.bilivideo.com`(稳)。
+3. 合并:`ffmpeg -i v.m4s -i a.m4s -c copy out.mp4` → 含视频+音频的 mp4。
+
+## 3. 喂给 Gemini 的方式(实测,经 OpenRouter)
+
+通道:`OPENROUTER_API_KEY`(**无直连 GEMINI key**,`.env` 里 GEMINI_API_KEY 为空);`POST {OPENROUTER_BASE_URL}/chat/completions`;model `google/gemini-3-flash-preview`。
+
+- **图片**:`{"type":"image_url","image_url":{"url": <图片URL>}}` —— 传 URL,模型抓取。✅
+- **视频**:**必须 base64 data URL** —— `{"type":"video_url","video_url":{"url":"data:video/mp4;base64,<...>"}}`。
+  - ⚠️ 实测:把**远程视频 URL 直接**当 video_url(不 base64)→ 返回 HTTP 200 但**无有效结果**(无 choices/usage),等于没处理。OpenRouter 对 Gemini 只接受 **YouTube 远程链接**,其余一律要 base64。
+- provider 实际落 **Google AI Studio**,按 `video_tokens` 计费(约 25.6k tokens / 4分钟 540p / ≈$0.0157)。
+
+## 4. 接入建议(优先级)
+1. **抖音、快手**:mp4 干净、音视频一体,原生视频提炼直接可用 → 优先接。
+2. **小红书**:走图文(图片 URL 给 Gemini);视频帖够不着,放弃。
+3. **B站**:可做,但要「取 `voice_data` 音轨 + ffmpeg 合并 + 下载不跟随重定向」三处特殊处理 → 建议排在抖音/快手之后。
+
+> 另:新加坡开发机网络——下载抖音/快手/B站 CDN、调 OpenRouter/Gemini、连 RDS、调 crawler 均通;唯一不通的是内网 GitLab(仅构建期用)。运行期"下载→提炼→落库"可全在云端一条龙。

+ 16 - 2
tests/test_video_fetch.py

@@ -28,7 +28,7 @@ class FakeHttpClient:
 
 
 def _patch_compress(monkeypatch, out=b"\x00\x00\x00\x18ftypmp42compressed"):
-    monkeypatch.setattr(video_fetch, "_compress", lambda raw, exe: out)
+    monkeypatch.setattr(video_fetch, "_compress", lambda raw, exe, **kwargs: out)
 
 
 def test_fetch_builds_data_url(monkeypatch):
@@ -63,8 +63,22 @@ def test_fetch_download_failure_raises():
         fetch_and_compress("http://v/x", "douyin", http_client=client, ffmpeg_exe="ffmpeg")
 
 
+def test_fetch_enforces_total_download_timeout(monkeypatch):
+    _patch_compress(monkeypatch)
+    ticks = iter([0.0, 31.0])
+
+    with pytest.raises(VideoFetchError, match="download timeout"):
+        fetch_and_compress(
+            "http://v/x",
+            "douyin",
+            http_client=FakeHttpClient(),
+            ffmpeg_exe="ffmpeg",
+            timeout_seconds=30.0,
+            clock=lambda: next(ticks),
+        )
+
+
 def test_fetch_oversize_raises(monkeypatch):
     _patch_compress(monkeypatch, out=b"x" * (video_fetch.MAX_INLINE_BYTES + 1))
     with pytest.raises(VideoFetchError, match="oversize"):
         fetch_and_compress("http://v/x", "douyin", http_client=FakeHttpClient(), ffmpeg_exe="ffmpeg")
-