@crawlee/utils 4.0.0-beta.21 → 4.0.0-beta.210
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 +14 -14
- package/index.d.ts +9 -16
- package/index.js +7 -15
- 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 +19 -18
- package/internals/general.d.ts +0 -25
- package/internals/general.js +2 -110
- package/internals/iterables.d.ts +9 -70
- package/internals/iterables.js +40 -111
- package/internals/open_graph_parser.d.ts +2 -3
- package/internals/open_graph_parser.js +8 -9
- package/internals/robots.d.ts +34 -15
- package/internals/robots.js +63 -29
- package/internals/schemas.d.ts +114 -0
- package/internals/schemas.js +115 -0
- package/internals/sitemap.d.ts +61 -3
- package/internals/sitemap.js +225 -61
- 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 +10 -8
- 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
package/internals/sitemap.js
CHANGED
|
@@ -2,31 +2,31 @@ import { createHash } from 'node:crypto';
|
|
|
2
2
|
import { PassThrough, pipeline, Readable, Transform } from 'node:stream';
|
|
3
3
|
import { StringDecoder } from 'node:string_decoder';
|
|
4
4
|
import { createGunzip } from 'node:zlib';
|
|
5
|
-
import {
|
|
6
|
-
import { fileTypeStream } from 'file-type';
|
|
7
|
-
import sax from 'sax';
|
|
5
|
+
import { FetchHttpClient } from '@crawlee/http-client';
|
|
8
6
|
import MIMEType from 'whatwg-mimetype';
|
|
9
|
-
import
|
|
7
|
+
import { mergeAsyncIterables } from './iterables.js';
|
|
8
|
+
import { RobotsTxtFile } from './robots.js';
|
|
9
|
+
import { filterUrl } from './url.js';
|
|
10
10
|
class SitemapTxtParser extends Transform {
|
|
11
|
-
decoder = new StringDecoder('utf8');
|
|
12
|
-
buffer = '';
|
|
11
|
+
#decoder = new StringDecoder('utf8');
|
|
12
|
+
#buffer = '';
|
|
13
13
|
constructor() {
|
|
14
14
|
super({
|
|
15
15
|
readableObjectMode: true,
|
|
16
16
|
transform: (chunk, _encoding, callback) => {
|
|
17
|
-
this.processBuffer(this
|
|
17
|
+
this.processBuffer(this.#decoder.write(chunk), false);
|
|
18
18
|
callback();
|
|
19
19
|
},
|
|
20
20
|
flush: (callback) => {
|
|
21
|
-
this.processBuffer(this
|
|
21
|
+
this.processBuffer(this.#decoder.end(), true);
|
|
22
22
|
callback();
|
|
23
23
|
},
|
|
24
24
|
});
|
|
25
25
|
}
|
|
26
26
|
processBuffer(input, finalize) {
|
|
27
|
-
this
|
|
28
|
-
if (finalize || this
|
|
29
|
-
const parts = this
|
|
27
|
+
this.#buffer += input;
|
|
28
|
+
if (finalize || this.#buffer.includes('\n')) {
|
|
29
|
+
const parts = this.#buffer
|
|
30
30
|
.split('\n')
|
|
31
31
|
.map((part) => part.trim())
|
|
32
32
|
.filter((part) => part.length > 0);
|
|
@@ -34,99 +34,109 @@ class SitemapTxtParser extends Transform {
|
|
|
34
34
|
for (const url of parts) {
|
|
35
35
|
this.push({ type: 'url', loc: url });
|
|
36
36
|
}
|
|
37
|
-
this
|
|
37
|
+
this.#buffer = '';
|
|
38
38
|
}
|
|
39
39
|
else if (parts.length > 0) {
|
|
40
40
|
for (const url of parts.slice(0, -1)) {
|
|
41
41
|
this.push({ type: 'url', loc: url });
|
|
42
42
|
}
|
|
43
|
-
this
|
|
43
|
+
this.#buffer = parts.at(-1);
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
48
|
class SitemapXmlParser extends Transform {
|
|
49
|
-
decoder = new StringDecoder('utf8');
|
|
50
|
-
parser
|
|
51
|
-
rootTagName;
|
|
52
|
-
currentTag = undefined;
|
|
53
|
-
url = {};
|
|
54
|
-
|
|
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) {
|
|
55
59
|
super({
|
|
56
60
|
readableObjectMode: true,
|
|
57
61
|
transform: (chunk, _encoding, callback) => {
|
|
58
|
-
this
|
|
62
|
+
this.#parser.write(this.#decoder.write(chunk));
|
|
59
63
|
callback();
|
|
60
64
|
},
|
|
61
65
|
flush: (callback) => {
|
|
62
|
-
const rest = this
|
|
66
|
+
const rest = this.#decoder.end();
|
|
63
67
|
if (rest.length > 0) {
|
|
64
|
-
this
|
|
68
|
+
this.#parser.write(rest);
|
|
65
69
|
}
|
|
66
|
-
this
|
|
70
|
+
this.#parser.end();
|
|
67
71
|
callback();
|
|
68
72
|
},
|
|
69
73
|
});
|
|
70
|
-
this
|
|
71
|
-
this
|
|
72
|
-
this
|
|
73
|
-
this
|
|
74
|
-
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);
|
|
75
80
|
}
|
|
76
81
|
onOpenTag(node) {
|
|
77
|
-
if (this
|
|
82
|
+
if (this.#rootTagName !== undefined) {
|
|
78
83
|
if (node.name === 'loc' ||
|
|
79
84
|
node.name === 'lastmod' ||
|
|
80
85
|
node.name === 'priority' ||
|
|
81
86
|
node.name === 'changefreq') {
|
|
82
|
-
this
|
|
87
|
+
this.#currentTag = node.name;
|
|
83
88
|
}
|
|
84
89
|
}
|
|
85
90
|
if (node.name === 'urlset') {
|
|
86
|
-
this
|
|
91
|
+
this.#rootTagName = 'urlset';
|
|
87
92
|
}
|
|
88
93
|
if (node.name === 'sitemapindex') {
|
|
89
|
-
this
|
|
94
|
+
this.#rootTagName = 'sitemapindex';
|
|
90
95
|
}
|
|
91
96
|
}
|
|
92
97
|
onCloseTag(name) {
|
|
93
98
|
if (name === 'loc' || name === 'lastmod' || name === 'priority' || name === 'changefreq') {
|
|
94
|
-
this
|
|
99
|
+
this.#currentTag = undefined;
|
|
95
100
|
}
|
|
96
|
-
if (name === 'url'
|
|
97
|
-
|
|
98
|
-
|
|
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 = {};
|
|
99
106
|
}
|
|
100
107
|
}
|
|
101
108
|
onText(text) {
|
|
102
|
-
if (this
|
|
103
|
-
if (this
|
|
109
|
+
if (this.#currentTag === 'loc') {
|
|
110
|
+
if (this.#rootTagName === 'sitemapindex') {
|
|
104
111
|
this.push({ type: 'sitemapUrl', url: text.trim() });
|
|
105
112
|
}
|
|
106
|
-
if (this
|
|
107
|
-
this
|
|
108
|
-
this
|
|
113
|
+
if (this.#rootTagName === 'urlset') {
|
|
114
|
+
this.#url ??= {};
|
|
115
|
+
this.#url.loc = text.trim();
|
|
109
116
|
}
|
|
110
117
|
}
|
|
111
118
|
text = text.trim();
|
|
112
|
-
if (this
|
|
113
|
-
|
|
119
|
+
if (this.#currentTag === 'lastmod') {
|
|
120
|
+
const lastmod = new Date(text);
|
|
121
|
+
if (!Number.isNaN(lastmod.getTime())) {
|
|
122
|
+
this.#url.lastmod = lastmod;
|
|
123
|
+
}
|
|
114
124
|
}
|
|
115
|
-
if (this
|
|
116
|
-
this
|
|
125
|
+
if (this.#currentTag === 'priority') {
|
|
126
|
+
this.#url.priority = Number(text);
|
|
117
127
|
}
|
|
118
|
-
if (this
|
|
128
|
+
if (this.#currentTag === 'changefreq') {
|
|
119
129
|
if (['always', 'hourly', 'daily', 'weekly', 'monthly', 'yearly', 'never'].includes(text)) {
|
|
120
|
-
this
|
|
130
|
+
this.#url.changefreq = text;
|
|
121
131
|
}
|
|
122
132
|
}
|
|
123
133
|
}
|
|
124
134
|
}
|
|
125
135
|
export async function* parseSitemap(initialSources, proxyUrl, options) {
|
|
126
|
-
const { httpClient = new
|
|
136
|
+
const { httpClient = new FetchHttpClient(), emitNestedSitemaps = false, maxDepth = Infinity, sitemapRetries = 3, timeoutMillis: timeout = 30000, reportNetworkErrors = true, nestedSitemapFilter, enqueueStrategy = 'same-hostname', logger, } = options ?? {};
|
|
127
137
|
const sources = [...initialSources];
|
|
128
138
|
const visitedSitemapUrls = new Set();
|
|
129
|
-
const createParser = (contentType = '', url) => {
|
|
139
|
+
const createParser = async (contentType = '', url) => {
|
|
130
140
|
let mimeType;
|
|
131
141
|
try {
|
|
132
142
|
mimeType = new MIMEType(contentType);
|
|
@@ -135,7 +145,7 @@ export async function* parseSitemap(initialSources, proxyUrl, options) {
|
|
|
135
145
|
mimeType = null;
|
|
136
146
|
}
|
|
137
147
|
if (mimeType?.isXML() || url?.pathname.endsWith('.xml')) {
|
|
138
|
-
return
|
|
148
|
+
return SitemapXmlParser.create();
|
|
139
149
|
}
|
|
140
150
|
if (mimeType?.essence === 'text/plain' || url?.pathname.endsWith('.txt')) {
|
|
141
151
|
return new SitemapTxtParser();
|
|
@@ -145,26 +155,27 @@ export async function* parseSitemap(initialSources, proxyUrl, options) {
|
|
|
145
155
|
while (sources.length > 0) {
|
|
146
156
|
const source = sources.shift();
|
|
147
157
|
if ((source?.depth ?? 0) > maxDepth) {
|
|
148
|
-
log.debug(`Skipping sitemap ${source.type === 'url' ? source.url : ''} because it reached max depth ${maxDepth}.`);
|
|
149
158
|
continue;
|
|
150
159
|
}
|
|
151
160
|
let items = null;
|
|
161
|
+
// Parent URL, parsed once and reused as the origin for the strategy checks below.
|
|
162
|
+
let sitemapUrl;
|
|
152
163
|
if (source.type === 'url') {
|
|
153
|
-
|
|
164
|
+
sitemapUrl = new URL(source.url);
|
|
154
165
|
visitedSitemapUrls.add(sitemapUrl.toString());
|
|
155
166
|
let retriesLeft = sitemapRetries + 1;
|
|
156
167
|
while (retriesLeft-- > 0) {
|
|
157
168
|
try {
|
|
158
169
|
let sitemapResponse;
|
|
159
170
|
try {
|
|
160
|
-
sitemapResponse = await httpClient.
|
|
171
|
+
sitemapResponse = await httpClient.sendRequest(new Request(sitemapUrl, {
|
|
161
172
|
method: 'GET',
|
|
162
173
|
headers: {
|
|
163
|
-
accept: '
|
|
174
|
+
accept: '*/*',
|
|
164
175
|
},
|
|
165
176
|
}), {
|
|
166
177
|
proxyUrl,
|
|
167
|
-
timeout,
|
|
178
|
+
timeoutMillis: timeout,
|
|
168
179
|
});
|
|
169
180
|
}
|
|
170
181
|
catch (error) {
|
|
@@ -176,6 +187,7 @@ export async function* parseSitemap(initialSources, proxyUrl, options) {
|
|
|
176
187
|
if (sitemapResponse.body === null) {
|
|
177
188
|
break;
|
|
178
189
|
}
|
|
190
|
+
const { fileTypeStream } = await import('file-type');
|
|
179
191
|
const streamWithType = await fileTypeStream(Readable.fromWeb(sitemapResponse.body));
|
|
180
192
|
if (streamWithType.fileType !== undefined) {
|
|
181
193
|
contentType = streamWithType.fileType.mime;
|
|
@@ -189,7 +201,7 @@ export async function* parseSitemap(initialSources, proxyUrl, options) {
|
|
|
189
201
|
sitemapUrl.pathname = sitemapUrl.pathname.substring(0, sitemapUrl.pathname.length - 3);
|
|
190
202
|
}
|
|
191
203
|
}
|
|
192
|
-
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) => {
|
|
193
205
|
if (e !== undefined && e !== null) {
|
|
194
206
|
error = { type: 'parser', error: e };
|
|
195
207
|
}
|
|
@@ -212,28 +224,53 @@ export async function* parseSitemap(initialSources, proxyUrl, options) {
|
|
|
212
224
|
}
|
|
213
225
|
}
|
|
214
226
|
catch (e) {
|
|
215
|
-
|
|
227
|
+
logger?.warning(`Malformed sitemap content: ${sitemapUrl}, ${retriesLeft === 0 ? 'no retries left.' : 'retrying...'} (${e})`);
|
|
216
228
|
}
|
|
217
229
|
}
|
|
218
230
|
}
|
|
219
231
|
else if (source.type === 'raw') {
|
|
220
|
-
items = pipeline(Readable.from([source.content]), createParser('text/xml'), (error) => {
|
|
232
|
+
items = pipeline(Readable.from([source.content]), await createParser('text/xml'), (error) => {
|
|
221
233
|
if (error !== undefined) {
|
|
222
|
-
|
|
234
|
+
logger?.warning(`Malformed sitemap content: ${error}`);
|
|
223
235
|
}
|
|
224
236
|
});
|
|
225
237
|
}
|
|
226
238
|
if (items === null) {
|
|
227
239
|
continue;
|
|
228
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;
|
|
229
244
|
for await (const item of items) {
|
|
230
245
|
if (item.type === 'sitemapUrl' && !visitedSitemapUrls.has(item.url)) {
|
|
246
|
+
if (nestedSitemapFilter && !nestedSitemapFilter(item.url)) {
|
|
247
|
+
logger?.debug(`Skipping sitemap ${item.url} due to nestedSitemapFilter.`);
|
|
248
|
+
continue;
|
|
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
|
+
}
|
|
231
259
|
sources.push({ type: 'url', url: item.url, depth: (source.depth ?? 0) + 1 });
|
|
232
260
|
if (emitNestedSitemaps) {
|
|
233
261
|
yield { loc: item.url, originSitemapUrl: null };
|
|
234
262
|
}
|
|
235
263
|
}
|
|
236
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
|
+
}
|
|
237
274
|
yield {
|
|
238
275
|
...item,
|
|
239
276
|
originSitemapUrl: source.type === 'url'
|
|
@@ -242,6 +279,9 @@ export async function* parseSitemap(initialSources, proxyUrl, options) {
|
|
|
242
279
|
};
|
|
243
280
|
}
|
|
244
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
|
+
}
|
|
245
285
|
}
|
|
246
286
|
}
|
|
247
287
|
/**
|
|
@@ -300,10 +340,134 @@ export class Sitemap {
|
|
|
300
340
|
urls.push(item.loc);
|
|
301
341
|
}
|
|
302
342
|
}
|
|
303
|
-
catch {
|
|
343
|
+
catch (e) {
|
|
344
|
+
parseSitemapOptions?.logger?.warning(`Sitemap.load: Failed to load sitemap, returning empty result. (${e})`);
|
|
304
345
|
return new Sitemap([]);
|
|
305
346
|
}
|
|
306
347
|
return new Sitemap(urls);
|
|
307
348
|
}
|
|
308
349
|
}
|
|
309
|
-
|
|
350
|
+
/**
|
|
351
|
+
* Given a list of URLs, discover related sitemap files for these domains by checking the `robots.txt` file,
|
|
352
|
+
* the default `sitemap.xml` & `sitemap.txt` files and the URLs themselves.
|
|
353
|
+
* @param `urls` The list of URLs to discover sitemaps for.
|
|
354
|
+
* @param `options` Options for sitemap discovery
|
|
355
|
+
* @returns An async iterable with the discovered sitemap URLs.
|
|
356
|
+
*/
|
|
357
|
+
export async function* discoverValidSitemaps(urls, options = {}) {
|
|
358
|
+
const { proxyUrl, timeoutMillis = 60_000, signal: externalSignal, requestTimeoutMillis = 20_000, httpClient = new FetchHttpClient(), logger, } = options;
|
|
359
|
+
const controller = new AbortController();
|
|
360
|
+
const timeoutHandle = setTimeout(() => controller.abort(), timeoutMillis);
|
|
361
|
+
const onExternalAbort = () => controller.abort();
|
|
362
|
+
if (externalSignal) {
|
|
363
|
+
if (externalSignal.aborted) {
|
|
364
|
+
controller.abort();
|
|
365
|
+
}
|
|
366
|
+
else {
|
|
367
|
+
externalSignal.addEventListener('abort', onExternalAbort, { once: true });
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
const signal = controller.signal;
|
|
371
|
+
const sitemapUrls = new Set();
|
|
372
|
+
const addSitemapUrl = (url) => {
|
|
373
|
+
const sizeBefore = sitemapUrls.size;
|
|
374
|
+
sitemapUrls.add(url);
|
|
375
|
+
if (sitemapUrls.size > sizeBefore) {
|
|
376
|
+
return url;
|
|
377
|
+
}
|
|
378
|
+
return undefined;
|
|
379
|
+
};
|
|
380
|
+
const urlExists = async (url) => {
|
|
381
|
+
if (!httpClient) {
|
|
382
|
+
return false;
|
|
383
|
+
}
|
|
384
|
+
try {
|
|
385
|
+
const response = await httpClient.sendRequest(new Request(url, { method: 'HEAD' }), {
|
|
386
|
+
proxyUrl,
|
|
387
|
+
timeoutMillis: requestTimeoutMillis,
|
|
388
|
+
signal,
|
|
389
|
+
});
|
|
390
|
+
return response.status >= 200 && response.status < 400;
|
|
391
|
+
}
|
|
392
|
+
catch {
|
|
393
|
+
return false;
|
|
394
|
+
}
|
|
395
|
+
};
|
|
396
|
+
const discoverSitemapsForDomainUrls = async function* (hostname, domainUrls) {
|
|
397
|
+
if (!hostname) {
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
try {
|
|
401
|
+
const robotsFile = await RobotsTxtFile.find(domainUrls[0], {
|
|
402
|
+
proxyUrl,
|
|
403
|
+
timeoutMillis: requestTimeoutMillis,
|
|
404
|
+
signal,
|
|
405
|
+
httpClient,
|
|
406
|
+
logger,
|
|
407
|
+
});
|
|
408
|
+
// Surface all referenced sitemaps, including cross-host; scoping happens at load time.
|
|
409
|
+
for (const sitemapUrl of robotsFile.getSitemaps({ enqueueStrategy: 'all' })) {
|
|
410
|
+
if (addSitemapUrl(sitemapUrl)) {
|
|
411
|
+
yield sitemapUrl;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
catch (err) {
|
|
416
|
+
logger?.warning(`Failed to fetch robots.txt file for ${hostname}`, { error: err });
|
|
417
|
+
}
|
|
418
|
+
const sitemapUrl = domainUrls.find((url) => /sitemap(?:_index)?\.(?:xml|txt)(?:\.gz)?$/i.test(url));
|
|
419
|
+
if (sitemapUrl !== undefined) {
|
|
420
|
+
if (addSitemapUrl(sitemapUrl)) {
|
|
421
|
+
yield sitemapUrl;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
else {
|
|
425
|
+
const firstUrl = new URL(domainUrls[0]);
|
|
426
|
+
// The guessed candidates are well-known paths on the same origin, so the query string
|
|
427
|
+
// and fragment of the input URL are not part of them - carrying them over probes (and
|
|
428
|
+
// yields) URLs like `/sitemap.xml?page=2`, which servers may well answer with a 404.
|
|
429
|
+
firstUrl.search = '';
|
|
430
|
+
firstUrl.hash = '';
|
|
431
|
+
const possibleSitemapPathnames = ['/sitemap.xml', '/sitemap.txt', '/sitemap_index.xml'];
|
|
432
|
+
const candidateSitemapUrls = possibleSitemapPathnames.map((pathname) => {
|
|
433
|
+
firstUrl.pathname = pathname;
|
|
434
|
+
return firstUrl.toString();
|
|
435
|
+
});
|
|
436
|
+
const candidateResults = await Promise.allSettled(candidateSitemapUrls.map(urlExists));
|
|
437
|
+
for (const [index, result] of candidateResults.entries()) {
|
|
438
|
+
const candidateSitemapUrl = candidateSitemapUrls[index];
|
|
439
|
+
if (result.status === 'fulfilled') {
|
|
440
|
+
if (result.value && addSitemapUrl(candidateSitemapUrl)) {
|
|
441
|
+
yield candidateSitemapUrl;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
else {
|
|
445
|
+
logger?.debug(`Failed to check sitemap candidate ${candidateSitemapUrl} for ${hostname}`, {
|
|
446
|
+
error: result.reason,
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
};
|
|
452
|
+
const groupedUrls = urls.reduce((acc, url) => {
|
|
453
|
+
const hostname = new URL(url)?.hostname ?? '';
|
|
454
|
+
acc[hostname] ??= [];
|
|
455
|
+
acc[hostname].push(url);
|
|
456
|
+
return acc;
|
|
457
|
+
}, {});
|
|
458
|
+
const iterables = Object.entries(groupedUrls).map(([hostname, domainUrls]) => discoverSitemapsForDomainUrls(hostname, domainUrls));
|
|
459
|
+
const discoveredUrls = new Set();
|
|
460
|
+
try {
|
|
461
|
+
for await (const url of mergeAsyncIterables(...iterables)) {
|
|
462
|
+
if (discoveredUrls.has(url)) {
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
discoveredUrls.add(url);
|
|
466
|
+
yield url;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
finally {
|
|
470
|
+
clearTimeout(timeoutHandle);
|
|
471
|
+
externalSignal?.removeEventListener('abort', onExternalAbort);
|
|
472
|
+
}
|
|
473
|
+
}
|
package/internals/social.d.ts
CHANGED
|
@@ -225,7 +225,7 @@ export declare const TWITTER_REGEX: RegExp;
|
|
|
225
225
|
* ```
|
|
226
226
|
* import { social } from 'crawlee';
|
|
227
227
|
*
|
|
228
|
-
* const matches = text.match(social.
|
|
228
|
+
* const matches = text.match(social.TWITTER_REGEX_GLOBAL);
|
|
229
229
|
* if (matches) console.log(`${matches.length} Twitter profiles found!`);
|
|
230
230
|
* ```
|
|
231
231
|
*/
|
|
@@ -488,5 +488,4 @@ export declare const DISCORD_REGEX_GLOBAL: RegExp;
|
|
|
488
488
|
* so that the caller doesn't need to parse the HTML document again, if needed.
|
|
489
489
|
* @return An object with the social handles.
|
|
490
490
|
*/
|
|
491
|
-
export declare function parseHandlesFromHtml(html: string, data?: Record<string, unknown> | null): SocialHandles
|
|
492
|
-
//# sourceMappingURL=social.d.ts.map
|
|
491
|
+
export declare function parseHandlesFromHtml(html: string, data?: Record<string, unknown> | null): Promise<SocialHandles>;
|
package/internals/social.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import * as cheerio from 'cheerio';
|
|
2
1
|
import { htmlToText } from './cheerio.js';
|
|
3
2
|
// Regex inspired by https://zapier.com/blog/extract-links-email-phone-regex/
|
|
4
|
-
|
|
3
|
+
// The dot-atom local part and domain labels use RFC 5321 length bounds ({1,64}, {0,62})
|
|
4
|
+
// instead of unbounded quantifiers to avoid quadratic backtracking (ReDoS) on long
|
|
5
|
+
// dotted or hyphenated inputs, e.g. text scraped by parseHandlesFromHtml().
|
|
6
|
+
const EMAIL_REGEX_STRING = '(?:[a-z0-9!#$%&\'*+/=?^_`{|}~-]{1,64}(?:\\.[a-z0-9!#$%&\'*+/=?^_`{|}~-]{1,64}){0,32}|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?\\.)+[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?|\\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\\])';
|
|
5
7
|
/**
|
|
6
8
|
* Regular expression to exactly match a single email address.
|
|
7
9
|
* It has the following form: `/^...$/i`.
|
|
@@ -324,7 +326,7 @@ export const TWITTER_REGEX = new RegExp(`^${TWITTER_REGEX_STRING}$`, 'i');
|
|
|
324
326
|
* ```
|
|
325
327
|
* import { social } from 'crawlee';
|
|
326
328
|
*
|
|
327
|
-
* const matches = text.match(social.
|
|
329
|
+
* const matches = text.match(social.TWITTER_REGEX_GLOBAL);
|
|
328
330
|
* if (matches) console.log(`${matches.length} Twitter profiles found!`);
|
|
329
331
|
* ```
|
|
330
332
|
*/
|
|
@@ -587,7 +589,8 @@ export const DISCORD_REGEX_GLOBAL = new RegExp(DISCORD_REGEX_STRING, 'ig');
|
|
|
587
589
|
* so that the caller doesn't need to parse the HTML document again, if needed.
|
|
588
590
|
* @return An object with the social handles.
|
|
589
591
|
*/
|
|
590
|
-
export function parseHandlesFromHtml(html, data = null) {
|
|
592
|
+
export async function parseHandlesFromHtml(html, data = null) {
|
|
593
|
+
const cheerio = await import('cheerio/slim');
|
|
591
594
|
const result = {
|
|
592
595
|
emails: [],
|
|
593
596
|
phones: [],
|
|
@@ -606,7 +609,7 @@ export function parseHandlesFromHtml(html, data = null) {
|
|
|
606
609
|
const $ = cheerio.load(html, { xml: { decodeEntities: true } });
|
|
607
610
|
if (data)
|
|
608
611
|
data.$ = $;
|
|
609
|
-
const text = htmlToText($);
|
|
612
|
+
const text = await htmlToText($);
|
|
610
613
|
if (data)
|
|
611
614
|
data.text = text;
|
|
612
615
|
// NOTE: we need to parse each text separately, orherwise we might concatenate unrelated texts
|
|
@@ -644,4 +647,3 @@ export function parseHandlesFromHtml(html, data = null) {
|
|
|
644
647
|
}
|
|
645
648
|
return result;
|
|
646
649
|
}
|
|
647
|
-
//# sourceMappingURL=social.js.map
|
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
|
*
|
|
@@ -7,4 +76,3 @@ import type { SearchParams } from '@crawlee/types';
|
|
|
7
76
|
* @internal
|
|
8
77
|
*/
|
|
9
78
|
export declare function applySearchParams(url: URL, searchParams: SearchParams | undefined): void;
|
|
10
|
-
//# sourceMappingURL=url.d.ts.map
|