addashboard-cli 0.9.2 → 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.
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 +257 -20
  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
@@ -4,8 +4,10 @@
4
4
  `adops.sh plan` から呼ばれる。単体でも動く:
5
5
 
6
6
  python3 plan_lint.py [<入稿計画.json>]
7
+ python3 plan_lint.py --wait [--timeout 7200]
7
8
 
8
9
  引数を省略すると ~/Downloads と 入稿計画/ の中で最も新しい入稿計画を拾う。
10
+ `--wait` は、プランナーの入稿ボタンで新しい計画が書き出されるまで待ってから同じ処理を行う。
9
11
  Downloads にあったものは 入稿計画/ へリネームして移動する。
10
12
 
11
13
  やること:
@@ -13,18 +15,34 @@ Downloads にあったものは 入稿計画/ へリネームして移動する
13
15
  2. 要約 — CP > 広告グループ > 広告 を1画面の表で出す
14
16
  3. lint — dry-run が検出しない不備(相対finalUrl・paramCode衝突など)を拾う
15
17
  4. 差分 — サンプルプラン.json(=合意した内容の正)と突き合わせ、違う項目だけ出す
18
+
19
+ 計画の特定はファイル名ではなく**中身**で行う。ブラウザがダウンロード名を勝手に
20
+ 整形するため、名前は当てにならない(実測: プランナーが指定した
21
+ `TikTok入稿API検証_TikTok入稿計画_20260827-010101.json` が
22
+ `TikTok 入稿 API 検証 2026年8月27日.json` / `YDA 入稿プラン 2026年8月27日.json`
23
+ として保存された。「入稿計画」という連続文字列すら残らない)。
16
24
  """
25
+ import argparse
17
26
  import glob
18
27
  import json
19
28
  import os
20
29
  import re
21
30
  import shutil
22
31
  import sys
32
+ import time
23
33
  import urllib.parse
24
34
 
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")
35
+ SKILL_DIR = os.environ.get("ADOPS_SKILL_DIR") or os.path.dirname(
36
+ os.path.dirname(os.path.abspath(__file__))
37
+ )
38
+ # テストが実ユーザーの Downloads を触らずに済むよう、探索先は env で差し替えられる
39
+ PLAN_DIR = os.environ.get("ADOPS_PLAN_DIR") or os.path.join(SKILL_DIR, "入稿計画")
40
+ DOWNLOADS = os.environ.get("ADOPS_DOWNLOADS") or os.path.expanduser("~/Downloads")
41
+
42
+ WAIT_INTERVAL_SEC = 2.0
43
+ WAIT_TIMEOUT_SEC = 7200.0
44
+ EXIT_CONTEXT_MISMATCH = 3
45
+ EXIT_TIMEOUT = 124
28
46
 
29
47
  # 新規モードの既定 12,000円 / 既存ASC の 5〜10万円 を包含するレンジ。
30
48
  # 外れたら桁間違いを疑う(エラーではなく確認を促す)。
@@ -44,9 +62,65 @@ def warn(msg: str) -> None:
44
62
 
45
63
 
46
64
  # --------------------------------------------------------------- 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
65
+ def scan_dir(directory: str) -> dict[str, tuple[float, int]]:
66
+ """直下の .json を {path: (mtime, size)} で返す。
67
+
68
+ サブディレクトリは見ない。E2E が 入稿計画/<timestamp>_<テスト名>/ 配下に
69
+ 書き出すので、再帰するとテスト成果物を計画として拾ってしまう。
70
+ """
71
+ found: dict[str, tuple[float, int]] = {}
72
+ try:
73
+ names = os.listdir(directory)
74
+ except OSError:
75
+ return found # 未作成でも探索は続ける(zip 展開直後は 入稿計画/ が無い)
76
+ for name in names:
77
+ if not name.endswith(".json"):
78
+ continue
79
+ path = os.path.join(directory, name)
80
+ try:
81
+ st = os.stat(path)
82
+ except OSError:
83
+ continue
84
+ if os.path.isfile(path):
85
+ found[path] = (st.st_mtime, st.st_size)
86
+ return found
87
+
88
+
89
+ def read_plan(path: str) -> dict | None:
90
+ """入稿計画JSONとして読めれば返す。読めなければ None。
91
+
92
+ ファイル名では判定しない(ブラウザが改変するため。冒頭の docstring 参照)。
93
+ parse 失敗は「壊れている」だけでなく「書き込み途中」でも起きる。
94
+ """
95
+ try:
96
+ with open(path, encoding="utf-8") as f:
97
+ plan = json.load(f)
98
+ except (OSError, ValueError):
99
+ return None
100
+ if not isinstance(plan, dict) or plan.get("planVersion") != 1:
101
+ return None
102
+ campaigns = plan.get("campaigns")
103
+ if not isinstance(campaigns, list) or not campaigns:
104
+ return None
105
+ return plan
106
+
107
+
108
+ def ad_count(plan: dict) -> int:
109
+ return sum(len(g.get("ads") or []) for c in plan["campaigns"] for g in ad_groups(c))
110
+
111
+
112
+ def newest_plan(directories: list[str]) -> str | None:
113
+ """指定ディレクトリ直下から、中身が入稿計画である最新のファイルを返す。
114
+
115
+ mtime が同着なら先頭のディレクトリを優先する。呼び出し側が Downloads を先に
116
+ 渡すので、未回収のものが 入稿計画/ の既存ファイルに負けない。
117
+ """
118
+ found: list[tuple[float, int, str]] = []
119
+ for rank, directory in enumerate(directories):
120
+ for path, (mtime, _size) in scan_dir(directory).items():
121
+ if read_plan(path) is not None:
122
+ found.append((mtime, -rank, path))
123
+ return max(found)[2] if found else None
50
124
 
51
125
 
52
126
  def load_account(platform: str, account_id: str) -> dict:
@@ -80,12 +154,26 @@ def collect(path: str, plan: dict) -> str:
80
154
  project = account.get("name") or plan.get("accountId", "plan")
81
155
  campaigns = plan.get("campaigns") or []
82
156
  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"
157
+ # ブラウザがダウンロード名から timestamp を落とすことがある。その場合はファイルの
158
+ # mtime(=保存時刻)から作る。以前は "unknown" 固定だったため、同じ案件・同じCPで
159
+ # 書き出すたびに同名になり、黙って上書きされていた。
160
+ matched = re.search(r"(\d{8}-\d{6})", os.path.basename(path))
161
+ stamp = matched.group(1) if matched else time.strftime(
162
+ "%Y%m%d-%H%M%S", time.localtime(os.path.getmtime(path))
163
+ )
85
164
 
86
165
  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
166
  os.makedirs(PLAN_DIR, exist_ok=True)
167
+ # 連番は CP 名側に付ける。末尾の _<timestamp>.json はプランナーの履歴ビューが
168
+ # 保存日時の抽出に使っているので崩さない。
169
+ dest = os.path.join(PLAN_DIR, f"{safe(project)}_{safe(cp)}_入稿計画_{stamp}.json")
170
+ seq = 2
171
+ while os.path.exists(dest):
172
+ dest = os.path.join(
173
+ PLAN_DIR, f"{safe(project)}_{safe(cp)}_{seq}_入稿計画_{stamp}.json"
174
+ )
175
+ seq += 1
176
+
89
177
  shutil.move(path, dest)
90
178
  print(f"📥 回収: ~/Downloads → 入稿計画/{os.path.basename(dest)}")
91
179
  return dest
@@ -96,17 +184,122 @@ def pick_plan(argv: list[str]) -> str:
96
184
  if not os.path.exists(argv[0]):
97
185
  sys.exit(f"✗ ファイルが見つかりません: {argv[0]}")
98
186
  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:
187
+ path = newest_plan([DOWNLOADS, PLAN_DIR])
188
+ if not path:
108
189
  sys.exit("✗ 入稿計画JSONが見つかりません(~/Downloads にも 入稿計画/ にも)")
109
- return max(candidates, key=os.path.getmtime)
190
+ return path
191
+
192
+
193
+ # ------------------------------------------------------------- 1b. 待機
194
+ def active_context() -> tuple[str, str] | None:
195
+ """アクティブプラン.json から (platform, accountId) を解決する。
196
+
197
+ `adops.sh planner --platform <pf> --account <名前>` が書く唯一の口。
198
+ 待機中に読み直さない — 別タブ・別セッションで作業対象が変わったときに、
199
+ 待っている相手が黙ってすり替わるのを避ける。
200
+ """
201
+ try:
202
+ with open(os.path.join(SKILL_DIR, "アクティブプラン.json"), encoding="utf-8") as f:
203
+ active = json.load(f)
204
+ except (OSError, ValueError):
205
+ return None
206
+ platform, name = active.get("platform"), active.get("accountName")
207
+ if not platform or not name:
208
+ return None
209
+ try:
210
+ with open(
211
+ os.path.join(SKILL_DIR, "アカウント", platform, f"{name}.json"), encoding="utf-8"
212
+ ) as f:
213
+ account = json.load(f)
214
+ except (OSError, ValueError):
215
+ return None
216
+ account_id = str(account.get("accountId") or "").strip()
217
+ return (platform, account_id) if account_id else None
218
+
219
+
220
+ def wait_for_plan(since: float, timeout: float) -> str:
221
+ """プランナーが新しい計画を書き出すまで待ち、そのパスを返す。
222
+
223
+ 保存先は接続状態で変わる(フォルダ接続済みなら 入稿計画/、未接続ならブラウザの
224
+ ダウンロード先)ため両方を見る。接続はブラウザを開き直すと切れることがあるので、
225
+ どちらか一方に決め打ちできない。
226
+ """
227
+ expected = active_context()
228
+ if not expected:
229
+ sys.exit(
230
+ "✗ 作業中の案件を特定できません"
231
+ "(adops.sh planner --platform <媒体> --account <案件名> で開き直してください)"
232
+ )
233
+ want_platform, want_account = expected
234
+ print(
235
+ f"▸ 入稿ボタンを待っています({want_platform} / {want_account}"
236
+ f" / 最大 {int(timeout / 60)} 分)",
237
+ file=sys.stderr,
238
+ )
239
+
240
+ stable: dict[str, tuple[float, int]] = {}
241
+ deadline = time.monotonic() + timeout
242
+ while time.monotonic() < deadline:
243
+ time.sleep(WAIT_INTERVAL_SEC)
244
+ for path, stat in sorted(
245
+ {**scan_dir(PLAN_DIR), **scan_dir(DOWNLOADS)}.items(),
246
+ key=lambda kv: kv[1][0],
247
+ reverse=True,
248
+ ):
249
+ if stat[0] <= since:
250
+ continue # 待機を張る前からあったもの
251
+ # 書き込み途中を掴まないよう、mtime と size が据わってから読む
252
+ if stable.get(path) != stat:
253
+ stable[path] = stat
254
+ continue
255
+ plan = read_plan(path)
256
+ if plan is None or ad_count(plan) == 0:
257
+ continue
258
+
259
+ got_platform = str(plan.get("platform") or "yda")
260
+ got_account = str(plan.get("accountId") or "")
261
+ if (got_platform, got_account) != (want_platform, want_account):
262
+ # 「押したのに無反応」を避けるため、待ち続けずここで知らせる
263
+ print(
264
+ json.dumps(
265
+ {
266
+ "error": "context_mismatch",
267
+ "expected": {"platform": want_platform, "accountId": want_account},
268
+ "actual": {"platform": got_platform, "accountId": got_account},
269
+ "planFile": path,
270
+ },
271
+ ensure_ascii=False,
272
+ )
273
+ )
274
+ print(
275
+ "\n✗ 作業中の案件と違う計画が書き出されました。"
276
+ "プランナーのアカウント選択を確認してください",
277
+ file=sys.stderr,
278
+ )
279
+ sys.exit(EXIT_CONTEXT_MISMATCH)
280
+
281
+ print(
282
+ "WEBGRAM_PLAN_DETECTED "
283
+ + json.dumps(
284
+ {
285
+ "planFile": path,
286
+ "platform": got_platform,
287
+ "accountId": got_account,
288
+ "campaignCount": len(plan["campaigns"]),
289
+ "adCount": ad_count(plan),
290
+ "location": "入稿計画" if path.startswith(PLAN_DIR) else "downloads",
291
+ },
292
+ ensure_ascii=False,
293
+ )
294
+ )
295
+ return path
296
+
297
+ print(
298
+ "\n✗ 時間内に入稿計画が書き出されませんでした。"
299
+ "プランナーで「完成 → 保存して入稿」を押したか確認してください",
300
+ file=sys.stderr,
301
+ )
302
+ sys.exit(EXIT_TIMEOUT)
110
303
 
111
304
 
112
305
  # ------------------------------------------------------- 共通: 値の取り出し
@@ -138,6 +331,11 @@ ON_STATUSES = ("ACTIVE", "ENABLED", "ENABLE")
138
331
  UNVERIFIED_PLATFORMS = ("google", "bytedance")
139
332
 
140
333
 
334
+ def is_existing_campaign(campaign: dict) -> bool:
335
+ """既存キャンペーンへの広告追加か(campaignId 指定)。予算・入札は送らない。"""
336
+ return bool(str(campaign.get("campaignId") or "").strip())
337
+
338
+
141
339
  def budget_of(campaign: dict) -> int:
142
340
  """日予算。キー名がファイル種別で違い、**予算を持つ階層も媒体で違う**。
