|
@@ -0,0 +1,274 @@
|
|
|
|
|
+"""创意搭建主入口(模块 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
|
|
|
|
|
+from dataclasses import asdict
|
|
|
|
|
+from typing import Optional
|
|
|
|
|
+
|
|
|
|
|
+from config import MARKETING_CARRIER_GH_ID
|
|
|
|
|
+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_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,
|
|
|
|
|
+) -> 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_content = material.title or landing.title or "查看详情"
|
|
|
|
|
+ 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": description_content}}],
|
|
|
|
|
+ "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")
|