ad_creation.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364
  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. # 转化
  35. DEFAULT_CONVERSION_ID,
  36. # 搜索场景扩量 · 定向拓展开关
  37. SEARCH_EXPAND_TARGETING_SWITCH,
  38. # 版位
  39. AVAILABLE_SITE_SETS,
  40. SITE_SET_COMBINATIONS,
  41. # 定向
  42. FIXED_TARGETING_AGE,
  43. FIXED_TARGETING_GENDER,
  44. FIXED_TARGETING_LOCATION_TYPES,
  45. FIXED_TARGETING_REGION_IDS,
  46. # 时段 / 日期 / 预算
  47. TIME_SERIES_DEFAULT,
  48. DEFAULT_END_DATE,
  49. DEFAULT_DAILY_BUDGET_FEN,
  50. # 一账一包
  51. ACCOUNT_AUDIENCE_PACK_MAPPING,
  52. # 监测链接 / 反馈 ID
  53. get_account_feedback_id,
  54. # 出价区间策略
  55. AUDIENCE_BID_RANGES,
  56. BID_PICK_STRATEGY,
  57. )
  58. logger = logging.getLogger(__name__)
  59. # ═══════════════════════════════════════════
  60. # 候选数据结构
  61. # ═══════════════════════════════════════════
  62. @dataclass
  63. class AdCandidate:
  64. """一条新广告候选 — 唯一性枚举阶段产出,审批后才转 API request"""
  65. account_id: int
  66. adgroup_name: str
  67. site_set: list # ["SITE_SET_MOMENTS", ...]
  68. custom_audience: Optional[list] # [audience_id] 或 None(不传)
  69. bid_amount_fen: int # 出价(分)
  70. audience_tier_label: str # 用于 reason / 审批表展示
  71. fingerprint: str # 营销内容指纹(本地判重)
  72. # ═══════════════════════════════════════════
  73. # 营销内容指纹 + 出价取值
  74. # ═══════════════════════════════════════════
  75. def compute_fingerprint(
  76. account_id: int,
  77. site_set: list,
  78. custom_audience: Optional[list],
  79. age: list,
  80. geo_regions: list,
  81. ) -> str:
  82. """计算"营销内容指纹"用于本地唯一性预校验。
  83. 注意:这里只是本地预筛(避免无效 API 调用)。
  84. 腾讯实际判重还看 marketing_goal/carrier_type/asset/opt_goal/smart_bid_type/site_set
  85. 其中 marketing_goal 等 5 个对本业务都固定,所以 site_set + targeting 决定 unique。
  86. """
  87. payload = {
  88. "account_id": account_id,
  89. "site_set": sorted(site_set),
  90. "custom_audience": sorted(custom_audience) if custom_audience else None,
  91. "age": age,
  92. "geo_regions": sorted(geo_regions),
  93. }
  94. return hashlib.md5(
  95. json.dumps(payload, sort_keys=True, ensure_ascii=False).encode("utf-8")
  96. ).hexdigest()
  97. def pick_bid_amount_fen(tier_label: str, strategy: Optional[str] = None) -> int:
  98. """从 SOP 出价区间内取一个具体出价(单位:分)。"""
  99. strategy = strategy or BID_PICK_STRATEGY
  100. bid_range = AUDIENCE_BID_RANGES.get(tier_label)
  101. if not bid_range:
  102. raise ValueError(
  103. f"tier_label '{tier_label}' 不在 AUDIENCE_BID_RANGES 中。"
  104. f"可用 keys: {list(AUDIENCE_BID_RANGES.keys())}"
  105. )
  106. min_yuan, max_yuan = bid_range
  107. if strategy == "midpoint":
  108. yuan = (min_yuan + max_yuan) / 2
  109. elif strategy == "max":
  110. yuan = max_yuan
  111. elif strategy == "min":
  112. yuan = min_yuan
  113. else:
  114. raise ValueError(
  115. f"未知出价策略 '{strategy}',支持:midpoint / max / min"
  116. )
  117. return int(round(yuan * 100)) # 元 → 分
  118. # ═══════════════════════════════════════════
  119. # 命名 / 摘要
  120. # ═══════════════════════════════════════════
  121. _SITE_SHORT_MAP = {
  122. "SITE_SET_MOMENTS": "MOMT",
  123. "SITE_SET_WECHAT": "WCHT",
  124. "SITE_SET_MINI_PROGRAM_WECHAT": "MINI",
  125. "SITE_SET_WECHAT_PLUGIN": "PLGN",
  126. "SITE_SET_SEARCH_SCENE": "SRCH",
  127. }
  128. def _add_years(date_str: str, years: int) -> str:
  129. """date_str 是 YYYY-MM-DD,返回 N 年后的同一天(不处理 2/29 等边界,默认无 leap day 输入)"""
  130. d = datetime.strptime(date_str, "%Y-%m-%d")
  131. return d.replace(year=d.year + years).strftime("%Y-%m-%d")
  132. # tier 名 → 中文短名(命名用)
  133. _TIER_NAME_MAP = {
  134. "no_audience_pack": "泛人群",
  135. }
  136. # 转化目标 → 中文短名(命名用)
  137. _OPT_GOAL_NAME_MAP = {
  138. "OPTIMIZATIONGOAL_PROMOTION_VIEW_KEY_PAGE": "关键页面",
  139. "OPTIMIZATIONGOAL_CLICK": "点击",
  140. "OPTIMIZATIONGOAL_PAGE_VIEW": "页面浏览",
  141. }
  142. def build_adgroup_name(
  143. account_id: int,
  144. site_set: list,
  145. audience_tier: str,
  146. seq: int,
  147. ) -> str:
  148. """命名规则(用户 2026-06-05 确认):{人群包}-{日期}-{转化目标}
  149. 例:
  150. 83846793(no_audience_pack) → 泛人群-20260605-关键页面
  151. 83846804(R330+) → R330+-20260605-关键页面
  152. """
  153. date_str = datetime.now().strftime("%Y%m%d")
  154. tier_name = _TIER_NAME_MAP.get(audience_tier, audience_tier)
  155. opt_name = _OPT_GOAL_NAME_MAP.get(OPTIMIZATION_GOAL, OPTIMIZATION_GOAL)
  156. return f"{tier_name}-{date_str}-{opt_name}"
  157. # ═══════════════════════════════════════════
  158. # 候选枚举
  159. # ═══════════════════════════════════════════
  160. def enumerate_new_ad_candidates(
  161. account_id: int,
  162. count: int = 3,
  163. existing_fingerprints: Optional[set] = None,
  164. ) -> list[AdCandidate]:
  165. """一账一包 + 固定定向 模式下,枚举 N 条 unique 候选广告。
  166. 差异化维度:site_set 组合(本业务有 3 种)。
  167. 其他维度(audience pack / age / geo / 优化目标 / 出价类型)都固定。
  168. Args:
  169. account_id: 广告账户 ID(从 ACCOUNT_AUDIENCE_PACK_MAPPING 取 pack)
  170. count: 期望产出条数(上限 = 可用 site_set 组合数 - 已存在指纹)
  171. existing_fingerprints: 已存在的指纹 set,用于跳过
  172. Returns:
  173. list[AdCandidate]
  174. """
  175. existing_fingerprints = existing_fingerprints or set()
  176. if account_id not in ACCOUNT_AUDIENCE_PACK_MAPPING:
  177. raise ValueError(
  178. f"account_id {account_id} 不在 ACCOUNT_AUDIENCE_PACK_MAPPING 中。"
  179. f"请先在 config.py 配置该账户的 audience pack。"
  180. )
  181. pack_id, tier_label = ACCOUNT_AUDIENCE_PACK_MAPPING[account_id]
  182. custom_audience = [pack_id] if pack_id else None
  183. bid_amount_fen = pick_bid_amount_fen(tier_label)
  184. candidates: list[AdCandidate] = []
  185. for seq, site_set in enumerate(SITE_SET_COMBINATIONS, start=1):
  186. if len(candidates) >= count:
  187. break
  188. fingerprint = compute_fingerprint(
  189. account_id=account_id,
  190. site_set=site_set,
  191. custom_audience=custom_audience,
  192. age=FIXED_TARGETING_AGE,
  193. geo_regions=FIXED_TARGETING_REGION_IDS,
  194. )
  195. if fingerprint in existing_fingerprints:
  196. logger.info(
  197. "[enumerate] skip: fingerprint %s 已存在 (account=%d site_set=%s)",
  198. fingerprint[:8], account_id, site_set,
  199. )
  200. continue
  201. adgroup_name = build_adgroup_name(account_id, site_set, tier_label, seq)
  202. candidates.append(
  203. AdCandidate(
  204. account_id=account_id,
  205. adgroup_name=adgroup_name,
  206. site_set=site_set,
  207. custom_audience=custom_audience,
  208. bid_amount_fen=bid_amount_fen,
  209. audience_tier_label=tier_label,
  210. fingerprint=fingerprint,
  211. )
  212. )
  213. logger.info(
  214. "[enumerate] account=%d 产出 %d 条候选(目标 %d 条)",
  215. account_id, len(candidates), count,
  216. )
  217. return candidates
  218. # ═══════════════════════════════════════════
  219. # 构造腾讯 adgroups/add 请求 body
  220. # ═══════════════════════════════════════════
  221. def build_ad_request_body(
  222. candidate: AdCandidate,
  223. begin_date: Optional[str] = None,
  224. configured_status: str = "AD_STATUS_SUSPEND",
  225. ) -> dict:
  226. """生成 /v3.0/adgroups/add 的完整请求 body。
  227. 关键点:
  228. - configured_status 默认 SUSPEND(创建后默认暂停,运营 review 后启用)
  229. - 不传 conversion_id(可选 + 不支持朋友圈,我们有朋友圈版位)
  230. - 不传顶层 marketing_target_type,只通过 marketing_asset_outer_spec 传
  231. (待 dry run 验证;若腾讯要求,加上)
  232. - gender 不传 = 不限性别
  233. """
  234. begin_date = begin_date or datetime.now().strftime("%Y-%m-%d")
  235. # 定向 — 固定地域 + 固定年龄 + 可选人群包
  236. targeting: dict = {
  237. "geo_location": {
  238. "location_types": FIXED_TARGETING_LOCATION_TYPES,
  239. "regions": FIXED_TARGETING_REGION_IDS,
  240. },
  241. "age": FIXED_TARGETING_AGE,
  242. }
  243. if candidate.custom_audience:
  244. targeting["custom_audience"] = candidate.custom_audience
  245. # 监测链接 ID(账户级)— 不能传 None
  246. feedback_id = get_account_feedback_id(candidate.account_id)
  247. if feedback_id is None:
  248. raise ValueError(
  249. f"account_id {candidate.account_id} 的 feedback_id 未配置。"
  250. f"请在 config.ACCOUNT_FEEDBACK_ID_MAPPING 中补充,或等运营提供。"
  251. )
  252. body: dict = {
  253. # === 基础 ===
  254. "account_id": candidate.account_id,
  255. "adgroup_name": candidate.adgroup_name,
  256. "configured_status": configured_status,
  257. "feedback_id": feedback_id,
  258. # === 营销内容 ===
  259. "marketing_goal": MARKETING_GOAL,
  260. "marketing_sub_goal": MARKETING_SUB_GOAL,
  261. "marketing_carrier_type": MARKETING_CARRIER_TYPE,
  262. "marketing_target_type": MARKETING_TARGET_TYPE, # 小程序投流必传(用户 2026-06-05 确认)
  263. "marketing_asset_outer_spec": MARKETING_ASSET_OUTER_SPEC,
  264. # === 优化目标 + 转化 ===
  265. # conversion_id 关联完整"平台转化"包(含优化目标 + 数据上报 + 归因方式)
  266. # 用户 2026-06-05 确认:两个测试账户都用 1007(样本一致)
  267. "optimization_goal": OPTIMIZATION_GOAL,
  268. "conversion_id": DEFAULT_CONVERSION_ID,
  269. # === 出价 / 计费(SOP 稳定拿量)===
  270. "bid_mode": BID_MODE,
  271. "smart_bid_type": SMART_BID_TYPE,
  272. "bid_strategy": BID_STRATEGY,
  273. "bid_amount": candidate.bid_amount_fen,
  274. "daily_budget": DEFAULT_DAILY_BUDGET_FEN,
  275. "auto_acquisition_enabled": AUTO_ACQUISITION_ENABLED,
  276. "auto_derived_creative_enabled": AUTO_DERIVED_CREATIVE_ENABLED,
  277. # === 时段 / 日期 ===
  278. # end_date 必填,且不接受 "0"。默认设 begin_date + 1 年表示长期
  279. "begin_date": begin_date,
  280. "end_date": _add_years(begin_date, 1),
  281. "time_series": TIME_SERIES_DEFAULT,
  282. # === 版位 ===
  283. "automatic_site_enabled": False,
  284. "site_set": candidate.site_set,
  285. # 搜索场景扩量 · 定向拓展(用户 2026-06-05 确认:关)
  286. "search_expand_targeting_switch": SEARCH_EXPAND_TARGETING_SWITCH,
  287. # === 定向 ===
  288. "targeting": targeting,
  289. }
  290. if AUTO_ACQUISITION_ENABLED and AUTO_ACQUISITION_BUDGET_FEN:
  291. body["auto_acquisition_budget"] = AUTO_ACQUISITION_BUDGET_FEN
  292. return body
  293. # ═══════════════════════════════════════════
  294. # 调试 / 演示入口(用于"先打印 body,再决定是否真调")
  295. # ═══════════════════════════════════════════
  296. def preview_candidates(account_id: int, count: int = 3) -> dict:
  297. """生成候选 + 完整 request body,只打印不调 API。
  298. 用法:在 REPL / 脚本里调用,看 body 后再决定是否真 dry run。
  299. """
  300. candidates = enumerate_new_ad_candidates(account_id, count=count)
  301. bodies = [build_ad_request_body(c) for c in candidates]
  302. return {
  303. "account_id": account_id,
  304. "candidate_count": len(candidates),
  305. "candidates": [asdict(c) for c in candidates],
  306. "request_bodies": bodies,
  307. }