Python の html.parser で HTML 解析:40行の抽出器

スクレイピング講座2。標準の html.parser で商品一覧を抽出し、urljoin で相対 URL を直し、空白と文字参照を整え、HTML に正規表現を使うと壊れる様子を確認。解析速度も Python 3.9 と 3.12 で実測。

シリーズ:Webスクレイピング講座 2/6(初級)。 ページの取得はできた(第1回)けれど、その中のデータをどう取り出せばいいか分からない人向けです。読み終えると、Python 標準の html.parser で40行ほどの抽出器を書き、テキストと属性のどちらを読むべきか判断でき、404 にならないリンクを組み立てられるようになります。

価格比較の表も、在庫監視の通知も、仕入れ先の Web ページから数字を拾う社内ツールも、中心にあるのはパーサーです。新人の多くはこれを str.find や正規表現で書き、最初のページでは動き、サイトのデザインが変わった週に黙って壊れます。この回では標準ライブラリだけで正しく作り、正規表現版が実際の出力で失敗する様子を見て、速さも測ります。実行環境は Apple Silicon Mac(M1 Pro、macOS 26.6)の Python 3.9.6、比較用に Python 3.12.14。相手は第1回の練習用ショップで、PHP 8.5.6 で 127.0.0.1:18160 に立てています。

まだショップを立てていない人は先に第1回を見てください。PHP ファイル1つとコマンド1行で済みます。

ここでいう「解析」とは

HTML のページは、タグ(<li>、<a>)、タグの中の属性(class="product"、href="product/101")、タグの間のテキスト(USB-Cケーブル 1m)でできています。開始タグから終了タグまでの中身全体を要素と呼びます。ショップの商品1つはこうなっています。

  <li class="product" data-id="101">
    <a href="product/101">
      USB-Cケーブル 1m
    </a>
    <span class="price">¥980</span>
    <span class="stock out">0</span>
  </li>

パーサーはこのテキストを読んで構造を教えてくれるものです。html.parser.HTMLParser はイベント駆動型で、ページを1回なめながら、出会ったものに応じてメソッドを呼びます。<li ...> なら handle_starttag(tag, attrs)、テキストなら handle_data(text)、</li> なら handle_endtag(tag)。あとで検索できる木構造は作らないので、「今は商品の中にいる」「次に来るテキストは価格だ」といった現在位置は自分のコードで覚えておきます。

BeautifulSoup と比べると原始的に見えますが、どの Python にも入っていて、崩れた HTML にも寛容で、一覧ページならたいていこれで足ります。

40行の抽出器

ショップと同じフォルダに extract.py として保存します。import とパーサークラスで39行、main() はページを取得して結果を表示する部分です。

import sys
import urllib.request
from html.parser import HTMLParser
from urllib.parse import urljoin

UA = "PracticeShopBot/0.1 (+https://example.com/bot-contact)"

class ProductParser(HTMLParser):
    def __init__(self, base_url):
        super().__init__()                      # convert_charrefs=True by default
        self.base_url = base_url
        self.items, self.next_url = [], None
        self.cur, self.field = None, None

    def handle_starttag(self, tag, attrs):
        a = dict(attrs)
        classes = (a.get("class") or "").split()
        if tag == "li" and "product" in classes:
            self.cur = {"id": a.get("data-id"), "name": "", "price": "", "url": None}
        elif self.cur is not None and tag == "a":
            self.cur["url"] = urljoin(self.base_url, a.get("href", ""))
            self.field = "name"
        elif self.cur is not None and tag == "span" and "price" in classes:
            self.field = "price"
        elif tag == "a" and a.get("rel") == "next":
            self.next_url = urljoin(self.base_url, a["href"])

    def handle_endtag(self, tag):
        if tag in ("a", "span"):
            self.field = None
        elif tag == "li" and self.cur is not None:
            self.cur = {k: " ".join(v.split()) if isinstance(v, str) else v
                        for k, v in self.cur.items()}
            self.items.append(self.cur)
            self.cur = None

    def handle_data(self, data):
        if self.cur is not None and self.field:
            self.cur[self.field] += data

def main():
    url = sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:18160/ja/products"
    req = urllib.request.Request(url, headers={"User-Agent": UA})
    with urllib.request.urlopen(req, timeout=10) as resp:
        html = resp.read().decode(resp.headers.get_content_charset() or "utf-8")
    p = ProductParser(resp.url)
    p.feed(html)
    p.close()
    for item in p.items:
        print(item)
    print("next:", p.next_url)

