test_platform_video_url_candidates.py 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862
  1. """快手/视频号 URL 解包诊断工具。
  2. pytest 默认只跑离线单测。真实探测手动执行:
  3. .venv/bin/python tests/test_platform_video_url_candidates.py --real-probe --probe-oss
  4. 真实响应和完整临时 URL 只写入 data/platform_url_probe/,该目录不提交。
  5. 提交到文档的内容应使用脚本生成的 *_sanitized_report.md。
  6. """
  7. from __future__ import annotations
  8. import argparse
  9. import json
  10. import os
  11. import re
  12. import subprocess
  13. import time
  14. from collections import Counter, defaultdict
  15. from dataclasses import asdict, dataclass
  16. from pathlib import Path
  17. from typing import Any
  18. from urllib.parse import urljoin, urlparse
  19. import httpx
  20. KUAISHOU_RUN_ID = "v1_run_94448252f5cd"
  21. SHIPINHAO_RUN_ID = "v1_run_35f4f7ccd1cc"
  22. DEFAULT_QUERIES = [
  23. "怀旧经典歌曲",
  24. "歌词唱词",
  25. "歌词同步滚动呈现",
  26. "怀旧金曲",
  27. "歌词",
  28. "经典老歌",
  29. "世界杯主题曲",
  30. "音乐盘点",
  31. "热门歌曲",
  32. "视频剪辑歌词",
  33. ]
  34. VIDEO_HINT_RE = re.compile(r"(video|play|mp4|m3u8|media|download|src)", re.I)
  35. IMAGE_HINT_RE = re.compile(r"(image|cover|avatar|poster|thumbnail|thumb|qlogo|heif|jpg|jpeg|png|kvif)", re.I)
  36. AUDIO_HINT_RE = re.compile(r"(audio|bgm|music|m4a|mp3)", re.I)
  37. PAGE_HINT_RE = re.compile(r"(content_link|share_url|page_url|link)$", re.I)
  38. AD_HINT_RE = re.compile(
  39. r"(^|[._/\-\[\]&?=#])(?:ad|ads|advert|commercial|promotion|material|marketing|营销|广告|推广)($|[._/\-\[\]&?=#])",
  40. re.I,
  41. )
  42. VIDEO_EXT_RE = re.compile(r"\.(mp4|m3u8)(?:$|[?&#])", re.I)
  43. @dataclass
  44. class UrlCandidate:
  45. path: str
  46. url: str
  47. host: str
  48. url_kind: str
  49. reject_hint: str = ""
  50. http_status: int | None = None
  51. content_type: str = ""
  52. content_length: int | None = None
  53. content_range: str = ""
  54. range_bytes: int = 0
  55. looks_like_video: bool = False
  56. ffprobe_ok: bool = False
  57. duration_seconds: float | None = None
  58. width: int | None = None
  59. height: int | None = None
  60. selected: bool = False
  61. oss_status: str = ""
  62. oss_failure_type: str = ""
  63. oss_url_present: bool = False
  64. oss_elapsed_seconds: float | None = None
  65. oss_response_shape: str = ""
  66. def find_url_candidates(value: Any, *, prefix: str = "$") -> list[UrlCandidate]:
  67. candidates: list[UrlCandidate] = []
  68. if isinstance(value, dict):
  69. for key, item in value.items():
  70. candidates.extend(find_url_candidates(item, prefix=f"{prefix}.{key}"))
  71. elif isinstance(value, list):
  72. for index, item in enumerate(value):
  73. candidates.extend(find_url_candidates(item, prefix=f"{prefix}[{index}]"))
  74. elif isinstance(value, str) and value.startswith(("http://", "https://")):
  75. kind, reject = classify_url_candidate(prefix, value)
  76. candidates.append(
  77. UrlCandidate(
  78. path=prefix,
  79. url=value,
  80. host=urlparse(value).netloc,
  81. url_kind=kind,
  82. reject_hint=reject,
  83. )
  84. )
  85. return candidates
  86. def classify_url_candidate(path: str, url: str) -> tuple[str, str]:
  87. haystack = f"{path} {url}".lower()
  88. if AD_HINT_RE.search(haystack):
  89. return "ad_or_material", "ad_or_material_path"
  90. if AUDIO_HINT_RE.search(haystack):
  91. return "audio", "audio_or_bgm_path"
  92. if IMAGE_HINT_RE.search(haystack):
  93. return "image", "image_or_avatar_path"
  94. if PAGE_HINT_RE.search(path):
  95. return "page", "page_link_path"
  96. if VIDEO_EXT_RE.search(url) or VIDEO_HINT_RE.search(path):
  97. return "video_candidate", ""
  98. return "unknown", "weak_video_signal"
  99. def probe_candidate(
  100. candidate: UrlCandidate,
  101. *,
  102. platform: str,
  103. client: httpx.Client,
  104. bytes_to_probe: int,
  105. timeout_seconds: float,
  106. ) -> None:
  107. headers = download_headers(platform)
  108. headers["Range"] = f"bytes=0-{max(bytes_to_probe - 1, 0)}"
  109. try:
  110. response = client.get(candidate.url, headers=headers, follow_redirects=True, timeout=timeout_seconds)
  111. candidate.http_status = response.status_code
  112. candidate.content_type = response.headers.get("content-type", "")
  113. candidate.content_length = _int_or_none(response.headers.get("content-length"))
  114. candidate.content_range = response.headers.get("content-range", "")
  115. candidate.range_bytes = len(response.content or b"")
  116. candidate.looks_like_video = looks_like_video(response.content, candidate.content_type)
  117. probe_ffprobe(candidate, response.content)
  118. except Exception as exc: # noqa: BLE001 - diagnostic output should retain exception type.
  119. candidate.reject_hint = candidate.reject_hint or f"http_probe_failed:{type(exc).__name__}"
  120. def select_video_url(candidates: list[UrlCandidate], platform: str) -> UrlCandidate | None:
  121. viable = [
  122. item
  123. for item in candidates
  124. if item.url_kind == "video_candidate"
  125. and not item.reject_hint
  126. and item.http_status in {200, 206}
  127. and (item.looks_like_video or "video" in item.content_type.lower() or item.ffprobe_ok)
  128. ]
  129. if not viable:
  130. viable = [
  131. item
  132. for item in candidates
  133. if item.url_kind == "video_candidate" and not item.reject_hint
  134. ]
  135. if not viable:
  136. return None
  137. return sorted(viable, key=lambda item: selection_score(item, platform))[0]
  138. def selection_score(candidate: UrlCandidate, platform: str) -> tuple[Any, ...]:
  139. path = candidate.path
  140. detail_bonus = 0 if "detail" in path else 1
  141. preferred_host = 0
  142. if platform == "shipinhao":
  143. preferred_host = 0 if "findermp.video.qq.com" in candidate.host else 1
  144. if platform == "kuaishou":
  145. preferred_host = 0 if "kwaicdn" in candidate.host or "kwai" in candidate.host else 1
  146. return (
  147. detail_bonus,
  148. preferred_host,
  149. candidate.http_status not in {200, 206},
  150. not candidate.looks_like_video,
  151. not candidate.ffprobe_ok,
  152. path.count("."),
  153. path,
  154. )
  155. def run_historical_audit(runtime_root: Path) -> dict[str, Any]:
  156. return {
  157. "kuaishou": audit_run(runtime_root / KUAISHOU_RUN_ID),
  158. "shipinhao": audit_run(runtime_root / SHIPINHAO_RUN_ID),
  159. }
  160. def audit_run(run_dir: Path) -> dict[str, Any]:
  161. content = _read_jsonl(run_dir / "discovered_content_items.jsonl")
  162. media = _read_jsonl(run_dir / "content_media_records.jsonl")
  163. evidence = _read_jsonl(run_dir / "pattern_recall_evidence.jsonl")
  164. decisions = _read_jsonl(run_dir / "rule_decisions.jsonl")
  165. by_content_id = {row.get("content_discovery_id"): row for row in content}
  166. by_platform_id = {row.get("platform_content_id"): row for row in content}
  167. media_by_platform_id = {row.get("platform_content_id"): row for row in media}
  168. evidence_by_content_id = {row.get("content_discovery_id"): row for row in evidence}
  169. rows = []
  170. for decision in decisions:
  171. target = decision.get("decision_target_id")
  172. item = by_content_id.get(target) or by_platform_id.get(target) or {}
  173. platform_id = item.get("platform_content_id") or target
  174. media_row = media_by_platform_id.get(platform_id, {})
  175. evidence_row = evidence_by_content_id.get(item.get("content_discovery_id"), {})
  176. evidence_summary = evidence_row.get("evidence_summary") or {}
  177. evidence_raw = evidence_row.get("raw_payload") or {}
  178. timing = evidence_summary.get("timing_metrics") or evidence_raw.get("timing_metrics") or {}
  179. video_fetch = timing.get("video_fetch") or {}
  180. gemini_request = timing.get("gemini_request") or {}
  181. media_raw = media_row.get("raw_payload") or {}
  182. platform_raw = item.get("platform_raw_payload") or {}
  183. rows.append(
  184. {
  185. "platform_content_id": platform_id,
  186. "search_query_id": item.get("search_query_id"),
  187. "decision_action": decision.get("decision_action"),
  188. "play_url_host": urlparse(str(media_row.get("play_url") or "")).netloc,
  189. "play_url_path_found_in_platform_raw": path_for_url(platform_raw, media_row.get("play_url")),
  190. "platform_raw_key_count": len(platform_raw),
  191. "has_full_platform_raw": has_full_raw_item(platform_raw),
  192. "content_media_status": media_row.get("content_media_status"),
  193. "oss_url_present": bool(media_row.get("oss_url")),
  194. "oss_archive_last_error": media_raw.get("oss_archive_last_error") or media_raw.get("failure_reason"),
  195. "gemini_video_source": video_fetch.get("gemini_video_source"),
  196. "download_seconds": _ms_to_seconds(video_fetch.get("download_duration_ms")),
  197. "ffmpeg_seconds": _ms_to_seconds(video_fetch.get("ffmpeg_duration_ms")),
  198. "gemini_seconds": _ms_to_seconds(gemini_request.get("total_duration_ms")),
  199. "failure_type": evidence_summary.get("failure_type") or evidence_raw.get("failure_type"),
  200. }
  201. )
  202. return {
  203. "run_id": run_dir.name,
  204. "rows": rows,
  205. "counts": {
  206. "decision_action": dict(Counter(row["decision_action"] for row in rows)),
  207. "media_status": dict(Counter(row["content_media_status"] for row in rows)),
  208. "oss_error": dict(Counter(row["oss_archive_last_error"] for row in rows if row["oss_archive_last_error"])),
  209. "play_url_host": dict(Counter(row["play_url_host"] for row in rows if row["play_url_host"])),
  210. },
  211. }
  212. def inspect_real_platforms(
  213. *,
  214. queries: list[str],
  215. output_dir: Path,
  216. items_per_query: int,
  217. bytes_to_probe: int,
  218. kuaishou_detail_limit_per_query: int,
  219. probe_oss_enabled: bool,
  220. oss_limit_per_platform: int,
  221. oss_timeout_seconds: float,
  222. oss_send_referer: bool,
  223. ) -> dict[str, Any]:
  224. output_dir.mkdir(parents=True, exist_ok=True)
  225. result = {
  226. "created_at": time.strftime("%Y-%m-%d %H:%M:%S"),
  227. "queries": queries,
  228. "platforms": {},
  229. }
  230. for platform in ["kuaishou", "shipinhao"]:
  231. print(f"[probe] platform={platform} start", flush=True)
  232. result["platforms"][platform] = inspect_platform(
  233. platform,
  234. queries,
  235. output_dir=output_dir,
  236. items_per_query=items_per_query,
  237. bytes_to_probe=bytes_to_probe,
  238. kuaishou_detail_limit_per_query=kuaishou_detail_limit_per_query,
  239. probe_oss_enabled=probe_oss_enabled,
  240. oss_limit=oss_limit_per_platform,
  241. oss_timeout_seconds=oss_timeout_seconds,
  242. oss_send_referer=oss_send_referer,
  243. )
  244. print(f"[probe] platform={platform} done", flush=True)
  245. time.sleep(15)
  246. return result
  247. def inspect_platform(
  248. platform: str,
  249. queries: list[str],
  250. *,
  251. output_dir: Path,
  252. items_per_query: int,
  253. bytes_to_probe: int,
  254. kuaishou_detail_limit_per_query: int,
  255. probe_oss_enabled: bool,
  256. oss_limit: int,
  257. oss_timeout_seconds: float,
  258. oss_send_referer: bool,
  259. ) -> dict[str, Any]:
  260. run = {
  261. "platform": platform,
  262. "searches": [],
  263. "items": [],
  264. "selected": [],
  265. "summary": {},
  266. }
  267. seen_content_ids: set[str] = set()
  268. with httpx.Client() as client:
  269. for query_index, query in enumerate(queries, start=1):
  270. print(f"[probe] {platform} query={query_index}/{len(queries)} page=1 keyword={query}", flush=True)
  271. search_result = fetch_search(platform, query, cursor="")
  272. raw_search_path = output_dir / f"{platform}_q{query_index:02d}_p1_raw_search.json"
  273. raw_search_path.write_text(json.dumps(search_result, ensure_ascii=False, indent=2), encoding="utf-8")
  274. run["searches"].append(search_summary(query, 1, search_result))
  275. items = _items_from_search(search_result)
  276. print(f"[probe] {platform} query={query_index} page=1 items={len(items)}", flush=True)
  277. inspect_items(
  278. platform,
  279. query,
  280. 1,
  281. items[:items_per_query],
  282. seen_content_ids,
  283. output_dir,
  284. run,
  285. client,
  286. bytes_to_probe,
  287. kuaishou_detail_limit_per_query,
  288. )
  289. if platform == "shipinhao":
  290. cursor = _next_cursor(search_result)
  291. if cursor:
  292. time.sleep(15)
  293. print(f"[probe] {platform} query={query_index}/{len(queries)} page=2 keyword={query}", flush=True)
  294. page2 = fetch_search(platform, query, cursor=cursor)
  295. (output_dir / f"{platform}_q{query_index:02d}_p2_raw_search.json").write_text(
  296. json.dumps(page2, ensure_ascii=False, indent=2),
  297. encoding="utf-8",
  298. )
  299. run["searches"].append(search_summary(query, 2, page2))
  300. print(f"[probe] {platform} query={query_index} page=2 items={len(_items_from_search(page2))}", flush=True)
  301. inspect_items(
  302. platform,
  303. query,
  304. 2,
  305. _items_from_search(page2)[: max(1, items_per_query // 2)],
  306. seen_content_ids,
  307. output_dir,
  308. run,
  309. client,
  310. bytes_to_probe,
  311. kuaishou_detail_limit_per_query,
  312. )
  313. if query_index < len(queries):
  314. time.sleep(15)
  315. selected = [item for item in run["items"] if item.get("selected")]
  316. for oss_index, item in enumerate(selected[:oss_limit], start=1):
  317. if probe_oss_enabled:
  318. print(f"[probe] {platform} oss={oss_index}/{min(len(selected), oss_limit)} content_id={item.get('content_id')}", flush=True)
  319. probe_selected_oss(item, platform, client, oss_timeout_seconds, send_referer=oss_send_referer)
  320. run["selected"].append(item)
  321. run["summary"] = summarize_platform(run)
  322. return run
  323. def inspect_items(
  324. platform: str,
  325. query: str,
  326. page: int,
  327. items: list[dict[str, Any]],
  328. seen_content_ids: set[str],
  329. output_dir: Path,
  330. run: dict[str, Any],
  331. client: httpx.Client,
  332. bytes_to_probe: int,
  333. kuaishou_detail_limit_per_query: int,
  334. ) -> None:
  335. for index, item in enumerate(items, start=1):
  336. content_id = str(item.get("channel_content_id") or f"{query}_{page}_{index}")
  337. if content_id in seen_content_ids:
  338. continue
  339. seen_content_ids.add(content_id)
  340. raw_sources = [{"source": "search", "payload": item}]
  341. if platform == "kuaishou" and content_id and index <= kuaishou_detail_limit_per_query:
  342. time.sleep(15)
  343. detail = fetch_kuaishou_detail(content_id)
  344. (output_dir / f"{platform}_{content_id}_detail.json").write_text(
  345. json.dumps(detail, ensure_ascii=False, indent=2),
  346. encoding="utf-8",
  347. )
  348. raw_sources.append({"source": "detail", "payload": _detail_item(detail)})
  349. candidates = []
  350. for source in raw_sources:
  351. for candidate in find_url_candidates(source["payload"], prefix=f"$.{source['source']}"):
  352. if candidate.url_kind == "video_candidate":
  353. probe_candidate(
  354. candidate,
  355. platform=platform,
  356. client=client,
  357. bytes_to_probe=bytes_to_probe,
  358. timeout_seconds=30.0,
  359. )
  360. candidates.append(candidate)
  361. candidates = unique_candidates(candidates)
  362. selected = select_video_url(candidates, platform)
  363. if selected:
  364. selected.selected = True
  365. run["items"].append(
  366. {
  367. "platform": platform,
  368. "query": query,
  369. "page": page,
  370. "content_id": content_id,
  371. "title": item.get("title") or item.get("body_text") or "",
  372. "content_type": item.get("content_type"),
  373. "raw_candidate_count": len(candidates),
  374. "video_candidate_count": sum(1 for candidate in candidates if candidate.url_kind == "video_candidate"),
  375. "selected": asdict(selected) if selected else None,
  376. "candidates": [asdict(candidate) for candidate in candidates],
  377. }
  378. )
  379. def probe_selected_oss(
  380. item: dict[str, Any],
  381. platform: str,
  382. client: httpx.Client,
  383. timeout_seconds: float,
  384. *,
  385. send_referer: bool,
  386. ) -> None:
  387. selected = item.get("selected")
  388. if not selected:
  389. return
  390. payload = {
  391. "src_url": selected["url"],
  392. "src_type": "video",
  393. "use_proxy": True,
  394. }
  395. if send_referer:
  396. payload["referer"] = download_headers(platform)
  397. selected["oss_payload_mode"] = "with_referer_dict" if send_referer else "no_referer"
  398. 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"
  399. started = time.monotonic()
  400. try:
  401. response = client.post(endpoint, json=payload, timeout=timeout_seconds)
  402. elapsed = round(time.monotonic() - started, 3)
  403. body = response.json()
  404. oss_object = body.get("oss_object") if isinstance(body, dict) else None
  405. selected["oss_status"] = f"http_{response.status_code}"
  406. selected["oss_elapsed_seconds"] = elapsed
  407. selected["oss_response_shape"] = response_shape(body)
  408. selected["oss_url_present"] = bool(isinstance(oss_object, dict) and oss_object.get("cdn_url"))
  409. if not selected["oss_url_present"]:
  410. selected["oss_failure_type"] = "oss_upload_response_invalid"
  411. except Exception as exc: # noqa: BLE001
  412. selected["oss_status"] = "exception"
  413. selected["oss_failure_type"] = type(exc).__name__
  414. selected["oss_elapsed_seconds"] = round(time.monotonic() - started, 3)
  415. def sanitize_report_payload(historical: dict[str, Any], real_probe: dict[str, Any]) -> dict[str, Any]:
  416. return {
  417. "historical": historical,
  418. "real_probe": {
  419. "created_at": real_probe.get("created_at"),
  420. "queries": real_probe.get("queries"),
  421. "platforms": {
  422. platform: {
  423. "summary": data.get("summary", {}),
  424. "searches": data.get("searches", []),
  425. "selected": [sanitize_item(item) for item in data.get("selected", [])],
  426. }
  427. for platform, data in (real_probe.get("platforms") or {}).items()
  428. },
  429. },
  430. }
  431. def sanitize_item(item: dict[str, Any]) -> dict[str, Any]:
  432. selected = item.get("selected") or {}
  433. candidates = item.get("candidates") or []
  434. return {
  435. "platform": item.get("platform"),
  436. "query": item.get("query"),
  437. "page": item.get("page"),
  438. "content_id": item.get("content_id"),
  439. "title_sample": _short(item.get("title") or "", 80),
  440. "content_type": item.get("content_type"),
  441. "raw_candidate_count": item.get("raw_candidate_count"),
  442. "video_candidate_count": item.get("video_candidate_count"),
  443. "candidate_kind_counts": dict(Counter(candidate.get("url_kind") for candidate in candidates)),
  444. "candidate_hosts": dict(Counter(candidate.get("host") for candidate in candidates if candidate.get("host"))),
  445. "selected_path": selected.get("path"),
  446. "selected_host": selected.get("host"),
  447. "selected_http_status": selected.get("http_status"),
  448. "selected_content_type": selected.get("content_type"),
  449. "selected_content_range": selected.get("content_range"),
  450. "selected_looks_like_video": selected.get("looks_like_video"),
  451. "selected_ffprobe_ok": selected.get("ffprobe_ok"),
  452. "selected_duration_seconds": selected.get("duration_seconds"),
  453. "oss_status": selected.get("oss_status"),
  454. "oss_failure_type": selected.get("oss_failure_type"),
  455. "oss_url_present": selected.get("oss_url_present"),
  456. "oss_elapsed_seconds": selected.get("oss_elapsed_seconds"),
  457. "oss_response_shape": selected.get("oss_response_shape"),
  458. "oss_payload_mode": selected.get("oss_payload_mode"),
  459. }
  460. def render_markdown_report(payload: dict[str, Any]) -> str:
  461. lines = [
  462. "# 快手/视频号 URL 解包与 OSS 批量验证报告",
  463. "",
  464. f"生成时间:{time.strftime('%Y-%m-%d %H:%M:%S')}",
  465. "",
  466. "## 1. 历史 run 对照",
  467. "",
  468. ]
  469. for platform, audit in payload["historical"].items():
  470. lines.extend([
  471. f"### {platform}",
  472. f"- run_id: `{audit['run_id']}`",
  473. f"- decision_action: `{audit['counts']['decision_action']}`",
  474. f"- media_status: `{audit['counts']['media_status']}`",
  475. f"- oss_error: `{audit['counts']['oss_error']}`",
  476. f"- play_url_host: `{audit['counts']['play_url_host']}`",
  477. "- 字段路径反查:当前历史 runtime 的 `platform_raw_payload` 只保留 content/account id,不能完整反查 URL 原始字段路径。",
  478. "",
  479. ])
  480. lines.extend(["## 2. 真实接口探测与 selected URL OSS 验证", ""])
  481. for platform, data in payload["real_probe"]["platforms"].items():
  482. summary = data["summary"]
  483. lines.extend([
  484. f"### {platform}",
  485. f"- search_count: `{summary.get('search_count')}`",
  486. f"- item_count: `{summary.get('item_count')}`",
  487. f"- selected_count: `{summary.get('selected_count')}`",
  488. f"- url_kind_counts: `{summary.get('url_kind_counts')}`",
  489. f"- selected_hosts: `{summary.get('selected_hosts')}`",
  490. f"- oss_counts: `{summary.get('oss_counts')}`",
  491. f"- oss_failure_counts: `{summary.get('oss_failure_counts')}`",
  492. "",
  493. "| content_id | query | selected_path | host | http | content_type | range | oss | failure |",
  494. "|---|---|---|---|---:|---|---|---|---|",
  495. ])
  496. for item in data.get("selected", [])[:30]:
  497. lines.append(
  498. "| {content_id} | {query} | `{path}` | `{host}` | {http} | `{ctype}` | `{range_}` | `{oss}` | `{failure}` |".format(
  499. content_id=item.get("content_id") or "",
  500. query=_short(item.get("query") or "", 20),
  501. path=item.get("selected_path") or "",
  502. host=item.get("selected_host") or "",
  503. http=item.get("selected_http_status") or "",
  504. ctype=item.get("selected_content_type") or "",
  505. range_=item.get("selected_content_range") or "",
  506. oss=item.get("oss_status") or "",
  507. failure=item.get("oss_failure_type") or "",
  508. )
  509. )
  510. lines.append("")
  511. lines.extend([
  512. "## 3. 结论口径",
  513. "",
  514. "- 快手/视频号 URL 解包不能再把 `video_url_list[0]` 当作唯一事实,应先递归分类 URL 候选。",
  515. "- 广告/素材、图片/封面、头像、BGM/音频、页面链接必须从正片候选中排除;视频号也按同样规则检查广告 MP4。",
  516. "- 如果 selected URL 轻量验证通过但 OSS invalid,应先归因为 OSS 转存兼容/响应结构问题,而不是直接判定 URL 选错。",
  517. "- 历史 run 里的 raw payload 不足以完整追溯字段路径,后续生产运行应保存 URL 候选摘要到 raw_payload。",
  518. "",
  519. ])
  520. return "\n".join(lines)
  521. def summarize_platform(run: dict[str, Any]) -> dict[str, Any]:
  522. items = run["items"]
  523. candidates = [candidate for item in items for candidate in item.get("candidates", [])]
  524. selected = [item.get("selected") for item in items if item.get("selected")]
  525. return {
  526. "search_count": len(run["searches"]),
  527. "item_count": len(items),
  528. "selected_count": len(selected),
  529. "url_kind_counts": dict(Counter(candidate.get("url_kind") for candidate in candidates)),
  530. "selected_hosts": dict(Counter(item.get("host") for item in selected if item.get("host"))),
  531. "selected_paths": dict(Counter(item.get("path") for item in selected if item.get("path"))),
  532. "oss_counts": dict(Counter(item.get("oss_status") for item in selected if item.get("oss_status"))),
  533. "oss_failure_counts": dict(Counter(item.get("oss_failure_type") for item in selected if item.get("oss_failure_type"))),
  534. }
  535. def unique_candidates(candidates: list[UrlCandidate]) -> list[UrlCandidate]:
  536. seen: set[str] = set()
  537. result = []
  538. for candidate in candidates:
  539. if candidate.url in seen:
  540. continue
  541. seen.add(candidate.url)
  542. result.append(candidate)
  543. return result
  544. def fetch_search(platform: str, query: str, *, cursor: str = "") -> dict[str, Any]:
  545. base_url = _required_env("CONTENTFIND_API_CRAWAPI_BASE_URL")
  546. path = "/crawler/kuai_shou/keyword_v2" if platform == "kuaishou" else "/crawler/shi_pin_hao/keyword"
  547. payload = {"keyword": query}
  548. if platform == "shipinhao":
  549. payload["cursor"] = cursor
  550. retry_events = []
  551. attempts = 3 if platform == "shipinhao" else 1
  552. with httpx.Client() as client:
  553. for attempt in range(1, attempts + 1):
  554. response = client.post(urljoin(base_url.rstrip("/") + "/", path.lstrip("/")), json=payload, timeout=60.0)
  555. response.raise_for_status()
  556. body = response.json()
  557. if platform == "shipinhao" and body.get("code") == 25011 and attempt < attempts:
  558. retry_events.append({"attempt": attempt, "code": body.get("code"), "msg": body.get("msg")})
  559. time.sleep(float(attempt))
  560. continue
  561. if retry_events:
  562. body["_retry_events"] = retry_events
  563. return body
  564. raise RuntimeError("unreachable fetch_search retry state")
  565. def fetch_kuaishou_detail(content_id: str) -> dict[str, Any]:
  566. base_url = _required_env("CONTENTFIND_API_CRAWAPI_BASE_URL")
  567. with httpx.Client() as client:
  568. response = client.post(urljoin(base_url.rstrip("/") + "/", "crawler/kuai_shou/detail"), json={"content_id": content_id}, timeout=60.0)
  569. response.raise_for_status()
  570. return response.json()
  571. def search_summary(query: str, page: int, response: dict[str, Any]) -> dict[str, Any]:
  572. block = response.get("data") if isinstance(response.get("data"), dict) else {}
  573. return {
  574. "query": query,
  575. "page": page,
  576. "code": response.get("code"),
  577. "msg": response.get("msg"),
  578. "item_count": len(block.get("data") if isinstance(block.get("data"), list) else []),
  579. "has_more": block.get("has_more"),
  580. "next_cursor_present": bool(block.get("next_cursor")),
  581. "retry_events": response.get("_retry_events") or [],
  582. }
  583. def download_headers(platform: str) -> dict[str, str]:
  584. headers = {
  585. "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/126 Safari/537.36",
  586. "Accept": "*/*",
  587. }
  588. if platform == "shipinhao":
  589. headers["Referer"] = "https://channels.weixin.qq.com/"
  590. elif platform == "kuaishou":
  591. headers["Referer"] = "https://www.kuaishou.com/"
  592. return headers
  593. def looks_like_video(content: bytes, content_type: str) -> bool:
  594. head = content[:512]
  595. return "video" in content_type.lower() or b"ftyp" in head or b"moov" in head or b"mdat" in head
  596. def probe_ffprobe(candidate: UrlCandidate, content: bytes) -> None:
  597. if not content:
  598. return
  599. tmp = Path("data/platform_url_probe/.tmp_probe.mp4")
  600. tmp.parent.mkdir(parents=True, exist_ok=True)
  601. try:
  602. tmp.write_bytes(content)
  603. completed = subprocess.run(
  604. ["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height,duration", "-of", "json", str(tmp)],
  605. check=False,
  606. capture_output=True,
  607. text=True,
  608. timeout=10,
  609. )
  610. if completed.returncode != 0:
  611. return
  612. data = json.loads(completed.stdout or "{}")
  613. stream = (data.get("streams") or [{}])[0]
  614. candidate.ffprobe_ok = True
  615. candidate.width = _int_or_none(stream.get("width"))
  616. candidate.height = _int_or_none(stream.get("height"))
  617. try:
  618. candidate.duration_seconds = round(float(stream.get("duration")), 3)
  619. except (TypeError, ValueError):
  620. candidate.duration_seconds = None
  621. except Exception:
  622. return
  623. finally:
  624. try:
  625. tmp.unlink()
  626. except OSError:
  627. pass
  628. def path_for_url(payload: Any, url: Any, prefix: str = "$") -> str:
  629. if not url:
  630. return ""
  631. if isinstance(payload, dict):
  632. for key, value in payload.items():
  633. found = path_for_url(value, url, f"{prefix}.{key}")
  634. if found:
  635. return found
  636. if isinstance(payload, list):
  637. for index, value in enumerate(payload):
  638. found = path_for_url(value, url, f"{prefix}[{index}]")
  639. if found:
  640. return found
  641. if payload == url:
  642. return prefix
  643. return ""
  644. def has_full_raw_item(payload: dict[str, Any]) -> bool:
  645. return any(key in payload for key in ["video_url_list", "image_url_list", "content_link", "bgm_data"])
  646. def response_shape(value: Any) -> str:
  647. if not isinstance(value, dict):
  648. return type(value).__name__
  649. pieces = []
  650. for key in sorted(value):
  651. item = value[key]
  652. pieces.append(f"{key}({','.join(sorted(item))})" if isinstance(item, dict) else str(key))
  653. return " / ".join(pieces)
  654. def _items_from_search(response: dict[str, Any]) -> list[dict[str, Any]]:
  655. block = response.get("data") if isinstance(response.get("data"), dict) else {}
  656. items = block.get("data")
  657. return items if isinstance(items, list) else []
  658. def _next_cursor(response: dict[str, Any]) -> str:
  659. block = response.get("data") if isinstance(response.get("data"), dict) else {}
  660. return str(block.get("next_cursor") or "")
  661. def _detail_item(response: dict[str, Any]) -> dict[str, Any]:
  662. block = response.get("data") if isinstance(response.get("data"), dict) else {}
  663. item = block.get("data", block) if isinstance(block, dict) else {}
  664. return item if isinstance(item, dict) else {}
  665. def _read_jsonl(path: Path) -> list[dict[str, Any]]:
  666. if not path.exists():
  667. return []
  668. return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()]
  669. def _load_project_env(env_file: str | Path = ".env") -> dict[str, str]:
  670. path = Path(env_file)
  671. if not path.exists():
  672. return {}
  673. env: dict[str, str] = {}
  674. for line in path.read_text(encoding="utf-8").splitlines():
  675. stripped = line.strip()
  676. if not stripped or stripped.startswith("#") or "=" not in stripped:
  677. continue
  678. key, value = stripped.split("=", 1)
  679. env[key.strip()] = value.strip().strip('"').strip("'")
  680. return env
  681. def _required_env(key: str) -> str:
  682. value = os.environ.get(key) or _load_project_env().get(key)
  683. if not value:
  684. raise RuntimeError(f"missing required env: {key}")
  685. return value
  686. def _int_or_none(value: Any) -> int | None:
  687. try:
  688. return int(value)
  689. except (TypeError, ValueError):
  690. return None
  691. def _ms_to_seconds(value: Any) -> float | None:
  692. try:
  693. return round(float(value) / 1000.0, 3)
  694. except (TypeError, ValueError):
  695. return None
  696. def _short(value: str, limit: int) -> str:
  697. return value if len(value) <= limit else value[: limit - 1] + "…"
  698. def test_find_url_candidates_classifies_video_image_audio_page_and_ad():
  699. payload = {
  700. "video_url_list": [{"video_url": "https://v.example/a.mp4?x=1"}],
  701. "image_url_list": [{"image_url": "https://img.example/a.jpg"}],
  702. "bgm_data": {"play_url": "https://audio.example/a.m4a"},
  703. "content_link": "https://page.example/item",
  704. "ads": {"video_url": "https://ad.example/ad.mp4"},
  705. }
  706. by_url = {item.url: item for item in find_url_candidates(payload)}
  707. assert by_url["https://v.example/a.mp4?x=1"].url_kind == "video_candidate"
  708. assert by_url["https://img.example/a.jpg"].url_kind == "image"
  709. assert by_url["https://audio.example/a.m4a"].url_kind == "audio"
  710. assert by_url["https://page.example/item"].url_kind == "page"
  711. assert by_url["https://ad.example/ad.mp4"].url_kind == "ad_or_material"
  712. def test_select_video_url_ignores_ad_mp4_and_prefers_verified_video():
  713. 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)
  714. 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)
  715. assert select_video_url([ad, video], "kuaishou") is video
  716. def test_select_video_url_prefers_kuaishou_detail_over_search():
  717. 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)
  718. 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)
  719. assert select_video_url([search, detail], "kuaishou") is detail
  720. def test_ad_classifier_does_not_match_download_or_head_substrings():
  721. kind, reject = classify_url_candidate(
  722. "$.search.video_url_list[0].video_url",
  723. "https://findermp.video.qq.com/251/20304/stodownload?head=1&token=abc",
  724. )
  725. assert kind == "video_candidate"
  726. assert reject == ""
  727. def main() -> None:
  728. parser = argparse.ArgumentParser()
  729. parser.add_argument("--real-probe", action="store_true")
  730. parser.add_argument("--probe-oss", action="store_true")
  731. parser.add_argument("--queries", default=",".join(DEFAULT_QUERIES))
  732. parser.add_argument("--items-per-query", type=int, default=4)
  733. parser.add_argument("--kuaishou-detail-limit-per-query", type=int, default=3)
  734. parser.add_argument("--oss-limit-per-platform", type=int, default=20)
  735. parser.add_argument("--oss-timeout-seconds", type=float, default=180.0)
  736. parser.add_argument("--oss-send-referer", action="store_true")
  737. parser.add_argument("--bytes", type=int, default=1024 * 1024)
  738. parser.add_argument("--output-dir", default="")
  739. args = parser.parse_args()
  740. timestamp = time.strftime("%Y%m%d_%H%M%S")
  741. output_dir = Path(args.output_dir or f"data/platform_url_probe/{timestamp}")
  742. historical = run_historical_audit(Path("runtime/v1"))
  743. if args.real_probe:
  744. queries = [item.strip() for item in args.queries.split(",") if item.strip()]
  745. real_probe = inspect_real_platforms(
  746. queries=queries,
  747. output_dir=output_dir,
  748. items_per_query=args.items_per_query,
  749. bytes_to_probe=args.bytes,
  750. kuaishou_detail_limit_per_query=args.kuaishou_detail_limit_per_query,
  751. probe_oss_enabled=args.probe_oss,
  752. oss_limit_per_platform=args.oss_limit_per_platform,
  753. oss_timeout_seconds=args.oss_timeout_seconds,
  754. oss_send_referer=args.oss_send_referer,
  755. )
  756. else:
  757. real_probe = {"created_at": time.strftime("%Y-%m-%d %H:%M:%S"), "queries": [], "platforms": {}}
  758. payload = sanitize_report_payload(historical, real_probe)
  759. output_dir.mkdir(parents=True, exist_ok=True)
  760. (output_dir / "historical_audit.json").write_text(json.dumps(historical, ensure_ascii=False, indent=2), encoding="utf-8")
  761. (output_dir / "sanitized_report.json").write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
  762. report = render_markdown_report(payload)
  763. (output_dir / "sanitized_report.md").write_text(report, encoding="utf-8")
  764. print(report)
  765. if __name__ == "__main__":
  766. main()