Skip to content

Commit df26169

Browse files
ikreymertw4l
andauthored
Sitemaps: parse /sitemap.xml if no sitemap listed in robots.txt (#933)
- Ensure the /sitemap.xml is parsed even if robots.txt exists, but no sitemaps listed there. - Resolve relative URLs listed in robots.txt, eg. 'Sitemap: /my-sitemap.xml' - Simplify sitemap detection logic, check robots first, then sitemap.xml OR alternate url if provided via --useSitemap <url> - Have two main methods, parseSitemap() and parseSitemapFromRobots() that handle the parsing. - follow-up to #930 --------- Co-authored-by: Tessa Walsh <tessa@bitarchivist.net>
1 parent 850a6a6 commit df26169

3 files changed

Lines changed: 101 additions & 137 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "browsertrix-crawler",
3-
"version": "1.10.0",
3+
"version": "1.10.1",
44
"main": "browsertrix-crawler",
55
"type": "module",
66
"repository": "https://github.com/webrecorder/browsertrix-crawler",

src/crawler.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2809,18 +2809,18 @@ self.__bx_behaviors.selectMainBehavior();
28092809
});
28102810
});
28112811

2812+
let found = false;
28122813
try {
2813-
await sitemapper.parse(sitemap, url);
2814+
found = await sitemapper.parse(sitemap, url);
28142815
} catch (e) {
2815-
logger.warn(
2816-
"Sitemap for seed failed",
2817-
{ url, sitemap, ...formatErr(e) },
2818-
"sitemap",
2819-
);
2820-
return;
2816+
//
28212817
}
28222818

2823-
await p;
2819+
if (found) {
2820+
await p;
2821+
} else {
2822+
logger.warn("No sitemap for seed", { url, sitemap }, "sitemap");
2823+
}
28242824
}
28252825

