HTMLを簡易に解析(tokenize / parse)する

はじめに

JavaScript で HTML を簡易に解析(字句解析・構文解析)します。

巨大なライブラリを使用せずに解析する方法を考えます。主にウェブページのスクレイピングを前提としています。処理速度は、考慮しません。コード量を重視して機能を実現します。

※次のようなライブラリが使用できる場合、そちらを使用したほうが懸命です。
 「jsdoc」「cheerio」「libxmljs」
 「chromy」「puppeteer」「Nightmare」「PhantomJS」

DOMParser を使用する

サンプルvar html = '<html><body><h1 id="header">〇〇ページ</h1><p>〇〇は、<span class="red">△△</span>です。</p></body></html>';

var parser = new DOMParser();
var doc = parser.parseFromString(html, 'text/html');

console.log(doc.getElementById('header').textContent);
// 〇〇ページ

※ブラウザ環境であれば、標準の DOMParser が使用できます。
 DOMParser - Web API | MDN

HTMLから文字列を抽出する

findText.js/**
 * 文字列を抽出する
 * 開始文字列と終了文字列に囲まれた文字列を取得します。
 * @see https://www.bugbugnow.net/2021/12/tokenize-parse-html.html
 * @param {string} text - テキスト
 * @param {string} stext - 開始文字列
 * @param {string} etext - 終了文字列
 * @return {string[]} - 抽出文字列
 */
function findText(text, stext, etext) {
  const stack = [];
  const slen = stext.length;
  const elen = etext.length;
  let si, sindex, ei, eindex = 0;
  while (true) {
    si = text.indexOf(stext, eindex);
    if (si < 0) { break; }
    sindex = si + slen;
    ei = text.indexOf(etext, sindex);
    if (ei < 0) { break; }
    stack.push(text.substring(sindex, ei));
    eindex = ei + elen;
  }
  return stack;
}
サンプルvar html = '<html><body><h1 id="header">〇〇ページ</h1><p>〇〇は、<span class="red">△△</span>です。</p></body></html>';

console.log(findText(html, '<', '>'));
// Array(10) [ "html", "body", "h1 id=\"header\"", "/h1", "p", "span class=\"red\"", "/span", "/p", "/body", "/html" ]

console.log(findText(html, '<h1', '</h1'));
// Array [ " id=\"header\">〇〇ページ" ]

解説

開始文字列と終了文字列に囲まれた文字列を単純に取得しています。単純ではあるものの、HTMLの構造を理解して使用すれば必要十分ではあります。
(HTML以外でも使用できます)

ただし、入れ子構造に弱く、コメントや属性内の「」など不親切なページに完全に対応することは難しいです。

正規表現を使用して、簡易にタグとタグ以外を分解する

tokenizeHTMLEasy.js/**
 * HTMLを簡易に字句解析する
 * 生テキストタグは、非対応です。
 * @author      toshi (https://github.com/k08045kk)
 * @license     MIT License | https://opensource.org/licenses/MIT
 * @version     3
 * @since       1 - 20211215 - 初版
 * @since       2 - 20220328 - fix 「'」「"」をタグ名・属性名に使用できる
 * @since       2 - 20220328 - fix 全角スペースをタグ名・属性名に使用できる
 * @since       2 - 20220328 - fix 属性の途中に「"'」を含むことがある
 * @since       3 - 20221013 - 正規表現を最適化
 * @since       3 - 20221013 - fix 「?」をタグ名先頭に許可しない
 * @see         https://www.bugbugnow.net/2021/12/tokenize-parse-html.html
 * @param {string} html - 生テキストのHTML
 * @param {Object} [option={}] - オプション
 * @param {boolean} option.trim - タグ間の空白を削除する
 * @return {string[]} - 分解した文字列
 */
