Panda Noir

JavaScript の限界を究めるブログです。

JavaScript製HTMLパーサー

追記

この記事のパーサは使いづらいです。子要素の取得すら面倒です。JavaScript製HTMLパーサー改に使いやすく改善したパーサーを載せましたのでそちらを御覧ください。

本題

今回はHTMLパーサーを作りました。HTMLパーサーは、Webスクレイピングやらなにやら何かと用途があるので便利です。 コードは例によってlodashバリバリ、Nodeでの実行のみ考慮です。あと、今回はregexとかstringの定義が面倒だったのでグローバルに直でParsimmonぶち込んでます。気になるようでしたら変更ください。

ブラウザと違い壊れたHTMLを読めません。さすがにそこまでやるのはアホらしいし、そういうコードを許容してはいけないと思っている人なのでご容赦ください。

使い方

下のパーサ本体を読み込み、 parser.parse(パースしたいHTML文字列).value でパースできます。

パーサ本体

const Parsimmon = require('parsimmon');
const {string, regex, alt, seq, lazy} = Parsimmon;

const lexeme = p => p.skip(Parsimmon.optWhitespace);

const join = s => {
    const _join = a => {
        if (Array.isArray(a)) return a.map(_join).join(' ');
        return a;
    };
    return _join(s);
};

const l = string('<');
const r = string('>');
const tagName = lexeme(regex(/[a-z]+/i).desc('tagName'));
const attrValue = alt(
    string('=').skip(regex(/"?/)).then(regex(/(?:\\.|[^"])*/)).skip(regex(/"?/)).map(s => `="${s}"`),
    string('=').skip(regex(/'?/)).then(regex(/(?:\\.|[^'])*/)).skip(regex(/'?/)).map(s => `="${s}"`)
);
const attr = alt(
    seq(lexeme(regex(/[a-z]+/i).desc('attrName')), attrValue),
    lexeme(regex(/[a-z]+/i).desc('attrName'))
).many();
const openingTag = lexeme(seq(l, tagName, attr, r)).desc('openingTag').map(join);
const closingTag = lexeme(seq(l, string('/'), tagName, r)).desc('closingTag').map(join);
const emptyElement = seq(l, lexeme(regex(/(?:area|base(?:font)?|br|col|frame|hr|img|input|isindex|link|meta|param|!doctype)/i)), attr, r).map(join);

const content = regex(/[^<>]+/);

const parser = lazy(() => lexeme(alt(emptyElement, seq(openingTag, parser, closingTag), content)).many());
const testCode = '<!DOCTYPE html>' +
'<html lang="ja">' +
'  <head>' +
'    <meta charset="UTF-8">' +
'    <title>Come back!</title>' +
'  </head>' +
'  <body>' +
'  Hello world!<img src="hoge.png">\n' +
'  </body>' +
'</html>';
console.log(JSON.stringify(parser.parse(testCode).value, null, '\t'));