0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

スヌー君式の正しいHTML解析の作法

0
Last updated at Posted at 2026-06-30

まえふり

HTML解析について、オープンソースのプロジェクトやネット上のドキュメントなどを見ててなんか言いたいことがあったので、ここに書く。半分はボヤキに近い。

ハナシの前提条件

ここでは「正しいこと」を書くのだけれど、世間一般で正しい事ではなく、あくまでも「スヌー君の流儀の中において正しいこと」である。なお、スヌー君に追従しても一円の特にもならないのだけれども、古いHTMLエディターで書かれた規格違反バリバリの文書や、世界レベルのポルノ中毒者の集う怪しい名門サイトの変なページでもソコソコ解析できるスクリプトが書けるというご利益はあるんだよ。

具体的な作法

シラブル分け
シラブル分けに通常の正規表現ライブラリを使用してはいけない。例えば、IMGタグにMIMEが埋め込まれていたら、タグ一つで数百メガバイトなんてこともありうる。汎用の正規表現エンジンの動作は細かすぎ、重すぎ、脆すぎる。

正しくは自前のNFAを使って行う。面識の有る無しを問わずにサナエトークンを取り出すんだよ木下さん。

この処理が終わってしまえば、この後の段階で汎用の正規表現ライブラリを使っても大丈夫になるはず。

パース
普通のパーサーを使うという事を考えてはいけない。「対象の構造は全体的に壊れている」という前提に立たないといけない。だから、XMLパーサーをベースにHTMLの解析プログラムを書こうなどという考えは現実の壁に当たって砕かれることになる。

正しくは、最初に全ての文書を読み込んで全てのタグのインデクスを作り、ファーストインラストアウトでペアを構成しうる要素の関連付けを行う。関連付けをしやすくデータ構造を工夫する。

そして、この索引を利用したイテレーターを作る。例えばこんな風に。

between('div', 'name', 'article') { |s, e| ... }
single('img', 'name', /^figure.*$/) { |n| ... }

こうやって、大丈夫そうな断片を引っ張り出して、必要に応じてそのブロック内に再びイテレーターを適用することによって入れ子構造を追跡する。

コード

作例として、スヌー君流のHTMLパーサーを掲載する。言語はRuby(2.3~3.4までは確認)、ライセンスはGPLv1。

