2018-10-23に更新

HTMLの文字コード決定プロセス

スクレイピングしていたら文字化けしているものがあったので、HTTPでやりとりされるHTMLの文字コード判定が、どのようなプロセスを経て行われているのか調べてみました。

HTTPでやりとりするHTMLでの文字コード

基本的には以下の情報を見ていくようです。

  1. BOM
  2. HTTPのContent-Typeヘッダ
  3. HTMLのmetaタグ
    • charset属性
    • http-equiv="Content-Type"なもののcontent属性

参考: https://www.w3.org/International/questions/qa-html-encoding-declarations.ja

axiosの場合

元々はnodeでaxiosを使っていて困った部分だったので、axiosで文字コードを考慮してどう処理するかをTypeScriptで書いていきます。

axiosはデフォルトでは上の情報はどれも利用されずにutf-8決め打ちでデコードされてしまいます。なのでoptionにresponseType: 'arraybuffer'を渡しresponse.dataBufferとして受け取って処理していきます。

最終目標は

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;
}

まとめ

以上です。全体像を貼っておきます。

import { AxiosResponse } from 'axios';
import contentType = require('content-type');
import cheerio = require('cheerio');

type Charset = string;
type IntermediateResult = Charset | null;

// 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;
}

export const fromHeader = (ctype): IntermediateResult => {
    const res = contentType.parse(ctype);
    return res.parameters.charset || null;
}

export const fromMetaTag = (buf): IntermediateResult => {
    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;
}

export const detect = (res: AxiosResponse): Charset =>
    fromBOM(res.data) ||
    fromHeader(res.headers["content-type"]) ||
    fromMetaTag(res.data) ||
    'utf-8';

最後に

僕は普段主にRubyを使っているので、Rubyの場合どうなのかも気になって少し調べてみたのですが、標準ライブラリのNet::HTTPContent-TypeをハンドルすべきかについてのIssueがありました

現実的には複数の方法で違う文字コードとして指定されていたり、実際使われているものと違ったりということもあるようで、絶対に信用できるメタデータというわけではないようです。

頻度から分類するアプローチもあるようで、現実的にはこちらの方がうまく動くかもしれません。
runk/node-chardet
データがあれば機械学習の実験課題としてちょうど良さそうですね。

最初は雑な正規表現で書いていたのですが、記事を書いているうちに正しいフォーマットはどうなのか気になってRFCを見にいったりして結構勉強になりました。全部utf-8だと嬉しいですね。

Originally published at blog.en30.net

en30
Crieitはαバージョンで開発中です。進捗は公式Twitterアカウントをフォローして確認してください。 興味がある方は是非記事の投稿もお願いします! どんな軽い内容でも嬉しいです。
なぜCrieitを作ろうと思ったか
関連記事

コメント