addashboard-cli 0.9.2 → 0.9.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/ads.js +4 -7
- package/dist/commands/ads.js.map +1 -1
- package/dist/commands/authDevice.js +10 -2
- package/dist/commands/authDevice.js.map +1 -1
- package/dist/commands/authGoogle.js +39 -42
- package/dist/commands/authGoogle.js.map +1 -1
- package/dist/commands/freee.js +2 -18
- package/dist/commands/freee.js.map +1 -1
- package/dist/commands/pl.js +4 -8
- package/dist/commands/pl.js.map +1 -1
- package/dist/commands/review.js +4 -8
- package/dist/commands/review.js.map +1 -1
- package/dist/commands/status.js +2 -1
- package/dist/commands/status.js.map +1 -1
- package/dist/commands/submission.js +236 -21
- package/dist/commands/submission.js.map +1 -1
- package/dist/index.js +46 -31
- package/dist/index.js.map +1 -1
- package/dist/lib/apiError.js +63 -0
- package/dist/lib/apiError.js.map +1 -0
- package/dist/lib/authFlow.js +82 -0
- package/dist/lib/authFlow.js.map +1 -0
- package/dist/lib/googleImageMetadata.js +145 -0
- package/dist/lib/googleImageMetadata.js.map +1 -0
- package/dist/lib/openBrowser.js +63 -0
- package/dist/lib/openBrowser.js.map +1 -0
- package/dist/schemas/submit-plan.js +27 -1
- package/dist/schemas/submit-plan.js.map +1 -1
- package/package.json +4 -3
- package/skills/ad-ops/SKILL.md +84 -10
- package/skills/ad-ops/ad-planner.html +275 -21
- package/skills/ad-ops/scripts/adops.sh +39 -9
- package/skills/ad-ops/scripts/plan_lint.py +262 -28
- package/skills/ad-ops/scripts/refs_import.py +251 -20
- package/skills/ad-ops/scripts/test_plan_lint.sh +126 -0
- 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>
|
|
@@ -647,6 +652,7 @@ finalUrl = baseURL + paramCode + "?ad=" + paramCode + "&creative=" + …
|
|
|
647
652
|
<tr><td>曜日・時間帯</td><td>配信スケジュール</td></tr>
|
|
648
653
|
<tr><td>デバイス</td><td>配信デバイス</td></tr>
|
|
649
654
|
<tr><td>性別</td><td>—</td></tr>
|
|
655
|
+
<tr><td>Advantage+ オーディエンス</td><td>広告セット行の「Advantage+」列。<b>空欄・オンなら Meta が指定した年齢・性別を「出発点」として対象を広げる</b>/オフなら指定どおりに限定して配信する。<b>性別を指定する広告セットでは Meta 側でこの指定が必須</b>(無いと広告セット作成が <code>subcode 1870227</code> で落ちる。2026-09-03 に v26.0 の実 API で確認)。従来はこの列が無く、性別を指定した広告セットが全滅していた</td></tr>
|
|
650
656
|
<tr><td>年齢</td><td>—</td></tr>
|
|
651
657
|
<tr><td>オーディエンスリスト</td><td>どのデータ(カスタム/類似オーディエンス)に配布するか。<code>fetchAudiences</code> で取得可</td></tr>
|
|
652
658
|
<tr><td>プレイスメント</td><td>配信面の指定</td></tr>
|
|
@@ -842,6 +848,13 @@ finalUrl = baseURL + paramCode + "?ad=" + paramCode + "&creative=" + …
|
|
|
842
848
|
/* ============================================================
|
|
843
849
|
YDA入稿プランナー — 単一state / シート・ツリー両ビュー投影
|
|
844
850
|
============================================================ */
|
|
851
|
+
/* ---- バージョン ----
|
|
852
|
+
プランナーと CLI (addashboard-cli) は同じ npm パッケージで配られるので**同じ版数**を名乗る。
|
|
853
|
+
正は cli/package.json の version で、この行は `npm run version:sync` が書き換える
|
|
854
|
+
(cli/test/unit/plannerVersion.test.ts が package.json との一致を検証するので、
|
|
855
|
+
手で書き換えて放置するとテストが落ちる)。
|
|
856
|
+
利用者が「どの版のプランナーを開いているか」を言えないと、不具合報告が噛み合わない。 */
|
|
857
|
+
const APP_VERSION = '0.9.4'; /* version:sync */
|
|
845
858
|
/* 案A: embed廃止。参照データ(refs)・初期プラン・共通設定は外部JSONから起動時に読み込む。
|
|
846
859
|
同梱デフォルト: アカウント/<媒体>/index.json の先頭アカウント (refs+principal+projectName+baseArticleUrl) と サンプルプラン.json (project+campaigns)。
|
|
847
860
|
固有名詞はハードコードせず、すべて外部JSON由来にする。 */
|
|
@@ -916,7 +929,34 @@ const firstNonEmpty = (...vs) => { for(const v of vs){ const s=String(v==null?''
|
|
|
916
929
|
**URL構造を壊す文字だけ**をパーセントエンコードし、日本語はそのまま残す
|
|
917
930
|
(計測ツール上での可読性を優先。1パスなので %→%25 の二重エンコードは起きない)。 */
|
|
918
931
|
const urlq = s => String(s==null?'':s).replace(/[%&#?=+\s]/g, c=>encodeURIComponent(c));
|
|
919
|
-
|
|
932
|
+
/**
|
|
933
|
+
* 画面下の通知。既定は 2.2 秒で消える。
|
|
934
|
+
*
|
|
935
|
+
* `{sticky:true}` は**自動で消さない**。書き出しをブロックしたときに使う
|
|
936
|
+
* (原因を読み返せないと直しようがない)。閉じるボタンと Esc で消す。
|
|
937
|
+
*/
|
|
938
|
+
function toast(m, opts){
|
|
939
|
+
const t=document.getElementById('toast');
|
|
940
|
+
const sticky=!!(opts&&opts.sticky);
|
|
941
|
+
clearTimeout(t._h);
|
|
942
|
+
t.textContent=m;
|
|
943
|
+
t.classList.toggle('sticky', sticky);
|
|
944
|
+
t.classList.add('show');
|
|
945
|
+
if(sticky){
|
|
946
|
+
const x=document.createElement('span');
|
|
947
|
+
x.className='tclose'; x.textContent='×'; x.title='閉じる (Esc)';
|
|
948
|
+
x.onclick=()=>t.classList.remove('show');
|
|
949
|
+
t.appendChild(x);
|
|
950
|
+
return;
|
|
951
|
+
}
|
|
952
|
+
t._h=setTimeout(()=>t.classList.remove('show'),2200);
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
/** 自動で消えないトーストを閉じる(Esc / 閉じるボタン)。 */
|
|
956
|
+
function dismissStickyToast(){
|
|
957
|
+
const t=document.getElementById('toast');
|
|
958
|
+
if(t && t.classList.contains('sticky')) t.classList.remove('show');
|
|
959
|
+
}
|
|
920
960
|
|
|
921
961
|
/* ---- エラー / 警告の安定コード (媒体非依存の語彙) ----
|
|
922
962
|
S2: UI判定 (セル赤枠 / ghost / ツリーのドット) は日本語文字列ではなくこのコードで行う。
|
|
@@ -939,6 +979,10 @@ const EC = {
|
|
|
939
979
|
LONG_HEADLINE_MISSING:'LONG_HEADLINE_MISSING' /* 動画広告は長見出しが必須 */,
|
|
940
980
|
BUSINESS_NAME_MISSING:'BUSINESS_NAME_MISSING' /* proto 上 REQUIRED */,
|
|
941
981
|
LOGO_MISSING:'LOGO_MISSING' /* 画像広告はロゴ 1-5 件が必須 */,
|
|
982
|
+
LOGO_FOREIGN_ACCOUNT:'LOGO_FOREIGN_ACCOUNT' /* ロゴが別 Customer の asset = 入稿時に必ず落ちる */,
|
|
983
|
+
LOGO_SOURCE_CONFLICT:'LOGO_SOURCE_CONFLICT' /* 登録済みIDとローカルパスの同時指定 = 二重にアセットを作る */,
|
|
984
|
+
CAMPAIGN_NAME_DUPLICATE:'CAMPAIGN_NAME_DUPLICATE' /* 既存CPと同名。Google は同名を拒否する */,
|
|
985
|
+
CAMPAIGN_NAME_DUPLICATE_IN_PLAN:'CAMPAIGN_NAME_DUPLICATE_IN_PLAN' /* 同じ計画の中で新規CP名が重複 */,
|
|
942
986
|
LOCATION_EMPTY:'LOCATION_EMPTY' /* 地域未指定。google は全世界配信 (warn) / bytedance は入稿不可 (err) */,
|
|
943
987
|
/* ByteDance (TikTok) 固有 */
|
|
944
988
|
IDENTITY_MISSING:'IDENTITY_MISSING' /* 広告主として表示する TikTok アカウント未設定 = 広告が作れない */,
|
|
@@ -1018,6 +1062,7 @@ function renderChrome(){
|
|
|
1018
1062
|
const P=pf(); if(!P) return;
|
|
1019
1063
|
try{ document.title = P.brandTitle+' / 媒体要件 — WEBGRAM'; }catch(e){}
|
|
1020
1064
|
const bl=document.getElementById('brandLogo'); if(bl) bl.textContent=P.brandTitle;
|
|
1065
|
+
const av=document.getElementById('appVer'); if(av) av.textContent='v'+APP_VERSION;
|
|
1021
1066
|
const stLabel={ads:P.levels.ad.label, groups:P.levels.adGroup.label, campaigns:P.levels.campaign.label};
|
|
1022
1067
|
document.querySelectorAll('#sheetTabs button[data-st]').forEach(b=>{
|
|
1023
1068
|
const l=stLabel[b.dataset.st]; if(l) b.textContent=l;
|
|
@@ -1512,6 +1557,12 @@ PLATFORMS.yda = {
|
|
|
1512
1557
|
const P=PLATFORMS.yda, p=ctx.project;
|
|
1513
1558
|
const bidLabel=String(c.biddingStrategy||'').trim(); // A-1: キャンペーン個別値のみ (project既定へフォールバックしない)
|
|
1514
1559
|
const biddingStrategy=P.enums.bidding[bidLabel]||bidLabel||P.enums.biddingFallback;
|
|
1560
|
+
/* S15: 既存キャンペーン呼び出しは「広告追加専用」で、予算・入札はプランナー上で編集できない
|
|
1561
|
+
(isExisting=true → 予算セルが未入力のまま)。それを Number(null)||0 で 0 として書き出すと
|
|
1562
|
+
backend の zod (budget は正の整数) に引っかかり dry-run が budget must be > 0 で落ちる。
|
|
1563
|
+
backend は既存CPでは budget/goal/入札を Yahoo 側へ送らない (ignoredFields) ので、
|
|
1564
|
+
**持っていない値はキー自体を出さない**のが正しい。 */
|
|
1565
|
+
const existing=isExistingCampaign(c);
|
|
1515
1566
|
const cp={
|
|
1516
1567
|
name:c.name,
|
|
1517
1568
|
goal:p.goal||'CONVERSION',
|
|
@@ -1519,10 +1570,10 @@ PLATFORMS.yda = {
|
|
|
1519
1570
|
/* S12/I-YDA-TIME: Yahoo Ads API は startDate(yyyyMMdd 相当) と startTime(HHmm) の2フィールド。
|
|
1520
1571
|
日付と時刻を分けて送る (公式: startTime を指定するときは startDate が必須)。 */
|
|
1521
1572
|
startDate:dateOnly(p.startDate)||defaultStart(), /* 既定は翌日 */
|
|
1522
|
-
budget:Number(c.budget)||0, // A-1: キャンペーン個別値のみ
|
|
1523
1573
|
biddingStrategy,
|
|
1524
1574
|
adGroups:[]
|
|
1525
1575
|
};
|
|
1576
|
+
if(!existing) cp.budget=Number(c.budget)||0; // A-1: キャンペーン個別値のみ
|
|
1526
1577
|
/* 開始時刻 (HHmm)。案件設定が時刻を持たないときは既定 05:00 を使う。
|
|
1527
1578
|
空欄になるのは「日付だけを明示的に入れた」ケースなので、そのときは startTime を送らない。 */
|
|
1528
1579
|
const stTime = p.startDate ? timeHHmm(p.startDate) : timeHHmm(defaultStartDateTime());
|
|
@@ -1538,8 +1589,12 @@ PLATFORMS.yda = {
|
|
|
1538
1589
|
cp.frequencyTimeUnit=fe.unit;
|
|
1539
1590
|
cp.vImps=fe.count;
|
|
1540
1591
|
}
|
|
1541
|
-
|
|
1542
|
-
|
|
1592
|
+
/* 既存CPは入札もプランナー上で持たない (biddingStrategy は '')。0 を書き出しても
|
|
1593
|
+
backend は無視するが、「反映される値」に見えるので出さない。 */
|
|
1594
|
+
if(!existing){
|
|
1595
|
+
if(bidLabel==='tCPA'){ cp.targetCpa=Number(c.tcpa)||0; }
|
|
1596
|
+
else if(biddingStrategy==='CPC'){ cp.cpc=Number(c.adGroups[0]&&c.adGroups[0].bid)||0; }
|
|
1597
|
+
}
|
|
1543
1598
|
const cid=resolveCampaignIdOf(c); if(cid) cp.campaignId=cid; // Phase C: 既存はIDを出力(backendで作成スキップ)。新規はID無し=作成対象
|
|
1544
1599
|
return cp;
|
|
1545
1600
|
},
|
|
@@ -1973,7 +2028,23 @@ function renderTplChips(){
|
|
|
1973
2028
|
};
|
|
1974
2029
|
const num=(f,v,w,d)=>'<input class="chipin" type="number" data-proj="'+f+'" value="'+esc(v)+'" style="width:'+(w||'6ch')+'"'+hint(d)+'>';
|
|
1975
2030
|
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>';
|
|
2031
|
+
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>';
|
|
2032
|
+
/* S15: kind:'refselect' = 参照表 (st.refs[refKey]) から選ぶチップ。
|
|
2033
|
+
**参照表が空のあいだは従来のテキスト入力にフォールバックする**。refs-import 前や
|
|
2034
|
+
参照表を持たないアカウントで「選べる候補がゼロのプルダウン」になると、値を入れる手段が
|
|
2035
|
+
消えて後退するため。現在値が参照表に無いときは末尾に option として残す
|
|
2036
|
+
(手打ちした値・旧プランの値を再描画で消さない)。 */
|
|
2037
|
+
const refsel=(f,v,d)=>{
|
|
2038
|
+
const table=(st.refs && st.refs[f.refKey]) || {};
|
|
2039
|
+
const keys=Object.keys(table);
|
|
2040
|
+
if(!keys.length) return txt(f.key, v, f.width, d);
|
|
2041
|
+
const opts=[{v:'', t:'(未選択)'}];
|
|
2042
|
+
keys.forEach(k=>opts.push({v:k, t:(f.optionLabel? f.optionLabel(k, table[k]) : k)}));
|
|
2043
|
+
const cur=String(v||'');
|
|
2044
|
+
if(cur && !Object.prototype.hasOwnProperty.call(table, cur))
|
|
2045
|
+
opts.push({v:cur, t:cur+'(一覧に無い値)'});
|
|
2046
|
+
return selv(f.key, cur, opts, d);
|
|
2047
|
+
};
|
|
1977
2048
|
/* C1: kind:'toggle' = チェックボックス。値は真偽で持ち、**未定義は fallback**(欠落=従来挙動)。
|
|
1978
2049
|
`p[key]||fallback` では false が fallback(true) に化けるので、toggle だけは別扱いにする。 */
|
|
1979
2050
|
const tgl=(f,v,d)=>'<input class="chipin" type="checkbox" data-proj="'+f+'"'+(v?' checked':'')+hint(d)+'>';
|
|
@@ -2013,6 +2084,7 @@ function renderTplChips(){
|
|
|
2013
2084
|
const ctrl = f.kind==='freq' ? freq(f)
|
|
2014
2085
|
: f.kind==='toggle' ? tgl(f.key, v, f)
|
|
2015
2086
|
: f.kind==='datetime' ? dt(f.key, v, f)
|
|
2087
|
+
: f.kind==='refselect' ? refsel(f, v, f)
|
|
2016
2088
|
: f.kind==='selectv' ? selv(f.key, v, f.options)
|
|
2017
2089
|
: f.kind==='select' ? sel(f.key, v, f.options)
|
|
2018
2090
|
: f.kind==='number' ? num(f.key, v, f.width, f)
|
|
@@ -2749,6 +2821,16 @@ const META_COLS = {
|
|
|
2749
2821
|
},
|
|
2750
2822
|
title:'空欄にすると上限なし(age_max を送りません)。特別広告カテゴリ選択時は 65 固定となり上限なしは無視されます'},
|
|
2751
2823
|
{field:'tg_gender', label:'性別', kind:'tgpick', tgKey:'tg_gender', readonlyOnExisting:true, roEmptyDash:true},
|
|
2824
|
+
/* S16: Advantage+ オーディエンス (targeting.targeting_automation.advantage_audience)。
|
|
2825
|
+
**性別を指定するときは Meta が必須にする**(無いと広告セット作成が 1870227 で落ちる。
|
|
2826
|
+
2026-09-03 に v26.0 の実 API で確認)。空欄 = オン(1) で、これは従来 Meta が暗黙に
|
|
2827
|
+
付けていた値と同じ。オフ(0) にすると指定した年齢・性別どおりに限定して配信される。 */
|
|
2828
|
+
{field:'advantageAudience', label:'Advantage+', kind:'select', options:['','オン','オフ'], minWidth:'78px',
|
|
2829
|
+
readonlyOnExisting:true, roEmptyDash:true,
|
|
2830
|
+
coerce:v=>{const t=String(v==null?'':v).trim(); return (t==='オン'||t==='オフ')?t:'';},
|
|
2831
|
+
title:'Advantage+ オーディエンス。空欄・オンなら Meta が指定した年齢・性別・オーディエンスを'
|
|
2832
|
+
+'「出発点」として対象を広げます。オフにすると指定どおりに限定して配信します。'
|
|
2833
|
+
+'(性別を指定する広告セットでは Meta 側でこの指定が必須です)'},
|
|
2752
2834
|
{field:'tg_device', label:'デバイス', kind:'tgpick', tgKey:'tg_device', readonlyOnExisting:true, roEmptyDash:true},
|
|
2753
2835
|
{field:'tg_placement', label:'配置', kind:'tgpick', tgKey:'tg_placement', readonlyOnExisting:true, roEmptyDash:true},
|
|
2754
2836
|
/* 配信日時: 空欄 = 案件設定チップの値を継承(buildPlan 側で解決)。placeholder に継承先を出す */
|
|
@@ -2903,6 +2985,8 @@ PLATFORMS.meta = {
|
|
|
2903
2985
|
mappers:{
|
|
2904
2986
|
devices(s, P){ return mapByEnum(s, P.enums.device, P.targeting.tg_device.allLabel); },
|
|
2905
2987
|
genders(s, P){ return mapByEnum(s, P.enums.gender, P.targeting.tg_gender.allLabel); },
|
|
2988
|
+
/* S16: 空欄・未知の値・旧プラン(キー無し)はオン(1)。'オフ' のときだけ 0 */
|
|
2989
|
+
advantageAudience(v){ return String(v==null?'':v).trim()==='オフ' ? 0 : 1; },
|
|
2906
2990
|
placements(s, P){
|
|
2907
2991
|
const l=tgList(s);
|
|
2908
2992
|
if(!l.length || l.indexOf(P.targeting.tg_placement.allLabel)>=0)
|
|
@@ -3017,7 +3101,7 @@ PLATFORMS.meta = {
|
|
|
3017
3101
|
ad:{format:'IMAGE', imageNo:'', titleNo:'', descNo:'', adName:'', paramCode:''},
|
|
3018
3102
|
adGroup:{name:'', paramCode:'', isExisting:false, dailyBudget:1000, bidAmount:null,
|
|
3019
3103
|
ageMin:18, ageMax:65, tg_gender:'すべて', tg_device:'すべてのデバイス',
|
|
3020
|
-
tg_placement:'自動配置', startDate:'', endDate:'', deliveryStatus:''},
|
|
3104
|
+
tg_placement:'自動配置', advantageAudience:'', startDate:'', endDate:'', deliveryStatus:''},
|
|
3021
3105
|
campaign:{name:'新規キャンペーン', campaignId:'', isExisting:false,
|
|
3022
3106
|
objective:'OUTCOME_SALES', specialAdCategory:'NONE', urlNumber:''}
|
|
3023
3107
|
},
|
|
@@ -3223,6 +3307,10 @@ PLATFORMS.meta = {
|
|
|
3223
3307
|
: (String(g.ageMax==null?'':g.ageMax).trim()!=='' && Number(g.ageMax)>0
|
|
3224
3308
|
? {ageMax:Number(g.ageMax)} : {})),
|
|
3225
3309
|
genders: constrained?[]:P.mappers.genders(g.tg_gender, P),
|
|
3310
|
+
/* S16: Advantage+ オーディエンスは**常に送る**。性別を指定した広告セットだけ
|
|
3311
|
+
1870227 で落ちる、という条件付きの不具合を再発させないため。
|
|
3312
|
+
旧プラン(キー無し)・空欄はオン(1) = 従来 Meta が暗黙に付けていた値。 */
|
|
3313
|
+
advantageAudience: P.mappers.advantageAudience(g.advantageAudience),
|
|
3226
3314
|
devicePlatforms:P.mappers.devices(g.tg_device, P),
|
|
3227
3315
|
placements:P.mappers.placements(g.tg_placement, P),
|
|
3228
3316
|
customAudiences:[], excludedAudiences:[], /* ID解決が必要なため常に空 */
|
|
@@ -3436,6 +3524,19 @@ const GOOGLE_REF_TABLES = {
|
|
|
3436
3524
|
keyCell:no=>'<td class="mono">'+esc(no)+'</td>',
|
|
3437
3525
|
columns:[
|
|
3438
3526
|
{label:'キャンペーン名', cls:'reftext', attr:(no,v)=>' title="'+esc(v&&v.name||'')+'"', cell:(no,v)=>esc(v&&v.name||'')}
|
|
3527
|
+
] },
|
|
3528
|
+
/* ロゴは番号を振らず resource_name をキーにする。asset の resource_name は
|
|
3529
|
+
Google 側で一意なので、番号を振ると同じロゴに2つの番号が生まれて参照がぶれる。
|
|
3530
|
+
取り込みは `adops.sh refs-import google <accountId> --write`。 */
|
|
3531
|
+
logos:{ key:'logos', label:'ロゴ', countLabel:'ロゴ', editable:false, required:false,
|
|
3532
|
+
keyKind:'string', keyLabel:'アセット', keyWidth:'260px',
|
|
3533
|
+
emptyMsg:'ロゴがありません(refs-import google で取り込むか、ロゴアセットを手で入力してください)',
|
|
3534
|
+
valueFields:[{k:'name',label:'名'}],
|
|
3535
|
+
keyCell:no=>'<td class="mono">'+esc(no)+'</td>',
|
|
3536
|
+
columns:[
|
|
3537
|
+
{label:'名前', cls:'reftext', attr:(no,v)=>' title="'+esc(v&&v.name||'')+'"', cell:(no,v)=>esc(v&&v.name||'')},
|
|
3538
|
+
{label:'サイズ', width:'100px', cls:'mono',
|
|
3539
|
+
cell:(no,v)=>esc(v&&v.width? String(v.width)+'x'+String(v.height) : '')}
|
|
3439
3540
|
] }
|
|
3440
3541
|
};
|
|
3441
3542
|
|
|
@@ -3527,7 +3628,13 @@ PLATFORMS.google = {
|
|
|
3527
3628
|
if(obj.name!=null) project.accountName=String(obj.name);
|
|
3528
3629
|
if(obj.principal!=null) project.principal=String(obj.principal);
|
|
3529
3630
|
if(obj.businessName!=null) project.businessName=String(obj.businessName);
|
|
3530
|
-
|
|
3631
|
+
/* ロゴはアカウント固有。切替時に前のアカウントの値を持ち越すと、
|
|
3632
|
+
**別 Customer に削除できないアセットを作る**。
|
|
3633
|
+
logoAsset は別Customer検証で止まるが、ローカルパスには効かない
|
|
3634
|
+
(パスは resource_name ではないので所有者を判定できない)。
|
|
3635
|
+
どちらもアカウントJSONの値で置き換え、無ければ空に戻す。 */
|
|
3636
|
+
project.logoAsset = obj.logoAsset!=null ? String(obj.logoAsset) : '';
|
|
3637
|
+
project.logoImagePath = '';
|
|
3531
3638
|
if(obj.consoleMcc!=null) project.consoleMcc=String(obj.consoleMcc).replace(/-/g,'');
|
|
3532
3639
|
if(obj.projectName) project.name=obj.projectName;
|
|
3533
3640
|
if(obj.baseArticleUrl) project.baseArticleUrl=obj.baseArticleUrl;
|
|
@@ -3574,8 +3681,25 @@ PLATFORMS.google = {
|
|
|
3574
3681
|
projectFields:[
|
|
3575
3682
|
{key:'businessName', label:'ブランド名', kind:'text', width:'14ch', fallback:'',
|
|
3576
3683
|
title:'広告に表示される広告主名。Demand Gen 広告では必須(表示幅25まで)'},
|
|
3577
|
-
|
|
3578
|
-
|
|
3684
|
+
/* 参照表 refs.logos があれば選択、無ければ従来どおり手打ち(refselect のフォールバック)。
|
|
3685
|
+
resource_name は長く手写しで取り違えやすいので、取り込み済みなら選ばせる。 */
|
|
3686
|
+
{key:'logoAsset', label:'ロゴアセット', kind:'refselect', width:'18ch', fallback:'',
|
|
3687
|
+
refKey:'logos',
|
|
3688
|
+
optionLabel:(rn, row)=>{
|
|
3689
|
+
const size=(row&&row.width)? row.width+'x'+row.height : '';
|
|
3690
|
+
const name=(row&&row.name)||rn.split('/').pop();
|
|
3691
|
+
return name+(size? ' ('+size+')' : '');
|
|
3692
|
+
},
|
|
3693
|
+
title:'ロゴ画像アセットの resource_name。画像広告では必須(1:1・128x128以上)。'
|
|
3694
|
+
+'候補は refs-import google で取り込む'},
|
|
3695
|
+
/* ロゴが未登録のアカウント向け。本実行の直前に CLI がアップロードして
|
|
3696
|
+
resource_name に解決する(dry-run では上げない)。上の「ロゴアセット」と
|
|
3697
|
+
同時に入れると二重にアセットを作るので、書き出しで止める。 */
|
|
3698
|
+
{key:'logoImagePath', label:'ロゴ画像パス', kind:'text', width:'16ch', fallback:'',
|
|
3699
|
+
placeholder:'素材/ロゴ.png',
|
|
3700
|
+
title:'ロゴが未登録のときに使う。ローカル画像のパス(入稿計画JSONからの相対 or 絶対)。'
|
|
3701
|
+
+'入稿時に自動でアップロードされます。1:1・128x128以上・GIF/JPEG/PNG。'
|
|
3702
|
+
+'カンマか改行で複数指定可(最大5件)。「ロゴアセット」と同時には使えません'},
|
|
3579
3703
|
{key:'cta', label:'CTA', kind:'select',
|
|
3580
3704
|
options:['詳しくはこちら','購入する','登録する','ダウンロード','お問い合わせ','今すぐ申し込む',
|
|
3581
3705
|
'予約する','見積もりを取得','購読する','インストール','サイトへアクセス','もっと見る','ボタンなし'],
|
|
@@ -3614,6 +3738,10 @@ PLATFORMS.google = {
|
|
|
3614
3738
|
|
|
3615
3739
|
mappers:{
|
|
3616
3740
|
status(s, P){ return s==='オン'?P.enums.statusOn:P.enums.statusOff; },
|
|
3741
|
+
/* 1つのテキスト欄で複数のロゴを受ける。カンマ・改行のどちらでも区切れる */
|
|
3742
|
+
logoImagePaths(v){
|
|
3743
|
+
return String(v||'').split(/[,\n]/).map(x=>x.trim()).filter(Boolean);
|
|
3744
|
+
},
|
|
3617
3745
|
locationIds(s, P){ return mapByEnum(s, P.enums.location, P.targeting.tg_location.allLabel); },
|
|
3618
3746
|
languageIds(s, P){ return mapByEnum(s, P.enums.language, P.targeting.tg_language.allLabel); },
|
|
3619
3747
|
/* 配信面。「すべて」= 指定しない (キー自体を出さず Google 既定に任せる)。
|
|
@@ -3633,7 +3761,7 @@ PLATFORMS.google = {
|
|
|
3633
3761
|
newCampaignBudget:12000,
|
|
3634
3762
|
targetCpa:8500,
|
|
3635
3763
|
project:{
|
|
3636
|
-
businessName:'', logoAsset:'', cta:'詳しくはこちら',
|
|
3764
|
+
businessName:'', logoAsset:'', logoImagePath:'', cta:'詳しくはこちら',
|
|
3637
3765
|
deliveryStatus:'オフ'
|
|
3638
3766
|
},
|
|
3639
3767
|
ad:{format:'VIDEO', imageNo:'', titleNo:'', longNo:'', descNo:'', adName:''},
|
|
@@ -3731,7 +3859,12 @@ PLATFORMS.google = {
|
|
|
3731
3859
|
if(p.urlParams!==false && String(group.paramCode||'').trim()==='') pushErr('paramCode未入力', EC.PARAM_CODE_EMPTY);
|
|
3732
3860
|
/* Demand Gen の必須 (proto: business_name は REQUIRED / 画像広告の logo_images は 1-5 件) */
|
|
3733
3861
|
if(String(p.businessName||'').trim()==='') pushErr('ブランド名未入力', EC.BUSINESS_NAME_MISSING);
|
|
3734
|
-
|
|
3862
|
+
/* 登録済みID・ローカルパスのどちらか。未登録アカウントはパスだけで入稿できる
|
|
3863
|
+
(両方あるのは validateProject が書き出し前に止める) */
|
|
3864
|
+
if(fmt.id==='IMAGE'
|
|
3865
|
+
&& String(p.logoAsset||'').trim()===''
|
|
3866
|
+
&& !P.mappers.logoImagePaths(p.logoImagePath).length)
|
|
3867
|
+
pushErr('ロゴ未入力(ロゴアセット または ロゴ画像パス)', EC.LOGO_MISSING);
|
|
3735
3868
|
/* 地域未指定は全世界配信になる。作成自体は通るのでエラーではなく警告 */
|
|
3736
3869
|
if(!P.mappers.locationIds(group.tg_location, P).length) pushWarn('地域未指定(全世界配信)', EC.LOCATION_EMPTY);
|
|
3737
3870
|
if(pendingImage) pushWarn(fmt.id==='VIDEO'?'YouTube動画ID未登録':'画像アセット未登録', EC.MEDIA_MISSING);
|
|
@@ -3763,8 +3896,34 @@ PLATFORMS.google = {
|
|
|
3763
3896
|
errs.push({code:EC.BUSINESS_NAME_MISSING, msg:'ブランド名を共通設定に入力してください(Demand Gen 広告で必須)'});
|
|
3764
3897
|
/* 画像広告を1件でも含むならロゴが要る (動画だけなら任意) */
|
|
3765
3898
|
const hasImageAd=((state&&state.campaigns)||[]).some(c=>(c.adGroups||[]).some(g=>(g.ads||[]).some(a=>a.format!=='VIDEO')));
|
|
3766
|
-
|
|
3767
|
-
|
|
3899
|
+
const logo=String((p&&p.logoAsset)||'').trim();
|
|
3900
|
+
const logoPaths=PLATFORMS.google.mappers.logoImagePaths(p&&p.logoImagePath);
|
|
3901
|
+
if(hasImageAd && !logo && !logoPaths.length)
|
|
3902
|
+
errs.push({code:EC.LOGO_MISSING,
|
|
3903
|
+
msg:'画像広告があるためロゴが必須です。登録済みなら「ロゴアセット」、未登録なら「ロゴ画像パス」を入れてください(1:1・128x128以上)'});
|
|
3904
|
+
/* 両方あると CLI が余計にアップロードし、削除できないアセットが増える */
|
|
3905
|
+
if(logo && logoPaths.length)
|
|
3906
|
+
errs.push({code:EC.LOGO_SOURCE_CONFLICT,
|
|
3907
|
+
msg:'「ロゴアセット」と「ロゴ画像パス」は同時に指定できません(どちらか一方にしてください)'});
|
|
3908
|
+
if(logoPaths.length>5)
|
|
3909
|
+
errs.push({code:EC.LOGO_SOURCE_CONFLICT,
|
|
3910
|
+
msg:'ロゴ画像パスは 5 件までです(received '+logoPaths.length+')'});
|
|
3911
|
+
/* asset は Customer をまたいで参照できない。別アカウントの resource_name を貼ると
|
|
3912
|
+
入稿時に必ず落ちるので、書き出しの手前で止める。アカウントJSONを取り違えたまま
|
|
3913
|
+
ロゴだけ前のアカウントの値が残る、が実際に起こりうる経路。
|
|
3914
|
+
**ローカルパスはこの検査に掛けない**(./logo.png を「別アカウント」と誤判定する)。 */
|
|
3915
|
+
if(logo){
|
|
3916
|
+
/* 終端まで締める。前方一致だと customers/<id>/assets/ や .../assets/abc が
|
|
3917
|
+
通り、形式は合っているのに入稿で落ちる値を書き出してしまう。 */
|
|
3918
|
+
const m=/^customers\/(\d+)\/assets\/(\d+)$/.exec(logo);
|
|
3919
|
+
const acc=String((p&&p.accountId)||'').trim();
|
|
3920
|
+
if(!m)
|
|
3921
|
+
errs.push({code:EC.LOGO_FOREIGN_ACCOUNT,
|
|
3922
|
+
msg:'ロゴアセットの形式が違います(customers/<アカウントID>/assets/<ID>): '+logo});
|
|
3923
|
+
else if(acc && m[1]!==acc)
|
|
3924
|
+
errs.push({code:EC.LOGO_FOREIGN_ACCOUNT,
|
|
3925
|
+
msg:'ロゴアセットが別アカウント('+m[1]+')のものです。現在のアカウントは '+acc+' です'});
|
|
3926
|
+
}
|
|
3768
3927
|
/* 予算はキャンペーン階層。コアの書き出しは広告グループ行しか検証しないため、
|
|
3769
3928
|
ここで見ないと budgetAmountYen:0 のまま書き出され dry-run まで気づけない。 */
|
|
3770
3929
|
((state&&state.campaigns)||[]).forEach(c=>{
|
|
@@ -3772,6 +3931,87 @@ PLATFORMS.google = {
|
|
|
3772
3931
|
if(!(Number(c.dailyBudget)>0))
|
|
3773
3932
|
errs.push({code:EC.BUDGET_MISSING, msg:'キャンペーン「'+String(c.name||'')+'」の日予算を入力してください'});
|
|
3774
3933
|
});
|
|
3934
|
+
errs.push(...PLATFORMS.google.checkCampaignNames(state));
|
|
3935
|
+
return errs;
|
|
3936
|
+
},
|
|
3937
|
+
|
|
3938
|
+
/**
|
|
3939
|
+
* キャンペーン名の重複を見る。**Google は同名キャンペーンを拒否する**
|
|
3940
|
+
* (実測: Trying to modify the name of an active or paused campaign,
|
|
3941
|
+
* where the name is already assigned to another active or paused campaign.)。
|
|
3942
|
+
* dry-run は実 API を叩かないので通ってしまい、本実行で初めて落ちる。
|
|
3943
|
+
*
|
|
3944
|
+
* 見るのは 2 種類:
|
|
3945
|
+
* 1. アカウントの既存CP(`refs.campaigns`)との衝突。**スナップショットなので
|
|
3946
|
+
* 古いと取りこぼす** — 実データとの突き合わせは CLI / backend が本実行の直前に行う
|
|
3947
|
+
* 2. 同じ計画の中での衝突。こちらは鮮度と無関係にローカルで確定する
|
|
3948
|
+
*
|
|
3949
|
+
* **既存CP行も対象にする。** Google は既存CPへの広告追加が未対応で、`buildPlan.campaign`
|
|
3950
|
+
* は `campaignId` を出さない(backend の zod が落とすため)。つまり「既存CPに足す」
|
|
3951
|
+
* つもりの行も**同名の新規CPとして書き出される**ので、除外すると
|
|
3952
|
+
* 「プランナーは通るのに CLI / backend で落ちる」経路が残る。
|
|
3953
|
+
*/
|
|
3954
|
+
checkCampaignNames(state){
|
|
3955
|
+
const errs=[];
|
|
3956
|
+
const known=new Map(); // 正規化名 → 元の表記
|
|
3957
|
+
Object.values((state&&state.refs&&state.refs.campaigns)||{}).forEach(v=>{
|
|
3958
|
+
/* stale = 媒体側から消えたCP。削除済みの名前は再利用できるので、
|
|
3959
|
+
残したままだと使える名前を「重複」と誤判定する */
|
|
3960
|
+
if(v && v.stale) return;
|
|
3961
|
+
const n=String((v&&v.name)||'').trim();
|
|
3962
|
+
if(n) known.set(n.toLowerCase(), n);
|
|
3963
|
+
});
|
|
3964
|
+
|
|
3965
|
+
/* **手つかずの行は判定しない。** コアは広告が1件も解決しないキャンペーンを
|
|
3966
|
+
書き出しから落とす(stats.emptyCampaigns)。既定のCP名は固定文字列なので、
|
|
3967
|
+
「+ 新規キャンペーン」を2回押しただけで送られもしない行どうしが重複扱いになり、
|
|
3968
|
+
**完成している他のキャンペーンごと書き出しがブロックされていた**。
|
|
3969
|
+
ただし「中身はあるが別の理由で解決していない」行は対象に残す
|
|
3970
|
+
(ブランド名未入力などで一時的に全滅しているだけ、を取りこぼさないため)。 */
|
|
3971
|
+
const DA=PLATFORMS.google.defaults.ad;
|
|
3972
|
+
const pristineAd=a=>Object.keys(DA).every(k=>{
|
|
3973
|
+
const d=DA[k];
|
|
3974
|
+
if(d && typeof d==='object') return true; // 入れ子は見ない
|
|
3975
|
+
return String((a&&a[k])==null?'':a[k])===String(d==null?'':d);
|
|
3976
|
+
});
|
|
3977
|
+
const hasContent=c=>(((c&&c.adGroups)||[]).some(g=>(((g&&g.ads)||[]).some(a=>!pristineAd(a)))));
|
|
3978
|
+
const newCps=((state&&state.campaigns)||[]).filter(hasContent);
|
|
3979
|
+
const planNames=new Set(newCps.map(c=>String(c.name||'').trim().toLowerCase()).filter(Boolean));
|
|
3980
|
+
/* 提案が既存とも計画内とも衝突しないことを保証する。片方だけ見ると
|
|
3981
|
+
「提案どおりに直したらプラン内で重複した」が起きる。
|
|
3982
|
+
**出した候補も taken に足す** — 足さないと同名2行に同じ候補を出してしまい、
|
|
3983
|
+
両方とも提案どおりに直すと重複が再発する。 */
|
|
3984
|
+
const taken=new Set([...known.keys(), ...planNames]);
|
|
3985
|
+
const suggest=(base)=>{
|
|
3986
|
+
const stem=base.replace(/_(\d+)$/,''); // 既に _2 なら _3 以降から
|
|
3987
|
+
const out=[];
|
|
3988
|
+
for(let i=2; out.length<3 && i<100; i++){
|
|
3989
|
+
const cand=stem+'_'+i;
|
|
3990
|
+
if(!taken.has(cand.toLowerCase())){ out.push(cand); taken.add(cand.toLowerCase()); }
|
|
3991
|
+
}
|
|
3992
|
+
return out;
|
|
3993
|
+
};
|
|
3994
|
+
|
|
3995
|
+
const seen=new Map();
|
|
3996
|
+
newCps.forEach(c=>{
|
|
3997
|
+
const raw=String(c.name||'').trim();
|
|
3998
|
+
if(!raw) return;
|
|
3999
|
+
const key=raw.toLowerCase();
|
|
4000
|
+
if(known.has(key)){
|
|
4001
|
+
const alt=suggest(raw);
|
|
4002
|
+
errs.push({code:EC.CAMPAIGN_NAME_DUPLICATE,
|
|
4003
|
+
msg:'キャンペーン名「'+raw+'」はアカウントの既存キャンペーンと同じです。'
|
|
4004
|
+
+'Google は既存キャンペーンへの広告追加に未対応で、この行も新規作成として送られます'
|
|
4005
|
+
+'(同名は拒否されるため入稿時に落ちます)。'
|
|
4006
|
+
+(alt.length?('代替候補: '+alt.join(' / ')):'別の名前を付けてください')});
|
|
4007
|
+
}
|
|
4008
|
+
if(seen.has(key)){
|
|
4009
|
+
errs.push({code:EC.CAMPAIGN_NAME_DUPLICATE_IN_PLAN,
|
|
4010
|
+
msg:'キャンペーン名「'+raw+'」がこの計画の中で重複しています(2件以上)。'
|
|
4011
|
+
+'まとめて入稿すると2件目以降が落ちます'});
|
|
4012
|
+
}
|
|
4013
|
+
seen.set(key, true);
|
|
4014
|
+
});
|
|
3775
4015
|
return errs;
|
|
3776
4016
|
},
|
|
3777
4017
|
|
|
@@ -3816,15 +4056,21 @@ PLATFORMS.google = {
|
|
|
3816
4056
|
finalUrls:[r.finalUrl]
|
|
3817
4057
|
};
|
|
3818
4058
|
const logo=String(p.logoAsset||'').trim();
|
|
4059
|
+
const logoPaths=P.mappers.logoImagePaths(p.logoImagePath);
|
|
3819
4060
|
if(isVideo){
|
|
3820
4061
|
out.youtubeVideoId=String(r.mediaId);
|
|
3821
4062
|
out.longHeadlines=r.longText?[String(r.longText)]:[];
|
|
3822
4063
|
if(logo) out.logoImages=[logo];
|
|
4064
|
+
/* 動画広告のロゴは v23 では任意。ローカルパスからの作成は画像広告に絞る
|
|
4065
|
+
(動画のためだけに消せないアセットを増やさない) */
|
|
3823
4066
|
} else {
|
|
3824
4067
|
/* 画像は横長・正方形のうち登録があるものだけ送る (proto: どちらか必須) */
|
|
3825
4068
|
if(r.imageAsset) out.marketingImages=[String(r.imageAsset)];
|
|
3826
4069
|
if(r.squareAsset) out.squareMarketingImages=[String(r.squareAsset)];
|
|
3827
|
-
|
|
4070
|
+
/* 登録済みIDが優先。両方あるのは validateProject が書き出し前に止める */
|
|
4071
|
+
if(logo) out.logoImages=[logo];
|
|
4072
|
+
else if(logoPaths.length) out.logoImagePaths=logoPaths;
|
|
4073
|
+
else out.logoImages=[];
|
|
3828
4074
|
const cta=P.mappers.cta(p.cta, P); if(cta) out.callToActionText=cta;
|
|
3829
4075
|
}
|
|
3830
4076
|
return out;
|
|
@@ -5819,7 +6065,7 @@ function showPromptModal(opts){
|
|
|
5819
6065
|
root.innerHTML='<div class="overlay"><div class="modal"><h3>'+esc(opts.title)+'</h3>'
|
|
5820
6066
|
+(opts.sub?'<div class="msub">'+esc(opts.sub)+'</div>':'')
|
|
5821
6067
|
+'<div class="mbody">'+(opts.infoHtml||'')
|
|
5822
|
-
+'<div class="mfield"><label>Claude
|
|
6068
|
+
+'<div class="mfield"><label>'+esc(opts.promptLabel||'Claudeに貼るプロンプト')+'</label>'
|
|
5823
6069
|
+'<textarea id="promptText" readonly style="min-height:120px; font-family:var(--mono); font-size:12px; white-space:pre-wrap">'+esc(opts.prompt)+'</textarea>'
|
|
5824
6070
|
+(opts.hint?'<div class="hint">'+esc(opts.hint)+'</div>':'')+'</div>'
|
|
5825
6071
|
+'</div><div class="mfoot"><button class="act" data-mo="cancel">閉じる</button><button class="act primary" id="btnCopyPrompt">📋 プロンプトをコピー</button></div></div></div>';
|
|
@@ -5907,15 +6153,20 @@ async function exportSubmitJson(){
|
|
|
5907
6153
|
/* S10: 媒体スキーマの accountId 書式ガード (Meta は act_ 付き必須) */
|
|
5908
6154
|
const idPat=P.account&&P.account.idPattern;
|
|
5909
6155
|
if(idPat && !idPat.test(String(plan.accountId))){
|
|
5910
|
-
toast('アカウントIDの形式が不正です('+P.account.idHint+')'); return;
|
|
6156
|
+
toast('アカウントIDの形式が不正です('+P.account.idHint+')', {sticky:true}); return;
|
|
5911
6157
|
}
|
|
5912
6158
|
/* S10: 媒体固有のプロジェクト単位ガード (Meta の pageId / pixelId 必須) */
|
|
5913
6159
|
if(P.validateProject){
|
|
5914
6160
|
const perrs=P.validateProject(st.project, st)||[];
|
|
5915
|
-
|
|
6161
|
+
/* 複数まとめて出す。1件ずつ直して押し直す往復を減らす */
|
|
6162
|
+
if(perrs.length){
|
|
6163
|
+
const head=perrs.length>1 ? ('書き出しできません('+perrs.length+'件)\n') : '';
|
|
6164
|
+
toast(head+perrs.map(e=>'・'+e.msg).join('\n'), {sticky:true});
|
|
6165
|
+
return;
|
|
6166
|
+
}
|
|
5916
6167
|
}
|
|
5917
6168
|
const n=stats.exported;
|
|
5918
|
-
if(n===0){ toast('入稿対象の広告が0件です(空入稿を防止しました)'); return; }
|
|
6169
|
+
if(n===0){ toast('入稿対象の広告が0件です(空入稿を防止しました)', {sticky:true}); return; }
|
|
5919
6170
|
const json=JSON.stringify(plan,null,2);
|
|
5920
6171
|
// 完成したプランは「新規ファイル」として保存する(上書きしない・毎回タイムスタンプ付き)。
|
|
5921
6172
|
// 接続済み入稿計画フォルダがあればそこへ、無ければダウンロード。Finderは出さない。
|
|
@@ -5941,7 +6192,7 @@ async function exportSubmitJson(){
|
|
|
5941
6192
|
// S7: 文言・コマンドは pf().prompts.submitHelp / pf().output.submitCommand (媒体スキーマ)
|
|
5942
6193
|
const prompt=pf().prompts.submitHelp({fileName, savedRel, wrote,
|
|
5943
6194
|
pendingImages:stats.pendingImages, pendingImageNos:stats.pendingImageNos});
|
|
5944
|
-
showPromptModal({title:'入稿計画JSONを保存しました', sub:'
|
|
6195
|
+
showPromptModal({title:'入稿計画JSONを保存しました', sub:'このままClaude Codeが続き(dry-run)を実行します。自動で進まないときだけ下のプロンプトを貼ってください', infoHtml:info, prompt, promptLabel:'自動で進まないとき用のプロンプト', hint:'submit-plan は1キャンペーンずつ入稿・進捗書き戻しで再開可(submitted済みはスキップ)'});
|
|
5945
6196
|
}
|
|
5946
6197
|
|
|
5947
6198
|
/* ---- JSON読込 (v1/v2 両対応) ---- */
|
|
@@ -6132,6 +6383,9 @@ document.addEventListener('copy',e=>{
|
|
|
6132
6383
|
});
|
|
6133
6384
|
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
6385
|
document.addEventListener('keydown',e=>{
|
|
6386
|
+
/* 自動で消えないトーストはどのビューからでも出る(書き出しボタンは常設ヘッダー)。
|
|
6387
|
+
ビューのガードより手前で閉じないと、title の「閉じる (Esc)」と食い違う */
|
|
6388
|
+
if(e.key==='Escape') dismissStickyToast();
|
|
6135
6389
|
if(activeView!=='sheet'||planMode!=='sheet') return;
|
|
6136
6390
|
if(e.key==='Escape'){ closeDrawer(); clearSel(); return; }
|
|
6137
6391
|
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>]
|
|
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="$(
|
|
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
|
-
|
|
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.
|
|
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>]
|
|
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 ;;
|