Skip to content

Commit

Permalink
feat: export BotDetectionService as class
Browse files Browse the repository at this point in the history
support result memoization
`bot` in userAgent will be now detected as Bot
Split out `isBot` and `getBotReason`
  • Loading branch information
kirillgroshkov committed Oct 19, 2024
1 parent 8bcf607 commit c14953e
Show file tree
Hide file tree
Showing 2 changed files with 71 additions and 25 deletions.
34 changes: 22 additions & 12 deletions src/bot.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { botDetectionService, BotReason } from './bot'
import { BotDetectionService, BotReason } from './bot'

beforeEach(() => {
Object.assign(globalThis, {
Expand All @@ -8,15 +8,18 @@ beforeEach(() => {
})
})

const botDetectionService = new BotDetectionService()

const userAgentChrome = 'Innocent Chrome 998 Mozilla IE Trident All Good Windows 95'
const userAgentSafari = 'Innocent Safari 21 Apple Cupertino 991 Next'

test('serverSide script is not a bot', () => {
Object.assign(globalThis, {
window: undefined,
})
expect(botDetectionService.isBot()).toBeUndefined()
expect(botDetectionService.getBotReason()).toBeNull()
expect(botDetectionService.isCDP()).toBe(false)
expect(botDetectionService.isBot()).toBe(false)
expect(botDetectionService.isBotOrCDP()).toBe(false)
})

Expand All @@ -27,7 +30,7 @@ test('innocent chrome is not a bot', () => {
} as Navigator,
chrome: {},
})
expect(botDetectionService.isBot()).toBeUndefined()
expect(botDetectionService.getBotReason()).toBeNull()
})

test('innocent safari is not a bot', () => {
Expand All @@ -36,44 +39,51 @@ test('innocent safari is not a bot', () => {
userAgent: userAgentSafari,
} as Navigator,
})
expect(botDetectionService.isBot()).toBeUndefined()
expect(botDetectionService.getBotReason()).toBeNull()
})

test('no navigator means bot', () => {
expect(botDetectionService.isBot()).toBe(BotReason.NoNavigator)
expect(botDetectionService.getBotReason()).toBe(BotReason.NoNavigator)
expect(botDetectionService.isBot()).toBe(true)
expect(botDetectionService.isBotOrCDP()).toBe(true)
})

test('no userAgent means bot', () => {
globalThis.navigator = {} as Navigator
expect(botDetectionService.isBot()).toBe(BotReason.NoUserAgent)
expect(botDetectionService.getBotReason()).toBe(BotReason.NoUserAgent)
})

test('"bot" in userAgent means bot', () => {
globalThis.navigator = { userAgent: 'KlaviyoBot' } as Navigator
expect(botDetectionService.getBotReason()).toBe(BotReason.UserAgent)
})

test('"headless" in userAgent means bot', () => {
globalThis.navigator = { userAgent: 'HeadlessChrome' } as Navigator
expect(botDetectionService.isBot()).toBe(BotReason.UserAgent)
expect(botDetectionService.getBotReason()).toBe(BotReason.UserAgent)
})

test('"electron" in userAgent means bot', () => {
globalThis.navigator = { userAgent: 'I am Electron 99' } as Navigator
expect(botDetectionService.isBot()).toBe(BotReason.UserAgent)
expect(botDetectionService.getBotReason()).toBe(BotReason.UserAgent)
})

test('"phantom" in userAgent means bot', () => {
globalThis.navigator = { userAgent: 'Phantom Menace' } as Navigator
expect(botDetectionService.isBot()).toBe(BotReason.UserAgent)
expect(botDetectionService.getBotReason()).toBe(BotReason.UserAgent)
})

test('"slimer" in userAgent means bot', () => {
globalThis.navigator = { userAgent: 'Slimer than Slime' } as Navigator
expect(botDetectionService.isBot()).toBe(BotReason.UserAgent)
expect(botDetectionService.getBotReason()).toBe(BotReason.UserAgent)
})