class Htmltoken < Struct.new(:token, :map)
  @@s_txt = 0
  @@s_tag = 1
  @@s_rem = 2
  @@s_tagchk = 3
  @@s_quote = 4
  @@s_remchk1 = 5
  @@s_remchk2 = 6
  @@s_remend1 = 7
  @@s_remend2 = 8
  @@cache = Hash.new(nil)
  def initialize(s = '')
    load_string(s)
  end
  def load_string(s)
    reset
    s.split('').each { |c| fetch(c) }
    close
    return check
  end
  def reset
    self.token = []
    self.map = Hash.new(nil)
    @buff = ''
    @stt = @@s_txt
  end
  def isspace(c)
    case c
    when ' ', "\r", "\n", "\t"
      return true
    end
    return false
  end
  protected :isspace
  def tospace(c)
    return isspace(c) ? ' ' : c
  end
  protected :tospace
  def istag(i)
    return nil if self.token[i].size < 2
    return nil if self.token[i][0, 1] != '<'
    return nil if self.token[i][-1, 1] != '>'
    return nil if (self.token[i].size > 3) && (self.token[i][1, 1] == '!')
    return true
  end
  def iscomment(i)
    return nil if self.token[i].size < 7
    return nil if self.token[i][0, 4] != '<!--'
    return nil if self.token[i][-3, 3] != '-->'
    return true
  end
  def istext(i)
    return true if self.token[i].size < 2
    return nil if self.token[i][0, 1] == '<'
    return nil if self.token[i][-1, 1] == '>'
    return true
  end
  def tagmatch(pttn, val)
    if (pttn.class == Regexp) && (val.class == String) then
      return pttn =~ val if val.class == String
      return nil
    elsif (pttn.class == String) && (val.class == String) then
      return pttn.downcase == val.downcase if val.class == String
      return nil
    elsif pttn.class == TrueClass then
      return val.class == TrueClass
    end
    return nil
  end
  protected :tagmatch
  def fetch(c)
    case @stt
    when @@s_txt
      if c == '<' then
        self.token.push @buff if @buff.size > 0
        @buff = c
        @stt = @@s_tagchk
      else
        @buff = @buff + c
      end
    when @@s_tagchk
      c = tospace(c)
      @buff = @buff + c if c != ' '
      if c == '>' then
        self.token.push @buff
        @buff = ''
        @stt = @@s_txt
      elsif c == '!'
        @stt = @@s_remchk1
      else
        @buff = @buff.downcase
        @stt = @@s_tag
      end
    when @@s_tag
      c = tospace(c)
      if (c == ' ') && (@buff.size == 1) then
        # <直後の空白は無視
      elsif (c == ' ') && (@buff[-1, 1] == ' ') then
        # タグ内の連続した空白は一つの空白に
      elsif c == '>' then
        if @buff[-1, 1] == ' ' then # >直前の空白は削除
          @buff = @buff.slice(0, @buff.size - 1)
        end
        if (@buff.size > 2) \
         && (@buff[-1, 1] == '/') && (@buff[-1, 1] == ' ') then
          @buff = @buff.slice(0, @buff.size - 2) + '/' # />直前の空白カット
        end
        @buff = @buff + '>'
        # begin mapping
        ts = fetchtagname(@buff)
        if ts[0, 1] != '/' then # begin-tag
          if ts[-1, 1] == '/' # ended
            ks = ts.slice(0, ts.size - 1)
            self.map[ks] = [] if not self.map[ks]
            #self.map[ks].push [self.token.size, self.token.size]
            self.map[ks].push [self.token.size, nil]
          else
            self.map[ts] = [] if not self.map[ts]
            self.map[ts].push [self.token.size, nil]
          end
        else # end-tag
          ks = ts.slice(1, ts.size - 1)
          if self.map[ks] then
            (self.map[ks].size - 1).downto(0) { |i|
              if not self.map[ks][i][1] then
                self.map[ks][i][1] = self.token.size
                break
              end
            }
          else # pair unmatch
          end
        end
        # end mapping
        self.token.push @buff
        @buff = ''
        @stt = @@s_txt
      elsif c == '"' then
        @buff = @buff + c
        @stt = @@s_quote
      else
        @buff = @buff + c.downcase
      end
    when @@s_quote
      @buff = @buff + c
      @stt = @@s_tag if c == '"'
    when @@s_remchk1
      @buff = @buff + c
      if c == '-' then
        @stt = @@s_remchk2 
      else
        @buff = @buff.downcase
        @stt = @@s_tag
      end
    when @@s_remchk2
      @buff = @buff + c
      if c == '-' then
        @stt = @@s_rem
      else
        @buff = @buff.downcase
        @stt = @@s_tag
      end
    when @@s_rem
      @buff = @buff + c
      @stt = @@s_remend1 if c == '-'
    when @@s_remend1
      @buff = @buff + c
      if c == '-' then
        @stt = @@s_remend2
      else
        @stt = @@s_rem
      end
    when @@s_remend2
      @buff = @buff + c
      if c == '>' then
        self.token.push @buff
        @buff = ''
        @stt = @@s_txt
      else
        @stt = @@s_rem
      end
    end
    return @stt
  end
  def check
    return @stt == @@s_txt
  end
  def emss
    case @stt
    when @@s_tag, @@s_tagchk
      return "tag paren unmatched"
    when @@s_rem, @@s_remchk1, @@s_remchk2, @@s_remend1, @@s_remend2
      return "comment paren unmatched"
    when @@s_quote
      return "double quote unmatched"
    else
      return "success"
    end
  end
  def close
    self.token.push @buff
    @buff = ''
  end
  def fetchtagname(s)
    return nil if s.size < 2
    return nil if s[0, 1] != '<'
    return nil if s[-1, 1] != '>'
    d = s.slice(1, s.size - 2)
    d = d.slice(0, d.size - 1) if d[-1, 1] == '/'
    l = d.index(/ /)
    l = d.size if not l
    d = d.slice(0, l)
    d = d + '/' if s[-2, 1] == '/'
    return d
  end
  protected :fetchtagname
  def fetchattr(s)
    if n = s.index('=') then
      k = s[0, n]
      v = (((n + 1) < s.size) ? s.slice(n + 1, s.size - 1) : '')
      if (v.size >= 2) && (v[0, 1] == '"') && (v[-1, 1] == '"') then
        v = v[1, v.size - 2]
      end
      return [k, v]
    else 
      return [s, true]
    end
  end
  protected :fetchattr
  def fetchtag(s)
    return @@cache[s] if @@cache[s]
    if (s[0, 1] == '<') && (s[-1, 1] == '>') && (s[1, 1] != '!') then
      s = s.slice(1, s.size - 2)
      s = s.slice(0, s.size - 1) if s[-1, 1] == '/'
      buff = ''
      stt = 0
      cs = s.split('')
      vs = []
      0.upto(cs.size - 1) { |i|
        if stt == 0 then
          if cs[i] == '"'
            buff = buff + cs[i]
            stt = 1
          elsif cs[i] == ' '
            vs.push buff
            buff = ''
          elsif i == (cs.size - 1) then
            vs.push buff + cs[i]
            buff = ''
          else
            buff = buff + cs[i]
          end
        else
          if cs[i] == '"'
            buff = buff + cs[i]
            stt = 0
            vs.push buff if i == (cs.size - 1)
          elsif i == (cs.size - 1) then
            vs.push buff + cs[i] # tail lost
          else
            buff = buff + cs[i]
          end
        end
      }
      if vs.size > 0 then
        vs = vs.slice(1, vs.size - 1)
        0.upto(vs.size - 1) { |i|
          vs[i] = fetchattr(vs[i])
        }
      end
      @@cache[s] = vs
      return vs
    else
      return s
    end
  end
  protected :fetchtag
  def findattr(vs, key, val)
    0.upto(vs.size - 1) { |i|
      next if key && !tagmatch(key, vs[i][0])
      next if val && !tagmatch(val, vs[i][1])
      return true
    }
    return (key == nil) && (val == nil)
  end
  protected :findattr
  def checkrange(min, v, max)
    return nil if v && min && (v < min)
    return nil if v && max && (v > max)
    return true
  end
  protected :checkrange
  def scan(name = nil, key = nil, val = nil, min = nil, max = nil)
    if name.class == String then
      self.map[name].each { |pair|
        if key || val then
          next if !checkrange(min, pair[0], max)
          next if !checkrange(min, pair[1], max)
          if findattr(fetchtag(self.token[pair[0]]), key, val) then
            yield(pair[0], pair[1])
          end
        else
          next if !checkrange(min, pair[0], max)
          next if !checkrange(min, pair[1], max)
          yield(pair[0], pair[1])
        end
      } if self.map[name]
    else
      self.map.each { |tkey, pairs|
        next if name && !tagmatch(name, tkey)
        pairs.each { |pair|
          if key || val then
            next if !pair[0]
            next if !checkrange(min, pair[0], max)
            next if !checkrange(min, pair[1], max)
            if findattr(fetchtag(self.token[pair[0]]), key, val) then
              yield(pair[0], pair[1])
            end
          else
            yield(pair[0], pair[1])
          end
        }
      }
    end
  end
  def block(name = nil, key = nil, val = nil, min = nil, max = nil)
    if block_given? then
      scan(name, key, val, min, max) { |s, e|
        yield(s, e) if s && e && (s < e) 
      }
    else
      vs = []
      scan(name, key, val, min, max) { |s, e|
        vs.push self.token[s, e] if s && e && (s < e) 
      }
      return vs
    end
  end
  def between(name = nil, key = nil, val = nil, min = nil, max = nil)
    if block_given? then
      scan(name, key, val, min, max) { |s, e|
        yield(s + 1, e - 1) if s && e && ((e - s) > 1)
      }
    else
      vs = []
      scan(name, key, val, min, max) { |s, e|
        vs.push self.token[(s + 1), (e - 1)] if s && e && ((e - 1) > 1) 
      }
      return vs
    end
  end
  def single(name = nil, key = nil, val = nil, min = nil, max = nil)
    if block_given? then
      scan(name, key, val, min, max) { |s, e|
        yield(s) if s && !e
      }
    else
      vs = []
      scan(name, key, val, min, max) { |s, e|
        vs.push s if s && !e
      }
      return vs
    end
  end
  def broken(name = nil, key = nil, val = nil, min = nil, max = nil)
    if block_given? then
      scan(name, key, val, min, max) { |s, e|
        yield(s) if !s && e
      }
    else
      vs = []
      scan(name, key, val, min, max) { |s, e|
        vs.push e if !s && e
      }
      return vs
    end
  end
  def find(tag = nil, key = nil, val = nil, min = nil, max = nil)
    min = 0 if !min
    max = self.token.size - 1 if !max
    if block_given? then
      min.upto(max) { |j|
        next if not istag(j)
        vs = fetchtag(self.token[j])
        next if vs.class == String
        next if tag && !tagmatch(tag, fetchtagname(self.token[j]))
        if !key && !val then
          yield(j)
        elsif (key || val) then
          0.upto(vs.size - 1) { |i|
            next if key && !tagmatch(key, vs[i][0])
            next if val && !tagmatch(val, vs[i][1])
            yield(j)
            break
          }
        else
          0.upto(vs.size - 1) { |i|
            if  (!key || tagmatch(key, vs[i][0])) \
             && (!val || tagmatch(val, vs[i][1])) then
              yield(j)
              break
            end
          }
        end
      }
    else
      vs = []
      min.upto(max) { |j|
        next if not istag(j)
        vs = fetchtag(self.token[j])
        next if vs.class == String
        next if tag && !tagmatch(tag, fetchtagname(self.token[j]))
        if !key && !val then
          vs.push j
        elsif (key || val) then
          0.upto(vs.size - 1) { |i|
            next if key && !tagmatch(key, vs[i][0])
            next if val && !tagmatch(val, vs[i][1])
            vs.push j
            break
          }
        else
          0.upto(vs.size - 1) { |i|
            if  (!key || tagmatch(key, vs[i][0])) \
             && (!val || tagmatch(val, vs[i][1])) then
              vs.push j
              break
            end
          }
        end
      }
      return vs
    end
  end
  def withtag(i)
    name = fetchtagname(self.token[i])
    attr = Hash[*fetchtag(self.token[i]).flatten]
    yield(name, attr)
  end
  def edit(i)
    name = fetchtagname(self.token[i])
    attr = Hash[*fetchtag(self.token[i]).flatten]
    yield(name, attr)
    s = ''
    attr.each { |k, v|
      if v.class == String then
        s = s + sprintf(" %s=\"%s\"", k, v)
      elsif v.class == TrueClass
        s = s + sprintf(" %s", k)
      end
    }
    self.token[i] = "<#{name}#{s}>"
  end
  def textonly(s = 0, e = self.token.size - 1)
    if block_given? then
      s.upto(e) { |i| yield(i) if istext(i) }
    else
      vs = []
      s.upto(e) { |i| vs.push i if istext(i) }
      return vs
    end
  end
  def commentonly(s = 0, e = self.token.size - 1)
    if block_given? then
      s.upto(e) { |i| yield(i) if iscomment(i) }
    else
      vs = []
      s.upto(e) { |i| vs.push i if iscomment(i) }
      return vs
    end
  end
  def values(a) 
    return a.map { |i| self.token[i] }
  end
  def get_radio(mname)
    find('input', 'name', mname) { |i|
      withtag(i) { |name, attr|
        next if attr['type'] != 'radio'
        return attr['value'] if attr['checked']
      }
    }
    return nil
  end
  def set_radio(mname, setval, defval = nil)
    map = Hash.new(nil)
    find('input', 'name', mname) { |i|
      edit(i) { |name, attr|
        next if attr['type'] != 'radio'
        map[attr['value']] = i
        attr.delete('checked') 
      }
    }
    if x = map[setval] then
      edit(x) { |name, attr| attr['checked'] = true }
      return true
    elsif defval && map[defval] then
      edit(map[defval]) { |name, attr| attr['checked'] = true }
      return nil 
    else
      return nil
    end
  end
  def get_select(mname)
    between('select', 'name', mname) { |s, e|
      s.upto(e) { |i|
        if istag(i) then
          withtag(i) { |name, attr|
            if (name == 'option') then
              return attr['value'] if attr['selected']
            end
          }
        end
      }
    }
    return nil
  end
  def set_select(mname, selval, defval = nil)
    map = Hash.new(nil)
    between('select', 'name', mname) { |s, e|
      s.upto(e) { |i|
        if istag(i) then
          edit(i) { |name, attr|
            if (name == 'option') then
              map[attr['value']] = i
              attr.delete('selected')
            end
          }
        end
      }
    }
    if x = map[selval] then
      edit(x) { |name, attr| attr['selected'] = true }
      return true
    elsif defval && map[defval] then
      edit(map[defval]) { |name, attr| attr['selected'] = true }
      return nil
    else
      return nil
    end
  end
