Sfoglia il codice sorgente

支持飞书最大转化量与投放版位配置

刘立冬 3 settimane fa
parent
commit
49259f6085

+ 17 - 2
examples/auto_put_ad_mini/config.py

@@ -312,13 +312,20 @@ def get_account_creation_config(account_id: int) -> dict:
 
     conn = get_connection()
     try:
+        try:
+            from configure_creation_accounts import ensure_account_delivery_config_columns
+            ensure_account_delivery_config_columns()
+        except Exception as e:
+            logger.warning("确保账户投放配置列失败:%s", e)
         with conn.cursor() as cur:
             cur.execute(
                 """
                 SELECT c.account_id, c.enabled, c.delivery_version,
                        c.audience_name, c.audience_pack_id,
                        c.audience_tier_label, c.bid_min_fen, c.bid_max_fen,
-                       c.bid_amount_fen, c.age_min, c.age_max,
+                       c.bid_amount_fen, c.bid_scene, c.custom_cost_cap_fen,
+                       c.automatic_site_enabled, c.site_set_json AS account_site_set_json,
+                       c.age_min, c.age_max,
                        c.daily_budget_fen AS account_daily_budget_fen,
                        t.site_set_json, t.location_types_json, t.region_ids_json,
                        t.daily_budget_fen, t.time_series_json,
@@ -389,10 +396,12 @@ def get_account_creation_config(account_id: int) -> dict:
         )
 
     import json as _json
-    site_set = _json.loads(row["site_set_json"])
+    from tools.delivery_config import parse_bid_scene
+    site_set = _json.loads(row.get("account_site_set_json") or row["site_set_json"])
     location_types = _json.loads(row["location_types_json"])
     region_ids = _json.loads(row["region_ids_json"])
     time_series = _json.loads(row["time_series_json"])
+    automatic_site_enabled = row.get("automatic_site_enabled")
 
     return {
         "account_id": int(row["account_id"]),
@@ -403,8 +412,14 @@ def get_account_creation_config(account_id: int) -> dict:
         "bid_min_fen": int(bid_min_fen) if bid_min_fen is not None else None,
         "bid_max_fen": int(bid_max_fen) if bid_max_fen is not None else None,
         "bid_amount_fen": int(bid_amount_fen),
+        "bid_scene": parse_bid_scene(row.get("bid_scene")),
+        "custom_cost_cap_fen": (
+            int(row["custom_cost_cap_fen"])
+            if row.get("custom_cost_cap_fen") is not None else None
+        ),
         "age": [{"min": int(age_min), "max": int(age_max)}],
         "site_set": site_set,
+        "automatic_site_enabled": bool(automatic_site_enabled) if automatic_site_enabled is not None else False,
         "location_types": location_types,
         "region_ids": region_ids,
         "daily_budget_fen": int(row.get("account_daily_budget_fen") or row["daily_budget_fen"]),

+ 87 - 3
examples/auto_put_ad_mini/configure_creation_accounts.py

@@ -12,6 +12,7 @@ from __future__ import annotations
 
 import argparse
 import csv
+import json
 import sys
 from dataclasses import dataclass
 from decimal import Decimal, ROUND_HALF_UP
@@ -30,6 +31,7 @@ DEFAULT_FEEDBACK_KEY = "miniapp_click_default"
 DEFAULT_AGE_MIN = 45
 DEFAULT_AGE_MAX = 66
 DEFAULT_SOURCE_ACCOUNT_ID = 55615440
+DEFAULT_BID_SCENE = "average_cost"
 
 
 @dataclass(frozen=True)
@@ -43,6 +45,10 @@ class AccountConfigInput:
     enabled: bool = True
     material_source: str = "history"
     ai_fallback_to_history: bool = False
+    bid_scene: str = DEFAULT_BID_SCENE
+    custom_cost_cap_fen: int | None = None
+    automatic_site_enabled: bool | None = None
+    site_set: list[str] | None = None
 
 
 def _bid_to_fen(raw: str) -> int:
@@ -74,6 +80,67 @@ def parse_budget_fen(raw: object) -> int | None:
     return _bid_to_fen(text)
 
 
+def ensure_account_delivery_config_columns() -> None:
+    """Add account-level delivery override columns when deploying old DBs."""
+    from db.connection import get_connection
+
+    conn = get_connection()
+    try:
+        with conn.cursor() as cur:
+            cur.execute(
+                """
+                SELECT COLUMN_NAME
+                FROM information_schema.COLUMNS
+                WHERE TABLE_SCHEMA = DATABASE()
+                  AND TABLE_NAME = 'ad_creation_account_config'
+                  AND COLUMN_NAME IN (
+                    'bid_scene', 'custom_cost_cap_fen',
+                    'automatic_site_enabled', 'site_set_json'
+                  )
+                """
+            )
+            existing = {row["COLUMN_NAME"] for row in (cur.fetchall() or [])}
+            if "bid_scene" not in existing:
+                cur.execute(
+                    """
+                    ALTER TABLE ad_creation_account_config
+                    ADD COLUMN bid_scene VARCHAR(50) DEFAULT NULL
+                    COMMENT '出价场景:BID_SCENE_NORMAL_AVERAGE/BID_SCENE_NORMAL_MAX'
+                    AFTER bid_amount_fen
+                    """
+                )
+            if "custom_cost_cap_fen" not in existing:
+                cur.execute(
+                    """
+                    ALTER TABLE ad_creation_account_config
+                    ADD COLUMN custom_cost_cap_fen INT DEFAULT NULL
+                    COMMENT '最大转化量控制成本(分),用于 custom_cost_cap'
+                    AFTER bid_scene
+                    """
+                )
+            if "automatic_site_enabled" not in existing:
+                cur.execute(
+                    """
+                    ALTER TABLE ad_creation_account_config
+                    ADD COLUMN automatic_site_enabled BOOLEAN DEFAULT NULL
+                    COMMENT '账户级是否开启智能版位;NULL使用模板默认手动版位'
+                    AFTER daily_budget_fen
+                    """
+                )
+            if "site_set_json" not in existing:
+                cur.execute(
+                    """
+                    ALTER TABLE ad_creation_account_config
+                    ADD COLUMN site_set_json TEXT DEFAULT NULL
+                    COMMENT '账户级投放版位覆盖JSON;NULL使用投放模板'
+                    AFTER automatic_site_enabled
+                    """
+                )
+        conn.commit()
+    finally:
+        conn.close()
+
+
 def _read_inputs(args: argparse.Namespace) -> list[AccountConfigInput]:
     rows: list[AccountConfigInput] = []
     if args.csv:
@@ -123,11 +190,14 @@ def upsert_account_config(row: AccountConfigInput, dry_run: bool = False) -> Non
             f"[dry-run] account={row.account_id} audience={row.audience_name} "
             f"bid={row.bid_min_fen}-{row.bid_max_fen} source={source_account_id} "
             f"status={grant_status} material_source={row.material_source} "
-            f"ai_fallback={row.ai_fallback_to_history}"
+            f"ai_fallback={row.ai_fallback_to_history} bid_scene={row.bid_scene} "
+            f"custom_cost_cap={row.custom_cost_cap_fen} "
+            f"automatic_site={row.automatic_site_enabled} site_set={row.site_set}"
         )
         return
 
     ensure_account_material_strategy_columns()
+    ensure_account_delivery_config_columns()
     conn = get_connection()
     try:
         with conn.cursor() as cur:
@@ -182,7 +252,9 @@ def upsert_account_config(row: AccountConfigInput, dry_run: bool = False) -> Non
                   (account_id, enabled, delivery_version, brand_key, feedback_key,
                    material_source, ai_fallback_to_history,
                    audience_name, audience_pack_id, audience_tier_label,
-                   bid_min_fen, bid_max_fen, bid_amount_fen, daily_budget_fen,
+                   bid_min_fen, bid_max_fen, bid_amount_fen, bid_scene,
+                   custom_cost_cap_fen,
+                   daily_budget_fen, automatic_site_enabled, site_set_json,
                    age_min, age_max, audience_source_account_id, audience_grant_status,
                    remark, created_by, updated_by)
                 VALUES
@@ -190,6 +262,8 @@ def upsert_account_config(row: AccountConfigInput, dry_run: bool = False) -> Non
                    %s, %s,
                    %s, %s, %s,
                    %s, %s, %s, %s,
+                   %s,
+                   %s, %s, %s,
                    %s, %s, %s, %s,
                    %s, %s, %s)
                 ON DUPLICATE KEY UPDATE
@@ -208,7 +282,11 @@ def upsert_account_config(row: AccountConfigInput, dry_run: bool = False) -> Non
                   bid_min_fen=VALUES(bid_min_fen),
                   bid_max_fen=VALUES(bid_max_fen),
                   bid_amount_fen=VALUES(bid_amount_fen),
+                  bid_scene=VALUES(bid_scene),
+                  custom_cost_cap_fen=VALUES(custom_cost_cap_fen),
                   daily_budget_fen=VALUES(daily_budget_fen),
+                  automatic_site_enabled=VALUES(automatic_site_enabled),
+                  site_set_json=VALUES(site_set_json),
                   age_min=VALUES(age_min),
                   age_max=VALUES(age_max),
                   audience_source_account_id=IF(
@@ -243,7 +321,11 @@ def upsert_account_config(row: AccountConfigInput, dry_run: bool = False) -> Non
                     row.bid_min_fen,
                     row.bid_max_fen,
                     row.bid_amount_fen,
+                    row.bid_scene,
+                    row.custom_cost_cap_fen,
                     row.daily_budget_fen,
+                    None if row.automatic_site_enabled is None else (1 if row.automatic_site_enabled else 0),
+                    json.dumps(row.site_set, ensure_ascii=False) if row.site_set is not None else None,
                     DEFAULT_AGE_MIN,
                     DEFAULT_AGE_MAX,
                     source_account_id,
@@ -259,7 +341,9 @@ def upsert_account_config(row: AccountConfigInput, dry_run: bool = False) -> Non
 
     print(
         f"configured account={row.account_id} audience={row.audience_name} "
-        f"bid={row.bid_min_fen / 100:.2f}-{row.bid_max_fen / 100:.2f} status={grant_status}"
+        f"bid={row.bid_min_fen / 100:.2f}-{row.bid_max_fen / 100:.2f} "
+        f"bid_scene={row.bid_scene} custom_cost_cap={row.custom_cost_cap_fen} "
+        f"status={grant_status}"
     )
 
 

+ 4 - 0
examples/auto_put_ad_mini/db/schema.sql

@@ -62,7 +62,11 @@ CREATE TABLE IF NOT EXISTS ad_creation_account_config (
     bid_min_fen INT NOT NULL COMMENT '最低出价(分)',
     bid_max_fen INT NOT NULL COMMENT '最高出价(分)',
     bid_amount_fen INT DEFAULT NULL COMMENT '固定出价(分);NULL则按范围取值',
+    bid_scene VARCHAR(50) DEFAULT NULL COMMENT '出价方式:average_cost/max_conversion',
+    custom_cost_cap_fen INT DEFAULT NULL COMMENT '最大转化量控制成本(分),用于 custom_cost_cap',
     daily_budget_fen INT DEFAULT NULL COMMENT '账户级单广告日预算(分);NULL则使用投放模板默认预算',
+    automatic_site_enabled BOOLEAN DEFAULT NULL COMMENT '账户级是否开启智能版位;NULL使用模板默认手动版位',
+    site_set_json TEXT DEFAULT NULL COMMENT '账户级投放版位覆盖JSON;NULL使用投放模板',
     age_min INT NOT NULL COMMENT '最小年龄',
     age_max INT NOT NULL COMMENT '最大年龄',
     audience_source_account_id BIGINT DEFAULT NULL COMMENT '人群包来源账户;默认可由 TENCENT_AUDIENCE_SOURCE_ACCOUNT_ID 提供',

+ 3 - 0
examples/auto_put_ad_mini/execute_creation_once.py

@@ -261,6 +261,9 @@ def phase0_create_ads(target_ads: int = ADS_PER_ACCOUNT) -> list[dict]:
                 "delivery_version": c.delivery_version,
                 "wechat_position": c.wechat_position,
                 "bid_amount_fen": c.bid_amount_fen,
+                "bid_scene": c.bid_scene,
+                "custom_cost_cap_fen": c.custom_cost_cap_fen,
+                "automatic_site_enabled": c.automatic_site_enabled,
                 "age_range": age_range,
                 "fingerprint": c.fingerprint,
                 "_request_body": body,

+ 41 - 1
examples/auto_put_ad_mini/sync_feishu_account_config.py

@@ -14,6 +14,8 @@
   - 「预算(单广告)」为空/不限制/不限 时使用模板默认预算;数字按元转换为分。
   - 「素材来源」为空默认历史素材;填 AI生成素材 时走 AI 图片生成链路。
   - 「生成失败是否回退历史素材」为空/否默认不回退。
+  - 「出价方式」为空默认稳定成本;填「最大转化量」时使用「最大转化量出价」作为控制成本。
+  - 「投放版位」为空使用投放模板默认;填 AIM+ 时使用智能版位。
 """
 
 from __future__ import annotations
@@ -43,6 +45,11 @@ from tools.account_material_strategy import (  # noqa: E402
     normalize_material_source,
     parse_bool_flag,
 )
+from tools.delivery_config import (  # noqa: E402
+    BID_MODE_MAX_CONVERSION,
+    parse_bid_scene,
+    parse_placement_config,
+)
 from tools.feishu_doc import (  # noqa: E402
     FEISHU_BASE_URL,
     _auth_headers,
@@ -52,7 +59,7 @@ from tools.feishu_doc import (  # noqa: E402
 
 DEFAULT_SPREADSHEET_TOKEN = "D8f4sKEb2hfBnNttX1ucZTklnHf"
 DEFAULT_SHEET_ID = "y3uCcz"
-DEFAULT_RANGE = "A1:I300"
+DEFAULT_RANGE = "A1:L300"
 
 HEADER_ROW_INDEX = 1
 DATA_START_INDEX = 2
@@ -68,6 +75,10 @@ REQUIRED_COLUMNS = {
 OPTIONAL_COLUMNS = {
     "素材来源": "material_source",
     "生成失败是否回退历史素材": "ai_fallback_to_history",
+    "出价方式": "bid_scene",
+    "最大转化量出价": "max_conversion_bid",
+    "投放版位": "placement",
+    "版位": "placement",
 }
 
 
@@ -163,7 +174,30 @@ def sync_from_feishu(
                 _cell(row, mapping["生成失败是否回退历史素材"])
                 if "生成失败是否回退历史素材" in mapping else ""
             )
+            bid_scene_text = (
+                _cell(row, mapping["出价方式"])
+                if "出价方式" in mapping else ""
+            )
+            max_conversion_bid_text = (
+                _cell(row, mapping["最大转化量出价"])
+                if "最大转化量出价" in mapping else ""
+            )
+            placement_col = "投放版位" if "投放版位" in mapping else "版位"
+            placement_text = (
+                _cell(row, mapping[placement_col])
+                if placement_col in mapping else ""
+            )
             bid_min, bid_max, bid_amount = parse_bid(bid_text)
+            bid_scene = parse_bid_scene(bid_scene_text)
+            custom_cost_cap_fen = None
+            if bid_scene == BID_MODE_MAX_CONVERSION:
+                if not max_conversion_bid_text:
+                    raise ValueError("最大转化量出价不能为空")
+                max_bid_min, max_bid_max, max_bid_amount = parse_bid(max_conversion_bid_text)
+                if max_bid_min != max_bid_max or max_bid_amount is None:
+                    raise ValueError("最大转化量出价必须是固定值")
+                custom_cost_cap_fen = max_bid_amount
+            placement = parse_placement_config(placement_text)
             enabled_records.append(AccountConfigInput(
                 account_id=account_id,
                 audience_name=audience_name,
@@ -174,6 +208,12 @@ def sync_from_feishu(
                 enabled=True,
                 material_source=normalize_material_source(material_source_text),
                 ai_fallback_to_history=parse_bool_flag(fallback_text),
+                bid_scene=bid_scene,
+                custom_cost_cap_fen=custom_cost_cap_fen,
+                automatic_site_enabled=(
+                    placement.automatic_site_enabled if placement is not None else None
+                ),
+                site_set=(placement.site_set if placement is not None else None),
             ))
             stats["enabled"] += 1
         except Exception as e:

+ 55 - 0
examples/auto_put_ad_mini/test_ad_creation_status.py

@@ -11,6 +11,12 @@ if str(PROJECT_ROOT) not in sys.path:
     sys.path.insert(0, str(PROJECT_ROOT))
 
 from tools.ad_creation import AdCandidate, build_ad_request_body
+from tools.delivery_config import (
+    BID_MODE_AVERAGE_COST,
+    BID_MODE_MAX_CONVERSION,
+    parse_bid_scene,
+    parse_placement_config,
+)
 
 
 def _candidate() -> AdCandidate:
@@ -39,6 +45,55 @@ class AdCreationStatusTest(unittest.TestCase):
 
         self.assertEqual(body["configured_status"], "AD_STATUS_NORMAL")
 
+    def test_max_conversion_uses_systematic_bid_with_custom_cost_cap(self):
+        candidate = _candidate()
+        candidate.bid_scene = BID_MODE_MAX_CONVERSION
+        candidate.custom_cost_cap_fen = 48
+        with patch("tools.ad_creation.get_account_feedback_id", return_value=6700001):
+            body = build_ad_request_body(candidate, begin_date="2026-07-01")
+
+        self.assertEqual("SMART_BID_TYPE_SYSTEMATIC", body["smart_bid_type"])
+        self.assertEqual(0, body["bid_amount"])
+        self.assertEqual("COST_CONSTRAINT_SCENE_OPEN", body["cost_constraint_scene"])
+        self.assertEqual(48, body["custom_cost_cap"])
+        self.assertNotIn("bid_scene", body)
+
+    def test_aim_placement_uses_automatic_site_without_site_set(self):
+        candidate = _candidate()
+        candidate.automatic_site_enabled = True
+        candidate.site_set = []
+        with patch("tools.ad_creation.get_account_feedback_id", return_value=6700001):
+            body = build_ad_request_body(candidate, begin_date="2026-07-01")
+
+        self.assertTrue(body["automatic_site_enabled"])
+        self.assertNotIn("site_set", body)
+
+    def test_parse_feishu_bid_scene_and_placement(self):
+        self.assertEqual(BID_MODE_AVERAGE_COST, parse_bid_scene(""))
+        self.assertEqual(BID_MODE_MAX_CONVERSION, parse_bid_scene("最大转化量"))
+
+        placement = parse_placement_config(
+            "微信朋友圈,微信公众号与小程序,腾讯平台与内容媒体,腾讯营销联盟"
+        )
+        self.assertFalse(placement.automatic_site_enabled)
+        self.assertEqual(
+            [
+                "SITE_SET_MOMENTS",
+                "SITE_SET_WECHAT",
+                "SITE_SET_WECHAT_PLUGIN",
+                "SITE_SET_TENCENT_NEWS",
+                "SITE_SET_TENCENT_VIDEO",
+                "SITE_SET_KANDIAN",
+                "SITE_SET_QQ_MUSIC_GAME",
+                "SITE_SET_MOBILE_UNION",
+            ],
+            placement.site_set,
+        )
+
+        aim = parse_placement_config("AIM+")
+        self.assertTrue(aim.automatic_site_enabled)
+        self.assertEqual([], aim.site_set)
+
 
 if __name__ == "__main__":
     unittest.main()

+ 1 - 0
examples/auto_put_ad_mini/tools/account_material_strategy.py

@@ -27,6 +27,7 @@ _HISTORY_SOURCE_VALUES = {
     "history",
     "历史",
     "历史素材",
+    "历史投放素材",
     "历史已投素材",
 }
 _TRUE_VALUES = {"1", "true", "yes", "y", "是", "允许", "回退"}

+ 36 - 6
examples/auto_put_ad_mini/tools/ad_creation.py

@@ -51,6 +51,7 @@ from config import (
     # 监测链接 / 反馈 ID
     get_account_feedback_id,
 )
+from tools.delivery_config import BID_MODE_MAX_CONVERSION
 
 logger = logging.getLogger(__name__)
 
@@ -80,6 +81,9 @@ class AdCandidate:
     # 版位定投场景 wechat_position(2026-06-09 1 账户 N 广告差异化机制)
     # None = 无定投(走 site_set 默认全场景)/ list[int] = 勾选具体场景 ID
     wechat_position: Optional[list] = None
+    bid_scene: str = "average_cost"
+    custom_cost_cap_fen: Optional[int] = None
+    automatic_site_enabled: bool = False
 
 
 # ═══════════════════════════════════════════
@@ -94,6 +98,8 @@ def compute_fingerprint(
     age: list,
     geo_regions: list,
     wechat_position: Optional[list] = None,
+    automatic_site_enabled: bool = False,
+    bid_scene: str = "average_cost",
 ) -> str:
     """计算"营销内容指纹"用于本地唯一性预校验。
 
@@ -108,6 +114,8 @@ def compute_fingerprint(
         "age": age,
         "geo_regions": sorted(geo_regions),
         "wechat_position": sorted(wechat_position) if wechat_position else None,
+        "automatic_site_enabled": automatic_site_enabled,
+        "bid_scene": bid_scene,
     }
     return hashlib.md5(
         json.dumps(payload, sort_keys=True, ensure_ascii=False).encode("utf-8")
@@ -194,12 +202,14 @@ def enumerate_new_ad_candidates(
     tier_label = cfg["audience_tier_label"]
     custom_audience = [pack_id] if pack_id else None
     bid_amount_fen = cfg["bid_amount_fen"]
+    automatic_site_enabled = bool(cfg.get("automatic_site_enabled"))
+    bid_scene = cfg.get("bid_scene") or "average_cost"
 
     # 差异化策略(2026-06-09 用户确认 + 2026-06-11 缩减):
     #   广告 #1 wechat_position=None   (无定投,走 site_set 默认全场景)
     #   广告 #2 wechat_position=WECHAT_POSITION_TARGETED_PRESET(公众号内容 + 小程序位置)
     # 同 site_set 同 targeting 但 wechat_position 不同 → 腾讯唯一性绕过(参考 77868332 实测)
-    wechat_position_variants = [None, WECHAT_POSITION_TARGETED_PRESET]
+    wechat_position_variants = [None] if automatic_site_enabled else [None, WECHAT_POSITION_TARGETED_PRESET]
 
     candidates: list[AdCandidate] = []
     site_set = cfg["site_set"]
@@ -214,6 +224,8 @@ def enumerate_new_ad_candidates(
             age=cfg["age"],
             geo_regions=cfg["region_ids"],
             wechat_position=wp,
+            automatic_site_enabled=automatic_site_enabled,
+            bid_scene=bid_scene,
         )
         if fingerprint in existing_fingerprints:
             logger.info(
@@ -233,6 +245,9 @@ def enumerate_new_ad_candidates(
                 site_set=site_set,
                 custom_audience=custom_audience,
                 bid_amount_fen=bid_amount_fen,
+                bid_scene=bid_scene,
+                custom_cost_cap_fen=cfg.get("custom_cost_cap_fen"),
+                automatic_site_enabled=automatic_site_enabled,
                 audience_tier_label=tier_label,
                 age=cfg["age"],
                 location_types=cfg["location_types"],
@@ -312,9 +327,16 @@ def build_ad_request_body(
         "conversion_id": DEFAULT_CONVERSION_ID,
         # === 出价 / 计费(SOP 稳定拿量)===
         "bid_mode": BID_MODE,
-        "smart_bid_type": SMART_BID_TYPE,
         "bid_strategy": BID_STRATEGY,
-        "bid_amount": candidate.bid_amount_fen,
+        "smart_bid_type": (
+            "SMART_BID_TYPE_SYSTEMATIC"
+            if candidate.bid_scene == BID_MODE_MAX_CONVERSION
+            else SMART_BID_TYPE
+        ),
+        "bid_amount": (
+            0 if candidate.bid_scene == BID_MODE_MAX_CONVERSION
+            else candidate.bid_amount_fen
+        ),
         "daily_budget": candidate.daily_budget_fen,
         "auto_acquisition_enabled": AUTO_ACQUISITION_ENABLED,
         "auto_derived_creative_enabled": AUTO_DERIVED_CREATIVE_ENABLED,
@@ -324,8 +346,7 @@ def build_ad_request_body(
         "end_date": _add_years(begin_date, 1),
         "time_series": candidate.time_series,
         # === 版位 ===
-        "automatic_site_enabled": False,
-        "site_set": candidate.site_set,
+        "automatic_site_enabled": candidate.automatic_site_enabled,
         # 搜索场景扩量 · 定向拓展(用户 2026-06-05 确认:关)
         "search_expand_targeting_switch": SEARCH_EXPAND_TARGETING_SWITCH,
         # === 定向 ===
@@ -335,10 +356,19 @@ def build_ad_request_body(
         "smart_targeting_mode": SMART_TARGETING_MODE,
         "targeting": targeting,
     }
+    if not candidate.automatic_site_enabled:
+        body["site_set"] = candidate.site_set
+    if candidate.bid_scene == BID_MODE_MAX_CONVERSION:
+        if candidate.custom_cost_cap_fen is None:
+            raise ValueError(
+                f"account_id {candidate.account_id} 最大转化量缺少 custom_cost_cap_fen"
+            )
+        body["cost_constraint_scene"] = "COST_CONSTRAINT_SCENE_OPEN"
+        body["custom_cost_cap"] = candidate.custom_cost_cap_fen
     # 版位定投场景 wechat_position(2026-06-10 实测修正:POST 时 scene_spec 是顶层字段)
     # 之前误把 scene_spec 放 targeting 内 → 腾讯 reject code 12813 "包含不识别的参数 scene_spec"
     # WebFetch /adgroups/add 文档确认:scene_spec 是顶层 struct,wechat_position 是它的子字段
-    if candidate.wechat_position:
+    if candidate.wechat_position and not candidate.automatic_site_enabled:
         body["scene_spec"] = {"wechat_position": list(candidate.wechat_position)}
 
     if AUTO_ACQUISITION_ENABLED and AUTO_ACQUISITION_BUDGET_FEN:

+ 74 - 0
examples/auto_put_ad_mini/tools/delivery_config.py

@@ -0,0 +1,74 @@
+"""Account-level bidding and placement config parsed from Feishu/DB."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+BID_MODE_AVERAGE_COST = "average_cost"
+BID_MODE_MAX_CONVERSION = "max_conversion"
+
+
+@dataclass(frozen=True)
+class PlacementConfig:
+    automatic_site_enabled: bool
+    site_set: list[str]
+
+
+_BID_SCENE_VALUES = {
+    "": BID_MODE_AVERAGE_COST,
+    "平均成本": BID_MODE_AVERAGE_COST,
+    "稳定成本": BID_MODE_AVERAGE_COST,
+    "最大转化量": BID_MODE_MAX_CONVERSION,
+    "最大转化量出价": BID_MODE_MAX_CONVERSION,
+    "average_cost": BID_MODE_AVERAGE_COST,
+    "max_conversion": BID_MODE_MAX_CONVERSION,
+    # 兼容已经同步过的旧值。
+    "BID_SCENE_NORMAL_AVERAGE": BID_MODE_AVERAGE_COST,
+    "BID_SCENE_NORMAL_MAX": BID_MODE_MAX_CONVERSION,
+}
+
+_PLACEMENT_GROUPS = {
+    "微信朋友圈": ["SITE_SET_MOMENTS"],
+    "朋友圈": ["SITE_SET_MOMENTS"],
+    "微信公众号与小程序": ["SITE_SET_WECHAT", "SITE_SET_WECHAT_PLUGIN"],
+    "微信公众号": ["SITE_SET_WECHAT"],
+    "微信插件": ["SITE_SET_WECHAT_PLUGIN"],
+    "搜索场景": ["SITE_SET_SEARCH_SCENE"],
+    "腾讯平台与内容媒体": [
+        "SITE_SET_TENCENT_NEWS",
+        "SITE_SET_TENCENT_VIDEO",
+        "SITE_SET_KANDIAN",
+        "SITE_SET_QQ_MUSIC_GAME",
+    ],
+    "腾讯营销联盟": ["SITE_SET_MOBILE_UNION"],
+}
+
+
+def parse_bid_scene(raw: object) -> str:
+    text = str(raw or "").strip()
+    if text in _BID_SCENE_VALUES:
+        return _BID_SCENE_VALUES[text]
+    raise ValueError(f"未知出价方式:{raw}")
+
+
+def parse_placement_config(raw: object) -> PlacementConfig | None:
+    text = str(raw or "").strip()
+    if not text:
+        return None
+    if text.upper() == "AIM+":
+        return PlacementConfig(automatic_site_enabled=True, site_set=[])
+
+    site_set: list[str] = []
+    for part in text.replace(",", ",").split(","):
+        name = part.strip()
+        if not name:
+            continue
+        values = _PLACEMENT_GROUPS.get(name)
+        if values is None:
+            raise ValueError(f"未知投放版位:{name}")
+        for value in values:
+            if value not in site_set:
+                site_set.append(value)
+    if not site_set:
+        raise ValueError(f"投放版位为空:{raw}")
+    return PlacementConfig(automatic_site_enabled=False, site_set=site_set)

+ 18 - 9
examples/auto_put_ad_mini/tools/im_approval_ad_creation.py

@@ -33,34 +33,35 @@ from tools.feishu_doc import (
     import_to_feishu,
 )
 from tools.scene_spec import describe_position_ids
+from tools.delivery_config import BID_MODE_MAX_CONVERSION
 
 logger = logging.getLogger(__name__)
 
 FEISHU_BASE_URL = "https://open.feishu.cn/open-apis"
 
-# 9 列(中文)
+# 11 列(中文)
 HEADERS = [
     # A 浅灰 4 列
     "日期", "账户ID", "人群包", "广告名称",
-    # B 浅紫 4 列(广告维度)
-    "投放版位", "版位定投场景", "出价(元)", "年龄定向",
+    # B 浅紫 6 列(广告维度)
+    "投放版位", "版位定投场景", "出价方式", "出价(元)", "智能版位", "年龄定向",
     # C 浅绿 1 列
     "决策",
 ]
 
 GROUP_COLORS = [
     ((1, 4), "FFD9D9D9"),    # A 浅灰
-    ((5, 8), "FFD9C8E8"),    # B 浅紫
-    ((9, 9), "FFC6E0B4"),    # C 浅绿
+    ((5, 10), "FFD9C8E8"),   # B 浅紫
+    ((11, 11), "FFC6E0B4"),  # C 浅绿
 ]
 
 COL_WIDTHS = {
     "A": 12, "B": 14, "C": 16, "D": 32,
-    "E": 36, "F": 50, "G": 10, "H": 12,
-    "I": 14,
+    "E": 36, "F": 50, "G": 18, "H": 10,
+    "I": 12, "J": 12, "K": 14,
 }
 
-DECISION_COL_LETTER = "I"  # 第 9
+DECISION_COL_LETTER = "K"  # 第 11
 VALID_ACTIONS = ("approve", "reject", "hold")
 
 
@@ -85,10 +86,16 @@ def _format_site_set_cn(site_set: list) -> str:
 
 def _format_record_to_row(rec: dict) -> list:
     """rec 是 ad_creation.AdCandidate 的 dict 形式 + 加 approval_date / audience_tier / age_range 等"""
-    site_set_cn = _format_site_set_cn(rec.get("site_set") or [])
+    automatic_site_enabled = bool(rec.get("automatic_site_enabled"))
+    site_set_cn = "AIM+" if automatic_site_enabled else _format_site_set_cn(rec.get("site_set") or [])
     wp_ids = rec.get("wechat_position") or []
     wp_cn = describe_position_ids(int(rec["account_id"]), wp_ids)
     bid_yuan = f"{rec['bid_amount_fen'] / 100:.2f}" if rec.get("bid_amount_fen") else ""
+    bid_scene_cn = (
+        "最大转化量"
+        if rec.get("bid_scene") == BID_MODE_MAX_CONVERSION
+        else "平均成本"
+    )
 
     return [
         rec["approval_date"],
@@ -97,7 +104,9 @@ def _format_record_to_row(rec: dict) -> list:
         rec.get("adgroup_name", ""),
         site_set_cn,
         wp_cn,
+        bid_scene_cn,
         bid_yuan,
+        "是" if automatic_site_enabled else "否",
         rec.get("age_range", ""),
         "",  # 决策列空,等运营填
     ]