ひとり勉強ログ

ITエンジニアの勉強したことメモ

7章 正規表現によるパターンマッチング【退屈なことはPythonにやらせよう】

# 正規表現を使わないテキストパターン検索
from pyexpat.errors import messages


def is_phone_number(text):
  if (len(text) != 12): # 文字列長がぴったり12文字かどうか調べる
    return False
  for i in range(0,3):
    if not text[i].isdecimal():
      return False
  if text[3] != '-': # 市外局番の後がハイフンになっていること
    return False
  for i in range(4,7):
    if not text[i].isdecimal(): # 3桁の数字が続くこと
      return False
  if text[7] != '-': # もう一度ハイフンがくること
    return False
  for i in range(8, 12):
    if not text[i].isdecimal(): # 最後に4桁の数字であること
      return False
  return True # すべて一致すればTrueを返す

print('415-555-4242 は電話番号:')
print(is_phone_number('415-555-4242'))
print('Moshi moshi は電話番号:')
print(is_phone_number('Moshi moshi'))
# 415-555-4242 は電話番号:
# True
# Moshi moshi は電話番号:
# False
message = '明日415-555-1011に電話してください。オフィスは415-555-9999です。'
for i in range(len(message)):
  chunk = message[i:i+12] # messageから12文字のまとまりを切り出して変数chunkに格納する。
  if is_phone_number(chunk): # 電話番号パターンに一致するか調べ、もしそうならchunkを返す。
    print('電話番号が見つかりました: ' + chunk)
print('完了')
# 電話番号が見つかりました: 415-555-1011
# 電話番号が見つかりました: 415-555-9999
# 完了
import re

