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.
@@ -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
- return parse_json_text(path.read_text(encoding="utf-8"))
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 exec_cli_to_file(cmd: list[str], out_path: Path) -> tuple[int, str]:
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
- raw = out_path.read_text(encoding="utf-8")
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, 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")
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
- error = parse_json_text(stderr) or stderr or parsed or raw or "无有效 CLI 回包"
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
- 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)):
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
- 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
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) -> list:
45
- all_rows: list = []
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
- rows, current_total = unwrap_page(env)
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
- all_rows.extend(rows)
61
- report_progress(f"{stem} page={page} got={len(rows)} acc={len(all_rows)} total={total}")
62
- if len(all_rows) > total:
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 len(all_rows) == total:
78
+ if accumulated == total:
65
79
  break
66
- if not rows or page >= PAGE_CAP:
67
- raise RuntimeError(f"{stem}: 分页不完整 {len(all_rows)}/{total}")
80
+ if got == 0 or page >= PAGE_CAP:
81
+ raise RuntimeError(f"{stem}: 分页不完整 {accumulated}/{total}")
68
82
  page += 1
69
- (bl_dir / f"{stem}.merged.json").write_text(
70
- json.dumps(all_rows, ensure_ascii=False), encoding="utf-8"
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 all_rows
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] = len(fetch_paged(DIM_CMD[ch], body, stem, bl_dir))
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] = len(fetch_paged(MET_CMD[ch], body, stem, bl_dir))
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')