sino-bc-cli 1.8.21 → 1.8.23

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.
@@ -28,7 +28,7 @@ metadata:
28
28
  使用本技能目录的 **`scripts/run_workflow.py`**,不要临时重写拉数、分析或排版脚本。执行前定位技能的绝对路径;Python 只需标准库。首次使用读取 [`references/runtime.md`](references/runtime.md)。
29
29
 
30
30
  1. 用户给名称,传 `--name`;命中多个候选则返回歧义。用户给多个 ID,重复传 `--id`。用户要第一页/前 N 条,先用既有 `biz-line +list --data` 选出真实 ID,再在**一次入口调用**中传入。
31
- 2. 入口默认最多 **3 条业务线并发**,允许 `--concurrency 1` 或 `2`,不能大于 3。同一批次不要并排启动多个入口绕开限制;单条内部 CLI 请求和分页串行。
31
+ 2. 入口默认最多 **3 条业务线并发**,允许 `--concurrency 1` 或 `2`,不能大于 3。同一批次不要并排启动多个入口绕开限制;单条内部 CLI 请求和分页串行。分页回包只落到本次临时目录的 `*.pN.out.json`,由脚本写 `*.pages.json` 索引;**禁止**在对话里重写拉数脚本、把各页 `extend` 进内存或再拼一份 `merged.json`。分析按页迭代,不要把全表读进 list。
32
32
  3. 每条自动执行现拉 → 固定分析 → 一次性排版 → Excel 回读校验 → 发布最终文件 → 清理。默认接口周期;用户给日期时成对传 `--curr-start/--curr-end`、`--prev-start/--prev-end`。
33
33
  4. 每条成功后立即交付**该业务线自己的最终 Excel**,并删除本次其他临时产物,包括 JSON、请求/回包、日志副本、图片、临时脚本、缓存、无扩展名文件及临时 Excel,不等全部业务线结束。
34
34
  5. 失败任务返回阶段和错误摘要,清理自己的临时文件,其他业务线继续。清理失败须报告残留路径,不能声称已清理。只给实际成功返回的 `output_path` 链接;不用已删除的请求目录作交付物。
@@ -69,7 +69,7 @@ python3 -B <本技能绝对路径>/scripts/run_workflow.py --id <真实ID1> --id
69
69
  - 未绑定项目/账号不要编造。多项目仍一行,名称 `\n` 分隔。
70
70
  - 文案用中文/`display_*`;词包只写「新增/本期未出现 N 个」+ 最多 3 短词。
71
71
  - learning 第 1 层只写消耗 ≥$50 的新增。
72
- - CLI 回包必须直写文件(`exec_cli_to_file`),`capture_output` 会在约 64KB 截断。翻页上限用脚本 `PAGE_CAP`(≥80),不要手写 30 页就停。
72
+ - CLI 回包必须直写文件(`exec_cli_to_file`),`capture_output` 会在约 64KB 截断。每页是 `stem.pN.out.json`,拉数成功才写 `stem.pages.json`;不要写全表 `merged.json`,也不要手写翻页把结果攒在内存里。翻页上限用脚本 `PAGE_CAP`(≥80),不要手写 30 页就停。
73
73
  - 每条业务线只留一份最终 Excel;禁止按桌面 `业务线策略生成_*.xlsx` glob 删除。
74
74
 
75
75
  ## References
@@ -94,7 +94,7 @@ sino-bc-cli biz-line api biz-line-project-list --data '{"bizLineId": 1251}'
94
94
 
95
95
  ## 分页与数据量
96
96
 
97
- 维度/成效接口默认每页 10 条(`size`),脚本显式 `size=1000`,按 `data.total` 翻页,上限 `PAGE_CAP=80`(TikTok 出价按天可到 4~5 万行)。回包直写文件。每次现拉,不要跳过已有页;最终 Excel 校验发布后立即清理该条全部临时文件。
97
+ 维度/成效接口默认每页 10 条(`size`),脚本显式 `size=1000`,按 `data.total` 翻页,上限 `PAGE_CAP=80`。每一页回包直写 `stem.pN.out.json`,全部页成功后写 `stem.pages.json`;不要把各页拼成内存全表或 `merged.json`。分析按页 yield,占比和比率一次扫描。TikTok 出价按天可达 4~5 万行,并发多条业务线时尤其不能在 Python 里 `extend` 全量。每次现拉,不要跳过已有页;最终 Excel 校验发布后立即清理该条全部临时文件。
98
98
 
99
99
  ## 汇总到 Excel 前的数据整理
100
100
 
@@ -9,6 +9,7 @@
9
9
  - Windows 通过已安装 CLI 对应 Node 入口传参数数组,避免 cmd 重新解释 JSON。权限错误应报告,不当成未安装去自动下载。
10
10
  - `--format json`,请求用 `--data`。成功必须同时满足退出码 0 和信封 `ok == true`;stderr 与 stdout 都直写本次文件。
11
11
  - 列表行为 `data.result`,项目为 `data` 数组,维度/成效为 `data.data` 和 `data.total`。分页 `size=1000`、`PAGE_CAP=80`;总数未取全、提前空页、失败或总数变化均中止该条,不导出部分成功文件。
