Просмотр исходного кода

feat: prefer piaoquantv crawapi for douyin

Sam Lee 3 недель назад
Родитель
Сommit
6544a44fe0
3 измененных файлов с 248 добавлено и 180 удалено
  1. 37 168
      .env
  2. 111 11
      content_agent/integrations/douyin.py
  3. 100 1
      tests/test_douyin_client.py

+ 37 - 168
.env

@@ -1,202 +1,71 @@
-# ContentFindAgentNew environment variables
-# Generated from tech_documents/data_interface.md and legacy ContentFindAgent code anchors.
-# Do not commit real secrets. Fill blank values from the approved secrets store before running live jobs.
+# ContentFindAgentNew runtime environment
+# Reorganized by Codex on 2026-07-03; values preserved from the previous .env.
+# Keys not referenced by runtime code or operational scripts were removed.
 
 
-# -----------------------------------------------------------------------------
-# Runtime
-# -----------------------------------------------------------------------------
-HOST=0.0.0.0
-PORT=8080
-TZ=Asia/Shanghai
-SCHEDULER_TIMEZONE=Asia/Shanghai
-MAX_CONCURRENT_TASKS=1
-SCHEDULE_DISPATCH_INTERVAL_SECONDS=30
-SCHEDULE_TASK_TIMEOUT_SECONDS=1500
-
-TRACE_DIR=.cache/traces
-OUTPUT_DIR=.cache/output
-INPUT_LOG_PATH=.cache/input_log
-OUTPUT_HTML_PATH=
-OUTPUT_JSON_PATH=
-TRACE_ID=
-SKILLS_DIR=./skills
-ENABLED_SKILLS=
-
-MODEL=anthropic/claude-sonnet-4.5
-TEMPERATURE=0.3
-MAX_ITERATIONS=30
-AGENT_PRICING_CONFIG=
-AGENT_DISABLE_SIDE_BRANCHES=
-AGENT_DEBUG=
-
-# -----------------------------------------------------------------------------
-# LLM providers
-# -----------------------------------------------------------------------------
-OPEN_ROUTER_API_KEY=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
-OPENROUTER_API_KEY=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
-OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
-# 内容判定:Gemini 直读视频(V3-M2),复用上面 OPENROUTER_API_KEY/BASE_URL
-CONTENT_AGENT_VIDEO_LLM_MODEL=google/gemini-3-flash-preview
-CONTENT_AGENT_VIDEO_LLM_TIMEOUT_SECONDS=90
-
-QWEN_API_KEY=
-QWEN_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
+# Core service and web
+CONTENT_AGENT_WEB_CORS_ORIGINS=http://192.168.82.27:8101,http://localhost:8101,http://127.0.0.1:8101
+VITE_CONTENTFIND_API_BASE_URL=
 
 
-GEMINI_API_KEY=
-YESCODE_BASE_URL=
-YESCODE_API_KEY=
+# Content Supply MySQL runtime
+CONTENT_AGENT_DB_RUNTIME_ENABLED=1
+CONTENT_SUPPLY_DB_HOST=192.168.82.27
+CONTENT_SUPPLY_DB_PORT=3306
+CONTENT_SUPPLY_DB_USER=content_rw
+CONTENT_SUPPLY_DB_PASSWORD=bC1aH4bA1lB0
+CONTENT_SUPPLY_DB_NAME=content-deconstruction-supply
 
 
-# -----------------------------------------------------------------------------
-# Upstream open_aigc / Pattern V2 inventory
-# Used by open_aigc.post, post_decode_topic_point_element,
-# post_script_paragraph_field_element, global_category, global_element,
-# element_classification_mapping, pattern_mining_execution,
-# pattern_mining_element, pattern_itemset, pattern_itemset_item.
-# Fill only when v2 service actually connects to these upstream stores.
-# -----------------------------------------------------------------------------
+# Pattern PG gate
 OPEN_AIGC_PG_HOST=gp-t4n72471pkmt4b9q7o-master.gpdbmaster.singapore.rds.aliyuncs.com
 OPEN_AIGC_PG_HOST=gp-t4n72471pkmt4b9q7o-master.gpdbmaster.singapore.rds.aliyuncs.com
 OPEN_AIGC_PG_PORT=5432
 OPEN_AIGC_PG_PORT=5432
 OPEN_AIGC_PG_USER=aiddit_aigc
 OPEN_AIGC_PG_USER=aiddit_aigc
 OPEN_AIGC_PG_PASSWORD=%a&&yqNxg^V1$toJ*WOa^-b^X=QJ
 OPEN_AIGC_PG_PASSWORD=%a&&yqNxg^V1$toJ*WOa^-b^X=QJ
 OPEN_AIGC_PG_DB_NAME=open_aigc
 OPEN_AIGC_PG_DB_NAME=open_aigc
