creative_creation.py 47 KB

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