addashboard-cli 0.7.0 → 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 +188 -14
- package/skills/ad-ops/ad-planner.html +1665 -304
- package/skills/ad-ops/scripts/adops.sh +55 -6
- package/skills/ad-ops/scripts/plan_lint.py +164 -24
- package/skills/ad-ops/scripts/refs_import.py +1 -1
- 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/242/343/202/253/343/202/246/343/203/263/343/203/210/yda/sample_account.json +2 -2
- package/skills/ad-ops//343/202/265/343/203/263/343/203/227/343/203/253/343/203/227/343/203/251/343/203/263.json +1 -1
- 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
|
@@ -141,6 +141,19 @@
|
|
|
141
141
|
|
|
142
142
|
/* ── sheet ── */
|
|
143
143
|
.sheetwrap{background:#fff; border:1px solid var(--line); border-radius:var(--radius); overflow:auto; max-height:calc(100vh - 220px);}
|
|
144
|
+
/* ▼ シートタブ + 表示フィルタを1行に並べ、表の上辺に接合する (フォルダタブ風)。
|
|
145
|
+
.seg の共通スタイルは #modeSeg 等でも使うので壊さず、専用クラスで上書きする。
|
|
146
|
+
アクティブタブは表ヘッダー(table.sheet th)と同じ var(--soft) にして、境界を消して地続きに見せる。 */
|
|
147
|
+
.tabrow{display:flex; align-items:flex-end; justify-content:space-between; gap:12px; margin:0;}
|
|
148
|
+
.seg.sheettabs{display:flex; margin:0; padding:0; gap:2px; background:transparent; border:0; border-radius:0; position:relative; z-index:1;}
|
|
149
|
+
.seg.sheettabs button{border:1px solid transparent; border-bottom:0; background:transparent; color:var(--muted); border-radius:8px 8px 0 0; padding:7px 16px; margin-bottom:-1px;}
|
|
150
|
+
.seg.sheettabs button:hover:not(.active){background:var(--softer); color:var(--fg);}
|
|
151
|
+
/* 選択中タブは表ヘッダーと同色。下辺の境界を消して表と繋げる */
|
|
152
|
+
.seg.sheettabs button.active{background:var(--soft); color:var(--fg); border-color:var(--line); box-shadow:none; padding-bottom:8px;}
|
|
153
|
+
/* 表示フィルタは同じ行の右端。アクティブタブと同色で帯として揃える */
|
|
154
|
+
.filt.tabfilt{margin-bottom:6px;}
|
|
155
|
+
.filt.tabfilt select{background:var(--soft); border-color:var(--line);}
|
|
156
|
+
.sheetwrap.tabbed{border-top-left-radius:0; border-top-right-radius:0;}
|
|
144
157
|
table.sheet{border-collapse:separate; border-spacing:0; width:100%; font-size:12.5px; min-width:1180px; user-select:none; -webkit-user-select:none;}
|
|
145
158
|
table.sheet input.cell-in{user-select:text; -webkit-user-select:text;}
|
|
146
159
|
table.sheet th{position:sticky; top:0; z-index:5; background:var(--soft); color:#3a4150; font-weight:700; font-size:11px; letter-spacing:.02em; text-align:left; padding:8px 9px; border-bottom:1px solid var(--line); white-space:nowrap;}
|
|
@@ -181,7 +194,6 @@
|
|
|
181
194
|
table.sheet tr.cphead .cptitle{font-size:12.5px; color:var(--fg); display:flex; align-items:center; gap:8px;}
|
|
182
195
|
table.sheet tr.adrow:hover td{background:#fcfdff;}
|
|
183
196
|
table.sheet tr.adrow.hasErr td{background:var(--danger-soft);}
|
|
184
|
-
table.sheet tr.adrow.banner td{background:#faf9fc;}
|
|
185
197
|
.cell-in{width:100%; border:1px solid transparent; background:transparent; border-radius:5px; padding:4px 6px; font-family:inherit; font-size:12.5px; color:var(--fg);}
|
|
186
198
|
.cell-in:hover{border-color:var(--line);}
|
|
187
199
|
.cell-in:focus{outline:none; border-color:var(--accent); background:#fff; box-shadow:0 0 0 2px var(--accent-soft);}
|
|
@@ -195,6 +207,7 @@
|
|
|
195
207
|
.medibadge{display:inline-flex; align-items:center; gap:6px; font-size:11px; flex-shrink:0; white-space:nowrap;}
|
|
196
208
|
.medibadge .sw{width:26px; height:26px; border-radius:5px; background:linear-gradient(135deg,#e9ecf5,#dfe3ee); display:inline-flex; align-items:center; justify-content:center; font-size:9px; color:#6b7280; font-weight:700; flex-shrink:0;}
|
|
197
209
|
.idpill{font-family:var(--mono); font-size:10.5px; color:var(--muted);}
|
|
210
|
+
.celldis{color:var(--faint); cursor:not-allowed;} /* その行には存在しない列 (バナー行のタイトル/説明文) */
|
|
198
211
|
.robadge{display:inline-block; font-size:12px; color:var(--muted); padding:2px 2px; max-width:170px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; vertical-align:middle;} /* 既存行の編集不可セル表示 */
|
|
199
212
|
.cell-in.roinput{background:var(--soft); color:var(--muted); cursor:not-allowed;} /* 既存行の read-only 入力欄 */
|
|
200
213
|
.idpill.warn{color:var(--amber);} .idpill.err{color:var(--danger);}
|
|
@@ -225,7 +238,6 @@
|
|
|
225
238
|
.agnode.collapsed .adwrap{display:none;}
|
|
226
239
|
.adnode{display:flex; align-items:center; gap:9px; padding:7px 9px; border:1px solid var(--line2); border-radius:8px; background:#fff;}
|
|
227
240
|
.adnode.hasErr{border-color:var(--danger); background:var(--danger-soft);}
|
|
228
|
-
.adnode.banner{background:#faf9fc;}
|
|
229
241
|
.dot{width:8px;height:8px;border-radius:50%; flex-shrink:0;}
|
|
230
242
|
.dot.ok{background:var(--green);} .dot.err{background:var(--danger);} .dot.warn{background:var(--amber);}
|
|
231
243
|
.adttl{font-size:12.5px; flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;}
|
|
@@ -384,7 +396,7 @@
|
|
|
384
396
|
<div class="seg" id="ydaTabs" style="display:inline-flex; margin-bottom:14px">
|
|
385
397
|
<button data-yt="overview" class="active">概要</button>
|
|
386
398
|
<button data-yt="resp">レスポンシブ</button>
|
|
387
|
-
<button data-yt="banner"
|
|
399
|
+
<button data-yt="banner">バナー</button>
|
|
388
400
|
<button data-yt="param">パラメータ</button>
|
|
389
401
|
<button data-yt="guide">使い方</button>
|
|
390
402
|
</div>
|
|
@@ -449,7 +461,7 @@
|
|
|
449
461
|
<tr><td><b>レスポンシブ(画像)・固定型</b></td><td><span class="pill green">API入稿 ★主用途</span></td><td>★★★</td><td>タイトル/説明文/画像を人が1通りに固定し、成果を自分で検証。<code>Card</code>(1200x628)/<code>SQ</code>(1080x1080) の2形式。API上は <code>RESPONSIVE_AD + mainMediaFormat:IMAGE</code>、画像は <code>asset.mediaAssets[].mediaId</code>(PR #82・実API疎通は検証中)</td></tr>
|
|
450
462
|
<tr><td>レスポンシブ(画像)・アセット型</td><td><span class="pill amber">非対象</span></td><td>—</td><td>複数タイトル/画像を登録しYahoo側が自動最適化。<b>1広告=単一組合せの本ツールでは非対象</b></td></tr>
|
|
451
463
|
<tr><td>レスポンシブ(動画)</td><td><span class="pill gray">別フロー(CLI)</span></td><td>—</td><td><code>webgram upload-video → submit</code> のAPI入稿(本番実績あり)。<code>thumbnailFlg:'TRUE'</code> のサムネ画像は動画専用で、画像広告のメイン画像とは排他</td></tr>
|
|
452
|
-
<tr><td>バナー(静止画・固定サイズ)</td><td><span class="pill
|
|
464
|
+
<tr><td><b>バナー(静止画・固定サイズ)</b></td><td><span class="pill green">API入稿 対応済</span></td><td>★★★</td><td>広告行の<b>広告タイプ列で「バナー」</b>を選ぶ(画像は形式列=Card/SQ のまま、<b>レスポンシブと同じ画像プールを共用</b>)。API上は <code>BANNER_AD + mainMediaFormat:IMAGE</code>、画像は <b>operand 直下の <code>mediaId</code></b>。<b>タイトル/説明文/主体者/CTAはAPIフィールド自体が無い</b>(画像に焼き込み)。2026-08-19 に本番実機で add 成功・読み戻し検証済み</td></tr>
|
|
453
465
|
<tr><td>カルーセル / テキスト</td><td><span class="pill gray">未使用</span></td><td>—</td><td>運用では使わない</td></tr>
|
|
454
466
|
</tbody></table>
|
|
455
467
|
</div></details>
|
|
@@ -463,7 +475,8 @@
|
|
|
463
475
|
<tr><td>入札戦略</td><td><b>tCPA 8,500円</b></td><td>API値は <code>MAXIMIZE_CONVERSIONS + targetCpa:8500</code>。もう一択は「最大配信」</td></tr>
|
|
464
476
|
<tr><td>予算</td><td>新規 12,000 / ASC 50,000〜</td><td>モードで切替</td></tr>
|
|
465
477
|
<tr><td>配信設定</td><td><b>オフ (PAUSED)</b></td><td>誤配信防止。入稿後に手動オン</td></tr>
|
|
466
|
-
<tr><td
|
|
478
|
+
<tr><td>広告タイプ</td><td>レスポンシブ</td><td><b>既定値</b>。広告行の<b>広告タイプ列</b>で「バナー」(API: <code>BANNER_AD</code>)に切替できる。同一キャンペーン内で混在可</td></tr>
|
|
479
|
+
<tr><td>メインメディア形式</td><td>画像</td><td>固定(API: <code>mainMediaFormat:IMAGE</code>)</td></tr>
|
|
467
480
|
<tr><td>主体者表記</td><td><b>アカウントJSON由来</b></td><td>案件固定(薬機/景表対応)。アカウント/<名前>.json の <code>principal</code>。API: <code>responsiveAd.principal</code></td></tr>
|
|
468
481
|
<tr><td>表示URLの形式</td><td>ドメインのみ表示する</td><td>固定(API: <code>displayUrlLevel:'DOMAIN'</code>)</td></tr>
|
|
469
482
|
</tbody></table>
|
|
@@ -476,6 +489,8 @@ crName = 画像番号 + (形式=="SQ" ? "<span class="k">s</span>" : "<span cl
|
|
|
476
489
|
<span class="c">// 広告名 (col5)</span>
|
|
477
490
|
adName = crName + "-" + タイトル番号 + "<span class="k">L</span>" + 説明文番号 + "-" + paramCode
|
|
478
491
|
<span class="c">→ 957c-45L60-jv_Cac_148</span>
|
|
492
|
+
<span class="c">// 広告タイプ=バナーはタイトル/説明文が存在しないので L 区間を出さない</span>
|
|
493
|
+
adName(バナー) = crName + "-" + paramCode <span class="c">→ 957c-jv_Cac_148</span>
|
|
479
494
|
<span class="c">// キャンペーン/広告グループID … paramCode で参照表を引く</span>
|
|
480
495
|
campaignId, adGroupId = groups[ paramCode ] <span class="c">→ 32525263 / 451760490</span>
|
|
481
496
|
<span class="c">// メディアID … 画像番号+形式で参照表を引く</span>
|
|
@@ -492,16 +507,26 @@ finalUrl = baseURL + paramCode + "?ad=" + paramCode + "&creative=" + …
|
|
|
492
507
|
</div></details>
|
|
493
508
|
</div>
|
|
494
509
|
<div id="yt-banner" style="display:none">
|
|
495
|
-
<details class="sec" open><summary><span class="num">1</span><span class="stitle">バナー(静止画・固定サイズ)—
|
|
510
|
+
<details class="sec" open><summary><span class="num">1</span><span class="stitle">バナー(静止画・固定サイズ)— API入稿 対応済 ⭐️⭐️⭐️</span><span class="chev"></span></summary>
|
|
496
511
|
<div class="body">
|
|
497
|
-
<p class="lead"
|
|
512
|
+
<p class="lead">利用頻度が高い(⭐️⭐️⭐️)形式。<b>2026-08-19 に本番実機で <code>AdGroupAdService/add</code> 成功・読み戻し検証済み</b>。以後は<b>レスポンシブと同じ入稿計画JSON→<code>webgram submit-plan</code> のAPI入稿</b>に乗る(管理画面での手入稿は不要)。</p>
|
|
513
|
+
<div class="callout"><b>広告行には2つの独立した軸がある</b>。<b>「広告タイプ」列</b>=レスポンシブ(<code>RESPONSIVE_AD</code>)/バナー(<code>BANNER_AD</code>)、<b>「形式」列</b>=画像のサイズ(<code>Card</code> 1200x628 / <code>SQ</code> 1080x1080)。<b>同一キャンペーン内にレスポンシブ行とバナー行を混在</b>できる。かつてドキュメントに書いていた「手入稿フラグ」は<b>実装には存在しなかった</b>(記述だけが残っていた)。</div>
|
|
514
|
+
<div class="callout"><b>YDA のメディアはアカウントに1ライブラリだけ</b>(2026-08-19 <code>MediaService</code> 実機調査)。「バナー用/レスポンシブ用」の区分は<b>存在しない</b>ので、<b>バナーとレスポンシブは同じ画像プールを共用する</b>。画像が持つのは <code>mediaAdFormat</code>(<code>YJ_1200_628</code> / <code>SQUARE_600</code> 等・<b>画像から自動判定</b>)/<code>width</code>/<code>height</code>/<code>approvalStatus</code>/<code>logoFlg</code>/<code>thumbnailFlg</code> で、<b>サイズを指定する場所は API のどこにも無い</b>(アップロードは base64 のみ、広告作成は <code>mediaId</code> のみ)。実測でも稼働中バナー広告の <code>mediaId 22103986</code> は <code>SQUARE_600</code> / 1080x1080 = <b>レスポンシブの SQ と同じ枠の画像</b>だった。なお <code>webgram list --platform yda --resource media</code> が返すのは <code>mediaId</code> / <code>mediaName</code> / <code>mediaType</code> / <code>thumbnailFlg</code> の4つだけなので、参照表の <code>card</code>/<code>sq</code> 振り分けは <code>mediaName</code>(<code>954-1200.jpg</code> 等)から判断する。</div>
|
|
498
515
|
<table class="g"><thead><tr><th>項目</th><th>内容</th></tr></thead><tbody>
|
|
499
|
-
<tr><td
|
|
500
|
-
<tr><td
|
|
501
|
-
<tr><td
|
|
502
|
-
<tr><td
|
|
503
|
-
<tr><td>将来対応</td><td>API での <code>BANNER_AD</code> 作成対応を検討中(未着手)。対応後は手入稿フラグを外すだけで同じフローに乗る想定</td></tr>
|
|
516
|
+
<tr><td>プランナーでの扱い</td><td>広告行の<b>広告タイプ列 = 「バナー」</b>。タイトル番号/説明文番号/タイトル本文/説明文本文のセルは <b>「—」で無効化</b>され、未入力でもバリデーションエラーにならない(列自体はレスポンシブ行と混在するため隠さない)</td></tr>
|
|
517
|
+
<tr><td>画像の参照</td><td><b>レスポンシブと同じ</b> 参照表 <code>refs.media[画像番号].card</code>/<code>.sq</code>(形式列で選ぶ)。<b>バナー専用の画像プールは無い</b>。未登録なら 🆕未登録 として画像パスからアップロード入稿する経路に乗る</td></tr>
|
|
518
|
+
<tr><td>命名規則</td><td>サイズ記号(<code>c</code>/<code>s</code>)は残し、<b>タイトル/説明文番号の区間を出さない</b> → <code>{画像番号}{c|s}-{paramCode}</code>(例 <code>957c-jv_Cac_148</code>)。レスポンシブは従来どおり <code>957c-45L60-jv_Cac_148</code></td></tr>
|
|
519
|
+
<tr><td>入稿計画JSON</td><td><code>ads[]</code> に <code>adType:"BANNER_AD"</code> が載り、<b><code>headlines</code>/<code>descriptions</code>/<code>principal</code>/<code>buttonText</code> は出力しない</b>。<code>adType</code> <b>省略時は <code>RESPONSIVE_AD</code> 扱い</b>(旧プランJSONの後方互換)</td></tr>
|
|
504
520
|
</tbody></table>
|
|
521
|
+
<div class="fx"><span class="c">// 本番実測で確定した AdGroupAdService/add の operand</span>
|
|
522
|
+
{ accountId, campaignId, adGroupId, adName, userStatus,
|
|
523
|
+
<span class="k">mediaId</span>: <画像mediaId>, <span class="c">// operand 直下 (レスポンシブ createImageAd と同じ位置)</span>
|
|
524
|
+
ad: { adType: '<span class="k">BANNER_AD</span>', mainMediaFormat: '<span class="k">IMAGE</span>',
|
|
525
|
+
bannerAd: {}, <span class="c">// IMAGE のときは空オブジェクト (中身は動画専用フィールドのみ)</span>
|
|
526
|
+
finalUrl: <URL>,
|
|
527
|
+
displayUrlLevel: 'DOMAIN' } <span class="c">// displayUrl は送らない (finalUrl から自動生成される)</span> }
|
|
528
|
+
<span class="c">// headline / description / principal / buttonText / logoMediaId は BANNER_AD に**フィールド自体が無い**</span>
|
|
529
|
+
<span class="c">// 読み戻すと ad.responsiveAd === null になる</span></div>
|
|
505
530
|
</div></details>
|
|
506
531
|
</div>
|
|
507
532
|
<div id="yt-param" style="display:none">
|
|
@@ -511,18 +536,20 @@ finalUrl = baseURL + paramCode + "?ad=" + paramCode + "&creative=" + …
|
|
|
511
536
|
<tr><td><b>案件設定チップ</b></td><td>案件名 / <b>アカウントID</b> / <b>目的(goal)</b> / <b>配信開始日時</b> / CTA / 配信 / タイプ / 入札既定(tCPA・目標CPA) / フリークエンシー / 主体者 / 表示URL / 記事URL / 予算(新規・ASC)</td><td>accountId・goal・startDate・入札既定・予算既定・finalUrlベース</td></tr>
|
|
512
537
|
<tr><td><b>キャンペーン行</b></td><td>キャンペーン名 / 入稿モード / 1日の予算 / 入札戦略 / 目標CV単価(tCPA) / 配信設定 / 入稿リンク番号</td><td>name・budget・biddingStrategy+targetCpa・status</td></tr>
|
|
513
538
|
<tr><td><b>広告グループ行</b></td><td>広告グループ名 / paramCode / 入札価格 / 配信設定 / スマートTG / <b>ターゲティング列</b>(デバイス・性別・年齢・地域・曜日時間帯・<b>オーディエンス(含む/除外)</b>・<b>配置(含む/除外)</b>・サーチKW)</td><td>name・status・smartTargeting・devices/ages/genders/days(+startHour/endHour)・<b>audiencesInclude/Exclude・placementsInclude/Exclude(実IDで送信)</b>。<b>未反映は 地域・サーチKW のみ</b></td></tr>
|
|
514
|
-
<tr><td><b>広告行</b></td><td
|
|
539
|
+
<tr><td><b>広告行</b></td><td>形式(Card/SQ) / <b>広告タイプ(レスポンシブ/バナー)</b> / 画像番号 / タイトル番号 / 説明文番号 / 最終リンク先URL(自動) / タイトル本文 / 説明文本文 / 画像パス / 広告名</td><td>ads[].adName・<b>adType</b>・mediaIds・imagePaths・headlines・descriptions・finalUrl(未解決は除外。<b>バナー行はテキスト系を出力しない</b>)</td></tr>
|
|
515
540
|
</tbody></table>
|
|
516
541
|
</div></details>
|
|
517
542
|
<details class="sec" open><summary><span class="num">2</span><span class="stitle">入稿JSON — 構造と入力項目・制約(パラメータ)</span><span class="chev"></span></summary>
|
|
518
543
|
<div class="body">
|
|
519
|
-
<p class="lead">「入稿JSON書き出し」はワンクリックで、<b>画面のプランをそのまま</b>入稿計画JSON(<code>planVersion:1</code>)に変換する(設定ダイアログなし)。<code>webgram submit-plan --plan-file <このファイル></code> が dry-run で全キャンペーンを一括検証し、本実行では1キャンペーンずつ入稿・進捗をファイルに書き戻す(再開可能・二重入稿防止)。広告は backend <code>ydaAdSchema</code> の IMAGE
|
|
544
|
+
<p class="lead">「入稿JSON書き出し」はワンクリックで、<b>画面のプランをそのまま</b>入稿計画JSON(<code>planVersion:1</code>)に変換する(設定ダイアログなし)。<code>webgram submit-plan --plan-file <このファイル></code> が dry-run で全キャンペーンを一括検証し、本実行では1キャンペーンずつ入稿・進捗をファイルに書き戻す(再開可能・二重入稿防止)。広告は backend <code>ydaAdSchema</code> の IMAGE 形式(<code>adType</code> で レスポンシブ/バナーを切り替える。画像プールは共通)。未解決の広告は自動除外。</p>
|
|
520
545
|
<div class="fx">{ planVersion: 1, platform: "yda", accountId,
|
|
521
546
|
campaigns: [ { name, goal, status, startDate, budget, biddingStrategy, targetCpa?, cpc?,
|
|
522
547
|
adGroups: [ { name, status, smartTargeting, devices, ages, genders, days, startHour?, endHour?,
|
|
523
548
|
placementsInclude, placementsExclude, audiencesInclude, audiencesExclude,
|
|
524
|
-
ads: [ { adName, status, mediaType:"IMAGE",
|
|
525
|
-
|
|
549
|
+
ads: [ { adName, status, mediaType:"IMAGE", adType:"RESPONSIVE_AD"|"BANNER_AD",
|
|
550
|
+
mediaIds[], headlines[], descriptions[], principal, buttonText,
|
|
551
|
+
finalUrl } ] } ] } ], // adType 省略時は RESPONSIVE_AD 扱い
|
|
552
|
+
// BANNER_AD は headlines/descriptions/principal/buttonText を出さない
|
|
526
553
|
progress?: [...] // submit-plan が書き戻す進捗 (再開・二重入稿防止) }</div>
|
|
527
554
|
<table class="g"><thead><tr><th>項目</th><th>画面上の出どころ</th><th>変換・制約</th></tr></thead><tbody>
|
|
528
555
|
<tr><td class="mono">accountId</td><td><b>案件設定「アカウントID」</b></td><td>必須。未入力だと書き出し中止</td></tr>
|
|
@@ -536,7 +563,8 @@ finalUrl = baseURL + paramCode + "?ad=" + paramCode + "&creative=" + …
|
|
|
536
563
|
<tr><td class="mono">days / startHour / endHour</td><td>広告グループ行「曜日・時間帯」</td><td><code>月→MONDAY</code> 等。<code>平日</code>=月〜金、<code>土日祝</code>=土日 に展開。時間帯(午前(6-12時)等)は <code>startHour/endHour</code> に変換(複数選択時は最初の1つ)</td></tr>
|
|
537
564
|
<tr><td class="mono">audiences* / placements*</td><td>広告グループ行「オーディエンス(除外)」「配置(除外)」</td><td><b>参照表(refs.audiences / refs.placements)の実IDで送信される</b>(表示は name)。数値IDでない値(旧・日本語ラベルや手入力の名前)は落として書き出し時に警告。<b>JSONに反映されない列は「地域」「サーチキーワード」だけ</b>(入稿後に管理画面で設定)</td></tr>
|
|
538
565
|
<tr><td class="mono">ads[].adName</td><td>広告行「広告名」(空欄なら<span class="pill">自動</span>)</td><td>空欄時の生成式 <code>{画像番号}{c|s}-{タイトル番号}L{説明文番号}-{paramCode}</code>。手入力するとその名前が使われ、<code>finalUrl</code> の <code>creative=</code> も変わる</td></tr>
|
|
539
|
-
<tr><td class="mono">ads[].
|
|
566
|
+
<tr><td class="mono">ads[].adType</td><td>広告行「広告タイプ」</td><td><code>レスポンシブ → RESPONSIVE_AD</code>・<code>バナー → BANNER_AD</code>。<b>省略時は RESPONSIVE_AD 扱い</b>(旧プランJSONの後方互換)。BANNER_AD では <code>headlines / descriptions / principal / buttonText</code> を<b>出力しない</b>(APIフィールド自体が無い)</td></tr>
|
|
567
|
+
<tr><td class="mono">ads[].mediaIds</td><td><span class="pill">自動</span></td><td>画像番号+形式 → 参照表の mediaId(<code>media[番号].card / .sq</code>。バナーも同じプールを使う)。未解決は入稿対象外</td></tr>
|
|
540
568
|
<tr><td class="mono">ads[].imagePaths</td><td>広告行「画像パス」</td><td><b>参照表に無い画像番号(未登録)のときだけ</b>送る。CLI が入稿前にアップロードして mediaId を解決する。画像番号が解決済みの行に入れても<b>送らない</b>(CLI は追加アップロードするが広告には既存 mediaId が使われるため。差し替えたいときは画像番号を空にする)</td></tr>
|
|
541
569
|
<tr><td class="mono">ads[].headlines</td><td><span class="pill purple">可変</span></td><td>タイトル番号 → 文言。<b>35字以内</b>(API上のアセット制約はタイトル≤20字・説明文≤90字)</td></tr>
|
|
542
570
|
<tr><td class="mono">ads[].descriptions</td><td><span class="pill purple">可変</span></td><td>説明文番号 → 文言</td></tr>
|
|
@@ -549,7 +577,7 @@ finalUrl = baseURL + paramCode + "?ad=" + paramCode + "&creative=" + …
|
|
|
549
577
|
<ul>
|
|
550
578
|
<li><b>タイトル 35文字以内</b>(タイトル集ヘッダに明記)。超過はシート/ツリーで赤表示。</li>
|
|
551
579
|
<li><b>メディアID未解決</b>:画像番号が参照表に無い→入稿不可(実シートの <code>#N/A</code>)。赤エラー。</li>
|
|
552
|
-
<li><b>タイトル/説明文番号未解決</b
|
|
580
|
+
<li><b>タイトル/説明文番号未解決</b>:文言ライブラリに無い→赤エラー。<b>広告タイプ=バナー の行はタイトル/説明文セルが無効化され、この検証の対象外</b>(BANNER_AD にAPI項目が無いため)。</li>
|
|
553
581
|
<li><b>paramCode未払出</b>:参照表に無い→黄色警告(新規は想定内、既存は要確認)。</li>
|
|
554
582
|
<li>説明文は同じ文言ライブラリを共有するが、タイトルより長文可のため35字制限は<b>タイトル枠のみ</b>に適用。</li>
|
|
555
583
|
</ul>
|
|
@@ -560,7 +588,7 @@ finalUrl = baseURL + paramCode + "?ad=" + paramCode + "&creative=" + …
|
|
|
560
588
|
<div class="body">
|
|
561
589
|
<ol>
|
|
562
590
|
<li><b>プラン作成</b>:プランビューは「戦略メモ → 案件設定 → シート/ツリー切替」の順。シート選択時はさらに広告/広告グループ/キャンペーンのタブで編集する。AI が生成した state JSON は「JSON読込」で取り込む(旧v1形式も自動マイグレーション)。</li>
|
|
563
|
-
<li><b>確認</b>:バリデーションバッジ(タイトル35字超・参照未解決・paramCode
|
|
591
|
+
<li><b>確認</b>:バリデーションバッジ(タイトル35字超・参照未解決・paramCode未払出)が緑になるまで修正。バナーは広告行の「広告タイプ」列で「バナー」を選ぶ(タイトル/説明文は無効化される。画像はレスポンシブと同じ Card/SQ を使う)。</li>
|
|
564
592
|
<li><b>バージョン保存</b>:ヘッダーの「バージョン保存」。保存のたびに<b>常に新バージョン</b>(v1, v2…)として名称・メモ・作成者付きで保存される(上書きなし)。<b>履歴本体はブラウザ内に自動保存</b>されるため、フォルダ設定なしでも一覧・再読込できる。</li>
|
|
565
593
|
<li><b>保存フォルダ接続(初回に1回だけ)</b>:履歴ビューの「📁 保存フォルダに接続」でスキル同梱の <code>入稿計画/</code> フォルダを一度選ぶと、<b>次回からは開くだけで自動接続</b>され、保存のたびにファイルにも書き出される。未接続時はダウンロード動作。</li>
|
|
566
594
|
<li><b>履歴の確認・再利用</b>:履歴ビューで一覧(Ver/日時/媒体/名称)を確認し、「読込」で過去バージョンを復元、「編集」で名称・メモ変更、「削除」で破棄。</li>
|
|
@@ -571,7 +599,7 @@ finalUrl = baseURL + paramCode + "?ad=" + paramCode + "&creative=" + …
|
|
|
571
599
|
<details class="sec" open><summary><span class="num">2</span><span class="stitle">注意事項・未対応事項(要確認)</span><span class="chev"></span></summary>
|
|
572
600
|
<div class="body">
|
|
573
601
|
<ul>
|
|
574
|
-
<li><b>バナー(固定サイズ)
|
|
602
|
+
<li><b>バナー(固定サイズ)もAPI入稿に対応済</b>(2026-08-19 本番実機で確認) → 広告行の<b>広告タイプ列</b>で「バナー」を選ぶとレスポンシブと同じフローで入稿できる(画像プールも共通)。</li>
|
|
575
603
|
<li><b>画像レスポンシブのAPI入稿(PR #82)は実API疎通が未検証</b>。本入稿前に必ず dry-run → 少数テスト入稿で確認。</li>
|
|
576
604
|
<li>CTA既定が「もっとみる」(画像) か「詳細を見る」(動画) か。画像/動画でCTA既定が異なる可能性。<code>buttonText</code> のenum値も要実測。</li>
|
|
577
605
|
<li>年齢区分enumは実装内に系統差あり(<code>GT_RANGE*</code> が本番採用)。公式ドキュメント裏取りは未記録のため本入稿前に確認推奨。</li>
|
|
@@ -649,6 +677,82 @@ finalUrl = baseURL + paramCode + "?ad=" + paramCode + "&creative=" + …
|
|
|
649
677
|
</ul>
|
|
650
678
|
</div></details>
|
|
651
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 -->
|
|
652
756
|
</div>
|
|
653
757
|
</section>
|
|
654
758
|
|
|
@@ -660,34 +764,30 @@ finalUrl = baseURL + paramCode + "?ad=" + paramCode + "&creative=" + …
|
|
|
660
764
|
<summary>共通設定</summary>
|
|
661
765
|
<div class="chipset" id="tplChips"></div>
|
|
662
766
|
</details>
|
|
663
|
-
<!-- E-1: プランツールバー(タイプ固定プルダウンのみ。キャンペーン追加導線はシート/ツリー側に配置) -->
|
|
664
|
-
<div class="toolbar2" id="planTools" style="margin:0 0 10px">
|
|
665
|
-
<span class="chip" title="このプランナーはレスポンシブ(画像)固定です(変更不可)">タイプ
|
|
666
|
-
<select class="chipin" id="typeAdType" disabled title="広告タイプ(固定)"><option>レスポンシブ</option></select>
|
|
667
|
-
<select class="chipin" id="typeMediaFormat" disabled title="メインメディアの形式(固定)"><option>画像</option></select>
|
|
668
|
-
</span>
|
|
669
|
-
</div>
|
|
670
767
|
<div class="seg" id="modeSeg" style="margin:0 0 10px">
|
|
671
768
|
<button data-m="sheet" class="active">シート</button>
|
|
672
769
|
<button data-m="tree">ツリー</button>
|
|
673
770
|
</div>
|
|
674
771
|
<div id="sheetArea">
|
|
675
|
-
<div class="seg" id="sheetTabs" style="margin:0 0 10px">
|
|
676
|
-
<button data-st="ads" class="active">広告</button>
|
|
677
|
-
<button data-st="groups">広告グループ</button>
|
|
678
|
-
<button data-st="campaigns">キャンペーン</button>
|
|
679
|
-
</div>
|
|
680
772
|
<div class="toolbar2">
|
|
681
773
|
<button class="act ghost" id="btnDupSel" title="選択した行を複製">⧉ 選択を複製</button>
|
|
682
774
|
<button class="act ghost" id="btnSeqParam" style="display:none" title="選択した列(paramCode / 画像番号 / タイトル番号 / 説明文番号)を先頭セル基点に連番採番">🔢 連番</button>
|
|
683
775
|
<span class="hinttxt">セル選択(クリック/Shift+クリック/ドラッグ)→ Cmd/Ctrl+C コピー・Cmd/Ctrl+V 貼付(1セルのコピーは選択範囲全体へ充填)/範囲選択のままプルダウン・入力欄を確定すると同じ列へ一括、ターゲティング列はサイドバーで一括ON/OFF</span>
|
|
684
|
-
|
|
685
|
-
|
|
776
|
+
</div>
|
|
777
|
+
<!-- シートタブと表示フィルタは同じ行に並べ、表の上辺に接合する (フォルダタブ風)。
|
|
778
|
+
id と data-st の構造は E2E が依存するので変えない -->
|
|
779
|
+
<div class="tabrow">
|
|
780
|
+
<div class="seg sheettabs" id="sheetTabs">
|
|
781
|
+
<button data-st="ads" class="active">広告</button>
|
|
782
|
+
<button data-st="groups">広告グループ</button>
|
|
783
|
+
<button data-st="campaigns">キャンペーン</button>
|
|
784
|
+
</div>
|
|
785
|
+
<span class="filt tabfilt">表示 <select id="sheetFilter">
|
|
686
786
|
<option value="all">すべて</option>
|
|
687
787
|
<option value="err">エラーのみ</option>
|
|
688
788
|
</select></span>
|
|
689
789
|
</div>
|
|
690
|
-
<div class="sheetwrap"><div id="sheetHost"></div></div>
|
|
790
|
+
<div class="sheetwrap tabbed"><div id="sheetHost"></div></div>
|
|
691
791
|
</div>
|
|
692
792
|
<div id="treeArea" hidden>
|
|
693
793
|
<div class="toolbar2">
|
|
@@ -832,7 +932,19 @@ const EC = {
|
|
|
832
932
|
BUDGET_MISSING:'BUDGET_MISSING', PIXEL_MISSING:'PIXEL_MISSING',
|
|
833
933
|
/* 配信日時 (媒体が日時列を持つ場合に使う。空欄はエラーにしない = 未指定は上位へフォールバック) */
|
|
834
934
|
START_TIME_INVALID:'START_TIME_INVALID', END_TIME_INVALID:'END_TIME_INVALID',
|
|
835
|
-
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 未取込) */
|
|
836
948
|
};
|
|
837
949
|
|
|
838
950
|
/* ---- refs (VLOOKUP代替) ----
|
|
@@ -855,7 +967,8 @@ function pf(){ return PLATFORMS[curPf]; }
|
|
|
855
967
|
/* ---- S11: 媒体切替はヘッダーの統合プルダウン (#accSelect) に一本化 ----
|
|
856
968
|
媒体要件ビューは選択中の媒体のドキュメントだけを表示する。
|
|
857
969
|
媒体を切り替えると state は当該媒体の既定値で作り直す (プランは媒体をまたげない)。 */
|
|
858
|
-
|
|
970
|
+
/* 媒体を増やしたときに列挙を書き換え忘れると、その媒体だけドキュメントが出ないまま気づけない */
|
|
971
|
+
function showMediaDoc(id){ Object.keys(PLATFORMS).forEach(k=>{ const el=document.getElementById('mt-'+k); if(el) el.style.display = (k===id?'':'none'); }); }
|
|
859
972
|
function renderPlatformTabs(){
|
|
860
973
|
showMediaDoc(curPf);
|
|
861
974
|
renderChrome();
|
|
@@ -920,18 +1033,6 @@ function renderChrome(){
|
|
|
920
1033
|
return '<button data-rt="'+esc(k)+'"'+(k===refTab?' class="active"':'')+'>'+esc(T.countLabel||T.label||k)+'</button>';
|
|
921
1034
|
}).join('');
|
|
922
1035
|
}
|
|
923
|
-
renderFixedType();
|
|
924
|
-
}
|
|
925
|
-
/* 固定タイプチップ (変更不可) は媒体定義 pf().fixedType から描画する。
|
|
926
|
-
YDA=レスポンシブ/画像、Meta=単一画像 / 単一動画。コアに媒体語彙を持たない。 */
|
|
927
|
-
function renderFixedType(){
|
|
928
|
-
const host=document.getElementById('planTools'); if(!host) return;
|
|
929
|
-
const list=(pf()&&pf().fixedType)||[];
|
|
930
|
-
if(!list.length){ host.innerHTML=''; host.hidden=true; return; }
|
|
931
|
-
host.hidden=false;
|
|
932
|
-
host.innerHTML='<span class="chip" title="このプランナーは固定タイプです(変更不可)">タイプ'
|
|
933
|
-
+ list.map(f=>'<select class="chipin" disabled title="'+esc(f.label)+'(固定)"><option>'+esc(f.value)+'</option></select>').join('')
|
|
934
|
-
+ '</span>';
|
|
935
1036
|
}
|
|
936
1037
|
window.switchPlatform=function(id){
|
|
937
1038
|
if(!PLATFORMS[id]){ toast('この媒体はまだ選択できません(媒体スキーマ未登録)'); return false; }
|
|
@@ -1009,8 +1110,10 @@ const REF_TABLES = {
|
|
|
1009
1110
|
keyKind:'number', keyLabel:'画像番号', keyWidth:'80px',
|
|
1010
1111
|
addSub:'画像番号→Card/SQ の mediaID。新規作成した画像をメディアに登録',
|
|
1011
1112
|
emptyMsg:'該当する画像がありません',
|
|
1012
|
-
|
|
1013
|
-
|
|
1113
|
+
/* YDA のメディアライブラリはアカウントに1つだけ (バナー専用プールは存在しない)。
|
|
1114
|
+
バナー広告もここの card / sq をそのまま使う。 */
|
|
1115
|
+
valueFields:[{k:'card', label:'Card mediaID (1200)', editLabel:'Card mediaID'},
|
|
1116
|
+
{k:'sq', label:'SQ mediaID (1080)', editLabel:'SQ mediaID'}],
|
|
1014
1117
|
keyCell:no=>'<td><span class="sw" style="display:inline-flex;width:26px;height:26px;vertical-align:middle">'+esc(no)+'</span> '+newBadge('media',no)+'</td>',
|
|
1015
1118
|
columns:[
|
|
1016
1119
|
{label:'Card mediaID (1200)', cls:'mono', cell:(no,v)=>esc(v&&v.card||'—')},
|
|
@@ -1018,7 +1121,7 @@ const REF_TABLES = {
|
|
|
1018
1121
|
{label:'ファイル名', cls:'mono', style:'color:var(--faint);font-size:11px',
|
|
1019
1122
|
cell:no=>esc(no)+'-1200.jpg / '+esc(no)+'-1080.jpg'}
|
|
1020
1123
|
],
|
|
1021
|
-
optionLabel:v=>
|
|
1124
|
+
optionLabel:v=>[v&&v.card?'Card':'', v&&v.sq?'SQ':''].filter(Boolean).join('/'),
|
|
1022
1125
|
/* ▼ドロワー(一覧)の1行テキスト。媒体ごとの値の見せ方はここだけが持つ */
|
|
1023
1126
|
drawerLabel:v=>'Card '+esc(v&&v.card||'—')+' / SQ '+esc(v&&v.sq||'—') },
|
|
1024
1127
|
adGroups:{ key:'adGroups', label:'広告グループ', countLabel:'広告グループ', editable:false, required:false,
|
|
@@ -1120,11 +1223,24 @@ PLATFORMS.yda = {
|
|
|
1120
1223
|
consoleUrl:'https://ads.yahoo.co.jp/manager/#/{mcc}/{accountId}/list/{resource}',
|
|
1121
1224
|
consoleResourceMap:{campaigns:'campaigns', adGroups:'adgroups', ads:'ads'},
|
|
1122
1225
|
|
|
1123
|
-
/* ---- 形式(Card/SQ)
|
|
1226
|
+
/* ---- 形式(Card/SQ) = **画像のサイズ軸**。クリエイティブ名サフィックス・media 値のキーの対応 ---- */
|
|
1227
|
+
/* YDA のメディアライブラリは **アカウントに1つだけ** で、バナー用/レスポンシブ用の区分は存在しない
|
|
1228
|
+
(2026-08-19 MediaService 実機調査)。画像が持つのは mediaAdFormat(YJ_1200_628 / SQUARE_600 等・画像から自動判定)
|
|
1229
|
+
/ width / height / approvalStatus で、サイズを指定する場所は API のどこにも無い
|
|
1230
|
+
(アップロードは base64 のみ、広告作成は mediaId のみ)。
|
|
1231
|
+
→ **バナーもレスポンシブも同じ画像プール (refs.media[番号].card / .sq) を共用する**。
|
|
1232
|
+
広告タイプ (RESPONSIVE_AD / BANNER_AD) は別の軸で、広告行の「広告タイプ」列 (ad.adKind) が持つ。 */
|
|
1124
1233
|
mediaFormats:[
|
|
1125
|
-
{id:'Card',
|
|
1126
|
-
{id:'SQ',
|
|
1234
|
+
{id:'Card', crSuffix:'c', mediaKey:'card', apiMediaKind:'IMAGE', label:'Card mediaID (1200)'},
|
|
1235
|
+
{id:'SQ', crSuffix:'s', mediaKey:'sq', apiMediaKind:'IMAGE', label:'SQ mediaID (1080)'}
|
|
1127
1236
|
],
|
|
1237
|
+
/* 広告タイプがバナー (BANNER_AD) か。**サイズ(形式)ではなく ad.adKind で決まる**。
|
|
1238
|
+
(タイトル/説明文セルの無効化・広告名・入稿ボディのテキスト系省略の判定はすべてここを見る。
|
|
1239
|
+
未設定 = 旧プランJSON は 'レスポンシブ' 扱い) */
|
|
1240
|
+
/* 判定は mappers.adType と同じ正規化 (trim) を使う。ここがズレると
|
|
1241
|
+
adKind:' バナー ' のような値で adType だけ BANNER_AD になり、
|
|
1242
|
+
テキスト系フィールドが入稿計画JSONに混入する。 */
|
|
1243
|
+
isBanner(ad){ return PLATFORMS.yda.mappers.adType((ad&&ad.adKind), PLATFORMS.yda)==='BANNER_AD'; },
|
|
1128
1244
|
fmtOf(ad){ const F=PLATFORMS.yda.mediaFormats; return F.find(x=>x.id===(ad&&ad.format)) || F[0]; },
|
|
1129
1245
|
|
|
1130
1246
|
/* ---- 参照テーブル (S6 の REF_TABLES) ---- */
|
|
@@ -1157,7 +1273,7 @@ PLATFORMS.yda = {
|
|
|
1157
1273
|
title:'OFF にすると最終リンク先URLに ?ad= / &creative= を付けません(案件単位)'}
|
|
1158
1274
|
],
|
|
1159
1275
|
/* プランツールバーの固定プルダウン (変更不可) */
|
|
1160
|
-
|
|
1276
|
+
/* 広告タイプ (レスポンシブ/バナー) は **広告行の列**で切り替えるので固定チップには出さない */
|
|
1161
1277
|
|
|
1162
1278
|
/* ---- ターゲティング列の選択肢 (旧 TG_OPTIONS)。キーは COLS の tgKey ---- */
|
|
1163
1279
|
targeting:{
|
|
@@ -1189,6 +1305,10 @@ PLATFORMS.yda = {
|
|
|
1189
1305
|
/* S14: viewableFrequencyCap の enum (正: frontend/src/pages/submission/types.ts の FREQ_*_OPTIONS) */
|
|
1190
1306
|
freqLevel:{'広告グループ':'AD_GROUP','キャンペーン':'CAMPAIGN','広告':'AD'},
|
|
1191
1307
|
freqUnit:{'週':'WEEK','日':'DAY','月':'MONTH'},
|
|
1308
|
+
/* 広告タイプ列 (ad.adKind) → AdGroupAdService/add の ad.adType。
|
|
1309
|
+
**省略時は RESPONSIVE_AD 扱い**が backend/CLI の後方互換規約なので、未設定もここに落ちる。 */
|
|
1310
|
+
adKind:{'レスポンシブ':'RESPONSIVE_AD','バナー':'BANNER_AD'},
|
|
1311
|
+
adKindFallback:'RESPONSIVE_AD',
|
|
1192
1312
|
statusOn:'ACTIVE', statusOff:'PAUSED'
|
|
1193
1313
|
},
|
|
1194
1314
|
|
|
@@ -1212,6 +1332,8 @@ PLATFORMS.yda = {
|
|
|
1212
1332
|
},
|
|
1213
1333
|
status(s, P){ return s==='オン'?P.enums.statusOn:P.enums.statusOff; },
|
|
1214
1334
|
cta(label, P){ return P.enums.cta[String(label||'').trim()] || P.enums.ctaFallback; },
|
|
1335
|
+
/* 広告タイプ(日本語ラベル) → adType enum。空欄・未知は RESPONSIVE_AD (旧プランJSONの後方互換) */
|
|
1336
|
+
adType(label, P){ return P.enums.adKind[String(label||'').trim()] || P.enums.adKindFallback; },
|
|
1215
1337
|
/* C7: refpick セル (実IDのCSV) → 数値ID配列。落ちた分は ctx.stats.droppedRefIds に計上 */
|
|
1216
1338
|
idList(s, ctx){ return refIdList(s, ctx); }
|
|
1217
1339
|
},
|
|
@@ -1230,7 +1352,8 @@ PLATFORMS.yda = {
|
|
|
1230
1352
|
budgetNew:12000, budgetAsc:50000, goal:'CONVERSION', startDate:''
|
|
1231
1353
|
},
|
|
1232
1354
|
/* C6: adName は空 = 自動生成 (naming.adName)。C1: imagePath は空 = 従来挙動 */
|
|
1233
|
-
|
|
1355
|
+
/* adKind = 広告タイプ軸 (レスポンシブ / バナー)。format はサイズ軸 (Card / SQ) で直交する */
|
|
1356
|
+
ad:{format:'Card', adKind:'レスポンシブ', imageNo:'', titleNo:'', descNo:'', imagePath:'', adName:'', desc2:''},
|
|
1234
1357
|
/* S14: freq* は「案件設定チップの既定を新規CP作成時にコピーする」ので factories.newCampaign 側で入れる */
|
|
1235
1358
|
campaign:{name:'新規キャンペーン', campaignId:'', isExisting:false, urlNumber:'',
|
|
1236
1359
|
freqMode:'default', freqOn:false, freqLevel:'AD_GROUP', freqUnit:'WEEK', freqCount:3}
|
|
@@ -1264,9 +1387,12 @@ PLATFORMS.yda = {
|
|
|
1264
1387
|
naming:{
|
|
1265
1388
|
crName(ad, P){ return String(ad.imageNo||'') + P.fmtOf(ad).crSuffix; },
|
|
1266
1389
|
/* C8: URLパラメータ OFF の案件は paramCode を入力しない (列も隠す) ので、
|
|
1267
|
-
広告名の末尾 "-{paramCode}" も付けない (末尾が "-" だけ残るのを避ける)。
|
|
1390
|
+
広告名の末尾 "-{paramCode}" も付けない (末尾が "-" だけ残るのを避ける)。
|
|
1391
|
+
バナー (BANNER_AD) にはタイトル/説明文が存在しないので、"-{タイトル番号}L{説明文番号}" の区間も
|
|
1392
|
+
出さない (957c-jv_Cac_148)。サイズ記号 c/s は残す。 */
|
|
1268
1393
|
adName(ad, group, crName, project){
|
|
1269
|
-
const head =
|
|
1394
|
+
const head = PLATFORMS.yda.isBanner(ad) ? crName
|
|
1395
|
+
: (crName + '-' + (ad.titleNo??'') + 'L' + (ad.descNo??''));
|
|
1270
1396
|
if(project && project.urlParams===false) return head;
|
|
1271
1397
|
return head + '-' + (group.paramCode||'');
|
|
1272
1398
|
},
|
|
@@ -1306,7 +1432,10 @@ PLATFORMS.yda = {
|
|
|
1306
1432
|
},
|
|
1307
1433
|
groupBadges(g){ return '<span class="pill mono">【'+esc(g.paramCode||'—')+'】</span>'; },
|
|
1308
1434
|
adBadge(a, r){ return '<span class="cbadge">'+esc(a.imageNo||'—')+(a.format||'Card')[0].toLowerCase()+'</span>'; },
|
|
1309
|
-
adSub(a, r){ return esc(a.titleNo)+'L'+esc(a.descNo); }
|
|
1435
|
+
adSub(a, r){ return r.isBanner ? 'バナー' : esc(a.titleNo)+'L'+esc(a.descNo); },
|
|
1436
|
+
/* バナーにはタイトルが存在しない。シート側は「—」で無効化しているのに
|
|
1437
|
+
ツリーが「(未入力)」と出すと「埋めるべき欄」に見えるので別表記にする。 */
|
|
1438
|
+
adTitle(a, r){ return r.isBanner ? '<span class="sm">タイトルなし(画像に焼き込み)</span>' : null; }
|
|
1310
1439
|
},
|
|
1311
1440
|
|
|
1312
1441
|
/* ---- S8: 解決 (派生値 / 純関数) ---- */
|
|
@@ -1320,8 +1449,12 @@ PLATFORMS.yda = {
|
|
|
1320
1449
|
const adName=String(ad.adName||'').trim() || autoAdName;
|
|
1321
1450
|
const me = refs.media[String(ad.imageNo)];
|
|
1322
1451
|
const mediaId = me ? me[fmt.mediaKey] : undefined;
|
|
1323
|
-
|
|
1324
|
-
|
|
1452
|
+
/* バナー広告 (BANNER_AD) はタイトル/説明文のAPI項目が存在しない (画像に焼き込む運用) ので、
|
|
1453
|
+
参照も検証もしない。セル自体も列記述子の col.disabled で無効化している。
|
|
1454
|
+
判定は「広告タイプ」列 (ad.adKind) — 画像サイズ (Card/SQ) とは独立した軸。 */
|
|
1455
|
+
const isBanner = P.isBanner(ad);
|
|
1456
|
+
const titleText = isBanner ? undefined : refs.titles[ad.titleNo];
|
|
1457
|
+
const descText = isBanner ? undefined : (refs.descriptions||{})[ad.descNo]; // A-5: 説明文テーブルのみ参照 (titlesへのフォールバックなし)
|
|
1325
1458
|
const finalUrl = P.naming.finalUrl(ad, group, adName, p);
|
|
1326
1459
|
const errs=[], warns=[], errCodes=[], warnCodes=[];
|
|
1327
1460
|
// S2: 日本語ラベル(errs/warns)と安定コード(errCodes/warnCodes)を同順で1:1に積む
|
|
@@ -1335,10 +1468,12 @@ PLATFORMS.yda = {
|
|
|
1335
1468
|
// 番号が入っているのに参照表で引けない場合のみ従来どおり未解決エラー。
|
|
1336
1469
|
const titleNoStr=String(ad.titleNo||'').trim();
|
|
1337
1470
|
const descNoStr=String(ad.descNo||'').trim();
|
|
1338
|
-
if(
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1471
|
+
if(!isBanner){
|
|
1472
|
+
if(titleNoStr!=='' && titleText===undefined) pushErr('タイトル未解決', EC.TITLE_UNRESOLVED);
|
|
1473
|
+
if(descNoStr!=='' && descText===undefined) pushErr('説明文未解決', EC.DESC_UNRESOLVED);
|
|
1474
|
+
if(titleText!==undefined && glen(titleText)>35) pushErr('タイトル35字超', EC.TITLE_TOO_LONG);
|
|
1475
|
+
else if(titleText!==undefined && glen(titleText)>20) pushWarn('タイトル20字超', EC.TITLE_LONG_WARN);
|
|
1476
|
+
}
|
|
1342
1477
|
// B11: paramCode空はエラー(export対象外・URL生成に必須)。F-6: refs.groups 払出判定は廃止。
|
|
1343
1478
|
/* C8: URLパラメータ OFF の案件では paramCode 自体が不要 (列も隠す) のでエラーにしない */
|
|
1344
1479
|
if(p.urlParams!==false && String(group.paramCode||'').trim()==='') pushErr('paramCode未入力', EC.PARAM_CODE_EMPTY);
|
|
@@ -1354,7 +1489,8 @@ PLATFORMS.yda = {
|
|
|
1354
1489
|
if(!pendingImage && imgNoStr!=='' && imagePaths.length>0) pushWarn('画像パス無視(画像番号が解決済み)', EC.IMAGE_PATH_IGNORED);
|
|
1355
1490
|
// F-6: ID解決は adGroupId ベース。campaignId は idsCell表示用に持たない(親CP参照は呼び出し側)
|
|
1356
1491
|
const aid=String(group.adGroupId||'').trim();
|
|
1357
|
-
return {crName, adName, autoAdName, imagePaths, campaignId:'', adGroupId:aid, mediaKind:fmt.apiMediaKind,
|
|
1492
|
+
return {crName, adName, autoAdName, imagePaths, campaignId:'', adGroupId:aid, mediaKind:fmt.apiMediaKind,
|
|
1493
|
+
adType:P.mappers.adType(ad.adKind, P), isBanner, imageNo:imgNoStr,
|
|
1358
1494
|
mediaId:mediaId||'', pendingImage, titleText:titleText===undefined?'':titleText,
|
|
1359
1495
|
descText:descText===undefined?'':descText, finalUrl, errs, warns, errCodes, warnCodes, resolvedGroup:!!aid};
|
|
1360
1496
|
},
|
|
@@ -1430,17 +1566,24 @@ PLATFORMS.yda = {
|
|
|
1430
1566
|
},
|
|
1431
1567
|
ad(a, r, g, ctx){
|
|
1432
1568
|
const P=PLATFORMS.yda, p=ctx.project;
|
|
1569
|
+
/* adType は「広告タイプ」列から決まる (レスポンシブ→RESPONSIVE_AD / バナー→BANNER_AD)。
|
|
1570
|
+
backend/CLI は **省略時 RESPONSIVE_AD 扱い** なので、明示しても旧プランと矛盾しない。 */
|
|
1433
1571
|
const ad={
|
|
1434
1572
|
adName:r.adName,
|
|
1435
1573
|
status:P.mappers.status(g.deliveryStatus||p.deliveryStatus, P),
|
|
1436
|
-
mediaType:'IMAGE', //
|
|
1437
|
-
|
|
1438
|
-
headlines:String(r.titleText||'')===''?[]:[String(r.titleText)],
|
|
1439
|
-
descriptions:String(r.descText||'')===''?[]:[String(r.descText)],
|
|
1440
|
-
principal:p.principal||'', // E-1: 主体者はアカウントJSON由来 (st.project.principal)
|
|
1441
|
-
buttonText:P.mappers.cta(p.cta, P), // CTA(案件設定)→ YDA buttonText enum。入稿ADD必須
|
|
1574
|
+
mediaType:'IMAGE', // 画像広告 (backend ydaAdSchema IMAGE)。バナーもメイン形式は IMAGE
|
|
1575
|
+
adType:r.adType,
|
|
1442
1576
|
finalUrl:r.finalUrl // 実データ準拠 (?ad=paramCode&creative=...)
|
|
1443
1577
|
};
|
|
1578
|
+
/* バナー (BANNER_AD) は headline / description / principal / buttonText / logoMediaId の
|
|
1579
|
+
**API フィールドが存在しない**。送るとエラーになるので出力しない (画像に焼き込む運用)。 */
|
|
1580
|
+
if(!r.isBanner){
|
|
1581
|
+
// 空欄許容: 空文字を要素に持つ配列を送らない (空なら [])
|
|
1582
|
+
ad.headlines=String(r.titleText||'')===''?[]:[String(r.titleText)];
|
|
1583
|
+
ad.descriptions=String(r.descText||'')===''?[]:[String(r.descText)];
|
|
1584
|
+
ad.principal=p.principal||''; // E-1: 主体者はアカウントJSON由来 (st.project.principal)
|
|
1585
|
+
ad.buttonText=P.mappers.cta(p.cta, P); // CTA(案件設定)→ YDA buttonText enum。入稿ADD必須
|
|
1586
|
+
}
|
|
1444
1587
|
if(r.pendingImage){
|
|
1445
1588
|
// 未登録画像: mediaId未確定。管理番号を残し CLI が先にアップロードして解決する
|
|
1446
1589
|
// C6-1: 画像パス列 (カンマ区切り) を imagePaths に展開する。空なら [] のまま (agent/CLI が補完)
|
|
@@ -1962,6 +2105,12 @@ function renderCell(col, item, ri, ci, ctx){
|
|
|
1962
2105
|
if(col.kind==='badge'){ const id=String(row[col.field]||'').trim(); return '<td>'+newOrIdBadge(!id, id)+'</td>'; }
|
|
1963
2106
|
if(col.kind==='derived') return '<td>'+col.derive(row, ctx)+'</td>';
|
|
1964
2107
|
|
|
2108
|
+
/* col.disabled(item, ctx) = **その行ではこの列自体が存在しない** (YDA バナー行のタイトル/説明文)。
|
|
2109
|
+
data-sel を付けないので範囲選択・コピー&ペースト・一括入力の対象からも外れる。
|
|
2110
|
+
列そのものは隠さない (同一シートにレスポンシブ行と混在するため) = セル単位の無効化。 */
|
|
2111
|
+
if(col.disabled && col.disabled(item, ctx))
|
|
2112
|
+
return '<td><span class="robadge celldis"'+(col.disabledTitle?(' title="'+esc(col.disabledTitle)+'"'):'')+'>—</span></td>';
|
|
2113
|
+
|
|
1965
2114
|
if(ro){
|
|
1966
2115
|
/* 既存呼び出し行の read-only 表現は列記述子で明示する:
|
|
1967
2116
|
hideOnExisting = 常に「—」/ roEmptyDash = 値ありは robadge・空は「—」/ roStyle:'input' = readonly入力欄 /
|
|
@@ -2016,6 +2165,21 @@ function renderCell(col, item, ri, ci, ctx){
|
|
|
2016
2165
|
return '<td class="tgtd"><div class="tgpick'+(shown?'':' tgempty')+'" '+sel+' title="'+esc(shown||(label+'(クリックで選択)'))+'">'+disp+'<span class="tgcaret">▾</span></div></td>';
|
|
2017
2166
|
}
|
|
2018
2167
|
|
|
2168
|
+
/* S16: optpick = **単一選択**のドロワーセル。DOM は tgpick と同じ (.tgpick) に揃え、
|
|
2169
|
+
見た目・範囲選択・コピペ・Undo を完全に共通化する (判別用に optpick クラスを併記)。
|
|
2170
|
+
複数選択の tgpick と違い、ドロワーで押した値は「選択範囲 ∩ 同じ列」へ **上書き代入** する。
|
|
2171
|
+
選択肢は列記述子の col.options が正 (媒体固有の語彙をコアに書かない)。 */
|
|
2172
|
+
if(col.kind==='optpick'){
|
|
2173
|
+
const label=col.label||'';
|
|
2174
|
+
const shown=String(val==null?'':val);
|
|
2175
|
+
const disp = shown? '<span class="tgval">'+esc(shown)+'</span>' : '<span class="tgval"><span class="tghint">'+esc(label)+'</span></span>';
|
|
2176
|
+
/* 列幅は **列記述子 (col.width / col.minWidth) が正**。.tgpick の min-width:130px を素通しすると
|
|
2177
|
+
シートが横に膨らみ、右端列が右サイドバーの下に潜り込む (矩形選択が届かなくなる)。 */
|
|
2178
|
+
const w=col.width||col.minWidth||'';
|
|
2179
|
+
const sty=w? ' style="min-width:'+w+'"' : '';
|
|
2180
|
+
return '<td class="tgtd"><div class="tgpick optpick'+(shown?'':' tgempty')+'" '+sel+sty+' title="'+esc((shown||label)+'(クリックで選択)')+'">'+disp+'<span class="tgcaret">▾</span></div></td>';
|
|
2181
|
+
}
|
|
2182
|
+
|
|
2019
2183
|
/* S15: kind:'datetime' = セル内を <input type=date> + <input type=time> の2分割にする。
|
|
2020
2184
|
state は従来どおり1本の文字列 ("YYYY-MM-DD HH:mm" / 時刻が空なら "YYYY-MM-DD") なので、
|
|
2021
2185
|
col.get/col.set は不要でコピペ・一括入力・連番は既存経路のまま動く。
|
|
@@ -2092,7 +2256,8 @@ function renderTree(){
|
|
|
2092
2256
|
html+='<div class="adnode'+(r.errs.length?' hasErr':'')+'" data-ad="'+a.uid+'">';
|
|
2093
2257
|
html+='<span class="dot '+dot+'"></span>';
|
|
2094
2258
|
html+=pf().tree.adBadge(a,r); // S7: クリエイティブ名バッジ (媒体スキーマ)
|
|
2095
|
-
|
|
2259
|
+
const adTitleHtml = (pf().tree.adTitle && pf().tree.adTitle(a,r)) || null; // 媒体側が出し分ける場合のみ差し替える
|
|
2260
|
+
html+='<span class="adttl">'+(adTitleHtml!==null?adTitleHtml:(r.errCodes.includes(EC.TITLE_UNRESOLVED)?'<span style="color:var(--danger)">タイトル未解決</span>':esc(r.titleText||'(未入力)')))+' <span class="sm">'+pf().tree.adSub(a,r)+'</span></span>';
|
|
2096
2261
|
html+='<span class="nodeact"><button class="iconbtn" data-act="ad-dup" data-u="'+a.uid+'" title="複製">⧉</button><button class="iconbtn danger" data-act="ad-del" data-u="'+a.uid+'" title="削除">✕</button></span>';
|
|
2097
2262
|
html+='</div>';
|
|
2098
2263
|
});
|
|
@@ -2320,8 +2485,20 @@ function freqInheritText(){
|
|
|
2320
2485
|
|
|
2321
2486
|
const COLS = {
|
|
2322
2487
|
ads:[
|
|
2323
|
-
|
|
2324
|
-
|
|
2488
|
+
/* 形式 = **画像のサイズ軸** (Card=1200x628 / SQ=1080x1080)。参照表 refs.media[番号].card / .sq を引く。
|
|
2489
|
+
広告タイプ (レスポンシブ/バナー) とは直交する軸で、バナーも同じ画像プールを使う。 */
|
|
2490
|
+
{field:'format', label:'形式', kind:'optpick', options:['Card','SQ'], width:'72px',
|
|
2491
|
+
title:'画像のサイズ。参照表の Card / SQ mediaID を引きます(バナーも同じ画像プールを使います)',
|
|
2492
|
+
coerce:v=>{ const s=String(v).trim().toUpperCase();
|
|
2493
|
+
if(s==='SQ') return 'SQ';
|
|
2494
|
+
return 'Card'; }},
|
|
2495
|
+
/* 広告タイプ = **RESPONSIVE_AD / BANNER_AD の軸**。行単位で切り替えるので同一キャンペーン内に混在できる。
|
|
2496
|
+
バナー行はタイトル/説明文のセルが col.disabled で無効化される (PLATFORMS.yda.isBanner)。 */
|
|
2497
|
+
{field:'adKind', label:'広告タイプ', kind:'optpick', options:['レスポンシブ','バナー'], width:'96px',
|
|
2498
|
+
title:'レスポンシブ = RESPONSIVE_AD / バナー = BANNER_AD(タイトル・説明文・主体者・CTAのAPI項目が無く、画像に焼き込む)',
|
|
2499
|
+
get:row=>row.adKind||'レスポンシブ',
|
|
2500
|
+
coerce:v=>{ const s=String(v).trim();
|
|
2501
|
+
return (s==='バナー'||s.toUpperCase()==='BANNER'||s.toUpperCase()==='BANNER_AD')?'バナー':'レスポンシブ'; }},
|
|
2325
2502
|
{field:'imageNo', label:'画像番号', kind:'refcombo', refTable:'media', drawerType:'media', mediaAxis:'format',
|
|
2326
2503
|
width:'6ch', sequenceable:true, errCodes:[EC.IMAGE_UNRESOLVED],
|
|
2327
2504
|
ghostKey:'media', ddTitle:'登録画像から選択', ghostTitle:r=>'メディアID '+r.mediaId,
|
|
@@ -2331,10 +2508,16 @@ const COLS = {
|
|
|
2331
2508
|
return '<span class="idpill err">#N/A</span>'; }},
|
|
2332
2509
|
{field:'titleNo', label:'タイトル番号', kind:'refcombo', refTable:'titles', drawerType:'titles',
|
|
2333
2510
|
width:'6ch', sequenceable:true, errCodes:[EC.TITLE_UNRESOLVED, EC.TITLE_TOO_LONG],
|
|
2511
|
+
disabled:it=>it.kind==='a' && PLATFORMS.yda.isBanner(it.a),
|
|
2512
|
+
disabledTitle:'バナー広告(BANNER_AD)にはタイトル/説明文のAPI項目がありません(画像に焼き込み)',
|
|
2513
|
+
|
|
2334
2514
|
ghostKey:'title', ddTitle:'タイトル一覧から選択', ghostTitle:r=>r.titleText,
|
|
2335
2515
|
ghost:r=>r.errCodes.includes(EC.TITLE_UNRESOLVED)?'<span class="err">未解決</span>':esc(r.titleText.slice(0,40))},
|
|
2336
2516
|
{field:'descNo', label:'説明文番号', kind:'refcombo', refTable:'descriptions', drawerType:'titles',
|
|
2337
2517
|
width:'6ch', sequenceable:true, errCodes:[EC.DESC_UNRESOLVED],
|
|
2518
|
+
disabled:it=>it.kind==='a' && PLATFORMS.yda.isBanner(it.a),
|
|
2519
|
+
disabledTitle:'バナー広告(BANNER_AD)にはタイトル/説明文のAPI項目がありません(画像に焼き込み)',
|
|
2520
|
+
|
|
2338
2521
|
ghostKey:'desc', ddTitle:'説明文一覧から選択', ghostTitle:r=>r.descText,
|
|
2339
2522
|
ghost:r=>r.errCodes.includes(EC.DESC_UNRESOLVED)?'<span class="err">— 未解決</span>':esc(r.descText.slice(0,44))},
|
|
2340
2523
|
{field:'paramCode', label:'paramCode(グループ)', kind:'mono', width:'120px',
|
|
@@ -2351,14 +2534,18 @@ const COLS = {
|
|
|
2351
2534
|
get:(row,ctx)=>ctx.r?ctx.r.finalUrl:''},
|
|
2352
2535
|
/* C5: 参照表(refs.titles / refs.descriptions)の本文を直接編集する列。
|
|
2353
2536
|
番号未入力のまま本文を書くと空き番号を自動採番して参照表に登録する。 */
|
|
2354
|
-
makeRefTextCol({field:'titleText', label:'タイトル本文', table:'titles', noField:'titleNo', width:'180px'}),
|
|
2355
|
-
|
|
2537
|
+
Object.assign(makeRefTextCol({field:'titleText', label:'タイトル本文', table:'titles', noField:'titleNo', width:'180px'}),
|
|
2538
|
+
{disabled:it=>it.kind==='a' && PLATFORMS.yda.isBanner(it.a),
|
|
2539
|
+
disabledTitle:'バナー広告(BANNER_AD)にはタイトルのAPI項目がありません(画像に焼き込み)'}),
|
|
2540
|
+
Object.assign(makeRefTextCol({field:'descText', label:'説明文本文', table:'descriptions', noField:'descNo', width:'180px'}),
|
|
2541
|
+
{disabled:it=>it.kind==='a' && PLATFORMS.yda.isBanner(it.a),
|
|
2542
|
+
disabledTitle:'バナー広告(BANNER_AD)には説明文のAPI項目がありません(画像に焼き込み)'}),
|
|
2356
2543
|
/* C6-1: 量産時のローカル画像パス担体 (カンマ区切りで複数可)。少数入稿は Skill の画像入稿フローが主導線 */
|
|
2357
2544
|
{field:'imagePath', label:'画像パス', kind:'mono', width:'150px',
|
|
2358
2545
|
title:'ローカル画像の絶対 / ~/… / 計画JSON基準の相対パス。カンマ区切りで複数可。※画像番号が参照表で解決できる行では入稿JSONに載りません(差し替えたいときは画像番号を空に)'},
|
|
2359
2546
|
/* C6: 広告名は自由入力 (空 = 自動生成)。placeholder に自動生成値を出す */
|
|
2360
2547
|
{field:'adName', label:'広告名', kind:'mono', width:'170px',
|
|
2361
|
-
title:'
|
|
2548
|
+
title:'空欄なら自動生成(レスポンシブ: {画像番号}{c|s}-{タイトル番号}L{説明文番号}-{paramCode} / バナー: {画像番号}{c|s}-{paramCode})。手入力すると最終リンク先URLの creative= も変わります',
|
|
2362
2549
|
placeholder:ctx=>ctx.r?ctx.r.autoAdName:''},
|
|
2363
2550
|
/* derived: resolveAd() 由来の読取専用セル。tabCols()/貼付/セル選択の対象外 (末尾固定) */
|
|
2364
2551
|
{field:'adGroupId', label:'CP / AG ID', kind:'derived',
|
|
@@ -2499,7 +2686,7 @@ const META_REF_TABLES = {
|
|
|
2499
2686
|
/* 列記述子 (Meta)。役割・意味は COLS のコメント参照 */
|
|
2500
2687
|
const META_COLS = {
|
|
2501
2688
|
ads:[
|
|
2502
|
-
{field:'format', label:'形式', kind:'
|
|
2689
|
+
{field:'format', label:'形式', kind:'optpick', options:['IMAGE','VIDEO'], minWidth:'70px',
|
|
2503
2690
|
coerce:v=>(String(v).toUpperCase()==='VIDEO'?'VIDEO':'IMAGE')},
|
|
2504
2691
|
{field:'imageNo', label:'クリエイティブ番号', kind:'refcombo', refTable:'media', drawerType:'media', mediaAxis:'format',
|
|
2505
2692
|
width:'6ch', sequenceable:true, errCodes:[EC.IMAGE_UNRESOLVED],
|
|
@@ -2684,7 +2871,6 @@ PLATFORMS.meta = {
|
|
|
2684
2871
|
{key:'urlParams', label:'URLパラメータ', kind:'toggle', fallback:true,
|
|
2685
2872
|
title:'OFF にすると最終リンク先URLに ?ad= / &creative= を付けません(案件単位)'}
|
|
2686
2873
|
],
|
|
2687
|
-
fixedType:[{label:'広告タイプ',value:'単一画像 / 単一動画'}],
|
|
2688
2874
|
|
|
2689
2875
|
targeting:{
|
|
2690
2876
|
tg_gender:{label:'性別', multi:true, allLabel:'すべて', opts:['すべて','男性','女性']},
|
|
@@ -3165,217 +3351,1335 @@ PLATFORMS.meta = {
|
|
|
3165
3351
|
docTabs:[{id:'overview',label:'概要'},{id:'image',label:'画像広告'},
|
|
3166
3352
|
{id:'video',label:'動画広告'},{id:'param',label:'パラメータ'},{id:'guide',label:'運用手順'}]
|
|
3167
3353
|
};
|
|
3168
|
-
/* DOM の data-st ('ads'|'groups'|'campaigns') と正規タブID ('ads'|'adGroups'|'campaigns') を橋渡し */
|
|
3169
|
-
function normTab(tab){ return tab==='groups'?'adGroups':(tab||'ads'); }
|
|
3170
|
-
/* 列記述子配列。引数省略時は現在のシートタブ */
|
|
3171
|
-
/* C8: 列記述子の hidden(project) が true の列は **描画も列index採番もしない**。
|
|
3172
|
-
colIndex / compatColsFor / getVar / setVar / コピペ / 連番 はすべて colDefs を基準に
|
|
3173
|
-
列番号を決めるので、ここで落としておけば列番号のズレが起きない
|
|
3174
|
-
(逆に renderSheetGeneric 側だけで隠すと data-c と setVar の対象がズレる)。 */
|
|
3175
|
-
function colDefs(tab){
|
|
3176
|
-
const S=pf().sheets, list=S[normTab(tab===undefined?sheetTab:tab)] || S.ads;
|
|
3177
|
-
const p=(typeof st!=='undefined' && st) ? st.project : null;
|
|
3178
|
-
return list.filter(c=>!(typeof c.hidden==='function' && c.hidden(p||{})));
|
|
3179
|
-
}
|
|
3180
|
-
/* C8: URLパラメータ OFF の案件では paramCode 列を表に出さない。
|
|
3181
|
-
OFF = 「最終URL は 案件設定の記事URL のみ」なので、paramCode の入力自体が不要になる。 */
|
|
3182
|
-
function urlParamsOff(project){ return !!project && project.urlParams===false; }
|
|
3183
|
-
/* 列番号リテラル撤去用: field → 列index */
|
|
3184
|
-
function colIndex(field, tab){ return colDefs(tab).findIndex(c=>c.field===field); }
|
|
3185
|
-
/* ドロワーで拾った番号を流し込める列index群 (旧 compat=[1] / [2,3] の置換) */
|
|
3186
|
-
function compatColsFor(drawerType, tab){
|
|
3187
|
-
const out=[]; colDefs(tab).forEach((c,i)=>{ if(c.kind==='refcombo' && c.drawerType===drawerType) out.push(i); }); return out;
|
|
3188
|
-
}
|
|
3189
|
-
/* 貼付/コピー/セル選択が扱う列の field 配列。derived (広告名/CP・AG ID) は入力セルを持たないので除外する。
|
|
3190
|
-
derived は常に配列末尾なので、tabCols() の index と colDefs() の index (= data-c) は一致する。 */
|
|
3191
|
-
function tabCols(){ return colDefs().filter(c=>c.kind!=='derived').map(c=>c.field); }
|
|
3192
|
-
/* ---- S5: セル値アクセスの記述子駆動化 ----
|
|
3193
|
-
行の実体 (広告 / 広告グループ / キャンペーン)。scope:'parentGroup' の列だけ所属グループを見る。 */
|
|
3194
|
-
function rowOf(item, col){
|
|
3195
|
-
if(col && col.scope==='parentGroup') return item.g;
|
|
3196
|
-
return item.kind==='c'?item.c:(item.kind==='g'?item.g:item.a);
|
|
3197
|
-
}
|
|
3198
|
-
/* その列の値の持ち主が「既存呼び出し行」か。既存行では readonlyOnExisting / hideOnExisting 列を触らせない。
|
|
3199
|
-
広告自身のフィールドは既存グループ配下でも編集可 (広告の追加・差し替えは許可)。 */
|
|
3200
|
-
function rowIsExisting(item, col){
|
|
3201
|
-
if(col && col.scope==='parentGroup') return isExistingGroup(item.g);
|
|
3202
|
-
if(item.kind==='g') return isExistingGroup(item.g);
|
|
3203
|
-
if(item.kind==='c') return isExistingCampaign(item.c);
|
|
3204
|
-
return false;
|
|
3205
|
-
}
|
|
3206
|
-
/* ---- C1: セル値アクセスの get/set フック ----
|
|
3207
|
-
列記述子に col.get(row, ctx) / col.set(row, val, ctx, item) を足すと、
|
|
3208
|
-
「派生値を持つ列」を derived ではなく **通常の入力列** として宣言できる。
|
|
3209
|
-
copySelText は getVar のみ・pasteGrid は setVar のみを使うので、
|
|
3210
|
-
フックを持つ列はそれだけで コピー&ペーストのラウンドトリップ対象になる。
|
|
3211
|
-
ctx は行コンテキスト {tab, item, project, refs, r(解決結果・遅延)}。
|
|
3212
|
-
renderCell は行で1回作った ctx をそのまま渡すので resolve が列数ぶん走らない。 */
|
|
3213
|
-
function cellCtx(item, tab, ctx){
|
|
3214
|
-
if(ctx) return ctx;
|
|
3215
|
-
const o={tab:normTab(tab===undefined?sheetTab:tab), item, project:st.project, refs:st.refs};
|
|
3216
|
-
if(item && item.kind==='a'){
|
|
3217
|
-
Object.defineProperty(o,'r',{configurable:true, get(){ return resolveAd(item.a, item.g); }});
|
|
3218
|
-
}
|
|
3219
|
-
return o;
|
|
3220
|
-
}
|
|
3221
|
-
function getVar(item,c,tab,ctx){
|
|
3222
|
-
const col=colDefs(tab)[c]; if(!col) return '';
|
|
3223
|
-
const row=rowOf(item,col); if(!row) return '';
|
|
3224
|
-
if(col.get){ const gv=col.get(row, cellCtx(item,tab,ctx)); return gv==null?'':String(gv); }
|
|
3225
|
-
const v=row[col.field];
|
|
3226
|
-
return v==null?'':String(v);
|
|
3227
|
-
}
|
|
3228
|
-
function setVar(item,c,val,tab,ctx){
|
|
3229
|
-
const col=colDefs(tab)[c]; if(!col) return;
|
|
3230
|
-
if(col.kind==='derived') return;
|
|
3231
|
-
if(col.alwaysReadonly) return; /* adGroupId / campaignId */
|
|
3232
|
-
if((col.readonlyOnExisting||col.hideOnExisting) && rowIsExisting(item,col)) return; /* F-2: 既存呼び出し行は基本パラメータ編集不可 */
|
|
3233
|
-
const row=rowOf(item,col); if(!row) return;
|
|
3234
|
-
const v = col.coerce?col.coerce(val):val;
|
|
3235
|
-
if(col.set) col.set(row, v, cellCtx(item,tab,ctx), item);
|
|
3236
|
-
else row[col.field] = v;
|
|
3237
|
-
if(col.onSet) col.onSet(item,row);
|
|
3238
|
-
}
|
|
3239
|
-
/* ---- 範囲選択中の1セル編集を、同じ列の選択行すべてへ一括反映する ----
|
|
3240
|
-
select / text / number など「▼ドロワーを持たない列」の一括入力口。
|
|
3241
|
-
同種の列 = 同じ field(=同じ列)に限る。異なる列へ値を流し込むのは意味が壊れるのでしない。
|
|
3242
|
-
編集したセルが選択範囲の外なら 0 を返し、従来どおり1セルだけの編集になる。 */
|
|
3243
|
-
function bulkFillColumn(tab, field, val, editedUid){
|
|
3244
|
-
if(activeView!=='sheet' || planMode!=='sheet') return 0;
|
|
3245
|
-
if(normTab(sheetTab)!==tab) return 0;
|
|
3246
|
-
const rg=selRange(); if(!rg) return 0;
|
|
3247
|
-
const ci=colIndex(field, tab); if(ci<0 || ci<rg.c0 || ci>rg.c1) return 0;
|
|
3248
|
-
const vis=flatVisible();
|
|
3249
|
-
const uidOf=it=>(it.kind==='a'?it.a.uid:(it.kind==='g'?it.g.uid:it.c.uid));
|
|
3250
|
-
let hit=false;
|
|
3251
|
-
for(let r=rg.r0;r<=rg.r1;r++){ const it=vis[r]; if(it && uidOf(it)===editedUid){ hit=true; break; } }
|
|
3252
|
-
if(!hit) return 0;
|
|
3253
|
-
let n=0;
|
|
3254
|
-
for(let r=rg.r0;r<=rg.r1;r++){ const it=vis[r]; if(!it||uidOf(it)===editedUid) continue; setVar(it, ci, val, tab); n++; }
|
|
3255
|
-
return n;
|
|
3256
|
-
}
|
|
3257
|
-
function bulkFillToast(tab, field, n){
|
|
3258
|
-
if(!n) return;
|
|
3259
|
-
const col=colDefs(tab).find(c=>c.field===field);
|
|
3260
|
-
toast(((col&&col.label)||field)+' を'+(n+1)+'セルに一括入力');
|
|
3261
|
-
}
|
|
3262
|
-
/* 列 field 指定で1セルを書く (change ハンドラを列記述子経由に載せるための入口)。
|
|
3263
|
-
列が見つからなければ false を返し、呼び出し側の旧経路にフォールバックさせる。 */
|
|
3264
|
-
function setVarByField(item, tab, field, val){
|
|
3265
|
-
const cols=colDefs(tab); const ci=cols.findIndex(c=>c.field===field);
|
|
3266
|
-
if(ci<0) return false;
|
|
3267
|
-
setVar(item, ci, val, tab);
|
|
3268
|
-
return true;
|
|
3269
|
-
}
|
|
3270
|
-
function flatVisible(){ const out=[]; if(sheetTab==='groups'){ st.campaigns.forEach(c=>c.adGroups.forEach(g=>out.push({kind:'g',g,c}))); return out; } if(sheetTab==='campaigns'){ st.campaigns.forEach(c=>out.push({kind:'c',c})); return out; } const filter=document.getElementById('sheetFilter').value; st.campaigns.forEach(c=>c.adGroups.forEach(g=>g.ads.forEach(a=>{ const r=resolveAd(a,g); const hasErr=r.errs.length>0; if(filter==='err'&&!hasErr)return; out.push({kind:'a',a,g,c}); }))); return out; }
|
|
3271
|
-
let selA=null, selB=null, dragging=false;
|
|
3272
|
-
function clearSel(){ selA=selB=null; applySelection(); }
|
|
3273
|
-
function selRange(){ if(!selA||!selB) return null; return {r0:Math.min(selA.r,selB.r),r1:Math.max(selA.r,selB.r),c0:Math.min(selA.c,selB.c),c1:Math.max(selA.c,selB.c)}; }
|
|
3274
|
-
function applySelection(){ const host=document.getElementById('sheetHost'); if(!host) return; host.querySelectorAll('.selcell,.selanchor').forEach(el=>el.classList.remove('selcell','selanchor')); host.querySelectorAll('.cellcombo.comboSel').forEach(el=>el.classList.remove('comboSel')); host.querySelectorAll('td.rownum.rowsel').forEach(el=>el.classList.remove('rowsel')); const rg=selRange(); if(!rg){ if(typeof updateSeqBtn==='function') updateSeqBtn(); return; } host.querySelectorAll('[data-sel]').forEach(el=>{ const r=+el.dataset.r, c=+el.dataset.c; if(r>=rg.r0&&r<=rg.r1&&c>=rg.c0&&c<=rg.c1){ el.classList.add('selcell'); const cb=el.closest('.cellcombo'); if(cb) cb.classList.add('comboSel'); if(selA&&r===selA.r&&c===selA.c) el.classList.add('selanchor'); } }); host.querySelectorAll('td.rownum').forEach(el=>{ const r=+el.dataset.rownum; if(r>=rg.r0&&r<=rg.r1) el.classList.add('rowsel'); }); if(typeof updateSeqBtn==='function') updateSeqBtn(); }
|
|
3275
|
-
function copySelText(){ const rg=selRange(); if(!rg) return null; const vis=flatVisible();
|
|
3276
|
-
/* C1: 範囲全セルで getVar → get フック列が resolveAd を呼ぶ。読み取り専用パスなのでメモ化する */
|
|
3277
|
-
return withResolvePass(()=>{ const lines=[]; for(let r=rg.r0;r<=rg.r1;r++){ const it=vis[r]; if(!it) continue; const cells=[]; for(let c=rg.c0;c<=rg.c1;c++) cells.push(getVar(it,c)); lines.push(cells.join('\t')); } return lines.join('\n'); }); }
|
|
3278
|
-
function pasteGrid(txt){
|
|
3279
|
-
const rg=selRange(); if(!rg){ toast('貼付先セルを選択してください'); return; }
|
|
3280
|
-
const rows=txt.replace(/\r/g,'').replace(/\n+$/,'').split('\n').map(l=>l.split('\t'));
|
|
3281
|
-
const vis=flatVisible(); const maxc=tabCols().length-1; if(!vis[rg.r0]){ toast('貼付先がありません'); return; }
|
|
3282
|
-
/* 1セルのコピーを範囲へ貼ったら選択範囲全体へ充填する (スプシ同等のタイル展開)。
|
|
3283
|
-
行を増やさないので既存行の同種セルを一括で埋める用途に限る。 */
|
|
3284
|
-
if(rows.length===1 && rows[0].length===1 && (rg.r1>rg.r0 || rg.c1>rg.c0)){
|
|
3285
|
-
const val=rows[0][0]; let n=0;
|
|
3286
|
-
mutate(()=>{ for(let r=rg.r0;r<=rg.r1;r++){ const it=vis[r]; if(!it) continue;
|
|
3287
|
-
for(let c=rg.c0;c<=Math.min(rg.c1,maxc);c++){ setVar(it,c,val); n++; } } });
|
|
3288
|
-
clearSel(); toast(n+'セルに貼り付け');
|
|
3289
|
-
return;
|
|
3290
|
-
}
|
|
3291
|
-
mutate(()=>{
|
|
3292
|
-
if(sheetTab==='ads'){
|
|
3293
|
-
// B8: 可視行基準に統一 (グループ境界・絞り込み表示を尊重)。不足分は末尾の可視グループに新規広告を追加。
|
|
3294
|
-
const anchor=vis[rg.r0]; let lastGroup=anchor.g;
|
|
3295
|
-
rows.forEach((cols,i)=>{
|
|
3296
|
-
let it=vis[rg.r0+i];
|
|
3297
|
-
if(it){ lastGroup=it.g; }
|
|
3298
|
-
else { const ad=newAd(); lastGroup.ads.push(ad); it={kind:'a',a:ad,g:lastGroup}; }
|
|
3299
|
-
cols.forEach((val,j)=>{ const cc=rg.c0+j; if(cc>maxc) return; setVar(it,cc,val); });
|
|
3300
|
-
});
|
|
3301
|
-
}
|
|
3302
|
-
else { const anchor=vis[rg.r0]; rows.forEach((cols,i)=>{ let it=vis[rg.r0+i]; if(!it){ if(sheetTab==='groups'){ const ng=newAdGroup(anchor.c); anchor.c.adGroups.push(ng); it={kind:'g',g:ng,c:anchor.c}; } else { const nc=makeNewCampaign(); st.campaigns.push(nc); it={kind:'c',c:nc}; } } cols.forEach((val,j)=>{ const cc=rg.c0+j; if(cc>maxc) return; setVar(it,cc,val); }); }); }
|
|
3303
|
-
});
|
|
3304
|
-
clearSel(); toast(rows.length+'行を貼り付け');
|
|
3305
|
-
}
|
|
3306
|
-
function dupSelected(){
|
|
3307
|
-
const rg=selRange(); if(!rg){ toast('複製する行を選択してください'); return; } const vis=flatVisible(); const items=[]; for(let r=rg.r0;r<=rg.r1;r++){ if(vis[r]) items.push(vis[r]); } if(!items.length){ toast('対象なし'); return; }
|
|
3308
|
-
mutate(()=>{ items.forEach(it=>{
|
|
3309
|
-
if(it.kind==='g'){ const ng=JSON.parse(JSON.stringify(it.g)); ng.uid=uid('g'); ng.adGroupId=''; ng.isExisting=false; ng.name=dupGroupName(it.g.name); ng.paramCode=nextParamCode(it.g.paramCode); ng.ads.forEach(a=>{ a.uid=uid('a'); }); const idx=it.c.adGroups.indexOf(it.g); it.c.adGroups.splice(idx+1,0,ng); ng.ads.forEach(renumberAdParamCode); autoResolveGroupIds(ng, it.c); }
|
|
3310
|
-
else if(it.kind==='c'){ const nc=cloneCampaign(it.c,false,false); const idx=st.campaigns.indexOf(it.c); st.campaigns.splice(idx+1,0,nc); }
|
|
3311
|
-
else { const idx=it.g.ads.indexOf(it.a); it.g.ads.splice(idx+1,0,cloneAd(it.a,null)); }
|
|
3312
|
-
}); }); clearSel(); toast(items.length+'件を複製');
|
|
3313
|
-
}
|
|
3314
3354
|
|
|
3315
|
-
/*
|
|
3316
|
-
|
|
3317
|
-
|
|
3318
|
-
|
|
3319
|
-
|
|
3320
|
-
|
|
3321
|
-
|
|
3322
|
-
|
|
3323
|
-
const col=cols[ci]; if(!col.sequenceable) continue;
|
|
3324
|
-
if(rg.c0<=ci && rg.c1>=ci) return {field:col.field, idx:ci, label:col.label,
|
|
3325
|
-
mode:(tab==='adGroups'||col.scope==='parentGroup')?'group':'ad'};
|
|
3326
|
-
}
|
|
3327
|
-
return null;
|
|
3328
|
-
}
|
|
3329
|
-
/* 連番ボタンの表示制御: 連番可能な列(paramCode/画像番号/タイトル番号/説明文番号)を含み対象2件以上で表示 */
|
|
3330
|
-
function updateSeqBtn(){
|
|
3331
|
-
const btn=document.getElementById('btnSeqParam'); if(!btn) return;
|
|
3332
|
-
const rg=selRange(); const sc=seqColInfo(); let show=false;
|
|
3333
|
-
if(rg && sc && rg.r1>rg.r0){
|
|
3334
|
-
const vis=flatVisible();
|
|
3335
|
-
if(sc.mode==='group'){ const seen=new Set(); for(let r=rg.r0;r<=rg.r1;r++){ const it=vis[r]; if(it&&it.g) seen.add(it.g.uid); } show=seen.size>=2; }
|
|
3336
|
-
else { let n=0; for(let r=rg.r0;r<=rg.r1;r++){ const it=vis[r]; if(it&&it.a) n++; } show=n>=2; }
|
|
3337
|
-
}
|
|
3338
|
-
btn.style.display=show?'':'none';
|
|
3339
|
-
}
|
|
3340
|
-
function applySequentialParamCodes(){
|
|
3341
|
-
const rg=selRange(); const sc=seqColInfo();
|
|
3342
|
-
if(!rg||!sc){ toast('連番できる列(paramCode / 画像番号 / タイトル番号 / 説明文番号)のセルを複数選択してください'); return; }
|
|
3343
|
-
const vis=flatVisible();
|
|
3344
|
-
if(sc.mode==='group'){
|
|
3345
|
-
const seen=new Set(); const groups=[];
|
|
3346
|
-
for(let r=rg.r0;r<=rg.r1;r++){ const it=vis[r]; if(!it||!it.g) continue; if(isExistingGroup(it.g)) continue; if(seen.has(it.g.uid)) continue; seen.add(it.g.uid); groups.push({g:it.g, c:it.c}); }
|
|
3347
|
-
if(groups.length<2){ toast('連番対象の広告グループが2件以上必要です'); return; }
|
|
3348
|
-
const m=String(groups[0].g[sc.field]||'').trim().match(/^(.*?)(\d+)\s*$/);
|
|
3349
|
-
if(!m){ toast('基点となる paramCode を先頭セルに入力してください(例 jv_Cac_148)'); return; }
|
|
3350
|
-
const prefix=m[1], startNum=parseInt(m[2],10), width=m[2].length;
|
|
3351
|
-
mutate(()=>{ groups.forEach((pair,i)=>{ if(i===0){ autoResolveGroupIds(pair.g,pair.c); return; } pair.g[sc.field]=prefix+String(startNum+i).padStart(width,'0'); autoResolveGroupIds(pair.g,pair.c); }); });
|
|
3352
|
-
toast((groups.length-1)+'グループに連番を採番しました('+prefix+(startNum+1)+'〜)');
|
|
3353
|
-
} else {
|
|
3354
|
-
const rows=[];
|
|
3355
|
-
for(let r=rg.r0;r<=rg.r1;r++){ const it=vis[r]; if(!it||!it.a) continue; if(isExistingGroup(it.g)) continue; rows.push(it); }
|
|
3356
|
-
if(rows.length<2){ toast('連番対象の広告が2件以上必要です'); return; }
|
|
3357
|
-
const lbl=sc.label; // S3: 列記述子のラベル
|
|
3358
|
-
const m=String(rows[0].a[sc.field]||'').trim().match(/^(.*?)(\d+)\s*$/);
|
|
3359
|
-
if(!m){ toast('基点の'+lbl+'を先頭セルに入力してください(例 954)'); return; }
|
|
3360
|
-
const prefix=m[1], startNum=parseInt(m[2],10), width=m[2].length;
|
|
3361
|
-
mutate(()=>{ rows.forEach((it,i)=>{ if(i===0) return; it.a[sc.field]=prefix+String(startNum+i).padStart(width,'0'); }); });
|
|
3362
|
-
toast((rows.length-1)+'件の'+lbl+'に連番を採番しました('+prefix+(startNum+1)+'〜)');
|
|
3363
|
-
}
|
|
3364
|
-
}
|
|
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 と同じ作り)。
|
|
3365
3363
|
|
|
3366
|
-
|
|
3367
|
-
|
|
3368
|
-
|
|
3369
|
-
|
|
3370
|
-
|
|
3371
|
-
|
|
3372
|
-
|
|
3373
|
-
|
|
3374
|
-
|
|
3375
|
-
|
|
3376
|
-
|
|
3377
|
-
|
|
3378
|
-
|
|
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
|
+
|
|
4466
|
+
/* DOM の data-st ('ads'|'groups'|'campaigns') と正規タブID ('ads'|'adGroups'|'campaigns') を橋渡し */
|
|
4467
|
+
function normTab(tab){ return tab==='groups'?'adGroups':(tab||'ads'); }
|
|
4468
|
+
/* 列記述子配列。引数省略時は現在のシートタブ */
|
|
4469
|
+
/* C8: 列記述子の hidden(project) が true の列は **描画も列index採番もしない**。
|
|
4470
|
+
colIndex / compatColsFor / getVar / setVar / コピペ / 連番 はすべて colDefs を基準に
|
|
4471
|
+
列番号を決めるので、ここで落としておけば列番号のズレが起きない
|
|
4472
|
+
(逆に renderSheetGeneric 側だけで隠すと data-c と setVar の対象がズレる)。 */
|
|
4473
|
+
function colDefs(tab){
|
|
4474
|
+
const S=pf().sheets, list=S[normTab(tab===undefined?sheetTab:tab)] || S.ads;
|
|
4475
|
+
const p=(typeof st!=='undefined' && st) ? st.project : null;
|
|
4476
|
+
return list.filter(c=>!(typeof c.hidden==='function' && c.hidden(p||{})));
|
|
4477
|
+
}
|
|
4478
|
+
/* C8: URLパラメータ OFF の案件では paramCode 列を表に出さない。
|
|
4479
|
+
OFF = 「最終URL は 案件設定の記事URL のみ」なので、paramCode の入力自体が不要になる。 */
|
|
4480
|
+
function urlParamsOff(project){ return !!project && project.urlParams===false; }
|
|
4481
|
+
/* 列番号リテラル撤去用: field → 列index */
|
|
4482
|
+
function colIndex(field, tab){ return colDefs(tab).findIndex(c=>c.field===field); }
|
|
4483
|
+
/* ドロワーで拾った番号を流し込める列index群 (旧 compat=[1] / [2,3] の置換) */
|
|
4484
|
+
function compatColsFor(drawerType, tab){
|
|
4485
|
+
const out=[]; colDefs(tab).forEach((c,i)=>{ if(c.kind==='refcombo' && c.drawerType===drawerType) out.push(i); }); return out;
|
|
4486
|
+
}
|
|
4487
|
+
/* 貼付/コピー/セル選択が扱う列の field 配列。derived (広告名/CP・AG ID) は入力セルを持たないので除外する。
|
|
4488
|
+
derived は常に配列末尾なので、tabCols() の index と colDefs() の index (= data-c) は一致する。 */
|
|
4489
|
+
function tabCols(){ return colDefs().filter(c=>c.kind!=='derived').map(c=>c.field); }
|
|
4490
|
+
/* ---- S5: セル値アクセスの記述子駆動化 ----
|
|
4491
|
+
行の実体 (広告 / 広告グループ / キャンペーン)。scope:'parentGroup' の列だけ所属グループを見る。 */
|
|
4492
|
+
function rowOf(item, col){
|
|
4493
|
+
if(col && col.scope==='parentGroup') return item.g;
|
|
4494
|
+
return item.kind==='c'?item.c:(item.kind==='g'?item.g:item.a);
|
|
4495
|
+
}
|
|
4496
|
+
/* その列の値の持ち主が「既存呼び出し行」か。既存行では readonlyOnExisting / hideOnExisting 列を触らせない。
|
|
4497
|
+
広告自身のフィールドは既存グループ配下でも編集可 (広告の追加・差し替えは許可)。 */
|
|
4498
|
+
function rowIsExisting(item, col){
|
|
4499
|
+
if(col && col.scope==='parentGroup') return isExistingGroup(item.g);
|
|
4500
|
+
if(item.kind==='g') return isExistingGroup(item.g);
|
|
4501
|
+
if(item.kind==='c') return isExistingCampaign(item.c);
|
|
4502
|
+
return false;
|
|
4503
|
+
}
|
|
4504
|
+
/* ---- C1: セル値アクセスの get/set フック ----
|
|
4505
|
+
列記述子に col.get(row, ctx) / col.set(row, val, ctx, item) を足すと、
|
|
4506
|
+
「派生値を持つ列」を derived ではなく **通常の入力列** として宣言できる。
|
|
4507
|
+
copySelText は getVar のみ・pasteGrid は setVar のみを使うので、
|
|
4508
|
+
フックを持つ列はそれだけで コピー&ペーストのラウンドトリップ対象になる。
|
|
4509
|
+
ctx は行コンテキスト {tab, item, project, refs, r(解決結果・遅延)}。
|
|
4510
|
+
renderCell は行で1回作った ctx をそのまま渡すので resolve が列数ぶん走らない。 */
|
|
4511
|
+
function cellCtx(item, tab, ctx){
|
|
4512
|
+
if(ctx) return ctx;
|
|
4513
|
+
const o={tab:normTab(tab===undefined?sheetTab:tab), item, project:st.project, refs:st.refs};
|
|
4514
|
+
if(item && item.kind==='a'){
|
|
4515
|
+
Object.defineProperty(o,'r',{configurable:true, get(){ return resolveAd(item.a, item.g); }});
|
|
4516
|
+
}
|
|
4517
|
+
return o;
|
|
4518
|
+
}
|
|
4519
|
+
function getVar(item,c,tab,ctx){
|
|
4520
|
+
const col=colDefs(tab)[c]; if(!col) return '';
|
|
4521
|
+
if(col.disabled && col.disabled(item, cellCtx(item,tab,ctx))) return ''; /* 無効セルはコピーにも載せない */
|
|
4522
|
+
const row=rowOf(item,col); if(!row) return '';
|
|
4523
|
+
if(col.get){ const gv=col.get(row, cellCtx(item,tab,ctx)); return gv==null?'':String(gv); }
|
|
4524
|
+
const v=row[col.field];
|
|
4525
|
+
return v==null?'':String(v);
|
|
4526
|
+
}
|
|
4527
|
+
function setVar(item,c,val,tab,ctx){
|
|
4528
|
+
const col=colDefs(tab)[c]; if(!col) return;
|
|
4529
|
+
if(col.kind==='derived') return;
|
|
4530
|
+
if(col.alwaysReadonly) return; /* adGroupId / campaignId */
|
|
4531
|
+
if(col.disabled && col.disabled(item, cellCtx(item,tab,ctx))) return; /* バナー行のタイトル/説明文 */
|
|
4532
|
+
if((col.readonlyOnExisting||col.hideOnExisting) && rowIsExisting(item,col)) return; /* F-2: 既存呼び出し行は基本パラメータ編集不可 */
|
|
4533
|
+
const row=rowOf(item,col); if(!row) return;
|
|
4534
|
+
const v = col.coerce?col.coerce(val):val;
|
|
4535
|
+
if(col.set) col.set(row, v, cellCtx(item,tab,ctx), item);
|
|
4536
|
+
else row[col.field] = v;
|
|
4537
|
+
if(col.onSet) col.onSet(item,row);
|
|
4538
|
+
}
|
|
4539
|
+
/* ---- 範囲選択中の1セル編集を、同じ列の選択行すべてへ一括反映する ----
|
|
4540
|
+
select / text / number など「▼ドロワーを持たない列」の一括入力口。
|
|
4541
|
+
同種の列 = 同じ field(=同じ列)に限る。異なる列へ値を流し込むのは意味が壊れるのでしない。
|
|
4542
|
+
編集したセルが選択範囲の外なら 0 を返し、従来どおり1セルだけの編集になる。 */
|
|
4543
|
+
function bulkFillColumn(tab, field, val, editedUid){
|
|
4544
|
+
if(activeView!=='sheet' || planMode!=='sheet') return 0;
|
|
4545
|
+
if(normTab(sheetTab)!==tab) return 0;
|
|
4546
|
+
const rg=selRange(); if(!rg) return 0;
|
|
4547
|
+
const ci=colIndex(field, tab); if(ci<0 || ci<rg.c0 || ci>rg.c1) return 0;
|
|
4548
|
+
const vis=flatVisible();
|
|
4549
|
+
const uidOf=it=>(it.kind==='a'?it.a.uid:(it.kind==='g'?it.g.uid:it.c.uid));
|
|
4550
|
+
let hit=false;
|
|
4551
|
+
for(let r=rg.r0;r<=rg.r1;r++){ const it=vis[r]; if(it && uidOf(it)===editedUid){ hit=true; break; } }
|
|
4552
|
+
if(!hit) return 0;
|
|
4553
|
+
let n=0;
|
|
4554
|
+
for(let r=rg.r0;r<=rg.r1;r++){ const it=vis[r]; if(!it||uidOf(it)===editedUid) continue; setVar(it, ci, val, tab); n++; }
|
|
4555
|
+
return n;
|
|
4556
|
+
}
|
|
4557
|
+
function bulkFillToast(tab, field, n){
|
|
4558
|
+
if(!n) return;
|
|
4559
|
+
const col=colDefs(tab).find(c=>c.field===field);
|
|
4560
|
+
toast(((col&&col.label)||field)+' を'+(n+1)+'セルに一括入力');
|
|
4561
|
+
}
|
|
4562
|
+
/* 列 field 指定で1セルを書く (change ハンドラを列記述子経由に載せるための入口)。
|
|
4563
|
+
列が見つからなければ false を返し、呼び出し側の旧経路にフォールバックさせる。 */
|
|
4564
|
+
function setVarByField(item, tab, field, val){
|
|
4565
|
+
const cols=colDefs(tab); const ci=cols.findIndex(c=>c.field===field);
|
|
4566
|
+
if(ci<0) return false;
|
|
4567
|
+
setVar(item, ci, val, tab);
|
|
4568
|
+
return true;
|
|
4569
|
+
}
|
|
4570
|
+
function flatVisible(){ const out=[]; if(sheetTab==='groups'){ st.campaigns.forEach(c=>c.adGroups.forEach(g=>out.push({kind:'g',g,c}))); return out; } if(sheetTab==='campaigns'){ st.campaigns.forEach(c=>out.push({kind:'c',c})); return out; } const filter=document.getElementById('sheetFilter').value; st.campaigns.forEach(c=>c.adGroups.forEach(g=>g.ads.forEach(a=>{ const r=resolveAd(a,g); const hasErr=r.errs.length>0; if(filter==='err'&&!hasErr)return; out.push({kind:'a',a,g,c}); }))); return out; }
|
|
4571
|
+
let selA=null, selB=null, dragging=false;
|
|
4572
|
+
function clearSel(){ selA=selB=null; applySelection(); }
|
|
4573
|
+
function selRange(){ if(!selA||!selB) return null; return {r0:Math.min(selA.r,selB.r),r1:Math.max(selA.r,selB.r),c0:Math.min(selA.c,selB.c),c1:Math.max(selA.c,selB.c)}; }
|
|
4574
|
+
function applySelection(){ const host=document.getElementById('sheetHost'); if(!host) return; host.querySelectorAll('.selcell,.selanchor').forEach(el=>el.classList.remove('selcell','selanchor')); host.querySelectorAll('.cellcombo.comboSel').forEach(el=>el.classList.remove('comboSel')); host.querySelectorAll('td.rownum.rowsel').forEach(el=>el.classList.remove('rowsel')); const rg=selRange(); if(!rg){ if(typeof updateSeqBtn==='function') updateSeqBtn(); return; } host.querySelectorAll('[data-sel]').forEach(el=>{ const r=+el.dataset.r, c=+el.dataset.c; if(r>=rg.r0&&r<=rg.r1&&c>=rg.c0&&c<=rg.c1){ el.classList.add('selcell'); const cb=el.closest('.cellcombo'); if(cb) cb.classList.add('comboSel'); if(selA&&r===selA.r&&c===selA.c) el.classList.add('selanchor'); } }); host.querySelectorAll('td.rownum').forEach(el=>{ const r=+el.dataset.rownum; if(r>=rg.r0&&r<=rg.r1) el.classList.add('rowsel'); }); if(typeof updateSeqBtn==='function') updateSeqBtn();
|
|
4575
|
+
/* S16: optpick ドロワーは「選択範囲 ∩ 同一列」を入力先にする。選択が動いたら
|
|
4576
|
+
✓/− と「入力先: N セル」を描き直さないと、実際に書き換わる範囲と表示が食い違う。 */
|
|
4577
|
+
if(drawerTarget && drawerTarget.type==='opt' && document.getElementById('refDrawer').classList.contains('open')) renderDrawer();
|
|
4578
|
+
}
|
|
4579
|
+
function copySelText(){ const rg=selRange(); if(!rg) return null; const vis=flatVisible();
|
|
4580
|
+
/* C1: 範囲全セルで getVar → get フック列が resolveAd を呼ぶ。読み取り専用パスなのでメモ化する */
|
|
4581
|
+
return withResolvePass(()=>{ const lines=[]; for(let r=rg.r0;r<=rg.r1;r++){ const it=vis[r]; if(!it) continue; const cells=[]; for(let c=rg.c0;c<=rg.c1;c++) cells.push(getVar(it,c)); lines.push(cells.join('\t')); } return lines.join('\n'); }); }
|
|
4582
|
+
function pasteGrid(txt){
|
|
4583
|
+
const rg=selRange(); if(!rg){ toast('貼付先セルを選択してください'); return; }
|
|
4584
|
+
const rows=txt.replace(/\r/g,'').replace(/\n+$/,'').split('\n').map(l=>l.split('\t'));
|
|
4585
|
+
const vis=flatVisible(); const maxc=tabCols().length-1; if(!vis[rg.r0]){ toast('貼付先がありません'); return; }
|
|
4586
|
+
/* 1セルのコピーを範囲へ貼ったら選択範囲全体へ充填する (スプシ同等のタイル展開)。
|
|
4587
|
+
行を増やさないので既存行の同種セルを一括で埋める用途に限る。 */
|
|
4588
|
+
if(rows.length===1 && rows[0].length===1 && (rg.r1>rg.r0 || rg.c1>rg.c0)){
|
|
4589
|
+
const val=rows[0][0]; let n=0;
|
|
4590
|
+
mutate(()=>{ for(let r=rg.r0;r<=rg.r1;r++){ const it=vis[r]; if(!it) continue;
|
|
4591
|
+
for(let c=rg.c0;c<=Math.min(rg.c1,maxc);c++){ setVar(it,c,val); n++; } } });
|
|
4592
|
+
clearSel(); toast(n+'セルに貼り付け');
|
|
4593
|
+
return;
|
|
4594
|
+
}
|
|
4595
|
+
mutate(()=>{
|
|
4596
|
+
if(sheetTab==='ads'){
|
|
4597
|
+
// B8: 可視行基準に統一 (グループ境界・絞り込み表示を尊重)。不足分は末尾の可視グループに新規広告を追加。
|
|
4598
|
+
const anchor=vis[rg.r0]; let lastGroup=anchor.g;
|
|
4599
|
+
rows.forEach((cols,i)=>{
|
|
4600
|
+
let it=vis[rg.r0+i];
|
|
4601
|
+
if(it){ lastGroup=it.g; }
|
|
4602
|
+
else { const ad=newAd(); lastGroup.ads.push(ad); it={kind:'a',a:ad,g:lastGroup}; }
|
|
4603
|
+
cols.forEach((val,j)=>{ const cc=rg.c0+j; if(cc>maxc) return; setVar(it,cc,val); });
|
|
4604
|
+
});
|
|
4605
|
+
}
|
|
4606
|
+
else { const anchor=vis[rg.r0]; rows.forEach((cols,i)=>{ let it=vis[rg.r0+i]; if(!it){ if(sheetTab==='groups'){ const ng=newAdGroup(anchor.c); anchor.c.adGroups.push(ng); it={kind:'g',g:ng,c:anchor.c}; } else { const nc=makeNewCampaign(); st.campaigns.push(nc); it={kind:'c',c:nc}; } } cols.forEach((val,j)=>{ const cc=rg.c0+j; if(cc>maxc) return; setVar(it,cc,val); }); }); }
|
|
4607
|
+
});
|
|
4608
|
+
clearSel(); toast(rows.length+'行を貼り付け');
|
|
4609
|
+
}
|
|
4610
|
+
function dupSelected(){
|
|
4611
|
+
const rg=selRange(); if(!rg){ toast('複製する行を選択してください'); return; } const vis=flatVisible(); const items=[]; for(let r=rg.r0;r<=rg.r1;r++){ if(vis[r]) items.push(vis[r]); } if(!items.length){ toast('対象なし'); return; }
|
|
4612
|
+
mutate(()=>{ items.forEach(it=>{
|
|
4613
|
+
if(it.kind==='g'){ const ng=JSON.parse(JSON.stringify(it.g)); ng.uid=uid('g'); ng.adGroupId=''; ng.isExisting=false; ng.name=dupGroupName(it.g.name); ng.paramCode=nextParamCode(it.g.paramCode); ng.ads.forEach(a=>{ a.uid=uid('a'); }); const idx=it.c.adGroups.indexOf(it.g); it.c.adGroups.splice(idx+1,0,ng); ng.ads.forEach(renumberAdParamCode); autoResolveGroupIds(ng, it.c); }
|
|
4614
|
+
else if(it.kind==='c'){ const nc=cloneCampaign(it.c,false,false); const idx=st.campaigns.indexOf(it.c); st.campaigns.splice(idx+1,0,nc); }
|
|
4615
|
+
else { const idx=it.g.ads.indexOf(it.a); it.g.ads.splice(idx+1,0,cloneAd(it.a,null)); }
|
|
4616
|
+
}); }); clearSel(); toast(items.length+'件を複製');
|
|
4617
|
+
}
|
|
4618
|
+
|
|
4619
|
+
/* ---- A-2: paramCode 連番採番 (複数選択→連番一括入力) ---- */
|
|
4620
|
+
/* 選択範囲が交差する「連番可能な列」を返す。{field, idx, mode:'group'|'ad'}
|
|
4621
|
+
S3: 旧 paramCodeColIndex() は本関数へ吸収。候補列は列記述子の sequenceable から算出する。
|
|
4622
|
+
mode は「値の書き込み先が広告グループか広告か」= 広告グループタブ or scope:'parentGroup' の列なら 'group'。 */
|
|
4623
|
+
function seqColInfo(){
|
|
4624
|
+
const rg=selRange(); if(!rg) return null;
|
|
4625
|
+
const tab=normTab(sheetTab), cols=colDefs(tab);
|
|
4626
|
+
for(let ci=0; ci<cols.length; ci++){
|
|
4627
|
+
const col=cols[ci]; if(!col.sequenceable) continue;
|
|
4628
|
+
if(rg.c0<=ci && rg.c1>=ci) return {field:col.field, idx:ci, label:col.label,
|
|
4629
|
+
mode:(tab==='adGroups'||col.scope==='parentGroup')?'group':'ad'};
|
|
4630
|
+
}
|
|
4631
|
+
return null;
|
|
4632
|
+
}
|
|
4633
|
+
/* 連番ボタンの表示制御: 連番可能な列(paramCode/画像番号/タイトル番号/説明文番号)を含み対象2件以上で表示 */
|
|
4634
|
+
function updateSeqBtn(){
|
|
4635
|
+
const btn=document.getElementById('btnSeqParam'); if(!btn) return;
|
|
4636
|
+
const rg=selRange(); const sc=seqColInfo(); let show=false;
|
|
4637
|
+
if(rg && sc && rg.r1>rg.r0){
|
|
4638
|
+
const vis=flatVisible();
|
|
4639
|
+
if(sc.mode==='group'){ const seen=new Set(); for(let r=rg.r0;r<=rg.r1;r++){ const it=vis[r]; if(it&&it.g) seen.add(it.g.uid); } show=seen.size>=2; }
|
|
4640
|
+
else { let n=0; for(let r=rg.r0;r<=rg.r1;r++){ const it=vis[r]; if(it&&it.a) n++; } show=n>=2; }
|
|
4641
|
+
}
|
|
4642
|
+
btn.style.display=show?'':'none';
|
|
4643
|
+
}
|
|
4644
|
+
function applySequentialParamCodes(){
|
|
4645
|
+
const rg=selRange(); const sc=seqColInfo();
|
|
4646
|
+
if(!rg||!sc){ toast('連番できる列(paramCode / 画像番号 / タイトル番号 / 説明文番号)のセルを複数選択してください'); return; }
|
|
4647
|
+
const vis=flatVisible();
|
|
4648
|
+
if(sc.mode==='group'){
|
|
4649
|
+
const seen=new Set(); const groups=[];
|
|
4650
|
+
for(let r=rg.r0;r<=rg.r1;r++){ const it=vis[r]; if(!it||!it.g) continue; if(isExistingGroup(it.g)) continue; if(seen.has(it.g.uid)) continue; seen.add(it.g.uid); groups.push({g:it.g, c:it.c}); }
|
|
4651
|
+
if(groups.length<2){ toast('連番対象の広告グループが2件以上必要です'); return; }
|
|
4652
|
+
const m=String(groups[0].g[sc.field]||'').trim().match(/^(.*?)(\d+)\s*$/);
|
|
4653
|
+
if(!m){ toast('基点となる paramCode を先頭セルに入力してください(例 jv_Cac_148)'); return; }
|
|
4654
|
+
const prefix=m[1], startNum=parseInt(m[2],10), width=m[2].length;
|
|
4655
|
+
mutate(()=>{ groups.forEach((pair,i)=>{ if(i===0){ autoResolveGroupIds(pair.g,pair.c); return; } pair.g[sc.field]=prefix+String(startNum+i).padStart(width,'0'); autoResolveGroupIds(pair.g,pair.c); }); });
|
|
4656
|
+
toast((groups.length-1)+'グループに連番を採番しました('+prefix+(startNum+1)+'〜)');
|
|
4657
|
+
} else {
|
|
4658
|
+
const rows=[];
|
|
4659
|
+
for(let r=rg.r0;r<=rg.r1;r++){ const it=vis[r]; if(!it||!it.a) continue; if(isExistingGroup(it.g)) continue; rows.push(it); }
|
|
4660
|
+
if(rows.length<2){ toast('連番対象の広告が2件以上必要です'); return; }
|
|
4661
|
+
const lbl=sc.label; // S3: 列記述子のラベル
|
|
4662
|
+
const m=String(rows[0].a[sc.field]||'').trim().match(/^(.*?)(\d+)\s*$/);
|
|
4663
|
+
if(!m){ toast('基点の'+lbl+'を先頭セルに入力してください(例 954)'); return; }
|
|
4664
|
+
const prefix=m[1], startNum=parseInt(m[2],10), width=m[2].length;
|
|
4665
|
+
mutate(()=>{ rows.forEach((it,i)=>{ if(i===0) return; it.a[sc.field]=prefix+String(startNum+i).padStart(width,'0'); }); });
|
|
4666
|
+
toast((rows.length-1)+'件の'+lbl+'に連番を採番しました('+prefix+(startNum+1)+'〜)');
|
|
4667
|
+
}
|
|
4668
|
+
}
|
|
4669
|
+
|
|
4670
|
+
/* ---- ▼ 右スライドサイドバー(一覧から番号を選ぶ) ---- */
|
|
4671
|
+
let drawerTarget=null;
|
|
4672
|
+
/* S3: ▼ドロワーの表示モードは列記述子の drawerType から引く (旧 imageNo?'media':'titles' の三項を撤去) */
|
|
4673
|
+
function drawerTypeForField(f){ const col=colDefs('ads').find(c=>c.field===f); return (col&&col.drawerType)||'titles'; }
|
|
4674
|
+
function openDrawerFor(u,f){
|
|
4675
|
+
if(!u||!f) return;
|
|
4676
|
+
const isOpen = document.getElementById('refDrawer').classList.contains('open');
|
|
4677
|
+
const same = drawerTarget && drawerTarget.u===u && drawerTarget.f===f;
|
|
4678
|
+
if(isOpen && same){ closeDrawer(); return; } // 同じセルを再度押すと閉じる(トグル)
|
|
4679
|
+
drawerTarget={u,f,type:drawerTypeForField(f)};
|
|
4680
|
+
if(!same) document.getElementById('drawerSearch').value='';
|
|
4681
|
+
renderDrawer();
|
|
4682
|
+
document.getElementById('refDrawer').classList.add('open'); // 背景は暗くしない(バックドロップ不使用)
|
|
3379
4683
|
}
|
|
3380
4684
|
function openRefDrawer(btn){ openDrawerFor(btn.dataset.u, btn.dataset.f); }
|
|
3381
4685
|
/* E-3: 既存キャンペーン呼び出しドロワー (アカウントJSON refs.campaigns の一覧) */
|
|
@@ -3473,32 +4777,57 @@ function openTargetingDrawer(gUid, field){
|
|
|
3473
4777
|
renderDrawer();
|
|
3474
4778
|
document.getElementById('refDrawer').classList.add('open');
|
|
3475
4779
|
}
|
|
4780
|
+
/* S16: optpick 列 (広告シートの単一選択セル)。同じセルの再クリックで閉じるトグルは tg と同じ。 */
|
|
4781
|
+
function openOptDrawer(uid, field){
|
|
4782
|
+
const fa=findAd(uid); if(!fa) return;
|
|
4783
|
+
const col=colDefs('ads').find(c=>c.field===field); if(!col) return;
|
|
4784
|
+
const item={kind:'a', a:fa.a, g:fa.g, c:fa.c};
|
|
4785
|
+
if(col.disabled && col.disabled(item, cellCtx(item,'ads'))) return; /* その行に存在しない列は開かない */
|
|
4786
|
+
const isOpen=document.getElementById('refDrawer').classList.contains('open');
|
|
4787
|
+
const same=drawerTarget && drawerTarget.type==='opt' && drawerTarget.uid===uid && drawerTarget.field===field;
|
|
4788
|
+
if(isOpen && same){ closeDrawer(); return; }
|
|
4789
|
+
drawerTarget={type:'opt', uid, field};
|
|
4790
|
+
if(!same) document.getElementById('drawerSearch').value='';
|
|
4791
|
+
renderDrawer();
|
|
4792
|
+
document.getElementById('refDrawer').classList.add('open');
|
|
4793
|
+
}
|
|
3476
4794
|
/* ---- ターゲティング列の一括トグル ----
|
|
3477
4795
|
ドロワーで押した項目を「選択範囲 ∩ 同種の列」の全セルへ ON/OFF 一括適用する。
|
|
3478
4796
|
同種の判定は列記述子から引く (refpick は同じ refTable / tgpick は同じ tgKey)。
|
|
3479
4797
|
選択が無い・対象列に掛かっていない場合はアンカーの1セルだけを返し、従来の単体トグルと同じ挙動になる。 */
|
|
3480
|
-
function
|
|
3481
|
-
const
|
|
3482
|
-
const
|
|
3483
|
-
|
|
3484
|
-
const
|
|
3485
|
-
if(
|
|
3486
|
-
const compat=[]; cols.forEach((c,i)=>{
|
|
3487
|
-
if(base.kind==='refpick'){ if(c.kind==='refpick' && c.refTable===base.refTable) compat.push(i); }
|
|
3488
|
-
else if(c.kind==='tgpick' && c.tgKey===base.tgKey) compat.push(i);
|
|
3489
|
-
});
|
|
4798
|
+
function bulkPickTargets(tab, field, anchorItem, isCompat){
|
|
4799
|
+
const cols=colDefs(tab), ci=colIndex(field, tab), base=cols[ci];
|
|
4800
|
+
const anchor=(anchorItem&&ci>=0)?[{item:anchorItem, ci}]:[];
|
|
4801
|
+
if(!base||!anchorItem) return anchor;
|
|
4802
|
+
const compat=[]; cols.forEach((c,i)=>{ if(isCompat(c, base)) compat.push(i); });
|
|
4803
|
+
if(normTab(sheetTab)!==tab) return anchor; /* 別タブに切り替わっていたら選択範囲は無関係 */
|
|
3490
4804
|
const rg=selRange(); if(!rg) return anchor;
|
|
3491
4805
|
const vis=flatVisible(), out=[];
|
|
3492
4806
|
for(let r=rg.r0;r<=rg.r1;r++){
|
|
3493
|
-
|
|
4807
|
+
/* 行種別はアンカーに合わせる (同一シートに複数 kind の行が並ぶビューがあるため) */
|
|
4808
|
+
const it=vis[r]; if(!it||it.kind!==anchorItem.kind) continue;
|
|
3494
4809
|
for(let c=rg.c0;c<=rg.c1;c++){
|
|
3495
4810
|
if(compat.indexOf(c)<0) continue;
|
|
3496
4811
|
if(rowIsExisting(it, cols[c])) continue; /* F-2: 既存呼び出しAGは編集不可 */
|
|
4812
|
+
if(cols[c].disabled && cols[c].disabled(it, cellCtx(it, tab))) continue; /* その行に存在しない列 (バナー行のタイトル等) */
|
|
3497
4813
|
out.push({item:it, ci:c});
|
|
3498
4814
|
}
|
|
3499
4815
|
}
|
|
3500
4816
|
return out.length?out:anchor;
|
|
3501
4817
|
}
|
|
4818
|
+
function tgBulkTargets(gUid, field){
|
|
4819
|
+
const tab=normTab(sheetTab), fg=findGroup(gUid);
|
|
4820
|
+
return bulkPickTargets(tab, field, fg?{kind:'g', g:fg.g, c:fg.c}:null, (c,base)=>
|
|
4821
|
+
base.kind==='refpick' ? (c.kind==='refpick' && c.refTable===base.refTable)
|
|
4822
|
+
: (c.kind==='tgpick' && c.tgKey===base.tgKey));
|
|
4823
|
+
}
|
|
4824
|
+
/* optpick の同種列は **同一 field のみ**。形式(Card/SQ) と 広告タイプ(レスポンシブ/バナー) は
|
|
4825
|
+
選択肢が排他なので、まとめて一括対象にしてはいけない。 */
|
|
4826
|
+
function optBulkTargets(uid, field){
|
|
4827
|
+
const fa=findAd(uid);
|
|
4828
|
+
return bulkPickTargets('ads', field, fa?{kind:'a', a:fa.a, g:fa.g, c:fa.c}:null,
|
|
4829
|
+
(c,base)=> c.kind==='optpick' && c.field===base.field);
|
|
4830
|
+
}
|
|
3502
4831
|
function tgListOf(t){ return String(getVar(t.item, t.ci)||'').split(',').map(s=>s.trim()).filter(Boolean); }
|
|
3503
4832
|
function drawerTgToggle(val){
|
|
3504
4833
|
if(!drawerTarget||drawerTarget.type!=='tg') return;
|
|
@@ -3514,6 +4843,16 @@ function drawerTgToggle(val){
|
|
|
3514
4843
|
if(targets.length>1) toast((allOn?'解除':'追加')+':'+targets.length+'セルに一括適用');
|
|
3515
4844
|
renderDrawer();
|
|
3516
4845
|
}
|
|
4846
|
+
/* optpick は単一選択なので **トグルではなく上書き代入**。値は必ず setVar 経由 (col.coerce/set を通す)。
|
|
4847
|
+
選択後もドロワーは閉じない (tgpick と同じ操作感。範囲選択したまま値を試せる)。 */
|
|
4848
|
+
function drawerOptPick(val){
|
|
4849
|
+
if(!drawerTarget||drawerTarget.type!=='opt') return;
|
|
4850
|
+
const targets=optBulkTargets(drawerTarget.uid, drawerTarget.field);
|
|
4851
|
+
if(!targets.length) return;
|
|
4852
|
+
mutate(()=>{ targets.forEach(t=> setVar(t.item, t.ci, val, 'ads')); });
|
|
4853
|
+
if(targets.length>1) toast('「'+(val||'(既定)')+'」を'+targets.length+'セルに一括適用');
|
|
4854
|
+
renderDrawer();
|
|
4855
|
+
}
|
|
3517
4856
|
function closeDrawer(){ document.getElementById('refDrawer').classList.remove('open'); drawerTarget=null; }
|
|
3518
4857
|
function renderDrawer(){
|
|
3519
4858
|
if(!drawerTarget) return;
|
|
@@ -3586,6 +4925,25 @@ function renderDrawer(){
|
|
|
3586
4925
|
cfg.opts.forEach(o=>{ if(q&&!o.toLowerCase().includes(q)) return; const mk=mark(o); h+='<div class="ddrow'+mk.cls+'" data-tgval="'+esc(o)+'"><span class="ddno">'+mk.ic+'</span><span class="ddtx">'+esc(o)+'</span></div>'; });
|
|
3587
4926
|
listEl.innerHTML=h||'<div class="empty" style="padding:24px">該当なし</div>'; return;
|
|
3588
4927
|
}
|
|
4928
|
+
if(drawerTarget.type==='opt'){
|
|
4929
|
+
const col=colDefs('ads').find(c=>c.field===drawerTarget.field);
|
|
4930
|
+
if(!col){ listEl.innerHTML=''; return; }
|
|
4931
|
+
const targets=optBulkTargets(drawerTarget.uid, drawerTarget.field), N=targets.length;
|
|
4932
|
+
const vals=targets.map(t=>{ const v=getVar(t.item, t.ci, 'ads'); return String(v==null?'':v); });
|
|
4933
|
+
/* 一括対象セル全体で ✓ を判定する: 全セル一致なら ✓ / 一部だけなら −(押すと全セルへ揃える) */
|
|
4934
|
+
const mark=v=>{ const n=vals.reduce((a,x)=>a+(x===v?1:0),0);
|
|
4935
|
+
return n===0?{cls:'', ic:''}:(n===N?{cls:' cur', ic:'✓'}:{cls:' cur', ic:'−'}); };
|
|
4936
|
+
const label=col.label||'';
|
|
4937
|
+
titleEl.textContent=label+' を選択'+(N>1?(' / '+N+'セル一括'):'');
|
|
4938
|
+
tgtEl.textContent = N>1
|
|
4939
|
+
? ('入力先: 選択中の '+N+' セル / '+label+'(押した項目を全セルへ適用)')
|
|
4940
|
+
: ('入力先: '+label+' / 現在 '+(vals[0]||'(既定)'));
|
|
4941
|
+
let h='';
|
|
4942
|
+
(col.options||[]).forEach(o=>{ const t=(o===''?'(既定)':o); if(q && !t.toLowerCase().includes(q)) return;
|
|
4943
|
+
const mk=mark(o);
|
|
4944
|
+
h+='<div class="ddrow'+mk.cls+'" data-optv="'+esc(o)+'"><span class="ddno">'+mk.ic+'</span><span class="ddtx">'+esc(t)+'</span></div>'; });
|
|
4945
|
+
listEl.innerHTML=h||'<div class="empty" style="padding:24px">該当なし</div>'; return;
|
|
4946
|
+
}
|
|
3589
4947
|
/* S7: 入力先の見出しは列記述子のラベル (媒体ごとの列名をここに書かない) */
|
|
3590
4948
|
const dcol=colDefs('ads').find(c=>c.field===drawerTarget.f); const fieldName=dcol?dcol.label:'';
|
|
3591
4949
|
let curNo=''; const fa=findAd(drawerTarget.u); if(fa) curNo=String(fa.a[drawerTarget.f]||'');
|
|
@@ -4621,7 +5979,9 @@ async function openPlanFile(){
|
|
|
4621
5979
|
WIRING
|
|
4622
5980
|
============================================================ */
|
|
4623
5981
|
document.getElementById('viewSeg').addEventListener('click',e=>{ const b=e.target.closest('button[data-v]'); if(!b) return; clearSel(); closeDrawer(); activeView=b.dataset.v; document.querySelectorAll('#viewSeg button').forEach(x=>x.classList.toggle('active',x===b)); document.querySelectorAll('.view').forEach(v=>v.classList.toggle('active', v.id==='view-'+activeView)); render(); });
|
|
4624
|
-
|
|
5982
|
+
/* 選択を解除したらドロワーも閉じる。閉じないと「N セル一括」の表示が残ったまま
|
|
5983
|
+
次の操作はアンカー1セルだけに当たる (viewSeg / modeSeg と同じ扱いに揃える)。 */
|
|
5984
|
+
document.getElementById('sheetFilter').addEventListener('change',()=>{ clearSel(); closeDrawer(); renderSheet(); });
|
|
4625
5985
|
/* プランビュー: シート / ツリー モード切替 (1階層下) */
|
|
4626
5986
|
document.getElementById('modeSeg').addEventListener('click',e=>{ const b=e.target.closest('button[data-m]'); if(!b) return; clearSel(); closeDrawer(); planMode=b.dataset.m; document.querySelectorAll('#modeSeg button').forEach(x=>x.classList.toggle('active',x===b)); render(); });
|
|
4627
5987
|
/* S11: 媒体セレクタ (#pfSeg) / 媒体要件タブ (#mediaTabs) は廃止。媒体切替はヘッダーの統合プルダウンに一本化 */
|
|
@@ -4727,7 +6087,7 @@ sheetHostEl.addEventListener('mousedown',e=>{
|
|
|
4727
6087
|
pendingDrawer=null; dragMoved=false;
|
|
4728
6088
|
// S3: 旧 [1,2,3] リテラルを撤去。列記述子の kind で「▼参照ドロワー列」「ターゲティング列」を判定する
|
|
4729
6089
|
const col=colDefs()[c];
|
|
4730
|
-
if(e.shiftKey && selA){ selB={r,c}; e.preventDefault(); } else { selA={r,c}; selB={r,c}; dragging='cell'; if(col && col.kind==='refcombo') pendingDrawer={kind:'ad', u:cell.dataset.u, f:cell.dataset.f}; else if(col && (col.kind==='tgpick'||col.kind==='refpick') && cell.dataset.gf) pendingDrawer={kind:'tg', gu:cell.dataset.gu, gf:cell.dataset.gf}; }
|
|
6090
|
+
if(e.shiftKey && selA){ selB={r,c}; e.preventDefault(); } else { selA={r,c}; selB={r,c}; dragging='cell'; if(col && col.kind==='refcombo') pendingDrawer={kind:'ad', u:cell.dataset.u, f:cell.dataset.f}; else if(col && (col.kind==='tgpick'||col.kind==='refpick') && cell.dataset.gf) pendingDrawer={kind:'tg', gu:cell.dataset.gu, gf:cell.dataset.gf}; else if(col && col.kind==='optpick' && cell.dataset.f) pendingDrawer={kind:'opt', u:cell.dataset.u, f:cell.dataset.f}; }
|
|
4731
6091
|
applySelection();
|
|
4732
6092
|
});
|
|
4733
6093
|
sheetHostEl.addEventListener('mouseover',e=>{
|
|
@@ -4751,6 +6111,7 @@ document.getElementById('drawerBackdrop').addEventListener('click',closeDrawer);
|
|
|
4751
6111
|
document.getElementById('drawerSearch').addEventListener('input',renderDrawer);
|
|
4752
6112
|
document.getElementById('drawerList').addEventListener('click',e=>{ const row=e.target.closest('.ddrow'); if(!row) return; if(!drawerTarget) return;
|
|
4753
6113
|
if(drawerTarget.type==='tg'){ drawerTgToggle(row.dataset.tgval); }
|
|
6114
|
+
else if(drawerTarget.type==='opt'){ if('optv' in row.dataset) drawerOptPick(row.dataset.optv); }
|
|
4754
6115
|
else if(drawerTarget.type==='cpref'){ if(row.dataset.cpid) addExistingCampaign(row.dataset.cpid); }
|
|
4755
6116
|
else if(drawerTarget.type==='agref'){ if(row.dataset.agid) addExistingAdGroup(row.dataset.agid); }
|
|
4756
6117
|
else if(drawerTarget.type==='cppick'){ if(row.dataset.cppick){ if(drawerTarget.mode==='addNew') addNewAdGroupToCampaign(row.dataset.cppick); else moveGroupToCampaign(drawerTarget.gUid, row.dataset.cppick); } }
|
|
@@ -4758,7 +6119,7 @@ document.getElementById('drawerList').addEventListener('click',e=>{ const row=e.
|
|
|
4758
6119
|
else { drawerPick(row.dataset.no); }
|
|
4759
6120
|
});
|
|
4760
6121
|
/* 単体クリック/複数ドラッグ選択どちらでも開く(選択範囲全セルに一括適用) */
|
|
4761
|
-
document.addEventListener('mouseup',()=>{ if(pendingDrawer){ if(pendingDrawer.kind==='ad') openDrawerFor(pendingDrawer.u,pendingDrawer.f); else openTargetingDrawer(pendingDrawer.gu,pendingDrawer.gf); } pendingDrawer=null; deferredCollapse=null; if(dragging){ dragging=false; document.body.classList.remove('noselect'); } });
|
|
6122
|
+
document.addEventListener('mouseup',()=>{ if(pendingDrawer){ if(pendingDrawer.kind==='ad') openDrawerFor(pendingDrawer.u,pendingDrawer.f); else if(pendingDrawer.kind==='opt') openOptDrawer(pendingDrawer.u,pendingDrawer.f); else openTargetingDrawer(pendingDrawer.gu,pendingDrawer.gf); } pendingDrawer=null; deferredCollapse=null; if(dragging){ dragging=false; document.body.classList.remove('noselect'); } });
|
|
4762
6123
|
|
|
4763
6124
|
/* ---- コピー / 貼付 ---- */
|
|
4764
6125
|
document.addEventListener('copy',e=>{
|