9 正規表現によるテキストパターンマッチング

CTRL-Fを押して探したい語を入力してテキストを検索するという作業には、すでにおなじみかもしれません。正規表現はそれよりもさらに強力です。(固定したテキストだけでなく)検索したいテキストのパターンを指定できるのです。アメリカ合衆国あるいはカナダの電話番号を検索したいとしたら、3桁-3桁-4桁の形になっているので、415-555-1234は電話番号だけれども$4,155,551,234は電話番号ではないとわかります。

私たちは日々、あらゆるテキストのパターンを識別しています。例をいくつか挙げると、メールアドレスなら途中に@がありますし、アメリカ合衆国の社会保障番号ならハイフンが2つある9桁の数字ですし、URLならドット(.)とスラッシュ(/)が入っていることが多いですし、英語のニュースの見出しならそれぞれの単語の語頭が大文字になっていますし、ソーシャルメディアのハッシュタグは#で始まりスペースを含みません。

正規表現は便利なのですが、非プログラマで正規表現を知っている人はほとんどいません。現代のテキストエディタやワープロには、たいてい正規表現で検索や置換ができる機能が備わっているにもかかわらずです。正規表現を使うと、ソフトウェアのユーザーだけでなく、プログラマの時間を大いに節約できます。実際、Guardian紙の記事“Here’s What ICT Should Really Teach Kids: How to Do Regular Expressions”で、技術ジャーナリストのCory Doctorowは、プログラミングよりも前に正規表現を教えるべきだと主張しています。

[正規表現を]知れば、問題を3000ステップで解いていたのを、3ステップで解けるようになるというくらいの違いがあります。専門家なら数回のキー操作で解決できる問題に対して、他の人は数日かけて退屈で間違えやすい作業に苦労して取り組みます。

本章では、正規表現を使わずにテキストのパターンを探すプログラムを書いてから、正規表現を使ってコードをシンプルにしていきます。正規表現での基本的なマッチについて説明してから、文字列置換や文字クラスの自作など、もっと強力な機能に進みます。正規表現の暗号のような構文ではなく平易な英語で表現できるHumreモジュールの使い方も説明します。

正規表現を使わずにテキストのパターンを探す

文字列中からアメリカ合衆国の電話番号を探したいとします。3桁の数字、ハイフン、3桁の数字、ハイフン、4桁の数字という形です。例えば、415-555-4242です。

文字列がこのパターンにマッチするかどうかを調べ TrueまたはFalseを返す、is_phone_number()という名前の関数を書きましょう。新しいファイルエディタウィンドウを開いて、以下のコードをisPhoneNumber.pyという名前で保存してください。

def is_phone_number(text):
  ❶ if len(text) != 12:  # 電話番号はぴったり12文字
        return False
    for i in range(0, 3):  # 最初の3文字は数字
      ❷ if not text[i].isdecimal():
            return False
  ❸ if text[3] != '-':  # 4文字目はハイフン
        return False
    for i in range(4, 7): # 次の3文字は数字
      ❹ if not text[i].isdecimal():
            return False
  ❺ if text[7] != '-':  # 8文字目はハイフン
        return False
    for i in range(8, 12):  # 次の4文字は数字
      ❻ if not text[i].isdecimal():
            return False
  ❼ return True

print('Is 415-555-4242 a phone number?', is_phone_number('415-555-4242'))
print(is_phone_number('415-555-4242'))
print('Is Moshi moshi a phone number?', is_phone_number('Moshi moshi'))
print(is_phone_number('Moshi moshi'))

このプログラムを実行すると、次のように出力されます。

Is 415-555-4242 a phone number?
True
Is Moshi moshi a phone number?
False

is_phone_number()関数では、textの文字列が電話番号であるかどうかを判定するために、いくつかのチェックを行っています。これらのチェックのうちどれか一つでも失敗すれば、関数はFalseを返します。まず、文字列がぴったり12文字であるかチェックします(❶)。次に、isdecimal()文字列メソッドを呼び出して、市外局番(textの最初の3文字)が数字のみで構成されているかをチェックします(❷)。残りのコードでは、文字列が電話番号のパターンに従っているかをチェックします。市外局番のあとにハイフンがあるか(❸)、その次にさらに3桁の数字があるか(❹)、さらにハイフンがあるか(❺)、最後に4桁の数字があるか(❻)のチェックです。すべてのチェックを通過したら、Trueを返します(❼)。

is_phone_number()を'415-555-4242'という引数で呼び出すと、Trueが返されます。is_phone_number()を'Moshi moshi'という引数で呼び出すと、Falseが返されます。'Moshi moshi'は12文字ではないので、最初のチェックで引っかかります。

長い文字列の中から電話番号を見つけたければ、パターンを適用する場所を決めるコードをさらに書く必要があります。最後のprint()関数呼び出しを以下のコードに書き換えてisPhoneNumber.pyという名前で保存してください。

message = 'Call me at 415-555-1011 tomorrow. 415-555-9999 is my office.'
for i in range(len(message)):
  ❶ segment = message[i:i+12]
  ❷ if is_phone_number(segment):
        print('Phone number found: ' + segment)
print('Done')

このプログラムを実行すると、出力は次のようになります。

Phone number found: 415-555-1011
Phone number found: 415-555-9999
Done

forループでは、messageから新しく12文字切り取ってsegmentに代入し、反復処理します(❶)。例えば、最初の反復では、i が0であり、segmentにはmessage[0:12]が代入されます(文字列は'Call me at 4')になります)。次の反復では、iが1であり、segmentにはmessage[1:13]が代入されます(文字列は'all me at 41'になります)。つまり、forループの反復では、segmentが順に次の値になります。

'Call me at 4'
'all me at 41'
'll me at 415'
'l me at 415-'

最後は's my office.'になります。

ループのコードではsegmentをis_phone_number()に渡し、電話番号のパターンにマッチするかどうかをチェックしています(❷)。マッチすればその部分の文字列を表示します。messageの処理が終わればDoneと表示します。

この例ではmessageの文字列が短かったですが、たとえ数百万字であってもプログラムの実行には1秒もかからないでしょう。正規表現で電話番号を探す似たようなプログラムも1秒もかかりません。それでも、正規表現を使うと、こうしたプログラムを素早く書けます。

正規表現を使ってテキストのパターンを探す

先ほど説明した電話番号探索プログラムは動作しますが、コード量が多いです。is_phone_number()関数は17行ですが、1つの電話番号形式しか探せません。415.555.4242 や (415) 555-4242のような形式はどうでしょうか。415-555-4242 x99のような内線番号がある形式はどうでしょうか。is_phone_number()関数はそれらを見つけることはできません。こうした追加的なパターンに対応するためのコードをさらに書くこともできますが、もっと簡単なやり方があります。

正規表現(regexes)は、テキストのパターンを記述するミニ言語です。例えば、正規表現の\dは0から9までの10進法の数値を表します。Pythonでは、r'\d\d\d-\d\d\d-\d\d\d\d'という正規表現の文字列で、3桁の数字、ハイフン、3桁の数字、ハイフン、4桁の数字という形の、先ほどのis_phone_number()関数が対象としていたテキストのパターンにマッチします。ほかの文字列は、正規表現のr'\d\d\d-\d\d\d-\d\d\d\d'にマッチしません。

正規表現はさらに洗練させられます。例えば、パターンのあとに波かっこの中に3を書けば({3})、「このパターンを3回繰り返すものにマッチ」します。先ほどの正規表現を短く書いたr'\d{3}-\d{3}-\d{4}'も電話番号のパターンにマッチします。

正規表現の文字列は、rをつけたraw文字列として書くことが多いです。正規表現ではバックスラッシュを多用するので、そのほうが便利です。raw文字列を使わなければ、'\\d'のような表記にしなければなりません。

正規表現の詳細に立ち入る前に、Pythonで正規表現を使う方法をまとめます。'My number is 415-555-4242'のようなテキスト文字列からアメリカ合衆国の電話番号を見つけるために使う、r'\d{3}-\d{3}-\d{4}'という正規表現文字列を例に説明します。以下の4ステップでPythonの正規表現を使えます。

  1. reモジュールをインポートします。

  2. re.compile()に正規表現文字列を渡してPatternオブジェクトを取得します。

  3. Patternオブジェクトのsearch()メソッドにテキスト文字列を渡してMatchオブジェクトを取得します。

  4. Matchオブジェクトのgroup()メソッドを呼び出し、マッチしたテキスト文字列を取得します。

