sino-bc-cli 1.8.19 → 1.8.21

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.
@@ -7,8 +7,33 @@ import json
7
7
  import os
8
8
  import shutil
9
9
  import subprocess
10
+ import threading
11
+ import math
12
+ import re
13
+ from contextlib import contextmanager
10
14
  from pathlib import Path
11
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
+
12
37
  ENUM_ZH = {
13
38
  "OUTCOME_APP_PROMOTION": "应用推广",
14
39
  "OUTCOME_SALES": "销量",
@@ -73,6 +98,9 @@ def resolve_cli() -> list[str]:
73
98
  """优先 PATH 上的 sino-bc-cli;Windows 找不到时再退到 npm 全局包。"""
74
99
  found = shutil.which("sino-bc-cli")
75
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)))]
76
104
  return [found]
77
105
  npm_js = (
78
106
  Path(os.environ.get("APPDATA", ""))
@@ -87,6 +115,21 @@ def resolve_cli() -> list[str]:
87
115
  raise FileNotFoundError("找不到 sino-bc-cli,请先全局安装或执行 /init")
88
116
 
89
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
+
90
133
  def parse_json_text(raw: str):
91
134
  """从混有日志的文本里取出第一段 JSON。数组文件必须从 '[' 起读,不能先找 '{'。"""
92
135
  text = (raw or "").strip()
@@ -121,14 +164,59 @@ def unwrap_page(envelope: dict) -> tuple[list, int]:
121
164
  return [], 0
122
165
  if isinstance(data.get("data"), list):
123
166
  rows = data["data"]
124
- return rows, int(data.get("total") or len(rows))
167
+ return rows, int(data["total"] if data.get("total") is not None else len(rows))
125
168
  if isinstance(data.get("result"), list):
126
169
  rows = data["result"]
127
- return rows, int(data.get("total") or len(rows))
170
+ return rows, int(data["total"] if data.get("total") is not None else len(rows))
128
171
  return [], 0
129
172
 
130
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
+
131
218
  def run_cli(args: list[str], body: dict | None, out_path: Path) -> dict:
219
+ out_path.parent.mkdir(parents=True, exist_ok=True)
132
220
  cmd = [*resolve_cli(), *args, "--format", "json"]
133
221
  if body is not None:
134
222
  body_path = out_path.with_name(out_path.name.replace(".out.json", ".body.json"))
@@ -136,23 +224,205 @@ def run_cli(args: list[str], body: dict | None, out_path: Path) -> dict:
136
224
  body_path = out_path.with_suffix(".body.json")
137
225
  body_path.write_text(json.dumps(body, ensure_ascii=False), encoding="utf-8")
138
226
  cmd.extend(["--data", json.dumps(body, ensure_ascii=False)])
139
- proc = subprocess.run(
140
- cmd,
141
- capture_output=True,
142
- text=True,
143
- encoding="utf-8",
144
- errors="replace",
145
- shell=False,
146
- )
147
- raw = (proc.stdout or "").strip() or (proc.stderr or "").strip()
148
- out_path.write_text(raw, encoding="utf-8")
149
- parsed = parse_json_text(raw) or {"ok": False, "error": raw[:400], "rc": proc.returncode}
150
- if proc.returncode != 0:
151
- parsed.setdefault("ok", False)
152
- parsed.setdefault("rc", proc.returncode)
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}
153
233
  return parsed
154
234
 
