@crawlee/basic 4.0.0-beta.220 → 4.0.0-beta.221

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.
@@ -297,7 +297,8 @@ export interface BasicCrawlerOptions<Context extends CrawlingContext = CrawlingC
297
297
  * 1. based on robots.txt file,
298
298
  * 2. because they don't match enqueueLinks filters,
299
299
  * 3. because they are redirected to a URL that doesn't match the enqueueLinks strategy,
300
- * 4. or because the {@link BasicCrawlerOptions.maxRequestsPerCrawl|`maxRequestsPerCrawl`} limit has been reached
300
+ * 4. because the {@link BasicCrawlerOptions.maxRequestsPerCrawl|`maxRequestsPerCrawl`} limit has been reached,
301
+ * 5. or by {@link RestrictedCrawlingContext.skipRequest|`context.skipRequest()`}.
301
302
  */
302
303
  onSkippedRequest?: SkippedRequestCallback;
303
304
  /**
@@ -583,13 +583,7 @@ export class BasicCrawler {
583
583
  async #checkRobotsTxt({ request }) {
584
584
  if (!(await this.#isAllowedBasedOnRobotsTxtFile(request.url))) {
585
585
  this.log.warning(`Skipping request ${request.url} (${request.id}) because it is disallowed based on robots.txt`);
586
- request.state = RequestState.SKIPPED;
587
- request.noRetry = true;
588
- await this.#handleSkippedRequest({
589
- request,
590
- reason: 'robotsTxt',
591
- });
592
- throw new ContextPipelineInterruptedError(`Skipping request ${request.url} as disallowed by robots.txt`);
586
+ throw new ContextPipelineInterruptedError('robotsTxt');
593
587
  }
594
588
  return {};
595
589
  }
@@ -635,6 +629,10 @@ export class BasicCrawler {
635
629
  this.log.debug('Extending the request processing time failed', { url: context.request.url, error });
636
630
  });
637
631
  },
632
+ skipRequest: (message) => {
633
+ this.log.debug(`Skipping request ${context.request.url} (${context.request.id})${message ? `: ${message}` : ''}`);
634
+ throw new ContextPipelineInterruptedError('manual', message);
635
+ },
638
636
  };
639
637
  }
640
638
  /**
@@ -700,10 +698,7 @@ export class BasicCrawler {
700
698
  // eslint-disable-next-line dot-notation
701
699
  const message = `Skipping request ${request.id} (starting url: ${request.url} -> loaded url: ${request.loadedUrl}) because it does not match the enqueue strategy (${request['enqueueStrategy']}).`;
702
700
  this.log.debug(message);
703
- request.noRetry = true;
704
- request.state = RequestState.SKIPPED;
705
- await this.#handleSkippedRequest({ request, reason: 'redirect' });
706
- throw new ContextPipelineInterruptedError(message);
701
+ throw new ContextPipelineInterruptedError('redirect');
707
702
  }
708
703
  return context;
709
704
  });
@@ -1525,23 +1520,30 @@ export class BasicCrawler {
1525
1520
  // Roll back *before* any error handler runs - error handlers write to real storage precisely
1526
1521
  // because the transaction is already closed. A no-op when the commit in `handleRequest` succeeded.
1527
1522
  currentStorageTransaction()?.rollback();
1528
- const skipped = rawError instanceof ContextPipelineInitializationError &&
1529
- rawError.cause instanceof ContextPipelineInterruptedError;
1530
- const err = this.#unwrapError(rawError);
1531
- const context = crawlingContext;
1532
- const retryCountBefore = request.retryCount;
1533
1523
  const resumeRequestTimeout = crawlingContext[suspendTimeoutKey]?.();
1534
1524
  try {
1535
- // ContextPipelineInterruptedError means the request was intentionally skipped
1536
- // (e.g., doesn't match enqueue strategy after redirect). Just return gracefully.
1537
- if (skipped) {
1538
- this.statistics.discardRequestRecord(request.id || request.uniqueKey);
1539
- await this.#timeoutAndRetry(async () => requestSource.markRequestAsHandled(request), this.internalTimeoutMillis, `Marking request ${request.url} (${request.id}) as handled timed out after ${this.internalTimeoutMillis / 1e3} seconds.`);
1540
- return;
1525
+ const unwrappedError = this.#unwrapError(rawError);
1526
+ // A failing or timed out `onSkippedRequest` makes this an ordinary failure, so the request gets retried.
1527
+ const err = unwrappedError instanceof ContextPipelineInterruptedError
1528
+ ? await this.#trySkipRequest(unwrappedError, request, requestSource)
1529
+ : unwrappedError;
1530
+ if (err) {
1531
+ await this.#runErrorHandler(err, crawlingContext, requestSource, request);
1541
1532
  }
1533
+ }
1534
+ finally {
1535
+ resumeRequestTimeout?.();
1536
+ }
1537
+ }
1538
+ async #runErrorHandler(err, context, requestSource, request) {
1539
+ const retryCountBefore = request.retryCount;
1540
+ try {
1542
1541
  request.state = RequestState.ERROR_HANDLER;
1543
1542
  await addTimeoutToPromise(async () => this.requestFunctionErrorHandler(err, context, request, requestSource), this.internalTimeoutMillis, `Handling request failure of ${request.url} (${request.id}) timed out after ${this.internalTimeoutMillis / 1e3} seconds.`);
1544
- request.state = RequestState.DONE;
1543
+ // The error handler may have set `SKIPPED`; TS keeps the narrowing from the assignment above.
1544
+ if (request.state !== RequestState.SKIPPED) {
1545
+ request.state = RequestState.DONE;
1546
+ }
1545
1547
  }
1546
1548
  catch (secondaryError) {
1547
1549
  const unwrappedSecondaryError = this.#unwrapError(secondaryError);
@@ -1551,7 +1553,7 @@ export class BasicCrawler {
1551
1553
  // Not worth crashing the crawler over. The request's state is unknown, so it gets retried - and the
1552
1554
  // retry is counted, so that an error handler that always times out does not retry it forever.
1553
1555
  this.log.warning(unwrappedSecondaryError.message);
1554
- if (!skipped && request.retryCount === retryCountBefore) {
1556
+ if (request.retryCount === retryCountBefore) {
1555
1557
  request.retryCount++;
1556
1558
  }
1557
1559
  }
@@ -1575,12 +1577,9 @@ export class BasicCrawler {
1575
1577
  throw unwrappedSecondaryError;
1576
1578
  }
1577
1579
  }
1578
- finally {
1579
- resumeRequestTimeout?.();
1580
- }
1581
1580
  // decrease the session score if the request fails (but the error handler did not throw);
1582
1581
  // skip when the error is a SessionError, which already retired the session
1583
- if (!skipped && !this.#errorAbsolvesSession(err)) {
1582
+ if (!this.#errorAbsolvesSession(err)) {
1584
1583
  context.session?.markBad();
1585
1584
  }
1586
1585
  }
@@ -1671,8 +1670,26 @@ export class BasicCrawler {
1671
1670
  const shouldRetryRequest = this.#canRequestBeRetried(request, error);
1672
1671
  if (shouldRetryRequest) {
1673
1672
  await this.statistics.errorTrackerRetry.addAsync(error, crawlingContext);
1674
- await this.#errorHandler?.(crawlingContext, // valid cast - ExtendedContext transitively extends CrawlingContext
1675
- error);
1673
+ try {
1674
+ await this.#errorHandler?.(crawlingContext, // valid cast - ExtendedContext transitively extends CrawlingContext
1675
+ error);
1676
+ }
1677
+ catch (handlerError) {
1678
+ if (handlerError instanceof ContextPipelineInterruptedError) {
1679
+ const skipError = await this.#trySkipRequest(handlerError, request, source);
1680
+ if (!skipError) {
1681
+ return;
1682
+ }
1683
+ // Retry for the original error. Routing `skipError` through `errorHandler` again
1684
+ // would loop forever with a handler that always skips.
1685
+ this.log.warning(`Skipping request ${request.url} failed, retrying it instead.`, {
1686
+ error: skipError.message,
1687
+ });
1688
+ }
1689
+ else {
1690
+ throw handlerError;
1691
+ }
1692
+ }
1676
1693
  if (error instanceof SessionError) {
1677
1694
  crawlingContext.session?.retire();
1678
1695
  }
@@ -1711,16 +1728,35 @@ export class BasicCrawler {
1711
1728
  this.statistics.recordRequestFailure(request.id || request.uniqueKey, request.retryCount);
1712
1729
  await this.handleFailedRequestHandler(crawlingContext, error); // This function prints an error message.
1713
1730
  }
1731
+ /** Settles a skipped request. Returns the error that prevented that, leaving the request untouched if `onSkippedRequest` failed. */
1732
+ async #trySkipRequest(error, request, source) {
1733
+ try {
1734
+ await addTimeoutToPromise(async () => this.#handleSkippedRequest({ request, reason: error.reason, message: error.skipMessage }), this.internalTimeoutMillis, `onSkippedRequest for ${request.url} (${request.id}) timed out after ${this.internalTimeoutMillis / 1e3} seconds.`);
1735
+ request.state = RequestState.SKIPPED;
1736
+ request.noRetry = true;
1737
+ await this.#timeoutAndRetry(async () => source.markRequestAsHandled(request), this.internalTimeoutMillis, `Marking request ${request.url} (${request.id}) as handled timed out after ${this.internalTimeoutMillis / 1e3} seconds.`);
1738
+ this.statistics.discardRequestRecord(request.id || request.uniqueKey);
1739
+ return undefined;
1740
+ }
1741
+ catch (settleError) {
1742
+ return this.#unwrapError(settleError);
1743
+ }
1744
+ }
1714
1745
  // oxlint-disable-next-line crawlee/prefer-private-fields -- patched by @crawlee/otel
