Skip to content

Commit ab34ffa

Browse files
authored
perf: compile default bot matcher at build time (#318)
1 parent 1998e6d commit ab34ffa

5 files changed

Lines changed: 220 additions & 14 deletions

File tree

build.config.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,19 @@
1+
import { writeFile } from 'node:fs/promises'
2+
import { resolve } from 'pathe'
13
import { defineBuildConfig } from 'unbuild'
4+
import { generateBotMatcherModule } from './scripts/generate-bot-matcher'
5+
import { BOT_MAP } from './src/const-bots'
26

37
export default defineBuildConfig({
48
declaration: true,
9+
hooks: {
10+
'build:before': async function (ctx) {
11+
await writeFile(
12+
resolve(ctx.options.rootDir, 'src/generated-bot-matcher.ts'),
13+
generateBotMatcherModule(BOT_MAP),
14+
)
15+
},
16+
},
517
rollup: {
618
emitCJS: true,
719
},

scripts/generate-bot-matcher.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
interface BotPatternDefinition {
2+
name: string
3+
pattern: string
4+
secondaryPatterns?: string[]
5+
}
6+
7+
interface BotCategoryDefinition {
8+
bots: BotPatternDefinition[]
9+
trusted: boolean
10+
type: string
11+
}
12+
13+
interface PatternEntry {
14+
botIndex: number
15+
definitionIndex: number
16+
pattern: string
17+
}
18+
19+
function collectPatterns(definitions: readonly BotCategoryDefinition[]): PatternEntry[] {
20+
const patterns = new Map<string, PatternEntry>()
21+
for (const [definitionIndex, definition] of definitions.entries()) {
22+
for (const [botIndex, bot] of definition.bots.entries()) {
23+
for (const sourcePattern of [bot.pattern, ...(bot.secondaryPatterns || [])]) {
24+
const pattern = sourcePattern.toLowerCase()
25+
patterns.set(pattern, {
26+
botIndex,
27+
definitionIndex,
28+
pattern,
29+
})
30+
}
31+
}
32+
}
33+
return [...patterns.values()]
34+
}
35+
36+
function escapeRegExpPattern(value: string): string {
37+
return value
38+
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
39+
.replaceAll('/', '\\/')
40+
.replaceAll('\n', '\\n')
41+
.replaceAll('\r', '\\r')
42+
.replaceAll('\u2028', '\\u2028')
43+
.replaceAll('\u2029', '\\u2029')
44+
}
45+
46+
function removeShadowedPatterns(patterns: PatternEntry[]): PatternEntry[] {
47+
return patterns.filter((entry, index) => !patterns
48+
.slice(0, index)
49+
.some(candidate => entry.pattern.includes(candidate.pattern)))
50+
}
51+
52+
export function generateBotMatcherModule(definitions: readonly BotCategoryDefinition[]): string {
53+
const patterns = removeShadowedPatterns(collectPatterns(definitions))
54+
const botTargets = patterns.map(({ definitionIndex, botIndex }) => {
55+
if (definitionIndex > 255 || botIndex > 255)
56+
throw new Error('Bot matcher target exceeds its 8-bit index limit.')
57+
return (definitionIndex << 8) | botIndex
58+
})
59+
const alternatives = patterns.map(({ pattern }) => `(${escapeRegExpPattern(pattern)})`).join('|')
60+
61+
return `// Generated by build.config.ts. Do not edit.
62+
63+
const defaultBotPattern = /(?=(?:${alternatives}))/g
64+
const botTargets: number[] = [${botTargets.join(', ')}]
65+
66+
export function matchDefaultBotTarget(value: string): number | undefined {
67+
let bestPriority = -1
68+
defaultBotPattern.lastIndex = 0
69+
let match = defaultBotPattern.exec(value)
70+
while (match) {
71+
let priority = 0
72+
while (typeof match[priority + 1] === 'undefined')
73+
priority++
74+
if (bestPriority === -1 || priority < bestPriority)
75+
bestPriority = priority
76+
if (bestPriority === 0)
77+
break
78+
defaultBotPattern.lastIndex = match.index + 1
79+
match = defaultBotPattern.exec(value)
80+
}
81+
return bestPriority === -1 ? undefined : botTargets[bestPriority]
82+
}
83+
`
84+
}

src/generated-bot-matcher.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
// Generated by build.config.ts. Do not edit.
2+
3+
const defaultBotPattern = /(?=(?:(googlebot)|(google\.com\/bot\.html)|(bingbot)|(msnbot)|(yandexbot)|(baiduspider)|(baidu\.com)|(duckduckbot)|(duckduckgo\.com)|(slurp)|(applebot)|(twitterbot)|(twitter)|(facebookexternalhit)|(facebook\.com)|(linkedinbot)|(linkedin)|(pinterestbot)|(pinterest)|(discordbot)|(discordapp)|(mj12bot)|(majestic12\.co\.uk\/bot)|(ahrefsbot)|(ahrefs\.com)|(semrushbot)|(semrush\.com\/bot)|(screaming frog)|(screamingfrog\.co\.uk)|(rogerbot)|(anthropic)|(claude)|(gptbot)|(openai\.com)|(google-extended)|(bytespider)|(diffbot)|(cohere)|(ccbot)|(commoncrawl\.org)|(perplexitybot)|(perplexity\.ai)|(bot)|(spider)|(crawler)|(scraper)|(phantomjs)|(headless)|(playwright)|(selenium)|(webdriver)|(puppeteer)|(python-requests)|(python)|(wget)|(curl)|(zgrab)|(masscan)|(nmap)|(insecure\.org)|(nikto)|(wpscan)|(scrapy)))/g
4+
const botTargets: number[] = [0, 0, 1, 1, 2, 3, 3, 4, 4, 5, 6, 256, 256, 257, 257, 258, 258, 259, 259, 260, 260, 512, 512, 513, 513, 514, 514, 515, 515, 516, 768, 769, 770, 770, 771, 773, 774, 776, 777, 777, 778, 778, 1024, 1025, 1026, 1027, 1280, 1284, 1282, 1283, 1283, 1284, 1536, 1536, 1537, 1538, 1792, 1793, 1794, 1794, 1795, 1796, 2048]
5+
6+
export function matchDefaultBotTarget(value: string): number | undefined {
7+
let bestPriority = -1
8+
defaultBotPattern.lastIndex = 0
9+
let match = defaultBotPattern.exec(value)
10+
while (match) {
11+
let priority = 0
12+
while (typeof match[priority + 1] === 'undefined')
13+
priority++
14+
if (bestPriority === -1 || priority < bestPriority)
15+
bestPriority = priority
16+
if (bestPriority === 0)
17+
break
18+
defaultBotPattern.lastIndex = match.index + 1
19+
match = defaultBotPattern.exec(value)
20+
}
21+
return bestPriority === -1 ? undefined : botTargets[bestPriority]
22+
}

src/util.ts

Lines changed: 31 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
SEO_BOTS,
1616
SOCIAL_BOTS,
1717
} from './const-bots'
18+
import { matchDefaultBotTarget } from './generated-bot-matcher'
1819

1920
export * from './const-bots'
2021
export { AiBots, NonHelpfulBots }
@@ -538,22 +539,39 @@ export function isBotFromHeaders(
538539

539540
const userAgentLower = userAgent.toLowerCase()
540541

541-
// Use provided pattern map or create on demand for standalone usage
542-
const resolvedPatternMap = patternMap || createPatternMap()
543-
544-
// Use optimized pattern lookup - O(n) instead of O(n³)
545-
for (const [pattern, botData] of resolvedPatternMap) {
546-
if (userAgentLower.includes(pattern)) {
547-
return {
548-
isBot: true,
549-
data: {
550-
botName: botData.botName,
551-
botCategory: botData.botCategory,
552-
trusted: botData.trusted,
553-
},
542+
const defaultBotTarget = patternMap ? undefined : matchDefaultBotTarget(userAgentLower)
543+
if (typeof defaultBotTarget !== 'undefined') {
544+
const definition = BOT_MAP[defaultBotTarget >> 8]!
545+
const bot = definition.bots[defaultBotTarget & 255]!
546+
return {
547+
isBot: true,
548+
data: {
549+
botName: bot.name as BotName,
550+
botCategory: definition.type,
551+
trusted: definition.trusted,
552+
},
553+
}
554+
}
555+
556+
let botData: PatternMapValue | undefined
557+
if (patternMap) {
558+
for (const [pattern, candidate] of patternMap) {
559+
if (userAgentLower.includes(pattern)) {
560+
botData = candidate
561+
break
554562
}
555563
}
556564
}
565+
if (botData) {
566+
return {
567+
isBot: true,
568+
data: {
569+
botName: botData.botName,
570+
botCategory: botData.botCategory,
571+
trusted: botData.trusted,
572+
},
573+
}
574+
}
557575

558576
return { isBot: false }
559577
}

test/unit/botDetection.test.ts

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1+
import type { PatternMapValue } from '../../src/runtime/types'
12
import { describe, expect, it } from 'vitest'
2-
import { isBotFromHeaders } from '../../src/util'
3+
import { createPatternMap, getBotDetection, isBotFromHeaders } from '../../src/util'
34

45
const ValidHeaders = {
56
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
@@ -11,6 +12,12 @@ const ValidHeaders = {
1112
}
1213

1314
describe('bot Detection from Headers', () => {
15+
it.each([...createPatternMap().keys()])('matches the default %s pattern with reusable-map semantics', (pattern) => {
16+
const headers = { 'user-agent': `Mozilla/5.0 compatible ${pattern}/1.0` }
17+
18+
expect(isBotFromHeaders(headers)).toEqual(isBotFromHeaders(headers, createPatternMap()))
19+
})
20+
1421
// Test empty user agent handling
1522
it('handles empty user agent properly', () => {
1623
const result = isBotFromHeaders({})
@@ -103,6 +110,24 @@ describe('bot Detection from Headers', () => {
103110
expect(result.data?.trusted).toBe(false)
104111
})
105112

113+
it('preserves definition priority over match position', () => {
114+
const result = isBotFromHeaders({ 'user-agent': 'SomeRandomBot/1.0 Googlebot/2.1' })
115+
116+
expect(result.data?.botName).toBe('googlebot')
117+
})
118+
119+
it('preserves reusable-map priority for every pair of default patterns', () => {
120+
const patternMap = createPatternMap()
121+
const patterns = [...patternMap.keys()]
122+
123+
for (const firstPattern of patterns) {
124+
for (const secondPattern of patterns) {
125+
const headers = { 'user-agent': `${firstPattern}/${secondPattern}` }
126+
expect(isBotFromHeaders(headers)).toEqual(isBotFromHeaders(headers, patternMap))
127+
}
128+
}
129+
})
130+
106131
// Test human traffic
107132
it('identifies likely human traffic', () => {
108133
const result = isBotFromHeaders(ValidHeaders)
@@ -129,4 +154,49 @@ describe('bot Detection from Headers', () => {
129154

130155
expect(result.isBot).toBe(false)
131156
})
157+
158+
it('uses a caller-provided bot map', () => {
159+
const customMap = new Map<string, PatternMapValue>([
160+
['private-crawler', { botName: 'private', botCategory: 'scraping', trusted: false }],
161+
])
162+
163+
const result = getBotDetection({ 'user-agent': 'Private-Crawler/1.0' }, customMap)
164+
165+
expect(result).toEqual({
166+
isBot: true,
167+
userAgent: 'Private-Crawler/1.0',
168+
detectionMethod: 'headers',
169+
botName: 'private',
170+
botCategory: 'scraping',
171+
trusted: false,
172+
})
173+
})
174+
175+
it('keeps caller-provided bot maps isolated', async () => {
176+
const firstMap = new Map<string, PatternMapValue>([
177+
['first-crawler', { botName: 'first', botCategory: 'scraping', trusted: false }],
178+
])
179+
const secondMap = new Map<string, PatternMapValue>([
180+
['second-crawler', { botName: 'second', botCategory: 'search-engine', trusted: true }],
181+
])
182+
183+
const [firstResult, secondResult] = await Promise.all([
184+
Promise.resolve(isBotFromHeaders({ 'user-agent': 'First-Crawler/1.0' }, firstMap)),
185+
Promise.resolve(isBotFromHeaders({ 'user-agent': 'Second-Crawler/1.0' }, secondMap)),
186+
])
187+
188+
expect(firstResult.data?.botName).toBe('first')
189+
expect(secondResult.data?.botName).toBe('second')
190+
expect(isBotFromHeaders({ 'user-agent': 'First-Crawler/1.0' }, secondMap)).toEqual({ isBot: false })
191+
})
192+
193+
it('returns independently mutable pattern maps', () => {
194+
const firstMap = createPatternMap()
195+
const secondMap = createPatternMap()
196+
197+
firstMap.clear()
198+
199+
expect(isBotFromHeaders({ 'user-agent': 'Googlebot/2.1' }, firstMap)).toEqual({ isBot: false })
200+
expect(isBotFromHeaders({ 'user-agent': 'Googlebot/2.1' }, secondMap).data?.botName).toBe('googlebot')
201+
})
132202
})

0 commit comments

Comments
 (0)