SHEIN 40/40 + Temu 全托管 46/46 批量进待发布;补 shbatch/tmbatch 配方、类目弹窗必须关、SHEIN 规格上限3、必填属性兜底、EAN 游标落盘
This commit is contained in:
1 parent
cdde45ed9a
commit
fd4cc6619b
16 files changed
+1260
-41
No files matched your search
@@ -23,3 +23,35 @@
|
||||
## 待批量
|
||||
- 40 条按品类分到 4 家店(美妆18 / 文具13 / 箱包8 / 内睡袜子1)
|
||||
- 需要把上述流程写成 `shbatch.mjs`,逐条跑
|
||||
|
||||
---
|
||||
|
||||
# 批量(已完成:40/40 进待发布,2026-09-20)
|
||||
|
||||
`node shbatch.mjs`(全部)· `node shbatch.mjs <pid>`(单条)· `node shbatch.mjs --dry <pid>`(只构建不保存)
|
||||
|
||||
- 数据源:`/home/admin9webs/tiktok建品/tk_input.json`(48 商品:title/imgs/cost/price_cny)
|
||||
- 路由:`shein_route.json`(4 家店 × 每条的类目关键词),`sh_done.json` 记逐条结果,可断点续跑
|
||||
- 公共库:`sh_lib.mjs`(newPage/closePage/wait/installHook/readHook)· `sh_build.mjs`(各步骤函数)
|
||||
|
||||
## 单条固定顺序(不能换)
|
||||
`选店铺 → 选类目 → 标题 → 加规格(≤3) → 选规格值 → 填 SKU 行 → 尺码表 → 图片 → 填必填属性 → 保存`
|
||||
|
||||
## 这次新踩的坑
|
||||
1. **选类目弹窗必须确认关掉**:`搜索` → 点 `.search-result-item`(它里面嵌 `<span class="f-red">`,
|
||||
所以别用 `children.length===0` 过滤)→ 若弹窗还在就点「选择/确定」或右上角 ×、按 Esc。
|
||||
弹窗没关时后面的点击全被挡,最终报的却是别的错(Temu 那边表现为「请上传预览图」)。
|
||||
2. **SHEIN 一个产品最多 3 个规格**(颜色已占 1 个)→ `addSpecs` 里加满 3 个就停,
|
||||
否则报「规格最多选择3个,请移除后再添加」。
|
||||
3. **规格要去重**:按标签去重,否则会点出「款式/款式」两个同名规格。
|
||||
4. **类目动态必填属性**用 `.attr-label.required` 标记(不是 `.ant-form-item-required`):
|
||||
- 下拉型空着 → 选第一项
|
||||
- 勾选组(如箱包的「特征 Features」,`.checkbox-group-with-search`)一个都没勾 → 勾第一个
|
||||
- 不填只会得到一句「产品信息中有错误,请检查」
|
||||
5. **店铺发品额度**:①Shein-武汉九个网-箱包 报「商家无可用的发品额度,暂时无法发布商品」,
|
||||
把那条改挂 ②Shein-易世通达-箱包(`SHOP_OVERRIDE='②Shein-易世通达-箱包'`)后成功。
|
||||
该类目在 ② 店只有「不保温午餐包」,与路由表写的「保温午餐包」不完全一致。
|
||||
|
||||
## 结果
|
||||
40 条全部「已保存到待发布」(39 条在路由指定店铺 + 1 条改挂 ② 店)。
|
||||
店小秘 SHEIN 待发布列表核对:共 44 条(含此前手工首条等)。
|
||||
@@ -0,0 +1,259 @@
|
||||
/** SHEIN 建品:单条产品的构建函数集(逐步验证中) */
|
||||
import { P, wait } from './sh_lib.mjs';
|
||||
|
||||
export async function mouseClick(c, x, y) {
|
||||
await c.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x, y, buttons: 0 }); await wait(250);
|
||||
for (const t of ['mousePressed', 'mouseReleased'])
|
||||
await c.send('Input.dispatchMouseEvent', { type: t, x, y, button: 'left', clickCount: 1, buttons: 1 });
|
||||
}
|
||||
async function centerOf(c, expr) {
|
||||
return JSON.parse(String(await c.evalJS(`(()=>{const e=${expr}; if(!e) return '[]';
|
||||
e.scrollIntoView({block:'center'}); const r=e.getBoundingClientRect();
|
||||
return JSON.stringify([Math.round(r.x+r.width/2), Math.round(r.y+r.height/2)]);})()`)));
|
||||
}
|
||||
|
||||
/** 选店铺账号(下拉里按名字精确匹配) */
|
||||
export async function selectShop(c, shopName) {
|
||||
const pos = await centerOf(c, `[...document.querySelectorAll('.ant-form-item')].filter(e=>e.offsetParent!==null).find(e=>/店铺账号/.test((e.querySelector('label')||{}).innerText||''))?.querySelector('.ant-select-selector')`);
|
||||
if (!pos.length) return 'no-select';
|
||||
await mouseClick(c, pos[0], pos[1]);
|
||||
await wait(2200);
|
||||
const hit = 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(shopName.replace(/\s+/g, ''))}));
|
||||
if(!it) return 'no-match';
|
||||
const r=it.getBoundingClientRect();
|
||||
for(const ev of ['mousedown','mouseup','click']) it.dispatchEvent(new MouseEvent(ev,{bubbles:true,cancelable:true,view:window,clientX:r.x+r.width/2,clientY:r.y+r.height/2}));
|
||||
return 'picked:'+(it.innerText||'').trim().slice(0,24);})()`);
|
||||
await wait(4500);
|
||||
return hit;
|
||||
}
|
||||
|
||||
/** 选产品分类(弹窗搜索关键词 → 点第一个结果 → 确定) */
|
||||
export async function selectCategory(c, kw, preferName) {
|
||||
const clicked = await 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 b=[...it.querySelectorAll('*')].find(e=>(e.innerText||'').trim()==='选择分类'&&e.children.length===0);
|
||||
if(!b) return 'no-btn';
|
||||
for(const ev of ['mousedown','mouseup','click']) b.dispatchEvent(new MouseEvent(ev,{bubbles:true,cancelable:true,view:window}));
|
||||
return 'opened';})()`);
|
||||
if (clicked !== 'opened') return clicked;
|
||||
await wait(3000);
|
||||
const typed = await c.evalJS(`(()=>{
|
||||
const m=[...document.querySelectorAll('.ant-modal')].filter(e=>e.offsetParent!==null).pop();
|
||||
if(!m) return 'no-modal';
|
||||
const inp=m.querySelector('input[placeholder="搜索分类名称"]')||m.querySelector('input');
|
||||
if(!inp) return 'no-input';
|
||||
const setter=Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype,'value').set;
|
||||
setter.call(inp, ${JSON.stringify(kw)}); inp.dispatchEvent(new Event('input',{bubbles:true})); inp.dispatchEvent(new Event('change',{bubbles:true}));
|
||||
return 'typed';})()`);
|
||||
if (typed !== 'typed') return typed;
|
||||
await wait(800);
|
||||
// 2) 点「搜索」→ 结果是一条红字完整类目路径,点它才算选中
|
||||
await c.evalJS(`(()=>{const m=[...document.querySelectorAll('.ant-modal')].filter(e=>e.offsetParent!==null).pop();
|
||||
const b=[...m.querySelectorAll('button')].find(x=>/^\\s*搜索\\s*$/.test((x.innerText||'').trim()));
|
||||
if(b) for(const ev of ['mousedown','mouseup','click']) b.dispatchEvent(new MouseEvent(ev,{bubbles:true,cancelable:true,view:window})); return 1;})()`);
|
||||
await wait(3200);
|
||||
let picked = 'no-path';
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
picked = await c.evalJS(`(()=>{
|
||||
const m=[...document.querySelectorAll('.ant-modal')].filter(e=>e.offsetParent!==null).pop();
|
||||
if(!m) return 'no-modal2';
|
||||
// 搜索结果:.search-result-item(里面通常还嵌 <span class="f-red">最后一级</span>,所以别按 children.length===0 过滤)
|
||||
const items=[...m.querySelectorAll('.search-result-item')].filter(e=>e.offsetParent!==null);
|
||||
const want=${JSON.stringify(preferName || '')};
|
||||
const seg=e=>{const t=(e.innerText||'').replace(/\s+/g,''); const parts=t.split('/'); return ((parts[parts.length-1]||'').split('(')[0]);};
|
||||
const it=(want && items.find(e=>seg(e)===want)) || (want && items.find(e=>(e.innerText||'').replace(/\s+/g,'').includes(want))) || items[0];
|
||||
if(!it) return 'no-path';
|
||||
const r=it.getBoundingClientRect();
|
||||
for(const ev of ['mousedown','mouseup','click']) it.dispatchEvent(new MouseEvent(ev,{bubbles:true,cancelable:true,view:window,clientX:r.x+r.width/2,clientY:r.y+r.height/2}));
|
||||
return 'path:'+(it.innerText||'').replace(/\\s+/g,' ').trim().slice(0,60);})()`);
|
||||
if (String(picked).startsWith('path:')) break;
|
||||
if (picked === 'no-modal2') break;
|
||||
await wait(2000);
|
||||
}
|
||||
await wait(1800);
|
||||
// 3) 弹窗必须关掉,否则后面的点击全被挡(曾出现「类目已选但弹窗还开着 → 预览图挂不上」)
|
||||
let left = Number(await c.evalJS(`[...document.querySelectorAll('.ant-modal')].filter(e=>e.offsetParent!==null).length`));
|
||||
for (let k = 0; k < 3 && left; k++) {
|
||||
await c.evalJS(`(()=>{const m=[...document.querySelectorAll('.ant-modal')].filter(e=>e.offsetParent!==null).pop();
|
||||
if(!m) return 'closed';
|
||||
const b=[...m.querySelectorAll('button')].find(x=>/^\\s*(确定|选择)\\s*$/.test((x.innerText||'').trim()));
|
||||
if(b) for(const ev of ['mousedown','mouseup','click']) b.dispatchEvent(new MouseEvent(ev,{bubbles:true,cancelable:true,view:window}));
|
||||
const c2=[...m.querySelectorAll('.ant-modal-close')][0]; if(c2) c2.click();
|
||||
return b?'ok':'still-open';})()`);
|
||||
await wait(1500);
|
||||
if (k >= 1) await c.send('Input.dispatchKeyEvent', {type:'keyDown', key:'Escape', code:'Escape', windowsVirtualKeyCode:27}).catch(()=>{});
|
||||
left = Number(await c.evalJS(`[...document.querySelectorAll('.ant-modal')].filter(e=>e.offsetParent!==null).length`));
|
||||
}
|
||||
await wait(3000);
|
||||
left = Number(await c.evalJS(`[...document.querySelectorAll('.ant-modal')].filter(e=>e.offsetParent!==null).length`));
|
||||
return picked + (left ? ' / MODAL-STILL-OPEN' : '');
|
||||
}
|
||||
|
||||
export async function fillTitle(c, title) {
|
||||
return c.evalJS(`(()=>{const p=${P};
|
||||
const f=p._s.get('sheinAddProductStore').$state.formState; f.productName=${JSON.stringify(title)};
|
||||
return 'ok';})()`);
|
||||
}
|
||||
|
||||
/** 逐个点开主题里还没启用的规格(颜色默认已启用,parent 带 disabled 类)。
|
||||
* 按标签去重:点开一个后同类主题可能还留在 DOM 里,重复点会多出「款式/款式」这种重复规格。 */
|
||||
export async function addSpecs(c, max = 4, maxTotal = 3) {
|
||||
const added = [], seen = new Set();
|
||||
for (let i = 0; i < max; i++) {
|
||||
// SHEIN 硬限制:一个产品最多 3 个规格(颜色已占 1 个)。加满就停,否则报「规格最多选择3个」。
|
||||
const boxes = Number(await c.evalJS(`[...document.querySelectorAll('.sku-info-box')].filter(e=>e.offsetParent!==null).length`));
|
||||
if (boxes >= maxTotal) break;
|
||||
const r = await c.evalJS(`(()=>{
|
||||
const items=[...document.querySelectorAll('.theme-select-item')].filter(e=>e.offsetParent!==null&&!/disabled/.test(String(e.className)));
|
||||
const out=items.map(it=>{const t=[...it.querySelectorAll('*')].find(x=>x.children.length===0&&(x.innerText||'').trim()); return t?((t.innerText||'').trim().slice(0,16)):'';}).filter(Boolean);
|
||||
return JSON.stringify(out);})()`);
|
||||
const cands = JSON.parse(String(r || '[]')).filter(x => !seen.has(x));
|
||||
if (!cands.length) break;
|
||||
const label = cands[0];
|
||||
await c.evalJS(`(()=>{
|
||||
const items=[...document.querySelectorAll('.theme-select-item')].filter(e=>e.offsetParent!==null&&!/disabled/.test(String(e.className)));
|
||||
const it=items.map(it=>{const t=[...it.querySelectorAll('*')].find(x=>x.children.length===0&&(x.innerText||'').trim()); return {it, t, label:t?(t.innerText||'').trim().slice(0,16):''};})
|
||||
.find(o=>o.label===${JSON.stringify(label)});
|
||||
if(!it) return 'nf';
|
||||
for(const ev of ['mousedown','mouseup','click']) it.t.dispatchEvent(new MouseEvent(ev,{bubbles:true,cancelable:true,view:window}));
|
||||
return 'ok';})()`);
|
||||
seen.add(label);
|
||||
added.push(label);
|
||||
await wait(5000);
|
||||
}
|
||||
return added.join(' | ');
|
||||
}
|
||||
|
||||
/** 每个规格组各勾第一个值(加规格会重建 SKU 行,必须在选完值之后再填 SKU 表) */
|
||||
export async function pickSpecValues(c) {
|
||||
const r = await c.evalJS(`(()=>{const boxes=[...document.querySelectorAll('.sku-info-box')].filter(e=>e.offsetParent!==null);
|
||||
const done=[];
|
||||
boxes.forEach((b,i)=>{ const opts=[...b.querySelectorAll('.sku-option')].filter(e=>e.offsetParent!==null);
|
||||
if(!opts.length){done.push(i+':no-opt');return;}
|
||||
const inp=opts[0].querySelector('input[type=checkbox]');
|
||||
if(inp&&inp.checked){done.push(i+':already');return;}
|
||||
if(inp&&!inp.checked) inp.click();
|
||||
const lab=opts[0].querySelector('label')||opts[0];
|
||||
for(const ev of ['mousedown','mouseup','click']) lab.dispatchEvent(new MouseEvent(ev,{bubbles:true,cancelable:true,view:window}));
|
||||
done.push(i+':'+(opts[0].innerText||'').trim().slice(0,16)); });
|
||||
return done.join(' | ');})()`);
|
||||
await wait(8000);
|
||||
return r;
|
||||
}
|
||||
|
||||
/** 填 SKU 行(在规格值选完之后调用) */
|
||||
export async function fillSkuRows(c, { prefix = 'SHEIN', price = '12.90', supply = '8.50', stock = '999' } = {}) {
|
||||
return c.evalJS(`(()=>{const p=${P};
|
||||
const d=p._s.get('sheinSkuDataStore').$state.formState;
|
||||
const rows=d.skuDataList||[]; let n=0;
|
||||
rows.forEach((x,i)=>{ x.skcSupplierCode=${JSON.stringify(prefix)}+'-'+String(i+1).padStart(3,'0');
|
||||
x.skcRetailPrice=${JSON.stringify(price)}; x.supplyPrice=${JSON.stringify(supply)}; x.stockVariety=${JSON.stringify(stock)};
|
||||
x.weight='100'; x.length='200'; x.width='150'; x.height='30'; n++; });
|
||||
return 'rows:'+n;})()`);
|
||||
}
|
||||
|
||||
/** 尺码表弹窗:6 个「请输入」→ 确定 */
|
||||
export async function fillSizeTable(c) {
|
||||
await c.evalJS(`(()=>{const it=[...document.querySelectorAll('.ant-form-item')].filter(e=>e.offsetParent!==null)
|
||||
.find(x=>{const l=x.querySelector('label'); return l&&/尺码表/.test(l.innerText||'');});
|
||||
if(!it)return 0; const el=it.querySelector('.link')||it.querySelector('a,span'); if(!el)return 0;
|
||||
el.scrollIntoView({block:'center'});
|
||||
for(const ev of ['mousedown','mouseup','click']) el.dispatchEvent(new MouseEvent(ev,{bubbles:true,cancelable:true,view:window})); return 1;})()`);
|
||||
await wait(5000);
|
||||
const f = await c.evalJS(`(()=>{const m=[...document.querySelectorAll('.ant-modal')].filter(e=>e.offsetParent!==null).pop();
|
||||
if(!m)return'no-modal'; const ins=[...m.querySelectorAll('input')].filter(i=>i.offsetParent!==null&&i.placeholder==='请输入');
|
||||
const vals=['10','18','3','2','18','5']; let n=0;
|
||||
ins.forEach((el,i)=>{ if(i>=vals.length)return; el.focus();
|
||||
Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype,'value').set.call(el,vals[i]);
|
||||
el.dispatchEvent(new Event('input',{bubbles:true})); el.dispatchEvent(new Event('change',{bubbles:true})); el.dispatchEvent(new Event('blur',{bubbles:true})); n++; });
|
||||
return 'filled:'+n;})()`);
|
||||
await wait(1200);
|
||||
await c.evalJS(`(()=>{const m=[...document.querySelectorAll('.ant-modal')].filter(e=>e.offsetParent!==null).pop();
|
||||
if(m){const b=[...m.querySelectorAll('button')].find(x=>/^\\s*确定\\s*$/.test(x.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(4000);
|
||||
return f;
|
||||
}
|
||||
|
||||
/** 图片:sheinImgStore.mainImgData.multipleSpuImgs 吃外链数组 */
|
||||
export async function fillImages(c, urls) {
|
||||
const r = await c.evalJS(`(()=>{const p=${P};
|
||||
const im=p._s.get('sheinImgStore').$state.formState;
|
||||
const arr=${JSON.stringify(urls)}.map((u,i)=>({url:u,imgUrl:u,name:'img'+(i+1)+'.jpg',width:800,height:800}));
|
||||
if(!Array.isArray(im.mainImgData.multipleSpuImgs)||im.mainImgData.multipleSpuImgs.length<arr.length) im.mainImgData.multipleSpuImgs=arr;
|
||||
return 'imgs:'+(Array.isArray(im.mainImgData.multipleSpuImgs)?im.mainImgData.multipleSpuImgs.length:'?');})()`);
|
||||
await wait(3000);
|
||||
return r;
|
||||
}
|
||||
|
||||
/** 兜底填「必填但没有值」的类目动态属性。
|
||||
* · 下拉型(.ant-select 空着)→ 选第一个选项
|
||||
* · 勾选型(.checkbox-group-with-search 里一个都没勾,如箱包的「特征 Features」)→ 勾第一个
|
||||
* 这些属性用 .attr-label.required 标记(不是 .ant-form-item-required),不填只会得到
|
||||
* 一句「产品信息中有错误,请检查」。
|
||||
*/
|
||||
export async function fillRequiredSelects(c, skip = ['店铺账号', '产品分类', '主规格', '产品标题', '店小秘分类', '尺码表']) {
|
||||
const ticked = await (async () => {
|
||||
const out = [];
|
||||
for (let round = 0; round < 6; round++) {
|
||||
const r = await c.evalJS(`(()=>{
|
||||
const items=[...document.querySelectorAll('.ant-form-item')].filter(e=>{
|
||||
if(e.offsetParent===null) return false;
|
||||
const req=e.querySelector('.attr-label.required')||e.querySelector('label.ant-form-item-required');
|
||||
if(!req) return false;
|
||||
const name=(req.innerText||'').replace(/\s+/g,' ').trim();
|
||||
if(${JSON.stringify(skip)}.some(s=>name.includes(s))) return false;
|
||||
const g=e.querySelector('.ant-checkbox-group'); if(!g) return false;
|
||||
const boxes=[...g.querySelectorAll('.ant-checkbox-wrapper')].filter(x=>x.offsetParent!==null);
|
||||
if(!boxes.length) return false;
|
||||
const checked=[...g.querySelectorAll('input.ant-checkbox-input')].some(i=>i.checked);
|
||||
return !checked;});
|
||||
const it=items[0]; if(!it) return '[]';
|
||||
const g=it.querySelector('.ant-checkbox-group');
|
||||
const box=[...g.querySelectorAll('.ant-checkbox-wrapper')].filter(x=>x.offsetParent!==null)[0];
|
||||
box.scrollIntoView({block:'center'});
|
||||
for(const ev of ['mousedown','mouseup','click']) box.dispatchEvent(new MouseEvent(ev,{bubbles:true,cancelable:true,view:window}));
|
||||
const lab=box.querySelector('span')||box;
|
||||
return JSON.stringify({name:(it.querySelector('.attr-label.required').innerText||'').replace(/\s+/g,' ').trim().slice(0,24), val:(lab.innerText||'').trim().slice(0,18)});})()`);
|
||||
const o = JSON.parse(String(r || '[]'));
|
||||
if (!o.name) break;
|
||||
out.push(o.name + '=' + o.val);
|
||||
await wait(1500);
|
||||
}
|
||||
return out.join(' | ');
|
||||
})();
|
||||
const filled = [];
|
||||
for (let round = 0; round < 8; round++) {
|
||||
const tgt = JSON.parse(String(await c.evalJS(`(()=>{
|
||||
const items=[...document.querySelectorAll('.ant-form-item')].filter(e=>{
|
||||
const l=e.querySelector('label'); if(!l||!/ant-form-item-required/.test(String(l.className))) return false;
|
||||
if(e.offsetParent===null) return false;
|
||||
const name=(l.innerText||'').replace(/\\s+/g,' ').trim();
|
||||
if(${JSON.stringify(skip)}.some(s=>name.includes(s))) return false;
|
||||
const sel=e.querySelector('.ant-select'); if(!sel) return false;
|
||||
const txt=(sel.innerText||'').replace(/\\s+/g,' ').trim();
|
||||
return !txt || /请选择/.test(txt);});
|
||||
const it=items[0]; if(!it) return '[]';
|
||||
const l=it.querySelector('label'); const sel=it.querySelector('.ant-select-selector')||it.querySelector('.ant-select');
|
||||
sel.scrollIntoView({block:'center'}); const r=sel.getBoundingClientRect();
|
||||
return JSON.stringify({name:(l.innerText||'').replace(/\\s+/g,' ').trim().slice(0,30), x:Math.round(r.x+r.width/2), y:Math.round(r.y+r.height/2)});})()`)));
|
||||
if (!tgt.name) break;
|
||||
await c.send('Input.dispatchMouseEvent', {type:'mouseMoved', x:tgt.x, y:tgt.y, buttons:0}); await wait(250);
|
||||
for (const t of ['mousePressed','mouseReleased']) await c.send('Input.dispatchMouseEvent', {type:t, x:tgt.x, y:tgt.y, button:'left', clickCount:1, buttons:1});
|
||||
await wait(1800);
|
||||
const pick = await c.evalJS(`(()=>{const dd=[...document.querySelectorAll('.ant-select-dropdown')].filter(e=>e.offsetParent!==null).pop();
|
||||
if(!dd) return 'no-dd';
|
||||
const op=[...dd.querySelectorAll('.ant-select-item-option')].filter(e=>e.offsetParent!==null)[0];
|
||||
if(!op) return 'no-op';
|
||||
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,18);})()`);
|
||||
filled.push(tgt.name + '=' + pick);
|
||||
await wait(1800);
|
||||
if (pick === 'no-dd' || pick === 'no-op') break;
|
||||
}
|
||||
return [filled.join(' | '), ticked].filter(x=>x&&x!=='none').join(' | ') || 'none';
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { newPage, closePage, wait, installHook, readHook } from './sh_lib.mjs';
|
||||
import { selectShop, selectCategory, fillTitle, addSpecs, pickSpecValues, fillSkuRows, fillSizeTable, fillImages } from './sh_build.mjs';
|
||||
import { SAN as S } from '/tmp/cdp3.mjs';
|
||||
import { readFileSync } from 'fs';
|
||||
const P = `document.querySelector('#app').__vue_app__.config.globalProperties.$pinia`;
|
||||
const pid='694637537727', kw='保温午餐包', shop=process.env.SHOP_OVERRIDE||'②Shein-易世通达-箱包';
|
||||
const it=JSON.parse(readFileSync('/home/admin9webs/tiktok建品/tk_input.json','utf8'))[pid];
|
||||
const c=await newPage('https://www.dianxiaomi.com/web/sheinProduct/add');
|
||||
await wait(11000); await installHook(c);
|
||||
console.log('shop:', await selectShop(c, shop));
|
||||
console.log('cat:', await selectCategory(c, kw));
|
||||
await fillTitle(c, it.title); await wait(1500);
|
||||
console.log('specs:', await addSpecs(c));
|
||||
console.log('vals:', await pickSpecValues(c));
|
||||
await fillSkuRows(c,{prefix:'SHEIN',price:String(it.price_cny),supply:String(it.cost)}); await wait(2500);
|
||||
console.log('size:', await fillSizeTable(c));
|
||||
console.log('imgs:', await fillImages(c,(it.imgs||[]).slice(0,6)));
|
||||
await wait(2000);
|
||||
// 保存一次并抓全部证据
|
||||
await c.evalJS(`window.scrollTo(0,0)`); await wait(500);
|
||||
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(12000);
|
||||
const d = await c.evalJS(`(()=>{
|
||||
const errItems=[...document.querySelectorAll('.ant-form-item-has-error')].map(x=>{const l=x.querySelector('label'); return (l?(l.innerText||''):'?').replace(/\\s+/g,' ').trim().slice(0,40);});
|
||||
const ex=[...document.querySelectorAll('.ant-form-item-explain-error')].map(e=>(e.innerText||'').trim());
|
||||
const reds=[]; document.querySelectorAll('*').forEach(el=>{ if(el.offsetParent===null||el.children.length)return;
|
||||
const t=(el.innerText||'').trim(); if(!t||t.length>50)return; const s=getComputedStyle(el).color||'';
|
||||
const m=s.match(/rgba?\\((\\d+),\\s*(\\d+),\\s*(\\d+)/); if(m&&+m[1]>150&&+m[2]<130&&+m[3]<130) reds.push(t); });
|
||||
const emptyReq=[...document.querySelectorAll('.ant-form-item')].filter(e=>{
|
||||
const l=e.querySelector('label'); if(!l||!/ant-form-item-required/.test(String(l.className))) return false;
|
||||
const inp=e.querySelector('input,textarea'); if(!inp) return false;
|
||||
return !inp.value && inp.offsetParent!==null;}).map(e=>(e.querySelector('label').innerText||'').replace(/\\s+/g,' ').trim().slice(0,34));
|
||||
return JSON.stringify({errItems:[...new Set(errItems)], ex:[...new Set(ex)].slice(0,8), reds:[...new Set(reds)].slice(0,12), emptyReq:[...new Set(emptyReq)].slice(0,12)});})()`);
|
||||
console.log('诊断:', S(String(d)).slice(0,1600));
|
||||
console.log('hook:', String(await readHook(c)).slice(-300));
|
||||
try{ await c.shot('/tmp/shein_diag.jpg'); console.log('shot ok'); }catch(e){}
|
||||
await closePage(c); process.exit(0);
|
||||
@@ -0,0 +1,162 @@
|
||||
{
|
||||
"661190598139": {
|
||||
"ok": true,
|
||||
"msg": "saved"
|
||||
},
|
||||
"902440558099": {
|
||||
"ok": true,
|
||||
"msg": "saved"
|
||||
},
|
||||
"823560564174": {
|
||||
"ok": true,
|
||||
"msg": "saved"
|
||||
},
|
||||
"607330049419": {
|
||||
"ok": true,
|
||||
"msg": "saved"
|
||||
},
|
||||
"854003999648": {
|
||||
"ok": true,
|
||||
"msg": "saved"
|
||||
},
|
||||
"735147938123": {
|
||||
"ok": true,
|
||||
"msg": "saved"
|
||||
},
|
||||
"610164857063": {
|
||||
"ok": true,
|
||||
"msg": "saved"
|
||||
},
|
||||
"833753630925": {
|
||||
"ok": true,
|
||||
"msg": "saved"
|
||||
},
|
||||
"676236596821": {
|
||||
"ok": true,
|
||||
"msg": "saved"
|
||||
},
|
||||
"944470567338": {
|
||||
"ok": true,
|
||||
"msg": "saved"
|
||||
},
|
||||
"787261097583": {
|
||||
"ok": true,
|
||||
"msg": "saved"
|
||||
},
|
||||
"857781294719": {
|
||||
"ok": true,
|
||||
"msg": "saved"
|
||||
},
|
||||
"863384503032": {
|
||||
"ok": true,
|
||||
"msg": "saved"
|
||||
},
|
||||
"726442972676": {
|
||||
"ok": true,
|
||||
"msg": "saved"
|
||||
},
|
||||
"893592264148": {
|
||||
"ok": true,
|
||||
"msg": "saved"
|
||||
},
|
||||
"836512561982": {
|
||||
"ok": true,
|
||||
"msg": "saved"
|
||||
},
|
||||
"714492799386": {
|
||||
"ok": true,
|
||||
"msg": "saved"
|
||||
},
|
||||
"719391062058": {
|
||||
"ok": true,
|
||||
"msg": "saved"
|
||||
},
|
||||
"953869908708": {
|
||||
"ok": true,
|
||||
"msg": "saved"
|
||||
},
|
||||
"863524134410": {
|
||||
"ok": true,
|
||||
"msg": "saved"
|
||||
},
|
||||
"623011262210": {
|
||||
"ok": true,
|
||||
"msg": "saved"
|
||||
},
|
||||
"997450831819": {
|
||||
"ok": true,
|
||||
"msg": "saved"
|
||||
},
|
||||
"639409307567": {
|
||||
"ok": true,
|
||||
"msg": "saved"
|
||||
},
|
||||
"698782391248": {
|
||||
"ok": true,
|
||||
"msg": "saved"
|
||||
},
|
||||
"591994902720": {
|
||||
"ok": true,
|
||||
"msg": "saved"
|
||||
},
|
||||
"682195499823": {
|
||||
"ok": true,
|
||||
"msg": "saved"
|
||||
},
|
||||
"657617635303": {
|
||||
"ok": true,
|
||||
"msg": "saved"
|
||||
},
|
||||
"703822717997": {
|
||||
"ok": true,
|
||||
"msg": "saved"
|
||||
},
|
||||
"621300323670": {
|
||||
"ok": true,
|
||||
"msg": "saved"
|
||||
},
|
||||
"823838532886": {
|
||||
"ok": true,
|
||||
"msg": "saved"
|
||||
},
|
||||
"1027332215847": {
|
||||
"ok": true,
|
||||
"msg": "saved"
|
||||
},
|
||||
"641673262387": {
|
||||
"ok": true,
|
||||
"msg": "saved"
|
||||
},
|
||||
"707826905566": {
|
||||
"ok": true,
|
||||
"msg": "saved"
|
||||
},
|
||||
"694637537727": {
|
||||
"ok": true,
|
||||
"msg": "saved"
|
||||
},
|
||||
"708420302844": {
|
||||
"ok": true,
|
||||
"msg": "saved"
|
||||
},
|
||||
"750271307455": {
|
||||
"ok": true,
|
||||
"msg": "saved"
|
||||
},
|
||||
"774494945453": {
|
||||
"ok": true,
|
||||
"msg": "saved"
|
||||
},
|
||||
"775057912118": {
|
||||
"ok": true,
|
||||
"msg": "saved"
|
||||
},
|
||||
"896469995553": {
|
||||
"ok": true,
|
||||
"msg": "saved"
|
||||
},
|
||||
"627879474686": {
|
||||
"ok": true,
|
||||
"msg": "saved"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/** SHEIN 批量公共库:按 target 直连页面,避免多处 attach(URL) 抢到同一个标签 */
|
||||
import { CDP } from '/tmp/cdp3.mjs';
|
||||
export const wait = ms => new Promise(z => setTimeout(z, ms));
|
||||
export const BASE = process.env.CDP_BASE || 'http://127.0.0.1:9223';
|
||||
|
||||
/** 新开一个页面并返回它自己的 CDP 连接(不会被别的同 URL 标签抢走) */
|
||||
export async function newPage(url = 'about:blank') {
|
||||
const r = await fetch(`${BASE}/json/new?${encodeURIComponent(url)}`, { method: 'PUT' });
|
||||
const t = await r.json();
|
||||
const c = await CDP.connect(t.webSocketDebuggerUrl);
|
||||
await c.send('Page.enable'); await c.send('Runtime.enable');
|
||||
c.targetId = t.id; c.pageUrl = url;
|
||||
return c;
|
||||
}
|
||||
export async function closePage(c) { try { await fetch(`${BASE}/json/close/${c.targetId}`); } catch {} try { c.close(); } catch {} }
|
||||
export async function listPages() { return (await (await fetch(`${BASE}/json/list`)).json()).filter(t => t.type === 'page'); }
|
||||
/** 关掉所有匹配 url 的旧标签(清理重复 tab,attach(URL) 才不会抢错) */
|
||||
export async function closePagesByUrl(part) {
|
||||
const ps = (await listPages()).filter(t => t.url.includes(part));
|
||||
for (const t of ps) await fetch(`${BASE}/json/close/${t.id}`).catch(() => {});
|
||||
return ps.length;
|
||||
}
|
||||
export const P = `document.querySelector('#app').__vue_app__.config.globalProperties.$pinia`;
|
||||
export const stub = store => `(()=>{const p=${P};return p._s.get('${store}').$state;})()`;
|
||||
export async function state(c, store) { return c.evalJS(`(()=>{const p=${P};const s=p._s.get('${store}').$state;return JSON.stringify(s.formState||s);})()`); }
|
||||
/** 在指定页面上点「保存」并抓 12 秒内的被吞消息 */
|
||||
export async function installHook(c) {
|
||||
await c.evalJS(`(()=>{ window.__cap=[];
|
||||
function wrap(o,n,l){ try{const f=o[n]; o[n]=function(){ try{window.__cap.push(l+': '+[...arguments].map(a=>typeof a==='string'?a:JSON.stringify(a)).join(' ').slice(0,170));}catch(e){} return f.apply(o,arguments);}; }catch(e){} }
|
||||
try{['error','warning','info','success'].forEach(k=>wrap(MYJ.message,k,'MYJ.'+k));}catch(e){}
|
||||
try{['error','warning','info','success'].forEach(k=>wrap(antd.message,k,'antd.'+k));}catch(e){} return 1;})()`);
|
||||
}
|
||||
export async function readHook(c) { return String(await c.evalJS(`JSON.stringify(window.__cap||[])`)); }
|
||||
export async function clickByText(c, re, tag = 'button') {
|
||||
return c.evalJS(`(()=>{const x=[...document.querySelectorAll('${tag}')].filter(e=>e.offsetParent!==null).find(e=>new RegExp(${JSON.stringify(re)}).test((e.innerText||'').trim()));
|
||||
if(!x) return 'nf'; x.scrollIntoView({block:'center'});
|
||||
for(const ev of ['mousedown','mouseup','click']) x.dispatchEvent(new MouseEvent(ev,{bubbles:true,cancelable:true,view:window})); return (x.innerText||'').trim().slice(0,20);})()`);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { newPage, closePage, wait } from './sh_lib.mjs';
|
||||
import { SAN as S } from '/tmp/cdp3.mjs';
|
||||
const c = await newPage('https://www.dianxiaomi.com/web/sheinProduct/offline');
|
||||
await wait(13000);
|
||||
const o = await c.evalJS(`(()=>{
|
||||
const body=(document.body.innerText||'').replace(/\\s+/g,' ');
|
||||
const m=body.match(/共\\s*\\d+\\s*条/);
|
||||
const rows=[...document.querySelectorAll('.vxe-body--row')].slice(0,3).map(r=>(r.innerText||'').replace(/\\s+/g,' ').trim().slice(0,110));
|
||||
const shops=[...new Set([...document.querySelectorAll('.vxe-body--row')].map(r=>{const m=(r.innerText||'').match(/[①-⑳][^\\s]{2,20}/); return m?m[0]:''}).filter(Boolean))];
|
||||
return JSON.stringify({total:m?m[0]:'', rows, shops});})()`);
|
||||
console.log(S(String(o)).slice(0,900));
|
||||
try{ await c.shot('/tmp/shein_offline.jpg'); console.log('shot ok'); }catch(e){ console.log('shot fail'); }
|
||||
await closePage(c); process.exit(0);
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* 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}`);
|
||||
Reference in new issue
Block a user