sino-bc-cli 1.8.20 → 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.
Files changed (22) hide show
  1. package/dist/SKILL.md +1 -1
  2. package/dist/index.js +1 -1
  3. package/dist/skills/custom/biz-line-strategy-generate/SKILL.md +20 -23
  4. package/dist/skills/custom/biz-line-strategy-generate/references/api-flow.md +5 -5
  5. package/dist/skills/custom/biz-line-strategy-generate/references/four-strategies.md +16 -14
  6. package/dist/skills/custom/biz-line-strategy-generate/references/metrics.md +29 -278
  7. package/dist/skills/custom/biz-line-strategy-generate/references/output-excel.md +8 -57
  8. package/dist/skills/custom/biz-line-strategy-generate/references/runtime.md +22 -57
  9. package/dist/skills/custom/biz-line-strategy-generate/references/spec.md +124 -0
  10. package/dist/skills/custom/biz-line-strategy-generate/scripts/build_report.py +98 -9
  11. package/dist/skills/custom/biz-line-strategy-generate/scripts/cleanup.py +71 -82
  12. package/dist/skills/custom/biz-line-strategy-generate/scripts/cli_json.py +125 -19
  13. package/dist/skills/custom/biz-line-strategy-generate/scripts/fetch_data.py +61 -32
  14. package/dist/skills/custom/biz-line-strategy-generate/scripts/run_workflow.py +189 -0
  15. package/dist/skills/custom/biz-line-strategy-generate/scripts/summarize.py +158 -148
  16. package/dist/skills/custom/biz-line-strategy-generate/scripts/test_strategy_rules.py +16 -2
  17. package/dist/skills/custom/biz-line-strategy-generate/scripts/test_workflow.py +345 -0
  18. package/package.json +1 -1
  19. package/dist/skills/custom/biz-line-strategy-generate/scripts/__pycache__/build_report.cpython-314.pyc +0 -0
  20. package/dist/skills/custom/biz-line-strategy-generate/scripts/__pycache__/cli_json.cpython-314.pyc +0 -0
  21. package/dist/skills/custom/biz-line-strategy-generate/scripts/__pycache__/fetch_data.cpython-314.pyc +0 -0
  22. package/dist/skills/custom/biz-line-strategy-generate/scripts/__pycache__/summarize.cpython-314.pyc +0 -0
@@ -1,98 +1,87 @@
1
1
  #!/usr/bin/env python3
2
2
  # -*- coding: utf-8 -*-
3
- """只清理「本次请求」登记过的临时目录和 Excel。
4
-
5
- 默认生成流程:Excel 落盘后不要删 request 目录(现拉回包要留着备查)。
6
- 只有用户明确要求清缓存时才传 --request-dir。
7
- 不会按桌面文件名 glob,也不会删除未出现在 --written-excel 里的文件。
8
- """
3
+ """仅清理本次进程创建并登记的目录,所有类型临时文件统一处理。"""
9
4
  from __future__ import annotations
10
5
 
11
- import argparse
12
6
  import json
13
7
  import os
14
- import shutil
15
- import sys
16
-
8
+ import stat
9
+ import tempfile
10
+ import uuid
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
17
13
 
18
- def rm_request_dir(path: str) -> dict:
19
- if not path:
20
- return {"ok": False, "error": "empty --request-dir"}
21
- abs_path = os.path.abspath(path)
22
- if not os.path.isdir(abs_path):
23
- return {"ok": True, "removed_dir": abs_path, "existed": False}
24
- # 只允许删 sino-bc-strategy 下的某一层 requestId 目录,防止误传桌面/主目录
25
- parent = os.path.basename(os.path.dirname(abs_path)).lower()
26
- if parent != "sino-bc-strategy":
27
- return {
28
- "ok": False,
29
- "error": "refuse: --request-dir 必须是 .../sino-bc-strategy/<requestId>",
30
- }
31
- shutil.rmtree(abs_path, ignore_errors=False)
32
- return {"ok": True, "removed_dir": abs_path, "existed": True}
14
+ MARKER = '.strategy-owner.json'
33
15
 
34
16
 