1715
1746
  async handleFailedRequestHandler(crawlingContext, error) {
1716
1747
  // Always log the last error regardless if the user provided a failedRequestHandler
1717
1748
  const { id, url, method, uniqueKey } = crawlingContext.request;
1718
1749
  const message = this.getMessageFromError(error, true);
1719
1750
  this.log.error(`Request failed and reached maximum retries. ${message}`, { id, url, method, uniqueKey });
1720
- if (this.#failedRequestHandler) {
1751
+ try {
1721
1752
  await this.#failedRequestHandler?.(crawlingContext, // valid cast - ExtendedContext transitively extends CrawlingContext
1722
1753
  error);
1723
1754
  }
1755
+ catch (handlerError) {
1756
+ if (!(handlerError instanceof ContextPipelineInterruptedError))
1757
+ throw handlerError;
1758
+ this.log.warning(`skipRequest() has no effect in failedRequestHandler, request ${url} (${id}) has already failed.`);
1759
+ }
1724
1760
  }
1725
1761
  /**
1726
1762
  * Resolves the most verbose error message from a thrown error
@@ -1747,7 +1783,9 @@ export class BasicCrawler {
1747
1783
  * failure says nothing about the session (a rate limit is a property of the domain).
1748
1784
  */
1749
1785
  #errorAbsolvesSession(error) {
