Files

107 lines
6.2 KiB
JavaScript

/**
* 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}`);