202 lines
13 KiB
JavaScript
202 lines
13 KiB
JavaScript
/**
|
||
* Temu 全托管(①Alimingpro, shopId 4021529)批量建品
|
||
*
|
||
* 用法:
|
||
* node tmbatch.mjs --dry 548459586741 # 只构建不保存(验证)
|
||
* node tmbatch.mjs 548459586741 # 建这一条并保存
|
||
* node tmbatch.mjs # 跑全部未完成的
|
||
*
|
||
* 报错是层层递进的(每修一层才露出下一层),全靠 MYJ.message 钩子看:
|
||
* 省份 → 变种 → 尺寸 → 净重<重量 → 预览图(已解:见 tmpv.mjs)
|
||
* 顺序不可换:先规格值(theme-value-add 手动输值+添加)→ 再填 SKU 行 → 再挂预览图。
|
||
*/
|
||
import { readFileSync, writeFileSync, appendFileSync } from 'fs';
|
||
import { newPage, closePage, wait, installHook, readHook } from '/home/admin9webs/shein建品/sh_lib.mjs';
|
||
import { selectCategory } from '/home/admin9webs/shein建品/sh_build.mjs';
|
||
import { fillPreviewImages } from './tmpv.mjs';
|
||
|
||
const DIR = '/home/admin9webs/temu建品';
|
||
const P = `document.querySelector('#app').__vue_app__.config.globalProperties.$pinia`;
|
||
const INPUT = JSON.parse(readFileSync('/home/admin9webs/tiktok建品/tk_input.json', 'utf8'));
|
||
const ROUTE = JSON.parse(readFileSync(`${DIR}/temu_route.json`, 'utf8'));
|
||
const DONE_F = `${DIR}/tm_done.json`;
|
||
const LOG_F = `${DIR}/tm_batch.log`;
|
||
const SHOP = '①Alimingpro';
|
||
const PROVINCE = '43000000000002'; // 北京市(region2Id)
|
||
// 已手工建过、别再建一条的(否则待发布里重复)
|
||
const SKIP = new Set(['661190598139']);
|
||
|
||
const argv = process.argv.slice(2);
|
||
const dry = argv.includes('--dry');
|
||
const only = argv.filter(a => !a.startsWith('--'));
|
||
let tasks = Object.entries(ROUTE).map(([pid, v]) => ({ pid, cat: v.cat, catId: v.catId }));
|
||
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 {}
|
||
const log = m => { const l = `[${new Date().toISOString().slice(11, 19)}] ${m}`; console.log(l); appendFileSync(LOG_F, l + '\n'); };
|
||
|
||
async function selectShop(c, name) {
|
||
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(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(2200);
|
||
const r = await c.evalJS(`(()=>{const dd=[...document.querySelectorAll('.ant-select-dropdown')].filter(e=>e.offsetParent!==null).pop();
|
||
if(!dd) return 'no-dd';
|
||
const it=[...dd.querySelectorAll('.ant-select-item-option')].find(e=>(e.innerText||'').replace(/\\s+/g,'').includes(${JSON.stringify(name)}));
|
||
if(!it) return 'no-match'; const rr=it.getBoundingClientRect();
|
||
for(const ev of ['mousedown','mouseup','click']) it.dispatchEvent(new MouseEvent(ev,{bubbles:true,cancelable:true,view:window,clientX:rr.x+rr.width/2,clientY:rr.y+rr.height/2}));
|
||
return 'picked:'+(it.innerText||'').trim();})()`);
|
||
await wait(5000);
|
||
return r;
|
||
}
|
||
|
||
/** 变种:每个 .theme-value-add 块输一个值 → 点「添加」(直接改 store 无效) */
|
||
async function addVariantValues(c, values = ['Black', 'One Size']) {
|
||
const out = [];
|
||
for (let i = 0; i < 2; i++) {
|
||
const info = JSON.parse(String(await c.evalJS(`(()=>{const blocks=[...document.querySelectorAll('.theme-value-add')].filter(e=>e.offsetParent!==null);
|
||
const b=blocks[${i}]; if(!b) return '[]';
|
||
const inp=b.querySelector('input[class*="!w-200"]')||b.querySelector('input'); if(!inp) return '[0,0]';
|
||
b.scrollIntoView({block:'center'}); const r=inp.getBoundingClientRect();
|
||
inp.setAttribute('data-tmfill','1');
|
||
return JSON.stringify([Math.round(r.x+r.width/2),Math.round(r.y+r.height/2)]);})()`)));
|
||
if (!info.length) { out.push('no-block' + i); continue; }
|
||
for (const t of ['mousePressed', 'mouseReleased'])
|
||
await c.send('Input.dispatchMouseEvent', { type: t, x: info[0], y: info[1], button: 'left', clickCount: 1, buttons: 1 });
|
||
await wait(300);
|
||
await c.send('Input.insertText', { text: values[i] || ('V' + i) }).catch(() => {});
|
||
// 兜底:直接写 value + input 事件
|
||
await c.evalJS(`(()=>{const inp=document.querySelector('input[data-tmfill="1"]'); if(!inp) return 0;
|
||
const setter=Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype,'value').set;
|
||
if(!inp.value) { setter.call(inp, ${JSON.stringify(values[i] || '')}); inp.dispatchEvent(new Event('input',{bubbles:true})); }
|
||
return 1;})()`);
|
||
await wait(600);
|
||
const added = await c.evalJS(`(()=>{const b=[...document.querySelectorAll('.theme-value-add')].filter(e=>e.offsetParent!==null)[${i}];
|
||
if(!b) return 'no-block';
|
||
const btn=[...b.querySelectorAll('button')].find(x=>/添加/.test(x.innerText||''));
|
||
if(!btn) return 'no-add-btn';
|
||
for(const ev of ['mousedown','mouseup','click']) btn.dispatchEvent(new MouseEvent(ev,{bubbles:true,cancelable:true,view:window}));
|
||
return 'added';})()`);
|
||
out.push(added);
|
||
await wait(2500);
|
||
}
|
||
return out.join(' | ');
|
||
}
|
||
|
||
/** 填其余表单(图片/产地/包装/标题) */
|
||
function padImgs(list, n = 5) { const a = (list || []).slice(0, 6); if (!a.length) return a;
|
||
let i = 0; while (a.length < n) { a.push(a[i % list.length]); i++; } return a; }
|
||
|
||
async function fillBasics(c, it) {
|
||
const PADS = padImgs(it.imgs);
|
||
return c.evalJS(`(()=>{const p=${P};
|
||
const f=p._s.get('temuAddProductStore').$state.formState;
|
||
const imgs=${JSON.stringify(PADS)};
|
||
const mk=(u,i)=>({url:u,imgUrl:u,name:'img'+(i+1)+'.jpg',width:800,height:800});
|
||
f.productName=${JSON.stringify(it.title)};
|
||
f.mainImage=imgs.slice(0,5).map(mk);
|
||
f.materialImage=imgs.slice(0,3).map(mk);
|
||
f.productOrigin='CN';
|
||
f.region2Id=${JSON.stringify(PROVINCE)};
|
||
const q=p._s.get('temuAddOtherStore').$state;
|
||
if(q.packageFormState){ q.packageFormState.packageImages=imgs.slice(0,3).map(mk); q.packageFormState.packageShape=1; q.packageFormState.packageType=0; }
|
||
return JSON.stringify({main:f.mainImage.length, padded:imgs.length, mat:Array.isArray(f.materialImage)?f.materialImage.length:'obj',
|
||
pkg:q.packageFormState?q.packageFormState.packageImages.length:'-'});})()`);
|
||
}
|
||
|
||
/** SKU 行:货号/EAN/价格/尺寸/重量/净重。返回 {rows, codes, next} —— 游标必须落盘,否则换进程重跑会撞号 */
|
||
async function fillSkuRows(c, it, eanStart, pid) {
|
||
return JSON.parse(String(await c.evalJS(`(()=>{const p=${P};
|
||
const d=p._s.get('temuAddSkuDataStore').$state.formState;
|
||
const rows=d.skuDataList||[]; let pc=${eanStart}; const codes=[];
|
||
const price=String(${JSON.stringify(String(it.price_cny || '19.90'))});
|
||
const cost=String(${JSON.stringify(String(it.cost || '10'))});
|
||
const chk=v=>{const s=String(v||'');const d12=s.slice(0,12).split('').map(Number);
|
||
const sum=d12.reduce((a,x,i)=>a+x*(i%2===0?1:3),0); return s.slice(0,12)+String((10-sum%10)%10);};
|
||
rows.forEach((x,i)=>{ x.extCode='TM-'+${JSON.stringify(pid)}+'-'+String(i+1);
|
||
x.code=chk('69803093'+String(pc).padStart(4,'0')); codes.push(x.code);
|
||
x.supplierPrice=cost; x.suggestedPrice=price;
|
||
x.length='20'; x.width='15'; x.height='3'; x.weight='100'; x.netWeight='90'; pc++; });
|
||
return JSON.stringify({rows:rows.length, codes, next:pc});})()`)));
|
||
}
|
||
|
||
log(`Temu 批量开始:任务 ${tasks.length},dry=${dry}`);
|
||
// EAN 游标必须持久化:换进程重跑时从 ean_cursor.json 接着走,否则会撞号
|
||
const CURSOR_F = `${DIR}/ean_cursor.json`;
|
||
function loadCursor() {
|
||
try { const j = JSON.parse(readFileSync(CURSOR_F, 'utf8')); if (j.next) return j.next; } catch {}
|
||
let max = 3448; // 3447/3448 已被手工测试条(Hair Clips)占用
|
||
try {
|
||
for (const line of readFileSync(`${DIR}/ean_temu_used.txt`, 'utf8').split('\n')) {
|
||
const m = line.match(/69803093(\d{4})\d/); if (m) max = Math.max(max, Number(m[1]));
|
||
}
|
||
} catch {}
|
||
return max + 1;
|
||
}
|
||
let eanCursor = loadCursor();
|
||
const saveCursor = () => writeFileSync(CURSOR_F, JSON.stringify({ next: eanCursor, at: new Date().toISOString() }, null, 1));
|
||
log(`EAN 游标起点:pc ${eanCursor}(台账文件 ${CURSOR_F})`);
|
||
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} ${t.cat}(${t.catId}) :: ${String(it.title).slice(0, 42)}`);
|
||
let c;
|
||
try {
|
||
c = await newPage('https://www.dianxiaomi.com/web/temu/add');
|
||
await wait(12000);
|
||
await installHook(c);
|
||
log(` 店铺: ${await selectShop(c, SHOP)}`);
|
||
log(` 类目: ${await selectCategory(c, t.cat, t.cat)}`);
|
||
const catNow = await c.evalJS(`(()=>{const p=${P};const f=p._s.get('temuAddBasicStore').$state.formState;return String(f.categoryId||'');})()`);
|
||
if (catNow && String(t.catId) && catNow !== String(t.catId)) log(` ⚠ 类目 id 不一致:选到 ${catNow},路由表写 ${t.catId}`);
|
||
log(` 基础: ${await fillBasics(c, it)}`);
|
||
await wait(3500);
|
||
log(` 变种: ${await addVariantValues(c, ['Black', 'One Size'])}`);
|
||
const st = await c.evalJS(`(()=>{const p=${P};
|
||
const d=p._s.get('temuAddSkuDataStore').$state.formState;
|
||
const a=p._s.get('temuAddSkuAttrStore').$state.formState;
|
||
return JSON.stringify({rows:(d.skuDataList||[]).length, attrs:(a.skuAttrs||[]).map(x=>x.parentSpecName+':'+((x.options||[]).length))});})()`);
|
||
log(` 状态: ${st}`);
|
||
if (/"rows":0/.test(String(st))) throw new Error('变种行没生成');
|
||
const sku = await fillSkuRows(c, it, eanCursor, t.pid);
|
||
eanCursor = sku.next; saveCursor(); // 用掉几个号就前进几个,且立刻落盘(失败也不回退,宁浪费不撞号)
|
||
log(` SKU: rows=${sku.rows} EAN=${JSON.stringify(sku.codes)} → 下一号 pc ${eanCursor}`);
|
||
await wait(2500);
|
||
const pv = await fillPreviewImages(c);
|
||
const miss = JSON.parse(String(await c.evalJS(`(()=>{const p=${P};
|
||
const rows=p._s.get('temuAddSkuDataStore').$state.formState.skuDataList||[];
|
||
return JSON.stringify(rows.map((r,i)=>r.image?null:i).filter(x=>x!==null));})()`)));
|
||
if (miss.length) { log(` ⚠ 预览图缺 ${JSON.stringify(miss)},补挂`); pv.push(...await fillPreviewImages(c, miss)); }
|
||
log(` 预览图行: ${JSON.stringify(pv)}`);
|
||
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 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 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));
|
||
return JSON.stringify({mods});})()`);
|
||
const cap = await readHook(c);
|
||
const ok = /已保存|保存成功|待发布/.test(String(info));
|
||
if (ok) {
|
||
const codes = await c.evalJS(`(()=>{const p=${P};const d=p._s.get('temuAddSkuDataStore').$state.formState;
|
||
return JSON.stringify((d.skuDataList||[]).map(r=>r.code));})()`);
|
||
appendFileSync(`${DIR}/ean_temu_used.txt`, `${t.pid}\t${codes}\t${new Date().toISOString()}\n`);
|
||
log(` EAN: ${codes}(已记入 ean_temu_used.txt,需回 S03 落盘)`);
|
||
}
|
||
done[t.pid] = ok ? { ok: true, ean: sku.codes } : { ok: false, ean: sku.codes, msg: String(info).slice(0, 160), hook: String(cap).slice(-240) };
|
||
log(ok ? ` ✓ 已保存到待发布` : ` ✗ 失败 ${String(info).slice(0, 200)} | ${String(cap).slice(-200)}`);
|
||
} catch (e) {
|
||
done[t.pid] = { ok: false, msg: String(e).slice(0, 260) };
|
||
log(` ✗ 异常: ${String(e).slice(0, 240)}`);
|
||
} finally {
|
||
if (!dry) writeFileSync(DONE_F, JSON.stringify(done, null, 1));
|
||
if (c) await closePage(c);
|
||
await wait(2500);
|
||
}
|
||
}
|
||
log(`Temu 批量结束:成功 ${Object.values(done).filter(x => x.ok).length}/${tasks.length}`);
|