@crawlee/browser 4.0.0-beta.12 → 4.0.0-beta.120

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,8 +1,22 @@
1
- import { BasicCrawler, BLOCKED_STATUS_CODES as DEFAULT_BLOCKED_STATUS_CODES, Configuration, ContextPipeline, cookieStringToToughCookie, enqueueLinks, EVENT_SESSION_RETIRED, handleRequestTimeout, RequestState, resolveBaseUrlForEnqueueLinksFiltering, SessionError, tryAbsoluteURL, validators, } from '@crawlee/basic';
2
- import { BrowserPool } from '@crawlee/browser-pool';
3
- import { CLOUDFLARE_RETRY_CSS_SELECTORS, RETRY_CSS_SELECTORS, sleep } from '@crawlee/utils';
1
+ import { BasicCrawler, browserPoolCookieToToughCookie, ContextPipeline, cookieStringToToughCookie, enqueueLinks, NavigationSkippedError, OwnedOrInjected, remainingNavigationWindowMillis, RequestState, RequestThrottledError, 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 } from '@crawlee/utils/internal';
4
+ import { sleep } from '@crawlee/utils';
4
5
  import ow from 'ow';
5
- import { tryCancel } from '@apify/timeout';
6
+ import { addTimeoutToPromise, TimeoutError, tryCancel } from '@apify/timeout';
7
+ const COOKIES_BEFORE_HOOKS = Symbol('cookiesBeforeHooks');
8
+ const readContextField = (ctx, key) => ctx[key];
9
+ /**
10
+ * Whether an error thrown by `page.goto()` is a navigation timeout - either our own {@link TimeoutError}
11
+ * or the driver's, which Playwright/Puppeteer report with their own class and a `Timeout ... exceeded` message
12
+ * naming the raw millisecond value rather than the configured window.
13
+ */
14
+ function isNavigationTimeoutError(error) {
15
+ return (error instanceof TimeoutError ||
16
+ error?.name === 'TimeoutError' ||
17
+ error?.constructor?.name === 'TimeoutError' ||
18
+ /timeout.*exceeded/i.test(error?.message ?? ''));
19
+ }
6
20
  /**
7
21
  * Provides a simple framework for parallel crawling of web pages
8
22
  * using headless browsers with [Puppeteer](https://github.com/puppeteer/puppeteer)
@@ -15,46 +29,51 @@ import { tryCancel } from '@apify/timeout';
15
29
  * If the target website doesn't need JavaScript, we should consider using the {@link CheerioCrawler},
16
30
  * which downloads the pages using raw HTTP requests and is about 10x faster.
17
31
  *
18
- * The source URLs are represented by the {@link Request} objects that are fed from the {@link RequestList} or {@link RequestQueue} instances
19
- * provided by the {@link BrowserCrawlerOptions.requestList|`requestList`} or {@link BrowserCrawlerOptions.requestQueue|`requestQueue`}
20
- * constructor options, respectively. If neither `requestList` nor `requestQueue` options are provided,
32
+ * The source URLs are represented by the {@link Request} objects that are fed from the
33
+ * {@link IRequestManager|request manager} provided via the {@link BrowserCrawlerOptions.requestManager|`requestManager`}
34
+ * constructor option (a {@link RequestQueue} is itself a request manager). If no `requestManager` is provided,
21
35
  * the crawler will open the default request queue either when the {@link BrowserCrawler.addRequests|`crawler.addRequests()`} function is called,
22
36
  * or if `requests` parameter (representing the initial requests) of the {@link BrowserCrawler.run|`crawler.run()`} function is provided.
23
37
  *
24
- * If both {@link BrowserCrawlerOptions.requestList|`requestList`} and {@link BrowserCrawlerOptions.requestQueue|`requestQueue`} options are used,
25
- * the instance first processes URLs from the {@link RequestList} and automatically enqueues all of them
26
- * to the {@link RequestQueue} before it starts their processing. This ensures that a single URL is not crawled multiple times.
38
+ * To read from a read-only source such as a {@link RequestList} while still being able to enqueue new requests,
39
+ * combine it with a queue into a {@link RequestManagerTandem} via {@link IRequestLoader.toTandem|`requestLoader.toTandem()`}
40
+ * and pass the result as `requestManager`.
41
+ *
42
+ * > The {@link BrowserCrawlerOptions.requestList|`requestList`} and {@link BrowserCrawlerOptions.requestQueue|`requestQueue`}
43
+ * > options are deprecated; they are still accepted and folded into a single `requestManager` for back-compat.
27
44
  *
28
45
  * The crawler finishes when there are no more {@link Request} objects to crawl.
29
46
  *
30
47
  * `BrowserCrawler` opens a new browser page (i.e. tab or window) for each {@link Request} object to crawl
31
48
  * and then calls the function provided by user as the {@link BrowserCrawlerOptions.requestHandler|`requestHandler`} option.
32
49
  *
33
- * New pages are only opened when there is enough free CPU and memory available,
34
- * using the functionality provided by the {@link AutoscaledPool} class.
35
- * All {@link AutoscaledPool} configuration options can be passed to the {@link BrowserCrawlerOptions.autoscaledPoolOptions|`autoscaledPoolOptions`}
36
- * parameter of the `BrowserCrawler` constructor.
37
- * For user convenience, the {@link AutoscaledPoolOptions.minConcurrency|`minConcurrency`} and
38
- * {@link AutoscaledPoolOptions.maxConcurrency|`maxConcurrency`} options of the
39
- * underlying {@link AutoscaledPool} constructor are available directly in the `BrowserCrawler` constructor.
50
+ * New pages are only opened when there is enough free CPU and memory available, as judged by the crawler's
51
+ * {@link ConcurrencySystem}.
52
+ * Concurrency is tuned via the `minConcurrency`, `maxConcurrency` and `maxRequestsPerMinute` options of the
53
+ * `BrowserCrawler` constructor, or, for finer control, by injecting a pre-configured
54
+ * {@link ConcurrencySystem|`concurrencySystem`}.
40
55
  *
41
56
  * > *NOTE:* the pool of browser instances is internally managed by the {@link BrowserPool} class.
42
57
  *
43
58
  * @category Crawlers
44
59
  */
