addashboard-cli 0.8.1 → 0.9.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/dist/commands/ads.js +97 -7
- package/dist/commands/ads.js.map +1 -1
- package/dist/commands/submission.js +232 -16
- package/dist/commands/submission.js.map +1 -1
- package/dist/index.js +8 -2
- package/dist/index.js.map +1 -1
- package/dist/schemas/submit-plan.js +80 -5
- package/dist/schemas/submit-plan.js.map +1 -1
- package/package.json +1 -1
- package/skills/ad-ops/SKILL.md +179 -9
- package/skills/ad-ops/ad-planner.html +1203 -2
- package/skills/ad-ops/scripts/adops.sh +55 -6
- package/skills/ad-ops/scripts/plan_lint.py +164 -24
- package/skills/ad-ops//343/202/242/343/202/253/343/202/246/343/203/263/343/203/210/bytedance/index.json +5 -0
- package/skills/ad-ops//343/202/242/343/202/253/343/202/246/343/203/263/343/203/210/bytedance/sample_account.json +55 -0
- package/skills/ad-ops//343/202/242/343/202/253/343/202/246/343/203/263/343/203/210/google/index.json +5 -0
- package/skills/ad-ops//343/202/242/343/202/253/343/202/246/343/203/263/343/203/210/google/sample_account.json +40 -0
- 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_bytedance.json +30 -0
- 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_google.json +33 -0
|
@@ -12,9 +12,17 @@
|
|
|
12
12
|
set -uo pipefail
|
|
13
13
|
|
|
14
14
|
SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
15
|
-
|
|
15
|
+
# 配信ポート。既定 8799 は cli/playwright.config.ts の webServer と共用する。
|
|
16
|
+
# **worktree を分けて並行作業していると別 worktree のサーバを再利用してしまう**
|
|
17
|
+
# (curl が応答すれば「起動済み」と判断するので、別ブランチのプランナーが開く)。
|
|
18
|
+
# 実測では「追加した媒体がプルダウンに出ない」と誤診した。
|
|
19
|
+
# ADOPS_PORT=8801 adops.sh planner ... で自分のディレクトリを配信する専用サーバを立てる。
|
|
20
|
+
PORT="${ADOPS_PORT:-8799}"
|
|
16
21
|
URL="http://127.0.0.1:${PORT}/ad-planner.html"
|
|
17
|
-
|
|
22
|
+
# 既定は npm 公開版。開発中の未publish な機能を Skill フローで通したいときは
|
|
23
|
+
# WEBGRAM_CLI で手元のビルドに差し替える:
|
|
24
|
+
# WEBGRAM_CLI="node $PWD/cli/dist/index.js" adops.sh preflight google 1234567890
|
|
25
|
+
CLI="${WEBGRAM_CLI:-npx -y addashboard-cli@latest}"
|
|
18
26
|
|
|
19
27
|
die() { echo "✗ $*" >&2; exit 1; }
|
|
20
28
|
|
|
@@ -69,9 +77,11 @@ PY
|
|
|
69
77
|
write_active_plan() {
|
|
70
78
|
local platform="$1" account="$2" sample
|
|
71
79
|
case "$platform" in
|
|
72
|
-
yda)
|
|
73
|
-
meta)
|
|
74
|
-
|
|
80
|
+
yda) sample='サンプルプラン.json' ;;
|
|
81
|
+
meta) sample='サンプルプラン_meta.json' ;;
|
|
82
|
+
google) sample='サンプルプラン_google.json' ;;
|
|
83
|
+
bytedance) sample='サンプルプラン_bytedance.json' ;;
|
|
84
|
+
*) die "未知の媒体: ${platform}(yda / meta / google / bytedance)" ;;
|
|
75
85
|
esac
|
|
76
86
|
|
|
77
87
|
# 参照データが無いまま指定するとプランナーが同梱デフォルトへ黙って落ちるので、先に止める
|
|
@@ -118,6 +128,8 @@ cmd_planner() {
|
|
|
118
128
|
|
|
119
129
|
if curl -sf -o /dev/null "$URL"; then
|
|
120
130
|
echo "✓ 配信は起動済み(再利用)"
|
|
131
|
+
echo " ⚠ 別の作業ディレクトリ (worktree) が起動したサーバの可能性があります。" >&2
|
|
132
|
+
echo " プランナーの内容が想定と違うときは ADOPS_PORT=8801 adops.sh planner ... で専用ポートに切り替えてください。" >&2
|
|
121
133
|
else
|
|
122
134
|
echo "▸ http.server を起動中 (port ${PORT})..."
|
|
123
135
|
# nohup でデタッチしないと、呼び出し元シェルの終了に巻き込まれて死ぬ
|
|
@@ -204,7 +216,44 @@ PY
|
|
|
204
216
|
local mres="$key"; [ "$key" = "adgroups" ] && mres="adsets"
|
|
205
217
|
url="https://adsmanager.facebook.com/adsmanager/manage/${mres}?act=${account#act_}"
|
|
206
218
|
;;
|
|
207
|
-
|
|
219
|
+
google)
|
|
220
|
+
# Google は MCC 配下のアカウントを __c(manager) / __u(customer) で指定する。
|
|
221
|
+
# mccId 相当はアカウントJSONの consoleMcc(プランナーの consoleUrl と同じ供給元)。
|
|
222
|
+
mcc="$(ACCOUNT_ID="$account" SKILL_DIR="$SKILL_DIR" python3 - <<'PY'
|
|
223
|
+
import glob, json, os
|
|
224
|
+
want, skill = os.environ["ACCOUNT_ID"], os.environ["SKILL_DIR"]
|
|
225
|
+
paths = [p for p in glob.glob(os.path.join(skill, "アカウント", "google", "*.json"))
|
|
226
|
+
if os.path.basename(p) != "index.json"]
|
|
227
|
+
paths.sort(key=lambda p: os.path.basename(p) == "sample_account.json")
|
|
228
|
+
for p in paths:
|
|
229
|
+
try:
|
|
230
|
+
d = json.load(open(p, encoding="utf-8"))
|
|
231
|
+
except Exception:
|
|
232
|
+
continue
|
|
233
|
+
if str(d.get("accountId", "")) == want:
|
|
234
|
+
print(str(d.get("consoleMcc") or ""))
|
|
235
|
+
break
|
|
236
|
+
PY
|
|
237
|
+
)"
|
|
238
|
+
if [ -z "$mcc" ]; then
|
|
239
|
+
echo "⚠ アカウントJSONに consoleMcc がありません。MCC 指定なしで開きます。" >&2
|
|
240
|
+
echo " アカウントが切り替わらない場合は、管理画面URLの '__c=' の値を控えて" >&2
|
|
241
|
+
echo " アカウント/google/<名前>.json に \"consoleMcc\": \"...\" を追記してください。" >&2
|
|
242
|
+
url="https://ads.google.com/aw/${key}?__u=${account}"
|
|
243
|
+
else
|
|
244
|
+
url="https://ads.google.com/aw/${key}?ocid=&__c=${mcc}&__u=${account}"
|
|
245
|
+
fi
|
|
246
|
+
;;
|
|
247
|
+
bytedance)
|
|
248
|
+
# TikTok Ads Manager は キャンペーン / 広告グループ / 広告 を URL で指定できず、
|
|
249
|
+
# 広告管理画面のタブで切り替える。resource は受け取るが URL には反映しない
|
|
250
|
+
# (実 API 検証で URL 構造が確定したら分岐を足す)。
|
|
251
|
+
url="https://ads.tiktok.com/i18n/perf/creative?aadvid=${account}"
|
|
252
|
+
if [ "$key" != "campaigns" ]; then
|
|
253
|
+
echo "ℹ TikTok は ${key} 単位のURLが無いため広告管理画面を開きます(画面上のタブで切り替えてください)。" >&2
|
|
254
|
+
fi
|
|
255
|
+
;;
|
|
256
|
+
*) die "open が未対応の媒体です: ${platform}(yda / meta / google / bytedance)" ;;
|
|
208
257
|
esac
|
|
209
258
|
|
|
210
259
|
echo "$url"
|
|
@@ -110,6 +110,56 @@ def pick_plan(argv: list[str]) -> str:
|
|
|
110
110
|
|
|
111
111
|
|
|
112
112
|
# ------------------------------------------------------- 共通: 値の取り出し
|
|
113
|
+
def final_url_of(ad: dict) -> str:
|
|
114
|
+
"""最終リンク先URL。キーの形が媒体で違う。
|
|
115
|
+
|
|
116
|
+
yda / meta = finalUrl / google = finalUrls[] / bytedance = landingPageUrl
|
|
117
|
+
"""
|
|
118
|
+
if ad.get("finalUrl"):
|
|
119
|
+
return str(ad["finalUrl"])
|
|
120
|
+
if ad.get("landingPageUrl"):
|
|
121
|
+
return str(ad["landingPageUrl"])
|
|
122
|
+
urls = ad.get("finalUrls") or []
|
|
123
|
+
return str(urls[0]) if urls else ""
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def status_of(node: dict) -> str:
|
|
127
|
+
"""配信状態。**キー名も値も媒体で違う**。
|
|
128
|
+
|
|
129
|
+
キー: yda / meta / google = status / bytedance = operationStatus
|
|
130
|
+
配信オンの値: yda / meta = ACTIVE / google = ENABLED / bytedance = ENABLE
|
|
131
|
+
"""
|
|
132
|
+
return str(node.get("status") or node.get("operationStatus") or "")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# 配信オンを表す値の全集合。1 つでも取りこぼすと「配信オンの計画」が lint を素通りする
|
|
136
|
+
ON_STATUSES = ("ACTIVE", "ENABLED", "ENABLE")
|
|
137
|
+
# 実 API での入稿検証が未了の媒体。配信オンは warn ではなく err で止める
|
|
138
|
+
UNVERIFIED_PLATFORMS = ("google", "bytedance")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def budget_of(campaign: dict) -> int:
|
|
142
|
+
"""日予算。キー名がファイル種別で違い、**予算を持つ階層も媒体で違う**。
|
|
143
|
+
|
|
144
|
+
キャンペーン階層: yda = budget / google = budgetAmountYen / サンプル = dailyBudget
|
|
145
|
+
広告グループ階層: meta / bytedance (キャンペーンに予算を持たない)
|
|
146
|
+
|
|
147
|
+
階層差を吸収しないと、予算が広告グループにある媒体では常に 0 と読めてしまい
|
|
148
|
+
「日予算 0 が想定レンジ外」の警告が毎回出る (= 本物の桁ミスが埋もれる)。
|
|
149
|
+
"""
|
|
150
|
+
direct = (
|
|
151
|
+
campaign.get("budget")
|
|
152
|
+
or campaign.get("budgetAmountYen")
|
|
153
|
+
or campaign.get("dailyBudget")
|
|
154
|
+
)
|
|
155
|
+
if direct:
|
|
156
|
+
return int(direct)
|
|
157
|
+
return sum(
|
|
158
|
+
int(g.get("budget") or g.get("budgetAmountYen") or g.get("dailyBudget") or 0)
|
|
159
|
+
for g in ad_groups(campaign)
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
113
163
|
def param_code_of(ad: dict, group: dict) -> str:
|
|
114
164
|
"""広告グループの paramCode。
|
|
115
165
|
|
|
@@ -118,7 +168,7 @@ def param_code_of(ad: dict, group: dict) -> str:
|
|
|
118
168
|
"""
|
|
119
169
|
if group.get("paramCode"):
|
|
120
170
|
return str(group["paramCode"])
|
|
121
|
-
url = ad
|
|
171
|
+
url = final_url_of(ad)
|
|
122
172
|
q = urllib.parse.parse_qs(urllib.parse.urlparse(url).query)
|
|
123
173
|
code = (q.get("ad") or [""])[0]
|
|
124
174
|
if code:
|
|
@@ -131,12 +181,13 @@ def image_no_of(ad: dict) -> str:
|
|
|
131
181
|
"""画像番号。ad.imageNo → creative パラメータ → 広告名 '1047c-...' の順で復元する。"""
|
|
132
182
|
if ad.get("imageNo"):
|
|
133
183
|
return str(ad["imageNo"])
|
|
134
|
-
url = ad
|
|
184
|
+
url = final_url_of(ad)
|
|
135
185
|
q = urllib.parse.parse_qs(urllib.parse.urlparse(url).query)
|
|
136
186
|
m = re.search(r"-(\d+)[a-z]-", (q.get("creative") or [""])[0])
|
|
137
187
|
if m:
|
|
138
188
|
return m.group(1)
|
|
139
|
-
|
|
189
|
+
# 形式記号は媒体で違う (yda: c/s / meta・google: i/v)。1文字の英小文字として扱う
|
|
190
|
+
m = re.match(r"^(\d+)[a-z]-", str(ad.get("adName") or ""))
|
|
140
191
|
return m.group(1) if m else ""
|
|
141
192
|
|
|
142
193
|
|
|
@@ -158,11 +209,14 @@ def summarize(plan: dict) -> None:
|
|
|
158
209
|
print(f"\n── 入稿計画 ── platform={plan.get('platform', 'yda')} "
|
|
159
210
|
f"accountId={plan.get('accountId')} planVersion={plan.get('planVersion')}")
|
|
160
211
|
for ci, c in enumerate(plan.get("campaigns") or []):
|
|
161
|
-
bid = c.get("biddingStrategy"
|
|
162
|
-
|
|
163
|
-
|
|
212
|
+
bid = c.get("biddingStrategy") or c.get("advertisingChannelType") or ""
|
|
213
|
+
# 目標CPA のキー名も媒体で違う (yda: targetCpa / google: targetCpaYen)
|
|
214
|
+
tcpa = c.get("targetCpa") or c.get("targetCpaYen")
|
|
215
|
+
if tcpa:
|
|
216
|
+
bid += f" (tCPA {tcpa:,})"
|
|
217
|
+
budget = budget_of(c)
|
|
164
218
|
print(f"\n CP[{ci}] {c.get('name')}")
|
|
165
|
-
print(f" 予算 {
|
|
219
|
+
print(f" 予算 {budget:,}/日 | {bid} | status {status_of(c) or '-'} "
|
|
166
220
|
f"| 開始 {c.get('startDate', '-')}")
|
|
167
221
|
for g in ad_groups(c):
|
|
168
222
|
tg = "/".join(filter(None, [
|
|
@@ -170,12 +224,20 @@ def summarize(plan: dict) -> None:
|
|
|
170
224
|
",".join(g.get("ages") or []) or "全年齢",
|
|
171
225
|
",".join(g.get("genders") or []) or "全性別",
|
|
172
226
|
]))
|
|
173
|
-
print(f" AG {g.get('name')} | {tg} | status {g
|
|
227
|
+
print(f" AG {g.get('name')} | {tg} | status {status_of(g) or '-'}")
|
|
174
228
|
for a in g.get("ads") or []:
|
|
175
|
-
|
|
229
|
+
# クリエイティブの持ち方は媒体で違う(google は YouTube動画ID / 画像アセット)
|
|
230
|
+
media = (
|
|
231
|
+
",".join(a.get("mediaIds") or [])
|
|
232
|
+
or a.get("videoId")
|
|
233
|
+
or a.get("imageHash")
|
|
234
|
+
or a.get("youtubeVideoId")
|
|
235
|
+
or ",".join((a.get("marketingImages") or []) + (a.get("squareMarketingImages") or []))
|
|
236
|
+
or "-"
|
|
237
|
+
)
|
|
176
238
|
head = (a.get("headlines") or ["(空)"])[0]
|
|
177
239
|
print(f" AD media {media} | 画像No {image_no_of(a) or '-'} | {head}")
|
|
178
|
-
print(f" {a
|
|
240
|
+
print(f" {final_url_of(a)}")
|
|
179
241
|
|
|
180
242
|
|
|
181
243
|
# --------------------------------------------------------------- 3. lint
|
|
@@ -192,6 +254,13 @@ def lint(plan: dict, plan_path: str = "") -> None:
|
|
|
192
254
|
for v in (account.get("refs", {}).get("adGroups") or {}).values()
|
|
193
255
|
if v.get("paramCode")
|
|
194
256
|
}
|
|
257
|
+
# Google は同名キャンペーンを拒否する(2026-08-23 実測。他媒体は未確認なので google 限定で見る)。
|
|
258
|
+
# dry-run は実 API を叩かないので通ってしまい、本実行の campaign 作成で初めて落ちる。
|
|
259
|
+
known_cp_names = {
|
|
260
|
+
str(v.get("name"))
|
|
261
|
+
for v in (account.get("refs", {}).get("campaigns") or {}).values()
|
|
262
|
+
if v.get("name")
|
|
263
|
+
}
|
|
195
264
|
|
|
196
265
|
if not base:
|
|
197
266
|
warn(f"アカウントJSONに baseArticleUrl がありません({platform}/{plan.get('accountId')})。"
|
|
@@ -200,13 +269,28 @@ def lint(plan: dict, plan_path: str = "") -> None:
|
|
|
200
269
|
seen_media: dict[str, str] = {}
|
|
201
270
|
for c in plan.get("campaigns") or []:
|
|
202
271
|
name = c.get("name", "")
|
|
203
|
-
budget = c
|
|
272
|
+
budget = budget_of(c)
|
|
204
273
|
if not (BUDGET_MIN <= budget <= BUDGET_MAX):
|
|
205
274
|
warn(f"CP「{name}」の日予算 {budget:,} が想定レンジ外({BUDGET_MIN:,}〜{BUDGET_MAX:,})。桁を確認してください")
|
|
206
|
-
|
|
207
|
-
|
|
275
|
+
# 配信オンを表す値は媒体で違う: yda / meta は ACTIVE、**google は ENABLED**。
|
|
276
|
+
# ACTIVE だけ見ていると Google の配信オンが素通りする。
|
|
277
|
+
cp_status = status_of(c)
|
|
278
|
+
if cp_status in ON_STATUSES:
|
|
279
|
+
if platform in UNVERIFIED_PLATFORMS:
|
|
280
|
+
# 実 API での入稿検証が未了の媒体は配信オンでの入稿を止める
|
|
281
|
+
off = "DISABLE" if platform == "bytedance" else "PAUSED"
|
|
282
|
+
err(f"CP「{name}」が {cp_status}(配信オン)です。"
|
|
283
|
+
f" {platform} は実 API での入稿検証が未了なので、{off}(配信オフ)で入稿してください")
|
|
284
|
+
else:
|
|
285
|
+
warn(f"CP「{name}」が {cp_status} です。初回・PoC は PAUSED(配信オフ)を推奨します")
|
|
208
286
|
if SUSPICIOUS_NAME.search(name):
|
|
209
287
|
warn(f"CP名「{name}」に検証用らしき語が残っています。本番入稿の命名か確認してください")
|
|
288
|
+
# 同名 CP を拒否するのは **google だけ**(2026-08-23 実測。他媒体は未確認)。
|
|
289
|
+
# YDA は `_2` サフィックスで自動リトライする先例があるので、全媒体で止めると
|
|
290
|
+
# これまで通っていた計画をブロックしてしまう。
|
|
291
|
+
if platform == "google" and name in known_cp_names:
|
|
292
|
+
err(f"CP名「{name}」はアカウントの既存キャンペーンと同名です。"
|
|
293
|
+
" Google は同名キャンペーンを拒否するため、本実行の作成で落ちます")
|
|
210
294
|
|
|
211
295
|
for g in ad_groups(c):
|
|
212
296
|
gname = g.get("name", "")
|
|
@@ -217,7 +301,7 @@ def lint(plan: dict, plan_path: str = "") -> None:
|
|
|
217
301
|
warn(f"広告グループ「{gname}」の smartTargeting が false です"
|
|
218
302
|
f"(方針は常時ON)。意図的でなければ外してください")
|
|
219
303
|
for a in g.get("ads") or []:
|
|
220
|
-
url = a
|
|
304
|
+
url = final_url_of(a)
|
|
221
305
|
if not re.match(r"https?://", url):
|
|
222
306
|
err(f"CP「{name}」の finalUrl が相対パスです: {url}\n"
|
|
223
307
|
f" → アカウントJSONの baseArticleUrl 未設定。"
|
|
@@ -227,8 +311,23 @@ def lint(plan: dict, plan_path: str = "") -> None:
|
|
|
227
311
|
f"({base_host}) と違います: {urllib.parse.urlparse(url).netloc}")
|
|
228
312
|
|
|
229
313
|
media = a.get("mediaIds") or []
|
|
230
|
-
|
|
231
|
-
|
|
314
|
+
# クリエイティブの持ち方は媒体で違う。
|
|
315
|
+
# yda : mediaIds / videoMediaId
|
|
316
|
+
# meta : imageHash / videoId
|
|
317
|
+
# google : youtubeVideoId / marketingImages / squareMarketingImages
|
|
318
|
+
# bytedance : videoId + imageIds (カバー画像) / アップロード前は videoPath
|
|
319
|
+
has_creative = bool(
|
|
320
|
+
media
|
|
321
|
+
or a.get("videoId")
|
|
322
|
+
or a.get("videoPath")
|
|
323
|
+
or a.get("imageHash")
|
|
324
|
+
or a.get("imageIds")
|
|
325
|
+
or a.get("youtubeVideoId")
|
|
326
|
+
or a.get("marketingImages")
|
|
327
|
+
or a.get("squareMarketingImages")
|
|
328
|
+
)
|
|
329
|
+
if not has_creative:
|
|
330
|
+
err(f"CP「{name}」の広告にクリエイティブがありません")
|
|
232
331
|
for m in media:
|
|
233
332
|
if m in seen_media:
|
|
234
333
|
warn(f"mediaId {m} が複数の広告で使われています"
|
|
@@ -246,14 +345,43 @@ def lint(plan: dict, plan_path: str = "") -> None:
|
|
|
246
345
|
f"(backend は mediaIds[0] のみ使用)。差し替えたいなら mediaIds を空にし、"
|
|
247
346
|
f"既存画像のままでよいなら imagePaths を消してください")
|
|
248
347
|
|
|
249
|
-
#
|
|
250
|
-
|
|
348
|
+
# ローカル素材パスの存在チェック(dry-run は検出しない)
|
|
349
|
+
# bytedance は videoPath / coverImagePath(s) も CLI がアップロードする
|
|
350
|
+
local_paths = list(a.get("imagePaths") or [])
|
|
351
|
+
if a.get("videoPath"):
|
|
352
|
+
local_paths.append(a["videoPath"])
|
|
353
|
+
if a.get("coverImagePath"):
|
|
354
|
+
local_paths.append(a["coverImagePath"])
|
|
355
|
+
local_paths.extend(a.get("coverImagePaths") or [])
|
|
356
|
+
for raw in local_paths:
|
|
251
357
|
resolved = resolve_media_path(raw, base_dir)
|
|
252
358
|
if not os.path.isfile(resolved):
|
|
253
|
-
err(f"CP「{name}
|
|
359
|
+
err(f"CP「{name}」のローカル素材が見つかりません: {raw}\n"
|
|
254
360
|
f" → 解決先 {resolved}(相対パスの基準は計画JSONのディレクトリ "
|
|
255
361
|
f"{base_dir})")
|
|
256
362
|
|
|
363
|
+
# TikTok の SINGLE_VIDEO は **動画とカバー画像の両方**が必要。
|
|
364
|
+
# 片方だけだと lint を通り、live で動画だけアップロードされた後に
|
|
365
|
+
# backend の「カバー画像が指定されていません」で落ちる。
|
|
366
|
+
if platform == "bytedance":
|
|
367
|
+
has_video = bool(a.get("videoId") or a.get("videoPath"))
|
|
368
|
+
has_cover = bool(
|
|
369
|
+
a.get("imageIds") or a.get("coverImagePath") or a.get("coverImagePaths")
|
|
370
|
+
)
|
|
371
|
+
if not has_video:
|
|
372
|
+
err(f"CP「{name}」の広告に動画がありません({a.get('adName') or ''})\n"
|
|
373
|
+
f" → videoId か、アップロード前なら videoPath が必要")
|
|
374
|
+
if not has_cover:
|
|
375
|
+
err(f"CP「{name}」の広告にカバー画像がありません({a.get('adName') or ''})\n"
|
|
376
|
+
f" → SINGLE_VIDEO では必須。imageIds か、アップロード前なら "
|
|
377
|
+
f"coverImagePath が必要")
|
|
378
|
+
# 解決済みなのにパスも入っていると CLI が余計にアップロードする
|
|
379
|
+
if a.get("videoId") and a.get("videoPath"):
|
|
380
|
+
err(f"CP「{name}」の広告に videoId と videoPath が両方あります"
|
|
381
|
+
f"({a.get('adName') or ''})\n"
|
|
382
|
+
f" → アップロードした動画は使われません。差し替えたいなら videoId を、"
|
|
383
|
+
f"既存動画のままでよいなら videoPath を消してください")
|
|
384
|
+
|
|
257
385
|
query = urllib.parse.urlparse(url).query
|
|
258
386
|
if not url_params and query:
|
|
259
387
|
warn(f"CP「{name}」は urlParamsEnabled: false ですが finalUrl に"
|
|
@@ -284,8 +412,16 @@ def flatten(plan: dict, is_sample: bool) -> dict:
|
|
|
284
412
|
for ci, c in enumerate(plan.get("campaigns") or []):
|
|
285
413
|
p = f"CP[{ci}]"
|
|
286
414
|
out[f"{p}.name"] = c.get("name")
|
|
287
|
-
|
|
288
|
-
|
|
415
|
+
# 予算・目標CPA のキー名は媒体で違う。google は budgetAmountYen / targetCpaYen。
|
|
416
|
+
# budget / targetCpa だけを見ると Google の差分が常に None 同士になり検出できない。
|
|
417
|
+
out[f"{p}.budget"] = budget_of(c) or None
|
|
418
|
+
# tCPA のキー: サンプルは tcpa (yda/meta) か targetCpa (google)、入稿計画は
|
|
419
|
+
# targetCpa (yda/meta) か targetCpaYen (google)
|
|
420
|
+
out[f"{p}.tCPA"] = (
|
|
421
|
+
(c.get("tcpa") or c.get("targetCpa"))
|
|
422
|
+
if is_sample
|
|
423
|
+
else (c.get("targetCpa") or c.get("targetCpaYen"))
|
|
424
|
+
)
|
|
289
425
|
groups = ad_groups(c)
|
|
290
426
|
out[f"{p}.広告グループ数"] = len(groups)
|
|
291
427
|
for gi, g in enumerate(groups):
|
|
@@ -301,9 +437,13 @@ def flatten(plan: dict, is_sample: bool) -> dict:
|
|
|
301
437
|
|
|
302
438
|
def diff_against_sample(plan: dict) -> None:
|
|
303
439
|
platform = plan.get("platform", "yda")
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
440
|
+
# 媒体を足したらここにも足す (合意内容の正となるサンプルプランは媒体別)
|
|
441
|
+
SAMPLE_BY_PF = {
|
|
442
|
+
"meta": "サンプルプラン_meta.json",
|
|
443
|
+
"google": "サンプルプラン_google.json",
|
|
444
|
+
"bytedance": "サンプルプラン_bytedance.json",
|
|
445
|
+
}
|
|
446
|
+
sample_path = os.path.join(SKILL_DIR, SAMPLE_BY_PF.get(platform, "サンプルプラン.json"))
|
|
307
447
|
if not os.path.exists(sample_path):
|
|
308
448
|
print("\n(サンプルプランが無いため差分比較をスキップしました)")
|
|
309
449
|
return
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"accountJsonVersion": 1,
|
|
3
|
+
"platform": "bytedance",
|
|
4
|
+
"accountId": "7000000000000000001",
|
|
5
|
+
"name": "sample_account",
|
|
6
|
+
"projectName": "TikTokサンプル案件",
|
|
7
|
+
"baseArticleUrl": "https://example.com/tiktok-lp/",
|
|
8
|
+
"identityId": "7100000000000000001",
|
|
9
|
+
"identityType": "BC_AUTH_TT",
|
|
10
|
+
"refs": {
|
|
11
|
+
"titles": {
|
|
12
|
+
"1": "サンプル広告文1。商材の特徴とオファーを簡潔に伝えます。",
|
|
13
|
+
"2": "サンプル広告文2。ターゲットの悩みに寄せた訴求を入れます。",
|
|
14
|
+
"3": "サンプル広告文3。行動を促すメッセージを入れます。"
|
|
15
|
+
},
|
|
16
|
+
"media": {
|
|
17
|
+
"1": {
|
|
18
|
+
"video": "v0200000000000000001",
|
|
19
|
+
"cover": "ad-site-i18n-sg/000000000000000001"
|
|
20
|
+
},
|
|
21
|
+
"2": {
|
|
22
|
+
"video": "v0200000000000000002",
|
|
23
|
+
"cover": "ad-site-i18n-sg/000000000000000002"
|
|
24
|
+
},
|
|
25
|
+
"3": {
|
|
26
|
+
"video": "v0200000000000000003",
|
|
27
|
+
"cover": ""
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"regions": {
|
|
31
|
+
"日本": "1200000000"
|
|
32
|
+
},
|
|
33
|
+
"campaigns": {
|
|
34
|
+
"1780000000000000001": {
|
|
35
|
+
"name": "TikTokサンプルキャンペーンA"
|
|
36
|
+
},
|
|
37
|
+
"1780000000000000002": {
|
|
38
|
+
"name": "TikTokサンプルキャンペーンB"
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
"adGroups": {
|
|
42
|
+
"1790000000000000001": {
|
|
43
|
+
"name": "サンプル広告グループA",
|
|
44
|
+
"campaignId": "1780000000000000001",
|
|
45
|
+
"paramCode": "b_sample_01"
|
|
46
|
+
},
|
|
47
|
+
"1790000000000000002": {
|
|
48
|
+
"name": "サンプル広告グループB",
|
|
49
|
+
"campaignId": "1780000000000000002",
|
|
50
|
+
"paramCode": "b_sample_02"
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
"identityAuthorizedBcId": "7100000000000000009"
|
|
55
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"accountJsonVersion": 1,
|
|
3
|
+
"platform": "google",
|
|
4
|
+
"accountId": "1000000001",
|
|
5
|
+
"name": "sample_account",
|
|
6
|
+
"projectName": "Googleサンプル案件",
|
|
7
|
+
"baseArticleUrl": "https://example.com/google-lp/",
|
|
8
|
+
"consoleMcc": "9000000001",
|
|
9
|
+
"businessName": "サンプルブランド",
|
|
10
|
+
"logoAsset": "customers/1000000001/assets/900000001",
|
|
11
|
+
"refs": {
|
|
12
|
+
"titles": {
|
|
13
|
+
"1": "サンプル見出し1(訴求A)",
|
|
14
|
+
"2": "サンプル見出し2(訴求B)",
|
|
15
|
+
"3": "サンプル見出し3(訴求C)"
|
|
16
|
+
},
|
|
17
|
+
"longHeadlines": {
|
|
18
|
+
"1": "サンプル長見出し1。動画広告で使う長めの見出しです。",
|
|
19
|
+
"2": "サンプル長見出し2。訴求のポイントを補足する長見出しです。"
|
|
20
|
+
},
|
|
21
|
+
"descriptions": {
|
|
22
|
+
"1": "サンプル説明文1。商材の特徴とオファーを簡潔に伝える説明文です。",
|
|
23
|
+
"2": "サンプル説明文2。ターゲットの悩みに寄せた訴求を入れる説明文です。",
|
|
24
|
+
"3": "サンプル説明文3。行動を促すメッセージを入れる説明文です。"
|
|
25
|
+
},
|
|
26
|
+
"media": {
|
|
27
|
+
"1": { "image": "customers/1000000001/assets/800000001", "square": "customers/1000000001/assets/800000002", "video": "" },
|
|
28
|
+
"2": { "image": "", "square": "", "video": "dQw4w9WgXcQ" },
|
|
29
|
+
"3": { "image": "customers/1000000001/assets/800000003", "square": "", "video": "M7lc1UVf-VE" }
|
|
30
|
+
},
|
|
31
|
+
"campaigns": {
|
|
32
|
+
"20000000001": { "name": "GoogleサンプルキャンペーンA" },
|
|
33
|
+
"20000000002": { "name": "GoogleサンプルキャンペーンB" }
|
|
34
|
+
},
|
|
35
|
+
"adGroups": {
|
|
36
|
+
"30000000001": { "name": "サンプル広告グループA", "campaignId": "20000000001", "paramCode": "g_sample_01" },
|
|
37
|
+
"30000000002": { "name": "サンプル広告グループB", "campaignId": "20000000002", "paramCode": "g_sample_02" }
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 3,
|
|
3
|
+
"platform": "bytedance",
|
|
4
|
+
"accountId": "7000000000000000001",
|
|
5
|
+
"campaigns": [
|
|
6
|
+
{
|
|
7
|
+
"uid": "c1",
|
|
8
|
+
"name": "TikTokサンプル検証_0823",
|
|
9
|
+
"campaignId": "",
|
|
10
|
+
"isExisting": false,
|
|
11
|
+
"urlNumber": "01",
|
|
12
|
+
"adGroups": [
|
|
13
|
+
{
|
|
14
|
+
"uid": "g1",
|
|
15
|
+
"name": "AG1",
|
|
16
|
+
"paramCode": "sample_b1",
|
|
17
|
+
"isExisting": false,
|
|
18
|
+
"dailyBudget": 5000,
|
|
19
|
+
"tg_location": "日本",
|
|
20
|
+
"tg_placement": "自動配置",
|
|
21
|
+
"deliveryStatus": "",
|
|
22
|
+
"ads": [
|
|
23
|
+
{ "uid": "a1", "imageNo": "1", "titleNo": 1 },
|
|
24
|
+
{ "uid": "a2", "imageNo": "2", "titleNo": 2 }
|
|
25
|
+
]
|
|
26
|
+
}
|
|
27
|
+
]
|
|
28
|
+
}
|
|
29
|
+
]
|
|
30
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 3,
|
|
3
|
+
"platform": "google",
|
|
4
|
+
"accountId": "1000000001",
|
|
5
|
+
"campaigns": [
|
|
6
|
+
{
|
|
7
|
+
"uid": "c1",
|
|
8
|
+
"name": "GoogleサンプルDemandGen検証_0818",
|
|
9
|
+
"campaignId": "",
|
|
10
|
+
"isExisting": false,
|
|
11
|
+
"dailyBudget": 12000,
|
|
12
|
+
"targetCpa": 8500,
|
|
13
|
+
"urlNumber": "01",
|
|
14
|
+
"adGroups": [
|
|
15
|
+
{
|
|
16
|
+
"uid": "g1",
|
|
17
|
+
"name": "AG1",
|
|
18
|
+
"paramCode": "sample_g1",
|
|
19
|
+
"isExisting": false,
|
|
20
|
+
"tg_location": "日本",
|
|
21
|
+
"tg_language": "日本語",
|
|
22
|
+
"tg_channel": "YouTube",
|
|
23
|
+
"deliveryStatus": "",
|
|
24
|
+
"ads": [
|
|
25
|
+
{ "uid": "a1", "format": "VIDEO", "imageNo": "2", "titleNo": 1, "longNo": 1, "descNo": 1 },
|
|
26
|
+
{ "uid": "a2", "format": "VIDEO", "imageNo": "3", "titleNo": 2, "longNo": 2, "descNo": 2 },
|
|
27
|
+
{ "uid": "a3", "format": "IMAGE", "imageNo": "1", "titleNo": 1, "longNo": "", "descNo": 1 }
|
|
28
|
+
]
|
|
29
|
+
}
|
|
30
|
+
]
|
|
31
|
+
}
|
|
32
|
+
]
|
|
33
|
+
}
|