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
|
@@ -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.
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
84
|
-
|
|
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
|
-
|
|
100
|
-
|
|
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
|
|
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"
|
|
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
|
-
|
|
260
|
-
|
|
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
|
-
|
|
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"
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
|
|
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}")
|