HTMLをElementTreeにするPythonスクリプト

2018-04-16

旧ブログ

t f B! P L
xml.etree.ElementTree.XML()はXMLしかパースできないため、HTMLをまずXMLに変換しないといけません。XMLはHTMLよりも文法に厳格なのでエラーなくパースするには少し工夫が必要でした。

前の関連記事:HTMLを整形するPythonスクリプト


HTMLをElementTreeにするhtml2elem.py 

#!/usr/bin/python3
# -*- coding: utf-8 -*-
import re, html, sys
from random import randrange
from xml.etree import ElementTree
def html2elem(s):
 regex = re.compile(r"(?is)<(?:(?:((script|style).*?)>(.+?)<\/\2)|(\w+).*?|(!DOCTYPE.*?))>")  # <script>要素、<style>要素、タグすべて、ドキュメントタイプ宣言、にマッチする正規表現オブジェクト。
 stashdic = {}  # XMLパーサーでエラーになる要素を一時的に退避しておく辞書。キー:  代替タグ名、値: マッチオブジェクト。
 replHTML = replHTMLCreator(stashdic)  # 置換する関数を取得。
 s = html.unescape(s)  # HTML文字参照をUnicodeに変換する。 
 s = regex.sub(replHTML, s) # XMLパーサーでエラーになるタグの処理。
 x = "".join(["<root>", s, "</root>"]) # htmlにルート付ける。一つのノードにまとまっていないとjunk after document elementがでる。
 try:
  root = ElementTree.XML(x)  # ElementTreeのElementにする。HTMLをXMLに変換して渡さないといけない。
 except ElementTree.ParseError as e:  # XMLとしてパースできなかったとき。
  errorLines(e, x)  # エラー部分の出力。 
  sys.exit() 
 for t, m in stashdic.items():  # 退避していたタグを戻す。
  node = root.find("".join([".//", t]))  # XPathで代替タグを取得。
  if t=="doctype":
   node.text = m.group(0)  # ドキュメントタイプ宣言はdoctypeタグのテキストノードに入れる(処理方法がわからないため)。
  else:
   scriptnode = ElementTree.XML("".join(["<", m.group(1), "/>"]))  # scriptノードをパーサーに読み込ませて属性の処理をする。
   node.tag = scriptnode.tag  # 代替タグ名を戻す。
   node.text = m.group(3)  # 代替タグにテキストノードを戻す。
   for attr in scriptnode.items():  # 代替タグに属性を戻す。
    node.set(*attr)
 return root
def replHTMLCreator(stashdic):
 noendtags = "br", "img", "hr", "meta", "input", "embed", "area", "base", "col", "link", "param", "source", "wbr", "track"   # ウェブブラウザで保存すると終了タグがなくなるタグ。
 def replHTML(m):
  if m.group(4) is not None:
   if m.group(4).lower() in noendtags:  # 閉じられていないタグのとき。
    if not m.group(0).endswith("/>"):
     return "".join([m.group(0)[:-1], "/>"])  # タグを閉じて返す。
  elif m.group(1) is not None:  # scriptタグの時。
    if m.group(3) and m.group(3).strip():  # 空白文字以外のテキストノードがある時。
     key = "stashrepl{}".format(randrange(10000))  # 置換するランダムタグを生成。
     stashdic[key] = m
     return "".join(["<", key, "/>"])  # 代替タグを返す。
  elif m.group(5) is not None:  # ドキュメントタイプ宣言がある時。
   stashdic["doctype"] = m
   return "<doctype/>"
  return m.group(0)
 return replHTML