if __name__ == "__main__":
    main()
$ python3 extract.py
{'id': '101', 'name': 'USB-Cケーブル 1m', 'price': '¥980', 'url': 'http://127.0.0.1:18160/ja/product/101'}
{'id': '102', 'name': 'ワイヤレスマウス', 'price': '¥2,480', 'url': 'http://127.0.0.1:18160/ja/product/102'}
{'id': '103', 'name': 'ノートPCスタンド', 'price': '¥3,980', 'url': 'http://127.0.0.1:18160/ja/product/103'}
{'id': '104', 'name': 'メカニカルキーボード', 'price': '¥12,800', 'url': 'http://127.0.0.1:18160/ja/product/104'}
next: http://127.0.0.1:18160/ja/products?page=2
$ python3 extract.py 'http://127.0.0.1:18160/ja/products?page=2'
{'id': '105', 'name': '27インチモニター', 'price': '¥32,800', 'url': 'http://127.0.0.1:18160/ja/product/105'}
{'id': '106', 'name': 'Webカメラ HD', 'price': '¥4,980', 'url': 'http://127.0.0.1:18160/ja/product/106'}
{'id': '107', 'name': 'デスクライト', 'price': '¥2,980', 'url': 'http://127.0.0.1:18160/ja/product/107'}
{'id': '108', 'name': 'ノイズキャンセリングヘッドホン', 'price': '¥19,800', 'url': 'http://127.0.0.1:18160/ja/product/108'}
next: None

シェルでは URL を引用符で囲みます。囲まないと ? がファイル名のワイルドカードとして扱われ、zsh は no matches found と返してきました。

2段の流れ図。上段:feed(html) で解析が始まり、class が product の li の handle_starttag で空の現在アイテムを作り、a の handle_starttag で urljoin した絶対 URL を記録して現在の項目を name にし、handle_data でその項目に文字を足す。下段:a の handle_endtag で項目を解除し、class が price の span で項目を price にして handle_data で埋め、li の handle_endtag で空白をまとめて完成したアイテムをリストに追加する。
html.parser の抽出器が <li class="product"> 1つを辞書に変える流れ。パーサーはメソッドを呼ぶだけで、今どこにいるかは自分のコードが覚えておく。

パターンはいつも同じです。handle_starttag で「ここは欲しい場所だ」と判断して目印(self.cur、self.field)を立て、handle_data は目印が立っている間だけテキストを集め、handle_endtag で目印を下ろして結果を保存します。これが書ければ、どんな一覧ページのパーサーも書けます。

テキストと属性

データにはタグの間のテキストにあるもの(商品名、価格)と、属性にあるもの(data-id の商品 ID、href のリンク)があります。attrs はペアのリストで届くので、抽出器では最初に辞書にしています。確かめた細かい点が3つあります。

li [('class', 'product featured'), ('data-id', '109')] {'class': 'product featured', 'data-id': '109'}
input [('disabled', None), ('value', '3')] {'disabled': None, 'value': '3'}
a [('href', 'x'), ('href', 'y')] {'href': 'y'}
  • class は空白区切りの1つの文字列。 <li class="product featured"> は 'product featured' になるので、a.get("class") == "product" は False です。抽出器のように分割してから含まれるかを調べます。
  • 値のない属性は None。 だから .split() の前に (a.get("class") or "") と書いています。None.split() は落ちます。
  • 同じ属性が2回あると dict() では後勝ち。 まれですが、手で編集されたページでは起こります。

属性名は小文字にそろえて渡されるので、HREF も href も href になります。値はそのままです。

相対リンクと urljoin

ショップのリンクは href="product/101" という相対 URL で、「今のページのフォルダから見た product/101」という意味です。取得するには絶対 URL が必要で、urllib.parse.urljoin がブラウザと同じ規則で組み立ててくれます。

http://127.0.0.1:18160/ja/products         + product/101              -> http://127.0.0.1:18160/ja/product/101
http://127.0.0.1:18160/ja/products/        + product/101              -> http://127.0.0.1:18160/ja/products/product/101
http://127.0.0.1:18160/                    + product/101              -> http://127.0.0.1:18160/product/101
http://127.0.0.1:18160/ja/products?page=1  + ?page=2                  -> http://127.0.0.1:18160/ja/products?page=2
http://127.0.0.1:18160/ja/products         + /static/101.png          -> http://127.0.0.1:18160/static/101.png
http://127.0.0.1:18160/ja/products         + //cdn.example.com/a.png  -> http://cdn.example.com/a.png
http://127.0.0.1:18160/ja/products         + https://example.com/     -> https://example.com/