143
341
 
@@ -215,8 +413,10 @@ def summarize(plan: dict) -> None:
215
413
  if tcpa:
216
414
  bid += f" (tCPA {tcpa:,})"
217
415
  budget = budget_of(c)
416
+ # 既存CPへの広告追加は予算・入札・開始日を送らない(媒体側の設定をそのまま使う)
417
+ budget_txt = "予算 既存CPのまま" if is_existing_campaign(c) else f"予算 {budget:,}/日"
218
418
  print(f"\n CP[{ci}] {c.get('name')}")
219
- print(f" 予算 {budget:,}/日 | {bid} | status {status_of(c) or '-'} "
419
+ print(f" {budget_txt} | {bid} | status {status_of(c) or '-'} "
220
420
  f"| 開始 {c.get('startDate', '-')}")
221
421
  for g in ad_groups(c):
222
422
  tg = "/".join(filter(None, [
@@ -256,11 +456,15 @@ def lint(plan: dict, plan_path: str = "") -> None:
256
456
  }
257
457
  # Google は同名キャンペーンを拒否する(2026-08-23 実測。他媒体は未確認なので google 限定で見る)。
258
458
  # dry-run は実 API を叩かないので通ってしまい、本実行の campaign 作成で初めて落ちる。
259
- known_cp_names = {
260
- str(v.get("name"))
459
+ # stale = 媒体側から消えたCP。削除済みの名前は再利用できるので同名チェックから外す
460
+ known_cp_lower = {
461
+ str(v.get("name")).strip().lower()
261
462
  for v in (account.get("refs", {}).get("campaigns") or {}).values()
262
- if v.get("name")
463
+ if v.get("name") and not v.get("stale")
263
464
  }
465
+ # 同じ計画の中での重複を見るための累積。既存との衝突と違い、
466
+ # refs の鮮度と無関係にローカルで確定する
467
+ plan_cp_names: set[str] = set()
264
468
 
265
469
  if not base:
266
470
  warn(f"アカウントJSONに baseArticleUrl がありません({platform}/{plan.get('accountId')})。"
@@ -270,7 +474,9 @@ def lint(plan: dict, plan_path: str = "") -> None:
270
474
  for c in plan.get("campaigns") or []:
271
475
  name = c.get("name", "")
272
476
  budget = budget_of(c)
273
- if not (BUDGET_MIN <= budget <= BUDGET_MAX):
477
+ # 既存CP(campaignId 指定)は広告追加専用で、予算はプランに載らない。
478
+ # ここで 0 を見て警告すると「毎回出るので読み飛ばす警告」になる。
479
+ if not is_existing_campaign(c) and not (BUDGET_MIN <= budget <= BUDGET_MAX):
274
480
  warn(f"CP「{name}」の日予算 {budget:,} が想定レンジ外({BUDGET_MIN:,}〜{BUDGET_MAX:,})。桁を確認してください")
275
481
  # 配信オンを表す値は媒体で違う: yda / meta は ACTIVE、**google は ENABLED**。
276
482
  # ACTIVE だけ見ていると Google の配信オンが素通りする。
@@ -288,9 +494,19 @@ def lint(plan: dict, plan_path: str = "") -> None:
288
494
  # 同名 CP を拒否するのは **google だけ**(2026-08-23 実測。他媒体は未確認)。
289
495
  # YDA は `_2` サフィックスで自動リトライする先例があるので、全媒体で止めると
290
496
  # これまで通っていた計画をブロックしてしまう。
291
- if platform == "google" and name in known_cp_names:
292
- err(f"CP名「{name}」はアカウントの既存キャンペーンと同名です。"
293
- " Google は同名キャンペーンを拒否するため、本実行の作成で落ちます")
497
+ if platform == "google":
498
+ # 大文字小文字だけ違う名前も同名として扱う(取りこぼすより安全側)
499
+ if name.strip().lower() in known_cp_lower:
500
+ err(f"CP名「{name}」はアカウントの既存キャンペーンと同名です。"
501
+ " Google は同名キャンペーンを拒否するため、本実行の作成で落ちます")
502
+ # 既存との衝突とは別に、同じ計画の中での重複も落ちる。
503
+ # こちらは refs の鮮度と無関係にローカルで確定する
504
+ key = name.strip().lower()
505
+ if key and key in plan_cp_names:
506
+ err(f"CP名「{name}」がこの計画の中で重複しています。"
507
+ " まとめて入稿すると2件目以降が落ちます")
508
+ if key:
509
+ plan_cp_names.add(key)
294
510
 
295
511
  for g in ad_groups(c):
296
512
  gname = g.get("name", "")
@@ -469,7 +685,25 @@ def diff_against_sample(plan: dict) -> None:
469
685
 
470
686
  # ---------------------------------------------------------------- main
471
687
  def main() -> int:
472
- path = pick_plan(sys.argv[1:])
688
+ parser = argparse.ArgumentParser(add_help=True, description=__doc__)
689
+ parser.add_argument("plan_file", nargs="?", help="入稿計画JSON(省略時は最新を探す)")
690
+ parser.add_argument(
691
+ "--wait",
692
+ action="store_true",
693
+ help="プランナーが新しい計画を書き出すまで待ってから処理する",
694
+ )
695
+ parser.add_argument(
696
+ "--timeout", type=float, default=WAIT_TIMEOUT_SEC, help="--wait の上限秒数"
697
+ )
698
+ args = parser.parse_args()
699
+
700
+ if args.wait:
701
+ if args.plan_file:
702
+ sys.exit("✗ --wait とファイル指定は同時に使えません")
703
+ path = wait_for_plan(since=time.time(), timeout=args.timeout)
704
+ else:
705
+ path = pick_plan([args.plan_file] if args.plan_file else [])
706
+
473
707
  plan = json.load(open(path, encoding="utf-8"))
474
708
  path = collect(path, plan)
475
709
  print(f"📄 {path}")