From 5171718e5720bee16f3eb671a3ffdd1e92167842 Mon Sep 17 00:00:00 2001 From: aliyun1121003339 Date: Sat, 17 Oct 2026 15:00:34 +0800 Subject: [PATCH 1/7] refactor(core): share detection and rule preparation entry points Move the existing page detector, rule normalizer and precompiler without introducing a second matching engine. Keep extension invocation semantics and add cooperative timing checks for bounded SDK use. Signed-off-by: aliyun1121003339 --- src/background/rule-loader.ts | 95 +- src/core/page-detector.ts | 1669 +++++++++++++++++++++++++++++++++ src/core/rule-normalizer.ts | 93 ++ src/core/rule-precompiler.ts | 52 + src/injected/page-detector.ts | 1668 +------------------------------- tests/sdk-core.test.mjs | 49 + vite.config.ts | 53 +- 7 files changed, 1866 insertions(+), 1813 deletions(-) create mode 100644 src/core/page-detector.ts create mode 100644 src/core/rule-normalizer.ts create mode 100644 src/core/rule-precompiler.ts create mode 100644 tests/sdk-core.test.mjs diff --git a/src/background/rule-loader.ts b/src/background/rule-loader.ts index 2b17d98..112ed7c 100644 --- a/src/background/rule-loader.ts +++ b/src/background/rule-loader.ts @@ -1,100 +1,7 @@ import type { RuleConfig } from '@/types/rules' +import { mergeRulePartial } from '@/core/rule-normalizer' const RULE_INDEX_PATH = 'rules/index.json' -const EMPTY_DEFAULTS = Object.freeze({}) - -// 规则 JSON 都是 JSON.parse 出来的普通对象,按原型判断比 Object.prototype.toString 快得多 -const isPlainObject = (value: unknown): value is Record => { - if (value === null || typeof value !== 'object') return false - const proto = Object.getPrototypeOf(value) - return proto === Object.prototype || proto === null -} - -const isRuleGroup = (value: any): boolean => isPlainObject(value) && Array.isArray(value.rules) - -// patterns / resourceHints / globals 这类纯字符串数组占了规则的大头,原样复用,不再逐项复制 -const hasNestedObject = (items: any[]): boolean => { - for (let index = 0; index < items.length; index++) { - const item = items[index] - if (item !== null && typeof item === 'object') return true - } - return false -} - -const normalizeRuleObject = (object: any) => { - const result: any = {} - for (const key in object) { - result[key] = normalizeRuleValue(object[key]) - } - return result -} - -const expandRuleGroupInto = (out: any[], group: any, inheritedDefaults: any) => { - const defaults = { - ...inheritedDefaults, - ...(isPlainObject(group.defaults) ? group.defaults : {}), - ...(isPlainObject(group.$defaults) ? group.$defaults : {}) - } - for (const rule of group.rules) { - pushRuleArrayItem(out, rule, defaults) - } -} - -const pushRuleArrayItem = (out: any[], item: any, defaults: any) => { - if (isRuleGroup(item)) { - expandRuleGroupInto(out, item, defaults) - return - } - if (!isPlainObject(item)) { - out.push(item) - return - } - out.push({ ...defaults, ...normalizeRuleObject(item) }) -} - -const normalizeRuleValue = (value: any): any => { - if (Array.isArray(value)) { - if (!hasNestedObject(value)) return value - const out: any[] = [] - for (const item of value) { - pushRuleArrayItem(out, item, EMPTY_DEFAULTS) - } - return out - } - if (isRuleGroup(value)) { - const out: any[] = [] - expandRuleGroupInto(out, value, EMPTY_DEFAULTS) - return out - } - if (isPlainObject(value)) { - const result: any = {} - for (const key in value) { - result[key] = normalizeRuleValue(value[key]) - } - return result - } - return value -} - -const mergeRulePartial = (target: any, source: any) => { - const normalized = normalizeRuleValue(source) || {} - for (const key in normalized) { - const value = normalized[key] - if (Array.isArray(value)) { - const dst = Array.isArray(target[key]) ? target[key] : (target[key] = []) - for (let i = 0; i < value.length; i++) dst.push(value[i]) - continue - } - if (value && typeof value === 'object') { - const base = target[key] && typeof target[key] === 'object' && !Array.isArray(target[key]) ? target[key] : {} - target[key] = mergeRulePartial(base, value) - continue - } - target[key] = value - } - return target -} - const fetchRuleJson = async (relativePath: string): Promise => { const response = await fetch(chrome.runtime.getURL(relativePath)) if (!response.ok) { diff --git a/src/core/page-detector.ts b/src/core/page-detector.ts new file mode 100644 index 0000000..b7b3b1e --- /dev/null +++ b/src/core/page-detector.ts @@ -0,0 +1,1669 @@ +// @ts-nocheck +/* eslint-disable */ +import { createHintScanner, extractRuleHints } from '@/utils/rule-hints' + +const yieldToMainThread = () => + typeof globalThis.scheduler?.yield === 'function' ? globalThis.scheduler.yield() : new Promise(resolve => setTimeout(resolve, 0)) + +// 单个同步任务的时间预算:规则匹配超过预算就让出主线程,避免形成长任务 +const SLICE_BUDGET_MS = 30 +const RESOURCE_HINT_PARTS = ['resources'] + +export const detectPageTechnologies = async (ruleConfig: Record = {}, budgetMs = Infinity) => { + const deadline = performance.now() + budgetMs + const technologies = [] + const ruleRegexCache = new WeakMap() + const ruleCombinedCache = new WeakMap() + const ruleHintCache = new WeakMap() + const lineProbeCache = new WeakMap() + const resources = collectResources() + const classTokens = collectClassTokens() + const cssVariables = collectCssVariables() + const documentHtmlSample = getHtmlSample() + const globalKeys = safeGlobalKeys() + const add = createCollector(technologies) + let cachedBodyText = null + let classTokenSource = null + let classTokenNames = null + let sliceStartedAt = performance.now() + const hintIndex = createHintIndex(ruleConfig, { + resources: resources.text, + html: documentHtmlSample, + globals: globalKeys.join('\n').toLowerCase(), + cssVars: cssVariables.text, + href: location.href.toLowerCase(), + title: String(document.title || '').toLowerCase(), + meta: [ + getMetaContent('apple-mobile-web-app-title'), + getMetaContent('application-name'), + 'generator: ' + (getMetaContent('generator') || '').toLowerCase() + ] + .join('\n') + .toLowerCase(), + body: () => readBodyText(120000).toLowerCase() + }) + const phpRuntimeTechnologyNames = new Set( + [ + 'WordPress', + 'ThinkPHP', + 'Discuz!', + 'phpBB', + 'Drupal', + 'Joomla', + 'Typecho', + 'Z-BlogPHP', + 'Emlog', + 'Magento / Adobe Commerce', + 'OpenCart', + 'PrestaShop', + 'DedeCMS', + 'EmpireCMS', + 'PHPCMS', + 'PHPWind', + 'BBSXP', + 'HDWiki', + 'MediaWiki', + 'Laravel', + 'Laravel Livewire', + 'Symfony', + 'Yii', + 'CodeIgniter', + 'CakePHP', + 'Laminas / Zend Framework', + 'Zend Framework', + 'Swoole', + 'OpenSwoole', + 'FrankenPHP' + ].map(normalizeRuleName) + ) + + await yieldSlice() + await detectFrontendFrameworks(add, resources, classTokens, documentHtmlSample, globalKeys, ruleConfig.frontendFrameworks || []) + await detectUiFrameworks(add, resources, classTokens, cssVariables, documentHtmlSample, ruleConfig.uiFrameworks || []) + await detectAdditionalFrontendTechnologies(add, resources, classTokens, documentHtmlSample, ruleConfig.frontendExtra || []) + detectMinifiedScriptFallback(add, resources, technologies) + + await yieldSlice() + await detectBuildAndRuntime(add, resources, documentHtmlSample, globalKeys, ruleConfig.buildRuntime || []) + await detectCdnAndHosting(add, resources, ruleConfig.cdnProviders || []) + await detectBackendFrameworkHints(add, resources, documentHtmlSample, ruleConfig.backendHints || []) + await detectCmsAndCommerce(add, resources, documentHtmlSample, ruleConfig.websitePrograms || []) + + await yieldSlice() + await detectWebsitePrograms(add, resources, documentHtmlSample, globalKeys, ruleConfig.websitePrograms || []) + await detectCmsThemesAndSource( + add, + resources, + classTokens, + documentHtmlSample, + globalKeys, + ruleConfig.cmsThemes || [], + ruleConfig.dynamicAssetExtractors || [] + ) + await detectProbeTools(add, resources, documentHtmlSample, globalKeys, ruleConfig.probes || []) + await detectProgrammingLanguages(add, resources, documentHtmlSample, globalKeys, ruleConfig.languages || []) + + await yieldSlice() + inferLanguagesFromDetectedTechnologies(add, technologies) + await detectFeeds(add, resources, documentHtmlSample, ruleConfig.feeds || []) + await detectSaasServices(add, resources, documentHtmlSample, globalKeys, ruleConfig.saasServices || []) + await detectThirdPartyLogins(add, resources, documentHtmlSample, globalKeys, ruleConfig.thirdPartyLogins || []) + + await yieldSlice() + await detectPaymentSystems(add, resources, documentHtmlSample, globalKeys, ruleConfig.paymentSystems || []) + await detectAnalytics(add, resources, documentHtmlSample, globalKeys, ruleConfig.analyticsProviders || []) + await detectCustomRules(add, resources, documentHtmlSample, globalKeys, ruleConfig.customRules || []) + detectSecurityAndProtocol(add) + + return { + url: location.href, + title: document.title, + generatedAt: new Date().toISOString(), + technologies: suppressDuplicateWebsiteProgramCategories( + suppressFrontendAliasTechnologies(suppressFrontendFallbackDuplicates(technologies)) + ), + resources: { + total: resources.all.length, + scripts: resources.scripts.slice(0, 120), + stylesheets: resources.stylesheets.slice(0, 120), + resourceTiming: resources.resourceTiming.slice(0, 220), + all: resources.all.slice(0, 300), + themeAssetUrls: resources.all.filter(url => /\/wp-content\/themes\//i.test(url)).slice(0, 80), + resourceDomains: summarizeDomains(resources.all), + cssVariableCount: cssVariables.names.length, + metaGenerator: getMetaContent('generator'), + manifest: document.querySelector("link[rel='manifest']")?.href || null + } + } + + function collectResources() { + const scripts = [...document.scripts].map(script => script.src).filter(isInspectableResourceUrl) + const stylesheets = [...document.querySelectorAll("link[rel~='stylesheet'], link[as='style']")] + .map(link => link.href) + .filter(isInspectableResourceUrl) + const resourceTiming = performance + .getEntriesByType('resource') + .map(entry => entry.name) + .filter(isInspectableResourceUrl) + const images = [...document.images] + .map(image => image.currentSrc || image.src) + .filter(isInspectableResourceUrl) + .slice(0, 200) + const all = unique([...scripts, ...stylesheets, ...resourceTiming, ...images]) + return { scripts, stylesheets, resourceTiming, images, all, text: all.join('\n').toLowerCase() } + } + + function collectClassTokens() { + const counts = {} + const nodes = document.querySelectorAll('[class]') + const limit = Math.min(nodes.length, 1000) + for (let i = 0; i < limit; i++) { + const list = nodes[i].classList + if (list && list.length) { + for (let j = 0; j < list.length; j++) { + const token = list[j] + if (!token) continue + counts[token] = (counts[token] || 0) + 1 + } + continue + } + const raw = typeof nodes[i].className === 'string' ? nodes[i].className : nodes[i].getAttribute('class') || '' + if (!raw) continue + for (const token of raw.split(/\s+/)) { + if (!token) continue + counts[token] = (counts[token] || 0) + 1 + } + } + return counts + } + + function collectCssVariables() { + const names = new Set() + const values = {} + const targets = [document.documentElement, document.body].filter(Boolean) + + for (const target of targets) { + try { + const style = getComputedStyle(target) + for (let index = 0; index < style.length; index += 1) { + const name = style.item(index) + if (!name || !name.startsWith('--')) { + continue + } + names.add(name) + if (!values[name]) { + values[name] = style.getPropertyValue(name).trim().slice(0, 160) + } + } + } catch { + continue + } + } + + const orderedNames = [...names].slice(0, 500) + return { + names: orderedNames, + values, + text: orderedNames + .map(name => `${name}: ${values[name] || ''}`) + .join('\n') + .toLowerCase() + } + } + + function getHtmlSample() { + const html = document.documentElement?.outerHTML || '' + return stripInlineDataUrls(html).slice(0, 500000).toLowerCase() + } + + function isInspectableResourceUrl(value) { + const url = String(value || '').trim() + return Boolean(url) && !/^(?:data|blob|javascript|about):/i.test(url) + } + + function stripInlineDataUrls(value) { + return String(value || '').replace(/data:[^"'()<>\s]+/gi, '[inline-data-url]') + } + + function safeGlobalKeys() { + try { + return Object.keys(window).slice(0, 5000) + } catch { + return [] + } + } + + async function detectFrontendFrameworks(add, resources, classes, html, globalKeys, externalRules) { + if (hasReactDomMarker()) { + add('前端框架', 'React', '高', 'DOM 节点存在 React Fiber 标记') + } + + await detectJsonRuleList(add, externalRules, { + defaultCategory: '前端框架', + resources, + classes, + html, + text: `${resources.text}\n${html}\n${globalKeys.join('\n')}`, + resourceConfidence: '中', + hintParts: ['href', 'resources', 'html', 'globals'], + sourceLabel: 'JSON 前端框架规则' + }) + } + + async function detectUiFrameworks(add, resources, classes, cssVariables, html, externalRules) { + const atomicCssOrigin = detectAtomicCssOrigin(cssVariables) + if (atomicCssOrigin === 'unocss') { + add('UI / CSS 框架', 'UnoCSS', '高', '存在 --un-* CSS 变量(UnoCSS 默认前缀)') + } else if (atomicCssOrigin === 'tailwind') { + add('UI / CSS 框架', 'Tailwind CSS', '高', '存在 --tw-* CSS 变量(Tailwind 默认前缀)') + } else if (scoreTailwind(classes) >= 10) { + add('UI / CSS 框架', 'Tailwind CSS', '中', '存在大量 Tailwind 风格原子类名') + } + + await detectJsonRuleList(add, externalRules, { + defaultCategory: 'UI / CSS 框架', + resources, + classes, + cssVariables, + html, + text: `${resources.text}\n${html}\n${cssVariables.text}`, + resourceConfidence: '中', + hintParts: ['href', 'resources', 'html', 'cssVars'], + sourceLabel: 'JSON UI 框架规则' + }) + } + + function detectAtomicCssOrigin(cssVariables) { + const names = cssVariables?.names || [] + let hasUn = false + let hasTw = false + for (const name of names) { + if (!hasUn && name.startsWith('--un-')) hasUn = true + if (!hasTw && name.startsWith('--tw-')) hasTw = true + if (hasUn && hasTw) break + } + if (hasUn) return 'unocss' + if (hasTw) return 'tailwind' + + try { + for (const sheet of document.styleSheets) { + let rules + try { + rules = sheet.cssRules + } catch { + continue + } + if (!rules) continue + const limit = rules.length < 400 ? rules.length : 400 + for (let i = 0; i < limit; i++) { + const text = rules[i]?.cssText || '' + if (!hasUn && text.includes('--un-')) hasUn = true + if (!hasTw && text.includes('--tw-')) hasTw = true + if (hasUn || hasTw) break + } + if (hasUn || hasTw) break + } + } catch { + // ignore + } + + if (hasUn) return 'unocss' + if (hasTw) return 'tailwind' + return '' + } + + async function detectAdditionalFrontendTechnologies(add, resources, classes, html, externalRules) { + const text = `${resources.text}\n${html}` + await detectJsonRuleList(add, externalRules, { + defaultCategory: '前端库', + resources, + classes, + html, + text, + resourceConfidence: '中', + hintParts: ['href', 'resources', 'html'], + sourceLabel: 'JSON 前端补充规则' + }) + } + + function detectMinifiedScriptFallback(add, resources, currentTechnologies) { + const knownNames = new Set(currentTechnologies.map(tech => normalizeFallbackTechName(tech.name))) + const seen = new Set() + const scriptUrls = unique([...(resources.scripts || []), ...(resources.resourceTiming || [])]) + for (const rawUrl of scriptUrls) { + const info = extractMinifiedScriptLibrary(rawUrl) + if (!info) { + continue + } + const normalized = normalizeFallbackTechName(info.name) + if (!normalized || seen.has(normalized) || knownNames.has(normalized)) { + continue + } + seen.add(normalized) + add('前端库', `疑似前端库: ${info.name}`, '低', `兜底识别:根据脚本文件名 ${info.fileName} 判断,未匹配到内置规则或官网链接`) + if (seen.size >= 20) { + break + } + } + } + + function extractMinifiedScriptLibrary(rawUrl) { + let pathname = '' + try { + pathname = new URL(rawUrl, location.href).pathname + } catch { + pathname = String(rawUrl || '').split(/[?#]/)[0] + } + if (/\/wp-includes\/js\/dist\//i.test(pathname)) { + return null + } + const fileName = safeDecodeURIComponent(pathname.split('/').filter(Boolean).pop() || '') + if (!/\.js$/i.test(fileName) || !/(?:^|[.-])min\.js$/i.test(fileName)) { + return null + } + + let name = fileName + .replace(/\.js$/i, '') + .replace( + /(?:[._-](?:min|prod|production|development|dev|bundle|bundled|umd|esm|cjs|iife|global|runtime|legacy|modern|browser|web|all|full))+$/gi, + '' + ) + .replace(/(?:[._-]pkgd)$/i, '') + .replace(/(?:[._-]v?\d+(?:\.\d+){1,4})$/i, '') + .replace(/(?:[._-][a-f0-9]{7,})$/i, '') + .replace(/^npm\./i, '') + .replace(/^@/, '') + .trim() + + if (!isLikelyLibraryFileName(name)) { + return null + } + return { name, fileName } + } + + function isLikelyLibraryFileName(name) { + if (!name || name.length < 2 || name.length > 60) { + return false + } + if (!/[a-z]/i.test(name)) { + return false + } + if (/^[a-f0-9]{8,}$/i.test(name) || /^[a-z0-9_-]{18,}$/i.test(name)) { + return false + } + const genericNames = new Set([ + 'app', + 'application', + 'message', + 'main', + 'index', + 'home', + 'base', + 'core', + 'common', + 'commons', + 'global', + 'runtime', + 'manifest', + 'vendor', + 'vendors', + 'chunk', + 'chunks', + 'bundle', + 'bundles', + 'min', + 'prod', + 'production', + 'development', + 'dev', + 'dist', + 'all', + 'full', + 'browser', + 'web', + 'modern', + 'legacy', + 'umd', + 'esm', + 'cjs', + 'iife', + 'module', + 'modules', + 'plugin', + 'plugins', + 'lib', + 'libs', + 'cdn', + 'scripts', + 'script', + 'custom', + 'theme', + 'frontend', + 'backend', + 'admin', + 'site', + 'page', + 'public', + 'static', + 'lazyload', + 'polyfill', + 'polyfills', + 'webpack', + 'vite', + 'parcel', + 'rollup', + 'esbuild', + 'swc', + 'turbopack', + 'rspack', + 'require', + 'requirejs', + 'system', + 'systemjs', + // 文档站 / 内容站常见的搜索 worker 文件名(mkdocs / docusaurus / vitepress 等都叫这名), + // 真实的搜索库(Lunr / FlexSearch / Pagefind / Algolia)会通过专用规则或官方版权注释命中 + 'search', + // 通用名,几乎所有站点都有但不属于公共库 + 'sdk', + 'analytics', + 'tracker', + 'tracking', + 'beacon', + 'pixel', + // 站点自身的内部脚本,不是公共库 + 'tgwallpaper', + 'jsbin' + ]) + if (genericNames.has(name.toLowerCase())) { + return false + } + // vscode.dev / 微软系站点的内部脚本:ms.core / ms.post / ms.deploy 等 + if (/^ms\.[a-z0-9_-]+$/i.test(name)) { + return false + } + // Microsoft AB-test 客户端、Read the Docs 广告脚本、通用 svg loader 等不是公共库 + if (/^(?:tas-client|ethicalads|svg-loader)$/i.test(name)) { + return false + } + return true + } + + function normalizeFallbackTechName(name) { + const normalized = String(name || '') + .toLowerCase() + .replace(/^疑似前端库:\s*/, '') + .replace(/(?:\.js|js)$/i, '') + .replace(/(?:[._-]pkgd)$/i, '') + .replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '') + const aliases = { + clipboardjs: 'clipboard', + jquerycompat: 'jquery', + imagesloadedjs: 'imagesloaded', + layerjs: 'layer', + slickcarousel: 'slick', + twitterbootstrap: 'bootstrap', + vuejs: 'vue' + } + return aliases[normalized] || normalized + } + + function suppressFrontendAliasTechnologies(items) { + if (!Array.isArray(items) || !items.length) { + return [] + } + const aliases = { + angular: { category: '前端框架', name: 'Angular' }, + jquery: { category: '前端框架', name: 'jQuery' }, + jquerycompat: { category: '前端框架', name: 'jQuery' }, + layer: { category: '前端库', name: 'Layer.js' }, + preact: { category: '前端框架', name: 'Preact' }, + react: { category: '前端框架', name: 'React' }, + svelte: { category: '前端框架', name: 'Svelte' }, + twitterbootstrap: { category: 'UI / CSS 框架', name: 'Bootstrap' }, + vue: { category: '前端框架', name: 'Vue' } + } + const frontendCategories = new Set(['前端库', '前端框架', 'UI / CSS 框架']) + return items.map(item => { + if (!frontendCategories.has(item?.category)) { + return item + } + const key = String(item?.name || '') + .toLowerCase() + .replace(/^疑似前端库:\s*/, '') + .replace(/(?:\.js|js)$/i, '') + .replace(/(?:[._-]pkgd)$/i, '') + .replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '') + const canonical = aliases[key] + return canonical ? { ...item, category: canonical.category, name: canonical.name } : item + }) + } + + function suppressFrontendFallbackDuplicates(items) { + if (!Array.isArray(items) || !items.length) { + return [] + } + + // 任何已识别的技术都用来消重,避免 SaaS / 统计 / 第三方登录 / 支付 类目里已经命中的库 + // 同名脚本(如 filestack.min.js 已识别为 Filestack SaaS)还被兜底再加一条「疑似前端库」 + const knownNames = new Set( + items + .filter(item => !isFrontendFallback(item)) + .map(item => normalizeFallbackTechName(item.name)) + .filter(Boolean) + ) + if (!knownNames.size) { + return items + } + + return items.filter(item => !isFrontendFallback(item) || !knownNames.has(normalizeFallbackTechName(item.name))) + } + + function isFrontendFallback(item) { + return item?.category === '前端库' && /^疑似前端库:/i.test(String(item?.name || '').trim()) + } + + async function detectBuildAndRuntime(add, resources, html, globalKeys, externalRules) { + if (navigator.serviceWorker?.controller) { + add('构建与运行时', 'Service Worker', '中', '当前页面受 Service Worker 控制') + } + + await detectJsonRuleList(add, externalRules, { + defaultCategory: '构建与运行时', + resources, + html, + text: `${resources.text}\n${html}\n${globalKeys.join('\n')}`, + hintParts: ['href', 'resources', 'html', 'globals'], + sourceLabel: 'JSON 构建运行时规则' + }) + } + + async function detectCdnAndHosting(add, resources, externalRules) { + await detectJsonRuleList(add, externalRules, { + defaultCategory: 'CDN / 托管', + resources, + text: resources.text, + resourceOnly: true, + hintParts: ['href', 'resources'], + sourceLabel: 'JSON CDN 规则' + }) + + const privateCdnMatches = collectPrivateCdnMatches(resources.all) + if (privateCdnMatches.length) { + add( + 'CDN / 托管', + '自定义 / 私有 CDN', + '低', + privateCdnMatches.length + ' 个资源域名疑似私有 CDN,如 ' + privateCdnMatches.slice(0, 3).join('、') + ) + } + } + + function collectPrivateCdnMatches(urls) { + const pageHost = location.hostname.replace(/^www\./, '') + const hosts = new Set() + for (const raw of urls) { + try { + const host = new URL(raw, location.href).hostname.toLowerCase() + const normalizedHost = host.replace(/^www\./, '') + if (normalizedHost === pageHost) { + continue + } + if (isKnownThirdPartyServiceHost(normalizedHost)) { + continue + } + if ( + /(^cdn\d*\.|\.cdn\d*\.|-cdn\d*\.|^static\d*\.|\.static\d*\.|^assets\d*\.|\.assets\d*\.|^edge\d*\.|\.edge\d*\.|^media\d*\.)/.test( + host + ) + ) { + hosts.add(host) + } + } catch { + continue + } + } + return [...hosts].slice(0, 20) + } + + function isKnownThirdPartyServiceHost(host) { + return /^(?:static\.cloudflareinsights\.com|challenges\.cloudflare\.com)$/i.test(host) + } + + async function detectBackendFrameworkHints(add, resources, html, externalRules) { + const text = [location.href, resources.text, html].join('\n') + await detectJsonRuleList(add, externalRules, { + defaultCategory: '后端 / 服务器框架', + resources, + html, + text, + hintParts: ['href', 'resources', 'html'], + sourceLabel: 'JSON 后端规则' + }) + } + + async function detectCmsAndCommerce(add, resources, html, externalRules) { + const generator = (getMetaContent('generator') || '').toLowerCase() + const text = [resources.text, html, 'generator: ' + generator].join('\n') + await detectJsonRuleList(add, filterCmsAndCommerceRules(externalRules), { + defaultCategory: 'CMS / 电商平台', + resources, + html, + text, + // matchIn 含 url 的规则只靠页面地址命中(如 *.hashnode.dev),预筛也要看地址 + hintParts: ['href', 'resources', 'html', 'meta'], + sourceLabel: 'JSON CMS / 电商平台规则' + }) + } + + function filterCmsAndCommerceRules(rules) { + if (!Array.isArray(rules)) { + return [] + } + return rules.filter(rule => normalizeRuleName(rule.name) !== 'wordpress') + } + + function normalizeRuleName(name) { + return String(name || '') + .toLowerCase() + .replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '') + } + + function hasTechnology(items, category, name) { + const normalizedName = normalizeRuleName(name) + return items.some(item => item?.category === category && normalizeRuleName(item.name) === normalizedName) + } + + function isPhpRuntimeSourceTechnology(item) { + return phpRuntimeTechnologyNames.has(normalizeRuleName(item?.name)) + } + + function phpRuntimeInferenceEvidence(item) { + const name = String(item?.name || '').trim() || 'PHP 系技术' + if (item?.category === '后端 / 服务器框架') { + return `由 ${name} 后端框架推断 PHP 后端运行时` + } + if (item?.category === '网站程序' || item?.category === 'CMS / 电商平台') { + return `由 ${name} 站点程序推断 PHP 后端运行时` + } + return `由 ${name} 技术线索推断 PHP 后端运行时` + } + + function inferLanguagesFromDetectedTechnologies(add, items) { + if (hasTechnology(items, '开发语言 / 运行时', 'PHP')) { + return + } + const source = items.find(isPhpRuntimeSourceTechnology) + if (source) { + add('开发语言 / 运行时', 'PHP', '中', phpRuntimeInferenceEvidence(source)) + } + } + + function suppressDuplicateWebsiteProgramCategories(items) { + if (!Array.isArray(items) || !items.length) { + return [] + } + + const websiteProgramNames = new Set( + items + .filter(item => item?.category === '网站程序') + .map(item => normalizeRuleName(item.name)) + .filter(Boolean) + ) + if (!websiteProgramNames.size) { + return items + } + + return items.filter(item => item?.category !== 'CMS / 电商平台' || !websiteProgramNames.has(normalizeRuleName(item.name))) + } + + async function detectCmsThemesAndSource(add, resources, classes, html, globalKeys, externalRules, assetExtractors = []) { + const assetText = `${location.href} +${resources.all.join('\n')}` + const text = `${assetText} +${html}` + const normalizedText = text.toLowerCase() + const normalizedAssetText = assetText.toLowerCase() + + for (const extractor of assetExtractors) { + collectAssetDirectoryMatches(add, assetText, normalizedAssetText, extractor) + } + + try { + const shopifyTheme = window.Shopify?.theme + if (shopifyTheme?.name) { + add( + '主题 / 模板', + `Shopify 主题: ${String(shopifyTheme.name).slice(0, 80)}`, + '高', + `存在 window.Shopify.theme${shopifyTheme.id ? `,theme id: ${shopifyTheme.id}` : ''}` + ) + } else if (shopifyTheme?.id) { + add('主题 / 模板', `Shopify 主题 ID: ${shopifyTheme.id}`, '中', '存在 window.Shopify.theme.id') + } + } catch { + // 忽略跨站脚本或代理对象异常。 + } + + await detectJsonRuleList(add, externalRules, { + defaultCategory: '主题 / 模板', + resources, + classes, + html, + text, + hintParts: ['href', 'resources', 'html'], + sourceLabel: 'JSON 主题模板规则', + evidencePrefix: rule => (rule.kind ? `${rule.kind}:` : '') + }) + } + + function collectAssetDirectoryMatches(add, text, normalizedText, extractor) { + const requires = compileAssetPattern(extractor.requires) + if (requires && !requires.test(normalizedText)) { + return + } + + let count = 0 + const limit = extractor.limit || 12 + const seen = new Set() + const pattern = compileAssetPattern(extractor.pattern, 'gi') + if (!pattern) { + return + } + let match + while ((match = pattern.exec(text)) && count < limit) { + const groups = match.slice(1).map(cleanAssetSlug) + if (groups.some(value => !value)) { + continue + } + const value = extractor.format === 'joinSlash' ? groups.join('/') : groups[0] + const key = `${extractor.category}::${extractor.label}::${value}`.toLowerCase() + if (seen.has(key)) { + continue + } + seen.add(key) + count += 1 + add(extractor.category, `${extractor.label}: ${value}`, '高', `资源或源码路径包含 ${shortPathEvidence(match[0])}`) + } + } + + function compileAssetPattern(pattern, defaultFlags = 'i') { + if (!pattern) { + return null + } + try { + const source = pattern instanceof RegExp ? pattern.source : String(pattern) + return new RegExp(source, defaultFlags) + } catch { + return null + } + } + + function cleanAssetSlug(value) { + const decoded = safeDecodeURIComponent(String(value || '')) + .replace(/\\/g, '/') + .replace(/['")<>]/g, '') + .trim() + if (!decoded || decoded.length > 90 || decoded.includes('/') || /[*{}[\]]/.test(decoded)) { + return '' + } + if (!/[a-z0-9\u4e00-\u9fa5]/i.test(decoded)) { + return '' + } + if (/^(?:assets?|static|public|dist|build|cache|css|js|img|images?|fonts?|vendor)$/i.test(decoded)) { + return '' + } + return decoded + } + + function safeDecodeURIComponent(value) { + try { + return decodeURIComponent(value) + } catch { + return value + } + } + + function shortPathEvidence(value) { + return String(value || '') + .replace(/\s+/g, ' ') + .slice(0, 160) + } + + async function detectSaasServices(add, resources, html, globalKeys, externalRules) { + const text = [resources.text, html].join('\n') + await detectJsonRuleList(add, externalRules, { + defaultCategory: 'SaaS / 第三方服务', + resources, + html, + text, + hintParts: ['href', 'resources', 'html'], + sourceLabel: 'JSON SaaS 规则', + evidencePrefix: rule => (rule.kind ? rule.kind + ':' : '') + }) + } + + async function detectWebsitePrograms(add, resources, html, globalKeys, externalRules) { + await detectJsonRuleList(add, externalRules, { + defaultCategory: '网站程序', + resources, + html, + text: `${resources.text}\n${html}`, + hintParts: ['href', 'resources', 'html'], + sourceLabel: 'JSON 网站程序规则', + evidencePrefix: rule => (rule.kind ? `${rule.kind}:` : '') + }) + } + + async function detectProbeTools(add, resources, html, globalKeys, externalRules) { + const titleText = document.title ? `\n${document.title}` : '' + const appMetadataText = [getMetaContent('apple-mobile-web-app-title'), getMetaContent('application-name')].filter(Boolean).join('\n') + const appMetadata = appMetadataText ? `\n${appMetadataText}` : '' + await detectJsonRuleList(add, externalRules, { + defaultCategory: '探针 / 监控', + resources, + html: '', + text: `${location.href}\n${resources.text}${titleText}${appMetadata}`, + hintParts: ['href', 'resources', 'title', 'meta'], + sourceLabel: 'JSON 探针规则', + evidencePrefix: rule => (rule.kind ? `${rule.kind}:` : '') + }) + } + + async function detectThirdPartyLogins(add, resources, html, globalKeys, externalRules) { + const titleText = document.title ? `\n${document.title}` : '' + const bodyText = readBodyText(100000) ? `\n${readBodyText(100000)}` : '' + await detectJsonRuleList(add, externalRules, { + defaultCategory: '第三方登录 / OAuth', + resources, + html, + text: `${resources.text}\n${html}${titleText}${bodyText}`, + hintParts: ['href', 'resources', 'html', 'title', 'body'], + sourceLabel: 'JSON 第三方登录规则', + evidencePrefix: rule => (rule.kind ? `${rule.kind}:` : '') + }) + } + + async function detectPaymentSystems(add, resources, html, globalKeys, externalRules) { + const bodyText = readBodyText(80000) ? `\n${readBodyText(80000)}` : '' + await detectJsonRuleList(add, externalRules, { + defaultCategory: '支付系统', + resources, + html, + text: `${location.href}\n${resources.text}\n${html}${bodyText}`, + hintParts: ['href', 'resources', 'html', 'body'], + sourceLabel: 'JSON 支付规则', + evidencePrefix: rule => (rule.kind ? `${rule.kind}:` : '') + }) + } + + async function detectCustomRules(add, resources, html, globalKeys, externalRules) { + const bodyText = readBodyText(120000) ? `\n${readBodyText(120000)}` : '' + const text = [location.href, document.title, resources.text, html, bodyText, globalKeys.join('\n')].join('\n') + await detectJsonRuleList(add, externalRules, { + defaultCategory: '其他库', + resources, + html, + text, + hintParts: ['href', 'title', 'resources', 'html', 'body', 'globals'], + sourceLabel: '自定义页面规则', + evidencePrefix: rule => (rule.kind ? `${rule.kind}:` : '') + }) + } + + async function detectProgrammingLanguages(add, resources, html, globalKeys, externalRules) { + await detectJsonRuleList(add, externalRules, { + defaultCategory: '开发语言 / 运行时', + resources, + html, + text: `${resources.text}\n${html}`, + hintParts: ['href', 'resources', 'html'], + sourceLabel: 'JSON 语言规则', + evidencePrefix: rule => (rule.kind ? `${rule.kind}:` : '') + }) + } + + async function detectFeeds(add, resources, html, externalRules) { + const feedLinks = [...document.querySelectorAll("link[rel~='alternate']")] + .map(link => ({ + href: link.href || link.getAttribute('href') || '', + type: (link.type || '').toLowerCase(), + title: link.title || '' + })) + // 只接受真正的 feed 类型,避免 application/json+oembed 这种非 feed 的备用链接被误算成 JSON Feed + .filter( + link => + link.href && + !/oembed/.test(`${link.type} ${link.href}`.toLowerCase()) && + /(?:rss|atom|jsonfeed|feed\+json|json\+feed|application\/feed|\.rss\b|\.atom\b|\/feed(?:\/|\.json|$|\?)|\/rss(?:\/|$|\?)|\/atom(?:\/|$|\?))/.test( + `${link.type} ${link.href}`.toLowerCase() + ) + ) + + for (const link of feedLinks.slice(0, 20)) { + const name = feedNameFromType(link.type, link.href) + add('RSS / 订阅', name, '高', `发现 feed 链接:${shortUrl(link.href)}${link.title ? ` (${link.title})` : ''}`) + } + + await detectJsonRuleList(add, externalRules, { + defaultCategory: 'RSS / 订阅', + resources, + html, + text: `${resources.text}\n${html}`, + hintParts: ['href', 'resources', 'html'], + sourceLabel: 'JSON Feed 规则', + confidence: '中' + }) + } + + function feedNameFromType(type, href) { + const value = `${type} ${href}`.toLowerCase() + if (value.includes('atom')) { + return 'Atom Feed' + } + if (value.includes('json')) { + return 'JSON Feed' + } + return 'RSS Feed' + } + + async function yieldSlice() { + await yieldToMainThread() + sliceStartedAt = performance.now() + if (sliceStartedAt >= deadline) throw new Error('StackPrism detection deadline exceeded') + } + + async function detectJsonRuleList(add, rules, context) { + if (!Array.isArray(rules) || !rules.length) { + return + } + + for (const rule of rules) { + if (performance.now() - sliceStartedAt > SLICE_BUDGET_MS) { + await yieldSlice() + } + const match = matchJsonRule(rule, context) + if (!match) { + continue + } + const confidence = match.confidence || context.confidence || rule.confidence || '中' + const prefix = typeof context.evidencePrefix === 'function' ? context.evidencePrefix(rule) : context.evidencePrefix || '' + const extras: { version?: string; url?: string } = {} + if (match.version) extras.version = match.version + if (rule.url) extras.url = rule.url + add( + rule.category || context.defaultCategory || '其他库', + rule.name, + confidence, + `${prefix}${match.evidence}`, + Object.keys(extras).length ? extras : undefined + ) + } + } + + // 从命中的资源 URL 里抽版本号:覆盖 cdn / npm / 自托管几种常见 URL 形态 + // 例:cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js → 3.6.0 + // unpkg.com/react@18.3.1/umd/react.production.min.js → 18.3.1 + // /assets/swiper-bundle-9.4.1.min.js → 9.4.1 + function extractVersionFromUrl(rule, url) { + if (!url || typeof url !== 'string') return '' + const name = String(rule?.name || '').trim() + if (!name) return '' + const escape = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const npmToken = name + .toLowerCase() + .replace(/\.js$/i, '') + .replace(/\s*\/\s*.*$/, '') + .replace(/\s+/g, '-') + .replace(/[^a-z0-9@_-]/gi, '') + const tokens = [npmToken, name.toLowerCase()].filter(Boolean) + for (const token of tokens) { + const esc = escape(token) + // 形式 1:`@X.Y.Z`(unpkg / jsdelivr) + const m1 = new RegExp('[/@]' + esc + '@(\\d+\\.\\d+(?:\\.\\d+)?)', 'i').exec(url) + if (m1) return m1[1] + // 形式 2:`//X.Y.Z/`(cdnjs 风格) + const m2 = new RegExp('/' + esc + '/(\\d+\\.\\d+(?:\\.\\d+)?)/', 'i').exec(url) + if (m2) return m2[1] + // 形式 3:`/-X.Y.Z.(?:min\\.)?js`(自托管直接命名) + const m3 = new RegExp('/' + esc + '[-._](\\d+\\.\\d+(?:\\.\\d+)?)\\.(?:min\\.)?(?:m?js|css)', 'i').exec(url) + if (m3) return m3[1] + } + return '' + } + + // 比较两个 semver 字符串("3.10.0" > "3.9.0",字典序则相反):用作 sort comparator + function compareSemver(a, b) { + const parse = (s: string) => + String(s || '') + .split('.') + .map(x => parseInt(x, 10) || 0) + const aa = parse(a) + const bb = parse(b) + const len = Math.max(aa.length, bb.length) + for (let i = 0; i < len; i++) { + const av = aa[i] || 0 + const bv = bb[i] || 0 + if (av !== bv) return av - bv + } + return 0 + } + + // 命中 globals 时,从 window. 智能抽版本号: + // 1) value 本身是版本号字符串 + // 2) 对象上有 .version / .VERSION 字符串字段 + // 3) lit-html: window.litHtmlVersions 是数组 ["1.1.2", "2.8.0"],push 进去的版本字符串 + // 4) 数组里的元素是版本号字符串 + // 5) 兜底:扫一层自身属性找符合严格 semver(\d+\.\d+\.\d+)的字符串 + function extractGlobalVersion(globalPath) { + try { + let value: any = window + for (const key of globalPath.split('.')) { + if (value == null) return '' + value = value[key] + } + if (value == null) return '' + if (typeof value === 'string' && /^\d+\.\d+/.test(value)) return value + if (typeof value !== 'object' && typeof value !== 'function') return '' + if (Array.isArray(value)) { + // 数组元素可能是版本号字符串(lit-html: ["2.8.0"]),也可能是带 .version 字段的对象 + // (core-js: __core-js_shared__.versions = [{version: "3.46.0", mode: "global"}, ...]) + const versions = value + .map(item => { + if (typeof item === 'string') return item + if (item && typeof item === 'object' && typeof item.version === 'string') return item.version + return '' + }) + .filter(v => typeof v === 'string' && /^\d+\.\d+/.test(v)) + if (!versions.length) return '' + // 按 semver 数字比较,避免 "3.10.0" 字典序 < "3.9.0" 的坑 + return versions.slice().sort(compareSemver).pop() || '' + } + if (typeof value.version === 'string' && /^\d+\.\d+/.test(value.version)) return value.version + if (typeof value.VERSION === 'string' && /^\d+\.\d+/.test(value.VERSION)) return value.VERSION + // 兜底:遍历自身属性找 semver 字符串(\d+\.\d+\.\d+) + try { + for (const key of Object.keys(value)) { + const v = value[key] + if (typeof v === 'string' && v.length < 20 && /^\d+\.\d+\.\d+$/.test(v)) return v + } + } catch { + // 跨 origin / Proxy 阻止访问属性时静默忽略 + } + return '' + } catch { + return '' + } + } + + function matchJsonRule(rule, context) { + const ruleResourceOnly = rule?.resourceOnly === true + const globalName = !ruleResourceOnly && shouldMatchTarget(rule, 'globals') ? (rule.globals || []).find(name => hasGlobal(name)) : null + if (globalName) { + // 优先看规则上的 versionFrom 显式路径(jQuery.fn.jquery 这种非标准位置), + // 否则用通用启发(.version / .VERSION / 数组元素) + const version = (rule.versionFrom && extractGlobalVersion(rule.versionFrom)) || extractGlobalVersion(globalName) + return { confidence: '高', evidence: `存在 window.${globalName}`, version } + } + + const selector = + !ruleResourceOnly && shouldMatchTarget(rule, 'selectors') + ? (rule.selectors || []).find(selectorText => hasSelector(selectorText)) + : null + if (selector) { + // 规则可声明 versionFromAttribute(例如 styled-components 把版本写在