180 lines
12 KiB
JavaScript
180 lines
12 KiB
JavaScript
/**
|
||
* Joom 批量建品(84 家店取第一家;与 aliChoice 共用 smtChoice* store)
|
||
*
|
||
* 用法: node jmbatch.mjs [--dry] [pid...]
|
||
*
|
||
* 单条顺序:勾店铺(复选框) → 选类目(可搜索 ant-select) → 标题/ParentSKU/描述/标签
|
||
* → 价格/库存/运费/报关金额 → 图片(主图+详情图) → 敏感货类型 → 详情图语言 → 保存
|
||
*
|
||
* 被钩子抓出来的两条「没标必填但必须填」:运费 shipping、详情图语言(标签就叫「详情图」)
|
||
*/
|
||
import { readFileSync, writeFileSync, appendFileSync } from 'fs';
|
||
import { newPage, closePage, wait, installHook, readHook } from '/home/admin9webs/shein建品/sh_lib.mjs';
|
||
import { fillRequiredSelects } from '/home/admin9webs/shein建品/sh_build.mjs';
|
||
|
||
const DIR = '/home/admin9webs/joom建品';
|
||
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}/jm_done.json`;
|
||
const LOG_F = `${DIR}/jm_batch.log`;
|
||
const SKIP = new Set([
|
||
'676236596821', // 首条手工测试已进待发布(详情图语言选了阿拉伯语),不再重复建
|
||
'601964534137', // 遥控器保护套:18 张图全带水印、无干净图 → 所有平台统一跳过
|
||
]);
|
||
|
||
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}/joom_route.json`, 'utf8')); } catch {}
|
||
const log = m => { const l = `[${new Date().toISOString().slice(11, 19)}] ${m}`; console.log(l); appendFileSync(LOG_F, l + '\n'); };
|
||
|
||
/** 勾第一家真实店铺(Joom 店名没有圈号,取 DOM 第一个非「全部」,即 Dummer-Pets) */
|
||
async function tickShop(c, want = null) {
|
||
return c.evalJS(`(()=>{const it=[...document.querySelectorAll('.ant-form-item')].filter(e=>e.offsetParent!==null)
|
||
.find(e=>/店铺名称/.test((e.querySelector('label')||{}).innerText||''));
|
||
if(!it) return 'no-item';
|
||
const ws=[...it.querySelectorAll('.ant-checkbox-wrapper')];
|
||
const want=${JSON.stringify(want)};
|
||
const w=want ? ws.find(x=>(x.innerText||'').trim()===want) : ws.find(x=>{const t=(x.innerText||'').trim(); return t&&!/^全部/.test(t);});
|
||
if(!w) return 'no-shop';
|
||
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();
|
||
return 'ticked:'+(w.innerText||'').trim();})()`);
|
||
}
|
||
|
||
/** 类目:点开可搜索下拉 → 输关键词 → 选名字匹配的项(优先去掉括注后完全相等) */
|
||
async function pickCategory(c, kw) {
|
||
const pos = JSON.parse(String(await c.evalJS(`(()=>{const it=[...document.querySelectorAll('.ant-form-item')].filter(e=>e.offsetParent!==null)
|
||
.find(e=>/产品分类/.test((e.querySelector('label')||{}).innerText||''));
|
||
const s=it?it.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 (!pos.length) return 'no-select';
|
||
await c.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: pos[0], y: pos[1], buttons: 0 }); await wait(250);
|
||
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(1500);
|
||
await c.send('Input.insertText', { text: kw }).catch(() => {});
|
||
await wait(2600);
|
||
return c.evalJS(`(()=>{const dd=[...document.querySelectorAll('.ant-select-dropdown')].filter(e=>e.offsetParent!==null).pop();
|
||
if(!dd) return 'no-dd';
|
||
const ops=[...dd.querySelectorAll('.ant-select-item-option')].filter(e=>e.offsetParent!==null);
|
||
if(!ops.length) return 'no-option';
|
||
const clean=s=>(s||'').replace(/\\s+/g,'').split('(')[0];
|
||
const op=ops.find(e=>clean(e.innerText)===clean(${JSON.stringify(kw)}))||ops[0];
|
||
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,26)+'/'+ops.length;})()`);
|
||
}
|
||
|
||
/** 详情图语言:标签就是「详情图」,优先英语 */
|
||
async function pickDetailLang(c) {
|
||
const pos = JSON.parse(String(await c.evalJS(`(()=>{
|
||
for(const e of document.querySelectorAll('.ant-form-item')){
|
||
if(e.offsetParent===null) continue;
|
||
const l=e.querySelector('label'); const t=l?(l.innerText||''):'';
|
||
if(t.indexOf('详情图')>=0&&e.querySelector('.ant-select')){
|
||
const s=e.querySelector('.ant-select-selector'); 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)]); } }
|
||
return '[]';})()`)));
|
||
if (!pos.length) return 'no-lang';
|
||
await c.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: pos[0], y: pos[1], buttons: 0 }); await wait(250);
|
||
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(2000);
|
||
// 列表是虚拟滚动的,前 10 项里没有「英语」→ 先试着输「英」过滤,不行就滚到底
|
||
await c.send('Input.insertText', { text: '英' }).catch(() => {});
|
||
await wait(1500);
|
||
let hit = await c.evalJS(`(()=>{const dd=[...document.querySelectorAll('.ant-select-dropdown')].filter(e=>e.offsetParent!==null).pop();
|
||
if(!dd) return 'no-dd';
|
||
const ops=[...dd.querySelectorAll('.ant-select-item-option')].filter(e=>e.offsetParent!==null);
|
||
const names=ops.map(e=>(e.innerText||'').trim()).filter(Boolean);
|
||
const op=ops.find(e=>/英|English/i.test(e.innerText||''));
|
||
if(!op) return 'none|'+names.slice(0,6).join('/');
|
||
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();})()`);
|
||
if (String(hit).startsWith('none')) {
|
||
hit = await c.evalJS(`(()=>{const dd=[...document.querySelectorAll('.ant-select-dropdown')].filter(e=>e.offsetParent!==null).pop();
|
||
if(!dd) return 'no-dd';
|
||
const sc=dd.querySelector('.rc-virtual-list-holder')||dd; sc.scrollTop=sc.scrollHeight;
|
||
const ops=[...dd.querySelectorAll('.ant-select-item-option')].filter(e=>e.offsetParent!==null);
|
||
const op=ops.find(e=>/英|English/i.test(e.innerText||''));
|
||
if(!op) return 'still-none|'+ops.map(e=>(e.innerText||'').trim()).slice(-6).join('/');
|
||
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();})()`);
|
||
}
|
||
return hit;
|
||
}
|
||
|
||
log(`Joom 批量开始:任务 ${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/joomProduct/add');
|
||
await wait(12000);
|
||
await installHook(c);
|
||
const shop = await tickShop(c, process.env.JOOM_SHOP || null);
|
||
if (!String(shop).startsWith('ticked:')) throw new Error('选店铺失败:' + shop);
|
||
await wait(6000);
|
||
const cat = await pickCategory(c, it.cat_kw || '发夹');
|
||
log(` 店=${shop.slice(7)} 类目=${cat}`);
|
||
if (!String(cat).startsWith('picked:')) throw new Error('选类目失败:' + cat);
|
||
// 记录实际落到的类目名(Joom 类目比 1688 粗,kw 常常只能近似匹配,事后要人工抽查)
|
||
const catName = String(await c.evalJS(`(()=>{const it=[...document.querySelectorAll('.ant-form-item')].filter(e=>e.offsetParent!==null)
|
||
.find(e=>/产品分类/.test((e.querySelector('label')||{}).innerText||''));
|
||
const s=it?it.querySelector('.ant-select'):null; return s?(s.innerText||'').replace(/\\s+/g,' ').trim().slice(0,40):'';})()`));
|
||
ROUTE[t.pid] = { shop: shop.slice(7), kw: it.cat_kw || '', cat: catName };
|
||
writeFileSync(`${DIR}/joom_route.json`, JSON.stringify(ROUTE, null, 1));
|
||
await wait(7000);
|
||
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.name=${JSON.stringify(it.title)}; b.parentSku='JM-'+${JSON.stringify(t.pid)};
|
||
b.description=(${JSON.stringify((it.bullets || []).join('. '))}||b.name);
|
||
b.tags=String(${JSON.stringify(it.keywords || '')}).split(', ').slice(0,10);
|
||
const pr=p._s.get('joomAddPriceStore').$state.formState;
|
||
pr.price=String(${JSON.stringify(String(it.price_cny || '19.90'))}); pr.inventory='999';
|
||
pr.shipping='0'; pr.msrp=''; pr.declaredValue=String(${JSON.stringify(String(it.cost || '10'))});
|
||
const im=p._s.get('joomAddImageStore').$state.formState;
|
||
im.imageList=imgs.map(mk);
|
||
if(Array.isArray(im.detailImageList)&&im.detailImageList[0]) im.detailImageList[0].images=imgs.map(mk);
|
||
return JSON.stringify({name:b.name.slice(0,18), price:pr.price, imgs:im.imageList.length, cat:b.categoryId});})()`);
|
||
log(` 填充: ${fill}`);
|
||
await wait(3500);
|
||
const attrs = await fillRequiredSelects(c);
|
||
if (attrs !== 'none') log(` 必填属性: ${attrs}`);
|
||
await wait(1500);
|
||
log(` 详情图语言: ${await pickDetailLang(c)}`);
|
||
await wait(2500);
|
||
if (dry) { delete done[t.pid]; log(' ✓ dry 构建完成(未保存)'); await closePage(c); continue; }
|
||
await c.evalJS(`window.scrollTo(0,0)`); await wait(600);
|
||
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 1;})()`);
|
||
await wait(13000);
|
||
const 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 ok = /已保存|保存成功|待发布/.test(mods);
|
||
done[t.pid] = ok ? { ok: true } : { ok: false, mods: mods.slice(0, 160), hook: String(cap).slice(-220) };
|
||
log(ok ? ' ✓ 已保存到待发布' : ` ✗ 失败 ${String(cap).slice(-180)}`);
|
||
} 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(`Joom 批量结束:成功 ${Object.values(done).filter(x => x.ok).length}/${tasks.length}`);
|