HTMLファイルからテキストを抜き出すコード例です。
PythonでHTMLをスクレイピングして、テキストのみを抽出します。
高速なlxmlライブラリを使ったコード例を紹介します。
※ 記事の『本文だけ』をきれいに取得する Python コード例も書きました。簡単な方法ですが、見出しと文章を対応付けて取得することができました。
HTML から本文のテキストだけを抽出する Python コード例
lxmlでテキスト抽出するのは簡単です。
HTMLをfromstring()で解析して、text_content()で抽出します。
文字のエンコーディングが判定できないときは、cchardet.detect()で判定します。
テキスト抽出前に不要なタグを削除するときは、findall()でリストアップして、drop_tree()で消していきます。
そのあとにtext_content()を実行します。
さて、lxmlには『HTML用のパッケージ(lxml.html)』と『XML用のモジュール(lxml.etree)』がありますが、HTMLからテキストを抽出するときは、lxml.htmlを使います。
最初にコード例を載せています。そのあとで、ほかの書き方やパフォーマンスの比較などを紹介しています。
テキスト分析に取り組まれている方の参考になれば幸いです。
テキスト抽出
Pythonでタグを除去して、テキストを抽出する方法です。
コード例
読み込んだHTMLからそのままテキスト抽出する例と、不要なタグを削除してから抽出する例です。
"""html_main1.py"""
from lxml import html
html_file = r'*****\sample.html'
# HTML読み込み f.read()
# 解析 html.fromstring()
with open(html_file, mode='rb') as f:
t = html.fromstring(f.read())
# エンコーディングはlxmlが自動で判定します。
# そのままテキスト抽出
# 先頭と末尾からスペースや改行を除去 strip()
text1 = t.text_content().strip()
print('-----[ text1 ]--------------------')
print(text1)
print('----------------------------------')
# 不要なタグを削除する例
remove_tags = ('.//style', './/script', './/noscript')
for remove_tag in remove_tags:
for tag in t.findall(remove_tag):
tag.drop_tree()
# ここでの削除は元の変数tに反映されます。
# 不要タグ削除後のテキスト抽出
# 先頭と末尾からスペースや改行を除去 strip()
text2 = t.text_content().strip()
print('-----[ text2 ]--------------------')
print(text2)
print('----------------------------------')
抽出結果の先頭と末尾には余分な空白や改行が入っているので、Pythonのstrip()メソッドで取り除いています。
HTMLソースには見やすさのために改行を入れたりしますが、それらが抽出結果に現れているんですね。先頭と末尾だけならstrip()で簡単に取り除けます。
XPathの中の'.//'
は、現在のタグより下にあるものといった意味です。ピリオドが現在のタグの位置を表していて、スラッシュ2つが途中の階層をまとめて表しています。
styleタグ、scriptタグ、noscriptタグについては、たいていのケースで削除していいと思います。タグの用途からして、重要な文章が入っていることはほとんどありません。
サンプルHTML
テスト用に作ったサンプルHTMLファイルです(sample.html)。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>タイトル - サンプルサイト</title>
<style type="text/css"><!-- h1 {color:blue;} --></style>
</head><body><h1>H1見出し</h1>
<p>本文。あいうえお<strong>アイウエオ</strong>11111。</p>
<script>document.write("スクリプト");</script>
<noscript>JavaScriptがOFFです。</noscript>
</body>
</html>
抽出結果
サンプルからテキストを抽出した結果です。
後半は不要タグを削除してから抽出した結果で、意図した通り、styleタグ、scriptタグ、noscriptタグの内容が消えています。
-----[ text1 ]--------------------
タイトル - サンプルサイト
<!-- h1 {color:blue;} -->
H1見出し
本文。あいうえおアイウエオ11111。
document.write("スクリプト");
JavaScriptがOFFです。
----------------------------------
-----[ text2 ]--------------------
タイトル - サンプルサイト
H1見出し
本文。あいうえおアイウエオ11111。
----------------------------------
解説
『文字エンコーディングの判定方法』や『エラー対策』の紹介と、『テキスト抽出の速さ』、『タグ検索の速さ』の測定結果です。
エンコーディングはlxmlが自動判定します
HTMLのencoding(文字コード)は、特に指定しなくてもlxmlが自動で判定してくれます。
バイナリデータをhtml.fromstring()に渡せば、ほとんどの場合でうまく解析してくれます。
エンコーディングを指定して解析することもできます。
エンコーディングを指定する方法
HTMLParser()でエンコーディングが指定できます。
そのパーサーをfromstring()関数に渡すと、指定したエンコーディングで解析できます。
# エンコーディングを指定してパーサーを作成
html_parser = html.HTMLParser(encoding='utf-8')
with open(html_file, mode='rb') as f:
# パーサーを指定して解析
html_data = html.fromstring(f.read(), parser=html_parser)
エラー回復モードなど
HTMLParser()には、ほかにも色々な機能があります。
エンコーディングのほかに、エラー回復モード(recover:デフォルトでTrue)や、巨大なデータに対応するモード(huge_tree:デフォルトでFalse)などがあります。
html_parser = html.HTMLParser(encoding='utf-8', recover=True, huge_tree=False)
壊れたHTMLを検出したいときはrecover=Falseにすると良いでしょう。
huge_treeについてはMemory allocation failed を回避する【lxml.etree】で解説しています。こちらはXBRLで遭遇したエラーです(XBRLはXMLの一種)。
HTMLで遭遇した経験はまだないのですが、エラー回復モードがTrueなのにエラーが発生する場合は、huge_tree=Trueに設定すると改善するかもしれません。
cchardetを使ったエンコーディング判定
シー・チャー・デットって読めばいいのかな?
HTMLのバイナリデータからエンコーディングを推定してくれます。
cchardetのdetect()関数を使って判定します。
この判定結果でパーサーを作れば、より精度の高い解析ができます。
lxmlが勘違いしてしまったHTMLでも、cchardetなら適切に判定できた場合がたくさんありました。
事例としては、metaタグのエンコーディング指定が間違ってた場合が多かったですね。
結論ですが、一般のウェブサイトを扱うならcchardet.detect()を使うのが最良だと思います。
一方で、仕様が分かっているHTMLだけを扱うなら、lxmlの自動判定に任せたり、パーサーで指定するのが良いと思います。
以下、cchardetのコード例と解説です。cchardetは事前にインストールしておきます。
"""html_detect_encoding.py"""
from lxml import html
from cchardet import detect
# HTML読み込み
with open(html_file, mode='rb') as f:
html_data = f.read()
# エンコーディングを判定
det_enc = detect(html_data)['encoding']
# HTMLパーサーはUTF-8-SIGを受け付けないので変更する。
if det_enc == 'UTF-8-SIG':
det_enc = 'utf-8'
# 判定したエンコーディングでパーサーを作成
html_parser = html.HTMLParser(encoding=det_enc)
# 解析
t = html.fromstring(html_data, parser=html_parser)
# テキスト抽出
text = t.text_content()
以下、解説です。
detect()の戻り値は辞書になっています。
{"encoding": encoding, "confidence": confidence}
カンフィデンス(confidence)は、判定の確かさを表す数値です。
通常はencodingだけあれば十分ですので、detect(html_data)['encoding']
のように辞書から直接取得しています。
あと、HTMLパーサーは『UTF-8-SIG(BOM付きのUTF-8)』を受け付けないので、そのときは普通の『UTF-8』に変更します。
このようにしても、テキスト抽出の結果に不都合はありませんでした。
先頭にBOMのデータが残っている様子もありませんでした。
cchardet.detect() 判定結果の具体例
普通の文字列を utf-8 でバイト列に変換して、cchardet.detect() に渡した場合です。
from cchardet import detect
data = 'Hello. こんにちは。'.encode('utf-8')
det_enc = detect(data)
print(f'data: {data}')
print(f'det_enc: {det_enc}')
data: b'Hello. \xe3\x81\x93\xe3\x82\x93\xe3\x81\xab\xe3\x81\xa1\xe3\x81\xaf\xe3\x80\x82'
det_enc: {'encoding': 'UTF-8', 'confidence': 0.9900000095367432}
ところで、encoding と confidence が None になった場合もありました。
cchardet.detect() が空のバイト列 b''
を受け取ったときに None になりました。
よくあったのが、空のファイルを読みこんだときでした。たぶん、データが無いから『判定のしようがない』ということだと思います。
from cchardet import detect
data = ''.encode('utf-8')
det_enc = detect(data)
print(f'data: {data}')
print(f'det_enc: {det_enc}')
data: b''
det_enc: {'encoding': None, 'confidence': None}
2種類のテキスト抽出方法
個々のタグから使えるtext_content()メソッドを使う方法と、lxml.htmlから使えるtostring()関数を使う方法です。
結論です。
tostring()でunicodeを指定した場合が一番高速でした。
いままではtext_content()を使っていたのですが、これからはtostring()に変えてもいいかなって思いました。
それぞれ以下のように使います。
text = t.text_content()
text = html.tostring(t, method='text', encoding='unicode')
text = html.tostring(t, method="text", encoding="utf-8").decode("utf-8")
サンプルHTMLでの抽出結果は同じでした。すべてのHTMLで同じになるかは調べていません。
なお、tostring()のencoding指定は必須のようでした。指定しないと以下のようなエラーが発生しました。
UnicodeEncodeError: 'ascii' codec can't encode characters in position 6-9: ordinal not in range(128)
あと、text_content()はlxml.htmlだけの機能です。XML用のlxml.etreeには存在しません。
テキスト抽出の速さ比較
速さに関しては、tostring()でunicodeを指定した場合が一番高速でした。
以下、計測コードです。
"""html_text_timeit.py"""
from lxml.html import fromstring, tostring
from timeit import timeit
html_file = r'*****\sample.html'
with open(html_file, mode='rb') as f:
t = fromstring(f.read())
n_max = 100000 # 10万回ずつ実行する
g = globals()
t1 = timeit(
't.text_content()',
globals=g, number=n_max,
)
print('%f t.text_content()' % t1)
t2 = timeit(
'tostring(t, method="text", encoding="unicode")',
globals=g, number=n_max,
)
print('%f tostring(t, method="text", encoding="unicode")' % t2)
t3 = timeit(
'tostring(t, method="text", encoding="utf-8").decode("utf-8")',
globals=g, number=n_max,
)
print('%f tostring(t, method="text", encoding="utf-8").decode("utf-8")' % t3)
実行結果です。
それぞれ10万回の実行にかかった時間(秒)です。
tostring()でunicodeを指定した場合が最短で完了しています。
1.050410 t.text_content()
0.475821 tostring(t, method="text", encoding="unicode")
0.601755 tostring(t, method="text", encoding="utf-8").decode("utf-8")
2種類のタグ検索方法
findall()メソッドとxpath()メソッドを紹介します。
たとえば、不要タグをまとめて検索する時に使います。
結論です。
findall()のほうが高速だけど、タグの属性は『名前』しか指定できない。
xpath()は少し遅いけど、タグの属性は『名前と値』を指定できる。
以下はスタイルタグを取得する例です。2つとも結果は同じでした。
tags = t.findall('.//style')
tags = t.xpath('.//style')
2つの違いですが、たとえばfindall()はタグ属性の『値』を指定できません。
指定できるのは属性の『名前』だけです。属性の存在だけですね。
findall()はid属性の『名前』だけ指定できる。
tags = t.findall('.//div[@id]')
xpath()はid属性の『名前と値』まで指定できる。
tags = t.xpath('.//div[@id="header"]')
tags = t.xpath('.//div[contains(@id,"header")]')
タグ検索の速さ比較
速さに関しては、findall()のほうが高速でした。
以下、計測コードです。
"""html_search_timeit.py"""
from lxml import html
from timeit import timeit
html_file = r'*****\sample.html'
with open(html_file, mode='rb') as f:
t = html.fromstring(f.read())
n_max = 100000 # 10万回ずつ実行する
g = globals()
remove_tag = './/script'
t1 = timeit(
't.findall(remove_tag)',
globals=g, number=n_max,
)
print('%f t.findall(remove_tag)' % t1)
t2 = timeit(
't.xpath(remove_tag)',
globals=g, number=n_max,
)
print('%f t.xpath(remove_tag)' % t2)
実行結果です。
それぞれ10万回の実行にかかった時間(秒)です。
findall()のほうが早く完了しています。
1.451534 t.findall(remove_tag)
4.434657 t.xpath(remove_tag)
lxmlは速さで圧倒的なメリットがある
おわりに、速さの面では、BeautifulSoupでlxmlを使う方法もありますが、それと比較しても、素のlxmlのほうが8倍くらい高速でした。これは、BeautifulSoupをCPU8コアで動かすのに匹敵する速さといえます。
開発の面では、BeautifulSoupのほうが圧倒的に分かりやすかったのですが、素のlxmlでも調べたら使えるようになりました。大規模な分析で速さが必要なときに、とてもおすすめです。
大量のHTMLからテキストを取り出すときは、lxml モジュールがおすすめ。
テキスト分析に取り組まれている方の参考になれば幸いです。