function tokenizeHTMLEasy(html, option={}) {
  const stack = [];

  let lastIndex = 0;
  const findTag = /<[!/?A-Za-z][^\t\n\f\r />]*([\t\n\f\r /]+[^\t\n\f\r /][^\t\n\f\r /=]*([\t\n\f\r ]*=[\t\n\f\r ]*("[^"]*"|'[^']*'|[^\t\n\f\r >]*))?)*[\t\n\f\r /]*>/g;
  for (let m; m=findTag.exec(html); ) {
    if (lastIndex < m.index) {
      let text = html.substring(lastIndex, m.index);
      if (option.trim) { text = text.trim(); }
      if (text.length > 0) { stack.push(text); }
    }
    lastIndex = findTag.lastIndex;

    let tag = m[0];
    if (option.trim) { tag = tag.trim(); }
    stack.push(tag);
  }
  return stack;
}
サンプルvar html = '<html><body><h1 id="header">〇〇ページ</h1><p>〇〇は、<span class="red">△△</span>です。</p></body></html>';

console.log(tokenizeHTMLEasy(html));
// Array(14) [ "<html>", "<body>", "<h1 id=\"header\">", "〇〇ページ", "</h1>", "<p>", "〇〇は、", "<span class=\"red\">", "△△", "</span>", "です。", "</p>", "</body>", "</html>" ]

解説

正規表現を利用した単純な方法です。(正規表現はお世辞にも単純ではありませんが…)

コメントや改行文字、属性内の「>」などほとんどのパターンに対応できます。20行未満のコードであり、行数的にも簡易です。

正規表現の概略図を次に示します。

概略図

※概略図は、次のサイトで生成したものです。
 https://jex.im/regulex/

※生テキストタグ(<templete><script><style>等)は、非対応です。
※タグの開始文字が[!/?A-Za-z]以外の場合、タグとして認識しないため、弾いています。
 例:<>, < >, <0>, <あ>
   ブラウザはタグとして認識しません。文字列として処理します。
※一部のタグを間違って解釈します。
 例:</>
   タグとして処理しますが、ブラウザでは不正なタグとして消滅します。
※ファイル末尾の不完全なタグは、非対応です。
 例:abc<aabcのみを表示する。<aは消滅する)
   ファイル末尾の不完全なタグは、ブラウザでは消滅します。

※タグ名の「!」「?」は、特殊なタグを判定します。
 例:<?xml version="1.0" encoding="UTF-8"?>
 例:<!DOCTYPE html>
 例:<!-- comment -->
※タグ名の「/」は、閉じタグを判定します。
 例:</a>
※タグ名判定で [!/?A-Za-z] の部分を [!/?]?[A-Za-z] と括りだす方が正確に判定しているように感じますが、次のパターンで問題が発生するため、実施しない。
 例:</>(不正なタグとして消滅すべき)

※タグ名・属性名に全角スペースを許容します。
 例:<a b>a bがタグ名となる)
 HTMLの区切り文字は、「\t\n\f\r 」の5文字です。
 \t:U+0009(水平タブ)
 \n:U+000A(改行)
 \f:U+000C(書式送り)
 \r:U+000D(復帰)
  :U+0020(半角スペース)
  :U+3000(全角スペース)などを含む \s とは異なる点に注意してください。
 see https://www.w3.org/TR/2011/WD-html5-20110525/common-microsyntaxes.html#space-character
※属性名に「"」「'」を許容します。
 例:<a "=b>(属性"bの値を持ちます)
※属性値に「"」「'」を途中から含めることができます。
 例:<a b=c'd>(属性bc'dの値を持ちます)
※タグ内の「/」を一部無視します。(「/」の一部を区切り文字として扱います)
 例(無視する):<a/b=c><a b=c>と同じ)
 例(無視する):<a//=c><a =c>と同じ、 (空文字)の属性を持つ)
 例(無視する):<a/b/=c><a b =c>と同じ、b (空文字)の属性を持つ)
 例(無視する):<a b="c"/d=e><a b=c d=e>と同じ)
 例(無視しない):<a b=c/d=e><a b="c/d=e">と同じ)

