193 lines
13 KiB
JavaScript
193 lines
13 KiB
JavaScript
/**
|
||
* aliChoice(速卖通全托管)批量建品
|
||
*
|
||
* 用法: node alcbatch.mjs [--dry] [pid...]
|
||
*
|
||
* 顺序:店铺(下拉第一家) → 备货类型(国内履约) → 类目(弹窗) → 标题(subject)/主图/PC端描述
|
||
* → SKU 行 → 必填类目属性(品牌/高关注化学品/产地) → **物流属性(先保存一次触发报错,
|
||
* 点报错里的 icon_edit2 → 勾「普货」→ 确定)** → 再保存
|
||
*/
|
||
import { readFileSync, writeFileSync, appendFileSync } from 'fs';
|
||
import { newPage, closePage, wait, installHook, readHook } from '/home/admin9webs/shein建品/sh_lib.mjs';
|
||
import { selectCategory, fillRequiredSelects, fillRequiredTextInputs, fillNativeSelects } from '/home/admin9webs/shein建品/sh_build.mjs';
|
||
|
||
const DIR = '/home/admin9webs/alichoice建品';
|
||
const P = `document.querySelector('#app').__vue_app__.config.globalProperties.$pinia`;
|
||
const INPUT = JSON.parse(readFileSync('/home/admin9webs/tiktok建品/tk_input.json', 'utf8'));
|
||
const DONE_F = `${DIR}/alc_done.json`;
|
||
const LOG_F = `${DIR}/alc_batch.log`;
|
||
const SKIP = new Set(['601964534137']); // 遥控器保护套:无干净图,全平台统一跳过
|
||
|
||
const argv = process.argv.slice(2);
|
||
const dry = argv.includes('--dry');
|
||
const only = argv.filter(a => !a.startsWith('--'));
|
||
let tasks = Object.keys(INPUT).filter(pid => (INPUT[pid].imgs || []).length > 0).map(pid => ({ pid }));
|
||
if (only.length) tasks = tasks.filter(t => only.includes(t.pid)); else tasks = tasks.filter(t => !SKIP.has(t.pid));
|
||
let done = {}; try { done = JSON.parse(readFileSync(DONE_F, 'utf8')); } catch {}
|
||
let ROUTE = {}; try { ROUTE = JSON.parse(readFileSync(`${DIR}/alc_route.json`, 'utf8')); } catch {}
|
||
const log = m => { const l = `[${new Date().toISOString().slice(11, 19)}] ${m}`; console.log(l); appendFileSync(LOG_F, l + '\n'); };
|
||
|
||
/** PC端描述:真实控件是 CKEditor(隐藏 textarea + 所见即所得),给编辑器实例 setData 才有效 */
|
||
async function fillDescription(c, text) {
|
||
return c.evalJS(`(()=>{const p=${P};
|
||
const html=${JSON.stringify(text)};
|
||
let how='none';
|
||
try{ if(window.CKEDITOR&&window.CKEDITOR.instances){ const inst=Object.values(window.CKEDITOR.instances);
|
||
if(inst.length){ inst.forEach(x=>{ try{ x.setData(html); }catch(e){} }); how='ckeditor:'+inst.length; } } }catch(e){}
|
||
const d=p._s.get('smtChoiceAddStore').$state.describeInfoData;
|
||
if(d){ d.detail=html; if('mobileDetail' in d) d.mobileDetail=html; how+='|store'; }
|
||
if(how==='none'){ const f=[...document.querySelectorAll('.ant-form-item')].filter(e=>e.offsetParent!==null)
|
||
.find(e=>/PC端描述/.test((e.querySelector('label')||{}).innerText||''));
|
||
const ta=f?f.querySelector('textarea'):null;
|
||
if(ta){ const setter=Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype,'value').set;
|
||
setter.call(ta, html); ta.dispatchEvent(new Event('input',{bubbles:true})); how='textarea'; } }
|
||
return how;})()`);
|
||
}
|
||
|
||
async function pickFirstOption(c, labelRe) {
|
||
const p = JSON.parse(String(await c.evalJS(`(()=>{const f=[...document.querySelectorAll('.ant-form-item')].filter(e=>e.offsetParent!==null)
|
||
.find(e=>new RegExp(${JSON.stringify(labelRe)}).test((e.querySelector('label')||{}).innerText||''));
|
||
const s=f?f.querySelector('.ant-select-selector'):null; if(!s) return '[]'; s.scrollIntoView({block:'center'});
|
||
const r=s.getBoundingClientRect(); return JSON.stringify([Math.round(r.x+r.width/2),Math.round(r.y+r.height/2)]);})()`)));
|
||
if (!p.length) return 'no-select';
|
||
await c.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: p[0], y: p[1], buttons: 0 }); await wait(300);
|
||
for (const t of ['mousePressed', 'mouseReleased'])
|
||
await c.send('Input.dispatchMouseEvent', { type: t, x: p[0], y: p[1], button: 'left', clickCount: 1, buttons: 1 });
|
||
await wait(2000);
|
||
return c.evalJS(`(()=>{const dd=[...document.querySelectorAll('.ant-select-dropdown')].filter(e=>e.offsetParent!==null).pop();
|
||
if(!dd) return 'no-dd'; const op=[...dd.querySelectorAll('.ant-select-item-option')].filter(e=>e.offsetParent!==null)[0];
|
||
if(!op) return 'no-op'; const r=op.getBoundingClientRect();
|
||
for(const ev of ['mousedown','mouseup','click']) op.dispatchEvent(new MouseEvent(ev,{bubbles:true,cancelable:true,view:window,clientX:r.x+r.width/2,clientY:r.y+r.height/2}));
|
||
return 'picked:'+(op.innerText||'').trim().slice(0,16);})()`);
|
||
}
|
||
|
||
async function clickSave(c) {
|
||
await c.evalJS(`window.scrollTo(0,0)`); await wait(500);
|
||
await c.evalJS(`(()=>{const xs=[...document.querySelectorAll('button')].filter(e=>e.offsetParent!==null);
|
||
const b=xs.find(e=>/^\\s*保存\\s*$/.test((e.innerText||'').trim()))||xs.find(e=>/保存/.test(e.innerText||''));
|
||
if(b) for(const ev of ['mousedown','mouseup','click']) b.dispatchEvent(new MouseEvent(ev,{bubbles:true,cancelable:true,view:window}));
|
||
return !!b;})()`);
|
||
}
|
||
|
||
/** 物流属性:先保存一次逼出「请选择物流属性」,点它的编辑入口 → 勾「普货」→ 确定 */
|
||
async function fixLogistics(c) {
|
||
const pos = JSON.parse(String(await c.evalJS(`(()=>{
|
||
const bad=[...document.querySelectorAll('.ant-form-item-has-error')].find(e=>/物流属性/.test(e.innerText||''));
|
||
if(!bad) return '[]';
|
||
const link=bad.querySelector('.icon_edit2')||bad.querySelector('i.link');
|
||
if(!link) return '[]'; link.scrollIntoView({block:'center'});
|
||
const r=link.getBoundingClientRect(); return JSON.stringify([Math.round(r.x+r.width/2),Math.round(r.y+r.height/2)]);})()`)));
|
||
if (!pos.length) return 'no-entry';
|
||
await c.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: pos[0], y: pos[1], buttons: 0 }); await wait(300);
|
||
for (const t of ['mousePressed', 'mouseReleased'])
|
||
await c.send('Input.dispatchMouseEvent', { type: t, x: pos[0], y: pos[1], button: 'left', clickCount: 1, buttons: 1 });
|
||
await wait(3500);
|
||
const tick = await c.evalJS(`(()=>{const m=[...document.querySelectorAll('.ant-modal')].filter(e=>e.offsetParent!==null).pop();
|
||
if(!m) return 'no-modal';
|
||
const ws=[...m.querySelectorAll('.ant-checkbox-wrapper')];
|
||
const w=ws.find(x=>/普货/.test(x.innerText||''))||ws[0];
|
||
if(!w) return 'no-checkbox';
|
||
for(const ev of ['mousedown','mouseup','click']) w.dispatchEvent(new MouseEvent(ev,{bubbles:true,cancelable:true,view:window}));
|
||
const i=w.querySelector('input'); if(i&&!i.checked) i.click();
|
||
const ok=[...m.querySelectorAll('button')].find(x=>/^\\s*确定\\s*$/.test((x.innerText||'').trim()));
|
||
if(ok) for(const ev of ['mousedown','mouseup','click']) ok.dispatchEvent(new MouseEvent(ev,{bubbles:true,cancelable:true,view:window}));
|
||
return 'ticked:'+(w.innerText||'').trim().slice(0,10);})()`);
|
||
await wait(3000);
|
||
return tick;
|
||
}
|
||
|
||
log(`aliChoice 批量开始:任务 ${tasks.length},dry=${dry}`);
|
||
for (const t of tasks) {
|
||
if (!dry && done[t.pid] && done[t.pid].ok) { log(`跳过 ${t.pid}(已建)`); continue; }
|
||
const it = INPUT[t.pid]; if (!it) { log(`✗ ${t.pid} 无输入`); continue; }
|
||
log(`── ${t.pid} kw=${it.cat_kw || '?'} :: ${String(it.title).slice(0, 40)}`);
|
||
let c;
|
||
try {
|
||
c = await newPage('https://www.dianxiaomi.com/web/smtChoice/add');
|
||
await wait(12000);
|
||
await installHook(c);
|
||
log(` 店铺: ${await pickFirstOption(c, '店铺名称')}`);
|
||
await wait(6000);
|
||
log(` 备货类型: ${await pickFirstOption(c, '备货类型')}`);
|
||
await wait(3000);
|
||
// 类目:用 alcdry.mjs 扫出来的词(已确认该词落到的类目不强制「商品说明书」)
|
||
const rc = ROUTE[t.pid];
|
||
if (!rc || !rc.cat) {
|
||
log(` ⏭ 跳过:没有不要求资质文件的类目(dry 扫描结论)`);
|
||
done[t.pid] = { ok: false, skipped: true, msg: '无可用类目:全部候选都强制商品说明书' };
|
||
await closePage(c); c = null;
|
||
if (!dry) writeFileSync(DONE_F, JSON.stringify(done, null, 1));
|
||
continue;
|
||
}
|
||
const cat = await selectCategory(c, rc.cat, rc.cat);
|
||
log(` 类目: 「${rc.cat}」 ${String(cat).slice(0, 90)}`);
|
||
if (!String(cat).startsWith('path:')) throw new Error('选类目失败:' + cat);
|
||
await wait(6000);
|
||
const fill = await c.evalJS(`(()=>{const p=${P}; const imgs=${JSON.stringify((it.imgs || []).slice(0, 6))};
|
||
const mk=(u,i)=>({url:u,imgUrl:u,name:'img'+(i+1)+'.jpg',width:800,height:800});
|
||
const b=p._s.get('smtChoiceAddBasicStore').$state.formState; b.subject=${JSON.stringify(it.title)};
|
||
const pr=p._s.get('smtChoiceAddProductStore').$state.formState; pr.mainImage=imgs.map(mk);
|
||
const sd=p._s.get('smtChoiceSkuDataStore').$state.formState;
|
||
(sd.skuDataList||[]).forEach((r,i)=>{ r.skuPrice=String(${JSON.stringify(String(it.price_cny || '19.90'))});
|
||
r.skuCode='AC-'+${JSON.stringify(t.pid)}+'-'+(i+1); r.packageWeight='100';
|
||
r.packageLength='20'; r.packageWidth='15'; r.packageHeight='3'; });
|
||
const desc=p._s.get('smtChoiceAddStore').$state.describeInfoData;
|
||
if(desc&&!desc.detail) desc.detail=${JSON.stringify((it.bullets || []).join('<br/>'))};
|
||
return JSON.stringify({subject:b.subject.slice(0,16), imgs:pr.mainImage.length, rows:(sd.skuDataList||[]).length});})()`);
|
||
log(` 填充: ${fill}`);
|
||
await wait(3500);
|
||
const attrs = await fillRequiredSelects(c);
|
||
if (attrs !== 'none') log(` 必填属性: ${String(attrs).slice(0, 150)}`);
|
||
await wait(1200);
|
||
// 文本型必填(aliChoice 的「材质(Material)」是 input,不是 select):按标题里的材质词兜底
|
||
const ttl = String(it.title || '');
|
||
const matHit = ttl.match(/plastic|cotton|polyester|stainless steel|metal|silicone|wood|resin|aluminum|alloy|fabric|nylon|glass|ceramic|leather/i);
|
||
const colorHit = ttl.match(/black|white|pink|red|blue|green|beige|grey|gray|purple|yellow|brown|khaki|ivory|apricot|silver|gold|multicolou?r/i);
|
||
// 按字段名给值:颜色别串成材质(曾把「颜色」填成 Resin)
|
||
const valueFor = (name) => {
|
||
if (/颜色|Color/i.test(name)) return colorHit ? colorHit[0] : 'Multicolor';
|
||
if (/材质|Material/i.test(name)) return matHit ? matHit[0] : 'Polyester';
|
||
if (/图案|Pattern/i.test(name)) return 'Solid';
|
||
if (/尺寸|Size/i.test(name)) return 'Medium';
|
||
return 'Other';
|
||
};
|
||
const txtAttrs = await fillRequiredTextInputs(c, valueFor);
|
||
if (txtAttrs !== 'none') log(` 文本必填: ${String(txtAttrs).slice(0, 120)}`);
|
||
await wait(1200);
|
||
// 原生 <select>(SKU分类=单品单件、单位=件/个)——antd 选择器够不到
|
||
const nat = await fillNativeSelects(c, ['单品单件', '件/个']);
|
||
if (nat !== 'none') log(` 原生下拉: ${String(nat).slice(0, 120)}`);
|
||
await wait(1200);
|
||
log(` PC端描述: ${await fillDescription(c, (it.bullets || []).join('<br/>') || it.title)}`);
|
||
await wait(1500);
|
||
if (dry) { delete done[t.pid]; log(' ✓ dry 构建完成(未保存)'); await closePage(c); continue; }
|
||
// 保存 → 若报「请选择物流属性」就补上 → 再保存
|
||
await clickSave(c);
|
||
await wait(11000);
|
||
let mods = String(await c.evalJS(`JSON.stringify([...document.querySelectorAll('.ant-modal')].filter(e=>e.offsetParent!==null).map(e=>(e.innerText||'').replace(/\\s+/g,' ').trim().slice(0,110)))`));
|
||
if (!/已保存|保存成功|待发布/.test(mods)) {
|
||
const lg = await fixLogistics(c);
|
||
log(` 物流属性: ${lg}`);
|
||
await clickSave(c);
|
||
await wait(12000);
|
||
mods = String(await c.evalJS(`JSON.stringify([...document.querySelectorAll('.ant-modal')].filter(e=>e.offsetParent!==null).map(e=>(e.innerText||'').replace(/\\s+/g,' ').trim().slice(0,110)))`));
|
||
}
|
||
const cap = await readHook(c);
|
||
const errItems = String(await c.evalJS(`JSON.stringify([...document.querySelectorAll('.ant-form-item-has-error')].map(x=>{
|
||
const l=x.querySelector('label'); const t=l?(l.innerText||'').replace(/\\s+/g,' ').trim().slice(0,30):'(无label)';
|
||
const red=[...x.querySelectorAll('.text-red-500')].map(e=>(e.innerText||'').trim().slice(0,30)).filter(Boolean);
|
||
return t+':'+red.join('/');}).slice(0,6))`));
|
||
const ok = /已保存|保存成功|待发布/.test(mods);
|
||
done[t.pid] = ok ? { ok: true, cat: rc.cat, path: rc.path } : { ok: false, cat: rc.cat, errItems: errItems.slice(0, 260), mods: mods.slice(0, 120), hook: String(cap).slice(-200) };
|
||
log(ok ? ' ✓ 已保存到待发布' : ` ✗ 失败 ${mods.slice(0, 110)} | ${String(cap).slice(-170)}`);
|
||
} catch (e) {
|
||
done[t.pid] = { ok: false, msg: String(e).slice(0, 240) };
|
||
log(` ✗ 异常: ${String(e).slice(0, 220)}`);
|
||
} finally {
|
||
if (!dry) writeFileSync(DONE_F, JSON.stringify(done, null, 1));
|
||
if (c) await closePage(c);
|
||
await wait(2500);
|
||
}
|
||
}
|
||
log(`aliChoice 批量结束:成功 ${Object.values(done).filter(x => x.ok).length}/${tasks.length}`);
|