Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: expose detectors and separate detect and collect functions #144

Merged
merged 15 commits into from
Nov 5, 2023
74 changes: 74 additions & 0 deletions src/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import {
AbstractDetectorDict,
AbstractSourceDict,
BotdError,
BotDetectionResult,
BotKind,
Component,
ComponentDict,
DetectionDict,
State,
} from './types'

export function detect<T extends AbstractDetectorDict>(
components: ComponentDict<any>,
detectors: T,
): [DetectionDict<T>, BotDetectionResult] {
Finesse marked this conversation as resolved.
Show resolved Hide resolved
const detections = {} as DetectionDict<T>
let finalDetection: BotDetectionResult = {
bot: false,
}

for (const detectorName in detectors) {
const detector = detectors[detectorName as keyof typeof detectors]
const detectorRes = detector(components)

let detection: BotDetectionResult = { bot: false }

if (typeof detectorRes === 'string') {
detection = { bot: true, botKind: detectorRes }
} else if (detectorRes) {
detection = { bot: true, botKind: BotKind.Unknown }
}

detections[detectorName as keyof typeof detectors] = detection

if (detection.bot) {
finalDetection = detection
}
}

return [detections, finalDetection]
}

export async function collect<T extends AbstractSourceDict>(sources: T): Promise<ComponentDict<T>> {
const components = {} as ComponentDict<T>
const sourcesKeys = Object.keys(sources) as (keyof typeof sources)[]

await Promise.all(
sourcesKeys.map(async (sourceKey) => {
const res = sources[sourceKey]

try {
components[sourceKey] = ({
value: await res(),
state: State.Success,
} as Component<any>) as any
} catch (error) {
if (error instanceof BotdError) {
components[sourceKey] = {
state: error.state,
error: `${error.name}: ${error.message}`,
}
} else {
components[sourceKey] = {
state: State.UnexpectedBehaviour,
error: error instanceof Error ? `${error.name}: ${error.message}` : String(error),
}
}
}
}),
)

return components
}
85 changes: 5 additions & 80 deletions src/detector.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,6 @@
import { detectors } from './detectors'
import { sources } from './sources'
import {
BotdError,
BotDetectionResult,
BotDetectorInterface,
BotKind,
Component,
ComponentDict,
DetectionDict,
State,
} from './types'
import { BotDetectionResult, BotDetectorInterface, ComponentDict, DetectionDict } from './types'
import { collect, detect } from './api'
import { detectors, sources } from '.'
Finesse marked this conversation as resolved.
Show resolved Hide resolved

/**
* Class representing a bot detector.
Expand All @@ -30,16 +21,6 @@ export default class BotDetector implements BotDetectorInterface {
return this.detections
}

// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
protected getSources() {
return sources
}

// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
protected getDetectors() {
return detectors
}

/**
* @inheritdoc
*/
Expand All @@ -48,73 +29,17 @@ export default class BotDetector implements BotDetectorInterface {
throw new Error("BotDetector.detect can't be called before BotDetector.collect")
}

const components = this.components
const detectors = this.getDetectors()

const detections = {} as DetectionDict
let finalDetection: BotDetectionResult = {
bot: false,
}

for (const detectorName in detectors) {
const detector = detectors[detectorName as keyof typeof detectors]
const detectorRes = detector(components)

let detection: BotDetectionResult = { bot: false }

if (typeof detectorRes === 'string') {
detection = { bot: true, botKind: detectorRes }
} else if (detectorRes) {
detection = { bot: true, botKind: BotKind.Unknown }
}

detections[detectorName as keyof typeof detectors] = detection

if (detection.bot) {
finalDetection = detection
}
}
const [detections, finalDetection] = detect(this.components, detectors)

this.detections = detections

return finalDetection
}

/**
* @inheritdoc
*/
public async collect(): Promise<ComponentDict> {
const sources = this.getSources()
const components = {} as ComponentDict

const sourcesKeys = Object.keys(sources) as (keyof typeof sources)[]

await Promise.all(
sourcesKeys.map(async (sourceKey) => {
const res = sources[sourceKey]

try {
components[sourceKey] = ({
value: await res(),
state: State.Success,
} as Component<any>) as any
} catch (error) {
if (error instanceof BotdError) {
components[sourceKey] = {
state: error.state,
error: `${error.name}: ${error.message}`,
}
} else {
components[sourceKey] = {
state: State.UnexpectedBehaviour,
error: error instanceof Error ? `${error.name}: ${error.message}` : String(error),
}
}
}
}),
)

this.components = components
this.components = await collect(sources)
return this.components
}
}
18 changes: 10 additions & 8 deletions src/detectors/eval_length.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
import { arrayIncludes } from '../utils/ponyfills'
import { BrowserEngineKind, BrowserKind, ComponentDict, DetectorResponse, State } from '../types'
import { getBrowserEngineKind, getBrowserKind } from '../utils/browser'