※正確ではありませんが、findTagを次の簡易実装に置き換えることもできます。
 例:/<[!/?A-Za-z][^\s/>]*([\s/]+[^\s/][^\s/=]*([\s]*=([\s]*("[^"]*"|'[^']*'|[^\s>]*)))?)*[\s/]*>/g
   「\t\n\f\r 」を「\s」に置き換えた簡易実装です。
   タグ名・属性名に全角スペースを含む場合、問題になります。
 例:/<[!/?A-Za-z]("[^"]*"|'[^']*'|[^"'>])*>/g
   タグ名と属性の括りだけを考慮した簡易実装です。
   タグ名・属性名に全角スペース・「"'」を含む場合、問題になります。
   属性値の途中に「"'」を含む場合、問題になります。

正規表現を使用して、もう少し考えて分解する

tokenizerHTML.js/**
 * HTMLを字句解析する
 * 正規表現を利用して、HTMLのタグとタグ以外を分離します。
 * 生テキストタグ、空タグに対応します。
 * @author      toshi (https://github.com/k08045kk)
 * @license     MIT License | https://opensource.org/licenses/MIT
 * @version     3
 * @since       1 - 20211215 - 初版
 * @since       2 - 20220328 - fix 「'」「"」をタグ名・属性名に使用できる
 * @since       2 - 20220328 - fix 全角スペースをタグ名・属性名に使用できる
 * @since       2 - 20220328 - fix 属性の途中に「"'」を含むことがある
 * @since       3 - 20221013 - 正規表現を最適化
 * @since       3 - 20221013 - fix 「?」をタグ名先頭に許可しない
 * @see         https://www.bugbugnow.net/2021/12/tokenize-parse-html.html
 * @param {string} html - 生テキストのHTML
 * @param {Object} [option={}] - オプション
 * @param {boolean} option.trim - タグ間の空白を削除する
 * @return {Object} - 分解したノード
 */
function *tokenizerHTML(html, option={}) {
  const rawTags = ['SCRIPT','STYLE','TEMPLATE','TEXTAREA','TITLE'];
  const emptyTags = ['AREA','BASE','BASEFONT','BR','COL','EMBED','FRAME','HR','IMG','INPUT','KEYGEN','ISINDEX','LINK','META','PARAM','SOURCE','TRACK','WBR'];
  // Note: 廃止・非推奨:<basefont><keygen><frame><isindex>

  let lastIndex = 0;
  let findClosingRawTag = null;
  const findTag = /<[!/?A-Za-z][^\t\n\f\r />]*([\t\n\f\r /]+[^\t\n\f\r /][^\t\n\f\r /=]*([\t\n\f\r ]*=[\t\n\f\r ]*("[^"]*"|'[^']*'|[^\t\n\f\r >]*))?)*[\t\n\f\r /]*>/g;
  for (let m; m=findTag.exec(html); ) {
    const tag = m[0];
    if (findClosingRawTag) {
      if (findClosingRawTag.test(tag)) { findClosingRawTag = null; }
      else {                             continue; }
    }

    let type = 'opening-tag';
    const pre = tag.substring(0, 2);
    if (pre === '</') { type = 'closeing-tag'; }
    if (pre === '<!') { type = 'comment'; }
    // Note: <!DOCTYPE html> をコメントとして処理する
    // Note: <?xml version="1.0" encoding="UTF-8"?> を無効として処理する(要検討)

    let name;
    if (type !== 'comment') {
      if (tag.substring(tag.length-1) === '>') {
        const findTagName = /^<\/?([A-Za-z][^\t\n\f\r />]*)/;
        const n = tag.match(findTagName);
        if (n) { name = n[1].toUpperCase(); }
      }
      if (name == null) { type = 'invalid'; }
    }

    let attribute;
    if (type === 'opening-tag') {
      attribute = {};
      const content = tag.replace(/^<[A-Za-z][^\t\n\f\r />]*|>$/g, '');
      const findTagAttribute = /([^\t\n\f\r /][^\t\n\f\r /=]*)([\t\n\f\r ]*=([\t\n\f\r ]*("([^"]*)"|'([^']*)'|([^\t\n\f\r >]*))))?/g;
      for (let a; a=findTagAttribute.exec(content); ) {
        if (!attribute.hasOwnProperty(a[1])) {
          attribute[a[1]] = a[7] || a[6] || a[5]
                         || a[3]
                         || '';;
        }
      }
      if (rawTags.indexOf(name) != -1) { findClosingRawTag = new RegExp('^</'+name+'[\\t\\n\\f\\r />]', 'i'); }
      if (emptyTags.indexOf(name) != -1) { type = 'empty-tag'; }
      // Note: <tagName /> を考慮しない
    }

    if (lastIndex < m.index) {
      let text = html.substring(lastIndex, m.index);
      if (option.trim) { text = text.trim(); }
      if (text.length > 0) { yield {type:'text', name:'#text', content:text}; }
    }
    lastIndex = findTag.lastIndex;

    const token = {type:type, name:(name ? name : '#'+type), content:(option.trim ? tag.trim() : tag)};
    if (attribute) { token.attribute = attribute; }
    yield token;
  }
  if (lastIndex < html.length) {
    let text = html.substring(lastIndex);
    if (option.trim) { text = text.trim(); }
    if (text.length > 0) {
      const findTagStart = /<[!/?A-Za-z]/;
      const s = findTagStart.exec(text);
      if (findClosingRawTag) {
        yield {type:'text', name:'#text', content:text};
      } else if (s) {
        let text1 = text.substring(0, s.index);
        let text2 = text.substring(s.index);
        if (option.trim) {
          text1 = text1.trim();
          text2 = text2.trim();
        }
        if (text1.length > 0) { yield {type:'text', name:'#text', content:text1}; }
        if (text2.length > 0) { yield {type:'invalid', name:'#invalid', content:text2}; }
      } else {
        yield {type:'text', name:'#text', content:text};
      }
    }
  }
  // Note: {type, name, content, attribute}
  // Note: type = opening-tag / closeing-tag / empty-tag / comment / text / invalid
  // Note: name = tagName / #comment / #text / #invalid
}

function tokenizeHTML(html, option) {
  const stack = [];
  for (const token of tokenizerHTML(html, option)) {
    stack.push(token);
  }
  return stack;
}
サンプルvar html = '<html><body><h1 id="header">〇〇ページ</h1><p>〇〇は、<span class="red">△△</span>です。</p></body></html>';

console.log(tokenizeHTML(html));
//Array(14) [ {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, … ]
// 0: Object { type: "opening-tag", content: "<html>", name: "HTML", attribute: Object {} }
// 1: Object { type: "opening-tag", content: "<body>", name: "BODY", attribute: Object {} }
// 2: Object { type: "opening-tag", content: "<h1 id=\"header\">", name: "H1", attribute: Object {id: "header"} }
// 3: Object { type: "text", content: "〇〇ページ" }
// 4: Object { type: "closeing-tag", content: "</h1>", name: "H1" }
// 5: Object { type: "opening-tag", name: "P", content: "<p>", Object {} }
// 6: Object { type: "text", name: "#text", content: "〇〇は、" }
// 7: Object { type: "opening-tag", name: "SPAN", content: "<span class=\"red\">", Object {class: "red"} }
// 8: Object { type: "text", name: "#text", content: "△△" }
// 9: Object { type: "closeing-tag", name: "SPAN", content: "</span>" }
// 10: Object { type: "text", name: "#text", content: "です。" }
// 11: Object { type: "closeing-tag", name: "P", content: "</p>" }
// 12: Object { type: "closeing-tag", content: "</body>", name: "BODY" }
// 13: Object { type: "closeing-tag", content: "</html>", name: "HTML" }

解説

生テキストタグや空タグを含めて、HTMLを分解します。タグ名と属性の抽出もサポートします。

HTMLの字句解析としては、フルスペックの性能があります。この次の段階は、字句解析の結果を使用して、構文解析でドキュメントツリーに変換します。

HTMLのドキュメントツリーを簡易に作成する

parseHTMLEasy.js/**
 * HTMLを構文解析する
 * HTMLから簡易なドキュメントツリーを作成します。
 * @author      toshi (https://github.com/k08045kk)
 * @license     MIT License | https://opensource.org/licenses/MIT
 * @version     1
 * @since       1 - 20211215 - 初版
 * @see         https://www.bugbugnow.net/2021/12/tokenize-parse-html.html
 * @param {string} html - 生テキストのHTML
 * @param {Object} [option={}] - オプション
 * @param {boolean} option.trim - タグ間の空白を削除する
 * @return {Object} - ドキュメントツリー
 */
function parseHTMLEasy(html, option) {
  const Node = function(name, option) {
    option && Object.keys(option).forEach((key) => {
      this[key] = option[key];
    });
    this.parentNode = null;
    this.nodeName = name;
    this.childNodes = [];
    this.firstChild = null;
    this.lastChild = null;
    this.previousSibling = null;
    this.nextSibling = null;
  };

  const appendChild = (parent, node) => {
    node.parentNode = parent;
    node.previousSibling = null;
    node.nextSibling = null;
    if (parent.lastChild) {
      parent.lastChild.nextSibling = node;
      node.previousSibling = parent.lastChild;
    }
    if (parent.firstChild == null) { parent.firstChild = node; }
    parent.lastChild = node;
    parent.childNodes.push(node);
  };

  const dom = new Node('#document');
  let parent = dom;
  let lastIndex = 0;
  for (const token of tokenizerHTML(html, option)) {
    switch (token.type) {
    case 'opening-tag':
    case 'empty-tag':
      const node = new Node(token.name, {attribute:token.attribute});
      appendChild(parent, node);
      if (token.type !== 'empty-tag') { parent = node; }
      break;
    case 'closeing-tag':
      let valid = false;
      for (let node=parent; node; node=node.parentNode) {
        if (node.nodeName === token.name) {
          parent = node.parentNode;
          valid = true;
          break;
        }
      }
      if (valid) { break; }
    case 'invalid':
      break;
    case 'comment':
    case 'text':
      appendChild(parent, new Node(token.name, {content:token.content}));
      break;
    default:
      break;
    }
  }
  return dom;
}
サンプルvar html = '<html><body><h1 id="header">〇〇ページ</h1><p>〇〇は、<span class="red">△△</span>です。</p></body></html>';

function showNode(node, deep=0) {
  const option = node.attribute ? node.attribute : node.content;
  console.log(Array(deep+1).join('  ')+node.nodeName, option);
  for (const child of node.childNodes) { showNode(child, deep+1); }
}
showNode(parseHTMLEasy(html));
// #document undefined
//   HTML Object {  }
//     BODY Object {  }
//       H1 Object { id: "header" }
//         #text 〇〇ページ
//       P Object {  }
//         #text 〇〇は、
//         SPAN Object { class: "red" }
//           #text △△
//         #text です。

解説

HTMLから簡易なドキュメントツリーを作成しています。

簡易な Node を使用して、ドキュメントツリーを構築しています。簡易であるため、省略可能なタグなどには対応していません。XML形式などのがっちりとしたHTMLならば、意図した動作をします。ですが、省略などを含む場合、意図した動作にはなりません。HTML5的な省略やブラウザ基準のドキュメントツリーを作成する場合、複雑度が跳ね上がるため、ここまでとします。

※前述の tokenizerHTML() を字句解析に使用しています。