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.
- package/dist/index.js +1 -1
- package/dist/skills/custom/biz-line-strategy-generate/SKILL.md +2 -2
- package/dist/skills/custom/biz-line-strategy-generate/references/api-flow.md +1 -1
- package/dist/skills/custom/biz-line-strategy-generate/references/runtime.md +2 -1
- package/dist/skills/custom/biz-line-strategy-generate/references/spec.md +10 -2
- package/dist/skills/custom/biz-line-strategy-generate/scripts/cli_json.py +12 -4
- package/dist/skills/custom/biz-line-strategy-generate/scripts/fetch_data.py +24 -12
- package/dist/skills/custom/biz-line-strategy-generate/scripts/summarize.py +146 -64
- package/dist/skills/custom/biz-line-strategy-generate/scripts/test_workflow.py +113 -2
- package/package.json +1 -1
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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) ->
|
|
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:
|
|
@@ -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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
75
|
+
if accumulated == total:
|
|
65
76
|
break
|
|
66
77
|
if not rows or page >= PAGE_CAP:
|
|
67
|
-
raise RuntimeError(f"{stem}: 分页不完整 {
|
|
78
|
+
raise RuntimeError(f"{stem}: 分页不完整 {accumulated}/{total}")
|
|
68
79
|
page += 1
|
|
69
|
-
(bl_dir / f"{stem}.
|
|
70
|
-
json.dumps(
|
|
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
|
|
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] =
|
|
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] =
|
|
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
|
-
"""
|
|
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
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
51
|
-
|
|
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
|
-
|
|
93
|
+
"""一次扫描累加消耗占比,可接迭代器。"""
|
|
94
|
+
total = 0.0
|
|
60
95
|
acc = defaultdict(float)
|
|
61
|
-
for
|
|
62
|
-
|
|
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
|
-
"""
|
|
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
|
-
|
|
97
|
-
|
|
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:
|
|
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 = [(
|
|
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":
|
|
113
|
-
**primary, "times": len(changed_dates), "fields": changes, "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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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 = [
|
|
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
|
-
|
|
212
|
-
if
|
|
213
|
-
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
412
|
-
prev, _ = share_map(
|
|
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
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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(
|
|
438
|
-
prev |= labels_of(
|
|
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
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
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([
|
|
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
|
-
|
|
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":
|
|
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
|
-
|
|
518
|
-
|
|
519
|
-
curr_share_map, _ = share_map(
|
|
520
|
-
prev_share_map, _ = share_map(
|
|
521
|
-
items, stops = new_items(
|
|
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 /
|
|
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
|
|