Skip to content

Commit 29f52ed

Browse files
authored
feat(utils): add discoverValidSitemaps utility (#3339)
Related to https://github.com/apify/apify-sdk-js/issues/486. I'm [developing generic sitemap scraper](apify/actor-scraper#205) and it's going to share a big utility function (main chunk of logic) with wcc - `discoverValidSitemaps`. I've asked @barjin if I could factor it out and he told this util could fit into crawlee. It's mainly copied from wcc, but to keep the dependencies unchanged, it's using got-scraping to check for url existence instead of impit (I think it doesn't matter for sitemaps), and `urlExists` is inlined (until we don't add http client to these utils in v4 as @barjin told me). It's also turned into an async generator. Let me know if you see a better place for this util.
1 parent 16a6967 commit 29f52ed

3 files changed

Lines changed: 267 additions & 1 deletion

File tree

packages/utils/src/internals/iterables.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,3 +216,56 @@ export function peekableAsyncIterable<T>(iterable: AsyncIterable<T> | Iterable<T
216216
},
217217
};
218218
}
219+
220+
// Source - https://stackoverflow.com/a/71288323
221+
/**
222+
* Merges multiple async iterables into a single async iterable, yielding values concurrently.
223+
*
224+
* **Example usage:**
225+
* ```ts
226+
* const asyncIterable1 = async function* () {
227+
* yield 1; yield 3; yield 5;
228+
* };
229+
*
230+
* const asyncIterable2 = async function* () {
231+
* yield 2; yield 4; yield 6;
232+
* };
233+
*
234+
* for await (const value of mergeAsyncIterables(asyncIterable1(), asyncIterable2())) {
235+
* console.log(value);
236+
* }
237+
* ```
238+
*/
239+
export async function* mergeAsyncIterables<T>(...iterables: AsyncIterable<T>[]): AsyncIterable<T> {
240+
const asyncIterators = iterables.map((iterable) => iterable[Symbol.asyncIterator]());
241+
const results = [];
242+
let count = asyncIterators.length;
243+
const never: Promise<never> = new Promise(() => {});
244+
async function getNext(asyncIterator: AsyncIterator<T>, index: number) {
245+
const result = await asyncIterator.next();
246+
return {
247+
index,
248+
result,
249+
};
250+
}
251+
const nextPromises = asyncIterators.map(getNext);
252+
try {
253+
while (count) {
254+
const { index, result } = await Promise.race(nextPromises);
255+
if (result.done) {
256+
nextPromises[index] = never;
257+
results[index] = result.value;
258+
count--;
259+
} else {
260+
nextPromises[index] = getNext(asyncIterators[index], index);
261+
yield result.value;
262+
}
263+
}
264+
} finally {
265+
for (const [index, iterator] of asyncIterators.entries()) {
266+
// no await here - see https://github.com/tc39/proposal-async-iteration/issues/126
267+
if (nextPromises[index] !== never && iterator.return != null) void iterator.return();
268+
}
269+
}
270+
return results;
271+
}

packages/utils/src/internals/sitemap.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ import MIMEType from 'whatwg-mimetype';
1111

1212
import log from '@apify/log';
1313

14+
import { mergeAsyncIterables } from './iterables';
15+
import { RobotsFile } from './robots';
16+
1417
interface SitemapUrlData {
1518
loc: string;
1619
lastmod?: Date;
@@ -436,3 +439,102 @@ export class Sitemap {
436439
return new Sitemap(urls);
437440
}
438441
}
442+
443+
/**
444+
* Given a list of URLs, discover related sitemap files for these domains by checking the `robots.txt` file,
445+
* the default `sitemap.xml` & `sitemap.txt` files and the URLs themselves.
446+
* @param `urls` The list of URLs to discover sitemaps for.
447+
* @param `options` Options for sitemap discovery
448+
* @returns An async iterable with the discovered sitemap URLs.
449+
*/
450+
export async function* discoverValidSitemaps(
451+
urls: string[],
452+
options: {
453+
/**
454+
* Proxy URL to be used for network requests.
455+
*/
456+
proxyUrl?: string;
457+
} = {},
458+
): AsyncIterable<string> {
459+
const { proxyUrl } = options;
460+
const { gotScraping } = await import('got-scraping');
461+
const sitemapUrls = new Set<string>();
462+
463+
const addSitemapUrl = (url: string): string | undefined => {
464+
const sizeBefore = sitemapUrls.size;
465+
466+
sitemapUrls.add(url);
467+
468+
if (sitemapUrls.size > sizeBefore) {
469+
return url;
470+
}
471+
472+
return undefined;
473+
};
474+
475+
const urlExists = (url: string) =>
476+
gotScraping({
477+
proxyUrl,
478+
url,
479+
method: 'HEAD',
480+
}).then((response) => response.statusCode >= 200 && response.statusCode < 400);
481+
482+
const discoverSitemapsForDomainUrls = async function* (hostname: string, domainUrls: string[]) {
483+
if (!hostname) {
484+
return;
485+
}
486+
487+
try {
488+
const robotsFile = await RobotsFile.find(domainUrls[0], proxyUrl);
489+
490+
for (const sitemapUrl of robotsFile.getSitemaps()) {
491+
if (addSitemapUrl(sitemapUrl)) {
492+
yield sitemapUrl;
493+
}
494+
}
495+
} catch (err) {
496+
log.warning(`Failed to fetch robots.txt file for ${hostname}`, { error: err });
497+
}
498+
499+
const sitemapUrl = domainUrls.find((url) => /sitemap\.(?:xml|txt)(?:\.gz)?$/i.test(url));
500+
501+
if (sitemapUrl !== undefined) {
502+
if (addSitemapUrl(sitemapUrl)) {
503+
yield sitemapUrl;
504+
}
505+
} else {
506+
const firstUrl = new URL(domainUrls[0]);
507+
firstUrl.pathname = '/sitemap.xml';
508+
if (await urlExists(firstUrl.toString())) {
509+
if (addSitemapUrl(firstUrl.toString())) {
510+
yield firstUrl.toString();
511+
}
512+
}
513+
514+
firstUrl.pathname = '/sitemap.txt';
515+
if (await urlExists(firstUrl.toString())) {
516+
if (addSitemapUrl(firstUrl.toString())) {
517+
yield firstUrl.toString();
518+
}
519+
}
520+
}
521+
};
522+
523+
const groupedUrls = urls.reduce(
524+
(acc, url) => {
525+
const hostname = new URL(url)?.hostname ?? '';
526+
acc[hostname] ??= [];
527+
acc[hostname].push(url);
528+
return acc;
529+
},
530+
{} as Record<string, string[]>,
531+
);
532+
533+
const iterables = Object.entries(groupedUrls).map(([hostname, domainUrls]) =>
534+
discoverSitemapsForDomainUrls(hostname, domainUrls),
535+
);
536+
537+
for await (const url of mergeAsyncIterables(...iterables)) {
538+
yield url;
539+
}
540+
}