-OPEN_AIGC_PG_SCHEMA=public
 
 
-# -----------------------------------------------------------------------------
-# DemandAgent demand_content / CFA production fact DB
-# V1 reads DemandAgent demand_content and writes content_agent_* tables here.
-# Host uses the VPC private IP when CFA runs from this repo / local VPN.
-# -----------------------------------------------------------------------------
-CONTENT_SUPPLY_DB_HOST=192.168.82.27
-CONTENT_SUPPLY_DB_PORT=3306
-CONTENT_SUPPLY_DB_NAME=content-deconstruction-supply
-CONTENT_SUPPLY_DB_USER=content_rw
-CONTENT_SUPPLY_DB_PASSWORD=bC1aH4bA1lB0
-CONTENT_AGENT_DB_RUNTIME_ENABLED=1
-
-# -----------------------------------------------------------------------------
-# Platform APIs: Crawapi / Douyin(V3 双渠道 抖音+视频号 均走此 host)
-# -----------------------------------------------------------------------------
-CONTENTFIND_API_CRAWAPI_BASE_URL=http://crawler.aiddit.com
-CONTENTFIND_API_CRAWAPI_KEY=
+# Crawapi shared
+CONTENTFIND_API_CRAWAPI_BASE_URL=http://crawapi.piaoquantv.com
+CONTENTFIND_API_CRAWAPI_FALLBACK_BASE_URL=http://crawler.aiddit.com
 CONTENTFIND_API_CRAWAPI_TIMEOUT_SECONDS=60
 CONTENTFIND_API_CRAWAPI_TIMEOUT_SECONDS=60
 
 
+# Douyin Crawapi
 CONTENTFIND_DOUYIN_KEYWORD_PATH=/crawler/dou_yin/keyword
 CONTENTFIND_DOUYIN_KEYWORD_PATH=/crawler/dou_yin/keyword
 CONTENTFIND_DOUYIN_BLOGGER_PATH=/crawler/dou_yin/blogger
 CONTENTFIND_DOUYIN_BLOGGER_PATH=/crawler/dou_yin/blogger
 CONTENTFIND_DOUYIN_DETAIL_PATH=/crawler/dou_yin/detail
 CONTENTFIND_DOUYIN_DETAIL_PATH=/crawler/dou_yin/detail
-
 CONTENTFIND_DOUYIN_DEFAULT_ACCOUNT_ID=771431222
 CONTENTFIND_DOUYIN_DEFAULT_ACCOUNT_ID=771431222
 CONTENTFIND_DOUYIN_DEFAULT_CONTENT_TYPE=视频
 CONTENTFIND_DOUYIN_DEFAULT_CONTENT_TYPE=视频
 CONTENTFIND_DOUYIN_DEFAULT_SORT_TYPE=综合排序
 CONTENTFIND_DOUYIN_DEFAULT_SORT_TYPE=综合排序
 CONTENTFIND_DOUYIN_DEFAULT_PUBLISH_TIME=不限
 CONTENTFIND_DOUYIN_DEFAULT_PUBLISH_TIME=不限
 CONTENTFIND_DOUYIN_DEFAULT_CURSOR=0
 CONTENTFIND_DOUYIN_DEFAULT_CURSOR=0
-CONTENTFIND_DOUYIN_MAX_RESULTS_PER_QUERY=3
 CONTENTFIND_DOUYIN_ACCOUNT_WORKS_DEFAULT_SORT_TYPE=最新
 CONTENTFIND_DOUYIN_ACCOUNT_WORKS_DEFAULT_SORT_TYPE=最新
