Files
dianxiaomi-crossborder-play…/temu建品/tmbatch.mjs
T

202 lines
13 KiB
JavaScript
Raw 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.
/**
* 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}`);