sino-bc-cli 1.8.20 → 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 +20 -23
- package/dist/skills/custom/biz-line-strategy-generate/references/api-flow.md +5 -5
- package/dist/skills/custom/biz-line-strategy-generate/references/four-strategies.md +16 -14
- package/dist/skills/custom/biz-line-strategy-generate/references/metrics.md +29 -278
- package/dist/skills/custom/biz-line-strategy-generate/references/output-excel.md +8 -57
- 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 +98 -9
- package/dist/skills/custom/biz-line-strategy-generate/scripts/cleanup.py +71 -82
- package/dist/skills/custom/biz-line-strategy-generate/scripts/cli_json.py +125 -19
- package/dist/skills/custom/biz-line-strategy-generate/scripts/fetch_data.py +61 -32
- 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 +158 -148
- package/dist/skills/custom/biz-line-strategy-generate/scripts/test_strategy_rules.py +16 -2
- package/dist/skills/custom/biz-line-strategy-generate/scripts/test_workflow.py +345 -0
- package/package.json +1 -1
- package/dist/skills/custom/biz-line-strategy-generate/scripts/__pycache__/build_report.cpython-314.pyc +0 -0
- package/dist/skills/custom/biz-line-strategy-generate/scripts/__pycache__/cli_json.cpython-314.pyc +0 -0
- package/dist/skills/custom/biz-line-strategy-generate/scripts/__pycache__/fetch_data.cpython-314.pyc +0 -0
- package/dist/skills/custom/biz-line-strategy-generate/scripts/__pycache__/summarize.cpython-314.pyc +0 -0
|
@@ -1,98 +1,87 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
# -*- coding: utf-8 -*-
|
|
3
|
-
"""
|
|
4
|
-
|
|
5
|
-
默认生成流程:Excel 落盘后不要删 request 目录(现拉回包要留着备查)。
|
|
6
|
-
只有用户明确要求清缓存时才传 --request-dir。
|
|
7
|
-
不会按桌面文件名 glob,也不会删除未出现在 --written-excel 里的文件。
|
|
8
|
-
"""
|
|
3
|
+
"""仅清理本次进程创建并登记的目录,所有类型临时文件统一处理。"""
|
|
9
4
|
from __future__ import annotations
|
|
10
5
|
|
|
11
|
-
import argparse
|
|
12
6
|
import json
|
|
13
7
|
import os
|
|
14
|
-
import
|
|
15
|
-
import
|
|
16
|
-
|
|
8
|
+
import stat
|
|
9
|
+
import tempfile
|
|
10
|
+
import uuid
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
17
13
|
|
|
18
|
-
|
|
19
|
-
if not path:
|
|
20
|
-
return {"ok": False, "error": "empty --request-dir"}
|
|
21
|
-
abs_path = os.path.abspath(path)
|
|
22
|
-
if not os.path.isdir(abs_path):
|
|
23
|
-
return {"ok": True, "removed_dir": abs_path, "existed": False}
|
|
24
|
-
# 只允许删 sino-bc-strategy 下的某一层 requestId 目录,防止误传桌面/主目录
|
|
25
|
-
parent = os.path.basename(os.path.dirname(abs_path)).lower()
|
|
26
|
-
if parent != "sino-bc-strategy":
|
|
27
|
-
return {
|
|
28
|
-
"ok": False,
|
|
29
|
-
"error": "refuse: --request-dir 必须是 .../sino-bc-strategy/<requestId>",
|
|
30
|
-
}
|
|
31
|
-
shutil.rmtree(abs_path, ignore_errors=False)
|
|
32
|
-
return {"ok": True, "removed_dir": abs_path, "existed": True}
|
|
14
|
+
MARKER = '.strategy-owner.json'
|
|
33
15
|
|
|
34
16
|
|
|
35
|
-
def
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
for raw in written:
|
|
40
|
-
if not raw:
|
|
41
|
-
continue
|
|
42
|
-
target = os.path.abspath(raw)
|
|
43
|
-
if keep_abs and os.path.normcase(target) == os.path.normcase(keep_abs):
|
|
44
|
-
skipped.append({"path": target, "reason": "keep"})
|
|
45
|
-
continue
|
|
46
|
-
if not os.path.isfile(target):
|
|
47
|
-
skipped.append({"path": target, "reason": "missing"})
|
|
48
|
-
continue
|
|
49
|
-
os.remove(target)
|
|
50
|
-
deleted.append(target)
|
|
51
|
-
return {"ok": True, "deleted": deleted, "skipped": skipped, "kept": keep_abs}
|
|
17
|
+
def is_link(path: Path) -> bool:
|
|
18
|
+
"""Windows junction 等重解析点与符号链接都不允许递归跟随。"""
|
|
19
|
+
info = path.lstat()
|
|
20
|
+
return stat.S_ISLNK(info.st_mode) or bool(getattr(info, 'st_file_attributes', 0) & 0x400)
|
|
52
21
|
|
|
53
22
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
)
|
|
61
|
-
parser.add_argument(
|
|
62
|
-
"--keep-excel",
|
|
63
|
-
default=None,
|
|
64
|
-
help="最终保留的 xlsx 绝对路径",
|
|
65
|
-
)
|
|
66
|
-
parser.add_argument(
|
|
67
|
-
"--written-excel",
|
|
68
|
-
action="append",
|
|
69
|
-
default=[],
|
|
70
|
-
help="本次请求成功写出过的 xlsx,可重复;只从这份名单里删",
|
|
71
|
-
)
|
|
72
|
-
args = parser.parse_args()
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class OwnedDirectory:
|
|
25
|
+
"""创建时保存真实父路径与随机令牌,清理时不能只相信目录名。"""
|
|
26
|
+
path: Path
|
|
27
|
+
parent: Path
|
|
28
|
+
token: str
|
|
73
29
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
)
|
|
81
|
-
return
|
|
30
|
+
@classmethod
|
|
31
|
+
def create(cls, parent: Path, prefix: str):
|
|
32
|
+
parent = parent.resolve()
|
|
33
|
+
parent.mkdir(parents=True, exist_ok=True)
|
|
34
|
+
path = Path(tempfile.mkdtemp(prefix=prefix, dir=parent))
|
|
35
|
+
token = uuid.uuid4().hex
|
|
36
|
+
(path / MARKER).write_text(json.dumps({'token': token, 'path': str(path)}), encoding='utf-8')
|
|
37
|
+
return cls(path, parent, token)
|
|
82
38
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
39
|
+
def validate(self):
|
|
40
|
+
"""根目录或父路径被替换后拒绝删除,防止越界。"""
|
|
41
|
+
if is_link(self.path) or self.path.resolve() != self.path or self.path.parent.resolve() != self.parent:
|
|
42
|
+
raise ValueError(f'清理范围已变化:{self.path}')
|
|
43
|
+
marker = self.path / MARKER
|
|
44
|
+
if is_link(marker):
|
|
45
|
+
raise ValueError('所有权文件不能是链接')
|
|
46
|
+
data = json.loads(marker.read_text(encoding='utf-8'))
|
|
47
|
+
if data != {'token': self.token, 'path': str(self.path)}:
|
|
48
|
+
raise ValueError(f'所有权不匹配:{self.path}')
|
|
92
49
|
|
|
93
|
-
|
|
94
|
-
|
|
50
|
+
def cleanup(self, final: Path | None = None) -> dict:
|
|
51
|
+
"""不按后缀筛选;保留可返回的失败清单,绝不静默忽略残留。"""
|
|
52
|
+
errors = []
|
|
53
|
+
try:
|
|
54
|
+
self.validate()
|
|
55
|
+
if final is not None and final.resolve().is_relative_to(self.path):
|
|
56
|
+
raise ValueError('最终 Excel 位于临时目录内,拒绝清理')
|
|
57
|
+
except Exception as exc:
|
|
58
|
+
return {'ok': False, 'residual': [{'path': str(self.path), 'error': str(exc)}]}
|
|
95
59
|
|
|
60
|
+
def remove(path):
|
|
61
|
+
try:
|
|
62
|
+
# 先 lstat 再决定删除方式;对 junction 仅解除链接自身。
|
|
63
|
+
if is_link(path):
|
|
64
|
+
if path.is_symlink():
|
|
65
|
+
path.unlink()
|
|
66
|
+
else:
|
|
67
|
+
os.rmdir(path)
|
|
68
|
+
elif path.is_dir():
|
|
69
|
+
for child in path.iterdir():
|
|
70
|
+
remove(child)
|
|
71
|
+
path.rmdir()
|
|
72
|
+
else:
|
|
73
|
+
path.unlink()
|
|
74
|
+
except Exception as exc:
|
|
75
|
+
errors.append({'path': str(path), 'error': str(exc)})
|
|
96
76
|
|
|
97
|
-
|
|
98
|
-
|
|
77
|
+
# 若某子项未能删除,留下所有权标记供诊断,不伪报清理成功。
|
|
78
|
+
for child in self.path.iterdir():
|
|
79
|
+
if child.name != MARKER:
|
|
80
|
+
remove(child)
|
|
81
|
+
if not errors:
|
|
82
|
+
remove(self.path / MARKER)
|
|
83
|
+
try:
|
|
84
|
+
self.path.rmdir()
|
|
85
|
+
except Exception as exc:
|
|
86
|
+
errors.append({'path': str(self.path), 'error': str(exc)})
|
|
87
|
+
return {'ok': not errors, 'residual': errors}
|
|
@@ -7,8 +7,33 @@ import json
|
|
|
7
7
|
import os
|
|
8
8
|
import shutil
|
|
9
9
|
import subprocess
|
|
10
|
+
import threading
|
|
11
|
+
import math
|
|
12
|
+
import re
|
|
13
|
+
from contextlib import contextmanager
|
|
10
14
|
from pathlib import Path
|
|
11
15
|
|
|
16
|
+
_local = threading.local()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@contextmanager
|
|
20
|
+
def cli_context(cancel, progress):
|
|
21
|
+
"""每个业务线独立传递取消信号与进度回调,不共享可变回包。"""
|
|
22
|
+
_local.cancel, _local.progress = cancel, progress
|
|
23
|
+
try:
|
|
24
|
+
yield
|
|
25
|
+
finally:
|
|
26
|
+
_local.cancel = _local.progress = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def report_progress(message):
|
|
30
|
+
"""统一入口由协调回调串行输出;诊断脚本沿用控制台输出。"""
|
|
31
|
+
callback = getattr(_local, "progress", None)
|
|
32
|
+
if callback:
|
|
33
|
+
callback(message)
|
|
34
|
+
else:
|
|
35
|
+
print(message, flush=True)
|
|
36
|
+
|
|
12
37
|
ENUM_ZH = {
|
|
13
38
|
"OUTCOME_APP_PROMOTION": "应用推广",
|
|
14
39
|
"OUTCOME_SALES": "销量",
|
|
@@ -73,6 +98,9 @@ def resolve_cli() -> list[str]:
|
|
|
73
98
|
"""优先 PATH 上的 sino-bc-cli;Windows 找不到时再退到 npm 全局包。"""
|
|
74
99
|
found = shutil.which("sino-bc-cli")
|
|
75
100
|
if found:
|
|
101
|
+
# Windows 的 cmd shim 会重新解释 JSON 中的特殊字符,直接调用同包 Node 入口。
|
|
102
|
+
if os.name == "nt" and Path(found).suffix.lower() in (".cmd", ".bat"):
|
|
103
|
+
return [shutil.which("node") or "node", str(shim_entry(Path(found)))]
|
|
76
104
|
return [found]
|
|
77
105
|
npm_js = (
|
|
78
106
|
Path(os.environ.get("APPDATA", ""))
|
|
@@ -87,6 +115,21 @@ def resolve_cli() -> list[str]:
|
|
|
87
115
|
raise FileNotFoundError("找不到 sino-bc-cli,请先全局安装或执行 /init")
|
|
88
116
|
|
|
89
117
|
|
|
118
|
+
def shim_entry(shim: Path) -> Path:
|
|
119
|
+
"""只读解析 npm/pnpm 的 Node 入口,不执行 cmd 文本或解释用户 JSON。"""
|
|
120
|
+
candidates = [shim.parent / 'node_modules/sino-bc-cli/dist/index.js']
|
|
121
|
+
content = shim.read_text(encoding='utf-8')
|
|
122
|
+
for value in re.findall(r'"([^"\r\n]*sino-bc-cli[\\/]dist[\\/]index\.js)"', content, re.I):
|
|
123
|
+
value = re.sub(r'%(?:~dp0%?|dp0%)', lambda _: str(shim.parent) + os.sep, value, flags=re.I)
|
|
124
|
+
if '%' not in value:
|
|
125
|
+
path = Path(value)
|
|
126
|
+
candidates.append(path if path.is_absolute() else shim.parent / path)
|
|
127
|
+
for entry in candidates:
|
|
128
|
+
if entry.is_file():
|
|
129
|
+
return entry.resolve()
|
|
130
|
+
raise RuntimeError('CLI shim 对应包入口不可访问或布局无法识别,请检查全局安装目录权限')
|
|
131
|
+
|
|
132
|
+
|
|
90
133
|
def parse_json_text(raw: str):
|
|
91
134
|
"""从混有日志的文本里取出第一段 JSON。数组文件必须从 '[' 起读,不能先找 '{'。"""
|
|
92
135
|
text = (raw or "").strip()
|
|
@@ -121,26 +164,37 @@ def unwrap_page(envelope: dict) -> tuple[list, int]:
|
|
|
121
164
|
return [], 0
|
|
122
165
|
if isinstance(data.get("data"), list):
|
|
123
166
|
rows = data["data"]
|
|
124
|
-
return rows, int(data.get("total")
|
|
167
|
+
return rows, int(data["total"] if data.get("total") is not None else len(rows))
|
|
125
168
|
if isinstance(data.get("result"), list):
|
|
126
169
|
rows = data["result"]
|
|
127
|
-
return rows, int(data.get("total")
|
|
170
|
+
return rows, int(data["total"] if data.get("total") is not None else len(rows))
|
|
128
171
|
return [], 0
|
|
129
172
|
|
|
130
173
|
|
|
131
174
|
def exec_cli_to_file(cmd: list[str], out_path: Path) -> tuple[int, str]:
|
|
132
|
-
"""
|
|
175
|
+
"""两个输出流直写本次文件;取消时等待子进程退出后才允许清理。"""
|
|
133
176
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
177
|
+
cancel = getattr(_local, "cancel", None)
|
|
178
|
+
if cancel and cancel.is_set():
|
|
179
|
+
raise InterruptedError("任务已取消")
|
|
180
|
+
with out_path.open("w", encoding="utf-8") as f, out_path.with_suffix(".stderr.txt").open("w", encoding="utf-8") as err:
|
|
181
|
+
proc = subprocess.Popen(cmd, stdout=f, stderr=err, shell=False)
|
|
182
|
+
try:
|
|
183
|
+
while True:
|
|
184
|
+
try:
|
|
185
|
+
proc.wait(timeout=0.2)
|
|
186
|
+
break
|
|
187
|
+
except subprocess.TimeoutExpired:
|
|
188
|
+
if cancel and cancel.is_set():
|
|
189
|
+
raise InterruptedError("任务已取消")
|
|
190
|
+
finally:
|
|
191
|
+
if proc.poll() is None:
|
|
192
|
+
proc.terminate()
|
|
193
|
+
try:
|
|
194
|
+
proc.wait(timeout=5)
|
|
195
|
+
except subprocess.TimeoutExpired:
|
|
196
|
+
proc.kill()
|
|
197
|
+
proc.wait()
|
|
144
198
|
raw = out_path.read_text(encoding="utf-8")
|
|
145
199
|
return proc.returncode, raw
|
|
146
200
|
|
|
@@ -162,6 +216,7 @@ def list_query_body(*, name: str = "", biz_id: int = 0) -> dict:
|
|
|
162
216
|
|
|
163
217
|
|
|
164
218
|
def run_cli(args: list[str], body: dict | None, out_path: Path) -> dict:
|
|
219
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
165
220
|
cmd = [*resolve_cli(), *args, "--format", "json"]
|
|
166
221
|
if body is not None:
|
|
167
222
|
body_path = out_path.with_name(out_path.name.replace(".out.json", ".body.json"))
|
|
@@ -170,13 +225,64 @@ def run_cli(args: list[str], body: dict | None, out_path: Path) -> dict:
|
|
|
170
225
|
body_path.write_text(json.dumps(body, ensure_ascii=False), encoding="utf-8")
|
|
171
226
|
cmd.extend(["--data", json.dumps(body, ensure_ascii=False)])
|
|
172
227
|
rc, raw = exec_cli_to_file(cmd, out_path)
|
|
173
|
-
parsed = parse_json_text(raw.strip())
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
parsed
|
|
228
|
+
parsed = parse_json_text(raw.strip())
|
|
229
|
+
stderr = out_path.with_suffix(".stderr.txt").read_text(encoding="utf-8", errors="replace")
|
|
230
|
+
if not isinstance(parsed, dict) or rc != 0 or parsed.get("ok") is not True:
|
|
231
|
+
error = parse_json_text(stderr) or stderr or parsed or raw or "无有效 CLI 回包"
|
|
232
|
+
parsed = {"ok": False, "error": error, "rc": rc}
|
|
177
233
|
return parsed
|
|
178
234
|
|
|
179
235
|
|
|
236
|
+
def require_ok(envelope, label):
|
|
237
|
+
"""失败信封不能按空数据继续生成报告。"""
|
|
238
|
+
if not isinstance(envelope, dict) or envelope.get("ok") is not True:
|
|
239
|
+
raise RuntimeError(f"{label}: {json.dumps(envelope, ensure_ascii=False)[:2000]}")
|
|
240
|
+
return envelope
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def optional_num(row, key):
|
|
244
|
+
"""指标缺失与零分开,拒绝 NaN/Infinity 进入分析及 Excel。"""
|
|
245
|
+
try:
|
|
246
|
+
value = float(row.get(key))
|
|
247
|
+
return value if math.isfinite(value) else None
|
|
248
|
+
except (TypeError, ValueError):
|
|
249
|
+
return None
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def metric_value(rows, metric="CPI"):
|
|
253
|
+
"""同一粒度先合计基础指标再计算,任一缺失不当作零补齐。"""
|
|
254
|
+
formulas = {"CPI": ("spend_usd", "installs", 1), "CPC": ("spend_usd", "clicks", 1),
|
|
255
|
+
"CPM": ("spend_usd", "impressions", 1000), "ROAS": ("purchase_value", "spend_usd", 1),
|
|
256
|
+
"CTR": ("clicks", "impressions", 100)}
|
|
257
|
+
a, b, scale = formulas[metric]
|
|
258
|
+
if not rows or any(optional_num(r, key) is None for r in rows for key in (a, b)):
|
|
259
|
+
return None
|
|
260
|
+
denominator = sum(optional_num(r, b) for r in rows)
|
|
261
|
+
return sum(optional_num(r, a) for r in rows) / denominator * scale if denominator > 0 else None
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def core_rule(text):
|
|
265
|
+
"""只解析定义及币种明确的单一 KPI;自由文本组合目标保持不可判定。"""
|
|
266
|
+
text = (text or "").strip()
|
|
267
|
+
match = re.fullmatch(r"(CPI|CPC|CPM|ROAS|CTR)\s*(USD|美元|\$)?\s*(<=|>=|≤|≥|<|>)\s*(\d+(?:\.\d+)?)\s*(USD|美元|\$|%)?", text, re.I)
|
|
268
|
+
if match:
|
|
269
|
+
metric, currency, op, target, suffix = match.groups()
|
|
270
|
+
metric = metric.upper()
|
|
271
|
+
currency, suffix = (currency or '').upper(), (suffix or '').upper()
|
|
272
|
+
if (metric in ("CPI", "CPC", "CPM") and (currency or suffix in ("USD", "美元", "$")) and suffix != "%") or (metric == "ROAS" and not currency and not suffix) or (metric == "CTR" and not currency and suffix == "%"):
|
|
273
|
+
return {"metric": metric, "op": op, "target": float(target), "usable": True, "reason": "同口径 KPI"}
|
|
274
|
+
return {"metric": "CPI", "usable": False, "reason": "客户 KPI 不可判定:缺少明确的同币种、同转化定义或留存数据;安装 CPI 仅作观察指标"}
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def rule_hit(value, rule):
|
|
278
|
+
"""仅对明确定义的 KPI 判断,缺数据不输出未达标。"""
|
|
279
|
+
if value is None or not rule.get("usable"):
|
|
280
|
+
return None
|
|
281
|
+
target, op = rule["target"], rule["op"]
|
|
282
|
+
return {"<=": value <= target, "≤": value <= target, "<": value < target,
|
|
283
|
+
">=": value >= target, "≥": value >= target, ">": value > target}[op]
|
|
284
|
+
|
|
285
|
+
|
|
180
286
|
def is_first_period(biz: dict) -> bool:
|
|
181
287
|
start = str((biz or {}).get("prevPeriodStart") or "").strip()
|
|
182
288
|
end = str((biz or {}).get("prevPeriodEnd") or "").strip()
|
|
@@ -373,14 +479,14 @@ def hit_kpi(v, target: float = 5.0):
|
|
|
373
479
|
return v <= target
|
|
374
480
|
|
|
375
481
|
|
|
376
|
-
def qoq_axis(curr, prev):
|
|
482
|
+
def qoq_axis(curr, prev, higher_is_better=False):
|
|
377
483
|
"""成本类:下降为好。返回 (label, pct)。"""
|
|
378
484
|
if curr is None or prev is None or prev == 0:
|
|
379
485
|
return "无法对比", None
|
|
380
486
|
change = (curr - prev) / prev
|
|
381
487
|
if abs(change) <= 0.10:
|
|
382
488
|
return "稳定", change
|
|
383
|
-
return ("变好" if change < 0 else "变差"), change
|
|
489
|
+
return ("变好" if (change > 0 if higher_is_better else change < 0) else "变差"), change
|
|
384
490
|
|
|
385
491
|
|
|
386
492
|
def dual_verdict(is_hit, qoq_label: str) -> str:
|
|
@@ -15,7 +15,8 @@ SCRIPTS = Path(__file__).resolve().parent
|
|
|
15
15
|
if str(SCRIPTS) not in sys.path:
|
|
16
16
|
sys.path.insert(0, str(SCRIPTS))
|
|
17
17
|
|
|
18
|
-
from cli_json import list_query_body, run_cli, unwrap_page
|
|
18
|
+
from cli_json import list_query_body, run_cli, unwrap_page, require_ok, report_progress
|
|
19
|
+
from datetime import date
|
|
19
20
|
|
|
20
21
|
# 维度/成效 size=1000 时,TikTok 出价按天明细可到 4~5 万行,旧上限 30 页会截断
|
|
21
22
|
PAGE_CAP = 80
|
|
@@ -48,17 +49,23 @@ def fetch_paged(shortcut: str, base_body: dict, stem: str, bl_dir: Path) -> list
|
|
|
48
49
|
body = {**base_body, "page": page, "size": 1000}
|
|
49
50
|
out = bl_dir / f"{stem}.p{page}.out.json"
|
|
50
51
|
env = run_cli(["biz-line", shortcut], body, out)
|
|
51
|
-
|
|
52
|
+
require_ok(env, stem)
|
|
53
|
+
data = env.get("data")
|
|
54
|
+
if not isinstance(data, dict) or not isinstance(data.get("data"), list) or not isinstance(data.get("total"), int) or data["total"] < 0:
|
|
55
|
+
raise RuntimeError(f"{stem}: 分页信封缺少有效 data/total")
|
|
56
|
+
rows, current_total = unwrap_page(env)
|
|
57
|
+
if page > 1 and current_total != total:
|
|
58
|
+
raise RuntimeError(f"{stem}: 分页期间总数变化,请重新拉取")
|
|
59
|
+
total = current_total
|
|
52
60
|
all_rows.extend(rows)
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
)
|
|
57
|
-
if not env.get("ok") or not rows or len(all_rows) >= total or len(rows) < 1000:
|
|
61
|
+
report_progress(f"{stem} page={page} got={len(rows)} acc={len(all_rows)} total={total}")
|
|
62
|
+
if len(all_rows) > total:
|
|
63
|
+
raise RuntimeError(f"{stem}: 累计行数超过 total")
|
|
64
|
+
if len(all_rows) == total:
|
|
58
65
|
break
|
|
66
|
+
if not rows or page >= PAGE_CAP:
|
|
67
|
+
raise RuntimeError(f"{stem}: 分页不完整 {len(all_rows)}/{total}")
|
|
59
68
|
page += 1
|
|
60
|
-
if page > PAGE_CAP:
|
|
61
|
-
break
|
|
62
69
|
(bl_dir / f"{stem}.merged.json").write_text(
|
|
63
70
|
json.dumps(all_rows, ensure_ascii=False), encoding="utf-8"
|
|
64
71
|
)
|
|
@@ -75,12 +82,13 @@ CHANNEL_ALIAS = {
|
|
|
75
82
|
|
|
76
83
|
|
|
77
84
|
def load_projects(req: Path, biz_line_id: int) -> tuple[list[str], dict[str, list[str]]]:
|
|
78
|
-
"""
|
|
85
|
+
"""关联项目必须查询成功,名称为空时查详情;账号按渠道去重。"""
|
|
79
86
|
pl = run_cli(
|
|
80
87
|
["biz-line", "+project-list"],
|
|
81
88
|
{"bizLineId": biz_line_id},
|
|
82
89
|
req / "biz-line-project-list.out.json",
|
|
83
90
|
)
|
|
91
|
+
require_ok(pl, "关联项目")
|
|
84
92
|
rows, _ = unwrap_page(pl)
|
|
85
93
|
names = [
|
|
86
94
|
p.get("projectName")
|
|
@@ -107,6 +115,7 @@ def load_projects(req: Path, biz_line_id: int) -> tuple[list[str], dict[str, lis
|
|
|
107
115
|
{"id": biz_line_id},
|
|
108
116
|
req / "biz-line-detail.out.json",
|
|
109
117
|
)
|
|
118
|
+
require_ok(detail, "业务线详情")
|
|
110
119
|
data = detail.get("data") if isinstance(detail.get("data"), dict) else {}
|
|
111
120
|
names = list(data.get("projectNames") or [])
|
|
112
121
|
return names, buckets
|
|
@@ -218,11 +227,17 @@ def met_dims(ch: str) -> list[list[str]]:
|
|
|
218
227
|
return [["objective"], ["targeting_interests"], *common]
|
|
219
228
|
|
|
220
229
|
|
|
221
|
-
def
|
|
230
|
+
def argument_parser(batch=False):
|
|
231
|
+
"""单条诊断入口与批次入口共享日期和账号参数。"""
|
|
222
232
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
parser.
|
|
233
|
+
if not batch:
|
|
234
|
+
parser.add_argument("--request-dir", required=True, help="本次 TEMP/sino-bc-strategy/<requestId>")
|
|
235
|
+
target = parser.add_mutually_exclusive_group(required=True)
|
|
236
|
+
target.add_argument("--name", default="", help="业务线名称,命中多条时报歧义")
|
|
237
|
+
if batch:
|
|
238
|
+
target.add_argument("--id", type=int, action="append", default=[], help="业务线 ID,可重复传入多个")
|
|
239
|
+
else:
|
|
240
|
+
target.add_argument("--id", type=int, default=0, help="已知业务线 ID")
|
|
226
241
|
parser.add_argument("--curr-start", default="")
|
|
227
242
|
parser.add_argument("--curr-end", default="")
|
|
228
243
|
parser.add_argument("--prev-start", default="")
|
|
@@ -230,7 +245,11 @@ def main() -> int:
|
|
|
230
245
|
parser.add_argument("--account-fb", action="append", default=[])
|
|
231
246
|
parser.add_argument("--account-gg", action="append", default=[])
|
|
232
247
|
parser.add_argument("--account-tt", action="append", default=[])
|
|
233
|
-
|
|
248
|
+
return parser
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def fetch_business(args):
|
|
252
|
+
"""一条业务线内所有调用串行;阶段失败交由统一入口收尾。"""
|
|
234
253
|
|
|
235
254
|
req = Path(args.request_dir)
|
|
236
255
|
req.mkdir(parents=True, exist_ok=True)
|
|
@@ -241,16 +260,17 @@ def main() -> int:
|
|
|
241
260
|
list_query_body(name=args.name, biz_id=args.id),
|
|
242
261
|
req / "biz-line-list.out.json",
|
|
243
262
|
)
|
|
244
|
-
|
|
263
|
+
require_ok(listed, "查找业务线")
|
|
264
|
+
rows, total = unwrap_page(listed)
|
|
245
265
|
if not rows:
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
if len(rows) > 1 and not args.id:
|
|
266
|
+
raise RuntimeError("业务线列表为空")
|
|
267
|
+
if len(rows) != 1 or total != 1:
|
|
249
268
|
names = ";".join(f"{r.get('id')} {r.get('name')}" for r in rows[:10])
|
|
250
|
-
|
|
251
|
-
return 1
|
|
269
|
+
raise RuntimeError(f"名称命中 {total} 条,请收窄:{names}")
|
|
252
270
|
biz = rows[0]
|
|
253
271
|
biz_id = int(biz["id"])
|
|
272
|
+
if args.id and biz_id != args.id:
|
|
273
|
+
raise RuntimeError("返回的业务线 ID 与请求不一致")
|
|
254
274
|
(req / "biz-line.json").write_text(json.dumps(biz, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
255
275
|
|
|
256
276
|
curr_start = args.curr_start or str(biz.get("periodStart") or "")
|
|
@@ -258,18 +278,23 @@ def main() -> int:
|
|
|
258
278
|
prev_start = args.prev_start or str(biz.get("prevPeriodStart") or "")
|
|
259
279
|
prev_end = args.prev_end or str(biz.get("prevPeriodEnd") or "")
|
|
260
280
|
if not (curr_start and curr_end):
|
|
261
|
-
|
|
262
|
-
return 1
|
|
281
|
+
raise RuntimeError("缺少本期日期,且业务线也没有 periodStart/periodEnd")
|
|
263
282
|
periods = {"curr": (curr_start, curr_end)}
|
|
264
283
|
if prev_start and prev_end:
|
|
265
284
|
periods["prev"] = (prev_start, prev_end)
|
|
285
|
+
if bool(prev_start) != bool(prev_end):
|
|
286
|
+
raise ValueError("上期起止日期必须成对提供")
|
|
287
|
+
for start, end in periods.values():
|
|
288
|
+
if date.fromisoformat(start) > date.fromisoformat(end):
|
|
289
|
+
raise ValueError("周期开始日期晚于结束日期")
|
|
266
290
|
|
|
267
|
-
run_cli(["biz-line", "+detail"], {"id": biz_id}, req / "biz-line-detail.out.json")
|
|
291
|
+
require_ok(run_cli(["biz-line", "+detail"], {"id": biz_id}, req / "biz-line-detail.out.json"), "业务线详情")
|
|
268
292
|
names, discovered = load_projects(req, biz_id)
|
|
269
293
|
# 用户指定账号覆盖接口返回;没指定则用项目下按 ID 去重后的账号
|
|
270
294
|
for ch in accounts:
|
|
271
295
|
if not accounts[ch]:
|
|
272
296
|
accounts[ch] = discovered[ch]
|
|
297
|
+
accounts[ch] = list(dict.fromkeys(accounts[ch]))
|
|
273
298
|
(req / "periods.json").write_text(
|
|
274
299
|
json.dumps(
|
|
275
300
|
{
|
|
@@ -290,15 +315,13 @@ def main() -> int:
|
|
|
290
315
|
encoding="utf-8",
|
|
291
316
|
)
|
|
292
317
|
if not names and not any(accounts.values()):
|
|
293
|
-
|
|
294
|
-
return 2
|
|
318
|
+
raise RuntimeError("未绑定项目/广告账号")
|
|
295
319
|
if not any(accounts.values()):
|
|
296
|
-
|
|
297
|
-
return 2
|
|
298
|
-
print("ACCOUNTS", json.dumps(accounts, ensure_ascii=False), flush=True)
|
|
320
|
+
raise RuntimeError("项目下没有 Meta/Google/TikTok 广告账号")
|
|
299
321
|
|
|
300
322
|
bl_dir = req / f"bl-{biz_id}"
|
|
301
323
|
bl_dir.mkdir(parents=True, exist_ok=True)
|
|
324
|
+
manifest = {}
|
|
302
325
|
|
|
303
326
|
for ch, acc in accounts.items():
|
|
304
327
|
if not acc:
|
|
@@ -313,7 +336,7 @@ def main() -> int:
|
|
|
313
336
|
"dimensions": dims,
|
|
314
337
|
**extra,
|
|
315
338
|
}
|
|
316
|
-
fetch_paged(DIM_CMD[ch], body, stem, bl_dir)
|
|
339
|
+
manifest[stem] = len(fetch_paged(DIM_CMD[ch], body, stem, bl_dir))
|
|
317
340
|
for dims in met_dims(ch):
|
|
318
341
|
stem = f"met_{ch}_{per}_{'+'.join(dims)}"
|
|
319
342
|
body = {
|
|
@@ -323,9 +346,15 @@ def main() -> int:
|
|
|
323
346
|
"dimensions": dims,
|
|
324
347
|
"indicators": INDICATORS,
|
|
325
348
|
}
|
|
326
|
-
fetch_paged(MET_CMD[ch], body, stem, bl_dir)
|
|
349
|
+
manifest[stem] = len(fetch_paged(MET_CMD[ch], body, stem, bl_dir))
|
|
350
|
+
|
|
351
|
+
# 只有全部所需数据完成后登记清单,汇总前按清单核对文件和数量。
|
|
352
|
+
(req / 'fetch-manifest.json').write_text(json.dumps(manifest), encoding='utf-8')
|
|
353
|
+
return biz
|
|
327
354
|
|
|
328
|
-
|
|
355
|
+
|
|
356
|
+
def main() -> int:
|
|
357
|
+
fetch_business(argument_parser().parse_args())
|
|
329
358
|
return 0
|
|
330
359
|
|
|
331
360
|
|