Selaa lähdekoodia

广告创建 飞书表地域设置支持

wangyunpeng 6 tuntia sitten
vanhempi
commit
d68af252f7

+ 2 - 0
examples/auto_put_ad_mini/PROJECT_STRUCTURE.md

@@ -38,6 +38,7 @@ examples/auto_put_ad_mini/
 │   ├── run_full_analysis.py            # 仅数据准备步骤(无 LLM,用于调试)
 │   ├── fetch_data.py                   # 独立数据拉取脚本
 │   ├── merge_data.py                   # 独立数据合并脚本
+│   ├── setup_region_dropdown.py        # 设置飞书地域列下拉多选(选项来自 regions_all.json)
 │   └── logging_setup.py                # ★统一日志基础设施(SLS + 本地文件)
 ├── 🟢 生产服务
@@ -565,6 +566,7 @@ Flow D 的核心差异化能力之一:
 | `run_full_analysis.py` | 调试工具 | 仅数据准备(无 LLM) | 开发者手动 |
 | `fetch_data.py` | 独立工具 | 仅拉取 ODPS 数据 | 开发者手动 |
 | `merge_data.py` | 独立工具 | 仅合并数据 | 开发者手动 |
+| `setup_region_dropdown.py` | 独立工具 | 设置飞书地域列下拉多选(选项来自 `regions_all.json`) | 运营/开发手动 |
 | `server.py` | 常驻服务 | 旧版 Flow A 调度(FastAPI + APScheduler) | 历史保留,非当前生产入口 |
 | `scan_creative_reviews.py` | 硬编码脚本 | **Flow C 审核扫描** | run_daily_service.py / K8s CronJob |
 | `refresh_roi_fission_multiplier.py` | 硬编码脚本 | **T15 成熟系数刷新** | 手动或 Cron |

+ 279 - 0
examples/auto_put_ad_mini/setup_region_dropdown.py

