@crawlee/utils 4.0.0-beta.24 → 4.0.0-beta.240

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.
Files changed (78) hide show
  1. package/README.md +15 -15
  2. package/index.d.ts +10 -17
  3. package/index.js +8 -16
  4. package/internal.d.ts +10 -0
  5. package/internal.js +9 -0
  6. package/internals/blocked.d.ts +0 -1
  7. package/internals/blocked.js +0 -1
  8. package/internals/cheerio.d.ts +1 -3
  9. package/internals/cheerio.js +7 -6
  10. package/internals/extract-microdata.d.ts +24 -0
  11. package/internals/extract-microdata.js +118 -0
  12. package/internals/extract-urls.d.ts +1 -2
  13. package/internals/extract-urls.js +18 -17
  14. package/internals/general.d.ts +0 -25
  15. package/internals/general.js +2 -110
  16. package/internals/iterables.d.ts +0 -80
  17. package/internals/iterables.js +0 -126
  18. package/internals/{open_graph_parser.d.ts → open-graph-parser.d.ts} +2 -3
  19. package/internals/{open_graph_parser.js → open-graph-parser.js} +8 -9
  20. package/internals/robots.d.ts +35 -15
  21. package/internals/robots.js +66 -30
  22. package/internals/schemas.d.ts +114 -0
  23. package/internals/schemas.js +115 -0
  24. package/internals/sitemap.d.ts +44 -3
  25. package/internals/sitemap.js +171 -85
  26. package/internals/social.d.ts +2 -3
  27. package/internals/social.js +8 -6
  28. package/internals/url.d.ts +69 -1
  29. package/internals/url.js +120 -2
  30. package/internals/validation.d.ts +9 -0
  31. package/internals/validation.js +16 -0
  32. package/package.json +12 -11
  33. package/index.d.ts.map +0 -1
  34. package/index.js.map +0 -1
  35. package/internals/blocked.d.ts.map +0 -1
  36. package/internals/blocked.js.map +0 -1
  37. package/internals/cheerio.d.ts.map +0 -1
  38. package/internals/cheerio.js.map +0 -1
  39. package/internals/chunk.d.ts +0 -2
  40. package/internals/chunk.d.ts.map +0 -1
  41. package/internals/chunk.js +0 -40
  42. package/internals/chunk.js.map +0 -1
  43. package/internals/debug.d.ts +0 -32
  44. package/internals/debug.d.ts.map +0 -1
  45. package/internals/debug.js +0 -41
  46. package/internals/debug.js.map +0 -1
  47. package/internals/extract-urls.d.ts.map +0 -1
  48. package/internals/extract-urls.js.map +0 -1
  49. package/internals/general.d.ts.map +0 -1
  50. package/internals/general.js.map +0 -1
  51. package/internals/iterables.d.ts.map +0 -1
  52. package/internals/iterables.js.map +0 -1
  53. package/internals/open_graph_parser.d.ts.map +0 -1
  54. package/internals/open_graph_parser.js.map +0 -1
  55. package/internals/robots.d.ts.map +0 -1
  56. package/internals/robots.js.map +0 -1
  57. package/internals/sitemap.d.ts.map +0 -1
  58. package/internals/sitemap.js.map +0 -1
  59. package/internals/social.d.ts.map +0 -1
  60. package/internals/social.js.map +0 -1
  61. package/internals/system-info/cpu-info.d.ts +0 -64
  62. package/internals/system-info/cpu-info.d.ts.map +0 -1
  63. package/internals/system-info/cpu-info.js +0 -211
  64. package/internals/system-info/cpu-info.js.map +0 -1
  65. package/internals/system-info/memory-info.d.ts +0 -28
  66. package/internals/system-info/memory-info.d.ts.map +0 -1
  67. package/internals/system-info/memory-info.js +0 -118
  68. package/internals/system-info/memory-info.js.map +0 -1
  69. package/internals/system-info/ps-tree.d.ts +0 -18
  70. package/internals/system-info/ps-tree.d.ts.map +0 -1
  71. package/internals/system-info/ps-tree.js +0 -145
  72. package/internals/system-info/ps-tree.js.map +0 -1
  73. package/internals/typedefs.d.ts +0 -5
  74. package/internals/typedefs.d.ts.map +0 -1
  75. package/internals/typedefs.js +0 -9
  76. package/internals/typedefs.js.map +0 -1
  77. package/internals/url.d.ts.map +0 -1
  78. 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
+ });
@@ -1,4 +1,6 @@
1
- import type { BaseHttpClient } from '@crawlee/types';
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
@@ -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 { fileTypeStream } from 'file-type';
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 { RobotsFile } from './robots.js';
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.processBuffer(this.decoder.write(chunk), false);
17
+ this.#processBuffer(this.#decoder.write(chunk), false);
20
18
  callback();
21
19
  },
22
20
  flush: (callback) => {
23
- this.processBuffer(this.decoder.end(), true);
21
+ this.#processBuffer(this.#decoder.end(), true);
24
22
  callback();
25
23
  },
