addashboard-cli 0.5.1 → 0.6.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.
@@ -297,13 +297,9 @@
297
297
  <body>
298
298
  <header class="bar">
299
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>
305
- <!-- F-3: アカウント切替はヘッダーのプルダウンに一本化 -->
306
- <span class="acchdr" title="アカウント切替(+新規追加/フォルダ接続/編集もここから)"><select class="chipin" id="accSelect" style="min-width:12ch"></select></span>
300
+ <!-- S11: 「案件 × 媒体」を1つで選ぶ統合プルダウン。option value = "<platform>::<アカウント名>"。
301
+ 旧 #pfSeg(媒体セレクタ)/ #mediaTabs(媒体要件タブ)は廃止し、媒体切替もここに一本化した (2026-08-05) -->
302
+ <span class="acchdr" title="案件×媒体を切り替える(+新規追加/編集もここから)"><select class="chipin" id="accSelect" style="min-width:18ch"></select></span>
307
303
  <div class="seg" id="viewSeg">
308
304
  <button data-v="req">媒体要件</button>
309
305
  <button data-v="sheet" class="active">プラン</button>
@@ -324,11 +320,8 @@
324
320
  <!-- ============ 媒体要件ビュー ============ -->
325
321
  <section class="view" id="view-req">
326
322
  <div class="doc">
327
- <!-- S9: 媒体セレクタ (要件ドキュメントのタブ兼 媒体切替)。data-pf = PLATFORMS のキー -->
328
- <div class="seg" id="mediaTabs" style="display:inline-flex; margin-bottom:16px">
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>
331
- </div>
323
+ <!-- S11: 旧・媒体要件タブ (#mediaTabs) は廃止。媒体はヘッダーの統合プルダウンで切り替え、
324
+ このビューは選択中の媒体のドキュメントだけを表示する (showMediaDoc(curPf)) -->
332
325
 
333
326
  <!-- ======== YDA タブ ======== -->
334
327
  <div id="mt-yda">
@@ -462,7 +455,7 @@ finalUrl = baseURL + 記事slug + "?ad=" + paramCode</div>
462
455
  <details class="sec" open><summary><span class="num">1</span><span class="stitle">画面の入力項目(どこで何を設定するか)</span><span class="chev"></span></summary>
463
456
  <div class="body">
464
457
  <table class="g"><thead><tr><th>場所</th><th>入力項目</th><th>入稿JSONへの反映</th></tr></thead><tbody>
465
- <tr><td><b>案件設定チップ</b></td><td>案件名 / <b>アカウントID</b> / <b>目的(goal)</b> / <b>開始日</b> / CTA / 配信 / タイプ / 入札既定(tCPA・目標CPA) / 計測 / 主体者 / 表示URL / 記事URL / 予算(新規・ASC)</td><td>accountId・goal・startDate・入札既定・予算既定・finalUrlベース</td></tr>
458
+ <tr><td><b>案件設定チップ</b></td><td>案件名 / <b>アカウントID</b> / <b>目的(goal)</b> / <b>配信開始日</b> / CTA / 配信 / タイプ / 入札既定(tCPA・目標CPA) / 計測 / 主体者 / 表示URL / 記事URL / 予算(新規・ASC)</td><td>accountId・goal・startDate・入札既定・予算既定・finalUrlベース</td></tr>
466
459
  <tr><td><b>キャンペーン行</b></td><td>キャンペーン名 / 入稿モード / 1日の予算 / 入札戦略 / 目標CV単価(tCPA) / 配信設定 / 検証変数 / 入稿リンク番号 / 戦略メモ</td><td>name・budget・biddingStrategy+targetCpa・status</td></tr>
467
460
  <tr><td><b>広告グループ行</b></td><td>広告グループ名 / paramCode / 入札価格 / 配信設定 / <b>ターゲティング列</b>(デバイス・性別・年齢・地域・曜日時間帯・オーディエンス・サーチKW)</td><td>name・status・devices/ages/genders/days(+startHour/endHour)。地域・オーディエンス・サーチKWは未反映</td></tr>
468
461
  <tr><td><b>広告行</b></td><td>フォーマット(Card/SQ) / 画像番号 / タイトル番号 / 説明文番号 / 記事slug / 手入稿フラグ</td><td>ads[].adName・mediaIds・headlines・descriptions・finalUrl(手入稿・未解決は除外)</td></tr>
@@ -481,7 +474,7 @@ finalUrl = baseURL + 記事slug + "?ad=" + paramCode</div>
481
474
  <table class="g"><thead><tr><th>項目</th><th>画面上の出どころ</th><th>変換・制約</th></tr></thead><tbody>
482
475
  <tr><td class="mono">accountId</td><td><b>案件設定「アカウントID」</b></td><td>必須。未入力だと書き出し中止</td></tr>
483
476
  <tr><td class="mono">goal</td><td><b>案件設定「目的」</b></td><td><code>CONVERSION</code> / <code>CLICK</code> / <code>BRAND_AWARENESS</code> / <code>VIDEO_VIEW</code> / <code>VIEWABLE_IMPRESSION</code></td></tr>
484
- <tr><td class="mono">startDate</td><td><b>案件設定「開始日」</b></td><td>YYYY-MM-DD。空欄=書き出し日</td></tr>
477
+ <tr><td class="mono">startDate</td><td><b>案件設定「配信開始日」</b></td><td>YYYY-MM-DD。空欄=書き出し日</td></tr>
485
478
  <tr><td class="mono">status</td><td>キャンペーン行/広告グループ行の「配信設定」</td><td><code>オン→ACTIVE</code> / <code>オフ→PAUSED</code>(既定オフ=誤配信防止)</td></tr>
486
479
  <tr><td class="mono">budget</td><td>キャンペーン行「1日の予算」</td><td>未入力時はモード別既定(新規12,000/ASC50,000)</td></tr>
487
480
  <tr><td class="mono">biddingStrategy</td><td>キャンペーン行「入札戦略」+ 目標CV単価</td><td><code>tCPA → MAXIMIZE_CONVERSIONS + targetCpa</code>(tCPA値はキャンペーン行→案件既定8,500の順)/ <code>最大配信 → MAXIMIZE_CONVERSIONS</code>(目標なし <span class="pill amber">要実機確認</span>)</td></tr>
@@ -517,7 +510,7 @@ finalUrl = baseURL + 記事slug + "?ad=" + paramCode</div>
517
510
  <li><b>バージョン保存</b>:ヘッダーの「バージョン保存」。保存のたびに<b>常に新バージョン</b>(v1, v2…)として名称・メモ・作成者付きで保存される(上書きなし)。<b>履歴本体はブラウザ内に自動保存</b>されるため、フォルダ設定なしでも一覧・再読込できる。</li>
518
511
  <li><b>保存フォルダ接続(初回に1回だけ)</b>:履歴ビューの「📁 保存フォルダに接続」でスキル同梱の <code>入稿計画/</code> フォルダを一度選ぶと、<b>次回からは開くだけで自動接続</b>され、保存のたびにファイルにも書き出される。未接続時はダウンロード動作。</li>
519
512
  <li><b>履歴の確認・再利用</b>:履歴ビューで一覧(Ver/日時/媒体/名称)を確認し、「読込」で過去バージョンを復元、「編集」で名称・メモ変更、「削除」で破棄。</li>
520
- <li><b>入稿JSON書き出し</b>:ヘッダーの「入稿JSON書き出し」をワンクリック。アカウントID・目的・開始日は案件設定チップ、ターゲティングは広告グループ行の列から自動変換される(<code>*_入稿計画_*.json</code>)。</li>
513
+ <li><b>入稿JSON書き出し</b>:ヘッダーの「入稿JSON書き出し」をワンクリック。アカウントID・目的・配信開始日は案件設定チップ、ターゲティングは広告グループ行の列から自動変換される(<code>*_入稿計画_*.json</code>)。</li>
521
514
  <li><b>入稿(dry-run → 本実行)</b>:<code>webgram submit-plan --plan-file &lt;入稿計画&gt;</code>。dry-run が全キャンペーンを一括検証 → OKなら <code>--no-dry-run --yes</code> で本実行(1キャンペーンずつ入稿・進捗書き戻しで中断後も再開可能)。実行後は管理画面の一覧でOK確認。</li>
522
515
  </ol>
523
516
  </div></details>
@@ -714,6 +707,8 @@ const KEY_NS = 'ad_planner_';
714
707
  const LEGACY_KEY_NS = 'yda_planner_';
715
708
  const AUTHOR_KEY = KEY_NS + 'author';
716
709
  const MIGRATED_KEY = KEY_NS + 'migrated_v1';
710
+ /* ad-ops スキルが書き出す「いま作業中のプラン」の指定ファイル (媒体 + 案件名)。起動時の既定選択に使う */
711
+ const ACTIVE_PLAN_FILE = 'アクティブプラン.json';
717
712
  function stateKey(){ return KEY_NS + 'state_v1:' + curPf; }
718
713
  function histKey(){ return KEY_NS + 'history_v1:' + curPf; }
719
714
  function bodyKey(vid){ return KEY_NS + 'histbody_' + curPf + ':' + vid; }
@@ -770,18 +765,11 @@ let BUNDLED_ACCOUNT_NAME = '';
770
765
  var PLATFORMS = {};
771
766
  var curPf = 'yda';
772
767
  function pf(){ return PLATFORMS[curPf]; }
773
- /* ---- S9: 媒体セレクタ (#mediaTabs の data-pf ボタン) ----
774
- 未登録媒体 (PLATFORMS に定義が無い) は disabled 表示で、媒体要件ドキュメントの閲覧のみ許す。
768
+ /* ---- S11: 媒体切替はヘッダーの統合プルダウン (#accSelect) に一本化 ----
769
+ 媒体要件ビューは選択中の媒体のドキュメントだけを表示する。
775
770
  媒体を切り替えると state は当該媒体の既定値で作り直す (プランは媒体をまたげない)。 */
776
771
  function showMediaDoc(id){ ['yda','meta'].forEach(k=>{ const el=document.getElementById('mt-'+k); if(el) el.style.display = (k===id?'':'none'); }); }
777
772
  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
773
  showMediaDoc(curPf);
786
774
  renderChrome();
787
775
  }
