設計思想

このドキュメントでは、緊急避妊薬薬局検索サイトの設計判断とその背景を記録する。「なぜこうなっているのか」を理解したい開発者や、同種のプロジェクトを作る方の参考にどうぞ。

技術的な仕様は 機能仕様書営業時間パーサー設計 を参照。


1. このサイトは「検索ツール」であり「医療情報サイト」ではない

最も根本的な設計判断。緊急避妊薬の効能・副作用・服用方法といった医療情報は扱わない。

理由:

このため、ヘッダーやフィルター周辺に医療情報や長い説明文を置くことは意図的に避けている。急いでいるユーザーの導線にノイズを足すべきでない。利用判断に関わる情報は FAQ で対応している(FAQ の設計原則は §10 を参照)。


2. フィルター設計原則

このサイトのフィルターには2種類ある。判断基準がそれぞれ異なる。

デフォルト除外(ユーザーに選択肢を見せない)

緊急避妊薬は時間勝負。デフォルトで選択肢を隠す判断は慎重に、各軸を深く検討して行う。以下の 3軸 で評価する:

問い
情報の出所 施設自身の申告(一次情報)か、こちらのパース・推定(二次加工)か
代替の有無 除外された選択肢の目的を、別ルートで達成できるか
除外しない場合の害 無駄足のリスク(ノイズ)か、有用な選択肢の喪失か

適用例: 医療機関の常時在庫フィルター → 除外OK

医療機関データには厚労省PDFの「常時在庫の有無」列がある。「無」「不明」はデフォルトで非表示としている。

→ 一次情報 + 代替あり + ノイズ除去の実益。除外は妥当。

適用例: 「今対応可能」フィルター → ~~実装しない~~ ユーザー起動型として実装

当初は「実装しない」と判断していた。しかしパーサー改善(薬局98.2%、医療機関88.3%)と「不明バッジ」設計により、3軸評価が変わった:

→ 「確実に閉まっている(時間外対応もない)」だけを隠し、不確実なもの(パース不能)や時間外対応ありの施設は残す設計。フィルターが新たなリスクを追加しない。デフォルトOFF(ユーザー起動型)。「時間外対応あり」チェックボックスはこのフィルターに統合して廃止。バッジは4値(緑「営業中」/ 青「時間外対応可」/ 灰「営業時間外」/ 琥珀「営業状況不明」)。詳細は NOW_OPEN_FILTER.md を参照。

ユーザー起動型フィルター(ユーザーが自分でONにする)

OFFで全件見えるため、判断基準はデフォルト除外より緩い:

  1. データが一次情報であること — 施設の自己申告に基づくこと
  2. 絞り込み率が意味のある水準であること — 通過率が高すぎるフィルターは機能しない

適用例: 個室あり → 実装

privacy フィールドに「個室」を含む薬局に絞り込む。

適用例: 衝立あり → 実装しない


3. 空レコード除外と件数表示の2層構造

厚労省データには住所・電話番号・営業時間がすべて空のレコードが含まれる(2026年3月時点で177件)。備考欄に「XXXへ統合」等とあり、統合・廃止済みの施設。これらはユーザーにとって無価値(電話もかけられず、場所も分からない)なので、data.json から除外する。

除外の判断根拠

フィルター設計原則(セクション2)の3軸で評価:

→ フィルターですらなく、データ品質の問題。表示しない判断に迷う余地がない。

件数表示の設計

除外により data.json の件数は厚労省公表件数より少なくなる。この2つの数字はそれぞれ別の意味を持つ(2026-03-25時点の例):

数字 意味 表示場所
11,931 厚労省が公表した施設数(データの網羅性) ハイライトカード「💊 全国 11,931 件」
11,734 実際に検索可能な施設数(ユーザーが触れるデータ) ステータスバー「11,734件の薬局データを読み込みました」

ハイライトカードは「厚労省公式データ全件収録」と謳っている。ここに表示すべきは「自分たちのデータベースの件数」ではなく「厚労省データの規模」。だから meta.totalPublished(厚労省公表件数)を使う。

ステータスバーは検索エンジンの動作報告。実際に検索対象として読み込んだ件数を正確に表示する。

実装

title / meta の概数表現(「全国11,000件以上」等)は厚労省データの規模を参照しており、除外の影響を受けない。


4. 薬局デフォルト・医療機関トグル方式

デフォルトは薬局のみ表示。「常時在庫ありの医療機関も表示」トグルをONにすると clinics.json を遅延読み込みして結果に統合する。

却下した他の方式