対話型シェルでは、これらの手順は次のようになります。

>>> import re
>>> phone_num_pattern_obj = re.compile(r'\d{3}-\d{3}-\d{4}')
>>> match_obj = phone_num_pattern_obj.search('My number is 415-555-4242.')
>>> match_obj.group()
'415-555-4242'

Pythonの正規表現の関数はすべてreモジュールに入っています。本章のほとんどの例ではreモジュールが必要ですから、プログラムの冒頭で忘れずにインポートしてください。そうしないとNameError: name 're' is not definedエラーメッセージが表示されます。どのモジュールでもそうですが、一つのプログラム中あるいは対話型セッション中で一回インポートすれば足ります。

正規表現文字列をre.compile()に渡すとPatternオブジェクトが返されます。Patternオブジェクトのコンパイルは一度だけで大丈夫です。以後はPatternオブジェクトのsearch()メソッドを何度でも呼び出せます。

Patternオブジェクトのsearch()メソッドは、渡された文字列を探索して正規表現にマッチするかどうかを調べます。正規表現のパターンを文字列中に発見できなければsearch()メソッドはNoneを返します。パターンを発見したら、search()メソッドはMatchオブジェクトを返します。そのオブジェクトのgroup()メソッドでマッチしたテキストの文字列を取り出せます。

注記

対話型シェルでコード例を試してみることをおすすめしますが、ウェブベースの正規表現テストを活用してもよいでしょう。入力したテキストに正規表現がどうマッチするか確かめられます。 https://pythex.org と https://regex101.comを推奨します。プログラム言語ごとに微妙に異なる正規表現の構文を用いるので、これらのウェブサイトではPythonを選んでください。

正規表現の構文

Pythonで正規表現を使う基本的な手順がわかりましたから、正規表現の構文を幅広く学んでいきましょう。この節では、かっこで正規表現の要素をグループ化する方法、特別な文字をエスケープする方法、パイプで選択的にマッチさせる方法、findall()メソッドですべてのマッチを返す方法を説明します。

かっこでグループ化する

例えば電話番号のうちで市外局番に別の処理をするなど、マッチしたテキストの一部を取り出したい場合があります。r'(\d\d\d)-(\d\d\d-\d\d\d\d)'のように丸かっこで囲むと正規表現文字列をグループ化できます。Matchオブジェクトのgroup()メソッドでマッチしたテキストをグループごとに取得できます。

正規表現文字列中の最初のかっこがグループ1で、2番目のかっこがグループ2です。整数の1や2をgroup()メソッドに渡すと、マッチしたテキストの対応する部分を取得できます。group()メソッドに0を渡すか何も渡さなければ、マッチしたテキスト全体が返されます。以下の式を対話型シェルに入力してみてください。

>>> import re
>>> phone_re = re.compile(r'(\d\d\d)-(\d\d\d-\d\d\d\d)')
>>> mo = phone_re.search('My number is 415-555-4242.')
>>> mo.group(1)  # マッチしたテキストの最初のグループを返す
'415'
>>> mo.group(2)  # マッチしたテキストの2番目のグループを返す
'555-4242'
>>> mo.group(0)  # マッチしたテキスト全体を返す
'415-555-4242'
>>> mo.group()  # これもマッチしたテキスト全体を返す
'415-555-4242'

すべてのグループをまとめて取得したければ、groups()メソッドを使います(複数形になっていることに注意してください)。

>>> mo.groups()
('415', '555-4242')
>>> area_code, main_number = mo.groups()
>>> print(area_code)
415
>>> print(main_number)
555-4242

mo.groups()は複数の値が中に入ったタプルを返しますから、area_code, main_number = mo.groups()の行のように、多重代入を使って複数の変数に代入できます。

エスケープ文字

丸かっこは正規表現の中でグループを作り、テキストのパターンであるとは解釈されません。そうすると、テキスト中で丸かっこにマッチさせたいときにはどうすればよいのでしょうか。例えば、マッチさせたい電話番号は、'(415) 555-4242'のように丸かっこ内に市外局番が書かれているかもしれません。

この場合、(と)をバックスラッシュでエスケープする必要があります。\(と\)で丸かっこをエスケープすれば、マッチさせたいパターンの一部であると解釈されます。以下の式を対話型シェルに入力してみてください。

>>> pattern = re.compile(r'(\(\d\d\d\)) (\d\d\d-\d\d\d\d)')
>>> mo = pattern.search('My phone number is (415) 555-4242.')
>>> mo.group(1)
'(415)'
>>> mo.group(2)
'555-4242'

re.compile()に渡されるraw文字列の\(と\)のエスケープ文字は、丸かっこにマッチします。正規表現では、以下の文字(記号)には特別な意味があります。

$ () * + - . ? [\] ^ {|}

これらの文字(記号)をテキストのパターンの一部にしたければ、バックスラッシュでエスケープする必要があります。

\$ \(\) \* \+ \- \. \? \[\\ \] \^ \{\| \}

正規表現でエスケープしていない丸かっこ(と)を、エスケープした丸かっこ\(と\)に取り違えていないかを、必ずダブルチェックしてください。“missing)”や“unbalanced parenthesis,”のようなエラーメッセージが表示されたら、以下の例のように、グループ化のかっこを閉じ忘れている可能性があります。

>>> import re
>>> re.compile(r'(\(Parentheses\)')
Traceback (most recent call last):
--snip--
re.error: missing), unterminated subpattern at position 0

エラーメッセージを読むと、文字列r'(\(Parentheses\)'のインデックス0にある開き丸かっこに対応する閉じ丸かっこが欠けていることがわかります。本章の最後のほうで説明するHumreモジュールを使うと、こうしたミスを防げます。

選択的なグループからのマッチ

正規表現では、パイプと呼ばれる|を選択演算子として用います。複数の表現のうちどれか一つにマッチさせられます。例えば、正規表現r'Cat|Dog'は、'Cat'か'Dog'のどちらかにマッチします。

正規表現の一部に選択演算子を使うことで、複数のパターンにマッチさせられます。例えば、'Caterpillar'、'Catastrophe'、'Catch'、'Category'という文字列にマッチさせたいとします。これらの文字列はすべてCatで始まっていますから、その共通部分を一度だけ指定できるとうれしいです。共通部分をくくりだして丸かっこ内でパイプを使うと、それを実現できます。以下の式を対話型シェルに入力してみてください。

>>> import re
>>> pattern = re.compile(r'Cat(erpillar|astrophe|ch|egory)')
>>> match = pattern.search('Catch me if you can.')
>>> match.group()
'Catch'
>>> match.group(1)
'ch'

match.group()メソッドを呼び出すと、マッチしたテキスト全体である'Catch'が返されます。match.group(1)はテキスト内で最初の丸かっこのグループでマッチした部分'ch'だけを返します。パイプとグループ化を活用すれば、正規表現でマッチさせたい選択的なパターンを指定できます。

パイプ記号にマッチさせたい場合は、\|のようにバックスラッシュでエスケープしてください。

すべてのマッチを返す

Patternオブジェクトには、search()メソッドのほかに、findall()メソッドがあります。search()は検索文字列が最初にマッチしたMatchオブジェクトを返すのに対し、findall()メソッドは検索文字列のすべてのマッチを返します。

findall()を使う際に覚えておくべきことが一つあります。。このメソッドは、正規表現にグループがなければ文字列のリストを返します。以下の式を対話型シェルに入力してみてください。

>>> import re
>>> pattern = re.compile(r'\d{3}-\d{3}-\d{4}')  # グループなしの正規表現
>>> pattern.findall('Cell: 415-555-9999 Work: 212-555-0000')
['415-555-9999', '212-555-0000']

正規表現にグループがあれば、findall()はタプルのリストを返します。それぞれのタプルが一つのマッチを表し、グループの文字列がタプルに含まれます。対話型シェルに次のコードを入力してこの動作を確かめてみましょう(今回は正規表現を丸かっこでグループ化しています)。

>>> import re
>>> pattern = re.compile(r'(\d{3})-(\d{3})-(\d{4})')  # グループありの正規表現
>>> pattern.findall('Cell: 415-555-9999 Work: 212-555-0000')
[('415', '555', '9999'), ('212', '555', '0000')]

