同じサイトなのにURLの形式がバラバラという現実
スクレイピングや自動化のスクリプトを書いていると、対象ページを一意に識別するために「URLからページ固有のIDを取り出す」という処理が必ずと言っていいほど必要になります。ところが同じサイトであっても、実際に流通しているURLの形式は1種類ではありません。パス型(https://shop.example/item/AB12CD34EF56/)もあれば、クエリ型(https://shop.example/detail?pid=AB12CD34EF56)もあり、短縮リンク(https://shop.example/p/AB12CD34EF56)や、広告経由で余計なトラッキングパラメータが大量に付いたURLも混在します。
最初は「パス型だけ対応すればいい」と思って実装しても、収集対象を広げるうちに別の形式のURLが混ざり込み、IDが取れずに処理がスキップされたり、誤った文字列をIDとして拾ってしまったりする不具合にすぐ突き当たります。
1本の巨大な正規表現に全部詰め込むと保守できなくなる
複数の形式に対応しようとして、最初にやりがちなのが「1本の正規表現に|で分岐を詰め込む」やり方です。
# アンチパターン: 分岐を詰め込みすぎた正規表現
URL_ID_REGEX = %r{
(?:/item/|[?&]pid=|/p/)([A-Z0-9]{8,14})
}x
見た目はコンパクトですが、これは実際には破綻しやすい書き方です。新しいURL形式が見つかるたびにこの1本に分岐を追加していくと、どの分岐がどのURL形式に対応しているのか読み取りにくくなり、ある形式向けの修正が別の形式のマッチを壊す、ということが起こります。さらに「このURLがマッチしなかった理由」も「どの分岐でマッチしたのか」も正規表現1本からは分からず、原因調査のたびに正規表現とにらめっこすることになります。
「厳しい順に試す正規表現の配列」という設計
そこで採用したのが、URL形式ごとに正規表現を独立したエントリとして用意し、厳密なものから緩いものへ順番に並べた配列を上から順に試す、という設計です。最初にマッチしたパターンを採用し、後続のパターンは試しません。
PATTERNS = [
{ name: :path_item, regex: %r{/item/([A-Z0-9]{8,14})(?:[/?]|z)} },
{ name: :query_pid, regex: %r{[?&]pid=([A-Z0-9]{8,14})(?:&|z)} },
{ name: :short_link, regex: %r{/p/([A-Z0-9]{8,14})(?:[/?]|z)} },
# 最後は緩いフォールバック: パス中の英数字トークンを拾うだけ
{ name: :loose_fallback, regex: %r{/([A-Z0-9]{8,14})(?:[/?]|z)} }
].freeze
def extract_page_id(url)
PATTERNS.each do |pattern|
match = url.match(pattern[:regex])
next unless match
candidate = match[1]
next unless valid_page_id?(candidate)
return { id: candidate, matched_by: pattern[:name] }
end
nil
end
パスやクエリの構造を厳密に指定したpath_item・query_pid・short_linkを先に試し、それでも取れなかった場合だけ、パス中の英数字トークンを緩く拾うloose_fallbackに落ちる、という優先順位です。新しいURL形式が見つかったら、既存のパターンをいじらずに配列へ1件追加するだけで済むため、変更が他の形式に影響しません。
取れた値の妥当性検証を挟む
正規表現がマッチしたからといって、その値が本当にページ固有のIDとして妥当とは限りません。特にloose_fallbackはパスの構造をほとんど見ていないため、日付や言語コードのような紛らわしい文字列を誤って拾う可能性があります。そこで、マッチ後に桁数と文字種を独立してチェックする関数を必ず通します。
def valid_page_id?(candidate)
return false unless candidate.is_a?(String)
return false unless candidate.length.between?(8, 14)
candidate.match?(/A[A-Z0-9]+z/)
end
正規表現の中にも{8,14}と文字種の指定は書いてありますが、それとは別にこの検証関数を通す二段構えにしています。理由は、将来正規表現側の桁数指定を緩めたり、別のパターンを追加したりしたときに、うっかり検証が抜け落ちるのを防ぎたいからです。抽出ロジックと妥当性の判断基準を分けておくことで、どちらか一方だけを直したいときに影響範囲を限定できます。
どのパターンでマッチしたかのログが後の調査を楽にする
extract_page_idがmatched_byにパターン名を含めて返しているのがこの設計の要です。呼び出し側では、IDそのものだけでなく、どのパターンで拾えたのかも記録しておきます。
result = extract_page_id(url)
if result
logger.info("id=#{result[:id]} matched_by=#{result[:matched_by]} url=#{url}")
else
logger.warn("id extraction failed url=#{url}")
end
これを続けておくと、例えば「今週からloose_fallbackにマッチする割合が急に増えた」といった変化に気づけます。これはサイト側でURL形式が変わり始めているのに、厳密なパターンがまだ追随できていないという前兆であることが多く、緩いフォールバックに頼り切る前に専用パターンを追加する判断材料になります。IDが取れた場合でも「どう取れたか」を残しておくことが、正規表現配列を長く保守するうえで効いてきます。
テーブル駆動テストで全パターンをまとめて担保する
パターンが増えるほど、1つの修正が別のURL形式を壊していないかを都度手で確認するのは非現実的になります。そこで、URLと期待するID・期待するマッチパターン名を並べたテーブルを用意し、Minitestで1件ずつテストケースを動的に生成します。
require 'minitest/autorun'
class ExtractPageIdTest < Minitest::Test
CASES = [
{ url: "https://shop.example/item/AB12CD34EF56/", id: "AB12CD34EF56", pattern: :path_item },
{ url: "https://shop.example/detail?pid=CD34EF56AB12", id: "CD34EF56AB12", pattern: :query_pid },
{ url: "https://shop.example/p/EF56AB12CD34", id: "EF56AB12CD34", pattern: :short_link },
{ url: "https://shop.example/item/AB12CD34EF56?ref=ad&utm_source=x",
id: "AB12CD34EF56", pattern: :path_item },
{ url: "https://shop.example/misc/JP2026SUMMER/", id: "JP2026SUMMER", pattern: :loose_fallback }
].freeze
CASES.each_with_index do |c, i|
define_method("test_case_#{i}_#{c[:pattern]}") do
result = extract_page_id(c[:url])
refute_nil result, "id が抽出できませんでした: #{c[:url]}"
assert_equal c[:id], result[:id]
assert_equal c[:pattern], result[:matched_by]
end
end
end
assert_equal c[:pattern], result[:matched_by]まで確認しているのがポイントです。IDの値だけを検証していると、本来query_pidで拾うべきURLが偶然loose_fallbackでも同じ値を拾ってしまい、意図しないパターンでマッチしていることに気づけません。新しいURL形式が見つかったときは、このテーブルに1行追加するだけでテストが増え、既存パターンへの回帰も同時に検知できます。
まとめ
- 同じサイトでも複数のURL形式が混在するため、1本の巨大な正規表現に分岐を詰め込む実装は早々に保守不能になる
- URL形式ごとに独立した正規表現を用意し、厳密なものから緩いものへ順に並べた配列を上から試して、最初にマッチしたものを採用する
- 正規表現のマッチだけでなく、桁数・文字種を独立して検証する関数を必ず通し、緩いフォールバックの誤検出を防ぐ
- 抽出結果に「どのパターンでマッチしたか」を含めてログに残すと、緩いフォールバックへの依存が増えていないかといった兆候にも気づける
- URLと期待するID・期待するマッチパターン名を並べたテーブル駆動テストで、パターン追加のたびに全ケースの回帰を機械的に確認する


コメント