35
- def rm_extra_excels(keep: str | None, written: list[str]) -> dict:
36
- keep_abs = os.path.abspath(keep) if keep else None
37
- deleted = []
38
- skipped = []
39
- for raw in written:
40
- if not raw:
41
- continue
42
- target = os.path.abspath(raw)
43
- if keep_abs and os.path.normcase(target) == os.path.normcase(keep_abs):
44
- skipped.append({"path": target, "reason": "keep"})
45
- continue
46
- if not os.path.isfile(target):
47
- skipped.append({"path": target, "reason": "missing"})
48
- continue
49
- os.remove(target)
50
- deleted.append(target)
51
- return {"ok": True, "deleted": deleted, "skipped": skipped, "kept": keep_abs}
17
+ def is_link(path: Path) -> bool:
18
+ """Windows junction 等重解析点与符号链接都不允许递归跟随。"""
19
+ info = path.lstat()
20
+ return stat.S_ISLNK(info.st_mode) or bool(getattr(info, 'st_file_attributes', 0) & 0x400)
52
21
 
53
22
 
54
- def main() -> int:
55
- parser = argparse.ArgumentParser(description=__doc__)
56
- parser.add_argument(
57
- "--request-dir",
58
- default=None,
59
- help="本次请求工作目录 TEMP/sino-bc-strategy/<requestId>,整目录删除",
60
- )
61
- parser.add_argument(
62
- "--keep-excel",
63
- default=None,
64
- help="最终保留的 xlsx 绝对路径",
65
- )
66
- parser.add_argument(
67
- "--written-excel",
68
- action="append",
69
- default=[],
70
- help="本次请求成功写出过的 xlsx,可重复;只从这份名单里删",
71
- )
72
- args = parser.parse_args()
23
+ @dataclass(frozen=True)
24
+ class OwnedDirectory:
25
+ """创建时保存真实父路径与随机令牌,清理时不能只相信目录名。"""
26
+ path: Path
27
+ parent: Path
28
+ token: str
73
29
 
74
- if not args.request_dir and not args.written_excel:
75
- print(
76
- json.dumps(
77
- {"ok": False, "error": "至少传 --request-dir 或 --written-excel"},
78
- ensure_ascii=False,
79
- )
80
- )
81
- return 1
30
+ @classmethod
31
+ def create(cls, parent: Path, prefix: str):
32
+ parent = parent.resolve()
33
+ parent.mkdir(parents=True, exist_ok=True)
34
+ path = Path(tempfile.mkdtemp(prefix=prefix, dir=parent))
35
+ token = uuid.uuid4().hex
36
+ (path / MARKER).write_text(json.dumps({'token': token, 'path': str(path)}), encoding='utf-8')
37
+ return cls(path, parent, token)
82
38
 
83
- result = {"ok": True}
84
- if args.written_excel:
85
- result["excels"] = rm_extra_excels(args.keep_excel, args.written_excel)
86
- if result["excels"].get("ok") is False:
87
- result["ok"] = False
88
- if args.request_dir:
89
- result["temps"] = rm_request_dir(args.request_dir)
90
- if result["temps"].get("ok") is False:
91
- result["ok"] = False
39
+ def validate(self):
40
+ """根目录或父路径被替换后拒绝删除,防止越界。"""
41
+ if is_link(self.path) or self.path.resolve() != self.path or self.path.parent.resolve() != self.parent:
42
+ raise ValueError(f'清理范围已变化:{self.path}')
43
+ marker = self.path / MARKER
44
+ if is_link(marker):
45
+ raise ValueError('所有权文件不能是链接')
46
+ data = json.loads(marker.read_text(encoding='utf-8'))
47
+ if data != {'token': self.token, 'path': str(self.path)}:
48
+ raise ValueError(f'所有权不匹配:{self.path}')
92
49
 
93
- print(json.dumps(result, ensure_ascii=False))
94
- return 0 if result["ok"] else 1
50
+ def cleanup(self, final: Path | None = None) -> dict:
51
+ """不按后缀筛选;保留可返回的失败清单,绝不静默忽略残留。"""
52
+ errors = []
53
+ try:
54
+ self.validate()
55
+ if final is not None and final.resolve().is_relative_to(self.path):
56
+ raise ValueError('最终 Excel 位于临时目录内,拒绝清理')
57
+ except Exception as exc:
58
+ return {'ok': False, 'residual': [{'path': str(self.path), 'error': str(exc)}]}
95
59
 
