| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327 |
- """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]]
- MAX_VIDEO_URL_CANDIDATES = 3
- 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),
- "video_url_candidates": serialize_video_url_candidates(
- candidates,
- selected,
- platform,
- limit=MAX_VIDEO_URL_CANDIDATES,
- ),
- }
- if result["media_failure_reason"]:
- result["platform_raw_payload"]["no_valid_play_url"] = True
- return result
- def serialize_video_url_candidates(
- candidates: list[VideoUrlCandidate],
- selected: VideoUrlCandidate | None,
- platform: str,
- *,
- limit: int = MAX_VIDEO_URL_CANDIDATES,
- ) -> list[dict[str, Any]]:
- ordered = _ordered_video_candidates(candidates, selected, platform)[: max(0, limit)]
- result: list[dict[str, Any]] = []
- for index, candidate in enumerate(ordered):
- result.append(
- _compact_candidate(
- {
- "url": candidate.url,
- "host": candidate.host,
- "path": candidate.path,
- "source": _source_from_path(candidate.path),
- "probe_status": candidate.probe_status,
- "content_type": candidate.content_type,
- "content_range": candidate.content_range,
- "selected": bool(selected and candidate.url == selected.url),
- "candidate_index": index,
- }
- )
- )
- 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 _ordered_video_candidates(
- candidates: list[VideoUrlCandidate],
- selected: VideoUrlCandidate | None,
- platform: str,
- ) -> list[VideoUrlCandidate]:
- video_candidates = [candidate for candidate in candidates if candidate.kind == "video"]
- ordered: list[VideoUrlCandidate] = []
- seen: set[str] = set()
- if selected:
- ordered.append(selected)
- seen.add(selected.url)
- for candidate in sorted(
- video_candidates,
- key=lambda item: _selection_score(
- item,
- platform,
- fallback=item.probe_status not in {"verified"},
- ),
- ):
- if candidate.url in seen:
- continue
- ordered.append(candidate)
- seen.add(candidate.url)
- return ordered
- 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
- elif platform == "douyin":
- host = candidate.host.lower()
- if "v11-weba.douyinvod.com" in host:
- preferred_host = 3
- elif "douyinvod.com" in host:
- preferred_host = 0
- else:
- preferred_host = 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/"
- elif platform == "douyin":
- headers["Referer"] = "https://www.douyin.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
- def _source_from_path(path: str) -> str:
- if not path.startswith("$."):
- return ""
- remainder = path[2:]
- for separator in (".", "["):
- if separator in remainder:
- return remainder.split(separator, 1)[0]
- return remainder
- def _compact_candidate(candidate: dict[str, Any]) -> dict[str, Any]:
- return {key: value for key, value in candidate.items() if value not in (None, "", {}, [])}
|