@crawlee/browser 4.0.0-beta.99 → 4.0.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
- import { BasicCrawler, browserPoolCookieToToughCookie, ContextPipeline, cookieStringToToughCookie, enqueueLinks, NavigationSkippedError, OwnedOrInjected, remainingNavigationWindowMillis, RequestState, resolveBaseUrlForEnqueueLinksFiltering, SessionError, toughCookieToBrowserPoolCookie, tryAbsoluteURL, validators, } from '@crawlee/basic';
2
- import { BrowserPool, RemoteBrowserPool } from '@crawlee/browser-pool';
3
- import { CLOUDFLARE_RETRY_CSS_SELECTORS, RETRY_CSS_SELECTORS, sleep } from '@crawlee/utils';
4
- import ow from 'ow';
1
+ import { BasicCrawler, browserPoolCookieToToughCookie, ContextPipeline, cookieStringToToughCookie, EnqueueStrategy, NavigationSkippedError, OwnedOrInjected, remainingNavigationWindowMillis, RequestState, ContextPipelineInitializationError, RequestHandlerError, RequestThrottledError, resolveBaseUrlForEnqueueLinksFiltering, SessionError, SessionRetiredError, toughCookieToBrowserPoolCookie, validators, } from '@crawlee/basic';
2
+ import { assertBrowserPoolNotConfigured, CLOUDFLARE_RETRY_CSS_SELECTORS, parseArgument, RETRY_CSS_SELECTORS, schemas, tryAbsoluteURL, } from '@crawlee/utils/internal';
3
+ import { sleep } from '@crawlee/utils';
4
+ import { z } from 'zod';
5
5
  import { addTimeoutToPromise, TimeoutError, tryCancel } from '@apify/timeout';
6
6
  const COOKIES_BEFORE_HOOKS = Symbol('cookiesBeforeHooks');
7
7
  const readContextField = (ctx, key) => ctx[key];