+CONTENTFIND_DOUYIN_MAX_RESULTS_PER_QUERY=3
 
 
-# -----------------------------------------------------------------------------
-# Good case / Pattern query tuning
-# -----------------------------------------------------------------------------
-GET_GOODCASE_TOPIC_POINT_IN_BATCH_SIZE=200
-GET_VIDEO_TOPIC_IN_BATCH_SIZE=200
+# Query LLM
+CONTENT_AGENT_QUERY_LLM_API_KEY=sk-ws-H.RPHIMMY.iCXw.MEQCIEK5lMoyNCQqbob6F49k3onjTYOcXFN4QrMe9jcSY7T9AiBDBofse4gRk_hOSqBYKAbaXwJL7Be-9B8JZw2pFVD5oA
+CONTENT_AGENT_QUERY_LLM_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
+CONTENT_AGENT_QUERY_LLM_MODEL=qwen-plus
+MODEL=anthropic/claude-sonnet-4.5
 
 
-# -----------------------------------------------------------------------------
-# Scheduler external query source
-# -----------------------------------------------------------------------------
-SCHEDULE_QUERY_API=
-SCHEDULE_QUERY_API_KEY=
-SCHEDULE_QUERY_API_TIMEOUT=10.0
+# OpenRouter fallback
+OPENROUTER_API_KEY=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
+OPEN_ROUTER_API_KEY=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
+OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
 
 
-# -----------------------------------------------------------------------------
-# AIGC plan handoff
-# Current evidence: source-located / blocked because this has write-side effects.
-# Keep CAN_NOT_CREATE_PLAN=true unless an operator explicitly enables live writes.
-# -----------------------------------------------------------------------------
+# Video LLM
+CONTENT_AGENT_VIDEO_LLM_PROVIDER=dashscope
+CONTENT_AGENT_VIDEO_LLM_API_KEY=sk-ws-H.RPHIMMY.iCXw.MEQCIEK5lMoyNCQqbob6F49k3onjTYOcXFN4QrMe9jcSY7T9AiBDBofse4gRk_hOSqBYKAbaXwJL7Be-9B8JZw2pFVD5oA
+CONTENT_AGENT_VIDEO_LLM_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
+CONTENT_AGENT_VIDEO_LLM_MODEL=qwen3.6-plus
+CONTENT_AGENT_VIDEO_LLM_TIMEOUT_SECONDS=600
+QWEN_API_KEY=
+
+# AIGC push
 CAN_NOT_CREATE_PLAN=true
 CAN_NOT_CREATE_PLAN=true
 CONTENTFIND_API_AIGC_BASE_URL=https://aigc-api.aiddit.com
 CONTENTFIND_API_AIGC_BASE_URL=https://aigc-api.aiddit.com
 CONTENTFIND_API_AIGC_TOKEN=8bf14f27fc3a486788f3383452422d72
 CONTENTFIND_API_AIGC_TOKEN=8bf14f27fc3a486788f3383452422d72
 AIGC_TOKEN=8bf14f27fc3a486788f3383452422d72
 AIGC_TOKEN=8bf14f27fc3a486788f3383452422d72
-PRODUCE_PLAN_ID=20260408085649635441036
-
 CONTENTFIND_AIGC_CRAWLER_PLAN_SAVE_PATH=/aigc/crawler/plan/save
 CONTENTFIND_AIGC_CRAWLER_PLAN_SAVE_PATH=/aigc/crawler/plan/save
 CONTENTFIND_AIGC_PRODUCE_PLAN_DETAIL_PATH=/aigc/produce/plan/detail
 CONTENTFIND_AIGC_PRODUCE_PLAN_DETAIL_PATH=/aigc/produce/plan/detail
 CONTENTFIND_AIGC_PRODUCE_PLAN_SAVE_PATH=/aigc/produce/plan/save
 CONTENTFIND_AIGC_PRODUCE_PLAN_SAVE_PATH=/aigc/produce/plan/save
-
-# findagentv2 生成计划 ID(2026-06-22 建)。仅跑抖音/快手。
-# 路由:每个平台入池视频一分为二 → 一半进 _1、一半进 _2 对应的生成计划。
 CONTENTFIND_AIGC_PRODUCE_PLAN_ID_DOUYIN_1=20260622194927901764131
 CONTENTFIND_AIGC_PRODUCE_PLAN_ID_DOUYIN_1=20260622194927901764131
 CONTENTFIND_AIGC_PRODUCE_PLAN_ID_DOUYIN_2=20260622195006094256747
 CONTENTFIND_AIGC_PRODUCE_PLAN_ID_DOUYIN_2=20260622195006094256747
 CONTENTFIND_AIGC_PRODUCE_PLAN_ID_KUAISHOU_1=20260622195240115701808
 CONTENTFIND_AIGC_PRODUCE_PLAN_ID_KUAISHOU_1=20260622195240115701808
 CONTENTFIND_AIGC_PRODUCE_PLAN_ID_KUAISHOU_2=20260622195305377617013
 CONTENTFIND_AIGC_PRODUCE_PLAN_ID_KUAISHOU_2=20260622195305377617013
