営業時間パーサー設計ドキュメント

なぜこんなに複雑なのか

根本原因: 厚労省データが自由記述

厚労省の薬局データ(10,128件)の hours フィールドは自由記述テキスト。 統一フォーマットの指定がなく、各都道府県・薬局が独自に入力している。 同じ「月-金 9:00-18:00」を表すのに数千通りの書き方が混在する。

最初に試した単純なアプローチ

初版(89.4%)は 月-金:9:00-18:00,土:9:00-13:00 のような「きれいな」パターンだけ対応した。 しかし全角文字、漢字時刻、括弧、スラッシュ、タイプミスなどで1割以上がパースできなかった。

なぜ段階的に複雑になったか

1つのルールを追加するたびに、そのルールが既存ルールの出力に干渉するケースが見つかった。 例えば 金9時金9:00 に変換した後、金9:00金:9:00 のコロン挿入が必要になる。 このように正規化ルールには順序依存性があるため、場当たり的に追加できず、 パイプライン全体の整合性を保つ必要がある。

数字で見る複雑さ

アーキテクチャ概要

生テキスト (raw)
  ↓
parseHours() 冒頭(正規化前の抽出):
  ├→ rawNorm 生成(fullwidth→halfwidth, 祭日→祝, 【】→空白 等の軽量正規化)
  ├→ holidayClosed 検出(6種の正規表現)
  ├→ closedDays 抽出(5パターン: 定休日リスト/Xを除く/括弧内除外/X休み/括弧内休診)
  ├→ hoursNotes 抽出(全除外情報を注記テキストとして保全)
  ↓
normalizeHoursText()  ← テキスト正規化(80+段の置換ルール)
  │  【定休日】専用ハンドラ → 閉店曜日リストごと除去(カンマ区切り)
  │  【】汎用ハンドラ → 曜日指定は展開、それ以外はスペース(構造保持)
  │  ①→カンマ + クリーンアップ
  │  bare-hour チルダ正規化(10~12:30 → 10-12:30)
  ↓
正規化済みテキスト(例: "月-金:9:00-18:00,土:9:00-13:00")
  ↓
splitHoursSegments()  ← 曜日グループごとに分割
  ↓
セグメント配列(例: ["月-金:9:00-18:00", "土:9:00-13:00"])
  ↓
parseHours() 本体   ← 各セグメントから曜日+時間帯+祝日情報を抽出
  ↓
applyClosedDays()    ← closedDays をスケジュールに保守的に適用
  │                     (単一 multi-day エントリ内のみ。曖昧な場合は適用しない)
  ↓
構造化データ: { schedule, holidaySchedule, holidayClosed, closedDays, hoursNotes }
  ↓
getHoursInfo(raw, ctx) ← 今日の営業状態を判定(祝日・深夜日跨ぎ考慮)
  │                       ctx = getJstContext()(JST日時をバッチで1回計算)
  │                       isJapaneseHoliday() で祝日判定
  ↓
{ todayRanges, isOpen, isHoliday, holidayClosed, hoursNotes, ... }
  ↓
renderHoursHtml(raw, info)
  │  バッジ + 今日の営業時間
  │  折りたたみ「全営業時間」:
  │    ※除外注記(amber 背景ストリップ、グリッド上部に表示)
  │    週間スケジュールグリッド + 祝日行
  │    ※営業状況は店舗にご確認ください

normalizeHoursText(): なぜ75段もあるのか

入力テキストの揺れを「1つの正規形」に収束させるのが目的。 各ルールは実データで発見された具体的なパターンに対応している。

Phase 1: 文字レベルの統一

なぜ最初にやるか: 後続の全ルールが 9:00 - などの半角・標準文字を前提にしている。 ここで統一しないと、後続の正規表現が全角版と半角版の2倍必要になり、保守不能になる。

