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 ComponentDict, K extends AbstractDetectorDict<T>>(
components: T,
detectors: K,
): [DetectionDict<K>, BotDetectionResult] {
const detections = {} as DetectionDict<K>
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
}
82 changes: 4 additions & 78 deletions src/detector.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,7 @@
import { BotDetectionResult, BotDetectorInterface, ComponentDict, DetectionDict } from './types'
import { collect, detect } from './api'
import { detectors } from './detectors'
import { sources } from './sources'
import {
BotdError,
BotDetectionResult,
BotDetectorInterface,
BotKind,
Component,
ComponentDict,
DetectionDict,
State,
} from './types'

/**
* Class representing a bot detector.
Expand All @@ -30,16 +22,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 +30,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
}
}
26 changes: 16 additions & 10 deletions src/detectors/eval_length.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,24 @@
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,
browserKind,
browserEngineKind,
}: ComponentDict): DetectorResponse {
if (
evalLength.state !== State.Success ||
browserKind.state !== State.Success ||
browserEngineKind.state !== State.Success
)
return

const length = evalLength.value
const browser = getBrowserKind()
const browserEngine = getBrowserEngineKind()
if (browserEngine == BrowserEngineKind.Unknown) {
if (browserEngineKind.value === BrowserEngineKind.Unknown)
return false
}
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.value)) ||
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please, remove .value from enums.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also @Le0Developer please, merge new changes from main

(length === 39 && !arrayIncludes([BrowserKind.IE], browserKind.value)) ||
(length === 33 && !arrayIncludes([BrowserEngineKind.Chromium], browserEngineKind.value))
)
}
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,
browserKind,
}: ComponentDict): DetectorResponse {
if (browserKind.state !== State.Success || browserKind.value !== BrowserKind.Chrome) return false

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

export function detectPluginsLengthInconsistency({ pluginsLength }: ComponentDict): DetectorResponse {
if (pluginsLength.state !== State.Success) return
const browserKind = getBrowserKind()
const browserEngineKind = getBrowserEngineKind()
if (browserKind !== BrowserKind.Chrome || isAndroid() || browserEngineKind !== BrowserEngineKind.Chromium) return
export function detectPluginsLengthInconsistency({
pluginsLength,
android,
browserKind,
browserEngineKind,
}: ComponentDict): DetectorResponse {
if (
pluginsLength.state !== State.Success ||
android.state !== State.Success ||
browserKind.state !== State.Success ||
browserEngineKind.state !== State.Success
)
return
if (
browserKind.value !== BrowserKind.Chrome ||
android.value ||
browserEngineKind.value !== BrowserEngineKind.Chromium
)
return
if (pluginsLength.value === 0) return BotKind.HeadlessChrome
}
14 changes: 6 additions & 8 deletions src/detectors/product_sub.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
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, browserKind }: ComponentDict): DetectorResponse {
if (productSub.state !== State.Success || browserKind.state !== State.Success) return false
if (
(browserKind === BrowserKind.Chrome ||
browserKind === BrowserKind.Safari ||
browserKind === BrowserKind.Opera ||
browserKind === BrowserKind.WeChat) &&
(browserKind.value === BrowserKind.Chrome ||
browserKind.value === BrowserKind.Safari ||
browserKind.value === BrowserKind.Opera ||
browserKind.value === BrowserKind.WeChat) &&
productSub.value !== '20030107'
)
return BotKind.Unknown
Expand Down
7 changes: 3 additions & 4 deletions src/detectors/rtt.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
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, android }: ComponentDict): DetectorResponse {
if (rtt.state !== State.Success || android.state !== State.Success) return
// Rtt is 0 on android webview
if (isAndroid()) return
if (android.value) return
if (rtt.value === 0) return BotKind.HeadlessChrome
}
7 changes: 3 additions & 4 deletions src/detectors/window_size.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
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, documentFocus }: ComponentDict): DetectorResponse {
if (windowSize.state !== State.Success || documentFocus.state !== State.Success) return false
const { outerWidth, outerHeight } = windowSize.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.value) 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'

/**
* Sends an unpersonalized AJAX request to collect installation statistics
Expand Down Expand Up @@ -37,6 +39,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
5 changes: 5 additions & 0 deletions src/sources/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,13 @@ import getWebGL from './webgl'
import getWindowExternal from './window_external'
import getWindowSize, { WindowSizePayload } from './window_size'
import checkDistinctiveProperties, { DistinctivePropertiesPayload } from './distinctive_properties'
import { getBrowserEngineKind, getBrowserKind, getDocumentFocus, isAndroid } from '../utils/browser'

export const sources = {
android: isAndroid,
browserKind: getBrowserKind,
browserEngineKind: getBrowserEngineKind,
documentFocus: getDocumentFocus,
userAgent: getUserAgent,
appVersion: getAppVersion,
rtt: getRTT,
Expand Down
9 changes: 6 additions & 3 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,11 +87,11 @@ export type DefaultDetectorDict = typeof detectors
*/
export type SourceResponse<T> = T extends (...args: any[]) => any ? Awaited<ReturnType<T>> : T

export type AbstractDetector = (...args: any[]) => DetectorResponse
export type AbstractDetector<T> = (components: T) => DetectorResponse

export type AbstractSourceDict = Record<string, SourceResponse<any>>

export type AbstractDetectorDict = Record<string, AbstractDetector>
export type AbstractDetectorDict<T> = Record<string, AbstractDetector<T>>

export type AbstractComponentDict = Record<string, Component<any>>

Expand All @@ -100,7 +100,10 @@ export type AbstractDetectionsDict = Record<string, BotDetectionResult>
/**
* Represents a dictionary of detectors detection.
*/
export type DetectionDict<T extends AbstractDetectorDict = DefaultDetectorDict> = Record<keyof T, BotDetectionResult>
export type DetectionDict<T extends AbstractDetectorDict<any> = DefaultDetectorDict> = Record<
keyof T,
BotDetectionResult
>

/**
* Dictionary of components.
Expand Down