Skip to content

Commit 7acf908

Browse files
authored
fix: read content encoding hints from <meta> tags (#3780)
Utilizes the byte-stream prescan (https://html.spec.whatwg.org/multipage/parsing.html#prescan-a-byte-stream-to-determine-its-encoding) to determine the HTML document encoding, if not specified in HTTP headers (or explicitly forced by user). Closes #2317
1 parent 9963157 commit 7acf908

5 files changed

Lines changed: 91 additions & 1 deletion

File tree

packages/http-crawler/src/internals/http-crawler.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ import type { JsonValue } from 'type-fest';
3434

3535
import { addTimeoutToPromise, tryCancel } from '@apify/timeout';
3636

37-
import { parseContentTypeFromResponse, processHttpRequestOptions } from './utils.js';
37+
import { extractCharsetFromHtmlBytes, parseContentTypeFromResponse, processHttpRequestOptions } from './utils.js';
3838

3939
/**
4040
* Default mime types, which HttpScraper supports.
@@ -640,6 +640,15 @@ export class HttpCrawler<
640640
// It's not a JSON, so it's probably some text. Get the first 100 chars of it.
641641
throw new Error(`${status} - Internal Server Error: ${body.slice(0, 100)}`);
642642
} else if (HTML_AND_XML_MIME_TYPES.includes(type)) {
643+
if (!charset && !this.forceResponseEncoding) {
644+
const rawBytes = Buffer.from(await response.arrayBuffer());
645+
const metaCharset = extractCharsetFromHtmlBytes(rawBytes);
646+
const charsetToUse = metaCharset ?? this.suggestResponseEncoding ?? 'utf-8';
647+
const body = iconv.encodingExists(charsetToUse)
648+
? iconv.decode(rawBytes, charsetToUse)
649+
: rawBytes.toString('utf8');
650+
return { response, contentType: { type, encoding: 'utf-8' as BufferEncoding }, body };
651+
}
643652
return { response, contentType, body: await reencodedResponse.text() };
644653
} else {
645654
const body = Buffer.from(await reencodedResponse.bytes());

packages/http-crawler/src/internals/utils.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,18 @@ export function processHttpRequestOptions({
5959
return { ...request, body, url, headers };
6060
}
6161

62+
/**
63+
* Scans the first 1024 bytes of an HTML document (as latin1) to extract the charset
64+
* declared via `<meta charset>` or `<meta http-equiv="Content-Type" content="...;charset=...">`.
65+
* This implements a simplified version of the HTML spec's byte-stream prescan algorithm.
66+
*/
67+
export function extractCharsetFromHtmlBytes(bytes: Buffer): string | undefined {
68+
// latin1 preserves byte values for ASCII-compatible encodings, making the meta tags readable
69+
const prescan = bytes.subarray(0, 1024).toString('latin1');
70+
const match = /<meta[^>]+\bcharset\s*=\s*["']?\s*([^"'\s;>]+)/i.exec(prescan);
71+
return match?.[1];
72+
}
73+
6274
/**
6375
* Gets parsed content type from response object
6476
* @param response HTTP response object

test/core/crawlers/cheerio_crawler.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -695,6 +695,22 @@ describe('CheerioCrawler', () => {
695695
expect(await response.text()).toBe(html);
696696
});
697697

698+
test('via http-equiv meta tag when no charset in HTTP header', async () => {
699+
let context: CheerioCrawlingContext | null = null;
700+
701+
const crawler = new CheerioCrawler({
702+
requestHandler: (ctx) => {
703+
context = ctx;
704+
},
705+
});
706+
707+
await crawler.run([`${serverAddress}/special/meta-charset`]);
708+
709+
context = context as unknown as CheerioCrawlingContext;
710+
expect(context?.body).toContain('Žluťoučký kůň');
711+
expect(context?.$('body').text()).toContain('Žluťoučký kůň');
712+
});
713+
698714
test('Cheerio decodes html entities', async () => {
699715
let context: CheerioCrawlingContext | null = null;
700716

test/core/crawlers/http_crawler.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Readable } from 'node:stream';
44

55
import { HttpCrawler, SessionPool } from '@crawlee/http';
66
import { ResponseWithUrl } from '@crawlee/http-client';
7+
import iconv from 'iconv-lite';
78
import { MemoryStorageEmulator } from '../../shared/MemoryStorageEmulator.js';
89

910
const router = new Map<string, http.RequestListener>();
@@ -60,6 +61,20 @@ router.set('/403-with-octet-stream', (req, res) => {
6061
res.end();
6162
});
6263

64+
router.set('/meta-charset', (req, res) => {
65+
const text = 'Žluťoučký kůň';
66+
const html = `<html><head><meta http-equiv="Content-Type" content="text/html; charset=windows-1250"></head><body>${text}</body></html>`;
67+
res.setHeader('content-type', 'text/html'); // no charset in HTTP header
68+
res.end(iconv.encode(html, 'windows-1250'));
69+
});
70+
71+
router.set('/meta-charset-html5', (req, res) => {
72+
const text = 'Žluťoučký kůň';
73+
const html = `<html><head><meta charset="windows-1250"></head><body>${text}</body></html>`;
74+
res.setHeader('content-type', 'text/html'); // no charset in HTTP header
75+
res.end(iconv.encode(html, 'windows-1250'));
76+
});
77+
6378
let server: http.Server;
6479
let url: string;
6580

@@ -208,6 +223,36 @@ test('invalid content type defaults to octet-stream', async () => {
208223
]);
209224
});
210225

226+
test('decodes charset from http-equiv meta tag when absent in HTTP header', async () => {
227+
const results: string[] = [];
228+
229+
const crawler = new HttpCrawler({
230+
maxRequestRetries: 0,
231+
requestHandler: ({ body }) => {
232+
results.push(body as string);
233+
},
234+
});
235+
236+
await crawler.run([`${url}/meta-charset`]);
237+
238+
expect(results[0]).toContain('Žluťoučký kůň');
239+
});
240+
241+
test('decodes charset from HTML5 meta charset attribute when absent in HTTP header', async () => {
242+
const results: string[] = [];
243+
244+
const crawler = new HttpCrawler({
245+
maxRequestRetries: 0,
246+
requestHandler: ({ body }) => {
247+
results.push(body as string);
248+
},
249+
});
250+
251+
await crawler.run([`${url}/meta-charset-html5`]);
252+
253+
expect(results[0]).toContain('Žluťoučký kůň');
254+
});
255+
211256
test('handles cookies from redirects', async () => {
212257
const results: string[] = [];
213258

test/shared/_helper.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import bodyParser from 'body-parser';
77
import { entries } from 'crawlee';
88
import type { Application } from 'express';
99
import express from 'express';
10+
import iconv from 'iconv-lite';
1011

1112
export const startExpressAppPromise = async (app: Application, port: number) => {
1213
return new Promise<Server>((resolve) => {
@@ -332,6 +333,13 @@ export async function runExampleComServer(): Promise<[Server, number]> {
332333
res.type('html').send('&quot;&lt;&gt;"<>');
333334
});
334335

336+
special.get('/meta-charset', (_req, res) => {
337+
const text = 'Žluťoučký kůň';
338+
const html = `<html><head><meta http-equiv="Content-Type" content="text/html; charset=windows-1250"></head><body>${text}</body></html>`;
339+
res.setHeader('content-type', 'text/html');
340+
res.end(iconv.encode(html, 'windows-1250'));
341+
});
342+
335343
special.get('/set-cookie', (req, res) => {
336344
const cookieName = (req.query.name as string) || 'testCookie';
337345
const cookieValue = (req.query.value as string) || 'testValue';

0 commit comments

Comments
 (0)