addashboard-cli 0.8.1 → 0.9.1
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/placements.js +234 -0
- package/dist/commands/placements.js.map +1 -0
- package/dist/commands/submission.js +232 -16
- package/dist/commands/submission.js.map +1 -1
- package/dist/index.js +121 -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
|
@@ -677,6 +677,82 @@ finalUrl = baseURL + paramCode + "?ad=" + paramCode + "&creative=" + …
|
|
|
677
677
|
</ul>
|
|
678
678
|
</div></details>
|
|
679
679
|
</div><!-- /mt-meta -->
|
|
680
|
+
|
|
681
|
+
<div id="mt-google" style="display:none">
|
|
682
|
+
<div class="eyebrow">Google Ads (Demand Gen) — 対応範囲・要件一覧</div>
|
|
683
|
+
<h1 class="title">Google 広告入稿 <span class="pill green" style="vertical-align:middle">実 API 確認済み</span></h1>
|
|
684
|
+
<p class="sub">2026-08-23 更新。<b>実 API での入稿を確認済み</b>(PAUSED で 1 キャンペーン)。<b>配信オン(ENABLED)では入稿できない</b> — 意図しない配信・課金を防ぐため backend が 400 で拒否する。<b>配信オフで入稿し、管理画面で確認してから手でオンにする</b>運用。Google Ads API v23 の proto 定義に照合して実装。</p>
|
|
685
|
+
|
|
686
|
+
<details class="sec" open><summary><span class="num">1</span><span class="stitle">概要 — なぜ Demand Gen なのか</span><span class="chev"></span></summary>
|
|
687
|
+
<div class="body">
|
|
688
|
+
<p class="lead"><b>Google Ads API では動画(VIDEO)キャンペーンを新規作成できない</b>(公式明記)。API から YouTube 面へ広告を出す経路は <b>Demand Gen キャンペーンだけ</b>。</p>
|
|
689
|
+
<p>Demand Gen は YouTube(インストリーム / インフィード / ショート)・Discover・Gmail・ディスプレイに配信できる。画像広告と動画広告を同じキャンペーンで扱えるため、広告行の「形式」列で切り替える(Meta と同じ作り)。</p>
|
|
690
|
+
<table class="g"><thead><tr><th>レイヤ</th><th>状態</th><th>内容</th></tr></thead><tbody>
|
|
691
|
+
<tr><td>backend</td><td><span class="pill green">実装済み</span></td><td><code>/api/submission/google</code> + <code>GoogleSubmissionConnector</code>。Demand Gen キャンペーン / 広告グループ / 動画・画像広告 / 目標CPA入札 / 地域・言語ターゲティング / 配信面の制御</td></tr>
|
|
692
|
+
<tr><td>CLI</td><td><span class="pill green">実 API 確認済み</span></td><td><code>webgram submit-plan</code> が Google に対応(dry-run / 本実行とも)。2026-08-23 に PAUSED で入稿を確認した。初回は <b>PAUSED・1キャンペーン・最小予算</b>で流し、管理画面で目視確認する。広告が1件も作れなかったキャンペーンは自動で削除される</td></tr>
|
|
693
|
+
<tr><td>プランナーGUI</td><td><span class="pill green">実装済み</span></td><td>ヘッダーの統合プルダウンで Google に切替。シート(広告 / 広告グループ / キャンペーン)・ツリー・参照データ・入稿JSON書き出しに対応</td></tr>
|
|
694
|
+
<tr><td>実 API 検証</td><td><span class="pill green">実施済み</span></td><td>2026-08-23 に本番アカウントで Demand Gen 画像広告を PAUSED 入稿し、作成を確認した。developer token のアクセスレベルは Basic 以上(読み取りが通っているため)。<b>ただし配信オン(ENABLED)での実配信は未検証</b>。また<b>アカウントによって書き込み可否が違う</b>(停止・未有効化のものがある)</td></tr>
|
|
695
|
+
</tbody></table>
|
|
696
|
+
</div></details>
|
|
697
|
+
|
|
698
|
+
<details class="sec" open><summary><span class="num">2</span><span class="stitle">動画広告 — YouTube 動画IDを人が用意する</span><span class="chev"></span></summary>
|
|
699
|
+
<div class="body">
|
|
700
|
+
<p class="lead"><b>動画ファイルのアップロードは未対応。</b>YouTube に上げた動画のIDを使う。Google Ads API には窓口(<code>YouTubeVideoUploadService</code>)があるが、使っている Node ライブラリが非対応のため、運用者が YouTube へ手で上げる。<b>手で上げた場合、動画IDはアップロード直後に URL から取得できる</b>(処理完了を待つ必要はない)。</p>
|
|
701
|
+
<p>参照データの「クリエイティブ」に <code>video</code> として YouTube 動画ID(例 <code>dQw4w9WgXcQ</code>)を登録し、広告行の形式を <code>VIDEO</code> にして番号で参照する。</p>
|
|
702
|
+
<table class="g"><thead><tr><th>項目</th><th>必須</th><th>制約</th></tr></thead><tbody>
|
|
703
|
+
<tr><td>YouTube動画ID</td><td><span class="pill green">必須</span></td><td>事前に YouTube へアップロードしておく</td></tr>
|
|
704
|
+
<tr><td>見出し</td><td><span class="pill green">必須</span></td><td>表示幅30まで</td></tr>
|
|
705
|
+
<tr><td><b>長見出し</b></td><td><span class="pill green">必須</span></td><td>表示幅90まで。<b>動画広告だけが持つ項目</b>(画像広告にはない)</td></tr>
|
|
706
|
+
<tr><td>説明文</td><td><span class="pill green">必須</span></td><td>表示幅90まで</td></tr>
|
|
707
|
+
<tr><td>ブランド名</td><td><span class="pill green">必須</span></td><td>案件設定で指定。表示幅25まで</td></tr>
|
|
708
|
+
<tr><td>ロゴ</td><td>任意</td><td>1:1・128x128以上。将来のAPIバージョンで必須化される予定</td></tr>
|
|
709
|
+
</tbody></table>
|
|
710
|
+
</div></details>
|
|
711
|
+
|
|
712
|
+
<details class="sec" open><summary><span class="num">3</span><span class="stitle">画像広告 — ロゴが必須</span><span class="chev"></span></summary>
|
|
713
|
+
<div class="body">
|
|
714
|
+
<table class="g"><thead><tr><th>項目</th><th>必須</th><th>制約</th></tr></thead><tbody>
|
|
715
|
+
<tr><td>横長画像 / 正方形画像</td><td><span class="pill green">どちらか必須</span></td><td>横長 = 1.91:1・600x314以上 / 正方形 = 1:1・300x300以上。合計20枚まで</td></tr>
|
|
716
|
+
<tr><td><b>ロゴ</b></td><td><span class="pill green">必須</span></td><td>1〜5枚。1:1・128x128以上。<b>案件設定の「ロゴアセット」で指定する</b></td></tr>
|
|
717
|
+
<tr><td>見出し / 説明文 / ブランド名</td><td><span class="pill green">必須</span></td><td>動画広告と同じ</td></tr>
|
|
718
|
+
<tr><td>CTA</td><td>任意</td><td>案件設定の CTA がそのままボタン文言になる</td></tr>
|
|
719
|
+
</tbody></table>
|
|
720
|
+
<p>画像は事前に Google Ads へ IMAGE アセットとして登録し、その <code>resource_name</code> を参照データに入れる。</p>
|
|
721
|
+
</div></details>
|
|
722
|
+
|
|
723
|
+
<details class="sec" open><summary><span class="num">4</span><span class="stitle">ターゲティング — 地域を入れないと全世界配信</span><span class="chev"></span></summary>
|
|
724
|
+
<div class="body">
|
|
725
|
+
<p class="lead"><b>地域を指定しないと全世界に配信される。</b>広告グループ行の「地域」を必ず確認する(未指定のときは警告が出る)。</p>
|
|
726
|
+
<table class="g"><thead><tr><th>項目</th><th>置き場所</th><th>内容</th></tr></thead><tbody>
|
|
727
|
+
<tr><td>地域</td><td>広告グループ行</td><td>日本 = criterion ID 2392</td></tr>
|
|
728
|
+
<tr><td>言語</td><td>広告グループ行</td><td>日本語 = 1005 / 英語 = 1000</td></tr>
|
|
729
|
+
<tr><td>配信面</td><td>広告グループ行</td><td>YouTube を選ぶとインストリーム・インフィード・ショートの3面が有効になる。「すべて」なら Google の既定に任せる</td></tr>
|
|
730
|
+
</tbody></table>
|
|
731
|
+
<p>Demand Gen は地域・言語を<b>広告グループ階層</b>に持つ(キャンペーン階層ではない)。デモグラフィック・オーディエンスのターゲティングは今回のスコープ外。</p>
|
|
732
|
+
</div></details>
|
|
733
|
+
|
|
734
|
+
<details class="sec" open><summary><span class="num">5</span><span class="stitle">予算と入札</span><span class="chev"></span></summary>
|
|
735
|
+
<div class="body">
|
|
736
|
+
<table class="g"><thead><tr><th>項目</th><th>置き場所</th><th>内容</th></tr></thead><tbody>
|
|
737
|
+
<tr><td>日予算</td><td><b>キャンペーン行</b></td><td>Meta と違い予算はキャンペーン階層(Google の CampaignBudget)</td></tr>
|
|
738
|
+
<tr><td>目標CPA</td><td>キャンペーン行</td><td>入れると目標CPA入札。<b>空欄ならコンバージョン数の最大化</b></td></tr>
|
|
739
|
+
<tr><td>広告グループ入札</td><td>—</td><td>Demand Gen は自動入札のため広告グループに入札を持たない</td></tr>
|
|
740
|
+
</tbody></table>
|
|
741
|
+
</div></details>
|
|
742
|
+
|
|
743
|
+
<details class="sec" open><summary><span class="num">6</span><span class="stitle">運用手順</span><span class="chev"></span></summary>
|
|
744
|
+
<div class="body">
|
|
745
|
+
<ol>
|
|
746
|
+
<li>YouTube に動画をアップロードし、動画IDを控える(画像広告なら Google Ads に画像アセットを登録する)</li>
|
|
747
|
+
<li>参照データに 見出し / 長見出し / 説明文 / クリエイティブ を登録する</li>
|
|
748
|
+
<li>案件設定で <b>ブランド名</b>(必須)と <b>ロゴアセット</b>(画像広告なら必須)を入れる</li>
|
|
749
|
+
<li>シートで キャンペーン(日予算・目標CPA)→ 広告グループ(地域・言語・配信面)→ 広告 を組む</li>
|
|
750
|
+
<li>「入稿JSON書き出し」→ <code>webgram submit-plan --plan-file <file></code> で dry-run</li>
|
|
751
|
+
</ol>
|
|
752
|
+
<p class="sub">dry-run で計画の不備(必須項目・文字数・チャネルと広告タイプの不整合)を潰したら <code>--no-dry-run --yes</code> で本実行できる。<b>CTA は英語の表示テキスト</b>(<code>Learn more</code> 形式。<code>Learn More</code> のような大文字表記・日本語・<code>LEARN_MORE</code> は弾かれる)。<b>使える値はアカウントによって違い</b>、弾かれると入稿時に <code>Invalid call to action text.</code> が返る。<b>広告が1件も作れなかったキャンペーンは自動で削除される</b>(作りかけの孤児を残さない)。</p>
|
|
753
|
+
<p class="sub"><b>既存キャンペーン・既存広告グループへの広告追加は未対応。</b>毎回あたらしいキャンペーンを作る形になる。</p>
|
|
754
|
+
</div></details>
|
|
755
|
+
</div><!-- /mt-google -->
|
|
680
756
|
</div>
|
|
681
757
|
</section>
|
|
682
758
|
|
|
@@ -856,7 +932,19 @@ const EC = {
|
|
|
856
932
|
BUDGET_MISSING:'BUDGET_MISSING', PIXEL_MISSING:'PIXEL_MISSING',
|
|
857
933
|
/* 配信日時 (媒体が日時列を持つ場合に使う。空欄はエラーにしない = 未指定は上位へフォールバック) */
|
|
858
934
|
START_TIME_INVALID:'START_TIME_INVALID', END_TIME_INVALID:'END_TIME_INVALID',
|
|
859
|
-
END_BEFORE_START:'END_BEFORE_START'
|
|
935
|
+
END_BEFORE_START:'END_BEFORE_START',
|
|
936
|
+
/* Google (Demand Gen) 固有 */
|
|
937
|
+
LONG_HEADLINE_UNRESOLVED:'LONG_HEADLINE_UNRESOLVED',
|
|
938
|
+
LONG_HEADLINE_TOO_LONG:'LONG_HEADLINE_TOO_LONG',
|
|
939
|
+
LONG_HEADLINE_MISSING:'LONG_HEADLINE_MISSING' /* 動画広告は長見出しが必須 */,
|
|
940
|
+
BUSINESS_NAME_MISSING:'BUSINESS_NAME_MISSING' /* proto 上 REQUIRED */,
|
|
941
|
+
LOGO_MISSING:'LOGO_MISSING' /* 画像広告はロゴ 1-5 件が必須 */,
|
|
942
|
+
LOCATION_EMPTY:'LOCATION_EMPTY' /* 地域未指定。google は全世界配信 (warn) / bytedance は入稿不可 (err) */,
|
|
943
|
+
/* ByteDance (TikTok) 固有 */
|
|
944
|
+
IDENTITY_MISSING:'IDENTITY_MISSING' /* 広告主として表示する TikTok アカウント未設定 = 広告が作れない */,
|
|
945
|
+
IDENTITY_BC_MISSING:'IDENTITY_BC_MISSING' /* identityType=BC_AUTH_TT なのに BC ID が無い (実運用の identity はほぼ BC_AUTH_TT) */,
|
|
946
|
+
COVER_MISSING:'COVER_MISSING' /* SINGLE_VIDEO はカバー画像が必須 */,
|
|
947
|
+
REGION_UNRESOLVED:'REGION_UNRESOLVED'/* 地域名を location_id に解決できない (refs.regions 未取込) */
|
|
860
948
|
};
|
|
861
949
|
|
|
862
950
|
/* ---- refs (VLOOKUP代替) ----
|
|
@@ -879,7 +967,8 @@ function pf(){ return PLATFORMS[curPf]; }
|
|
|
879
967
|
/* ---- S11: 媒体切替はヘッダーの統合プルダウン (#accSelect) に一本化 ----
|
|
880
968
|
媒体要件ビューは選択中の媒体のドキュメントだけを表示する。
|
|
881
969
|
媒体を切り替えると state は当該媒体の既定値で作り直す (プランは媒体をまたげない)。 */
|
|
882
|
-
|
|
970
|
+
/* 媒体を増やしたときに列挙を書き換え忘れると、その媒体だけドキュメントが出ないまま気づけない */
|
|
971
|
+
function showMediaDoc(id){ Object.keys(PLATFORMS).forEach(k=>{ const el=document.getElementById('mt-'+k); if(el) el.style.display = (k===id?'':'none'); }); }
|
|
883
972
|
function renderPlatformTabs(){
|
|
884
973
|
showMediaDoc(curPf);
|
|
885
974
|
renderChrome();
|
|
@@ -3262,6 +3351,1118 @@ PLATFORMS.meta = {
|
|
|
3262
3351
|
docTabs:[{id:'overview',label:'概要'},{id:'image',label:'画像広告'},
|
|
3263
3352
|
{id:'video',label:'動画広告'},{id:'param',label:'パラメータ'},{id:'guide',label:'運用手順'}]
|
|
3264
3353
|
};
|
|
3354
|
+
|
|
3355
|
+
/* ============================================================
|
|
3356
|
+
PLATFORMS.google — Google (Demand Gen) の媒体スキーマ
|
|
3357
|
+
------------------------------------------------------------
|
|
3358
|
+
YouTube 面へ動画を出す API 経路は Demand Gen だけ。VIDEO キャンペーンは
|
|
3359
|
+
Google Ads API から新規作成できないため、キャンペーン型は DEMAND_GEN 固定にする。
|
|
3360
|
+
Demand Gen は画像広告 (DEMAND_GEN_MULTI_ASSET_AD) と
|
|
3361
|
+
動画広告 (DEMAND_GEN_VIDEO_RESPONSIVE_AD) を同じキャンペーンで扱えるので、
|
|
3362
|
+
広告行の「形式」列で切り替える (Meta と同じ作り)。
|
|
3363
|
+
|
|
3364
|
+
【重要】列は backend の googleSubmitSchema が受け取れる項目だけを置く。
|
|
3365
|
+
GUI にあるのに入稿JSONへ載らない列を作ると、運用者が設定したつもりの
|
|
3366
|
+
条件が黙って無視される (YDA のターゲティング列で実際に起きている)。
|
|
3367
|
+
============================================================ */
|
|
3368
|
+
const GOOGLE_REF_TABLES = {
|
|
3369
|
+
titles:{ key:'titles', label:'見出し', countLabel:'見出し', editable:true, copyable:true, required:true,
|
|
3370
|
+
keyKind:'number', keyLabel:'番号', keyWidth:'70px', dropEmpty:true,
|
|
3371
|
+
addSub:'番号→見出し。表示幅30まで',
|
|
3372
|
+
emptyMsg:'該当する見出しがありません',
|
|
3373
|
+
lengthLimit:{warn:30, err:40},
|
|
3374
|
+
valueFields:[{k:'text', label:'文言'}],
|
|
3375
|
+
keyCell:no=>'<td class="mono">'+esc(no)+'</td>',
|
|
3376
|
+
columns:[
|
|
3377
|
+
{label:'見出し', cls:'reftext', attr:(no,v)=>' title="'+esc(v)+'"', cell:(no,v)=>esc(v)},
|
|
3378
|
+
{label:'文字数', width:'70px', cls:(no,v)=>'reflen '+refLenCls(GOOGLE_REF_TABLES.titles,v), cell:(no,v)=>glen(v)}
|
|
3379
|
+
],
|
|
3380
|
+
optionLabel:v=>String(v==null?'':v) },
|
|
3381
|
+
longHeadlines:{ key:'longHeadlines', label:'長見出し', countLabel:'長見出し', editable:true, copyable:true, required:false,
|
|
3382
|
+
keyKind:'number', keyLabel:'番号', keyWidth:'70px', dropEmpty:true,
|
|
3383
|
+
addSub:'番号→長見出し(動画広告で使用・表示幅90まで)',
|
|
3384
|
+
emptyMsg:'該当する長見出しがありません',
|
|
3385
|
+
lengthLimit:{warn:90, err:120},
|
|
3386
|
+
valueFields:[{k:'text', label:'文言'}],
|
|
3387
|
+
keyCell:no=>'<td class="mono">'+esc(no)+'</td>',
|
|
3388
|
+
columns:[
|
|
3389
|
+
{label:'長見出し', cls:'reftext', attr:(no,v)=>' title="'+esc(v)+'"', cell:(no,v)=>esc(v)},
|
|
3390
|
+
{label:'文字数', width:'70px', cls:(no,v)=>'reflen '+refLenCls(GOOGLE_REF_TABLES.longHeadlines,v), cell:(no,v)=>glen(v)}
|
|
3391
|
+
],
|
|
3392
|
+
optionLabel:v=>String(v==null?'':v) },
|
|
3393
|
+
descriptions:{ key:'descriptions', label:'説明文', countLabel:'説明文', editable:true, copyable:true, required:true,
|
|
3394
|
+
keyKind:'number', keyLabel:'番号', keyWidth:'70px', dropEmpty:true,
|
|
3395
|
+
addSub:'番号→説明文(表示幅90まで)',
|
|
3396
|
+
emptyMsg:'該当する説明文がありません',
|
|
3397
|
+
lengthLimit:{warn:90, err:120},
|
|
3398
|
+
valueFields:[{k:'text', label:'文言'}],
|
|
3399
|
+
keyCell:no=>'<td class="mono">'+esc(no)+'</td>',
|
|
3400
|
+
columns:[
|
|
3401
|
+
{label:'説明文', cls:'reftext', attr:(no,v)=>' title="'+esc(v)+'"', cell:(no,v)=>esc(v)},
|
|
3402
|
+
{label:'文字数', width:'70px', cls:(no,v)=>'reflen '+refLenCls(GOOGLE_REF_TABLES.descriptions,v), cell:(no,v)=>glen(v)}
|
|
3403
|
+
],
|
|
3404
|
+
optionLabel:v=>String(v==null?'':v) },
|
|
3405
|
+
/* image / square は IMAGE アセットの resource_name、video は YouTube 動画ID。
|
|
3406
|
+
動画は Google Ads API へアップロードできないため、人が YouTube に上げた動画IDを使う。 */
|
|
3407
|
+
media:{ key:'media', label:'クリエイティブ', countLabel:'クリエイティブ', editable:true, required:true,
|
|
3408
|
+
keyKind:'number', keyLabel:'番号', keyWidth:'80px',
|
|
3409
|
+
addSub:'番号→横長画像 / 正方形画像のアセット名、YouTube動画ID',
|
|
3410
|
+
emptyMsg:'該当するクリエイティブがありません',
|
|
3411
|
+
valueFields:[{k:'image', label:'横長画像 (1.91:1) アセット', editLabel:'横長画像アセット'},
|
|
3412
|
+
{k:'square', label:'正方形画像 (1:1) アセット', editLabel:'正方形画像アセット'},
|
|
3413
|
+
{k:'video', label:'YouTube動画ID', editLabel:'YouTube動画ID'}],
|
|
3414
|
+
keyCell:no=>'<td><span class="sw" style="display:inline-flex;width:26px;height:26px;vertical-align:middle">'+esc(no)+'</span> '+newBadge('media',no)+'</td>',
|
|
3415
|
+
columns:[
|
|
3416
|
+
{label:'横長画像', cls:'mono', cell:(no,v)=>esc(v&&v.image||'—')},
|
|
3417
|
+
{label:'正方形画像', cls:'mono', cell:(no,v)=>esc(v&&v.square||'—')},
|
|
3418
|
+
{label:'YouTube動画ID', cls:'mono', cell:(no,v)=>esc(v&&v.video||'—')}
|
|
3419
|
+
],
|
|
3420
|
+
optionLabel:v=>(((v&&(v.image||v.square))?'IMG':'')+((v&&(v.image||v.square)&&v.video)?'/':'')+((v&&v.video)?'VID':'')),
|
|
3421
|
+
drawerLabel:v=>'IMG '+esc(v&&(v.image||v.square)||'—')+' / VID '+esc(v&&v.video||'—') },
|
|
3422
|
+
adGroups:{ key:'adGroups', label:'広告グループ', countLabel:'広告グループ', editable:false, required:false,
|
|
3423
|
+
keyKind:'string', keyLabel:'広告グループID', keyWidth:'130px',
|
|
3424
|
+
emptyMsg:'既存の広告グループがありません(アカウントJSONの refs.adGroups)',
|
|
3425
|
+
valueFields:[{k:'name',label:'名'},{k:'paramCode',label:'paramCode'},{k:'campaignId',label:'キャンペーンID'}],
|
|
3426
|
+
keyCell:no=>'<td class="mono">'+esc(no)+'</td>',
|
|
3427
|
+
columns:[
|
|
3428
|
+
{label:'広告グループ名', cls:'reftext', attr:(no,v)=>' title="'+esc(v&&v.name||'')+'"', cell:(no,v)=>esc(v&&v.name||'')},
|
|
3429
|
+
{label:'paramCode', width:'130px', cls:'mono', cell:(no,v)=>esc(v&&v.paramCode||'')},
|
|
3430
|
+
{label:'キャンペーンID', width:'130px', cls:'mono', cell:(no,v)=>esc(v&&v.campaignId||'')}
|
|
3431
|
+
] },
|
|
3432
|
+
campaigns:{ key:'campaigns', label:'キャンペーン', countLabel:'キャンペーン', editable:false, required:false,
|
|
3433
|
+
keyKind:'string', keyLabel:'キャンペーンID', keyWidth:'130px',
|
|
3434
|
+
emptyMsg:'既存のキャンペーンがありません(アカウントJSONの refs.campaigns)',
|
|
3435
|
+
valueFields:[{k:'name',label:'名'}],
|
|
3436
|
+
keyCell:no=>'<td class="mono">'+esc(no)+'</td>',
|
|
3437
|
+
columns:[
|
|
3438
|
+
{label:'キャンペーン名', cls:'reftext', attr:(no,v)=>' title="'+esc(v&&v.name||'')+'"', cell:(no,v)=>esc(v&&v.name||'')}
|
|
3439
|
+
] }
|
|
3440
|
+
};
|
|
3441
|
+
|
|
3442
|
+
const GOOGLE_COLS = {
|
|
3443
|
+
ads:[
|
|
3444
|
+
{field:'format', label:'形式', kind:'select', options:['IMAGE','VIDEO'], minWidth:'70px',
|
|
3445
|
+
coerce:v=>(String(v).toUpperCase()==='VIDEO'?'VIDEO':'IMAGE'),
|
|
3446
|
+
title:'IMAGE = Demand Gen 画像広告 / VIDEO = Demand Gen 動画広告(YouTube)'},
|
|
3447
|
+
{field:'imageNo', label:'クリエイティブ番号', kind:'refcombo', refTable:'media', drawerType:'media', mediaAxis:'format',
|
|
3448
|
+
width:'6ch', sequenceable:true, errCodes:[EC.IMAGE_UNRESOLVED],
|
|
3449
|
+
ghostKey:'media', ddTitle:'登録クリエイティブから選択',
|
|
3450
|
+
ghostTitle:r=>(r.mediaKind==='VIDEO'?'YouTube動画ID ':'画像アセット ')+r.mediaId,
|
|
3451
|
+
ghost:(r,a)=>{ if(!a.imageNo) return '<span class="idpill" style="color:var(--faint)">—</span>';
|
|
3452
|
+
if(r.mediaId!=='') return '<span class="idpill">'+(r.mediaKind==='VIDEO'?'VID ':'IMG ')+esc(r.mediaId)+'</span>';
|
|
3453
|
+
if(r.pendingImage) return '<span class="idpill warn" title="アカウント未登録。入稿前にアップロードして新規登録">🆕未登録</span>';
|
|
3454
|
+
return '<span class="idpill err">#N/A</span>'; }},
|
|
3455
|
+
{field:'titleNo', label:'見出し番号', kind:'refcombo', refTable:'titles', drawerType:'titles',
|
|
3456
|
+
width:'6ch', sequenceable:true, errCodes:[EC.TITLE_UNRESOLVED, EC.TITLE_TOO_LONG],
|
|
3457
|
+
ghostKey:'title', ddTitle:'見出し一覧から選択', ghostTitle:r=>r.titleText,
|
|
3458
|
+
ghost:r=>r.errCodes.includes(EC.TITLE_UNRESOLVED)?'<span class="err">未解決</span>':esc(r.titleText.slice(0,40))},
|
|
3459
|
+
/* 長見出しは動画広告だけが持つ (DemandGenVideoResponsiveAdInfo.long_headlines)。
|
|
3460
|
+
画像広告では送らないので、IMAGE 行では空のままでよい。 */
|
|
3461
|
+
{field:'longNo', label:'長見出し番号', kind:'refcombo', refTable:'longHeadlines', drawerType:'titles',
|
|
3462
|
+
width:'6ch', sequenceable:true, errCodes:[EC.LONG_HEADLINE_UNRESOLVED, EC.LONG_HEADLINE_TOO_LONG, EC.LONG_HEADLINE_MISSING],
|
|
3463
|
+
ghostKey:'longHeadline', ddTitle:'長見出し一覧から選択', ghostTitle:r=>r.longText,
|
|
3464
|
+
title:'動画広告(VIDEO)で必須。画像広告では使いません',
|
|
3465
|
+
ghost:r=>r.errCodes.includes(EC.LONG_HEADLINE_UNRESOLVED)?'<span class="err">未解決</span>'
|
|
3466
|
+
:r.errCodes.includes(EC.LONG_HEADLINE_MISSING)?'<span class="err">動画は必須</span>'
|
|
3467
|
+
:esc(r.longText.slice(0,40))},
|
|
3468
|
+
{field:'descNo', label:'説明文番号', kind:'refcombo', refTable:'descriptions', drawerType:'titles',
|
|
3469
|
+
width:'6ch', sequenceable:true, errCodes:[EC.DESC_UNRESOLVED, EC.DESC_TOO_LONG],
|
|
3470
|
+
ghostKey:'desc', ddTitle:'説明文一覧から選択', ghostTitle:r=>r.descText,
|
|
3471
|
+
ghost:r=>r.errCodes.includes(EC.DESC_UNRESOLVED)?'<span class="err">— 未解決</span>':esc(r.descText.slice(0,44))},
|
|
3472
|
+
{field:'paramCode', label:'paramCode(広告グループ)', kind:'mono', width:'120px',
|
|
3473
|
+
scope:'parentGroup', sequenceable:true, readonlyOnExisting:true, errCodes:[EC.PARAM_CODE_EMPTY],
|
|
3474
|
+
title:'広告グループ共通(変更で同グループ全広告に反映)。最終URLは 記事URL?ad={paramCode}',
|
|
3475
|
+
hidden:p=>urlParamsOff(p),
|
|
3476
|
+
roStyle:'input', roTitle:'既存広告グループのため編集不可'},
|
|
3477
|
+
{field:'finalUrl', label:'最終リンク先URL', kind:'mono', width:'240px',
|
|
3478
|
+
alwaysReadonly:true, roStyle:'input',
|
|
3479
|
+
roTitle:'自動生成(編集不可)。変えるときは 案件設定の記事URL / paramCode を直します',
|
|
3480
|
+
get:(row,ctx)=>ctx.r?ctx.r.finalUrl:''},
|
|
3481
|
+
{field:'adName', label:'広告名', kind:'mono', width:'170px',
|
|
3482
|
+
title:'空欄なら自動生成({番号}{i|v}-{見出し番号}L{説明文番号}-{paramCode})',
|
|
3483
|
+
placeholder:ctx=>ctx.r?ctx.r.autoAdName:''},
|
|
3484
|
+
{field:'adGroupId', label:'CP / AG ID', kind:'derived',
|
|
3485
|
+
derive:(a,ctx)=>'<span class="idpill '+(ctx.r.resolvedGroup?'':'warn')+'" data-d="'+a.uid+'-ids">'+esc(ctx.r.adGroupId?('AG '+ctx.r.adGroupId):'新規')+'</span>'}
|
|
3486
|
+
],
|
|
3487
|
+
adGroups:[
|
|
3488
|
+
{field:'adGroupId', label:'広告グループID', kind:'badge', alwaysReadonly:true},
|
|
3489
|
+
{field:'name', label:'広告グループ名', kind:'text', width:'150px', required:true,
|
|
3490
|
+
readonlyOnExisting:true, roTitle:'既存広告グループのため編集不可'},
|
|
3491
|
+
{field:'paramCode', label:'paramCode', kind:'mono', width:'120px', required:true,
|
|
3492
|
+
sequenceable:true, readonlyOnExisting:true, hidden:p=>urlParamsOff(p),
|
|
3493
|
+
title:'URL生成用のパラメータ(広告グループ紐づけには不使用)'},
|
|
3494
|
+
/* Demand Gen は upgraded_targeting が既定 true のため、地域・言語は広告グループ層に置く。
|
|
3495
|
+
ここが空だと全世界配信になるので resolve で警告を出す。 */
|
|
3496
|
+
{field:'tg_location', label:'地域', kind:'tgpick', tgKey:'tg_location', readonlyOnExisting:true, roEmptyDash:true,
|
|
3497
|
+
title:'未指定だと全世界配信になります'},
|
|
3498
|
+
{field:'tg_language', label:'言語', kind:'tgpick', tgKey:'tg_language', readonlyOnExisting:true, roEmptyDash:true},
|
|
3499
|
+
{field:'tg_channel', label:'配信面', kind:'tgpick', tgKey:'tg_channel', readonlyOnExisting:true, roEmptyDash:true,
|
|
3500
|
+
title:'YouTube だけに出したい場合はここで絞る。「すべて」なら Google の既定に任せる'},
|
|
3501
|
+
{field:'deliveryStatus', label:'配信', kind:'select', options:['','オフ','オン'], minWidth:'66px',
|
|
3502
|
+
readonlyOnExisting:true, roEmptyDash:true, coerce:v=>{const s=String(v).trim(); return (s==='オフ'||s==='オン')?s:'';}}
|
|
3503
|
+
],
|
|
3504
|
+
campaigns:[
|
|
3505
|
+
{field:'campaignId', label:'キャンペーンID', kind:'badge', alwaysReadonly:true},
|
|
3506
|
+
{field:'name', label:'キャンペーン名', kind:'text', width:'170px', required:true,
|
|
3507
|
+
readonlyOnExisting:true, roTitle:'既存キャンペーンのため編集不可'},
|
|
3508
|
+
/* budgetLevel='campaign' → 予算はキャンペーン行が持つ (Google の CampaignBudget) */
|
|
3509
|
+
{field:'dailyBudget', label:'日予算(円)', kind:'number', width:'84px', required:true,
|
|
3510
|
+
coerce:numOrNull, readonlyOnExisting:true, roEmptyDash:true, errCodes:[EC.BUDGET_MISSING],
|
|
3511
|
+
placeholder:()=>String(pf().defaults.newCampaignBudget)},
|
|
3512
|
+
{field:'targetCpa', label:'目標CPA(円)', kind:'number', width:'84px', coerce:numOrNull,
|
|
3513
|
+
readonlyOnExisting:true, roEmptyDash:true,
|
|
3514
|
+
title:'入れると TARGET_CPA 入札。空欄なら MAXIMIZE_CONVERSIONS(コンバージョン数の最大化)',
|
|
3515
|
+
placeholder:()=>String(pf().defaults.targetCpa)},
|
|
3516
|
+
{field:'urlNumber', label:'リンク番号', kind:'mono', width:'54px', hideOnExisting:true}
|
|
3517
|
+
]
|
|
3518
|
+
};
|
|
3519
|
+
|
|
3520
|
+
PLATFORMS.google = {
|
|
3521
|
+
id:'google', displayName:'Google', brandTitle:'Google入稿プランナー',
|
|
3522
|
+
|
|
3523
|
+
account:{
|
|
3524
|
+
idPattern:/^\d{10}$/, idHint:'Google 広告アカウントID(ハイフン無し10桁)', dir:'アカウント/google',
|
|
3525
|
+
apply(obj, project){
|
|
3526
|
+
if(obj.accountId!=null) project.accountId = String(obj.accountId).replace(/-/g,'');
|
|
3527
|
+
if(obj.name!=null) project.accountName=String(obj.name);
|
|
3528
|
+
if(obj.principal!=null) project.principal=String(obj.principal);
|
|
3529
|
+
if(obj.businessName!=null) project.businessName=String(obj.businessName);
|
|
3530
|
+
if(obj.logoAsset!=null) project.logoAsset=String(obj.logoAsset);
|
|
3531
|
+
if(obj.consoleMcc!=null) project.consoleMcc=String(obj.consoleMcc).replace(/-/g,'');
|
|
3532
|
+
if(obj.projectName) project.name=obj.projectName;
|
|
3533
|
+
if(obj.baseArticleUrl) project.baseArticleUrl=obj.baseArticleUrl;
|
|
3534
|
+
if(obj.urlParamsEnabled!=null) project.urlParams=!!obj.urlParamsEnabled; else delete project.urlParams;
|
|
3535
|
+
},
|
|
3536
|
+
schemaLines(opts){
|
|
3537
|
+
const o=opts||{};
|
|
3538
|
+
return ['{',' "accountJsonVersion": 1,',' "platform": "google",',' "accountId": "<10桁のCustomer ID(ハイフン無し)>",',
|
|
3539
|
+
' "name": "'+(o.nameHint||'<アカウント名>')+'",',
|
|
3540
|
+
' "consoleMcc": "<MCCのCustomer ID(管理画面URL用・任意)>",',
|
|
3541
|
+
' "businessName": "<広告に出すブランド名(25字以内)>",',
|
|
3542
|
+
' "logoAsset": "<ロゴ画像アセットの resource_name>",']
|
|
3543
|
+
.concat(o.updatedAt===false?[]:[' "updatedAt": "<ISO8601>",'])
|
|
3544
|
+
.concat([
|
|
3545
|
+
' "refs": {',
|
|
3546
|
+
' "titles": {"1": "見出し(表示幅30まで)"},',
|
|
3547
|
+
' "longHeadlines": {"1": "長見出し(動画広告用・表示幅90まで)"},',
|
|
3548
|
+
' "descriptions": {"1": "説明文(表示幅90まで)"},',
|
|
3549
|
+
' "media": {"1": {"image": "<横長画像アセット>", "square": "<正方形画像アセット>", "video": "<YouTube動画ID>"}},',
|
|
3550
|
+
' "campaigns": {"<campaignId>": {"name": "CP名"}},',
|
|
3551
|
+
' "adGroups": {"<adGroupId>": {"name": "広告グループ名", "campaignId": "<campaignId>", "paramCode": "<code>"}}',
|
|
3552
|
+
' }','}']);
|
|
3553
|
+
}
|
|
3554
|
+
},
|
|
3555
|
+
|
|
3556
|
+
levels:{
|
|
3557
|
+
campaign:{label:'キャンペーン', childKey:'adGroups', planKey:'adGroups'},
|
|
3558
|
+
adGroup :{label:'広告グループ', childKey:'ads', planKey:'ads'},
|
|
3559
|
+
ad :{label:'広告'}
|
|
3560
|
+
},
|
|
3561
|
+
budgetLevel:'campaign',
|
|
3562
|
+
consoleUrl:'https://ads.google.com/aw/{resource}?ocid=&__c={mcc}&__u={accountId}',
|
|
3563
|
+
consoleResourceMap:{campaigns:'campaigns', adGroups:'adgroups', ads:'ads'},
|
|
3564
|
+
|
|
3565
|
+
mediaFormats:[
|
|
3566
|
+
{id:'IMAGE', crSuffix:'i', mediaKey:'image', apiMediaKind:'IMAGE', label:'画像アセット'},
|
|
3567
|
+
{id:'VIDEO', crSuffix:'v', mediaKey:'video', apiMediaKind:'VIDEO', label:'YouTube動画ID'}
|
|
3568
|
+
],
|
|
3569
|
+
fmtOf(ad){ const F=PLATFORMS.google.mediaFormats; return F.find(x=>x.id===(ad&&ad.format)) || F[0]; },
|
|
3570
|
+
|
|
3571
|
+
refTables:GOOGLE_REF_TABLES,
|
|
3572
|
+
sheets:GOOGLE_COLS,
|
|
3573
|
+
|
|
3574
|
+
projectFields:[
|
|
3575
|
+
{key:'businessName', label:'ブランド名', kind:'text', width:'14ch', fallback:'',
|
|
3576
|
+
title:'広告に表示される広告主名。Demand Gen 広告では必須(表示幅25まで)'},
|
|
3577
|
+
{key:'logoAsset', label:'ロゴアセット', kind:'text', width:'18ch', fallback:'',
|
|
3578
|
+
title:'ロゴ画像アセットの resource_name。画像広告では必須(1:1・128x128以上)'},
|
|
3579
|
+
{key:'cta', label:'CTA', kind:'select',
|
|
3580
|
+
options:['詳しくはこちら','購入する','登録する','ダウンロード','お問い合わせ','今すぐ申し込む',
|
|
3581
|
+
'予約する','見積もりを取得','購読する','インストール','サイトへアクセス','もっと見る','ボタンなし'],
|
|
3582
|
+
title:'画像広告のボタン文言。使える値はアカウントによって違い、弾かれると入稿時にエラーが出ます。動画広告ではアセット登録が要るため今は送りません'},
|
|
3583
|
+
{key:'deliveryStatus', label:'配信', kind:'select', options:['オフ','オン']},
|
|
3584
|
+
{key:'baseArticleUrl', label:'記事URL', kind:'text', width:'16ch', fallback:''},
|
|
3585
|
+
{key:'urlParams', label:'URLパラメータ', kind:'toggle', fallback:true,
|
|
3586
|
+
title:'OFF にすると最終リンク先URLに ?ad= を付けません(案件単位)'}
|
|
3587
|
+
],
|
|
3588
|
+
fixedType:[{label:'キャンペーン型',value:'Demand Gen(YouTube / Discover / Gmail)'}],
|
|
3589
|
+
|
|
3590
|
+
targeting:{
|
|
3591
|
+
tg_location:{label:'地域', multi:true, allLabel:'指定なし(全世界)', opts:['指定なし(全世界)','日本']},
|
|
3592
|
+
tg_language:{label:'言語', multi:true, allLabel:'指定なし', opts:['指定なし','日本語','英語']},
|
|
3593
|
+
tg_channel:{label:'配信面', multi:true, allLabel:'すべて(Google既定)',
|
|
3594
|
+
opts:['すべて(Google既定)','YouTube','Discover','Gmail','ディスプレイ']}
|
|
3595
|
+
},
|
|
3596
|
+
|
|
3597
|
+
enums:{
|
|
3598
|
+
/* criterion ID は Google の公式データ表 (geotargets / codes-formats) 由来 */
|
|
3599
|
+
location:{'日本':'2392'},
|
|
3600
|
+
language:{'日本語':'1005','英語':'1000'},
|
|
3601
|
+
/* call_to_action_text は公式 Call-to-Action Text Translations の **en-GB 表記**。
|
|
3602
|
+
2026-08-23 実測: en 表記 (Learn More) も日本語 (詳細) も enum 名 (LEARN_MORE) も全滅し、
|
|
3603
|
+
en-GB 表記だけが通った。**使える値はアカウントによって違う**(あるアカウントでは
|
|
3604
|
+
Shop now が弾かれ、別アカウントでは通った)ので、ここでは表記だけ正しく変換する。 */
|
|
3605
|
+
cta:{'詳しくはこちら':'Learn more','購入する':'Shop now','登録する':'Sign up',
|
|
3606
|
+
'ダウンロード':'Download','お問い合わせ':'Contact us','今すぐ申し込む':'Apply now',
|
|
3607
|
+
'予約する':'Book now','見積もりを取得':'Get quote','購読する':'Subscribe',
|
|
3608
|
+
'インストール':'Install','サイトへアクセス':'Visit site','もっと見る':'See more',
|
|
3609
|
+
'ボタンなし':''},
|
|
3610
|
+
statusOn:'ENABLED', statusOff:'PAUSED',
|
|
3611
|
+
advertisingChannelType:'DEMAND_GEN',
|
|
3612
|
+
adTypeOf:{IMAGE:'DEMAND_GEN_MULTI_ASSET_AD', VIDEO:'DEMAND_GEN_VIDEO_RESPONSIVE_AD'}
|
|
3613
|
+
},
|
|
3614
|
+
|
|
3615
|
+
mappers:{
|
|
3616
|
+
status(s, P){ return s==='オン'?P.enums.statusOn:P.enums.statusOff; },
|
|
3617
|
+
locationIds(s, P){ return mapByEnum(s, P.enums.location, P.targeting.tg_location.allLabel); },
|
|
3618
|
+
languageIds(s, P){ return mapByEnum(s, P.enums.language, P.targeting.tg_language.allLabel); },
|
|
3619
|
+
/* 配信面。「すべて」= 指定しない (キー自体を出さず Google 既定に任せる)。
|
|
3620
|
+
YouTube を選ぶと in-stream / in-feed / shorts の3面がまとめて ON になる。 */
|
|
3621
|
+
channels(s, P){
|
|
3622
|
+
const l=tgList(s);
|
|
3623
|
+
if(!l.length || l.indexOf(P.targeting.tg_channel.allLabel)>=0) return null;
|
|
3624
|
+
const yt=l.indexOf('YouTube')>=0;
|
|
3625
|
+
return {youtubeInStream:yt, youtubeInFeed:yt, youtubeShorts:yt,
|
|
3626
|
+
discover:l.indexOf('Discover')>=0, gmail:l.indexOf('Gmail')>=0,
|
|
3627
|
+
display:l.indexOf('ディスプレイ')>=0};
|
|
3628
|
+
},
|
|
3629
|
+
cta(label, P){ const v=P.enums.cta[String(label||'').trim()]; return v==null?'':v; }
|
|
3630
|
+
},
|
|
3631
|
+
|
|
3632
|
+
defaults:{
|
|
3633
|
+
newCampaignBudget:12000,
|
|
3634
|
+
targetCpa:8500,
|
|
3635
|
+
project:{
|
|
3636
|
+
businessName:'', logoAsset:'', cta:'詳しくはこちら',
|
|
3637
|
+
deliveryStatus:'オフ'
|
|
3638
|
+
},
|
|
3639
|
+
ad:{format:'VIDEO', imageNo:'', titleNo:'', longNo:'', descNo:'', adName:''},
|
|
3640
|
+
adGroup:{name:'', paramCode:'', isExisting:false,
|
|
3641
|
+
tg_location:'日本', tg_language:'日本語', tg_channel:'YouTube', deliveryStatus:''},
|
|
3642
|
+
campaign:{name:'新規キャンペーン', campaignId:'', isExisting:false,
|
|
3643
|
+
dailyBudget:12000, targetCpa:8500, urlNumber:''}
|
|
3644
|
+
},
|
|
3645
|
+
factories:{
|
|
3646
|
+
newAd(seed){ return Object.assign({uid:uid('a')}, PLATFORMS.google.defaults.ad, seed||{}); },
|
|
3647
|
+
newAdGroup(c, i){ const P=PLATFORMS.google;
|
|
3648
|
+
return Object.assign({uid:uid('g')}, P.defaults.adGroup,
|
|
3649
|
+
{name:'AG'+((i!=null?i:(c?c.adGroups.length:0))+1), ads:[P.factories.newAd()]}); },
|
|
3650
|
+
newCampaign(seed){ const P=PLATFORMS.google;
|
|
3651
|
+
return Object.assign({uid:uid('c')}, P.defaults.campaign,
|
|
3652
|
+
{adGroups:[P.factories.newAdGroup(null,0)]}, seed||{}); },
|
|
3653
|
+
existingCampaign(cpId, name){ const P=PLATFORMS.google;
|
|
3654
|
+
return Object.assign({uid:uid('c')}, P.defaults.campaign,
|
|
3655
|
+
{name:name!=null?name:'', campaignId:String(cpId), isExisting:true, urlNumber:'', adGroups:[]}); },
|
|
3656
|
+
existingAdGroup(agId, meta){ const P=PLATFORMS.google, m=meta||{};
|
|
3657
|
+
return Object.assign({uid:uid('g')}, P.defaults.adGroup,
|
|
3658
|
+
{name:m.name||('AG '+agId), paramCode:m.paramCode||'', adGroupId:String(agId),
|
|
3659
|
+
isExisting:true, ads:[P.factories.newAd()]}); }
|
|
3660
|
+
},
|
|
3661
|
+
|
|
3662
|
+
/* 命名規則は YDA / Meta 踏襲 (形式記号は mediaFormats.crSuffix = i/v) */
|
|
3663
|
+
naming:{
|
|
3664
|
+
crName(ad, P){ return String(ad.imageNo||'') + P.fmtOf(ad).crSuffix; },
|
|
3665
|
+
adName(ad, group, crName, project){
|
|
3666
|
+
const head = crName + '-' + (ad.titleNo??'') + 'L' + (ad.descNo??'');
|
|
3667
|
+
if(project && project.urlParams===false) return head;
|
|
3668
|
+
return head + '-' + (group.paramCode||'');
|
|
3669
|
+
},
|
|
3670
|
+
/* Meta と同じ形(記事URL + ?ad={paramCode})。creative= は付けない */
|
|
3671
|
+
finalUrl(ad, group, adName, project){
|
|
3672
|
+
const base = (project.baseArticleUrl||'');
|
|
3673
|
+
if(project.urlParams===false) return base;
|
|
3674
|
+
const sep = base.indexOf('?')>=0 ? '&' : '?';
|
|
3675
|
+
return base + sep + 'ad=' + urlq(group.paramCode||'');
|
|
3676
|
+
},
|
|
3677
|
+
campaignName(urlNumber, firstAd, P){
|
|
3678
|
+
const cr = firstAd ? (String(firstAd.imageNo||'CR') + P.fmtOf(firstAd).crSuffix) : 'CR';
|
|
3679
|
+
return String(urlNumber) + '_' + cr + '_' + today();
|
|
3680
|
+
}
|
|
3681
|
+
},
|
|
3682
|
+
creativeFields:['imageNo','titleNo','longNo','descNo'],
|
|
3683
|
+
notSubmittedFields:[],
|
|
3684
|
+
labels:{submitButton:'完成 → 保存して入稿', paramCodeLabel:'paramCode', tgDroppedWarn:''},
|
|
3685
|
+
|
|
3686
|
+
tree:{
|
|
3687
|
+
campaignBadges(c, P, p){
|
|
3688
|
+
return '<span class="chip">Demand Gen</span>'
|
|
3689
|
+
+ (c.dailyBudget!=null&&c.dailyBudget!=='' ? '<span class="chip">¥'+esc(c.dailyBudget)+'/日</span>' : '')
|
|
3690
|
+
+ (c.targetCpa!=null&&c.targetCpa!=='' ? '<span class="chip">tCPA ¥'+esc(c.targetCpa)+'</span>' : '');
|
|
3691
|
+
},
|
|
3692
|
+
groupBadges(g){ return '<span class="pill mono">【'+esc(g.paramCode||'—')+'】</span>'
|
|
3693
|
+
+ '<span class="chip">'+esc(g.tg_channel||'—')+'</span>'; },
|
|
3694
|
+
adBadge(a, r){ return '<span class="cbadge">'+esc(a.imageNo||'—')+((a.format==='VIDEO')?'v':'i')+'</span>'; },
|
|
3695
|
+
adSub(a, r){ return esc(a.titleNo)+'L'+esc(a.descNo); }
|
|
3696
|
+
},
|
|
3697
|
+
|
|
3698
|
+
resolve(ad, group, ctx){
|
|
3699
|
+
const P=PLATFORMS.google, p=ctx.project, refs=ctx.refs;
|
|
3700
|
+
const fmt=P.fmtOf(ad);
|
|
3701
|
+
const crName=P.naming.crName(ad, P);
|
|
3702
|
+
const autoAdName=P.naming.adName(ad, group, crName, p);
|
|
3703
|
+
const adName=String(ad.adName||'').trim() || autoAdName;
|
|
3704
|
+
const me = refs.media[String(ad.imageNo)];
|
|
3705
|
+
/* 画像広告は横長・正方形のどちらかがあれば作れる。表示用には先に見つかったほうを出す。 */
|
|
3706
|
+
const mediaId = me ? (fmt.id==='VIDEO' ? me.video : (me.image || me.square)) : undefined;
|
|
3707
|
+
const imageAsset = me ? me.image : undefined;
|
|
3708
|
+
const squareAsset = me ? me.square : undefined;
|
|
3709
|
+
const titleText = refs.titles[ad.titleNo];
|
|
3710
|
+
const longText = (refs.longHeadlines||{})[ad.longNo];
|
|
3711
|
+
const descText = (refs.descriptions||{})[ad.descNo];
|
|
3712
|
+
const finalUrl = P.naming.finalUrl(ad, group, adName, p);
|
|
3713
|
+
const errs=[], warns=[], errCodes=[], warnCodes=[];
|
|
3714
|
+
const pushErr=(label,code)=>{ errs.push(label); errCodes.push(code); };
|
|
3715
|
+
const pushWarn=(label,code)=>{ warns.push(label); warnCodes.push(code); };
|
|
3716
|
+
const noStr=String(ad.imageNo||'').trim();
|
|
3717
|
+
const pendingImage = (noStr!=='' && (mediaId===undefined || mediaId===''));
|
|
3718
|
+
const T=P.refTables.titles.lengthLimit, L=P.refTables.longHeadlines.lengthLimit,
|
|
3719
|
+
D=P.refTables.descriptions.lengthLimit;
|
|
3720
|
+
if(noStr==='') pushErr('クリエイティブ未解決', EC.IMAGE_UNRESOLVED);
|
|
3721
|
+
if(String(ad.titleNo||'').trim()!=='' && titleText===undefined) pushErr('見出し未解決', EC.TITLE_UNRESOLVED);
|
|
3722
|
+
if(String(ad.longNo||'').trim()!=='' && longText===undefined) pushErr('長見出し未解決', EC.LONG_HEADLINE_UNRESOLVED);
|
|
3723
|
+
if(String(ad.descNo||'').trim()!=='' && descText===undefined) pushErr('説明文未解決', EC.DESC_UNRESOLVED);
|
|
3724
|
+
/* 動画広告は long_headlines が要る (画像広告は持たない) */
|
|
3725
|
+
if(fmt.id==='VIDEO' && longText===undefined) pushErr('動画広告は長見出しが必須', EC.LONG_HEADLINE_MISSING);
|
|
3726
|
+
if(titleText!==undefined && glen(titleText)>T.err) pushErr('見出し'+T.err+'字超', EC.TITLE_TOO_LONG);
|
|
3727
|
+
else if(titleText!==undefined && glen(titleText)>T.warn) pushWarn('見出し'+T.warn+'字超', EC.TITLE_LONG_WARN);
|
|
3728
|
+
if(longText!==undefined && glen(longText)>L.err) pushErr('長見出し'+L.err+'字超', EC.LONG_HEADLINE_TOO_LONG);
|
|
3729
|
+
if(descText!==undefined && glen(descText)>D.err) pushErr('説明文'+D.err+'字超', EC.DESC_TOO_LONG);
|
|
3730
|
+
else if(descText!==undefined && glen(descText)>D.warn) pushWarn('説明文'+D.warn+'字超', EC.DESC_LONG_WARN);
|
|
3731
|
+
if(p.urlParams!==false && String(group.paramCode||'').trim()==='') pushErr('paramCode未入力', EC.PARAM_CODE_EMPTY);
|
|
3732
|
+
/* Demand Gen の必須 (proto: business_name は REQUIRED / 画像広告の logo_images は 1-5 件) */
|
|
3733
|
+
if(String(p.businessName||'').trim()==='') pushErr('ブランド名未入力', EC.BUSINESS_NAME_MISSING);
|
|
3734
|
+
if(fmt.id==='IMAGE' && String(p.logoAsset||'').trim()==='') pushErr('ロゴアセット未入力', EC.LOGO_MISSING);
|
|
3735
|
+
/* 地域未指定は全世界配信になる。作成自体は通るのでエラーではなく警告 */
|
|
3736
|
+
if(!P.mappers.locationIds(group.tg_location, P).length) pushWarn('地域未指定(全世界配信)', EC.LOCATION_EMPTY);
|
|
3737
|
+
if(pendingImage) pushWarn(fmt.id==='VIDEO'?'YouTube動画ID未登録':'画像アセット未登録', EC.MEDIA_MISSING);
|
|
3738
|
+
const aid=String(group.adGroupId||'').trim();
|
|
3739
|
+
return {crName, adName, autoAdName, campaignId:'', adGroupId:aid, mediaKind:fmt.apiMediaKind, imageNo:noStr,
|
|
3740
|
+
mediaId:mediaId||'', imageAsset:imageAsset||'', squareAsset:squareAsset||'', pendingImage,
|
|
3741
|
+
titleText:titleText===undefined?'':titleText,
|
|
3742
|
+
longText:longText===undefined?'':longText,
|
|
3743
|
+
descText:descText===undefined?'':descText,
|
|
3744
|
+
finalUrl, errs, warns, errCodes, warnCodes, resolvedGroup:!!aid};
|
|
3745
|
+
},
|
|
3746
|
+
|
|
3747
|
+
validateRow(row, level, ctx){
|
|
3748
|
+
const errCodes=[], warnCodes=[];
|
|
3749
|
+
/* budgetLevel='campaign' なので予算はキャンペーン行が持つ */
|
|
3750
|
+
if(level==='campaign' && !isExistingCampaign(row)){
|
|
3751
|
+
if(!(Number(row.dailyBudget)>0)) errCodes.push(EC.BUDGET_MISSING);
|
|
3752
|
+
}
|
|
3753
|
+
if(level==='adGroup' && !isExistingGroup(row)){
|
|
3754
|
+
if(!PLATFORMS.google.mappers.locationIds(row.tg_location, PLATFORMS.google).length)
|
|
3755
|
+
warnCodes.push(EC.LOCATION_EMPTY);
|
|
3756
|
+
}
|
|
3757
|
+
return {errCodes, warnCodes};
|
|
3758
|
+
},
|
|
3759
|
+
|
|
3760
|
+
validateProject(p, state){
|
|
3761
|
+
const errs=[];
|
|
3762
|
+
if(!String((p&&p.businessName)||'').trim())
|
|
3763
|
+
errs.push({code:EC.BUSINESS_NAME_MISSING, msg:'ブランド名を共通設定に入力してください(Demand Gen 広告で必須)'});
|
|
3764
|
+
/* 画像広告を1件でも含むならロゴが要る (動画だけなら任意) */
|
|
3765
|
+
const hasImageAd=((state&&state.campaigns)||[]).some(c=>(c.adGroups||[]).some(g=>(g.ads||[]).some(a=>a.format!=='VIDEO')));
|
|
3766
|
+
if(hasImageAd && !String((p&&p.logoAsset)||'').trim())
|
|
3767
|
+
errs.push({code:EC.LOGO_MISSING, msg:'画像広告があるためロゴアセットが必須です(1:1・128x128以上)'});
|
|
3768
|
+
/* 予算はキャンペーン階層。コアの書き出しは広告グループ行しか検証しないため、
|
|
3769
|
+
ここで見ないと budgetAmountYen:0 のまま書き出され dry-run まで気づけない。 */
|
|
3770
|
+
((state&&state.campaigns)||[]).forEach(c=>{
|
|
3771
|
+
if(isExistingCampaign(c)) return;
|
|
3772
|
+
if(!(Number(c.dailyBudget)>0))
|
|
3773
|
+
errs.push({code:EC.BUDGET_MISSING, msg:'キャンペーン「'+String(c.name||'')+'」の日予算を入力してください'});
|
|
3774
|
+
});
|
|
3775
|
+
return errs;
|
|
3776
|
+
},
|
|
3777
|
+
|
|
3778
|
+
buildPlan:{
|
|
3779
|
+
campaign(c, ctx){
|
|
3780
|
+
const P=PLATFORMS.google, p=ctx.project;
|
|
3781
|
+
const cp={
|
|
3782
|
+
name:c.name,
|
|
3783
|
+
status:P.mappers.status(p.deliveryStatus, P),
|
|
3784
|
+
budgetAmountYen:Number(c.dailyBudget)||0,
|
|
3785
|
+
advertisingChannelType:P.enums.advertisingChannelType
|
|
3786
|
+
};
|
|
3787
|
+
/* 目標CPA があれば TARGET_CPA 入札。無ければ backend 側が MAXIMIZE_CONVERSIONS を選ぶ */
|
|
3788
|
+
if(Number(c.targetCpa)>0) cp.targetCpaYen=Number(c.targetCpa);
|
|
3789
|
+
/* 既存キャンペーンへの追加は未対応。campaignId を載せても backend の zod が落とすため、
|
|
3790
|
+
「指定したのに新規作成される」誤解を生まないよう最初から出さない。 */
|
|
3791
|
+
return cp;
|
|
3792
|
+
},
|
|
3793
|
+
adGroup(g, c, ctx){
|
|
3794
|
+
const P=PLATFORMS.google, p=ctx.project;
|
|
3795
|
+
const ag={
|
|
3796
|
+
name:g.name,
|
|
3797
|
+
status:P.mappers.status(g.deliveryStatus||p.deliveryStatus, P)
|
|
3798
|
+
};
|
|
3799
|
+
/* 地域・言語は広告グループ層 (Demand Gen は upgraded_targeting=true が既定) */
|
|
3800
|
+
const loc=P.mappers.locationIds(g.tg_location, P); if(loc.length) ag.locationIds=loc;
|
|
3801
|
+
const lang=P.mappers.languageIds(g.tg_language, P); if(lang.length) ag.languageIds=lang;
|
|
3802
|
+
const ch=P.mappers.channels(g.tg_channel, P); if(ch) ag.demandGenChannels=ch;
|
|
3803
|
+
/* 既存広告グループへの追加は未対応 (キャンペーンと同じ理由で adGroupId を出さない) */
|
|
3804
|
+
return ag;
|
|
3805
|
+
},
|
|
3806
|
+
ad(a, r, g, ctx){
|
|
3807
|
+
const P=PLATFORMS.google, p=ctx.project;
|
|
3808
|
+
const isVideo = r.mediaKind==='VIDEO';
|
|
3809
|
+
const out={
|
|
3810
|
+
type:P.enums.adTypeOf[isVideo?'VIDEO':'IMAGE'],
|
|
3811
|
+
adName:r.adName,
|
|
3812
|
+
status:P.mappers.status(g.deliveryStatus||p.deliveryStatus, P),
|
|
3813
|
+
businessName:String(p.businessName||''),
|
|
3814
|
+
headlines:r.titleText?[String(r.titleText)]:[],
|
|
3815
|
+
descriptions:r.descText?[String(r.descText)]:[],
|
|
3816
|
+
finalUrls:[r.finalUrl]
|
|
3817
|
+
};
|
|
3818
|
+
const logo=String(p.logoAsset||'').trim();
|
|
3819
|
+
if(isVideo){
|
|
3820
|
+
out.youtubeVideoId=String(r.mediaId);
|
|
3821
|
+
out.longHeadlines=r.longText?[String(r.longText)]:[];
|
|
3822
|
+
if(logo) out.logoImages=[logo];
|
|
3823
|
+
} else {
|
|
3824
|
+
/* 画像は横長・正方形のうち登録があるものだけ送る (proto: どちらか必須) */
|
|
3825
|
+
if(r.imageAsset) out.marketingImages=[String(r.imageAsset)];
|
|
3826
|
+
if(r.squareAsset) out.squareMarketingImages=[String(r.squareAsset)];
|
|
3827
|
+
out.logoImages=logo?[logo]:[];
|
|
3828
|
+
const cta=P.mappers.cta(p.cta, P); if(cta) out.callToActionText=cta;
|
|
3829
|
+
}
|
|
3830
|
+
return out;
|
|
3831
|
+
}
|
|
3832
|
+
},
|
|
3833
|
+
|
|
3834
|
+
output:{
|
|
3835
|
+
planVersion:1,
|
|
3836
|
+
planDir:'.claude/skills/ad-ops/入稿計画',
|
|
3837
|
+
samplePlanFile:'サンプルプラン_google.json',
|
|
3838
|
+
fileNamePrefix:p=>(p.name||'plan')+'_Google入稿計画_',
|
|
3839
|
+
submitCommand:(rel,opts)=>'webgram submit-plan --plan-file "'+rel+'"'+((opts&&opts.dryRun===false)?' --no-dry-run --yes':'')
|
|
3840
|
+
},
|
|
3841
|
+
|
|
3842
|
+
prompts:{
|
|
3843
|
+
importAccount(ctx){
|
|
3844
|
+
const P=PLATFORMS.google, c=ctx||{}, mode=c.mode||'help';
|
|
3845
|
+
const name=c.name||'', accId=c.accountId||'', idArg=accId||'<10桁のCustomer ID>';
|
|
3846
|
+
const headName = mode==='help' ? '<アカウント名(日本語OK)>' : (name||'<アカウント名>');
|
|
3847
|
+
const fileName = mode==='help' ? '<アカウント名>' : (name||'<アカウント名>');
|
|
3848
|
+
const dir=P.account.dir;
|
|
3849
|
+
const shape=[' - media は 管理番号 → {image(横長画像アセット), square(正方形画像アセット), video(YouTube動画ID)}',
|
|
3850
|
+
' - 動画は Google Ads API へアップロードできないため、YouTube に上げた動画のIDを入れる',
|
|
3851
|
+
' - campaigns/adGroups は campaignId/adGroupId をキーに(adGroups.paramCode はURLパラメータ)'];
|
|
3852
|
+
const lines=[];
|
|
3853
|
+
if(mode==='ai'){
|
|
3854
|
+
lines.push('Google入稿プランナー用に、新規アカウントの参照データJSONを生成してください。','',
|
|
3855
|
+
'1. まず対象アカウントを対話で決めてください:',
|
|
3856
|
+
' - webgram list --platform google --resource accounts で候補一覧を取得し、どのアカウントを追加するか私に確認して アカウント名・accountId(10桁) を確定する',
|
|
3857
|
+
'2. 確定したアカウントの既存キャンペーン/広告グループを取得:',
|
|
3858
|
+
' webgram list --platform google --resource campaigns --account-id '+idArg,
|
|
3859
|
+
'3. 取得結果を下記スキーマに整形し、'+dir+'/<アカウント名>.json として書き出す:');
|
|
3860
|
+
shape.forEach(l=>lines.push(l));
|
|
3861
|
+
} else {
|
|
3862
|
+
lines.push('Google入稿プランナー用に、アカウント「'+headName+'」'+(mode==='import'&&accId?('(accountId: '+accId+')'):'')+'の参照データJSONを生成してください。','',
|
|
3863
|
+
'手順:',
|
|
3864
|
+
'1. webgram CLI で対象アカウントの既存キャンペーン/広告グループを取得:',
|
|
3865
|
+
' webgram list --platform google --resource campaigns --account-id '+idArg,
|
|
3866
|
+
'2. 取得結果を下記スキーマに整形して '+dir+'/'+fileName+'.json として書き出す:');
|
|
3867
|
+
shape.forEach(l=>lines.push(l));
|
|
3868
|
+
}
|
|
3869
|
+
lines.push('', 'スキーマ:');
|
|
3870
|
+
P.account.schemaLines({updatedAt:mode!=='help',
|
|
3871
|
+
nameHint:mode==='help'?'<アカウント名(日本語OK)>':'<アカウント名>'}).forEach(l=>lines.push(l));
|
|
3872
|
+
lines.push('');
|
|
3873
|
+
lines.push(mode==='ai' ? '書き出したら、プランナー右上のアカウントプルダウン →「+新規追加」→「📄 ファイルから追加」で その JSON を読み込めます。'
|
|
3874
|
+
: mode==='import' ? '書き出したら、プランナーで「📁 フォルダ」を再接続 or アカウントセレクトを開くと自動で一覧に出ます。'
|
|
3875
|
+
: '書き出したら、プランナーで「+新規追加 →📄 ファイルから追加」or「📁 フォルダ」再接続で一覧に出ます。');
|
|
3876
|
+
return lines.join('\n');
|
|
3877
|
+
},
|
|
3878
|
+
strategy(){
|
|
3879
|
+
return ['Google広告(Demand Gen / YouTube)の入稿プランを一緒に作りたいです。まず戦略を壁打ちさせてください。','',
|
|
3880
|
+
'# 前提','- アカウント: <アカウント名(日本語OK)>','- 商材 / LP: <商材名・URL>',
|
|
3881
|
+
'- 予算・目標CPA: <キャンペーンの日予算 / 目標CPA>',
|
|
3882
|
+
'- 動画: <YouTube にアップロード済みの動画URL または動画ID>',
|
|
3883
|
+
'- 訴求したいポイント: <強み・オファー・ターゲット層>','',
|
|
3884
|
+
'# お願い',
|
|
3885
|
+
'1. 上記をもとに配信面(YouTube / Discover / Gmail)・キャンペーン/広告グループ構成・訴求軸を提案してください。',
|
|
3886
|
+
'2. 私が「OK」と言ったら、その戦略を ad-ops Skill の「サンプルプラン_google.json」に書き出して(上書きして)ください。',
|
|
3887
|
+
' - 保存先: .claude/skills/ad-ops/サンプルプラン_google.json',
|
|
3888
|
+
' - フォーマット: {"schemaVersion":'+SCHEMA_VERSION+', "platform":"google", "accountId":"<10桁のCustomer ID>", "campaigns":[ ... ]}',
|
|
3889
|
+
' - campaigns には キャンペーン(dailyBudget / targetCpa) → 広告グループ(paramCode / 地域 / 言語 / 配信面) → 広告(形式 IMAGE|VIDEO / クリエイティブ番号 / 見出し番号 / 長見出し番号 / 説明文番号) の構成を入れてください。',
|
|
3890
|
+
' - 動画広告(VIDEO)は長見出しが必須です。',
|
|
3891
|
+
'3. 書き出したら、プランナーを開き直す(またはリロードして媒体を Google に切り替える)と内容が自動で表示されます。'].join('\n');
|
|
3892
|
+
},
|
|
3893
|
+
submitHelp(ctx){
|
|
3894
|
+
const P=PLATFORMS.google, c=ctx||{}, rel=c.savedRel, dir=P.output.planDir;
|
|
3895
|
+
const lines=['入稿を試みてください。手順:',
|
|
3896
|
+
'1) 入稿計画JSON('+c.fileName+')を ad-ops Skill の入稿計画フォルダに置く(保存先: '+rel+')。',
|
|
3897
|
+
' ・入稿計画フォルダ('+dir+'/)に無ければ、ダウンロードフォルダ(~/Downloads/'+c.fileName+')を探して上記パスへ移動する。',
|
|
3898
|
+
(c.wrote? ' ・プランナーからは既に入稿計画フォルダへ保存済みのはず。' : ' ・フォルダ未接続のためダウンロード済み。~/Downloads から移動してください。'),
|
|
3899
|
+
'2) 置いたパスで dry-run 入稿を実行:',
|
|
3900
|
+
' '+P.output.submitCommand(rel,{dryRun:true}),
|
|
3901
|
+
'',
|
|
3902
|
+
'※ Google は実 API での入稿検証が未了です。初回は PAUSED・1キャンペーン・最小予算で流し、管理画面で目視確認してください。'];
|
|
3903
|
+
return lines.join('\n');
|
|
3904
|
+
},
|
|
3905
|
+
afterSave(ctx){
|
|
3906
|
+
const P=PLATFORMS.google, c=ctx||{};
|
|
3907
|
+
return 'プラン「'+c.name+'」v'+c.versionId+' を保存しました。\n入稿するには「入稿JSON書き出し」を押し、生成された *_Google入稿計画_*.json を指定して dry-run してください:\n\n'
|
|
3908
|
+
+ P.output.submitCommand('<入稿計画JSONのパス>',{dryRun:true});
|
|
3909
|
+
}
|
|
3910
|
+
},
|
|
3911
|
+
docTabs:[{id:'overview',label:'概要'},{id:'video',label:'動画広告'},
|
|
3912
|
+
{id:'image',label:'画像広告'},{id:'param',label:'パラメータ'},{id:'guide',label:'運用手順'}]
|
|
3913
|
+
};
|
|
3914
|
+
|
|
3915
|
+
/* ============================================================
|
|
3916
|
+
ByteDance (TikTok) — 記事LP誘導の最小構成
|
|
3917
|
+
objective_type=TRAFFIC / promotion_type=WEBSITE / optimization_goal=CLICK
|
|
3918
|
+
billing_event=CPC / ad_format=SINGLE_VIDEO / creative_material_mode=CUSTOM
|
|
3919
|
+
------------------------------------------------------------
|
|
3920
|
+
CV最適化 (WEB_CONVERSIONS + pixel) ・画像広告 (SINGLE_IMAGE) ・カルーセル・
|
|
3921
|
+
Spark Ads は対象外。動画とカバー画像は**アップロード済みのID**を refs に持つ
|
|
3922
|
+
(プランナーからのローカルアップロードは未対応。CLI 側の videoPath /
|
|
3923
|
+
coverImagePath は手書きの計画ファイルからは使える)。
|
|
3924
|
+
============================================================ */
|
|
3925
|
+
const BYTEDANCE_REF_TABLES = {
|
|
3926
|
+
/* TikTok の広告文は 1 本 (ad_text)。見出し/説明文の 2 段構えを持たない */
|
|
3927
|
+
titles:{ key:'titles', label:'広告文', countLabel:'広告文', editable:true, copyable:true, required:true,
|
|
3928
|
+
keyKind:'number', keyLabel:'番号', keyWidth:'70px', dropEmpty:true,
|
|
3929
|
+
addSub:'番号→広告文(100字まで)',
|
|
3930
|
+
emptyMsg:'該当する広告文がありません',
|
|
3931
|
+
lengthLimit:{warn:80, err:100},
|
|
3932
|
+
valueFields:[{k:'text', label:'文言'}],
|
|
3933
|
+
keyCell:no=>'<td class="mono">'+esc(no)+'</td>',
|
|
3934
|
+
columns:[
|
|
3935
|
+
{label:'広告文', cls:'reftext', attr:(no,v)=>' title="'+esc(v)+'"', cell:(no,v)=>esc(v)},
|
|
3936
|
+
{label:'文字数', width:'70px', cls:(no,v)=>'reflen '+refLenCls(BYTEDANCE_REF_TABLES.titles,v), cell:(no,v)=>glen(v)}
|
|
3937
|
+
],
|
|
3938
|
+
optionLabel:v=>String(v==null?'':v) },
|
|
3939
|
+
/* video = アセットライブラリの video_id、cover = カバー画像の image_id。
|
|
3940
|
+
SINGLE_VIDEO は**カバー画像が必須**なので、動画だけでは広告を作れない。 */
|
|
3941
|
+
media:{ key:'media', label:'クリエイティブ', countLabel:'クリエイティブ', editable:true, required:true,
|
|
3942
|
+
keyKind:'number', keyLabel:'番号', keyWidth:'80px',
|
|
3943
|
+
addSub:'番号→動画ID / カバー画像ID',
|
|
3944
|
+
emptyMsg:'該当するクリエイティブがありません',
|
|
3945
|
+
valueFields:[{k:'video', label:'動画ID (video_id)', editLabel:'動画ID'},
|
|
3946
|
+
{k:'cover', label:'カバー画像ID (image_id)', editLabel:'カバー画像ID'}],
|
|
3947
|
+
keyCell:no=>'<td><span class="sw" style="display:inline-flex;width:26px;height:26px;vertical-align:middle">'+esc(no)+'</span> '+newBadge('media',no)+'</td>',
|
|
3948
|
+
columns:[
|
|
3949
|
+
{label:'動画ID', cls:'mono', cell:(no,v)=>esc(v&&v.video||'—')},
|
|
3950
|
+
{label:'カバー画像ID', cls:'mono', cell:(no,v)=>esc(v&&v.cover||'—')}
|
|
3951
|
+
],
|
|
3952
|
+
optionLabel:v=>((v&&v.video)?'VID':'')+((v&&v.video&&v.cover)?'/':'')+((v&&v.cover)?'COV':''),
|
|
3953
|
+
drawerLabel:v=>'VID '+esc(v&&v.video||'—')+' / COV '+esc(v&&v.cover||'—') },
|
|
3954
|
+
/**
|
|
3955
|
+
* 地域名 → location_id。**TikTok の地域IDは独自採番で公開一覧が無い**ので、
|
|
3956
|
+
* `webgram list --platform bytedance --resource regions` で引いてここに入れる。
|
|
3957
|
+
* 決め打ちの値(`1210023`)は本番で invalid だった。
|
|
3958
|
+
*/
|
|
3959
|
+
regions:{ key:'regions', label:'地域', countLabel:'地域', editable:true, required:false,
|
|
3960
|
+
keyKind:'string', keyLabel:'地域名', keyWidth:'140px',
|
|
3961
|
+
addSub:'地域名→location_id(webgram list --resource regions で取得)',
|
|
3962
|
+
emptyMsg:'地域がありません(webgram list --platform bytedance --resource regions で取り込む)',
|
|
3963
|
+
valueFields:[{k:'id', label:'location_id', editLabel:'location_id'}],
|
|
3964
|
+
keyCell:no=>'<td>'+esc(no)+'</td>',
|
|
3965
|
+
columns:[
|
|
3966
|
+
{label:'location_id', cls:'mono', cell:(no,v)=>esc(typeof v==='string'?v:(v&&v.id)||'—')}
|
|
3967
|
+
],
|
|
3968
|
+
optionLabel:v=>String(typeof v==='string'?v:(v&&v.id)||'') },
|
|
3969
|
+
adGroups:{ key:'adGroups', label:'広告グループ', countLabel:'広告グループ', editable:false, required:false,
|
|
3970
|
+
keyKind:'string', keyLabel:'広告グループID', keyWidth:'130px',
|
|
3971
|
+
emptyMsg:'既存の広告グループがありません(アカウントJSONの refs.adGroups)',
|
|
3972
|
+
valueFields:[{k:'name',label:'名'},{k:'paramCode',label:'paramCode'},{k:'campaignId',label:'キャンペーンID'}],
|
|
3973
|
+
keyCell:no=>'<td class="mono">'+esc(no)+'</td>',
|
|
3974
|
+
columns:[
|
|
3975
|
+
{label:'広告グループ名', cls:'reftext', attr:(no,v)=>' title="'+esc(v&&v.name||'')+'"', cell:(no,v)=>esc(v&&v.name||'')},
|
|
3976
|
+
{label:'paramCode', width:'130px', cls:'mono', cell:(no,v)=>esc(v&&v.paramCode||'')},
|
|
3977
|
+
{label:'キャンペーンID', width:'130px', cls:'mono', cell:(no,v)=>esc(v&&v.campaignId||'')}
|
|
3978
|
+
] },
|
|
3979
|
+
campaigns:{ key:'campaigns', label:'キャンペーン', countLabel:'キャンペーン', editable:false, required:false,
|
|
3980
|
+
keyKind:'string', keyLabel:'キャンペーンID', keyWidth:'130px',
|
|
3981
|
+
emptyMsg:'既存のキャンペーンがありません(アカウントJSONの refs.campaigns)',
|
|
3982
|
+
valueFields:[{k:'name',label:'名'}],
|
|
3983
|
+
keyCell:no=>'<td class="mono">'+esc(no)+'</td>',
|
|
3984
|
+
columns:[
|
|
3985
|
+
{label:'キャンペーン名', cls:'reftext', attr:(no,v)=>' title="'+esc(v&&v.name||'')+'"', cell:(no,v)=>esc(v&&v.name||'')}
|
|
3986
|
+
] }
|
|
3987
|
+
};
|
|
3988
|
+
|
|
3989
|
+
const BYTEDANCE_COLS = {
|
|
3990
|
+
ads:[
|
|
3991
|
+
{field:'imageNo', label:'クリエイティブ番号', kind:'refcombo', refTable:'media', drawerType:'media',
|
|
3992
|
+
width:'6ch', sequenceable:true, errCodes:[EC.IMAGE_UNRESOLVED, EC.COVER_MISSING],
|
|
3993
|
+
ghostKey:'media', ddTitle:'登録クリエイティブから選択',
|
|
3994
|
+
ghostTitle:r=>'動画 '+r.mediaId+(r.coverId?(' / カバー '+r.coverId):''),
|
|
3995
|
+
ghost:(r,a)=>{ if(!a.imageNo) return '<span class="idpill" style="color:var(--faint)">—</span>';
|
|
3996
|
+
if(r.errCodes.includes(EC.COVER_MISSING)) return '<span class="idpill err" title="SINGLE_VIDEO はカバー画像が必須">カバー無し</span>';
|
|
3997
|
+
if(r.mediaId!=='') return '<span class="idpill">VID '+esc(r.mediaId)+'</span>';
|
|
3998
|
+
if(r.pendingImage) return '<span class="idpill warn" title="アカウント未登録。入稿前にアップロードして新規登録">🆕未登録</span>';
|
|
3999
|
+
return '<span class="idpill err">#N/A</span>'; }},
|
|
4000
|
+
{field:'titleNo', label:'広告文番号', kind:'refcombo', refTable:'titles', drawerType:'titles',
|
|
4001
|
+
width:'6ch', sequenceable:true, errCodes:[EC.TITLE_UNRESOLVED, EC.TITLE_TOO_LONG],
|
|
4002
|
+
ghostKey:'title', ddTitle:'広告文一覧から選択', ghostTitle:r=>r.titleText,
|
|
4003
|
+
ghost:r=>r.errCodes.includes(EC.TITLE_UNRESOLVED)?'<span class="err">未解決</span>':esc(r.titleText.slice(0,40))},
|
|
4004
|
+
{field:'paramCode', label:'paramCode(広告グループ)', kind:'mono', width:'120px',
|
|
4005
|
+
scope:'parentGroup', sequenceable:true, readonlyOnExisting:true, errCodes:[EC.PARAM_CODE_EMPTY],
|
|
4006
|
+
title:'広告グループ共通(変更で同グループ全広告に反映)。最終URLは 記事URL?ad={paramCode}',
|
|
4007
|
+
hidden:p=>urlParamsOff(p),
|
|
4008
|
+
roStyle:'input', roTitle:'既存広告グループのため編集不可'},
|
|
4009
|
+
{field:'finalUrl', label:'最終リンク先URL', kind:'mono', width:'240px',
|
|
4010
|
+
alwaysReadonly:true, roStyle:'input',
|
|
4011
|
+
roTitle:'自動生成(編集不可)。変えるときは 案件設定の記事URL / paramCode を直します',
|
|
4012
|
+
get:(row,ctx)=>ctx.r?ctx.r.finalUrl:''},
|
|
4013
|
+
/* 未登録クリエイティブ用のローカルパス。**本実行の直前に CLI がアップロードして
|
|
4014
|
+
videoId / imageIds に解決する** (dry-run では上げない)。Meta / YDA と同じ運用。 */
|
|
4015
|
+
{field:'videoPath', label:'動画パス', kind:'mono', width:'150px',
|
|
4016
|
+
title:'クリエイティブ番号が参照データに無い(未登録)ときだけ使う。入稿前に CLI が自動アップロードして動画IDに解決する',
|
|
4017
|
+
errCodes:[EC.IMAGE_PATH_MISSING], placeholder:()=>'例: media/ad.mp4'},
|
|
4018
|
+
{field:'coverImagePath', label:'カバー画像パス', kind:'mono', width:'150px',
|
|
4019
|
+
title:'カバー画像が未登録のときだけ使う。SINGLE_VIDEO はカバー画像が必須なので、動画だけでは入稿できない',
|
|
4020
|
+
errCodes:[EC.COVER_MISSING], placeholder:()=>'例: media/cover.jpg'},
|
|
4021
|
+
{field:'adName', label:'広告名', kind:'mono', width:'170px',
|
|
4022
|
+
title:'空欄なら自動生成({番号}v-{広告文番号}-{paramCode})',
|
|
4023
|
+
placeholder:ctx=>ctx.r?ctx.r.autoAdName:''},
|
|
4024
|
+
{field:'adGroupId', label:'CP / AG ID', kind:'derived',
|
|
4025
|
+
derive:(a,ctx)=>'<span class="idpill '+(ctx.r.resolvedGroup?'':'warn')+'" data-d="'+a.uid+'-ids">'+esc(ctx.r.adGroupId?('AG '+ctx.r.adGroupId):'新規')+'</span>'}
|
|
4026
|
+
],
|
|
4027
|
+
adGroups:[
|
|
4028
|
+
{field:'adGroupId', label:'広告グループID', kind:'badge', alwaysReadonly:true},
|
|
4029
|
+
{field:'name', label:'広告グループ名', kind:'text', width:'150px', required:true,
|
|
4030
|
+
readonlyOnExisting:true, roTitle:'既存広告グループのため編集不可'},
|
|
4031
|
+
{field:'paramCode', label:'paramCode', kind:'mono', width:'120px', required:true,
|
|
4032
|
+
sequenceable:true, readonlyOnExisting:true, hidden:p=>urlParamsOff(p),
|
|
4033
|
+
title:'URL生成用のパラメータ(広告グループ紐づけには不使用)'},
|
|
4034
|
+
/* budgetLevel='adGroup' → 予算・入札・最適化は広告グループが持つ (TikTok の必須項目) */
|
|
4035
|
+
{field:'dailyBudget', label:'日予算(円)', kind:'number', width:'84px', required:true,
|
|
4036
|
+
coerce:numOrNull, readonlyOnExisting:true, roEmptyDash:true, errCodes:[EC.BUDGET_MISSING],
|
|
4037
|
+
placeholder:()=>String(pf().defaults.newAdGroupBudget)},
|
|
4038
|
+
{field:'tg_location', label:'地域', kind:'tgpick', tgKey:'tg_location', readonlyOnExisting:true, roEmptyDash:true,
|
|
4039
|
+
title:'TikTok は地域を 1 つ以上指定しないと入稿できません'},
|
|
4040
|
+
{field:'tg_placement', label:'配信面', kind:'tgpick', tgKey:'tg_placement', readonlyOnExisting:true, roEmptyDash:true,
|
|
4041
|
+
title:'自動配置なら TikTok が配信先を決めます。TikTok のみに絞ることもできます'},
|
|
4042
|
+
{field:'deliveryStatus', label:'配信', kind:'select', options:['','オフ','オン'], minWidth:'66px',
|
|
4043
|
+
readonlyOnExisting:true, roEmptyDash:true, coerce:v=>{const s=String(v).trim(); return (s==='オフ'||s==='オン')?s:'';}}
|
|
4044
|
+
],
|
|
4045
|
+
campaigns:[
|
|
4046
|
+
{field:'campaignId', label:'キャンペーンID', kind:'badge', alwaysReadonly:true},
|
|
4047
|
+
{field:'name', label:'キャンペーン名', kind:'text', width:'170px', required:true,
|
|
4048
|
+
readonlyOnExisting:true, roTitle:'既存キャンペーンのため編集不可'},
|
|
4049
|
+
{field:'urlNumber', label:'リンク番号', kind:'mono', width:'54px', hideOnExisting:true}
|
|
4050
|
+
]
|
|
4051
|
+
};
|
|
4052
|
+
|
|
4053
|
+
PLATFORMS.bytedance = {
|
|
4054
|
+
id:'bytedance', displayName:'TikTok', brandTitle:'TikTok入稿プランナー',
|
|
4055
|
+
|
|
4056
|
+
account:{
|
|
4057
|
+
idPattern:/^\d+$/, idHint:'TikTok 広告アカウントID(advertiser_id・数値)', dir:'アカウント/bytedance',
|
|
4058
|
+
apply(obj, project){
|
|
4059
|
+
if(obj.accountId!=null) project.accountId = String(obj.accountId);
|
|
4060
|
+
if(obj.name!=null) project.accountName=String(obj.name);
|
|
4061
|
+
if(obj.principal!=null) project.principal=String(obj.principal);
|
|
4062
|
+
if(obj.identityId!=null) project.identityId=String(obj.identityId);
|
|
4063
|
+
if(obj.identityType!=null) project.identityType=String(obj.identityType);
|
|
4064
|
+
/* BC_AUTH_TT では identity_authorized_bc_id が必須。**本番実測で identity は
|
|
4065
|
+
ほぼ BC_AUTH_TT**(4アカウントで各14件。CUSTOMIZED_USER は 0〜2件)なので、
|
|
4066
|
+
これが無いと実運用で使える identity を選べない */
|
|
4067
|
+
if(obj.identityAuthorizedBcId!=null) project.identityAuthorizedBcId=String(obj.identityAuthorizedBcId);
|
|
4068
|
+
if(obj.projectName) project.name=obj.projectName;
|
|
4069
|
+
if(obj.baseArticleUrl) project.baseArticleUrl=obj.baseArticleUrl;
|
|
4070
|
+
if(obj.urlParamsEnabled!=null) project.urlParams=!!obj.urlParamsEnabled; else delete project.urlParams;
|
|
4071
|
+
},
|
|
4072
|
+
schemaLines(opts){
|
|
4073
|
+
const o=opts||{};
|
|
4074
|
+
return ['{',' "accountJsonVersion": 1,',' "platform": "bytedance",',' "accountId": "<advertiser_id(数値)>",',
|
|
4075
|
+
' "name": "'+(o.nameHint||'<アカウント名>')+'",',
|
|
4076
|
+
' "identityId": "<identity_id(webgram list --resource identities)>",',
|
|
4077
|
+
' "identityType": "BC_AUTH_TT",',
|
|
4078
|
+
' "identityAuthorizedBcId": "<BC_AUTH_TT のとき必須。identities の authorizedBcId>",']
|
|
4079
|
+
.concat(o.updatedAt===false?[]:[' "updatedAt": "<ISO8601>",'])
|
|
4080
|
+
.concat([
|
|
4081
|
+
' "refs": {',
|
|
4082
|
+
' "titles": {"1": "広告文(100字まで)"},',
|
|
4083
|
+
' "media": {"1": {"video": "<video_id>", "cover": "<カバー画像の image_id>"}},',
|
|
4084
|
+
' "regions": {"日本": "<location_id(webgram list --resource regions)>"},',
|
|
4085
|
+
' "campaigns": {"<campaignId>": {"name": "CP名"}},',
|
|
4086
|
+
' "adGroups": {"<adGroupId>": {"name": "広告グループ名", "campaignId": "<campaignId>", "paramCode": "<code>"}}',
|
|
4087
|
+
' }','}']);
|
|
4088
|
+
}
|
|
4089
|
+
},
|
|
4090
|
+
|
|
4091
|
+
levels:{
|
|
4092
|
+
campaign:{label:'キャンペーン', childKey:'adGroups', planKey:'adGroups'},
|
|
4093
|
+
adGroup :{label:'広告グループ', childKey:'ads', planKey:'ads'},
|
|
4094
|
+
ad :{label:'広告'}
|
|
4095
|
+
},
|
|
4096
|
+
budgetLevel:'adGroup',
|
|
4097
|
+
consoleUrl:'https://ads.tiktok.com/i18n/perf/creative?aadvid={accountId}',
|
|
4098
|
+
consoleResourceMap:{campaigns:'campaign', adGroups:'adgroup', ads:'ad'},
|
|
4099
|
+
|
|
4100
|
+
/* SINGLE_VIDEO のみ。画像広告 (SINGLE_IMAGE) とカルーセルは対象外 */
|
|
4101
|
+
mediaFormats:[
|
|
4102
|
+
{id:'VIDEO', crSuffix:'v', mediaKey:'video', apiMediaKind:'VIDEO', label:'動画'}
|
|
4103
|
+
],
|
|
4104
|
+
fmtOf(){ return PLATFORMS.bytedance.mediaFormats[0]; },
|
|
4105
|
+
|
|
4106
|
+
refTables:BYTEDANCE_REF_TABLES,
|
|
4107
|
+
sheets:BYTEDANCE_COLS,
|
|
4108
|
+
|
|
4109
|
+
projectFields:[
|
|
4110
|
+
{key:'identityId', label:'identity ID', kind:'text', width:'18ch', fallback:'',
|
|
4111
|
+
title:'広告主として表示される TikTok アカウントの identity_id。webgram list --platform bytedance --resource identities で取得。**未入力では広告が作れません**'},
|
|
4112
|
+
{key:'identityType', label:'identity種別', kind:'select',
|
|
4113
|
+
options:['BC_AUTH_TT','CUSTOMIZED_USER','TT_USER','AUTH_CODE'],
|
|
4114
|
+
title:'本番実測ではほぼ BC_AUTH_TT(Business Center 経由で認可された TikTok アカウント)。BC_AUTH_TT では下の BC ID が必須'},
|
|
4115
|
+
{key:'identityAuthorizedBcId', label:'BC ID', kind:'text', width:'18ch', fallback:'',
|
|
4116
|
+
title:'identity種別が BC_AUTH_TT のとき必須。identities の authorizedBcId をそのまま入れる'},
|
|
4117
|
+
{key:'cta', label:'CTA', kind:'select',
|
|
4118
|
+
options:['詳しくはこちら','今すぐ購入','登録する','ダウンロード','お問い合わせ','今すぐ申し込む',
|
|
4119
|
+
'予約する','注文する','今すぐ見る','続きを読む','購読する'],
|
|
4120
|
+
title:'広告のボタン文言'},
|
|
4121
|
+
{key:'deliveryStatus', label:'配信', kind:'select', options:['オフ','オン'],
|
|
4122
|
+
title:'TikTok は実 API 検証が未了のため、オンでは入稿できません(backend が拒否します)'},
|
|
4123
|
+
{key:'baseArticleUrl', label:'記事URL', kind:'text', width:'16ch', fallback:''},
|
|
4124
|
+
{key:'urlParams', label:'URLパラメータ', kind:'toggle', fallback:true,
|
|
4125
|
+
title:'OFF にすると最終リンク先URLに ?ad= を付けません(案件単位)'}
|
|
4126
|
+
],
|
|
4127
|
+
fixedType:[{label:'キャンペーン目的',value:'TRAFFIC(ウェブサイトへの誘導)'}],
|
|
4128
|
+
|
|
4129
|
+
targeting:{
|
|
4130
|
+
tg_location:{label:'地域', multi:true, allLabel:'', opts:['日本']},
|
|
4131
|
+
tg_placement:{label:'配信面', multi:false, allLabel:'自動配置', opts:['自動配置','TikTok のみ']}
|
|
4132
|
+
},
|
|
4133
|
+
|
|
4134
|
+
enums:{
|
|
4135
|
+
/**
|
|
4136
|
+
* **地域は enum を持たない**。location_id は TikTok 独自の採番で、公開ドキュメントに
|
|
4137
|
+
* 一覧が無い(`1210023` を決め打ちしていたところ、本番で
|
|
4138
|
+
* `code=40002 At least 1 location ID is invalid` になった)。
|
|
4139
|
+
* アカウントJSONの `refs.regions`(`{地域名: location_id}`)を正とし、
|
|
4140
|
+
* `webgram list --platform bytedance --resource regions` で取り込む。
|
|
4141
|
+
*/
|
|
4142
|
+
/* CTA は TikTok の call_to_action enum */
|
|
4143
|
+
cta:{'詳しくはこちら':'LEARN_MORE','今すぐ購入':'SHOP_NOW','登録する':'SIGN_UP',
|
|
4144
|
+
'ダウンロード':'DOWNLOAD','お問い合わせ':'CONTACT_US','今すぐ申し込む':'APPLY_NOW',
|
|
4145
|
+
'予約する':'BOOK_NOW','注文する':'ORDER_NOW','今すぐ見る':'WATCH_NOW',
|
|
4146
|
+
'続きを読む':'READ_MORE','購読する':'SUBSCRIBE'},
|
|
4147
|
+
/* TikTok の配信オンは ENABLE (yda/meta は ACTIVE / google は ENABLED) */
|
|
4148
|
+
statusOn:'ENABLE', statusOff:'DISABLE',
|
|
4149
|
+
objectiveType:'TRAFFIC',
|
|
4150
|
+
promotionType:'WEBSITE',
|
|
4151
|
+
optimizationGoal:'CLICK',
|
|
4152
|
+
billingEvent:'CPC',
|
|
4153
|
+
creativeMaterialMode:'CUSTOM',
|
|
4154
|
+
adFormat:'SINGLE_VIDEO',
|
|
4155
|
+
identityType:'BC_AUTH_TT'
|
|
4156
|
+
},
|
|
4157
|
+
|
|
4158
|
+
mappers:{
|
|
4159
|
+
status(s, P){ return s==='オン'?P.enums.statusOn:P.enums.statusOff; },
|
|
4160
|
+
/**
|
|
4161
|
+
* 地域名 → location_id。**参照データ (`refs.regions`) から引く**。
|
|
4162
|
+
* refs が渡らない呼び出し(`validateRow`)では解決できないので空を返す。
|
|
4163
|
+
* その場合の地域チェックは `resolve` 側(REGION_UNRESOLVED)で行う。
|
|
4164
|
+
*/
|
|
4165
|
+
locationIds(s, P, refs){
|
|
4166
|
+
const table=(refs && refs.regions) || {};
|
|
4167
|
+
return tgList(s)
|
|
4168
|
+
.filter(n=>n!==P.targeting.tg_location.allLabel)
|
|
4169
|
+
/* 参照データは {"日本": "123"} でも {"日本": {"id": "123"}} でも受ける */
|
|
4170
|
+
.map(n=>{ const v=table[n]; return (v && typeof v==='object') ? v.id : v; })
|
|
4171
|
+
.filter(v=>v!=null && String(v).trim()!=='')
|
|
4172
|
+
.map(String);
|
|
4173
|
+
},
|
|
4174
|
+
/* 自動配置なら placements キーを出さない (PLACEMENT_TYPE_NORMAL のときだけ必須) */
|
|
4175
|
+
placement(s, P){
|
|
4176
|
+
const l=tgList(s);
|
|
4177
|
+
if(!l.length || l.indexOf(P.targeting.tg_placement.allLabel)>=0) {
|
|
4178
|
+
return {placementType:'PLACEMENT_TYPE_AUTOMATIC'};
|
|
4179
|
+
}
|
|
4180
|
+
return {placementType:'PLACEMENT_TYPE_NORMAL', placements:['PLACEMENT_TIKTOK']};
|
|
4181
|
+
},
|
|
4182
|
+
cta(label, P){ const v=P.enums.cta[String(label||'').trim()]; return v==null?'':v; }
|
|
4183
|
+
},
|
|
4184
|
+
|
|
4185
|
+
defaults:{
|
|
4186
|
+
newAdGroupBudget:5000,
|
|
4187
|
+
project:{
|
|
4188
|
+
identityId:'', identityType:'BC_AUTH_TT', identityAuthorizedBcId:'', cta:'詳しくはこちら',
|
|
4189
|
+
deliveryStatus:'オフ'
|
|
4190
|
+
},
|
|
4191
|
+
ad:{imageNo:'', titleNo:'', videoPath:'', coverImagePath:'', adName:''},
|
|
4192
|
+
adGroup:{name:'', paramCode:'', isExisting:false, dailyBudget:5000,
|
|
4193
|
+
tg_location:'日本', tg_placement:'自動配置', deliveryStatus:''},
|
|
4194
|
+
campaign:{name:'新規キャンペーン', campaignId:'', isExisting:false, urlNumber:''}
|
|
4195
|
+
},
|
|
4196
|
+
factories:{
|
|
4197
|
+
newAd(seed){ return Object.assign({uid:uid('a')}, PLATFORMS.bytedance.defaults.ad, seed||{}); },
|
|
4198
|
+
newAdGroup(c, i){ const P=PLATFORMS.bytedance;
|
|
4199
|
+
return Object.assign({uid:uid('g')}, P.defaults.adGroup,
|
|
4200
|
+
{name:'AG'+((i!=null?i:(c?c.adGroups.length:0))+1), ads:[P.factories.newAd()]}); },
|
|
4201
|
+
newCampaign(seed){ const P=PLATFORMS.bytedance;
|
|
4202
|
+
return Object.assign({uid:uid('c')}, P.defaults.campaign,
|
|
4203
|
+
{adGroups:[P.factories.newAdGroup(null,0)]}, seed||{}); },
|
|
4204
|
+
existingCampaign(cpId, name){ const P=PLATFORMS.bytedance;
|
|
4205
|
+
return Object.assign({uid:uid('c')}, P.defaults.campaign,
|
|
4206
|
+
{name:name!=null?name:'', campaignId:String(cpId), isExisting:true, urlNumber:'', adGroups:[]}); },
|
|
4207
|
+
existingAdGroup(agId, meta){ const P=PLATFORMS.bytedance, m=meta||{};
|
|
4208
|
+
return Object.assign({uid:uid('g')}, P.defaults.adGroup,
|
|
4209
|
+
{name:m.name||('AG '+agId), paramCode:m.paramCode||'', adGroupId:String(agId),
|
|
4210
|
+
isExisting:true, ads:[P.factories.newAd()]}); }
|
|
4211
|
+
},
|
|
4212
|
+
|
|
4213
|
+
/* 命名規則は YDA / Meta / Google 踏襲。TikTok は説明文を持たないので L 区間が無い */
|
|
4214
|
+
naming:{
|
|
4215
|
+
crName(ad, P){ return String(ad.imageNo||'') + P.fmtOf(ad).crSuffix; },
|
|
4216
|
+
adName(ad, group, crName, project){
|
|
4217
|
+
const head = crName + '-' + (ad.titleNo??'');
|
|
4218
|
+
if(project && project.urlParams===false) return head;
|
|
4219
|
+
return head + '-' + (group.paramCode||'');
|
|
4220
|
+
},
|
|
4221
|
+
/* Meta / Google と同じ形(記事URL + ?ad={paramCode}) */
|
|
4222
|
+
finalUrl(ad, group, adName, project){
|
|
4223
|
+
const base = (project.baseArticleUrl||'');
|
|
4224
|
+
if(project.urlParams===false) return base;
|
|
4225
|
+
const sep = base.indexOf('?')>=0 ? '&' : '?';
|
|
4226
|
+
return base + sep + 'ad=' + urlq(group.paramCode||'');
|
|
4227
|
+
},
|
|
4228
|
+
campaignName(urlNumber, firstAd, P){
|
|
4229
|
+
const cr = firstAd ? (String(firstAd.imageNo||'CR') + P.fmtOf(firstAd).crSuffix) : 'CR';
|
|
4230
|
+
return String(urlNumber) + '_' + cr + '_' + today();
|
|
4231
|
+
}
|
|
4232
|
+
},
|
|
4233
|
+
creativeFields:['imageNo','titleNo','videoPath','coverImagePath'],
|
|
4234
|
+
notSubmittedFields:[],
|
|
4235
|
+
labels:{submitButton:'完成 → 保存して入稿', paramCodeLabel:'paramCode', tgDroppedWarn:''},
|
|
4236
|
+
|
|
4237
|
+
tree:{
|
|
4238
|
+
campaignBadges(c, P, p){ return '<span class="chip">TRAFFIC</span>'; },
|
|
4239
|
+
groupBadges(g){ return '<span class="pill mono">【'+esc(g.paramCode||'—')+'】</span>'
|
|
4240
|
+
+ (g.dailyBudget!=null&&g.dailyBudget!=='' ? '<span class="chip">¥'+esc(g.dailyBudget)+'/日</span>' : '')
|
|
4241
|
+
+ '<span class="chip">'+esc(g.tg_placement||'—')+'</span>'; },
|
|
4242
|
+
adBadge(a, r){ return '<span class="cbadge">'+esc(a.imageNo||'—')+'v</span>'; },
|
|
4243
|
+
adSub(a, r){ return esc(a.titleNo); }
|
|
4244
|
+
},
|
|
4245
|
+
|
|
4246
|
+
resolve(ad, group, ctx){
|
|
4247
|
+
const P=PLATFORMS.bytedance, p=ctx.project, refs=ctx.refs;
|
|
4248
|
+
const crName=P.naming.crName(ad, P);
|
|
4249
|
+
const autoAdName=P.naming.adName(ad, group, crName, p);
|
|
4250
|
+
const adName=String(ad.adName||'').trim() || autoAdName;
|
|
4251
|
+
const me = refs.media[String(ad.imageNo)];
|
|
4252
|
+
const mediaId = me ? me.video : undefined;
|
|
4253
|
+
const coverId = me ? me.cover : undefined;
|
|
4254
|
+
const titleText = refs.titles[ad.titleNo];
|
|
4255
|
+
const finalUrl = P.naming.finalUrl(ad, group, adName, p);
|
|
4256
|
+
const errs=[], warns=[], errCodes=[], warnCodes=[];
|
|
4257
|
+
const pushErr=(label,code)=>{ errs.push(label); errCodes.push(code); };
|
|
4258
|
+
const pushWarn=(label,code)=>{ warns.push(label); warnCodes.push(code); };
|
|
4259
|
+
const noStr=String(ad.imageNo||'').trim();
|
|
4260
|
+
const pendingImage = (noStr!=='' && (mediaId===undefined || mediaId===''));
|
|
4261
|
+
/* 未登録クリエイティブ用のローカルパス。本実行の直前に CLI がアップロードして解決する */
|
|
4262
|
+
const videoPath=String(ad.videoPath||'').trim();
|
|
4263
|
+
const coverPath=String(ad.coverImagePath||'').trim();
|
|
4264
|
+
const hasVideo = (mediaId!==undefined && mediaId!=='') || videoPath!=='';
|
|
4265
|
+
const hasCover = (coverId!==undefined && coverId!=='') || coverPath!=='';
|
|
4266
|
+
const T=P.refTables.titles.lengthLimit;
|
|
4267
|
+
if(noStr==='') pushErr('クリエイティブ未解決', EC.IMAGE_UNRESOLVED);
|
|
4268
|
+
/* 未登録なのにローカルパスも無ければ、アップロードするものが無い = 入稿できない。
|
|
4269
|
+
YDA は warn 止まりだが、TikTok は素材が無いと広告そのものが作れないので err にする */
|
|
4270
|
+
else if(!hasVideo) pushErr('動画パス未入力(未登録クリエイティブ)', EC.IMAGE_PATH_MISSING);
|
|
4271
|
+
/* SINGLE_VIDEO はカバー画像が必須。動画だけでは広告を作れない */
|
|
4272
|
+
if(noStr!=='' && hasVideo && !hasCover) pushErr('カバー画像未登録(パス未入力)', EC.COVER_MISSING);
|
|
4273
|
+
/* 解決済みなのにパスも入っていると、CLI が余計にアップロードして課金対象の素材が増える */
|
|
4274
|
+
if(mediaId!==undefined && mediaId!=='' && videoPath!=='')
|
|
4275
|
+
pushWarn('動画パス無視(クリエイティブ番号が解決済み)', EC.IMAGE_PATH_IGNORED);
|
|
4276
|
+
if(coverId!==undefined && coverId!=='' && coverPath!=='')
|
|
4277
|
+
pushWarn('カバー画像パス無視(登録済み)', EC.IMAGE_PATH_IGNORED);
|
|
4278
|
+
if(String(ad.titleNo||'').trim()!=='' && titleText===undefined) pushErr('広告文未解決', EC.TITLE_UNRESOLVED);
|
|
4279
|
+
if(String(ad.titleNo||'').trim()==='') pushErr('広告文未解決', EC.TITLE_UNRESOLVED);
|
|
4280
|
+
if(titleText!==undefined && glen(titleText)>T.err) pushErr('広告文'+T.err+'字超', EC.TITLE_TOO_LONG);
|
|
4281
|
+
else if(titleText!==undefined && glen(titleText)>T.warn) pushWarn('広告文'+T.warn+'字超', EC.TITLE_LONG_WARN);
|
|
4282
|
+
if(p.urlParams!==false && String(group.paramCode||'').trim()==='') pushErr('paramCode未入力', EC.PARAM_CODE_EMPTY);
|
|
4283
|
+
/* identity は広告作成の必須項目 (Meta の pageId 相当)。無いと必ず落ちる */
|
|
4284
|
+
if(String(p.identityId||'').trim()==='') pushErr('identity ID 未入力', EC.IDENTITY_MISSING);
|
|
4285
|
+
/* BC_AUTH_TT は identity_authorized_bc_id が必須。実運用の identity はほぼこれ */
|
|
4286
|
+
if(String(p.identityType||'')==='BC_AUTH_TT' && String(p.identityAuthorizedBcId||'').trim()==='')
|
|
4287
|
+
pushErr('BC ID 未入力(identity種別 BC_AUTH_TT)', EC.IDENTITY_BC_MISSING);
|
|
4288
|
+
/* TikTok は地域を 1 つ以上指定しないと入稿できない (未指定は全世界配信ではなく API エラー)。
|
|
4289
|
+
location_id は参照データから引くので、名前を選んでいても未取込なら解決できない */
|
|
4290
|
+
if(!tgList(group.tg_location).filter(n=>n!==P.targeting.tg_location.allLabel).length)
|
|
4291
|
+
pushErr('地域未指定', EC.LOCATION_EMPTY);
|
|
4292
|
+
else if(!P.mappers.locationIds(group.tg_location, P, refs).length)
|
|
4293
|
+
pushErr('地域IDが未解決(参照データの regions を取り込んでください)', EC.REGION_UNRESOLVED);
|
|
4294
|
+
if(pendingImage) pushWarn('動画ID未登録', EC.MEDIA_MISSING);
|
|
4295
|
+
const aid=String(group.adGroupId||'').trim();
|
|
4296
|
+
return {crName, adName, autoAdName, campaignId:'', adGroupId:aid, mediaKind:'VIDEO', imageNo:noStr,
|
|
4297
|
+
mediaId:mediaId||'', coverId:coverId||'', pendingImage, videoPath, coverPath,
|
|
4298
|
+
titleText:titleText===undefined?'':titleText,
|
|
4299
|
+
finalUrl, errs, warns, errCodes, warnCodes, resolvedGroup:!!aid};
|
|
4300
|
+
},
|
|
4301
|
+
|
|
4302
|
+
validateRow(row, level, ctx){
|
|
4303
|
+
const errCodes=[], warnCodes=[];
|
|
4304
|
+
const P=PLATFORMS.bytedance;
|
|
4305
|
+
/* budgetLevel='adGroup' なので予算は広告グループ行が持つ */
|
|
4306
|
+
if(level==='adGroup' && !isExistingGroup(row)){
|
|
4307
|
+
if(!(Number(row.dailyBudget)>0)) errCodes.push(EC.BUDGET_MISSING);
|
|
4308
|
+
/* ここは refs を受け取れないので「選んでいるか」だけを見る。
|
|
4309
|
+
location_id に解決できるかは resolve が REGION_UNRESOLVED で拾う */
|
|
4310
|
+
if(!tgList(row.tg_location).filter(n=>n!==P.targeting.tg_location.allLabel).length)
|
|
4311
|
+
errCodes.push(EC.LOCATION_EMPTY);
|
|
4312
|
+
}
|
|
4313
|
+
return {errCodes, warnCodes};
|
|
4314
|
+
},
|
|
4315
|
+
|
|
4316
|
+
validateProject(p, state){
|
|
4317
|
+
const errs=[];
|
|
4318
|
+
if(!String((p&&p.identityId)||'').trim())
|
|
4319
|
+
errs.push({code:EC.IDENTITY_MISSING,
|
|
4320
|
+
msg:'identity ID を共通設定に入力してください(webgram list --platform bytedance --resource identities で取得)'});
|
|
4321
|
+
if(String((p&&p.identityType)||'')==='BC_AUTH_TT' && !String((p&&p.identityAuthorizedBcId)||'').trim())
|
|
4322
|
+
errs.push({code:EC.IDENTITY_BC_MISSING,
|
|
4323
|
+
msg:'identity種別が BC_AUTH_TT なので BC ID が必須です(identities の authorizedBcId)'});
|
|
4324
|
+
return errs;
|
|
4325
|
+
},
|
|
4326
|
+
|
|
4327
|
+
buildPlan:{
|
|
4328
|
+
campaign(c, ctx){
|
|
4329
|
+
const P=PLATFORMS.bytedance, p=ctx.project;
|
|
4330
|
+
return {
|
|
4331
|
+
name:c.name,
|
|
4332
|
+
objectiveType:P.enums.objectiveType,
|
|
4333
|
+
/* 予算は広告グループ側で持つ。CP 側は無制限 */
|
|
4334
|
+
budgetMode:'BUDGET_MODE_INFINITE',
|
|
4335
|
+
operationStatus:P.mappers.status(p.deliveryStatus, P)
|
|
4336
|
+
};
|
|
4337
|
+
},
|
|
4338
|
+
adGroup(g, c, ctx){
|
|
4339
|
+
const P=PLATFORMS.bytedance, p=ctx.project;
|
|
4340
|
+
const first=(g.ads||[])[0];
|
|
4341
|
+
const ag={
|
|
4342
|
+
name:g.name,
|
|
4343
|
+
promotionType:P.enums.promotionType,
|
|
4344
|
+
optimizationGoal:P.enums.optimizationGoal,
|
|
4345
|
+
billingEvent:P.enums.billingEvent,
|
|
4346
|
+
creativeMaterialMode:P.enums.creativeMaterialMode,
|
|
4347
|
+
budgetMode:'BUDGET_MODE_DAY',
|
|
4348
|
+
budget:Number(g.dailyBudget)||0,
|
|
4349
|
+
landingPageUrl:P.naming.finalUrl(first||{}, g, '', p),
|
|
4350
|
+
operationStatus:P.mappers.status(g.deliveryStatus||p.deliveryStatus, P)
|
|
4351
|
+
};
|
|
4352
|
+
const loc=P.mappers.locationIds(g.tg_location, P, ctx.refs); if(loc.length) ag.locationIds=loc;
|
|
4353
|
+
Object.assign(ag, P.mappers.placement(g.tg_placement, P));
|
|
4354
|
+
return ag;
|
|
4355
|
+
},
|
|
4356
|
+
ad(a, r, g, ctx){
|
|
4357
|
+
const P=PLATFORMS.bytedance, p=ctx.project;
|
|
4358
|
+
const out={
|
|
4359
|
+
adName:r.adName,
|
|
4360
|
+
identityId:String(p.identityId||''),
|
|
4361
|
+
identityType:String(p.identityType||P.enums.identityType),
|
|
4362
|
+
/* BC_AUTH_TT では必須。**出さないと backend の zod が 400 を返す**
|
|
4363
|
+
(実運用の identity はほぼ BC_AUTH_TT なので、これが無いと入稿できない) */
|
|
4364
|
+
...(String(p.identityType||P.enums.identityType)==='BC_AUTH_TT' && String(p.identityAuthorizedBcId||'').trim()
|
|
4365
|
+
? {identityAuthorizedBcId:String(p.identityAuthorizedBcId).trim()} : {}),
|
|
4366
|
+
adFormat:P.enums.adFormat,
|
|
4367
|
+
adText:String(r.titleText||''),
|
|
4368
|
+
callToAction:P.mappers.cta(p.cta, P),
|
|
4369
|
+
landingPageUrl:r.finalUrl,
|
|
4370
|
+
operationStatus:P.mappers.status(g.deliveryStatus||p.deliveryStatus, P)
|
|
4371
|
+
};
|
|
4372
|
+
/* 解決済みなら ID、未登録ならローカルパスを出す。**両方を同時に載せない** —
|
|
4373
|
+
CLI は videoPath / coverImagePath があると必ずアップロードするので、
|
|
4374
|
+
ID が既にある行にパスを添えると素材が二重に増える (差し替えたいときは番号を空にする) */
|
|
4375
|
+
if(r.mediaId) out.videoId=String(r.mediaId);
|
|
4376
|
+
else if(r.videoPath) out.videoPath=r.videoPath;
|
|
4377
|
+
if(r.coverId) out.imageIds=[String(r.coverId)];
|
|
4378
|
+
else if(r.coverPath) out.coverImagePath=r.coverPath;
|
|
4379
|
+
return out;
|
|
4380
|
+
}
|
|
4381
|
+
},
|
|
4382
|
+
|
|
4383
|
+
output:{
|
|
4384
|
+
planVersion:1,
|
|
4385
|
+
planDir:'.claude/skills/ad-ops/入稿計画',
|
|
4386
|
+
samplePlanFile:'サンプルプラン_bytedance.json',
|
|
4387
|
+
fileNamePrefix:p=>(p.name||'plan')+'_TikTok入稿計画_',
|
|
4388
|
+
submitCommand:(rel,opts)=>'webgram submit-plan --plan-file "'+rel+'"'+((opts&&opts.dryRun===false)?' --no-dry-run --yes':'')
|
|
4389
|
+
},
|
|
4390
|
+
|
|
4391
|
+
prompts:{
|
|
4392
|
+
importAccount(ctx){
|
|
4393
|
+
const P=PLATFORMS.bytedance, c=ctx||{}, mode=c.mode||'help';
|
|
4394
|
+
const name=c.name||'', accId=c.accountId||'', idArg=accId||'<advertiser_id>';
|
|
4395
|
+
const headName = mode==='help' ? '<アカウント名(日本語OK)>' : (name||'<アカウント名>');
|
|
4396
|
+
const fileName = mode==='help' ? '<アカウント名>' : (name||'<アカウント名>');
|
|
4397
|
+
const dir=P.account.dir;
|
|
4398
|
+
const shape=[' - identityId は 広告主として表示される TikTok アカウント。**無いと広告が作れない**',
|
|
4399
|
+
' - media は 管理番号 → {video(動画ID), cover(カバー画像ID)}。SINGLE_VIDEO はカバー画像が必須',
|
|
4400
|
+
' - campaigns/adGroups は campaignId/adGroupId をキーに(adGroups.paramCode はURLパラメータ)'];
|
|
4401
|
+
const lines=[];
|
|
4402
|
+
if(mode==='ai'){
|
|
4403
|
+
lines.push('TikTok入稿プランナー用に、新規アカウントの参照データJSONを生成してください。','',
|
|
4404
|
+
'1. まず対象アカウントを対話で決めてください:',
|
|
4405
|
+
' - webgram list --platform bytedance --resource accounts で候補一覧を取得し、どのアカウントを追加するか私に確認して アカウント名・accountId を確定する',
|
|
4406
|
+
'2. 確定したアカウントの identity と素材を取得:',
|
|
4407
|
+
' webgram list --platform bytedance --resource identities --account-id '+idArg,
|
|
4408
|
+
' webgram list --platform bytedance --resource resources --account-id '+idArg,
|
|
4409
|
+
'3. 取得結果を下記スキーマに整形し、'+dir+'/<アカウント名>.json として書き出す:');
|
|
4410
|
+
shape.forEach(l=>lines.push(l));
|
|
4411
|
+
} else {
|
|
4412
|
+
lines.push('TikTok入稿プランナー用に、アカウント「'+headName+'」'+(mode==='import'&&accId?('(accountId: '+accId+')'):'')+'の参照データJSONを生成してください。','',
|
|
4413
|
+
'手順:',
|
|
4414
|
+
'1. webgram CLI で identity と素材を取得:',
|
|
4415
|
+
' webgram list --platform bytedance --resource identities --account-id '+idArg,
|
|
4416
|
+
' webgram list --platform bytedance --resource resources --account-id '+idArg,
|
|
4417
|
+
'2. 取得結果を下記スキーマに整形して '+dir+'/'+fileName+'.json として書き出す:');
|
|
4418
|
+
shape.forEach(l=>lines.push(l));
|
|
4419
|
+
}
|
|
4420
|
+
lines.push('', 'スキーマ:');
|
|
4421
|
+
P.account.schemaLines({updatedAt:mode!=='help',
|
|
4422
|
+
nameHint:mode==='help'?'<アカウント名(日本語OK)>':'<アカウント名>'}).forEach(l=>lines.push(l));
|
|
4423
|
+
lines.push('');
|
|
4424
|
+
lines.push(mode==='ai' ? '書き出したら、プランナー右上のアカウントプルダウン →「+新規追加」→「📄 ファイルから追加」で その JSON を読み込めます。'
|
|
4425
|
+
: mode==='import' ? '書き出したら、プランナーで「📁 フォルダ」を再接続 or アカウントセレクトを開くと自動で一覧に出ます。'
|
|
4426
|
+
: '書き出したら、プランナーで「+新規追加 →📄 ファイルから追加」or「📁 フォルダ」再接続で一覧に出ます。');
|
|
4427
|
+
return lines.join('\n');
|
|
4428
|
+
},
|
|
4429
|
+
strategy(){
|
|
4430
|
+
return ['TikTok広告の入稿プランを一緒に作りたいです。まず戦略を壁打ちさせてください。','',
|
|
4431
|
+
'# 前提','- アカウント: <アカウント名(日本語OK)>','- 商材 / LP: <商材名・URL>',
|
|
4432
|
+
'- 予算: <広告グループの日予算>',
|
|
4433
|
+
'- 動画: <アセットライブラリにアップロード済みの動画ID / カバー画像ID>',
|
|
4434
|
+
'- 訴求したいポイント: <強み・オファー・ターゲット層>','',
|
|
4435
|
+
'# お願い',
|
|
4436
|
+
'1. 上記をもとにキャンペーン/広告グループ構成・訴求軸(広告文)を提案してください。',
|
|
4437
|
+
'2. 私が「OK」と言ったら、その戦略を ad-ops Skill の「サンプルプラン_bytedance.json」に書き出して(上書きして)ください。',
|
|
4438
|
+
' - 保存先: .claude/skills/ad-ops/サンプルプラン_bytedance.json',
|
|
4439
|
+
' - フォーマット: {"schemaVersion":'+SCHEMA_VERSION+', "platform":"bytedance", "accountId":"<advertiser_id>", "campaigns":[ ... ]}',
|
|
4440
|
+
' - campaigns には キャンペーン → 広告グループ(paramCode / 日予算 / 地域 / 配信面) → 広告(クリエイティブ番号 / 広告文番号) の構成を入れてください。',
|
|
4441
|
+
' - 目的は TRAFFIC(記事LPへの誘導)固定、広告は単一動画のみです。',
|
|
4442
|
+
'3. 書き出したら、プランナーを開き直す(またはリロードして媒体を TikTok に切り替える)と内容が自動で表示されます。'].join('\n');
|
|
4443
|
+
},
|
|
4444
|
+
submitHelp(ctx){
|
|
4445
|
+
const P=PLATFORMS.bytedance, c=ctx||{}, rel=c.savedRel, dir=P.output.planDir;
|
|
4446
|
+
const lines=['入稿を試みてください。手順:',
|
|
4447
|
+
'1) 入稿計画JSON('+c.fileName+')を ad-ops Skill の入稿計画フォルダに置く(保存先: '+rel+')。',
|
|
4448
|
+
' ・入稿計画フォルダ('+dir+'/)に無ければ、ダウンロードフォルダ(~/Downloads/'+c.fileName+')を探して上記パスへ移動する。',
|
|
4449
|
+
(c.wrote? ' ・プランナーからは既に入稿計画フォルダへ保存済みのはず。' : ' ・フォルダ未接続のためダウンロード済み。~/Downloads から移動してください。'),
|
|
4450
|
+
'2) 置いたパスで dry-run 入稿を実行:',
|
|
4451
|
+
' '+P.output.submitCommand(rel,{dryRun:true}),
|
|
4452
|
+
'',
|
|
4453
|
+
'※ TikTok は実 API での入稿検証が未了です。配信オン(ENABLE)は backend が拒否します。初回は 1キャンペーン・最小予算で流し、管理画面で目視確認してください。'];
|
|
4454
|
+
return lines.join('\n');
|
|
4455
|
+
},
|
|
4456
|
+
afterSave(ctx){
|
|
4457
|
+
const P=PLATFORMS.bytedance, c=ctx||{};
|
|
4458
|
+
return 'プラン「'+c.name+'」v'+c.versionId+' を保存しました。\n入稿するには「入稿JSON書き出し」を押し、生成された *_TikTok入稿計画_*.json を指定して dry-run してください:\n\n'
|
|
4459
|
+
+ P.output.submitCommand('<入稿計画JSONのパス>',{dryRun:true});
|
|
4460
|
+
}
|
|
4461
|
+
},
|
|
4462
|
+
docTabs:[{id:'overview',label:'概要'},{id:'video',label:'動画広告'},
|
|
4463
|
+
{id:'param',label:'パラメータ'},{id:'guide',label:'運用手順'}]
|
|
4464
|
+
};
|
|
4465
|
+
|
|
3265
4466
|
/* DOM の data-st ('ads'|'groups'|'campaigns') と正規タブID ('ads'|'adGroups'|'campaigns') を橋渡し */
|
|
3266
4467
|
function normTab(tab){ return tab==='groups'?'adGroups':(tab||'ads'); }
|
|
3267
4468
|
/* 列記述子配列。引数省略時は現在のシートタブ */
|