URL を + や f 文字列でつなげてはいけません。2行目のように、末尾のスラッシュ1つで結果が変わります。そしてベースは実際に受け取ったページの URL でなければなりません。ショップの / は /ja/products にリダイレクトされるので、それぞれのベースで抽出器を動かすとこうなります。

base=http://127.0.0.1:18160/                -> http://127.0.0.1:18160/product/101  404
base=http://127.0.0.1:18160/ja/products     -> http://127.0.0.1:18160/ja/product/101  200

main() がリクエストした URL ではなく resp.url を渡しているのはこのためです(ページが <base href="..."> を宣言していると、ベースがさらに変わります。この抽出器は無視しているので、リンクがおかしいときは確認してください)。

空白と文字参照

handle_endtag の整形の行がないと、商品名に HTML ソースの改行とインデントがそのまま残ります。

{'id': '105', 'name': '\n      27インチモニター\n    ', 'price': '¥32,800', 'url': 'http://127.0.0.1:18160/ja/product/105'}

" ".join(v.split()) で直ります。引数なしの str.split() は Unicode の空白すべてで区切るので、ノーブレークスペースや全角スペースもまとめて処理できます。日本語のサイトでは全角スペースが混ざるのは珍しくありません。

'27&quot; &amp; HD' -> '27" & HD' -> '27" & HD'
'¥980&nbsp;(税込)' -> '¥980\xa0(税込)' -> '¥980 (税込)'
'ノートPC スタンド' -> 'ノートPC スタンド' -> 'ノートPC スタンド'
'&#8361;8,900' -> '₩8,900' -> '₩8,900'
'caf&eacute;' -> 'café' -> 'café'

左から元の文字列、html.unescape(s) の結果、さらに split と join をした結果です。&amp; や &quot; のような文字参照は、HTML の中でそのまま書くと紛らわしい文字を表す書き方です。HTMLParser は既定でこれを元の文字に戻してくれます。その設定を切るとどうなるか、ショップの見出しと「次へ」リンクで試しました。

convert_charrefs = True
  data: 'Practice Shop & Co.'
  data: 'Next »'
convert_charrefs = False
  data: 'Practice Shop '
  entityref: 'amp'
  data: ' Co.'
  data: 'Next '
  entityref: 'raquo'

False だとテキストが分割され、文字参照は別のメソッドに回ります。handle_data しか書いていない抽出器では & が黙って消えます。既定のままにしておきましょう。

HTML に正規表現を使う落とし穴

多くの人が最初に書くのがこの版です。ショップの2ページ目に対して実行しました。

import re, urllib.request

html = urllib.request.urlopen("http://127.0.0.1:18160/ja/products?page=2", timeout=10).read().decode()

# attempt 1: "simple" regex
print("1:", re.findall(r'<a href="(.*?)">(.*?)</a>', html))
# attempt 2: let . match newlines
print("2:", re.findall(r'<a href="(.*?)">(.*?)</a>', html, re.S)[:2])
# attempt 3: greedy by accident
m = re.search(r'<a href="(.*)">', html, re.S)
print("3:", len(m.group(1)), "chars captured as one href")
# attempt 4: the site adds one attribute (same data, same browser view)
v2 = html.replace('<a href=', '<a class="p-link" href=')
print("4:", re.findall(r'<a href="(.*?)">(.*?)</a>', v2, re.S))
1: []
2: [('product/105', '\n      27インチモニター\n    '), ('product/106', '\n      Webカメラ HD\n    ')]
3: 621 chars captured as one href
4: []
  1. 何も取れません。. は改行に一致せず、商品名は独立した行にあるからです。
  2. re.S を付けると一致しますが、インデントまで付いてくるので結局掃除が必要です。
  3. ? を1つ消すと .* が最初のリンクから最後のリンクまで621文字を飲み込みます。
  4. サイトが class="p-link" を足しただけ(見た目は何も変わらない)で、正規表現はリンクを0件しか見つけず、エラーも出ません。

