Skip to content

Commit 174e78b

Browse files
fix: resolve relative links against the page's <base href> (#157)
* fix: resolve relative links against the page's `<base href>` * chore: remove base URL edge cases
1 parent b2dd455 commit 174e78b

6 files changed

Lines changed: 82 additions & 14 deletions

File tree

‎src/request-handler.ts‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
extractJsonLd,
1616
extractOpenGraphProperties,
1717
extractTitle,
18+
getDocumentBaseUrl,
1819
processHtml,
1920
} from './website-content-crawler/html-processing.js';
2021
import { htmlToMarkdown } from './website-content-crawler/markdown.js';
@@ -188,9 +189,12 @@ async function handleContent(
188189
const { request } = context;
189190
const { contentScraperSettings: settings } = request.userData;
190191

192+
const pageUrl = request.loadedUrl ?? request.url;
193+
const baseUrl = getDocumentBaseUrl($, pageUrl);
194+
191195
const $html = $('html');
192196
const html = $html.html()!;
193-
const processedHtml = await processHtml(html, request.url, settings, $);
197+
const processedHtml = await processHtml(html, baseUrl, settings, $);
194198
addTimeMeasureEvent(request.userData, `${crawlerType}-process-html`);
195199

196200
const isTooLarge = processedHtml.length > settings.maxHtmlCharsToProcess;
@@ -211,17 +215,17 @@ async function handleContent(
211215
description: $('meta[name=description]').first().attr('content') ?? undefined,
212216
keywords: $('meta[name=keywords]').first().attr('content') ?? undefined,
213217
languageCode: $html.first().attr('lang') ?? undefined,
214-
url: request.loadedUrl ?? request.url,
218+
url: pageUrl,
215219
redirectedUrl: request.loadedUrl,
216-
canonicalUrl: extractCanonicalUrl($, request.loadedUrl ?? request.url),
220+
canonicalUrl: extractCanonicalUrl($, baseUrl),
217221
openGraph: extractOpenGraphProperties($),
218222
jsonLd: extractJsonLd($),
219223
headers,
220224
},
221225
query: request.userData.query,
222226
text: settings.outputFormats.includes('text') ? text : undefined,
223227
markdown: settings.outputFormats.includes('markdown')
224-
? htmlToMarkdown(processedHtml, request.loadedUrl ?? request.url)
228+
? htmlToMarkdown(processedHtml, pageUrl, baseUrl)
225229
: undefined,
226230
html: settings.outputFormats.includes('html') ? processedHtml : undefined,
227231
};

‎src/website-content-crawler/html-processing.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,17 @@ export function extractTitle($: CheerioAPI): string {
2929
return '';
3030
}
3131