12
+ - 维度/成效每一页 MUST 直写 `bl-<id>/<stem>.pN.out.json`。拉数在内存中只保留当前页和行数累计,成功后才写 `<stem>.pages.json`(`total` / `pages` / `counts`)。开始前若已有本 stem 的 `pages.json` 必须先删掉。MUST NOT 把各页 `extend` 成全表再写 `merged.json`。汇总按 `pages.json` 逐页 yield,占比/比率/覆盖日期一次扫描,不得为分析再拼全表 list。无索引却有 `p2` 及之后页时失败。清单校验只对索引上的 `total`/`counts`,不得为校验再整表加载。失败页 MUST NOT 写下完整 `pages.json`。
12
13
 
13
14
  ## 目录归属
14
15
 
@@ -17,7 +18,7 @@
17
18
  .strategy-owner.json
18
19
  line-<业务线ID>-<随机标识>/
19
20
  .strategy-owner.json
20
- 请求体、回包、stderr、merged JSON、payload、summary、临时 Excel
21
+ 请求体、stderr、分页回包 <stem>.pN.out.json、索引 <stem>.pages.json、临时 Excel
21
22
  ```
22
23
 
23
24
  每次现拉,新建目录,不复用历史缓存。所有本次临时文件写入本次目录;不向技能源码树写缓存。最终 Excel 写在批次临时根以外,文件名包含业务线 ID 和随机请求标识,独占创建、不覆盖已有文件。
@@ -37,12 +37,20 @@
37
37
 
38
38
  ### Requirement: 分页与失败回包必须完整校验
39
39
 
40
- 每页 CLI 回包 MUST 直写文件并检查退出码、信封和分页总数。异常、解析失败、未到总数即遇空页、达到分页上限仍未取全,MUST 阻止该业务线生成成功报告。CLI stderr 错误 MUST 保留到本次任务结果摘要,而非当作无数据。其他业务线 MUST 可以继续执行。
40
+ 每页 CLI 回包 MUST 直写 `<stem>.pN.out.json` 并检查退出码、信封和分页总数。拉数成功后 MUST 写 `<stem>.pages.json`(含 `total`、`pages`、每页 `counts`)。拉数过程 MUST 只在内存保留当前页与行数累计,MUST NOT 将各页累积为全表 list,MUST NOT 再写一份全表 `merged.json`。汇总 MUST 按 `pages.json` 顺序逐页 yield 行;`pages.json` 缺失但存在 `p2` 及之后页文件时 MUST 失败,不得只读第一页。`share_map` / `metric_value` / 覆盖日期 / 出价快照 MUST 一次扫描完成,MUST NOT 为占比或比率把全表物化成 list。新增对象识别 MUST 只为新增行保留明细,持续对象 MUST NOT 再攒成全表。开始分析前的清单校验 MUST 只核对该索引与 manifest 的 `total`/`counts`,MUST NOT 为校验整表加载。广告/广告组命名规律 MUST 只用原始展示名,MUST NOT 把空名回退成 ID 再拆词。异常、解析失败、未到总数即遇空页、达到分页上限仍未取全,MUST 阻止该业务线生成成功报告,且 MUST NOT 写下完整 `pages.json`。CLI stderr 错误 MUST 保留到本次任务结果摘要,而非当作无数据。其他业务线 MUST 可以继续执行。
41
41
 
42
42
  #### Scenario: 后续页失败不导出部分结果
43
43
  - **GIVEN** 首页成功且声明仍有下一页
44
44
  - **WHEN** 后续页调用失败或返回空页但累计数量不足
45
- - **THEN** 该业务线失败,不将不完整数据生成最终 Excel,其他业务线继续执行
45
+ - **THEN** 该业务线失败,不将不完整数据生成最终 Excel,不留下完整 `pages.json`,其他业务线继续执行
46
+
47
+ #### Scenario: 多页成功只落分页文件
48
+ - **GIVEN** 某 stem 两页合计 3 行且 `data.total` 为 3
49
+ - **WHEN** 拉数成功
50
+ - **THEN** 目录中有 `stem.p1.out.json`、`stem.p2.out.json` 与 `stem.pages.json`(`total=3`、`counts=[2,1]` 或等价分页)
51
+ - **AND** 不存在 `stem.merged.json`
52
+ - **AND** 汇总能从页文件读出这 3 行
53
+ - **AND** `share_map` / `metric_value` 对一次性迭代器也能得到与 list 相同的占比和比率
46
54
 
47
55
  ### Requirement: 指标与周期口径固定
48
56
 
@@ -250,15 +250,23 @@ def optional_num(row, key):
250
250
 
251
251
 
252
252
  def metric_value(rows, metric="CPI"):
253
- """同一粒度先合计基础指标再计算,任一缺失不当作零补齐。"""
253
+ """同一粒度先合计基础指标再计算,任一缺失不当作零补齐。只扫一遍,可接迭代器。"""
254
254
  formulas = {"CPI": ("spend_usd", "installs", 1), "CPC": ("spend_usd", "clicks", 1),
255
255
  "CPM": ("spend_usd", "impressions", 1000), "ROAS": ("purchase_value", "spend_usd", 1),
256
256
  "CTR": ("clicks", "impressions", 100)}
257
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)):
258
+ seen = False
259
+ num_a = num_b = 0.0
260
+ for row in rows or ():
261
+ seen = True
262
+ left, right = optional_num(row, a), optional_num(row, b)
263
+ if left is None or right is None:
264
+ return None
265
+ num_a += left
266
+ num_b += right
267
+ if not seen:
259
268
  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
269
+ return num_a / num_b * scale if num_b > 0 else None
262
270
 
263
271
 
264
272
  def core_rule(text):
@@ -41,8 +41,17 @@ 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:
@@ -57,19 +66,22 @@ def fetch_paged(shortcut: str, base_body: dict, stem: str, bl_dir: Path) -> list
57
66
  if page > 1 and current_total != total:
58
67
  raise RuntimeError(f"{stem}: 分页期间总数变化,请重新拉取")
59
68
  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:
69
+ got = len(rows)
70
+ accumulated += got
71
+ counts.append(got)
72
+ report_progress(f"{stem} page={page} got={got} acc={accumulated} total={total}")
73
+ if accumulated > total:
63
74
  raise RuntimeError(f"{stem}: 累计行数超过 total")
64
- if len(all_rows) == total:
75
+ if accumulated == total:
65
76
  break
66
77
  if not rows or page >= PAGE_CAP:
67
- raise RuntimeError(f"{stem}: 分页不完整 {len(all_rows)}/{total}")
78
+ raise RuntimeError(f"{stem}: 分页不完整 {accumulated}/{total}")
68
79
  page += 1
69
- (bl_dir / f"{stem}.merged.json").write_text(
70
- json.dumps(all_rows, ensure_ascii=False), encoding="utf-8"
80
+ (bl_dir / f"{stem}.pages.json").write_text(
81
+ json.dumps({"total": total, "pages": len(counts), "counts": counts}, ensure_ascii=False),
82
+ encoding="utf-8",
71
83
  )
72
- return all_rows
84
+ return total
73
85
 
74
86
 
75
87
  CHANNEL_ALIAS = {
@@ -336,7 +348,7 @@ def fetch_business(args):
336
348
  "dimensions": dims,
337
349
  **extra,
338
350
  }
339
- manifest[stem] = len(fetch_paged(DIM_CMD[ch], body, stem, bl_dir))
351
+ manifest[stem] = fetch_paged(DIM_CMD[ch], body, stem, bl_dir)
340
352
  for dims in met_dims(ch):
341
353
  stem = f"met_{ch}_{per}_{'+'.join(dims)}"
342
354
  body = {
@@ -346,7 +358,7 @@ def fetch_business(args):
346
358
  "dimensions": dims,
347
359
  "indicators": INDICATORS,
348
360
  }
349
- manifest[stem] = len(fetch_paged(MET_CMD[ch], body, stem, bl_dir))
361
+ manifest[stem] = fetch_paged(MET_CMD[ch], body, stem, bl_dir)
350
362
 
351
363
  # 只有全部所需数据完成后登记清单,汇总前按清单核对文件和数量。
352
364
  (req / 'fetch-manifest.json').write_text(json.dumps(manifest), encoding='utf-8')
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env python3
2
2
  # -*- coding: utf-8 -*-
3
- """按固定规则汇总 merged JSON,生成可直接校验及导出的 payload。
3
+ """按固定规则汇总分页临时文件,生成可直接校验及导出的 payload。
4
4
  """
5
5
  from __future__ import annotations
6
6
 
@@ -42,13 +42,47 @@ CH_LABEL = {"fb": "Meta", "gg": "Google", "tt": "TikTok"}
42
42
  DETAIL_LIMIT = 8
43
43
 
44
44
 
45
- def load_merged(bl: Path, stem: str) -> list:
46
- data = load_json_file(bl / f"{stem}.merged.json")
47
- if isinstance(data, list):
48
- return data
45
+ def iter_stem_rows(bl: Path, stem: str):
46
+ """按页 yield 行,不把全表拼成 list。可重复调用,每次重新读盘。"""
47
+ meta_path = bl / f"{stem}.pages.json"
48
+ if meta_path.exists():
49
+ meta = load_json_file(meta_path)
50
+ if not isinstance(meta, dict) or not isinstance(meta.get("counts"), list):
51
+ raise ValueError(f"拉数文件不完整:{stem}")
52
+ accumulated = 0
53
+ for index, expected in enumerate(meta["counts"], start=1):
54
+ env = load_json_file(bl / f"{stem}.p{index}.out.json")
55
+ page_rows, _ = unwrap_page(env or {})
56
+ if len(page_rows) != expected:
57
+ raise ValueError(f"拉数文件不完整:{stem}")
58
+ yield from page_rows
59
+ accumulated += len(page_rows)
60
+ if meta.get("total") != accumulated:
61
+ raise ValueError(f"拉数文件不完整:{stem}")
62
+ return
63
+ extras = [p for p in bl.glob(f"{stem}.p*.out.json") if p.name != f"{stem}.p1.out.json"]
64
+ if extras:
65
+ raise ValueError(f"拉数文件不完整:{stem}")
49
66
  env = load_json_file(bl / f"{stem}.p1.out.json")
50
- rows, _ = unwrap_page(env or {})
51
- return rows
67
+ yield from unwrap_page(env or {})[0]
68
+
69
+
70
+ def load_merged(bl: Path, stem: str) -> list:
71
+ """兼容测试与诊断:需要 list 时才物化。分析路径应走 iter_stem_rows。"""
72
+ return list(iter_stem_rows(bl, stem))
73
+
74
+
75
+ def object_name_maps(rows, id_key, name_key) -> tuple[dict[str, str], dict[str, str]]:
76
+ """id → 明细展示名(空则回退 id),以及原始 display(空名不当作 id,避免命名规律拆 ID)。"""
77
+ names: dict[str, str] = {}
78
+ raw: dict[str, str] = {}
79
+ for row in rows:
80
+ if row.get(id_key):
81
+ ident = str(row[id_key])
82
+ shown = disp(row, name_key)
83
+ raw[ident] = shown
84
+ names[ident] = shown or ident
85
+ return names, raw
52
86
 
53
87
 
54
88
  def labels_of(rows, key) -> set[str]:
@@ -56,10 +90,13 @@ def labels_of(rows, key) -> set[str]:
56
90
 
57
91
 
58
92
  def share_map(rows, dim_key):
59
- total = sum(num(r, "spend_usd") for r in rows)
93
+ """一次扫描累加消耗占比,可接迭代器。"""
94
+ total = 0.0
60
95
  acc = defaultdict(float)
61
- for r in rows:
62
- acc[disp(r, dim_key) or "未标注"] += num(r, "spend_usd")
96
+ for row in rows:
97
+ spend = num(row, "spend_usd")
98
+ total += spend
99
+ acc[disp(row, dim_key) or "未标注"] += spend
63
100
  if total <= 0:
64
101
  return {}, 0.0
65
102
  return {k: v / total * 100 for k, v in acc.items()}, total
@@ -90,18 +127,30 @@ def share_moved(curr: dict, prev: dict) -> bool:
90
127
 
91
128
 
92
129
  def bid_changes(rows, fields):
93
- """逐个出价字段比较有效日快照,缺失值不补零;保留字段与对象 ID。"""
130
+ """逐个出价字段比较有效日快照,缺失值不补零;只保留日期与出价字段,可接迭代器。"""
94
131
  by_ag = defaultdict(list)
132
+ names: dict[str, tuple[str, str]] = {}
133
+ strategies: dict[str, str] = {}
95
134
  for row in rows:
96
- if row.get("adgroup_id"):
97
- by_ag[str(row["adgroup_id"])].append(row)
135
+ ag = row.get("adgroup_id")
136
+ if not ag:
137
+ continue
138
+ ident = str(ag)
139
+ date_s = str(row.get("calendar_date") or "")
140
+ if ident not in names or date_s >= names[ident][0]:
141
+ names[ident] = (date_s, disp(row, "adgroup_name") or ident)
142
+ strategies[ident] = disp(row, "bid_strategy")
143
+ snap = {"calendar_date": str(row.get("calendar_date") or "")}
144
+ for field in fields:
145
+ snap[field] = optional_num(row, field)
146
+ by_ag[ident].append(snap)
98
147
  result = []
99
148
  for ag, records in by_ag.items():
100
- records = sorted(records, key=lambda r: str(r.get("calendar_date") or ""))
149
+ records = sorted(records, key=lambda r: r["calendar_date"])
101
150
  changes = []
102
151
  changed_dates = set()
103
152
  for field in fields:
104
- valid = [(str(r.get("calendar_date") or ""), optional_num(r, field)) for r in records if optional_num(r, field) is not None]
153
+ valid = [(r["calendar_date"], r[field]) for r in records if r[field] is not None]
105
154
  times = sum(a[1] != b[1] for a, b in zip(valid, valid[1:]))
106
155
  changed_dates.update(b[0] for a, b in zip(valid, valid[1:]) if a[1] != b[1])
107
156
  if times:
@@ -109,28 +158,35 @@ def bid_changes(rows, fields):
109
158
  if changes:
110
159
  # 多字段变动以首个首末不同的字段展示方向,明细完整保留各字段。
111
160
  primary = next((c for c in changes if c["from"] != c["to"]), changes[0])
112
- result.append({"id": ag, "name": disp(records[-1], "adgroup_name") or ag,
113
- **primary, "times": len(changed_dates), "fields": changes, "strategy": disp(records[-1], "bid_strategy")})
161
+ result.append({"id": ag, "name": names[ag][1],
162
+ **primary, "times": len(changed_dates), "fields": changes, "strategy": strategies[ag]})
114
163
  return sorted(result, key=lambda r: (-r["times"], r["id"]))
115
164
 
116
165
 
117
166
  def new_items(curr_rows, prev_rows, key, id_key=None):
118
- """按稳定 ID/维度值聚合基础指标,再识别本期新出现的对象。"""
167
+ """按稳定 ID/维度值聚合基础指标,再识别本期新出现的对象。只为新增对象保留行。"""
119
168
  def identity(row):
120
169
  return str(row.get(id_key) or "") if id_key else disp(row, key)
170
+ previous: dict[str, str] = {}
171
+ for row in prev_rows:
172
+ ident = identity(row)
173
+ if ident:
174
+ previous[ident] = disp(row, key) or ident
121
175
  grouped = defaultdict(list)
176
+ current_ids: set[str] = set()
122
177
  for row in curr_rows:
123
- if identity(row):
124
- grouped[identity(row)].append(row)
125
- previous = {identity(r): r for r in prev_rows if identity(r)}
178
+ ident = identity(row)
179
+ if not ident:
180
+ continue
181
+ current_ids.add(ident)
182
+ if ident not in previous:
183
+ grouped[ident].append(row)
126
184
  items = []
127
185
  for ident, records in grouped.items():
128
- if ident in previous:
129
- continue
130
186
  items.append({"id": ident if id_key else "", "label": disp(records[0], key) or ident,
131
187
  "spend": sum(num(r, "spend_usd") for r in records), "rows": records,
132
188
  "cpi": metric_value(records), "cpm": metric_value(records, "CPM")})
133
- stops = [disp(r, key) or ident for ident, r in previous.items() if ident not in grouped]
189
+ stops = [label for ident, label in previous.items() if ident not in current_ids]
134
190
  return items, stops
135
191
 
136
192
 
@@ -208,9 +264,14 @@ def summarize_request(req: Path):
208
264
  manifest = load_json_file(req / 'fetch-manifest.json')
209
265
  if manifest is not None:
210
266
  for stem, count in manifest.items():
211
- data = load_json_file(bl / f'{stem}.merged.json')
212
- if not isinstance(data, list) or len(data) != count:
213
- raise ValueError(f'拉数文件不完整:{stem}')
267
+ meta = load_json_file(bl / f"{stem}.pages.json")
268
+ if (
269
+ not isinstance(meta, dict)
270
+ or meta.get("total") != count
271
+ or not isinstance(meta.get("counts"), list)
272
+ or sum(meta["counts"]) != count
273
+ ):
274
+ raise ValueError(f"拉数文件不完整:{stem}")
214
275
  group = detail_data.get("ssoTeamCodeName") or biz.get("ssoTeamCodeName") or ""
215
276
  name = detail_data.get("name") or biz.get("name") or ""
216
277
  project = "\n".join(projects.get("projectNames") or detail_data.get("projectNames") or [])
@@ -231,21 +292,33 @@ def summarize_request(req: Path):
231
292
  imps = {"curr": {}, "prev": {}}
232
293
  inst = {"curr": {}, "prev": {}}
233
294
  days = {"curr": {}, "prev": {}}
295
+ coverage_dates = {"curr": {}, "prev": {}}
234
296
  for ch in ("fb", "gg", "tt"):
235
297
  for per in ("curr", "prev"):
236
- day_rows = load_merged(bl, f"met_{ch}_{per}_calendar_date")
237
- acc_rows = load_merged(bl, f"met_{ch}_{per}_account_id")
238
- rows = acc_rows or day_rows
239
- spend[per][ch] = sum(num(r, "spend_usd") for r in rows)
240
- imps[per][ch] = sum(num(r, "impressions") for r in rows)
241
- inst[per][ch] = sum(num(r, "installs") for r in acc_rows or rows)
242
- days[per][ch] = len(
243
- {
244
- disp(r, "calendar_date") or str(r.get("calendar_date") or "")
245
- for r in day_rows
246
- if num(r, "spend_usd") > 0
247
- }
248
- )
298
+ day_spend = day_imps = day_inst = 0.0
299
+ dates_all: set[str] = set()
300
+ dates_spend: set[str] = set()
301
+ for row in iter_stem_rows(bl, f"met_{ch}_{per}_calendar_date"):
302
+ day_spend += num(row, "spend_usd")
303
+ day_imps += num(row, "impressions")
304
+ day_inst += num(row, "installs")
305
+ raw = row.get("calendar_date")
306
+ if raw:
307
+ dates_all.add(str(raw)[:10])
308
+ if num(row, "spend_usd") > 0:
309
+ dates_spend.add(disp(row, "calendar_date") or str(raw or ""))
310
+ acc_spend = acc_imps = acc_inst = 0.0
311
+ has_acc = False
312
+ for row in iter_stem_rows(bl, f"met_{ch}_{per}_account_id"):
313
+ has_acc = True
314
+ acc_spend += num(row, "spend_usd")
315
+ acc_imps += num(row, "impressions")
316
+ acc_inst += num(row, "installs")
317
+ spend[per][ch] = acc_spend if has_acc else day_spend
318
+ imps[per][ch] = acc_imps if has_acc else day_imps
319
+ inst[per][ch] = acc_inst if has_acc else day_inst
320
+ days[per][ch] = len(dates_spend)
321
+ coverage_dates[per][ch] = dates_all
249
322
 
250
323
  curr_total = sum(spend["curr"].values())
251
324
  prev_total = sum(spend["prev"].values())
@@ -262,9 +335,12 @@ def summarize_request(req: Path):
262
335
  core_index = str(biz.get("coreIndex") or detail_data.get("coreIndex") or "").strip()
263
336
  rule = core_rule(core_index)
264
337
  metric = rule["metric"]
265
- all_rows = {per: [r for ch in CH_LABEL for r in load_merged(bl, f"met_{ch}_{per}_account_id")] for per in ("curr", "prev")}
266
- core_c, core_p = (metric_value(all_rows[per], metric) for per in ("curr", "prev"))
267
- cpi_c, cpi_p = (metric_value(all_rows[per], "CPI") for per in ("curr", "prev"))
338
+ def iter_account_rows(per: str):
339
+ for ch in CH_LABEL:
340
+ yield from iter_stem_rows(bl, f"met_{ch}_{per}_account_id")
341
+
342
+ core_c, core_p = (metric_value(iter_account_rows(per), metric) for per in ("curr", "prev"))
343
+ cpi_c, cpi_p = (metric_value(iter_account_rows(per), "CPI") for per in ("curr", "prev"))
268
344
  qoq_label, qoq_pct = qoq_axis(core_c, core_p, metric in ("ROAS", "CTR"))
269
345
  kpi_usable = rule["usable"]
270
346
  spend_verdict = period_verdict(
@@ -289,7 +365,7 @@ def summarize_request(req: Path):
289
365
  for ch, accounts in projects.get("accounts", {}).items():
290
366
  if not accounts:
291
367
  continue
292
- dates = sorted({str(r.get("calendar_date") or "")[:10] for r in load_merged(bl, f"met_{ch}_{per}_calendar_date") if r.get("calendar_date")})
368
+ dates = sorted(coverage_dates[per].get(ch) or set())
293
369
  missing = expected - set(dates)
294
370
  label = "本期" if per == "curr" else "上期"
295
371
  coverage_notes.append(f"{CH_LABEL[ch]}{label}实际覆盖 {dates[0] if dates else '-'}~{dates[-1] if dates else '-'},{len(dates)}天/{len(expected)}天" + ("(覆盖不完整)" if missing else ""))
@@ -309,7 +385,7 @@ def summarize_request(req: Path):
309
385
  "gg": "dim_gg_curr_calendar_date+adgroup_id+adgroup_name+cpc_bid_amount",
310
386
  "tt": "dim_tt_curr_calendar_date+adgroup_id+adgroup_name+bid_amount",
311
387
  }
312
- bids = {ch: bid_changes(load_merged(bl, bid_stems[ch]), bid_fields[ch]) for ch in bid_fields}
388
+ bids = {ch: bid_changes(iter_stem_rows(bl, bid_stems[ch]), bid_fields[ch]) for ch in bid_fields}
313
389
  bid_stats = {ch: bid_adjust_stats(bids[ch]) for ch in bids}
314
390
 
315
391
  def bid_text(ch):
@@ -408,8 +484,8 @@ def summarize_request(req: Path):
408
484
  segs = []
409
485
  moved = False
410
486
  for ch in channels:
411
- curr, _ = share_map(load_merged(bl, f"met_{ch}_curr_{stem}"), dim_key)
412
- prev, _ = share_map(load_merged(bl, f"met_{ch}_prev_{stem}"), dim_key)
487
+ curr, _ = share_map(iter_stem_rows(bl, f"met_{ch}_curr_{stem}"), dim_key)
488
+ prev, _ = share_map(iter_stem_rows(bl, f"met_{ch}_prev_{stem}"), dim_key)
413
489
  if share_moved(curr, prev):
414
490
  moved = True
415
491
  if curr or prev:
@@ -430,12 +506,16 @@ def summarize_request(req: Path):
430
506
  # Meta 兴趣的三个配置字段来自同一组合请求,不能找不存在的单字段文件。
431
507
  if ch == 'fb' and stem == 'targeting_interests':
432
508
  combined = 'targeting_interests+targeting_flexible_interests+targeting_exclusion_interests'
433
- for field in combined.split('+'):
434
- curr |= labels_of(load_merged(bl, f'dim_{ch}_curr_{combined}'), field)
435
- prev |= labels_of(load_merged(bl, f'dim_{ch}_prev_{combined}'), field)
509
+ fields = combined.split('+')
510
+ for target, per in ((curr, 'curr'), (prev, 'prev')):
511
+ for row in iter_stem_rows(bl, f'dim_{ch}_{per}_{combined}'):
512
+ for field in fields:
513
+ lab = disp(row, field)
514
+ if lab:
515
+ target.add(lab)
436
516
  continue
437
- curr |= labels_of(load_merged(bl, f"dim_{ch}_curr_{stem}"), key)
438
- prev |= labels_of(load_merged(bl, f"dim_{ch}_prev_{stem}"), key)
517
+ curr |= labels_of(iter_stem_rows(bl, f"dim_{ch}_curr_{stem}"), key)
518
+ prev |= labels_of(iter_stem_rows(bl, f"dim_{ch}_prev_{stem}"), key)
439
519
  return curr, prev
440
520
 
441
521
  def add_setting_details(kind, curr_set, prev_set, channel=""):
@@ -476,16 +556,18 @@ def summarize_request(req: Path):
476
556
  bits = []
477
557
  for ch in CH_LABEL:
478
558
  stem = f"{id_key}+{name_key}"
479
- crows = load_merged(bl, f"dim_{ch}_curr_{stem}")
480
- prows = load_merged(bl, f"dim_{ch}_prev_{stem}")
481
- c = {str(r[id_key]): r for r in crows if r.get(id_key)}
482
- p = {str(r[id_key]): r for r in prows if r.get(id_key)}
559
+
560
+ def index_objects(period: str) -> tuple[dict[str, str], dict[str, str]]:
561
+ return object_name_maps(iter_stem_rows(bl, f"dim_{ch}_{period}_{stem}"), id_key, name_key)
562
+
563
+ c, c_raw = index_objects("curr")
564
+ p, _p_raw = index_objects("prev")
483
565
  new, absent = sorted(c.keys() - p.keys()), sorted(p.keys() - c.keys())
484
- bits.append(f"{CH_LABEL[ch]}:本期新出现 {len(new)} 个,本期未出现 {len(absent)} 个;命名规律:{naming_patterns([disp(c[i], name_key) for i in new])}")
566
+ bits.append(f"{CH_LABEL[ch]}:本期新出现 {len(new)} 个,本期未出现 {len(absent)} 个;命名规律:{naming_patterns([c_raw[i] for i in new])}")
485
567
  for ident in sorted(c.keys() | p.keys()):
486
- record = c.get(ident) or p[ident]
568
+ record_name = c.get(ident) or p[ident]
487
569
  action = "当期" if first_period else ("新增" if ident in new else ("本期未出现" if ident in absent else "持续"))
488
- details.append({"bizLine": name, "kind": kind, "action": action, "channel": CH_LABEL[ch], "name": disp(record, name_key) or ident, "extra": f"ID={ident};集合差异不等同创建/暂停操作"})
570
+ details.append({"bizLine": name, "kind": kind, "action": action, "channel": CH_LABEL[ch], "name": record_name, "extra": f"ID={ident};集合差异不等同创建/暂停操作"})
489
571
  return bits
490
572
 
491
573
  ag_bits = object_strategy("广告组", "adgroup_id", "adgroup_name")
@@ -514,11 +596,11 @@ def summarize_request(req: Path):
514
596
  for title, stem, key, channels, id_key in dim_specs:
515
597
  items_all, stops_all = [], []
516
598
  for ch in channels:
517
- curr_rows = load_merged(bl, f"met_{ch}_curr_{stem}")
518
- prev_rows = load_merged(bl, f"met_{ch}_prev_{stem}")
519
- curr_share_map, _ = share_map(curr_rows, key)
520
- prev_share_map, _ = share_map(prev_rows, key)
521
- items, stops = new_items(curr_rows, prev_rows, key, id_key)
599
+ curr_stem = f"met_{ch}_curr_{stem}"
600
+ prev_stem = f"met_{ch}_prev_{stem}"
601
+ curr_share_map, _ = share_map(iter_stem_rows(bl, curr_stem), key)
602
+ prev_share_map, _ = share_map(iter_stem_rows(bl, prev_stem), key)
603
+ items, stops = new_items(iter_stem_rows(bl, curr_stem), iter_stem_rows(bl, prev_stem), key, id_key)
522
604
  stops = [
523
605
  s
524
606
  for s in stops
@@ -53,6 +53,115 @@ class DataRules(unittest.TestCase):
53
53
  with self.assertRaises(RuntimeError):
54
54
  fetch_paged("+facebook-metrics", {}, "fixture", Path(tmp))
55
55
  self.assertFalse((Path(tmp) / "fixture.merged.json").exists())
56
+ self.assertFalse((Path(tmp) / "fixture.pages.json").exists())
57
+
58
+ def test_paged_success_writes_page_files_not_merged(self):
59
+ """拉数只把各页回包落到临时文件,不在内存拼全表、不写 merged.json。"""
60
+ pages = [
61
+ {"ok": True, "data": {"data": [{"id": 1}, {"id": 2}], "total": 3}},
62
+ {"ok": True, "data": {"data": [{"id": 3}], "total": 3}},
63
+ ]
64
+
65
+ def api(_command, body, out):
66
+ env = pages[body["page"] - 1]
67
+ out.parent.mkdir(parents=True, exist_ok=True)
68
+ out.write_text(json.dumps(env, ensure_ascii=False), encoding="utf-8")
69
+ return env
70
+
71
+ with tempfile.TemporaryDirectory() as tmp, patch("fetch_data.run_cli", api):
72
+ root = Path(tmp)
73
+ total = fetch_paged("+facebook-metrics", {}, "fixture", root)
74
+ self.assertEqual(total, 3)
75
+ self.assertFalse((root / "fixture.merged.json").exists())
76
+ self.assertEqual(
77
+ json.loads((root / "fixture.pages.json").read_text(encoding="utf-8")),
78
+ {"total": 3, "pages": 2, "counts": [2, 1]},
79
+ )
80
+ self.assertTrue((root / "fixture.p1.out.json").exists())
81
+ self.assertTrue((root / "fixture.p2.out.json").exists())
82
+ from summarize import load_merged
83
+ self.assertEqual(load_merged(root, "fixture"), [{"id": 1}, {"id": 2}, {"id": 3}])
84
+ from summarize import iter_stem_rows, share_map
85
+ self.assertEqual(list(iter_stem_rows(root, "fixture")), [{"id": 1}, {"id": 2}, {"id": 3}])
86
+ self.assertEqual(list(iter_stem_rows(root, "fixture")), [{"id": 1}, {"id": 2}, {"id": 3}])
87
+
88
+ def test_share_map_and_metric_value_accept_iterators(self):
89
+ from summarize import share_map
90
+ shares, total = share_map(
91
+ iter([
92
+ {"spend_usd": 10, "audience_type": "A"},
93
+ {"spend_usd": 30, "audience_type": "B"},
94
+ ]),
95
+ "audience_type",
96
+ )
97
+ self.assertEqual(total, 40)
98
+ self.assertEqual(shares["A"], 25)
99
+ self.assertEqual(shares["B"], 75)
100
+ self.assertEqual(
101
+ metric_value(iter([{"spend_usd": 10, "installs": 1}, {"spend_usd": 90, "installs": 49}])),
102
+ 2,
103
+ )
104
+ self.assertIsNone(metric_value(iter([{"spend_usd": 10, "installs": 0}])))
105
+
106
+ def test_bid_changes_accepts_iterator_without_full_row_list(self):
107
+ rows = iter([
108
+ {"adgroup_id": "1", "adgroup_name": "同名", "calendar_date": "2026-09-01", "bid_amount": 1},
109
+ {"adgroup_id": "1", "adgroup_name": "同名", "calendar_date": "2026-09-02", "bid_amount": 2},
110
+ {"adgroup_id": "1", "adgroup_name": "同名", "calendar_date": "2026-09-03", "bid_amount": 1},
111
+ ])
112
+ changes = bid_changes(rows, ["bid_amount"])
113
+ self.assertEqual(changes[0]["times"], 2)
114
+ self.assertEqual(changes[0]["from"], changes[0]["to"])
115
+ self.assertEqual(changes[0]["name"], "同名")
116
+
117
+ def test_new_items_only_materializes_new_rows(self):
118
+ from summarize import new_items
119
+ items, stops = new_items(
120
+ iter([
121
+ {"ad_id": "keep", "ad_name": "新", "spend_usd": 10, "installs": 1, "impressions": 1000},
122
+ {"ad_id": "old", "ad_name": "旧", "spend_usd": 99, "installs": 1, "impressions": 1000},
123
+ ]),
124
+ iter([{"ad_id": "old", "ad_name": "旧"}]),
125
+ "ad_name",
126
+ "ad_id",
127
+ )
128
+ self.assertEqual(len(items), 1)
129
+ self.assertEqual(items[0]["id"], "keep")
130
+ self.assertEqual(len(items[0]["rows"]), 1)
131
+ self.assertEqual(stops, [])
132
+
133
+ def test_iter_stem_rows_rejects_extra_pages_without_index(self):
134
+ from summarize import iter_stem_rows
135
+ with tempfile.TemporaryDirectory() as tmp:
136
+ root = Path(tmp)
137
+ page = {"ok": True, "data": {"data": [{"id": 1}], "total": 2}}
138
+ (root / "fixture.p1.out.json").write_text(json.dumps(page), encoding="utf-8")
139
+ (root / "fixture.p2.out.json").write_text(json.dumps(page), encoding="utf-8")
140
+ with self.assertRaises(ValueError):
141
+ list(iter_stem_rows(root, "fixture"))
142
+
143
+ def test_failed_retry_unlinks_stale_pages_index(self):
144
+ with tempfile.TemporaryDirectory() as tmp, patch("fetch_data.run_cli", return_value={"ok": False, "error": "失败"}):
145
+ root = Path(tmp)
146
+ (root / "fixture.pages.json").write_text(
147
+ json.dumps({"total": 3, "pages": 2, "counts": [2, 1]}), encoding="utf-8"
148
+ )
149
+ with self.assertRaises(RuntimeError):
150
+ fetch_paged("+facebook-metrics", {}, "fixture", root)
151
+ self.assertFalse((root / "fixture.pages.json").exists())
152
+
153
+ def test_empty_ad_name_stays_out_of_naming_patterns(self):
154
+ from summarize import object_name_maps
155
+ from cli_json import naming_patterns
156
+ names, raw = object_name_maps(
157
+ iter([{"ad_id": "camp_summer_01", "ad_name": ""}]),
158
+ "ad_id",
159
+ "ad_name",
160
+ )
161
+ self.assertEqual(names["camp_summer_01"], "camp_summer_01")
162
+ self.assertEqual(raw["camp_summer_01"], "")
163
+ self.assertEqual(naming_patterns([raw["camp_summer_01"]]), "命名规律不明显")
164
+ self.assertIn("camp", naming_patterns([names["camp_summer_01"]]))
56
165
 
57
166
  def test_short_page_still_fetches_total(self):
58
167
  pages = [{"ok": True, "data": {"data": [{"id": 1}], "total": 2}}, {"ok": True, "data": {"data": [], "total": 2}}]
@@ -163,8 +272,10 @@ class DataRules(unittest.TestCase):
163
272
  self.assertIn('ID=2', objects[1]['extra'])
164
273
  bid = next(r for r in payload['details'] if r['kind'] == '出价')
165
274
  self.assertNotEqual(bid['action'], '下调')
166
- # 汇总前损坏已完成文件必须失败,不能重新退回第一页继续。
167
- (root / 'bl-1/met_fb_curr_account_id.merged.json').write_text('[]')
275
+ # 汇总前损坏已完成的分页索引必须失败,不能重新退回第一页继续。
276
+ (root / "bl-1/met_fb_curr_account_id.pages.json").write_text(
277
+ json.dumps({"total": 0, "pages": 0, "counts": []}), encoding="utf-8"
278
+ )
168
279
  with self.assertRaises(ValueError):
169
280
  summarize_request(root)
170
281