addashboard-cli 0.5.1 → 0.6.2

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.
@@ -0,0 +1,243 @@
1
+ #!/usr/bin/env bash
2
+ # ad-ops スキルの単一エントリポイント。
3
+ #
4
+ # adops.sh preflight <platform> <accountId> フェーズ0: ログイン + アカウント疎通確認
5
+ # adops.sh planner [--platform <媒体> --account <案件名>] [--stop]
6
+ # フェーズ2: プランナーをHTTPで開く (file:// 禁止)
7
+ # adops.sh plan [<入稿計画.json>] フェーズ3: DL回収 + 要約 + lint + 合意との差分
8
+ # adops.sh open <platform> <accountId> [resource] 管理画面を開く (URLを手で組み立てない)
9
+ # adops.sh refs-import <platform> <accountId> [--write] 実アカウントから参照表を取り込む
10
+ #
11
+ # 覚えるコマンドを1本に畳むためのラッパー。個別の python3 -c / curl / ls -t を書かない。
12
+ set -uo pipefail
13
+
14
+ SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
15
+ PORT=8799 # cli/playwright.config.ts の webServer と同じポートを共用する
16
+ URL="http://127.0.0.1:${PORT}/ad-planner.html"
17
+ CLI="npx -y addashboard-cli@latest"
18
+
19
+ die() { echo "✗ $*" >&2; exit 1; }
20
+
21
+ # ---------------------------------------------------------------- preflight
22
+ # フェーズ4の本実行で初めて 401 Account not found に気づく事故を防ぐ。
23
+ # 戦略壁打ちを始める前にこれを通し、落ちたら戦略を練らずに止める。
24
+ cmd_preflight() {
25
+ local platform="${1:-}" account="${2:-}"
26
+ [ -n "$platform" ] && [ -n "$account" ] \
27
+ || die "usage: adops.sh preflight <platform> <accountId>"
28
+
29
+ echo "▸ ログイン確認..."
30
+ local who
31
+ who="$($CLI auth whoami 2>&1)" || {
32
+ echo "$who" >&2
33
+ die "未ログイン、または認証が期限切れです。ターミナルで '$CLI auth login' を実行してください(Google SSO は本人が操作)"
34
+ }
35
+ python3 -c 'import json,sys; d=json.load(sys.stdin); print(" ✓", d.get("email"), "/ role:", d.get("role"))' <<<"$who" \
36
+ || die "whoami の応答を解釈できません: $who"
37
+
38
+ echo "▸ アカウント疎通確認 (${platform} / ${account})..."
39
+ local accounts
40
+ accounts="$($CLI list --platform "$platform" --resource accounts 2>&1)" \
41
+ || { echo "$accounts" >&2; die "アカウント一覧の取得に失敗しました"; }
42
+
43
+ ACCOUNT_ID="$account" python3 - <<'PY' <<<"$accounts"
44
+ import json, os, sys
45
+ want = os.environ["ACCOUNT_ID"]
46
+ try:
47
+ accounts = json.load(sys.stdin).get("accounts", [])
48
+ except Exception as e:
49
+ sys.exit(f"✗ アカウント一覧を解釈できません: {e}")
50
+ hit = next((a for a in accounts if str(a.get("id")) == want), None)
51
+ if hit:
52
+ print(f" ✓ {want} — {hit.get('name')} (アクセス可能: 全{len(accounts)}件中)")
53
+ sys.exit(0)
54
+ print(f" ✗ accountId {want} は接続中の認証情報のスコープ外です(このまま入稿すると 401 Account not found)")
55
+ print(f" アクセス可能な {len(accounts)} 件:")
56
+ for a in accounts:
57
+ print(f" {a.get('id')} {a.get('name')}")
58
+ sys.exit(1)
59
+ PY
60
+ }
61
+
62
+ # ------------------------------------------------------------------ planner
63
+ # アクティブプラン.json = 「いま作業中の案件×媒体」を指す各自のローカル状態。
64
+ # プランナーは起動時にこれを読んで案件と媒体を自動選択する (ad-planner.html の bootstrapBundled)。
65
+ #
66
+ # 書き出す実装がどこにも無かったため、リポジトリに commit された値が全環境へ配られ、
67
+ # 「別案件で作ったアカウントが既定になり、毎回切り替えが要る」状態になっていた。
68
+ # ここが唯一の書き出し口。ファイル自体は .gitignore 済みで追跡しない。
69
+ write_active_plan() {
70
+ local platform="$1" account="$2" sample
71
+ case "$platform" in
72
+ yda) sample='サンプルプラン.json' ;;
73
+ meta) sample='サンプルプラン_meta.json' ;;
74
+ *) die "未知の媒体: ${platform}(yda / meta)" ;;
75
+ esac
76
+
77
+ # 参照データが無いまま指定するとプランナーが同梱デフォルトへ黙って落ちるので、先に止める
78
+ local acc_json="${SKILL_DIR}/アカウント/${platform}/${account}.json"
79
+ [ -f "$acc_json" ] \
80
+ || die "アカウント参照データがありません: アカウント/${platform}/${account}.json(先に adops.sh refs-import で取り込む)"
81
+
82
+ SKILL_DIR="$SKILL_DIR" PF="$platform" ACC="$account" SAMPLE="$sample" python3 - <<'PY'
83
+ import json, os
84
+ body = {
85
+ "_comment": "ad-ops スキルが書き出す『いま作業中のプラン』の指定。プランナーは起動時にこれを読み、媒体と案件(アカウント)を自動選択してサンプルプランを適用する。各自のローカル状態なので git では追跡しない。",
86
+ "platform": os.environ["PF"],
87
+ "accountName": os.environ["ACC"],
88
+ "samplePlanFile": os.environ["SAMPLE"],
89
+ }
90
+ with open(os.path.join(os.environ["SKILL_DIR"], "アクティブプラン.json"), "w", encoding="utf-8") as f:
91
+ json.dump(body, f, ensure_ascii=False, indent=1)
92
+ f.write("\n")
93
+ print(f"✓ 作業中プランを設定: {os.environ['ACC']} / {os.environ['PF']}")
94
+ PY
95
+ }
96
+
97
+ # file:// で開くと fetch('サンプルプラン.json') がブロックされ、
98
+ # エラーも出ないまま空のプランナーが表示される。必ずHTTPで開く。
99
+ cmd_planner() {
100
+ local platform='' account=''
101
+ while [ $# -gt 0 ]; do
102
+ case "$1" in
103
+ --stop)
104
+ pkill -f "http.server ${PORT}" && echo "✓ プランナーの配信を停止しました" \
105
+ || echo "(起動中のサーバーはありませんでした)"
106
+ return 0 ;;
107
+ --platform) platform="${2:-}"; shift 2 ;;
108
+ --account) account="${2:-}"; shift 2 ;;
109
+ *) die "unknown option: $1(usage: adops.sh planner [--platform <媒体> --account <案件名>] [--stop])" ;;
110
+ esac
111
+ done
112
+
113
+ if [ -n "$platform" ] || [ -n "$account" ]; then
114
+ [ -n "$platform" ] && [ -n "$account" ] \
115
+ || die "--platform と --account は両方指定してください"
116
+ write_active_plan "$platform" "$account"
117
+ fi
118
+
119
+ if curl -sf -o /dev/null "$URL"; then
120
+ echo "✓ 配信は起動済み(再利用)"
121
+ else
122
+ echo "▸ http.server を起動中 (port ${PORT})..."
123
+ # nohup でデタッチしないと、呼び出し元シェルの終了に巻き込まれて死ぬ
124
+ nohup python3 -m http.server "$PORT" --directory "$SKILL_DIR" \
125
+ >/tmp/adops-planner.log 2>&1 &
126
+ for _ in $(seq 1 20); do
127
+ curl -sf -o /dev/null "$URL" && break
128
+ sleep 0.3
129
+ done
130
+ curl -sf -o /dev/null "$URL" || die "配信を起動できませんでした(/tmp/adops-planner.log を確認)"
131
+ echo "✓ 起動しました"
132
+ fi
133
+
134
+ open "$URL"
135
+ echo " $URL"
136
+ echo " 停止: adops.sh planner --stop"
137
+ }
138
+
139
+ # --------------------------------------------------------------------- plan
140
+ cmd_plan() {
141
+ python3 "${SKILL_DIR}/scripts/plan_lint.py" "$@"
142
+ }
143
+
144
+ # --------------------------------------------------------------------- open
145
+ # 管理画面URLの唯一の供給元。SKILL.md 本文や会話にURLをベタ書きしない
146
+ # (ホスト名の記憶違いで存在しないURL(ads-display.yahoo.co.jp)を開く事故の根治)。
147
+ #
148
+ # YDA : https://ads.yahoo.co.jp/manager/#/{mccId}/{accountId}/list/{resource}
149
+ # Meta: https://adsmanager.facebook.com/adsmanager/manage/{resource}?act={accountIdNum}
150
+ #
151
+ # resource は campaigns | adgroups | ads で受け、媒体別の語へ変換する
152
+ # (中間層は YDA=adgroups / Meta=adsets。adsets 表記でも受ける)。
153
+ #
154
+ # ⚠ YDA はキャンペーン詳細への直リンクを表現できない(list/ 配下しかルートが無い)。
155
+ # 一覧を開いて CP 名で探す。
156
+ cmd_open() {
157
+ local platform="${1:-}" account="${2:-}" resource="${3:-campaigns}" dry="${ADOPS_OPEN_DRY:-}"
158
+ [ "${4:-}" = "--dry" ] && dry=1
159
+ [ "${3:-}" = "--dry" ] && { resource="campaigns"; dry=1; }
160
+ [ -n "$platform" ] && [ -n "$account" ] \
161
+ || die "usage: adops.sh open <platform> <accountId> [campaigns|adgroups|ads] [--dry]"
162
+
163
+ # 中間層のURL語彙は媒体で違う (YDA=adgroups / Meta=adsets)。
164
+ # プランナー側 (PLATFORMS[].consoleResourceMap) と同じ吸収をここでも行う。
165
+ # 素通しにすると `open meta ... adgroups` が存在しないビューを開く。
166
+ local key
167
+ case "$(printf '%s' "$resource" | tr 'A-Z' 'a-z')" in
168
+ campaigns|campaign) key=campaigns ;;
169
+ adgroups|adgroup|adsets|adset) key=adgroups ;;
170
+ ads|ad) key=ads ;;
171
+ *) die "resource は campaigns | adgroups (Meta では adsets) | ads のいずれかです: ${resource}" ;;
172
+ esac
173
+
174
+ local url mcc
175
+ case "$platform" in
176
+ yda)
177
+ # mccId はアカウントJSON(v2)が唯一の供給元。未設定なら accountId で代替する。
178
+ mcc="$(ACCOUNT_ID="$account" SKILL_DIR="$SKILL_DIR" python3 - <<'PY'
179
+ import glob, json, os
180
+ want, skill = os.environ["ACCOUNT_ID"], os.environ["SKILL_DIR"]
181
+ paths = [p for p in glob.glob(os.path.join(skill, "アカウント", "yda", "*.json"))
182
+ if os.path.basename(p) != "index.json"]
183
+ paths.sort(key=lambda p: os.path.basename(p) == "sample_account.json")
184
+ for p in paths:
185
+ try:
186
+ d = json.load(open(p, encoding="utf-8"))
187
+ except Exception:
188
+ continue
189
+ if str(d.get("accountId", "")) == want:
190
+ print(str(d.get("mccId") or ""))
191
+ break
192
+ PY
193
+ )"
194
+ if [ -z "$mcc" ]; then
195
+ mcc="$account"
196
+ echo "⚠ アカウントJSONに mccId がありません。accountId (${account}) で代替します。" >&2
197
+ echo " 404 / 白画面になったら、管理画面URLの '#/{ここ}/' の値を控えて" >&2
198
+ echo " アカウント/yda/<名前>.json に \"mccId\": \"...\" を追記してください。" >&2
199
+ fi
200
+ url="https://ads.yahoo.co.jp/manager/#/${mcc}/${account}/list/${key}"
201
+ ;;
202
+ meta)
203
+ # Meta の中間層は adsets (adgroups というルートは無い)
204
+ local mres="$key"; [ "$key" = "adgroups" ] && mres="adsets"
205
+ url="https://adsmanager.facebook.com/adsmanager/manage/${mres}?act=${account#act_}"
206
+ ;;
207
+ *) die "open が未対応の媒体です: ${platform}(yda / meta)" ;;
208
+ esac
209
+
210
+ echo "$url"
211
+ [ -n "$dry" ] || open "$url"
212
+ }
213
+
214
+ # --------------------------------------------------------------- refs-import
215
+ # 実アカウントから見出し/説明文/オーディエンス/配置面を取り込み、アカウントJSONへマージする。
216
+ # --write なしは差分表示のみ(dry-run 既定)。
217
+ cmd_refs_import() {
218
+ python3 "${SKILL_DIR}/scripts/refs_import.py" "$@"
219
+ }
220
+
221
+ case "${1:-}" in
222
+ preflight) shift; cmd_preflight "$@" ;;
223
+ planner) shift; cmd_planner "$@" ;;
224
+ plan) shift; cmd_plan "$@" ;;
225
+ open) shift; cmd_open "$@" ;;
226
+ refs-import) shift; cmd_refs_import "$@" ;;
227
+ *)
228
+ cat >&2 <<EOF
229
+ usage:
230
+ adops.sh preflight <platform> <accountId> フェーズ0: ログイン + アカウント疎通確認
231
+ adops.sh planner [--platform <媒体> --account <案件名>] [--stop]
232
+ フェーズ2: プランナーをHTTPで開く
233
+ --platform/--account を渡すと「作業中プラン」を
234
+ 設定してから開く(省略時は前回の設定を引き継ぐ)
235
+ adops.sh plan [<入稿計画.json>] フェーズ3: DL回収 + 要約 + lint + 合意との差分
236
+ adops.sh open <platform> <accountId> [campaigns|adgroups|ads] [--dry]
237
+ 管理画面を開く(URLを手で組み立てない)
238
+ 中間層は YDA=adgroups / Meta=adsets に自動変換
239
+ adops.sh refs-import <platform> <accountId> [--write] [--campaign-id <id>]
240
+ 実アカウントから参照表を取り込む(既定 dry-run)
241
+ EOF
242
+ exit 2 ;;
243
+ esac
@@ -0,0 +1,357 @@
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。
115
+
116
+ 優先順: group.paramCode → finalUrl の ?ad= → 広告グループ名の【…】。
117
+ URLパラメータ無し運用(urlParamsEnabled: false)でも AG 名から復元できるようにする。
118
+ """
119
+ if group.get("paramCode"):
120
+ return str(group["paramCode"])
121
+ url = ad.get("finalUrl") or ""
122
+ q = urllib.parse.parse_qs(urllib.parse.urlparse(url).query)
123
+ code = (q.get("ad") or [""])[0]
124
+ if code:
125
+ return code
126
+ m = re.search(r"【([^】]+)】", str(group.get("name") or ""))
127
+ return m.group(1) if m else ""
128
+
129
+
130
+ def image_no_of(ad: dict) -> str:
131
+ """画像番号。ad.imageNo → creative パラメータ → 広告名 '1047c-...' の順で復元する。"""
132
+ if ad.get("imageNo"):
133
+ return str(ad["imageNo"])
134
+ url = ad.get("finalUrl") or ""
135
+ q = urllib.parse.parse_qs(urllib.parse.urlparse(url).query)
136
+ m = re.search(r"-(\d+)[a-z]-", (q.get("creative") or [""])[0])
137
+ if m:
138
+ return m.group(1)
139
+ m = re.match(r"^(\d+)[cs]-", str(ad.get("adName") or ""))
140
+ return m.group(1) if m else ""
141
+
142
+
143
+ def resolve_media_path(raw: str, base_dir: str) -> str:
144
+ """CLI の resolveLocalMediaPath と同じ規則でローカル画像パスを解決する。
145
+
146
+ `~` 展開 → 絶対はそのまま → 相対は計画JSONのあるディレクトリ基準。
147
+ """
148
+ p = os.path.expanduser(str(raw or ""))
149
+ return p if os.path.isabs(p) else os.path.normpath(os.path.join(base_dir, p))
150
+
151
+
152
+ def ad_groups(campaign: dict) -> list:
153
+ return campaign.get("adGroups") or campaign.get("adSets") or []
154
+
155
+
156
+ # --------------------------------------------------------------- 2. 要約
157
+ def summarize(plan: dict) -> None:
158
+ print(f"\n── 入稿計画 ── platform={plan.get('platform', 'yda')} "
159
+ f"accountId={plan.get('accountId')} planVersion={plan.get('planVersion')}")
160
+ for ci, c in enumerate(plan.get("campaigns") or []):
161
+ bid = c.get("biddingStrategy", "")
162
+ if c.get("targetCpa"):
163
+ bid += f" (tCPA {c['targetCpa']:,})"
164
+ print(f"\n CP[{ci}] {c.get('name')}")
165
+ print(f" 予算 {c.get('budget', 0):,}/日 | {bid} | status {c.get('status')} "
166
+ f"| 開始 {c.get('startDate', '-')}")
167
+ for g in ad_groups(c):
168
+ tg = "/".join(filter(None, [
169
+ ",".join(g.get("devices") or g.get("devicePlatforms") or []) or "全デバイス",
170
+ ",".join(g.get("ages") or []) or "全年齢",
171
+ ",".join(g.get("genders") or []) or "全性別",
172
+ ]))
173
+ print(f" AG {g.get('name')} | {tg} | status {g.get('status')}")
174
+ for a in g.get("ads") or []:
175
+ media = ",".join(a.get("mediaIds") or []) or a.get("videoId") or a.get("imageHash") or "-"
176
+ head = (a.get("headlines") or ["(空)"])[0]
177
+ print(f" AD media {media} | 画像No {image_no_of(a) or '-'} | {head}")
178
+ print(f" {a.get('finalUrl')}")
179
+
180
+
181
+ # --------------------------------------------------------------- 3. lint
182
+ def lint(plan: dict, plan_path: str = "") -> None:
183
+ platform = plan.get("platform", "yda")
184
+ account = load_account(platform, plan.get("accountId", ""))
185
+ # キー無し = true(従来挙動)。アカウントJSON v2 の追加キーなので .get() で読む。
186
+ url_params = account.get("urlParamsEnabled", True) is not False
187
+ base_dir = os.path.dirname(os.path.abspath(plan_path)) if plan_path else os.getcwd()
188
+ base = account.get("baseArticleUrl") or ""
189
+ base_host = urllib.parse.urlparse(base).netloc if base else ""
190
+ known_codes = {
191
+ str(v.get("paramCode"))
192
+ for v in (account.get("refs", {}).get("adGroups") or {}).values()
193
+ if v.get("paramCode")
194
+ }
195
+
196
+ if not base:
197
+ warn(f"アカウントJSONに baseArticleUrl がありません({platform}/{plan.get('accountId')})。"
198
+ " finalUrl が相対パスになる原因になります")
199
+
200
+ seen_media: dict[str, str] = {}
201
+ for c in plan.get("campaigns") or []:
202
+ name = c.get("name", "")
203
+ budget = c.get("budget") or 0
204
+ if not (BUDGET_MIN <= budget <= BUDGET_MAX):
205
+ warn(f"CP「{name}」の日予算 {budget:,} が想定レンジ外({BUDGET_MIN:,}〜{BUDGET_MAX:,})。桁を確認してください")
206
+ if c.get("status") == "ACTIVE":
207
+ warn(f"CP「{name}」が ACTIVE です。初回・PoC は PAUSED(配信オフ)を推奨します")
208
+ if SUSPICIOUS_NAME.search(name):
209
+ warn(f"CP名「{name}」に検証用らしき語が残っています。本番入稿の命名か確認してください")
210
+
211
+ for g in ad_groups(c):
212
+ gname = g.get("name", "")
213
+ if SUSPICIOUS_NAME.search(gname):
214
+ warn(f"広告グループ名「{gname}」に検証用らしき語が残っています")
215
+ # 方針: スマートターゲティングは常時 ON。明示 false は事故を疑う
216
+ if g.get("smartTargeting") is False:
217
+ warn(f"広告グループ「{gname}」の smartTargeting が false です"
218
+ f"(方針は常時ON)。意図的でなければ外してください")
219
+ for a in g.get("ads") or []:
220
+ url = a.get("finalUrl") or ""
221
+ if not re.match(r"https?://", url):
222
+ err(f"CP「{name}」の finalUrl が相対パスです: {url}\n"
223
+ f" → アカウントJSONの baseArticleUrl 未設定。"
224
+ f"**dry-run はこれを検出しません**")
225
+ elif base_host and urllib.parse.urlparse(url).netloc != base_host:
226
+ warn(f"CP「{name}」の finalUrl のホストがアカウントの baseArticleUrl "
227
+ f"({base_host}) と違います: {urllib.parse.urlparse(url).netloc}")
228
+
229
+ media = a.get("mediaIds") or []
230
+ if not media and not a.get("videoId") and not a.get("imageHash"):
231
+ err(f"CP「{name}」の広告にクリエイティブがありません(mediaIds が空)")
232
+ for m in media:
233
+ if m in seen_media:
234
+ warn(f"mediaId {m} が複数の広告で使われています"
235
+ f"({seen_media[m]} / {name})。画像検証なら重複は想定外です")
236
+ else:
237
+ seen_media[m] = name
238
+
239
+ # mediaIds(解決済み)と imagePaths の同時指定は **サイレントな取り違え**。
240
+ # CLI はアップロードした mediaId を末尾に追記するだけで、backend は
241
+ # mediaIds[0] しか使わない → 新画像は登録されるのに広告は旧画像のまま。
242
+ if media and (a.get("imagePaths") or []):
243
+ err(f"CP「{name}」の広告に mediaIds と imagePaths が両方あります"
244
+ f"({a.get('adName') or ''})\n"
245
+ f" → アップロードされた画像は広告に使われません"
246
+ f"(backend は mediaIds[0] のみ使用)。差し替えたいなら mediaIds を空にし、"
247
+ f"既存画像のままでよいなら imagePaths を消してください")
248
+
249
+ # ローカル画像パスの存在チェック(dry-run は検出しない)
250
+ for raw in a.get("imagePaths") or []:
251
+ resolved = resolve_media_path(raw, base_dir)
252
+ if not os.path.isfile(resolved):
253
+ err(f"CP「{name}」の imagePaths のファイルが見つかりません: {raw}\n"
254
+ f" → 解決先 {resolved}(相対パスの基準は計画JSONのディレクトリ "
255
+ f"{base_dir})")
256
+
257
+ query = urllib.parse.urlparse(url).query
258
+ if not url_params and query:
259
+ warn(f"CP「{name}」は urlParamsEnabled: false ですが finalUrl に"
260
+ f" クエリが付いています: ?{query}")
261
+ # 相対/空 URL は上で ERROR 済みなので二重に鳴らさない
262
+ if url_params and not query and re.match(r"https?://", url):
263
+ err(f"CP「{name}」の finalUrl に URLパラメータがありません: {url}\n"
264
+ f" → パラメータ無し運用ならアカウントJSONに"
265
+ f" \"urlParamsEnabled\": false を入れてください")
266
+
267
+ code = param_code_of(a, g)
268
+ if not code:
269
+ if url_params:
270
+ err(f"CP「{name}」の paramCode が特定できません")
271
+ else:
272
+ warn(f"CP「{name}」の paramCode が特定できません"
273
+ f"(urlParamsEnabled: false のため WARN 止まり)。"
274
+ f"広告グループ名に【コード】を入れると復元できます")
275
+ elif code in known_codes:
276
+ err(f"paramCode {code} はアカウントの既存広告グループと重複しています"
277
+ f"({name})。新規なら未使用の番号に変えてください")
278
+
279
+
280
+ # --------------------------------------------------------------- 4. 差分
281
+ def flatten(plan: dict, is_sample: bool) -> dict:
282
+ """サンプルプラン(schemaVersion 3)と入稿計画(planVersion 1)を同じ形に潰して比較可能にする。"""
283
+ out: dict[str, object] = {"CP数": len(plan.get("campaigns") or [])}
284
+ for ci, c in enumerate(plan.get("campaigns") or []):
285
+ p = f"CP[{ci}]"
286
+ out[f"{p}.name"] = c.get("name")
287
+ out[f"{p}.budget"] = c.get("budget")
288
+ out[f"{p}.tCPA"] = c.get("tcpa") if is_sample else c.get("targetCpa")
289
+ groups = ad_groups(c)
290
+ out[f"{p}.広告グループ数"] = len(groups)
291
+ for gi, g in enumerate(groups):
292
+ gp = f"{p}.AG[{gi}]"
293
+ out[f"{gp}.name"] = g.get("name")
294
+ ads = g.get("ads") or []
295
+ out[f"{gp}.広告数"] = len(ads)
296
+ for ai, a in enumerate(ads):
297
+ out[f"{gp}.AD[{ai}].paramCode"] = param_code_of(a, g)
298
+ out[f"{gp}.AD[{ai}].画像No"] = image_no_of(a)
299
+ return out
300
+
301
+
302
+ def diff_against_sample(plan: dict) -> None:
303
+ platform = plan.get("platform", "yda")
304
+ sample_path = os.path.join(
305
+ SKILL_DIR, "サンプルプラン_meta.json" if platform == "meta" else "サンプルプラン.json"
306
+ )
307
+ if not os.path.exists(sample_path):
308
+ print("\n(サンプルプランが無いため差分比較をスキップしました)")
309
+ return
310
+ sample = json.load(open(sample_path, encoding="utf-8"))
311
+ if str(sample.get("accountId")) != str(plan.get("accountId")):
312
+ print(f"\n⚠ サンプルプランのaccountId({sample.get('accountId')})と入稿計画"
313
+ f"({plan.get('accountId')})が違うため差分比較をスキップしました")
314
+ return
315
+
316
+ a, b = flatten(sample, True), flatten(plan, False)
317
+ rows = [(k, a.get(k, "—"), b.get(k, "—")) for k in dict.fromkeys([*a, *b])
318
+ if str(a.get(k, "—")) != str(b.get(k, "—"))]
319
+
320
+ print("\n── 合意(サンプルプラン)との差分 ──")
321
+ if not rows:
322
+ print(" ✓ 差分なし(合意どおり)")
323
+ return
324
+ width = max(len(k) for k, _, _ in rows)
325
+ for k, want, got in rows:
326
+ print(f" {k.ljust(width)} {want} → {got}")
327
+ print(" ↑ この差分だけをユーザーに確認すること(全項目の読み上げはしない)")
328
+
329
+
330
+ # ---------------------------------------------------------------- main
331
+ def main() -> int:
332
+ path = pick_plan(sys.argv[1:])
333
+ plan = json.load(open(path, encoding="utf-8"))
334
+ path = collect(path, plan)
335
+ print(f"📄 {path}")
336
+
337
+ summarize(plan)
338
+ lint(plan, path)
339
+ diff_against_sample(plan)
340
+
341
+ print("\n── lint(dry-run が検出しない項目)──")
342
+ for m in errors:
343
+ print(f" ERROR {m}")
344
+ for m in warns:
345
+ print(f" WARN {m}")
346
+ if not errors and not warns:
347
+ print(" ✓ 問題なし")
348
+
349
+ if errors:
350
+ print(f"\n✗ ERROR {len(errors)}件。修正するまで本実行しないこと")
351
+ return 1
352
+ print(f"\n✓ ERROR なし{f'(WARN {len(warns)}件は要確認)' if warns else ''}")
353
+ return 0
354
+
355
+
356
+ if __name__ == "__main__":
357
+ sys.exit(main())