addashboard-cli 0.9.2 → 0.9.4

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.
Files changed (36) hide show
  1. package/dist/commands/ads.js +4 -7
  2. package/dist/commands/ads.js.map +1 -1
  3. package/dist/commands/authDevice.js +10 -2
  4. package/dist/commands/authDevice.js.map +1 -1
  5. package/dist/commands/authGoogle.js +39 -42
  6. package/dist/commands/authGoogle.js.map +1 -1
  7. package/dist/commands/freee.js +2 -18
  8. package/dist/commands/freee.js.map +1 -1
  9. package/dist/commands/pl.js +4 -8
  10. package/dist/commands/pl.js.map +1 -1
  11. package/dist/commands/review.js +4 -8
  12. package/dist/commands/review.js.map +1 -1
  13. package/dist/commands/status.js +2 -1
  14. package/dist/commands/status.js.map +1 -1
  15. package/dist/commands/submission.js +236 -21
  16. package/dist/commands/submission.js.map +1 -1
  17. package/dist/index.js +46 -31
  18. package/dist/index.js.map +1 -1
  19. package/dist/lib/apiError.js +63 -0
  20. package/dist/lib/apiError.js.map +1 -0
  21. package/dist/lib/authFlow.js +82 -0
  22. package/dist/lib/authFlow.js.map +1 -0
  23. package/dist/lib/googleImageMetadata.js +145 -0
  24. package/dist/lib/googleImageMetadata.js.map +1 -0
  25. package/dist/lib/openBrowser.js +63 -0
  26. package/dist/lib/openBrowser.js.map +1 -0
  27. package/dist/schemas/submit-plan.js +27 -1
  28. package/dist/schemas/submit-plan.js.map +1 -1
  29. package/package.json +4 -3
  30. package/skills/ad-ops/SKILL.md +84 -10
  31. package/skills/ad-ops/ad-planner.html +275 -21
  32. package/skills/ad-ops/scripts/adops.sh +39 -9
  33. package/skills/ad-ops/scripts/plan_lint.py +262 -28
  34. package/skills/ad-ops/scripts/refs_import.py +251 -20
  35. package/skills/ad-ops/scripts/test_plan_lint.sh +126 -0
  36. 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 を返す。失敗したら空dict + stderr に理由。"""
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 or {}
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 != "yda":
318
- sys.exit(f"✗ refs-import は現状 yda のみ対応です(指定: {args.platform})")
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
- fetched = import_yda(args.account_id, args.campaign_id)
327
- if not fetched["ads"] and not fetched["audiences"] and not fetched["placements"]:
328
- sys.exit("✗ 取得できたデータがありません(ログイン状態・accountId を確認してください)")
329
-
330
- merged, report = merge(account, fetched)
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
- with open(path, "w", encoding="utf-8") as f:
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
- print(f"\n✓ 書き込みました: {path}(accountJsonVersion=2)")
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" }