ルール 入力例 出力 なぜ必要か
全角数字→半角 9:00 9:00 全角混在が非常に多い
コロン系文字統一 ː : 5種類のコロン類似文字が実データに存在
チルダ系→~ ~ 範囲記号が4種類
ダッシュ系→- - 9種類のハイフン/ダッシュが存在
句読点統一 , 区切り文字の揺れ
CJK互換文字 Unicode互換領域の文字が一部データに混入

実例: ある県のデータは全件 ⽉⽕⽔⽊⾦⼟⽇ (CJK互換)で入力されていた。 見た目は同じ「月火水」だが Unicode のコードポイントが違うため、正規表現 [月火水] にマッチしない。

Phase 2: 日本語表現の正規化

なぜ必要か: 同じ曜日を 月曜 月曜日 の3通りで書く人がいる。 また 平日 年中無休 のような日本語略称もある。 曜日を1文字に統一しないと、後続の曜日パース(parseDaySpec)が組合せ爆発する。

ルール 入力例 出力 なぜ必要か
曜日suffix strip 月曜日 金曜 だけで十分。ただし金曜日曜金日(後述)
range末尾+曜日隣接 月-金日(strip後) 月-金・日 曜日stripで消えたのせいで金と日がくっつく
年中無休毎日 年中無休 毎日 曜日指定なしの表現
平日月-金 平日 月-金 日本語の略称
24時間→全日 24時間 毎日:0:00-24:00
/表記 9時30分 18時 9:30 18:00 漢字の時刻表記
から- 月から金 9:00から18:00 月-金 9:00-18:00 ひらがな接続詞
/: 月は9:00 水が8:00 月:9:00 水:8:00 助詞が区切り文字代わり

ハマった事例: 金曜日曜 の貪欲マッチ 月曜-金曜日曜9:00-18:00曜日? で strip すると、金曜日 が先にマッチして (日曜の日)まで食べてしまい、日曜が消える。 修正: 曜(?:日(?!曜))? の後に が続く場合は食べない(次の曜日の先頭だから)。 さらに strip 後の 月-金日月-金・日 に分離する追加ルールが必要になった。

Phase 3: 区切り文字の正規化

なぜ必要か: parseHours は 曜日:時間 の形だけを認識する。 しかし入力データでは曜日と時間の区切りに以下が使われている:

すべて 曜日:時間 の形に統一する。

セグメント間の区切りも同様: - カンマ ,(標準) - スラッシュ 月-金:9:00/土:9:00 - ピリオド 月-金:8:30.土:8:30 - スペース 月-金:9:00 土:9:00 - 句点 - 直接結合 月-金:9:00-18:00土:9:00-13:00

Phase 4: 時刻フォーマット修正

ルール 入力例 出力 なぜ必要か
3-4桁時刻 1800 900 18:00 9:00 コロン省略
分省略 9-18:00 9:00-18:00 開始時刻の:00省略
.: 18.30 18:30 ピリオドが時刻区切り
ダブルコロン 水::9:00 水:9:00 タイプミス
連続時間帯 14:0015:00 14:00 15:00 区切りなし

Phase 5: 休業日・祝日情報の抽出と除去

3段階の処理: 「祝日抽出 → 除外情報抽出 → テキスト除去」。情報は除去前に全て抽出される。

5a: 祝日情報の抽出(parseHours 内、正規化前)

parseHours() は正規化の生テキストから祝日関連情報を抽出する:

  1. holidayClosed フラグ: 祝休み 日祝:閉局 定休日:日・祝 祝を除く 等のパターンを 6種の正規表現で検出。正規化後だとこれらの文字列が除去されて失われるため、先に検出する。
  2. holidaySchedule: 正規化後のセグメント分割で 祝:9:00-17:00 のような祝日営業時間を捕捉し、 holidaySchedule 配列に格納する。
月-金:9:00-18:00,日祝:休み → holidayClosed = true
月-金:9:00-18:00,祝:9:00-12:00 → holidaySchedule = [{open:"9:00", close:"12:00"}]

5b: 除外情報の抽出(parseHours 内、正規化前)

holidayClosed と同じ手法で、rawNorm から除外/休業情報を抽出する。5つの抽出パターン + 3つの注記専用パターン:

パターン 対象 closedDays hoursNotes
A: 定休日リスト 定休日:水曜 休診日:木・日 定休日 水 水(3) 定休日 水
B: Xを除く 水を除く月-金 木曜日を除く 水を除く 水(3) 水を除く
C: 括弧内除外 (除く水曜) (木は除く) (除く水曜) 水(3) 除く水曜
D: X休み/休診 土日祝休み 水曜午後休診 水曜午後休診 水曜午後休診
E: 括弧内休診 (水曜は休診) (木休診) (木休診) 木(4) 木休診

分類基準: 午前/午後/時刻が絡む → hoursNotes のみ(部分休業)。曜日のみ → closedDays + hoursNotes。第N曜日/最終/複合語 → hoursNotes のみ(Pattern B の negative lookbehind でブロック)。

保守的適用ルール(applyClosedDays): closedDays は単一 multi-day エントリ内の曜日のみ除去する。同じ曜日が複数エントリに現れる場合(例: 平日:9:00-18:00 + 木・土:9:30-13:00)は適用しない — 異なる時間帯を持つ可能性があるため。

hoursNotes の表示: 全除外情報を amber 背景ストリップの注記として、折りたたみ「全営業時間」内のスケジュールグリッド 上部 に表示。closedDays 適用の有無にかかわらず常に表示。

原則: 情報は捨てない(DESIGN.md §6「写像の限界」参照)。正規化段階で休業日テキストを除去しても、事前抽出により情報は hoursNotes に残る。

5c: 休業日テキストの除去(normalizeHoursText 内)

抽出後、休業日テキストは正規化パイプラインで除去する。 曜日パーサーの出力は {days, open, close} の配列で「休み」を表現する型がない。 残すとパース失敗→本来パースできる営業時間まで道連れで生データ表示になるため、 除去して営業日だけ構造化し、休業日は「スケジュールに載っていない=休み」として暗黙表現する。 ただし除外情報は 5b で既に保全済みのため、情報損失は発生しない。

月-金:9:00-18:00,土日祝休み      → 月-金:9:00-18:00
月-金:9:00-18:00,日祝:定休       → 月-金:9:00-18:00
月-金:9:00-18:00,休:日祝         → 月-金:9:00-18:00
月-金:9:00-18:00 日              → 月-金:9:00-18:00(末尾の裸の曜日=休み)
月-金:9:00-18:00,水日祝は        → 月-金:9:00-18:00

Phase 6: 第N曜日(ordinal day)の処理

なぜスキップか: 第1・3土曜:9:00-12:00 は「月の第1・第3土曜だけ営業」の意味。 週次スケジュール({days, open, close})では「第何週の何曜日」を表現できない。

最初は「括弧内を strip して通常曜日として扱う」(=毎週土曜として表示)案で実装した。 しかしユーザーから「これは不正。情報の意味が変わる」と指摘を受けた。 「第1・3土曜のみ」を「毎週土曜」と表示すると、第2・4・5土曜に来局してしまう人が出る。 薬のアクセスに関わる情報なので、誤った営業日の表示は許容できない。 第N曜日の情報はスケジュールには反映せず、hoursNotes に ※第1・3土曜を除く 等の注記として保全する。

正規化段階では: - 漢数字→算用数字: 第一土第1土 - ordinalセパレータ統一: 第1,3,5 / 第1.3.5第1・3・5 - 括弧内ordinal+時間帯: (第2,4木:9:00-19:30) → 除去

parseHours段階では: - 土(第1-3):8:30-18:00 → 土をスキップ(第N限定なので毎週ではない) - 水・土(第1・3・4):9:00-13:00 → 水だけ残す、土(第1・3・4)はスキップ - 月-金,第1・3・5土:9:00-19:00 → 月-金だけ残す、第1・3・5土はスキップ

設計判断: Correct > Correct + caveat > Unknown > Wrong(DESIGN.md §6 参照)。 「毎週土曜営業」と誤解させる(Wrong)より、注記で「※第1・3土曜のみ」と伝える(Correct + caveat)方が安全。

