execute_creation_apply.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415
  1. """模块 B Phase 3 执行入口(P0-A 配套,2026-06-09)。
  2. 数据流:
  3. Phase 1 已准备好的 pending records(含 _request_body)+ 运营决策(action)
  4. → 对 action=approve 的 → POST 腾讯 /dynamic_creatives/add
  5. → 写 creation_run_{date}.json + 发"执行汇报"飞书消息
  6. 可独立运行(从 outputs/data/creation_pending_*.json 读 → 审批已写 → apply):
  7. python execute_creation_apply.py <pending_records_json>
  8. 也可被 execute_creation_once.py import 调用:
  9. apply_pending_records(records) -> summary
  10. """
  11. import asyncio
  12. import argparse
  13. import json
  14. import logging
  15. import os
  16. import sys
  17. import time
  18. from pathlib import Path
  19. _HERE = Path(__file__).parent
  20. sys.path.insert(0, str(_HERE.parent.parent))
  21. sys.path.insert(0, str(_HERE))
  22. from dotenv import load_dotenv # noqa: E402
  23. load_dotenv(_HERE / ".env")
  24. from config import ( # noqa: E402
  25. CREATION_APPROVAL_TIMEOUT_MINUTES,
  26. FEISHU_CREATION_CHAT_ID,
  27. now_in_timezone,
  28. )
  29. # 强占 sys.path[0],绕过 config import 副作用(im-client/tools.py 同名冲突)
  30. while str(_HERE) in sys.path:
  31. sys.path.remove(str(_HERE))
  32. sys.path.insert(0, str(_HERE))
  33. from tools.creative_creation import post_creative_with_prepared_body # noqa: E402
  34. from tools.ad_api import images_add # noqa: E402
  35. from tools.ai_generated_material import update_generated_material_status # noqa: E402
  36. from tools.external_recalled_material import update_external_material_status # noqa: E402
  37. from tools.creative_review import ( # noqa: E402
  38. mark_creation_submit_failed,
  39. record_creation_submission,
  40. )
  41. from tools.creative_material_usage import update_material_usage_status # noqa: E402
  42. logger = logging.getLogger("execute_creation_apply")
  43. def _update_deferred_material_status(record: dict, status: str, **kwargs) -> None:
  44. update_generated_material_status(record, status, **kwargs)
  45. update_external_material_status(record, status, **kwargs)
  46. def _fmt_quality_summary(r: dict) -> str:
  47. """Task 29:把 record 里的素材质量字段(prepare 阶段写入)格式化成一行。
  48. 返回类似 ' · 素材质量: ctr=12.5% imp=8,500 cost=420元',字段缺失时省略对应项。
  49. """
  50. ctr = r.get("material_ctr")
  51. imp = r.get("material_impressions")
  52. cost = r.get("material_cost")
  53. parts = []
  54. if ctr is not None:
  55. # ctr 取决于服务端格式,有的返回 0.125(比例),有的 12.5(百分号字面值)
  56. try:
  57. ctr_f = float(ctr)
  58. parts.append(f"ctr={ctr_f * 100:.1f}%" if ctr_f <= 1 else f"ctr={ctr_f:.1f}%")
  59. except (TypeError, ValueError):
  60. pass
  61. if imp is not None:
  62. try:
  63. parts.append(f"imp={int(imp):,}")
  64. except (TypeError, ValueError):
  65. pass
  66. if cost is not None:
  67. try:
  68. parts.append(f"cost={float(cost):.0f}元")
  69. except (TypeError, ValueError):
  70. pass
  71. return f" · 素材质量: {' '.join(parts)}" if parts else ""
  72. def _send_apply_summary_to_feishu(
  73. summary: dict, chat_id: str = ""
  74. ) -> None:
  75. """Phase 3 完成后发"执行汇报"飞书纯文本消息(创意 ID 列表 + 素材质量摘要)。
  76. Task 29(2026-06-11):每行加 ctr / cost / impressions 三个字段,
  77. 让运营在不打开 Excel 的情况下,一眼看出本次挂上的创意素材质量。
  78. """
  79. if not chat_id:
  80. chat_id = FEISHU_CREATION_CHAT_ID
  81. if not chat_id:
  82. logger.warning("[apply] FEISHU_CREATION_CHAT_ID 未配置,不发执行汇报")
  83. return
  84. try:
  85. from tools.feishu_doc import _auth_headers, _get_tenant_token
  86. import httpx
  87. t = summary["total"]
  88. lines = [
  89. "【创意搭建·执行汇报】",
  90. f"运营 approve 共 {t['approved']} 条,实际挂上 {t['posted_ok']} 条,失败 {t['posted_failed']} 条",
  91. ]
  92. approved_list = [r for r in summary["records"] if r.get("action") == "approve"]
  93. if approved_list:
  94. # Task 29:算挂上创意的素材质量均值,放汇报头(运营快速判断本批整体质量)
  95. ok_records = [r for r in approved_list if r.get("dynamic_creative_id")]
  96. valid_ctr = [float(r["material_ctr"]) for r in ok_records
  97. if r.get("material_ctr") is not None]
  98. valid_imp = [int(r["material_impressions"]) for r in ok_records
  99. if r.get("material_impressions") is not None]
  100. valid_cost = [float(r["material_cost"]) for r in ok_records
  101. if r.get("material_cost") is not None]
  102. if valid_ctr or valid_imp or valid_cost:
  103. stat_parts = []
  104. if valid_ctr:
  105. avg_ctr = sum(valid_ctr) / len(valid_ctr)
  106. stat_parts.append(
  107. f"平均 ctr={avg_ctr * 100:.1f}%" if avg_ctr <= 1
  108. else f"平均 ctr={avg_ctr:.1f}%"
  109. )
  110. if valid_imp:
  111. stat_parts.append(f"平均 imp={int(sum(valid_imp) / len(valid_imp)):,}")
  112. if valid_cost:
  113. stat_parts.append(f"平均 cost={sum(valid_cost) / len(valid_cost):.0f}元")
  114. lines.append(f"素材质量(n={len(ok_records)}): {' · '.join(stat_parts)}")
  115. lines.append("")
  116. lines.append("【已挂创意】")
  117. for r in approved_list:
  118. cid = r.get("dynamic_creative_id") or "(挂失败)"
  119. quality = _fmt_quality_summary(r)
  120. lines.append(
  121. f" · adgroup {r['adgroup_id']}({r['adgroup_name']}) "
  122. f"→ creative_id={cid} · name={r['creative_name']}{quality}"
  123. )
  124. rejected_list = [r for r in summary["records"] if r.get("action") == "reject"]
  125. hold_list = [r for r in summary["records"] if r.get("action") == "hold"]
  126. skip_list = [r for r in summary["records"] if r.get("action") == "skip"]
  127. if rejected_list or hold_list or skip_list:
  128. lines.append("")
  129. lines.append(
  130. f"【未挂】reject {len(rejected_list)} · hold {len(hold_list)} · skip {len(skip_list)}"
  131. )
  132. text = "\n".join(lines)
  133. token = _get_tenant_token()
  134. url = "https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=chat_id"
  135. body = {
  136. "receive_id": chat_id,
  137. "msg_type": "text",
  138. "content": json.dumps({"text": text}, ensure_ascii=False),
  139. }
  140. resp = httpx.post(url, headers=_auth_headers(token), json=body, timeout=30)
  141. if resp.json().get("code") == 0:
  142. logger.info("[apply] 执行汇报已发送 chat=%s", chat_id)
  143. else:
  144. logger.warning("[apply] 执行汇报发送失败: %s", resp.text[:200])
  145. except Exception as e:
  146. logger.exception("[apply] 发送执行汇报异常: %s", e)
  147. def apply_pending_records(records: list[dict]) -> dict:
  148. """Phase 3:对 records 里 action=approve 的项调腾讯 POST 创意,返回 summary。
  149. Args:
  150. records: pending records,每个含 action 字段(approve/reject/hold/skip)
  151. 以及 _request_body / account_id / adgroup_id / 等
  152. Returns:
  153. summary dict:
  154. {
  155. "run_started", "run_finished",
  156. "total": {records, approved, posted_ok, posted_failed},
  157. "records": [...] # 每条含 action + dynamic_creative_id(成功时) + error(失败时)
  158. }
  159. """
  160. run_started = now_in_timezone().isoformat()
  161. posted_ok = 0
  162. posted_failed = 0
  163. approved_total = 0
  164. out_records = []
  165. for r in records:
  166. rec = dict(r)
  167. action = (rec.get("action") or "skip").lower()
  168. rec["action"] = action
  169. if action != "approve":
  170. try:
  171. update_material_usage_status(rec, action)
  172. _update_deferred_material_status(rec, action)
  173. except Exception as e:
  174. logger.warning(
  175. "[apply] 更新素材 usage 状态失败 action=%s material=%s: %s",
  176. action, rec.get("_material_id"), e,
  177. )
  178. out_records.append(rec)
  179. continue
  180. approved_total += 1
  181. body = rec.get("_request_body")
  182. if not body:
  183. rec["error"] = "missing _request_body"
  184. posted_failed += 1
  185. try:
  186. update_material_usage_status(rec, "post_failed", error=rec["error"])
  187. _update_deferred_material_status(rec, "post_failed", error=rec["error"])
  188. except Exception as e:
  189. logger.warning("[apply] 更新素材 usage 状态失败: %s", e)
  190. out_records.append(rec)
  191. continue
  192. pending_image_url = str(rec.get("_pending_image_url") or "").strip()
  193. if pending_image_url and not rec.get("_material_image_id"):
  194. try:
  195. material_image_id = images_add(int(rec["account_id"]), pending_image_url)
  196. rec["_material_image_id"] = material_image_id
  197. image_comp = body.get("creative_components", {}).get("image") or []
  198. if image_comp:
  199. image_comp[0].setdefault("value", {})["image_id"] = material_image_id
  200. _update_deferred_material_status(
  201. rec,
  202. "approve",
  203. tencent_image_id=material_image_id,
  204. )
  205. except Exception as e:
  206. rec["error"] = f"ai_image_upload_failed:{e}"
  207. posted_failed += 1
  208. mark_creation_submit_failed(rec, rec["error"])
  209. try:
  210. update_material_usage_status(rec, "post_failed", error=rec["error"])
  211. _update_deferred_material_status(rec, "post_failed", error=rec["error"])
  212. except Exception as update_e:
  213. logger.warning("[apply] 更新素材 usage 状态失败: %s", update_e)
  214. out_records.append(rec)
  215. continue
  216. cid = post_creative_with_prepared_body(
  217. account_id=int(rec["account_id"]),
  218. body=body,
  219. skip_if_exists=True,
  220. )
  221. if cid:
  222. rec["dynamic_creative_id"] = str(cid)
  223. posted_ok += 1
  224. try:
  225. update_material_usage_status(rec, "posted_ok", dynamic_creative_id=cid)
  226. _update_deferred_material_status(
  227. rec,
  228. "posted_ok",
  229. dynamic_creative_id=cid,
  230. tencent_image_id=str(rec.get("_material_image_id") or ""),
  231. )
  232. except Exception as e:
  233. logger.warning("[apply] 更新素材 usage 状态失败 cid=%s: %s", cid, e)
  234. try:
  235. record_creation_submission(rec, int(cid))
  236. except Exception as e:
  237. logger.warning("[apply] 记录创意审核扫描任务失败 cid=%s: %s", cid, e)
  238. else:
  239. rec["error"] = "post_failed"
  240. posted_failed += 1
  241. mark_creation_submit_failed(rec, "post_failed")
  242. try:
  243. update_material_usage_status(rec, "post_failed", error=rec["error"])
  244. _update_deferred_material_status(rec, "post_failed", error=rec["error"])
  245. except Exception as e:
  246. logger.warning("[apply] 更新素材 usage 状态失败: %s", e)
  247. out_records.append(rec)
  248. run_finished = now_in_timezone().isoformat()
  249. return {
  250. "run_started": run_started,
  251. "run_finished": run_finished,
  252. "total": {
  253. "records": len(records),
  254. "approved": approved_total,
  255. "posted_ok": posted_ok,
  256. "posted_failed": posted_failed,
  257. },
  258. "records": out_records,
  259. }
  260. def _strip_body_for_json(records: list[dict]) -> list[dict]:
  261. """删 _request_body 中可能含 jump_spec 的长字段,JSON 落盘体积更小。
  262. 保留 body 顶层字段(adgroup_id/name/account_id)作为追溯。"""
  263. out = []
  264. for r in records:
  265. rec = dict(r)
  266. body = rec.pop("_request_body", None)
  267. if body:
  268. rec["_body_summary"] = {
  269. "account_id": body.get("account_id"),
  270. "adgroup_id": body.get("adgroup_id"),
  271. "dynamic_creative_name": body.get("dynamic_creative_name"),
  272. }
  273. out.append(rec)
  274. return out
  275. def write_summary(summary: dict, output_dir: Path) -> Path:
  276. """把 Phase 3 执行 summary 落盘为 creation_run_{date}_{ts}.json(剥离长 body 字段),返回文件路径。"""
  277. output_dir.mkdir(parents=True, exist_ok=True)
  278. now = now_in_timezone()
  279. date_str = now.strftime("%Y%m%d")
  280. ts = now.strftime("%H%M%S")
  281. out_path = output_dir / f"creation_run_{date_str}_{ts}.json"
  282. persisted = dict(summary)
  283. persisted["records"] = _strip_body_for_json(summary["records"])
  284. with open(out_path, "w", encoding="utf-8") as f:
  285. json.dump(persisted, f, ensure_ascii=False, indent=2)
  286. return out_path
  287. def main() -> int:
  288. """独立模式:从 JSON 文件读 pending records → apply → 写 summary + 发飞书。"""
  289. from logging_setup import setup_logging
  290. setup_logging(level=os.getenv("LOG_LEVEL", "INFO"))
  291. parser = argparse.ArgumentParser(
  292. description="从 pending records 执行创意创建;可选择读取已有飞书审批表决策。",
  293. )
  294. parser.add_argument("pending_records_json")
  295. parser.add_argument("--sheet-token", default="", help="已有创意审批飞书表 token")
  296. parser.add_argument("--sheet-id", default="", help="已有创意审批飞书表 sheet_id")
  297. parser.add_argument(
  298. "--approval-timeout-minutes",
  299. type=int,
  300. default=CREATION_APPROVAL_TIMEOUT_MINUTES,
  301. help="读取已有审批表时等待决策的分钟数",
  302. )
  303. args = parser.parse_args()
  304. started = time.monotonic()
  305. logger.info(
  306. "event=creation_apply stage=load_pending result=started file=%s",
  307. args.pending_records_json,
  308. )
  309. pending_path = Path(args.pending_records_json)
  310. if not pending_path.exists():
  311. logger.error(f"文件不存在: {pending_path}")
  312. return 1
  313. try:
  314. with open(pending_path, encoding="utf-8") as f:
  315. records = json.load(f)
  316. except Exception:
  317. logger.exception(
  318. "event=creation_apply stage=load_pending result=failed file=%s",
  319. pending_path,
  320. )
  321. raise
  322. logger.info(
  323. "event=creation_apply stage=load_pending result=succeeded records=%d",
  324. len(records),
  325. )
  326. if args.sheet_token and args.sheet_id:
  327. from tools.im_approval_creation import poll_approval_actions
  328. logger.info(
  329. "读取已有审批表决策 sheet_token=%s sheet_id=%s records=%d",
  330. args.sheet_token, args.sheet_id, len(records),
  331. )
  332. actions = poll_approval_actions(
  333. sheet_token=args.sheet_token,
  334. sheet_id=args.sheet_id,
  335. expected_row_count=len(records),
  336. timeout_minutes=args.approval_timeout_minutes,
  337. )
  338. for i, rec in enumerate(records, start=1):
  339. rec["action"] = actions.get(i, "skip")
  340. logger.info("审批决策读取完成: %d/%d", len(actions), len(records))
  341. logger.info(f"读到 {len(records)} 条 pending records,开始 Phase 3 执行")
  342. try:
  343. summary = apply_pending_records(records)
  344. except Exception:
  345. logger.exception(
  346. "event=creation_apply stage=apply result=failed records=%d duration_ms=%d",
  347. len(records), int((time.monotonic() - started) * 1000),
  348. )
  349. raise
  350. out_path = write_summary(summary, _HERE / "outputs" / "data")
  351. _send_apply_summary_to_feishu(summary)
  352. t = summary["total"]
  353. logger.info(
  354. f"Phase 3 完成: approve={t['approved']} ok={t['posted_ok']} fail={t['posted_failed']}"
  355. )
  356. logger.info(f"summary: {out_path}")
  357. logger.info(
  358. "event=creation_apply stage=apply result=%s approved=%d posted_ok=%d "
  359. "posted_failed=%d duration_ms=%d summary=%s",
  360. "succeeded" if t["posted_failed"] == 0 else "failed",
  361. t["approved"], t["posted_ok"], t["posted_failed"],
  362. int((time.monotonic() - started) * 1000), out_path,
  363. )
  364. return 0 if t["posted_failed"] == 0 else 1
  365. if __name__ == "__main__":
  366. sys.exit(main())