sino-bc-cli 1.8.22 → 1.8.24
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/index.js +1 -1
- package/dist/skills/custom/biz-line-strategy-generate/SKILL.md +3 -2
- package/dist/skills/custom/biz-line-strategy-generate/references/api-flow.md +1 -1
- package/dist/skills/custom/biz-line-strategy-generate/references/output-excel.md +1 -1
- package/dist/skills/custom/biz-line-strategy-generate/references/runtime.md +3 -2
- package/dist/skills/custom/biz-line-strategy-generate/references/spec.md +18 -3
- package/dist/skills/custom/biz-line-strategy-generate/scripts/build_report.py +225 -79
- package/dist/skills/custom/biz-line-strategy-generate/scripts/cli_json.py +94 -20
- package/dist/skills/custom/biz-line-strategy-generate/scripts/fetch_data.py +31 -16
- package/dist/skills/custom/biz-line-strategy-generate/scripts/summarize.py +682 -412
- package/dist/skills/custom/biz-line-strategy-generate/scripts/test_strategy_rules.py +53 -6
- package/dist/skills/custom/biz-line-strategy-generate/scripts/test_workflow.py +279 -6
- package/package.json +1 -1
|
@@ -142,10 +142,39 @@ def parse_json_text(raw: str):
|
|
|
142
142
|
return None
|
|
143
143
|
|
|
144
144
|
|
|
145
|
-
def load_json_file(path: Path):
|
|
145
|
+
def load_json_file(path: Path, drop_page_rows: bool = False):
|
|
146
|
+
"""从文件解析 JSON。优先 json.load,避免先整文件读成 str 再 loads。
|
|
147
|
+
|
|
148
|
+
drop_page_rows:分页信封在 object_hook 里记下行数并丢掉行 list,调用方拿不到整页行。
|
|
149
|
+
解析失败只读文件头,不把后面埋着的 JSON 当成功回包。
|
|
150
|
+
"""
|
|
146
151
|
if not path.exists():
|
|
147
152
|
return None
|
|
148
|
-
|
|
153
|
+
|
|
154
|
+
def hook(obj):
|
|
155
|
+
if isinstance(obj.get("data"), list) and obj.get("total") is not None:
|
|
156
|
+
obj["_page_got"] = len(obj["data"])
|
|
157
|
+
obj["data"] = []
|
|
158
|
+
if isinstance(obj.get("result"), list) and obj.get("total") is not None:
|
|
159
|
+
obj["_page_got"] = obj.get("_page_got") or len(obj["result"])
|
|
160
|
+
obj["result"] = []
|
|
161
|
+
return obj
|
|
162
|
+
|
|
163
|
+
try:
|
|
164
|
+
with path.open(encoding="utf-8") as handle:
|
|
165
|
+
if drop_page_rows:
|
|
166
|
+
return json.load(handle, object_hook=hook)
|
|
167
|
+
return json.load(handle)
|
|
168
|
+
except json.JSONDecodeError:
|
|
169
|
+
return parse_json_text(read_text_head(path))
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def read_text_head(path: Path, limit: int = 2000) -> str:
|
|
173
|
+
"""失败摘要只读文件头部,不把整页 stdout 再读进内存。"""
|
|
174
|
+
if not path.exists():
|
|
175
|
+
return ""
|
|
176
|
+
with path.open(encoding="utf-8", errors="replace") as handle:
|
|
177
|
+
return handle.read(limit)
|
|
149
178
|
|
|
150
179
|
|
|
151
180
|
def unwrap_page(envelope: dict) -> tuple[list, int]:
|
|
@@ -171,8 +200,23 @@ def unwrap_page(envelope: dict) -> tuple[list, int]:
|
|
|
171
200
|
return [], 0
|
|
172
201
|
|
|
173
202
|
|
|
174
|
-
def
|
|
175
|
-
"""
|
|
203
|
+
def take_page_stats(envelope: dict) -> tuple[int, int]:
|
|
204
|
+
"""取出本页行数与 total,并丢掉行 list,让整页数据可被回收。"""
|
|
205
|
+
rows, total = unwrap_page(envelope)
|
|
206
|
+
got = len(rows)
|
|
207
|
+
data = envelope.get("data")
|
|
208
|
+
if isinstance(data, dict):
|
|
209
|
+
if isinstance(data.get("data"), list):
|
|
210
|
+
data["data"] = []
|
|
211
|
+
if isinstance(data.get("result"), list):
|
|
212
|
+
data["result"] = []
|
|
213
|
+
elif isinstance(data, list):
|
|
214
|
+
envelope["data"] = []
|
|
215
|
+
return got, total
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def exec_cli_to_file(cmd: list[str], out_path: Path) -> int:
|
|
219
|
+
"""两个输出流直写本次文件,只返回退出码,不把 stdout 读回内存。"""
|
|
176
220
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
177
221
|
cancel = getattr(_local, "cancel", None)
|
|
178
222
|
if cancel and cancel.is_set():
|
|
@@ -195,8 +239,7 @@ def exec_cli_to_file(cmd: list[str], out_path: Path) -> tuple[int, str]:
|
|
|
195
239
|
except subprocess.TimeoutExpired:
|
|
196
240
|
proc.kill()
|
|
197
241
|
proc.wait()
|
|
198
|
-
|
|
199
|
-
return proc.returncode, raw
|
|
242
|
+
return proc.returncode
|
|
200
243
|
|
|
201
244
|
|
|
202
245
|
def list_query_body(*, name: str = "", biz_id: int = 0) -> dict:
|
|
@@ -215,7 +258,7 @@ def list_query_body(*, name: str = "", biz_id: int = 0) -> dict:
|
|
|
215
258
|
return body
|
|
216
259
|
|
|
217
260
|
|
|
218
|
-
def run_cli(args: list[str], body: dict | None, out_path: Path) -> dict:
|
|
261
|
+
def run_cli(args: list[str], body: dict | None, out_path: Path, *, drop_rows: bool = False) -> dict:
|
|
219
262
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
220
263
|
cmd = [*resolve_cli(), *args, "--format", "json"]
|
|
221
264
|
if body is not None:
|
|
@@ -224,11 +267,12 @@ def run_cli(args: list[str], body: dict | None, out_path: Path) -> dict:
|
|
|
224
267
|
body_path = out_path.with_suffix(".body.json")
|
|
225
268
|
body_path.write_text(json.dumps(body, ensure_ascii=False), encoding="utf-8")
|
|
226
269
|
cmd.extend(["--data", json.dumps(body, ensure_ascii=False)])
|
|
227
|
-
rc
|
|
228
|
-
parsed =
|
|
229
|
-
|
|
270
|
+
rc = exec_cli_to_file(cmd, out_path)
|
|
271
|
+
parsed = load_json_file(out_path, drop_page_rows=drop_rows)
|
|
272
|
+
stderr_path = out_path.with_suffix(".stderr.txt")
|
|
230
273
|
if not isinstance(parsed, dict) or rc != 0 or parsed.get("ok") is not True:
|
|
231
|
-
|
|
274
|
+
stderr = read_text_head(stderr_path)
|
|
275
|
+
error = parse_json_text(stderr) or stderr or parsed or read_text_head(out_path) or "无有效 CLI 回包"
|
|
232
276
|
parsed = {"ok": False, "error": error, "rc": rc}
|
|
233
277
|
return parsed
|
|
234
278
|
|
|
@@ -249,16 +293,46 @@ def optional_num(row, key):
|
|
|
249
293
|
return None
|
|
250
294
|
|
|
251
295
|
|
|
252
|
-
|
|
253
|
-
"""
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
296
|
+
METRIC_FORMULAS = {
|
|
297
|
+
"CPI": ("spend_usd", "installs", 1),
|
|
298
|
+
"CPC": ("spend_usd", "clicks", 1),
|
|
299
|
+
"CPM": ("spend_usd", "impressions", 1000),
|
|
300
|
+
"ROAS": ("purchase_value", "spend_usd", 1),
|
|
301
|
+
"CTR": ("clicks", "impressions", 100),
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def new_metric_acc(metric="CPI"):
|
|
306
|
+
a, b, scale = METRIC_FORMULAS[metric]
|
|
307
|
+
return {"a": a, "b": b, "scale": scale, "num_a": 0.0, "num_b": 0.0, "ok": True, "seen": False}
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def add_metric_acc(acc, row) -> None:
|
|
311
|
+
if not acc["ok"]:
|
|
312
|
+
return
|
|
313
|
+
acc["seen"] = True
|
|
314
|
+
left, right = optional_num(row, acc["a"]), optional_num(row, acc["b"])
|
|
315
|
+
if left is None or right is None:
|
|
316
|
+
acc["ok"] = False
|
|
317
|
+
return
|
|
318
|
+
acc["num_a"] += left
|
|
319
|
+
acc["num_b"] += right
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def finish_metric_acc(acc):
|
|
323
|
+
if not acc["ok"] or not acc["seen"]:
|
|
259
324
|
return None
|
|
260
|
-
|
|
261
|
-
|
|
325
|
+
return acc["num_a"] / acc["num_b"] * acc["scale"] if acc["num_b"] > 0 else None
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def metric_value(rows, metric="CPI"):
|
|
329
|
+
"""同一粒度先合计基础指标再计算,任一缺失不当作零补齐。只扫一遍,可接迭代器。"""
|
|
330
|
+
acc = new_metric_acc(metric)
|
|
331
|
+
for row in rows or ():
|
|
332
|
+
add_metric_acc(acc, row)
|
|
333
|
+
if not acc["ok"]:
|
|
334
|
+
return None
|
|
335
|
+
return finish_metric_acc(acc)
|
|
262
336
|
|
|
263
337
|
|
|
264
338
|
def core_rule(text):
|
|
@@ -15,7 +15,7 @@ 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, require_ok, report_progress
|
|
18
|
+
from cli_json import list_query_body, run_cli, unwrap_page, take_page_stats, require_ok, report_progress
|
|
19
19
|
from datetime import date
|
|
20
20
|
|
|
21
21
|
# 维度/成效 size=1000 时,TikTok 出价按天明细可到 4~5 万行,旧上限 30 页会截断
|
|
@@ -41,35 +41,50 @@ MET_CMD = {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
|
|
44
|
-
def fetch_paged(shortcut: str, base_body: dict, stem: str, bl_dir: Path) ->
|
|
45
|
-
|
|
44
|
+
def fetch_paged(shortcut: str, base_body: dict, stem: str, bl_dir: Path) -> int:
|
|
45
|
+
"""逐页把 CLI 回包落到 stem.pN.out.json,成功后再写 stem.pages.json。
|
|
46
|
+
|
|
47
|
+
只在内存里保留当前页和行数累计,不把全表 extend 成一份 list,也不写 merged.json。
|
|
48
|
+
开始前删掉本 stem 残留索引,避免同目录重试失败后仍留下上一轮完整 pages.json。
|
|
49
|
+
"""
|
|
50
|
+
index_path = bl_dir / f"{stem}.pages.json"
|
|
51
|
+
if index_path.exists():
|
|
52
|
+
index_path.unlink()
|
|
53
|
+
accumulated = 0
|
|
54
|
+
counts: list[int] = []
|
|
46
55
|
page = 1
|
|
47
56
|
total = 0
|
|
48
57
|
while True:
|
|
49
58
|
body = {**base_body, "page": page, "size": 1000}
|
|
50
59
|
out = bl_dir / f"{stem}.p{page}.out.json"
|
|
51
|
-
env = run_cli(["biz-line", shortcut], body, out)
|
|
60
|
+
env = run_cli(["biz-line", shortcut], body, out, drop_rows=True)
|
|
52
61
|
require_ok(env, stem)
|
|
53
62
|
data = env.get("data")
|
|
54
63
|
if not isinstance(data, dict) or not isinstance(data.get("data"), list) or not isinstance(data.get("total"), int) or data["total"] < 0:
|
|
55
64
|
raise RuntimeError(f"{stem}: 分页信封缺少有效 data/total")
|
|
56
|
-
|
|
65
|
+
got = data.get("_page_got")
|
|
66
|
+
if isinstance(got, int):
|
|
67
|
+
current_total = data["total"]
|
|
68
|
+
else:
|
|
69
|
+
got, current_total = take_page_stats(env)
|
|
57
70
|
if page > 1 and current_total != total:
|
|
58
71
|
raise RuntimeError(f"{stem}: 分页期间总数变化,请重新拉取")
|
|
59
72
|
total = current_total
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
73
|
+
accumulated += got
|
|
74
|
+
counts.append(got)
|
|
75
|
+
report_progress(f"{stem} page={page} got={got} acc={accumulated} total={total}")
|
|
76
|
+
if accumulated > total:
|
|
63
77
|
raise RuntimeError(f"{stem}: 累计行数超过 total")
|
|
64
|
-
if
|
|
78
|
+
if accumulated == total:
|
|
65
79
|
break
|
|
66
|
-
if
|
|
67
|
-
raise RuntimeError(f"{stem}: 分页不完整 {
|
|
80
|
+
if got == 0 or page >= PAGE_CAP:
|
|
81
|
+
raise RuntimeError(f"{stem}: 分页不完整 {accumulated}/{total}")
|
|
68
82
|
page += 1
|
|
69
|
-
(bl_dir / f"{stem}.
|
|
70
|
-
json.dumps(
|
|
83
|
+
(bl_dir / f"{stem}.pages.json").write_text(
|
|
84
|
+
json.dumps({"total": total, "pages": len(counts), "counts": counts}, ensure_ascii=False),
|
|
85
|
+
encoding="utf-8",
|
|
71
86
|
)
|
|
72
|
-
return
|
|
87
|
+
return total
|
|
73
88
|
|
|
74
89
|
|
|
75
90
|
CHANNEL_ALIAS = {
|
|
@@ -336,7 +351,7 @@ def fetch_business(args):
|
|
|
336
351
|
"dimensions": dims,
|
|
337
352
|
**extra,
|
|
338
353
|
}
|
|
339
|
-
manifest[stem] =
|
|
354
|
+
manifest[stem] = fetch_paged(DIM_CMD[ch], body, stem, bl_dir)
|
|
340
355
|
for dims in met_dims(ch):
|
|
341
356
|
stem = f"met_{ch}_{per}_{'+'.join(dims)}"
|
|
342
357
|
body = {
|
|
@@ -346,7 +361,7 @@ def fetch_business(args):
|
|
|
346
361
|
"dimensions": dims,
|
|
347
362
|
"indicators": INDICATORS,
|
|
348
363
|
}
|
|
349
|
-
manifest[stem] =
|
|
364
|
+
manifest[stem] = fetch_paged(MET_CMD[ch], body, stem, bl_dir)
|
|
350
365
|
|
|
351
366
|
# 只有全部所需数据完成后登记清单,汇总前按清单核对文件和数量。
|
|
352
367
|
(req / 'fetch-manifest.json').write_text(json.dumps(manifest), encoding='utf-8')
|