@crawlee/utils 3.17.1-beta.6 → 3.17.1-beta.61

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.mjs CHANGED
@@ -2,11 +2,13 @@ import mod from "./index.js";
2
2
 
3
3
  export default mod;
4
4
  export const CLOUDFLARE_RETRY_CSS_SELECTORS = mod.CLOUDFLARE_RETRY_CSS_SELECTORS;
5
+ export const EnqueueStrategy = mod.EnqueueStrategy;
5
6
  export const RETRY_CSS_SELECTORS = mod.RETRY_CSS_SELECTORS;
6
7
  export const ROTATE_PROXY_ERRORS = mod.ROTATE_PROXY_ERRORS;
7
8
  export const RobotsFile = mod.RobotsFile;
8
9
  export const RobotsTxtFile = mod.RobotsTxtFile;
9
10
  export const Sitemap = mod.Sitemap;
11
+ export const UNSUPPORTED_SCHEME_MESSAGE = mod.UNSUPPORTED_SCHEME_MESSAGE;
10
12
  export const URL_NO_COMMAS_REGEX = mod.URL_NO_COMMAS_REGEX;
11
13
  export const URL_WITH_COMMAS_REGEX = mod.URL_WITH_COMMAS_REGEX;
12
14
  export const applySearchParams = mod.applySearchParams;
@@ -20,6 +22,7 @@ export const entries = mod.entries;
20
22
  export const expandShadowRoots = mod.expandShadowRoots;
21
23
  export const extractUrls = mod.extractUrls;
22
24
  export const extractUrlsFromCheerio = mod.extractUrlsFromCheerio;
25
+ export const filterUrl = mod.filterUrl;
23
26
  export const getCgroupsVersion = mod.getCgroupsVersion;
24
27
  export const getCurrentCpuTicksV2 = mod.getCurrentCpuTicksV2;
25
28
  export const getMemoryInfo = mod.getMemoryInfo;
@@ -33,6 +36,7 @@ export const isDocker = mod.isDocker;
33
36
  export const isIterable = mod.isIterable;
34
37
  export const isLambda = mod.isLambda;
35
38
  export const keys = mod.keys;
39
+ export const matchesEnqueueStrategy = mod.matchesEnqueueStrategy;
36
40
  export const mergeAsyncIterables = mod.mergeAsyncIterables;
37
41
  export const parseOpenGraph = mod.parseOpenGraph;
38
42
  export const parseSitemap = mod.parseSitemap;
@@ -16,12 +16,12 @@ const promises_2 = require("node:timers/promises");
16
16
  * Default regular expression to match URLs in a string that may be plain text, JSON, CSV or other. It supports common URL characters
17
17
  * and does not support URLs containing commas or spaces. The URLs also may contain Unicode letters (not symbols).
18
18
  */
