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.
- package/dist/SKILL.md +1 -1
- package/dist/index.js +1 -1
- package/dist/skills/custom/biz-line-strategy-generate/SKILL.md +53 -63
- package/dist/skills/custom/biz-line-strategy-generate/references/api-flow.md +6 -5
- package/dist/skills/custom/biz-line-strategy-generate/references/four-strategies.md +19 -17
- package/dist/skills/custom/biz-line-strategy-generate/references/metrics.md +29 -280
- package/dist/skills/custom/biz-line-strategy-generate/references/output-excel.md +9 -53
- package/dist/skills/custom/biz-line-strategy-generate/references/runtime.md +22 -57
- package/dist/skills/custom/biz-line-strategy-generate/references/spec.md +124 -0
- package/dist/skills/custom/biz-line-strategy-generate/scripts/build_report.py +172 -35
- package/dist/skills/custom/biz-line-strategy-generate/scripts/cleanup.py +71 -80
- package/dist/skills/custom/biz-line-strategy-generate/scripts/cli_json.py +288 -18
- package/dist/skills/custom/biz-line-strategy-generate/scripts/fetch_data.py +84 -46
- package/dist/skills/custom/biz-line-strategy-generate/scripts/run_workflow.py +189 -0
- package/dist/skills/custom/biz-line-strategy-generate/scripts/summarize.py +315 -238
- package/dist/skills/custom/biz-line-strategy-generate/scripts/test_strategy_rules.py +215 -0
- package/dist/skills/custom/biz-line-strategy-generate/scripts/test_workflow.py +345 -0
- package/package.json +1 -1
|
@@ -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")
|
|
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")
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
"""
|
|
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
|
|
230
|
+
def argument_parser(batch=False):
|
|
231
|
+
"""单条诊断入口与批次入口共享日期和账号参数。"""
|
|
219
232
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
parser.
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
listed
|
|
246
|
-
rows,
|
|
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
|
-
|
|
249
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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": [
|
|
268
|
-
"prev": [
|
|
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
|
-
|
|
285
|
-
return 2
|
|
318
|
+
raise RuntimeError("未绑定项目/广告账号")
|
|
286
319
|
if not any(accounts.values()):
|
|
287
|
-
|
|
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
|
-
|
|
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
|
|