@@ -0,0 +1,279 @@
+"""给飞书「自动化账户」表地域列设置下拉多选(选项来自 regions_all.json)。
+
+用法:
+    cd examples/auto_put_ad_mini
+    python3 setup_region_dropdown.py             # 设置下拉(幂等,可重复跑)
+    python3 setup_region_dropdown.py --dry-run   # 只打印选项,不调飞书
+
+选项集(顺序):
+    1. 不限(全国) / 排地域(回退投放模板) / 中国未知(156)
+    2. 其余省级/直辖市/特别行政区按拼音排序(依赖 pypinyin,缺失回退 Unicode)
+    排除: 市级/区级、国外(9000)、中国(1156)
+
+说明:
+    本脚本刻意保持自包含(仅依赖 httpx + python-dotenv),不引入 agent 框架,
+    方便在任意环境独立执行。token 获取逻辑与 tools/feishu_doc 保持一致。
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import time
+from pathlib import Path
+
+import httpx
+from dotenv import load_dotenv
+
+# 拼音排序依赖(可选):未安装时回退到 Unicode 排序
+try:
+    from pypinyin import lazy_pinyin as _lazy_pinyin
+except ImportError:
+    _lazy_pinyin = None
+
+_HERE = Path(__file__).parent
+load_dotenv(_HERE / ".env")
+
+# 飞书「自动化账户」表
+DEFAULT_SPREADSHEET_TOKEN = "D8f4sKEb2hfBnNttX1ucZTklnHf"
+DEFAULT_SHEET_ID = "y3uCcz"
+DEFAULT_RANGE = "A1:N300"
+
+FEISHU_BASE_URL = "https://open.feishu.cn/open-apis"
+_REGION_DICTIONARY_PATH = (
+    _HERE / "data" / "tencent_constants" / "regions_all.json"
+)
+
+# 顶层节点中排除的 id: 国外(9000)、中国(1156,整包节点,与"不限"语义重叠)
+_EXCLUDED_TOP_IDS = {9000, 1156}
+# 固定前导项,保持 parse_geo_location_config 语义(不限→全国 / 排地域→模板),
+# 中国未知(156)紧随其后,其余省级按名称排序。
+_SPECIAL_OPTIONS = ["不限", "排地域", "中国未知"]
+_EXCLUDED_NAMES = {"中国未知"}
+# 下拉选项胶囊颜色(与 conditionValues 一一对应;highlightValidData=true 时飞书强制要求)
+_COLOR_PALETTE = [
+    "#1FB6C1", "#F006C2", "#FB16C3", "#FFB6C1", "#A6D6FF",
+    "#FFB84D", "#4CD3A0", "#8A7DFF", "#FF8FA3", "#5B8DEF",
+    "#F7B500", "#00C2A8", "#B08CF9", "#FF7A45", "#36CFC9",
+    "#597EF7", "#F759AB", "#7CB305", "#FAAD14", "#13C2C2",
+]
+# 表头在第 2 行,数据从第 3 行开始
+HEADER_ROW_INDEX = 1
+DATA_START_INDEX = 2
+# 地域列下拉覆盖到第 300 行(与 sync 的 DEFAULT_RANGE A1:N300 对齐)
+MAX_ROW = 300
+
+# tenant token 缓存
+_token_cache: dict = {}
+_TOKEN_TTL = 1500  # 25 分钟
+
+
+def _get_tenant_token() -> str:
+    """获取飞书 tenant_access_token(带缓存,逻辑同 tools.feishu_doc)。"""
+    cached = _token_cache.get("tenant")
+    if cached and time.time() - cached["ts"] < _TOKEN_TTL:
+        return cached["token"]
+    app_id = os.getenv("FEISHU_APP_ID", "cli_a955e97067f85cb3")
+    app_secret = os.getenv("FEISHU_APP_SECRET", "NQaG4ci1plXRDTgwCqrLJgMLLoA2tdF8")
+    resp = httpx.post(
+        f"{FEISHU_BASE_URL}/auth/v3/tenant_access_token/internal",
+        json={"app_id": app_id, "app_secret": app_secret},
+        timeout=30,
+    )
+    data = resp.json()
+    if data.get("code") != 0:
+        raise RuntimeError(f"获取飞书 token 失败: {data}")
+    token = data["tenant_access_token"]
+    _token_cache["tenant"] = {"token": token, "ts": time.time()}
+    return token
+
+
+def _auth_headers(token: str) -> dict:
+    return {"Authorization": f"Bearer {token}"}
+
+
+def _pinyin_sort_key(name: str) -> str:
+    """按拼音排序键;pypinyin 缺失时回退 Unicode 码位排序。"""
+    if _lazy_pinyin is not None:
+        return "".join(_lazy_pinyin(name))
+    return name
+
+
+def build_region_options() -> list[str]:
+    """构建下拉选项:不限、排地域、中国未知固定在前,其余省级按拼音排序。"""
+    regions = json.loads(_REGION_DICTIONARY_PATH.read_text(encoding="utf-8"))
+    names: list[str] = []
+    for r in regions:
+        if r.get("parent_id") != 0:
+            continue
+        rid = int(r["id"])
+        if rid in _EXCLUDED_TOP_IDS:
+            continue
+        name = str(r.get("name") or "").strip()
+        if not name or name in _EXCLUDED_NAMES:
+            continue
+        names.append(name)
+    names.sort(key=_pinyin_sort_key)
+    return _SPECIAL_OPTIONS + names
+
+
+def _column_letter(idx: int) -> str:
+    """0-based 列索引转列字母(A→0,Z→25,AA→26)。"""
+    letters = ""
+    idx += 1
+    while idx:
+        idx, rem = divmod(idx - 1, 26)
+        letters = chr(65 + rem) + letters
+    return letters
+
+
+def _cell_text(value) -> str:
+    """飞书单元格值(可能是富文本 list/dict)转纯文本。"""
+    if value is None:
+        return ""
+    if isinstance(value, list):
+        parts = []
+        for item in value:
+            if isinstance(item, dict):
+                parts.append(str(item.get("text") or item.get("link") or ""))
+            else:
+                parts.append(str(item))
+        return "".join(parts).strip()
+    if isinstance(value, dict):
+        return str(value.get("text") or value.get("link") or "").strip()
+    return str(value).strip()
+
+
+def _get_sheet_row_count(spreadsheet_token: str, sheet_id: str) -> int:
+    """查询 sheet 的实际网格行数(数据验证 range 不能超出网格)。"""
+    token = _get_tenant_token()
+    resp = httpx.get(
+        f"{FEISHU_BASE_URL}/sheets/v2/spreadsheets/{spreadsheet_token}/metainfo",
+        headers=_auth_headers(token),
+        timeout=30,
+    )
+    resp.raise_for_status()
+    data = resp.json()
+    if data.get("code") != 0:
+        raise RuntimeError(f"读取表格元数据失败: {json.dumps(data, ensure_ascii=False)[:500]}")
+    for s in (data.get("data") or {}).get("sheets") or []:
+        if s.get("sheetId") == sheet_id:
+            return int(s.get("rowCount") or 0)
+    raise RuntimeError(f"未找到 sheet: {sheet_id}")
+
+
+def _find_geo_column_letter(
+    spreadsheet_token: str, sheet_id: str, cell_range: str
+) -> str:
+    """读取表头,返回地域列字母(优先「地域」,回退「投放地域」)。"""
+    token = _get_tenant_token()
+    url = (
+        f"{FEISHU_BASE_URL}/sheets/v2/spreadsheets/{spreadsheet_token}/values/"
+        f"{sheet_id}!{cell_range}"
+    )
+    resp = httpx.get(url, headers=_auth_headers(token), timeout=30)
+    resp.raise_for_status()
+    data = resp.json()
+    if data.get("code") != 0:
+        raise RuntimeError(f"读取飞书配置表失败: {json.dumps(data, ensure_ascii=False)[:500]}")
+    values = (data.get("data") or {}).get("valueRange") or {}
+    rows = values.get("values") or []
+    if len(rows) <= HEADER_ROW_INDEX:
+        raise RuntimeError("飞书配置表缺少表头行")
+    headers = [_cell_text(v) for v in rows[HEADER_ROW_INDEX]]
+    mapping = {name: idx for idx, name in enumerate(headers) if name}
+    col = "地域" if "地域" in mapping else "投放地域"
+    if col not in mapping:
+        raise RuntimeError(f"飞书表缺少地域列; 当前表头={headers}")
+    return _column_letter(mapping[col])
+
+
+def _set_dropdown(
+    spreadsheet_token: str,
+    sheet_id: str,
+    col_letter: str,
+    options: list[str],
+    row_count: int,
+    dry_run: bool = False,
+) -> dict:
+    """幂等设置下拉:PUT 更新接口同时支持创建/更新,直接覆盖目标 range。"""
+    end_row = min(max(row_count, DATA_START_INDEX + 1), MAX_ROW)
+    data_range = f"{sheet_id}!{col_letter}{DATA_START_INDEX + 1}:{col_letter}{end_row}"
+
+    if dry_run:
+        return {"range": data_range, "options": len(options), "dry_run": True}
+
+    token = _get_tenant_token()
+    headers = _auth_headers(token)
+    url = (
+        f"{FEISHU_BASE_URL}/sheets/v2/spreadsheets/{spreadsheet_token}"
+        f"/dataValidation/{sheet_id}"
+    )
+
+    # highlightValidData=true 时 colors 必须与选项一一对应
+    colors = [_COLOR_PALETTE[i % len(_COLOR_PALETTE)] for i in range(len(options))]
+    body = {
+        "ranges": [data_range],
+        "dataValidationType": "list",
+        "dataValidation": {
+            "conditionValues": options,
+            "options": {
+                "multipleValues": True,
+                "highlightValidData": True,
+                "colors": colors,
+            },
+        },
+    }
+    resp = httpx.put(
+        url,
+        headers={**headers, "Content-Type": "application/json"},
+        json=body,
+        timeout=30,
+    )
+    data = resp.json()
+    if data.get("code") != 0:
+        raise RuntimeError(f"设置下拉失败: {json.dumps(data, ensure_ascii=False)[:500]}")
+    return {"range": data_range, "options": len(options), "dry_run": False}
+
+
+def main() -> int:
+    parser = argparse.ArgumentParser(description="给飞书「自动化账户」表地域列设置下拉多选")
+    parser.add_argument("--spreadsheet-token", default=DEFAULT_SPREADSHEET_TOKEN)
+    parser.add_argument("--sheet-id", default=DEFAULT_SHEET_ID)
+    parser.add_argument("--range", default=DEFAULT_RANGE)
+    parser.add_argument("--dry-run", action="store_true", help="只打印选项,不调用飞书 API")
+    args = parser.parse_args()
+
+    options = build_region_options()
+    print(f"下拉选项共 {len(options)} 个:")
+    for i, name in enumerate(options, start=1):
+        print(f"  {i:2d}. {name}")
+
+    col_letter = _find_geo_column_letter(
+        args.spreadsheet_token, args.sheet_id, args.range
+    )
+    print(f"地域列: {col_letter}")
+
+    row_count = _get_sheet_row_count(args.spreadsheet_token, args.sheet_id)
+    print(f"表格行数: {row_count}")
+
+    result = _set_dropdown(
+        args.spreadsheet_token,
+        args.sheet_id,
+        col_letter,
+        options,
+        row_count=row_count,
+        dry_run=args.dry_run,
+    )
+    if result.get("dry_run"):
+        print("(dry-run) 未调用飞书 API。正式设置请去掉 --dry-run 重新运行。")
+    else:
+        print(
+            f"✅ 下拉已设置: range={result['range']} 选项数={result['options']}"
+        )
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

+ 22 - 1
examples/auto_put_ad_mini/sync_feishu_account_config.py

@@ -113,6 +113,22 @@ def _plain_value(value: Any) -> str:
     return str(value).strip()
 
 
+def _geo_cell_value(value: Any) -> str:
+    """地域列专用:多选下拉单元格可能返回逗号拼接字符串或数组,统一成逗号分隔文本。
+
+    _plain_value 对 list 是无分隔符拼接(富文本场景),而多选下拉的 list 是多个
+    独立选项,必须用逗号分隔才能被 parse_geo_location_config 正确拆分。
+    """
+    if isinstance(value, list):
+        parts = [
+            _plain_value(item).strip()
+            for item in value
+            if _plain_value(item).strip()
+        ]
+        return ",".join(parts)
+    return _plain_value(value)
+
+
 def _fetch_rows(spreadsheet_token: str, sheet_id: str, cell_range: str) -> list[list[Any]]:
     """读取飞书表格指定范围的原始行数据"""
     token = _get_tenant_token()
@@ -282,7 +298,12 @@ def sync_from_feishu(
             )
             age_text = _cell(row, mapping["年龄"]) if "年龄" in mapping else ""
             geo_col = "地域" if "地域" in mapping else "投放地域"
-            geo_text = _cell(row, mapping[geo_col]) if geo_col in mapping else ""
+            if geo_col in mapping:
+                geo_idx = mapping[geo_col]
+                geo_raw = row[geo_idx] if geo_idx < len(row) else None
+                geo_text = _geo_cell_value(geo_raw)
+            else:
+                geo_text = ""
             bid_min, bid_max, bid_amount = parse_bid(bid_text)
             bid_scene = parse_bid_scene(bid_scene_text)
             custom_cost_cap_fen = None

+ 23 - 7
examples/auto_put_ad_mini/tools/delivery_config.py

@@ -59,6 +59,10 @@ REGION_DICTIONARY_PATH = (
     / "regions_all.json"
 )
 
+# 地域特殊项(多选混选时的解析优先级,均小写比较)
+_UNLIMITED_KEYS = {"不限", "不限地域", "全国", "all"}   # 全国不限制地域
+_TEMPLATE_KEYS = {"排地域", "默认地域"}                  # 使用投放模板默认地域
+
 
 def validate_region_dictionary() -> Path:
     """Fail fast when the versioned Tencent region asset is missing or malformed."""
@@ -126,19 +130,31 @@ def _region_name_mapping() -> dict[str, int]:
 
 
 def parse_geo_location_config(raw: object) -> GeoLocationConfig | None:
-    """Parse Feishu geography: blank keeps the delivery template; 不限 omits geo targeting."""
+    """Parse Feishu geography: blank keeps the delivery template; 不限 omits geo targeting.
+
+    多选下拉可能把特殊项与具体地域混选,这里做安全解析(2026-08-10 用户确认):
+      - 含「不限/不限地域/全国/all」→ 全国优先,忽略其他项
+      - 否则含「排地域/默认地域」→ 使用投放模板(None),忽略其他项
+      - 否则按具体地域解析;未知地域名仍报错(fail-fast 防错别字)
+    """
     text = str(raw or "").strip()
-    if not text or text in {"排地域", "默认地域"}:
+    if not text:
         return None
-    if text.lower() in {"不限", "不限地域", "全国", "all"}:
+    parts = [part.strip() for part in text.replace(",", ",").split(",") if part.strip()]
+    if not parts:
+        return None
+
+    lowered = {p.lower() for p in parts}
+    if lowered & _UNLIMITED_KEYS:
+        # 只要选了「不限」就按全国处理(不限是超集,忽略其余混选项)
         return GeoLocationConfig(location_types=[], region_ids=[])
+    if lowered & _TEMPLATE_KEYS:
+        # 选了「排地域/默认地域」就按投放模板处理(不覆盖模板地域)
+        return None
 
-    names = [part.strip() for part in text.replace(",", ",").split(",") if part.strip()]
-    if not names:
-        raise ValueError(f"地域为空:{raw}")
     name_mapping = _region_name_mapping()
     region_ids: list[int] = []
-    for name in names:
+    for name in parts:
         try:
             region_id = int(name)
         except ValueError: