読者です 読者をやめる 読者になる 読者になる

YAMAGUCHI::weblog

土足で窓から失礼いたします。今日からあなたの息子になります。 当年とって92歳、下町の発明王、エジソンです。

正規表現モジュールからはじめるスクレイピング

Python

はじめに

こんにちは、Python界のつけ麺大王@麻布十番です。先日はpyqueryを使ってWebでスクレイピングをする方法をご紹介いたしましたが、そもそも昨今のプログラミング言語ではたいてい正規表現が使えるようになっていまして、単純なものならこれを使ってスクレイピングするのもいいですよねー、っつー話。ほんの10分やってみればわかると思います。

リンク

全部標準ドキュメントの正規表現操作のところに載ってます。日本語ドキュメントはローカルに置いときましょう。

確認したい点

  • パターンマッチさせようとしている文字列はstrかUnicode
    • パターンもUnicodeにしなければいけなくなる
    • re.UNICODEを付ける
  • VERBOSEを使うのかどうか
    • 長すぎるパターンでは積極的に使うべき
  • エスケープすべき文字は?
    • re.VERBOSE内で空白の扱いに注意(基本しておけばいい)
    • []の中ではエスケープしなくてもよいものがある
  • パターン内で取得したいグループは何か
    • グルーピングも括弧で囲って終わりではない
  • 繰り返しの数に注意
    • 基本はgreedy matchになっている

ユースケース

文字列から複数の要素を取り出したい

どういうことかというと例えばいま扱ってるデータとしてこんな感じのデータがあるとします。なんか全部おんなじようで微妙に違う。

hoge 2011/01/16(日) 11:50:48 user:o$ka9jdk group:i%dK
foo 2011/01/17(月) 09:30:35 user:83hau3j3H group:u&8k3J
spam 2011/01/17(月) 10:15:46.77 user:i8ikj@27
xxxx 2011/01/18(火) 15:46:04
piyo 2011/01/18(火) 07:20:46 user:ik#ju87z

でもこれを見て分かるのが、このデータはこんな感じで並んでる。

YYYY/MM/DD HH:mm:SS.ss user:<ユーザID> group:<グループID>
ただしss、user、groupはないかもしれない。

