Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
51876d2
fix(core): make Session.retire() permanently mark the session as unus…
barjin May 11, 2026
81dfe06
refactor(browser-crawler): retire browsers via context pipeline cleanup
barjin May 11, 2026
bcd87b0
refactor(core)!: drop EventEmitter dependency from SessionPool
barjin May 11, 2026
f593fa2
feat(types): add SessionFingerprint interface
barjin May 11, 2026
bb9ed89
feat(core): store fingerprint on Session
barjin May 11, 2026
1caf6be
feat(browser-pool): persist fingerprint on session
barjin May 11, 2026
05726cc
Merge branch 'v4' into feat/session-fingerprint
barjin May 26, 2026
690a8e5
chore: revert unwanted changes in upgrading guide
barjin May 26, 2026
1dfcda2
feat(http-client): thread session fingerprint through sendRequest
barjin May 27, 2026
3cfd2d1
feat(impit-client): apply session fingerprint best-effort
barjin May 27, 2026
546adfc
refactor(http-client): pass session through CustomFetchOptions
barjin May 27, 2026
195d4e9
refactor(types)!: narrow SessionFingerprint to browser/platform/device
barjin May 27, 2026
f609f0c
refactor(types)!: rename browserFingerprint to details
barjin May 27, 2026
c810ba4
refactor(http-client): pass fingerprint instead of session to fetch
barjin May 27, 2026
ed7f86c
feat(impit-client): bump impit to ^0.14.0 and randomize impersonation…
barjin May 27, 2026
8888ebc
feat(core): default SessionFingerprint at session creation
barjin May 27, 2026
a9fc52b
refactor(types)!: make SessionFingerprint hints-only
barjin May 27, 2026
be3b26e
Merge branch 'v4' into feat/session-fingerprint
barjin Jun 2, 2026
fa3ae2d
chore: bump `impit`, update docs
barjin Jun 2, 2026
3ac943f
feat: discard host OS with `Session` fingerprints
barjin Jun 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 21 additions & 8 deletions packages/browser-pool/src/fingerprinting/hooks.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { ISession, SessionFingerprint } from '@crawlee/types';
import type { BrowserFingerprintWithHeaders } from 'fingerprint-generator';
import type { FingerprintInjector } from 'fingerprint-injector';

Expand All @@ -6,8 +7,22 @@ import type { BrowserPool } from '../browser-pool.js';
import type { LaunchContext } from '../launch-context.js';
import { PlaywrightPlugin } from '../playwright/playwright-plugin.js';
import { PuppeteerPlugin } from '../puppeteer/puppeteer-plugin.js';
import type { FingerprintGeneratorOptions } from './types.js';
import { getGeneratorDefaultOptions } from './utils.js';

function applySessionHints(
base: FingerprintGeneratorOptions,
fingerprint?: SessionFingerprint,
): FingerprintGeneratorOptions {
if (!fingerprint) return base;
return {
...base,
...(fingerprint.browser ? { browsers: [{ name: fingerprint.browser }] } : {}),
...(fingerprint.platform ? { operatingSystems: [fingerprint.platform] } : {}),
...(fingerprint.device ? { devices: [fingerprint.device] } : {}),
};
}