19
- exports.URL_NO_COMMAS_REGEX = /https?:\/\/(www\.)?([\p{L}0-9]|[\p{L}0-9][-\p{L}0-9@:%._+~#=]{0,254}[\p{L}0-9])\.[a-z]{2,63}(:\d{1,5})?(\/[-\p{L}0-9@:%_+.~#?&/=()]*)?/giu;
19
+ exports.URL_NO_COMMAS_REGEX = /https?:\/\/(www\.)?([\p{L}0-9]|[\p{L}0-9][-\p{L}0-9@:%._+~#=]{0,254}[\p{L}0-9])\.[a-z]{2,63}(:\d{1,5})?(\/[-\p{L}0-9@:%_+.~#?&/=()'*]*)?/giu;
20
20
  /**
21
21
  * Regular expression that, in addition to the default regular expression `URL_NO_COMMAS_REGEX`, supports matching commas in URL path and query.
22
22
  * Note, however, that this may prevent parsing URLs from comma delimited lists, or the URLs may become malformed.
23
23
  */
24
- exports.URL_WITH_COMMAS_REGEX = /https?:\/\/(www\.)?([\p{L}0-9]|[\p{L}0-9][-\p{L}0-9@:%._+~#=]{0,254}[\p{L}0-9])\.[a-z]{2,63}(:\d{1,5})?(\/[-\p{L}0-9@:%_+,.~#?&/=()]*)?/giu;
24
+ exports.URL_WITH_COMMAS_REGEX = /https?:\/\/(www\.)?([\p{L}0-9]|[\p{L}0-9][-\p{L}0-9@:%._+~#=]{0,254}[\p{L}0-9])\.[a-z]{2,63}(:\d{1,5})?(\/[-\p{L}0-9@:%_+,.~#?&/=()'*]*)?/giu;
25
25
  let isDockerPromiseCache;
26
26
  async function createIsDockerPromise() {
27
27
  const promise1 = promises_1.default
@@ -258,32 +258,32 @@ const OPEN_GRAPH_PROPERTIES = [
258
258
  outputName: 'articleInfo',
259
259
  children: [
260
260
  {
261
- name: 'music:published_time',
261
+ name: 'article:published_time',
262
262
  outputName: 'publishedTime',
263
263
  children: [],
264
264
  },
265
265
  {
266
- name: 'music:modified_time',
266
+ name: 'article:modified_time',
267
267
  outputName: 'modifiedTime',
268
268
  children: [],
269
269
  },
270
270
  {
271
- name: 'music:expiration_time',
271
+ name: 'article:expiration_time',
272
272
  outputName: 'expirationTime',
273
273
  children: [],
274
274
  },
275
275
  {
276
- name: 'music:author',
276
+ name: 'article:author',
277
277
  outputName: 'author',
278
278
  children: [],
279
279
  },
280
280
  {
281
- name: 'music:section',
281
+ name: 'article:section',
282
282
  outputName: 'section',
283
283
  children: [],
284
284
  },
285
285
  {
286
- name: 'music:tag',
286
+ name: 'article:tag',
287
287
  outputName: 'tag',
288
288
  children: [],
289
289
  },
@@ -1,4 +1,13 @@
1
1
  import { Sitemap } from './sitemap';
2
+ import { type EnqueueStrategy } from './url';
3
+ export interface RobotsTxtFileSitemapsOptions {
4
+ /**
5
+ * Keep only sitemap URLs matching this strategy relative to the robots.txt host; non-`http(s)` schemes
6
+ * are always dropped. Pass `'all'` to disable host filtering.
7
+ * @default 'same-hostname'
8
+ */
9
+ enqueueStrategy?: EnqueueStrategy | `${EnqueueStrategy}`;
10
+ }
2
11
  /**
3
12
  * Loads and queries information from a [robots.txt file](https://en.wikipedia.org/wiki/Robots.txt).
4
13
  *
@@ -18,6 +27,7 @@ import { Sitemap } from './sitemap';
18
27
  * ```
19
28
  */
20
29
  export declare class RobotsTxtFile {
30
+ private url;
21
31
  private robots;
22
32
  private proxyUrl?;
23
33
  private constructor();
@@ -51,16 +61,20 @@ export declare class RobotsTxtFile {
51
61
  */
52
62
  isAllowed(url: string, userAgent?: string): boolean;
53
63
  /**
54
- * Get URLs of sitemaps referenced in the robots file.
64
+ * Get URLs of sitemaps referenced in the robots file, filtered by `options.enqueueStrategy` relative to
65
+ * the robots.txt host (default `'same-hostname'`; pass `'all'` to disable). Non-`http(s)` schemes are
66
+ * always dropped.
55
67
  */
56
- getSitemaps(): string[];
68
+ getSitemaps(options?: RobotsTxtFileSitemapsOptions): string[];
57
69
  /**
58
- * Parse all the sitemaps referenced in the robots file.
70
+ * Parse all the sitemaps referenced in the robots file. `options` are forwarded to `getSitemaps`
71
+ * and the sitemap parser.
59
72
  */
60
- parseSitemaps(): Promise<Sitemap>;
73
+ parseSitemaps(options?: RobotsTxtFileSitemapsOptions): Promise<Sitemap>;
61
74
  /**
62
75
  * Get all URLs from all the sitemaps referenced in the robots file. A shorthand for `(await robots.parseSitemaps()).urls`.
76
+ * `options` are forwarded to `parseSitemaps`.
63
77
  */
64
- parseUrlsFromSitemaps(): Promise<string[]>;
78
+ parseUrlsFromSitemaps(options?: RobotsTxtFileSitemapsOptions): Promise<string[]>;
65
79
  }
66
80
  export { RobotsTxtFile as RobotsFile };
@@ -3,8 +3,10 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.RobotsFile = exports.RobotsTxtFile = void 0;
4
4
  const tslib_1 = require("tslib");
5
5
  const robots_parser_1 = tslib_1.__importDefault(require("robots-parser"));
6
+ const log_1 = tslib_1.__importDefault(require("@apify/log"));
6
7
  const gotScraping_1 = require("./gotScraping");
7
8
  const sitemap_1 = require("./sitemap");
9
+ const url_1 = require("./url");
8
10
  let HTTPError;
9
11
  /**
10
12
  * Loads and queries information from a [robots.txt file](https://en.wikipedia.org/wiki/Robots.txt).
@@ -25,7 +27,13 @@ let HTTPError;
25
27
  * ```
26
28
  */
27
29
  class RobotsTxtFile {
28
- constructor(robots, proxyUrl) {
30
+ constructor(url, robots, proxyUrl) {
31
+ Object.defineProperty(this, "url", {
32
+ enumerable: true,
33
+ configurable: true,
34
+ writable: true,
35
+ value: url
36
+ });
29
37
  Object.defineProperty(this, "robots", {
30
38
  enumerable: true,
31
39
  configurable: true,
@@ -60,7 +68,7 @@ class RobotsTxtFile {
60
68
  * @param [proxyUrl] a proxy to be used for fetching the robots.txt file
61
69
  */
62
70
  static from(url, content, proxyUrl) {
63
- return new RobotsTxtFile((0, robots_parser_1.default)(url, content), proxyUrl);
71
+ return new RobotsTxtFile(url, (0, robots_parser_1.default)(url, content), proxyUrl);
64
72
  }
65
73
  static async load(url, proxyUrl, options) {
66
74
  if (!HTTPError) {
@@ -75,11 +83,11 @@ class RobotsTxtFile {
75
83
  signal: options?.signal,
76
84
  ...(options?.timeoutMillis ? { timeout: { request: options.timeoutMillis } } : {}),
77
85
  });
78
- return new RobotsTxtFile((0, robots_parser_1.default)(url.toString(), response.body), proxyUrl);
86
+ return new RobotsTxtFile(url, (0, robots_parser_1.default)(url.toString(), response.body), proxyUrl);
79
87
  }
80
88
  catch (e) {
81
89
  if (e instanceof HTTPError && e.response.statusCode === 404) {
82
- return new RobotsTxtFile({
90
+ return new RobotsTxtFile(url, {
83
91
  isAllowed() {
84
92
  return true;
85
93
  },
@@ -100,22 +108,37 @@ class RobotsTxtFile {
100
108
  return this.robots.isAllowed(url, userAgent) ?? true; // `undefined` means that there is no explicit rule for the requested URL - assume it's allowed
101
109
  }
102
110
  /**
103
- * Get URLs of sitemaps referenced in the robots file.
111
+ * Get URLs of sitemaps referenced in the robots file, filtered by `options.enqueueStrategy` relative to
112
+ * the robots.txt host (default `'same-hostname'`; pass `'all'` to disable). Non-`http(s)` schemes are
113
+ * always dropped.
104
114
  */
105
- getSitemaps() {
106
- return this.robots.getSitemaps();
115
+ getSitemaps(options = {}) {
116
+ const { enqueueStrategy = 'same-hostname' } = options;
117
+ const sitemaps = [];
118
+ for (const sitemapUrl of this.robots.getSitemaps()) {
119
+ // `filterUrl` tolerates an unparseable origin (returns not-allowed) rather than throwing.
120
+ const { allowed, reason } = (0, url_1.filterUrl)(sitemapUrl, this.url, enqueueStrategy);
121
+ if (!allowed) {
122
+ log_1.default.warning(`Skipping sitemap ${sitemapUrl} listed in robots.txt at ${this.url}: ${reason}.`);
123
+ continue;
124
+ }
125
+ sitemaps.push(sitemapUrl);
126
+ }
127
+ return sitemaps;
107
128
  }
108
129
  /**
109
- * Parse all the sitemaps referenced in the robots file.
130
+ * Parse all the sitemaps referenced in the robots file. `options` are forwarded to `getSitemaps`
131
+ * and the sitemap parser.
110
132
  */
111
- async parseSitemaps() {
112
- return sitemap_1.Sitemap.load(this.robots.getSitemaps(), this.proxyUrl);
133
+ async parseSitemaps(options = {}) {
134
+ return sitemap_1.Sitemap.load(this.getSitemaps(options), this.proxyUrl, options);
113
135
  }
114
136
  /**
115
137
  * Get all URLs from all the sitemaps referenced in the robots file. A shorthand for `(await robots.parseSitemaps()).urls`.
138
+ * `options` are forwarded to `parseSitemaps`.
116
139
  */
117
- async parseUrlsFromSitemaps() {
118
- return (await this.parseSitemaps()).urls;
140
+ async parseUrlsFromSitemaps(options = {}) {
141
+ return (await this.parseSitemaps(options)).urls;
119
142
  }
120
143
  }
121
144
  exports.RobotsTxtFile = RobotsTxtFile;
@@ -1,5 +1,6 @@
1
1
  // @ts-ignore optional peer dependency or compatibility with es2022
2
2
  import type { Delays } from 'got-scraping';
3
+ import { type EnqueueStrategy } from './url';
3
4
  interface SitemapUrlData {
4
5
  loc: string;
5
6
  lastmod?: Date;
@@ -51,6 +52,13 @@ export interface ParseSitemapOptions {
51
52
  * If not provided, all nested sitemaps are followed.
52
53
  */
53
54
  nestedSitemapFilter?: (sitemapUrl: string) => boolean;
55
+ /**
56
+ * Keep only sitemap-derived URLs (nested `<sitemap>` and `<url>` entries) matching this strategy
57
+ * relative to the parent sitemap URL; non-`http(s)` schemes are always dropped. Skipped for raw string
58
+ * sources (no parent URL). Pass `'all'` to disable host filtering.
59
+ * @default 'same-hostname'
60
+ */
61
+ enqueueStrategy?: EnqueueStrategy | `${EnqueueStrategy}`;
54
62
  }
55
63
  export declare function parseSitemap<T extends ParseSitemapOptions>(initialSources: SitemapSource[], proxyUrl?: string, options?: T): AsyncIterable<T['emitNestedSitemaps'] extends true ? SitemapUrl | NestedSitemap : SitemapUrl>;
56
64
  /**
@@ -13,6 +13,7 @@ const whatwg_mimetype_1 = tslib_1.__importDefault(require("whatwg-mimetype"));
13
13
  const log_1 = tslib_1.__importDefault(require("@apify/log"));
14
14
  const iterables_1 = require("./iterables");
15
15
  const robots_1 = require("./robots");
16
+ const url_1 = require("./url");
16
17
  class SitemapTxtParser extends node_stream_1.Transform {
17
18
  constructor() {
18
19
  super({
@@ -134,8 +135,10 @@ class SitemapXmlParser extends node_stream_1.Transform {
134
135
  if (name === 'loc' || name === 'lastmod' || name === 'priority' || name === 'changefreq') {
135
136
  this.currentTag = undefined;
136
137
  }
137
- if (name === 'url' && this.url.loc !== undefined) {
138
- this.push({ type: 'url', ...this.url, loc: this.url.loc });
138
+ if (name === 'url') {
139
+ if (this.url.loc !== undefined) {
140
+ this.push({ type: 'url', ...this.url, loc: this.url.loc });
141
+ }
139
142
  this.url = {};
140
143
  }
141
144
  }
@@ -151,7 +154,10 @@ class SitemapXmlParser extends node_stream_1.Transform {
151
154
  }
152
155
  text = text.trim();
153
156
  if (this.currentTag === 'lastmod') {
154
- this.url.lastmod = new Date(text);
157
+ const lastmod = new Date(text);
158
+ if (!Number.isNaN(lastmod.getTime())) {
159
+ this.url.lastmod = lastmod;
160
+ }
155
161
  }
156
162
  if (this.currentTag === 'priority') {
157
163
  this.url.priority = Number(text);
@@ -166,7 +172,7 @@ class SitemapXmlParser extends node_stream_1.Transform {
166
172
  async function* parseSitemap(initialSources, proxyUrl, options) {
167
173
  const { gotScraping } = await import('got-scraping');
168
174
  const { fileTypeStream } = await import('file-type');
169
- const { emitNestedSitemaps = false, maxDepth = Infinity, sitemapRetries = 3, networkTimeouts, reportNetworkErrors = true, nestedSitemapFilter, } = options ?? {};
175
+ const { emitNestedSitemaps = false, maxDepth = Infinity, sitemapRetries = 3, networkTimeouts, reportNetworkErrors = true, nestedSitemapFilter, enqueueStrategy = 'same-hostname', } = options ?? {};
170
176
  const sources = [...initialSources];
171
177
  const visitedSitemapUrls = new Set();
172
178
  const createParser = (contentType = '', url) => {
@@ -192,8 +198,10 @@ async function* parseSitemap(initialSources, proxyUrl, options) {
192
198
  continue;
193
199
  }
194
200
  let items = null;
201
+ // Parent URL, parsed once and reused as the origin for the strategy checks below.
202
+ let sitemapUrl;
195
203
  if (source.type === 'url') {
196
- const sitemapUrl = new URL(source.url);
204
+ sitemapUrl = new URL(source.url);
197
205
  visitedSitemapUrls.add(sitemapUrl.toString());
198
206
  let retriesLeft = sitemapRetries + 1;
199
207
  while (retriesLeft-- > 0) {
@@ -264,18 +272,39 @@ async function* parseSitemap(initialSources, proxyUrl, options) {
264
272
  if (items === null) {
265
273
  continue;
266
274
  }
275
+ // URL entries dropped by the enqueue strategy filter, reported in one warning per sitemap after
276
+ // the loop (per-entry warnings could flood the log; individual drops are logged at debug level).
277
+ let droppedUrlEntries = 0;
267
278
  for await (const item of items) {
268
279
  if (item.type === 'sitemapUrl' && !visitedSitemapUrls.has(item.url)) {
269
280
  if (nestedSitemapFilter && !nestedSitemapFilter(item.url)) {
270
281
  log_1.default.debug(`Skipping sitemap ${item.url} due to nestedSitemapFilter.`);
271
282
  continue;
272
283
  }
284
+ // Keep only nested sitemaps matching the strategy (and using http(s)) relative to the
285
+ // parent. Raw string sources have no parent URL, so the check is skipped.
286
+ if (source.type === 'url') {
287
+ const { allowed, reason } = (0, url_1.filterUrl)(item.url, sitemapUrl, enqueueStrategy);
288
+ if (!allowed) {
289
+ log_1.default.warning(`Skipping nested sitemap ${item.url} (parent ${source.url}): ${reason}.`);
290
+ continue;
291
+ }
292
+ }
273
293
  sources.push({ type: 'url', url: item.url, depth: (source.depth ?? 0) + 1 });
274
294
  if (emitNestedSitemaps) {
275
295
  yield { loc: item.url, originSitemapUrl: null };
276
296
  }
277
297
  }
278
298
  if (item.type === 'url') {
299
+ // Keep only URL entries that match the enqueue strategy relative to the parent (see above).
300
+ if (source.type === 'url') {
301
+ const { allowed, reason } = (0, url_1.filterUrl)(item.loc, sitemapUrl, enqueueStrategy);
302
+ if (!allowed) {
303
+ droppedUrlEntries++;
304
+ log_1.default.debug(`Skipping sitemap URL ${item.loc} (parent ${source.url}): ${reason}.`);
305
+ continue;
306
+ }
307
+ }
279
308
  yield {
280
309
  ...item,
281
310
  originSitemapUrl: source.type === 'url'
@@ -284,6 +313,9 @@ async function* parseSitemap(initialSources, proxyUrl, options) {
284
313
  };
285
314
  }
286
315
  }
316
+ if (droppedUrlEntries > 0 && source.type === 'url') {
317
+ log_1.default.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.`);
318
+ }
287
319
  }
288
320
  }
289
321
  /**
@@ -406,7 +438,8 @@ async function* discoverValidSitemaps(urls, options = {}) {
406
438
  timeoutMillis: requestTimeoutMillis,
407
439
  signal,
408
440
  });
409
- for (const sitemapUrl of robotsFile.getSitemaps()) {
441
+ // Surface all referenced sitemaps, including cross-host; scoping happens at load time.
442
+ for (const sitemapUrl of robotsFile.getSitemaps({ enqueueStrategy: 'all' })) {
410
443
  if (addSitemapUrl(sitemapUrl)) {
411
444
  yield sitemapUrl;
412
445
  }
@@ -10,7 +10,10 @@ const tslib_1 = require("tslib");
10
10
  const cheerio = tslib_1.__importStar(require("cheerio"));
11
11
  const cheerio_1 = require("./cheerio");
12
12
  // Regex inspired by https://zapier.com/blog/extract-links-email-phone-regex/
13
- const EMAIL_REGEX_STRING = '(?:[a-z0-9!#$%&\'*+/=?^_`{|}~-]+(?:\\.[a-z0-9!#$%&\'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\\.)+[a-z0-9](?:[a-z0-9-]*[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])+)\\])';
13
+ // The dot-atom local part and domain labels use RFC 5321 length bounds ({1,64}, {0,62})
14
+ // instead of unbounded quantifiers to avoid quadratic backtracking (ReDoS) on long
15
+ // dotted or hyphenated inputs, e.g. text scraped by parseHandlesFromHtml().
16
+ 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])+)\\])';
14
17
  /**
15
18
  * Regular expression to exactly match a single email address.
16
19
  * It has the following form: `/^...$/i`.
@@ -51,6 +51,7 @@ function getCurrentCpuTicks() {
51
51
  }, { idle: 0, total: 0 });
52
52
  const idleTicksDelta = ticks.idle - previousTicks.idle;
53
53
  const totalTicksDelta = ticks.total - previousTicks.total;
54
+ Object.assign(previousTicks, ticks);
54
55
  return totalTicksDelta ? 1 - idleTicksDelta / totalTicksDelta : 0;
55
56
  }
56
57
  /**
@@ -1,4 +1,73 @@
1
1
  export type SearchParams = string | URLSearchParams | Record<string, string | number | boolean | null | undefined>;
2
+ /**
3
+ * The different enqueueing strategies available.
4
+ *
5
+ * Depending on the strategy you select, we will only check certain parts of the URLs found. Here is a diagram of each URL part and their name:
6
+ *
7
+ * ```md
8
+ * Protocol Domain
9
+ * ┌────┐ ┌─────────┐
10
+ * https://example.crawlee.dev/...
11
+ * │ └─────────────────┤
12
+ * │ Hostname │
13
+ * │ │
14
+ * └─────────────────────────┘
15
+ * Origin
16
+ *```
17
+ *
18
+ * - The `Protocol` is usually `http` or `https`
19
+ * - The `Domain` represents the path without any possible subdomains to a website. For example, `crawlee.dev` is the domain of `https://example.crawlee.dev/`
20
+ * - The `Hostname` is the full path to a website, including any subdomains. For example, `example.crawlee.dev` is the hostname of `https://example.crawlee.dev/`
21
+ * - The `Origin` is the combination of the `Protocol` and `Hostname`. For example, `https://example.crawlee.dev` is the origin of `https://example.crawlee.dev/`
22
+ */
23
+ export declare enum EnqueueStrategy {
24
+ /**
25
+ * Matches any URLs found
26
+ */
27
+ All = "all",
28
+ /**
29
+ * Matches any URLs that have the same hostname.
30
+ * For example, `https://wow.example.com/hello` will be matched for a base url of `https://wow.example.com/`, but
31
+ * `https://example.com/hello` will not be matched.
32
+ *
33
+ * > This strategy will match both `http` and `https` protocols regardless of the base URL protocol.
34
+ */
35
+ SameHostname = "same-hostname",
36
+ /**
37
+ * Matches any URLs that have the same domain as the base URL.
38
+ * For example, `https://wow.an.example.com` and `https://example.com` will both be matched for a base url of
39
+ * `https://example.com`.
40
+ *
41
+ * > This strategy will match both `http` and `https` protocols regardless of the base URL protocol.
42
+ */
43
+ SameDomain = "same-domain",
44
+ /**
45
+ * Matches any URLs that have the same hostname and protocol.
46
+ * For example, `https://wow.example.com/hello` will be matched for a base url of `https://wow.example.com/`, but
47
+ * `http://wow.example.com/hello` will not be matched.
48
+ *
49
+ * > This strategy will ensure the protocol of the base URL is the same as the protocol of the URL to be enqueued.
50
+ */
51
+ SameOrigin = "same-origin"
52
+ }
53
+ /** Reusable suffix for log messages explaining why a non-`http(s)` URL was rejected. */
54
+ export declare const UNSUPPORTED_SCHEME_MESSAGE = "unsupported URL scheme (only http and https are allowed)";
55
+ /**
56
+ * Check whether `target` matches `origin` under the given enqueue `strategy`. The URL scheme is not
57
+ * considered here (use {@link filterUrl} for the combined scheme + strategy check).
58
+ *
59
+ * The `enqueueLinks` implementation in `@crawlee/core` matches the same strategies via glob patterns
60
+ * (see `packages/core/src/enqueue_links/enqueue_links.ts`) — keep the two in sync when changing either.
61
+ */
62
+ export declare function matchesEnqueueStrategy(strategy: EnqueueStrategy | `${EnqueueStrategy}`, target: URL, origin: URL): boolean;
63
+ /**
64
+ * Check whether `target` may be enqueued under `strategy` relative to `origin`: it must use an `http(s)`
65
+ * scheme and match the strategy. On rejection, `reason` is a human-readable message for log output.
66
+ */
67
+ export declare function filterUrl(target: string | URL, origin: string | URL, strategy: EnqueueStrategy | `${EnqueueStrategy}`): {
68
+ allowed: boolean;
69
+ reason?: string;
70
+ };
2
71
  /**
3
72
  * Appends search (query string) parameters to a URL, replacing the original value (if any).
4
73
  *
package/internals/url.js CHANGED
@@ -1,6 +1,128 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.UNSUPPORTED_SCHEME_MESSAGE = exports.EnqueueStrategy = void 0;
4
+ exports.matchesEnqueueStrategy = matchesEnqueueStrategy;
5
+ exports.filterUrl = filterUrl;
3
6
  exports.applySearchParams = applySearchParams;
7
+ const tldts_1 = require("tldts");
8
+ /**
9
+ * The different enqueueing strategies available.
10
+ *
11
+ * 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:
12
+ *
13
+ * ```md
14
+ * Protocol Domain
15
+ * ┌────┐ ┌─────────┐
16
+ * https://example.crawlee.dev/...
17
+ * │ └─────────────────┤
18
+ * │ Hostname │
19
+ * │ │
20
+ * └─────────────────────────┘
21
+ * Origin
22
+ *```
23
+ *
24
+ * - The `Protocol` is usually `http` or `https`
25
+ * - The `Domain` represents the path without any possible subdomains to a website. For example, `crawlee.dev` is the domain of `https://example.crawlee.dev/`
26
+ * - 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/`
27
+ * - The `Origin` is the combination of the `Protocol` and `Hostname`. For example, `https://example.crawlee.dev` is the origin of `https://example.crawlee.dev/`
28
+ */
29
+ var EnqueueStrategy;
30
+ (function (EnqueueStrategy) {
31
+ /**
32
+ * Matches any URLs found
33
+ */
34
+ EnqueueStrategy["All"] = "all";
35
+ /**
36
+ * Matches any URLs that have the same hostname.
37
+ * For example, `https://wow.example.com/hello` will be matched for a base url of `https://wow.example.com/`, but
38
+ * `https://example.com/hello` will not be matched.
39
+ *
40
+ * > This strategy will match both `http` and `https` protocols regardless of the base URL protocol.
41
+ */
42
+ EnqueueStrategy["SameHostname"] = "same-hostname";
43
+ /**
44
+ * Matches any URLs that have the same domain as the base URL.
45
+ * For example, `https://wow.an.example.com` and `https://example.com` will both be matched for a base url of
46
+ * `https://example.com`.
47
+ *
48
+ * > This strategy will match both `http` and `https` protocols regardless of the base URL protocol.
49
+ */
50
+ EnqueueStrategy["SameDomain"] = "same-domain";
51
+ /**
52
+ * Matches any URLs that have the same hostname and protocol.
53
+ * For example, `https://wow.example.com/hello` will be matched for a base url of `https://wow.example.com/`, but
54
+ * `http://wow.example.com/hello` will not be matched.
55
+ *
56
+ * > This strategy will ensure the protocol of the base URL is the same as the protocol of the URL to be enqueued.
57
+ */
58
+ EnqueueStrategy["SameOrigin"] = "same-origin";
59
+ })(EnqueueStrategy || (exports.EnqueueStrategy = EnqueueStrategy = {}));
60
+ /** Reusable suffix for log messages explaining why a non-`http(s)` URL was rejected. */
61
+ exports.UNSUPPORTED_SCHEME_MESSAGE = 'unsupported URL scheme (only http and https are allowed)';
62
+ const ALLOWED_SCHEMES = new Set(['http:', 'https:']);
63
+ function toUrl(value) {
64
+ if (value instanceof URL) {
65
+ return value;
66
+ }
67
+ try {
68
+ return new URL(value);
69
+ }
70
+ catch {
71
+ return null;
72
+ }
73
+ }
74
+ /** Strip a trailing dot so `example.com.` equals `example.com`. */
75
+ function normalizeHostname(hostname) {
76
+ return hostname.endsWith('.') ? hostname.slice(0, -1) : hostname;
77
+ }
78
+ /**
79
+ * Check whether `target` matches `origin` under the given enqueue `strategy`. The URL scheme is not
80
+ * considered here (use {@link filterUrl} for the combined scheme + strategy check).
81
+ *
82
+ * The `enqueueLinks` implementation in `@crawlee/core` matches the same strategies via glob patterns
83
+ * (see `packages/core/src/enqueue_links/enqueue_links.ts`) — keep the two in sync when changing either.
84
+ */
85
+ function matchesEnqueueStrategy(strategy, target, origin) {
86
+ switch (strategy) {
87
+ case 'all':
88
+ return true;
89
+ case 'same-hostname':
90
+ return normalizeHostname(target.hostname) === normalizeHostname(origin.hostname);
91
+ case 'same-domain': {
92
+ const originDomain = (0, tldts_1.getDomain)(origin.hostname, { mixedInputs: false });
93
+ if (originDomain) {
94
+ return originDomain === (0, tldts_1.getDomain)(target.hostname, { mixedInputs: false });
95
+ }
96
+ // No registrable domain (e.g. an IP address), fall back to comparing origins.
97
+ return target.origin === origin.origin;
98
+ }
99
+ case 'same-origin':
100
+ // Compare scheme/host/port directly so a trailing-dot host is normalized.
101
+ return (target.protocol === origin.protocol &&
102
+ normalizeHostname(target.hostname) === normalizeHostname(origin.hostname) &&
103
+ target.port === origin.port);
104
+ default:
105
+ throw new Error(`Unknown enqueue strategy '${strategy}'.`);
106
+ }
107
+ }
108
+ /**
109
+ * Check whether `target` may be enqueued under `strategy` relative to `origin`: it must use an `http(s)`
110
+ * scheme and match the strategy. On rejection, `reason` is a human-readable message for log output.
111
+ */
112
+ function filterUrl(target, origin, strategy) {
113
+ const targetUrl = toUrl(target);
114
+ if (targetUrl === null || !ALLOWED_SCHEMES.has(targetUrl.protocol)) {
115
+ return { allowed: false, reason: exports.UNSUPPORTED_SCHEME_MESSAGE };
116
+ }
117
+ const originUrl = toUrl(origin);
118
+ if (originUrl === null) {
119
+ return { allowed: false, reason: 'invalid origin URL' };
120
+ }
121
+ if (!matchesEnqueueStrategy(strategy, targetUrl, originUrl)) {
122
+ return { allowed: false, reason: `does not match enqueue strategy '${strategy}'` };
123
+ }
124
+ return { allowed: true };
125
+ }
4
126
  /**
5
127
  * Appends search (query string) parameters to a URL, replacing the original value (if any).
6
128
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crawlee/utils",
3
- "version": "3.17.1-beta.6",
3
+ "version": "3.17.1-beta.61",
4
4
  "description": "A set of shared utilities that can be used by crawlers",
5
5
  "engines": {
6
6
  "node": ">=16.0.0"
@@ -49,7 +49,7 @@
49
49
  "dependencies": {
50
50
  "@apify/log": "^2.4.0",
51
51
  "@apify/ps-tree": "^1.2.0",
52
- "@crawlee/types": "3.17.1-beta.6",
52
+ "@crawlee/types": "3.17.1-beta.61",
53
53
  "@types/sax": "^1.2.7",
54
54
  "cheerio": "1.0.0-rc.12",
55
55
  "file-type": "^21.3.1",
@@ -57,6 +57,7 @@
57
57
  "ow": "^0.28.1",
58
58
  "robots-parser": "^3.0.1",
59
59
  "sax": "^1.4.1",
60
+ "tldts": "^7.0.0",
60
61
  "tslib": "^2.4.0",
61
62
  "whatwg-mimetype": "^4.0.0"
62
63
  },
@@ -70,5 +71,5 @@
70
71
  }
71
72
  }
72
73
  },
73
- "gitHead": "eda1d0870cfa01cfd7d2a4a9d0e4e6b99d18dea1"
74
+ "gitHead": "d13113f54e00408626b4f05032140b8667d85442"
74
75
  }