@crawlee/browser 4.0.0-beta.95 → 4.0.0-beta.97

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.
@@ -158,7 +158,11 @@ export interface BrowserCrawlerOptions<Page extends CommonPage = CommonPage, Res
158
158
  */
159
159
  postNavigationHooks?: BrowserHook<Context, ContextExtension>[];
160
160
  /**
161
- * Timeout in which page navigation needs to finish, in seconds.
161
+ * Timeout for the whole navigation phase, in seconds. A single window shared by the `preNavigationHooks`,
162
+ * the page navigation, and the `postNavigationHooks` - so a slow hook eats into the same budget the
163
+ * navigation uses. Separate from the
164
+ * {@link BasicCrawlerOptions.requestHandlerTimeoutSecs|`requestHandlerTimeoutSecs`}, which times only the
165
+ * request handler.
162
166
  */
163
167
  navigationTimeoutSecs?: number;
164
168
  /**
@@ -332,6 +336,7 @@ export declare abstract class BrowserCrawler<Page extends CommonPage = CommonPag
332
336
  protected constructor(options: BrowserCrawlerOptions<Page, Response, Context, ContextExtension, ExtendedContext> & {
333
337
  contextPipelineBuilder: () => ContextPipeline<CrawlingContext, Context>;
334
338
  });
339
+ protected getNavigationTimeoutMillis(): number;
335
340
  protected buildContextPipeline(): ContextPipeline<CrawlingContext, BrowserCrawlingContext<Page, Response, Dictionary>>;
336
341
  private containsSelectors;
337
342
  private isRequestBlocked;
@@ -339,6 +344,15 @@ export declare abstract class BrowserCrawler<Page extends CommonPage = CommonPag
339
344
  private prepareNavigation;
340
345
  private navigate;
341
346
  private finalizeNavigation;
347
+ /**
348
+ * Copies cookies from the live browser page into the session cookie jar.
349
+ */
350
+ private persistCookiesFromPage;
351
+ /**
352
+ * Runs the user request handler, then re-reads browser cookies so login flows /
353
+ * `page.setCookie` / XHR `Set-Cookie` updates are stored for later requests.
354
+ */
355
+ protected runRequestHandler(crawlingContext: ExtendedContext): Promise<void>;
342
356
  private handleBlockedRequestByContent;
343
357
  private restoreRequestState;
344
358
  private applyCookies;
@@ -1,10 +1,21 @@
1
- import { BasicCrawler, browserPoolCookieToToughCookie, ContextPipeline, cookieStringToToughCookie, enqueueLinks, handleRequestTimeout, NavigationSkippedError, OwnedOrInjected, RequestState, resolveBaseUrlForEnqueueLinksFiltering, SessionError, toughCookieToBrowserPoolCookie, tryAbsoluteURL, validators, } from '@crawlee/basic';
1
+ import { BasicCrawler, browserPoolCookieToToughCookie, ContextPipeline, cookieStringToToughCookie, enqueueLinks, NavigationSkippedError, OwnedOrInjected, remainingNavigationWindowMillis, RequestState, resolveBaseUrlForEnqueueLinksFiltering, SessionError, toughCookieToBrowserPoolCookie, tryAbsoluteURL, validators, } from '@crawlee/basic';
2
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 { tryCancel } from '@apify/timeout';
5
+ import { addTimeoutToPromise, TimeoutError, tryCancel } from '@apify/timeout';
6
6
  const COOKIES_BEFORE_HOOKS = Symbol('cookiesBeforeHooks');
