creative_creation.py 48 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194
  1. """创意搭建主入口(模块 B)。
  2. 数据流(2026-06-08 用户确认 + 端到端打通):
  3. 承接视频(piaoquantv) → 多路召回素材(vector) → top 1 → 上传素材图(MD5 幂等)
  4. → 调 xcx/save 注册落地计划(拿 page_url + creative_name)→ 读账户 brand
  5. → 构造 body → POST /dynamic_creatives/add → 绑到广告
  6. 决策落地(已验证):
  7. - image 组件:**必须用 image_id**(image_url 会 reject code=18001)
  8. → 实现:下载 cover URL → MD5 → POST /v3.0/images/add multipart → 拿 image_id
  9. → MD5 幂等:同账户重复上传返回同 ID(无需本地缓存)
  10. - brand 组件:**必填**(漏传会 reject code=1800269)
  11. → brand_image_id 跨账户不可复用(reject code=1530003)
  12. → 优先从 DB account_whitelist 读账户级 brand_name/brand_image_id
  13. → 若缺失,按 ad_creation_account_config.brand_key 读取 brand_asset_template,上传并写回账户
  14. - dynamic_creative_type: DYNAMIC_CREATIVE_TYPE_PROGRAM(参考样本 9744753978)
  15. - description: 素材 title 一条(MVP)
  16. - jump_info / 命名:**piaoquantv xcx/save 接口统一管**
  17. → page_url(mini_program_path)和 root_source_id(creative_name)都来自服务侧
  18. → 客户端不本地拼接、不本地命名,保证归因锚点跨系统一致
  19. - 配置 NORMAL 状态,挂上后腾讯进入 PENDING(普通素材审核 2-4 小时)
  20. """
  21. import logging
  22. import random
  23. from dataclasses import asdict, dataclass
  24. from typing import Iterator, Optional
  25. from config import (
  26. CREATIVE_DESCRIPTION_COUNT_PER_AD,
  27. CREATIVE_DESCRIPTION_POOL,
  28. LANDING_EXCLUDED_CATEGORIES,
  29. MARKETING_CARRIER_GH_ID,
  30. MAX_LANDING_ATTEMPTS_PER_AD,
  31. MAX_MATERIAL_PER_LANDING,
  32. TARGET_CREATIVES_PER_AD,
  33. )
  34. from tools.ad_api import _check, _get, _post, images_add
  35. from tools.account_material_strategy import load_account_material_strategy
  36. from tools.ai_generated_material import (
  37. get_or_generate_assets_for_landing,
  38. update_generated_material_status,
  39. )
  40. from tools.external_recalled_material import (
  41. prepare_external_material_for_landing,
  42. update_external_material_status,
  43. )
  44. from tools.landing_plan import LandingPlanResult, create_landing_plan
  45. from tools.material_recall import Material, recall_materials_for_video
  46. from tools.creative_material_usage import (
  47. load_recent_used_material_ids,
  48. merge_used_material_ids,
  49. )
  50. from tools.video_recall import (
  51. LandingVideo,
  52. PIAOQUANTV_HOT_FALLBACK_SOURCE,
  53. PIAOQUANTV_VIDEO_SOURCE,
  54. fetch_landing_videos_for_account,
  55. get_account_crowd_package,
  56. map_crowd_package_for_video_recall,
  57. )
  58. from tools.video_feature_query import VideoElementFeature, fetch_video_element_features
  59. from tools.video_risk import VideoRiskResult, check_video_risk
  60. logger = logging.getLogger(__name__)
  61. def _landing_category_values(v: LandingVideo) -> set[str]:
  62. """解析承接视频的内容品类,返回去重后的品类标签集合。"""
  63. raw = v.category or ""
  64. return {part.strip() for part in raw.replace(",", ",").split(",") if part.strip()}
  65. def _is_landing_candidate(v: LandingVideo) -> bool:
  66. """承接视频基础预筛:内容品类不在黑名单。
  67. 素材召回特征统一通过 video_id 查 ODPS,不再依赖 videoContentList 返回的
  68. pointType / standardElement。
  69. """
  70. excluded = _landing_category_values(v) & LANDING_EXCLUDED_CATEGORIES
  71. if excluded:
  72. logger.info(
  73. "[creative_creation] landing=%d category=%r 命中排除品类,跳过",
  74. v.video_id, v.category,
  75. )
  76. return False
  77. return True
  78. def _pick_image_url(material: Material) -> str:
  79. """从素材里取 image URL — material.cover 优先,fallback 到 raw.imageList[0]"""
  80. if material.cover:
  81. return material.cover
  82. images = (material.raw or {}).get("imageList") or []
  83. return images[0] if images else ""
  84. @dataclass
  85. class _LandingSourceState:
  86. videos: list[LandingVideo]
  87. features_by_vid: dict[int, list[VideoElementFeature]]
  88. stats: dict
  89. class LandingCandidatePool:
  90. """一次运行内复用的承接视频候选池。
  91. 只缓存无外部副作用的步骤:视频拉取、品类过滤、ODPS 特征读取。
  92. 风险审核按实际遍历到的 landing 懒执行并缓存,避免一次性审核用不到的视频。
  93. 图片上传、AI 生图、xcx/save 仍由 prepare_one_creative_for_ad 串行执行。
  94. """
  95. def __init__(self, account_id: int, max_landings: int = MAX_LANDING_ATTEMPTS_PER_AD):
  96. self.account_id = account_id
  97. self.max_landings = max_landings
  98. self.crowd_package = get_account_crowd_package(account_id)
  99. self.video_crowd_package = map_crowd_package_for_video_recall(self.crowd_package)
  100. self._source_state_by_label: dict[str, _LandingSourceState] = {}
  101. self._risk_by_vid: dict[int, VideoRiskResult] = {}
  102. self._no_features_logged: set[tuple[str, int]] = set()
  103. @property
  104. def source_plan(self) -> list[tuple[str, str]]:
  105. plan = [("primary", PIAOQUANTV_VIDEO_SOURCE)]
  106. if PIAOQUANTV_HOT_FALLBACK_SOURCE != PIAOQUANTV_VIDEO_SOURCE:
  107. plan.append(("hot", PIAOQUANTV_HOT_FALLBACK_SOURCE))
  108. return plan
  109. def iter_featured_landings(self) -> Iterator[tuple[str, LandingVideo, list[VideoElementFeature]]]:
  110. for source_label, source in self.source_plan:
  111. state = self._load_source(source_label, source)
  112. for v in state.videos:
  113. element_features = state.features_by_vid.get(v.video_id) or []
  114. if not element_features:
  115. if (source_label, v.video_id) not in self._no_features_logged:
  116. state.stats["no_features"] += 1
  117. self._no_features_logged.add((source_label, v.video_id))
  118. logger.info(
  119. "[landing_candidate_pool] landing=%d 无 ODPS 召回特征,跳过",
  120. v.video_id,
  121. )
  122. continue
  123. state.stats["feature_candidates_yielded"] += 1
  124. yield source_label, v, element_features
  125. def check_risk(self, source_label: str, landing: LandingVideo) -> VideoRiskResult:
  126. risk = self._risk_by_vid.get(landing.video_id)
  127. if risk is not None:
  128. return risk
  129. state = self._source_state_by_label[source_label]
  130. risk = check_video_risk(landing.video_id)
  131. self._risk_by_vid[landing.video_id] = risk
  132. state.stats["risk_checked"] += 1
  133. if not risk.passed:
  134. state.stats["risk_blocked"] += 1
  135. state.stats["risk_blocked_by_vid"][landing.video_id] = risk.reason
  136. logger.warning(
  137. "[landing_candidate_pool] landing=%d 风险拦截:%s",
  138. landing.video_id, risk.reason,
  139. )
  140. return risk
  141. def _load_source(self, source_label: str, source: str) -> _LandingSourceState:
  142. if source_label in self._source_state_by_label:
  143. return self._source_state_by_label[source_label]
  144. videos = fetch_landing_videos_for_account(
  145. self.account_id,
  146. page_size=100,
  147. source=source,
  148. enable_hot_fallback=False,
  149. )
  150. valid = [v for v in videos if _is_landing_candidate(v)]
  151. features_by_vid = fetch_video_element_features(v.video_id for v in valid)
  152. stats = {
  153. "fetched": len(videos),
  154. "valid": len(valid),
  155. "category_filtered": max(len(videos) - len(valid), 0),
  156. "risk_checked": 0,
  157. "risk_blocked": 0,
  158. "risk_blocked_by_vid": {},
  159. "no_features": 0,
  160. "feature_candidates_yielded": 0,
  161. }
  162. logger.info(
  163. "[landing_candidate_pool] account=%d source=%s valid=%d/%d feature_videos=%d feature_rows=%d",
  164. self.account_id, source_label, len(valid),
  165. len(videos), len(features_by_vid), sum(len(v) for v in features_by_vid.values()),
  166. )
  167. state = _LandingSourceState(
  168. videos=valid,
  169. features_by_vid=features_by_vid,
  170. stats=stats,
  171. )
  172. self._source_state_by_label[source_label] = state
  173. logger.info(
  174. "[landing_candidate_pool] account=%d source=%s stats=%s",
  175. self.account_id, source_label, stats,
  176. )
  177. return state
  178. def build_landing_candidate_pool(
  179. account_id: int,
  180. max_landings: int = MAX_LANDING_ATTEMPTS_PER_AD,
  181. ) -> LandingCandidatePool:
  182. return LandingCandidatePool(account_id, max_landings=max_landings)
  183. def find_ads_needing_creatives(
  184. account_id: int,
  185. min_creatives: int = TARGET_CREATIVES_PER_AD,
  186. ) -> list[dict]:
  187. """扫描账户下"需要补创意"的广告(P0-B,2026-06-09)。
  188. 口径(2026-06-08 用户确认 = C):
  189. configured_status = AD_STATUS_NORMAL AND creative_count < min_creatives
  190. 实测发现:腾讯 /adgroups/get 不返回 creative_count 字段,
  191. 所以分两条路径取创意数:
  192. - 快路径:system_status = ADGROUP_STATUS_CREATIVE_EMPTY → creative_count = 0
  193. - 慢路径:其他状态 → 单独调 /dynamic_creatives/get 数总数(N+1,目前广告 ≤ 10 可接受)
  194. Args:
  195. account_id: 腾讯广告主账号 ID
  196. min_creatives: 默认读 config.TARGET_CREATIVES_PER_AD(测试=1,生产应回 15)
  197. — find 阈值 + 补量目标的同一变量,无矛盾
  198. Returns:
  199. [{adgroup_id, adgroup_name, system_status, creative_count}, ...]
  200. """
  201. # 注意:腾讯 filtering 对 configured_status IN 静默拒绝(实测 2026-06-09 加了 filtering 返回 0)
  202. # 改为不加 filtering 拉全部 + Python 内存过滤(单账户 ≤ 10 条广告,代价≈0)
  203. resp = _get("/adgroups/get", {
  204. "account_id": account_id, "page": 1, "page_size": 100,
  205. "fields": ["adgroup_id", "adgroup_name", "system_status", "configured_status"],
  206. })
  207. all_ads_raw = (resp.get("data") or {}).get("list") or []
  208. all_ads = [a for a in all_ads_raw if a.get("configured_status") == "AD_STATUS_NORMAL"]
  209. logger.info(
  210. "[find_ads_needing_creatives] account=%d 共 %d 条广告,%d 条 NORMAL",
  211. account_id, len(all_ads_raw), len(all_ads),
  212. )
  213. out = []
  214. for ad in all_ads:
  215. adgroup_id = ad["adgroup_id"]
  216. system_status = ad.get("system_status", "")
  217. adgroup_name = ad.get("adgroup_name", "")
  218. if system_status == "ADGROUP_STATUS_CREATIVE_EMPTY":
  219. creative_count = 0
  220. else:
  221. cresp = _get("/dynamic_creatives/get", {
  222. "account_id": account_id, "page": 1, "page_size": 100,
  223. "filtering": [{
  224. "field": "adgroup_id", "operator": "IN",
  225. "values": [str(adgroup_id)],
  226. }],
  227. "fields": ["dynamic_creative_id", "system_status"],
  228. })
  229. all_creatives = (cresp.get("data") or {}).get("list") or []
  230. # 2026-06-09:忽略 DENIED 状态(审核拒绝),让 find_ads 能重补
  231. # 其他状态(PENDING/ACTIVE/SUSPEND 等)都算"已挂"
  232. denied_count = sum(
  233. 1 for c in all_creatives
  234. if c.get("system_status") == "DYNAMIC_CREATIVE_STATUS_DENIED"
  235. )
  236. creative_count = len(all_creatives) - denied_count
  237. if denied_count:
  238. logger.info(
  239. "[find_ads_needing_creatives] adgroup=%d 总创意 %d 减去 %d 条 DENIED → 有效 %d",
  240. adgroup_id, len(all_creatives), denied_count, creative_count,
  241. )
  242. if creative_count < min_creatives:
  243. out.append({
  244. "adgroup_id": adgroup_id,
  245. "adgroup_name": adgroup_name,
  246. "system_status": system_status,
  247. "creative_count": creative_count,
  248. })
  249. logger.info(
  250. "[find_ads_needing_creatives] ✓ adgroup=%d name=%r creative_count=%d < %d",
  251. adgroup_id, adgroup_name, creative_count, min_creatives,
  252. )
  253. logger.info(
  254. "[find_ads_needing_creatives] account=%d 命中 %d 条需补创意",
  255. account_id, len(out),
  256. )
  257. return out
  258. def find_existing_creative_by_image(
  259. account_id: int, adgroup_id: int, material_image_id: str,
  260. ) -> Optional[int]:
  261. """查 adgroup 下是否已有创意挂了这个素材 image_id(幂等检查)。
  262. 判等键(2026-06-08 决策):**同 adgroup + 同 material_image_id**。
  263. - 不按 creative_name 判等:name = root_source_id,每次 xcx/save 都新,等于没做
  264. - 按 image_id 判等:同 image 在同 adgroup 下挂多条会被腾讯模型降权曝光
  265. Returns:
  266. 已有创意的 dynamic_creative_id;无则 None。
  267. """
  268. resp = _get("/dynamic_creatives/get", {
  269. "account_id": account_id, "page": 1, "page_size": 100,
  270. "filtering": [{
  271. "field": "adgroup_id", "operator": "IN", "values": [str(adgroup_id)],
  272. }],
  273. "fields": ["dynamic_creative_id", "creative_components"],
  274. })
  275. items = (resp.get("data") or {}).get("list") or []
  276. for c in items:
  277. images = (c.get("creative_components") or {}).get("image") or []
  278. for img in images:
  279. existing_id = ((img.get("value") or {}).get("image_id")) or ""
  280. if str(existing_id) == str(material_image_id):
  281. cid = int(c.get("dynamic_creative_id"))
  282. logger.info(
  283. "[idempotency] adgroup=%d image_id=%s 已挂创意 creative_id=%d,skip 新建",
  284. adgroup_id, material_image_id, cid,
  285. )
  286. return cid
  287. return None
  288. def get_account_brand(account_id: int) -> dict:
  289. """读取或初始化账户级 brand 资产。
  290. 返回 {"brand_name": str, "brand_image_id": str}。
  291. 跨账户 brand_image_id 不可复用,所以一定按 account_id 取。
  292. 如果 account_whitelist 还没有 brand_image_id,则按待投放配置的 brand_key
  293. 找统一品牌模板,上传模板 URL 到当前账户,并把新 image_id 写回 DB。
  294. """
  295. from db.connection import get_connection
  296. conn = get_connection()
  297. try:
  298. with conn.cursor() as cur:
  299. cur.execute(
  300. "SELECT brand_name, brand_image_id FROM account_whitelist WHERE account_id=%s",
  301. (account_id,),
  302. )
  303. row = cur.fetchone()
  304. if row and row.get("brand_name") and row.get("brand_image_id"):
  305. return {
  306. "brand_name": row["brand_name"],
  307. "brand_image_id": str(row["brand_image_id"]),
  308. }
  309. cur.execute(
  310. """
  311. SELECT b.brand_key, b.brand_name, b.brand_image_url
  312. FROM ad_creation_account_config c
  313. JOIN brand_asset_template b
  314. ON b.brand_key = c.brand_key
  315. AND b.enabled = TRUE
  316. WHERE c.account_id=%s
  317. AND c.enabled = TRUE
  318. """,
  319. (account_id,),
  320. )
  321. tpl = cur.fetchone()
  322. finally:
  323. conn.close()
  324. if not tpl or not tpl.get("brand_name") or not tpl.get("brand_image_url"):
  325. raise RuntimeError(
  326. f"account {account_id} 未配置可用品牌资产模板。"
  327. "请在 ad_creation_account_config.brand_key 关联 brand_asset_template"
  328. )
  329. brand_name = tpl["brand_name"]
  330. brand_image_url = tpl["brand_image_url"]
  331. logger.info(
  332. "[brand] account=%d 缺 brand_image_id,按模板 %s 上传品牌图",
  333. account_id, tpl.get("brand_key"),
  334. )
  335. brand_image_id = images_add(account_id, brand_image_url)
  336. conn = get_connection()
  337. try:
  338. with conn.cursor() as cur:
  339. cur.execute(
  340. """
  341. UPDATE account_whitelist
  342. SET brand_name=%s,
  343. brand_image_id=%s,
  344. updated_by=%s,
  345. updated_at=CURRENT_TIMESTAMP
  346. WHERE account_id=%s
  347. """,
  348. (brand_name, brand_image_id, "auto-brand-init", account_id),
  349. )
  350. conn.commit()
  351. finally:
  352. conn.close()
  353. logger.info(
  354. "[brand] account=%d 已初始化 brand_name=%s brand_image_id=%s",
  355. account_id, brand_name, brand_image_id,
  356. )
  357. return {
  358. "brand_name": brand_name,
  359. "brand_image_id": str(brand_image_id),
  360. }
  361. def build_creative_request_body(
  362. account_id: int,
  363. adgroup_id: int,
  364. landing: LandingVideo,
  365. material: Material,
  366. material_image_id: str,
  367. brand_name: str,
  368. brand_image_id: str,
  369. creative_name: str,
  370. jump_path: str,
  371. description_contents: Optional[list] = None,
  372. ) -> dict:
  373. """生成 /v3.0/dynamic_creatives/add 的请求 body(纯函数,无 I/O)。
  374. Args:
  375. material_image_id: 已上传到当前账户的素材图 image_id
  376. brand_name / brand_image_id: 账户级 brand 资产(已上传)
  377. creative_name: 由 xcx/save 返回的 root_source_id(归因锚点)
  378. jump_path: 由 xcx/save 返回的 page_url(小程序跳转路径)
  379. description_contents: 文案列表(可选);None 时自动 random.sample(用于审批表回显)
  380. """
  381. # 默认文案由配置控制;生产当前统一使用"打开看看"。
  382. if description_contents is None:
  383. description_count = min(CREATIVE_DESCRIPTION_COUNT_PER_AD, len(CREATIVE_DESCRIPTION_POOL))
  384. description_contents = CREATIVE_DESCRIPTION_POOL[:description_count]
  385. jump_spec = {
  386. "page_type": "PAGE_TYPE_WECHAT_MINI_PROGRAM",
  387. "page_spec": {
  388. "wechat_mini_program_spec": {
  389. "mini_program_id": MARKETING_CARRIER_GH_ID,
  390. "mini_program_path": jump_path,
  391. }
  392. },
  393. }
  394. return {
  395. "account_id": account_id,
  396. "adgroup_id": adgroup_id,
  397. "dynamic_creative_name": creative_name,
  398. "delivery_mode": "DELIVERY_MODE_COMPONENT",
  399. "dynamic_creative_type": "DYNAMIC_CREATIVE_TYPE_PROGRAM",
  400. "configured_status": "AD_STATUS_NORMAL",
  401. "creative_components": {
  402. "description": [{"value": {"content": d}} for d in description_contents],
  403. "image": [{"value": {"image_id": material_image_id}}],
  404. "brand": [{"value": {
  405. "brand_name": brand_name,
  406. "brand_image_id": brand_image_id,
  407. }}],
  408. "action_button": [{"value": {"button_text": "查看详情"}}],
  409. "jump_info": [{"value": jump_spec}],
  410. "main_jump_info": [{"value": jump_spec}],
  411. },
  412. }
  413. def _pick_landing_and_materials(account_id: int) -> tuple[LandingVideo, list[Material]]:
  414. """召回链:承接视频 → 多路素材召回 → 取第一个有素材命中的 landing 及其 top 素材。
  415. 出口:确保 landing.point_type+standard_element 都有值(否则多路召回会全 skip)。
  416. """
  417. videos = fetch_landing_videos_for_account(account_id, page_size=50)
  418. valid = [v for v in videos if _is_landing_candidate(v)]
  419. if not valid:
  420. raise RuntimeError(
  421. f"account={account_id} 无 pointType+standardElement 都有值的承接视频"
  422. )
  423. for v in valid:
  424. risk = check_video_risk(v.video_id)
  425. if not risk.passed:
  426. logger.warning(
  427. "[creative_creation] landing=%d 风险拦截:%s",
  428. v.video_id, risk.reason,
  429. )
  430. continue
  431. materials = recall_materials_for_video(v, final_top_n=5)
  432. if materials:
  433. return v, materials
  434. raise RuntimeError(
  435. f"account={account_id} 前 {len(valid)} 条承接视频都召回 0 素材"
  436. )
  437. def preview_for_account(account_id: int, adgroup_id: int) -> dict:
  438. """承接视频 → 召回素材 → top 1 → 上传素材图 → 注册落地计划 → 读 brand → 构造 body。
  439. **会真实调:**
  440. - 腾讯 /images/add(MD5 幂等)
  441. - piaoquantv xcx/save(每次新建一条计划)
  442. body 是真实可投放的(只差 POST /dynamic_creatives/add)。
  443. """
  444. landing, materials = _pick_landing_and_materials(account_id)
  445. top_material = materials[0]
  446. image_url = _pick_image_url(top_material)
  447. if not image_url:
  448. raise ValueError(
  449. f"素材 {top_material.material_id} 既无 cover 也无 imageList,无法构造 image 组件"
  450. )
  451. material_image_id = images_add(account_id, image_url)
  452. crowd_package = get_account_crowd_package(account_id)
  453. plan = create_landing_plan(crowd_package, landing)
  454. brand = get_account_brand(account_id)
  455. body = build_creative_request_body(
  456. account_id=account_id, adgroup_id=adgroup_id,
  457. landing=landing, material=top_material,
  458. material_image_id=material_image_id,
  459. brand_name=brand["brand_name"],
  460. brand_image_id=brand["brand_image_id"],
  461. creative_name=plan.root_source_id,
  462. jump_path=plan.page_url,
  463. )
  464. return {
  465. "landing": asdict(landing),
  466. "material": asdict(top_material),
  467. "material_image_id": material_image_id,
  468. "brand": brand,
  469. "landing_plan": asdict(plan),
  470. "body": body,
  471. }
  472. def create_creative_for_ad(
  473. account_id: int, adgroup_id: int,
  474. landing: LandingVideo, material: Material,
  475. skip_if_exists: bool = True,
  476. ) -> int:
  477. """编排:上传素材图 → 幂等检查 → 注册落地计划 → 读 brand → build body → POST。
  478. Args:
  479. skip_if_exists: True(默认) — 同 adgroup + 同 material_image_id 已有创意时直接返回已有 ID,
  480. 跳过 xcx/save 注册和腾讯 POST,节省审核额度 + 避免模型降权。
  481. False — 强制新建(用于 A/B 测试不同归因锚点的场景)。
  482. Returns:
  483. dynamic_creative_id(新建或已有)
  484. """
  485. image_url = _pick_image_url(material)
  486. if not image_url:
  487. raise ValueError(
  488. f"素材 {material.material_id} 既无 cover 也无 imageList,无法构造 image 组件"
  489. )
  490. material_image_id = images_add(account_id, image_url)
  491. if skip_if_exists:
  492. existing_cid = find_existing_creative_by_image(account_id, adgroup_id, material_image_id)
  493. if existing_cid:
  494. return existing_cid
  495. crowd_package = get_account_crowd_package(account_id)
  496. plan = create_landing_plan(crowd_package, landing)
  497. brand = get_account_brand(account_id)
  498. body = build_creative_request_body(
  499. account_id, adgroup_id, landing, material,
  500. material_image_id=material_image_id,
  501. brand_name=brand["brand_name"],
  502. brand_image_id=brand["brand_image_id"],
  503. creative_name=plan.root_source_id,
  504. jump_path=plan.page_url,
  505. )
  506. logger.info(
  507. "[creative_creation] POST /dynamic_creatives/add adgroup=%d name=%s image_id=%s plan_id=%d",
  508. adgroup_id, body["dynamic_creative_name"], material_image_id, plan.plan_id,
  509. )
  510. resp = _post("/dynamic_creatives/add", body)
  511. data = _check(resp, "creative_create")
  512. return data.get("dynamic_creative_id")
  513. def prepare_one_creative_for_ad(
  514. account_id: int,
  515. adgroup_id: int,
  516. excluded_material_ids: Optional[set] = None,
  517. excluded_landing_ids: Optional[set] = None,
  518. landing_candidates: Optional[LandingCandidatePool] = None,
  519. failed_landing_ids: Optional[set[int]] = None,
  520. max_landings: int = MAX_LANDING_ATTEMPTS_PER_AD,
  521. max_materials_per_landing: int = MAX_MATERIAL_PER_LANDING,
  522. ) -> Optional[dict]:
  523. """Phase 1 准备:召回 + 上传图 + xcx/save + build body → 返回 pending record。
  524. **不 POST 腾讯**。POST 行为留给 Phase 3(供"先审后挂"流程审批后才挂)。
  525. 跟 try_create_one_creative_with_fallback 的差异:
  526. - 不做 POST-based fallback(因为不 POST)
  527. - 信任黑名单 + 选 top 1 material(`EXCLUDED_COVER_URL_PATTERNS` 已经截掉尺寸不符的)
  528. - 如果 Phase 3 POST 失败(腾讯尺寸 reject 等),由 Phase 3 记 error,下轮主循环重试
  529. Args:
  530. excluded_material_ids: 同广告内已挂的 material_id 集合(2026-06-09 N=3 时去重必需)。
  531. 召回后过滤掉这些,避免同广告挂重复素材被腾讯模型降权曝光。
  532. excluded_landing_ids: 本轮/近期已使用的 landing_video_id 集合。
  533. 用于同人群包跨广告、跨轮 landing 排重。
  534. landing_candidates: 本轮复用的承接视频候选池。传入后不重复拉视频/查特征/风险。
  535. failed_landing_ids: 本广告本轮已尝试失败的 landing_video_id,避免 AI 拒审/无素材后重复尝试。
  536. Returns:
  537. pending record dict(飞书表格字段 + Phase 3 POST 用的完整 body + 元数据);
  538. 所有 landing 都召回 0 素材时返回 None。
  539. """
  540. from datetime import datetime, timezone
  541. excluded_material_ids = excluded_material_ids or set()
  542. excluded_landing_ids = excluded_landing_ids or set()
  543. failed_landing_ids = failed_landing_ids if failed_landing_ids is not None else set()
  544. crowd_package = get_account_crowd_package(account_id)
  545. material_strategy = load_account_material_strategy(account_id)
  546. video_crowd_package = map_crowd_package_for_video_recall(crowd_package)
  547. landing_candidates = landing_candidates or build_landing_candidate_pool(
  548. account_id,
  549. max_landings=max_landings,
  550. )
  551. if material_strategy.use_ai_generated and not material_strategy.ai_fallback_to_history:
  552. recent_material_ids = set()
  553. logger.info(
  554. "[prepare_one_creative] account=%d adgroup=%d material_source=%s fallback_history=%s; "
  555. "AI素材不回退历史,跳过历史素材排重库",
  556. account_id, adgroup_id, material_strategy.material_source,
  557. material_strategy.ai_fallback_to_history,
  558. )
  559. else:
  560. try:
  561. recent_material_ids = load_recent_used_material_ids(crowd_package)
  562. except Exception as e:
  563. logger.warning(
  564. "[prepare_one_creative] account=%d crowd=%r 读取素材使用历史失败,仅使用本轮排重:%s",
  565. account_id, crowd_package, e,
  566. )
  567. recent_material_ids = set()
  568. effective_excluded_material_ids = merge_used_material_ids(
  569. excluded_material_ids,
  570. recent_material_ids,
  571. )
  572. logger.info(
  573. "[prepare_one_creative] account=%d adgroup=%d crowd=%r video_crowd=%r "
  574. "material_source=%s fallback_history=%s material_dedupe run=%d recent=%d effective=%d",
  575. account_id, adgroup_id, crowd_package, video_crowd_package,
  576. material_strategy.material_source, material_strategy.ai_fallback_to_history,
  577. len(excluded_material_ids), len(recent_material_ids),
  578. len(effective_excluded_material_ids),
  579. )
  580. chosen_landing = None
  581. chosen_material = None
  582. chosen_risk: Optional[VideoRiskResult] = None
  583. chosen_landing_source = ""
  584. chosen_material_source = material_strategy.material_source
  585. chosen_ai_generated_material_id = None
  586. chosen_external_recalled_material_id = None
  587. source_stats_by_label: dict[str, dict] = {}
  588. for source_label, v, element_features in landing_candidates.iter_featured_landings():
  589. source_stats = source_stats_by_label.setdefault(source_label, {
  590. "pool_candidates": 0,
  591. "landing_dedupe": 0,
  592. "failed_landing_dedupe": 0,
  593. "ai_attempts": 0,
  594. "ai_failed": 0,
  595. "ai_empty": 0,
  596. "external_attempts": 0,
  597. "external_failed": 0,
  598. "external_empty": 0,
  599. "history_recall_empty": 0,
  600. "all_excluded": 0,
  601. "selected": 0,
  602. })
  603. source_stats["pool_candidates"] += 1
  604. if v.video_id in excluded_landing_ids:
  605. source_stats["landing_dedupe"] += 1
  606. logger.info(
  607. "[prepare_one_creative] landing=%d landing 排重命中,跳过",
  608. v.video_id,
  609. )
  610. continue
  611. if v.video_id in failed_landing_ids:
  612. source_stats["failed_landing_dedupe"] += 1
  613. logger.info(
  614. "[prepare_one_creative] landing=%d 本广告本轮已失败,跳过",
  615. v.video_id,
  616. )
  617. continue
  618. risk = landing_candidates.check_risk(source_label, v)
  619. if not risk.passed:
  620. continue
  621. source_stats = source_stats_by_label[source_label]
  622. materials = []
  623. candidate_material_source = material_strategy.material_source
  624. if material_strategy.use_external_recall:
  625. source_stats["external_attempts"] += 1
  626. try:
  627. processed = prepare_external_material_for_landing(
  628. account_id=account_id,
  629. adgroup_id=adgroup_id,
  630. crowd_package=crowd_package,
  631. landing=v,
  632. element_features=element_features,
  633. excluded_material_ids=effective_excluded_material_ids,
  634. )
  635. materials = [processed] if processed is not None else []
  636. except Exception as e:
  637. source_stats["external_failed"] += 1
  638. logger.exception(
  639. "[prepare_one_creative] landing=%d 外部素材处理失败:%s",
  640. v.video_id, e,
  641. )
  642. if not materials:
  643. source_stats["external_empty"] += 1
  644. failed_landing_ids.add(v.video_id)
  645. continue
  646. elif material_strategy.use_ai_generated:
  647. source_stats["ai_attempts"] += 1
  648. try:
  649. assets = get_or_generate_assets_for_landing(
  650. account_id=account_id,
  651. adgroup_id=adgroup_id,
  652. crowd_package=crowd_package,
  653. landing=v,
  654. )
  655. materials = [asset.to_material() for asset in assets]
  656. logger.info(
  657. "[prepare_one_creative] landing=%d AI生成素材候选=%d fallback_history=%s",
  658. v.video_id, len(materials), material_strategy.ai_fallback_to_history,
  659. )
  660. except Exception as e:
  661. source_stats["ai_failed"] += 1
  662. failed_landing_ids.add(v.video_id)
  663. logger.exception(
  664. "[prepare_one_creative] landing=%d AI生成素材失败:%s",
  665. v.video_id, e,
  666. )
  667. if not material_strategy.ai_fallback_to_history:
  668. continue
  669. if (
  670. material_strategy.use_ai_generated
  671. and not materials
  672. and not material_strategy.ai_fallback_to_history
  673. ):
  674. source_stats["ai_empty"] += 1
  675. failed_landing_ids.add(v.video_id)
  676. logger.info(
  677. "[prepare_one_creative] landing=%d AI无可用素材且不允许回退历史素材,跳过",
  678. v.video_id,
  679. )
  680. continue
  681. if (not materials) and (
  682. (
  683. not material_strategy.use_ai_generated
  684. and not material_strategy.use_external_recall
  685. )
  686. or (
  687. material_strategy.use_ai_generated
  688. and material_strategy.ai_fallback_to_history
  689. )
  690. ):
  691. if material_strategy.use_ai_generated:
  692. logger.info(
  693. "[prepare_one_creative] landing=%d AI无可用素材,按账户配置回退历史素材",
  694. v.video_id,
  695. )
  696. candidate_material_source = "history_fallback"
  697. materials = recall_materials_for_video(
  698. v,
  699. final_top_n=max_materials_per_landing,
  700. element_features=element_features,
  701. )
  702. if not materials:
  703. source_stats["history_recall_empty"] += 1
  704. failed_landing_ids.add(v.video_id)
  705. # material_id 去重(2026-06-09):跳过已用素材(账户层 set,跨广告也共享)
  706. fresh = [
  707. m for m in materials
  708. if m.material_id not in effective_excluded_material_ids
  709. ]
  710. if fresh:
  711. chosen_landing = v
  712. chosen_material = fresh[0]
  713. chosen_risk = risk
  714. chosen_landing_source = source_label
  715. chosen_material_source = candidate_material_source
  716. if chosen_material.material_id.startswith("ai:"):
  717. chosen_ai_generated_material_id = chosen_material.raw.get("ai_generated_material_id")
  718. if chosen_material.material_id.startswith("external:"):
  719. chosen_external_recalled_material_id = chosen_material.raw.get(
  720. "external_recalled_material_id"
  721. )
  722. source_stats["selected"] += 1
  723. logger.info(
  724. "[prepare_one_creative] 选中 landing=%d source=%s category=%r material_source=%s material=%s recall=%s/%s/%s cost=%s roi=%s ctr=%s imp=%s score=%s policy=%s",
  725. v.video_id, source_label, v.category,
  726. chosen_material_source,
  727. chosen_material.material_id,
  728. chosen_material.recall_element_dimension,
  729. chosen_material.recall_point_type,
  730. chosen_material.recall_standard_element,
  731. chosen_material.cost,
  732. chosen_material.roi,
  733. chosen_material.ctr,
  734. chosen_material.impressions,
  735. chosen_material.score,
  736. (
  737. "ai_generated"
  738. if chosen_material.material_id.startswith("ai:")
  739. else "score>=0.8,visit_uv_window_desc,ai_cleanup"
  740. if chosen_material.material_id.startswith("external:")
  741. else "score>=0.8,cost_desc"
  742. ),
  743. )
  744. break
  745. if materials:
  746. source_stats["all_excluded"] += 1
  747. failed_landing_ids.add(v.video_id)
  748. logger.info(
  749. "[prepare_one_creative] landing=%d 召回 %d 全在 excluded,试下一条",
  750. v.video_id, len(materials),
  751. )
  752. if chosen_landing and chosen_material:
  753. logger.info(
  754. "[prepare_one_creative] source_summary account=%d adgroup=%d source=%s stats=%s",
  755. account_id, adgroup_id, chosen_landing_source,
  756. source_stats_by_label.get(chosen_landing_source, {}),
  757. )
  758. else:
  759. logger.info(
  760. "[prepare_one_creative] account=%d adgroup=%d 未产出可用创意 stats=%s",
  761. account_id, adgroup_id, source_stats_by_label,
  762. )
  763. if not chosen_landing or not chosen_material:
  764. logger.error(
  765. "[prepare_one_creative] account=%d adgroup=%d material_source=%s fallback_history=%s "
  766. "穷尽 landing 后无可用素材(excluded=%d)",
  767. account_id, adgroup_id, material_strategy.material_source,
  768. material_strategy.ai_fallback_to_history, len(effective_excluded_material_ids),
  769. )
  770. return None
  771. image_url = _pick_image_url(chosen_material)
  772. if not image_url:
  773. logger.error(
  774. "[prepare_one_creative] material=%s 既无 cover 也无 imageList,放弃",
  775. chosen_material.material_id,
  776. )
  777. failed_landing_ids.add(chosen_landing.video_id)
  778. return None
  779. is_ai_generated_material = chosen_material.material_id.startswith("ai:")
  780. is_external_recalled_material = chosen_material.material_id.startswith("external:")
  781. is_deferred_generated_material = (
  782. is_ai_generated_material or is_external_recalled_material
  783. )
  784. if is_deferred_generated_material:
  785. # AI生成图与外部素材派生图先走人工审批,Phase 3 才上传腾讯。
  786. material_image_id = ""
  787. else:
  788. # 历史素材保持原有行为:Phase 1 上传腾讯图片(MD5 幂等)。
  789. material_image_id = images_add(account_id, image_url)
  790. # 注册落地计划(xcx/save)
  791. plan = create_landing_plan(crowd_package, chosen_landing)
  792. # 读账户 brand
  793. brand = get_account_brand(account_id)
  794. # 先确定文案,再传给 build_body — 这样 record 跟 body 文案一致。
  795. desc_count = min(CREATIVE_DESCRIPTION_COUNT_PER_AD, len(CREATIVE_DESCRIPTION_POOL))
  796. description_contents = CREATIVE_DESCRIPTION_POOL[:desc_count]
  797. # 构造完整 POST body(Phase 3 直接用)
  798. body = build_creative_request_body(
  799. account_id, adgroup_id, chosen_landing, chosen_material,
  800. material_image_id=material_image_id,
  801. brand_name=brand["brand_name"],
  802. brand_image_id=brand["brand_image_id"],
  803. creative_name=plan.root_source_id,
  804. jump_path=plan.page_url,
  805. description_contents=description_contents,
  806. )
  807. # 反查广告 metadata(飞书表格 B 组用)
  808. ad_info = _fetch_ad_metadata_for_approval(account_id, adgroup_id)
  809. today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
  810. rec = {
  811. # === 飞书表格字段(im_approval_creation 用)===
  812. "approval_date": today,
  813. "account_id": account_id,
  814. "audience_tier": crowd_package,
  815. "video_crowd_package": video_crowd_package,
  816. "adgroup_id": adgroup_id,
  817. "adgroup_name": ad_info.get("adgroup_name", ""),
  818. "bid_amount_yuan": ad_info.get("bid_amount_yuan", ""),
  819. "site_set": ad_info.get("site_set", ""),
  820. "age_range": ad_info.get("age_range", ""),
  821. "landing_video_id": chosen_landing.video_id,
  822. "landing_video_url": chosen_landing.video_url,
  823. "landing_title": chosen_landing.title,
  824. "landing_source": chosen_landing_source,
  825. "landing_category": chosen_landing.category,
  826. "material_source": chosen_material_source,
  827. "material_cover_url": chosen_material.cover,
  828. # 素材质量字段(2026-06-10 batchByText 升级 — 给 Task 25 飞书表展示用)
  829. "material_ctr": chosen_material.ctr,
  830. "material_cost": chosen_material.cost,
  831. "material_roi": chosen_material.roi,
  832. "material_impressions": chosen_material.impressions,
  833. "material_visit_uv_30d": chosen_material.visit_uv_30d,
  834. "material_quality_score": chosen_material.quality_score,
  835. "material_score": chosen_material.score,
  836. "material_selection_policy": (
  837. "ai_generated_manual_review"
  838. if is_ai_generated_material
  839. else "score>=0.8,visit_uv_window_desc,ai_cleanup_manual_review"
  840. if is_external_recalled_material
  841. else "score>=0.8,cost_desc"
  842. ),
  843. "material_recall_strategy": chosen_material.recall_strategy,
  844. "material_recall_query_text": chosen_material.recall_query_text,
  845. "material_recall_config_code": chosen_material.recall_config_code,
  846. "material_recall_element_dimension": chosen_material.recall_element_dimension,
  847. "material_recall_point_type": chosen_material.recall_point_type,
  848. "material_recall_standard_element": chosen_material.recall_standard_element,
  849. "material_recall_hit_queries": chosen_material.recall_hit_queries,
  850. "material_dedupe_recent_count": len(recent_material_ids),
  851. "material_dedupe_run_count": len(excluded_material_ids),
  852. "material_dedupe_effective_count": len(effective_excluded_material_ids),
  853. "landing_dedupe_run_count": len(excluded_landing_ids),
  854. "creative_name": plan.root_source_id,
  855. # === Phase 3 POST 用 ===
  856. "_request_body": body,
  857. # === 追溯元数据(写 summary JSON)===
  858. "_material_id": chosen_material.material_id,
  859. "_material_image_id": material_image_id,
  860. "_pending_image_url": image_url if is_deferred_generated_material else "",
  861. "_ai_generated_material_id": chosen_ai_generated_material_id,
  862. "_external_recalled_material_id": chosen_external_recalled_material_id,
  863. "_external_source_material_id": (
  864. chosen_material.raw.get("source_material_id")
  865. if is_external_recalled_material else ""
  866. ),
  867. "_external_source_image_url": (
  868. chosen_material.raw.get("source_image_url")
  869. if is_external_recalled_material else ""
  870. ),
  871. "_plan_id": plan.plan_id,
  872. "_experiment_id": chosen_landing.experiment_id,
  873. "_brand_image_id": brand["brand_image_id"],
  874. # 文案选择回显(飞书表格"创意文案"列要展示)
  875. "_description_contents": description_contents,
  876. }
  877. if chosen_risk is not None:
  878. rec.update(chosen_risk.to_record_fields())
  879. if chosen_ai_generated_material_id:
  880. try:
  881. update_generated_material_status(
  882. rec,
  883. "prepared",
  884. tencent_image_id=material_image_id,
  885. )
  886. except Exception as e:
  887. logger.warning(
  888. "[prepare_one_creative] AI素材状态更新失败 id=%s:%s",
  889. chosen_ai_generated_material_id, e,
  890. )
  891. if chosen_external_recalled_material_id:
  892. try:
  893. update_external_material_status(rec, "prepared")
  894. except Exception as e:
  895. logger.warning(
  896. "[prepare_one_creative] 外部素材状态更新失败 id=%s:%s",
  897. chosen_external_recalled_material_id, e,
  898. )
  899. return rec
  900. def _fetch_ad_metadata_for_approval(account_id: int, adgroup_id: int) -> dict:
  901. """反查广告,为飞书审批表组装 B 组(广告维度)字段。"""
  902. resp = _get("/adgroups/get", {
  903. "account_id": account_id, "page": 1, "page_size": 1,
  904. "filtering": [{
  905. "field": "adgroup_id", "operator": "IN", "values": [str(adgroup_id)],
  906. }],
  907. "fields": [
  908. "adgroup_id", "adgroup_name",
  909. "bid_amount", "site_set", "targeting",
  910. ],
  911. })
  912. items = (resp.get("data") or {}).get("list") or []
  913. if not items:
  914. return {}
  915. ad = items[0]
  916. bid_fen = ad.get("bid_amount")
  917. bid_yuan = f"{int(bid_fen) / 100:.2f}" if bid_fen is not None else ""
  918. site_set_cn_map = {
  919. "SITE_SET_WECHAT": "微信公众号",
  920. "SITE_SET_WECHAT_PLUGIN": "微信插件",
  921. "SITE_SET_SEARCH_SCENE": "搜索场景",
  922. "SITE_SET_MOMENTS": "朋友圈",
  923. "SITE_SET_MINI_GAME_WECHAT": "小游戏",
  924. "SITE_SET_MINI_PROGRAM_WECHAT": "小程序",
  925. }
  926. site_raw = ad.get("site_set") or []
  927. if isinstance(site_raw, str):
  928. site_str = site_set_cn_map.get(site_raw, site_raw)
  929. else:
  930. site_str = ",".join(site_set_cn_map.get(s, s) for s in site_raw)
  931. targeting = ad.get("targeting") or {}
  932. age_list = targeting.get("age") or []
  933. if age_list:
  934. age_str = ",".join(f"{a.get('min', '?')}-{a.get('max', '?')}" for a in age_list)
  935. else:
  936. age_str = "不限"
  937. return {
  938. "adgroup_name": ad.get("adgroup_name", ""),
  939. "bid_amount_yuan": bid_yuan,
  940. "site_set": site_str,
  941. "age_range": age_str,
  942. }
  943. # Task 28:永久业务错误码(重试无意义,同一 body 永远报同样错)
  944. # 实测/文档来源:
  945. # 1801159 — 图片尺寸/格式不符
  946. # 1801143 — 创意素材审核拒绝
  947. # 1800269 — 品牌字段缺失/不合规
  948. # 1801118 — 视频 / 图片资产 ID 无效
  949. # 1901634 — 唯一性 reject(同营销内容广告组已存在相同创意)
  950. # 1901589 — 相似性 reject
  951. # 33001 — 参数校验失败
  952. _PERMANENT_ERROR_CODES = ("1801159", "1801143", "1800269", "1801118", "1901634", "1901589", "33001")
  953. def post_creative_with_prepared_body(
  954. account_id: int,
  955. body: dict,
  956. skip_if_exists: bool = True,
  957. max_retries: int = 2,
  958. ) -> Optional[int]:
  959. """Phase 3 POST:用 Phase 1 准备好的 body 调腾讯 /dynamic_creatives/add。
  960. Task 28(2026-06-11):加重试机制,区分瞬时 / 永久错误。
  961. - 永久业务错误(图尺寸 / 品牌缺失 / 唯一性 / 33001 等)→ 直接返回 None,不重试
  962. - 瞬时错误(网络超时 / 5xx / QPS limit)→ 最多重试 max_retries 次,指数退避
  963. Args:
  964. account_id: 腾讯账户 ID(用于幂等检查)
  965. body: Phase 1 prepare_one_creative_for_ad 返回的 _request_body
  966. skip_if_exists: True(默认)POST 前做幂等检查,命中返回已有 cid
  967. max_retries: 瞬时错误的最大重试次数(默认 2;首次 + 2 次重试 = 最坏 3 次总尝试)
  968. Returns:
  969. 成功 dynamic_creative_id;失败 None(记 error log)
  970. """
  971. import time
  972. adgroup_id = body.get("adgroup_id")
  973. image_comp = body.get("creative_components", {}).get("image") or []
  974. material_image_id = ""
  975. if image_comp:
  976. material_image_id = (image_comp[0].get("value") or {}).get("image_id", "")
  977. if skip_if_exists and material_image_id and adgroup_id:
  978. existing_cid = find_existing_creative_by_image(
  979. account_id, int(adgroup_id), str(material_image_id),
  980. )
  981. if existing_cid:
  982. return existing_cid
  983. for attempt in range(max_retries + 1): # 首次 + 重试
  984. try:
  985. logger.info(
  986. "[post_creative] account=%d adgroup=%s name=%s image_id=%s attempt=%d/%d",
  987. account_id, adgroup_id,
  988. body.get("dynamic_creative_name", ""), material_image_id,
  989. attempt + 1, max_retries + 1,
  990. )
  991. resp = _post("/dynamic_creatives/add", body)
  992. data = _check(resp, "creative_create")
  993. cid = data.get("dynamic_creative_id")
  994. if cid:
  995. logger.info("[post_creative] 成功 cid=%s", cid)
  996. return cid
  997. logger.error("[post_creative] 返回 data 缺 dynamic_creative_id: %s", data)
  998. return None
  999. except RuntimeError as e:
  1000. err = str(e)
  1001. # 永久错误 → 不重试,直接放弃(节省 quota,避免无意义打)
  1002. is_permanent = any(code in err for code in _PERMANENT_ERROR_CODES)
  1003. if is_permanent:
  1004. logger.error(
  1005. "[post_creative] 永久错误,不重试 account=%d adgroup=%s: %s",
  1006. account_id, adgroup_id, err[:200],
  1007. )
  1008. return None
  1009. # 瞬时错误:重试 quota 没用完 → backoff 后重试
  1010. if attempt < max_retries:
  1011. backoff_s = 5 * (2 ** attempt) # 5s, 10s
  1012. logger.warning(
  1013. "[post_creative] 瞬时错误 attempt=%d/%d,sleep %ds 后重试 account=%d adgroup=%s: %s",
  1014. attempt + 1, max_retries + 1, backoff_s,
  1015. account_id, adgroup_id, err[:150],
  1016. )
  1017. time.sleep(backoff_s)
  1018. continue
  1019. # 重试用完 → 放弃
  1020. logger.error(
  1021. "[post_creative] 重试 %d 次后仍失败 account=%d adgroup=%s: %s",
  1022. max_retries, account_id, adgroup_id, err[:200],
  1023. )
  1024. return None
  1025. return None
  1026. def try_create_one_creative_with_fallback(
  1027. account_id: int,
  1028. adgroup_id: int,
  1029. max_landings: int = MAX_LANDING_ATTEMPTS_PER_AD,
  1030. max_materials_per_landing: int = MAX_MATERIAL_PER_LANDING,
  1031. ) -> Optional[int]:
  1032. """对一条广告挂 1 条创意,带 try-fallback 鲁棒性(P0-A 核心 helper,2026-06-09)。
  1033. 召回的素材不一定都能挂(实测 code=1801159 尺寸不符,1530003 等),
  1034. 所以遍历 landing × material 尝试 POST,失败就试下一条,直到成功或穷尽。
  1035. Args:
  1036. account_id: 腾讯广告主账号 ID
  1037. adgroup_id: 目标广告 ID
  1038. max_landings: 最多尝试的 landing 数
  1039. max_materials_per_landing: 每条 landing 召回 top N 素材尝试
  1040. Returns:
  1041. 成功:dynamic_creative_id;穷尽:None(记 error log,主循环继续下一广告)
  1042. """
  1043. videos = fetch_landing_videos_for_account(account_id, page_size=max_landings * 2)
  1044. valid = [v for v in videos if _is_landing_candidate(v)][:max_landings]
  1045. logger.info(
  1046. "[try_create_one_creative] account=%d adgroup=%d 候选 landing=%d",
  1047. account_id, adgroup_id, len(valid),
  1048. )
  1049. attempts = []
  1050. for v in valid:
  1051. materials = recall_materials_for_video(v, final_top_n=max_materials_per_landing)
  1052. if not materials:
  1053. attempts.append(f" · landing={v.video_id} 召回 0 素材,跳过")
  1054. continue
  1055. for m in materials:
  1056. try:
  1057. cid = create_creative_for_ad(account_id, adgroup_id, v, m)
  1058. attempts.append(
  1059. f" · landing={v.video_id} material={m.material_id[:12]} → 成功 cid={cid}"
  1060. )
  1061. logger.info("[try_create_one_creative] 成功 cid=%s\n%s", cid, "\n".join(attempts))
  1062. return cid
  1063. except RuntimeError as e:
  1064. err = str(e)[:100]
  1065. attempts.append(f" · landing={v.video_id} material={m.material_id[:12]} → 失败 {err}")
  1066. continue
  1067. logger.error(
  1068. "[try_create_one_creative] account=%d adgroup=%d 穷尽所有 landing×material 仍失败:\n%s",
  1069. account_id, adgroup_id, "\n".join(attempts),
  1070. )
  1071. return None