addashboard-cli 0.6.0 → 0.6.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/dist/commands/ads.js +66 -8
- package/dist/commands/ads.js.map +1 -1
- package/dist/commands/submission.js +188 -21
- package/dist/commands/submission.js.map +1 -1
- package/dist/index.js +7 -3
- package/dist/index.js.map +1 -1
- package/dist/lib/apiClient.js +11 -0
- package/dist/lib/apiClient.js.map +1 -1
- package/dist/lib/localPath.js +58 -0
- package/dist/lib/localPath.js.map +1 -0
- package/package.json +1 -1
- package/skills/ad-ops/README.md +1 -0
- package/skills/ad-ops/SKILL.md +207 -20
- package/skills/ad-ops/ad-planner.html +1179 -149
- package/skills/ad-ops/scripts/adops.sh +146 -9
- package/skills/ad-ops/scripts/plan_lint.py +66 -6
- package/skills/ad-ops/scripts/refs_import.py +348 -0
- package/skills/ad-ops//343/202/242/343/202/253/343/202/246/343/203/263/343/203/210/yda/sample_account.json +14 -3
- package/skills/ad-ops//343/202/265/343/203/263/343/203/227/343/203/253/343/203/227/343/203/251/343/203/263.json +0 -4
- package/skills/ad-ops//343/202/265/343/203/263/343/203/227/343/203/253/343/203/227/343/203/251/343/203/263_meta.json +3 -4
- package/skills/ad-ops//343/202/242/343/202/257/343/203/206/343/202/243/343/203/226/343/203/227/343/203/251/343/203/263.json +0 -6
|
@@ -2,8 +2,11 @@
|
|
|
2
2
|
# ad-ops スキルの単一エントリポイント。
|
|
3
3
|
#
|
|
4
4
|
# adops.sh preflight <platform> <accountId> フェーズ0: ログイン + アカウント疎通確認
|
|
5
|
-
# adops.sh planner [--
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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)
|
|
96
|
-
planner)
|
|
97
|
-
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]
|
|
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。
|
|
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
|
-
|
|
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 パラメータ '
|
|
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
|
-
|
|
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())
|