Panda Noir

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

JavaScript製HTMLパーサー改

(この記事はQiitaで僕が書いたものを移行した記事です。記事中のコメントはQiitaの該当記事を参照ください)

※これは私が以前書いたJavaScript製HTMLパーサーとは仕様が大きく異なります。

以前との違い

以前書いたHTMLパーサーをより使いやすくしました。どう改善したかというと、パース後に返される値が単一の配列になりました。以前は返される配列の構造が複雑で子要素の取得すらままならないという使い物にならないものでした。今回はそれを踏まえ、1次配列を返すようにしました。1次配列に入るのは空要素、プレーンテキスト、開始タグ、終了タグです。子要素を取得するには開始タグから終了タグまでを抜き出せばOKです。

使い方

下のコードを読み込み、 htmlParser.parse(パースしたいHTML文字列).value でパースできます。パース後返ってくる配列は下のような形です。

[
    {
        "name": "!DOCTYPE",
        "attrs": [
            "html"
        ],
        "allString": "<!DOCTYPE html>",
        "type": "empty element"
    },
    {
        "name": "html",
        "attrs": [
            [
                "lang",
                "=\"ja\""
            ]
        ],
        "allString": "<html lang=\"ja\">",
        "type": "opening tag"
    },
    {
        "name": "head",
        "attrs": [],
        "allString": "<head>",
        "type": "opening tag"
    },
    {
        "name": "meta",
        "attrs": [
            [
                "charset",
                "=\"UTF-8\""
            ]
        ],
        "allString": "<meta charset=\"UTF-8\">",
        "type": "empty element"
    }
]

用例

1次配列なのでループを回して、parentsという配列に開始タグが出てきたらその要素をpush、終了タグが出てきたら問答無用でpopするだけで祖先一覧が作れます。こんな感じ

const res = htmlParser.parse(testCode).value, parents = [];
for (let i = 0; i < res.length; i++) {
    if (res[i].type === 'opening tag') {
        parents.push(res[i].name);
        console.log(parents.join('>'));
    }else if (res[i].type === 'closing tag')
        parents.pop();
    else if (res[i].type === 'plain text')
        console.log(`${parents.join('>')}>${res[i].content}`);
    else
        console.log(`${parents.join('>')}>${res[i].name}`);
}

パーサ本体

const Parsimmon = require('parsimmon');
const {alt, optWhitespace, regex, seq, string} = Parsimmon;
const lexeme = p => p.skip(Parsimmon.optWhitespace);

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

const commentElement = lexeme(seq(l, string('!'), string('--'), regex(/(?:[^\-]|\-[^\-])*/), string('--'), r)).map(res => ({
    body: res[3],
    allString: '<!--' + res[3] + '-->',
    type: 'comment element'
}));
const openingTag = lexeme(seq(l, tagName, attr, r)).desc('openingTag').map(res => ({
    name: res[1],
    attrs: res[2],
    allString: `<${res[1]}${res[2].length > 0 ?
        ` ${res[2].map(item => {
            if (item.attrValue)
                return item.attrName + '=' + item.attrValue;
            return item.attrName;
        }).join(' ')}` : ''
    }>`,
    type: 'opening tag'
}));
const closingTag = lexeme(seq(l, string('/'), tagName, r)).desc('closingTag').map(res => ({
    name: res[2],
    allString: `</${res[2]}>`,
    type: 'closing tag'
}));
const emptyElement = seq(l, lexeme(regex(emptyElementName)), attr, string('/').times(0, 1), r).map(res => ({
    name: res[1],
    attrs: res[2],
    allString: `<${res[1]}${res[2].length > 0 ?
        ` ${res[2].map(item => {
            if (item.attrValue)
                return item.attrName + '=' + item.attrValue;
            return item.attrName;
        }).join(' ')}` : ''}>`,
    type: 'empty element'
}));

const content = regex(/[^<>]+/).map(res => ({
    content: res,
    allString: res,
    type: 'plain text'
}));

const htmlParser = lexeme(alt(commentElement, emptyElement, openingTag, closingTag, content)).many();
const testCode = `<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="utf-8">
    <meta name="description" content="webツール(javascript製)を作り、載せています。見てみてください。">
    <meta name="keywords" content="htmlツール集,javascriptツール集,webツール,html,javascript,Panda noir">
    <meta name="google-site-verification" content="pyj1U1e7cGBzHHQcAwseHik7E8vMPGJPAZt7durcwa4" />
    <title>Panda Noir</title>
    <!--ほげ-->
    <link rel="stylesheet" href="style.css">
    <link href=\'http://fonts.googleapis.com/css?family=Ubuntu:300|Fondamento\' rel=\'stylesheet\' type=\'text/css\'>
    <script src="http://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>
    <script src="main.js"></script>
    <script>
      $(function(){
        $(\'nav\').hide();
        setTimeout(function(){
            $(\'nav\').show().find(\'li\').each(function(index){
                const n=$(this).parent().children().length;
                $(this).css({
                    top:Math.floor(Math.sin((index/n)*Math.PI*2)*25),
                    left:Math.floor(Math.cos((index/n)*Math.PI*2)*30),
                    opacity:0
                }).animate({
                    top:Math.floor(Math.sin(index/n*Math.PI*2)*250),
                    left:Math.floor(Math.cos(index/n*Math.PI*2)*300),
                    opacity:1
                },400);
            });
        },600);
      });
    </script>
  </head>
  <body>
    <h1 id="title">Panda Noir</h1>
    <nav>
      <ul>
        <li><a href="index.html">Home</a></li>
        <li><a href="works.html">Works</a></li>
        <li><a href="arts.html">Arts</a></li>
        <li><a href="profile.html">Profile</a></li>
        <li><a href="contact.html">Contact</a></li>
        <li><a href="sitemap.html">Sitemap</a></li>
      </ul>
    </nav>
  </body>
</html>`;
console.log(JSON.stringify(htmlParser.parse(testCode).value, null, '\t'));