26
24
  });
27
25
  }
28
- processBuffer(input, finalize) {
29
- this.buffer += input;
30
- if (finalize || this.buffer.includes('\n')) {
31
- const parts = this.buffer
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.buffer = '';
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.buffer = parts.at(-1);
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 = new sax.SAXParser(true);
53
- rootTagName;
54
- currentTag = undefined;
55
- url = {};
56
- constructor() {
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.parser.write(this.decoder.write(chunk));
62
+ this.#parser.write(this.#decoder.write(chunk));
61
63
  callback();
62
64
  },
63
65
  flush: (callback) => {
64
- const rest = this.decoder.end();
66
+ const rest = this.#decoder.end();
65
67
  if (rest.length > 0) {
66
- this.parser.write(rest);
68
+ this.#parser.write(rest);
67
69
  }
68
- this.parser.end();
70
+ this.#parser.end();
69
71
  callback();
70
72
  },
71
73
  });
72
- this.parser.onopentag = this.onOpenTag.bind(this);
73
- this.parser.onclosetag = this.onCloseTag.bind(this);
74
- this.parser.ontext = this.onText.bind(this);
75
- this.parser.oncdata = this.onText.bind(this);
76
- this.parser.onerror = this.destroy.bind(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.rootTagName !== undefined) {
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.currentTag = node.name;
87
+ this.#currentTag = node.name;
85
88
  }
86
89
  }
87
90
  if (node.name === 'urlset') {
88
- this.rootTagName = 'urlset';
91
+ this.#rootTagName = 'urlset';
89
92
  }
90
93
  if (node.name === 'sitemapindex') {
91
- this.rootTagName = 'sitemapindex';
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.currentTag = undefined;
99
+ this.#currentTag = undefined;
97
100
  }
98
- if (name === 'url' && this.url.loc !== undefined) {
99
- this.push({ type: 'url', ...this.url, loc: this.url.loc });
100
- this.url = {};
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
- if (this.currentTag === 'loc') {
105
- if (this.rootTagName === 'sitemapindex') {
106
- this.push({ type: 'sitemapUrl', url: text.trim() });
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.rootTagName === 'urlset') {
109
- this.url ??= {};
110
- this.url.loc = text.trim();
118
+ if (this.#rootTagName === 'urlset') {
119
+ this.#url ??= {};
120
+ this.#url.loc = text;
111
121
  }
112
122
  }
113
- text = text.trim();
114
- if (this.currentTag === 'lastmod') {
115
- this.url.lastmod = new Date(text);
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.currentTag === 'priority') {
118
- this.url.priority = Number(text);
129
+ if (this.#currentTag === 'priority') {
130
+ this.#url.priority = Number(text);
119
131
  }
120
- if (this.currentTag === 'changefreq') {
132
+ if (this.#currentTag === 'changefreq') {
121
133
  if (['always', 'hourly', 'daily', 'weekly', 'monthly', 'yearly', 'never'].includes(text)) {
122
- this.url.changefreq = text;
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 new SitemapXmlParser();
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
- const sitemapUrl = new URL(source.url);
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: 'text/plain, application/xhtml+xml, application/xml;q=0.9, */*;q=0.8',
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
- log.warning(`Malformed sitemap content: ${sitemapUrl}, ${retriesLeft === 0 ? 'no retries left.' : 'retrying...'} (${e})`);
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
- log.warning(`Malformed sitemap content: ${error}`);
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.parse((Array.isArray(urls) ? urls : [urls]).map((url) => ({ type: 'url', url })), proxyUrl, parseSitemapOptions);
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.parse([{ type: 'raw', content }], proxyUrl, parseSitemapOptions);
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' }), { proxyUrl });
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 RobotsFile.find(domainUrls[0], { proxyUrl, httpClient });
347
- for (const sitemapUrl of robotsFile.getSitemaps()) {
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
- log.warning(`Failed to fetch robots.txt file for ${hostname}`, { error: err });
420
+ logger?.warning(`Failed to fetch robots.txt file for ${hostname}`, { error: err });
355
421
  }
356
- const sitemapUrl = domainUrls.find((url) => /sitemap\.(?:xml|txt)(?:\.gz)?$/i.test(url));
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
- for (const pathname of possibleSitemapPathnames) {
436
+ const candidateSitemapUrls = possibleSitemapPathnames.map((pathname) => {
366
437
  firstUrl.pathname = pathname;
367
- if (await urlExists(firstUrl.toString())) {
368
- if (addSitemapUrl(firstUrl.toString())) {
369
- yield firstUrl.toString();
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
- for await (const url of mergeAsyncIterables(...iterables)) {
384
- if (discoveredUrls.has(url)) {
385
- continue;
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
- discoveredUrls.add(url);
388
- yield url;
472
+ }
473
+ finally {
474
+ clearTimeout(timeoutHandle);
475
+ externalSignal?.removeEventListener('abort', onExternalAbort);
389
476
  }
390
477
  }
391
- //# sourceMappingURL=sitemap.js.map