ad_creation.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399
  1. """模块 A · 广告新建(基于 SOP 固定参数 + 唯一性枚举)
  2. 设计原则(参考 CLAUDE.md no-guessing-rule):
  3. - 不猜测字段值;所有 SOP 字段从 config.py 读
  4. - conversion_id **不传**:文档说可选,且不支持朋友圈,我们朋友圈版位有
  5. - 用 optimization_goal=PROMOTION_VIEW_KEY_PAGE 替代 conversion_id
  6. 本模块只生成 request body,不直接调腾讯 API;
  7. 真实执行由 execution_engine + ad_api.ad_create() 走。
  8. """
  9. import hashlib
  10. import json
  11. import logging
  12. from dataclasses import dataclass, field, asdict
  13. from datetime import datetime
  14. from typing import Optional
  15. # SOP 固定参数 + 业务配置全部从 config.py 取
  16. from config import (
  17. # 营销内容
  18. MARKETING_GOAL,
  19. MARKETING_SUB_GOAL,
  20. MARKETING_CARRIER_TYPE,
  21. MARKETING_TARGET_TYPE,
  22. MARKETING_ASSET_OUTER_SPEC,
  23. # 优化目标
  24. OPTIMIZATION_GOAL,
  25. # 出价 / 计费
  26. BID_MODE,
  27. SMART_BID_TYPE,
  28. BID_STRATEGY,
  29. AUTO_ACQUISITION_ENABLED,
  30. AUTO_ACQUISITION_BUDGET_FEN,
  31. AUTO_DERIVED_CREATIVE_ENABLED,
  32. AIM_SMART_TARGETING_ENABLED,
  33. AIM_SMART_SITE_ENABLED,
  34. # AIM 智能定向(2026-06-11 修正:write 接口字段是 smart_targeting_mode,值 SMART_TARGETING_MANUAL)
  35. SMART_TARGETING_MODE,
  36. # 版位定投场景(2026-06-09 新增,1 账户 2 广告差异化)
  37. WECHAT_POSITION_TARGETED_PRESET,
  38. ADS_PER_ACCOUNT,
  39. # 转化
  40. DEFAULT_CONVERSION_ID,
  41. # 搜索场景扩量 · 定向拓展开关
  42. SEARCH_EXPAND_TARGETING_SWITCH,
  43. # 定向 / 日期
  44. DEFAULT_END_DATE,
  45. # 账户创建配置(DB 单一真相源)
  46. get_account_creation_config,
  47. # 监测链接 / 反馈 ID
  48. get_account_feedback_id,
  49. )
  50. logger = logging.getLogger(__name__)
  51. # ═══════════════════════════════════════════
  52. # 候选数据结构
  53. # ═══════════════════════════════════════════
  54. @dataclass
  55. class AdCandidate:
  56. """一条新广告候选 — 唯一性枚举阶段产出,审批后才转 API request"""
  57. account_id: int
  58. adgroup_name: str
  59. site_set: list # ["SITE_SET_MOMENTS", ...]
  60. custom_audience: Optional[list] # [audience_id] 或 None(不传)
  61. bid_amount_fen: int # 出价(分)
  62. audience_tier_label: str # 用于 reason / 审批表展示
  63. age: list # [{"min":45,"max":66}]
  64. location_types: list # ["LIVE_IN"]
  65. region_ids: list # 地域 region_id
  66. daily_budget_fen: int
  67. time_series: str
  68. delivery_version: str
  69. fingerprint: str # 营销内容指纹(本地判重)
  70. # 版位定投场景 wechat_position(2026-06-09 1 账户 N 广告差异化机制)
  71. # None = 无定投(走 site_set 默认全场景)/ list[int] = 勾选具体场景 ID
  72. wechat_position: Optional[list] = None
  73. # ═══════════════════════════════════════════
  74. # 营销内容指纹 + 出价取值
  75. # ═══════════════════════════════════════════
  76. def compute_fingerprint(
  77. account_id: int,
  78. site_set: list,
  79. custom_audience: Optional[list],
  80. age: list,
  81. geo_regions: list,
  82. wechat_position: Optional[list] = None,
  83. ) -> str:
  84. """计算"营销内容指纹"用于本地唯一性预校验。
  85. 注意:这里只是本地预筛(避免无效 API 调用)。
  86. 腾讯实际判重还看 marketing_goal/carrier_type/asset/opt_goal/smart_bid_type/site_set
  87. 其中 marketing_goal 等 5 个对本业务都固定,所以 site_set + targeting + wechat_position 决定 unique。
  88. """
  89. payload = {
  90. "account_id": account_id,
  91. "site_set": sorted(site_set),
  92. "custom_audience": sorted(custom_audience) if custom_audience else None,
  93. "age": age,
  94. "geo_regions": sorted(geo_regions),
  95. "wechat_position": sorted(wechat_position) if wechat_position else None,
  96. }
  97. return hashlib.md5(
  98. json.dumps(payload, sort_keys=True, ensure_ascii=False).encode("utf-8")
  99. ).hexdigest()
  100. # ═══════════════════════════════════════════
  101. # 命名 / 摘要
  102. # ═══════════════════════════════════════════
  103. _SITE_SHORT_MAP = {
  104. "SITE_SET_MOMENTS": "MOMT",
  105. "SITE_SET_WECHAT": "WCHT",
  106. "SITE_SET_MINI_PROGRAM_WECHAT": "MINI",
  107. "SITE_SET_WECHAT_PLUGIN": "PLGN",
  108. "SITE_SET_SEARCH_SCENE": "SRCH",
  109. }
  110. def _add_years(date_str: str, years: int) -> str:
  111. """date_str 是 YYYY-MM-DD,返回 N 年后的同一天(不处理 2/29 等边界,默认无 leap day 输入)"""
  112. d = datetime.strptime(date_str, "%Y-%m-%d")
  113. return d.replace(year=d.year + years).strftime("%Y-%m-%d")
  114. # tier 名 → 中文短名(命名用)
  115. _TIER_NAME_MAP = {
  116. "no_audience_pack": "泛人群",
  117. }
  118. # 转化目标 → 中文短名(命名用)
  119. _OPT_GOAL_NAME_MAP = {
  120. "OPTIMIZATIONGOAL_PROMOTION_VIEW_KEY_PAGE": "关键页面",
  121. "OPTIMIZATIONGOAL_CLICK": "点击",
  122. "OPTIMIZATIONGOAL_PAGE_VIEW": "页面浏览",
  123. }
  124. def build_adgroup_name(
  125. account_id: int,
  126. site_set: list,
  127. audience_tier: str,
  128. seq: int,
  129. ) -> str:
  130. """命名规则(用户 2026-06-05 确认):{人群包}-{日期}-{转化目标}
  131. 例:
  132. 83846793(no_audience_pack) → 泛人群-20260605-关键页面
  133. 83846804(R330+) → R330+-20260605-关键页面
  134. """
  135. date_str = datetime.now().strftime("%Y%m%d")
  136. tier_name = _TIER_NAME_MAP.get(audience_tier, audience_tier)
  137. opt_name = _OPT_GOAL_NAME_MAP.get(OPTIMIZATION_GOAL, OPTIMIZATION_GOAL)
  138. return f"{tier_name}-{date_str}-{opt_name}"
  139. # ═══════════════════════════════════════════
  140. # 候选枚举
  141. # ═══════════════════════════════════════════
  142. def enumerate_new_ad_candidates(
  143. account_id: int,
  144. count: int = 3,
  145. existing_fingerprints: Optional[set] = None,
  146. ) -> list[AdCandidate]:
  147. """一账一包 + 固定定向 模式下,枚举 N 条 unique 候选广告。
  148. 差异化维度:site_set 组合(本业务有 3 种)。
  149. 其他维度(audience pack / age / geo / 优化目标 / 出价类型)都固定。
  150. Args:
  151. account_id: 广告账户 ID(从 DB account_whitelist 取 pack)
  152. count: 期望产出条数(上限 = 可用 site_set 组合数 - 已存在指纹)
  153. existing_fingerprints: 已存在的指纹 set,用于跳过
  154. Returns:
  155. list[AdCandidate]
  156. """
  157. existing_fingerprints = existing_fingerprints or set()
  158. cfg = get_account_creation_config(account_id)
  159. pack_id = cfg["audience_pack_id"]
  160. tier_label = cfg["audience_tier_label"]
  161. custom_audience = [pack_id] if pack_id else None
  162. bid_amount_fen = cfg["bid_amount_fen"]
  163. # 差异化策略(2026-06-09 用户确认 + 2026-06-11 缩减):
  164. # 广告 #1 wechat_position=None (无定投,走 site_set 默认全场景)
  165. # 广告 #2 wechat_position=WECHAT_POSITION_TARGETED_PRESET(本期 4 项小程序版位)
  166. # 同 site_set 同 targeting 但 wechat_position 不同 → 腾讯唯一性绕过(参考 77868332 实测)
  167. wechat_position_variants = [None, WECHAT_POSITION_TARGETED_PRESET]
  168. candidates: list[AdCandidate] = []
  169. site_set = cfg["site_set"]
  170. for seq, wp in enumerate(wechat_position_variants, start=1):
  171. if len(candidates) >= count:
  172. break
  173. fingerprint = compute_fingerprint(
  174. account_id=account_id,
  175. site_set=site_set,
  176. custom_audience=custom_audience,
  177. age=cfg["age"],
  178. geo_regions=cfg["region_ids"],
  179. wechat_position=wp,
  180. )
  181. if fingerprint in existing_fingerprints:
  182. logger.info(
  183. "[enumerate] skip: fingerprint %s 已存在 (account=%d wp=%s)",
  184. fingerprint[:8], account_id, "targeted" if wp else "default",
  185. )
  186. continue
  187. wp_label = "targeted" if wp else "default"
  188. adgroup_name = build_adgroup_name(account_id, site_set, tier_label, seq)
  189. # 加 wp 后缀,让两条广告名可识别
  190. adgroup_name = f"{adgroup_name}-{wp_label}"
  191. candidates.append(
  192. AdCandidate(
  193. account_id=account_id,
  194. adgroup_name=adgroup_name,
  195. site_set=site_set,
  196. custom_audience=custom_audience,
  197. bid_amount_fen=bid_amount_fen,
  198. audience_tier_label=tier_label,
  199. age=cfg["age"],
  200. location_types=cfg["location_types"],
  201. region_ids=cfg["region_ids"],
  202. daily_budget_fen=cfg["daily_budget_fen"],
  203. time_series=cfg["time_series"],
  204. delivery_version=cfg["delivery_version"],
  205. fingerprint=fingerprint,
  206. wechat_position=wp,
  207. )
  208. )
  209. logger.info(
  210. "[enumerate] account=%d 产出 %d 条候选(目标 %d 条) — 差异化:wechat_position 有/无",
  211. account_id, len(candidates), count,
  212. )
  213. return candidates
  214. # ═══════════════════════════════════════════
  215. # 构造腾讯 adgroups/add 请求 body
  216. # ═══════════════════════════════════════════
  217. def build_ad_request_body(
  218. candidate: AdCandidate,
  219. begin_date: Optional[str] = None,
  220. configured_status: str = "AD_STATUS_SUSPEND",
  221. ) -> dict:
  222. """生成 /v3.0/adgroups/add 的完整请求 body。
  223. 关键点:
  224. - configured_status 默认 SUSPEND(创建后默认暂停,运营 review 后启用)
  225. - 不传 conversion_id(可选 + 不支持朋友圈,我们有朋友圈版位)
  226. - 不传顶层 marketing_target_type,只通过 marketing_asset_outer_spec 传
  227. (待 dry run 验证;若腾讯要求,加上)
  228. - gender 不传 = 不限性别
  229. """
  230. begin_date = begin_date or datetime.now().strftime("%Y-%m-%d")
  231. # 定向 — 固定地域 + 固定年龄 + 可选人群包
  232. targeting: dict = {
  233. "geo_location": {
  234. "location_types": candidate.location_types,
  235. "regions": candidate.region_ids,
  236. },
  237. "age": candidate.age,
  238. }
  239. if candidate.custom_audience:
  240. targeting["custom_audience"] = candidate.custom_audience
  241. # 监测链接 ID(账户级)— 不能传 None
  242. feedback_id = get_account_feedback_id(candidate.account_id)
  243. if feedback_id is None:
  244. raise ValueError(
  245. f"account_id {candidate.account_id} 的 feedback_id 未配置。"
  246. f"请在 config.ACCOUNT_FEEDBACK_ID_MAPPING 中补充,或等运营提供。"
  247. )
  248. body: dict = {
  249. # === 基础 ===
  250. "account_id": candidate.account_id,
  251. "adgroup_name": candidate.adgroup_name,
  252. "configured_status": configured_status,
  253. "feedback_id": feedback_id,
  254. # === 营销内容 ===
  255. "marketing_goal": MARKETING_GOAL,
  256. "marketing_sub_goal": MARKETING_SUB_GOAL,
  257. "marketing_carrier_type": MARKETING_CARRIER_TYPE,
  258. "marketing_target_type": MARKETING_TARGET_TYPE, # 小程序投流必传(用户 2026-06-05 确认)
  259. "marketing_asset_outer_spec": MARKETING_ASSET_OUTER_SPEC,
  260. # === 优化目标 + 转化 ===
  261. # conversion_id 关联完整"平台转化"包(含优化目标 + 数据上报 + 归因方式)
  262. # 用户 2026-06-05 确认:两个测试账户都用 1007(样本一致)
  263. "optimization_goal": OPTIMIZATION_GOAL,
  264. "conversion_id": DEFAULT_CONVERSION_ID,
  265. # === 出价 / 计费(SOP 稳定拿量)===
  266. "bid_mode": BID_MODE,
  267. "smart_bid_type": SMART_BID_TYPE,
  268. "bid_strategy": BID_STRATEGY,
  269. "bid_amount": candidate.bid_amount_fen,
  270. "daily_budget": candidate.daily_budget_fen,
  271. "auto_acquisition_enabled": AUTO_ACQUISITION_ENABLED,
  272. "auto_derived_creative_enabled": AUTO_DERIVED_CREATIVE_ENABLED,
  273. # === 时段 / 日期 ===
  274. # end_date 必填,且不接受 "0"。默认设 begin_date + 1 年表示长期
  275. "begin_date": begin_date,
  276. "end_date": _add_years(begin_date, 1),
  277. "time_series": candidate.time_series,
  278. # === 版位 ===
  279. "automatic_site_enabled": False,
  280. "site_set": candidate.site_set,
  281. # 搜索场景扩量 · 定向拓展(用户 2026-06-05 确认:关)
  282. "search_expand_targeting_switch": SEARCH_EXPAND_TARGETING_SWITCH,
  283. # === 定向 ===
  284. # AIM 智能定向(2026-06-11 实测修正:字段名是 smart_targeting_mode 不是 status)
  285. # 之前用错字段名 smart_targeting_status,腾讯静默忽略,创建出来全 AUTO
  286. # 正确写法:smart_targeting_mode=SMART_TARGETING_MANUAL(手动定向 = AIM 关闭)
  287. "smart_targeting_mode": SMART_TARGETING_MODE,
  288. "targeting": targeting,
  289. }
  290. # 版位定投场景 wechat_position(2026-06-10 实测修正:POST 时 scene_spec 是顶层字段)
  291. # 之前误把 scene_spec 放 targeting 内 → 腾讯 reject code 12813 "包含不识别的参数 scene_spec"
  292. # WebFetch /adgroups/add 文档确认:scene_spec 是顶层 struct,wechat_position 是它的子字段
  293. if candidate.wechat_position:
  294. body["scene_spec"] = {"wechat_position": list(candidate.wechat_position)}
  295. if AUTO_ACQUISITION_ENABLED and AUTO_ACQUISITION_BUDGET_FEN:
  296. body["auto_acquisition_budget"] = AUTO_ACQUISITION_BUDGET_FEN
  297. return body
  298. # ═══════════════════════════════════════════
  299. # 调试 / 演示入口(用于"先打印 body,再决定是否真调")
  300. # ═══════════════════════════════════════════
  301. def post_ad_with_prepared_body(account_id: int, body: dict) -> Optional[int]:
  302. """Phase 0 POST:用 build_ad_request_body 构造的 body 调腾讯 /adgroups/add。
  303. 对偶 creative_creation.post_creative_with_prepared_body,Phase 0 模块 A 用。
  304. Returns:
  305. 成功:adgroup_id;失败:None(记 error log)
  306. """
  307. from tools.ad_api import _check, _post
  308. try:
  309. logger.info(
  310. "[post_ad] account=%d adgroup_name=%s site_set=%s wp=%s",
  311. account_id, body.get("adgroup_name"),
  312. body.get("site_set"),
  313. "targeted" if (body.get("targeting") or {}).get("scene_spec") else "default",
  314. )
  315. resp = _post("/adgroups/add", body)
  316. data = _check(resp, "ad_create")
  317. adgroup_id = data.get("adgroup_id")
  318. if adgroup_id:
  319. logger.info("[post_ad] 成功 adgroup_id=%s", adgroup_id)
  320. return int(adgroup_id)
  321. logger.error("[post_ad] 返回 data 缺 adgroup_id: %s", data)
  322. return None
  323. except RuntimeError as e:
  324. logger.error(
  325. "[post_ad] account=%d 失败: %s",
  326. account_id, str(e)[:200],
  327. )
  328. return None
  329. def preview_candidates(account_id: int, count: int = 3) -> dict:
  330. """生成候选 + 完整 request body,只打印不调 API。
  331. 用法:在 REPL / 脚本里调用,看 body 后再决定是否真 dry run。
  332. """
  333. candidates = enumerate_new_ad_candidates(account_id, count=count)
  334. bodies = [build_ad_request_body(c) for c in candidates]
  335. return {
  336. "account_id": account_id,
  337. "candidate_count": len(candidates),
  338. "candidates": [asdict(c) for c in candidates],
  339. "request_bodies": bodies,
  340. }