-# 发布计划 ID:同平台 _1/_2 共用一个
-CONTENTFIND_AIGC_PUBLISH_PLAN_ID_DOUYIN=20260622200651592303486
-CONTENTFIND_AIGC_PUBLISH_PLAN_ID_KUAISHOU=20260622200725048750428
-
-# -----------------------------------------------------------------------------
-# OSS log HTML upload
-# Current evidence: source-located / blocked because this has upload side effects.
-# -----------------------------------------------------------------------------
-ALIYUN_OSS_ACCESS_KEY_ID=
-ALIYUN_OSS_ACCESS_KEY_SECRET=
-ALIYUN_OSS_BUCKET=
-ALIYUN_OSS_REGION=
-ALIYUN_OSS_PREFIX=
-ALIYUN_OSS_PUBLIC_BASE_URL=
-
-# -----------------------------------------------------------------------------
-# Strategy learning external performance stores
-# Used by loghubods.video_dimension_detail_add_column and other performance review data.
-# Fill only when the strategy-learning layer connects to ODPS/Hive.
-# -----------------------------------------------------------------------------
-ODPS_ACCESS_ID=LTAI9EBa0bd5PrDa
-ODPS_ACCESS_KEY=vAalxds7YxhfOA2yVv8GziCg3Y87v5
-ODPS_ENDPOINT=http://service.odps.aliyun.com/api
-ODPS_PROJECT=loghubods
-ODPS_TABLE_VIDEO_DIMENSION_DETAIL=loghubods.video_dimension_detail_add_column
-
-HIVE_HOST=
-HIVE_PORT=
-HIVE_USER=
-HIVE_PASSWORD=
-HIVE_DATABASE=
-
-# -----------------------------------------------------------------------------
-# Optional framework services
-# -----------------------------------------------------------------------------
-KNOWHUB_API=http://localhost:8000
-BRAND_NAME=KnowHub
-BRAND_API_ENV=KNOWHUB_API
-BRAND_DB=knowhub.db
-ORG_KEYS=
-BROWSER_USE_API_KEY=
-
-# -----------------------------------------------------------------------------
-# Optional Feishu tools in the shared agent framework
-# Leave blank unless this project actually enables Feishu tools.
-# -----------------------------------------------------------------------------
-FEISHU_APP_ID=cli_a90fe317987a9cc9
-FEISHU_APP_SECRET=nn2dWuXTiRA2N6xodbm4g0qz1AfM2ayi
-FEISHU_AGENT_MODEL=qwen3.5-397b-a17b
-FEISHU_AGENT_SYSTEM_PROMPT=
-
-# -----------------------------------------------------------------------------
-# Vite / show frontend
-# Current show app is a static product sandbox. These are reserved for future API wiring.
-# -----------------------------------------------------------------------------
-VITE_API_BASE_URL=
-VITE_CONTENTFIND_API_BASE_URL=
-
-# -----------------------------------------------------------------------------
-# Added aliases from legacy implementation and data-engineering verification
-# -----------------------------------------------------------------------------
-CONTENTFIND_AIGC_READONLY_PRODUCE_PLAN_ID=20260408085649635441036
-CONTENTFIND_API_READONLY_TOKEN=8bf14f27fc3a486788f3383452422d72
-DATA_ENGINEERING_OPEN_AIGC_PG_DSN=postgresql://aiddit_aigc:%25a%26%26yqNxg%5EV1%24toJ%2AWOa%5E-b%5EX%3DQJ@gp-t4n72471pkmt4b9q7o-master.gpdbmaster.singapore.rds.aliyuncs.com:5432/open_aigc
-PGVECTOR_DSN=postgresql://aiddit_aigc:%25a%26%26yqNxg%5EV1%24toJ%2AWOa%5E-b%5EX%3DQJ@gp-t4n72471pkmt4b9q7o-master.gpdbmaster.singapore.rds.aliyuncs.com:5432/open_aigc
-OSS_ACCESS_KEY_ID=
-OSS_ACCESS_KEY_SECRET=
-OPEN_AIGC_PG_DATABASE=open_aigc
-
-# 火山方舟 embedding (Doubao-embedding-vision, 实测 2026-06-17)
-ARK_API_KEY=ark-6ca740e2-a719-4478-a8f8-bbb97985a784-059ce
-ARK_EMBEDDING_EP=ep-20260617180207-vmwg6
-ARK_EMBEDDING_URL=https://ark.cn-beijing.volces.com/api/v3/embeddings/multimodal
-ARK_EMBEDDING_DIM=2048

