addashboard-cli 0.3.1 → 0.5.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/README.md +14 -4
- package/dist/commands/ads.js +29 -1
- package/dist/commands/ads.js.map +1 -1
- package/dist/commands/pl.js +67 -0
- package/dist/commands/pl.js.map +1 -0
- package/dist/commands/review.js +62 -0
- package/dist/commands/review.js.map +1 -0
- package/dist/commands/submission.js +106 -14
- package/dist/commands/submission.js.map +1 -1
- package/dist/index.js +59 -3
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/skills/ad-ops/README.md +11 -1
- package/skills/ad-ops/SKILL.md +171 -28
- package/skills/ad-ops/{yda-planner.html → ad-planner.html} +1785 -632
- package/skills/ad-ops//343/202/242/343/202/253/343/202/246/343/203/263/343/203/210/meta/sample_account.json +36 -0
- package/skills/ad-ops//343/202/242/343/202/253/343/202/246/343/203/263/343/203/210/yda/index.json +5 -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.json +58 -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_meta.json +38 -0
- /package/skills/ad-ops//343/202/242/343/202/253/343/202/246/343/203/263/343/203/210/{index.json" → meta/index.json"} +0 -0
- /package/skills/ad-ops//343/202/242/343/202/253/343/202/246/343/203/263/343/203/210/{sample_account.json" → yda/sample_account.json"} +0 -0
|
@@ -40,6 +40,8 @@
|
|
|
40
40
|
.seg{display:inline-flex; background:var(--soft); border:1px solid var(--line); border-radius:8px; padding:2px;}
|
|
41
41
|
.seg button{border:0; background:transparent; padding:6px 14px; font-size:12.5px; font-weight:600; color:var(--muted); border-radius:6px; cursor:pointer;}
|
|
42
42
|
.seg button.active{background:#fff; color:var(--fg); box-shadow:0 1px 2px rgba(16,24,40,.08);}
|
|
43
|
+
/* S9: 媒体セレクタ — 未登録媒体 (PLATFORMS に無い) は disabled 表示。要件ドキュメントは閲覧できる */
|
|
44
|
+
.seg button.pfoff{opacity:.55;}
|
|
43
45
|
.spacer{flex:1;}
|
|
44
46
|
.act{display:inline-flex; align-items:center; gap:6px; font-size:12.5px; font-weight:600; color:var(--fg); background:#fff; border:1px solid var(--line); padding:6px 11px; border-radius:7px; cursor:pointer;}
|
|
45
47
|
.act:hover{background:var(--soft);}
|
|
@@ -294,7 +296,12 @@
|
|
|
294
296
|
</head>
|
|
295
297
|
<body>
|
|
296
298
|
<header class="bar">
|
|
297
|
-
<div class="brand"><span class="logo">YDA入稿プランナー</span><span class="pjname" id="pjLabel">—</span></div>
|
|
299
|
+
<div class="brand"><span class="logo" id="brandLogo">YDA入稿プランナー</span><span class="pjname" id="pjLabel">—</span></div>
|
|
300
|
+
<!-- S10: 媒体セレクタ (ヘッダー常設)。data-pf = PLATFORMS のキー。未登録媒体は pfoff 表示 -->
|
|
301
|
+
<div class="seg" id="pfSeg" title="媒体を切り替える(プランは媒体ごとに別管理)">
|
|
302
|
+
<button data-pf="yda" class="active">YDA</button>
|
|
303
|
+
<button data-pf="meta">Meta</button>
|
|
304
|
+
</div>
|
|
298
305
|
<!-- F-3: アカウント切替はヘッダーのプルダウンに一本化 -->
|
|
299
306
|
<span class="acchdr" title="アカウント切替(+新規追加/フォルダ接続/編集もここから)"><select class="chipin" id="accSelect" style="min-width:12ch"></select></span>
|
|
300
307
|
<div class="seg" id="viewSeg">
|
|
@@ -317,9 +324,10 @@
|
|
|
317
324
|
<!-- ============ 媒体要件ビュー ============ -->
|
|
318
325
|
<section class="view" id="view-req">
|
|
319
326
|
<div class="doc">
|
|
327
|
+
<!-- S9: 媒体セレクタ (要件ドキュメントのタブ兼 媒体切替)。data-pf = PLATFORMS のキー -->
|
|
320
328
|
<div class="seg" id="mediaTabs" style="display:inline-flex; margin-bottom:16px">
|
|
321
|
-
<button data-mt="yda" class="active">YDA</button>
|
|
322
|
-
<button data-mt="meta">Meta広告 <span class="pill amber" style="margin-left:4px">検証中</span></button>
|
|
329
|
+
<button data-mt="yda" data-pf="yda" class="active">YDA</button>
|
|
330
|
+
<button data-mt="meta" data-pf="meta">Meta広告 <span class="pill amber" style="margin-left:4px">検証中</span></button>
|
|
323
331
|
</div>
|
|
324
332
|
|
|
325
333
|
<!-- ======== YDA タブ ======== -->
|
|
@@ -529,20 +537,20 @@ finalUrl = baseURL + 記事slug + "?ad=" + paramCode</div>
|
|
|
529
537
|
</div>
|
|
530
538
|
</div><!-- /mt-yda -->
|
|
531
539
|
|
|
532
|
-
<!-- ======== Meta
|
|
540
|
+
<!-- ======== Meta タブ ======== -->
|
|
533
541
|
<div id="mt-meta" style="display:none">
|
|
534
|
-
<div class="eyebrow">Meta Ads —
|
|
535
|
-
<h1 class="title">Meta 広告入稿 <span class="pill amber" style="vertical-align:middle"
|
|
536
|
-
<p class="sub">2026-07-
|
|
542
|
+
<div class="eyebrow">Meta Ads — 対応範囲・要件一覧</div>
|
|
543
|
+
<h1 class="title">Meta 広告入稿 <span class="pill amber" style="vertical-align:middle">本番PoC前</span></h1>
|
|
544
|
+
<p class="sub">2026-07-30 更新。backend / CLI / プランナーGUI はすべて実装済み(媒体セレクタで切替)。<b>本番アカウントでの入稿 PoC のみ未実施</b>。北さんヒアリング(YDA入稿Skillブラッシュアップ)と backend 実装(metaSubmission.ts)、Meta Marketing API 公式ドキュメント照合にもとづく。</p>
|
|
537
545
|
|
|
538
|
-
<details class="sec" open><summary><span class="num">1</span><span class="stitle">概要 —
|
|
546
|
+
<details class="sec" open><summary><span class="num">1</span><span class="stitle">概要 — 実装状況</span><span class="chev"></span></summary>
|
|
539
547
|
<div class="body">
|
|
540
|
-
<p class="lead"
|
|
548
|
+
<p class="lead">全レイヤ実装済み。残るのは本番アカウントでの実入稿検証(PAUSED で1キャンペーンから)。</p>
|
|
541
549
|
<table class="g"><thead><tr><th>レイヤ</th><th>状態</th><th>内容</th></tr></thead><tbody>
|
|
542
|
-
<tr><td>backend</td><td><span class="pill green">実装済み</span></td><td><code>/api/submission/meta</code> 系ルート + <code>MetaSubmissionConnector</code>(オーディエンス取得 / 既存キャンペーン取得・インポート / キャンペーン作成 /
|
|
543
|
-
<tr><td>CLI</td><td><span class="pill green">実装済み</span></td><td><code>webgram submit --platform meta</code
|
|
544
|
-
<tr><td>プランナーGUI</td><td><span class="pill
|
|
545
|
-
<tr><td
|
|
550
|
+
<tr><td>backend</td><td><span class="pill green">実装済み</span></td><td><code>/api/submission/meta</code> 系ルート + <code>MetaSubmissionConnector</code>(オーディエンス取得 / 既存キャンペーン取得・インポート / キャンペーン作成 / 動画・画像ライブラリ / <code>upload-video</code>・<code>upload-image</code>)。2026-07-30 に公式Docs照合で12件の乖離を修正(API v26.0 集約 / <code>instagram_user_id</code> / <code>promoted_object</code> / JPY 予算 / schema 一本化)</td></tr>
|
|
551
|
+
<tr><td>CLI</td><td><span class="pill green">実装済み</span></td><td><code>webgram submit --platform meta</code>(単発)と <code>webgram submit-plan</code>(入稿計画JSON・dry-run一括検証 → 1CPずつ本実行・進捗書き戻しで再開可)</td></tr>
|
|
552
|
+
<tr><td>プランナーGUI</td><td><span class="pill green">実装済み</span></td><td>ヘッダーの媒体セレクタで Meta に切替。シート(広告 / 広告セット / キャンペーン)・ツリー・参照データ・入稿JSON書き出しに対応。画像・動画の両形式(形式列 IMAGE/VIDEO)</td></tr>
|
|
553
|
+
<tr><td>本番入稿検証</td><td><span class="pill amber">未着手</span></td><td>本番アカウントでの PoC 入稿・運用フロー確立が未実施。<b>PAUSED・1キャンペーン・最小予算</b>から始め、管理画面で目視確認する</td></tr>
|
|
546
554
|
</tbody></table>
|
|
547
555
|
</div></details>
|
|
548
556
|
|
|
@@ -577,18 +585,20 @@ finalUrl = baseURL + 記事slug + "?ad=" + paramCode</div>
|
|
|
577
585
|
<li><b>契約確認</b>:<code>webgram submit --platform meta --json-file fixtures/meta_poc.json</code> を dry-run で実行し、送信ボディの構造を確認する。</li>
|
|
578
586
|
<li><b>既存構造の取り込み</b>:既存キャンペーンのインポート(<code>importCampaign</code>)で、実運用中のキャンペーン設定(7項目)を吸い上げてテンプレ化できるか検証。</li>
|
|
579
587
|
<li><b>動画アップロード</b>:upload-video → 動画ライブラリ反映 → 広告作成の一連を dry-run→少額 PoC で確認。</li>
|
|
580
|
-
<li><b>本番PoC</b
|
|
581
|
-
<li><b>GUI対応判断</b>:PoC の結果を踏まえ、プランナーのMeta対応(媒体切替・Meta用参照データ・入稿JSON書き出し)の要件を確定する。</li>
|
|
588
|
+
<li><b>本番PoC(残作業)</b>:プランナーで1キャンペーンだけ計画 → 入稿JSON書き出し → <code>submit-plan</code> dry-run → <b>配信オフ(PAUSED)・最小予算</b>で本実行 → Ads Manager で目視確認 → 問題なければ配信オン。</li>
|
|
582
589
|
</ol>
|
|
583
590
|
</div></details>
|
|
584
591
|
|
|
585
|
-
<details class="sec" open><summary><span class="num">5</span><span class="stitle"
|
|
592
|
+
<details class="sec" open><summary><span class="num">5</span><span class="stitle">未確定事項・未対応</span><span class="chev"></span></summary>
|
|
586
593
|
<div class="body">
|
|
594
|
+
<p class="lead">命名規則は <b>YDA 踏襲で確定</b>(<code>{番号}{i|v}-{見出し}L{本文}-{paramCode}</code> / URL は <code>?ad=&creative=</code>)。以下が残っている。</p>
|
|
587
595
|
<ul>
|
|
588
596
|
<li>Meta広告フィードバックの所在と最終確認者(タスク確認事項より・未回答)。</li>
|
|
589
|
-
<li>命名規則(YDAの <code>{画像番号}{c|s}-{title}L{desc}-{paramCode}</code> に相当するもの)をMetaでどう定義するか。</li>
|
|
590
597
|
<li>クリエイティブ検証の単位(YDAの「1キャンペーン=1変数」に相当する実験設計)。</li>
|
|
591
|
-
<li
|
|
598
|
+
<li><b>未対応</b>:カルーセル / コレクション広告(1広告=1メディアの形式軸を超えるため)。</li>
|
|
599
|
+
<li><b>未対応</b>:カスタム・類似オーディエンスの ID 解決(列自体を出していない。YDA の <code>tg_audience</code> と同根)。</li>
|
|
600
|
+
<li><b>未対応</b>:地域ターゲティングの細分化(国単位 <code>JP</code> 固定。都道府県・市区町村は未対応)。</li>
|
|
601
|
+
<li><b>未対応</b>:通算予算(<code>lifetimeBudget</code>)。日予算一本に固定している。</li>
|
|
592
602
|
</ul>
|
|
593
603
|
</div></details>
|
|
594
604
|
</div><!-- /mt-meta -->
|
|
@@ -691,14 +701,36 @@ finalUrl = baseURL + 記事slug + "?ad=" + paramCode</div>
|
|
|
691
701
|
YDA入稿プランナー — 単一state / シート・ツリー両ビュー投影
|
|
692
702
|
============================================================ */
|
|
693
703
|
/* 案A: embed廃止。参照データ(refs)・初期プラン・共通設定は外部JSONから起動時に読み込む。
|
|
694
|
-
同梱デフォルト:
|
|
704
|
+
同梱デフォルト: アカウント/<媒体>/index.json の先頭アカウント (refs+principal+projectName+baseArticleUrl) と サンプルプラン.json (project+campaigns)。
|
|
695
705
|
固有名詞はハードコードせず、すべて外部JSON由来にする。 */
|
|
696
706
|
let bundledRefs=null; let bundledPrincipal=''; let bundledProjectName=''; let bundledBaseUrl=''; let samplePlan=null;
|
|
697
707
|
let planMismatchMsg=''; // サンプルプランと現在アカウントの accountId 不一致警告 (持続バナー用)
|
|
698
708
|
function fetchJson(rel){ try{ return fetch(rel,{cache:'no-store'}).then(r=>r.ok?r.json():null).catch(()=>null); }catch(e){ return Promise.resolve(null); } }
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
709
|
+
/* ---- S9: 永続化キー (ad_planner_* / 媒体別) ----
|
|
710
|
+
媒体スキーマ駆動化に伴い、localStorage / IndexedDB のキーを `ad_planner_` 名前空間へ移し、
|
|
711
|
+
プラン state と履歴は媒体別 (`…:<platform>`) に分ける。作成者名だけは媒体共通。
|
|
712
|
+
旧 `yda_planner_*` キーからは起動時に1回だけ片方向コピーする (migrateLegacyLocalStorage)。 */
|
|
713
|
+
const KEY_NS = 'ad_planner_';
|
|
714
|
+
const LEGACY_KEY_NS = 'yda_planner_';
|
|
715
|
+
const AUTHOR_KEY = KEY_NS + 'author';
|
|
716
|
+
const MIGRATED_KEY = KEY_NS + 'migrated_v1';
|
|
717
|
+
function stateKey(){ return KEY_NS + 'state_v1:' + curPf; }
|
|
718
|
+
function histKey(){ return KEY_NS + 'history_v1:' + curPf; }
|
|
719
|
+
function bodyKey(vid){ return KEY_NS + 'histbody_' + curPf + ':' + vid; }
|
|
720
|
+
/* 旧キー → 新キー の片方向コピー (旧キーは消さない: 旧版HTMLと併用しても壊れないように)。 */
|
|
721
|
+
function migrateLegacyLocalStorage(){
|
|
722
|
+
try{
|
|
723
|
+
if(localStorage.getItem(MIGRATED_KEY)) return;
|
|
724
|
+
const copy=(from,to)=>{ try{ const v=localStorage.getItem(from); if(v!=null && localStorage.getItem(to)==null) localStorage.setItem(to,v); }catch(e){} };
|
|
725
|
+
copy(LEGACY_KEY_NS+'state_v1', KEY_NS+'state_v1:yda');
|
|
726
|
+
copy(LEGACY_KEY_NS+'history_v1', KEY_NS+'history_v1:yda');
|
|
727
|
+
copy(LEGACY_KEY_NS+'author', AUTHOR_KEY);
|
|
728
|
+
const legacyBody = LEGACY_KEY_NS+'histbody_';
|
|
729
|
+
const keys=[]; for(let i=0;i<localStorage.length;i++){ const k=localStorage.key(i); if(k && k.indexOf(legacyBody)===0) keys.push(k); }
|
|
730
|
+
keys.forEach(k=>copy(k, KEY_NS+'histbody_yda:'+k.slice(legacyBody.length)));
|
|
731
|
+
localStorage.setItem(MIGRATED_KEY, new Date().toISOString());
|
|
732
|
+
}catch(e){}
|
|
733
|
+
}
|
|
702
734
|
const SCHEMA_VERSION = 3;
|
|
703
735
|
|
|
704
736
|
/* ---- utils ---- */
|
|
@@ -709,46 +741,648 @@ const glen = s => [...String(s==null?'':s)].length;
|
|
|
709
741
|
const today = () => { const d=new Date(); return d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0')+'-'+String(d.getDate()).padStart(2,'0'); };
|
|
710
742
|
function toast(m){ const t=document.getElementById('toast'); t.textContent=m; t.classList.add('show'); clearTimeout(t._h); t._h=setTimeout(()=>t.classList.remove('show'),2200); }
|
|
711
743
|
|
|
744
|
+
/* ---- エラー / 警告の安定コード (媒体非依存の語彙) ----
|
|
745
|
+
S2: UI判定 (セル赤枠 / ghost / ツリーのドット) は日本語文字列ではなくこのコードで行う。
|
|
746
|
+
resolveAd().errs / warns の日本語ラベルは E2E 互換のため据え置き、errCodes / warnCodes を並走させる。 */
|
|
747
|
+
const EC = {
|
|
748
|
+
IMAGE_UNRESOLVED:'IMAGE_UNRESOLVED', TITLE_UNRESOLVED:'TITLE_UNRESOLVED',
|
|
749
|
+
DESC_UNRESOLVED:'DESC_UNRESOLVED', TITLE_TOO_LONG:'TITLE_TOO_LONG',
|
|
750
|
+
DESC_TOO_LONG:'DESC_TOO_LONG', PARAM_CODE_EMPTY:'PARAM_CODE_EMPTY',
|
|
751
|
+
MEDIA_MISSING:'MEDIA_MISSING' /* = pendingImage (warn) */,
|
|
752
|
+
TITLE_LONG_WARN:'TITLE_LONG_WARN', DESC_LONG_WARN:'DESC_LONG_WARN',
|
|
753
|
+
BUDGET_MISSING:'BUDGET_MISSING', PIXEL_MISSING:'PIXEL_MISSING'
|
|
754
|
+
};
|
|
755
|
+
|
|
712
756
|
/* ---- refs (VLOOKUP代替) ----
|
|
713
757
|
B-1: refs は6テーブル (titles/descriptions/media/campaigns/adGroups/groups)。
|
|
714
|
-
|
|
715
|
-
同梱デフォルトアカウントは
|
|
758
|
+
参照データの正は「アカウント/<媒体>/<名前>.json」で、フォルダ接続で差し替える (applyAccountJson)。
|
|
759
|
+
同梱デフォルトアカウントは <account.dir>/index.json の先頭エントリを起動時に読み込んで決める(固有名詞のハードコードなし)。 */
|
|
716
760
|
let BUNDLED_ACCOUNT_ID = '';
|
|
717
761
|
let BUNDLED_ACCOUNT_NAME = '';
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
762
|
+
/* ============================================================
|
|
763
|
+
S7: PLATFORMS — 媒体スキーマ定義レジストリ
|
|
764
|
+
------------------------------------------------------------
|
|
765
|
+
コアは PLATFORMS[curPf] (= pf()) しか見ない。媒体固有の語彙・enum・命名規則・
|
|
766
|
+
既定値・プロンプト文言・入稿ボディ生成はすべてこのオブジェクトに閉じる。
|
|
767
|
+
window.PLATFORMS / window.curPf として公開する (E2E が媒体切替を検査するため
|
|
768
|
+
const/let ではなく var で宣言している)。
|
|
769
|
+
============================================================ */
|
|
770
|
+
var PLATFORMS = {};
|
|
771
|
+
var curPf = 'yda';
|
|
772
|
+
function pf(){ return PLATFORMS[curPf]; }
|
|
773
|
+
/* ---- S9: 媒体セレクタ (#mediaTabs の data-pf ボタン) ----
|
|
774
|
+
未登録媒体 (PLATFORMS に定義が無い) は disabled 表示で、媒体要件ドキュメントの閲覧のみ許す。
|
|
775
|
+
媒体を切り替えると state は当該媒体の既定値で作り直す (プランは媒体をまたげない)。 */
|
|
776
|
+
function showMediaDoc(id){ ['yda','meta'].forEach(k=>{ const el=document.getElementById('mt-'+k); if(el) el.style.display = (k===id?'':'none'); }); }
|
|
777
|
+
function renderPlatformTabs(){
|
|
778
|
+
document.querySelectorAll('#pfSeg button[data-pf], #mediaTabs button[data-pf]').forEach(b=>{
|
|
779
|
+
const id=b.dataset.pf, ok=!!PLATFORMS[id];
|
|
780
|
+
b.classList.toggle('pfoff', !ok); /* 未登録媒体 = 選択不可の見え方 (要件ドキュメントは開ける) */
|
|
781
|
+
b.dataset.pfstate = ok?'ready':'disabled';
|
|
782
|
+
b.title = ok ? (PLATFORMS[id].displayName+' に切り替える') : '準備中(媒体スキーマ未登録): 媒体要件の閲覧のみ';
|
|
783
|
+
b.classList.toggle('active', id===curPf);
|
|
784
|
+
});
|
|
785
|
+
showMediaDoc(curPf);
|
|
786
|
+
renderChrome();
|
|
787
|
+
}
|
|
788
|
+
/* ---- S10: 媒体で変わる UI 文言 (タイトル / ブランド名 / シートタブ / 参照データタブ) ----
|
|
789
|
+
媒体語彙 (「広告グループ」「登録画像」…) を静的HTMLに残さず、媒体スキーマから流し込む。 */
|
|
790
|
+
/* 中間階層のラベル (YDA=広告グループ / Meta=広告セット)。UI 文言はここから供給する */
|
|
791
|
+
function agLabel(){ const P=pf(); return (P&&P.levels&&P.levels.adGroup&&P.levels.adGroup.label)||'広告グループ'; }
|
|
792
|
+
function renderChrome(){
|
|
793
|
+
const P=pf(); if(!P) return;
|
|
794
|
+
try{ document.title = P.brandTitle+' / 媒体要件 — WEBGRAM'; }catch(e){}
|
|
795
|
+
const bl=document.getElementById('brandLogo'); if(bl) bl.textContent=P.brandTitle;
|
|
796
|
+
const stLabel={ads:P.levels.ad.label, groups:P.levels.adGroup.label, campaigns:P.levels.campaign.label};
|
|
797
|
+
document.querySelectorAll('#sheetTabs button[data-st]').forEach(b=>{
|
|
798
|
+
const l=stLabel[b.dataset.st]; if(l) b.textContent=l;
|
|
799
|
+
});
|
|
800
|
+
document.querySelectorAll('#refTabs button[data-rt]').forEach(b=>{
|
|
801
|
+
const T=P.refTables[b.dataset.rt]; if(T) b.textContent=(T.countLabel||T.label);
|
|
802
|
+
});
|
|
803
|
+
renderFixedType();
|
|
804
|
+
}
|
|
805
|
+
/* 固定タイプチップ (変更不可) は媒体定義 pf().fixedType から描画する。
|
|
806
|
+
YDA=レスポンシブ/画像、Meta=単一画像 / 単一動画。コアに媒体語彙を持たない。 */
|
|
807
|
+
function renderFixedType(){
|
|
808
|
+
const host=document.getElementById('planTools'); if(!host) return;
|
|
809
|
+
const list=(pf()&&pf().fixedType)||[];
|
|
810
|
+
if(!list.length){ host.innerHTML=''; host.hidden=true; return; }
|
|
811
|
+
host.hidden=false;
|
|
812
|
+
host.innerHTML='<span class="chip" title="このプランナーは固定タイプです(変更不可)">タイプ'
|
|
813
|
+
+ list.map(f=>'<select class="chipin" disabled title="'+esc(f.label)+'(固定)"><option>'+esc(f.value)+'</option></select>').join('')
|
|
814
|
+
+ '</span>';
|
|
815
|
+
}
|
|
816
|
+
window.switchPlatform=function(id){
|
|
817
|
+
if(!PLATFORMS[id]){ toast('この媒体はまだ選択できません(媒体スキーマ未登録)'); return false; }
|
|
818
|
+
if(id===curPf){ renderPlatformTabs(); return true; }
|
|
819
|
+
curPf=id;
|
|
820
|
+
applyBundle(id); /* S10: 同梱アカウント / サンプルプランは媒体別 */
|
|
821
|
+
refTab=firstRefTabKey(); /* 参照データタブは媒体の宣言順で先頭に戻す */
|
|
822
|
+
st=initialState();
|
|
823
|
+
if(typeof clearSel==='function') clearSel();
|
|
824
|
+
if(typeof closeDrawer==='function') closeDrawer();
|
|
825
|
+
buildDatalists(); render(); renderAccountBar(); renderPlatformTabs();
|
|
826
|
+
fetchAccountList().then(()=>renderAccountBar()).catch(()=>{});
|
|
827
|
+
toast(pf().displayName+' に切り替えました');
|
|
828
|
+
return true;
|
|
829
|
+
};
|
|
830
|
+
/* ラベル配列 → API enum 配列。allLabel (「全てのデバイス」等) を含むか空なら [] = 指定なし。 */
|
|
831
|
+
function mapByEnum(s, table, allLabel){
|
|
832
|
+
const l=tgList(s);
|
|
833
|
+
if(!l.length || (allLabel && l.indexOf(allLabel)>=0)) return [];
|
|
834
|
+
return l.map(x=>table[x]).filter(Boolean);
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
/* ---- S6: 参照テーブル記述子 (REF_TABLES = PLATFORMS.yda.refTables) ----
|
|
838
|
+
refs の各テーブル定義。参照データビューの表・件数チップ・新規登録/編集/削除モーダル・datalist・
|
|
839
|
+
emptyRefs/normalizeRefs/validRefs はすべてこの宣言だけを見る。
|
|
840
|
+
・key/label : st.refs のキー / モーダル見出しに使う名称 ('画像' など)
|
|
841
|
+
・countLabel : #refCount の文言 ('登録画像 633/633件')
|
|
842
|
+
・editable : 新規登録/編集/削除/リセットを許可 (=行末に操作列を出す)
|
|
843
|
+
・copyable : 「他アカウントから文言コピー」対象
|
|
844
|
+
・required : validRefs() の必須テーブル
|
|
845
|
+
・keyKind : 'number' = 数値ソート / 'string' = localeCompare ソート
|
|
846
|
+
・keyLabel/keyWidth : キー列の見出しと幅 (モーダルのキー項目ラベル・警告文にも使う)
|
|
847
|
+
・dropEmpty : 値が空文字のエントリを一覧から除外 (タイトル/説明文)
|
|
848
|
+
・valueFields : 値のフィールド。1件=文言そのものを格納 / 複数=オブジェクトを格納
|
|
849
|
+
・columns : キー列の右に並ぶ表示列 {label,width,cls,style,attr,cell}
|
|
850
|
+
・keyCell : キー列の <td> (媒体固有の見せ方: 画像は sw ピル + new バッジ)
|
|
851
|
+
・optionLabel : datalist の option テキスト
|
|
852
|
+
・lengthLimit : 文字数セルの warn/err 閾値 (null = 判定なし) */
|
|
853
|
+
const REF_TABLES = {
|
|
854
|
+
titles:{ key:'titles', label:'タイトル', countLabel:'タイトル', editable:true, copyable:true, required:true,
|
|
855
|
+
keyKind:'number', keyLabel:'番号', keyWidth:'70px', dropEmpty:true,
|
|
856
|
+
addSub:'番号→文言。タイトル枠は20字目安・35字上限',
|
|
857
|
+
emptyMsg:'該当するタイトルがありません',
|
|
858
|
+
lengthLimit:{warn:20, err:35},
|
|
859
|
+
valueFields:[{k:'text', label:'文言'}],
|
|
860
|
+
keyCell:no=>'<td class="mono">'+esc(no)+'</td>',
|
|
861
|
+
columns:[
|
|
862
|
+
{label:'タイトル', cls:'reftext', attr:(no,v)=>' title="'+esc(v)+'"', cell:(no,v)=>esc(v)},
|
|
863
|
+
{label:'文字数', width:'70px', cls:(no,v)=>'reflen '+refLenCls(REF_TABLES.titles,v), cell:(no,v)=>glen(v)}
|
|
864
|
+
],
|
|
865
|
+
optionLabel:v=>String(v==null?'':v) },
|
|
866
|
+
descriptions:{ key:'descriptions', label:'説明文', countLabel:'説明文', editable:true, copyable:true, required:true,
|
|
867
|
+
keyKind:'number', keyLabel:'番号', keyWidth:'70px', dropEmpty:true,
|
|
868
|
+
addSub:'番号→説明文(長文可)',
|
|
869
|
+
emptyMsg:'該当する説明文がありません',
|
|
870
|
+
/* 現行は説明文に文字数チェックなし (docs の90字上限を err にするのは別フェーズ) */
|
|
871
|
+
lengthLimit:null,
|
|
872
|
+
valueFields:[{k:'text', label:'文言'}],
|
|
873
|
+
keyCell:no=>'<td class="mono">'+esc(no)+'</td>',
|
|
874
|
+
columns:[
|
|
875
|
+
{label:'説明文', cls:'reftext', attr:(no,v)=>' title="'+esc(v)+'"', cell:(no,v)=>esc(v)},
|
|
876
|
+
{label:'文字数', width:'70px', cls:(no,v)=>'reflen '+refLenCls(REF_TABLES.descriptions,v), cell:(no,v)=>glen(v)}
|
|
877
|
+
],
|
|
878
|
+
optionLabel:v=>String(v==null?'':v) },
|
|
879
|
+
media:{ key:'media', label:'画像', countLabel:'登録画像', editable:true, required:true,
|
|
880
|
+
keyKind:'number', keyLabel:'画像番号', keyWidth:'80px',
|
|
881
|
+
addSub:'画像番号→Card/SQ の mediaID。新規作成した画像をメディアに登録',
|
|
882
|
+
emptyMsg:'該当する画像がありません',
|
|
883
|
+
valueFields:[{k:'card', label:'Card mediaID (1200)', editLabel:'Card mediaID'},
|
|
884
|
+
{k:'sq', label:'SQ mediaID (1080)', editLabel:'SQ mediaID'}],
|
|
885
|
+
keyCell:no=>'<td><span class="sw" style="display:inline-flex;width:26px;height:26px;vertical-align:middle">'+esc(no)+'</span> '+newBadge('media',no)+'</td>',
|
|
886
|
+
columns:[
|
|
887
|
+
{label:'Card mediaID (1200)', cls:'mono', cell:(no,v)=>esc(v&&v.card||'—')},
|
|
888
|
+
{label:'SQ mediaID (1080)', cls:'mono', cell:(no,v)=>esc(v&&v.sq||'—')},
|
|
889
|
+
{label:'ファイル名', cls:'mono', style:'color:var(--faint);font-size:11px',
|
|
890
|
+
cell:no=>esc(no)+'-1200.jpg / '+esc(no)+'-1080.jpg'}
|
|
891
|
+
],
|
|
892
|
+
optionLabel:v=>((v&&v.card?'Card':'')+(v&&v.card&&v.sq?'/':'')+(v&&v.sq?'SQ':'')),
|
|
893
|
+
/* ▼ドロワー(一覧)の1行テキスト。媒体ごとの値の見せ方はここだけが持つ */
|
|
894
|
+
drawerLabel:v=>'Card '+esc(v&&v.card||'—')+' / SQ '+esc(v&&v.sq||'—') },
|
|
895
|
+
adGroups:{ key:'adGroups', label:'広告グループ', countLabel:'広告グループ', editable:false, required:false,
|
|
896
|
+
keyKind:'string', keyLabel:'広告グループID', keyWidth:'110px',
|
|
897
|
+
emptyMsg:'既存の広告グループがありません(アカウントJSONの refs.adGroups)',
|
|
898
|
+
valueFields:[{k:'name',label:'名'},{k:'paramCode',label:'paramCode'},{k:'campaignId',label:'キャンペーンID'}],
|
|
899
|
+
keyCell:no=>'<td class="mono">'+esc(no)+'</td>',
|
|
900
|
+
columns:[
|
|
901
|
+
{label:'広告グループ名', cls:'reftext', attr:(no,v)=>' title="'+esc(v&&v.name||'')+'"', cell:(no,v)=>esc(v&&v.name||'')},
|
|
902
|
+
{label:'paramCode', width:'130px', cls:'mono', cell:(no,v)=>esc(v&&v.paramCode||'')},
|
|
903
|
+
{label:'キャンペーンID', width:'110px', cls:'mono', cell:(no,v)=>esc(v&&v.campaignId||'')}
|
|
904
|
+
] },
|
|
905
|
+
campaigns:{ key:'campaigns', label:'キャンペーン', countLabel:'キャンペーン', editable:false, required:false,
|
|
906
|
+
keyKind:'string', keyLabel:'キャンペーンID', keyWidth:'120px',
|
|
907
|
+
emptyMsg:'既存のキャンペーンがありません(アカウントJSONの refs.campaigns)',
|
|
908
|
+
valueFields:[{k:'name',label:'名'}],
|
|
909
|
+
keyCell:no=>'<td class="mono">'+esc(no)+'</td>',
|
|
910
|
+
columns:[
|
|
911
|
+
{label:'キャンペーン名', cls:'reftext', attr:(no,v)=>' title="'+esc(v&&v.name||'')+'"', cell:(no,v)=>esc(v&&v.name||'')}
|
|
912
|
+
] }
|
|
913
|
+
};
|
|
914
|
+
/* ============================================================
|
|
915
|
+
PLATFORMS.yda — YDA (Yahoo!ディスプレイ広告) の媒体スキーマ
|
|
916
|
+
------------------------------------------------------------
|
|
917
|
+
S7/S8: 値・関数はすべて S6 以前のコア実装からそのまま移設したもの (挙動不変)。
|
|
918
|
+
コア側に媒体語彙 (imageNo / GT_RANGE / MORE_DETAIL / paramCode 既定値 …) を残さない。
|
|
919
|
+
============================================================ */
|
|
920
|
+
PLATFORMS.yda = {
|
|
921
|
+
id:'yda', displayName:'YDA', brandTitle:'YDA入稿プランナー',
|
|
922
|
+
|
|
923
|
+
/* ---- account: アカウントJSON (アカウント/yda/<名前>.json) の扱い ---- */
|
|
924
|
+
account:{
|
|
925
|
+
idPattern:/^\d+$/, idHint:'YDA アカウントID(数値)', dir:'アカウント/yda',
|
|
926
|
+
/* アカウントJSON → st.project への写し込み (refs は コア側で normalizeRefs) */
|
|
927
|
+
apply(obj, project){
|
|
928
|
+
project.accountId=String(obj.accountId||'');
|
|
929
|
+
project.accountName=String(obj.name||'');
|
|
930
|
+
/* E-1: 主体者(principal)はアカウントJSON由来。無ければ既存値を維持 */
|
|
931
|
+
if(obj.principal!=null) project.principal=String(obj.principal);
|
|
932
|
+
},
|
|
933
|
+
/* AIプロンプトに埋め込む refs スキーマ例 (媒体固有。ここ1箇所だけで定義する) */
|
|
934
|
+
schemaLines(opts){
|
|
935
|
+
const o=opts||{};
|
|
936
|
+
return ['{',' "accountJsonVersion": 1,',' "accountId": "<ID>",',
|
|
937
|
+
' "name": "'+(o.nameHint||'<アカウント名>')+'",',
|
|
938
|
+
' "principal": "<主体者表記(例: ○○製薬)>",']
|
|
939
|
+
.concat(o.updatedAt===false?[]:[' "updatedAt": "<ISO8601>",'])
|
|
940
|
+
.concat([
|
|
941
|
+
' "refs": {',
|
|
942
|
+
' "titles": {"1": "見出し文言"},',
|
|
943
|
+
' "descriptions": {"1": "説明文文言"},',
|
|
944
|
+
' "media": {"954": {"card": "<mediaId>", "sq": "<mediaId>"}},',
|
|
945
|
+
' "campaigns": {"<campaignId>": {"name": "CP名"}},',
|
|
946
|
+
' "adGroups": {"<adGroupId>": {"name": "AG名", "campaignId": "<campaignId>", "paramCode": "<code>"}},',
|
|
947
|
+
' "groups": {"<paramCode>": {"campaignId": "<campaignId>", "adGroupId": "<adGroupId>", "groupName": "AG名"}}',
|
|
948
|
+
' }','}']);
|
|
949
|
+
}
|
|
950
|
+
},
|
|
951
|
+
|
|
952
|
+
/* ---- 3階層のラベルと入稿計画JSONのキー ---- */
|
|
953
|
+
levels:{
|
|
954
|
+
campaign:{label:'キャンペーン', childKey:'adGroups', planKey:'adGroups'},
|
|
955
|
+
adGroup :{label:'広告グループ', childKey:'ads', planKey:'ads'},
|
|
956
|
+
ad :{label:'広告'}
|
|
957
|
+
},
|
|
958
|
+
budgetLevel:'campaign',
|
|
959
|
+
/* 管理画面 (SPAハッシュルート)。{mcc} は YDA の loginAccountId */
|
|
960
|
+
consoleUrl:'https://ads.yahoo.co.jp/manager/#/{mcc}/{accountId}/list/{resource}',
|
|
961
|
+
consoleMcc:'1002385608',
|
|
962
|
+
|
|
963
|
+
/* ---- 形式(Card/SQ) と クリエイティブ名サフィックス・media 値のキーの対応 ---- */
|
|
964
|
+
mediaFormats:[
|
|
965
|
+
{id:'Card', crSuffix:'c', mediaKey:'card', apiMediaKind:'IMAGE', label:'Card mediaID (1200)'},
|
|
966
|
+
{id:'SQ', crSuffix:'s', mediaKey:'sq', apiMediaKind:'IMAGE', label:'SQ mediaID (1080)'}
|
|
967
|
+
],
|
|
968
|
+
fmtOf(ad){ const F=PLATFORMS.yda.mediaFormats; return F.find(x=>x.id===(ad&&ad.format)) || F[0]; },
|
|
969
|
+
|
|
970
|
+
/* ---- 参照テーブル (S6 の REF_TABLES) ---- */
|
|
971
|
+
refTables:REF_TABLES,
|
|
972
|
+
|
|
973
|
+
/* ---- 共通設定チップ (renderTplChips のループ元) ---- */
|
|
974
|
+
projectFields:[
|
|
975
|
+
{key:'goal', label:'目的', kind:'selectv', fallback:'CONVERSION',
|
|
976
|
+
options:[{v:'CONVERSION',t:'コンバージョン'},{v:'CLICK',t:'クリック'},{v:'BRAND_AWARENESS',t:'ブランド認知'},
|
|
977
|
+
{v:'VIDEO_VIEW',t:'動画再生'},{v:'VIEWABLE_IMPRESSION',t:'vインプ'}]},
|
|
978
|
+
{key:'startDate', label:'開始日', kind:'text', width:'10ch', fallback:''},
|
|
979
|
+
{key:'cta', label:'CTA', kind:'text', width:'7ch'},
|
|
980
|
+
{key:'deliveryStatus', label:'配信', kind:'select', options:['オフ','オン']},
|
|
981
|
+
{key:'attributionDays', label:'計測', kind:'number', width:'3ch', suffix:'日'},
|
|
982
|
+
{key:'displayUrlFormat',label:'表示URL', kind:'text', width:'13ch'},
|
|
983
|
+
{key:'baseArticleUrl', label:'記事URL', kind:'text', width:'16ch'}
|
|
984
|
+
],
|
|
985
|
+
/* プランツールバーの固定プルダウン (変更不可) */
|
|
986
|
+
fixedType:[{label:'広告タイプ',value:'レスポンシブ'},{label:'メイン形式',value:'画像'}],
|
|
987
|
+
|
|
988
|
+
/* ---- ターゲティング列の選択肢 (旧 TG_OPTIONS)。キーは COLS の tgKey ---- */
|
|
989
|
+
targeting:{
|
|
990
|
+
tg_device:{label:'デバイス', multi:true, allLabel:'全てのデバイス',
|
|
991
|
+
opts:['全てのデバイス','PC','スマートフォン','タブレット']},
|
|
992
|
+
tg_gender:{label:'性別', multi:true, opts:['男性','女性','不明']},
|
|
993
|
+
tg_age:{label:'年齢', multi:true, opts:['18-19歳','20-24歳','25-29歳','30-34歳','35-39歳','40-44歳','45-49歳','50-54歳','55-59歳','60-64歳','65-69歳','70歳以上','不明']},
|
|
994
|
+
tg_region:{label:'地域', multi:true, opts:['全国','北海道','青森県','岩手県','宮城県','秋田県','山形県','福島県','茨城県','栃木県','群馬県','埼玉県','千葉県','東京都','神奈川県','新潟県','富山県','石川県','福井県','山梨県','長野県','岐阜県','静岡県','愛知県','三重県','滋賀県','京都府','大阪府','兵庫県','奈良県','和歌山県','鳥取県','島根県','岡山県','広島県','山口県','徳島県','香川県','愛媛県','高知県','福岡県','佐賀県','長崎県','熊本県','大分県','宮崎県','鹿児島県','沖縄県']},
|
|
995
|
+
tg_dayHour:{label:'曜日・時間帯', multi:true, allLabel:'全ての曜日・時間帯',
|
|
996
|
+
opts:['全ての曜日・時間帯','平日','土日祝','月','火','水','木','金','土','日','午前(6-12時)','午後(12-18時)','夜間(18-24時)','深夜(0-6時)']},
|
|
997
|
+
tg_audience:{label:'オーディエンスリスト', multi:true,
|
|
998
|
+
opts:['サイトリターゲティング','類似ユーザー','オーディエンスカテゴリー','サーチキーワード','購買データ','属性・興味関心']}
|
|
999
|
+
},
|
|
1000
|
+
|
|
1001
|
+
/* ---- 画面の日本語ラベル → API enum 変換表 (正: frontend/src/pages/submission/types.ts) ---- */
|
|
1002
|
+
enums:{
|
|
1003
|
+
device:{'スマートフォン':'SMARTPHONE','PC':'DESKTOP','タブレット':'TABLET'},
|
|
1004
|
+
gender:{'男性':'ST_MALE','女性':'ST_FEMALE','不明':'ST_UNKNOWN'},
|
|
1005
|
+
age:{'18-19歳':'GT_RANGE18_19','20-24歳':'GT_RANGE20_24','25-29歳':'GT_RANGE25_29','30-34歳':'GT_RANGE30_34','35-39歳':'GT_RANGE35_39','40-44歳':'GT_RANGE40_44','45-49歳':'GT_RANGE45_49','50-54歳':'GT_RANGE50_54','55-59歳':'GT_RANGE55_59','60-64歳':'GT_RANGE60_64','65-69歳':'GT_RANGE65_69','70歳以上':'GT_RANGE70_UL2','不明':'GT_UNKNOWN2'},
|
|
1006
|
+
day:{'月':'MONDAY','火':'TUESDAY','水':'WEDNESDAY','木':'THURSDAY','金':'FRIDAY','土':'SATURDAY','日':'SUNDAY'},
|
|
1007
|
+
dayGroups:{'平日':['MONDAY','TUESDAY','WEDNESDAY','THURSDAY','FRIDAY'],'土日祝':['SATURDAY','SUNDAY']},
|
|
1008
|
+
// tCPA=コンバージョン数最大化+目標CPA / 最大配信=コンバージョン数最大化(目標なし・要実機確認)
|
|
1009
|
+
bidding:{'tCPA':'MAXIMIZE_CONVERSIONS','最大配信':'MAXIMIZE_CONVERSIONS'},
|
|
1010
|
+
biddingFallback:'MAXIMIZE_CONVERSIONS',
|
|
1011
|
+
/* CTA(案件設定の日本語ラベル) → YDA buttonText enum。入稿ADDでは必須。
|
|
1012
|
+
未知ラベルは MORE_DETAIL(=「詳しくはこちら」) にフォールバック(実データ既定)。 */
|
|
1013
|
+
cta:{'もっとみる':'MORE_DETAIL','詳しくはこちら':'MORE_DETAIL','詳細を見る':'MORE_DETAIL','今すぐ確認':'CONFIRM_NOW','今すぐ申し込む':'APPLY_NOW','購入する':'PURCHASE','今すぐ購入':'PURCHASE_NOW','応募する':'APPLY_HERE','無料体験':'EXPERIENCE','資料請求':'REQUEST_INFO','ダウンロード':'DOWNLOAD','インストール':'INSTALL','予約する':'RESERVE_HERE','今すぐ予約':'RESERVE_NOW','登録する':'REGISTER','今すぐ登録':'REGISTER_NOW','お問い合わせ':'CONTACT_US'},
|
|
1014
|
+
ctaFallback:'MORE_DETAIL',
|
|
1015
|
+
statusOn:'ACTIVE', statusOff:'PAUSED'
|
|
1016
|
+
},
|
|
1017
|
+
|
|
1018
|
+
/* ---- mappers: セル値(日本語ラベルのCSV) → API 値 ---- */
|
|
1019
|
+
mappers:{
|
|
1020
|
+
devices(s, P){ return mapByEnum(s, P.enums.device, P.targeting.tg_device.allLabel); },
|
|
1021
|
+
ages(s, P){ return mapByEnum(s, P.enums.age); },
|
|
1022
|
+
genders(s, P){ return mapByEnum(s, P.enums.gender); },
|
|
1023
|
+
/* 曜日・時間帯セル → {days[], hours{start,end}|null}。「平日」「土日祝」は展開、
|
|
1024
|
+
時間帯(午前(6-12時)等)は startHour/endHour に変換 (複数選択時は最初の1つ) */
|
|
1025
|
+
dayHour(s, P){
|
|
1026
|
+
const l=tgList(s), days=[]; let hours=null;
|
|
1027
|
+
if(!l.length || l.indexOf(P.targeting.tg_dayHour.allLabel)>=0) return {days:[], hours:null};
|
|
1028
|
+
const add=d=>{ if(days.indexOf(d)<0) days.push(d); };
|
|
1029
|
+
l.forEach(x=>{
|
|
1030
|
+
if(P.enums.dayGroups[x]) P.enums.dayGroups[x].forEach(add);
|
|
1031
|
+
else if(P.enums.day[x]) add(P.enums.day[x]);
|
|
1032
|
+
else { const m=x.match(/\((\d+)-(\d+)時\)/); if(m&&!hours) hours={start:+m[1], end:+m[2]}; }
|
|
1033
|
+
});
|
|
1034
|
+
return {days, hours};
|
|
1035
|
+
},
|
|
1036
|
+
status(s, P){ return s==='オン'?P.enums.statusOn:P.enums.statusOff; },
|
|
1037
|
+
cta(label, P){ return P.enums.cta[String(label||'').trim()] || P.enums.ctaFallback; }
|
|
1038
|
+
},
|
|
1039
|
+
|
|
1040
|
+
/* ---- 既定値 ---- */
|
|
1041
|
+
defaults:{
|
|
1042
|
+
/* E-2: 新規CPの予算は固定 12,000 円 */
|
|
1043
|
+
newCampaignBudget:12000,
|
|
1044
|
+
/* 案件名・主体者・ベースURL・accountId は同梱デフォルトアカウント由来なのでコア側 defaultProject() が足す */
|
|
1045
|
+
project:{
|
|
1046
|
+
cta:'もっとみる', adType:'レスポンシブ', mainMediaFormat:'画像',
|
|
1047
|
+
deliveryStatus:'オフ', displayUrlFormat:'ドメインのみ表示する',
|
|
1048
|
+
attributionDays:1, biddingStrategyDefault:'tCPA', tcpaDefault:8500,
|
|
1049
|
+
budgetNew:12000, budgetAsc:50000, goal:'CONVERSION', startDate:''
|
|
1050
|
+
},
|
|
1051
|
+
ad:{format:'Card', imageNo:'', titleNo:'', descNo:'', articleSlug:'', adIdOverride:'', desc2:''},
|
|
1052
|
+
campaign:{name:'新規キャンペーン', campaignId:'', isExisting:false, variable:'', urlNumber:''}
|
|
1053
|
+
},
|
|
1054
|
+
|
|
1055
|
+
/* ---- factories: 行の生成 (リテラル生成をコアから撤去) ---- */
|
|
1056
|
+
factories:{
|
|
1057
|
+
newAd(seed){ return Object.assign({uid:uid('a')}, PLATFORMS.yda.defaults.ad, seed||{}); },
|
|
1058
|
+
newAdGroup(c, i){ const F=PLATFORMS.yda.factories;
|
|
1059
|
+
return {uid:uid('g'), name:'AG'+((i!=null?i:(c?c.adGroups.length:0))+1), paramCode:'', isExisting:false, ads:[F.newAd()]}; },
|
|
1060
|
+
/* E-2: 新規キャンペーン (空のAG1を1つ内包)。isExisting:false = 常に編集可 */
|
|
1061
|
+
newCampaign(seed){ const p=st.project, D=PLATFORMS.yda.defaults, F=PLATFORMS.yda.factories;
|
|
1062
|
+
return Object.assign({uid:uid('c')}, D.campaign, {
|
|
1063
|
+
budget:D.newCampaignBudget, biddingStrategy:p.biddingStrategyDefault||'tCPA', tcpa:p.tcpaDefault,
|
|
1064
|
+
adGroups:[F.newAdGroup(null,0)]
|
|
1065
|
+
}, seed||{}); },
|
|
1066
|
+
/* E-3/F-2: 既存キャンペーン呼び出し (campaignIdを持つ広告追加専用の器)。isExisting:true = 基本パラメータ編集不可 */
|
|
1067
|
+
existingCampaign(cpId, name){ return {uid:uid('c'), name:name!=null?name:'', campaignId:String(cpId),
|
|
1068
|
+
isExisting:true, variable:'', urlNumber:'', budget:null, biddingStrategy:'', tcpa:null, adGroups:[]}; },
|
|
1069
|
+
/* F-6: ID起点で名前・paramCode等を refs.adGroups から自動で埋める */
|
|
1070
|
+
existingAdGroup(agId, meta){ const F=PLATFORMS.yda.factories; const m=meta||{};
|
|
1071
|
+
return {uid:uid('g'), name:m.name||('AG '+agId), paramCode:m.paramCode||'', adGroupId:String(agId),
|
|
1072
|
+
isExisting:true, ads:[F.newAd()]}; }
|
|
1073
|
+
},
|
|
1074
|
+
|
|
1075
|
+
/* ---- naming: クリエイティブ名 / 広告名 / 最終URL / キャンペーン名 ---- */
|
|
1076
|
+
naming:{
|
|
1077
|
+
crName(ad, P){ return String(ad.imageNo||'') + P.fmtOf(ad).crSuffix; },
|
|
1078
|
+
adName(ad, group, crName){ return crName + '-' + (ad.titleNo??'') + 'L' + (ad.descNo??'') + '-' + (group.paramCode||''); },
|
|
1079
|
+
finalUrl(ad, group, adName, project){
|
|
1080
|
+
return (project.baseArticleUrl||'') + (ad.articleSlug||'')
|
|
1081
|
+
+ '?ad=' + (group.paramCode||'')
|
|
1082
|
+
+ '&creative=' + (group.paramCode||'') + '-' + adName;
|
|
1083
|
+
},
|
|
1084
|
+
/* キャンペーン名 自動採番: {空き番号2桁}_{CR名}_{今日} */
|
|
1085
|
+
campaignName(urlNumber, firstAd, P){
|
|
1086
|
+
const cr = firstAd ? (String(firstAd.imageNo||'CR') + P.fmtOf(firstAd).crSuffix) : 'CR';
|
|
1087
|
+
return String(urlNumber) + '_' + cr + '_' + today();
|
|
1088
|
+
}
|
|
1089
|
+
},
|
|
1090
|
+
/* 「クリエイティブをクリア」対象フィールド */
|
|
1091
|
+
creativeFields:['imageNo','titleNo','descNo'],
|
|
1092
|
+
/* AG の 未反映列 (入稿JSONに載らない = tgDropped 警告の対象) */
|
|
1093
|
+
notSubmittedFields:['tg_region','tg_audience','tg_searchKw'],
|
|
1094
|
+
labels:{submitButton:'完成 → 保存して入稿', paramCodeLabel:'paramCode',
|
|
1095
|
+
tgDroppedWarn:'地域/オーディエンス/サーチKW'},
|
|
1096
|
+
|
|
1097
|
+
/* ---- tree ビューの媒体固有バッジ (DOM構造は renderTree 側と一致させる) ---- */
|
|
1098
|
+
tree:{
|
|
1099
|
+
campaignBadges(c, P, p){
|
|
1100
|
+
return '<span class="chip">予算 <b>'+esc((c.budget??P.defaults.newCampaignBudget).toLocaleString())+'</b>円</span>'
|
|
1101
|
+
+ '<span class="chip">'+esc(c.biddingStrategy||p.biddingStrategyDefault)+' <b>'+esc((c.tcpa??p.tcpaDefault))+'</b></span>';
|
|
1102
|
+
},
|
|
1103
|
+
groupBadges(g){ return '<span class="pill mono">【'+esc(g.paramCode||'—')+'】</span>'; },
|
|
1104
|
+
adBadge(a, r){ return '<span class="cbadge">'+esc(a.imageNo||'—')+(a.format||'Card')[0].toLowerCase()+'</span>'; },
|
|
1105
|
+
adSub(a, r){ return esc(a.titleNo)+'L'+esc(a.descNo); }
|
|
1106
|
+
},
|
|
1107
|
+
|
|
1108
|
+
/* ---- S8: 解決 (派生値 / 純関数) ---- */
|
|
1109
|
+
resolve(ad, group, ctx){
|
|
1110
|
+
const P=PLATFORMS.yda, p=ctx.project, refs=ctx.refs;
|
|
1111
|
+
const fmt=P.fmtOf(ad);
|
|
1112
|
+
const crName=P.naming.crName(ad, P);
|
|
1113
|
+
const adName=P.naming.adName(ad, group, crName);
|
|
1114
|
+
const me = refs.media[String(ad.imageNo)];
|
|
1115
|
+
const mediaId = me ? me[fmt.mediaKey] : undefined;
|
|
1116
|
+
const titleText = refs.titles[ad.titleNo];
|
|
1117
|
+
const descText = (refs.descriptions||{})[ad.descNo]; // A-5: 説明文テーブルのみ参照 (titlesへのフォールバックなし)
|
|
1118
|
+
const finalUrl = P.naming.finalUrl(ad, group, adName, p);
|
|
1119
|
+
const errs=[], warns=[], errCodes=[], warnCodes=[];
|
|
1120
|
+
// S2: 日本語ラベル(errs/warns)と安定コード(errCodes/warnCodes)を同順で1:1に積む
|
|
1121
|
+
const pushErr=(label,code)=>{ errs.push(label); errCodes.push(code); };
|
|
1122
|
+
const pushWarn=(label,code)=>{ warns.push(label); warnCodes.push(code); };
|
|
1123
|
+
// 画像を3分類: 番号空=真エラー / 番号あり&refs.mediaに無い=未登録(要アップロード=pendingImage) / 解決OK
|
|
1124
|
+
const imgNoStr=String(ad.imageNo||'').trim();
|
|
1125
|
+
const pendingImage = (imgNoStr!=='' && mediaId===undefined); // アカウントのメディアライブラリに未登録→先にアップロードして新規作成
|
|
1126
|
+
if(imgNoStr==='') pushErr('画像未解決', EC.IMAGE_UNRESOLVED);
|
|
1127
|
+
if(String(ad.titleNo||'')==='' || titleText===undefined) pushErr('タイトル未解決', EC.TITLE_UNRESOLVED);
|
|
1128
|
+
if(String(ad.descNo||'')==='' || descText===undefined) pushErr('説明文未解決', EC.DESC_UNRESOLVED);
|
|
1129
|
+
if(titleText!==undefined && glen(titleText)>35) pushErr('タイトル35字超', EC.TITLE_TOO_LONG);
|
|
1130
|
+
else if(titleText!==undefined && glen(titleText)>20) pushWarn('タイトル20字超', EC.TITLE_LONG_WARN);
|
|
1131
|
+
// B11: paramCode空はエラー(export対象外・URL生成に必須)。F-6: refs.groups 払出判定は廃止。
|
|
1132
|
+
if(String(group.paramCode||'').trim()==='') pushErr('paramCode未入力', EC.PARAM_CODE_EMPTY);
|
|
1133
|
+
if(pendingImage) pushWarn('画像未登録(要アップロード)', EC.MEDIA_MISSING); // errではなくwarn: 新規作成対象として保持
|
|
1134
|
+
// F-6: ID解決は adGroupId ベース。campaignId は idsCell表示用に持たない(親CP参照は呼び出し側)
|
|
1135
|
+
const aid=String(group.adGroupId||'').trim();
|
|
1136
|
+
return {crName, adName, campaignId:'', adGroupId:aid, mediaKind:fmt.apiMediaKind, imageNo:imgNoStr,
|
|
1137
|
+
mediaId:mediaId||'', pendingImage, titleText:titleText===undefined?'':titleText,
|
|
1138
|
+
descText:descText===undefined?'':descText, finalUrl, errs, warns, errCodes, warnCodes, resolvedGroup:!!aid};
|
|
1139
|
+
},
|
|
1140
|
+
|
|
1141
|
+
/* ---- S8: 入稿計画JSON (SubmitPlanFile) の3階層ボディ生成 ---- */
|
|
1142
|
+
buildPlan:{
|
|
1143
|
+
campaign(c, ctx){
|
|
1144
|
+
const P=PLATFORMS.yda, p=ctx.project;
|
|
1145
|
+
const bidLabel=String(c.biddingStrategy||'').trim(); // A-1: キャンペーン個別値のみ (project既定へフォールバックしない)
|
|
1146
|
+
const biddingStrategy=P.enums.bidding[bidLabel]||bidLabel||P.enums.biddingFallback;
|
|
1147
|
+
const cp={
|
|
1148
|
+
name:c.name,
|
|
1149
|
+
goal:p.goal||'CONVERSION',
|
|
1150
|
+
status:P.mappers.status(p.deliveryStatus, P), // E-2: キャンペーンstatus列は撤去 → 案件設定の配信値を使用
|
|
1151
|
+
startDate:p.startDate||today(),
|
|
1152
|
+
budget:Number(c.budget)||0, // A-1: キャンペーン個別値のみ
|
|
1153
|
+
biddingStrategy,
|
|
1154
|
+
adGroups:[]
|
|
1155
|
+
};
|
|
1156
|
+
if(bidLabel==='tCPA'){ cp.targetCpa=Number(c.tcpa)||0; }
|
|
1157
|
+
else if(biddingStrategy==='CPC'){ cp.cpc=Number(c.adGroups[0]&&c.adGroups[0].bid)||0; }
|
|
1158
|
+
const cid=resolveCampaignIdOf(c); if(cid) cp.campaignId=cid; // Phase C: 既存はIDを出力(backendで作成スキップ)。新規はID無し=作成対象
|
|
1159
|
+
return cp;
|
|
1160
|
+
},
|
|
1161
|
+
adGroup(g, c, ctx){
|
|
1162
|
+
const P=PLATFORMS.yda, p=ctx.project;
|
|
1163
|
+
const dh=P.mappers.dayHour(g.tg_dayHour, P);
|
|
1164
|
+
const ag={
|
|
1165
|
+
name:g.name,
|
|
1166
|
+
status:P.mappers.status(g.deliveryStatus||p.deliveryStatus, P),
|
|
1167
|
+
smartTargeting:false, // GUI未対応 (将来ターゲティング列に追加)
|
|
1168
|
+
devices:P.mappers.devices(g.tg_device, P),
|
|
1169
|
+
ages:P.mappers.ages(g.tg_age, P),
|
|
1170
|
+
genders:P.mappers.genders(g.tg_gender, P),
|
|
1171
|
+
days:dh.days,
|
|
1172
|
+
placementsInclude:[], placementsExclude:[],
|
|
1173
|
+
audiencesInclude:[], audiencesExclude:[],
|
|
1174
|
+
ads:[]
|
|
1175
|
+
};
|
|
1176
|
+
if(dh.hours){ ag.startHour=dh.hours.start; ag.endHour=dh.hours.end; }
|
|
1177
|
+
const aid=resolveAdGroupIdOf(g); if(aid) ag.adGroupId=aid; // Phase C: 既存はIDを出力(backendで作成スキップ)。新規はID無し=作成対象
|
|
1178
|
+
return ag;
|
|
1179
|
+
},
|
|
1180
|
+
ad(a, r, g, ctx){
|
|
1181
|
+
const P=PLATFORMS.yda, p=ctx.project;
|
|
1182
|
+
const ad={
|
|
1183
|
+
adName:r.adName,
|
|
1184
|
+
status:P.mappers.status(g.deliveryStatus||p.deliveryStatus, P),
|
|
1185
|
+
mediaType:'IMAGE', // レスポンシブ画像広告 (backend ydaAdSchema IMAGE)
|
|
1186
|
+
headlines:[String(r.titleText)],
|
|
1187
|
+
descriptions:[String(r.descText)],
|
|
1188
|
+
principal:p.principal||'', // E-1: 主体者はアカウントJSON由来 (st.project.principal)
|
|
1189
|
+
buttonText:P.mappers.cta(p.cta, P), // CTA(案件設定)→ YDA buttonText enum。入稿ADD必須
|
|
1190
|
+
finalUrl:r.finalUrl // 実データ準拠 (?ad=paramCode&creative=...)
|
|
1191
|
+
};
|
|
1192
|
+
if(r.pendingImage){
|
|
1193
|
+
// 未登録画像: mediaId未確定。管理番号を残し CLI が先にアップロードして解決する (imagePaths は agent/CLI が補完)
|
|
1194
|
+
ad.mediaIds=[]; ad.imageNo=r.imageNo; ad.pendingImageNo=r.imageNo; ad.imagePaths=[];
|
|
1195
|
+
} else {
|
|
1196
|
+
ad.mediaIds=[String(r.mediaId)];
|
|
1197
|
+
}
|
|
1198
|
+
return ad;
|
|
1199
|
+
}
|
|
1200
|
+
},
|
|
1201
|
+
|
|
1202
|
+
/* ---- 入稿計画ファイル / CLI コマンド ---- */
|
|
1203
|
+
output:{
|
|
1204
|
+
planVersion:1,
|
|
1205
|
+
planDir:'.claude/skills/ad-ops/入稿計画',
|
|
1206
|
+
samplePlanFile:'サンプルプラン.json',
|
|
1207
|
+
fileNamePrefix:p=>(p.name||'plan')+'_入稿計画_',
|
|
1208
|
+
submitCommand:(rel,opts)=>'webgram submit-plan --plan-file "'+rel+'"'+((opts&&opts.dryRun===false)?' --no-dry-run --yes':'')
|
|
1209
|
+
},
|
|
1210
|
+
|
|
1211
|
+
/* ---- Claude Code に貼るプロンプト (媒体固有の文言はここ1箇所) ---- */
|
|
1212
|
+
prompts:{
|
|
1213
|
+
/* アカウント参照データJSONの生成プロンプト。mode: 'ai'(対話で選ぶ) / 'import'(対象確定済) / 'help'(使い方モーダル) */
|
|
1214
|
+
importAccount(ctx){
|
|
1215
|
+
const P=PLATFORMS.yda, c=ctx||{}, mode=c.mode||'help';
|
|
1216
|
+
const name=c.name||'', accId=c.accountId||'';
|
|
1217
|
+
const idArg=accId||'<ID>';
|
|
1218
|
+
const headName = mode==='help' ? '<アカウント名(日本語OK)>' : (name||'<アカウント名>');
|
|
1219
|
+
const fileName = mode==='help' ? '<アカウント名>' : (name||'<アカウント名>');
|
|
1220
|
+
const dir=P.account.dir;
|
|
1221
|
+
const shape=[' - media は 画像番号 → {card, sq}(mediaId)',
|
|
1222
|
+
' - groups は paramCode → {campaignId, adGroupId, groupName} の逆引き',
|
|
1223
|
+
' - campaigns/adGroups は campaignId/adGroupId をキーに'];
|
|
1224
|
+
const lines=[];
|
|
1225
|
+
if(mode==='ai'){
|
|
1226
|
+
lines.push('YDA入稿プランナー用に、新規アカウントの参照データJSONを生成してください。','',
|
|
1227
|
+
'1. まず対象アカウントを対話で決めてください:',
|
|
1228
|
+
' - webgram list --platform yda --resource accounts で候補一覧を取得し、どのアカウントを追加するか私に確認してアカウント名・accountId・主体者表記を確定する',
|
|
1229
|
+
'2. 確定したアカウントについて、登録画像・既存キャンペーン/広告グループを取得:',
|
|
1230
|
+
' webgram list --platform yda --resource media --account-id <ID>',
|
|
1231
|
+
' webgram list --platform yda --resource campaigns --account-id <ID>',
|
|
1232
|
+
' (既存グループ paramCode↔ID の突合は GET /yda/campaign-import も利用可)',
|
|
1233
|
+
'3. 取得結果を下記スキーマに整形し、'+dir+'/<アカウント名>.json として書き出す:');
|
|
1234
|
+
shape.forEach(l=>lines.push(l));
|
|
1235
|
+
} else {
|
|
1236
|
+
lines.push('YDA入稿プランナー用に、アカウント「'+headName+'」'+(mode==='import'&&accId?('(accountId: '+accId+')'):'')+'の参照データJSONを生成してください。','',
|
|
1237
|
+
'手順:',
|
|
1238
|
+
'1. webgram CLI で対象アカウントの情報・登録画像・既存キャンペーン/広告グループを取得:',
|
|
1239
|
+
' webgram list --platform yda --resource accounts',
|
|
1240
|
+
' webgram list --platform yda --resource media --account-id '+idArg,
|
|
1241
|
+
' webgram list --platform yda --resource campaigns --account-id '+idArg);
|
|
1242
|
+
if(mode==='import') lines.push(' (既存グループ paramCode↔ID の突合は GET /yda/campaign-import も利用可)');
|
|
1243
|
+
lines.push('2. 取得結果を下記スキーマに整形して '+dir+'/'+fileName+'.json として書き出す:');
|
|
1244
|
+
shape.forEach(l=>lines.push(l));
|
|
1245
|
+
}
|
|
1246
|
+
lines.push('', 'スキーマ:');
|
|
1247
|
+
P.account.schemaLines({updatedAt:mode!=='help',
|
|
1248
|
+
nameHint:mode==='help'?'<アカウント名(日本語OK)>':'<アカウント名>'}).forEach(l=>lines.push(l));
|
|
1249
|
+
lines.push('');
|
|
1250
|
+
lines.push(mode==='ai' ? '書き出したら、プランナー右上のアカウントプルダウン →「+新規追加 →「📄 ファイルから追加」で その JSON を読み込めます。'
|
|
1251
|
+
: mode==='import' ? '書き出したら、プランナーで「📁 フォルダ」を再接続 or アカウントセレクトを開くと自動で一覧に出ます。'
|
|
1252
|
+
: '書き出したら、プランナーで「+新規追加 →📄 ファイルから追加」or「📁 フォルダ」再接続で一覧に出ます。');
|
|
1253
|
+
return lines.join('\n');
|
|
1254
|
+
},
|
|
1255
|
+
/* 戦略壁打ちを始めるためのプロンプト例(アピール用の即コピー素材) */
|
|
1256
|
+
strategy(){
|
|
1257
|
+
return ['YDA(Yahoo!ディスプレイ広告)の入稿プランを一緒に作りたいです。まず戦略を壁打ちさせてください。','',
|
|
1258
|
+
'# 前提','- アカウント: <アカウント名(日本語OK)>','- 商材 / LP: <商材名・URL>',
|
|
1259
|
+
'- 目的: <コンバージョン獲得 / 認知 など>','- 予算・期間: <月予算 / 開始日>',
|
|
1260
|
+
'- 訴求したいポイント: <強み・オファー・ターゲット層>','',
|
|
1261
|
+
'# お願い','1. 上記をもとにターゲティング方針・キャンペーン/広告グループ構成・訴求軸を提案してください。',
|
|
1262
|
+
'2. 私が「OK」と言ったら、その戦略を ad-ops Skill の「サンプルプラン.json」に書き出して(上書きして)ください。',
|
|
1263
|
+
' - 保存先: .claude/skills/ad-ops/サンプルプラン.json',
|
|
1264
|
+
' - フォーマット: {"schemaVersion":'+SCHEMA_VERSION+', "accountId":"<対象アカウントID>", "campaigns":[ ... ]}',
|
|
1265
|
+
' - campaigns には キャンペーン → 広告グループ(paramCode) → 広告 の構成を入れてください。',
|
|
1266
|
+
'3. 書き出したら、プランナーを開き直す(またはリロードする)と サンプルプラン.json の内容が自動で表示されます。'].join('\n');
|
|
1267
|
+
},
|
|
1268
|
+
/* 入稿JSON書き出し後の「次アクション(入稿)」プロンプト */
|
|
1269
|
+
submitHelp(ctx){
|
|
1270
|
+
const P=PLATFORMS.yda, c=ctx||{}, rel=c.savedRel, dir=P.output.planDir;
|
|
1271
|
+
const lines=['入稿を試みてください。手順:',
|
|
1272
|
+
'1) 入稿計画JSON('+c.fileName+')を ad-ops Skill の入稿計画フォルダに置く(保存先: '+rel+')。',
|
|
1273
|
+
' ・入稿計画フォルダ('+dir+'/)に無ければ、ダウンロードフォルダ(~/Downloads/'+c.fileName+')を探して上記パスへ移動する。',
|
|
1274
|
+
(c.wrote? ' ・プランナーからは既に入稿計画フォルダへ保存済みのはず。' : ' ・フォルダ未接続のためダウンロード済み。~/Downloads から移動してください。')];
|
|
1275
|
+
if(c.pendingImages){
|
|
1276
|
+
lines.push('2) 【未登録画像の新規登録】入稿計画内に mediaIds が空で pendingImageNo が付いた広告があります(管理番号: '+(c.pendingImageNos||[]).join(', ')+')。');
|
|
1277
|
+
lines.push(' これらは各管理番号に対応するローカル画像ファイルのパスを、その広告の "imagePaths":[] に設定してください(submit-plan 実行時に自動で /yda/upload-image → mediaId 解決されます)。');
|
|
1278
|
+
lines.push('3) 置いたパスで dry-run 入稿を実行:');
|
|
1279
|
+
} else {
|
|
1280
|
+
lines.push('2) 置いたパスで dry-run 入稿を実行:');
|
|
1281
|
+
}
|
|
1282
|
+
lines.push(' '+P.output.submitCommand(rel,{dryRun:true}));
|
|
1283
|
+
lines.push((c.pendingImages?'4':'3')+') dry-run が全件OKなら本実行:');
|
|
1284
|
+
lines.push(' '+P.output.submitCommand(rel,{dryRun:false}));
|
|
1285
|
+
return lines.join('\n');
|
|
1286
|
+
},
|
|
1287
|
+
/* バージョン保存後の「次アクション(入稿)」プロンプト */
|
|
1288
|
+
afterSave(ctx){
|
|
1289
|
+
const P=PLATFORMS.yda, c=ctx||{};
|
|
1290
|
+
return 'プラン「'+c.name+'」v'+c.versionId+' を保存しました。\n入稿するには「入稿JSON書き出し」を押し、生成された *_入稿計画_*.json を指定して dry-run してください:\n\n'
|
|
1291
|
+
+ P.output.submitCommand('<入稿計画JSONのパス>',{dryRun:true});
|
|
1292
|
+
}
|
|
1293
|
+
},
|
|
1294
|
+
docTabs:[{id:'overview',label:'概要'},{id:'resp',label:'レスポンシブ'},
|
|
1295
|
+
{id:'banner',label:'バナー'},{id:'param',label:'パラメータ'},{id:'guide',label:'運用手順'}]
|
|
1296
|
+
};
|
|
1297
|
+
|
|
1298
|
+
/* 宣言に載せない旧テーブル。resolveGroupIds/maxParamNum が参照中のため S11 の撤去まで温存する。 */
|
|
1299
|
+
const LEGACY_REF_KEYS = ['groups'];
|
|
1300
|
+
function refTableKeys(){ return Object.keys(pf().refTables); }
|
|
1301
|
+
function refTableDef(key){ return pf().refTables[key] || null; }
|
|
1302
|
+
function refLenCls(T,v){ const L=glen(v), lim=T&&T.lengthLimit; if(!lim) return ''; return L>lim.err?'err':(L>lim.warn?'warn':''); }
|
|
1303
|
+
/* S10: 媒体を明示して使える版 (起動時に他媒体の同梱refsも正規化するため)。既存名は現媒体版の薄いラッパ。 */
|
|
1304
|
+
function emptyRefsFor(P){ const o={}; Object.keys(P.refTables).forEach(k=>{ o[k]={}; }); LEGACY_REF_KEYS.forEach(k=>{ o[k]={}; }); return o; }
|
|
1305
|
+
function normalizeRefsFor(P,r){ const e=emptyRefsFor(P); if(!r) return e; Object.keys(e).forEach(k=>{ e[k]=r[k]||{}; }); return e; }
|
|
1306
|
+
function emptyRefs(){ return emptyRefsFor(pf()); }
|
|
1307
|
+
function validRefs(r){ if(!r) return false; return refTableKeys().filter(k=>refTableDef(k).required).concat(LEGACY_REF_KEYS).every(k=>!!r[k]); }
|
|
1308
|
+
function normalizeRefs(r){ return normalizeRefsFor(pf(), r); }
|
|
721
1309
|
/* 案A: 同梱デフォルトアカウントの refs を起動時に外部JSONから読み込む(固有名詞ハードコードなし)。
|
|
722
1310
|
descriptions は独立テーブル(titlesコピーの暫定を廃止)。 */
|
|
723
1311
|
function buildRefs(){ return bundledRefs ? normalizeRefs(bundledRefs) : emptyRefs(); }
|
|
1312
|
+
/* ---- S10: 同梱データ (デフォルトアカウント + サンプルプラン) は媒体別 ----
|
|
1313
|
+
BUNDLES[<platform>] = {accountName, accountId, account(JSON全体), refs, principal, projectName, baseUrl, samplePlan}
|
|
1314
|
+
起動時に登録済み全媒体を先読みし、媒体切替 (switchPlatform) では applyBundle() で同期的に差し替える
|
|
1315
|
+
(非同期 fetch を切替後に走らせると applyAccountJson と競合するため)。 */
|
|
1316
|
+
const BUNDLES = {};
|
|
1317
|
+
async function loadBundle(pfId){
|
|
1318
|
+
const P=PLATFORMS[pfId]; if(!P) return null;
|
|
1319
|
+
const b={accountName:'', accountId:'', account:null, refs:null, principal:'', projectName:'', baseUrl:'', samplePlan:null};
|
|
1320
|
+
const dir=(P.account&&P.account.dir)||LEGACY_ACCOUNT_DIR;
|
|
1321
|
+
const legacy=(pfId==='yda')?LEGACY_ACCOUNT_DIR:null;
|
|
1322
|
+
const readJson=async rel=>{ let j=await fetchJson(dir+'/'+rel); if(!j && legacy) j=await fetchJson(legacy+'/'+rel); return j; };
|
|
1323
|
+
try{
|
|
1324
|
+
const idx=await readJson('index.json');
|
|
1325
|
+
const arr=idx?(Array.isArray(idx)?idx:(idx.accounts||[])):[];
|
|
1326
|
+
const firstName=String((arr||[])[0]||'');
|
|
1327
|
+
if(firstName){
|
|
1328
|
+
const acc=await readJson(encodeURIComponent(firstName)+'.json');
|
|
1329
|
+
if(acc){
|
|
1330
|
+
b.accountName=firstName; b.account=acc;
|
|
1331
|
+
if(acc.accountId) b.accountId=String(acc.accountId);
|
|
1332
|
+
if(acc.refs) b.refs=normalizeRefsFor(P, acc.refs);
|
|
1333
|
+
if(acc.principal) b.principal=acc.principal;
|
|
1334
|
+
if(acc.projectName) b.projectName=acc.projectName;
|
|
1335
|
+
if(acc.baseArticleUrl) b.baseUrl=acc.baseArticleUrl;
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
}catch(e){}
|
|
1339
|
+
try{ const plan=await fetchJson(P.output.samplePlanFile); if(plan) b.samplePlan=plan; }catch(e){}
|
|
1340
|
+
BUNDLES[pfId]=b;
|
|
1341
|
+
return b;
|
|
1342
|
+
}
|
|
1343
|
+
function applyBundle(pfId){
|
|
1344
|
+
const b=BUNDLES[pfId]||{};
|
|
1345
|
+
BUNDLED_ACCOUNT_NAME=b.accountName||''; BUNDLED_ACCOUNT_ID=b.accountId||'';
|
|
1346
|
+
bundledRefs=b.refs||null; bundledPrincipal=b.principal||'';
|
|
1347
|
+
bundledProjectName=b.projectName||''; bundledBaseUrl=b.baseUrl||'';
|
|
1348
|
+
samplePlan=b.samplePlan||null;
|
|
1349
|
+
}
|
|
1350
|
+
/* S7: 媒体既定値 (pf().defaults.project) + 同梱デフォルトアカウント由来の値。
|
|
1351
|
+
案件名・主体者・ベースURL・accountId は <account.dir>/index.json 先頭由来。未読込時は空。 */
|
|
724
1352
|
function defaultProject(){
|
|
725
|
-
|
|
726
|
-
|
|
1353
|
+
const P=pf();
|
|
1354
|
+
const p=Object.assign({}, P.defaults.project, {
|
|
727
1355
|
name: bundledProjectName || '',
|
|
728
1356
|
principal: bundledPrincipal,
|
|
729
|
-
|
|
730
|
-
deliveryStatus:'オフ', displayUrlFormat:'ドメインのみ表示する',
|
|
731
|
-
attributionDays:1, biddingStrategyDefault:'tCPA', tcpaDefault:8500,
|
|
732
|
-
budgetNew:12000, budgetAsc:50000, baseArticleUrl: bundledBaseUrl || '',
|
|
1357
|
+
baseArticleUrl: bundledBaseUrl || '',
|
|
733
1358
|
// 入稿設定 (プランに内包。入稿JSON書き出し時にそのまま使う)。accountId/Name は同梱デフォルトアカウント
|
|
734
|
-
accountId:BUNDLED_ACCOUNT_ID, accountName:BUNDLED_ACCOUNT_NAME
|
|
735
|
-
};
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
const
|
|
1359
|
+
accountId:BUNDLED_ACCOUNT_ID, accountName:BUNDLED_ACCOUNT_NAME
|
|
1360
|
+
});
|
|
1361
|
+
/* S10: 同梱アカウントJSONの媒体固有項目 (Meta の pageId / instagramUserId / pixelId 等) も既定値に載せる。
|
|
1362
|
+
写し込み規則は媒体スキーマ (account.apply) が唯一の定義元。 */
|
|
1363
|
+
const acc=BUNDLES[curPf] && BUNDLES[curPf].account;
|
|
1364
|
+
if(acc){ try{ P.account.apply(acc, p); }catch(e){} }
|
|
1365
|
+
return p;
|
|
1366
|
+
}
|
|
1367
|
+
/* 参照データビューの先頭タブ (媒体の refTables 宣言順) */
|
|
1368
|
+
function firstRefTabKey(){ return Object.keys(pf().refTables)[0] || 'titles'; }
|
|
1369
|
+
/* E-2: mode/MODES 廃止。新規/既存はID解決(isNewCampaign/isNewGroup)で自動判定。 */
|
|
739
1370
|
|
|
740
1371
|
/* ---- initial state ---- */
|
|
741
|
-
|
|
1372
|
+
/* S7: 行生成は pf().factories へ委譲 (既存名は呼び出し側/E2E互換のため薄いラッパで残す) */
|
|
1373
|
+
function newAd(seed){ return pf().factories.newAd(seed); }
|
|
1374
|
+
function newAdGroup(c, i){ return pf().factories.newAdGroup(c, i); }
|
|
742
1375
|
/* ---- schema v2: バージョンメタ ---- */
|
|
743
|
-
function defaultMeta(){ return {versionId:null, name:'', memo:'', author:'', platform:
|
|
1376
|
+
function defaultMeta(){ return {versionId:null, name:'', memo:'', author:'', platform:curPf, createdAt:null, updatedAt:null}; }
|
|
744
1377
|
/* 数値化 (NaN/不正入力は null 維持) — B10 */
|
|
745
1378
|
function numOrNull(v){ if(v===''||v==null) return null; const n=Number(v); return Number.isFinite(n)?n:null; }
|
|
1379
|
+
/* 整数化 + 範囲クランプ (Meta の ageMin/ageMax: 13〜65)。不正入力は null 維持 */
|
|
1380
|
+
function clampInt(v,min,max){ if(v===''||v==null) return null; const n=parseInt(v,10); if(!Number.isFinite(n)) return null; return Math.min(max,Math.max(min,n)); }
|
|
746
1381
|
/* 新規キャンペーンの個別既定値 (A-1: project既定はここでのみ供給し、以後はキャンペーン個別値運用) */
|
|
747
1382
|
function cpDefaults(project, mode){ const p=project||st.project; return {budget: mode==='new'?p.budgetNew:p.budgetAsc, biddingStrategy:p.biddingStrategyDefault||'tCPA', tcpa:p.tcpaDefault}; }
|
|
748
|
-
/*
|
|
749
|
-
function makeNewCampaign(seed){
|
|
750
|
-
|
|
751
|
-
function makeExistingCampaign(cpId, name){ return {uid:uid('c'), name:name||'', campaignId:String(cpId), isExisting:true, variable:'', urlNumber:'', budget:null, biddingStrategy:'', tcpa:null, adGroups:[]}; }
|
|
1383
|
+
/* S7: 新規/既存キャンペーンの生成も pf().factories へ委譲 */
|
|
1384
|
+
function makeNewCampaign(seed){ return pf().factories.newCampaign(seed); }
|
|
1385
|
+
function makeExistingCampaign(cpId, name){ return pf().factories.existingCampaign(cpId, name); }
|
|
752
1386
|
function initialState(){
|
|
753
1387
|
const refs=buildRefs();
|
|
754
1388
|
// 共通設定(project=案件名/主体者/アカウント等)はアカウントJSON由来。サンプルプランには campaigns と accountId のみ。
|
|
@@ -773,10 +1407,12 @@ function stateJson(){ const {meta,project,campaigns,ui,refs}=st; return {schemaV
|
|
|
773
1407
|
let _persistWarned=false;
|
|
774
1408
|
function persist(){ /* localStorageプラン廃止: no-op。プランの永続化はサンプルプラン更新(サンプルプラン.json)で行う */ }
|
|
775
1409
|
/* B12: SCHEMA_VERSION 不一致は後方互換なしで破棄→初期化。refs欠落時は再構築。 */
|
|
776
|
-
function load(){ return null; /* LSプラン廃止 */ try{ const j=JSON.parse(localStorage.getItem(
|
|
1410
|
+
function load(){ return null; /* LSプラン廃止 */ try{ const j=JSON.parse(localStorage.getItem(stateKey())); if(!j||!j.campaigns||j.schemaVersion!==SCHEMA_VERSION) return null; const refs=validRefs(j.refs)?normalizeRefs(j.refs):buildRefs(); return {schemaVersion:SCHEMA_VERSION, meta:Object.assign(defaultMeta(),j.meta||{}), project:Object.assign(defaultProject(),j.project), refs, campaigns:j.campaigns, ui:j.ui||{expanded:{}}}; }catch(e){ return null; } }
|
|
777
1411
|
|
|
778
1412
|
/* localStorage のプラン永続化は廃止。プランの正はサンプルプラン.json(外部)。起動時に必ず読み込む。 */
|
|
779
|
-
|
|
1413
|
+
/* S10: window.st として参照できるよう var 宣言 (let だとグローバルオブジェクトに載らない)。
|
|
1414
|
+
E2E / デバッグコンソールから状態を読むための唯一のシーム。 */
|
|
1415
|
+
var st = initialState();
|
|
780
1416
|
|
|
781
1417
|
/* ---- undo (bounded) ---- */
|
|
782
1418
|
const undoStack=[];
|
|
@@ -794,32 +1430,10 @@ function findGroup(u){ let r=null; st.campaigns.forEach(c=>c.adGroups.forEach((g
|
|
|
794
1430
|
function findCp(u){ const i=st.campaigns.findIndex(c=>c.uid===u); return i<0?null:{c:st.campaigns[i],i}; }
|
|
795
1431
|
|
|
796
1432
|
/* ---- resolve (派生値 / 純関数) ---- */
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
const adName = crName + '-' + (ad.titleNo??'') + 'L' + (ad.descNo??'') + '-' + (group.paramCode||'');
|
|
802
|
-
const me = refs.media[String(ad.imageNo)];
|
|
803
|
-
const mediaId = me ? me[sizeKey] : undefined;
|
|
804
|
-
const titleText = refs.titles[ad.titleNo];
|
|
805
|
-
const descText = (refs.descriptions||{})[ad.descNo]; // A-5: 説明文テーブルのみ参照 (titlesへのフォールバックなし)
|
|
806
|
-
const finalUrl = (p.baseArticleUrl||'') + (ad.articleSlug||'') + '?ad=' + (group.paramCode||'') + '&creative=' + (group.paramCode||'') + '-' + adName;
|
|
807
|
-
const errs=[], warns=[];
|
|
808
|
-
// 画像を3分類: 番号空=真エラー / 番号あり&refs.mediaに無い=未登録(要アップロード=pendingImage) / 解決OK
|
|
809
|
-
const imgNoStr=String(ad.imageNo||'').trim();
|
|
810
|
-
const pendingImage = (imgNoStr!=='' && mediaId===undefined); // アカウントのメディアライブラリに未登録→先にアップロードして新規作成
|
|
811
|
-
if(imgNoStr==='') errs.push('画像未解決');
|
|
812
|
-
if(String(ad.titleNo||'')==='' || titleText===undefined) errs.push('タイトル未解決');
|
|
813
|
-
if(String(ad.descNo||'')==='' || descText===undefined) errs.push('説明文未解決');
|
|
814
|
-
if(titleText!==undefined && glen(titleText)>35) errs.push('タイトル35字超');
|
|
815
|
-
else if(titleText!==undefined && glen(titleText)>20) warns.push('タイトル20字超');
|
|
816
|
-
// B11: paramCode空はエラー(export対象外・URL生成に必須)。F-6: refs.groups 払出判定は廃止。
|
|
817
|
-
if(String(group.paramCode||'').trim()==='') errs.push('paramCode未入力');
|
|
818
|
-
if(pendingImage) warns.push('画像未登録(要アップロード)'); // errではなくwarn: 新規作成対象として保持
|
|
819
|
-
// F-6: ID解決は adGroupId ベース。campaignId は idsCell表示用に持たない(親CP参照は呼び出し側)
|
|
820
|
-
const aid=String(group.adGroupId||'').trim();
|
|
821
|
-
return {crName, adName, campaignId:'', adGroupId:aid, imageNo:imgNoStr, mediaId:mediaId||'', pendingImage, titleText:titleText===undefined?'':titleText, descText:descText===undefined?'':descText, finalUrl, errs, warns, resolvedGroup:!!aid};
|
|
822
|
-
}
|
|
1433
|
+
/* S8: 解決本体は pf().resolve(ad, group, ctx) に移設。関数名は据え置き
|
|
1434
|
+
(E2E が page.evaluate から resolveAd(...) を直接呼ぶため)。 */
|
|
1435
|
+
function planCtx(){ const P=pf(); return {project:st.project, refs:st.refs, pf:P, resolve:resolveAd}; }
|
|
1436
|
+
function resolveAd(ad, group){ return pf().resolve(ad, group, planCtx()); }
|
|
823
1437
|
function validationCounts(){ let err=0,warn=0,ads=0; eachAd((a,g)=>{ ads++; const r=resolveAd(a,g); if(r.errs.length) err++; else if(r.warns.length) warn++; }); return {err,warn,ads}; }
|
|
824
1438
|
|
|
825
1439
|
/* ============================================================
|
|
@@ -834,7 +1448,9 @@ function renderPlanWarn(){
|
|
|
834
1448
|
else bar.hidden=true;
|
|
835
1449
|
}
|
|
836
1450
|
function render(){
|
|
837
|
-
|
|
1451
|
+
/* 主体者(principal)は YDA 固有の概念。持たない媒体では空ラベルを出さない */
|
|
1452
|
+
const _pr=String(st.project.principal||'').trim();
|
|
1453
|
+
document.getElementById('pjLabel').textContent = st.project.name + (_pr ? (' / 主体者:'+_pr) : '');
|
|
838
1454
|
renderPlanWarn();
|
|
839
1455
|
renderTplChips(); renderValidationBadge();
|
|
840
1456
|
if(activeView==='sheet'){
|
|
@@ -866,163 +1482,141 @@ function renderTplChips(){
|
|
|
866
1482
|
// - アカウント名/IDは上部のアカウントセレクト由来 (applyAccountJson で st.project へ同期)
|
|
867
1483
|
// - 主体者(principal)はアカウントJSON由来 (アカウント編集モーダルで変更)
|
|
868
1484
|
// - タイプ(レスポンシブ/画像)はプランツールバーの固定プルダウン (変更不可)
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
chip(
|
|
1485
|
+
// S7: チップの並び・種別・幅は pf().projectFields (媒体スキーマ) が持つ
|
|
1486
|
+
document.getElementById('tplChips').innerHTML = pf().projectFields.map(f=>{
|
|
1487
|
+
const v = (f.fallback!==undefined) ? (p[f.key]||f.fallback) : p[f.key];
|
|
1488
|
+
const ctrl = f.kind==='selectv' ? selv(f.key, v, f.options)
|
|
1489
|
+
: f.kind==='select' ? sel(f.key, v, f.options)
|
|
1490
|
+
: f.kind==='number' ? num(f.key, v, f.width)
|
|
1491
|
+
: txt(f.key, v, f.width);
|
|
1492
|
+
return chip(f.label, ctrl+(f.suffix||''));
|
|
1493
|
+
}).join('');
|
|
877
1494
|
// A-1: 入札(tcpaDefault) / 予算新規 / 予算ASC チップは撤去。project既定は新規CP作成時の初期値供給のみに使い、以後はキャンペーン行で個別編集する。
|
|
878
1495
|
}
|
|
879
1496
|
|
|
880
1497
|
/* ---- SHEET ---- */
|
|
881
1498
|
function renderSheet(){
|
|
882
1499
|
document.querySelectorAll('#sheetTabs button').forEach(b=>b.classList.toggle('active', b.dataset.st===sheetTab));
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
1500
|
+
renderSheetGeneric(normTab(sheetTab));
|
|
1501
|
+
}
|
|
1502
|
+
/* ---- S4: シート描画の統合 ----
|
|
1503
|
+
旧 renderSheet / renderSheetGroups / renderSheetCampaigns の手書きHTML3本を
|
|
1504
|
+
renderSheetGeneric(tab) + renderRow + renderCell に統合した。
|
|
1505
|
+
・列ごとの差異 (ラベル/幅/入力種別/read-only表現/ゴースト/派生セル) は COLS の記述子だけが持つ
|
|
1506
|
+
・タブごとの差異 (行番号ラベル / 親列 / 操作ボタン / 追加行 / 空表示 / data-* 属性名) は SHEET_UI が持つ
|
|
1507
|
+
DOM構造 (class名 / data-c / data-sel / colspan / td の個数と順序) は統合前と完全一致させている。 */
|
|
1508
|
+
const SHEET_UI = {
|
|
1509
|
+
ads:{
|
|
1510
|
+
uAttr:'u', fAttr:'f', rowAttr:'data-adrow', rowNumLabel:'', parentLabel:'グループ',
|
|
1511
|
+
/* 旧 renderSheet だけが再描画前に activeElement を blur していた (change を先に確定させる) */
|
|
1512
|
+
blurBeforeRender:true,
|
|
1513
|
+
rowNumTitle:it=>' title="行を選択('+esc(it.g.name)+')"',
|
|
1514
|
+
parentCell:it=>'<td><div class="cppick" data-agpick-ad="'+it.a.uid+'" title="クリックで所属グループを変更(プラン内の広告グループから選択)"><span class="breadc">'+esc(it.g.name)+'</span><span class="tgcaret">▾</span></div></td>',
|
|
1515
|
+
actions:uid=>'<button class="iconbtn" data-act="ad-dup" data-u="'+uid+'" title="複製">⧉</button><button class="iconbtn danger" data-act="ad-del" data-u="'+uid+'" title="削除">✕</button>',
|
|
1516
|
+
addRow:'<button class="addbtn" data-act="add-ad-bottom">+ 広告を追加</button>',
|
|
1517
|
+
emptyHtml:rows=>(rows.length===0&&st.campaigns.length===0)?'<div class="empty">キャンペーンがありません。シートの「キャンペーン」タブ →「+ キャンペーンを追加」から。</div>':null
|
|
1518
|
+
},
|
|
1519
|
+
adGroups:{
|
|
1520
|
+
uAttr:'gu', fAttr:'gf', rowAttr:'data-gu', rowNumLabel:'#', parentLabel:'キャンペーン',
|
|
1521
|
+
/* 新規AGはクリックでプラン内キャンペーンをドロワー選択(親付け替え)。既存AGは変更不可 */
|
|
1522
|
+
parentCell:it=>isExistingGroup(it.g)
|
|
1523
|
+
? '<td><span class="robadge" title="既存広告グループのためキャンペーン変更不可" style="max-width:150px; display:inline-block; overflow:hidden; text-overflow:ellipsis; vertical-align:middle">'+esc(it.c.name)+'</span></td>'
|
|
1524
|
+
: '<td><div class="cppick" data-cppick-g="'+it.g.uid+'" title="クリックでキャンペーンを選択(プラン内)"><span class="breadc" style="max-width:150px">'+esc(it.c.name)+'</span><span class="tgcaret">▾</span></div></td>',
|
|
1525
|
+
actions:uid=>'<button class="iconbtn" data-act="ag-dup" data-u="'+uid+'" title="複製">⧉</button><button class="iconbtn danger" data-act="ag-del" data-u="'+uid+'" title="削除">✕</button>',
|
|
1526
|
+
/* 文言は媒体の階層ラベル (YDA=広告グループ / Meta=広告セット) から供給する */
|
|
1527
|
+
addRow:()=>'<button class="addbtn" data-act="add-adgroup">+ 新規'+esc(agLabel())+'</button> <button class="addbtn" data-act="add-adgroup-existing">📂 既存'+esc(agLabel())+'を呼び出し</button>',
|
|
1528
|
+
emptyHtml:()=>null
|
|
1529
|
+
},
|
|
1530
|
+
campaigns:{
|
|
1531
|
+
uAttr:'cu', fAttr:'cf', rowAttr:'data-cu', rowNumLabel:'#', parentLabel:null,
|
|
1532
|
+
actions:uid=>'<button class="iconbtn" data-act="cp-dup" data-u="'+uid+'" title="複製/量産">⧉</button><button class="iconbtn danger" data-act="cp-del" data-u="'+uid+'" title="削除">✕</button>',
|
|
1533
|
+
addRow:'<button class="addbtn" data-act="add-campaign">+ 新規キャンペーン</button> <button class="addbtn" data-act="add-campaign-existing">📂 既存キャンペーンを呼び出し</button>',
|
|
1534
|
+
emptyHtml:()=>st.campaigns.length?null:'<div class="empty">キャンペーンがありません。「+ 新規キャンペーン」または「📂 既存キャンペーンを呼び出し」から。</div>'
|
|
1535
|
+
}
|
|
1536
|
+
};
|
|
1537
|
+
/* tab は正規タブID ('ads'|'adGroups'|'campaigns')。行は flatVisible() の可視順 = 選択範囲の行番号と一致 */
|
|
1538
|
+
function renderSheetGeneric(tab){
|
|
1539
|
+
const ui=SHEET_UI[tab], cols=colDefs(tab), host=document.getElementById('sheetHost');
|
|
1540
|
+
const restore=focusRestoreG(host, ui.uAttr, ui.fAttr);
|
|
1541
|
+
const ths=[ui.rowNumLabel].concat(ui.parentLabel!=null?[ui.parentLabel]:[]).concat(cols.map(c=>c.label)).concat(['']);
|
|
1542
|
+
const rows=flatVisible();
|
|
1543
|
+
let html='<table class="sheet"><thead><tr>'+ths.map(c=>'<th>'+esc(c)+'</th>').join('')+'</tr></thead><tbody>';
|
|
1544
|
+
rows.forEach((it,ri)=>{ html+=renderRow(it, ri, cols, tab, ui); });
|
|
1545
|
+
html+='<tr><td></td><td colspan="'+(ths.length-1)+'">'+(typeof ui.addRow==='function'?ui.addRow():ui.addRow)+'</td></tr>';
|
|
926
1546
|
html+='</tbody></table>';
|
|
927
|
-
|
|
928
|
-
const
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
1547
|
+
const em=ui.emptyHtml(rows); if(em) html=em;
|
|
1548
|
+
if(ui.blurBeforeRender){ const fo=document.activeElement; if(fo && host.contains(fo)){ try{ fo.blur(); }catch(e){} } }
|
|
1549
|
+
host.innerHTML=html; restore(); applySelection();
|
|
1550
|
+
}
|
|
1551
|
+
/* 1行 = 行番号td + (親td) + 列td... + 操作td */
|
|
1552
|
+
function renderRow(item, ri, cols, tab, ui){
|
|
1553
|
+
const row = rowOf(item);
|
|
1554
|
+
const ctx = {tab:tab, item:item, ri:ri, project:st.project, refs:st.refs};
|
|
1555
|
+
let cls='adrow';
|
|
1556
|
+
if(item.kind==='a'){ ctx.r=resolveAd(item.a,item.g); if(ctx.r.errs.length) cls+=' hasErr'; }
|
|
1557
|
+
let h='<tr class="'+cls+'" '+ui.rowAttr+'="'+row.uid+'">';
|
|
1558
|
+
h+='<td class="rownum" data-rownum="'+ri+'"'+(ui.rowNumTitle?ui.rowNumTitle(item):'')+'>'+(ri+1)+'</td>';
|
|
1559
|
+
if(ui.parentLabel!=null) h+=ui.parentCell(item);
|
|
1560
|
+
cols.forEach((col,ci)=>{ h+=renderCell(col,item,ri,ci,ctx); });
|
|
1561
|
+
h+='<td><span class="rowact">'+ui.actions(row.uid)+'</span></td>';
|
|
1562
|
+
return h+'</tr>';
|
|
1563
|
+
}
|
|
1564
|
+
/* 1セル。col.kind で分岐する唯一の場所。 */
|
|
1565
|
+
function renderCell(col, item, ri, ci, ctx){
|
|
1566
|
+
const ui=SHEET_UI[ctx.tab], row=rowOf(item);
|
|
1567
|
+
const val=getVar(item, ci, ctx.tab);
|
|
1568
|
+
const ro = col.alwaysReadonly || ((col.readonlyOnExisting||col.hideOnExisting) && rowIsExisting(item,col));
|
|
1569
|
+
const sel='data-sel="1" data-r="'+ri+'" data-c="'+ci+'" data-'+ui.uAttr+'="'+row.uid+'" data-'+ui.fAttr+'="'+col.field+'"';
|
|
1570
|
+
const sty=col.width?(' style="width:'+col.width+'"'):'';
|
|
1571
|
+
const DASH='<span class="idpill" style="color:var(--faint)">—</span>';
|
|
1572
|
+
const roDash=v=>'<td>'+(v?'<span class="robadge">'+esc(v)+'</span>':DASH)+'</td>';
|
|
1573
|
+
|
|
1574
|
+
if(col.kind==='badge'){ const id=String(row[col.field]||'').trim(); return '<td>'+newOrIdBadge(!id, id)+'</td>'; }
|
|
1575
|
+
if(col.kind==='derived') return '<td>'+col.derive(row, ctx)+'</td>';
|
|
1576
|
+
|
|
1577
|
+
if(ro){
|
|
1578
|
+
/* 既存呼び出し行の read-only 表現は列記述子で明示する:
|
|
1579
|
+
hideOnExisting = 常に「—」/ roEmptyDash = 値ありは robadge・空は「—」/ roStyle:'input' = readonly入力欄 /
|
|
1580
|
+
既定 = 常に robadge (空でもバッジを出す) */
|
|
1581
|
+
if(col.hideOnExisting) return '<td>'+DASH+'</td>';
|
|
1582
|
+
if(col.roEmptyDash) return roDash(val);
|
|
1583
|
+
if(col.roStyle==='input') return '<td><input class="cell-in'+(col.kind==='mono'?' mono':'')+' roinput" '+sel+' value="'+esc(val)+'" readonly title="'+esc(col.roTitle)+'"'+sty+'></td>';
|
|
1584
|
+
return '<td><span class="robadge'+(col.kind==='mono'?' mono':'')+'"'+(col.roTitle?(' title="'+esc(col.roTitle)+'"'):'')+'>'+esc(val)+'</span></td>';
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
if(col.kind==='select'){
|
|
1588
|
+
/* '' は「(既定)」。emptyNoSelected は「(既定)に selected を付けない」現行DOMの踏襲 */
|
|
1589
|
+
const opts=col.options.map(o=> o===''
|
|
1590
|
+
? '<option value=""'+((val===''&&!col.emptyNoSelected)?' selected':'')+'>(既定)</option>'
|
|
1591
|
+
: '<option'+(o===val?' selected':'')+'>'+esc(o)+'</option>').join('');
|
|
1592
|
+
return '<td><select class="cell-in fmt"'+(col.minWidth?(' style="min-width:'+col.minWidth+'"'):'')+' '+sel+'>'+opts+'</select></td>';
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
if(col.kind==='refcombo'){
|
|
1596
|
+
const bad=(col.errCodes||[]).some(e=>ctx.r.errCodes.includes(e));
|
|
1597
|
+
return '<td><div class="cellcombo'+(bad?' err':'')+'"><input class="cell-in num" '+sel+' value="'+esc(val)+'">'+
|
|
1598
|
+
'<span class="combosep">:</span><span class="ghost" data-d="'+row.uid+'-'+col.ghostKey+'" title="'+esc(col.ghostTitle(ctx.r))+'">'+col.ghost(ctx.r,row)+'</span>'+
|
|
1599
|
+
'<button class="combodd" data-dd="'+col.drawerType+'" data-u="'+row.uid+'" data-f="'+col.field+'" tabindex="-1" title="'+esc(col.ddTitle)+'">▾</button></div></td>';
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
if(col.kind==='tgpick'){
|
|
1603
|
+
const label=pf().targeting[col.tgKey].label;
|
|
1604
|
+
const disp = val? '<span class="tgval">'+esc(val)+'</span>' : '<span class="tgval"><span class="tghint">'+esc(label)+'</span></span>';
|
|
1605
|
+
return '<td class="tgtd"><div class="tgpick'+(val?'':' tgempty')+'" '+sel+' title="'+esc(val||(label+'(クリックで選択)'))+'">'+disp+'<span class="tgcaret">▾</span></div></td>';
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
/* text / mono / number */
|
|
1609
|
+
const cls='cell-in'+(col.kind==='mono'?' mono':'')+(col.kind==='number'?' num':'')+((col.required&&val==='')?' warnempty':'');
|
|
1610
|
+
return '<td><input class="'+cls+'"'+(col.kind==='number'?' type="number"':'')+' '+sel+' value="'+esc(val)+'"'+
|
|
1611
|
+
(col.placeholder?(' placeholder="'+esc(col.placeholder())+'"'):'')+
|
|
1612
|
+
(col.title?(' title="'+esc(col.title)+'"'):'')+sty+'></td>';
|
|
934
1613
|
}
|
|
935
|
-
function mediaInline(a,r){ if(!a.imageNo) return '<span class="idpill" style="color:var(--faint)">—</span>'; if(r.mediaId!=='') return '<span class="idpill">ID '+esc(r.mediaId)+'</span>'; if(r.pendingImage) return '<span class="idpill warn" title="アカウント未登録。入稿前にアップロードして新規登録">🆕未登録</span>'; return '<span class="idpill err">#N/A</span>'; }
|
|
936
|
-
function titleGhost(r){ if(r.errs.includes('タイトル未解決')) return '<span class="err">未解決</span>'; return esc(r.titleText.slice(0,40)); }
|
|
937
|
-
function descGhost(r){ if(r.errs.includes('説明文未解決')) return '<span class="err">— 未解決</span>'; return esc(r.descText.slice(0,44)); }
|
|
938
|
-
function idsCell(r){ return r.adGroupId ? ('AG '+r.adGroupId) : '新規'; } // F-6: adGroupIdベース表示
|
|
939
1614
|
|
|
940
1615
|
/* ---- TREE ---- */
|
|
941
1616
|
function isCollapsed(u){ return !!st.ui.expanded[u]===false ? false : st.ui.expanded[u]==='c'; }
|
|
942
1617
|
function focusRestoreG(host, attrU, attrF){
|
|
943
1618
|
const fa=document.activeElement, fu=fa&&fa.dataset?fa.dataset[attrU]:null, ff=fa&&fa.dataset?fa.dataset[attrF]:null, ss=fa&&fa.selectionStart;
|
|
944
|
-
return function(){ if(fu&&ff){ const el=host.querySelector('[
|
|
945
|
-
}
|
|
946
|
-
/* ---- シート: 広告グループ タブ(変更列を一覧編集) ---- */
|
|
947
|
-
function renderSheetGroups(){
|
|
948
|
-
const host=document.getElementById('sheetHost'); const restore=focusRestoreG(host,'gu','gf');
|
|
949
|
-
// F-6 列順: キャンペーン → 広告グループID(左から2番目) → 名前 → paramCode → 入札/配信/ターゲティング
|
|
950
|
-
const cols=['#','キャンペーン','広告グループID','広告グループ名','paramCode','入札','配信','デバイス','性別','年齢','地域','曜日時間','オーディエンス','サーチKW',''];
|
|
951
|
-
let html='<table class="sheet"><thead><tr>'+cols.map(c=>'<th>'+esc(c)+'</th>').join('')+'</tr></thead><tbody>'; let ri=-1;
|
|
952
|
-
const gin=(g,ci,w,req)=>{ const f=VARF_GROUPS[ci]; const v=g[f]; const empty=req&&(v==null||v===''); return '<td><input class="cell-in'+(empty?' warnempty':'')+'" data-sel="1" data-r="'+ri+'" data-c="'+ci+'" data-gu="'+g.uid+'" data-gf="'+f+'" value="'+esc(v==null?'':v)+'" style="width:'+w+'"></td>'; };
|
|
953
|
-
const roTd=v=>'<td>'+(v?'<span class="robadge">'+esc(v)+'</span>':'<span class="idpill" style="color:var(--faint)">—</span>')+'</td>';
|
|
954
|
-
st.campaigns.forEach(c=>{
|
|
955
|
-
c.adGroups.forEach(g=>{ ri++;
|
|
956
|
-
const gEd=!isExistingGroup(g); // 既存呼び出し行=disabled / 新規行=編集可
|
|
957
|
-
html+='<tr class="adrow" data-gu="'+g.uid+'">';
|
|
958
|
-
html+='<td class="rownum" data-rownum="'+ri+'">'+(ri+1)+'</td>';
|
|
959
|
-
// キャンペーン列: 新規AGはクリックでプラン内キャンペーンをドロワー選択(親付け替え)。既存AGはキャンペーンが確定済みのため変更不可
|
|
960
|
-
if(gEd){
|
|
961
|
-
html+='<td><div class="cppick" data-cppick-g="'+g.uid+'" title="クリックでキャンペーンを選択(プラン内)"><span class="breadc" style="max-width:150px">'+esc(c.name)+'</span><span class="tgcaret">▾</span></div></td>';
|
|
962
|
-
} else {
|
|
963
|
-
html+='<td><span class="robadge" title="既存広告グループのためキャンペーン変更不可" style="max-width:150px; display:inline-block; overflow:hidden; text-overflow:ellipsis; vertical-align:middle">'+esc(c.name)+'</span></td>';
|
|
964
|
-
}
|
|
965
|
-
// 広告グループID (左から2番目): 既存=ID / 新規=「新規」バッジ (手入力なし)
|
|
966
|
-
html+='<td>'+newOrIdBadge(isNewGroup(g), resolveAdGroupIdOf(g))+'</td>';
|
|
967
|
-
if(gEd){
|
|
968
|
-
html+=gin(g,1,'150px',true); // 名前(c1)
|
|
969
|
-
html+='<td><input class="cell-in mono'+(!g.paramCode?' warnempty':'')+'" data-sel="1" data-r="'+ri+'" data-c="2" data-gu="'+g.uid+'" data-gf="paramCode" value="'+esc(g.paramCode||'')+'" title="URL生成用のパラメータ(グループ紐づけには不使用)" style="width:120px"></td>'; // paramCode(c2)
|
|
970
|
-
html+=gin(g,3,'56px',false); // 入札(c3)
|
|
971
|
-
html+='<td><select class="cell-in fmt" style="min-width:66px" data-sel="1" data-r="'+ri+'" data-c="4" data-gu="'+g.uid+'" data-gf="deliveryStatus"><option value=""'+(!g.deliveryStatus?' selected':'')+'>(既定)</option><option'+(g.deliveryStatus==='オフ'?' selected':'')+'>オフ</option><option'+(g.deliveryStatus==='オン'?' selected':'')+'>オン</option></select></td>'; // 配信(c4)
|
|
972
|
-
[5,6,7,8,9,10,11].forEach(ci=>{ const tf=VARF_GROUPS[ci]; if(TG_OPTIONS[tf]){ const val=g[tf]||''; const disp = val? '<span class="tgval">'+esc(val)+'</span>' : '<span class="tgval"><span class="tghint">'+esc(TG_OPTIONS[tf].label)+'</span></span>'; html+='<td class="tgtd"><div class="tgpick'+(val?'':' tgempty')+'" data-sel="1" data-r="'+ri+'" data-c="'+ci+'" data-gu="'+g.uid+'" data-gf="'+tf+'" title="'+esc(val||(TG_OPTIONS[tf].label+'(クリックで選択)'))+'">'+disp+'<span class="tgcaret">▾</span></div></td>'; } else { html+=gin(g,ci,'120px',false); } });
|
|
973
|
-
} else {
|
|
974
|
-
// 既存呼び出し行: 名前/paramCode/入札/配信/ターゲティングを read-only(disabled)。ID起点で自動で埋まった値を表示
|
|
975
|
-
html+='<td><span class="robadge" title="既存広告グループのため編集不可">'+esc(g.name||'')+'</span></td>';
|
|
976
|
-
html+='<td><span class="robadge mono">'+esc(g.paramCode||'')+'</span></td>';
|
|
977
|
-
html+=roTd(g.bid);
|
|
978
|
-
html+=roTd(g.deliveryStatus);
|
|
979
|
-
[5,6,7,8,9,10,11].forEach(ci=>{ const tf=VARF_GROUPS[ci]; html+=roTd(g[tf]); });
|
|
980
|
-
}
|
|
981
|
-
html+='<td><span class="rowact"><button class="iconbtn" data-act="ag-dup" data-u="'+g.uid+'" title="複製">⧉</button><button class="iconbtn danger" data-act="ag-del" data-u="'+g.uid+'" title="削除">✕</button></span></td>';
|
|
982
|
-
html+='</tr>';
|
|
983
|
-
});
|
|
984
|
-
});
|
|
985
|
-
// F-5: 追加行はタブ最下部に1行だけ。追加時にキャンペーンを選択(ドロワー)
|
|
986
|
-
html+='<tr><td></td><td colspan="14"><button class="addbtn" data-act="add-adgroup">+ 新規広告グループ</button> <button class="addbtn" data-act="add-adgroup-existing">📂 既存広告グループを呼び出し</button></td></tr>';
|
|
987
|
-
html+='</tbody></table>';
|
|
988
|
-
host.innerHTML=html; restore(); applySelection();
|
|
989
|
-
}
|
|
990
|
-
/* ---- シート: キャンペーン タブ(変更列を一覧編集) ---- */
|
|
991
|
-
function renderSheetCampaigns(){
|
|
992
|
-
const host=document.getElementById('sheetHost'); const restore=focusRestoreG(host,'cu','cf');
|
|
993
|
-
// E-2: campaignId 最左・read-only。モード/配信/戦略メモ列は撤去
|
|
994
|
-
const cols=['#','キャンペーンID','キャンペーン名','予算/日','入札戦略','tCPA','検証変数','リンク番号',''];
|
|
995
|
-
let html='<table class="sheet"><thead><tr>'+cols.map(c=>'<th>'+esc(c)+'</th>').join('')+'</tr></thead><tbody>';
|
|
996
|
-
let RIDX=-1;
|
|
997
|
-
st.campaigns.forEach((c)=>{ RIDX++;
|
|
998
|
-
const cEd=!isExistingCampaign(c); // F-2: 編集可否は明示フラグのみ。表示バッジはID解決ベースで分離
|
|
999
|
-
const a=(f)=>'data-sel="1" data-r="'+RIDX+'" data-c="'+VARF_CAMPAIGNS.indexOf(f)+'" data-cu="'+c.uid+'" data-cf="'+f+'"';
|
|
1000
|
-
html+='<tr class="adrow" data-cu="'+c.uid+'">';
|
|
1001
|
-
html+='<td class="rownum" data-rownum="'+RIDX+'">'+(RIDX+1)+'</td>';
|
|
1002
|
-
// campaignId: 入力欄廃止。既存=「ID xxx」/ 新規=「新規」バッジ (表示はID解決ベース)
|
|
1003
|
-
html+='<td>'+newOrIdBadge(isNewCampaign(c), resolveCampaignIdOf(c))+'</td>';
|
|
1004
|
-
// 既存呼び出しキャンペーンは広告追加専用 → 基本パラメータ(name/予算/入札/tCPA/検証変数/リンク番号)は全て read-only (backendが無視するため編集齟齬を防ぐ)
|
|
1005
|
-
if(cEd){
|
|
1006
|
-
html+='<td><input class="cell-in'+(!c.name?' warnempty':'')+'" '+a('name')+' value="'+esc(c.name||'')+'" style="width:170px"></td>';
|
|
1007
|
-
html+='<td><input class="cell-in num" type="number" '+a('budget')+' value="'+esc(c.budget??'')+'" placeholder="'+NEW_CP_BUDGET+'" style="width:78px"></td>';
|
|
1008
|
-
html+='<td><select class="cell-in fmt" style="min-width:82px" '+a('biddingStrategy')+'><option value="">(既定)</option><option'+(c.biddingStrategy==='tCPA'?' selected':'')+'>tCPA</option><option'+(c.biddingStrategy==='最大配信'?' selected':'')+'>最大配信</option></select></td>';
|
|
1009
|
-
html+='<td><input class="cell-in num" type="number" '+a('tcpa')+' value="'+esc(c.tcpa??'')+'" placeholder="'+st.project.tcpaDefault+'" style="width:70px"></td>';
|
|
1010
|
-
html+='<td><input class="cell-in" '+a('variable')+' value="'+esc(c.variable||'')+'" style="width:140px"></td>';
|
|
1011
|
-
html+='<td><input class="cell-in mono" '+a('urlNumber')+' value="'+esc(c.urlNumber||'')+'" style="width:54px"></td>';
|
|
1012
|
-
} else {
|
|
1013
|
-
html+='<td><span class="robadge" title="既存キャンペーンのため編集不可">'+esc(c.name||'')+'</span></td>';
|
|
1014
|
-
html+='<td><span class="idpill" style="color:var(--faint)">—</span></td>';
|
|
1015
|
-
html+='<td><span class="idpill" style="color:var(--faint)">—</span></td>';
|
|
1016
|
-
html+='<td><span class="idpill" style="color:var(--faint)">—</span></td>';
|
|
1017
|
-
html+='<td>'+(c.variable?'<span class="robadge">'+esc(c.variable)+'</span>':'<span class="idpill" style="color:var(--faint)">—</span>')+'</td>';
|
|
1018
|
-
html+='<td><span class="idpill" style="color:var(--faint)">—</span></td>';
|
|
1019
|
-
}
|
|
1020
|
-
html+='<td><span class="rowact"><button class="iconbtn" data-act="cp-dup" data-u="'+c.uid+'" title="複製/量産">⧉</button><button class="iconbtn danger" data-act="cp-del" data-u="'+c.uid+'" title="削除">✕</button></span></td>';
|
|
1021
|
-
html+='</tr>';
|
|
1022
|
-
});
|
|
1023
|
-
html+='<tr><td></td><td colspan="8"><button class="addbtn" data-act="add-campaign">+ 新規キャンペーン</button> <button class="addbtn" data-act="add-campaign-existing">📂 既存キャンペーンを呼び出し</button></td></tr>';
|
|
1024
|
-
html+='</tbody></table>'; if(!st.campaigns.length) html='<div class="empty">キャンペーンがありません。「+ 新規キャンペーン」または「📂 既存キャンペーンを呼び出し」から。</div>';
|
|
1025
|
-
host.innerHTML=html; restore(); applySelection();
|
|
1619
|
+
return function(){ if(fu&&ff){ const el=host.querySelector('[data-'+attrU+'="'+fu+'"][data-'+attrF+'="'+ff+'"]'); if(el){ el.focus(); try{ if(ss!=null&&el.setSelectionRange) el.setSelectionRange(ss,ss);}catch(_){}}}};
|
|
1026
1620
|
}
|
|
1027
1621
|
function renderTree(){
|
|
1028
1622
|
if(st.campaigns.length===0){ document.getElementById('treeHost').innerHTML='<div class="empty">キャンペーンがありません。</div><div class="treeadd" style="margin-top:10px"><button class="addbtn" data-act="add-campaign">+ 新規キャンペーン</button><button class="addbtn" data-act="add-campaign-existing" style="margin-left:6px">📂 既存キャンペーンを呼び出し</button></div>'; return; }
|
|
@@ -1040,10 +1634,7 @@ function renderTree(){
|
|
|
1040
1634
|
: '<span class="nodename" title="既存キャンペーンのため編集不可">'+esc(c.name)+'</span>';
|
|
1041
1635
|
html+='<span class="metae"><span class="ratio">'+esc(ratio)+'</span>'+newOrIdBadge(isNewCampaign(c), resolveCampaignIdOf(c));
|
|
1042
1636
|
// 予算/入札は編集可(新規扱い)のCPのみ表示 (既存呼び出しCPは広告追加専用で予算等は無視)
|
|
1043
|
-
if(cpEd)
|
|
1044
|
-
html+='<span class="chip">予算 <b>'+esc((c.budget??NEW_CP_BUDGET).toLocaleString())+'</b>円</span>';
|
|
1045
|
-
html+='<span class="chip">'+esc(c.biddingStrategy||st.project.biddingStrategyDefault)+' <b>'+esc((c.tcpa??st.project.tcpaDefault))+'</b></span>';
|
|
1046
|
-
}
|
|
1637
|
+
if(cpEd) html+=pf().tree.campaignBadges(c, pf(), st.project); // S7: 予算/入札の見せ方は媒体スキーマ
|
|
1047
1638
|
if(c.variable) html+='<span class="pill purple">変数:'+esc(c.variable)+'</span>';
|
|
1048
1639
|
if(cerr) html+='<span class="pill danger">要修正</span>';
|
|
1049
1640
|
html+='</span>';
|
|
@@ -1057,7 +1648,7 @@ function renderTree(){
|
|
|
1057
1648
|
html+='<div class="noderow"><button class="caret" data-act="toggle" data-u="'+g.uid+'"><span class="a"></span></button>';
|
|
1058
1649
|
html+= gEd ? '<span class="nodename" contenteditable="true" data-rename="ag" data-u="'+g.uid+'">'+esc(g.name)+'</span>'
|
|
1059
1650
|
: '<span class="nodename" title="既存広告グループのため編集不可">'+esc(g.name)+'</span>';
|
|
1060
|
-
html+='<span class="metae"
|
|
1651
|
+
html+='<span class="metae">'+pf().tree.groupBadges(g)+'<span class="chip">広告 <b>'+g.ads.length+'</b></span>';
|
|
1061
1652
|
html+= newOrIdBadge(isNewGroup(g), resolveAdGroupIdOf(g));
|
|
1062
1653
|
html+='</span>';
|
|
1063
1654
|
html+='<span class="nodeact">'+(gEd?'<button class="iconbtn" data-act="ag-param" data-u="'+g.uid+'" title="paramCode編集">#</button>':'')+'<button class="iconbtn" data-act="ag-dup" data-u="'+g.uid+'" title="複製">⧉</button><button class="iconbtn danger" data-act="ag-del" data-u="'+g.uid+'" title="削除">✕</button></span>';
|
|
@@ -1066,15 +1657,15 @@ function renderTree(){
|
|
|
1066
1657
|
const r=resolveAd(a,g); const dot=r.errs.length?'err':(r.warns.length?'warn':'ok');
|
|
1067
1658
|
html+='<div class="adnode'+(r.errs.length?' hasErr':'')+'" data-ad="'+a.uid+'">';
|
|
1068
1659
|
html+='<span class="dot '+dot+'"></span>';
|
|
1069
|
-
html+=
|
|
1070
|
-
html+='<span class="adttl">'+(r.
|
|
1660
|
+
html+=pf().tree.adBadge(a,r); // S7: クリエイティブ名バッジ (媒体スキーマ)
|
|
1661
|
+
html+='<span class="adttl">'+(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>';
|
|
1071
1662
|
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>';
|
|
1072
1663
|
html+='</div>';
|
|
1073
1664
|
});
|
|
1074
1665
|
html+='<div class="addrow"><button class="addbtn" data-act="ag-addad" data-u="'+g.uid+'">+ 広告を追加</button><button class="addbtn" data-act="ag-addn" data-u="'+g.uid+'">+ N本まとめて</button></div>';
|
|
1075
1666
|
html+='</div></div>';
|
|
1076
1667
|
});
|
|
1077
|
-
html+='<div class="addrow"><button class="addbtn" data-act="cp-addag" data-u="'+c.uid+'">+
|
|
1668
|
+
html+='<div class="addrow"><button class="addbtn" data-act="cp-addag" data-u="'+c.uid+'">+ 新規'+esc(agLabel())+'</button><button class="addbtn" data-act="cp-addag-existing" data-u="'+c.uid+'">📂 既存'+esc(agLabel())+'を呼び出し</button></div>';
|
|
1078
1669
|
html+='</div></div>';
|
|
1079
1670
|
});
|
|
1080
1671
|
// E-1: ツリー側のキャンペーン追加導線(新規/既存の2択)
|
|
@@ -1086,92 +1677,765 @@ function renderTree(){
|
|
|
1086
1677
|
REFERENCE DATA (タイトル/説明一覧・登録画像一覧) — 閲覧+登録
|
|
1087
1678
|
============================================================ */
|
|
1088
1679
|
let refTab='titles';
|
|
1089
|
-
|
|
1090
|
-
|
|
1680
|
+
/* 新規追加に一時的な new ラベル(リロードで消える)。テーブルキーごとに1セット。 */
|
|
1681
|
+
const newRefIds=(()=>{ const o={}; refTableKeys().forEach(k=>{ o[k]=new Set(); }); return o; })();
|
|
1682
|
+
function newBadge(key,no){ const s=newRefIds[key]; return (s&&s.has(String(no))) ? '<span class="newbadge">new</span>' : ''; }
|
|
1683
|
+
/* refcombo 列が引く参照テーブルの key 群 (宣言順・重複排除) */
|
|
1684
|
+
function refDatalistKeys(){
|
|
1685
|
+
const out=[], S=pf().sheets; Object.keys(S).forEach(t=>S[t].forEach(c=>{
|
|
1686
|
+
if(c.kind==='refcombo' && c.refTable && refTableDef(c.refTable) && out.indexOf(c.refTable)<0) out.push(c.refTable); }));
|
|
1687
|
+
return out;
|
|
1688
|
+
}
|
|
1689
|
+
function refSortedKeys(T,store){
|
|
1690
|
+
const ks=Object.keys(store||{});
|
|
1691
|
+
return T.keyKind==='number' ? ks.sort((a,b)=>a-b) : ks.sort((a,b)=>String(a).localeCompare(String(b)));
|
|
1692
|
+
}
|
|
1091
1693
|
function buildDatalists(){
|
|
1092
1694
|
const host=document.getElementById('dlHost'); if(!host) return;
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1695
|
+
host.innerHTML=refDatalistKeys().map(k=>{
|
|
1696
|
+
const T=refTableDef(k), store=st.refs[k]||{};
|
|
1697
|
+
const opts=refSortedKeys(T,store).map(no=>'<option value="'+esc(no)+'">'+esc(T.optionLabel?T.optionLabel(store[no]):'')+'</option>').join('');
|
|
1698
|
+
return '<datalist id="dl-'+k+'">'+opts+'</datalist>';
|
|
1699
|
+
}).join('');
|
|
1700
|
+
}
|
|
1701
|
+
/* 検索対象文字列 (キー + 全 valueFields の値) */
|
|
1702
|
+
function refSearchStrings(T,no,val){
|
|
1703
|
+
const out=[String(no).toLowerCase()];
|
|
1704
|
+
if(T.valueFields.length===1) out.push(String(val==null?'':val).toLowerCase());
|
|
1705
|
+
else T.valueFields.forEach(f=>out.push(String((val||{})[f.k]||'').toLowerCase()));
|
|
1706
|
+
return out;
|
|
1707
|
+
}
|
|
1708
|
+
function refTh(w,label){ return '<th'+(w?' style="width:'+w+'"':'')+'>'+esc(label)+'</th>'; }
|
|
1709
|
+
function refTd(col,no,val){
|
|
1710
|
+
const cls=typeof col.cls==='function'?col.cls(no,val):col.cls;
|
|
1711
|
+
return '<td'+(cls?' class="'+cls+'"':'')+(col.style?' style="'+col.style+'"':'')+(col.attr?col.attr(no,val):'')+'>'+col.cell(no,val)+'</td>';
|
|
1712
|
+
}
|
|
1713
|
+
function refActionTd(T,no){
|
|
1714
|
+
return '<td><button class="iconbtn" data-refedit="'+T.key+'" data-no="'+esc(no)+'" title="編集">✎</button>'
|
|
1715
|
+
+ '<button class="iconbtn danger" data-refdel="'+T.key+'" data-no="'+esc(no)+'" title="削除">✕</button></td>';
|
|
1098
1716
|
}
|
|
1099
1717
|
function renderRef(){
|
|
1100
1718
|
const q=(document.getElementById('refSearch').value||'').trim().toLowerCase();
|
|
1101
|
-
const host=document.getElementById('refHost');
|
|
1719
|
+
const host=document.getElementById('refHost');
|
|
1720
|
+
const T=refTableDef(refTab); if(!T){ host.innerHTML=''; return; }
|
|
1102
1721
|
// アカウントアセット: 広告グループ/キャンペーンは既存(アカウント)の閲覧のみ → 追加/文言コピーは非表示
|
|
1103
|
-
const editable = (refTab==='titles'||refTab==='descriptions'||refTab==='media');
|
|
1104
1722
|
const setDisp=(id,show)=>{ const el=document.getElementById(id); if(el) el.style.display=show?'':'none'; };
|
|
1105
|
-
setDisp('btnRefAdd', editable); setDisp('btnAccCopy',
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
if(
|
|
1117
|
-
const store=st.refs.campaigns||{};
|
|
1118
|
-
const all=Object.keys(store).map(id=>({id, ...store[id]}));
|
|
1119
|
-
const f=all.filter(e=>!q||String(e.id).includes(q)||String(e.name||'').toLowerCase().includes(q)).sort((a,b)=>String(a.id).localeCompare(String(b.id)));
|
|
1120
|
-
document.getElementById('refCount').textContent='キャンペーン '+f.length+'/'+all.length+'件';
|
|
1121
|
-
html='<table class="reftable"><thead><tr><th style="width:120px">キャンペーンID</th><th>キャンペーン名</th></tr></thead><tbody>';
|
|
1122
|
-
f.forEach(e=>{ html+='<tr><td class="mono">'+esc(e.id)+'</td><td class="reftext" title="'+esc(e.name||'')+'">'+esc(e.name||'')+'</td></tr>'; });
|
|
1123
|
-
html+='</tbody></table>'; if(!f.length) html='<div class="empty">既存のキャンペーンがありません(アカウントJSONの refs.campaigns)</div>';
|
|
1124
|
-
host.innerHTML=html; return;
|
|
1125
|
-
}
|
|
1126
|
-
if(refTab==='titles'||refTab==='descriptions'){
|
|
1127
|
-
const isTitle=refTab==='titles';
|
|
1128
|
-
const store=isTitle?st.refs.titles:(st.refs.descriptions||{});
|
|
1129
|
-
const kind=isTitle?'t':'d';
|
|
1130
|
-
const label=isTitle?'タイトル':'説明文';
|
|
1131
|
-
const all=Object.keys(store).map(no=>({no:+no,text:store[no]})).filter(e=>String(e.text||'').trim()!=='');
|
|
1132
|
-
const f=all.filter(e=>!q||String(e.no).includes(q)||String(e.text).toLowerCase().includes(q)).sort((a,b)=>a.no-b.no);
|
|
1133
|
-
document.getElementById('refCount').textContent=label+' '+f.length+'/'+all.length+'件';
|
|
1134
|
-
html='<table class="reftable"><thead><tr><th style="width:70px">番号</th><th>'+esc(label)+'</th><th style="width:70px">文字数</th><th style="width:70px"></th></tr></thead><tbody>';
|
|
1135
|
-
f.forEach(e=>{ const L=glen(e.text); const cls=(isTitle&&L>35)?'err':(isTitle&&L>20?'warn':''); html+='<tr><td class="mono">'+esc(e.no)+'</td><td class="reftext" title="'+esc(e.text)+'">'+esc(e.text)+'</td><td class="reflen '+cls+'">'+L+'</td><td><button class="iconbtn" data-refedit="'+kind+'" data-no="'+esc(e.no)+'" title="編集">✎</button><button class="iconbtn danger" data-refdel="'+kind+'" data-no="'+esc(e.no)+'" title="削除">✕</button></td></tr>'; });
|
|
1136
|
-
html+='</tbody></table>'; if(!f.length) html='<div class="empty">該当する'+esc(label)+'がありません</div>';
|
|
1137
|
-
} else {
|
|
1138
|
-
const all=Object.keys(st.refs.media).map(no=>({no:+no,m:st.refs.media[no]}));
|
|
1139
|
-
const f=all.filter(e=>!q||String(e.no).includes(q)||String(e.m.card||'').includes(q)||String(e.m.sq||'').includes(q)).sort((a,b)=>a.no-b.no);
|
|
1140
|
-
document.getElementById('refCount').textContent='登録画像 '+f.length+'/'+all.length+'件';
|
|
1141
|
-
html='<table class="reftable"><thead><tr><th style="width:80px">画像番号</th><th>Card mediaID (1200)</th><th>SQ mediaID (1080)</th><th>ファイル名</th><th style="width:70px"></th></tr></thead><tbody>';
|
|
1142
|
-
f.forEach(e=>{ html+='<tr><td><span class="sw" style="display:inline-flex;width:26px;height:26px;vertical-align:middle">'+esc(e.no)+'</span> '+newBadge('media',e.no)+'</td><td class="mono">'+esc(e.m.card||'—')+'</td><td class="mono">'+esc(e.m.sq||'—')+'</td><td class="mono" style="color:var(--faint);font-size:11px">'+esc(e.no)+'-1200.jpg / '+esc(e.no)+'-1080.jpg</td><td><button class="iconbtn" data-refedit="m" data-no="'+esc(e.no)+'" title="編集">✎</button><button class="iconbtn danger" data-refdel="m" data-no="'+esc(e.no)+'" title="削除">✕</button></td></tr>'; });
|
|
1143
|
-
html+='</tbody></table>'; if(!f.length) html='<div class="empty">該当する画像がありません</div>';
|
|
1144
|
-
}
|
|
1723
|
+
setDisp('btnRefAdd', !!T.editable); setDisp('btnAccCopy', !!T.copyable); setDisp('btnRefReset', !!T.editable);
|
|
1724
|
+
const store=st.refs[T.key]||{};
|
|
1725
|
+
let all=refSortedKeys(T,store).map(k=>({no:(T.keyKind==='number'?+k:k), val:store[k]}));
|
|
1726
|
+
if(T.dropEmpty) all=all.filter(e=>String(e.val==null?'':e.val).trim()!=='');
|
|
1727
|
+
const f=all.filter(e=>!q||refSearchStrings(T,e.no,e.val).some(s=>s.includes(q)));
|
|
1728
|
+
document.getElementById('refCount').textContent=T.countLabel+' '+f.length+'/'+all.length+'件';
|
|
1729
|
+
let html='<table class="reftable"><thead><tr>'+refTh(T.keyWidth,T.keyLabel)
|
|
1730
|
+
+T.columns.map(c=>refTh(c.width,c.label)).join('')+(T.editable?refTh('70px',''):'')+'</tr></thead><tbody>';
|
|
1731
|
+
f.forEach(e=>{ html+='<tr>'+T.keyCell(e.no,e.val)+T.columns.map(c=>refTd(c,e.no,e.val)).join('')
|
|
1732
|
+
+(T.editable?refActionTd(T,e.no):'')+'</tr>'; });
|
|
1733
|
+
html+='</tbody></table>';
|
|
1734
|
+
if(!f.length) html='<div class="empty">'+esc(T.emptyMsg)+'</div>';
|
|
1145
1735
|
host.innerHTML=html;
|
|
1146
1736
|
}
|
|
1147
1737
|
function refCommit(fn,msg){ snapshot(); fn(); persist(); buildDatalists(); render(); if(typeof saveActiveAccountJson==='function') saveActiveAccountJson(); toast(msg); }
|
|
1738
|
+
/* valueFields が1件のテーブルは文言そのもの(文字列)、複数件はオブジェクトを格納する */
|
|
1739
|
+
function refIsSingle(T){ return T.valueFields.length===1; }
|
|
1148
1740
|
function refAdd(){
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1741
|
+
const T=refTableDef(refTab); if(!T||!T.editable) return;
|
|
1742
|
+
const single=refIsSingle(T);
|
|
1743
|
+
openModal({title:T.label+'を登録', sub:T.addSub,
|
|
1744
|
+
fields:[{key:'no',label:T.keyLabel,type:'number',value:''}]
|
|
1745
|
+
.concat(T.valueFields.map(fd=>({key:fd.k,label:fd.label,type:'text',value:''}))),
|
|
1746
|
+
onOk:v=>{
|
|
1747
|
+
const no=parseInt(v.no); if(!no){ toast(T.keyLabel+'を入力してください'); return; }
|
|
1748
|
+
const store=st.refs[T.key];
|
|
1749
|
+
if(single){ const k=T.valueFields[0].k, dup=store[no]!=null;
|
|
1750
|
+
refCommit(()=>{ store[no]=v[k]; }, dup?(T.keyLabel+no+'を上書き'):'登録しました'); return; }
|
|
1751
|
+
const isNew=store[no]==null;
|
|
1752
|
+
refCommit(()=>{ const e=Object.assign({},store[no]||{});
|
|
1753
|
+
T.valueFields.forEach(fd=>{ if(v[fd.k]!=='') e[fd.k]=String(v[fd.k]).trim(); });
|
|
1754
|
+
store[no]=e; if(isNew) newRefIds[T.key].add(String(no)); }, '登録しました');
|
|
1755
|
+
}});
|
|
1756
|
+
}
|
|
1757
|
+
function refEdit(tableKey,no){
|
|
1758
|
+
const T=refTableDef(tableKey); if(!T||!T.editable) return;
|
|
1759
|
+
const store=st.refs[T.key], cur=store[no], single=refIsSingle(T);
|
|
1760
|
+
openModal({title:T.label+'を編集', sub:T.keyLabel+' '+no,
|
|
1761
|
+
fields:T.valueFields.map(fd=>({key:fd.k, label:fd.editLabel||fd.label, type:'text',
|
|
1762
|
+
value:single?cur:((cur||{})[fd.k]||'')})),
|
|
1763
|
+
onOk:v=>{ refCommit(()=>{
|
|
1764
|
+
if(single){ store[no]=v[T.valueFields[0].k]; return; }
|
|
1765
|
+
const e={}; T.valueFields.forEach(fd=>{ e[fd.k]=String(v[fd.k]).trim(); }); store[no]=e;
|
|
1766
|
+
}, '更新しました'); }});
|
|
1767
|
+
}
|
|
1768
|
+
function refDel(tableKey,no){
|
|
1769
|
+
const T=refTableDef(tableKey); if(!T||!T.editable) return;
|
|
1770
|
+
refCommit(()=>{ delete st.refs[T.key][no]; }, '削除しました');
|
|
1163
1771
|
}
|
|
1164
|
-
function refDel(kind,no){ refCommit(()=>{ if(kind==='t') delete st.refs.titles[no]; else if(kind==='d') delete st.refs.descriptions[no]; else delete st.refs.media[no]; }, '削除しました'); }
|
|
1165
1772
|
|
|
1166
1773
|
/* ============================================================
|
|
1167
1774
|
SPREADSHEET SELECTION / COPY / PASTE
|
|
1168
1775
|
============================================================ */
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1776
|
+
/* ---- S3/S4/S5: 列記述子 (COLS) ----
|
|
1777
|
+
シート描画 (renderCell) / セル値アクセス (getVar/setVar) / 貼付 / 連番 / ドロワーが見る唯一の列定義。
|
|
1778
|
+
列番号リテラルはコードから撤去済み。S7 で PLATFORMS[curPf].sheets へ移設する。
|
|
1779
|
+
・kind : 'text'|'number'|'mono'|'select'|'refcombo'|'tgpick'|'badge'|'derived'
|
|
1780
|
+
・width : 入力欄の style="width:…"(select は minWidth で min-width、refcombo/tgpick は無指定)
|
|
1781
|
+
・scope : 'parentGroup' = 広告行から所属グループの値を読み書きする (YDA paramCode)
|
|
1782
|
+
・coerce : setVar 時の値正規化 / onSet: 書き込み後フック (paramCode の ID 再解決)
|
|
1783
|
+
・required : 空欄で .warnempty (黄枠)
|
|
1784
|
+
・alwaysReadonly / readonlyOnExisting / hideOnExisting / roEmptyDash / roStyle / roTitle
|
|
1785
|
+
: read-only 表現の指定 (renderCell の ro 分岐 / setVar の書込ガードを兼ねる)
|
|
1786
|
+
・refTable : refcombo が引く参照テーブル (media/titles/descriptions)
|
|
1787
|
+
・drawerType: ▼ドロワーの表示モード ('media' = 登録画像一覧 / 'titles' = 文言ライブラリ)。
|
|
1788
|
+
説明文番号は store が descriptions でも表示モードは 'titles' (現行踏襲)
|
|
1789
|
+
・ghostKey / ghostTitle / ghost / ddTitle : refcombo の下段ゴースト表示と ▼ボタン
|
|
1790
|
+
・derive : kind:'derived' のセル内容 (resolveAd 由来の読取専用セル)
|
|
1791
|
+
・tgKey : pf().targeting のキー
|
|
1792
|
+
・sequenceable: 連番採番の対象列 / notSubmitted: 入稿JSONに載らない列 (書き出し時の警告対象) */
|
|
1793
|
+
const COLS = {
|
|
1794
|
+
ads:[
|
|
1795
|
+
{field:'format', label:'形式', kind:'select', options:['Card','SQ'], width:'56px',
|
|
1796
|
+
coerce:v=>(String(v).toUpperCase()==='SQ'?'SQ':'Card')},
|
|
1797
|
+
{field:'imageNo', label:'画像番号', kind:'refcombo', refTable:'media', drawerType:'media', mediaAxis:'format',
|
|
1798
|
+
width:'6ch', sequenceable:true, errCodes:[EC.IMAGE_UNRESOLVED],
|
|
1799
|
+
ghostKey:'media', ddTitle:'登録画像から選択', ghostTitle:r=>'メディアID '+r.mediaId,
|
|
1800
|
+
ghost:(r,a)=>{ if(!a.imageNo) return '<span class="idpill" style="color:var(--faint)">—</span>';
|
|
1801
|
+
if(r.mediaId!=='') return '<span class="idpill">ID '+esc(r.mediaId)+'</span>';
|
|
1802
|
+
if(r.pendingImage) return '<span class="idpill warn" title="アカウント未登録。入稿前にアップロードして新規登録">🆕未登録</span>';
|
|
1803
|
+
return '<span class="idpill err">#N/A</span>'; }},
|
|
1804
|
+
{field:'titleNo', label:'タイトル番号', kind:'refcombo', refTable:'titles', drawerType:'titles',
|
|
1805
|
+
width:'6ch', sequenceable:true, errCodes:[EC.TITLE_UNRESOLVED, EC.TITLE_TOO_LONG],
|
|
1806
|
+
ghostKey:'title', ddTitle:'タイトル一覧から選択', ghostTitle:r=>r.titleText,
|
|
1807
|
+
ghost:r=>r.errCodes.includes(EC.TITLE_UNRESOLVED)?'<span class="err">未解決</span>':esc(r.titleText.slice(0,40))},
|
|
1808
|
+
{field:'descNo', label:'説明文番号', kind:'refcombo', refTable:'descriptions', drawerType:'titles',
|
|
1809
|
+
width:'6ch', sequenceable:true, errCodes:[EC.DESC_UNRESOLVED],
|
|
1810
|
+
ghostKey:'desc', ddTitle:'説明文一覧から選択', ghostTitle:r=>r.descText,
|
|
1811
|
+
ghost:r=>r.errCodes.includes(EC.DESC_UNRESOLVED)?'<span class="err">— 未解決</span>':esc(r.descText.slice(0,44))},
|
|
1812
|
+
{field:'paramCode', label:'paramCode(グループ)', kind:'mono', width:'120px',
|
|
1813
|
+
scope:'parentGroup', sequenceable:true, readonlyOnExisting:true, errCodes:[EC.PARAM_CODE_EMPTY],
|
|
1814
|
+
title:'グループ共通(変更で同グループ全広告に反映)',
|
|
1815
|
+
roStyle:'input', roTitle:'既存広告グループのため編集不可',
|
|
1816
|
+
onSet:it=>autoResolveGroupIds(it.g,it.c)},
|
|
1817
|
+
{field:'articleSlug', label:'記事slug', kind:'mono', width:'90px'},
|
|
1818
|
+
/* derived: resolveAd() 由来の読取専用セル。tabCols()/貼付/セル選択の対象外 (末尾固定) */
|
|
1819
|
+
{field:'adName', label:'広告名(自動)', kind:'derived',
|
|
1820
|
+
derive:(a,ctx)=>'<span class="idpill" data-d="'+a.uid+'-adname" title="'+esc(ctx.r.finalUrl)+'">'+esc(ctx.r.adName)+'</span>'},
|
|
1821
|
+
{field:'adGroupId', label:'CP / AG ID', kind:'derived',
|
|
1822
|
+
derive:(a,ctx)=>'<span class="idpill '+(ctx.r.resolvedGroup?'':'warn')+'" data-d="'+a.uid+'-ids">'+esc(ctx.r.adGroupId?('AG '+ctx.r.adGroupId):'新規')+'</span>'}
|
|
1823
|
+
],
|
|
1824
|
+
/* F-6 列順: adGroupId(index0・非編集バッジ) → name → paramCode → 入札/配信/ターゲティング */
|
|
1825
|
+
adGroups:[
|
|
1826
|
+
{field:'adGroupId', label:'広告グループID', kind:'badge', alwaysReadonly:true},
|
|
1827
|
+
{field:'name', label:'広告グループ名', kind:'text', width:'150px', required:true,
|
|
1828
|
+
readonlyOnExisting:true, roTitle:'既存広告グループのため編集不可'},
|
|
1829
|
+
{field:'paramCode', label:'paramCode', kind:'mono', width:'120px', required:true,
|
|
1830
|
+
sequenceable:true, readonlyOnExisting:true,
|
|
1831
|
+
title:'URL生成用のパラメータ(グループ紐づけには不使用)',
|
|
1832
|
+
onSet:it=>autoResolveGroupIds(it.g,it.c)},
|
|
1833
|
+
{field:'bid', label:'入札', kind:'text', width:'56px', readonlyOnExisting:true, roEmptyDash:true},
|
|
1834
|
+
{field:'deliveryStatus', label:'配信', kind:'select', options:['','オフ','オン'], minWidth:'66px',
|
|
1835
|
+
readonlyOnExisting:true, roEmptyDash:true, coerce:v=>{const s=String(v).trim(); return (s==='オフ'||s==='オン')?s:'';}},
|
|
1836
|
+
{field:'tg_device', label:'デバイス', kind:'tgpick', tgKey:'tg_device', readonlyOnExisting:true, roEmptyDash:true},
|
|
1837
|
+
{field:'tg_gender', label:'性別', kind:'tgpick', tgKey:'tg_gender', readonlyOnExisting:true, roEmptyDash:true},
|
|
1838
|
+
{field:'tg_age', label:'年齢', kind:'tgpick', tgKey:'tg_age', readonlyOnExisting:true, roEmptyDash:true},
|
|
1839
|
+
{field:'tg_region', label:'地域', kind:'tgpick', tgKey:'tg_region', readonlyOnExisting:true, roEmptyDash:true, notSubmitted:true},
|
|
1840
|
+
{field:'tg_dayHour', label:'曜日時間', kind:'tgpick', tgKey:'tg_dayHour', readonlyOnExisting:true, roEmptyDash:true},
|
|
1841
|
+
{field:'tg_audience', label:'オーディエンス', kind:'tgpick', tgKey:'tg_audience', readonlyOnExisting:true, roEmptyDash:true, notSubmitted:true},
|
|
1842
|
+
/* 暗黙 else を廃止: pf().targeting に無い列は kind:'text' で明示する */
|
|
1843
|
+
{field:'tg_searchKw', label:'サーチKW', kind:'text', width:'120px', readonlyOnExisting:true, roEmptyDash:true, notSubmitted:true}
|
|
1844
|
+
],
|
|
1845
|
+
/* E-2: campaignId最左・read-only / mode/status/strategyMemo撤去 */
|
|
1846
|
+
campaigns:[
|
|
1847
|
+
{field:'campaignId', label:'キャンペーンID', kind:'badge', alwaysReadonly:true},
|
|
1848
|
+
{field:'name', label:'キャンペーン名', kind:'text', width:'170px', required:true,
|
|
1849
|
+
readonlyOnExisting:true, roTitle:'既存キャンペーンのため編集不可'},
|
|
1850
|
+
{field:'budget', label:'予算/日', kind:'number', width:'78px',
|
|
1851
|
+
placeholder:()=>String(pf().defaults.newCampaignBudget), coerce:numOrNull, hideOnExisting:true},
|
|
1852
|
+
{field:'biddingStrategy', label:'入札戦略', kind:'select', options:['','tCPA','最大配信'], minWidth:'82px',
|
|
1853
|
+
hideOnExisting:true, emptyNoSelected:true},
|
|
1854
|
+
{field:'tcpa', label:'tCPA', kind:'number', width:'70px',
|
|
1855
|
+
placeholder:()=>String(st.project.tcpaDefault), coerce:numOrNull, hideOnExisting:true},
|
|
1856
|
+
{field:'variable', label:'検証変数', kind:'text', width:'140px', readonlyOnExisting:true, roEmptyDash:true},
|
|
1857
|
+
{field:'urlNumber', label:'リンク番号', kind:'mono', width:'54px', hideOnExisting:true}
|
|
1858
|
+
]
|
|
1859
|
+
};
|
|
1860
|
+
/* S7: 列記述子を媒体スキーマレジストリへ接続 (宣言順の都合で後付け。COLS が唯一の定義元) */
|
|
1861
|
+
PLATFORMS.yda.sheets = COLS;
|
|
1862
|
+
|
|
1863
|
+
/* ============================================================
|
|
1864
|
+
S10: PLATFORMS.meta — Meta (Facebook / Instagram) の媒体スキーマ
|
|
1865
|
+
------------------------------------------------------------
|
|
1866
|
+
コア (renderCell / renderSheetGeneric / buildSubmitPlanWithStats / resolveAd) は無変更で、
|
|
1867
|
+
この宣言だけで Meta 版プランナーが成立する。
|
|
1868
|
+
公式Docs照合 (20260730_Meta_MarketingAPI公式Docs照合ギャップ.md) を先回りして反映:
|
|
1869
|
+
・instagram_actor_id ではなく instagramUserId (B2)
|
|
1870
|
+
・promoted_object 用の pixelId + customEventType を必須ガード (B3)
|
|
1871
|
+
・日予算は円のまま (JPY currency_offset=1 / ×100 しない) (B4)
|
|
1872
|
+
・bid_amount が必須になる入札戦略で bidAmount>0 を強制 (B5)
|
|
1873
|
+
・bidStrategy は公式4値のみ (MINIMUM_ROAS は載せない) (B6)
|
|
1874
|
+
・特別広告カテゴリで genders を送らず age を 18-65 に固定 (B9)
|
|
1875
|
+
・startTime は JST 明示 ISO8601 (B13)
|
|
1876
|
+
============================================================ */
|
|
1877
|
+
const META_REF_TABLES = {
|
|
1878
|
+
titles:{ key:'titles', label:'見出し', countLabel:'見出し', editable:true, copyable:true, required:true,
|
|
1879
|
+
keyKind:'number', keyLabel:'番号', keyWidth:'70px', dropEmpty:true,
|
|
1880
|
+
addSub:'番号→見出し。推奨27字・上限40字',
|
|
1881
|
+
emptyMsg:'該当する見出しがありません',
|
|
1882
|
+
lengthLimit:{warn:27, err:40},
|
|
1883
|
+
valueFields:[{k:'text', label:'文言'}],
|
|
1884
|
+
keyCell:no=>'<td class="mono">'+esc(no)+'</td>',
|
|
1885
|
+
columns:[
|
|
1886
|
+
{label:'見出し', cls:'reftext', attr:(no,v)=>' title="'+esc(v)+'"', cell:(no,v)=>esc(v)},
|
|
1887
|
+
{label:'文字数', width:'70px', cls:(no,v)=>'reflen '+refLenCls(META_REF_TABLES.titles,v), cell:(no,v)=>glen(v)}
|
|
1888
|
+
],
|
|
1889
|
+
optionLabel:v=>String(v==null?'':v) },
|
|
1890
|
+
descriptions:{ key:'descriptions', label:'メインテキスト', countLabel:'メインテキスト', editable:true, copyable:true, required:true,
|
|
1891
|
+
keyKind:'number', keyLabel:'番号', keyWidth:'70px', dropEmpty:true,
|
|
1892
|
+
addSub:'番号→メインテキスト(推奨125字・運用上限500字)',
|
|
1893
|
+
emptyMsg:'該当するメインテキストがありません',
|
|
1894
|
+
lengthLimit:{warn:125, err:500},
|
|
1895
|
+
valueFields:[{k:'text', label:'文言'}],
|
|
1896
|
+
keyCell:no=>'<td class="mono">'+esc(no)+'</td>',
|
|
1897
|
+
columns:[
|
|
1898
|
+
{label:'メインテキスト', cls:'reftext', attr:(no,v)=>' title="'+esc(v)+'"', cell:(no,v)=>esc(v)},
|
|
1899
|
+
{label:'文字数', width:'70px', cls:(no,v)=>'reflen '+refLenCls(META_REF_TABLES.descriptions,v), cell:(no,v)=>glen(v)}
|
|
1900
|
+
],
|
|
1901
|
+
optionLabel:v=>String(v==null?'':v) },
|
|
1902
|
+
media:{ key:'media', label:'クリエイティブ', countLabel:'クリエイティブ', editable:true, required:true,
|
|
1903
|
+
keyKind:'number', keyLabel:'番号', keyWidth:'80px',
|
|
1904
|
+
addSub:'番号→画像 imageHash / 動画 videoId / 動画サムネ imageHash',
|
|
1905
|
+
emptyMsg:'該当するクリエイティブがありません',
|
|
1906
|
+
valueFields:[{k:'image', label:'画像 imageHash', editLabel:'画像 imageHash'},
|
|
1907
|
+
{k:'video', label:'動画 videoId', editLabel:'動画 videoId'},
|
|
1908
|
+
{k:'thumb', label:'動画サムネ imageHash', editLabel:'サムネ imageHash'}],
|
|
1909
|
+
keyCell:no=>'<td><span class="sw" style="display:inline-flex;width:26px;height:26px;vertical-align:middle">'+esc(no)+'</span> '+newBadge('media',no)+'</td>',
|
|
1910
|
+
columns:[
|
|
1911
|
+
{label:'画像 imageHash', cls:'mono', cell:(no,v)=>esc(v&&v.image||'—')},
|
|
1912
|
+
{label:'動画 videoId', cls:'mono', cell:(no,v)=>esc(v&&v.video||'—')},
|
|
1913
|
+
{label:'サムネ hash', cls:'mono', cell:(no,v)=>esc(v&&v.thumb||'—')}
|
|
1914
|
+
],
|
|
1915
|
+
optionLabel:v=>((v&&v.image?'IMG':'')+(v&&v.image&&v.video?'/':'')+(v&&v.video?'VID':'')),
|
|
1916
|
+
drawerLabel:v=>'IMG '+esc(v&&v.image||'—')+' / VID '+esc(v&&v.video||'—') },
|
|
1917
|
+
adGroups:{ key:'adGroups', label:'広告セット', countLabel:'広告セット', editable:false, required:false,
|
|
1918
|
+
keyKind:'string', keyLabel:'広告セットID', keyWidth:'130px',
|
|
1919
|
+
emptyMsg:'既存の広告セットがありません(アカウントJSONの refs.adGroups)',
|
|
1920
|
+
valueFields:[{k:'name',label:'名'},{k:'paramCode',label:'paramCode'},{k:'campaignId',label:'キャンペーンID'}],
|
|
1921
|
+
keyCell:no=>'<td class="mono">'+esc(no)+'</td>',
|
|
1922
|
+
columns:[
|
|
1923
|
+
{label:'広告セット名', cls:'reftext', attr:(no,v)=>' title="'+esc(v&&v.name||'')+'"', cell:(no,v)=>esc(v&&v.name||'')},
|
|
1924
|
+
{label:'paramCode', width:'130px', cls:'mono', cell:(no,v)=>esc(v&&v.paramCode||'')},
|
|
1925
|
+
{label:'キャンペーンID', width:'130px', cls:'mono', cell:(no,v)=>esc(v&&v.campaignId||'')}
|
|
1926
|
+
] },
|
|
1927
|
+
campaigns:{ key:'campaigns', label:'キャンペーン', countLabel:'キャンペーン', editable:false, required:false,
|
|
1928
|
+
keyKind:'string', keyLabel:'キャンペーンID', keyWidth:'130px',
|
|
1929
|
+
emptyMsg:'既存のキャンペーンがありません(アカウントJSONの refs.campaigns)',
|
|
1930
|
+
valueFields:[{k:'name',label:'名'}],
|
|
1931
|
+
keyCell:no=>'<td class="mono">'+esc(no)+'</td>',
|
|
1932
|
+
columns:[
|
|
1933
|
+
{label:'キャンペーン名', cls:'reftext', attr:(no,v)=>' title="'+esc(v&&v.name||'')+'"', cell:(no,v)=>esc(v&&v.name||'')}
|
|
1934
|
+
] }
|
|
1935
|
+
};
|
|
1936
|
+
/* 列記述子 (Meta)。役割・意味は COLS のコメント参照 */
|
|
1937
|
+
const META_COLS = {
|
|
1938
|
+
ads:[
|
|
1939
|
+
{field:'format', label:'形式', kind:'select', options:['IMAGE','VIDEO'], minWidth:'70px',
|
|
1940
|
+
coerce:v=>(String(v).toUpperCase()==='VIDEO'?'VIDEO':'IMAGE')},
|
|
1941
|
+
{field:'imageNo', label:'クリエイティブ番号', kind:'refcombo', refTable:'media', drawerType:'media', mediaAxis:'format',
|
|
1942
|
+
width:'6ch', sequenceable:true, errCodes:[EC.IMAGE_UNRESOLVED],
|
|
1943
|
+
ghostKey:'media', ddTitle:'登録クリエイティブから選択',
|
|
1944
|
+
ghostTitle:r=>(r.mediaKind==='VIDEO'?'動画ID ':'画像ハッシュ ')+r.mediaId,
|
|
1945
|
+
ghost:(r,a)=>{ if(!a.imageNo) return '<span class="idpill" style="color:var(--faint)">—</span>';
|
|
1946
|
+
if(r.mediaId!=='') return '<span class="idpill">'+(r.mediaKind==='VIDEO'?'VID ':'HASH ')+esc(r.mediaId)+'</span>';
|
|
1947
|
+
if(r.pendingImage) return '<span class="idpill warn" title="アカウント未登録。入稿前にアップロードして新規登録">🆕未登録</span>';
|
|
1948
|
+
return '<span class="idpill err">#N/A</span>'; }},
|
|
1949
|
+
{field:'titleNo', label:'見出し番号', kind:'refcombo', refTable:'titles', drawerType:'titles',
|
|
1950
|
+
width:'6ch', sequenceable:true, errCodes:[EC.TITLE_UNRESOLVED, EC.TITLE_TOO_LONG],
|
|
1951
|
+
ghostKey:'title', ddTitle:'見出し一覧から選択', ghostTitle:r=>r.titleText,
|
|
1952
|
+
ghost:r=>r.errCodes.includes(EC.TITLE_UNRESOLVED)?'<span class="err">未解決</span>':esc(r.titleText.slice(0,40))},
|
|
1953
|
+
{field:'descNo', label:'本文番号', kind:'refcombo', refTable:'descriptions', drawerType:'titles',
|
|
1954
|
+
width:'6ch', sequenceable:true, errCodes:[EC.DESC_UNRESOLVED, EC.DESC_TOO_LONG],
|
|
1955
|
+
ghostKey:'desc', ddTitle:'メインテキスト一覧から選択', ghostTitle:r=>r.descText,
|
|
1956
|
+
ghost:r=>r.errCodes.includes(EC.DESC_UNRESOLVED)?'<span class="err">— 未解決</span>':esc(r.descText.slice(0,44))},
|
|
1957
|
+
{field:'paramCode', label:'paramCode(広告セット)', kind:'mono', width:'120px',
|
|
1958
|
+
scope:'parentGroup', sequenceable:true, readonlyOnExisting:true, errCodes:[EC.PARAM_CODE_EMPTY],
|
|
1959
|
+
title:'広告セット共通(変更で同広告セット全広告に反映)',
|
|
1960
|
+
roStyle:'input', roTitle:'既存広告セットのため編集不可'},
|
|
1961
|
+
{field:'articleSlug', label:'記事slug', kind:'mono', width:'90px'},
|
|
1962
|
+
{field:'adName', label:'広告名(自動)', kind:'derived',
|
|
1963
|
+
derive:(a,ctx)=>'<span class="idpill" data-d="'+a.uid+'-adname" title="'+esc(ctx.r.finalUrl)+'">'+esc(ctx.r.adName)+'</span>'},
|
|
1964
|
+
{field:'adGroupId', label:'CP / AdSet ID', kind:'derived',
|
|
1965
|
+
derive:(a,ctx)=>'<span class="idpill '+(ctx.r.resolvedGroup?'':'warn')+'" data-d="'+a.uid+'-ids">'+esc(ctx.r.adGroupId?('AS '+ctx.r.adGroupId):'新規')+'</span>'}
|
|
1966
|
+
],
|
|
1967
|
+
adGroups:[
|
|
1968
|
+
{field:'adGroupId', label:'広告セットID', kind:'badge', alwaysReadonly:true},
|
|
1969
|
+
{field:'name', label:'広告セット名', kind:'text', width:'150px', required:true,
|
|
1970
|
+
readonlyOnExisting:true, roTitle:'既存広告セットのため編集不可'},
|
|
1971
|
+
{field:'paramCode', label:'paramCode', kind:'mono', width:'120px', required:true,
|
|
1972
|
+
sequenceable:true, readonlyOnExisting:true,
|
|
1973
|
+
title:'URL生成用のパラメータ(広告セット紐づけには不使用)'},
|
|
1974
|
+
/* budgetLevel='adGroup' → 予算は広告セット行が持つ (CBO は使わない) */
|
|
1975
|
+
{field:'dailyBudget', label:'日予算(円)', kind:'number', width:'84px', required:true,
|
|
1976
|
+
coerce:numOrNull, readonlyOnExisting:true, roEmptyDash:true, errCodes:[EC.BUDGET_MISSING],
|
|
1977
|
+
placeholder:()=>String(pf().defaults.newCampaignBudget)},
|
|
1978
|
+
{field:'bidAmount', label:'入札上限(円)', kind:'number', width:'84px', coerce:numOrNull,
|
|
1979
|
+
readonlyOnExisting:true, roEmptyDash:true,
|
|
1980
|
+
title:'COST_CAP / LOWEST_COST_WITH_BID_CAP のときは必須'},
|
|
1981
|
+
{field:'ageMin', label:'年齢下限', kind:'number', width:'56px', coerce:v=>clampInt(v,13,65),
|
|
1982
|
+
readonlyOnExisting:true, roEmptyDash:true},
|
|
1983
|
+
{field:'ageMax', label:'年齢上限', kind:'number', width:'56px', coerce:v=>clampInt(v,13,65),
|
|
1984
|
+
readonlyOnExisting:true, roEmptyDash:true},
|
|
1985
|
+
{field:'tg_gender', label:'性別', kind:'tgpick', tgKey:'tg_gender', readonlyOnExisting:true, roEmptyDash:true},
|
|
1986
|
+
{field:'tg_device', label:'デバイス', kind:'tgpick', tgKey:'tg_device', readonlyOnExisting:true, roEmptyDash:true},
|
|
1987
|
+
{field:'tg_placement', label:'配置', kind:'tgpick', tgKey:'tg_placement', readonlyOnExisting:true, roEmptyDash:true},
|
|
1988
|
+
{field:'deliveryStatus', label:'配信', kind:'select', options:['','オフ','オン'], minWidth:'66px',
|
|
1989
|
+
readonlyOnExisting:true, roEmptyDash:true, coerce:v=>{const s=String(v).trim(); return (s==='オフ'||s==='オン')?s:'';}}
|
|
1990
|
+
],
|
|
1991
|
+
campaigns:[
|
|
1992
|
+
{field:'campaignId', label:'キャンペーンID', kind:'badge', alwaysReadonly:true},
|
|
1993
|
+
{field:'name', label:'キャンペーン名', kind:'text', width:'170px', required:true,
|
|
1994
|
+
readonlyOnExisting:true, roTitle:'既存キャンペーンのため編集不可'},
|
|
1995
|
+
{field:'objective', label:'目的', kind:'select', minWidth:'150px', readonlyOnExisting:true,
|
|
1996
|
+
options:['OUTCOME_SALES','OUTCOME_LEADS','OUTCOME_TRAFFIC','OUTCOME_AWARENESS',
|
|
1997
|
+
'OUTCOME_ENGAGEMENT','OUTCOME_APP_PROMOTION']},
|
|
1998
|
+
{field:'specialAdCategory', label:'特別カテゴリ', kind:'select', minWidth:'150px', readonlyOnExisting:true,
|
|
1999
|
+
/* 公式7値 + GUIセンチネル NONE (送信時 [] に変換) */
|
|
2000
|
+
options:['NONE','HOUSING','EMPLOYMENT','CREDIT','ISSUES_ELECTIONS_POLITICS',
|
|
2001
|
+
'ONLINE_GAMBLING_AND_GAMING','FINANCIAL_PRODUCTS_SERVICES']},
|
|
2002
|
+
{field:'variable', label:'検証変数', kind:'text', width:'140px', readonlyOnExisting:true, roEmptyDash:true},
|
|
2003
|
+
{field:'urlNumber', label:'リンク番号', kind:'mono', width:'54px', hideOnExisting:true}
|
|
2004
|
+
]
|
|
2005
|
+
};
|
|
2006
|
+
PLATFORMS.meta = {
|
|
2007
|
+
id:'meta', displayName:'Meta', brandTitle:'Meta入稿プランナー',
|
|
2008
|
+
|
|
2009
|
+
account:{
|
|
2010
|
+
idPattern:/^act_\d+$/, idHint:'Meta 広告アカウントID(act_ 付き)', dir:'アカウント/meta',
|
|
2011
|
+
apply(obj, project){
|
|
2012
|
+
if(obj.accountId!=null){ const s=String(obj.accountId); project.accountId = /^act_/.test(s)?s:('act_'+s); }
|
|
2013
|
+
if(obj.name!=null) project.accountName=String(obj.name);
|
|
2014
|
+
if(obj.principal!=null) project.principal=String(obj.principal);
|
|
2015
|
+
if(obj.pageId!=null) project.pageId=String(obj.pageId);
|
|
2016
|
+
if(obj.instagramUserId!=null) project.instagramUserId=String(obj.instagramUserId);
|
|
2017
|
+
if(obj.pixelId!=null) project.pixelId=String(obj.pixelId);
|
|
2018
|
+
if(obj.projectName) project.name=obj.projectName;
|
|
2019
|
+
if(obj.baseArticleUrl) project.baseArticleUrl=obj.baseArticleUrl;
|
|
2020
|
+
},
|
|
2021
|
+
schemaLines(opts){
|
|
2022
|
+
const o=opts||{};
|
|
2023
|
+
return ['{',' "accountJsonVersion": 1,',' "platform": "meta",',' "accountId": "act_<ID>",',
|
|
2024
|
+
' "name": "'+(o.nameHint||'<アカウント名>')+'",',
|
|
2025
|
+
' "pageId": "<FacebookページID>",',
|
|
2026
|
+
' "instagramUserId": "<InstagramユーザーID(任意)>",',
|
|
2027
|
+
' "pixelId": "<ピクセルID>",']
|
|
2028
|
+
.concat(o.updatedAt===false?[]:[' "updatedAt": "<ISO8601>",'])
|
|
2029
|
+
.concat([
|
|
2030
|
+
' "refs": {',
|
|
2031
|
+
' "titles": {"1": "見出し(40字以内 / 推奨27字)"},',
|
|
2032
|
+
' "descriptions": {"1": "メインテキスト(推奨125字)"},',
|
|
2033
|
+
' "media": {"1": {"image": "<imageHash>", "video": "<videoId>", "thumb": "<imageHash>"}},',
|
|
2034
|
+
' "campaigns": {"<campaignId>": {"name": "CP名"}},',
|
|
2035
|
+
' "adGroups": {"<adSetId>": {"name": "広告セット名", "campaignId": "<campaignId>", "paramCode": "<code>"}}',
|
|
2036
|
+
' }','}']);
|
|
2037
|
+
}
|
|
2038
|
+
},
|
|
2039
|
+
|
|
2040
|
+
levels:{
|
|
2041
|
+
campaign:{label:'キャンペーン', childKey:'adGroups', planKey:'adSets'}, /* 内部は adGroups / 入稿計画は adSets */
|
|
2042
|
+
adGroup :{label:'広告セット', childKey:'ads', planKey:'ads'},
|
|
2043
|
+
ad :{label:'広告'}
|
|
2044
|
+
},
|
|
2045
|
+
budgetLevel:'adGroup',
|
|
2046
|
+
consoleUrl:'https://adsmanager.facebook.com/adsmanager/manage/{resource}?act={accountIdNum}',
|
|
2047
|
+
consoleResourceMap:{campaigns:'campaigns', adGroups:'adsets', ads:'ads'},
|
|
2048
|
+
|
|
2049
|
+
mediaFormats:[
|
|
2050
|
+
{id:'IMAGE', crSuffix:'i', mediaKey:'image', apiMediaKind:'IMAGE', label:'画像 imageHash'},
|
|
2051
|
+
{id:'VIDEO', crSuffix:'v', mediaKey:'video', apiMediaKind:'VIDEO', label:'動画 videoId'}
|
|
2052
|
+
],
|
|
2053
|
+
fmtOf(ad){ const F=PLATFORMS.meta.mediaFormats; return F.find(x=>x.id===(ad&&ad.format)) || F[0]; },
|
|
2054
|
+
|
|
2055
|
+
refTables:META_REF_TABLES,
|
|
2056
|
+
sheets:META_COLS,
|
|
2057
|
+
|
|
2058
|
+
projectFields:[
|
|
2059
|
+
{key:'pageId', label:'FBページID', kind:'text', width:'16ch', fallback:''},
|
|
2060
|
+
{key:'instagramUserId', label:'IGユーザーID', kind:'text', width:'16ch', fallback:''},
|
|
2061
|
+
{key:'pixelId', label:'ピクセルID', kind:'text', width:'16ch', fallback:''},
|
|
2062
|
+
{key:'customEventType', label:'CVイベント', kind:'select',
|
|
2063
|
+
options:['PURCHASE','LEAD','COMPLETE_REGISTRATION','ADD_TO_CART','INITIATED_CHECKOUT','SUBMIT_APPLICATION','CONTACT']},
|
|
2064
|
+
{key:'optimizationGoal', label:'最適化', kind:'select',
|
|
2065
|
+
options:['OFFSITE_CONVERSIONS','VALUE','LINK_CLICKS','LANDING_PAGE_VIEWS','IMPRESSIONS','REACH','THRUPLAY','LEAD_GENERATION','APP_INSTALLS']},
|
|
2066
|
+
{key:'billingEvent', label:'課金', kind:'select', options:['IMPRESSIONS','LINK_CLICKS','THRUPLAY']},
|
|
2067
|
+
/* 公式4値のみ (MINIMUM_ROAS は不正値なので載せない) */
|
|
2068
|
+
{key:'bidStrategy', label:'入札戦略', kind:'select',
|
|
2069
|
+
options:['LOWEST_COST_WITHOUT_CAP','LOWEST_COST_WITH_BID_CAP','COST_CAP','LOWEST_COST_WITH_MIN_ROAS']},
|
|
2070
|
+
{key:'buyingType', label:'購入タイプ', kind:'select', options:['AUCTION']},
|
|
2071
|
+
{key:'cta', label:'CTA', kind:'select',
|
|
2072
|
+
options:['詳しくはこちら','購入する','登録する','ダウンロード','お問い合わせ','今すぐ申し込む',
|
|
2073
|
+
'予約する','見積もりを取得','購読する','動画を見る','ボタンなし']},
|
|
2074
|
+
{key:'startDate', label:'開始日', kind:'text', width:'10ch', fallback:''},
|
|
2075
|
+
{key:'deliveryStatus', label:'配信', kind:'select', options:['オフ','オン']},
|
|
2076
|
+
{key:'baseArticleUrl', label:'記事URL', kind:'text', width:'16ch', fallback:''}
|
|
2077
|
+
],
|
|
2078
|
+
fixedType:[{label:'広告タイプ',value:'単一画像 / 単一動画'}],
|
|
2079
|
+
|
|
2080
|
+
targeting:{
|
|
2081
|
+
tg_gender:{label:'性別', multi:true, allLabel:'すべて', opts:['すべて','男性','女性']},
|
|
2082
|
+
tg_device:{label:'デバイス', multi:true, allLabel:'すべてのデバイス',
|
|
2083
|
+
opts:['すべてのデバイス','モバイル','デスクトップ']},
|
|
2084
|
+
tg_placement:{label:'配置', multi:true, allLabel:'自動配置',
|
|
2085
|
+
opts:['自動配置','Facebook','Instagram','Audience Network','Messenger']}
|
|
2086
|
+
},
|
|
2087
|
+
|
|
2088
|
+
enums:{
|
|
2089
|
+
gender:{'男性':1,'女性':2}, /* 未指定 = 全性別 (genders を送らない) */
|
|
2090
|
+
device:{'モバイル':'mobile','デスクトップ':'desktop'},
|
|
2091
|
+
placement:{'Facebook':'facebook','Instagram':'instagram',
|
|
2092
|
+
'Audience Network':'audience_network','Messenger':'messenger'},
|
|
2093
|
+
cta:{'詳しくはこちら':'LEARN_MORE','購入する':'SHOP_NOW','登録する':'SIGN_UP',
|
|
2094
|
+
'ダウンロード':'DOWNLOAD','お問い合わせ':'CONTACT_US','今すぐ申し込む':'APPLY_NOW',
|
|
2095
|
+
'予約する':'BOOK_NOW','見積もりを取得':'GET_QUOTE','購読する':'SUBSCRIBE',
|
|
2096
|
+
'動画を見る':'WATCH_MORE','ボタンなし':'NO_BUTTON'},
|
|
2097
|
+
ctaFallback:'LEARN_MORE',
|
|
2098
|
+
statusOn:'ACTIVE', statusOff:'PAUSED',
|
|
2099
|
+
/* 特別広告カテゴリでターゲティングが強制されるルール (公式) */
|
|
2100
|
+
specialAdCategoryConstraints:{
|
|
2101
|
+
forcedAgeMin:18, forcedAgeMax:65, dropGenders:true, dropAudiences:true,
|
|
2102
|
+
appliesTo:['HOUSING','EMPLOYMENT','CREDIT','FINANCIAL_PRODUCTS_SERVICES']
|
|
2103
|
+
},
|
|
2104
|
+
bidAmountRequiredFor:['LOWEST_COST_WITH_BID_CAP','COST_CAP'],
|
|
2105
|
+
pixelRequiredFor:['OFFSITE_CONVERSIONS','VALUE']
|
|
2106
|
+
},
|
|
2107
|
+
|
|
2108
|
+
mappers:{
|
|
2109
|
+
devices(s, P){ return mapByEnum(s, P.enums.device, P.targeting.tg_device.allLabel); },
|
|
2110
|
+
genders(s, P){ return mapByEnum(s, P.enums.gender, P.targeting.tg_gender.allLabel); },
|
|
2111
|
+
placements(s, P){
|
|
2112
|
+
const l=tgList(s);
|
|
2113
|
+
if(!l.length || l.indexOf(P.targeting.tg_placement.allLabel)>=0)
|
|
2114
|
+
return {auto:true, facebook:true, instagram:true, audienceNetwork:false, messenger:false};
|
|
2115
|
+
return {auto:false,
|
|
2116
|
+
facebook:l.indexOf('Facebook')>=0, instagram:l.indexOf('Instagram')>=0,
|
|
2117
|
+
audienceNetwork:l.indexOf('Audience Network')>=0, messenger:l.indexOf('Messenger')>=0};
|
|
2118
|
+
},
|
|
2119
|
+
status(s, P){ return s==='オン'?P.enums.statusOn:P.enums.statusOff; },
|
|
2120
|
+
cta(label, P){ return P.enums.cta[String(label||'').trim()] || P.enums.ctaFallback; },
|
|
2121
|
+
/* B13: JST 00:00 を明示した ISO8601 (日付のみだと UTC 解釈で前日にずれる) */
|
|
2122
|
+
startTime(dateStr){ return (dateStr||today())+'T00:00:00+0900'; }
|
|
2123
|
+
},
|
|
2124
|
+
|
|
2125
|
+
defaults:{
|
|
2126
|
+
newCampaignBudget:1000, /* Meta は広告セット予算なので「新規広告セットの日予算」既定 */
|
|
2127
|
+
project:{
|
|
2128
|
+
cta:'詳しくはこちら', pageId:'', instagramUserId:'', pixelId:'', customEventType:'PURCHASE',
|
|
2129
|
+
optimizationGoal:'OFFSITE_CONVERSIONS', billingEvent:'IMPRESSIONS',
|
|
2130
|
+
bidStrategy:'LOWEST_COST_WITHOUT_CAP', buyingType:'AUCTION',
|
|
2131
|
+
deliveryStatus:'オフ', startDate:''
|
|
2132
|
+
},
|
|
2133
|
+
ad:{format:'IMAGE', imageNo:'', titleNo:'', descNo:'', articleSlug:''},
|
|
2134
|
+
adGroup:{name:'', paramCode:'', isExisting:false, dailyBudget:1000, bidAmount:null,
|
|
2135
|
+
ageMin:18, ageMax:65, tg_gender:'すべて', tg_device:'すべてのデバイス',
|
|
2136
|
+
tg_placement:'自動配置', deliveryStatus:''},
|
|
2137
|
+
campaign:{name:'新規キャンペーン', campaignId:'', isExisting:false,
|
|
2138
|
+
objective:'OUTCOME_SALES', specialAdCategory:'NONE', variable:'', urlNumber:''}
|
|
2139
|
+
},
|
|
2140
|
+
factories:{
|
|
2141
|
+
newAd(seed){ return Object.assign({uid:uid('a')}, PLATFORMS.meta.defaults.ad, seed||{}); },
|
|
2142
|
+
newAdGroup(c, i){ const P=PLATFORMS.meta;
|
|
2143
|
+
return Object.assign({uid:uid('g')}, P.defaults.adGroup,
|
|
2144
|
+
{name:'AS'+((i!=null?i:(c?c.adGroups.length:0))+1), ads:[P.factories.newAd()]}); },
|
|
2145
|
+
newCampaign(seed){ const P=PLATFORMS.meta;
|
|
2146
|
+
return Object.assign({uid:uid('c')}, P.defaults.campaign,
|
|
2147
|
+
{adGroups:[P.factories.newAdGroup(null,0)]}, seed||{}); },
|
|
2148
|
+
existingCampaign(cpId, name){ return {uid:uid('c'), name:name!=null?name:'', campaignId:String(cpId),
|
|
2149
|
+
isExisting:true, objective:'', specialAdCategory:'NONE', variable:'', urlNumber:'', adGroups:[]}; },
|
|
2150
|
+
existingAdGroup(agId, meta){ const P=PLATFORMS.meta, m=meta||{};
|
|
2151
|
+
return Object.assign({uid:uid('g')}, P.defaults.adGroup,
|
|
2152
|
+
{name:m.name||('AS '+agId), paramCode:m.paramCode||'', adGroupId:String(agId),
|
|
2153
|
+
isExisting:true, ads:[P.factories.newAd()]}); }
|
|
2154
|
+
},
|
|
2155
|
+
|
|
2156
|
+
/* 命名規則は YDA 踏襲 (形式記号は mediaFormats.crSuffix = i/v) */
|
|
2157
|
+
naming:{
|
|
2158
|
+
crName(ad, P){ return String(ad.imageNo||'') + P.fmtOf(ad).crSuffix; },
|
|
2159
|
+
adName(ad, group, crName){ return crName + '-' + (ad.titleNo??'') + 'L' + (ad.descNo??'') + '-' + (group.paramCode||''); },
|
|
2160
|
+
finalUrl(ad, group, adName, project){
|
|
2161
|
+
return (project.baseArticleUrl||'') + (ad.articleSlug||'')
|
|
2162
|
+
+ '?ad=' + (group.paramCode||'')
|
|
2163
|
+
+ '&creative=' + (group.paramCode||'') + '-' + adName;
|
|
2164
|
+
},
|
|
2165
|
+
campaignName(urlNumber, firstAd, P){
|
|
2166
|
+
const cr = firstAd ? (String(firstAd.imageNo||'CR') + P.fmtOf(firstAd).crSuffix) : 'CR';
|
|
2167
|
+
return String(urlNumber) + '_' + cr + '_' + today();
|
|
2168
|
+
}
|
|
2169
|
+
},
|
|
2170
|
+
creativeFields:['imageNo','titleNo','descNo'],
|
|
2171
|
+
/* Meta は「値はあるが送らない列」を作らない */
|
|
2172
|
+
notSubmittedFields:[],
|
|
2173
|
+
labels:{submitButton:'完成 → 保存して入稿', paramCodeLabel:'paramCode', tgDroppedWarn:''},
|
|
2174
|
+
|
|
2175
|
+
tree:{
|
|
2176
|
+
campaignBadges(c, P, p){
|
|
2177
|
+
return '<span class="chip">'+esc(c.objective||'OUTCOME_SALES')+'</span>'
|
|
2178
|
+
+ ((c.specialAdCategory && c.specialAdCategory!=='NONE') ? '<span class="chip">'+esc(c.specialAdCategory)+'</span>' : '');
|
|
2179
|
+
},
|
|
2180
|
+
groupBadges(g){ return '<span class="pill mono">【'+esc(g.paramCode||'—')+'】</span>'
|
|
2181
|
+
+ (g.dailyBudget!=null&&g.dailyBudget!=='' ? '<span class="chip">¥'+esc(g.dailyBudget)+'/日</span>' : ''); },
|
|
2182
|
+
adBadge(a, r){ return '<span class="cbadge">'+esc(a.imageNo||'—')+((a.format==='VIDEO')?'v':'i')+'</span>'; },
|
|
2183
|
+
adSub(a, r){ return esc(a.titleNo)+'L'+esc(a.descNo); }
|
|
2184
|
+
},
|
|
2185
|
+
|
|
2186
|
+
/* ---- 解決 (派生値 / 純関数) ---- */
|
|
2187
|
+
resolve(ad, group, ctx){
|
|
2188
|
+
const P=PLATFORMS.meta, p=ctx.project, refs=ctx.refs;
|
|
2189
|
+
const fmt=P.fmtOf(ad);
|
|
2190
|
+
const crName=P.naming.crName(ad, P);
|
|
2191
|
+
const adName=P.naming.adName(ad, group, crName);
|
|
2192
|
+
const me = refs.media[String(ad.imageNo)];
|
|
2193
|
+
const mediaId = me ? me[fmt.mediaKey] : undefined;
|
|
2194
|
+
const thumbHash = me ? me.thumb : undefined;
|
|
2195
|
+
const titleText = refs.titles[ad.titleNo];
|
|
2196
|
+
const descText = (refs.descriptions||{})[ad.descNo];
|
|
2197
|
+
const finalUrl = P.naming.finalUrl(ad, group, adName, p);
|
|
2198
|
+
const errs=[], warns=[], errCodes=[], warnCodes=[];
|
|
2199
|
+
const pushErr=(label,code)=>{ errs.push(label); errCodes.push(code); };
|
|
2200
|
+
const pushWarn=(label,code)=>{ warns.push(label); warnCodes.push(code); };
|
|
2201
|
+
const noStr=String(ad.imageNo||'').trim();
|
|
2202
|
+
/* 番号ありだが当該形式のIDが未登録 (空文字含む) = アップロード対象 */
|
|
2203
|
+
const pendingImage = (noStr!=='' && (mediaId===undefined || mediaId===''));
|
|
2204
|
+
const T=P.refTables.titles.lengthLimit, D=P.refTables.descriptions.lengthLimit;
|
|
2205
|
+
if(noStr==='') pushErr('クリエイティブ未解決', EC.IMAGE_UNRESOLVED);
|
|
2206
|
+
if(String(ad.titleNo||'')==='' || titleText===undefined) pushErr('見出し未解決', EC.TITLE_UNRESOLVED);
|
|
2207
|
+
if(String(ad.descNo||'')==='' || descText===undefined) pushErr('本文未解決', EC.DESC_UNRESOLVED);
|
|
2208
|
+
if(titleText!==undefined && glen(titleText)>T.err) pushErr('見出し'+T.err+'字超', EC.TITLE_TOO_LONG);
|
|
2209
|
+
else if(titleText!==undefined && glen(titleText)>T.warn) pushWarn('見出し'+T.warn+'字超', EC.TITLE_LONG_WARN);
|
|
2210
|
+
if(descText!==undefined && glen(descText)>D.err) pushErr('本文'+D.err+'字超', EC.DESC_TOO_LONG);
|
|
2211
|
+
else if(descText!==undefined && glen(descText)>D.warn) pushWarn('本文'+D.warn+'字超', EC.DESC_LONG_WARN);
|
|
2212
|
+
if(String(group.paramCode||'').trim()==='') pushErr('paramCode未入力', EC.PARAM_CODE_EMPTY);
|
|
2213
|
+
if(pendingImage) pushWarn(ad.format==='VIDEO'?'動画未登録(要アップロード)':'画像未登録(要アップロード)', EC.MEDIA_MISSING);
|
|
2214
|
+
const aid=String(group.adGroupId||'').trim();
|
|
2215
|
+
return {crName, adName, campaignId:'', adGroupId:aid, mediaKind:fmt.apiMediaKind, imageNo:noStr,
|
|
2216
|
+
mediaId:mediaId||'', thumbHash:thumbHash||'', pendingImage,
|
|
2217
|
+
titleText:titleText===undefined?'':titleText, descText:descText===undefined?'':descText,
|
|
2218
|
+
finalUrl, errs, warns, errCodes, warnCodes, resolvedGroup:!!aid};
|
|
2219
|
+
},
|
|
2220
|
+
|
|
2221
|
+
/* ---- 広告セット行の追加検証 (予算・入札) ---- */
|
|
2222
|
+
validateRow(row, level, ctx){
|
|
2223
|
+
const P=PLATFORMS.meta, p=(ctx&&ctx.project)||{}, errCodes=[], warnCodes=[];
|
|
2224
|
+
if(level==='adGroup' && !isExistingGroup(row)){
|
|
2225
|
+
if(!(Number(row.dailyBudget)>0)) errCodes.push(EC.BUDGET_MISSING);
|
|
2226
|
+
if(P.enums.bidAmountRequiredFor.indexOf(p.bidStrategy)>=0 && !(Number(row.bidAmount)>0))
|
|
2227
|
+
errCodes.push('BID_AMOUNT_REQUIRED');
|
|
2228
|
+
}
|
|
2229
|
+
return {errCodes, warnCodes};
|
|
2230
|
+
},
|
|
2231
|
+
/* ---- プロジェクト単位の書き出し前ガード (B3: promoted_object 欠落を防ぐ) ---- */
|
|
2232
|
+
validateProject(p){
|
|
2233
|
+
const P=PLATFORMS.meta, errs=[];
|
|
2234
|
+
if(!String((p&&p.pageId)||'').trim()) errs.push({code:'PAGE_ID_MISSING', msg:'FBページIDを共通設定に入力してください'});
|
|
2235
|
+
if(P.enums.pixelRequiredFor.indexOf(p&&p.optimizationGoal)>=0 && !String((p&&p.pixelId)||'').trim())
|
|
2236
|
+
errs.push({code:EC.PIXEL_MISSING, msg:'最適化が'+p.optimizationGoal+'のためピクセルIDが必須です'});
|
|
2237
|
+
return errs;
|
|
2238
|
+
},
|
|
2239
|
+
|
|
2240
|
+
/* ---- 入稿計画JSON (SubmitPlanFile) の3階層ボディ生成 ---- */
|
|
2241
|
+
buildPlan:{
|
|
2242
|
+
campaign(c, ctx){
|
|
2243
|
+
const P=PLATFORMS.meta, p=ctx.project;
|
|
2244
|
+
const cat=String(c.specialAdCategory||'NONE');
|
|
2245
|
+
const cp={
|
|
2246
|
+
name:c.name,
|
|
2247
|
+
objective:c.objective||'OUTCOME_SALES',
|
|
2248
|
+
status:P.mappers.status(p.deliveryStatus, P),
|
|
2249
|
+
buyingType:p.buyingType||'AUCTION',
|
|
2250
|
+
specialAdCategories: cat==='NONE'?[]:[cat] /* NONE は GUI センチネル。API へは [] */
|
|
2251
|
+
};
|
|
2252
|
+
const cid=resolveCampaignIdOf(c); if(cid) cp.campaignId=cid;
|
|
2253
|
+
/* CBO は使わない (予算は広告セット階層 = budgetLevel)。×100 バグの経路に入れない */
|
|
2254
|
+
return cp;
|
|
2255
|
+
},
|
|
2256
|
+
adGroup(g, c, ctx){
|
|
2257
|
+
const P=PLATFORMS.meta, p=ctx.project;
|
|
2258
|
+
const cat=String((c&&c.specialAdCategory)||'NONE');
|
|
2259
|
+
const K=P.enums.specialAdCategoryConstraints;
|
|
2260
|
+
const constrained = K.appliesTo.indexOf(cat)>=0;
|
|
2261
|
+
const ag={
|
|
2262
|
+
name:g.name,
|
|
2263
|
+
status:P.mappers.status(g.deliveryStatus||p.deliveryStatus, P),
|
|
2264
|
+
optimizationGoal:p.optimizationGoal||'OFFSITE_CONVERSIONS',
|
|
2265
|
+
billingEvent:p.billingEvent||'IMPRESSIONS',
|
|
2266
|
+
bidStrategy:p.bidStrategy||'LOWEST_COST_WITHOUT_CAP',
|
|
2267
|
+
dailyBudget:Number(g.dailyBudget)||0, /* JPY はそのまま (currency_offset=1) */
|
|
2268
|
+
startTime:P.mappers.startTime(p.startDate), /* JST 明示 ISO8601 */
|
|
2269
|
+
/* 特別カテゴリ時は 18-65 固定・性別指定不可 (公式制約) */
|
|
2270
|
+
ageMin: constrained?K.forcedAgeMin:(Number(g.ageMin)||18),
|
|
2271
|
+
ageMax: constrained?K.forcedAgeMax:(Number(g.ageMax)||65),
|
|
2272
|
+
genders: constrained?[]:P.mappers.genders(g.tg_gender, P),
|
|
2273
|
+
devicePlatforms:P.mappers.devices(g.tg_device, P),
|
|
2274
|
+
placements:P.mappers.placements(g.tg_placement, P),
|
|
2275
|
+
customAudiences:[], excludedAudiences:[], /* ID解決が必要なため常に空 */
|
|
2276
|
+
geoCountries:['JP'],
|
|
2277
|
+
pixelId:String(p.pixelId||''), /* promoted_object 用 */
|
|
2278
|
+
customEventType:p.customEventType||'PURCHASE'
|
|
2279
|
+
};
|
|
2280
|
+
if(P.enums.bidAmountRequiredFor.indexOf(ag.bidStrategy)>=0 || Number(g.bidAmount)>0)
|
|
2281
|
+
ag.bidAmount=Number(g.bidAmount)||0;
|
|
2282
|
+
const aid=resolveAdGroupIdOf(g); if(aid) ag.adSetId=aid;
|
|
2283
|
+
return ag;
|
|
2284
|
+
},
|
|
2285
|
+
ad(a, r, g, ctx){
|
|
2286
|
+
const P=PLATFORMS.meta, p=ctx.project;
|
|
2287
|
+
const out={
|
|
2288
|
+
adName:r.adName,
|
|
2289
|
+
status:P.mappers.status(g.deliveryStatus||p.deliveryStatus, P),
|
|
2290
|
+
facebookPageId:String(p.pageId||''),
|
|
2291
|
+
mediaType:r.mediaKind, /* 'IMAGE' | 'VIDEO' */
|
|
2292
|
+
bodyText:String(r.descText),
|
|
2293
|
+
title:String(r.titleText),
|
|
2294
|
+
cta:P.mappers.cta(p.cta, P),
|
|
2295
|
+
finalUrl:r.finalUrl
|
|
2296
|
+
};
|
|
2297
|
+
/* B2: instagram_actor_id は廃止。キー名は instagramUserId で統一し、未設定ならキー自体を出さない */
|
|
2298
|
+
if(String(p.instagramUserId||'').trim()) out.instagramUserId=String(p.instagramUserId).trim();
|
|
2299
|
+
if(r.mediaKind==='VIDEO'){
|
|
2300
|
+
if(r.pendingImage){ out.videoPath=''; out.pendingMediaNo=r.imageNo; } /* CLI が本実行時に upload */
|
|
2301
|
+
else out.videoId=String(r.mediaId);
|
|
2302
|
+
if(r.thumbHash) out.imageHash=String(r.thumbHash); /* サムネは adimages の hash */
|
|
2303
|
+
} else {
|
|
2304
|
+
if(r.pendingImage){ out.imagePaths=[]; out.pendingMediaNo=r.imageNo; }
|
|
2305
|
+
else out.imageHash=String(r.mediaId);
|
|
2306
|
+
}
|
|
2307
|
+
return out;
|
|
2308
|
+
}
|
|
2309
|
+
},
|
|
2310
|
+
|
|
2311
|
+
output:{
|
|
2312
|
+
planVersion:1,
|
|
2313
|
+
planDir:'.claude/skills/ad-ops/入稿計画',
|
|
2314
|
+
samplePlanFile:'サンプルプラン_meta.json',
|
|
2315
|
+
fileNamePrefix:p=>(p.name||'plan')+'_Meta入稿計画_',
|
|
2316
|
+
submitCommand:(rel,opts)=>'webgram submit-plan --plan-file "'+rel+'"'+((opts&&opts.dryRun===false)?' --no-dry-run --yes':'')
|
|
2317
|
+
},
|
|
2318
|
+
|
|
2319
|
+
prompts:{
|
|
2320
|
+
importAccount(ctx){
|
|
2321
|
+
const P=PLATFORMS.meta, c=ctx||{}, mode=c.mode||'help';
|
|
2322
|
+
const name=c.name||'', accId=c.accountId||'', idArg=accId||'act_<ID>';
|
|
2323
|
+
const headName = mode==='help' ? '<アカウント名(日本語OK)>' : (name||'<アカウント名>');
|
|
2324
|
+
const fileName = mode==='help' ? '<アカウント名>' : (name||'<アカウント名>');
|
|
2325
|
+
const dir=P.account.dir;
|
|
2326
|
+
const shape=[' - media は 管理番号 → {image(imageHash), video(videoId), thumb(サムネのimageHash)}',
|
|
2327
|
+
' - campaigns/adGroups は campaignId/adSetId をキーに(adGroups.paramCode はURLパラメータ)'];
|
|
2328
|
+
const lines=[];
|
|
2329
|
+
if(mode==='ai'){
|
|
2330
|
+
lines.push('Meta入稿プランナー用に、新規アカウントの参照データJSONを生成してください。','',
|
|
2331
|
+
'1. まず対象アカウントを対話で決めてください:',
|
|
2332
|
+
' - webgram list --platform meta --resource accounts で候補一覧を取得し、どのアカウントを追加するか私に確認して アカウント名・accountId(act_付き)・FBページID・IGユーザーID・ピクセルID を確定する',
|
|
2333
|
+
'2. 確定したアカウントについて、登録画像/動画・既存キャンペーン/広告セットを取得:',
|
|
2334
|
+
' webgram list --platform meta --resource image-library --account-id '+idArg,
|
|
2335
|
+
' webgram list --platform meta --resource video-library --account-id '+idArg,
|
|
2336
|
+
' webgram list --platform meta --resource campaigns --account-id '+idArg,
|
|
2337
|
+
'3. 取得結果を下記スキーマに整形し、'+dir+'/<アカウント名>.json として書き出す:');
|
|
2338
|
+
shape.forEach(l=>lines.push(l));
|
|
2339
|
+
} else {
|
|
2340
|
+
lines.push('Meta入稿プランナー用に、アカウント「'+headName+'」'+(mode==='import'&&accId?('(accountId: '+accId+')'):'')+'の参照データJSONを生成してください。','',
|
|
2341
|
+
'手順:',
|
|
2342
|
+
'1. webgram CLI で対象アカウントの登録画像/動画・既存キャンペーン/広告セットを取得:',
|
|
2343
|
+
' webgram list --platform meta --resource image-library --account-id '+idArg,
|
|
2344
|
+
' webgram list --platform meta --resource video-library --account-id '+idArg,
|
|
2345
|
+
' webgram list --platform meta --resource campaigns --account-id '+idArg,
|
|
2346
|
+
'2. 取得結果を下記スキーマに整形して '+dir+'/'+fileName+'.json として書き出す:');
|
|
2347
|
+
shape.forEach(l=>lines.push(l));
|
|
2348
|
+
}
|
|
2349
|
+
lines.push('', 'スキーマ:');
|
|
2350
|
+
P.account.schemaLines({updatedAt:mode!=='help',
|
|
2351
|
+
nameHint:mode==='help'?'<アカウント名(日本語OK)>':'<アカウント名>'}).forEach(l=>lines.push(l));
|
|
2352
|
+
lines.push('');
|
|
2353
|
+
lines.push(mode==='ai' ? '書き出したら、プランナー右上のアカウントプルダウン →「+新規追加」→「📄 ファイルから追加」で その JSON を読み込めます。'
|
|
2354
|
+
: mode==='import' ? '書き出したら、プランナーで「📁 フォルダ」を再接続 or アカウントセレクトを開くと自動で一覧に出ます。'
|
|
2355
|
+
: '書き出したら、プランナーで「+新規追加 →📄 ファイルから追加」or「📁 フォルダ」再接続で一覧に出ます。');
|
|
2356
|
+
return lines.join('\n');
|
|
2357
|
+
},
|
|
2358
|
+
strategy(){
|
|
2359
|
+
return ['Meta広告(Facebook / Instagram)の入稿プランを一緒に作りたいです。まず戦略を壁打ちさせてください。','',
|
|
2360
|
+
'# 前提','- アカウント: <アカウント名(日本語OK)>','- 商材 / LP: <商材名・URL>',
|
|
2361
|
+
'- 目的(objective): <OUTCOME_SALES / OUTCOME_LEADS など>','- 予算・期間: <広告セットの日予算 / 開始日>',
|
|
2362
|
+
'- 訴求したいポイント: <強み・オファー・ターゲット層>','',
|
|
2363
|
+
'# お願い',
|
|
2364
|
+
'1. 上記をもとにターゲティング方針(年齢・性別・配置)・キャンペーン/広告セット構成・訴求軸を提案してください。',
|
|
2365
|
+
'2. 私が「OK」と言ったら、その戦略を ad-ops Skill の「サンプルプラン_meta.json」に書き出して(上書きして)ください。',
|
|
2366
|
+
' - 保存先: .claude/skills/ad-ops/サンプルプラン_meta.json',
|
|
2367
|
+
' - フォーマット: {"schemaVersion":'+SCHEMA_VERSION+', "platform":"meta", "accountId":"act_<対象アカウントID>", "campaigns":[ ... ]}',
|
|
2368
|
+
' - campaigns には キャンペーン → 広告セット(paramCode / dailyBudget / 年齢 / 性別 / 配置) → 広告(形式 IMAGE|VIDEO / クリエイティブ番号 / 見出し番号 / 本文番号) の構成を入れてください。',
|
|
2369
|
+
'3. 書き出したら、プランナーを開き直す(またはリロードして媒体を Meta に切り替える)と内容が自動で表示されます。'].join('\n');
|
|
2370
|
+
},
|
|
2371
|
+
submitHelp(ctx){
|
|
2372
|
+
const P=PLATFORMS.meta, c=ctx||{}, rel=c.savedRel, dir=P.output.planDir;
|
|
2373
|
+
const lines=['入稿を試みてください。手順:',
|
|
2374
|
+
'1) 入稿計画JSON('+c.fileName+')を ad-ops Skill の入稿計画フォルダに置く(保存先: '+rel+')。',
|
|
2375
|
+
' ・入稿計画フォルダ('+dir+'/)に無ければ、ダウンロードフォルダ(~/Downloads/'+c.fileName+')を探して上記パスへ移動する。',
|
|
2376
|
+
(c.wrote? ' ・プランナーからは既に入稿計画フォルダへ保存済みのはず。' : ' ・フォルダ未接続のためダウンロード済み。~/Downloads から移動してください。')];
|
|
2377
|
+
if(c.pendingImages){
|
|
2378
|
+
lines.push('2) 【未登録クリエイティブの新規登録】pendingMediaNo が付いた広告があります(管理番号: '+(c.pendingImageNos||[]).join(', ')+')。');
|
|
2379
|
+
lines.push(' 画像広告は "imagePaths":[] に、動画広告は "videoPath" に対応するローカルファイルのパスを設定してください(本実行時に imageHash / videoId へ自動解決されます)。');
|
|
2380
|
+
lines.push('3) 置いたパスで dry-run 入稿を実行:');
|
|
2381
|
+
} else {
|
|
2382
|
+
lines.push('2) 置いたパスで dry-run 入稿を実行:');
|
|
2383
|
+
}
|
|
2384
|
+
lines.push(' '+P.output.submitCommand(rel,{dryRun:true}));
|
|
2385
|
+
lines.push((c.pendingImages?'4':'3')+') dry-run が全件OKなら本実行:');
|
|
2386
|
+
lines.push(' '+P.output.submitCommand(rel,{dryRun:false}));
|
|
2387
|
+
return lines.join('\n');
|
|
2388
|
+
},
|
|
2389
|
+
afterSave(ctx){
|
|
2390
|
+
const P=PLATFORMS.meta, c=ctx||{};
|
|
2391
|
+
return 'プラン「'+c.name+'」v'+c.versionId+' を保存しました。\n入稿するには「入稿JSON書き出し」を押し、生成された *_Meta入稿計画_*.json を指定して dry-run してください:\n\n'
|
|
2392
|
+
+ P.output.submitCommand('<入稿計画JSONのパス>',{dryRun:true});
|
|
2393
|
+
}
|
|
2394
|
+
},
|
|
2395
|
+
docTabs:[{id:'overview',label:'概要'},{id:'image',label:'画像広告'},
|
|
2396
|
+
{id:'video',label:'動画広告'},{id:'param',label:'パラメータ'},{id:'guide',label:'運用手順'}]
|
|
2397
|
+
};
|
|
2398
|
+
/* DOM の data-st ('ads'|'groups'|'campaigns') と正規タブID ('ads'|'adGroups'|'campaigns') を橋渡し */
|
|
2399
|
+
function normTab(tab){ return tab==='groups'?'adGroups':(tab||'ads'); }
|
|
2400
|
+
/* 列記述子配列。引数省略時は現在のシートタブ */
|
|
2401
|
+
function colDefs(tab){ const S=pf().sheets; return S[normTab(tab===undefined?sheetTab:tab)] || S.ads; }
|
|
2402
|
+
/* 列番号リテラル撤去用: field → 列index */
|
|
2403
|
+
function colIndex(field, tab){ return colDefs(tab).findIndex(c=>c.field===field); }
|
|
2404
|
+
/* ドロワーで拾った番号を流し込める列index群 (旧 compat=[1] / [2,3] の置換) */
|
|
2405
|
+
function compatColsFor(drawerType, tab){
|
|
2406
|
+
const out=[]; colDefs(tab).forEach((c,i)=>{ if(c.kind==='refcombo' && c.drawerType===drawerType) out.push(i); }); return out;
|
|
2407
|
+
}
|
|
2408
|
+
/* 貼付/コピー/セル選択が扱う列の field 配列。derived (広告名/CP・AG ID) は入力セルを持たないので除外する。
|
|
2409
|
+
derived は常に配列末尾なので、tabCols() の index と colDefs() の index (= data-c) は一致する。 */
|
|
2410
|
+
function tabCols(){ return colDefs().filter(c=>c.kind!=='derived').map(c=>c.field); }
|
|
2411
|
+
/* ---- S5: セル値アクセスの記述子駆動化 ----
|
|
2412
|
+
行の実体 (広告 / 広告グループ / キャンペーン)。scope:'parentGroup' の列だけ所属グループを見る。 */
|
|
2413
|
+
function rowOf(item, col){
|
|
2414
|
+
if(col && col.scope==='parentGroup') return item.g;
|
|
2415
|
+
return item.kind==='c'?item.c:(item.kind==='g'?item.g:item.a);
|
|
2416
|
+
}
|
|
2417
|
+
/* その列の値の持ち主が「既存呼び出し行」か。既存行では readonlyOnExisting / hideOnExisting 列を触らせない。
|
|
2418
|
+
広告自身のフィールドは既存グループ配下でも編集可 (広告の追加・差し替えは許可)。 */
|
|
2419
|
+
function rowIsExisting(item, col){
|
|
2420
|
+
if(col && col.scope==='parentGroup') return isExistingGroup(item.g);
|
|
2421
|
+
if(item.kind==='g') return isExistingGroup(item.g);
|
|
2422
|
+
if(item.kind==='c') return isExistingCampaign(item.c);
|
|
2423
|
+
return false;
|
|
2424
|
+
}
|
|
2425
|
+
function getVar(item,c,tab){
|
|
2426
|
+
const col=colDefs(tab)[c]; if(!col) return '';
|
|
2427
|
+
const row=rowOf(item,col); const v=row?row[col.field]:null;
|
|
2428
|
+
return v==null?'':String(v);
|
|
2429
|
+
}
|
|
2430
|
+
function setVar(item,c,val,tab){
|
|
2431
|
+
const col=colDefs(tab)[c]; if(!col) return;
|
|
2432
|
+
if(col.kind==='derived') return;
|
|
2433
|
+
if(col.alwaysReadonly) return; /* adGroupId / campaignId */
|
|
2434
|
+
if((col.readonlyOnExisting||col.hideOnExisting) && rowIsExisting(item,col)) return; /* F-2: 既存呼び出し行は基本パラメータ編集不可 */
|
|
2435
|
+
const row=rowOf(item,col); if(!row) return;
|
|
2436
|
+
row[col.field] = col.coerce?col.coerce(val):val;
|
|
2437
|
+
if(col.onSet) col.onSet(item,row);
|
|
2438
|
+
}
|
|
1175
2439
|
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; }
|
|
1176
2440
|
let selA=null, selB=null, dragging=false;
|
|
1177
2441
|
function clearSel(){ selA=selB=null; applySelection(); }
|
|
@@ -1193,7 +2457,7 @@ function pasteGrid(txt){
|
|
|
1193
2457
|
cols.forEach((val,j)=>{ const cc=rg.c0+j; if(cc>maxc) return; setVar(it,cc,val); });
|
|
1194
2458
|
});
|
|
1195
2459
|
}
|
|
1196
|
-
else { const anchor=vis[rg.r0]; rows.forEach((cols,i)=>{ let it=vis[rg.r0+i]; if(!it){ if(sheetTab==='groups'){ const ng=
|
|
2460
|
+
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); }); }); }
|
|
1197
2461
|
});
|
|
1198
2462
|
clearSel(); toast(rows.length+'行を貼り付け');
|
|
1199
2463
|
}
|
|
@@ -1207,15 +2471,16 @@ function dupSelected(){
|
|
|
1207
2471
|
}
|
|
1208
2472
|
|
|
1209
2473
|
/* ---- A-2: paramCode 連番採番 (複数選択→連番一括入力) ---- */
|
|
1210
|
-
|
|
1211
|
-
|
|
2474
|
+
/* 選択範囲が交差する「連番可能な列」を返す。{field, idx, mode:'group'|'ad'}
|
|
2475
|
+
S3: 旧 paramCodeColIndex() は本関数へ吸収。候補列は列記述子の sequenceable から算出する。
|
|
2476
|
+
mode は「値の書き込み先が広告グループか広告か」= 広告グループタブ or scope:'parentGroup' の列なら 'group'。 */
|
|
1212
2477
|
function seqColInfo(){
|
|
1213
2478
|
const rg=selRange(); if(!rg) return null;
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
2479
|
+
const tab=normTab(sheetTab), cols=colDefs(tab);
|
|
2480
|
+
for(let ci=0; ci<cols.length; ci++){
|
|
2481
|
+
const col=cols[ci]; if(!col.sequenceable) continue;
|
|
2482
|
+
if(rg.c0<=ci && rg.c1>=ci) return {field:col.field, idx:ci, label:col.label,
|
|
2483
|
+
mode:(tab==='adGroups'||col.scope==='parentGroup')?'group':'ad'};
|
|
1219
2484
|
}
|
|
1220
2485
|
return null;
|
|
1221
2486
|
}
|
|
@@ -1238,16 +2503,16 @@ function applySequentialParamCodes(){
|
|
|
1238
2503
|
const seen=new Set(); const groups=[];
|
|
1239
2504
|
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}); }
|
|
1240
2505
|
if(groups.length<2){ toast('連番対象の広告グループが2件以上必要です'); return; }
|
|
1241
|
-
const m=String(groups[0].g.
|
|
2506
|
+
const m=String(groups[0].g[sc.field]||'').trim().match(/^(.*?)(\d+)\s*$/);
|
|
1242
2507
|
if(!m){ toast('基点となる paramCode を先頭セルに入力してください(例 jv_Cac_148)'); return; }
|
|
1243
2508
|
const prefix=m[1], startNum=parseInt(m[2],10), width=m[2].length;
|
|
1244
|
-
mutate(()=>{ groups.forEach((pair,i)=>{ if(i===0){ autoResolveGroupIds(pair.g,pair.c); return; } pair.g.
|
|
2509
|
+
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); }); });
|
|
1245
2510
|
toast((groups.length-1)+'グループに連番を採番しました('+prefix+(startNum+1)+'〜)');
|
|
1246
2511
|
} else {
|
|
1247
2512
|
const rows=[];
|
|
1248
2513
|
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); }
|
|
1249
2514
|
if(rows.length<2){ toast('連番対象の広告が2件以上必要です'); return; }
|
|
1250
|
-
const lbl=
|
|
2515
|
+
const lbl=sc.label; // S3: 列記述子のラベル
|
|
1251
2516
|
const m=String(rows[0].a[sc.field]||'').trim().match(/^(.*?)(\d+)\s*$/);
|
|
1252
2517
|
if(!m){ toast('基点の'+lbl+'を先頭セルに入力してください(例 954)'); return; }
|
|
1253
2518
|
const prefix=m[1], startNum=parseInt(m[2],10), width=m[2].length;
|
|
@@ -1258,7 +2523,8 @@ function applySequentialParamCodes(){
|
|
|
1258
2523
|
|
|
1259
2524
|
/* ---- ▼ 右スライドサイドバー(一覧から番号を選ぶ) ---- */
|
|
1260
2525
|
let drawerTarget=null;
|
|
1261
|
-
|
|
2526
|
+
/* S3: ▼ドロワーの表示モードは列記述子の drawerType から引く (旧 imageNo?'media':'titles' の三項を撤去) */
|
|
2527
|
+
function drawerTypeForField(f){ const col=colDefs('ads').find(c=>c.field===f); return (col&&col.drawerType)||'titles'; }
|
|
1262
2528
|
function openDrawerFor(u,f){
|
|
1263
2529
|
if(!u||!f) return;
|
|
1264
2530
|
const isOpen = document.getElementById('refDrawer').classList.contains('open');
|
|
@@ -1301,7 +2567,7 @@ function openCampaignPickDrawer(ctx){
|
|
|
1301
2567
|
/* F-4: プラン内広告グループ選択ドロワー (広告の所属変更) */
|
|
1302
2568
|
function openAdGroupPickDrawer(adUid){
|
|
1303
2569
|
const anyGroup=st.campaigns.some(c=>c.adGroups.length);
|
|
1304
|
-
if(!anyGroup){ toast('
|
|
2570
|
+
if(!anyGroup){ toast('先に'+agLabel()+'を追加してください'); return; }
|
|
1305
2571
|
drawerTarget={type:'agpick', adUid};
|
|
1306
2572
|
document.getElementById('drawerSearch').value='';
|
|
1307
2573
|
renderDrawer();
|
|
@@ -1327,8 +2593,8 @@ function addExistingAdGroup(agId){
|
|
|
1327
2593
|
let target=null;
|
|
1328
2594
|
if(meta.campaignId){ target=st.campaigns.find(c=>String(resolveCampaignIdOf(c))===String(meta.campaignId)); }
|
|
1329
2595
|
if(!target) target=st.campaigns[st.campaigns.length-1];
|
|
1330
|
-
// F-6: ID起点で名前・paramCode等を refs.adGroups から自動で埋める
|
|
1331
|
-
mutate(()=>target.adGroups.push(
|
|
2596
|
+
// F-6: ID起点で名前・paramCode等を refs.adGroups から自動で埋める (行生成は pf().factories)
|
|
2597
|
+
mutate(()=>target.adGroups.push(pf().factories.existingAdGroup(agId, meta)));
|
|
1332
2598
|
toast('既存広告グループ '+agId+' を呼び出しました('+esc(target.name)+')');
|
|
1333
2599
|
closeDrawer();
|
|
1334
2600
|
}
|
|
@@ -1343,8 +2609,8 @@ function moveGroupToCampaign(gUid, targetCUid){
|
|
|
1343
2609
|
/* F-5: 選択キャンペーンに新規空グループを追加 */
|
|
1344
2610
|
function addNewAdGroupToCampaign(cUid){
|
|
1345
2611
|
const r=findCp(cUid); if(!r) return;
|
|
1346
|
-
mutate(()=>r.c.adGroups.push(
|
|
1347
|
-
toast('
|
|
2612
|
+
mutate(()=>r.c.adGroups.push(newAdGroup(r.c)));
|
|
2613
|
+
toast('新規'+agLabel()+'を「'+esc(r.c.name)+'」に追加');
|
|
1348
2614
|
closeDrawer();
|
|
1349
2615
|
}
|
|
1350
2616
|
/* F-4: 広告を別グループへ移動 */
|
|
@@ -1355,15 +2621,7 @@ function moveAdToGroup(adUid, targetGUid){
|
|
|
1355
2621
|
toast('広告を「'+esc(tg.g.name)+'」へ移動');
|
|
1356
2622
|
closeDrawer();
|
|
1357
2623
|
}
|
|
1358
|
-
/*
|
|
1359
|
-
const TG_OPTIONS = {
|
|
1360
|
-
tg_device:{label:'デバイス', opts:['全てのデバイス','PC','スマートフォン','タブレット']},
|
|
1361
|
-
tg_gender:{label:'性別', opts:['男性','女性','不明']},
|
|
1362
|
-
tg_age:{label:'年齢', opts:['18-19歳','20-24歳','25-29歳','30-34歳','35-39歳','40-44歳','45-49歳','50-54歳','55-59歳','60-64歳','65-69歳','70歳以上','不明']},
|
|
1363
|
-
tg_region:{label:'地域', opts:['全国','北海道','青森県','岩手県','宮城県','秋田県','山形県','福島県','茨城県','栃木県','群馬県','埼玉県','千葉県','東京都','神奈川県','新潟県','富山県','石川県','福井県','山梨県','長野県','岐阜県','静岡県','愛知県','三重県','滋賀県','京都府','大阪府','兵庫県','奈良県','和歌山県','鳥取県','島根県','岡山県','広島県','山口県','徳島県','香川県','愛媛県','高知県','福岡県','佐賀県','長崎県','熊本県','大分県','宮崎県','鹿児島県','沖縄県']},
|
|
1364
|
-
tg_dayHour:{label:'曜日・時間帯', opts:['全ての曜日・時間帯','平日','土日祝','月','火','水','木','金','土','日','午前(6-12時)','午後(12-18時)','夜間(18-24時)','深夜(0-6時)']},
|
|
1365
|
-
tg_audience:{label:'オーディエンスリスト', opts:['サイトリターゲティング','類似ユーザー','オーディエンスカテゴリー','サーチキーワード','購買データ','属性・興味関心']}
|
|
1366
|
-
};
|
|
2624
|
+
/* S7: ターゲティング列の選択肢は pf().targeting (媒体スキーマ) が持つ */
|
|
1367
2625
|
function openTargetingDrawer(gUid, field){
|
|
1368
2626
|
const isOpen=document.getElementById('refDrawer').classList.contains('open');
|
|
1369
2627
|
const same=drawerTarget && drawerTarget.type==='tg' && drawerTarget.gUid===gUid && drawerTarget.field===field;
|
|
@@ -1398,7 +2656,7 @@ function renderDrawer(){
|
|
|
1398
2656
|
if(drawerTarget.type==='agref'){
|
|
1399
2657
|
// F-5: アカウントアセット全体の既存広告グループ (refs.adGroups)。追加済みはグレーアウト
|
|
1400
2658
|
const ags=st.refs.adGroups||{}; const inPlan=planAdGroupIdSet();
|
|
1401
|
-
titleEl.textContent='
|
|
2659
|
+
titleEl.textContent='既存'+agLabel()+'を呼び出し';
|
|
1402
2660
|
const ids=Object.keys(ags).sort();
|
|
1403
2661
|
const shown=ids.filter(id=>!q||String(id).includes(q)||String((ags[id]&&ags[id].name)||'').toLowerCase().includes(q)||String((ags[id]&&ags[id].paramCode)||'').toLowerCase().includes(q)||String((ags[id]&&ags[id].campaignId)||'').includes(q));
|
|
1404
2662
|
tgtEl.textContent='アカウントアセット全体 / '+ids.length+'件';
|
|
@@ -1407,7 +2665,7 @@ function renderDrawer(){
|
|
|
1407
2665
|
}
|
|
1408
2666
|
if(drawerTarget.type==='cppick'){
|
|
1409
2667
|
// F-6: プラン内キャンペーン選択 (親付け替え or 新規AG追加先)
|
|
1410
|
-
titleEl.textContent = drawerTarget.mode==='addNew' ? '
|
|
2668
|
+
titleEl.textContent = drawerTarget.mode==='addNew' ? ('新規'+agLabel()+'の追加先キャンペーン') : (agLabel()+'のキャンペーンを選択');
|
|
1411
2669
|
tgtEl.textContent='プラン内キャンペーン / '+st.campaigns.length+'件';
|
|
1412
2670
|
let curCUid=null; if(drawerTarget.gUid){ const r=findGroup(drawerTarget.gUid); if(r) curCUid=r.c.uid; }
|
|
1413
2671
|
let h=''; st.campaigns.forEach((c,i)=>{ if(q&&!String(c.name||'').toLowerCase().includes(q)) return; const cur=c.uid===curCUid; h+='<div class="ddrow'+(cur?' cur':'')+'" data-cppick="'+esc(c.uid)+'" title="'+esc(c.name)+'"><span class="ddno">'+(i+1)+'</span><span class="ddtx">'+esc(c.name||'(無名)')+(cur?' <span class="pill">現在</span>':'')+'</span></div>'; });
|
|
@@ -1422,32 +2680,38 @@ function renderDrawer(){
|
|
|
1422
2680
|
listEl.innerHTML=h||'<div class="empty" style="padding:24px">広告グループがありません</div>'; return;
|
|
1423
2681
|
}
|
|
1424
2682
|
if(drawerTarget.type==='tg'){
|
|
1425
|
-
const cfg=
|
|
2683
|
+
const cfg=pf().targeting[drawerTarget.field]; const r=findGroup(drawerTarget.gUid); const g=r?r.g:null;
|
|
1426
2684
|
const cur=(g&&g[drawerTarget.field]?String(g[drawerTarget.field]):'').split(',').map(s=>s.trim()).filter(Boolean);
|
|
1427
2685
|
titleEl.textContent=cfg.label+' を選択(複数可)';
|
|
1428
2686
|
tgtEl.textContent='入力先: '+(g&&g.name?g.name:'広告グループ')+' / '+cfg.label+(cur.length?('('+cur.length+'件選択中)'):'(未設定)');
|
|
1429
2687
|
let h=''; cfg.opts.forEach(o=>{ if(q&&!o.toLowerCase().includes(q)) return; const on=cur.indexOf(o)>=0; h+='<div class="ddrow'+(on?' cur':'')+'" data-tgval="'+esc(o)+'"><span class="ddno">'+(on?'✓':'')+'</span><span class="ddtx">'+esc(o)+'</span></div>'; });
|
|
1430
2688
|
listEl.innerHTML=h||'<div class="empty" style="padding:24px">該当なし</div>'; return;
|
|
1431
2689
|
}
|
|
1432
|
-
|
|
2690
|
+
/* S7: 入力先の見出しは列記述子のラベル (媒体ごとの列名をここに書かない) */
|
|
2691
|
+
const dcol=colDefs('ads').find(c=>c.field===drawerTarget.f); const fieldName=dcol?dcol.label:'';
|
|
1433
2692
|
let curNo=''; const fa=findAd(drawerTarget.u); if(fa) curNo=String(fa.a[drawerTarget.f]||'');
|
|
1434
2693
|
tgtEl.textContent='入力先: '+fieldName+(curNo?(' / 現在 '+curNo):' / 未設定');
|
|
2694
|
+
/* S10: 参照テーブルは列記述子の refTable から引く。値の見せ方は refTables[key] (label/drawerLabel/dropEmpty)。 */
|
|
2695
|
+
const rkey=(dcol&&dcol.refTable) || (drawerTarget.type==='titles'?'titles':'media');
|
|
2696
|
+
const T=refTableDef(rkey)||{}, store=st.refs[rkey]||{};
|
|
2697
|
+
const single=!!(T.valueFields&&T.valueFields.length===1);
|
|
2698
|
+
titleEl.textContent = single ? ((T.label||'')+'一覧(文言ライブラリ)') : ((T.countLabel||T.label||'')+' 一覧');
|
|
1435
2699
|
let html='';
|
|
1436
|
-
|
|
1437
|
-
const
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
}
|
|
2700
|
+
refSortedKeys({keyKind:'number'},store).forEach(no=>{
|
|
2701
|
+
const v=store[no];
|
|
2702
|
+
if(T.dropEmpty && String(v==null?'':v).trim()==='') return;
|
|
2703
|
+
if(q && !refSearchStrings(T,no,v).some(s=>s.includes(q))) return;
|
|
2704
|
+
const txt = T.drawerLabel ? T.drawerLabel(v) : esc(String(v==null?'':v));
|
|
2705
|
+
html+='<div class="ddrow'+(String(no)===curNo?' cur':'')+'" data-no="'+esc(no)+'"><span class="ddno">'+esc(no)+'</span>'
|
|
2706
|
+
+(single?'':newBadge(rkey,no))+'<span class="ddtx">'+txt+'</span></div>';
|
|
2707
|
+
});
|
|
1445
2708
|
listEl.innerHTML=html||'<div class="empty" style="padding:24px">該当なし</div>';
|
|
1446
2709
|
}
|
|
1447
2710
|
function drawerPick(no){
|
|
1448
2711
|
if(!drawerTarget) return;
|
|
1449
2712
|
const f=drawerTarget.f, num=/^\d+$/.test(no)?Number(no):no;
|
|
1450
|
-
|
|
2713
|
+
// S3: 旧 [1] / [2,3] リテラルを撤去。同じ▼ドロワー表示モードを持つ refcombo 列を記述子から算出する
|
|
2714
|
+
const compat = compatColsFor(drawerTarget.type, sheetTab);
|
|
1451
2715
|
const rg=selRange(); let applied=0;
|
|
1452
2716
|
mutate(()=>{
|
|
1453
2717
|
if(rg){ const vis=flatVisible(); for(let rr=rg.r0; rr<=rg.r1; rr++){ const item=vis[rr]; if(!item) continue; for(let cc=rg.c0; cc<=rg.c1; cc++){ if(compat.indexOf(cc)>=0){ setVar(item,cc,num); applied++; } } } }
|
|
@@ -1497,7 +2761,7 @@ document.addEventListener('click', e=>{
|
|
|
1497
2761
|
if(act==='ag-dup'){ dupGroup(u); return; }
|
|
1498
2762
|
if(act==='ag-del'){ const r=findGroup(u); if(r) mutate(()=>r.c.adGroups.splice(r.i,1)); return; }
|
|
1499
2763
|
if(act==='ag-param'){ editParam(u); return; }
|
|
1500
|
-
if(act==='cp-addag'){ const r=findCp(u); if(r) mutate(()=>r.c.adGroups.push(
|
|
2764
|
+
if(act==='cp-addag'){ const r=findCp(u); if(r) mutate(()=>r.c.adGroups.push(newAdGroup(r.c))); return; }
|
|
1501
2765
|
if(act==='cp-addag-existing'){ openAdGroupRefDrawer(); return; } // F-5: アカウントアセット全体から
|
|
1502
2766
|
if(act==='cp-edit'){ editCampaign(u); return; }
|
|
1503
2767
|
if(act==='cp-dup'){ dupCampaign(u); return; }
|
|
@@ -1514,13 +2778,13 @@ document.addEventListener('click', e=>{
|
|
|
1514
2778
|
function addAdBottom(){
|
|
1515
2779
|
const cps=st.campaigns; if(!cps.length){ toast('先にキャンペーンを追加してください'); return; }
|
|
1516
2780
|
let g=null; for(let i=cps.length-1;i>=0 && !g;i--){ const ags=cps[i].adGroups; if(ags.length) g=ags[ags.length-1]; }
|
|
1517
|
-
if(!g){ toast('
|
|
2781
|
+
if(!g){ toast('先に'+agLabel()+'を追加してください'); return; }
|
|
1518
2782
|
mutate(()=>g.ads.push(newAd())); toast('広告を追加('+esc(g.name)+')');
|
|
1519
2783
|
}
|
|
1520
2784
|
function addCampaign(){ mutate(()=>st.campaigns.push(makeNewCampaign({urlNumber:suggestUrlNumber()}))); toast('新規キャンペーンを追加'); }
|
|
1521
2785
|
|
|
1522
2786
|
/* ---- clone helpers ---- */
|
|
1523
|
-
function cloneAd(a, clear){ const n=newAd(Object.assign({},a)); n.uid=uid('a'); if(clear){ clear.forEach(k=>{ if(k==='creative'){
|
|
2787
|
+
function cloneAd(a, clear){ const n=newAd(Object.assign({},a)); n.uid=uid('a'); if(clear){ clear.forEach(k=>{ if(k==='creative'){ pf().creativeFields.forEach(f=>{ n[f]=''; }); } else n[k]=''; }); } return n; }
|
|
1524
2788
|
function dupAd(u, clear){ const r=findAd(u); if(!r) return; mutate(()=>r.g.ads.splice(r.i+1,0,cloneAd(r.a, clear?['creative']:null))); }
|
|
1525
2789
|
function addNAds(u){
|
|
1526
2790
|
const r=findGroup(u); if(!r) return;
|
|
@@ -1559,18 +2823,18 @@ function editCampaign(u){
|
|
|
1559
2823
|
{key:'name',label:'キャンペーン名',type:'text',value:c.name},
|
|
1560
2824
|
{key:'variable',label:'検証する変数(1キャンペーン=1変数)',type:'text',value:c.variable},
|
|
1561
2825
|
{key:'urlNumber',label:'入稿リンク末尾番号(空き: '+suggestUrlNumber()+')',type:'text',value:c.urlNumber},
|
|
1562
|
-
{key:'budget',label:'予算/日(空で既定 '+
|
|
2826
|
+
{key:'budget',label:'予算/日(空で既定 '+pf().defaults.newCampaignBudget+')',type:'number',value:c.budget??''},
|
|
1563
2827
|
{key:'biddingStrategy',label:'配信戦略',type:'select',value:c.biddingStrategy||'tCPA',options:[{v:'tCPA',t:'tCPA'},{v:'最大配信',t:'最大配信'}]},
|
|
1564
2828
|
{key:'tcpa',label:'tCPA(円)',type:'number',value:c.tcpa??''}
|
|
1565
2829
|
], extra:'<button class="act" id="genName" style="margin-right:auto">キャンペーン名を自動生成</button>', onMount:(root,getVals,setVal)=>{
|
|
1566
|
-
root.querySelector('#genName').addEventListener('click',()=>{ const num=getVals().urlNumber||suggestUrlNumber(); const first=c.adGroups[0]&&c.adGroups[0].ads[0];
|
|
2830
|
+
root.querySelector('#genName').addEventListener('click',()=>{ const num=getVals().urlNumber||suggestUrlNumber(); const first=c.adGroups[0]&&c.adGroups[0].ads[0]; setVal('name', pf().naming.campaignName(num, first, pf())); });
|
|
1567
2831
|
}, onOk:v=>{ mutate(()=>{ c.name=v.name; c.variable=v.variable; c.urlNumber=v.urlNumber; c.budget=numOrNull(v.budget); c.biddingStrategy=v.biddingStrategy; c.tcpa=numOrNull(v.tcpa); }); }});
|
|
1568
2832
|
// B6解消: 配信戦略/tCPAはキャンペーン個別値のみを参照 (project既定を焼き付けない)
|
|
1569
2833
|
}
|
|
1570
2834
|
function cloneCampaign(c, clearCreative, clearParam){
|
|
1571
2835
|
const nc=JSON.parse(JSON.stringify(c)); nc.uid=uid('c');
|
|
1572
2836
|
nc.campaignId=''; nc.isExisting=false; // B7/F-2: 複製はIDを引き継がず必ず新規扱い(編集可)
|
|
1573
|
-
nc.adGroups.forEach(g=>{ g.uid=uid('g'); g.adGroupId=''; g.isExisting=false; if(clearParam) g.paramCode=''; g.ads.forEach(a=>{ a.uid=uid('a'); if(clearCreative)
|
|
2837
|
+
nc.adGroups.forEach(g=>{ g.uid=uid('g'); g.adGroupId=''; g.isExisting=false; if(clearParam) g.paramCode=''; g.ads.forEach(a=>{ a.uid=uid('a'); if(clearCreative) pf().creativeFields.forEach(f=>{ a[f]=''; }); }); });
|
|
1574
2838
|
return nc;
|
|
1575
2839
|
}
|
|
1576
2840
|
/* 指定prefixを持つ paramCode の現在の最大末尾番号 (プラン内 + アカウントJSON refs.groups を走査) */
|
|
@@ -1593,7 +2857,7 @@ function dupCampaign(u){
|
|
|
1593
2857
|
// paramCode 連番自動採番 (prefixごとに現状の最大+1から連番)
|
|
1594
2858
|
nc.adGroups.forEach(g=>{ const m=String(g.paramCode||'').match(/^(.*?)(\d+)\s*$/); if(m){ const prefix=m[1], width=m[2].length; if(nextByPrefix[prefix]==null) nextByPrefix[prefix]=maxParamNum(prefix)+1; g.paramCode=prefix+String(nextByPrefix[prefix]++).padStart(width,'0'); autoResolveGroupIds(g,nc); } });
|
|
1595
2859
|
// キャンペーン名 自動採番 ({空き番号}_{CR名}_{今日})
|
|
1596
|
-
const num=String(startNum+i).padStart(2,'0'); nc.urlNumber=num; const f=nc.adGroups[0]&&nc.adGroups[0].ads[0];
|
|
2860
|
+
const num=String(startNum+i).padStart(2,'0'); nc.urlNumber=num; const f=nc.adGroups[0]&&nc.adGroups[0].ads[0]; nc.name=pf().naming.campaignName(num, f, pf());
|
|
1597
2861
|
st.campaigns.splice(base+1+i,0,nc);
|
|
1598
2862
|
}
|
|
1599
2863
|
});
|
|
@@ -1659,14 +2923,40 @@ let samplePlanFileHandle=null; // サンプルプラン.json の書込ハンド
|
|
|
1659
2923
|
const fsSupported = typeof window.showDirectoryPicker==='function';
|
|
1660
2924
|
const fsOpenSupported = typeof window.showOpenFilePicker==='function';
|
|
1661
2925
|
async function writeToHandle(h,text){ try{ const w=await h.createWritable(); await w.write(text); await w.close(); return true; }catch(e){ return false; } }
|
|
1662
|
-
|
|
2926
|
+
/* S9: IndexedDB も ad_planner_fs へ改名。キーは 'dir'(入稿計画/・媒体共通) /
|
|
2927
|
+
'accountsDir:<platform>' / 'samplePlanFile:<platform>'。旧 'yda_planner_fs' からは1回だけコピーする。 */
|
|
2928
|
+
const IDB_NAME='ad_planner_fs';
|
|
2929
|
+
const LEGACY_IDB_NAME='yda_planner_fs';
|
|
2930
|
+
function idbKeyAccountsDir(){ return 'accountsDir:'+curPf; }
|
|
2931
|
+
function idbKeySamplePlan(){ return 'samplePlanFile:'+curPf; }
|
|
2932
|
+
function idbOpen(name){ return new Promise((res,rej)=>{ const r=indexedDB.open(name,1); r.onupgradeneeded=()=>{ try{ r.result.createObjectStore('handles'); }catch(e){} }; r.onsuccess=()=>res(r.result); r.onerror=()=>rej(r.error); }); }
|
|
2933
|
+
function idbGetIn(db,k){ return new Promise(res=>{ try{ const rq=db.transaction('handles','readonly').objectStore('handles').get(k); rq.onsuccess=()=>res(rq.result); rq.onerror=()=>res(undefined); }catch(e){ res(undefined); } }); }
|
|
2934
|
+
function idbPutIn(db,k,v){ return new Promise(res=>{ try{ const tx=db.transaction('handles','readwrite'); tx.objectStore('handles').put(v,k); tx.oncomplete=()=>res(true); tx.onerror=()=>res(false); }catch(e){ res(false); } }); }
|
|
2935
|
+
async function migrateLegacyIdb(db){
|
|
2936
|
+
try{
|
|
2937
|
+
if(typeof indexedDB.databases==='function'){
|
|
2938
|
+
const list=await indexedDB.databases();
|
|
2939
|
+
if(!(list||[]).some(d=>d&&d.name===LEGACY_IDB_NAME)) return; // 旧DBが無ければ何もしない(空DBを作らない)
|
|
2940
|
+
}
|
|
2941
|
+
const old=await idbOpen(LEGACY_IDB_NAME);
|
|
2942
|
+
const pairs=[['dir','dir'],[LEGACY_KEY_NS+'fs_accounts','accountsDir:yda'],['samplePlanFile','samplePlanFile:yda']];
|
|
2943
|
+
for(const [from,to] of pairs){
|
|
2944
|
+
const v=await idbGetIn(old,from);
|
|
2945
|
+
if(v==null) continue;
|
|
2946
|
+
if((await idbGetIn(db,to))==null) await idbPutIn(db,to,v);
|
|
2947
|
+
}
|
|
2948
|
+
try{ old.close(); }catch(e){}
|
|
2949
|
+
}catch(e){}
|
|
2950
|
+
}
|
|
2951
|
+
let _idbPromise=null;
|
|
2952
|
+
function idb(){ if(!_idbPromise) _idbPromise=idbOpen(IDB_NAME).then(async db=>{ await migrateLegacyIdb(db); return db; }); return _idbPromise; }
|
|
1663
2953
|
async function idbSet(k,v){ const db=await idb(); return new Promise((res,rej)=>{ const tx=db.transaction('handles','readwrite'); tx.objectStore('handles').put(v,k); tx.oncomplete=res; tx.onerror=()=>rej(tx.error); }); }
|
|
1664
2954
|
async function idbGet(k){ const db=await idb(); return new Promise((res,rej)=>{ const tx=db.transaction('handles','readonly'); const rq=tx.objectStore('handles').get(k); rq.onsuccess=()=>res(rq.result); rq.onerror=()=>rej(rq.error); }); }
|
|
1665
2955
|
async function connectFolder(){
|
|
1666
2956
|
if(!fsSupported){ toast('このブラウザはフォルダ連携非対応です(保存はダウンロード動作)'); return; }
|
|
1667
2957
|
try{
|
|
1668
2958
|
if(pendingDirHandle){ const p=await pendingDirHandle.requestPermission({mode:'readwrite'}); if(p==='granted'){ dirHandle=pendingDirHandle; pendingDirHandle=null; renderHist(); toast('保存フォルダに再接続しました'); return; } }
|
|
1669
|
-
dirHandle=await window.showDirectoryPicker({id:'
|
|
2959
|
+
dirHandle=await window.showDirectoryPicker({id:'ad-planner-plans', mode:'readwrite'});
|
|
1670
2960
|
pendingDirHandle=null; await idbSet('dir',dirHandle); renderHist(); updateSubmitJsonBtn(); toast('保存フォルダに接続しました: '+dirHandle.name);
|
|
1671
2961
|
}catch(e){ if(e && e.name!=='AbortError') toast('フォルダ接続に失敗: '+e.message); }
|
|
1672
2962
|
}
|
|
@@ -1688,23 +2978,45 @@ async function writePlanFile(name, text){
|
|
|
1688
2978
|
}
|
|
1689
2979
|
|
|
1690
2980
|
/* ============================================================
|
|
1691
|
-
B-1: アカウント別参照データ (
|
|
1692
|
-
File System Access API 前提 (Chrome系・フォールバックなし)。未接続時は同梱デフォルト(
|
|
1693
|
-
IndexedDB キー '
|
|
2981
|
+
B-1: アカウント別参照データ (アカウント/<媒体>/ フォルダ + <名前>.json)
|
|
2982
|
+
File System Access API 前提 (Chrome系・フォールバックなし)。未接続時は同梱デフォルト(<account.dir>/index.json 先頭)を使用。
|
|
2983
|
+
IndexedDB キー 'accountsDir:<platform>' に第2ハンドルを保存 (入稿計画/ の 'dir' とは別枠)。
|
|
1694
2984
|
============================================================ */
|
|
1695
2985
|
const ACC_JSON_VERSION=1;
|
|
1696
2986
|
let accDirHandle=null, pendingAccDirHandle=null;
|
|
1697
2987
|
let accountFiles=[]; // フォルダ内の <名前>.json のベース名一覧 (File System Access接続時)
|
|
1698
|
-
let httpAccountFiles=[]; //
|
|
1699
|
-
/* アカウント一覧は マニフェスト
|
|
2988
|
+
let httpAccountFiles=[]; // <account.dir>/index.json から取得した <名前> 一覧 (読み取り専用切替)
|
|
2989
|
+
/* アカウント一覧は マニフェスト <account.dir>/index.json (直接ファイルfetch)から取得する。
|
|
1700
2990
|
サンプルプランと同じ「ファイル読み込み」なので、file:///autoindex無し環境でも読める。
|
|
1701
2991
|
フォールバック: index.json が無ければディレクトリ一覧(HTML)を解析。 */
|
|
2992
|
+
/* S9: アカウントJSON は媒体別サブディレクトリ (pf().account.dir = 'アカウント/<platform>') に置く。
|
|
2993
|
+
旧レイアウト (アカウント/ 直下) も引き続き読めるようフォールバックする
|
|
2994
|
+
(他環境・配布済みスキルを壊さないため。旧レイアウトは YDA のみ存在した)。 */
|
|
2995
|
+
const LEGACY_ACCOUNT_DIR='アカウント';
|
|
2996
|
+
function accountDir(){ const P=pf(); return (P&&P.account&&P.account.dir)||LEGACY_ACCOUNT_DIR; }
|
|
2997
|
+
function legacyAccountDir(){ return curPf==='yda' ? LEGACY_ACCOUNT_DIR : null; }
|
|
2998
|
+
async function fetchAccountIndex(){
|
|
2999
|
+
const idx=await fetchJson(accountDir()+'/index.json');
|
|
3000
|
+
if(idx) return idx;
|
|
3001
|
+
const legacy=legacyAccountDir();
|
|
3002
|
+
return legacy ? await fetchJson(legacy+'/index.json') : null;
|
|
3003
|
+
}
|
|
3004
|
+
async function fetchAccountJsonByName(name){
|
|
3005
|
+
const rel=encodeURIComponent(name)+'.json';
|
|
3006
|
+
const obj=await fetchJson(accountDir()+'/'+rel);
|
|
3007
|
+
if(obj) return obj;
|
|
3008
|
+
const legacy=legacyAccountDir();
|
|
3009
|
+
return legacy ? await fetchJson(legacy+'/'+rel) : null;
|
|
3010
|
+
}
|
|
1702
3011
|
async function fetchAccountList(){
|
|
1703
3012
|
const set=new Set();
|
|
1704
|
-
// (1) マニフェスト
|
|
1705
|
-
try{ const idx=await
|
|
3013
|
+
// (1) マニフェスト <account.dir>/index.json (file://・autoindex無しでも読める。旧レイアウトへフォールバック)
|
|
3014
|
+
try{ const idx=await fetchAccountIndex(); if(idx){ const arr=Array.isArray(idx)?idx:(idx.accounts||[]); arr.map(String).filter(Boolean).forEach(n=>set.add(n)); } }catch(e){}
|
|
1706
3015
|
// (2) ディレクトリ一覧(HTML) — http配信時は index.json に無い新規ファイルも自動検出してマージ
|
|
1707
|
-
|
|
3016
|
+
for(const dir of [accountDir(), legacyAccountDir()]){
|
|
3017
|
+
if(!dir) continue;
|
|
3018
|
+
try{ const r=await fetch(dir+'/',{cache:'no-store'}); if(r&&r.ok){ const html=await r.text(); const re=/href="([^"]+\.json)"/gi; let m; while((m=re.exec(html))){ let n=decodeURIComponent(m[1]).replace(/^.*\//,'').replace(/\.json$/i,''); if(n&&n!=='index') set.add(n); } } }catch(e){}
|
|
3019
|
+
}
|
|
1708
3020
|
httpAccountFiles=[...set].sort();
|
|
1709
3021
|
}
|
|
1710
3022
|
function accStatusText(){
|
|
@@ -1731,14 +3043,14 @@ async function connectAccountFolder(){
|
|
|
1731
3043
|
if(!fsSupported){ toast('このブラウザはフォルダ連携非対応です'); return; }
|
|
1732
3044
|
try{
|
|
1733
3045
|
if(pendingAccDirHandle){ const p=await pendingAccDirHandle.requestPermission({mode:'readwrite'}); if(p==='granted'){ accDirHandle=pendingAccDirHandle; pendingAccDirHandle=null; await refreshAccountFiles(); toast('アカウントフォルダに再接続しました'); return; } }
|
|
1734
|
-
accDirHandle=await window.showDirectoryPicker({id:'
|
|
1735
|
-
pendingAccDirHandle=null; await idbSet(
|
|
3046
|
+
accDirHandle=await window.showDirectoryPicker({id:'ad-planner-accounts-'+curPf, mode:'readwrite'});
|
|
3047
|
+
pendingAccDirHandle=null; await idbSet(idbKeyAccountsDir(), accDirHandle);
|
|
1736
3048
|
await refreshAccountFiles(); toast('アカウントフォルダに接続: '+accDirHandle.name);
|
|
1737
3049
|
}catch(e){ if(e && e.name!=='AbortError') toast('フォルダ接続に失敗: '+e.message); }
|
|
1738
3050
|
}
|
|
1739
3051
|
async function restoreAccountFolder(){
|
|
1740
3052
|
if(!fsSupported){ renderAccountBar(); return; }
|
|
1741
|
-
try{ const h=await idbGet(
|
|
3053
|
+
try{ const h=await idbGet(idbKeyAccountsDir()); if(h){ const p=await h.queryPermission({mode:'readwrite'}); if(p==='granted'){ accDirHandle=h; await refreshAccountFiles(); } else pendingAccDirHandle=h; } }catch(e){}
|
|
1742
3054
|
renderAccountBar();
|
|
1743
3055
|
}
|
|
1744
3056
|
async function refreshAccountFiles(){
|
|
@@ -1758,12 +3070,15 @@ async function writeAccountJson(name, obj){
|
|
|
1758
3070
|
/* 参照データ差し替えのシーム: フォルダ読込・新規作成・E2Eテストが共通で使う */
|
|
1759
3071
|
window.applyAccountJson=function(obj){
|
|
1760
3072
|
if(!obj||obj.accountJsonVersion!==ACC_JSON_VERSION||!obj.refs) throw new Error('アカウントJSON形式が不正です(accountJsonVersion:'+ACC_JSON_VERSION+' 必須)');
|
|
3073
|
+
/* S9: platform 検証。未記載は 'yda' (旧アカウントJSONの後方互換)。別媒体なら媒体ごと切り替える。 */
|
|
3074
|
+
const declared=String(obj.platform||'yda');
|
|
3075
|
+
if(declared!==curPf){
|
|
3076
|
+
if(!PLATFORMS[declared]) throw new Error('このアカウントJSONの媒体は未対応です(platform: '+declared+')');
|
|
3077
|
+
window.switchPlatform(declared);
|
|
3078
|
+
}
|
|
1761
3079
|
snapshot();
|
|
1762
3080
|
st.refs=normalizeRefs(obj.refs);
|
|
1763
|
-
|
|
1764
|
-
st.project.accountName=String(obj.name||'');
|
|
1765
|
-
// E-1: 主体者(principal)はアカウントJSON由来。無ければ既存値を維持
|
|
1766
|
-
if(obj.principal!=null) st.project.principal=String(obj.principal);
|
|
3081
|
+
pf().account.apply(obj, st.project); // S7: project への写し込みは媒体スキーマ (accountId書式/追加項目)
|
|
1767
3082
|
persist(); buildDatalists(); render(); renderAccountBar();
|
|
1768
3083
|
};
|
|
1769
3084
|
async function selectAccount(name){
|
|
@@ -1771,7 +3086,7 @@ async function selectAccount(name){
|
|
|
1771
3086
|
if(name===st.project.accountName){ renderAccountBar(); return; }
|
|
1772
3087
|
if(!accDirHandle){
|
|
1773
3088
|
if(httpAccountFiles.includes(name)){ // http一覧のアカウントを fetch で読み込み(読み取り専用切替)
|
|
1774
|
-
const obj=await
|
|
3089
|
+
const obj=await fetchAccountJsonByName(name);
|
|
1775
3090
|
if(obj){ try{ window.applyAccountJson(obj); toast('アカウント '+name+' に切替(参照データを差し替え)'); }catch(e){ toast(e.message); renderAccountBar(); } }
|
|
1776
3091
|
else { toast('アカウントJSONを読み込めません: '+name); renderAccountBar(); }
|
|
1777
3092
|
return;
|
|
@@ -1819,43 +3134,9 @@ async function loadAccountFile(file){
|
|
|
1819
3134
|
}
|
|
1820
3135
|
/* F-3: AIで追加 — フォーム入力は無し。プロンプトだけを表示し、アカウント名/ID/主体者は Claude Code 上でAIと対話して決めさせる */
|
|
1821
3136
|
function openAiAddAccountModal(){
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
' "accountId": "<ID>",',
|
|
1826
|
-
' "name": "<アカウント名>",',
|
|
1827
|
-
' "principal": "<主体者表記(例: ○○製薬)>",',
|
|
1828
|
-
' "updatedAt": "<ISO8601>",',
|
|
1829
|
-
' "refs": {',
|
|
1830
|
-
' "titles": {"1": "見出し文言"},',
|
|
1831
|
-
' "descriptions": {"1": "説明文文言"},',
|
|
1832
|
-
' "media": {"954": {"card": "<mediaId>", "sq": "<mediaId>"}},',
|
|
1833
|
-
' "campaigns": {"<campaignId>": {"name": "CP名"}},',
|
|
1834
|
-
' "adGroups": {"<adGroupId>": {"name": "AG名", "campaignId": "<campaignId>", "paramCode": "<code>"}},',
|
|
1835
|
-
' "groups": {"<paramCode>": {"campaignId": "<campaignId>", "adGroupId": "<adGroupId>", "groupName": "AG名"}}',
|
|
1836
|
-
' }',
|
|
1837
|
-
'}'
|
|
1838
|
-
].join('\n');
|
|
1839
|
-
const prompt=[
|
|
1840
|
-
'YDA入稿プランナー用に、新規アカウントの参照データJSONを生成してください。',
|
|
1841
|
-
'',
|
|
1842
|
-
'1. まず対象アカウントを対話で決めてください:',
|
|
1843
|
-
' - webgram list --platform yda --resource accounts で候補一覧を取得し、どのアカウントを追加するか私に確認してアカウント名・accountId・主体者表記を確定する',
|
|
1844
|
-
'2. 確定したアカウントについて、登録画像・既存キャンペーン/広告グループを取得:',
|
|
1845
|
-
' webgram list --platform yda --resource media --account-id <ID>',
|
|
1846
|
-
' webgram list --platform yda --resource campaigns --account-id <ID>',
|
|
1847
|
-
' (既存グループ paramCode↔ID の突合は GET /yda/campaign-import も利用可)',
|
|
1848
|
-
'3. 取得結果を下記スキーマに整形し、アカウント/<アカウント名>.json として書き出す:',
|
|
1849
|
-
' - media は 画像番号 → {card, sq}(mediaId)',
|
|
1850
|
-
' - groups は paramCode → {campaignId, adGroupId, groupName} の逆引き',
|
|
1851
|
-
' - campaigns/adGroups は campaignId/adGroupId をキーに',
|
|
1852
|
-
'',
|
|
1853
|
-
'スキーマ:',
|
|
1854
|
-
schema,
|
|
1855
|
-
'',
|
|
1856
|
-
'書き出したら、プランナー右上のアカウントプルダウン →「+新規追加 →「📄 ファイルから追加」で その JSON を読み込めます。'
|
|
1857
|
-
].join('\n');
|
|
1858
|
-
showPromptModal({title:'AIで追加(CLIから収集)', sub:'このプロンプトをClaude Codeに貼ってください。アカウント名はAIと対話して決めます', prompt, hint:'生成された アカウント/<名前>.json は「+新規追加 →「📄 ファイルから追加」で読み込めます'});
|
|
3137
|
+
/* S7: プロンプト文言は pf().prompts.importAccount (媒体スキーマ) に集約 */
|
|
3138
|
+
const prompt=pf().prompts.importAccount({mode:'ai'});
|
|
3139
|
+
showPromptModal({title:'AIで追加(CLIから収集)', sub:'このプロンプトをClaude Codeに貼ってください。アカウント名はAIと対話して決めます', prompt, hint:'生成された '+accountDir()+'/<名前>.json は「+新規追加 →「📄 ファイルから追加」で読み込めます'});
|
|
1859
3140
|
}
|
|
1860
3141
|
/* E-1: アクティブアカウントの主体者/アカウントIDを編集 (アカウントJSONへ書き戻し) */
|
|
1861
3142
|
function openEditAccountModal(){
|
|
@@ -1871,44 +3152,9 @@ function openEditAccountModal(){
|
|
|
1871
3152
|
}
|
|
1872
3153
|
/* B-3: CLI経由インポートのプロンプトを表示 (静的HTMLは直接APIを叩かず Claude に生成させる) */
|
|
1873
3154
|
function openImportModal(){
|
|
1874
|
-
const name=st.project.accountName||''
|
|
1875
|
-
const
|
|
1876
|
-
|
|
1877
|
-
' "accountJsonVersion": 1,',
|
|
1878
|
-
' "accountId": "<ID>",',
|
|
1879
|
-
' "name": "<アカウント名>",',
|
|
1880
|
-
' "principal": "<主体者表記(例: ○○製薬)>",',
|
|
1881
|
-
' "updatedAt": "<ISO8601>",',
|
|
1882
|
-
' "refs": {',
|
|
1883
|
-
' "titles": {"1": "見出し文言"},',
|
|
1884
|
-
' "descriptions": {"1": "説明文文言"},',
|
|
1885
|
-
' "media": {"954": {"card": "<mediaId>", "sq": "<mediaId>"}},',
|
|
1886
|
-
' "campaigns": {"<campaignId>": {"name": "CP名"}},',
|
|
1887
|
-
' "adGroups": {"<adGroupId>": {"name": "AG名", "campaignId": "<campaignId>", "paramCode": "<code>"}},',
|
|
1888
|
-
' "groups": {"<paramCode>": {"campaignId": "<campaignId>", "adGroupId": "<adGroupId>", "groupName": "AG名"}}',
|
|
1889
|
-
' }',
|
|
1890
|
-
'}'
|
|
1891
|
-
].join('\n');
|
|
1892
|
-
const prompt=[
|
|
1893
|
-
'YDA入稿プランナー用に、アカウント「'+(name||'<アカウント名>')+'」'+(accId?'(accountId: '+accId+')':'')+'の参照データJSONを生成してください。',
|
|
1894
|
-
'',
|
|
1895
|
-
'手順:',
|
|
1896
|
-
'1. webgram CLI で対象アカウントの情報・登録画像・既存キャンペーン/広告グループを取得:',
|
|
1897
|
-
' webgram list --platform yda --resource accounts',
|
|
1898
|
-
' webgram list --platform yda --resource media --account-id '+(accId||'<ID>'),
|
|
1899
|
-
' webgram list --platform yda --resource campaigns --account-id '+(accId||'<ID>'),
|
|
1900
|
-
' (既存グループ paramCode↔ID の突合は GET /yda/campaign-import も利用可)',
|
|
1901
|
-
'2. 取得結果を下記スキーマに整形して アカウント/'+(name||'<アカウント名>')+'.json として書き出す:',
|
|
1902
|
-
' - media は 画像番号 → {card, sq}(mediaId)',
|
|
1903
|
-
' - groups は paramCode → {campaignId, adGroupId, groupName} の逆引き',
|
|
1904
|
-
' - campaigns/adGroups は campaignId/adGroupId をキーに',
|
|
1905
|
-
'',
|
|
1906
|
-
'スキーマ:',
|
|
1907
|
-
schema,
|
|
1908
|
-
'',
|
|
1909
|
-
'書き出したら、プランナーで「📁 フォルダ」を再接続 or アカウントセレクトを開くと自動で一覧に出ます。'
|
|
1910
|
-
].join('\n');
|
|
1911
|
-
showPromptModal({title:'アカウント情報をインポート', sub:'このプロンプトをClaude Codeに貼ってCLIでアカウントJSONを生成してください', prompt, hint:'アカウント/<名前>.json が生成されたら「📁 フォルダ」再接続で自動的にセレクトに現れます'});
|
|
3155
|
+
const name=st.project.accountName||'', accId=st.project.accountId||'';
|
|
3156
|
+
const prompt=pf().prompts.importAccount({mode:'import', name, accountId:accId});
|
|
3157
|
+
showPromptModal({title:'アカウント情報をインポート', sub:'このプロンプトをClaude Codeに貼ってCLIでアカウントJSONを生成してください', prompt, hint:accountDir()+'/<名前>.json が生成されたら「📁 フォルダ」再接続で自動的にセレクトに現れます'});
|
|
1912
3158
|
}
|
|
1913
3159
|
/* 参照ビュー: 他アカウントから タイトル/説明文 をコピー (文言はアカウント間で共通のことが多い) */
|
|
1914
3160
|
function openCopyRefsModal(){
|
|
@@ -1920,7 +3166,7 @@ function openCopyRefsModal(){
|
|
|
1920
3166
|
], onOk: async v=>{
|
|
1921
3167
|
const obj=await readAccountJson(v.src); if(!obj||!obj.refs){ toast('読み込めません'); return; }
|
|
1922
3168
|
const src=normalizeRefs(obj.refs); let n=0; snapshot();
|
|
1923
|
-
|
|
3169
|
+
refTableKeys().filter(k=>refTableDef(k).copyable).forEach(k=>{ Object.keys(src[k]||{}).forEach(no=>{ if(st.refs[k][no]==null){ st.refs[k][no]=src[k][no]; n++; } }); });
|
|
1924
3170
|
persist(); buildDatalists(); render(); saveActiveAccountJson();
|
|
1925
3171
|
toast(v.src+' から '+n+' 件の文言をコピーしました');
|
|
1926
3172
|
}});
|
|
@@ -1929,16 +3175,16 @@ function openCopyRefsModal(){
|
|
|
1929
3175
|
/* ---- 履歴 (localStorage: メタ一覧 + バージョン本体) ----
|
|
1930
3176
|
本体もブラウザ内に自動保存する → フォルダ選択なしで一覧・再読込・削除が完結する。
|
|
1931
3177
|
フォルダ(入稿計画/)接続時はファイルにも書き出す(二重保存)。 */
|
|
1932
|
-
|
|
1933
|
-
function histList(){ try{ return JSON.parse(localStorage.getItem(
|
|
1934
|
-
function histSave(list){ try{ localStorage.setItem(
|
|
3178
|
+
/* S9: 履歴・本体キーは媒体別 (histKey() / bodyKey(vid))。 */
|
|
3179
|
+
function histList(){ try{ return JSON.parse(localStorage.getItem(histKey()))||[]; }catch(e){ return []; } }
|
|
3180
|
+
function histSave(list){ try{ localStorage.setItem(histKey(), JSON.stringify(list)); }catch(e){} }
|
|
1935
3181
|
function nextVersionId(){ return histList().reduce((m,h)=>Math.max(m,h.versionId||0),0)+1; }
|
|
1936
|
-
function histGetBody(vid){ try{ return localStorage.getItem(
|
|
1937
|
-
function histDelBody(vid){ try{ localStorage.removeItem(
|
|
3182
|
+
function histGetBody(vid){ try{ return localStorage.getItem(bodyKey(vid)); }catch(e){ return null; } }
|
|
3183
|
+
function histDelBody(vid){ try{ localStorage.removeItem(bodyKey(vid)); }catch(e){} }
|
|
1938
3184
|
/* 容量超過時は古い本体から捨てる (メタ一覧は残す = ファイル側から読み直せる) */
|
|
1939
3185
|
function histPutBody(vid, json){
|
|
1940
3186
|
for(;;){
|
|
1941
|
-
try{ localStorage.setItem(
|
|
3187
|
+
try{ localStorage.setItem(bodyKey(vid), json); return true; }
|
|
1942
3188
|
catch(e){
|
|
1943
3189
|
const stored=histList().filter(h=>h.versionId!==vid && histGetBody(h.versionId)!=null)
|
|
1944
3190
|
.sort((a,b)=>(a.versionId||0)-(b.versionId||0));
|
|
@@ -1951,14 +3197,14 @@ function histPutBody(vid, json){
|
|
|
1951
3197
|
/* ---- バージョン保存: 保存のたびに常に新バージョン (上書きなし) ---- */
|
|
1952
3198
|
function saveVersion(){
|
|
1953
3199
|
const prevAuthor = st.meta.author || localStorage.getItem(AUTHOR_KEY) || '';
|
|
1954
|
-
openModal({title:'バージョン保存', sub:'常に新バージョンとして保存します(上書きなし)/ 媒体:
|
|
3200
|
+
openModal({title:'バージョン保存', sub:'常に新バージョンとして保存します(上書きなし)/ 媒体: '+pf().displayName+' / 次: v'+nextVersionId()+' / 保存先: '+(dirHandle?dirHandle.name+'/':'ダウンロード(フォルダ未接続)'), fields:[
|
|
1955
3201
|
{key:'name',label:'名称',type:'text',value:st.meta.name||st.project.name,hint:'履歴一覧に表示される名前'},
|
|
1956
3202
|
{key:'memo',label:'メモ',type:'textarea',value:st.meta.memo||''},
|
|
1957
3203
|
{key:'author',label:'作成者',type:'text',value:prevAuthor,hint:'空欄可。次回保存時も記憶されます'}
|
|
1958
3204
|
], onOk: async v=>{
|
|
1959
3205
|
const now=new Date().toISOString();
|
|
1960
3206
|
const versionId=nextVersionId();
|
|
1961
|
-
st.meta={versionId, name:v.name||st.project.name, memo:v.memo||'', author:v.author||'', platform:
|
|
3207
|
+
st.meta={versionId, name:v.name||st.project.name, memo:v.memo||'', author:v.author||'', platform:curPf, createdAt:now, updatedAt:now};
|
|
1962
3208
|
try{ localStorage.setItem(AUTHOR_KEY, v.author||''); }catch(e){}
|
|
1963
3209
|
const c=validationCounts();
|
|
1964
3210
|
const fileName=(st.project.name||'plan')+'_v'+versionId+'_'+tsCompact()+'.json';
|
|
@@ -1967,7 +3213,7 @@ function saveVersion(){
|
|
|
1967
3213
|
const wrote=await writePlanFile(fileName,json); // フォルダ(入稿計画/)接続時はファイルにも書き出し
|
|
1968
3214
|
if(!wrote) download(fileName,json,'application/json'); // 未接続時はDLでファイルも残す(入稿計画へ手動格納可)
|
|
1969
3215
|
const list=histList();
|
|
1970
|
-
list.push({versionId, name:st.meta.name, memo:st.meta.memo, author:st.meta.author, platform:
|
|
3216
|
+
list.push({versionId, name:st.meta.name, memo:st.meta.memo, author:st.meta.author, platform:curPf, createdAt:now, fileName, inFolder:wrote, inLocal, campaigns:st.campaigns.length, ads:c.ads});
|
|
1971
3217
|
histSave(list); persist();
|
|
1972
3218
|
if(activeView==='hist') renderHist();
|
|
1973
3219
|
toast('v'+versionId+' を保存しました'+(wrote?'(入稿計画フォルダにも書き出し)':''));
|
|
@@ -1975,7 +3221,7 @@ function saveVersion(){
|
|
|
1975
3221
|
let info='<div class="callout green" style="margin-top:0">プラン「'+esc(st.meta.name)+'」<b>v'+versionId+'</b> を保存しました(CP '+st.campaigns.length+' / 広告 '+c.ads+' 件)</div>';
|
|
1976
3222
|
info+='<div class="mfield"><label>保存先 / ファイル名</label><div class="mono" style="font-size:12px; word-break:break-all">'+esc((wrote&&dirHandle?dirHandle.name+'/':'')+fileName)+'</div></div>';
|
|
1977
3223
|
if(c.err) info+='<div class="callout amber">エラー '+c.err+' 件・警告 '+c.warn+' 件があります。書き出し時に未解決広告は除外されます。</div>';
|
|
1978
|
-
const prompt=
|
|
3224
|
+
const prompt=pf().prompts.afterSave({name:(st.meta.name||st.project.name), versionId});
|
|
1979
3225
|
showPromptModal({title:'v'+versionId+' を保存しました', sub:'次のアクション(入稿)用のプロンプトです', infoHtml:info, prompt, hint:'まず「入稿JSON書き出し」でプランを入稿計画JSONに変換してください'});
|
|
1980
3226
|
}});
|
|
1981
3227
|
}
|
|
@@ -2047,105 +3293,52 @@ function histDeleteVersion(vid){
|
|
|
2047
3293
|
|
|
2048
3294
|
/* ---- 入稿JSON書き出し (webgram submit-plan 用の入稿計画JSON) ----
|
|
2049
3295
|
画面のプラン (案件設定チップ / キャンペーン行 / 広告グループ行のターゲティング列) を
|
|
2050
|
-
そのまま SubmitPlanFile {planVersion
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
/* 画面の日本語ラベル → API enum 変換表 (正: frontend/src/pages/submission/types.ts) */
|
|
2055
|
-
const TG_ENUM = {
|
|
2056
|
-
device:{'スマートフォン':'SMARTPHONE','PC':'DESKTOP','タブレット':'TABLET'},
|
|
2057
|
-
gender:{'男性':'ST_MALE','女性':'ST_FEMALE','不明':'ST_UNKNOWN'},
|
|
2058
|
-
age:{'18-19歳':'GT_RANGE18_19','20-24歳':'GT_RANGE20_24','25-29歳':'GT_RANGE25_29','30-34歳':'GT_RANGE30_34','35-39歳':'GT_RANGE35_39','40-44歳':'GT_RANGE40_44','45-49歳':'GT_RANGE45_49','50-54歳':'GT_RANGE50_54','55-59歳':'GT_RANGE55_59','60-64歳':'GT_RANGE60_64','65-69歳':'GT_RANGE65_69','70歳以上':'GT_RANGE70_UL2','不明':'GT_UNKNOWN2'},
|
|
2059
|
-
day:{'月':'MONDAY','火':'TUESDAY','水':'WEDNESDAY','木':'THURSDAY','金':'FRIDAY','土':'SATURDAY','日':'SUNDAY'},
|
|
2060
|
-
// tCPA=コンバージョン数最大化+目標CPA / 最大配信=コンバージョン数最大化(目標なし・要実機確認)
|
|
2061
|
-
bidding:{'tCPA':'MAXIMIZE_CONVERSIONS','最大配信':'MAXIMIZE_CONVERSIONS'}
|
|
2062
|
-
};
|
|
3296
|
+
そのまま SubmitPlanFile {planVersion, platform, accountId, campaigns} に変換する。
|
|
3297
|
+
3階層のボディ形状・enum変換は媒体スキーマ (pf().buildPlan / pf().enums / pf().mappers) 側。
|
|
3298
|
+
設定モーダルは持たない。未解決(参照表で引けない)広告は除外する。 */
|
|
3299
|
+
|
|
2063
3300
|
function tgList(s){ return String(s||'').split(',').map(x=>x.trim()).filter(Boolean); }
|
|
2064
|
-
function mapDevices(s){ const l=tgList(s); if(!l.length||l.indexOf('全てのデバイス')>=0) return []; return l.map(x=>TG_ENUM.device[x]).filter(Boolean); }
|
|
2065
|
-
/* 曜日・時間帯セル → {days[], hours{start,end}|null}。「平日」「土日祝」は展開、
|
|
2066
|
-
時間帯(午前(6-12時)等)は startHour/endHour に変換 (複数選択時は最初の1つ) */
|
|
2067
|
-
function mapDays(s){
|
|
2068
|
-
const l=tgList(s); const days=[]; let hours=null;
|
|
2069
|
-
if(!l.length||l.indexOf('全ての曜日・時間帯')>=0) return {days:[], hours:null};
|
|
2070
|
-
const add=d=>{ if(days.indexOf(d)<0) days.push(d); };
|
|
2071
|
-
l.forEach(x=>{
|
|
2072
|
-
if(x==='平日'){ ['MONDAY','TUESDAY','WEDNESDAY','THURSDAY','FRIDAY'].forEach(add); }
|
|
2073
|
-
else if(x==='土日祝'){ add('SATURDAY'); add('SUNDAY'); }
|
|
2074
|
-
else if(TG_ENUM.day[x]){ add(TG_ENUM.day[x]); }
|
|
2075
|
-
else { const m=x.match(/\((\d+)-(\d+)時\)/); if(m&&!hours) hours={start:+m[1], end:+m[2]}; }
|
|
2076
|
-
});
|
|
2077
|
-
return {days, hours};
|
|
2078
|
-
}
|
|
2079
|
-
const toStatus=s=>s==='オン'?'ACTIVE':'PAUSED';
|
|
2080
3301
|
|
|
2081
3302
|
/* A-4: plan本体 + 除外/警告集計を返す。plan本体だけ必要な既存呼び出し(E2E含む)は buildSubmitPlan() を使う。
|
|
2082
|
-
|
|
2083
|
-
|
|
3303
|
+
S8: 媒体依存の3階層ボディ生成は pf().buildPlan.{campaign,adGroup,ad} に委譲し、
|
|
3304
|
+
ここには走査・除外・stats集計の骨格だけを残す (媒体語彙ゼロ)。
|
|
3305
|
+
除外: 未解決(errs)広告・空広告グループ(ads:[])・空キャンペーン(adGroups:[])。
|
|
3306
|
+
警告: pf().notSubmittedFields に値があるがJSON未反映の広告グループ数 (tgDropped)。 */
|
|
2084
3307
|
function buildSubmitPlanWithStats(){
|
|
2085
|
-
const p=st.project;
|
|
3308
|
+
const P=pf(), p=st.project;
|
|
2086
3309
|
const stats={exported:0, unresolved:0, pendingImages:0, pendingImageNos:[], emptyGroups:0, emptyCampaigns:0, tgDropped:0};
|
|
3310
|
+
/* 行レベル検証を持つ媒体 (Meta の 日予算/入札上限 必須) だけ rowErrors を集計する */
|
|
3311
|
+
if(P.validateRow) stats.rowErrors=0;
|
|
3312
|
+
const ctx=Object.assign(planCtx(), {stats});
|
|
3313
|
+
const CK=P.levels.campaign.planKey, GK=P.levels.adGroup.planKey;
|
|
3314
|
+
const notSub=P.notSubmittedFields||[];
|
|
2087
3315
|
const campaigns=st.campaigns.map(c=>{
|
|
2088
|
-
const
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
const
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
finalUrl:x.r.finalUrl // 実データ準拠 (?ad=paramCode&creative=...)
|
|
2105
|
-
};
|
|
2106
|
-
if(x.r.pendingImage){
|
|
2107
|
-
// 未登録画像: mediaId未確定。管理番号を残し CLI が先にアップロードして解決する (imagePaths は agent/CLI が補完)
|
|
2108
|
-
ad.mediaIds=[]; ad.imageNo=x.r.imageNo; ad.pendingImageNo=x.r.imageNo; ad.imagePaths=[];
|
|
2109
|
-
stats.pendingImages++; if(stats.pendingImageNos.indexOf(x.r.imageNo)<0) stats.pendingImageNos.push(x.r.imageNo);
|
|
2110
|
-
} else {
|
|
2111
|
-
ad.mediaIds=[String(x.r.mediaId)];
|
|
2112
|
-
}
|
|
2113
|
-
return ad;
|
|
3316
|
+
const cp=P.buildPlan.campaign(c, ctx);
|
|
3317
|
+
cp[CK]=c.adGroups.map(g=>{
|
|
3318
|
+
/* 行レベル検証NG (Meta: 日予算未入力 / COST_CAP で入札上限0) は行ごと除外して計上 */
|
|
3319
|
+
if(P.validateRow){
|
|
3320
|
+
const v=P.validateRow(g, 'adGroup', ctx)||{};
|
|
3321
|
+
if((v.errCodes||[]).length){ stats.rowErrors++; return null; }
|
|
3322
|
+
}
|
|
3323
|
+
const ag=P.buildPlan.adGroup(g, c, ctx);
|
|
3324
|
+
const kept=[];
|
|
3325
|
+
g.ads.forEach(a=>{
|
|
3326
|
+
const r=resolveAd(a,g);
|
|
3327
|
+
// Part A: 未登録画像(pendingImage)は除外せず「新規アップロード対象」として保持。真エラー(errs)のみ除外。
|
|
3328
|
+
if(r.errs.length){ stats.unresolved++; return; }
|
|
3329
|
+
kept.push(P.buildPlan.ad(a, r, g, ctx));
|
|
3330
|
+
if(r.pendingImage){ stats.pendingImages++;
|
|
3331
|
+
if(stats.pendingImageNos.indexOf(r.imageNo)<0) stats.pendingImageNos.push(r.imageNo); }
|
|
2114
3332
|
});
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
name:g.name,
|
|
2120
|
-
status:agStatus,
|
|
2121
|
-
smartTargeting:false, // GUI未対応 (将来ターゲティング列に追加)
|
|
2122
|
-
devices:mapDevices(g.tg_device),
|
|
2123
|
-
ages:tgList(g.tg_age).map(x=>TG_ENUM.age[x]).filter(Boolean),
|
|
2124
|
-
genders:tgList(g.tg_gender).map(x=>TG_ENUM.gender[x]).filter(Boolean),
|
|
2125
|
-
days:dh.days,
|
|
2126
|
-
placementsInclude:[], placementsExclude:[],
|
|
2127
|
-
audiencesInclude:[], audiencesExclude:[],
|
|
2128
|
-
ads:keptAds
|
|
2129
|
-
};
|
|
2130
|
-
if(dh.hours){ ag.startHour=dh.hours.start; ag.endHour=dh.hours.end; }
|
|
2131
|
-
const aid=resolveAdGroupIdOf(g); if(aid) ag.adGroupId=aid; // Phase C: 既存はIDを出力(backendで作成スキップ)。新規はID無し=作成対象
|
|
3333
|
+
ag[GK]=kept;
|
|
3334
|
+
stats.exported+=kept.length;
|
|
3335
|
+
// 未反映列 (YDAは地域/オーディエンス/サーチKW) はAPIのID解決が必要 → 出力ありのグループのみ警告計上
|
|
3336
|
+
if(kept.length && notSub.some(f=>String(g[f]||'').trim()!=='')) stats.tgDropped++;
|
|
2132
3337
|
return ag;
|
|
2133
|
-
}).filter(ag=>{ if(ag.
|
|
2134
|
-
const cp={
|
|
2135
|
-
name:c.name,
|
|
2136
|
-
goal:p.goal||'CONVERSION',
|
|
2137
|
-
status:toStatus(p.deliveryStatus), // E-2: キャンペーンstatus列は撤去 → 案件設定の配信値を使用
|
|
2138
|
-
startDate:p.startDate||today(),
|
|
2139
|
-
budget:Number(c.budget)||0, // A-1: キャンペーン個別値のみ
|
|
2140
|
-
biddingStrategy,
|
|
2141
|
-
adGroups
|
|
2142
|
-
};
|
|
2143
|
-
if(bidLabel==='tCPA'){ cp.targetCpa=Number(c.tcpa)||0; }
|
|
2144
|
-
else if(biddingStrategy==='CPC'){ cp.cpc=Number(c.adGroups[0]&&c.adGroups[0].bid)||0; }
|
|
2145
|
-
const cid=resolveCampaignIdOf(c); if(cid) cp.campaignId=cid; // Phase C: 既存はIDを出力(backendで作成スキップ)。新規はID無し=作成対象
|
|
3338
|
+
}).filter(ag=>{ if(!ag) return false; if(ag[GK].length===0){ stats.emptyGroups++; return false; } return true; }); // B9: 空広告グループを除外 (null = 行検証NG)
|
|
2146
3339
|
return cp;
|
|
2147
|
-
}).filter(cp=>{ if(cp.
|
|
2148
|
-
const plan={planVersion:
|
|
3340
|
+
}).filter(cp=>{ if(cp[CK].length===0){ stats.emptyCampaigns++; return false; } return true; }); // B9: 空キャンペーンを除外
|
|
3341
|
+
const plan={planVersion:P.output.planVersion, platform:P.id, accountId:String(p.accountId||'').trim(), campaigns};
|
|
2149
3342
|
return {plan, stats};
|
|
2150
3343
|
}
|
|
2151
3344
|
function buildSubmitPlan(){ return buildSubmitPlanWithStats().plan; }
|
|
@@ -2171,57 +3364,7 @@ function showPromptModal(opts){
|
|
|
2171
3364
|
初回セットアップ(アカウント取込)・戦略壁打ちの
|
|
2172
3365
|
プロンプト例をコピーボタン付きで提示する。
|
|
2173
3366
|
============================================================ */
|
|
2174
|
-
/*
|
|
2175
|
-
const HELP_STRATEGY_PROMPT=[
|
|
2176
|
-
'YDA(Yahoo!ディスプレイ広告)の入稿プランを一緒に作りたいです。まず戦略を壁打ちさせてください。',
|
|
2177
|
-
'',
|
|
2178
|
-
'# 前提',
|
|
2179
|
-
'- アカウント: <アカウント名(日本語OK)>',
|
|
2180
|
-
'- 商材 / LP: <商材名・URL>',
|
|
2181
|
-
'- 目的: <コンバージョン獲得 / 認知 など>',
|
|
2182
|
-
'- 予算・期間: <月予算 / 開始日>',
|
|
2183
|
-
'- 訴求したいポイント: <強み・オファー・ターゲット層>',
|
|
2184
|
-
'',
|
|
2185
|
-
'# お願い',
|
|
2186
|
-
'1. 上記をもとにターゲティング方針・キャンペーン/広告グループ構成・訴求軸を提案してください。',
|
|
2187
|
-
'2. 私が「OK」と言ったら、その戦略を ad-ops Skill の「サンプルプラン.json」に書き出して(上書きして)ください。',
|
|
2188
|
-
' - 保存先: .claude/skills/ad-ops/サンプルプラン.json',
|
|
2189
|
-
' - フォーマット: {"schemaVersion":3, "accountId":"<対象アカウントID>", "campaigns":[ ... ]}',
|
|
2190
|
-
' - campaigns には キャンペーン → 広告グループ(paramCode) → 広告 の構成を入れてください。',
|
|
2191
|
-
'3. 書き出したら、プランナーを開き直す(またはリロードする)と サンプルプラン.json の内容が自動で表示されます。'
|
|
2192
|
-
].join('\n');
|
|
2193
|
-
/* 初回セットアップ: 対象アカウントの参照データ(画像・見出し・既存CP/AG)をCLIで取り込むプロンプト例 */
|
|
2194
|
-
const HELP_IMPORT_PROMPT=[
|
|
2195
|
-
'YDA入稿プランナー用に、アカウント「<アカウント名(日本語OK)>」の参照データJSONを生成してください。',
|
|
2196
|
-
'',
|
|
2197
|
-
'手順:',
|
|
2198
|
-
'1. webgram CLI で対象アカウントの情報・登録画像・既存キャンペーン/広告グループを取得:',
|
|
2199
|
-
' webgram list --platform yda --resource accounts',
|
|
2200
|
-
' webgram list --platform yda --resource media --account-id <ID>',
|
|
2201
|
-
' webgram list --platform yda --resource campaigns --account-id <ID>',
|
|
2202
|
-
'2. 取得結果を下記スキーマに整形して アカウント/<アカウント名>.json として書き出す:',
|
|
2203
|
-
' - media は 画像番号 → {card, sq}(mediaId)',
|
|
2204
|
-
' - groups は paramCode → {campaignId, adGroupId, groupName} の逆引き',
|
|
2205
|
-
' - campaigns/adGroups は campaignId/adGroupId をキーに',
|
|
2206
|
-
'',
|
|
2207
|
-
'スキーマ:',
|
|
2208
|
-
'{',
|
|
2209
|
-
' "accountJsonVersion": 1,',
|
|
2210
|
-
' "accountId": "<ID>",',
|
|
2211
|
-
' "name": "<アカウント名(日本語OK)>",',
|
|
2212
|
-
' "principal": "<主体者表記(例: ○○製薬)>",',
|
|
2213
|
-
' "refs": {',
|
|
2214
|
-
' "titles": {"1": "見出し文言"},',
|
|
2215
|
-
' "descriptions": {"1": "説明文文言"},',
|
|
2216
|
-
' "media": {"954": {"card": "<mediaId>", "sq": "<mediaId>"}},',
|
|
2217
|
-
' "campaigns": {"<campaignId>": {"name": "CP名"}},',
|
|
2218
|
-
' "adGroups": {"<adGroupId>": {"name": "AG名", "campaignId": "<campaignId>", "paramCode": "<code>"}},',
|
|
2219
|
-
' "groups": {"<paramCode>": {"campaignId": "<campaignId>", "adGroupId": "<adGroupId>", "groupName": "AG名"}}',
|
|
2220
|
-
' }',
|
|
2221
|
-
'}',
|
|
2222
|
-
'',
|
|
2223
|
-
'書き出したら、プランナーで「+新規追加 →📄 ファイルから追加」or「📁 フォルダ」再接続で一覧に出ます。'
|
|
2224
|
-
].join('\n');
|
|
3367
|
+
/* S7: 使い方モーダルのプロンプト例は pf().prompts (媒体スキーマ) に集約 */
|
|
2225
3368
|
function openHelpModal(){
|
|
2226
3369
|
const root=document.getElementById('modalRoot');
|
|
2227
3370
|
const close=()=>root.innerHTML='';
|
|
@@ -2236,7 +3379,7 @@ function openHelpModal(){
|
|
|
2236
3379
|
['右上から保存','編集が終わったら、<b>右上の「バージョン保存」</b>を押してください。常に新バージョン(v1, v2…)として保存されます。'],
|
|
2237
3380
|
['表示されたプロンプトをAIに貼る','保存すると<b>次のアクション用のプロンプトが表示</b>されます。そのプロンプトをコピーしてAI(Claude Code)に貼ると、入稿(dry-run→本実行)まで進められます。']
|
|
2238
3381
|
];
|
|
2239
|
-
root.innerHTML='<div class="overlay"><div class="modal help"><h3>
|
|
3382
|
+
root.innerHTML='<div class="overlay"><div class="modal help"><h3>'+esc(pf().brandTitle)+'の使い方</h3>'
|
|
2240
3383
|
+'<div class="msub">戦略を壁打ち → プランナーで確認・編集 → 保存 → 表示されたプロンプトで入稿、という流れです。</div>'
|
|
2241
3384
|
+'<div class="mbody">'
|
|
2242
3385
|
+'<div class="callout"><b>全体像</b>:AIで作れる部分はAIに作らせ(戦略壁打ち→JSON)、人が確定すべきクリエイティブ選定・確認をこのプランナーで楽に行います。最後に表示されるプロンプトでそのまま入稿できます。</div>'
|
|
@@ -2245,11 +3388,11 @@ function openHelpModal(){
|
|
|
2245
3388
|
+'</ol>'
|
|
2246
3389
|
+'<div class="mfield"><label>🚀 まず最初に:戦略壁打ちプロンプト例</label>'
|
|
2247
3390
|
+'<div class="hint" style="margin-bottom:6px">これをコピーしてClaude Codeに貼り、<>の部分を埋めて壁打ちを始めてください。</div>'
|
|
2248
|
-
+promptCard('helpPromptStrategy','戦略壁打ち用プロンプト',
|
|
3391
|
+
+promptCard('helpPromptStrategy','戦略壁打ち用プロンプト',pf().prompts.strategy())
|
|
2249
3392
|
+'</div>'
|
|
2250
3393
|
+'<div class="mfield"><label>🔧 初回セットアップ:アカウント取込プロンプト例</label>'
|
|
2251
3394
|
+'<div class="hint" style="margin-bottom:6px">初めて使うアカウントは、まずこのプロンプトで登録画像・見出し・既存キャンペーン等を取り込みます(アカウント名は日本語でOK)。</div>'
|
|
2252
|
-
+promptCard('helpPromptImport','アカウント取込プロンプト',
|
|
3395
|
+
+promptCard('helpPromptImport','アカウント取込プロンプト',pf().prompts.importAccount({mode:'help'}))
|
|
2253
3396
|
+'</div>'
|
|
2254
3397
|
+'</div><div class="mfoot"><button class="act primary" data-mo="cancel">閉じて始める</button></div></div></div>';
|
|
2255
3398
|
root.querySelector('[data-mo="cancel"]').addEventListener('click',close);
|
|
@@ -2267,11 +3410,11 @@ function openHelpModal(){
|
|
|
2267
3410
|
async function updateSamplePlan(){
|
|
2268
3411
|
const data={schemaVersion:SCHEMA_VERSION, project:st.project, campaigns:st.campaigns};
|
|
2269
3412
|
const json=JSON.stringify(data,null,1);
|
|
2270
|
-
if(!samplePlanFileHandle){ try{ const h=await idbGet(
|
|
3413
|
+
if(!samplePlanFileHandle){ try{ const h=await idbGet(idbKeySamplePlan()); if(h){ const perm=await h.queryPermission({mode:'readwrite'}); if(perm==='granted') samplePlanFileHandle=h; else samplePlanFileHandle=h; } }catch(e){} }
|
|
2271
3414
|
if(samplePlanFileHandle){ try{ let perm=await samplePlanFileHandle.queryPermission({mode:'readwrite'}); if(perm!=='granted') perm=await samplePlanFileHandle.requestPermission({mode:'readwrite'}); if(perm!=='granted') samplePlanFileHandle=null; }catch(e){ samplePlanFileHandle=null; } }
|
|
2272
3415
|
if(!samplePlanFileHandle){
|
|
2273
3416
|
if(!fsOpenSupported){ download('サンプルプラン.json',json,'application/json'); toast('サンプルプラン.json をダウンロードしました(同名で差し替えてください)'); return; }
|
|
2274
|
-
try{ const [h]=await window.showOpenFilePicker({id:'
|
|
3417
|
+
try{ const [h]=await window.showOpenFilePicker({id:'ad-planner-sample-plan-'+curPf, types:[{description:'JSON', accept:{'application/json':['.json']}}]}); samplePlanFileHandle=h; await idbSet(idbKeySamplePlan(),h); }
|
|
2275
3418
|
catch(e){ if(e && e.name==='AbortError') return; toast('サンプルプラン.json を選べませんでした'); return; }
|
|
2276
3419
|
}
|
|
2277
3420
|
const ok=await writeToHandle(samplePlanFileHandle, json);
|
|
@@ -2279,6 +3422,7 @@ async function updateSamplePlan(){
|
|
|
2279
3422
|
else toast('サンプルプランの更新に失敗しました');
|
|
2280
3423
|
}
|
|
2281
3424
|
async function exportSubmitJson(){
|
|
3425
|
+
const P=pf();
|
|
2282
3426
|
const {plan,stats}=buildSubmitPlanWithStats();
|
|
2283
3427
|
if(!plan.accountId){
|
|
2284
3428
|
toast('アカウントを選択してください(上部のアカウントセレクト)');
|
|
@@ -2286,16 +3430,26 @@ async function exportSubmitJson(){
|
|
|
2286
3430
|
const el=document.getElementById('accSelect'); if(el) el.focus();
|
|
2287
3431
|
return;
|
|
2288
3432
|
}
|
|
3433
|
+
/* S10: 媒体スキーマの accountId 書式ガード (Meta は act_ 付き必須) */
|
|
3434
|
+
const idPat=P.account&&P.account.idPattern;
|
|
3435
|
+
if(idPat && !idPat.test(String(plan.accountId))){
|
|
3436
|
+
toast('アカウントIDの形式が不正です('+P.account.idHint+')'); return;
|
|
3437
|
+
}
|
|
3438
|
+
/* S10: 媒体固有のプロジェクト単位ガード (Meta の pageId / pixelId 必須) */
|
|
3439
|
+
if(P.validateProject){
|
|
3440
|
+
const perrs=P.validateProject(st.project)||[];
|
|
3441
|
+
if(perrs.length){ toast(perrs[0].msg); return; }
|
|
3442
|
+
}
|
|
2289
3443
|
const n=stats.exported;
|
|
2290
3444
|
if(n===0){ toast('入稿対象の広告が0件です(空入稿を防止しました)'); return; }
|
|
2291
3445
|
const json=JSON.stringify(plan,null,2);
|
|
2292
3446
|
// 完成したプランは「新規ファイル」として保存する(上書きしない・毎回タイムスタンプ付き)。
|
|
2293
3447
|
// 接続済み入稿計画フォルダがあればそこへ、無ければダウンロード。Finderは出さない。
|
|
2294
|
-
const fileName=(st.project
|
|
3448
|
+
const fileName=pf().output.fileNamePrefix(st.project)+tsCompact()+'.json';
|
|
2295
3449
|
const wrote=await writePlanFile(fileName,json);
|
|
2296
3450
|
if(!wrote) download(fileName,json,'application/json');
|
|
2297
3451
|
_dirty=false; // 保存(入稿計画書き出し)で未保存フラグをクリア
|
|
2298
|
-
const savedRel = '
|
|
3452
|
+
const savedRel = pf().output.planDir+'/'+fileName; // Skill同梱の入稿計画フォルダ内の想定パス
|
|
2299
3453
|
const savedPath=(wrote&&dirHandle?dirHandle.name+'/':'')+fileName;
|
|
2300
3454
|
const savedLabel = wrote?'入稿計画フォルダに新規保存':'ダウンロード(フォルダ未接続)';
|
|
2301
3455
|
// 除外内訳
|
|
@@ -2307,25 +3461,11 @@ async function exportSubmitJson(){
|
|
|
2307
3461
|
info+='<div class="mfield"><label>保存先 / ファイル名</label><div class="mono" style="font-size:12px; word-break:break-all">'+esc(savedPath||fileName)+'</div></div>';
|
|
2308
3462
|
info+='<div class="mfield"><label>除外件数の内訳</label><div style="font-size:12.5px">'+(exc.length?esc(exc.join(' / ')):'なし')+'</div></div>';
|
|
2309
3463
|
if(stats.pendingImages) info+='<div class="callout amber">🆕 未登録画像 <b>'+stats.pendingImages+'</b> 件(管理番号: '+esc(stats.pendingImageNos.join(', '))+')。これらはアカウントのメディアライブラリに未登録のため、入稿前にローカル画像をアップロードして新規登録が必要です(下記プロンプト参照)。</div>';
|
|
2310
|
-
if(stats.tgDropped) info+='<div class="callout amber">⚠
|
|
3464
|
+
if(stats.tgDropped) info+='<div class="callout amber">⚠ '+esc(pf().labels.tgDroppedWarn)+' を設定した広告グループが '+stats.tgDropped+' 件ありますが、これらはAPIのID解決が必要なため入稿JSONには反映されていません(入稿後に管理画面で設定してください)。</div>';
|
|
2311
3465
|
// Claudeに貼るプロンプト: 保存したJSONを Skillの入稿計画/ に入れて submit-plan で入稿を試みる
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
' ・入稿計画フォルダ(.claude/skills/ad-ops/入稿計画/)に無ければ、ダウンロードフォルダ(~/Downloads/'+fileName+')を探して上記パスへ移動する。',
|
|
2316
|
-
(wrote? ' ・プランナーからは既に入稿計画フォルダへ保存済みのはず。' : ' ・フォルダ未接続のためダウンロード済み。~/Downloads から移動してください。')
|
|
2317
|
-
];
|
|
2318
|
-
if(stats.pendingImages){
|
|
2319
|
-
promptLines.push('2) 【未登録画像の新規登録】入稿計画内に mediaIds が空で pendingImageNo が付いた広告があります(管理番号: '+stats.pendingImageNos.join(', ')+')。');
|
|
2320
|
-
promptLines.push(' これらは各管理番号に対応するローカル画像ファイルのパスを、その広告の "imagePaths":[] に設定してください(submit-plan 実行時に自動で /yda/upload-image → mediaId 解決されます)。');
|
|
2321
|
-
promptLines.push('3) 置いたパスで dry-run 入稿を実行:');
|
|
2322
|
-
} else {
|
|
2323
|
-
promptLines.push('2) 置いたパスで dry-run 入稿を実行:');
|
|
2324
|
-
}
|
|
2325
|
-
promptLines.push(' webgram submit-plan --plan-file "'+savedRel+'"');
|
|
2326
|
-
promptLines.push((stats.pendingImages?'4':'3')+') dry-run が全件OKなら本実行:');
|
|
2327
|
-
promptLines.push(' webgram submit-plan --plan-file "'+savedRel+'" --no-dry-run --yes');
|
|
2328
|
-
const prompt=promptLines.join('\n');
|
|
3466
|
+
// S7: 文言・コマンドは pf().prompts.submitHelp / pf().output.submitCommand (媒体スキーマ)
|
|
3467
|
+
const prompt=pf().prompts.submitHelp({fileName, savedRel, wrote,
|
|
3468
|
+
pendingImages:stats.pendingImages, pendingImageNos:stats.pendingImageNos});
|
|
2329
3469
|
showPromptModal({title:'入稿計画JSONを保存しました', sub:'このプロンプトをClaude Codeに貼って入稿してください', infoHtml:info, prompt, hint:'submit-plan は1キャンペーンずつ入稿・進捗書き戻しで再開可(submitted済みはスキップ)'});
|
|
2330
3470
|
}
|
|
2331
3471
|
|
|
@@ -2334,6 +3474,12 @@ async function exportSubmitJson(){
|
|
|
2334
3474
|
function applyLoadedJson(j){
|
|
2335
3475
|
if(!j||!j.campaigns) throw new Error('読み込み失敗: 不正なJSON');
|
|
2336
3476
|
if(j.schemaVersion!==SCHEMA_VERSION) throw new Error('スキーマ形式が異なるため読み込めません(planner v'+SCHEMA_VERSION+' のJSONのみ対応)');
|
|
3477
|
+
/* S9: platform 検証。meta.platform / platform 未記載は 'yda' (旧JSONの後方互換)。別媒体なら媒体ごと切り替える。 */
|
|
3478
|
+
const declared=String((j.meta&&j.meta.platform)||j.platform||'yda');
|
|
3479
|
+
if(declared!==curPf){
|
|
3480
|
+
if(!PLATFORMS[declared]) throw new Error('媒体が異なるため読み込めません(platform: '+declared+' は未対応)');
|
|
3481
|
+
window.switchPlatform(declared);
|
|
3482
|
+
}
|
|
2337
3483
|
snapshot();
|
|
2338
3484
|
st.project=Object.assign(defaultProject(),j.project);
|
|
2339
3485
|
st.campaigns=j.campaigns;
|
|
@@ -2348,7 +3494,7 @@ function loadJson(file){ planFileHandle=null; const rd=new FileReader(); rd.onlo
|
|
|
2348
3494
|
async function openPlanFile(){
|
|
2349
3495
|
if(!fsOpenSupported){ document.getElementById('fileInput').click(); return; }
|
|
2350
3496
|
try{
|
|
2351
|
-
const [h]=await window.showOpenFilePicker({id:'
|
|
3497
|
+
const [h]=await window.showOpenFilePicker({id:'ad-planner-plans', types:[{description:'JSON', accept:{'application/json':['.json']}}]});
|
|
2352
3498
|
const f=await h.getFile(); applyLoadedJson(JSON.parse(await f.text())); planFileHandle=h; updateSubmitJsonBtn();
|
|
2353
3499
|
toast('JSONを読み込みました(このファイルを「入稿JSON書き出し」で上書き更新します)');
|
|
2354
3500
|
}catch(e){ if(e && e.name!=='AbortError') toast(e instanceof SyntaxError ? '読み込み失敗: 不正なJSONです' : ('読み込み失敗: '+(e&&e.message||''))); }
|
|
@@ -2361,8 +3507,22 @@ document.getElementById('viewSeg').addEventListener('click',e=>{ const b=e.targe
|
|
|
2361
3507
|
document.getElementById('sheetFilter').addEventListener('change',()=>{ clearSel(); renderSheet(); });
|
|
2362
3508
|
/* プランビュー: シート / ツリー モード切替 (1階層下) */
|
|
2363
3509
|
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(); });
|
|
2364
|
-
/*
|
|
2365
|
-
document.getElementById('
|
|
3510
|
+
/* S10: ヘッダーの媒体セレクタ (常設)。登録済み媒体なら媒体を切り替える */
|
|
3511
|
+
document.getElementById('pfSeg').addEventListener('click',e=>{
|
|
3512
|
+
const b=e.target.closest('button[data-pf]'); if(!b) return;
|
|
3513
|
+
const id=b.dataset.pf;
|
|
3514
|
+
if(PLATFORMS[id]){ if(id!==curPf) window.switchPlatform(id); else renderPlatformTabs(); }
|
|
3515
|
+
else toast('この媒体はまだ選択できません(媒体スキーマ未登録・媒体要件の閲覧のみ)');
|
|
3516
|
+
});
|
|
3517
|
+
/* 媒体セレクタ: 媒体要件ドキュメントのタブ切替 + 登録済み媒体なら媒体そのものを切り替える (S9) */
|
|
3518
|
+
document.getElementById('mediaTabs').addEventListener('click',e=>{
|
|
3519
|
+
const b=e.target.closest('button[data-mt]'); if(!b) return;
|
|
3520
|
+
document.querySelectorAll('#mediaTabs button').forEach(x=>x.classList.toggle('active',x===b));
|
|
3521
|
+
showMediaDoc(b.dataset.mt);
|
|
3522
|
+
const id=b.dataset.pf; if(!id) return;
|
|
3523
|
+
if(PLATFORMS[id]){ if(id!==curPf) window.switchPlatform(id); }
|
|
3524
|
+
else toast('この媒体はまだ選択できません(媒体スキーマ未登録・媒体要件の閲覧のみ)');
|
|
3525
|
+
});
|
|
2366
3526
|
/* YDA内: 概要 / レスポンシブ / バナー / パラメータ / 使い方 タブ切替 */
|
|
2367
3527
|
document.getElementById('ydaTabs').addEventListener('click',e=>{ const b=e.target.closest('button[data-yt]'); if(!b) return; document.querySelectorAll('#ydaTabs button').forEach(x=>x.classList.toggle('active',x===b)); ['overview','resp','banner','param','guide'].forEach(k=>{ document.getElementById('yt-'+k).style.display = b.dataset.yt===k?'':'none'; }); });
|
|
2368
3528
|
/* 案件テンプレ: チップ直接編集 */
|
|
@@ -2414,7 +3574,9 @@ sheetHostEl.addEventListener('mousedown',e=>{
|
|
|
2414
3574
|
const rn=e.target.closest('td.rownum'); if(rn){ const r=+rn.dataset.rownum; if(isNaN(r)) return; selA={r,c:0}; selB={r,c:tabCols().length-1}; dragging='row'; pendingDrawer=null; applySelection(); return; }
|
|
2415
3575
|
const cell=cellFromEvent(e); if(!cell) return; const r=+cell.dataset.r, c=+cell.dataset.c;
|
|
2416
3576
|
pendingDrawer=null; dragMoved=false;
|
|
2417
|
-
|
|
3577
|
+
// S3: 旧 [1,2,3] リテラルを撤去。列記述子の kind で「▼参照ドロワー列」「ターゲティング列」を判定する
|
|
3578
|
+
const col=colDefs()[c];
|
|
3579
|
+
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' && cell.dataset.gf) pendingDrawer={kind:'tg', gu:cell.dataset.gu, gf:cell.dataset.gf}; }
|
|
2418
3580
|
applySelection();
|
|
2419
3581
|
});
|
|
2420
3582
|
sheetHostEl.addEventListener('mouseover',e=>{
|
|
@@ -2464,32 +3626,23 @@ document.addEventListener('keydown',e=>{
|
|
|
2464
3626
|
}
|
|
2465
3627
|
});
|
|
2466
3628
|
|
|
3629
|
+
migrateLegacyLocalStorage(); // S9: 旧 yda_planner_* キー → ad_planner_* へ1回だけコピー
|
|
2467
3630
|
buildDatalists();
|
|
2468
3631
|
render();
|
|
3632
|
+
renderPlatformTabs(); // S9: 媒体セレクタ (未登録媒体は disabled 表示)
|
|
2469
3633
|
renderAccountBar(); // アカウントセレクト初期化 (同梱デフォルト)
|
|
2470
3634
|
restoreFolder(); // 入稿計画フォルダのhandleを復元 (権限失効時は再接続待ちに)
|
|
2471
3635
|
restoreAccountFolder(); // B-1: アカウントフォルダのhandleを復元 + セレクト再列挙
|
|
2472
3636
|
/* 離脱時: 未保存の編集があればブラウザの確認ダイアログを出す */
|
|
2473
3637
|
window.addEventListener('beforeunload', function(e){ if(_dirty){ e.preventDefault(); e.returnValue='未保存の変更があります。「完成 → 保存して入稿」で保存してから離れてください。'; return e.returnValue; } });
|
|
2474
|
-
(async()=>{ try{ const h=await idbGet(
|
|
3638
|
+
(async()=>{ try{ const h=await idbGet(idbKeySamplePlan()); if(h){ const perm=await h.queryPermission({mode:'readwrite'}); if(perm==='granted'||perm==='prompt') samplePlanFileHandle=h; } }catch(e){} })(); // サンプルプラン更新用ハンドル復元
|
|
2475
3639
|
/* 案A: 起動時に外部JSON(同梱デフォルトアカウント + サンプルプラン)を読み込む。localStorage無しの初回のみ作り直して再描画。 */
|
|
2476
3640
|
(async function bootstrapBundled(){
|
|
2477
3641
|
try{
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
const acc=await fetchJson('アカウント/'+encodeURIComponent(firstName)+'.json');
|
|
2483
|
-
if(acc){
|
|
2484
|
-
BUNDLED_ACCOUNT_NAME=firstName;
|
|
2485
|
-
if(acc.accountId) BUNDLED_ACCOUNT_ID=String(acc.accountId);
|
|
2486
|
-
if(acc.refs) bundledRefs=normalizeRefs(acc.refs);
|
|
2487
|
-
if(acc.principal) bundledPrincipal=acc.principal;
|
|
2488
|
-
if(acc.projectName) bundledProjectName=acc.projectName;
|
|
2489
|
-
if(acc.baseArticleUrl) bundledBaseUrl=acc.baseArticleUrl;
|
|
2490
|
-
}
|
|
2491
|
-
}
|
|
2492
|
-
const plan=await fetchJson('サンプルプラン.json'); if(plan) samplePlan=plan;
|
|
3642
|
+
/* S10: 登録済み全媒体の同梱データ (アカウント/<媒体>/index.json 先頭 + 媒体別サンプルプラン) を先読みする。
|
|
3643
|
+
媒体切替は同期処理にしたいので、切替時に fetch しない (applyBundle でキャッシュを差し替えるだけ)。 */
|
|
3644
|
+
await Promise.all(Object.keys(PLATFORMS).map(id=>loadBundle(id)));
|
|
3645
|
+
applyBundle(curPf);
|
|
2493
3646
|
await fetchAccountList();
|
|
2494
3647
|
}catch(e){}
|
|
2495
3648
|
// 開いたときは必ずサンプルプランを読み込む(localStorage廃止)
|