findall()は重なる部分でマッチしないことも覚えておいてください。例えば、正規表現文字列r'\d{3}'で3つの数字にマッチさせると、'1234'の最初の3つの数字にマッチしますが、最後の3つの数字にはマッチしません。

>>> import re
>>> pattern = re.compile(r'\d{3}')
>>> pattern.findall('1234')
['123']
>>> pattern.findall('12345')
['123']
>>> pattern.findall('123456')
['123', '456']

'1234'の最初の3つの数字が '123'としてマッチするので、'234'はさらなるマッチ対象には含まれません。r'\d{3}'というパターンに合致するように見えてもこういう結果になります。

質指定構文:どの文字にマッチさせるか

正規表現は2つの部分に分解できます。どの文字にマッチさせるかという質指定子と、どれだけの量にマッチさせるかという量指定子です。例のr'\d{3}-\d{3}-\d{4}'という電話番号の正規表現文字列であれば、r'\d'と'-'の部分が質指定子で、'{3}'と'{4}'が量指定子です。まず質指定子から説明します。

文字クラスと否定文字クラス

ここまでの例で見てきたようにマッチさせる一つの文字を指定することができますが、角かっこ内で複数の文字を指定することもできます。これを文字クラスと呼びます。例えば、[aeiouAEIOU]という文字クラスは、小文字と大文字のすべての母音にマッチします。a|e|i|o|u|A|E|I|O|Uと書くのと同じですが、文字クラスのほうが書きやすいです。以下の式を対話型シェルに入力してみてください。

>>> import re
>>> vowel_pattern = re.compile(r'[aeiouAEIOU]')
>>> vowel_pattern.findall('RoboCop eats BABY FOOD.')
['o', 'o', 'o', 'e', 'a', 'A', 'O', 'O']

ハイフンを使って文字や数字の範囲を含めることもできます。例えば、[a-zA-Z0-9]という文字クラスは、アルファベットのすべての小文字と大文字と数字にマッチします。

角かっこ内では正規表現で特別な意味を持つ記号が特別な意味に解釈されないことに注意してください。角かっこ内では丸かっこなどをエスケープしなくてもよいということです。例えば、文字クラス[()]は開き丸かっこと閉じ丸かっこのどちらかにマッチします。[\(\)]と書く必要はありません。

文字クラスの開き角かっこの直後にキャレット(^)を置くと、否定文字クラスを作れます。否定文字クラスは、その文字クラスに含まれない文字すべてにマッチします。対話型シェルで次のように入力してみてください。

>>> import re
>>> consonant_pattern = re.compile(r'[^aeiouAEIOU]')
>>> consonant_pattern.findall('RoboCop eats BABY FOOD.')
['R', 'b', 'C', 'p', ' ', 't', 's', ' ', 'B', 'B', 'Y', ' ', 'F', 'D', '.']

すべての母音にマッチするのではなく、すべての母音以外の文字にマッチします。スペースや改行や句読記号や数字にもマッチする点に注意してください。

短縮文字クラス

先の電話番号の正規表現の例では、\dが10進数のすべての数字を表すと説明しました。\dは0|1|2|3|4|5|6|7|8|9や[0-9]の短縮記法です。表9-1に示すように、短縮文字クラスはほかにもあります。

表 9-1:一般的な短縮文字クラス

短縮文字クラス

表現内容

\d

0から9までのすべての数字

\D

0から9までの数字以外のすべて

\w

すべての文字と数字とアンダースコア(単語にマッチするものだと考えてください)

\W

文字と数字とアンダースコア以外のすべて

\s

スペースとタブと改行(スペース文字だと考えてください)

\S

スペースとタブと改行以外のすべて

\dは数字にマッチして\wは数字と文字とアンダースコアにマッチしますが、文字だけにマッチする短縮文字クラスは存在しません。[a-zA-Z]という文字クラスを使うことができますが、これは'é'のようなアクセント文字や非ローマアルファベット文字にはマッチしません。また、バックスラッシュをエスケープするために、r'\d'のようにraw文字列を使うようにしてください。

対話型シェルで次のように入力してみてください。

