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.
- package/dist/commands/submission.js +95 -60
- package/dist/commands/submission.js.map +1 -1
- package/dist/schemas/submit-plan.js +11 -0
- package/dist/schemas/submit-plan.js.map +1 -1
- package/package.json +1 -1
- package/skills/ad-ops/SKILL.md +33 -0
- package/skills/ad-ops/ad-planner.html +94 -12
- package/skills/ad-ops/scripts/adops.sh +55 -8
- package/skills/ad-ops/scripts/assets_sync.py +277 -0
- package/skills/ad-ops/scripts/plan_lint.py +58 -9
- package/skills/ad-ops/scripts/test_assets_sync.py +132 -0
|
@@ -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.
|
|
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:'
|
|
3851
|
-
|
|
3852
|
-
|
|
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.
|
|
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
|
-
|
|
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)
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
220
|
+
curl -sf "$URL" >/dev/null && break
|
|
187
221
|
sleep 0.3
|
|
188
222
|
done
|
|
189
|
-
curl -sf
|
|
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
|
-
|
|
200
|
-
|
|
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" ] ||
|
|
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())
|