/**
* 店小秘自动导出脚本 - 生产版
*
* 流程:
* 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);