From 16a08bd07d68bc4aae6c9e59761e850d7f92e448 Mon Sep 17 00:00:00 2001 From: SukkaW Date: Sun, 17 Dec 2023 23:37:35 +0800 Subject: [PATCH] Refactor: speed up reject parsing --- Build/build-internal-cdn-rules.ts | 4 +- Build/build-internal-chn-domains.ts | 4 +- Build/build-internal-reverse-chn-cidr.ts | 4 +- Build/build-speedtest-domainset.ts | 4 +- Build/download-previous-build.ts | 8 +- Build/lib/cached-tld-parse.ts | 2 +- Build/lib/fetch-assets.ts | 61 ++++++ Build/lib/get-gorhill-publicsuffix.ts | 4 +- Build/lib/parse-filter.ts | 237 +++++++++-------------- Build/lib/stable-sort-domain.ts | 2 +- Build/mod.d.ts | 2 +- bun.lockb | Bin 88634 -> 88602 bytes package.json | 2 +- 13 files changed, 175 insertions(+), 159 deletions(-) create mode 100644 Build/lib/fetch-assets.ts diff --git a/Build/build-internal-cdn-rules.ts b/Build/build-internal-cdn-rules.ts index a6c2a408..da64319f 100644 --- a/Build/build-internal-cdn-rules.ts +++ b/Build/build-internal-cdn-rules.ts @@ -58,7 +58,7 @@ export const buildInternalCDNDomains = task(import.meta.path, async () => { } }; - const [gorhill] = await Promise.all([ + const gorhill = (await Promise.all([ getGorhillPublicSuffixPromise(), processLocalRuleSet(path.resolve(import.meta.dir, '../List/non_ip/cdn.conf')), processLocalRuleSet(path.resolve(import.meta.dir, '../List/non_ip/global.conf')), @@ -70,7 +70,7 @@ export const buildInternalCDNDomains = task(import.meta.path, async () => { processLocalDomainSet(path.resolve(import.meta.dir, '../List/domainset/download.conf')), fsp.mkdir(path.resolve(import.meta.dir, '../List/internal'), { recursive: true }) - ]); + ]))[0]; return compareAndWriteFile( [ diff --git a/Build/build-internal-chn-domains.ts b/Build/build-internal-chn-domains.ts index 821c1bd6..1bc1aaa0 100644 --- a/Build/build-internal-chn-domains.ts +++ b/Build/build-internal-chn-domains.ts @@ -5,10 +5,10 @@ import { task } from './lib/trace-runner'; import { compareAndWriteFile } from './lib/create-file'; export const buildInternalChnDomains = task(import.meta.path, async () => { - const [result] = await Promise.all([ + const result = (await Promise.all([ parseFelixDnsmasq('https://raw.githubusercontent.com/felixonmars/dnsmasq-china-list/master/accelerated-domains.china.conf'), fsp.mkdir(path.resolve(import.meta.dir, '../List/internal'), { recursive: true }) - ]); + ]))[0]; return compareAndWriteFile( result.map(line => `SUFFIX,${line}`), diff --git a/Build/build-internal-reverse-chn-cidr.ts b/Build/build-internal-reverse-chn-cidr.ts index c8081db9..5d9213b0 100644 --- a/Build/build-internal-reverse-chn-cidr.ts +++ b/Build/build-internal-reverse-chn-cidr.ts @@ -25,10 +25,10 @@ const RESERVED_IPV4_CIDR = [ ]; export const buildInternalReverseChnCIDR = task(import.meta.path, async () => { - const [cidr] = await Promise.all([ + const cidr = (await Promise.all([ processLineFromReadline(await fetchRemoteTextAndReadByLine('https://raw.githubusercontent.com/misakaio/chnroutes2/master/chnroutes.txt')), fsp.mkdir(path.resolve(import.meta.dir, '../List/internal'), { recursive: true }) - ]); + ]))[0]; const reversedCidr = exclude( [ diff --git a/Build/build-speedtest-domainset.ts b/Build/build-speedtest-domainset.ts index dfa4dcb4..b8aebfe6 100644 --- a/Build/build-speedtest-domainset.ts +++ b/Build/build-speedtest-domainset.ts @@ -16,10 +16,10 @@ const latestTopUserAgentsPromise = fetchWithRetry('https://unpkg.com/top-user-ag .then(res => res.json() as Promise); const querySpeedtestApi = async (keyword: string): Promise> => { - const [topUserAgents] = await Promise.all([ + const topUserAgents = (await Promise.all([ latestTopUserAgentsPromise, s.acquire() - ]); + ]))[0]; const randomUserAgent = topUserAgents[Math.floor(Math.random() * topUserAgents.length)]; diff --git a/Build/download-previous-build.ts b/Build/download-previous-build.ts index 436f079c..592b9b0f 100644 --- a/Build/download-previous-build.ts +++ b/Build/download-previous-build.ts @@ -53,10 +53,10 @@ export const downloadPreviousBuild = task(import.meta.path, async () => { await traceAsync( 'Download and extract previous build', async () => { - const [resp] = await Promise.all([ + const resp = (await Promise.all([ fetchWithRetry('https://codeload.github.com/sukkalab/ruleset.skk.moe/tar.gz/master', defaultRequestInit), fsp.mkdir(extractedPath, { recursive: true }) - ]); + ]))[0]; const extract = tarStream.extract(); Readable.fromWeb(resp.body!).pipe(zlib.createGunzip()).pipe(extract); @@ -88,10 +88,10 @@ export const downloadPublicSuffixList = task(import.meta.path, async () => { const publicSuffixDir = path.resolve(import.meta.dir, '../node_modules/.cache'); const publicSuffixPath = path.join(publicSuffixDir, 'public_suffix_list_dat.txt'); - const [resp] = await Promise.all([ + const resp = (await Promise.all([ fetchWithRetry('https://publicsuffix.org/list/public_suffix_list.dat', defaultRequestInit), fsp.mkdir(publicSuffixDir, { recursive: true }) - ]); + ]))[0]; return Bun.write(publicSuffixPath, resp as Response); }, 'download-publicsuffixlist'); diff --git a/Build/lib/cached-tld-parse.ts b/Build/lib/cached-tld-parse.ts index b5ced6de..73ccfeae 100644 --- a/Build/lib/cached-tld-parse.ts +++ b/Build/lib/cached-tld-parse.ts @@ -1,6 +1,6 @@ import * as tldts from 'tldts'; import { createCache } from './cache-apply'; -import type { PublicSuffixList } from 'gorhill-publicsuffixlist'; +import type { PublicSuffixList } from '@gorhill/publicsuffixlist'; const cache = createCache('cached-tld-parse', true); diff --git a/Build/lib/fetch-assets.ts b/Build/lib/fetch-assets.ts new file mode 100644 index 00000000..775bd3bc --- /dev/null +++ b/Build/lib/fetch-assets.ts @@ -0,0 +1,61 @@ +import picocolors from 'picocolors'; +import { defaultRequestInit, fetchWithRetry } from './fetch-retry'; + +class CustomAbortError extends Error { + public readonly name = 'AbortError'; + public readonly digest = 'AbortError'; +} + +const sleepWithAbort = (ms: number, signal: AbortSignal) => new Promise((resolve, reject) => { + signal.throwIfAborted(); + signal.addEventListener('abort', stop); + Bun.sleep(ms).then(done).catch(doReject); + + function done() { + signal.removeEventListener('abort', stop); + resolve(); + } + function stop(this: AbortSignal) { + reject(this.reason); + } + function doReject(reason: unknown) { + signal.removeEventListener('abort', stop); + reject(reason); + } +}); + +export async function fetchAssets(url: string, fallbackUrls: string[] | readonly string[]) { + const controller = new AbortController(); + + const fetchMainPromise = fetchWithRetry(url, { signal: controller.signal, ...defaultRequestInit }) + .then(r => r.text()) + .then(text => { + controller.abort(); + return text; + }); + const createFetchFallbackPromise = async (url: string, index: number) => { + // Most assets can be downloaded within 250ms. To avoid wasting bandwidth, we will wait for 500ms before downloading from the fallback URL. + try { + await sleepWithAbort(500 + (index + 1) * 20, controller.signal); + } catch { + console.log(picocolors.gray('[fetch cancelled early]'), picocolors.gray(url)); + throw new CustomAbortError(); + } + if (controller.signal.aborted) { + console.log(picocolors.gray('[fetch cancelled]'), picocolors.gray(url)); + throw new CustomAbortError(); + } + const res = await fetchWithRetry(url, { signal: controller.signal, ...defaultRequestInit }); + const text = await res.text(); + controller.abort(); + return text; + }; + + return Promise.any([ + fetchMainPromise, + ...fallbackUrls.map(createFetchFallbackPromise) + ]).catch(e => { + console.log(`Download Rule for [${url}] failed`); + throw e; + }); +} diff --git a/Build/lib/get-gorhill-publicsuffix.ts b/Build/lib/get-gorhill-publicsuffix.ts index 0d74af1f..df010aa9 100644 --- a/Build/lib/get-gorhill-publicsuffix.ts +++ b/Build/lib/get-gorhill-publicsuffix.ts @@ -2,7 +2,7 @@ import { toASCII } from 'punycode'; import path from 'path'; import { traceAsync } from './trace-runner'; import { defaultRequestInit, fetchWithRetry } from './fetch-retry'; -import type { PublicSuffixList } from 'gorhill-publicsuffixlist'; +import type { PublicSuffixList } from '@gorhill/publicsuffixlist'; const publicSuffixPath = path.resolve(import.meta.dir, '../../node_modules/.cache/public_suffix_list_dat.txt'); @@ -18,7 +18,7 @@ const getGorhillPublicSuffix = () => traceAsync('create gorhill public suffix in console.log('public_suffix_list.dat not found, fetch directly from remote.'); return r.text(); }), - import('gorhill-publicsuffixlist') + import('@gorhill/publicsuffixlist') ]); gorhill.parse(publicSuffixListDat, toASCII); diff --git a/Build/lib/parse-filter.ts b/Build/lib/parse-filter.ts index f1e7d4b4..d6edd0f6 100644 --- a/Build/lib/parse-filter.ts +++ b/Build/lib/parse-filter.ts @@ -1,21 +1,20 @@ // @ts-check -import { defaultRequestInit, fetchWithRetry } from './fetch-retry'; - import { fetchRemoteTextAndReadByLine } from './fetch-text-by-line'; import { NetworkFilter } from '@cliqz/adblocker'; import { processLine } from './process-line'; import { getGorhillPublicSuffixPromise } from './get-gorhill-publicsuffix'; -import type { PublicSuffixList } from 'gorhill-publicsuffixlist'; +import type { PublicSuffixList } from '@gorhill/publicsuffixlist'; import { traceAsync } from './trace-runner'; import picocolors from 'picocolors'; import { normalizeDomain } from './normalize-domain'; +import { fetchAssets } from './fetch-assets'; const DEBUG_DOMAIN_TO_FIND: string | null = null; // example.com | null let foundDebugDomain = false; const warnOnceUrl = new Set(); -const warnOnce = (url: string, isWhite: boolean, ...message: any[]) => { +const warnOnce = (url: string, isWhite: boolean, ...message: string[]) => { const key = `${url}${isWhite ? 'white' : 'black'}`; if (warnOnceUrl.has(key)) { return; @@ -54,7 +53,7 @@ export function processHosts(hostsUrl: string, includeAllSubDomain = false, skip continue; } - const [, domain] = line.split(/\s/); + const domain = line.split(/\s/)[1]; if (!domain) { continue; } @@ -185,7 +184,9 @@ export async function processFilterRules( } const R_KNOWN_NOT_NETWORK_FILTER_PATTERN = /[#%&=~]/; -const R_KNOWN_NOT_NETWORK_FILTER_PATTERN_2 = /(\$popup|\$removeparam|\$popunder)/; +const R_KNOWN_NOT_NETWORK_FILTER_PATTERN_2 = /(\$popup|\$removeparam|\$popunder|\$cname)/; +// cname exceptional filter can not be parsed by NetworkFilter +// Surge / Clash can't handle CNAME either, so we just ignore them function parse($line: string, gorhill: PublicSuffixList): null | [hostname: string, flag: ParseType] { if ( @@ -213,15 +214,15 @@ function parse($line: string, gorhill: PublicSuffixList): null | [hostname: stri return null; } - const firstChar = line[0]; - const lastChar = line[len - 1]; + const firstCharCode = line[0].charCodeAt(0); + const lastCharCode = line[len - 1].charCodeAt(0); if ( - firstChar === '/' + firstCharCode === 47 // 47 `/` // ends with - || lastChar === '.' // || line.endsWith('.') - || lastChar === '-' // || line.endsWith('-') - || lastChar === '_' // || line.endsWith('_') + || lastCharCode === 46 // 46 `.`, line.endsWith('.') + || lastCharCode === 45 // 45 `-`, line.endsWith('-') + || lastCharCode === 95 // 95 `_`, line.endsWith('_') // special modifier || R_KNOWN_NOT_NETWORK_FILTER_PATTERN_2.test(line) // || line.includes('$popup') @@ -238,6 +239,8 @@ function parse($line: string, gorhill: PublicSuffixList): null | [hostname: stri const filter = NetworkFilter.parse(line); if (filter) { if ( + // filter.isCosmeticFilter() // always false + // filter.isNetworkFilter() // always true filter.isElemHide() || filter.isGenericHide() || filter.isSpecificHide() @@ -253,8 +256,7 @@ function parse($line: string, gorhill: PublicSuffixList): null | [hostname: stri if ( filter.hostname // filter.hasHostname() // must have - && filter.isPlain() - // && (!filter.isRegex()) // isPlain() === !isRegex() + && filter.isPlain() // isPlain() === !isRegex() && (!filter.isFullRegex()) ) { const hostname = normalizeDomain(filter.hostname); @@ -286,95 +288,106 @@ function parse($line: string, gorhill: PublicSuffixList): null | [hostname: stri } } - /** - * abnormal filter that can not be parsed by NetworkFilter - */ + // After NetworkFilter.parse, it means the line can not be parsed by cliqz NetworkFilter + // We now need to "salvage" the line as much as possible + /* + * From now on, we are mostly facing non-standard domain rules (some are regex like) + * We first skip third-party and frame rules, as Surge / Clash can't handle them + * + * `.sharecounter.$third-party` + * `.bbelements.com^$third-party` + * `://o0e.ru^$third-party` + * `.1.1.1.l80.js^$third-party` + */ if (line.includes('$third-party') || line.includes('$frame')) { - /* - * `.bbelements.com^$third-party` - * `://o0e.ru^$third-party` - */ return null; } /** @example line.endsWith('^') */ - const linedEndsWithCaret = lastChar === '^'; + const linedEndsWithCaret = lastCharCode === 94; // lastChar === '^'; /** @example line.endsWith('^|') */ - const lineEndsWithCaretVerticalBar = lastChar === '|' && line[len - 2] === '^'; + const lineEndsWithCaretVerticalBar = (lastCharCode === 124 /** lastChar === '|' */) && line[len - 2] === '^'; /** @example line.endsWith('^') || line.endsWith('^|') */ const lineEndsWithCaretOrCaretVerticalBar = linedEndsWithCaret || lineEndsWithCaretVerticalBar; // whitelist (exception) - if (firstChar === '@' && line[1] === '@') { - /** - * cname exceptional filter can not be parsed by NetworkFilter - * - * `@@||m.faz.net^$cname` - * - * Surge / Clash can't handle CNAME either, so we just ignore them - */ - if (line.endsWith('$cname')) { - return null; - } - + if ( + firstCharCode === 64 // 64 `@` + && line[1] === '@' + ) { /** * Some "malformed" regex-based filters can not be parsed by NetworkFilter - * "$genericblock`" is also not supported by NetworkFilter + * "$genericblock`" is also not supported by NetworkFilter, see: + * https://github.com/ghostery/adblocker/blob/62caf7786ba10ef03beffecd8cd4eec111bcd5ec/packages/adblocker/test/parsing.test.ts#L950 * * `@@||cmechina.net^$genericblock` * `@@|ftp.bmp.ovh^|` * `@@|adsterra.com^|` + * `@@.atlassian.net$document` + * `@@||ad.alimama.com^$genericblock` */ - if ( - ( - // line.startsWith('@@|') - line[2] === '|' - // line.startsWith('@@.') - || line[2] === '.' - /** - * line.startsWith('@@://') - * - * `@@://googleadservices.com^|` - * `@@://www.googleadservices.com^|` - */ - || (line[2] === ':' && line[3] === '/' && line[4] === '/') - ) - && ( - lineEndsWithCaretOrCaretVerticalBar - || line.endsWith('$genericblock') - || line.endsWith('$document') - ) - ) { - const _domain = line - .replace('@@||', '') - .replace('@@://', '') - .replace('@@|', '') - .replace('@@.', '') - .replace('^|', '') - .replace('^$genericblock', '') - .replace('$genericblock', '') - .replace('^$document', '') - .replace('$document', '') - .replaceAll('^', '') - .trim(); - const domain = normalizeDomain(_domain); + let sliceStart = 0; + let sliceEnd: number | undefined; + + // line.startsWith('@@|') || line.startsWith('@@.') + if (line[2] === '|' || line[2] === '.') { + sliceStart = 3; + // line.startsWith('@@||') + if (line[3] === '|') { + sliceStart = 4; + } + } + + /** + * line.startsWith('@@://') + * + * `@@://googleadservices.com^|` + * `@@://www.googleadservices.com^|` + */ + if (line[2] === ':' && line[3] === '/' && line[4] === '/') { + sliceStart = 5; + } + + if (lineEndsWithCaretOrCaretVerticalBar) { + sliceEnd = -2; + } else if (line.endsWith('$genericblock')) { + sliceEnd = -13; + if (line[len - 14] === '^') { // line.endsWith('^$genericblock') + sliceEnd = -14; + } + } else if (line.endsWith('$document')) { + sliceEnd = -9; + if (line[len - 10] === '^') { // line.endsWith('^$document') + sliceEnd = -10; + } + } + + if (sliceStart !== 0 || sliceEnd !== undefined) { + const sliced = line.slice(sliceStart, sliceEnd); + const domain = normalizeDomain(sliced); if (domain) { return [domain, ParseType.WhiteIncludeSubdomain]; } - return [ - `[parse-filter E0001] (white) invalid domain: ${_domain}`, + `[parse-filter E0001] (white) invalid domain: ${JSON.stringify({ + line, sliced, sliceStart, sliceEnd + })}`, ParseType.ErrorMessage ]; } + + return [ + `[parse-filter E0006] (white) failed to parse: ${JSON.stringify({ + line, sliceStart, sliceEnd + })}`, + ParseType.ErrorMessage + ]; } - if (firstChar === '|') { - const lineEndswithCname = line.endsWith('$cname'); - - if (lineEndsWithCaretOrCaretVerticalBar || lineEndswithCname) { + if (firstCharCode === 124) { // 124 `|` + if (lineEndsWithCaretOrCaretVerticalBar) { /** * Some malformed filters can not be parsed by NetworkFilter: * @@ -387,12 +400,11 @@ function parse($line: string, gorhill: PublicSuffixList): null | [hostname: stri const includeAllSubDomain = line[1] === '|'; const sliceStart = includeAllSubDomain ? 2 : 1; - const sliceEnd = lastChar === '^' + const sliceEnd = lastCharCode === 94 // lastChar === '^' ? -1 - : lineEndsWithCaretOrCaretVerticalBar + : (lineEndsWithCaretVerticalBar ? -2 - // eslint-disable-next-line sukka/unicorn/no-nested-ternary -- speed - : (lineEndswithCname ? -6 : 0); + : undefined); const _domain = line .slice(sliceStart, sliceEnd) // we already make sure line startsWith "|" @@ -410,7 +422,7 @@ function parse($line: string, gorhill: PublicSuffixList): null | [hostname: stri } } - const lineStartsWithSingleDot = firstChar === '.'; + const lineStartsWithSingleDot = firstCharCode === 46; // 46 `.` if ( lineStartsWithSingleDot && lineEndsWithCaretOrCaretVerticalBar @@ -489,7 +501,10 @@ function parse($line: string, gorhill: PublicSuffixList): null | [hostname: stri * `-logging.nextmedia.com` * `_social_tracking.js^` */ - if (firstChar !== '|' && lastChar === '^') { + if ( + firstCharCode !== 124 // 124 `|` + && lastCharCode === 94 // 94 `^` + ) { const _domain = line.slice(0, -1); const suffix = gorhill.getPublicSuffix(_domain); @@ -553,63 +568,3 @@ function parse($line: string, gorhill: PublicSuffixList): null | [hostname: stri ParseType.ErrorMessage ]; } - -class CustomAbortError extends Error { - public readonly name = 'AbortError'; - public readonly digest = 'AbortError'; -} - -const sleepWithAbort = (ms: number, signal: AbortSignal) => new Promise((resolve, reject) => { - signal.throwIfAborted(); - signal.addEventListener('abort', stop); - Bun.sleep(ms).then(done).catch(doReject); - - function done() { - signal.removeEventListener('abort', stop); - resolve(); - } - function stop(this: AbortSignal) { - reject(this.reason); - } - function doReject(reason: unknown) { - signal.removeEventListener('abort', stop); - reject(reason); - } -}); - -async function fetchAssets(url: string, fallbackUrls: string[] | readonly string[]) { - const controller = new AbortController(); - - const fetchMainPromise = fetchWithRetry(url, { signal: controller.signal, ...defaultRequestInit }) - .then(r => r.text()) - .then(text => { - console.log(picocolors.gray('[fetch finish]'), picocolors.gray(url)); - controller.abort(); - return text; - }); - const createFetchFallbackPromise = async (url: string, index: number) => { - // Most assets can be downloaded within 250ms. To avoid wasting bandwidth, we will wait for 350ms before downloading from the fallback URL. - try { - await sleepWithAbort(300 + (index + 1) * 20, controller.signal); - } catch { - console.log(picocolors.gray('[fetch cancelled early]'), picocolors.gray(url)); - throw new CustomAbortError(); - } - if (controller.signal.aborted) { - console.log(picocolors.gray('[fetch cancelled]'), picocolors.gray(url)); - throw new CustomAbortError(); - } - const res = await fetchWithRetry(url, { signal: controller.signal, ...defaultRequestInit }); - const text = await res.text(); - controller.abort(); - return text; - }; - - return Promise.any([ - fetchMainPromise, - ...fallbackUrls.map(createFetchFallbackPromise) - ]).catch(e => { - console.log(`Download Rule for [${url}] failed`); - throw e; - }); -} diff --git a/Build/lib/stable-sort-domain.ts b/Build/lib/stable-sort-domain.ts index d8516922..8b6da71b 100644 --- a/Build/lib/stable-sort-domain.ts +++ b/Build/lib/stable-sort-domain.ts @@ -1,4 +1,4 @@ -import type { PublicSuffixList } from 'gorhill-publicsuffixlist'; +import type { PublicSuffixList } from '@gorhill/publicsuffixlist'; import { createCachedGorhillGetDomain } from './cached-tld-parse'; const compare = (a: string | null, b: string | null) => { diff --git a/Build/mod.d.ts b/Build/mod.d.ts index 5e6e1299..212ff778 100644 --- a/Build/mod.d.ts +++ b/Build/mod.d.ts @@ -1,4 +1,4 @@ -declare module 'gorhill-publicsuffixlist' { +declare module '@gorhill/publicsuffixlist' { type Selfie = | string | { diff --git a/bun.lockb b/bun.lockb index 27cc62190ac630cf62fda7e5cb6e956c37d2bc1e..c90c6ab1e1fccd8f51404d130d96a80fda904626 100755 GIT binary patch delta 7381 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 N6_}o{`Np#5e*t~_I#2)r delta 7463 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