自分でHTMLの構造解析しようと試みて失敗したメモ

車輪の再発明みたいなもんだけど,
どうやってHTMLの構造って解析するのかなと言う事に関して少し興味が出てきて,
昨日の夜からちょっと書いてみた.
割と簡単にできるんじゃね?とか予想してたんだけど,
古いバージョンでのHTMLの書き方などをしてるページとかで解析するのと,
最近のページに対してやったりしてたら,
全然結果がクソになったりしてて,ダメダメでした.
HTML構造解析,奥が深すぎる.
やっぱパーサとか書ける人凄いよ.
ほんと.
とりあえずコード晒しておきます.

#!/usr/bin/perl -w

use strict;
use utf8;
use Encode qw/encode decode/;
use Dumpvalue;
use LWP::UserAgent;

# 
my $ua = LWP::UserAgent->new;
# 外部から取得する場合
my $url = 'http://sorauta.net/';
# htmlのハッシュ
my $root;
# デバッグレベル
my $debug_level = 0;
# インデントしないタグ一覧
my @not_indent_tag_list = qw/b i font li p/;

##========================================
## 実行部分
##========================================
# コンテンツ中身
my $content;
# 開始位置
my $offset = 0;
# 現在の親要素
my $parent;
# 現在のインデント幅
my $indent = 0;

# 引数にファイル指定してたらローカルと判断する
if (scalar(@ARGV) > 0) {
  _d("local : ", $ARGV[0]);
  $content = get_file($ARGV[0]);
}
else {
  _d("web : ", $url);
  $content = get_file();
}

# コメント等危険そうなものは取り除く
$content = trim_comment_and_code($content);

# エラー処理用
my $checker = 0;

# 抽出部分
while(1) {
  ($content, $offset) = search_start_tag($content, $offset);
  if (++$checker > 1000 || !$offset) {
    last;
  }
}

# デバッグ
_d('[information] dump_html_map:');
dump_html_map($root);

exit;

