@crawlee/browser 4.0.0-beta.1 → 4.0.0-beta.100

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