execute_creation_once.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582
  1. """模块 B 创意搭建子系统 — 主循环入口(P0-A / P0-C / P0-E,2026-06-09)。
  2. 数据流(三阶段,先审后挂):
  3. Phase 1 — 准备:
  4. 扫账户 → 关联点过滤(读调控当日 latest_decisions 排除 pause)
  5. → find_ads_needing_creatives
  6. → 对每条广告 prepare_one_creative_for_ad(召回 + 上传图 + xcx/save + build body)
  7. → pending_records (含完整 Phase 3 用的 _request_body)
  8. → 写 outputs/data/creation_pending_{date}.json(追溯 + 独立 apply 用)
  9. Phase 2 — 审批(若 CREATION_APPROVAL_REQUIRED=True):
  10. run_approval_workflow 生成 20 列 xlsx → 上传飞书 sheet → 发链接 → 轮询读决策列
  11. actions {row_idx: approve/reject/hold}
  12. → 写回 records["action"]
  13. Phase 3 — 执行:
  14. 对 action=approve 的 → POST /dynamic_creatives/add
  15. → 写 outputs/data/creation_run_{date}.json
  16. → 发飞书"执行汇报"消息
  17. 开关 CREATION_APPROVAL_REQUIRED=False 时跳过 Phase 2,全 records 直接 approve。
  18. """
  19. import json
  20. import logging
  21. import sys
  22. import time
  23. from datetime import datetime, timezone
  24. from pathlib import Path
  25. _HERE = Path(__file__).parent
  26. sys.path.insert(0, str(_HERE.parent.parent))
  27. sys.path.insert(0, str(_HERE))
  28. from dotenv import load_dotenv # noqa: E402
  29. load_dotenv(_HERE / ".env")
  30. from config import ( # noqa: E402
  31. ADS_PER_ACCOUNT,
  32. CREATION_APPROVAL_REQUIRED,
  33. CREATION_APPROVAL_TIMEOUT_MINUTES,
  34. TARGET_CREATIVES_PER_AD,
  35. WHITELIST_ACCOUNTS,
  36. get_creation_account_ids,
  37. )
  38. # config import 副作用会改 sys.path(把 im-client / agent/tools/builtin 顶到前面),
  39. # 强占 sys.path[0],只影响本入口自己的 import resolution。
  40. while str(_HERE) in sys.path:
  41. sys.path.remove(str(_HERE))
  42. sys.path.insert(0, str(_HERE))
  43. from tools.ad_creation import ( # noqa: E402
  44. build_ad_request_body,
  45. compute_fingerprint,
  46. enumerate_new_ad_candidates,
  47. post_ad_with_prepared_body,
  48. )
  49. from tools.audience_grant import ensure_account_audience_grant # noqa: E402
  50. from tools.creative_creation import ( # noqa: E402
  51. find_ads_needing_creatives,
  52. load_excluded_ad_ids_from_adjustment,
  53. prepare_one_creative_for_ad,
  54. )
  55. from execute_creation_apply import ( # noqa: E402
  56. apply_pending_records,
  57. write_summary as write_apply_summary,
  58. _send_apply_summary_to_feishu,
  59. )
  60. logger = logging.getLogger("execute_creation_once")
  61. def _setup_logging() -> None:
  62. logging.basicConfig(
  63. level=logging.INFO,
  64. format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
  65. datefmt="%H:%M:%S",
  66. )
  67. for noisy in ("httpx", "httpcore", "config", "db.config"):
  68. logging.getLogger(noisy).setLevel(logging.WARNING)
  69. # SLS 上报(2026-06-11 接入,K8s pod 直发)— 配置缺失自动降级,不阻塞主链路
  70. try:
  71. from tools.sls_setup import attach_sls_handler
  72. attach_sls_handler()
  73. except Exception as e:
  74. logger.warning("[sls] 挂载异常(降级为本地 only):%s", e)
  75. def _fetch_existing_fingerprints_for_account(account_id: int) -> set[str]:
  76. """Task 27:拉账户下所有非 DELETED 广告 → 反推 fingerprint 集合(预校验唯一性)。
  77. 腾讯文档:NORMAL + SUSPEND 状态广告均占用唯一性槽位,DENIED 也算占用。
  78. list 接口默认返回所有非 DELETED 状态 ad,故无需显式 filter status。
  79. 若腾讯 list 接口某些字段不返回(field 实测差异),降级:跳过该条 + log 警告。
  80. 返回空集 → 上层退化为"不预校验",enumerate 仍能跑(原有行为)。
  81. """
  82. from tools.ad_api import _get
  83. fingerprints: set[str] = set()
  84. skipped = 0
  85. page = 1
  86. try:
  87. while True:
  88. r = _get("/adgroups/get", {
  89. "account_id": account_id, "page": page, "page_size": 100,
  90. "fields": [
  91. "adgroup_id", "configured_status", "site_set",
  92. "targeting", "scene_spec",
  93. ],
  94. })
  95. ads = (r.get("data") or {}).get("list") or []
  96. if not ads:
  97. break
  98. for ad in ads:
  99. tgt = ad.get("targeting") or {}
  100. sc = ad.get("scene_spec") or {}
  101. wp = sc.get("wechat_position") if sc else None
  102. site_set = ad.get("site_set") or []
  103. age = tgt.get("age") or []
  104. geo_regions = (tgt.get("geo_location") or {}).get("regions") or []
  105. custom_audience = tgt.get("custom_audience")
  106. if not site_set or not age:
  107. skipped += 1
  108. continue
  109. try:
  110. fp = compute_fingerprint(
  111. account_id=account_id,
  112. site_set=site_set,
  113. custom_audience=custom_audience,
  114. age=age,
  115. geo_regions=geo_regions,
  116. wechat_position=wp,
  117. )
  118. fingerprints.add(fp)
  119. except Exception as e:
  120. skipped += 1
  121. logger.debug(
  122. "[phase0] fingerprint 算失败 ad=%s: %s",
  123. ad.get("adgroup_id"), e,
  124. )
  125. if len(ads) < 100:
  126. break
  127. page += 1
  128. except Exception as e:
  129. logger.warning(
  130. "[phase0] account=%d 拉 fingerprint 集失败(降级为不预校验): %s",
  131. account_id, e,
  132. )
  133. return set()
  134. logger.info(
  135. "[phase0] 已存在 fingerprint=%d 个(skip 缺字段广告 %d 条)",
  136. len(fingerprints), skipped,
  137. )
  138. return fingerprints
  139. def phase0_create_ads(target_ads: int = ADS_PER_ACCOUNT) -> list[dict]:
  140. """Phase 0:对每账户检查广告数,不足则建到 target_ads 条(模块 A,2026-06-09 P1-G)。
  141. 流程:
  142. account → 查当前广告数(NORMAL+SUSPEND 算占用)→ 不足 target_ads →
  143. → 反查 fingerprint 集 → enumerate candidates(排除已有 fp)→
  144. → 飞书审批 → approve 项 → 真 POST /adgroups/add → 返回新建的 adgroup_id 列表
  145. Returns: 本次新建成功的广告 list[dict],每项含 {account_id, adgroup_id, adgroup_name, wechat_position}
  146. """
  147. from datetime import datetime, timezone
  148. from tools.ad_api import _get
  149. from tools.im_approval_ad_creation import run_ad_approval_workflow
  150. creation_accounts = get_creation_account_ids()
  151. if not creation_accounts:
  152. logger.error("[phase0] 待投放账户配置为空,退出")
  153. return []
  154. # Task 26:NORMAL + SUSPEND 都算占用唯一性槽位(腾讯文档:删除前历史广告占槽位)
  155. OCCUPIED_STATUSES = {"AD_STATUS_NORMAL", "AD_STATUS_SUSPEND"}
  156. pending_ad_records: list[dict] = []
  157. for account_id in creation_accounts:
  158. logger.info("=" * 60)
  159. logger.info("[phase0] 账户 %d 处理开始", account_id)
  160. try:
  161. grant_result = ensure_account_audience_grant(account_id)
  162. logger.info(
  163. "[phase0] 人群包校验完成: status=%s audience_id=%s audience=%r",
  164. grant_result.get("status"),
  165. grant_result.get("audience_id"),
  166. grant_result.get("audience_name"),
  167. )
  168. except Exception as e:
  169. logger.exception("[phase0] account=%d 人群包授权/验证失败,跳过:%s", account_id, e)
  170. continue
  171. # Task 26:查所有非 DELETED 广告,NORMAL+SUSPEND 都算占用槽位
  172. try:
  173. r = _get("/adgroups/get", {
  174. "account_id": account_id, "page": 1, "page_size": 100,
  175. "fields": ["adgroup_id", "configured_status"],
  176. })
  177. all_ads = (r.get("data") or {}).get("list") or []
  178. normal_n = sum(1 for a in all_ads if a.get("configured_status") == "AD_STATUS_NORMAL")
  179. suspend_n = sum(1 for a in all_ads if a.get("configured_status") == "AD_STATUS_SUSPEND")
  180. occupied = [a for a in all_ads if a.get("configured_status") in OCCUPIED_STATUSES]
  181. logger.info(
  182. "[phase0] 广告数: 总 %d,NORMAL %d + SUSPEND %d = 占用 %d / 目标 %d",
  183. len(all_ads), normal_n, suspend_n, len(occupied), target_ads,
  184. )
  185. except Exception as e:
  186. logger.exception("[phase0] account=%d 查广告数失败: %s", account_id, e)
  187. continue
  188. to_create = max(0, target_ads - len(occupied))
  189. if to_create == 0:
  190. logger.info("[phase0] 占用槽位已满,跳过")
  191. continue
  192. # Task 27:fingerprint 预校验 — 反查现存广告的 fp 集合,enumerate 时排除
  193. existing_fps = _fetch_existing_fingerprints_for_account(account_id)
  194. # enumerate 候选(模块 A,差异化 wechat_position 有/无)
  195. try:
  196. candidates = enumerate_new_ad_candidates(
  197. account_id, count=to_create,
  198. existing_fingerprints=existing_fps,
  199. )
  200. except Exception as e:
  201. logger.exception("[phase0] account=%d enumerate 失败: %s", account_id, e)
  202. continue
  203. if not candidates:
  204. logger.warning("[phase0] enumerate 返回 0 条候选,跳过")
  205. continue
  206. today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
  207. for c in candidates:
  208. age_range = ",".join(
  209. f"{a.get('min')}-{a.get('max')}" for a in c.age
  210. )
  211. # 2026-06-10 修复:Phase 0 经过飞书审批后,广告应立即 NORMAL 生效
  212. # build_ad_request_body 的 default SUSPEND 是模块 A dry-run 时代的旧 SOP
  213. body = build_ad_request_body(c, configured_status="AD_STATUS_NORMAL")
  214. rec = {
  215. "approval_date": today,
  216. "account_id": c.account_id,
  217. "audience_tier_label": c.audience_tier_label,
  218. "adgroup_name": c.adgroup_name,
  219. "site_set": c.site_set,
  220. "delivery_version": c.delivery_version,
  221. "wechat_position": c.wechat_position,
  222. "bid_amount_fen": c.bid_amount_fen,
  223. "age_range": age_range,
  224. "fingerprint": c.fingerprint,
  225. "_request_body": body,
  226. }
  227. pending_ad_records.append(rec)
  228. if not pending_ad_records:
  229. logger.info("[phase0] 无广告需新建,Phase 0 结束")
  230. return []
  231. # 飞书审批(如需要)
  232. data_dir = _HERE / "outputs" / "data"
  233. if CREATION_APPROVAL_REQUIRED:
  234. logger.info(
  235. "[phase0] CREATION_APPROVAL_REQUIRED=True → 飞书审批 %d 条广告候选",
  236. len(pending_ad_records),
  237. )
  238. sheet_meta, actions = run_ad_approval_workflow(
  239. records=pending_ad_records,
  240. xlsx_output_dir=data_dir,
  241. timeout_minutes=CREATION_APPROVAL_TIMEOUT_MINUTES,
  242. )
  243. for i, rec in enumerate(pending_ad_records, start=1):
  244. rec["action"] = actions.get(i, "skip")
  245. else:
  246. for rec in pending_ad_records:
  247. rec["action"] = "approve"
  248. # POST 真建 approve 项
  249. created: list[dict] = []
  250. for rec in pending_ad_records:
  251. if rec.get("action") != "approve":
  252. continue
  253. adgroup_id = post_ad_with_prepared_body(
  254. account_id=int(rec["account_id"]),
  255. body=rec["_request_body"],
  256. )
  257. if adgroup_id:
  258. created.append({
  259. "account_id": rec["account_id"],
  260. "adgroup_id": adgroup_id,
  261. "adgroup_name": rec["adgroup_name"],
  262. "wechat_position": rec.get("wechat_position"),
  263. })
  264. logger.info(
  265. "[phase0] 完成:候选 %d 条,approve %d 条,实际建 %d 条",
  266. len(pending_ad_records),
  267. sum(1 for r in pending_ad_records if r.get("action") == "approve"),
  268. len(created),
  269. )
  270. return created
  271. def _wait_created_ads_visible(created_ads: list[dict], timeout_seconds: int = 60) -> None:
  272. """等待刚创建的广告在 /adgroups/get 中可见。
  273. 腾讯 adgroups/add 成功后,list 接口存在短暂一致性延迟。若立刻进入 Phase 1,
  274. 可能只扫到部分新广告,导致同一轮只给一条广告补创意。
  275. """
  276. if not created_ads:
  277. return
  278. from tools.ad_api import _get
  279. by_account: dict[int, set[int]] = {}
  280. for ad in created_ads:
  281. by_account.setdefault(int(ad["account_id"]), set()).add(int(ad["adgroup_id"]))
  282. deadline = time.time() + timeout_seconds
  283. remaining = {acct: set(ids) for acct, ids in by_account.items()}
  284. while time.time() < deadline and any(remaining.values()):
  285. for account_id, ids in list(remaining.items()):
  286. if not ids:
  287. continue
  288. try:
  289. resp = _get("/adgroups/get", {
  290. "account_id": account_id,
  291. "page": 1,
  292. "page_size": 100,
  293. "fields": ["adgroup_id", "configured_status", "system_status"],
  294. })
  295. visible = {
  296. int(item["adgroup_id"])
  297. for item in ((resp.get("data") or {}).get("list") or [])
  298. if item.get("adgroup_id") is not None
  299. }
  300. ids.difference_update(visible)
  301. except Exception as e:
  302. logger.warning(
  303. "[phase0] 等待新广告可见失败 account=%d: %s",
  304. account_id, e,
  305. )
  306. if any(remaining.values()):
  307. logger.info("[phase0] 等待新广告在列表接口可见: %s", remaining)
  308. time.sleep(5)
  309. not_visible = {acct: sorted(ids) for acct, ids in remaining.items() if ids}
  310. if not_visible:
  311. logger.warning("[phase0] 部分新广告仍未在 list 可见,继续 Phase 1: %s", not_visible)
  312. else:
  313. logger.info("[phase0] 新建广告均已在 list 接口可见")
  314. def phase1_prepare(target_creatives: int = TARGET_CREATIVES_PER_AD) -> list[dict]:
  315. """Phase 1:扫账户 → 关联点过滤 → 准备 pending records(不 POST 腾讯)。
  316. Returns:
  317. pending_records — 每个元素是 prepare_one_creative_for_ad 返回的 dict
  318. """
  319. creation_accounts = get_creation_account_ids()
  320. if not creation_accounts:
  321. logger.error("[phase1] 待投放账户配置为空,Phase 1 退出")
  322. return []
  323. excluded_ad_ids = load_excluded_ad_ids_from_adjustment()
  324. logger.info("[phase1] 关联点过滤集合 size=%d", len(excluded_ad_ids))
  325. pending_records: list[dict] = []
  326. # 全局 used 集合(2026-06-10 P0-NEW-3 修复:跨账户 landing/material 也互斥)
  327. # 起因:实测 70073686 跨"泛人群"+"回流330以上人群"两个 crowd_package 池子重复,
  328. # account 级 set 漏 — 提到 run_once 一份。
  329. used_material_ids_global: set = set()
  330. used_landing_ids_global: set = set()
  331. for account_id in creation_accounts:
  332. logger.info("=" * 60)
  333. logger.info("[phase1] 账户 %d 处理开始", account_id)
  334. try:
  335. ads = find_ads_needing_creatives(account_id, min_creatives=target_creatives)
  336. except Exception as e:
  337. logger.exception("[phase1] account=%d find_ads 失败,跳过:%s", account_id, e)
  338. continue
  339. ads_after_filter = [a for a in ads if a["adgroup_id"] not in excluded_ad_ids]
  340. excluded_count = len(ads) - len(ads_after_filter)
  341. if excluded_count:
  342. logger.info(
  343. "[phase1] account=%d 关联点过滤排除 %d 条",
  344. account_id, excluded_count,
  345. )
  346. if not ads_after_filter:
  347. logger.info("[phase1] account=%d 无广告需补创意", account_id)
  348. continue
  349. for ad in ads_after_filter:
  350. adgroup_id = ad["adgroup_id"]
  351. already_have = ad["creative_count"]
  352. to_add = max(0, target_creatives - already_have)
  353. logger.info(
  354. "[phase1] adgroup=%d(have=%d need=%d)",
  355. adgroup_id, already_have, to_add,
  356. )
  357. for _ in range(to_add):
  358. try:
  359. rec = prepare_one_creative_for_ad(
  360. account_id, adgroup_id,
  361. excluded_material_ids=used_material_ids_global,
  362. excluded_landing_ids=used_landing_ids_global,
  363. )
  364. except Exception as e:
  365. logger.exception(
  366. "[phase1] adgroup=%d 准备失败:%s", adgroup_id, e,
  367. )
  368. rec = None
  369. if rec:
  370. pending_records.append(rec)
  371. used_material_ids_global.add(rec["_material_id"])
  372. used_landing_ids_global.add(rec["landing_video_id"])
  373. else:
  374. # 2026-06-10 用户要求:单条 prepare 失败 → continue 不 break
  375. # 同广告剩余 to_add 创意还能继续试,不被一次失败拖累
  376. logger.info(
  377. "[phase1] adgroup=%d 本条创意 prepare 失败,试下一条",
  378. adgroup_id,
  379. )
  380. logger.info("=" * 60)
  381. logger.info("[phase1] 准备完成,共 %d 条 pending records", len(pending_records))
  382. return pending_records
  383. def _write_pending_records(records: list[dict], output_dir: Path) -> Path:
  384. """落 Phase 1 产物 JSON(供 Phase 3 独立 apply 用 / 追溯)。"""
  385. output_dir.mkdir(parents=True, exist_ok=True)
  386. date_str = datetime.now(timezone.utc).strftime("%Y%m%d")
  387. ts = datetime.now(timezone.utc).strftime("%H%M%S")
  388. out_path = output_dir / f"creation_pending_{date_str}_{ts}.json"
  389. with open(out_path, "w", encoding="utf-8") as f:
  390. json.dump(records, f, ensure_ascii=False, indent=2)
  391. return out_path
  392. def phase2_approval(records: list[dict], xlsx_output_dir: Path) -> dict[int, str]:
  393. """Phase 2:飞书审批(若需要)。
  394. Returns: actions {row_idx: action}, 1-based row_idx
  395. """
  396. from tools.im_approval_creation import run_approval_workflow
  397. sheet_meta, actions = run_approval_workflow(
  398. records=records,
  399. xlsx_output_dir=xlsx_output_dir,
  400. timeout_minutes=CREATION_APPROVAL_TIMEOUT_MINUTES,
  401. )
  402. logger.info(
  403. "[phase2] 审批完成 sheet_url=%s actions=%d/%d",
  404. sheet_meta.get("url"), len(actions), len(records),
  405. )
  406. return actions
  407. def run_once() -> dict:
  408. """完整主循环:Phase 0 → Phase 1 → Phase 2(若开关 True)→ Phase 3。"""
  409. run_started = datetime.now(timezone.utc).isoformat()
  410. sync_stats: dict = {}
  411. # Phase -1:每日主流程启动时先同步飞书「自动化账户」配置表。
  412. logger.info("=" * 60)
  413. logger.info("[main] Phase -1 启动 — 同步飞书自动化账户配置")
  414. try:
  415. from sync_feishu_account_config import sync_from_feishu
  416. sync_stats = sync_from_feishu()
  417. logger.info("[main] Phase -1 完成: %s", sync_stats)
  418. except Exception as e:
  419. logger.exception("[main] Phase -1 同步飞书配置失败,本轮停止:%s", e)
  420. return {
  421. "run_started": run_started,
  422. "run_finished": datetime.now(timezone.utc).isoformat(),
  423. "approval_required": CREATION_APPROVAL_REQUIRED,
  424. "sync_stats": {"error": str(e)},
  425. "phase0_created_ads": 0,
  426. "total": {"phase1_prepared": 0},
  427. }
  428. # Phase 0:模块 A 建广告(满足每账户 ADS_PER_ACCOUNT 条)
  429. logger.info("=" * 60)
  430. logger.info("[main] Phase 0 启动 — 模块 A 检查 + 建广告")
  431. created_ads = phase0_create_ads()
  432. logger.info("[main] Phase 0 完成:本轮新建广告 %d 条", len(created_ads))
  433. _wait_created_ads_visible(created_ads)
  434. # Phase 1:模块 B 给所有广告(新+旧)补创意
  435. logger.info("=" * 60)
  436. logger.info("[main] Phase 1 启动 — 模块 B 给广告补创意")
  437. pending_records = phase1_prepare()
  438. if not pending_records:
  439. logger.info("[main] Phase 1 无 pending records,主循环退出")
  440. return {
  441. "run_started": run_started,
  442. "run_finished": datetime.now(timezone.utc).isoformat(),
  443. "approval_required": CREATION_APPROVAL_REQUIRED,
  444. "sync_stats": sync_stats,
  445. "phase0_created_ads": len(created_ads),
  446. "total": {"phase1_prepared": 0},
  447. }
  448. data_dir = _HERE / "outputs" / "data"
  449. pending_path = _write_pending_records(pending_records, data_dir)
  450. logger.info("[main] pending records 已写入: %s", pending_path)
  451. # Phase 2:审批(或 skip)
  452. if CREATION_APPROVAL_REQUIRED:
  453. logger.info("[main] CREATION_APPROVAL_REQUIRED=True → 进 Phase 2 飞书审批")
  454. actions = phase2_approval(pending_records, data_dir)
  455. for i, rec in enumerate(pending_records, start=1):
  456. rec["action"] = actions.get(i, "skip")
  457. else:
  458. logger.info("[main] CREATION_APPROVAL_REQUIRED=False → 全 records approve")
  459. for rec in pending_records:
  460. rec["action"] = "approve"
  461. # Phase 3:执行
  462. logger.info("=" * 60)
  463. logger.info("[main] Phase 3 执行启动")
  464. summary = apply_pending_records(pending_records)
  465. summary["approval_required"] = CREATION_APPROVAL_REQUIRED
  466. summary["pending_records_path"] = str(pending_path)
  467. summary_path = write_apply_summary(summary, data_dir)
  468. logger.info("[main] summary 已写入: %s", summary_path)
  469. # 发执行汇报
  470. _send_apply_summary_to_feishu(summary)
  471. summary["run_started"] = run_started
  472. summary["run_finished"] = datetime.now(timezone.utc).isoformat()
  473. summary["sync_stats"] = sync_stats
  474. summary["phase0_created_ads"] = len(created_ads)
  475. summary["phase0_ads"] = created_ads
  476. return summary
  477. def main() -> int:
  478. _setup_logging()
  479. logger.info("=" * 60)
  480. logger.info("模块 B 创意搭建子系统 — 主循环启动")
  481. logger.info("TARGET_CREATIVES_PER_AD = %d", TARGET_CREATIVES_PER_AD)
  482. logger.info("CREATION_APPROVAL_REQUIRED = %s", CREATION_APPROVAL_REQUIRED)
  483. logger.info("WHITELIST_ACCOUNTS = %s", WHITELIST_ACCOUNTS)
  484. logger.info("=" * 60)
  485. summary = run_once()
  486. t = summary.get("total") or {}
  487. logger.info("=" * 60)
  488. logger.info("[main] 主循环结束")
  489. if "phase1_prepared" in t:
  490. logger.info(" phase1_prepared = %d", t["phase1_prepared"])
  491. else:
  492. logger.info(" records = %d", t.get("records", 0))
  493. logger.info(" approved = %d", t.get("approved", 0))
  494. logger.info(" posted_ok = %d", t.get("posted_ok", 0))
  495. logger.info(" posted_failed = %d", t.get("posted_failed", 0))
  496. logger.info("=" * 60)
  497. return 0 if t.get("posted_failed", 0) == 0 else 1
  498. if __name__ == "__main__":
  499. sys.exit(main())