'use strict'; const path = require('path'); const request = require('superagent'); const { chromium } = require('playwright-core'); const CNIPA_CPQUERY_BASE_URL = 'https://cpquery.cponline.cnipa.gov.cn'; const CNIPA_AM_BASE_URL = 'https://tysf.cponline.cnipa.gov.cn/am'; const CPQUERY_COOKIE_DOMAINS = [ 'tysf.cponline.cnipa.gov.cn', 'tysfjk.cponline.cnipa.gov.cn', 'sso.cponline.cnipa.gov.cn', 'cpquery.cponline.cnipa.gov.cn', 'cponline.cnipa.gov.cn', '.cponline.cnipa.gov.cn', ]; let latestQueryAuth = null; let persistentCpqueryContext = null; let persistentCpqueryHeadless = null; let persistentLoginPage = null; let latestLoginFlow = null; let cnipaOpQueue = Promise.resolve(); const BROWSER_USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36'; const CNIPA_READY_COOKIE_PATTERN = /(?:^|;\s*)(?:enable_dX1xbeyMT58W|dX1xbeyMT58WO|dX1xbeyMT58WP)=/; const CNIPA_AUTH_REDIS_KEY = 'cnipa:auth:v1:shared-account'; const CNIPA_AUTH_REDIS_TTL_SECONDS = 12 * 60 * 60; const readCnipaConfig = (ctx) => ctx.app.fs.config?.cnipa || {}; const getRedisClient = (ctx) => ctx?.app?.fs?.redis || null; const loadCnipaAuthState = async (ctx) => { const redis = getRedisClient(ctx); if (!redis) return; const raw = await redis.get(CNIPA_AUTH_REDIS_KEY); if (!raw) return; try { const parsed = JSON.parse(raw); latestQueryAuth = Object.prototype.hasOwnProperty.call(parsed || {}, 'latestQueryAuth') ? parsed.latestQueryAuth : latestQueryAuth; latestLoginFlow = Object.prototype.hasOwnProperty.call(parsed || {}, 'latestLoginFlow') ? parsed.latestLoginFlow : latestLoginFlow; } catch (error) { // Ignore invalid cached auth state. } }; const saveCnipaAuthState = async (ctx) => { const redis = getRedisClient(ctx); if (!redis) return; const payload = JSON.stringify({ latestQueryAuth: latestQueryAuth || null, latestLoginFlow: latestLoginFlow || null, updatedAt: Date.now(), }); await redis.set(CNIPA_AUTH_REDIS_KEY, payload, 'EX', CNIPA_AUTH_REDIS_TTL_SECONDS); }; const withCnipaSerializedOp = async (fn) => { const prev = cnipaOpQueue; let release; cnipaOpQueue = new Promise(resolve => { release = resolve; }); await prev; try { return await fn(); } finally { release(); } }; const getCookieNames = (cookieHeader) => String(cookieHeader || '') .split(';') .map(item => item.trim().split('=')[0]) .filter(Boolean); const hasCnipaReadyCookie = (cookieHeader) => CNIPA_READY_COOKIE_PATTERN.test(String(cookieHeader || '')); const getCookieDiagnostics = (cookieHeader) => ({ hasSession: /SESSION=/.test(String(cookieHeader || '')), hasEnableFlag: hasCnipaReadyCookie(cookieHeader), hasLegacyEnableFlag: /enable_dX1xbeyMT58W=/.test(String(cookieHeader || '')), hasWafCookie: /(?:^|;\s*)dX1xbeyMT58W[OP]=/.test(String(cookieHeader || '')), cookieLength: String(cookieHeader || '').length, cookieNames: getCookieNames(cookieHeader), }); const isCnipaAuthExpiredError = (error) => { const status = Number(error?.status || error?.response?.status || 0); const message = [ error?.message, error?.response?.body?.message, error?.response?.body?.msg, error?.response?.body?.diagnostics?.currentUrl, error?.response?.body?.diagnostics?.bodyText, ].filter(Boolean).join(' '); return status === 401 || status === 403 || status === 412 || /未登录|请登录|登录态|授权|认证|Precondition|前置条件|oauth|chrome-error|ERR_TOO_MANY_REDIRECTS/i.test(message); }; const invalidateCnipaAuthState = () => { latestQueryAuth = null; latestLoginFlow = null; }; const logCnipaDebug = (ctx, message, detail = {}) => { const logger = ctx?.logger; if (!logger?.log) return; logger.log(`[CNIPA] ${message}`, detail); }; const launchChromiumWithFallback = async (launchOptions = {}) => { // 优先使用系统 Chromium(Docker 环境) const systemChromium = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH; if (systemChromium) { try { return await chromium.launch({ ...launchOptions, executablePath: systemChromium, }); } catch (error) { // 系统 Chromium 失败,继续尝试其他方式 } } const attempts = [ { ...launchOptions, channel: 'chrome' }, { ...launchOptions, channel: 'msedge' }, { ...launchOptions }, ]; let lastError = null; for (const options of attempts) { try { return await chromium.launch(options); } catch (error) { lastError = error; } } const error = new Error('未检测到可用浏览器。请安装 Chrome/Edge,或在服务器安装 Playwright Chromium 浏览器。'); error.cause = lastError; throw error; }; const launchPersistentChromiumWithFallback = async (userDataDir, launchOptions = {}) => { // 优先使用系统 Chromium(Docker 环境) const systemChromium = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH; if (systemChromium) { try { return await chromium.launchPersistentContext(userDataDir, { ...launchOptions, executablePath: systemChromium, }); } catch (error) { // 系统 Chromium 失败,继续尝试其他方式 } } const attempts = [ { ...launchOptions, channel: 'chrome' }, { ...launchOptions, channel: 'msedge' }, { ...launchOptions }, ]; let lastError = null; for (const options of attempts) { try { return await chromium.launchPersistentContext(userDataDir, options); } catch (error) { lastError = error; } } const error = new Error('未检测到可用浏览器。请安装 Chrome/Edge,或在服务器安装 Playwright Chromium 浏览器。'); error.cause = lastError; throw error; }; const mergeCookieHeaders = (...cookieHeaders) => { const cookies = new Map(); cookieHeaders.filter(Boolean).forEach(header => { String(header).split(';').map(item => item.trim()).filter(Boolean).forEach(item => { const index = item.indexOf('='); if (index <= 0) return; const name = item.substring(0, index); const value = item.substring(index + 1); if (/^(path|domain|expires|max-age|secure|httponly|samesite)$/i.test(name)) return; cookies.set(name, value); }); }); return [...cookies.entries()].map(([name, value]) => `${name}=${value}`).join('; '); }; const cookieHeaderToPlaywrightCookies = (cookieHeader, domains) => { const pairs = String(cookieHeader || '') .split(';') .map(item => item.trim()) .filter(Boolean) .map(item => { const index = item.indexOf('='); return index > 0 ? { name: item.substring(0, index), value: item.substring(index + 1) } : null; }) .filter(Boolean); const uniquePairs = [...new Map(pairs.map(item => [item.name, item])).values()]; return domains.flatMap(domain => uniquePairs.map(item => ({ name: item.name, value: item.value, domain, path: '/', secure: true, httpOnly: false, sameSite: 'Lax', }))); }; const collectSetCookieHeader = (response) => { const setCookie = response?.headers?.['set-cookie']; if (Array.isArray(setCookie)) return setCookie.join('; '); return setCookie || ''; }; const buildAmHeaders = (cookieHeader) => ({ Accept: 'application/json, text/plain, */*', 'Accept-Language': 'zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7', 'Content-Type': 'application/json;charset=UTF-8', Origin: 'https://tysf.cponline.cnipa.gov.cn', Referer: `${CNIPA_AM_BASE_URL}/`, 'User-Agent': BROWSER_USER_AGENT, 'sec-ch-ua': '"Google Chrome";v="147", "Not.A/Brand";v="8", "Chromium";v="147"', 'sec-ch-ua-mobile': '?0', 'sec-ch-ua-platform': '"Windows"', ...(cookieHeader ? { Cookie: cookieHeader } : {}), }); const postCnipaAm = async (pathName, body, cookieHeader) => { const response = await request .post(`${CNIPA_AM_BASE_URL}${pathName}`) .set(buildAmHeaders(cookieHeader)) .send(body) .timeout({ response: 20000, deadline: 30000 }); const nextCookie = mergeCookieHeaders(cookieHeader, collectSetCookieHeader(response)); return { body: response.body, text: response.text, cookie: nextCookie, status: response.status, }; }; const getCnipaAm = async (pathName = '/', cookieHeader) => { const response = await request .get(`${CNIPA_AM_BASE_URL}${pathName}`) .set({ ...buildAmHeaders(cookieHeader), Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', 'Content-Type': 'text/plain;charset=UTF-8', }) .timeout({ response: 20000, deadline: 30000 }); const nextCookie = mergeCookieHeaders(cookieHeader, collectSetCookieHeader(response)); return { body: response.body, text: response.text, cookie: nextCookie, status: response.status, }; }; const getCnipaPage = async (url, cookieHeader, referer) => { const response = await request .get(url) .set({ Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', 'Accept-Language': 'zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7', 'User-Agent': BROWSER_USER_AGENT, ...(referer ? { Referer: referer } : {}), ...(cookieHeader ? { Cookie: cookieHeader } : {}), }) .redirects(5) .timeout({ response: 20000, deadline: 30000 }); const nextCookie = mergeCookieHeaders(cookieHeader, collectSetCookieHeader(response)); return { body: response.body, text: response.text, cookie: nextCookie, status: response.status, url: response.redirects?.[response.redirects.length - 1] || url, }; }; const bridgeCnipaPortalSession = async (cookieHeader, redirectURL) => { let cookie = cookieHeader || ''; const diagnostics = []; const redirectTarget = String(redirectURL || '').startsWith('//') ? `https:${redirectURL}` : (String(redirectURL || '').startsWith('http') ? redirectURL : 'https://tysfjk.cponline.cnipa.gov.cn/portal'); const steps = [ { url: redirectTarget, referer: `${CNIPA_AM_BASE_URL}/` }, { url: 'https://tysfjk.cponline.cnipa.gov.cn/portal', referer: redirectTarget }, { url: `${CNIPA_CPQUERY_BASE_URL}/chinesepatent/index`, referer: 'https://tysfjk.cponline.cnipa.gov.cn/portal' }, ]; for (const step of steps) { try { const res = await getCnipaPage(step.url, cookie, step.referer); cookie = mergeCookieHeaders(cookie, res.cookie); diagnostics.push({ step: step.url, status: res.status, finalUrl: res.url }); } catch (error) { diagnostics.push({ step: step.url, error: error.message }); } } return { cookie, diagnostics }; }; const bridgeCnipaPortalSessionByBrowser = async (cookieHeader, redirectURL, cnipaConfig = {}) => { const diagnostics = []; const redirectTarget = String(redirectURL || '').startsWith('//') ? `https:${redirectURL}` : (String(redirectURL || '').startsWith('http') ? redirectURL : 'https://tysfjk.cponline.cnipa.gov.cn/portal'); const browser = await launchChromiumWithFallback({ headless: cnipaConfig.browserQueryHeadless !== false, args: [ '--disable-blink-features=AutomationControlled', '--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu', '--disable-software-rasterizer', ], }); try { const context = await browser.newContext({ userAgent: BROWSER_USER_AGENT, locale: 'zh-CN', viewport: { width: 1366, height: 768 }, }); if (cookieHeader) { await context.addCookies(cookieHeaderToPlaywrightCookies(cookieHeader, CPQUERY_COOKIE_DOMAINS)); } const page = await context.newPage(); const steps = [ { url: redirectTarget, referer: `${CNIPA_AM_BASE_URL}/` }, { url: 'https://tysfjk.cponline.cnipa.gov.cn/portal', referer: redirectTarget }, { url: `${CNIPA_CPQUERY_BASE_URL}/chinesepatent/index`, referer: 'https://tysfjk.cponline.cnipa.gov.cn/portal' }, ]; for (const step of steps) { try { const resp = await page.goto(step.url, { waitUntil: 'domcontentloaded', timeout: 25000, referer: step.referer, }); await page.waitForTimeout(1200).catch(() => {}); const status = resp?.status?.() || null; diagnostics.push({ step: step.url, status, finalUrl: page.url(), }); if (status === 412) { await retryCpqueryAfterPrecondition(page, context, step, diagnostics); } } catch (error) { diagnostics.push({ step: step.url, error: error.message }); } } const cookie = await collectContextCookieHeader(context); return { cookie: mergeCookieHeaders(cookieHeader, cookie), diagnostics, }; } finally { await browser.close().catch(() => {}); } }; const collectBrowserDiagnostics = async (page, networkTrace = []) => { let title = ''; let bodyText = ''; let resources = []; try { title = await page.title(); } catch (error) { title = ''; } try { bodyText = await page.locator('body').innerText({ timeout: 1500 }); } catch (error) { bodyText = ''; } try { resources = await page.evaluate(() => performance.getEntriesByType('resource').map(item => item.name).slice(-30)); } catch (error) { resources = []; } return { currentUrl: page.url(), title, bodyText: String(bodyText || '').slice(0, 500), recentNetwork: networkTrace.slice(-30), recentResources: resources, challengeDiagnostics: page.__cnipaCpqueryChallengeDiagnostics || [], }; }; const collectContextCookieHeader = async (context) => { const cookies = await context.cookies().catch(() => []); return cookies .filter(item => item.domain && item.name) .map(item => `${item.name}=${item.value}`) .join('; '); }; const retryCpqueryAfterPrecondition = async (page, context, step, diagnostics) => { if (!String(step.url || '').includes('cpquery.cponline.cnipa.gov.cn')) return null; await page.waitForLoadState('networkidle', { timeout: 8000 }).catch(() => {}); await page.waitForTimeout(2500).catch(() => {}); const retryResp = await page.reload({ waitUntil: 'domcontentloaded', timeout: 25000, }).catch(error => { diagnostics.push({ step: `${step.url}#after-412-reload`, error: error.message, ...getCookieDiagnostics(''), }); return null; }); await page.waitForTimeout(1200).catch(() => {}); const retryCookie = await collectContextCookieHeader(context); diagnostics.push({ step: `${step.url}#after-412-reload`, status: retryResp?.status?.() || null, finalUrl: page.url(), ...getCookieDiagnostics(retryCookie), }); return retryResp; }; const createCpqueryBrowser = async (cnipaConfig = {}, options = {}) => { const headless = options.headless ?? cnipaConfig.browserHeadless === true; if (persistentCpqueryContext) { if (persistentCpqueryHeadless !== headless) { await persistentCpqueryContext.close().catch(() => {}); persistentCpqueryContext = null; persistentCpqueryHeadless = null; } else { return { context: persistentCpqueryContext, close: async () => {}, }; } } if (persistentCpqueryContext) { return { context: persistentCpqueryContext, close: async () => {}, }; } const userDataDir = cnipaConfig.browserUserDataDir || path.join(process.cwd(), '.cnipa-browser'); persistentCpqueryContext = await launchPersistentChromiumWithFallback(userDataDir, { headless, userAgent: BROWSER_USER_AGENT, locale: 'zh-CN', viewport: { width: 1366, height: 768 }, args: [ '--disable-blink-features=AutomationControlled', '--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu', '--disable-software-rasterizer', ], }); persistentCpqueryHeadless = headless; persistentCpqueryContext.on('close', () => { persistentCpqueryContext = null; persistentCpqueryHeadless = null; }); return { context: persistentCpqueryContext, close: async () => {}, }; }; const getCnipaUserDataDir = (cnipaConfig = {}) => cnipaConfig.browserUserDataDir || path.join(process.cwd(), '.cnipa-browser'); const getCpqueryPage = async (cnipaConfig = {}, options = {}) => { const browser = await createCpqueryBrowser(cnipaConfig, options); const context = browser.context; const page = persistentLoginPage && !persistentLoginPage.isClosed() ? persistentLoginPage : context.pages().find(item => !item.isClosed()) || await context.newPage(); persistentLoginPage = page; return { browser, context, page }; }; const isLoginStateReady = async (page) => { try { const bodyText = await page.locator('body').innerText({ timeout: 2500 }); const text = String(bodyText || '').replace(/\s+/g, ''); if (/欢迎您|退出登录/.test(text) && !/用户登录|账号登录|密码登录|请登录/.test(text)) return true; if (/案件查询|申请号\/专利号|共查询到/.test(text)) return true; } catch (error) { // Treat unreadable pages as not ready. } return false; }; const refreshLatestBrowserCookies = async (context, page) => { const browserCookies = await context.cookies().catch(() => []); const cookie = browserCookies .filter(item => item.domain && item.name) .map(item => `${item.name}=${item.value}`) .join('; '); latestQueryAuth = { ...(latestQueryAuth || {}), cookie: mergeCookieHeaders(latestQueryAuth?.cookie, cookie) || null, redirectURL: page.url(), cpqueryReferer: page.url(), browserReady: true, updatedAt: Date.now(), }; return latestQueryAuth.cookie; }; const ensureCnipaAmSession = async (ctx) => { if (!latestLoginFlow?.cookie || !/SESSION=/.test(latestLoginFlow.cookie)) { const initRes = await getCnipaAm('/', latestLoginFlow?.cookie || latestQueryAuth?.cookie || '').catch(() => null); const cookie = initRes?.cookie || latestLoginFlow?.cookie || latestQueryAuth?.cookie || ''; latestLoginFlow = { ...(latestLoginFlow || {}), cookie: mergeCookieHeaders(latestLoginFlow?.cookie, cookie), updatedAt: Date.now(), }; } return latestLoginFlow?.cookie || latestQueryAuth?.cookie || ''; }; const createCaptchaClientUid = () => `slider-${Date.now().toString(16)}-${Math.random().toString(16).slice(2, 10)}`; const normalizeCaptchaPointJson = (requestBody = {}) => { if (requestBody.pointJson) return requestBody.pointJson; const x = Number(requestBody.x); const y = Number(requestBody.y); if (Number.isFinite(x)) { return JSON.stringify({ x: Math.round(x), y: Number.isFinite(y) ? Math.round(y) : 5, }); } return ''; }; const buildCnipaLoginPayload = (cnipaConfig = {}, requestBody = {}) => { const loginName = requestBody.loginName || requestBody.username || cnipaConfig.loginName || cnipaConfig.username; const encryptedPassword = requestBody.encryptedPassword || cnipaConfig.encryptedPassword || cnipaConfig.password; if (!loginName || !encryptedPassword) { const error = new Error('未配置 CNIPA_LOGIN_NAME 或 CNIPA_ENCRYPTED_PASSWORD,无法调用 CNIPA 登录接口'); error.status = 400; throw error; } return { callbackVOList: [ { callback: { loginName, moduleName: 'PasswordModule', prompt: '用户名', step: 1, subjectNeedFill: true, }, callbackType: 'cn.org.bjca.am.authframework.callback.LoginNameCallback', order: 0, }, { callback: { encoding: false, moduleName: 'PasswordModule', password: encryptedPassword, prompt: '密码', step: 1, subjectNeedFill: true, }, callbackType: 'cn.org.bjca.am.authframework.callback.PasswordCallback', order: 1, }, { callback: { choices: { 1: '个人用户', 2: '单位用户', 3: '代办人' }, defaultChoice: '', moduleName: 'PasswordModule', multipleSelectionsAllowed: false, prompt: '111', selectedKey: String(requestBody.userType || cnipaConfig.loginUserType || '1'), step: 1, subjectNeedFill: true, }, callbackType: 'cn.org.bjca.am.authframework.callback.ChoiceCallback', order: 2, }, { callback: { moduleName: 'PasswordModule', prompt: '验证码', step: 1, subjectNeedFill: true, validateCode: requestBody.validateCode || '', }, callbackType: 'cn.org.bjca.am.authframework.callback.ValidateCodeCallback', order: 3, }, { callback: { moduleName: 'PasswordModule', step: 1, subjectNeedFill: true, type: requestBody.appType || '', }, callbackType: 'cn.org.bjca.am.authframework.callback.AppTypeCallback', order: 4, }, ], indexName: 'initService', indexType: 'service', moduleName: 'PasswordModule', step: 1, }; }; const syncLoginCookieToBrowser = async (ctx, cookieHeader) => { const cnipaConfig = readCnipaConfig(ctx); const { context, page } = await getCpqueryPage(cnipaConfig, { headless: cnipaConfig.browserQueryHeadless !== false, }); await context.clearCookies().catch(() => {}); if (cookieHeader) { await context.addCookies(cookieHeaderToPlaywrightCookies(cookieHeader, CPQUERY_COOKIE_DOMAINS)); } const redirectURL = latestLoginFlow?.loginResponse?.data?.callback?.redirectURL || latestLoginFlow?.loginResponse?.data?.redirectURL || ''; const redirectTarget = String(redirectURL || '').startsWith('//') ? `https:${redirectURL}` : (String(redirectURL || '').startsWith('http') ? redirectURL : 'https://tysfjk.cponline.cnipa.gov.cn/portal'); await page.goto(redirectTarget, { waitUntil: 'domcontentloaded', timeout: 25000, referer: `${CNIPA_AM_BASE_URL}/`, }).catch(() => {}); await page.waitForTimeout(1500).catch(() => {}); // 先经过统一身份入口,完成 SSO 会话桥接,再进入 cpquery。 await page.goto('https://tysfjk.cponline.cnipa.gov.cn/portal', { waitUntil: 'domcontentloaded', timeout: 20000, referer: `${CNIPA_AM_BASE_URL}/`, }).catch(() => {}); await page.waitForTimeout(1200).catch(() => {}); await page.goto(`${CNIPA_CPQUERY_BASE_URL}/chinesepatent/index`, { waitUntil: 'domcontentloaded', timeout: 20000, referer: 'https://tysfjk.cponline.cnipa.gov.cn/portal', }).catch(() => {}); try { await page.waitForLoadState('networkidle', { timeout: 6000 }); } catch (error) { // some pages keep long polling } await page.waitForTimeout(1200).catch(() => {}); const cookie = await refreshLatestBrowserCookies(context, page); return { loggedIn: await isLoginStateReady(page), cookie, diagnostics: await collectBrowserDiagnostics(page), }; }; const getCnipaLoginCredentials = (cnipaConfig = {}, requestBody = {}) => ({ loginName: requestBody.loginName || requestBody.username || cnipaConfig.loginName || cnipaConfig.username || '', password: requestBody.password || requestBody.encryptedPassword || cnipaConfig.password || cnipaConfig.encryptedPassword || '', }); const collectLoginPanelState = async (page) => page.evaluate(() => { const normalize = (text) => String(text || '').replace(/\s+/g, ''); const visible = element => { if (!element) return false; const rect = element.getBoundingClientRect(); const style = window.getComputedStyle(element); return rect.width > 0 && rect.height > 0 && style.visibility !== 'hidden' && style.display !== 'none'; }; const checkedTab = [...document.querySelectorAll('.title-group label, label[role="radio"], .el-radio')] .filter(visible) .find(element => element.classList.contains('is-checked') || element.getAttribute('aria-checked') === 'true'); return { checkedTabText: normalize(checkedTab?.innerText || checkedTab?.textContent), placeholders: [...document.querySelectorAll('input')] .filter(visible) .map(element => element.placeholder || element.getAttribute('aria-label') || element.type || ''), }; }).catch(() => ({ checkedTabText: '', placeholders: [], })); const selectLegalLoginTab = async (page) => { const tabSelectors = [ 'label:has-text("法人登录")', 'label[role="radio"]:has-text("法人登录")', '.title-group label:has-text("法人登录")', '.title-group .el-radio:has-text("法人登录")', 'span:has-text("法人登录")', 'input.el-radio__original[value="2"]', ]; for (const selector of tabSelectors) { const locator = page.locator(selector).first(); try { await locator.waitFor({ state: 'visible', timeout: 4000 }); await locator.click({ timeout: 4000, force: true }); return true; } catch (error) { // Try the next selector; the login page has changed markup before. } } return page.evaluate(() => { const normalize = (text) => String(text || '').replace(/\s+/g, ''); const target = [...document.querySelectorAll('label,button,div,span,a,li')] .find(element => /法人登录/.test(normalize(element.textContent))); if (!target) return false; const rect = target.getBoundingClientRect(); const eventInit = { bubbles: true, cancelable: true, composed: true, view: window, clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2, }; target.dispatchEvent(new PointerEvent('pointerdown', eventInit)); target.dispatchEvent(new MouseEvent('mousedown', eventInit)); target.dispatchEvent(new PointerEvent('pointerup', eventInit)); target.dispatchEvent(new MouseEvent('mouseup', eventInit)); target.dispatchEvent(new MouseEvent('click', eventInit)); if (typeof target.click === 'function') target.click(); return true; }).catch(() => false); }; const fillVisibleInputByPattern = async (page, patterns, value) => page.evaluate(({ patterns: patternSources, value: inputValue }) => { const regexList = patternSources.map(source => new RegExp(source)); const visible = element => { if (!element || element.disabled || element.readOnly) return false; const type = String(element.type || '').toLowerCase(); if (['hidden', 'radio', 'checkbox', 'button', 'submit'].includes(type)) return false; const rect = element.getBoundingClientRect(); const style = window.getComputedStyle(element); return rect.width > 0 && rect.height > 0 && style.visibility !== 'hidden' && style.display !== 'none'; }; const textOf = element => [ element.placeholder, element.getAttribute('name'), element.getAttribute('id'), element.getAttribute('aria-label'), element.closest('.el-form-item')?.innerText, element.closest('form')?.innerText, element.parentElement?.innerText, ].filter(Boolean).join(' '); const target = [...document.querySelectorAll('input')] .filter(visible) .find(element => regexList.some(regex => regex.test(textOf(element)))); if (!target) return false; target.focus(); const prototype = Object.getPrototypeOf(target); const descriptor = Object.getOwnPropertyDescriptor(prototype, 'value'); if (descriptor?.set) descriptor.set.call(target, inputValue); else target.value = inputValue; target.dispatchEvent(new Event('input', { bubbles: true })); target.dispatchEvent(new Event('change', { bubbles: true })); target.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: '0' })); target.blur(); return true; }, { patterns: patterns.map(pattern => pattern.source), value, }).catch(() => false); const clickVisibleLoginButton = async (page) => { const loginButton = page.locator('form button:has-text("登录"), button.login-btn:has-text("登录")').first(); try { await loginButton.waitFor({ state: 'visible', timeout: 4000 }); await loginButton.click({ timeout: 4000 }); return true; } catch (error) { return page.evaluate(() => { const normalize = (text) => String(text || '').replace(/\s+/g, ''); const visible = element => { const rect = element.getBoundingClientRect(); const style = window.getComputedStyle(element); return rect.width > 0 && rect.height > 0 && style.visibility !== 'hidden' && style.display !== 'none'; }; const button = [...document.querySelectorAll('form button,button')] .filter(visible) .find(element => /^登录$/.test(normalize(element.textContent))); if (!button) return false; button.click(); return true; }).catch(() => false); } }; const autoFillLegalLogin = async (page, cnipaConfig = {}, requestBody = {}) => { const credentials = getCnipaLoginCredentials(cnipaConfig, requestBody); const result = { legalTabClicked: false, legalTabReady: false, loginFilled: false, passwordFilled: false, loginClicked: false, missingLoginName: !credentials.loginName, missingPassword: !credentials.password, beforeState: await collectLoginPanelState(page), afterState: null, }; for (let attempt = 0; attempt < 3; attempt += 1) { const clicked = await selectLegalLoginTab(page); if (clicked) result.legalTabClicked = true; await page.waitForTimeout(120).catch(() => {}); const state = await collectLoginPanelState(page); if ((state.checkedTabText || '').includes('法人登录')) break; } try { await page.locator('input[placeholder*="统一社会信用代码"]').first().waitFor({ state: 'visible', timeout: 1500 }); result.legalTabReady = true; } catch (error) { const state = await collectLoginPanelState(page); result.legalTabReady = state.placeholders.some(item => /统一社会信用代码/.test(item)); } if (credentials.loginName) { try { await page.locator('input[placeholder*="统一社会信用代码"]').first().fill(credentials.loginName, { timeout: 4000 }); result.loginFilled = true; } catch (error) { result.loginFilled = await fillVisibleInputByPattern(page, [/统一社会信用代码|用户名|账号|登录名/], credentials.loginName); } } if (credentials.password) { try { await page.locator('input[type="password"], input[placeholder*="密码"]').first().fill(credentials.password, { timeout: 4000 }); result.passwordFilled = true; } catch (error) { result.passwordFilled = await fillVisibleInputByPattern(page, [/密码/], credentials.password); } } if (result.loginFilled && result.passwordFilled) { result.loginClicked = await clickVisibleLoginButton(page); } result.afterState = await collectLoginPanelState(page); return result; }; const openPreparedCpqueryBrowser = async (ctx, url = `${CNIPA_CPQUERY_BASE_URL}/chinesepatent/index`) => { const cnipaConfig = readCnipaConfig(ctx); const requestBody = ctx.request.body || {}; const browser = await createCpqueryBrowser(cnipaConfig, { headless: requestBody.headless === true || cnipaConfig.browserPrepareHeadless === true, }); const context = browser.context; const page = context.pages()[0] || await context.newPage(); await page.bringToFront(); await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000, }); const autoLogin = await autoFillLegalLogin(page, cnipaConfig, requestBody).catch(() => ({ legalTabClicked: false, legalTabReady: false, loginFilled: false, passwordFilled: false, loginClicked: false, missingLoginName: !getCnipaLoginCredentials(cnipaConfig, requestBody).loginName, missingPassword: !getCnipaLoginCredentials(cnipaConfig, requestBody).password, })); const browserCookies = await context.cookies().catch(() => []); const cookie = browserCookies .filter(item => item.domain && item.name) .map(item => `${item.name}=${item.value}`) .join('; '); latestQueryAuth = { ...(latestQueryAuth || {}), cookie: mergeCookieHeaders(latestQueryAuth?.cookie, cookie) || null, redirectURL: page.url(), cpqueryReferer: page.url(), browserReady: true, updatedAt: Date.now(), }; return { url: page.url(), diagnostics: await collectBrowserDiagnostics(page), userDataDir: cnipaConfig.browserUserDataDir || path.join(process.cwd(), '.cnipa-browser'), headless: persistentCpqueryHeadless === true, autoLogin, }; }; const setSearchInputValue = async (page, value) => { for (const frame of page.frames()) { try { const success = await frame.evaluate((patentNumber) => { const hiddenTypes = ['hidden', 'radio', 'checkbox', 'button', 'submit']; const isUsable = element => { const style = window.getComputedStyle(element); if (style.visibility === 'hidden' || style.display === 'none') return false; if (element.disabled || element.readOnly) return false; if (hiddenTypes.includes(String(element.type || '').toLowerCase())) return false; return true; }; const center = rect => ({ x: rect.left + rect.width / 2, y: rect.top + rect.height / 2, }); const findLabelRect = () => { const elements = [...document.querySelectorAll('label,span,div,p,td,th')] .filter(element => /申请号\s*\/\s*专利号|申请号|专利号/.test(String(element.textContent || ''))) .map(element => ({ element, rect: element.getBoundingClientRect() })) .filter(item => item.rect.width > 0 && item.rect.height > 0); return elements[0]?.rect || null; }; const textOf = element => [ element.placeholder, element.getAttribute('aria-label'), element.getAttribute('name'), element.getAttribute('id'), element.closest('.el-form-item')?.innerText, element.closest('.ant-form-item')?.innerText, element.parentElement?.innerText, ].filter(Boolean).join(' '); const controls = [...document.querySelectorAll('input,textarea,[contenteditable="true"],[role="textbox"]')] .filter(isUsable) .map((element, index) => { const text = textOf(element); let score = 0; const rect = element.getBoundingClientRect(); if (/2010|申请号|专利号|申请/.test(text)) score += 10; if (/请输入|输入/.test(text)) score += 2; if (rect.width > 80 && rect.height > 15) score += 1; return { element, index, score, rect }; }) .sort((a, b) => b.score - a.score || a.index - b.index); const labelRect = findLabelRect(); const coordinateTarget = labelRect ? controls .map(item => { const labelCenter = center(labelRect); const itemCenter = center(item.rect); const sameRowPenalty = Math.abs(itemCenter.y - labelCenter.y) * 20; const rightPenalty = itemCenter.x >= labelRect.right ? 0 : 10000; const distance = rightPenalty + sameRowPenalty + Math.max(0, itemCenter.x - labelRect.right); return { ...item, distance }; }) .sort((a, b) => a.distance - b.distance)[0] : null; const target = coordinateTarget?.distance < 4000 ? coordinateTarget.element : controls[0]?.element; if (!target) return false; target.setAttribute('data-cnipa-query-input', 'true'); target.focus(); if (target.isContentEditable) { target.textContent = patentNumber; } else { const prototype = Object.getPrototypeOf(target); const descriptor = Object.getOwnPropertyDescriptor(prototype, 'value'); if (descriptor?.set) { descriptor.set.call(target, patentNumber); } else { target.value = patentNumber; } } target.dispatchEvent(new Event('input', { bubbles: true })); target.dispatchEvent(new Event('change', { bubbles: true })); target.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: '0' })); const buttonCandidates = [...document.querySelectorAll('button,.q-btn,.el-button,.ant-btn,[role="button"]')] .filter(element => isUsable(element)) .map((element, index) => ({ element, index, text: String(element.innerText || element.textContent || element.getAttribute('aria-label') || '').trim(), rect: element.getBoundingClientRect(), })) .filter(item => { const compactText = item.text.replace(/\s+/g, ''); if (!/查询/.test(compactText)) return false; if (/中国|多国|审查信息|重置|更多|展开|案件查询|申请方式查询/.test(compactText)) return false; if (item.rect.top < target.getBoundingClientRect().bottom) return false; return compactText.length <= 12 || item.element.matches('button,.q-btn,.el-button,.ant-btn'); }) .map(item => { const targetRect = target.getBoundingClientRect(); const itemCenter = center(item.rect); const targetCenter = center(targetRect); const verticalDistance = Math.abs(itemCenter.y - (targetRect.bottom + 95)); const horizontalDistance = Math.abs(itemCenter.x - targetCenter.x); return { ...item, distance: verticalDistance * 4 + horizontalDistance, }; }) .sort((a, b) => a.distance - b.distance || a.index - b.index); const searchButton = buttonCandidates[0]?.element; if (searchButton) { searchButton.setAttribute('data-cnipa-query-button', 'true'); searchButton.setAttribute('title', 'CNIPA 自动查询按钮'); searchButton.style.outline = '2px solid #ef4444'; } return true; }, value); if (success) return frame; } catch (error) { // Try the next frame. } } return null; }; const findDetailUrlInPage = async (page) => page.evaluate(() => { const resolveUrl = (value) => { if (!value) return null; try { return new URL(value, location.href).toString(); } catch (error) { return null; } }; const anchors = [...document.querySelectorAll('a[href]')]; for (const anchor of anchors) { const url = resolveUrl(anchor.getAttribute('href')); if (url && url.includes('/detail/index') && url.includes('zhuanlisqh=')) return url; } const html = document.documentElement.outerHTML; const patterns = [ /https:\/\/cpquery\.cponline\.cnipa\.gov\.cn\/detail\/index\?[^"'<>\s]+/i, /\/detail\/index\?[^"'<>\s]+zhuanlisqh=[^"'<>\s]+/i, ]; for (const pattern of patterns) { const match = html.match(pattern); if (match?.[0]) return resolveUrl(match[0].replace(/&/g, '&')); } return null; }); const isCpqueryDetailPage = async (page) => { for (const frame of page.frames()) { try { const isDetail = await frame.evaluate(() => { const text = String(document.body?.innerText || ''); return /国内案件信息详情|申请信息|发文信息|申请号\/专利号:\d{8,}/.test(text) && /class="[^"]*(detailBox|boxRow|el-tree)/.test(document.documentElement.outerHTML); }); if (isDetail) return true; } catch (error) { // Keep scanning other frames. } } return false; }; const clickFirstSearchResult = async (frame, number) => frame.evaluate((patentNumber) => { const visible = element => { const rect = element.getBoundingClientRect(); const style = window.getComputedStyle(element); return rect.width > 0 && rect.height > 0 && style.visibility !== 'hidden' && style.display !== 'none'; }; const clickElement = element => { element.scrollIntoView({ block: 'center', inline: 'center' }); element.setAttribute('title', 'CNIPA 自动点击详情'); element.style.outline = '2px solid #22c55e'; const rect = element.getBoundingClientRect(); const eventInit = { bubbles: true, cancelable: true, view: window, clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2, }; element.dispatchEvent(new MouseEvent('mouseover', eventInit)); element.dispatchEvent(new MouseEvent('mousedown', eventInit)); element.dispatchEvent(new MouseEvent('mouseup', eventInit)); element.dispatchEvent(new MouseEvent('click', eventInit)); return true; }; const patentNumberText = String(patentNumber || ''); const resultCards = [ ...document.querySelectorAll('.tabele_list,.q-pa-sm,.table_info,tbody tr,.el-table__row,[role="row"]'), ] .filter(visible) .map(element => ({ element, text: String(element.innerText || element.textContent || '').trim(), })) .filter(item => item.text.includes(patentNumberText) || /申请号\s*\/\s*专利号/.test(item.text)); const matchedCard = resultCards.find(item => item.text.includes(patentNumberText))?.element || resultCards[0]?.element; if (matchedCard) { const detailTarget = matchedCard.querySelector('.title.hover_active,.hover_active.title,a[href*="/detail/index"],span.hover_active'); if (detailTarget && visible(detailTarget)) return clickElement(detailTarget); } const isHeaderLike = text => /申请号\/专利号\s*发明名称|发明名称\s*申请人\s*专利类型|共查询到|主办单位|案件查询/.test(text); const candidates = [ ...document.querySelectorAll('tbody tr, .el-table__row, .ant-table-row, .mat-row, [role="row"], .v-data-table__tr'), ] .filter(visible) .map(element => ({ element, text: String(element.innerText || element.textContent || '').trim() })) .filter(item => item.text.length > 8 && !isHeaderLike(item.text)); const row = candidates.find(item => /发明|实用新型|外观设计|申请|公布|授权/.test(item.text))?.element || candidates[0]?.element; if (!row) return false; const clickable = row.querySelector('.title.hover_active,.hover_active.title,a[href*="/detail/index"],a,button,[role="button"],td,div,span') || row; clickElement(clickable); return true; }, number); const clickSearchButton = async (frame) => { const markedButton = frame.locator('[data-cnipa-query-button="true"]').first(); try { await markedButton.scrollIntoViewIfNeeded({ timeout: 3000 }); await markedButton.click({ timeout: 5000, force: true }); return true; } catch (error) { // Fall through to a constrained DOM search. } const clicked = await frame.evaluate(() => { const visible = element => { const rect = element.getBoundingClientRect(); const style = window.getComputedStyle(element); return rect.width > 0 && rect.height > 0 && style.visibility !== 'hidden' && style.display !== 'none'; }; const center = rect => ({ x: rect.left + rect.width / 2, y: rect.top + rect.height / 2, }); const input = document.querySelector('[data-cnipa-query-input="true"]'); const inputRect = input?.getBoundingClientRect(); if (!inputRect) return false; const buttons = [...document.querySelectorAll('button,.q-btn,.el-button,.ant-btn,[role="button"]')] .filter(visible) .map((element, index) => ({ element, index, text: String(element.innerText || element.textContent || element.getAttribute('aria-label') || '').trim(), rect: element.getBoundingClientRect(), })) .filter(item => { const compactText = item.text.replace(/\s+/g, ''); if (!/查询/.test(compactText)) return false; if (/中国|多国|审查信息|重置|更多|展开|案件查询|申请方式查询/.test(compactText)) return false; if (item.rect.top < inputRect.bottom) return false; return compactText.length <= 12 || item.element.matches('button,.q-btn,.el-button,.ant-btn'); }) .map(item => { const inputCenter = center(inputRect); const itemCenter = center(item.rect); return { ...item, distance: Math.abs(itemCenter.y - (inputRect.bottom + 95)) * 4 + Math.abs(itemCenter.x - inputCenter.x), }; }) .sort((a, b) => a.distance - b.distance || a.index - b.index); const button = buttons[0]?.element; if (!button) return false; button.setAttribute('title', 'CNIPA 自动查询按钮'); button.style.outline = '2px solid #ef4444'; button.scrollIntoView({ block: 'center', inline: 'center' }); const rect = button.getBoundingClientRect(); const eventInit = { bubbles: true, cancelable: true, view: window, clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2, }; button.dispatchEvent(new MouseEvent('mouseover', eventInit)); button.dispatchEvent(new MouseEvent('mousedown', eventInit)); button.dispatchEvent(new MouseEvent('mouseup', eventInit)); button.dispatchEvent(new MouseEvent('click', eventInit)); if (typeof button.click === 'function') button.click(); return true; }); if (clicked) return true; try { const input = frame.locator('[data-cnipa-query-input="true"]').first(); await input.press('Enter', { timeout: 3000 }); return true; } catch (error) { return false; } }; const getSearchResultSummary = async (frame) => frame.evaluate(() => { const text = String(document.body?.innerText || ''); const countMatch = text.match(/共查询到\s*(\d+)\s*条结果/); const input = document.querySelector('[data-cnipa-query-input="true"]'); return { count: countMatch ? Number(countMatch[1]) : null, inputValue: input ? (input.value || input.textContent || '') : '', }; }); const normalizePageDate = (value = '') => { const match = String(value || '').match(/\d{4}[-./年]\d{1,2}[-./月]\d{1,2}/); return match ? match[0].replace(/[年月/.]/g, '-').replace(/日/g, '') : ''; }; const normalizePageText = (value = '') => String(value || '') .replace(/\r/g, '\n') .replace(/[ \t]+/g, ' ') .replace(/\n{2,}/g, '\n') .trim(); const extractNoticeNameFromText = (value = '') => { const text = normalizePageText(value).replace(/\s+/g, ''); const explicitNames = [ '第一次审查意见通知书', '第二次审查意见通知书', '第三次审查意见通知书', '第四次审查意见通知书', '第五次审查意见通知书', '办理登记手续通知书', '授予发明专利权通知书', '授予实用新型专利权通知书', '授予外观设计专利权通知书', '驳回决定', '视为撤回通知书', '补正通知书', '手续合格通知书', '专利证书', ]; const explicit = explicitNames.find(name => text.includes(name)); if (explicit) return explicit; const match = text.match(/[\u4e00-\u9fa5A-Za-z0-9()()、-]{2,40}(?:通知书|决定书|专利证书)/); return match?.[0] || ''; }; const buildNoticeRecordFromText = (value = '') => { const text = normalizePageText(value); const name = extractNoticeNameFromText(text); if (!name) return null; const date = normalizePageDate(text); return { tongzhismc: name, fawenr: date || '-', xiazaisj: date || '-', rawText: text, }; }; const mergeNoticeRecords = (records = []) => { const unique = new Map(); records.filter(Boolean).forEach(record => { const key = `${record.tongzhismc || ''}|${record.fawenr || ''}|${record.rawText || ''}`; if (!unique.has(key)) unique.set(key, record); }); return [...unique.values()]; }; const isIssueInfoPageLoaded = async (page) => { for (const frame of page.frames()) { try { const loaded = await frame.evaluate(() => { const text = String(document.body?.innerText || ''); const title = [...document.querySelectorAll('.table .title,p.title,.tableTile')] .map(element => String(element.innerText || element.textContent || '').replace(/\s+/g, '')) .find(Boolean); return title === '发文信息' || /通知书发文/.test(text); }); if (loaded) return true; } catch (error) { // Keep scanning other frames. } } return false; }; const clickIssueInfoTreeNode = async (page) => { await page.waitForTimeout(1200); const diagnostics = []; for (let attempt = 0; attempt < 5; attempt += 1) { for (const frame of page.frames()) { try { const clickResult = await frame.evaluate(() => { const normalize = value => String(value || '').replace(/\s+/g, ''); const visible = element => { const rect = element.getBoundingClientRect(); const style = window.getComputedStyle(element); return rect.width > 0 && rect.height > 0 && style.visibility !== 'hidden' && style.display !== 'none'; }; const title = [...document.querySelectorAll('.table .title,p.title,.tableTile')] .map(element => normalize(element.innerText || element.textContent)) .find(Boolean) || ''; const treeItems = [...document.querySelectorAll('.tree [role="treeitem"],[role="treeitem"].el-tree-node')] .filter(visible) .map((element, index) => ({ element, index, text: normalize(element.innerText || element.textContent), current: element.classList.contains('is-current'), })); const targetItem = treeItems.find(item => item.text === '发文信息'); if (!targetItem) { return { clicked: false, reason: '发文信息 treeitem not found', title, treeTexts: treeItems.map(item => item.text), }; } const treeNode = targetItem.element; const content = treeNode.querySelector('.el-tree-node__content') || treeNode; const textTarget = [...treeNode.querySelectorAll('.custom-tree-node span,.custom-tree-node,span')] .find(element => normalize(element.innerText || element.textContent) === '发文信息') || content; const rect = content.getBoundingClientRect(); const textRect = textTarget.getBoundingClientRect(); const eventInit = { bubbles: true, cancelable: true, composed: true, view: window, clientX: textRect.left + textRect.width / 2, clientY: textRect.top + textRect.height / 2, }; [textTarget, content, treeNode].forEach(element => { element.setAttribute('title', 'CNIPA 自动点击发文信息'); element.style.outline = '4px solid #f59e0b'; element.dispatchEvent(new PointerEvent('pointerdown', eventInit)); element.dispatchEvent(new MouseEvent('mousedown', eventInit)); element.dispatchEvent(new PointerEvent('pointerup', eventInit)); element.dispatchEvent(new MouseEvent('mouseup', eventInit)); element.dispatchEvent(new MouseEvent('click', eventInit)); if (typeof element.click === 'function') element.click(); }); return { clicked: true, title, treeTexts: treeItems.map(item => `${item.current ? '*' : ''}${item.text}`), x: textRect.left + textRect.width / 2, y: textRect.top + textRect.height / 2, contentX: rect.left + rect.width / 2, contentY: rect.top + rect.height / 2, }; }); diagnostics.push({ attempt, frameUrl: frame.url(), ...clickResult }); if (clickResult?.clicked) { await page.mouse.click(clickResult.x, clickResult.y); await page.waitForTimeout(300); await page.mouse.click(clickResult.contentX, clickResult.contentY); await page.waitForTimeout(1200); if (await isIssueInfoPageLoaded(page)) return true; } } catch (error) { diagnostics.push({ attempt, frameUrl: frame.url(), error: error.message }); } } } const loaded = await isIssueInfoPageLoaded(page); page.__cnipaIssueInfoDiagnostics = diagnostics.slice(-10); return loaded; }; const scrapeNoticeInfoFromPage = async (page, number) => { const issueInfoClicked = await clickIssueInfoTreeNode(page); const snapshots = []; for (const frame of page.frames()) { try { snapshots.push(await frame.evaluate(() => { const visible = element => { const rect = element.getBoundingClientRect(); const style = window.getComputedStyle(element); return rect.width > 0 && rect.height > 0 && style.visibility !== 'hidden' && style.display !== 'none'; }; const rowSelectors = [ 'tbody tr', '.el-table__row', '.ant-table-row', '[role="row"]', '.el-descriptions__row', '.ant-descriptions-row', 'li', ]; const rows = rowSelectors.flatMap(selector => [...document.querySelectorAll(selector)]) .filter(visible) .map(element => String(element.innerText || element.textContent || '').trim()) .filter(Boolean); return { bodyText: String(document.body?.innerText || '').trim(), rows, }; })); } catch (error) { // Ignore inaccessible frames. } } const rowRecords = snapshots.flatMap(snapshot => snapshot.rows || []) .filter(text => /通知书|决定书|专利证书|办理登记手续|审查意见/.test(text)) .map(buildNoticeRecordFromText); const bodyRecords = snapshots.flatMap(snapshot => normalizePageText(snapshot.bodyText).split('\n')) .filter(text => /通知书|决定书|专利证书|办理登记手续|审查意见/.test(text)) .map(buildNoticeRecordFromText); const bodyText = normalizePageText(snapshots.map(snapshot => snapshot.bodyText).filter(Boolean).join('\n')); const hasFirstOfficeAction = bodyText.includes('第一次审查意见通知书'); const records = mergeNoticeRecords([ ...rowRecords, ...bodyRecords, hasFirstOfficeAction ? { tongzhismc: '第一次审查意见通知书', fawenr: normalizePageDate(bodyText) || '-', xiazaisj: normalizePageDate(bodyText) || '-', rawText: '页面存在第一次审查意见通知书', } : null, ]); const certificateList = records.filter(item => /证书|授权|授予|办理登记手续/.test(item.tongzhismc || item.rawText || '')); const noticeList = records.filter(item => !certificateList.includes(item)); return { code: 200, msg: 'success', data: { zhuanlisqh: number, tongzhishufw: { tongzhishufwList: noticeList, }, zhuanlizsfw: { zhuanlizsfwList: certificateList, }, }, rawPage: { notices: noticeList, certificates: certificateList, issueInfoClicked, issueInfoDiagnostics: page.__cnipaIssueInfoDiagnostics || [], hasFirstOfficeAction, bodyText: bodyText.slice(0, 2000), }, }; }; const openCpqueryDetailFromSearch = async (page, number, referer) => { const searchUrl = `${CNIPA_CPQUERY_BASE_URL}/chinesepatent/index`; if (referer && !String(referer).includes('cpquery.cponline.cnipa.gov.cn')) { try { await page.goto(referer, { waitUntil: 'domcontentloaded', timeout: 20000, }); await page.waitForTimeout(1000); } catch (error) { // Continue with the cpquery search page; it will redirect if the login state is insufficient. } } const searchResp = await page.goto(searchUrl, { waitUntil: 'domcontentloaded', timeout: 30000, referer: referer || 'https://tysfjk.cponline.cnipa.gov.cn/portal', }); if (searchResp?.status?.() === 412) { page.__cnipaCpqueryChallengeDiagnostics = page.__cnipaCpqueryChallengeDiagnostics || []; await retryCpqueryAfterPrecondition( page, page.context(), { url: searchUrl }, page.__cnipaCpqueryChallengeDiagnostics ); } try { await page.waitForLoadState('networkidle', { timeout: 10000 }); } catch (error) { // The page can keep background requests open. } const searchFrame = await setSearchInputValue(page, number); if (!searchFrame) { const error = new Error('cpquery 查询页未找到申请号输入框,请先确认浏览器已登录并已进入查询页'); error.response = { body: { diagnostics: await collectBrowserDiagnostics(page) } }; throw error; } const beforeUrl = page.url(); try { await searchFrame.locator('[data-cnipa-query-input="true"]').first().fill(number, { timeout: 5000 }); await searchFrame.locator('[data-cnipa-query-input="true"]').first().dispatchEvent('input', {}, { timeout: 3000 }); await searchFrame.locator('[data-cnipa-query-input="true"]').first().dispatchEvent('change', {}, { timeout: 3000 }); } catch (error) { // setSearchInputValue already populated the field through DOM events. } const clickedQuery = await clickSearchButton(searchFrame); if (!clickedQuery) { const error = new Error('cpquery 查询页未找到查询按钮'); error.response = { body: { diagnostics: await collectBrowserDiagnostics(page) } }; throw error; } try { await page.waitForLoadState('networkidle', { timeout: 12000 }); } catch (error) { // Results can render before all requests are idle. } await page.waitForTimeout(1000); let resultSummary = await getSearchResultSummary(searchFrame).catch(() => null); if (resultSummary?.count === 0 && resultSummary.inputValue === number) { await clickSearchButton(searchFrame).catch(() => false); try { await searchFrame.locator('[data-cnipa-query-input="true"]').first().press('Enter', { timeout: 3000 }); } catch (error) { // Some inputs do not submit on Enter. } try { await page.waitForLoadState('networkidle', { timeout: 8000 }); } catch (error) { // Results can render before all requests are idle. } await page.waitForTimeout(1000); resultSummary = await getSearchResultSummary(searchFrame).catch(() => resultSummary); } if (resultSummary?.count === 0) { const error = new Error(`cpquery 查询结果为 0,可能是申请号未正确填入或该号码无记录。输入框当前值:${resultSummary.inputValue || '空'}`); error.response = { body: { searchUrl, beforeUrl, resultSummary, diagnostics: await collectBrowserDiagnostics(page), }, }; throw error; } let detailUrl = await findDetailUrlInPage(page) || await findDetailUrlInPage(searchFrame); if (!detailUrl) { const detailPagePromise = page.context().waitForEvent('page', { timeout: 8000 }).catch(() => null); const detailRequestPromise = page.waitForRequest( request => request.url().includes('/detail/index') && request.url().includes('zhuanlisqh='), { timeout: 8000 } ).catch(() => null); const clicked = await clickFirstSearchResult(searchFrame, number).catch(() => false); if (!clicked) { const row = searchFrame.locator('tbody tr, .el-table__body tr, .el-table__row, [role="row"], tr').first(); try { await row.dblclick({ timeout: 5000 }); } catch (error) { try { await row.click({ timeout: 5000 }); } catch (innerError) { // Some result lists render clickable cells instead of rows. } } } try { await page.waitForURL(url => String(url).includes('/detail/index'), { timeout: 8000 }); } catch (error) { // Fall back to parsing the page HTML below. } const openedPage = await detailPagePromise; if (openedPage) { await openedPage.waitForLoadState('domcontentloaded', { timeout: 15000 }).catch(() => {}); await openedPage.bringToFront().catch(() => {}); page = openedPage; } const detailRequest = await detailRequestPromise; detailUrl = page.url().includes('/detail/index') ? page.url() : detailRequest?.url() || (await findDetailUrlInPage(page) || await findDetailUrlInPage(searchFrame)); if (!detailUrl && await isCpqueryDetailPage(page)) { detailUrl = page.url(); } } if (!detailUrl) { const error = new Error('cpquery 查询页未找到详情链接'); error.response = { body: { searchUrl, beforeUrl, diagnostics: await collectBrowserDiagnostics(page), }, }; throw error; } if (page.url() !== detailUrl && !(await isCpqueryDetailPage(page))) { await page.goto(detailUrl, { waitUntil: 'domcontentloaded', timeout: 30000, referer: searchUrl, }); } try { await page.waitForLoadState('networkidle', { timeout: 10000 }); } catch (error) { // Captured requests and rendered page state are sufficient. } return { detailUrl, page }; }; const queryGrantNoticeByBrowser = async ({ number, cookieHeader, referer, cnipaConfig }) => { let browser; const networkTrace = []; try { browser = await createCpqueryBrowser(cnipaConfig, { headless: cnipaConfig.browserQueryHeadless !== false }); const context = browser.context; // 清除旧 cookies,避免与新 cookie 冲突导致 HTTP 412 await context.clearCookies().catch(() => {}); if (cookieHeader) { await context.addCookies(cookieHeaderToPlaywrightCookies(cookieHeader, CPQUERY_COOKIE_DOMAINS)); } let page = context.pages()[0] || await context.newPage(); page.on('request', request => { const url = request.url(); if (/cponline\.cnipa\.gov\.cn/.test(url)) { networkTrace.push({ type: 'request', method: request.method(), url }); } }); page.on('response', response => { const url = response.url(); if (/cponline\.cnipa\.gov\.cn/.test(url)) { networkTrace.push({ type: 'response', status: response.status(), url }); } }); const detailResult = await openCpqueryDetailFromSearch(page, number, referer); const detailUrl = detailResult.detailUrl; page = detailResult.page; const pageBody = await scrapeNoticeInfoFromPage(page, number); if (!pageBody.rawPage.issueInfoClicked) { const error = new Error('浏览器详情页未找到或未能点击“发文信息”节点,无法判断是否一次性授权'); error.response = { body: { detailUrl, diagnostics: await collectBrowserDiagnostics(page, networkTrace), rawPage: pageBody.rawPage, }, }; throw error; } const browserCookies = await context.cookies(); const nextCookieHeader = browserCookies .filter(item => item.domain && item.name) .map(item => `${item.name}=${item.value}`) .join('; '); return { detailUrl, cookie: nextCookieHeader, response: { ok: true, status: 200, statusText: 'OK', body: pageBody, text: JSON.stringify(pageBody), url: detailUrl, }, diagnostics: await collectBrowserDiagnostics(page, networkTrace), }; } finally { if (browser) await browser.close(); } }; module.exports.prepareBrowser = async (ctx) => { const targetUrl = ctx.request.body?.url || `${CNIPA_CPQUERY_BASE_URL}/chinesepatent/index`; try { const result = await openPreparedCpqueryBrowser(ctx, targetUrl); ctx.status = 200; ctx.body = { success: true, message: '已打开 CNIPA 浏览器,系统已自动执行“法人登录 + 账号密码填写 + 登录按钮点击”。请仅完成滑块验证。', ...result, }; } catch (error) { ctx.logger.log(error); ctx.status = 400; ctx.body = { success: false, message: error.message || '打开 CNIPA 查询浏览器失败', detail: error.response?.body || error.response?.text || null, }; } }; module.exports.prepareBrowserInternal = async (ctx, targetUrl = `${CNIPA_CPQUERY_BASE_URL}/chinesepatent/index`) => { return openPreparedCpqueryBrowser(ctx, targetUrl); }; module.exports.getCaptcha = async (ctx) => { try { const cookie = await ensureCnipaAmSession(ctx); const requestBody = ctx.request.body || {}; const payload = { captchaType: requestBody.captchaType || 'blockPuzzle', clientUid: requestBody.clientUid || createCaptchaClientUid(), ts: requestBody.ts || Date.now(), }; const response = await postCnipaAm('/captcha/get', payload, cookie); latestLoginFlow = { ...(latestLoginFlow || {}), cookie: response.cookie, captchaType: payload.captchaType, captchaClientUid: payload.clientUid, captchaGetResponse: response.body, updatedAt: Date.now(), }; ctx.status = 200; ctx.body = { success: true, data: response.body, cookie: response.cookie, }; } catch (error) { ctx.logger.log(error); ctx.status = 400; ctx.body = { success: false, message: error.message || '获取 CNIPA 验证码失败', detail: error.response?.body || error.response?.text || null, }; } }; module.exports.checkCaptcha = async (ctx) => { try { const cookie = await ensureCnipaAmSession(ctx); const requestBody = ctx.request.body || {}; const payload = { captchaType: requestBody.captchaType || latestLoginFlow?.captchaType || 'blockPuzzle', pointJson: normalizeCaptchaPointJson(requestBody), token: requestBody.token || latestLoginFlow?.captchaGetResponse?.repData?.token || '', }; if (!payload.pointJson || !payload.token) { const error = new Error('缺少参数 pointJson 或 token'); error.status = 400; throw error; } const response = await postCnipaAm('/captcha/check', payload, cookie); const repCode = String(response.body?.repCode || ''); const passed = repCode === '0000'; if (!passed) { // 验证失败时立即刷新一张新验证码,直接回传给前端,避免重走登录流程。 let nextCaptcha = null; try { const refreshPayload = { captchaType: payload.captchaType, clientUid: latestLoginFlow?.captchaClientUid || createCaptchaClientUid(), ts: Date.now(), }; const refreshResponse = await postCnipaAm('/captcha/get', refreshPayload, response.cookie || cookie); nextCaptcha = refreshResponse.body; latestLoginFlow = { ...(latestLoginFlow || {}), cookie: refreshResponse.cookie || response.cookie || cookie, captchaType: payload.captchaType, captchaClientUid: refreshPayload.clientUid, captchaGetResponse: refreshResponse.body, captchaCheckResponse: response.body, updatedAt: Date.now(), }; } catch (refreshError) { latestLoginFlow = { ...(latestLoginFlow || {}), cookie: response.cookie || cookie, captchaCheckResponse: response.body, updatedAt: Date.now(), }; } ctx.status = 200; ctx.body = { success: false, message: response.body?.repMsg || '滑块验证码校验失败', code: 'CAPTCHA_CHECK_FAILED', data: response.body, nextCaptcha, cookie: latestLoginFlow?.cookie || response.cookie || cookie, }; return; } const captchaVerification = requestBody.captchaVerification || requestBody.validateCode || `${payload.token}---${payload.pointJson}`; latestLoginFlow = { ...(latestLoginFlow || {}), cookie: response.cookie, captchaCheckResponse: response.body, validateCode: captchaVerification, updatedAt: Date.now(), }; ctx.status = 200; ctx.body = { success: true, data: response.body, validateCode: captchaVerification, cookie: response.cookie, }; } catch (error) { ctx.logger.log(error); ctx.status = 400; ctx.body = { success: false, message: error.message || '校验 CNIPA 验证码失败', detail: error.response?.body || error.response?.text || null, }; } }; module.exports.submitLogin = async (ctx) => { try { const cookie = await ensureCnipaAmSession(ctx); const cnipaConfig = readCnipaConfig(ctx); const requestBody = { ...(ctx.request.body || {}), userType: String((ctx.request.body || {}).userType || cnipaConfig.loginUserType || '2'), validateCode: (ctx.request.body || {}).validateCode || (ctx.request.body || {}).captchaVerification || latestLoginFlow?.validateCode || '', }; const payload = buildCnipaLoginPayload(cnipaConfig, requestBody); const response = await postCnipaAm('/login', payload, cookie); const loginCode = Number(response.body?.code); const loginRepCode = String(response.body?.repCode || ''); const loginSuccess = ( (!Number.isFinite(loginCode) || loginCode === 200) && (!loginRepCode || loginRepCode === '0000') && !/失败|错误|验证码/.test(String(response.body?.msg || response.body?.message || '')) ); if (!loginSuccess) { let nextCaptcha = null; try { const refreshPayload = { captchaType: latestLoginFlow?.captchaType || 'blockPuzzle', clientUid: latestLoginFlow?.captchaClientUid || createCaptchaClientUid(), ts: Date.now(), }; const refreshResponse = await postCnipaAm('/captcha/get', refreshPayload, response.cookie || cookie); nextCaptcha = refreshResponse.body; latestLoginFlow = { ...(latestLoginFlow || {}), cookie: refreshResponse.cookie || response.cookie || cookie, captchaType: refreshPayload.captchaType, captchaClientUid: refreshPayload.clientUid, captchaGetResponse: refreshResponse.body, loginResponse: response.body, updatedAt: Date.now(), }; } catch (refreshError) { latestLoginFlow = { ...(latestLoginFlow || {}), cookie: response.cookie || cookie, loginResponse: response.body, updatedAt: Date.now(), }; } ctx.status = 200; ctx.body = { success: false, message: response.body?.msg || response.body?.message || 'CNIPA 登录失败', code: 'LOGIN_FAILED', data: response.body, nextCaptcha, cookie: latestLoginFlow?.cookie || response.cookie || cookie, }; return; } latestLoginFlow = { ...(latestLoginFlow || {}), cookie: response.cookie, loginResponse: response.body, updatedAt: Date.now(), }; const redirectURL = response.body?.data?.callback?.redirectURL || response.body?.data?.redirectURL || ''; const baseCookie = mergeCookieHeaders(latestLoginFlow.cookie, response.cookie); logCnipaDebug(ctx, 'login success, start HTTP bridge', { redirectURL, responseCode: response.body?.code, responseRepCode: response.body?.repCode, ...getCookieDiagnostics(baseCookie), }); const bridgeHttpResult = await bridgeCnipaPortalSession( baseCookie, redirectURL ); logCnipaDebug(ctx, 'HTTP bridge finished', { diagnostics: bridgeHttpResult?.diagnostics || [], ...getCookieDiagnostics(bridgeHttpResult?.cookie), }); let bridgeResult = bridgeHttpResult; if (!hasCnipaReadyCookie(bridgeHttpResult?.cookie)) { logCnipaDebug(ctx, 'HTTP bridge missing enable flag, start browser bridge', { redirectURL, ...getCookieDiagnostics(mergeCookieHeaders(baseCookie, bridgeHttpResult?.cookie)), }); const bridgeBrowserResult = await bridgeCnipaPortalSessionByBrowser( mergeCookieHeaders(baseCookie, bridgeHttpResult?.cookie), redirectURL, cnipaConfig ).catch(error => ({ cookie: mergeCookieHeaders(baseCookie, bridgeHttpResult?.cookie), diagnostics: [{ step: 'browser-bridge', error: error.message }], })); bridgeResult = { cookie: mergeCookieHeaders(bridgeHttpResult?.cookie, bridgeBrowserResult?.cookie), diagnostics: [ ...(bridgeHttpResult?.diagnostics || []), ...(bridgeBrowserResult?.diagnostics || []), ], }; logCnipaDebug(ctx, 'browser bridge finished', { diagnostics: bridgeBrowserResult?.diagnostics || [], ...getCookieDiagnostics(bridgeBrowserResult?.cookie), }); } latestLoginFlow.cookie = mergeCookieHeaders(latestLoginFlow.cookie, bridgeResult.cookie); latestQueryAuth = { ...(latestQueryAuth || {}), cookie: mergeCookieHeaders(latestQueryAuth?.cookie, response.cookie, latestLoginFlow?.cookie, bridgeResult.cookie), browserReady: true, updatedAt: Date.now(), }; logCnipaDebug(ctx, 'latest query auth updated', { ...getCookieDiagnostics(latestQueryAuth.cookie), diagnostics: bridgeResult?.diagnostics || [], }); ctx.status = 200; ctx.body = { success: true, data: response.body, cookie: response.cookie, syncResult: { loggedIn: true, bridgeDiagnostics: bridgeResult.diagnostics, }, }; } catch (error) { ctx.logger.log(error); ctx.status = 400; ctx.body = { success: false, message: error.message || 'CNIPA 登录失败', detail: error.response?.body || error.response?.text || null, }; } }; module.exports.queryGrantNoticeByBrowser = async (ctx, number, options = {}) => { if (!latestQueryAuth) { latestQueryAuth = { browserReady: true, updatedAt: Date.now(), }; } const cookieHeader = mergeCookieHeaders(latestQueryAuth.cookie, latestLoginFlow?.cookie); const referer = latestQueryAuth.cpqueryReferer || latestQueryAuth.redirectURL; logCnipaDebug(ctx, 'grant notice query start', { number, referer, ...getCookieDiagnostics(cookieHeader), }); let browserResult; try { browserResult = await queryGrantNoticeByBrowser({ number, cookieHeader, referer, cnipaConfig: readCnipaConfig(ctx), }); } catch (error) { logCnipaDebug(ctx, 'grant notice query failed', { number, message: error.message, status: error?.status || error?.response?.status || null, diagnostics: error?.response?.body?.diagnostics || error?.response?.diagnostics || null, detailUrl: error?.response?.body?.detailUrl || error?.response?.detailUrl || null, ...getCookieDiagnostics(cookieHeader), }); if (isCnipaAuthExpiredError(error)) { invalidateCnipaAuthState(); logCnipaDebug(ctx, 'shared auth invalidated after query failure', { number, message: error.message, status: error?.status || error?.response?.status || null, }); } throw error; } logCnipaDebug(ctx, 'grant notice query browser result', { number, detailUrl: browserResult.detailUrl, responseStatus: browserResult.response?.status, responseOk: browserResult.response?.ok, diagnostics: browserResult.diagnostics, ...getCookieDiagnostics(browserResult.cookie), }); if (browserResult.cookie) latestQueryAuth.cookie = mergeCookieHeaders(latestQueryAuth.cookie, browserResult.cookie); if (browserResult.detailUrl) latestQueryAuth.cpqueryReferer = browserResult.detailUrl; logCnipaDebug(ctx, 'grant notice auth refreshed', { number, cpqueryReferer: latestQueryAuth.cpqueryReferer, ...getCookieDiagnostics(latestQueryAuth.cookie), }); const fetchResponse = browserResult.response; if (!fetchResponse?.ok) { const error = new Error(`CNIPA 浏览器页面查询失败: HTTP ${fetchResponse?.status || ''} ${fetchResponse?.statusText || ''}`.trim()); error.status = fetchResponse?.status; error.response = { status: fetchResponse?.status, body: fetchResponse?.body, text: fetchResponse?.text, detailUrl: browserResult.detailUrl, diagnostics: browserResult.diagnostics, }; throw error; } if (fetchResponse.body?.code !== undefined && Number(fetchResponse.body.code) !== 200) { const error = new Error(fetchResponse.body?.msg || fetchResponse.body?.message || 'CNIPA 浏览器页面查询返回失败'); error.response = { body: fetchResponse.body }; throw error; } return fetchResponse.body; }; const buildAuthDiagnostics = () => { const cookie = mergeCookieHeaders(latestQueryAuth?.cookie, latestLoginFlow?.cookie); return { hasSession: /SESSION=/.test(cookie), hasEnableFlag: hasCnipaReadyCookie(cookie), hasLegacyEnableFlag: /enable_dX1xbeyMT58W=/.test(cookie), hasWafCookie: /(?:^|;\s*)dX1xbeyMT58W[OP]=/.test(cookie), cookieLength: String(cookie || '').length, cookieNames: getCookieNames(cookie), updatedAt: latestQueryAuth?.updatedAt || latestLoginFlow?.updatedAt || 0, }; }; const withAuthState = (handler) => async (...args) => withCnipaSerializedOp(async () => { const ctx = args[0]; if (ctx) await loadCnipaAuthState(ctx); try { return await handler(...args); } finally { if (ctx) await saveCnipaAuthState(ctx).catch(() => {}); } }); module.exports.prepareBrowser = withAuthState(module.exports.prepareBrowser); module.exports.prepareBrowserInternal = withAuthState(module.exports.prepareBrowserInternal); module.exports.getCaptcha = withAuthState(module.exports.getCaptcha); module.exports.checkCaptcha = withAuthState(module.exports.checkCaptcha); module.exports.submitLogin = withAuthState(module.exports.submitLogin); module.exports.queryGrantNoticeByBrowser = withAuthState(module.exports.queryGrantNoticeByBrowser); module.exports.__debugCookieHeader = () => mergeCookieHeaders( latestQueryAuth?.cookie, latestLoginFlow?.cookie ); module.exports.__getAuthDiagnostics = buildAuthDiagnostics; module.exports.getAuthDiagnostics = async (ctx) => withCnipaSerializedOp(async () => { await loadCnipaAuthState(ctx); return buildAuthDiagnostics(); }); module.exports.getDebugCookieHeader = async (ctx) => withCnipaSerializedOp(async () => { await loadCnipaAuthState(ctx); return mergeCookieHeaders(latestQueryAuth?.cookie, latestLoginFlow?.cookie); }); module.exports.__hasCnipaReadyCookie = hasCnipaReadyCookie;