packages/utils/test/sitemap.test.ts

Lines changed: 112 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
44
import log from '@apify/log';
55

66
import type { SitemapUrl } from '../src/internals/sitemap';
7-
import { parseSitemap, Sitemap } from '../src/internals/sitemap';
7+
import { discoverValidSitemaps, parseSitemap, Sitemap } from '../src/internals/sitemap';
88

99
describe('Sitemap', () => {
1010
beforeEach(() => {
@@ -449,3 +449,114 @@ describe('Sitemap', () => {
449449
);
450450
});
451451
});
452+
453+
describe('discoverValidSitemaps', () => {
454+
beforeEach(() => {
455+
nock.disableNetConnect();
456+
});
457+
458+
afterEach(() => {
459+
nock.cleanAll();
460+
nock.enableNetConnect();
461+
});
462+
463+
it('extracts sitemap from robots.txt', async () => {
464+
nock('http://sitemap-discovery.com')
465+
.get('/robots.txt')
466+
.reply(200, 'Sitemap: http://sitemap-discovery.com/some-sitemap.xml')
467+
.head('/some-sitemap.xml')
468+
.reply(200, '')
469+
.head('/sitemap.xml')
470+
.reply(404, '')
471+
.head('/sitemap.txt')
472+
.reply(404, '');
473+
474+
const urls = [];
475+
for await (const url of discoverValidSitemaps(['http://sitemap-discovery.com'])) {
476+
urls.push(url);
477+
}
478+
479+
expect(urls).toEqual(['http://sitemap-discovery.com/some-sitemap.xml']);
480+
});
481+
482+
it('extracts sitemap from well-known paths if robots.txt is missing', async () => {
483+
nock('http://sitemap-discovery.com')
484+
.get('/robots.txt')
485+
.reply(404)
486+
.head('/sitemap.xml')
487+
.reply(200, '')
488+
.head('/sitemap.txt')
489+
.reply(404, '');
490+
491+
const urls = [];
492+
for await (const url of discoverValidSitemaps(['http://sitemap-discovery.com'])) {
493+
urls.push(url);
494+
}
495+
496+
expect(urls).toEqual(['http://sitemap-discovery.com/sitemap.xml']);
497+
});
498+
499+
it('extracts sitemap from well-known paths if robots.txt is missing (txt)', async () => {
500+
nock('http://sitemap-discovery.com')
501+
.get('/robots.txt')
502+
.reply(404)
503+
.head('/sitemap.xml')
504+
.reply(404, '')
505+
.head('/sitemap.txt')
506+
.reply(200, '');
507+
508+
const urls = [];
509+
for await (const url of discoverValidSitemaps(['http://sitemap-discovery.com'])) {
510+
urls.push(url);
511+
}
512+
513+
expect(urls).toEqual(['http://sitemap-discovery.com/sitemap.txt']);
514+
});
515+
516+
it('extracts sitemap from input url', async () => {
517+
nock('http://sitemap-discovery.com').get('/robots.txt').reply(404);
518+
519+
const urls = [];
520+
for await (const url of discoverValidSitemaps(['http://sitemap-discovery.com/sitemap.xml'])) {
521+
urls.push(url);
522+
}
523+
524+
expect(urls).toEqual(['http://sitemap-discovery.com/sitemap.xml']);
525+
});
526+
527+
it('extracts sitemaps from multiple domains with mixed order', async () => {
528+
nock('http://domain-a.com')
529+
.get('/robots.txt')
530+
.delay(10)
531+
.reply(404)
532+
.head('/sitemap.xml')
533+
.delay(30)
534+
.reply(200, '')
535+
.head('/sitemap.txt')
536+
.delay(50)
537+
.reply(200, '');
538+
539+
nock('http://domain-b.com')
540+
.get('/robots.txt')
541+
.delay(20)
542+
.reply(404)
543+
.head('/sitemap.xml')
544+
.delay(40)
545+
.reply(200, '')
546+
.head('/sitemap.txt')
547+
.delay(60)
548+
.reply(200, '');
549+
550+
const urls = [];
551+
for await (const url of discoverValidSitemaps(['http://domain-a.com', 'http://domain-b.com'])) {
552+
urls.push(url);
553+
}
554+
555+
expect(urls).toEqual([
556+
'http://domain-a.com/sitemap.xml',
557+
'http://domain-b.com/sitemap.xml',
558+
'http://domain-a.com/sitemap.txt',
559+
'http://domain-b.com/sitemap.txt',
560+
]);
561+
});
562+
});

0 commit comments

Comments
 (0)