155
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
+ def metric_value(rows, metric="CPI"):
253
+ """同一粒度先合计基础指标再计算,任一缺失不当作零补齐。"""
254
+ formulas = {"CPI": ("spend_usd", "installs", 1), "CPC": ("spend_usd", "clicks", 1),
255
+ "CPM": ("spend_usd", "impressions", 1000), "ROAS": ("purchase_value", "spend_usd", 1),
256
+ "CTR": ("clicks", "impressions", 100)}
257
+ a, b, scale = formulas[metric]
258
+ if not rows or any(optional_num(r, key) is None for r in rows for key in (a, b)):
259
+ return None
260
+ denominator = sum(optional_num(r, b) for r in rows)
261
+ return sum(optional_num(r, a) for r in rows) / denominator * scale if denominator > 0 else None
262
+
263
+
264
+ def core_rule(text):
265
+ """只解析定义及币种明确的单一 KPI;自由文本组合目标保持不可判定。"""
266
+ text = (text or "").strip()
267
+ match = re.fullmatch(r"(CPI|CPC|CPM|ROAS|CTR)\s*(USD|美元|\$)?\s*(<=|>=|≤|≥|<|>)\s*(\d+(?:\.\d+)?)\s*(USD|美元|\$|%)?", text, re.I)
268
+ if match:
269
+ metric, currency, op, target, suffix = match.groups()
270
+ metric = metric.upper()
271
+ currency, suffix = (currency or '').upper(), (suffix or '').upper()
272
+ if (metric in ("CPI", "CPC", "CPM") and (currency or suffix in ("USD", "美元", "$")) and suffix != "%") or (metric == "ROAS" and not currency and not suffix) or (metric == "CTR" and not currency and suffix == "%"):
273
+ return {"metric": metric, "op": op, "target": float(target), "usable": True, "reason": "同口径 KPI"}
274
+ return {"metric": "CPI", "usable": False, "reason": "客户 KPI 不可判定:缺少明确的同币种、同转化定义或留存数据;安装 CPI 仅作观察指标"}
275
+
276
+
277
+ def rule_hit(value, rule):
278
+ """仅对明确定义的 KPI 判断,缺数据不输出未达标。"""
279
+ if value is None or not rule.get("usable"):
280
+ return None
281
+ target, op = rule["target"], rule["op"]
282
+ return {"<=": value <= target, "≤": value <= target, "<": value < target,
283
+ ">=": value >= target, "≥": value >= target, ">": value > target}[op]
284
+
285
+
286
+ def is_first_period(biz: dict) -> bool:
287
+ start = str((biz or {}).get("prevPeriodStart") or "").strip()
288
+ end = str((biz or {}).get("prevPeriodEnd") or "").strip()
289
+ return not (start and end)
290
+
291
+
292
+ def period_verdict(
293
+ *,
294
+ first_period: bool,
295
+ qoq_label: str,
296
+ kpi_usable: bool = False,
297
+ kpi_hit=None,
298
+ ) -> str:
299
+ """第二期结论只跟环比;KPI 未达标不能单独打成无效。首期 KPI 认不出则待观察。"""
300
+ if first_period:
301
+ if not kpi_usable:
302
+ return "待观察"
303
+ return "有效" if kpi_hit else "待观察"
304
+ if qoq_label == "变好":
305
+ return "有效"
306
+ if qoq_label == "变差":
307
+ return "待观察(预警)"
308
+ return "待观察"
309
+
310
+
311
+ def is_ghost_enum(curr_share: float, prev_share: float) -> bool:
312
+ """两端占比都是 0 的残留枚举,不当关停动作。"""
313
+ return float(curr_share or 0) <= 0 and float(prev_share or 0) <= 0
314
+
315
+
316
+ def region_bucket(name: str) -> str:
317
+ n = name or ""
318
+ if "非东南亚" in n:
319
+ return "other"
320
+ if "东南亚" in n:
321
+ return "sea"
322
+ return "other"
323
+
324
+
325
+ def bid_adjust_stats(items: list) -> dict:
326
+ raise_n = lower_n = flat_n = 0
327
+ deltas: list[float] = []
328
+ details = []
329
+ for it in items:
330
+ fr = float(it.get("from") or 0)
331
+ to = float(it.get("to") or 0)
332
+ delta = to - fr
333
+ deltas.append(abs(delta))
334
+ if to > fr:
335
+ raise_n += 1
336
+ action = "上调"
337
+ elif to < fr:
338
+ lower_n += 1
339
+ action = "下调"
340
+ else:
341
+ flat_n += 1
342
+ action = "持平"
343
+ details.append({**it, "action": action, "delta": delta})
344
+ n = len(deltas)
345
+ if n == 0:
346
+ median = 0.0
347
+ else:
348
+ ordered = sorted(deltas)
349
+ mid = n // 2
350
+ median = ordered[mid] if n % 2 else (ordered[mid - 1] + ordered[mid]) / 2
351
+ list_all = len(items) < 10
352
+ return {
353
+ "raise_n": raise_n,
354
+ "lower_n": lower_n,
355
+ "flat_n": flat_n,
356
+ "median_abs": median,
357
+ "list_all": list_all,
358
+ "details": details if list_all else [],
359
+ }
360
+
361
+
362
+ def budget_abs_share(
363
+ channel: str,
364
+ *,
365
+ prev_abs: float,
366
+ curr_abs: float,
367
+ prev_share: float,
368
+ curr_share: float,
369
+ ) -> str:
370
+ abs_delta = curr_abs - prev_abs
371
+ share_delta = curr_share - prev_share
372
+ flag = ""
373
+ if share_delta > 0.2 and abs_delta < -0.5:
374
+ flag = ";⚠️占比升但金额降"
375
+ elif share_delta < -0.2 and abs_delta > 0.5:
376
+ flag = ";⚠️占比降但金额升"
377
+ return (
378
+ f"{channel}:${prev_abs:,.0f} → ${curr_abs:,.0f}({abs_delta:+,.0f});"
379
+ f"{prev_share:.1f}% → {curr_share:.1f}%({share_delta:+.1f}%){flag}"
380
+ )
381
+
382
+
383
+ _NAMING_SKIP = {
384
+ "ios",
385
+ "and",
386
+ "android",
387
+ "fb",
388
+ "tt",
389
+ "gg",
390
+ "bid",
391
+ "cap",
392
+ "all",
393
+ "copy",
394
+ "test",
395
+ "测试",
396
+ "自制",
397
+ "客供",
398
+ "英语",
399
+ "英文",
400
+ "中文",
401
+ "繁中",
402
+ "install",
403
+ "smart",
404
+ }
405
+
406
+
407
+ def naming_patterns(names: list[str], limit: int = 5) -> str:
408
+ counts: dict[str, int] = {}
409
+ for name in names:
410
+ for tok in str(name or "").replace("_", "-").split("-"):
411
+ tok = tok.strip()
412
+ if not tok or tok.isdigit() or len(tok) < 2 or tok.lower() in _NAMING_SKIP:
413
+ continue
414
+ counts[tok] = counts.get(tok, 0) + 1
415
+ top = sorted(counts, key=lambda k: (-counts[k], k))[:limit]
416
+ return "、".join(top) if top else "命名规律不明显"
417
+
418
+
419
+ def summarize_countries(countries: list[str]) -> tuple[str, list[str]]:
420
+ uniq = [c for c in dict.fromkeys(countries) if c]
421
+ if len(uniq) <= 50:
422
+ return f"共 {len(uniq)} 个国家", uniq
423
+ return f"国家超过 50 个,主页面只展示区域/大洲聚合(共 {len(uniq)} 个)", uniq
424
+
425
+
156
426
  def num(row: dict, key: str) -> float:
157
427
  v = row.get(key)
158
428
  if v in (None, "", "-"):
@@ -209,14 +479,14 @@ def hit_kpi(v, target: float = 5.0):
209
479
  return v <= target
210
480
 
211
481
 
212
- def qoq_axis(curr, prev):
482
+ def qoq_axis(curr, prev, higher_is_better=False):
213
483
  """成本类:下降为好。返回 (label, pct)。"""
214
484
  if curr is None or prev is None or prev == 0:
215
485
  return "无法对比", None
216
486
  change = (curr - prev) / prev
217
487
  if abs(change) <= 0.10:
218
488
  return "稳定", change
219
- return ("变好" if change < 0 else "变差"), change
489
+ return ("变好" if (change > 0 if higher_is_better else change < 0) else "变差"), change
220
490
 
221
491
 
222
492
  def dual_verdict(is_hit, qoq_label: str) -> str:
@@ -15,7 +15,11 @@ SCRIPTS = Path(__file__).resolve().parent
15
15
  if str(SCRIPTS) not in sys.path:
16
16
  sys.path.insert(0, str(SCRIPTS))
17
17
 
18
- from cli_json import run_cli, unwrap_page
18
+ from cli_json import list_query_body, run_cli, unwrap_page, require_ok, report_progress
19
+ from datetime import date
20
+
21
+ # 维度/成效 size=1000 时,TikTok 出价按天明细可到 4~5 万行,旧上限 30 页会截断
22
+ PAGE_CAP = 80
19
23
 