1750
- return error instanceof SessionError || error instanceof RequestThrottledError;
1786
+ return (error instanceof SessionError ||
1787
+ error instanceof RequestThrottledError ||
1788
+ error instanceof ContextPipelineInterruptedError);
1751
1789
  }
1752
1790
  #canRequestBeRetried(request, error) {
1753
1791
  // Request should never be retried, or the error encountered makes it not able to be retried.
@@ -102,6 +102,28 @@ export interface RestrictedCrawlingContext<UserData extends Dictionary = Diction
102
102
  * A preconfigured logger for the request handler.
103
103
  */
104
104
  log: CrawleeLogger;
105
+ /**
106
+ * Stops processing the current request and marks it as handled without counting it as a failure: no retry, no
107
+ * `failedRequestHandler`, and no error log. The request's `state` becomes {@link RequestState.SKIPPED|`SKIPPED`}
108
+ * and {@link BasicCrawlerOptions.onSkippedRequest|`onSkippedRequest`} fires with the `'manual'` reason.
109
+ *
110
+ * Storage writes made for the request so far are rolled back, as with a failure, unless
111
+ * {@link BasicCrawlerOptions.transactionalStorage|`transactionalStorage`} is disabled.
112
+ *
113
+ * Works in `errorHandler` too, skipping a request that would otherwise be retried. In `failedRequestHandler` it
114
+ * has no effect, since the request has already failed.
115
+ *
116
+ * ```ts
117
+ * preNavigationHooks: [
118
+ * async ({ request, skipRequest }) => {
119
+ * if (await alreadyScraped(request.url)) skipRequest('already scraped');
120
+ * },
121
+ * ],
122
+ * ```
123
+ *
124
+ * @param message Passed to `onSkippedRequest`.
125
+ */
126
+ skipRequest(message?: string): never;
105
127
  }
106
128
  export interface CrawlingContext<UserData extends Dictionary = Dictionary> extends RestrictedCrawlingContext<UserData> {
107
129
  /**
@@ -1,7 +1,7 @@
1
1
  import type { EnqueueStrategyOption } from '@crawlee/core';
2
2
  import { Request } from '@crawlee/core';
3
3
  import type { Dictionary, RequestSchema } from '@crawlee/types';
4
- export type SkippedRequestReason = 'robotsTxt' | 'limit' | 'enqueueLimit' | 'filters' | 'transform' | 'redirect' | 'depth';
4
+ export type SkippedRequestReason = 'robotsTxt' | 'limit' | 'enqueueLimit' | 'filters' | 'transform' | 'redirect' | 'depth' | 'manual';
5
5
  export declare enum RequestState {
6
6
  UNPROCESSED = 0,
7
7
  BEFORE_NAV = 1,
@@ -21,6 +21,8 @@ export declare const urlPatternSchema: z.ZodType<UrlPatternInput>;
21
21
  export type SkippedRequestCallback = (args: {
22
22
  request: Request;
23
23
  reason: SkippedRequestReason;
24
+ /** The `message` passed to {@link RestrictedCrawlingContext.skipRequest|`context.skipRequest()`}, if any. */
25
+ message?: string;
24
26
  }) => Awaitable<void>;
