You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
2086 lines
81 KiB
2086 lines
81 KiB
'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;
|
|
|