platform_video_url.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327
  1. """Platform video URL candidate selection.
  2. Keeps full temporary URLs out of runtime diagnostics while preserving enough
  3. field-path/probe evidence to debug no-play-url cases.
  4. """
  5. from __future__ import annotations
  6. import re
  7. from collections import Counter
  8. from dataclasses import dataclass
  9. from typing import Any, Callable
  10. from urllib.parse import urlparse
  11. ProbeFn = Callable[[str, str], dict[str, Any]]
  12. MAX_VIDEO_URL_CANDIDATES = 3
  13. VIDEO_HINT_RE = re.compile(r"(video|play|mp4|m3u8|media|download|src)", re.I)
  14. IMAGE_HINT_RE = re.compile(r"(image|cover|poster|thumbnail|thumb|heif|jpg|jpeg|png|kvif)", re.I)
  15. AVATAR_HINT_RE = re.compile(r"(avatar|qlogo|uhead)", re.I)
  16. AUDIO_HINT_RE = re.compile(r"(audio|bgm|music|m4a|mp3)", re.I)
  17. PAGE_HINT_RE = re.compile(r"(content_link|share_url|page_url|account_link|link)$", re.I)
  18. AD_HINT_RE = re.compile(
  19. r"(^|[._/\-\[\]&?=#])(?:ad|ads|advert|commercial|promotion|material|marketing|营销|广告|推广)($|[._/\-\[\]&?=#])",
  20. re.I,
  21. )
  22. VIDEO_EXT_RE = re.compile(r"\.(mp4|m3u8)(?:$|[?&#])", re.I)
  23. @dataclass
  24. class VideoUrlCandidate:
  25. path: str
  26. url: str
  27. host: str
  28. kind: str
  29. reject_reason: str = ""
  30. http_status: int | None = None
  31. content_type: str = ""
  32. content_range: str = ""
  33. range_bytes: int = 0
  34. looks_like_video: bool = False
  35. probe_status: str = "not_probed"
  36. def select_video_url(
  37. platform: str,
  38. sources: list[tuple[str, dict[str, Any]]],
  39. *,
  40. probe_fn: ProbeFn | None = None,
  41. ) -> dict[str, Any]:
  42. candidates = _unique_candidates(
  43. candidate
  44. for source_name, payload in sources
  45. for candidate in find_url_candidates(payload, prefix=f"$.{source_name}")
  46. )
  47. for candidate in candidates:
  48. if candidate.kind != "video":
  49. continue
  50. if probe_fn is None:
  51. continue
  52. try:
  53. probe = probe_fn(candidate.url, platform)
  54. except Exception as exc: # noqa: BLE001 - diagnostics should survive upstream quirks.
  55. probe = {"probe_status": "failed", "reject_reason": f"probe_failed:{type(exc).__name__}"}
  56. _apply_probe(candidate, probe)
  57. selected = _select_best(candidates, platform)
  58. result = {
  59. "play_url": selected.url if selected else None,
  60. "media_failure_reason": None if selected else "no_valid_play_url",
  61. "platform_raw_payload": build_diagnostic_payload(candidates, selected),
  62. "video_url_candidates": serialize_video_url_candidates(
  63. candidates,
  64. selected,
  65. platform,
  66. limit=MAX_VIDEO_URL_CANDIDATES,
  67. ),
  68. }
  69. if result["media_failure_reason"]:
  70. result["platform_raw_payload"]["no_valid_play_url"] = True
  71. return result
  72. def serialize_video_url_candidates(
  73. candidates: list[VideoUrlCandidate],
  74. selected: VideoUrlCandidate | None,
  75. platform: str,
  76. *,
  77. limit: int = MAX_VIDEO_URL_CANDIDATES,
  78. ) -> list[dict[str, Any]]:
  79. ordered = _ordered_video_candidates(candidates, selected, platform)[: max(0, limit)]
  80. result: list[dict[str, Any]] = []
  81. for index, candidate in enumerate(ordered):
  82. result.append(
  83. _compact_candidate(
  84. {
  85. "url": candidate.url,
  86. "host": candidate.host,
  87. "path": candidate.path,
  88. "source": _source_from_path(candidate.path),
  89. "probe_status": candidate.probe_status,
  90. "content_type": candidate.content_type,
  91. "content_range": candidate.content_range,
  92. "selected": bool(selected and candidate.url == selected.url),
  93. "candidate_index": index,
  94. }
  95. )
  96. )
  97. return result
  98. def find_url_candidates(value: Any, *, prefix: str = "$") -> list[VideoUrlCandidate]:
  99. candidates: list[VideoUrlCandidate] = []
  100. if isinstance(value, dict):
  101. for key, item in value.items():
  102. candidates.extend(find_url_candidates(item, prefix=f"{prefix}.{key}"))
  103. elif isinstance(value, list):
  104. for index, item in enumerate(value):
  105. candidates.extend(find_url_candidates(item, prefix=f"{prefix}[{index}]"))
  106. elif isinstance(value, str) and value.startswith(("http://", "https://")):
  107. kind, reject = classify_url_candidate(prefix, value)
  108. candidates.append(
  109. VideoUrlCandidate(
  110. path=prefix,
  111. url=value,
  112. host=urlparse(value).netloc,
  113. kind=kind,
  114. reject_reason=reject,
  115. )
  116. )
  117. return candidates
  118. def classify_url_candidate(path: str, url: str) -> tuple[str, str]:
  119. haystack = f"{path} {url}".lower()
  120. if AD_HINT_RE.search(haystack):
  121. return "ad_or_material", "ad_or_material_path"
  122. if AUDIO_HINT_RE.search(haystack):
  123. return "audio", "audio_or_bgm_path"
  124. if AVATAR_HINT_RE.search(haystack):
  125. return "avatar", "avatar_path"
  126. if IMAGE_HINT_RE.search(haystack):
  127. return "image", "image_path"
  128. if PAGE_HINT_RE.search(path):
  129. return "page", "page_link_path"
  130. if VIDEO_EXT_RE.search(url) or VIDEO_HINT_RE.search(path):
  131. return "video", ""
  132. return "unknown", "weak_video_signal"
  133. def probe_url_with_httpx(
  134. url: str,
  135. platform: str,
  136. *,
  137. http_client: Any,
  138. bytes_to_probe: int = 1024 * 1024,
  139. timeout_seconds: float = 30.0,
  140. ) -> dict[str, Any]:
  141. get = getattr(http_client, "get", None)
  142. if not callable(get):
  143. return {"probe_status": "failed", "reject_reason": "probe_client_missing_get"}
  144. headers = _download_headers(platform)
  145. headers["Range"] = f"bytes=0-{max(bytes_to_probe - 1, 0)}"
  146. response = get(url, headers=headers, follow_redirects=True, timeout=timeout_seconds)
  147. content = response.content or b""
  148. content_type = response.headers.get("content-type", "")
  149. return {
  150. "http_status": response.status_code,
  151. "content_type": content_type,
  152. "content_range": response.headers.get("content-range", ""),
  153. "range_bytes": len(content),
  154. "looks_like_video": _looks_like_video(content, content_type),
  155. "probe_status": "verified"
  156. if response.status_code in {200, 206} and _looks_like_video(content, content_type)
  157. else "failed",
  158. }
  159. def build_diagnostic_payload(
  160. candidates: list[VideoUrlCandidate],
  161. selected: VideoUrlCandidate | None,
  162. ) -> dict[str, Any]:
  163. reject_reasons = Counter(
  164. candidate.reject_reason or candidate.probe_status
  165. for candidate in candidates
  166. if candidate.kind != "video" or candidate.probe_status == "failed" or candidate.reject_reason
  167. )
  168. payload: dict[str, Any] = {
  169. "selected_video_url_path": selected.path if selected else None,
  170. "selected_video_url_host": selected.host if selected else None,
  171. "selected_video_url_probe_status": selected.probe_status if selected else None,
  172. "selected_video_url_content_type": selected.content_type if selected else None,
  173. "selected_video_url_content_range": selected.content_range if selected else None,
  174. "video_url_candidate_counts": dict(Counter(candidate.kind for candidate in candidates)),
  175. "video_url_reject_reasons": dict(reject_reasons),
  176. }
  177. return {key: value for key, value in payload.items() if value not in (None, "", {}, [])}
  178. def _select_best(candidates: list[VideoUrlCandidate], platform: str) -> VideoUrlCandidate | None:
  179. video_candidates = [candidate for candidate in candidates if candidate.kind == "video"]
  180. verified = [
  181. candidate
  182. for candidate in video_candidates
  183. if candidate.probe_status == "verified"
  184. and candidate.http_status in {200, 206}
  185. and (candidate.looks_like_video or "video" in candidate.content_type.lower())
  186. ]
  187. if verified:
  188. return sorted(verified, key=lambda candidate: _selection_score(candidate, platform, fallback=False))[0]
  189. if not video_candidates:
  190. return None
  191. selected = sorted(video_candidates, key=lambda candidate: _selection_score(candidate, platform, fallback=True))[0]
  192. selected.probe_status = "failed_fallback" if selected.probe_status != "not_probed" else "not_probed_fallback"
  193. return selected
  194. def _ordered_video_candidates(
  195. candidates: list[VideoUrlCandidate],
  196. selected: VideoUrlCandidate | None,
  197. platform: str,
  198. ) -> list[VideoUrlCandidate]:
  199. video_candidates = [candidate for candidate in candidates if candidate.kind == "video"]
  200. ordered: list[VideoUrlCandidate] = []
  201. seen: set[str] = set()
  202. if selected:
  203. ordered.append(selected)
  204. seen.add(selected.url)
  205. for candidate in sorted(
  206. video_candidates,
  207. key=lambda item: _selection_score(
  208. item,
  209. platform,
  210. fallback=item.probe_status not in {"verified"},
  211. ),
  212. ):
  213. if candidate.url in seen:
  214. continue
  215. ordered.append(candidate)
  216. seen.add(candidate.url)
  217. return ordered
  218. def _selection_score(candidate: VideoUrlCandidate, platform: str, *, fallback: bool) -> tuple[Any, ...]:
  219. detail_bonus = 0 if platform == "kuaishou" and ".detail." in candidate.path else 1
  220. preferred_host = 0
  221. if platform == "shipinhao":
  222. preferred_host = 0 if "findermp.video.qq.com" in candidate.host else 1
  223. elif platform == "kuaishou":
  224. preferred_host = 0 if ("kwai" in candidate.host or "kwimgs" in candidate.host) else 1
  225. elif platform == "douyin":
  226. host = candidate.host.lower()
  227. if "v11-weba.douyinvod.com" in host:
  228. preferred_host = 3
  229. elif "douyinvod.com" in host:
  230. preferred_host = 0
  231. else:
  232. preferred_host = 1
  233. return (
  234. detail_bonus,
  235. preferred_host,
  236. fallback and candidate.probe_status == "failed",
  237. candidate.http_status not in {200, 206},
  238. not candidate.looks_like_video,
  239. candidate.path.count("."),
  240. candidate.path,
  241. )
  242. def _apply_probe(candidate: VideoUrlCandidate, probe: dict[str, Any]) -> None:
  243. candidate.http_status = _int_or_none(probe.get("http_status"))
  244. candidate.content_type = str(probe.get("content_type") or "")
  245. candidate.content_range = str(probe.get("content_range") or "")
  246. candidate.range_bytes = int(probe.get("range_bytes") or 0)
  247. candidate.looks_like_video = bool(probe.get("looks_like_video"))
  248. candidate.probe_status = str(probe.get("probe_status") or "failed")
  249. def _unique_candidates(candidates: Any) -> list[VideoUrlCandidate]:
  250. seen: set[str] = set()
  251. result: list[VideoUrlCandidate] = []
  252. for candidate in candidates:
  253. if candidate.url in seen:
  254. continue
  255. seen.add(candidate.url)
  256. result.append(candidate)
  257. return result
  258. def _download_headers(platform: str) -> dict[str, str]:
  259. headers = {
  260. "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/126 Safari/537.36",
  261. "Accept": "*/*",
  262. }
  263. if platform == "shipinhao":
  264. headers["Referer"] = "https://channels.weixin.qq.com/"
  265. elif platform == "kuaishou":
  266. headers["Referer"] = "https://www.kuaishou.com/"
  267. elif platform == "douyin":
  268. headers["Referer"] = "https://www.douyin.com/"
  269. return headers
  270. def _looks_like_video(content: bytes, content_type: str) -> bool:
  271. head = content[:512]
  272. return "video" in content_type.lower() or b"ftyp" in head or b"moov" in head or b"mdat" in head
  273. def _int_or_none(value: Any) -> int | None:
  274. try:
  275. return int(value)
  276. except (TypeError, ValueError):
  277. return None
  278. def _source_from_path(path: str) -> str:
  279. if not path.startswith("$."):
  280. return ""
  281. remainder = path[2:]
  282. for separator in (".", "["):
  283. if separator in remainder:
  284. return remainder.split(separator, 1)[0]
  285. return remainder
  286. def _compact_candidate(candidate: dict[str, Any]) -> dict[str, Any]:
  287. return {key: value for key, value in candidate.items() if value not in (None, "", {}, [])}