方式 却下理由
タブ分離(薬局タブ / 医療機関タブ) 「近い順」で薬局と医療機関を横断ソートできない。最寄りが医療機関でも気づけない
完全統合(常に両方表示) フィルターが複雑化する。薬局にしかないフィールド(女性薬剤師、個室等)と医療機関にしかないフィールド(産婦人科標榜、常時在庫)が混在
別ページ 緊急時に遷移が余計。1ページで完結すべき

デフォルトを薬局にした理由

カードの視覚的区別

薬局と医療機関が混在する検索結果で、ユーザーが一目で区別できることが重要。医療機関カードには赤の左ボーダー + 「医療機関」ラベルを表示。地図では薬局=青ピン、医療機関=赤ピン。

医療機関カードに「※医師の対面診察・処方箋が必要です」のような注記は付けない。理由:

  1. 自明な情報はノイズ — 医療機関で診察があるのは常識。このサイトの利用者は急いでいて不安な状態にあり、カードに載る情報が多いほど本当に必要な情報(住所・電話・営業時間)が埋もれる
  2. 「処方箋」は誤解を招く — 表示対象は常時在庫ありの医療機関のみ(§2参照)。院内処方でその場でもらえるのに「処方箋が必要」と書くと、処方箋を持って別の薬局に行くイメージを与える
  3. 心理的ハードルを上げる — 「診察を受けて…」と明記されると身構える利用者がいる。薬局カードに「※薬剤師の対面指導が必要です」と書かないのと同じ判断
  4. 視覚要素で区別は十分 — 赤ボーダー + 「医療機関」ラベルの二重の視覚的手がかりがあり、テキスト注記がなくても薬局との区別は機能する

5. 位置情報のUX

「近い順」ソートにはブラウザの位置情報が必要だが、ブラウザ標準の confirm() ダイアログは使っていない。

問題

緊急避妊薬というコンテキストで、システムダイアログは「警告」感が強く不安を増幅する。「このサイトが位置情報を使用しようとしています」というOS標準の文言は、プライバシーに敏感な状況では恐怖を与えかねない。

解決策: インラインプライバシーパネル

位置情報はブラウザ内の距離計算にのみ使用し、サーバーには一切送信しない。静的サイトなのでサーバー自体が存在しない。


6. 営業時間パーサーの哲学

根本原則: 写像の限界を認識し、限界外の情報を失わない

パーサーは現実の写像(モデル)である。週間スケジュールグリッド(7曜日 × 時間帯)というモデルには表現力の限界がある — 「毎月第2日曜を除く」「水曜午後のみ休診」「テナント休館日を除く」等はモデルの外にある。

モデルに収まらない ≠ ユーザーに不要。 モデルの限界を超えた情報を捨てるのではなく、モデルの外側に保全する:

  1. モデルに収まる情報 → 構造化(スケジュールグリッド)
  2. モデルに収まらない情報 → 付加テキスト(※注記として表示)
  3. 決して捨てない

この原則はこのプロジェクトの倫理的文脈に根差す: 情報が医療アクセスに関わるとき、情報損失のコストは人の実害で計られる。 飲食店の営業時間が間違っていても「残念」で済むが、緊急避妊薬は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%を目指さないか

フォールバック戦略

パース不能な営業時間は生データ(厚労省の記載そのまま)を表示する。「パースできたふりをして不正確な情報を見せる」よりも「生データを見せる」方が安全。緊急避妊薬は、間違った営業時間を信じて行ったら閉まっていた、が深刻な結果を招きうる。

医療機関データへの拡張

医療機関の営業時間は薬局とフォーマット傾向が異なる(AM/PM表記、午前/午後、括弧内注記、自由記述が多い)。薬局と同じパーサーを拡張して対応した。別パーサーを作らない理由: 正規化→セグメント分割→曜日:時間パースという核心ロジックは同一であり、差異は入力フォーマットのバリエーションだけ。拡張は normalizeHoursText への正規化ルール追加で対応でき、パーサー本体の分岐は最小限。

拡張の方針:

祝日対応

祝日の判定は外部API依存ゼロの純粋計算で実装(getJapaneseHolidays()、約70行)。固定日・ハッピーマンデー・春分秋分(天文公式)・振替休日・国民の休日すべてに対応し、~2099年まで有効。

外部の祝日APIに依存しない理由: - 緊急時ツールにネットワーク依存の障害点を増やしたくない - 日本の祝日は法律で決まっており、計算可能(特例による一時的変更は年次チェックで対応)


7. 技術選定の原則

一貫して 無料・APIキー不要・静的ホスティング を原則としている。