##========================================
## 開始タグ調査
##========================================
sub search_start_tag {
  my($text, $offset) = @_;
  my($tag, $tag_start_index, $tag_end_index, $is_closer_tag);

  # オフセットまでの文字列の中に閉じタグないか調査
  # これ実行すると何かずれる...
  #search_end_tag($text, $offset);

  # 文字列を切り取る
  $text = substr($text, $offset);

  # 開始タグがないか調査
  my($raw_start_tag, $start_tag) = ($text =~ /(<[\s]*?([\w]+))/s);
  #if ($start_tag) {
  #  _d("start ... $start_tag");
  #}

  # 開始タグが存在する場合
  if ($start_tag) {
    # <htmlの場合<の場所
    $tag_start_index = index($text, $raw_start_tag);
    # "ja">の場合>の場所
    $tag_end_index = index($text, '>', $tag_start_index) + 1;
    # <html lang="ja" ....>
    $tag = substr($text, $tag_start_index, $tag_end_index - $tag_start_index);
    # <html />か<html ...>か判定
    ($is_closer_tag) = ($tag =~ /\/[\s]*?>/) || ($start_tag eq 'br');

    # <htmlまでの文字列に閉じタグが無いか調査
    search_end_tag($text, $tag_start_index);

    # 属性取得
    my $attr_text = substr($text, $tag_start_index + 1, $tag_end_index - $tag_start_index - 1);
    my %attrs;
    foreach (($attr_text =~ /([\w]+?=[\"\']{1}.*?[\"\']{1})/gs)) {
      my($key,$val) = split(/=/, $_);
      $attrs{$key} = $val;
    }

    # リストに追加
    _d('[find tag] ', $start_tag, "($tag_start_index,$tag_end_index)");
    unless ($is_closer_tag) {
      my $tag_option = {
        _tag   => $start_tag,
        _attrs => \%attrs,
        indent => $indent,
        parent => $parent ? $parent : undef,
        children => [],
      };
      $indent++;

      # 親タグが存在する場合,子要素に追加
      if ($parent) {
        _d("[information] add: $start_tag at ", $parent->{_tag});
        push(@{$parent->{children}}, $tag_option);
      }
      elsif($start_tag eq 'html') {
        $root = $tag_option;
      }

      # 親に指定
      $parent = $tag_option;
    }
    else {
      _d("\t is closer tag");
    }

    #_d($tag);
    $offset = $tag_end_index;
  }
  else {
    # 次の<へ
    $offset = index($text, '<');
  }

  return $text, $offset;
}

##========================================
## 終了タグを調査
## 任意の文字列の区間で調べる
##========================================
sub search_end_tag {
  my($text, $offset) = @_;
  #my($tag, $tag_start_index, $tag_end_index, $is_closer_tag);

  # 文字列を切り取る
  $text = substr($text, 0, $offset);

  # 終了タグがないか調査
  my @end_tag_list = ($text =~ /<[\s]*?\/[\s]*?([\w]+)[\s]*?>/igs);
  foreach my $end_tag(@end_tag_list) {
    # インデントを1段戻す
    if ($parent->{_tag} eq $end_tag) {
      _d("[find close tag] ", $end_tag);

      $parent = $parent->{parent};
      $indent--;
    }
  }
}

##========================================
## ダンプ
##========================================
sub dump_html_map {
  my $tag_option = shift;
  my @tag_children = @{$tag_option->{children}};
  foreach (@tag_children) {
    my $tab = "\t" x $_->{indent};
    my $id = $_->{_attrs} && $_->{_attrs}->{id} ? 'id = ' . $_->{_attrs}->{id} : '';
    my $class = $_->{_attrs} && $_->{_attrs}->{class} ? 'class = ' . $_->{_attrs}->{class} : '';
    _d($tab, '(', $_->{indent}, ')', $_->{_tag}, ' : ', $id, ' : ', $class);

    if ($_->{children}) {
      dump_html_map($_);
    }
  }
}

##========================================
## コメント類危険そうなものを取り除く
##========================================
sub trim_comment_and_code {
  my $text = shift;
  $text =~ s/<[\s]*?script.*?>(.*?)<[\s]*?\/[\s]*?script[\s]*?>//igs;
  $text =~ s/<[\s]*?\!\-\-(.*?)\-\-[\s]*?>//igs;
  return $text;
}

##========================================
## ファイルの中身取得
##========================================
sub get_file_contents {
  my $filename = shift;
  open my $F, '<', $filename;
  my @contents = <$F>;
  close $F;

  return join('', @contents);
}

##========================================
## ファイルの取得方法を切り替える
##========================================
sub get_file {
  my $filename = shift;
  my $html;
  if ($filename) {
    $html = get_file_contents($filename);
  }
  else {
    $html = $ua->simple_request(
      HTTP::Request->new('POST', $url)
    )->content;
  }
  #_d($html);
  return $html;
}

##========================================
## debug
##========================================
sub _d {
  my @text = @_;
  print join('', @text), $/;
}

1;


これで,コマンドライン

perl parser.pl htmlfilename

とかやって,htmlfilenameにローカルのファイル名してたらローカルの開発環境で実行できます.
因みに,僕のサイト(http://sorauta.net/)に対して実行した結果は以下のような感じでした.



(1)head : :
(2)title : :
(1)body : :
(2)div : id = "ucMain" :
(3)h1 : :
(4)a : :
(3)h2 : :
(3)ul : id = "ucMenu" :
(4)li : :
(5)a : : class = "home"
(4)li : :
(5)a : : class = "about"
(4)li : :
(5)a : : class = "diary"
(4)li : :
(5)a : : class = "projects"
(4)li : :
(5)a : : class = "link"
(3)div : id = "ucBodyLeft" :
(4)h3 : :
(4)p : :
(3)dl : id = "ucBodyRight" :
(4)dt : :
(4)dd : :
(5)a : :
(4)dt : :
(4)dd : :
(5)form : :
(4)dt : :
(4)dd : :
(5)i : :
(4)dt : :
(4)dd : :
(3)div : id = "ucFooter" :


・メモ
こちらのパーサ(http://www.mlab.im.dendai.ac.jp/~yamada/ir/HTMLParser/)だと,閉じタグ忘れやすいものはインデントしないようにしたりしている.


・HTMLのバージョンによって,
だったり
が違うとかの差異を吸収できるようにしないといけない.
 これは$is_closer_tagの判定部分に上記パターンが発生する可能性があるタグをチェックすれば実現可能

・タグの開始と閉じの対応がごちゃごちゃのケース(例:<b><a>aaaaa</b></a>)
 どうすりゃいいんだこれw


...などなど問題山積み.
HTMLパーサ書いてる人とかにアドバイスをいただければ嬉しかったり.


・追記(15:21)
@todesking先生にアドバイスもらったので,転載.
「@rin1024 だめなHTMLを完璧に解析するのはほとんど不可能の部類です。自分で書こうと思ったら<div><a></div>があったら中のaも閉じるとかそういうヒューリスティックの地道な積み重ねしかないです。DOM構築を既存ブラウザのエンジンに丸投げするとかどうでしょうか。」
「@rin1024 あるいは解析前にHTML Tidyとか通すと幸せになれるかもしれません」
「HTMLのパースは特別に難しい、なぜなら文法が存在しないからです。HTML4.01とかそういう規格はあるけど、「世に出回っている、そのへんのブラウザで正しく表示できるHTML風のもの」についての規格はありません。ランダムで大量のヒューリスティックなルールの集合、それだけです。」


だめだめな構造のタグを修正してくれる,HTML Tidyというのがあるらしいです.
http://www.w3.org/People/asada/tidy/

これ通した後に,作ったプログラム通せば,それっぽく動くかも.
@todesking先生アドバイスありがとうございまs><