Files

180 lines
12 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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}`);