end

取説

パブリックメンバー
token
map

コンストラクタ
new(s = '')

文字列の読み込み
load_string(s)

内容を空にする
reset

トークンの種別
istag(i)
iscomment(i)
istext(i)

例:指定したページの最初の画像のURLを抽出する

  url = URI.parse(lnk)
  http = Net::HTTP.new(url.host, url.port)
  http.use_ssl = true if url.class == URI::HTTPS
  http.verify_mode = OpenSSL::SSL::VERIFY_NONE
  res = http.get(url)
  html = Htmltoken.new(res.body)
  html.single('img', 'id') { |s| 
    html.withtag(s) { |name, attr| puts "#{attr['src']}" }
  }

例:先頭のタグを一つ抽出する

  puts html.token[0] # 例えば"<html>"と表示される。

ペアになっているタグの探索
block(name = nil, key = nil, val = nil, min = nil, max = nil)
block(name = nil, key = nil, val = nil, min = nil, max = nil) {
 |startpos, endpos| ... }
between(name, key = nil, val = nil, min = nil, max = nil)
between(name, key = nil, val = nil, min = nil, max = nil) {
 |startpos, endndpos| ... }

blockは開始/終了タグを探す
betweenは開始/終了タグに挟まれた範囲を探す
min,maxは検索の範囲で、省略すれば文書全てを対象にする。
min,maxを指定すれば入れ子の処理ができる。
プロシジャーを与えていれば、開始位置と終了位置をパラメーターに実行する。