splitHoursSegments(): なぜ単純なsplitではダメか

最初は .split(",") だけだった。 しかしカンマの意味が文脈で変わるため、 単純分割だと壊れるケースが大量に見つかった:

  1. セグメント区切り: 月-金:9:00-18:00,土:9:00-13:00(月-金と土は別グループ)
  2. 時間帯区切り: 月-金:9:00-13:00,14:00-18:00(午前と午後の2部制)
  3. 曜日列挙: 月,水,金:9:00-18:00(月・水・金は同じ時間)

アルゴリズム: 1. まず時刻の後に曜日が来る境界で分割(9:00-18:00土: → 2つのセグメント) 2. 各セグメント内でカンマ分割し、以下のヒューリスティクスでマージ: - 「曜日+時間」→ 新しいセグメント - 「時間のみ」→ 直前セグメントに追加(午前/午後の2部制) - 「曜日のみ」→ 次の要素とマージ(月,水,金:9:00月・水・金:9:00

parseTimeRange(): 時刻バリデーション

なぜ 29:59 まで許容するか: 日本の慣習として深夜営業を 25:00(= 翌1:00)と表記する。 24:00 以降を弾くと正当なデータを拒否してしまう。上限 29:59 は実用上十分。

なぜバリデーションが必要か: 元データにタイプミスがある: - 8:30-6:00 — おそらく 8:30-16:008:30-18:00 - 9:00-1:30 — おそらく 9:00-13:00

バリデーションなしだと 80:3088:50 も通ってしまう(実際にあった)。 hours 0-29, minutes 0-59 の範囲チェックで弾き、生データ表示にフォールバックする。

expandDayRange(): wrap-around対応

なぜ do-while か: 月-日 は「月曜から日曜まで = 全7日」。 内部の曜日番号は 日=0, 月=1, ..., 土=6 なので、 月(1)-日(0)1→2→3→4→5→6→0 と wrap する。

初期実装は for (i=start; i<=end; i++) だった。 月-日 の場合 1<=0 が即 false → 空配列 → パース失敗になるバグがあった。 do-while + modular arithmetic に修正して wrap-around を正しく処理する。

parseDaySpecExtended(): 複合曜日指定

parseDaySpec() は単純パターン(月-金 or 月・火・木)のみ処理。 parseDaySpecExtended() は以下の複合パターンに対応:

残り2.9%がパースできない理由

パース不能な292件の主な原因:

原因 件数(概算)
括弧内の例外条件 月~金:9:00-17:00、(13:00-14:00は閉局) ~50
曜日ごとの個別記述(タブ区切り) 月\t09:00~19:00火\t09:00~19:00... ~20
URL https://www.example.com 数件
時間帯なし 月-金: 数件
自然言語の条件 お客様感謝デーを除く 隔週で17:00まで ~30
括弧の対応不良 土(第2,4:8:30-17:00) ~20
データ不良 9:00-8:00 8:30-6:00 ~10

これらは個別対応のコスト(コード複雑化)に対してカバー率の改善が小さいため、 生データ表示のフォールバックで対応している。

祝日対応

なぜ祝日を考慮するか

営業時間データに「祝日休み」「日祝:9:00-12:00」等の祝日関連情報が含まれているが、 初期実装では祝日かどうかを判定しなかった。その結果:

3層構造

Layer B: getJapaneseHolidays(year) / isJapaneseHoliday(date)
  → 純粋計算(~60行)。外部依存ゼロ、~2099年まで有効
  → 固定日・ハッピーマンデー・春分秋分(天文公式)・振替休日・国民の休日

Layer A: parseHours() の拡張
  → 生テキストから holidayClosed(祝日休みフラグ)を正規表現で検出
  → 正規化後のセグメントから holidaySchedule(祝日営業時間)を抽出
  → 既存の曜日パースは一切変更なし

Layer C: getHoursInfo() の拡張
  → isJapaneseHoliday(today) で祝日判定
  → holidayClosed → 休み表示
  → holidaySchedule → 祝日時間を使用
  → どちらもなし → 通常曜日にフォールバック

holidayClosed の検出

生テキスト(正規化前)に対して6パターンの正規表現で検出: - 祝休み 祝:休み 祝は休業(正順) - 休業日:日祝 定休日:日・祝(逆順) - 祝を除く (祝除く)(除外表現) - 祝祭日 に正規化、半角カナ中黒 も正規化

検出率: 137件 / 152件(残り15件はパーサー本体の変更が必要でコスト高)。false positive: 0件。

深夜営業の日跨ぎ判定

close > 24:00(例: 25:00 = 翌1:00)の営業枠が翌日に跨る場合: - 前日のスケジュールを確認し、nowMin < closeMin - 1440 で判定 - 前日が祝日だった場合は祝日オーバーライドも考慮

パフォーマンス: getJstContext()

getHoursInfo() 内の toLocaleString("en-US", {timeZone: "Asia/Tokyo"}) は高コスト。 カードごとに呼ぶと50回/検索で無駄。getJstContext() でバッチごとに1回だけ計算し、 getHoursInfo(raw, ctx) に渡す設計に変更。

コード上の注意点

正規化ルールの順序依存

多くのルールは前のルールの出力に依存する。例:

  1. :00 変換は曜日strip後に実行する必要がある(金9時金9:00金:9:00
  2. コロン挿入(金9:00金:9:00)は変換後に再実行する必要がある
  3. チルダ→ダッシュ変換も変換後に再実行(9:00~18:00 が新たに生成される)
  4. 休業日stripはお休み休み変換の後に実行する

ルールの順序を変更する場合は、全データでの回帰テストが必須。

parseHoursの設計方針: なぜ部分パースを返さないか

パース不能なセグメントに遭遇した場合、全体を失敗にする(return null)。

理由: 月-金:9:00-18:00,土:???,日:9:00-14:00 で土だけスキップして月-金+日を返すと、 ユーザーは「この薬局は土曜やっていない」と誤解する。実際には土曜も営業しているかもしれない。 全体失敗→生データ表示の方が、ユーザーが自分の目で判断できる。

例外的に graceful skip するのは、スキップしても情報が歪まないケース: - 「休み」セグメント(もともと営業していない) - 祝日セグメント(週次スケジュールの対象外) - ordinalセグメント(第N限定は週次で表現不能、スキップが正解) - 不正な時間帯(データのタイプミス)

テスト方法

# カバー率テスト
node -e '
const code = require("fs").readFileSync("docs/app.js","utf8");
const endIdx = code.indexOf("function timeToMinutes");
global.document = {getElementById:()=>null};
eval(code.substring(0, endIdx));
const data = require("./docs/data.json").data;
let total=0, ok=0;
for (const r of data) {
  if (!r.hours?.trim()) continue;
  total++;
  const p = parseHours(r.hours);
  if (p?.schedule?.length) ok++;
}
console.log(ok + "/" + total + " = " + (ok/total*100).toFixed(1) + "%");
'

改修履歴

コミット 内容 カバー率
3c3c685 初版パーサー 89.4%
4fb20eb 6項目の正規化改善 95.7%
39c4597 第N曜日対応 96.6%
03987aa 土(第N)スキップ修正(情報歪み防止) 96.3%
0580d00 ordinal処理リファクタ 96.9%
8d48a6a 深層検証バグ修正 97.1%
04853e7 金曜日曜の貪欲マッチ修正 97.1%
c3a7f62 祝日対応(3層構造)、月曜始まり化 97.1%
85870b8 holidayClosed regex拡張(125→137件)、深夜営業isOpen修正、double parse解消 97.1%
d3091c7 getJstContext() によるパフォーマンス改善 97.1%
2cf05b5 医療機関対応: AM/PM変換、午前午後、除く安全処理、括弧曜日、注記スキップ、parseTimeRange末尾許容 薬局98.2% / 医療機関88.3%