/** * 全自动登录店小秘 v2 - 监听网络请求,处理 AJAX 登录 */ 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 = 10; mkdirSync(SCREENSHOTS_DIR, { recursive: true }); async function ocrCaptcha(page) { // 验证码图片 ID: verifyImgCode const captchaImg = await page.$('#verifyImgCode'); if (!captchaImg) { console.log(' 未找到 #verifyImgCode'); return null; } const captchaPath = `${SCREENSHOTS_DIR}/captcha.png`; await captchaImg.screenshot({ path: captchaPath }); // 预处理图片 const processedPath = `${SCREENSHOTS_DIR}/captcha_processed.png`; await sharp(captchaPath) .grayscale() .resize({ width: 468, kernel: 'lanczos3' }) .normalize() .sharpen({ sigma: 2 }) .threshold(140) .negate() // 有时候反色效果更好 .toFile(processedPath); // 同时生成一个不反色的版本 const processedPath2 = `${SCREENSHOTS_DIR}/captcha_processed2.png`; await sharp(captchaPath) .grayscale() .resize({ width: 468, kernel: 'lanczos3' }) .normalize() .sharpen({ sigma: 2 }) .threshold(140) .toFile(processedPath2); // 尝试两种预处理 for (const path of [processedPath2, processedPath]) { try { const result = execSync( `tesseract "${path}" stdout --psm 7 -c tessedit_char_whitelist=0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ`, { encoding: 'utf-8', timeout: 10000 } ).trim().replace(/\s/g, ''); if (result && result.length >= 4 && result.length <= 6) { console.log(` OCR 结果 (${path.includes('2') ? '正常' : '反色'}): "${result}"`); return result; } } catch (e) {} } // 如果上面都不行,直接用原图试 try { const result = execSync( `tesseract "${captchaPath}" stdout --psm 7 -c tessedit_char_whitelist=0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ`, { encoding: 'utf-8', timeout: 10000 } ).trim().replace(/\s/g, ''); console.log(` OCR 结果 (原图): "${result}"`); return result; } catch (e) { console.error(' OCR 失败:', e.message); return null; } } async function main() { console.log('====== 店小秘全自动登录 v2 ======\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(); // 监听所有网络响应 const responses = []; page.on('response', async (resp) => { const url = resp.url(); if (url.includes('login') || url.includes('Login') || url.includes('user') || url.includes('auth') || url.includes('verify') || url.includes('check')) { const status = resp.status(); let body = ''; try { body = await resp.text(); if (body.length > 500) body = body.substring(0, 500) + '...'; } catch (e) {} responses.push({ url: url.substring(0, 100), status, body }); console.log(` [NET] ${status} ${url.substring(0, 80)}`); if (body) console.log(` [BODY] ${body.substring(0, 200)}`); } }); try { console.log('>> 打开登录页...'); await page.goto('https://www.dianxiaomi.com/home.htm', { waitUntil: 'networkidle', timeout: 30000 }); // 查看登录表单的 action 和 method const formInfo = await page.evaluate(() => { const forms = document.querySelectorAll('form'); return Array.from(forms).map(f => ({ id: f.id, action: f.action, method: f.method, className: f.className, })); }); console.log('>> 表单信息:', JSON.stringify(formInfo, null, 2)); // 查看登录按钮的 onclick 事件 const btnInfo = await page.evaluate(() => { const btns = document.querySelectorAll('button'); return Array.from(btns).map(b => ({ text: b.textContent.trim().substring(0, 20), onclick: b.getAttribute('onclick'), type: b.type, id: b.id, className: b.className, })); }); console.log('>> 按钮信息:', JSON.stringify(btnInfo, null, 2)); // 查看是否有 JS 登录函数 const loginFunctions = await page.evaluate(() => { // 查找登录相关的脚本 const scripts = document.querySelectorAll('script:not([src])'); const loginCode = []; for (const script of scripts) { const text = script.textContent; if (text.includes('login') || text.includes('Login') || text.includes('登录')) { // 只取包含登录逻辑的关键部分 const lines = text.split('\n').filter(l => l.includes('login') || l.includes('Login') || l.includes('ajax') || l.includes('submit') || l.includes('url') || l.includes('post') || l.includes('$.') || l.includes('fetch') ); loginCode.push(lines.join('\n').substring(0, 800)); } } return loginCode; }); console.log('>> 登录相关JS代码:'); loginFunctions.forEach(code => console.log(code)); // 多次尝试 for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { console.log(`\n>> ===== 第 ${attempt} 次尝试 =====`); // 刷新验证码(非首次) if (attempt > 1) { // 点击验证码图片刷新 const captchaImg = await page.$('#verifyImgCode'); if (captchaImg) { await captchaImg.click(); await page.waitForTimeout(1500); } } // 清空并填写 await page.fill('input[name="account"]', 'MiLe-kf01'); await page.fill('input[name="password"]', 'Vxdas@302'); // 勾选登录协议 checkbox(如果有) const checkboxes = await page.$$('input[type="checkbox"]'); for (const cb of checkboxes) { const checked = await cb.isChecked(); if (!checked) { await cb.check().catch(() => {}); } } // OCR 验证码 const code = await ocrCaptcha(page); if (!code || code.length < 4) { console.log(' 验证码识别太短,跳过...'); const captchaImg = await page.$('#verifyImgCode'); if (captchaImg) await captchaImg.click(); await page.waitForTimeout(1000); continue; } // 填验证码 await page.fill('#verifyCode', code); // 截图确认 await page.screenshot({ path: `${SCREENSHOTS_DIR}/attempt-${attempt}.png` }); // 清空响应记录 responses.length = 0; // 点击登录 console.log(' 点击登录...'); const loginBtn = await page.$('button:has-text("登录")'); if (!loginBtn) { console.log(' 找不到登录按钮!'); continue; } await Promise.all([ loginBtn.click(), // 同时等待可能的页面跳转或 AJAX 响应 page.waitForTimeout(5000), ]); // 打印所有捕获的响应 console.log(`\n 捕获 ${responses.length} 个网络响应`); // 检查页面状态 const currentUrl = page.url(); console.log(` 当前 URL: ${currentUrl}`); // 检查页面上是否有错误消息 const pageText = await page.evaluate(() => { // 查找弹窗、错误提示 const alerts = document.querySelectorAll('.alert, .error, .msg, .tip, .toast, [class*="error"], [class*="alert"], [class*="msg"], [class*="tip"]'); const texts = []; alerts.forEach(el => { const t = el.textContent.trim(); if (t) texts.push(t.substring(0, 100)); }); // 也检查 layer 弹窗(常用于中文网站) const layerContent = document.querySelector('.layui-layer-content, .layer-content'); if (layerContent) texts.push('layer: ' + layerContent.textContent.trim().substring(0, 100)); return texts; }); if (pageText.length > 0) { console.log(' 页面消息:', pageText); } // 检查是否出现了后台元素 const hasBackend = await page.evaluate(() => { const body = document.body.textContent; return body.includes('退出') || body.includes('注销') || body.includes('控制台') || body.includes('我的账号') || body.includes('操作中心') || body.includes('工作台'); }); if (hasBackend || (currentUrl !== 'https://www.dianxiaomi.com/home.htm' && currentUrl !== 'https://www.dianxiaomi.com/index.htm' && !currentUrl.includes('home.htm'))) { console.log('\n>> ★★★ 登录成功!★★★'); const cookies = await context.cookies(); writeFileSync(COOKIE_FILE, JSON.stringify(cookies, null, 2)); console.log(`>> Cookie 已保存(${cookies.length} 条)`); await page.screenshot({ path: `${SCREENSHOTS_DIR}/backend.png`, fullPage: true }); await exploreDashboard(page, context); await browser.close(); return true; } // 可能 AJAX 登录后需要手动跳转 // 检查 cookie 中是否有登录标识 const cookies = await context.cookies(); const sessionCookies = cookies.filter(c => c.name.toLowerCase().includes('session') || c.name.toLowerCase().includes('token') || c.name.toLowerCase().includes('user') || c.name.toLowerCase().includes('login') ); console.log(' Session cookies:', sessionCookies.map(c => `${c.name}=${c.value.substring(0, 20)}...`)); // 尝试手动跳转到后台 console.log(' 尝试手动跳转后台...'); const backendUrls = [ 'https://www.dianxiaomi.com/saleManage/index.htm', 'https://www.dianxiaomi.com/user/index.htm', 'https://www.dianxiaomi.com/setting/index.htm', 'https://www.dianxiaomi.com/purchaseManage/purchaseSuggestion.htm', ]; for (const url of backendUrls) { await page.goto(url, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {}); const newUrl = page.url(); console.log(` ${url} -> ${newUrl}`); if (!newUrl.includes('/home.htm') && !newUrl.includes('/index.htm')) { console.log('\n>> ★★★ 已进入后台!★★★'); const cookies = await context.cookies(); writeFileSync(COOKIE_FILE, JSON.stringify(cookies, null, 2)); console.log(`>> Cookie 已保存(${cookies.length} 条)`); await page.screenshot({ path: `${SCREENSHOTS_DIR}/backend.png`, fullPage: true }); await exploreDashboard(page, context); await browser.close(); return true; } } console.log(' 仍未进入后台,可能验证码错误,继续重试...'); } console.log('\n>> 全部 ' + MAX_RETRIES + ' 次尝试失败'); await page.screenshot({ path: `${SCREENSHOTS_DIR}/final-fail.png`, fullPage: true }); await browser.close(); return false; } catch (err) { console.error('致命错误:', err.message); await page.screenshot({ path: `${SCREENSHOTS_DIR}/error.png` }).catch(() => {}); await browser.close(); return false; } } async function exploreDashboard(page, context) { console.log('\n>> ===== 探索后台 ====='); console.log('>> URL:', page.url()); console.log('>> 标题:', await page.title()); // 获取整个页面文本的前 3000 字符 const bodyText = await page.evaluate(() => document.body?.innerText?.substring(0, 3000)); console.log('>> 页面内容(前3000字):\n', bodyText); // 所有链接 const links = 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.startsWith('http')) .filter((e, i, arr) => arr.findIndex(a => a.href === e.href) === i) ); console.log(`\n>> 所有链接(${links.length}个):`); for (const l of links) { const tag = (l.text.includes('采购') || l.text.includes('仓库') || l.text.includes('导出') || l.text.includes('库存') || l.text.includes('备货')) ? '★' : ' '; console.log(` ${tag} [${l.text}] ${l.href}`); } } const ok = await main(); process.exit(ok ? 0 : 1);