sino-bc-cli 1.0.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/README.md +256 -0
  2. package/dist/affordance/auth.md +31 -0
  3. package/dist/affordance/bill.md +48 -0
  4. package/dist/affordance/biz-line-ai-strategy.md +35 -0
  5. package/dist/affordance/biz-line-metrics.md +47 -0
  6. package/dist/affordance/biz-line.md +84 -0
  7. package/dist/affordance/common-file.md +25 -0
  8. package/dist/affordance/dashboard.md +75 -0
  9. package/dist/affordance/dict.md +28 -0
  10. package/dist/affordance/document.md +67 -0
  11. package/dist/affordance/im.md +91 -0
  12. package/dist/affordance/invoice.md +49 -0
  13. package/dist/affordance/media.md +17 -0
  14. package/dist/affordance/organization.md +47 -0
  15. package/dist/affordance/project-analysis.md +95 -0
  16. package/dist/affordance/project.md +321 -0
  17. package/dist/affordance/quota.md +19 -0
  18. package/dist/affordance/reconciliation.md +41 -0
  19. package/dist/affordance/report.md +75 -0
  20. package/dist/affordance/sso-team-group.md +30 -0
  21. package/dist/affordance/task.md +148 -0
  22. package/dist/affordance/tenant.md +17 -0
  23. package/dist/affordance/user.md +41 -0
  24. package/dist/affordance/wallet.md +100 -0
  25. package/dist/api-doc/README.md +67 -0
  26. package/dist/api-doc/custom/template.md +0 -0
  27. package/dist/api-doc/oapi/auth.md +61 -0
  28. package/dist/api-doc/oapi/bill.md +130 -0
  29. package/dist/api-doc/oapi/biz-line-ai-strategy.md +119 -0
  30. package/dist/api-doc/oapi/biz-line-metrics.md +419 -0
  31. package/dist/api-doc/oapi/biz-line.md +382 -0
  32. package/dist/api-doc/oapi/common-file.md +50 -0
  33. package/dist/api-doc/oapi/dashboard.md +218 -0
  34. package/dist/api-doc/oapi/dict.md +49 -0
  35. package/dist/api-doc/oapi/document.md +182 -0
  36. package/dist/api-doc/oapi/im.md +278 -0
  37. package/dist/api-doc/oapi/invoice.md +129 -0
  38. package/dist/api-doc/oapi/media.md +17 -0
  39. package/dist/api-doc/oapi/organization.md +94 -0
  40. package/dist/api-doc/oapi/project-analysis.md +364 -0
  41. package/dist/api-doc/oapi/project.md +1114 -0
  42. package/dist/api-doc/oapi/quota.md +21 -0
  43. package/dist/api-doc/oapi/reconciliation.md +98 -0
  44. package/dist/api-doc/oapi/report.md +254 -0
  45. package/dist/api-doc/oapi/sso-team-group.md +54 -0
  46. package/dist/api-doc/oapi/task.md +528 -0
  47. package/dist/api-doc/oapi/tenant.md +17 -0
  48. package/dist/api-doc/oapi/user.md +98 -0
  49. package/dist/api-doc/oapi/wallet.md +275 -0
  50. package/dist/generated/api-catalog.json +39681 -0
  51. package/dist/generated/api-directory-index.json +1509 -0
  52. package/dist/index.js +2 -0
  53. package/dist/skill-manifest.json +336 -0
  54. package/dist/skills/biz-line-strategy-generate/SKILL.md +122 -0
  55. package/dist/skills/biz-line-strategy-generate/references/ai-strategy-prompt.md +50 -0
  56. package/dist/skills/biz-line-strategy-generate/references/api-flow.md +155 -0
  57. package/dist/skills/biz-line-strategy-generate/references/four-strategies.md +58 -0
  58. package/dist/skills/biz-line-strategy-generate/references/metrics.md +119 -0
  59. package/dist/skills/biz-line-strategy-generate/references/output-excel.md +63 -0
  60. package/dist/skills/biz-line-strategy-generate/references/runtime.md +127 -0
  61. package/dist/skills/biz-line-strategy-generate/references/spec.md +500 -0
  62. package/dist/skills/biz-line-strategy-generate/scripts/ai_strategy.py +89 -0
  63. package/dist/skills/biz-line-strategy-generate/scripts/build_report.py +629 -0
  64. package/dist/skills/biz-line-strategy-generate/scripts/cleanup.py +87 -0
  65. package/dist/skills/biz-line-strategy-generate/scripts/cli_json.py +705 -0
  66. package/dist/skills/biz-line-strategy-generate/scripts/fetch_data.py +449 -0
  67. package/dist/skills/biz-line-strategy-generate/scripts/run_workflow.py +402 -0
  68. package/dist/skills/biz-line-strategy-generate/scripts/strategy_output.py +556 -0
  69. package/dist/skills/biz-line-strategy-generate/scripts/submit_tags.py +32 -0
  70. package/dist/skills/biz-line-strategy-generate/scripts/summarize.py +932 -0
  71. package/dist/skills/biz-line-strategy-generate/scripts/test_ai_strategy.py +235 -0
  72. package/dist/skills/biz-line-strategy-generate/scripts/test_cli_json.py +97 -0
  73. package/dist/skills/biz-line-strategy-generate/scripts/test_database_output.py +904 -0
  74. package/dist/skills/biz-line-strategy-generate/scripts/test_pipeline.py +253 -0
  75. package/dist/skills/biz-line-strategy-generate/scripts/test_strategy_rules.py +224 -0
  76. package/dist/skills/biz-line-strategy-generate/scripts/test_tag_submission.py +238 -0
  77. package/dist/skills/biz-line-strategy-generate/scripts/test_workflow.py +1233 -0
  78. package/dist/skills/sino-bc-cli/SKILL.md +62 -0
  79. package/dist/skills/sino-bc-cli/domains/sino-bc-auth/SKILL.md +36 -0
  80. package/dist/skills/sino-bc-cli/domains/sino-bc-bill/SKILL.md +48 -0
  81. package/dist/skills/sino-bc-cli/domains/sino-bc-biz-line/SKILL.md +58 -0
  82. package/dist/skills/sino-bc-cli/domains/sino-bc-biz-line/references/common-mistakes.md +7 -0
  83. package/dist/skills/sino-bc-cli/domains/sino-bc-biz-line/references/enums.md +13 -0
  84. package/dist/skills/sino-bc-cli/domains/sino-bc-biz-line-ai-strategy/SKILL.md +46 -0
  85. package/dist/skills/sino-bc-cli/domains/sino-bc-biz-line-metrics/SKILL.md +46 -0
  86. package/dist/skills/sino-bc-cli/domains/sino-bc-common-file/SKILL.md +44 -0
  87. package/dist/skills/sino-bc-cli/domains/sino-bc-dashboard/SKILL.md +50 -0
  88. package/dist/skills/sino-bc-cli/domains/sino-bc-dict/SKILL.md +48 -0
  89. package/dist/skills/sino-bc-cli/domains/sino-bc-document/SKILL.md +53 -0
  90. package/dist/skills/sino-bc-cli/domains/sino-bc-im/SKILL.md +62 -0
  91. package/dist/skills/sino-bc-cli/domains/sino-bc-invoice/SKILL.md +48 -0
  92. package/dist/skills/sino-bc-cli/domains/sino-bc-media/SKILL.md +40 -0
  93. package/dist/skills/sino-bc-cli/domains/sino-bc-organization/SKILL.md +46 -0
  94. package/dist/skills/sino-bc-cli/domains/sino-bc-project/SKILL.md +112 -0
  95. package/dist/skills/sino-bc-cli/domains/sino-bc-project-analysis/SKILL.md +61 -0
  96. package/dist/skills/sino-bc-cli/domains/sino-bc-project-analysis/references/analysis-workflow.md +21 -0
  97. package/dist/skills/sino-bc-cli/domains/sino-bc-project-analysis/references/filters-and-metrics.md +30 -0
  98. package/dist/skills/sino-bc-cli/domains/sino-bc-quota/SKILL.md +44 -0
  99. package/dist/skills/sino-bc-cli/domains/sino-bc-reconciliation/SKILL.md +47 -0
  100. package/dist/skills/sino-bc-cli/domains/sino-bc-report/SKILL.md +53 -0
  101. package/dist/skills/sino-bc-cli/domains/sino-bc-sso-team-group/SKILL.md +45 -0
  102. package/dist/skills/sino-bc-cli/domains/sino-bc-task/SKILL.md +73 -0
  103. package/dist/skills/sino-bc-cli/domains/sino-bc-task/references/state-machine.md +18 -0
  104. package/dist/skills/sino-bc-cli/domains/sino-bc-tenant/SKILL.md +34 -0
  105. package/dist/skills/sino-bc-cli/domains/sino-bc-user/SKILL.md +57 -0
  106. package/dist/skills/sino-bc-cli/domains/sino-bc-wallet/SKILL.md +55 -0
  107. package/dist/skills/sino-bc-cli/references/domain-index/module-8534789.md +53 -0
  108. package/dist/skills/sino-bc-cli/references/domain-index/module-8551546.md +70 -0
  109. package/dist/skills/sino-bc-cli/references/domain-index/module-8562638.md +13 -0
  110. package/dist/skills/sino-bc-cli/references/domain-index/module-8566367.md +13 -0
  111. package/dist/skills/sino-bc-cli/references/domain-index/module-8581524.md +17 -0
  112. package/dist/skills/sino-bc-cli/references/domain-index/module-8601540.md +13 -0
  113. package/dist/skills/sino-bc-cli/references/domain-index/module-8614891.md +33 -0
  114. package/dist/skills/sino-bc-cli/references/domain-index/module-8621792.md +17 -0
  115. package/dist/skills/sino-bc-shared/SKILL.md +31 -0
  116. package/dist/skills/sino-bc-shared/references/auth-recovery.md +36 -0
  117. package/dist/skills/sino-bc-shared/references/config-and-env.md +60 -0
  118. package/dist/skills/sino-bc-shared/references/output-contract.md +86 -0
  119. package/dist/skills/sino-bc-shared/references/skill-lifecycle.md +17 -0
  120. package/dist/skills/sino-bc-shared/references/tenant-context.md +21 -0
  121. package/dist/skills/sino-bc-shared/references/write-approval.md +16 -0
  122. package/package.json +70 -0
  123. package/scripts/postinstall.cjs +36 -0