この時次のデータを引っ張ってきたいとする。

  • 日付
  • 時刻 (ただし小数点以下はいらない)
  • ユーザID (英数字,$,#,@で8桁から10桁。なければ空。)
  • グループID (英数字,%,&で4桁から6桁。なければ空。)

これも正規表現を使えば一発でできる。やってみましょう。

data = [u"hoge 2011/01/16(日) 11:50:48 user:o$ka9jdk group:i%dK",
        u"foo 2011/01/17(月) 09:30:35 user:83hau3j3H group:u&8k3J",
        u"spam 2011/01/17(月) 10:15:46.77 user:i8ikj@27",
        u"xxxx 2011/01/18(火) 15:46:04",
        u"piyo 2011/01/18(火) 07:20:46 user:ik#ju87z"]

import re

pattern = re.compile(u"""
(?P<date>\d{4}/\d{2}/\d{2}\((?:月|火|水|木|金|土|日)\)) #日付
\ (?P<time>\d{2}:\d{2}:\d{2})(?:\.\d{2})?         #時刻
(?:\ user\:(?P<user>[\w\$#@]{8,10}))?             #ユーザ
(?:\ group\:(?P<group>[\w%&]{4,6}))?              #グループ
""", re.VERBOSE)

for d in data:
    matched = pattern.search(d)
    if matched:
        print matched.groupdict()
    else:
        print "no match\n"

これを実行すると結果はこうなります。

{u'date': u'2011/01/16(\u65e5)', u'group': u'i%dK', u'user': u'o$ka9jdk', u'time': u'11:50:48'}
{u'date': u'2011/01/17(\u6708)', u'group': u'u&8k3J', u'user': u'83hau3j3H', u'time': u'09:30:35'}
{u'date': u'2011/01/17(\u6708)', u'group': None, u'user': u'i8ikj@27', u'time': u'10:15:46'}
{u'date': u'2011/01/18(\u706b)', u'group': None, u'user': None, u'time': u'15:46:04'}
{u'date': u'2011/01/18(\u706b)', u'group': None, u'user': u'ik#ju87z', u'time': u'07:20:46'}

ちゃんと取れてますね!ここのパターンで出てきてる便利パターンは次のとおり。

  • re.VERBOSE
    • これを使うと上にあるようにパターンを複数行にわたって記述し、かつコメントを添えることが出来ます。空白と#以降が無視される仕様です
  • (?P
  • (?:...)
    • これを使うとこのグループでマッチした文字列は後から参照されません。findallとかしたときに結果に出てくるのはウザイなという部分をグループ化するのに使います。
日本語(マルチバイト)を扱う場合

Pythonではマルチバイトを扱う場合は当然Unicode文字列を扱うわけですが、それを対象に正規表現使いたいときはパターンもUnicode文字列にしないとだめ。strとUnicodeだから別のオブジェクトなんだけど、ついついうっかりしてしまう。

unicodeptn = re.compile(u"\d{4}/\d{2}/\d{2}\((月|火|水|木|金|土|日)\) \d{2}:\d{2}:\d{2}")
unicodeptn.search(u"ただいまの日時は2011/01/16(日) 12:04:30です")
re.VERBOSEを使う場合のスペースの扱い

たとえばHTMLをパースしていてaタグを取得したい時にこんなパターンがあるとします。

import re
pattern = ur'<a href="(?P<link>https?://[\S.-_]+/(?:[\S.-_/]*))"(?:\ target="_blank")?>(?P<title>.*)</a>'
lines = [u"<html>",
         u"<head><title>test</test></head>",
         u"<body>",
         u"これはテストですよ。たとえば本文中にハイパーリンクがある場合です",
         u'たとえば<a href="http://python.org/">Python</a>です。',
         u"</body>",
         u"</html>"]
for l in lines:
    m = re.search(pattern, l, re.UNICODE)
    if m: print m.groupdict()

すでに問題が見えているひとがいるかもしれまえんが、これを実行してみましょう。そうすると次の結果が得られます。ちゃんと取れていますね。

{u'link': u'http://python.org/', u'title': u'Python'}

見づらいのでこれをre.VERBOSEを使ってパターンにします。先程のpattern変数を次のように変更します。

pattern2 = ur"""
<a href="                                  # aタグ開始
(?P<link>https?://[\S.-_]+/(?:[\S.-_/]*))  # href属性取得
"(?:\ target="_blank)?>                    # target属性は関係ない
(?P<title>.*)                              # リンク先ラベル
</a>                                       # aタグ終了
"""
for l in lines:
    m = re.search(pattern2, l, re.UNICODE | re.VERBOSE)
    if m: print m.groupdict()

これを実行してみるとわかりますがなにも起きません。何が悪いのか?実はaタグの開始とhrefの間の空白が無視されてしまっています。pattern2_modとして空白をエスケープしてみましょう。

pattern2_mod = ur"""
<a\ href="                                 # aタグ開始
(?P<link>https?://[\S.-_]+/(?:[\S.-_/]*))  # href属性取得
"(?:\ target="_blank)?>                    # target属性は関係ない
(?P<title>.*)                              # リンク先ラベル
</a>                                       # aタグ終了
"""
for l in lines:
    m = re.search(pattern2_mod, l, re.UNICODE | re.VERBOSE)
    if m: print m.groupdict()

こうするとちゃんと結果を得られます。

最小限のパターンで

たとえばHTML内を1行ずつパターンマッチさせて各行の最初のリンクURLを見つけたいとしたときに、aタグを使って次のようなパターンを書いたとします。

a_tag_ptn = ur"""
<a\ href="(?P<url>https?://[\w\.]+[\w\.\+\?/$,;:&=!*~@#_()]*)">  # aタグ開始
(?P<title>.*)
</a>
"""
for l in lines:
    m = re.search(a_tag_ptn, l, re.UNICODE | re.VERBOSE)
    if m: print m.groupdict()

このときlに次のような1行があった場合、結果は予想に反したものになってしまいます。

<a href="http://www.python.org/">Python</a> and <a href="http://pypi.python.org/pypi">PyPI</a><br>

この部分の結果は次のようになります。

{u'url': u'http://www.python.org/', u'title': u'Python</a> and <a href="http://pypi.python.org/pypi">PyPI'}

これは繰り返しパターンがデフォルトで貪欲にマッチするように設定されているからです。この場合は1回だけマッチして欲しいので繰り返し(a_tag_ptnではtitleグループの*)の後に?を付けます。

a_tag_ptn_mod = ur"""
<a\ href="(?P<url>https?://[\w\.]+[\w\.\+\?/$,;:&=!*~@#_()]*)">  # aタグ開始
(?P<title>.*?)
</a>
"""

これで結果は予想通りになります。

{u'url': u'http://www.python.org/', u'title': u'Python'}

もしページ内のすべてのURLを見つけたいなら、まずaタグのペアのパターンでfindallしたあとに、マッチしたそれぞれのパターンでいまのような処理を行えばいいですね。その場合にもaタグを探すときには貪欲にマッチしないように指定しないといけません。

おわりに

自分も正規表現マスターな訳ではないですが、ある程度知っておけば大抵の文字列処理は出来るようになります。もちろんパターンが複雑になればなるほど処理が重くなるので、別途モジュールを用いるかどうかはその時の判断で。