addashboard-cli 0.6.0 → 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.
@@ -2,8 +2,11 @@
2
2
  # ad-ops スキルの単一エントリポイント。
3
3
  #
4
4
  # adops.sh preflight <platform> <accountId> フェーズ0: ログイン + アカウント疎通確認
5
- # adops.sh planner [--stop] フェーズ2: プランナーをHTTPで開く (file:// 禁止)
5
+ # adops.sh planner [--platform <媒体> --account <案件名>] [--stop]
6
+ # フェーズ2: プランナーをHTTPで開く (file:// 禁止)
6
7
  # adops.sh plan [<入稿計画.json>] フェーズ3: DL回収 + 要約 + lint + 合意との差分
8
+ # adops.sh open <platform> <accountId> [resource] 管理画面を開く (URLを手で組み立てない)
9
+ # adops.sh refs-import <platform> <accountId> [--write] 実アカウントから参照表を取り込む
7
10
  #
8
11
  # 覚えるコマンドを1本に畳むためのラッパー。個別の python3 -c / curl / ls -t を書かない。
9
12
  set -uo pipefail
@@ -57,13 +60,60 @@ PY
57
60
  }
58
61
 
59
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
+
60
97
  # file:// で開くと fetch('サンプルプラン.json') がブロックされ、
61
98
  # エラーも出ないまま空のプランナーが表示される。必ずHTTPで開く。
62
99
  cmd_planner() {
63
- if [ "${1:-}" = "--stop" ]; then
64
- pkill -f "http.server ${PORT}" && echo "✓ プランナーの配信を停止しました" \
65
- || echo "(起動中のサーバーはありませんでした)"
66
- return 0
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"
67
117
  fi
68
118
 
69
119
  if curl -sf -o /dev/null "$URL"; then
@@ -91,16 +141,103 @@ cmd_plan() {
91
141
  python3 "${SKILL_DIR}/scripts/plan_lint.py" "$@"
92
142
  }
93
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
+
94
221
  case "${1:-}" in
95
- preflight) shift; cmd_preflight "$@" ;;
96
- planner) shift; cmd_planner "$@" ;;
97
- plan) shift; cmd_plan "$@" ;;
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 "$@" ;;
98
227
  *)
99
228
  cat >&2 <<EOF
100
229
  usage:
101
230
  adops.sh preflight <platform> <accountId> フェーズ0: ログイン + アカウント疎通確認
102
- adops.sh planner [--stop] フェーズ2: プランナーをHTTPで開く
231
+ adops.sh planner [--platform <媒体> --account <案件名>] [--stop]
232
+ フェーズ2: プランナーをHTTPで開く
233
+ --platform/--account を渡すと「作業中プラン」を
234
+ 設定してから開く(省略時は前回の設定を引き継ぐ)
103
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)
104
241
  EOF
105
242
  exit 2 ;;
106
243
  esac
@@ -111,24 +111,44 @@ def pick_plan(argv: list[str]) -> str:
111
111
 
112
112
  # ------------------------------------------------------- 共通: 値の取り出し
113
113
  def param_code_of(ad: dict, group: dict) -> str:
114
- """広告グループの paramCode。export形式には無いので finalUrl の ?ad= から復元する。"""
114
+ """広告グループの paramCode。
115
+
116
+ 優先順: group.paramCode → finalUrl の ?ad= → 広告グループ名の【…】。
117
+ URLパラメータ無し運用(urlParamsEnabled: false)でも AG 名から復元できるようにする。
118
+ """
115
119
  if group.get("paramCode"):
116
120
  return str(group["paramCode"])
117
121
  url = ad.get("finalUrl") or ""
118
122
  q = urllib.parse.parse_qs(urllib.parse.urlparse(url).query)
119
- return (q.get("ad") or [""])[0]
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 ""
120
128
 
121
129
 
122
130
  def image_no_of(ad: dict) -> str:
123
- """creative パラメータ '<code>-1047c-45L60-<code>' から画像番号を復元する。"""
131
+ """画像番号。ad.imageNo → creative パラメータ → 広告名 '1047c-...' の順で復元する。"""
124
132
  if ad.get("imageNo"):
125
133
  return str(ad["imageNo"])
126
134
  url = ad.get("finalUrl") or ""
127
135
  q = urllib.parse.parse_qs(urllib.parse.urlparse(url).query)
128
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 ""))
129
140
  return m.group(1) if m else ""
130
141
 
131
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
+
132
152
  def ad_groups(campaign: dict) -> list:
133
153
  return campaign.get("adGroups") or campaign.get("adSets") or []
134
154
 
@@ -159,9 +179,12 @@ def summarize(plan: dict) -> None:
159
179
 
160
180
 
161
181
  # --------------------------------------------------------------- 3. lint
162
- def lint(plan: dict) -> None:
182
+ def lint(plan: dict, plan_path: str = "") -> None:
163
183
  platform = plan.get("platform", "yda")
164
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()
165
188
  base = account.get("baseArticleUrl") or ""
166
189
  base_host = urllib.parse.urlparse(base).netloc if base else ""
167
190
  known_codes = {
@@ -189,6 +212,10 @@ def lint(plan: dict) -> None:
189
212
  gname = g.get("name", "")
190
213
  if SUSPICIOUS_NAME.search(gname):
191
214
  warn(f"広告グループ名「{gname}」に検証用らしき語が残っています")
215
+ # 方針: スマートターゲティングは常時 ON。明示 false は事故を疑う
216
+ if g.get("smartTargeting") is False:
217
+ warn(f"広告グループ「{gname}」の smartTargeting が false です"
218
+ f"(方針は常時ON)。意図的でなければ外してください")
192
219
  for a in g.get("ads") or []:
193
220
  url = a.get("finalUrl") or ""
194
221
  if not re.match(r"https?://", url):
@@ -209,9 +236,42 @@ def lint(plan: dict) -> None:
209
236
  else:
210
237
  seen_media[m] = name
211
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
+
212
267
  code = param_code_of(a, g)
213
268
  if not code:
214
- err(f"CP「{name}」の paramCode が特定できません")
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"広告グループ名に【コード】を入れると復元できます")
215
275
  elif code in known_codes:
216
276
  err(f"paramCode {code} はアカウントの既存広告グループと重複しています"
217
277
  f"({name})。新規なら未使用の番号に変えてください")
@@ -275,7 +335,7 @@ def main() -> int:
275
335
  print(f"📄 {path}")
276
336
 
277
337
  summarize(plan)
278
- lint(plan)
338
+ lint(plan, path)
279
339
  diff_against_sample(plan)
280
340
 
281
341
  print("\n── lint(dry-run が検出しない項目)──")
