HTMLを整形するPythonスクリプト

2018-04-15

旧ブログ

t f B! P L
Python標準ライブラリだけでHTMLを整形するPythonスクリプトです。

HTMLをインデントして整形するformathtml.py

#!/usr/bin/python3
# -*- coding: utf-8 -*-
import re, html
def formatHTML(s, reset=None):  # HTMLを整形する。第2引数があるときはすでにあるインデントを除去する。
 if reset is not None:  # 第2引数がある時。
  s = re.sub(r'(?<=>)\s+?(?=<)', "", s)  # 空文字だけのtextとtailを削除してすでにあるインデントをリセットする。
 indentunit = "\t"  # インデントの1単位。
 tagregex = re.compile(r"(?s)<(?:\/?(\w+).*?\/?|!--.*?--)>|(?<=>).+?(?=<)")  # 開始タグと終了タグ、コメント、テキストノードすべてを抽出する正規表現オブジェクト。ただし<を含んだテキストノードはそこまでしか取得できない。
 replTag = repltagCreator(indentunit)  # マッチオブジェクトを処理する関数を取得。
 s = html.unescape(s)  # HTMLの文字参照をユニコードに戻す。
 s = tagregex.sub(replTag, s)  # script要素とstyle要素以外インデントを付けて整形する。
 return s.lstrip("\n")  # 先頭の改行を削除して返す。
def repltagCreator(indentunit):  # 開始タグと終了タグのマッチオブジェクトを処理する関数を返す。
 starttagregex = re.compile(r'<\w+.*?>')  # 開始タグ。
 endendtagregex = re.compile(r'<\/\w+>$')  # 終了タグで終わっているか。 
 noendtags = "br", "img", "hr", "meta", "input", "embed", "area", "base", "col", "link", "param", "source", "wbr", "track"  # HTMLでは終了タグがなくなるタグ。
 c = 0  # インデントの数。
 starttagtype = ""  # 開始タグと終了タグが対になっているかを確認するため開始タグの要素型をクロージャに保存する。
 txtnodeflg = False  # テキストノードを処理したときに立てるフラグ。テキストノードが分断されたときのため。
 def replTag(m):  # 開始タグと終了タグのマッチオブジェクトを処理する関数。
  nonlocal c, starttagtype, txtnodeflg  # 変更するクロージャ変数。
  txt = m.group(0)  # マッチした文字列を取得。
  tagtype = m.group(1)  # 要素型を取得。Noneのときもある。
  tagtype = tagtype and tagtype.lower()  # 要素型を小文字にする。
  if tagtype in noendtags:  # 空要素の時。開始タグと区別がつかないのでまずこれを最初に判別する必要がある。
   txt = "".join(["\n", indentunit*c, txt])  # タグの前で改行してインデントする。
   starttagtype = ""  # 開始タグの要素型をリセットする。  
   txtnodeflg = False  # テキストノードのフラグを倒す。 
  elif txt.endswith("</{}>".format(tagtype)):  # 終了タグの時。
   c -= 1  # インデントの数を減らす。
   if tagtype!=starttagtype:  # 開始タグと同じ要素型ではない時。
    txt = "".join(["\n", indentunit*c, txt])  # タグの前で改行してインデントする。
   starttagtype = ""  # 開始タグの要素型をリセットする。
   txtnodeflg = False  # テキストノードのフラグを倒す。   
  elif starttagregex.match(txt) is not None:  # 開始タグの時。
   txt = "".join(["\n", indentunit*c, txt])  # タグの前で改行してインデントする。
   starttagtype = tagtype  # タグの要素型をクロージャに取得。
   c += 1  # インデントの数を増やす。
   txtnodeflg = False  # テキストノードのフラグを倒す。  
  elif txt.startswith("<!--"):  # コメントの時。
   pass  # そのまま返す。
  else:  # 上記以外はテキストノードと判断する。
   if not txt.strip():  # 改行や空白だけのとき。
    txt = ""  # 削除する。
   if "\n" in txt: # テキストノードが複数行に渡る時。
    txt = txt.rstrip("\n").replace("\n", "".join(["\n", indentunit*c]))  # 最後の改行を除いたあと全行をインデントする。
    if not txtnodeflg:  # 直前に処理したのがテキストノードではない時。
     txt = "".join(["\n", indentunit*c, txt])  # 前を改行してインデントする。  
    if endendtagregex.search(txt):  # 終了タグで終わっている時。テキストノードに<があるときそうなる。
     c -= 1  # インデントを一段上げる。
     txt = endendtagregex.sub(lambda m: "".join(["\n", indentunit*c, m.group(0)]), txt)  # 終了タグの前を改行してインデントする。
    starttagtype = ""  # 開始タグの要素型をリセットする。開始タグと終了タグが一致しているままだと終了タグの前で改行されないため。
   elif not starttagtype:  # 単行、かつ、開始タグが一致していない時。
    txt = "".join(["\n", indentunit*c, txt])  # テキストノードの前で改行してインデントする。
   txtnodeflg = True  # テキストノードのフラグを立てる。
  return txt
 return replTag