60
+ def remove(path):
61
+ try:
62
+ # 先 lstat 再决定删除方式;对 junction 仅解除链接自身。
63
+ if is_link(path):
64
+ if path.is_symlink():
65
+ path.unlink()
66
+ else:
67
+ os.rmdir(path)
68
+ elif path.is_dir():
69
+ for child in path.iterdir():
70
+ remove(child)
71
+ path.rmdir()
72
+ else:
73
+ path.unlink()
74
+ except Exception as exc:
75
+ errors.append({'path': str(path), 'error': str(exc)})
96
76
 
97
- if __name__ == "__main__":
98
- sys.exit(main())
77
+ # 若某子项未能删除,留下所有权标记供诊断,不伪报清理成功。
78
+ for child in self.path.iterdir():
79
+ if child.name != MARKER:
80
+ remove(child)
81
+ if not errors:
82
+ remove(self.path / MARKER)
83
+ try:
84
+ self.path.rmdir()
85
+ except Exception as exc:
86
+ errors.append({'path': str(self.path), 'error': str(exc)})
87
+ return {'ok': not errors, 'residual': errors}
@@ -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,26 +164,37 @@ 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
 
131
174
  def exec_cli_to_file(cmd: list[str], out_path: Path) -> tuple[int, str]:
132
- """CLI 回包直写文件,避免 capture_output 在沙箱里被截到约 64KB。"""
175
+ """两个输出流直写本次文件;取消时等待子进程退出后才允许清理。"""
133
176
  out_path.parent.mkdir(parents=True, exist_ok=True)
134
- with out_path.open("w", encoding="utf-8") as f:
135
- proc = subprocess.run(
136
- cmd,
137
- stdout=f,
138
- stderr=subprocess.PIPE,
139
- text=True,
140
- encoding="utf-8",
141
- errors="replace",
142
- shell=False,
143
- )
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()
144
198
  raw = out_path.read_text(encoding="utf-8")
145
199
  return proc.returncode, raw
146
200
 
@@ -162,6 +216,7 @@ def list_query_body(*, name: str = "", biz_id: int = 0) -> dict:
162
216
 
163
217
 
164
218
  def run_cli(args: list[str], body: dict | None, out_path: Path) -> dict:
219
+ out_path.parent.mkdir(parents=True, exist_ok=True)
165
220
  cmd = [*resolve_cli(), *args, "--format", "json"]
166
221
  if body is not None:
167
222
  body_path = out_path.with_name(out_path.name.replace(".out.json", ".body.json"))
@@ -170,13 +225,64 @@ def run_cli(args: list[str], body: dict | None, out_path: Path) -> dict:
170
225
  body_path.write_text(json.dumps(body, ensure_ascii=False), encoding="utf-8")
171
226
  cmd.extend(["--data", json.dumps(body, ensure_ascii=False)])
172
227
  rc, raw = exec_cli_to_file(cmd, out_path)
173
- parsed = parse_json_text(raw.strip()) or {"ok": False, "error": (raw or "")[:400], "rc": rc}
174
- if rc != 0:
175
- parsed.setdefault("ok", False)
176
- parsed.setdefault("rc", rc)
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}
177
233
  return parsed
178
234
 
179
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
+
180
286
  def is_first_period(biz: dict) -> bool:
181
287
  start = str((biz or {}).get("prevPeriodStart") or "").strip()
182
288
  end = str((biz or {}).get("prevPeriodEnd") or "").strip()
@@ -373,14 +479,14 @@ def hit_kpi(v, target: float = 5.0):
373
479
  return v <= target
374
480
 
375
481
 
376
- def qoq_axis(curr, prev):
482
+ def qoq_axis(curr, prev, higher_is_better=False):
377
483
  """成本类:下降为好。返回 (label, pct)。"""
378
484
  if curr is None or prev is None or prev == 0:
379
485
  return "无法对比", None
380
486
  change = (curr - prev) / prev
381
487
  if abs(change) <= 0.10:
382
488
  return "稳定", change
383
- return ("变好" if change < 0 else "变差"), change
489
+ return ("变好" if (change > 0 if higher_is_better else change < 0) else "变差"), change
384
490
 
385
491
 
386
492
  def dual_verdict(is_hit, qoq_label: str) -> str:
@@ -15,7 +15,8 @@ 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 list_query_body, 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
19
20
 
20
21
  # 维度/成效 size=1000 时,TikTok 出价按天明细可到 4~5 万行,旧上限 30 页会截断
21
22
  PAGE_CAP = 80