+ 111 - 11
content_agent/integrations/douyin.py

@@ -8,6 +8,8 @@ import httpx
 # 共享 crawapi 基座(V3-M1A):HTTP/限流/限流错误识别/env helper 集中于 crawapi_http,
 # 共享 crawapi 基座(V3-M1A):HTTP/限流/限流错误识别/env helper 集中于 crawapi_http,
 # 下方 re-export 保持既有外部 import(测试、smoke 脚本)零改。
 # 下方 re-export 保持既有外部 import(测试、smoke 脚本)零改。
 from content_agent.integrations.crawapi_http import (
 from content_agent.integrations.crawapi_http import (
+    CrawapiBusinessError,
+    CrawapiTransientError,
     RATE_LIMIT_MESSAGE_TOKENS,
     RATE_LIMIT_MESSAGE_TOKENS,
     RateLimiter,
     RateLimiter,
     _env,
     _env,
@@ -19,6 +21,7 @@ from content_agent.integrations.crawapi_http import (
     score_from_statistics as _score_from_statistics,
     score_from_statistics as _score_from_statistics,
     search_previous_discovery_step as _search_previous_discovery_step,
     search_previous_discovery_step as _search_previous_discovery_step,
 )
 )
+from content_agent.errors import ContentAgentError
 from content_agent.integrations import platform_video_url
 from content_agent.integrations import platform_video_url
 
 
 RAW_CONTENT_ID_KEY = "_".join(["aweme", "id"])
 RAW_CONTENT_ID_KEY = "_".join(["aweme", "id"])
@@ -33,6 +36,8 @@ SEARCH_RATE_LIMIT_BUCKET = "douyin_search"
 BLOGGER_RATE_LIMIT_BUCKET = "douyin_blogger"
 BLOGGER_RATE_LIMIT_BUCKET = "douyin_blogger"
 DOUYIN_SEARCH_MIN_INTERVAL_SECONDS = 10.0
 DOUYIN_SEARCH_MIN_INTERVAL_SECONDS = 10.0
 DOUYIN_SEARCH_MAX_INTERVAL_SECONDS = 12.0
 DOUYIN_SEARCH_MAX_INTERVAL_SECONDS = 12.0
+NON_FALLBACK_BUSINESS_CODES = {"10001"}
+NON_FALLBACK_BUSINESS_MESSAGE_TOKENS = ("参数异常", "参数校验", "invalid parameter")
 
 
 
 
 class CrawapiDouyinClient:
 class CrawapiDouyinClient:
@@ -42,6 +47,7 @@ class CrawapiDouyinClient:
         self,
         self,
         base_url: str,
         base_url: str,
         keyword_path: str,
         keyword_path: str,
+        fallback_base_url: str = "",
         blogger_path: str = "",
         blogger_path: str = "",
         detail_path: str = "",
         detail_path: str = "",
         portrait_path: str = "",
         portrait_path: str = "",
@@ -58,6 +64,10 @@ class CrawapiDouyinClient:
         video_url_probe_fn: platform_video_url.ProbeFn | None = None,
         video_url_probe_fn: platform_video_url.ProbeFn | None = None,
     ) -> None:
     ) -> None:
         self.base_url = base_url.rstrip("/") + "/"
         self.base_url = base_url.rstrip("/") + "/"
+        self.fallback_base_url = fallback_base_url.rstrip("/") + "/" if fallback_base_url else ""
+        self._crawapi_base_urls = _dedupe_crawapi_base_urls(
+            [("primary", self.base_url), ("candidate", self.fallback_base_url)]
+        )
         self.keyword_path = keyword_path.lstrip("/")
         self.keyword_path = keyword_path.lstrip("/")
         self.blogger_path = blogger_path.lstrip("/")
         self.blogger_path = blogger_path.lstrip("/")
         self.detail_path = detail_path.lstrip("/")
         self.detail_path = detail_path.lstrip("/")
