فهرست منبع

Improve platform video URL handling and diagnostics

Sam Lee 2 ماه پیش
والد
کامیت
659f571ef6

+ 1 - 2
.env.example

@@ -46,6 +46,5 @@ CONTENTFIND_DOUYIN_MAX_RESULTS_PER_QUERY=3
 
 # 内容判定:Gemini 直读视频(V3-M2)
 CONTENT_AGENT_VIDEO_LLM_MODEL=google/gemini-3-flash-preview
-CONTENT_AGENT_VIDEO_LLM_TIMEOUT_SECONDS=90
+CONTENT_AGENT_VIDEO_LLM_TIMEOUT_SECONDS=1800
 # 复用 OPENROUTER_API_KEY / OPENROUTER_BASE_URL(见上方 query LLM 段)
-

+ 25 - 1
content_agent/business_modules/content_discovery/content_discovery_builder.py

@@ -121,7 +121,31 @@ def _build_content_media_record(
     }
     if not record["play_url"]:
         record["failure_reason"] = result.get("media_failure_reason", "no_play_url_returned")
-    return with_raw_payload(record)
+    record = with_raw_payload(record)
+    platform_raw_payload = result.get("platform_raw_payload") or {}
+    if isinstance(platform_raw_payload, dict):
+        for field in [
+            "no_valid_play_url",
+            "selected_video_url_path",
+            "selected_video_url_host",
+            "selected_video_url_probe_status",
+            "selected_video_url_content_type",
+            "selected_video_url_content_range",
+            "video_url_candidate_counts",
+            "video_url_reject_reasons",
+            "kuaishou_detail_fallback_attempted",
+            "kuaishou_detail_fallback_status",
+            "kuaishou_detail_fallback_exception_type",
+            "kuaishou_detail_fallback_error",
+            "shipinhao_research_source",
+            "shipinhao_research_status",
+            "shipinhao_research_exception_type",
+            "shipinhao_research_error",
+            "shipinhao_research_item_count",
+        ]:
+            if field in platform_raw_payload:
+                record["raw_payload"][field] = platform_raw_payload[field]
+    return record
 
 
 def _build_evidence_bundle(

+ 1 - 1
content_agent/business_modules/content_discovery/pattern_recall/recall_decision.py

@@ -239,7 +239,7 @@ def _build_evidence_row(
         "final_status": final_status,
         "retry_count": int(judgment.get("retry_count") or 0),
     }
-    for field in ["failure_type", "exception_type", "error_message", "http_status_code"]:
+    for field in ["failure_type", "exception_type", "error_message", "http_status_code", "response_body_summary"]:
         if field in judgment:
             summary[field] = judgment.get(field)
     if isinstance(judgment.get("timing_metrics"), dict):

+ 19 - 1
content_agent/flow_ledger_service.py

@@ -739,6 +739,7 @@ def _technical_retry_detail(
             "failure_type": _text(item.get("failure_type")),
             "exception_type": _text(item.get("exception_type")),
             "http_status_code": item.get("http_status_code"),
+            "response_body_summary": _record(item.get("response_body_summary")),
         }
         for item in _list(gemini_request.get("attempts"))
     ]
@@ -764,6 +765,9 @@ def _technical_retry_detail(
             "compressed_mb": _megabytes(video_fetch.get("compressed_bytes")),
         },
         "attempts": attempts,
+        "openrouter": {
+            "response_summary": _record(merged.get("response_body_summary")),
+        },
         "oss": {
             "status": _text((media or {}).get("content_media_status")),
             "last_error": _text(media_raw.get("oss_archive_last_error") or media_raw.get("failure_reason")),
@@ -771,6 +775,17 @@ def _technical_retry_detail(
             "last_http_status_code": media_raw.get("oss_archive_last_http_status_code"),
             "attempt_count": media_raw.get("oss_archive_attempt_count"),
             "duration_seconds": _seconds(_record(media_raw.get("oss_timing_metrics")).get("oss_upload_duration_ms")),
+            "payload_mode": _text(media_raw.get("oss_payload_mode")),
+            "response_summary": _record(media_raw.get("oss_response_summary")),
+        },
+        "video_url": {
+            "selected_path": _text(media_raw.get("selected_video_url_path")),
+            "selected_host": _text(media_raw.get("selected_video_url_host")),
+            "probe_status": _text(media_raw.get("selected_video_url_probe_status")),
+            "content_type": _text(media_raw.get("selected_video_url_content_type")),
+            "content_range": _text(media_raw.get("selected_video_url_content_range")),
+            "candidate_counts": _record(media_raw.get("video_url_candidate_counts")),
+            "reject_reasons": _record(media_raw.get("video_url_reject_reasons")),
         },
     }
     return detail
@@ -781,6 +796,8 @@ def _technical_retry_brief_reason(
     payload: dict[str, Any],
     media_raw: dict[str, Any],
 ) -> str:
+    if failure_type == "no_valid_play_url":
+        return "平台结果未找到可用正片 URL"
     if failure_type == "no_play_url":
         return "平台结果没有返回可用 play_url"
     if failure_type == "gemini_client_timeout":
@@ -802,7 +819,7 @@ def _technical_retry_brief_reason(
 def _technical_retry_stage(failure_type: str) -> str:
     if failure_type.startswith("oss_"):
         return "oss"
-    if failure_type == "no_play_url":
+    if failure_type in {"no_play_url", "no_valid_play_url"}:
         return "play_url"
     if "ffmpeg" in failure_type:
         return "ffmpeg"
@@ -831,6 +848,7 @@ def _technical_retry_failure_label(failure_type: str) -> str:
         "gemini_response_invalid": "Gemini 响应无法解析",
         "video_fetch_failed": "视频获取失败",
         "no_play_url": "缺少 play_url",
+        "no_valid_play_url": "未找到可用正片 URL",
         "oss_upload_response_invalid": "OSS 响应无效",
         "oss_upload_http_error": "OSS HTTP 错误",
     }.get(failure_type, failure_type)

+ 64 - 3
content_agent/integrations/gemini_video.py

@@ -9,6 +9,7 @@ from __future__ import annotations
 
 import json
 import os
+import re
 import time
 from typing import Any, Callable, Mapping
 
@@ -39,6 +40,7 @@ def _fail(
     error_message: str | None = None,
     http_status_code: int | None = None,
     retry_count: int = 1,
+    response_body_summary: dict[str, Any] | None = None,
 ) -> dict[str, Any]:
     result = {
         "schema_version": V4_GEMINI_QUERY_RELEVANCE_SCHEMA_VERSION,
@@ -53,6 +55,8 @@ def _fail(
     }
     if error_message:
         result["error_message"] = error_message
+    if response_body_summary:
+        result["response_body_summary"] = response_body_summary
     return result
 
 
@@ -136,6 +140,46 @@ def _http_status(exc: httpx.HTTPError) -> int | None:
     return int(status_code) if isinstance(status_code, int) else None
 
 
+def _response_body_summary(response: Any | None) -> dict[str, Any] | None:
+    if response is None:
+        return None
+    summary: dict[str, Any] = {
+        "http_status_code": getattr(response, "status_code", None),
+    }
+    headers = getattr(response, "headers", None)
+    if headers is not None:
+        summary["content_type"] = headers.get("content-type", "")
+    try:
+        content = response.content or b""
+    except Exception:
+        content = b""
+    if content:
+        summary["body_bytes"] = len(content)
+    try:
+        payload = response.json()
+    except Exception:
+        payload = None
+    if isinstance(payload, dict):
+        summary["json_top_level_keys"] = sorted(str(key) for key in payload.keys())[:20]
+        text_source = payload.get("error") or payload.get("message") or payload.get("msg")
+        if isinstance(text_source, dict):
+            text_source = text_source.get("message") or text_source.get("msg") or text_source.get("code")
+        if text_source is not None:
+            summary["text_excerpt"] = _scrub_response_text(str(text_source))
+    elif content:
+        summary["text_excerpt"] = _scrub_response_text(content.decode("utf-8", errors="replace"))
+    return {key: value for key, value in summary.items() if value not in (None, "", [], {})}
+
+
+def _scrub_response_text(text: str, limit: int = 500) -> str:
+    text = " ".join(text.split())
+    text = re.sub(r"Bearer\s+[A-Za-z0-9._~+/=-]+", "Bearer [REDACTED]", text, flags=re.IGNORECASE)
+    text = re.sub(r"sk-[A-Za-z0-9_-]{16,}", "sk-[REDACTED]", text)
+    text = re.sub(r"data:[^,\s]+;base64,[A-Za-z0-9+/=]{128,}", "data:[REDACTED]", text)
+    text = re.sub(r"(https?://[^\s?]+)\?[^\s]+", r"\1?[REDACTED]", text)
+    return text[:limit] + ("..." if len(text) > limit else "")
+
+
 def _retryable_http(exc: httpx.HTTPError) -> bool:
     if isinstance(exc, (httpx.ConnectError, httpx.TimeoutException, httpx.NetworkError)):
         return True
@@ -183,9 +227,18 @@ class GeminiVideoClient:
         query_text = _query_text(content, source_context)
         video_candidates = _video_url_candidates(media)
         if not video_candidates:
+            media_raw = media.get("raw_payload") if isinstance(media.get("raw_payload"), dict) else {}
+            failure_reason = (
+                media.get("failure_reason")
+                or media_raw.get("failure_reason")
+                or "no_play_url"
+            )
             return _with_media_update(
-                _with_timing(_fail("no_play_url", query_text=query_text), {"video_fetch": {"skipped": "no_play_url"}}),
-                _media_unavailable_update("no_play_url"),
+                _with_timing(
+                    _fail(str(failure_reason), query_text=query_text),
+                    {"video_fetch": {"skipped": str(failure_reason)}},
+                ),
+                _media_unavailable_update(str(failure_reason)),
             )
         timing_metrics: dict[str, Any] = {}
         data_url = ""
@@ -246,6 +299,7 @@ class GeminiVideoClient:
         for attempt in range(2):
             retry_count = attempt + 1
             attempt_started_at = time.monotonic()
+            response_summary: dict[str, Any] | None = None
             try:
                 response = self.http_post(
                     f"{self.base_url}/chat/completions",
@@ -253,7 +307,9 @@ class GeminiVideoClient:
                     json={"model": self.model, "messages": messages},
                     timeout=self.timeout_seconds,
                 )
+                response_summary = _response_body_summary(response)
                 response.raise_for_status()
+                payload = response.json()
                 request_attempts.append(
                     {
                         "attempt": retry_count,
@@ -266,9 +322,10 @@ class GeminiVideoClient:
                     "total_duration_ms": _elapsed_ms(request_started_at, time.monotonic()),
                     "attempts": request_attempts,
                 }
-                return _with_timing(_parse(response.json(), query_text), timing_metrics)
+                return _with_timing(_parse(payload, query_text), timing_metrics)
             except httpx.HTTPError as exc:
                 failure_type = "gemini_client_timeout" if isinstance(exc, httpx.TimeoutException) else "gemini_http_error"
+                response_summary = response_summary or _response_body_summary(getattr(exc, "response", None))
                 request_attempts.append(
                     {
                         "attempt": retry_count,
@@ -277,6 +334,7 @@ class GeminiVideoClient:
                         "status": "failed",
                         "failure_type": failure_type,
                         "exception_type": type(exc).__name__,
+                        **({"response_body_summary": response_summary} if response_summary else {}),
                     }
                 )
                 timing_metrics["gemini_request"] = {
@@ -290,6 +348,7 @@ class GeminiVideoClient:
                     error_message=str(exc),
                     http_status_code=_http_status(exc),
                     retry_count=retry_count,
+                    response_body_summary=response_summary,
                 )
                 if attempt == 0 and _retryable_http(exc):
                     continue
@@ -302,6 +361,7 @@ class GeminiVideoClient:
                         "status": "failed",
                         "failure_type": "gemini_response_invalid",
                         "exception_type": type(exc).__name__,
+                        **({"response_body_summary": response_summary} if response_summary else {}),
                     }
                 )
                 timing_metrics["gemini_request"] = {
@@ -314,6 +374,7 @@ class GeminiVideoClient:
                     exception_type=type(exc).__name__,
                     error_message=str(exc),
                     retry_count=retry_count,
+                    response_body_summary=response_summary,
                 )
                 if attempt == 0:
                     continue

+ 100 - 34
content_agent/integrations/kuaishou.py

@@ -19,6 +19,7 @@ from content_agent.integrations.crawapi_http import (
     post_crawapi_json,
     score_from_statistics,
 )
+from content_agent.integrations import platform_video_url
 
 SEARCH_RATE_LIMIT_BUCKET = "kuaishou_search"
 DETAIL_RATE_LIMIT_BUCKET = "kuaishou_detail"
@@ -51,16 +52,6 @@ def _extract_tags(item: dict[str, Any]) -> list[str]:
     return list(dict.fromkeys(tags))
 
 
-def _extract_play_url(item: dict[str, Any]) -> str | None:
-    video_list = item.get("video_url_list") or []
-    if not video_list:
-        return None
-    first = video_list[0]
-    if isinstance(first, dict):
-        return first.get("video_url")
-    return str(first)
-
-
 def _publish_time_seconds(item: dict[str, Any]) -> int | None:
     publish_ms = item.get("publish_timestamp")
     if not publish_ms:
@@ -74,18 +65,29 @@ def _normalize_kuaishou_item(
     index: int,
     has_more: bool,
     next_cursor: str,
+    video_url_selection: dict[str, Any] | None = None,
 ) -> dict[str, Any]:
+    video_url_selection = video_url_selection or platform_video_url.select_video_url(
+        "kuaishou",
+        [("search", item)],
+        probe_fn=None,
+    )
     statistics = _statistics(item)
     platform_content_id = str(item.get("channel_content_id") or "")
     platform_author_id = str(item.get("channel_account_id") or "")
