creative_creation.py 41 KB

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