export function detectEvalLengthInconsistency({ evalLength }: ComponentDict): DetectorResponse {
if (evalLength.state !== State.Success) return
export function detectEvalLengthInconsistency({
evalLength,
browser: browserComponent
}: ComponentDict): DetectorResponse {
if (evalLength.state !== State.Success || browserComponent.state !== State.Success) return

const { browserKind, browserEngineKind } = browserComponent.value
const length = evalLength.value
const browser = getBrowserKind()
const browserEngine = getBrowserEngineKind()
return (
(length === 37 && !arrayIncludes([BrowserEngineKind.Webkit, BrowserEngineKind.Gecko], browserEngine)) ||
(length === 39 && !arrayIncludes([BrowserKind.IE], browser)) ||
(length === 33 && !arrayIncludes([BrowserEngineKind.Chromium], browserEngine))
(length === 37 && !arrayIncludes([BrowserEngineKind.Webkit, BrowserEngineKind.Gecko], browserEngineKind)) ||
(length === 39 && !arrayIncludes([BrowserKind.IE], browserKind)) ||
(length === 33 && !arrayIncludes([BrowserEngineKind.Chromium], browserEngineKind))
)
}
9 changes: 5 additions & 4 deletions src/detectors/notification_permissions.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { BotKind, BrowserKind, ComponentDict, DetectorResponse, State } from '../types'
import { getBrowserKind } from '../utils/browser'

export function detectNotificationPermissions({ notificationPermissions }: ComponentDict): DetectorResponse {
const browserKind = getBrowserKind()
if (browserKind !== BrowserKind.Chrome) return false
export function detectNotificationPermissions({
notificationPermissions,
browser
}: ComponentDict): DetectorResponse {
if (browser.state !== State.Success || browser.value.browserKind !== BrowserKind.Chrome) return false

if (notificationPermissions.state === State.Success && notificationPermissions.value) {
return BotKind.HeadlessChrome
Expand Down
12 changes: 7 additions & 5 deletions src/detectors/plugins_inconsistency.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { BotKind, BrowserKind, ComponentDict, DetectorResponse, State } from '../types'
import { getBrowserKind, isAndroid } from '../utils/browser'

export function detectPluginsLengthInconsistency({ pluginsLength }: ComponentDict): DetectorResponse {
if (pluginsLength.state !== State.Success) return
const browserKind = getBrowserKind()
if (browserKind !== BrowserKind.Chrome || isAndroid()) return
export function detectPluginsLengthInconsistency({
pluginsLength,
browser
}: ComponentDict): DetectorResponse {
if (pluginsLength.state !== State.Success || browser.state !== State.Success) return
const { isAndroid, browserKind } = browser.value
if (browserKind !== BrowserKind.Chrome || isAndroid) return
if (pluginsLength.value === 0) return BotKind.HeadlessChrome
}
7 changes: 3 additions & 4 deletions src/detectors/product_sub.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { BotKind, BrowserKind, ComponentDict, DetectorResponse, State } from '../types'
import { getBrowserKind } from '../utils/browser'

export function detectProductSub({ productSub }: ComponentDict): DetectorResponse {
if (productSub.state !== State.Success) return false
const browserKind = getBrowserKind()
export function detectProductSub({ productSub, browser }: ComponentDict): DetectorResponse {
if (productSub.state !== State.Success || browser.state !== State.Success) return false
const { browserKind } = browser.value
if (
(browserKind === BrowserKind.Chrome ||
browserKind === BrowserKind.Safari ||
Expand Down
8 changes: 4 additions & 4 deletions src/detectors/rtt.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { BotKind, ComponentDict, DetectorResponse, State } from '../types'
import { isAndroid } from '../utils/browser'

export function detectRTT({ rtt }: ComponentDict): DetectorResponse {
if (rtt.state !== State.Success) return
export function detectRTT({ rtt, browser }: ComponentDict): DetectorResponse {
if (rtt.state !== State.Success || browser.state !== State.Success) return
const { isAndroid } = browser.value
// Rtt is 0 on android webview
if (isAndroid()) return
if (isAndroid) return
if (rtt.value === 0) return BotKind.HeadlessChrome
}
8 changes: 4 additions & 4 deletions src/detectors/window_size.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { BotKind, ComponentDict, DetectorResponse, State } from '../types'
import { getDocumentFocus } from '../utils/browser'

export function detectWindowSize({ windowSize }: ComponentDict): DetectorResponse {
if (windowSize.state !== State.Success) return false
export function detectWindowSize({ windowSize, browser }: ComponentDict): DetectorResponse {
if (windowSize.state !== State.Success || browser.state !== State.Success) return false
const { outerWidth, outerHeight } = windowSize.value
const { documentFocus } = browser.value
// When a page is opened in a new tab without focusing it right away, the window outer size is 0x0
if (!getDocumentFocus()) return
if (!documentFocus) return
if (outerWidth === 0 && outerHeight === 0) return BotKind.HeadlessChrome
}
5 changes: 5 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { version } from '../package.json'
import BotDetector from './detector'
import { sources, WindowSizePayload, ProcessPayload, DistinctivePropertiesPayload } from './sources'
import { detectors } from './detectors'
import { BotdError, BotDetectorInterface, BotKind, BotDetectionResult } from './types'
import { collect, detect } from './api'

/**
* Options for BotD loading
Expand Down Expand Up @@ -48,6 +50,9 @@ export default { load }
/** Not documented, out of Semantic Versioning, usage is at your own risk */
export {
sources,
detectors,
collect,
detect,
BotdError,
WindowSizePayload,
ProcessPayload,
Expand Down
27 changes: 27 additions & 0 deletions src/sources/browser.test.ts
Le0Developer marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { isChromium, isGecko, isMobile, isWebKit } from '../../tests/utils'
import { BrowserEngineKind, BrowserKind } from '../types'
import getBrowserInformation from './browser'

describe('Sources', () => {
describe('browser', () => {
it('returns an expected value', () => {
const value = getBrowserInformation()

if (isChromium()) {
expect(value.browserEngineKind).toBe(BrowserEngineKind.Chromium)
expect(value.browserKind).toBe(BrowserKind.Chrome)
expect(value.isAndroid).toBe(isMobile())
}
if (isGecko()) {
expect(value.browserEngineKind).toBe(BrowserEngineKind.Gecko)
expect(value.browserKind).toBe(BrowserKind.Firefox)
expect(value.isAndroid).toBeFalse()
}
if (isWebKit()) {
expect(value.browserEngineKind).toBe(BrowserEngineKind.Webkit)
expect(value.browserKind).toBe(BrowserKind.Safari)
expect(value.isAndroid).toBeFalse()
}
})
})
})
18 changes: 18 additions & 0 deletions src/sources/browser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { BrowserEngineKind, BrowserKind } from '../types'
import { getBrowserEngineKind, getBrowserKind, getDocumentFocus, isAndroid } from '../utils/browser'

export default function getBrowserInformation(): BrowserInformation {
Le0Developer marked this conversation as resolved.
Show resolved Hide resolved
return {
browserEngineKind: getBrowserEngineKind(),
browserKind: getBrowserKind(),
isAndroid: isAndroid(),
documentFocus: getDocumentFocus(),
}
}

Finesse marked this conversation as resolved.
Show resolved Hide resolved
interface BrowserInformation {
browserEngineKind: BrowserEngineKind
browserKind: BrowserKind
isAndroid: boolean
documentFocus: boolean
}
2 changes: 2 additions & 0 deletions src/sources/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import getAppVersion from './app_version'
import getBrowserInformation from './browser'
import getDocumentElementKeys from './document_element_keys'
import getErrorTrace from './error_trace'
import getEvalLength from './eval_length'
Expand All @@ -21,6 +22,7 @@ import checkDistinctiveProperties, { DistinctivePropertiesPayload } from './dist
export const sources = {
userAgent: getUserAgent,
appVersion: getAppVersion,
browser: getBrowserInformation,
rtt: getRTT,
windowSize: getWindowSize,
pluginsLength: getPluginsLength,
Expand Down