ペアになっていないタグの探索
single(name = nil, key = nil, val = nil, min = nil, max = nil)
single(name = nil, key = nil, val = nil, min = nil, max = nil) {
 |pos| ... }
broken(name = nil, key = nil, val = nil, min = nil, max = nil)
broken(name = nil, key = nil, val = nil, min = nil, max = nil) {
 |pos| ... }

singleはペアになる終了タグを持たない開始タグを探す
singleはペアになる開始タグを持たない終了タグを探す
min,maxは検索の範囲で、省略すれば文書全てを対象にする。

タグを検索
find(name = nil, key = nil, val = nil, min = nil, max = nil)
find(name = nil, key = nil, val = nil, min = nil, max = nil) {
 |pos| ... }

findはペアであるかどうかを問わずタグを検索する
min,maxは検索の範囲で、省略すれば文書全てを対象にする。

参照・編集
withtag(i) { |tagname, attr| ... }
edit(i) { |tagname, attr| ... }

タグを解釈してタグ名および{属性名=>属性値,...}の形式で
ブロックパラメータを与え、ブロックを実行する。
editはtagnameおよびattrに対する変更を保存しwithtagは無視する。

テキスト
textonly(s = 0, e = self.token.size - 1)
textonly(s = 0, e = self.token.size - 1) { |pos| ... }