phone_num_regex = re.compile(r'\d\d\d-\d\d\d-\d\d\d\d')
mo = phone_num_regex.search('私の電話番号は415-555-4242です。') # moという変数名はMatchオブジェクトに用いられる汎用名。
print('電話番号が見つかりました: ' + mo.group())
# 電話番号が見つかりました: 415-555-4242
# 電話番号を、市外局番とそれ以外に分けて取得したい。そうするには(\d\d\d)-(\d\d\d-\d\d\d\d)のように正規表現に丸カッコを追加して「グループ」を作成する。
# 最初の丸カッコで囲まれたグループは、グループ1となる。2番めはグループ2である。group()メソッドに整数1や2を渡すと、マッチした文字列の異なる部分を取得することができる。
phone_num_regex = re.compile(r'(\d\d\d)-(\d\d\d-\d\d\d\d)')
mo = phone_num_regex.search('私の電話番号は415-555-4242です。')
print(mo.group(1))
# 415
print(mo.group(2))
# 555-4242
print(mo.group(0))
# 415-555-4242
print(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
# 正規表現では丸カッコは特別な意味を持つが、丸カッコを検索したいときはバックスラッシュを使って(と)をエスケープする必要がある。
phone_num_regex = re.compile(r'(\(\d\d\d\)) (\d\d\d-\d\d\d\d)')
mo = phone_num_regex.search('私の電話番号は (415) 555-4242です。')
print(mo.group(1))
# (415)
print(mo.group(2))
# 555-4242
# 縦線を使って複数のグループとマッチする
# | 文字は「縦線」と呼ばれている。複数のパターンの内ひとつとマッチすることができる。
import re

hero_regex = re.compile(r'Batman|Tina Fey')
mo1 = hero_regex.search('Batman and Tina Fey')
print(mo1.group())
# Batman
mo2 = hero_regex.search('Tina Fey and Batman')
print(mo2.group())
# Tina Fey

bat_regex = re.compile(r'Bat(man|mobile|copter|bat)')
mo = bat_regex.search('Batmobile lost a wheel')
print(mo.group())
# Batmobile
print(mo.group(1))
# mobile

# メソッドmo.group()を引数を付けずに呼び出すと、マッチした文字列全体’Batmobile'を取得でき、mo.group(1)のようび呼び出すと、1番目の松カッコのグループにマッチした文字列だけを取得することができる。
# 疑問符を用いた任意のマッチ
import re
bat_regex = re.compile(r'Bat(wo)?man')
mo1 = bat_regex.search('The Adventures of Batman')
print(mo1.group())
# Batman
mo2 = bat_regex.search('The Adventures of Batwoman')
print(mo2.group())
# Batwoman

# 正規表現の(wo)?の部分は、woというパターンが任意のグループであることを意味する。
# 正規表現はwoが0回もしくは1回現れるテキストにマッチする。そのため、'Batman'にも'Batwoman'にもマッチする。
# 電話番号の例で、市外局番の有無にかかわらず電話番号を検索したい。
import re
phone_regex = re.compile(r'(\d\d\d-)?\d\d\d-\d\d\d\d')
mo1 = phone_regex.search('私の電話番号は415-555-4242です。')
print(mo1.group())
# 415-555-4242
mo2 = phone_regex.search('私の電話番号は555-4242です。')
print(mo2.group())
# 555-4242

# ?は「直前のグループに0回か1回マッチする」と考えることができる。
# 疑問符の文字そのものにマッチさせたいときには、バックスラッシュを使って\?のようにエスケープする。
# アスタリスクを用いた0回以上のマッチ
# アスタリスク*は、「0回以上にマッチする」という意味である。すなわち、アスタリスクの直前のグループが任意の回数出現してもよいということ。まったく出現しなくてもよいし、繰り返し出現しても構わない。

import re
bat_regex = re.compile(r'Bat(wo)*man')
mo1 = bat_regex.search('The adventure of Batman')
print(mo1.group())
# Batman
mo2 = bat_regex.search('The adventure of Batwoman')
print(mo2.group())
# Batwoman
mo3 = bat_regex.search('The adventure of Batwowowowoman')
print(mo3.group())
# Batwowowowoman

# 'Batman'に対しては(wo)*の正規表現のwoが0回出現する場合としてマッチする。'Batman'に対してはwoが1回、'Batwowowowoman'に対しては4回、それぞれ出現する場合としてマッチする。
# プラスを用いた1回以上のマッチ
# プラス + は「1回上マッチする」ことを意味する。アスタリスクは直前のグループで出現しなくても良いが、プラスは少なくとも1回は出現する必要がある。

import re
bat_regex = re.compile(r'Bat(wo)+man')
mo1 = bat_regex.search('The Adventures of Batwoman')
print(mo1.group())
# Batwoman

mo2 = bat_regex.search('The Adventures of Batwowowowoman')
print(mo2.group())
# Batwowowowoman

mo3 = bat_regex.search('The Adventures of Batman')
print(mo3 == None)
# True
# 'Bat(wo)+man'は、woが少なくとも1回出現する必要があるので、'Batman'にはマッチせず、mo3はNoneになる。
# 波カッコを用いて繰り返し回数を指定する。
# グループの繰り返し回数を指定したいときには、波カッコの中に回数を指定する。
# 繰り返し回数はひとつの文字だけではなく、カンマで区切って最小値と最大値を指定することもできる。
# 次の2つの正規表現は同じパターンにマッチする
# (Ha){3}
# (HaHaHa)

# 次の2つの表現も同じパターンにマッチする
# (Ha){5}
# (HaHaHa|HaHaHaHa|HaHaHaHaHa)

import re

ha_regex = re.compile(r'(Ha){3,5}')
mo1 = ha_regex.search('HaHaHaHaHa')
print(mo1.group)
# HaHaHaHaHa
mo2 = ha_regex.search('Ha')
print(mo2 == None)
# True

# (Ha){3}は'HaHaHa'にマッチするが、'Ha'にはまっちしない
# 貪欲マッチと非貪欲マッチ
# Pythonの正規表現は、デフォルトでは「貪欲(greedy)」にマッチする。つまり、複数の可能性がある場合には最も長いものにマッチする。一方、閉じカッコの後に疑問符を付けると「非貪欲」なマッチを意味し、最も短いものにマッチするようになる。

import re

greedy_Ha_regex = re.compile(r'(Ha){3,5}')
mo1 = greedy_Ha_regex.search('HaHaHaHaHa')
print(mo1.group)
# HaHaHaHaHa
nongreedy_Ha_regex = re.compile(r'(Ha){3,5}?')
mo2 = nongreedy_Ha_regex.search('HaHaHaHaHa')
print(mo2.group)
# HaHaHa
# findallメソッド
# Regexオブジェクトには、search()メソッドの他にfindall()メソッドがある。search() が最初に見つかった文字列のMatchオブジェクトを返すのに対し、findall()はすべての文字列を返す。

import re

phone_num_regex = re.compile(r'\d\d\d-\d\d\d-\d\d\d\d')
mo = phone_num_regex.search('Cell: 415-555-9999 Work: 212-555-0000')
print(mo.group())
# 415-555-9999

# これに対しfindall()はMatchオブジェクトではなく文字列のリストを返す。リストの各要素は、正規表現にマッチした文字列。

phone_num_regex = re.compile(r'\d\d\d-\d\d\d-\d\d\d\d')
print(phone_num_regex.findall('Cell: 415-555-9999 Work: 212-555-0000'))
# ['415-555-9999', '212-555-0000']

# 正規表現にグループが含まれていると、findall()はタプルのリストを返す。各タプルの要素は、正規表現のグループに対応してマッチした文字列。
phone_num_regex = re.compile(r'(\d\d\d)-(\d\d\d)-(\d\d\d\d)')
print(phone_num_regex.findall('Cell: 415-555-9999 Work: 212-555-0000'))
# [('415', '555', '9999'), ('212', '555', '0000')]

文字集合

一般的な文字集合を表す短縮形

aa

|短縮形|意味| |\d|0〜9の数字| |\D|0〜9の数字以外| |\w|文字、数字、下線| |\W|文字、数字、下線以外| |\s|スペース、タブ、改行(空白spaceのs)| |\S|スペース、タブ、改行以外|

import re

xmas_regex = re.compile(r'\d+\s\w+')
print(xmas_regex.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、\w、\sのような短縮形では、文字の集合が広すぎる場合には、角カッコを使って独自の文字集合を定義することができる。
# [aeiouAEIOU]は、大文字と小文字の母音にマッチする。

import re

vowel_regex = re.compile(r'[aeiouAEIOU]')
print(vowel_regex.findall('RoboCop eats baby food. BABY FOOD.'))
# ['o', 'o', 'o', 'e', 'a', 'a', 'o', 'o', 'A', 'O', 'O']

# ハイフンを使って文字や数字の範囲を指定することもできる。例えば、[a-zA-Z0-9]という文字集合は、小文字と大文字と数字にマッチする。
# [の直後にキャレット記号(^)を付けると、文字の「補集合」となる。補修号とは、定義した文字集合以外とマッチするという意味である。

consonant_regex = re.compile(r'[^aeiouAEIOU]')
print(consonant_regex.findall('RoboCop eats baby food. BABY FOOD.'))
# ['R', 'b', 'C', 'p', ' ', 't', 's', ' ', 'b', 'b', 'y', ' ', 'f', 'd', '.', ' ', 'B', 'B', 'Y', ' ', 'F', 'D', '.']

# 母音ではなく、母音以外の文字とマッチするようになった
# キャレットとドル記号
# キャレット(^)には別の使い方があり、検索対象の文字列の先頭にマッチすることを指定するときにも使う。同様に、ドル記号($)は文字列の末尾にマッチすることを表す。^と$を同時に使うと、文字列全体が正規表現とマッチすることを表す。つまり、文字列の一部がマッチするだけでは不十分になるのである。
import re

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

# \d$という表現は、0〜9の数字で終わる文字列にマッチする。
ends_with_number = re.compile(r'\d$')
print(ends_with_number.search('Your number is 42'))
# <re.Match object; span=(16, 17), match='2'>
print(ends_with_number.search('You are 42 years old.') == None)
# True

# ^\d+$という正規表現は、全体が1文字以上の数字である文字列とマッチする。
whole_string_is_num = re.compile(r'^\d+$')
print(whole_string_is_num.search('1234567890'))
# <re.Match object; span=(0, 10), match='1234567890'>
print(whole_string_is_num.search('12345xyz67890') == None)
# True
print(whole_string_is_num.search('12 34567890') == None)
# True
# ワイルドカード文字
# 正規表現ではドット(.)は「ワイルドカード」といい、改行以外の任意の文字列とマッチする。
import re

at_regex = re.compile(r'.at')
print(at_regex.findall('The cat in the hat sat on the flat mat.'))
# ['cat', 'hat', 'sat', 'lat', 'mat']

# ドットとアスタリスクであらゆる文字列とマッチする
# どんな文字列でもマッチしたいことがある。例えば、'First Name:'(名)にマッチした後に続く文字列や、'Last Name:'(姓)にマッチした後に続く文字列など。このような「なんでも」に相当する正規表現は、.*と書く。ドット¥は「改行以外の任意の1文字」であり、アスタリスクは「直前のパターンの0回以上の繰り返し」を意味する。

name_regex = re.compile(r'First Name:(.*) Last Name:(.*)')
mo = name_regex.search('First Name: Al Last Name: Sweigart')
print(mo.group(1))
# Al
print(mo.group(2))
# Sweigart

# .*は貪欲モードなので、できるだけ長い文字列にマッチする。非貪欲モードにしたいときは、.*?と疑問符を付ける。前記の波カッコ{}の場合と同様に、疑問符?は非貪欲モードにすることを意味する。

nongreedy_regex = re.compile(r'<.*?>') # 非貪欲
mo = nongreedy_regex.search('<To serve man> for dinner.>')
print(mo.group())
# <To serve man>
greedy_regex = re.compile(r'<.*>') # 貪欲
mo = greedy_regex.search('<To serve man> for dinner.>')
print(mo.group())
# <To serve man> for dinner.>

# ドットを改行とマッチさせる
# .*は改行以外のあらゆる文字列とマッチする。re.compile()の第2引数として、re.DOALLを渡すと、ドット文字が改行を含むすべての文字とマッチするようになる。
no_new_line_regex = re.compile(r'.*')
print(no_new_line_regex.search('Serve the public trust.\nProtect the innocent.\nUphold the low.').group())
# Serve the public trust.
new_line_regex = re.compile(r'.*', re.DOTALL)
print(new_line_regex.search('Serve the public trust.\nProtect the innocent.\nUphold the low.').group())
# Serve the public trust.
# Protect the innocent.
# Uphold the low.

正規表現に用いる記号のまとめ

  • ?は、直前のグループの0回か1回の出現にマッチする。
  • *は、直前のグループの0回以上の出現にマッチする。
  • +は、直前のグループの1回以上の出現にマッチする。
  • {n}は、直前のグループのn回の出現にマッチする。
  • {n,}は、直前のグループのn回以上の出現にマッチする。
  • {,m}は、直前のグループの0〜m回の出現にマッチする。
  • {n,m}は、直前のグループのn〜m回の出現にマッチする。
  • {n,m}?、{*?}、{+?}は、直前のグループの非貪欲マッチを行う。
  • ^spam$は、「spam」から始まる文字列とマッチする。
  • spam$は、「spam」で終わる文字列とマッチする。
  • .は、改行文字以外の任意の1文字とマッチする。
  • \d、\w、\sは、それぞれ、数字、単語を構成する文字、空白文字にマッチする。
  • \D、\W、\Sは、それぞれ、数字、単語を構成する文字、空白文字以外の文字にマッチする。
  • [abc]は、角カッコの中の任意の1文字にマッチする。
  • [^abc]は、角カッコの中の文字以外の任意の1文字にマッチする。
# 大文字・小文字を無視したマッチ
# 通常は、正規表現は大文字と小文字を区別してマッチする。例えば、次の正規表現はまったく異なる文字列とマッチする。
import re

regex1 = re.compile('Robocop')
regex1 = re.compile('ROBOCOP')
regex1 = re.compile('robOcop')
regex1 = re.compile('RobocOp')

# けれども、大文字と小文字を区別せずにマッチしたい場合、re.compile()に、re.IGNORECASEもしくはre.Iオプションを渡す。
robocop = re.compile(r'robocop', re.I)
print(robocop.search('RoboCop is part of man, part machine, all cop.').group())
# RoboCop
print(robocop.search('ROBOCOP protects the innocent.').group())
# ROBOCOP
print(robocop.search('Al, why does your programming book talk robocop so much?.').group())
# robocop
# sub()メソッドを用いて文字列を置換する
# 正規表現は文字列のパターンを検索するだけでなく、文字列を置換するのにも使える。Regexオブジェクトのsub()メソッドは引数を2つとる。第1引数は置き換える文字列で、第2引数は検索置換対象の文字列である。sub()メソッドは、置換後の文字列を返す。
from os import name
import re

names_regex = re.compile(r'Agent \w+')
print(names_regex.sub('CENSORED', 'Agent Alice gave the secret docments to Agent Bob.'))
# CENSORED gave the secret docments to CENSORED.

# マッチした文字列を、置き換えの一部として使いたいときもある。そうするには、sub()の第1引数に、\1、\2、\3のように、グループ番号を使って記述する。
# 例えば、秘密諜報員の名前を検閲し、頭文字だけで表示したいとする。そうするには、(\w)\w*という正規表現を用いて、sub()の第1引数に、\1****を渡します。\1はグループ1にマッチした文字列、この場合は正規表現の(\w)に置き換わる。

agent_names_regex = re.compile(r'Agent (\w)\w*')
print(agent_names_regex.sub(r'\1****', 'Agent Alice told Agent Carol that Agent Eve knew Agent Bob was a double agent.'))
# A**** told C**** that E**** knew B**** was a double agent.
# 複雑な正規表現を管理する
# 正規表現は、マッチしようとするパターンが単純なうちはよいが、複雑になってくると、長くてややこしい記述が必要になる。
import re

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

# 以上のような解読困難な1行の正規表現を、次のように、複数行に分けてコメントを付けて分かりやすく記述することができる。

phonr_regex = re.compile(r'''(
  (\d{3}|\(\d{3}\))? # 3桁の市外局番(()がついていても良い)
  (\s|-|\.)? # 区切り(スペースかハイフンかドット)
  \d{3} # 3桁の市外局番
  (\s|-|\.) # 区切り
  \d{4} # 4桁の番号
  (\s*(ext|x|ext.)\s*\d{2,5})? # 2〜5桁の内線番号
)''', re.VERBOSE)
# 電話番号とメールアドレスの抽出

# 1.電話番号の正規表現を作る
import pyperclip, re

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

# 電子メールの正規表現を作る
email_regex = re.compile(r'''(
  [a-zA-Z0-9._%+-]+ # ユーザー名
  @ # @記号
  [a-zA-Z0-9.-]+ # ドメイン名
  (\.[a-zA-Z]{2,4}) # ドットなんとか
)''', re.VERBOSE)

# クリップボードのテキストを検索
text = str((pyperclip.paste()))
matches = []
for groups in phone_regex.findall(text):
  phone_num = '-'.join(groups[1], groups[3], groups[5])
  if groups[8] != '':
    phone_num += ' x' + groups[8]
  matches.append(phone_num)
for groups in email_regex.findall(text):
  matches.append(groups[0])

# 検索結果をクリップボードに貼り付ける
if len(matches) > 0:
  pyperclip.copy('\n'.join(matches))
  print('クリップボードにコピーしました')
  print('\n'.join(matches))
else:
  print('電話番号やメールアドレスは見つかりませんでした。')