25
27
  /**
26
28
  * Builds the `{ request, reason }` argument passed to a {@link SkippedRequestCallback}, constructing the
@@ -1,4 +1,5 @@
1
1
  import { CriticalError, NonRetryableError } from '@crawlee/core';
2
+ import type { SkippedRequestReason } from './crawling_request.js';
2
3
  /**
3
4
  * @ignore
4
5
  */
@@ -38,8 +39,15 @@ export declare class PersistentRateLimitError extends CriticalError {
38
39
  export declare class MissingSessionError extends Error {
39
40
  constructor(sessionId?: string);
40
41
  }
42
+ /**
43
+ * Settles the request as skipped rather than failed: no retry, no failure, and its storage writes are rolled back.
44
+ * Use {@link RestrictedCrawlingContext.skipRequest|`context.skipRequest()`} rather than throwing this directly.
45
+ * @internal
46
+ */
41
47
  export declare class ContextPipelineInterruptedError extends Error {
42
- constructor(message?: string);
48
+ readonly reason: SkippedRequestReason;
49
+ readonly skipMessage?: string | undefined;
50
+ constructor(reason: SkippedRequestReason, skipMessage?: string | undefined);
43
51
  }
44
52
  export declare class ContextPipelineCleanupError extends CriticalError {
45
53
  constructor(error: unknown, options?: ErrorOptions);
@@ -44,9 +44,18 @@ export class MissingSessionError extends Error {
44
44
  super(`The current SessionPool instance couldn't find a valid session${sessionId ? ` for the following id: ${sessionId}.` : '.'}`);
45
45
  }
46
46
  }
47
+ /**
48
+ * Settles the request as skipped rather than failed: no retry, no failure, and its storage writes are rolled back.
49
+ * Use {@link RestrictedCrawlingContext.skipRequest|`context.skipRequest()`} rather than throwing this directly.
50
+ * @internal
51
+ */
47
52
  export class ContextPipelineInterruptedError extends Error {
48
- constructor(message) {
49
- super(`Request handling was interrupted during context initialization ${message ? ` - ${message}` : ''}`);
53
+ reason;
54
+ skipMessage;
55
+ constructor(reason, skipMessage) {
56
+ super(skipMessage ?? 'Request was skipped');
57
+ this.reason = reason;
58
+ this.skipMessage = skipMessage;
50
59
  }
51
60
  }
52
61
  export class ContextPipelineCleanupError extends CriticalError {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crawlee/basic",
3
- "version": "4.0.0-beta.220",
3
+ "version": "4.0.0-beta.221",
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.13.0"
@@ -42,10 +42,10 @@
42
42
  "@apify/datastructures": "^3.0.1",
43
43
  "@apify/timeout": "^1.0.1",
44
44
  "@apify/utilities": "^3.0.1",
45
- "@crawlee/core": "4.0.0-beta.220",
46
- "@crawlee/http-client": "4.0.0-beta.220",
47
- "@crawlee/types": "4.0.0-beta.220",
48
- "@crawlee/utils": "4.0.0-beta.220",
45
+ "@crawlee/core": "4.0.0-beta.221",
46
+ "@crawlee/http-client": "4.0.0-beta.221",
47
+ "@crawlee/types": "4.0.0-beta.221",
48
+ "@crawlee/utils": "4.0.0-beta.221",
49
49
  "@sapphire/async-queue": "^1.5.5",
50
50
  "@standard-schema/spec": "^1.0.0",
51
51
  "csv-stringify": "^6.5.2",
@@ -57,7 +57,7 @@
57
57
  "zod": "^4.5.4"
58
58
  },
59
59
  "optionalDependencies": {
60
- "@crawlee/impit-client": "4.0.0-beta.220"
60
+ "@crawlee/impit-client": "4.0.0-beta.221"
61
61
  },
62
62
  "lerna": {
63
63
  "command": {
@@ -66,5 +66,5 @@
66
66
  }
67
67
  }
68
68
  },
69
- "gitHead": "c9ef485f92968109ea496d121c9189b873c07ce8"
69
+ "gitHead": "08c8472ab3ae7ec4d81fedd099f616c7ed10a263"
70
70
  }