コメント
commentonly(s = 0, e = self.token.size - 1)
commentonly(s = 0, e = self.token.size - 1) { |pos| ... }

ラジオボタン
get_radio(mname)
set_radio(mname, setval, defval = nil)

get_radioは
inputタグのradioボタンに関して、
指定された名前のradioボタンの一覧からcheckedに
なっているタグを検索し、設定されているvalueを返す。

set_radioは
inputタグのradioボタンに関して、
指定された名前のradioボタンの一覧から
valueがsetvalと等しければそのタグにcheckedを付け、
そうでなければcheckedを外す。
defvalを指定した場合でかつcheckedを付けるべき項目
が見つからない場合、setvalではなくdefvalで探す。
(選択肢に「その他」があるような場合を想定している)

セレクタ
get_select(mname)
set_select(mname, selval, defval = nil)

get_selectは
指定された名前のselectタグに連なるoptionタグに関して、
一覧からselectedになっているoptionタグを検索し、
設定されているvalueを返す。

set_selectは
指定された名前のselectタグに連なるoptionタグに関して、
valueがsetvalと等しければそのタグにselectedを付け、
そうでなければselectedを外す。
defvalを指定した場合でかつselectedを付けるべき項目
が見つからない場合、setvalではなくdefvalで探す。
(選択肢に「その他」があるような場合を想定している)


0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?