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,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())
@@ -1,10 +1,13 @@
1
1
  {
2
- "accountJsonVersion": 1,
2
+ "accountJsonVersion": 2,
3
3
  "accountId": "1002811292",
4
+ "mccId": "1002811292",
4
5
  "name": "sample_account",
5
6
  "principal": "サンプル主体者",
6
7
  "projectName": "サンプル案件",
7
8
  "baseArticleUrl": "https://example.com/lp/",
9
+ "urlParamsEnabled": true,
10
+ "adNamePolicy": "imageNo",
8
11
  "refs": {
9
12
  "titles": {
10
13
  "1": "サンプル見出し その1(訴求ポイントA)",
@@ -27,14 +30,22 @@
27
30
  "10000003": { "name": "サンプルキャンペーンC" }
28
31
  },
29
32
  "adGroups": {
30
- "20000001": { "name": "【sample_a1】サンプル広告グループA1", "campaignId": "10000001", "paramCode": "sample_a1" },
31
- "20000002": { "name": "【sample_b1】サンプル広告グループB1", "campaignId": "10000002", "paramCode": "sample_b1" },
33
+ "20000001": { "name": "【sample_a1】サンプル広告グループA1", "campaignId": "10000001", "paramCode": "sample_a1", "finalUrlSample": "https://example.com/lp/sample-a?ad=sample_a1&creative=sample_a1-1c-1L1-sample_a1" },
34
+ "20000002": { "name": "【sample_b1】サンプル広告グループB1", "campaignId": "10000002", "paramCode": "sample_b1", "finalUrlSample": "https://example.com/lp/sample-b?ad=sample_b1&creative=sample_b1-2c-2L2-sample_b1" },
32
35
  "20000003": { "name": "【sample_c1】サンプル広告グループC1", "campaignId": "10000003", "paramCode": "sample_c1" }
33
36
  },
34
37
  "groups": {
35
38
  "sample_a1": { "campaignId": "10000001", "adGroupId": "20000001", "groupName": "【sample_a1】サンプル広告グループA1" },
36
39
  "sample_b1": { "campaignId": "10000002", "adGroupId": "20000002", "groupName": "【sample_b1】サンプル広告グループB1" },
37
40
  "sample_c1": { "campaignId": "10000003", "adGroupId": "20000003", "groupName": "【sample_c1】サンプル広告グループC1" }
41
+ },
42
+ "audiences": {
43
+ "30000001": { "name": "サンプルオーディエンス(サイト訪問30日)", "kind": "audienceList" },
44
+ "30000002": { "name": "サンプルオーディエンス(購入者除外用)", "kind": "audienceList" }
45
+ },
46
+ "placements": {
47
+ "40000001": { "name": "サンプル配信面リスト(標準)", "kind": "standard" },
48
+ "40000002": { "name": "サンプル除外面リスト", "kind": "exclusion" }
38
49
  }
39
50
  }
40
51
  }