Skip to content

Commit 9dc3160

Browse files
authored
fix: filter sitemap-derived URLs by enqueue strategy (#3797)
`SitemapRequestList`, `Sitemap.load` / `parseSitemap`, and `RobotsTxtFile.getSitemaps()` now apply an enqueue strategy to the URLs they extract. They keep only entries that match the strategy (default `same-hostname`) relative to their parent sitemap, and always drop non-`http(s)` schemes. This brings sitemap loading in line with `enqueueLinks`, which already scopes discovered links to the same hostname by default. The selected strategy is also stamped onto the emitted `Request` objects, so it keeps being enforced after navigation (e.g. across redirects). This changes the default behavior: cross-host sitemap entries are no longer enqueued unless you opt in with `enqueueStrategy: 'all'` (or `'same-domain'` / `'same-origin'`).
1 parent 3e45246 commit 9dc3160

11 files changed

Lines changed: 615 additions & 81 deletions

File tree

packages/core/src/enqueue_links/enqueue_links.ts

Lines changed: 3 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { BatchAddRequestsResult, Dictionary } from '@crawlee/types';
2-
import { type RobotsTxtFile } from '@crawlee/utils';
2+
import { EnqueueStrategy, type RobotsTxtFile } from '@crawlee/utils';
33
import ow from 'ow';
44
import { getDomain } from 'tldts';
55
import type { SetRequired } from 'type-fest';
@@ -31,6 +31,8 @@ import {
3131
filterRequestsByPatterns,
3232
} from './shared';
3333

34+
export { EnqueueStrategy };
35+
3436
export interface EnqueueLinksOptions extends RequestQueueOperationOptions {
3537
/** Limit the amount of actually enqueued URLs to this number. Useful for testing across the entire crawling scope. */
3638
limit?: number;
@@ -199,61 +201,6 @@ export interface EnqueueLinksOptions extends RequestQueueOperationOptions {
199201
onSkippedRequest?: SkippedRequestCallback;
200202
}
201203

202-
/**
203-
* The different enqueueing strategies available.
204-
*
205-
* 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:
206-
*
207-
* ```md
208-
* Protocol Domain
209-
* ┌────┐ ┌─────────┐
210-
* https://example.crawlee.dev/...
211-
* │ └─────────────────┤
212-
* │ Hostname │
213-
* │ │
214-
* └─────────────────────────┘
215-
* Origin
216-
*```
217-
*
218-
* - The `Protocol` is usually `http` or `https`
219-
* - The `Domain` represents the path without any possible subdomains to a website. For example, `crawlee.dev` is the domain of `https://example.crawlee.dev/`
220-
* - 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/`
221-
* - The `Origin` is the combination of the `Protocol` and `Hostname`. For example, `https://example.crawlee.dev` is the origin of `https://example.crawlee.dev/`
222-
*/
223-
export enum EnqueueStrategy {
224-
/**
225-
* Matches any URLs found
226-
*/
227-
All = 'all',
228-
229-
/**
230-
* Matches any URLs that have the same hostname.
231-
* For example, `https://wow.example.com/hello` will be matched for a base url of `https://wow.example.com/`, but
232-
* `https://example.com/hello` will not be matched.
233-
*
234-
* > This strategy will match both `http` and `https` protocols regardless of the base URL protocol.
235-
*/
236-
SameHostname = 'same-hostname',
237-
238-
/**
239-
* Matches any URLs that have the same domain as the base URL.
240-
* For example, `https://wow.an.example.com` and `https://example.com` will both be matched for a base url of
241-
* `https://example.com`.
242-
*
243-
* > This strategy will match both `http` and `https` protocols regardless of the base URL protocol.
244-
*/
245-
SameDomain = 'same-domain',
246-
247-
/**
248-
* Matches any URLs that have the same hostname and protocol.
249-
* For example, `https://wow.example.com/hello` will be matched for a base url of `https://wow.example.com/`, but
250-
* `http://wow.example.com/hello` will not be matched.
251-
*
252-
* > This strategy will ensure the protocol of the base URL is the same as the protocol of the URL to be enqueued.
253-
*/
254-
SameOrigin = 'same-origin',
255-
}
256-
257204
/**
258205
* This function enqueues the urls provided to the {@apilink RequestQueue} provided. If you want to automatically find and enqueue links,
259206
* you should use the context-aware `enqueueLinks` function provided on the crawler contexts.

packages/core/src/storages/sitemap_request_list.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import defaultLog from '@apify/log';
99

1010
import { Configuration } from '../configuration';
1111
import type { GlobInput, RegExpInput, UrlPatternObject } from '../enqueue_links';
12-
import { constructGlobObjectsFromGlobs, constructRegExpObjectsFromRegExps } from '../enqueue_links';
12+
import { constructGlobObjectsFromGlobs, constructRegExpObjectsFromRegExps, EnqueueStrategy } from '../enqueue_links';
1313
import { type EventManager, EventType } from '../events/event_manager';
1414
import { Request } from '../request';
1515
import { KeyValueStore } from './key_value_store';
@@ -95,6 +95,13 @@ export interface SitemapRequestListOptions extends UrlConstraints {
9595
* @default 200
9696
*/
9797
maxBufferSize?: number;
98+
/**
99+
* Keep only sitemap-derived URLs matching this strategy relative to the parent sitemap URL; non-`http(s)`
100+
* schemes are always dropped. The filtering stays enforced after navigation (e.g. across redirects).
101+
* Pass `'all'` to disable host filtering.
102+
* @default EnqueueStrategy.SameHostname
103+
*/
104+
enqueueStrategy?: EnqueueStrategy | `${EnqueueStrategy}`;
98105
/**
99106
* Advanced options for the underlying `parseSitemap` call.
100107
*/
@@ -192,6 +199,11 @@ export class SitemapRequestList implements IRequestList {
192199
*/
193200
private proxyUrl: string | undefined;
194201

202+
/**
203+
* Enqueue strategy applied to sitemap-derived URLs and stamped onto the emitted `Request` objects.
204+
*/
205+
private enqueueStrategy: EnqueueStrategy | `${EnqueueStrategy}`;
206+
195207
/**
196208
* Logger instance.
197209
*/
@@ -216,6 +228,7 @@ export class SitemapRequestList implements IRequestList {
216228
signal: ow.optional.any(),
217229
timeoutMillis: ow.optional.number,
218230
maxBufferSize: ow.optional.number,
231+
enqueueStrategy: ow.optional.string.oneOf(Object.values(EnqueueStrategy)),
219232
parseSitemapOptions: ow.optional.object,
220233
globs: ow.optional.array.ofType(ow.any(ow.string, ow.object.hasKeys('glob'))),
221234
exclude: ow.optional.array.ofType(
@@ -251,6 +264,7 @@ export class SitemapRequestList implements IRequestList {
251264
this.persistenceOptions = { enable: true, ...options.persistenceOptions };
252265

253266
this.proxyUrl = options.proxyUrl;
267+
this.enqueueStrategy = options.enqueueStrategy ?? EnqueueStrategy.SameHostname;
254268

255269
this.urlQueueStream = this.createNewStream(options.maxBufferSize ?? 200);
256270

@@ -382,6 +396,7 @@ export class SitemapRequestList implements IRequestList {
382396
...parseSitemapOptions,
383397
maxDepth: 0,
384398
emitNestedSitemaps: true,
399+
enqueueStrategy: this.enqueueStrategy,
385400
})) {
386401
if (!item.originSitemapUrl) {
387402
// This is a nested sitemap
@@ -561,7 +576,7 @@ export class SitemapRequestList implements IRequestList {
561576
if (!nextUrl) {
562577
return null;
563578
}
564-
this.requestData.set(nextUrl, new Request({ url: nextUrl }));
579+
this.requestData.set(nextUrl, new Request({ url: nextUrl, enqueueStrategy: this.enqueueStrategy }));
565580
}
566581

567582
this.inProgress.add(nextUrl);

packages/utils/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
"ow": "^0.28.1",
5858
"robots-parser": "^3.0.1",
5959
"sax": "^1.4.1",
60+
"tldts": "^7.0.0",
6061
"tslib": "^2.4.0",
6162
"whatwg-mimetype": "^4.0.0"
6263
},

packages/utils/src/internals/robots.ts

Lines changed: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,23 @@ import type { HTTPError as HTTPErrorClass } from 'got-scraping';
33
import type { Robot } from 'robots-parser';
44
import robotsParser from 'robots-parser';
55

6+
import log from '@apify/log';
7+
68
import { gotScraping } from './gotScraping';
79
import { Sitemap } from './sitemap';
10+
import { type EnqueueStrategy, filterUrl } from './url';
811

912
let HTTPError: typeof HTTPErrorClass;
1013

14+
export interface RobotsTxtFileSitemapsOptions {
15+
/**
16+
* Keep only sitemap URLs matching this strategy relative to the robots.txt host; non-`http(s)` schemes
17+
* are always dropped. Pass `'all'` to disable host filtering.
18+
* @default 'same-hostname'
19+
*/
20+
enqueueStrategy?: EnqueueStrategy | `${EnqueueStrategy}`;
21+
}
22+
1123
/**
1224
* Loads and queries information from a [robots.txt file](https://en.wikipedia.org/wiki/Robots.txt).
1325
*
@@ -28,6 +40,7 @@ let HTTPError: typeof HTTPErrorClass;
2840
*/
2941
export class RobotsTxtFile {
3042
private constructor(
43+
private url: string,
3144
private robots: Pick<Robot, 'isAllowed' | 'getSitemaps'>,
3245
private proxyUrl?: string,
3346
) {}
@@ -59,7 +72,7 @@ export class RobotsTxtFile {
5972
* @param [proxyUrl] a proxy to be used for fetching the robots.txt file
6073
*/
6174
static from(url: string, content: string, proxyUrl?: string): RobotsTxtFile {
62-
return new RobotsTxtFile(robotsParser(url, content), proxyUrl);
75+
return new RobotsTxtFile(url, robotsParser(url, content), proxyUrl);
6376
}
6477

6578
protected static async load(
@@ -81,10 +94,11 @@ export class RobotsTxtFile {
8194
...(options?.timeoutMillis ? { timeout: { request: options.timeoutMillis } } : {}),
8295
});
8396

84-
return new RobotsTxtFile(robotsParser(url.toString(), response.body), proxyUrl);
97+
return new RobotsTxtFile(url, robotsParser(url.toString(), response.body), proxyUrl);
8598
} catch (e) {
8699
if (e instanceof HTTPError && e.response.statusCode === 404) {
87100
return new RobotsTxtFile(
101+
url,
88102
{
89103
isAllowed() {
90104
return true;
@@ -110,24 +124,41 @@ export class RobotsTxtFile {
110124
}
111125

112126
/**
113-
* Get URLs of sitemaps referenced in the robots file.
127+
* Get URLs of sitemaps referenced in the robots file, filtered by `options.enqueueStrategy` relative to
128+
* the robots.txt host (default `'same-hostname'`; pass `'all'` to disable). Non-`http(s)` schemes are
129+
* always dropped.
114130
*/
115-
getSitemaps(): string[] {
116-
return this.robots.getSitemaps();
131+
getSitemaps(options: RobotsTxtFileSitemapsOptions = {}): string[] {
132+
const { enqueueStrategy = 'same-hostname' } = options;
133+
const sitemaps: string[] = [];
134+
135+
for (const sitemapUrl of this.robots.getSitemaps()) {
136+
// `filterUrl` tolerates an unparseable origin (returns not-allowed) rather than throwing.
137+
const { allowed, reason } = filterUrl(sitemapUrl, this.url, enqueueStrategy);
138+
if (!allowed) {
139+
log.warning(`Skipping sitemap ${sitemapUrl} listed in robots.txt at ${this.url}: ${reason}.`);
140+
continue;
141+
}
142+
sitemaps.push(sitemapUrl);
143+
}
144+
145+
return sitemaps;
117146
}
118147

119148
/**
120-
* Parse all the sitemaps referenced in the robots file.
149+
* Parse all the sitemaps referenced in the robots file. `options` are forwarded to `getSitemaps`
150+
* and the sitemap parser.
121151
*/
122-
async parseSitemaps(): Promise<Sitemap> {
123-
return Sitemap.load(this.robots.getSitemaps(), this.proxyUrl);
152+
async parseSitemaps(options: RobotsTxtFileSitemapsOptions = {}): Promise<Sitemap> {
153+
return Sitemap.load(this.getSitemaps(options), this.proxyUrl, options);
124154
}
125155

126156
/**
127157
* Get all URLs from all the sitemaps referenced in the robots file. A shorthand for `(await robots.parseSitemaps()).urls`.
158+
* `options` are forwarded to `parseSitemaps`.
128159
*/
129-
async parseUrlsFromSitemaps(): Promise<string[]> {
130-
return (await this.parseSitemaps()).urls;
160+
async parseUrlsFromSitemaps(options: RobotsTxtFileSitemapsOptions = {}): Promise<string[]> {
161+
return (await this.parseSitemaps(options)).urls;
131162
}
132163
}
133164

packages/utils/src/internals/sitemap.ts

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import log from '@apify/log';
1313

1414
import { mergeAsyncIterables } from './iterables';
1515
import { RobotsFile } from './robots';
16+
import { type EnqueueStrategy, filterUrl } from './url';
1617

1718
interface SitemapUrlData {
1819
loc: string;
@@ -206,6 +207,13 @@ export interface ParseSitemapOptions {
206207
* If not provided, all nested sitemaps are followed.
207208
*/
208209
nestedSitemapFilter?: (sitemapUrl: string) => boolean;
210+
/**
211+
* Keep only sitemap-derived URLs (nested `<sitemap>` and `<url>` entries) matching this strategy
212+
* relative to the parent sitemap URL; non-`http(s)` schemes are always dropped. Skipped for raw string
213+
* sources (no parent URL). Pass `'all'` to disable host filtering.
214+
* @default 'same-hostname'
215+
*/
216+
enqueueStrategy?: EnqueueStrategy | `${EnqueueStrategy}`;
209217
}
210218

211219
export async function* parseSitemap<T extends ParseSitemapOptions>(
@@ -222,6 +230,7 @@ export async function* parseSitemap<T extends ParseSitemapOptions>(
222230
networkTimeouts,
223231
reportNetworkErrors = true,
224232
nestedSitemapFilter,
233+
enqueueStrategy = 'same-hostname',
225234
} = options ?? {};
226235

227236
const sources = [...initialSources];
@@ -259,8 +268,11 @@ export async function* parseSitemap<T extends ParseSitemapOptions>(
259268

260269
let items: AsyncIterable<SitemapItem> | null = null;
261270

271+
// Parent URL, parsed once and reused as the origin for the strategy checks below.
272+
let sitemapUrl: URL | undefined;
273+
262274
if (source.type === 'url') {
263-
const sitemapUrl = new URL(source.url);
275+
sitemapUrl = new URL(source.url);
264276
visitedSitemapUrls.add(sitemapUrl.toString());
265277
let retriesLeft = sitemapRetries + 1;
266278

@@ -351,20 +363,44 @@ export async function* parseSitemap<T extends ParseSitemapOptions>(
351363
continue;
352364
}
353365

366+
// URL entries dropped by the enqueue strategy filter, reported in one warning per sitemap after
367+
// the loop (per-entry warnings could flood the log; individual drops are logged at debug level).
368+
let droppedUrlEntries = 0;
369+
354370
for await (const item of items) {
355371
if (item.type === 'sitemapUrl' && !visitedSitemapUrls.has(item.url)) {
356372
if (nestedSitemapFilter && !nestedSitemapFilter(item.url)) {
357373
log.debug(`Skipping sitemap ${item.url} due to nestedSitemapFilter.`);
358374
continue;
359375
}
360376

377+
// Keep only nested sitemaps matching the strategy (and using http(s)) relative to the
378+
// parent. Raw string sources have no parent URL, so the check is skipped.
379+
if (source.type === 'url') {
380+
const { allowed, reason } = filterUrl(item.url, sitemapUrl!, enqueueStrategy);
381+
if (!allowed) {
382+
log.warning(`Skipping nested sitemap ${item.url} (parent ${source.url}): ${reason}.`);
383+
continue;
384+
}
385+
}
386+
361387
sources.push({ type: 'url', url: item.url, depth: (source.depth ?? 0) + 1 });
362388
if (emitNestedSitemaps) {
363389
yield { loc: item.url, originSitemapUrl: null } as any;
364390
}
365391
}
366392

367393
if (item.type === 'url') {
394+
// Keep only URL entries that match the enqueue strategy relative to the parent (see above).
395+
if (source.type === 'url') {
396+
const { allowed, reason } = filterUrl(item.loc, sitemapUrl!, enqueueStrategy);
397+
if (!allowed) {
398+
droppedUrlEntries++;
399+
log.debug(`Skipping sitemap URL ${item.loc} (parent ${source.url}): ${reason}.`);
400+
continue;
401+
}
402+
}
403+
368404
yield {
369405
...item,
370406
originSitemapUrl:
@@ -374,6 +410,12 @@ export async function* parseSitemap<T extends ParseSitemapOptions>(
374410
};
375411
}
376412
}
413+
414+
if (droppedUrlEntries > 0 && source.type === 'url') {
415+
log.warning(
416+
`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.`,
417+
);
418+
}
377419
}
378420
}
379421

@@ -546,7 +588,8 @@ export async function* discoverValidSitemaps(
546588
timeoutMillis: requestTimeoutMillis,
547589
signal,
548590
});
549-
for (const sitemapUrl of robotsFile.getSitemaps()) {
591+
// Surface all referenced sitemaps, including cross-host; scoping happens at load time.
592+
for (const sitemapUrl of robotsFile.getSitemaps({ enqueueStrategy: 'all' })) {
550593
if (addSitemapUrl(sitemapUrl)) {
551594
yield sitemapUrl;
552595
}

0 commit comments

Comments
 (0)