// ==UserScript==
// @name Moda ERP 图案链接采集器
// @namespace http://tampermonkey.net/
// @version 4.3.1
// @description 自动翻页采集所有商品的图案链接,导出Excel
// @author QoderWork
// @match https://pod.modaai.net/my-design/my-commodity/mycommoditycenter*
// @run-at document-idle
// @inject-into page
// ==/UserScript==
(function() {
'use strict';
const API = 'https://api.modaai.net';
let XLSX = null;
let collectedData = [];
let stopFlag = false;
let shadowRoot = null;
// ========== 拦截页面 API 请求 ==========
let lastListBody = null;
let captureResolve = null;
function tryCaptureList(url, body) {
if (!url || !url.includes('/pod/product/selectProductList')) return;
try {
if (typeof body === 'string') {
lastListBody = JSON.parse(body);
if (captureResolve) { const r = captureResolve; captureResolve = null; r(lastListBody); }
}
} catch(e) {}
}
// 用 prototype 级别拦截,即使页面已创建 XHR 实例也能捕获
(function interceptXHR() {
if (window.__mc_xhr2__) return;
window.__mc_xhr2__ = true;
const OrigXHR = window.XMLHttpRequest;
// 替换构造函数
window.XMLHttpRequest = function() {
const xhr = new OrigXHR();
let _url = '';
const origOpen = xhr.open;
xhr.open = function(method, url) { _url = url; return origOpen.apply(this, arguments); };
const origSend = xhr.send;
xhr.send = function(body) {
tryCaptureList(_url, body);
return origSend.apply(this, arguments);
};
return xhr;
};
// 同时拦截 prototype(捕获已存在的实例)
try {
const origProtoOpen = OrigXHR.prototype.open;
OrigXHR.prototype.open = function(method, url) {
this.__mc_url__ = url;
return origProtoOpen.apply(this, arguments);
};
const origProtoSend = OrigXHR.prototype.send;
OrigXHR.prototype.send = function(body) {
tryCaptureList(this.__mc_url__, body);
return origProtoSend.apply(this, arguments);
};
} catch(e) {}
})();
// 同时拦截 fetch
(function interceptFetch() {
if (window.__mc_fetch2__) return;
window.__mc_fetch2__ = true;
const origFetch = window.fetch;
window.fetch = function(...args) {
const url = typeof args[0] === 'string' ? args[0] : (args[0] && args[0].url);
const options = args[1] || {};
tryCaptureList(url, options.body);
return origFetch.apply(this, args);
};
})();
// 主动触发一次翻页来捕获请求体
function captureByPageFlip() {
return new Promise((resolve) => {
captureResolve = resolve;
// 点下一页
const nextBtn = document.querySelector('.el-pagination .btn-next');
if (nextBtn && !nextBtn.classList.contains('disabled')) {
nextBtn.click();
} else {
// 如果在最后一页,点上一页
const prevBtn = document.querySelector('.el-pagination .btn-prev');
if (prevBtn && !prevBtn.classList.contains('disabled')) {
prevBtn.click();
} else {
// 都无法点击,直接 resolve null
captureResolve = null;
resolve(null);
}
}
// 5秒超时
setTimeout(() => {
if (captureResolve) { captureResolve = null; resolve(lastListBody || null); }
}, 5000);
});
}
// ========== 工具函数 ==========
function getToken() {
const m = document.cookie.match(/(?:^|;\s*)token=([^;]*)/);
return m ? m[1] : null;
}
function getOrgId() {
try {
const v = localStorage.getItem('org-id');
return v ? v.replace(/^"|"$/g, '') : '6';
} catch(e) { return '6'; }
}
function apiFetch(url, options) {
const token = getToken();
if (!token) throw new Error('未登录');
const sep = url.indexOf('?') > -1 ? '&' : '?';
const bustUrl = API + url + sep + '_cb=' + Date.now();
return fetch(bustUrl, {
...options,
cache: 'no-store',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + token,
'org-id': getOrgId(),
'X-Skip-SW-Cache': 'true',
...(options && options.headers || {})
}
}).then(r => {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
});
}
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
function $(id) { return shadowRoot.getElementById(id); }
function waitUntil(fn, timeout, interval) {
return new Promise((resolve, reject) => {
const start = Date.now();
const timer = setInterval(() => {
try {
if (fn()) { clearInterval(timer); resolve(); return; }
} catch(e) {}
if (Date.now() - start > timeout) { clearInterval(timer); reject(new Error('等待超时')); }
}, interval);
});
}
function loadXLSX() {
return new Promise((resolve, reject) => {
if (XLSX && XLSX.utils) { resolve(); return; }
fetch('https://cdn.sheetjs.com/xlsx-0.20.3/package/dist/xlsx.full.min.js', { cache: 'no-store' })
.then(r => r.text())
.then(code => {
// 清理可能存在的残缺 XLSX
if (window.XLSX && !window.XLSX.utils) delete window.XLSX;
// 方式1: indirect eval (全局作用域)
(0, eval)(code);
if (window.XLSX && window.XLSX.utils) { XLSX = window.XLSX; resolve(); return; }
// 方式2: new Function (隔离作用域)
try {
const fn = new Function(code + '\nreturn (typeof XLSX !== "undefined" ? XLSX : window.XLSX);');
const result = fn();
if (result && result.utils) { XLSX = result; window.XLSX = result; resolve(); return; }
} catch(e2) {}
// 方式3: script tag 加载
try {
if (window.XLSX && !window.XLSX.utils) delete window.XLSX;
const script = document.createElement('script');
script.src = 'https://cdn.sheetjs.com/xlsx-0.20.3/package/dist/xlsx.full.min.js?_t=' + Date.now();
script.onload = () => {
if (window.XLSX && window.XLSX.utils) { XLSX = window.XLSX; resolve(); }
else reject(new Error('SheetJS 加载后未正确挂载'));
};
script.onerror = () => reject(new Error('SheetJS CDN 加载失败'));
document.head.appendChild(script);
return;
} catch(e3) {}
reject(new Error('SheetJS 无法加载'));
})
.catch(e => reject(new Error('SheetJS 下载失败: ' + e.message)));
});
}
// ========== 读取当前页码 ==========
function readCurrentPage() {
// 从分页组件的跳转输入框读取
const jumper = document.querySelector('.el-pagination__jump input');
if (jumper && jumper.value) return parseInt(jumper.value) || 1;
// 兜底:从 active 页码读取
const active = document.querySelector('.el-pager li.is-active');
if (active) return parseInt(active.textContent.trim()) || 1;
return 1;
}
// 从页面 UI 读取筛选条件(兜底,当 XHR 拦截器未捕获到时使用)
function readPageFilters() {
const filters = {};
// 只找顶层 el-segmented 容器(避免匹配到内部子元素)
document.querySelectorAll('div.el-segmented').forEach(g => {
const selected = g.querySelector('.el-segmented__item.is-selected');
if (selected) {
const label = selected.querySelector('.el-segmented__item-label');
const t = label ? label.textContent.trim() : selected.textContent.trim();
if (t === '服饰') filters.type = '1';
else if (t === '非服饰') filters.type = '2';
else if (t === '烫画') filters.productionProcess = '1';
else if (t === '满印') filters.productionProcess = '2';
}
});
return filters;
}
// ========== 从 API 获取商品列表 ==========
async function fetchProductsPage(pageNum, pageSize) {
// 使用拦截到的过滤条件,如果没有则用默认值
const filters = lastListBody ? { ...lastListBody } : { pageSize: 30 };
delete filters.page;
delete filters._cb;
const body = { ...filters, page: pageNum, pageSize: pageSize || 30 };
const resp = await apiFetch('/pod/product/selectProductList', {
method: 'POST',
body: JSON.stringify(body)
});
if (resp.code !== 200 && resp.code !== 0) {
throw new Error('API 返回错误: ' + (resp.message || resp.code));
}
const data = resp.data;
const records = (data.records || []).map(r => ({
id: String(r.id),
spu: r.spu || r.spuCode || '',
name: (r.title && (r.title.cn || r.title.en)) || r.title || r.productName || ''
}));
return {
records,
total: parseInt(data.total) || 0,
pages: data.pages || Math.ceil(parseInt(data.total) / (pageSize || 30))
};
}
// ========== UI 创建 ==========
function createPanel() {
if (document.getElementById('mc-host')) return;
const host = document.createElement('div');
host.id = 'mc-host';
host.style.cssText = 'position:fixed;top:0;left:0;width:0;height:0;z-index:2147483647;';
document.body.appendChild(host);
shadowRoot = host.attachShadow({ mode: 'open' });
shadowRoot.innerHTML = `
图案链接采集器
条商品
设置采集数量后点击开始
`;
$('mc-close').addEventListener('click', () => host.remove());
$('mc-start').addEventListener('click', startCollection);
$('mc-stop').addEventListener('click', () => { stopFlag = true; });
$('mc-excel').addEventListener('click', exportExcel);
const panel = $('mc-panel');
const hdr = $('mc-hdr');
let dragging = false, sx, sy, ox, oy;
hdr.addEventListener('mousedown', e => {
dragging = true;
const rect = panel.getBoundingClientRect();
sx = e.clientX; sy = e.clientY;
ox = rect.left; oy = rect.top;
e.preventDefault();
});
shadowRoot.addEventListener('mousemove', e => {
if (!dragging) return;
panel.style.left = (ox + e.clientX - sx) + 'px';
panel.style.top = (oy + e.clientY - sy) + 'px';
});
shadowRoot.addEventListener('mouseup', () => { dragging = false; });
document.addEventListener('mouseup', () => { dragging = false; });
}
// ========== 采集逻辑 ==========
async function startCollection() {
const $status = $('mc-status');
const $bar = $('mc-bar');
const $progress = $('mc-progress-wrap');
const $log = $('mc-log');
const $start = $('mc-start');
const $stop = $('mc-stop');
const $excel = $('mc-excel');
const limit = parseInt($('mc-limit').value) || 100;
collectedData = [];
stopFlag = false;
$log.innerHTML = '';
$log.style.display = 'none';
$excel.style.display = 'none';
$start.style.display = 'none';
$stop.style.display = 'inline-block';
$progress.style.display = 'block';
$bar.style.width = '0%';
try {
// 读取当前页码
const currentPage = readCurrentPage();
$status.textContent = `正在获取商品列表(从第 ${currentPage} 页开始)...`;
// 如果没有捕获到请求参数,自动触发翻页来捕获
if (!lastListBody) {
$status.textContent = '正在捕获筛选参数...';
const captured = await captureByPageFlip();
if (!captured) {
$status.textContent = '无法捕获请求参数,请手动翻一页后再试。';
finish();
return;
}
// 等页面加载完新数据
await sleep(1500);
}
// 先获取第一页,确定总数
const firstPage = await fetchProductsPage(currentPage, 30);
if (firstPage.records.length === 0) {
$status.textContent = '未找到商品。请先在页面上切换一下筛选条件或翻页,让脚本捕获请求参数。';
finish();
return;
}
$status.textContent = `共 ${firstPage.total} 条商品,当前第 ${currentPage} 页(${firstPage.pages} 页),开始采集...`;
$log.style.display = 'block';
let totalCollected = 0;
let pageNum = currentPage;
let currentBatch = firstPage.records;
const totalPages = firstPage.pages;
while (totalCollected < limit && !stopFlag) {
// 并发处理,每次3个
const concurrency = 3;
const batch = currentBatch.slice(0, Math.min(currentBatch.length, limit - totalCollected));
for (let i = 0; i < batch.length && !stopFlag; i += concurrency) {
const chunk = batch.slice(i, i + concurrency);
const results = await Promise.all(chunk.map(async (p) => {
if (stopFlag) return null;
const globalIdx = totalCollected + chunk.indexOf(p) + 1;
$status.textContent = `(${globalIdx}/${limit}) 第${pageNum}页 ${p.spu}`;
try {
const urls = await fetchPatternUrls(p.id);
if (urls.length > 0) {
return { page: pageNum, spu: p.spu, name: p.name, urls };
}
} catch(e) {
console.warn(p.spu, e.message);
}
return null;
}));
for (const r of results) {
if (r) {
collectedData.push(r);
addGroup(r.page, r.spu, r.urls);
}
totalCollected++;
}
$bar.style.width = (Math.min(totalCollected, limit) / limit * 100) + '%';
if (totalCollected >= limit || stopFlag) break;
await sleep(300);
}
// 获取下一页
if (totalCollected < limit && !stopFlag && pageNum < totalPages) {
pageNum++;
$status.textContent = `正在获取第 ${pageNum} 页商品列表...`;
try {
const nextPage = await fetchProductsPage(pageNum, 30);
currentBatch = nextPage.records;
if (currentBatch.length === 0) break;
} catch(e) {
$status.textContent = `获取第 ${pageNum} 页失败: ${e.message}`;
break;
}
} else {
break;
}
}
$status.textContent = stopFlag
? `已停止,已采集 ${collectedData.length} 条有图案链接`
: `完成!共 ${collectedData.length} 条有图案链接`;
$excel.style.display = 'inline-block';
} catch(e) {
$status.textContent = '失败: ' + e.message;
console.error(e);
} finally {
finish();
}
}
function finish() {
$('mc-start').style.display = 'inline-block';
$('mc-start').textContent = '重新采集';
$('mc-stop').style.display = 'none';
$('mc-progress-wrap').style.display = 'none';
}
// ========== 获取图案链接(API优先,iframe兜底) ==========
async function fetchPatternUrls(productId) {
// 先尝试 API
try {
const urls = await fetchPatternUrlsFromAPI(productId);
if (urls.length >= 2) return urls; // API 返回了完整数据
if (urls.length === 1) {
// 只有1条,可能 SW 缓存导致不完整,用 iframe 兜底
const iframeUrls = await fetchPatternUrlsFromIframe(productId);
return iframeUrls.length > urls.length ? iframeUrls : urls;
}
} catch(e) {
// API 失败,用 iframe 兜底
}
return fetchPatternUrlsFromIframe(productId);
}
// 通过 getProductById API 获取图案链接
async function fetchPatternUrlsFromAPI(productId) {
const token = getToken();
if (!token) throw new Error('未登录');
// 用 XHR 绕过 Service Worker 缓存
return new Promise((resolve, reject) => {
const url = API + '/pod/product/getProductById?productId=' + productId + '&_t=' + Date.now() + Math.random();
const xhr = new XMLHttpRequest();
xhr.open('GET', url, true);
xhr.setRequestHeader('Authorization', 'Bearer ' + token);
xhr.setRequestHeader('org-id', getOrgId());
xhr.setRequestHeader('X-Skip-SW-Cache', 'true');
xhr.setRequestHeader('Cache-Control', 'no-cache, no-store');
xhr.timeout = 10000;
xhr.onload = function() {
try {
const resp = JSON.parse(xhr.responseText);
if (resp.code !== 200 && resp.code !== 0) {
reject(new Error('API ' + resp.code));
return;
}
const urls = [];
const printAreas = resp.data && resp.data.printAreaVoList || [];
for (const area of printAreas) {
const printInfos = area.printInfoList || [];
for (const info of printInfos) {
const mappers = info.mapperList || [];
for (const mapper of mappers) {
const pattern = mapper.pattern;
if (pattern && pattern.patternPath) {
const p = pattern.patternPath;
const fullUrl = p.startsWith('http') ? p : 'https://cdn.modaai.net/' + p;
if (!urls.includes(fullUrl)) urls.push(fullUrl);
}
}
}
}
resolve(urls);
} catch(e) {
reject(e);
}
};
xhr.onerror = () => reject(new Error('XHR 错误'));
xhr.ontimeout = () => reject(new Error('XHR 超时'));
xhr.send();
});
}
// 通过 iframe 打开详情页提取图案链接(兜底方案)
async function fetchPatternUrlsFromIframe(productId) {
const detailUrl = `https://pod.modaai.net/my-design/my-commodity/mycommoditydetails?id=${productId}&source=0`;
const iframe = document.createElement('iframe');
iframe.src = detailUrl;
iframe.style.cssText = 'position:fixed;top:-1000px;left:-1000px;width:10px;height:10px;opacity:0;pointer-events:none;z-index:-1;border:none;';
document.body.appendChild(iframe);
try {
await waitForIframeLoad(iframe);
const doc = iframe.contentDocument || iframe.contentWindow.document;
// 等待「图片链接」按钮出现
const linkBtn = await waitForElement(doc, (d) =>
Array.from(d.querySelectorAll('button')).find(b => b.textContent.trim().includes('图片链接')),
15000, 500
);
linkBtn.click();
// 等待弹窗出现
await waitUntil(() => {
const allText = (doc.body.innerText || doc.body.textContent || '');
return allText.includes('复制图片链接');
}, 15000, 500);
// 提取所有 podPattern 链接
const allText = doc.body.innerText || doc.body.textContent || '';
const urls = [...allText.matchAll(/https:\/\/cdn\.modaai\.net\/podPattern\/[^\s"'<>]+/g)].map(m => m[0]);
return [...new Set(urls)];
} finally {
iframe.remove();
}
}
function waitForIframeLoad(iframe) {
return new Promise((resolve, reject) => {
let done = false;
const complete = () => { if (!done) { done = true; resolve(); } }
iframe.onload = complete;
iframe.onerror = () => { complete(); reject(new Error('iframe 加载失败')); };
setTimeout(complete, 8000);
});
}
function waitForElement(doc, finder, timeout, interval) {
return new Promise((resolve, reject) => {
const start = Date.now();
const timer = setInterval(() => {
try {
const el = finder(doc);
if (el) { clearInterval(timer); resolve(el); return; }
} catch(e) {}
if (Date.now() - start > timeout) { clearInterval(timer); reject(new Error('等待元素超时')); }
}, interval);
});
}
// ========== UI 渲染 ==========
function addGroup(page, spu, urls) {
const log = $('mc-log');
const group = document.createElement('div');
group.className = 'group';
group.innerHTML =
`` +
`${spu}` +
`第${page}页 · ${urls.length}条链接` +
`
` +
`` +
urls.map((u, i) =>
`
`
).join('') +
`
`;
log.appendChild(group);
log.scrollTop = log.scrollHeight;
}
// ========== Excel 导出 ==========
async function exportExcel() {
if (!collectedData.length) return alert('没有数据');
const $status = $('mc-status');
$status.textContent = '正在生成 Excel...';
try {
await loadXLSX();
if (!XLSX || !XLSX.utils) throw new Error('SheetJS 未正确加载');
const rows = [['页码','SPU','商品名称','序号','图案链接']];
collectedData.forEach(d => d.urls.forEach((u, i) => rows.push([d.page, d.spu, d.name, i+1, u])));
const ws = XLSX.utils.aoa_to_sheet(rows);
ws['!cols'] = [{wch:6},{wch:18},{wch:20},{wch:6},{wch:70}];
const sumRows = [['页码','SPU','商品名称','图案链接1','图案链接2']];
collectedData.forEach(d => sumRows.push([d.page, d.spu, d.name, d.urls[0]||'', d.urls[1]||'']));
const ws2 = XLSX.utils.aoa_to_sheet(sumRows);
ws2['!cols'] = [{wch:6},{wch:18},{wch:20},{wch:70},{wch:70}];
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, '图案链接');
XLSX.utils.book_append_sheet(wb, ws2, '汇总');
XLSX.writeFile(wb, `图案链接_${new Date().toISOString().slice(0,10)}.xlsx`);
$status.textContent = `Excel 已导出!共 ${collectedData.length} 条`;
} catch(e) {
$status.textContent = '导出失败: ' + e.message;
console.error(e);
}
}
// ========== 初始化 ==========
createPanel();
})();