sino-bc-cli 1.0.0-beta.0
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/README.md +256 -0
- package/dist/affordance/auth.md +31 -0
- package/dist/affordance/bill.md +48 -0
- package/dist/affordance/biz-line-ai-strategy.md +35 -0
- package/dist/affordance/biz-line-metrics.md +47 -0
- package/dist/affordance/biz-line.md +84 -0
- package/dist/affordance/common-file.md +25 -0
- package/dist/affordance/dashboard.md +75 -0
- package/dist/affordance/dict.md +28 -0
- package/dist/affordance/document.md +67 -0
- package/dist/affordance/im.md +91 -0
- package/dist/affordance/invoice.md +49 -0
- package/dist/affordance/media.md +17 -0
- package/dist/affordance/organization.md +47 -0
- package/dist/affordance/project-analysis.md +95 -0
- package/dist/affordance/project.md +321 -0
- package/dist/affordance/quota.md +19 -0
- package/dist/affordance/reconciliation.md +41 -0
- package/dist/affordance/report.md +75 -0
- package/dist/affordance/sso-team-group.md +30 -0
- package/dist/affordance/task.md +148 -0
- package/dist/affordance/tenant.md +17 -0
- package/dist/affordance/user.md +41 -0
- package/dist/affordance/wallet.md +100 -0
- package/dist/api-doc/README.md +67 -0
- package/dist/api-doc/custom/template.md +0 -0
- package/dist/api-doc/oapi/auth.md +61 -0
- package/dist/api-doc/oapi/bill.md +130 -0
- package/dist/api-doc/oapi/biz-line-ai-strategy.md +119 -0
- package/dist/api-doc/oapi/biz-line-metrics.md +419 -0
- package/dist/api-doc/oapi/biz-line.md +382 -0
- package/dist/api-doc/oapi/common-file.md +50 -0
- package/dist/api-doc/oapi/dashboard.md +218 -0
- package/dist/api-doc/oapi/dict.md +49 -0
- package/dist/api-doc/oapi/document.md +182 -0
- package/dist/api-doc/oapi/im.md +278 -0
- package/dist/api-doc/oapi/invoice.md +129 -0
- package/dist/api-doc/oapi/media.md +17 -0
- package/dist/api-doc/oapi/organization.md +94 -0
- package/dist/api-doc/oapi/project-analysis.md +364 -0
- package/dist/api-doc/oapi/project.md +1114 -0
- package/dist/api-doc/oapi/quota.md +21 -0
- package/dist/api-doc/oapi/reconciliation.md +98 -0
- package/dist/api-doc/oapi/report.md +254 -0
- package/dist/api-doc/oapi/sso-team-group.md +54 -0
- package/dist/api-doc/oapi/task.md +528 -0
- package/dist/api-doc/oapi/tenant.md +17 -0
- package/dist/api-doc/oapi/user.md +98 -0
- package/dist/api-doc/oapi/wallet.md +275 -0
- package/dist/generated/api-catalog.json +39681 -0
- package/dist/generated/api-directory-index.json +1509 -0
- package/dist/index.js +2 -0
- package/dist/skill-manifest.json +336 -0
- package/dist/skills/biz-line-strategy-generate/SKILL.md +122 -0
- package/dist/skills/biz-line-strategy-generate/references/ai-strategy-prompt.md +50 -0
- package/dist/skills/biz-line-strategy-generate/references/api-flow.md +155 -0
- package/dist/skills/biz-line-strategy-generate/references/four-strategies.md +58 -0
- package/dist/skills/biz-line-strategy-generate/references/metrics.md +119 -0
- package/dist/skills/biz-line-strategy-generate/references/output-excel.md +63 -0
- package/dist/skills/biz-line-strategy-generate/references/runtime.md +127 -0
- package/dist/skills/biz-line-strategy-generate/references/spec.md +500 -0
- package/dist/skills/biz-line-strategy-generate/scripts/ai_strategy.py +89 -0
- package/dist/skills/biz-line-strategy-generate/scripts/build_report.py +629 -0
- package/dist/skills/biz-line-strategy-generate/scripts/cleanup.py +87 -0
- package/dist/skills/biz-line-strategy-generate/scripts/cli_json.py +705 -0
- package/dist/skills/biz-line-strategy-generate/scripts/fetch_data.py +449 -0
- package/dist/skills/biz-line-strategy-generate/scripts/run_workflow.py +402 -0
- package/dist/skills/biz-line-strategy-generate/scripts/strategy_output.py +556 -0
- package/dist/skills/biz-line-strategy-generate/scripts/submit_tags.py +32 -0
- package/dist/skills/biz-line-strategy-generate/scripts/summarize.py +932 -0
- package/dist/skills/biz-line-strategy-generate/scripts/test_ai_strategy.py +235 -0
- package/dist/skills/biz-line-strategy-generate/scripts/test_cli_json.py +97 -0
- package/dist/skills/biz-line-strategy-generate/scripts/test_database_output.py +904 -0
- package/dist/skills/biz-line-strategy-generate/scripts/test_pipeline.py +253 -0
- package/dist/skills/biz-line-strategy-generate/scripts/test_strategy_rules.py +224 -0
- package/dist/skills/biz-line-strategy-generate/scripts/test_tag_submission.py +238 -0
- package/dist/skills/biz-line-strategy-generate/scripts/test_workflow.py +1233 -0
- package/dist/skills/sino-bc-cli/SKILL.md +62 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-auth/SKILL.md +36 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-bill/SKILL.md +48 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-biz-line/SKILL.md +58 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-biz-line/references/common-mistakes.md +7 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-biz-line/references/enums.md +13 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-biz-line-ai-strategy/SKILL.md +46 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-biz-line-metrics/SKILL.md +46 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-common-file/SKILL.md +44 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-dashboard/SKILL.md +50 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-dict/SKILL.md +48 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-document/SKILL.md +53 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-im/SKILL.md +62 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-invoice/SKILL.md +48 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-media/SKILL.md +40 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-organization/SKILL.md +46 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-project/SKILL.md +112 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-project-analysis/SKILL.md +61 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-project-analysis/references/analysis-workflow.md +21 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-project-analysis/references/filters-and-metrics.md +30 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-quota/SKILL.md +44 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-reconciliation/SKILL.md +47 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-report/SKILL.md +53 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-sso-team-group/SKILL.md +45 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-task/SKILL.md +73 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-task/references/state-machine.md +18 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-tenant/SKILL.md +34 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-user/SKILL.md +57 -0
- package/dist/skills/sino-bc-cli/domains/sino-bc-wallet/SKILL.md +55 -0
- package/dist/skills/sino-bc-cli/references/domain-index/module-8534789.md +53 -0
- package/dist/skills/sino-bc-cli/references/domain-index/module-8551546.md +70 -0
- package/dist/skills/sino-bc-cli/references/domain-index/module-8562638.md +13 -0
- package/dist/skills/sino-bc-cli/references/domain-index/module-8566367.md +13 -0
- package/dist/skills/sino-bc-cli/references/domain-index/module-8581524.md +17 -0
- package/dist/skills/sino-bc-cli/references/domain-index/module-8601540.md +13 -0
- package/dist/skills/sino-bc-cli/references/domain-index/module-8614891.md +33 -0
- package/dist/skills/sino-bc-cli/references/domain-index/module-8621792.md +17 -0
- package/dist/skills/sino-bc-shared/SKILL.md +31 -0
- package/dist/skills/sino-bc-shared/references/auth-recovery.md +36 -0
- package/dist/skills/sino-bc-shared/references/config-and-env.md +60 -0
- package/dist/skills/sino-bc-shared/references/output-contract.md +86 -0
- package/dist/skills/sino-bc-shared/references/skill-lifecycle.md +17 -0
- package/dist/skills/sino-bc-shared/references/tenant-context.md +21 -0
- package/dist/skills/sino-bc-shared/references/write-approval.md +16 -0
- package/package.json +70 -0
- package/scripts/postinstall.cjs +36 -0
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""按业务线批量拉列表 / 项目 / 维度 / 成效,写入本次请求目录。
|
|
4
|
+
|
|
5
|
+
不要在 Agent 里手写上百次 CLI:信封解包和翻页都收在这里。
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import json
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
SCRIPTS = Path(__file__).resolve().parent
|
|
15
|
+
if str(SCRIPTS) not in sys.path:
|
|
16
|
+
sys.path.insert(0, str(SCRIPTS))
|
|
17
|
+
|
|
18
|
+
from cli_json import BASE_METRICS, list_query_body, run_cli, unwrap_page, require_ok, report_progress
|
|
19
|
+
from datetime import date, timedelta
|
|
20
|
+
from strategy_output import GenerationOutcome, precheck_projects, validate_period
|
|
21
|
+
|
|
22
|
+
# 维度/成效 size=1000;大业务线广告可超过 10 万行,200 页仍作为防失控上限。
|
|
23
|
+
PAGE_CAP = 200
|
|
24
|
+
|
|
25
|
+
INDICATORS = list(BASE_METRICS)
|
|
26
|
+
DIM_CMD = {
|
|
27
|
+
"fb": "+facebook-dimension",
|
|
28
|
+
"gg": "+google-dimension",
|
|
29
|
+
"tt": "+tiktok-dimension",
|
|
30
|
+
}
|
|
31
|
+
MET_CMD = {
|
|
32
|
+
"fb": "+facebook-metrics",
|
|
33
|
+
"gg": "+google-metrics",
|
|
34
|
+
"tt": "+tiktok-metrics",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def fetch_paged(shortcut: str, base_body: dict, stem: str, bl_dir: Path) -> int:
|
|
39
|
+
"""逐页把 CLI 回包落到 stem.pN.out.json,成功后再写 stem.pages.json。
|
|
40
|
+
|
|
41
|
+
只在内存里保留当前页和行数累计,不把全表 extend 成一份 list,也不写 merged.json。
|
|
42
|
+
开始前删掉本 stem 残留索引,避免同目录重试失败后仍留下上一轮完整 pages.json。
|
|
43
|
+
"""
|
|
44
|
+
index_path = bl_dir / f"{stem}.pages.json"
|
|
45
|
+
if index_path.exists():
|
|
46
|
+
index_path.unlink()
|
|
47
|
+
accumulated = 0
|
|
48
|
+
counts: list[int] = []
|
|
49
|
+
page = 1
|
|
50
|
+
total = 0
|
|
51
|
+
conflicts = set()
|
|
52
|
+
while True:
|
|
53
|
+
body = {**base_body, "page": page, "size": 1000}
|
|
54
|
+
out = bl_dir / f"{stem}.p{page}.out.json"
|
|
55
|
+
env = run_cli(["biz-line-metrics", shortcut], body, out)
|
|
56
|
+
require_ok(env, stem)
|
|
57
|
+
data = env.get("data")
|
|
58
|
+
if not isinstance(data, dict) or not isinstance(data.get("data"), list) or not isinstance(data.get("total"), int) or data["total"] < 0:
|
|
59
|
+
raise RuntimeError(f"{stem}: 分页信封缺少有效 data/total")
|
|
60
|
+
rows, current_total = unwrap_page(env)
|
|
61
|
+
# 接口会静默忽略不支持字段;保留反馈,分析时不能误用部分渠道的指标。
|
|
62
|
+
reported = data.get('conflictField', [])
|
|
63
|
+
# 维度接口可能用 null 表示无冲突;只兼容空值,不放过 0/false/空字符串等非法类型。
|
|
64
|
+
if reported is None:
|
|
65
|
+
reported = []
|
|
66
|
+
if not isinstance(reported, list) or any(not isinstance(value, str) for value in reported):
|
|
67
|
+
raise RuntimeError(f'{stem}: conflictField 格式无效')
|
|
68
|
+
conflicts.update(reported)
|
|
69
|
+
if page > 1 and current_total != total:
|
|
70
|
+
raise RuntimeError(f"{stem}: 分页期间总数变化,请重新拉取")
|
|
71
|
+
total = current_total
|
|
72
|
+
got = len(rows)
|
|
73
|
+
accumulated += got
|
|
74
|
+
counts.append(got)
|
|
75
|
+
report_progress(f"{stem} page={page} got={got} acc={accumulated} total={total}")
|
|
76
|
+
if accumulated > total:
|
|
77
|
+
raise RuntimeError(f"{stem}: 累计行数超过 total")
|
|
78
|
+
if accumulated == total:
|
|
79
|
+
break
|
|
80
|
+
if not rows or page >= PAGE_CAP:
|
|
81
|
+
raise RuntimeError(f"{stem}: 分页不完整 {accumulated}/{total}")
|
|
82
|
+
page += 1
|
|
83
|
+
(bl_dir / f"{stem}.pages.json").write_text(
|
|
84
|
+
json.dumps({"total": total, "pages": len(counts), "counts": counts,
|
|
85
|
+
**({'conflictField': sorted(conflicts)} if conflicts else {})}, ensure_ascii=False),
|
|
86
|
+
encoding="utf-8",
|
|
87
|
+
)
|
|
88
|
+
return total
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
CHANNEL_ALIAS = {
|
|
92
|
+
"facebook": "fb",
|
|
93
|
+
"meta": "fb",
|
|
94
|
+
"fb": "fb",
|
|
95
|
+
"google": "gg",
|
|
96
|
+
"gg": "gg",
|
|
97
|
+
"tiktok": "tt",
|
|
98
|
+
"tik tok": "tt",
|
|
99
|
+
"tt": "tt",
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def load_projects(req: Path, biz_line_id: int) -> tuple[list[str], dict[str, list[str]]]:
|
|
104
|
+
"""校验所有关联项目的广告账号渠道,再按渠道去重;不使用项目渠道。"""
|
|
105
|
+
pl = run_cli(
|
|
106
|
+
["biz-line", "+project-list"],
|
|
107
|
+
{"bizLineId": biz_line_id},
|
|
108
|
+
req / "biz-line-project-list.out.json",
|
|
109
|
+
)
|
|
110
|
+
require_ok(pl, "关联项目")
|
|
111
|
+
if not isinstance(pl.get('data'), list):
|
|
112
|
+
raise RuntimeError('关联项目回包格式无效')
|
|
113
|
+
rows, _ = unwrap_page(pl)
|
|
114
|
+
names = [
|
|
115
|
+
p.get("projectName")
|
|
116
|
+
for p in rows
|
|
117
|
+
if isinstance(p, dict) and p.get("projectName")
|
|
118
|
+
]
|
|
119
|
+
buckets: dict[str, list[str]] = {"fb": [], "gg": [], "tt": []}
|
|
120
|
+
seen: set[tuple[str, str]] = set()
|
|
121
|
+
channels_by_id = None
|
|
122
|
+
for proj in rows:
|
|
123
|
+
if not isinstance(proj, dict):
|
|
124
|
+
raise RuntimeError('关联项目回包含无效项目')
|
|
125
|
+
accounts = proj.get('adAccounts')
|
|
126
|
+
if not isinstance(accounts, list):
|
|
127
|
+
raise RuntimeError('关联项目广告账号列表格式无效')
|
|
128
|
+
for acc in accounts:
|
|
129
|
+
if not isinstance(acc, dict):
|
|
130
|
+
raise RuntimeError('关联项目广告账号格式无效')
|
|
131
|
+
# 契约允许channelName为空;有channelId时必须以渠道字典解析ID,不能用名称掩盖未知ID。
|
|
132
|
+
if acc.get('channelId') is not None:
|
|
133
|
+
if channels_by_id is None:
|
|
134
|
+
dictionary = require_ok(run_cli(['media', '+channels'], None, req / 'channels.out.json'), '渠道字典')
|
|
135
|
+
if not isinstance(dictionary.get('data'), list):
|
|
136
|
+
raise RuntimeError('渠道字典格式无效')
|
|
137
|
+
channels_by_id = {str(item['channelId']).strip(): CHANNEL_ALIAS.get(str(item.get('channelName') or '').strip().lower())
|
|
138
|
+
for item in dictionary['data'] if isinstance(item, dict) and item.get('channelId') is not None}
|
|
139
|
+
ch = channels_by_id.get(str(acc['channelId']).strip())
|
|
140
|
+
else:
|
|
141
|
+
ch = CHANNEL_ALIAS.get(str(acc.get('channelName') or '').strip().lower())
|
|
142
|
+
if ch is None:
|
|
143
|
+
raise GenerationOutcome('UNSUPPORTED_CHANNEL',
|
|
144
|
+
f"项目 {proj.get('projectId') or proj.get('projectName') or '未知'} 的广告账号渠道不支持或无法识别:"
|
|
145
|
+
f"channelId={acc.get('channelId')},channelName={acc.get('channelName')};仅允许 Facebook、Google、TikTok")
|
|
146
|
+
aid = str(acc.get("adAccountId") or "").strip()
|
|
147
|
+
if not aid or (ch, aid) in seen:
|
|
148
|
+
continue
|
|
149
|
+
seen.add((ch, aid))
|
|
150
|
+
buckets[ch].append(aid)
|
|
151
|
+
if not names:
|
|
152
|
+
detail = run_cli(
|
|
153
|
+
["biz-line", "+detail"],
|
|
154
|
+
{"id": biz_line_id},
|
|
155
|
+
req / "biz-line-detail.out.json",
|
|
156
|
+
)
|
|
157
|
+
require_ok(detail, "业务线详情")
|
|
158
|
+
data = detail.get("data") if isinstance(detail.get("data"), dict) else {}
|
|
159
|
+
names = list(data.get("projectNames") or [])
|
|
160
|
+
return names, buckets
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def dim_jobs(ch: str) -> list[tuple[list[str], dict]]:
|
|
164
|
+
jobs: list[tuple[list[str], dict]] = []
|
|
165
|
+
orders = {"orders": {"adgroup_id": "asc", "calendar_date": "asc"}}
|
|
166
|
+
if ch == "fb":
|
|
167
|
+
jobs.append((["calendar_date", "adgroup_id", "adgroup_name", "bid_amount", "bid_strategy"], orders))
|
|
168
|
+
elif ch == "gg":
|
|
169
|
+
jobs.append(
|
|
170
|
+
(
|
|
171
|
+
[
|
|
172
|
+
"calendar_date",
|
|
173
|
+
"adgroup_id",
|
|
174
|
+
"adgroup_name",
|
|
175
|
+
"cpc_bid_amount",
|
|
176
|
+
"cpm_bid_amount",
|
|
177
|
+
"cpv_bid_amount",
|
|
178
|
+
"bid_strategy",
|
|
179
|
+
],
|
|
180
|
+
orders,
|
|
181
|
+
)
|
|
182
|
+
)
|
|
183
|
+
else:
|
|
184
|
+
jobs.append(
|
|
185
|
+
(
|
|
186
|
+
[
|
|
187
|
+
"calendar_date",
|
|
188
|
+
"adgroup_id",
|
|
189
|
+
"adgroup_name",
|
|
190
|
+
"bid_amount",
|
|
191
|
+
"conversion_bid",
|
|
192
|
+
"deep_cpabid",
|
|
193
|
+
"bid_strategy",
|
|
194
|
+
],
|
|
195
|
+
orders,
|
|
196
|
+
)
|
|
197
|
+
)
|
|
198
|
+
jobs.append((["country_code"], {}))
|
|
199
|
+
singles = {
|
|
200
|
+
"fb": [
|
|
201
|
+
"objective",
|
|
202
|
+
"optimization_goal",
|
|
203
|
+
"bid_strategy",
|
|
204
|
+
"ad_format",
|
|
205
|
+
"audience_type",
|
|
206
|
+
"targeting_age",
|
|
207
|
+
"targeting_genders",
|
|
208
|
+
],
|
|
209
|
+
"gg": [
|
|
210
|
+
"advertising_channel_type",
|
|
211
|
+
"optimization_goal",
|
|
212
|
+
"bid_strategy",
|
|
213
|
+
"ad_format",
|
|
214
|
+
"audience_type",
|
|
215
|
+
"targeting_age",
|
|
216
|
+
"targeting_genders",
|
|
217
|
+
"keywords",
|
|
218
|
+
],
|
|
219
|
+
"tt": [
|
|
220
|
+
"objective",
|
|
221
|
+
"optimization_goal",
|
|
222
|
+
"bid_strategy",
|
|
223
|
+
"ad_format",
|
|
224
|
+
"audience_type",
|
|
225
|
+
"targeting_age",
|
|
226
|
+
"targeting_genders",
|
|
227
|
+
"targeting_interests",
|
|
228
|
+
],
|
|
229
|
+
}[ch]
|
|
230
|
+
for field in singles:
|
|
231
|
+
jobs.append(([field], {}))
|
|
232
|
+
if ch == "fb":
|
|
233
|
+
jobs.append(
|
|
234
|
+
(
|
|
235
|
+
[
|
|
236
|
+
"targeting_interests",
|
|
237
|
+
"targeting_flexible_interests",
|
|
238
|
+
"targeting_exclusion_interests",
|
|
239
|
+
],
|
|
240
|
+
{},
|
|
241
|
+
)
|
|
242
|
+
)
|
|
243
|
+
jobs.append((["adgroup_id", "adgroup_name"], {}))
|
|
244
|
+
jobs.append((["ad_id", "ad_name"], {}))
|
|
245
|
+
return jobs
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def met_dims(ch: str) -> list[list[str]]:
|
|
249
|
+
common = [
|
|
250
|
+
["account_id"],
|
|
251
|
+
["calendar_date"],
|
|
252
|
+
["optimization_goal"],
|
|
253
|
+
["bid_strategy"],
|
|
254
|
+
["audience_type"],
|
|
255
|
+
["targeting_age"],
|
|
256
|
+
["targeting_genders"],
|
|
257
|
+
["adgroup_id", "adgroup_name"],
|
|
258
|
+
["ad_id", "ad_name"],
|
|
259
|
+
["ad_format"],
|
|
260
|
+
["country_code"],
|
|
261
|
+
]
|
|
262
|
+
if ch == "fb":
|
|
263
|
+
return [["objective"], ["targeting_interests"], *common]
|
|
264
|
+
if ch == "gg":
|
|
265
|
+
return [["advertising_channel_type"], ["keywords"], *common]
|
|
266
|
+
return [["objective"], ["targeting_interests"], *common]
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def core_index_override(value):
|
|
270
|
+
"""覆盖值绑定真实业务线ID,按首个等号切分以保留指标阈值比较符。"""
|
|
271
|
+
ident, separator, expression = value.partition('=')
|
|
272
|
+
if not separator or not ident.isdigit() or int(ident) <= 0 or not expression.strip():
|
|
273
|
+
raise argparse.ArgumentTypeError('核心指标覆盖格式为 业务线ID=指标,例如 123=CPI <= 3')
|
|
274
|
+
return int(ident), expression.strip()
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def argument_parser(batch=False):
|
|
278
|
+
"""单条诊断入口与批次入口共享日期和账号参数。"""
|
|
279
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
280
|
+
parser.add_argument('--core-index', action='append', type=core_index_override, default=[],
|
|
281
|
+
help='仅本次覆盖指定业务线核心指标,格式 ID=指标,可重复传不同ID')
|
|
282
|
+
if not batch:
|
|
283
|
+
parser.add_argument("--request-dir", required=True, help="strategy_task/日期/tmp内的独立请求目录(维护诊断用)")
|
|
284
|
+
target = parser.add_mutually_exclusive_group(required=True)
|
|
285
|
+
target.add_argument("--name", default="", help="业务线名称,命中多条时报歧义")
|
|
286
|
+
if batch:
|
|
287
|
+
target.add_argument("--id", type=int, action="append", default=[], help="业务线 ID,可重复传入多个")
|
|
288
|
+
else:
|
|
289
|
+
target.add_argument("--id", type=int, default=0, help="已知业务线 ID")
|
|
290
|
+
parser.add_argument("--curr-start", default="")
|
|
291
|
+
parser.add_argument("--curr-end", default="")
|
|
292
|
+
parser.add_argument("--prev-start", default="")
|
|
293
|
+
parser.add_argument("--prev-end", default="")
|
|
294
|
+
parser.add_argument("--skip-project-spend-check", action="store_true", help="仅用户明确要求时跳过项目上周消耗预检")
|
|
295
|
+
parser.add_argument("--account-fb", action="append", default=[])
|
|
296
|
+
parser.add_argument("--account-gg", action="append", default=[])
|
|
297
|
+
parser.add_argument("--account-tt", action="append", default=[])
|
|
298
|
+
return parser
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def resolve_periods(args, today=None):
|
|
302
|
+
"""按执行机器当地日期取两个已结束自然周,各期用户起止日期成对覆盖。"""
|
|
303
|
+
anchor = today or date.today()
|
|
304
|
+
monday = anchor - timedelta(days=anchor.weekday())
|
|
305
|
+
result = {}
|
|
306
|
+
for key, weeks in (('curr', 1), ('prev', 2)):
|
|
307
|
+
start, end = getattr(args, key + '_start'), getattr(args, key + '_end')
|
|
308
|
+
if bool(start) != bool(end):
|
|
309
|
+
raise ValueError('本期和上期起止日期必须分别成对提供')
|
|
310
|
+
if not start:
|
|
311
|
+
first = monday - timedelta(weeks=weeks)
|
|
312
|
+
start, end = first.isoformat(), (first + timedelta(days=6)).isoformat()
|
|
313
|
+
if date.fromisoformat(start) > date.fromisoformat(end):
|
|
314
|
+
raise ValueError('周期开始日期晚于结束日期')
|
|
315
|
+
result[key] = (start, end)
|
|
316
|
+
return result
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def fetch_business(args):
|
|
320
|
+
"""一条业务线内所有调用串行;阶段失败交由统一入口收尾。"""
|
|
321
|
+
|
|
322
|
+
req = Path(args.request_dir)
|
|
323
|
+
req.mkdir(parents=True, exist_ok=True)
|
|
324
|
+
accounts = {"fb": args.account_fb, "gg": args.account_gg, "tt": args.account_tt}
|
|
325
|
+
|
|
326
|
+
listed = run_cli(
|
|
327
|
+
["biz-line", "+list"],
|
|
328
|
+
list_query_body(name=args.name, biz_id=args.id),
|
|
329
|
+
req / "biz-line-list.out.json",
|
|
330
|
+
)
|
|
331
|
+
require_ok(listed, "查找业务线")
|
|
332
|
+
rows, total = unwrap_page(listed)
|
|
333
|
+
if not rows:
|
|
334
|
+
raise RuntimeError("业务线列表为空")
|
|
335
|
+
if len(rows) != 1 or total != 1:
|
|
336
|
+
names = ";".join(f"{r.get('id')} {r.get('name')}" for r in rows[:10])
|
|
337
|
+
raise RuntimeError(f"名称命中 {total} 条,请收窄:{names}")
|
|
338
|
+
biz = rows[0]
|
|
339
|
+
biz_id = int(biz["id"])
|
|
340
|
+
# 用户覆盖仅写本次分析配置,不修改服务端业务线字段,也不跨业务线复用。
|
|
341
|
+
overrides = {}
|
|
342
|
+
for ident, expression in getattr(args, 'core_index', []):
|
|
343
|
+
if ident in overrides:
|
|
344
|
+
raise ValueError(f'业务线 {ident} 重复指定核心指标')
|
|
345
|
+
overrides[ident] = expression
|
|
346
|
+
if not args.id and set(overrides) - {biz_id}:
|
|
347
|
+
raise ValueError('核心指标覆盖ID与名称匹配的业务线不一致')
|
|
348
|
+
if biz_id in overrides:
|
|
349
|
+
(req / 'analysis-settings.json').write_text(json.dumps(
|
|
350
|
+
{'bizLineId': biz_id, 'coreIndex': overrides[biz_id]}, ensure_ascii=False), encoding='utf-8')
|
|
351
|
+
if args.id and biz_id != args.id:
|
|
352
|
+
raise RuntimeError("返回的业务线 ID 与请求不一致")
|
|
353
|
+
(req / "biz-line.json").write_text(json.dumps(biz, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
354
|
+
|
|
355
|
+
# 查询、报表和保存共享本次解析的周期,不读取业务线返回的周期字段。
|
|
356
|
+
periods = resolve_periods(args)
|
|
357
|
+
curr_start, curr_end = periods['curr']
|
|
358
|
+
prev_start, prev_end = periods['prev']
|
|
359
|
+
|
|
360
|
+
# 只有真实业务线及合法周期才建立入库上下文,后续失败可据此保存状态。
|
|
361
|
+
if getattr(args, 'output_mode', 'excel') == 'database':
|
|
362
|
+
validate_period(curr_start, curr_end)
|
|
363
|
+
(req / 'save-context.json').write_text(json.dumps({
|
|
364
|
+
'bizLineId': biz_id, 'periodStart': curr_start, 'periodEnd': curr_end,
|
|
365
|
+
}), encoding='utf-8')
|
|
366
|
+
# 业务线已唯一确定且保存上下文已准备:先按行业加载标签,再取项目和广告数据。
|
|
367
|
+
if getattr(args, 'tag_cache', None) is not None:
|
|
368
|
+
args.tag_cache.get(biz)
|
|
369
|
+
if getattr(args, 'output_mode', 'excel') == 'database':
|
|
370
|
+
expected_ids = biz.get('projectIds')
|
|
371
|
+
if not isinstance(expected_ids, list) or any(type(value) is not int or value <= 0 for value in expected_ids):
|
|
372
|
+
raise RuntimeError('业务线缺少有效 projectIds,无法校验项目预检的完整性')
|
|
373
|
+
precheck_projects(req, biz_id, expected_ids, skip_spend_check=getattr(args, 'skip_project_spend_check', False))
|
|
374
|
+
|
|
375
|
+
require_ok(run_cli(["biz-line", "+detail"], {"id": biz_id}, req / "biz-line-detail.out.json"), "业务线详情")
|
|
376
|
+
names, discovered = load_projects(req, biz_id)
|
|
377
|
+
# 用户指定账号覆盖接口返回;没指定则用项目下按 ID 去重后的账号
|
|
378
|
+
for ch in accounts:
|
|
379
|
+
if not accounts[ch]:
|
|
380
|
+
accounts[ch] = discovered[ch]
|
|
381
|
+
accounts[ch] = list(dict.fromkeys(accounts[ch]))
|
|
382
|
+
(req / "periods.json").write_text(
|
|
383
|
+
json.dumps(
|
|
384
|
+
{
|
|
385
|
+
"curr": [curr_start, curr_end],
|
|
386
|
+
"prev": [prev_start, prev_end],
|
|
387
|
+
},
|
|
388
|
+
ensure_ascii=False,
|
|
389
|
+
indent=2,
|
|
390
|
+
),
|
|
391
|
+
encoding="utf-8",
|
|
392
|
+
)
|
|
393
|
+
(req / "projects.json").write_text(
|
|
394
|
+
json.dumps(
|
|
395
|
+
{"bizLineId": biz_id, "projectNames": names, "accounts": accounts, "allAccounts": discovered},
|
|
396
|
+
ensure_ascii=False,
|
|
397
|
+
indent=2,
|
|
398
|
+
),
|
|
399
|
+
encoding="utf-8",
|
|
400
|
+
)
|
|
401
|
+
if not names and not any(accounts.values()):
|
|
402
|
+
raise GenerationOutcome('NO_AD_ACCOUNT' if getattr(args, 'output_mode', 'excel') == 'database' else 'NO_PROJECT', "未绑定项目/广告账号")
|
|
403
|
+
if not any(accounts.values()):
|
|
404
|
+
raise GenerationOutcome('NO_AD_ACCOUNT', "项目下没有 Meta/Google/TikTok 广告账号")
|
|
405
|
+
|
|
406
|
+
bl_dir = req / f"bl-{biz_id}"
|
|
407
|
+
bl_dir.mkdir(parents=True, exist_ok=True)
|
|
408
|
+
manifest = {}
|
|
409
|
+
|
|
410
|
+
for ch, acc in accounts.items():
|
|
411
|
+
if not acc:
|
|
412
|
+
continue
|
|
413
|
+
for per, (start, end) in periods.items():
|
|
414
|
+
for dims, extra in dim_jobs(ch):
|
|
415
|
+
stem = f"dim_{ch}_{per}_{'+'.join(dims[:4])}"
|
|
416
|
+
body = {
|
|
417
|
+
"startDate": start,
|
|
418
|
+
"endDate": end,
|
|
419
|
+
"dimensionValues": {"account_id": acc},
|
|
420
|
+
"dimensions": dims,
|
|
421
|
+
**extra,
|
|
422
|
+
}
|
|
423
|
+
manifest[stem] = fetch_paged(DIM_CMD[ch], body, stem, bl_dir)
|
|
424
|
+
for dims in met_dims(ch):
|
|
425
|
+
stem = f"met_{ch}_{per}_{'+'.join(dims)}"
|
|
426
|
+
# 维度成效仍保留按日颗粒度,供明细和已有汇总口径使用。
|
|
427
|
+
# 文件标识保留业务维度名;账户总体与按日总体请求保持原口径。
|
|
428
|
+
query_dims = dims if dims in (["account_id"], ["calendar_date"]) else [*dims, "calendar_date"]
|
|
429
|
+
body = {
|
|
430
|
+
"startDate": start,
|
|
431
|
+
"endDate": end,
|
|
432
|
+
"dimensionValues": {"account_id": acc},
|
|
433
|
+
"dimensions": query_dims,
|
|
434
|
+
"indicators": INDICATORS,
|
|
435
|
+
}
|
|
436
|
+
manifest[stem] = fetch_paged(MET_CMD[ch], body, stem, bl_dir)
|
|
437
|
+
|
|
438
|
+
# 只有全部所需数据完成后登记清单,汇总前按清单核对文件和数量。
|
|
439
|
+
(req / 'fetch-manifest.json').write_text(json.dumps(manifest), encoding='utf-8')
|
|
440
|
+
return biz
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def main() -> int:
|
|
444
|
+
fetch_business(argument_parser().parse_args())
|
|
445
|
+
return 0
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
if __name__ == "__main__":
|
|
449
|
+
sys.exit(main())
|