>>> import re
>>> pattern = re.compile(r'\d+\s\w+')
>>> pattern.findall('12 drummers, 11 pipers, 10 lords, 9 ladies, 8 maids, 
7 swans, 6 geese, 5 rings, 4 birds, 3 hens, 2 doves, 1 partridge')
['12 drummers', '11 pipers', '10 lords', '9 ladies', '8 maids', '7 swans', '
6 geese', '5 rings', '4 birds', '3 hens', '2 doves', '1 partridge']

正規表現\d+\s\w+は、1つ以上の数字(\d+)にスペース(\s)が続き、さらに文字か数字かアンダースコア(\w+)が続くテキストにマッチします。findall()メソッドは正規表現のパターンにマッチする文字列をすべてリストで返します。

ドットで全てにマッチさせる

ドット(.)を正規表現文字列で使うと、改行文字以外のすべての文字にマッチします。対話型シェルで次のように入力してみてください。

>>> import re
>>> at_re = re.compile(r'.at')
>>> at_re.findall('The cat in the hat sat on the flat mat.')
['cat', 'hat', 'sat', 'lat', 'mat']

ドットは1文字だけにマッチするのを忘れないでください。上記の例flatがlatにしかマッチしないのはそのためです。ドットそのものにマッチさせたい場合は、\.のようにバックスラッシュでエスケープしてください。

マッチ対象に注意する

正規表現は、良くも悪くも、指定したパターンに正確にマッチします。ここでは文字クラスで誤解しやすい点を取り上げます。

  • [A-Z]や[a-z]の文字クラスは、大文字あるいは小文字のアルファベットにマッチしますが、両方にマッチするわけではありません。大文字と小文字の両方にマッチさせるには[A-Za-z]と書きます。
  • 文字クラス[A-Za-z]はアクセント記号のないアルファベットにマッチします。例えば、正規表現文字列r'First Name: ([A-Za-z]+)'は“First Name: ”のあとにアクセントのないアルファベットが来るものにマッチします。歌手Sinéad O’Connorの名前ではéの手前までしかマッチしません。グループ化されるのは'Sin'になります。
  • 文字クラス\wは、アクセントのあるアルファベットや非アルファベットも含めて、すべての文字にマッチします。ただし、数字とアンダースコアにもマッチするので、r'First Name: (\w+)'は意図した以上のものにマッチする可能性があります。
  • 文字クラス\wはすべての文字にマッチしますが、正規表現文字列r'Last Name: (\w+)'はSinéad O’Connorの名前のアポストロフィの手前までしかマッチしません。グループ化されるのは'O'になります。
  • クォート記号(' " ‘ ’ “ ”)はそれぞれ別物として扱われるので、別々に指定しなければなりません。

現実世界のデータは複雑です。Sinéad O’Connorの名前をうまく扱えたとしても、Jean-Paul Sartreの名前ではハイフンがあるのでうまくいかないかもしれません。

もちろん、ソフトウェアが名前を正常に処理できないときには、名前が悪いのではなくソフトウェアが悪いです。人の名前が異常だと言うことはできません。https://www.kalzumeus.com/2010/06/17/falsehoods-programmers-believe-about-names/で読めるPatrick McKenzieの記事“Falsehoods Programmers Believe About Names”にもっと詳しく書かれています。この記事は、思い込みのせいでソフトウェアが日付、タイムゾーン、通貨、郵便番号、性別、空港コードなどをうまく処理できない、「プログラマの誤った信念」というジャンルを生み出しました。https://youtu.be/PYYfVqtcWQYでCarina C. Zona’s 2015 PyCon talkの“Schemas for the Real World”をご覧ください。

量指定子:どれだけの量にマッチさせるか

正規表現文字列では、質指定子のあとに量指定子をつけることで、どれだけの量にマッチさせるかを指定します。先の電話番号の例で言うと、\dのあとの{3}は3桁の数字にマッチさせます。質指定子のあとに量指定子がなければ、1つだけになります。r'\d'はr'\d{1}'と同じだと考えることができます。

あってもなくてもマッチするパターン

指定したパターンがあってもなくてもマッチさせたいことが時々あります。質指定子に0または1の量を指定するということです。質指定子に?をつけるとその意味になります。対話型シェルで次のように入力してみてください。

>>> import re
>>> pattern = re.compile(r'42!?')
>>> pattern.search('42!')
<re.Match object; span=(0, 3), match='42!'>
>>> pattern.search('42')
<re.Match object; span=(0, 2), match='42'>

正規表現の?の部分は、!があってもなくてもいいことを示します。よって、これは42!(エクスクラメーションマークあり)と42(エクスクラメーションマークなし)の両方にマッチします。

そろそろお気づきの頃かと存じますが、正規表現の構文は特別な意味を持つ記号に依存しているので、読みづらいです。クエスチョンマーク(?)には特別な意味がありますが、エクスクラメーションマーク(!)には特別な意味がありません。r'42!?'は、'42'のあとに'!'があってもなくてもマッチするという意味になりますが、r'42?!'は'4'と'!'の間に'2'があってもなくてもマッチするという意味になります。

>>> import re
>>> pattern = re.compile(r'42?!')
>>> pattern.search('42!')
<re.Match object; span=(0, 3), match='42!'>
>>> pattern.search('4!')
<re.Match object; span=(0, 2), match='4!'>
>>> pattern.search('42') == None  # マッチしない
True

複数の文字があってもなくてもマッチさせるには、その複数の文字をグループ化して、その直後に?を置きます。先の電話番号の例なら、?を使って市外局番があってもなくてもマッチさせることができます。以下の式を対話型シェルに入力してみてください。

>>> pattern = re.compile(r'(\d{3}-)?\d{3}-\d{4}')
>>> match1 = pattern.search('My number is 415-555-4242')
>>> match1.group()
'415-555-4242'

>>> match2 = pattern.search('My number is 555-4242')
>>> match2.group()
'555-4242'

?は「このクエスチョンマークの直前のグループがあってもなくてもマッチする」という意味になります。

クエスチョンマークにマッチさせたければ、\?のようにエスケープしてください。

0個以上という量指定子

アスタリスク(*)は、「0個以上」を意味します。言い換えると、その直前の質指定子が、何回繰り返し出現しても構わないということです。まったくなくてもマッチしますし、何回繰り返されていてもマッチします。次の例を見てください。

>>> import re
>>> pattern = re.compile('Eggs(and spam)*')
>>> pattern.search('Eggs')
<re.Match object; span=(0, 4), match='Eggs'>
>>> pattern.search('Eggs and spam')
<re.Match object; span=(0, 13), match='Eggs and spam'>
>>> pattern.search('Eggs and spam and spam')
<re.Match object; span=(0, 22), match='Eggs and spam and spam'>
>>> pattern.search('Eggs and spam and spam and spam')
<re.Match object; span=(0, 31), match='Eggs and spam and spam and spam'>

文字列の'Eggs'という部分は1回だけ現れますが、それに続く' and spam'という部分は何回出現しても構いませんし、出現しなくても構いません。

アスタリスクにマッチさせたければ、\*のようにバックスラッシュでエスケープしてください。

1個以上という量指定子

*は「0個以上」を意味しましたが、プラス(+)は「1個以上」を意味します。出現しなくてもマッチするアスタリスクとは異なり、プラスはその直前の質指定子が少なくとも1回は出現しなければマッチしません。出現するかしないかが選択的ではないということです。対話型シェルに次の内容を入力して、先のアスタリスクの例と比べてください。

>>> pattern = re.compile('Eggs(and spam)+')
>>> pattern.search('Eggs and spam')
<re.Match object; span=(0, 13), match='Eggs and spam'>
>>> pattern.search('Eggs and spam and spam')
<re.Match object; span=(0, 22), match='Eggs and spam and spam'>
>>> pattern.search('Eggs and spam and spam and spam')
<re.Match object; span=(0, 31), match='Eggs and spam and spam and spam'>

正規表現'Eggs(and spam)+'は'Eggs'にはマッチしません。プラスは少なくとも1回の' and spam'にマッチします。

正規表現文字列内で量指定子を使うときには、グループ全体に適用するために、丸かっこを使ってグループ化することがよくあるでしょう。例えば、モールス信号のドットとダッシュの組み合わせをr'(\.|\-)+'でマッチさせられます(この表現は正しくないモールス信号にもマッチします)。

プラス記号にマッチさせたければ、\+のようにバックスラッシュでエスケープしてください。

指定した回数のマッチ

繰り返し回数を指定してマッチさせたければ、その回数を波かっこで囲みます。例えば、正規表現(Ha){3}は'HaHaHa'にマッチしますが、(Ha)が2回しか繰り返されていない'HaHa'にはマッチしません。

波かっこ内に一つの数値で回数を指定するのではなく、最小回数とカンマと最大回数で範囲を指定することもできます。例えば、(Ha){3,5}は'HaHaHa'、'HaHaHaHa'、'HaHaHaHaHa'にマッチします。

最小回数と最大回数は省略可能です。例えば、(Ha){3,}は(Ha)の3回以上の繰り返しにマッチし、(Ha){,5}は5回以下の繰り返しにマッチします。波かっこを使うと正規表現を短く書けます。この2つの正規表現は同じパターンを示しています。

(Ha){3}
HaHaHa

この2つも同じです。

(Ha){3,5}
(HaHaHa)|(HaHaHaHa)|(HaHaHaHaHa)

以下の式を対話型シェルに入力してみてください。

>>> import re
>>> haRegex = re.compile(r'(Ha){3}')
>>> match1 = haRegex.search('HaHaHa')
>>> match1.group()
'HaHaHa'

>>> match = haRegex.search('HaHa')
>>> match == None
True

(Ha){3}は'HaHaHa'にマッチしますが、'Ha'にはマッチしません。'HaHa'にマッチしないのでsearch()はNoneを返します。

波かっこの量指定子の構文は、Pythonのスライスの構文(例:'Hello, world!'[3:5]が'lo'に評価されます)に似ています。しかし大きな違いがあります。正規表現の量指定子は、コロンではなくカンマで2つの数値を区切ります。また、量指定子の2つ目の数値は含まれます。'(Ha){3,5}'は'(Ha)'質指定子が5回繰り返されるものも含みます。

貪欲マッチと非貪欲マッチ

(Ha){3,5}は'HaHaHaHaHa'のうちのHaの3回、4回、5回の繰り返しにマッチするので、先の波かっこの例でなぜMatchオブジェクトのgroup()呼び出しがもっと短いものではなく'HaHaHaHaHa'を返したのか不思議に思うかもしれません。'HaHaHa'と'HaHaHaHa'も正規表現(Ha){3,5}の正しいマッチですから。

Pythonの正規表現はデフォルトでは貪欲です。曖昧な状況では可能な限り長い文字列にマッチさせるということです。可能な限り短い文字列にマッチさせる非貪欲(怠惰)なマッチにしたければ、波かっこを閉じたあとにクエスチョンマークをつけます。

次の内容を対話型シェルに入力し、波かっこで同じ文字列を対象とした貪欲なマッチと非貪欲なマッチの違いを確認してください。

>>> import re
>>> greedy_pattern = re.compile(r'(Ha){3,5}')
>>> match1 = greedy_pattern.search('HaHaHaHaHa')
>>> match1.group()
'HaHaHaHaHa'

>>> lazy_pattern = re.compile(r'(Ha){3,5}?')
>>> match2 = lazy_pattern.search('HaHaHaHaHa')
>>> match2.group()
'HaHaHa'

正規表現ではクエスチョンマークに2つの意味があります。非貪欲なマッチとあってもなくてもマッチする量指定子の2つです。この2つは全く関係ありません。

技術的には、?、*、+の量指定子を使わずにすませられます。

  • ?量指定子は{0,1}と同じです。
  • *量指定子は{0,}と同じです。
  • +量指定子は{1,}と同じです。

とはいえ、?、*、+量指定子はよく使われる短縮記法です。

すべてにマッチさせる

すべてにマッチさせたい場面が時々あります。例えば、'First Name:'のあとに何らかのテキストがあり、続けて'Last Name:'のあとに何らかのテキストがあるときなどです。「何でも」を表すのにドットスター(.*)を使えます。ドットは「改行以外のすべての文字」という意味ですし、スター(アスタリスク)は「0個以上」という意味です。

以下の式を対話型シェルに入力してみてください。

>>> import re
>>> name_pattern = re.compile(r'First Name: (.*) Last Name: (.*)')
>>> name_match = name_pattern.search('First Name: Al Last Name: Sweigart')
>>> name_match.group(1)
'Al'
>>> name_match.group(2)
'Sweigart'

ドットスターは貪欲です。できる限り長いテキストにマッチします。非貪欲にしたければ、ドットスターのあとにクエスチョンマークをつけてください(.*?)。波かっこのあとにクエスチョンマークをつけたときと同じように、非貪欲マッチにできます。

以下の内容を対話型シェルに入力し、貪欲と非貪欲の違いを確認してください。

>>> import re
>>> lazy_pattern = re.compile(r'<.*?>')
>>> match1 = lazy_pattern.search('<To serve man> for dinner.>')
>>> match1.group()
'<To serve man>'

>>> greedy_re = re.compile(r'<.*>')
>>> match2 = greedy_re.search('<To serve man> for dinner.>')
>>> match2.group()
'<To serve man> for dinner.>'

どちらの正規表現も、大まかに言うと、「小なり記号と大なり記号にマッチして、間に何が入っていてもよい」という内容になります。しかし、文字列'<To serve man> for dinner.>'には、大なり記号に関して2つの可能性があります。非貪欲マッチでは、可能な限り短い文字列である'<To serve man>'にマッチします。貪欲マッチでは、可能な限り長い文字列である'<To serve man> for dinner.>'にマッチします。

改行文字のマッチ

.*のドットは改行以外のすべてにマッチします。re.compile()の第二引数にre.DOTALLを渡すと、ドットが改行を含めてすべての文字にマッチします。

以下の式を対話型シェルに入力してみてください。

>>> import re
>>> no_newline_re = re.compile('.*')
>>> no_newline_re.search('Serve the public trust.\nProtect the innocent. 
\nUphold the law.').group()
'Serve the public trust.'

>>> newline_re = re.compile('.*', re.DOTALL)
>>> newline_re.search('Serve the public trust.\nProtect the innocent. 
\nUphold the law.').group()
'Serve the public trust.\nProtect the innocent.\nUphold the law.'

正規表現no_newline_reは、re.compile()にre.DOTALLを渡していないので、最初の改行までのすべてにマッチします。他方で、newline_reはre.compile()にre.DOTALLを渡しているので、すべてにマッチします。newline_re.search()を呼び出すと改行文字を含めて文字列全体にマッチするのはそのためです。

文字列の先頭や末尾でのマッチ

キャレット記号(^)で正規表現の先頭を示すことができます。テキストの先頭でマッチしなければならなくなります。同様に、ドル記号($)で正規表現の末尾を示すことができます。^と$を同時に使えば、(先頭と末尾を指定することになるので)文字列全体のマッチを指定できます。文字列の一部とはマッチしなくなります。

例えば、正規表現r'^Hello'は、'Hello'で始まる文字列とマッチします。以下の式を対話型シェルに入力してみてください。

>>> import re
>>> begins_with_hello = re.compile(r'^Hello')
>>> begins_with_hello.search('Hello, world!')
<re.Match object; span=(0, 5), match='Hello'>
>>> begins_with_hello.search('He said "Hello."') == None
True

正規表現r'\d$'は、0から9の数字で終わる文字列にマッチします。以下の式を対話型シェルに入力してみてください。

>>> import re
>>> ends_with_number = re.compile(r'\d$')
>>> ends_with_number.search('Your number is 42')
<re.Match object; span=(16, 17), match='2'>
>>> ends_with_number.search('Your number is forty two.') == None
True

正規表現r'^\d+$'は、最初から最後まで数字であるものにマッチします。以下の式を対話型シェルに入力してみてください。

>>> import re
>>> whole_string_is_num = re.compile(r'^\d+$')
>>> whole_string_is_num.search('1234567890')
<re.Match object; span=(0, 10), match='1234567890'>
>>> whole_string_is_num.search('12345xyz67890') == None
True

最後の2つのsearch()の呼び出しを見ると、^と$を使えば文字列全体にマッチさせるということがどういう意味なのかわかります。(私はこれら2つの記号の意味を取り違えることが多いので、「キャロット(にんじん)は数ドルする」と記憶して、キャレットが最初でドルが最後だと覚えています。)

\bを使うと単語の境界(単語の始まり、単語の終わり、単語の始まりと終わりの両方)にマッチさせられます。「単語」とは、ここでは文字以外のもので分割された文字の連続のことです。例えば、r'\bcat.*?\b'は、'cat'で始まり次の単語の境界までマッチします。

>>> import re
>>> pattern = re.compile(r'\bcat.*?\b')
>>> pattern.findall('The cat found a catapult catalog in the catacombs.')
['cat', 'catapult', 'catalog', 'catacombs']

\Bは単語の境界以外でマッチします。

>>> import re
>>> pattern = re.compile(r'\Bcat\B')
>>> pattern.findall('certificate')  # マッチする
['cat']
>>> pattern.findall('catastrophe')  # マッチしない
[]

単語の境界以外でマッチさせると便利が場合があります。

正規表現の記号のおさらい

この章ではここまでたくさんの表記を紹介してきました。基本的な正規表現の構文のおさらいをしましょう。

  • ?は質指定子の0回または1回にマッチします。
  • *は質指定子の0回以上にマッチします。
  • +は質指定子の1回以上にマッチします。
  • {n}は質指定子のちょうどn回にマッチします。
  • {n,}は質指定子のn回以上にマッチします。
  • {,m}は質指定子のm回以下にマッチします。
  • {n,m}は質指定子のn 回以上m回以下にマッチします。
  • {n,m}?や*?や+?は、質指定子の非貪欲マッチです。
  • ^spamは文字列がspamで始まればマッチします。
  • spam$は文字列がspamで終わればマッチします。
  • .は改行以外のすべての文字にマッチします。
  • \d、\w、 \sは、それぞれ、数字、単語、空白文字にマッチします。
  • \D、\W、\Sは、それぞれ、数字以外、単語以外、空白文字以外にマッチします。
  • [abc]は、aまたはbまたはcのように、角かっこ内のどの文字にでもマッチします。
  • [^abc]は角かっこ内の文字以外のすべての文字にマッチします。
  • (Hello)のようにグループ化すると、'Hello'を一つの質指定子として扱います。

大文字と小文字を区別しないマッチ

通常、正規表現は、大文字と小文字を区別してマッチします。例えば、以下の正規表現は全部異なります。

>>> import re
>>> pattern1 = re.compile('RoboCop')
>>> pattern2 = re.compile('ROBOCOP')
>>> pattern3 = re.compile('robOcop')
>>> pattern4 = re.compile('RobocOp')

しかし、大文字と小文字を関係なく文字に着目してマッチさせたい場合もあるでしょう。大文字と小文字を区別せずにマッチさせるには、re.IGNORECASEまたはre.Iをre.compile()の第二引数として渡します。以下の式を対話型シェルに入力してみてください。

>>> import re
>>> pattern = re.compile(r'robocop', re.I)
>>> pattern.search('RoboCop is part man, part machine, all cop.').group()
'RoboCop'

>>> pattern.search('ROBOCOP protects the innocent.').group()
'ROBOCOP'

>>> pattern.search('Have you seen robocop?').group()
'robocop'

正規表現が大文字と小文字を区別せずにマッチするようになります。

文字列置換

正規表現は、パターンを検索するだけでなく、パターンを新しいテキストで置換することもできます。Patternオブジェクトのsub()メソッドは2つの引数を取ります。第一引数はマッチしたときに置換する文字列です。第二引数は正規表現の文字列です。sub()メソッドは置換後の文字列を返します。

例えば、以下の内容を対話型シェルに入力すると、諜報部員の名前をCENSOREDに置換します。

>>> import re
>>> agent_pattern = re.compile(r'Agent \w+')
>>> agent_pattern.sub('CENSORED', 'Agent Alice contacted Agent Bob.')
'CENSORED contacted CENSORED.'

マッチしたテキストの一部を用いて置換したい場合もあるでしょう。sub()の第一引数に、グループ1、グループ2、グループ3…を意味する\1、\2、\3…を含められます。この構文は後方参照と呼ばれます。

例えば、諜報部員の名前を最初の文字だけ残して検閲したいとしましょう。そのためにはAgent (\w)\w*という正規表現を用い、sub()の第一引数にr'\1****'を渡します。

>>> import re
>>> agent_pattern = re.compile(r'Agent (\w)\w*')
>>> agent_pattern.sub(r'\1****', 'Agent Alice contacted Agent Bob.')
'A**** contacted B****.'

正規表現文字列の\1はグループ1にマッチしたテキスト、ここでは(\w)のグループにマッチしたテキストが入ります。

複雑な正規表現の冗長モードでの管理

正規表現でマッチさせたいパターンが単純であればまあいいでしょう。しかし、複雑なパターンにマッチさせるためには、長くて込み入った正規表現が必要になります。re.compile()関数に正規表現文字列中のスペースとコメントを無視させるよう伝えることにより、この複雑さを緩和できます。re.compile()の第二引数にre.VERBOSEを渡すことで「冗長モード」を有効にします。

このような読みにくい正規表現の代わりに

pattern = re.compile(r'((\d{3}|\(\d{3}\))?(\s|-|\.)?\d{3}(\s|-
|\.)\d{4}(\s*(ext|x|ext\.)\s*\d{2,5})?)')

このように複数行で正規表現を書いてコメントを入れることができるようになります。

pattern = re.compile(r'''(
    (\d{3}|\(\d{3}\))?# 市外局番
    (\s|-|\.)?# 区切り
    \d{3}  # 最初の3桁の数字
    (\s|-|\.)  # 区切り
    \d{4}  # 最後の4桁の数字
    (\s*(ext|x|ext\.)\s*\d{2,5})?# 内線番号
    )''', re.VERBOSE)

三重引用符(''')を使って複数行文字列を作成し、正規表現を複数行で書くことで、読みやすくしています。

正規表現文字列中のコメントのルールは、通常のPythonのコードのルールと同じです。#記号からその行の終わりまではコメントとして無視されます。また、複数行文字列内のスペースはマッチさせるパターンであるとは解釈されません。これにより、正規表現を読みやすく整列させられます。

冗長モードで書くと正規表現を読みやすくできます。もっとも、正規表現を読みやすくするためには、本章の終わりで紹介するHumreモジュールを活用することをおすすめします。

re.IGNORECASEとre.DOTALLとre.VERBOSEを組み合わせる

正規表現中にコメントを書きたくてre.VERBOSEを使い、さらに大文字と小文字を区別しないようにre.IGNORECASEを使いたい場合には、どうすればよいでしょうか。あいにく、re.compile()関数は第二引数に一つの値しか取りません。

ビット論理和演算子として知られるパイプ(|)を使って変数re.IGNORECASE、re.DOTALL、re.VERBOSEを組み合わせることによりこの制約を回避できます。例えば、大文字と小文字を区別せず、かつドットに改行もマッチさせたければ、re.compile()をこのように呼び出します。

>>> some_regex = re.compile('foo', re.IGNORECASE | re.DOTALL)

第二引数の3つのオプションをすべて含めるならこうします。

>>> some_regex = re.compile('foo', re.IGNORECASE | re.DOTALL | re.VERBOSE)

この構文は初期のPythonに由来していて少し古臭いです。ビット演算子の詳細は本書の範囲外ですが、詳細についてはhttps://nostarch.com/automate-boring-stuff-python-3rd-edition をご覧ください。第二引数に渡せるオプションはほかにもありますが、あまり一般的ではないので、それについては別途お調べください。

プロジェクト3:長い文書から連絡先情報を抽出する

長いウェブページや文書から電話番号とメールアドレスをすべて見つけるという退屈な作業をしなければならないとします。ページを手動でスクロールして探すと、とても長い時間がかかります。クリップボードからテキストを検索して電話番号とメールアドレスを見つけてくれるプログラムがあればどうでしょうか。CTRL-Aを押すとすべてのテキストを選択でき、CTRL-Cを押すとそれをクリップボードにコピーできます。その状態でプログラムを実行すると、クリップボードの内容を発見した電話番号とメールアドレスに書き換えます。

新たなプロジェクトに取り組む際に、すぐにコードを書き始めたくなるかもしれません。しかし、多くの場合、一歩引いて大きな構想を練るのがベストです。プログラムが実行する内容の計画を高水準で書くことをおすすめします。まだ実際のコードのことは考えないでください。それはあとから何とでもなります。今は大きな動きを捉えてください。

例えば、今回の電話番号とメールアドレスを抽出するプログラムなら、以下の内容が必要です。

  • クリップボードからテキストを取得する
  • そのテキストの中からすべての電話番号とメールアドレスを見つける
  • 見つけた電話番号とメールアドレスをクリップボードに貼り付ける

ここでこれをどのように動作させるかのコードを考え始めます。以下の内容が必要になります。

  • pyperclipモジュールで文字列のコピーと貼り付けを行う
  • 電話番号用とメールアドレス用の2つの正規表現を作成する
  • 2つの正規表現にマッチするものすべて(最初のマッチだけでなく)を取得する
  • マッチした文字列を貼り付けられるように一つの文字列にきちんと整形する
  • マッチしなければ何らかのメッセージを表示する

このリストはプロジェクトのロードマップのようなものです。コードを書くときには、これらのステップごとに取り組むと、何とかなりそうに見えてきます。Pythonでどのように実現するかという観点から表現します。

ステップ1:電話番号用の正規表現の作成

まず、電話番号用の正規表現を作成します。新しいファイルエディタウィンドウを開いて、以下のコードをphoneAndEmail.pyという名前で保存してください。

import pyperclip, re

phone_re = re.compile(r'''(
    (\d{3}|\(\d{3}\))?# 市外局番
    (\s|-|\.)?# 区切り
    (\d{3})  # 最初の3桁の数字
    (\s|-|\.)  # 区切り
    (\d{4})  # 最後の4桁の数字
    (\s*(ext|x|ext\.)\s*(\d{2,5}))?# 内線番号
    )''', re.VERBOSE)

# TODO:メールアドレス用の正規表現を作成する

# TODO:クリップボードのテキストでマッチさせる

# TODO: 結果をクリップボードに貼り付ける

TODOコメントはプログラムの骨組みです。実際にその部分のコードを書いたらコメントは削除します。

電話番号で市外局番を書くこともあれば書かないこともあるので、市外局番をグループ化してクエスチョンマークをつけました。市外局番は3桁なので(訳注:日本の市外局番は3桁とは限りません)、\d{3}です。市外局番がかっこ内に書かれている場合は\(\d{3}\)です。これらをパイプでつなぎます。(\d{3}|\(\d{3}\))?が何にマッチするのかを思い出せるように、複数行文字列の正規表現中に# 市外局番というコメントを入れておきます。

電話番号の区切りとなる可能性があるのは、スペース(\s)かハイフン(-)かピリオド(.)なので、これらをパイプでつなげます。その次の部分は見たままです。3桁の数字、区切り、4桁の数字です。最後の部分は内線番号です。extかxかext.にスペースを入れて2〜5桁の数字が続く可能性があります。

注記

グループ化のかっこ()とエスケープしたかっこ\(\)が混在している正規表現では混乱しがちです。“missing), unterminated subpattern”エラーメッセージが表示されたら、正しく表現できているかダブルチェックしてください。

ステップ2:メールアドレス用の正規表現の作成

メールアドレス用の正規表現も必要です。以下のようなコードになります。

import pyperclip, re

phone_re = re.compile(r'''(
--snip--

# メールアドレス用の正規表現
email_re = re.compile(r'''(
  ❶ [a-zA-Z0-9._%+-]+  # ユーザー名
  ❷ @  # @記号
  ❸ [a-zA-Z0-9.-]+  # ドメイン名
    (\.[a-zA-Z]{2,4})  # ドット何とか
    )''', re.VERBOSE)

# TODO:クリップボードのテキストでマッチさせる

# TODO: 結果をクリップボードに貼り付ける

メールアドレスのユーザー名の部分(❶)は、小文字と大文字のアルファベット、数字、ドット、アンダースコア、パーセント記号、プラス記号、ハイフンの中からなる1文字以上の文字列です。文字クラス[a-zA-Z0-9._%+-]でそれを表します。

ドメインとユーザー名は@で区切られます(❷)。ドメイン名(❸)に使える文字種はユーザー名に使える文字種よりも少ないです。アルファベット、数字、ピリオド、ハイフンだけで、[a-zA-Z0-9.-]です。最後はドットコム的な部分で(技術的にはトップレベルドメインと呼ばれます)、ドット何とかという形です。

メールアドレスの形式には奇妙なルールがたくさんあり、この正規表現ですべてのあり得るメールアドレスにマッチするわけではありませんが、探している典型的なメールアドレスにはほぼマッチするでしょう。

ステップ3:クリップボードのテキストからすべてのマッチを見つける

電話番号用とメールアドレス用の正規表現を作成しましたから、クリップボードからすべてのマッチを見つけるという面倒な作業をPythonのreモジュールにやらせましょう。pyperclip.paste()関数でクリップボードのテキストの文字列値を取得し、正規表現のfindall()メソッドでマッチしたリストをタプルで返します。

以下のようなコードになります。

import pyperclip, re

phone_re = re.compile(r'''(
--snip--

# クリップボードのテキストでマッチさせる
text = str(pyperclip.paste())

❶ matches = []
❷ for groups in phone_re.findall(text):
    phone_num = '-'.join([groups[1], groups[3], groups[5]])
    if groups[6] != '':
        phone_num += ' x' + groups[6]
    matches.append(phone_num)
❸ for groups in email_re.findall(text):
    matches.append(groups[0])

# TODO:結果をクリップボードに貼り付ける

マッチごとに一つのタプルがあり、各タプルには正規表現のそれぞれのグループの文字列が含まれています。グループ0は正規表現全体ですから、タプルのインデックス0が対象になります。

matchesという名前のリスト変数にマッチした結果を格納します(❶)。空リストで始めて2つのforループに入ります。メールアドレスに関しては、マッチごとにグループ0を追加します(❸)。電話番号に関しては、単純にグループ0を追加するわけにはいきません。いくつかの形式の電話番号を検出しているので、一つの標準化した形式でリストに追加します。変数phone_numには、マッチした文字列のグループ1、3、5、6から組み立てた文字列を格納します(❷)。(これらのグループは、市外局番、3桁の数字、4桁の数字、内線番号です。)

ステップ4:すべてのマッチを一つの文字列にする

matchesというリストの中にメールアドレスと電話番号の文字列を準備できましたから、次はこれらをクリップボードに貼り付けます。pyperclip.copy()関数は、文字列のリストではなく、一つの文字列値を取るので、matchesについてjoin()メソッドを呼び出します。

以下のようなコードになります。

import pyperclip, re

phone_re = re.compile(r'''(
--snip--
for groups in email_re.findall(text):
    matches.append(groups[0])

# 結果をクリップボードに貼り付ける
if len(matches) > 0:
    pyperclip.copy('\n'.join(matches))
    print('Copied to clipboard:')
    print('\n'.join(matches))
else:
    print('No phone numbers or email addresses found.')

プログラムの動作を見えやすくするために、マッチしたものをターミナルウィンドウに表示します。電話番号とメールアドレスが一つも見つからなければ、そのことをユーザーに知らせます。

このプログラムを試すには、https://nostarch.com/contactusのNo Starch Press のお問い合わせページを開き、CTRL-Aを押してそのページのすべてのテキストを選択して、CTRL-Cを押してクリップボードにコピーしてください。このプログラムを実行すると、以下のように表示されるはずです。

Copied to clipboard:
800-555-7240
415-555-9900
415-555-9950
info@nostarch.com
media@nostarch.com
academic@nostarch.com
info@nostarch.com

このプログラムを改造すれば、メーリングリストのアドレス、ソーシャルメディアのハンドルネーム、その他のテキストパターンを探すことができます。

似たようなプログラムのアイデア

テキストのパターンを指定できると(sub()メソッドで置換もできます)、さまざまなアプリケーションを作成できる可能性に開かれます。例えば、以下の内容が考えられます。

  • http://またはhttps://で始まるURLを探す
  • (3/14/2030や03-14-2030や2030/3/14のような)日付のフォーマットを標準的なフォーマットに統一する
  • 社会保障番号やクレジットカード番号のような機密情報を取り除く
  • 単語と単語の間の複数スペースや重複やエクスクラメーションマークの繰り返しなど、よくあるタイプミスを発見する

Humre:人間に読みやすい正規表現のモジュール

コードは書くよりも読むことのほうがずっと多いですから、コードを読みやすくするのは大切なことです。しかし、記号を多用している正規表現は、経験を積んだプログラマにとっても読みにくいです。この問題を解決するために、サードパーティのHumreモジュールが開発されました。冗長モードよりも読みやすく、英語の名前で正規表現を作成できます。HumreはAppendix Aの手順に従ってインストールできます。

本章の最初に示したr'\d{3}-\d{3}-\d{4}'の電話番号の例に戻りましょう。Humreの関数と定数により簡単な英語で同じ正規表現を作成できます。

>>> from humre import *
>>> phone_regex = exactly(3, DIGIT) + '-' + exactly(3, DIGIT) + '-' + exactly(4, DIGIT)
>>> phone_regex
'\\d{3}-\\d{3}-\\d{4}'

DIGITのようなHumreの定数には文字列が格納されており、exactly()のようなHumreの関数は文字列を返します。Humreはreモジュールを代替するのではなく、re.compile()に渡す正規表現文字列を生成します。

>>> import re
>>> pattern = re.compile(phone_regex)
>>> pattern.search('My number is 415-555-4242')
<re.Match object; span=(13, 25), match='415-555-4242'>

Humreには正規表現構文のそれぞれの機能に対応する定数と関数があります。それをほかの文字列と同じように結合できます。例えば、以下のような、短縮文字クラスに対応するHumreの定数があります。

  • DIGITとNONDIGITはそれぞれr'\d'とr'\D'を表します。
  • WORDとNONWORDはそれぞれr'\w'とr'\W'を表します。
  • WHITESPACEとNONWHITESPACEはそれぞれr'\s'とr'\S'を表します。

正規表現のバグの原因は、エスケープ忘れであることが多いです。自分でエスケープ文字を打ち込まずにHumreの定数を使うことができます。例えば'0.9'や'4.5'のような、小数第一位までの浮動小数点数にマッチさせたいとします。r'\d.\d'という正規表現文字列を使うと、ドットは確かに('4.5'のような)小数点数にマッチしますが、('4A5'のような)ほかのどのような文字にもマッチしてしまいます。

Humreの定数PERIODには、r'\.'という文字列が格納されています。DIGIT + PERIOD + DIGITという表現はr'\d\.\d'に評価されます。正規表現がマッチさせたいものをより明確に示せます。

Humreにはエスケープ文字を表す以下の定数が存在します。

PERIOD     OPEN_PAREN  OPEN_BRACKET    PIPE
DOLLAR_SIGN CLOSE_PAREN CLOSE_BRACKET   CARET
QUESTION_MARK   ASTERISK    OPEN_BRACE  TILDE
HASHTAG     PLUS        CLOSE_BRACE 
AMPERSAND   MINUS       BACKSLASH   

NEWLINE、TAB、QUOTE、DOUBLE_QUOTEの定数も存在します。r'\1'からr'\99'までの後方参照はBACK_1からBACK_99で表現できます。

Humreの関数を活用すると、大いに読みやすくなります。表9-2にHumreの関数とその関数が表す正規表現をまとめています。

表 9-2:Humreの関数

Humreの関数

正規表現文字列

group('A')

r'(A)'

optional('A')

r'A?'

either('A', 'B', 'C')

r'A|B|C'

exactly(3, 'A')

'A{3}'

between(3, 5, 'A')

'A{3,5}'

at_least(3, 'A')

'A{3,}'

at_most(3, 'A')

'A{,3}'

chars('A-Z')

'[A-Z]'

nonchars('A-Z')

'[^A-Z]'

zero_or_more('A')

'A*'

zero_or_more_lazy('A')

'A*?'

one_or_more('A')

'A+'

one_or_more_lazy('A')

'A+?'

starts_with('A')

'^A'

ends_with('A')

'A$'

starts_and_ends_with('A')

'^A$'

named_group('name', 'A')

'(?P<name>A)'

Humreは関数呼び出しを組み合わせた便利な関数も提供しています。例えば、optional(group('A'))で'(A)?'を作れますが、optional_group('A')とシンプルに呼び出すこともできます。表9-3はHumreの便利な関数の全一覧です。

表 9-3:Humreの便利関数

便利関数

同等の関数

正規表現文字列

optional_group('A')

optional(group('A'))

'(A)?'

group_either('A')

group(either('A', 'B', 'C'))

'(A|B|C)'

exactly_group(3, 'A')

exactly(3, group('A'))

'(A){3}'

between_group(3, 5, 'A')

between(3, 5, group('A'))

'(A){3,5}'

at_least_group (3, 'A')

at_least(3, group('A'))

'(A){3,}'

at_most_group (3, 'A')

at_most(3, group('A'))

'(A){,3}'

zero_or_more_group('A')

zero_or_more(group('A'))

'(A)*'

zero_or_more_lazy_group('A')

zero_or_more_lazy(group('A'))

'(A)*?'

one_or_more_group('A')

one_or_more(group('A'))

'(A)+'

one_or_more_lazy_group('A')

one_or_more_lazy(group('A'))

'(A)+?'

either()とgroup_either()以外のHumreの関数に渡した文字列は、すべて自動的に連結されます。group(DIGIT, PERIOD, DIGIT)を呼び出すとgroup(DIGIT + PERIOD + DIGIT)と同じ正規表現文字列を生成します。どちらもr'(\d\.\d)'になります。

最後に、Humreにはよく使う正規表現のパターンを表す定数があります。

ANY_SINGLE 改行を除くすべての1文字にマッチする.パターンです。

ANYTHING_LAZY 怠惰(非貪欲)な.*?パターンです。

ANYTHING_GREEDY 貪欲な.*パターンです。

SOMETHING_LAZY 怠惰(非貪欲)な.+?パターンです。

SOMETHING_GREEDY 貪欲な.+パターンです。

大規模で複雑な正規表現をHumreで書くと、明らかに読みやすくなります。先の電話番号を抽出するプロジェクトの正規表現をHumreで書き直してみましょう。

import re
from humre import *
phone_regex = group(
    optional_group(either(exactly(3, DIGIT),  # 市外局番
                          OPEN_PAREN + exactly(3, DIGIT) + CLOSE_PAREN)),
    optional(group_either(WHITESPACE, '-', PERIOD)),  # 区切り
    group(exactly(3, DIGIT)),  # 最初の3桁の数字
    group_either(WHITESPACE, '-', PERIOD),  # 区切り
    group(exactly(4, DIGIT)),  # 最後の4桁の数字
    optional_group(  # 内線番号
      zero_or_more(WHITESPACE),
      group_either('ext', 'x', r'ext\.'),
      zero_or_more(WHITESPACE),
      group(between(2, 5, DIGIT))
      )
    )

pattern = re.compile(phone_regex)
match = pattern.search('My number is 415-555-1212.')
print(match.group())

このプログラムを実行すると、次のように出力されます。

415-555-1212

このコードは、正規表現の冗長モードよりもずっと冗長です。from humre import *でHumreをインポートしているので、Humreの関数と定数の前にhumre.をつける必要はありません。コードの長さと読みやすさは別次元です(Humreを使うとコードが長くなりがちですが、読みやすくなります)。

humre.parse()関数を呼び出すと、Pythonのコードを返してくれるので、既存の正規表現をHumreのコードに切り替えることができます。

>>> import humre
>>> humre.parse(r'\d{3}-\d{3}-\d{4}')
"exactly(3, DIGIT) + '-' + exactly(3, DIGIT) + '-' + exactly(4, DIGIT)"

PyCharmやVisual Studio Codeといった現代的なエディタで利用すると、Humreにはさらなら利点があります。

  • 正規表現の対応関係がわかりやすくなるようにコードをインデントできます。
  • エディタが開きかっこと閉じかっこを対応させてくれます。
  • エディタが強調表示してくれます。
  • エディタのリンターとタイプヒントツールがタイプミスを検出してくれます。
  • エディタの自動補完が関数と定数の名前を補完してくれます。
  • Humreがraw文字列を処理してエスケープしてくれます。
  • HumreのコードにPythonのコメントを入れられます。
  • タイプミスによるエラーがわかりやすくなります。

経験の長いプログラマの中には、複雑で読みにくい標準の正規表現構文以外は受け付けないという人がいます。プログラマのPeter Bhat Harkinsはこのように言いました。「プログラマは難しい事柄になじんでいるせいで、簡単に行う方法を探したり、簡単に行う方法に反対したりするのがもどかしいです。」

同僚がHumreの利用に反対したとしても、Humreが生成する正規表現を表示して、それをソースコードに貼り付けられます。例えば、電話番号を抽出するプロジェクトの変数phone_regexの内容は、次のようになります。

r'((\d{3}|\(\d{3}\))?(\s|-|\.)?(\d{3})(\s|-|\.)(\d{4})(\s*(ext|x|ext\.)\s*(\d{2,5}))?)'

お好みならどうぞこの正規表現を使ってください。

まとめ

コンピュータのテキスト検索は高速ですが、検索対象を正確に指定しなければなりません。正規表現を活用すると、検索対象として、探したいテキストそのものではなく、文字のパターンを指定できます。実はワープロソフトやスプレッドシートアプリケーションの検索と置換機能で正規表現が使える場合があります。記号が多い正規表現は、マッチ対象の質指定子とマッチ回数の量指定子から構成されています。

Python本体に付属しているreモジュールで正規表現文字列をPatternオブジェクトにコンパイルできます。このオブジェクトにはいくつかのメソッドがあります。search()は1回のマッチを検索し、findall()はすべてのマッチを検索し、sub()はテキストを置換します。

https://docs.python.org/3/library/re.htmlのPythonの公式ドキュメントにもっと詳しく書かれていますし、https://www.regular-expressions.infoのチュートリアルも有益な情報源です。Python Package IndexのHumreのページはhttps://pypi.org/project/Humre/です。

練習問題

  1. Regexオブジェクトを返す関数は何ですか?

  2. Regexオブジェクトを作成するときにraw文字列を使うことが多いのはなぜですか?

  3. search()メソッドは何を返しますか?

  4. Matchオブジェクトから実際にマッチした文字列をどうやって取り出しますか?

  5. r'(\d\d\d)-(\d\d\d-\d\d\d\d)'の正規表現で、グループ0、グループ1、グループ2は何になりますか?

  6. 正規表現でかっことピリオドには特別な意味があります。正規表現でかっこやピリオドの文字にマッチさせたい場合にどうすればよいですか?

  7. findall()メソッドは文字列のリストまたは文字列のタプルのリストを返します。どういう場合に文字列のリストを返し、どういう場合に文字列のタプルのリストを返しますか?

  8. 正規表現で|はどういう意味になりますか?

  9. 正規表現で?はどういう意味になるか、2つ答えてください。

10. 正規表現で+と*はどう違いますか?

11. 正規表現で{3}と{3,5}はどう違いますか?

12. 正規表現で短縮文字クラス\d、\w、\sはどういう意味になりますか?

13. 正規表現で短縮文字クラス\D、\W、\Sはどういう意味になりますか?

14. 正規表現の.*と.*?はどう違いますか?

15. すべての数字と小文字のアルファベットにマッチする文字クラスは何ですか?

16. 正規表現で大文字と小文字を区別しないようにするにはどうすればよいですか?

17. .は通常何にマッチしますか? re.compile()の第二引数にre.DOTALLを渡すと何にマッチしますか?

18. num_re = re.compile(r'\d+')である場合に、num_re.sub('X', '12 drummers, 11 pipers, five rings, 3 hens')は何を返しますか?

19. re.compile()の第二引数にre.VERBOSEを渡すと、何ができるようになりますか?

練習プログラム

以下の練習プログラムを書いてください。

強力なパスワードであることの検証

正規表現を使って、渡されたパスワード文字列が強力か確かめる関数を書いてください。ここでの強力なパスワードとは、8文字以上の長さで、大文字と小文字と数字をすべて含むパスワードだとします。ヒント:すべてのルールを検証する一つの正規表現を書くよりも、複数の正規表現を書くほうが簡単です。

正規表現バージョンのstrip()メソッド

文字列を引数に取り、文字列メソッドstrip()と同じ動作をする関数を書いてください。対象とする文字列以外の引数が渡されなければ(第一引数しか存在しなければ)、文字列の両端から空白文字を取り除いてください。第二引数が存在すれば、第二引数で指定された文字を取り除いてください。