addashboard-cli 0.5.1 → 0.6.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/dist/commands/submission.js +41 -3
- package/dist/commands/submission.js.map +1 -1
- package/dist/index.js +0 -0
- package/dist/lib/apiClient.js +4 -0
- package/dist/lib/apiClient.js.map +1 -1
- package/package.json +2 -2
- package/skills/ad-ops/SKILL.md +102 -18
- package/skills/ad-ops/ad-planner.html +175 -86
- package/skills/ad-ops/scripts/adops.sh +106 -0
- package/skills/ad-ops/scripts/plan_lint.py +297 -0
- package/skills/ad-ops//343/202/242/343/202/257/343/203/206/343/202/243/343/203/226/343/203/227/343/203/251/343/203/263.json +6 -0
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""入稿計画JSONの回収・要約・lint・合意差分を1コマンドで行う。
|
|
3
|
+
|
|
4
|
+
`adops.sh plan` から呼ばれる。単体でも動く:
|
|
5
|
+
|
|
6
|
+
python3 plan_lint.py [<入稿計画.json>]
|
|
7
|
+
|
|
8
|
+
引数を省略すると ~/Downloads と 入稿計画/ の中で最も新しい入稿計画を拾う。
|
|
9
|
+
Downloads にあったものは 入稿計画/ へリネームして移動する。
|
|
10
|
+
|
|
11
|
+
やること:
|
|
12
|
+
1. 回収 — DLに落ちた計画を 入稿計画/<案件名>_<CP名>_入稿計画_<timestamp>.json へ移動
|
|
13
|
+
2. 要約 — CP > 広告グループ > 広告 を1画面の表で出す
|
|
14
|
+
3. lint — dry-run が検出しない不備(相対finalUrl・paramCode衝突など)を拾う
|
|
15
|
+
4. 差分 — サンプルプラン.json(=合意した内容の正)と突き合わせ、違う項目だけ出す
|
|
16
|
+
"""
|
|
17
|
+
import glob
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import re
|
|
21
|
+
import shutil
|
|
22
|
+
import sys
|
|
23
|
+
import urllib.parse
|
|
24
|
+
|
|
25
|
+
SKILL_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
26
|
+
PLAN_DIR = os.path.join(SKILL_DIR, "入稿計画")
|
|
27
|
+
DOWNLOADS = os.path.expanduser("~/Downloads")
|
|
28
|
+
|
|
29
|
+
# 新規モードの既定 12,000円 / 既存ASC の 5〜10万円 を包含するレンジ。
|
|
30
|
+
# 外れたら桁間違いを疑う(エラーではなく確認を促す)。
|
|
31
|
+
BUDGET_MIN, BUDGET_MAX = 3_000, 200_000
|
|
32
|
+
SUSPICIOUS_NAME = re.compile(r"テスト|test|サンプル|sample|dummy|仮", re.I)
|
|
33
|
+
|
|
34
|
+
errors: list[str] = []
|
|
35
|
+
warns: list[str] = []
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def err(msg: str) -> None:
|
|
39
|
+
errors.append(msg)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def warn(msg: str) -> None:
|
|
43
|
+
warns.append(msg)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# --------------------------------------------------------------- 1. 回収
|
|
47
|
+
def newest(pattern: str) -> str | None:
|
|
48
|
+
hits = glob.glob(pattern)
|
|
49
|
+
return max(hits, key=os.path.getmtime) if hits else None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def load_account(platform: str, account_id: str) -> dict:
|
|
53
|
+
"""accountId からアカウントJSONを引く。見つからなければ空dict。
|
|
54
|
+
|
|
55
|
+
sample_account.json は雛形として実アカウントと同じ accountId を持つため、
|
|
56
|
+
実データを優先して最後に回す(先に拾うと案件名が 'sample_account' になる)。
|
|
57
|
+
"""
|
|
58
|
+
paths = [
|
|
59
|
+
p for p in glob.glob(os.path.join(SKILL_DIR, "アカウント", platform, "*.json"))
|
|
60
|
+
if os.path.basename(p) != "index.json"
|
|
61
|
+
]
|
|
62
|
+
paths.sort(key=lambda p: os.path.basename(p) == "sample_account.json")
|
|
63
|
+
for path in paths:
|
|
64
|
+
try:
|
|
65
|
+
data = json.load(open(path, encoding="utf-8"))
|
|
66
|
+
except Exception:
|
|
67
|
+
continue
|
|
68
|
+
if str(data.get("accountId", "")) == str(account_id):
|
|
69
|
+
return data
|
|
70
|
+
return {}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def collect(path: str, plan: dict) -> str:
|
|
74
|
+
"""Downloads にある計画を 入稿計画/ へリネーム移動する。移動後のパスを返す。"""
|
|
75
|
+
if os.path.dirname(os.path.abspath(path)) != os.path.abspath(DOWNLOADS):
|
|
76
|
+
return path
|
|
77
|
+
|
|
78
|
+
platform = plan.get("platform", "yda")
|
|
79
|
+
account = load_account(platform, plan.get("accountId", ""))
|
|
80
|
+
project = account.get("name") or plan.get("accountId", "plan")
|
|
81
|
+
campaigns = plan.get("campaigns") or []
|
|
82
|
+
cp = (campaigns[0].get("name") if campaigns else "") or "no-campaign"
|
|
83
|
+
stamp = re.search(r"(\d{8}-\d{6})", os.path.basename(path))
|
|
84
|
+
stamp = stamp.group(1) if stamp else "unknown"
|
|
85
|
+
|
|
86
|
+
safe = lambda s: re.sub(r"[/\\:\s]+", "_", str(s)).strip("_")
|
|
87
|
+
dest = os.path.join(PLAN_DIR, f"{safe(project)}_{safe(cp)}_入稿計画_{stamp}.json")
|
|
88
|
+
os.makedirs(PLAN_DIR, exist_ok=True)
|
|
89
|
+
shutil.move(path, dest)
|
|
90
|
+
print(f"📥 回収: ~/Downloads → 入稿計画/{os.path.basename(dest)}")
|
|
91
|
+
return dest
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def pick_plan(argv: list[str]) -> str:
|
|
95
|
+
if argv:
|
|
96
|
+
if not os.path.exists(argv[0]):
|
|
97
|
+
sys.exit(f"✗ ファイルが見つかりません: {argv[0]}")
|
|
98
|
+
return argv[0]
|
|
99
|
+
candidates = [
|
|
100
|
+
p
|
|
101
|
+
for p in (
|
|
102
|
+
newest(os.path.join(DOWNLOADS, "*入稿計画*.json")),
|
|
103
|
+
newest(os.path.join(PLAN_DIR, "*.json")),
|
|
104
|
+
)
|
|
105
|
+
if p
|
|
106
|
+
]
|
|
107
|
+
if not candidates:
|
|
108
|
+
sys.exit("✗ 入稿計画JSONが見つかりません(~/Downloads にも 入稿計画/ にも)")
|
|
109
|
+
return max(candidates, key=os.path.getmtime)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ------------------------------------------------------- 共通: 値の取り出し
|
|
113
|
+
def param_code_of(ad: dict, group: dict) -> str:
|
|
114
|
+
"""広告グループの paramCode。export形式には無いので finalUrl の ?ad= から復元する。"""
|
|
115
|
+
if group.get("paramCode"):
|
|
116
|
+
return str(group["paramCode"])
|
|
117
|
+
url = ad.get("finalUrl") or ""
|
|
118
|
+
q = urllib.parse.parse_qs(urllib.parse.urlparse(url).query)
|
|
119
|
+
return (q.get("ad") or [""])[0]
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def image_no_of(ad: dict) -> str:
|
|
123
|
+
"""creative パラメータ '<code>-1047c-45L60-<code>' から画像番号を復元する。"""
|
|
124
|
+
if ad.get("imageNo"):
|
|
125
|
+
return str(ad["imageNo"])
|
|
126
|
+
url = ad.get("finalUrl") or ""
|
|
127
|
+
q = urllib.parse.parse_qs(urllib.parse.urlparse(url).query)
|
|
128
|
+
m = re.search(r"-(\d+)[a-z]-", (q.get("creative") or [""])[0])
|
|
129
|
+
return m.group(1) if m else ""
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def ad_groups(campaign: dict) -> list:
|
|
133
|
+
return campaign.get("adGroups") or campaign.get("adSets") or []
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# --------------------------------------------------------------- 2. 要約
|
|
137
|
+
def summarize(plan: dict) -> None:
|
|
138
|
+
print(f"\n── 入稿計画 ── platform={plan.get('platform', 'yda')} "
|
|
139
|
+
f"accountId={plan.get('accountId')} planVersion={plan.get('planVersion')}")
|
|
140
|
+
for ci, c in enumerate(plan.get("campaigns") or []):
|
|
141
|
+
bid = c.get("biddingStrategy", "")
|
|
142
|
+
if c.get("targetCpa"):
|
|
143
|
+
bid += f" (tCPA {c['targetCpa']:,})"
|
|
144
|
+
print(f"\n CP[{ci}] {c.get('name')}")
|
|
145
|
+
print(f" 予算 {c.get('budget', 0):,}/日 | {bid} | status {c.get('status')} "
|
|
146
|
+
f"| 開始 {c.get('startDate', '-')}")
|
|
147
|
+
for g in ad_groups(c):
|
|
148
|
+
tg = "/".join(filter(None, [
|
|
149
|
+
",".join(g.get("devices") or g.get("devicePlatforms") or []) or "全デバイス",
|
|
150
|
+
",".join(g.get("ages") or []) or "全年齢",
|
|
151
|
+
",".join(g.get("genders") or []) or "全性別",
|
|
152
|
+
]))
|
|
153
|
+
print(f" AG {g.get('name')} | {tg} | status {g.get('status')}")
|
|
154
|
+
for a in g.get("ads") or []:
|
|
155
|
+
media = ",".join(a.get("mediaIds") or []) or a.get("videoId") or a.get("imageHash") or "-"
|
|
156
|
+
head = (a.get("headlines") or ["(空)"])[0]
|
|
157
|
+
print(f" AD media {media} | 画像No {image_no_of(a) or '-'} | {head}")
|
|
158
|
+
print(f" {a.get('finalUrl')}")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# --------------------------------------------------------------- 3. lint
|
|
162
|
+
def lint(plan: dict) -> None:
|
|
163
|
+
platform = plan.get("platform", "yda")
|
|
164
|
+
account = load_account(platform, plan.get("accountId", ""))
|
|
165
|
+
base = account.get("baseArticleUrl") or ""
|
|
166
|
+
base_host = urllib.parse.urlparse(base).netloc if base else ""
|
|
167
|
+
known_codes = {
|
|
168
|
+
str(v.get("paramCode"))
|
|
169
|
+
for v in (account.get("refs", {}).get("adGroups") or {}).values()
|
|
170
|
+
if v.get("paramCode")
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if not base:
|
|
174
|
+
warn(f"アカウントJSONに baseArticleUrl がありません({platform}/{plan.get('accountId')})。"
|
|
175
|
+
" finalUrl が相対パスになる原因になります")
|
|
176
|
+
|
|
177
|
+
seen_media: dict[str, str] = {}
|
|
178
|
+
for c in plan.get("campaigns") or []:
|
|
179
|
+
name = c.get("name", "")
|
|
180
|
+
budget = c.get("budget") or 0
|
|
181
|
+
if not (BUDGET_MIN <= budget <= BUDGET_MAX):
|
|
182
|
+
warn(f"CP「{name}」の日予算 {budget:,} が想定レンジ外({BUDGET_MIN:,}〜{BUDGET_MAX:,})。桁を確認してください")
|
|
183
|
+
if c.get("status") == "ACTIVE":
|
|
184
|
+
warn(f"CP「{name}」が ACTIVE です。初回・PoC は PAUSED(配信オフ)を推奨します")
|
|
185
|
+
if SUSPICIOUS_NAME.search(name):
|
|
186
|
+
warn(f"CP名「{name}」に検証用らしき語が残っています。本番入稿の命名か確認してください")
|
|
187
|
+
|
|
188
|
+
for g in ad_groups(c):
|
|
189
|
+
gname = g.get("name", "")
|
|
190
|
+
if SUSPICIOUS_NAME.search(gname):
|
|
191
|
+
warn(f"広告グループ名「{gname}」に検証用らしき語が残っています")
|
|
192
|
+
for a in g.get("ads") or []:
|
|
193
|
+
url = a.get("finalUrl") or ""
|
|
194
|
+
if not re.match(r"https?://", url):
|
|
195
|
+
err(f"CP「{name}」の finalUrl が相対パスです: {url}\n"
|
|
196
|
+
f" → アカウントJSONの baseArticleUrl 未設定。"
|
|
197
|
+
f"**dry-run はこれを検出しません**")
|
|
198
|
+
elif base_host and urllib.parse.urlparse(url).netloc != base_host:
|
|
199
|
+
warn(f"CP「{name}」の finalUrl のホストがアカウントの baseArticleUrl "
|
|
200
|
+
f"({base_host}) と違います: {urllib.parse.urlparse(url).netloc}")
|
|
201
|
+
|
|
202
|
+
media = a.get("mediaIds") or []
|
|
203
|
+
if not media and not a.get("videoId") and not a.get("imageHash"):
|
|
204
|
+
err(f"CP「{name}」の広告にクリエイティブがありません(mediaIds が空)")
|
|
205
|
+
for m in media:
|
|
206
|
+
if m in seen_media:
|
|
207
|
+
warn(f"mediaId {m} が複数の広告で使われています"
|
|
208
|
+
f"({seen_media[m]} / {name})。画像検証なら重複は想定外です")
|
|
209
|
+
else:
|
|
210
|
+
seen_media[m] = name
|
|
211
|
+
|
|
212
|
+
code = param_code_of(a, g)
|
|
213
|
+
if not code:
|
|
214
|
+
err(f"CP「{name}」の paramCode が特定できません")
|
|
215
|
+
elif code in known_codes:
|
|
216
|
+
err(f"paramCode {code} はアカウントの既存広告グループと重複しています"
|
|
217
|
+
f"({name})。新規なら未使用の番号に変えてください")
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
# --------------------------------------------------------------- 4. 差分
|
|
221
|
+
def flatten(plan: dict, is_sample: bool) -> dict:
|
|
222
|
+
"""サンプルプラン(schemaVersion 3)と入稿計画(planVersion 1)を同じ形に潰して比較可能にする。"""
|
|
223
|
+
out: dict[str, object] = {"CP数": len(plan.get("campaigns") or [])}
|
|
224
|
+
for ci, c in enumerate(plan.get("campaigns") or []):
|
|
225
|
+
p = f"CP[{ci}]"
|
|
226
|
+
out[f"{p}.name"] = c.get("name")
|
|
227
|
+
out[f"{p}.budget"] = c.get("budget")
|
|
228
|
+
out[f"{p}.tCPA"] = c.get("tcpa") if is_sample else c.get("targetCpa")
|
|
229
|
+
groups = ad_groups(c)
|
|
230
|
+
out[f"{p}.広告グループ数"] = len(groups)
|
|
231
|
+
for gi, g in enumerate(groups):
|
|
232
|
+
gp = f"{p}.AG[{gi}]"
|
|
233
|
+
out[f"{gp}.name"] = g.get("name")
|
|
234
|
+
ads = g.get("ads") or []
|
|
235
|
+
out[f"{gp}.広告数"] = len(ads)
|
|
236
|
+
for ai, a in enumerate(ads):
|
|
237
|
+
out[f"{gp}.AD[{ai}].paramCode"] = param_code_of(a, g)
|
|
238
|
+
out[f"{gp}.AD[{ai}].画像No"] = image_no_of(a)
|
|
239
|
+
return out
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def diff_against_sample(plan: dict) -> None:
|
|
243
|
+
platform = plan.get("platform", "yda")
|
|
244
|
+
sample_path = os.path.join(
|
|
245
|
+
SKILL_DIR, "サンプルプラン_meta.json" if platform == "meta" else "サンプルプラン.json"
|
|
246
|
+
)
|
|
247
|
+
if not os.path.exists(sample_path):
|
|
248
|
+
print("\n(サンプルプランが無いため差分比較をスキップしました)")
|
|
249
|
+
return
|
|
250
|
+
sample = json.load(open(sample_path, encoding="utf-8"))
|
|
251
|
+
if str(sample.get("accountId")) != str(plan.get("accountId")):
|
|
252
|
+
print(f"\n⚠ サンプルプランのaccountId({sample.get('accountId')})と入稿計画"
|
|
253
|
+
f"({plan.get('accountId')})が違うため差分比較をスキップしました")
|
|
254
|
+
return
|
|
255
|
+
|
|
256
|
+
a, b = flatten(sample, True), flatten(plan, False)
|
|
257
|
+
rows = [(k, a.get(k, "—"), b.get(k, "—")) for k in dict.fromkeys([*a, *b])
|
|
258
|
+
if str(a.get(k, "—")) != str(b.get(k, "—"))]
|
|
259
|
+
|
|
260
|
+
print("\n── 合意(サンプルプラン)との差分 ──")
|
|
261
|
+
if not rows:
|
|
262
|
+
print(" ✓ 差分なし(合意どおり)")
|
|
263
|
+
return
|
|
264
|
+
width = max(len(k) for k, _, _ in rows)
|
|
265
|
+
for k, want, got in rows:
|
|
266
|
+
print(f" {k.ljust(width)} {want} → {got}")
|
|
267
|
+
print(" ↑ この差分だけをユーザーに確認すること(全項目の読み上げはしない)")
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
# ---------------------------------------------------------------- main
|
|
271
|
+
def main() -> int:
|
|
272
|
+
path = pick_plan(sys.argv[1:])
|
|
273
|
+
plan = json.load(open(path, encoding="utf-8"))
|
|
274
|
+
path = collect(path, plan)
|
|
275
|
+
print(f"📄 {path}")
|
|
276
|
+
|
|
277
|
+
summarize(plan)
|
|
278
|
+
lint(plan)
|
|
279
|
+
diff_against_sample(plan)
|
|
280
|
+
|
|
281
|
+
print("\n── lint(dry-run が検出しない項目)──")
|
|
282
|
+
for m in errors:
|
|
283
|
+
print(f" ERROR {m}")
|
|
284
|
+
for m in warns:
|
|
285
|
+
print(f" WARN {m}")
|
|
286
|
+
if not errors and not warns:
|
|
287
|
+
print(" ✓ 問題なし")
|
|
288
|
+
|
|
289
|
+
if errors:
|
|
290
|
+
print(f"\n✗ ERROR {len(errors)}件。修正するまで本実行しないこと")
|
|
291
|
+
return 1
|
|
292
|
+
print(f"\n✓ ERROR なし{f'(WARN {len(warns)}件は要確認)' if warns else ''}")
|
|
293
|
+
return 0
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
if __name__ == "__main__":
|
|
297
|
+
sys.exit(main())
|