スクレイピングしていたら文字化けしているものがあったので、HTTPでやりとりされるHTMLの文字コード判定が、どのようなプロセスを経て行われているのか調べてみました。
HTTPでやりとりするHTMLでの文字コード
基本的には以下の情報を見ていくようです。
- BOM
- HTTPのContent-Typeヘッダ
- HTMLのmetaタグ
- charset属性
http-equiv="Content-Type"
なもののcontent属性
- charset属性
参考 https://www.w3.org/International/questions/qa-html-encoding-declarations.ja
axiosの場合
元々はnodeでaxiosを使っていて困った部分だったので、axiosで文字コードを考慮してどう処理するかをTypeScriptで書いていきます。
axiosはデフォルトでは上の情報はどれも利用されずにutf-8決め打ちでデコードされてしまいます。なのでoptionにresponseType: 'arraybuffer'
を渡しresponse.data
をBuffer
として受け取って処理していきます。
最終目標は
import axios from 'axios';
import iconv = require('iconv-lite');
import * as charset from './charset';
(async () => {
const response = await axios.get(url, { responseType: 'arraybuffer' });
const body = iconv.decode(response.data, charset.detect(response));
})()
のように使えるcharset.detect
を実装することです。
各判定処理ごとに関数にして、決定できなかった場合にはutf-8にフォールバックするようにします。
import { AxiosResponse } from 'axios';
type Charset = string;
type IntermediateResult = Charset | null;
export const detect = (res: AxiosResponse): Charset =>
fromBOM(res.data) ||
fromHeader(res.headers["content-type"]) ||
fromMetaTag(res.data) ||
Charset.UTF8;
Charset
はきちんとやるなら https://www.iana.org/assignments/character-sets/character-sets.xhtml にあるもののunion typeとかstring enumsとかの方が良いのかもしれません。
BOM
BOMはByte Order Markで先頭数バイトを特定のパターンにすることで、ユニコードであることとそのエンコーディング、エンディアンを示すものです。
https://en.wikipedia.org/wiki/Byte_order_mark#Byte_order_marks_by_encoding から持ってきています。
// to assert elements as tuple (inferred Array<string | Buffer>)
const bomify = ([c, bytes]) => ([c, Buffer.from(bytes)] as [Charset, Buffer]);
const BOMS: ReadonlyMap<Charset, Buffer> = new Map([
['utf-8', [0xEF, 0xBB, 0xBF]],
['utf-16be', [0xFE, 0xFF]],
['utf-16le', [0xFF, 0xFE]],
['utf-7', [0x2B, 0x2F, 0x76, 0x38]],
['utf-7', [0x2B, 0x2F, 0x76, 0x39]],
['utf-7', [0x2B, 0x2F, 0x76, 0x2B]],
['utf-7', [0x2B, 0x2F, 0x76, 0x3F]],
['utf-7', [0x2B, 0x2F, 0x76, 0x38, 0x2D]],
['utf-1', [0xF7, 0x64, 0x4C]],
['utf-ebcdic', [0xDD, 0x73, 0x66, 0x73]],
['scsu', [0x0E, 0xFE, 0xFF]],
['bocu-1', [0xFB, 0xEE, 0x28]],
['gb-18030', [0x84, 0x31, 0x95, 0x33]],
].map(bomify));
export const fromBOM = (buf): IntermediateResult => {
const startsWith = (bom) =>
buf.slice(0, bom.length).equals(bom)
for (let [charset, bom] of BOMS) {
if (startsWith(bom)) return charset;
}
return null;
}
Content-Type Header
Content-TypeヘッダのフォーマットはRFC 7231のSection 3.1.1.5で決められています。
それに基づいて実装されているjshttp/content-typeを利用します。
import contentType = require('content-type');
export const fromHeader = (ctype): IntermediateResult => {
const res = contentType.parse(ctype);
return res.parameters.charset || null;
}
metaタグ
Bufferをasciiにデコードして
- metaタグのcharset属性
http-equiv="Content-Type"
なmetaタグのcontent属性
をcheerioを使って探します。
import cheerio = require('cheerio');
export const fromMetaTag = (buf): DetectionResult => {
const $ = cheerio.load(buf.toString('ascii'));
let res = $('meta[charset]').attr('charset');
if (res) return res;
res = $('meta[http-equiv="Content-Type"]').attr('content');
if (res) return fromHeader(res);
return null;
}
まとめ
以上です。全体像のgistを貼っておきます。
最後に
僕は普段主にRubyを使っているので、Rubyの場合どうなのかも気になって少し調べてみたのですが、標準ライブラリのNet::HTTP
でContent-Type
をハンドルすべきかについてのIssueがありました。
現実的には複数の方法で違う文字コードとして指定されていたり、実際使われているものと違ったりということもあるようで、絶対に信用できるメタデータというわけではないようです。
頻度から分類するアプローチもあるようで、現実的にはこちらの方がうまく動くかもしれません。
https://github.com/runk/node-chardet
データがあれば機械学習の実験課題としてちょうど良さそうですね。
最初は雑な正規表現で書いていたのですが、記事を書いているうちに正しいフォーマットはどうなのか気になってRFCを見にいったりして結構勉強になりました。全部utf-8だと嬉しいですね。