@crawlee/utils 4.0.0-beta.99 → 4.0.0-rc.0
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/index.d.ts +6 -6
- package/index.js +5 -6
- package/internal.d.ts +9 -0
- package/internal.js +8 -0
- package/internals/extract-urls.d.ts +1 -1
- package/internals/extract-urls.js +17 -15
- package/internals/robots.d.ts +26 -9
- package/internals/robots.js +47 -19
- package/internals/schemas.d.ts +114 -0
- package/internals/schemas.js +114 -0
- package/internals/sitemap.d.ts +10 -1
- package/internals/sitemap.js +85 -53
- package/internals/url.d.ts +69 -0
- package/internals/url.js +119 -0
- package/internals/validation.d.ts +25 -0
- package/internals/validation.js +140 -0
- package/package.json +8 -6
package/index.d.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
export
|
|
2
|
-
export
|
|
3
|
-
export
|
|
4
|
-
export
|
|
1
|
+
export { htmlToText } from './internals/cheerio.js';
|
|
2
|
+
export { downloadListOfUrls, extractUrls } from './internals/extract-urls.js';
|
|
3
|
+
export { EnqueueStrategy } from './internals/url.js';
|
|
4
|
+
export type { DownloadListOfUrlsOptions, ExtractUrlsOptions } from './internals/extract-urls.js';
|
|
5
|
+
export { sleep, expandShadowRoots } from './internals/general.js';
|
|
5
6
|
export * as social from './internals/social.js';
|
|
6
7
|
export * from './internals/open_graph_parser.js';
|
|
7
8
|
export * from './internals/robots.js';
|
|
8
9
|
export * from './internals/sitemap.js';
|
|
9
|
-
export * from './internals/
|
|
10
|
-
export * from './internals/url.js';
|
|
10
|
+
export * from './internals/validation.js';
|
package/index.js
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
|
-
export
|
|
2
|
-
export
|
|
3
|
-
export
|
|
4
|
-
export
|
|
1
|
+
export { htmlToText } from './internals/cheerio.js';
|
|
2
|
+
export { downloadListOfUrls, extractUrls } from './internals/extract-urls.js';
|
|
3
|
+
export { EnqueueStrategy } from './internals/url.js';
|
|
4
|
+
export { sleep, expandShadowRoots } from './internals/general.js';
|
|
5
5
|
export * as social from './internals/social.js';
|
|
6
6
|
export * from './internals/open_graph_parser.js';
|
|
7
7
|
export * from './internals/robots.js';
|
|
8
8
|
export * from './internals/sitemap.js';
|
|
9
|
-
export * from './internals/
|
|
10
|
-
export * from './internals/url.js';
|
|
9
|
+
export * from './internals/validation.js';
|
package/internal.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export * from './internals/blocked.js';
|
|
2
|
+
export type { CheerioRoot, CheerioAPI, Cheerio, Element } from './internals/cheerio.js';
|
|
3
|
+
export { extractUrlsFromCheerio } from './internals/cheerio.js';
|
|
4
|
+
export { tryAbsoluteURL } from './internals/extract-urls.js';
|
|
5
|
+
export { URL_NO_COMMAS_REGEX, URL_WITH_COMMAS_REGEX } from './internals/general.js';
|
|
6
|
+
export * from './internals/iterables.js';
|
|
7
|
+
export * from './internals/url.js';
|
|
8
|
+
export * from './internals/validation.js';
|
|
9
|
+
export * as schemas from './internals/schemas.js';
|
package/internal.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export * from './internals/blocked.js';
|
|
2
|
+
export { extractUrlsFromCheerio } from './internals/cheerio.js';
|
|
3
|
+
export { tryAbsoluteURL } from './internals/extract-urls.js';
|
|
4
|
+
export { URL_NO_COMMAS_REGEX, URL_WITH_COMMAS_REGEX } from './internals/general.js';
|
|
5
|
+
export * from './internals/iterables.js';
|
|
6
|
+
export * from './internals/url.js';
|
|
7
|
+
export * from './internals/validation.js';
|
|
8
|
+
export * as schemas from './internals/schemas.js';
|
|
@@ -1,19 +1,25 @@
|
|
|
1
1
|
import { FetchHttpClient } from '@crawlee/http-client';
|
|
2
|
-
import
|
|
2
|
+
import { z } from 'zod';
|
|
3
3
|
import { URL_NO_COMMAS_REGEX } from './general.js';
|
|
4
|
+
import { httpClient as httpClientSchema } from './schemas.js';
|
|
5
|
+
import { parseArgument } from './validation.js';
|
|
6
|
+
const downloadListOfUrlsOptionsSchema = z.strictObject({
|
|
7
|
+
url: z.url(),
|
|
8
|
+
encoding: z.string().default('utf8'),
|
|
9
|
+
urlRegExp: z.instanceof(RegExp).default(URL_NO_COMMAS_REGEX),
|
|
10
|
+
proxyUrl: z.string().optional(),
|
|
11
|
+
httpClient: httpClientSchema.default(() => new FetchHttpClient()),
|
|
12
|
+
});
|
|
13
|
+
const extractUrlsOptionsSchema = z.strictObject({
|
|
14
|
+
string: z.string(),
|
|
15
|
+
urlRegExp: z.instanceof(RegExp).default(URL_NO_COMMAS_REGEX),
|
|
16
|
+
});
|
|
4
17
|
/**
|
|
5
18
|
* Returns a promise that resolves to an array of urls parsed from the resource available at the provided url.
|
|
6
19
|
* Optionally, custom regular expression and encoding may be provided.
|
|
7
20
|
*/
|
|
8
21
|
export async function downloadListOfUrls(options) {
|
|
9
|
-
|
|
10
|
-
url: ow.string.url,
|
|
11
|
-
encoding: ow.optional.string,
|
|
12
|
-
urlRegExp: ow.optional.regExp,
|
|
13
|
-
proxyUrl: ow.optional.string,
|
|
14
|
-
httpClient: ow.optional.object,
|
|
15
|
-
}));
|
|
16
|
-
const { url, encoding = 'utf8', urlRegExp = URL_NO_COMMAS_REGEX, proxyUrl, httpClient = new FetchHttpClient(), } = options;
|
|
22
|
+
const { url, encoding, urlRegExp, proxyUrl, httpClient } = parseArgument(options, downloadListOfUrlsOptionsSchema);
|
|
17
23
|
// Try to detect wrong urls and fix them. Currently, detects only sharing url instead of csv download one.
|
|
18
24
|
const match = /^(https:\/\/docs\.google\.com\/spreadsheets\/d\/(?:\w|-)+)\/?/.exec(url);
|
|
19
25
|
let fixedUrl = url;
|
|
@@ -30,13 +36,9 @@ export async function downloadListOfUrls(options) {
|
|
|
30
36
|
* Collects all URLs in an arbitrary string to an array, optionally using a custom regular expression.
|
|
31
37
|
*/
|
|
32
38
|
export function extractUrls(options) {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
urlRegExp: ow.optional.regExp,
|
|
36
|
-
}));
|
|
37
|
-
const lines = options.string.split('\n');
|
|
39
|
+
const { string, urlRegExp } = parseArgument(options, extractUrlsOptionsSchema);
|
|
40
|
+
const lines = string.split('\n');
|
|
38
41
|
const result = [];
|
|
39
|
-
const urlRegExp = options.urlRegExp ?? URL_NO_COMMAS_REGEX;
|
|
40
42
|
for (const line of lines) {
|
|
41
43
|
result.push(...(line.match(urlRegExp) ?? []));
|
|
42
44
|
}
|
package/internals/robots.d.ts
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
|
-
import type { BaseHttpClient
|
|
1
|
+
import type { BaseHttpClient } from '@crawlee/http-client';
|
|
2
|
+
import type { CrawleeLogger } from '@crawlee/types';
|
|
2
3
|
import { Sitemap } from './sitemap.js';
|
|
4
|
+
import { type EnqueueStrategy } from './url.js';
|
|
5
|
+
export interface RobotsTxtFileSitemapsOptions {
|
|
6
|
+
/**
|
|
7
|
+
* Keep only sitemap URLs matching this strategy relative to the robots.txt host; non-`http(s)` schemes
|
|
8
|
+
* are always dropped. Pass `'all'` to disable host filtering.
|
|
9
|
+
* @default 'same-hostname'
|
|
10
|
+
*/
|
|
11
|
+
enqueueStrategy?: EnqueueStrategy | `${EnqueueStrategy}`;
|
|
12
|
+
}
|
|
3
13
|
/**
|
|
4
14
|
* Loads and queries information from a [robots.txt file](https://en.wikipedia.org/wiki/Robots.txt).
|
|
5
15
|
*
|
|
@@ -19,9 +29,7 @@ import { Sitemap } from './sitemap.js';
|
|
|
19
29
|
* ```
|
|
20
30
|
*/
|
|
21
31
|
export declare class RobotsTxtFile {
|
|
22
|
-
private
|
|
23
|
-
private proxyUrl?;
|
|
24
|
-
private logger?;
|
|
32
|
+
#private;
|
|
25
33
|
private constructor();
|
|
26
34
|
/**
|
|
27
35
|
* Determine the location of a robots.txt file for a URL and fetch it.
|
|
@@ -46,6 +54,11 @@ export declare class RobotsTxtFile {
|
|
|
46
54
|
*/
|
|
47
55
|
static from(url: string, content: string, proxyUrl?: string): RobotsTxtFile;
|
|
48
56
|
private static load;
|
|
57
|
+
/**
|
|
58
|
+
* Get crawl delay for a given user agent.
|
|
59
|
+
* @param [userAgent] relevant user agent, default to `*`
|
|
60
|
+
*/
|
|
61
|
+
getCrawlDelay(userAgent?: string): number | undefined;
|
|
49
62
|
/**
|
|
50
63
|
* Check if a URL should be crawled by robots.
|
|
51
64
|
* @param url the URL to check against the rules in robots.txt
|
|
@@ -53,15 +66,19 @@ export declare class RobotsTxtFile {
|
|
|
53
66
|
*/
|
|
54
67
|
isAllowed(url: string, userAgent?: string): boolean;
|
|
55
68
|
/**
|
|
56
|
-
* Get URLs of sitemaps referenced in the robots file.
|
|
69
|
+
* Get URLs of sitemaps referenced in the robots file, filtered by `options.enqueueStrategy` relative to
|
|
70
|
+
* the robots.txt host (default `'same-hostname'`; pass `'all'` to disable). Non-`http(s)` schemes are
|
|
71
|
+
* always dropped.
|
|
57
72
|
*/
|
|
58
|
-
getSitemaps(): string[];
|
|
73
|
+
getSitemaps(options?: RobotsTxtFileSitemapsOptions): string[];
|
|
59
74
|
/**
|
|
60
|
-
* Parse all the sitemaps referenced in the robots file.
|
|
75
|
+
* Parse all the sitemaps referenced in the robots file. `options` are forwarded to `getSitemaps`
|
|
76
|
+
* and the sitemap parser.
|
|
61
77
|
*/
|
|
62
|
-
parseSitemaps(): Promise<Sitemap>;
|
|
78
|
+
parseSitemaps(options?: RobotsTxtFileSitemapsOptions): Promise<Sitemap>;
|
|
63
79
|
/**
|
|
64
80
|
* Get all URLs from all the sitemaps referenced in the robots file. A shorthand for `(await robots.parseSitemaps()).urls`.
|
|
81
|
+
* `options` are forwarded to `parseSitemaps`.
|
|
65
82
|
*/
|
|
66
|
-
parseUrlsFromSitemaps(): Promise<string[]>;
|
|
83
|
+
parseUrlsFromSitemaps(options?: RobotsTxtFileSitemapsOptions): Promise<string[]>;
|
|
67
84
|
}
|
package/internals/robots.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { FetchHttpClient } from '@crawlee/http-client';
|
|
2
2
|
import robotsParser from 'robots-parser';
|
|
3
3
|
import { Sitemap } from './sitemap.js';
|
|
4
|
+
import { filterUrl } from './url.js';
|
|
4
5
|
/**
|
|
5
6
|
* Loads and queries information from a [robots.txt file](https://en.wikipedia.org/wiki/Robots.txt).
|
|
6
7
|
*
|
|
@@ -20,13 +21,15 @@ import { Sitemap } from './sitemap.js';
|
|
|
20
21
|
* ```
|
|
21
22
|
*/
|
|
22
23
|
export class RobotsTxtFile {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
this
|
|
29
|
-
this
|
|
24
|
+
#url;
|
|
25
|
+
#robots;
|
|
26
|
+
#proxyUrl;
|
|
27
|
+
#logger;
|
|
28
|
+
constructor(url, robots, proxyUrl, logger) {
|
|
29
|
+
this.#url = url;
|
|
30
|
+
this.#robots = robots;
|
|
31
|
+
this.#proxyUrl = proxyUrl;
|
|
32
|
+
this.#logger = logger;
|
|
30
33
|
}
|
|
31
34
|
/**
|
|
32
35
|
* Determine the location of a robots.txt file for a URL and fetch it.
|
|
@@ -50,7 +53,7 @@ export class RobotsTxtFile {
|
|
|
50
53
|
*/
|
|
51
54
|
static from(url, content, proxyUrl) {
|
|
52
55
|
// @ts-ignore
|
|
53
|
-
return new RobotsTxtFile(robotsParser(url, content), proxyUrl);
|
|
56
|
+
return new RobotsTxtFile(url, robotsParser(url, content), proxyUrl);
|
|
54
57
|
}
|
|
55
58
|
static async load(url, options) {
|
|
56
59
|
const { proxyUrl, logger, httpClient = new FetchHttpClient() } = options || {};
|
|
@@ -63,17 +66,27 @@ export class RobotsTxtFile {
|
|
|
63
66
|
throw new Error(`Failed to load robots.txt from ${url}: HTTP ${response.status}`);
|
|
64
67
|
}
|
|
65
68
|
if (response.status === 404) {
|
|
66
|
-
return new RobotsTxtFile({
|
|
69
|
+
return new RobotsTxtFile(url, {
|
|
67
70
|
isAllowed() {
|
|
68
71
|
return true;
|
|
69
72
|
},
|
|
70
73
|
getSitemaps() {
|
|
71
74
|
return [];
|
|
72
75
|
},
|
|
76
|
+
getCrawlDelay() {
|
|
77
|
+
return undefined;
|
|
78
|
+
},
|
|
73
79
|
}, proxyUrl, logger);
|
|
74
80
|
}
|
|
75
81
|
// @ts-ignore
|
|
76
|
-
return new RobotsTxtFile(robotsParser(url.toString(), await response.text()), proxyUrl, logger);
|
|
82
|
+
return new RobotsTxtFile(url, robotsParser(url.toString(), await response.text()), proxyUrl, logger);
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Get crawl delay for a given user agent.
|
|
86
|
+
* @param [userAgent] relevant user agent, default to `*`
|
|
87
|
+
*/
|
|
88
|
+
getCrawlDelay(userAgent = '*') {
|
|
89
|
+
return this.#robots.getCrawlDelay(userAgent);
|
|
77
90
|
}
|
|
78
91
|
/**
|
|
79
92
|
* Check if a URL should be crawled by robots.
|
|
@@ -81,24 +94,39 @@ export class RobotsTxtFile {
|
|
|
81
94
|
* @param [userAgent] relevant user agent, default to `*`
|
|
82
95
|
*/
|
|
83
96
|
isAllowed(url, userAgent = '*') {
|
|
84
|
-
return this
|
|
97
|
+
return this.#robots.isAllowed(url, userAgent) ?? true; // `undefined` means that there is no explicit rule for the requested URL - assume it's allowed
|
|
85
98
|
}
|
|
86
99
|
/**
|
|
87
|
-
* Get URLs of sitemaps referenced in the robots file.
|
|
100
|
+
* Get URLs of sitemaps referenced in the robots file, filtered by `options.enqueueStrategy` relative to
|
|
101
|
+
* the robots.txt host (default `'same-hostname'`; pass `'all'` to disable). Non-`http(s)` schemes are
|
|
102
|
+
* always dropped.
|
|
88
103
|
*/
|
|
89
|
-
getSitemaps() {
|
|
90
|
-
|
|
104
|
+
getSitemaps(options = {}) {
|
|
105
|
+
const { enqueueStrategy = 'same-hostname' } = options;
|
|
106
|
+
const sitemaps = [];
|
|
107
|
+
for (const sitemapUrl of this.#robots.getSitemaps()) {
|
|
108
|
+
// `filterUrl` tolerates an unparseable origin (returns not-allowed) rather than throwing.
|
|
109
|
+
const { allowed, reason } = filterUrl(sitemapUrl, this.#url, enqueueStrategy);
|
|
110
|
+
if (!allowed) {
|
|
111
|
+
this.#logger?.warning(`Skipping sitemap ${sitemapUrl} listed in robots.txt at ${this.#url}: ${reason}.`);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
sitemaps.push(sitemapUrl);
|
|
115
|
+
}
|
|
116
|
+
return sitemaps;
|
|
91
117
|
}
|
|
92
118
|
/**
|
|
93
|
-
* Parse all the sitemaps referenced in the robots file.
|
|
119
|
+
* Parse all the sitemaps referenced in the robots file. `options` are forwarded to `getSitemaps`
|
|
120
|
+
* and the sitemap parser.
|
|
94
121
|
*/
|
|
95
|
-
async parseSitemaps() {
|
|
96
|
-
return Sitemap.load(this.
|
|
122
|
+
async parseSitemaps(options = {}) {
|
|
123
|
+
return Sitemap.load(this.getSitemaps(options), this.#proxyUrl, { ...options, logger: this.#logger });
|
|
97
124
|
}
|
|
98
125
|
/**
|
|
99
126
|
* Get all URLs from all the sitemaps referenced in the robots file. A shorthand for `(await robots.parseSitemaps()).urls`.
|
|
127
|
+
* `options` are forwarded to `parseSitemaps`.
|
|
100
128
|
*/
|
|
101
|
-
async parseUrlsFromSitemaps() {
|
|
102
|
-
return (await this.parseSitemaps()).urls;
|
|
129
|
+
async parseUrlsFromSitemaps(options = {}) {
|
|
130
|
+
return (await this.parseSitemaps(options)).urls;
|
|
103
131
|
}
|
|
104
132
|
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { BaseHttpClient } from '@crawlee/http-client';
|
|
2
|
+
import type { Dictionary } from '@crawlee/types';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
/**
|
|
5
|
+
* Accepts any object (including arrays and functions).
|
|
6
|
+
* @internal
|
|
7
|
+
*/
|
|
8
|
+
export declare const anyObject: z.ZodCustom<Dictionary, Dictionary>;
|
|
9
|
+
/**
|
|
10
|
+
* Accepts any array without validating its items (cheap for huge arrays).
|
|
11
|
+
* @internal
|
|
12
|
+
*/
|
|
13
|
+
export declare const anyArray: z.ZodCustom<unknown[], unknown[]>;
|
|
14
|
+
/**
|
|
15
|
+
* Accepts any function.
|
|
16
|
+
* @internal
|
|
17
|
+
*/
|
|
18
|
+
export declare const anyFunction: z.ZodCustom<(...args: any[]) => unknown, (...args: any[]) => unknown>;
|
|
19
|
+
/**
|
|
20
|
+
* Mirrors `ow.number`: `Infinity` is a valid number, `NaN` is not.
|
|
21
|
+
* @internal
|
|
22
|
+
*/
|
|
23
|
+
export declare const anyNumber: z.ZodCustom<number, number>;
|
|
24
|
+
/**
|
|
25
|
+
* Accepts any object (including functions) that has all the given keys, own or inherited.
|
|
26
|
+
* @internal
|
|
27
|
+
*/
|
|
28
|
+
export declare function objectWithKeys(keys: string[], message?: string): z.ZodType<Dictionary>;
|
|
29
|
+
/**
|
|
30
|
+
* Accepts only instances of {@link BaseHttpClient} (all Crawlee HTTP clients extend it).
|
|
31
|
+
* @internal
|
|
32
|
+
*/
|
|
33
|
+
export declare const httpClient: z.ZodCustom<BaseHttpClient, BaseHttpClient>;
|
|
34
|
+
/**
|
|
35
|
+
* Accepts any object implementing the CrawleeLogger interface.
|
|
36
|
+
* @internal
|
|
37
|
+
*/
|
|
38
|
+
export declare const logger: z.ZodType<Dictionary, unknown, z.core.$ZodTypeInternals<Dictionary, unknown>>;
|
|
39
|
+
/**
|
|
40
|
+
* Accepts any typed array (`Uint8Array`, `Float64Array`, ...), but not a `DataView`.
|
|
41
|
+
* @internal
|
|
42
|
+
*/
|
|
43
|
+
export declare const typedArray: z.ZodCustom<NodeJS.TypedArray<ArrayBufferLike>, NodeJS.TypedArray<ArrayBufferLike>>;
|
|
44
|
+
/**
|
|
45
|
+
* Accepts any non-null, non-array object.
|
|
46
|
+
* @internal
|
|
47
|
+
*/
|
|
48
|
+
export declare const plainObject: z.ZodCustom<Record<string, unknown>, Record<string, unknown>>;
|
|
49
|
+
/**
|
|
50
|
+
* Shape of a request stored in a request queue.
|
|
51
|
+
* @internal
|
|
52
|
+
*/
|
|
53
|
+
export declare const storageRequest: z.ZodObject<{
|
|
54
|
+
id: z.ZodString;
|
|
55
|
+
url: z.ZodURL;
|
|
56
|
+
uniqueKey: z.ZodString;
|
|
57
|
+
method: z.ZodOptional<z.ZodString>;
|
|
58
|
+
retryCount: z.ZodOptional<z.ZodNumber>;
|
|
59
|
+
handledAt: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodDate]>>;
|
|
60
|
+
}, z.core.$loose>;
|
|
61
|
+
/**
|
|
62
|
+
* {@link storageRequest} before an id is assigned.
|
|
63
|
+
* @internal
|
|
64
|
+
*/
|
|
65
|
+
export declare const storageRequestWithoutId: z.ZodObject<{
|
|
66
|
+
url: z.ZodURL;
|
|
67
|
+
uniqueKey: z.ZodString;
|
|
68
|
+
method: z.ZodOptional<z.ZodString>;
|
|
69
|
+
retryCount: z.ZodOptional<z.ZodNumber>;
|
|
70
|
+
handledAt: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodDate]>>;
|
|
71
|
+
}, z.core.$loose>;
|
|
72
|
+
/**
|
|
73
|
+
* `z.array(item)` whose top-level type error names the element type — ``expected an array of numbers`` —
|
|
74
|
+
* instead of zod's bare `expected array`. Element failures keep zod's per-index messages, and `elements`
|
|
75
|
+
* is a human-readable plural (`'numbers'`, `'URL patterns'`), since element types cannot be introspected.
|
|
76
|
+
* @internal
|
|
77
|
+
*/
|
|
78
|
+
export declare function arrayOf<TItem extends z.ZodType>(item: TItem, elements: string): z.ZodArray<TItem>;
|
|
79
|
+
/**
|
|
80
|
+
* Batch of {@link storageRequestWithoutId}.
|
|
81
|
+
* @internal
|
|
82
|
+
*/
|
|
83
|
+
export declare const storageRequestBatch: z.ZodArray<z.ZodObject<{
|
|
84
|
+
url: z.ZodURL;
|
|
85
|
+
uniqueKey: z.ZodString;
|
|
86
|
+
method: z.ZodOptional<z.ZodString>;
|
|
87
|
+
retryCount: z.ZodOptional<z.ZodNumber>;
|
|
88
|
+
handledAt: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodDate]>>;
|
|
89
|
+
}, z.core.$loose>>;
|
|
90
|
+
/**
|
|
91
|
+
* Options of request queue add/update operations.
|
|
92
|
+
* @internal
|
|
93
|
+
*/
|
|
94
|
+
export declare const requestQueueOperationOptions: z.ZodObject<{
|
|
95
|
+
forefront: z.ZodOptional<z.ZodBoolean>;
|
|
96
|
+
}, z.core.$strip>;
|
|
97
|
+
/**
|
|
98
|
+
* Options of key-value store `listKeys`.
|
|
99
|
+
* @internal
|
|
100
|
+
*/
|
|
101
|
+
export declare const keyValueStoreListKeysOptions: z.ZodObject<{
|
|
102
|
+
prefix: z.ZodOptional<z.ZodString>;
|
|
103
|
+
exclusiveStartKey: z.ZodOptional<z.ZodString>;
|
|
104
|
+
limit: z.ZodOptional<z.ZodNumber>;
|
|
105
|
+
}, z.core.$strip>;
|
|
106
|
+
/**
|
|
107
|
+
* Options of dataset item listing.
|
|
108
|
+
* @internal
|
|
109
|
+
*/
|
|
110
|
+
export declare const datasetListItemsOptions: z.ZodObject<{
|
|
111
|
+
desc: z.ZodOptional<z.ZodBoolean>;
|
|
112
|
+
limit: z.ZodOptional<z.ZodNumber>;
|
|
113
|
+
offset: z.ZodOptional<z.ZodNumber>;
|
|
114
|
+
}, z.core.$strip>;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { BaseHttpClient } from '@crawlee/http-client';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
/**
|
|
4
|
+
* Accepts any object (including arrays and functions).
|
|
5
|
+
* @internal
|
|
6
|
+
*/
|
|
7
|
+
export const anyObject = z.custom((value) => (typeof value === 'object' && value !== null) || typeof value === 'function', { message: 'Invalid input: expected object' });
|
|
8
|
+
/**
|
|
9
|
+
* Accepts any array without validating its items (cheap for huge arrays).
|
|
10
|
+
* @internal
|
|
11
|
+
*/
|
|
12
|
+
export const anyArray = z.custom(Array.isArray, { message: 'Invalid input: expected array' });
|
|
13
|
+
/**
|
|
14
|
+
* Accepts any function.
|
|
15
|
+
* @internal
|
|
16
|
+
*/
|
|
17
|
+
export const anyFunction = z.custom((value) => typeof value === 'function', {
|
|
18
|
+
message: 'Invalid input: expected function',
|
|
19
|
+
});
|
|
20
|
+
/**
|
|
21
|
+
* Mirrors `ow.number`: `Infinity` is a valid number, `NaN` is not.
|
|
22
|
+
* @internal
|
|
23
|
+
*/
|
|
24
|
+
export const anyNumber = z.custom((value) => typeof value === 'number' && !Number.isNaN(value), {
|
|
25
|
+
message: 'Invalid input: expected number',
|
|
26
|
+
});
|
|
27
|
+
/**
|
|
28
|
+
* Accepts any object (including functions) that has all the given keys, own or inherited.
|
|
29
|
+
* @internal
|
|
30
|
+
*/
|
|
31
|
+
export function objectWithKeys(keys, message) {
|
|
32
|
+
return z.custom((value) => ((typeof value === 'object' && value !== null) || typeof value === 'function') &&
|
|
33
|
+
keys.every((key) => key in value), {
|
|
34
|
+
message: message ?? `Invalid input: expected an object with keys ${keys.map((key) => `'${key}'`).join(', ')}`,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Accepts only instances of {@link BaseHttpClient} (all Crawlee HTTP clients extend it).
|
|
39
|
+
* @internal
|
|
40
|
+
*/
|
|
41
|
+
export const httpClient = z.instanceof(BaseHttpClient);
|
|
42
|
+
/**
|
|
43
|
+
* Accepts any object implementing the CrawleeLogger interface.
|
|
44
|
+
* @internal
|
|
45
|
+
*/
|
|
46
|
+
export const logger = objectWithKeys(['child', 'info', 'error', 'warning'], "Expected an object implementing the CrawleeLogger interface (missing one of 'child', 'info', 'error', 'warning'), got something else.");
|
|
47
|
+
/**
|
|
48
|
+
* Accepts any typed array (`Uint8Array`, `Float64Array`, ...), but not a `DataView`.
|
|
49
|
+
* @internal
|
|
50
|
+
*/
|
|
51
|
+
export const typedArray = z.custom((value) => ArrayBuffer.isView(value) && !(value instanceof DataView), { message: 'Invalid input: expected a typed array' });
|
|
52
|
+
/**
|
|
53
|
+
* Accepts any non-null, non-array object.
|
|
54
|
+
* @internal
|
|
55
|
+
*/
|
|
56
|
+
export const plainObject = z.custom((value) => typeof value === 'object' && value !== null && !Array.isArray(value), { message: 'Invalid input: expected an object' });
|
|
57
|
+
/**
|
|
58
|
+
* Shape of a request stored in a request queue.
|
|
59
|
+
* @internal
|
|
60
|
+
*/
|
|
61
|
+
export const storageRequest = z.looseObject({
|
|
62
|
+
id: z.string(),
|
|
63
|
+
url: z.url({ protocol: /^https?$/ }),
|
|
64
|
+
uniqueKey: z.string(),
|
|
65
|
+
method: z.string().optional(),
|
|
66
|
+
retryCount: z.number().int().optional(),
|
|
67
|
+
handledAt: z.union([z.string(), z.date()]).optional(),
|
|
68
|
+
});
|
|
69
|
+
/**
|
|
70
|
+
* {@link storageRequest} before an id is assigned.
|
|
71
|
+
* @internal
|
|
72
|
+
*/
|
|
73
|
+
export const storageRequestWithoutId = storageRequest.omit({ id: true });
|
|
74
|
+
/**
|
|
75
|
+
* `z.array(item)` whose top-level type error names the element type — ``expected an array of numbers`` —
|
|
76
|
+
* instead of zod's bare `expected array`. Element failures keep zod's per-index messages, and `elements`
|
|
77
|
+
* is a human-readable plural (`'numbers'`, `'URL patterns'`), since element types cannot be introspected.
|
|
78
|
+
* @internal
|
|
79
|
+
*/
|
|
80
|
+
export function arrayOf(item, elements) {
|
|
81
|
+
return z.array(item, {
|
|
82
|
+
error: (issue) => issue.code === 'invalid_type' ? `Invalid input: expected an array of ${elements}` : undefined,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Batch of {@link storageRequestWithoutId}.
|
|
87
|
+
* @internal
|
|
88
|
+
*/
|
|
89
|
+
export const storageRequestBatch = arrayOf(storageRequestWithoutId, 'requests');
|
|
90
|
+
/**
|
|
91
|
+
* Options of request queue add/update operations.
|
|
92
|
+
* @internal
|
|
93
|
+
*/
|
|
94
|
+
export const requestQueueOperationOptions = z.object({
|
|
95
|
+
forefront: z.boolean().optional(),
|
|
96
|
+
});
|
|
97
|
+
/**
|
|
98
|
+
* Options of key-value store `listKeys`.
|
|
99
|
+
* @internal
|
|
100
|
+
*/
|
|
101
|
+
export const keyValueStoreListKeysOptions = z.object({
|
|
102
|
+
prefix: z.string().optional(),
|
|
103
|
+
exclusiveStartKey: z.string().optional(),
|
|
104
|
+
limit: z.number().int().gt(0).optional(),
|
|
105
|
+
});
|
|
106
|
+
/**
|
|
107
|
+
* Options of dataset item listing.
|
|
108
|
+
* @internal
|
|
109
|
+
*/
|
|
110
|
+
export const datasetListItemsOptions = z.object({
|
|
111
|
+
desc: z.boolean().optional(),
|
|
112
|
+
limit: z.number().int().optional(),
|
|
113
|
+
offset: z.number().int().optional(),
|
|
114
|
+
});
|
package/internals/sitemap.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import type { BaseHttpClient
|
|
1
|
+
import type { BaseHttpClient } from '@crawlee/http-client';
|
|
2
|
+
import type { CrawleeLogger } from '@crawlee/types';
|
|
3
|
+
import { type EnqueueStrategy } from './url.js';
|
|
2
4
|
interface SitemapUrlData {
|
|
3
5
|
loc: string;
|
|
4
6
|
lastmod?: Date;
|
|
@@ -54,6 +56,13 @@ export interface ParseSitemapOptions {
|
|
|
54
56
|
* If not provided, all nested sitemaps are followed.
|
|
55
57
|
*/
|
|
56
58
|
nestedSitemapFilter?: (sitemapUrl: string) => boolean;
|
|
59
|
+
/**
|
|
60
|
+
* Keep only sitemap-derived URLs (nested `<sitemap>` and `<url>` entries) matching this strategy
|
|
61
|
+
* relative to the parent sitemap URL; non-`http(s)` schemes are always dropped. Skipped for raw string
|
|
62
|
+
* sources (no parent URL). Pass `'all'` to disable host filtering.
|
|
63
|
+
* @default 'same-hostname'
|
|
64
|
+
*/
|
|
65
|
+
enqueueStrategy?: EnqueueStrategy | `${EnqueueStrategy}`;
|
|
57
66
|
/**
|
|
58
67
|
* Optional logger for reporting warnings during sitemap parsing.
|
|
59
68
|
*/
|
package/internals/sitemap.js
CHANGED
|
@@ -3,31 +3,30 @@ import { PassThrough, pipeline, Readable, Transform } from 'node:stream';
|
|
|
3
3
|
import { StringDecoder } from 'node:string_decoder';
|
|
4
4
|
import { createGunzip } from 'node:zlib';
|
|
5
5
|
import { FetchHttpClient } from '@crawlee/http-client';
|
|
6
|
-
import { fileTypeStream } from 'file-type';
|
|
7
|
-
import sax from 'sax';
|
|
8
6
|
import MIMEType from 'whatwg-mimetype';
|
|
9
7
|
import { mergeAsyncIterables } from './iterables.js';
|
|
10
8
|
import { RobotsTxtFile } from './robots.js';
|
|
9
|
+
import { filterUrl } from './url.js';
|
|
11
10
|
class SitemapTxtParser extends Transform {
|
|
12
|
-
decoder = new StringDecoder('utf8');
|
|
13
|
-
buffer = '';
|
|
11
|
+
#decoder = new StringDecoder('utf8');
|
|
12
|
+
#buffer = '';
|
|
14
13
|
constructor() {
|
|
15
14
|
super({
|
|
16
15
|
readableObjectMode: true,
|
|
17
16
|
transform: (chunk, _encoding, callback) => {
|
|
18
|
-
this.processBuffer(this
|
|
17
|
+
this.processBuffer(this.#decoder.write(chunk), false);
|
|
19
18
|
callback();
|
|
20
19
|
},
|
|
21
20
|
flush: (callback) => {
|
|
22
|
-
this.processBuffer(this
|
|
21
|
+
this.processBuffer(this.#decoder.end(), true);
|
|
23
22
|
callback();
|
|
24
23
|
},
|
|
25
24
|
});
|
|
26
25
|
}
|
|
27
26
|
processBuffer(input, finalize) {
|
|
28
|
-
this
|
|
29
|
-
if (finalize || this
|
|
30
|
-
const parts = this
|
|
27
|
+
this.#buffer += input;
|
|
28
|
+
if (finalize || this.#buffer.includes('\n')) {
|
|
29
|
+
const parts = this.#buffer
|
|
31
30
|
.split('\n')
|
|
32
31
|
.map((part) => part.trim())
|
|
33
32
|
.filter((part) => part.length > 0);
|
|
@@ -35,104 +34,109 @@ class SitemapTxtParser extends Transform {
|
|
|
35
34
|
for (const url of parts) {
|
|
36
35
|
this.push({ type: 'url', loc: url });
|
|
37
36
|
}
|
|
38
|
-
this
|
|
37
|
+
this.#buffer = '';
|
|
39
38
|
}
|
|
40
39
|
else if (parts.length > 0) {
|
|
41
40
|
for (const url of parts.slice(0, -1)) {
|
|
42
41
|
this.push({ type: 'url', loc: url });
|
|
43
42
|
}
|
|
44
|
-
this
|
|
43
|
+
this.#buffer = parts.at(-1);
|
|
45
44
|
}
|
|
46
45
|
}
|
|
47
46
|
}
|
|
48
47
|
}
|
|
49
48
|
class SitemapXmlParser extends Transform {
|
|
50
|
-
decoder = new StringDecoder('utf8');
|
|
51
|
-
parser
|
|
52
|
-
rootTagName;
|
|
53
|
-
currentTag = undefined;
|
|
54
|
-
url = {};
|
|
55
|
-
|
|
49
|
+
#decoder = new StringDecoder('utf8');
|
|
50
|
+
#parser;
|
|
51
|
+
#rootTagName;
|
|
52
|
+
#currentTag = undefined;
|
|
53
|
+
#url = {};
|
|
54
|
+
static async create() {
|
|
55
|
+
const { SAXParser } = await import('sax');
|
|
56
|
+
return new SitemapXmlParser(new SAXParser(true));
|
|
57
|
+
}
|
|
58
|
+
constructor(parser) {
|
|
56
59
|
super({
|
|
57
60
|
readableObjectMode: true,
|
|
58
61
|
transform: (chunk, _encoding, callback) => {
|
|
59
|
-
this
|
|
62
|
+
this.#parser.write(this.#decoder.write(chunk));
|
|
60
63
|
callback();
|
|
61
64
|
},
|
|
62
65
|
flush: (callback) => {
|
|
63
|
-
const rest = this
|
|
66
|
+
const rest = this.#decoder.end();
|
|
64
67
|
if (rest.length > 0) {
|
|
65
|
-
this
|
|
68
|
+
this.#parser.write(rest);
|
|
66
69
|
}
|
|
67
|
-
this
|
|
70
|
+
this.#parser.end();
|
|
68
71
|
callback();
|
|
69
72
|
},
|
|
70
73
|
});
|
|
71
|
-
this
|
|
72
|
-
this
|
|
73
|
-
this
|
|
74
|
-
this
|
|
75
|
-
this
|
|
74
|
+
this.#parser = parser;
|
|
75
|
+
this.#parser.onopentag = this.onOpenTag.bind(this);
|
|
76
|
+
this.#parser.onclosetag = this.onCloseTag.bind(this);
|
|
77
|
+
this.#parser.ontext = this.onText.bind(this);
|
|
78
|
+
this.#parser.oncdata = this.onText.bind(this);
|
|
79
|
+
this.#parser.onerror = this.destroy.bind(this);
|
|
76
80
|
}
|
|
77
81
|
onOpenTag(node) {
|
|
78
|
-
if (this
|
|
82
|
+
if (this.#rootTagName !== undefined) {
|
|
79
83
|
if (node.name === 'loc' ||
|
|
80
84
|
node.name === 'lastmod' ||
|
|
81
85
|
node.name === 'priority' ||
|
|
82
86
|
node.name === 'changefreq') {
|
|
83
|
-
this
|
|
87
|
+
this.#currentTag = node.name;
|
|
84
88
|
}
|
|
85
89
|
}
|
|
86
90
|
if (node.name === 'urlset') {
|
|
87
|
-
this
|
|
91
|
+
this.#rootTagName = 'urlset';
|
|
88
92
|
}
|
|
89
93
|
if (node.name === 'sitemapindex') {
|
|
90
|
-
this
|
|
94
|
+
this.#rootTagName = 'sitemapindex';
|
|
91
95
|
}
|
|
92
96
|
}
|
|
93
97
|
onCloseTag(name) {
|
|
94
98
|
if (name === 'loc' || name === 'lastmod' || name === 'priority' || name === 'changefreq') {
|
|
95
|
-
this
|
|
99
|
+
this.#currentTag = undefined;
|
|
96
100
|
}
|
|
97
101
|
if (name === 'url') {
|
|
98
|
-
if (this
|
|
99
|
-
this.push({ type: 'url', ...this
|
|
102
|
+
if (this.#url.loc !== undefined) {
|
|
103
|
+
this.push({ type: 'url', ...this.#url, loc: this.#url.loc });
|
|
100
104
|
}
|
|
101
|
-
this
|
|
105
|
+
this.#url = {};
|
|
102
106
|
}
|
|
103
107
|
}
|
|
104
108
|
onText(text) {
|
|
105
|
-
if (this
|
|
106
|
-
if (this
|
|
109
|
+
if (this.#currentTag === 'loc') {
|
|
110
|
+
if (this.#rootTagName === 'sitemapindex') {
|
|
107
111
|
this.push({ type: 'sitemapUrl', url: text.trim() });
|
|
108
112
|
}
|
|
109
|
-
if (this
|
|
110
|
-
this
|
|
111
|
-
this
|
|
113
|
+
if (this.#rootTagName === 'urlset') {
|
|
114
|
+
this.#url ??= {};
|
|
115
|
+
this.#url.loc = text.trim();
|
|
112
116
|
}
|
|
113
117
|
}
|
|
114
118
|
text = text.trim();
|
|
115
|
-
if (this
|
|
119
|
+
if (this.#currentTag === 'lastmod') {
|
|
116
120
|
const lastmod = new Date(text);
|
|
117
121
|
if (!Number.isNaN(lastmod.getTime())) {
|
|
118
|
-
this
|
|
122
|
+
this.#url.lastmod = lastmod;
|
|
119
123
|
}
|
|
120
124
|
}
|
|
121
|
-
if (this
|
|
122
|
-
this
|
|
125
|
+
if (this.#currentTag === 'priority') {
|
|
126
|
+
this.#url.priority = Number(text);
|
|
123
127
|
}
|
|
124
|
-
if (this
|
|
128
|
+
if (this.#currentTag === 'changefreq') {
|
|
125
129
|
if (['always', 'hourly', 'daily', 'weekly', 'monthly', 'yearly', 'never'].includes(text)) {
|
|
126
|
-
this
|
|
130
|
+
this.#url.changefreq = text;
|
|
127
131
|
}
|
|
128
132
|
}
|
|
129
133
|
}
|
|
130
134
|
}
|
|
131
135
|
export async function* parseSitemap(initialSources, proxyUrl, options) {
|
|
132
|
-
const { httpClient = new FetchHttpClient(), emitNestedSitemaps = false, maxDepth = Infinity, sitemapRetries = 3, timeoutMillis: timeout = 30000, reportNetworkErrors = true, nestedSitemapFilter, logger, } = options ?? {};
|
|
136
|
+
const { httpClient = new FetchHttpClient(), emitNestedSitemaps = false, maxDepth = Infinity, sitemapRetries = 3, timeoutMillis: timeout = 30000, reportNetworkErrors = true, nestedSitemapFilter, enqueueStrategy = 'same-hostname', logger, } = options ?? {};
|
|
133
137
|
const sources = [...initialSources];
|
|
134
138
|
const visitedSitemapUrls = new Set();
|
|
135
|
-
const createParser = (contentType = '', url) => {
|
|
139
|
+
const createParser = async (contentType = '', url) => {
|
|
136
140
|
let mimeType;
|
|
137
141
|
try {
|
|
138
142
|
mimeType = new MIMEType(contentType);
|
|
@@ -141,7 +145,7 @@ export async function* parseSitemap(initialSources, proxyUrl, options) {
|
|
|
141
145
|
mimeType = null;
|
|
142
146
|
}
|
|
143
147
|
if (mimeType?.isXML() || url?.pathname.endsWith('.xml')) {
|
|
144
|
-
return
|
|
148
|
+
return SitemapXmlParser.create();
|
|
145
149
|
}
|
|
146
150
|
if (mimeType?.essence === 'text/plain' || url?.pathname.endsWith('.txt')) {
|
|
147
151
|
return new SitemapTxtParser();
|
|
@@ -154,8 +158,10 @@ export async function* parseSitemap(initialSources, proxyUrl, options) {
|
|
|
154
158
|
continue;
|
|
155
159
|
}
|
|
156
160
|
let items = null;
|
|
161
|
+
// Parent URL, parsed once and reused as the origin for the strategy checks below.
|
|
162
|
+
let sitemapUrl;
|
|
157
163
|
if (source.type === 'url') {
|
|
158
|
-
|
|
164
|
+
sitemapUrl = new URL(source.url);
|
|
159
165
|
visitedSitemapUrls.add(sitemapUrl.toString());
|
|
160
166
|
let retriesLeft = sitemapRetries + 1;
|
|
161
167
|
while (retriesLeft-- > 0) {
|
|
@@ -181,6 +187,7 @@ export async function* parseSitemap(initialSources, proxyUrl, options) {
|
|
|
181
187
|
if (sitemapResponse.body === null) {
|
|
182
188
|
break;
|
|
183
189
|
}
|
|
190
|
+
const { fileTypeStream } = await import('file-type');
|
|
184
191
|
const streamWithType = await fileTypeStream(Readable.fromWeb(sitemapResponse.body));
|
|
185
192
|
if (streamWithType.fileType !== undefined) {
|
|
186
193
|
contentType = streamWithType.fileType.mime;
|
|
@@ -194,7 +201,7 @@ export async function* parseSitemap(initialSources, proxyUrl, options) {
|
|
|
194
201
|
sitemapUrl.pathname = sitemapUrl.pathname.substring(0, sitemapUrl.pathname.length - 3);
|
|
195
202
|
}
|
|
196
203
|
}
|
|
197
|
-
items = pipeline(streamWithType, isGzipped ? createGunzip() : new PassThrough(), createParser(contentType ?? undefined, sitemapUrl), (e) => {
|
|
204
|
+
items = pipeline(streamWithType, isGzipped ? createGunzip() : new PassThrough(), await createParser(contentType ?? undefined, sitemapUrl), (e) => {
|
|
198
205
|
if (e !== undefined && e !== null) {
|
|
199
206
|
error = { type: 'parser', error: e };
|
|
200
207
|
}
|
|
@@ -222,7 +229,7 @@ export async function* parseSitemap(initialSources, proxyUrl, options) {
|
|
|
222
229
|
}
|
|
223
230
|
}
|
|
224
231
|
else if (source.type === 'raw') {
|
|
225
|
-
items = pipeline(Readable.from([source.content]), createParser('text/xml'), (error) => {
|
|
232
|
+
items = pipeline(Readable.from([source.content]), await createParser('text/xml'), (error) => {
|
|
226
233
|
if (error !== undefined) {
|
|
227
234
|
logger?.warning(`Malformed sitemap content: ${error}`);
|
|
228
235
|
}
|
|
@@ -231,18 +238,39 @@ export async function* parseSitemap(initialSources, proxyUrl, options) {
|
|
|
231
238
|
if (items === null) {
|
|
232
239
|
continue;
|
|
233
240
|
}
|
|
241
|
+
// URL entries dropped by the enqueue strategy filter, reported in one warning per sitemap after
|
|
242
|
+
// the loop (per-entry warnings could flood the log; individual drops are logged at debug level).
|
|
243
|
+
let droppedUrlEntries = 0;
|
|
234
244
|
for await (const item of items) {
|
|
235
245
|
if (item.type === 'sitemapUrl' && !visitedSitemapUrls.has(item.url)) {
|
|
236
246
|
if (nestedSitemapFilter && !nestedSitemapFilter(item.url)) {
|
|
237
247
|
logger?.debug(`Skipping sitemap ${item.url} due to nestedSitemapFilter.`);
|
|
238
248
|
continue;
|
|
239
249
|
}
|
|
250
|
+
// Keep only nested sitemaps matching the strategy (and using http(s)) relative to the
|
|
251
|
+
// parent. Raw string sources have no parent URL, so the check is skipped.
|
|
252
|
+
if (source.type === 'url') {
|
|
253
|
+
const { allowed, reason } = filterUrl(item.url, sitemapUrl, enqueueStrategy);
|
|
254
|
+
if (!allowed) {
|
|
255
|
+
logger?.warning(`Skipping nested sitemap ${item.url} (parent ${source.url}): ${reason}.`);
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
240
259
|
sources.push({ type: 'url', url: item.url, depth: (source.depth ?? 0) + 1 });
|
|
241
260
|
if (emitNestedSitemaps) {
|
|
242
261
|
yield { loc: item.url, originSitemapUrl: null };
|
|
243
262
|
}
|
|
244
263
|
}
|
|
245
264
|
if (item.type === 'url') {
|
|
265
|
+
// Keep only URL entries that match the enqueue strategy relative to the parent (see above).
|
|
266
|
+
if (source.type === 'url') {
|
|
267
|
+
const { allowed, reason } = filterUrl(item.loc, sitemapUrl, enqueueStrategy);
|
|
268
|
+
if (!allowed) {
|
|
269
|
+
droppedUrlEntries++;
|
|
270
|
+
logger?.debug(`Skipping sitemap URL ${item.loc} (parent ${source.url}): ${reason}.`);
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
246
274
|
yield {
|
|
247
275
|
...item,
|
|
248
276
|
originSitemapUrl: source.type === 'url'
|
|
@@ -251,6 +279,9 @@ export async function* parseSitemap(initialSources, proxyUrl, options) {
|
|
|
251
279
|
};
|
|
252
280
|
}
|
|
253
281
|
}
|
|
282
|
+
if (droppedUrlEntries > 0 && source.type === 'url') {
|
|
283
|
+
logger?.warning(`Skipped ${droppedUrlEntries} URL(s) from sitemap ${source.url} not matching enqueue strategy '${enqueueStrategy}' (or using a non-http(s) scheme). Enable debug logs to see each skipped URL.`);
|
|
284
|
+
}
|
|
254
285
|
}
|
|
255
286
|
}
|
|
256
287
|
/**
|
|
@@ -374,7 +405,8 @@ export async function* discoverValidSitemaps(urls, options = {}) {
|
|
|
374
405
|
httpClient,
|
|
375
406
|
logger,
|
|
376
407
|
});
|
|
377
|
-
|
|
408
|
+
// Surface all referenced sitemaps, including cross-host; scoping happens at load time.
|
|
409
|
+
for (const sitemapUrl of robotsFile.getSitemaps({ enqueueStrategy: 'all' })) {
|
|
378
410
|
if (addSitemapUrl(sitemapUrl)) {
|
|
379
411
|
yield sitemapUrl;
|
|
380
412
|
}
|
|
@@ -383,7 +415,7 @@ export async function* discoverValidSitemaps(urls, options = {}) {
|
|
|
383
415
|
catch (err) {
|
|
384
416
|
logger?.warning(`Failed to fetch robots.txt file for ${hostname}`, { error: err });
|
|
385
417
|
}
|
|
386
|
-
const sitemapUrl = domainUrls.find((url) => /sitemap
|
|
418
|
+
const sitemapUrl = domainUrls.find((url) => /sitemap(?:_index)?\.(?:xml|txt)(?:\.gz)?$/i.test(url));
|
|
387
419
|
if (sitemapUrl !== undefined) {
|
|
388
420
|
if (addSitemapUrl(sitemapUrl)) {
|
|
389
421
|
yield sitemapUrl;
|
package/internals/url.d.ts
CHANGED
|
@@ -1,4 +1,73 @@
|
|
|
1
1
|
import type { SearchParams } from '@crawlee/types';
|
|
2
|
+
/**
|
|
3
|
+
* The different enqueueing strategies available.
|
|
4
|
+
*
|
|
5
|
+
* Depending on the strategy you select, we will only check certain parts of the URLs found. Here is a diagram of each URL part and their name:
|
|
6
|
+
*
|
|
7
|
+
* ```md
|
|
8
|
+
* Protocol Domain
|
|
9
|
+
* ┌────┐ ┌─────────┐
|
|
10
|
+
* https://example.crawlee.dev/...
|
|
11
|
+
* │ └─────────────────┤
|
|
12
|
+
* │ Hostname │
|
|
13
|
+
* │ │
|
|
14
|
+
* └─────────────────────────┘
|
|
15
|
+
* Origin
|
|
16
|
+
*```
|
|
17
|
+
*
|
|
18
|
+
* - The `Protocol` is usually `http` or `https`
|
|
19
|
+
* - The `Domain` represents the path without any possible subdomains to a website. For example, `crawlee.dev` is the domain of `https://example.crawlee.dev/`
|
|
20
|
+
* - The `Hostname` is the full path to a website, including any subdomains. For example, `example.crawlee.dev` is the hostname of `https://example.crawlee.dev/`
|
|
21
|
+
* - The `Origin` is the combination of the `Protocol` and `Hostname`. For example, `https://example.crawlee.dev` is the origin of `https://example.crawlee.dev/`
|
|
22
|
+
*/
|
|
23
|
+
export declare enum EnqueueStrategy {
|
|
24
|
+
/**
|
|
25
|
+
* Matches any URLs found
|
|
26
|
+
*/
|
|
27
|
+
All = "all",
|
|
28
|
+
/**
|
|
29
|
+
* Matches any URLs that have the same hostname.
|
|
30
|
+
* For example, `https://wow.example.com/hello` will be matched for a base url of `https://wow.example.com/`, but
|
|
31
|
+
* `https://example.com/hello` will not be matched.
|
|
32
|
+
*
|
|
33
|
+
* > This strategy will match both `http` and `https` protocols regardless of the base URL protocol.
|
|
34
|
+
*/
|
|
35
|
+
SameHostname = "same-hostname",
|
|
36
|
+
/**
|
|
37
|
+
* Matches any URLs that have the same domain as the base URL.
|
|
38
|
+
* For example, `https://wow.an.example.com` and `https://example.com` will both be matched for a base url of
|
|
39
|
+
* `https://example.com`.
|
|
40
|
+
*
|
|
41
|
+
* > This strategy will match both `http` and `https` protocols regardless of the base URL protocol.
|
|
42
|
+
*/
|
|
43
|
+
SameDomain = "same-domain",
|
|
44
|
+
/**
|
|
45
|
+
* Matches any URLs that have the same hostname and protocol.
|
|
46
|
+
* For example, `https://wow.example.com/hello` will be matched for a base url of `https://wow.example.com/`, but
|
|
47
|
+
* `http://wow.example.com/hello` will not be matched.
|
|
48
|
+
*
|
|
49
|
+
* > This strategy will ensure the protocol of the base URL is the same as the protocol of the URL to be enqueued.
|
|
50
|
+
*/
|
|
51
|
+
SameOrigin = "same-origin"
|
|
52
|
+
}
|
|
53
|
+
/** Reusable suffix for log messages explaining why a non-`http(s)` URL was rejected. */
|
|
54
|
+
export declare const UNSUPPORTED_SCHEME_MESSAGE = "unsupported URL scheme (only http and https are allowed)";
|
|
55
|
+
/**
|
|
56
|
+
* Check whether `target` matches `origin` under the given enqueue `strategy`. The URL scheme is not
|
|
57
|
+
* considered here (use {@link filterUrl} for the combined scheme + strategy check).
|
|
58
|
+
*
|
|
59
|
+
* The `enqueueLinks` implementation in `@crawlee/core` matches the same strategies via glob patterns
|
|
60
|
+
* (see `packages/core/src/enqueue_links/enqueue_links.ts`) — keep the two in sync when changing either.
|
|
61
|
+
*/
|
|
62
|
+
export declare function matchesEnqueueStrategy(strategy: EnqueueStrategy | `${EnqueueStrategy}`, target: URL, origin: URL): boolean;
|
|
63
|
+
/**
|
|
64
|
+
* Check whether `target` may be enqueued under `strategy` relative to `origin`: it must use an `http(s)`
|
|
65
|
+
* scheme and match the strategy. On rejection, `reason` is a human-readable message for log output.
|
|
66
|
+
*/
|
|
67
|
+
export declare function filterUrl(target: string | URL, origin: string | URL, strategy: EnqueueStrategy | `${EnqueueStrategy}`): {
|
|
68
|
+
allowed: boolean;
|
|
69
|
+
reason?: string;
|
|
70
|
+
};
|
|
2
71
|
/**
|
|
3
72
|
* Appends search (query string) parameters to a URL, replacing the original value (if any).
|
|
4
73
|
*
|
package/internals/url.js
CHANGED
|
@@ -1,3 +1,122 @@
|
|
|
1
|
+
import { getDomain } from 'tldts';
|
|
2
|
+
/**
|
|
3
|
+
* The different enqueueing strategies available.
|
|
4
|
+
*
|
|
5
|
+
* Depending on the strategy you select, we will only check certain parts of the URLs found. Here is a diagram of each URL part and their name:
|
|
6
|
+
*
|
|
7
|
+
* ```md
|
|
8
|
+
* Protocol Domain
|
|
9
|
+
* ┌────┐ ┌─────────┐
|
|
10
|
+
* https://example.crawlee.dev/...
|
|
11
|
+
* │ └─────────────────┤
|
|
12
|
+
* │ Hostname │
|
|
13
|
+
* │ │
|
|
14
|
+
* └─────────────────────────┘
|
|
15
|
+
* Origin
|
|
16
|
+
*```
|
|
17
|
+
*
|
|
18
|
+
* - The `Protocol` is usually `http` or `https`
|
|
19
|
+
* - The `Domain` represents the path without any possible subdomains to a website. For example, `crawlee.dev` is the domain of `https://example.crawlee.dev/`
|
|
20
|
+
* - The `Hostname` is the full path to a website, including any subdomains. For example, `example.crawlee.dev` is the hostname of `https://example.crawlee.dev/`
|
|
21
|
+
* - The `Origin` is the combination of the `Protocol` and `Hostname`. For example, `https://example.crawlee.dev` is the origin of `https://example.crawlee.dev/`
|
|
22
|
+
*/
|
|
23
|
+
export var EnqueueStrategy;
|
|
24
|
+
(function (EnqueueStrategy) {
|
|
25
|
+
/**
|
|
26
|
+
* Matches any URLs found
|
|
27
|
+
*/
|
|
28
|
+
EnqueueStrategy["All"] = "all";
|
|
29
|
+
/**
|
|
30
|
+
* Matches any URLs that have the same hostname.
|
|
31
|
+
* For example, `https://wow.example.com/hello` will be matched for a base url of `https://wow.example.com/`, but
|
|
32
|
+
* `https://example.com/hello` will not be matched.
|
|
33
|
+
*
|
|
34
|
+
* > This strategy will match both `http` and `https` protocols regardless of the base URL protocol.
|
|
35
|
+
*/
|
|
36
|
+
EnqueueStrategy["SameHostname"] = "same-hostname";
|
|
37
|
+
/**
|
|
38
|
+
* Matches any URLs that have the same domain as the base URL.
|
|
39
|
+
* For example, `https://wow.an.example.com` and `https://example.com` will both be matched for a base url of
|
|
40
|
+
* `https://example.com`.
|
|
41
|
+
*
|
|
42
|
+
* > This strategy will match both `http` and `https` protocols regardless of the base URL protocol.
|
|
43
|
+
*/
|
|
44
|
+
EnqueueStrategy["SameDomain"] = "same-domain";
|
|
45
|
+
/**
|
|
46
|
+
* Matches any URLs that have the same hostname and protocol.
|
|
47
|
+
* For example, `https://wow.example.com/hello` will be matched for a base url of `https://wow.example.com/`, but
|
|
48
|
+
* `http://wow.example.com/hello` will not be matched.
|
|
49
|
+
*
|
|
50
|
+
* > This strategy will ensure the protocol of the base URL is the same as the protocol of the URL to be enqueued.
|
|
51
|
+
*/
|
|
52
|
+
EnqueueStrategy["SameOrigin"] = "same-origin";
|
|
53
|
+
})(EnqueueStrategy || (EnqueueStrategy = {}));
|
|
54
|
+
/** Reusable suffix for log messages explaining why a non-`http(s)` URL was rejected. */
|
|
55
|
+
export const UNSUPPORTED_SCHEME_MESSAGE = 'unsupported URL scheme (only http and https are allowed)';
|
|
56
|
+
const ALLOWED_SCHEMES = new Set(['http:', 'https:']);
|
|
57
|
+
function toUrl(value) {
|
|
58
|
+
if (value instanceof URL) {
|
|
59
|
+
return value;
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
return new URL(value);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/** Strip a trailing dot so `example.com.` equals `example.com`. */
|
|
69
|
+
function normalizeHostname(hostname) {
|
|
70
|
+
return hostname.endsWith('.') ? hostname.slice(0, -1) : hostname;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Check whether `target` matches `origin` under the given enqueue `strategy`. The URL scheme is not
|
|
74
|
+
* considered here (use {@link filterUrl} for the combined scheme + strategy check).
|
|
75
|
+
*
|
|
76
|
+
* The `enqueueLinks` implementation in `@crawlee/core` matches the same strategies via glob patterns
|
|
77
|
+
* (see `packages/core/src/enqueue_links/enqueue_links.ts`) — keep the two in sync when changing either.
|
|
78
|
+
*/
|
|
79
|
+
export function matchesEnqueueStrategy(strategy, target, origin) {
|
|
80
|
+
switch (strategy) {
|
|
81
|
+
case 'all':
|
|
82
|
+
return true;
|
|
83
|
+
case 'same-hostname':
|
|
84
|
+
return normalizeHostname(target.hostname) === normalizeHostname(origin.hostname);
|
|
85
|
+
case 'same-domain': {
|
|
86
|
+
const originDomain = getDomain(origin.hostname, { mixedInputs: false });
|
|
87
|
+
if (originDomain) {
|
|
88
|
+
return originDomain === getDomain(target.hostname, { mixedInputs: false });
|
|
89
|
+
}
|
|
90
|
+
// No registrable domain (e.g. an IP address), fall back to comparing origins.
|
|
91
|
+
return target.origin === origin.origin;
|
|
92
|
+
}
|
|
93
|
+
case 'same-origin':
|
|
94
|
+
// Compare scheme/host/port directly so a trailing-dot host is normalized.
|
|
95
|
+
return (target.protocol === origin.protocol &&
|
|
96
|
+
normalizeHostname(target.hostname) === normalizeHostname(origin.hostname) &&
|
|
97
|
+
target.port === origin.port);
|
|
98
|
+
default:
|
|
99
|
+
throw new Error(`Unknown enqueue strategy '${strategy}'.`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Check whether `target` may be enqueued under `strategy` relative to `origin`: it must use an `http(s)`
|
|
104
|
+
* scheme and match the strategy. On rejection, `reason` is a human-readable message for log output.
|
|
105
|
+
*/
|
|
106
|
+
export function filterUrl(target, origin, strategy) {
|
|
107
|
+
const targetUrl = toUrl(target);
|
|
108
|
+
if (targetUrl === null || !ALLOWED_SCHEMES.has(targetUrl.protocol)) {
|
|
109
|
+
return { allowed: false, reason: UNSUPPORTED_SCHEME_MESSAGE };
|
|
110
|
+
}
|
|
111
|
+
const originUrl = toUrl(origin);
|
|
112
|
+
if (originUrl === null) {
|
|
113
|
+
return { allowed: false, reason: 'invalid origin URL' };
|
|
114
|
+
}
|
|
115
|
+
if (!matchesEnqueueStrategy(strategy, targetUrl, originUrl)) {
|
|
116
|
+
return { allowed: false, reason: `does not match enqueue strategy '${strategy}'` };
|
|
117
|
+
}
|
|
118
|
+
return { allowed: true };
|
|
119
|
+
}
|
|
1
120
|
/**
|
|
2
121
|
* Appends search (query string) parameters to a URL, replacing the original value (if any).
|
|
3
122
|
*
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { z } from 'zod';
|
|
2
|
+
/**
|
|
3
|
+
* Thrown when an argument fails schema validation.
|
|
4
|
+
*
|
|
5
|
+
* Its `message` is a human-readable sentence naming the offending field and the
|
|
6
|
+
* value it received (rather than a raw JSON dump). The structured
|
|
7
|
+
* {@link https://zod.dev | zod} issues are available on `issues`, and the
|
|
8
|
+
* original `ZodError` on `cause`, for programmatic inspection.
|
|
9
|
+
*/
|
|
10
|
+
export declare class ArgumentValidationError extends Error {
|
|
11
|
+
/** Structured issues from the underlying schema check. */
|
|
12
|
+
readonly issues: z.ZodError['issues'];
|
|
13
|
+
/** The raw zod error that triggered this. */
|
|
14
|
+
readonly cause: z.ZodError;
|
|
15
|
+
constructor(error: z.ZodError, value: unknown, label?: string);
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Parses `value` with `schema`, returning the typed result (with schema defaults applied).
|
|
19
|
+
* Throws {@link ArgumentValidationError} on failure.
|
|
20
|
+
*
|
|
21
|
+
* The optional `label` names the interface being validated and is appended to every error line
|
|
22
|
+
* (e.g. ``… at `maxRequestRetries` in `BasicCrawlerOptions` ``).
|
|
23
|
+
* @internal
|
|
24
|
+
*/
|
|
25
|
+
export declare function parseArgument<TValue, TSchema extends z.ZodType>(value: TValue, schema: TSchema, label?: string): TValue & z.output<TSchema>;
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/** Formats a zod issue path like `groups[0]` or `countryCode`. */
|
|
2
|
+
function formatIssuePath(path) {
|
|
3
|
+
let out = '';
|
|
4
|
+
for (const key of path) {
|
|
5
|
+
if (typeof key === 'number')
|
|
6
|
+
out += `[${key}]`;
|
|
7
|
+
else
|
|
8
|
+
out += out ? `.${String(key)}` : String(key);
|
|
9
|
+
}
|
|
10
|
+
return out;
|
|
11
|
+
}
|
|
12
|
+
/** Reads the value at `path` from the validated input, to include in the error. */
|
|
13
|
+
function valueAtPath(root, path) {
|
|
14
|
+
let current = root;
|
|
15
|
+
for (const key of path) {
|
|
16
|
+
if (current === null || typeof current !== 'object')
|
|
17
|
+
return undefined;
|
|
18
|
+
current = current[key];
|
|
19
|
+
}
|
|
20
|
+
return current;
|
|
21
|
+
}
|
|
22
|
+
/** Names the runtime type of `value` the way zod's own messages do (`null`, `array`, `string`, …). */
|
|
23
|
+
function describeType(value) {
|
|
24
|
+
if (value === null)
|
|
25
|
+
return 'null';
|
|
26
|
+
if (Array.isArray(value))
|
|
27
|
+
return 'array';
|
|
28
|
+
return typeof value;
|
|
29
|
+
}
|
|
30
|
+
/** The bare custom-schema messages that stop at the expected type, e.g. `Invalid input: expected number`. */
|
|
31
|
+
const BARE_EXPECTED_TYPE_MESSAGE = /^Invalid input: expected (an array of .+|a typed array|an object|object|array|function|number|string|boolean)$/;
|
|
32
|
+
/** Longest received string rendered in an error; the rest is elided. */
|
|
33
|
+
const MAX_RENDERED_STRING_LENGTH = 200;
|
|
34
|
+
/** Renders a primitive received value for an error; skips objects/Dates (noisy). */
|
|
35
|
+
function describeReceived(value) {
|
|
36
|
+
switch (typeof value) {
|
|
37
|
+
case 'string':
|
|
38
|
+
// An empty string would render as bare backticks — make it visible.
|
|
39
|
+
if (value === '')
|
|
40
|
+
return "''";
|
|
41
|
+
return value.length > MAX_RENDERED_STRING_LENGTH
|
|
42
|
+
? `${value.slice(0, MAX_RENDERED_STRING_LENGTH)}… (${value.length - MAX_RENDERED_STRING_LENGTH} more characters)`
|
|
43
|
+
: value;
|
|
44
|
+
case 'number':
|
|
45
|
+
case 'boolean':
|
|
46
|
+
case 'bigint':
|
|
47
|
+
return String(value);
|
|
48
|
+
default:
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/** Renders the received side of a sentence: ``received the string `abc` ``, `received NaN`, `received array`. */
|
|
53
|
+
function describeReceivedClause(value) {
|
|
54
|
+
if (typeof value === 'number' && Number.isNaN(value))
|
|
55
|
+
return 'received NaN';
|
|
56
|
+
if (value === '')
|
|
57
|
+
return 'received an empty string';
|
|
58
|
+
const rendered = describeReceived(value);
|
|
59
|
+
return rendered === undefined
|
|
60
|
+
? `received ${describeType(value)}`
|
|
61
|
+
: `received the ${describeType(value)} \`${rendered}\``;
|
|
62
|
+
}
|
|
63
|
+
/** Renders one issue as a line each; a union expands into a line per failed arm. */
|
|
64
|
+
function formatIssue(issue, root, basePath) {
|
|
65
|
+
const path = [...basePath, ...issue.path];
|
|
66
|
+
// A union's own message is a bare "Invalid input" — the useful part is in `errors`,
|
|
67
|
+
// whose paths are relative to the union, hence passing `path` down as the base.
|
|
68
|
+
if (issue.code === 'invalid_union') {
|
|
69
|
+
return issue.errors.flatMap((arm) => arm.flatMap((nested) => formatIssue(nested, root, path)));
|
|
70
|
+
}
|
|
71
|
+
const location = path.length ? ` at \`${formatIssuePath(path)}\`` : '';
|
|
72
|
+
const value = valueAtPath(root, path);
|
|
73
|
+
const rendered = describeReceived(value);
|
|
74
|
+
// ow named the received type ("expected `number` but received type `string`"). The received value is
|
|
75
|
+
// folded into that clause (``received the string `3` ``) rather than dangling after the location: our
|
|
76
|
+
// custom schemas stop at the expected type, so the clause is appended; zod's built-in messages already
|
|
77
|
+
// end with `, received <type>`, so that tail is replaced with the enriched one.
|
|
78
|
+
let { message } = issue;
|
|
79
|
+
let got = '';
|
|
80
|
+
const bareExpected = BARE_EXPECTED_TYPE_MESSAGE.exec(message);
|
|
81
|
+
const zodReceived = /, received (\S+)$/.exec(message);
|
|
82
|
+
// `arrayOf` messages name the element type — their expected runtime type is `array`.
|
|
83
|
+
const expectedType = bareExpected?.[1].startsWith('an array of') ? 'array' : bareExpected?.[1];
|
|
84
|
+
if (bareExpected && expectedType !== (Number.isNaN(value) ? 'NaN' : describeType(value))) {
|
|
85
|
+
message += `, ${describeReceivedClause(value)}`;
|
|
86
|
+
}
|
|
87
|
+
else if (zodReceived && zodReceived[1] === describeType(value) && rendered !== undefined) {
|
|
88
|
+
message = `${message.slice(0, zodReceived.index)}, ${describeReceivedClause(value)}`;
|
|
89
|
+
}
|
|
90
|
+
else if (rendered !== undefined && !message.endsWith(`received ${rendered}`)) {
|
|
91
|
+
// Messages that never name a received type (regex, min/max, enums) keep the plain value suffix.
|
|
92
|
+
got = `, got \`${rendered}\``;
|
|
93
|
+
}
|
|
94
|
+
return [`${message}${location}${got}`];
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Formats a `ZodError` as a plain, human-readable message that names the
|
|
98
|
+
* offending field *and* the value it received (e.g. ``must match pattern
|
|
99
|
+
* /^[A-Z]{2}$/ at `countryCode`, got `CZE` ``) — closer to the old `ow` errors
|
|
100
|
+
* than zod's default, which omits the received value.
|
|
101
|
+
*/
|
|
102
|
+
function formatZodError(error, root, label) {
|
|
103
|
+
const lines = error.issues.flatMap((issue) => formatIssue(issue, root, []));
|
|
104
|
+
// The label names the validated interface, the way ow's errors ended with "in object `X`".
|
|
105
|
+
return (label ? lines.map((line) => `${line} in \`${label}\``) : lines).join('\n');
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Thrown when an argument fails schema validation.
|
|
109
|
+
*
|
|
110
|
+
* Its `message` is a human-readable sentence naming the offending field and the
|
|
111
|
+
* value it received (rather than a raw JSON dump). The structured
|
|
112
|
+
* {@link https://zod.dev | zod} issues are available on `issues`, and the
|
|
113
|
+
* original `ZodError` on `cause`, for programmatic inspection.
|
|
114
|
+
*/
|
|
115
|
+
export class ArgumentValidationError extends Error {
|
|
116
|
+
/** Structured issues from the underlying schema check. */
|
|
117
|
+
issues;
|
|
118
|
+
/** The raw zod error that triggered this. */
|
|
119
|
+
cause;
|
|
120
|
+
constructor(error, value, label) {
|
|
121
|
+
super(formatZodError(error, value, label), { cause: error });
|
|
122
|
+
this.name = 'ArgumentValidationError';
|
|
123
|
+
this.issues = error.issues;
|
|
124
|
+
this.cause = error;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Parses `value` with `schema`, returning the typed result (with schema defaults applied).
|
|
129
|
+
* Throws {@link ArgumentValidationError} on failure.
|
|
130
|
+
*
|
|
131
|
+
* The optional `label` names the interface being validated and is appended to every error line
|
|
132
|
+
* (e.g. ``… at `maxRequestRetries` in `BasicCrawlerOptions` ``).
|
|
133
|
+
* @internal
|
|
134
|
+
*/
|
|
135
|
+
export function parseArgument(value, schema, label) {
|
|
136
|
+
const result = schema.safeParse(value);
|
|
137
|
+
if (!result.success)
|
|
138
|
+
throw new ArgumentValidationError(result.error, value, label);
|
|
139
|
+
return result.data;
|
|
140
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crawlee/utils",
|
|
3
|
-
"version": "4.0.0-
|
|
3
|
+
"version": "4.0.0-rc.0",
|
|
4
4
|
"description": "A set of shared utilities that can be used by crawlers",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=22.0.0"
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
"type": "module",
|
|
9
9
|
"exports": {
|
|
10
10
|
".": "./index.js",
|
|
11
|
+
"./internal": "./internal.js",
|
|
11
12
|
"./package.json": "./package.json"
|
|
12
13
|
},
|
|
13
14
|
"keywords": [
|
|
@@ -42,17 +43,18 @@
|
|
|
42
43
|
},
|
|
43
44
|
"dependencies": {
|
|
44
45
|
"@apify/ps-tree": "^1.2.0",
|
|
45
|
-
"@crawlee/http-client": "4.0.0-
|
|
46
|
-
"@crawlee/types": "4.0.0-
|
|
46
|
+
"@crawlee/http-client": "4.0.0-rc.0",
|
|
47
|
+
"@crawlee/types": "4.0.0-rc.0",
|
|
47
48
|
"@types/sax": "^1.2.7",
|
|
48
49
|
"cheerio": "^1.0.0",
|
|
49
50
|
"domhandler": "^5.0.3",
|
|
50
51
|
"file-type": "^21.0.0",
|
|
51
|
-
"ow": "^2.0.0",
|
|
52
52
|
"robots-parser": "^3.0.1",
|
|
53
53
|
"sax": "^1.4.1",
|
|
54
|
+
"tldts": "^7.0.6",
|
|
54
55
|
"tslib": "^2.8.1",
|
|
55
|
-
"whatwg-mimetype": "^4.0.0"
|
|
56
|
+
"whatwg-mimetype": "^4.0.0",
|
|
57
|
+
"zod": "^4.4.3"
|
|
56
58
|
},
|
|
57
59
|
"lerna": {
|
|
58
60
|
"command": {
|
|
@@ -61,5 +63,5 @@
|
|
|
61
63
|
}
|
|
62
64
|
}
|
|
63
65
|
},
|
|
64
|
-
"gitHead": "
|
|
66
|
+
"gitHead": "79ab33dacdacb83e0197e6516d145f3aceef80c7"
|
|
65
67
|
}
|