Files
20260324-42433647/export.mjs
2026-04-25 21:50:03 +08:00

438 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 店小秘自动导出脚本 - 生产版
*
* 流程:
* 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 });
// 通过创建 <a> 标签触发下载,避免 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);
}
// 如果没找到 <a> 链接,尝试点击按钮
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);