im_approval_creation.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509
  1. """模块 B 创意搭建审批 — 飞书 sheet 生成 + 发送 + 轮询(P0-C,2026-06-09)。
  2. 数据流:
  3. Phase 1 准备 pending records (list[dict])
  4. → generate_approval_xlsx 生成 30 列 xlsx(hyperlink/下拉/颜色/冻结)
  5. → send_approval_to_feishu 上传转飞书 sheet + 发链接到群
  6. → poll_approval_actions 轮询读 sheet "决策"列,拿 approve/reject/hold
  7. → 调用方按 row_idx 匹配回 records 写决策
  8. 设计决策(2026-06-09 用户确认):
  9. - 30 列:A(灰)5 + B(紫)3 + C(橙)21 + D(绿)1,header 颜色分组
  10. - 素材预览用 HYPERLINK 公式(=IMAGE() 不被飞书支持,write-images API 留作 P3-5)
  11. - 落地视频列也用 HYPERLINK(点 video_id 跳转 video_url)
  12. - 决策列下拉:approve / reject / hold
  13. - 冻结首行(A2 起冻结上方)
  14. - 飞书 sheet 不支持 =IMAGE,实测 #VALUE!,所以 hyperlink 兜底
  15. """
  16. import asyncio
  17. import json
  18. import logging
  19. import os
  20. import tempfile
  21. import time
  22. from pathlib import Path
  23. from typing import Optional
  24. import httpx
  25. from openpyxl import Workbook
  26. from openpyxl.drawing.image import Image as OpenpyxlImage
  27. from openpyxl.styles import Alignment, Font, PatternFill
  28. from openpyxl.worksheet.datavalidation import DataValidation
  29. from PIL import Image as PilImage
  30. from config import (
  31. CREATION_APPROVAL_TIMEOUT_MINUTES,
  32. FEISHU_OPERATOR_CHAT_ID,
  33. now_in_timezone,
  34. )
  35. logger = logging.getLogger(__name__)
  36. FEISHU_BASE_URL = "https://open.feishu.cn/open-apis"
  37. EXTERNAL_RECALL_UV_WINDOW_DAYS = int(
  38. os.getenv("EXTERNAL_RECALL_UV_WINDOW_DAYS", "90")
  39. )
  40. # 30 列(中文)— 外部素材额外展示原图与配置窗口访问UV。
  41. HEADERS = [
  42. # A 浅灰 5 列
  43. "日期", "账户ID", "人群包", "广告ID", "广告名称",
  44. # B 浅紫 3 列
  45. "出价(元)", "投放版位", "年龄定向",
  46. # C 浅橙 21 列(落地视频 + 风险审核 + 素材来源 + 素材质量)
  47. "落地视频", "落地视频标题", "风险等级", "风险标签", "风险原因",
  48. "素材来源", "素材预览", "素材链接", "原始素材",
  49. f"{EXTERNAL_RECALL_UV_WINDOW_DAYS}天访问UV", "创意文案",
  50. "成本(元)", "ROI", "CTR", "曝光数", "相似度",
  51. "召回维度", "召回点类型", "召回元素", "命中维度明细",
  52. "创意名(归因)",
  53. # D 浅绿 1 列
  54. "决策",
  55. ]
  56. GROUP_COLORS = [
  57. ((1, 5), "FFD9D9D9"), # A 浅灰
  58. ((6, 8), "FFD9C8E8"), # B 浅紫
  59. ((9, 29), "FFFCD8B4"), # C 浅橙
  60. ((30, 30), "FFC6E0B4"), # D 浅绿
  61. ]
  62. COL_WIDTHS = {
  63. "A": 12, "B": 14, "C": 16, "D": 16, "E": 28,
  64. "F": 10, "G": 28, "H": 12,
  65. "I": 14, "J": 26, "K": 10, "L": 24, "M": 32,
  66. "N": 14, "O": 18, "P": 24, "Q": 18, "R": 14, "S": 30,
  67. "T": 12, "U": 10, "V": 10, "W": 10, "X": 10,
  68. "Y": 14, "Z": 14, "AA": 18, "AB": 42,
  69. "AC": 38, # 创意名(归因)
  70. "AD": 14, # 决策
  71. }
  72. DECISION_COL_LETTER = "AD"
  73. VALID_ACTIONS = ("approve", "reject", "hold")
  74. EMBED_MATERIAL_PREVIEW_IMAGES = os.getenv(
  75. "CREATION_APPROVAL_EMBED_IMAGES", "1"
  76. ).strip().lower() in {"1", "true", "yes", "y", "on"}
  77. MATERIAL_PREVIEW_COL_IDX = 15
  78. MATERIAL_PREVIEW_COL_LETTER = "O"
  79. def _color_for_col(col_idx: int) -> str:
  80. for (lo, hi), color in GROUP_COLORS:
  81. if lo <= col_idx <= hi:
  82. return color
  83. return "FFFFFFFF"
  84. def _format_record_to_row(rec: dict) -> list:
  85. """把 pending record 字典转成 xlsx 一行。
  86. record 必含字段:
  87. approval_date, account_id, audience_tier, adgroup_id, adgroup_name,
  88. bid_amount_yuan, site_set, age_range,
  89. landing_video_id, landing_video_url, landing_title,
  90. material_cover_url, creative_name
  91. """
  92. # 创意文案:N 条 description 用 \n 拼接(N=1 时单条)
  93. descriptions = rec.get("_description_contents") or []
  94. descriptions_str = "\n".join(descriptions) if descriptions else ""
  95. # 素材质量数据(2026-06-10 batchByText 升级 — 来自 materialDetail.quality)
  96. ctr = rec.get("material_ctr")
  97. cost = rec.get("material_cost")
  98. roi = rec.get("material_roi")
  99. imp = rec.get("material_impressions")
  100. score = rec.get("material_score")
  101. hit_queries = rec.get("material_recall_hit_queries") or []
  102. hit_detail = json.dumps(hit_queries, ensure_ascii=False) if hit_queries else ""
  103. return [
  104. rec["approval_date"],
  105. str(rec["account_id"]),
  106. rec["audience_tier"],
  107. str(rec["adgroup_id"]),
  108. rec["adgroup_name"],
  109. rec["bid_amount_yuan"],
  110. rec["site_set"],
  111. rec["age_range"],
  112. # 落地视频:HYPERLINK(video_url, video_id)
  113. f'=HYPERLINK("{rec["landing_video_url"]}","{rec["landing_video_id"]}")',
  114. rec["landing_title"][:30],
  115. rec.get("landing_risk_level", ""),
  116. rec.get("landing_risk_tag_ids", ""),
  117. rec.get("landing_risk_reason", ""),
  118. rec.get("material_source", "history"),
  119. # 素材预览:HYPERLINK(cover_url, "查看素材")— 见模块顶部说明
  120. f'=HYPERLINK("{rec["material_cover_url"]}","查看素材")',
  121. f'=HYPERLINK("{rec["material_cover_url"]}","打开素材")',
  122. (
  123. f'=HYPERLINK("{rec["_external_source_image_url"]}","打开原图")'
  124. if rec.get("_external_source_image_url") else ""
  125. ),
  126. (
  127. str(int(rec["material_visit_uv_30d"]))
  128. if rec.get("material_visit_uv_30d") is not None else ""
  129. ),
  130. # 创意文案(2026-06-09 加列):description 换行展示
  131. descriptions_str,
  132. f"{cost:.2f}" if cost is not None else "",
  133. f"{roi:.4f}" if roi is not None else "",
  134. f"{ctr:.4f}" if ctr is not None else "",
  135. str(imp) if imp is not None else "",
  136. f"{score:.4f}" if score is not None else "",
  137. rec.get("material_recall_element_dimension", ""),
  138. rec.get("material_recall_point_type", ""),
  139. rec.get("material_recall_standard_element", ""),
  140. hit_detail,
  141. rec["creative_name"],
  142. "", # 决策列空,等运营填
  143. ]
  144. def _download_preview_image(url: str, output_path: Path) -> bool:
  145. try:
  146. resp = httpx.get(url, timeout=20, follow_redirects=True)
  147. resp.raise_for_status()
  148. output_path.write_bytes(resp.content)
  149. return True
  150. except Exception as e:
  151. logger.warning("[im_approval_creation] 素材图下载失败 url=%s error=%s", url, e)
  152. return False
  153. def _prepare_thumbnail(src_path: Path, dst_path: Path) -> bool:
  154. try:
  155. with PilImage.open(src_path) as img:
  156. img = img.convert("RGB")
  157. img.thumbnail((180, 100))
  158. img.save(dst_path, format="JPEG", quality=85)
  159. return True
  160. except Exception as e:
  161. logger.warning(
  162. "[im_approval_creation] 素材图缩略图生成失败 path=%s error=%s",
  163. src_path, e,
  164. )
  165. return False
  166. def _embed_material_preview_images(ws, records: list[dict]) -> None:
  167. """Embed thumbnails into the material preview column, keeping hyperlinks as fallback."""
  168. if not EMBED_MATERIAL_PREVIEW_IMAGES:
  169. return
  170. tmp_dir = tempfile.TemporaryDirectory(prefix="creative_preview_")
  171. tmp_root = Path(tmp_dir.name)
  172. # Keep the tempdir alive until workbook.save() finishes.
  173. ws._creative_preview_tmp_dir = tmp_dir # type: ignore[attr-defined]
  174. embedded = 0
  175. for row_idx, rec in enumerate(records, start=2):
  176. url = str(rec.get("material_cover_url") or "").strip()
  177. if not url:
  178. continue
  179. raw_path = tmp_root / f"raw_{row_idx}"
  180. thumb_path = tmp_root / f"thumb_{row_idx}.jpg"
  181. if not _download_preview_image(url, raw_path):
  182. continue
  183. if not _prepare_thumbnail(raw_path, thumb_path):
  184. continue
  185. try:
  186. img = OpenpyxlImage(str(thumb_path))
  187. img.width = 180
  188. img.height = 100
  189. ws.add_image(img, f"{MATERIAL_PREVIEW_COL_LETTER}{row_idx}")
  190. ws.cell(row=row_idx, column=MATERIAL_PREVIEW_COL_IDX).value = "查看素材"
  191. ws.cell(row=row_idx, column=MATERIAL_PREVIEW_COL_IDX).hyperlink = url
  192. ws.row_dimensions[row_idx].height = 88
  193. embedded += 1
  194. except Exception as e:
  195. logger.warning(
  196. "[im_approval_creation] 素材图嵌入失败 row=%d url=%s error=%s",
  197. row_idx, url, e,
  198. )
  199. logger.info(
  200. "[im_approval_creation] 素材图嵌入完成 embedded=%d/%d",
  201. embedded, len(records),
  202. )
  203. def generate_approval_xlsx(records: list[dict], output_path: Path) -> Path:
  204. """生成待审批 xlsx,含 hyperlink/下拉/颜色/冻结。"""
  205. wb = Workbook()
  206. ws = wb.active
  207. ws.title = "创意审批"
  208. # header 行(颜色 + 加粗 + 居中)
  209. for col_idx, name in enumerate(HEADERS, start=1):
  210. cell = ws.cell(row=1, column=col_idx, value=name)
  211. cell.font = Font(bold=True, size=11)
  212. cell.alignment = Alignment(horizontal="center", vertical="center")
  213. cell.fill = PatternFill(
  214. start_color=_color_for_col(col_idx),
  215. end_color=_color_for_col(col_idx),
  216. fill_type="solid",
  217. )
  218. # 数据行
  219. for row_idx, rec in enumerate(records, start=2):
  220. row = _format_record_to_row(rec)
  221. for col_idx, val in enumerate(row, start=1):
  222. cell = ws.cell(row=row_idx, column=col_idx, value=val)
  223. cell.alignment = Alignment(
  224. horizontal="center", vertical="center", wrap_text=True,
  225. )
  226. # 决策列下拉
  227. dv = DataValidation(
  228. type="list",
  229. formula1='"approve,reject,hold"',
  230. allow_blank=True,
  231. )
  232. dv.error = "请选择 approve / reject / hold"
  233. dv.errorTitle = "无效选项"
  234. dv.prompt = "点击单元格选择决策"
  235. dv.promptTitle = "审批决策"
  236. ws.add_data_validation(dv)
  237. last_row = max(100, len(records) + 1)
  238. dv.add(f"{DECISION_COL_LETTER}2:{DECISION_COL_LETTER}{last_row}")
  239. # 冻结首行
  240. ws.freeze_panes = "A2"
  241. # 列宽 + 行高
  242. for col, w in COL_WIDTHS.items():
  243. ws.column_dimensions[col].width = w
  244. ws.row_dimensions[1].height = 28
  245. for r in range(2, 2 + len(records)):
  246. ws.row_dimensions[r].height = 80
  247. _embed_material_preview_images(ws, records)
  248. output_path.parent.mkdir(parents=True, exist_ok=True)
  249. wb.save(output_path)
  250. logger.info(
  251. "[im_approval_creation] xlsx 已生成 records=%d path=%s",
  252. len(records), output_path,
  253. )
  254. return output_path
  255. def _query_first_sheet_id(sheet_token: str) -> str:
  256. """调 sheets/v3 查 spreadsheet 的 sheets 列表,返回第一个 sheet_id。
  257. 飞书 sheet 内每个 sheet(tab)有独立 sheet_id,后续读 cell 需要用它。
  258. """
  259. from tools.feishu_doc import _auth_headers, _get_tenant_token
  260. token = _get_tenant_token()
  261. url = f"{FEISHU_BASE_URL}/sheets/v3/spreadsheets/{sheet_token}/sheets/query"
  262. resp = httpx.get(url, headers=_auth_headers(token), timeout=30)
  263. resp.raise_for_status()
  264. data = resp.json()
  265. if data.get("code") != 0:
  266. raise RuntimeError(
  267. f"查 sheet_id 失败 sheet_token={sheet_token} data={data}"
  268. )
  269. sheets = (data.get("data") or {}).get("sheets") or []
  270. if not sheets:
  271. raise RuntimeError(f"sheet_token={sheet_token} 无 sheet")
  272. first = sheets[0]
  273. sheet_id = first.get("sheet_id", "")
  274. logger.info(
  275. "[im_approval_creation] sheet_token=%s 首个 sheet_id=%s",
  276. sheet_token, sheet_id,
  277. )
  278. return sheet_id
  279. async def send_approval_to_feishu(
  280. xlsx_path: Path,
  281. chat_id: str = "",
  282. preamble: str = "",
  283. ) -> dict:
  284. """上传 xlsx → 转飞书 sheet → 发链接到群,返回 sheet 元数据。
  285. Returns:
  286. {url, sheet_token, sheet_id, im_sent, im_message_id}
  287. """
  288. chat_id = chat_id or FEISHU_OPERATOR_CHAT_ID
  289. if not preamble:
  290. preamble = (
  291. "【创意搭建审批】下方在线表格列出本次准备挂的创意。\n"
  292. "系统已在创建落地计划前过滤高风险视频,表格中的【风险等级/风险标签/风险原因】为通过项的审核摘要。\n"
  293. "历史和外部召回素材按相似度≥0.8准入;历史素材按消耗倒序,"
  294. f"外部素材按最近{EXTERNAL_RECALL_UV_WINDOW_DAYS}天访问UV倒序并经AI清理。\n"
  295. "请在最右侧【决策】列下拉框选择 approve / reject / hold。\n"
  296. "我们会在 2 小时内或所有行决策完成后,执行 approve 项,跳过 reject / hold。\n"
  297. "提示:点击【素材预览】或【落地视频】可在浏览器打开查看(若 403 请右键新标签或复制 URL)。"
  298. )
  299. from tools.feishu_doc import import_to_feishu
  300. result = await import_to_feishu(
  301. xlsx_path=str(xlsx_path),
  302. send_im=True,
  303. chat_id=chat_id,
  304. preamble=preamble,
  305. )
  306. meta = result.metadata or {}
  307. sheet_token = meta.get("sheet_token", "")
  308. if not sheet_token:
  309. raise RuntimeError(
  310. f"send_approval_to_feishu: 未拿到 sheet_token result={result}"
  311. )
  312. sheet_id = _query_first_sheet_id(sheet_token)
  313. return {
  314. "url": meta.get("url", ""),
  315. "sheet_token": sheet_token,
  316. "sheet_id": sheet_id,
  317. "im_sent": meta.get("im_sent", False),
  318. "im_message_id": meta.get("im_message_id", ""),
  319. }
  320. def read_actions_from_sheet(
  321. sheet_token: str, sheet_id: str, row_count: int,
  322. ) -> dict[int, str]:
  323. """读 sheet 的"决策"列,返回 {row_idx: action}。
  324. Args:
  325. row_count: pending records 数(不含 header)
  326. Returns:
  327. {row_idx: action} — row_idx 是 1-based 数据行编号(1=第一条 record)
  328. 未填或非法值的 row 不在 dict 中,视为"未决策"。
  329. """
  330. from tools.feishu_doc import _auth_headers, _get_tenant_token
  331. token = _get_tenant_token()
  332. last_row = row_count + 1
  333. range_str = f"{sheet_id}!{DECISION_COL_LETTER}2:{DECISION_COL_LETTER}{last_row}"
  334. url = (
  335. f"{FEISHU_BASE_URL}/sheets/v2/spreadsheets/{sheet_token}"
  336. f"/values/{range_str}?valueRenderOption=ToString"
  337. )
  338. resp = None
  339. last_error: Exception | None = None
  340. for attempt, delay_seconds in enumerate((0, 2, 5, 10), start=1):
  341. if delay_seconds:
  342. time.sleep(delay_seconds)
  343. try:
  344. resp = httpx.get(url, headers=_auth_headers(token), timeout=30)
  345. resp.raise_for_status()
  346. break
  347. except httpx.HTTPStatusError as e:
  348. last_error = e
  349. status = e.response.status_code
  350. if status < 500 and status != 429:
  351. raise
  352. logger.warning(
  353. "[poll_approval] 读 sheet HTTP %s,第 %d 次重试 range=%s",
  354. status, attempt, range_str,
  355. )
  356. except httpx.RequestError as e:
  357. last_error = e
  358. logger.warning(
  359. "[poll_approval] 读 sheet 网络异常,第 %d 次重试 range=%s error=%s",
  360. attempt, range_str, e,
  361. )
  362. else:
  363. assert last_error is not None
  364. raise last_error
  365. assert resp is not None
  366. data = resp.json()
  367. if data.get("code") != 0:
  368. raise RuntimeError(f"读 sheet 失败 range={range_str} data={data}")
  369. values = (data.get("data") or {}).get("valueRange", {}).get("values", []) or []
  370. actions: dict[int, str] = {}
  371. for i, row in enumerate(values, start=1):
  372. if not row:
  373. continue
  374. cell_raw = row[0]
  375. if cell_raw is None:
  376. continue
  377. cell = str(cell_raw).strip().lower()
  378. if cell in VALID_ACTIONS:
  379. actions[i] = cell
  380. return actions
  381. def poll_approval_actions(
  382. sheet_token: str,
  383. sheet_id: str,
  384. expected_row_count: int,
  385. timeout_minutes: int = CREATION_APPROVAL_TIMEOUT_MINUTES,
  386. poll_interval_seconds: int = 60,
  387. ) -> dict[int, str]:
  388. """轮询读 sheet "决策"列,直到所有 row 决策填完或超时。
  389. Args:
  390. expected_row_count: 等待决策的总条数
  391. timeout_minutes: 最长等待(配 config.CREATION_APPROVAL_TIMEOUT_MINUTES,默认 120)
  392. poll_interval_seconds: 轮询间隔
  393. Returns:
  394. {row_idx: action} — 完整 dict;超时未填的 row 不在 dict
  395. """
  396. deadline = time.time() + timeout_minutes * 60
  397. last_count = -1
  398. actions: dict[int, str] = {}
  399. while time.time() < deadline:
  400. actions = read_actions_from_sheet(sheet_token, sheet_id, expected_row_count)
  401. if len(actions) != last_count:
  402. logger.info(
  403. "[poll_approval] %d/%d 已决策",
  404. len(actions), expected_row_count,
  405. )
  406. last_count = len(actions)
  407. if len(actions) >= expected_row_count:
  408. logger.info(
  409. "[poll_approval] 全部完成 %d/%d, 提前退出轮询",
  410. len(actions), expected_row_count,
  411. )
  412. return actions
  413. time.sleep(poll_interval_seconds)
  414. logger.warning(
  415. "[poll_approval] 超时 %d 分钟, 仅 %d/%d 已决策 — 未决策视为跳过",
  416. timeout_minutes, last_count, expected_row_count,
  417. )
  418. return actions
  419. def run_approval_workflow(
  420. records: list[dict],
  421. xlsx_output_dir: Path,
  422. timeout_minutes: int = CREATION_APPROVAL_TIMEOUT_MINUTES,
  423. ) -> tuple[dict, dict[int, str]]:
  424. """完整审批工作流:生成 xlsx → 发飞书 → 轮询拿决策。
  425. Args:
  426. records: Phase 1 准备好的待审批 records(每个 dict 见 _format_record_to_row)
  427. xlsx_output_dir: xlsx 输出目录
  428. timeout_minutes: 审批超时
  429. Returns:
  430. (sheet_meta, actions)
  431. sheet_meta:{url, sheet_token, sheet_id, im_sent, im_message_id}
  432. actions:{row_idx: action},row_idx 1-based 对应 records[row_idx-1]
  433. """
  434. now = now_in_timezone()
  435. date_str = now.strftime("%Y%m%d")
  436. ts = now.strftime("%H%M%S")
  437. xlsx_path = xlsx_output_dir / f"creation_approval_{date_str}_{ts}.xlsx"
  438. generate_approval_xlsx(records, xlsx_path)
  439. sheet_meta = asyncio.run(send_approval_to_feishu(xlsx_path))
  440. actions = poll_approval_actions(
  441. sheet_token=sheet_meta["sheet_token"],
  442. sheet_id=sheet_meta["sheet_id"],
  443. expected_row_count=len(records),
  444. timeout_minutes=timeout_minutes,
  445. )
  446. return sheet_meta, actions