execute_creation_once.py 26 KB

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