営業時間パーサー設計ドキュメント
なぜこんなに複雑なのか
根本原因: 厚労省データが自由記述
厚労省の薬局データ(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 のコロン挿入が必要になる。
このように正規化ルールには順序依存性があるため、場当たり的に追加できず、
パイプライン全体の整合性を保つ必要がある。
数字で見る複雑さ
- 全11,734件(hours非空)のうち、一意なフォーマットは数千種
normalizeHoursTextだけで75個の.replace()呼び出し(約156行)- パーサー全体(正規化〜レンダリング)で約590行、正規表現パターン約150個
- 結果として薬局カバー率 98.2%(9,768件をパース可能)、医療機関カバー率 88.3%(2,739件をパース可能)
- 残りは自然言語(「外来診療時間内」等)・URL・データ不良
アーキテクチャ概要
生テキスト (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 - スラッシュ
月-金/9:00 - セミコロン
月-金;9:00 - 括弧
(月火金)9:00、月火木)9:15 - 角括弧
[月-金]9:00、【月】9:00
すべて 曜日:時間 の形に統一する。
セグメント間の区切りも同様:
- カンマ ,(標準)
- スラッシュ 月-金: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() は正規化前の生テキストから祝日関連情報を抽出する:
- holidayClosed フラグ:
祝休み日祝:閉局定休日:日・祝祝を除く等のパターンを 6種の正規表現で検出。正規化後だとこれらの文字列が除去されて失われるため、先に検出する。 - 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(",") だけだった。 しかしカンマの意味が文脈で変わるため、
単純分割だと壊れるケースが大量に見つかった:
- セグメント区切り:
月-金:9:00-18:00,土:9:00-13:00(月-金と土は別グループ) - 時間帯区切り:
月-金:9:00-13:00,14:00-18:00(午前と午後の2部制) - 曜日列挙:
月,水,金: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:00 か 8:30-18:00
- 9:00-1:30 — おそらく 9:00-13:00
バリデーションなしだと 80:30 や 88: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() は以下の複合パターンに対応:
- range + 個別:
月-水・金→ [月,火,水,金] - 連続文字:
月火水金→ [月,火,水,金](・なし列挙) - カンマ区切り:
月,火,水,金→ [月,火,水,金] - 混合:
月-水,金,土日祝→ [月,火,水,金,土,日](祝はスキップ)
残り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」等の祝日関連情報が含まれているが、 初期実装では祝日かどうかを判定しなかった。その結果:
- 「月-金:9:00-18: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) に渡す設計に変更。
コード上の注意点
正規化ルールの順序依存
多くのルールは前のルールの出力に依存する。例:
時→:00変換は曜日strip後に実行する必要がある(金9時→金9:00→金:9:00)- コロン挿入(
金9:00→金:9:00)は時変換後に再実行する必要がある - チルダ→ダッシュ変換も
時変換後に再実行(9:00~18:00が新たに生成される) - 休業日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% |