sino-bc-cli 1.8.22 → 1.8.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +1 -1
- package/dist/skills/custom/biz-line-strategy-generate/SKILL.md +3 -2
- package/dist/skills/custom/biz-line-strategy-generate/references/api-flow.md +1 -1
- package/dist/skills/custom/biz-line-strategy-generate/references/output-excel.md +1 -1
- package/dist/skills/custom/biz-line-strategy-generate/references/runtime.md +3 -2
- package/dist/skills/custom/biz-line-strategy-generate/references/spec.md +18 -3
- package/dist/skills/custom/biz-line-strategy-generate/scripts/build_report.py +225 -79
- package/dist/skills/custom/biz-line-strategy-generate/scripts/cli_json.py +94 -20
- package/dist/skills/custom/biz-line-strategy-generate/scripts/fetch_data.py +31 -16
- package/dist/skills/custom/biz-line-strategy-generate/scripts/summarize.py +682 -412
- package/dist/skills/custom/biz-line-strategy-generate/scripts/test_strategy_rules.py +53 -6
- package/dist/skills/custom/biz-line-strategy-generate/scripts/test_workflow.py +279 -6
- package/package.json +1 -1
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
# -*- coding: utf-8 -*-
|
|
3
|
-
"""
|
|
3
|
+
"""按固定规则汇总分页临时文件,生成可直接校验及导出的 payload。
|
|
4
4
|
"""
|
|
5
5
|
from __future__ import annotations
|
|
6
6
|
|
|
7
7
|
import argparse
|
|
8
|
+
import heapq
|
|
8
9
|
import json
|
|
9
10
|
import sys
|
|
11
|
+
import tempfile
|
|
10
12
|
from collections import defaultdict
|
|
11
13
|
from pathlib import Path
|
|
12
14
|
|
|
@@ -15,13 +17,16 @@ if str(SCRIPTS) not in sys.path:
|
|
|
15
17
|
sys.path.insert(0, str(SCRIPTS))
|
|
16
18
|
|
|
17
19
|
from cli_json import (
|
|
20
|
+
add_metric_acc,
|
|
18
21
|
bid_adjust_stats,
|
|
19
22
|
budget_abs_share,
|
|
20
23
|
cpm,
|
|
21
24
|
disp,
|
|
22
25
|
examples,
|
|
26
|
+
finish_metric_acc,
|
|
23
27
|
fmt_cpm,
|
|
24
28
|
metric_value,
|
|
29
|
+
new_metric_acc,
|
|
25
30
|
optional_num,
|
|
26
31
|
core_rule,
|
|
27
32
|
rule_hit,
|
|
@@ -42,13 +47,47 @@ CH_LABEL = {"fb": "Meta", "gg": "Google", "tt": "TikTok"}
|
|
|
42
47
|
DETAIL_LIMIT = 8
|
|
43
48
|
|
|
44
49
|
|
|
45
|
-
def
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
50
|
+
def iter_stem_rows(bl: Path, stem: str):
|
|
51
|
+
"""按页 yield 行,不把全表拼成 list。可重复调用,每次重新读盘。"""
|
|
52
|
+
meta_path = bl / f"{stem}.pages.json"
|
|
53
|
+
if meta_path.exists():
|
|
54
|
+
meta = load_json_file(meta_path)
|
|
55
|
+
if not isinstance(meta, dict) or not isinstance(meta.get("counts"), list):
|
|
56
|
+
raise ValueError(f"拉数文件不完整:{stem}")
|
|
57
|
+
accumulated = 0
|
|
58
|
+
for index, expected in enumerate(meta["counts"], start=1):
|
|
59
|
+
env = load_json_file(bl / f"{stem}.p{index}.out.json")
|
|
60
|
+
page_rows, _ = unwrap_page(env or {})
|
|
61
|
+
if len(page_rows) != expected:
|
|
62
|
+
raise ValueError(f"拉数文件不完整:{stem}")
|
|
63
|
+
yield from page_rows
|
|
64
|
+
accumulated += len(page_rows)
|
|
65
|
+
if meta.get("total") != accumulated:
|
|
66
|
+
raise ValueError(f"拉数文件不完整:{stem}")
|
|
67
|
+
return
|
|
68
|
+
extras = [p for p in bl.glob(f"{stem}.p*.out.json") if p.name != f"{stem}.p1.out.json"]
|
|
69
|
+
if extras:
|
|
70
|
+
raise ValueError(f"拉数文件不完整:{stem}")
|
|
49
71
|
env = load_json_file(bl / f"{stem}.p1.out.json")
|
|
50
|
-
|
|
51
|
-
|
|
72
|
+
yield from unwrap_page(env or {})[0]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def load_merged(bl: Path, stem: str) -> list:
|
|
76
|
+
"""兼容测试与诊断:需要 list 时才物化。分析路径应走 iter_stem_rows。"""
|
|
77
|
+
return list(iter_stem_rows(bl, stem))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def object_name_maps(rows, id_key, name_key) -> tuple[dict[str, str], dict[str, str]]:
|
|
81
|
+
"""id → 明细展示名(空则回退 id),以及原始 display(空名不当作 id,避免命名规律拆 ID)。"""
|
|
82
|
+
names: dict[str, str] = {}
|
|
83
|
+
raw: dict[str, str] = {}
|
|
84
|
+
for row in rows:
|
|
85
|
+
if row.get(id_key):
|
|
86
|
+
ident = str(row[id_key])
|
|
87
|
+
shown = disp(row, name_key)
|
|
88
|
+
raw[ident] = shown
|
|
89
|
+
names[ident] = shown or ident
|
|
90
|
+
return names, raw
|
|
52
91
|
|
|
53
92
|
|
|
54
93
|
def labels_of(rows, key) -> set[str]:
|
|
@@ -56,10 +95,13 @@ def labels_of(rows, key) -> set[str]:
|
|
|
56
95
|
|
|
57
96
|
|
|
58
97
|
def share_map(rows, dim_key):
|
|
59
|
-
|
|
98
|
+
"""一次扫描累加消耗占比,可接迭代器。"""
|
|
99
|
+
total = 0.0
|
|
60
100
|
acc = defaultdict(float)
|
|
61
|
-
for
|
|
62
|
-
|
|
101
|
+
for row in rows:
|
|
102
|
+
spend = num(row, "spend_usd")
|
|
103
|
+
total += spend
|
|
104
|
+
acc[disp(row, dim_key) or "未标注"] += spend
|
|
63
105
|
if total <= 0:
|
|
64
106
|
return {}, 0.0
|
|
65
107
|
return {k: v / total * 100 for k, v in acc.items()}, total
|
|
@@ -89,48 +131,248 @@ def share_moved(curr: dict, prev: dict) -> bool:
|
|
|
89
131
|
return False
|
|
90
132
|
|
|
91
133
|
|
|
92
|
-
def
|
|
93
|
-
|
|
134
|
+
def _bid_snap(row: dict, fields: list[str]) -> dict | None:
|
|
135
|
+
ag = row.get("adgroup_id")
|
|
136
|
+
if not ag:
|
|
137
|
+
return None
|
|
138
|
+
ident = str(ag)
|
|
139
|
+
date_s = str(row.get("calendar_date") or "")
|
|
140
|
+
snap = {
|
|
141
|
+
"id": ident,
|
|
142
|
+
"calendar_date": date_s,
|
|
143
|
+
"name": disp(row, "adgroup_name") or ident,
|
|
144
|
+
"strategy": disp(row, "bid_strategy"),
|
|
145
|
+
}
|
|
146
|
+
for field in fields:
|
|
147
|
+
snap[field] = optional_num(row, field)
|
|
148
|
+
return snap
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _bid_result_from_records(ag: str, records: list[dict], fields: list[str]) -> dict | None:
|
|
152
|
+
records = sorted(records, key=lambda r: r["calendar_date"])
|
|
153
|
+
changes = []
|
|
154
|
+
changed_dates = set()
|
|
155
|
+
for field in fields:
|
|
156
|
+
valid = [(r["calendar_date"], r[field]) for r in records if r[field] is not None]
|
|
157
|
+
times = sum(a[1] != b[1] for a, b in zip(valid, valid[1:]))
|
|
158
|
+
changed_dates.update(b[0] for a, b in zip(valid, valid[1:]) if a[1] != b[1])
|
|
159
|
+
if times:
|
|
160
|
+
changes.append({"field": field, "from": valid[0][1], "to": valid[-1][1], "times": times})
|
|
161
|
+
if not changes:
|
|
162
|
+
return None
|
|
163
|
+
primary = next((c for c in changes if c["from"] != c["to"]), changes[0])
|
|
164
|
+
last = records[-1]
|
|
165
|
+
return {"id": ag, "name": last["name"], **primary, "times": len(changed_dates), "fields": changes, "strategy": last["strategy"]}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _sort_line_file(src: Path, chunk_size: int = 4000) -> Path:
|
|
169
|
+
"""按行外排序,一次只留一块在内存。"""
|
|
170
|
+
parts: list[Path] = []
|
|
171
|
+
buf: list[str] = []
|
|
172
|
+
|
|
173
|
+
def flush() -> None:
|
|
174
|
+
if not buf:
|
|
175
|
+
return
|
|
176
|
+
buf.sort()
|
|
177
|
+
part = src.with_name(f"{src.name}.s{len(parts)}")
|
|
178
|
+
part.write_text("".join(buf), encoding="utf-8")
|
|
179
|
+
parts.append(part)
|
|
180
|
+
buf.clear()
|
|
181
|
+
|
|
182
|
+
with src.open(encoding="utf-8") as handle:
|
|
183
|
+
for line in handle:
|
|
184
|
+
buf.append(line)
|
|
185
|
+
if len(buf) >= chunk_size:
|
|
186
|
+
flush()
|
|
187
|
+
flush()
|
|
188
|
+
if not parts:
|
|
189
|
+
return src
|
|
190
|
+
if len(parts) == 1:
|
|
191
|
+
parts[0].replace(src)
|
|
192
|
+
return src
|
|
193
|
+
out = src.with_name(src.name + ".sorted")
|
|
194
|
+
handles = [part.open(encoding="utf-8") for part in parts]
|
|
195
|
+
try:
|
|
196
|
+
with out.open("w", encoding="utf-8") as dest:
|
|
197
|
+
dest.writelines(heapq.merge(*handles))
|
|
198
|
+
finally:
|
|
199
|
+
for handle in handles:
|
|
200
|
+
handle.close()
|
|
201
|
+
for part in parts:
|
|
202
|
+
part.unlink(missing_ok=True)
|
|
203
|
+
return out
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _bid_changes_memory(rows, fields):
|
|
207
|
+
"""小样本对照:内存按广告组聚合。生产路径必须走 bid_changes 落盘。"""
|
|
94
208
|
by_ag = defaultdict(list)
|
|
95
209
|
for row in rows:
|
|
96
|
-
|
|
97
|
-
|
|
210
|
+
snap = _bid_snap(row, fields)
|
|
211
|
+
if snap:
|
|
212
|
+
by_ag[snap["id"]].append(snap)
|
|
98
213
|
result = []
|
|
99
214
|
for ag, records in by_ag.items():
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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")})
|
|
215
|
+
item = _bid_result_from_records(ag, records, fields)
|
|
216
|
+
if item:
|
|
217
|
+
result.append(item)
|
|
114
218
|
return sorted(result, key=lambda r: (-r["times"], r["id"]))
|
|
115
219
|
|
|
116
220
|
|
|
117
|
-
def
|
|
118
|
-
"""
|
|
221
|
+
def bid_changes(rows, fields, work_dir: Path | None = None):
|
|
222
|
+
"""逐个出价字段比较有效日快照,缺失值不补零。始终按广告组落盘再逐组处理。"""
|
|
223
|
+
if work_dir is None:
|
|
224
|
+
with tempfile.TemporaryDirectory(prefix="bid-") as tmp:
|
|
225
|
+
return bid_changes(rows, fields, work_dir=Path(tmp))
|
|
226
|
+
work_dir.mkdir(parents=True, exist_ok=True)
|
|
227
|
+
spill = work_dir / "snaps.ndjson"
|
|
228
|
+
with spill.open("w", encoding="utf-8") as handle:
|
|
229
|
+
for row in rows:
|
|
230
|
+
snap = _bid_snap(row, fields)
|
|
231
|
+
if not snap:
|
|
232
|
+
continue
|
|
233
|
+
handle.write(f"{snap['id']}\t{snap['calendar_date']}\t{json.dumps(snap, ensure_ascii=False)}\n")
|
|
234
|
+
ordered = _sort_line_file(spill)
|
|
235
|
+
result = []
|
|
236
|
+
current_id = ""
|
|
237
|
+
group: list[dict] = []
|
|
238
|
+
|
|
239
|
+
def flush_group() -> None:
|
|
240
|
+
if not group:
|
|
241
|
+
return
|
|
242
|
+
item = _bid_result_from_records(current_id, group, fields)
|
|
243
|
+
if item:
|
|
244
|
+
result.append(item)
|
|
245
|
+
group.clear()
|
|
246
|
+
|
|
247
|
+
with ordered.open(encoding="utf-8") as handle:
|
|
248
|
+
for line in handle:
|
|
249
|
+
ident, _date, payload = line.split("\t", 2)
|
|
250
|
+
snap = json.loads(payload)
|
|
251
|
+
if ident != current_id:
|
|
252
|
+
flush_group()
|
|
253
|
+
current_id = ident
|
|
254
|
+
group.append(snap)
|
|
255
|
+
flush_group()
|
|
256
|
+
return sorted(result, key=lambda r: (-r["times"], r["id"]))
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class DetailWriter:
|
|
260
|
+
"""明细逐条写 ndjson,分析阶段不攒全表 list。"""
|
|
261
|
+
|
|
262
|
+
def __init__(self, path: Path):
|
|
263
|
+
self.path = path
|
|
264
|
+
self.count = 0
|
|
265
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
266
|
+
self._fh = path.open("w", encoding="utf-8")
|
|
267
|
+
|
|
268
|
+
def append(self, record: dict) -> None:
|
|
269
|
+
self._fh.write(json.dumps(record, ensure_ascii=False) + "\n")
|
|
270
|
+
self.count += 1
|
|
271
|
+
|
|
272
|
+
def close(self) -> None:
|
|
273
|
+
if self._fh and not self._fh.closed:
|
|
274
|
+
self._fh.close()
|
|
275
|
+
|
|
276
|
+
def __enter__(self):
|
|
277
|
+
return self
|
|
278
|
+
|
|
279
|
+
def __exit__(self, *_exc) -> None:
|
|
280
|
+
self.close()
|
|
281
|
+
|
|
282
|
+
def __len__(self) -> int:
|
|
283
|
+
return self.count
|
|
284
|
+
|
|
285
|
+
def __iter__(self):
|
|
286
|
+
self.close()
|
|
287
|
+
yield from iter_details(self.path)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def iter_details(details):
|
|
291
|
+
"""统一遍历 list / DetailWriter / ndjson 路径。"""
|
|
292
|
+
if isinstance(details, DetailWriter):
|
|
293
|
+
yield from details
|
|
294
|
+
return
|
|
295
|
+
if isinstance(details, (str, Path)):
|
|
296
|
+
path = Path(details)
|
|
297
|
+
if not path.exists():
|
|
298
|
+
return
|
|
299
|
+
with path.open(encoding="utf-8") as handle:
|
|
300
|
+
for line in handle:
|
|
301
|
+
if line.strip():
|
|
302
|
+
yield json.loads(line)
|
|
303
|
+
return
|
|
304
|
+
yield from details or []
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def share_and_new_items(curr_rows, prev_rows, key, id_key=None):
|
|
308
|
+
"""同一对 curr/prev 迭代器各扫一遍,同时得到占比与新增对象。"""
|
|
119
309
|
def identity(row):
|
|
120
310
|
return str(row.get(id_key) or "") if id_key else disp(row, key)
|
|
311
|
+
|
|
312
|
+
previous: dict[str, str] = {}
|
|
313
|
+
prev_acc: dict[str, float] = defaultdict(float)
|
|
314
|
+
prev_total = 0.0
|
|
315
|
+
for row in prev_rows:
|
|
316
|
+
ident = identity(row)
|
|
317
|
+
if ident:
|
|
318
|
+
previous[ident] = disp(row, key) or ident
|
|
319
|
+
spend = num(row, "spend_usd")
|
|
320
|
+
prev_total += spend
|
|
321
|
+
prev_acc[disp(row, key) or "未标注"] += spend
|
|
322
|
+
|
|
121
323
|
grouped = defaultdict(list)
|
|
324
|
+
current_ids: set[str] = set()
|
|
325
|
+
curr_acc: dict[str, float] = defaultdict(float)
|
|
326
|
+
curr_total = 0.0
|
|
122
327
|
for row in curr_rows:
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
328
|
+
spend = num(row, "spend_usd")
|
|
329
|
+
curr_total += spend
|
|
330
|
+
curr_acc[disp(row, key) or "未标注"] += spend
|
|
331
|
+
ident = identity(row)
|
|
332
|
+
if not ident:
|
|
333
|
+
continue
|
|
334
|
+
current_ids.add(ident)
|
|
335
|
+
if ident not in previous:
|
|
336
|
+
grouped[ident].append(row)
|
|
337
|
+
|
|
338
|
+
def to_share(acc, total):
|
|
339
|
+
if total <= 0:
|
|
340
|
+
return {}
|
|
341
|
+
return {k: v / total * 100 for k, v in acc.items()}
|
|
342
|
+
|
|
126
343
|
items = []
|
|
127
344
|
for ident, records in grouped.items():
|
|
128
|
-
|
|
345
|
+
items.append({"id": ident if id_key else "", "label": disp(records[0], key) or ident,
|
|
346
|
+
"spend": sum(num(r, "spend_usd") for r in records), "rows": records,
|
|
347
|
+
"cpi": metric_value(records), "cpm": metric_value(records, "CPM")})
|
|
348
|
+
stops = [label for ident, label in previous.items() if ident not in current_ids]
|
|
349
|
+
return to_share(curr_acc, curr_total), to_share(prev_acc, prev_total), items, stops
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def new_items(curr_rows, prev_rows, key, id_key=None):
|
|
353
|
+
"""按稳定 ID/维度值聚合基础指标,再识别本期新出现的对象。只为新增对象保留行。"""
|
|
354
|
+
def identity(row):
|
|
355
|
+
return str(row.get(id_key) or "") if id_key else disp(row, key)
|
|
356
|
+
previous: dict[str, str] = {}
|
|
357
|
+
for row in prev_rows:
|
|
358
|
+
ident = identity(row)
|
|
359
|
+
if ident:
|
|
360
|
+
previous[ident] = disp(row, key) or ident
|
|
361
|
+
grouped = defaultdict(list)
|
|
362
|
+
current_ids: set[str] = set()
|
|
363
|
+
for row in curr_rows:
|
|
364
|
+
ident = identity(row)
|
|
365
|
+
if not ident:
|
|
129
366
|
continue
|
|
367
|
+
current_ids.add(ident)
|
|
368
|
+
if ident not in previous:
|
|
369
|
+
grouped[ident].append(row)
|
|
370
|
+
items = []
|
|
371
|
+
for ident, records in grouped.items():
|
|
130
372
|
items.append({"id": ident if id_key else "", "label": disp(records[0], key) or ident,
|
|
131
373
|
"spend": sum(num(r, "spend_usd") for r in records), "rows": records,
|
|
132
374
|
"cpi": metric_value(records), "cpm": metric_value(records, "CPM")})
|
|
133
|
-
stops = [
|
|
375
|
+
stops = [label for ident, label in previous.items() if ident not in current_ids]
|
|
134
376
|
return items, stops
|
|
135
377
|
|
|
136
378
|
|
|
@@ -208,9 +450,14 @@ def summarize_request(req: Path):
|
|
|
208
450
|
manifest = load_json_file(req / 'fetch-manifest.json')
|
|
209
451
|
if manifest is not None:
|
|
210
452
|
for stem, count in manifest.items():
|
|
211
|
-
|
|
212
|
-
if
|
|
213
|
-
|
|
453
|
+
meta = load_json_file(bl / f"{stem}.pages.json")
|
|
454
|
+
if (
|
|
455
|
+
not isinstance(meta, dict)
|
|
456
|
+
or meta.get("total") != count
|
|
457
|
+
or not isinstance(meta.get("counts"), list)
|
|
458
|
+
or sum(meta["counts"]) != count
|
|
459
|
+
):
|
|
460
|
+
raise ValueError(f"拉数文件不完整:{stem}")
|
|
214
461
|
group = detail_data.get("ssoTeamCodeName") or biz.get("ssoTeamCodeName") or ""
|
|
215
462
|
name = detail_data.get("name") or biz.get("name") or ""
|
|
216
463
|
project = "\n".join(projects.get("projectNames") or detail_data.get("projectNames") or [])
|
|
@@ -231,21 +478,40 @@ def summarize_request(req: Path):
|
|
|
231
478
|
imps = {"curr": {}, "prev": {}}
|
|
232
479
|
inst = {"curr": {}, "prev": {}}
|
|
233
480
|
days = {"curr": {}, "prev": {}}
|
|
481
|
+
coverage_dates = {"curr": {}, "prev": {}}
|
|
482
|
+
core_index = str(biz.get("coreIndex") or detail_data.get("coreIndex") or "").strip()
|
|
483
|
+
rule = core_rule(core_index)
|
|
484
|
+
metric = rule["metric"]
|
|
485
|
+
core_acc = {"curr": new_metric_acc(metric), "prev": new_metric_acc(metric)}
|
|
486
|
+
cpi_acc = {"curr": new_metric_acc("CPI"), "prev": new_metric_acc("CPI")}
|
|
234
487
|
for ch in ("fb", "gg", "tt"):
|
|
235
488
|
for per in ("curr", "prev"):
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
489
|
+
day_spend = day_imps = day_inst = 0.0
|
|
490
|
+
dates_all: set[str] = set()
|
|
491
|
+
dates_spend: set[str] = set()
|
|
492
|
+
for row in iter_stem_rows(bl, f"met_{ch}_{per}_calendar_date"):
|
|
493
|
+
day_spend += num(row, "spend_usd")
|
|
494
|
+
day_imps += num(row, "impressions")
|
|
495
|
+
day_inst += num(row, "installs")
|
|
496
|
+
raw = row.get("calendar_date")
|
|
497
|
+
if raw:
|
|
498
|
+
dates_all.add(str(raw)[:10])
|
|
499
|
+
if num(row, "spend_usd") > 0:
|
|
500
|
+
dates_spend.add(disp(row, "calendar_date") or str(raw or ""))
|
|
501
|
+
acc_spend = acc_imps = acc_inst = 0.0
|
|
502
|
+
has_acc = False
|
|
503
|
+
for row in iter_stem_rows(bl, f"met_{ch}_{per}_account_id"):
|
|
504
|
+
has_acc = True
|
|
505
|
+
acc_spend += num(row, "spend_usd")
|
|
506
|
+
acc_imps += num(row, "impressions")
|
|
507
|
+
acc_inst += num(row, "installs")
|
|
508
|
+
add_metric_acc(core_acc[per], row)
|
|
509
|
+
add_metric_acc(cpi_acc[per], row)
|
|
510
|
+
spend[per][ch] = acc_spend if has_acc else day_spend
|
|
511
|
+
imps[per][ch] = acc_imps if has_acc else day_imps
|
|
512
|
+
inst[per][ch] = acc_inst if has_acc else day_inst
|
|
513
|
+
days[per][ch] = len(dates_spend)
|
|
514
|
+
coverage_dates[per][ch] = dates_all
|
|
249
515
|
|
|
250
516
|
curr_total = sum(spend["curr"].values())
|
|
251
517
|
prev_total = sum(spend["prev"].values())
|
|
@@ -255,16 +521,10 @@ def summarize_request(req: Path):
|
|
|
255
521
|
prev_share = {ch: (spend["prev"][ch] / prev_total * 100 if prev_total else 0) for ch in spend["prev"]}
|
|
256
522
|
overall_c = cpm(curr_total, sum(imps["curr"].values()))
|
|
257
523
|
overall_p = cpm(prev_total, sum(imps["prev"].values()))
|
|
258
|
-
|
|
259
|
-
cpi_p = (
|
|
524
|
+
core_c, core_p = finish_metric_acc(core_acc["curr"]), finish_metric_acc(core_acc["prev"])
|
|
525
|
+
cpi_c, cpi_p = finish_metric_acc(cpi_acc["curr"]), finish_metric_acc(cpi_acc["prev"])
|
|
260
526
|
eff_days = max(days["curr"].values() or [0])
|
|
261
527
|
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
528
|
qoq_label, qoq_pct = qoq_axis(core_c, core_p, metric in ("ROAS", "CTR"))
|
|
269
529
|
kpi_usable = rule["usable"]
|
|
270
530
|
spend_verdict = period_verdict(
|
|
@@ -278,301 +538,296 @@ def summarize_request(req: Path):
|
|
|
278
538
|
spend_verdict = "待观察"
|
|
279
539
|
gate_note = f"本期有效投放 {eff_days} 天、消耗 ${curr_total:,.0f}、安装 {curr_inst:,.0f}"
|
|
280
540
|
qoq_pct_s = "" if qoq_pct is None else f"{qoq_pct * 100:+.1f}%"
|
|
281
|
-
details
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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:
|
|
541
|
+
details = DetailWriter(req / "details.ndjson")
|
|
542
|
+
try:
|
|
543
|
+
details.append({"bizLine": name, "kind": "口径", "action": "客户 KPI", "channel": "", "name": core_index or "未填写", "extra": rule["reason"]})
|
|
544
|
+
coverage_notes = []
|
|
545
|
+
from datetime import date, timedelta
|
|
546
|
+
for per, bounds in (("curr", periods.get("curr")), ("prev", periods.get("prev"))):
|
|
547
|
+
if not bounds or not all(bounds):
|
|
291
548
|
continue
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
"
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
549
|
+
start, end = (date.fromisoformat(d) for d in bounds)
|
|
550
|
+
expected = {(start + timedelta(days=i)).isoformat() for i in range((end - start).days + 1)}
|
|
551
|
+
for ch, accounts in projects.get("accounts", {}).items():
|
|
552
|
+
if not accounts:
|
|
553
|
+
continue
|
|
554
|
+
dates = sorted(coverage_dates[per].get(ch) or set())
|
|
555
|
+
missing = expected - set(dates)
|
|
556
|
+
label = "本期" if per == "curr" else "上期"
|
|
557
|
+
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 ""))
|
|
558
|
+
details.append({"bizLine": name, "kind": "周期覆盖", "channel": CH_LABEL[ch], "action": label, "name": f"请求 {bounds[0]}~{bounds[1]}", "extra": "实际日期:" + "、".join(dates)})
|
|
559
|
+
period += "\n" + "\n".join(coverage_notes)
|
|
560
|
+
if any("覆盖不完整" in n for n in coverage_notes):
|
|
561
|
+
period += "\n周期覆盖不完整,消耗减少不能直接解释为主动减预算"
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
bid_fields = {
|
|
565
|
+
"fb": ["bid_amount"],
|
|
566
|
+
"gg": ["cpc_bid_amount", "cpm_bid_amount", "cpv_bid_amount"],
|
|
567
|
+
"tt": ["bid_amount", "conversion_bid", "deep_cpabid"],
|
|
568
|
+
}
|
|
569
|
+
bid_stems = {
|
|
570
|
+
"fb": "dim_fb_curr_calendar_date+adgroup_id+adgroup_name+bid_amount",
|
|
571
|
+
"gg": "dim_gg_curr_calendar_date+adgroup_id+adgroup_name+cpc_bid_amount",
|
|
572
|
+
"tt": "dim_tt_curr_calendar_date+adgroup_id+adgroup_name+bid_amount",
|
|
573
|
+
}
|
|
574
|
+
bids = {ch: bid_changes(iter_stem_rows(bl, bid_stems[ch]), bid_fields[ch], work_dir=bl / f"_bid_{ch}") for ch in bid_fields}
|
|
575
|
+
bid_stats = {ch: bid_adjust_stats(bids[ch]) for ch in bids}
|
|
576
|
+
|
|
577
|
+
def bid_text(ch):
|
|
578
|
+
items = bids[ch]
|
|
579
|
+
stats = bid_stats[ch]
|
|
580
|
+
label = CH_LABEL[ch]
|
|
581
|
+
if not items:
|
|
582
|
+
return f"{label}:本期未发生明显出价调整"
|
|
583
|
+
lines = [
|
|
584
|
+
f"{label}:上调 {stats['raise_n']} 个广告组,下调 {stats['lower_n']} 个;"
|
|
585
|
+
f"期内变化但首末持平 {stats['flat_n']} 个;幅度中位数 {stats['median_abs']:.2f}"
|
|
586
|
+
]
|
|
587
|
+
if stats["list_all"]:
|
|
588
|
+
for b in stats["details"]:
|
|
589
|
+
lines.append(
|
|
590
|
+
f"· {short_label(b.get('name') or '')}:{b.get('action')} "
|
|
591
|
+
f"{b.get('from')} → {b.get('to')}({b.get('times')} 次)"
|
|
592
|
+
)
|
|
593
|
+
return "\n".join(lines)
|
|
594
|
+
|
|
595
|
+
for ch in ("fb", "gg", "tt"):
|
|
596
|
+
stats = bid_stats[ch]
|
|
597
|
+
src = stats["details"] if stats["list_all"] else bids[ch]
|
|
598
|
+
for b in src:
|
|
599
|
+
details.append(
|
|
600
|
+
{
|
|
601
|
+
"bizLine": name,
|
|
602
|
+
"kind": "出价",
|
|
603
|
+
"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 "期内变化但首末持平")),
|
|
604
|
+
"channel": CH_LABEL[ch],
|
|
605
|
+
"name": b.get("name") or "",
|
|
606
|
+
"extra": f"ID={b['id']};" + json.dumps(b.get("fields", []), ensure_ascii=False),
|
|
607
|
+
}
|
|
330
608
|
)
|
|
331
|
-
return "\n".join(lines)
|
|
332
|
-
|
|
333
|
-
for ch in ("fb", "gg", "tt"):
|
|
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
|
-
}
|
|
346
|
-
)
|
|
347
|
-
|
|
348
|
-
budget_strategy = "\n".join(
|
|
349
|
-
[
|
|
350
|
-
"【调整出价】",
|
|
351
|
-
bid_text("fb"),
|
|
352
|
-
bid_text("gg"),
|
|
353
|
-
bid_text("tt"),
|
|
354
|
-
"",
|
|
355
|
-
"【预算】",
|
|
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}",
|
|
378
|
-
]
|
|
379
|
-
)
|
|
380
|
-
cpi_s = f"全渠道 {metric}:本期 {fmt_cpm(core_c)} / 上期 {fmt_cpm(core_p)}"
|
|
381
|
-
cpi_s += "(美元消耗 / 平台安装量,非客户激活或留存成本)" if metric == "CPI" else "(同粒度基础指标合计后计算)"
|
|
382
|
-
budget_learning = "\n".join(
|
|
383
|
-
[
|
|
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 []),
|
|
388
|
-
"",
|
|
389
|
-
"Spend",
|
|
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'])}",
|
|
395
|
-
"",
|
|
396
|
-
"调整出价",
|
|
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
|
-
],
|
|
402
|
-
"",
|
|
403
|
-
advice_of(spend_verdict),
|
|
404
|
-
]
|
|
405
|
-
)
|
|
406
|
-
|
|
407
|
-
def share_line(title, dim_key, stem, channels):
|
|
408
|
-
segs = []
|
|
409
|
-
moved = False
|
|
410
|
-
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)
|
|
413
|
-
if share_moved(curr, prev):
|
|
414
|
-
moved = True
|
|
415
|
-
if curr or prev:
|
|
416
|
-
segs.append(f"{CH_LABEL[ch]}:{fmt_share(curr, prev)}")
|
|
417
|
-
return ("\n".join([title] + segs) if segs else ""), moved
|
|
418
|
-
|
|
419
|
-
p_obj, m1 = share_line("【广告目标】", "objective", "objective", ["fb", "tt"])
|
|
420
|
-
p_ch, m2 = share_line("【广告系列类型】", "advertising_channel_type", "advertising_channel_type", ["gg"])
|
|
421
|
-
p_opt, m3 = share_line("【成效目标】", "optimization_goal", "optimization_goal", ["fb", "gg", "tt"])
|
|
422
|
-
p_bid, m4 = share_line("【出价方式】", "bid_strategy", "bid_strategy", ["fb", "gg", "tt"])
|
|
423
|
-
product_strategy = "\n\n".join(x for x in [p_obj, p_ch, p_opt, p_bid] if x)
|
|
424
|
-
product_share_moved = any([m1, m2, m3, m4])
|
|
425
|
-
|
|
426
|
-
def dim_sets(stem, key, channels):
|
|
427
|
-
curr = set()
|
|
428
|
-
prev = set()
|
|
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
|
|
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)
|
|
439
|
-
return curr, prev
|
|
440
|
-
|
|
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")
|
|
492
|
-
audience_strategy = "\n\n".join(
|
|
493
|
-
x
|
|
494
|
-
for x in [
|
|
495
|
-
setting_line("【国家】", country_curr, country_prev, first_period=first_period),
|
|
496
|
-
setting_line("【兴趣词包】", interest_curr, interest_prev, first_period=first_period),
|
|
497
|
-
share_line("【受众类型】", "audience_type", "audience_type", ["fb", "gg", "tt"])[0],
|
|
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),
|
|
501
|
-
"【广告组】\n" + "\n".join(ag_bits),
|
|
502
|
-
]
|
|
503
|
-
if x
|
|
504
|
-
)
|
|
505
609
|
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
610
|
+
budget_strategy = "\n".join(
|
|
611
|
+
[
|
|
612
|
+
"【调整出价】",
|
|
613
|
+
bid_text("fb"),
|
|
614
|
+
bid_text("gg"),
|
|
615
|
+
bid_text("tt"),
|
|
616
|
+
"",
|
|
617
|
+
"【预算】",
|
|
618
|
+
budget_abs_share(
|
|
619
|
+
"FB",
|
|
620
|
+
prev_abs=spend["prev"]["fb"],
|
|
621
|
+
curr_abs=spend["curr"]["fb"],
|
|
622
|
+
prev_share=prev_share["fb"],
|
|
623
|
+
curr_share=curr_share["fb"],
|
|
624
|
+
),
|
|
625
|
+
budget_abs_share(
|
|
626
|
+
"GG",
|
|
627
|
+
prev_abs=spend["prev"]["gg"],
|
|
628
|
+
curr_abs=spend["curr"]["gg"],
|
|
629
|
+
prev_share=prev_share["gg"],
|
|
630
|
+
curr_share=curr_share["gg"],
|
|
631
|
+
),
|
|
632
|
+
budget_abs_share(
|
|
633
|
+
"TT",
|
|
634
|
+
prev_abs=spend["prev"]["tt"],
|
|
635
|
+
curr_abs=spend["curr"]["tt"],
|
|
636
|
+
prev_share=prev_share["tt"],
|
|
637
|
+
curr_share=curr_share["tt"],
|
|
638
|
+
),
|
|
639
|
+
f"消耗合计:本期 ${curr_total:,.0f} / 上期 ${prev_total:,.0f}",
|
|
640
|
+
]
|
|
641
|
+
)
|
|
642
|
+
cpi_s = f"全渠道 {metric}:本期 {fmt_cpm(core_c)} / 上期 {fmt_cpm(core_p)}"
|
|
643
|
+
cpi_s += "(美元消耗 / 平台安装量,非客户激活或留存成本)" if metric == "CPI" else "(同粒度基础指标合计后计算)"
|
|
644
|
+
budget_learning = "\n".join(
|
|
645
|
+
[
|
|
646
|
+
f"结论:{spend_verdict}(第二期起跟环比,KPI 只作依据)" if not first_period else f"结论:{spend_verdict}(首期)",
|
|
647
|
+
f"依据:{cpi_s},环比{qoq_label}" + (f" {qoq_pct_s}" if qoq_pct_s else ""),
|
|
648
|
+
f"客户 KPI:{core_index or '未填写'};{rule['reason']}",
|
|
649
|
+
*([f"门槛:{gate_note} → 待观察"] if gate_note else []),
|
|
650
|
+
"",
|
|
651
|
+
"Spend",
|
|
652
|
+
f"· {cpi_s}",
|
|
653
|
+
f"· 参考 CPM:本期 {fmt_cpm(overall_c)} / 上期 {fmt_cpm(overall_p)}(不作结论轴)",
|
|
654
|
+
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'])}",
|
|
655
|
+
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'])}",
|
|
656
|
+
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'])}",
|
|
657
|
+
"",
|
|
658
|
+
"调整出价",
|
|
659
|
+
*[
|
|
660
|
+
f"· {CH_LABEL[ch]}:上调 {bid_stats[ch]['raise_n']} / 下调 {bid_stats[ch]['lower_n']},"
|
|
661
|
+
f"幅度中位数 {bid_stats[ch]['median_abs']:.2f}"
|
|
662
|
+
for ch in ("fb", "gg", "tt")
|
|
663
|
+
],
|
|
664
|
+
"",
|
|
665
|
+
advice_of(spend_verdict),
|
|
666
|
+
]
|
|
667
|
+
)
|
|
509
668
|
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
any_new = only_stop = False
|
|
514
|
-
for title, stem, key, channels, id_key in dim_specs:
|
|
515
|
-
items_all, stops_all = [], []
|
|
669
|
+
def share_line(title, dim_key, stem, channels):
|
|
670
|
+
segs = []
|
|
671
|
+
moved = False
|
|
516
672
|
for ch in channels:
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
)
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
673
|
+
curr, _ = share_map(iter_stem_rows(bl, f"met_{ch}_curr_{stem}"), dim_key)
|
|
674
|
+
prev, _ = share_map(iter_stem_rows(bl, f"met_{ch}_prev_{stem}"), dim_key)
|
|
675
|
+
if share_moved(curr, prev):
|
|
676
|
+
moved = True
|
|
677
|
+
if curr or prev:
|
|
678
|
+
segs.append(f"{CH_LABEL[ch]}:{fmt_share(curr, prev)}")
|
|
679
|
+
return ("\n".join([title] + segs) if segs else ""), moved
|
|
680
|
+
|
|
681
|
+
p_obj, m1 = share_line("【广告目标】", "objective", "objective", ["fb", "tt"])
|
|
682
|
+
p_ch, m2 = share_line("【广告系列类型】", "advertising_channel_type", "advertising_channel_type", ["gg"])
|
|
683
|
+
p_opt, m3 = share_line("【成效目标】", "optimization_goal", "optimization_goal", ["fb", "gg", "tt"])
|
|
684
|
+
p_bid, m4 = share_line("【出价方式】", "bid_strategy", "bid_strategy", ["fb", "gg", "tt"])
|
|
685
|
+
product_strategy = "\n\n".join(x for x in [p_obj, p_ch, p_opt, p_bid] if x)
|
|
686
|
+
product_share_moved = any([m1, m2, m3, m4])
|
|
687
|
+
|
|
688
|
+
def dim_sets(stem, key, channels):
|
|
689
|
+
curr = set()
|
|
690
|
+
prev = set()
|
|
691
|
+
for ch in channels:
|
|
692
|
+
# Meta 兴趣的三个配置字段来自同一组合请求,不能找不存在的单字段文件。
|
|
693
|
+
if ch == 'fb' and stem == 'targeting_interests':
|
|
694
|
+
combined = 'targeting_interests+targeting_flexible_interests+targeting_exclusion_interests'
|
|
695
|
+
fields = combined.split('+')
|
|
696
|
+
for target, per in ((curr, 'curr'), (prev, 'prev')):
|
|
697
|
+
for row in iter_stem_rows(bl, f'dim_{ch}_{per}_{combined}'):
|
|
698
|
+
for field in fields:
|
|
699
|
+
lab = disp(row, field)
|
|
700
|
+
if lab:
|
|
701
|
+
target.add(lab)
|
|
702
|
+
continue
|
|
703
|
+
curr |= labels_of(iter_stem_rows(bl, f"dim_{ch}_curr_{stem}"), key)
|
|
704
|
+
prev |= labels_of(iter_stem_rows(bl, f"dim_{ch}_prev_{stem}"), key)
|
|
705
|
+
return curr, prev
|
|
706
|
+
|
|
707
|
+
def add_setting_details(kind, curr_set, prev_set, channel=""):
|
|
708
|
+
action_curr = "当期" if first_period else "新增"
|
|
709
|
+
news = sorted(curr_set if first_period or kind == "国家" else (curr_set - prev_set))
|
|
710
|
+
stops = [] if first_period else sorted(prev_set - curr_set)
|
|
711
|
+
for lab in news:
|
|
712
|
+
details.append(
|
|
713
|
+
{
|
|
714
|
+
"bizLine": name,
|
|
715
|
+
"kind": kind,
|
|
716
|
+
"action": "持续" if not first_period and lab in prev_set else action_curr,
|
|
717
|
+
"channel": channel,
|
|
718
|
+
"name": lab,
|
|
719
|
+
"extra": "",
|
|
720
|
+
}
|
|
721
|
+
)
|
|
722
|
+
for lab in stops:
|
|
723
|
+
details.append(
|
|
724
|
+
{
|
|
725
|
+
"bizLine": name,
|
|
726
|
+
"kind": kind,
|
|
727
|
+
"action": "本期未出现",
|
|
728
|
+
"channel": channel,
|
|
729
|
+
"name": lab,
|
|
730
|
+
"extra": "",
|
|
731
|
+
}
|
|
732
|
+
)
|
|
733
|
+
|
|
734
|
+
country_curr, country_prev = dim_sets("country_code", "country_code", ["fb", "gg", "tt"])
|
|
735
|
+
interest_curr, interest_prev = dim_sets("targeting_interests", "targeting_interests", ["fb", "tt"])
|
|
736
|
+
kw_curr, kw_prev = dim_sets("keywords", "keywords", ["gg"])
|
|
737
|
+
add_setting_details("国家", country_curr, country_prev)
|
|
738
|
+
add_setting_details("兴趣词", interest_curr, interest_prev)
|
|
739
|
+
add_setting_details("关键词", kw_curr, kw_prev)
|
|
740
|
+
|
|
741
|
+
def object_strategy(kind, id_key, name_key):
|
|
742
|
+
bits = []
|
|
743
|
+
for ch in CH_LABEL:
|
|
744
|
+
stem = f"{id_key}+{name_key}"
|
|
745
|
+
|
|
746
|
+
def index_objects(period: str) -> tuple[dict[str, str], dict[str, str]]:
|
|
747
|
+
return object_name_maps(iter_stem_rows(bl, f"dim_{ch}_{period}_{stem}"), id_key, name_key)
|
|
748
|
+
|
|
749
|
+
c, c_raw = index_objects("curr")
|
|
750
|
+
p, _p_raw = index_objects("prev")
|
|
751
|
+
new, absent = sorted(c.keys() - p.keys()), sorted(p.keys() - c.keys())
|
|
752
|
+
bits.append(f"{CH_LABEL[ch]}:本期新出现 {len(new)} 个,本期未出现 {len(absent)} 个;命名规律:{naming_patterns([c_raw[i] for i in new])}")
|
|
753
|
+
for ident in sorted(c.keys() | p.keys()):
|
|
754
|
+
record_name = c.get(ident) or p[ident]
|
|
755
|
+
action = "当期" if first_period else ("新增" if ident in new else ("本期未出现" if ident in absent else "持续"))
|
|
756
|
+
details.append({"bizLine": name, "kind": kind, "action": action, "channel": CH_LABEL[ch], "name": record_name, "extra": f"ID={ident};集合差异不等同创建/暂停操作"})
|
|
757
|
+
return bits
|
|
758
|
+
|
|
759
|
+
ag_bits = object_strategy("广告组", "adgroup_id", "adgroup_name")
|
|
760
|
+
audience_strategy = "\n\n".join(
|
|
761
|
+
x
|
|
762
|
+
for x in [
|
|
763
|
+
setting_line("【国家】", country_curr, country_prev, first_period=first_period),
|
|
764
|
+
setting_line("【兴趣词包】", interest_curr, interest_prev, first_period=first_period),
|
|
765
|
+
share_line("【受众类型】", "audience_type", "audience_type", ["fb", "gg", "tt"])[0],
|
|
766
|
+
setting_line("【关键词】", kw_curr, kw_prev, first_period=first_period),
|
|
767
|
+
setting_line("【年龄】", *dim_sets("targeting_age", "targeting_age", ["fb", "gg", "tt"]), first_period=first_period),
|
|
768
|
+
setting_line("【性别】", *dim_sets("targeting_genders", "targeting_genders", ["fb", "gg", "tt"]), first_period=first_period),
|
|
769
|
+
"【广告组】\n" + "\n".join(ag_bits),
|
|
770
|
+
]
|
|
771
|
+
if x
|
|
557
772
|
)
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
]
|
|
773
|
+
|
|
774
|
+
ad_bits = object_strategy("广告", "ad_id", "ad_name")
|
|
775
|
+
creative_fmt = share_line("【创意类型】", "ad_format", "ad_format", ["fb", "gg", "tt"])[0] or ""
|
|
776
|
+
creative_strategy = "【广告】\n" + "\n".join(ad_bits) + (("\n\n" + creative_fmt) if creative_fmt else "")
|
|
777
|
+
|
|
778
|
+
def strategy_learning(dim_specs, allow_share_fallback=False, share_did_move=False):
|
|
779
|
+
texts = []
|
|
780
|
+
hit_spend = all_spend = 0.0
|
|
781
|
+
any_new = only_stop = False
|
|
782
|
+
for title, stem, key, channels, id_key in dim_specs:
|
|
783
|
+
items_all, stops_all = [], []
|
|
784
|
+
for ch in channels:
|
|
785
|
+
curr_stem = f"met_{ch}_curr_{stem}"
|
|
786
|
+
prev_stem = f"met_{ch}_prev_{stem}"
|
|
787
|
+
curr_share_map, prev_share_map, items, stops = share_and_new_items(
|
|
788
|
+
iter_stem_rows(bl, curr_stem), iter_stem_rows(bl, prev_stem), key, id_key
|
|
789
|
+
)
|
|
790
|
+
stops = [
|
|
791
|
+
s
|
|
792
|
+
for s in stops
|
|
793
|
+
if not is_ghost_enum(curr_share_map.get(s, 0), prev_share_map.get(s, 0))
|
|
794
|
+
]
|
|
795
|
+
for item in items:
|
|
796
|
+
item["channel"] = CH_LABEL[ch]
|
|
797
|
+
if item["spend"] >= 50:
|
|
798
|
+
value = metric_value(item["rows"], metric)
|
|
799
|
+
hit = rule_hit(value, rule)
|
|
800
|
+
verdict_text = '不可判定' if hit is None else ('达标' if hit else '未达标')
|
|
801
|
+
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}"})
|
|
802
|
+
items_all.extend(items)
|
|
803
|
+
stops_all.extend(stops)
|
|
804
|
+
if items_all:
|
|
805
|
+
any_new = True
|
|
806
|
+
if stops_all and not items_all:
|
|
807
|
+
only_stop = True
|
|
808
|
+
text, _hit, hs, ts = dim_layer_text(title, items_all, stops_all, rule)
|
|
809
|
+
texts.append(text)
|
|
810
|
+
if ts > 0:
|
|
811
|
+
hit_spend += hs
|
|
812
|
+
all_spend += ts
|
|
813
|
+
detail = "\n\n".join(texts)
|
|
814
|
+
verdict = period_verdict(
|
|
815
|
+
first_period=first_period,
|
|
816
|
+
qoq_label=qoq_label,
|
|
817
|
+
kpi_usable=kpi_usable,
|
|
818
|
+
kpi_hit=rule_hit(core_c, rule),
|
|
569
819
|
)
|
|
570
|
-
|
|
571
|
-
|
|
820
|
+
if gate_note:
|
|
821
|
+
verdict = "待观察"
|
|
822
|
+
evidence = (
|
|
823
|
+
f"{cpi_s},环比{qoq_label}" + (f" {qoq_pct_s}" if qoq_pct_s else "")
|
|
824
|
+
+ "。此为整体环比观察,不代表本次单一动作的因果效果;" + rule["reason"]
|
|
825
|
+
)
|
|
826
|
+
if not any_new and only_stop:
|
|
572
827
|
return "\n".join(
|
|
573
828
|
[
|
|
574
829
|
f"结论:{verdict}",
|
|
575
|
-
"
|
|
830
|
+
"口径:本期无新增,走本期未出现",
|
|
576
831
|
f"依据:{evidence}",
|
|
577
832
|
"",
|
|
578
833
|
detail,
|
|
@@ -580,86 +835,101 @@ def summarize_request(req: Path):
|
|
|
580
835
|
advice_of(verdict),
|
|
581
836
|
]
|
|
582
837
|
)
|
|
838
|
+
if not any_new:
|
|
839
|
+
if allow_share_fallback and share_did_move:
|
|
840
|
+
return "\n".join(
|
|
841
|
+
[
|
|
842
|
+
f"结论:{verdict}",
|
|
843
|
+
"口径:无新增枚举,占比变化超过 ±2%,按可环比",
|
|
844
|
+
f"依据:{evidence}",
|
|
845
|
+
"",
|
|
846
|
+
detail,
|
|
847
|
+
"",
|
|
848
|
+
advice_of(verdict),
|
|
849
|
+
]
|
|
850
|
+
)
|
|
851
|
+
return "\n".join(
|
|
852
|
+
[
|
|
853
|
+
f"结论:{verdict}",
|
|
854
|
+
"口径:本期无实质调整动作",
|
|
855
|
+
f"依据:{evidence}",
|
|
856
|
+
"",
|
|
857
|
+
detail,
|
|
858
|
+
]
|
|
859
|
+
)
|
|
860
|
+
ratio = (hit_spend / all_spend) if all_spend else 0.0
|
|
583
861
|
return "\n".join(
|
|
584
862
|
[
|
|
585
863
|
f"结论:{verdict}",
|
|
586
|
-
"口径:本期无实质调整动作",
|
|
587
864
|
f"依据:{evidence}",
|
|
588
865
|
"",
|
|
589
866
|
detail,
|
|
867
|
+
"",
|
|
868
|
+
advice_of(verdict),
|
|
590
869
|
]
|
|
591
870
|
)
|
|
592
|
-
|
|
593
|
-
|
|
871
|
+
|
|
872
|
+
product_learning = strategy_learning(
|
|
594
873
|
[
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
"",
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
874
|
+
("广告目标", "objective", "objective", ["fb", "tt"], None),
|
|
875
|
+
("广告系列类型", "advertising_channel_type", "advertising_channel_type", ["gg"], None),
|
|
876
|
+
("成效目标", "optimization_goal", "optimization_goal", ["fb", "gg", "tt"], None),
|
|
877
|
+
("出价方式", "bid_strategy", "bid_strategy", ["fb", "gg", "tt"], None),
|
|
878
|
+
],
|
|
879
|
+
allow_share_fallback=True,
|
|
880
|
+
share_did_move=product_share_moved,
|
|
881
|
+
)
|
|
882
|
+
audience_learning = strategy_learning(
|
|
883
|
+
[
|
|
884
|
+
("国家", "country_code", "country_code", ["fb", "gg", "tt"], None),
|
|
885
|
+
("兴趣词包", "targeting_interests", "targeting_interests", ["fb", "tt"], None),
|
|
886
|
+
("受众类型", "audience_type", "audience_type", ["fb", "gg", "tt"], None),
|
|
887
|
+
("关键词", "keywords", "keywords", ["gg"], None),
|
|
888
|
+
("年龄", "targeting_age", "targeting_age", ["fb", "gg", "tt"], None),
|
|
889
|
+
("性别", "targeting_genders", "targeting_genders", ["fb", "gg", "tt"], None),
|
|
890
|
+
("广告组", "adgroup_id+adgroup_name", "adgroup_name", ["fb", "gg", "tt"], "adgroup_id"),
|
|
891
|
+
]
|
|
892
|
+
)
|
|
893
|
+
creative_learning = strategy_learning(
|
|
894
|
+
[
|
|
895
|
+
("广告", "ad_id+ad_name", "ad_name", ["fb", "gg", "tt"], "ad_id"),
|
|
896
|
+
("创意类型", "ad_format", "ad_format", ["fb", "gg", "tt"], None),
|
|
601
897
|
]
|
|
602
898
|
)
|
|
603
899
|
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
"
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
"budgetStrategy": budget_strategy,
|
|
638
|
-
"budgetLearning": budget_learning,
|
|
639
|
-
"productStrategy": product_strategy,
|
|
640
|
-
"productLearning": product_learning,
|
|
641
|
-
"audienceStrategy": audience_strategy,
|
|
642
|
-
"audienceLearning": audience_learning,
|
|
643
|
-
"creativeStrategy": creative_strategy,
|
|
644
|
-
"creativeLearning": creative_learning,
|
|
645
|
-
}
|
|
646
|
-
payload = {"rows": [row], "details": details}
|
|
647
|
-
(req / "payload.json").write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
648
|
-
summary = {
|
|
649
|
-
"bizLineId": biz_id,
|
|
650
|
-
"curr_total": curr_total,
|
|
651
|
-
"prev_total": prev_total,
|
|
652
|
-
"overall_cpi": [cpi_c, cpi_p],
|
|
653
|
-
"overall_cpm": [overall_c, overall_p],
|
|
654
|
-
"eff_days": eff_days,
|
|
655
|
-
"first_period": first_period,
|
|
656
|
-
"spend_verdict": spend_verdict,
|
|
657
|
-
"bid_counts": {ch: len(bids[ch]) for ch in bids},
|
|
658
|
-
"detail_count": len(details),
|
|
659
|
-
"request_dir": str(req),
|
|
660
|
-
}
|
|
661
|
-
(req / "summary.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
662
|
-
return payload, summary
|
|
900
|
+
row = {
|
|
901
|
+
"group": group,
|
|
902
|
+
"bizLine": name,
|
|
903
|
+
"period": period,
|
|
904
|
+
"project": project,
|
|
905
|
+
"budgetStrategy": budget_strategy,
|
|
906
|
+
"budgetLearning": budget_learning,
|
|
907
|
+
"productStrategy": product_strategy,
|
|
908
|
+
"productLearning": product_learning,
|
|
909
|
+
"audienceStrategy": audience_strategy,
|
|
910
|
+
"audienceLearning": audience_learning,
|
|
911
|
+
"creativeStrategy": creative_strategy,
|
|
912
|
+
"creativeLearning": creative_learning,
|
|
913
|
+
}
|
|
914
|
+
payload = {"rows": [row], "details": str(details.path), "detail_count": details.count}
|
|
915
|
+
(req / "payload.json").write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
916
|
+
summary = {
|
|
917
|
+
"bizLineId": biz_id,
|
|
918
|
+
"curr_total": curr_total,
|
|
919
|
+
"prev_total": prev_total,
|
|
920
|
+
"overall_cpi": [cpi_c, cpi_p],
|
|
921
|
+
"overall_cpm": [overall_c, overall_p],
|
|
922
|
+
"eff_days": eff_days,
|
|
923
|
+
"first_period": first_period,
|
|
924
|
+
"spend_verdict": spend_verdict,
|
|
925
|
+
"bid_counts": {ch: len(bids[ch]) for ch in bids},
|
|
926
|
+
"detail_count": details.count,
|
|
927
|
+
"request_dir": str(req),
|
|
928
|
+
}
|
|
929
|
+
(req / "summary.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
930
|
+
return payload, summary
|
|
931
|
+
finally:
|
|
932
|
+
details.close()
|
|
663
933
|
|
|
664
934
|
|
|
665
935
|
def main() -> int:
|