@crawlee/core 3.17.1-beta.54 → 3.17.1-beta.55

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.
package/errors.d.ts CHANGED
@@ -14,6 +14,26 @@ export declare class CriticalError extends NonRetryableError {
14
14
  */
15
15
  export declare class MissingRouteError extends CriticalError {
16
16
  }
17
+ /**
18
+ * Thrown when a request's `userData` does not match the {@link RouteSchemas|Standard Schema} registered for its label.
19
+ *
20
+ * As the `userData` does not change between attempts, this error is non-retryable.
21
+ */
22
+ export declare class RequestValidationError extends NonRetryableError {
23
+ readonly label: string | symbol;
24
+ readonly issues: readonly {
25
+ readonly message: string;
26
+ readonly path?: readonly (PropertyKey | {
27
+ key: PropertyKey;
28
+ })[];
29
+ }[];
30
+ constructor(label: string | symbol, issues: readonly {
31
+ readonly message: string;
32
+ readonly path?: readonly (PropertyKey | {
33
+ key: PropertyKey;
34
+ })[];
35
+ }[]);
36
+ }
17
37
  /**
18
38
  * Errors of `RetryRequestError` type will always be retried by the crawler.
19
39
  *
package/errors.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.SessionError = exports.RetryRequestError = exports.MissingRouteError = exports.CriticalError = exports.NonRetryableError = void 0;
3
+ exports.SessionError = exports.RetryRequestError = exports.RequestValidationError = exports.MissingRouteError = exports.CriticalError = exports.NonRetryableError = void 0;
4
4
  /**
5
5
  * Errors of `NonRetryableError` type will never be retried by the crawler.
6
6
  */
@@ -20,6 +20,37 @@ exports.CriticalError = CriticalError;
20
20
  class MissingRouteError extends CriticalError {
21
21
  }
22
22
  exports.MissingRouteError = MissingRouteError;
23
+ /**
24
+ * Thrown when a request's `userData` does not match the {@link RouteSchemas|Standard Schema} registered for its label.
25
+ *
26
+ * As the `userData` does not change between attempts, this error is non-retryable.
27
+ */
28
+ class RequestValidationError extends NonRetryableError {
29
+ constructor(label, issues) {
30
+ const details = issues
31
+ .map((issue) => {
32
+ const path = (issue.path ?? [])
33
+ .map((segment) => (typeof segment === 'object' ? segment.key : segment))
34
+ .join('.');
35
+ return `- ${path ? `${path}: ` : ''}${issue.message}`;
36
+ })
37
+ .join('\n');
38
+ super(`Request userData for label '${String(label)}' failed schema validation:\n${details}`);
39
+ Object.defineProperty(this, "label", {
40
+ enumerable: true,
41
+ configurable: true,
42
+ writable: true,
43
+ value: label
44
+ });
45
+ Object.defineProperty(this, "issues", {
46
+ enumerable: true,
47
+ configurable: true,
48
+ writable: true,
49
+ value: issues
50
+ });
51
+ }
52
+ }
53
+ exports.RequestValidationError = RequestValidationError;
23
54
  /**
24
55
  * Errors of `RetryRequestError` type will always be retried by the crawler.
25
56
  *
package/index.mjs CHANGED
@@ -46,6 +46,7 @@ export const RequestQueue = mod.RequestQueue;
46
46
  export const RequestQueueV1 = mod.RequestQueueV1;
47
47
  export const RequestQueueV2 = mod.RequestQueueV2;
48
48
  export const RequestState = mod.RequestState;
49
+ export const RequestValidationError = mod.RequestValidationError;
49
50
  export const RetryRequestError = mod.RetryRequestError;
50
51
  export const Router = mod.Router;
51
52
  export const STATE_PERSISTENCE_KEY = mod.STATE_PERSISTENCE_KEY;
@@ -73,6 +74,7 @@ export const createDeserialize = mod.createDeserialize;
73
74
  export const createEventLoopLoadSignal = mod.createEventLoopLoadSignal;
74
75
  export const createRequestOptions = mod.createRequestOptions;
75
76
  export const createRequests = mod.createRequests;
77
+ export const defaultRoute = mod.defaultRoute;
76
78
  export const deserializeArray = mod.deserializeArray;
77
79
  export const enqueueLinks = mod.enqueueLinks;
78
80
  export const evaluateLoadSignalSample = mod.evaluateLoadSignalSample;
@@ -93,5 +95,6 @@ export const tryAbsoluteURL = mod.tryAbsoluteURL;
93
95
  export const updateEnqueueLinksPatternCache = mod.updateEnqueueLinksPatternCache;
94
96
  export const useState = mod.useState;
95
97
  export const validateGlobPattern = mod.validateGlobPattern;
98
+ export const validateUserData = mod.validateUserData;
96
99
  export const validators = mod.validators;
97
100
  export const withCheckedStorageAccess = mod.withCheckedStorageAccess;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crawlee/core",
3
- "version": "3.17.1-beta.54",
3
+ "version": "3.17.1-beta.55",
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": ">=16.0.0"
@@ -59,10 +59,11 @@
59
59
  "@apify/pseudo_url": "^2.0.30",
60
60
  "@apify/timeout": "^0.3.0",
61
61
  "@apify/utilities": "^2.7.10",
62
- "@crawlee/memory-storage": "3.17.1-beta.54",
63
- "@crawlee/types": "3.17.1-beta.54",
64
- "@crawlee/utils": "3.17.1-beta.54",
62
+ "@crawlee/memory-storage": "3.17.1-beta.55",
63
+ "@crawlee/types": "3.17.1-beta.55",
64
+ "@crawlee/utils": "3.17.1-beta.55",
65
65
  "@sapphire/async-queue": "^1.5.1",
66
+ "@standard-schema/spec": "^1.0.0",
66
67
  "@vladfrangu/async_event_emitter": "^2.2.2",
67
68
  "csv-stringify": "^6.2.0",
68
69
  "fs-extra": "^11.0.0",
@@ -83,5 +84,5 @@
83
84
  }
84
85
  }
85
86
  },
86
- "gitHead": "45d0756fd5f016539d6e2f80f72340a652ddc48f"
87
+ "gitHead": "93d29e305f01ca6ca9bb79264a5ca553b19766ef"
87
88
  }
package/router.d.ts CHANGED
@@ -1,13 +1,43 @@
1
1
  import type { Dictionary } from '@crawlee/types';
2
+ import type { StandardSchemaV1 } from '@standard-schema/spec';
2
3
  import type { CrawlingContext, LoadedRequest, RestrictedCrawlingContext } from './crawlers/crawler_commons';
3
4
  import type { Request } from './request';
4
5
  import type { Awaitable } from './typedefs';
6
+ /**
7
+ * The key of the default route — the fallback handler registered via {@link Router.addDefaultHandler}.
8
+ * Use it in a {@link RouteSchemas} map to register a schema that validates the `userData` of every request
9
+ * that falls through to the default handler (i.e. whose label has no route of its own).
10
+ */
11
+ export declare const defaultRoute: unique symbol;
5
12
  /**
6
13
  * The crawling context received by a route handler, with `request.userData` narrowed to `UserData`.
7
14
  */
8
15
  export type RouterHandlerContext<Context, UserData extends Dictionary> = Omit<Context, 'request'> & {
9
16
  request: LoadedRequest<Request<UserData>>;
10
17
  };
18
+ /**
19
+ * A map of request labels to a [Standard Schema](https://standardschema.dev) (Zod, Valibot, ArkType, …)
20
+ * validating that label's `request.userData`. Pass it to {@link Router.create} or a `createXRouter`
21
+ * factory to derive the per-label `request.userData` types *and* validate them at runtime. The optional
22
+ * {@link defaultRoute} key registers a schema for requests handled by the default route.
23
+ */
24
+ export type RouteSchemas = Record<string, StandardSchemaV1> & {
25
+ [defaultRoute]?: StandardSchemaV1;
26
+ };
27
+ /**
28
+ * Derives a route map (label → `userData` type) from a {@link RouteSchemas} map by inferring each
29
+ * schema's output type. Outputs that are not object-shaped fall back to a plain {@link Dictionary}. The
30
+ * {@link defaultRoute} schema drives runtime validation only, so it is excluded from the typed route map.
31
+ */
32
+ export type RoutesFromSchemas<Schemas extends RouteSchemas> = {
33
+ [Label in Extract<keyof Schemas, string>]: StandardSchemaV1.InferOutput<Schemas[Label]> extends Dictionary ? StandardSchemaV1.InferOutput<Schemas[Label]> : Dictionary;
34
+ };
35
+ /**
36
+ * Validates `userData` against a {@link RouteSchemas|Standard Schema}, returning the parsed (and coerced)
37
+ * value. Throws a {@link RequestValidationError} when validation fails.
38
+ * @internal
39
+ */
40
+ export declare function validateUserData(label: string | symbol, schema: StandardSchemaV1, userData: unknown): Promise<Dictionary>;
11
41
  /**
12
42
  * The set of labels accepted by {@link Router.addHandler}. When the router declares a concrete
13
43
  * route map (e.g. `{ PRODUCT: ...; CATEGORY: ... }`), only those labels (plus symbols) are
@@ -110,9 +140,29 @@ export type RouterRoutes<Context, Routes extends Record<keyof Routes, Dictionary
110
140
  *
111
141
  * router.addHandler('TYPO', async () => {}); // compile error: not a known label
112
142
  * ```
143
+ *
144
+ * Passing a [Standard Schema](https://standardschema.dev) per label instead of a plain type both infers the
145
+ * `request.userData` types *and* validates them at runtime — when the request is handled, and when it is
146
+ * added to the crawler (`crawler.addRequests`, `context.addRequests`, `enqueueLinks`). A failing request
147
+ * throws a {@link RequestValidationError}.
148
+ *
149
+ * ```ts
150
+ * import { z } from 'zod';
151
+ * import { createCheerioRouter } from 'crawlee';
152
+ *
153
+ * const router = createCheerioRouter({
154
+ * PRODUCT: z.object({ sku: z.string(), price: z.number() }),
155
+ * CATEGORY: z.object({ categoryId: z.string() }),
156
+ * });
157
+ *
158
+ * router.addHandler('PRODUCT', async ({ request }) => {
159
+ * request.userData.price; // number, inferred from the schema and validated at runtime
160
+ * });
161
+ * ```
113
162
  */
114
163
  export declare class Router<Context extends Omit<RestrictedCrawlingContext, 'enqueueLinks'>, Routes extends Record<keyof Routes, Dictionary> = Record<string, GetUserDataFromRequest<Context['request']>>> {
115
164
  private readonly routes;
165
+ private readonly schemas;
116
166
  private readonly middlewares;
117
167
  /**
118
168
  * use Router.create() instead!
@@ -136,6 +186,12 @@ export declare class Router<Context extends Omit<RestrictedCrawlingContext, 'enq
136
186
  * (loosely typed by default). Pass an explicit `UserData` type argument to narrow it.
137
187
  */
138
188
  addDefaultHandler<UserData extends Dictionary = GetUserDataFromRequest<Context['request']>>(handler: (ctx: RouterHandlerContext<Context, UserData>) => Awaitable<void>): void;
189
+ /**
190
+ * Returns the {@link RouteSchemas|Standard Schema} registered for a label, if any. Used by the crawler
191
+ * to validate `request.userData` when requests are added.
192
+ * @internal
193
+ */
194
+ getSchema(label?: string | symbol): StandardSchemaV1 | undefined;
139
195
  /**
140
196
  * Registers a middleware that will be fired before the matching route handler.
141
197
  * Multiple middlewares can be registered, they will be fired in the same order.
@@ -145,6 +201,11 @@ export declare class Router<Context extends Omit<RestrictedCrawlingContext, 'enq
145
201
  * Returns route handler for given label. If no label is provided, the default request handler will be returned.
146
202
  */
147
203
  getHandler(label?: string | symbol): (ctx: Context) => Awaitable<void>;
204
+ /**
205
+ * Validates `request.userData` against the schema registered for its label (if any), replacing it with
206
+ * the parsed value. Throws a {@link RequestValidationError} when validation fails.
207
+ */
208
+ private validateRequest;
148
209
  /**
149
210
  * Throws when the label already exists in our registry.
150
211
  */
@@ -171,4 +232,5 @@ export declare class Router<Context extends Omit<RestrictedCrawlingContext, 'enq
171
232
  */
172
233
  static create<Context extends Omit<RestrictedCrawlingContext, 'enqueueLinks'> = CrawlingContext, Routes extends Record<keyof Routes, Dictionary> = Record<string, GetUserDataFromRequest<Context['request']>>>(routes?: RouterRoutes<Context, Routes>): RouterHandler<Context, Routes>;
173
234
  static create<Context extends Omit<RestrictedCrawlingContext, 'enqueueLinks'> = CrawlingContext, UserData extends Dictionary = GetUserDataFromRequest<Context['request']>>(routes?: RouterRoutes<Context, Record<string, UserData>>): RouterHandler<Context, Record<string, UserData>>;
235
+ static create<Context extends Omit<RestrictedCrawlingContext, 'enqueueLinks'> = CrawlingContext, const Schemas extends RouteSchemas = RouteSchemas>(schemas: Schemas): RouterHandler<Context, RoutesFromSchemas<Schemas>>;
174
236
  }
package/router.js CHANGED
@@ -1,8 +1,43 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.Router = void 0;
3
+ exports.Router = exports.defaultRoute = void 0;
4
+ exports.validateUserData = validateUserData;
4
5
  const errors_1 = require("./errors");
5
- const defaultRoute = Symbol('default-route');
6
+ /**
7
+ * The key of the default route — the fallback handler registered via {@link Router.addDefaultHandler}.
8
+ * Use it in a {@link RouteSchemas} map to register a schema that validates the `userData` of every request
9
+ * that falls through to the default handler (i.e. whose label has no route of its own).
10
+ */
11
+ exports.defaultRoute = Symbol('default-route');
12
+ /** Whether a validation issue points at the top-level `label` key. */
13
+ function isLabelIssue(issue) {
14
+ if (issue.path?.length !== 1) {
15
+ return false;
16
+ }
17
+ const [segment] = issue.path;
18
+ return (typeof segment === 'object' ? segment.key : segment) === 'label';
19
+ }
20
+ /**
21
+ * Validates `userData` against a {@link RouteSchemas|Standard Schema}, returning the parsed (and coerced)
22
+ * value. Throws a {@link RequestValidationError} when validation fails.
23
+ * @internal
24
+ */
25
+ async function validateUserData(label, schema, userData) {
26
+ const { label: _label, ...rest } = (userData ?? {});
27
+ // `label` is a Crawlee-managed key that lives inside `userData`, so validating it is opt-in: we validate
28
+ // without it first, letting schemas that don't describe it pass (including `.strict()` ones). A schema that
29
+ // *does* declare `label` reports an issue for the now-missing key — so we re-validate with it included,
30
+ // honouring the declaration. Unlike `userData.__crawlee`, `label` is enumerable, so schemas do see it.
31
+ let result = await schema['~standard'].validate(rest);
32
+ if (result.issues?.some(isLabelIssue)) {
33
+ result = await schema['~standard'].validate({ ...rest, label });
34
+ }
35
+ if (result.issues) {
36
+ throw new errors_1.RequestValidationError(label, result.issues);
37
+ }
38
+ // Restore the label so it survives schemas that strip undeclared keys.
39
+ return { ...result.value, label };
40
+ }
6
41
  /**
7
42
  * Simple router that works based on request labels. This instance can then serve as a `requestHandler` of your crawler.
8
43
  *
@@ -89,6 +124,25 @@ const defaultRoute = Symbol('default-route');
89
124
  *
90
125
  * router.addHandler('TYPO', async () => {}); // compile error: not a known label
91
126
  * ```
127
+ *
128
+ * Passing a [Standard Schema](https://standardschema.dev) per label instead of a plain type both infers the
129
+ * `request.userData` types *and* validates them at runtime — when the request is handled, and when it is
130
+ * added to the crawler (`crawler.addRequests`, `context.addRequests`, `enqueueLinks`). A failing request
131
+ * throws a {@link RequestValidationError}.
132
+ *
133
+ * ```ts
134
+ * import { z } from 'zod';
135
+ * import { createCheerioRouter } from 'crawlee';
136
+ *
137
+ * const router = createCheerioRouter({
138
+ * PRODUCT: z.object({ sku: z.string(), price: z.number() }),
139
+ * CATEGORY: z.object({ categoryId: z.string() }),
140
+ * });
141
+ *
142
+ * router.addHandler('PRODUCT', async ({ request }) => {
143
+ * request.userData.price; // number, inferred from the schema and validated at runtime
144
+ * });
145
+ * ```
92
146
  */
93
147
  class Router {
94
148
  /**
@@ -102,6 +156,12 @@ class Router {
102
156
  writable: true,
103
157
  value: new Map()
104
158
  });
159
+ Object.defineProperty(this, "schemas", {
160
+ enumerable: true,
161
+ configurable: true,
162
+ writable: true,
163
+ value: new Map()
164
+ });
105
165
  Object.defineProperty(this, "middlewares", {
106
166
  enumerable: true,
107
167
  configurable: true,
@@ -119,8 +179,28 @@ class Router {
119
179
  * (loosely typed by default). Pass an explicit `UserData` type argument to narrow it.
120
180
  */
121
181
  addDefaultHandler(handler) {
122
- this.validate(defaultRoute);
123
- this.routes.set(defaultRoute, handler);
182
+ this.validate(exports.defaultRoute);
183
+ this.routes.set(exports.defaultRoute, handler);
184
+ }
185
+ /**
186
+ * Returns the {@link RouteSchemas|Standard Schema} registered for a label, if any. Used by the crawler
187
+ * to validate `request.userData` when requests are added.
188
+ * @internal
189
+ */
190
+ getSchema(label) {
191
+ if (label != null) {
192
+ const schema = this.schemas.get(label);
193
+ if (schema) {
194
+ return schema;
195
+ }
196
+ // A label with its own route is fully specified; don't fall back to the default-route schema.
197
+ if (this.routes.has(label)) {
198
+ return undefined;
199
+ }
200
+ }
201
+ // Requests with no route of their own fall through to the default handler, so validate their
202
+ // `userData` against the default-route schema, if one was registered.
203
+ return this.schemas.get(exports.defaultRoute);
124
204
  }
125
205
  /**
126
206
  * Registers a middleware that will be fired before the matching route handler.
@@ -136,37 +216,57 @@ class Router {
136
216
  if (label && this.routes.has(label)) {
137
217
  return this.routes.get(label);
138
218
  }
139
- if (this.routes.has(defaultRoute)) {
140
- return this.routes.get(defaultRoute);
219
+ if (this.routes.has(exports.defaultRoute)) {
220
+ return this.routes.get(exports.defaultRoute);
141
221
  }
142
222
  throw new errors_1.MissingRouteError(`Route not found for label '${String(label)}'.` +
143
223
  ' You must set up a route for this label or a default route.' +
144
224
  ' Use `requestHandler`, `router.addHandler` or `router.addDefaultHandler`.');
145
225
  }
226
+ /**
227
+ * Validates `request.userData` against the schema registered for its label (if any), replacing it with
228
+ * the parsed value. Throws a {@link RequestValidationError} when validation fails.
229
+ */
230
+ async validateRequest(context) {
231
+ const label = context.request.label;
232
+ const schema = this.getSchema(label);
233
+ if (schema) {
234
+ context.request.userData = (await validateUserData(label, schema, context.request.userData));
235
+ }
236
+ }
146
237
  /**
147
238
  * Throws when the label already exists in our registry.
148
239
  */
149
240
  validate(label) {
150
241
  if (this.routes.has(label)) {
151
- const message = label === defaultRoute
242
+ const message = label === exports.defaultRoute
152
243
  ? `Default route is already defined!`
153
244
  : `Route for label '${String(label)}' is already defined!`;
154
245
  throw new Error(message);
155
246
  }
156
247
  }
157
- static create(routes) {
248
+ static create(routesOrSchemas) {
158
249
  const router = new Router();
159
250
  const obj = Object.create(Function.prototype);
160
251
  obj.addHandler = router.addHandler.bind(router);
161
252
  obj.addDefaultHandler = router.addDefaultHandler.bind(router);
253
+ obj.getSchema = router.getSchema.bind(router);
162
254
  obj.getHandler = router.getHandler.bind(router);
163
255
  obj.use = router.use.bind(router);
164
- for (const [label, handler] of Object.entries(routes ?? {})) {
165
- router.addHandler(label, handler);
256
+ // `Reflect.ownKeys` (unlike `Object.entries`) also yields the `defaultRoute` symbol key.
257
+ for (const label of Reflect.ownKeys(routesOrSchemas ?? {})) {
258
+ const value = routesOrSchemas[label];
259
+ if (typeof value === 'function') {
260
+ router.addHandler(label, value);
261
+ }
262
+ else {
263
+ router.schemas.set(label, value);
264
+ }
166
265
  }
167
266
  const func = async function (context) {
168
267
  const { url, loadedUrl, label } = context.request;
169
268
  context.log.debug('Page opened.', { label, url: loadedUrl ?? url });
269
+ await router.validateRequest(context);
170
270
  for (const middleware of router.middlewares) {
171
271
  await middleware(context);
172
272
  }