/** * SHEIN 批量建品(40 条,按 shein_route.json 分 4 家店) * * 用法: * node shbatch.mjs # 跑全部未完成的 * node shbatch.mjs 661190598139 # 只跑指定商品 * node shbatch.mjs --dry 661190598139 # 只构建不保存(验证用) * * 顺序(硬约束):选店铺 → 选类目 → 标题 → **加规格 → 选规格值** → 填 SKU 行 → 尺码表 → 图片 → 保存 * 加规格会重建 SKU 行,所以 SKU 表必须在规格之后填。 */ import { readFileSync, writeFileSync, appendFileSync, existsSync } from 'fs'; import { newPage, closePage, wait, installHook, readHook } from './sh_lib.mjs'; import { selectShop, selectCategory, fillTitle, addSpecs, pickSpecValues, fillSkuRows, fillSizeTable, fillImages, fillRequiredSelects } from './sh_build.mjs'; import { P } from './sh_lib.mjs'; const DIR = '/home/admin9webs/shein建品'; const INPUT = JSON.parse(readFileSync('/home/admin9webs/tiktok建品/tk_input.json', 'utf8')); const ROUTE = JSON.parse(readFileSync(`${DIR}/shein_route.json`, 'utf8')); const DONE_F = `${DIR}/sh_done.json`; const LOG_F = `${DIR}/sh_batch.log`; const TASKS = []; for (const [group, cfg] of Object.entries(ROUTE)) for (const [pid, kw] of Object.entries(cfg.kw)) TASKS.push({ pid, kw, shop: cfg.shop, group }); const argv = process.argv.slice(2); const dry = argv.includes('--dry'); const only = argv.filter(a => !a.startsWith('--')); const list = only.length ? TASKS.filter(t => only.includes(t.pid)) : TASKS; let done = {}; try { done = JSON.parse(readFileSync(DONE_F, 'utf8')); } catch {} const log = m => { const line = `[${new Date().toISOString().slice(11, 19)}] ${m}`; console.log(line); appendFileSync(LOG_F, line + '\n'); }; log(`SHEIN 批量开始:任务 ${list.length} 条(总 ${TASKS.length}),dry=${dry}`); for (const t of list) { if (!dry && done[t.pid] && done[t.pid].ok) { log(`跳过 ${t.pid}(已建)`); continue; } const it = INPUT[t.pid]; if (!it) { log(`✗ ${t.pid} 无输入数据`); done[t.pid] = { ok: false, msg: 'no-input' }; continue; } log(`── ${t.pid} [${t.group}] ${t.shop} / ${t.kw} :: ${String(it.title).slice(0, 40)}`); let c; try { c = await newPage('https://www.dianxiaomi.com/web/sheinProduct/add'); await wait(11000); await installHook(c); const shopName = process.env.SHOP_OVERRIDE || t.shop; // ①Shein-武汉九个网-箱包 报「无可用的发品额度」,可临时换 ② 验证 const shop = await selectShop(c, shopName); if (shop !== `picked:${shopName}`) throw new Error('选店铺失败:' + shop); const cat = await selectCategory(c, t.kw); if (!String(cat).startsWith('path:')) throw new Error('选类目失败:' + cat); await fillTitle(c, it.title); await wait(1500); const specs = await addSpecs(c); const vals = await pickSpecValues(c); log(` 规格: ${specs} / 值: ${vals}`); const st = await c.evalJS(`(()=>{const p=${P}; const f=p._s.get('sheinAddBasicStore').$state.formState; const d=p._s.get('sheinSkuDataStore').$state.formState; const a=p._s.get('sheinSkuAttrStore').$state.formState; return JSON.stringify({cat:f.categoryId, rows:(d.skuDataList||[]).length, attrs:(a.skuAttrs||[]).map(x=>x.name+':'+((x.options||x._options||[]).length))});})()`); log(` 状态: ${st}`); if (/"rows":0/.test(String(st))) throw new Error('SKU 行为 0(规格值没选中)'); await fillSkuRows(c, { prefix: 'SHEIN', price: String(it.price_cny || '12.90'), supply: String(it.cost || '8.50') }); await wait(2500); const sz = await fillSizeTable(c); log(` 尺码表: ${sz}`); const im = await fillImages(c, (it.imgs || []).slice(0, 6)); log(` 图片: ${im}`); const attrs = await fillRequiredSelects(c); // 类目相关的必填属性(如箱包的「特征」) if (attrs !== 'none') log(` 必填属性: ${attrs}`); if (dry) { log(` ✓ dry 构建完成(未保存)`); delete done[t.pid]; await closePage(c); continue; } // 保存 await c.evalJS(`window.scrollTo(0,0)`); await wait(600); await c.evalJS(`(()=>{const x=[...document.querySelectorAll('button')].filter(e=>e.offsetParent!==null).find(e=>/^\\s*保存\\s*$/.test((e.innerText||'').trim())); if(x) for(const ev of ['mousedown','mouseup','click']) x.dispatchEvent(new MouseEvent(ev,{bubbles:true,cancelable:true,view:window})); return 1;})()`); await wait(13000); const cap = await readHook(c); const info = await c.evalJS(`(()=>{ const mods=[...document.querySelectorAll('.ant-modal')].filter(e=>e.offsetParent!==null).map(e=>(e.innerText||'').replace(/\\s+/g,' ').trim().slice(0,160)); const ex=[...document.querySelectorAll('.ant-form-item-explain-error')].map(e=>(e.innerText||'').trim()).filter(Boolean); return JSON.stringify({href:location.href, mods, ex:[...new Set(ex)].slice(0,5)});})()`); const ok = /已保存|保存成功|待发布/.test(String(info)); if (!ok) { // 有些情况会弹「确定」确认框 await c.evalJS(`(()=>{const m=[...document.querySelectorAll('.ant-modal')].filter(e=>e.offsetParent!==null).pop(); if(m){const b=[...m.querySelectorAll('button')].find(x=>/确定|确认/.test(x.innerText||'')); if(b)b.click();} return 1;})()`); await wait(5000); } const info2 = await c.evalJS(`(()=>{const mods=[...document.querySelectorAll('.ant-modal')].filter(e=>e.offsetParent!==null).map(e=>(e.innerText||'').replace(/\\s+/g,' ').trim().slice(0,160)); return JSON.stringify({href:location.href, mods});})()`); const ok2 = ok || /已保存|保存成功|待发布/.test(String(info2)); done[t.pid] = ok2 ? { ok: true, msg: 'saved' } : { ok: false, msg: String(cap).slice(-260), info: String(info).slice(0, 200) }; log(ok2 ? ` ✓ 已保存到待发布` : ` ✗ 保存失败 | ${String(info).slice(0, 220)} | hook=${String(cap).slice(-220)}`); } catch (e) { done[t.pid] = { ok: false, msg: String(e).slice(0, 300) }; log(` ✗ 异常: ${String(e).slice(0, 260)}`); } finally { if (!dry) writeFileSync(DONE_F, JSON.stringify(done, null, 1)); if (c) await closePage(c); await wait(2500); } } const okN = Object.values(done).filter(x => x.ok).length; log(`SHEIN 批量结束:累计成功 ${okN} / ${TASKS.length}`);