aigc_platform.py 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164
  1. """AIGC 平台接口:把 CFA 入池视频推进 AIGC 生成计划。
  2. 3 个接口(文档见 `tech_documents/AIGC 接口调用说明(create_crawler_plan_by_{douyin,kuaishou}_content_id 链路).md`):
  3. 1. 建爬取计划 `/aigc/crawler/plan/save`(传一批平台内容 ID,抖音 aweme_id / 快手 photo_id,返回 crawler_plan_id)
  4. 2. 查生成计划详情 `/aigc/produce/plan/detail`
  5. 3. 存生成计划 `/aigc/produce/plan/save`(把 crawler_plan_id 作为输入源增量追加)
  6. 本模块只做纯接口调用,不含 dry-run / 安全闸等编排逻辑(那些在 scripts/push_runs_to_aigc.py)。
  7. """
  8. from __future__ import annotations
  9. from typing import Any
  10. import httpx
  11. # 各平台建爬取计划的固定参数(crawlerMode=5 按内容 ID 抓取)。
  12. # 抖音文档 §2.2:channel=2、filterMatchMode=2;快手文档 §2.2:channel=7、filterMatchMode=1。
  13. _PLATFORM_CRAWLER_PARAMS: dict[str, dict[str, Any]] = {
  14. "douyin": {
  15. "channel": 2,
  16. "contentModal": 4,
  17. "crawlerComment": 0,
  18. "crawlerMode": 5,
  19. "filterAccountMatchMode": 2,
  20. "filterContentMatchMode": 2,
  21. "frequencyType": 2,
  22. "planType": 2,
  23. "searchModeValues": [],
  24. "srtExtractFlag": 1,
  25. "videoKeyFrameType": 1,
  26. "voiceExtractFlag": 1,
  27. },
  28. "kuaishou": {
  29. "channel": 7,
  30. "contentModal": 4,
  31. "crawlerComment": 0,
  32. "crawlerMode": 5,
  33. "filterAccountMatchMode": 1,
  34. "filterContentMatchMode": 1,
  35. "frequencyType": 2,
  36. "planType": 2,
  37. "searchModeValues": [],
  38. "srtExtractFlag": 1,
  39. "videoKeyFrameType": 1,
  40. "voiceExtractFlag": 1,
  41. },
  42. }
  43. # 绑进生成计划时,匹配输入源组 + 新输入源的固定字段(channel 随平台:抖音 2 / 快手 7,文档 4.2)。
  44. _PLATFORM_INPUT_MATCH: dict[str, dict[str, Any]] = {
  45. "douyin": {"inputSourceModal": 4, "inputSourceChannel": 2, "contentType": 1},
  46. "kuaishou": {"inputSourceModal": 4, "inputSourceChannel": 7, "contentType": 1},
  47. }
  48. class AigcPlatformError(RuntimeError):
  49. pass
  50. class AigcPlatformClient:
  51. def __init__(
  52. self,
  53. *,
  54. base_url: str,
  55. token: str,
  56. paths: dict[str, str],
  57. timeout: float = 30.0,
  58. ) -> None:
  59. self._base_url = base_url.rstrip("/")
  60. self._token = token
  61. self._paths = paths
  62. self._timeout = timeout
  63. @classmethod
  64. def from_env(cls, env: dict[str, str]) -> "AigcPlatformClient":
  65. token = env.get("CONTENTFIND_API_AIGC_TOKEN") or env.get("AIGC_TOKEN") or ""
  66. if not token:
  67. raise AigcPlatformError("缺 CONTENTFIND_API_AIGC_TOKEN")
  68. return cls(
  69. base_url=env.get("CONTENTFIND_API_AIGC_BASE_URL") or "https://aigc-api.aiddit.com",
  70. token=token,
  71. paths={
  72. "crawler_plan_save": env.get("CONTENTFIND_AIGC_CRAWLER_PLAN_SAVE_PATH") or "/aigc/crawler/plan/save",
  73. "produce_plan_detail": env.get("CONTENTFIND_AIGC_PRODUCE_PLAN_DETAIL_PATH") or "/aigc/produce/plan/detail",
  74. "produce_plan_save": env.get("CONTENTFIND_AIGC_PRODUCE_PLAN_SAVE_PATH") or "/aigc/produce/plan/save",
  75. },
  76. )
  77. def _post(self, path: str, params: dict[str, Any]) -> dict[str, Any]:
  78. body = {"baseInfo": {"token": self._token}, "params": params}
  79. response = httpx.post(
  80. f"{self._base_url}{path}",
  81. json=body,
  82. headers={"Content-Type": "application/json"},
  83. timeout=self._timeout,
  84. )
  85. response.raise_for_status()
  86. data = response.json()
  87. if not isinstance(data, dict):
  88. raise AigcPlatformError(f"{path}: 非法响应 {data!r}")
  89. if data.get("code") not in (0, "0"):
  90. raise AigcPlatformError(f"{path}: 业务失败 code={data.get('code')} msg={data.get('msg')}")
  91. return data.get("data") or {}
  92. def create_crawler_plan(self, content_ids: list[str], *, platform: str, name: str) -> str:
  93. """接口一:按平台建爬取计划(抖音/快手,按内容 ID 抓取),返回 crawler_plan_id(响应 data.id)。"""
  94. crawler_params = _PLATFORM_CRAWLER_PARAMS.get(platform)
  95. if crawler_params is None:
  96. raise AigcPlatformError(f"不支持的平台:{platform}")
  97. params = {**crawler_params, "inputModeValues": list(content_ids), "name": name}
  98. data = self._post(self._paths["crawler_plan_save"], params)
  99. plan_id = str(data.get("id") or "")
  100. if not plan_id:
  101. raise AigcPlatformError("接口一未返回 data.id")
  102. return plan_id
  103. def get_produce_plan(self, produce_plan_id: str) -> dict[str, Any]:
  104. """接口二:查生成计划完整详情(含 inputSourceGroups)。"""
  105. data = self._post(self._paths["produce_plan_detail"], {"id": produce_plan_id})
  106. if not data:
  107. raise AigcPlatformError(f"接口二空 data,produce_plan_id={produce_plan_id}")
  108. return data
  109. def bind_crawler_to_produce(
  110. self,
  111. *,
  112. crawler_plan_id: str,
  113. produce_plan_id: str,
  114. label: str,
  115. platform: str,
  116. ) -> None:
  117. """接口二+三:把 crawler_plan_id 作为新输入源,增量追加进生成计划并整体保存。
  118. 文档 4.4 要求:把接口二返回的完整 data 原样带上,只增量追加,避免字段缺失保存失败。
  119. """
  120. input_match = _PLATFORM_INPUT_MATCH.get(platform)
  121. if input_match is None:
  122. raise AigcPlatformError(f"不支持的平台:{platform}")
  123. plan = self.get_produce_plan(produce_plan_id)
  124. group = _match_input_group(plan.get("inputSourceGroups") or [], input_match)
  125. if group is None:
  126. raise AigcPlatformError(f"生成计划 {produce_plan_id} 找不到匹配的输入源组")
  127. group.setdefault("inputSources", []).append(
  128. {
  129. **input_match,
  130. "inputSourceType": 2,
  131. "inputSourceValue": crawler_plan_id,
  132. "inputSourceLabel": label,
  133. }
  134. )
  135. self._post(self._paths["produce_plan_save"], plan)
  136. def _match_input_group(
  137. groups: list[dict[str, Any]], input_match: dict[str, Any]
  138. ) -> dict[str, Any] | None:
  139. """找含匹配输入源(modal/channel/contentType,channel 随平台)的那一组(文档 4.2 step2);
  140. 没匹配但只有一组时退而用它;否则返回 None(不瞎猜)。"""
  141. for group in groups:
  142. for source in group.get("inputSources") or []:
  143. if all(source.get(key) == value for key, value in input_match.items()):
  144. return group
  145. return groups[0] if len(groups) == 1 else None