@@ -48,17 +49,23 @@ def fetch_paged(shortcut: str, base_body: dict, stem: str, bl_dir: Path) -> list
48
49
  body = {**base_body, "page": page, "size": 1000}
49
50
  out = bl_dir / f"{stem}.p{page}.out.json"
50
51
  env = run_cli(["biz-line", shortcut], body, out)
51
- 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
52
60
  all_rows.extend(rows)
53
- print(
54
- f" {stem} page={page} got={len(rows)} acc={len(all_rows)} total={total} ok={env.get('ok')}",
55
- flush=True,
56
- )
57
- 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:
58
65
  break
66
+ if not rows or page >= PAGE_CAP:
67
+ raise RuntimeError(f"{stem}: 分页不完整 {len(all_rows)}/{total}")
59
68
  page += 1
60
- if page > PAGE_CAP:
61
- break
62
69
  (bl_dir / f"{stem}.merged.json").write_text(
63
70
  json.dumps(all_rows, ensure_ascii=False), encoding="utf-8"
64
71
  )
@@ -75,12 +82,13 @@ CHANNEL_ALIAS = {
75
82
 
76
83
 
77
84
  def load_projects(req: Path, biz_line_id: int) -> tuple[list[str], dict[str, list[str]]]:
78
- """project-list 优先;404 / 失败时退回详情的 projectNames。账号按渠道去重。"""
85
+ """关联项目必须查询成功,名称为空时查详情;账号按渠道去重。"""
79
86
  pl = run_cli(
80
87
  ["biz-line", "+project-list"],
81
88
  {"bizLineId": biz_line_id},
82
89
  req / "biz-line-project-list.out.json",
83
90
  )
91
+ require_ok(pl, "关联项目")
84
92
  rows, _ = unwrap_page(pl)
85
93
  names = [
86
94
  p.get("projectName")
@@ -107,6 +115,7 @@ def load_projects(req: Path, biz_line_id: int) -> tuple[list[str], dict[str, lis
107
115
  {"id": biz_line_id},
108
116
  req / "biz-line-detail.out.json",
109
117
  )
118
+ require_ok(detail, "业务线详情")
110
119
  data = detail.get("data") if isinstance(detail.get("data"), dict) else {}
111
120
  names = list(data.get("projectNames") or [])
112
121
  return names, buckets
@@ -218,11 +227,17 @@ def met_dims(ch: str) -> list[list[str]]:
218
227
  return [["objective"], ["targeting_interests"], *common]
219
228
 
220
229
 
221
- def main() -> int:
230
+ def argument_parser(batch=False):
231
+ """单条诊断入口与批次入口共享日期和账号参数。"""
222
232
  parser = argparse.ArgumentParser(description=__doc__)
223
- parser.add_argument("--request-dir", required=True, help="本次 TEMP/sino-bc-strategy/<requestId>")
224
- parser.add_argument("--name", default="", help="业务线名称模糊匹配")
225
- parser.add_argument("--id", type=int, default=0, help="已知业务线 ID")
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")
226
241
  parser.add_argument("--curr-start", default="")
227
242
  parser.add_argument("--curr-end", default="")
228
243
  parser.add_argument("--prev-start", default="")
@@ -230,7 +245,11 @@ def main() -> int:
230
245
  parser.add_argument("--account-fb", action="append", default=[])
231
246
  parser.add_argument("--account-gg", action="append", default=[])
232
247
  parser.add_argument("--account-tt", action="append", default=[])
233
- args = parser.parse_args()
248
+ return parser
249
+
250
+
251
+ def fetch_business(args):
252
+ """一条业务线内所有调用串行;阶段失败交由统一入口收尾。"""
234
253
 
235
254
  req = Path(args.request_dir)
236
255
  req.mkdir(parents=True, exist_ok=True)
@@ -241,16 +260,17 @@ def main() -> int:
241
260
  list_query_body(name=args.name, biz_id=args.id),
242
261
  req / "biz-line-list.out.json",
243
262
  )
244
- rows, _ = unwrap_page(listed)
263
+ require_ok(listed, "查找业务线")
264
+ rows, total = unwrap_page(listed)
245
265
  if not rows:
246
- print("FAIL 业务线列表为空", flush=True)
247
- return 1
248
- if len(rows) > 1 and not args.id:
266
+ raise RuntimeError("业务线列表为空")
267
+ if len(rows) != 1 or total != 1:
249
268
  names = ";".join(f"{r.get('id')} {r.get('name')}" for r in rows[:10])
250
- print(f"FAIL 名称命中 {len(rows)} 条,请收窄:{names}", flush=True)
251
- return 1
269
+ raise RuntimeError(f"名称命中 {total} 条,请收窄:{names}")
252
270
  biz = rows[0]
253
271
  biz_id = int(biz["id"])
272
+ if args.id and biz_id != args.id:
273
+ raise RuntimeError("返回的业务线 ID 与请求不一致")
254
274
  (req / "biz-line.json").write_text(json.dumps(biz, ensure_ascii=False, indent=2), encoding="utf-8")
255
275
 
256
276
  curr_start = args.curr_start or str(biz.get("periodStart") or "")
@@ -258,18 +278,23 @@ def main() -> int:
258
278
  prev_start = args.prev_start or str(biz.get("prevPeriodStart") or "")
259
279
  prev_end = args.prev_end or str(biz.get("prevPeriodEnd") or "")
260
280
  if not (curr_start and curr_end):
261
- print("FAIL 缺少本期日期,且业务线也没有 periodStart/periodEnd", flush=True)
262
- return 1
281
+ raise RuntimeError("缺少本期日期,且业务线也没有 periodStart/periodEnd")
263
282
  periods = {"curr": (curr_start, curr_end)}
264
283
  if prev_start and prev_end:
265
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("周期开始日期晚于结束日期")
266
290
 
267
- run_cli(["biz-line", "+detail"], {"id": biz_id}, req / "biz-line-detail.out.json")
291
+ require_ok(run_cli(["biz-line", "+detail"], {"id": biz_id}, req / "biz-line-detail.out.json"), "业务线详情")
268
292
  names, discovered = load_projects(req, biz_id)
269
293
  # 用户指定账号覆盖接口返回;没指定则用项目下按 ID 去重后的账号
270
294
  for ch in accounts:
271
295
  if not accounts[ch]:
272
296
  accounts[ch] = discovered[ch]
297
+ accounts[ch] = list(dict.fromkeys(accounts[ch]))
273
298
  (req / "periods.json").write_text(
274
299
  json.dumps(
275
300
  {
@@ -290,15 +315,13 @@ def main() -> int:
290
315
  encoding="utf-8",
291
316
  )
292
317
  if not names and not any(accounts.values()):
293
- print("SKIP 未绑定项目/广告账号", flush=True)
294
- return 2
318
+ raise RuntimeError("未绑定项目/广告账号")
295
319
  if not any(accounts.values()):
296
- print("SKIP 项目下没有 Meta/Google/TikTok 广告账号", flush=True)
297
- return 2
298
- print("ACCOUNTS", json.dumps(accounts, ensure_ascii=False), flush=True)
320
+ raise RuntimeError("项目下没有 Meta/Google/TikTok 广告账号")
299
321
 
300
322
  bl_dir = req / f"bl-{biz_id}"
301
323
  bl_dir.mkdir(parents=True, exist_ok=True)
324
+ manifest = {}
302
325
 
303
326
  for ch, acc in accounts.items():
304
327
  if not acc:
@@ -313,7 +336,7 @@ def main() -> int:
313
336
  "dimensions": dims,
314
337
  **extra,
315
338
  }
316
- fetch_paged(DIM_CMD[ch], body, stem, bl_dir)
339
+ manifest[stem] = len(fetch_paged(DIM_CMD[ch], body, stem, bl_dir))
317
340
  for dims in met_dims(ch):
318
341
  stem = f"met_{ch}_{per}_{'+'.join(dims)}"
319
342
  body = {
@@ -323,9 +346,15 @@ def main() -> int:
323
346
  "dimensions": dims,
324
347
  "indicators": INDICATORS,
325
348
  }
326
- fetch_paged(MET_CMD[ch], body, stem, bl_dir)
349
+ manifest[stem] = len(fetch_paged(MET_CMD[ch], body, stem, bl_dir))
350
+
351
+ # 只有全部所需数据完成后登记清单,汇总前按清单核对文件和数量。
352
+ (req / 'fetch-manifest.json').write_text(json.dumps(manifest), encoding='utf-8')
353
+ return biz
327
354
 
328
- print("DONE", flush=True)
355
+
356
+ def main() -> int:
357
+ fetch_business(argument_parser().parse_args())
329
358
  return 0
330
359
 
331
360