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
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
# -*- coding: utf-8 -*-
|
|
3
|
-
"""
|
|
4
|
-
|
|
5
|
-
Agent 应先读 payload 草稿再决定要不要改文案;不要从原始回包重新发明聚合。
|
|
3
|
+
"""按固定规则汇总 merged JSON,生成可直接校验及导出的 payload。
|
|
6
4
|
"""
|
|
7
5
|
from __future__ import annotations
|
|
8
6
|
|
|
@@ -17,25 +15,31 @@ if str(SCRIPTS) not in sys.path:
|
|
|
17
15
|
sys.path.insert(0, str(SCRIPTS))
|
|
18
16
|
|
|
19
17
|
from cli_json import (
|
|
18
|
+
bid_adjust_stats,
|
|
19
|
+
budget_abs_share,
|
|
20
20
|
cpm,
|
|
21
21
|
disp,
|
|
22
|
-
dual_verdict,
|
|
23
22
|
examples,
|
|
24
23
|
fmt_cpm,
|
|
25
|
-
|
|
24
|
+
metric_value,
|
|
25
|
+
optional_num,
|
|
26
|
+
core_rule,
|
|
27
|
+
rule_hit,
|
|
28
|
+
is_first_period,
|
|
29
|
+
is_ghost_enum,
|
|
26
30
|
load_json_file,
|
|
31
|
+
naming_patterns,
|
|
27
32
|
num,
|
|
33
|
+
period_verdict,
|
|
28
34
|
qoq_axis,
|
|
29
35
|
short_label,
|
|
36
|
+
summarize_countries,
|
|
30
37
|
unwrap_page,
|
|
31
38
|
)
|
|
32
39
|
|
|
33
40
|
CH_LABEL = {"fb": "Meta", "gg": "Google", "tt": "TikTok"}
|
|
34
|
-
|
|
35
|
-
KPI_TARGET = 5.0
|
|
36
|
-
# 单元格内明细最多展开条数,其余收成「另有 N 个」
|
|
41
|
+
# 第 1 层明细仍用消耗门槛;结论不再用 CPM≤5 当 KPI
|
|
37
42
|
DETAIL_LIMIT = 8
|
|
38
|
-
BID_SHOW = 6
|
|
39
43
|
|
|
40
44
|
|
|
41
45
|
def load_merged(bl: Path, stem: str) -> list:
|
|
@@ -78,13 +82,6 @@ def advice_of(verdict: str) -> str:
|
|
|
78
82
|
return "建议:复核策略"
|
|
79
83
|
|
|
80
84
|
|
|
81
|
-
def channel_delta(curr, prev) -> str:
|
|
82
|
-
d = curr - prev
|
|
83
|
-
if abs(d) <= 2:
|
|
84
|
-
return "无变化"
|
|
85
|
-
return f"{d:+.1f}%"
|
|
86
|
-
|
|
87
|
-
|
|
88
85
|
def share_moved(curr: dict, prev: dict) -> bool:
|
|
89
86
|
for lab in set(curr) | set(prev):
|
|
90
87
|
if abs(curr.get(lab, 0) - prev.get(lab, 0)) > 2:
|
|
@@ -93,113 +90,97 @@ def share_moved(curr: dict, prev: dict) -> bool:
|
|
|
93
90
|
|
|
94
91
|
|
|
95
92
|
def bid_changes(rows, fields):
|
|
93
|
+
"""逐个出价字段比较有效日快照,缺失值不补零;保留字段与对象 ID。"""
|
|
96
94
|
by_ag = defaultdict(list)
|
|
97
|
-
for
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
by_ag[ag].append(r)
|
|
95
|
+
for row in rows:
|
|
96
|
+
if row.get("adgroup_id"):
|
|
97
|
+
by_ag[str(row["adgroup_id"])].append(row)
|
|
101
98
|
result = []
|
|
102
|
-
for ag,
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
if
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
from_v, to_v = first_vals[0], last_vals[0]
|
|
119
|
-
for i, _f in enumerate(fields):
|
|
120
|
-
if first_vals[i] != last_vals[i]:
|
|
121
|
-
from_v, to_v = first_vals[i], last_vals[i]
|
|
122
|
-
break
|
|
123
|
-
result.append(
|
|
124
|
-
{"id": ag, "name": name or ag, "times": times, "from": from_v, "to": to_v, "strategy": strategy}
|
|
125
|
-
)
|
|
126
|
-
result.sort(key=lambda x: (-x["times"], x["name"]))
|
|
127
|
-
return result
|
|
99
|
+
for ag, records in by_ag.items():
|
|
100
|
+
records = sorted(records, key=lambda r: str(r.get("calendar_date") or ""))
|
|
101
|
+
changes = []
|
|
102
|
+
changed_dates = set()
|
|
103
|
+
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]
|
|
105
|
+
times = sum(a[1] != b[1] for a, b in zip(valid, valid[1:]))
|
|
106
|
+
changed_dates.update(b[0] for a, b in zip(valid, valid[1:]) if a[1] != b[1])
|
|
107
|
+
if times:
|
|
108
|
+
changes.append({"field": field, "from": valid[0][1], "to": valid[-1][1], "times": times})
|
|
109
|
+
if changes:
|
|
110
|
+
# 多字段变动以首个首末不同的字段展示方向,明细完整保留各字段。
|
|
111
|
+
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")})
|
|
114
|
+
return sorted(result, key=lambda r: (-r["times"], r["id"]))
|
|
128
115
|
|
|
129
116
|
|
|
130
117
|
def new_items(curr_rows, prev_rows, key, id_key=None):
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
}
|
|
148
|
-
for r in news
|
|
149
|
-
]
|
|
118
|
+
"""按稳定 ID/维度值聚合基础指标,再识别本期新出现的对象。"""
|
|
119
|
+
def identity(row):
|
|
120
|
+
return str(row.get(id_key) or "") if id_key else disp(row, key)
|
|
121
|
+
grouped = defaultdict(list)
|
|
122
|
+
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)}
|
|
126
|
+
items = []
|
|
127
|
+
for ident, records in grouped.items():
|
|
128
|
+
if ident in previous:
|
|
129
|
+
continue
|
|
130
|
+
items.append({"id": ident if id_key else "", "label": disp(records[0], key) or ident,
|
|
131
|
+
"spend": sum(num(r, "spend_usd") for r in records), "rows": records,
|
|
132
|
+
"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]
|
|
150
134
|
return items, stops
|
|
151
135
|
|
|
152
136
|
|
|
153
|
-
def dim_layer_text(title: str, items: list, stop_labs: list):
|
|
154
|
-
|
|
137
|
+
def dim_layer_text(title: str, items: list, stop_labs: list, rule=None):
|
|
138
|
+
"""新增值自身先合计再算指标,不使用默认 CPM 目标或伪造达标占比。"""
|
|
139
|
+
rule = rule or core_rule("")
|
|
140
|
+
metric = rule["metric"]
|
|
155
141
|
if not items and not stop_labs:
|
|
156
142
|
return f"{title}\n本期无新增,跳过。", None, 0.0, 0.0
|
|
157
143
|
lines = [title]
|
|
158
|
-
usable = []
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
if
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
if low:
|
|
179
|
-
lines.append(f"· 另有 {low} 个新增消耗不足 $50,不纳入聚合")
|
|
180
|
-
if stop_labs and not items:
|
|
181
|
-
lines.append(f"· 关停 {len(stop_labs)} 个:{examples(stop_labs)}")
|
|
182
|
-
elif stop_labs:
|
|
183
|
-
lines.append(f"· 另关停 {len(stop_labs)} 个:{examples(stop_labs)}")
|
|
184
|
-
if not usable:
|
|
185
|
-
return "\n".join(lines), None, 0.0, 0.0
|
|
186
|
-
spend_sum = sum(it["spend"] for it in usable)
|
|
187
|
-
dim_cpm = cpm(spend_sum, sum(it["imps"] for it in usable))
|
|
188
|
-
dim_hit = hit_kpi(dim_cpm, KPI_TARGET)
|
|
189
|
-
lines.append(f"· 合计 CPM {fmt_cpm(dim_cpm)},{'达标' if dim_hit else '未达标'}")
|
|
190
|
-
hit_spend = sum(it["spend"] for it in usable if hit_kpi(it["cpm"], KPI_TARGET))
|
|
191
|
-
return "\n".join(lines), dim_hit, hit_spend, spend_sum
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
def setting_line(title, curr_set, prev_set) -> str:
|
|
144
|
+
usable = [it for it in items if it["spend"] >= 50]
|
|
145
|
+
for it in usable[:DETAIL_LIMIT]:
|
|
146
|
+
value = metric_value(it.get("rows", []), metric)
|
|
147
|
+
hit = rule_hit(value, rule)
|
|
148
|
+
verdict = "不可判定" if hit is None else ("达标" if hit else "未达标")
|
|
149
|
+
lines.append(f"· 新增「{short_label(it['label'])}」{metric} {fmt_cpm(value)},KPI {verdict}(${it['spend']:.0f})")
|
|
150
|
+
if len(usable) > DETAIL_LIMIT:
|
|
151
|
+
lines.append(f"· 另有 {len(usable) - DETAIL_LIMIT} 个过门槛新增未展开,见明细")
|
|
152
|
+
if len(items) > len(usable):
|
|
153
|
+
lines.append(f"· 另有 {len(items) - len(usable)} 个新增消耗不足 $50,不纳入聚合")
|
|
154
|
+
if stop_labs:
|
|
155
|
+
lines.append(f"· 本期未出现 {len(stop_labs)} 个:{examples(stop_labs)}(不等同已暂停)")
|
|
156
|
+
records = [r for it in usable for r in it.get("rows", [])]
|
|
157
|
+
value = metric_value(records, metric)
|
|
158
|
+
hit = rule_hit(value, rule)
|
|
159
|
+
lines.append(f"· 合计(新增){metric} {fmt_cpm(value)};仅观察同期表现,不推断动作因果")
|
|
160
|
+
return "\n".join(lines), hit, sum(it["spend"] for it in usable if rule_hit(metric_value(it.get("rows", []), metric), rule)), sum(it["spend"] for it in usable)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def setting_line(title, curr_set, prev_set, first_period=False) -> str:
|
|
195
164
|
news, stops = sorted(curr_set - prev_set), sorted(prev_set - curr_set)
|
|
196
165
|
lines = [title]
|
|
166
|
+
if first_period:
|
|
167
|
+
labs = sorted(curr_set)
|
|
168
|
+
if title.startswith("【国家】"):
|
|
169
|
+
main, _extra = summarize_countries(labs)
|
|
170
|
+
lines.append(f"首期展示当期配置:{main};示例 {examples(labs)}")
|
|
171
|
+
else:
|
|
172
|
+
lines.append(f"首期展示当期 {len(labs)} 个:{examples(labs)}")
|
|
173
|
+
return "\n".join(lines)
|
|
197
174
|
if news:
|
|
198
|
-
|
|
175
|
+
if title.startswith("【国家】"):
|
|
176
|
+
main, _extra = summarize_countries(news)
|
|
177
|
+
lines.append(f"新增 {len(news)} 个({main}):{examples(news)}")
|
|
178
|
+
else:
|
|
179
|
+
lines.append(f"新增 {len(news)} 个:{examples(news)}")
|
|
199
180
|
if stops:
|
|
200
|
-
lines.append(f"
|
|
181
|
+
lines.append(f"本期未出现 {len(stops)} 个:{examples(stops)}")
|
|
201
182
|
if len(lines) == 1:
|
|
202
|
-
lines.append("
|
|
183
|
+
lines.append("未发现新增或本期未出现")
|
|
203
184
|
return "\n".join(lines)
|
|
204
185
|
|
|
205
186
|
|
|
@@ -216,17 +197,20 @@ def ad_keywords(names, n=3):
|
|
|
216
197
|
return seen
|
|
217
198
|
|
|
218
199
|
|
|
219
|
-
def
|
|
220
|
-
|
|
221
|
-
parser.add_argument("--request-dir", required=True)
|
|
222
|
-
args = parser.parse_args()
|
|
223
|
-
req = Path(args.request_dir)
|
|
200
|
+
def summarize_request(req: Path):
|
|
201
|
+
"""仅使用本次通过分页校验的文件,固定生成摘要和完整明细。"""
|
|
224
202
|
biz = load_json_file(req / "biz-line.json") or {}
|
|
225
203
|
detail = load_json_file(req / "biz-line-detail.out.json") or {}
|
|
226
204
|
detail_data = detail.get("data") if isinstance(detail.get("data"), dict) else {}
|
|
227
205
|
projects = load_json_file(req / "projects.json") or {}
|
|
228
206
|
biz_id = biz.get("id") or detail_data.get("id")
|
|
229
207
|
bl = req / f"bl-{biz_id}"
|
|
208
|
+
manifest = load_json_file(req / 'fetch-manifest.json')
|
|
209
|
+
if manifest is not None:
|
|
210
|
+
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}')
|
|
230
214
|
group = detail_data.get("ssoTeamCodeName") or biz.get("ssoTeamCodeName") or ""
|
|
231
215
|
name = detail_data.get("name") or biz.get("name") or ""
|
|
232
216
|
project = "\n".join(projects.get("projectNames") or detail_data.get("projectNames") or [])
|
|
@@ -245,14 +229,16 @@ def main() -> int:
|
|
|
245
229
|
|
|
246
230
|
spend = {"curr": {}, "prev": {}}
|
|
247
231
|
imps = {"curr": {}, "prev": {}}
|
|
232
|
+
inst = {"curr": {}, "prev": {}}
|
|
248
233
|
days = {"curr": {}, "prev": {}}
|
|
249
234
|
for ch in ("fb", "gg", "tt"):
|
|
250
235
|
for per in ("curr", "prev"):
|
|
251
236
|
day_rows = load_merged(bl, f"met_{ch}_{per}_calendar_date")
|
|
252
237
|
acc_rows = load_merged(bl, f"met_{ch}_{per}_account_id")
|
|
253
|
-
rows =
|
|
238
|
+
rows = acc_rows or day_rows
|
|
254
239
|
spend[per][ch] = sum(num(r, "spend_usd") for r in rows)
|
|
255
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)
|
|
256
242
|
days[per][ch] = len(
|
|
257
243
|
{
|
|
258
244
|
disp(r, "calendar_date") or str(r.get("calendar_date") or "")
|
|
@@ -263,19 +249,55 @@ def main() -> int:
|
|
|
263
249
|
|
|
264
250
|
curr_total = sum(spend["curr"].values())
|
|
265
251
|
prev_total = sum(spend["prev"].values())
|
|
252
|
+
curr_inst = sum(inst["curr"].values())
|
|
253
|
+
prev_inst = sum(inst["prev"].values())
|
|
266
254
|
curr_share = {ch: (spend["curr"][ch] / curr_total * 100 if curr_total else 0) for ch in spend["curr"]}
|
|
267
255
|
prev_share = {ch: (spend["prev"][ch] / prev_total * 100 if prev_total else 0) for ch in spend["prev"]}
|
|
268
256
|
overall_c = cpm(curr_total, sum(imps["curr"].values()))
|
|
269
257
|
overall_p = cpm(prev_total, sum(imps["prev"].values()))
|
|
258
|
+
cpi_c = (curr_total / curr_inst) if curr_inst else None
|
|
259
|
+
cpi_p = (prev_total / prev_inst) if prev_inst else None
|
|
270
260
|
eff_days = max(days["curr"].values() or [0])
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
261
|
+
first_period = not (len(prev) == 2 and all(prev))
|
|
262
|
+
core_index = str(biz.get("coreIndex") or detail_data.get("coreIndex") or "").strip()
|
|
263
|
+
rule = core_rule(core_index)
|
|
264
|
+
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"))
|
|
268
|
+
qoq_label, qoq_pct = qoq_axis(core_c, core_p, metric in ("ROAS", "CTR"))
|
|
269
|
+
kpi_usable = rule["usable"]
|
|
270
|
+
spend_verdict = period_verdict(
|
|
271
|
+
first_period=first_period,
|
|
272
|
+
qoq_label=qoq_label,
|
|
273
|
+
kpi_usable=kpi_usable,
|
|
274
|
+
kpi_hit=rule_hit(core_c, rule),
|
|
275
|
+
)
|
|
274
276
|
gate_note = ""
|
|
275
|
-
if curr_total < 50 or eff_days < 3:
|
|
277
|
+
if curr_total < 50 or eff_days < 3 or (metric == "CPI" and curr_inst < 10):
|
|
276
278
|
spend_verdict = "待观察"
|
|
277
|
-
gate_note = f"本期有效投放 {eff_days} 天、消耗 ${curr_total:,.0f}"
|
|
279
|
+
gate_note = f"本期有效投放 {eff_days} 天、消耗 ${curr_total:,.0f}、安装 {curr_inst:,.0f}"
|
|
278
280
|
qoq_pct_s = "" if qoq_pct is None else f"{qoq_pct * 100:+.1f}%"
|
|
281
|
+
details: list[dict] = [{"bizLine": name, "kind": "口径", "action": "客户 KPI", "channel": "", "name": core_index or "未填写", "extra": rule["reason"]}]
|
|
282
|
+
coverage_notes = []
|
|
283
|
+
from datetime import date, timedelta
|
|
284
|
+
for per, bounds in (("curr", periods.get("curr")), ("prev", periods.get("prev"))):
|
|
285
|
+
if not bounds or not all(bounds):
|
|
286
|
+
continue
|
|
287
|
+
start, end = (date.fromisoformat(d) for d in bounds)
|
|
288
|
+
expected = {(start + timedelta(days=i)).isoformat() for i in range((end - start).days + 1)}
|
|
289
|
+
for ch, accounts in projects.get("accounts", {}).items():
|
|
290
|
+
if not accounts:
|
|
291
|
+
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")})
|
|
293
|
+
missing = expected - set(dates)
|
|
294
|
+
label = "本期" if per == "curr" else "上期"
|
|
295
|
+
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 ""))
|
|
296
|
+
details.append({"bizLine": name, "kind": "周期覆盖", "channel": CH_LABEL[ch], "action": label, "name": f"请求 {bounds[0]}~{bounds[1]}", "extra": "实际日期:" + "、".join(dates)})
|
|
297
|
+
period += "\n" + "\n".join(coverage_notes)
|
|
298
|
+
if any("覆盖不完整" in n for n in coverage_notes):
|
|
299
|
+
period += "\n周期覆盖不完整,消耗减少不能直接解释为主动减预算"
|
|
300
|
+
|
|
279
301
|
|
|
280
302
|
bid_fields = {
|
|
281
303
|
"fb": ["bid_amount"],
|
|
@@ -288,49 +310,40 @@ def main() -> int:
|
|
|
288
310
|
"tt": "dim_tt_curr_calendar_date+adgroup_id+adgroup_name+bid_amount",
|
|
289
311
|
}
|
|
290
312
|
bids = {ch: bid_changes(load_merged(bl, bid_stems[ch]), bid_fields[ch]) for ch in bid_fields}
|
|
313
|
+
bid_stats = {ch: bid_adjust_stats(bids[ch]) for ch in bids}
|
|
291
314
|
|
|
292
315
|
def bid_text(ch):
|
|
293
316
|
items = bids[ch]
|
|
317
|
+
stats = bid_stats[ch]
|
|
294
318
|
label = CH_LABEL[ch]
|
|
295
319
|
if not items:
|
|
296
320
|
return f"{label}:本期未发生明显出价调整"
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
if
|
|
302
|
-
|
|
321
|
+
lines = [
|
|
322
|
+
f"{label}:上调 {stats['raise_n']} 个广告组,下调 {stats['lower_n']} 个;"
|
|
323
|
+
f"期内变化但首末持平 {stats['flat_n']} 个;幅度中位数 {stats['median_abs']:.2f}"
|
|
324
|
+
]
|
|
325
|
+
if stats["list_all"]:
|
|
326
|
+
for b in stats["details"]:
|
|
327
|
+
lines.append(
|
|
328
|
+
f"· {short_label(b.get('name') or '')}:{b.get('action')} "
|
|
329
|
+
f"{b.get('from')} → {b.get('to')}({b.get('times')} 次)"
|
|
330
|
+
)
|
|
303
331
|
return "\n".join(lines)
|
|
304
332
|
|
|
305
|
-
bid_kpi_bits = []
|
|
306
|
-
bid_spend = bid_imps = 0.0
|
|
307
333
|
for ch in ("fb", "gg", "tt"):
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
bid_kpi_bits.append(f"{short_label(b['name'])} 消耗仅 ${sp:.0f},样本不足")
|
|
321
|
-
continue
|
|
322
|
-
bid_kpi_bits.append(
|
|
323
|
-
f"{short_label(b['name'])} CPM {fmt_cpm(val)} "
|
|
324
|
-
f"{'达标' if hit_kpi(val, KPI_TARGET) else '未达标'}(消耗 ${sp:.0f})"
|
|
334
|
+
stats = bid_stats[ch]
|
|
335
|
+
src = stats["details"] if stats["list_all"] else bids[ch]
|
|
336
|
+
for b in src:
|
|
337
|
+
details.append(
|
|
338
|
+
{
|
|
339
|
+
"bizLine": name,
|
|
340
|
+
"kind": "出价",
|
|
341
|
+
"action": b.get("action") or ("上调" if float(b.get("to") or 0) > float(b.get("from") or 0) else ("下调" if float(b.get("to") or 0) < float(b.get("from") or 0) else "期内变化但首末持平")),
|
|
342
|
+
"channel": CH_LABEL[ch],
|
|
343
|
+
"name": b.get("name") or "",
|
|
344
|
+
"extra": f"ID={b['id']};" + json.dumps(b.get("fields", []), ensure_ascii=False),
|
|
345
|
+
}
|
|
325
346
|
)
|
|
326
|
-
bid_spend += sp
|
|
327
|
-
bid_imps += num(r, "impressions")
|
|
328
|
-
bid_sum_cpm = cpm(bid_spend, bid_imps)
|
|
329
|
-
bid_sum_s = (
|
|
330
|
-
f"有消耗的合计 CPM {fmt_cpm(bid_sum_cpm)},{'达标' if hit_kpi(bid_sum_cpm, KPI_TARGET) else '未达标'}"
|
|
331
|
-
if bid_kpi_bits
|
|
332
|
-
else "无实质动作 / 无法对比"
|
|
333
|
-
)
|
|
334
347
|
|
|
335
348
|
budget_strategy = "\n".join(
|
|
336
349
|
[
|
|
@@ -340,36 +353,52 @@ def main() -> int:
|
|
|
340
353
|
bid_text("tt"),
|
|
341
354
|
"",
|
|
342
355
|
"【预算】",
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
356
|
+
budget_abs_share(
|
|
357
|
+
"FB",
|
|
358
|
+
prev_abs=spend["prev"]["fb"],
|
|
359
|
+
curr_abs=spend["curr"]["fb"],
|
|
360
|
+
prev_share=prev_share["fb"],
|
|
361
|
+
curr_share=curr_share["fb"],
|
|
362
|
+
),
|
|
363
|
+
budget_abs_share(
|
|
364
|
+
"GG",
|
|
365
|
+
prev_abs=spend["prev"]["gg"],
|
|
366
|
+
curr_abs=spend["curr"]["gg"],
|
|
367
|
+
prev_share=prev_share["gg"],
|
|
368
|
+
curr_share=curr_share["gg"],
|
|
369
|
+
),
|
|
370
|
+
budget_abs_share(
|
|
371
|
+
"TT",
|
|
372
|
+
prev_abs=spend["prev"]["tt"],
|
|
373
|
+
curr_abs=spend["curr"]["tt"],
|
|
374
|
+
prev_share=prev_share["tt"],
|
|
375
|
+
curr_share=curr_share["tt"],
|
|
376
|
+
),
|
|
377
|
+
f"消耗合计:本期 ${curr_total:,.0f} / 上期 ${prev_total:,.0f}",
|
|
347
378
|
]
|
|
348
379
|
)
|
|
349
|
-
|
|
350
|
-
if
|
|
351
|
-
bid_learn_lines.append(f"· 另有 {len(bid_kpi_bits) - BID_SHOW} 个见上")
|
|
352
|
-
if bid_kpi_bits:
|
|
353
|
-
bid_learn_lines.append(f"· {bid_sum_s}")
|
|
354
|
-
else:
|
|
355
|
-
bid_learn_lines.append(f"· {bid_sum_s}")
|
|
380
|
+
cpi_s = f"全渠道 {metric}:本期 {fmt_cpm(core_c)} / 上期 {fmt_cpm(core_p)}"
|
|
381
|
+
cpi_s += "(美元消耗 / 平台安装量,非客户激活或留存成本)" if metric == "CPI" else "(同粒度基础指标合计后计算)"
|
|
356
382
|
budget_learning = "\n".join(
|
|
357
383
|
[
|
|
358
|
-
f"结论:{spend_verdict}
|
|
359
|
-
f"
|
|
360
|
-
|
|
384
|
+
f"结论:{spend_verdict}(第二期起跟环比,KPI 只作依据)" if not first_period else f"结论:{spend_verdict}(首期)",
|
|
385
|
+
f"依据:{cpi_s},环比{qoq_label}" + (f" {qoq_pct_s}" if qoq_pct_s else ""),
|
|
386
|
+
f"客户 KPI:{core_index or '未填写'};{rule['reason']}",
|
|
387
|
+
*([f"门槛:{gate_note} → 待观察"] if gate_note else []),
|
|
361
388
|
"",
|
|
362
389
|
"Spend",
|
|
363
|
-
f"·
|
|
364
|
-
f"{
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
f"·
|
|
368
|
-
f"GG {pct_pair(prev_share['gg'], curr_share['gg'])};"
|
|
369
|
-
f"TT {pct_pair(prev_share['tt'], curr_share['tt'])}",
|
|
390
|
+
f"· {cpi_s}",
|
|
391
|
+
f"· 参考 CPM:本期 {fmt_cpm(overall_c)} / 上期 {fmt_cpm(overall_p)}(不作结论轴)",
|
|
392
|
+
f"· {budget_abs_share('FB', prev_abs=spend['prev']['fb'], curr_abs=spend['curr']['fb'], prev_share=prev_share['fb'], curr_share=curr_share['fb'])}",
|
|
393
|
+
f"· {budget_abs_share('GG', prev_abs=spend['prev']['gg'], curr_abs=spend['curr']['gg'], prev_share=prev_share['gg'], curr_share=curr_share['gg'])}",
|
|
394
|
+
f"· {budget_abs_share('TT', prev_abs=spend['prev']['tt'], curr_abs=spend['curr']['tt'], prev_share=prev_share['tt'], curr_share=curr_share['tt'])}",
|
|
370
395
|
"",
|
|
371
396
|
"调整出价",
|
|
372
|
-
*
|
|
397
|
+
*[
|
|
398
|
+
f"· {CH_LABEL[ch]}:上调 {bid_stats[ch]['raise_n']} / 下调 {bid_stats[ch]['lower_n']},"
|
|
399
|
+
f"幅度中位数 {bid_stats[ch]['median_abs']:.2f}"
|
|
400
|
+
for ch in ("fb", "gg", "tt")
|
|
401
|
+
],
|
|
373
402
|
"",
|
|
374
403
|
advice_of(spend_verdict),
|
|
375
404
|
]
|
|
@@ -398,53 +427,83 @@ def main() -> int:
|
|
|
398
427
|
curr = set()
|
|
399
428
|
prev = set()
|
|
400
429
|
for ch in channels:
|
|
430
|
+
# Meta 兴趣的三个配置字段来自同一组合请求,不能找不存在的单字段文件。
|
|
431
|
+
if ch == 'fb' and stem == 'targeting_interests':
|
|
432
|
+
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)
|
|
436
|
+
continue
|
|
401
437
|
curr |= labels_of(load_merged(bl, f"dim_{ch}_curr_{stem}"), key)
|
|
402
438
|
prev |= labels_of(load_merged(bl, f"dim_{ch}_prev_{stem}"), key)
|
|
403
439
|
return curr, prev
|
|
404
440
|
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
441
|
+
def add_setting_details(kind, curr_set, prev_set, channel=""):
|
|
442
|
+
action_curr = "当期" if first_period else "新增"
|
|
443
|
+
news = sorted(curr_set if first_period or kind == "国家" else (curr_set - prev_set))
|
|
444
|
+
stops = [] if first_period else sorted(prev_set - curr_set)
|
|
445
|
+
for lab in news:
|
|
446
|
+
details.append(
|
|
447
|
+
{
|
|
448
|
+
"bizLine": name,
|
|
449
|
+
"kind": kind,
|
|
450
|
+
"action": "持续" if not first_period and lab in prev_set else action_curr,
|
|
451
|
+
"channel": channel,
|
|
452
|
+
"name": lab,
|
|
453
|
+
"extra": "",
|
|
454
|
+
}
|
|
455
|
+
)
|
|
456
|
+
for lab in stops:
|
|
457
|
+
details.append(
|
|
458
|
+
{
|
|
459
|
+
"bizLine": name,
|
|
460
|
+
"kind": kind,
|
|
461
|
+
"action": "本期未出现",
|
|
462
|
+
"channel": channel,
|
|
463
|
+
"name": lab,
|
|
464
|
+
"extra": "",
|
|
465
|
+
}
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
country_curr, country_prev = dim_sets("country_code", "country_code", ["fb", "gg", "tt"])
|
|
469
|
+
interest_curr, interest_prev = dim_sets("targeting_interests", "targeting_interests", ["fb", "tt"])
|
|
470
|
+
kw_curr, kw_prev = dim_sets("keywords", "keywords", ["gg"])
|
|
471
|
+
add_setting_details("国家", country_curr, country_prev)
|
|
472
|
+
add_setting_details("兴趣词", interest_curr, interest_prev)
|
|
473
|
+
add_setting_details("关键词", kw_curr, kw_prev)
|
|
474
|
+
|
|
475
|
+
def object_strategy(kind, id_key, name_key):
|
|
476
|
+
bits = []
|
|
477
|
+
for ch in CH_LABEL:
|
|
478
|
+
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)}
|
|
483
|
+
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])}")
|
|
485
|
+
for ident in sorted(c.keys() | p.keys()):
|
|
486
|
+
record = c.get(ident) or p[ident]
|
|
487
|
+
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};集合差异不等同创建/暂停操作"})
|
|
489
|
+
return bits
|
|
490
|
+
|
|
491
|
+
ag_bits = object_strategy("广告组", "adgroup_id", "adgroup_name")
|
|
417
492
|
audience_strategy = "\n\n".join(
|
|
418
493
|
x
|
|
419
494
|
for x in [
|
|
420
|
-
setting_line("【国家】",
|
|
421
|
-
setting_line("【兴趣词包】",
|
|
495
|
+
setting_line("【国家】", country_curr, country_prev, first_period=first_period),
|
|
496
|
+
setting_line("【兴趣词包】", interest_curr, interest_prev, first_period=first_period),
|
|
422
497
|
share_line("【受众类型】", "audience_type", "audience_type", ["fb", "gg", "tt"])[0],
|
|
423
|
-
setting_line("【关键词】",
|
|
424
|
-
setting_line("【年龄】", *dim_sets("targeting_age", "targeting_age", ["fb", "gg", "tt"])),
|
|
425
|
-
setting_line("【性别】", *dim_sets("targeting_genders", "targeting_genders", ["fb", "gg", "tt"])),
|
|
498
|
+
setting_line("【关键词】", kw_curr, kw_prev, first_period=first_period),
|
|
499
|
+
setting_line("【年龄】", *dim_sets("targeting_age", "targeting_age", ["fb", "gg", "tt"]), first_period=first_period),
|
|
500
|
+
setting_line("【性别】", *dim_sets("targeting_genders", "targeting_genders", ["fb", "gg", "tt"]), first_period=first_period),
|
|
426
501
|
"【广告组】\n" + "\n".join(ag_bits),
|
|
427
502
|
]
|
|
428
503
|
if x
|
|
429
504
|
)
|
|
430
505
|
|
|
431
|
-
ad_bits =
|
|
432
|
-
for ch in ("fb", "gg", "tt"):
|
|
433
|
-
curr = load_merged(bl, f"dim_{ch}_curr_ad_id+ad_name") or load_merged(bl, f"met_{ch}_curr_ad_id+ad_name")
|
|
434
|
-
prev = load_merged(bl, f"dim_{ch}_prev_ad_id+ad_name") or load_merged(bl, f"met_{ch}_prev_ad_id+ad_name")
|
|
435
|
-
curr_ids = {str(r.get("ad_id")): r for r in curr if r.get("ad_id")}
|
|
436
|
-
prev_ids = {str(r.get("ad_id")): r for r in prev if r.get("ad_id")}
|
|
437
|
-
new_ids = [i for i in curr_ids if i not in prev_ids]
|
|
438
|
-
stop_ids = [i for i in prev_ids if i not in curr_ids]
|
|
439
|
-
nk = ad_keywords([disp(curr_ids[i], "ad_name") for i in new_ids[:30]])
|
|
440
|
-
sk = ad_keywords([disp(prev_ids[i], "ad_name") for i in stop_ids[:30]])
|
|
441
|
-
line = f"{CH_LABEL[ch]}:新增 {len(new_ids)} 个,关停 {len(stop_ids)} 个"
|
|
442
|
-
extras = []
|
|
443
|
-
if nk:
|
|
444
|
-
extras.append(f"新增方向「{' / '.join(nk)}」")
|
|
445
|
-
if sk:
|
|
446
|
-
extras.append(f"关停方向「{' / '.join(sk)}」")
|
|
447
|
-
ad_bits.append(line + (f"\n· {';'.join(extras)}" if extras else ""))
|
|
506
|
+
ad_bits = object_strategy("广告", "ad_id", "ad_name")
|
|
448
507
|
creative_fmt = share_line("【创意类型】", "ad_format", "ad_format", ["fb", "gg", "tt"])[0] or ""
|
|
449
508
|
creative_strategy = "【广告】\n" + "\n".join(ad_bits) + (("\n\n" + creative_fmt) if creative_fmt else "")
|
|
450
509
|
|
|
@@ -455,35 +514,53 @@ def main() -> int:
|
|
|
455
514
|
for title, stem, key, channels, id_key in dim_specs:
|
|
456
515
|
items_all, stops_all = [], []
|
|
457
516
|
for ch in channels:
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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)
|
|
522
|
+
stops = [
|
|
523
|
+
s
|
|
524
|
+
for s in stops
|
|
525
|
+
if not is_ghost_enum(curr_share_map.get(s, 0), prev_share_map.get(s, 0))
|
|
526
|
+
]
|
|
527
|
+
for item in items:
|
|
528
|
+
item["channel"] = CH_LABEL[ch]
|
|
529
|
+
if item["spend"] >= 50:
|
|
530
|
+
value = metric_value(item["rows"], metric)
|
|
531
|
+
hit = rule_hit(value, rule)
|
|
532
|
+
verdict_text = '不可判定' if hit is None else ('达标' if hit else '未达标')
|
|
533
|
+
details.append({"bizLine": name, "kind": f"新增成效/{title}", "action": "新增", "channel": CH_LABEL[ch], "name": item["label"], "extra": f"ID={item['id']};消耗美元 {item['spend']:.2f};{metric} {fmt_cpm(value)};KPI {verdict_text}"})
|
|
464
534
|
items_all.extend(items)
|
|
465
535
|
stops_all.extend(stops)
|
|
466
536
|
if items_all:
|
|
467
537
|
any_new = True
|
|
468
538
|
if stops_all and not items_all:
|
|
469
539
|
only_stop = True
|
|
470
|
-
text, _hit, hs, ts = dim_layer_text(title, items_all, stops_all)
|
|
540
|
+
text, _hit, hs, ts = dim_layer_text(title, items_all, stops_all, rule)
|
|
471
541
|
texts.append(text)
|
|
472
542
|
if ts > 0:
|
|
473
543
|
hit_spend += hs
|
|
474
544
|
all_spend += ts
|
|
475
545
|
detail = "\n\n".join(texts)
|
|
546
|
+
verdict = period_verdict(
|
|
547
|
+
first_period=first_period,
|
|
548
|
+
qoq_label=qoq_label,
|
|
549
|
+
kpi_usable=kpi_usable,
|
|
550
|
+
kpi_hit=rule_hit(core_c, rule),
|
|
551
|
+
)
|
|
552
|
+
if gate_note:
|
|
553
|
+
verdict = "待观察"
|
|
554
|
+
evidence = (
|
|
555
|
+
f"{cpi_s},环比{qoq_label}" + (f" {qoq_pct_s}" if qoq_pct_s else "")
|
|
556
|
+
+ "。此为整体环比观察,不代表本次单一动作的因果效果;" + rule["reason"]
|
|
557
|
+
)
|
|
476
558
|
if not any_new and only_stop:
|
|
477
|
-
verdict = dual_verdict(spend_hit, qoq_label)
|
|
478
559
|
return "\n".join(
|
|
479
560
|
[
|
|
480
561
|
f"结论:{verdict}",
|
|
481
|
-
"
|
|
482
|
-
f"
|
|
483
|
-
"",
|
|
484
|
-
f"关停后存量 CPM {fmt_cpm(overall_c)},{'达标' if spend_hit else '未达标'}",
|
|
485
|
-
f"关停前 {fmt_cpm(overall_p)},环比{qoq_label}"
|
|
486
|
-
+ (f" {qoq_pct_s}" if qoq_pct_s else ""),
|
|
562
|
+
"口径:本期无新增,走本期未出现",
|
|
563
|
+
f"依据:{evidence}",
|
|
487
564
|
"",
|
|
488
565
|
detail,
|
|
489
566
|
"",
|
|
@@ -492,15 +569,11 @@ def main() -> int:
|
|
|
492
569
|
)
|
|
493
570
|
if not any_new:
|
|
494
571
|
if allow_share_fallback and share_did_move:
|
|
495
|
-
verdict = dual_verdict(spend_hit, qoq_label)
|
|
496
572
|
return "\n".join(
|
|
497
573
|
[
|
|
498
574
|
f"结论:{verdict}",
|
|
499
575
|
"口径:无新增枚举,占比变化超过 ±2%,按可环比",
|
|
500
|
-
f"
|
|
501
|
-
"",
|
|
502
|
-
f"核心指标 CPM:本期 {fmt_cpm(overall_c)} / 上期 {fmt_cpm(overall_p)},"
|
|
503
|
-
f"{'达标' if spend_hit else '未达标'} + {qoq_label} → {verdict}",
|
|
576
|
+
f"依据:{evidence}",
|
|
504
577
|
"",
|
|
505
578
|
detail,
|
|
506
579
|
"",
|
|
@@ -509,26 +582,18 @@ def main() -> int:
|
|
|
509
582
|
)
|
|
510
583
|
return "\n".join(
|
|
511
584
|
[
|
|
512
|
-
"
|
|
585
|
+
f"结论:{verdict}",
|
|
513
586
|
"口径:本期无实质调整动作",
|
|
514
|
-
f"
|
|
587
|
+
f"依据:{evidence}",
|
|
515
588
|
"",
|
|
516
589
|
detail,
|
|
517
590
|
]
|
|
518
591
|
)
|
|
519
592
|
ratio = (hit_spend / all_spend) if all_spend else 0.0
|
|
520
|
-
axis_hit = ratio >= 0.70
|
|
521
|
-
verdict = dual_verdict(axis_hit, qoq_label)
|
|
522
|
-
if gate_note:
|
|
523
|
-
verdict = "待观察"
|
|
524
593
|
return "\n".join(
|
|
525
594
|
[
|
|
526
595
|
f"结论:{verdict}",
|
|
527
|
-
f"
|
|
528
|
-
f"{'达标' if axis_hit else '未达标'});环比本期 CPM {fmt_cpm(overall_c)} / "
|
|
529
|
-
f"上期 {fmt_cpm(overall_p)},{qoq_label}"
|
|
530
|
-
+ (f" {qoq_pct_s}" if qoq_pct_s else ""),
|
|
531
|
-
f"KPI:{KPI_LABEL}",
|
|
596
|
+
f"依据:{evidence}",
|
|
532
597
|
"",
|
|
533
598
|
detail,
|
|
534
599
|
"",
|
|
@@ -578,18 +643,30 @@ def main() -> int:
|
|
|
578
643
|
"creativeStrategy": creative_strategy,
|
|
579
644
|
"creativeLearning": creative_learning,
|
|
580
645
|
}
|
|
581
|
-
payload = {"rows": [row]}
|
|
646
|
+
payload = {"rows": [row], "details": details}
|
|
582
647
|
(req / "payload.json").write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
583
648
|
summary = {
|
|
584
649
|
"bizLineId": biz_id,
|
|
585
650
|
"curr_total": curr_total,
|
|
586
651
|
"prev_total": prev_total,
|
|
652
|
+
"overall_cpi": [cpi_c, cpi_p],
|
|
587
653
|
"overall_cpm": [overall_c, overall_p],
|
|
588
654
|
"eff_days": eff_days,
|
|
655
|
+
"first_period": first_period,
|
|
589
656
|
"spend_verdict": spend_verdict,
|
|
590
657
|
"bid_counts": {ch: len(bids[ch]) for ch in bids},
|
|
658
|
+
"detail_count": len(details),
|
|
659
|
+
"request_dir": str(req),
|
|
591
660
|
}
|
|
592
661
|
(req / "summary.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
662
|
+
return payload, summary
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def main() -> int:
|
|
666
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
667
|
+
parser.add_argument("--request-dir", required=True)
|
|
668
|
+
args = parser.parse_args()
|
|
669
|
+
_, summary = summarize_request(Path(args.request_dir))
|
|
593
670
|
print(json.dumps(summary, ensure_ascii=False), flush=True)
|
|
594
671
|
return 0
|
|
595
672
|
|