test('navigator.webdriver means bot', () => {
globalThis.navigator = {
userAgent: userAgentSafari,
webdriver: true,
} as Navigator
expect(botDetectionService.isBot()).toBe(BotReason.WebDriver)
expect(botDetectionService.getBotReason()).toBe(BotReason.WebDriver)
})

// test('0 plugins means bot', () => {
Expand All @@ -89,7 +99,7 @@ test('"" languages means bot', () => {
userAgent: userAgentSafari,
languages: '' as any,
} as Navigator
expect(botDetectionService.isBot()).toBe(BotReason.EmptyLanguages)
expect(botDetectionService.getBotReason()).toBe(BotReason.EmptyLanguages)
})

// test('Chrome without chrome means bot', () => {
Expand Down
62 changes: 49 additions & 13 deletions src/bot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,32 +3,59 @@

import { isServerSide } from '@naturalcycles/js-lib'

export interface BotDetectionServiceCfg {
/**
* Defaults to false.
* If true - the instance will memoize (remember) the results of the detection
* and won't re-run it.
*/
memoizeResults?: boolean
}

/**
* Service to detect bots and CDP (Chrome DevTools Protocol).
*
* @experimental
*/
class BotDetectionService {
export class BotDetectionService {
constructor(public cfg: BotDetectionServiceCfg = {}) {}

// memoized results
private botReason: BotReason | null | undefined
private cdp: boolean | undefined

isBotOrCDP(): boolean {
return !!this.isBot() || this.isCDP()
return !!this.getBotReason() || this.isCDP()
}

isBot(): boolean {
return !!this.getBotReason()
}

/**
* Returns undefined if it's not a Bot,
* Returns null if it's not a Bot,
* otherwise a truthy BotReason.
*/
isBot(): BotReason | undefined {
getBotReason(): BotReason | null {
if (this.cfg.memoizeResults && this.botReason !== undefined) {
return this.botReason
}

this.botReason = this.detectBotReason()
return this.botReason
}

private detectBotReason(): BotReason | null {
// SSR - not a bot
if (isServerSide()) return
if (isServerSide()) return null
const { navigator } = globalThis
if (!navigator) return BotReason.NoNavigator
const { userAgent } = navigator
if (!userAgent) return BotReason.NoUserAgent

if (/headless/i.test(userAgent)) return BotReason.UserAgent
if (/electron/i.test(userAgent)) return BotReason.UserAgent
if (/phantom/i.test(userAgent)) return BotReason.UserAgent
if (/slimer/i.test(userAgent)) return BotReason.UserAgent
if (/bot|headless|electron|phantom|slimer/i.test(userAgent)) {
return BotReason.UserAgent
}

if (navigator.webdriver) {
return BotReason.WebDriver
Expand All @@ -51,6 +78,8 @@ class BotDetectionService {
// if (userAgent.includes('Chrome') && !(globalThis as any).chrome) {
// return BotReason.ChromeWithoutChrome // Headless Chrome
// }

return null
}

/**
Expand All @@ -67,6 +96,15 @@ class BotDetectionService {
* Based on: https://deviceandbrowserinfo.com/learning_zone/articles/detecting-headless-chrome-puppeteer-2024
*/
isCDP(): boolean {
if (this.cfg.memoizeResults && this.cdp !== undefined) {
return this.cdp
}

this.cdp = this.detectCDP()
return this.cdp
}

private detectCDP(): boolean {
if (isServerSide()) return false
let cdpCheck1 = false
try {
Expand All @@ -90,14 +128,12 @@ class BotDetectionService {
}
}

export const botDetectionService = new BotDetectionService()

export enum BotReason {
NoNavigator = 1,
NoUserAgent = 2,
UserAgent = 3,
WebDriver = 4,
ZeroPlugins = 5,
// ZeroPlugins = 5,
EmptyLanguages = 6,
ChromeWithoutChrome = 7,
// ChromeWithoutChrome = 7,
}

0 comments on commit c14953e

Please sign in to comment.