addashboard-cli 0.9.1 → 0.9.3
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 +3 -0
- package/dist/commands/ads.js +4 -7
- package/dist/commands/ads.js.map +1 -1
- package/dist/commands/authDevice.js +10 -2
- package/dist/commands/authDevice.js.map +1 -1
- package/dist/commands/authGoogle.js +39 -42
- package/dist/commands/authGoogle.js.map +1 -1
- package/dist/commands/freee.js +2 -18
- package/dist/commands/freee.js.map +1 -1
- package/dist/commands/pl.js +4 -8
- package/dist/commands/pl.js.map +1 -1
- package/dist/commands/review.js +4 -8
- package/dist/commands/review.js.map +1 -1
- package/dist/commands/status.js +188 -0
- package/dist/commands/status.js.map +1 -0
- package/dist/commands/submission.js +236 -21
- package/dist/commands/submission.js.map +1 -1
- package/dist/index.js +86 -31
- package/dist/index.js.map +1 -1
- package/dist/lib/apiError.js +63 -0
- package/dist/lib/apiError.js.map +1 -0
- package/dist/lib/authFlow.js +82 -0
- package/dist/lib/authFlow.js.map +1 -0
- package/dist/lib/googleImageMetadata.js +145 -0
- package/dist/lib/googleImageMetadata.js.map +1 -0
- package/dist/lib/openBrowser.js +63 -0
- package/dist/lib/openBrowser.js.map +1 -0
- package/dist/schemas/submit-plan.js +27 -1
- package/dist/schemas/submit-plan.js.map +1 -1
- package/package.json +4 -3
- package/skills/ad-ops/SKILL.md +108 -10
- package/skills/ad-ops/ad-planner.html +257 -20
- package/skills/ad-ops/scripts/adops.sh +39 -9
- package/skills/ad-ops/scripts/plan_lint.py +262 -28
- package/skills/ad-ops/scripts/refs_import.py +251 -20
- package/skills/ad-ops/scripts/test_plan_lint.sh +126 -0
- package/skills/ad-ops//343/202/242/343/202/253/343/202/246/343/203/263/343/203/210/google/sample_account.json +4 -0
|
@@ -25,10 +25,12 @@ import glob
|
|
|
25
25
|
import json
|
|
26
26
|
import os
|
|
27
27
|
import re
|
|
28
|
+
import shutil
|
|
28
29
|
import subprocess
|
|
29
30
|
import sys
|
|
30
31
|
import unicodedata
|
|
31
32
|
import urllib.parse
|
|
33
|
+
from datetime import datetime
|
|
32
34
|
|
|
33
35
|
SKILL_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
34
36
|
CLI = os.environ.get("ADOPS_CLI", "npx -y addashboard-cli@latest")
|
|
@@ -51,8 +53,12 @@ def int_keys(d: dict) -> list[int]:
|
|
|
51
53
|
return out
|
|
52
54
|
|
|
53
55
|
|
|
54
|
-
def run_list(resource: str, account_id: str, **opts) -> dict:
|
|
55
|
-
"""webgram list を1回叩いて JSON
|
|
56
|
+
def run_list(resource: str, account_id: str, **opts) -> dict | None:
|
|
57
|
+
"""webgram list を1回叩いて JSON を返す。**失敗したら None** + stderr に理由。
|
|
58
|
+
|
|
59
|
+
「取得できて0件だった」と「そもそも取れなかった」は区別する。潰すと、
|
|
60
|
+
取得の失敗を「媒体側から消えた」と誤読して stale を付けてしまう。
|
|
61
|
+
"""
|
|
56
62
|
cmd = [*CLI.split(), "list", "--platform", opts.pop("platform", "yda"),
|
|
57
63
|
"--resource", resource, "--account-id", account_id]
|
|
58
64
|
for k, v in opts.items():
|
|
@@ -63,16 +69,16 @@ def run_list(resource: str, account_id: str, **opts) -> dict:
|
|
|
63
69
|
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
|
|
64
70
|
except Exception as e: # noqa: BLE001
|
|
65
71
|
print(f" ✗ {resource} の取得に失敗: {e}", file=sys.stderr)
|
|
66
|
-
return
|
|
72
|
+
return None
|
|
67
73
|
if proc.returncode != 0:
|
|
68
74
|
print(f" ✗ {resource} の取得に失敗 (exit {proc.returncode}): "
|
|
69
75
|
f"{proc.stderr.strip()[:400]}", file=sys.stderr)
|
|
70
|
-
return
|
|
76
|
+
return None
|
|
71
77
|
try:
|
|
72
78
|
return json.loads(proc.stdout)
|
|
73
79
|
except Exception as e: # noqa: BLE001
|
|
74
80
|
print(f" ✗ {resource} の応答を解釈できません: {e}", file=sys.stderr)
|
|
75
|
-
return
|
|
81
|
+
return None
|
|
76
82
|
|
|
77
83
|
|
|
78
84
|
def find_account(platform: str, account_id: str) -> tuple[str | None, dict]:
|
|
@@ -135,8 +141,8 @@ def import_yda(account_id: str, campaign_id: str | None) -> dict:
|
|
|
135
141
|
"""YDA から取り込んだ生データを {ads, placements, audiences, campaigns} で返す。"""
|
|
136
142
|
out: dict = {"ads": [], "placements": {}, "audiences": [], "campaigns": {}}
|
|
137
143
|
|
|
138
|
-
camp = run_list("campaigns", account_id, platform="yda")
|
|
139
|
-
out["campaigns"] = camp
|
|
144
|
+
camp = run_list("campaigns", account_id, platform="yda") or {}
|
|
145
|
+
out["campaigns"] = camp
|
|
140
146
|
|
|
141
147
|
ad_kwargs = {}
|
|
142
148
|
if campaign_id:
|
|
@@ -147,21 +153,200 @@ def import_yda(account_id: str, campaign_id: str | None) -> dict:
|
|
|
147
153
|
for c in (camp.get("campaigns") or []) if (c.get("campaignId") or c.get("id"))]
|
|
148
154
|
ads: list = []
|
|
149
155
|
for cid in ids:
|
|
150
|
-
r = run_list("ads", account_id, platform="yda", **{"campaign-id": cid})
|
|
156
|
+
r = run_list("ads", account_id, platform="yda", **{"campaign-id": cid}) or {}
|
|
151
157
|
ads += r.get("ads") or []
|
|
152
158
|
out["ads"] = ads
|
|
153
|
-
res = run_list("resources", account_id, platform="yda")
|
|
159
|
+
res = run_list("resources", account_id, platform="yda") or {}
|
|
154
160
|
out["placements"] = res.get("placements") or {}
|
|
155
161
|
out["audiences"] = res.get("audiences") or []
|
|
156
162
|
return out
|
|
157
163
|
|
|
158
|
-
out["ads"] = (run_list("ads", account_id, platform="yda", **ad_kwargs)).get("ads") or []
|
|
159
|
-
res = run_list("resources", account_id, platform="yda")
|
|
164
|
+
out["ads"] = (run_list("ads", account_id, platform="yda", **ad_kwargs) or {}).get("ads") or []
|
|
165
|
+
res = run_list("resources", account_id, platform="yda") or {}
|
|
160
166
|
out["placements"] = res.get("placements") or {}
|
|
161
167
|
out["audiences"] = res.get("audiences") or []
|
|
162
168
|
return out
|
|
163
169
|
|
|
164
170
|
|
|
171
|
+
def import_google(account_id: str) -> dict:
|
|
172
|
+
"""Google (Demand Gen) の参照データを取得する。
|
|
173
|
+
|
|
174
|
+
`/google/resources` が既存 Demand Gen 広告と画像アセットの両方から集めて
|
|
175
|
+
`businessNames` / `longHeadlines` / `logos` / `logoCandidates` を返す。
|
|
176
|
+
|
|
177
|
+
キャンペーン名も取る。**Google は同名キャンペーンを拒否する**ので、
|
|
178
|
+
プランナーが書き出し前に重複を弾くための材料になる。
|
|
179
|
+
"""
|
|
180
|
+
out = run_list("resources", account_id, platform="google") or {}
|
|
181
|
+
camp = run_list("campaigns", account_id, platform="google")
|
|
182
|
+
# **取得に失敗したら None のまま渡す**(`[]` にしない)。`[]` は「見たけど0件」で、
|
|
183
|
+
# merge_google がそれを「媒体側から全部消えた」と読み、既存CPを全部 stale にする。
|
|
184
|
+
# stale はプランナーも plan_lint も判定から外すので、一度の失敗で同名ガードが丸ごと死ぬ
|
|
185
|
+
out["campaigns"] = None if camp is None else (camp.get("campaigns") or [])
|
|
186
|
+
out["campaignsFetchFailed"] = camp is None
|
|
187
|
+
# 上限で切れていたら「一覧に無い = 空いている」と読めない。呼び出し側へ伝える
|
|
188
|
+
out["campaignsTruncated"] = bool(camp.get("truncated")) if camp else False
|
|
189
|
+
return out
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def merge_google(account: dict, fetched: dict) -> tuple[dict, list[str]]:
|
|
193
|
+
"""Google の参照データをアカウントJSONへマージする。
|
|
194
|
+
|
|
195
|
+
YDA と同じ原則: **既存の番号を振り直さない・既存の値を消さない**。
|
|
196
|
+
`logoAsset` / `businessName` は既に入っていれば触らない(人が選んだ値を
|
|
197
|
+
自動取得で上書きしない)。空のときだけ候補を埋める。
|
|
198
|
+
"""
|
|
199
|
+
acc = json.loads(json.dumps(account)) # deep copy
|
|
200
|
+
refs = acc.setdefault("refs", {})
|
|
201
|
+
report: list[str] = []
|
|
202
|
+
|
|
203
|
+
# --- 長見出し(Demand Gen 動画広告だけが持つ。使用回数の多い順で採番)
|
|
204
|
+
long_headlines = [t.get("text", "") for t in (fetched.get("longHeadlines") or [])]
|
|
205
|
+
lh, lh_stats = assign_numbers(refs.get("longHeadlines") or {}, long_headlines)
|
|
206
|
+
refs["longHeadlines"] = lh
|
|
207
|
+
report.append(f"長見出し 追加 {lh_stats['added']} / 既存一致 {lh_stats['reused']}"
|
|
208
|
+
f"(合計 {len(lh)})")
|
|
209
|
+
|
|
210
|
+
# --- ロゴ: resource_name をキーにする(番号採番しない)
|
|
211
|
+
# アセットの resource_name は Google 側で一意なので、番号を振ると
|
|
212
|
+
# 「同じロゴに2つの番号」が生まれて参照がぶれる。
|
|
213
|
+
logos = refs.setdefault("logos", {})
|
|
214
|
+
logo_added = 0
|
|
215
|
+
for logo in fetched.get("logos") or []:
|
|
216
|
+
rn = str(logo.get("asset") or "")
|
|
217
|
+
if not rn or rn in logos:
|
|
218
|
+
continue
|
|
219
|
+
logos[rn] = {
|
|
220
|
+
"name": logo.get("name") or "",
|
|
221
|
+
"width": logo.get("width") or 0,
|
|
222
|
+
"height": logo.get("height") or 0,
|
|
223
|
+
"usedAsLogo": True,
|
|
224
|
+
}
|
|
225
|
+
logo_added += 1
|
|
226
|
+
report.append(f"ロゴ 追加 {logo_added}(合計 {len(logos)}・使用実績あり)")
|
|
227
|
+
|
|
228
|
+
candidates = fetched.get("logoCandidates") or []
|
|
229
|
+
if candidates:
|
|
230
|
+
report.append(f"ロゴ候補 {len(candidates)} 件(1:1・128px 以上で未使用。"
|
|
231
|
+
f"取り込まないので必要なら手で refs.logos へ)")
|
|
232
|
+
|
|
233
|
+
# --- ブランド名 / ロゴ: 未設定のときだけ候補を入れる
|
|
234
|
+
# 既に人が設定している値を自動取得で書き換えない(意図が消える)
|
|
235
|
+
names = [t.get("text", "") for t in (fetched.get("businessNames") or [])]
|
|
236
|
+
if not str(acc.get("businessName") or "").strip() and names:
|
|
237
|
+
acc["businessName"] = names[0] # 使用回数が最多のもの
|
|
238
|
+
report.append(f"ブランド名 未設定だったので補完: {names[0]}")
|
|
239
|
+
elif names:
|
|
240
|
+
cur = acc.get("businessName")
|
|
241
|
+
others = [n for n in names if n != cur]
|
|
242
|
+
report.append(f"ブランド名 既存 {cur} を維持"
|
|
243
|
+
+ (f"(他の候補: {', '.join(others[:3])})" if others else ""))
|
|
244
|
+
|
|
245
|
+
if not str(acc.get("logoAsset") or "").strip() and logos:
|
|
246
|
+
acc["logoAsset"] = next(iter(logos))
|
|
247
|
+
report.append(f"logoAsset 未設定だったので補完: {acc['logoAsset']}")
|
|
248
|
+
elif acc.get("logoAsset"):
|
|
249
|
+
report.append(f"logoAsset 既存 {acc['logoAsset']} を維持")
|
|
250
|
+
|
|
251
|
+
# --- キャンペーン: ID → name(同名チェックの材料。プランナーが書き出し前に弾く)
|
|
252
|
+
cps = refs.setdefault("campaigns", {})
|
|
253
|
+
cp_added = 0
|
|
254
|
+
live_ids = set()
|
|
255
|
+
for c in fetched.get("campaigns") or []:
|
|
256
|
+
cid = str(c.get("id") or c.get("campaignId") or "")
|
|
257
|
+
if not cid:
|
|
258
|
+
continue
|
|
259
|
+
live_ids.add(cid)
|
|
260
|
+
if cid not in cps:
|
|
261
|
+
cps[cid] = {}
|
|
262
|
+
cp_added += 1
|
|
263
|
+
if c.get("name"):
|
|
264
|
+
cps[cid]["name"] = str(c["name"])
|
|
265
|
+
if c.get("status"):
|
|
266
|
+
cps[cid]["status"] = str(c["status"])
|
|
267
|
+
cps[cid].pop("stale", None)
|
|
268
|
+
|
|
269
|
+
if fetched.get("campaigns") is not None:
|
|
270
|
+
# 消えたCPは削除せず stale を付ける(過去プランが参照しているかもしれない)。
|
|
271
|
+
# **同名チェックは stale を見ない** — 削除済みCPの名前は再利用できるので、
|
|
272
|
+
# 残したままだと使える名前を「重複」と誤判定する。
|
|
273
|
+
# 上限で切れているときは「消えた」と判断できないので触らない
|
|
274
|
+
cp_stale = 0
|
|
275
|
+
if not fetched.get("campaignsTruncated"):
|
|
276
|
+
for cid, row in cps.items():
|
|
277
|
+
if cid not in live_ids:
|
|
278
|
+
row["stale"] = True
|
|
279
|
+
cp_stale += 1
|
|
280
|
+
report.append(f"キャンペーン 新規 {cp_added} / stale {cp_stale}(合計 {len(cps)})"
|
|
281
|
+
+ (" ※stale は過去プランが参照している可能性があるため削除しません"
|
|
282
|
+
if cp_stale else ""))
|
|
283
|
+
elif fetched.get("campaignsFetchFailed"):
|
|
284
|
+
# 取得を試みて落ちた。stale を触らないだけでは気づけないので、報告に必ず出す
|
|
285
|
+
# (--from-plan は実 API を見ないので、こちらには入らない)
|
|
286
|
+
report.append("⚠ キャンペーン一覧を取得できませんでした。同名チェックの材料は更新していません"
|
|
287
|
+
"(既存の記録はそのまま残しています)")
|
|
288
|
+
if fetched.get("campaignsTruncated"):
|
|
289
|
+
report.append("⚠ キャンペーンが取得上限を超えました。"
|
|
290
|
+
"同名チェックの取りこぼしがありえます(一覧に無くても既存かもしれません)")
|
|
291
|
+
|
|
292
|
+
# 取得側が上限で切れていたら黙って進まない。
|
|
293
|
+
# 「一覧に無い=存在しない」と読んで、実在するロゴを作り直す事故につながる。
|
|
294
|
+
summary = fetched.get("summary") or {}
|
|
295
|
+
if summary.get("adsTruncated"):
|
|
296
|
+
report.append("⚠ 既存 Demand Gen 広告が取得上限を超えました。"
|
|
297
|
+
"ブランド名・長見出し・ロゴの使用実績は全量ではありません")
|
|
298
|
+
if summary.get("assetsTruncated"):
|
|
299
|
+
report.append("⚠ 画像アセットが取得上限を超えました。ロゴ候補は全量ではありません")
|
|
300
|
+
if fetched.get("message"):
|
|
301
|
+
report.append(f"(API からの注意: {fetched['message']})")
|
|
302
|
+
|
|
303
|
+
return acc, report
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def import_from_plan(path: str, account_id: str) -> dict:
|
|
307
|
+
"""入稿計画JSONの `uploadedAssets` を読む。**実 API を叩かない**。
|
|
308
|
+
|
|
309
|
+
本実行でアップロードしたロゴは Google 側に存在するが、アカウントJSONには
|
|
310
|
+
載っていない。アセットは削除できないので、取りこぼすと同じロゴを何度も
|
|
311
|
+
作ることになる。CLI が入稿の途中で直接アカウントJSONを書かない設計なので、
|
|
312
|
+
ここで明示的に取り込む。
|
|
313
|
+
"""
|
|
314
|
+
try:
|
|
315
|
+
plan = json.load(open(path, encoding="utf-8"))
|
|
316
|
+
except Exception as e: # noqa: BLE001
|
|
317
|
+
sys.exit(f"✗ 入稿計画JSONを読めません: {path} ({e})")
|
|
318
|
+
|
|
319
|
+
platform = str(plan.get("platform") or "yda")
|
|
320
|
+
if platform != "google":
|
|
321
|
+
sys.exit(f"✗ --from-plan は google の計画のみ対応です(この計画は {platform})")
|
|
322
|
+
|
|
323
|
+
# accountId 欠落は「どのアカウントの計画か判別できない」ので止める。
|
|
324
|
+
# 書き込み先が実クライアントの参照データなので、planner の fail-safe と揃える
|
|
325
|
+
plan_account = str(plan.get("accountId") or plan.get("customerId") or "")
|
|
326
|
+
if not plan_account:
|
|
327
|
+
sys.exit("✗ 計画に accountId がありません。どのアカウントのものか判別できないため中止します")
|
|
328
|
+
if plan_account != str(account_id):
|
|
329
|
+
sys.exit(f"✗ 計画のアカウント({plan_account})が指定({account_id})と違います。"
|
|
330
|
+
"取り違えたまま書き込むと別アカウントの参照データが混ざります")
|
|
331
|
+
|
|
332
|
+
logos = ((plan.get("uploadedAssets") or {}).get("google") or {}).get("logos") or []
|
|
333
|
+
valid, skipped = [], []
|
|
334
|
+
for item in logos:
|
|
335
|
+
rn = str((item or {}).get("resourceName") or "")
|
|
336
|
+
# 別 Customer の asset は参照できない。取り込むと選べるように見えて入稿で落ちる
|
|
337
|
+
if re.fullmatch(rf"customers/{re.escape(str(account_id))}/assets/\d+", rn):
|
|
338
|
+
valid.append({"asset": rn, "name": str(item.get("fileName") or ""),
|
|
339
|
+
"usedAsLogo": True})
|
|
340
|
+
elif rn:
|
|
341
|
+
skipped.append(rn)
|
|
342
|
+
|
|
343
|
+
# campaigns は入れない (None のまま)。実 API を叩いていないので、
|
|
344
|
+
# 「0件だった」ではなく「見ていない」を表す
|
|
345
|
+
return {"businessNames": [], "longHeadlines": [], "logos": valid,
|
|
346
|
+
"logoCandidates": [], "_skipped": skipped,
|
|
347
|
+
"summary": {"logos": len(valid)}}
|
|
348
|
+
|
|
349
|
+
|
|
165
350
|
def sort_key(ad: dict) -> tuple:
|
|
166
351
|
"""(adGroupId, adId) 昇順。数値化できないものは末尾へ。"""
|
|
167
352
|
def num(v):
|
|
@@ -312,10 +497,19 @@ def main() -> int:
|
|
|
312
497
|
ap.add_argument("--write", action="store_true", help="アカウントJSONへ書き込む(既定は dry-run)")
|
|
313
498
|
ap.add_argument("--campaign-id", dest="campaign_id", default=None,
|
|
314
499
|
help="対象キャンペーンを絞る(省略時は全CP)")
|
|
500
|
+
ap.add_argument("--from-plan", dest="from_plan", default=None, metavar="PLAN.json",
|
|
501
|
+
help="入稿計画JSONに記録されたアップロード済みアセットを取り込む"
|
|
502
|
+
"(実APIを叩かない。google のみ)")
|
|
315
503
|
args = ap.parse_args()
|
|
316
504
|
|
|
317
|
-
if args.platform
|
|
318
|
-
sys.exit(f"✗ refs-import
|
|
505
|
+
if args.platform not in ("yda", "google"):
|
|
506
|
+
sys.exit(f"✗ refs-import は yda / google のみ対応です(指定: {args.platform})")
|
|
507
|
+
if args.from_plan and args.platform != "google":
|
|
508
|
+
sys.exit("✗ --from-plan は google のみ対応です")
|
|
509
|
+
# 入力元が別物なので、黙って片方を無視すると「絞ったつもり」の取り違えになる
|
|
510
|
+
if args.from_plan and args.campaign_id:
|
|
511
|
+
sys.exit("✗ --from-plan と --campaign-id は同時に使えません"
|
|
512
|
+
"(--from-plan は計画ファイルだけを読み、実 API を叩きません)")
|
|
319
513
|
|
|
320
514
|
path, account = find_account(args.platform, args.account_id)
|
|
321
515
|
if not path:
|
|
@@ -323,11 +517,33 @@ def main() -> int:
|
|
|
323
517
|
f"アカウント/{args.platform}/ にありません。先に作成してください")
|
|
324
518
|
print(f"📄 {path}")
|
|
325
519
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
520
|
+
if args.platform == "google":
|
|
521
|
+
if args.from_plan:
|
|
522
|
+
fetched = import_from_plan(args.from_plan, args.account_id)
|
|
523
|
+
if not fetched["logos"]:
|
|
524
|
+
sys.exit("✗ 計画にアップロード済みのロゴがありません"
|
|
525
|
+
"(本実行で logoImagePaths からアップロードした計画を指定してください)")
|
|
526
|
+
else:
|
|
527
|
+
if args.campaign_id:
|
|
528
|
+
print("(google は --campaign-id を使いません。アカウント全体から集めます)",
|
|
529
|
+
file=sys.stderr)
|
|
530
|
+
fetched = import_google(args.account_id)
|
|
531
|
+
# campaigns も判定に入れる。Demand Gen 広告がまだ無いアカウント
|
|
532
|
+
# (=これから初めて入稿する=同名チェックが最も要る状況)でも、
|
|
533
|
+
# キャンペーンが取れていれば取り込む意味がある
|
|
534
|
+
if not (fetched.get("businessNames") or fetched.get("longHeadlines")
|
|
535
|
+
or fetched.get("logos") or fetched.get("logoCandidates")
|
|
536
|
+
or fetched.get("campaigns")):
|
|
537
|
+
sys.exit("✗ 取得できたデータがありません"
|
|
538
|
+
"(ログイン状態・accountId を確認してください)")
|
|
539
|
+
merged, report = merge_google(account, fetched)
|
|
540
|
+
for rn in fetched.get("_skipped") or []:
|
|
541
|
+
report.append(f"⚠ 別アカウントの asset なので取り込みませんでした: {rn}")
|
|
542
|
+
else:
|
|
543
|
+
fetched = import_yda(args.account_id, args.campaign_id)
|
|
544
|
+
if not fetched["ads"] and not fetched["audiences"] and not fetched["placements"]:
|
|
545
|
+
sys.exit("✗ 取得できたデータがありません(ログイン状態・accountId を確認してください)")
|
|
546
|
+
merged, report = merge(account, fetched)
|
|
331
547
|
|
|
332
548
|
print("\n── 取り込み結果 ──")
|
|
333
549
|
for line in report:
|
|
@@ -337,10 +553,25 @@ def main() -> int:
|
|
|
337
553
|
print("\n(dry-run: 書き込んでいません。反映するには --write を付けてください)")
|
|
338
554
|
return 0
|
|
339
555
|
|
|
340
|
-
|
|
556
|
+
# 参照データを飛ばした事例があるので、書き込み前に必ず退避する。
|
|
557
|
+
# 固定名にすると2回目の write で「最初の安全な退避」が消えるため世代を残す。
|
|
558
|
+
# 秒まで同じ連続実行でも既存の退避を壊さないよう、衝突したら連番を足す。
|
|
559
|
+
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
560
|
+
backup, n = f"{path}.{stamp}.bak", 1
|
|
561
|
+
while os.path.exists(backup):
|
|
562
|
+
backup, n = f"{path}.{stamp}-{n}.bak", n + 1
|
|
563
|
+
shutil.copy2(path, backup)
|
|
564
|
+
# 一時ファイルへ書いてから rename する。途中で落ちても元が壊れない
|
|
565
|
+
tmp = f"{path}.tmp"
|
|
566
|
+
with open(tmp, "w", encoding="utf-8") as f:
|
|
341
567
|
json.dump(merged, f, indent=1, ensure_ascii=False)
|
|
342
568
|
f.write("\n")
|
|
343
|
-
|
|
569
|
+
os.replace(tmp, path)
|
|
570
|
+
print(f"\n✓ 書き込みました: {path}")
|
|
571
|
+
print(f" 退避: {os.path.basename(backup)}(元に戻すならこれを上書きコピー)")
|
|
572
|
+
olds = sorted(glob.glob(f"{path}.*.bak"))
|
|
573
|
+
if len(olds) > 1:
|
|
574
|
+
print(f" (過去の退避が {len(olds) - 1} 件残っています。不要なら消してください)")
|
|
344
575
|
return 0
|
|
345
576
|
|
|
346
577
|
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# plan_lint.py の回帰テスト。
|
|
3
|
+
#
|
|
4
|
+
# bash skills/ad-ops/scripts/test_plan_lint.sh
|
|
5
|
+
#
|
|
6
|
+
# ADOPS_PLAN_DIR / ADOPS_DOWNLOADS を一時ディレクトリへ向けるので、
|
|
7
|
+
# 実ユーザーの ~/Downloads と 入稿計画/ には一切触れない。
|
|
8
|
+
set -uo pipefail
|
|
9
|
+
|
|
10
|
+
SCRIPT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/plan_lint.py"
|
|
11
|
+
ROOT=$(mktemp -d)
|
|
12
|
+
SKILL="$ROOT/skill"
|
|
13
|
+
export ADOPS_SKILL_DIR="$SKILL"
|
|
14
|
+
export ADOPS_PLAN_DIR="$SKILL/入稿計画"
|
|
15
|
+
export ADOPS_DOWNLOADS="$ROOT/Downloads"
|
|
16
|
+
mkdir -p "$ADOPS_PLAN_DIR" "$ADOPS_DOWNLOADS" "$SKILL/アカウント/yda"
|
|
17
|
+
|
|
18
|
+
cat > "$SKILL/アクティブプラン.json" <<'EOF'
|
|
19
|
+
{"platform":"yda","accountName":"テスト案件","samplePlanFile":"サンプルプラン.json"}
|
|
20
|
+
EOF
|
|
21
|
+
cat > "$SKILL/アカウント/yda/テスト案件.json" <<'EOF'
|
|
22
|
+
{"accountJsonVersion":2,"accountId":"9999000001","name":"テスト案件",
|
|
23
|
+
"baseArticleUrl":"https://example.com/ab/","urlParamsEnabled":false,"refs":{}}
|
|
24
|
+
EOF
|
|
25
|
+
|
|
26
|
+
# $1=出力先 $2=accountId $3=CP名
|
|
27
|
+
plan_json() {
|
|
28
|
+
cat > "$1" <<EOF
|
|
29
|
+
{"planVersion":1,"platform":"yda","accountId":"$2",
|
|
30
|
+
"campaigns":[{"name":"$3","goal":"CONVERSION","status":"PAUSED","budget":50000,
|
|
31
|
+
"adGroups":[{"name":"AG1","ads":[
|
|
32
|
+
{"mediaIds":["21667629"],"headlines":["見出し"],"descriptions":["説明"],
|
|
33
|
+
"principal":"テスト","finalUrl":"https://example.com/ab/x"}]}]}]}
|
|
34
|
+
EOF
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
pass=0; fail=0
|
|
38
|
+
check() { # $1=説明 $2=期待exit $3=実exit
|
|
39
|
+
if [ "$2" = "$3" ]; then echo " ✓ $1"; pass=$((pass+1))
|
|
40
|
+
else echo " ✗ $1 (期待 exit=$2 / 実際 exit=$3)"; fail=$((fail+1)); fi
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
echo "=== 1. ブラウザ改変名を内容で拾う(無引数)==="
|
|
44
|
+
plan_json "$ADOPS_DOWNLOADS/YDA 入稿プラン 2026年8月27日.json" 9999000001 "CP_A"
|
|
45
|
+
out=$(python3 "$SCRIPT" 2>&1); rc=$?
|
|
46
|
+
check "改変名でも回収された" 0 "$rc"
|
|
47
|
+
echo "$out" | grep -q "📥 回収" && echo " ✓ collect が動いた" && pass=$((pass+1)) \
|
|
48
|
+
|| { echo " ✗ collect が動かなかった"; fail=$((fail+1)); }
|
|
49
|
+
found=$(ls "$ADOPS_PLAN_DIR" | head -1)
|
|
50
|
+
case "$found" in
|
|
51
|
+
*_入稿計画_[0-9]*-[0-9]*.json) echo " ✓ stamp が mtime から生成された: $found"; pass=$((pass+1)) ;;
|
|
52
|
+
*unknown*) echo " ✗ stamp が unknown のまま: $found"; fail=$((fail+1)) ;;
|
|
53
|
+
*) echo " ✗ 想定外の名前: $found"; fail=$((fail+1)) ;;
|
|
54
|
+
esac
|
|
55
|
+
|
|
56
|
+
echo "=== 2. 同一 stamp で回収しても上書きしない ==="
|
|
57
|
+
rm -f "$ADOPS_PLAN_DIR"/*.json "$ADOPS_DOWNLOADS"/*.json
|
|
58
|
+
for i in 1 2 3; do
|
|
59
|
+
plan_json "$ADOPS_DOWNLOADS/dl$i.json" 9999000001 "CP_A"
|
|
60
|
+
touch -t 202608270158.56 "$ADOPS_DOWNLOADS/dl$i.json"
|
|
61
|
+
python3 "$SCRIPT" "$ADOPS_DOWNLOADS/dl$i.json" >/dev/null 2>&1
|
|
62
|
+
done
|
|
63
|
+
n=$(ls "$ADOPS_PLAN_DIR" | wc -l | tr -d ' ')
|
|
64
|
+
[ "$n" = "3" ] && { echo " ✓ 3件に分かれた(上書きされず)"; pass=$((pass+1)); } \
|
|
65
|
+
|| { echo " ✗ $n 件(上書きされた)"; fail=$((fail+1)); }
|
|
66
|
+
ls "$ADOPS_PLAN_DIR" | sed 's/^/ /'
|
|
67
|
+
# 連番は CP 名側。末尾の _<timestamp>.json は履歴ビューが読むので崩さない
|
|
68
|
+
ls "$ADOPS_PLAN_DIR" | grep -q '_入稿計画_20260827-015856\.json$' \
|
|
69
|
+
&& { echo " ✓ 末尾の timestamp が保たれている"; pass=$((pass+1)); } \
|
|
70
|
+
|| { echo " ✗ 末尾が壊れた"; fail=$((fail+1)); }
|
|
71
|
+
|
|
72
|
+
echo "=== 2b. mtime 同着なら未回収の Downloads を優先 ==="
|
|
73
|
+
rm -f "$ADOPS_PLAN_DIR"/*.json "$ADOPS_DOWNLOADS"/*.json
|
|
74
|
+
plan_json "$ADOPS_PLAN_DIR/既存_入稿計画_20260827-015856.json" 9999000001 "CP_OLD"
|
|
75
|
+
plan_json "$ADOPS_DOWNLOADS/新着.json" 9999000001 "CP_NEW"
|
|
76
|
+
touch -t 202608270158.56 "$ADOPS_PLAN_DIR/既存_入稿計画_20260827-015856.json" "$ADOPS_DOWNLOADS/新着.json"
|
|
77
|
+
out=$(python3 "$SCRIPT" 2>&1)
|
|
78
|
+
echo "$out" | grep -q "📥 回収" \
|
|
79
|
+
&& { echo " ✓ Downloads 側が回収された"; pass=$((pass+1)); } \
|
|
80
|
+
|| { echo " ✗ 入稿計画/ の既存を拾って Downloads が残った"; fail=$((fail+1)); }
|
|
81
|
+
|
|
82
|
+
echo "=== 3. --wait が新規ファイルを検出 ==="
|
|
83
|
+
rm -f "$ADOPS_PLAN_DIR"/*.json
|
|
84
|
+
( sleep 4; plan_json "$ADOPS_DOWNLOADS/YDA 入稿プラン 2026年8月27日.json" 9999000001 "CP_WAIT" ) &
|
|
85
|
+
out=$(python3 "$SCRIPT" --wait --timeout 30 2>&1); rc=$?
|
|
86
|
+
check "検出して正常終了" 0 "$rc"
|
|
87
|
+
echo "$out" | grep -q "WEBGRAM_PLAN_DETECTED" && { echo " ✓ sentinel が出た"; pass=$((pass+1)); } \
|
|
88
|
+
|| { echo " ✗ sentinel が無い"; fail=$((fail+1)); }
|
|
89
|
+
wait
|
|
90
|
+
|
|
91
|
+
echo "=== 4. 待機前からあるファイルは拾わない ==="
|
|
92
|
+
rm -f "$ADOPS_PLAN_DIR"/*.json "$ADOPS_DOWNLOADS"/*.json
|
|
93
|
+
plan_json "$ADOPS_DOWNLOADS/古い計画.json" 9999000001 "CP_OLD"
|
|
94
|
+
sleep 1
|
|
95
|
+
python3 "$SCRIPT" --wait --timeout 6 >/dev/null 2>&1; rc=$?
|
|
96
|
+
check "timeout した(古いファイルを拾わない)" 124 "$rc"
|
|
97
|
+
|
|
98
|
+
echo "=== 5. 別 accountId は context_mismatch で即停止 ==="
|
|
99
|
+
rm -f "$ADOPS_DOWNLOADS"/*.json
|
|
100
|
+
( sleep 4; plan_json "$ADOPS_DOWNLOADS/別案件.json" 9999000002 "CP_OTHER" ) &
|
|
101
|
+
out=$(python3 "$SCRIPT" --wait --timeout 30 2>&1); rc=$?
|
|
102
|
+
check "exit 3 で停止" 3 "$rc"
|
|
103
|
+
echo "$out" | grep -q "context_mismatch" && { echo " ✓ 構造化エラーが出た"; pass=$((pass+1)); } \
|
|
104
|
+
|| { echo " ✗ context_mismatch が無い"; fail=$((fail+1)); }
|
|
105
|
+
wait
|
|
106
|
+
|
|
107
|
+
echo "=== 6. 書き込み途中(不完全JSON)を掴まない ==="
|
|
108
|
+
rm -f "$ADOPS_PLAN_DIR"/*.json "$ADOPS_DOWNLOADS"/*.json
|
|
109
|
+
( printf '{"planVersion":1,"platform":"yda","campai' > "$ADOPS_DOWNLOADS/途中.json"
|
|
110
|
+
sleep 5; plan_json "$ADOPS_DOWNLOADS/途中.json" 9999000001 "CP_LATE" ) &
|
|
111
|
+
out=$(python3 "$SCRIPT" --wait --timeout 30 2>&1); rc=$?
|
|
112
|
+
check "完成後に検出できた" 0 "$rc"
|
|
113
|
+
wait
|
|
114
|
+
|
|
115
|
+
echo "=== 7. サブディレクトリ(E2E成果物)を拾わない ==="
|
|
116
|
+
rm -f "$ADOPS_PLAN_DIR"/*.json "$ADOPS_DOWNLOADS"/*.json
|
|
117
|
+
mkdir -p "$ADOPS_PLAN_DIR/20260827-020000_通しテスト"
|
|
118
|
+
( sleep 3; plan_json "$ADOPS_PLAN_DIR/20260827-020000_通しテスト/e2e.json" 9999000001 "CP_E2E" ) &
|
|
119
|
+
python3 "$SCRIPT" --wait --timeout 8 >/dev/null 2>&1; rc=$?
|
|
120
|
+
check "timeout した(サブディレクトリは対象外)" 124 "$rc"
|
|
121
|
+
wait
|
|
122
|
+
|
|
123
|
+
echo
|
|
124
|
+
echo "══ 結果: ${pass} passed / ${fail} failed ══"
|
|
125
|
+
rm -rf "$ROOT"
|
|
126
|
+
[ "$fail" = "0" ]
|
|
@@ -28,6 +28,10 @@
|
|
|
28
28
|
"2": { "image": "", "square": "", "video": "dQw4w9WgXcQ" },
|
|
29
29
|
"3": { "image": "customers/1000000001/assets/800000003", "square": "", "video": "M7lc1UVf-VE" }
|
|
30
30
|
},
|
|
31
|
+
"logos": {
|
|
32
|
+
"customers/1000000001/assets/900000001": { "name": "サンプルロゴ(正方形)", "width": 512, "height": 512, "usedAsLogo": true },
|
|
33
|
+
"customers/1000000001/assets/900000002": { "name": "サンプルロゴ(小)", "width": 128, "height": 128, "usedAsLogo": true }
|
|
34
|
+
},
|
|
31
35
|
"campaigns": {
|
|
32
36
|
"20000000001": { "name": "GoogleサンプルキャンペーンA" },
|
|
33
37
|
"20000000002": { "name": "GoogleサンプルキャンペーンB" }
|