@crawlee/utils 4.0.0-beta.23 → 4.0.0-beta.231
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/README.md +15 -15
- package/index.d.ts +10 -17
- package/index.js +8 -16
- package/internal.d.ts +10 -0
- package/internal.js +9 -0
- package/internals/blocked.d.ts +0 -1
- package/internals/blocked.js +0 -1
- package/internals/cheerio.d.ts +1 -3
- package/internals/cheerio.js +7 -6
- package/internals/extract-microdata.d.ts +24 -0
- package/internals/extract-microdata.js +118 -0
- package/internals/extract-urls.d.ts +1 -2
- package/internals/extract-urls.js +18 -17
- package/internals/general.d.ts +0 -25
- package/internals/general.js +2 -110
- package/internals/iterables.d.ts +0 -80
- package/internals/iterables.js +0 -126
- package/internals/{open_graph_parser.d.ts → open-graph-parser.d.ts} +2 -3
- package/internals/{open_graph_parser.js → open-graph-parser.js} +8 -9
- package/internals/robots.d.ts +33 -15
- package/internals/robots.js +64 -30
- package/internals/schemas.d.ts +114 -0
- package/internals/schemas.js +115 -0
- package/internals/sitemap.d.ts +44 -3
- package/internals/sitemap.js +171 -85
- package/internals/social.d.ts +2 -3
- package/internals/social.js +8 -6
- package/internals/url.d.ts +69 -1
- package/internals/url.js +120 -2
- package/internals/validation.d.ts +9 -0
- package/internals/validation.js +16 -0
- package/package.json +12 -11
- package/index.d.ts.map +0 -1
- package/index.js.map +0 -1
- package/internals/blocked.d.ts.map +0 -1
- package/internals/blocked.js.map +0 -1
- package/internals/cheerio.d.ts.map +0 -1
- package/internals/cheerio.js.map +0 -1
- package/internals/chunk.d.ts +0 -2
- package/internals/chunk.d.ts.map +0 -1
- package/internals/chunk.js +0 -40
- package/internals/chunk.js.map +0 -1
- package/internals/debug.d.ts +0 -32
- package/internals/debug.d.ts.map +0 -1
- package/internals/debug.js +0 -41
- package/internals/debug.js.map +0 -1
- package/internals/extract-urls.d.ts.map +0 -1
- package/internals/extract-urls.js.map +0 -1
- package/internals/general.d.ts.map +0 -1
- package/internals/general.js.map +0 -1
- package/internals/iterables.d.ts.map +0 -1
- package/internals/iterables.js.map +0 -1
- package/internals/open_graph_parser.d.ts.map +0 -1
- package/internals/open_graph_parser.js.map +0 -1
- package/internals/robots.d.ts.map +0 -1
- package/internals/robots.js.map +0 -1
- package/internals/sitemap.d.ts.map +0 -1
- package/internals/sitemap.js.map +0 -1
- package/internals/social.d.ts.map +0 -1
- package/internals/social.js.map +0 -1
- package/internals/system-info/cpu-info.d.ts +0 -64
- package/internals/system-info/cpu-info.d.ts.map +0 -1
- package/internals/system-info/cpu-info.js +0 -211
- package/internals/system-info/cpu-info.js.map +0 -1
- package/internals/system-info/memory-info.d.ts +0 -28
- package/internals/system-info/memory-info.d.ts.map +0 -1
- package/internals/system-info/memory-info.js +0 -118
- package/internals/system-info/memory-info.js.map +0 -1
- package/internals/system-info/ps-tree.d.ts +0 -18
- package/internals/system-info/ps-tree.d.ts.map +0 -1
- package/internals/system-info/ps-tree.js +0 -145
- package/internals/system-info/ps-tree.js.map +0 -1
- package/internals/typedefs.d.ts +0 -5
- package/internals/typedefs.d.ts.map +0 -1
- package/internals/typedefs.js +0 -9
- package/internals/typedefs.js.map +0 -1
- package/internals/url.d.ts.map +0 -1
- package/internals/url.js.map +0 -1
|
@@ -0,0 +1,115 @@
|
|
|
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
|
+
const storageRequestShape = z.looseObject({
|
|
58
|
+
id: z.string(),
|
|
59
|
+
url: z.url({ protocol: /^https?$/ }),
|
|
60
|
+
uniqueKey: z.string(),
|
|
61
|
+
method: z.string().optional(),
|
|
62
|
+
retryCount: z.number().int().optional(),
|
|
63
|
+
handledAt: z.union([z.string(), z.date()]).optional(),
|
|
64
|
+
});
|
|
65
|
+
/**
|
|
66
|
+
* Shape of a request stored in a request queue. Compiled: storage clients validate every request with it.
|
|
67
|
+
* @internal
|
|
68
|
+
*/
|
|
69
|
+
export const storageRequest = z.compile(storageRequestShape);
|
|
70
|
+
/**
|
|
71
|
+
* {@link storageRequest} before an id is assigned.
|
|
72
|
+
* @internal
|
|
73
|
+
*/
|
|
74
|
+
export const storageRequestWithoutId = z.compile(storageRequestShape.omit({ id: true }));
|
|
75
|
+
/**
|
|
76
|
+
* `z.array(item)` whose top-level type error names the element type — ``expected an array of numbers`` —
|
|
77
|
+
* instead of zod's bare `expected array`. Element failures keep zod's per-index messages, and `elements`
|
|
78
|
+
* is a human-readable plural (`'numbers'`, `'URL patterns'`), since element types cannot be introspected.
|
|
79
|
+
* @internal
|
|
80
|
+
*/
|
|
81
|
+
export function arrayOf(item, elements) {
|
|
82
|
+
return z.array(item, {
|
|
83
|
+
error: (issue) => issue.code === 'invalid_type' ? `Invalid input: expected an array of ${elements}` : undefined,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Batch of {@link storageRequestWithoutId}.
|
|
88
|
+
* @internal
|
|
89
|
+
*/
|
|
90
|
+
export const storageRequestBatch = z.compile(arrayOf(storageRequestWithoutId, 'requests'));
|
|
91
|
+
/**
|
|
92
|
+
* Options of request queue add/update operations.
|
|
93
|
+
* @internal
|
|
94
|
+
*/
|
|
95
|
+
export const requestQueueOperationOptions = z.object({
|
|
96
|
+
forefront: z.boolean().optional(),
|
|
97
|
+
});
|
|
98
|
+
/**
|
|
99
|
+
* Options of key-value store `listKeys`.
|
|
100
|
+
* @internal
|
|
101
|
+
*/
|
|
102
|
+
export const keyValueStoreListKeysOptions = z.object({
|
|
103
|
+
prefix: z.string().optional(),
|
|
104
|
+
exclusiveStartKey: z.string().optional(),
|
|
105
|
+
limit: z.number().int().gt(0).optional(),
|
|
106
|
+
});
|
|
107
|
+
/**
|
|
108
|
+
* Options of dataset item listing.
|
|
109
|
+
* @internal
|
|
110
|
+
*/
|
|
111
|
+
export const datasetListItemsOptions = z.object({
|
|
112
|
+
desc: z.boolean().optional(),
|
|
113
|
+
limit: z.number().int().optional(),
|
|
114
|
+
offset: z.number().int().optional(),
|
|
115
|
+
});
|
package/internals/sitemap.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import type { BaseHttpClient } from '@crawlee/
|
|
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;
|
|
@@ -47,6 +49,24 @@ export interface ParseSitemapOptions {
|
|
|
47
49
|
* Custom HTTP client to be used for fetching sitemaps.
|
|
48
50
|
*/
|
|
49
51
|
httpClient?: BaseHttpClient;
|
|
52
|
+
/**
|
|
53
|
+
* Optional filter for nested sitemap URLs discovered in sitemap index files.
|
|
54
|
+
* Called with the URL of each child sitemap before it is fetched.
|
|
55
|
+
* Return `true` to include the sitemap, `false` to skip it.
|
|
56
|
+
* If not provided, all nested sitemaps are followed.
|
|
57
|
+
*/
|
|
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}`;
|
|
66
|
+
/**
|
|
67
|
+
* Optional logger for reporting warnings during sitemap parsing.
|
|
68
|
+
*/
|
|
69
|
+
logger?: CrawleeLogger;
|
|
50
70
|
}
|
|
51
71
|
export declare function parseSitemap<T extends ParseSitemapOptions>(initialSources: SitemapSource[], proxyUrl?: string, options?: T): AsyncIterable<T['emitNestedSitemaps'] extends true ? SitemapUrl | NestedSitemap : SitemapUrl>;
|
|
52
72
|
/**
|
|
@@ -62,6 +82,7 @@ export declare function parseSitemap<T extends ParseSitemapOptions>(initialSourc
|
|
|
62
82
|
* ```
|
|
63
83
|
*/
|
|
64
84
|
export declare class Sitemap {
|
|
85
|
+
#private;
|
|
65
86
|
readonly urls: string[];
|
|
66
87
|
constructor(urls: string[]);
|
|
67
88
|
/**
|
|
@@ -83,7 +104,6 @@ export declare class Sitemap {
|
|
|
83
104
|
* @param proxyUrl URL of a proxy to be used for fetching sitemap contents
|
|
84
105
|
*/
|
|
85
106
|
static fromXmlString(content: string, proxyUrl?: string, parseSitemapOptions?: ParseSitemapOptions): Promise<Sitemap>;
|
|
86
|
-
protected static parse(sources: SitemapSource[], proxyUrl?: string, parseSitemapOptions?: ParseSitemapOptions): Promise<Sitemap>;
|
|
87
107
|
}
|
|
88
108
|
/**
|
|
89
109
|
* Given a list of URLs, discover related sitemap files for these domains by checking the `robots.txt` file,
|
|
@@ -97,10 +117,31 @@ export declare function discoverValidSitemaps(urls: string[], options?: {
|
|
|
97
117
|
* Proxy URL to be used for network requests.
|
|
98
118
|
*/
|
|
99
119
|
proxyUrl?: string;
|
|
120
|
+
/**
|
|
121
|
+
* Timeout in milliseconds for the entire `discoverValidSitemaps` call.
|
|
122
|
+
* An `AbortController` is created internally and its signal is passed to every HTTP request,
|
|
123
|
+
* so the whole discovery operation is cancelled once the timeout elapses.
|
|
124
|
+
* Defaults to `60_000` ms (60 seconds) to prevent indefinite hangs.
|
|
125
|
+
*/
|
|
126
|
+
timeoutMillis?: number;
|
|
127
|
+
/**
|
|
128
|
+
* An external `AbortSignal` to cancel the entire discovery operation.
|
|
129
|
+
* If both `signal` and `timeout` are provided, the operation is cancelled
|
|
130
|
+
* when either the signal is aborted or the timeout elapses (whichever comes first).
|
|
131
|
+
*/
|
|
132
|
+
signal?: AbortSignal;
|
|
133
|
+
/**
|
|
134
|
+
* Timeout in milliseconds for each individual HTTP request during discovery.
|
|
135
|
+
* Defaults to `20000` ms (20 seconds).
|
|
136
|
+
*/
|
|
137
|
+
requestTimeoutMillis?: number;
|
|
100
138
|
/**
|
|
101
139
|
* HTTP client to be used for network requests.
|
|
102
140
|
*/
|
|
103
141
|
httpClient?: BaseHttpClient;
|
|
142
|
+
/**
|
|
143
|
+
* Optional logger for reporting warnings during sitemap discovery.
|
|
144
|
+
*/
|
|
145
|
+
logger?: CrawleeLogger;
|
|
104
146
|
}): AsyncIterable<string>;
|
|
105
147
|
export {};
|
|
106
|
-
//# sourceMappingURL=sitemap.d.ts.map
|
package/internals/sitemap.js
CHANGED
|
@@ -3,32 +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 {
|
|
7
|
-
import sax from 'sax';
|
|
8
|
-
import MIMEType from 'whatwg-mimetype';
|
|
9
|
-
import log from '@apify/log';
|
|
6
|
+
import { MIMEType } from 'whatwg-mimetype';
|
|
10
7
|
import { mergeAsyncIterables } from './iterables.js';
|
|
11
|
-
import {
|
|
8
|
+
import { RobotsTxtFile } from './robots.js';
|
|
9
|
+
import { filterUrl } from './url.js';
|
|
12
10
|
class SitemapTxtParser extends Transform {
|
|
13
|
-
decoder = new StringDecoder('utf8');
|
|
14
|
-
buffer = '';
|
|
11
|
+
#decoder = new StringDecoder('utf8');
|
|
12
|
+
#buffer = '';
|
|
15
13
|
constructor() {
|
|
16
14
|
super({
|
|
17
15
|
readableObjectMode: true,
|
|
18
16
|
transform: (chunk, _encoding, callback) => {
|
|
19
|
-
this
|
|
17
|
+
this.#processBuffer(this.#decoder.write(chunk), false);
|
|
20
18
|
callback();
|
|
21
19
|
},
|
|
22
20
|
flush: (callback) => {
|
|
23
|
-
this
|
|
21
|
+
this.#processBuffer(this.#decoder.end(), true);
|
|
24
22
|
callback();
|
|
25
23
|
},
|
|
26
24
|
});
|
|
27
25
|
}
|
|
28
|
-
processBuffer(input, finalize) {
|
|
29
|
-
this
|
|
30
|
-
if (finalize || this
|
|
31
|
-
const parts = this
|
|
26
|
+
#processBuffer(input, finalize) {
|
|
27
|
+
this.#buffer += input;
|
|
28
|
+
if (finalize || this.#buffer.includes('\n')) {
|
|
29
|
+
const parts = this.#buffer
|
|
32
30
|
.split('\n')
|
|
33
31
|
.map((part) => part.trim())
|
|
34
32
|
.filter((part) => part.length > 0);
|
|
@@ -36,99 +34,113 @@ class SitemapTxtParser extends Transform {
|
|
|
36
34
|
for (const url of parts) {
|
|
37
35
|
this.push({ type: 'url', loc: url });
|
|
38
36
|
}
|
|
39
|
-
this
|
|
37
|
+
this.#buffer = '';
|
|
40
38
|
}
|
|
41
39
|
else if (parts.length > 0) {
|
|
42
40
|
for (const url of parts.slice(0, -1)) {
|
|
43
41
|
this.push({ type: 'url', loc: url });
|
|
44
42
|
}
|
|
45
|
-
this
|
|
43
|
+
this.#buffer = parts.at(-1);
|
|
46
44
|
}
|
|
47
45
|
}
|
|
48
46
|
}
|
|
49
47
|
}
|
|
50
48
|
class SitemapXmlParser extends Transform {
|
|
51
|
-
decoder = new StringDecoder('utf8');
|
|
52
|
-
parser
|
|
53
|
-
rootTagName;
|
|
54
|
-
currentTag = undefined;
|
|
55
|
-
url = {};
|
|
56
|
-
|
|
49
|
+
#decoder = new StringDecoder('utf8');
|
|
50
|
+
#parser;
|
|
51
|
+
#rootTagName;
|
|
52
|
+
#currentTag = undefined;
|
|
53
|
+
#url = {};
|
|
54
|
+
static async create() {
|
|
55
|
+
const { SAXParser } = (await import('sax')).default;
|
|
56
|
+
return new SitemapXmlParser(new SAXParser(true));
|
|
57
|
+
}
|
|
58
|
+
constructor(parser) {
|
|
57
59
|
super({
|
|
58
60
|
readableObjectMode: true,
|
|
59
61
|
transform: (chunk, _encoding, callback) => {
|
|
60
|
-
this
|
|
62
|
+
this.#parser.write(this.#decoder.write(chunk));
|
|
61
63
|
callback();
|
|
62
64
|
},
|
|
63
65
|
flush: (callback) => {
|
|
64
|
-
const rest = this
|
|
66
|
+
const rest = this.#decoder.end();
|
|
65
67
|
if (rest.length > 0) {
|
|
66
|
-
this
|
|
68
|
+
this.#parser.write(rest);
|
|
67
69
|
}
|
|
68
|
-
this
|
|
70
|
+
this.#parser.end();
|
|
69
71
|
callback();
|
|
70
72
|
},
|
|
71
73
|
});
|
|
72
|
-
this
|
|
73
|
-
this
|
|
74
|
-
this
|
|
75
|
-
this
|
|
76
|
-
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);
|
|
77
80
|
}
|
|
78
|
-
onOpenTag(node) {
|
|
79
|
-
if (this
|
|
81
|
+
#onOpenTag(node) {
|
|
82
|
+
if (this.#rootTagName !== undefined) {
|
|
80
83
|
if (node.name === 'loc' ||
|
|
81
84
|
node.name === 'lastmod' ||
|
|
82
85
|
node.name === 'priority' ||
|
|
83
86
|
node.name === 'changefreq') {
|
|
84
|
-
this
|
|
87
|
+
this.#currentTag = node.name;
|
|
85
88
|
}
|
|
86
89
|
}
|
|
87
90
|
if (node.name === 'urlset') {
|
|
88
|
-
this
|
|
91
|
+
this.#rootTagName = 'urlset';
|
|
89
92
|
}
|
|
90
93
|
if (node.name === 'sitemapindex') {
|
|
91
|
-
this
|
|
94
|
+
this.#rootTagName = 'sitemapindex';
|
|
92
95
|
}
|
|
93
96
|
}
|
|
94
|
-
onCloseTag(name) {
|
|
97
|
+
#onCloseTag(name) {
|
|
95
98
|
if (name === 'loc' || name === 'lastmod' || name === 'priority' || name === 'changefreq') {
|
|
96
|
-
this
|
|
99
|
+
this.#currentTag = undefined;
|
|
97
100
|
}
|
|
98
|
-
if (name === 'url'
|
|
99
|
-
|
|
100
|
-
|
|
101
|
+
if (name === 'url') {
|
|
102
|
+
if (this.#url.loc !== undefined) {
|
|
103
|
+
this.push({ type: 'url', ...this.#url, loc: this.#url.loc });
|
|
104
|
+
}
|
|
105
|
+
this.#url = {};
|
|
101
106
|
}
|
|
102
107
|
}
|
|
103
|
-
onText(text) {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
108
|
+
#onText(text) {
|
|
109
|
+
text = text.trim();
|
|
110
|
+
// Whitespace around a CDATA section arrives as separate text events.
|
|
111
|
+
if (text === '') {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (this.#currentTag === 'loc') {
|
|
115
|
+
if (this.#rootTagName === 'sitemapindex') {
|
|
116
|
+
this.push({ type: 'sitemapUrl', url: text });
|
|
107
117
|
}
|
|
108
|
-
if (this
|
|
109
|
-
this
|
|
110
|
-
this
|
|
118
|
+
if (this.#rootTagName === 'urlset') {
|
|
119
|
+
this.#url ??= {};
|
|
120
|
+
this.#url.loc = text;
|
|
111
121
|
}
|
|
112
122
|
}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
123
|
+
if (this.#currentTag === 'lastmod') {
|
|
124
|
+
const lastmod = new Date(text);
|
|
125
|
+
if (!Number.isNaN(lastmod.getTime())) {
|
|
126
|
+
this.#url.lastmod = lastmod;
|
|
127
|
+
}
|
|
116
128
|
}
|
|
117
|
-
if (this
|
|
118
|
-
this
|
|
129
|
+
if (this.#currentTag === 'priority') {
|
|
130
|
+
this.#url.priority = Number(text);
|
|
119
131
|
}
|
|
120
|
-
if (this
|
|
132
|
+
if (this.#currentTag === 'changefreq') {
|
|
121
133
|
if (['always', 'hourly', 'daily', 'weekly', 'monthly', 'yearly', 'never'].includes(text)) {
|
|
122
|
-
this
|
|
134
|
+
this.#url.changefreq = text;
|
|
123
135
|
}
|
|
124
136
|
}
|
|
125
137
|
}
|
|
126
138
|
}
|
|
127
139
|
export async function* parseSitemap(initialSources, proxyUrl, options) {
|
|
128
|
-
const { httpClient = new FetchHttpClient(), emitNestedSitemaps = false, maxDepth = Infinity, sitemapRetries = 3, timeoutMillis: timeout = 30000, reportNetworkErrors = true, } = options ?? {};
|
|
140
|
+
const { httpClient = new FetchHttpClient(), emitNestedSitemaps = false, maxDepth = Infinity, sitemapRetries = 3, timeoutMillis: timeout = 30000, reportNetworkErrors = true, nestedSitemapFilter, enqueueStrategy = 'same-hostname', logger, } = options ?? {};
|
|
129
141
|
const sources = [...initialSources];
|
|
130
142
|
const visitedSitemapUrls = new Set();
|
|
131
|
-
const createParser = (contentType = '', url) => {
|
|
143
|
+
const createParser = async (contentType = '', url) => {
|
|
132
144
|
let mimeType;
|
|
133
145
|
try {
|
|
134
146
|
mimeType = new MIMEType(contentType);
|
|
@@ -137,7 +149,7 @@ export async function* parseSitemap(initialSources, proxyUrl, options) {
|
|
|
137
149
|
mimeType = null;
|
|
138
150
|
}
|
|
139
151
|
if (mimeType?.isXML() || url?.pathname.endsWith('.xml')) {
|
|
140
|
-
return
|
|
152
|
+
return SitemapXmlParser.create();
|
|
141
153
|
}
|
|
142
154
|
if (mimeType?.essence === 'text/plain' || url?.pathname.endsWith('.txt')) {
|
|
143
155
|
return new SitemapTxtParser();
|
|
@@ -147,12 +159,13 @@ export async function* parseSitemap(initialSources, proxyUrl, options) {
|
|
|
147
159
|
while (sources.length > 0) {
|
|
148
160
|
const source = sources.shift();
|
|
149
161
|
if ((source?.depth ?? 0) > maxDepth) {
|
|
150
|
-
log.debug(`Skipping sitemap ${source.type === 'url' ? source.url : ''} because it reached max depth ${maxDepth}.`);
|
|
151
162
|
continue;
|
|
152
163
|
}
|
|
153
164
|
let items = null;
|
|
165
|
+
// Parent URL, parsed once and reused as the origin for the strategy checks below.
|
|
166
|
+
let sitemapUrl;
|
|
154
167
|
if (source.type === 'url') {
|
|
155
|
-
|
|
168
|
+
sitemapUrl = new URL(source.url);
|
|
156
169
|
visitedSitemapUrls.add(sitemapUrl.toString());
|
|
157
170
|
let retriesLeft = sitemapRetries + 1;
|
|
158
171
|
while (retriesLeft-- > 0) {
|
|
@@ -162,11 +175,11 @@ export async function* parseSitemap(initialSources, proxyUrl, options) {
|
|
|
162
175
|
sitemapResponse = await httpClient.sendRequest(new Request(sitemapUrl, {
|
|
163
176
|
method: 'GET',
|
|
164
177
|
headers: {
|
|
165
|
-
accept: '
|
|
178
|
+
accept: '*/*',
|
|
166
179
|
},
|
|
167
180
|
}), {
|
|
168
181
|
proxyUrl,
|
|
169
|
-
timeout,
|
|
182
|
+
timeoutMillis: timeout,
|
|
170
183
|
});
|
|
171
184
|
}
|
|
172
185
|
catch (error) {
|
|
@@ -178,6 +191,7 @@ export async function* parseSitemap(initialSources, proxyUrl, options) {
|
|
|
178
191
|
if (sitemapResponse.body === null) {
|
|
179
192
|
break;
|
|
180
193
|
}
|
|
194
|
+
const { fileTypeStream } = await import('file-type');
|
|
181
195
|
const streamWithType = await fileTypeStream(Readable.fromWeb(sitemapResponse.body));
|
|
182
196
|
if (streamWithType.fileType !== undefined) {
|
|
183
197
|
contentType = streamWithType.fileType.mime;
|
|
@@ -191,7 +205,7 @@ export async function* parseSitemap(initialSources, proxyUrl, options) {
|
|
|
191
205
|
sitemapUrl.pathname = sitemapUrl.pathname.substring(0, sitemapUrl.pathname.length - 3);
|
|
192
206
|
}
|
|
193
207
|
}
|
|
194
|
-
items = pipeline(streamWithType, isGzipped ? createGunzip() : new PassThrough(), createParser(contentType ?? undefined, sitemapUrl), (e) => {
|
|
208
|
+
items = pipeline(streamWithType, isGzipped ? createGunzip() : new PassThrough(), await createParser(contentType ?? undefined, sitemapUrl), (e) => {
|
|
195
209
|
if (e !== undefined && e !== null) {
|
|
196
210
|
error = { type: 'parser', error: e };
|
|
197
211
|
}
|
|
@@ -214,28 +228,53 @@ export async function* parseSitemap(initialSources, proxyUrl, options) {
|
|
|
214
228
|
}
|
|
215
229
|
}
|
|
216
230
|
catch (e) {
|
|
217
|
-
|
|
231
|
+
logger?.warning(`Malformed sitemap content: ${sitemapUrl}, ${retriesLeft === 0 ? 'no retries left.' : 'retrying...'} (${e})`);
|
|
218
232
|
}
|
|
219
233
|
}
|
|
220
234
|
}
|
|
221
235
|
else if (source.type === 'raw') {
|
|
222
|
-
items = pipeline(Readable.from([source.content]), createParser('text/xml'), (error) => {
|
|
236
|
+
items = pipeline(Readable.from([source.content]), await createParser('text/xml'), (error) => {
|
|
223
237
|
if (error !== undefined) {
|
|
224
|
-
|
|
238
|
+
logger?.warning(`Malformed sitemap content: ${error}`);
|
|
225
239
|
}
|
|
226
240
|
});
|
|
227
241
|
}
|
|
228
242
|
if (items === null) {
|
|
229
243
|
continue;
|
|
230
244
|
}
|
|
245
|
+
// URL entries dropped by the enqueue strategy filter, reported in one warning per sitemap after
|
|
246
|
+
// the loop (per-entry warnings could flood the log; individual drops are logged at debug level).
|
|
247
|
+
let droppedUrlEntries = 0;
|
|
231
248
|
for await (const item of items) {
|
|
232
249
|
if (item.type === 'sitemapUrl' && !visitedSitemapUrls.has(item.url)) {
|
|
250
|
+
if (nestedSitemapFilter && !nestedSitemapFilter(item.url)) {
|
|
251
|
+
logger?.debug(`Skipping sitemap ${item.url} due to nestedSitemapFilter.`);
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
// Keep only nested sitemaps matching the strategy (and using http(s)) relative to the
|
|
255
|
+
// parent. Raw string sources have no parent URL, so the check is skipped.
|
|
256
|
+
if (source.type === 'url') {
|
|
257
|
+
const { allowed, reason } = filterUrl(item.url, sitemapUrl, enqueueStrategy);
|
|
258
|
+
if (!allowed) {
|
|
259
|
+
logger?.warning(`Skipping nested sitemap ${item.url} (parent ${source.url}): ${reason}.`);
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
233
263
|
sources.push({ type: 'url', url: item.url, depth: (source.depth ?? 0) + 1 });
|
|
234
264
|
if (emitNestedSitemaps) {
|
|
235
265
|
yield { loc: item.url, originSitemapUrl: null };
|
|
236
266
|
}
|
|
237
267
|
}
|
|
238
268
|
if (item.type === 'url') {
|
|
269
|
+
// Keep only URL entries that match the enqueue strategy relative to the parent (see above).
|
|
270
|
+
if (source.type === 'url') {
|
|
271
|
+
const { allowed, reason } = filterUrl(item.loc, sitemapUrl, enqueueStrategy);
|
|
272
|
+
if (!allowed) {
|
|
273
|
+
droppedUrlEntries++;
|
|
274
|
+
logger?.debug(`Skipping sitemap URL ${item.loc} (parent ${source.url}): ${reason}.`);
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
239
278
|
yield {
|
|
240
279
|
...item,
|
|
241
280
|
originSitemapUrl: source.type === 'url'
|
|
@@ -244,6 +283,9 @@ export async function* parseSitemap(initialSources, proxyUrl, options) {
|
|
|
244
283
|
};
|
|
245
284
|
}
|
|
246
285
|
}
|
|
286
|
+
if (droppedUrlEntries > 0 && source.type === 'url') {
|
|
287
|
+
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.`);
|
|
288
|
+
}
|
|
247
289
|
}
|
|
248
290
|
}
|
|
249
291
|
/**
|
|
@@ -285,7 +327,7 @@ export class Sitemap {
|
|
|
285
327
|
* @param proxyUrl URL of a proxy to be used for fetching sitemap contents
|
|
286
328
|
*/
|
|
287
329
|
static async load(urls, proxyUrl, parseSitemapOptions) {
|
|
288
|
-
return await this
|
|
330
|
+
return await this.#parse((Array.isArray(urls) ? urls : [urls]).map((url) => ({ type: 'url', url })), proxyUrl, parseSitemapOptions);
|
|
289
331
|
}
|
|
290
332
|
/**
|
|
291
333
|
* Parse XML sitemap content from a string and return URLs of referenced pages. If the sitemap references other sitemaps, they will be loaded via HTTP.
|
|
@@ -293,16 +335,17 @@ export class Sitemap {
|
|
|
293
335
|
* @param proxyUrl URL of a proxy to be used for fetching sitemap contents
|
|
294
336
|
*/
|
|
295
337
|
static async fromXmlString(content, proxyUrl, parseSitemapOptions) {
|
|
296
|
-
return await this
|
|
338
|
+
return await this.#parse([{ type: 'raw', content }], proxyUrl, parseSitemapOptions);
|
|
297
339
|
}
|
|
298
|
-
static async parse(sources, proxyUrl, parseSitemapOptions) {
|
|
340
|
+
static async #parse(sources, proxyUrl, parseSitemapOptions) {
|
|
299
341
|
const urls = [];
|
|
300
342
|
try {
|
|
301
343
|
for await (const item of parseSitemap(sources, proxyUrl, parseSitemapOptions)) {
|
|
302
344
|
urls.push(item.loc);
|
|
303
345
|
}
|
|
304
346
|
}
|
|
305
|
-
catch {
|
|
347
|
+
catch (e) {
|
|
348
|
+
parseSitemapOptions?.logger?.warning(`Sitemap.load: Failed to load sitemap, returning empty result. (${e})`);
|
|
306
349
|
return new Sitemap([]);
|
|
307
350
|
}
|
|
308
351
|
return new Sitemap(urls);
|
|
@@ -316,7 +359,19 @@ export class Sitemap {
|
|
|
316
359
|
* @returns An async iterable with the discovered sitemap URLs.
|
|
317
360
|
*/
|
|
318
361
|
export async function* discoverValidSitemaps(urls, options = {}) {
|
|
319
|
-
const { proxyUrl, httpClient = new FetchHttpClient() } = options;
|
|
362
|
+
const { proxyUrl, timeoutMillis = 60_000, signal: externalSignal, requestTimeoutMillis = 20_000, httpClient = new FetchHttpClient(), logger, } = options;
|
|
363
|
+
const controller = new AbortController();
|
|
364
|
+
const timeoutHandle = setTimeout(() => controller.abort(), timeoutMillis);
|
|
365
|
+
const onExternalAbort = () => controller.abort();
|
|
366
|
+
if (externalSignal) {
|
|
367
|
+
if (externalSignal.aborted) {
|
|
368
|
+
controller.abort();
|
|
369
|
+
}
|
|
370
|
+
else {
|
|
371
|
+
externalSignal.addEventListener('abort', onExternalAbort, { once: true });
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
const signal = controller.signal;
|
|
320
375
|
const sitemapUrls = new Set();
|
|
321
376
|
const addSitemapUrl = (url) => {
|
|
322
377
|
const sizeBefore = sitemapUrls.size;
|
|
@@ -331,7 +386,11 @@ export async function* discoverValidSitemaps(urls, options = {}) {
|
|
|
331
386
|
return false;
|
|
332
387
|
}
|
|
333
388
|
try {
|
|
334
|
-
const response = await httpClient.sendRequest(new Request(url, { method: 'HEAD' }), {
|
|
389
|
+
const response = await httpClient.sendRequest(new Request(url, { method: 'HEAD' }), {
|
|
390
|
+
proxyUrl,
|
|
391
|
+
timeoutMillis: requestTimeoutMillis,
|
|
392
|
+
signal,
|
|
393
|
+
});
|
|
335
394
|
return response.status >= 200 && response.status < 400;
|
|
336
395
|
}
|
|
337
396
|
catch {
|
|
@@ -343,17 +402,24 @@ export async function* discoverValidSitemaps(urls, options = {}) {
|
|
|
343
402
|
return;
|
|
344
403
|
}
|
|
345
404
|
try {
|
|
346
|
-
const robotsFile = await
|
|
347
|
-
|
|
405
|
+
const robotsFile = await RobotsTxtFile.find(domainUrls[0], {
|
|
406
|
+
proxyUrl,
|
|
407
|
+
timeoutMillis: requestTimeoutMillis,
|
|
408
|
+
signal,
|
|
409
|
+
httpClient,
|
|
410
|
+
logger,
|
|
411
|
+
});
|
|
412
|
+
// Surface all referenced sitemaps, including cross-host; scoping happens at load time.
|
|
413
|
+
for (const sitemapUrl of robotsFile.getSitemaps({ enqueueStrategy: 'all' })) {
|
|
348
414
|
if (addSitemapUrl(sitemapUrl)) {
|
|
349
415
|
yield sitemapUrl;
|
|
350
416
|
}
|
|
351
417
|
}
|
|
352
418
|
}
|
|
353
419
|
catch (err) {
|
|
354
|
-
|
|
420
|
+
logger?.warning(`Failed to fetch robots.txt file for ${hostname}`, { error: err });
|
|
355
421
|
}
|
|
356
|
-
const sitemapUrl = domainUrls.find((url) => /sitemap
|
|
422
|
+
const sitemapUrl = domainUrls.find((url) => /sitemap(?:_index)?\.(?:xml|txt)(?:\.gz)?$/i.test(url));
|
|
357
423
|
if (sitemapUrl !== undefined) {
|
|
358
424
|
if (addSitemapUrl(sitemapUrl)) {
|
|
359
425
|
yield sitemapUrl;
|
|
@@ -361,14 +427,29 @@ export async function* discoverValidSitemaps(urls, options = {}) {
|
|
|
361
427
|
}
|
|
362
428
|
else {
|
|
363
429
|
const firstUrl = new URL(domainUrls[0]);
|
|
430
|
+
// The guessed candidates are well-known paths on the same origin, so the query string
|
|
431
|
+
// and fragment of the input URL are not part of them - carrying them over probes (and
|
|
432
|
+
// yields) URLs like `/sitemap.xml?page=2`, which servers may well answer with a 404.
|
|
433
|
+
firstUrl.search = '';
|
|
434
|
+
firstUrl.hash = '';
|
|
364
435
|
const possibleSitemapPathnames = ['/sitemap.xml', '/sitemap.txt', '/sitemap_index.xml'];
|
|
365
|
-
|
|
436
|
+
const candidateSitemapUrls = possibleSitemapPathnames.map((pathname) => {
|
|
366
437
|
firstUrl.pathname = pathname;
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
438
|
+
return firstUrl.toString();
|
|
439
|
+
});
|
|
440
|
+
const candidateResults = await Promise.allSettled(candidateSitemapUrls.map(urlExists));
|
|
441
|
+
for (const [index, result] of candidateResults.entries()) {
|
|
442
|
+
const candidateSitemapUrl = candidateSitemapUrls[index];
|
|
443
|
+
if (result.status === 'fulfilled') {
|
|
444
|
+
if (result.value && addSitemapUrl(candidateSitemapUrl)) {
|
|
445
|
+
yield candidateSitemapUrl;
|
|
370
446
|
}
|
|
371
447
|
}
|
|
448
|
+
else {
|
|
449
|
+
logger?.debug(`Failed to check sitemap candidate ${candidateSitemapUrl} for ${hostname}`, {
|
|
450
|
+
error: result.reason,
|
|
451
|
+
});
|
|
452
|
+
}
|
|
372
453
|
}
|
|
373
454
|
}
|
|
374
455
|
};
|
|
@@ -380,12 +461,17 @@ export async function* discoverValidSitemaps(urls, options = {}) {
|
|
|
380
461
|
}, {});
|
|
381
462
|
const iterables = Object.entries(groupedUrls).map(([hostname, domainUrls]) => discoverSitemapsForDomainUrls(hostname, domainUrls));
|
|
382
463
|
const discoveredUrls = new Set();
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
464
|
+
try {
|
|
465
|
+
for await (const url of mergeAsyncIterables(...iterables)) {
|
|
466
|
+
if (discoveredUrls.has(url)) {
|
|
467
|
+
continue;
|
|
468
|
+
}
|
|
469
|
+
discoveredUrls.add(url);
|
|
470
|
+
yield url;
|
|
386
471
|
}
|
|
387
|
-
|
|
388
|
-
|
|
472
|
+
}
|
|
473
|
+
finally {
|
|
474
|
+
clearTimeout(timeoutHandle);
|
|
475
|
+
externalSignal?.removeEventListener('abort', onExternalAbort);
|
|
389
476
|
}
|
|
390
477
|
}
|
|
391
|
-
//# sourceMappingURL=sitemap.js.map
|