設計思想
このドキュメントでは、緊急避妊薬薬局検索サイトの設計判断とその背景を記録する。「なぜこうなっているのか」を理解したい開発者や、同種のプロジェクトを作る方の参考にどうぞ。
技術的な仕様は 機能仕様書 と 営業時間パーサー設計 を参照。
1. このサイトは「検索ツール」であり「医療情報サイト」ではない
最も根本的な設計判断。緊急避妊薬の効能・副作用・服用方法といった医療情報は扱わない。
理由:
- 医療情報の正確性を担保し続けるのは専門機関の仕事であり、個人プロジェクトが負うべき責任ではない
- このサイトのユーザーは「今すぐ薬局を見つけたい」人であり、医療情報を読むフェーズではない
- スコープを絞ることで、検索機能の品質に集中できる
このため、ヘッダーやフィルター周辺に医療情報や長い説明文を置くことは意図的に避けている。急いでいるユーザーの導線にノイズを足すべきでない。利用判断に関わる情報は FAQ で対応している(FAQ の設計原則は §10 を参照)。
2. フィルター設計原則
このサイトのフィルターには2種類ある。判断基準がそれぞれ異なる。
デフォルト除外(ユーザーに選択肢を見せない)
緊急避妊薬は時間勝負。デフォルトで選択肢を隠す判断は慎重に、各軸を深く検討して行う。以下の 3軸 で評価する:
| 軸 | 問い |
|---|---|
| 情報の出所 | 施設自身の申告(一次情報)か、こちらのパース・推定(二次加工)か |
| 代替の有無 | 除外された選択肢の目的を、別ルートで達成できるか |
| 除外しない場合の害 | 無駄足のリスク(ノイズ)か、有用な選択肢の喪失か |
適用例: 医療機関の常時在庫フィルター → 除外OK
医療機関データには厚労省PDFの「常時在庫の有無」列がある。「無」「不明」はデフォルトで非表示としている。
- 情報の出所: 施設自身が「常備していない」と厚労省に回答(一次情報)
- 代替: 在庫なし施設は診察後に院外処方→薬局へ。薬局は11,000件超掲載済みで情報損失なし
- 除外しない場合の害: 「常備していない」施設に期待して行く無駄足リスク
→ 一次情報 + 代替あり + ノイズ除去の実益。除外は妥当。
適用例: 「今対応可能」フィルター → ~~実装しない~~ ユーザー起動型として実装
当初は「実装しない」と判断していた。しかしパーサー改善(薬局98.2%、医療機関88.3%)と「不明バッジ」設計により、3軸評価が変わった:
- 情報の出所: パーサー推定(二次加工)だが、パース成功レコードの時間判定は正確。フィルターが隠すのは「パース成功 AND 営業時間外 AND 時間外対応なし」のみ
- 代替: パース不能施設は隠さず「営業状況不明」バッジ付きで表示。時間外対応ありの薬局は「時間外対応可」バッジ付きで表示。最寄り施設が消えることはない
- 除外しない場合の害: 東京都で夜10時 → 50件中40件が営業時間外。モバイルで10件の営業中を探すスクロールは、緊急時に実害がある
→ 「確実に閉まっている(時間外対応もない)」だけを隠し、不確実なもの(パース不能)や時間外対応ありの施設は残す設計。フィルターが新たなリスクを追加しない。デフォルトOFF(ユーザー起動型)。「時間外対応あり」チェックボックスはこのフィルターに統合して廃止。バッジは4値(緑「営業中」/ 青「時間外対応可」/ 灰「営業時間外」/ 琥珀「営業状況不明」)。詳細は NOW_OPEN_FILTER.md を参照。
ユーザー起動型フィルター(ユーザーが自分でONにする)
OFFで全件見えるため、判断基準はデフォルト除外より緩い:
- データが一次情報であること — 施設の自己申告に基づくこと
- 絞り込み率が意味のある水準であること — 通過率が高すぎるフィルターは機能しない
適用例: 個室あり → 実装
privacy フィールドに「個室」を含む薬局に絞り込む。
- データ: 施設の自己申告(一次情報)
- 絞り込み率: 1,443件/10,128件(14%)。十分に意味のある絞り込み
適用例: 衝立あり → 実装しない
- 全薬局の79%が該当。10件中9件が通過するフィルターは機能しない
- 緊急時にフィルターが多すぎると迷いの原因になる
3. 空レコード除外と件数表示の2層構造
厚労省データには住所・電話番号・営業時間がすべて空のレコードが含まれる(2026年3月時点で177件)。備考欄に「XXXへ統合」等とあり、統合・廃止済みの施設。これらはユーザーにとって無価値(電話もかけられず、場所も分からない)なので、data.json から除外する。
除外の判断根拠
フィルター設計原則(セクション2)の3軸で評価:
- 情報の出所: 厚労省データ自体が addr / tel / hours すべて空(一次情報として「情報なし」)
- 代替の有無: 統合先の施設が別レコードとして存在する。情報損失ゼロ
- 除外しない場合の害: 名前と「備考: XXXへ統合」だけのカードが表示され、ユーザーの信頼を損なう
→ フィルターですらなく、データ品質の問題。表示しない判断に迷う余地がない。
件数表示の設計
除外により data.json の件数は厚労省公表件数より少なくなる。この2つの数字はそれぞれ別の意味を持つ(2026-03-25時点の例):
| 数字 | 意味 | 表示場所 |
|---|---|---|
| 11,931 | 厚労省が公表した施設数(データの網羅性) | ハイライトカード「💊 全国 11,931 件」 |
| 11,734 | 実際に検索可能な施設数(ユーザーが触れるデータ) | ステータスバー「11,734件の薬局データを読み込みました」 |
ハイライトカードは「厚労省公式データ全件収録」と謳っている。ここに表示すべきは「自分たちのデータベースの件数」ではなく「厚労省データの規模」。だから meta.totalPublished(厚労省公表件数)を使う。
ステータスバーは検索エンジンの動作報告。実際に検索対象として読み込んだ件数を正確に表示する。
実装
update_data.py: addr 空レコードをスキップ。meta.totalPublished = len(records) + skippedで厚労省公表件数を保持。meta.scriptHashでキャッシュの自動無効化を保証(§8参照)app.js(init):!rr.addrで防御フィルター(data.json 直編集等への安全策)app.js(ハイライト):META.totalPublished || DATA.lengthで表示。totalPublished がない古いデータでもフォールバック
title / meta の概数表現(「全国11,000件以上」等)は厚労省データの規模を参照しており、除外の影響を受けない。
4. 薬局デフォルト・医療機関トグル方式
デフォルトは薬局のみ表示。「常時在庫ありの医療機関も表示」トグルをONにすると clinics.json を遅延読み込みして結果に統合する。
却下した他の方式
| 方式 | 却下理由 |
|---|---|
| タブ分離(薬局タブ / 医療機関タブ) | 「近い順」で薬局と医療機関を横断ソートできない。最寄りが医療機関でも気づけない |
| 完全統合(常に両方表示) | フィルターが複雑化する。薬局にしかないフィールド(女性薬剤師、個室等)と医療機関にしかないフィールド(産婦人科標榜、常時在庫)が混在 |
| 別ページ | 緊急時に遷移が余計。1ページで完結すべき |
デフォルトを薬局にした理由
- 薬局は処方箋なしで購入できる(2026年2月からOTC化)。医療機関は診察を受けて処方してもらう必要があり、手順が1段階多い
- 既存のSEO・ブックマーク・外部リンクへの影響ゼロ(既存ユーザーの体験が変わらない)
clinics.json(972KB)を使わないユーザーにはダウンロードさせない(パフォーマンス)- 薬局が少ない地域では「医療機関も検索できます」バナーが自動表示され、誘導は機能している
カードの視覚的区別
薬局と医療機関が混在する検索結果で、ユーザーが一目で区別できることが重要。医療機関カードには赤の左ボーダー + 「医療機関」ラベルを表示。地図では薬局=青ピン、医療機関=赤ピン。
医療機関カードに「※医師の対面診察・処方箋が必要です」のような注記は付けない。理由:
- 自明な情報はノイズ — 医療機関で診察があるのは常識。このサイトの利用者は急いでいて不安な状態にあり、カードに載る情報が多いほど本当に必要な情報(住所・電話・営業時間)が埋もれる
- 「処方箋」は誤解を招く — 表示対象は常時在庫ありの医療機関のみ(§2参照)。院内処方でその場でもらえるのに「処方箋が必要」と書くと、処方箋を持って別の薬局に行くイメージを与える
- 心理的ハードルを上げる — 「診察を受けて…」と明記されると身構える利用者がいる。薬局カードに「※薬剤師の対面指導が必要です」と書かないのと同じ判断
- 視覚要素で区別は十分 — 赤ボーダー + 「医療機関」ラベルの二重の視覚的手がかりがあり、テキスト注記がなくても薬局との区別は機能する
5. 位置情報のUX
「近い順」ソートにはブラウザの位置情報が必要だが、ブラウザ標準の confirm() ダイアログは使っていない。
問題
緊急避妊薬というコンテキストで、システムダイアログは「警告」感が強く不安を増幅する。「このサイトが位置情報を使用しようとしています」というOS標準の文言は、プライバシーに敏感な状況では恐怖を与えかねない。
解決策: インラインプライバシーパネル
- 「📍 近い順」ボタン横に常時注記: 「※位置情報の記録や送信は一切行いません」
- 初回クリック時: ページ内にパネルを展開し、プライバシー説明 +「位置情報を使って近い順に並べる」/「やめる」ボタンを表示
- 2回目以降: パネルなしでトグルのみ(一度理解したユーザーに毎回説明しない)
位置情報はブラウザ内の距離計算にのみ使用し、サーバーには一切送信しない。静的サイトなのでサーバー自体が存在しない。
6. 営業時間パーサーの哲学
根本原則: 写像の限界を認識し、限界外の情報を失わない
パーサーは現実の写像(モデル)である。週間スケジュールグリッド(7曜日 × 時間帯)というモデルには表現力の限界がある — 「毎月第2日曜を除く」「水曜午後のみ休診」「テナント休館日を除く」等はモデルの外にある。
モデルに収まらない ≠ ユーザーに不要。 モデルの限界を超えた情報を捨てるのではなく、モデルの外側に保全する:
- モデルに収まる情報 → 構造化(スケジュールグリッド)
- モデルに収まらない情報 → 付加テキスト(※注記として表示)
- 決して捨てない
この原則はこのプロジェクトの倫理的文脈に根差す: 情報が医療アクセスに関わるとき、情報損失のコストは人の実害で計られる。 飲食店の営業時間が間違っていても「残念」で済むが、緊急避妊薬は72時間の制約がある。「行ったら閉まっていた」は取り返しがつかない可能性がある。情報の保全基準は一般のWebサービスより高くなければならない。
結果の優先順位: Correct > Correct + caveat > Unknown > Wrong
| レベル | 意味 | 例 |
|---|---|---|
| Correct | 正確なスケジュール | 月火木金 9:00-18:00(水を除外済み) |
| Correct + caveat | 基盤スケジュール + 注記 | 月-金 9:00-18:00 + ※第2日曜を除く |
| Unknown | パース不能 → 生テキスト表示 | 営業状況不明バッジ + 生データ |
| Wrong | 除外情報を捨てて誤った営業日を表示 | 月-金 9:00-18:00(水曜が営業中に見える) |
カバー率の設計
厚労省データの hours フィールドには多様なフォーマット揺れがある。100%のパースを目指すのではなく、高カバー率の正確なパース + グレースフルフォールバック という設計を採用した。現在のカバー率は薬局 98.2%、医療機関 88.3%。
なぜ100%を目指さないか
- 残りは自然言語パターン(「外来診療時間内」「緊急の場合は24時間対応可」等)やデータ不良で、正規表現ベースのパーサーでは原理的に限界がある
- 100%を目指すと、まれなパターンのために複雑なロジックを追加→メンテナンス困難→既存の正確なパースにバグを入れるリスクが生じる
- パース不能でも生データをそのまま表示すれば、ユーザーは自分で読める。情報が失われることはない
フォールバック戦略
パース不能な営業時間は生データ(厚労省の記載そのまま)を表示する。「パースできたふりをして不正確な情報を見せる」よりも「生データを見せる」方が安全。緊急避妊薬は、間違った営業時間を信じて行ったら閉まっていた、が深刻な結果を招きうる。
医療機関データへの拡張
医療機関の営業時間は薬局とフォーマット傾向が異なる(AM/PM表記、午前/午後、括弧内注記、自由記述が多い)。薬局と同じパーサーを拡張して対応した。別パーサーを作らない理由: 正規化→セグメント分割→曜日:時間パースという核心ロジックは同一であり、差異は入力フォーマットのバリエーションだけ。拡張は normalizeHoursText への正規化ルール追加で対応でき、パーサー本体の分岐は最小限。
拡張の方針:
- 括弧内の除外/休診注記(
(除く水曜)(木休診)等)は正規化段階で除去するが、parseHoursが事前に raw テキストから除外情報を抽出・保全する。情報は捨てない — 解釈できるものはスケジュールに反映し、できないものは付加情報(※注記)として表示する。 - 自明な完全休業(単一エントリ内の曜日除去)→ closedDays としてスケジュールから除去(例:
月-金(木を除く)→ 木が「休み」に) - 曖昧な除外(複数エントリに跨る曜日、ordinal、temporal)→ スケジュールは変更せず、
※木曜日を除く等の注記を表示 - 旧設計(〜2026-03-31)の反省: 以前は「Missing info > Wrong info」を根拠に除外情報を丸ごと捨てていたが、これはモデルの限界外の情報を捨てる行為であり、根本原則に反していた(上記「写像の限界」参照)
- 注記セグメント(
日・祝・GW・お盆・年末年始除く等)は graceful skip。従来は1つでもパース不能なセグメントがあると文字列全体をnullにしていたが、医療機関データでは注記セグメントが頻出するため、キーワード判定(休/除/GW/お盆 等)で注記と判断したセグメントのみスキップし、有効なセグメントを救出する - 時間範囲の末尾テキスト許容:
parseTimeRangeの$アンカーを除去。9:00-12:00その他・時間外も対応可のように時間範囲の後に注記が続くパターンで、従来は全体が失敗していた。末尾テキストを無視して時間範囲だけ抽出する方が、情報損失(そのセグメントの曜日が消える)を防げる - 自由記述(「外来診療時間内」「緊急の場合は24時間対応可」等)はパース不能として生テキスト表示。無理にパースしない
祝日対応
祝日の判定は外部API依存ゼロの純粋計算で実装(getJapaneseHolidays()、約70行)。固定日・ハッピーマンデー・春分秋分(天文公式)・振替休日・国民の休日すべてに対応し、~2099年まで有効。
外部の祝日APIに依存しない理由: - 緊急時ツールにネットワーク依存の障害点を増やしたくない - 日本の祝日は法律で決まっており、計算可能(特例による一時的変更は年次チェックで対応)
7. 技術選定の原則
一貫して 無料・APIキー不要・静的ホスティング を原則としている。
| 選択 | 理由 |
|---|---|
| Leaflet.js + OpenStreetMap | 無料。Google Maps Platform は有料 |
| 東大CSIS ジオコーディング | 無料・APIキー不要・番地レベル精度 |
| GitHub Pages | 無料の静的ホスティング。サーバー運用不要 |
| バニラJS(フレームワークなし) | ビルドステップなし。docs/ のファイルがそのまま本番。依存の少なさ=長期メンテナンスの容易さ |
| GitHub Actions | データの日次自動更新。無料枠で十分 |
各技術の選定理由と不採用にした代替案の詳細は 機能仕様書 を参照。
なぜ静的サイトか
- 個人プロジェクトにサーバー運用コストとメンテナンス負担をかけたくない
- 緊急時ツールとして、サーバーダウンのリスクを排除したい(GitHub Pages は高可用性)
- データ更新は GitHub Actions で日次バッチ処理。リアルタイム性は不要(厚労省データの更新頻度が月1回程度)
8. データパイプラインのキャッシュ設計
update_data.py は厚労省のXLSXを取得・加工して data.json を生成する。同じ as_of 日付のデータが既にキャッシュ(data/data_*.json)に存在する場合、不要なダウンロードとノイジーな git diff を避けるためスキップする。
問題: キャッシュキーの不完全性
キャッシュが「有効」かどうかの判定に使えるのは、本来 2つの入力 の組み合わせ:
- ソースデータ(厚労省XLSX)—
as_of日付で識別 - 処理ロジック(
update_data.py自体)— スクリプトが変わればデータも変わる
当初はソースデータの一致しか見ていなかった。このため、処理ロジックを変更しても(空レコード除外の追加、meta フィールドの追加など)、既存キャッシュが「valid」と判定され続け、新しいロジックが適用されないバグが発生した。
これはビルドシステムにおける典型的な問題と同じ構造を持つ。ソースコードを変更したのにオブジェクトファイルを再コンパイルしなければ、古いバイナリが使われ続ける。
解決策: 入力ハッシュによるキャッシュ無効化
meta.scriptHash にスクリプト自体の SHA-256 ハッシュ(先頭16文字)を保存する。キャッシュの有効性判定時にこのハッシュを照合し、一致しなければキャッシュミスとして XLSXから再生成する。
キャッシュキー = as_of 日付 + scriptHash
→ ソースデータが同じでも、スクリプトが変わればキャッシュミス
→ スクリプトが同じでも、ソースデータが変わればキャッシュミス
この手法は Docker のレイヤーキャッシュ、Webpack の contenthash、Nix/Bazel のビルドハッシュと同じ原理: すべての入力が一致しない限りキャッシュを使わない。
なぜバージョン番号ではなくハッシュか
| 方式 | 利点 | 欠点 |
|---|---|---|
| 手動バージョン番号 | 明示的 | 上げ忘れる。今回のバグと同じ構造の問題が再発しうる |
| スクリプトハッシュ | 完全自動。忘れようがない | コメント変更でも再生成が走る(実害なし。再生成コストは数秒) |
手動の手順に依存する安全策は、その手順を忘れた瞬間に壊れる。自動化できるものは自動化する。
実装
_script_hash():Path(__file__).read_bytes()→ SHA-256 → 先頭16文字looks_like_valid_app_json():meta.scriptHash != _script_hash()→False(キャッシュ無効)- 生成時:
meta.scriptHash = _script_hash()でハッシュを埋め込み
9. 医療機関データの安定IDと部分失敗保護
医療機関データ(clinics.json)は47都道府県のPDFからパースして生成する。薬局データと異なり、PDFには施設番号のような安定した識別子がない。
問題: シーケンシャルIDの脆弱性
当初は c1, c2, ... のシーケンシャルIDを使用していた。これは2つの致命的な問題を持つ:
- IDシフト: 1件の増減で全後続IDがずれ、
geocode_cache.json(緯度経度のマッピング)が壊れる。地図上で全医療機関の位置がおかしくなる - 部分失敗: 47県中一部のPDFダウンロードが失敗すると、不完全なデータで
clinics.jsonが上書きされる。実際に2026-03-20に361件(東京のみ)で上書きが発生した
解決策1: ハッシュベースの安定ID
(施設名, 住所) の組み合わせからSHA-256ハッシュを算出し、c- + 先頭8文字をIDとする。
ID = c- + SHA256("施設名\t住所")[:8]
例: c-a3f8b2e1
- PDFの生データ3,107件中、
(name, addr)は3,105件ユニーク(2件はPDFの真の重複 → パース時に除去) - 8 hex chars(32ビット)で衝突確率は実質ゼロ。万一衝突時は12文字に拡張
- 順序・都道府県の成否に一切依存しない完全に決定的なID
解決策2: 部分失敗保護(2層チェック)
| チェック | 条件 | 狙い |
|---|---|---|
| 都道府県カバレッジ | 前回存在した都道府県が1つでも欠けている | 1県まるごとのダウンロード失敗を検知 |
| グローバル閾値 | 新データ件数 < 前回の80% | 複数県にまたがる大規模欠損を検知 |
チェックに引っかかった場合、clinics.json を書き換えずに exit 0(GitHub Actionsのワークフローは継続し、薬局のジオコーディングは正常に進行する)。--force-write で安全チェックをオーバーライド可能。
ジオキャッシュのマイグレーション
旧形式ID(c1, c2, ...)から新形式ID(c-XXXXXXXX)への移行は update_clinics.py 内で自動実行。旧形式IDが geocode_cache.json に存在する場合のみ発動し、キーを書き換えて旧キーを削除する。初回実行後は自動スキップ。
10. FAQ の設計原則
FAQ は検索ツールの補助であり、ユーザーが 自分の疑問にピンポイントで到達する ことを最優先する。
構成原則: ユーザーの意思決定順
FAQ の問いは「このサイトにどんな機能があるか」ではなく、危機的状況のユーザーが実際にたどる意思決定の順序 で並べる:
- 買えるのか?(処方箋なしで買えますか?)
- どこに行くのか?(薬局と医療機関、どちらに行けばいいですか?)
- 行く前に何かすべきか?(事前に電話は必要ですか?)
- 条件を絞れるか?(女性薬剤師や個室のある薬局を探せますか?)
統合の判断基準: 「同じ情報が複数箇所にある」は統合、「同じニーズに応える」も統合
統合した例:
- 旧Q1「どこで買えますか?」+ 旧Q2「処方箋は必要ですか?」→ 1問に統合。Q1の回答に「処方箋なし」が既に含まれており、Q2は独立した問いに見えて 回答の核が同一 だった
- 旧Q3「女性薬剤師がいる薬局を探せますか?」+ 旧Q5「個室のある薬局を探せますか?」→ 1問に統合。別の問いに見えて ユーザーの根底にあるニーズ(安心して行ける薬局を探したい)が同一
統合しなかった例:
- 「事前に電話は必要ですか?」は独立して残した。プライバシーへの配慮(Q4)とは異なる ロジスティクス上の疑問 であり、統合すると回答が散漫になる
回答の文言原則
- 情報の出所を書かない。「厚生労働省の公式データには…が含まれており」のような前置きは、ユーザーにとってノイズ。データの出所はヘッダーに明示済み
- このサイトの存在を前提にしてよい。「販売可能な薬局の一覧は厚生労働省が公表しています」のような説明は、このサイト自体がその一覧なので自明
- 同じ事実を複数の回答に書かない。「処方箋なし」はQ1の回答に1回だけ。他の問いの回答で繰り返さない
構造化データ(JSON-LD)との対応
HTML の <details> と JSON-LD の FAQPage は 同じ4問を同じ順序 で持つ。JSON-LD 側の質問文には「緊急避妊薬」を含め、検索エンジンが文脈なしで理解できるようにする(HTML側はページ文脈があるので省略可)。
関連ドキュメント
- 機能仕様書 — 4機能の要件と技術方針
- 営業時間パーサー設計 — 多段正規化パイプラインの詳細設計