関数formatHTML()にHTMLの文字列を渡すと整形したHTMLの文字列を返します。

空要素(Empty element (空要素) - 用語集 | MDN)には終了タグがあってはいけません。

開始タグや終了タグが省略可能な要素は省略すると正しく整形できません。

FirefoxでHTMLファイルを読み込んで、名前をつけて保存すると省略可能な要素の省略されたタグを追加してくれます。

空要素に終了タグをつけている場合(例えば<br></br>)は、Firefoxに読み込んで保存し直しても正しく修正されません(<br><br>になってしまう)。

7行目でインデントの1単位を設定しています。

デフォルトではタブ1個にしています。

すでにインデントしてある文字列を渡すと綺麗に整形できないときは、第2引数を渡すとまず空文字のtextとtailを削除します(6行目)。
        <td width="299">
         <span style="color:#099eac;">All Tests (Sony_AVCHD_Test_Discs_60Hz_
          <br>
          00001.m2ts)
         </span>
        </td>
        <td width="61">
         <span style="color:#ff0000;">34787</span>
        </td>
整形後はこんな感じになります。

開始タグと終了タグの間に他の要素を挟まない時は改行しません(8行目の<span>要素)。

開始タグと終了タグの間に他の要素を含むときやテキストノードが複数行のときは、開始タグの後と終了タグの前で改行し(7-9行目の<td>要素)、その間の要素は1段階インデントを深くします(8行目の<span>要素)。

終了タグのない要素の場合はタグの前後で改行し、その前後ではインデントの深さは変更しません(3行目の<br>要素)。

スクリプトの解説

def formatHTML(s, reset=None):  # HTMLを整形する。第2引数があるときはすでにあるインデントを除去する。
 if reset is not None:  # 第2引数がある時。
  s = re.sub(r'(?<=>)\s+?(?=<)', "", s)  # 空文字だけのtextとtailを削除してすでにあるインデントをリセットする。
 indentunit = "\t"  # インデントの1単位。
 tagregex = re.compile(r"(?s)<(?:\/?(\w+).*?\/?|!--.*?--)>|(?<=>).+?(?=<)")  # 開始タグと終了タグ、コメント、テキストノードすべてを抽出する正規表現オブジェクト。ただし<を含んだテキストノードはそこまでしか取得できない。
 replTag = repltagCreator(indentunit)  # マッチオブジェクトを処理する関数を取得。
 s = html.unescape(s)  # HTMLの文字参照をユニコードに戻す。
 s = tagregex.sub(replTag, s)  # script要素とstyle要素以外インデントを付けて整形する。
 return s.lstrip("\n")  # 先頭の改行を削除して返す。
8行目で正規表現オブジェクトtagregexにマッチしたマッチオブジェクトを11行目で関数replTagに渡して整形しています。


(?s)<(?:\/?(\w+).*?\/?|!--.*?--)>|(?<=>).+?(?=<)

6行目で定義しているこの正規表現パターンで開始タグと終了タグ、コメント、テキストノードをそれぞれ抽出しています。

Group1で要素型をキャプチャしています。

テキストノードに<があるとその手前でまででマッチしてしまいます。

