|
|
@@ -19,6 +19,7 @@ from content_agent.integrations.crawapi_http import (
|
|
|
score_from_statistics as _score_from_statistics,
|
|
|
search_previous_discovery_step as _search_previous_discovery_step,
|
|
|
)
|
|
|
+from content_agent.integrations import platform_video_url
|
|
|
|
|
|
RAW_CONTENT_ID_KEY = "_".join(["aweme", "id"])
|
|
|
RAW_AUTHOR_ID_KEY = "_".join(["sec", "uid"])
|
|
|
@@ -54,6 +55,7 @@ class CrawapiDouyinClient:
|
|
|
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:
|
|
|
self.base_url = base_url.rstrip("/") + "/"
|
|
|
self.keyword_path = keyword_path.lstrip("/")
|
|
|
@@ -70,6 +72,7 @@ class CrawapiDouyinClient:
|
|
|
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") -> "CrawapiDouyinClient":
|
|
|
@@ -137,7 +140,10 @@ class CrawapiDouyinClient:
|
|
|
results: list[dict[str, Any]] = []
|
|
|
selected_items = items[:max_results_per_query] if max_results_per_query else items
|
|
|
for index, item in enumerate(selected_items, start=1):
|
|
|
- results.append(self._normalize_content_item(query, item, index, has_more, next_cursor))
|
|
|
+ selection = self._select_video_url_with_detail_fallback(item)
|
|
|
+ results.append(
|
|
|
+ self._normalize_content_item(query, item, index, has_more, next_cursor, selection)
|
|
|
+ )
|
|
|
return results
|
|
|
|
|
|
def fetch_author_works(self, query: dict[str, Any]) -> list[dict[str, Any]]:
|
|
|
@@ -158,7 +164,8 @@ class CrawapiDouyinClient:
|
|
|
selected_items = items[: self.max_results_per_query] if self.max_results_per_query else items
|
|
|
results: list[dict[str, Any]] = []
|
|
|
for index, item in enumerate(selected_items, start=1):
|
|
|
- normalized = self._normalize_content_item(query, item, index, has_more, next_cursor)
|
|
|
+ selection = self._select_video_url_with_detail_fallback(item)
|
|
|
+ normalized = self._normalize_content_item(query, item, index, has_more, next_cursor, selection)
|
|
|
normalized["previous_discovery_step"] = "author_works"
|
|
|
normalized["content_metadata_source"] = "douyin_blogger"
|
|
|
results.append(normalized)
|
|
|
@@ -189,18 +196,20 @@ class CrawapiDouyinClient:
|
|
|
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 self._select_video_url([("search", item)])
|
|
|
author = item.get("author", {}) if isinstance(item.get("author"), dict) else {}
|
|
|
statistics = item.get("statistics", {}) if isinstance(item.get("statistics"), dict) else {}
|
|
|
platform_content_id = str(item.get(RAW_CONTENT_ID_KEY) or "")
|
|
|
platform_author_id = str(author.get(RAW_AUTHOR_ID_KEY) or "")
|
|
|
- return {
|
|
|
+ result = {
|
|
|
"content_discovery_id": f"{query['search_query_id']}_content_{index:03d}",
|
|
|
"search_query_id": query["search_query_id"],
|
|
|
"platform": "douyin",
|
|
|
"platform_content_id": platform_content_id,
|
|
|
"platform_content_format": _content_format(self.default_content_type),
|
|
|
- "play_url": _extract_play_url(item),
|
|
|
+ "play_url": video_url_selection.get("play_url"),
|
|
|
"description": item.get("desc") or item.get("item_title") or "",
|
|
|
"platform_author_id": platform_author_id,
|
|
|
"author_display_name": author.get("nickname") or "",
|
|
|
@@ -226,18 +235,17 @@ class CrawapiDouyinClient:
|
|
|
"platform_raw_payload": {
|
|
|
RAW_CONTENT_ID_KEY: platform_content_id,
|
|
|
"author": {RAW_AUTHOR_ID_KEY: platform_author_id},
|
|
|
+ **dict(video_url_selection.get("platform_raw_payload") or {}),
|
|
|
},
|
|
|
}
|
|
|
+ if video_url_selection.get("media_failure_reason"):
|
|
|
+ result["media_failure_reason"] = video_url_selection["media_failure_reason"]
|
|
|
+ if video_url_selection.get("video_url_candidates"):
|
|
|
+ result["video_url_candidates"] = video_url_selection["video_url_candidates"]
|
|
|
+ return result
|
|
|
|
|
|
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=SEARCH_RATE_LIMIT_BUCKET,
|
|
|
- )
|
|
|
- block = data.get("data", {}) if isinstance(data.get("data"), dict) else {}
|
|
|
- detail = block.get("data", {}) if isinstance(block.get("data"), dict) else {}
|
|
|
+ detail = self._fetch_detail_item(content_id)
|
|
|
statistics = {
|
|
|
"digg_count": int(detail.get("like_count") or 0),
|
|
|
"comment_count": int(detail.get("comment_count") or 0),
|
|
|
@@ -247,10 +255,9 @@ class CrawapiDouyinClient:
|
|
|
}
|
|
|
topic_list = detail.get("topic_list") or []
|
|
|
tags = [t if str(t).startswith("#") else f"#{t}" for t in topic_list if t]
|
|
|
- video_list = detail.get("video_url_list") or []
|
|
|
- play_url = video_list[0].get("video_url") if video_list else None
|
|
|
+ selection = self._select_video_url([("detail", detail)])
|
|
|
publish_ms = detail.get("publish_timestamp")
|
|
|
- return {
|
|
|
+ result = {
|
|
|
"platform": "douyin",
|
|
|
"platform_content_id": str(detail.get("channel_content_id") or content_id),
|
|
|
"platform_content_url": detail.get("content_link"),
|
|
|
@@ -259,10 +266,79 @@ class CrawapiDouyinClient:
|
|
|
"author_display_name": detail.get("channel_account_name") or "",
|
|
|
"statistics": statistics,
|
|
|
"tags": tags,
|
|
|
- "play_url": play_url,
|
|
|
+ "play_url": selection.get("play_url"),
|
|
|
"create_time": int(publish_ms) // 1000 if publish_ms else None,
|
|
|
"content_metadata_source": "douyin_detail",
|
|
|
+ "platform_raw_payload": dict(selection.get("platform_raw_payload") or {}),
|
|
|
}
|
|
|
+ if selection.get("media_failure_reason"):
|
|
|
+ result["media_failure_reason"] = selection["media_failure_reason"]
|
|
|
+ if selection.get("video_url_candidates"):
|
|
|
+ result["video_url_candidates"] = selection["video_url_candidates"]
|
|
|
+ return result
|
|
|
+
|
|
|
+ def _select_video_url(self, sources: list[tuple[str, dict[str, Any]]]) -> dict[str, Any]:
|
|
|
+ return platform_video_url.select_video_url(
|
|
|
+ "douyin",
|
|
|
+ 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)])
|
|
|
+ content_id = str(item.get(RAW_CONTENT_ID_KEY) or "")
|
|
|
+ if not self._should_fetch_detail_for_video_url(search_selection) or not self.detail_path or 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(
|
|
|
+ {
|
|
|
+ "douyin_detail_fallback_attempted": True,
|
|
|
+ "douyin_detail_fallback_status": "failed",
|
|
|
+ "douyin_detail_fallback_exception_type": type(exc).__name__,
|
|
|
+ "douyin_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(
|
|
|
+ {
|
|
|
+ "douyin_detail_fallback_attempted": True,
|
|
|
+ "douyin_detail_fallback_status": (
|
|
|
+ "used" if detail_selection.get("play_url") else "no_valid_play_url"
|
|
|
+ ),
|
|
|
+ }
|
|
|
+ )
|
|
|
+ return {**detail_selection, "platform_raw_payload": payload}
|
|
|
+
|
|
|
+ def _should_fetch_detail_for_video_url(self, selection: dict[str, Any]) -> bool:
|
|
|
+ payload = selection.get("platform_raw_payload") if isinstance(selection.get("platform_raw_payload"), dict) else {}
|
|
|
+ if not selection.get("play_url"):
|
|
|
+ return True
|
|
|
+ host = str(payload.get("selected_video_url_host") or "").lower()
|
|
|
+ if host in {"v11-weba.douyinvod.com", "v96-hcc.douyinvod.com"}:
|
|
|
+ return True
|
|
|
+ return str(payload.get("selected_video_url_probe_status") or "") in {"failed", "failed_fallback"}
|
|
|
+
|
|
|
+ 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=SEARCH_RATE_LIMIT_BUCKET,
|
|
|
+ )
|
|
|
+ block = data.get("data", {}) if isinstance(data.get("data"), dict) else {}
|
|
|
+ return block.get("data", {}) if isinstance(block.get("data"), 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 _post_json(
|
|
|
self,
|