7
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
+ }
8
19
  /**
9
20
  * Provides a simple framework for parallel crawling of web pages
10
21
  * using headless browsers with [Puppeteer](https://github.com/puppeteer/puppeteer)
@@ -87,13 +98,24 @@ export class BrowserCrawler extends BasicCrawler {
87
98
  super({
88
99
  ...basicCrawlerOptions,
89
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
+ });
90
112
  let pipeline = contextPipelineBuilder().compose({ action: this.prepareNavigation.bind(this) });
91
113
  for (const hook of this.preNavigationHooks) {
92
- pipeline = pipeline.compose(skipGuard(hook));
114
+ pipeline = pipeline.compose(windowGuard(hook));
93
115
  }
94
116
  pipeline = pipeline.compose(skipGuard(this.navigate.bind(this)));
95
117
  for (const hook of this.postNavigationHooks) {
96
- pipeline = pipeline.compose(skipGuard(hook));
118
+ pipeline = pipeline.compose(windowGuard(hook));
97
119
  }
98
120
  return pipeline
99
121
  .compose(skipGuard(this.finalizeNavigation.bind(this)))
@@ -145,6 +167,9 @@ export class BrowserCrawler extends BasicCrawler {
145
167
  });
146
168
  });
147
169
  }
170
+ getNavigationTimeoutMillis() {
171
+ return this.navigationTimeoutMillis;
172
+ }
148
173
  buildContextPipeline() {
149
174
  return ContextPipeline.create().compose({
150
175
  action: this.preparePage.bind(this),
@@ -236,6 +261,8 @@ export class BrowserCrawler extends BasicCrawler {
236
261
  }
237
262
  crawlingContext.request.state = RequestState.BEFORE_NAV;
238
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).
239
266
  gotoOptions: { timeout: this.navigationTimeoutMillis },
240
267
  [COOKIES_BEFORE_HOOKS]: this._getCookieHeaderFromRequest(crawlingContext.request),
241
268
  };
@@ -243,6 +270,18 @@ export class BrowserCrawler extends BasicCrawler {
243
270
  async navigate(crawlingContext) {
244
271
  tryCancel();
245
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.`);
276
+ }
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;
284
+ }
246
285
  const cookiesBeforeHooks = readContextField(crawlingContext, COOKIES_BEFORE_HOOKS);
247
286
  const cookiesAfterHooks = this._getCookieHeaderFromRequest(crawlingContext.request);
248
287
  await this.applyCookies(crawlingContext, cookiesBeforeHooks, cookiesAfterHooks);
@@ -272,23 +311,51 @@ export class BrowserCrawler extends BasicCrawler {
272
311
  }
273
312
  await this.processResponse(response, crawlingContext);
274
313
  tryCancel();
275
- // TODO: Should we save the cookies also after/only the handle page?
276
- if (this.saveResponseCookies && crawlingContext.session) {
277
- const { cookies } = await this.browserPool.extractPageState(crawlingContext.page);
278
- tryCancel();
279
- const url = crawlingContext.request.loadedUrl;
280
- for (const cookie of cookies) {
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 };
318
+ }
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);
327
+ tryCancel();
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) {
346
+ try {
347
+ await super.runRequestHandler(crawlingContext);
348
+ }
349
+ finally {
350
+ if (!crawlingContext.request.skipNavigation) {
281
351
  try {
282
- crawlingContext.session.cookieJar.setCookieSync(browserPoolCookieToToughCookie(cookie), url, {
283
- ignoreError: false,
284
- });
352
+ await this.persistCookiesFromPage(crawlingContext);
285
353
  }
286
- catch (e) {
287
- this.log.debug(`Could not set cookie: ${e.message}`);
354
+ catch {
355
+ // Page may already be closed on some failure paths; ignore.
288
356
  }
289
357
  }
290
358
  }
291
- return { request: crawlingContext.request };
292
359
  }
293
360
  async handleBlockedRequestByContent(crawlingContext) {
294
361
  if (this.retryOnBlocked) {
@@ -316,12 +383,15 @@ export class BrowserCrawler extends BasicCrawler {
316
383
  */
317
384
  async handleNavigationTimeout(crawlingContext, error) {
318
385
  const { session, page } = crawlingContext;
319
- if (error?.constructor.name === 'TimeoutError') {
320
- handleRequestTimeout({ session, errorMessage: error.message });
321
- }
322
386
  // Fire-and-forget: no user code will run on this page after a failed navigation.
323
387
  // Swallow rejections: the page may already be detached.
324
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.`);
394
+ }
325
395
  }
326
396
  /**
327
397
  * Transforms proxy-related errors to `SessionError`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crawlee/browser",
3
- "version": "4.0.0-beta.95",
3
+ "version": "4.0.0-beta.97",
4
4
  "description": "The scalable web crawling and scraping library for JavaScript/Node.js. Enables development of data extraction and web automation jobs (not only) with headless Chrome and Puppeteer.",
5
5
  "engines": {
6
6
  "node": ">=22.0.0"
@@ -47,11 +47,11 @@
47
47
  "access": "public"
48
48
  },
49
49
  "dependencies": {
50
- "@apify/timeout": "^0.3.2",
51
- "@crawlee/basic": "4.0.0-beta.95",
52
- "@crawlee/browser-pool": "4.0.0-beta.95",
53
- "@crawlee/types": "4.0.0-beta.95",
54
- "@crawlee/utils": "4.0.0-beta.95",
50
+ "@apify/timeout": "^0.4.4",
51
+ "@crawlee/basic": "4.0.0-beta.97",
52
+ "@crawlee/browser-pool": "4.0.0-beta.97",
53
+ "@crawlee/types": "4.0.0-beta.97",
54
+ "@crawlee/utils": "4.0.0-beta.97",
55
55
  "ow": "^2.0.0",
56
56
  "tslib": "^2.8.1",
57
57
  "type-fest": "^4.41.0"
@@ -75,5 +75,5 @@
75
75
  }
76
76
  }
77
77
  },
78
- "gitHead": "ba8602d011706fb5c9930232402156eb5b657eb3"
78
+ "gitHead": "43008dd43f4832d083353ba1b731c0b4607c9cfa"
79
79
  }