Skip to content

Commit ebb1b26

Browse files
authored
refactor!: Introduce IBrowserPool interface and utilize it in BrowserCrawler (#3669)
1 parent 0c1fbcf commit ebb1b26

13 files changed

Lines changed: 530 additions & 146 deletions

File tree

docs/upgrading/upgrading_v4.md

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,70 @@ const crawler = new BasicCrawler({
214214
});
215215
```
216216

217+
## Custom `BrowserPool` implementations via the `IBrowserPool` interface
218+
219+
Browser crawlers now accept any object implementing the new `IBrowserPool` interface as their `browserPool` option, not just instances of the built-in `BrowserPool`. The interface follows the classic acquire/release pattern, plus a pair of helpers for moving state between the crawling session and the page:
220+
221+
- **`newPage(options?)`** — opens a new page. An optional `session` can be passed as a best-effort hint — the pool may use it for proxy configuration, fingerprinting, etc., but nothing is guaranteed.
222+
- **`closePage(page, options?)`** — signals the pool that the caller is done with the page. If the optional `error` is a `SessionError`, the pool should purge all state associated with the session (e.g. retire the underlying browser).
223+
- **`extractPageState(page)`** — reads the relevant state (currently cookies) out of a page so the crawler can persist it back into the session.
224+
- **`injectPageState(page, state)`** — the counterpart to `extractPageState`; seeds a page with state (currently cookies) before navigation. Isolation between pages is best-effort and depends on the pool implementation.
225+
226+
Lifecycle (`destroy`) is the responsibility of whoever owns the pool: a custom pool you construct yourself is never owned by the crawler, so the crawler never tears it down. This makes it straightforward to plug in a remote browser farm, a session-aware pool, or another custom browser-management strategy without subclassing `BrowserPool`.
227+
228+
```typescript
229+
import { PuppeteerCrawler } from '@crawlee/puppeteer';
230+
import { BrowserPool, PuppeteerPlugin, type IBrowserPool } from '@crawlee/browser-pool';
231+
import puppeteer from 'puppeteer';
232+
233+
const sharedPool = new BrowserPool({ browserPlugins: [new PuppeteerPlugin(puppeteer)] });
234+
235+
const crawler = new PuppeteerCrawler({
236+
browserPool: sharedPool,
237+
requestHandler: async ({ page }) => {
238+
//
239+
},
240+
});
241+
242+
// You own `sharedPool` — destroy it yourself when you're done.
243+
await crawler.run();
244+
await sharedPool.destroy();
245+
```
246+
247+
## `BrowserCrawlingContext.browserController` has been removed
248+
249+
The `browserController` property is no longer part of the crawling context (`BrowserCrawlingContext`). Browser controller management is now fully internal to the pool — the crawler interacts with the pool only through the `IBrowserPool` interface (`newPage`, `closePage`, `extractPageState`, and `injectPageState`).
250+
251+
If you previously used `browserController` in your request handlers, here is how to migrate the most common patterns:
252+
253+
**Cookies** — Cookie injection and persistence are now handled automatically by the crawler and the pool. You no longer need to call `browserController.getCookies()` or `browserController.setCookies()` manually.
254+
255+
**Proxy info** — Access proxy information via `session.proxyInfo` instead of `browserController.launchContext.proxyUrl`. TLS-error handling moved along with it: the pool reads `session.proxyInfo.ignoreTlsErrors`, so there is no standalone `ignoreTlsErrors` page option anymore. If you need to disable TLS verification for some other reason, set `ignoreHTTPSErrors` (Playwright) / `acceptInsecureCerts` (Puppeteer) through the browser's `launchOptions`.
256+
257+
**Direct browser access** — If you need the raw browser or controller instance (e.g. for Puppeteer/Playwright-specific APIs), construct a `BrowserPool` yourself, pass it to the crawler, and reference it directly in your handler — no cast needed:
258+
259+
```typescript
260+
import { BrowserPool, PuppeteerPlugin } from '@crawlee/browser-pool';
261+
import { PuppeteerCrawler } from '@crawlee/puppeteer';
262+
import puppeteer from 'puppeteer';
263+
264+
const pool = new BrowserPool({ browserPlugins: [new PuppeteerPlugin(puppeteer)] });
265+
266+
const crawler = new PuppeteerCrawler({
267+
browserPool: pool,
268+
requestHandler: async ({ page }) => {
269+
const controller = pool.getBrowserControllerByPage(page);
270+
// controller.browser, controller.launchContext, etc.
271+
},
272+
});
273+
274+
await crawler.run();
275+
// You own the pool — tear it down yourself.
276+
await pool.destroy();
277+
```
278+
279+
Note that this couples your code to the built-in `BrowserPool` — custom `IBrowserPool` implementations may not expose controllers at all.
280+
217281
## `tieredProxyUrls` is removed from `ProxyConfiguration`
218282

219283
The `tieredProxyUrls` option has been removed, together with the `proxyTier` field on `ProxyInfo` and the `proxyTier` plumbing in `BrowserPool`. In v4 the `Session` is the main rotation unit - a session already carries its own proxy, cookies and error score, so the pool rotates the whole fingerprint when a session gets retired on a block.

packages/browser-crawler/src/internals/browser-crawler.ts

Lines changed: 61 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ import type {
3838
LaunchContext,
3939
} from '@crawlee/browser-pool';
4040
import { BrowserPool } from '@crawlee/browser-pool';
41-
import type { BatchAddRequestsResult, Cookie as CookieObject, ISession } from '@crawlee/types';
41+
import type { BatchAddRequestsResult, Cookie as CookieObject, IBrowserPool, ISession } from '@crawlee/types';
4242
import type { RobotsTxtFile } from '@crawlee/utils';
4343
import { CLOUDFLARE_RETRY_CSS_SELECTORS, RETRY_CSS_SELECTORS, sleep } from '@crawlee/utils';
4444
import ow from 'ow';
@@ -57,15 +57,9 @@ type ContextDifference<T, U> = Omit<U, keyof T> & Partial<U>;
5757
export interface BrowserCrawlingContext<
5858
Page extends CommonPage = CommonPage,
5959
Response extends BaseResponse = BaseResponse,
60-
ProvidedController = BrowserController,
6160
UserData extends Dictionary = Dictionary,
6261
GoToOptions extends Dictionary = Dictionary,
6362
> extends CrawlingContext<UserData> {
64-
/**
65-
* An instance of the {@apilink BrowserController} that manages the browser instance and provides access to its API.
66-
*/
67-
browserController: ProvidedController;
68-
6963
/**
7064
* The browser page object where the web page is loaded and rendered.
7165
*/
@@ -104,11 +98,9 @@ const readContextField = <T>(ctx: object, key: symbol): T => (ctx as Record<symb
10498
export interface BrowserCrawlerOptions<
10599
Page extends CommonPage = CommonPage,
106100
Response extends BaseResponse = BaseResponse,
107-
ProvidedController extends BrowserController = BrowserController,
108-
Context extends BrowserCrawlingContext<Page, Response, ProvidedController, Dictionary> = BrowserCrawlingContext<
101+
Context extends BrowserCrawlingContext<Page, Response, Dictionary> = BrowserCrawlingContext<
109102
Page,
110103
Response,
111-
ProvidedController,
112104
Dictionary
113105
>,
114106
ContextExtension = Dictionary<never>,
@@ -124,6 +116,13 @@ export interface BrowserCrawlerOptions<
124116
> {
125117
launchContext?: BrowserLaunchContext<any, any>;
126118

119+
/**
120+
* An existing browser pool instance to use. When provided, the crawler will use this pool directly instead of
121+
* constructing a new one from `browserPoolOptions`, enabling browser sharing across multiple crawlers. The crawler
122+
* will not tear down a shared pool — the caller is responsible for its lifecycle.
123+
*/
124+
browserPool?: IBrowserPool<Page>;
125+
127126
/**
128127
* Function that is called to process each request.
129128
*
@@ -134,7 +133,6 @@ export interface BrowserCrawlerOptions<
134133
* - {@apilink BrowserCrawlingContext.page|`page`} is an instance of the
135134
* Puppeteer [Page](https://pptr.dev/api/puppeteer.page) or
136135
* Playwright [Page](https://playwright.dev/docs/api/class-page);
137-
* - {@apilink BrowserCrawlingContext.browserController|`browserController`} is an instance of the {@apilink BrowserController};
138136
* - {@apilink BrowserCrawlingContext.response|`response`} is an instance of the
139137
* Puppeteer [Response](https://pptr.dev/api/puppeteer.httpresponse) or
140138
* Playwright [Response](https://playwright.dev/docs/api/class-response),
@@ -306,23 +304,30 @@ export interface BrowserCrawlerOptions<
306304
export abstract class BrowserCrawler<
307305
Page extends CommonPage = CommonPage,
308306
Response extends BaseResponse = BaseResponse,
309-
ProvidedController extends BrowserController = BrowserController,
310307
InternalBrowserPoolOptions extends BrowserPoolOptions = BrowserPoolOptions,
311308
LaunchOptions extends Dictionary | undefined = Dictionary,
312-
Context extends BrowserCrawlingContext<Page, Response, ProvidedController, Dictionary> = BrowserCrawlingContext<
309+
Context extends BrowserCrawlingContext<Page, Response, Dictionary> = BrowserCrawlingContext<
313310
Page,
314311
Response,
315-
ProvidedController,
316312
Dictionary
317313
>,
318314
ContextExtension = Dictionary<never>,
319315
ExtendedContext extends Context = Context & ContextExtension,
320316
GoToOptions extends Dictionary = Dictionary,
321317
> extends BasicCrawler<Context, ContextExtension, ExtendedContext> {
322318
/**
323-
* A reference to the underlying {@apilink BrowserPool} class that manages the crawler's browsers.
319+
* A reference to the underlying browser pool that manages the crawler's browsers. Typed as
320+
* {@apilink IBrowserPool} so custom implementations can be plugged in via the `browserPool` constructor option.
324321
*/
325-
browserPool: BrowserPool<InternalBrowserPoolOptions>;
322+
browserPool: IBrowserPool<Page>;
323+
324+
/**
325+
* Set when the crawler constructed its own {@apilink BrowserPool} (no `browserPool` option was provided).
326+
* Holds the same instance as `browserPool`, but typed as the concrete class so the crawler can call
327+
* lifecycle methods (`destroy`) that aren't part of {@apilink IBrowserPool}. A user-supplied pool is
328+
* never owned and never torn down by the crawler.
329+
*/
330+
private ownedBrowserPool?: BrowserPool<InternalBrowserPoolOptions>;
326331

327332
launchContext: BrowserLaunchContext<LaunchOptions, unknown>;
328333

@@ -343,7 +348,8 @@ export abstract class BrowserCrawler<
343348

344349
launchContext: ow.optional.object,
345350
headless: ow.optional.any(ow.boolean, ow.string),
346-
browserPoolOptions: ow.object,
351+
browserPool: ow.optional.object.validate(validators.browserPool),
352+
browserPoolOptions: ow.optional.object,
347353
saveResponseCookies: ow.optional.boolean,
348354
proxyConfiguration: ow.optional.object.validate(validators.proxyConfiguration),
349355
};
@@ -352,14 +358,7 @@ export abstract class BrowserCrawler<
352358
* All `BrowserCrawler` parameters are passed via an options object.
353359
*/
354360
protected constructor(
355-
options: BrowserCrawlerOptions<
356-
Page,
357-
Response,
358-
ProvidedController,
359-
Context,
360-
ContextExtension,
361-
ExtendedContext
362-
> & {
361+
options: BrowserCrawlerOptions<Page, Response, Context, ContextExtension, ExtendedContext> & {
363362
contextPipelineBuilder: () => ContextPipeline<CrawlingContext, Context>;
364363
},
365364
) {
@@ -368,6 +367,7 @@ export abstract class BrowserCrawler<
368367
navigationTimeoutSecs = 60,
369368
saveResponseCookies = true,
370369
launchContext = {},
370+
browserPool,
371371
browserPoolOptions,
372372
preNavigationHooks = [],
373373
postNavigationHooks = [],
@@ -422,43 +422,46 @@ export abstract class BrowserCrawler<
422422

423423
this.saveResponseCookies = saveResponseCookies;
424424

425+
if (browserPool) {
426+
this.browserPool = browserPool;
427+
return;
428+
}
429+
430+
const resolvedBrowserPoolOptions = browserPoolOptions ?? ({} as Partial<BrowserPoolOptions>);
431+
425432
if (launchContext?.userAgent) {
426-
if (browserPoolOptions.useFingerprints)
433+
if (resolvedBrowserPoolOptions.useFingerprints)
427434
this.log.info('Custom user agent provided, disabling automatic browser fingerprint injection!');
428-
browserPoolOptions.useFingerprints = false;
435+
resolvedBrowserPoolOptions.useFingerprints = false;
429436
}
430437

431-
this.browserPool = new BrowserPool<InternalBrowserPoolOptions>({
432-
...(browserPoolOptions as any),
438+
this.ownedBrowserPool = new BrowserPool<InternalBrowserPoolOptions>({
439+
...(resolvedBrowserPoolOptions as any),
433440
});
441+
this.browserPool = this.ownedBrowserPool as IBrowserPool<Page>;
434442
}
435443

436444
protected override buildContextPipeline(): ContextPipeline<
437445
CrawlingContext,
438-
BrowserCrawlingContext<Page, Response, ProvidedController, Dictionary>
446+
BrowserCrawlingContext<Page, Response, Dictionary>
439447
> {
440448
return ContextPipeline.create<CrawlingContext>().compose({
441449
action: this.preparePage.bind(this),
442450
cleanup: async (context: {
443451
page: Page;
444452
session: ISession;
445-
browserController: ProvidedController;
446453
registerDeferredCleanup: BasicCrawlingContext['registerDeferredCleanup'];
447454
}) => {
448455
context.registerDeferredCleanup(async () => {
449-
// In non-incognito mode the browser controller carries the session's cookies
450-
// and storage across pages. If the session is no longer usable, retire the
451-
// controller so its state can't leak into whichever session lands on it next.
452-
if (!context.session.isUsable()) {
453-
this.browserPool.retireBrowserController(
454-
context.browserController as Parameters<
455-
BrowserPool<InternalBrowserPoolOptions>['retireBrowserController']
456-
>[0],
456+
const error = !context.session.isUsable()
457+
? new SessionError('Session is no longer usable')
458+
: undefined;
459+
460+
await this.browserPool
461+
.closePage(context.page, { error })
462+
.catch((closeError: Error) =>
463+
this.log.debug('Error while closing page', { error: closeError }),
457464
);
458-
}
459-
await context.page
460-
.close()
461-
.catch((error: Error) => this.log.debug('Error while closing page', { error }));
462465
});
463466
},
464467
});
@@ -473,9 +476,7 @@ export abstract class BrowserCrawler<
473476
return foundSelectors.length > 0 ? foundSelectors : null;
474477
}
475478

476-
protected async isRequestBlocked(
477-
crawlingContext: BrowserCrawlingContext<Page, Response, ProvidedController>,
478-
): Promise<string | false> {
479+
protected async isRequestBlocked(crawlingContext: BrowserCrawlingContext<Page, Response>): Promise<string | false> {
479480
const { page, response } = crawlingContext;
480481

481482
// Cloudflare specific heuristic - wait 5 seconds if we get a 403 for the JS challenge to load / resolve.
@@ -500,27 +501,13 @@ export abstract class BrowserCrawler<
500501

501502
private async preparePage(
502503
crawlingContext: CrawlingContext,
503-
): Promise<
504-
ContextDifference<CrawlingContext, BrowserCrawlingContext<Page, Response, ProvidedController, Dictionary>>
505-
> {
506-
const newPageOptions: Dictionary = {
504+
): Promise<ContextDifference<CrawlingContext, BrowserCrawlingContext<Page, Response, Dictionary>>> {
505+
const page = await this.browserPool.newPage({
507506
id: crawlingContext.id,
508-
};
509-
510-
if (crawlingContext.session?.proxyInfo) {
511-
const proxyInfo = crawlingContext.session.proxyInfo;
512-
513-
newPageOptions.proxyUrl = proxyInfo?.url;
514-
newPageOptions.ignoreTlsErrors = proxyInfo?.ignoreTlsErrors;
515-
}
516-
517-
const page = (await this.browserPool.newPage(newPageOptions)) as Page;
507+
session: crawlingContext.session,
508+
});
518509
tryCancel();
519510

520-
const browserControllerInstance = this.browserPool.getBrowserControllerByPage(
521-
page as any,
522-
) as ProvidedController;
523-
524511
const contextEnqueueLinks = crawlingContext.enqueueLinks;
525512

526513
return {
@@ -533,7 +520,6 @@ export abstract class BrowserCrawler<
533520
get gotoOptions(): Dictionary {
534521
throw new Error('The `gotoOptions` property is not available until `prepareNavigation` runs.');
535522
},
536-
browserController: browserControllerInstance,
537523
enqueueLinks: async (enqueueOptions: EnqueueLinksOptions = {}) => {
538524
return (await browserCrawlerEnqueueLinks({
539525
options: {
@@ -622,7 +608,7 @@ export abstract class BrowserCrawler<
622608

623609
// TODO: Should we save the cookies also after/only the handle page?
624610
if (this.saveResponseCookies && crawlingContext.session) {
625-
const cookies = await crawlingContext.browserController.getCookies(crawlingContext.page);
611+
const { cookies } = await this.browserPool.extractPageState(crawlingContext.page);
626612
tryCancel();
627613
const url = crawlingContext.request.loadedUrl!;
628614
for (const cookie of cookies) {
@@ -639,9 +625,7 @@ export abstract class BrowserCrawler<
639625
return { request: crawlingContext.request as LoadedRequest<Request> } as Partial<Context>;
640626
}
641627

642-
private async handleBlockedRequestByContent(
643-
crawlingContext: BrowserCrawlingContext<Page, Response, ProvidedController>,
644-
) {
628+
private async handleBlockedRequestByContent(crawlingContext: BrowserCrawlingContext<Page, Response>) {
645629
if (this.retryOnBlocked) {
646630
const error = await this.isRequestBlocked(crawlingContext);
647631
if (error) throw new SessionError(error);
@@ -656,20 +640,19 @@ export abstract class BrowserCrawler<
656640
}
657641

658642
protected async _applyCookies(
659-
{ session, request, page, browserController }: BrowserCrawlingContext,
643+
{ session, request, page }: BrowserCrawlingContext<Page, Response>,
660644
preHooksCookies: string,
661645
postHooksCookies: string,
662646
) {
663647
const sessionCookie = session?.cookieJar.getCookiesSync(request.url).map(toughCookieToBrowserPoolCookie) ?? [];
664648
const parsedPreHooksCookies = preHooksCookies.split(/ *; */).map((c) => cookieStringToToughCookie(c));
665649
const parsedPostHooksCookies = postHooksCookies.split(/ *; */).map((c) => cookieStringToToughCookie(c));
666650

667-
await browserController.setCookies(
668-
page,
669-
[...sessionCookie, ...parsedPreHooksCookies, ...parsedPostHooksCookies]
670-
.filter((c): c is CookieObject => typeof c !== 'undefined' && c !== null)
671-
.map((c) => ({ ...c, url: c.domain ? undefined : request.url })),
672-
);
651+
const cookies = [...sessionCookie, ...parsedPreHooksCookies, ...parsedPostHooksCookies]
652+
.filter((c): c is CookieObject => typeof c !== 'undefined' && c !== null)
653+
.map((c) => ({ ...c, url: c.domain ? undefined : request.url }));
654+
655+
await this.browserPool.injectPageState(page, { cookies });
673656
}
674657

675658
/**
@@ -695,7 +678,7 @@ export abstract class BrowserCrawler<
695678
}
696679

697680
protected abstract _navigationHandler(
698-
crawlingContext: BrowserCrawlingContext<Page, Response, ProvidedController>,
681+
crawlingContext: BrowserCrawlingContext<Page, Response>,
699682
gotoOptions: GoToOptions,
700683
): Promise<Context['response'] | null | undefined>;
701684

@@ -735,7 +718,7 @@ export abstract class BrowserCrawler<
735718
* @ignore
736719
*/
737720
override async teardown(): Promise<void> {
738-
await this.browserPool.destroy();
721+
await this.ownedBrowserPool?.destroy();
739722
await super.teardown();
740723
}
741724
}

0 commit comments

Comments
 (0)