def errorLines(e, txt):  # エラー部分の出力。e: ElementTree.ParseError, txt: XML 
 print("Failed to convert HTML to XML.", file=sys.stderr)
 print(e, file=sys.stderr)
 outputs = []
 r, c = e.position  # エラー行と列の取得。行は1から始まる。
 lines = txt.split("\n")  # 行のリストにする。
 errorline = lines[r-1]  # エラー行を取得。
 lastcolumn = len(errorline) - 1  # エラー行の最終列を取得。 
 startc, endc = (c-2, c+3) if c>3 else (0, 5)
 outputs.append("\nline {}, column {}-{}: {}\n".format(r, startc, endc-1, errorline[startc:endc]))  # まずエラー列の前後5列を出力する。 
 maxcolmuns = 400  # 折り返す列数。 
 if lastcolumn>maxcolmuns:   # エラー行が400列より大きいときはエラー列の前後200列を2行に分けて出力する。
  startcolumn = c - int(maxcolmuns/2)
  startcolumn = 0 if startcolumn<0 else startcolumn
  endcolumn = c + int(maxcolmuns/2)
  endcolumn = lastcolumn if endcolumn>lastcolumn else endcolumn   
  outputs.append("{}c{}to{}:  {}".format(r, startcolumn, c-1, errorline[startcolumn:c]))
  outputs.append("{}c{}to{}:  {}".format(r, c, endcolumn, errorline[c:endcolumn]))
 else:   # エラー行が400列以下のときは上下2行も出力。
  lastrow = len(lines) - 1
  firstrow = r - 2
  firstrow = 0 if firstrow<0 else firstrow
  endrow = r + 2
  endrow = lastrow if endrow>lastrow else endrow
  if endrow-firstrow<5:  # 5行以下のときは5行表示する。
   firstrow = endrow - 5
   firstrow = 0 if firstrow<0 else firstrow
  for i in range(firstrow, endrow+1):
   outputs.append("{}:  {}".format(i+1, lines[i]))
 print("\n".join(outputs))  
関数html2elem()にHTMLの文字列を渡すとElementTreeのルートが返ってきます。

ElementTree.tostring(root, encoding="unicode")

作成したElementTreeを文字列にして確認するにはこのコマンドでXMLとしてみれます。

HTMLをElementTreeにパースする前に10行目でまずHTML参照文字を html.unescape(s)ですべてユニコードに戻しています。

HTML参照文字のままだとinvalid tokenというパースエラーがでてきます。

XMLはHTMLと違って必ず一つのルートが必要なので、まずすべてのHTMLを<root></root>で挟み込んでいます(12行目)。

そうしないとjunk after document elementというパースエラーがでてきます。

ドキュメントタイプ宣言もXMLにうまくできないので、doctypeというタグのノードを作成してそのテキストノードにまるごと入れています。

<script>要素はセミコロンの前の文字によってはxml.etree.ElementTree.XML()にinvalid tokenと言われるので、ユニークな代替タグに置き換えておいて、ElementTreeに変換後に書き戻しています。

<style>要素はいまのところパースエラーになったことはないですが、<script>要素と同様に代替タグに置換後Elementオブジェクトで元の位置に戻しています。

XMLでは終了タグは必須なので、HTMLでは終了タグがあってはいけない要素に終了タグを追加しています。


(?is)<(?:(?:((script|style).*?)>(.+?)<\/\2)|(\w+).*?|(!DOCTYPE.*?))>

この正規表現パターンで<script>要素、<style>要素、すべての開始タグまたは空要素、ドキュメントタイプ宣言をマッチさせています。

すべての開始タグにマッチさせなくても、HTMLの空要素のタグ(30行目のタプル)だけにマッチすればよいのですが、余りにもパターンが複雑になりすぎて、マッチできないとOnline regex tester and debugger: PHP, PCRE, Python, Golang and JavaScriptで言われたので、まずはタグを取得してから判別するようにしています。

xml.etree.ElementTree.XML()のパースエラーの箇所をわかりやすく表示する


47行目の関数errorLines()はxml.etree.ElementTree.XML()xml.etree.ElementTree.ParseErrorの結果をわかりやすく表示するためだけのものです。

56行目のmaxcolmuns = 400は1行の長さが400文字以上のときは1行しか表示しないという意味です。
Failed to convert HTML to XML.
not well-formed (invalid token): line 1043, column 34

