325 lines
11 KiB
JavaScript
325 lines
11 KiB
JavaScript
/**
|
||
* 店小秘全自动登录 v3
|
||
* 直接调 AJAX API 登录,改进 OCR 预处理
|
||
*/
|
||
import { chromium } from 'playwright';
|
||
import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'fs';
|
||
import { execSync } from 'child_process';
|
||
import sharp from 'sharp';
|
||
|
||
const COOKIE_FILE = './cookies.json';
|
||
const SCREENSHOTS_DIR = './screenshots';
|
||
const MAX_RETRIES = 15;
|
||
const DOWNLOAD_DIR = './downloads';
|
||
|
||
mkdirSync(SCREENSHOTS_DIR, { recursive: true });
|
||
mkdirSync(DOWNLOAD_DIR, { recursive: true });
|
||
|
||
// ====== 多种预处理方案尝试 OCR ======
|
||
async function ocrCaptcha(page) {
|
||
const captchaImg = await page.$('#verifyImgCode');
|
||
if (!captchaImg) return null;
|
||
|
||
const rawPath = `${SCREENSHOTS_DIR}/captcha_raw.png`;
|
||
await captchaImg.screenshot({ path: rawPath });
|
||
|
||
const presets = [
|
||
// 方案1:灰度 + 放大 + 二值化(中阈值)
|
||
async (src, dst) => {
|
||
await sharp(src).grayscale().resize({ width: 468 }).normalize().sharpen({ sigma: 1.5 }).threshold(130).toFile(dst);
|
||
},
|
||
// 方案2:灰度 + 放大 + 低阈值
|
||
async (src, dst) => {
|
||
await sharp(src).grayscale().resize({ width: 468 }).normalize().threshold(100).toFile(dst);
|
||
},
|
||
// 方案3:灰度 + 放大 + 高阈值
|
||
async (src, dst) => {
|
||
await sharp(src).grayscale().resize({ width: 468 }).normalize().threshold(160).toFile(dst);
|
||
},
|
||
// 方案4:原图直接放大
|
||
async (src, dst) => {
|
||
await sharp(src).resize({ width: 468 }).toFile(dst);
|
||
},
|
||
// 方案5:灰度 + 反色 + 二值化
|
||
async (src, dst) => {
|
||
await sharp(src).grayscale().resize({ width: 468 }).negate().normalize().threshold(128).toFile(dst);
|
||
},
|
||
];
|
||
|
||
const psmModes = ['7', '8', '13']; // 单行、单词、单行原始
|
||
|
||
for (let i = 0; i < presets.length; i++) {
|
||
const processedPath = `${SCREENSHOTS_DIR}/captcha_p${i}.png`;
|
||
try {
|
||
await presets[i](rawPath, processedPath);
|
||
} catch (e) { continue; }
|
||
|
||
for (const psm of psmModes) {
|
||
try {
|
||
const result = execSync(
|
||
`tesseract "${processedPath}" stdout --psm ${psm} -c tessedit_char_whitelist=0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ`,
|
||
{ encoding: 'utf-8', timeout: 10000 }
|
||
).trim().replace(/[\s\n\r]/g, '');
|
||
|
||
if (result && result.length === 4) {
|
||
console.log(` OCR [方案${i + 1}, psm${psm}]: "${result}"`);
|
||
return result;
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
}
|
||
|
||
// 实在不行,返回任何 >= 4 字符的结果
|
||
for (let i = 0; i < presets.length; i++) {
|
||
const processedPath = `${SCREENSHOTS_DIR}/captcha_p${i}.png`;
|
||
if (!existsSync(processedPath)) continue;
|
||
try {
|
||
const result = execSync(
|
||
`tesseract "${processedPath}" stdout --psm 7 -c tessedit_char_whitelist=0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ`,
|
||
{ encoding: 'utf-8', timeout: 10000 }
|
||
).trim().replace(/[\s\n\r]/g, '');
|
||
if (result && result.length >= 4) {
|
||
console.log(` OCR [fallback 方案${i + 1}]: "${result}" (取前4字符)`);
|
||
return result.substring(0, 4);
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
|
||
console.log(' OCR 全部失败');
|
||
return null;
|
||
}
|
||
|
||
// ====== 主流程 ======
|
||
async function main() {
|
||
console.log('====== 店小秘全自动登录 v3 ======\n');
|
||
|
||
const browser = await chromium.launch({
|
||
headless: true,
|
||
args: ['--no-sandbox'],
|
||
});
|
||
|
||
const context = 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 page = await context.newPage();
|
||
|
||
try {
|
||
// 先试 Cookie
|
||
if (existsSync(COOKIE_FILE)) {
|
||
console.log('>> 尝试复用 Cookie...');
|
||
const cookies = JSON.parse(readFileSync(COOKIE_FILE, 'utf-8'));
|
||
await context.addCookies(cookies);
|
||
|
||
// 访问一个需要登录的页面来验证
|
||
const resp = await page.goto('https://www.dianxiaomi.com/saleManage/index.htm', {
|
||
waitUntil: 'domcontentloaded', timeout: 20000
|
||
}).catch(() => null);
|
||
|
||
const url = page.url();
|
||
const title = await page.title();
|
||
if (resp && !url.includes('/home.htm') && !url.includes('/index.htm') && !title.includes('Error')) {
|
||
console.log('>> Cookie 有效!直接进入后台');
|
||
return { success: true, page, context, browser };
|
||
}
|
||
console.log('>> Cookie 无效,重新登录\n');
|
||
}
|
||
|
||
// 打开登录页
|
||
await page.goto('https://www.dianxiaomi.com/home.htm', { waitUntil: 'networkidle', timeout: 30000 });
|
||
|
||
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||
console.log(`\n>> 第 ${attempt}/${MAX_RETRIES} 次尝试...`);
|
||
|
||
// 刷新验证码
|
||
if (attempt > 1) {
|
||
await page.evaluate(() => {
|
||
const img = document.getElementById('verifyImgCode');
|
||
if (img) img.src = '/verify/code.htm?t=' + Date.now();
|
||
});
|
||
await page.waitForTimeout(1500);
|
||
}
|
||
|
||
// 填写表单
|
||
await page.fill('input[name="account"]', 'MiLe-kf01');
|
||
await page.fill('input[name="password"]', 'Vxdas@302');
|
||
|
||
// OCR 验证码
|
||
const code = await ocrCaptcha(page);
|
||
if (!code) {
|
||
console.log(' 跳过本次');
|
||
continue;
|
||
}
|
||
|
||
await page.fill('#verifyCode', code);
|
||
|
||
// 用 AJAX API 提交登录
|
||
const loginResult = await page.evaluate(async (verifyCode) => {
|
||
const formData = new FormData();
|
||
formData.append('account', 'MiLe-kf01');
|
||
formData.append('password', 'Vxdas@302');
|
||
formData.append('verifyCode', verifyCode);
|
||
formData.append('remeber', 'on');
|
||
|
||
try {
|
||
const resp = await fetch('/user/userLoginNew2.json', {
|
||
method: 'POST',
|
||
body: formData,
|
||
});
|
||
return await resp.json();
|
||
} catch (e) {
|
||
return { code: -999, error: e.message };
|
||
}
|
||
}, code);
|
||
|
||
console.log(` API 响应: ${JSON.stringify(loginResult)}`);
|
||
|
||
if (loginResult.code === 0 || loginResult.code === 1) {
|
||
console.log('\n>> ★★★ 登录成功!★★★');
|
||
console.log(`>> 跳转 URL: ${loginResult.url}`);
|
||
|
||
// 跳转到后台
|
||
if (loginResult.url) {
|
||
await page.goto('https://www.dianxiaomi.com' + loginResult.url, {
|
||
waitUntil: 'networkidle', timeout: 30000
|
||
});
|
||
} else {
|
||
await page.goto('https://www.dianxiaomi.com/saleManage/index.htm', {
|
||
waitUntil: 'networkidle', timeout: 30000
|
||
});
|
||
}
|
||
|
||
// 保存 Cookie
|
||
const cookies = await context.cookies();
|
||
writeFileSync(COOKIE_FILE, JSON.stringify(cookies, null, 2));
|
||
console.log(`>> Cookie 已保存(${cookies.length} 条)`);
|
||
|
||
return { success: true, page, context, browser };
|
||
}
|
||
|
||
// 登录失败
|
||
const errorMsg = loginResult.error || '未知错误';
|
||
console.log(` 失败: ${errorMsg}`);
|
||
|
||
if (errorMsg.includes('账号') || errorMsg.includes('密码') || errorMsg.includes('不存在')) {
|
||
console.log('>> 账号或密码错误,停止重试');
|
||
break;
|
||
}
|
||
// 验证码错误继续重试
|
||
}
|
||
|
||
console.log('\n>> 登录失败');
|
||
await browser.close();
|
||
return { success: false };
|
||
|
||
} catch (err) {
|
||
console.error('致命错误:', err.message);
|
||
await browser.close();
|
||
return { success: false };
|
||
}
|
||
}
|
||
|
||
// ====== 探索后台 ======
|
||
async function explore(page) {
|
||
console.log('\n>> ===== 探索后台页面 =====');
|
||
console.log('>> URL:', page.url());
|
||
console.log('>> 标题:', await page.title());
|
||
|
||
await page.screenshot({ path: `${SCREENSHOTS_DIR}/backend.png`, fullPage: true });
|
||
|
||
// 获取页面文本
|
||
const bodyText = await page.evaluate(() => document.body?.innerText?.substring(0, 5000));
|
||
console.log('>> 页面内容:\n', bodyText);
|
||
|
||
// 左侧菜单
|
||
const menuItems = await page.$$eval(
|
||
'.sidebar a, .menu a, .nav a, .left-menu a, [class*="sidebar"] a, [class*="menu"] a, a[href*="Manage"], a[href*="manage"]',
|
||
els => els.map(el => ({
|
||
text: el.textContent.trim().replace(/\s+/g, ' ').substring(0, 50),
|
||
href: el.href,
|
||
})).filter(e => e.text && e.href.includes('dianxiaomi'))
|
||
.filter((e, i, arr) => arr.findIndex(a => a.href === e.href) === i)
|
||
);
|
||
|
||
console.log('\n>> 菜单链接:');
|
||
for (const m of menuItems) {
|
||
console.log(` [${m.text}] -> ${m.href}`);
|
||
}
|
||
|
||
// 所有链接
|
||
const allLinks = await page.$$eval('a', els =>
|
||
els.map(el => ({
|
||
text: el.textContent.trim().replace(/\s+/g, ' ').substring(0, 60),
|
||
href: el.href,
|
||
}))
|
||
.filter(e => e.text && e.href && e.href.includes('dianxiaomi'))
|
||
.filter((e, i, arr) => arr.findIndex(a => a.href === e.href) === i)
|
||
);
|
||
|
||
console.log(`\n>> 全部链接(${allLinks.length} 个):`);
|
||
for (const l of allLinks) {
|
||
const tag = (l.text.includes('采购') || l.text.includes('仓库') || l.text.includes('导出') ||
|
||
l.text.includes('库存') || l.text.includes('备货') || l.text.includes('建议')) ? '★' : ' ';
|
||
console.log(` ${tag} [${l.text}] ${l.href}`);
|
||
}
|
||
|
||
// 尝试常见后台路径
|
||
const paths = [
|
||
'/saleManage/index.htm',
|
||
'/purchaseManage/purchaseSuggestion.htm',
|
||
'/purchaseManage/purchaseOrder.htm',
|
||
'/purchaseManage/index.htm',
|
||
'/stockManage/stockList.htm',
|
||
'/stockManage/index.htm',
|
||
'/warehouseManage/index.htm',
|
||
'/warehouseManage/stockList.htm',
|
||
];
|
||
|
||
console.log('\n>> 尝试后台路径:');
|
||
for (const p of paths) {
|
||
const url = `https://www.dianxiaomi.com${p}`;
|
||
try {
|
||
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 10000 });
|
||
const finalUrl = page.url();
|
||
const title = await page.title();
|
||
const isError = title.includes('Error') || finalUrl.includes('/home.htm');
|
||
console.log(` ${isError ? '✗' : '✓'} ${p} -> [${title}] ${finalUrl}`);
|
||
|
||
if (!isError) {
|
||
await page.screenshot({ path: `${SCREENSHOTS_DIR}/page${p.replace(/\//g, '_')}.png`, fullPage: true });
|
||
// 在该页面查找导出按钮
|
||
const exportInfo = await page.$$eval('*', els =>
|
||
els.filter(el => {
|
||
const t = el.textContent?.trim();
|
||
return t && (t === '导出' || t === '导出全部' || t === '导出建议' ||
|
||
t.includes('导出') || t.includes('下载'));
|
||
}).map(el => ({
|
||
tag: el.tagName,
|
||
text: el.textContent.trim().substring(0, 40),
|
||
id: el.id,
|
||
className: (el.className || '').substring(0, 50),
|
||
})).filter(e => ['A', 'BUTTON', 'SPAN', 'INPUT', 'DIV'].includes(e.tag))
|
||
.slice(0, 15)
|
||
);
|
||
if (exportInfo.length) {
|
||
console.log(` 导出按钮:`);
|
||
exportInfo.forEach(e => console.log(` ${e.tag}#${e.id}.${e.className}: "${e.text}"`));
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.log(` ✗ ${p} -> 超时`);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ====== 执行 ======
|
||
const result = await main();
|
||
if (result.success) {
|
||
await explore(result.page);
|
||
await result.browser.close();
|
||
} else {
|
||
process.exit(1);
|
||
}
|