/** * 店小秘自动导出脚本 - 生产版 * * 流程: * 1. 复用 Cookie 登录(Cookie 由 login.mjs 手动登录获取) * 2. 采购建议 → 导出全部 * 3. 自营仓库 → 按所有页导出 * 4. 文件保存到 downloads/ * * 首次使用:先运行 node login.mjs 手动登录获取 Cookie * 定时运行:crontab 每 4 小时执行 run-export.sh * Cookie 过期:重新运行 node login.mjs */ import { chromium } from 'playwright'; import { writeFileSync, readFileSync, existsSync, mkdirSync, readdirSync, appendFileSync } from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const COOKIE_FILE = path.join(__dirname, 'cookies.json'); const SCREENSHOTS_DIR = path.join(__dirname, 'screenshots'); const DOWNLOAD_DIR = path.join(__dirname, 'downloads'); const LOG_FILE = path.join(__dirname, 'export.log'); const BASE_URL = 'https://www.dianxiaomi.com'; mkdirSync(SCREENSHOTS_DIR, { recursive: true }); mkdirSync(DOWNLOAD_DIR, { recursive: true }); function log(msg) { const ts = new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' }); const line = `[${ts}] ${msg}`; console.log(line); try { appendFileSync(LOG_FILE, line + '\n'); } catch {} } function dateTag() { const d = new Date(); return `${d.getFullYear()}${String(d.getMonth()+1).padStart(2,'0')}${String(d.getDate()).padStart(2,'0')}_${String(d.getHours()).padStart(2,'0')}${String(d.getMinutes()).padStart(2,'0')}`; } // ====== 登录(仅 Cookie 复用,过期需手动 node login.mjs)====== async function doLogin(browser) { log('检查登录状态...'); if (!existsSync(COOKIE_FILE)) { log('✗ Cookie 不存在!请先运行: node login.mjs'); return null; } const ctx = await browser.newContext({ viewport: { width: 1920, height: 1080 }, locale: 'zh-CN', userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36', }); const pg = await ctx.newPage(); await ctx.addCookies(JSON.parse(readFileSync(COOKIE_FILE, 'utf-8'))); try { await pg.goto(`${BASE_URL}/home.htm`, { waitUntil: 'load', timeout: 30000 }); const isLogged = await pg.evaluate(() => document.body.innerText.includes('MiLe-kf01') || document.body.innerText.includes('待办事项') ); if (isLogged) { log('Cookie 有效!'); return { page: pg, context: ctx }; } } catch {} await ctx.close(); log('✗ Cookie 已过期!请重新运行: node login.mjs'); return null; } // ====== 关闭页面上的所有弹窗/公告 ====== async function closeModals(page) { // 关闭 ant-modal 弹窗、公告、通知 await page.evaluate(() => { // 关闭所有 ant-modal document.querySelectorAll('.ant-modal-wrap, .bullet-layer, .comm-modal').forEach(el => { el.style.display = 'none'; el.remove(); }); // 关闭遮罩层 document.querySelectorAll('.ant-modal-mask, .modal-backdrop, .v-modal').forEach(el => { el.style.display = 'none'; el.remove(); }); // 点击所有可见的关闭按钮 document.querySelectorAll('.ant-modal-close, .close, [class*="close-btn"]').forEach(btn => { if (btn.offsetHeight > 0) btn.click(); }); }).catch(() => {}); await page.waitForTimeout(500); } // ====== 下载辅助 ====== async function doExport(page, clickAction, label) { log(`导出: ${label}`); try { const downloadPromise = page.waitForEvent('download', { timeout: 120000 }); await clickAction(); const download = await downloadPromise; const tag = dateTag(); const saveName = `${tag}_${label}_${download.suggestedFilename()}`; const savePath = path.join(DOWNLOAD_DIR, saveName); await download.saveAs(savePath); log(`✓ 下载完成: ${saveName}`); return true; } catch (e) { log(`✗ 下载失败(${label}): ${e.message}`); await page.screenshot({ path: path.join(SCREENSHOTS_DIR, `fail-${label}.png`) }).catch(() => {}); return false; } } // ====== 直接 URL 下载 ====== async function downloadUrl(page, url, label) { log(`直接下载: ${label} -> ${url.substring(0, 80)}...`); try { const downloadPromise = page.waitForEvent('download', { timeout: 60000 }); // 通过创建 标签触发下载,避免 page.goto 的问题 await page.evaluate((href) => { const a = document.createElement('a'); a.href = href; a.download = ''; document.body.appendChild(a); a.click(); a.remove(); }, url); const download = await downloadPromise; const tag = dateTag(); const saveName = `${tag}_${label}_${download.suggestedFilename()}`; const savePath = path.join(DOWNLOAD_DIR, saveName); await download.saveAs(savePath); log(`✓ 下载完成: ${saveName}`); return true; } catch (e) { log(`✗ URL下载失败(${label}): ${e.message}`); return false; } } // ====== 处理采购建议导出对话框(字段选择 → 点导出 → 等下载链接)====== async function handlePurchaseExportDialog(page, label) { await page.waitForTimeout(2000); await page.screenshot({ path: path.join(SCREENSHOTS_DIR, `dialog-${label}.png`) }); // 对话框里的"导出"按钮(蓝色按钮,和"关闭"并列) // 用 JavaScript 直接点,避免被其他元素挡住 const clicked = await page.evaluate(() => { const btns = Array.from(document.querySelectorAll('button')); const exportBtn = btns.find(b => b.textContent.trim() === '导出' && b.offsetHeight > 0 && b.closest('.ant-modal, [class*="dialog"], [class*="modal"]') ); if (exportBtn) { exportBtn.click(); return true; } return false; }); if (!clicked) { log(`✗ 未找到对话框"导出"按钮 (${label})`); return false; } log(` 点击了对话框"导出"按钮`); // 等待处理(可能生成文件,可能弹出下载链接,可能直接下载) await page.waitForTimeout(10000); await page.screenshot({ path: path.join(SCREENSHOTS_DIR, `after-export-${label}.png`) }); // 检查是否有下载链接出现 return await clickDownloadLinks(page, label); } // ====== 处理仓库导出对话框(字段选择 → 点导出 → 进度条 → 下载链接)====== async function handleWarehouseExportDialog(page, label) { await page.waitForTimeout(2000); await page.screenshot({ path: path.join(SCREENSHOTS_DIR, `dialog-${label}.png`) }); // 1. 先点对话框里的"导出"按钮 const clicked = await page.evaluate(() => { const btns = Array.from(document.querySelectorAll('button')); const exportBtn = btns.find(b => b.textContent.trim() === '导出' && b.offsetHeight > 0 && b.closest('.ant-modal, [class*="dialog"], [class*="modal"]') ); if (exportBtn) { exportBtn.click(); return true; } return false; }); if (!clicked) { log(`✗ 仓库对话框未找到"导出"按钮`); return false; } log(' 已点击仓库对话框"导出"按钮'); // 2. 等进度条完成(最多等 300 秒,6000+商品需要时间) log(' 等待仓库导出处理(6000+商品)...'); for (let i = 0; i < 150; i++) { await page.waitForTimeout(2000); const state = await page.evaluate(() => { const text = document.body.innerText; // 精确检测:完成状态有"已导出 X 个商品"和"下载"链接 const hasCompleted = text.includes('已导出') && text.match(/已导出.*?\d+.*?商品/); // 仍在处理 const isProcessing = text.includes('导出中') && text.includes('正在导出'); return { done: !!hasCompleted, processing: isProcessing, text: text.substring(text.indexOf('导出'), text.indexOf('导出') + 100), }; }).catch(() => ({ done: false })); if (state.done) { log(` 仓库导出完成! ${state.text}`); break; } if (i % 15 === 0 && i > 0) { log(` 仍在处理...(${i * 2}秒)${state.text || ''}`); await page.screenshot({ path: path.join(SCREENSHOTS_DIR, `progress-${label}-${i}.png`) }); } } await page.screenshot({ path: path.join(SCREENSHOTS_DIR, `after-export-${label}.png`) }); // 3. 查找下载链接(仓库导出完成后有"下载单品/加工SKU文件"等链接) const downloadLinks = await page.evaluate(() => { const links = Array.from(document.querySelectorAll('a')); return links .filter(a => a.textContent.includes('下载') && a.offsetHeight > 0) .map(a => ({ text: a.textContent.trim(), href: a.href })); }); log(` 找到 ${downloadLinks.length} 个下载链接`); let downloaded = 0; for (const link of downloadLinks) { if (!link.href || link.href === 'null' || (!link.href.includes('.xls') && !link.href.includes('temp/'))) continue; log(` 下载: "${link.text}" -> ${link.href.substring(0, 80)}`); const ok = await downloadUrl(page, link.href, `${label}_${link.text.substring(0, 10)}`); if (ok) downloaded++; await page.waitForTimeout(1000); } // 如果没找到 链接,尝试点击按钮 if (downloaded === 0) { const btns = await page.locator('a:visible:has-text("下载"), button:visible:has-text("下载")').all(); for (let i = 0; i < btns.length; i++) { const text = await btns[i].textContent().catch(() => ''); if (text.includes('下载') && !text.includes('自定导出')) { log(` 点击下载按钮: "${text.trim()}"`); const ok = await doExport(page, () => btns[i].click(), `${label}_btn${i}`); if (ok) downloaded++; } } } // 最后尝试用 JS 查找隐藏的下载 URL if (downloaded === 0) { const hiddenLinks = await page.evaluate(() => { const all = Array.from(document.querySelectorAll('a[href*=".xls"], a[href*="temp/"], a[href*="download"]')); return all.map(a => ({ text: a.textContent.trim(), href: a.href })); }); for (const l of hiddenLinks) { if (l.href.includes('.xls') || l.href.includes('temp/')) { log(` 隐藏链接: "${l.text}" -> ${l.href.substring(0, 80)}`); const ok = await downloadUrl(page, l.href, `${label}_hidden`); if (ok) downloaded++; } } } return downloaded > 0; } // ====== 点击所有"下载"链接 ====== async function clickDownloadLinks(page, label) { let downloaded = 0; // 查找所有包含"下载"文字的链接/按钮 const downloadLinks = await page.locator('a:has-text("下载"), button:has-text("下载")').all(); log(` 找到 ${downloadLinks.length} 个下载链接`); for (let i = 0; i < downloadLinks.length; i++) { const link = downloadLinks[i]; if (!(await link.isVisible().catch(() => false))) continue; const text = await link.textContent().catch(() => ''); const href = await link.getAttribute('href').catch(() => ''); log(` 下载: "${text.trim()}" href="${href}"`); if (href && (href.includes('.xls') || href.includes('.csv') || href.includes('temp/'))) { // 直接 URL 下载 const fullUrl = href.startsWith('http') ? href : `${BASE_URL}${href}`; const ok = await downloadUrl(page, fullUrl, `${label}_${i + 1}`); if (ok) downloaded++; } else { // 点击按钮下载 const ok = await doExport(page, () => link.click(), `${label}_${i + 1}`); if (ok) downloaded++; } await page.waitForTimeout(2000); } if (downloaded === 0) { log(` 未成功下载任何文件,尝试用 JS 查找隐藏链接...`); // 有些下载链接可能是通过 window.open 或 location.href const links = await page.evaluate(() => { const els = document.querySelectorAll('a[href*="download"], a[href*="export"], a[href*=".xls"], a[href*=".csv"]'); return Array.from(els).map(a => ({ text: a.textContent.trim(), href: a.href })); }); if (links.length) { log(' 找到直接下载链接:'); links.forEach(l => log(` ${l.text} -> ${l.href}`)); for (const l of links) { // 跳过非文件链接 if (!l.href.includes('.xls') && !l.href.includes('.csv') && !l.href.includes('.zip') && !l.href.includes('download') && !l.href.includes('temp/')) continue; const ok = await downloadUrl(page, l.href, `${label}_${l.text.substring(0, 10)}`); if (ok) downloaded++; } } } return downloaded > 0; } // ====== 采购建议页面 ====== async function exportPurchase(page) { log('\n===== 采购建议 ====='); // --- 导出全部 --- await page.goto(`${BASE_URL}/purchasingProposal/index.htm?state=3`, { waitUntil: 'load', timeout: 30000 }); await page.waitForTimeout(5000); await closeModals(page); const dropdown1 = await page.locator('text=导出建议').first(); if (await dropdown1.isVisible().catch(() => false)) { await dropdown1.click(); await page.waitForTimeout(1500); const exportAllItem = await page.locator('text=导出全部').first(); if (await exportAllItem.isVisible().catch(() => false)) { await exportAllItem.click(); // 弹出字段选择对话框 await handlePurchaseExportDialog(page, '采购_导出全部'); } else { log('✗ 未找到"导出全部"菜单项'); } } else { log('✗ 未找到"导出建议"按钮'); } await page.waitForTimeout(3000); // --- 导出建议 --- await page.goto(`${BASE_URL}/purchasingProposal/index.htm?state=3`, { waitUntil: 'load', timeout: 30000 }); await page.waitForTimeout(5000); await closeModals(page); const dropdown2 = await page.locator('text=导出建议').first(); if (await dropdown2.isVisible().catch(() => false)) { await dropdown2.click(); await page.waitForTimeout(1500); const exportSuggItem = await page.locator('text=导出勾选项').first(); if (await exportSuggItem.isVisible().catch(() => false)) { await exportSuggItem.click(); await handlePurchaseExportDialog(page, '采购_导出勾选项'); } } } // ====== 自营仓库页面 ====== async function exportWarehouse(page) { log('\n===== 自营仓库 ====='); await page.goto(`${BASE_URL}/warehouseProduct/index.htm`, { waitUntil: 'load', timeout: 30000 }); await page.waitForTimeout(5000); await closeModals(page); await page.screenshot({ path: path.join(SCREENSHOTS_DIR, 'page-仓库.png'), fullPage: true }); // 1. 点击"导入/导出"展开下拉 const importExportBtn = await page.locator('text=导入/导出').first(); if (!(await importExportBtn.isVisible().catch(() => false))) { log('✗ 未找到"导入/导出"按钮'); return; } await importExportBtn.click(); await page.waitForTimeout(1500); // 2. 点"按所有页导出" const allPagesBtn = await page.locator('text=按所有页导出').first(); if (!(await allPagesBtn.isVisible().catch(() => false))) { log('✗ 未找到"按所有页导出"'); return; } await allPagesBtn.click(); await page.waitForTimeout(2000); // 3. 等进度条完成,下载文件 await handleWarehouseExportDialog(page, '仓库_全部导出'); } // ====== 主流程 ====== async function main() { log('========================================'); log('店小秘自动导出 启动'); log('========================================\n'); const browser = await chromium.launch({ headless: true, args: ['--no-sandbox'] }); try { const session = await doLogin(browser); if (!session) { log('登录失败'); await browser.close(); return false; } const { page, context } = session; await exportPurchase(page); await exportWarehouse(page); // 统计 const tag = dateTag().substring(0, 8); const files = readdirSync(DOWNLOAD_DIR).filter(f => f.startsWith(tag)); log(`\n今日已下载 ${files.length} 个文件:`); files.forEach(f => log(` 📄 ${f}`)); await context.close(); await browser.close(); log('\n✓ 导出完成\n'); return true; } catch (e) { log(`致命错误: ${e.message}`); await browser.close(); return false; } } const ok = await main(); process.exit(ok ? 0 : 1);