如何唯一标识Node.js自动化场景下的Puppeteer浏览器实例?
解决多Puppeteer实例下验证码提交的会话定位问题
看起来你遇到的核心问题是全局变量导致的实例冲突——你用了全局的browser和page变量,当多个用户同时发起请求时,新创建的实例会直接覆盖旧的,所以后续提交验证码时自然会定位到最后创建的那个实例,而非目标用户的会话。
要解决这个问题,我们需要给每个用户的自动化流程分配一个唯一会话ID,并把每个会话对应的Puppeteer实例(browser、page)和用户信息绑定起来,用一个管理器来存储这些会话。这样提交验证码时,只要带上对应的会话ID,就能精准定位到目标实例。
下面是具体的实现方案:
步骤1:创建会话管理器
我们可以用Map来存储会话,键是唯一会话ID,值是包含该会话的browser、page、用户邮箱等信息的对象,这样可以快速通过ID查找对应的实例。
// 全局会话管理器,存储每个用户的会话数据 const sessionManager = new Map(); // 生成唯一会话ID的工具函数 function generateSessionId() { return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; }
步骤2:修改startBrowser函数,返回实例而非赋值全局变量
去掉原来的全局browser和page,让函数返回创建好的浏览器和页面实例,方便后续绑定到对应会话:
async function startBrowser(proxyUrl, proxyUn, proxyPw) { let browser; if (!proxyUrl || !proxyUn || !proxyPw) { browser = await puppeteer.launch({ headless: false, ignoreDefaultArgs: ["--enable-automation"], defaultViewport: null, args: ["--no-sandbox", "--disable-setuid-sandbox", "--start-maximized", '--window-size=1920,1080', "--disable-gpu", "--disable-dev-profile"] }); } else { const oldProxyUrl = `http://${proxyUn}:${proxyPw}@${proxyUrl}`; const newProxyUrl = await proxyChain.anonymizeProxy(oldProxyUrl); browser = await puppeteer.launch({ headless: false, ignoreDefaultArgs: ["--enable-automation"], defaultViewport: null, args: ["--no-sandbox", "--disable-setuid-sandbox", "--start-maximized", '--window-size=1920,1080', "--disable-gpu", "--disable-dev-profile", `--proxy-server=${newProxyUrl}`] }); } // 浏览器断开时自动清理会话,避免内存泄漏 browser.on('disconnected', () => { for (const [sessionId, session] of sessionManager.entries()) { if (session.browser === browser) { sessionManager.delete(sessionId); break; } } }); const page = await browser.newPage(); await page.setViewport({ width: 1920, height: 1080 }); page.setUserAgent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/93.0.4577.63 Safari/537.36"); return { browser, page }; }
步骤3:修改playTest函数,生成会话ID并存储会话数据
在启动浏览器后,生成会话ID,把browser、page、用户邮箱等信息存入会话管理器,当检测到需要验证码时,把会话ID返回给前端:
async function playTest(url, res, email, password) { // 这里需要传入用户的email和password try { let browser, page; try { // 假设proxy参数从请求中获取,根据你的实际逻辑调整 const { browser: newBrowser, page: newPage } = await startBrowser(proxyUrl, proxyUn, proxyPw); browser = newBrowser; page = newPage; await page.setDefaultNavigationTimeout(32400000); } catch (e) { proxyFailedFlag = true; console.error(new Date().toLocaleString() + ': ', e); return res.status(400).json({ status: 'failure', info: 'Invalid Proxy' }).end(); } try { console.log(new Date().toLocaleString() + ': ', 'connecting login page ...'); await page.goto(url); } catch (e) { proxyFailedFlag = true; await browser.close(); console.error(new Date().toLocaleString() + ': ', e); return res.status(400).json({ status: 'failure', info: 'Invalid Proxy' }).end(); } await waitTillHTMLRendered(page); await page.setViewport({ width: 1400, height: 966 }); // 生成唯一会话ID const sessionId = generateSessionId(); // 存储会话数据:包括browser、page、用户身份信息 sessionManager.set(sessionId, { browser, page, email, password }); // 调用playing时传入page、res和sessionId,方便返回会话ID await playing(page, res, sessionId); } catch (e) { console.error(new Date().toLocaleString() + ': ', e); res.status(500).json({ status: 'failure', info: 'Internal Server Error' }).end(); } }
步骤4:修改playing函数,在需要验证码时返回会话ID
调整playing函数,不再依赖全局page,而是接收传入的page和sessionId,当检测到需要验证码或PIN码时,把sessionId包含在响应中返回给前端:
const playing = async (page, res, sessionId) => { console.log(new Date().toLocaleString() + ': ', 'waiting for login form ...'); if (await page.$(ACCEPT_COOKIES_SELECTOR) !== null) { const accept_elm = await page.$(ACCEPT_COOKIES_SELECTOR); await accept_elm.click({ clickCount: 1 }); } await page.waitForSelector(USERNAME_SELECTOR, { visible: true }); await page.click(USERNAME_SELECTOR); // 从会话中获取用户的邮箱和密码 const { email, password } = sessionManager.get(sessionId); await page.keyboard.type(email); await page.click(PASSWORD_SELECTOR); await page.keyboard.type(password); const m_login_elm = await page.$(M_LOGIN_SELECTOR); await m_login_elm.click({ clickCount: 1 }); console.log(new Date().toLocaleString() + ': ', 'logging in ...'); try { await page.waitForNavigation(); const pageURL = page.url(); console.log("page url: ", pageURL); if (pageURL === 'https://www.targetwebsite.com/feed/') { loginFailedFlag = false; const accountElm = await page.$(PROFILE_NAME_SELECTOR); const accountName = await page.evaluate(accountElm => accountElm.textContent.trim(), accountElm); console.log("Login success", accountName); // 登录成功后清理会话 sessionManager.delete(sessionId); return res.status(200).json({ status: 'success', info: 'Login Completed', accountName }).end(); } else if (pageURL === "https:/www.targetwebsite.com/login-submit") { console.log("Exception Login Submit Form!"); if (await page.$(NOT_REMEMBER_BTN_SELECTOR) !== null) { const notRememberBtn = await page.$(NOT_REMEMBER_BTN_SELECTOR); await notRememberBtn.click({ clickCount: 1 }); } else { throw new Error('Login fail!'); } } else { const pageURLParts = pageURL.split("challenge"); if (pageURLParts[0] === "https://www.targetwebsite.com/captcha/") { loginFailedFlag = true; // 返回会话ID给前端,让用户提交验证码时带上 return res.status(400).json({ status: 'failure', info: 'Captcha Required', sessionId }).end(); } console.log("Pin code is required"); // 返回会话ID,等待用户提交PIN码 return res.status(400).json({ status: 'pending', info: 'Awaiting IP Code', sessionId }).end(); } } catch (error) { loginFailedFlag = true; sessionManager.delete(sessionId); return res.status(400).json({ status: 'failure', info: 'Invalid Login' }).end(); } if (await page.$(PIN_INPUT_SELECTOR) !== null) { console.log("pin code required") // 这里已提前返回会话ID,无需重复处理 } else { console.log('not found Pin Code step'); await waitTillHTMLRendered(page); if (await page.$(RE_ACCEPT_COOKIES_SELECTOR) !== null) { const accept_elm = await page.$(RE_ACCEPT_COOKIES_SELECTOR); await accept_elm.click({ clickCount: 1 }); } await getCookies(page); // 修改getCookies,接收page参数 sessionManager.delete(sessionId); res.status(200).json({ status: 'success', info: 'Process Completed' }).end(); } }
步骤5:修改验证码提交API,通过会话ID定位实例
调整handleIpProcess函数,接收sessionId、ip_code作为参数,从会话管理器中取出对应的page进行操作:
async function handleIpProcess(sessionId, ip_code) { // 通过会话ID获取对应的会话数据 const session = sessionManager.get(sessionId); if (!session) { throw new Error('Invalid or expired session'); } const { page, browser } = session; try { await waitTillHTMLRendered(page); await page.click(PIN_INPUT_SELECTOR); await page.keyboard.type(ip_code); await page.waitForSelector(PIN_SUBMIT_SELECTOR, { visible: true }); await page.click(PIN_SUBMIT_SELECTOR); // 等待验证完成 await page.waitForNavigation(); const pageURL = page.url(); if (pageURL === 'https://www.targetwebsite.com/feed/') { // 验证成功,清理会话 sessionManager.delete(sessionId); await getCookies(page); return { status: 'success', info: 'Login Completed' }; } else { throw new Error('Pin code verification failed'); } } catch (error) { sessionManager.delete(sessionId); await browser.close(); throw error; } }
额外注意事项
- 内存管理:一定要在会话完成(登录成功/失败)或浏览器断开时,从
sessionManager中删除对应的会话并关闭浏览器,防止内存泄漏。 - 会话过期:可以给会话设置过期时间(比如30分钟),定期清理无效会话,避免资源浪费。
- 错误处理:在
handleIpProcess中先检查会话是否存在,避免用户传入无效ID导致报错。
这样修改后,每个用户的自动化流程都有独立的会话ID,提交验证码时只要带上这个ID,就能精准找到对应的Puppeteer实例,不会再出现串号的问题了。
内容的提问来源于stack exchange,提问作者Noman
相关产品推荐
相关产品推荐

