はじめに
公開しているWebページに、連絡先のメールアドレスが載っているかを機械で確かめる検査スクリプトを持っている。直したばかりのページに当てたところ、「連絡先が1件も無い」と返ってきた。ブラウザで開くとアドレスは普通に出ている。
原因は正規表現の末尾に付けていた \b だった。HTMLのタグを剥がした結果、アドレスの直後が日本語の「メ」になり、Python の re では語の境目として扱われなかった。検査が「ある」を「無い」と読んだ。
この記事は、実測の記録だ。その1件を \b の問題として一般化し、手元の環境で測れた処理系すべてに同じ入力を流して、どこで境目が立つのかを表にしたものだ。測った範囲だけを書く。測れなかったものは測れなかったと書く。
この記事で分かること
-
user@example.comの直後に日本語が来ると、\bが立つ処理系と立たない処理系がある(21設定で実測) - 同じ処理系でも、フラグやロケールで答えが変わる
- Ruby 2.6 と ugrep の
-Eでは、\wと\bの答えが食い違っていた - 先読みに替えると直るが、メモに書いていた形にも取りこぼしが1件あった
- 手元の静的HTML 1,722ファイルで、タグを剥がすと日本語の隣に英数字が来る箇所が 1,052あった
目次
- 実測した環境
- 測定のやり方
- 再現した症状
- 21設定の結果
- wとbの食い違い
- コマンドラインツールの読み解き
- 先読みで直す
- 先読みが使えない処理系では
- 前側も巻き込む
- タグ除去が境界を作り替える
- 選び方と回帰テスト
- まとめ
実測した環境
測定はすべて2026年10月、1台のMac(macOS 26.5、arm64)で行った。同じ39行の入力(cases.tsv)を全処理系に読ませ、結果を同じ形式で書き出して集計している。
| 処理系 | バージョン | 備考 |
|---|---|---|
| Python | 3.9.6 | str(既定)・re.ASCII・bytes の3通り |
| JavaScript(V8) | Chrome 155.0.8059.84 | ヘッドレスで実行。無フラグ・u・v・iu |
| JavaScript(JavaScriptCore) | macOS 同梱 |
osascript -l JavaScript 経由。無フラグ・u・iu |
| Perl | 5.34.1 | decode 済み・バイト列のまま・/a
|
| Ruby | 2.6.10p210 | macOS 同梱 |
| ripgrep | 14.1.1 | 既定・--no-unicode・-P(PCRE2) |
| ugrep | 7.8.4 |
-E と -P
|
| BSD grep | 2.6.0-FreeBSD |
-E。LC_ALL を C・en_US.UTF-8・ja_JP.UTF-8 で |
| Swift | 6.3.2 |
NSRegularExpression(ICU) |
測れなかったものも書いておく。macOS の grep には -P が無く、grep -P は invalid option で終わる。Node.js はこの環境に入っていなかったので、JavaScript は Chrome と JavaScriptCore で測った。awk(20200816)と sed -E は \b を語境界として扱わず、sed の com\b は「a com」にも反応しなかった。この2つは表に入れていない。
Python は3.9.6だけで測っている。他のバージョンの挙動は測っていないので、ここでは断言しない。
再現した症状
最小の再現はこれだけだ。ドメインは仮の example.com にしている。
import re
html = '<p>連絡先: info@example.com<br>メールでご連絡ください</p>'
plain = re.sub(r'<[^>]+>', '', html) # タグを消すだけ(空白は入らない)
print(repr(plain))
bad = re.compile(r'[\w.%+-]+@example\.com\b')
print('\\b で閉じた版 :', bad.search(plain))
fixed = re.compile(r'[A-Za-z0-9._%+-]+@example\.com(?![A-Za-z0-9-]|\.[A-Za-z0-9])')
print('先読みで閉じた版:', fixed.search(plain))
print('re.ASCII の版 :', re.compile(bad.pattern, re.A).search(plain))
print('タグを空白に置換:', bad.search(re.sub(r'<[^>]+>', ' ', html)))
実行結果はこうなった。
'連絡先: info@example.comメールでご連絡ください'
\b で閉じた版 : None
先読みで閉じた版: <re.Match object; span=(5, 21), match='info@example.com'>
re.ASCII の版 : <re.Match object; span=(5, 21), match='info@example.com'>
タグを空白に置換: <re.Match object; span=(6, 22), match='info@example.com'>
<br> を消した直後に「メ」が来るので、com と メ の間は空白ゼロでつながる。\b で閉じた版だけが None になった。
何が間違っていたか
最初は「アドレスが書かれていない」と疑った。しかし生HTMLを検索するとアドレスは在った。次に「正規表現が壊れている」と疑い、@example\.com までで閉じると見つかった。残るのは \b だけだった。
\b は \w と \W の境目に立つ。Python 3 の str では \w が Unicode の文字を含むので、「メ」は \w に入る。com の m も \w なので、両方が語の中の文字になり、境目は立たない。Python のドキュメントにも、str のパターンは既定で Unicode の \w を使い、re.ASCII で ASCII に限れると書かれている。
21設定の結果
以下の件数はすべて2026年10月の実測(上の環境・21設定)。
次に、これがどの処理系でも起きるのかを測った。入力は user@example.com の直後に、次の文字を1つずつ付けた26行だ。
| 区分 | 件数 | 例 |
|---|---|---|
| 記号・空白・絵文字(検出されるべき) | 12 | 行末・半角空白・カンマ・句点「。」・全角括弧・全角空白・絵文字 |
| かな・漢字(検出されるべき) | 5 | 連・あ・メ・半角カナ ア・長音 ー |
| 全角英数・々(検出されるべき) | 3 | A・1・々 |
| ſ と K(検出されるべき) | 2 | U+017F、U+212A |
| 検出されてはいけない | 4 |
example.comm・example.com9・.jp・-evil.jp
|
パターンは [\w.%+-]+@example\.com\b を基準にした。結果を図にした。
結果の読み方
実測では、記号・空白・絵文字の12件は、21設定のすべてで検出できた。日本語の句点「。」や全角空白で困ることはなかった。割れたのは、\w に入る文字だけだ。
| 結果(実測) | 設定 |
|---|---|
| かな・漢字5件を全部取りこぼす | Python str(既定)、Perl decode 済み、Ruby、ripgrep 既定・-P、ugrep -P、BSD grep(UTF-8)、Swift(ICU) |
| 全部検出できる(実測) | Python re.ASCII・bytes、JavaScript 全部、Perl バイト列・/a、ripgrep --no-unicode、ugrep -E、BSD grep(LC_ALL=C) |
| 例外 | JavaScript の /iu(ſ と K だけ取りこぼす) |
同じ Python でも、str と bytes で答えが違う。同じ Perl でも、文字列を decode したかどうかで違う。同じ BSD grep でも、ロケールが C か UTF-8 かで違った。つまり**「この言語は日本語の前で \b が立つか」という問いには答えがなく**、「どういう文字列を、どのフラグで」まで決めて初めて答えが出る。
逆向きの誤検出はどの設定でも2件
実測では、検出されてはいけない4件のうち、example.comm と example.com9 は正しく弾かれた。しかし .jp(user@example.com.jp)と -evil.jp は、21設定のすべてで検出されてしまった。\b は「. や - の前でも境目が立つ」ので、別ドメインの先頭を同じドメインと読む。日本語の話とは別に、\b で閉じたドメインの検査は、実測した21設定すべてでこの2件を誤る。
wとbの食い違い
もう1つ、\w が日本語に当たるかと、\b が日本語の前で立つかを、別々に測った。素直な実装なら、片方が当たるならもう片方は立たない(逆も同じ)はずだ。
食い違ったのは2設定だった。Ruby 2.6 では \w が「メ」に当たらないのに、\b も立たなかった。
$ ruby ruby_w.rb
[nil, nil, 0]
3つの値は順に、"メ" =~ /\w/、"a@example.comメ" =~ /com\b/、"メ" =~ /[[:word:]]/ だ。\w は nil(当たらない)、\b も nil(立たない)、[[:word:]] は位置0で当たる。Ruby のドキュメントでは \w は ASCII だけで、POSIX ブラケットは Unicode を含むと説明されている。Ruby のドキュメントに従うと、\b だけが Unicode の定義を使っているように見える。これは測定結果から読んだ推測で、実装は確認していない。
ugrep の -E は、\w が日本語に当たるのに、英数字の直後に日本語が来る \b は立った。一方、逆向きの メexample.com に対する \bexample は立たなかった。同じ \b でも、左右で答えが違う。
測定のやり方
結果の前に、どう測ったかを書いておく。測定器が間違っているのに結果だけ信じるのが、この記事の発端と同じ型の事故だからだ。
入力と出力を1つに揃えた
入力は cases.tsv の39行で、各行は「区分・番号・本文」のタブ区切りにした。区分Aが後ろ側の26行、Bが前側の6行、Cが前側の \bexample の7行、Wが \w と \b を単独で見る2行だ。全処理系が同じファイルを読み、出力は「処理系・パターン名・区分・番号・一致したか・一致した文字列」のタブ区切りに揃えた。集計は Python が1つのスクリプトで行う。結果を集計の側で再解釈しないので、処理系ごとの癖が混ざりにくい。
Chrome は --headless=new --dump-dom で、ページ内の JavaScript が結果を <pre> に書き込む方式にした。JavaScriptCore は osascript -l JavaScript で実行している。ripgrep と ugrep は Claude Code に同梱されているバイナリを呼んだ(2026年10月時点の版は上の表のとおり)。
自分の測定器が間違えかけた3点
実測の途中で、測定器そのものを疑った場面が3回あった。
-
ERE では
[\w]が使えない。 POSIX の拡張正規表現では、ブラケットの中の\wは「バックスラッシュと w」という意味になる。ERE のパターンだけ[[:alnum:]_.%+-]に書き換えた。これは前側の検査には影響するが、後ろ側の\bの検査には影響しない。 -
JavaScript の v フラグで194件のエラーが出た。 最初の実行で v の設定は全部
SyntaxErrorだった。「v は何も検出できない」と読む手前で、原因が文字クラスのハイフンだと分かり、\-に書き換えて再測定した。エラーを「0件」と数えていたら、v だけ極端に悪い表になっていた。 -
検出されてはいけない行は2件ではなく4件だった。 最初の集計は「誤検出 2件」を前提に件数を検査していて、assert が失敗した。
example.commとexample.com9は正しく弾かれるので誤検出の数には出ないが、母数は4だ。ここで母数と分子を混同しないよう、表は「検出された件数/ケース数」の形に直した。
処理系ごとの読み解き
以下の件数はどれも2026年10月の実測(上の環境)だ。表を処理系ごとに文章にしておく。出典に当たったのは各ドキュメントの該当ページを開いたところまでで、実装まで追ってはいない。
Python:str と bytes で別物になる
Python 3.9.6 では、str のパターンを str に当てると \w は Unicode を含み、「メ」が語の中に入るので \b が立たない。re.ASCII(re.A)を付けるか、bytes のパターンを bytes に当てると ASCII だけになり、境目が立つ。同じソースコードで、文字列を encode した瞬間に答えが変わる。ファイルを rb で読んで検査していた場合と、encoding="utf-8" で読んだ場合で、検査結果が割れる。
JavaScript:u を付けても ASCII のまま
Chrome 155 の V8 でも、macOS 同梱の JavaScriptCore でも、\b と \w は ASCII の範囲だった。u を付けても変わらない。v も同じだった。変わるのは大文字小文字を無視する i と u(または v)を併用したときだけで、ſ(U+017F)と K(U+212A、ケルビン記号)が \w に入り、アドレスの直後にこの2文字が来ると境目が立たなくなる。\w の定義はMDN の文字クラスエスケープにある。
Perl:decode したかで割れる
Perl 5.34.1 では、文字列を decode('UTF-8', …) した場合は Unicode の規則(0/5)、バイト列のままなら ASCII の規則(5/5)になった。/a を付けても ASCII の規則(5/5)だった。/u は decode 済みと同じ結果だった。Unicode の扱いの詳細は perlunicode にある。ワンライナーで perl -pe に UTF-8 を流すとバイト列扱いになるので、同じパターンでも use utf8 を付けたスクリプトとは答えが逆になる。
Ruby:\w と \b が噛み合わない
Ruby 2.6.10 は、前に書いたとおり \w が ASCII で、\b は日本語の前で立たなかった。ドキュメント上は [[:word:]] や \p{Word} を使えば Unicode の語文字になる。
コマンドラインツールの読み解き
ここからは grep 系のツールだ。
ripgrep と ugrep:既定と -P と -E で別々
ripgrep 14.1.1 は既定で Unicode の \b(0/5)、--no-unicode で ASCII(5/5)だった。-P(PCRE2)にすると既定と同じ 0/5 になった。ugrep 7.8.4 は -P が 0/5、-E が 5/5で、先に書いた左右非対称が出た。ripgrep の詳しい使い方は GUIDE にある。
BSD grep:ロケールで変わる
macOS の grep(BSD grep 2.6.0)は LC_ALL=C だと5/5、en_US.UTF-8 と ja_JP.UTF-8 だと0/5だった。シェルのロケールが違うだけで、同じコマンドの出力が変わる。cron や予約タスクから呼ぶときは、ロケールが空(C)になりがちなので、対話シェルで試した結果と食い違う。
$ LC_ALL=C grep -E -c '@example\.com\b' t1.txt
1
$ LC_ALL=en_US.UTF-8 grep -E -c '@example\.com\b' t1.txt
0
Swift(ICU):Unicode で固定
NSRegularExpression は 0/5 で、設定を変える余地は無かった。ICU の正規表現の仕様はICU のドキュメントにある。
先読みで直す
数値はすべて2026年10月の実測(上の環境・26ケース)。
直し方は、「そのトークンの続きではないこと」を先読みで書くことだ。手元のメモにはこう書いていた。
(?![A-Za-z0-9.-])
これで Python では直った。ところが26行に通すと、全設定で1件取りこぼした。文末のピリオドだ。連絡は info@example.com. のように、文を . で終えると、. を除外しているので検出できない。
そこで、. は「直後が英数字のときだけ」弾く形に変えた。
(?![A-Za-z0-9-]|\.[A-Za-z0-9])
先読みが使える設定は、フラグ違い・モード違いを別に数えて21あった(前の表の21設定とは数え方が別)。26ケースを全部正しく判定できたのは、\b が0設定、最初の先読みが0設定(文末のピリオドで全部落ちる)、2つ目の先読みが19設定だった。残る2設定は JavaScript の /iu と /iv だ。大文字小文字を無視する Unicode モードでは、ſ(U+017F)と K(U+212A)が [A-Za-z] に当たるため、2件取りこぼした。ECMAScript の仕様では、u と i を併用したときの \w にこの2文字が加わると書かれている(ECMAScript の仕様の WordCharacters)。実測でも、/\w/iu.test("ſ") は true、/\w/i.test("ſ") は false だった。
JavaScript の v フラグで書き換えが要った
v フラグでは、[\w.%+-] が SyntaxError になる。
[\w.%+-]+@x => Invalid regular expression: /[\w.%+-]+@x/v: Invalid character in character class
[\w.%+\-]+@x => OK
ハイフンを \- にすれば通る。測定では v の設定だけパターンを \- に直している。MDN の unicodeSets には、v モードでは文字クラスの構文が厳しくなると書かれている。
先読みが使えない処理系では
POSIX の ERE(macOS の grep -E、sed -E)には先読みが無い。こちらは次のように書いた。
@example\.com($|[^A-Za-z0-9.-]|\.($|[^A-Za-z0-9]))
末尾の1文字を消費する書き方で、BSD grep も ugrep -E も26/26だった。macOS の BSD grep では、\b の代わりに [[:>:]](語末)が使える。
$ printf 'info@example.comメ\n' | LC_ALL=C sed -E 's/com[[:>:]]/<END>/'
info@example.<END>メ
$ printf 'info@example.comメ\n' | LC_ALL=en_US.UTF-8 sed -E 's/com[[:>:]]/<END>/'
info@example.comメ
C ロケールでは語末が立ち、UTF-8 ロケールでは立たない。[[:>:]] も \b と同じく、ロケールで答えが変わる。
前側も巻き込む
数値は2026年10月の実測(6ケース+7ケース)。
ここまでは語の後ろ側の話だった。前側にも同じ罠がある。ローカル部を [\w.%+-]+ と書くと、アドレスの手前の日本語まで取り込まれる。
実測では、手前に「連絡先」「MAIL」「メール」を付けた3件で、Python の str は 連絡先user@example.com のようなアドレスを返した。検出は成功するので、0件にはならない。気づきにくいぶん、後ろ側より厄介だ。ローカル部を [A-Za-z0-9._%+-]+ と書けば6件とも user@example.com になった。
前に \b を置いた \bexample\.com でも同じだった。メexample.com と 連絡先example.com は、Python str・Ruby・ICU・ripgrep 既定で外れた。前に置く否定先読みの (?<![A-Za-z0-9.-])example\.com なら、全設定で正しく判定できた。
タグ除去が境界を作り替える
数値は2026年10月に手元の静的HTMLで数えた実測。
最初の症状に戻る。そもそもなぜ日本語が直後に来たのか。HTMLでは <br> や </a> が区切りだったのに、タグを剥がすと区切りが消える。
手元の静的HTML 1,722ファイルで、英数字1文字の直後に日本語が来る箇所を数えた。script・style・コメントは除いている。
実測で、そのまま隣り合っていた箇所が46,411、タグを消した結果として隣り合う箇所が1,052あり、後者は379ファイル(22.0%)に出た。正規表現での近似なので、属性値や本文以外も含みうる。「1,052箇所が全部バグ」という意味ではない。ただ、タグ除去は区切りを黙って消すので、英数字トークンを \b で閉じる検査は、同じ型の誤りを起こせる規模だと分かった。
タグを空白に置き換えれば再現した症状は直る(先の出力の最後の行)。ただしこれは、タグ以外で日本語が隣り合う箇所には効かない。
選び方と回帰テスト
ASCII のアドレスや型番を取りたいなら、まず次の形を使う。
import re
EMAIL = re.compile(
r'(?<![A-Za-z0-9._%+-])[A-Za-z0-9._%+-]+@example\.com(?![A-Za-z0-9-]|\.[A-Za-z0-9])'
)
この関数を、26行(後ろ側)と6行(前側)の32ケースに通した結果がこれだ。
$ python3 selftest.py
cases=32 NG=0
回帰ケースは、少なくとも次の3種類を入れておく。
- 日本語がすぐ後ろに続く(
user@example.comメ) - 文末のピリオドで終わる(
user@example.com.) - 別ドメインが続く(
user@example.com.jp)
2番目は、先のメモの形で取りこぼした。3番目は \b で誤検出する。1番目は今回の症状そのものだ。どれも、パターンを書いた日に1行ずつ足せば済む。
検査が嘘をつく向きは2つある
この検査は「あるのに無いと言う」側に間違えた。以前の記事(検査が返す「0件」の偽陰性、HTTP 200 は生きている証明にならない、押せるかの判定10通り)でも同じ側の話を扱った。「無い」と出たときは、生のデータに在るのかを別の手段で必ず引き直す。これが一番安い防御だった。
まとめ
-
user@example.comの直後に日本語が続くとき、\bが立つかは処理系・フラグ・ロケールで割れた(21設定のうち、かな・漢字を全部取りこぼしたのは8設定) - 実測で、同じ入力で
.jpと-evil.jpの誤検出が、全設定で2件出た。\bは別ドメインも通す - Ruby 2.6 と ugrep の
-Eでは、\wと\bの答えが食い違った - 先読みは
(?![A-Za-z0-9-]|\.[A-Za-z0-9])が良かった。.ごと除外する形は文末のピリオドを取りこぼす。JavaScript の/iuは ſ と K で例外が出る - **タグを剥がす前処理は、HTMLにあった区切りを消す。**手元では1,722ファイルのうち379ファイルで英数字と日本語が直接つながった
次の1アクション
次の1アクションとして、手元の検査スクリプトで、\b で終わる正規表現を1本探して、直後に日本語を付けた1行を入れて走らせる。0件になるなら、その検査は今も黙って取りこぼしている可能性がある。
公開しているサイトの連絡先や公開情報を、同じように機械で点検したい場合は、サイト監査の窓口から相談できる。