@@ -0,0 +1,348 @@
1
+ #!/usr/bin/env python3
2
+ """実アカウントから参照表(見出し・説明文・オーディエンス・配置面)を取り込む。
3
+
4
+ `adops.sh refs-import <platform> <accountId> [--write]` から呼ばれる。単体でも動く:
5
+
6
+ python3 refs_import.py yda 1002811292 [--write] [--campaign-id 32430712]
7
+
8
+ `webgram list` を campaigns / ads / resources の3系統で叩き、
9
+ `アカウント/<platform>/<name>.json` にマージする。--write なしは差分表示のみ。
10
+
11
+ ━━ 採番アルゴリズム(最重要)━━
12
+ 既存の番号は **絶対に振り直さない**。既存番号は過去の入稿計画・広告名・
13
+ `creative` パラメータに焼き込まれており、renumber は計測を壊す。
14
+
15
+ 1. 比較用 norm(s): NFKC → trim → 連続空白畳み込み。**比較にのみ使い、保存は原文**
16
+ 2. 既存 refs.titles を {norm(値): 番号} に反転
17
+ 3. 取得した headline が既存に一致 → その番号を再利用
18
+ 4. 未知は max(既存の整数キー)+1 から (adGroupId, adId) 昇順の初出順で採番(決定的)
19
+ 5. 同一テキストが複数広告に出たら1番号に名寄せ
20
+ 6. descriptions も同手順(独立した番号空間)
21
+ 7. 消えた audiences / placements の ID は **削除せず "stale": true** を付ける
22
+ """
23
+ import argparse
24
+ import glob
25
+ import json
26
+ import os
27
+ import re
28
+ import subprocess
29
+ import sys
30
+ import unicodedata
31
+ import urllib.parse
32
+
33
+ SKILL_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
34
+ CLI = os.environ.get("ADOPS_CLI", "npx -y addashboard-cli@latest")
35
+
36
+
37
+ # ---------------------------------------------------------------- 共通
38
+ def norm(s: object) -> str:
39
+ """比較専用の正規化。保存するのは常に原文であることに注意。"""
40
+ t = unicodedata.normalize("NFKC", str(s or "")).strip()
41
+ return re.sub(r"\s+", " ", t)
42
+
43
+
44
+ def int_keys(d: dict) -> list[int]:
45
+ out = []
46
+ for k in d or {}:
47
+ try:
48
+ out.append(int(k))
49
+ except (TypeError, ValueError):
50
+ pass
51
+ return out
52
+
53
+
54
+ def run_list(resource: str, account_id: str, **opts) -> dict:
55
+ """webgram list を1回叩いて JSON を返す。失敗したら空dict + stderr に理由。"""
56
+ cmd = [*CLI.split(), "list", "--platform", opts.pop("platform", "yda"),
57
+ "--resource", resource, "--account-id", account_id]
58
+ for k, v in opts.items():
59
+ if v:
60
+ cmd += [f"--{k}", str(v)]
61
+ print(f"▸ {resource} を取得中...", file=sys.stderr)
62
+ try:
63
+ proc = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
64
+ except Exception as e: # noqa: BLE001
65
+ print(f" ✗ {resource} の取得に失敗: {e}", file=sys.stderr)
66
+ return {}
67
+ if proc.returncode != 0:
68
+ print(f" ✗ {resource} の取得に失敗 (exit {proc.returncode}): "
69
+ f"{proc.stderr.strip()[:400]}", file=sys.stderr)
70
+ return {}
71
+ try:
72
+ return json.loads(proc.stdout)
73
+ except Exception as e: # noqa: BLE001
74
+ print(f" ✗ {resource} の応答を解釈できません: {e}", file=sys.stderr)
75
+ return {}
76
+
77
+
78
+ def find_account(platform: str, account_id: str) -> tuple[str | None, dict]:
79
+ """accountId からアカウントJSONのパスと内容を引く(sample_account は最後に回す)。"""
80
+ paths = [p for p in glob.glob(os.path.join(SKILL_DIR, "アカウント", platform, "*.json"))
81
+ if os.path.basename(p) != "index.json"]
82
+ paths.sort(key=lambda p: os.path.basename(p) == "sample_account.json")
83
+ for path in paths:
84
+ try:
85
+ data = json.load(open(path, encoding="utf-8"))
86
+ except Exception: # noqa: BLE001
87
+ continue
88
+ if str(data.get("accountId", "")) == str(account_id):
89
+ return path, data
90
+ return None, {}
91
+
92
+
93
+ # --------------------------------------------------- 採番(既存を絶対に守る)
94
+ def assign_numbers(existing: dict, incoming: list[str]) -> tuple[dict, dict]:
95
+ """既存番号を保ったまま未知テキストへ採番する。
96
+
97
+ existing: {"1": "見出し", ...}(原文)
98
+ incoming: 出現順((adGroupId, adId) 昇順)のテキスト列。重複を含んでよい。
99
+
100
+ returns (merged, stats)
101
+ merged: 既存 + 追加ぶん(既存の番号・原文は不変)
102
+ stats : {"reused": n, "added": n, "merged": n}
103
+ """
104
+ merged = dict(existing or {})
105
+ by_norm: dict[str, str] = {}
106
+ for k, v in merged.items():
107
+ by_norm.setdefault(norm(v), str(k))
108
+
109
+ nxt = (max(int_keys(merged)) + 1) if int_keys(merged) else 1
110
+ stats = {"reused": 0, "added": 0, "merged": 0}
111
+ seen_this_run: set[str] = set()
112
+
113
+ for text in incoming:
114
+ if not str(text or "").strip():
115
+ continue
116
+ n = norm(text)
117
+ if n in by_norm:
118
+ # 既存番号を再利用(振り直さない)。同一 run で2回目なら名寄せ扱い。
119
+ if n in seen_this_run:
120
+ stats["merged"] += 1
121
+ else:
122
+ stats["reused"] += 1
123
+ seen_this_run.add(n)
124
+ continue
125
+ merged[str(nxt)] = text # 保存は原文
126
+ by_norm[n] = str(nxt)
127
+ seen_this_run.add(n)
128
+ nxt += 1
129
+ stats["added"] += 1
130
+ return merged, stats
131
+
132
+
133
+ # ------------------------------------------------------------- 取り込み本体
134
+ def import_yda(account_id: str, campaign_id: str | None) -> dict:
135
+ """YDA から取り込んだ生データを {ads, placements, audiences, campaigns} で返す。"""
136
+ out: dict = {"ads": [], "placements": {}, "audiences": [], "campaigns": {}}
137
+
138
+ camp = run_list("campaigns", account_id, platform="yda")
139
+ out["campaigns"] = camp or {}
140
+
141
+ ad_kwargs = {}
142
+ if campaign_id:
143
+ ad_kwargs["campaign-id"] = campaign_id
144
+ else:
145
+ # campaignId が無いと backend が 400 を返すので、campaigns から全CPを回す
146
+ ids = [str(c.get("campaignId") or c.get("id") or "")
147
+ for c in (camp.get("campaigns") or []) if (c.get("campaignId") or c.get("id"))]
148
+ ads: list = []
149
+ for cid in ids:
150
+ r = run_list("ads", account_id, platform="yda", **{"campaign-id": cid})
151
+ ads += r.get("ads") or []
152
+ out["ads"] = ads
153
+ res = run_list("resources", account_id, platform="yda")
154
+ out["placements"] = res.get("placements") or {}
155
+ out["audiences"] = res.get("audiences") or []
156
+ return out
157
+
158
+ out["ads"] = (run_list("ads", account_id, platform="yda", **ad_kwargs)).get("ads") or []
159
+ res = run_list("resources", account_id, platform="yda")
160
+ out["placements"] = res.get("placements") or {}
161
+ out["audiences"] = res.get("audiences") or []
162
+ return out
163
+
164
+
165
+ def sort_key(ad: dict) -> tuple:
166
+ """(adGroupId, adId) 昇順。数値化できないものは末尾へ。"""
167
+ def num(v):
168
+ try:
169
+ return (0, int(v))
170
+ except (TypeError, ValueError):
171
+ return (1, 0)
172
+ return (num(ad.get("adGroupId")), num(ad.get("adId")))
173
+
174
+
175
+ def merge(account: dict, fetched: dict) -> tuple[dict, list[str]]:
176
+ """アカウントJSON(v1/v2 どちらでも)へマージし、(新しいdict, レポート行) を返す。"""
177
+ acc = json.loads(json.dumps(account)) # deep copy
178
+ refs = acc.setdefault("refs", {})
179
+ report: list[str] = []
180
+
181
+ ads = sorted(fetched.get("ads") or [], key=sort_key)
182
+
183
+ # --- 見出し / 説明文(既存番号は不変)
184
+ # backend の /yda/ads は複数形の配列 (headlines / descriptions) で返す。
185
+ # レスポンシブ広告は実測で1件だが、媒体側が複数返す形に変わっても取りこぼさない
186
+ # よう配列を平坦化して全件を採番対象にする(単数キーは旧レスポンスの後方互換)。
187
+ def texts(key_plural: str, key_singular: str) -> list:
188
+ out = []
189
+ for a in ads:
190
+ v = a.get(key_plural)
191
+ if isinstance(v, list):
192
+ out.extend(v)
193
+ elif v:
194
+ out.append(v)
195
+ else:
196
+ out.append(a.get(key_singular))
197
+ return out
198
+
199
+ titles, t_stats = assign_numbers(refs.get("titles") or {},
200
+ texts("headlines", "headline"))
201
+ descs, d_stats = assign_numbers(refs.get("descriptions") or {},
202
+ texts("descriptions", "description"))
203
+ refs["titles"], refs["descriptions"] = titles, descs
204
+ report.append(f"見出し 追加 {t_stats['added']} / 既存一致 {t_stats['reused']}"
205
+ f" / 名寄せ統合 {t_stats['merged']}(合計 {len(titles)})")
206
+ report.append(f"説明文 追加 {d_stats['added']} / 既存一致 {d_stats['reused']}"
207
+ f" / 名寄せ統合 {d_stats['merged']}(合計 {len(descs)})")
208
+
209
+ # --- キャンペーン: ID → name(プランナーの「既存キャンペーン呼び出し」が引く)
210
+ cps = refs.setdefault("campaigns", {})
211
+ cp_added = 0
212
+ for c in (fetched.get("campaigns") or {}).get("campaigns") or []:
213
+ cid = str(c.get("campaignId") or c.get("id") or "")
214
+ if not cid:
215
+ continue
216
+ if cid not in cps:
217
+ cps[cid] = {}
218
+ cp_added += 1
219
+ if c.get("name"):
220
+ cps[cid]["name"] = str(c["name"])
221
+ report.append(f"キャンペーン 新規 {cp_added}(合計 {len(cps)})")
222
+
223
+ # --- 広告グループ: campaignId / finalUrlSample / paramCode 補完
224
+ # ⚠ campaignId を必ず入れる。無いとプランナーの「既存広告グループ呼び出し」が親CPを
225
+ # 特定できず、**プラン末尾の無関係なキャンペーン配下にぶら下がる**。
226
+ # ⚠ AG名は /yda/ads が返さないので新規行は name 無しになる(一覧では ID 表示)。
227
+ # 件数をレポートに出して、名前が要るなら手で足せると分かるようにする。
228
+ ag = refs.setdefault("adGroups", {})
229
+ url_added = code_added = cid_added = 0
230
+ new_ags: list[str] = []
231
+ for a in ads:
232
+ gid = str(a.get("adGroupId") or "")
233
+ if not gid:
234
+ continue
235
+ if gid not in ag:
236
+ ag[gid] = {}
237
+ new_ags.append(gid)
238
+ row = ag[gid]
239
+ cid = str(a.get("campaignId") or "")
240
+ if cid and not row.get("campaignId"):
241
+ row["campaignId"] = cid
242
+ cid_added += 1
243
+ url = a.get("finalUrl") or ""
244
+ if not url:
245
+ continue
246
+ if not row.get("finalUrlSample"):
247
+ row["finalUrlSample"] = url
248
+ url_added += 1
249
+ if not row.get("paramCode"):
250
+ q = urllib.parse.parse_qs(urllib.parse.urlparse(url).query)
251
+ code = (q.get("ad") or [""])[0]
252
+ if not code:
253
+ m = re.search(r"【([^】]+)】", str(row.get("name") or ""))
254
+ code = m.group(1) if m else ""
255
+ if code:
256
+ row["paramCode"] = code
257
+ code_added += 1
258
+ report.append(f"広告グループ 新規 {len(new_ags)} / campaignId 補完 {cid_added}"
259
+ f" / finalUrlSample 補完 {url_added} / paramCode 補完 {code_added}"
260
+ + (" ※新規行は AG名 が未設定です(/yda/ads は広告グループ名を返しません)。"
261
+ "プランナーの一覧では ID 表示になるので、必要なら name を手で足してください"
262
+ if new_ags else ""))
263
+
264
+ # --- baseArticleUrl 候補(提示のみ・自動上書きしない)
265
+ urls = [a.get("finalUrl") for a in ads if str(a.get("finalUrl") or "").startswith("http")]
266
+ if urls:
267
+ pre = os.path.commonprefix([u.split("?")[0] for u in urls])
268
+ pre = pre[: pre.rfind("/") + 1] if "/" in pre else pre
269
+ cur = acc.get("baseArticleUrl") or ""
270
+ if pre and pre != cur:
271
+ report.append(f"baseArticleUrl 候補: {pre}(現在: {cur or '未設定'})"
272
+ " ※自動では上書きしません")
273
+
274
+ # --- audiences / placements(消えたIDは削除せず stale マーク)
275
+ for key, incoming in (
276
+ ("audiences", [{"id": str(x.get("id")), "name": x.get("name"), "kind": "audienceList"}
277
+ for x in (fetched.get("audiences") or []) if x.get("id")]),
278
+ ("placements", [
279
+ {"id": str(x.get("id")), "name": x.get("name"), "kind": kind}
280
+ for kind, arr in (("standard", (fetched.get("placements") or {}).get("standard") or []),
281
+ ("exclusion", (fetched.get("placements") or {}).get("exclusions") or []))
282
+ for x in arr if x.get("id")
283
+ ]),
284
+ ):
285
+ table = refs.setdefault(key, {})
286
+ live_ids = {r["id"] for r in incoming}
287
+ added = 0
288
+ for r in incoming:
289
+ if r["id"] not in table:
290
+ added += 1
291
+ row = table.setdefault(r["id"], {})
292
+ row["name"], row["kind"] = r["name"], r["kind"]
293
+ row.pop("stale", None)
294
+ stale = 0
295
+ for i in table:
296
+ if i not in live_ids:
297
+ table[i]["stale"] = True
298
+ stale += 1
299
+ report.append(f"{key:<10} {len(live_ids)}件(新規 {added} / stale {stale})"
300
+ + (" ※stale は過去プランが参照している可能性があるため削除しません"
301
+ if stale else ""))
302
+
303
+ acc["accountJsonVersion"] = 2
304
+ return acc, report
305
+
306
+
307
+ # ---------------------------------------------------------------- main
308
+ def main() -> int:
309
+ ap = argparse.ArgumentParser(description="実アカウントから参照表を取り込む")
310
+ ap.add_argument("platform")
311
+ ap.add_argument("account_id")
312
+ ap.add_argument("--write", action="store_true", help="アカウントJSONへ書き込む(既定は dry-run)")
313
+ ap.add_argument("--campaign-id", dest="campaign_id", default=None,
314
+ help="対象キャンペーンを絞る(省略時は全CP)")
315
+ args = ap.parse_args()
316
+
317
+ if args.platform != "yda":
318
+ sys.exit(f"✗ refs-import は現状 yda のみ対応です(指定: {args.platform})")
319
+
320
+ path, account = find_account(args.platform, args.account_id)
321
+ if not path:
322
+ sys.exit(f"✗ accountId {args.account_id} のアカウントJSONが "
323
+ f"アカウント/{args.platform}/ にありません。先に作成してください")
324
+ print(f"📄 {path}")
325
+
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)
331
+
332
+ print("\n── 取り込み結果 ──")
333
+ for line in report:
334
+ print(f" {line}")
335
+
336
+ if not args.write:
337
+ print("\n(dry-run: 書き込んでいません。反映するには --write を付けてください)")
338
+ return 0
339
+
340
+ with open(path, "w", encoding="utf-8") as f:
341
+ json.dump(merged, f, indent=1, ensure_ascii=False)
342
+ f.write("\n")
343
+ print(f"\n✓ 書き込みました: {path}(accountJsonVersion=2)")
344
+ return 0
345
+
346
+
347
+ if __name__ == "__main__":
348
+ sys.exit(main())