line 1043, column 32-36: th;i+

1041:      var tabname = target.textContent.replace(/\s+/g, "")  // タブ名を空白を除いて取得。
1042:      var tabbodys = g.tabbody.children  // HTMLCollection(≠配列)が返る。childNodesだとTextNodeまでも返ってくる。
1043:      for (var i=0;i<tabbodys.length;i++) {  // childrenではTextNodeを除外して取得できるが配列ではないのでforEachは使えないらしい。タブノードのHTMLCollection。
1044:       if (tabbodys[i].id==tabname) {  // タブ名が一致する時。
1045:        tabbodys[i].style.display = "inline-block";  // タブボディを表示する
1046:       } else {  // タブ名が一致しない時。
パースエラーのときでてくるメッセージは通常は最初の2行だけです。

今回の場合1043行目の34文字目でinvalid tokenがある、ということはわかります。

行番号は1から始まり文字番号は0から始まっているようです。

具体的にどの部分にエラーがあるのか簡単に知りたかったので、関数errorLines()はxml.etree.ElementTree.ParseErrorと入力した文字列を引数にして、その部分を出力するようにしました。

 line 1043, column 32-36: th;i+

これは1043行目の32文字目から36文字目を表示していることを表します。

エラーで指摘されているのは34文字目なのでセミコロンの;でひかかっていることがわかります。

 その下にエラー行の前後2行分も行番号付きで表示させているので、そこからth;i+を元にエラーの原因が推測できます。

今回の場合はinvalid tokenと言われているので、length;が意味のある用語と思われて、それが何か解釈できないということになります。

文字列の次にセミコロンが続くことはJavaScriptでは頻繁にあることなので、上記のように<script>要素のテキストノードはElementTree.XML()でパースしないことで対処しました。
Failed to convert HTML to XML.
mismatched tag: line 2, column 129

line 2, column 127-131: </hea

2c0to128:  <meta http-equiv="content-type" content="text/html; charset=UTF-8"><title>TCU - Tree Command for UNO</title><meta meta="UTF-8"></
2c129to329:  head><body><stashrepl9340/><stashrepl4547/><div id="tcuheader" style="display:flex;justify-content:space-between;border-bottom:1px solid #C4CFE5;padding:0.5em 0 0.5em 0.5em"><div class="tcutitle" styl
これは閉じていないタグを読み込んだときのパースエラーです。 タグがミスマッチしていると言われています。

 line 2, column 127-131: </hea

2行目の129文字目が問題なので、hでひっかかかっていることになります。

今回の場合2行目がmaxcolmuns = 400を超えているので、エラーのでている行をそのエラー箇所の直前で折り返してその前後200文字(=maxcolumns/2)の1行のみ表示するようにしています。

行頭の2c0to128は2行目の0文字目から128文字目という意味です。

このエラーは最初にでくる<meta>要素が閉じられておらず、終了タグが見つからないまま</head>にであったために生じたものです。

この対策としては<meta>要素もちゃんと閉じなてからXML()にわたさないといけません。
Failed to convert HTML to XML.
not well-formed (invalid token): line 1, column 8

line 1, column 6-10: <!DOC

1:  <root><!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN"><html><head><META http-equiv="Content-Type" content="text/html; charset=utf-8"/></head><body>
2:  
3:      <div bgcolor="#48486c">
4:  
これはドキュメントタイプ宣言のパースエラーです。

ドキュメントタイプ宣言はタグを閉じてもうまくパースできかなったのでdoctypeというタグのノードを作ってそのテキストノードに入れることで回避しています。

参考にしたサイト


Online regex tester and debugger: PHP, PCRE, Python, Golang and JavaScript
正規表現チェッカー。マッチするまでのステップもわかって便利です。

次の関連記事:Element オブジェクトをHTMLに変換するPythonスクリプト

ブログ検索 by Blogger

Translate

最近のコメント

Created by Calendar Gadget

QooQ