@@ -79,6 +89,7 @@ class CrawapiDouyinClient:
         env = _load_env_file(env_path)
         env = _load_env_file(env_path)
         return cls(
         return cls(
             base_url=_env("CONTENTFIND_API_CRAWAPI_BASE_URL", env, required=True),
             base_url=_env("CONTENTFIND_API_CRAWAPI_BASE_URL", env, required=True),
+            fallback_base_url=_env("CONTENTFIND_API_CRAWAPI_FALLBACK_BASE_URL", env, default=""),
             keyword_path=_env("CONTENTFIND_DOUYIN_KEYWORD_PATH", env, required=True),
             keyword_path=_env("CONTENTFIND_DOUYIN_KEYWORD_PATH", env, required=True),
             blogger_path=_env("CONTENTFIND_DOUYIN_BLOGGER_PATH", env, required=True),
             blogger_path=_env("CONTENTFIND_DOUYIN_BLOGGER_PATH", env, required=True),
             detail_path=_env(
             detail_path=_env(
@@ -347,17 +358,106 @@ class CrawapiDouyinClient:
         operation: str,
         operation: str,
         rate_limit_bucket: str | None = None,
         rate_limit_bucket: str | None = None,
     ) -> dict[str, Any]:
     ) -> dict[str, Any]:
-        return post_crawapi_json(
-            http_client=self.http_client,
-            base_url=self.base_url,
-            path=path,
-            payload=payload,
-            operation=operation,
-            timeout_seconds=self.timeout_seconds,
-            rate_limiter=self.rate_limiter,
-            rate_limit_bucket=rate_limit_bucket,
-            business_codes=RATE_LIMIT_BUSINESS_CODES,
-        )
+        attempts: list[dict[str, Any]] = []
+        for index, (role, base_url) in enumerate(self._crawapi_base_urls):
+            try:
+                return post_crawapi_json(
+                    http_client=self.http_client,
+                    base_url=base_url,
+                    path=path,
+                    payload=payload,
+                    operation=operation,
+                    timeout_seconds=self.timeout_seconds,
+                    rate_limiter=self.rate_limiter,
+                    rate_limit_bucket=rate_limit_bucket,
+                    business_codes=RATE_LIMIT_BUSINESS_CODES,
+                )
+            except ContentAgentError as exc:
+                attempt = _crawapi_host_attempt_summary(role, base_url, exc)
+                self._raise_crawapi_error(exc, attempts + [attempt])
+            except (CrawapiBusinessError, CrawapiTransientError, RuntimeError) as exc:
+                attempt = _crawapi_host_attempt_summary(role, base_url, exc)
+                if index == len(self._crawapi_base_urls) - 1 or not _should_try_crawapi_candidate(exc):
+                    self._raise_crawapi_error(exc, attempts + [attempt])
+                attempts.append(attempt)
+        raise RuntimeError(f"crawapi {operation} failed: no base_url configured")
+
+    def _raise_crawapi_error(self, exc: Exception, attempts: list[dict[str, Any]]) -> None:
+        if len(self._crawapi_base_urls) > 1:
+            detail = getattr(exc, "detail", None)
+            if not isinstance(detail, dict):
+                detail = {}
+                setattr(exc, "detail", detail)
+            detail["crawapi_host_attempts"] = attempts
+        raise exc
+
+
+def _dedupe_crawapi_base_urls(candidates: list[tuple[str, str]]) -> list[tuple[str, str]]:
+    result: list[tuple[str, str]] = []
+    seen: set[str] = set()
+    for role, base_url in candidates:
+        if not base_url:
+            continue
+        normalized = base_url.rstrip("/") + "/"
+        if normalized in seen:
+            continue
+        seen.add(normalized)
+        result.append((role, normalized))
+    return result
+
+
+def _should_try_crawapi_candidate(exc: Exception) -> bool:
+    if not isinstance(exc, CrawapiBusinessError):
+        return True
+    detail = getattr(exc, "detail", None)
+    if not isinstance(detail, dict):
+        return True
+    business_code = str(detail.get("business_code") or "")
+    business_message = str(detail.get("business_message") or "").lower()
+    if business_code in NON_FALLBACK_BUSINESS_CODES:
+        return False
+    return not any(
+        token.lower() in business_message
+        for token in NON_FALLBACK_BUSINESS_MESSAGE_TOKENS
+    )
+
+
+def _crawapi_host_attempt_summary(
+    role: str,
+    base_url: str,
+    exc: Exception,
+) -> dict[str, Any]:
+    summary: dict[str, Any] = {
+        "role": role,
+        "base_url": base_url.rstrip("/"),
+        "exception_type": type(exc).__name__,
+    }
+    message = str(exc)
+    if message:
+        summary["exception_message"] = message[:300]
+        http_status = _http_status_from_exception_message(message)
+        if http_status is not None:
+            summary["status_code"] = http_status
+    if isinstance(exc, ContentAgentError):
+        summary["error_code"] = exc.error_code.value
+    detail = getattr(exc, "detail", None)
+    if isinstance(detail, dict):
+        for key in ("operation", "business_code", "business_message", "status_code"):
+            if key in detail:
+                summary[key] = detail.get(key)
+        response_summary = detail.get("response_summary")
+        if isinstance(response_summary, dict):
+            for key in ("status_code", "content_type"):
+                if key in response_summary:
+                    summary[key] = response_summary.get(key)
+    return summary
+
+
+def _http_status_from_exception_message(message: str) -> int | None:
+    if "HTTP " not in message:
+        return None
+    status_text = message.split("HTTP ", 1)[1].split(";", 1)[0].strip()
+    return int(status_text) if status_text.isdigit() else None
 
 
 
 
 def _extract_play_url(item: dict[str, Any]) -> str | None:
 def _extract_play_url(item: dict[str, Any]) -> str | None:

+ 100 - 1
tests/test_douyin_client.py

@@ -34,9 +34,10 @@ def _response(status_code, data):
     )
     )
 
 
 
 