JavaScriptを埋め込んでいる場合は高確率で<が入ってくるので、その問題については関数replTag内で処理しています。
def repltagCreator(indentunit):  # 開始タグと終了タグのマッチオブジェクトを処理する関数を返す。
 starttagregex = re.compile(r'<\w+.*?>')  # 開始タグ。
 endendtagregex = re.compile(r'<\/\w+>$')  # 終了タグで終わっているか。 
 noendtags = "br", "img", "hr", "meta", "input", "embed", "area", "base", "col", "link", "param", "source", "wbr", "track"  # HTMLでは終了タグがなくなるタグ。
 c = 0  # インデントの数。
 starttagtype = ""  # 開始タグと終了タグが対になっているかを確認するため開始タグの要素型をクロージャに保存する。
 txtnodeflg = False  # テキストノードを処理したときに立てるフラグ。テキストノードが分断されたときのため。
 def replTag(m):  # 開始タグと終了タグのマッチオブジェクトを処理する関数。
  nonlocal c, starttagtype, txtnodeflg  # 変更するクロージャ変数。
  txt = m.group(0)  # マッチした文字列を取得。
  tagtype = m.group(1)  # 要素型を取得。Noneのときもある。
  tagtype = tagtype and tagtype.lower()  # 要素型を小文字にする。
  if tagtype in noendtags:  # 空要素の時。開始タグと区別がつかないのでまずこれを最初に判別する必要がある。
   txt = "".join(["\n", indentunit*c, txt])  # タグの前で改行してインデントする。
   starttagtype = ""  # 開始タグの要素型をリセットする。  
   txtnodeflg = False  # テキストノードのフラグを倒す。 
  elif txt.endswith("</{}>".format(tagtype)):  # 終了タグの時。
   c -= 1  # インデントの数を減らす。
   if tagtype!=starttagtype:  # 開始タグと同じ要素型ではない時。
    txt = "".join(["\n", indentunit*c, txt])  # タグの前で改行してインデントする。
   starttagtype = ""  # 開始タグの要素型をリセットする。
   txtnodeflg = False  # テキストノードのフラグを倒す。   
  elif starttagregex.match(txt) is not None:  # 開始タグの時。
   txt = "".join(["\n", indentunit*c, txt])  # タグの前で改行してインデントする。
   starttagtype = tagtype  # タグの要素型をクロージャに取得。
   c += 1  # インデントの数を増やす。
   txtnodeflg = False  # テキストノードのフラグを倒す。  
  elif txt.startswith("<!--"):  # コメントの時。
   pass  # そのまま返す。
  else:  # 上記以外はテキストノードと判断する。
   if not txt.strip():  # 改行や空白だけのとき。
    txt = ""  # 削除する。
   if "\n" in txt: # テキストノードが複数行に渡る時。
    txt = txt.rstrip("\n").replace("\n", "".join(["\n", indentunit*c]))  # 最後の改行を除いたあと全行をインデントする。
    if not txtnodeflg:  # 直前に処理したのがテキストノードではない時。
     txt = "".join(["\n", indentunit*c, txt])  # 前を改行してインデントする。  
    if endendtagregex.search(txt):  # 終了タグで終わっている時。テキストノードに<があるときそうなる。
     c -= 1  # インデントを一段上げる。
     txt = endendtagregex.sub(lambda m: "".join(["\n", indentunit*c, m.group(0)]), txt)  # 終了タグの前を改行してインデントする。
    starttagtype = ""  # 開始タグの要素型をリセットする。開始タグと終了タグが一致しているままだと終了タグの前で改行されないため。
   elif not starttagtype:  # 単行、かつ、開始タグが一致していない時。
    txt = "".join(["\n", indentunit*c, txt])  # テキストノードの前で改行してインデントする。
   txtnodeflg = True  # テキストノードのフラグを立てる。
  return txt
 return replTag
この関数replTagは8行目の正規表現オブジェクトtagregexのマッチオブジェクトを引数にします。

25行目で空要素、29行目で終了タグ、35行目で開始タグ、40行目でコメント、42行目でテキストノードについて処理しています。

空要素なのか開始タグなのかはマッチした文字列の形式からは判断できないので、16行目で定義した要素型名のタプルで判断しています。

なので空要素は開始タグの前に判別する必要があります。

開始タグのときは常にその前で改行し、インデントの階層を一つ深くします。

終了タグの時は、まずインデントの階層を一つ浅くし、開始タグとの間にテキストノード以外の要素が入り込んでいないかテキストノードが複数行があるときのみ、終了タグの前で改行します。

空要素の前は常に改行しますが、終了タグはないので、インデントの階層は変化させません。

テキストノードに<があるとそこまででマッチングしてしまうので、連続してテキストノードにマッチしたときはそこで途切れないようにしています。

そのときは終了タグも含まれてしまうので、終了タグの前で改行してインデントするようにしています。

参考にしたサイト


Empty element (空要素) - 用語集 | MDN
空要素の一覧。

html - HTML要素の終了タグの仕様を確認したい - スタック・オーバーフロー
HTML5の開始タグと終了タグの省略についてまとめられています。

Debuggex: Online visual regex tester. JavaScript, Python, and PCRE.
正規表現パターン図示化ツール。

Python Ultimate Regular Expression to Catch HTML Tags | Kevin Deldycke
HTMLからタグを取得する正規表現パターンの一例。

次の関連記事:HTMLをElementTreeにするPythonスクリプト

ブログ検索 by Blogger

Translate

最近のコメント

Created by Calendar Gadget

QooQ