@@ -817,6 +805,15 @@ window.switchPlatform=function(id){
817
805
  if(!PLATFORMS[id]){ toast('この媒体はまだ選択できません(媒体スキーマ未登録)'); return false; }
818
806
  if(id===curPf){ renderPlatformTabs(); return true; }
819
807
  curPf=id;
808
+ /* 接続中のアカウントフォルダは媒体別 (idbKeyAccountsDir() が 'accountsDir:'+curPf)。
809
+ 切替時に前媒体のハンドルを掴んだままにすると、前媒体のファイル一覧を新媒体の
810
+ アカウント候補として出し、autoSaveAccountJson が同名ファイル (例: 双方にある
811
+ sample_account) を **別媒体の内容で上書き** してしまう。必ずクリアする。
812
+ 新媒体のハンドル復元はここでは行わない (restoreAccountFolder() を切替のたびに
813
+ 呼ぶと IndexedDB アクセスで描画が詰まり E2E が軒並みタイムアウトした)。
814
+ 切替後にフォルダを使うときは、ユーザーが明示的に再接続する。
815
+ リロード時は boot 経路の restoreAccountFolder() が curPf のぶんを復元する。 */
816
+ accDirHandle=null; pendingAccDirHandle=null; accountFiles=[];
820
817
  applyBundle(id); /* S10: 同梱アカウント / サンプルプランは媒体別 */
821
818
  refTab=firstRefTabKey(); /* 参照データタブは媒体の宣言順で先頭に戻す */
822
819
  st=initialState();
@@ -975,7 +972,7 @@ PLATFORMS.yda = {
975
972
  {key:'goal', label:'目的', kind:'selectv', fallback:'CONVERSION',
976
973
  options:[{v:'CONVERSION',t:'コンバージョン'},{v:'CLICK',t:'クリック'},{v:'BRAND_AWARENESS',t:'ブランド認知'},
977
974
  {v:'VIDEO_VIEW',t:'動画再生'},{v:'VIEWABLE_IMPRESSION',t:'vインプ'}]},
978
- {key:'startDate', label:'開始日', kind:'text', width:'10ch', fallback:''},
975
+ {key:'startDate', label:'配信開始日', kind:'text', width:'10ch', fallback:''},
979
976
  {key:'cta', label:'CTA', kind:'text', width:'7ch'},
980
977
  {key:'deliveryStatus', label:'配信', kind:'select', options:['オフ','オン']},
981
978
  {key:'attributionDays', label:'計測', kind:'number', width:'3ch', suffix:'日'},
@@ -1124,8 +1121,12 @@ PLATFORMS.yda = {
1124
1121
  const imgNoStr=String(ad.imageNo||'').trim();
1125
1122
  const pendingImage = (imgNoStr!=='' && mediaId===undefined); // アカウントのメディアライブラリに未登録→先にアップロードして新規作成
1126
1123
  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);
1124
+ // 見出し/説明文は「番号空欄=意図的に使わない」を許容 (エラーにしない)。
1125
+ // 番号が入っているのに参照表で引けない場合のみ従来どおり未解決エラー。
1126
+ const titleNoStr=String(ad.titleNo||'').trim();
1127
+ const descNoStr=String(ad.descNo||'').trim();
1128
+ if(titleNoStr!=='' && titleText===undefined) pushErr('タイトル未解決', EC.TITLE_UNRESOLVED);
1129
+ if(descNoStr!=='' && descText===undefined) pushErr('説明文未解決', EC.DESC_UNRESOLVED);
1129
1130
  if(titleText!==undefined && glen(titleText)>35) pushErr('タイトル35字超', EC.TITLE_TOO_LONG);
1130
1131
  else if(titleText!==undefined && glen(titleText)>20) pushWarn('タイトル20字超', EC.TITLE_LONG_WARN);
1131
1132
  // B11: paramCode空はエラー(export対象外・URL生成に必須)。F-6: refs.groups 払出判定は廃止。
@@ -1183,8 +1184,9 @@ PLATFORMS.yda = {
1183
1184
  adName:r.adName,
1184
1185
  status:P.mappers.status(g.deliveryStatus||p.deliveryStatus, P),
1185
1186
  mediaType:'IMAGE', // レスポンシブ画像広告 (backend ydaAdSchema IMAGE)
1186
- headlines:[String(r.titleText)],
1187
- descriptions:[String(r.descText)],
1187
+ // 空欄許容: 空文字を要素に持つ配列を送らない (空なら [])
1188
+ headlines:String(r.titleText||'')===''?[]:[String(r.titleText)],
1189
+ descriptions:String(r.descText||'')===''?[]:[String(r.descText)],
1188
1190
  principal:p.principal||'', // E-1: 主体者はアカウントJSON由来 (st.project.principal)
1189
1191
  buttonText:P.mappers.cta(p.cta, P), // CTA(案件設定)→ YDA buttonText enum。入稿ADD必須
1190
1192
  finalUrl:r.finalUrl // 実データ準拠 (?ad=paramCode&creative=...)
@@ -1256,7 +1258,7 @@ PLATFORMS.yda = {
1256
1258
  strategy(){
1257
1259
  return ['YDA(Yahoo!ディスプレイ広告)の入稿プランを一緒に作りたいです。まず戦略を壁打ちさせてください。','',
1258
1260
  '# 前提','- アカウント: <アカウント名(日本語OK)>','- 商材 / LP: <商材名・URL>',
1259
- '- 目的: <コンバージョン獲得 / 認知 など>','- 予算・期間: <月予算 / 開始日>',
1261
+ '- 目的: <コンバージョン獲得 / 認知 など>','- 予算・期間: <月予算 / 配信開始日・配信終了日>',
1260
1262
  '- 訴求したいポイント: <強み・オファー・ターゲット層>','',
1261
1263
  '# お願い','1. 上記をもとにターゲティング方針・キャンペーン/広告グループ構成・訴求軸を提案してください。',
1262
1264
  '2. 私が「OK」と言ったら、その戦略を ad-ops Skill の「サンプルプラン.json」に書き出して(上書きして)ください。',
@@ -1420,6 +1422,7 @@ function snapshot(){ undoStack.push(JSON.stringify({project:st.project,campaigns
1420
1422
  let _dirty=false; // 未保存の編集があるか (離脱時警告用)
1421
1423
  function markDirty(){ _dirty=true; }
1422
1424
  function markSaved(){ _dirty=false; }
1425
+ window.isDirty=function(){ return _dirty; }; // 未保存フラグの読み取り口 (E2E から参照。_dirty は let のため window に載らない)
1423
1426
  function mutate(fn){ snapshot(); fn(); _dirty=true; persist(); render(); }
1424
1427
  function undo(){ const s=undoStack.pop(); if(!s){toast('これ以上戻せません'); return;} const j=JSON.parse(s); st.project=j.project; st.campaigns=j.campaigns; if(j.refs) st.refs=j.refs; _dirty=true; persist(); if(typeof buildDatalists==='function') buildDatalists(); render(); toast('元に戻しました'); }
1425
1428
 
@@ -1608,7 +1611,7 @@ function renderCell(col, item, ri, ci, ctx){
1608
1611
  /* text / mono / number */
1609
1612
  const cls='cell-in'+(col.kind==='mono'?' mono':'')+(col.kind==='number'?' num':'')+((col.required&&val==='')?' warnempty':'');
1610
1613
  return '<td><input class="'+cls+'"'+(col.kind==='number'?' type="number"':'')+' '+sel+' value="'+esc(val)+'"'+
1611
- (col.placeholder?(' placeholder="'+esc(col.placeholder())+'"'):'')+
1614
+ (col.placeholder?(' placeholder="'+esc(col.placeholder(ctx, row))+'"'):'')+
1612
1615
  (col.title?(' title="'+esc(col.title)+'"'):'')+sty+'></td>';
1613
1616
  }
1614
1617
 
@@ -1981,7 +1984,14 @@ const META_COLS = {
1981
1984
  {field:'ageMin', label:'年齢下限', kind:'number', width:'56px', coerce:v=>clampInt(v,13,65),
1982
1985
  readonlyOnExisting:true, roEmptyDash:true},
1983
1986
  {field:'ageMax', label:'年齢上限', kind:'number', width:'56px', coerce:v=>clampInt(v,13,65),
1984
- readonlyOnExisting:true, roEmptyDash:true},
1987
+ readonlyOnExisting:true, roEmptyDash:true,
1988
+ /* 空欄 = 上限なし (age_max を送らない)。特別広告カテゴリ時は 18-65 固定なので上限なしは無視される */
1989
+ placeholder:ctx=>{
1990
+ const K=PLATFORMS.meta.enums.specialAdCategoryConstraints;
1991
+ const c=ctx&&ctx.item&&ctx.item.c, cat=String((c&&c.specialAdCategory)||'NONE');
1992
+ return K.appliesTo.indexOf(cat)>=0 ? (String(K.forcedAgeMax)+'(固定)') : '上限なし';
1993
+ },
1994
+ title:'空欄にすると上限なし(age_max を送りません)。特別広告カテゴリ選択時は 65 固定となり上限なしは無視されます'},
1985
1995
  {field:'tg_gender', label:'性別', kind:'tgpick', tgKey:'tg_gender', readonlyOnExisting:true, roEmptyDash:true},
1986
1996
  {field:'tg_device', label:'デバイス', kind:'tgpick', tgKey:'tg_device', readonlyOnExisting:true, roEmptyDash:true},
1987
1997
  {field:'tg_placement', label:'配置', kind:'tgpick', tgKey:'tg_placement', readonlyOnExisting:true, roEmptyDash:true},
@@ -2071,7 +2081,8 @@ PLATFORMS.meta = {
2071
2081
  {key:'cta', label:'CTA', kind:'select',
2072
2082
  options:['詳しくはこちら','購入する','登録する','ダウンロード','お問い合わせ','今すぐ申し込む',
2073
2083
  '予約する','見積もりを取得','購読する','動画を見る','ボタンなし']},
2074
- {key:'startDate', label:'開始日', kind:'text', width:'10ch', fallback:''},
2084
+ {key:'startDate', label:'配信開始日', kind:'text', width:'10ch', fallback:''},
2085
+ {key:'endDate', label:'配信終了日', kind:'text', width:'10ch', fallback:''},
2075
2086
  {key:'deliveryStatus', label:'配信', kind:'select', options:['オフ','オン']},
2076
2087
  {key:'baseArticleUrl', label:'記事URL', kind:'text', width:'16ch', fallback:''}
2077
2088
  ],
@@ -2119,7 +2130,10 @@ PLATFORMS.meta = {
2119
2130
  status(s, P){ return s==='オン'?P.enums.statusOn:P.enums.statusOff; },
2120
2131
  cta(label, P){ return P.enums.cta[String(label||'').trim()] || P.enums.ctaFallback; },
2121
2132
  /* B13: JST 00:00 を明示した ISO8601 (日付のみだと UTC 解釈で前日にずれる) */
2122
- startTime(dateStr){ return (dateStr||today())+'T00:00:00+0900'; }
2133
+ startTime(dateStr){ return (dateStr||today())+'T00:00:00+0900'; },
2134
+ /* 終了日は「その日の終わりまで配信」の解釈。日付のみの入力を JST 23:59:59 に寄せる。
2135
+ 空欄なら null を返し、呼び出し側で endTime キー自体を出さない (無期限配信)。 */
2136
+ endTime(dateStr){ const s=String(dateStr||'').trim(); return s? s+'T23:59:59+0900' : null; }
2123
2137
  },
2124
2138
 
2125
2139
  defaults:{
@@ -2128,7 +2142,7 @@ PLATFORMS.meta = {
2128
2142
  cta:'詳しくはこちら', pageId:'', instagramUserId:'', pixelId:'', customEventType:'PURCHASE',
2129
2143
  optimizationGoal:'OFFSITE_CONVERSIONS', billingEvent:'IMPRESSIONS',
2130
2144
  bidStrategy:'LOWEST_COST_WITHOUT_CAP', buyingType:'AUCTION',
2131
- deliveryStatus:'オフ', startDate:''
2145
+ deliveryStatus:'オフ', startDate:'', endDate:''
2132
2146
  },
2133
2147
  ad:{format:'IMAGE', imageNo:'', titleNo:'', descNo:'', articleSlug:''},
2134
2148
  adGroup:{name:'', paramCode:'', isExisting:false, dailyBudget:1000, bidAmount:null,
@@ -2203,8 +2217,9 @@ PLATFORMS.meta = {
2203
2217
  const pendingImage = (noStr!=='' && (mediaId===undefined || mediaId===''));
2204
2218
  const T=P.refTables.titles.lengthLimit, D=P.refTables.descriptions.lengthLimit;
2205
2219
  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);
2220
+ /* YDA と同仕様: 番号未入力は許容 (見出し・本文なしの広告)。番号ありで参照表に無い場合のみ未解決エラー */
2221
+ if(String(ad.titleNo||'').trim()!=='' && titleText===undefined) pushErr('見出し未解決', EC.TITLE_UNRESOLVED);
2222
+ if(String(ad.descNo||'').trim()!=='' && descText===undefined) pushErr('本文未解決', EC.DESC_UNRESOLVED);
2208
2223
  if(titleText!==undefined && glen(titleText)>T.err) pushErr('見出し'+T.err+'字超', EC.TITLE_TOO_LONG);
2209
2224
  else if(titleText!==undefined && glen(titleText)>T.warn) pushWarn('見出し'+T.warn+'字超', EC.TITLE_LONG_WARN);
2210
2225
  if(descText!==undefined && glen(descText)>D.err) pushErr('本文'+D.err+'字超', EC.DESC_TOO_LONG);
@@ -2268,7 +2283,12 @@ PLATFORMS.meta = {
2268
2283
  startTime:P.mappers.startTime(p.startDate), /* JST 明示 ISO8601 */
2269
2284
  /* 特別カテゴリ時は 18-65 固定・性別指定不可 (公式制約) */
2270
2285
  ageMin: constrained?K.forcedAgeMin:(Number(g.ageMin)||18),
2271
- ageMax: constrained?K.forcedAgeMax:(Number(g.ageMax)||65),
2286
+ /* 年齢上限は空欄 = 上限なし → ageMax キー自体を出さない (age_max 省略 = Meta 側で上限なし)。
2287
+ ただし特別広告カテゴリ時は forcedAgeMax 固定が最優先 (ポリシー制約なので上限なしは無視する)。 */
2288
+ ...(constrained
2289
+ ? {ageMax:K.forcedAgeMax}
2290
+ : (String(g.ageMax==null?'':g.ageMax).trim()!=='' && Number(g.ageMax)>0
2291
+ ? {ageMax:Number(g.ageMax)} : {})),
2272
2292
  genders: constrained?[]:P.mappers.genders(g.tg_gender, P),
2273
2293
  devicePlatforms:P.mappers.devices(g.tg_device, P),
2274
2294
  placements:P.mappers.placements(g.tg_placement, P),
@@ -2277,6 +2297,8 @@ PLATFORMS.meta = {
2277
2297
  pixelId:String(p.pixelId||''), /* promoted_object 用 */
2278
2298
  customEventType:p.customEventType||'PURCHASE'
2279
2299
  };
2300
+ /* 終了日は任意。空欄なら endTime キー自体を出さない (= 無期限配信) */
2301
+ const endT=P.mappers.endTime(p.endDate); if(endT) ag.endTime=endT;
2280
2302
  if(P.enums.bidAmountRequiredFor.indexOf(ag.bidStrategy)>=0 || Number(g.bidAmount)>0)
2281
2303
  ag.bidAmount=Number(g.bidAmount)||0;
2282
2304
  const aid=resolveAdGroupIdOf(g); if(aid) ag.adSetId=aid;
@@ -2358,7 +2380,7 @@ PLATFORMS.meta = {
2358
2380
  strategy(){
2359
2381
  return ['Meta広告(Facebook / Instagram)の入稿プランを一緒に作りたいです。まず戦略を壁打ちさせてください。','',
2360
2382
  '# 前提','- アカウント: <アカウント名(日本語OK)>','- 商材 / LP: <商材名・URL>',
2361
- '- 目的(objective): <OUTCOME_SALES / OUTCOME_LEADS など>','- 予算・期間: <広告セットの日予算 / 開始日>',
2383
+ '- 目的(objective): <OUTCOME_SALES / OUTCOME_LEADS など>','- 予算・期間: <広告セットの日予算 / 配信開始日・配信終了日>',
2362
2384
  '- 訴求したいポイント: <強み・オファー・ターゲット層>','',
2363
2385
  '# お願い',
2364
2386
  '1. 上記をもとにターゲティング方針(年齢・性別・配置)・キャンペーン/広告セット構成・訴求軸を提案してください。',
@@ -2995,12 +3017,7 @@ let httpAccountFiles=[]; // <account.dir>/index.json から取得した <名前>
2995
3017
  const LEGACY_ACCOUNT_DIR='アカウント';
2996
3018
  function accountDir(){ const P=pf(); return (P&&P.account&&P.account.dir)||LEGACY_ACCOUNT_DIR; }
2997
3019
  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
- }
3020
+ /* (旧 fetchAccountIndex は fetchAccountNamesFor に統合済み。呼び出し元が無くなったため削除) */
3004
3021
  async function fetchAccountJsonByName(name){
3005
3022
  const rel=encodeURIComponent(name)+'.json';
3006
3023
  const obj=await fetchJson(accountDir()+'/'+rel);
@@ -3008,16 +3025,34 @@ async function fetchAccountJsonByName(name){
3008
3025
  const legacy=legacyAccountDir();
3009
3026
  return legacy ? await fetchJson(legacy+'/'+rel) : null;
3010
3027
  }
3011
- async function fetchAccountList(){
3028
+ /* S11: 媒体別のアカウント名一覧。統合プルダウン (案件×媒体) はこれを全媒体ぶん結合して描画する。
3029
+ ACCOUNTS_BY_PF[<platform>] = ['<アカウント名>', ...] */
3030
+ const ACCOUNTS_BY_PF={};
3031
+ /* 1媒体ぶんのアカウント名一覧を取得 (index.json → 無ければディレクトリ一覧HTML)。 */
3032
+ async function fetchAccountNamesFor(pfId){
3033
+ const P=PLATFORMS[pfId]; if(!P) return [];
3034
+ const dir=(P.account&&P.account.dir)||LEGACY_ACCOUNT_DIR;
3035
+ const legacy=(pfId==='yda')?LEGACY_ACCOUNT_DIR:null;
3012
3036
  const set=new Set();
3013
3037
  // (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){}
3038
+ try{
3039
+ let idx=await fetchJson(dir+'/index.json');
3040
+ if(!idx && legacy) idx=await fetchJson(legacy+'/index.json');
3041
+ if(idx){ const arr=Array.isArray(idx)?idx:(idx.accounts||[]); arr.map(String).filter(Boolean).forEach(n=>set.add(n)); }
3042
+ }catch(e){}
3015
3043
  // (2) ディレクトリ一覧(HTML) — http配信時は index.json に無い新規ファイルも自動検出してマージ
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){}
3044
+ for(const d of [dir, legacy]){
3045
+ if(!d) continue;
3046
+ try{ const r=await fetch(d+'/',{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
3047
  }
3020
- httpAccountFiles=[...set].sort();
3048
+ return [...set].sort();
3049
+ }
3050
+ /* 全媒体のアカウント一覧を取得する。1媒体が読めなくても他媒体は出す (Promise ごとに握り潰す)。 */
3051
+ async function fetchAccountList(){
3052
+ await Promise.all(Object.keys(PLATFORMS).map(async id=>{
3053
+ try{ ACCOUNTS_BY_PF[id]=await fetchAccountNamesFor(id); }catch(e){ if(!ACCOUNTS_BY_PF[id]) ACCOUNTS_BY_PF[id]=[]; }
3054
+ }));
3055
+ httpAccountFiles=(ACCOUNTS_BY_PF[curPf]||[]).slice(); // 既存コード互換: 現在媒体の一覧
3021
3056
  }
3022
3057
  function accStatusText(){
3023
3058
  if(!fsSupported) return 'このブラウザはフォルダ連携非対応';
@@ -3025,13 +3060,42 @@ function accStatusText(){
3025
3060
  if(pendingAccDirHandle) return 'フォルダ再接続待ち(📁 フォルダ を押す)';
3026
3061
  return 'フォルダ接続してください(現在は同梱の'+BUNDLED_ACCOUNT_NAME+'を使用中)';
3027
3062
  }
3063
+ /* S11: 統合プルダウン用ヘルパ。option の value は "<platform>::<アカウント名>"。 */
3064
+ function accOptionValue(pfId, name){ return pfId+'::'+name; }
3065
+ function parseAccOptionValue(v){ const i=String(v).indexOf('::'); return i<0 ? {pf:curPf, name:String(v)} : {pf:String(v).slice(0,i), name:String(v).slice(i+2)}; }
3066
+ /* 媒体 pfId のアカウント名一覧。現在媒体でフォルダ接続中はフォルダ内ファイルを正とする。 */
3067
+ function accountNamesForPf(pfId){
3068
+ if(pfId===curPf && accDirHandle) return accountFiles.slice();
3069
+ const names=(ACCOUNTS_BY_PF[pfId]||[]).slice();
3070
+ const b=BUNDLES[pfId]||{};
3071
+ if(b.accountName && !names.includes(b.accountName)) names.unshift(b.accountName);
3072
+ return names;
3073
+ }
3028
3074
  function renderAccountBar(){
3029
3075
  const sel=document.getElementById('accSelect'); if(!sel) return;
3030
3076
  const cur=st.project.accountName||BUNDLED_ACCOUNT_NAME;
3031
- let names = accDirHandle ? accountFiles.slice() : (httpAccountFiles.length ? httpAccountFiles.slice() : [BUNDLED_ACCOUNT_NAME]);
3032
- if(!accDirHandle && !names.includes(BUNDLED_ACCOUNT_NAME)) names.unshift(BUNDLED_ACCOUNT_NAME);
3033
- if(!names.includes(cur)) names.unshift(cur);
3034
- let opts=names.map(n=>'<option value="'+esc(n)+'"'+(n===cur?' selected':'')+'>'+esc(n)+((n===BUNDLED_ACCOUNT_NAME&&!accDirHandle)?'(同梱)':'')+'</option>').join('');
3077
+ const curVal=accOptionValue(curPf, cur);
3078
+ // S11: 「案件 × 媒体」を全媒体ぶん列挙する (片方の媒体の一覧が読めなくても、もう片方は出る)
3079
+ let opts='';
3080
+ Object.keys(PLATFORMS).forEach(pid=>{
3081
+ const names=accountNamesForPf(pid);
3082
+ if(pid===curPf && cur && !names.includes(cur)) names.unshift(cur);
3083
+ const label=(PLATFORMS[pid]&&PLATFORMS[pid].displayName)||pid;
3084
+ /* S11-fix: アカウント一覧が1件も取れない媒体 (file:// / アカウントディレクトリ未配置 / http未起動 で
3085
+ fetch が全滅した状態) でも、媒体そのものには到達できるようにプレースホルダを1件必ず出す。
3086
+ 旧 #pfSeg / #mediaTabs が担っていた「媒体切替と媒体要件ドキュメントの閲覧」を統合プルダウンで維持する。
3087
+ value は "<platform>::"(アカウント名が空)で、selectAccount は媒体切替だけ行う。 */
3088
+ if(!names.length){
3089
+ const v=accOptionValue(pid,'');
3090
+ opts+='<option value="'+esc(v)+'"'+(pid===curPf?' selected':'')+'>'+esc(label)+'(アカウント未読込)</option>';
3091
+ return;
3092
+ }
3093
+ names.forEach(n=>{
3094
+ const v=accOptionValue(pid,n);
3095
+ const bundled=(BUNDLES[pid]&&BUNDLES[pid].accountName===n && !(pid===curPf&&accDirHandle));
3096
+ opts+='<option value="'+esc(v)+'"'+(v===curVal?' selected':'')+'>'+esc(n)+' '+esc(label)+(bundled?'(同梱)':'')+'</option>';
3097
+ });
3098
+ });
3035
3099
  // F-3: プルダウン内にアクション項目 (新規追加/このアカウントを編集)。フォルダ接続オプションは廃止
3036
3100
  opts+='<option disabled>──────────</option>';
3037
3101
  opts+='<option value="__add__">+ 新規追加…</option>';
@@ -3079,26 +3143,54 @@ window.applyAccountJson=function(obj){
3079
3143
  snapshot();
3080
3144
  st.refs=normalizeRefs(obj.refs);
3081
3145
  pf().account.apply(obj, st.project); // S7: project への写し込みは媒体スキーマ (accountId書式/追加項目)
3146
+ /* S11-fix2: switchPlatform 内の initialState() は「同梱デフォルトアカウント」を基準に
3147
+ サンプルプランの accountId を照合するため、別アカウントを選ぶと必ず不一致になり
3148
+ campaigns=[] のまま残る。ここでアカウントが確定したので、プランが空のときに限り
3149
+ サンプルプランを読み直す (既存プランがあるときは触らない)。 */
3150
+ if((!st.campaigns || !st.campaigns.length) && samplePlan && Array.isArray(samplePlan.campaigns)
3151
+ && samplePlan.campaigns.length && String(samplePlan.accountId||'')===String(st.project.accountId||'')){
3152
+ st.campaigns=JSON.parse(JSON.stringify(samplePlan.campaigns));
3153
+ st.campaigns.forEach(c=>{ c.uid=uid('c'); (c.adGroups||[]).forEach(g=>{ g.uid=uid('g'); (g.ads||[]).forEach(a=>{ a.uid=uid('a'); }); }); });
3154
+ planMismatchMsg='';
3155
+ if(typeof renderPlanWarn==='function') renderPlanWarn();
3156
+ }
3082
3157
  persist(); buildDatalists(); render(); renderAccountBar();
3083
3158
  };
3084
- async function selectAccount(name){
3159
+ /* S11: 統合プルダウンの選択 = 「案件 × 媒体」。
3160
+ 媒体が変わる場合は **先に** switchPlatform してから、その媒体のアカウントJSONを読む
3161
+ (逆順だと applyAccountJson の platform 検証・accountId 不一致の安全弁に弾かれる)。 */
3162
+ async function selectAccount(name, pfId){
3085
3163
  if(String(name).startsWith('__')){ renderAccountBar(); return; } // アクション項目は change リスナーで処理
3164
+ const target=pfId||curPf;
3165
+ const prevPf=curPf;
3166
+ if(target!==curPf){
3167
+ if(!PLATFORMS[target]){ toast('この媒体はまだ選択できません(媒体スキーマ未登録)'); renderAccountBar(); return; }
3168
+ if(!window.switchPlatform(target)){ renderAccountBar(); return; }
3169
+ }
3170
+ // S11-fix: 「<媒体>(アカウント未読込)」プレースホルダ = 媒体切替のみ (アカウントJSONは読まない)
3171
+ if(!name){ renderAccountBar(); return; }
3086
3172
  if(name===st.project.accountName){ renderAccountBar(); return; }
3087
- if(!accDirHandle){
3088
- if(httpAccountFiles.includes(name)){ // http一覧のアカウントを fetch で読み込み(読み取り専用切替)
3089
- const obj=await fetchAccountJsonByName(name);
3090
- if(obj){ try{ window.applyAccountJson(obj); toast('アカウント '+name+' に切替(参照データを差し替え)'); }catch(e){ toast(e.message); renderAccountBar(); } }
3091
- else { toast('アカウントJSONを読み込めません: '+name); renderAccountBar(); }
3092
- return;
3093
- }
3094
- if(name===BUNDLED_ACCOUNT_NAME){ window.applyAccountJson({accountJsonVersion:1, accountId:BUNDLED_ACCOUNT_ID, name:BUNDLED_ACCOUNT_NAME, principal:bundledPrincipal, refs:buildRefs()}); toast('同梱アカウント '+BUNDLED_ACCOUNT_NAME+' に切替'); }
3095
- else { toast('先にアカウントフォルダに接続してください'); renderAccountBar(); }
3173
+ /* (1) フォルダ接続中で、そのフォルダにあるアカウント。
3174
+ S11-fix: 接続フォルダは媒体別ではないので、媒体をまたぐ選択のときは参照しない
3175
+ (別媒体の同名JSONを掴んで applyAccountJson の platform 検証で媒体が巻き戻る) */
3176
+ if(accDirHandle && target===prevPf && accountFiles.includes(name)){
3177
+ const obj=await readAccountJson(name);
3178
+ if(!obj){ toast('アカウントJSONを読み込めません: '+name); renderAccountBar(); return; }
3179
+ try{ window.applyAccountJson(obj); toast('アカウント '+name+' に切替(参照データを差し替え)'); }
3180
+ catch(e){ toast(e.message); renderAccountBar(); }
3181
+ return;
3182
+ }
3183
+ // (2) index.json / ディレクトリ一覧に載っているアカウントを fetch で読み込み(読み取り専用切替)
3184
+ if((ACCOUNTS_BY_PF[curPf]||[]).includes(name)){
3185
+ const obj=await fetchAccountJsonByName(name);
3186
+ if(obj){ try{ window.applyAccountJson(obj); toast('アカウント '+name+' に切替(参照データを差し替え)'); }catch(e){ toast(e.message); renderAccountBar(); } }
3187
+ else { toast('アカウントJSONを読み込めません: '+name); renderAccountBar(); }
3096
3188
  return;
3097
3189
  }
3098
- const obj=await readAccountJson(name);
3099
- if(!obj){ toast('アカウントJSONを読み込めません: '+name); renderAccountBar(); return; }
3100
- try{ window.applyAccountJson(obj); toast('アカウント '+name+' に切替(参照データを差し替え)'); }
3101
- catch(e){ toast(e.message); renderAccountBar(); }
3190
+ // (3) 同梱デフォルトアカウント
3191
+ if(name===BUNDLED_ACCOUNT_NAME){ window.applyAccountJson({accountJsonVersion:ACC_JSON_VERSION, platform:curPf, accountId:BUNDLED_ACCOUNT_ID, name:BUNDLED_ACCOUNT_NAME, principal:bundledPrincipal, refs:buildRefs()}); toast('同梱アカウント '+BUNDLED_ACCOUNT_NAME+' に切替'); return; }
3192
+ if(!accDirHandle){ toast('先にアカウントフォルダに接続してください'); renderAccountBar(); return; }
3193
+ toast('アカウントJSONを読み込めません: '+name); renderAccountBar();
3102
3194
  }
3103
3195
  /* refAdd/refEdit/refDel 後にアクティブアカウントJSONへ自動保存 (フォルダ接続 & フォルダ内アカウント時) */
3104
3196
  async function saveActiveAccountJson(){
@@ -3507,22 +3599,7 @@ document.getElementById('viewSeg').addEventListener('click',e=>{ const b=e.targe
3507
3599
  document.getElementById('sheetFilter').addEventListener('change',()=>{ clearSel(); renderSheet(); });
3508
3600
  /* プランビュー: シート / ツリー モード切替 (1階層下) */
3509
3601
  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(); });
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
- });
3602
+ /* S11: 媒体セレクタ (#pfSeg) / 媒体要件タブ (#mediaTabs) は廃止。媒体切替はヘッダーの統合プルダウンに一本化 */
3526
3603
  /* YDA内: 概要 / レスポンシブ / バナー / パラメータ / 使い方 タブ切替 */
3527
3604
  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'; }); });
3528
3605
  /* 案件テンプレ: チップ直接編集 */
@@ -3538,7 +3615,8 @@ document.getElementById('accFileInput').addEventListener('change',e=>{ const f=e
3538
3615
  document.getElementById('accSelect').addEventListener('change',e=>{ const v=e.target.value;
3539
3616
  if(v==='__add__'){ renderAccountBar(); openAddAccountModal(); return; }
3540
3617
  if(v==='__edit__'){ renderAccountBar(); openEditAccountModal(); return; }
3541
- selectAccount(v);
3618
+ const t=parseAccOptionValue(v); // S11: "<platform>::<アカウント名>"
3619
+ selectAccount(t.name, t.pf);
3542
3620
  });
3543
3621
  /* E-1: キャンペーン追加導線はシート/ツリー内(data-act="add-campaign"/"add-campaign-existing")に配置。ツールバー独立ボタンは廃止 */
3544
3622
  document.getElementById('histHost').addEventListener('click',async e=>{
@@ -3649,6 +3727,17 @@ window.addEventListener('beforeunload', function(e){ if(_dirty){ e.preventDefaul
3649
3727
  if(bundledRefs || samplePlan){ st=initialState(); st.project.accountId=BUNDLED_ACCOUNT_ID; st.project.accountName=BUNDLED_ACCOUNT_NAME; if(typeof buildDatalists==='function') buildDatalists(); render(); _dirty=false; }
3650
3728
  else { toast('初期データ(アカウント/サンプルプラン)を読み込めません。httpサーバー経由で開くか、アカウントを接続してください'); }
3651
3729
  renderAccountBar();
3730
+ /* S11-fix3: 「いま作業中のプラン」を アクティブプラン.json (ad-ops スキルが書き出す) から適用する。
3731
+ これが無いと同梱デフォルト (sample_account) で開くため、毎回プルダウンで案件×媒体を選び直す
3732
+ ことになり、さらにサンプルプランの accountId 不一致でプランが読み込まれない。 */
3733
+ try{
3734
+ const r=await fetch(ACTIVE_PLAN_FILE, {cache:'no-store'});
3735
+ if(r.ok){
3736
+ const a=await r.json();
3737
+ const tp=String(a.platform||'yda'), tn=String(a.accountName||'');
3738
+ if(tn && PLATFORMS[tp] && !(tp===curPf && tn===st.project.accountName)) await selectAccount(tn, tp);
3739
+ }
3740
+ }catch(e){}
3652
3741
  })();
3653
3742
  </script>
3654
3743
  </body>
@@ -0,0 +1,106 @@
1
+ #!/usr/bin/env bash
2
+ # ad-ops スキルの単一エントリポイント。
3
+ #
4
+ # adops.sh preflight <platform> <accountId> フェーズ0: ログイン + アカウント疎通確認
5
+ # adops.sh planner [--stop] フェーズ2: プランナーをHTTPで開く (file:// 禁止)
6
+ # adops.sh plan [<入稿計画.json>] フェーズ3: DL回収 + 要約 + lint + 合意との差分
7
+ #
8
+ # 覚えるコマンドを1本に畳むためのラッパー。個別の python3 -c / curl / ls -t を書かない。
9
+ set -uo pipefail
10
+
11
+ SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
12
+ PORT=8799 # cli/playwright.config.ts の webServer と同じポートを共用する
13
+ URL="http://127.0.0.1:${PORT}/ad-planner.html"
14
+ CLI="npx -y addashboard-cli@latest"
15
+
16
+ die() { echo "✗ $*" >&2; exit 1; }
17
+
18
+ # ---------------------------------------------------------------- preflight
19
+ # フェーズ4の本実行で初めて 401 Account not found に気づく事故を防ぐ。
20
+ # 戦略壁打ちを始める前にこれを通し、落ちたら戦略を練らずに止める。
21
+ cmd_preflight() {
22
+ local platform="${1:-}" account="${2:-}"
23
+ [ -n "$platform" ] && [ -n "$account" ] \
24
+ || die "usage: adops.sh preflight <platform> <accountId>"
25
+
26
+ echo "▸ ログイン確認..."
27
+ local who
28
+ who="$($CLI auth whoami 2>&1)" || {
29
+ echo "$who" >&2
30
+ die "未ログイン、または認証が期限切れです。ターミナルで '$CLI auth login' を実行してください(Google SSO は本人が操作)"
31
+ }
32
+ python3 -c 'import json,sys; d=json.load(sys.stdin); print(" ✓", d.get("email"), "/ role:", d.get("role"))' <<<"$who" \
33
+ || die "whoami の応答を解釈できません: $who"
34
+
35
+ echo "▸ アカウント疎通確認 (${platform} / ${account})..."
36
+ local accounts
37
+ accounts="$($CLI list --platform "$platform" --resource accounts 2>&1)" \
38
+ || { echo "$accounts" >&2; die "アカウント一覧の取得に失敗しました"; }
39
+
40
+ ACCOUNT_ID="$account" python3 - <<'PY' <<<"$accounts"
41
+ import json, os, sys
42
+ want = os.environ["ACCOUNT_ID"]
43
+ try:
44
+ accounts = json.load(sys.stdin).get("accounts", [])
45
+ except Exception as e:
46
+ sys.exit(f"✗ アカウント一覧を解釈できません: {e}")
47
+ hit = next((a for a in accounts if str(a.get("id")) == want), None)
48
+ if hit:
49
+ print(f" ✓ {want} — {hit.get('name')} (アクセス可能: 全{len(accounts)}件中)")
50
+ sys.exit(0)
51
+ print(f" ✗ accountId {want} は接続中の認証情報のスコープ外です(このまま入稿すると 401 Account not found)")
52
+ print(f" アクセス可能な {len(accounts)} 件:")
53
+ for a in accounts:
54
+ print(f" {a.get('id')} {a.get('name')}")
55
+ sys.exit(1)
56
+ PY
57
+ }
58
+
59
+ # ------------------------------------------------------------------ planner
60
+ # file:// で開くと fetch('サンプルプラン.json') がブロックされ、
61
+ # エラーも出ないまま空のプランナーが表示される。必ずHTTPで開く。
62
+ cmd_planner() {
63
+ if [ "${1:-}" = "--stop" ]; then
64
+ pkill -f "http.server ${PORT}" && echo "✓ プランナーの配信を停止しました" \
65
+ || echo "(起動中のサーバーはありませんでした)"
66
+ return 0
67
+ fi
68
+
69
+ if curl -sf -o /dev/null "$URL"; then
70
+ echo "✓ 配信は起動済み(再利用)"
71
+ else
72
+ echo "▸ http.server を起動中 (port ${PORT})..."
73
+ # nohup でデタッチしないと、呼び出し元シェルの終了に巻き込まれて死ぬ
74
+ nohup python3 -m http.server "$PORT" --directory "$SKILL_DIR" \
75
+ >/tmp/adops-planner.log 2>&1 &
76
+ for _ in $(seq 1 20); do
77
+ curl -sf -o /dev/null "$URL" && break
78
+ sleep 0.3
79
+ done
80
+ curl -sf -o /dev/null "$URL" || die "配信を起動できませんでした(/tmp/adops-planner.log を確認)"
81
+ echo "✓ 起動しました"
82
+ fi
83
+
84
+ open "$URL"
85
+ echo " $URL"
86
+ echo " 停止: adops.sh planner --stop"
87
+ }
88
+
89
+ # --------------------------------------------------------------------- plan
90
+ cmd_plan() {
91
+ python3 "${SKILL_DIR}/scripts/plan_lint.py" "$@"
92
+ }
93
+
94
+ case "${1:-}" in
95
+ preflight) shift; cmd_preflight "$@" ;;
96
+ planner) shift; cmd_planner "$@" ;;
97
+ plan) shift; cmd_plan "$@" ;;
98
+ *)
99
+ cat >&2 <<EOF
100
+ usage:
101
+ adops.sh preflight <platform> <accountId> フェーズ0: ログイン + アカウント疎通確認
102
+ adops.sh planner [--stop] フェーズ2: プランナーをHTTPで開く
103
+ adops.sh plan [<入稿計画.json>] フェーズ3: DL回収 + 要約 + lint + 合意との差分
104
+ EOF
105
+ exit 2 ;;
106
+ esac