45
60
  export class BrowserCrawler extends BasicCrawler {
46
- config;
61
+ /** Backs the {@link BrowserCrawler.browserPool|`browserPool`} getter. */
62
+ #browserPoolDep;
47
63
  /**
48
- * A reference to the underlying {@link BrowserPool} class that manages the crawler's browsers.
64
+ * A reference to the underlying browser pool that manages the crawler's browsers. Typed as
65
+ * {@link IBrowserPool} so custom implementations can be plugged in via the `browserPool` constructor option.
49
66
  */
50
- browserPool;
67
+ get browserPool() {
68
+ return this.#browserPoolDep.value;
69
+ }
51
70
  launchContext;
52
71
  ignoreShadowRoots;
53
72
  ignoreIframes;
54
- navigationTimeoutMillis;
55
- preNavigationHooks;
56
- postNavigationHooks;
57
- persistCookiesPerSession;
73
+ #navigationTimeoutMillis;
74
+ #preNavigationHooks;
75
+ #postNavigationHooks;
76
+ #saveResponseCookies;
58
77
  static optionsShape = {
59
78
  ...BasicCrawler.optionsShape,
60
79
  navigationTimeoutSecs: ow.optional.number.greaterThan(0),
@@ -62,68 +81,107 @@ export class BrowserCrawler extends BasicCrawler {
62
81
  postNavigationHooks: ow.optional.array,
63
82
  launchContext: ow.optional.object,
64
83
  headless: ow.optional.any(ow.boolean, ow.string),
65
- browserPoolOptions: ow.object,
66
- sessionPoolOptions: ow.optional.object,
67
- persistCookiesPerSession: ow.optional.boolean,
68
- useSessionPool: ow.optional.boolean,
84
+ browserPool: ow.optional.object.validate(validators.browserPool),
85
+ remoteBrowser: ow.optional.object,
86
+ browserPoolOptions: ow.optional.object,
87
+ saveResponseCookies: ow.optional.boolean,
69
88
  proxyConfiguration: ow.optional.object.validate(validators.proxyConfiguration),
70
89
  };
71
90
  /**
72
91
  * All `BrowserCrawler` parameters are passed via an options object.
73
92
  */
74
- constructor(options, config = Configuration.getGlobalConfig()) {
93
+ constructor(options) {
75
94
  ow(options, 'BrowserCrawlerOptions', ow.object.exactShape(BrowserCrawler.optionsShape));
76
- const { navigationTimeoutSecs = 60, persistCookiesPerSession, launchContext = {}, browserPoolOptions, preNavigationHooks = [], postNavigationHooks = [], headless, ignoreIframes = false, ignoreShadowRoots = false, contextPipelineBuilder, extendContext, proxyConfiguration, ...basicCrawlerOptions } = options;
95
+ const { navigationTimeoutSecs = 60, saveResponseCookies = true, launchContext = {}, browserPool, remoteBrowser, browserPoolOptions, preNavigationHooks = [], postNavigationHooks = [], headless, ignoreIframes = false, ignoreShadowRoots = false, contextPipelineBuilder, extendContext, ...basicCrawlerOptions } = options;
96
+ const skipGuard = (action) => ({
97
+ action: async (ctx) => (ctx.request.skipNavigation ? {} : ((await action(ctx)) ?? {})),
98
+ });
77
99
  super({
78
100
  ...basicCrawlerOptions,
79
- contextPipelineBuilder: () => contextPipelineBuilder()
80
- .compose({ action: this.performNavigation.bind(this) })
81
- .compose({ action: this.handleBlockedRequestByContent.bind(this) })
82
- .compose({ action: this.restoreRequestState.bind(this) }),
83
- extendContext: extendContext,
84
- }, config);
85
- this.config = config;
86
- // Cookies should be persisted per session only if session pool is used
87
- if (!this.useSessionPool && persistCookiesPerSession) {
88
- throw new Error('You cannot use "persistCookiesPerSession" without "useSessionPool" set to true.');
89
- }
101
+ contextPipelineBuilder: () => {
102
+ // A single navigation window covers the pre-navigation hooks, the navigation, and the
103
+ // post-navigation hooks: the whole phase shares one `navigationTimeoutSecs` budget, so a slow
104
+ // hook eats into the same window the navigation uses. The navigation itself is bounded by
105
+ // capping its `gotoOptions.timeout` to the remaining budget.
106
+ const windowGuard = (step) => skipGuard(async (ctx) => {
107
+ const remaining = remainingNavigationWindowMillis(ctx, this.#navigationTimeoutMillis);
108
+ if (remaining <= 0) {
109
+ throw new TimeoutError(`Navigation timed out after ${this.#navigationTimeoutMillis / 1000} seconds.`);
110
+ }
111
+ return addTimeoutToPromise(async () => step(ctx), remaining, `Navigation timed out after ${this.#navigationTimeoutMillis / 1000} seconds.`);
112
+ });
113
+ let pipeline = contextPipelineBuilder().compose({ action: this.prepareNavigation.bind(this) });
114
+ for (const hook of this.#preNavigationHooks) {
115
+ pipeline = pipeline.compose(windowGuard(hook));
116
+ }
117
+ pipeline = pipeline.compose(skipGuard(this.navigate.bind(this)));
118
+ for (const hook of this.#postNavigationHooks) {
119
+ pipeline = pipeline.compose(windowGuard(hook));
120
+ }
121
+ return pipeline
122
+ .compose(skipGuard(this.finalizeNavigation.bind(this)))
123
+ .compose({ action: this.handleBlockedRequestByContent.bind(this) })
124
+ .compose({ action: this.restoreRequestState.bind(this) });
125
+ },
126
+ extendContext,
127
+ });
90
128
  this.launchContext = launchContext;
91
- this.navigationTimeoutMillis = navigationTimeoutSecs * 1000;
92
- this.proxyConfiguration = proxyConfiguration;
93
- this.preNavigationHooks = preNavigationHooks;
94
- this.postNavigationHooks = postNavigationHooks;
129
+ this.#navigationTimeoutMillis = navigationTimeoutSecs * 1000;
130
+ // The public option hooks are extension-aware; internal storage uses the base context type
131
+ // (the pipeline composes hooks against the concrete context, which does not statically carry
132
+ // `ContextExtension`). The extension members are present at runtime regardless.
133
+ this.#preNavigationHooks = preNavigationHooks;
134
+ this.#postNavigationHooks = postNavigationHooks;
95
135
  this.ignoreIframes = ignoreIframes;
96
136
  this.ignoreShadowRoots = ignoreShadowRoots;
97
137
  if (headless != null) {
98
138
  this.launchContext.launchOptions ??= {};
99
139
  this.launchContext.launchOptions.headless = headless;
100
140
  }
101
- if (this.useSessionPool) {
102
- this.persistCookiesPerSession = persistCookiesPerSession !== undefined ? persistCookiesPerSession : true;
103
- }
104
- else {
105
- this.persistCookiesPerSession = false;
106
- }
107
- if (launchContext?.userAgent) {
108
- if (browserPoolOptions.useFingerprints)
109
- this.log.info('Custom user agent provided, disabling automatic browser fingerprint injection!');
110
- browserPoolOptions.useFingerprints = false;
111
- }
112
- const { preLaunchHooks = [], postLaunchHooks = [], ...rest } = browserPoolOptions;
113
- this.browserPool = new BrowserPool({
114
- ...rest,
115
- preLaunchHooks: [this._extendLaunchContext.bind(this), ...preLaunchHooks],
116
- postLaunchHooks: [this._maybeAddSessionRetiredListener.bind(this), ...postLaunchHooks],
141
+ this.#saveResponseCookies = saveResponseCookies;
142
+ // `browserPool` wins over `remoteBrowser` — a passed-in pool is used as-is (borrowed), the sugar is ignored.
143
+ // The default is only built when no pool was injected, so all the option/launchContext fiddling below stays
144
+ // inside the factory.
145
+ this.#browserPoolDep = OwnedOrInjected.resolve(browserPool, () => {
146
+ const resolvedBrowserPoolOptions = browserPoolOptions ?? {};
147
+ if (launchContext?.userAgent) {
148
+ if (resolvedBrowserPoolOptions.useFingerprints)
149
+ this.log.info('Custom user agent provided, disabling automatic browser fingerprint injection!');
150
+ resolvedBrowserPoolOptions.useFingerprints = false;
151
+ }
152
+ if (remoteBrowser) {
153
+ // The crawler already built the right plugin for its browser — hand it to a RemoteBrowserPool so the
154
+ // remote connection is always for the matching browser (no plugin to construct, no way to mismatch).
155
+ const { browserPlugins, ...remoteBrowserPoolOptions } = resolvedBrowserPoolOptions;
156
+ return new RemoteBrowserPool({
157
+ browserPlugins: browserPlugins,
158
+ ...remoteBrowser,
159
+ browserPoolOptions: remoteBrowserPoolOptions,
160
+ });
161
+ }
162
+ // Double cast: `BrowserPool` implements `IBrowserPool<PageReturn>`, where `PageReturn` is derived from the
163
+ // plugin/controller generics and doesn't overlap with the crawler's free `Page` type param, so TS won't
164
+ // narrow it directly. The concrete pool does satisfy the `Page`/`destroy` contract at runtime — this is the
165
+ // long-standing `Page` variance gap, not a `destroy`-related hole.
166
+ return new BrowserPool({
167
+ ...resolvedBrowserPoolOptions,
168
+ });
117
169
  });
118
170
  }
171
+ getNavigationTimeoutMillis() {
172
+ return this.#navigationTimeoutMillis;
173
+ }
119
174
  buildContextPipeline() {
120
175
  return ContextPipeline.create().compose({
121
176
  action: this.preparePage.bind(this),
122
177
  cleanup: async (context) => {
123
178
  context.registerDeferredCleanup(async () => {
124
- await context.page
125
- .close()
126
- .catch((error) => this.log.debug('Error while closing page', { error }));
179
+ const error = !context.session.isUsable()
180
+ ? new SessionError('Session is no longer usable')
181
+ : undefined;
182
+ await this.browserPool
183
+ .closePage(context.page, { error })
184
+ .catch((closeError) => this.log.debug('Error while closing page', { error: closeError }));
127
185
  });
128
186
  },
129
187
  });
@@ -137,12 +195,6 @@ export class BrowserCrawler extends BasicCrawler {
137
195
  }
138
196
  async isRequestBlocked(crawlingContext) {
139
197
  const { page, response } = crawlingContext;
140
- const blockedStatusCodes =
141
- // eslint-disable-next-line dot-notation
142
- (this.sessionPool?.['blockedStatusCodes'].length ?? 0) > 0
143
- ? // eslint-disable-next-line dot-notation
144
- this.sessionPool['blockedStatusCodes']
145
- : DEFAULT_BLOCKED_STATUS_CODES;
146
198
  // Cloudflare specific heuristic - wait 5 seconds if we get a 403 for the JS challenge to load / resolve.
147
199
  if ((await this.containsSelectors(page, CLOUDFLARE_RETRY_CSS_SELECTORS)) && response?.status() === 403) {
148
200
  await sleep(5000);
@@ -153,115 +205,158 @@ export class BrowserCrawler extends BasicCrawler {
153
205
  return `Cloudflare challenge failed, found selectors: ${foundSelectors.join(', ')}`;
154
206
  }
155
207
  const foundSelectors = await this.containsSelectors(page, RETRY_CSS_SELECTORS);
156
- const blockedStatusCode = blockedStatusCodes.find((x) => x === (response?.status() ?? 0));
208
+ const statusCode = response?.status() ?? 0;
157
209
  if (foundSelectors)
158
210
  return `Found selectors: ${foundSelectors.join(', ')}`;
159
- if (blockedStatusCode)
160
- return `Received blocked status code: ${blockedStatusCode}`;
211
+ if (this.blockedStatusCodes.has(statusCode))
212
+ return `Received blocked status code: ${statusCode}`;
161
213
  return false;
162
214
  }
163
215
  async preparePage(crawlingContext) {
164
- const newPageOptions = {
216
+ const page = await this.browserPool.newPage({
165
217
  id: crawlingContext.id,
166
- };
167
- const useIncognitoPages = this.launchContext?.useIncognitoPages;
168
- if (crawlingContext.session?.proxyInfo) {
169
- const proxyInfo = crawlingContext.session.proxyInfo;
170
- crawlingContext.proxyInfo = proxyInfo;
171
- newPageOptions.proxyUrl = proxyInfo?.url;
172
- newPageOptions.proxyTier = proxyInfo?.proxyTier;
173
- if (proxyInfo?.ignoreTlsErrors) {
174
- /**
175
- * @see https://playwright.dev/docs/api/class-browser/#browser-new-context
176
- * @see https://github.com/puppeteer/puppeteer/blob/main/docs/api.md
177
- */
178
- newPageOptions.pageOptions = {
179
- ignoreHTTPSErrors: true,
180
- acceptInsecureCerts: true,
181
- };
182
- }
183
- }
184
- const page = (await this.browserPool.newPage(newPageOptions));
218
+ session: crawlingContext.session,
219
+ });
185
220
  tryCancel();
186
- const browserControllerInstance = this.browserPool.getBrowserControllerByPage(page);
187
- const session = useIncognitoPages
188
- ? crawlingContext.session
189
- : browserControllerInstance.launchContext.session;
221
+ const contextEnqueueLinks = crawlingContext.enqueueLinks;
190
222
  return {
191
223
  page,
192
224
  get response() {
193
225
  throw new Error("The `response` property is not available. This might mean that you're trying to access it before navigation or that navigation resulted in `null` (this should only happen with `about:` URLs)");
194
226
  },
195
- browserController: browserControllerInstance,
196
- session,
197
- proxyInfo: session?.proxyInfo,
227
+ get gotoOptions() {
228
+ throw new Error('The `gotoOptions` property is not available until `prepareNavigation` runs.');
229
+ },
198
230
  enqueueLinks: async (enqueueOptions = {}) => {
199
- return browserCrawlerEnqueueLinks({
200
- options: enqueueOptions,
231
+ return (await browserCrawlerEnqueueLinks({
232
+ options: {
233
+ ...enqueueOptions,
234
+ limit: await this.calculateEnqueuedRequestLimit(enqueueOptions?.limit),
235
+ },
201
236
  page,
202
- requestQueue: await this.getRequestQueue(),
237
+ requestManager: await this.getRequestManager(),
203
238
  robotsTxtFile: await this.getRobotsTxtFileForUrl(crawlingContext.request.url),
204
- onSkippedRequest: this.onSkippedRequest,
239
+ onSkippedRequest: this.handleSkippedRequest,
205
240
  originalRequestUrl: crawlingContext.request.url,
206
241
  finalRequestUrl: crawlingContext.request.loadedUrl,
207
- });
242
+ enqueueLinks: contextEnqueueLinks,
243
+ })); // TODO make this type safe
208
244
  },
209
245
  };
210
246
  }
211
- async performNavigation(crawlingContext) {
247
+ async prepareNavigation(crawlingContext) {
212
248
  if (crawlingContext.request.skipNavigation) {
213
249
  return {
214
250
  request: new Proxy(crawlingContext.request, {
215
251
  get(target, propertyName, receiver) {
216
252
  if (propertyName === 'loadedUrl') {
217
- throw new Error('The `request.loadedUrl` property is not available - `skipNavigation` was used');
253
+ throw new NavigationSkippedError('The `request.loadedUrl` property is not available - `skipNavigation` was used');
218
254
  }
219
255
  return Reflect.get(target, propertyName, receiver);
220
256
  },
221
257
  }),
222
258
  get response() {
223
- throw new Error('The `response` property is not available - `skipNavigation` was used');
259
+ throw new NavigationSkippedError('The `response` property is not available - `skipNavigation` was used');
224
260
  },
225
261
  };
226
262
  }
227
- const gotoOptions = { timeout: this.navigationTimeoutMillis };
228
- const preNavigationHooksCookies = this._getCookieHeaderFromRequest(crawlingContext.request);
229
263
  crawlingContext.request.state = RequestState.BEFORE_NAV;
230
- await this._executeHooks(this.preNavigationHooks, crawlingContext, gotoOptions);
264
+ return {
265
+ // Default to the full navigation timeout so a pre-navigation hook can read it; `navigate` narrows it
266
+ // to the remaining shared window unless a hook overrode it (see there).
267
+ gotoOptions: { timeout: this.#navigationTimeoutMillis },
268
+ [COOKIES_BEFORE_HOOKS]: this.getCookieHeaderFromRequest(crawlingContext.request),
269
+ };
270
+ }
271
+ async navigate(crawlingContext) {
231
272
  tryCancel();
232
- const postNavigationHooksCookies = this._getCookieHeaderFromRequest(crawlingContext.request);
233
- await this._applyCookies(crawlingContext, preNavigationHooksCookies, postNavigationHooksCookies);
273
+ const gotoOptions = crawlingContext.gotoOptions;
274
+ const remaining = remainingNavigationWindowMillis(crawlingContext, this.#navigationTimeoutMillis);
275
+ if (remaining <= 0) {
276
+ throw new TimeoutError(`Navigation timed out after ${this.#navigationTimeoutMillis / 1000} seconds.`);
277
+ }
278
+ // If a hook left the default `navigationTimeoutMillis` in place, bound the goto to whatever is left of the
279
+ // shared navigation window. If it overrode the value - including `0`, Playwright's "no timeout" - honour
280
+ // that verbatim as the goto's own timeout. The driver enforces this natively (so a timed-out goto is
281
+ // aborted, not left lingering) and `handleNavigationTimeout` turns its error into our own message.
282
+ const gotoTimeout = gotoOptions;
283
+ if (gotoTimeout.timeout === this.#navigationTimeoutMillis) {
284
+ gotoTimeout.timeout = remaining;
285
+ }
286
+ const cookiesBeforeHooks = readContextField(crawlingContext, COOKIES_BEFORE_HOOKS);
287
+ const cookiesAfterHooks = this.getCookieHeaderFromRequest(crawlingContext.request);
288
+ await this.applyCookies(crawlingContext, cookiesBeforeHooks, cookiesAfterHooks);
234
289
  let response;
235
290
  try {
236
- response = (await this._navigationHandler(crawlingContext, gotoOptions)) ?? undefined;
291
+ response = (await this.navigationHandler(crawlingContext, gotoOptions)) ?? undefined;
237
292
  }
238
293
  catch (error) {
239
- await this._handleNavigationTimeout(crawlingContext, error);
294
+ await this.handleNavigationTimeout(crawlingContext, error);
240
295
  crawlingContext.request.state = RequestState.ERROR;
241
- this._throwIfProxyError(error);
296
+ this.throwIfProxyError(error);
242
297
  throw error;
243
298
  }
244
299
  tryCancel();
245
300
  crawlingContext.request.state = RequestState.AFTER_NAV;
246
- await this._executeHooks(this.postNavigationHooks, crawlingContext, gotoOptions);
301
+ return { response };
302
+ }
303
+ async finalizeNavigation(crawlingContext) {
304
+ tryCancel();
305
+ let response;
306
+ try {
307
+ response = crawlingContext.response;
308
+ }
309
+ catch {
310
+ // `preparePage` installs a throwing getter for `response`; reaching this branch means
311
+ // navigation produced no response and no hook overrode it. Treat as undefined.
312
+ }
247
313
  await this.processResponse(response, crawlingContext);
248
314
  tryCancel();
249
- // save cookies
250
- // TODO: Should we save the cookies also after/only the handle page?
251
- if (this.persistCookiesPerSession) {
252
- const cookies = await crawlingContext.browserController.getCookies(crawlingContext.page);
253
- tryCancel();
254
- crawlingContext.session?.setCookies(cookies, crawlingContext.request.loadedUrl);
315
+ // Persist cookies from the navigation response before the user handler runs.
316
+ // Cookies set during `requestHandler` are saved again afterwards.
317
+ await this.persistCookiesFromPage(crawlingContext);
318
+ return { request: crawlingContext.request };
319
+ }
320
+ /**
321
+ * Copies cookies from the live browser page into the session cookie jar.
322
+ */
323
+ async persistCookiesFromPage(crawlingContext) {
324
+ if (!this.#saveResponseCookies || !crawlingContext.session) {
325
+ return;
255
326
  }
256
- if (response !== undefined) {
257
- return {
258
- request: crawlingContext.request,
259
- response,
260
- };
327
+ const { cookies } = await this.browserPool.extractPageState(crawlingContext.page);
328
+ tryCancel();
329
+ // Prefer the live page URL — the handler may have navigated after the initial load.
330
+ const url = (await crawlingContext.page.url()) || crawlingContext.request.loadedUrl || crawlingContext.request.url;
331
+ for (const cookie of cookies) {
332
+ try {
333
+ await crawlingContext.session.cookieJar.setCookie(browserPoolCookieToToughCookie(cookie), url, {
334
+ ignoreError: false,
335
+ });
336
+ }
337
+ catch (e) {
338
+ this.log.debug(`Could not set cookie: ${e.message}`);
339
+ }
340
+ }
341
+ }
342
+ /**
343
+ * Runs the user request handler, then re-reads browser cookies so login flows /
344
+ * `page.setCookie` / XHR `Set-Cookie` updates are stored for later requests.
345
+ */
346
+ async runRequestHandler(crawlingContext) {
347
+ try {
348
+ await super.runRequestHandler(crawlingContext);
349
+ }
350
+ finally {
351
+ if (!crawlingContext.request.skipNavigation) {
352
+ try {
353
+ await this.persistCookiesFromPage(crawlingContext);
354
+ }
355
+ catch {
356
+ // Page may already be closed on some failure paths; ignore.
357
+ }
358
+ }
261
359
  }
262
- return {
263
- request: crawlingContext.request,
264
- };
265
360
  }
266
361
  async handleBlockedRequestByContent(crawlingContext) {
267
362
  if (this.retryOnBlocked) {
@@ -275,30 +370,38 @@ export class BrowserCrawler extends BasicCrawler {
275
370
  crawlingContext.request.state = RequestState.REQUEST_HANDLER;
276
371
  return {};
277
372
  }
278
- async _applyCookies({ session, request, page, browserController }, preHooksCookies, postHooksCookies) {
279
- const sessionCookie = session?.getCookies(request.url) ?? [];
373
+ async applyCookies({ session, request, page }, preHooksCookies, postHooksCookies) {
374
+ const sessionCookie = session
375
+ ? (await session.cookieJar.getCookies(request.url)).map(toughCookieToBrowserPoolCookie)
376
+ : [];
280
377
  const parsedPreHooksCookies = preHooksCookies.split(/ *; */).map((c) => cookieStringToToughCookie(c));
281
378
  const parsedPostHooksCookies = postHooksCookies.split(/ *; */).map((c) => cookieStringToToughCookie(c));
282
- await browserController.setCookies(page, [...sessionCookie, ...parsedPreHooksCookies, ...parsedPostHooksCookies]
379
+ const cookies = [...sessionCookie, ...parsedPreHooksCookies, ...parsedPostHooksCookies]
283
380
  .filter((c) => typeof c !== 'undefined' && c !== null)
284
- .map((c) => ({ ...c, url: c.domain ? undefined : request.url })));
381
+ .map((c) => ({ ...c, url: c.domain ? undefined : request.url }));
382
+ await this.browserPool.injectPageState(page, { cookies });
285
383
  }
286
384
  /**
287
- * Marks session bad in case of navigation timeout.
385
+ * Marks session bad on navigation timeout, and stops in-flight page loading on any navigation error.
288
386
  */
289
- async _handleNavigationTimeout(crawlingContext, error) {
290
- const { session } = crawlingContext;
291
- if (error && error.constructor.name === 'TimeoutError') {
292
- handleRequestTimeout({ session, errorMessage: error.message });
387
+ async handleNavigationTimeout(crawlingContext, error) {
388
+ const { session, page } = crawlingContext;
389
+ // Fire-and-forget: no user code will run on this page after a failed navigation.
390
+ // Swallow rejections: the page may already be detached.
391
+ void page.evaluate(() => window.stop()).catch(() => { });
392
+ if (isNavigationTimeoutError(error)) {
393
+ session?.markBad();
394
+ // The driver was handed the remaining window (usually shorter than `navigationTimeoutSecs` once the
395
+ // hooks have run), so it names that value in its own error; report the configured window instead.
396
+ throw new TimeoutError(`Navigation timed out after ${this.#navigationTimeoutMillis / 1000} seconds.`);
293
397
  }
294
- await crawlingContext.page.close();
295
398
  }
296
399
  /**
297
400
  * Transforms proxy-related errors to `SessionError`.
298
401
  */
299
- _throwIfProxyError(error) {
402
+ throwIfProxyError(error) {
300
403
  if (this.isProxyError(error)) {
301
- throw new SessionError(this._getMessageFromError(error));
404
+ throw new SessionError(this.getMessageFromError(error));
302
405
  }
303
406
  }
304
407
  async processResponse(response, crawlingContext) {
@@ -306,10 +409,25 @@ export class BrowserCrawler extends BasicCrawler {
306
409
  if (typeof response === 'object' && typeof response.status === 'function') {
307
410
  const status = response.status();
308
411
  this.stats.registerStatusCode(status);
412
+ // Ahead of the error-status throw below: a 429 the user opted into treating as an error is still a
413
+ // rate limit the domain should back off from.
414
+ if (status === 429) {
415
+ // Both drivers lower-case header names and join duplicates, so a plain lookup is enough.
416
+ const retryAfter = response.headers?.()['retry-after'];
417
+ if (this.recordDomainRateLimit(crawlingContext.request.url, retryAfter)) {
418
+ throw new RequestThrottledError(`${crawlingContext.request.url} responded with 429.`);
419
+ }
420
+ }
421
+ if (this.isErrorStatusCode(status)) {
422
+ if (this.additionalHttpErrorStatusCodes.has(status)) {
423
+ throw new Error(`${status} - Error status code was set by user.`);
424
+ }
425
+ throw new Error(`${status} - Internal Server Error`);
426
+ }
309
427
  }
310
428
  if (this.sessionPool && response && session) {
311
429
  if (typeof response === 'object' && typeof response.status === 'function') {
312
- this._throwOnBlockedRequest(session, response.status());
430
+ this.throwOnBlockedRequest(response.status());
313
431
  }
314
432
  else {
315
433
  this.log.debug('Got a malformed Browser response.', { request, response });
@@ -317,70 +435,43 @@ export class BrowserCrawler extends BasicCrawler {
317
435
  }
318
436
  request.loadedUrl = await page.url();
319
437
  }
320
- async _extendLaunchContext(_pageId, launchContext) {
321
- const launchContextExtends = {};
322
- if (this.sessionPool) {
323
- launchContextExtends.session = await this.sessionPool.newSession({
324
- proxyInfo: await this.proxyConfiguration?.newProxyInfo({
325
- // cannot pass a request here, since session is created on browser launch
326
- }),
327
- });
328
- }
329
- if (!launchContext.proxyUrl && launchContextExtends.session?.proxyInfo) {
330
- const proxyInfo = launchContextExtends.session.proxyInfo;
331
- launchContext.proxyUrl = proxyInfo?.url;
332
- launchContextExtends.proxyInfo = proxyInfo;
333
- // Disable SSL verification for MITM proxies
334
- if (proxyInfo?.ignoreTlsErrors) {
335
- /**
336
- * @see https://playwright.dev/docs/api/class-browser/#browser-new-context
337
- * @see https://github.com/puppeteer/puppeteer/blob/main/docs/api.md
338
- */
339
- launchContext.launchOptions.ignoreHTTPSErrors = true;
340
- launchContext.launchOptions.acceptInsecureCerts = true;
341
- }
342
- }
343
- launchContext.extend(launchContextExtends);
344
- }
345
- _maybeAddSessionRetiredListener(_pageId, browserController) {
346
- if (this.sessionPool) {
347
- const listener = (session) => {
348
- const { launchContext } = browserController;
349
- if (session.id === launchContext.session.id) {
350
- this.browserPool.retireBrowserController(browserController);
351
- }
352
- };
353
- this.sessionPool.on(EVENT_SESSION_RETIRED, listener);
354
- browserController.on("browserClosed" /* BROWSER_CONTROLLER_EVENTS.BROWSER_CLOSED */, () => {
355
- return this.sessionPool.removeListener(EVENT_SESSION_RETIRED, listener);
356
- });
357
- }
358
- }
359
438
  /**
360
439
  * Function for cleaning up after all requests are processed.
361
440
  * @ignore
362
441
  */
363
442
  async teardown() {
364
- await this.browserPool.destroy();
443
+ await this.#browserPoolDep.ifOwned((pool) => pool.destroy());
365
444
  await super.teardown();
366
445
  }
367
446
  }
368
447
  /** @internal */
369
- export async function browserCrawlerEnqueueLinks({ options, page, requestQueue, robotsTxtFile, onSkippedRequest, originalRequestUrl, finalRequestUrl, }) {
448
+ function containsEnqueueLinks(options) {
449
+ return !!options.enqueueLinks;
450
+ }
451
+ /** @internal */
452
+ export async function browserCrawlerEnqueueLinks(options) {
453
+ const { options: enqueueLinksOptions, finalRequestUrl, originalRequestUrl, page } = options;
370
454
  const baseUrl = resolveBaseUrlForEnqueueLinksFiltering({
371
- enqueueStrategy: options?.strategy,
455
+ enqueueStrategy: enqueueLinksOptions?.strategy,
372
456
  finalRequestUrl,
373
457
  originalRequestUrl,
374
- userProvidedBaseUrl: options?.baseUrl,
458
+ userProvidedBaseUrl: enqueueLinksOptions?.baseUrl,
375
459
  });
376
- const urls = await extractUrlsFromPage(page, options?.selector ?? 'a', options?.baseUrl ?? finalRequestUrl ?? originalRequestUrl);
460
+ const urls = await extractUrlsFromPage(page, enqueueLinksOptions?.selector ?? 'a', enqueueLinksOptions?.baseUrl ?? finalRequestUrl ?? originalRequestUrl);
461
+ if (containsEnqueueLinks(options)) {
462
+ return options.enqueueLinks({
463
+ urls,
464
+ baseUrl,
465
+ ...enqueueLinksOptions,
466
+ });
467
+ }
377
468
  return enqueueLinks({
378
- requestQueue,
379
- robotsTxtFile,
380
- onSkippedRequest,
469
+ requestManager: options.requestManager,
470
+ robotsTxtFile: options.robotsTxtFile,
471
+ onSkippedRequest: options.onSkippedRequest,
381
472
  urls,
382
473
  baseUrl,
383
- ...options,
474
+ ...enqueueLinksOptions,
384
475
  });
385
476
  }
386
477
  /**
@@ -408,4 +499,3 @@ page, selector, baseUrl) {
408
499
  })
409
500
  .filter((href) => !!href);
410
501
  }
411
- //# sourceMappingURL=browser-crawler.js.map