import fs from 'node:fs/promises' import { downloadRemoteQrImage, logBrowserSessionDebug, waitForFrame } from './session-login-shared.js' export async function ensureWxLoginReady(page) { try { const frame = await waitForFrame( page, (item) => item.url().includes('open.weixin.qq.com/connect/qrconnect'), 10_000, ) for (let attempt = 1; attempt <= 12; attempt += 1) { const viewState = await inspectWxLoginView(frame) logBrowserSessionDebug('wx.ensureQrMode.viewState', { attempt, frameUrl: frame.url(), qrVisible: viewState.qrVisible, quickLoginVisible: viewState.quickLoginVisible, switchToNormalVisible: viewState.switchToNormalVisible, qrCandidates: viewState.qrCandidates, bodyText: viewState.bodyText.slice(0, 160), }) if (viewState.qrVisible) { return } if ( viewState.quickLoginVisible || viewState.switchToNormalVisible || /使用其他头像、昵称或账号|微信快捷登录/.test(viewState.bodyText) ) { const switched = await switchWxQuickLoginToQr(frame) logBrowserSessionDebug('wx.ensureQrMode.switchToNormal', { attempt, switched, }) if (switched) { await frame.waitForTimeout(700) continue } } await frame.waitForTimeout(400) } } catch { // ignore wx quick-login switch failures; the screenshot retry path will try again } } export async function captureWxQrImage(page, qrImagePath) { const qrLocator = await findWxQrLocator(page) const frame = await waitForFrame( page, (item) => item.url().includes('open.weixin.qq.com/connect/qrconnect'), 10_000, ) const qrUrl = await resolveWxQrImageUrl(frame) logBrowserSessionDebug('wx.capture.qrUrl', { qrUrl, frameUrl: frame.url(), }) if (qrUrl) { const downloaded = await downloadRemoteQrImage(page, qrUrl, qrImagePath, frame.url(), 'wx') logBrowserSessionDebug('wx.capture.downloadResult', { qrUrl, downloaded: Boolean(downloaded?.length), qrImagePath, }) if (downloaded?.length) { await fs.writeFile(qrImagePath, downloaded) return } } logBrowserSessionDebug('wx.capture.fallbackScreenshot', { qrImagePath, }) await qrLocator.screenshot({ path: qrImagePath }) } export async function findWxQrLocator(page) { await ensureWxLoginReady(page) const frame = await waitForFrame( page, (item) => item.url().includes('open.weixin.qq.com/connect/qrconnect'), ) const viewState = await inspectWxLoginView(frame) logBrowserSessionDebug('wx.findQrLocator.beforeWait', { frameUrl: frame.url(), qrVisible: viewState.qrVisible, qrCandidates: viewState.qrCandidates, bodyText: viewState.bodyText.slice(0, 160), }) const locator = frame.locator('.js_qrcode_img:visible').first() await locator.waitFor({ state: 'visible', timeout: 15_000 }) logBrowserSessionDebug('wx.findQrLocator.visibleReady', { frameUrl: frame.url(), }) return locator } export async function extractWxQrState(page) { const frame = page.frames().find((item) => item.url().includes('open.weixin.qq.com/connect/qrconnect')) if (!frame) { return { visible: false, scanned: false, expired: false, message: '', } } try { const qrVisible = await frame .locator('.js_qrcode_img') .first() .isVisible() .catch(() => false) const bodyText = String(await frame.locator('body').textContent()).replace(/\s+/g, ' ').trim() return { visible: true, qrVisible, scanned: !qrVisible && /扫描成功|请在手机上确认登录/.test(bodyText), expired: qrVisible ? false : /二维码失效|已失效/.test(bodyText), message: bodyText.slice(0, 200), frameUrl: frame.url(), } } catch { return { visible: true, qrVisible: false, scanned: false, expired: false, message: '', frameUrl: frame.url(), } } } async function inspectWxLoginView(frame) { return frame .evaluate(() => { const isVisible = (element) => { if (!(element instanceof HTMLElement)) { return false } const style = window.getComputedStyle(element) const rect = element.getBoundingClientRect() return ( style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0' && rect.width > 0 && rect.height > 0 ) } const quickLogin = document.querySelector('.js_quick_login') const switchToNormal = document.querySelector('.js_switchToNormal') const qrCandidates = Array.from(document.querySelectorAll('.js_qrcode_img')).map((item, index) => { if (!(item instanceof HTMLImageElement)) { return { index, visible: false, src: '', naturalWidth: 0, } } return { index, visible: isVisible(item), src: String(item.currentSrc || item.src || item.getAttribute('src') || '').trim(), naturalWidth: Number(item.naturalWidth || 0), } }) const visibleQrCandidate = qrCandidates.find((item) => item.visible && item.naturalWidth > 0) return { qrVisible: Boolean(visibleQrCandidate), quickLoginVisible: isVisible(quickLogin), switchToNormalVisible: isVisible(switchToNormal), qrCandidates, bodyText: String(document.body?.textContent || '').replace(/\s+/g, ' ').trim(), } }) .catch(() => ({ qrVisible: false, quickLoginVisible: false, switchToNormalVisible: false, qrCandidates: [], bodyText: '', })) } async function switchWxQuickLoginToQr(frame) { const switcherCandidates = [ frame.locator('.js_switchToNormal:visible').first(), frame.locator('.js_switchToNormal').first(), frame.locator('button:has-text("使用其他头像、昵称或账号")').first(), frame.locator('text=使用其他头像、昵称或账号').first(), ] for (const candidate of switcherCandidates) { const visible = await candidate.isVisible().catch(() => false) if (!visible) { continue } logBrowserSessionDebug('wx.switchToNormal.candidateVisible', { candidateIndex: switcherCandidates.indexOf(candidate), }) const clicked = (await candidate .click({ timeout: 2_000 }) .then(() => true) .catch(() => false)) || (await candidate .evaluate((element) => { if (!(element instanceof HTMLElement)) { return false } element.click() return true }) .catch(() => false)) if (clicked) { logBrowserSessionDebug('wx.switchToNormal.clicked', { candidateIndex: switcherCandidates.indexOf(candidate), }) return true } } return frame .evaluate(() => { const candidates = Array.from(document.querySelectorAll('button, a, div')).filter((element) => { if (!(element instanceof HTMLElement)) { return false } const text = String(element.textContent || '').replace(/\s+/g, ' ').trim() const style = window.getComputedStyle(element) const rect = element.getBoundingClientRect() return ( /使用其他头像、昵称或账号/.test(text) && style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0' && rect.width > 0 && rect.height > 0 ) }) const target = candidates[0] if (!(target instanceof HTMLElement)) { return false } target.click() return true }) .catch(() => false) } async function resolveWxQrImageUrl(frame) { return frame .evaluate(() => { const isVisible = (element) => { if (!(element instanceof HTMLElement)) { return false } const style = window.getComputedStyle(element) const rect = element.getBoundingClientRect() return ( style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0' && rect.width > 0 && rect.height > 0 ) } const qrImages = Array.from(document.querySelectorAll('.js_qrcode_img')) const qrImage = qrImages.find((item) => item instanceof HTMLImageElement && isVisible(item)) || qrImages.find((item) => item instanceof HTMLImageElement) || null if (!(qrImage instanceof HTMLImageElement)) { return '' } return String(qrImage.currentSrc || qrImage.src || qrImage.getAttribute('src') || '').trim() }) .then((src) => { if (!src) { return '' } try { return new URL(src, frame.url()).toString() } catch { return '' } }) .catch(() => '') }