addashboard-cli 0.9.1 → 0.9.3

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.
Files changed (37) hide show
  1. package/README.md +3 -0
  2. package/dist/commands/ads.js +4 -7
  3. package/dist/commands/ads.js.map +1 -1
  4. package/dist/commands/authDevice.js +10 -2
  5. package/dist/commands/authDevice.js.map +1 -1
  6. package/dist/commands/authGoogle.js +39 -42
  7. package/dist/commands/authGoogle.js.map +1 -1
  8. package/dist/commands/freee.js +2 -18
  9. package/dist/commands/freee.js.map +1 -1
  10. package/dist/commands/pl.js +4 -8
  11. package/dist/commands/pl.js.map +1 -1
  12. package/dist/commands/review.js +4 -8
  13. package/dist/commands/review.js.map +1 -1
  14. package/dist/commands/status.js +188 -0
  15. package/dist/commands/status.js.map +1 -0
  16. package/dist/commands/submission.js +236 -21
  17. package/dist/commands/submission.js.map +1 -1
  18. package/dist/index.js +86 -31
  19. package/dist/index.js.map +1 -1
  20. package/dist/lib/apiError.js +63 -0
  21. package/dist/lib/apiError.js.map +1 -0
  22. package/dist/lib/authFlow.js +82 -0
  23. package/dist/lib/authFlow.js.map +1 -0
  24. package/dist/lib/googleImageMetadata.js +145 -0
  25. package/dist/lib/googleImageMetadata.js.map +1 -0
  26. package/dist/lib/openBrowser.js +63 -0
  27. package/dist/lib/openBrowser.js.map +1 -0
  28. package/dist/schemas/submit-plan.js +27 -1
  29. package/dist/schemas/submit-plan.js.map +1 -1
  30. package/package.json +4 -3
  31. package/skills/ad-ops/SKILL.md +108 -10
  32. package/skills/ad-ops/ad-planner.html +257 -20
  33. package/skills/ad-ops/scripts/adops.sh +39 -9
  34. package/skills/ad-ops/scripts/plan_lint.py +262 -28
  35. package/skills/ad-ops/scripts/refs_import.py +251 -20
  36. package/skills/ad-ops/scripts/test_plan_lint.sh +126 -0
  37. package/skills/ad-ops//343/202/242/343/202/253/343/202/246/343/203/263/343/203/210/google/sample_account.json +4 -0
@@ -35,6 +35,7 @@
35
35
  header.bar .brand{display:flex; align-items:baseline; gap:10px; margin-right:6px;}
36
36
  header.bar .brand .logo{font-size:15px; font-weight:800; letter-spacing:.02em;}
37
37
  header.bar .brand .pjname{font-size:12.5px; color:var(--muted);}
38
+ header.bar .brand .appver{font-size:11px; color:var(--muted); font-variant-numeric:tabular-nums; opacity:.8;}
38
39
  header.bar .acchdr{display:inline-flex; align-items:center; gap:6px; font-size:12px; color:var(--muted); margin-right:6px;} /* F-3: ヘッダーのアカウント切替 */
