addashboard-cli 0.13.0 → 0.15.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.
@@ -918,7 +918,7 @@ finalUrl = baseURL + paramCode + "?ad=" + paramCode + "&creative=" + …
918
918
  (cli/test/unit/plannerVersion.test.ts が package.json との一致を検証するので、
919
919
  手で書き換えて放置するとテストが落ちる)。
920
920
  利用者が「どの版のプランナーを開いているか」を言えないと、不具合報告が噛み合わない。 */
921
- const APP_VERSION = '0.13.0'; /* version:sync */
921
+ const APP_VERSION = '0.15.0'; /* version:sync */
922
922
  /* 案A: embed廃止。参照データ(refs)・初期プラン・共通設定は外部JSONから起動時に読み込む。
923
923
  同梱デフォルト: アカウント/<媒体>/index.json の先頭アカウント (refs+principal+projectName+baseArticleUrl) と サンプルプラン.json (project+campaigns)。
924
924
  固有名詞はハードコードせず、すべて外部JSON由来にする。 */
@@ -1173,6 +1173,10 @@ const EC = {
1173
1173
  LONG_HEADLINE_UNRESOLVED:'LONG_HEADLINE_UNRESOLVED',
1174
1174
  LONG_HEADLINE_TOO_LONG:'LONG_HEADLINE_TOO_LONG',
1175
1175
  LONG_HEADLINE_MISSING:'LONG_HEADLINE_MISSING' /* 動画広告は長見出しが必須 */,
1176
+ VIDEO_EXTRA_UNRESOLVED:'VIDEO_EXTRA_UNRESOLVED' /* 2本目以降の動画が未登録 = 足りない本数で入稿してしまう (Google 動画広告・#347) */,
1177
+ VIDEO_DUPLICATE:'VIDEO_DUPLICATE' /* 同じ YouTube 動画を2回選んでいる (backend が 400) */,
1178
+ VIDEO_TOO_MANY:'VIDEO_TOO_MANY' /* 動画が 5 本を超える */,
1179
+ VIDEO_EXTRA_IGNORED:'VIDEO_EXTRA_IGNORED' /* 画像広告に2本目以降の番号がある (warn・送らない) */,
1176
1180
  BUSINESS_NAME_MISSING:'BUSINESS_NAME_MISSING' /* proto 上 REQUIRED */,
1177
1181
  LOGO_MISSING:'LOGO_MISSING' /* 画像広告はロゴ 1-5 件が必須 */,
1178
1182
  LOGO_FOREIGN_ACCOUNT:'LOGO_FOREIGN_ACCOUNT' /* ロゴが別 Customer の asset = 入稿時に必ず落ちる */,
@@ -1185,6 +1189,7 @@ const EC = {
1185
1189
  THUMB_MISSING:'THUMB_MISSING' /* YDA は動画からサムネを自動生成しないので必ず要る */,
1186
1190
  VIDEO_PATH_IGNORED:'VIDEO_PATH_IGNORED' /* 動画 mediaID が解決済みなのにローカル動画パスも入力 (warn) */,
1187
1191
  BANNER_VIDEO_UNSUPPORTED:'BANNER_VIDEO_UNSUPPORTED' /* 広告タイプ=バナー × 形式=動画 は未対応 */,
1192
+ BANNER_CARD_UNSUPPORTED:'BANNER_CARD_UNSUPPORTED' /* 広告タイプ=バナー × 形式=Card(1200x628) は Yahoo が拒否する */,
1188
1193
  /* ByteDance (TikTok) 固有 */
1189
1194
  IDENTITY_MISSING:'IDENTITY_MISSING' /* 広告主として表示する TikTok アカウント未設定 = 広告が作れない */,
1190
1195
  IDENTITY_BC_MISSING:'IDENTITY_BC_MISSING' /* identityType=BC_AUTH_TT なのに BC ID が無い (実運用の identity はほぼ BC_AUTH_TT) */,
@@ -1198,6 +1203,12 @@ const EC = {
1198
1203
  同梱デフォルトアカウントは <account.dir>/index.json の先頭エントリを起動時に読み込んで決める(固有名詞のハードコードなし)。 */
1199
1204
  let BUNDLED_ACCOUNT_ID = '';
1200
1205
  let BUNDLED_ACCOUNT_NAME = '';
1206
+ /* Google 動画広告の動画本数の上限(公式ヘルプ「Upload 1-5 videos per ad」・backend と同じ) */
1207
+ const GOOGLE_MAX_VIDEOS=5;
1208
+ /** 「542, 543」「542、543」「542 543」を番号の配列に。重複は先に出たほうを残す */
1209
+ function splitNos(v){ const out=[]; String(v==null?'':v).split(/[,、\s]+/).map(x=>x.trim()).filter(Boolean).forEach(x=>{ if(out.indexOf(x)<0) out.push(x); }); return out; }
1210
+ /** 広告行の動画番号(先頭が代表 = imageNo)。画像広告や旧プランは imageNo だけ */
1211
+ function videoNosOf(a){ return splitNos([a.imageNo==null?'':a.imageNo, a.format==='VIDEO'?(a.moreVideoNos||''):''].join(',')); }
1201
1212
  /* ============================================================
1202
1213
  S7: PLATFORMS — 媒体スキーマ定義レジストリ
1203
1214
  ------------------------------------------------------------
@@ -1761,6 +1772,15 @@ PLATFORMS.yda = {
1761
1772
  ・**サムネは必須**。YDA は動画からサムネを自動生成しないので (本番実測)、
1762
1773
  mediaId かローカルパスのどちらかが無いと広告作成が R0001 で落ちる
1763
1774
  ・バナー × 動画 は backend の zod が弾く組み合わせ。dry-run より手前で気づけるよう err にする */
1775
+ /* バナー × Card(1200x628) は **Yahoo が受け付けない組み合わせ**。
1776
+ 公式の入稿規定でバナー(画像)の対応アスペクト比に 1.91:1 は無く (1:1 / 6:5 等のみ)、
1777
+ 1200x628 はレスポンシブのサイズとして載っている。2026-10-13 に広告事業部へ確認済み。
1778
+ 本番実測でも 24アカウント・980広告のうち BANNER_AD × 1200x628 は 0 件で、
1779
+ 1200x628 を使う 322 件はすべて RESPONSIVE_AD だった。
1780
+ 実 API は 220115 Unexpected aspect ratio で弾くが、**dry-run は実 API を叩かないので通る**。
1781
+ バナー × 動画 と同じく、書き出しの手前で止める。 */
1782
+ if(isBanner && !isVideo && fmt.mediaKey==='card')
1783
+ pushErr('バナー×Card(1200)は入稿不可(レスポンシブにするか SQ を使う)', EC.BANNER_CARD_UNSUPPORTED);
1764
1784
  const videoPath=String(ad.videoPath||'').trim();
1765
1785
  const thumbnailPath=String(ad.thumbnailPath||'').trim();
1766
1786
  if(isVideo){
@@ -3846,12 +3866,23 @@ const GOOGLE_COLS = {
3846
3866
  {field:'format', label:'形式', kind:'select', options:['IMAGE','VIDEO'], minWidth:'70px',
3847
3867
  coerce:v=>(String(v).toUpperCase()==='VIDEO'?'VIDEO':'IMAGE'),
3848
3868
  title:'IMAGE = Demand Gen 画像広告 / VIDEO = Demand Gen 動画広告(YouTube)'},
3869
+ /* 動画広告は 1 広告に最大 5 本(縦・横・正方形など・#347)。セルは「542, 543」のように並べ、
3870
+ 先頭(imageNo)が代表 = 広告名・キャンペーン名の番号。2本目以降は moreVideoNos に持つ。
3871
+ imageNo を単一のまま残すのは、命名・連番・新規登録素材のパス補完がすべて imageNo を見ているため。
3872
+ 縦横の区別は持たない(Google が配信面ごとに比率で選ぶので、入稿には要らない)。 */
3849
3873
  {field:'imageNo', label:'クリエイティブ番号', kind:'refcombo', refTable:'media', drawerType:'media', mediaAxis:'format',
3850
- width:'6ch', sequenceable:true, errCodes:[EC.IMAGE_UNRESOLVED],
3851
- ghostKey:'media', ddTitle:'登録クリエイティブから選択',
3852
- ghostTitle:r=>(r.mediaKind==='VIDEO'?'YouTube動画ID ':'画像アセット ')+r.mediaId,
3874
+ width:'9ch', sequenceable:true, errCodes:[EC.IMAGE_UNRESOLVED, EC.VIDEO_EXTRA_UNRESOLVED, EC.VIDEO_DUPLICATE, EC.VIDEO_TOO_MANY],
3875
+ multiMax:a=>(a.format==='VIDEO'?GOOGLE_MAX_VIDEOS:0),
3876
+ get:a=>splitNos([a.imageNo==null?'':a.imageNo, a.moreVideoNos||''].join(',')).join(', '),
3877
+ set:(a,v)=>{ const l=splitNos(v); a.imageNo=l.length?(/^\d+$/.test(l[0])?Number(l[0]):l[0]):''; a.moreVideoNos=l.slice(1).join(', '); },
3878
+ ghostKey:'media', ddTitle:'登録クリエイティブから選択(動画は複数可)',
3879
+ ghostTitle:r=>(r.mediaKind==='VIDEO'?'YouTube動画ID ':'画像アセット ')+[r.mediaId].concat(r.extraVideoIds).join(', '),
3853
3880
  ghost:(r,a)=>{ if(!a.imageNo) return '<span class="idpill" style="color:var(--faint)">—</span>';
3854
- if(r.mediaId!=='') return '<span class="idpill">'+(r.mediaKind==='VIDEO'?'VID ':'IMG ')+esc(r.mediaId)+'</span>';
3881
+ if(r.errCodes.includes(EC.VIDEO_TOO_MANY)) return '<span class="err">'+GOOGLE_MAX_VIDEOS+'本まで</span>';
3882
+ if(r.errCodes.includes(EC.VIDEO_DUPLICATE)) return '<span class="err">同じ動画が重複</span>';
3883
+ if(r.errCodes.includes(EC.VIDEO_EXTRA_UNRESOLVED)) return '<span class="err">2本目以降が未登録</span>';
3884
+ const more=r.extraVideoIds.length?' <span class="pill" title="代表を含めて'+(r.extraVideoIds.length+1)+'本">計'+(r.extraVideoIds.length+1)+'本</span>':'';
3885
+ if(r.mediaId!=='') return '<span class="idpill">'+(r.mediaKind==='VIDEO'?'VID ':'IMG ')+esc(r.mediaId)+'</span>'+more;
3855
3886
  if(r.pendingImage) return '<span class="idpill warn" title="アカウント未登録。入稿前にアップロードして新規登録">🆕未登録</span>';
3856
3887
  return '<span class="idpill err">#N/A</span>'; }},
3857
3888
  {field:'titleNo', label:'見出し番号', kind:'refcombo', refTable:'titles', drawerType:'titles',
@@ -4092,7 +4123,7 @@ PLATFORMS.google = {
4092
4123
  businessName:'', logoAsset:'', logoImagePath:'', cta:'詳しくはこちら', startDate:'',
4093
4124
  deliveryStatus:'オフ'
4094
4125
  },
4095
- ad:{format:'VIDEO', imageNo:'', titleNo:'', longNo:'', descNo:'', videoPath:'', adName:''},
4126
+ ad:{format:'VIDEO', imageNo:'', moreVideoNos:'', titleNo:'', longNo:'', descNo:'', videoPath:'', adName:''},
4096
4127
  adGroup:{name:'', paramCode:'', isExisting:false,
4097
4128
  tg_location:'日本', tg_language:'日本語',
4098
4129
  /* 従来の 'YouTube'(3面まとめて)と同じ配信範囲を、選択肢に載っている値で表す。
@@ -4154,7 +4185,7 @@ PLATFORMS.google = {
4154
4185
  g.tg_channel=out.join(', ');
4155
4186
  }));
4156
4187
  },
4157
- creativeFields:['imageNo','titleNo','longNo','descNo','videoPath'],
4188
+ creativeFields:['imageNo','moreVideoNos','titleNo','longNo','descNo','videoPath'],
4158
4189
  notSubmittedFields:[],
4159
4190
  labels:{submitButton:'完成 → 保存して入稿', paramCodeLabel:'paramCode', tgDroppedWarn:''},
4160
4191
 
@@ -4224,9 +4255,20 @@ PLATFORMS.google = {
4224
4255
  pushWarn('動画パス無視(動画IDが解決済み)', EC.IMAGE_PATH_IGNORED);
4225
4256
  if(fmt.id==='IMAGE' && videoPath!=='')
4226
4257
  pushWarn('動画パス無視(画像広告)', EC.IMAGE_PATH_IGNORED);
4258
+ /* 2本目以降の動画(#347)。未登録のローカル動画は先頭(代表)だけ — 2本目以降は登録済みの番号に限る */
4259
+ const extraNos = fmt.id==='VIDEO' ? videoNosOf(ad).slice(1) : [];
4260
+ const extraVideoIds = extraNos.map(n=>String(((refs.media[n]||{}).video)||''));
4261
+ if(fmt.id==='VIDEO' && extraNos.length){
4262
+ if(extraVideoIds.some(v=>v==='')) pushErr('2本目以降の動画が未登録(登録済みの番号を選ぶ)', EC.VIDEO_EXTRA_UNRESOLVED);
4263
+ const ids=[String(mediaId||'')].concat(extraVideoIds).filter(Boolean);
4264
+ if(new Set(ids).size!==ids.length) pushErr('同じ動画を2回選んでいる', EC.VIDEO_DUPLICATE);
4265
+ if(extraNos.length+1>GOOGLE_MAX_VIDEOS) pushErr('動画は'+GOOGLE_MAX_VIDEOS+'本まで', EC.VIDEO_TOO_MANY);
4266
+ }
4267
+ if(fmt.id==='IMAGE' && String(ad.moreVideoNos||'').trim()!=='') pushWarn('2本目以降の番号を無視(画像広告は1つ)', EC.VIDEO_EXTRA_IGNORED);
4227
4268
  const aid=String(group.adGroupId||'').trim();
4228
4269
  return {crName, adName, autoAdName, campaignId:'', adGroupId:aid, mediaKind:fmt.apiMediaKind, imageNo:noStr,
4229
4270
  mediaId:mediaId||'', imageAsset:imageAsset||'', squareAsset:squareAsset||'', pendingImage, videoPath,
4271
+ extraVideoIds,
4230
4272
  titleText:titleText===undefined?'':titleText,
4231
4273
  longText:longText===undefined?'':longText,
4232
4274
  descText:descText===undefined?'':descText,
@@ -4435,6 +4477,13 @@ PLATFORMS.google = {
4435
4477
  superRefine の「動画が指定されていません」が出ない(zod は基底 parse に失敗すると
4436
4478
  refinement を走らせない)。キーごと出さないほうが理由が届く。 */
4437
4479
  else if(r.mediaId) out.youtubeVideoId=String(r.mediaId);
4480
+ /* 2本以上のときだけ videos[](#347)。1本なら旧形式のまま出す — 1本の計画まで新形式にすると
4481
+ 旧 CLI で何も入稿できなくなる */
4482
+ if(r.extraVideoIds.length && (out.youtubeVideoId || out.videoPath)){
4483
+ out.videos=[out.youtubeVideoId ? {youtubeVideoId:out.youtubeVideoId} : {videoPath:out.videoPath}]
4484
+ .concat(r.extraVideoIds.map(id=>({youtubeVideoId:id})));
4485
+ delete out.youtubeVideoId; delete out.videoPath;
4486
+ }
4438
4487
  out.longHeadlines=r.longText?[String(r.longText)]:[];
4439
4488
  if(logo) out.logoImages=[logo];
4440
4489
  /* 動画広告のロゴは v23 では任意。ローカルパスからの作成は画像広告に絞る
@@ -5575,7 +5624,13 @@ function renderDrawer(){
5575
5624
  /* S7: 入力先の見出しは列記述子のラベル (媒体ごとの列名をここに書かない) */
5576
5625
  const dcol=colDefs('ads').find(c=>c.field===drawerTarget.f); const fieldName=dcol?dcol.label:'';
5577
5626
  let curNo=''; const fa=findAd(drawerTarget.u); if(fa) curNo=String(fa.a[drawerTarget.f]||'');
5578
- tgtEl.textContent='入力先: '+fieldName+(curNo?(' / 現在 '+curNo):' / 未設定');
5627
+ /* 複数選択できる列(Google の動画広告)。押すたびに ON/OFF し、選んだ順に並ぶ。先頭が代表 */
5628
+ const multiMax = (dcol && dcol.multiMax && fa) ? dcol.multiMax(fa.a) : 0;
5629
+ const curList = multiMax ? splitNos(getVar({kind:'a',a:fa.a,g:fa.g,c:fa.c}, colIndex(drawerTarget.f,'ads'), 'ads')) : (curNo?[curNo]:[]);
5630
+ const orderMark = no=>{ const i=curList.indexOf(String(no)); if(!multiMax || i<0) return '';
5631
+ return '<span class="pill"'+(i===0?' title="広告名・キャンペーン名はこの番号から付きます"':'')+'>'+(i===0?'1 代表':String(i+1))+'</span>'; };
5632
+ tgtEl.textContent='入力先: '+fieldName+(curList.length?(' / 現在 '+curList.join(', ')):' / 未設定')
5633
+ + (multiMax?('(押すたびに追加/解除・最大'+multiMax+'本・先頭が代表)'):'');
5579
5634
  /* S10: 参照テーブルは列記述子の refTable から引く。値の見せ方は refTables[key] (label/drawerLabel/dropEmpty)。 */
5580
5635
  const rkey=(dcol&&dcol.refTable) || (drawerTarget.type==='titles'?'titles':'media');
5581
5636
  const T=refTableDef(rkey)||{}, store=st.refs[rkey]||{};
@@ -5595,9 +5650,9 @@ function renderDrawer(){
5595
5650
  if(isMedia && !hasFmt(v)) return;
5596
5651
  if(q && !refSearchStrings(T,no,v).some(s=>s.includes(q))) return;
5597
5652
  const txt = T.drawerLabel ? T.drawerLabel(v) : esc(String(v==null?'':v));
5598
- html+='<div class="ddrow'+(String(no)===curNo?' cur':'')+'" data-no="'+esc(no)+'">'
5653
+ html+='<div class="ddrow'+(curList.indexOf(String(no))>=0?' cur':'')+'" data-no="'+esc(no)+'">'
5599
5654
  +(single?'':thumbImgHtml(no))+'<span class="ddno">'+esc(no)+'</span>'
5600
- +(single?'':newBadge(rkey,no))+'<span class="ddtx">'+txt+'</span></div>';
5655
+ +orderMark(no)+(single?'':newBadge(rkey,no))+'<span class="ddtx">'+txt+'</span></div>';
5601
5656
  });
5602
5657
  /* この計画で新しく登録した素材。**まだ媒体に上がっていないので mediaID が無く、refs には入れない**
5603
5658
  (空の ID を入れると「登録済み」と誤判定され、CLI のアップロードが走らない)。
@@ -5608,8 +5663,8 @@ function renderDrawer(){
5608
5663
  /* 種類 (動画 / 画像) が行と違う素材は出さない */
5609
5664
  if(lm && fmt && ((fmt.apiMediaKind==='VIDEO') !== !!lm.video)) return;
5610
5665
  const thumb=lm ? (lm.thumb||lm.image) : '';
5611
- html+='<div class="ddrow'+(String(no)===curNo?' cur':'')+'" data-no="'+esc(no)+'">'
5612
- +(thumb?'<img class="ddthumb" data-localthumb="'+esc(thumb)+'" alt="">':'')
5666
+ html+='<div class="ddrow'+(curList.indexOf(String(no))>=0?' cur':'')+'" data-no="'+esc(no)+'">'
5667
+ +(thumb?'<img class="ddthumb" data-localthumb="'+esc(thumb)+'" alt="">':'')+orderMark(no)
5613
5668
  +'<span class="ddno">'+esc(no)+'</span>'
5614
5669
  +'<span class="idpill warn">🆕未登録</span>'
5615
5670
  +'<span class="ddtx">'+(lm&&lm.file?esc(lm.file)+' ':'')+'この計画で追加(入稿時にアップロードします)</span></div>';
@@ -5746,8 +5801,35 @@ function closeThumbZoom(){
5746
5801
 
5747
5802
  function thumbZoomKey(ev){ if(ev.key==='Escape') closeThumbZoom(); }
5748
5803
 
5804
+ /** 複数選択の列で番号を ON/OFF する。範囲選択中は、押したセルの状態に揃えて選択範囲の同じ列へ。
5805
+ 閉じずに描き直す(続けて2本目・3本目を選べるように) */
5806
+ function drawerToggleMulti(col, no){
5807
+ const ci=colIndex(col.field,'ads'), fa=findAd(drawerTarget.u); if(!fa||ci<0) return;
5808
+ const self={kind:'a',a:fa.a,g:fa.g,c:fa.c};
5809
+ const adding = splitNos(getVar(self,ci,'ads')).indexOf(String(no))<0;
5810
+ const rg=selRange(), items=[];
5811
+ if(rg && rg.c0<=ci && rg.c1>=ci){ const vis=flatVisible(); for(let r=rg.r0;r<=rg.r1;r++){ const it=vis[r]; if(it&&it.kind==='a') items.push(it); } }
5812
+ if(!items.some(it=>it.a===fa.a)) items.splice(0, items.length, self);
5813
+ let applied=0, full=0;
5814
+ mutate(()=>items.forEach(it=>{
5815
+ const max=col.multiMax(it.a), l=splitNos(getVar(it,ci,'ads')), i=l.indexOf(String(no));
5816
+ if(!max) return;
5817
+ if(adding && i<0){ if(l.length>=max){ full++; return; } l.push(String(no)); }
5818
+ else if(!adding && i>=0) l.splice(i,1);
5819
+ else return;
5820
+ const wasFirst=String(it.a.imageNo==null?'':it.a.imageNo);
5821
+ setVar(it,ci,l.join(', '),'ads');
5822
+ /* 代表が新規登録の素材に変わったら、その素材のパスも入れる */
5823
+ if(String(it.a.imageNo)!==wasFirst) applyLocalMediaPaths(it, it.a.imageNo);
5824
+ applied++;
5825
+ }));
5826
+ toast('番号 '+no+' を'+applied+'セルで'+(adding?'追加':'解除')+(full?('('+full+'セルは上限のため追加せず)'):''));
5827
+ renderDrawer();
5828
+ }
5749
5829
  function drawerPick(no){
5750
5830
  if(!drawerTarget) return;
5831
+ const mcol=colDefs('ads').find(c=>c.field===drawerTarget.f), mfa=findAd(drawerTarget.u);
5832
+ if(mcol && mcol.multiMax && mfa && mcol.multiMax(mfa.a)){ drawerToggleMulti(mcol, no); return; }
5751
5833
  const f=drawerTarget.f, num=/^\d+$/.test(no)?Number(no):no;
5752
5834
  // S3: 旧 [1] / [2,3] リテラルを撤去。同じ▼ドロワー表示モードを持つ refcombo 列を記述子から算出する
5753
5835
  const compat = compatColsFor(drawerTarget.type, sheetTab);
@@ -8,13 +8,22 @@
8
8
  # --wait は入稿ボタンが押されるまで待ってから処理する
9
9
  # adops.sh open <platform> <accountId> [resource] 管理画面を開く (URLを手で組み立てない)
10
10
  # adops.sh refs-import <platform> <accountId> [--write] 実アカウントから参照表を取り込む
11
+ # adops.sh assets-sync <platform> <accountId> [--write] 媒体の素材を参照表(refs.media)へ取り込む
11
12
  # adops.sh refs-import google <accountId> --from-plan <入稿計画.json> [--write]
12
13
  # 入稿で作ったロゴを取り込む(実APIを叩かない)
13
14
  #
14
15
  # 覚えるコマンドを1本に畳むためのラッパー。個別の python3 -c / curl / ls -t を書かない。
15
16
  set -uo pipefail
17
+ # Windows の既定コンソール (cp932) だと Python の ✓ や日本語の出力で UnicodeEncodeError になる
18
+ export PYTHONUTF8=1 PYTHONIOENCODING=utf-8
19
+ # Windows の Python (subprocess) は npx を見つけられない (実体が npx.cmd で WinError 2 になる)。
20
+ # refs_import.py / thumbs_download.py は ADOPS_CLI で CLI を呼ぶので、既定をここで合わせる
21
+ case "${OSTYPE:-}" in
22
+ msys*|cygwin*) export ADOPS_CLI="${ADOPS_CLI:-npx.cmd -y addashboard-cli@latest}" ;;
23
+ esac
16
24
 
17
- SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
25
+ # Windows ではネイティブ形式のパスで持つ (Python に渡すため)。pwd -W が無い Mac は pwd
26
+ SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && { pwd -W 2>/dev/null || pwd; })"
18
27
  # 配信ポート。既定 8799 は cli/playwright.config.ts の webServer と共用する。
19
28
  # **worktree を分けて並行作業していると別 worktree のサーバを再利用してしまう**
20
29
  # (curl が応答すれば「起動済み」と判断するので、別ブランチのプランナーが開く)。
@@ -29,6 +38,20 @@ CLI="${WEBGRAM_CLI:-npx -y addashboard-cli@latest}"
29
38
 
30
39
  die() { echo "✗ $*" >&2; exit 1; }
31
40
 
41
+ # URL をブラウザで開く。Mac は従来どおり open に渡す (`open -a ...` の引数もそのまま通す)。
42
+ # Git Bash (Windows) には open が無いので Chrome を直接起動し、無ければ既定ブラウザで開く。
43
+ # rundll32 は shell を経由しないので、& を含む URL (Google 管理画面など) でも欠けない。
44
+ open_url() {
45
+ if command -v open >/dev/null 2>&1; then open "$@"; return; fi
46
+ local url="${!#}" c
47
+ for c in "/c/Program Files/Google/Chrome/Application/chrome.exe" \
48
+ "/c/Program Files (x86)/Google/Chrome/Application/chrome.exe" \
49
+ "${LOCALAPPDATA:-}/Google/Chrome/Application/chrome.exe"; do
50
+ [ -x "$c" ] && { "$c" "$url" >/dev/null 2>&1 & return; }
51
+ done
52
+ rundll32 url.dll,FileProtocolHandler "$url"
53
+ }
54
+
32
55
  # ------------------------------------------------------------- ensure_login
33
56
  # 未ログインなら auth login を起動して完了まで待ち、そのまま処理を続ける。
34
57
  # 以前は「ターミナルで auth login を実行してください」と案内して止めていたため、
@@ -150,6 +173,17 @@ with open(os.environ["OUT"], "w", encoding="utf-8") as f:
150
173
  PY
151
174
  }
152
175
 
176
+ # 配信サーバーを止める。Mac は従来どおり pkill。
177
+ # Git Bash (Windows) には pkill が無いので、ポートを LISTEN している python だけを taskkill で止める
178
+ # (/PID 形式は Git Bash がパスに変換してしまうので - 形式で渡す)。
179
+ stop_server() {
180
+ if command -v pkill >/dev/null 2>&1; then pkill -f "http.server ${PORT}"; return; fi
181
+ local pid
182
+ pid="$(netstat -ano | awk -v p=":${PORT}" '$1 == "TCP" && $2 ~ p"$" && $4 == "LISTENING" { print $5; exit }')"
183
+ [ -n "$pid" ] && tasklist -FI "PID eq ${pid}" -NH | grep -qi python \
184
+ && taskkill -PID "$pid" -F >/dev/null 2>&1
185
+ }
186
+
153
187
  # file:// で開くと fetch('サンプルプラン.json') がブロックされ、
154
188
  # エラーも出ないまま空のプランナーが表示される。必ずHTTPで開く。
155
189
  cmd_planner() {
@@ -157,7 +191,7 @@ cmd_planner() {
157
191
  while [ $# -gt 0 ]; do
158
192
  case "$1" in
159
193
  --stop)
160
- pkill -f "http.server ${PORT}" && echo "✓ プランナーの配信を停止しました" \
194
+ stop_server && echo "✓ プランナーの配信を停止しました" \
161
195
  || echo "(起動中のサーバーはありませんでした)"
162
196
  return 0 ;;
163
197
  --platform) platform="${2:-}"; shift 2 ;;
@@ -173,7 +207,7 @@ cmd_planner() {
173
207
  fi
174
208
  write_planner_env
175
209
 
176
- if curl -sf -o /dev/null "$URL"; then
210
+ if curl -sf "$URL" >/dev/null; then
177
211
  echo "✓ 配信は起動済み(再利用)"
178
212
  echo " ⚠ 別の作業ディレクトリ (worktree) が起動したサーバの可能性があります。" >&2
179
213
  echo " プランナーの内容が想定と違うときは ADOPS_PORT=8801 adops.sh planner ... で専用ポートに切り替えてください。" >&2
@@ -183,10 +217,10 @@ cmd_planner() {
183
217
  nohup python3 -m http.server "$PORT" --directory "$SKILL_DIR" \
184
218
  >/tmp/adops-planner.log 2>&1 &
185
219
  for _ in $(seq 1 20); do
186
- curl -sf -o /dev/null "$URL" && break
220
+ curl -sf "$URL" >/dev/null && break
187
221
  sleep 0.3
188
222
  done
189
- curl -sf -o /dev/null "$URL" || die "配信を起動できませんでした(/tmp/adops-planner.log を確認)"
223
+ curl -sf "$URL" >/dev/null || die "配信を起動できませんでした(/tmp/adops-planner.log を確認)"
190
224
  echo "✓ 起動しました"
191
225
  fi
192
226
 
@@ -196,8 +230,10 @@ cmd_planner() {
196
230
  if [ -d "/Applications/Google Chrome.app" ] || [ -d "$HOME/Applications/Google Chrome.app" ]; then
197
231
  open -a "Google Chrome" "$URL"
198
232
  else
199
- open "$URL"
200
- echo " ⚠ Google Chrome が見つかりません。素材の登録と入稿計画の保存は Chrome 系ブラウザでしか動きません" >&2
233
+ open_url "$URL"
234
+ # Windows は open_url が Chrome を直接探して起動するので、Mac (open がある) のときだけ警告する
235
+ command -v open >/dev/null 2>&1 \
236
+ && echo " ⚠ Google Chrome が見つかりません。素材の登録と入稿計画の保存は Chrome 系ブラウザでしか動きません" >&2
201
237
  fi
202
238
  echo " $URL"
203
239
  echo " 停止: adops.sh planner --stop"
@@ -312,7 +348,7 @@ PY
312
348
  esac
313
349
 
314
350
  echo "$url"
315
- [ -n "$dry" ] || open "$url"
351
+ [ -n "$dry" ] || open_url "$url"
316
352
  }
317
353
 
318
354
  # --------------------------------------------------------------- refs-import
@@ -322,6 +358,13 @@ cmd_refs_import() {
322
358
  python3 "${SKILL_DIR}/scripts/refs_import.py" "$@"
323
359
  }
324
360
 
361
+ # 媒体に登録済みの素材を参照表(refs.media)へ取り込む。
362
+ # refs-import は見出し/説明文/オーディエンス/配置面だけで media を扱わないため分けてある。
363
+ # --write なしは差分表示のみ(dry-run 既定)。
364
+ cmd_assets_sync() {
365
+ python3 "${SKILL_DIR}/scripts/assets_sync.py" "$@"
366
+ }
367
+
325
368
  # 素材のサムネイルを落として アカウント/<媒体>/サムネ/<accountId>/ に置く。
326
369
  # プランナーの素材選択でサムネを出すために使う。保存先は .gitignore 済み。
327
370
  cmd_thumbs() {
@@ -334,6 +377,7 @@ case "${1:-}" in
334
377
  plan) shift; cmd_plan "$@" ;;
335
378
  open) shift; cmd_open "$@" ;;
336
379
  refs-import) shift; cmd_refs_import "$@" ;;
380
+ assets-sync) shift; cmd_assets_sync "$@" ;;
337
381
  thumbs) shift; cmd_thumbs "$@" ;;
338
382
  *)
339
383
  cat >&2 <<EOF
@@ -351,6 +395,9 @@ usage:
351
395
  adops.sh refs-import <platform> <accountId> [--write] [--campaign-id <id>]
352
396
  adops.sh refs-import google <accountId> --from-plan <入稿計画.json> [--write]
353
397
  実アカウントから参照表を取り込む(既定 dry-run)
398
+ adops.sh assets-sync <platform> <accountId> [--write]
399
+ 媒体の素材を参照表(refs.media)へ取り込む(既定 dry-run)
400
+ 既存の番号・値は変更せず、足りない分だけ足す
354
401
  adops.sh thumbs <platform> <accountId> [--force] [--limit N]
355
402
  素材のサムネイルを落として素材選択で見られるようにする
356
403
  (保存先は アカウント/<媒体>/サムネ/。追跡・公開しない)
@@ -0,0 +1,277 @@
1
+ #!/usr/bin/env python3
2
+ """媒体に登録済みの素材を参照表(refs.media)へ取り込む。
3
+
4
+ `adops.sh assets-sync <platform> <accountId> [--write]` から呼ばれる。単体でも動く:
5
+
6
+ python3 assets_sync.py yda <accountId> [--write]
7
+
8
+ refs_import.py が見出し・説明文・オーディエンス・配置面を扱うのに対し、
9
+ こちらは **media 専用**。両者は番号空間も取得元も違うので分けてある。
10
+
11
+ ━━ なぜ要るか ━━
12
+ 参照表は「初回に作る」手順(プランナーの ❓使い方 → アカウント取込プロンプト)はあるが、
13
+ **後から追記する手段が無かった**。媒体に素材を足してもプランナーの選択肢に出ないため、
14
+ 「媒体にはあるのに入稿できない」という状態になる(#325)。
15
+
16
+ ━━ 振り分けはファイル名ではなく実寸で行う ━━
17
+ mediaName の `-1080` / `-1200` は実サイズとズレている(実測: `-1080` の 96% が 600x600、
18
+ `-1200` で 2400x1256 のものもある)。アップロード時に `元ファイル名 + contentHash` へ
19
+ 加工される経路もあるので、名前は振り分けの根拠にしない。
20
+
21
+ aspectRatio が 1.91:1 系 → card
22
+ aspectRatio が 1:1 系 → sq
23
+ それ以外 → 取り込まない(件数だけ報告)
24
+
25
+ ━━ 既存は絶対に触らない ━━
26
+ 画像番号は広告名(`957c-jv_Cac_148`)と計測パラメータ(`creative=`)に焼き込まれている。
27
+ 番号がずれると過去の実績と突き合わせられなくなるので、
28
+
29
+ * 既存の番号は振り直さない
30
+ * 既存の値は上書きしない(違う mediaId を見つけても報告だけ)
31
+ * 媒体から消えたものは削除しない
32
+
33
+ ━━ 取りきれていないときは書かない ━━
34
+ backend の MediaService 取得は 500 件固定でページング未対応。全件取れていない状態で
35
+ 書き込むと「媒体に無い」と誤認した欠落データを正にしてしまう。取得が怪しいときは
36
+ 書き込まずに報告して終わる(呼び出し側は既存の参照表のまま先へ進める)。
37
+ """
38
+ import argparse
39
+ import glob
40
+ import json
41
+ import os
42
+ import re
43
+ import shutil
44
+ import subprocess
45
+ import sys
46
+ from datetime import datetime
47
+
48
+ SKILL_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
49
+ CLI = os.environ.get("ADOPS_CLI", "npx -y addashboard-cli@latest")
50
+
51
+ # backend の MediaService 取得が 1 回で返す上限。ここに達していたら全件取れていない
52
+ # 可能性があるので書き込まない(connector 側が thumbnailFlg 等を間引くため、
53
+ # 見える件数が上限未満でも取りこぼしはありうる。あくまで「明らかに怪しい」の検知)。
54
+ FETCH_LIMIT = 500
55
+
56
+ # 取り込んでよい審査状態。
57
+ # APPROVED … 承認済み
58
+ # PENDING_UNSPECIFIED_AD … まだ広告に使われていないだけ。否認ではない
59
+ # PRE_DISAPPROVED / POST_DISAPPROVED を入れると「選べるのに入稿で落ちる」状態になる。
60
+ USABLE_APPROVAL = {"APPROVED", "PENDING_UNSPECIFIED_AD"}
61
+
62
+ # aspectRatio → 参照表のスロット。媒体の表記ゆれ(AR_191_100 / AR_1200_628)を吸収する。
63
+ SLOT_BY_ASPECT = {
64
+ "AR_191_100": "card",
65
+ "AR_1200_628": "card",
66
+ "AR_1_1": "sq",
67
+ }
68
+
69
+
70
+ def fetch_media(platform: str, account_id: str) -> list | None:
71
+ """登録済み素材の一覧を取る。取れなければ None(空配列と区別する)。
72
+
73
+ 「取得失敗」を「素材が0件」と取り違えると、参照表を空にしかねない。
74
+ """
75
+ cmd = [*CLI.split(), "list", "--platform", platform,
76
+ "--resource", "media", "--account-id", account_id]
77
+ print("▸ 媒体の素材一覧を取得中...", file=sys.stderr)
78
+ try:
79
+ proc = subprocess.run(cmd, capture_output=True, text=True, timeout=600)
80
+ except Exception as e: # noqa: BLE001
81
+ print(f" ✗ 取得に失敗: {e}", file=sys.stderr)
82
+ return None
83
+ if proc.returncode != 0:
84
+ print(f" ✗ 取得に失敗 (exit {proc.returncode}): {proc.stderr.strip()[:400]}",
85
+ file=sys.stderr)
86
+ return None
87
+ try:
88
+ data = json.loads(proc.stdout)
89
+ except Exception as e: # noqa: BLE001
90
+ print(f" ✗ 応答を解釈できません: {e}", file=sys.stderr)
91
+ return None
92
+ media = data.get("media")
93
+ if not isinstance(media, list):
94
+ print(" ✗ 応答に media がありません", file=sys.stderr)
95
+ return None
96
+ return media
97
+
98
+
99
+ def find_account(platform: str, account_id: str) -> tuple[str | None, dict]:
100
+ """accountId から参照表のパスと内容を引く(sample_account は最後に回す)。"""
101
+ paths = [p for p in glob.glob(os.path.join(SKILL_DIR, "アカウント", platform, "*.json"))
102
+ if os.path.basename(p) != "index.json"]
103
+ paths.sort(key=lambda p: os.path.basename(p) == "sample_account.json")
104
+ for path in paths:
105
+ try:
106
+ data = json.load(open(path, encoding="utf-8"))
107
+ except Exception: # noqa: BLE001
108
+ continue
109
+ if str(data.get("accountId", "")) == str(account_id):
110
+ return path, data
111
+ return None, {}
112
+
113
+
114
+ def image_no(media_name: object) -> str | None:
115
+ """素材名の先頭の数字を画像番号として取る(`183-1200.jpg` → `183`)。
116
+
117
+ 先頭が数字でないものは番号を決められないので取り込まない。推測で番号を振ると
118
+ 既存の番号体系を壊すため、ここは厳格にする。
119
+ """
120
+ m = re.match(r"(\d+)", str(media_name or ""))
121
+ return m.group(1) if m else None
122
+
123
+
124
+ def classify(media: list) -> dict:
125
+ """素材一覧を (番号, スロット) → mediaId の候補表に畳む。
126
+
127
+ 同じ組み合わせに複数の素材が当たったら、どれを採るか機械的に決められないので
128
+ 候補をすべて残して呼び出し側に判断させる(自動採用しない)。
129
+ """
130
+ out = {
131
+ "candidates": {}, # (no, slot) -> [mediaId, ...]
132
+ "skipped_approval": 0, # 否認済み
133
+ "skipped_aspect": 0, # card/sq に当てはまらない比率
134
+ "skipped_name": 0, # 番号を取れない
135
+ }
136
+ for m in media:
137
+ mid = str(m.get("mediaId") or "")
138
+ if not mid:
139
+ continue
140
+ if (m.get("approvalStatus") or "") not in USABLE_APPROVAL:
141
+ out["skipped_approval"] += 1
142
+ continue
143
+ slot = SLOT_BY_ASPECT.get(m.get("aspectRatio") or "")
144
+ if not slot:
145
+ out["skipped_aspect"] += 1
146
+ continue
147
+ no = image_no(m.get("mediaName"))
148
+ if not no:
149
+ out["skipped_name"] += 1
150
+ continue
151
+ out["candidates"].setdefault((no, slot), []).append(mid)
152
+ return out
153
+
154
+
155
+ def merge(refs_media: dict, classified: dict) -> tuple[dict, list[str], dict]:
156
+ """既存を保ったまま不足分だけ足す。
157
+
158
+ returns (merged, report, stats)
159
+ """
160
+ merged = {k: dict(v) if isinstance(v, dict) else v for k, v in (refs_media or {}).items()}
161
+ added, ambiguous, conflicts = [], [], []
162
+
163
+ for (no, slot), ids in sorted(classified["candidates"].items(), key=lambda x: (int(x[0][0]), x[0][1])):
164
+ cur = merged.get(no)
165
+ if cur is not None and not isinstance(cur, dict):
166
+ continue # 想定外の形。触らない
167
+ if len(ids) > 1:
168
+ # どれが正か決められない。既に値があるならそのまま、無ければ空けておく
169
+ ambiguous.append((no, slot, ids))
170
+ continue
171
+ mid = ids[0]
172
+ if cur is None:
173
+ merged[no] = {slot: mid}
174
+ added.append((no, slot, mid, "新規番号"))
175
+ elif slot not in cur:
176
+ cur[slot] = mid
177
+ added.append((no, slot, mid, "枠を追加"))
178
+ elif str(cur[slot]) != mid:
179
+ # 既存の値は上書きしない。媒体側で差し替わっている可能性を報告だけする
180
+ conflicts.append((no, slot, str(cur[slot]), mid))
181
+
182
+ report = []
183
+ report.append(f"追加: {len(added)} 件"
184
+ f"(card {sum(1 for a in added if a[1] == 'card')} / "
185
+ f"sq {sum(1 for a in added if a[1] == 'sq')})")
186
+ if ambiguous:
187
+ report.append(f"⚠ 同じ番号・同じ形式に候補が複数あるため取り込みませんでした: {len(ambiguous)} 件")
188
+ for no, slot, ids in ambiguous[:5]:
189
+ report.append(f" 番号 {no} / {slot}: {', '.join(ids)}")
190
+ if conflicts:
191
+ report.append(f"⚠ 既存と違う mediaId が媒体にあります(上書きしていません): {len(conflicts)} 件")
192
+ for no, slot, old, new in conflicts[:5]:
193
+ report.append(f" 番号 {no} / {slot}: 参照表 {old} / 媒体 {new}")
194
+ for key, label in (("skipped_approval", "審査で否認された素材"),
195
+ ("skipped_aspect", "card/sq に当てはまらない比率の素材"),
196
+ ("skipped_name", "名前から番号を取れない素材")):
197
+ if classified[key]:
198
+ report.append(f"除外: {label} {classified[key]} 件")
199
+
200
+ stats = {"added": len(added), "ambiguous": len(ambiguous), "conflicts": len(conflicts)}
201
+ return merged, report, stats
202
+
203
+
204
+ def main() -> int:
205
+ ap = argparse.ArgumentParser(description="媒体の素材を参照表(refs.media)へ取り込む")
206
+ ap.add_argument("platform", choices=["yda"],
207
+ help="現在は yda のみ。媒体ごとに番号の付け方が違うため横展開はしない")
208
+ ap.add_argument("account_id")
209
+ ap.add_argument("--write", action="store_true", help="参照表へ書き込む(既定は差分表示のみ)")
210
+ args = ap.parse_args()
211
+
212
+ path, account = find_account(args.platform, args.account_id)
213
+ if not path:
214
+ print(f"✗ accountId {args.account_id} の参照表が見つかりません", file=sys.stderr)
215
+ print(" 初めて使うアカウントは、先にアカウント取込プロンプトで作成してください",
216
+ file=sys.stderr)
217
+ return 1
218
+
219
+ media = fetch_media(args.platform, args.account_id)
220
+ if media is None:
221
+ print("✗ 素材一覧を取得できなかったため、参照表は変更していません")
222
+ return 1
223
+
224
+ print(f" 媒体の素材: {len(media)} 件")
225
+
226
+ # 取得上限に達している = 全件取れていない可能性がある。欠落したまま書くと危険なので止める。
227
+ truncated = len(media) >= FETCH_LIMIT
228
+ classified = classify(media)
229
+ refs = (account.get("refs") or {}).get("media") or {}
230
+ merged, report, stats = merge(refs, classified)
231
+
232
+ print(f"\n── {os.path.basename(path)} ──")
233
+ before_card = sum(1 for v in refs.values() if isinstance(v, dict) and v.get("card"))
234
+ before_sq = sum(1 for v in refs.values() if isinstance(v, dict) and v.get("sq"))
235
+ after_card = sum(1 for v in merged.values() if isinstance(v, dict) and v.get("card"))
236
+ after_sq = sum(1 for v in merged.values() if isinstance(v, dict) and v.get("sq"))
237
+ print(f" 画像番号 {len(refs)} → {len(merged)}")
238
+ print(f" card {before_card} → {after_card}")
239
+ print(f" sq {before_sq} → {after_sq}")
240
+ for line in report:
241
+ print(f" {line}")
242
+
243
+ if truncated:
244
+ print(f"\n✗ 取得件数が上限({FETCH_LIMIT})に達しています。全件取れていない可能性が"
245
+ "あるため書き込みません。")
246
+ print(" ページング対応が入るまで、このアカウントは手動で参照表を整えてください。")
247
+ return 2
248
+
249
+ if stats["added"] == 0:
250
+ print("\n(参照表は最新です)")
251
+ return 0
252
+
253
+ if not args.write:
254
+ print("\n(dry-run: 書き込んでいません。反映するには --write を付けてください)")
255
+ return 0
256
+
257
+ # 書き込み前に必ず退避する。固定名だと2回目の write で最初の退避が消えるので世代を残す。
258
+ stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
259
+ backup, n = f"{path}.{stamp}.bak", 1
260
+ while os.path.exists(backup):
261
+ backup, n = f"{path}.{stamp}-{n}.bak", n + 1
262
+ shutil.copy2(path, backup)
263
+
264
+ account.setdefault("refs", {})["media"] = merged
265
+ # 一時ファイルへ書いてから rename する。途中で落ちても元が壊れない
266
+ tmp = f"{path}.tmp"
267
+ with open(tmp, "w", encoding="utf-8") as f:
268
+ json.dump(account, f, indent=1, ensure_ascii=False)
269
+ f.write("\n")
270
+ os.replace(tmp, path)
271
+ print(f"\n✓ 書き込みました: {path}")
272
+ print(f" 退避: {os.path.basename(backup)}(元に戻すならこれを上書きコピー)")
273
+ return 0
274
+
275
+
276
+ if __name__ == "__main__":
277
+ sys.exit(main())