20
24
  INDICATORS = [
21
25
  "spend_usd",
@@ -45,17 +49,23 @@ def fetch_paged(shortcut: str, base_body: dict, stem: str, bl_dir: Path) -> list
45
49
  body = {**base_body, "page": page, "size": 1000}
46
50
  out = bl_dir / f"{stem}.p{page}.out.json"
47
51
  env = run_cli(["biz-line", shortcut], body, out)
48
- rows, total = unwrap_page(env)
52
+ require_ok(env, stem)
53
+ data = env.get("data")
54
+ if not isinstance(data, dict) or not isinstance(data.get("data"), list) or not isinstance(data.get("total"), int) or data["total"] < 0:
55
+ raise RuntimeError(f"{stem}: 分页信封缺少有效 data/total")
56
+ rows, current_total = unwrap_page(env)
57
+ if page > 1 and current_total != total:
58
+ raise RuntimeError(f"{stem}: 分页期间总数变化,请重新拉取")
59
+ total = current_total
49
60
  all_rows.extend(rows)
50
- print(
51
- f" {stem} page={page} got={len(rows)} acc={len(all_rows)} total={total} ok={env.get('ok')}",
52
- flush=True,
53
- )
54
- if not env.get("ok") or not rows or len(all_rows) >= total or len(rows) < 1000:
61
+ report_progress(f"{stem} page={page} got={len(rows)} acc={len(all_rows)} total={total}")
62
+ if len(all_rows) > total:
63
+ raise RuntimeError(f"{stem}: 累计行数超过 total")
64
+ if len(all_rows) == total:
55
65
  break
66
+ if not rows or page >= PAGE_CAP:
67
+ raise RuntimeError(f"{stem}: 分页不完整 {len(all_rows)}/{total}")
56
68
  page += 1
57
- if page > 30:
58
- break
59
69
  (bl_dir / f"{stem}.merged.json").write_text(
60
70
  json.dumps(all_rows, ensure_ascii=False), encoding="utf-8"
61
71
  )
@@ -72,12 +82,13 @@ CHANNEL_ALIAS = {
72
82
 
73
83
 
74
84
  def load_projects(req: Path, biz_line_id: int) -> tuple[list[str], dict[str, list[str]]]:
75
- """project-list 优先;404 / 失败时退回详情的 projectNames。账号按渠道去重。"""
85
+ """关联项目必须查询成功,名称为空时查详情;账号按渠道去重。"""
76
86
  pl = run_cli(
77
87
  ["biz-line", "+project-list"],
78
88
  {"bizLineId": biz_line_id},
79
89
  req / "biz-line-project-list.out.json",
80
90
  )
91
+ require_ok(pl, "关联项目")
81
92
  rows, _ = unwrap_page(pl)
82
93
  names = [
83
94
  p.get("projectName")
@@ -104,6 +115,7 @@ def load_projects(req: Path, biz_line_id: int) -> tuple[list[str], dict[str, lis
104
115
  {"id": biz_line_id},
105
116
  req / "biz-line-detail.out.json",
106
117
  )
118
+ require_ok(detail, "业务线详情")
107
119
  data = detail.get("data") if isinstance(detail.get("data"), dict) else {}
108
120
  names = list(data.get("projectNames") or [])
109
121
  return names, buckets
@@ -215,57 +227,79 @@ def met_dims(ch: str) -> list[list[str]]:
215
227
  return [["objective"], ["targeting_interests"], *common]
216
228
 
217
229
 
218
- def main() -> int:
230
+ def argument_parser(batch=False):
231
+ """单条诊断入口与批次入口共享日期和账号参数。"""
219
232
  parser = argparse.ArgumentParser(description=__doc__)
220
- parser.add_argument("--request-dir", required=True, help="本次 TEMP/sino-bc-strategy/<requestId>")
221
- parser.add_argument("--name", default="", help="业务线名称模糊匹配")
222
- parser.add_argument("--id", type=int, default=0, help="已知业务线 ID")
223
- parser.add_argument("--curr-start", required=True)
224
- parser.add_argument("--curr-end", required=True)
225
- parser.add_argument("--prev-start", required=True)
226
- parser.add_argument("--prev-end", required=True)
233
+ if not batch:
234
+ parser.add_argument("--request-dir", required=True, help="本次 TEMP/sino-bc-strategy/<requestId>")
235
+ target = parser.add_mutually_exclusive_group(required=True)
236
+ target.add_argument("--name", default="", help="业务线名称,命中多条时报歧义")
237
+ if batch:
238
+ target.add_argument("--id", type=int, action="append", default=[], help="业务线 ID,可重复传入多个")
239
+ else:
240
+ target.add_argument("--id", type=int, default=0, help="已知业务线 ID")
241
+ parser.add_argument("--curr-start", default="")
242
+ parser.add_argument("--curr-end", default="")
243
+ parser.add_argument("--prev-start", default="")
244
+ parser.add_argument("--prev-end", default="")
227
245
  parser.add_argument("--account-fb", action="append", default=[])
228
246
  parser.add_argument("--account-gg", action="append", default=[])
229
247
  parser.add_argument("--account-tt", action="append", default=[])
230
- args = parser.parse_args()
248
+ return parser
249
+
250
+
251
+ def fetch_business(args):
252
+ """一条业务线内所有调用串行;阶段失败交由统一入口收尾。"""
231
253
 
232
254
  req = Path(args.request_dir)
233
255
  req.mkdir(parents=True, exist_ok=True)
234
- periods = {
235
- "curr": (args.curr_start, args.curr_end),
236
- "prev": (args.prev_start, args.prev_end),
237
- }
238
256
  accounts = {"fb": args.account_fb, "gg": args.account_gg, "tt": args.account_tt}
239
257
 
240
- list_args = ["biz-line", "+list"]
241
- if args.id:
242
- list_args += ["--id", str(args.id)]
243
- elif args.name:
244
- list_args += ["--name", args.name]
245
- listed = run_cli(list_args, None, req / "biz-line-list.out.json")
246
- rows, _ = unwrap_page(listed)
258
+ listed = run_cli(
259
+ ["biz-line", "+list"],
260
+ list_query_body(name=args.name, biz_id=args.id),
261
+ req / "biz-line-list.out.json",
262
+ )
263
+ require_ok(listed, "查找业务线")
264
+ rows, total = unwrap_page(listed)
247
265
  if not rows:
248
- print("FAIL 业务线列表为空", flush=True)
249
- return 1
250
- if len(rows) > 1 and not args.id:
266
+ raise RuntimeError("业务线列表为空")
267
+ if len(rows) != 1 or total != 1:
251
268
  names = ";".join(f"{r.get('id')} {r.get('name')}" for r in rows[:10])
252
- print(f"FAIL 名称命中 {len(rows)} 条,请收窄:{names}", flush=True)
253
- return 1
269
+ raise RuntimeError(f"名称命中 {total} 条,请收窄:{names}")
254
270
  biz = rows[0]
255
271
  biz_id = int(biz["id"])
272
+ if args.id and biz_id != args.id:
273
+ raise RuntimeError("返回的业务线 ID 与请求不一致")
256
274
  (req / "biz-line.json").write_text(json.dumps(biz, ensure_ascii=False, indent=2), encoding="utf-8")
257
275
 
258
- run_cli(["biz-line", "+detail"], {"id": biz_id}, req / "biz-line-detail.out.json")
276
+ curr_start = args.curr_start or str(biz.get("periodStart") or "")
277
+ curr_end = args.curr_end or str(biz.get("periodEnd") or "")
278
+ prev_start = args.prev_start or str(biz.get("prevPeriodStart") or "")
279
+ prev_end = args.prev_end or str(biz.get("prevPeriodEnd") or "")
280
+ if not (curr_start and curr_end):
281
+ raise RuntimeError("缺少本期日期,且业务线也没有 periodStart/periodEnd")
282
+ periods = {"curr": (curr_start, curr_end)}
283
+ if prev_start and prev_end:
284
+ periods["prev"] = (prev_start, prev_end)
285
+ if bool(prev_start) != bool(prev_end):
286
+ raise ValueError("上期起止日期必须成对提供")
287
+ for start, end in periods.values():
288
+ if date.fromisoformat(start) > date.fromisoformat(end):
289
+ raise ValueError("周期开始日期晚于结束日期")
290
+
291
+ require_ok(run_cli(["biz-line", "+detail"], {"id": biz_id}, req / "biz-line-detail.out.json"), "业务线详情")
259
292
  names, discovered = load_projects(req, biz_id)
260
293
  # 用户指定账号覆盖接口返回;没指定则用项目下按 ID 去重后的账号
261
294
  for ch in accounts:
262
295
  if not accounts[ch]:
263
296
  accounts[ch] = discovered[ch]
297
+ accounts[ch] = list(dict.fromkeys(accounts[ch]))
264
298
  (req / "periods.json").write_text(
265
299
  json.dumps(
266
300
  {
267
- "curr": [args.curr_start, args.curr_end],
268
- "prev": [args.prev_start, args.prev_end],
301
+ "curr": [curr_start, curr_end],
302
+ "prev": [prev_start, prev_end],
269
303
  },
270
304
  ensure_ascii=False,
271
305
  indent=2,
@@ -281,15 +315,13 @@ def main() -> int:
281
315
  encoding="utf-8",
282
316
  )
283
317
  if not names and not any(accounts.values()):
284
- print("SKIP 未绑定项目/广告账号", flush=True)
285
- return 2
318
+ raise RuntimeError("未绑定项目/广告账号")
286
319
  if not any(accounts.values()):
287
- print("SKIP 项目下没有 Meta/Google/TikTok 广告账号", flush=True)
288
- return 2
289
- print("ACCOUNTS", json.dumps(accounts, ensure_ascii=False), flush=True)
320
+ raise RuntimeError("项目下没有 Meta/Google/TikTok 广告账号")
290
321
 
291
322
  bl_dir = req / f"bl-{biz_id}"
292
323
  bl_dir.mkdir(parents=True, exist_ok=True)
324
+ manifest = {}
293
325
 
294
326
  for ch, acc in accounts.items():
295
327
  if not acc:
@@ -304,7 +336,7 @@ def main() -> int:
304
336
  "dimensions": dims,
305
337
  **extra,
306
338
  }
307
- fetch_paged(DIM_CMD[ch], body, stem, bl_dir)
339
+ manifest[stem] = len(fetch_paged(DIM_CMD[ch], body, stem, bl_dir))
308
340
  for dims in met_dims(ch):
309
341
  stem = f"met_{ch}_{per}_{'+'.join(dims)}"
310
342
  body = {
@@ -314,9 +346,15 @@ def main() -> int:
314
346
  "dimensions": dims,
315
347
  "indicators": INDICATORS,
316
348
  }
317
- fetch_paged(MET_CMD[ch], body, stem, bl_dir)
349
+ manifest[stem] = len(fetch_paged(MET_CMD[ch], body, stem, bl_dir))
318
350
 
319
- print("DONE", flush=True)
351
+ # 只有全部所需数据完成后登记清单,汇总前按清单核对文件和数量。
352
+ (req / 'fetch-manifest.json').write_text(json.dumps(manifest), encoding='utf-8')
353
+ return biz
354
+
355
+
356
+ def main() -> int:
357
+ fetch_business(argument_parser().parse_args())
320
358
  return 0
321
359
 
322
360