同じ v2 のページに html.parser の抽出器を使うと、こうです。

v2: ['27インチモニター', 'Webカメラ HD', 'デスクライト', 'ノイズキャンセリングヘッドホン']

属性を名前で読むので、順番が変わっても属性が増えても関係ありません。正規表現が役に立つのは、すでに取り出した項目の中(¥2,480 から 2480 を取るなど)で、項目を探すことには向きません。基本のおさらいは正規表現とはをどうぞ。

よくある間違いと実際に起きること

間違い ここで起きたこと 直し方
リクエストした URL とリンクを結合 /product/101 で 404 urljoin(resp.url, href)
a.get("class") == "product" class="product featured" を取りこぼす 分割してから "product" in classes
convert_charrefs=False で handle_data だけ Practice Shop & Co. が割れて & が消える 既定のままにする
<li> に </li> がないページ 0 items, no error 件数を数え、0件なら警告(第6回)
zsh で ? 入りの URL を引用符なしで渡す no matches found URL を引用符で囲む

4行目はもう一度見てください。ブラウザは <li> を自動で閉じるので、壊れたページも画面では普通に見えます。でも html.parser はブラウザではなく、書かれたとおりにタグを報告するだけです。この抽出器は </li> を待ってからアイテムを保存するので、1件も保存せず、例外も出しませんでした。空のリストを返すスクレイパーは、いちばん危険な壊れ方です。

どのページに何を使うか

ページ 使うもの ここで実行したか
JSON がある(API、または <script> 内の JSON) json.loads はい(第1回)
普通の一覧・詳細ページ html.parser(この回) はい
ひどく崩れた HTML、CSS セレクターを使いたい BeautifulSoup や lxml いいえ(未インストール)
取り出したテキストの中の値1つ re はい
読み込み後に JavaScript が作る内容 ページが呼んでいる JSON を探す(第6回) 説明のみ

BeautifulSoup なら状態を自分で管理せず soup.select("li.product") と書けます。パッケージを入れられる環境ならよい選択です。テキストと属性の区別、urljoin、空白の扱いといったこの回の考え方はそのまま使えます。

実測:ページの大きさと解析時間

ショップは ?per_page=N で任意の商品数を1ページに並べられます。10〜10,000商品のページを1回ずつ取得し、それぞれ5回解析した中央値です(Apple M1 Pro、time.perf_counter、日本語カタログ)。

商品数 ページサイズ html.parser 3.9.6 html.parser 3.12.14 正規表現 3.9.6
10 2,084 B 0.47 ms 0.32 ms 0.01 ms
100 19,493 B 4.19 ms 2.61 ms 0.06 ms
1,000 194,574 B 41.32 ms 27.38 ms 0.58 ms
10,000 1,970,459 B 424.42 ms 265.92 ms 6.97 ms
棒グラフ。同じ1万商品のページで、Python 3.9.6 の html.parser は 408.6 ms、Python 3.12.14 の html.parser は 269.3 ms、Python 3.9.6 の正規表現は 6.8 ms。
1万商品のページ1枚(1.86 MB、英語版カタログ)を Apple M1 Pro で解析した時間。5回の中央値。正規表現は約60倍速いが、サイトが属性を1つ足しただけで壊れたのも正規表現のほう。

図は英語カタログのページで測ったもので、日本語版とほぼ同じ傾向です。ポイントは3つ。

  • 時間はページの大きさに比例します。 どのサイズでも 3.9.6 で毎秒約24,000商品(4.6 MB)、3.12.14 で約37,000商品(7 MB 強)。日本語は1文字3バイトなので、同じ商品数でも英語版よりページが約6% 大きくなりました。
  • 新しい Python は同じコードで約1.6倍速い。 書き換えは不要です。
  • 解析はボトルネックではありません。 10商品の普通のページは0.5ミリ秒以下。第1回の1秒間隔を入れれば、実行時間に占める解析は0.05% です。最速のパーサーより、デザイン変更に耐えるパーサーを書きましょう。