28262826
async combineWARC() {

src/util/sitemapper.ts

Lines changed: 92 additions & 128 deletions
Original file line numberDiff line numberDiff line change
@@ -62,14 +62,23 @@ export class SitemapReader extends EventEmitter {
6262
return ct.split(";")[0];
6363
}
6464

65-
async _fetchWithRetry(url: string, message: string) {
65+
async _fetchWithRetry(url: string, expectedCT = XML_CONTENT_TYPES) {
6666
while (true) {
6767
const resp = await fetch(url, {
6868
headers: this.headers,
6969
dispatcher: getProxyDispatcher(url),
7070
});
7171

7272
if (resp.ok) {
73+
const ct = resp.headers.get("content-type");
74+
if (expectedCT && ct && !expectedCT.includes(ct.split(";")[0])) {
75+
logger.debug(
76+
"Not loading sitemap: invalid content-type",
77+
{ ct },
78+
"sitemap",
79+
);
80+
return null;
81+
}
7382
return resp;
7483
}
7584

@@ -85,148 +94,108 @@ export class SitemapReader extends EventEmitter {
8594
continue;
8695
}
8796

88-
logger.debug(message, { status: resp.status }, "sitemap");
89-
return null;
90-
}
91-
}
92-
93-
async tryFetch(url: string, expectedCT?: string[] | null) {
94-
try {
9597
logger.debug(
96-
"Detecting Sitemap: fetching",
97-
{ url, expectedCT },
98+
"Not loading sitemap: invalid status code",
99+
{ status: resp.status },
98100
"sitemap",
99101
);
100-
101-
const resp = await this._fetchWithRetry(
102-
url,
103-
"Detecting Sitemap: invalid status code",
104-
);
105-
106-
if (!resp) {
107-
return null;
108-
}
109-
110-
const ct = resp.headers.get("content-type");
111-
if (expectedCT && ct && !expectedCT.includes(ct.split(";")[0])) {
112-
logger.debug(
113-
"Detecting Sitemap: invalid content-type",
114-
{ ct },
115-
"sitemap",
116-
);
117-
return null;
118-
}
119-
120-
return resp;
121-
} catch (e) {
122-
logger.debug("Detecting Sitemap: unknown error", e, "sitemap");
123102
return null;
124103
}
125104
}
126105

127106
async parse(sitemap: string, seedUrl: string) {
128-
let resp: Response | null = null;
129-
let fullUrl: string | null = null;
130-
let isRobots = false;
131-
let isSitemap = false;
132-
133-
// if set to auto-detect, eg. --sitemap / --useSitemap with no URL
134-
// 1. first check robots.txt
135-
// 2. if not found, check /sitemap.xml
107+
let found = false;
108+
136109
if (sitemap === DETECT_SITEMAP) {
110+
// if set to auto-detect, eg. --sitemap / --useSitemap with no URL
111+
// 1. first check robots.txt
112+
// 2. if not found, check /sitemap.xml
137113
logger.debug("Detecting sitemap for seed", { seedUrl }, "sitemap");
138-
fullUrl = new URL("/robots.txt", seedUrl).href;
139-
resp = await this.tryFetch(fullUrl, TEXT_CONTENT_TYPE);
140-
if (resp) {
141-
isRobots = true;
142-
} else {
143-
fullUrl = new URL("/sitemap.xml", seedUrl).href;
144-
resp = await this.tryFetch(fullUrl, XML_CONTENT_TYPES);
145-
if (resp) {
146-
isSitemap = true;
147-
}
114+
const robotsUrl = new URL("/robots.txt", seedUrl).href;
115+
found = await this.parseRobotsForSitemap(robotsUrl);
116+
117+
const sitemapUrl = new URL("/sitemap.xml", seedUrl).href;
118+
119+
if (!found) {
120+
found = await this.parseSitemap(sitemapUrl);
121+
}
122+
if (!found) {
123+
logger.debug(
124+
"Sitemap not detected in robots.txt or sitemap.xml",
125+
{ robotsUrl, sitemapUrl, seedUrl },
126+
"sitemap",
127+
);
148128
}
149129
} else {
150130
// if specific URL provided, check if its a .xml file or a robots.txt file
151-
fullUrl = new URL(sitemap, seedUrl).href;
152-
let expected = null;
131+
const fullUrl = new URL(sitemap, seedUrl).href;
153132
if (fullUrl.endsWith(".xml") || fullUrl.endsWith(".xml.gz")) {
154-
expected = XML_CONTENT_TYPES;
155-
isSitemap = true;
133+
found = await this.parseSitemap(fullUrl);
156134
} else if (fullUrl.endsWith(".txt")) {
157-
expected = TEXT_CONTENT_TYPE;
158-
isRobots = true;
135+
found = await this.parseRobotsForSitemap(fullUrl);
136+
} else {
137+
logger.debug(
138+
"URL provided must be a sitemap XML or robots TXT file",
139+
{ sitemap, seedUrl, fullUrl },
140+
"sitemap",
141+
);
159142
}
160-
resp = await this.tryFetch(fullUrl, expected);
161143
}
162144

163-
// fail if no successful response fetched
164-
if (!resp) {
165-
logger.debug(
166-
"Sitemap not found",
167-
{ sitemap, seedUrl, fullUrl },
168-
"sitemap",
169-
);
170-
throw new Error("not found");
171-
}
172-
173-
// fail if neither an xml nor robots.txt
174-
if (!isRobots && !isSitemap) {
175-
logger.info("Sitemap not detected for seed", { seedUrl }, "sitemap");
176-
throw new Error("not xml or robots.txt");
177-
}
145+
return found;
146+
}
178147

179-
if (isRobots) {
180-
logger.debug(
181-
"Sitemap: parsing from robots.txt",
182-
{ fullUrl, seedUrl },
183-
"sitemap",
184-
);
185-
await this._parseRobotsFromResponse(resp);
186-
} else if (isSitemap) {
148+
private async parseRobotsForSitemap(robotsUrl: string) {
149+
let sitemapFound = false;
150+
try {
187151
logger.debug(
188-
"Sitemap: parsing from top-level sitemap XML",
189-
{ fullUrl, seedUrl },
152+
"Sitemap: Parsing robots to detect sitemap",
153+
{ url: robotsUrl },
190154
"sitemap",
191155
);
192-
await this.parseSitemapFromResponse(fullUrl, resp);
193-
}
156+
const resp = await this._fetchWithRetry(robotsUrl, TEXT_CONTENT_TYPE);
157+
if (!resp) {
158+
return sitemapFound;
159+
}
194160

195-
await this.checkIfDone();
196-
}
161+
const text = await resp.text();
197162

198-
async parseFromRobots(url: string) {
199-
const resp = await this._fetchWithRetry(
200-
url,
201-
"Sitemap robots.txt parse failed",
202-
);
203-
if (!resp) {
204-
return;
163+
text.replace(/^Sitemap:\s?([^\s]+)$/gim, (m, urlStr) => {
164+
try {
165+
const url = new URL(urlStr, robotsUrl).href;
166+
logger.debug("Sitemap: Added from robots", { url }, "sitemap");
167+
this.addNewSitemap(url, null);
168+
sitemapFound = true;
169+
} catch (e) {
170+
// ignore invalid
171+
}
172+
return urlStr;
173+
});
174+
} catch (e) {
175+
//
205176
}
206-
207-
await this._parseRobotsFromResponse(resp);
208-
}
209-
210-
private async _parseRobotsFromResponse(resp: Response) {
211-
const text = await resp.text();
212-
213-
text.replace(/^Sitemap:\s?([^\s]+)$/gim, (m, url) => {
214-
this.addNewSitemap(url, null);
215-
return url;
216-
});
177+
return sitemapFound;
217178
}
218179

219180
async parseSitemap(url: string) {
220-
this.seenSitemapSet.add(url);
181+
try {
182+
this.seenSitemapSet.add(url);
221183

222-
const resp = await this._fetchWithRetry(url, "Sitemap parse failed");
223-
if (!resp) {
224-
return;
225-
}
184+
logger.debug("Parsing sitemap XML", url, "sitemap");
185+
186+
const resp = await this._fetchWithRetry(url);
187+
if (!resp) {
188+
return false;
189+
}
226190

227-
await this.parseSitemapFromResponse(url, resp);
191+
await this.parseSitemapFromResponse(url, resp);
228192

229-
await this.checkIfDone();
193+
await this.checkIfDone();
194+
return true;
195+
} catch (e) {
196+
logger.warn("Sitemap parse failed", { url, ...formatErr(e) }, "sitemap");
197+
return false;
198+
}
230199
}
231200

232201
private async parseSitemapFromResponse(url: string, resp: Response) {
@@ -430,14 +399,19 @@ export class SitemapReader extends EventEmitter {
430399
parserStream.on("text", (text: string) => processText(text));
431400
parserStream.on("cdata", (text: string) => processText(text));
432401

402+
let limitLogged = false;
403+
433404
parserStream.on("error", (err: Error) => {
434405
const msg = { url, err, errCount };
435406
if (this.atLimit()) {
436-
logger.warn(
437-
"Sitemap parsing aborting, page limit reached",
438-
msg,
439-
"sitemap",
440-
);
407+
if (!limitLogged) {
408+
logger.warn(
409+
"Sitemap parsing aborting, page limit reached",
410+
msg,
411+
"sitemap",
412+
);
413+
limitLogged = true;
414+
}
441415
resolve();
442416
} else {
443417
logger.warn("Sitemap error parsing XML", msg, "sitemap");
@@ -490,17 +464,7 @@ export class SitemapReader extends EventEmitter {
490464
return;
491465
}
492466

493-
void this.queue.add(async () => {
494-
try {
495-
await this.parseSitemap(url);
496-
} catch (e) {
497-
logger.warn(
498-
"Sitemap parse failed",
499-
{ url, ...formatErr(e) },
500-
"sitemap",
501-
);
502-
}
503-
});
467+
void this.queue.add(() => this.parseSitemap(url));
504468
}
505469

506470
emitEntry(url: string | null, lastmod: Date | null) {

0 commit comments

Comments
 (0)