-def _client(responses, rate_limiter=None):
+def _client(responses, rate_limiter=None, fallback_base_url=""):
     return CrawapiDouyinClient(
     return CrawapiDouyinClient(
         base_url="http://crawapi.test",
         base_url="http://crawapi.test",
+        fallback_base_url=fallback_base_url,
         keyword_path="/crawler/dou_yin/keyword",
         keyword_path="/crawler/dou_yin/keyword",
         blogger_path="/crawler/dou_yin/blogger",
         blogger_path="/crawler/dou_yin/blogger",
         default_crawapi_account_ref="771431222",
         default_crawapi_account_ref="771431222",
@@ -175,6 +176,85 @@ def test_douyin_keyword_search_business_error_is_failed():
         client.search(_search_query("账号态失败"))
         client.search(_search_query("账号态失败"))
 
 
 
 
+def test_douyin_keyword_search_falls_back_to_candidate_on_unknown_business_error():
+    client = _client(
+        [
+            _response(200, {"code": 10000, "msg": "未知错误", "data": None}),
+            _response(200, {"code": 0, "data": {"data": [], "has_more": False}}),
+        ],
+        fallback_base_url="http://candidate.test",
+    )
+
+    assert client.search(_search_query("主站业务错误")) == []
+    assert [request["url"] for request in client.http_client.requests] == [
+        "http://crawapi.test/crawler/dou_yin/keyword",
+        "http://candidate.test/crawler/dou_yin/keyword",
+    ]
+
+
+def test_douyin_keyword_search_does_not_fallback_on_parameter_error():
+    client = _client(
+        [
+            _response(200, {"code": 10001, "msg": "参数异常: 参数校验不通过", "data": None}),
+            _response(200, {"code": 0, "data": {"data": [], "has_more": False}}),
+        ],
+        fallback_base_url="http://candidate.test",
+    )
+
+    with pytest.raises(RuntimeError, match="business_error code=10001"):
+        client.search(_search_query("参数错误"))
+    assert len(client.http_client.requests) == 1
+
+
+def test_douyin_keyword_search_falls_back_to_candidate_on_network_error():
+    client = _client(
+        [
+            httpx.ConnectError("network failed"),
+            _response(200, {"code": 0, "data": {"data": [], "has_more": False}}),
+        ],
+        fallback_base_url="http://candidate.test",
+    )
+
+    assert client.search(_search_query("网络失败候选")) == []
+    assert [request["url"] for request in client.http_client.requests] == [
+        "http://crawapi.test/crawler/dou_yin/keyword",
+        "http://candidate.test/crawler/dou_yin/keyword",
+    ]
+
+
+def test_douyin_keyword_search_candidate_uses_same_rate_limit_bucket():
+    limiter = FakeRateLimiter()
+    client = _client(
+        [
+            _response(200, {"code": 10000, "msg": "未知错误", "data": None}),
+            _response(200, {"code": 0, "data": {"data": [], "has_more": False}}),
+        ],
+        rate_limiter=limiter,
+        fallback_base_url="http://candidate.test",
+    )
+
+    client.search(_search_query("候选限流"))
+
+    assert limiter.buckets == ["douyin_search", "douyin_search"]
+
+
+def test_douyin_keyword_search_keeps_host_attempts_when_candidate_fails():
+    client = _client(
+        [
+            _response(200, {"code": 10000, "msg": "未知错误", "data": None}),
+            _response(500, {"error": "server failed"}),
+        ],
+        fallback_base_url="http://candidate.test",
+    )
+
+    with pytest.raises(RuntimeError, match="HTTP 500") as exc_info:
+        client.search(_search_query("主备都失败"))
+    attempts = exc_info.value.detail["crawapi_host_attempts"]
+    assert [attempt["role"] for attempt in attempts] == ["primary", "candidate"]
+    assert attempts[0]["business_code"] == "10000"
+    assert attempts[1]["status_code"] == 500
+
+
 def test_douyin_keyword_search_network_error_is_sanitized():
 def test_douyin_keyword_search_network_error_is_sanitized():
     client = _client([httpx.ConnectError("network failed")])
     client = _client([httpx.ConnectError("network failed")])
 
 
@@ -369,12 +449,15 @@ def test_fetch_author_works_normalizes_author_work_fields():
 
 
 def test_from_env_reads_blogger_path_and_sort_type(monkeypatch, tmp_path):
 def test_from_env_reads_blogger_path_and_sort_type(monkeypatch, tmp_path):
     monkeypatch.setenv("CONTENTFIND_API_CRAWAPI_BASE_URL", "http://crawapi.test")
     monkeypatch.setenv("CONTENTFIND_API_CRAWAPI_BASE_URL", "http://crawapi.test")
+    monkeypatch.setenv("CONTENTFIND_API_CRAWAPI_FALLBACK_BASE_URL", "http://candidate.test")
     monkeypatch.setenv("CONTENTFIND_DOUYIN_KEYWORD_PATH", "/crawler/dou_yin/keyword")
     monkeypatch.setenv("CONTENTFIND_DOUYIN_KEYWORD_PATH", "/crawler/dou_yin/keyword")
     monkeypatch.setenv("CONTENTFIND_DOUYIN_BLOGGER_PATH", "/crawler/dou_yin/blogger")
     monkeypatch.setenv("CONTENTFIND_DOUYIN_BLOGGER_PATH", "/crawler/dou_yin/blogger")
     monkeypatch.setenv("CONTENTFIND_DOUYIN_ACCOUNT_WORKS_DEFAULT_SORT_TYPE", "最热")
     monkeypatch.setenv("CONTENTFIND_DOUYIN_ACCOUNT_WORKS_DEFAULT_SORT_TYPE", "最热")
 
 
     client = CrawapiDouyinClient.from_env(env_path=tmp_path / "missing.env")
     client = CrawapiDouyinClient.from_env(env_path=tmp_path / "missing.env")
 
 
+    assert client.base_url == "http://crawapi.test/"
+    assert client.fallback_base_url == "http://candidate.test/"
     assert client.blogger_path == "crawler/dou_yin/blogger"
     assert client.blogger_path == "crawler/dou_yin/blogger"
     assert client.default_account_works_sort_type == "最热"
     assert client.default_account_works_sort_type == "最热"
     assert client.max_results_per_query == 5
     assert client.max_results_per_query == 5
@@ -473,6 +556,22 @@ def test_http_429_maps_to_platform_rate_limited():
     assert exc_info.value.detail["status_code"] == 429
     assert exc_info.value.detail["status_code"] == 429
 
 
 
 
+def test_http_429_does_not_fallback_to_candidate():
+    client = _client(
+        [
+            _response(429, {"error": "too many"}),
+            _response(200, {"code": 0, "data": {"data": [], "has_more": False}}),
+        ],
+        fallback_base_url="http://candidate.test",
+    )
+
+    with pytest.raises(ContentAgentError) as exc_info:
+        client.search(_search_query("候选也不能绕限流"))
+    assert exc_info.value.error_code == ErrorCode.PLATFORM_RATE_LIMITED
+    assert len(client.http_client.requests) == 1
+    assert exc_info.value.detail["crawapi_host_attempts"][0]["role"] == "primary"
+
+
 def test_business_rate_limit_code_maps_to_platform_rate_limited(monkeypatch):
 def test_business_rate_limit_code_maps_to_platform_rate_limited(monkeypatch):
     from content_agent.integrations import douyin
     from content_agent.integrations import douyin