正直な限界・バージョンの注意

  • 実行は Python 3.9.6、比較に 3.12.14。抽出結果は両方で同じでした。convert_charrefs の既定値は Python 3.5 から True です。
  • html.parser は木構造を作らず、ブラウザ(HTML5 の構文解析アルゴリズム)のように崩れた入れ子を直すこともなく、JavaScript も実行しません。
  • BeautifulSoup、lxml、html5lib はインストールされていないので実行していません。それらについての記述は測定ではなくドキュメントに基づきます。
  • ページは練習用ショップが生成したものです。本物のページは1商品あたりのマークアップがずっと多く、1商品あたりの解析は遅くなります。持ち帰れるのは「比例する」という形の方です。
  • UTF-8 以外の文字コード(Shift_JIS、EUC-JP)は第6回で扱います。それまでは文字コード判定の講座を参照してください。

演習

1. ProductParser を書き換えずに、各アイテムに在庫数(<span class="stock ...">)を追加してください。

2. ショップの価格文字列を数値にする to_number(text) を書き、2,480円(税込) や全角の 12,800円 でも動くか確かめてください。

3. next_url を使い、ページの間に1秒空けながら全ページの商品を集めてください。

解答

1. サブクラスを作り、在庫の span で項目を立てます。

class StockParser(ProductParser):
    def handle_starttag(self, tag, attrs):
        super().handle_starttag(tag, attrs)
        a = dict(attrs)
        if self.cur is not None and tag == "span" and "stock" in (a.get("class") or "").split():
            self.cur.setdefault("stock", "")
            self.field = "stock"

1ページ目で int() に変換すると [('101', 0), ('102', 5), ('103', 9), ('104', 4)] になりました。

2. 数字と小数点だけ残して変換します。

import re

def to_number(text, decimal="."):
    s = re.sub(r"[^\d" + re.escape(decimal) + "]", "", text)
    return float(s.replace(decimal, ".")) if s else None

for t in ["¥2,480", "2,480円(税込)", "¥12,800", "12,800円"]:
    print(t, "->", to_number(t))
¥2,480 -> 2480.0
2,480円(税込) -> 2480.0
¥12,800 -> 12800.0
12,800円 -> 12800.0

Python の \d は全角数字にも一致し、float() も全角数字を受け付けるので、全角でも 12800.0 になりました。逆に言えば、\d が全角に一致するのを知らずに「半角数字だけのはず」と思い込むと、検証のつもりのコードが素通しになります。金額を保存するときは float より decimal.Decimal が安全です。

3.

import time, urllib.request
from extract import ProductParser, UA

url, items = "http://127.0.0.1:18160/ja/products", []
while url:
    req = urllib.request.Request(url, headers={"User-Agent": UA})
    with urllib.request.urlopen(req, timeout=10) as resp:
        p = ProductParser(resp.url)
        p.feed(resp.read().decode())
    items += p.items
    print(url, len(p.items))
    url = p.next_url
    if url:
        time.sleep(1)
print("total", len(items))
http://127.0.0.1:18160/ja/products 4
http://127.0.0.1:18160/ja/products?page=2 4
total 8

このループに足りないもの(最大ページ数、429 応答の扱い、リトライ)は第3回で加えます。

次へ

よくある質問

取り出したリンクが正しく見えるのに 404 になるのはなぜですか?

間違ったベース URL と結合している可能性が高いです。ショップは / を /ja/products へリダイレクトしており、href="product/101" をリクエストした URL と結合すると /product/101(404)、実際に到着した resp.url と結合すると /ja/product/101(200)になりました。urljoin には必ず最終的な URL を渡します。

HTML に正規表現を使うのは本当にそんなに悪いのですか?

特定の1ページなら動きますし速いです。今回の測定では html.parser の約60倍でした。ただし単純なパターンは re.S を付けるまで何も見つけず、付けると空白ごと取り込み、サイトが href の前に class="p-link" を足しただけでリンクを1件も見つけなくなりました。html.parser はその変更でも動き続けました。

html.parser の convert_charrefs は何をする設定ですか?

既定の convert_charrefs=True では、&amp; や &quot; などの文字参照が handle_data に渡る文字列の中で元の文字に戻ります。False にすると文字列が分割され、文字参照は handle_entityref に回るので、handle_data しか書いていない抽出器では文字が黙って消えます。Practice Shop &amp; Co. は 'Practice Shop ' と ' Co.' に割れました。

  • #スクレイピング
  • #初級
  • #Python
  • #html.parser
  • #urljoin
  • #正規表現

t.laboratory

実際に作って運用しているアプリ・サーバー・サービスで起きた問題と、その解決に使ったコードを記録する技術ブログです。

運営者情報 · アプリ

↑