addashboard-cli 0.14.0 → 0.15.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "addashboard-cli",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "webgram CLI — ad-dashboard (https://macchiato.site) を Claude Code / Codex CLI から操作するための CLI",
5
5
  "bin": {
6
6
  "webgram": "./dist/index.js"
@@ -72,6 +72,32 @@ skills/ad-ops/scripts/adops.sh preflight yda <accountId>
72
72
  * ユーザーの依頼にアカウント名・媒体が明示されていれば、確認を省いて既定として提示してよい
73
73
  * ここで決まった `platform` / アカウント名は**以降の全分岐で使い回す。下流で再度聞かない**(旧 Step 3 の「入稿先アカウント名」はこのステップに前倒しした)
74
74
 
75
+ #### 媒体の素材を参照表に取り込む(アカウントが決まった直後・#161)
76
+
77
+ **サムネ取得より先に実行する。** 参照表を更新してから落とせば、増えた素材のサムネも同じ回で揃う。
78
+
79
+ ```bash
80
+ skills/ad-ops/scripts/adops.sh assets-sync <platform> <accountId> # 差分を見るだけ
81
+ skills/ad-ops/scripts/adops.sh assets-sync <platform> <accountId> --write # 参照表へ反映する
82
+ ```
83
+
84
+ **参照表(`refs.media`)には「初回に作る」手順しか無く、後から増えた素材が入らない。** 媒体にはあるのにプランナーの素材一覧に出ない、という状態がこれで起きる(#325 は 1200×628 が入っていなかった例)。
85
+
86
+ 判断の目安:
87
+
88
+ | 状況 | どうするか |
89
+ |---|---|
90
+ | 差分 0 件 | **何も言わずに次へ進む。** 参照表は最新 |
91
+ | 追加がある | **件数を伝えてから `--write` する。** 既存の番号・値は変更されない |
92
+ | 取得件数が上限(500)に達した | **書き込まない。** 全件取れていない可能性がある(ページング未対応)。その旨を伝えて先へ進む |
93
+ | 取得に失敗した(403 等) | **書き込まない。** 既存の参照表のまま先へ進む。入稿自体は従来どおりできる |
94
+ | 参照表がまだ無いアカウント | 先に「アカウント取込プロンプト」で作る(このコマンドは既存ファイルへの追記専用) |
95
+
96
+ * **既存の番号・値は絶対に変更しない。** 画像番号は広告名(`957c-jv_Cac_148`)と `creative=` に焼き込まれており、振り直すと過去の実績と突き合わせられなくなる
97
+ * **振り分けはファイル名ではなく実寸(`aspectRatio`)で行う。** 実測では `-1080` という名前の 96% が 600×600 で、名前は当てにならない
98
+ * **否認済み(`PRE_DISAPPROVED` / `POST_DISAPPROVED`)は取り込まない。** 入れると「選べるのに入稿で落ちる」状態になる
99
+ * 既存と違う mediaId が媒体にあった場合は**上書きせず報告する**。媒体側で差し替えられた可能性があるため、人が判断する
100
+
75
101
  #### 素材のサムネを取得する(アカウントが決まった直後・#157)
76
102
 
77
103
  **アカウントJSON にトップレベル `thumbs` が無ければ、確認せずに取得してよい。**
@@ -905,6 +931,12 @@ npx -y addashboard-cli@latest list --platform yda --resource ads \
905
931
  - モード: **新規**(12,000円/日・1CP=1AG=1広告を量産)/ **既存ASC**(5万〜10万円/日・まとめ入稿)/ 既存-広告グループ追加 / 既存-広告追加。
906
932
  - **YDA のバナー(静止画・固定サイズ)は API 入稿に対応済**(2026-08-19 本番実機で `AdGroupAdService/add` 成功)。プランナーの広告行「広告タイプ」列で **レスポンシブ / バナー** を選ぶだけで、レスポンシブと同じ入稿計画JSON → `submit-plan` に乗る(**手入稿フラグという仕組みは存在しない**。過去のドキュメント記述だけが残っていた)。
907
933
  - **画像プールは共用**: YDA のメディアはアカウントに1ライブラリだけで「バナー用/レスポンシブ用」の区分は無い。バナー行も参照表 `refs.media[画像番号].card` / `.sq` をそのまま引く(形式列 = 画像サイズ Card/SQ)。**広告タイプ(レスポンシブ/バナー)と 形式(Card/SQ)は独立した2軸**。
934
+ - **【重要】バナーに Card(1200×628) は使えない。使えるのは SQ だけ**(2026-10-13 確定・#161)。**Yahoo がバナー広告で 1.91:1 に対応していない**ためで、ツールの制約ではない:
935
+ - 公式の入稿規定でバナー(画像)の対応アスペクト比は 1:1 / 6:5 等。**1.91:1 はレスポンシブ側のサイズ**として載っている(広告事業部に確認済み)
936
+ - 実 API は `220115 Unexpected aspect ratio of an image.` で弾く。**dry-run は実 API を叩かないので通ってしまう**
937
+ - 本番実測(24アカウント・980広告)でも **BANNER_AD × 1200×628 は 0 件**。1200×628 を使う 322 件はすべて RESPONSIVE_AD で、バナーが使っているのは SQUARE_600(374件) と IAB_UAP_MEDIUM_RECTANGLE(42件) だけ
938
+ - **1200×628 を使いたいなら広告タイプをレスポンシブにする。** プランナーが書き出しの手前で止め(`BANNER_CARD_UNSUPPORTED`)、`adops.sh plan` の lint も ERROR で止める(二重)
939
+ - 2026-08-19 の実機検証で add に成功したバナーは **SQUARE_600 / 1080×1080**(コミット `f28e003`)。Card では試されていなかった
908
940
  - **命名**: バナーはタイトル/説明文が存在しないので L 区間を出さない → `{画像番号}{c|s}-{paramCode}`(例 `957c-jv_Cac_148`)。レスポンシブは従来どおり `957c-45L60-jv_Cac_148`。
909
941
  - 入稿計画JSON では `adType:"BANNER_AD"` が載り、`headlines` / `descriptions` / `principal` / `buttonText` は出さない(`adType` 省略時は `RESPONSIVE_AD` 扱い=旧プラン後方互換)。
910
942
  - **YDA のレスポンシブ動画もプランナーから入稿できる**(2026-09-21・#324)。広告行の**形式列で「動画」**を選ぶ(広告タイプはレスポンシブのまま)。API 上は `RESPONSIVE_AD + mainMediaFormat:VIDEO`。
@@ -918,7 +918,7 @@ finalUrl = baseURL + paramCode + "?ad=" + paramCode + "&creative=" + …
918
918
  (cli/test/unit/plannerVersion.test.ts が package.json との一致を検証するので、
919
919
  手で書き換えて放置するとテストが落ちる)。
920
920
  利用者が「どの版のプランナーを開いているか」を言えないと、不具合報告が噛み合わない。 */
921
- const APP_VERSION = '0.14.0'; /* version:sync */
921
+ const APP_VERSION = '0.15.0'; /* version:sync */
922
922
  /* 案A: embed廃止。参照データ(refs)・初期プラン・共通設定は外部JSONから起動時に読み込む。
923
923
  同梱デフォルト: アカウント/<媒体>/index.json の先頭アカウント (refs+principal+projectName+baseArticleUrl) と サンプルプラン.json (project+campaigns)。
924
924
  固有名詞はハードコードせず、すべて外部JSON由来にする。 */
@@ -1189,6 +1189,7 @@ const EC = {
1189
1189
  THUMB_MISSING:'THUMB_MISSING' /* YDA は動画からサムネを自動生成しないので必ず要る */,
1190
1190
  VIDEO_PATH_IGNORED:'VIDEO_PATH_IGNORED' /* 動画 mediaID が解決済みなのにローカル動画パスも入力 (warn) */,
1191
1191
  BANNER_VIDEO_UNSUPPORTED:'BANNER_VIDEO_UNSUPPORTED' /* 広告タイプ=バナー × 形式=動画 は未対応 */,
1192
+ BANNER_CARD_UNSUPPORTED:'BANNER_CARD_UNSUPPORTED' /* 広告タイプ=バナー × 形式=Card(1200x628) は Yahoo が拒否する */,
1192
1193
  /* ByteDance (TikTok) 固有 */
1193
1194
  IDENTITY_MISSING:'IDENTITY_MISSING' /* 広告主として表示する TikTok アカウント未設定 = 広告が作れない */,
1194
1195
  IDENTITY_BC_MISSING:'IDENTITY_BC_MISSING' /* identityType=BC_AUTH_TT なのに BC ID が無い (実運用の identity はほぼ BC_AUTH_TT) */,
@@ -1771,6 +1772,15 @@ PLATFORMS.yda = {
1771
1772
  ・**サムネは必須**。YDA は動画からサムネを自動生成しないので (本番実測)、
1772
1773
  mediaId かローカルパスのどちらかが無いと広告作成が R0001 で落ちる
1773
1774
  ・バナー × 動画 は backend の zod が弾く組み合わせ。dry-run より手前で気づけるよう err にする */
1775
+ /* バナー × Card(1200x628) は **Yahoo が受け付けない組み合わせ**。
1776
+ 公式の入稿規定でバナー(画像)の対応アスペクト比に 1.91:1 は無く (1:1 / 6:5 等のみ)、
1777
+ 1200x628 はレスポンシブのサイズとして載っている。2026-10-13 に広告事業部へ確認済み。
1778
+ 本番実測でも 24アカウント・980広告のうち BANNER_AD × 1200x628 は 0 件で、
1779
+ 1200x628 を使う 322 件はすべて RESPONSIVE_AD だった。
1780
+ 実 API は 220115 Unexpected aspect ratio で弾くが、**dry-run は実 API を叩かないので通る**。
1781
+ バナー × 動画 と同じく、書き出しの手前で止める。 */
1782
+ if(isBanner && !isVideo && fmt.mediaKey==='card')
1783
+ pushErr('バナー×Card(1200)は入稿不可(レスポンシブにするか SQ を使う)', EC.BANNER_CARD_UNSUPPORTED);
1774
1784
  const videoPath=String(ad.videoPath||'').trim();
1775
1785
  const thumbnailPath=String(ad.thumbnailPath||'').trim();
1776
1786
  if(isVideo){
@@ -8,6 +8,7 @@
8
8
  # --wait は入稿ボタンが押されるまで待ってから処理する
9
9
  # adops.sh open <platform> <accountId> [resource] 管理画面を開く (URLを手で組み立てない)
10
10
  # adops.sh refs-import <platform> <accountId> [--write] 実アカウントから参照表を取り込む
11
+ # adops.sh assets-sync <platform> <accountId> [--write] 媒体の素材を参照表(refs.media)へ取り込む
11
12
  # adops.sh refs-import google <accountId> --from-plan <入稿計画.json> [--write]
12
13
  # 入稿で作ったロゴを取り込む(実APIを叩かない)
13
14
  #
@@ -357,6 +358,13 @@ cmd_refs_import() {
357
358
  python3 "${SKILL_DIR}/scripts/refs_import.py" "$@"
358
359
  }
359
360
 
361
+ # 媒体に登録済みの素材を参照表(refs.media)へ取り込む。
362
+ # refs-import は見出し/説明文/オーディエンス/配置面だけで media を扱わないため分けてある。
363
+ # --write なしは差分表示のみ(dry-run 既定)。
364
+ cmd_assets_sync() {
365
+ python3 "${SKILL_DIR}/scripts/assets_sync.py" "$@"
366
+ }
367
+
360
368
  # 素材のサムネイルを落として アカウント/<媒体>/サムネ/<accountId>/ に置く。
361
369
  # プランナーの素材選択でサムネを出すために使う。保存先は .gitignore 済み。
362
370
  cmd_thumbs() {
@@ -369,6 +377,7 @@ case "${1:-}" in
369
377
  plan) shift; cmd_plan "$@" ;;
370
378
  open) shift; cmd_open "$@" ;;
371
379
  refs-import) shift; cmd_refs_import "$@" ;;
380
+ assets-sync) shift; cmd_assets_sync "$@" ;;
372
381
  thumbs) shift; cmd_thumbs "$@" ;;
373
382
  *)
374
383
  cat >&2 <<EOF
@@ -386,6 +395,9 @@ usage:
386
395
  adops.sh refs-import <platform> <accountId> [--write] [--campaign-id <id>]
387
396
  adops.sh refs-import google <accountId> --from-plan <入稿計画.json> [--write]
388
397
  実アカウントから参照表を取り込む(既定 dry-run)
398
+ adops.sh assets-sync <platform> <accountId> [--write]
399
+ 媒体の素材を参照表(refs.media)へ取り込む(既定 dry-run)
400
+ 既存の番号・値は変更せず、足りない分だけ足す
389
401
  adops.sh thumbs <platform> <accountId> [--force] [--limit N]
390
402
  素材のサムネイルを落として素材選択で見られるようにする
391
403
  (保存先は アカウント/<媒体>/サムネ/。追跡・公開しない)
@@ -0,0 +1,277 @@
1
+ #!/usr/bin/env python3
2
+ """媒体に登録済みの素材を参照表(refs.media)へ取り込む。
3
+
4
+ `adops.sh assets-sync <platform> <accountId> [--write]` から呼ばれる。単体でも動く:
5
+
6
+ python3 assets_sync.py yda <accountId> [--write]
7
+
8
+ refs_import.py が見出し・説明文・オーディエンス・配置面を扱うのに対し、
9
+ こちらは **media 専用**。両者は番号空間も取得元も違うので分けてある。
10
+
11
+ ━━ なぜ要るか ━━
12
+ 参照表は「初回に作る」手順(プランナーの ❓使い方 → アカウント取込プロンプト)はあるが、
13
+ **後から追記する手段が無かった**。媒体に素材を足してもプランナーの選択肢に出ないため、
14
+ 「媒体にはあるのに入稿できない」という状態になる(#325)。
15
+
16
+ ━━ 振り分けはファイル名ではなく実寸で行う ━━
17
+ mediaName の `-1080` / `-1200` は実サイズとズレている(実測: `-1080` の 96% が 600x600、
18
+ `-1200` で 2400x1256 のものもある)。アップロード時に `元ファイル名 + contentHash` へ
19
+ 加工される経路もあるので、名前は振り分けの根拠にしない。
20
+
21
+ aspectRatio が 1.91:1 系 → card
22
+ aspectRatio が 1:1 系 → sq
23
+ それ以外 → 取り込まない(件数だけ報告)
24
+
25
+ ━━ 既存は絶対に触らない ━━
26
+ 画像番号は広告名(`957c-jv_Cac_148`)と計測パラメータ(`creative=`)に焼き込まれている。
27
+ 番号がずれると過去の実績と突き合わせられなくなるので、
28
+
29
+ * 既存の番号は振り直さない
30
+ * 既存の値は上書きしない(違う mediaId を見つけても報告だけ)
31
+ * 媒体から消えたものは削除しない
32
+
33
+ ━━ 取りきれていないときは書かない ━━
34
+ backend の MediaService 取得は 500 件固定でページング未対応。全件取れていない状態で
35
+ 書き込むと「媒体に無い」と誤認した欠落データを正にしてしまう。取得が怪しいときは
36
+ 書き込まずに報告して終わる(呼び出し側は既存の参照表のまま先へ進める)。
37
+ """
38
+ import argparse
39
+ import glob
40
+ import json
41
+ import os
42
+ import re
43
+ import shutil
44
+ import subprocess
45
+ import sys
46
+ from datetime import datetime
47
+
48
+ SKILL_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
49
+ CLI = os.environ.get("ADOPS_CLI", "npx -y addashboard-cli@latest")
50
+
51
+ # backend の MediaService 取得が 1 回で返す上限。ここに達していたら全件取れていない
52
+ # 可能性があるので書き込まない(connector 側が thumbnailFlg 等を間引くため、
53
+ # 見える件数が上限未満でも取りこぼしはありうる。あくまで「明らかに怪しい」の検知)。
54
+ FETCH_LIMIT = 500
55
+
56
+ # 取り込んでよい審査状態。
57
+ # APPROVED … 承認済み
58
+ # PENDING_UNSPECIFIED_AD … まだ広告に使われていないだけ。否認ではない
59
+ # PRE_DISAPPROVED / POST_DISAPPROVED を入れると「選べるのに入稿で落ちる」状態になる。
60
+ USABLE_APPROVAL = {"APPROVED", "PENDING_UNSPECIFIED_AD"}
61
+
62
+ # aspectRatio → 参照表のスロット。媒体の表記ゆれ(AR_191_100 / AR_1200_628)を吸収する。
63
+ SLOT_BY_ASPECT = {
64
+ "AR_191_100": "card",
65
+ "AR_1200_628": "card",
66
+ "AR_1_1": "sq",
67
+ }
68
+
69
+
70
+ def fetch_media(platform: str, account_id: str) -> list | None:
71
+ """登録済み素材の一覧を取る。取れなければ None(空配列と区別する)。
72
+
73
+ 「取得失敗」を「素材が0件」と取り違えると、参照表を空にしかねない。
74
+ """
75
+ cmd = [*CLI.split(), "list", "--platform", platform,
76
+ "--resource", "media", "--account-id", account_id]
77
+ print("▸ 媒体の素材一覧を取得中...", file=sys.stderr)
78
+ try:
79
+ proc = subprocess.run(cmd, capture_output=True, text=True, timeout=600)
80
+ except Exception as e: # noqa: BLE001
81
+ print(f" ✗ 取得に失敗: {e}", file=sys.stderr)
82
+ return None
83
+ if proc.returncode != 0:
84
+ print(f" ✗ 取得に失敗 (exit {proc.returncode}): {proc.stderr.strip()[:400]}",
85
+ file=sys.stderr)
86
+ return None
87
+ try:
88
+ data = json.loads(proc.stdout)
89
+ except Exception as e: # noqa: BLE001
90
+ print(f" ✗ 応答を解釈できません: {e}", file=sys.stderr)
91
+ return None
92
+ media = data.get("media")
93
+ if not isinstance(media, list):
94
+ print(" ✗ 応答に media がありません", file=sys.stderr)
95
+ return None
96
+ return media
97
+
98
+
99
+ def find_account(platform: str, account_id: str) -> tuple[str | None, dict]:
100
+ """accountId から参照表のパスと内容を引く(sample_account は最後に回す)。"""
101
+ paths = [p for p in glob.glob(os.path.join(SKILL_DIR, "アカウント", platform, "*.json"))
102
+ if os.path.basename(p) != "index.json"]
103
+ paths.sort(key=lambda p: os.path.basename(p) == "sample_account.json")
104
+ for path in paths:
105
+ try:
106
+ data = json.load(open(path, encoding="utf-8"))
107
+ except Exception: # noqa: BLE001
108
+ continue
109
+ if str(data.get("accountId", "")) == str(account_id):
110
+ return path, data
111
+ return None, {}
112
+
113
+
114
+ def image_no(media_name: object) -> str | None:
115
+ """素材名の先頭の数字を画像番号として取る(`183-1200.jpg` → `183`)。
116
+
117
+ 先頭が数字でないものは番号を決められないので取り込まない。推測で番号を振ると
118
+ 既存の番号体系を壊すため、ここは厳格にする。
119
+ """
120
+ m = re.match(r"(\d+)", str(media_name or ""))
121
+ return m.group(1) if m else None
122
+
123
+
124
+ def classify(media: list) -> dict:
125
+ """素材一覧を (番号, スロット) → mediaId の候補表に畳む。
126
+
127
+ 同じ組み合わせに複数の素材が当たったら、どれを採るか機械的に決められないので
128
+ 候補をすべて残して呼び出し側に判断させる(自動採用しない)。
129
+ """
130
+ out = {
131
+ "candidates": {}, # (no, slot) -> [mediaId, ...]
132
+ "skipped_approval": 0, # 否認済み
133
+ "skipped_aspect": 0, # card/sq に当てはまらない比率
134
+ "skipped_name": 0, # 番号を取れない
135
+ }
136
+ for m in media:
137
+ mid = str(m.get("mediaId") or "")
138
+ if not mid:
139
+ continue
140
+ if (m.get("approvalStatus") or "") not in USABLE_APPROVAL:
141
+ out["skipped_approval"] += 1
142
+ continue
143
+ slot = SLOT_BY_ASPECT.get(m.get("aspectRatio") or "")
144
+ if not slot:
145
+ out["skipped_aspect"] += 1
146
+ continue
147
+ no = image_no(m.get("mediaName"))
148
+ if not no:
149
+ out["skipped_name"] += 1
150
+ continue
151
+ out["candidates"].setdefault((no, slot), []).append(mid)
152
+ return out
153
+
154
+
155
+ def merge(refs_media: dict, classified: dict) -> tuple[dict, list[str], dict]:
156
+ """既存を保ったまま不足分だけ足す。
157
+
158
+ returns (merged, report, stats)
159
+ """
160
+ merged = {k: dict(v) if isinstance(v, dict) else v for k, v in (refs_media or {}).items()}
161
+ added, ambiguous, conflicts = [], [], []
162
+
163
+ for (no, slot), ids in sorted(classified["candidates"].items(), key=lambda x: (int(x[0][0]), x[0][1])):
164
+ cur = merged.get(no)
165
+ if cur is not None and not isinstance(cur, dict):
166
+ continue # 想定外の形。触らない
167
+ if len(ids) > 1:
168
+ # どれが正か決められない。既に値があるならそのまま、無ければ空けておく
169
+ ambiguous.append((no, slot, ids))
170
+ continue
171
+ mid = ids[0]
172
+ if cur is None:
173
+ merged[no] = {slot: mid}
174
+ added.append((no, slot, mid, "新規番号"))
175
+ elif slot not in cur:
176
+ cur[slot] = mid
177
+ added.append((no, slot, mid, "枠を追加"))
178
+ elif str(cur[slot]) != mid:
179
+ # 既存の値は上書きしない。媒体側で差し替わっている可能性を報告だけする
180
+ conflicts.append((no, slot, str(cur[slot]), mid))
181
+
182
+ report = []
183
+ report.append(f"追加: {len(added)} 件"
184
+ f"(card {sum(1 for a in added if a[1] == 'card')} / "
185
+ f"sq {sum(1 for a in added if a[1] == 'sq')})")
186
+ if ambiguous:
187
+ report.append(f"⚠ 同じ番号・同じ形式に候補が複数あるため取り込みませんでした: {len(ambiguous)} 件")
188
+ for no, slot, ids in ambiguous[:5]:
189
+ report.append(f" 番号 {no} / {slot}: {', '.join(ids)}")
190
+ if conflicts:
191
+ report.append(f"⚠ 既存と違う mediaId が媒体にあります(上書きしていません): {len(conflicts)} 件")
192
+ for no, slot, old, new in conflicts[:5]:
193
+ report.append(f" 番号 {no} / {slot}: 参照表 {old} / 媒体 {new}")
194
+ for key, label in (("skipped_approval", "審査で否認された素材"),
195
+ ("skipped_aspect", "card/sq に当てはまらない比率の素材"),
196
+ ("skipped_name", "名前から番号を取れない素材")):
197
+ if classified[key]:
198
+ report.append(f"除外: {label} {classified[key]} 件")
199
+
200
+ stats = {"added": len(added), "ambiguous": len(ambiguous), "conflicts": len(conflicts)}
201
+ return merged, report, stats
202
+
203
+
204
+ def main() -> int:
205
+ ap = argparse.ArgumentParser(description="媒体の素材を参照表(refs.media)へ取り込む")
206
+ ap.add_argument("platform", choices=["yda"],
207
+ help="現在は yda のみ。媒体ごとに番号の付け方が違うため横展開はしない")
208
+ ap.add_argument("account_id")
209
+ ap.add_argument("--write", action="store_true", help="参照表へ書き込む(既定は差分表示のみ)")
210
+ args = ap.parse_args()
211
+
212
+ path, account = find_account(args.platform, args.account_id)
213
+ if not path:
214
+ print(f"✗ accountId {args.account_id} の参照表が見つかりません", file=sys.stderr)
215
+ print(" 初めて使うアカウントは、先にアカウント取込プロンプトで作成してください",
216
+ file=sys.stderr)
217
+ return 1
218
+
219
+ media = fetch_media(args.platform, args.account_id)
220
+ if media is None:
221
+ print("✗ 素材一覧を取得できなかったため、参照表は変更していません")
222
+ return 1
223
+
224
+ print(f" 媒体の素材: {len(media)} 件")
225
+
226
+ # 取得上限に達している = 全件取れていない可能性がある。欠落したまま書くと危険なので止める。
227
+ truncated = len(media) >= FETCH_LIMIT
228
+ classified = classify(media)
229
+ refs = (account.get("refs") or {}).get("media") or {}
230
+ merged, report, stats = merge(refs, classified)
231
+
232
+ print(f"\n── {os.path.basename(path)} ──")
233
+ before_card = sum(1 for v in refs.values() if isinstance(v, dict) and v.get("card"))
234
+ before_sq = sum(1 for v in refs.values() if isinstance(v, dict) and v.get("sq"))
235
+ after_card = sum(1 for v in merged.values() if isinstance(v, dict) and v.get("card"))
236
+ after_sq = sum(1 for v in merged.values() if isinstance(v, dict) and v.get("sq"))
237
+ print(f" 画像番号 {len(refs)} → {len(merged)}")
238
+ print(f" card {before_card} → {after_card}")
239
+ print(f" sq {before_sq} → {after_sq}")
240
+ for line in report:
241
+ print(f" {line}")
242
+
243
+ if truncated:
244
+ print(f"\n✗ 取得件数が上限({FETCH_LIMIT})に達しています。全件取れていない可能性が"
245
+ "あるため書き込みません。")
246
+ print(" ページング対応が入るまで、このアカウントは手動で参照表を整えてください。")
247
+ return 2
248
+
249
+ if stats["added"] == 0:
250
+ print("\n(参照表は最新です)")
251
+ return 0
252
+
253
+ if not args.write:
254
+ print("\n(dry-run: 書き込んでいません。反映するには --write を付けてください)")
255
+ return 0
256
+
257
+ # 書き込み前に必ず退避する。固定名だと2回目の write で最初の退避が消えるので世代を残す。
258
+ stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
259
+ backup, n = f"{path}.{stamp}.bak", 1
260
+ while os.path.exists(backup):
261
+ backup, n = f"{path}.{stamp}-{n}.bak", n + 1
262
+ shutil.copy2(path, backup)
263
+
264
+ account.setdefault("refs", {})["media"] = merged
265
+ # 一時ファイルへ書いてから rename する。途中で落ちても元が壊れない
266
+ tmp = f"{path}.tmp"
267
+ with open(tmp, "w", encoding="utf-8") as f:
268
+ json.dump(account, f, indent=1, ensure_ascii=False)
269
+ f.write("\n")
270
+ os.replace(tmp, path)
271
+ print(f"\n✓ 書き込みました: {path}")
272
+ print(f" 退避: {os.path.basename(backup)}(元に戻すならこれを上書きコピー)")
273
+ return 0
274
+
275
+
276
+ if __name__ == "__main__":
277
+ sys.exit(main())
@@ -467,6 +467,14 @@ def lint(plan: dict, plan_path: str = "") -> None:
467
467
  # refs の鮮度と無関係にローカルで確定する
468
468
  plan_cp_names: set[str] = set()
469
469
 
470
+ # YDA のバナー広告は 1200x628 (Card) を受け付けない。入稿計画JSONに画像サイズは
471
+ # 載らないので、参照表の card 側に登録されている mediaId を集めて逆引きする。
472
+ card_media_ids = {
473
+ str(v.get("card"))
474
+ for v in (account.get("refs", {}).get("media") or {}).values()
475
+ if isinstance(v, dict) and v.get("card")
476
+ }
477
+
470
478
  if not base:
471
479
  warn(f"アカウントJSONに baseArticleUrl がありません({platform}/{plan.get('accountId')})。"
472
480
  " finalUrl が相対パスになる原因になります")
@@ -579,6 +587,22 @@ def lint(plan: dict, plan_path: str = "") -> None:
579
587
  f" → 解決先 {resolved}(相対パスの基準は計画JSONのディレクトリ "
580
588
  f"{base_dir})")
581
589
 
590
+ # YDA のバナー広告 (BANNER_AD) に 1200x628 の画像は使えない。
591
+ # 公式の入稿規定でバナー(画像)の対応アスペクト比は 1:1 / 6:5 等で、
592
+ # 1.91:1 はレスポンシブ側のサイズ (2026-10-13 に広告事業部へ確認済み)。
593
+ # 実 API は 220115 Unexpected aspect ratio で弾くが dry-run は通るので、
594
+ # プランナーと二重に止める (プランナーを経由しない計画にも効かせる)。
595
+ if platform == "yda" and (a.get("adType") or "RESPONSIVE_AD") == "BANNER_AD":
596
+ banner_cards = [
597
+ str(m) for m in (a.get("mediaIds") or []) if str(m) in card_media_ids
598
+ ]
599
+ if banner_cards:
600
+ err(f"CP「{name}」のバナー広告に Card(1200x628) の画像が指定されています"
601
+ f"({a.get('adName') or ''} / mediaId {', '.join(banner_cards)})\n"
602
+ f" → Yahoo はバナー広告の 1200x628 を受け付けません"
603
+ f"(220115 Unexpected aspect ratio)。"
604
+ f"広告タイプをレスポンシブにするか、形式を SQ にしてください")
605
+
582
606
  # Google (Demand Gen) の動画は「動画IDかローカルパスのどちらか一方」。
583
607
  # 両方あると CLI が余計にアップロードし、使われない動画が YouTube に残る。
584
608
  # backend も superRefine で 400 にするが、CLI はその手前でアップロードを
@@ -0,0 +1,132 @@
1
+ #!/usr/bin/env python3
2
+ """assets_sync.py の単体テスト(実 API を叩かない)。
3
+
4
+ python3 test_assets_sync.py
5
+
6
+ 媒体のレスポンスを固定データで与え、振り分け・除外・マージの判断を確かめる。
7
+ 参照表は「既存を絶対に壊さない」のが最重要なので、そこを厚く見る。
8
+ """
9
+ import json
10
+ import os
11
+ import subprocess
12
+ import sys
13
+ import tempfile
14
+
15
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
16
+ import assets_sync as S # noqa: E402
17
+
18
+ ok = 0
19
+ ng = []
20
+
21
+
22
+ def check(name: str, cond: bool, detail: str = "") -> None:
23
+ global ok
24
+ if cond:
25
+ ok += 1
26
+ print(f" ✓ {name}")
27
+ else:
28
+ ng.append(f"{name}{(': ' + detail) if detail else ''}")
29
+ print(f" ✗ {name}{(': ' + detail) if detail else ''}")
30
+
31
+
32
+ def media(mid, name, aspect, approval="APPROVED"):
33
+ return {"mediaId": mid, "mediaName": name, "aspectRatio": aspect,
34
+ "approvalStatus": approval}
35
+
36
+
37
+ print("── 振り分け(実寸で判定し、名前は使わない) ──")
38
+ c = S.classify([
39
+ media("1001", "10-1200.jpg", "AR_191_100"),
40
+ media("1002", "10-1080.jpg", "AR_1_1"),
41
+ media("1003", "11-1200.jpg", "AR_1200_628"), # 表記ゆれも card に寄せる
42
+ ])
43
+ check("1.91:1 は card", c["candidates"].get(("10", "card")) == ["1001"])
44
+ check("1:1 は sq", c["candidates"].get(("10", "sq")) == ["1002"])
45
+ check("AR_1200_628 も card", c["candidates"].get(("11", "card")) == ["1003"])
46
+
47
+ # 名前が -1080 でも実寸が 1.91:1 なら card。実データの96%が名前とズレているため
48
+ c = S.classify([media("2001", "20-1080.jpg", "AR_191_100")])
49
+ check("名前が -1080 でも実寸が 1.91:1 なら card",
50
+ c["candidates"].get(("20", "card")) == ["2001"])
51
+
52
+ print("\n── 除外 ──")
53
+ c = S.classify([
54
+ media("3001", "30-1200.jpg", "AR_191_100", "PRE_DISAPPROVED"),
55
+ media("3002", "31-1200.jpg", "AR_191_100", "POST_DISAPPROVED"),
56
+ media("3003", "32-1200.jpg", "AR_191_100", "PENDING_UNSPECIFIED_AD"),
57
+ ])
58
+ check("否認済み(PRE/POST)は取り込まない", c["skipped_approval"] == 2)
59
+ check("審査保留(PENDING)は取り込む", ("32", "card") in c["candidates"])
60
+
61
+ c = S.classify([media("4001", "40-xx.jpg", "AR_4_5")])
62
+ check("card/sq 以外の比率は取り込まない", c["skipped_aspect"] == 1 and not c["candidates"])
63
+
64
+ c = S.classify([media("5001", "banner-1200.jpg", "AR_191_100")])
65
+ check("名前から番号を取れないものは取り込まない",
66
+ c["skipped_name"] == 1 and not c["candidates"])
67
+
68
+ print("\n── 既存を壊さない ──")
69
+ existing = {"10": {"card": "9999"}, "20": {"sq": "8888"}}
70
+ c = S.classify([
71
+ media("1001", "10-1200.jpg", "AR_191_100"), # 既存と違う mediaId
72
+ media("1002", "10-1080.jpg", "AR_1_1"), # 空いている枠
73
+ media("2002", "20-1080.jpg", "AR_1_1"), # 既存と違う mediaId
74
+ ])
75
+ merged, report, stats = S.merge(existing, c)
76
+ check("既存の値を上書きしない", merged["10"]["card"] == "9999",
77
+ f'got {merged["10"]["card"]}')
78
+ check("空いている枠には足す", merged["10"].get("sq") == "1002")
79
+ check("食い違いを報告する", stats["conflicts"] == 2, f'got {stats["conflicts"]}')
80
+ check("追加件数が合う", stats["added"] == 1, f'got {stats["added"]}')
81
+
82
+ print("\n── 同じ番号・同じ形式に候補が複数 ──")
83
+ c = S.classify([
84
+ media("6001", "60-1200.jpg", "AR_191_100"),
85
+ media("6002", "60-1200_dup.jpg", "AR_191_100"),
86
+ ])
87
+ merged, report, stats = S.merge({}, c)
88
+ check("どれが正か決められないので取り込まない", "60" not in merged)
89
+ check("候補が複数あることを報告する", stats["ambiguous"] == 1)
90
+
91
+ print("\n── 新規番号 ──")
92
+ c = S.classify([media("7001", "70-1200.jpg", "AR_191_100")])
93
+ merged, report, stats = S.merge({}, c)
94
+ check("参照表に無い番号は新規に作る", merged.get("70") == {"card": "7001"})
95
+
96
+ print("\n── CLI の挙動(実 API は叩かない) ──")
97
+ with tempfile.TemporaryDirectory() as td:
98
+ acc_dir = os.path.join(td, "アカウント", "yda")
99
+ os.makedirs(acc_dir)
100
+ acc_path = os.path.join(acc_dir, "テスト案件.json")
101
+ with open(acc_path, "w", encoding="utf-8") as f:
102
+ json.dump({"accountId": "999", "refs": {"media": {"1": {"sq": "111"}}}},
103
+ f, ensure_ascii=False)
104
+ before = open(acc_path, encoding="utf-8").read()
105
+
106
+ env = {**os.environ, "ADOPS_CLI": "false"} # 必ず失敗する CLI を使わせる
107
+ script = os.path.join(os.path.dirname(os.path.abspath(__file__)), "assets_sync.py")
108
+ # SKILL_DIR を一時ディレクトリに向けるため、スクリプトを一時側へ写して実行する
109
+ tmp_scripts = os.path.join(td, "scripts")
110
+ os.makedirs(tmp_scripts)
111
+ import shutil
112
+ shutil.copy2(script, os.path.join(tmp_scripts, "assets_sync.py"))
113
+ p = subprocess.run([sys.executable, os.path.join(tmp_scripts, "assets_sync.py"),
114
+ "yda", "999", "--write"],
115
+ capture_output=True, text=True, env=env)
116
+ check("取得に失敗したら書き込まない(終了コード1)", p.returncode == 1,
117
+ f"exit={p.returncode}")
118
+ check("取得に失敗しても参照表が変わらない",
119
+ open(acc_path, encoding="utf-8").read() == before)
120
+
121
+ p = subprocess.run([sys.executable, os.path.join(tmp_scripts, "assets_sync.py"),
122
+ "yda", "不明なID"],
123
+ capture_output=True, text=True, env=env)
124
+ check("参照表が無いアカウントはエラーで止まる", p.returncode == 1)
125
+
126
+ print()
127
+ if ng:
128
+ print(f"✗ {len(ng)} 件failed / {ok} 件passed")
129
+ for n in ng:
130
+ print(f" - {n}")
131
+ sys.exit(1)
132
+ print(f"✓ {ok} 件すべて passed")