39
40
  header.bar .acchdr select{font-family:inherit; font-size:12.5px; border:1px solid var(--line); border-radius:7px; padding:4px 6px; background:#fff;}
40
41
  .seg{display:inline-flex; background:var(--soft); border:1px solid var(--line); border-radius:8px; padding:2px;}
@@ -328,8 +329,12 @@
328
329
  .mrow{display:grid; grid-template-columns:1fr 1fr; gap:12px;}
329
330
  .modal .mfoot{display:flex; justify-content:flex-end; gap:8px; padding:14px 20px 18px; border-top:1px solid var(--line2); margin-top:8px; position:sticky; bottom:0; background:#fff;}
330
331
  .hint{font-size:11.5px; color:var(--faint); margin-top:4px;}
331
- .toast{position:fixed; bottom:22px; left:50%; transform:translateX(-50%); background:#111418; color:#fff; font-size:12.5px; padding:9px 16px; border-radius:9px; z-index:100; box-shadow:0 8px 24px rgba(0,0,0,.25); opacity:0; transition:opacity .2s, transform .2s;}
332
+ .toast{position:fixed; bottom:22px; left:50%; transform:translateX(-50%); background:#111418; color:#fff; font-size:12.5px; padding:9px 16px; border-radius:9px; z-index:100; box-shadow:0 8px 24px rgba(0,0,0,.25); opacity:0; transition:opacity .2s, transform .2s; max-width:min(720px,92vw); text-align:left; line-height:1.6;}
332
333
  .toast.show{opacity:1; transform:translateX(-50%) translateY(-2px);}
334
+ /* 書き出しをブロックしたときは自動で消さない。原因を読み返せないと直しようがない */
335
+ .toast.sticky{padding-right:34px; background:#3a1518; border:1px solid #a33; white-space:pre-wrap;}
336
+ .toast .tclose{position:absolute; top:4px; right:8px; cursor:pointer; font-size:15px; line-height:1; color:#fff9; padding:2px 4px;}
337
+ .toast .tclose:hover{color:#fff;}
333
338
  .empty{padding:40px; text-align:center; color:var(--faint); font-size:13px;}
334
339
  /* ── plan warning banner (accountId 不一致など) ── */
335
340
  .planwarn{display:flex; align-items:center; gap:10px; background:var(--amber-soft); color:#7a4d00; border-bottom:1px solid var(--amber); font-size:12.5px; padding:9px 16px;}
@@ -357,7 +362,7 @@
357
362
  </head>
358
363
  <body>
359
364
  <header class="bar">
360
- <div class="brand"><span class="logo" id="brandLogo">YDA入稿プランナー</span><span class="pjname" id="pjLabel">—</span></div>
365
+ <div class="brand"><span class="logo" id="brandLogo">YDA入稿プランナー</span><span class="appver" id="appVer" title="このプランナーのバージョン(addashboard-cli と共通)"></span><span class="pjname" id="pjLabel">—</span></div>
361
366
  <!-- S11: 「案件 × 媒体」を1つで選ぶ統合プルダウン。option value = "<platform>::<アカウント名>"。
362
367
  旧 #pfSeg(媒体セレクタ)/ #mediaTabs(媒体要件タブ)は廃止し、媒体切替もここに一本化した (2026-08-05) -->
363
368
  <span class="acchdr" title="案件×媒体を切り替える(+新規追加/編集もここから)"><select class="chipin" id="accSelect" style="min-width:18ch"></select></span>
@@ -842,6 +847,13 @@ finalUrl = baseURL + paramCode + "?ad=" + paramCode + "&creative=" + …
842
847
  /* ============================================================
843
848
  YDA入稿プランナー — 単一state / シート・ツリー両ビュー投影
844
849
  ============================================================ */
850
+ /* ---- バージョン ----
851
+ プランナーと CLI (addashboard-cli) は同じ npm パッケージで配られるので**同じ版数**を名乗る。
852
+ 正は cli/package.json の version で、この行は `npm run version:sync` が書き換える
853
+ (cli/test/unit/plannerVersion.test.ts が package.json との一致を検証するので、
854
+ 手で書き換えて放置するとテストが落ちる)。
855
+ 利用者が「どの版のプランナーを開いているか」を言えないと、不具合報告が噛み合わない。 */
856
+ const APP_VERSION = '0.9.3'; /* version:sync */
845
857
  /* 案A: embed廃止。参照データ(refs)・初期プラン・共通設定は外部JSONから起動時に読み込む。
846
858
  同梱デフォルト: アカウント/<媒体>/index.json の先頭アカウント (refs+principal+projectName+baseArticleUrl) と サンプルプラン.json (project+campaigns)。
847
859
  固有名詞はハードコードせず、すべて外部JSON由来にする。 */
@@ -916,7 +928,34 @@ const firstNonEmpty = (...vs) => { for(const v of vs){ const s=String(v==null?''
916
928
  **URL構造を壊す文字だけ**をパーセントエンコードし、日本語はそのまま残す
917
929
  (計測ツール上での可読性を優先。1パスなので %→%25 の二重エンコードは起きない)。 */
918
930
  const urlq = s => String(s==null?'':s).replace(/[%&#?=+\s]/g, c=>encodeURIComponent(c));
919
- 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); }
931
+ /**
932
+ * 画面下の通知。既定は 2.2 秒で消える。
933
+ *
934
+ * `{sticky:true}` は**自動で消さない**。書き出しをブロックしたときに使う
935
+ * (原因を読み返せないと直しようがない)。閉じるボタンと Esc で消す。
936
+ */
937
+ function toast(m, opts){
938
+ const t=document.getElementById('toast');
939
+ const sticky=!!(opts&&opts.sticky);
940
+ clearTimeout(t._h);
941
+ t.textContent=m;
942
+ t.classList.toggle('sticky', sticky);
943
+ t.classList.add('show');
944
+ if(sticky){
945
+ const x=document.createElement('span');
946
+ x.className='tclose'; x.textContent='×'; x.title='閉じる (Esc)';
947
+ x.onclick=()=>t.classList.remove('show');
948
+ t.appendChild(x);
949
+ return;
950
+ }
951
+ t._h=setTimeout(()=>t.classList.remove('show'),2200);
952
+ }
953
+
954
+ /** 自動で消えないトーストを閉じる(Esc / 閉じるボタン)。 */
955
+ function dismissStickyToast(){
956
+ const t=document.getElementById('toast');
957
+ if(t && t.classList.contains('sticky')) t.classList.remove('show');
958
+ }
920
959
 
921
960
  /* ---- エラー / 警告の安定コード (媒体非依存の語彙) ----
922
961
  S2: UI判定 (セル赤枠 / ghost / ツリーのドット) は日本語文字列ではなくこのコードで行う。
@@ -939,6 +978,10 @@ const EC = {
939
978
  LONG_HEADLINE_MISSING:'LONG_HEADLINE_MISSING' /* 動画広告は長見出しが必須 */,
940
979
  BUSINESS_NAME_MISSING:'BUSINESS_NAME_MISSING' /* proto 上 REQUIRED */,
941
980
  LOGO_MISSING:'LOGO_MISSING' /* 画像広告はロゴ 1-5 件が必須 */,
981
+ LOGO_FOREIGN_ACCOUNT:'LOGO_FOREIGN_ACCOUNT' /* ロゴが別 Customer の asset = 入稿時に必ず落ちる */,
982
+ LOGO_SOURCE_CONFLICT:'LOGO_SOURCE_CONFLICT' /* 登録済みIDとローカルパスの同時指定 = 二重にアセットを作る */,
983
+ CAMPAIGN_NAME_DUPLICATE:'CAMPAIGN_NAME_DUPLICATE' /* 既存CPと同名。Google は同名を拒否する */,
984
+ CAMPAIGN_NAME_DUPLICATE_IN_PLAN:'CAMPAIGN_NAME_DUPLICATE_IN_PLAN' /* 同じ計画の中で新規CP名が重複 */,
942
985
  LOCATION_EMPTY:'LOCATION_EMPTY' /* 地域未指定。google は全世界配信 (warn) / bytedance は入稿不可 (err) */,
943
986
  /* ByteDance (TikTok) 固有 */
944
987
  IDENTITY_MISSING:'IDENTITY_MISSING' /* 広告主として表示する TikTok アカウント未設定 = 広告が作れない */,
@@ -1018,6 +1061,7 @@ function renderChrome(){
1018
1061
  const P=pf(); if(!P) return;
1019
1062
  try{ document.title = P.brandTitle+' / 媒体要件 — WEBGRAM'; }catch(e){}
1020
1063
  const bl=document.getElementById('brandLogo'); if(bl) bl.textContent=P.brandTitle;
1064
+ const av=document.getElementById('appVer'); if(av) av.textContent='v'+APP_VERSION;
1021
1065
  const stLabel={ads:P.levels.ad.label, groups:P.levels.adGroup.label, campaigns:P.levels.campaign.label};
1022
1066
  document.querySelectorAll('#sheetTabs button[data-st]').forEach(b=>{
1023
1067
  const l=stLabel[b.dataset.st]; if(l) b.textContent=l;
@@ -1512,6 +1556,12 @@ PLATFORMS.yda = {
1512
1556
  const P=PLATFORMS.yda, p=ctx.project;
1513
1557
  const bidLabel=String(c.biddingStrategy||'').trim(); // A-1: キャンペーン個別値のみ (project既定へフォールバックしない)
1514
1558
  const biddingStrategy=P.enums.bidding[bidLabel]||bidLabel||P.enums.biddingFallback;
1559
+ /* S15: 既存キャンペーン呼び出しは「広告追加専用」で、予算・入札はプランナー上で編集できない
1560
+ (isExisting=true → 予算セルが未入力のまま)。それを Number(null)||0 で 0 として書き出すと
1561
+ backend の zod (budget は正の整数) に引っかかり dry-run が budget must be > 0 で落ちる。
1562
+ backend は既存CPでは budget/goal/入札を Yahoo 側へ送らない (ignoredFields) ので、
1563
+ **持っていない値はキー自体を出さない**のが正しい。 */
1564
+ const existing=isExistingCampaign(c);
1515
1565
  const cp={
1516
1566
  name:c.name,
1517
1567
  goal:p.goal||'CONVERSION',
@@ -1519,10 +1569,10 @@ PLATFORMS.yda = {
1519
1569
  /* S12/I-YDA-TIME: Yahoo Ads API は startDate(yyyyMMdd 相当) と startTime(HHmm) の2フィールド。
1520
1570
  日付と時刻を分けて送る (公式: startTime を指定するときは startDate が必須)。 */
1521
1571
  startDate:dateOnly(p.startDate)||defaultStart(), /* 既定は翌日 */
1522
- budget:Number(c.budget)||0, // A-1: キャンペーン個別値のみ
1523
1572
  biddingStrategy,
1524
1573
  adGroups:[]
1525
1574
  };
1575
+ if(!existing) cp.budget=Number(c.budget)||0; // A-1: キャンペーン個別値のみ
1526
1576
  /* 開始時刻 (HHmm)。案件設定が時刻を持たないときは既定 05:00 を使う。
1527
1577
  空欄になるのは「日付だけを明示的に入れた」ケースなので、そのときは startTime を送らない。 */
1528
1578
  const stTime = p.startDate ? timeHHmm(p.startDate) : timeHHmm(defaultStartDateTime());
@@ -1538,8 +1588,12 @@ PLATFORMS.yda = {
1538
1588
  cp.frequencyTimeUnit=fe.unit;
1539
1589
  cp.vImps=fe.count;
1540
1590
  }
1541
- if(bidLabel==='tCPA'){ cp.targetCpa=Number(c.tcpa)||0; }
1542
- else if(biddingStrategy==='CPC'){ cp.cpc=Number(c.adGroups[0]&&c.adGroups[0].bid)||0; }
1591
+ /* 既存CPは入札もプランナー上で持たない (biddingStrategy は '')。0 を書き出しても
1592
+ backend は無視するが、「反映される値」に見えるので出さない。 */
1593
+ if(!existing){
1594
+ if(bidLabel==='tCPA'){ cp.targetCpa=Number(c.tcpa)||0; }
1595
+ else if(biddingStrategy==='CPC'){ cp.cpc=Number(c.adGroups[0]&&c.adGroups[0].bid)||0; }
1596
+ }
1543
1597
  const cid=resolveCampaignIdOf(c); if(cid) cp.campaignId=cid; // Phase C: 既存はIDを出力(backendで作成スキップ)。新規はID無し=作成対象
1544
1598
  return cp;
1545
1599
  },
@@ -1973,7 +2027,23 @@ function renderTplChips(){
1973
2027
  };
1974
2028
  const num=(f,v,w,d)=>'<input class="chipin" type="number" data-proj="'+f+'" value="'+esc(v)+'" style="width:'+(w||'6ch')+'"'+hint(d)+'>';
1975
2029
  const sel=(f,v,opts)=>'<select class="chipin" data-proj="'+f+'">'+opts.map(o=>'<option'+(o===v?' selected':'')+'>'+esc(o)+'</option>').join('')+'</select>';
1976
- const selv=(f,v,opts)=>'<select class="chipin" data-proj="'+f+'">'+opts.map(o=>'<option value="'+esc(o.v)+'"'+(o.v===v?' selected':'')+'>'+esc(o.t)+'</option>').join('')+'</select>';
2030
+ const selv=(f,v,opts,d)=>'<select class="chipin" data-proj="'+f+'"'+hint(d)+'>'+opts.map(o=>'<option value="'+esc(o.v)+'"'+(o.v===v?' selected':'')+'>'+esc(o.t)+'</option>').join('')+'</select>';
2031
+ /* S15: kind:'refselect' = 参照表 (st.refs[refKey]) から選ぶチップ。
2032
+ **参照表が空のあいだは従来のテキスト入力にフォールバックする**。refs-import 前や
2033
+ 参照表を持たないアカウントで「選べる候補がゼロのプルダウン」になると、値を入れる手段が
2034
+ 消えて後退するため。現在値が参照表に無いときは末尾に option として残す
2035
+ (手打ちした値・旧プランの値を再描画で消さない)。 */
2036
+ const refsel=(f,v,d)=>{
2037
+ const table=(st.refs && st.refs[f.refKey]) || {};
2038
+ const keys=Object.keys(table);
2039
+ if(!keys.length) return txt(f.key, v, f.width, d);
2040
+ const opts=[{v:'', t:'(未選択)'}];
2041
+ keys.forEach(k=>opts.push({v:k, t:(f.optionLabel? f.optionLabel(k, table[k]) : k)}));
2042
+ const cur=String(v||'');
2043
+ if(cur && !Object.prototype.hasOwnProperty.call(table, cur))
2044
+ opts.push({v:cur, t:cur+'(一覧に無い値)'});
2045
+ return selv(f.key, cur, opts, d);
2046
+ };
1977
2047
  /* C1: kind:'toggle' = チェックボックス。値は真偽で持ち、**未定義は fallback**(欠落=従来挙動)。
1978
2048
  `p[key]||fallback` では false が fallback(true) に化けるので、toggle だけは別扱いにする。 */
1979
2049
  const tgl=(f,v,d)=>'<input class="chipin" type="checkbox" data-proj="'+f+'"'+(v?' checked':'')+hint(d)+'>';
@@ -2013,6 +2083,7 @@ function renderTplChips(){
2013
2083
  const ctrl = f.kind==='freq' ? freq(f)
2014
2084
  : f.kind==='toggle' ? tgl(f.key, v, f)
2015
2085
  : f.kind==='datetime' ? dt(f.key, v, f)
2086
+ : f.kind==='refselect' ? refsel(f, v, f)
2016
2087
  : f.kind==='selectv' ? selv(f.key, v, f.options)
2017
2088
  : f.kind==='select' ? sel(f.key, v, f.options)
2018
2089
  : f.kind==='number' ? num(f.key, v, f.width, f)
@@ -3436,6 +3507,19 @@ const GOOGLE_REF_TABLES = {
3436
3507
  keyCell:no=>'<td class="mono">'+esc(no)+'</td>',
3437
3508
  columns:[
3438
3509
  {label:'キャンペーン名', cls:'reftext', attr:(no,v)=>' title="'+esc(v&&v.name||'')+'"', cell:(no,v)=>esc(v&&v.name||'')}
3510
+ ] },
3511
+ /* ロゴは番号を振らず resource_name をキーにする。asset の resource_name は
3512
+ Google 側で一意なので、番号を振ると同じロゴに2つの番号が生まれて参照がぶれる。
3513
+ 取り込みは `adops.sh refs-import google <accountId> --write`。 */
3514
+ logos:{ key:'logos', label:'ロゴ', countLabel:'ロゴ', editable:false, required:false,
3515
+ keyKind:'string', keyLabel:'アセット', keyWidth:'260px',
3516
+ emptyMsg:'ロゴがありません(refs-import google で取り込むか、ロゴアセットを手で入力してください)',
3517
+ valueFields:[{k:'name',label:'名'}],
3518
+ keyCell:no=>'<td class="mono">'+esc(no)+'</td>',
3519
+ columns:[
3520
+ {label:'名前', cls:'reftext', attr:(no,v)=>' title="'+esc(v&&v.name||'')+'"', cell:(no,v)=>esc(v&&v.name||'')},
3521
+ {label:'サイズ', width:'100px', cls:'mono',
3522
+ cell:(no,v)=>esc(v&&v.width? String(v.width)+'x'+String(v.height) : '')}
3439
3523
  ] }
3440
3524
  };
3441
3525
 
@@ -3527,7 +3611,13 @@ PLATFORMS.google = {
3527
3611
  if(obj.name!=null) project.accountName=String(obj.name);
3528
3612
  if(obj.principal!=null) project.principal=String(obj.principal);
3529
3613
  if(obj.businessName!=null) project.businessName=String(obj.businessName);
3530
- if(obj.logoAsset!=null) project.logoAsset=String(obj.logoAsset);
3614
+ /* ロゴはアカウント固有。切替時に前のアカウントの値を持ち越すと、
3615
+ **別 Customer に削除できないアセットを作る**。
3616
+ logoAsset は別Customer検証で止まるが、ローカルパスには効かない
3617
+ (パスは resource_name ではないので所有者を判定できない)。
3618
+ どちらもアカウントJSONの値で置き換え、無ければ空に戻す。 */
3619
+ project.logoAsset = obj.logoAsset!=null ? String(obj.logoAsset) : '';
3620
+ project.logoImagePath = '';
3531
3621
  if(obj.consoleMcc!=null) project.consoleMcc=String(obj.consoleMcc).replace(/-/g,'');
3532
3622
  if(obj.projectName) project.name=obj.projectName;
3533
3623
  if(obj.baseArticleUrl) project.baseArticleUrl=obj.baseArticleUrl;
@@ -3574,8 +3664,25 @@ PLATFORMS.google = {
3574
3664
  projectFields:[
3575
3665
  {key:'businessName', label:'ブランド名', kind:'text', width:'14ch', fallback:'',
3576
3666
  title:'広告に表示される広告主名。Demand Gen 広告では必須(表示幅25まで)'},
3577
- {key:'logoAsset', label:'ロゴアセット', kind:'text', width:'18ch', fallback:'',
3578
- title:'ロゴ画像アセットの resource_name。画像広告では必須(1:1・128x128以上)'},
3667
+ /* 参照表 refs.logos があれば選択、無ければ従来どおり手打ち(refselect のフォールバック)。
3668
+ resource_name は長く手写しで取り違えやすいので、取り込み済みなら選ばせる。 */
3669
+ {key:'logoAsset', label:'ロゴアセット', kind:'refselect', width:'18ch', fallback:'',
3670
+ refKey:'logos',
3671
+ optionLabel:(rn, row)=>{
3672
+ const size=(row&&row.width)? row.width+'x'+row.height : '';
3673
+ const name=(row&&row.name)||rn.split('/').pop();
3674
+ return name+(size? ' ('+size+')' : '');
3675
+ },
3676
+ title:'ロゴ画像アセットの resource_name。画像広告では必須(1:1・128x128以上)。'
3677
+ +'候補は refs-import google で取り込む'},
3678
+ /* ロゴが未登録のアカウント向け。本実行の直前に CLI がアップロードして
3679
+ resource_name に解決する(dry-run では上げない)。上の「ロゴアセット」と
3680
+ 同時に入れると二重にアセットを作るので、書き出しで止める。 */
3681
+ {key:'logoImagePath', label:'ロゴ画像パス', kind:'text', width:'16ch', fallback:'',
3682
+ placeholder:'素材/ロゴ.png',
3683
+ title:'ロゴが未登録のときに使う。ローカル画像のパス(入稿計画JSONからの相対 or 絶対)。'
3684
+ +'入稿時に自動でアップロードされます。1:1・128x128以上・GIF/JPEG/PNG。'
3685
+ +'カンマか改行で複数指定可(最大5件)。「ロゴアセット」と同時には使えません'},
3579
3686
  {key:'cta', label:'CTA', kind:'select',
3580
3687
  options:['詳しくはこちら','購入する','登録する','ダウンロード','お問い合わせ','今すぐ申し込む',
3581
3688
  '予約する','見積もりを取得','購読する','インストール','サイトへアクセス','もっと見る','ボタンなし'],
@@ -3614,6 +3721,10 @@ PLATFORMS.google = {
3614
3721
 
3615
3722
  mappers:{
3616
3723
  status(s, P){ return s==='オン'?P.enums.statusOn:P.enums.statusOff; },
3724
+ /* 1つのテキスト欄で複数のロゴを受ける。カンマ・改行のどちらでも区切れる */
3725
+ logoImagePaths(v){
3726
+ return String(v||'').split(/[,\n]/).map(x=>x.trim()).filter(Boolean);
3727
+ },
3617
3728
  locationIds(s, P){ return mapByEnum(s, P.enums.location, P.targeting.tg_location.allLabel); },
3618
3729
  languageIds(s, P){ return mapByEnum(s, P.enums.language, P.targeting.tg_language.allLabel); },
3619
3730
  /* 配信面。「すべて」= 指定しない (キー自体を出さず Google 既定に任せる)。
@@ -3633,7 +3744,7 @@ PLATFORMS.google = {
3633
3744
  newCampaignBudget:12000,
3634
3745
  targetCpa:8500,
3635
3746
  project:{
3636
- businessName:'', logoAsset:'', cta:'詳しくはこちら',
3747
+ businessName:'', logoAsset:'', logoImagePath:'', cta:'詳しくはこちら',
3637
3748
  deliveryStatus:'オフ'
3638
3749
  },
3639
3750
  ad:{format:'VIDEO', imageNo:'', titleNo:'', longNo:'', descNo:'', adName:''},
@@ -3731,7 +3842,12 @@ PLATFORMS.google = {
3731
3842
  if(p.urlParams!==false && String(group.paramCode||'').trim()==='') pushErr('paramCode未入力', EC.PARAM_CODE_EMPTY);
3732
3843
  /* Demand Gen の必須 (proto: business_name は REQUIRED / 画像広告の logo_images は 1-5 件) */
3733
3844
  if(String(p.businessName||'').trim()==='') pushErr('ブランド名未入力', EC.BUSINESS_NAME_MISSING);
3734
- if(fmt.id==='IMAGE' && String(p.logoAsset||'').trim()==='') pushErr('ロゴアセット未入力', EC.LOGO_MISSING);
3845
+ /* 登録済みID・ローカルパスのどちらか。未登録アカウントはパスだけで入稿できる
3846
+ (両方あるのは validateProject が書き出し前に止める) */
3847
+ if(fmt.id==='IMAGE'
3848
+ && String(p.logoAsset||'').trim()===''
3849
+ && !P.mappers.logoImagePaths(p.logoImagePath).length)
3850
+ pushErr('ロゴ未入力(ロゴアセット または ロゴ画像パス)', EC.LOGO_MISSING);
3735
3851
  /* 地域未指定は全世界配信になる。作成自体は通るのでエラーではなく警告 */
3736
3852
  if(!P.mappers.locationIds(group.tg_location, P).length) pushWarn('地域未指定(全世界配信)', EC.LOCATION_EMPTY);
3737
3853
  if(pendingImage) pushWarn(fmt.id==='VIDEO'?'YouTube動画ID未登録':'画像アセット未登録', EC.MEDIA_MISSING);
@@ -3763,8 +3879,34 @@ PLATFORMS.google = {
3763
3879
  errs.push({code:EC.BUSINESS_NAME_MISSING, msg:'ブランド名を共通設定に入力してください(Demand Gen 広告で必須)'});
3764
3880
  /* 画像広告を1件でも含むならロゴが要る (動画だけなら任意) */
3765
3881
  const hasImageAd=((state&&state.campaigns)||[]).some(c=>(c.adGroups||[]).some(g=>(g.ads||[]).some(a=>a.format!=='VIDEO')));
3766
- if(hasImageAd && !String((p&&p.logoAsset)||'').trim())
3767
- errs.push({code:EC.LOGO_MISSING, msg:'画像広告があるためロゴアセットが必須です(1:1・128x128以上)'});
3882
+ const logo=String((p&&p.logoAsset)||'').trim();
3883
+ const logoPaths=PLATFORMS.google.mappers.logoImagePaths(p&&p.logoImagePath);
3884
+ if(hasImageAd && !logo && !logoPaths.length)
3885
+ errs.push({code:EC.LOGO_MISSING,
3886
+ msg:'画像広告があるためロゴが必須です。登録済みなら「ロゴアセット」、未登録なら「ロゴ画像パス」を入れてください(1:1・128x128以上)'});
3887
+ /* 両方あると CLI が余計にアップロードし、削除できないアセットが増える */
3888
+ if(logo && logoPaths.length)
3889
+ errs.push({code:EC.LOGO_SOURCE_CONFLICT,
3890
+ msg:'「ロゴアセット」と「ロゴ画像パス」は同時に指定できません(どちらか一方にしてください)'});
3891
+ if(logoPaths.length>5)
3892
+ errs.push({code:EC.LOGO_SOURCE_CONFLICT,
3893
+ msg:'ロゴ画像パスは 5 件までです(received '+logoPaths.length+')'});
3894
+ /* asset は Customer をまたいで参照できない。別アカウントの resource_name を貼ると
3895
+ 入稿時に必ず落ちるので、書き出しの手前で止める。アカウントJSONを取り違えたまま
3896
+ ロゴだけ前のアカウントの値が残る、が実際に起こりうる経路。
3897
+ **ローカルパスはこの検査に掛けない**(./logo.png を「別アカウント」と誤判定する)。 */
3898
+ if(logo){
3899
+ /* 終端まで締める。前方一致だと customers/<id>/assets/ や .../assets/abc が
3900
+ 通り、形式は合っているのに入稿で落ちる値を書き出してしまう。 */
3901
+ const m=/^customers\/(\d+)\/assets\/(\d+)$/.exec(logo);
3902
+ const acc=String((p&&p.accountId)||'').trim();
3903
+ if(!m)
3904
+ errs.push({code:EC.LOGO_FOREIGN_ACCOUNT,
3905
+ msg:'ロゴアセットの形式が違います(customers/<アカウントID>/assets/<ID>): '+logo});
3906
+ else if(acc && m[1]!==acc)
3907
+ errs.push({code:EC.LOGO_FOREIGN_ACCOUNT,
3908
+ msg:'ロゴアセットが別アカウント('+m[1]+')のものです。現在のアカウントは '+acc+' です'});
3909
+ }
3768
3910
  /* 予算はキャンペーン階層。コアの書き出しは広告グループ行しか検証しないため、
3769
3911
  ここで見ないと budgetAmountYen:0 のまま書き出され dry-run まで気づけない。 */
3770
3912
  ((state&&state.campaigns)||[]).forEach(c=>{
@@ -3772,6 +3914,87 @@ PLATFORMS.google = {
3772
3914
  if(!(Number(c.dailyBudget)>0))
3773
3915
  errs.push({code:EC.BUDGET_MISSING, msg:'キャンペーン「'+String(c.name||'')+'」の日予算を入力してください'});
3774
3916
  });
3917
+ errs.push(...PLATFORMS.google.checkCampaignNames(state));
3918
+ return errs;
3919
+ },
3920
+
3921
+ /**
3922
+ * キャンペーン名の重複を見る。**Google は同名キャンペーンを拒否する**
3923
+ * (実測: Trying to modify the name of an active or paused campaign,
3924
+ * where the name is already assigned to another active or paused campaign.)。
3925
+ * dry-run は実 API を叩かないので通ってしまい、本実行で初めて落ちる。
3926
+ *
3927
+ * 見るのは 2 種類:
3928
+ * 1. アカウントの既存CP(`refs.campaigns`)との衝突。**スナップショットなので
3929
+ * 古いと取りこぼす** — 実データとの突き合わせは CLI / backend が本実行の直前に行う
3930
+ * 2. 同じ計画の中での衝突。こちらは鮮度と無関係にローカルで確定する
3931
+ *
3932
+ * **既存CP行も対象にする。** Google は既存CPへの広告追加が未対応で、`buildPlan.campaign`
3933
+ * は `campaignId` を出さない(backend の zod が落とすため)。つまり「既存CPに足す」
3934
+ * つもりの行も**同名の新規CPとして書き出される**ので、除外すると
3935
+ * 「プランナーは通るのに CLI / backend で落ちる」経路が残る。
3936
+ */
3937
+ checkCampaignNames(state){
3938
+ const errs=[];
3939
+ const known=new Map(); // 正規化名 → 元の表記
3940
+ Object.values((state&&state.refs&&state.refs.campaigns)||{}).forEach(v=>{
3941
+ /* stale = 媒体側から消えたCP。削除済みの名前は再利用できるので、
3942
+ 残したままだと使える名前を「重複」と誤判定する */
3943
+ if(v && v.stale) return;
3944
+ const n=String((v&&v.name)||'').trim();
3945
+ if(n) known.set(n.toLowerCase(), n);
3946
+ });
3947
+
3948
+ /* **手つかずの行は判定しない。** コアは広告が1件も解決しないキャンペーンを
3949
+ 書き出しから落とす(stats.emptyCampaigns)。既定のCP名は固定文字列なので、
3950
+ 「+ 新規キャンペーン」を2回押しただけで送られもしない行どうしが重複扱いになり、
3951
+ **完成している他のキャンペーンごと書き出しがブロックされていた**。
3952
+ ただし「中身はあるが別の理由で解決していない」行は対象に残す
3953
+ (ブランド名未入力などで一時的に全滅しているだけ、を取りこぼさないため)。 */
3954
+ const DA=PLATFORMS.google.defaults.ad;
3955
+ const pristineAd=a=>Object.keys(DA).every(k=>{
3956
+ const d=DA[k];
3957
+ if(d && typeof d==='object') return true; // 入れ子は見ない
3958
+ return String((a&&a[k])==null?'':a[k])===String(d==null?'':d);
3959
+ });
3960
+ const hasContent=c=>(((c&&c.adGroups)||[]).some(g=>(((g&&g.ads)||[]).some(a=>!pristineAd(a)))));
3961
+ const newCps=((state&&state.campaigns)||[]).filter(hasContent);
3962
+ const planNames=new Set(newCps.map(c=>String(c.name||'').trim().toLowerCase()).filter(Boolean));
3963
+ /* 提案が既存とも計画内とも衝突しないことを保証する。片方だけ見ると
3964
+ 「提案どおりに直したらプラン内で重複した」が起きる。
3965
+ **出した候補も taken に足す** — 足さないと同名2行に同じ候補を出してしまい、
3966
+ 両方とも提案どおりに直すと重複が再発する。 */
3967
+ const taken=new Set([...known.keys(), ...planNames]);
3968
+ const suggest=(base)=>{
3969
+ const stem=base.replace(/_(\d+)$/,''); // 既に _2 なら _3 以降から
3970
+ const out=[];
3971
+ for(let i=2; out.length<3 && i<100; i++){
3972
+ const cand=stem+'_'+i;
3973
+ if(!taken.has(cand.toLowerCase())){ out.push(cand); taken.add(cand.toLowerCase()); }
3974
+ }
3975
+ return out;
3976
+ };
3977
+
3978
+ const seen=new Map();
3979
+ newCps.forEach(c=>{
3980
+ const raw=String(c.name||'').trim();
3981
+ if(!raw) return;
3982
+ const key=raw.toLowerCase();
3983
+ if(known.has(key)){
3984
+ const alt=suggest(raw);
3985
+ errs.push({code:EC.CAMPAIGN_NAME_DUPLICATE,
3986
+ msg:'キャンペーン名「'+raw+'」はアカウントの既存キャンペーンと同じです。'
3987
+ +'Google は既存キャンペーンへの広告追加に未対応で、この行も新規作成として送られます'
3988
+ +'(同名は拒否されるため入稿時に落ちます)。'
3989
+ +(alt.length?('代替候補: '+alt.join(' / ')):'別の名前を付けてください')});
3990
+ }
3991
+ if(seen.has(key)){
3992
+ errs.push({code:EC.CAMPAIGN_NAME_DUPLICATE_IN_PLAN,
3993
+ msg:'キャンペーン名「'+raw+'」がこの計画の中で重複しています(2件以上)。'
3994
+ +'まとめて入稿すると2件目以降が落ちます'});
3995
+ }
3996
+ seen.set(key, true);
3997
+ });
3775
3998
  return errs;
3776
3999
  },
3777
4000
 
@@ -3816,15 +4039,21 @@ PLATFORMS.google = {
3816
4039
  finalUrls:[r.finalUrl]
3817
4040
  };
3818
4041
  const logo=String(p.logoAsset||'').trim();
4042
+ const logoPaths=P.mappers.logoImagePaths(p.logoImagePath);
3819
4043
  if(isVideo){
3820
4044
  out.youtubeVideoId=String(r.mediaId);
3821
4045
  out.longHeadlines=r.longText?[String(r.longText)]:[];
3822
4046
  if(logo) out.logoImages=[logo];
4047
+ /* 動画広告のロゴは v23 では任意。ローカルパスからの作成は画像広告に絞る
4048
+ (動画のためだけに消せないアセットを増やさない) */
3823
4049
  } else {
3824
4050
  /* 画像は横長・正方形のうち登録があるものだけ送る (proto: どちらか必須) */
3825
4051
  if(r.imageAsset) out.marketingImages=[String(r.imageAsset)];
3826
4052
  if(r.squareAsset) out.squareMarketingImages=[String(r.squareAsset)];
3827
- out.logoImages=logo?[logo]:[];
4053
+ /* 登録済みIDが優先。両方あるのは validateProject が書き出し前に止める */
4054
+ if(logo) out.logoImages=[logo];
4055
+ else if(logoPaths.length) out.logoImagePaths=logoPaths;
4056
+ else out.logoImages=[];
3828
4057
  const cta=P.mappers.cta(p.cta, P); if(cta) out.callToActionText=cta;
3829
4058
  }
3830
4059
  return out;
@@ -5819,7 +6048,7 @@ function showPromptModal(opts){
5819
6048
  root.innerHTML='<div class="overlay"><div class="modal"><h3>'+esc(opts.title)+'</h3>'
5820
6049
  +(opts.sub?'<div class="msub">'+esc(opts.sub)+'</div>':'')
5821
6050
  +'<div class="mbody">'+(opts.infoHtml||'')
5822
- +'<div class="mfield"><label>Claudeに貼るプロンプト</label>'
6051
+ +'<div class="mfield"><label>'+esc(opts.promptLabel||'Claudeに貼るプロンプト')+'</label>'
5823
6052
  +'<textarea id="promptText" readonly style="min-height:120px; font-family:var(--mono); font-size:12px; white-space:pre-wrap">'+esc(opts.prompt)+'</textarea>'
5824
6053
  +(opts.hint?'<div class="hint">'+esc(opts.hint)+'</div>':'')+'</div>'
5825
6054
  +'</div><div class="mfoot"><button class="act" data-mo="cancel">閉じる</button><button class="act primary" id="btnCopyPrompt">📋 プロンプトをコピー</button></div></div></div>';
@@ -5907,15 +6136,20 @@ async function exportSubmitJson(){
5907
6136
  /* S10: 媒体スキーマの accountId 書式ガード (Meta は act_ 付き必須) */
5908
6137
  const idPat=P.account&&P.account.idPattern;
5909
6138
  if(idPat && !idPat.test(String(plan.accountId))){
5910
- toast('アカウントIDの形式が不正です('+P.account.idHint+')'); return;
6139
+ toast('アカウントIDの形式が不正です('+P.account.idHint+')', {sticky:true}); return;
5911
6140
  }
5912
6141
  /* S10: 媒体固有のプロジェクト単位ガード (Meta の pageId / pixelId 必須) */
5913
6142
  if(P.validateProject){
5914
6143
  const perrs=P.validateProject(st.project, st)||[];
5915
- if(perrs.length){ toast(perrs[0].msg); return; }
6144
+ /* 複数まとめて出す。1件ずつ直して押し直す往復を減らす */
6145
+ if(perrs.length){
6146
+ const head=perrs.length>1 ? ('書き出しできません('+perrs.length+'件)\n') : '';
6147
+ toast(head+perrs.map(e=>'・'+e.msg).join('\n'), {sticky:true});
6148
+ return;
6149
+ }
5916
6150
  }
5917
6151
  const n=stats.exported;
5918
- if(n===0){ toast('入稿対象の広告が0件です(空入稿を防止しました)'); return; }
6152
+ if(n===0){ toast('入稿対象の広告が0件です(空入稿を防止しました)', {sticky:true}); return; }
5919
6153
  const json=JSON.stringify(plan,null,2);
5920
6154
  // 完成したプランは「新規ファイル」として保存する(上書きしない・毎回タイムスタンプ付き)。
5921
6155
  // 接続済み入稿計画フォルダがあればそこへ、無ければダウンロード。Finderは出さない。
@@ -5941,7 +6175,7 @@ async function exportSubmitJson(){
5941
6175
  // S7: 文言・コマンドは pf().prompts.submitHelp / pf().output.submitCommand (媒体スキーマ)
5942
6176
  const prompt=pf().prompts.submitHelp({fileName, savedRel, wrote,
5943
6177
  pendingImages:stats.pendingImages, pendingImageNos:stats.pendingImageNos});
5944
- showPromptModal({title:'入稿計画JSONを保存しました', sub:'このプロンプトをClaude Codeに貼って入稿してください', infoHtml:info, prompt, hint:'submit-plan は1キャンペーンずつ入稿・進捗書き戻しで再開可(submitted済みはスキップ)'});
6178
+ showPromptModal({title:'入稿計画JSONを保存しました', sub:'このままClaude Codeが続き(dry-run)を実行します。自動で進まないときだけ下のプロンプトを貼ってください', infoHtml:info, prompt, promptLabel:'自動で進まないとき用のプロンプト', hint:'submit-plan は1キャンペーンずつ入稿・進捗書き戻しで再開可(submitted済みはスキップ)'});
5945
6179
  }
5946
6180
 
5947
6181
  /* ---- JSON読込 (v1/v2 両対応) ---- */
@@ -6132,6 +6366,9 @@ document.addEventListener('copy',e=>{
6132
6366
  });
6133
6367
  document.addEventListener('paste',e=>{ if(activeView!=='sheet'||planMode!=='sheet') return; if(!selRange()) return; const txt=(e.clipboardData||window.clipboardData).getData('text'); if(txt==null) return; e.preventDefault(); pasteGrid(txt); });
6134
6368
  document.addEventListener('keydown',e=>{
6369
+ /* 自動で消えないトーストはどのビューからでも出る(書き出しボタンは常設ヘッダー)。
6370
+ ビューのガードより手前で閉じないと、title の「閉じる (Esc)」と食い違う */
6371
+ if(e.key==='Escape') dismissStickyToast();
6135
6372
  if(activeView!=='sheet'||planMode!=='sheet') return;
6136
6373
  if(e.key==='Escape'){ closeDrawer(); clearSel(); return; }
6137
6374
  if(e.shiftKey && selB && ['ArrowUp','ArrowDown','ArrowLeft','ArrowRight'].includes(e.key)){
@@ -4,9 +4,12 @@
4
4
  # adops.sh preflight <platform> <accountId> フェーズ0: ログイン + アカウント疎通確認
5
5
  # adops.sh planner [--platform <媒体> --account <案件名>] [--stop]
6
6
  # フェーズ2: プランナーをHTTPで開く (file:// 禁止)
7
- # adops.sh plan [<入稿計画.json>] フェーズ3: DL回収 + 要約 + lint + 合意との差分
7
+ # adops.sh plan [--wait] [<入稿計画.json>] フェーズ3: DL回収 + 要約 + lint + 合意との差分
8
+ # --wait は入稿ボタンが押されるまで待ってから処理する
8
9
  # adops.sh open <platform> <accountId> [resource] 管理画面を開く (URLを手で組み立てない)
9
10
  # adops.sh refs-import <platform> <accountId> [--write] 実アカウントから参照表を取り込む
11
+ # adops.sh refs-import google <accountId> --from-plan <入稿計画.json> [--write]
12
+ # 入稿で作ったロゴを取り込む(実APIを叩かない)
10
13
  #
11
14
  # 覚えるコマンドを1本に畳むためのラッパー。個別の python3 -c / curl / ls -t を書かない。
12
15
  set -uo pipefail
@@ -26,6 +29,32 @@ CLI="${WEBGRAM_CLI:-npx -y addashboard-cli@latest}"
26
29
 
27
30
  die() { echo "✗ $*" >&2; exit 1; }
28
31
 
32
+ # ------------------------------------------------------------- ensure_login
33
+ # 未ログインなら auth login を起動して完了まで待ち、そのまま処理を続ける。
34
+ # 以前は「ターミナルで auth login を実行してください」と案内して止めていたため、
35
+ # ユーザーが自分でコマンドを打ち、改めて指示を出し直す往復が必要だった。
36
+ #
37
+ # ローカルのデスクトップ環境では CLI がブラウザを自動で開く (loopback)。
38
+ # CI / SSH / コンテナでは URL + コード提示 (device flow) に自動で落ちる。
39
+ # パスワードはここでも CLI 側でも扱わない (Google SSO の画面操作は本人が行う)。
40
+ #
41
+ # 成功時は whoami の JSON を stdout に出す。進捗表示は stderr に出して混ぜない。
42
+ ensure_login() {
43
+ local who
44
+ echo "▸ ログイン確認..." >&2
45
+ if who="$($CLI auth whoami 2>/dev/null)"; then
46
+ printf '%s\n' "$who"
47
+ return 0
48
+ fi
49
+
50
+ echo "▸ 未ログインのためログインを開始します(ブラウザが開きます)..." >&2
51
+ $CLI auth login >&2 || die "ログインに失敗しました"
52
+
53
+ who="$($CLI auth whoami 2>&1)" \
54
+ || die "ログイン直後に whoami が失敗しました: $who"
55
+ printf '%s\n' "$who"
56
+ }
57
+
29
58
  # ---------------------------------------------------------------- preflight
30
59
  # フェーズ4の本実行で初めて 401 Account not found に気づく事故を防ぐ。
31
60
  # 戦略壁打ちを始める前にこれを通し、落ちたら戦略を練らずに止める。
@@ -34,12 +63,8 @@ cmd_preflight() {
34
63
  [ -n "$platform" ] && [ -n "$account" ] \
35
64
  || die "usage: adops.sh preflight <platform> <accountId>"
36
65
 
37
- echo "▸ ログイン確認..."
38
66
  local who
39
- who="$($CLI auth whoami 2>&1)" || {
40
- echo "$who" >&2
41
- die "未ログイン、または認証が期限切れです。ターミナルで '$CLI auth login' を実行してください(Google SSO は本人が操作)"
42
- }
67
+ who="$(ensure_login)" || exit 1
43
68
  python3 -c 'import json,sys; d=json.load(sys.stdin); print(" ✓", d.get("email"), "/ role:", d.get("role"))' <<<"$who" \
44
69
  || die "whoami の応答を解釈できません: $who"
45
70
 
@@ -48,11 +73,14 @@ cmd_preflight() {
48
73
  accounts="$($CLI list --platform "$platform" --resource accounts 2>&1)" \
49
74
  || { echo "$accounts" >&2; die "アカウント一覧の取得に失敗しました"; }
50
75
 
51
- ACCOUNT_ID="$account" python3 - <<'PY' <<<"$accounts"
76
+ # JSON は環境変数で渡す。stdin は python 本体が使うので、ここに <<< を足すと
77
+ # ヒアドキュメントを上書きして「JSON をスクリプトとして実行」になる
78
+ # (JSON は dict リテラルとして構文が通るので、照合されないまま exit 0 で素通りしていた)。
79
+ ACCOUNT_ID="$account" ACCOUNTS_JSON="$accounts" python3 - <<'PY'
52
80
  import json, os, sys
53
81
  want = os.environ["ACCOUNT_ID"]
54
82
  try:
55
- accounts = json.load(sys.stdin).get("accounts", [])
83
+ accounts = json.loads(os.environ["ACCOUNTS_JSON"]).get("accounts", [])
56
84
  except Exception as e:
57
85
  sys.exit(f"✗ アカウント一覧を解釈できません: {e}")
58
86
  hit = next((a for a in accounts if str(a.get("id")) == want), None)
@@ -281,11 +309,13 @@ usage:
281
309
  フェーズ2: プランナーをHTTPで開く
282
310
  --platform/--account を渡すと「作業中プラン」を
283
311
  設定してから開く(省略時は前回の設定を引き継ぐ)
284
- adops.sh plan [<入稿計画.json>] フェーズ3: DL回収 + 要約 + lint + 合意との差分
312
+ adops.sh plan [--wait] [<入稿計画.json>] フェーズ3: DL回収 + 要約 + lint + 合意との差分
313
+ --wait は入稿ボタンが押されるまで待ってから処理する
285
314
  adops.sh open <platform> <accountId> [campaigns|adgroups|ads] [--dry]
286
315
  管理画面を開く(URLを手で組み立てない)
287
316
  中間層は YDA=adgroups / Meta=adsets に自動変換
288
317
  adops.sh refs-import <platform> <accountId> [--write] [--campaign-id <id>]
318
+ adops.sh refs-import google <accountId> --from-plan <入稿計画.json> [--write]
289
319
  実アカウントから参照表を取り込む(既定 dry-run)
290
320
  EOF
291
321
  exit 2 ;;