-    return {
+    platform_raw_payload = {
+        "channel_content_id": platform_content_id,
+        "channel_account_id": platform_author_id,
+        **dict(video_url_selection.get("platform_raw_payload") or {}),
+    }
+    result = {
         "content_discovery_id": f"{query['search_query_id']}_content_{index:03d}",
         "search_query_id": query["search_query_id"],
         "platform": "kuaishou",
         "platform_content_id": platform_content_id,
         "platform_content_url": item.get("content_link"),
         "platform_content_format": content_format(item.get("content_type") or "video"),
-        "play_url": _extract_play_url(item),
+        "play_url": video_url_selection.get("play_url"),
         "description": item.get("body_text") or item.get("title") or "",
         "platform_author_id": platform_author_id,
         "author_display_name": item.get("channel_account_name") or "",
@@ -102,11 +104,11 @@ def _normalize_kuaishou_item(
         "previous_discovery_step": "search_query_direct",
         "content_metadata_source": "kuaishou_keyword_search",
         "platform_auth_mode": "no_bearer",
-        "platform_raw_payload": {
-            "channel_content_id": platform_content_id,
-            "channel_account_id": platform_author_id,
-        },
+        "platform_raw_payload": platform_raw_payload,
     }
+    if video_url_selection.get("media_failure_reason"):
+        result["media_failure_reason"] = video_url_selection["media_failure_reason"]
+    return result
 
 
 class CrawapiKuaishouClient:
@@ -122,6 +124,7 @@ class CrawapiKuaishouClient:
         max_results_per_query: int | None = 5,
         http_client: Any | None = None,
         rate_limiter: RateLimiter | None = None,
+        video_url_probe_fn: platform_video_url.ProbeFn | None = None,
     ) -> None:
         import httpx
 
@@ -133,6 +136,7 @@ class CrawapiKuaishouClient:
         self.max_results_per_query = max_results_per_query
         self.http_client = http_client or httpx.Client(timeout=timeout_seconds)
         self.rate_limiter = rate_limiter
+        self.video_url_probe_fn = video_url_probe_fn
 
     @classmethod
     def from_env(cls, env_path: str | Path = ".env") -> "CrawapiKuaishouClient":
@@ -185,22 +189,30 @@ class CrawapiKuaishouClient:
         has_more = bool(block.get("has_more", False))
         next_cursor = str(block.get("next_cursor") or "")
         selected = items[:max_results_per_query] if max_results_per_query else items
-        return [
-            _normalize_kuaishou_item(query, item, index, has_more, next_cursor)
-            for index, item in enumerate(selected, start=1)
-        ]
+        results = []
+        for index, item in enumerate(selected, start=1):
+            results.append(
+                _normalize_kuaishou_item(
+                    query,
+                    item,
+                    index,
+                    has_more,
+                    next_cursor,
+                    self._select_video_url_with_detail_fallback(item),
+                )
+            )
+        return results
 
     def fetch_detail(self, content_id: str) -> dict[str, Any]:
-        data = self._post_json(
-            self.detail_path,
-            {"content_id": str(content_id)},
-            operation="detail",
-            rate_limit_bucket=DETAIL_RATE_LIMIT_BUCKET,
-        )
-        block = data.get("data", {}) if isinstance(data.get("data"), dict) else {}
-        detail = block.get("data", block) if isinstance(block, dict) else {}
+        detail = self._fetch_detail_item(content_id)
         statistics = _statistics(detail)
-        return {
+        selection = self._select_video_url([("detail", detail)])
+        platform_raw_payload = {
+            "channel_content_id": str(detail.get("channel_content_id") or content_id),
+            "channel_account_id": str(detail.get("channel_account_id") or ""),
+            **dict(selection.get("platform_raw_payload") or {}),
+        }
+        result = {
             "platform": "kuaishou",
             "platform_content_id": str(detail.get("channel_content_id") or content_id),
             "platform_content_url": detail.get("content_link"),
@@ -210,14 +222,68 @@ class CrawapiKuaishouClient:
             "author_display_name": detail.get("channel_account_name") or "",
             "statistics": statistics,
             "tags": _extract_tags(detail),
-            "play_url": _extract_play_url(detail),
+            "play_url": selection.get("play_url"),
             "create_time": _publish_time_seconds(detail),
             "content_metadata_source": "kuaishou_detail",
-            "platform_raw_payload": {
-                "channel_content_id": str(detail.get("channel_content_id") or content_id),
-                "channel_account_id": str(detail.get("channel_account_id") or ""),
-            },
+            "platform_raw_payload": platform_raw_payload,
         }
+        if selection.get("media_failure_reason"):
+            result["media_failure_reason"] = selection["media_failure_reason"]
+        return result
+
+    def _select_video_url(self, sources: list[tuple[str, dict[str, Any]]]) -> dict[str, Any]:
+        return platform_video_url.select_video_url(
+            "kuaishou",
+            sources,
+            probe_fn=self.video_url_probe_fn or self._probe_video_url,
+        )
+
+    def _select_video_url_with_detail_fallback(self, item: dict[str, Any]) -> dict[str, Any]:
+        search_selection = self._select_video_url([("search", item)])
+        if search_selection.get("play_url"):
+            return search_selection
+        content_id = str(item.get("channel_content_id") or "")
+        if not content_id:
+            return search_selection
+        try:
+            detail = self._fetch_detail_item(content_id)
+        except Exception as exc:  # noqa: BLE001 - keep search diagnostics if detail is unavailable.
+            payload = dict(search_selection.get("platform_raw_payload") or {})
+            payload.update(
+                {
+                    "kuaishou_detail_fallback_attempted": True,
+                    "kuaishou_detail_fallback_status": "failed",
+                    "kuaishou_detail_fallback_exception_type": type(exc).__name__,
+                    "kuaishou_detail_fallback_error": str(exc)[:300],
+                }
+            )
+            return {**search_selection, "platform_raw_payload": payload}
+        detail_selection = self._select_video_url([("search", item), ("detail", detail)])
+        payload = dict(detail_selection.get("platform_raw_payload") or {})
+        payload.update(
+            {
+                "kuaishou_detail_fallback_attempted": True,
+                "kuaishou_detail_fallback_status": "used" if detail_selection.get("play_url") else "no_valid_play_url",
+            }
+        )
+        return {**detail_selection, "platform_raw_payload": payload}
+
+    def _fetch_detail_item(self, content_id: str) -> dict[str, Any]:
+        data = self._post_json(
+            self.detail_path,
+            {"content_id": str(content_id)},
+            operation="detail",
+            rate_limit_bucket=DETAIL_RATE_LIMIT_BUCKET,
+        )
+        block = data.get("data", {}) if isinstance(data.get("data"), dict) else {}
+        return block.get("data", block) if isinstance(block, dict) else {}
+
+    def _probe_video_url(self, url: str, platform: str) -> dict[str, Any]:
+        return platform_video_url.probe_url_with_httpx(
+            url,
+            platform,
+            http_client=self.http_client,
+        )
 
     def fetch_account_info(self, account_id: str, is_cache: bool = True) -> dict[str, Any]:
         data = self._post_json(

+ 6 - 2
content_agent/integrations/oss_archive.py

@@ -7,7 +7,7 @@ from datetime import datetime, timedelta, timezone
 from threading import Lock
 from typing import Any, Callable
 
-from content_agent.integrations import oss_upload, video_fetch
+from content_agent.integrations import oss_upload
 from content_agent.interfaces import RuntimeFileStore
 
 
@@ -238,7 +238,6 @@ def _archive_one(
     attempt_started_at = time.monotonic()
     upload_result = upload_fn(
         record.get("play_url") or "",
-        referer=video_fetch._download_headers(str(record.get("platform") or ""), None),
         timeout_seconds=attempt_timeout_seconds,
     )
     timing_metrics = {
@@ -259,6 +258,7 @@ def _archive_one(
             "oss_object_key": upload_result.get("oss_object_key"),
             "save_oss_timestamp": upload_result.get("save_oss_timestamp"),
             "oss_timing_metrics": timing_metrics,
+            "oss_payload_mode": upload_result.get("oss_payload_mode"),
         }
         return {
             **record,
@@ -283,6 +283,8 @@ def _archive_one(
         "oss_archive_last_exception_type": upload_result.get("exception_type"),
         "oss_archive_last_error_message": upload_result.get("error_message"),
         "oss_archive_last_http_status_code": upload_result.get("http_status_code"),
+        "oss_payload_mode": upload_result.get("oss_payload_mode"),
+        "oss_response_summary": upload_result.get("oss_response_summary"),
         "oss_archive_updated_at": _iso(now),
         "upload_failure_reason": failure,
         "oss_timing_metrics": timing_metrics,
@@ -319,6 +321,8 @@ def _with_failed_archive(
         "oss_archive_last_exception_type": upload_result.get("exception_type"),
         "oss_archive_last_error_message": upload_result.get("error_message"),
         "oss_archive_last_http_status_code": upload_result.get("http_status_code"),
+        "oss_payload_mode": upload_result.get("oss_payload_mode"),
+        "oss_response_summary": upload_result.get("oss_response_summary"),
         "oss_archive_updated_at": _iso(now),
         "upload_failure_reason": failure,
     }

+ 32 - 4
content_agent/integrations/oss_upload.py

@@ -27,10 +27,9 @@ def upload_video_from_url(
         "src_type": "video",
         "use_proxy": use_proxy,
     }
+    payload_mode = "no_referer"
     if project:
         payload["project"] = project
-    if referer:
-        payload["referer"] = dict(referer)
     try:
         response = http_post(endpoint, json=payload, timeout=timeout_seconds)
         response.raise_for_status()
@@ -41,18 +40,29 @@ def upload_video_from_url(
             exception_type=type(exc).__name__,
             error_message=str(exc),
             http_status_code=_http_status(exc),
+            oss_payload_mode=payload_mode,
         )
     except Exception as exc:
-        return _failure("oss_upload_failed", exception_type=type(exc).__name__, error_message=str(exc))
+        return _failure(
+            "oss_upload_failed",
+            exception_type=type(exc).__name__,
+            error_message=str(exc),
+            oss_payload_mode=payload_mode,
+        )
 
     oss_object = body.get("oss_object") if isinstance(body, dict) else None
     if not isinstance(oss_object, dict) or not oss_object.get("cdn_url"):
-        return _failure("oss_upload_response_invalid")
+        return _failure(
+            "oss_upload_response_invalid",
+            oss_payload_mode=payload_mode,
+            oss_response_summary=_response_summary(body),
+        )
     return {
         "status": "ok",
         "oss_url": oss_object.get("cdn_url"),
         "oss_object_key": oss_object.get("oss_object_key"),
         "save_oss_timestamp": oss_object.get("save_oss_timestamp"),
+        "oss_payload_mode": payload_mode,
         "raw_payload": body,
     }
 
@@ -85,6 +95,8 @@ def _failure(
     exception_type: str | None = None,
     error_message: str | None = None,
     http_status_code: int | None = None,
+    oss_payload_mode: str | None = None,
+    oss_response_summary: dict[str, Any] | None = None,
 ) -> dict[str, Any]:
     result: dict[str, Any] = {"status": "failed", "failure_type": failure_type}
     if exception_type:
@@ -93,9 +105,25 @@ def _failure(
         result["error_message"] = error_message
     if http_status_code is not None:
         result["http_status_code"] = http_status_code
+    if oss_payload_mode:
+        result["oss_payload_mode"] = oss_payload_mode
+    if oss_response_summary:
+        result["oss_response_summary"] = oss_response_summary
     return result
 
 
+def _response_summary(body: Any) -> dict[str, Any]:
+    if not isinstance(body, dict):
+        return {"body_type": type(body).__name__}
+    oss_object = body.get("oss_object")
+    return {
+        "status": body.get("status"),
+        "msg": body.get("msg"),
+        "oss_object_present": isinstance(oss_object, dict),
+        "oss_object_has_cdn_url": isinstance(oss_object, dict) and bool(oss_object.get("cdn_url")),
+    }
+
+
 def _http_status(exc: httpx.HTTPError) -> int | None:
     response = getattr(exc, "response", None)
     status_code = getattr(response, "status_code", None)

+ 242 - 0
content_agent/integrations/platform_video_url.py

@@ -0,0 +1,242 @@
+"""Platform video URL candidate selection.
+
+Keeps full temporary URLs out of runtime diagnostics while preserving enough
+field-path/probe evidence to debug no-play-url cases.
+"""
+
+from __future__ import annotations
+
+import re
+from collections import Counter
+from dataclasses import dataclass
+from typing import Any, Callable
+from urllib.parse import urlparse
+
+ProbeFn = Callable[[str, str], dict[str, Any]]
+
+VIDEO_HINT_RE = re.compile(r"(video|play|mp4|m3u8|media|download|src)", re.I)
+IMAGE_HINT_RE = re.compile(r"(image|cover|poster|thumbnail|thumb|heif|jpg|jpeg|png|kvif)", re.I)
+AVATAR_HINT_RE = re.compile(r"(avatar|qlogo|uhead)", re.I)
+AUDIO_HINT_RE = re.compile(r"(audio|bgm|music|m4a|mp3)", re.I)
+PAGE_HINT_RE = re.compile(r"(content_link|share_url|page_url|account_link|link)$", re.I)
+AD_HINT_RE = re.compile(
+    r"(^|[._/\-\[\]&?=#])(?:ad|ads|advert|commercial|promotion|material|marketing|营销|广告|推广)($|[._/\-\[\]&?=#])",
+    re.I,
+)
+VIDEO_EXT_RE = re.compile(r"\.(mp4|m3u8)(?:$|[?&#])", re.I)
+
+
+@dataclass
+class VideoUrlCandidate:
+    path: str
+    url: str
+    host: str
+    kind: str
+    reject_reason: str = ""
+    http_status: int | None = None
+    content_type: str = ""
+    content_range: str = ""
+    range_bytes: int = 0
+    looks_like_video: bool = False
+    probe_status: str = "not_probed"
+
+
+def select_video_url(
+    platform: str,
+    sources: list[tuple[str, dict[str, Any]]],
+    *,
+    probe_fn: ProbeFn | None = None,
+) -> dict[str, Any]:
+    candidates = _unique_candidates(
+        candidate
+        for source_name, payload in sources
+        for candidate in find_url_candidates(payload, prefix=f"$.{source_name}")
+    )
+    for candidate in candidates:
+        if candidate.kind != "video":
+            continue
+        if probe_fn is None:
+            continue
+        try:
+            probe = probe_fn(candidate.url, platform)
+        except Exception as exc:  # noqa: BLE001 - diagnostics should survive upstream quirks.
+            probe = {"probe_status": "failed", "reject_reason": f"probe_failed:{type(exc).__name__}"}
+        _apply_probe(candidate, probe)
+
+    selected = _select_best(candidates, platform)
+    result = {
+        "play_url": selected.url if selected else None,
+        "media_failure_reason": None if selected else "no_valid_play_url",
+        "platform_raw_payload": build_diagnostic_payload(candidates, selected),
+    }
+    if result["media_failure_reason"]:
+        result["platform_raw_payload"]["no_valid_play_url"] = True
+    return result
+
+
+def find_url_candidates(value: Any, *, prefix: str = "$") -> list[VideoUrlCandidate]:
+    candidates: list[VideoUrlCandidate] = []
+    if isinstance(value, dict):
+        for key, item in value.items():
+            candidates.extend(find_url_candidates(item, prefix=f"{prefix}.{key}"))
+    elif isinstance(value, list):
+        for index, item in enumerate(value):
+            candidates.extend(find_url_candidates(item, prefix=f"{prefix}[{index}]"))
+    elif isinstance(value, str) and value.startswith(("http://", "https://")):
+        kind, reject = classify_url_candidate(prefix, value)
+        candidates.append(
+            VideoUrlCandidate(
+                path=prefix,
+                url=value,
+                host=urlparse(value).netloc,
+                kind=kind,
+                reject_reason=reject,
+            )
+        )
+    return candidates
+
+
+def classify_url_candidate(path: str, url: str) -> tuple[str, str]:
+    haystack = f"{path} {url}".lower()
+    if AD_HINT_RE.search(haystack):
+        return "ad_or_material", "ad_or_material_path"
+    if AUDIO_HINT_RE.search(haystack):
+        return "audio", "audio_or_bgm_path"
+    if AVATAR_HINT_RE.search(haystack):
+        return "avatar", "avatar_path"
+    if IMAGE_HINT_RE.search(haystack):
+        return "image", "image_path"
+    if PAGE_HINT_RE.search(path):
+        return "page", "page_link_path"
+    if VIDEO_EXT_RE.search(url) or VIDEO_HINT_RE.search(path):
+        return "video", ""
+    return "unknown", "weak_video_signal"
+
+
+def probe_url_with_httpx(
+    url: str,
+    platform: str,
+    *,
+    http_client: Any,
+    bytes_to_probe: int = 1024 * 1024,
+    timeout_seconds: float = 30.0,
+) -> dict[str, Any]:
+    get = getattr(http_client, "get", None)
+    if not callable(get):
+        return {"probe_status": "failed", "reject_reason": "probe_client_missing_get"}
+    headers = _download_headers(platform)
+    headers["Range"] = f"bytes=0-{max(bytes_to_probe - 1, 0)}"
+    response = get(url, headers=headers, follow_redirects=True, timeout=timeout_seconds)
+    content = response.content or b""
+    content_type = response.headers.get("content-type", "")
+    return {
+        "http_status": response.status_code,
+        "content_type": content_type,
+        "content_range": response.headers.get("content-range", ""),
+        "range_bytes": len(content),
+        "looks_like_video": _looks_like_video(content, content_type),
+        "probe_status": "verified"
+        if response.status_code in {200, 206} and _looks_like_video(content, content_type)
+        else "failed",
+    }
+
+
+def build_diagnostic_payload(
+    candidates: list[VideoUrlCandidate],
+    selected: VideoUrlCandidate | None,
+) -> dict[str, Any]:
+    reject_reasons = Counter(
+        candidate.reject_reason or candidate.probe_status
+        for candidate in candidates
+        if candidate.kind != "video" or candidate.probe_status == "failed" or candidate.reject_reason
+    )
+    payload: dict[str, Any] = {
+        "selected_video_url_path": selected.path if selected else None,
+        "selected_video_url_host": selected.host if selected else None,
+        "selected_video_url_probe_status": selected.probe_status if selected else None,
+        "selected_video_url_content_type": selected.content_type if selected else None,
+        "selected_video_url_content_range": selected.content_range if selected else None,
+        "video_url_candidate_counts": dict(Counter(candidate.kind for candidate in candidates)),
+        "video_url_reject_reasons": dict(reject_reasons),
+    }
+    return {key: value for key, value in payload.items() if value not in (None, "", {}, [])}
+
+
+def _select_best(candidates: list[VideoUrlCandidate], platform: str) -> VideoUrlCandidate | None:
+    video_candidates = [candidate for candidate in candidates if candidate.kind == "video"]
+    verified = [
+        candidate
+        for candidate in video_candidates
+        if candidate.probe_status == "verified"
+        and candidate.http_status in {200, 206}
+        and (candidate.looks_like_video or "video" in candidate.content_type.lower())
+    ]
+    if verified:
+        return sorted(verified, key=lambda candidate: _selection_score(candidate, platform, fallback=False))[0]
+    if not video_candidates:
+        return None
+    selected = sorted(video_candidates, key=lambda candidate: _selection_score(candidate, platform, fallback=True))[0]
+    selected.probe_status = "failed_fallback" if selected.probe_status != "not_probed" else "not_probed_fallback"
+    return selected
+
+
+def _selection_score(candidate: VideoUrlCandidate, platform: str, *, fallback: bool) -> tuple[Any, ...]:
+    detail_bonus = 0 if platform == "kuaishou" and ".detail." in candidate.path else 1
+    preferred_host = 0
+    if platform == "shipinhao":
+        preferred_host = 0 if "findermp.video.qq.com" in candidate.host else 1
+    elif platform == "kuaishou":
+        preferred_host = 0 if ("kwai" in candidate.host or "kwimgs" in candidate.host) else 1
+    return (
+        detail_bonus,
+        preferred_host,
+        fallback and candidate.probe_status == "failed",
+        candidate.http_status not in {200, 206},
+        not candidate.looks_like_video,
+        candidate.path.count("."),
+        candidate.path,
+    )
+
+
+def _apply_probe(candidate: VideoUrlCandidate, probe: dict[str, Any]) -> None:
+    candidate.http_status = _int_or_none(probe.get("http_status"))
+    candidate.content_type = str(probe.get("content_type") or "")
+    candidate.content_range = str(probe.get("content_range") or "")
+    candidate.range_bytes = int(probe.get("range_bytes") or 0)
+    candidate.looks_like_video = bool(probe.get("looks_like_video"))
+    candidate.probe_status = str(probe.get("probe_status") or "failed")
+
+
+def _unique_candidates(candidates: Any) -> list[VideoUrlCandidate]:
+    seen: set[str] = set()
+    result: list[VideoUrlCandidate] = []
+    for candidate in candidates:
+        if candidate.url in seen:
+            continue
+        seen.add(candidate.url)
+        result.append(candidate)
+    return result
+
+
+def _download_headers(platform: str) -> dict[str, str]:
+    headers = {
+        "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/126 Safari/537.36",
+        "Accept": "*/*",
+    }
+    if platform == "shipinhao":
+        headers["Referer"] = "https://channels.weixin.qq.com/"
+    elif platform == "kuaishou":
+        headers["Referer"] = "https://www.kuaishou.com/"
+    return headers
+
+
+def _looks_like_video(content: bytes, content_type: str) -> bool:
+    head = content[:512]
+    return "video" in content_type.lower() or b"ftyp" in head or b"moov" in head or b"mdat" in head
+
+
+def _int_or_none(value: Any) -> int | None:
+    try:
+        return int(value)
+    except (TypeError, ValueError):
+        return None

+ 114 - 21
content_agent/integrations/shipinhao.py

@@ -24,6 +24,7 @@ from content_agent.integrations.crawapi_http import (
     post_crawapi_json,
     score_from_statistics,
 )
+from content_agent.integrations import platform_video_url
 
 SEARCH_RATE_LIMIT_BUCKET = "shipinhao_search"
 TRANSIENT_BUSINESS_CODES = {"25011"}
@@ -52,7 +53,13 @@ def _normalize_shipinhao_item(
     index: int,
     has_more: bool,
     next_cursor: str,
+    video_url_selection: dict[str, Any] | None = None,
 ) -> dict[str, Any]:
+    video_url_selection = video_url_selection or platform_video_url.select_video_url(
+        "shipinhao",
+        [("search", item)],
+        probe_fn=None,
+    )
     title = item.get("title") or ""
     statistics = {
         "digg_count": int(item.get("like_count") or 0),
@@ -65,18 +72,21 @@ def _normalize_shipinhao_item(
     tags = [t if str(t).startswith("#") else f"#{t}" for t in topic_list if t]
     if not tags:
         tags = [f"#{m}" for m in _TAG_RE.findall(title)]
-    video_list = item.get("video_url_list") or []
-    play_url = video_list[0].get("video_url") if video_list else None
     platform_content_id = str(item.get("channel_content_id") or "")
     platform_author_id = str(item.get("channel_account_id") or "")
     publish_ms = item.get("publish_timestamp")
-    return {
+    platform_raw_payload = {
+        "channel_content_id": platform_content_id,
+        "channel_account_id": platform_author_id,
+        **dict(video_url_selection.get("platform_raw_payload") or {}),
+    }
+    result = {
         "content_discovery_id": f"{query['search_query_id']}_content_{index:03d}",
         "search_query_id": query["search_query_id"],
         "platform": "shipinhao",
         "platform_content_id": platform_content_id,
         "platform_content_format": content_format(item.get("content_type") or "video"),
-        "play_url": play_url,
+        "play_url": video_url_selection.get("play_url"),
         "description": title,
         "platform_author_id": platform_author_id,
         "author_display_name": item.get("channel_account_name") or "",
@@ -93,11 +103,18 @@ def _normalize_shipinhao_item(
         "previous_discovery_step": "search_query_direct",
         "content_metadata_source": "shipinhao_keyword_search",
         "platform_auth_mode": "no_bearer",
-        "platform_raw_payload": {
-            "channel_content_id": platform_content_id,
-            "channel_account_id": platform_author_id,
-        },
+        "platform_raw_payload": platform_raw_payload,
     }
+    if video_url_selection.get("media_failure_reason"):
+        result["media_failure_reason"] = video_url_selection["media_failure_reason"]
+    return result
+
+
+def _research_keyword(item: dict[str, Any], query: dict[str, Any]) -> str:
+    title = " ".join(str(item.get("title") or "").split())
+    if title:
+        return title[:80]
+    return str(query.get("search_query") or "")[:80]
 
 
 class CrawapiShipinhaoClient:
@@ -114,6 +131,7 @@ class CrawapiShipinhaoClient:
         http_client: Any | None = None,
         rate_limiter: RateLimiter | None = None,
         sleep_fn: Callable[[float], None] = time.sleep,
+        video_url_probe_fn: platform_video_url.ProbeFn | None = None,
     ) -> None:
         import httpx
 
@@ -126,6 +144,7 @@ class CrawapiShipinhaoClient:
         self.http_client = http_client or httpx.Client(timeout=timeout_seconds)
         self.rate_limiter = rate_limiter
         self.sleep_fn = sleep_fn
+        self.video_url_probe_fn = video_url_probe_fn
 
     @classmethod
     def from_env(cls, env_path: str | Path = ".env") -> "CrawapiShipinhaoClient":
@@ -152,11 +171,29 @@ class CrawapiShipinhaoClient:
         query: dict[str, Any],
         max_results_per_query: int | None,
     ) -> list[dict[str, Any]]:
-        payload = {
-            "keyword": query["search_query"],
-            "cursor": str(query.get("page_cursor") or ""),
-        }
+        data = self._keyword_search(
+            {"keyword": query["search_query"], "cursor": str(query.get("page_cursor") or "")}
+        )
+        block = data.get("data", {}) if isinstance(data.get("data"), dict) else {}
+        items = block.get("data", []) if isinstance(block.get("data"), list) else []
+        has_more = bool(block.get("has_more", False))
+        next_cursor = str(block.get("next_cursor") or "")
+        selected = items[:max_results_per_query] if max_results_per_query else items
+        results = []
+        for index, item in enumerate(selected, start=1):
+            results.append(
+                _normalize_shipinhao_item(
+                    query,
+                    item,
+                    index,
+                    has_more,
+                    next_cursor,
+                    self._select_video_url_with_title_research(item, query),
+                )
+            )
+        return results
 
+    def _keyword_search(self, payload: dict[str, Any]) -> dict[str, Any]:
         def _call() -> dict[str, Any]:
             return post_crawapi_json(
                 http_client=self.http_client,
@@ -172,7 +209,7 @@ class CrawapiShipinhaoClient:
             )
 
         try:
-            data = _retry_transient(
+            return _retry_transient(
                 _call,
                 attempts=self.max_attempts,
                 backoff_seconds=self.backoff_seconds,
@@ -185,15 +222,71 @@ class CrawapiShipinhaoClient:
                 {"operation": "keyword_search", "max_attempts": self.max_attempts},
             ) from exc
 
+    def _select_video_url(self, sources: list[tuple[str, dict[str, Any]]]) -> dict[str, Any]:
+        return platform_video_url.select_video_url(
+            "shipinhao",
+            sources,
+            probe_fn=self.video_url_probe_fn or self._probe_video_url,
+        )
+
+    def _select_video_url_with_title_research(
+        self,
+        item: dict[str, Any],
+        query: dict[str, Any],
+    ) -> dict[str, Any]:
+        search_selection = self._select_video_url([("search", item)])
+        if search_selection.get("play_url"):
+            return search_selection
+        content_id = str(item.get("channel_content_id") or "")
+        title_keyword = _research_keyword(item, query)
+        if not content_id or not title_keyword:
+            return search_selection
+        try:
+            data = self._keyword_search({"keyword": title_keyword, "cursor": ""})
+        except Exception as exc:  # noqa: BLE001 - retain original no-valid diagnostics.
+            payload = dict(search_selection.get("platform_raw_payload") or {})
+            payload.update(
+                {
+                    "shipinhao_research_source": "title",
+                    "shipinhao_research_status": "failed",
+                    "shipinhao_research_exception_type": type(exc).__name__,
+                    "shipinhao_research_error": str(exc)[:300],
+                }
+            )
+            return {**search_selection, "platform_raw_payload": payload}
         block = data.get("data", {}) if isinstance(data.get("data"), dict) else {}
-        items = block.get("data", []) if isinstance(block.get("data"), list) else []
-        has_more = bool(block.get("has_more", False))
-        next_cursor = str(block.get("next_cursor") or "")
-        selected = items[:max_results_per_query] if max_results_per_query else items
-        return [
-            _normalize_shipinhao_item(query, item, index, has_more, next_cursor)
-            for index, item in enumerate(selected, start=1)
-        ]
+        rows = block.get("data", []) if isinstance(block.get("data"), list) else []
+        matched = next(
+            (row for row in rows if str(row.get("channel_content_id") or "") == content_id),
+            None,
+        )
+        if not matched:
+            payload = dict(search_selection.get("platform_raw_payload") or {})
+            payload.update(
+                {
+                    "shipinhao_research_source": "title",
+                    "shipinhao_research_status": "not_matched",
+                    "shipinhao_research_item_count": len(rows),
+                }
+            )
+            return {**search_selection, "platform_raw_payload": payload}
+        selection = self._select_video_url([("search", item), ("research", matched)])
+        payload = dict(selection.get("platform_raw_payload") or {})
+        payload.update(
+            {
+                "shipinhao_research_source": "title",
+                "shipinhao_research_status": "used" if selection.get("play_url") else "no_valid_play_url",
+                "shipinhao_research_item_count": len(rows),
+            }
+        )
+        return {**selection, "platform_raw_payload": payload}
+
+    def _probe_video_url(self, url: str, platform: str) -> dict[str, Any]:
+        return platform_video_url.probe_url_with_httpx(
+            url,
+            platform,
+            http_client=self.http_client,
+        )
 
     def fetch_author_works(self, query: dict[str, Any]) -> list[dict[str, Any]]:
         # 上游 blogger 接口 blocked(code=25011),不发请求、不抛,游走自然退化。

+ 7 - 0
tech_documents/数据接口与来源/00_数据接口总览.md

@@ -742,6 +742,13 @@ Platform 阶段回答:同一个 Query 在平台上用什么动作执行,返
 
 本节记录 2026-06-11 对 `crawler.aiddit.com` 10 个平台接口的真实抓包实测。机器可读台账见 [crawler_endpoints.registry.json](crawler_endpoints.registry.json)(接口级,共 26 条 endpoint),原始 capture 在 `/tmp/aiddit_captures/`。所有接口共用 `CONTENTFIND_API_CRAWAPI_BASE_URL`,限流间隔 ≥15s。
 
+2026-06-17 追加快手/视频号 URL 解包与 OSS v2 批量验证:完整 raw 和临时 URL 只保存在 gitignore 下的 `data/platform_url_probe/20260617_115219/`,提交文档只保留脱敏摘要。
+
+- 快手:10 个 query,60 条 item,50 条 selected 正片 URL;selected 来源为 detail/search 各 25 条。20 条 selected URL 轻量验证均为 `video/mp4` 且 HTTP 200/206;no-referer 调 OSS v2 后 20/20 成功。
+- 视频号:10 个 query,18 页,46 条 item,41 条 selected 正片 URL;selected 全部来自 `$.search.video_url_list[0].video_url`,host 为 `findermp.video.qq.com`。20 条 selected URL 轻量验证均为 `video/mp4` 且 HTTP 200/206;no-referer 调 OSS v2 后 20/20 成功。
+- 两个平台使用生产当前 payload(把 `referer` dict 传给 OSS v2)时均 20/20 `oss_upload_response_invalid`;抽样 body 显示 `status=10000`、`oss_object=null`,错误包含 `dict object has no attribute encode`。去掉 `referer` 字段后同一批 selected URL 成功转存,因此历史 invalid 更像 OSS v2 payload 兼容问题,不是 URL 字段拆错。
+- URL 解包规则必须先做候选分类和轻量验证,不能再把 `video_url_list[0]` 当唯一事实;快手推荐 detail 正片优先、search fallback,视频号推荐 findermp 正片优先,同时过滤 image/avatar/BGM/page/ad/material。
+
 状态标签:✅=本批实测 code=0 可用;⛔=失败/超时/不可达(附错误码);—=本平台无该能力或本批未抓到独立接口。
 
 ### 9.1 平台能力矩阵

+ 128 - 0
tech_documents/数据接口与来源/快手视频号_URL解包与OSS验证报告_20260617.md

@@ -0,0 +1,128 @@
+# 快手/视频号 URL 解包与 OSS 验证报告(2026-06-17)
+
+## 1. 目标与边界
+
+本轮目标是先把快手/视频号搜索与详情回包里的 URL 结构解析清楚,再用 OSS v2 批量验证“推荐规则选中的正片 URL 是否真的可归档”。本轮不修改生产 `play_url` 抽取逻辑,只做事实核验、交叉验证、诊断工具和文档更新。
+
+完整原始响应、临时 URL、OSS 原始响应只保存在 gitignore 下的 `data/platform_url_probe/20260617_115219/`。本文只记录脱敏摘要、字段路径、host、HTTP 状态、Range 状态和统计。
+
+## 2. Sub-agent 交叉结论
+
+### 需求审计
+
+- 本轮先结构解析,再对 selected URL 做 OSS v2 批量验证。
+- 历史 run 固定为快手 `v1_run_94448252f5cd`、视频号 `v1_run_35f4f7ccd1cc`。
+- 视频号也要扫描广告/素材 MP4,不能默认只有快手有广告素材。
+- 本轮不修改生产 `play_url` 抽取逻辑,不提交完整临时 URL。
+
+### 代码审计
+
+- 快手当前生产抽取:`content_agent/integrations/kuaishou.py` 的 `_extract_play_url()` 取 `video_url_list[0].video_url`。
+- 视频号当前生产抽取:`content_agent/integrations/shipinhao.py` 取 `video_url_list[0].video_url`。
+- builder 只把 client 归一化后的 `play_url` 写入 media record。
+- 现有 `platform_raw_payload` 只保留 `channel_content_id/channel_account_id`,不能完整反查原始字段路径。
+- OSS 当前会把 `play_url` 传给 `oss_upload.upload_video_from_env()`;Gemini 优先用 `oss_url`,否则 fallback 到 `play_url`。
+
+### 历史 run 审计
+
+- 快手 `TECHNICAL_RETRY_REQUIRED=14`:8 条 `no_play_url`,6 条 Gemini/OpenRouter 错误;技术重试直接原因不是 OSS。
+- 视频号 `TECHNICAL_RETRY_REQUIRED=6`:决策层直接原因是 Gemini/OpenRouter;但 30 条 media 全部为 `oss_upload_pending + oss_upload_response_invalid`。
+- 两个历史 run 都不能从 runtime 反查 selected URL 的原始字段路径。
+
+### 文档/台账审计
+
+- 旧台账说明快手/视频号直链可下载,但没有把“直链可下载”“Gemini 可下载”“OSS 可转存”拆开记录。
+- 快手文档建议下载带 Range/Referer/UA,但当前 `video_fetch` 快手默认头与文档不完全一致。
+- 视频号直链需 `Referer: https://channels.weixin.qq.com/` 用于本机下载;OSS v2 是否接受 headers/referer 需要单独验证。
+
+## 3. 历史 run 对照
+
+### 快手 `v1_run_94448252f5cd`
+
+- 决策统计:`ADD_TO_CONTENT_POOL=8`、`KEEP_CONTENT_FOR_REVIEW=9`、`REJECT_CONTENT=6`、`TECHNICAL_RETRY_REQUIRED=14`。
+- 媒体状态:`oss_uploaded=29`、`unavailable=8`。
+- OSS/媒体错误:`oss_upload_http_error=1`、`no_play_url=8`。
+- play_url host:`cc1.xyydnode.com=7`、`v5.oskwai.com=5`、`v4.kwaicdn.com=8`、`v23-3.kwaicdn.com=9`。
+- 字段路径证据不足:历史 runtime 无完整上游 raw item。
+
+### 视频号 `v1_run_35f4f7ccd1cc`
+
+- 决策统计:`ADD_TO_CONTENT_POOL=10`、`KEEP_CONTENT_FOR_REVIEW=5`、`REJECT_CONTENT=9`、`TECHNICAL_RETRY_REQUIRED=6`。
+- 媒体状态:`oss_upload_pending=30`。
+- OSS/媒体错误:`oss_upload_response_invalid=30`。
+- play_url host:`findermp.video.qq.com=30`。
+- 字段路径证据不足:历史 runtime 无完整上游 raw item。
+
+## 4. 真实接口结构探测
+
+### 快手
+
+- 真实搜索 query:10 个。
+- 搜索 item 样本:60 条。
+- URL 候选统计:`page=89`、`image=80`、`video_candidate=75`、`audio=1`。
+- selected URL:50 条。
+- selected 路径:`$.detail.video_url_list[0].video_url=25`、`$.search.video_url_list[0].video_url=25`。
+- selected host:`tymov2.a.kwimgs.com=17`、`hwmov.a.yximgs.com=7`、`v23-3.kwaicdn.com=5`、`v2.oskwai.com=6`、`ucmov.a.kwimgs.com=5`、`dxsc.oppass.cn=4`、`v4.kwaicdn.com=3`、`cc1.xyydnode.com=2`、`v1.oskwai.com=1`。
+
+### 视频号
+
+- 真实搜索 query:10 个。
+- 搜索页数:18 页。
+- 搜索 item 样本:46 条。
+- URL 候选统计:`image=95`、`video_candidate=41`、`audio=2`。
+- selected URL:41 条。
+- selected 路径:`$.search.video_url_list[0].video_url=41`。
+- selected host:`findermp.video.qq.com=41`。
+- 本轮保留 25011 retry:例如 `怀旧经典歌曲` page1 前两次 25011,第三次成功;`怀旧金曲` 重试后仍 25011,最终 item_count=0。
+
+## 5. OSS v2 批量验证
+
+### 第一组:生产当前 payload 形态
+
+生产当前链路会把 `referer` 作为 dict 字段传给 OSS v2。
+
+- 快手:20 条 selected URL 均 HTTP 200,但没有 `oss_object.cdn_url`,分类为 `oss_upload_response_invalid`。
+- 视频号:20 条 selected URL 均 HTTP 200,但没有 `oss_object.cdn_url`,分类为 `oss_upload_response_invalid`。
+- 抽样复打快手 selected URL 时,OSS body 为 `status=10000`、`oss_object=null`,错误包含 `dict object has no attribute encode`。
+
+### 第二组:no-referer payload 复验
+
+同一批 raw 样本重新抽 selected URL,去掉 `referer` 字段后调用 OSS v2,并发 2。
+
+- 快手:20/20 成功返回 `oss_object.cdn_url`。耗时约 0.891s 到 119.923s。
+- 视频号:20/20 成功返回 `oss_object.cdn_url`。耗时约 1.078s 到 19.248s。
+
+结论:本轮推荐规则选出的 selected URL 能被 OSS v2 成功归档。历史/生产里的 `oss_upload_response_invalid` 更像 OSS v2 payload 兼容问题,不是 URL 字段拆错。
+
+## 6. 推荐解包规则
+
+### 快手
+
+- 优先 detail 正片 URL:`$.detail.video_url_list[].video_url`。
+- detail 不可用时 fallback search 正片 URL:`$.search.video_url_list[].video_url`。
+- 不默认 `video_url_list[0]` 永远正确;多个候选按路径、HTTP 200/206、`content_type=video/mp4`、Range、duration、host 稳定性排序。
+- 过滤 `content_link`、`image_url_list`、头像、`bgm_data.play_url`、广告/素材字段。
+
+### 视频号
+
+- 优先 `$.search.video_url_list[].video_url` 中的 findermp 正片 URL。
+- 同样过滤封面、头像、音频/BGM、页面链接、广告/素材字段。
+- 广告关键词匹配不能用裸 `ad` 子串,否则会误伤 `stodownload/head` 这类正常 URL。
+- 本机下载/轻量验证继续带 `Referer: https://channels.weixin.qq.com/`;OSS v2 转存 payload 不应直接传 dict 型 `referer`。
+
+## 7. 生产改造建议
+
+1. 先修 OSS v2 payload:不要把 `referer` dict 直接传给 OSS v2。若上传服务需要 headers,需约定可编码字段格式,例如字符串 referer 或 `headers` object。
+2. 再补 runtime raw payload:记录 `selected_path`、`selected_host`、`probe_status`、`content_type`、`content_range`、`oss_payload_mode`、`oss_failure_type`。
+3. 最后改平台 adapter:快手 detail 优先、search fallback;视频号 findermp 正片优先;统一做 URL 候选分类过滤。
+
+## 8. 验证命令
+
+```bash
+.venv/bin/python -m pytest tests/test_platform_video_url_candidates.py -q
+CONTENT_AGENT_OSS_UPLOAD_URL=http://crawler-upload-v2.aiddit.com/crawler/oss/upload_stream \
+  .venv/bin/python tests/test_platform_video_url_candidates.py \
+  --real-probe --probe-oss --items-per-query 6 \
+  --kuaishou-detail-limit-per-query 3 \
+  --oss-limit-per-platform 20 --oss-timeout-seconds 180
+```

+ 49 - 0
tech_documents/数据接口与来源/接口台账/快手.md

@@ -8,6 +8,55 @@
 
 ---
 
+## 2026-06-17 URL 解包与 OSS v2 批量验证
+
+本节来自 V4 诊断脚本 `tests/test_platform_video_url_candidates.py` 的真实探测。完整原始响应和临时 URL 仅保存在 gitignore 下的 `data/platform_url_probe/20260617_115219/`,本文只记录脱敏统计、字段路径、host 和状态。
+
+### 探测规模
+
+- 真实搜索 query:10 个。
+- 搜索 item 样本:60 条。
+- 抽取 URL 候选:`page=89`、`image=80`、`video_candidate=75`、`audio=1`。
+- 推荐规则选中正片 URL:50 条。
+- 其中 20 条 selected URL 做 OSS v2 批量验证。
+- 历史对照 run:`v1_run_94448252f5cd`。
+
+### 历史 run 对照
+
+- 历史 run 决策:`ADD_TO_CONTENT_POOL=8`、`KEEP_CONTENT_FOR_REVIEW=9`、`REJECT_CONTENT=6`、`TECHNICAL_RETRY_REQUIRED=14`。
+- 媒体状态:`oss_uploaded=29`、`unavailable=8`。
+- 失败侧:8 条 `no_play_url` 没有 `play_url/oss_url`;1 条 media 侧出现 `oss_upload_http_error`,但技术重试直接原因仍是 Gemini timeout。
+- 历史 runtime 不能反查原始字段路径:`platform_raw_payload` 只保留 `channel_content_id/channel_account_id`,没有完整 `video_url_list/image_url_list/content_link/bgm_data`。
+
+### 推荐 URL 解包规则
+
+- 递归抽取 search/detail 中所有 URL,不再把 `video_url_list[0]` 当作唯一事实。
+- 快手优先 detail 中的正片 URL:`$.detail.video_url_list[].video_url`。
+- detail 不存在或不可用时,fallback 到 search 正片 URL:`$.search.video_url_list[].video_url`。
+- 明确排除:`content_link` 页面链接、`image_url_list` 封面/图片、`avatar` 头像、`bgm_data.play_url` 音频、广告/素材字段。
+- 多候选排序看字段路径、HTTP 200/206、`content_type=video/mp4`、Range 支持、host 稳定性和 duration;不默认取第一个。
+
+### 本轮真实字段与 host
+
+- selected 字段路径:`$.detail.video_url_list[0].video_url=25`,`$.search.video_url_list[0].video_url=25`。
+- selected host 分布:`tymov2.a.kwimgs.com=17`、`hwmov.a.yximgs.com=7`、`v23-3.kwaicdn.com=5`、`v2.oskwai.com=6`、`ucmov.a.kwimgs.com=5`、`dxsc.oppass.cn=4`、`v4.kwaicdn.com=3`、`cc1.xyydnode.com=2`、`v1.oskwai.com=1`。
+- 所有 selected 样本轻量验证均为可下载视频:HTTP `200/206`,`content_type=video/mp4`,Range 返回字节范围或完整小文件。
+
+### OSS v2 验证结论
+
+- 使用生产当前 payload 形态(把 `referer` 作为 dict 字段传给 OSS v2)时:20/20 返回 HTTP 200 但 `oss_object.cdn_url` 缺失,分类为 `oss_upload_response_invalid`。
+- 抽样复打发现 OSS body 为 `status=10000`、`oss_object=null`,错误原因包含 `dict object has no attribute encode`。
+- 使用同一批 selected URL、去掉 `referer` 字段后做 no-referer 复验:20/20 成功返回 `oss_object.cdn_url`。
+- 结论:本轮 selected URL 规则能选出可被 OSS v2 归档的正片 URL;历史/生产里的 invalid 更像 OSS v2 payload 兼容问题,不是 URL 字段拆错。
+
+### 生产改造建议
+
+- 第一优先级:调整 OSS v2 调用 payload,不再把 `referer` dict 直接传给 v2;如 OSS 侧确需 headers,应与上传服务约定字符串/headers 字段格式后再传。
+- 第二优先级:生产 runtime 里补充 URL 候选摘要:`selected_path`、`selected_host`、`probe_status`、`content_type`、`content_range`、`oss_payload_mode`、`oss_failure_type`。
+- 第三优先级:后续再改平台 adapter 的 `play_url` 抽取逻辑,改为 detail 优先、候选分类过滤、search fallback。
+
+---
+
 ## 一、关键词搜索 PLT_KUAISHOU_KEYWORD
 
 ### ① 身份

+ 58 - 0
tech_documents/数据接口与来源/接口台账/视频号.md

@@ -9,6 +9,64 @@
 
 ---
 
+## 2026-06-17 URL 解包与 OSS v2 批量验证
+
+本节来自 V4 诊断脚本 `tests/test_platform_video_url_candidates.py` 的真实探测。完整原始响应和临时 URL 仅保存在 gitignore 下的 `data/platform_url_probe/20260617_115219/`,本文只记录脱敏统计、字段路径、host 和状态。
+
+### 探测规模
+
+- 真实搜索 query:10 个。
+- 搜索请求页数:18 页(page1 + cursor page2,部分 query 无结果或无 page2)。
+- 搜索 item 样本:46 条。
+- 抽取 URL 候选:`image=95`、`video_candidate=41`、`audio=2`。
+- 推荐规则选中正片 URL:41 条。
+- 其中 20 条 selected URL 做 OSS v2 批量验证。
+- 历史对照 run:`v1_run_35f4f7ccd1cc`。
+
+### 历史 run 对照
+
+- 历史 run 决策:`ADD_TO_CONTENT_POOL=10`、`KEEP_CONTENT_FOR_REVIEW=5`、`REJECT_CONTENT=9`、`TECHNICAL_RETRY_REQUIRED=6`。
+- 媒体状态:30 条全部为 `oss_upload_pending`,失败原因全部为 `oss_upload_response_invalid`。
+- 6 条技术重试的决策直接原因均在 Gemini/OpenRouter 链路;但这 6 条 media 侧同时带有 OSS invalid 症状。
+- 历史 runtime 不能反查原始字段路径:`platform_raw_payload` 只保留 `channel_content_id/channel_account_id`,没有完整 `video_url_list/image_url_list/content_link/bgm_data`。
+
+### 25011 与翻页观察
+
+- 本轮真实探测保留了 25011 retry 事件。
+- 例:`怀旧经典歌曲` page1 前两次返回 `25011: 视频号接口异常: 获取搜索结果失败`,第 3 次返回 `code=0` 且 `item_count=9`。
+- 例:`歌词同步滚动呈现` page1 第 1 次返回 25011,重试后成功。
+- 例:`怀旧金曲` page1 重试后仍为 25011,最终 item_count=0。
+- page 返回数量不固定:本轮见到 0、9、11、12 条;page2 也可能返回 0、9、11、12 条。
+
+### 推荐 URL 解包规则
+
+- 递归抽取搜索 item 中所有 URL,视频号也按广告/素材规则扫描,不能假设没有广告 MP4。
+- 优先选择 `$.search.video_url_list[].video_url` 中的 findermp 正片 URL。
+- 明确排除:`image_url_list` 封面/图片、`channel_account_avatar` 头像、音频/BGM、页面链接、广告/素材字段。
+- 广告关键词不能用裸 `ad` 子串匹配,否则会误伤 `stodownload/head` 这类正常视频号 URL;应按字段路径 token 或分隔符匹配。
+- 下载/轻量验证需带 `Referer: https://channels.weixin.qq.com/`。
+
+### 本轮真实字段与 host
+
+- selected 字段路径:`$.search.video_url_list[0].video_url=41`。
+- selected host:`findermp.video.qq.com=41`。
+- 所有 selected 样本轻量验证均为可下载视频:HTTP `200/206`,`content_type=video/mp4`,Range 返回字节范围或完整小文件。
+
+### OSS v2 验证结论
+
+- 使用生产当前 payload 形态(把 `referer` 作为 dict 字段传给 OSS v2)时:20/20 返回 HTTP 200 但 `oss_object.cdn_url` 缺失,分类为 `oss_upload_response_invalid`。
+- 快手抽样复打证明该 invalid 与 `referer` dict payload 强相关;去掉 `referer` 后 OSS v2 能返回 `oss_object.cdn_url`。
+- 使用同一批视频号 selected URL、去掉 `referer` 字段后做 no-referer 复验:20/20 成功返回 `oss_object.cdn_url`。
+- 结论:视频号 selected URL 本身可下载、可转存;历史 30/30 invalid 更像 OSS v2 payload 兼容问题,不是 findermp URL 字段拆错。
+
+### 生产改造建议
+
+- 第一优先级:调整 OSS v2 调用 payload,不再把 `referer` dict 直接传给 v2;如 OSS 侧确需 headers,应与上传服务约定字符串/headers 字段格式后再传。
+- 第二优先级:保留视频号 25011 retry 事件到 query failure / raw payload,区分“无结果”和“重试耗尽”。
+- 第三优先级:生产 runtime 里补充 URL 候选摘要:`selected_path`、`selected_host`、`probe_status`、`content_type`、`content_range`、`oss_payload_mode`、`oss_failure_type`。
+
+---
+
 ## 1. 关键词搜索(PLT_SHIPINHAO_KEYWORD)
 
 ### ① 身份

+ 63 - 0
tests/test_content_discovery_media_diagnostics.py

@@ -0,0 +1,63 @@
+from __future__ import annotations
+
+from content_agent.business_modules.content_discovery import run
+from content_agent.integrations.runtime_files import LocalRuntimeFileStore
+
+
+def test_media_record_keeps_no_valid_play_url_diagnostics(tmp_path):
+    runtime = LocalRuntimeFileStore(tmp_path)
+    runtime.prepare_run("run_001")
+    result = run(
+        "run_001",
+        "policy_001",
+        [
+            {
+                "content_discovery_id": "q_001_content_001",
+                "search_query_id": "q_001",
+                "search_query": "歌词",
+                "search_query_generation_method": "category_leaf_element",
+                "platform": "shipinhao",
+                "platform_content_id": "finder_001",
+                "platform_content_format": "video",
+                "description": "title",
+                "platform_author_id": "author_001",
+                "author_display_name": "author",
+                "statistics": {},
+                "tags": [],
+                "discovery_start_source": "pattern_itemset",
+                "previous_discovery_step": "search_query_direct",
+                "content_metadata_source": "shipinhao_keyword_search",
+                "play_url": None,
+                "media_failure_reason": "no_valid_play_url",
+                "platform_raw_payload": {
+                    "channel_content_id": "finder_001",
+                    "selected_video_url_path": "$.search.video_url_list[0].video_url",
+                    "selected_video_url_host": "findermp.video.qq.com",
+                    "selected_video_url_probe_status": "failed_fallback",
+                    "selected_video_url_content_type": "video/mp4",
+                    "selected_video_url_content_range": "bytes 0-99/100",
+                    "video_url_candidate_counts": {"video": 1, "image": 1},
+                    "video_url_reject_reasons": {"image_path": 1},
+                    "no_valid_play_url": True,
+                    "shipinhao_research_source": "title",
+                    "shipinhao_research_status": "not_matched",
+                    "shipinhao_research_item_count": 8,
+                    "kuaishou_detail_fallback_attempted": True,
+                    "kuaishou_detail_fallback_status": "no_valid_play_url",
+                },
+            }
+        ],
+        {"ext_data": {"evidence_pack": {}}},
+        runtime,
+        write_runtime=False,
+    )
+
+    [media] = result["content_media_records"]
+
+    assert media["failure_reason"] == "no_valid_play_url"
+    assert media["raw_payload"]["no_valid_play_url"] is True
+    assert media["raw_payload"]["selected_video_url_host"] == "findermp.video.qq.com"
+    assert media["raw_payload"]["video_url_candidate_counts"] == {"video": 1, "image": 1}
+    assert media["raw_payload"]["shipinhao_research_status"] == "not_matched"
+    assert media["raw_payload"]["shipinhao_research_item_count"] == 8
+    assert media["raw_payload"]["kuaishou_detail_fallback_status"] == "no_valid_play_url"

+ 103 - 3
tests/test_flow_ledger_api.py

@@ -214,6 +214,18 @@ def test_flow_ledger_api_returns_business_v4_ledger(tmp_path, monkeypatch):
                 "statistics": {},
                 "raw_payload": {},
             },
+            {
+                "run_id": run_id,
+                "policy_run_id": policy_run_id,
+                "content_discovery_id": "content_no_valid",
+                "platform_content_id": "shipinhao_no_valid",
+                "search_query_id": "q_003",
+                "platform": "shipinhao",
+                "description": "无可用正片 URL",
+                "author_display_name": "技术作者",
+                "statistics": {},
+                "raw_payload": {},
+            },
             {
                 "run_id": run_id,
                 "policy_run_id": policy_run_id,
@@ -256,8 +268,42 @@ def test_flow_ledger_api_returns_business_v4_ledger(tmp_path, monkeypatch):
                 "raw_payload": {
                     "oss_archive_attempt_count": 1,
                     "oss_timing_metrics": {"oss_upload_duration_ms": 1234},
+                    "oss_payload_mode": "no_referer",
+                    "oss_response_summary": {
+                        "status": 10000,
+                        "msg": "bad",
+                        "oss_object_present": False,
+                        "oss_object_has_cdn_url": False,
+                    },
+                    "selected_video_url_path": "$.search.video_url_list[0].video_url",
+                    "selected_video_url_host": "findermp.video.qq.com",
+                    "selected_video_url_probe_status": "verified",
+                    "selected_video_url_content_type": "video/mp4",
+                    "selected_video_url_content_range": "bytes 0-99/100",
+                    "video_url_candidate_counts": {"video": 1, "image": 1},
+                    "video_url_reject_reasons": {"image_path": 1},
                 },
-            }
+            },
+            {
+                "run_id": run_id,
+                "policy_run_id": policy_run_id,
+                "platform": "shipinhao",
+                "platform_content_id": "shipinhao_no_valid",
+                "play_url": None,
+                "oss_url": None,
+                "local_path": None,
+                "content_media_status": "unavailable",
+                "failure_reason": "no_valid_play_url",
+                "raw_payload": {
+                    "failure_reason": "no_valid_play_url",
+                    "no_valid_play_url": True,
+                    "selected_video_url_path": "$.search.video_url_list[0].video_url",
+                    "selected_video_url_host": "findermp.video.qq.com",
+                    "selected_video_url_probe_status": "failed_fallback",
+                    "video_url_candidate_counts": {"video": 1, "image": 1},
+                    "video_url_reject_reasons": {"image_path": 1, "probe_failed": 1},
+                },
+            },
         ],
     )
     service.runtime.append_jsonl(
@@ -285,6 +331,12 @@ def test_flow_ledger_api_returns_business_v4_ledger(tmp_path, monkeypatch):
                     "error_message": "The write operation timed out",
                     "retry_count": 2,
                     "final_status": "failed",
+                    "response_body_summary": {
+                        "http_status_code": 502,
+                        "content_type": "application/json",
+                        "json_top_level_keys": ["error"],
+                        "text_excerpt": "upstream overloaded",
+                    },
                     "timing_metrics": {
                         "video_fetch": {
                             "gemini_video_source": "oss_url",
@@ -304,13 +356,32 @@ def test_flow_ledger_api_returns_business_v4_ledger(tmp_path, monkeypatch):
                                     "failure_type": "gemini_client_timeout",
                                     "exception_type": "WriteTimeout",
                                     "http_status_code": None,
+                                    "response_body_summary": {
+                                        "http_status_code": 502,
+                                        "text_excerpt": "upstream overloaded",
+                                    },
                                 }
                             ],
                         },
                     },
                 },
                 "raw_payload": {},
-            }
+            },
+            {
+                "run_id": run_id,
+                "policy_run_id": policy_run_id,
+                "recall_evidence_id": "evidence_no_valid",
+                "platform_content_id": "shipinhao_no_valid",
+                "recall_status": "failed",
+                "evidence_summary": {
+                    "schema_version": "v4_gemini_query_relevance.v1",
+                    "failure_type": "no_valid_play_url",
+                    "final_status": "failed",
+                    "retry_count": 1,
+                    "timing_metrics": {"video_fetch": {"skipped": "no_valid_play_url"}},
+                },
+                "raw_payload": {},
+            },
         ],
     )
     service.runtime.append_jsonl(
@@ -380,7 +451,24 @@ def test_flow_ledger_api_returns_business_v4_ledger(tmp_path, monkeypatch):
                 },
                 "decision_replay_data": {"allow_walk": False},
                 "raw_payload": {},
-            }
+            },
+            {
+                "run_id": run_id,
+                "policy_run_id": policy_run_id,
+                "decision_id": "decision_no_valid",
+                "search_query_id": "q_003",
+                "decision_target_id": "content_no_valid",
+                "decision_action": "TECHNICAL_RETRY_REQUIRED",
+                "decision_reason_code": "v4_technical_retry_needed",
+                "score": None,
+                "scorecard": {
+                    "failure_type": "no_valid_play_url",
+                    "final_status": "failed",
+                    "total_score": None,
+                },
+                "decision_replay_data": {"allow_walk": False},
+                "raw_payload": {},
+            },
         ],
     )
     service.runtime.append_jsonl(
@@ -497,6 +585,18 @@ def test_flow_ledger_api_returns_business_v4_ledger(tmp_path, monkeypatch):
     assert tech_video["technical_retry_detail"]["timings"]["gemini_seconds"] == 180.0
     assert tech_video["technical_retry_detail"]["sizes"]["downloaded_mb"] == 10.0
     assert tech_video["technical_retry_detail"]["attempts"][0]["exception_type"] == "WriteTimeout"
+    assert tech_video["technical_retry_detail"]["attempts"][0]["response_body_summary"]["http_status_code"] == 502
+    assert tech_video["technical_retry_detail"]["openrouter"]["response_summary"]["text_excerpt"] == "upstream overloaded"
+    assert tech_video["technical_retry_detail"]["oss"]["payload_mode"] == "no_referer"
+    assert tech_video["technical_retry_detail"]["oss"]["response_summary"]["status"] == 10000
+    assert tech_video["technical_retry_detail"]["video_url"]["selected_host"] == "findermp.video.qq.com"
+    assert tech_video["technical_retry_detail"]["video_url"]["probe_status"] == "verified"
+    assert tech_video["technical_retry_detail"]["video_url"]["candidate_counts"] == {"video": 1, "image": 1}
+    no_valid_video = next(item for item in tech_videos["videos"] if item["platform_content_id"] == "shipinhao_no_valid")
+    assert no_valid_video["technical_retry_detail"]["failure_type"] == "no_valid_play_url"
+    assert no_valid_video["technical_retry_detail"]["failure_label"] == "未找到可用正片 URL"
+    assert no_valid_video["technical_retry_detail"]["brief_reason"] == "平台结果未找到可用正片 URL"
+    assert no_valid_video["technical_retry_detail"]["video_url"]["probe_status"] == "failed_fallback"
 
     walk = client.get(f"/runs/{run_id}/flow-ledger/queries/q_001/walk").json()
     assert walk["summary"]["total_actions"] == 2

+ 90 - 3
tests/test_gemini_video.py

@@ -11,17 +11,25 @@ from content_agent.integrations.gemini_video import (
 
 
 class FakeResponse:
-    def __init__(self, content, *, status_code=200):
+    def __init__(self, content, *, status_code=200, json_payload=None, headers=None):
         self._content = content
+        self._json_payload = json_payload
         self.status_code = status_code
+        self.headers = headers or {"content-type": "application/json"}
+        self.content = (
+            content.encode("utf-8")
+            if isinstance(content, str)
+            else content or b""
+        )
         self.request = httpx.Request("POST", "https://openrouter.test/chat/completions")
 
     def raise_for_status(self):
         if self.status_code >= 400:
-            response = httpx.Response(self.status_code, request=self.request)
-            raise httpx.HTTPStatusError("bad", request=self.request, response=response)
+            raise httpx.HTTPStatusError("bad", request=self.request, response=self)
 
     def json(self):
+        if self._json_payload is not None:
+            return self._json_payload
         return {"choices": [{"message": {"content": self._content}}]}
 
 
@@ -188,6 +196,21 @@ def test_analyze_no_play_url_returns_v4_fail():
     assert result["retry_count"] == 1
 
 
+def test_analyze_no_valid_play_url_preserves_media_failure_reason():
+    result = _client("{}").analyze(
+        _ITEM,
+        {
+            "failure_reason": "no_valid_play_url",
+            "raw_payload": {"no_valid_play_url": True},
+        },
+        _CTX,
+    )
+
+    assert result["final_status"] == "failed"
+    assert result["failure_type"] == "no_valid_play_url"
+    assert result["media_storage_update"]["failure_reason"] == "no_valid_play_url"
+
+
 def test_analyze_video_fetch_failure_returns_v4_fail():
     def boom(play_url, platform):
         raise RuntimeError("dl")
@@ -216,6 +239,51 @@ def test_analyze_retryable_http_error_retries_once_then_fails():
     assert result["retry_count"] == 2
 
 
+def test_analyze_http_error_keeps_response_body_summary():
+    calls = []
+
+    def post(*a, **k):
+        calls.append(1)
+        return FakeResponse(
+            '{"error":{"message":"upstream overloaded"}}',
+            status_code=502,
+            json_payload={"error": {"message": "upstream overloaded"}, "provider": "x"},
+        )
+
+    result = _client(post=post).analyze(_ITEM, _MEDIA, _CTX)
+
+    assert len(calls) == 2
+    assert result["failure_type"] == "gemini_http_error"
+    assert result["response_body_summary"]["http_status_code"] == 502
+    assert result["response_body_summary"]["json_top_level_keys"] == ["error", "provider"]
+    assert result["response_body_summary"]["text_excerpt"] == "upstream overloaded"
+    attempts = result["timing_metrics"]["gemini_request"]["attempts"]
+    assert attempts[-1]["response_body_summary"]["text_excerpt"] == "upstream overloaded"
+
+
+def test_analyze_response_body_summary_redacts_sensitive_text():
+    def post(*a, **k):
+        return FakeResponse(
+            '{"error":{"message":"failed https://x.test/video.mp4?token=secret Bearer abc.def sk-testsecret1234567890"}}',
+            status_code=502,
+            json_payload={
+                "error": {
+                    "message": "failed https://x.test/video.mp4?token=secret Bearer abc.def sk-testsecret1234567890"
+                }
+            },
+        )
+
+    result = _client(post=post).analyze(_ITEM, _MEDIA, _CTX)
+
+    excerpt = result["response_body_summary"]["text_excerpt"]
+    assert "token=secret" not in excerpt
+    assert "abc.def" not in excerpt
+    assert "sk-testsecret" not in excerpt
+    assert "https://x.test/video.mp4?[REDACTED]" in excerpt
+    assert "Bearer [REDACTED]" in excerpt
+    assert "sk-[REDACTED]" in excerpt
+
+
 def test_analyze_bad_json_retries_once_then_fails():
     calls = []
 
@@ -230,6 +298,25 @@ def test_analyze_bad_json_retries_once_then_fails():
     assert result["retry_count"] == 2
 
 
+def test_analyze_missing_choices_keeps_response_body_summary():
+    calls = []
+
+    def post(*a, **k):
+        calls.append(1)
+        return FakeResponse(
+            '{"error":{"message":"missing choices body"}}',
+            json_payload={"error": {"message": "missing choices body"}, "id": "r1"},
+        )
+
+    result = _client(post=post).analyze(_ITEM, _MEDIA, _CTX)
+
+    assert len(calls) == 2
+    assert result["failure_type"] == "gemini_response_invalid"
+    assert result["exception_type"] == "KeyError"
+    assert result["response_body_summary"]["json_top_level_keys"] == ["error", "id"]
+    assert result["response_body_summary"]["text_excerpt"] == "missing choices body"
+
+
 def test_from_env_missing_key_returns_missing_client():
     client = GeminiVideoClient.from_env({})
 

+ 37 - 0
tests/test_kuaishou_client.py

@@ -93,6 +93,43 @@ def test_kuaishou_search_maps_canonical_fields():
     assert result["platform_raw_payload"]["channel_content_id"] == "ks_001"
 
 
+def test_kuaishou_search_falls_back_to_detail_video_url():
+    search_item = {**_item("ks_detail_fallback"), "video_url_list": []}
+    detail_item = {**_item("ks_detail_fallback"), "video_url_list": [{"video_url": "https://v.kwaicdn.test/detail.mp4"}]}
+    client = _client(
+        [
+            _response(200, {"code": 0, "data": {"data": [search_item], "has_more": False}}),
+            _response(200, {"code": 0, "data": {"data": detail_item}}),
+        ]
+    )
+
+    result = client.search(_query())[0]
+
+    assert client.http_client.requests[1]["url"].endswith("/crawler/kuai_shou/detail")
+    assert client.http_client.requests[1]["json"] == {"content_id": "ks_detail_fallback"}
+    assert result["play_url"] == "https://v.kwaicdn.test/detail.mp4"
+    assert result["platform_raw_payload"]["selected_video_url_path"] == "$.detail.video_url_list[0].video_url"
+    assert result["platform_raw_payload"]["kuaishou_detail_fallback_status"] == "used"
+    assert "media_failure_reason" not in result
+
+
+def test_kuaishou_search_keeps_no_valid_after_search_and_detail_fail():
+    search_item = {**_item("ks_no_video"), "video_url_list": [], "image_url_list": []}
+    detail_item = {**search_item, "content_link": "https://www.gifshow.com/fw/photo/ks_no_video"}
+    client = _client(
+        [
+            _response(200, {"code": 0, "data": {"data": [search_item], "has_more": False}}),
+            _response(200, {"code": 0, "data": {"data": detail_item}}),
+        ]
+    )
+
+    result = client.search(_query())[0]
+
+    assert result["play_url"] is None
+    assert result["media_failure_reason"] == "no_valid_play_url"
+    assert result["platform_raw_payload"]["kuaishou_detail_fallback_status"] == "no_valid_play_url"
+
+
 def test_kuaishou_search_limits_to_five_by_default():
     items = [_item(f"ks_{index}") for index in range(6)]
     client = _client([_response(200, {"code": 0, "data": {"data": items}})])

+ 30 - 0
tests/test_oss_archive.py

@@ -54,6 +54,7 @@ def test_archive_due_records_updates_success_to_oss_uploaded():
             "oss_url": "https://res.example/video.mp4",
             "oss_object_key": "crawler/video/content_001.mp4",
             "save_oss_timestamp": 123,
+            "oss_payload_mode": "no_referer",
         }
 
     [row] = archive_due_records([pending], upload_fn=upload, now_fn=lambda: NOW)
@@ -63,6 +64,7 @@ def test_archive_due_records_updates_success_to_oss_uploaded():
     assert row["raw_payload"]["oss_archive_status"] == "uploaded"
     assert row["raw_payload"]["oss_archive_attempt_count"] == 1
     assert row["raw_payload"]["oss_object_key"] == "crawler/video/content_001.mp4"
+    assert row["raw_payload"]["oss_payload_mode"] == "no_referer"
     assert row["raw_payload"]["oss_timing_metrics"]["oss_upload_duration_ms"] >= 0
 
 
@@ -207,17 +209,20 @@ def test_archive_due_records_keeps_failed_attempt_pending_before_deadline():
     [pending] = mark_archive_pending([_record()], now_fn=lambda: NOW)
 
     def upload(src_url, **kwargs):
+        assert "referer" not in kwargs
         assert kwargs["timeout_seconds"] == 3600.0
         return {
             "status": "failed",
             "failure_type": "oss_upload_http_error",
             "exception_type": "ReadTimeout",
             "error_message": "proxy timed out",
+            "oss_payload_mode": "no_referer",
         }
 
     [row] = archive_due_records([pending], upload_fn=upload, now_fn=lambda: NOW)
 
     assert row["content_media_status"] == "oss_upload_pending"
+    assert row["raw_payload"]["oss_payload_mode"] == "no_referer"
     assert row["failure_reason"] == "oss_upload_http_error"
     assert row["raw_payload"]["oss_archive_attempt_count"] == 1
     assert row["raw_payload"]["oss_archive_last_error"] == "oss_upload_http_error"
@@ -226,6 +231,31 @@ def test_archive_due_records_keeps_failed_attempt_pending_before_deadline():
     assert row["raw_payload"]["oss_timing_metrics"]["oss_upload_duration_ms"] >= 0
 
 
+def test_archive_due_records_keeps_invalid_response_summary():
+    [pending] = mark_archive_pending([_record()], now_fn=lambda: NOW)
+
+    def upload(src_url, **kwargs):
+        assert "referer" not in kwargs
+        return {
+            "status": "failed",
+            "failure_type": "oss_upload_response_invalid",
+            "oss_payload_mode": "no_referer",
+            "oss_response_summary": {
+                "status": 10000,
+                "msg": "bad",
+                "oss_object_present": False,
+                "oss_object_has_cdn_url": False,
+            },
+        }
+
+    [row] = archive_due_records([pending], upload_fn=upload, now_fn=lambda: NOW)
+
+    assert row["content_media_status"] == "oss_upload_pending"
+    assert row["raw_payload"]["oss_archive_last_error"] == "oss_upload_response_invalid"
+    assert row["raw_payload"]["oss_payload_mode"] == "no_referer"
+    assert row["raw_payload"]["oss_response_summary"]["status"] == 10000
+
+
 def test_archive_due_records_marks_failed_after_deadline():
     [pending] = mark_archive_pending([_record()], now_fn=lambda: NOW)
     pending["raw_payload"]["oss_archive_deadline_at"] = (NOW - timedelta(seconds=1)).isoformat()

+ 23 - 0
tests/test_oss_upload.py

@@ -51,6 +51,8 @@ def test_upload_video_from_url_parses_oss_object():
     assert seen["json"]["src_type"] == "video"
     assert seen["json"]["use_proxy"] is True
     assert seen["json"]["project"] == "content-agent"
+    assert "referer" not in seen["json"]
+    assert result["oss_payload_mode"] == "no_referer"
 
 
 def test_upload_video_from_url_failure_is_returned_not_raised():
@@ -64,3 +66,24 @@ def test_upload_video_from_url_failure_is_returned_not_raised():
     assert result["failure_type"] == "oss_upload_http_error"
     assert result["http_status_code"] == 500
     assert result["error_message"] == "bad"
+    assert result["oss_payload_mode"] == "no_referer"
+
+
+def test_upload_video_from_url_invalid_response_keeps_summary():
+    result = upload_video_from_url(
+        "https://source.example/video.mp4",
+        http_post=lambda *a, **k: FakeResponse(
+            {"status": 10000, "msg": "bad", "oss_object": None}
+        ),
+        endpoint="http://oss.test/upload",
+    )
+
+    assert result["status"] == "failed"
+    assert result["failure_type"] == "oss_upload_response_invalid"
+    assert result["oss_payload_mode"] == "no_referer"
+    assert result["oss_response_summary"] == {
+        "status": 10000,
+        "msg": "bad",
+        "oss_object_present": False,
+        "oss_object_has_cdn_url": False,
+    }

+ 110 - 0
tests/test_platform_video_url.py

@@ -0,0 +1,110 @@
+from __future__ import annotations
+
+from content_agent.integrations.platform_video_url import (
+    classify_url_candidate,
+    find_url_candidates,
+    select_video_url,
+)
+
+
+def _probe(status: str = "verified"):
+    def probe(url: str, platform: str):
+        return {
+            "http_status": 206 if status == "verified" else None,
+            "content_type": "video/mp4" if status == "verified" else "",
+            "content_range": "bytes 0-99/100" if status == "verified" else "",
+            "range_bytes": 100 if status == "verified" else 0,
+            "looks_like_video": status == "verified",
+            "probe_status": status,
+        }
+
+    return probe
+
+
+def test_classifies_urls_without_ad_substring_false_positive():
+    payload = {
+        "content_link": "https://www.example.com/item",
+        "image_url_list": [{"image_url": "https://img.example/a.jpg"}],
+        "avatar": "https://wx.qlogo.cn/avatar",
+        "bgm_data": {"play_url": "https://audio.example/a.m4a"},
+        "ads": {"video_url": "https://ad.example/ad.mp4"},
+        "video_url_list": [
+            {
+                "video_url": (
+                    "https://findermp.video.qq.com/251/20302/stodownload?"
+                    "head=1&token=abc"
+                )
+            }
+        ],
+    }
+
+    by_path = {item.path: item for item in find_url_candidates(payload)}
+
+    assert by_path["$.content_link"].kind == "page"
+    assert by_path["$.image_url_list[0].image_url"].kind == "image"
+    assert by_path["$.avatar"].kind == "avatar"
+    assert by_path["$.bgm_data.play_url"].kind == "audio"
+    assert by_path["$.ads.video_url"].kind == "ad_or_material"
+    assert by_path["$.video_url_list[0].video_url"].kind == "video"
+    assert classify_url_candidate("$.video_url", "https://x/stodownload?head=1")[0] == "video"
+
+
+def test_kuaishou_prefers_verified_detail_video_over_search():
+    result = select_video_url(
+        "kuaishou",
+        [
+            ("search", {"video_url_list": [{"video_url": "https://v23-3.kwaicdn.com/search.mp4"}]}),
+            ("detail", {"video_url_list": [{"video_url": "https://tymov2.a.kwimgs.com/detail.mp4"}]}),
+        ],
+        probe_fn=_probe(),
+    )
+
+    assert result["play_url"] == "https://tymov2.a.kwimgs.com/detail.mp4"
+    assert result["platform_raw_payload"]["selected_video_url_path"] == "$.detail.video_url_list[0].video_url"
+    assert result["platform_raw_payload"]["selected_video_url_probe_status"] == "verified"
+
+
+def test_shipinhao_prefers_findermp_video_and_filters_non_video():
+    result = select_video_url(
+        "shipinhao",
+        [
+            (
+                "search",
+                {
+                    "image_url_list": [{"image_url": "https://findermp.video.qq.com/cover.jpg"}],
+                    "video_url_list": [{"video_url": "https://findermp.video.qq.com/video.mp4"}],
+                },
+            )
+        ],
+        probe_fn=_probe(),
+    )
+
+    assert result["play_url"] == "https://findermp.video.qq.com/video.mp4"
+    assert result["platform_raw_payload"]["video_url_candidate_counts"] == {
+        "image": 1,
+        "video": 1,
+    }
+
+
+def test_probe_failure_falls_back_to_strong_video_candidate():
+    result = select_video_url(
+        "kuaishou",
+        [("search", {"video_url_list": [{"video_url": "https://v23-3.kwaicdn.com/search.mp4"}]})],
+        probe_fn=_probe("failed"),
+    )
+
+    assert result["play_url"] == "https://v23-3.kwaicdn.com/search.mp4"
+    assert result["platform_raw_payload"]["selected_video_url_probe_status"] == "failed_fallback"
+    assert result["media_failure_reason"] is None
+
+
+def test_no_video_candidate_returns_no_valid_play_url():
+    result = select_video_url(
+        "kuaishou",
+        [("search", {"image_url_list": [{"image_url": "https://img.example/a.jpg"}]})],
+        probe_fn=_probe(),
+    )
+
+    assert result["play_url"] is None
+    assert result["media_failure_reason"] == "no_valid_play_url"
+    assert result["platform_raw_payload"]["no_valid_play_url"] is True

+ 862 - 0
tests/test_platform_video_url_candidates.py

@@ -0,0 +1,862 @@
+"""快手/视频号 URL 解包诊断工具。
+
+pytest 默认只跑离线单测。真实探测手动执行:
+
+    .venv/bin/python tests/test_platform_video_url_candidates.py --real-probe --probe-oss
+
+真实响应和完整临时 URL 只写入 data/platform_url_probe/,该目录不提交。
+提交到文档的内容应使用脚本生成的 *_sanitized_report.md。
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import re
+import subprocess
+import time
+from collections import Counter, defaultdict
+from dataclasses import asdict, dataclass
+from pathlib import Path
+from typing import Any
+from urllib.parse import urljoin, urlparse
+
+import httpx
+
+
+KUAISHOU_RUN_ID = "v1_run_94448252f5cd"
+SHIPINHAO_RUN_ID = "v1_run_35f4f7ccd1cc"
+DEFAULT_QUERIES = [
+    "怀旧经典歌曲",
+    "歌词唱词",
+    "歌词同步滚动呈现",
+    "怀旧金曲",
+    "歌词",
+    "经典老歌",
+    "世界杯主题曲",
+    "音乐盘点",
+    "热门歌曲",
+    "视频剪辑歌词",
+]
+VIDEO_HINT_RE = re.compile(r"(video|play|mp4|m3u8|media|download|src)", re.I)
+IMAGE_HINT_RE = re.compile(r"(image|cover|avatar|poster|thumbnail|thumb|qlogo|heif|jpg|jpeg|png|kvif)", re.I)
+AUDIO_HINT_RE = re.compile(r"(audio|bgm|music|m4a|mp3)", re.I)
+PAGE_HINT_RE = re.compile(r"(content_link|share_url|page_url|link)$", re.I)
+AD_HINT_RE = re.compile(
+    r"(^|[._/\-\[\]&?=#])(?:ad|ads|advert|commercial|promotion|material|marketing|营销|广告|推广)($|[._/\-\[\]&?=#])",
+    re.I,
+)
+VIDEO_EXT_RE = re.compile(r"\.(mp4|m3u8)(?:$|[?&#])", re.I)
+
+
+@dataclass
+class UrlCandidate:
+    path: str
+    url: str
+    host: str
+    url_kind: str
+    reject_hint: str = ""
+    http_status: int | None = None
+    content_type: str = ""
+    content_length: int | None = None
+    content_range: str = ""
+    range_bytes: int = 0
+    looks_like_video: bool = False
+    ffprobe_ok: bool = False
+    duration_seconds: float | None = None
+    width: int | None = None
+    height: int | None = None
+    selected: bool = False
+    oss_status: str = ""
+    oss_failure_type: str = ""
+    oss_url_present: bool = False
+    oss_elapsed_seconds: float | None = None
+    oss_response_shape: str = ""
+
+
+def find_url_candidates(value: Any, *, prefix: str = "$") -> list[UrlCandidate]:
+    candidates: list[UrlCandidate] = []
+    if isinstance(value, dict):
+        for key, item in value.items():
+            candidates.extend(find_url_candidates(item, prefix=f"{prefix}.{key}"))
+    elif isinstance(value, list):
+        for index, item in enumerate(value):
+            candidates.extend(find_url_candidates(item, prefix=f"{prefix}[{index}]"))
+    elif isinstance(value, str) and value.startswith(("http://", "https://")):
+        kind, reject = classify_url_candidate(prefix, value)
+        candidates.append(
+            UrlCandidate(
+                path=prefix,
+                url=value,
+                host=urlparse(value).netloc,
+                url_kind=kind,
+                reject_hint=reject,
+            )
+        )
+    return candidates
+
+
+def classify_url_candidate(path: str, url: str) -> tuple[str, str]:
+    haystack = f"{path} {url}".lower()
+    if AD_HINT_RE.search(haystack):
+        return "ad_or_material", "ad_or_material_path"
+    if AUDIO_HINT_RE.search(haystack):
+        return "audio", "audio_or_bgm_path"
+    if IMAGE_HINT_RE.search(haystack):
+        return "image", "image_or_avatar_path"
+    if PAGE_HINT_RE.search(path):
+        return "page", "page_link_path"
+    if VIDEO_EXT_RE.search(url) or VIDEO_HINT_RE.search(path):
+        return "video_candidate", ""
+    return "unknown", "weak_video_signal"
+
+
+def probe_candidate(
+    candidate: UrlCandidate,
+    *,
+    platform: str,
+    client: httpx.Client,
+    bytes_to_probe: int,
+    timeout_seconds: float,
+) -> None:
+    headers = download_headers(platform)
+    headers["Range"] = f"bytes=0-{max(bytes_to_probe - 1, 0)}"
+    try:
+        response = client.get(candidate.url, headers=headers, follow_redirects=True, timeout=timeout_seconds)
+        candidate.http_status = response.status_code
+        candidate.content_type = response.headers.get("content-type", "")
+        candidate.content_length = _int_or_none(response.headers.get("content-length"))
+        candidate.content_range = response.headers.get("content-range", "")
+        candidate.range_bytes = len(response.content or b"")
+        candidate.looks_like_video = looks_like_video(response.content, candidate.content_type)
+        probe_ffprobe(candidate, response.content)
+    except Exception as exc:  # noqa: BLE001 - diagnostic output should retain exception type.
+        candidate.reject_hint = candidate.reject_hint or f"http_probe_failed:{type(exc).__name__}"
+
+
+def select_video_url(candidates: list[UrlCandidate], platform: str) -> UrlCandidate | None:
+    viable = [
+        item
+        for item in candidates
+        if item.url_kind == "video_candidate"
+        and not item.reject_hint
+        and item.http_status in {200, 206}
+        and (item.looks_like_video or "video" in item.content_type.lower() or item.ffprobe_ok)
+    ]
+    if not viable:
+        viable = [
+            item
+            for item in candidates
+            if item.url_kind == "video_candidate" and not item.reject_hint
+        ]
+    if not viable:
+        return None
+    return sorted(viable, key=lambda item: selection_score(item, platform))[0]
+
+
+def selection_score(candidate: UrlCandidate, platform: str) -> tuple[Any, ...]:
+    path = candidate.path
+    detail_bonus = 0 if "detail" in path else 1
+    preferred_host = 0
+    if platform == "shipinhao":
+        preferred_host = 0 if "findermp.video.qq.com" in candidate.host else 1
+    if platform == "kuaishou":
+        preferred_host = 0 if "kwaicdn" in candidate.host or "kwai" in candidate.host else 1
+    return (
+        detail_bonus,
+        preferred_host,
+        candidate.http_status not in {200, 206},
+        not candidate.looks_like_video,
+        not candidate.ffprobe_ok,
+        path.count("."),
+        path,
+    )
+
+
+def run_historical_audit(runtime_root: Path) -> dict[str, Any]:
+    return {
+        "kuaishou": audit_run(runtime_root / KUAISHOU_RUN_ID),
+        "shipinhao": audit_run(runtime_root / SHIPINHAO_RUN_ID),
+    }
+
+
+def audit_run(run_dir: Path) -> dict[str, Any]:
+    content = _read_jsonl(run_dir / "discovered_content_items.jsonl")
+    media = _read_jsonl(run_dir / "content_media_records.jsonl")
+    evidence = _read_jsonl(run_dir / "pattern_recall_evidence.jsonl")
+    decisions = _read_jsonl(run_dir / "rule_decisions.jsonl")
+    by_content_id = {row.get("content_discovery_id"): row for row in content}
+    by_platform_id = {row.get("platform_content_id"): row for row in content}
+    media_by_platform_id = {row.get("platform_content_id"): row for row in media}
+    evidence_by_content_id = {row.get("content_discovery_id"): row for row in evidence}
+    rows = []
+    for decision in decisions:
+        target = decision.get("decision_target_id")
+        item = by_content_id.get(target) or by_platform_id.get(target) or {}
+        platform_id = item.get("platform_content_id") or target
+        media_row = media_by_platform_id.get(platform_id, {})
+        evidence_row = evidence_by_content_id.get(item.get("content_discovery_id"), {})
+        evidence_summary = evidence_row.get("evidence_summary") or {}
+        evidence_raw = evidence_row.get("raw_payload") or {}
+        timing = evidence_summary.get("timing_metrics") or evidence_raw.get("timing_metrics") or {}
+        video_fetch = timing.get("video_fetch") or {}
+        gemini_request = timing.get("gemini_request") or {}
+        media_raw = media_row.get("raw_payload") or {}
+        platform_raw = item.get("platform_raw_payload") or {}
+        rows.append(
+            {
+                "platform_content_id": platform_id,
+                "search_query_id": item.get("search_query_id"),
+                "decision_action": decision.get("decision_action"),
+                "play_url_host": urlparse(str(media_row.get("play_url") or "")).netloc,
+                "play_url_path_found_in_platform_raw": path_for_url(platform_raw, media_row.get("play_url")),
+                "platform_raw_key_count": len(platform_raw),
+                "has_full_platform_raw": has_full_raw_item(platform_raw),
+                "content_media_status": media_row.get("content_media_status"),
+                "oss_url_present": bool(media_row.get("oss_url")),
+                "oss_archive_last_error": media_raw.get("oss_archive_last_error") or media_raw.get("failure_reason"),
+                "gemini_video_source": video_fetch.get("gemini_video_source"),
+                "download_seconds": _ms_to_seconds(video_fetch.get("download_duration_ms")),
+                "ffmpeg_seconds": _ms_to_seconds(video_fetch.get("ffmpeg_duration_ms")),
+                "gemini_seconds": _ms_to_seconds(gemini_request.get("total_duration_ms")),
+                "failure_type": evidence_summary.get("failure_type") or evidence_raw.get("failure_type"),
+            }
+        )
+    return {
+        "run_id": run_dir.name,
+        "rows": rows,
+        "counts": {
+            "decision_action": dict(Counter(row["decision_action"] for row in rows)),
+            "media_status": dict(Counter(row["content_media_status"] for row in rows)),
+            "oss_error": dict(Counter(row["oss_archive_last_error"] for row in rows if row["oss_archive_last_error"])),
+            "play_url_host": dict(Counter(row["play_url_host"] for row in rows if row["play_url_host"])),
+        },
+    }
+
+
+def inspect_real_platforms(
+    *,
+    queries: list[str],
+    output_dir: Path,
+    items_per_query: int,
+    bytes_to_probe: int,
+    kuaishou_detail_limit_per_query: int,
+    probe_oss_enabled: bool,
+    oss_limit_per_platform: int,
+    oss_timeout_seconds: float,
+    oss_send_referer: bool,
+) -> dict[str, Any]:
+    output_dir.mkdir(parents=True, exist_ok=True)
+    result = {
+        "created_at": time.strftime("%Y-%m-%d %H:%M:%S"),
+        "queries": queries,
+        "platforms": {},
+    }
+    for platform in ["kuaishou", "shipinhao"]:
+        print(f"[probe] platform={platform} start", flush=True)
+        result["platforms"][platform] = inspect_platform(
+            platform,
+            queries,
+            output_dir=output_dir,
+            items_per_query=items_per_query,
+            bytes_to_probe=bytes_to_probe,
+            kuaishou_detail_limit_per_query=kuaishou_detail_limit_per_query,
+            probe_oss_enabled=probe_oss_enabled,
+            oss_limit=oss_limit_per_platform,
+            oss_timeout_seconds=oss_timeout_seconds,
+            oss_send_referer=oss_send_referer,
+        )
+        print(f"[probe] platform={platform} done", flush=True)
+        time.sleep(15)
+    return result
+
+
+def inspect_platform(
+    platform: str,
+    queries: list[str],
+    *,
+    output_dir: Path,
+    items_per_query: int,
+    bytes_to_probe: int,
+    kuaishou_detail_limit_per_query: int,
+    probe_oss_enabled: bool,
+    oss_limit: int,
+    oss_timeout_seconds: float,
+    oss_send_referer: bool,
+) -> dict[str, Any]:
+    run = {
+        "platform": platform,
+        "searches": [],
+        "items": [],
+        "selected": [],
+        "summary": {},
+    }
+    seen_content_ids: set[str] = set()
+    with httpx.Client() as client:
+        for query_index, query in enumerate(queries, start=1):
+            print(f"[probe] {platform} query={query_index}/{len(queries)} page=1 keyword={query}", flush=True)
+            search_result = fetch_search(platform, query, cursor="")
+            raw_search_path = output_dir / f"{platform}_q{query_index:02d}_p1_raw_search.json"
+            raw_search_path.write_text(json.dumps(search_result, ensure_ascii=False, indent=2), encoding="utf-8")
+            run["searches"].append(search_summary(query, 1, search_result))
+            items = _items_from_search(search_result)
+            print(f"[probe] {platform} query={query_index} page=1 items={len(items)}", flush=True)
+            inspect_items(
+                platform,
+                query,
+                1,
+                items[:items_per_query],
+                seen_content_ids,
+                output_dir,
+                run,
+                client,
+                bytes_to_probe,
+                kuaishou_detail_limit_per_query,
+            )
+            if platform == "shipinhao":
+                cursor = _next_cursor(search_result)
+                if cursor:
+                    time.sleep(15)
+                    print(f"[probe] {platform} query={query_index}/{len(queries)} page=2 keyword={query}", flush=True)
+                    page2 = fetch_search(platform, query, cursor=cursor)
+                    (output_dir / f"{platform}_q{query_index:02d}_p2_raw_search.json").write_text(
+                        json.dumps(page2, ensure_ascii=False, indent=2),
+                        encoding="utf-8",
+                    )
+                    run["searches"].append(search_summary(query, 2, page2))
+                    print(f"[probe] {platform} query={query_index} page=2 items={len(_items_from_search(page2))}", flush=True)
+                    inspect_items(
+                        platform,
+                        query,
+                        2,
+                        _items_from_search(page2)[: max(1, items_per_query // 2)],
+                        seen_content_ids,
+                        output_dir,
+                        run,
+                        client,
+                        bytes_to_probe,
+                        kuaishou_detail_limit_per_query,
+                    )
+            if query_index < len(queries):
+                time.sleep(15)
+        selected = [item for item in run["items"] if item.get("selected")]
+        for oss_index, item in enumerate(selected[:oss_limit], start=1):
+            if probe_oss_enabled:
+                print(f"[probe] {platform} oss={oss_index}/{min(len(selected), oss_limit)} content_id={item.get('content_id')}", flush=True)
+                probe_selected_oss(item, platform, client, oss_timeout_seconds, send_referer=oss_send_referer)
+            run["selected"].append(item)
+    run["summary"] = summarize_platform(run)
+    return run
+
+
+def inspect_items(
+    platform: str,
+    query: str,
+    page: int,
+    items: list[dict[str, Any]],
+    seen_content_ids: set[str],
+    output_dir: Path,
+    run: dict[str, Any],
+    client: httpx.Client,
+    bytes_to_probe: int,
+    kuaishou_detail_limit_per_query: int,
+) -> None:
+    for index, item in enumerate(items, start=1):
+        content_id = str(item.get("channel_content_id") or f"{query}_{page}_{index}")
+        if content_id in seen_content_ids:
+            continue
+        seen_content_ids.add(content_id)
+        raw_sources = [{"source": "search", "payload": item}]
+        if platform == "kuaishou" and content_id and index <= kuaishou_detail_limit_per_query:
+            time.sleep(15)
+            detail = fetch_kuaishou_detail(content_id)
+            (output_dir / f"{platform}_{content_id}_detail.json").write_text(
+                json.dumps(detail, ensure_ascii=False, indent=2),
+                encoding="utf-8",
+            )
+            raw_sources.append({"source": "detail", "payload": _detail_item(detail)})
+        candidates = []
+        for source in raw_sources:
+            for candidate in find_url_candidates(source["payload"], prefix=f"$.{source['source']}"):
+                if candidate.url_kind == "video_candidate":
+                    probe_candidate(
+                        candidate,
+                        platform=platform,
+                        client=client,
+                        bytes_to_probe=bytes_to_probe,
+                        timeout_seconds=30.0,
+                    )
+                candidates.append(candidate)
+        candidates = unique_candidates(candidates)
+        selected = select_video_url(candidates, platform)
+        if selected:
+            selected.selected = True
+        run["items"].append(
+            {
+                "platform": platform,
+                "query": query,
+                "page": page,
+                "content_id": content_id,
+                "title": item.get("title") or item.get("body_text") or "",
+                "content_type": item.get("content_type"),
+                "raw_candidate_count": len(candidates),
+                "video_candidate_count": sum(1 for candidate in candidates if candidate.url_kind == "video_candidate"),
+                "selected": asdict(selected) if selected else None,
+                "candidates": [asdict(candidate) for candidate in candidates],
+            }
+        )
+
+
+def probe_selected_oss(
+    item: dict[str, Any],
+    platform: str,
+    client: httpx.Client,
+    timeout_seconds: float,
+    *,
+    send_referer: bool,
+) -> None:
+    selected = item.get("selected")
+    if not selected:
+        return
+    payload = {
+        "src_url": selected["url"],
+        "src_type": "video",
+        "use_proxy": True,
+    }
+    if send_referer:
+        payload["referer"] = download_headers(platform)
+    selected["oss_payload_mode"] = "with_referer_dict" if send_referer else "no_referer"
+    endpoint = os.environ.get("CONTENT_AGENT_OSS_UPLOAD_URL") or _load_project_env().get("CONTENT_AGENT_OSS_UPLOAD_URL") or "http://crawler-upload-v2.aiddit.com/crawler/oss/upload_stream"
+    started = time.monotonic()
+    try:
+        response = client.post(endpoint, json=payload, timeout=timeout_seconds)
+        elapsed = round(time.monotonic() - started, 3)
+        body = response.json()
+        oss_object = body.get("oss_object") if isinstance(body, dict) else None
+        selected["oss_status"] = f"http_{response.status_code}"
+        selected["oss_elapsed_seconds"] = elapsed
+        selected["oss_response_shape"] = response_shape(body)
+        selected["oss_url_present"] = bool(isinstance(oss_object, dict) and oss_object.get("cdn_url"))
+        if not selected["oss_url_present"]:
+            selected["oss_failure_type"] = "oss_upload_response_invalid"
+    except Exception as exc:  # noqa: BLE001
+        selected["oss_status"] = "exception"
+        selected["oss_failure_type"] = type(exc).__name__
+        selected["oss_elapsed_seconds"] = round(time.monotonic() - started, 3)
+
+
+def sanitize_report_payload(historical: dict[str, Any], real_probe: dict[str, Any]) -> dict[str, Any]:
+    return {
+        "historical": historical,
+        "real_probe": {
+            "created_at": real_probe.get("created_at"),
+            "queries": real_probe.get("queries"),
+            "platforms": {
+                platform: {
+                    "summary": data.get("summary", {}),
+                    "searches": data.get("searches", []),
+                    "selected": [sanitize_item(item) for item in data.get("selected", [])],
+                }
+                for platform, data in (real_probe.get("platforms") or {}).items()
+            },
+        },
+    }
+
+
+def sanitize_item(item: dict[str, Any]) -> dict[str, Any]:
+    selected = item.get("selected") or {}
+    candidates = item.get("candidates") or []
+    return {
+        "platform": item.get("platform"),
+        "query": item.get("query"),
+        "page": item.get("page"),
+        "content_id": item.get("content_id"),
+        "title_sample": _short(item.get("title") or "", 80),
+        "content_type": item.get("content_type"),
+        "raw_candidate_count": item.get("raw_candidate_count"),
+        "video_candidate_count": item.get("video_candidate_count"),
+        "candidate_kind_counts": dict(Counter(candidate.get("url_kind") for candidate in candidates)),
+        "candidate_hosts": dict(Counter(candidate.get("host") for candidate in candidates if candidate.get("host"))),
+        "selected_path": selected.get("path"),
+        "selected_host": selected.get("host"),
+        "selected_http_status": selected.get("http_status"),
+        "selected_content_type": selected.get("content_type"),
+        "selected_content_range": selected.get("content_range"),
+        "selected_looks_like_video": selected.get("looks_like_video"),
+        "selected_ffprobe_ok": selected.get("ffprobe_ok"),
+        "selected_duration_seconds": selected.get("duration_seconds"),
+        "oss_status": selected.get("oss_status"),
+        "oss_failure_type": selected.get("oss_failure_type"),
+        "oss_url_present": selected.get("oss_url_present"),
+        "oss_elapsed_seconds": selected.get("oss_elapsed_seconds"),
+        "oss_response_shape": selected.get("oss_response_shape"),
+        "oss_payload_mode": selected.get("oss_payload_mode"),
+    }
+
+
+def render_markdown_report(payload: dict[str, Any]) -> str:
+    lines = [
+        "# 快手/视频号 URL 解包与 OSS 批量验证报告",
+        "",
+        f"生成时间:{time.strftime('%Y-%m-%d %H:%M:%S')}",
+        "",
+        "## 1. 历史 run 对照",
+        "",
+    ]
+    for platform, audit in payload["historical"].items():
+        lines.extend([
+            f"### {platform}",
+            f"- run_id: `{audit['run_id']}`",
+            f"- decision_action: `{audit['counts']['decision_action']}`",
+            f"- media_status: `{audit['counts']['media_status']}`",
+            f"- oss_error: `{audit['counts']['oss_error']}`",
+            f"- play_url_host: `{audit['counts']['play_url_host']}`",
+            "- 字段路径反查:当前历史 runtime 的 `platform_raw_payload` 只保留 content/account id,不能完整反查 URL 原始字段路径。",
+            "",
+        ])
+    lines.extend(["## 2. 真实接口探测与 selected URL OSS 验证", ""])
+    for platform, data in payload["real_probe"]["platforms"].items():
+        summary = data["summary"]
+        lines.extend([
+            f"### {platform}",
+            f"- search_count: `{summary.get('search_count')}`",
+            f"- item_count: `{summary.get('item_count')}`",
+            f"- selected_count: `{summary.get('selected_count')}`",
+            f"- url_kind_counts: `{summary.get('url_kind_counts')}`",
+            f"- selected_hosts: `{summary.get('selected_hosts')}`",
+            f"- oss_counts: `{summary.get('oss_counts')}`",
+            f"- oss_failure_counts: `{summary.get('oss_failure_counts')}`",
+            "",
+            "| content_id | query | selected_path | host | http | content_type | range | oss | failure |",
+            "|---|---|---|---|---:|---|---|---|---|",
+        ])
+        for item in data.get("selected", [])[:30]:
+            lines.append(
+                "| {content_id} | {query} | `{path}` | `{host}` | {http} | `{ctype}` | `{range_}` | `{oss}` | `{failure}` |".format(
+                    content_id=item.get("content_id") or "",
+                    query=_short(item.get("query") or "", 20),
+                    path=item.get("selected_path") or "",
+                    host=item.get("selected_host") or "",
+                    http=item.get("selected_http_status") or "",
+                    ctype=item.get("selected_content_type") or "",
+                    range_=item.get("selected_content_range") or "",
+                    oss=item.get("oss_status") or "",
+                    failure=item.get("oss_failure_type") or "",
+                )
+            )
+        lines.append("")
+    lines.extend([
+        "## 3. 结论口径",
+        "",
+        "- 快手/视频号 URL 解包不能再把 `video_url_list[0]` 当作唯一事实,应先递归分类 URL 候选。",
+        "- 广告/素材、图片/封面、头像、BGM/音频、页面链接必须从正片候选中排除;视频号也按同样规则检查广告 MP4。",
+        "- 如果 selected URL 轻量验证通过但 OSS invalid,应先归因为 OSS 转存兼容/响应结构问题,而不是直接判定 URL 选错。",
+        "- 历史 run 里的 raw payload 不足以完整追溯字段路径,后续生产运行应保存 URL 候选摘要到 raw_payload。",
+        "",
+    ])
+    return "\n".join(lines)
+
+
+def summarize_platform(run: dict[str, Any]) -> dict[str, Any]:
+    items = run["items"]
+    candidates = [candidate for item in items for candidate in item.get("candidates", [])]
+    selected = [item.get("selected") for item in items if item.get("selected")]
+    return {
+        "search_count": len(run["searches"]),
+        "item_count": len(items),
+        "selected_count": len(selected),
+        "url_kind_counts": dict(Counter(candidate.get("url_kind") for candidate in candidates)),
+        "selected_hosts": dict(Counter(item.get("host") for item in selected if item.get("host"))),
+        "selected_paths": dict(Counter(item.get("path") for item in selected if item.get("path"))),
+        "oss_counts": dict(Counter(item.get("oss_status") for item in selected if item.get("oss_status"))),
+        "oss_failure_counts": dict(Counter(item.get("oss_failure_type") for item in selected if item.get("oss_failure_type"))),
+    }
+
+
+def unique_candidates(candidates: list[UrlCandidate]) -> list[UrlCandidate]:
+    seen: set[str] = set()
+    result = []
+    for candidate in candidates:
+        if candidate.url in seen:
+            continue
+        seen.add(candidate.url)
+        result.append(candidate)
+    return result
+
+
+def fetch_search(platform: str, query: str, *, cursor: str = "") -> dict[str, Any]:
+    base_url = _required_env("CONTENTFIND_API_CRAWAPI_BASE_URL")
+    path = "/crawler/kuai_shou/keyword_v2" if platform == "kuaishou" else "/crawler/shi_pin_hao/keyword"
+    payload = {"keyword": query}
+    if platform == "shipinhao":
+        payload["cursor"] = cursor
+    retry_events = []
+    attempts = 3 if platform == "shipinhao" else 1
+    with httpx.Client() as client:
+        for attempt in range(1, attempts + 1):
+            response = client.post(urljoin(base_url.rstrip("/") + "/", path.lstrip("/")), json=payload, timeout=60.0)
+            response.raise_for_status()
+            body = response.json()
+            if platform == "shipinhao" and body.get("code") == 25011 and attempt < attempts:
+                retry_events.append({"attempt": attempt, "code": body.get("code"), "msg": body.get("msg")})
+                time.sleep(float(attempt))
+                continue
+            if retry_events:
+                body["_retry_events"] = retry_events
+            return body
+    raise RuntimeError("unreachable fetch_search retry state")
+
+
+def fetch_kuaishou_detail(content_id: str) -> dict[str, Any]:
+    base_url = _required_env("CONTENTFIND_API_CRAWAPI_BASE_URL")
+    with httpx.Client() as client:
+        response = client.post(urljoin(base_url.rstrip("/") + "/", "crawler/kuai_shou/detail"), json={"content_id": content_id}, timeout=60.0)
+        response.raise_for_status()
+        return response.json()
+
+
+def search_summary(query: str, page: int, response: dict[str, Any]) -> dict[str, Any]:
+    block = response.get("data") if isinstance(response.get("data"), dict) else {}
+    return {
+        "query": query,
+        "page": page,
+        "code": response.get("code"),
+        "msg": response.get("msg"),
+        "item_count": len(block.get("data") if isinstance(block.get("data"), list) else []),
+        "has_more": block.get("has_more"),
+        "next_cursor_present": bool(block.get("next_cursor")),
+        "retry_events": response.get("_retry_events") or [],
+    }
+
+
+def download_headers(platform: str) -> dict[str, str]:
+    headers = {
+        "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/126 Safari/537.36",
+        "Accept": "*/*",
+    }
+    if platform == "shipinhao":
+        headers["Referer"] = "https://channels.weixin.qq.com/"
+    elif platform == "kuaishou":
+        headers["Referer"] = "https://www.kuaishou.com/"
+    return headers
+
+
+def looks_like_video(content: bytes, content_type: str) -> bool:
+    head = content[:512]
+    return "video" in content_type.lower() or b"ftyp" in head or b"moov" in head or b"mdat" in head
+
+
+def probe_ffprobe(candidate: UrlCandidate, content: bytes) -> None:
+    if not content:
+        return
+    tmp = Path("data/platform_url_probe/.tmp_probe.mp4")
+    tmp.parent.mkdir(parents=True, exist_ok=True)
+    try:
+        tmp.write_bytes(content)
+        completed = subprocess.run(
+            ["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height,duration", "-of", "json", str(tmp)],
+            check=False,
+            capture_output=True,
+            text=True,
+            timeout=10,
+        )
+        if completed.returncode != 0:
+            return
+        data = json.loads(completed.stdout or "{}")
+        stream = (data.get("streams") or [{}])[0]
+        candidate.ffprobe_ok = True
+        candidate.width = _int_or_none(stream.get("width"))
+        candidate.height = _int_or_none(stream.get("height"))
+        try:
+            candidate.duration_seconds = round(float(stream.get("duration")), 3)
+        except (TypeError, ValueError):
+            candidate.duration_seconds = None
+    except Exception:
+        return
+    finally:
+        try:
+            tmp.unlink()
+        except OSError:
+            pass
+
+
+def path_for_url(payload: Any, url: Any, prefix: str = "$") -> str:
+    if not url:
+        return ""
+    if isinstance(payload, dict):
+        for key, value in payload.items():
+            found = path_for_url(value, url, f"{prefix}.{key}")
+            if found:
+                return found
+    if isinstance(payload, list):
+        for index, value in enumerate(payload):
+            found = path_for_url(value, url, f"{prefix}[{index}]")
+            if found:
+                return found
+    if payload == url:
+        return prefix
+    return ""
+
+
+def has_full_raw_item(payload: dict[str, Any]) -> bool:
+    return any(key in payload for key in ["video_url_list", "image_url_list", "content_link", "bgm_data"])
+
+
+def response_shape(value: Any) -> str:
+    if not isinstance(value, dict):
+        return type(value).__name__
+    pieces = []
+    for key in sorted(value):
+        item = value[key]
+        pieces.append(f"{key}({','.join(sorted(item))})" if isinstance(item, dict) else str(key))
+    return " / ".join(pieces)
+
+
+def _items_from_search(response: dict[str, Any]) -> list[dict[str, Any]]:
+    block = response.get("data") if isinstance(response.get("data"), dict) else {}
+    items = block.get("data")
+    return items if isinstance(items, list) else []
+
+
+def _next_cursor(response: dict[str, Any]) -> str:
+    block = response.get("data") if isinstance(response.get("data"), dict) else {}
+    return str(block.get("next_cursor") or "")
+
+
+def _detail_item(response: dict[str, Any]) -> dict[str, Any]:
+    block = response.get("data") if isinstance(response.get("data"), dict) else {}
+    item = block.get("data", block) if isinstance(block, dict) else {}
+    return item if isinstance(item, dict) else {}
+
+
+def _read_jsonl(path: Path) -> list[dict[str, Any]]:
+    if not path.exists():
+        return []
+    return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()]
+
+
+def _load_project_env(env_file: str | Path = ".env") -> dict[str, str]:
+    path = Path(env_file)
+    if not path.exists():
+        return {}
+    env: dict[str, str] = {}
+    for line in path.read_text(encoding="utf-8").splitlines():
+        stripped = line.strip()
+        if not stripped or stripped.startswith("#") or "=" not in stripped:
+            continue
+        key, value = stripped.split("=", 1)
+        env[key.strip()] = value.strip().strip('"').strip("'")
+    return env
+
+
+def _required_env(key: str) -> str:
+    value = os.environ.get(key) or _load_project_env().get(key)
+    if not value:
+        raise RuntimeError(f"missing required env: {key}")
+    return value
+
+
+def _int_or_none(value: Any) -> int | None:
+    try:
+        return int(value)
+    except (TypeError, ValueError):
+        return None
+
+
+def _ms_to_seconds(value: Any) -> float | None:
+    try:
+        return round(float(value) / 1000.0, 3)
+    except (TypeError, ValueError):
+        return None
+
+
+def _short(value: str, limit: int) -> str:
+    return value if len(value) <= limit else value[: limit - 1] + "…"
+
+
+def test_find_url_candidates_classifies_video_image_audio_page_and_ad():
+    payload = {
+        "video_url_list": [{"video_url": "https://v.example/a.mp4?x=1"}],
+        "image_url_list": [{"image_url": "https://img.example/a.jpg"}],
+        "bgm_data": {"play_url": "https://audio.example/a.m4a"},
+        "content_link": "https://page.example/item",
+        "ads": {"video_url": "https://ad.example/ad.mp4"},
+    }
+
+    by_url = {item.url: item for item in find_url_candidates(payload)}
+
+    assert by_url["https://v.example/a.mp4?x=1"].url_kind == "video_candidate"
+    assert by_url["https://img.example/a.jpg"].url_kind == "image"
+    assert by_url["https://audio.example/a.m4a"].url_kind == "audio"
+    assert by_url["https://page.example/item"].url_kind == "page"
+    assert by_url["https://ad.example/ad.mp4"].url_kind == "ad_or_material"
+
+
+def test_select_video_url_ignores_ad_mp4_and_prefers_verified_video():
+    ad = UrlCandidate("$.ads.video_url", "https://ad.example/ad.mp4", "ad.example", "ad_or_material", "ad_or_material_path", http_status=200, content_type="video/mp4", looks_like_video=True)
+    video = UrlCandidate("$.search.video_url_list[0].video_url", "https://v.example/a.mp4", "v.example", "video_candidate", "", http_status=206, content_type="video/mp4", looks_like_video=True)
+
+    assert select_video_url([ad, video], "kuaishou") is video
+
+
+def test_select_video_url_prefers_kuaishou_detail_over_search():
+    search = UrlCandidate("$.search.video_url_list[0].video_url", "https://v.kwaicdn.test/search.mp4", "v.kwaicdn.test", "video_candidate", "", http_status=206, content_type="video/mp4", looks_like_video=True)
+    detail = UrlCandidate("$.detail.video_url_list[0].video_url", "https://v.kwaicdn.test/detail.mp4", "v.kwaicdn.test", "video_candidate", "", http_status=206, content_type="video/mp4", looks_like_video=True)
+
+    assert select_video_url([search, detail], "kuaishou") is detail
+
+
+def test_ad_classifier_does_not_match_download_or_head_substrings():
+    kind, reject = classify_url_candidate(
+        "$.search.video_url_list[0].video_url",
+        "https://findermp.video.qq.com/251/20304/stodownload?head=1&token=abc",
+    )
+
+    assert kind == "video_candidate"
+    assert reject == ""
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser()
+    parser.add_argument("--real-probe", action="store_true")
+    parser.add_argument("--probe-oss", action="store_true")
+    parser.add_argument("--queries", default=",".join(DEFAULT_QUERIES))
+    parser.add_argument("--items-per-query", type=int, default=4)
+    parser.add_argument("--kuaishou-detail-limit-per-query", type=int, default=3)
+    parser.add_argument("--oss-limit-per-platform", type=int, default=20)
+    parser.add_argument("--oss-timeout-seconds", type=float, default=180.0)
+    parser.add_argument("--oss-send-referer", action="store_true")
+    parser.add_argument("--bytes", type=int, default=1024 * 1024)
+    parser.add_argument("--output-dir", default="")
+    args = parser.parse_args()
+
+    timestamp = time.strftime("%Y%m%d_%H%M%S")
+    output_dir = Path(args.output_dir or f"data/platform_url_probe/{timestamp}")
+    historical = run_historical_audit(Path("runtime/v1"))
+    if args.real_probe:
+        queries = [item.strip() for item in args.queries.split(",") if item.strip()]
+        real_probe = inspect_real_platforms(
+            queries=queries,
+            output_dir=output_dir,
+            items_per_query=args.items_per_query,
+            bytes_to_probe=args.bytes,
+            kuaishou_detail_limit_per_query=args.kuaishou_detail_limit_per_query,
+            probe_oss_enabled=args.probe_oss,
+            oss_limit_per_platform=args.oss_limit_per_platform,
+            oss_timeout_seconds=args.oss_timeout_seconds,
+            oss_send_referer=args.oss_send_referer,
+        )
+    else:
+        real_probe = {"created_at": time.strftime("%Y-%m-%d %H:%M:%S"), "queries": [], "platforms": {}}
+    payload = sanitize_report_payload(historical, real_probe)
+    output_dir.mkdir(parents=True, exist_ok=True)
+    (output_dir / "historical_audit.json").write_text(json.dumps(historical, ensure_ascii=False, indent=2), encoding="utf-8")
+    (output_dir / "sanitized_report.json").write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
+    report = render_markdown_report(payload)
+    (output_dir / "sanitized_report.md").write_text(report, encoding="utf-8")
+    print(report)
+
+
+if __name__ == "__main__":
+    main()

+ 78 - 0
tests/test_shipinhao_client.py

@@ -80,6 +80,84 @@ def test_shipinhao_search_maps_canonical_fields():
     assert result["next_cursor"] == "12"
 
 
+def test_shipinhao_search_falls_back_to_title_research_for_same_content_id():
+    first = {
+        "code": 0,
+        "data": {
+            "data": [
+                {
+                    "channel_content_id": "finderobj_need_retry",
+                    "title": "当狗唱dj #心墙",
+                    "content_type": "video",
+                    "image_url_list": [{"image_url": "https://findermp.video.qq.com/cover.jpg"}],
+                    "channel_account_id": "acc_123",
+                    "channel_account_name": "掌上巴彦淖尔",
+                }
+            ]
+        },
+    }
+    second = {
+        "code": 0,
+        "data": {
+            "data": [
+                {
+                    "channel_content_id": "finderobj_need_retry",
+                    "title": "当狗唱dj #心墙",
+                    "content_type": "video",
+                    "video_url_list": [{"video_url": "https://findermp.video.qq.com/video.mp4"}],
+                    "channel_account_id": "acc_123",
+                    "channel_account_name": "掌上巴彦淖尔",
+                }
+            ]
+        },
+    }
+    client, _ = _client([_response(200, first), _response(200, second)])
+
+    result = client.search(_query())[0]
+
+    assert client.http_client.requests[1]["json"] == {"keyword": "当狗唱dj #心墙", "cursor": ""}
+    assert result["play_url"] == "https://findermp.video.qq.com/video.mp4"
+    assert result["platform_raw_payload"]["selected_video_url_path"] == "$.research.video_url_list[0].video_url"
+    assert result["platform_raw_payload"]["shipinhao_research_status"] == "used"
+    assert "media_failure_reason" not in result
+
+
+def test_shipinhao_search_keeps_no_valid_when_title_research_does_not_match():
+    first = {
+        "code": 0,
+        "data": {
+            "data": [
+                {
+                    "channel_content_id": "finderobj_need_retry",
+                    "title": "当狗唱dj #心墙",
+                    "content_type": "video",
+                    "image_url_list": [{"image_url": "https://findermp.video.qq.com/cover.jpg"}],
+                }
+            ]
+        },
+    }
+    second = {
+        "code": 0,
+        "data": {
+            "data": [
+                {
+                    "channel_content_id": "finderobj_other",
+                    "title": "当狗唱dj #心墙",
+                    "content_type": "video",
+                    "video_url_list": [{"video_url": "https://findermp.video.qq.com/video.mp4"}],
+                }
+            ]
+        },
+    }
+    client, _ = _client([_response(200, first), _response(200, second)])
+
+    result = client.search(_query())[0]
+
+    assert result["play_url"] is None
+    assert result["media_failure_reason"] == "no_valid_play_url"
+    assert result["platform_raw_payload"]["shipinhao_research_status"] == "not_matched"
+
+
 def test_shipinhao_search_default_limit_is_five():
     items = [
         {

+ 10 - 1
tests/test_v4_m3_scoring_replay.py

@@ -53,10 +53,16 @@ def test_v4_m3_scoring_replay_produces_v4_runtime_contract(tmp_path):
 
 
 def test_v4_m3_scoring_replay_routes_technical_failure_to_retry(tmp_path):
+    failed_result = fake_gemini_fail()
+    failed_result["response_body_summary"] = {
+        "http_status_code": 502,
+        "json_top_level_keys": ["error"],
+        "text_excerpt": "Provider returned error",
+    }
     artifacts = replay_case(
         "real_id45",
         runtime_root=tmp_path / "runtime",
-        gemini_video_client=FakeGeminiVideoClient(default_result=fake_gemini_fail()),
+        gemini_video_client=FakeGeminiVideoClient(default_result=failed_result),
     )
 
     assert artifacts.summary["pooled_content_count"] == 0
@@ -67,6 +73,9 @@ def test_v4_m3_scoring_replay_routes_technical_failure_to_retry(tmp_path):
     assert {d["decision_action"] for d in artifacts.decisions} == {"TECHNICAL_RETRY_REQUIRED"}
     assert all(d["decision_replay_data"]["allow_walk"] is False for d in artifacts.decisions)
     assert all(d["scorecard"]["schema_version"] == "v4_scorecard.v1" for d in artifacts.decisions)
+    first_evidence = artifacts.files["pattern_recall_evidence.jsonl"][0]
+    assert first_evidence["evidence_summary"]["response_body_summary"]["http_status_code"] == 502
+    assert first_evidence["raw_payload"]["response_body_summary"]["text_excerpt"] == "Provider returned error"
 
 
 def test_v4_m3_db_runtime_preserves_scoring_json_containers(tmp_path):