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

feat: implement V5 runtime configuration foundations

Sam Lee 1 месяц назад
Родитель
Сommit
1271e43811
31 измененных файлов с 1377 добавлено и 300 удалено
  1. 10 1
      content_agent/api.py
  2. 2 2
      content_agent/business_modules/policy_version.py
  3. 152 15
      content_agent/business_modules/rule_judgment/evaluator.py
  4. 34 11
      content_agent/business_modules/run_record/validation.py
  5. 4 2
      content_agent/business_modules/walk_engine.py
  6. 29 7
      content_agent/flow_ledger_service.py
  7. 5 1
      content_agent/graph.py
  8. 37 3
      content_agent/integrations/kuaishou.py
  9. 15 5
      content_agent/integrations/policy_json.py
  10. 61 8
      content_agent/integrations/walk_graph_json.py
  11. 25 8
      content_agent/integrations/walk_strategy_json.py
  12. 1 1
      content_agent/interfaces.py
  13. 1 120
      product_documents/抖音游走策略/douyin_walk_strategy.v1.json
  14. 172 0
      product_documents/规则包/douyin_rule_packs.v1.json
  15. 2 2
      tech_documents/数据接口与来源/00_数据接口总览.md
  16. 44 1
      tech_documents/数据接口与来源/crawler_endpoints.registry.json
  17. 3 3
      tech_documents/数据接口与来源/platform_profiles/bilibili.json
  18. 181 29
      tech_documents/数据接口与来源/platform_profiles/douyin.json
  19. 43 12
      tech_documents/数据接口与来源/platform_profiles/github.json
  20. 77 12
      tech_documents/数据接口与来源/platform_profiles/kuaishou.json
  21. 120 22
      tech_documents/数据接口与来源/platform_profiles/shipinhao.json
  22. 46 3
      tech_documents/数据接口与来源/platform_profiles/toutiao.json
  23. 5 5
      tech_documents/数据接口与来源/platform_profiles/weixin.json
  24. 1 1
      tech_documents/数据接口与来源/platform_profiles/xiaohongshu.json
  25. 4 4
      tech_documents/数据接口与来源/platform_profiles/youtube.json
  26. 46 3
      tech_documents/数据接口与来源/platform_profiles/zhihu.json
  27. 123 0
      tech_documents/数据接口与来源/walk_edge_catalog.json
  28. 3 3
      tech_documents/数据接口与来源/walk_graph.json
  29. 107 11
      tech_documents/数据接口与来源/walk_policy.json
  30. 20 1
      tech_documents/数据接口与来源/接口台账/快手.md
  31. 4 4
      tech_documents/数据接口与来源/游走图谱.md

+ 10 - 1
content_agent/api.py

@@ -202,7 +202,16 @@ def get_config_rule_packs() -> ConfigFileResponse:
 
 @app.get("/config/walk-strategy", response_model=ConfigFileResponse)
 def get_config_walk_strategy() -> ConfigFileResponse:
-    return _config_file_response("walk-strategy")
+    response = _config_file_response("walk-strategy")
+    if "walk_edge_catalog" not in response.data:
+        from pathlib import Path
+
+        from content_agent.integrations import config_store
+        from content_agent.integrations.walk_strategy_json import EDGE_CATALOG_PATH
+
+        catalog, _ = config_store.load_json(Path(EDGE_CATALOG_PATH))
+        response.data["walk_edge_catalog"] = catalog.get("walk_edge_catalog", [])
+    return response
 
 
 @app.get("/config/query-prompts", response_model=ConfigFileResponse)

+ 2 - 2
content_agent/business_modules/policy_version.py

@@ -5,5 +5,5 @@ from typing import Any
 from content_agent.interfaces import PolicyBundleStore
 
 
-def run(strategy_version: str, policy_store: PolicyBundleStore) -> dict[str, Any]:
-    return policy_store.load_policy_bundle(strategy_version)
+def run(strategy_version: str, policy_store: PolicyBundleStore, platform: str = "douyin") -> dict[str, Any]:
+    return policy_store.load_policy_bundle(strategy_version, platform=platform)

+ 152 - 15
content_agent/business_modules/rule_judgment/evaluator.py

@@ -36,9 +36,7 @@ RULE_DECISION_RAW_PAYLOAD_EXCLUDE_KEYS = {
     "source_evidence",
     "decision_replay_data",
 }
-# M9B:抖音 50+ 子分。仅当 bundle 带 content_audience_50plus 块(抖音)时计入;
-# 非抖音 bundle 无块 → 维持旧 0.5/0.5(零改动)。权重在 evaluator 计算,不进 rule pack
-# 维度(config 合同要求 active 维度恰为 query_relevance/platform_performance)。
+# V5-M2:V4 权重从规则包 score_weight_profiles 读取;缺配置时 fallback 保持历史行为。
 V4_PORTRAIT_UNAVAILABLE_REASON = "portrait_unavailable"
 V4_PORTRAIT_INCOMPLETE_REASON = "portrait_incomplete"
 
