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