【Python】検索語に記号があっても壊れない:re.escapeで複数語を検索する

PythonのTopに戻る

検索語を正規表現として解釈させない

C++、A(1)、a.bをまとめて検索するとき、検索語をそのまま「|」でつなぐと、+や括弧やピリオドが正規表現の記号として扱われてしまう。各検索語へre.escape()を適用してから連結すれば、検索語に含まれる記号を文字どおり探せる。外部から受け取った語一覧を使うときにも、検索条件と正規表現の構文を混ぜないことが大切である。

ここでの検索は部分一致であり、英単語としての独立性や日本語の形態素は判断しない。また、一度見つかった範囲と重なる別候補をすべて列挙するものではない。同じ位置から始まる候補は長い語を優先し、結果を左から重なりなしで集めるという規則にする。

長い語を先に並べ、空の一覧にも対応する

example.py

import re

def find_terms(text, terms):
    if any(not isinstance(term, str) for term in terms):
        raise TypeError("terms must contain strings")
    if any(term == "" for term in terms):
        raise ValueError("empty search term")
    ordered = sorted(set(terms), key=lambda term: (-len(term), term))
    if not ordered:
        return []
    pattern = re.compile("|".join(re.escape(term) for term in ordered))
    return [(m.group(0), m.span()) for m in pattern.finditer(text)]

text = "C++ / C / A(1) / a.b / axb"
terms = ["C", "C++", "A(1)", "a.b", "C++"]
result = find_terms(text, terms)
assert result == [("C++", (0, 3)), ("C", (6, 7)), ("A(1)", (10, 14)), ("a.b", (17, 20))]
assert find_terms(text, []) == []
try:
    find_terms(text, [""])
except ValueError as error:
    print(type(error).__name__, str(error))
else:
    raise AssertionError("empty term was accepted")
print(result)

実行結果

ValueError empty search term
[('C++', (0, 3)), ('C', (6, 7)), ('A(1)', (10, 14)), ('a.b', (17, 20))]

語の並び順が結果を変える

正規表現の選択肢A|Bは、同じ開始位置なら左側から試される。Cを先にするとC++の先頭にあるCだけで一致が成立し、長い候補まで進まない。そこで長さの降順に並べ、同じ長さでは文字列順にしている。set()で重複をなくしてから、明示した規則で並べ直すので、集合の列挙順に結果を依存させない。

re.escape()はそれぞれの語へ適用する。連結後の式全体をescapeすると、選択を表す|まで普通の縦棒になり、目的が変わる。逆に一部の語だけescapeし忘れると、その語に含まれる記号が構文へ戻る。検索語を作る段階と式を作る段階を関数に閉じ込めると、後から変更してもこの境界を保ちやすい。

空文字はどの位置にも一致しうるため、検索語として混ざると空のヒットが大量に出る。例では誤設定としてValueErrorにした。語一覧そのものが空なら、検索結果も空のリストとする。空の正規表現をcompileして実行するのとは異なる。これらはデータの少ない日や設定ファイルの読み込み失敗で起きやすいので、正常例と一緒に確認する。

境界・重なり・置換は別に決める

span()の位置はPython文字列上の添字で、終了位置は含まない。日本語などのUTF-8バイト数とは一致しないため、バイナリファイルのオフセットにそのまま使わない。また、Unicodeの見た目が同じでも別のコードポイント列なら一致しない。正規化が必要な場合は、検索文と語一覧の両方を同じ規則で処理し、元の位置との対応にも注意する。

語境界を付けたいからといって、式全体を機械的に\bで囲むのは避けたい。C++の末尾は単語文字ではなく、期待する境界と\bの意味が合わない場合がある。「前後が英数字ではない」「空白に接している」など用途に沿った条件を選ぶ。重なった候補をすべて必要とする検索は、この例と異なる結果仕様として設計する。

re.escape()は検索パターンへ埋め込む文字列のためのものなので、置換文字列にはそのまま流用しない。置換で文字列を文字どおり返したいなら関数を渡す方法がある。語が何十万件もある場合は巨大な選択式の作成・照合コストも問題になるので、入力件数を制限し、実際のデータで時間を測って検索方式を選ぶ。

結果を使う前に確かめること

実行結果ではa.bは見つかり、axbは見つからない。これがピリオドを文字どおり扱えている確認になる。C++とCが同じ文章にあることも、長い語の優先と単独の短い語の検索を同時に確かめるためである。語一覧をジェネレーターとして受け取りたい場合は、この関数の先頭でlist()へ変換する必要がある。例はリストやタプルのように繰り返し走査できる入力を前提にしており、一度しか読めない入力を何回も走査しないよう注意する。

実行環境と関連情報

掲載コードはLinux上のCPython 3.12.14で実行した。OS固有のファイル操作や対話環境の違いは、本文に記した条件に従って扱う。

関連:一致した内容で置換を変える:re.subに関数を渡す / IDの形式を厳密にチェックする:fullmatchとASCII文字の指定

PythonのTopに戻る