選択 理由
Leaflet.js + OpenStreetMap 無料。Google Maps Platform は有料
東大CSIS ジオコーディング 無料・APIキー不要・番地レベル精度
GitHub Pages 無料の静的ホスティング。サーバー運用不要
バニラJS(フレームワークなし) ビルドステップなし。docs/ のファイルがそのまま本番。依存の少なさ=長期メンテナンスの容易さ
GitHub Actions データの日次自動更新。無料枠で十分

各技術の選定理由と不採用にした代替案の詳細は 機能仕様書 を参照。

なぜ静的サイトか


8. データパイプラインのキャッシュ設計

update_data.py は厚労省のXLSXを取得・加工して data.json を生成する。同じ as_of 日付のデータが既にキャッシュ(data/data_*.json)に存在する場合、不要なダウンロードとノイジーな git diff を避けるためスキップする。

問題: キャッシュキーの不完全性

キャッシュが「有効」かどうかの判定に使えるのは、本来 2つの入力 の組み合わせ:

  1. ソースデータ(厚労省XLSX)— as_of 日付で識別
  2. 処理ロジックupdate_data.py 自体)— スクリプトが変わればデータも変わる

当初はソースデータの一致しか見ていなかった。このため、処理ロジックを変更しても(空レコード除外の追加、meta フィールドの追加など)、既存キャッシュが「valid」と判定され続け、新しいロジックが適用されないバグが発生した。

これはビルドシステムにおける典型的な問題と同じ構造を持つ。ソースコードを変更したのにオブジェクトファイルを再コンパイルしなければ、古いバイナリが使われ続ける。

解決策: 入力ハッシュによるキャッシュ無効化

meta.scriptHash にスクリプト自体の SHA-256 ハッシュ(先頭16文字)を保存する。キャッシュの有効性判定時にこのハッシュを照合し、一致しなければキャッシュミスとして XLSXから再生成する。

キャッシュキー = as_of 日付 + scriptHash
 → ソースデータが同じでも、スクリプトが変わればキャッシュミス
 → スクリプトが同じでも、ソースデータが変わればキャッシュミス

この手法は Docker のレイヤーキャッシュ、Webpack の contenthash、Nix/Bazel のビルドハッシュと同じ原理: すべての入力が一致しない限りキャッシュを使わない。

なぜバージョン番号ではなくハッシュか

方式 利点 欠点
手動バージョン番号 明示的 上げ忘れる。今回のバグと同じ構造の問題が再発しうる
スクリプトハッシュ 完全自動。忘れようがない コメント変更でも再生成が走る(実害なし。再生成コストは数秒)

手動の手順に依存する安全策は、その手順を忘れた瞬間に壊れる。自動化できるものは自動化する。

実装


9. 医療機関データの安定IDと部分失敗保護

医療機関データ(clinics.json)は47都道府県のPDFからパースして生成する。薬局データと異なり、PDFには施設番号のような安定した識別子がない。

問題: シーケンシャルIDの脆弱性

当初は c1, c2, ... のシーケンシャルIDを使用していた。これは2つの致命的な問題を持つ:

  1. IDシフト: 1件の増減で全後続IDがずれ、geocode_cache.json(緯度経度のマッピング)が壊れる。地図上で全医療機関の位置がおかしくなる
  2. 部分失敗: 47県中一部のPDFダウンロードが失敗すると、不完全なデータで clinics.json が上書きされる。実際に2026-03-20に361件(東京のみ)で上書きが発生した

解決策1: ハッシュベースの安定ID

(施設名, 住所) の組み合わせからSHA-256ハッシュを算出し、c- + 先頭8文字をIDとする。

ID = c- + SHA256("施設名\t住所")[:8]
例: c-a3f8b2e1

解決策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 の問いは「このサイトにどんな機能があるか」ではなく、危機的状況のユーザーが実際にたどる意思決定の順序 で並べる:

  1. 買えるのか?(処方箋なしで買えますか?)
  2. どこに行くのか?(薬局と医療機関、どちらに行けばいいですか?)
  3. 行く前に何かすべきか?(事前に電話は必要ですか?)
  4. 条件を絞れるか?(女性薬剤師や個室のある薬局を探せますか?)

統合の判断基準: 「同じ情報が複数箇所にある」は統合、「同じニーズに応える」も統合

統合した例:

統合しなかった例:

回答の文言原則

構造化データ(JSON-LD)との対応

HTML の <details> と JSON-LD の FAQPage同じ4問を同じ順序 で持つ。JSON-LD 側の質問文には「緊急避妊薬」を含め、検索エンジンが文脈なしで理解できるようにする(HTML側はページ文脈があるので省略可)。


関連ドキュメント