32+
export function getDocumentBaseUrl($: CheerioAPI, pageUrl: string): string {
33+
const href = $('base[href]').first().attr('href');
34+
if (!href) return pageUrl;
35+
36+
try {
37+
return new URL(href, pageUrl).href;
38+
} catch {
39+
return pageUrl;
40+
}
41+
}
42+
3243
export function extractCanonicalUrl($: CheerioAPI, baseUrl: string): string | undefined {
3344
const href = $('html > head > link[rel="canonical"]').first().attr('href');
3445
if (!href) return undefined;

‎src/website-content-crawler/markdown.ts‎

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,13 @@ function cleanAttribute(attribute: string | null): string {
1313
return attribute ? attribute.replace(/(\n+\s*)+/g, '\n') : '';
1414
}
1515

16-
function resolveHref(href: string, baseUrl: string): string {
17-
if (!baseUrl) return href;
16+
function resolveHref(href: string, pageUrl: string, baseUrl: string): string {
17+
if (!pageUrl) return href;
1818
try {
19-
return new URL(href, baseUrl).toString();
19+
// Unlike a browser, keep in-page anchors on the page, even when a `<base>` points elsewhere.
20+
return new URL(href, href.startsWith('#') ? pageUrl : baseUrl).toString();
2021
} catch {
21-
log.warning(`Failed to resolve link against the page URL: ${href}`);
22+
log.warning(`Failed to resolve link: ${href}`);
2223
return href;
2324
}
2425
}
@@ -28,31 +29,31 @@ function resolveHref(href: string, baseUrl: string): string {
2829
* links such as `/docs` remain valid once the markdown is used outside of the source page.
2930
* @see https://github.com/mixmark-io/turndown/blob/master/src/commonmark-rules.js
3031
*/
31-
const inlineLinkRule = (baseUrl: string): Rule => ({
32+
const inlineLinkRule = (pageUrl: string, baseUrl: string): Rule => ({
3233
filter: (node, options) => options.linkStyle === 'inlined'
3334
&& node.nodeName === 'A'
3435
&& Boolean(node.getAttribute('href')),
3536
replacement: (content, node) => {
3637
const element = node as HTMLElement;
37-
const href = resolveHref(element.getAttribute('href')!, baseUrl).replace(/([()])/g, '\\$1');
38+
const href = resolveHref(element.getAttribute('href')!, pageUrl, baseUrl).replace(/([()])/g, '\\$1');
3839
const title = cleanAttribute(element.getAttribute('title'));
3940
return `[${content}](${href}${title ? ` "${title.replace(/"/g, '\\"')}"` : ''})`;
4041
},
4142
});
4243

4344
/**
4445
* Converts HTML to markdown using Turndown (source: Website Content Crawler).
45-
* Relative links are resolved against `url`, when provided.
46+
* Relative links are resolved against `baseUrl`, and in-page anchors against `pageUrl`, when provided.
4647
*/
47-
export const htmlToMarkdown = (html: string | null, url?: string): string | null => {
48+
export const htmlToMarkdown = (html: string | null, pageUrl?: string, baseUrl = pageUrl): string | null => {
4849
try {
4950
if (!html?.length) return null;
5051

5152
const processor = new TurndownService(turndownSettings);
5253
if (html.length <= GFM_MAX_HTML_LENGTH) {
5354
processor.use(plugin.gfm); // Use GitHub Flavored Markdown
5455
}
55-
processor.addRule('inlineLink', inlineLinkRule(url ?? ''));
56+
processor.addRule('inlineLink', inlineLinkRule(pageUrl ?? '', baseUrl ?? ''));
5657

5758
return processor.turndown(html);
5859
} catch (err: unknown) {

‎tests/helpers/server.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,26 @@ export function createTestServer(): express.Express {
5353
sendHtml('with-image.html', res);
5454
});
5555

56+
const sendLinksPage = (base: string, res: express.Response) => {
57+
// Enough text for Readability to extract the content.
58+
const text = `<p>${'Some readable text. '.repeat(20)}</p>`.repeat(8);
59+
res.send(`<html><head>${base}<link rel="canonical" href="canonical-page"></head><body>
60+
<p><a href="article">relative link</a> <a href="#section">in-page anchor</a></p>${text}
61+
</body></html>`);
62+
};
63+
64+
app.get('/with-base', (_req, res) => {
65+
sendLinksPage('<base href="https://cdn.example.org/sub/">', res);
66+
});
67+
68+
app.get('/redirect/page', (_req, res) => {
69+
res.redirect('/redirected/page');
70+
});
71+
72+
app.get('/redirected/page', (_req, res) => {
73+
sendLinksPage('', res);
74+
});
75+
5676
// A minimal stand-in for a Google result page, holding a single organic result that points back
5777
// at this server, so that the search crawler can be exercised without reaching Google.
5878
app.get('/serp', (req, res) => {

‎tests/html-processing.test.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { load } from 'cheerio';
22
import type { CheerioAPI } from 'crawlee';
33
import { describe, expect, it } from 'vitest';
44

5-
import { extractTitle } from '../src/website-content-crawler/html-processing.js';
5+
import { extractTitle, getDocumentBaseUrl } from '../src/website-content-crawler/html-processing.js';
66

77
// The `cheerio` version bundled with Crawlee differs from the top-level one, so the types don't match.
88
const parse = (html: string) => load(html) as unknown as CheerioAPI;
@@ -45,3 +45,10 @@ describe('extractTitle', () => {
4545
expect(extractTitle($)).toBe('Test Title');
4646
});
4747
});
48+
49+
describe('getDocumentBaseUrl', () => {
50+
it('should resolve a relative base against the page URL', () => {
51+
const $ = parse('<html><head><base href="/docs/"></head><body></body></html>');
52+
expect(getDocumentBaseUrl($, 'https://example.com/a/b')).toBe('https://example.com/docs/');
53+
});
54+
});

‎tests/standby.test.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,31 @@ describe('Standby RAG tests', () => {
136136
expect(getImageRequestCount()).toBe(0);
137137
});
138138

139+
it.each([
140+
{ htmlTransformer: 'none' },
141+
{ htmlTransformer: 'readableText' },
142+
])('standby request with $htmlTransformer resolves links against the base URL', async (params) => {
143+
const pageUrl = `${baseUrl}/with-base`;
144+
const query = new URLSearchParams({ query: pageUrl, ...params });
145+
const response = await fetch(`http://localhost:${browserServerPort}/search?${query}`);
146+
const data = await response.json();
147+
148+
expect(response.status).toBe(200);
149+
expect(data[0].metadata.canonicalUrl).toBe('https://cdn.example.org/sub/canonical-page');
150+
expect(data[0].markdown).toContain('[relative link](https://cdn.example.org/sub/article)');
151+
expect(data[0].markdown).toContain(`[in-page anchor](${pageUrl}#section)`);
152+
});
153+
154+
it('standby request with readableText resolves links against the URL redirected to', async () => {
155+
const query = new URLSearchParams({ query: `${baseUrl}/redirect/page`, htmlTransformer: 'readableText' });
156+
const response = await fetch(`http://localhost:${browserServerPort}/search?${query}`);
157+
const data = await response.json();
158+
159+
expect(response.status).toBe(200);
160+
expect(data[0].metadata.url).toBe(`${baseUrl}/redirected/page`);
161+
expect(data[0].markdown).toContain(`[relative link](${baseUrl}/redirected/article)`);
162+
});
163+
139164
it('standby request playwright does not download media files of the page', async () => {
140165
resetImageRequestCount();
141166

0 commit comments

Comments
 (0)