@@ -58,43 +58,50 @@ function isNavigationTimeoutError(error) {
58
58
  */
59
59
  export class BrowserCrawler extends BasicCrawler {
60
60
  /** Backs the {@link BrowserCrawler.browserPool|`browserPool`} getter. */
61
- browserPoolDep;
61
+ #browserPoolDep;
62
62
  /**
63
63
  * A reference to the underlying browser pool that manages the crawler's browsers. Typed as
64
64
  * {@link IBrowserPool} so custom implementations can be plugged in via the `browserPool` constructor option.
65
65
  */
66
66
  get browserPool() {
67
- return this.browserPoolDep.value;
67
+ return this.#browserPoolDep.value;
68
68
  }
69
- launchContext;
70
69
  ignoreShadowRoots;
71
70
  ignoreIframes;
72
- navigationTimeoutMillis;
73
- preNavigationHooks;
74
- postNavigationHooks;
75
- saveResponseCookies;
71
+ #navigationTimeoutMillis;
72
+ #preNavigationHooks;
73
+ #postNavigationHooks;
74
+ #saveResponseCookies;
75
+ /**
76
+ * @internal
77
+ */
76
78
  static optionsShape = {
77
79
  ...BasicCrawler.optionsShape,
78
- navigationTimeoutSecs: ow.optional.number.greaterThan(0),
79
- preNavigationHooks: ow.optional.array,
80
- postNavigationHooks: ow.optional.array,
81
- launchContext: ow.optional.object,
82
- headless: ow.optional.any(ow.boolean, ow.string),
83
- browserPool: ow.optional.object.validate(validators.browserPool),
84
- remoteBrowser: ow.optional.object,
85
- browserPoolOptions: ow.optional.object,
86
- saveResponseCookies: ow.optional.boolean,
87
- proxyConfiguration: ow.optional.object.validate(validators.proxyConfiguration),
80
+ navigationTimeoutSecs: schemas.anyNumber
81
+ .refine((value) => value > 0, 'Expected a number greater than 0')
82
+ .default(60),
83
+ preNavigationHooks: schemas.anyArray.default(() => []),
84
+ postNavigationHooks: schemas.anyArray.default(() => []),
85
+ browserPool: validators.browserPool.optional(),
86
+ browserPoolBuilder: schemas.anyFunction.optional(),
87
+ remoteBrowser: schemas.anyObject.optional(),
88
+ saveResponseCookies: z.boolean().default(true),
89
+ proxyConfiguration: validators.proxyConfiguration.optional(),
90
+ ignoreIframes: z.boolean().default(false),
91
+ ignoreShadowRoots: z.boolean().default(false),
88
92
  };
93
+ /** @internal */
94
+ static optionsSchema = z.strictObject(BrowserCrawler.optionsShape);
89
95
  /**
90
96
  * All `BrowserCrawler` parameters are passed via an options object.
97
+ * @internal
91
98
  */
92
99
  constructor(options) {
93
- ow(options, 'BrowserCrawlerOptions', ow.object.exactShape(BrowserCrawler.optionsShape));
94
- const { navigationTimeoutSecs = 60, saveResponseCookies = true, launchContext = {}, browserPool, remoteBrowser, browserPoolOptions, preNavigationHooks = [], postNavigationHooks = [], headless, ignoreIframes = false, ignoreShadowRoots = false, contextPipelineBuilder, extendContext, ...basicCrawlerOptions } = options;
95
- const skipGuard = (action) => ({
96
- action: async (ctx) => (ctx.request.skipNavigation ? {} : ((await action(ctx)) ?? {})),
97
- });
100
+ const { navigationTimeoutSecs, saveResponseCookies, browserPool, remoteBrowser, preNavigationHooks, postNavigationHooks, ignoreIframes, ignoreShadowRoots, contextPipelineBuilder, browserPoolBuilder, extendContext, ...basicCrawlerOptions } = parseArgument(options, BrowserCrawler.optionsSchema, 'BrowserCrawlerOptions');
101
+ if (browserPool) {
102
+ assertBrowserPoolNotConfigured(new.target.name, { remoteBrowser });
103
+ }
104
+ const skipGuard = (action) => async (ctx) => ctx.request.skipNavigation ? {} : ((await action(ctx)) ?? {});
98
105
  super({
99
106
  ...basicCrawlerOptions,
100
107
  contextPipelineBuilder: () => {
@@ -103,107 +110,64 @@ export class BrowserCrawler extends BasicCrawler {
103
110
  // hook eats into the same window the navigation uses. The navigation itself is bounded by
104
111
  // capping its `gotoOptions.timeout` to the remaining budget.
105
112
  const windowGuard = (step) => skipGuard(async (ctx) => {
106
- const remaining = remainingNavigationWindowMillis(ctx, this.navigationTimeoutMillis);
113
+ const remaining = remainingNavigationWindowMillis(ctx, this.#navigationTimeoutMillis);
107
114
  if (remaining <= 0) {
108
- throw new TimeoutError(`Navigation timed out after ${this.navigationTimeoutMillis / 1000} seconds.`);
115
+ throw new TimeoutError(`Navigation timed out after ${this.#navigationTimeoutMillis / 1000} seconds.`);
109
116
  }
110
- return addTimeoutToPromise(async () => step(ctx), remaining, `Navigation timed out after ${this.navigationTimeoutMillis / 1000} seconds.`);
117
+ return addTimeoutToPromise(async () => step(ctx), remaining, `Navigation timed out after ${this.#navigationTimeoutMillis / 1000} seconds.`);
111
118
  });
112
- let pipeline = contextPipelineBuilder().compose({ action: this.prepareNavigation.bind(this) });
113
- for (const hook of this.preNavigationHooks) {
119
+ let pipeline = contextPipelineBuilder().compose(this.#prepareNavigation.bind(this));
120
+ for (const hook of this.#preNavigationHooks) {
114
121
  pipeline = pipeline.compose(windowGuard(hook));
115
122
  }
116
123
  pipeline = pipeline.compose(skipGuard(this.navigate.bind(this)));
117
- for (const hook of this.postNavigationHooks) {
124
+ for (const hook of this.#postNavigationHooks) {
118
125
  pipeline = pipeline.compose(windowGuard(hook));
119
126
  }
120
127
  return pipeline
121
- .compose(skipGuard(this.finalizeNavigation.bind(this)))
122
- .compose({ action: this.handleBlockedRequestByContent.bind(this) })
123
- .compose({ action: this.restoreRequestState.bind(this) });
128
+ .compose(skipGuard(this.#finalizeNavigation.bind(this)))
129
+ .compose(this.#handleBlockedRequestByContent.bind(this))
130
+ .compose(this.#restoreRequestState.bind(this));
124
131
  },
125
132
  extendContext,
126
133
  });
127
- this.launchContext = launchContext;
128
- this.navigationTimeoutMillis = navigationTimeoutSecs * 1000;
134
+ this.#navigationTimeoutMillis = navigationTimeoutSecs * 1000;
129
135
  // The public option hooks are extension-aware; internal storage uses the base context type
130
136
  // (the pipeline composes hooks against the concrete context, which does not statically carry
131
137
  // `ContextExtension`). The extension members are present at runtime regardless.
132
- this.preNavigationHooks = preNavigationHooks;
133
- this.postNavigationHooks = postNavigationHooks;
138
+ this.#preNavigationHooks = preNavigationHooks;
139
+ this.#postNavigationHooks = postNavigationHooks;
134
140
  this.ignoreIframes = ignoreIframes;
135
141
  this.ignoreShadowRoots = ignoreShadowRoots;
136
- if (headless != null) {
137
- this.launchContext.launchOptions ??= {};
138
- this.launchContext.launchOptions.headless = headless;
139
- }
140
- this.saveResponseCookies = saveResponseCookies;
141
- // `browserPool` wins over `remoteBrowser` — a passed-in pool is used as-is (borrowed), the sugar is ignored.
142
- // The default is only built when no pool was injected, so all the option/launchContext fiddling below stays
143
- // inside the factory.
144
- this.browserPoolDep = OwnedOrInjected.resolve(browserPool, () => {
145
- const resolvedBrowserPoolOptions = browserPoolOptions ?? {};
146
- if (launchContext?.userAgent) {
147
- if (resolvedBrowserPoolOptions.useFingerprints)
148
- this.log.info('Custom user agent provided, disabling automatic browser fingerprint injection!');
149
- resolvedBrowserPoolOptions.useFingerprints = false;
150
- }
151
- if (remoteBrowser) {
152
- // The crawler already built the right plugin for its browser — hand it to a RemoteBrowserPool so the
153
- // remote connection is always for the matching browser (no plugin to construct, no way to mismatch).
154
- const { browserPlugins, ...remoteBrowserPoolOptions } = resolvedBrowserPoolOptions;
155
- return new RemoteBrowserPool({
156
- browserPlugins: browserPlugins,
157
- ...remoteBrowser,
158
- browserPoolOptions: remoteBrowserPoolOptions,
159
- });
160
- }
161
- // Double cast: `BrowserPool` implements `IBrowserPool<PageReturn>`, where `PageReturn` is derived from the
162
- // plugin/controller generics and doesn't overlap with the crawler's free `Page` type param, so TS won't
163
- // narrow it directly. The concrete pool does satisfy the `Page`/`destroy` contract at runtime — this is the
164
- // long-standing `Page` variance gap, not a `destroy`-related hole.
165
- return new BrowserPool({
166
- ...resolvedBrowserPoolOptions,
167
- });
168
- });
142
+ this.#saveResponseCookies = saveResponseCookies;
143
+ this.#browserPoolDep = OwnedOrInjected.resolve(browserPool, () => browserPoolBuilder(remoteBrowser));
169
144
  }
145
+ /** @internal */
170
146
  getNavigationTimeoutMillis() {
171
- return this.navigationTimeoutMillis;
147
+ return this.#navigationTimeoutMillis;
172
148
  }
173
149
  buildContextPipeline() {
174
- return ContextPipeline.create().compose({
175
- action: this.preparePage.bind(this),
176
- cleanup: async (context) => {
177
- context.registerDeferredCleanup(async () => {
178
- const error = !context.session.isUsable()
179
- ? new SessionError('Session is no longer usable')
180
- : undefined;
181
- await this.browserPool
182
- .closePage(context.page, { error })
183
- .catch((closeError) => this.log.debug('Error while closing page', { error: closeError }));
184
- });
185
- },
186
- });
150
+ return ContextPipeline.create().compose(this.#preparePage.bind(this));
187
151
  }
188
- async containsSelectors(page, selectors) {
152
+ async #containsSelectors(page, selectors) {
189
153
  const foundSelectors = (await Promise.all(selectors.map((selector) => page.$(selector))))
190
154
  .map((x, i) => [x, selectors[i]])
191
155
  .filter(([x]) => x !== null)
192
156
  .map(([, selector]) => selector);
193
157
  return foundSelectors.length > 0 ? foundSelectors : null;
194
158
  }
195
- async isRequestBlocked(crawlingContext) {
159
+ async #isRequestBlocked(crawlingContext) {
196
160
  const { page, response } = crawlingContext;
197
161
  // Cloudflare specific heuristic - wait 5 seconds if we get a 403 for the JS challenge to load / resolve.
198
- if ((await this.containsSelectors(page, CLOUDFLARE_RETRY_CSS_SELECTORS)) && response?.status() === 403) {
162
+ if ((await this.#containsSelectors(page, CLOUDFLARE_RETRY_CSS_SELECTORS)) && response?.status() === 403) {
199
163
  await sleep(5000);
200
164
  // here we cannot test for response code, because we only have the original response, not the possible Cloudflare redirect on passed challenge.
201
- const foundSelectors = await this.containsSelectors(page, RETRY_CSS_SELECTORS);
165
+ const foundSelectors = await this.#containsSelectors(page, RETRY_CSS_SELECTORS);
202
166
  if (!foundSelectors)
203
167
  return false;
204
168
  return `Cloudflare challenge failed, found selectors: ${foundSelectors.join(', ')}`;
205
169
  }
206
- const foundSelectors = await this.containsSelectors(page, RETRY_CSS_SELECTORS);
170
+ const foundSelectors = await this.#containsSelectors(page, RETRY_CSS_SELECTORS);
207
171
  const statusCode = response?.status() ?? 0;
208
172
  if (foundSelectors)
209
173
  return `Found selectors: ${foundSelectors.join(', ')}`;
@@ -211,13 +175,38 @@ export class BrowserCrawler extends BasicCrawler {
211
175
  return `Received blocked status code: ${statusCode}`;
212
176
  return false;
213
177
  }
214
- async preparePage(crawlingContext) {
178
+ async #preparePage(crawlingContext, onCleanup) {
215
179
  const page = await this.browserPool.newPage({
216
180
  id: crawlingContext.id,
217
181
  session: crawlingContext.session,
218
182
  });
219
183
  tryCancel();
220
- const contextEnqueueLinks = crawlingContext.enqueueLinks;
184
+ onCleanup((failure) => {
185
+ crawlingContext.registerDeferredCleanup(async () => {
186
+ const cause = failure instanceof RequestHandlerError || failure instanceof ContextPipelineInitializationError
187
+ ? failure.cause
188
+ : failure;
189
+ // Only tells the pool what to do with the page's browser state - the request's failure, if any, has
190
+ // already been handled. A thrown `SessionError` or a blocked session counts as a block; a session
191
+ // unusable for any other reason is reported as retired. `retire()`/`markBad()` also bump the usage
192
+ // count, so the block check must come first.
193
+ const { session } = crawlingContext;
194
+ const closeReason = cause instanceof SessionError
195
+ ? cause
196
+ : session.isBlocked()
197
+ ? new SessionError()
198
+ : session.isUsable()
199
+ ? undefined
200
+ : new SessionRetiredError();
201
+ await this.browserPool
202
+ .closePage(page, { error: closeReason })
203
+ .catch((closeError) => this.log.debug('Error while closing page', { error: closeError }));
204
+ });
205
+ });
206
+ const addRequests = crawlingContext.addRequests;
207
+ const extractLinks = async (options) => {
208
+ return extractUrlsFromPage(page, options?.selector ?? 'a', options?.baseUrl ?? crawlingContext.request.loadedUrl ?? crawlingContext.request.url);
209
+ };
221
210
  return {
222
211
  page,
223
212
  get response() {
@@ -226,33 +215,36 @@ export class BrowserCrawler extends BasicCrawler {
226
215
  get gotoOptions() {
227
216
  throw new Error('The `gotoOptions` property is not available until `prepareNavigation` runs.');
228
217
  },
229
- enqueueLinks: async (enqueueOptions = {}) => {
230
- return (await browserCrawlerEnqueueLinks({
231
- options: {
232
- ...enqueueOptions,
233
- limit: await this.calculateEnqueuedRequestLimit(enqueueOptions?.limit),
234
- },
235
- page,
236
- requestManager: await this.getRequestManager(),
237
- robotsTxtFile: await this.getRobotsTxtFileForUrl(crawlingContext.request.url),
238
- onSkippedRequest: this.handleSkippedRequest,
239
- originalRequestUrl: crawlingContext.request.url,
218
+ extractLinks,
219
+ enqueueLinks: async (options = {}) => {
220
+ const baseUrl = resolveBaseUrlForEnqueueLinksFiltering({
221
+ enqueueStrategy: options.strategy,
240
222
  finalRequestUrl: crawlingContext.request.loadedUrl,
241
- enqueueLinks: contextEnqueueLinks,
242
- })); // TODO make this type safe
223
+ originalRequestUrl: crawlingContext.request.url,
224
+ userProvidedBaseUrl: options.baseUrl,
225
+ });
226
+ const urls = await extractLinks(options);
227
+ return addRequests(urls, {
228
+ ...options,
229
+ baseUrl,
230
+ strategy: options.strategy ?? EnqueueStrategy.SameHostname,
231
+ });
243
232
  },
244
233
  };
245
234
  }
246
- async prepareNavigation(crawlingContext) {
235
+ async #prepareNavigation(crawlingContext) {
247
236
  if (crawlingContext.request.skipNavigation) {
248
237
  return {
249
238
  request: new Proxy(crawlingContext.request, {
250
- get(target, propertyName, receiver) {
239
+ get(target, propertyName) {
251
240
  if (propertyName === 'loadedUrl') {
252
241
  throw new NavigationSkippedError('The `request.loadedUrl` property is not available - `skipNavigation` was used');
253
242
  }
254
- return Reflect.get(target, propertyName, receiver);
243
+ // `target` as receiver and bound methods, or `#` members throw on the proxy
244
+ const value = Reflect.get(target, propertyName, target);
245
+ return typeof value === 'function' ? value.bind(target) : value;
255
246
  },
247
+ set: (target, propertyName, value) => Reflect.set(target, propertyName, value, target),
256
248
  }),
257
249
  get response() {
258
250
  throw new NavigationSkippedError('The `response` property is not available - `skipNavigation` was used');
@@ -263,43 +255,44 @@ export class BrowserCrawler extends BasicCrawler {
263
255
  return {
264
256
  // Default to the full navigation timeout so a pre-navigation hook can read it; `navigate` narrows it
265
257
  // to the remaining shared window unless a hook overrode it (see there).
266
- gotoOptions: { timeout: this.navigationTimeoutMillis },
267
- [COOKIES_BEFORE_HOOKS]: this._getCookieHeaderFromRequest(crawlingContext.request),
258
+ gotoOptions: { timeout: this.#navigationTimeoutMillis },
259
+ [COOKIES_BEFORE_HOOKS]: this.getCookieHeaderFromRequest(crawlingContext.request),
268
260
  };
269
261
  }
262
+ // oxlint-disable-next-line crawlee/prefer-private-fields -- patched by @crawlee/otel
270
263
  async navigate(crawlingContext) {
271
264
  tryCancel();
272
265
  const gotoOptions = crawlingContext.gotoOptions;
273
- const remaining = remainingNavigationWindowMillis(crawlingContext, this.navigationTimeoutMillis);
266
+ const remaining = remainingNavigationWindowMillis(crawlingContext, this.#navigationTimeoutMillis);
274
267
  if (remaining <= 0) {
275
- throw new TimeoutError(`Navigation timed out after ${this.navigationTimeoutMillis / 1000} seconds.`);
268
+ throw new TimeoutError(`Navigation timed out after ${this.#navigationTimeoutMillis / 1000} seconds.`);
276
269
  }
277
270
  // If a hook left the default `navigationTimeoutMillis` in place, bound the goto to whatever is left of the
278
271
  // shared navigation window. If it overrode the value - including `0`, Playwright's "no timeout" - honour
279
272
  // that verbatim as the goto's own timeout. The driver enforces this natively (so a timed-out goto is
280
273
  // aborted, not left lingering) and `handleNavigationTimeout` turns its error into our own message.
281
274
  const gotoTimeout = gotoOptions;
282
- if (gotoTimeout.timeout === this.navigationTimeoutMillis) {
275
+ if (gotoTimeout.timeout === this.#navigationTimeoutMillis) {
283
276
  gotoTimeout.timeout = remaining;
284
277
  }
285
278
  const cookiesBeforeHooks = readContextField(crawlingContext, COOKIES_BEFORE_HOOKS);
286
- const cookiesAfterHooks = this._getCookieHeaderFromRequest(crawlingContext.request);
287
- await this.applyCookies(crawlingContext, cookiesBeforeHooks, cookiesAfterHooks);
279
+ const cookiesAfterHooks = this.getCookieHeaderFromRequest(crawlingContext.request);
280
+ await this.#applyCookies(crawlingContext, cookiesBeforeHooks, cookiesAfterHooks);
288
281
  let response;
289
282
  try {
290
- response = (await this._navigationHandler(crawlingContext, gotoOptions)) ?? undefined;
283
+ response = (await this.navigationHandler(crawlingContext, gotoOptions)) ?? undefined;
291
284
  }
292
285
  catch (error) {
293
- await this.handleNavigationTimeout(crawlingContext, error);
286
+ await this.#handleNavigationTimeout(crawlingContext, error);
294
287
  crawlingContext.request.state = RequestState.ERROR;
295
- this.throwIfProxyError(error);
288
+ this.#throwIfProxyError(error);
296
289
  throw error;
297
290
  }
298
291
  tryCancel();
299
292
  crawlingContext.request.state = RequestState.AFTER_NAV;
300
293
  return { response };
301
294
  }
302
- async finalizeNavigation(crawlingContext) {
295
+ async #finalizeNavigation(crawlingContext) {
303
296
  tryCancel();
304
297
  let response;
305
298
  try {
@@ -309,18 +302,18 @@ export class BrowserCrawler extends BasicCrawler {
309
302
  // `preparePage` installs a throwing getter for `response`; reaching this branch means
310
303
  // navigation produced no response and no hook overrode it. Treat as undefined.
311
304
  }
312
- await this.processResponse(response, crawlingContext);
305
+ await this.#processResponse(response, crawlingContext);
313
306
  tryCancel();
314
307
  // Persist cookies from the navigation response before the user handler runs.
315
308
  // Cookies set during `requestHandler` are saved again afterwards.
316
- await this.persistCookiesFromPage(crawlingContext);
309
+ await this.#persistCookiesFromPage(crawlingContext);
317
310
  return { request: crawlingContext.request };
318
311
  }
319
312
  /**
320
313
  * Copies cookies from the live browser page into the session cookie jar.
321
314
  */
322
- async persistCookiesFromPage(crawlingContext) {
323
- if (!this.saveResponseCookies || !crawlingContext.session) {
315
+ async #persistCookiesFromPage(crawlingContext) {
316
+ if (!this.#saveResponseCookies || !crawlingContext.session) {
324
317
  return;
325
318
  }
326
319
  const { cookies } = await this.browserPool.extractPageState(crawlingContext.page);
@@ -329,7 +322,7 @@ export class BrowserCrawler extends BasicCrawler {
329
322
  const url = (await crawlingContext.page.url()) || crawlingContext.request.loadedUrl || crawlingContext.request.url;
330
323
  for (const cookie of cookies) {
331
324
  try {
332
- crawlingContext.session.cookieJar.setCookieSync(browserPoolCookieToToughCookie(cookie), url, {
325
+ await crawlingContext.session.cookieJar.setCookie(browserPoolCookieToToughCookie(cookie), url, {
333
326
  ignoreError: false,
334
327
  });
335
328
  }
@@ -341,6 +334,7 @@ export class BrowserCrawler extends BasicCrawler {
341
334
  /**
342
335
  * Runs the user request handler, then re-reads browser cookies so login flows /
343
336
  * `page.setCookie` / XHR `Set-Cookie` updates are stored for later requests.
337
+ * @internal
344
338
  */
345
339
  async runRequestHandler(crawlingContext) {
346
340
  try {
@@ -349,7 +343,7 @@ export class BrowserCrawler extends BasicCrawler {
349
343
  finally {
350
344
  if (!crawlingContext.request.skipNavigation) {
351
345
  try {
352
- await this.persistCookiesFromPage(crawlingContext);
346
+ await this.#persistCookiesFromPage(crawlingContext);
353
347
  }
354
348
  catch {
355
349
  // Page may already be closed on some failure paths; ignore.
@@ -357,20 +351,22 @@ export class BrowserCrawler extends BasicCrawler {
357
351
  }
358
352
  }
359
353
  }
360
- async handleBlockedRequestByContent(crawlingContext) {
354
+ async #handleBlockedRequestByContent(crawlingContext) {
361
355
  if (this.retryOnBlocked) {
362
- const error = await this.isRequestBlocked(crawlingContext);
356
+ const error = await this.#isRequestBlocked(crawlingContext);
363
357
  if (error)
364
358
  throw new SessionError(error);
365
359
  }
366
360
  return {};
367
361
  }
368
- async restoreRequestState(crawlingContext) {
362
+ async #restoreRequestState(crawlingContext) {
369
363
  crawlingContext.request.state = RequestState.REQUEST_HANDLER;
370
364
  return {};
371
365
  }
372
- async applyCookies({ session, request, page }, preHooksCookies, postHooksCookies) {
373
- const sessionCookie = session?.cookieJar.getCookiesSync(request.url).map(toughCookieToBrowserPoolCookie) ?? [];
366
+ async #applyCookies({ session, request, page }, preHooksCookies, postHooksCookies) {
367
+ const sessionCookie = session
368
+ ? (await session.cookieJar.getCookies(request.url)).map(toughCookieToBrowserPoolCookie)
369
+ : [];
374
370
  const parsedPreHooksCookies = preHooksCookies.split(/ *; */).map((c) => cookieStringToToughCookie(c));
375
371
  const parsedPostHooksCookies = postHooksCookies.split(/ *; */).map((c) => cookieStringToToughCookie(c));
376
372
  const cookies = [...sessionCookie, ...parsedPreHooksCookies, ...parsedPostHooksCookies]
@@ -381,7 +377,7 @@ export class BrowserCrawler extends BasicCrawler {
381
377
  /**
382
378
  * Marks session bad on navigation timeout, and stops in-flight page loading on any navigation error.
383
379
  */
384
- async handleNavigationTimeout(crawlingContext, error) {
380
+ async #handleNavigationTimeout(crawlingContext, error) {
385
381
  const { session, page } = crawlingContext;
386
382
  // Fire-and-forget: no user code will run on this page after a failed navigation.
387
383
  // Swallow rejections: the page may already be detached.
@@ -390,22 +386,31 @@ export class BrowserCrawler extends BasicCrawler {
390
386
  session?.markBad();
391
387
  // The driver was handed the remaining window (usually shorter than `navigationTimeoutSecs` once the
392
388
  // hooks have run), so it names that value in its own error; report the configured window instead.
393
- throw new TimeoutError(`Navigation timed out after ${this.navigationTimeoutMillis / 1000} seconds.`);
389
+ throw new TimeoutError(`Navigation timed out after ${this.#navigationTimeoutMillis / 1000} seconds.`);
394
390
  }
395
391
  }
396
392
  /**
397
393
  * Transforms proxy-related errors to `SessionError`.
398
394
  */
399
- throwIfProxyError(error) {
395
+ #throwIfProxyError(error) {
400
396
  if (this.isProxyError(error)) {
401
- throw new SessionError(this._getMessageFromError(error));
397
+ throw new SessionError(this.getMessageFromError(error));
402
398
  }
403
399
  }
404
- async processResponse(response, crawlingContext) {
400
+ async #processResponse(response, crawlingContext) {
405
401
  const { session, request, page } = crawlingContext;
406
402
  if (typeof response === 'object' && typeof response.status === 'function') {
407
403
  const status = response.status();
408
- this.stats.registerStatusCode(status);
404
+ this.statistics.registerStatusCode(status);
405
+ // Ahead of the error-status throw below: a 429 the user opted into treating as an error is still a
406
+ // rate limit the domain should back off from.
407
+ if (status === 429) {
408
+ // Both drivers lower-case header names and join duplicates, so a plain lookup is enough.
409
+ const retryAfter = response.headers?.()['retry-after'];
410
+ if (this.recordDomainRateLimit(crawlingContext.request.url, retryAfter)) {
411
+ throw new RequestThrottledError(`${crawlingContext.request.url} responded with 429.`);
412
+ }
413
+ }
409
414
  if (this.isErrorStatusCode(status)) {
410
415
  if (this.additionalHttpErrorStatusCodes.has(status)) {
411
416
  throw new Error(`${status} - Error status code was set by user.`);
@@ -415,7 +420,7 @@ export class BrowserCrawler extends BasicCrawler {
415
420
  }
416
421
  if (this.sessionPool && response && session) {
417
422
  if (typeof response === 'object' && typeof response.status === 'function') {
418
- this._throwOnBlockedRequest(response.status());
423
+ this.throwOnBlockedRequest(response.status());
419
424
  }
420
425
  else {
421
426
  this.log.debug('Got a malformed Browser response.', { request, response });
@@ -424,43 +429,17 @@ export class BrowserCrawler extends BasicCrawler {
424
429
  request.loadedUrl = await page.url();
425
430
  }
426
431
  /**
427
- * Function for cleaning up after all requests are processed.
428
- * @ignore
432
+ * Closes the browsers of a pool the crawler owns, so a finished run leaves none behind. The pool itself is
433
+ * crawler-lifetime and survives — destroying it here would hand a repeated `run()` a dead pool.
429
434
  */
430
435
  async teardown() {
431
- await this.browserPoolDep.ifOwned((pool) => pool.destroy());
436
+ await this.#browserPoolDep.ifOwned((pool) => pool.releaseAllBrowsers());
432
437
  await super.teardown();
433
438
  }
434
- }
435
- /** @internal */
436
- function containsEnqueueLinks(options) {
437
- return !!options.enqueueLinks;
438
- }
439
- /** @internal */
440
- export async function browserCrawlerEnqueueLinks(options) {
441
- const { options: enqueueLinksOptions, finalRequestUrl, originalRequestUrl, page } = options;
442
- const baseUrl = resolveBaseUrlForEnqueueLinksFiltering({
443
- enqueueStrategy: enqueueLinksOptions?.strategy,
444
- finalRequestUrl,
445
- originalRequestUrl,
446
- userProvidedBaseUrl: enqueueLinksOptions?.baseUrl,
447
- });
448
- const urls = await extractUrlsFromPage(page, enqueueLinksOptions?.selector ?? 'a', enqueueLinksOptions?.baseUrl ?? finalRequestUrl ?? originalRequestUrl);
449
- if (containsEnqueueLinks(options)) {
450
- return options.enqueueLinks({
451
- urls,
452
- baseUrl,
453
- ...enqueueLinksOptions,
454
- });
439
+ async destroy() {
440
+ await super.destroy();
441
+ await this.#browserPoolDep.ifOwned((pool) => pool.destroy());
455
442
  }
456
- return enqueueLinks({
457
- requestManager: options.requestManager,
458
- robotsTxtFile: options.robotsTxtFile,
459
- onSkippedRequest: options.onSkippedRequest,
460
- urls,
461
- baseUrl,
462
- ...enqueueLinksOptions,
463
- });
464
443
  }
465
444
  /**
466
445
  * Extracts URLs from a given page.