@@ -0,0 +1,705 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """CLI 信封解包与文案用的小工具。拉数 / 汇总脚本共用,避免再手写解析。"""
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import os
8
+ import shutil
9
+ import subprocess
10
+ import threading
11
+ import math
12
+ import re
13
+ from contextlib import contextmanager
14
+ from pathlib import Path
15
+
16
+ _local = threading.local()
17
+
18
+
19
+ @contextmanager
20
+ def cli_context(cancel, progress):
21
+ """每个业务线独立传递取消信号与进度回调,不共享可变回包。"""
22
+ _local.cancel, _local.progress = cancel, progress
23
+ try:
24
+ yield
25
+ finally:
26
+ _local.cancel = _local.progress = None
27
+
28
+
29
+ def report_progress(message):
30
+ """统一入口由协调回调串行输出;诊断脚本沿用控制台输出。"""
31
+ callback = getattr(_local, "progress", None)
32
+ if callback:
33
+ callback(message)
34
+ else:
35
+ print(message, flush=True)
36
+
37
+ ENUM_ZH = {
38
+ "OUTCOME_APP_PROMOTION": "应用推广",
39
+ "OUTCOME_SALES": "销量",
40
+ "OUTCOME_LEADS": "线索",
41
+ "OUTCOME_AWARENESS": "认知",
42
+ "OUTCOME_TRAFFIC": "流量",
43
+ "OUTCOME_ENGAGEMENT": "互动",
44
+ "OUTCOME_APP_INSTALLS": "应用安装",
45
+ "APP_INSTALLS": "应用安装",
46
+ "APP_PROMOTION": "应用推广",
47
+ "CONVERSIONS": "转化",
48
+ "REACH": "覆盖",
49
+ "LINK_CLICKS": "链接点击",
50
+ "LANDING_PAGE_VIEWS": "落地页浏览",
51
+ "VALUE": "价值",
52
+ "OFFSITE_CONVERSIONS": "站外转化",
53
+ "COST_CAP": "成本上限",
54
+ "BID_CAP": "出价上限",
55
+ "LOWEST_COST_WITHOUT_CAP": "最低成本",
56
+ "LOWEST_COST_WITH_BID_CAP": "最低成本(出价上限)",
57
+ "LOWEST_COST_WITH_MIN_ROAS": "最低成本(最低 ROAS)",
58
+ "TARGET_COST": "目标成本",
59
+ "TARGET_CPA": "目标 CPA",
60
+ "TARGET_ROAS": "目标 ROAS",
61
+ "MAXIMIZE_CONVERSIONS": "尽可能多转化",
62
+ "MAXIMIZE_CONVERSION_VALUE": "尽可能高转化价值",
63
+ "LOOKALIKE": "类似受众",
64
+ "CUSTOM": "自定义受众",
65
+ "CUSTOM_AUDIENCE": "自定义受众",
66
+ "BOARD": "通用",
67
+ "BROAD": "通用",
68
+ "CORE": "核心受众",
69
+ "INTEREST": "兴趣",
70
+ "IMAGE": "图片",
71
+ "VIDEO": "视频",
72
+ "CAROUSEL": "轮播",
73
+ "COLLECTION": "精选",
74
+ "SINGLE_IMAGE": "单图",
75
+ "SINGLE_VIDEO": "单视频",
76
+ "PERFORMANCE_MAX": "PMax",
77
+ "SEARCH": "搜索广告",
78
+ "DISPLAY": "展示广告",
79
+ "SHOPPING": "购物广告",
80
+ "MULTI_CHANNEL": "多渠道",
81
+ "MALE": "男性",
82
+ "FEMALE": "女性",
83
+ "GENDER_MALE": "男性",
84
+ "GENDER_FEMALE": "女性",
85
+ "VIDEO_VIEWS": "视频播放",
86
+ "IN_APP_EVENT": "应用内事件",
87
+ "INSTALL": "安装",
88
+ "ENGAGED_VIEW": "有效观看",
89
+ "BID_TYPE_CUSTOM": "自定义出价",
90
+ "BID_TYPE_NO_BID": "不设出价",
91
+ "WEB_CONVERSIONS": "网站转化",
92
+ "CONVERT": "转化",
93
+ "CUSTOM&LOOKALIKE": "自定义/相似受众",
94
+ }
95
+
96
+
97
+ def resolve_cli() -> list[str]:
98
+ """优先 PATH 上的 sino-bc-cli;Windows 找不到时再退到 npm 全局包。"""
99
+ found = shutil.which("sino-bc-cli")
100
+ if found:
101
+ # Windows 的 cmd shim 会重新解释 JSON 中的特殊字符,直接调用同包 Node 入口。
102
+ if os.name == "nt" and Path(found).suffix.lower() in (".cmd", ".bat"):
103
+ return [shutil.which("node") or "node", str(shim_entry(Path(found)))]
104
+ return [found]
105
+ npm_js = (
106
+ Path(os.environ.get("APPDATA", ""))
107
+ / "npm"
108
+ / "node_modules"
109
+ / "sino-bc-cli"
110
+ / "dist"
111
+ / "index.js"
112
+ )
113
+ if npm_js.is_file():
114
+ return ["node", str(npm_js)]
115
+ raise FileNotFoundError("找不到 sino-bc-cli,请先全局安装或执行 /init")
116
+
117
+
118
+ def shim_entry(shim: Path) -> Path:
119
+ """只读解析 npm/pnpm 的 Node 入口,不执行 cmd 文本或解释用户 JSON。"""
120
+ candidates = [shim.parent / 'node_modules/sino-bc-cli/dist/index.js']
121
+ content = shim.read_text(encoding='utf-8')
122
+ for value in re.findall(r'"([^"\r\n]*sino-bc-cli[\\/]dist[\\/]index\.js)"', content, re.I):
123
+ value = re.sub(r'%(?:~dp0%?|dp0%)', lambda _: str(shim.parent) + os.sep, value, flags=re.I)
124
+ if '%' not in value:
125
+ path = Path(value)
126
+ candidates.append(path if path.is_absolute() else shim.parent / path)
127
+ for entry in candidates:
128
+ if entry.is_file():
129
+ return entry.resolve()
130
+ raise RuntimeError('CLI shim 对应包入口不可访问或布局无法识别,请检查全局安装目录权限')
131
+
132
+
133
+ def parse_json_text(raw: str):
134
+ """从混有日志的文本里取出第一段 JSON。数组文件必须从 '[' 起读,不能先找 '{'。"""
135
+ text = (raw or "").strip()
136
+ starts = [i for i in (text.find("{"), text.find("[")) if i >= 0]
137
+ if not starts:
138
+ return None
139
+ try:
140
+ return json.loads(text[min(starts) :])
141
+ except json.JSONDecodeError:
142
+ return None
143
+
144
+
145
+ def load_json_file(path: Path):
146
+ if not path.exists():
147
+ return None
148
+ return parse_json_text(path.read_text(encoding="utf-8"))
149
+
150
+
151
+ def unwrap_page(envelope: dict) -> tuple[list, int]:
152
+ """CLI 信封里的分页行。
153
+
154
+ - 列表:data.result + data.total
155
+ - 维度/成效:data.data + data.total(不是 data.result)
156
+ - project-list:data 直接是数组
157
+ """
158
+ if not isinstance(envelope, dict):
159
+ return [], 0
160
+ data = envelope.get("data")
161
+ if isinstance(data, list):
162
+ return data, len(data)
163
+ if not isinstance(data, dict):
164
+ return [], 0
165
+ if isinstance(data.get("data"), list):
166
+ rows = data["data"]
167
+ return rows, int(data["total"] if data.get("total") is not None else len(rows))
168
+ if isinstance(data.get("result"), list):
169
+ rows = data["result"]
170
+ return rows, int(data["total"] if data.get("total") is not None else len(rows))
171
+ return [], 0
172
+
173
+
174
+ def exec_cli_to_file(cmd: list[str], out_path: Path) -> tuple[int, str]:
175
+ """两个输出流直写本次文件;取消时等待子进程退出后才允许清理。"""
176
+ out_path.parent.mkdir(parents=True, exist_ok=True)
177
+ cancel = getattr(_local, "cancel", None)
178
+ if cancel and cancel.is_set():
179
+ raise InterruptedError("任务已取消")
180
+ with out_path.open("w", encoding="utf-8") as f, out_path.with_suffix(".stderr.txt").open("w", encoding="utf-8") as err:
181
+ proc = subprocess.Popen(cmd, stdout=f, stderr=err, shell=False)
182
+ try:
183
+ while True:
184
+ try:
185
+ proc.wait(timeout=0.2)
186
+ break
187
+ except subprocess.TimeoutExpired:
188
+ if cancel and cancel.is_set():
189
+ raise InterruptedError("任务已取消")
190
+ finally:
191
+ if proc.poll() is None:
192
+ proc.terminate()
193
+ try:
194
+ proc.wait(timeout=5)
195
+ except subprocess.TimeoutExpired:
196
+ proc.kill()
197
+ proc.wait()
198
+ raw = out_path.read_text(encoding="utf-8")
199
+ return proc.returncode, raw
200
+
201
+
202
+ def list_query_body(*, name: str = "", biz_id: int = 0) -> dict:
203
+ """+list 只接受 --data,禁止再拼 --name / --id flag。"""
204
+ body = {
205
+ "pageNum": 1,
206
+ "pageSize": 20,
207
+ "sortType": "PERIOD",
208
+ "sortDirection": "DESC",
209
+ }
210
+ if biz_id:
211
+ body["idList"] = [int(biz_id)]
212
+ body["pageSize"] = 10
213
+ elif name:
214
+ body["name"] = name
215
+ return body
216
+
217
+
218
+ def run_cli(args: list[str], body: dict | None, out_path: Path) -> dict:
219
+ out_path.parent.mkdir(parents=True, exist_ok=True)
220
+ cmd = [*resolve_cli(), *args, "--format", "json"]
221
+ if body is not None:
222
+ body_path = out_path.with_name(out_path.name.replace(".out.json", ".body.json"))
223
+ if body_path == out_path:
224
+ body_path = out_path.with_suffix(".body.json")
225
+ body_path.write_text(json.dumps(body, ensure_ascii=False), encoding="utf-8")
226
+ cmd.extend(["--data", json.dumps(body, ensure_ascii=False)])
227
+ rc, raw = exec_cli_to_file(cmd, out_path)
228
+ parsed = parse_json_text(raw.strip())
229
+ stderr = out_path.with_suffix(".stderr.txt").read_text(encoding="utf-8", errors="replace")
230
+ if not isinstance(parsed, dict) or rc != 0 or parsed.get("ok") is not True:
231
+ error = parse_json_text(stderr) or stderr or parsed or raw or "无有效 CLI 回包"
232
+ parsed = {"ok": False, "error": error, "rc": rc}
233
+ return parsed
234
+
235
+
236
+ def require_ok(envelope, label):
237
+ """失败信封不能按空数据继续生成报告。"""
238
+ if not isinstance(envelope, dict) or envelope.get("ok") is not True:
239
+ raise RuntimeError(f"{label}: {json.dumps(envelope, ensure_ascii=False)[:2000]}")
240
+ return envelope
241
+
242
+
243
+ def optional_num(row, key):
244
+ """指标缺失与零分开,拒绝 NaN/Infinity 进入分析及 Excel。"""
245
+ try:
246
+ value = float(row.get(key))
247
+ return value if math.isfinite(value) else None
248
+ except (TypeError, ValueError):
249
+ return None
250
+
251
+
252
+ # 2026-09-23 核对三个渠道成效接口 indicators 枚举,统一作为查询与识别来源。
253
+ BASE_METRICS = {
254
+ 'spend_usd': ('消耗', '消耗金额', '花费', 'spend'),
255
+ 'impressions': ('曝光', '曝光量', '展示', '展示次数'),
256
+ 'clicks': ('点击', '点击量', '点击次数'),
257
+ 'video_views': ('视频观看', '视频观看次数', '视频播放量'),
258
+ 'add_to_cart': ('添加购物车次数', '加购', '加购量', '加购次数'),
259
+ 'checkouts': ('发起结账次数', '结账', '结账次数'),
260
+ 'purchase': ('购买', '购买次数', '购买量'),
261
+ 'purchase_value': ('购买金额', '购买金额美元', '购买收入'),
262
+ 'installs': ('安装', '安装量', '应用安装量'),
263
+ 'complete_registration': ('注册', '注册量', '完成注册量'),
264
+ 'leads': ('线索', '线索量', '潜客', '潜客数量', '留下潜客信息次数'),
265
+ 'engagement': ('互动', '互动量', '互动次数'),
266
+ }
267
+
268
+ # 飞书「指标」sheet 的顺序是明细表的固定列顺序;code 是本地计算键,不传给成效接口。
269
+ REPORT_METRICS = [
270
+ ('spend_usd', '消耗金额_美元', False), ('impressions', '曝光量', False),
271
+ ('clicks', '点击量', False), ('video_views', '视频观看次数', False),
272
+ ('add_to_cart', '添加购物车次数', False), ('checkouts', '发起结账次数', False),
273
+ ('purchase', '购买次数', False), ('purchase_value', '购买金额', False),
274
+ ('installs', '应用安装量', False), ('complete_registration', '完成注册量', False),
275
+ ('leads', '留下潜客信息次数', False), ('engagement', '互动次数', False),
276
+ ('ctr', '点击率', True), ('cpc', '单次点击成本', False),
277
+ ('cpm', '千次曝光成本', False), ('cpi', '单次下载成本', False),
278
+ ('cpe', '单次互动成本', False), ('cpv', '单次视频观看成本', False),
279
+ ('cpaPurchase', '单次订单成本', False),
280
+ ('cpaAddsToCart', '单次加入购物车成本', False),
281
+ ('cpr', '单次注册成本', False), ('cpl', '单次线索成本', False),
282
+ ('cvrAddsToCart', '加入购物车转化率', True),
283
+ ('cvrPurchases', '订单转化率', True),
284
+ ('cvrAppInstalls', '安装转化率', True), ('vtr', '视频观看率', True),
285
+ ('roas', '投资回报率', False), ('arpp', '笔单价', False),
286
+ ('cvrRegistrationsCompleted', '注册转化率', True),
287
+ ]
288
+
289
+
290
+ # 衍生指标只在本地计算;不能把这些名称传给接口 indicators。
291
+ METRIC_FORMULAS = {"CPI": ("spend_usd", "installs", 1), "CPC": ("spend_usd", "clicks", 1),
292
+ "CPM": ("spend_usd", "impressions", 1000), "ROAS": ("purchase_value", "spend_usd", 1),
293
+ "CTR": ("clicks", "impressions", 100)}
294
+ # 文档公式中的 spendUsdUsd/spendUsd、add_to_cart/addsToCart 等写法统一映射到
295
+ # 三渠道成效接口的 12 个 snake_case 基础字段;百分率以百分数展示。
296
+ METRIC_FORMULAS.update({
297
+ 'ctr': ('clicks', 'impressions', 100),
298
+ 'cpc': ('spend_usd', 'clicks', 1),
299
+ 'cpm': ('spend_usd', 'impressions', 1000),
300
+ 'cpi': ('spend_usd', 'installs', 1),
301
+ 'cpe': ('spend_usd', 'engagement', 1),
302
+ 'cpv': ('spend_usd', 'video_views', 1),
303
+ 'cpaPurchase': ('spend_usd', 'purchase', 1),
304
+ 'cpaAddsToCart': ('spend_usd', 'add_to_cart', 1),
305
+ 'cpr': ('spend_usd', 'complete_registration', 1),
306
+ 'cpl': ('spend_usd', 'leads', 1),
307
+ 'cvrAddsToCart': ('add_to_cart', 'clicks', 100),
308
+ 'cvrPurchases': ('purchase', 'clicks', 100),
309
+ 'cvrAppInstalls': ('installs', 'clicks', 100),
310
+ 'vtr': ('video_views', 'impressions', 100),
311
+ 'roas': ('purchase_value', 'spend_usd', 1),
312
+ 'arpp': ('purchase_value', 'purchase', 1),
313
+ 'cvrRegistrationsCompleted': ('complete_registration', 'clicks', 100),
314
+ })
315
+
316
+ # 单次事件成本均可由美元消耗和对应事件量计算,不再局限原来的五个衍生指标。
317
+ DERIVED_ALIASES = {}
318
+ for _field, _names in BASE_METRICS.items():
319
+ if _field in ('spend_usd', 'purchase_value', 'impressions'):
320
+ continue
321
+ _code = 'cost_per_' + _field
322
+ METRIC_FORMULAS[_code] = ('spend_usd', _field, 1)
323
+ for _name in (_field, *_names):
324
+ DERIVED_ALIASES[_name + '成本'] = _code
325
+ DERIVED_ALIASES['单次' + _name + '成本'] = _code
326
+ DERIVED_ALIASES[_code] = _code
327
+ # 安装和点击继续使用旧标准 code,保证指标识别及输出兼容。
328
+ DERIVED_ALIASES.update({'安装成本': 'CPI', '应用安装成本': 'CPI', '点击成本': 'CPC',
329
+ '注册成本': 'cost_per_complete_registration', '完成注册成本': 'cost_per_complete_registration',
330
+ '线索成本': 'cost_per_leads', '购买成本': 'cost_per_purchase',
331
+ '加购成本': 'cost_per_add_to_cart', '结账成本': 'cost_per_checkouts',
332
+ '视频观看成本': 'cost_per_video_views', '互动成本': 'cost_per_engagement',
333
+ '客单价': 'average_purchase_value'})
334
+ METRIC_FORMULAS['average_purchase_value'] = ('purchase_value', 'purchase', 1)
335
+
336
+
337
+ def metric_formula(metric):
338
+ """解析已知衍生指标或由基础字段明确指定的比值,不执行任意表达式。"""
339
+ if metric in METRIC_FORMULAS:
340
+ return METRIC_FORMULAS[metric]
341
+ match = re.fullmatch(r'([a-z_]+)/([a-z_]+)(\*100)?', str(metric or ''))
342
+ if match and match[1] in BASE_METRICS and match[2] in BASE_METRICS:
343
+ return match[1], match[2], 100 if match[3] else 1
344
+ return None
345
+
346
+
347
+ def metric_fields(metric):
348
+ """返回核心指标依赖字段,以识别服务端 conflictField 的不支持反馈。"""
349
+ formula = metric_formula(metric)
350
+ return set(formula[:2]) if formula else ({metric} if metric else set())
351
+
352
+
353
+ def metric_value(rows, metric="CPI"):
354
+ """同一粒度先合计基础指标再计算,任一缺失不当作零补齐。只扫一遍,可接迭代器。"""
355
+ if metric in BASE_METRICS:
356
+ total, seen = 0.0, False
357
+ for row in rows or ():
358
+ seen = True
359
+ value = optional_num(row, metric)
360
+ if value is None:
361
+ return None
362
+ total += value
363
+ return total if seen else None
364
+ formula = metric_formula(metric)
365
+ if formula is None:
366
+ return None
367
+ a, b, scale = formula
368
+ seen = False
369
+ num_a = num_b = 0.0
370
+ for row in rows or ():
371
+ seen = True
372
+ left, right = optional_num(row, a), optional_num(row, b)
373
+ if left is None or right is None:
374
+ return None
375
+ num_a += left
376
+ num_b += right
377
+ if not seen:
378
+ return None
379
+ return num_a / num_b * scale if num_b > 0 else None
380
+
381
+
382
+ def core_rule(text):
383
+ """识别接口指标 code/中文别名及单一阈值;不猜复合目标或换算未知币种。"""
384
+ text = str(text or '').strip()
385
+ invalid = {'metric': None, 'usable': False, 'reason': '核心指标为空' if not text else '核心指标不支持'}
386
+ if not text:
387
+ return invalid
388
+ normalized = text.lower().replace(':', ':').strip()
389
+ for word, symbol in [('不超过', '<='), ('不低于', '>='), ('小于等于', '<='), ('大于等于', '>='),
390
+ ('至少', '>='), ('至多', '<='), ('低于', '<'), ('高于', '>'), ('小于', '<'), ('大于', '>')]:
391
+ normalized = normalized.replace(word, symbol)
392
+ aliases = {alias.lower(): code for code, names in BASE_METRICS.items() for alias in (code, *names)}
393
+ # 指标表的 API 字段沿用驼峰命名,统一映射到三个成效接口实际返回的 snake_case 字段。
394
+ aliases.update({'spendusdusd': 'spend_usd', 'spendusd': 'spend_usd',
395
+ 'videoviews': 'video_views', 'addstocart': 'add_to_cart',
396
+ 'checkoutsinitiated': 'checkouts', 'purchases': 'purchase',
397
+ 'purchasesvalue': 'purchase_value', 'appinstalls': 'installs',
398
+ 'registrationscompleted': 'complete_registration',
399
+ 'engagements': 'engagement'})
400
+ # 显式比值可使用字段 code 或中文别名,分子分母均须确认为接口基础指标。
401
+ base_aliases = dict(aliases)
402
+ expression = re.match(r'^(.+?)[/÷](.+?)(\s*[*×]\s*100)?(?=\s*(?:<=|>=|≤|≥|<|>|=|$))', normalized)
403
+ if expression:
404
+ left, right = (base_aliases.get(expression[i].strip()) for i in (1, 2))
405
+ if left and right:
406
+ code = left + '/' + right + ('*100' if expression[3] else '')
407
+ normalized = code + normalized[expression.end():]
408
+ aliases[code] = code
409
+ aliases.update({key.lower(): key for key in METRIC_FORMULAS})
410
+ aliases.update(DERIVED_ALIASES)
411
+ # 指标表的中文名称和 API 字段均可作核心指标;同义词只映射到这 29 项的公式。
412
+ aliases.update({label.lower(): code for code, label, _is_percent in REPORT_METRICS})
413
+ aliases.update({'cpi': 'CPI', 'cpc': 'CPC', 'cpm': 'CPM', 'ctr': 'CTR', 'roas': 'ROAS'})
414
+ aliases.update({'安装成本': 'CPI', '点击成本': 'CPC', '千次展示成本': 'CPM', '点击率': 'CTR', '广告支出回报率': 'ROAS'})
415
+ # 最长名称优先,避免把“购买金额”误识别成“购买”。
416
+ pattern = '|'.join(re.escape(key) for key in sorted(aliases, key=len, reverse=True))
417
+ match = re.fullmatch(r'(' + pattern + r')\s*(.*?)', normalized)
418
+ if not match:
419
+ return invalid
420
+ alias, tail = match.groups()
421
+ metric = aliases[alias]
422
+ formula = metric_formula(metric)
423
+ supported_formulas = {metric_formula(code) for code, _label, _is_percent in REPORT_METRICS
424
+ if code not in BASE_METRICS}
425
+ if metric not in BASE_METRICS and formula not in supported_formulas:
426
+ return invalid
427
+ cost = metric == 'spend_usd' or bool(formula and formula[0] == 'spend_usd')
428
+ percent = bool(formula and formula[2] == 100)
429
+ rule = {'metric': metric, 'usable': True, 'reason': '', 'target': None, 'op': None,
430
+ 'higher_is_better': not cost}
431
+ money = metric in ('spend_usd', 'purchase_value') or bool(formula and formula[0] in ('spend_usd', 'purchase_value')
432
+ and formula[1] not in ('spend_usd', 'purchase_value') and not percent)
433
+ if not tail or (money and tail in ('usd', '美元', '$')) or (percent and tail == '%'):
434
+ return rule
435
+ target_match = re.fullmatch(r'(usd|美元|\$)?\s*(<=|>=|≤|≥|<|>|=|==)\s*(\d+(?:\.\d+)?)\s*(usd|美元|\$|%|次|个|量)?', tail)
436
+ if not target_match:
437
+ return {**invalid, 'metric': metric, 'reason': '核心指标无法识别'}
438
+ currency, op, target, suffix = target_match.groups()
439
+ if ((currency or suffix in ('usd', '美元', '$')) and not money
440
+ or (suffix == '%' and not percent)
441
+ or (percent and suffix != '%')
442
+ or (money and suffix in ('次', '个', '量'))):
443
+ return {**invalid, 'metric': metric, 'reason': '核心指标无法识别'}
444
+ rule.update(op=op, target=float(target))
445
+ # 阈值明确上下限时,其方向优先于指标缺省方向。
446
+ if op in ('>', '>=', '≥', '<', '<=', '≤'):
447
+ rule['higher_is_better'] = op in ('>', '>=', '≥')
448
+ return rule
449
+
450
+
451
+ def rule_hit(value, rule):
452
+ """仅对明确定义的 KPI 判断,缺数据不输出未达标。"""
453
+ if value is None or not rule.get("usable") or rule.get('target') is None:
454
+ return None
455
+ target, op = rule["target"], rule["op"]
456
+ return {"<=": value <= target, "≤": value <= target, "<": value < target,
457
+ ">=": value >= target, "≥": value >= target, ">": value > target, "=": value == target, "==": value == target}[op]
458
+
459
+
460
+ def is_first_period(biz: dict) -> bool:
461
+ start = str((biz or {}).get("prevPeriodStart") or "").strip()
462
+ end = str((biz or {}).get("prevPeriodEnd") or "").strip()
463
+ return not (start and end)
464
+
465
+
466
+ def period_verdict(
467
+ *,
468
+ first_period: bool,
469
+ qoq_label: str,
470
+ kpi_usable: bool = False,
471
+ kpi_hit=None,
472
+ ) -> str:
473
+ """有可比上期时仅看趋势;没有上期时才按可识别阈值判断。"""
474
+ if not kpi_usable:
475
+ return '待观察'
476
+ if first_period:
477
+ return '待观察' if kpi_hit is None else ('有效' if kpi_hit else '无效')
478
+ if qoq_label == '无法对比':
479
+ return '待观察'
480
+ return '有效' if qoq_label == '变好' else '无效'
481
+
482
+
483
+ def evaluate_learning(rule, current, previous, has_previous, sample_sufficient=True):
484
+ """各数据范围共用判定规则;有数据但指标缺失不冒充没有上期。"""
485
+ if not rule.get('usable'):
486
+ return '待观察', learning_text('待观察', rule)
487
+ if current is None or not sample_sufficient:
488
+ return '待观察', learning_text('待观察')
489
+ if has_previous:
490
+ trend, _ = qoq_axis(current, previous, rule['higher_is_better'])
491
+ verdict = period_verdict(first_period=False, qoq_label=trend, kpi_usable=True)
492
+ return verdict, learning_text(verdict)
493
+ hit = rule_hit(current, rule)
494
+ verdict = period_verdict(first_period=True, qoq_label='无法对比', kpi_usable=True, kpi_hit=hit)
495
+ note = None if hit is None else ('核心指标达标' if hit else '核心指标未达标')
496
+ return verdict, learning_text(verdict, note=note)
497
+
498
+
499
+ def learning_text(verdict, rule=None, note=None):
500
+ """所有 learning 统一仅输出结论和业务说明,不附 KPI 原文或证据明细。"""
501
+ notes = {'有效': '策略有效', '待观察(预警)': '警惕,再观察一周',
502
+ '待观察(潜力)': '再给一周机会', '无效': '需关停',
503
+ '待观察': '数据不足,暂无法判断'}
504
+ if rule is not None and not rule.get('usable'):
505
+ return f'结论:待观察\n说明:{rule["reason"]}'
506
+ return f'结论:{verdict}\n说明:{note or notes[verdict]}'
507
+
508
+
509
+ def amount_change(curr, prev):
510
+ """相对上期的金额环比;零基数不伪造百分比。"""
511
+ if prev == 0:
512
+ return '环比持平' if curr == 0 else '环比不可计算(上期为0)'
513
+ return f'环比 {(curr - prev) / prev * 100:+.2f}%'
514
+
515
+
516
+ def is_ghost_enum(curr_share: float, prev_share: float) -> bool:
517
+ """两端占比都是 0 的残留枚举,不当关停动作。"""
518
+ return float(curr_share or 0) <= 0 and float(prev_share or 0) <= 0
519
+
520
+
521
+ def region_bucket(name: str) -> str:
522
+ n = name or ""
523
+ if "非东南亚" in n:
524
+ return "other"
525
+ if "东南亚" in n:
526
+ return "sea"
527
+ return "other"
528
+
529
+
530
+ def bid_adjust_stats(items: list) -> dict:
531
+ raise_n = lower_n = flat_n = 0
532
+ deltas: list[float] = []
533
+ details = []
534
+ for it in items:
535
+ fr = float(it.get("from") or 0)
536
+ to = float(it.get("to") or 0)
537
+ delta = to - fr
538
+ deltas.append(abs(delta))
539
+ if to > fr:
540
+ raise_n += 1
541
+ action = "上调"
542
+ elif to < fr:
543
+ lower_n += 1
544
+ action = "下调"
545
+ else:
546
+ flat_n += 1
547
+ action = "持平"
548
+ details.append({**it, "action": action, "delta": delta})
549
+ n = len(deltas)
550
+ if n == 0:
551
+ median = 0.0
552
+ else:
553
+ ordered = sorted(deltas)
554
+ mid = n // 2
555
+ median = ordered[mid] if n % 2 else (ordered[mid - 1] + ordered[mid]) / 2
556
+ list_all = len(items) < 10
557
+ return {
558
+ "raise_n": raise_n,
559
+ "lower_n": lower_n,
560
+ "flat_n": flat_n,
561
+ "median_abs": median,
562
+ "list_all": list_all,
563
+ "details": details if list_all else [],
564
+ }
565
+
566
+
567
+ def budget_abs_share(
568
+ channel: str,
569
+ *,
570
+ prev_abs: float,
571
+ curr_abs: float,
572
+ prev_share: float,
573
+ curr_share: float,
574
+ ) -> str:
575
+ abs_delta = curr_abs - prev_abs
576
+ share_delta = curr_share - prev_share
577
+ flag = ""
578
+ if share_delta > 0.2 and abs_delta < -0.5:
579
+ flag = ";⚠️占比升但金额降"
580
+ elif share_delta < -0.2 and abs_delta > 0.5:
581
+ flag = ";⚠️占比降但金额升"
582
+ share = '' if round(prev_share, 1) == round(curr_share, 1) == 100 else f';占比 {prev_share:.1f}% → {curr_share:.1f}%({share_delta:+.1f}%)'
583
+ return f'{channel}:${prev_abs:,.0f} → ${curr_abs:,.0f}({abs_delta:+,.0f});{amount_change(curr_abs, prev_abs)}{share}{flag}'
584
+
585
+
586
+ _NAMING_SKIP = {
587
+ "ios",
588
+ "and",
589
+ "android",
590
+ "fb",
591
+ "tt",
592
+ "gg",
593
+ "bid",
594
+ "cap",
595
+ "all",
596
+ "copy",
597
+ "test",
598
+ "测试",
599
+ "自制",
600
+ "客供",
601
+ "英语",
602
+ "英文",
603
+ "中文",
604
+ "繁中",
605
+ "install",
606
+ "smart",
607
+ }
608
+
609
+
610
+ def naming_patterns(names: list[str], limit: int = 3) -> str:
611
+ """按名称中可见词段统计对象数,一对象同词只计一次,不臆测内部代码含义。"""
612
+ counts = {}
613
+ for name in names:
614
+ tokens = set()
615
+ clean = re.sub(r'\.(mp4|mov|jpg|jpeg|png)$', '', str(name or ''), flags=re.I)
616
+ for tok in re.split(r'[-_\s/|]+', clean):
617
+ tok = tok.strip()
618
+ if not tok or tok.isdigit() or len(tok) < 2 or len(tok) > 16:
619
+ continue
620
+ if tok.lower() in {'copy', 'test', '测试', 'ad', 'adgroup'} or re.fullmatch(r'[0-9a-fA-F]{8,}', tok):
621
+ continue
622
+ tokens.add(tok)
623
+ for tok in tokens:
624
+ counts[tok] = counts.get(tok, 0) + 1
625
+ top = sorted(counts, key=lambda k: (-counts[k], k))[:limit]
626
+ return '、'.join(f'{tok}({counts[tok]}个)' for tok in top) if top else '未识别分类'
627
+
628
+
629
+ def summarize_countries(countries: list[str]) -> tuple[str, list[str]]:
630
+ uniq = [c for c in dict.fromkeys(countries) if c]
631
+ if len(uniq) <= 50:
632
+ return f"共 {len(uniq)} 个国家", uniq
633
+ return f"国家超过 50 个,主页面只展示区域/大洲聚合(共 {len(uniq)} 个)", uniq
634
+
635
+
636
+ def num(row: dict, key: str) -> float:
637
+ v = row.get(key)
638
+ if v in (None, "", "-"):
639
+ return 0.0
640
+ try:
641
+ return float(v)
642
+ except (TypeError, ValueError):
643
+ return 0.0
644
+
645
+
646
+ def zh(raw: str) -> str:
647
+ s = (raw or "").strip()
648
+ if not s or s == "-":
649
+ return ""
650
+ return ENUM_ZH.get(s) or ENUM_ZH.get(s.upper()) or s
651
+
652
+
653
+ def disp(row: dict, key: str) -> str:
654
+ for candidate in (f"display_{key}", key):
655
+ v = row.get(candidate)
656
+ if v not in (None, "", "-"):
657
+ return zh(str(v).strip())
658
+ return ""
659
+
660
+
661
+ def short_label(raw: str, limit: int = 18) -> str:
662
+ """词包是一个维度值,只留前几个短词,禁止整包进 Excel。"""
663
+ s = (raw or "").strip()
664
+ if "," in s and len(s) > limit:
665
+ parts = [p.strip() for p in s.split(",") if p.strip()]
666
+ s = "、".join(parts[:3]) + ("…" if len(parts) > 3 else "")
667
+ if len(s) > 36:
668
+ s = s[:36] + "…"
669
+ return s
670
+
671
+
672
+ def examples(items, n: int = 3) -> str:
673
+ return "、".join([short_label(x) for x in items if x][:n])
674
+
675
+
676
+ def cpm(spend: float, imps: float):
677
+ if imps <= 0:
678
+ return None
679
+ return spend / imps * 1000
680
+
681
+
682
+ def fmt_cpm(v) -> str:
683
+ return "-" if v is None else f"{v:.2f}"
684
+
685
+
686
+ def hit_kpi(v, target: float = 5.0):
687
+ if v is None:
688
+ return None
689
+ return v <= target
690
+
691
+
692
+ def qoq_axis(curr, prev, higher_is_better=False):
693
+ """成本类:下降为好。返回 (label, pct)。"""
694
+ if curr is None or prev is None or prev == 0:
695
+ return "无法对比", None
696
+ change = (curr - prev) / prev
697
+ # 包含 2% 边界;容忍浮点除法误差,不把恰好 2% 判成稳定。
698
+ if abs(change) < 0.02 and not math.isclose(abs(change), 0.02, rel_tol=1e-9, abs_tol=1e-12):
699
+ return "稳定", change
700
+ return ("变好" if (change > 0 if higher_is_better else change < 0) else "变差"), change
701
+
702
+
703
+ def dual_verdict(is_hit, qoq_label: str) -> str:
704
+ """旧调用兼容入口也使用新趋势规则,不再保留四象限分支。"""
705
+ return period_verdict(first_period=False, qoq_label=qoq_label, kpi_usable=True, kpi_hit=is_hit)