| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759 |
- """创意搭建主入口(模块 B)。
- 数据流(2026-06-08 用户确认 + 端到端打通):
- 承接视频(piaoquantv) → 多路召回素材(vector) → top 1 → 上传素材图(MD5 幂等)
- → 调 xcx/save 注册落地计划(拿 page_url + creative_name)→ 读账户 brand
- → 构造 body → POST /dynamic_creatives/add → 绑到广告
- 决策落地(已验证):
- - image 组件:**必须用 image_id**(image_url 会 reject code=18001)
- → 实现:下载 cover URL → MD5 → POST /v3.0/images/add multipart → 拿 image_id
- → MD5 幂等:同账户重复上传返回同 ID(无需本地缓存)
- - brand 组件:**必填**(漏传会 reject code=1800269)
- → brand_image_id 跨账户不可复用(reject code=1530003)
- → 从 DB account_whitelist 读账户级 brand_name/brand_image_id
- - dynamic_creative_type: DYNAMIC_CREATIVE_TYPE_PROGRAM(参考样本 9744753978)
- - description: 素材 title 一条(MVP)
- - jump_info / 命名:**piaoquantv xcx/save 接口统一管**
- → page_url(mini_program_path)和 root_source_id(creative_name)都来自服务侧
- → 客户端不本地拼接、不本地命名,保证归因锚点跨系统一致
- - 配置 NORMAL 状态,挂上后腾讯进入 PENDING(普通素材审核 2-4 小时)
- """
- import logging
- import random
- from dataclasses import asdict
- from typing import Optional
- from config import (
- CREATIVE_DESCRIPTION_COUNT_PER_AD,
- CREATIVE_DESCRIPTION_POOL,
- MARKETING_CARRIER_GH_ID,
- MAX_LANDING_ATTEMPTS_PER_AD,
- MAX_MATERIAL_PER_LANDING,
- TARGET_CREATIVES_PER_AD,
- )
- from tools.ad_api import _check, _get, _post, images_add
- from tools.landing_plan import LandingPlanResult, create_landing_plan
- from tools.material_recall import Material, recall_materials_for_video
- from tools.video_recall import (
- LandingVideo,
- fetch_landing_videos_for_account,
- get_account_crowd_package,
- )
- logger = logging.getLogger(__name__)
- def _pick_image_url(material: Material) -> str:
- """从素材里取 image URL — material.cover 优先,fallback 到 raw.imageList[0]"""
- if material.cover:
- return material.cover
- images = (material.raw or {}).get("imageList") or []
- return images[0] if images else ""
- def find_ads_needing_creatives(
- account_id: int,
- min_creatives: int = TARGET_CREATIVES_PER_AD,
- ) -> list[dict]:
- """扫描账户下"需要补创意"的广告(P0-B,2026-06-09)。
- 口径(2026-06-08 用户确认 = C):
- configured_status = AD_STATUS_NORMAL AND creative_count < min_creatives
- 实测发现:腾讯 /adgroups/get 不返回 creative_count 字段,
- 所以分两条路径取创意数:
- - 快路径:system_status = ADGROUP_STATUS_CREATIVE_EMPTY → creative_count = 0
- - 慢路径:其他状态 → 单独调 /dynamic_creatives/get 数总数(N+1,目前广告 ≤ 10 可接受)
- Args:
- account_id: 腾讯广告主账号 ID
- min_creatives: 默认读 config.TARGET_CREATIVES_PER_AD(测试=1,生产应回 15)
- — find 阈值 + 补量目标的同一变量,无矛盾
- Returns:
- [{adgroup_id, adgroup_name, system_status, creative_count}, ...]
- """
- # 注意:腾讯 filtering 对 configured_status IN 静默拒绝(实测 2026-06-09 加了 filtering 返回 0)
- # 改为不加 filtering 拉全部 + Python 内存过滤(单账户 ≤ 10 条广告,代价≈0)
- resp = _get("/adgroups/get", {
- "account_id": account_id, "page": 1, "page_size": 100,
- "fields": ["adgroup_id", "adgroup_name", "system_status", "configured_status"],
- })
- all_ads_raw = (resp.get("data") or {}).get("list") or []
- all_ads = [a for a in all_ads_raw if a.get("configured_status") == "AD_STATUS_NORMAL"]
- logger.info(
- "[find_ads_needing_creatives] account=%d 共 %d 条广告,%d 条 NORMAL",
- account_id, len(all_ads_raw), len(all_ads),
- )
- out = []
- for ad in all_ads:
- adgroup_id = ad["adgroup_id"]
- system_status = ad.get("system_status", "")
- adgroup_name = ad.get("adgroup_name", "")
- if system_status == "ADGROUP_STATUS_CREATIVE_EMPTY":
- creative_count = 0
- else:
- cresp = _get("/dynamic_creatives/get", {
- "account_id": account_id, "page": 1, "page_size": 100,
- "filtering": [{
- "field": "adgroup_id", "operator": "IN",
- "values": [str(adgroup_id)],
- }],
- "fields": ["dynamic_creative_id", "system_status"],
- })
- all_creatives = (cresp.get("data") or {}).get("list") or []
- # 2026-06-09:忽略 DENIED 状态(审核拒绝),让 find_ads 能重补
- # 其他状态(PENDING/ACTIVE/SUSPEND 等)都算"已挂"
- denied_count = sum(
- 1 for c in all_creatives
- if c.get("system_status") == "DYNAMIC_CREATIVE_STATUS_DENIED"
- )
- creative_count = len(all_creatives) - denied_count
- if denied_count:
- logger.info(
- "[find_ads_needing_creatives] adgroup=%d 总创意 %d 减去 %d 条 DENIED → 有效 %d",
- adgroup_id, len(all_creatives), denied_count, creative_count,
- )
- if creative_count < min_creatives:
- out.append({
- "adgroup_id": adgroup_id,
- "adgroup_name": adgroup_name,
- "system_status": system_status,
- "creative_count": creative_count,
- })
- logger.info(
- "[find_ads_needing_creatives] ✓ adgroup=%d name=%r creative_count=%d < %d",
- adgroup_id, adgroup_name, creative_count, min_creatives,
- )
- logger.info(
- "[find_ads_needing_creatives] account=%d 命中 %d 条需补创意",
- account_id, len(out),
- )
- return out
- def load_excluded_ad_ids_from_adjustment(
- date_str: Optional[str] = None,
- output_dir: str = "outputs/reports",
- ) -> set[int]:
- """读调控当日决策 CSV,提取被 pause 的 adgroup_id(P0-E 关联点过滤,2026-06-09)。
- 新建子系统启动时调用,排除调控当日已决策 pause 的广告,避免"调控刚关、新建又补创意"的冲突。
- Args:
- date_str: 形如 '20260609';None → 用昨天日期(调控 cron 02:00 UTC 跑完后)
- output_dir: 调控产物目录,相对当前工作目录
- Returns:
- 被 pause 的 adgroup_id 集合;文件不存在 / CSV 字段缺失时返回空集(降级,不阻塞)
- """
- import csv
- import glob
- import os
- from datetime import datetime, timedelta
- if date_str is None:
- date_str = (datetime.utcnow() - timedelta(days=1)).strftime("%Y%m%d")
- pattern = os.path.join(output_dir, f"llm_decisions_{date_str}*.csv")
- matches = sorted(glob.glob(pattern))
- if not matches:
- logger.warning(
- "[load_excluded_ad_ids] 未找到调控决策 CSV: %s,返回空排除集(不阻塞主循环)",
- pattern,
- )
- return set()
- latest = matches[-1]
- excluded: set[int] = set()
- with open(latest, newline="", encoding="utf-8") as f:
- reader = csv.DictReader(f)
- for row in reader:
- action = (row.get("action") or "").strip().lower()
- ad_id_raw = (row.get("ad_id") or row.get("adgroup_id") or "").strip()
- if action == "pause" and ad_id_raw.isdigit():
- excluded.add(int(ad_id_raw))
- logger.info(
- "[load_excluded_ad_ids] 从 %s 读出 %d 个 pause 广告(排除)",
- os.path.basename(latest), len(excluded),
- )
- return excluded
- def find_existing_creative_by_image(
- account_id: int, adgroup_id: int, material_image_id: str,
- ) -> Optional[int]:
- """查 adgroup 下是否已有创意挂了这个素材 image_id(幂等检查)。
- 判等键(2026-06-08 决策):**同 adgroup + 同 material_image_id**。
- - 不按 creative_name 判等:name = root_source_id,每次 xcx/save 都新,等于没做
- - 按 image_id 判等:同 image 在同 adgroup 下挂多条会被腾讯模型降权曝光
- Returns:
- 已有创意的 dynamic_creative_id;无则 None。
- """
- resp = _get("/dynamic_creatives/get", {
- "account_id": account_id, "page": 1, "page_size": 100,
- "filtering": [{
- "field": "adgroup_id", "operator": "IN", "values": [str(adgroup_id)],
- }],
- "fields": ["dynamic_creative_id", "creative_components"],
- })
- items = (resp.get("data") or {}).get("list") or []
- for c in items:
- images = (c.get("creative_components") or {}).get("image") or []
- for img in images:
- existing_id = ((img.get("value") or {}).get("image_id")) or ""
- if str(existing_id) == str(material_image_id):
- cid = int(c.get("dynamic_creative_id"))
- logger.info(
- "[idempotency] adgroup=%d image_id=%s 已挂创意 creative_id=%d,skip 新建",
- adgroup_id, material_image_id, cid,
- )
- return cid
- return None
- def get_account_brand(account_id: int) -> dict:
- """从 DB account_whitelist 读账户级 brand 资产。
- 返回 {"brand_name": str, "brand_image_id": str}。
- 跨账户 brand_image_id 不可复用,所以一定按 account_id 取。
- """
- from db.connection import get_connection
- conn = get_connection()
- try:
- with conn.cursor() as cur:
- cur.execute(
- "SELECT brand_name, brand_image_id FROM account_whitelist WHERE account_id=%s",
- (account_id,),
- )
- row = cur.fetchone()
- finally:
- conn.close()
- if not row or not row.get("brand_name") or not row.get("brand_image_id"):
- raise RuntimeError(
- f"account {account_id} 未在 account_whitelist 配置 brand_name/brand_image_id,"
- f"无法挂创意(腾讯 brand 组件必填)"
- )
- return {
- "brand_name": row["brand_name"],
- "brand_image_id": str(row["brand_image_id"]),
- }
- def build_creative_request_body(
- account_id: int,
- adgroup_id: int,
- landing: LandingVideo,
- material: Material,
- material_image_id: str,
- brand_name: str,
- brand_image_id: str,
- creative_name: str,
- jump_path: str,
- description_contents: Optional[list] = None,
- ) -> dict:
- """生成 /v3.0/dynamic_creatives/add 的请求 body(纯函数,无 I/O)。
- Args:
- material_image_id: 已上传到当前账户的素材图 image_id
- brand_name / brand_image_id: 账户级 brand 资产(已上传)
- creative_name: 由 xcx/save 返回的 root_source_id(归因锚点)
- jump_path: 由 xcx/save 返回的 page_url(小程序跳转路径)
- description_contents: 文案列表(可选);None 时自动 random.sample(用于审批表回显)
- """
- # 文案池随机选 N 条(2026-06-09 用户确认照搬示例 78420850 策略)
- # 示例所有创意 description × 3 都 ACTIVE;旧策略(素材标题)实测 DENIED + SUSPEND
- if description_contents is None:
- description_count = min(CREATIVE_DESCRIPTION_COUNT_PER_AD, len(CREATIVE_DESCRIPTION_POOL))
- description_contents = random.sample(CREATIVE_DESCRIPTION_POOL, description_count)
- jump_spec = {
- "page_type": "PAGE_TYPE_WECHAT_MINI_PROGRAM",
- "page_spec": {
- "wechat_mini_program_spec": {
- "mini_program_id": MARKETING_CARRIER_GH_ID,
- "mini_program_path": jump_path,
- }
- },
- }
- return {
- "account_id": account_id,
- "adgroup_id": adgroup_id,
- "dynamic_creative_name": creative_name,
- "delivery_mode": "DELIVERY_MODE_COMPONENT",
- "dynamic_creative_type": "DYNAMIC_CREATIVE_TYPE_PROGRAM",
- "configured_status": "AD_STATUS_NORMAL",
- "creative_components": {
- "description": [{"value": {"content": d}} for d in description_contents],
- "image": [{"value": {"image_id": material_image_id}}],
- "brand": [{"value": {
- "brand_name": brand_name,
- "brand_image_id": brand_image_id,
- }}],
- "action_button": [{"value": {"button_text": "查看详情"}}],
- "jump_info": [{"value": jump_spec}],
- "main_jump_info": [{"value": jump_spec}],
- },
- }
- def _pick_landing_and_materials(account_id: int) -> tuple[LandingVideo, list[Material]]:
- """召回链:承接视频 → 多路素材召回 → 取第一个有素材命中的 landing 及其 top 素材。
- 出口:确保 landing.point_type+standard_element 都有值(否则多路召回会全 skip)。
- """
- videos = fetch_landing_videos_for_account(account_id, page_size=50)
- valid = [v for v in videos if v.point_type and v.standard_element]
- if not valid:
- raise RuntimeError(
- f"account={account_id} 无 pointType+standardElement 都有值的承接视频"
- )
- for v in valid:
- materials = recall_materials_for_video(v, final_top_n=5)
- if materials:
- return v, materials
- raise RuntimeError(
- f"account={account_id} 前 {len(valid)} 条承接视频都召回 0 素材"
- )
- def preview_for_account(account_id: int, adgroup_id: int) -> dict:
- """承接视频 → 召回素材 → top 1 → 上传素材图 → 注册落地计划 → 读 brand → 构造 body。
- **会真实调:**
- - 腾讯 /images/add(MD5 幂等)
- - piaoquantv xcx/save(每次新建一条计划)
- body 是真实可投放的(只差 POST /dynamic_creatives/add)。
- """
- landing, materials = _pick_landing_and_materials(account_id)
- top_material = materials[0]
- image_url = _pick_image_url(top_material)
- if not image_url:
- raise ValueError(
- f"素材 {top_material.material_id} 既无 cover 也无 imageList,无法构造 image 组件"
- )
- material_image_id = images_add(account_id, image_url)
- crowd_package = get_account_crowd_package(account_id)
- plan = create_landing_plan(crowd_package, landing)
- brand = get_account_brand(account_id)
- body = build_creative_request_body(
- account_id=account_id, adgroup_id=adgroup_id,
- landing=landing, material=top_material,
- material_image_id=material_image_id,
- brand_name=brand["brand_name"],
- brand_image_id=brand["brand_image_id"],
- creative_name=plan.root_source_id,
- jump_path=plan.page_url,
- )
- return {
- "landing": asdict(landing),
- "material": asdict(top_material),
- "material_image_id": material_image_id,
- "brand": brand,
- "landing_plan": asdict(plan),
- "body": body,
- }
- def create_creative_for_ad(
- account_id: int, adgroup_id: int,
- landing: LandingVideo, material: Material,
- skip_if_exists: bool = True,
- ) -> int:
- """编排:上传素材图 → 幂等检查 → 注册落地计划 → 读 brand → build body → POST。
- Args:
- skip_if_exists: True(默认) — 同 adgroup + 同 material_image_id 已有创意时直接返回已有 ID,
- 跳过 xcx/save 注册和腾讯 POST,节省审核额度 + 避免模型降权。
- False — 强制新建(用于 A/B 测试不同归因锚点的场景)。
- Returns:
- dynamic_creative_id(新建或已有)
- """
- image_url = _pick_image_url(material)
- if not image_url:
- raise ValueError(
- f"素材 {material.material_id} 既无 cover 也无 imageList,无法构造 image 组件"
- )
- material_image_id = images_add(account_id, image_url)
- if skip_if_exists:
- existing_cid = find_existing_creative_by_image(account_id, adgroup_id, material_image_id)
- if existing_cid:
- return existing_cid
- crowd_package = get_account_crowd_package(account_id)
- plan = create_landing_plan(crowd_package, landing)
- brand = get_account_brand(account_id)
- body = build_creative_request_body(
- account_id, adgroup_id, landing, material,
- material_image_id=material_image_id,
- brand_name=brand["brand_name"],
- brand_image_id=brand["brand_image_id"],
- creative_name=plan.root_source_id,
- jump_path=plan.page_url,
- )
- logger.info(
- "[creative_creation] POST /dynamic_creatives/add adgroup=%d name=%s image_id=%s plan_id=%d",
- adgroup_id, body["dynamic_creative_name"], material_image_id, plan.plan_id,
- )
- resp = _post("/dynamic_creatives/add", body)
- data = _check(resp, "creative_create")
- return data.get("dynamic_creative_id")
- def prepare_one_creative_for_ad(
- account_id: int,
- adgroup_id: int,
- excluded_material_ids: Optional[set] = None,
- excluded_landing_ids: Optional[set] = None,
- max_landings: int = MAX_LANDING_ATTEMPTS_PER_AD,
- max_materials_per_landing: int = MAX_MATERIAL_PER_LANDING,
- ) -> Optional[dict]:
- """Phase 1 准备:召回 + 上传图 + xcx/save + build body → 返回 pending record。
- **不 POST 腾讯**。POST 行为留给 Phase 3(供"先审后挂"流程审批后才挂)。
- 跟 try_create_one_creative_with_fallback 的差异:
- - 不做 POST-based fallback(因为不 POST)
- - 信任黑名单 + 选 top 1 material(`EXCLUDED_COVER_URL_PATTERNS` 已经截掉尺寸不符的)
- - 如果 Phase 3 POST 失败(腾讯尺寸 reject 等),由 Phase 3 记 error,下轮主循环重试
- Args:
- excluded_material_ids: 同广告内已挂的 material_id 集合(2026-06-09 N=3 时去重必需)。
- 召回后过滤掉这些,避免同广告挂重复素材被腾讯模型降权曝光。
- Returns:
- pending record dict(飞书表格 13 列字段 + Phase 3 POST 用的完整 body + 元数据);
- 所有 landing 都召回 0 素材时返回 None。
- """
- from datetime import datetime, timezone
- excluded_material_ids = excluded_material_ids or set()
- excluded_landing_ids = excluded_landing_ids or set()
- # 2026-06-10 用户要求:扩大 landing 池(page_size=100,不 [:max_landings] 截)
- # 因为 used_landing_ids 全局共享后,每次 prepare 候选越来越少,池子太小会撞底
- videos = fetch_landing_videos_for_account(account_id, page_size=100)
- valid = [v for v in videos if v.point_type and v.standard_element]
- # 每次 prepare 仍最多尝试 max_landings 个未用 landing(单次时间预算控制)
- logger.info(
- "[prepare_one_creative] account=%d adgroup=%d valid landing=%d (池子) excl_mat=%d excl_landing=%d",
- account_id, adgroup_id, len(valid),
- len(excluded_material_ids), len(excluded_landing_ids),
- )
- chosen_landing = None
- chosen_material = None
- attempts = 0
- for v in valid:
- # 2026-06-10:跨广告 landing 去重(全局共享 used set)
- if v.video_id in excluded_landing_ids:
- continue
- attempts += 1
- if attempts > max_landings: # ← 单次 prepare 最多 10 次尝试
- break
- materials = recall_materials_for_video(v, final_top_n=max_materials_per_landing)
- # material_id 去重(2026-06-09):跳过已用素材(账户层 set,跨广告也共享)
- fresh = [m for m in materials if m.material_id not in excluded_material_ids]
- if fresh:
- chosen_landing = v
- chosen_material = fresh[0]
- break
- if materials:
- logger.info(
- "[prepare_one_creative] landing=%d 召回 %d 全在 excluded,试下一条",
- v.video_id, len(materials),
- )
- if not chosen_landing or not chosen_material:
- logger.error(
- "[prepare_one_creative] account=%d adgroup=%d 穷尽 landing 后无可用素材(excluded=%d)",
- account_id, adgroup_id, len(excluded_material_ids),
- )
- return None
- image_url = _pick_image_url(chosen_material)
- if not image_url:
- logger.error(
- "[prepare_one_creative] material=%s 既无 cover 也无 imageList,放弃",
- chosen_material.material_id,
- )
- return None
- # 上传素材图(MD5 幂等)
- material_image_id = images_add(account_id, image_url)
- # 注册落地计划(xcx/save)
- crowd_package = get_account_crowd_package(account_id)
- plan = create_landing_plan(crowd_package, chosen_landing)
- # 读账户 brand
- brand = get_account_brand(account_id)
- # 先 random.sample 文案,再传给 build_body — 这样 record 能存住"实际选了哪 3 条",
- # 飞书审批表展示一致(不能让 build 内部再 random,否则 record 跟 body 文案不同步)
- desc_count = min(CREATIVE_DESCRIPTION_COUNT_PER_AD, len(CREATIVE_DESCRIPTION_POOL))
- description_contents = random.sample(CREATIVE_DESCRIPTION_POOL, desc_count)
- # 构造完整 POST body(Phase 3 直接用)
- body = build_creative_request_body(
- account_id, adgroup_id, chosen_landing, chosen_material,
- material_image_id=material_image_id,
- brand_name=brand["brand_name"],
- brand_image_id=brand["brand_image_id"],
- creative_name=plan.root_source_id,
- jump_path=plan.page_url,
- description_contents=description_contents,
- )
- # 反查广告 metadata(飞书表格 B 组用)
- ad_info = _fetch_ad_metadata_for_approval(account_id, adgroup_id)
- today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
- return {
- # === 飞书表格 13 列(im_approval_creation 用)===
- "approval_date": today,
- "account_id": account_id,
- "audience_tier": crowd_package,
- "adgroup_id": adgroup_id,
- "adgroup_name": ad_info.get("adgroup_name", ""),
- "bid_amount_yuan": ad_info.get("bid_amount_yuan", ""),
- "site_set": ad_info.get("site_set", ""),
- "age_range": ad_info.get("age_range", ""),
- "landing_video_id": chosen_landing.video_id,
- "landing_video_url": chosen_landing.video_url,
- "landing_title": chosen_landing.title,
- "material_cover_url": chosen_material.cover,
- # 素材质量字段(2026-06-10 batchByText 升级 — 给 Task 25 飞书表展示用)
- "material_ctr": chosen_material.ctr,
- "material_cost": chosen_material.cost,
- "material_impressions": chosen_material.impressions,
- "material_quality_score": chosen_material.quality_score,
- "creative_name": plan.root_source_id,
- # === Phase 3 POST 用 ===
- "_request_body": body,
- # === 追溯元数据(写 summary JSON)===
- "_material_id": chosen_material.material_id,
- "_material_image_id": material_image_id,
- "_plan_id": plan.plan_id,
- "_experiment_id": chosen_landing.experiment_id,
- "_brand_image_id": brand["brand_image_id"],
- # 文案选择回显(飞书表格"创意文案"列要展示)
- "_description_contents": description_contents,
- }
- def _fetch_ad_metadata_for_approval(account_id: int, adgroup_id: int) -> dict:
- """反查广告,为飞书审批表组装 B 组(广告维度)字段。"""
- resp = _get("/adgroups/get", {
- "account_id": account_id, "page": 1, "page_size": 1,
- "filtering": [{
- "field": "adgroup_id", "operator": "IN", "values": [str(adgroup_id)],
- }],
- "fields": [
- "adgroup_id", "adgroup_name",
- "bid_amount", "site_set", "targeting",
- ],
- })
- items = (resp.get("data") or {}).get("list") or []
- if not items:
- return {}
- ad = items[0]
- bid_fen = ad.get("bid_amount")
- bid_yuan = f"{int(bid_fen) / 100:.2f}" if bid_fen is not None else ""
- site_set_cn_map = {
- "SITE_SET_WECHAT": "微信公众号",
- "SITE_SET_WECHAT_PLUGIN": "微信插件",
- "SITE_SET_SEARCH_SCENE": "搜索场景",
- "SITE_SET_MOMENTS": "朋友圈",
- "SITE_SET_MINI_GAME_WECHAT": "小游戏",
- "SITE_SET_MINI_PROGRAM_WECHAT": "小程序",
- }
- site_raw = ad.get("site_set") or []
- if isinstance(site_raw, str):
- site_str = site_set_cn_map.get(site_raw, site_raw)
- else:
- site_str = ",".join(site_set_cn_map.get(s, s) for s in site_raw)
- targeting = ad.get("targeting") or {}
- age_list = targeting.get("age") or []
- if age_list:
- age_str = ",".join(f"{a.get('min', '?')}-{a.get('max', '?')}" for a in age_list)
- else:
- age_str = "不限"
- return {
- "adgroup_name": ad.get("adgroup_name", ""),
- "bid_amount_yuan": bid_yuan,
- "site_set": site_str,
- "age_range": age_str,
- }
- # Task 28:永久业务错误码(重试无意义,同一 body 永远报同样错)
- # 实测/文档来源:
- # 1801159 — 图片尺寸/格式不符
- # 1801143 — 创意素材审核拒绝
- # 1800269 — 品牌字段缺失/不合规
- # 1801118 — 视频 / 图片资产 ID 无效
- # 1901634 — 唯一性 reject(同营销内容广告组已存在相同创意)
- # 1901589 — 相似性 reject
- # 33001 — 参数校验失败
- _PERMANENT_ERROR_CODES = ("1801159", "1801143", "1800269", "1801118", "1901634", "1901589", "33001")
- def post_creative_with_prepared_body(
- account_id: int,
- body: dict,
- skip_if_exists: bool = True,
- max_retries: int = 2,
- ) -> Optional[int]:
- """Phase 3 POST:用 Phase 1 准备好的 body 调腾讯 /dynamic_creatives/add。
- Task 28(2026-06-11):加重试机制,区分瞬时 / 永久错误。
- - 永久业务错误(图尺寸 / 品牌缺失 / 唯一性 / 33001 等)→ 直接返回 None,不重试
- - 瞬时错误(网络超时 / 5xx / QPS limit)→ 最多重试 max_retries 次,指数退避
- Args:
- account_id: 腾讯账户 ID(用于幂等检查)
- body: Phase 1 prepare_one_creative_for_ad 返回的 _request_body
- skip_if_exists: True(默认)POST 前做幂等检查,命中返回已有 cid
- max_retries: 瞬时错误的最大重试次数(默认 2;首次 + 2 次重试 = 最坏 3 次总尝试)
- Returns:
- 成功 dynamic_creative_id;失败 None(记 error log)
- """
- import time
- adgroup_id = body.get("adgroup_id")
- image_comp = body.get("creative_components", {}).get("image") or []
- material_image_id = ""
- if image_comp:
- material_image_id = (image_comp[0].get("value") or {}).get("image_id", "")
- if skip_if_exists and material_image_id and adgroup_id:
- existing_cid = find_existing_creative_by_image(
- account_id, int(adgroup_id), str(material_image_id),
- )
- if existing_cid:
- return existing_cid
- for attempt in range(max_retries + 1): # 首次 + 重试
- try:
- logger.info(
- "[post_creative] account=%d adgroup=%s name=%s image_id=%s attempt=%d/%d",
- account_id, adgroup_id,
- body.get("dynamic_creative_name", ""), material_image_id,
- attempt + 1, max_retries + 1,
- )
- resp = _post("/dynamic_creatives/add", body)
- data = _check(resp, "creative_create")
- cid = data.get("dynamic_creative_id")
- if cid:
- logger.info("[post_creative] 成功 cid=%s", cid)
- return cid
- logger.error("[post_creative] 返回 data 缺 dynamic_creative_id: %s", data)
- return None
- except RuntimeError as e:
- err = str(e)
- # 永久错误 → 不重试,直接放弃(节省 quota,避免无意义打)
- is_permanent = any(code in err for code in _PERMANENT_ERROR_CODES)
- if is_permanent:
- logger.error(
- "[post_creative] 永久错误,不重试 account=%d adgroup=%s: %s",
- account_id, adgroup_id, err[:200],
- )
- return None
- # 瞬时错误:重试 quota 没用完 → backoff 后重试
- if attempt < max_retries:
- backoff_s = 5 * (2 ** attempt) # 5s, 10s
- logger.warning(
- "[post_creative] 瞬时错误 attempt=%d/%d,sleep %ds 后重试 account=%d adgroup=%s: %s",
- attempt + 1, max_retries + 1, backoff_s,
- account_id, adgroup_id, err[:150],
- )
- time.sleep(backoff_s)
- continue
- # 重试用完 → 放弃
- logger.error(
- "[post_creative] 重试 %d 次后仍失败 account=%d adgroup=%s: %s",
- max_retries, account_id, adgroup_id, err[:200],
- )
- return None
- return None
- def try_create_one_creative_with_fallback(
- account_id: int,
- adgroup_id: int,
- max_landings: int = MAX_LANDING_ATTEMPTS_PER_AD,
- max_materials_per_landing: int = MAX_MATERIAL_PER_LANDING,
- ) -> Optional[int]:
- """对一条广告挂 1 条创意,带 try-fallback 鲁棒性(P0-A 核心 helper,2026-06-09)。
- 召回的素材不一定都能挂(实测 code=1801159 尺寸不符,1530003 等),
- 所以遍历 landing × material 尝试 POST,失败就试下一条,直到成功或穷尽。
- Args:
- account_id: 腾讯广告主账号 ID
- adgroup_id: 目标广告 ID
- max_landings: 最多尝试的 landing 数
- max_materials_per_landing: 每条 landing 召回 top N 素材尝试
- Returns:
- 成功:dynamic_creative_id;穷尽:None(记 error log,主循环继续下一广告)
- """
- videos = fetch_landing_videos_for_account(account_id, page_size=max_landings * 2)
- valid = [v for v in videos if v.point_type and v.standard_element][:max_landings]
- logger.info(
- "[try_create_one_creative] account=%d adgroup=%d 候选 landing=%d",
- account_id, adgroup_id, len(valid),
- )
- attempts = []
- for v in valid:
- materials = recall_materials_for_video(v, final_top_n=max_materials_per_landing)
- if not materials:
- attempts.append(f" · landing={v.video_id} 召回 0 素材,跳过")
- continue
- for m in materials:
- try:
- cid = create_creative_for_ad(account_id, adgroup_id, v, m)
- attempts.append(
- f" · landing={v.video_id} material={m.material_id[:12]} → 成功 cid={cid}"
- )
- logger.info("[try_create_one_creative] 成功 cid=%s\n%s", cid, "\n".join(attempts))
- return cid
- except RuntimeError as e:
- err = str(e)[:100]
- attempts.append(f" · landing={v.video_id} material={m.material_id[:12]} → 失败 {err}")
- continue
- logger.error(
- "[try_create_one_creative] account=%d adgroup=%d 穷尽所有 landing×material 仍失败:\n%s",
- account_id, adgroup_id, "\n".join(attempts),
- )
- return None
|