/**
* @internal
*/
Expand All @@ -20,21 +35,19 @@ export function createFingerprintPreLaunchHook(browserPool: BrowserPool<any, any

return (_pageId: string, launchContext: LaunchContext) => {
const { useIncognitoPages } = launchContext;
const cacheKey = (launchContext.session as { id: string } | undefined)?.id ?? launchContext.proxyUrl;
const session = launchContext.session as ISession | undefined;
const cacheKey = session?.id ?? launchContext.proxyUrl;
const { launchOptions }: { launchOptions: any } = launchContext;

// If no options are passed we try to pass best default options as possible to match browser and OS.
const fingerprintGeneratorFinalOptions =
fingerprintGeneratorOptions || getGeneratorDefaultOptions(launchContext);
let fingerprint: BrowserFingerprintWithHeaders;

if (cacheKey && fingerprintCache?.has(cacheKey)) {
fingerprint = fingerprintCache.get(cacheKey)!;
} else if (cacheKey) {
fingerprint = fingerprintGenerator!.getFingerprint(fingerprintGeneratorFinalOptions);
fingerprintCache?.set(cacheKey, fingerprint);
} else {
fingerprint = fingerprintGenerator!.getFingerprint(fingerprintGeneratorFinalOptions);
const baseOptions = fingerprintGeneratorOptions || getGeneratorDefaultOptions(launchContext);
const finalOptions = applySessionHints(baseOptions, session?.fingerprint);
fingerprint = fingerprintGenerator!.getFingerprint(finalOptions);
if (cacheKey) fingerprintCache?.set(cacheKey, fingerprint);
}

launchContext.extend({ fingerprint });
Expand Down
33 changes: 33 additions & 0 deletions packages/core/src/session_pool/fingerprint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type { SessionFingerprint } from '@crawlee/types';

/**
* (browser, platform, device) combinations that correspond to setups people
* actually run. Anything not listed here (e.g. `edge` on android, `safari` on
* windows, `desktop` mobile platforms) is left out so a randomized default
* never produces a fingerprint that would itself be a giveaway.
*/
const PROFILES_BY_PLATFORM = [
{ browser: 'chrome', platform: 'windows', device: 'desktop' },
{ browser: 'firefox', platform: 'windows', device: 'desktop' },
{ browser: 'edge', platform: 'windows', device: 'desktop' },
{ browser: 'chrome', platform: 'macos', device: 'desktop' },
{ browser: 'firefox', platform: 'macos', device: 'desktop' },
{ browser: 'safari', platform: 'macos', device: 'desktop' },
{ browser: 'edge', platform: 'macos', device: 'desktop' },
{ browser: 'chrome', platform: 'linux', device: 'desktop' },
{ browser: 'firefox', platform: 'linux', device: 'desktop' },
{ browser: 'chrome', platform: 'android', device: 'mobile' },
{ browser: 'firefox', platform: 'android', device: 'mobile' },
{ browser: 'safari', platform: 'ios', device: 'mobile' },
] as const;

/**
* Build a {@apilink SessionFingerprint} whose `platform` matches the host OS

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Is there a good reason to try to match the host OS? Is it not possible to impersonate a completely different one well enough?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This is legacy Crawlee v3 behaviour

const getOperatingSystem = (): OperatingSystemsName => {
const { platform } = process;
switch (platform) {
case 'win32':
// platform is win32 even for 64-bit
return OperatingSystemsName.windows;
case 'darwin':
return OperatingSystemsName.macos;
default:
// consider everything else a linux
return OperatingSystemsName.linux;
}
};

With e.g. impit, you can emulate any OS well enough; it's not the case with browsers (e.g., system fonts leak the OS, and there isn't much you can do about it).

Also, since the interface is now explicitly best-effort, I wouldn't see this as much of an issue, really ⬇️

* These are *suggestions*, not requirements: each client applies what it can

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is legacy Crawlee v3 behaviour

the interface is now explicitly best-effort, I wouldn't see this as much of an issue, really

These two points actually make me think that the default fingerprint should just not mention the OS at all and let the "consumer" decide.

Alternatively, we could rotate the OS so that there can be more variety in the generated fingerprints. And the consumer is still free to ignore the hint if they don't know how to impersonate a particular OS.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fair, you're right that the OS-stickyness is ratherBrowserPool's problem. I dropped the host-OS check here, let's see if this breaks anything

* and whose `browser`/`device` are randomized within the realistic profiles for
* that platform. Used by {@apilink SessionPool} as the default fingerprint for
* freshly created sessions; callers can override by passing their own
* `fingerprint` in `sessionOptions`.
*/
export function createDefaultSessionFingerprint(): SessionFingerprint {
return { ...PROFILES_BY_PLATFORM[Math.floor(Math.random() * PROFILES_BY_PLATFORM.length)] };
}
22 changes: 21 additions & 1 deletion packages/core/src/session_pool/session.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Dictionary, ISession, ProxyInfo, SessionState } from '@crawlee/types';
import type { Dictionary, ISession, ProxyInfo, SessionFingerprint, SessionState } from '@crawlee/types';
import ow from 'ow';
import { CookieJar } from 'tough-cookie';

Expand Down Expand Up @@ -68,6 +68,13 @@ export interface SessionOptions {
errorScore?: number;
cookieJar?: CookieJar;
proxyInfo?: ProxyInfo;

/**
* Browser / HTTP client fingerprint tied to this session. Backends use this to make
* repeated requests with the same session look consistent (same user-agent, headers,
* TLS profile). See {@apilink SessionFingerprint}.
*/
fingerprint?: SessionFingerprint;
}

/**
Expand All @@ -89,6 +96,7 @@ export class Session implements ISession {
private _retired = false;
private _proxyInfo?: ProxyInfo;
private _cookieJar: CookieJar;
private _fingerprint?: SessionFingerprint;
private log: CrawleeLogger;

get errorScore() {
Expand Down Expand Up @@ -127,6 +135,14 @@ export class Session implements ISession {
return this._proxyInfo;
}

get fingerprint(): SessionFingerprint | undefined {
return this._fingerprint;
}

set fingerprint(fingerprint: SessionFingerprint | undefined) {
this._fingerprint = fingerprint;
}

/**
* `true` once {@apilink Session.retire|`retire()`} has been called. Retirement is terminal:
* a retired session is never picked by the pool and cannot be revived via `markGood()`.
Expand Down Expand Up @@ -156,6 +172,7 @@ export class Session implements ISession {
maxUsageCount: ow.optional.number,
retired: ow.optional.boolean,
log: ow.optional.object,
fingerprint: ow.optional.object,
}),
);

Expand All @@ -173,6 +190,7 @@ export class Session implements ISession {
maxUsageCount = 50,
retired = false,
log = serviceLocator.getLogger(),
fingerprint,
} = options;

const { expiresAt = getDefaultCookieExpirationDate(maxAgeSecs) } = options;
Expand All @@ -181,6 +199,7 @@ export class Session implements ISession {

this._cookieJar = (cookieJar.setCookie as unknown) ? cookieJar : CookieJar.fromJSON(JSON.stringify(cookieJar));
this._proxyInfo = proxyInfo;
this._fingerprint = fingerprint;
this.id = id;
this.userData = userData;
this._maxErrorScore = maxErrorScore;
Expand Down Expand Up @@ -252,6 +271,7 @@ export class Session implements ISession {
cookieJar: this.cookieJar.toJSON()!,
proxyInfo: this._proxyInfo,
userData: this.userData,
fingerprint: this._fingerprint,
maxErrorScore: this.maxErrorScore,
errorScoreDecrement: this.errorScoreDecrement,
expiresAt: this.expiresAt.toISOString(),
Expand Down
13 changes: 12 additions & 1 deletion packages/core/src/session_pool/session_pool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type { CrawleeLogger } from '../log.js';
import { serviceLocator } from '../service_locator.js';
import { KeyValueStore } from '../storages/key_value_store.js';
import { MAX_POOL_SIZE, PERSIST_STATE_KEY } from './consts.js';
import { createDefaultSessionFingerprint } from './fingerprint.js';
import type { SessionOptions } from './session.js';
import { Session } from './session.js';

Expand Down Expand Up @@ -437,9 +438,19 @@ export class SessionPool implements ISessionPool {
/**
* Invokes `createSessionFunction` with `sessionOptions` already merged from pool-wide defaults and
* the supplied per-call overrides, so custom implementations don't need to spread `pool.sessionOptions` themselves.
*
* A default {@apilink SessionFingerprint} is generated up front (host OS as
* `platform`, a random valid `browser`/`device` for that platform). Pool-wide
* and per-call options override it, and a persisted fingerprint coming
* through `_maybeLoadSessionPool` naturally wins because it arrives in
* `perCallOptions`.
*/
private async _invokeCreateSessionFunction(perCallOptions?: SessionOptions): Promise<Session> {
const sessionOptions = { ...this.sessionOptions, ...perCallOptions };
const sessionOptions: SessionOptions = {
fingerprint: createDefaultSessionFingerprint(),
...this.sessionOptions,
...perCallOptions,
};
return this.createSessionFunction({ sessionOptions });
}

Expand Down
44 changes: 41 additions & 3 deletions packages/http-client/src/base-http-client.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,38 @@
import type { BaseHttpClient as BaseHttpClientInterface, CrawleeLogger, SendRequestOptions } from '@crawlee/types';
import type {
BaseHttpClient as BaseHttpClientInterface,
CrawleeLogger,
SendRequestOptions,
SessionFingerprint,
} from '@crawlee/types';
import { CookieJar } from 'tough-cookie';

/**
* Per-request options handed to a concrete client's `fetch` implementation.
*/
export interface CustomFetchOptions {
/**
* Effective proxy URL for this request. `sendRequest` populates this from
* the explicit `SendRequestOptions.proxyUrl` override when set, falling back
* to `session.proxyInfo.url`.
*/
proxyUrl?: string;

/**
* Effective cookie jar for this request. `sendRequest` populates this from
* the explicit `SendRequestOptions.cookieJar` override when set, falling
* back to `session.cookieJar` (or a fresh jar when neither is provided).
*/
cookieJar?: CookieJar;

/**
* Hints about which browser-like setup this request should be impersonating —
* `browser`, `platform`, `device`, and an opaque `details` slot for richer
* payloads (e.g. a full browser fingerprint from `fingerprint-generator`).
* These are *suggestions*, not requirements: each client applies what it can
* (e.g. impit maps `browser` to its impersonation profile) and ignores the
* rest on a best-effort basis. Sourced from `SendRequestOptions.session.fingerprint`.
*/
fingerprint?: SessionFingerprint;
}

/**
Expand Down Expand Up @@ -74,11 +104,17 @@ export abstract class BaseHttpClient implements BaseHttpClientInterface {
proxyUrl?: string;
cookieJar: CookieJar;
signal?: AbortSignal;
fingerprint?: SessionFingerprint;
} {
const proxyUrl = options?.proxyUrl ?? options?.session?.proxyInfo?.url;
const cookieJar = options?.cookieJar ?? options?.session?.cookieJar ?? new CookieJar();
const signal = this.createAbortSignal(options?.signal, options?.timeoutMillis);
return { proxyUrl, cookieJar: cookieJar as CookieJar, signal };
return {
proxyUrl,
cookieJar: cookieJar as CookieJar,
signal,
fingerprint: options?.session?.fingerprint,
};
}

private createAbortSignal(signal?: AbortSignal, timeoutMillis?: number): AbortSignal | undefined {
Expand Down Expand Up @@ -135,7 +171,7 @@ export abstract class BaseHttpClient implements BaseHttpClientInterface {
let currentRequest = initialRequest;
let redirectCount = 0;

const { proxyUrl, cookieJar, signal } = this.resolveRequestContext(options);
const { proxyUrl, cookieJar, signal, fingerprint } = this.resolveRequestContext(options);
currentRequest = initialRequest.clone();

while (true) {
Expand All @@ -144,6 +180,8 @@ export abstract class BaseHttpClient implements BaseHttpClientInterface {
const response = await this.fetch(currentRequest, {
signal,
proxyUrl,
cookieJar,
fingerprint,
redirect: 'manual',
});

Expand Down
2 changes: 1 addition & 1 deletion packages/impit-client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@
"@apify/datastructures": "^2.0.3",
"@crawlee/http-client": "workspace:*",
"@crawlee/types": "workspace:*",
"impit": "^0.9.0",
"impit": "^0.14.1",
"tough-cookie": "^6.0.0"
}
}
50 changes: 47 additions & 3 deletions packages/impit-client/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,32 @@
import type { CustomFetchOptions } from '@crawlee/http-client';
import { BaseHttpClient, ResponseWithUrl } from '@crawlee/http-client';
import type { CrawleeLogger } from '@crawlee/types';
import { Impit, type ImpitOptions } from 'impit';
import type { CrawleeLogger, SessionFingerprint } from '@crawlee/types';
import { Impit, type Browser as ImpitBrowser, type ImpitOptions } from 'impit';
import type { CookieJar as ToughCookieJar } from 'tough-cookie';

import { LruCache } from '@apify/datastructures';

// Concrete impit impersonation profiles per browser family. The plain `chrome` /
// `firefox` aliases fall back to the oldest available version, which is a
// fingerprint giveaway — we pick one of these explicitly instead. Keep in sync
// with impit's `Browser` type when bumping the dependency.
const IMPIT_VERSIONS_BY_BROWSER: Partial<Record<NonNullable<SessionFingerprint['browser']>, ImpitBrowser[]>> = {
chrome: [
'chrome100',
'chrome101',
'chrome104',
'chrome107',
'chrome110',
'chrome116',
'chrome124',
'chrome125',
'chrome131',
'chrome136',
'chrome142',
],
firefox: ['firefox128', 'firefox133', 'firefox135', 'firefox144'],
};

export const Browser = {
'Chrome': 'chrome',
'Firefox': 'firefox',
Expand All @@ -25,6 +46,13 @@ export class ImpitHttpClient extends BaseHttpClient {
*/
private clientCache: LruCache<{ client: Impit; cookieJar: ToughCookieJar }> = new LruCache({ maxLength: 10 });

/**
* Stable impit impersonation version per fingerprint object, so the same
* session keeps impersonating the same browser version across requests
* instead of rerolling on every call.
*/
private impitBrowserByFingerprint = new WeakMap<SessionFingerprint, ImpitBrowser>();

private getClient(options: ImpitOptions) {
const { cookieJar, ...rest } = options;

Expand All @@ -50,10 +78,13 @@ export class ImpitHttpClient extends BaseHttpClient {
* @inheritDoc
*/
async fetch(request: Request, options?: RequestInit & CustomFetchOptions): Promise<Response> {
const { proxyUrl, redirect, signal } = options ?? {};
const { proxyUrl, redirect, signal, fingerprint } = options ?? {};

const impitBrowser = this.resolveImpitBrowser(fingerprint);

const impit = this.getClient({
...this.impitOptions,
...(impitBrowser ? { browser: impitBrowser } : {}),
Comment thread
barjin marked this conversation as resolved.
proxyUrl,
followRedirects: redirect === 'follow',
});
Expand All @@ -63,4 +94,17 @@ export class ImpitHttpClient extends BaseHttpClient {
// todo - cast shouldn't be needed here, impit returns `Uint8Array`
return new ResponseWithUrl(response.body, response);
}

private resolveImpitBrowser(fingerprint?: SessionFingerprint): ImpitBrowser | undefined {
if (!fingerprint?.browser) return undefined;
const versions = IMPIT_VERSIONS_BY_BROWSER[fingerprint.browser];
if (!versions?.length) return undefined;

const cached = this.impitBrowserByFingerprint.get(fingerprint);
if (cached) return cached;

const picked = versions[Math.floor(Math.random() * versions.length)];
this.impitBrowserByFingerprint.set(fingerprint, picked);
return picked;
}
}
Loading
Loading