@@ -69,7 +67,7 @@ def decide(
         if _is_v4_policy(policy_bundle):
             replay_marker.update(_v4_walk_gate(False, primary_gate["decision_reason_code"]))
             if primary_gate["decision_reason_code"] == V4_TECHNICAL_RETRY_REASON:
-                scorecard = _v4_scorecard_total(bundle)
+                scorecard = _v4_scorecard_total(bundle, policy_bundle)
         effect = _effect_status_for_decision(
             policy_bundle,
             primary_gate["decision_action"],
@@ -165,7 +163,7 @@ def _decide_v4(
     matched_gates: list[dict[str, Any]],
     triggered_blocking_rules: list[str],
 ) -> dict[str, Any]:
-    scorecard = _v4_scorecard_total(bundle)
+    scorecard = _v4_scorecard_total(bundle, policy_bundle)
     score = scorecard.get("total_score")
     thresholds = _v4_thresholds(policy_bundle, _get_path(bundle, "content.platform"))
     scorecard["score_thresholds"] = thresholds  # 随决策落库:供前端展示实际生效门槛(单一真源)
@@ -206,7 +204,7 @@ def _decide_v4(
     )
 
 
-def _v4_scorecard_total(bundle: dict[str, Any]) -> dict[str, Any]:
+def _v4_scorecard_total(bundle: dict[str, Any], policy_bundle: dict[str, Any]) -> dict[str, Any]:
     query_score = _query_relevance_from_bundle(bundle)
     platform = _platform_performance_from_bundle(bundle)
     platform_score = platform.get("platform_performance_score") if isinstance(platform, dict) else None
@@ -220,26 +218,40 @@ def _v4_scorecard_total(bundle: dict[str, Any]) -> dict[str, Any]:
         "missing_observable_fields": missing if isinstance(missing, list) else [],
     }
     fifty = _fifty_plus_from_bundle(bundle)
+    selected_profile, weights = _v4_score_weight_profile(policy_bundle, bundle)
+    scorecard["score_weights"] = weights
+    missing_failure = _v4_missing_critical_dimension_failure(selected_profile, bundle)
+    if missing_failure:
+        scorecard.update(missing_failure)
+        scorecard["total_score"] = None
+        scorecard["score_missing"] = True
+        scorecard.update(_technical_failure_fields(bundle))
+        return scorecard
     if fifty is None:
-        # 非抖音:旧 0.5/0.5,零改动。
-        total = (
-            round(float(query_score) * 0.5 + float(platform_score) * 0.5, 2)
-            if _is_number(query_score) and _is_number(platform_score)
-            else None
+        total = _v4_weighted_total(
+            {"query_relevance": query_score, "platform_performance": platform_score},
+            weights,
         )
     else:
-        # 抖音:Query 35% + 平台 35% + 50+ 30%(块存在才计)。
         status = fifty.get("status")
         scorecard["fifty_plus_status"] = status
         fifty_score = fifty.get("score")
         if status == "ok" and _is_number(query_score) and _is_number(platform_score) and _is_number(fifty_score):
             scorecard["fifty_plus_score"] = fifty_score
             scorecard["fifty_plus_components"] = fifty.get("components", {})
-            total = round(
-                float(query_score) * 0.35 + float(platform_score) * 0.35 + float(fifty_score) * 0.30, 2
+            total = _v4_weighted_total(
+                {
+                    "query_relevance": query_score,
+                    "platform_performance": platform_score,
+                    "fifty_plus": fifty_score,
+                },
+                weights,
             )
         elif status == "not_attempted" and _is_number(query_score) and _is_number(platform_score):
-            total = round(float(query_score) * 0.35 + float(platform_score) * 0.35, 2)
+            total = _v4_weighted_total(
+                {"query_relevance": query_score, "platform_performance": platform_score},
+                weights,
+            )
         else:
             # unavailable / incomplete / 缺分 → 技术失败(停游走)。把失败类型+原因写进 scorecard,供技术详情展示。
             total = None
@@ -259,6 +271,131 @@ def _v4_scorecard_total(bundle: dict[str, Any]) -> dict[str, Any]:
     return scorecard
 
 
+_V4_SCORE_WEIGHT_FALLBACK_PROFILES = {
+    "default_two_dimension": {"query_relevance": 50, "platform_performance": 50},
+    "douyin_fifty_plus_ok": {"query_relevance": 35, "platform_performance": 35, "fifty_plus": 30},
+    "douyin_fifty_plus_not_attempted": {"query_relevance": 35, "platform_performance": 35},
+}
+
+
+def _v4_score_weights(policy_bundle: dict[str, Any], bundle: dict[str, Any]) -> dict[str, float]:
+    _, weights = _v4_score_weight_profile(policy_bundle, bundle)
+    return weights
+
+
+def _v4_score_weight_profile(policy_bundle: dict[str, Any], bundle: dict[str, Any]) -> tuple[dict[str, Any], dict[str, float]]:
+    profiles = (
+        policy_bundle.get("rule_pack", {})
+        .get("scorecard", {})
+        .get("score_weight_profiles")
+    )
+    generic_profile = _select_generic_score_weight_profile(profiles, bundle)
+    if generic_profile:
+        return generic_profile, _decimal_weights(generic_profile.get("weights"))
+    if not isinstance(profiles, dict) or "profiles" in profiles:
+        profiles = _V4_SCORE_WEIGHT_FALLBACK_PROFILES
+    fifty = _fifty_plus_from_bundle(bundle)
+    if fifty is None:
+        profile_name = "default_two_dimension"
+    elif fifty.get("status") == "not_attempted":
+        profile_name = "douyin_fifty_plus_not_attempted"
+    else:
+        profile_name = "douyin_fifty_plus_ok"
+    raw_weights = profiles.get(profile_name)
+    if not isinstance(raw_weights, dict) or not raw_weights:
+        raw_weights = _V4_SCORE_WEIGHT_FALLBACK_PROFILES[profile_name]
+    return {"profile_id": profile_name, "weights": raw_weights}, _decimal_weights(raw_weights)
+
+
+def _decimal_weights(raw_weights: Any) -> dict[str, float]:
+    if not isinstance(raw_weights, dict):
+        return {}
+    return {
+        str(key): float(value) / 100
+        for key, value in raw_weights.items()
+        if _is_number(value)
+    }
+
+
+def _select_generic_score_weight_profile(profiles: Any, bundle: dict[str, Any]) -> dict[str, Any] | None:
+    if not isinstance(profiles, dict) or not isinstance(profiles.get("profiles"), list):
+        return None
+    platform = str(_get_path(bundle, "content.platform") or "")
+    content_format = str(_get_path(bundle, "content.platform_content_format") or "video")
+    matches = [
+        profile
+        for profile in profiles["profiles"]
+        if isinstance(profile, dict)
+        and profile.get("approval_status") == "approved"
+        and profile.get("runtime_enabled") is True
+        and _scope_matches(profile.get("platform_scope"), platform)
+        and _content_format_matches(profile.get("content_format"), content_format)
+        and _applies_when_matches(str(profile.get("applies_when") or "always"), bundle)
+    ]
+    if not matches:
+        return None
+    matches.sort(key=lambda row: int(row.get("priority") or 0), reverse=True)
+    if len(matches) > 1 and int(matches[0].get("priority") or 0) == int(matches[1].get("priority") or 0):
+        raise ValueError(f"score_weight_profile_priority_conflict: {matches[0].get('profile_id')}, {matches[1].get('profile_id')}")
+    return matches[0]
+
+
+def _scope_matches(scope: Any, platform: str) -> bool:
+    values = scope if isinstance(scope, list) else [scope]
+    return "*" in values or platform in values
+
+
+def _content_format_matches(scope: Any, content_format: str) -> bool:
+    if scope in {None, "", "*"}:
+        return True
+    values = scope if isinstance(scope, list) else [scope]
+    return "*" in values or content_format in values
+
+
+def _applies_when_matches(expr: str, bundle: dict[str, Any]) -> bool:
+    if expr in {"", "always"}:
+        return True
+    if expr == "no_extra_dimension_available":
+        return _fifty_plus_from_bundle(bundle) is None
+    if expr.startswith("field_exists:"):
+        return _get_path(bundle, expr.removeprefix("field_exists:")) is not None
+    if expr.startswith("field_missing:"):
+        return _get_path(bundle, expr.removeprefix("field_missing:")) is None
+    if expr.startswith("field_equals:"):
+        _, path, expected = expr.split(":", 2)
+        return str(_get_path(bundle, path)) == expected
+    raise ValueError(f"unsupported score_weight_profile applies_when: {expr}")
+
+
+def _v4_missing_critical_dimension_failure(profile: dict[str, Any], bundle: dict[str, Any]) -> dict[str, Any] | None:
+    if profile.get("missing_dimension_policy") != "technical_retry":
+        return None
+    paths = profile.get("score_source_paths") if isinstance(profile.get("score_source_paths"), dict) else {}
+    for dimension in profile.get("critical_dimensions") or []:
+        source_path = paths.get(dimension)
+        if source_path and _get_path(bundle, source_path) is None:
+            return {
+                "failure_type": "critical_dimension_missing",
+                "final_status": "failed",
+                "missing_dimension_key": dimension,
+                "missing_dimension_score_source_path": source_path,
+                "missing_dimension_failure_reason": f"missing critical dimension {dimension} at {source_path}",
+            }
+    return None
+
+
+def _v4_weighted_total(scores: dict[str, Any], weights: dict[str, float]) -> float | None:
+    if not weights:
+        return None
+    total = 0.0
+    for key, weight in weights.items():
+        score = scores.get(key)
+        if not _is_number(score):
+            return None
+        total += float(score) * weight
+    return round(total, 2)
+
+
 def _fifty_plus_from_bundle(bundle: dict[str, Any]) -> dict[str, Any] | None:
     block = bundle.get("content_audience_50plus")
     return block if isinstance(block, dict) else None

+ 34 - 11
content_agent/business_modules/run_record/validation.py

@@ -961,17 +961,14 @@ def _check_v4_score_contract(data: dict[str, Any], findings: list[dict[str, Any]
                 f"decision {decision.get('decision_id')} missing_observable_fields must be a list",
             )
         if _is_number(query_score) and _is_number(platform_score) and _is_number(total_score):
-            # M9B:抖音(scorecard 带 fifty_plus_status)走 35/35/30 或 35/35(not_attempted);
-            # 非抖音(无该字段)维持 50/50。
-            fifty_status = scorecard.get("fifty_plus_status")
-            fifty_score = scorecard.get("fifty_plus_score")
-            if fifty_status == "ok" and _is_number(fifty_score):
-                expected = query_score * 0.35 + platform_score * 0.35 + fifty_score * 0.30
-            elif fifty_status == "not_attempted":
-                expected = query_score * 0.35 + platform_score * 0.35
-            else:
-                expected = query_score * 0.5 + platform_score * 0.5
-            if abs(total_score - expected) > 0.01:
+            weights = _v4_score_weights_from_scorecard(scorecard)
+            scores = {
+                "query_relevance": query_score,
+                "platform_performance": platform_score,
+                "fifty_plus": scorecard.get("fifty_plus_score"),
+            }
+            expected = _weighted_score(scores, weights)
+            if not _is_number(expected) or abs(total_score - expected) > 0.01:
                 _fail(
                     findings,
                     "v4_score_total_mismatch",
@@ -979,6 +976,32 @@ def _check_v4_score_contract(data: dict[str, Any], findings: list[dict[str, Any]
                 )
 
 
+def _v4_score_weights_from_scorecard(scorecard: dict[str, Any]) -> dict[str, float]:
+    weights = scorecard.get("score_weights")
+    if isinstance(weights, dict) and weights:
+        return {
+            str(key): float(value)
+            for key, value in weights.items()
+            if _is_number(value)
+        }
+    fifty_status = scorecard.get("fifty_plus_status")
+    if fifty_status == "ok" and _is_number(scorecard.get("fifty_plus_score")):
+        return {"query_relevance": 0.35, "platform_performance": 0.35, "fifty_plus": 0.30}
+    if fifty_status == "not_attempted":
+        return {"query_relevance": 0.35, "platform_performance": 0.35}
+    return {"query_relevance": 0.5, "platform_performance": 0.5}
+
+
+def _weighted_score(scores: dict[str, Any], weights: dict[str, float]) -> float | None:
+    total = 0.0
+    for key, weight in weights.items():
+        score = scores.get(key)
+        if not _is_number(score):
+            return None
+        total += float(score) * weight
+    return total
+
+
 def _check_v4_walk_gate_contract(data: dict[str, Any], findings: list[dict[str, Any]]) -> None:
     for decision in data.get("rule_decisions.jsonl", []):
         if not _is_v4_contract_record(decision):

+ 4 - 2
content_agent/business_modules/walk_engine.py

@@ -22,6 +22,7 @@ from content_agent.business_modules.progressive_screening import (
 from content_agent.constants import RUNTIME_RECORD_SCHEMA_VERSION
 from content_agent.integrations.walk_graph_json import (
     WalkGraphStore,
+    edge_budgets_for_platform,
     edge_permission,
     edge_supported,
 )
@@ -71,12 +72,13 @@ def run_bounded_walk(
     store = WalkGraphStore()
     policy = store.load_policy()
     max_depth = int(policy["global"]["max_depth"])
-    max_total_actions = int(policy["global"]["max_total_actions_per_run"])
-    budgets = policy["edge_budgets_by_id"]
     platform = next(
         (item["platform"] for item in discovered_content_items if item.get("platform")),
         "douyin",
     )
+    max_total_actions = int(policy["global"]["max_total_actions_per_run"])
+    budgets = edge_budgets_for_platform(policy, platform)
+    policy = {**policy, "edge_budgets_by_id": budgets}
     profile = store.load_profile(platform)
     content_pack = {
         "rule_pack_id": policy_bundle["rule_pack_id"],

+ 29 - 7
content_agent/flow_ledger_service.py

@@ -78,9 +78,10 @@ REASON_LABELS = {
     "v4_allow_walk_denied": "不适合继续向外扩展",
     "budget_exhausted": "达到该类游走次数上限",
     "global_action_cap_reached": "已达全 run 游走次数上限",
-    "edge_blocked_by_platform_profile": "该平台不做这种游走(非抖音不走作者)",
+    "edge_blocked_by_platform_profile": "该平台 profile 阻断这种游走",
     "portrait_unavailable": "拉不到作者 50+ 画像(热点宝接口失败),无法判定",
     "portrait_incomplete": "作者 50+ 画像字段缺失,无法判定",
+    "query>=65/platform>=65/score>=65": "评分达到继续扩展门槛",
     "query>=70/platform>=65/score>=70": "评分达到继续扩展门槛",
     "content_decision_reused_for_walk_gate": "复用本条视频的内容判断作为扩展门槛",
     "no_decision": "暂未产生判断结果",
@@ -685,6 +686,7 @@ class FlowLedgerService:
         score_items = _walk_score_items(action)
         result_label = _walk_result_label(action, edge, status, reason, source_video, target_query_summary, target_videos)
         reason_label = _walk_reason_label(action, reason)
+        operator_display_status = _walk_operator_display_status(status, reason)
         return {
             "id": _text(action.get("walk_action_id")),
             "edge_id": edge,
@@ -696,6 +698,7 @@ class FlowLedgerService:
             "trigger_label": trigger_label,
             "status": status,
             "status_label": _walk_status_label(status),
+            "operator_display_status": operator_display_status,
             "from_label": from_label,
             "result": result_label,
             "reason": reason,
@@ -1346,6 +1349,10 @@ def _walk_impact(status: str, reason: str, gate_reason: str) -> str:
     return "这一步暂不影响最终沉淀。"
 
 
+def _walk_operator_display_status(status: str, reason: str) -> str:
+    return "budget_blocked" if status == "skipped" and reason == "budget_exhausted" else status
+
+
 def _walk_trigger_label(
     action: dict[str, Any],
     source_content: dict[str, Any] | None = None,
@@ -1492,18 +1499,33 @@ def _decision_score_items(scorecard: dict[str, Any], score: Any) -> list[dict[st
 
 
 def _v4_display_weights(scorecard: dict[str, Any]) -> tuple[dict[str, float], dict[str, Any] | None]:
-    """由 scorecard 现有键推导展示用顶层权重 + 50+ 信息(纯展示层)。
-    须与 evaluator.py:195-220 的权重逻辑保持同步:
-    非抖音(无 fifty_plus_status)= 50/50;抖音 = 35/35/30(50+ 块存在才有该键)。
-    """
+    """展示 evaluator 已落在 scorecard 的权重;旧记录无 score_weights 时保留历史推断。"""
+    weights = _display_weights_from_scorecard(scorecard)
     if "fifty_plus_status" not in scorecard:
-        return {"query": 0.5, "platform": 0.5}, None
+        return weights or {"query": 0.5, "platform": 0.5}, None
     status = scorecard.get("fifty_plus_status")
     fifty: dict[str, Any] = {"status": status}
     if status == "ok":
         fifty["score"] = scorecard.get("fifty_plus_score")
         fifty["components"] = _record(scorecard.get("fifty_plus_components"))
-    return {"query": 0.35, "platform": 0.35, "fifty": 0.30}, fifty
+    return weights or {"query": 0.35, "platform": 0.35, "fifty": 0.30}, fifty
+
+
+def _display_weights_from_scorecard(scorecard: dict[str, Any]) -> dict[str, float]:
+    weights = scorecard.get("score_weights")
+    if not isinstance(weights, dict):
+        return {}
+    display = {}
+    mapping = {
+        "query_relevance": "query",
+        "platform_performance": "platform",
+        "fifty_plus": "fifty",
+    }
+    for source_key, target_key in mapping.items():
+        value = weights.get(source_key)
+        if isinstance(value, (int, float)) and not isinstance(value, bool):
+            display[target_key] = float(value)
+    return display
 
 
 def _fifty_status_note(status: Any) -> str:

+ 5 - 1
content_agent/graph.py

@@ -142,7 +142,11 @@ def build_run_graph(deps: RunDependencies):
         return {**result, "current_step": "recall_pattern"}
 
     def load_policy(state: RunState) -> dict[str, Any]:
-        bundle = policy_version.run(state["strategy_version"], deps.policy_store)
+        bundle = policy_version.run(
+            state["strategy_version"],
+            deps.policy_store,
+            platform=state.get("platform", "douyin"),
+        )
         return {
             "policy_bundle": bundle,
             "policy_bundle_id": bundle["policy_bundle_id"],

+ 37 - 3
content_agent/integrations/kuaishou.py

@@ -1,7 +1,6 @@
-"""快手(kuaishou)接入 client (V4-M2).
+"""快手(kuaishou)接入 client.
 
-复用 crawapi_http 共享基座。M2 只接入关键词搜索、内容详情和账号信息;
-作者作品接口未实测可用,不在 client 暴露为可靠能力。
+复用 crawapi_http 共享基座。M3 接入关键词搜索、内容详情、账号信息和作者作品。
 """
 
 from __future__ import annotations
@@ -25,6 +24,7 @@ from content_agent.integrations import platform_video_url
 SEARCH_RATE_LIMIT_BUCKET = "kuaishou_search"
 DETAIL_RATE_LIMIT_BUCKET = "kuaishou_detail"
 ACCOUNT_RATE_LIMIT_BUCKET = "kuaishou_account_info"
+BLOGGER_RATE_LIMIT_BUCKET = "kuaishou_blogger"
 KUAISHOU_SEARCH_MIN_INTERVAL_SECONDS = 10.0
 KUAISHOU_SEARCH_MAX_INTERVAL_SECONDS = 12.0
 _TAG_RE = re.compile(r"#([^\s#@((]+)")
@@ -127,6 +127,7 @@ class CrawapiKuaishouClient:
         keyword_path: str = "/crawler/kuai_shou/keyword_v2",
         detail_path: str = "/crawler/kuai_shou/detail",
         account_info_path: str = "/crawler/kuai_shou/account_info",
+        blogger_path: str = "/crawler/kuai_shou/blogger",
         timeout_seconds: float = 60.0,
         max_results_per_query: int | None = 5,
         http_client: Any | None = None,
@@ -139,6 +140,7 @@ class CrawapiKuaishouClient:
         self.keyword_path = keyword_path.lstrip("/")
         self.detail_path = detail_path.lstrip("/")
         self.account_info_path = account_info_path.lstrip("/")
+        self.blogger_path = blogger_path.lstrip("/")
         self.timeout_seconds = timeout_seconds
         self.max_results_per_query = max_results_per_query
         self.http_client = http_client or httpx.Client(timeout=timeout_seconds)
@@ -165,6 +167,11 @@ class CrawapiKuaishouClient:
                 env,
                 default="/crawler/kuai_shou/account_info",
             ),
+            blogger_path=_env(
+                "CONTENTFIND_KUAISHOU_BLOGGER_PATH",
+                env,
+                default="/crawler/kuai_shou/blogger",
+            ),
             timeout_seconds=float(
                 _env("CONTENTFIND_API_CRAWAPI_TIMEOUT_SECONDS", env, default="180")
             ),
@@ -367,6 +374,33 @@ class CrawapiKuaishouClient:
             "raw_payload": account,
         }
 
+    def fetch_author_works(self, query: dict[str, Any]) -> list[dict[str, Any]]:
+        author_id = str(query["platform_author_id"])
+        data = self._post_json(
+            self.blogger_path,
+            {"account_id": author_id},
+            operation="blogger",
+            rate_limit_bucket=BLOGGER_RATE_LIMIT_BUCKET,
+        )
+        block = data.get("data", {}) if isinstance(data.get("data"), dict) else {}
+        items = block.get("data", []) if isinstance(block.get("data"), list) else []
+        has_more = bool(block.get("has_more", False))
+        next_cursor = str(block.get("next_cursor") or "")
+        results: list[dict[str, Any]] = []
+        for index, item in enumerate(items, start=1):
+            normalized = _normalize_kuaishou_item(
+                query,
+                item,
+                index,
+                has_more,
+                next_cursor,
+                self._select_video_url_with_detail_fallback(item),
+            )
+            normalized["previous_discovery_step"] = "author_works"
+            normalized["content_metadata_source"] = "kuaishou_blogger"
+            results.append(normalized)
+        return results
+
     def _post_json(
         self,
         path: str,

+ 15 - 5
content_agent/integrations/policy_json.py

@@ -13,7 +13,7 @@ class JsonPolicyBundleStore:
     def __init__(self, root_dir: Path | str = Path(".")) -> None:
         self.root_dir = Path(root_dir)
 
-    def load_policy_bundle(self, strategy_version: str) -> dict[str, Any]:
+    def load_policy_bundle(self, strategy_version: str, platform: str = "douyin") -> dict[str, Any]:
         rule_pack_path = self.root_dir / "product_documents/规则包/douyin_rule_packs.v1.json"
         if not rule_pack_path.exists():
             raise FileNotFoundError(rule_pack_path)
@@ -25,9 +25,13 @@ class JsonPolicyBundleStore:
         if strategy_version != actual_strategy_version:
             raise ValueError(f"unknown strategy_version: {strategy_version}")
 
-        dispatch = _select_dispatch(rule_package, actual_strategy_version)
+        dispatch = _select_dispatch(rule_package, actual_strategy_version, platform=platform)
         rule_pack = _find_rule_pack_by_dispatch(rule_package, dispatch)
-        rule_pack_by_entity = _build_rule_pack_by_entity(rule_package, actual_strategy_version)
+        rule_pack_by_entity = _build_rule_pack_by_entity(
+            rule_package,
+            actual_strategy_version,
+            platform=platform,
+        )
         walk_strategy = WalkStrategyStore(self.root_dir).load_walk_strategy()
         # V4 判定/游走门槛:独立可手维护配置(不走 Excel→规则包管线),按平台解耦。
         score_thresholds_path = self.root_dir / "tech_documents/数据接口与来源/score_thresholds.json"
@@ -74,6 +78,7 @@ class JsonPolicyBundleStore:
                 "content_sha256": rule_pack_hash,
                 "generated_from": "local_product_json",
             },
+            "platform": platform,
             "policy_bundle_hash": rule_pack_hash,
             "score_thresholds": score_thresholds,
         }
@@ -146,12 +151,17 @@ def _assert_single_enabled_dispatch(
     )
 
 
-def _build_rule_pack_by_entity(rule_package: dict[str, Any], strategy_version: str) -> dict[str, Any]:
+def _build_rule_pack_by_entity(
+    rule_package: dict[str, Any],
+    strategy_version: str,
+    *,
+    platform: str = "douyin",
+) -> dict[str, Any]:
     by_entity: dict[str, Any] = {}
     for dispatch in rule_package.get("rule_pack_dispatch", []):
         if not (
             dispatch.get("dispatch_enabled")
-            and dispatch.get("platform") == "douyin"
+            and dispatch.get("platform") == platform
             and dispatch.get("runtime_stage") == "V1.0"
             and dispatch.get("strategy_version") == strategy_version
         ):

+ 61 - 8
content_agent/integrations/walk_graph_json.py

@@ -16,7 +16,8 @@ from content_agent.findings import fail as _fail
 WALK_GRAPH_PATH = Path("tech_documents/数据接口与来源/walk_graph.json")
 WALK_POLICY_PATH = Path("tech_documents/数据接口与来源/walk_policy.json")
 PROFILE_DIR = Path("tech_documents/数据接口与来源/platform_profiles")
-EDGE_CATALOG_PATH = Path("product_documents/抖音游走策略/douyin_walk_strategy.v1.json")
+EDGE_CATALOG_PATH = Path("tech_documents/数据接口与来源/walk_edge_catalog.json")
+PROFILE_EDGE_STATUSES = {"supported", "blocked", "no_interface", "planned"}
 
 PERMISSION_ACTIONS = [
     "ADD_TO_CONTENT_POOL",
@@ -44,9 +45,24 @@ def edge_permission(policy: dict[str, Any], decision_action: str | None, edge_id
     return row.get(edge_id, "deny")
 
 
+def edge_budgets_for_platform(policy: dict[str, Any], platform: str | None) -> dict[str, dict[str, Any]]:
+    """Return edge budgets for a platform, falling back to default then legacy rows."""
+    selected = dict(policy.get("edge_budgets_by_id") or {})
+    by_platform = policy.get("platform_edge_budgets_by_platform_edge") or {}
+    for edge_id in selected:
+        explicit = by_platform.get((platform or "", edge_id))
+        default = by_platform.get(("default", edge_id))
+        if explicit:
+            selected[edge_id] = {**selected[edge_id], **explicit}
+        elif default:
+            selected[edge_id] = {**selected[edge_id], **default}
+    return selected
+
+
 def edge_supported(profile: dict[str, Any], edge_id: str) -> bool:
     """profile 未列出的边 = 平台无关控制边(终端边),恒 supported。"""
-    return profile["edges"].get(edge_id, {"status": "supported"})["status"] == "supported"
+    spec = profile["edges"].get(edge_id, {"status": "supported"})
+    return isinstance(spec, dict) and spec.get("status") == "supported"
 
 
 @dataclass(frozen=True)
@@ -58,7 +74,7 @@ class WalkGraphStore:
 
         graph, _ = config_store.load_json(self.root_dir / WALK_GRAPH_PATH)
         catalog, _ = config_store.load_json(self.root_dir / EDGE_CATALOG_PATH)
-        catalog_ids = {row["edge_id"] for row in catalog["walk_edge_catalog"]}
+        catalog_ids = _catalog_edge_ids(catalog)
         _raise_on_fail(_validate_graph(graph, catalog_ids), "walk graph")
         return graph
 
@@ -91,6 +107,11 @@ def _unwrap_policy(raw: dict[str, Any]) -> dict[str, Any]:
     }
     policy["budget_tiers"] = {key: _unwrap(value) for key, value in raw["budget_tiers"].items()}
     policy["edge_budgets_by_id"] = {row["edge_id"]: row for row in raw["edge_budgets"]}
+    policy["platform_edge_budgets_by_platform_edge"] = {
+        (row.get("platform"), row.get("edge_id")): row
+        for row in raw.get("platform_edge_budgets", [])
+        if row.get("platform") and row.get("edge_id")
+    }
     return policy
 
 
@@ -119,12 +140,17 @@ def _optional_positive_int(value: str | None) -> int | None:
 def _validate_graph(graph: dict[str, Any], catalog_ids: set[str]) -> list[dict[str, Any]]:
     findings: list[dict[str, Any]] = []
     node_types = {node["node_type"] for node in graph.get("nodes", [])}
+    graph_edge_ids = set()
     for edge in graph.get("edges", []):
-        if edge.get("edge_id") not in catalog_ids:
-            _fail(findings, "edge_not_in_catalog", f"{edge.get('edge_id')} not in walk_edge_catalog")
+        edge_id = edge.get("edge_id")
+        graph_edge_ids.add(edge_id)
+        if edge_id not in catalog_ids:
+            _fail(findings, "edge_not_in_catalog", f"{edge_id} not in walk_edge_catalog")
         for field in ["from_node", "to_node"]:
             if edge.get(field) not in node_types:
-                _fail(findings, "edge_node_ref", f"{edge.get('edge_id')} unknown {field}: {edge.get(field)}")
+                _fail(findings, "edge_node_ref", f"{edge_id} unknown {field}: {edge.get(field)}")
+    for edge_id in sorted(catalog_ids - graph_edge_ids):
+        _fail(findings, "catalog_edge_missing_from_graph", f"{edge_id} missing from walk_graph.edges")
     return findings
 
 
@@ -133,6 +159,23 @@ def _validate_policy(policy: dict[str, Any], edge_ids: set[str]) -> list[dict[st
     for row in policy.get("edge_budgets", []):
         if row.get("edge_id") not in edge_ids:
             _fail(findings, "budget_edge_ref", f"edge_budgets unknown edge_id: {row.get('edge_id')}")
+    platform_budget_keys = set()
+    has_default_global = False
+    has_kuaishou_author = False
+    for row in policy.get("platform_edge_budgets_by_platform_edge", {}).values():
+        key = (row.get("platform"), row.get("edge_id"))
+        if key in platform_budget_keys:
+            _fail(findings, "platform_budget_duplicate", f"duplicate platform budget: {key}")
+        platform_budget_keys.add(key)
+        if row.get("edge_id") != "__global__" and row.get("edge_id") not in edge_ids:
+            _fail(findings, "platform_budget_edge_ref", f"platform budget unknown edge_id: {row.get('edge_id')}")
+        has_default_global = has_default_global or key == ("default", "__global__")
+        has_kuaishou_author = has_kuaishou_author or key == ("kuaishou", "author_to_works")
+    if policy.get("platform_edge_budgets_by_platform_edge"):
+        if not has_default_global:
+            _fail(findings, "platform_budget_default_global", "platform_edge_budgets missing default/__global__")
+        if not has_kuaishou_author:
+            _fail(findings, "platform_budget_kuaishou_author", "platform_edge_budgets missing kuaishou/author_to_works")
     permission_rows = policy.get("edge_permissions", {})
     key_sets = []
     for action in PERMISSION_ACTIONS:
@@ -150,14 +193,24 @@ def _validate_policy(policy: dict[str, Any], edge_ids: set[str]) -> list[dict[st
 
 def _validate_profile(profile: dict[str, Any], edge_ids: set[str]) -> list[dict[str, Any]]:
     findings: list[dict[str, Any]] = []
-    for edge_id, spec in profile.get("edges", {}).items():
+    edges = profile.get("edges")
+    if not isinstance(edges, dict):
+        _fail(findings, "profile_edges_invalid", "profile edges must be an object")
+        return findings
+    for edge_id, spec in edges.items():
         if edge_id not in edge_ids:
             _fail(findings, "profile_edge_ref", f"profile unknown edge_id: {edge_id}")
-        if spec.get("status") not in {"supported", "blocked"}:
+        if not isinstance(spec, dict):
+            _fail(findings, "profile_edge_invalid", f"{edge_id} must be an object")
+            continue
+        if spec.get("status") not in PROFILE_EDGE_STATUSES:
             _fail(findings, "profile_edge_status", f"{edge_id} invalid status: {spec.get('status')}")
     return findings
 
 
+def _catalog_edge_ids(catalog: dict[str, Any]) -> set[str]:
+    return {str(row["edge_id"]) for row in catalog.get("walk_edge_catalog", []) if row.get("edge_id")}
+
 
 
 def _raise_on_fail(findings: list[dict[str, Any]], label: str) -> None:

+ 25 - 8
content_agent/integrations/walk_strategy_json.py

@@ -10,12 +10,9 @@ from content_agent.findings import fail as _fail
 
 WALK_STRATEGY_PATH = Path("product_documents/抖音游走策略/douyin_walk_strategy.v1.json")
 RULE_PACK_PATH = Path("product_documents/规则包/douyin_rule_packs.v1.json")
-# V3 清理: 13 段收窄到 3 个仍被运行时/校验消费的段——
-# walk_edge_catalog(walk_graph.json 边 ID 合法性校验)、walk_rule_pack_binding
-# (终端阶段归属包)、walk_fact_contract(runtime 文件契约校验);其余 10 段
-# (老预算/停止/重试/触发规则等)已被 walk_graph+walk_policy 取代,随段删除。
+EDGE_CATALOG_PATH = Path("tech_documents/数据接口与来源/walk_edge_catalog.json")
+# V5 M1: 边目录迁到平台无关 catalog;strategy 只保留运行时实际消费的绑定/门/事实契约。
 REQUIRED_SECTIONS = [
-    "walk_edge_catalog",
     "walk_rule_pack_binding",
     "v4_walk_gate",
     "walk_fact_contract",
@@ -38,6 +35,7 @@ class WalkStrategyStore:
             root_dir=self.root_dir,
             strategy_path=self.strategy_path,
             rule_pack_path=self.rule_pack_path,
+            edge_catalog_path=EDGE_CATALOG_PATH,
         )
         failures = [finding for finding in findings if finding["level"] == "fail"]
         if failures:
@@ -60,6 +58,7 @@ def validate_walk_strategy_config(
     root_dir: Path = Path("."),
     strategy_path: Path = WALK_STRATEGY_PATH,
     rule_pack_path: Path = RULE_PACK_PATH,
+    edge_catalog_path: Path = EDGE_CATALOG_PATH,
 ) -> list[dict[str, Any]]:
     findings: list[dict[str, Any]] = []
     _check_identity(strategy, strategy_path, findings)
@@ -67,7 +66,9 @@ def validate_walk_strategy_config(
     if any(finding["level"] == "fail" for finding in findings):
         return findings
 
-    edge_ids = _ids(strategy["walk_edge_catalog"], "edge_id")
+    edge_ids = _load_edge_catalog_ids(root_dir / edge_catalog_path, findings)
+    if any(finding["level"] == "fail" for finding in findings):
+        return findings
     _check_edge_refs(strategy, edge_ids, findings)
     _check_v4_walk_gate(strategy["v4_walk_gate"], edge_ids, findings)
     _check_fact_contract(strategy["walk_fact_contract"], findings)
@@ -84,8 +85,8 @@ def _check_identity(
     strategy_path: Path,
     findings: list[dict[str, Any]],
 ) -> None:
-    if strategy.get("strategy_id") != "douyin_walk_strategy_v1":
-        _fail(findings, "strategy_id", "strategy_id must be douyin_walk_strategy_v1")
+    if not strategy.get("strategy_id"):
+        _fail(findings, "strategy_id", "strategy_id must be non-empty")
     if strategy.get("strategy_version") != "V1.0":
         _fail(findings, "strategy_version", "walk strategy config version must be V1.0")
     if strategy.get("source_of_truth") != str(strategy_path):
@@ -182,3 +183,19 @@ def _check_rule_pack_bindings(
 
 def _ids(rows: list[dict[str, Any]], field: str) -> set[str]:
     return {str(row[field]) for row in rows if row.get(field)}
+
+
+def _load_edge_catalog_ids(path: Path, findings: list[dict[str, Any]]) -> set[str]:
+    try:
+        catalog = json.loads(path.read_text(encoding="utf-8"))
+    except FileNotFoundError:
+        _fail(findings, "edge_catalog_missing", f"missing edge catalog: {path}")
+        return set()
+    except json.JSONDecodeError as exc:
+        _fail(findings, "edge_catalog_parse", f"edge catalog cannot parse: {exc}")
+        return set()
+    rows = catalog.get("walk_edge_catalog")
+    if not isinstance(rows, list) or not rows:
+        _fail(findings, "edge_catalog_invalid", "walk_edge_catalog must be a non-empty list")
+        return set()
+    return _ids(rows, "edge_id")

+ 1 - 1
content_agent/interfaces.py

@@ -82,4 +82,4 @@ class PlatformSearchClient(Protocol):
 
 
 class PolicyBundleStore(Protocol):
-    def load_policy_bundle(self, strategy_version: str) -> dict[str, Any]: ...
+    def load_policy_bundle(self, strategy_version: str, platform: str = "douyin") -> dict[str, Any]: ...

+ 1 - 120
product_documents/抖音游走策略/douyin_walk_strategy.v1.json

@@ -5,126 +5,7 @@
   "platform": "douyin",
   "status": "active",
   "source_of_truth": "product_documents/抖音游走策略/douyin_walk_strategy.v1.json",
-  "notes": "P6 runtime source of truth. Excel is the human-maintained source. The current schema and DB validator use the 21-table runtime contract.",
-  "walk_edge_catalog": [
-    {
-      "edge_id": "search_page_to_content",
-      "edge_type": "discovery",
-      "from_node_type": "search_page",
-      "to_node_type": "video",
-      "edge_label": "search page to video",
-      "enabled": true,
-      "runtime_stage": "P3/P6",
-      "creates_new_node": true,
-      "can_loop": false,
-      "priority": 20,
-      "notes": "拆出平台视频."
-    },
-    {
-      "edge_id": "video_to_author",
-      "edge_type": "author_seed",
-      "from_node_type": "video",
-      "to_node_type": "author",
-      "edge_label": "video to author",
-      "enabled": true,
-      "runtime_stage": "P6",
-      "creates_new_node": true,
-      "can_loop": false,
-      "priority": 30,
-      "notes": "原 P9 作者一跳入口."
-    },
-    {
-      "edge_id": "author_to_works",
-      "edge_type": "author_works",
-      "from_node_type": "author",
-      "to_node_type": "author_works_page",
-      "edge_label": "author to works",
-      "enabled": true,
-      "runtime_stage": "P6",
-      "creates_new_node": true,
-      "can_loop": true,
-      "priority": 40,
-      "notes": "拉作者作品页."
-    },
-    {
-      "edge_id": "author_work_to_content",
-      "edge_type": "author_work_content",
-      "from_node_type": "author_works_page",
-      "to_node_type": "video",
-      "edge_label": "author work to video",
-      "enabled": true,
-      "runtime_stage": "P6",
-      "creates_new_node": true,
-      "can_loop": false,
-      "priority": 50,
-      "notes": "作者作品重新进入视频判断."
-    },
-    {
-      "edge_id": "video_to_hashtag",
-      "edge_type": "hashtag_seed",
-      "from_node_type": "video",
-      "to_node_type": "hashtag",
-      "edge_label": "video to hashtag",
-      "enabled": true,
-      "runtime_stage": "P6",
-      "creates_new_node": true,
-      "can_loop": false,
-      "priority": 60,
-      "notes": "原 P10 tag 扩散入口."
-    },
-    {
-      "edge_id": "hashtag_to_query",
-      "edge_type": "tag_query",
-      "from_node_type": "hashtag",
-      "to_node_type": "query",
-      "edge_label": "hashtag to query",
-      "enabled": true,
-      "runtime_stage": "P6",
-      "creates_new_node": true,
-      "can_loop": true,
-      "priority": 70,
-      "notes": "强相关 tag 生成 query."
-    },
-    {
-      "edge_id": "decision_to_asset",
-      "edge_type": "asset_commit",
-      "from_node_type": "video",
-      "to_node_type": "asset",
-      "edge_label": "decision to asset",
-      "enabled": true,
-      "runtime_stage": "P7",
-      "creates_new_node": true,
-      "can_loop": false,
-      "priority": 80,
-      "notes": "P5 decision creates final output asset or record."
-    },
-    {
-      "edge_id": "path_stop",
-      "edge_type": "stop",
-      "from_node_type": "video",
-      "to_node_type": "video",
-      "edge_label": "path stop",
-      "enabled": true,
-      "runtime_stage": "P6",
-      "creates_new_node": false,
-      "can_loop": false,
-      "priority": 90,
-      "notes": "Stop current path."
-    },
-    {
-      "edge_id": "budget_downgrade",
-      "edge_type": "budget",
-      "from_node_type": "video",
-      "to_node_type": "video",
-      "edge_label": "budget downgrade",
-      "enabled": true,
-      "runtime_stage": "P6",
-      "creates_new_node": false,
-      "can_loop": false,
-      "priority": 100,
-      "notes": "Lower budget tier."
-    }
-  ],
+  "notes": "P6 runtime source of truth. Excel is the human-maintained source. The current schema and DB validator use the 21-table runtime contract. V5-M4: walk_edge_catalog moved to tech_documents/数据接口与来源/walk_edge_catalog.json; the former embedded compatibility copy is archived in archive/v5_m4_dead_config_archive/20260629T000000Z/.",
   "walk_rule_pack_binding": [
     {
       "binding_id": "bind_decision_asset_content_pack",

+ 172 - 0
product_documents/规则包/douyin_rule_packs.v1.json

@@ -32,6 +32,34 @@
       "priority": 1,
       "fallback_policy": "fail_if_not_matched_or_multi_matched",
       "notes": "V4 M3 runtime dispatches Content scoring."
+    },
+    {
+      "dispatch_id": "dispatch_content_kuaishou",
+      "platform": "kuaishou",
+      "runtime_stage": "V1.0",
+      "strategy_version": "V4",
+      "target_entity": "Content",
+      "content_format": "video",
+      "rule_pack_id": "douyin_content_discovery_rule_pack_v1",
+      "rule_pack_version": "4.0.0",
+      "dispatch_enabled": true,
+      "priority": 1,
+      "fallback_policy": "fail_if_not_matched_or_multi_matched",
+      "notes": "V5-M3 enables Kuaishou Content/video runtime dispatch using the standard V4 content rule pack."
+    },
+    {
+      "dispatch_id": "dispatch_content_shipinhao",
+      "platform": "shipinhao",
+      "runtime_stage": "V1.0",
+      "strategy_version": "V4",
+      "target_entity": "Content",
+      "content_format": "video",
+      "rule_pack_id": "douyin_content_discovery_rule_pack_v1",
+      "rule_pack_version": "4.0.0",
+      "dispatch_enabled": true,
+      "priority": 1,
+      "fallback_policy": "fail_if_not_matched_or_multi_matched",
+      "notes": "V5-M3 enables Shipinhao Content/video runtime dispatch using the standard V4 content rule pack."
     }
   ],
   "decision_action_catalog": [
@@ -305,6 +333,11 @@
             "max_score": 50,
             "weight_percent": 50,
             "runtime_status": "active",
+            "active": true,
+            "platform_scope": [
+              "*"
+            ],
+            "score_source_path": "pattern_match_result.query_relevance_score",
             "evidence_paths": [
               "pattern_match_result.query_relevance_score"
             ]
@@ -315,11 +348,138 @@
             "max_score": 50,
             "weight_percent": 50,
             "runtime_status": "active",
+            "active": true,
+            "platform_scope": [
+              "*"
+            ],
+            "score_source_path": "content_engagement_metrics.platform_performance.platform_performance_score",
             "evidence_paths": [
               "content_engagement_metrics.platform_performance.platform_performance_score"
             ]
+          },
+          {
+            "key": "fifty_plus",
+            "label": "50+ 老年受众匹配",
+            "max_score": 100,
+            "weight_percent": 30,
+            "runtime_status": "active",
+            "active": true,
+            "platform_scope": [
+              "douyin"
+            ],
+            "score_source_path": "content_audience_50plus.score",
+            "evidence_paths": [
+              "content_audience_50plus.score"
+            ]
           }
         ],
+        "score_weight_profiles": {
+          "schema_version": "score_weight_profiles.v2",
+          "profiles": [
+            {
+              "profile_id": "base_video_two_dimension_v1",
+              "profile_label": "短视频基础两维权重",
+              "historical_alias_ids": [
+                "default_two_dimension"
+              ],
+              "platform_scope": [
+                "*"
+              ],
+              "content_format": "video",
+              "applies_when": "no_extra_dimension_available",
+              "weights": {
+                "query_relevance": 50,
+                "platform_performance": 50
+              },
+              "normalization_policy": "sum_100",
+              "normalization_reason": "",
+              "missing_dimension_policy": "technical_retry",
+              "critical_dimensions": [
+                "query_relevance",
+                "platform_performance"
+              ],
+              "score_source_paths": {
+                "query_relevance": "pattern_match_result.query_relevance_score",
+                "platform_performance": "content_engagement_metrics.platform_performance.platform_performance_score"
+              },
+              "priority": 10,
+              "fallback_profile_id": null,
+              "runtime_enabled": true,
+              "approval_status": "approved",
+              "owner": "content-strategy",
+              "last_reviewed_at": "2026-06-29"
+            },
+            {
+              "profile_id": "audience_50plus_available_v1",
+              "profile_label": "50+画像可用三维权重",
+              "historical_alias_ids": [
+                "douyin_fifty_plus_ok"
+              ],
+              "platform_scope": [
+                "douyin"
+              ],
+              "content_format": "video",
+              "applies_when": "field_equals:content_audience_50plus.status:ok",
+              "weights": {
+                "query_relevance": 35,
+                "platform_performance": 35,
+                "fifty_plus": 30
+              },
+              "normalization_policy": "sum_100",
+              "normalization_reason": "",
+              "missing_dimension_policy": "technical_retry",
+              "critical_dimensions": [
+                "query_relevance",
+                "platform_performance",
+                "fifty_plus"
+              ],
+              "score_source_paths": {
+                "query_relevance": "pattern_match_result.query_relevance_score",
+                "platform_performance": "content_engagement_metrics.platform_performance.platform_performance_score",
+                "fifty_plus": "content_audience_50plus.score"
+              },
+              "priority": 30,
+              "fallback_profile_id": "base_video_two_dimension_v1",
+              "runtime_enabled": true,
+              "approval_status": "approved",
+              "owner": "content-strategy",
+              "last_reviewed_at": "2026-06-29"
+            },
+            {
+              "profile_id": "audience_50plus_skipped_by_query_gate_v1",
+              "profile_label": "50+画像因Query门控跳过",
+              "historical_alias_ids": [
+                "douyin_fifty_plus_not_attempted"
+              ],
+              "platform_scope": [
+                "douyin"
+              ],
+              "content_format": "video",
+              "applies_when": "field_equals:content_audience_50plus.status:not_attempted",
+              "weights": {
+                "query_relevance": 35,
+                "platform_performance": 35
+              },
+              "normalization_policy": "raw_sum",
+              "normalization_reason": "历史 V4 抖音 50+ not_attempted 只按 query/platform 各 35% 原始加总,不归一化到 100。",
+              "missing_dimension_policy": "score_missing",
+              "critical_dimensions": [
+                "query_relevance",
+                "platform_performance"
+              ],
+              "score_source_paths": {
+                "query_relevance": "pattern_match_result.query_relevance_score",
+                "platform_performance": "content_engagement_metrics.platform_performance.platform_performance_score"
+              },
+              "priority": 20,
+              "fallback_profile_id": "base_video_two_dimension_v1",
+              "runtime_enabled": true,
+              "approval_status": "approved",
+              "owner": "content-strategy",
+              "last_reviewed_at": "2026-06-29"
+            }
+          ]
+        },
         "scoring_rules": [
           {
             "scoring_rule_id": "score_query_relevance_precomputed",
@@ -344,6 +504,18 @@
             "priority": 2,
             "enabled": true,
             "notes": "V4 producer 直接使用 0-100 platform_performance_score, evaluator 按 50% 权重计算。"
+          },
+          {
+            "scoring_rule_id": "score_fifty_plus_precomputed",
+            "dimension_key": "fifty_plus",
+            "field_path": "content_audience_50plus.score",
+            "operator": "gte",
+            "expected_value": 0,
+            "score_value": 30,
+            "missing_policy": "v4_score_missing_policy",
+            "priority": 3,
+            "enabled": true,
+            "notes": "V5-M2 仅把抖音 50+ 已产出分项纳入运行权重;not_attempted 不归一化,画像失败仍走技术重试。"
           }
         ]
       },

+ 2 - 2
tech_documents/数据接口与来源/00_数据接口总览.md

@@ -758,7 +758,7 @@ Platform 阶段回答:同一个 Query 在平台上用什么动作执行,返
 | 平台 | 搜索 | 详情 | 作者作品 | 账号画像 | 视频可下载 |
 |---|---|---|---|---|---|
 | 抖音 douyin | ✅ `/dou_yin/keyword` | ✅ `/dou_yin/detail` | ✅ `/dou_yin/blogger` | ✅ `/dou_yin/re_dian_bao/account_fans_portrait`;内容画像 ⛔404 `video_like_portrait` | ✅(iOS UA+Referer 抖音) |
-| 快手 kuaishou | ✅ `/kuai_shou/keyword_v2` | ✅ `/kuai_shou/detail` | — 无独立接口,依赖搜索聚合 | ✅ `/kuai_shou/account_info` | ✅(Range+Referer 快手) |
+| 快手 kuaishou | ✅ `/kuai_shou/keyword_v2` | ✅ `/kuai_shou/detail` | ✅ `/kuai_shou/blogger`(2026-06-26 实测 20 条) | ✅ `/kuai_shou/account_info` | ✅(Range+Referer 快手) |
 | 视频号 shipinhao | ✅ `/shi_pin_hao/keyword`(抖动需重试) | — | ⛔25011 `blogger` | ⛔10001 `account_info` | ✅(Referer channels.weixin) |
 | 小红书 xiaohongshu | ✅ `/xiao_hong_shu/keyword` | ✅ `/xiao_hong_shu/detail` | ⛔超时 `blogger` | — | — 图文为主(note_card 嵌套) |
 | B站 bilibili | ✅ `/bilibili/keyword` | ✅ `/bilibili/detail` | ⛔ 无作者接口 | — | ✅(m4s 分轨+Referer B站) |
@@ -771,7 +771,7 @@ Platform 阶段回答:同一个 Query 在平台上用什么动作执行,返
 ### 9.2 每平台一句话状态
 
 - **抖音**:全链路最完整,搜索/详情/作者作品/账号画像/视频下载均通;仅内容级人群画像 404 不可用。详见 `接口台账/抖音.md`。
-- **快手**:搜索/详情/账号info/视频下载通;无独立作者作品接口,author_to_works 暂依赖搜索聚合。详见 `接口台账/快手.md`。
+- **快手**:搜索/详情/账号info/作者作品/视频下载通;V5-M3 打开 `author_to_works`。详见 `接口台账/快手.md`。
 - **视频号**:搜索可用但服务端抖动需重试+退避;视频可下载;blogger 与 account_info 失败。详见 `接口台账/视频号.md`。
 - **小红书**:搜索/详情通,以图文为主(内容字段嵌在 note_card 下);blogger 超时不可落地。详见 `接口台账/小红书.md`。
 - **B站**:搜索/详情/视频下载通(m4s 分轨需合并);集合内无作者作品接口。详见 `接口台账/B站.md`。

+ 44 - 1
tech_documents/数据接口与来源/crawler_endpoints.registry.json

@@ -341,7 +341,50 @@
         "has_more",
         "next_cursor"
       ],
-      "v1_boundary": "verified code=0;account_id 用 channel_account_id(如 3xfkwajatdh7p7i)非数字快手号;tags 已结构化(星座+地区);ip_location/collect_count/create_timestamp 实测 null;快手本批未抓到独立作者作品列表接口,author_to_works 在快手侧暂依赖搜索聚合;限流>=15s。"
+      "v1_boundary": "verified code=0;account_id 用 channel_account_id(如 3xfkwajatdh7p7i)非数字快手号;tags 已结构化(星座+地区);ip_location/collect_count/create_timestamp 实测 null;作者作品已由 PLT_KUAISHOU_BLOGGER 承接;限流>=15s。"
+    },
+    {
+      "platform": "kuaishou",
+      "source_id": "PLT_KUAISHOU_BLOGGER",
+      "display_name": "快手作者作品",
+      "source_type": "http_api",
+      "status": [
+        "verified"
+      ],
+      "system": "crawler.aiddit.com",
+      "table_or_endpoint": "/crawler/kuai_shou/blogger",
+      "access_mode": "read",
+      "usage_stages": [
+        "author_to_works"
+      ],
+      "owner_module": "平台接入模块",
+      "required_env_vars": [
+        "CONTENTFIND_API_CRAWAPI_BASE_URL"
+      ],
+      "input_fields": [
+        "account_id"
+      ],
+      "output_fields": [
+        "channel_content_id",
+        "content_link",
+        "title",
+        "body_text",
+        "topic_list",
+        "content_type",
+        "video_url_list",
+        "image_url_list",
+        "channel_account_id",
+        "channel_account_name",
+        "view_count",
+        "like_count",
+        "collect_count",
+        "comment_count",
+        "share_count",
+        "publish_timestamp",
+        "has_more",
+        "next_cursor"
+      ],
+      "v1_boundary": "verified by 2026-06-26 全平台游走策略实测;account_id 使用 channel_account_id(如 3xfkwajatdh7p7i);单次返回 20 条作品;M3 runtime 用作 author_to_works,并按 platform_content_id 与已发现内容去重;本仓库未新增 kuaishou_blogger capture,自动化测试使用 synthetic response。"
     },
     {
       "platform": "shipinhao",

+ 3 - 3
tech_documents/数据接口与来源/platform_profiles/bilibili.json

@@ -93,11 +93,11 @@
       "note": "仅落 mid 标识"
     },
     "author_to_works": {
-      "status": "unsupported",
+      "status": "no_interface",
       "note": "集合中无作者作品接口"
     },
     "author_work_to_content": {
-      "status": "unsupported"
+      "status": "no_interface"
     },
     "video_to_hashtag": {
       "status": "supported",
@@ -107,4 +107,4 @@
       "status": "supported"
     }
   }
-}
+}

+ 181 - 29
tech_documents/数据接口与来源/platform_profiles/douyin.json

@@ -7,55 +7,207 @@
   "runtime": {
     "rate_limit_seconds": 15,
     "retry": null,
-    "video_download": { "downloadable": true, "headers": { "User-Agent": "iOS UA", "Referer": "https://www.douyin.com/" }, "note": "实测 206/video/mp4;play_addr 地址有时效" }
+    "video_download": {
+      "downloadable": true,
+      "headers": {
+        "User-Agent": "iOS UA",
+        "Referer": "https://www.douyin.com/"
+      },
+      "note": "实测 206/video/mp4;play_addr 地址有时效"
+    }
   },
   "heat": {
     "note": "R3 第二步(2026-06-12):抖音无播放量,用 赞+评+转+藏 四字段复合;权重/锚点为起步值,凭真跑主观调",
     "signals": [
-      { "field": "digg_count",    "weight": 0.4, "floor": 10000, "ceil": 1000000 },
-      { "field": "comment_count", "weight": 0.2, "floor": 100,   "ceil": 50000 },
-      { "field": "share_count",   "weight": 0.2, "floor": 50,    "ceil": 20000 },
-      { "field": "collect_count", "weight": 0.2, "floor": 100,   "ceil": 100000 }
+      {
+        "field": "digg_count",
+        "weight": 0.4,
+        "floor": 10000,
+        "ceil": 1000000
+      },
+      {
+        "field": "comment_count",
+        "weight": 0.2,
+        "floor": 100,
+        "ceil": 50000
+      },
+      {
+        "field": "share_count",
+        "weight": 0.2,
+        "floor": 50,
+        "ceil": 20000
+      },
+      {
+        "field": "collect_count",
+        "weight": 0.2,
+        "floor": 100,
+        "ceil": 100000
+      }
     ]
   },
   "observable_performance": {
     "note": "M11(2026-06-23):量级分(总互动)+ 收缩后互动间比例;参数=759 条历史实测(prior=中位,target=p75,C=500)。抖音无曝光,比例分母用赞。",
     "c": 500,
     "components": [
-      { "field": "total_interaction", "label": "内容体量", "type": "absolute", "weight": 0.45, "floor": 1500, "ceil": 1000000, "sum_fields": ["digg_count", "comment_count", "share_count", "collect_count"] },
-      { "field": "share_ratio",   "label": "转发率", "type": "ratio", "weight": 0.25, "numerator": "share_count",   "denominator": "digg_count", "target": 0.27, "prior": 0.12, "c": 500 },
-      { "field": "collect_ratio", "label": "收藏率", "type": "ratio", "weight": 0.20, "numerator": "collect_count", "denominator": "digg_count", "target": 0.47, "prior": 0.21, "c": 500 },
-      { "field": "comment_ratio", "label": "评论率", "type": "ratio", "weight": 0.10, "numerator": "comment_count", "denominator": "digg_count", "target": 0.08, "prior": 0.04, "c": 500 }
+      {
+        "field": "total_interaction",
+        "label": "内容体量",
+        "type": "absolute",
+        "weight": 0.45,
+        "floor": 1500,
+        "ceil": 1000000,
+        "sum_fields": [
+          "digg_count",
+          "comment_count",
+          "share_count",
+          "collect_count"
+        ]
+      },
+      {
+        "field": "share_ratio",
+        "label": "转发率",
+        "type": "ratio",
+        "weight": 0.25,
+        "numerator": "share_count",
+        "denominator": "digg_count",
+        "target": 0.27,
+        "prior": 0.12,
+        "c": 500
+      },
+      {
+        "field": "collect_ratio",
+        "label": "收藏率",
+        "type": "ratio",
+        "weight": 0.2,
+        "numerator": "collect_count",
+        "denominator": "digg_count",
+        "target": 0.47,
+        "prior": 0.21,
+        "c": 500
+      },
+      {
+        "field": "comment_ratio",
+        "label": "评论率",
+        "type": "ratio",
+        "weight": 0.1,
+        "numerator": "comment_count",
+        "denominator": "digg_count",
+        "target": 0.08,
+        "prior": 0.04,
+        "c": 500
+      }
     ]
   },
   "observable_fields": [
-    { "field": "statistics.digg_count", "availability": "supported", "source": "search.statistics.digg_count / detail.like_count" },
-    { "field": "statistics.comment_count", "availability": "supported", "source": "search.statistics.comment_count / detail.comment_count" },
-    { "field": "statistics.share_count", "availability": "supported", "source": "search.statistics.share_count / detail.share_count" },
-    { "field": "statistics.collect_count", "availability": "supported", "source": "search.statistics.collect_count / detail.collect_count" }
+    {
+      "field": "statistics.digg_count",
+      "availability": "supported",
+      "source": "search.statistics.digg_count / detail.like_count"
+    },
+    {
+      "field": "statistics.comment_count",
+      "availability": "supported",
+      "source": "search.statistics.comment_count / detail.comment_count"
+    },
+    {
+      "field": "statistics.share_count",
+      "availability": "supported",
+      "source": "search.statistics.share_count / detail.share_count"
+    },
+    {
+      "field": "statistics.collect_count",
+      "availability": "supported",
+      "source": "search.statistics.collect_count / detail.collect_count"
+    }
   ],
   "missing_observable_fields": [
-    { "field": "statistics.play_count", "missing_type": "natural_platform_missing", "platform": "douyin", "evidence": "跨平台字段映射.json" }
+    {
+      "field": "statistics.play_count",
+      "missing_type": "natural_platform_missing",
+      "platform": "douyin",
+      "evidence": "跨平台字段映射.json"
+    }
   ],
   "endpoints": {
-    "search":         { "path": "/crawler/dou_yin/keyword", "params": { "keyword": "str", "content_type": "视频|图文", "sort_type": "最多点赞|最多分享", "cursor": "str" }, "response_shape": "raw(aweme 原生结构)" },
-    "detail":         { "path": "/crawler/dou_yin/detail", "params": { "content_id": "str" }, "response_shape": "normalized(channel_* 归一化)" },
-    "blogger":        { "path": "/crawler/dou_yin/blogger", "params": { "account_id": "sec_uid", "cursor": "str" }, "response_shape": "raw" },
-    "author_profile": { "path": "/crawler/dou_yin/re_dian_bao/account_fans_portrait", "params": { "account_id": "sec_uid", "need_age": true, "need_gender": true, "need_province": true } },
-    "content_profile": { "path": "/crawler/dou_yin/re_dian_bao/video_like_portrait", "status": "blocked", "note": "实测 HTTP 404" }
+    "search": {
+      "path": "/crawler/dou_yin/keyword",
+      "params": {
+        "keyword": "str",
+        "content_type": "视频|图文",
+        "sort_type": "最多点赞|最多分享",
+        "cursor": "str"
+      },
+      "response_shape": "raw(aweme 原生结构)"
+    },
+    "detail": {
+      "path": "/crawler/dou_yin/detail",
+      "params": {
+        "content_id": "str"
+      },
+      "response_shape": "normalized(channel_* 归一化)"
+    },
+    "blogger": {
+      "path": "/crawler/dou_yin/blogger",
+      "params": {
+        "account_id": "sec_uid",
+        "cursor": "str"
+      },
+      "response_shape": "raw"
+    },
+    "author_profile": {
+      "path": "/crawler/dou_yin/re_dian_bao/account_fans_portrait",
+      "params": {
+        "account_id": "sec_uid",
+        "need_age": true,
+        "need_gender": true,
+        "need_province": true
+      }
+    },
+    "content_profile": {
+      "path": "/crawler/dou_yin/re_dian_bao/video_like_portrait",
+      "status": "blocked",
+      "note": "实测 HTTP 404"
+    }
   },
   "jump_key_raw": {
-    "platform_content_id": { "search": "aweme_id", "detail": "channel_content_id" },
-    "platform_author_id":  { "search": "author.sec_uid", "detail": "channel_account_id" },
-    "tags":                { "search": "text_extra[].hashtag_name + video_tag[].tag_name", "detail": "topic_list" },
-    "video_url":           { "search": "video.play_addr.url_list[0]", "detail": "video_url_list[0].video_url" }
+    "platform_content_id": {
+      "search": "aweme_id",
+      "detail": "channel_content_id"
+    },
+    "platform_author_id": {
+      "search": "author.sec_uid",
+      "detail": "channel_account_id"
+    },
+    "tags": {
+      "search": "text_extra[].hashtag_name + video_tag[].tag_name",
+      "detail": "topic_list"
+    },
+    "video_url": {
+      "search": "video.play_addr.url_list[0]",
+      "detail": "video_url_list[0].video_url"
+    }
   },
   "edges": {
-    "search_page_to_content": { "status": "supported" },
-    "video_to_author":        { "status": "supported", "note": "搜索阶段即得完整作者档案(sec_uid/粉丝/签名)" },
-    "author_to_works":        { "status": "supported", "note": "blogger 可翻页,实测一次 22 条" },
-    "author_work_to_content": { "status": "supported" },
-    "video_to_hashtag":       { "status": "supported", "note": "结构化 tag 直接可用" },
-    "hashtag_to_query":       { "status": "supported" }
+    "search_page_to_content": {
+      "status": "supported"
+    },
+    "video_to_author": {
+      "status": "supported",
+      "note": "搜索阶段即得完整作者档案(sec_uid/粉丝/签名)"
+    },
+    "author_to_works": {
+      "status": "supported",
+      "note": "blogger 可翻页,实测一次 22 条"
+    },
+    "author_work_to_content": {
+      "status": "supported"
+    },
+    "video_to_hashtag": {
+      "status": "supported",
+      "note": "结构化 tag 直接可用"
+    },
+    "hashtag_to_query": {
+      "status": "supported"
+    }
   }
 }

+ 43 - 12
tech_documents/数据接口与来源/platform_profiles/github.json

@@ -7,23 +7,54 @@
   "runtime": {
     "rate_limit_seconds": 15,
     "retry": null,
-    "video_download": { "downloadable": false, "note": "代码仓库,非视频内容" }
+    "video_download": {
+      "downloadable": false,
+      "note": "代码仓库,非视频内容"
+    }
   },
   "endpoints": {
-    "search": { "path": "/crawler/github/keyword", "params": { "keyword": "str", "token": "" }, "pagination_note": "请求传 token、响应给 next_cursor(短页码),参数名不对称" }
+    "search": {
+      "path": "/crawler/github/keyword",
+      "params": {
+        "keyword": "str",
+        "token": ""
+      },
+      "pagination_note": "请求传 token、响应给 next_cursor(短页码),参数名不对称"
+    }
   },
   "jump_key_raw": {
-    "platform_content_id": { "search": "id" },
-    "platform_author_id":  { "search": "owner_login" },
-    "tags":                { "search": "topics(多数为空)" },
-    "url_trap":            "无现成仓库 url 字段,须 hl_name 去 <em> 后拼 https://github.com/{owner/repo}"
+    "platform_content_id": {
+      "search": "id"
+    },
+    "platform_author_id": {
+      "search": "owner_login"
+    },
+    "tags": {
+      "search": "topics(多数为空)"
+    },
+    "url_trap": "无现成仓库 url 字段,须 hl_name 去 <em> 后拼 https://github.com/{owner/repo}"
   },
   "edges": {
-    "search_page_to_content": { "status": "supported", "note": "repo,非视频" },
-    "video_to_author":        { "status": "supported", "note": "content→owner" },
-    "author_to_works":        { "status": "unsupported" },
-    "author_work_to_content": { "status": "unsupported" },
-    "video_to_hashtag":       { "status": "weak", "note": "topics 常空" },
-    "hashtag_to_query":       { "status": "supported" }
+    "search_page_to_content": {
+      "status": "supported",
+      "note": "repo,非视频"
+    },
+    "video_to_author": {
+      "status": "supported",
+      "note": "content→owner"
+    },
+    "author_to_works": {
+      "status": "no_interface"
+    },
+    "author_work_to_content": {
+      "status": "no_interface"
+    },
+    "video_to_hashtag": {
+      "status": "planned",
+      "note": "topics 常空"
+    },
+    "hashtag_to_query": {
+      "status": "supported"
+    }
   }
 }

+ 77 - 12
tech_documents/数据接口与来源/platform_profiles/kuaishou.json

@@ -55,18 +55,75 @@
     "note": "M11(2026-06-23):快手有曝光 play_count → 真互动率(per 播放);参数=245 条历史实测(prior=中位,target=p75,C=500)。快手 share_count 实测恒 0,不设转发项。",
     "c": 500,
     "components": [
-      { "field": "play_count",   "label": "播放量",        "type": "absolute", "weight": 0.45, "floor": 10000, "ceil": 5000000 },
-      { "field": "like_rate",    "label": "赞率(每播放)",   "type": "ratio", "weight": 0.25, "numerator": "digg_count",    "denominator": "play_count", "target": 0.039,  "prior": 0.015,  "c": 500 },
-      { "field": "collect_rate", "label": "收藏率(每播放)", "type": "ratio", "weight": 0.20, "numerator": "collect_count", "denominator": "play_count", "target": 0.0066, "prior": 0.0028, "c": 500 },
-      { "field": "comment_rate", "label": "评论率(每播放)", "type": "ratio", "weight": 0.10, "numerator": "comment_count", "denominator": "play_count", "target": 0.0039, "prior": 0.0013, "c": 500 }
+      {
+        "field": "play_count",
+        "label": "播放量",
+        "type": "absolute",
+        "weight": 0.45,
+        "floor": 10000,
+        "ceil": 5000000
+      },
+      {
+        "field": "like_rate",
+        "label": "赞率(每播放)",
+        "type": "ratio",
+        "weight": 0.25,
+        "numerator": "digg_count",
+        "denominator": "play_count",
+        "target": 0.039,
+        "prior": 0.015,
+        "c": 500
+      },
+      {
+        "field": "collect_rate",
+        "label": "收藏率(每播放)",
+        "type": "ratio",
+        "weight": 0.2,
+        "numerator": "collect_count",
+        "denominator": "play_count",
+        "target": 0.0066,
+        "prior": 0.0028,
+        "c": 500
+      },
+      {
+        "field": "comment_rate",
+        "label": "评论率(每播放)",
+        "type": "ratio",
+        "weight": 0.1,
+        "numerator": "comment_count",
+        "denominator": "play_count",
+        "target": 0.0039,
+        "prior": 0.0013,
+        "c": 500
+      }
     ]
   },
   "observable_fields": [
-    { "field": "statistics.play_count", "availability": "supported", "source": "view_count" },
-    { "field": "statistics.digg_count", "availability": "supported", "source": "like_count" },
-    { "field": "statistics.comment_count", "availability": "supported", "source": "comment_count" },
-    { "field": "statistics.share_count", "availability": "supported", "source": "share_count" },
-    { "field": "statistics.collect_count", "availability": "supported", "source": "collect_count" }
+    {
+      "field": "statistics.play_count",
+      "availability": "supported",
+      "source": "view_count"
+    },
+    {
+      "field": "statistics.digg_count",
+      "availability": "supported",
+      "source": "like_count"
+    },
+    {
+      "field": "statistics.comment_count",
+      "availability": "supported",
+      "source": "comment_count"
+    },
+    {
+      "field": "statistics.share_count",
+      "availability": "supported",
+      "source": "share_count"
+    },
+    {
+      "field": "statistics.collect_count",
+      "availability": "supported",
+      "source": "collect_count"
+    }
   ],
   "missing_observable_fields": [],
   "endpoints": {
@@ -90,6 +147,14 @@
         "account_id": "str",
         "is_cache": true
       }
+    },
+    "blogger": {
+      "path": "/crawler/kuai_shou/blogger",
+      "params": {
+        "account_id": "str"
+      },
+      "response_shape": "normalized(channel_*)",
+      "evidence": "product_documents/全平台游走策略.md 2026-06-26 实测 20 条"
     }
   },
   "jump_key_raw": {
@@ -115,11 +180,11 @@
       "note": "作者档案需 account_info 二次补齐"
     },
     "author_to_works": {
-      "status": "blocked",
-      "note": "无独立作者作品接口"
+      "status": "supported",
+      "note": "blogger 可用;2026-06-26 实测返回 20 条"
     },
     "author_work_to_content": {
-      "status": "blocked"
+      "status": "supported"
     },
     "video_to_hashtag": {
       "status": "supported",

+ 120 - 22
tech_documents/数据接口与来源/platform_profiles/shipinhao.json

@@ -6,47 +6,145 @@
   "evidence": "2026-06-11 实测(接口台账/视频号.md)",
   "runtime": {
     "rate_limit_seconds": 15,
-    "retry": { "search": { "trigger": "仅暂时性故障重试:code=25011 接口异常 / 超时 / 网络错误(code=0 空结果、参数/鉴权错 不重试)", "max_attempts": 3, "backoff_seconds": [1, 2, 4], "on_exhausted": "标 blocked·失败待查,游走绕行不卡流水线", "note": "实测首次 25011、重试 code=0;退避与 rate_limit_seconds=15 取较大值,故退避多被 15s 吸收(2026-06-11 拍板)" } },
-    "video_download": { "downloadable": true, "headers": { "User-Agent": "PC UA", "Referer": "https://channels.weixin.qq.com/" }, "note": "实测 200/video/mp4;findermp 带 encfilekey" }
+    "retry": {
+      "search": {
+        "trigger": "仅暂时性故障重试:code=25011 接口异常 / 超时 / 网络错误(code=0 空结果、参数/鉴权错 不重试)",
+        "max_attempts": 3,
+        "backoff_seconds": [
+          1,
+          2,
+          4
+        ],
+        "on_exhausted": "标 blocked·失败待查,游走绕行不卡流水线",
+        "note": "实测首次 25011、重试 code=0;退避与 rate_limit_seconds=15 取较大值,故退避多被 15s 吸收(2026-06-11 拍板)"
+      }
+    },
+    "video_download": {
+      "downloadable": true,
+      "headers": {
+        "User-Agent": "PC UA",
+        "Referer": "https://channels.weixin.qq.com/"
+      },
+      "note": "实测 200/video/mp4;findermp 带 encfilekey"
+    }
   },
   "heat": {
     "note": "R3 第一步(2026-06-12):点赞单信号=R3 前行为;视频号 comment/share/collect 实测常空,数据所限只能用点赞",
     "signals": [
-      { "field": "digg_count", "weight": 1.0, "floor": 50, "ceil": 50000 }
+      {
+        "field": "digg_count",
+        "weight": 1.0,
+        "floor": 50,
+        "ceil": 50000
+      }
     ]
   },
   "observable_performance": {
     "note": "M11(2026-06-23):视频号实测仅点赞非零(评/转/藏/播全 0),无法算任何比例 → 只用点赞量级(同 heat,绝对值 log 归一)。",
     "components": [
-      { "field": "digg_count", "label": "点赞量", "type": "absolute", "weight": 1.0, "floor": 50, "ceil": 50000 }
+      {
+        "field": "digg_count",
+        "label": "点赞量",
+        "type": "absolute",
+        "weight": 1.0,
+        "floor": 50,
+        "ceil": 50000
+      }
     ]
   },
   "observable_fields": [
-    { "field": "statistics.digg_count", "availability": "supported", "source": "like_count" }
+    {
+      "field": "statistics.digg_count",
+      "availability": "supported",
+      "source": "like_count"
+    }
   ],
   "missing_observable_fields": [
-    { "field": "statistics.comment_count", "missing_type": "natural_platform_missing", "platform": "shipinhao", "evidence": "跨平台字段映射.json" },
-    { "field": "statistics.share_count", "missing_type": "natural_platform_missing", "platform": "shipinhao", "evidence": "跨平台字段映射.json" },
-    { "field": "statistics.collect_count", "missing_type": "natural_platform_missing", "platform": "shipinhao", "evidence": "跨平台字段映射.json" },
-    { "field": "statistics.play_count", "missing_type": "natural_platform_missing", "platform": "shipinhao", "evidence": "跨平台字段映射.json" }
+    {
+      "field": "statistics.comment_count",
+      "missing_type": "natural_platform_missing",
+      "platform": "shipinhao",
+      "evidence": "跨平台字段映射.json"
+    },
+    {
+      "field": "statistics.share_count",
+      "missing_type": "natural_platform_missing",
+      "platform": "shipinhao",
+      "evidence": "跨平台字段映射.json"
+    },
+    {
+      "field": "statistics.collect_count",
+      "missing_type": "natural_platform_missing",
+      "platform": "shipinhao",
+      "evidence": "跨平台字段映射.json"
+    },
+    {
+      "field": "statistics.play_count",
+      "missing_type": "natural_platform_missing",
+      "platform": "shipinhao",
+      "evidence": "跨平台字段映射.json"
+    }
   ],
   "endpoints": {
-    "search":       { "path": "/crawler/shi_pin_hao/keyword", "params": { "keyword": "str", "cursor": "str" }, "response_shape": "normalized(channel_*)", "stability": "unstable" },
-    "blogger":      { "path": "/crawler/shi_pin_hao/blogger", "status": "blocked", "note": "实测 code=25011" },
-    "account_info": { "path": "/crawler/shi_pin_hao/account_info", "params": { "account_name": "str" }, "status": "blocked", "note": "以 registry/capture blocked 为准;2026-06-14 profile partial 不作为可靠运行能力" }
+    "search": {
+      "path": "/crawler/shi_pin_hao/keyword",
+      "params": {
+        "keyword": "str",
+        "cursor": "str"
+      },
+      "response_shape": "normalized(channel_*)",
+      "stability": "unstable"
+    },
+    "blogger": {
+      "path": "/crawler/shi_pin_hao/blogger",
+      "status": "blocked",
+      "note": "实测 code=25011"
+    },
+    "account_info": {
+      "path": "/crawler/shi_pin_hao/account_info",
+      "params": {
+        "account_name": "str"
+      },
+      "status": "blocked",
+      "note": "以 registry/capture blocked 为准;2026-06-14 profile partial 不作为可靠运行能力"
+    }
   },
   "jump_key_raw": {
-    "platform_content_id": { "search": "channel_content_id" },
-    "platform_author_id":  { "search": "channel_account_id" },
-    "tags":                { "search": "topic_list(实测 null,兜底标题正则 #xxx)" },
-    "video_url":           { "search": "video_url_list[0].video_url" }
+    "platform_content_id": {
+      "search": "channel_content_id"
+    },
+    "platform_author_id": {
+      "search": "channel_account_id"
+    },
+    "tags": {
+      "search": "topic_list(实测 null,兜底标题正则 #xxx)"
+    },
+    "video_url": {
+      "search": "video_url_list[0].video_url"
+    }
   },
   "edges": {
-    "search_page_to_content": { "status": "supported", "note": "抖动需重试" },
-    "video_to_author":        { "status": "supported", "note": "仅落 channel_account_id 标识" },
-    "author_to_works":        { "status": "blocked", "note": "blogger code=25011" },
-    "author_work_to_content": { "status": "blocked" },
-    "video_to_hashtag":       { "status": "supported", "note": "标题正则抽" },
-    "hashtag_to_query":       { "status": "supported" }
+    "search_page_to_content": {
+      "status": "supported",
+      "note": "抖动需重试"
+    },
+    "video_to_author": {
+      "status": "supported",
+      "note": "仅落 channel_account_id 标识"
+    },
+    "author_to_works": {
+      "status": "blocked",
+      "note": "blogger code=25011"
+    },
+    "author_work_to_content": {
+      "status": "blocked"
+    },
+    "video_to_hashtag": {
+      "status": "supported",
+      "note": "标题正则抽"
+    },
+    "hashtag_to_query": {
+      "status": "supported"
+    }
   }
 }

+ 46 - 3
tech_documents/数据接口与来源/platform_profiles/toutiao.json

@@ -4,9 +4,52 @@
   "platform_label": "头条",
   "status": "blocked",
   "evidence": "2026-06-11 实测(接口台账/头条.md):/crawler/tou_tiao_hao/keyword 两次(含变参)均 code=10000 未知错误",
-  "runtime": { "rate_limit_seconds": 15 },
+  "runtime": {
+    "rate_limit_seconds": 15
+  },
   "endpoints": {
-    "search": { "path": "/crawler/tou_tiao_hao/keyword", "status": "blocked", "note": "code=10000,与参数无关,平台侧不可用;修复后复抓再登记" }
+    "search": {
+      "path": "/crawler/tou_tiao_hao/keyword",
+      "status": "blocked",
+      "note": "code=10000,与参数无关,平台侧不可用;修复后复抓再登记"
+    }
   },
-  "edges": { "_all": "blocked(整平台所有边阻断)" }
+  "edges": {
+    "search_page_to_content": {
+      "status": "blocked",
+      "note": "整平台所有边阻断"
+    },
+    "video_to_author": {
+      "status": "blocked",
+      "note": "整平台所有边阻断"
+    },
+    "author_to_works": {
+      "status": "blocked",
+      "note": "整平台所有边阻断"
+    },
+    "author_work_to_content": {
+      "status": "blocked",
+      "note": "整平台所有边阻断"
+    },
+    "video_to_hashtag": {
+      "status": "blocked",
+      "note": "整平台所有边阻断"
+    },
+    "hashtag_to_query": {
+      "status": "blocked",
+      "note": "整平台所有边阻断"
+    },
+    "decision_to_asset": {
+      "status": "blocked",
+      "note": "整平台所有边阻断"
+    },
+    "path_stop": {
+      "status": "blocked",
+      "note": "整平台所有边阻断"
+    },
+    "budget_downgrade": {
+      "status": "blocked",
+      "note": "整平台所有边阻断"
+    }
+  }
 }

+ 5 - 5
tech_documents/数据接口与来源/platform_profiles/weixin.json

@@ -73,18 +73,18 @@
       "note": "仅落 biz(正则);昵称需 account_info 二次请求"
     },
     "author_to_works": {
-      "status": "unsupported",
+      "status": "no_interface",
       "note": "blogger 不可达,无作品列表"
     },
     "author_work_to_content": {
-      "status": "unsupported"
+      "status": "no_interface"
     },
     "video_to_hashtag": {
-      "status": "unsupported",
+      "status": "no_interface",
       "note": "整平台无 tag"
     },
     "hashtag_to_query": {
-      "status": "unsupported"
+      "status": "no_interface"
     }
   }
-}
+}

+ 1 - 1
tech_documents/数据接口与来源/platform_profiles/xiaohongshu.json

@@ -107,4 +107,4 @@
       "status": "supported"
     }
   }
-}
+}

+ 4 - 4
tech_documents/数据接口与来源/platform_profiles/youtube.json

@@ -73,18 +73,18 @@
       "status": "supported"
     },
     "author_to_works": {
-      "status": "unsupported",
+      "status": "no_interface",
       "note": "无作者作品接口"
     },
     "author_work_to_content": {
-      "status": "unsupported"
+      "status": "no_interface"
     },
     "video_to_hashtag": {
-      "status": "weak",
+      "status": "planned",
       "note": "仅 detail.body_text 正则,搜索无 tag"
     },
     "hashtag_to_query": {
       "status": "supported"
     }
   }
-}
+}

+ 46 - 3
tech_documents/数据接口与来源/platform_profiles/zhihu.json

@@ -4,9 +4,52 @@
   "platform_label": "知乎",
   "status": "blocked",
   "evidence": "2026-06-11 实测(接口台账/知乎.md):/crawler/zhi_hu/keyword 两次(含变参)均 code=10000 未知错误",
-  "runtime": { "rate_limit_seconds": 15 },
+  "runtime": {
+    "rate_limit_seconds": 15
+  },
   "endpoints": {
-    "search": { "path": "/crawler/zhi_hu/keyword", "status": "blocked", "note": "code=10000,与参数无关,平台侧不可用;修复后复抓再登记" }
+    "search": {
+      "path": "/crawler/zhi_hu/keyword",
+      "status": "blocked",
+      "note": "code=10000,与参数无关,平台侧不可用;修复后复抓再登记"
+    }
   },
-  "edges": { "_all": "blocked(整平台所有边阻断)" }
+  "edges": {
+    "search_page_to_content": {
+      "status": "blocked",
+      "note": "整平台所有边阻断"
+    },
+    "video_to_author": {
+      "status": "blocked",
+      "note": "整平台所有边阻断"
+    },
+    "author_to_works": {
+      "status": "blocked",
+      "note": "整平台所有边阻断"
+    },
+    "author_work_to_content": {
+      "status": "blocked",
+      "note": "整平台所有边阻断"
+    },
+    "video_to_hashtag": {
+      "status": "blocked",
+      "note": "整平台所有边阻断"
+    },
+    "hashtag_to_query": {
+      "status": "blocked",
+      "note": "整平台所有边阻断"
+    },
+    "decision_to_asset": {
+      "status": "blocked",
+      "note": "整平台所有边阻断"
+    },
+    "path_stop": {
+      "status": "blocked",
+      "note": "整平台所有边阻断"
+    },
+    "budget_downgrade": {
+      "status": "blocked",
+      "note": "整平台所有边阻断"
+    }
+  }
 }

+ 123 - 0
tech_documents/数据接口与来源/walk_edge_catalog.json

@@ -0,0 +1,123 @@
+{
+  "schema_version": "walk_edge_catalog.v1",
+  "source_of_truth": "tech_documents/数据接口与来源/walk_edge_catalog.json",
+  "walk_edge_catalog": [
+    {
+      "edge_id": "search_page_to_content",
+      "edge_type": "discovery",
+      "from_node_type": "search_page",
+      "to_node_type": "video",
+      "edge_label": "search page to video",
+      "enabled": true,
+      "runtime_stage": "P3/P6",
+      "creates_new_node": true,
+      "can_loop": false,
+      "priority": 20,
+      "notes": "拆出平台视频."
+    },
+    {
+      "edge_id": "video_to_author",
+      "edge_type": "author_seed",
+      "from_node_type": "video",
+      "to_node_type": "author",
+      "edge_label": "video to author",
+      "enabled": true,
+      "runtime_stage": "P6",
+      "creates_new_node": true,
+      "can_loop": false,
+      "priority": 30,
+      "notes": "原 P9 作者一跳入口."
+    },
+    {
+      "edge_id": "author_to_works",
+      "edge_type": "author_works",
+      "from_node_type": "author",
+      "to_node_type": "author_works_page",
+      "edge_label": "author to works",
+      "enabled": true,
+      "runtime_stage": "P6",
+      "creates_new_node": true,
+      "can_loop": true,
+      "priority": 40,
+      "notes": "拉作者作品页."
+    },
+    {
+      "edge_id": "author_work_to_content",
+      "edge_type": "author_work_content",
+      "from_node_type": "author_works_page",
+      "to_node_type": "video",
+      "edge_label": "author work to video",
+      "enabled": true,
+      "runtime_stage": "P6",
+      "creates_new_node": true,
+      "can_loop": false,
+      "priority": 50,
+      "notes": "作者作品重新进入视频判断."
+    },
+    {
+      "edge_id": "video_to_hashtag",
+      "edge_type": "hashtag_seed",
+      "from_node_type": "video",
+      "to_node_type": "hashtag",
+      "edge_label": "video to hashtag",
+      "enabled": true,
+      "runtime_stage": "P6",
+      "creates_new_node": true,
+      "can_loop": false,
+      "priority": 60,
+      "notes": "原 P10 tag 扩散入口."
+    },
+    {
+      "edge_id": "hashtag_to_query",
+      "edge_type": "tag_query",
+      "from_node_type": "hashtag",
+      "to_node_type": "query",
+      "edge_label": "hashtag to query",
+      "enabled": true,
+      "runtime_stage": "P6",
+      "creates_new_node": true,
+      "can_loop": true,
+      "priority": 70,
+      "notes": "强相关 tag 生成 query."
+    },
+    {
+      "edge_id": "decision_to_asset",
+      "edge_type": "asset_commit",
+      "from_node_type": "video",
+      "to_node_type": "asset",
+      "edge_label": "decision to asset",
+      "enabled": true,
+      "runtime_stage": "P7",
+      "creates_new_node": true,
+      "can_loop": false,
+      "priority": 80,
+      "notes": "P5 decision creates final output asset or record."
+    },
+    {
+      "edge_id": "path_stop",
+      "edge_type": "stop",
+      "from_node_type": "video",
+      "to_node_type": "video",
+      "edge_label": "path stop",
+      "enabled": true,
+      "runtime_stage": "P6",
+      "creates_new_node": false,
+      "can_loop": false,
+      "priority": 90,
+      "notes": "Stop current path."
+    },
+    {
+      "edge_id": "budget_downgrade",
+      "edge_type": "budget",
+      "from_node_type": "video",
+      "to_node_type": "video",
+      "edge_label": "budget downgrade",
+      "enabled": true,
+      "runtime_stage": "P6",
+      "creates_new_node": false,
+      "can_loop": false,
+      "priority": 100,
+      "notes": "Lower budget tier."
+    }
+  ]
+}

+ 3 - 3
tech_documents/数据接口与来源/walk_graph.json

@@ -1,8 +1,8 @@
 {
   "schema_version": "walk_graph.v2",
-  "title": "游走图谱(全局边目录,平台无关;8 节点 + 9 边,复用 douyin_walk_strategy.v1.json 命名;M8 删 query_next_page,翻页归渐进筛选)",
+  "title": "游走图谱(全局边目录,平台无关;8 节点 + 9 边,复用 walk_edge_catalog.json 命名;M8 删 query_next_page,翻页归渐进筛选)",
   "generated_at": "2026-06-11",
-  "source_of_truth": "product_documents/抖音游走策略/douyin_walk_strategy.v1.json",
+  "source_of_truth": "tech_documents/数据接口与来源/walk_edge_catalog.json",
   "evidence": "实测抓包(2026-06-11 多平台探针) + 接口台账/*.md",
   "companions": {
     "platform_profiles": "platform_profiles/<platform>.json — 每平台:支持哪些边、真实 endpoint、jump_key 的 raw 字段、extract_rule 覆写、运行参数(限流/重试/下载头)",
@@ -10,7 +10,7 @@
     "field_mapping": "跨平台字段映射.json — 平台响应 raw 字段 → canonical 字段"
   },
   "constraints": [
-    "edge_id 全部来自策略文件,零新造(M8 删 query_next_page 后 9 条;search_page 节点保留为孤儿,翻页由 progressive_screening 接管)。",
+    "edge_id 全部来自平台无关 walk_edge_catalog.json,零新造(M8 删 query_next_page 后 9 条;search_page 节点保留为孤儿,翻页由 progressive_screening 接管)。",
     "node 全部来自策略文件 8 类,id_field 与策略 walk_node_catalog 一致。",
     "本文件只写平台无关语义;一切平台差异在 platform_profiles/,一切数值护栏在 walk_policy.json。",
     "source_path_type 对齐血缘三类:pattern_to_search_query / search_query_to_content / decision_to_asset(其余边记 null)。"

+ 107 - 11
tech_documents/数据接口与来源/walk_policy.json

@@ -4,14 +4,84 @@
   "generated_at": "2026-06-11",
   "value_provenance": "基线=walk_engine v1 实际硬限(行为等价,M4 拍板 2026-06-11);2026-06-12 R7 拍板放宽 tag 1→3、作者 2→3;2026-06-17 M8 拍板:深度游走重做——max_depth 3→5、总闸 60→10(口径=向外游走次数)、删 max_reseed_rounds、tag/作者预算 3→5、每作者作品 3→5、删 query_next_page 边;2026-06-24 收敛真实跑测成本:总闸10、tag/作者各5;max_depth/max_total_actions_per_run 由 walk_engine frontier 真消费",
   "global": {
-    "max_total_actions_per_run": { "value": 10, "provenance": "2026-06-24 调整:全 run 向外游走(标签跳+作者跳合计)硬闸 30→10;不含结局标记;由 walk_engine frontier 真生效", "tbd": false },
-    "max_depth": { "value": 5, "provenance": "M8 拍板 2026-06-17:游走树深度天花板=5,run 级、挂视频继承父+1、不重置;由 walk_engine frontier 真生效", "tbd": false },
-    "gemini_max_workers": { "value": 5, "provenance": "拍板 2026-06-26:Qwen/DashScope视频判定走官方API,从2提升到5并发;batch内仍有渐进闸门", "tbd": false }
+    "max_total_actions_per_run": {
+      "value": 10,
+      "provenance": "2026-06-24 调整:全 run 向外游走(标签跳+作者跳合计)硬闸 30→10;不含结局标记;由 walk_engine frontier 真生效",
+      "tbd": false
+    },
+    "max_depth": {
+      "value": 5,
+      "provenance": "M8 拍板 2026-06-17:游走树深度天花板=5,run 级、挂视频继承父+1、不重置;由 walk_engine frontier 真生效",
+      "tbd": false
+    },
+    "gemini_max_workers": {
+      "value": 5,
+      "provenance": "拍板 2026-06-26:Qwen/DashScope视频判定走官方API,从2提升到5并发;batch内仍有渐进闸门",
+      "tbd": false
+    }
   },
   "edge_budgets": [
-    { "edge_id": "video_to_author",  "max_total_actions": null, "max_per_content": 1, "max_pages": null, "provenance": "作者标识随 video 携带,loop 不单独执行此边、无预算消费;作者数上限见 author_to_works" },
-    { "edge_id": "author_to_works",  "max_total_actions": 5,  "max_works_per_author": 5, "max_pages": null, "provenance": "2026-06-24 调整:全 run 作者游走 20→5、每作者仍 ≤5 新作品;由 walk_engine frontier 跨层运行扣减" },
-    { "edge_id": "hashtag_to_query", "max_total_actions": 5,  "max_per_content": null, "max_tag_hops": null, "provenance": "2026-06-24 调整:全 run 标签游走 20→5;由 walk_engine frontier 跨层运行扣减" }
+    {
+      "edge_id": "video_to_author",
+      "max_total_actions": null,
+      "max_per_content": 1,
+      "max_pages": null,
+      "provenance": "作者标识随 video 携带,loop 不单独执行此边、无预算消费;作者数上限见 author_to_works"
+    },
+    {
+      "edge_id": "author_to_works",
+      "max_total_actions": 5,
+      "max_works_per_author": 5,
+      "max_pages": null,
+      "provenance": "2026-06-24 调整:全 run 作者游走 20→5、每作者仍 ≤5 新作品;由 walk_engine frontier 跨层运行扣减"
+    },
+    {
+      "edge_id": "hashtag_to_query",
+      "max_total_actions": 5,
+      "max_per_content": null,
+      "max_tag_hops": null,
+      "provenance": "2026-06-24 调整:全 run 标签游走 20→5;由 walk_engine frontier 跨层运行扣减"
+    }
+  ],
+  "platform_edge_budgets": [
+    {
+      "platform": "default",
+      "edge_id": "__global__",
+      "max_total_actions": 10,
+      "max_depth": 5,
+      "budget_exhausted_reason_code": "budget_exhausted",
+      "operator_display_status": "budget_blocked"
+    },
+    {
+      "platform": "default",
+      "edge_id": "author_to_works",
+      "max_total_actions": 5,
+      "max_per_content": null,
+      "max_works_per_author": 5,
+      "max_pages": null,
+      "budget_exhausted_reason_code": "budget_exhausted",
+      "operator_display_status": "budget_blocked"
+    },
+    {
+      "platform": "default",
+      "edge_id": "hashtag_to_query",
+      "max_total_actions": 5,
+      "max_per_content": null,
+      "max_works_per_author": null,
+      "max_pages": null,
+      "budget_exhausted_reason_code": "budget_exhausted",
+      "operator_display_status": "budget_blocked"
+    },
+    {
+      "platform": "kuaishou",
+      "edge_id": "author_to_works",
+      "max_total_actions": 5,
+      "max_per_content": null,
+      "max_works_per_author": 5,
+      "max_pages": null,
+      "budget_exhausted_reason_code": "budget_exhausted",
+      "operator_display_status": "budget_blocked"
+    }
   ],
   "dedup": {
     "query_key": "归一化 keyword(去空白/全半角/大小写统一)后全 run 去重;tag query 与 P2 初始 query 共用一个去重集",
@@ -21,10 +91,32 @@
   "edge_permissions": {
     "_meaning": "判定层→游走层的唯一接口:判定结果决定哪些出边放行(取代 walk_engine._can_expand_from_decision 硬编码,亦即原 path_stop/budget 包语义的新家);hashtag_to_query 的放行随 video_to_hashtag(同一 keep_only 回灌链)",
     "_provenance": "现状行为来自 walk_engine v1 + E2E 实测(KEEP 内容 author_to_works success(low_budget));M4 拍板 2026-06-11",
-    "ADD_TO_CONTENT_POOL":     { "author_to_works": "allow",            "video_to_hashtag": "allow", "decision_to_asset": "allow" },
-    "KEEP_CONTENT_FOR_REVIEW": { "author_to_works": "allow_low_budget", "video_to_hashtag": { "value": "deny", "tbd": false, "note": "拍板 2026-06-11:从严 deny(v1 等价,防漂移)" }, "decision_to_asset": "deny" },
-    "TECHNICAL_RETRY_REQUIRED": { "author_to_works": "deny", "video_to_hashtag": "deny", "decision_to_asset": "deny", "note": "技术链路失败不触发业务游走或资产沉淀" },
-    "REJECT_CONTENT":          { "author_to_works": "deny", "video_to_hashtag": "deny", "decision_to_asset": "deny", "note": "即 path_stop:rule_blocked/failed 不开任何出边" }
+    "ADD_TO_CONTENT_POOL": {
+      "author_to_works": "allow",
+      "video_to_hashtag": "allow",
+      "decision_to_asset": "allow"
+    },
+    "KEEP_CONTENT_FOR_REVIEW": {
+      "author_to_works": "allow_low_budget",
+      "video_to_hashtag": {
+        "value": "deny",
+        "tbd": false,
+        "note": "拍板 2026-06-11:从严 deny(v1 等价,防漂移)"
+      },
+      "decision_to_asset": "deny"
+    },
+    "TECHNICAL_RETRY_REQUIRED": {
+      "author_to_works": "deny",
+      "video_to_hashtag": "deny",
+      "decision_to_asset": "deny",
+      "note": "技术链路失败不触发业务游走或资产沉淀"
+    },
+    "REJECT_CONTENT": {
+      "author_to_works": "deny",
+      "video_to_hashtag": "deny",
+      "decision_to_asset": "deny",
+      "note": "即 path_stop:rule_blocked/failed 不开任何出边"
+    }
   },
   "v4_walk_gate": {
     "requires_allow_walk": true,
@@ -47,6 +139,10 @@
   },
   "budget_tiers": {
     "normal": "edge_budgets 全额",
-    "low_budget": { "value": "halve_min_1", "provenance": "拍板 2026-06-11:各边预算减半向下取整至少 1,即 max(1, budget//2)", "tbd": false }
+    "low_budget": {
+      "value": "halve_min_1",
+      "provenance": "拍板 2026-06-11:各边预算减半向下取整至少 1,即 max(1, budget//2)",
+      "tbd": false
+    }
   }
 }

+ 20 - 1
tech_documents/数据接口与来源/接口台账/快手.md

@@ -268,7 +268,7 @@ curl -X POST http://crawler.aiddit.com/crawler/kuai_shou/account_info \
 ### ⑦ 边界与失败
 - 限流 ≥ 15s。
 - `account_id` 用 `channel_account_id`(如 `3xfkwajatdh7p7i`),非数字快手号。
-- 快手无独立的"作者作品列表"接口 capture;"作者→作品"游走在快手侧暂依赖搜索结果聚合(本批未抓到 kuaishou blogger 接口)
+- 作者作品由 `/crawler/kuai_shou/blogger` 承接;2026-06-26 实测返回 20 条。当前仓库不新增 capture,自动化测试使用 synthetic response
 
 ### ⑧ 落点
 | 字段 | canonical 名 | 游走边 |
@@ -277,3 +277,22 @@ curl -X POST http://crawler.aiddit.com/crawler/kuai_shou/account_info \
 | `account_name` | `author_display_name` | video_to_author |
 | `tags` | `tags`(账号级) | — |
 | `follower_count`/`like_count` | `statistics.*`(账号聚合) | — |
+
+## 4. 作者作品 blogger(PLT_KUAISHOU_BLOGGER)
+
+### ① 用途
+- 作者 → 作者作品(`author_to_works`)。
+- V5-M3 用 `channel_account_id` 作为 `account_id`,拉回该作者更多视频作品。
+
+### ② 调用方式
+- method:`POST` · path:`/crawler/kuai_shou/blogger`
+- body:
+```json
+{"account_id":"3xfkwajatdh7p7i"}
+```
+
+### ③ 返回与映射
+- 证据:`product_documents/全平台游走策略.md` 记录 2026-06-26 实测返回 20 条。
+- `data.data[]` 结构与快手搜索/详情归一化字段一致,映射为 canonical video item。
+- `previous_discovery_step=author_works`,`content_metadata_source=kuaishou_blogger`。
+- 作者作品需按 `platform_content_id` 与已发现内容去重。

+ 4 - 4
tech_documents/数据接口与来源/游走图谱.md

@@ -65,8 +65,8 @@
 | query_next_page | ✅ | 弱(单页) | ✅(需重试) | ✅ | ✅ | ✅ | ✅(长token) | ✅ |
 | search_page_to_content | ✅ | ✅ | ✅(抖动) | ✅(note_card) | ✅ | ✅ | ✅ | ✅(repo) |
 | video_to_author | ✅ | ✅ | ✅ | ✅ | ✅(仅mid标识) | ✅(仅biz/hash) | ✅ | ✅(→owner) |
-| **author_to_works** | ✅(blogger) | ❌无接口 | ⛔blocked25011 | ⛔blocked超时 | ❌无接口 | ❌无接口 | ❌无接口 | ❌ |
-| author_work_to_content | ✅ |  | ⛔ | ⛔ | ❌ | ❌ | ❌ | ❌ |
+| **author_to_works** | ✅(blogger) | ✅(blogger) | ⛔blocked25011 | ⛔blocked超时 | ❌无接口 | ❌无接口 | ❌无接口 | ❌ |
+| author_work_to_content | ✅ |  | ⛔ | ⛔ | ❌ | ❌ | ❌ | ❌ |
 | video_to_hashtag | ✅ | 弱(常null) | ✅(title正则) | ✅ | ✅(tag split) | ❌无tag | ✅(仅detail正则) | ✅(topics常空) |
 | hashtag_to_query | ✅ | ✅ | ✅ | ✅ | ✅ | n/a | ✅ | ✅ |
 | decision_to_asset | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
@@ -75,6 +75,6 @@
 > **头条、知乎**:整平台 blocked(`code=10000`/`data=null`),所有边阻断,不接入游走。
 
 ### 关键结论
-- **作者作品(`author_to_works`)只有抖音真正可走**(blogger 接口 `code=0`)。快手/B站/公众号/youtube **无独立作者作品接口**;视频号(`code=25011`)、小红书(超时)**接口存在但 blocked**。其余平台的"作者"维度只能落作者标识(`platform_author_id`),无法展开作品列表。
+- **作者作品(`author_to_works`)当前抖音、快手可走**。抖音 blogger 接口 `code=0`;快手 `/crawler/kuai_shou/blogger` 在 2026-06-26 实测返回 20 条。B站/公众号/youtube 无独立作者作品接口;视频号(`code=25011`)、小红书(超时)接口存在但 blocked。其余平台的"作者"维度只能落作者标识(`platform_author_id`),无法展开作品列表。
 - **公众号无 hashtag 回灌**(`topic_list` 恒 null),`video_to_hashtag` / `hashtag_to_query` 在公众号不可走。
-- 抖音是唯一**全 10 边可走**的平台,是游走能力的完整参照系
+- 抖音是完整参照系;V5-M3 后快手也打开 `author_to_works` / `author_work_to_content`,但仍缺 50+画像等抖音专属能力