// ==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) => `
${i+1}.${u}
` ).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(); })();