追記
この記事のパーサは使いづらいです。子要素の取得すら面倒です。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'));