setup_region_dropdown.py 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279
  1. """给飞书「自动化账户」表地域列设置下拉多选(选项来自 regions_all.json)。
  2. 用法:
  3. cd examples/auto_put_ad_mini
  4. python3 setup_region_dropdown.py # 设置下拉(幂等,可重复跑)
  5. python3 setup_region_dropdown.py --dry-run # 只打印选项,不调飞书
  6. 选项集(顺序):
  7. 1. 不限(全国) / 排地域(回退投放模板) / 中国未知(156)
  8. 2. 其余省级/直辖市/特别行政区按拼音排序(依赖 pypinyin,缺失回退 Unicode)
  9. 排除: 市级/区级、国外(9000)、中国(1156)
  10. 说明:
  11. 本脚本刻意保持自包含(仅依赖 httpx + python-dotenv),不引入 agent 框架,
  12. 方便在任意环境独立执行。token 获取逻辑与 tools/feishu_doc 保持一致。
  13. """
  14. from __future__ import annotations
  15. import argparse
  16. import json
  17. import os
  18. import time
  19. from pathlib import Path
  20. import httpx
  21. from dotenv import load_dotenv
  22. # 拼音排序依赖(可选):未安装时回退到 Unicode 排序
  23. try:
  24. from pypinyin import lazy_pinyin as _lazy_pinyin
  25. except ImportError:
  26. _lazy_pinyin = None
  27. _HERE = Path(__file__).parent
  28. load_dotenv(_HERE / ".env")
  29. # 飞书「自动化账户」表
  30. DEFAULT_SPREADSHEET_TOKEN = "D8f4sKEb2hfBnNttX1ucZTklnHf"
  31. DEFAULT_SHEET_ID = "y3uCcz"
  32. DEFAULT_RANGE = "A1:N300"
  33. FEISHU_BASE_URL = "https://open.feishu.cn/open-apis"
  34. _REGION_DICTIONARY_PATH = (
  35. _HERE / "data" / "tencent_constants" / "regions_all.json"
  36. )
  37. # 顶层节点中排除的 id: 国外(9000)、中国(1156,整包节点,与"不限"语义重叠)
  38. _EXCLUDED_TOP_IDS = {9000, 1156}
  39. # 固定前导项,保持 parse_geo_location_config 语义(不限→全国 / 排地域→模板),
  40. # 中国未知(156)紧随其后,其余省级按名称排序。
  41. _SPECIAL_OPTIONS = ["不限", "排地域", "中国未知"]
  42. _EXCLUDED_NAMES = {"中国未知"}
  43. # 下拉选项胶囊颜色(与 conditionValues 一一对应;highlightValidData=true 时飞书强制要求)
  44. _COLOR_PALETTE = [
  45. "#1FB6C1", "#F006C2", "#FB16C3", "#FFB6C1", "#A6D6FF",
  46. "#FFB84D", "#4CD3A0", "#8A7DFF", "#FF8FA3", "#5B8DEF",
  47. "#F7B500", "#00C2A8", "#B08CF9", "#FF7A45", "#36CFC9",
  48. "#597EF7", "#F759AB", "#7CB305", "#FAAD14", "#13C2C2",
  49. ]
  50. # 表头在第 2 行,数据从第 3 行开始
  51. HEADER_ROW_INDEX = 1
  52. DATA_START_INDEX = 2
  53. # 地域列下拉覆盖到第 300 行(与 sync 的 DEFAULT_RANGE A1:N300 对齐)
  54. MAX_ROW = 300
  55. # tenant token 缓存
  56. _token_cache: dict = {}
  57. _TOKEN_TTL = 1500 # 25 分钟
  58. def _get_tenant_token() -> str:
  59. """获取飞书 tenant_access_token(带缓存,逻辑同 tools.feishu_doc)。"""
  60. cached = _token_cache.get("tenant")
  61. if cached and time.time() - cached["ts"] < _TOKEN_TTL:
  62. return cached["token"]
  63. app_id = os.getenv("FEISHU_APP_ID", "cli_a955e97067f85cb3")
  64. app_secret = os.getenv("FEISHU_APP_SECRET", "NQaG4ci1plXRDTgwCqrLJgMLLoA2tdF8")
  65. resp = httpx.post(
  66. f"{FEISHU_BASE_URL}/auth/v3/tenant_access_token/internal",
  67. json={"app_id": app_id, "app_secret": app_secret},
  68. timeout=30,
  69. )
  70. data = resp.json()
  71. if data.get("code") != 0:
  72. raise RuntimeError(f"获取飞书 token 失败: {data}")
  73. token = data["tenant_access_token"]
  74. _token_cache["tenant"] = {"token": token, "ts": time.time()}
  75. return token
  76. def _auth_headers(token: str) -> dict:
  77. return {"Authorization": f"Bearer {token}"}
  78. def _pinyin_sort_key(name: str) -> str:
  79. """按拼音排序键;pypinyin 缺失时回退 Unicode 码位排序。"""
  80. if _lazy_pinyin is not None:
  81. return "".join(_lazy_pinyin(name))
  82. return name
  83. def build_region_options() -> list[str]:
  84. """构建下拉选项:不限、排地域、中国未知固定在前,其余省级按拼音排序。"""
  85. regions = json.loads(_REGION_DICTIONARY_PATH.read_text(encoding="utf-8"))
  86. names: list[str] = []
  87. for r in regions:
  88. if r.get("parent_id") != 0:
  89. continue
  90. rid = int(r["id"])
  91. if rid in _EXCLUDED_TOP_IDS:
  92. continue
  93. name = str(r.get("name") or "").strip()
  94. if not name or name in _EXCLUDED_NAMES:
  95. continue
  96. names.append(name)
  97. names.sort(key=_pinyin_sort_key)
  98. return _SPECIAL_OPTIONS + names
  99. def _column_letter(idx: int) -> str:
  100. """0-based 列索引转列字母(A→0,Z→25,AA→26)。"""
  101. letters = ""
  102. idx += 1
  103. while idx:
  104. idx, rem = divmod(idx - 1, 26)
  105. letters = chr(65 + rem) + letters
  106. return letters
  107. def _cell_text(value) -> str:
  108. """飞书单元格值(可能是富文本 list/dict)转纯文本。"""
  109. if value is None:
  110. return ""
  111. if isinstance(value, list):
  112. parts = []
  113. for item in value:
  114. if isinstance(item, dict):
  115. parts.append(str(item.get("text") or item.get("link") or ""))
  116. else:
  117. parts.append(str(item))
  118. return "".join(parts).strip()
  119. if isinstance(value, dict):
  120. return str(value.get("text") or value.get("link") or "").strip()
  121. return str(value).strip()
  122. def _get_sheet_row_count(spreadsheet_token: str, sheet_id: str) -> int:
  123. """查询 sheet 的实际网格行数(数据验证 range 不能超出网格)。"""
  124. token = _get_tenant_token()
  125. resp = httpx.get(
  126. f"{FEISHU_BASE_URL}/sheets/v2/spreadsheets/{spreadsheet_token}/metainfo",
  127. headers=_auth_headers(token),
  128. timeout=30,
  129. )
  130. resp.raise_for_status()
  131. data = resp.json()
  132. if data.get("code") != 0:
  133. raise RuntimeError(f"读取表格元数据失败: {json.dumps(data, ensure_ascii=False)[:500]}")
  134. for s in (data.get("data") or {}).get("sheets") or []:
  135. if s.get("sheetId") == sheet_id:
  136. return int(s.get("rowCount") or 0)
  137. raise RuntimeError(f"未找到 sheet: {sheet_id}")
  138. def _find_geo_column_letter(
  139. spreadsheet_token: str, sheet_id: str, cell_range: str
  140. ) -> str:
  141. """读取表头,返回地域列字母(优先「地域」,回退「投放地域」)。"""
  142. token = _get_tenant_token()
  143. url = (
  144. f"{FEISHU_BASE_URL}/sheets/v2/spreadsheets/{spreadsheet_token}/values/"
  145. f"{sheet_id}!{cell_range}"
  146. )
  147. resp = httpx.get(url, headers=_auth_headers(token), timeout=30)
  148. resp.raise_for_status()
  149. data = resp.json()
  150. if data.get("code") != 0:
  151. raise RuntimeError(f"读取飞书配置表失败: {json.dumps(data, ensure_ascii=False)[:500]}")
  152. values = (data.get("data") or {}).get("valueRange") or {}
  153. rows = values.get("values") or []
  154. if len(rows) <= HEADER_ROW_INDEX:
  155. raise RuntimeError("飞书配置表缺少表头行")
  156. headers = [_cell_text(v) for v in rows[HEADER_ROW_INDEX]]
  157. mapping = {name: idx for idx, name in enumerate(headers) if name}
  158. col = "地域" if "地域" in mapping else "投放地域"
  159. if col not in mapping:
  160. raise RuntimeError(f"飞书表缺少地域列; 当前表头={headers}")
  161. return _column_letter(mapping[col])
  162. def _set_dropdown(
  163. spreadsheet_token: str,
  164. sheet_id: str,
  165. col_letter: str,
  166. options: list[str],
  167. row_count: int,
  168. dry_run: bool = False,
  169. ) -> dict:
  170. """幂等设置下拉:PUT 更新接口同时支持创建/更新,直接覆盖目标 range。"""
  171. end_row = min(max(row_count, DATA_START_INDEX + 1), MAX_ROW)
  172. data_range = f"{sheet_id}!{col_letter}{DATA_START_INDEX + 1}:{col_letter}{end_row}"
  173. if dry_run:
  174. return {"range": data_range, "options": len(options), "dry_run": True}
  175. token = _get_tenant_token()
  176. headers = _auth_headers(token)
  177. url = (
  178. f"{FEISHU_BASE_URL}/sheets/v2/spreadsheets/{spreadsheet_token}"
  179. f"/dataValidation/{sheet_id}"
  180. )
  181. # highlightValidData=true 时 colors 必须与选项一一对应
  182. colors = [_COLOR_PALETTE[i % len(_COLOR_PALETTE)] for i in range(len(options))]
  183. body = {
  184. "ranges": [data_range],
  185. "dataValidationType": "list",
  186. "dataValidation": {
  187. "conditionValues": options,
  188. "options": {
  189. "multipleValues": True,
  190. "highlightValidData": True,
  191. "colors": colors,
  192. },
  193. },
  194. }
  195. resp = httpx.put(
  196. url,
  197. headers={**headers, "Content-Type": "application/json"},
  198. json=body,
  199. timeout=30,
  200. )
  201. data = resp.json()
  202. if data.get("code") != 0:
  203. raise RuntimeError(f"设置下拉失败: {json.dumps(data, ensure_ascii=False)[:500]}")
  204. return {"range": data_range, "options": len(options), "dry_run": False}
  205. def main() -> int:
  206. parser = argparse.ArgumentParser(description="给飞书「自动化账户」表地域列设置下拉多选")
  207. parser.add_argument("--spreadsheet-token", default=DEFAULT_SPREADSHEET_TOKEN)
  208. parser.add_argument("--sheet-id", default=DEFAULT_SHEET_ID)
  209. parser.add_argument("--range", default=DEFAULT_RANGE)
  210. parser.add_argument("--dry-run", action="store_true", help="只打印选项,不调用飞书 API")
  211. args = parser.parse_args()
  212. options = build_region_options()
  213. print(f"下拉选项共 {len(options)} 个:")
  214. for i, name in enumerate(options, start=1):
  215. print(f" {i:2d}. {name}")
  216. col_letter = _find_geo_column_letter(
  217. args.spreadsheet_token, args.sheet_id, args.range
  218. )
  219. print(f"地域列: {col_letter}")
  220. row_count = _get_sheet_row_count(args.spreadsheet_token, args.sheet_id)
  221. print(f"表格行数: {row_count}")
  222. result = _set_dropdown(
  223. args.spreadsheet_token,
  224. args.sheet_id,
  225. col_letter,
  226. options,
  227. row_count=row_count,
  228. dry_run=args.dry_run,
  229. )
  230. if result.get("dry_run"):
  231. print("(dry-run) 未调用飞书 API。正式设置请去掉 --dry-run 重新运行。")
  232. else:
  233. print(
  234. f"✅ 下拉已设置: range={result['range']} 选项数={result['options']}"
  235. )
  236. return 0
  237. if __name__ == "__main__":
  238. raise SystemExit(main())