背景
こども食堂でボランティアをしていたときに思ったことがあります。
ここには支援が届いている。では、届いていない子はどれくらいいるんだろう。
調べてみると、支援そのものはすでにたくさんありました。こども食堂、無料の学習支援、体験活動、奨学金、手当、ひとり親家庭への支援、医療費助成、相談窓口。
足りないのは支援ではありませんでした。それが自治体・社協・NPO・学校・行政のサイトに散っていて、ひとつの家庭が「自分がいま何を使えるか」を知る方法がないことでした。
そこで、関東7都県の支援 5,773件を1つのデータベースにまとめ、住んでいる市区町村と年齢からさがせるサイトを作りました。
- サイト: https://kodomo-map.org
- 構成: 静的サイト(Netlify)。サーバもサーバレス関数も無し
この記事では、判断が要った4か所を書きます。
- データ設計 —— 「わからない」をどう持つか
- 日本語の「開催日」のパース
- 地図 —— 地理院ベクトルタイル + MapLibre
- テスト —— 何を守っているか
作ったもの
データ 5,773件 / 8分类 / 関東7都県+全国
ページ 1,901枚(市区町村 × 分类 + 横断ページ)
ビルド resources.json → build.py → index.html + site/*.html
| 分类 | 件数 |
|---|---|
| こども食堂 | 3,027 |
| 体験活動(児童館・居場所) | 1,117 |
| 相談窓口 | 708 |
| 学習支援(無料塾・教育支援センター) | 508 |
| 奨学金 | 213 |
| 手当 | 98 |
| ひとり親家庭支援 | 55 |
| 医療費助成 | 47 |
8分类を1つのDBに入れているのが設計上の要点です。ひとつの家庭が困ることは、たいてい1つでは終わりません。経済的にきびしい → 勉強が遅れる → 無料の学習支援 → 就学援助にも当てはまる、というつながりが分类をまたぐので、別々のテーブルにすると意味がなくなります。
1. データ設計:「わからない」を一級市民にする
費用は boolean ではなく三態
最初は isFree: boolean にしていました。すぐ破綻します。出典(自治体や社協の一覧)には、費用が書かれていないことが普通にあるからです。false にすれば「有料」という嘘、true にすれば「無料」という嘘になります。
cost: "無料" | "一部有料" | "有料" | "不明"
不明 を用意して、画面にも「費用は分かりませんでした」と出します。拠点 4,584件のうち 不明 が 1,424件(31%)あります。
これは埋めるべき穴ではなく、出典が公開していないぶんです。茨城県のこども食堂は出典に参加費の欄そのものが無かったので、その県は費用を1件も書いていません(テストで「不明でないものがあれば赤」にしてあります)。
「一部有料」が要る理由
こども食堂には「こども無料・おとな300円」が多くあります。無料 にすると保護者がお金を持たずに行く事故が起き、有料 にするとこどもが「行けない」と判断します。真ん中が要ります。
判定は文字列から自動で派生させていますが、ここで1回まちがえました。「未就学児無料」だけで「無料」と判定していた時期があり、小中高生には無料でない食堂が「無料」と表示されていました(4件)。
KID = r"(こども|子ども|子供|小中学生|中高生|小学生|中学生|高校生)"
主語のない「木曜日は無料」も拾えないので、そういうデータは costDetail を「木曜日は子どもも大人も無料」と主語つきに書き直しています。
scope は4段階
同じ「支援」でも有効範囲がまったく違います。
拠点 4,584 その場所へ行く(こども食堂、児童館)
市区町村全域 942 その市に住んでいれば使える
都道府県 152 その県に住んでいれば使える
全国 95 どこに住んでいても使える(児童扶養手当など)
この4段階があるので「柏市の人がいま使えるもの」を組み立てられます。
柏市のひとり親家庭が使えるものは、柏市のぶんだけではありません。千葉県のぶんと全国のぶんを足して 39件・5分类 になります。
市の公式サイトは市のぶんしか載せません。県は県のぶんだけ。足し合わせた形はどこにも存在しない——これが、このサイトを作る理由そのものでした。
位置精度も持つ
住所の書かれ方はバラバラです。
丁目 2,913 丁目まで分かる
住所 861 実座標(オープンデータのCSVに緯度経度がある)
町名 650 町名までしか書かれていない → 丁目の重心
概算 5
住所が無いレコードは地図にピンを出しません。 出すと市の中心に偽のピンが立ち、「行ったら何も無い」が起きます。テストで固定しています。
発明しない
いちばん守っているのはこれです。
電話番号もURLも対象年齢も、確かめられなかったら書かない。
一度、URLを5つ「たぶんこれだろう」で書いて、全部取り消しました。毛呂山町の食堂は出典の電話が「049-294-022」と9桁でしたが、補わずに空にして、出典どおりの文字列を本文に書いて「メールで」と案内しました。
このやり方をしていると、後から正しい値が入ることがあります。実際4回ありました。木更津の食堂は県の一覧に「38-6977」としか書かれておらず局番を足さずにおいたら、のちに社協の一覧に「0438-38-6977」と完全な形で載っていました。
埋めてしまうと二度と直りません。
2. 日本語の「開催日」をパースする
「きょう ひらいている場所」を出す機能があります。おなかがすいている子に今日どこへ行けばいいかを答えるので、いちばん嘘をついてはいけない場所です。そしていちばんバグが出た場所でもありました。
元データはこういう文字列です。
毎週水曜日 16:00〜18:00
月曜日〜金曜日 15:00〜19:00
第2・第4土曜日
平日日中(自治体により土曜も対応)
(月〜金)9:00〜17:00
開館時間内はいつでも
月1回程度(不定期)
水曜日以外毎日
これを {freq, dows} に変換します。最初に決めた方針は1つ。
曜日が読めなければ
unknownにして、絶対に「今日」に入れない。
罠1:「火曜日〜金曜日」の“曜日”の日が日曜として拾われる
// 最初のコード
t.replace(/([月火水木金土日])[~〜~-]([月火水木金土日])/g, (m,a,b) => range(a,b));
火[曜][日]〜[金]曜日 の 日〜金 にマッチして、日曜が入ります。 日曜にやっていない食堂が3件、日曜に「今日あいてます」と出ていました。
直し方も1回まちがえました。範囲部分を消すと、今度は (月〜水・金) の金が落ちます(後続の「・でつないだ塊」の正規表現が2文字以上を要求するため)。
正解は消さずに置き換えることでした。
t = t.replace(/([月火水木金土日])曜日?\s*[~〜~ー-]\s*([月火水木金土日])曜日?/g,
(m,a,b) => { range(a,b); return '〓'; });
罠2:「月1回程度」の月を月曜と読む
単独の曜日字を無条件で拾うと踏みます。「曜」がついているものと「・」でつないだ塊からしか拾わないと決めました。
(t.match(/([月火水木金土日])曜/g) || []).forEach(...)
(t.match(/[月火水木金土日](?:[・、][月火水木金土日])+/g) || []).forEach(...)
罠3:カッコを落としたら340件が消えた
カッコの中は但し書きが多いので、曜日を読む前に落としていました。平日日中(自治体により土曜も対応) の「土曜」を拾わないためです。これは正しい。
ところが (月〜金)9:00〜17:00 はカッコの中身が営業日そのものでした。都のポータル由来の教育支援センター・相談窓口が 340件まるごと「きょうあいてる」に出ていませんでした。
// 中身が曜日字・範囲記号・区切りだけのカッコは、外して本文に残す
const bare = s.replace(/[((]([^(())]*)[))]/g, (m, inner) =>
/[月火水木金土日]/.test(inner) &&
/^[月火水木金土日曜日\s・、/\/~〜~ー-]+$/.test(inner)
? ' ' + inner.replace(/([月火水木金土日])(?!曜)/g, '$1曜') + ' '
: '');
(?!曜) で「曜」を補っているのは、(金) のような単独字が罠2の対策に引っかかるためです。
罠4:「毎日」が読めていなかった
いちばん間抜けでした。毎日 16:00〜21:00 が unknown になっていました。
別件で検査を書いたときに全库から17件出て、中身を見て青くなりました。
チャイルドライン(18歳までのこども専用・毎日16:00-21:00)
#いのちSOS(毎日12:00-22:00)
彩の国 よりそうみんなの電話・メール教育相談(24時間)
こども急病電話相談(毎日19:00〜翌8:00)
24時間つながる窓口が、一度も「今日つながります」と出ていませんでした。
パーサではなくデータを 月〜日 16:00〜21:00 に書き直しました。内容は変えず、パーサが読める言い方にしただけです。書き方の規則はテストで固定しました。
| 書き方 | 読まれ方 |
|---|---|
毎日 9:30〜20:00 |
❌ unknown・「今日」から消える |
月〜日 9:30〜20:00 |
✅ weekly・全7曜 |
開館時間内はいつでも |
⚠ always・日曜が落ちる
|
月〜日 開館時間内はいつでも |
❌ always が勝って日曜が落ちる |
この方針でよかったこと
「読めなければ unknown」を最初に決めていたので、バグの方向がいつも「出なさすぎ」側に倒れていました。 「出すぎ」側(罠1)は3件、罠3の340件は出ていなかったぶんです。方針が逆なら、数百件が嘘の「今日あいてます」を出していたはずです。
いまも unknown が数百件あります。でもそれは正しく unknown です。
3. 地図:地理院ベクトルタイル + MapLibre
最初はラスタタイルでしたが、拡大するとぼやける・重い、で作り直しました。
底図 国土地理院 最適化ベクトルタイル(optimal_bvmap-v1)
描画 MapLibre GL JS v5
配信 PMTiles
スタイルは地理院の公式123層から 12層だけ落として111層にしています。落としたのは等高線・等深線・送電線・鉄道の旗竿・国道番号のバッジ。それ以外は残しました。
目印は消してはいけない
一度、施設の絵記号(学校・病院・郵便局・神社)を全部消したことがあります。「色つきピンと役割が重なって混乱する」と思ったからです。
結果、「地標が何も無い」地図になりました。
役割は違いました。**色つきピンは「支援」、地図の記号は「目印」**です。自分がどこを見ているか分からない地図では、ピンの位置にも意味が出ません。
いまは記号を灰色で残し、彩度を持つのはピンだけという分け方にしています。
Worker が使えない環境がある
MapLibre GL JS は Worker 必須です(同梱物に new Worker( があります)。そして about: オリジンの iframe では、ブラウザが blob Worker を拒否します。
このとき地図は真っ白になりますが、原因が画面からは分かりません。
そこで、依存ゼロ・約170行のフォールバック地図を書きました。タイルは <img>、ピンは絶対配置の <div>、まとめは格子。ドラッグとホイールだけ。
これには副産物がありました。
<img>と<div>なので、JSDOM で数えられる。
WebGL の描画結果はテストできませんが、こちらは「タイルが10枚出た」「ピンが6個出た」を機械で確認できます。「本当に描かれたか」を自動で確かめられるのは大きな利点でした。
能力判定は「無ければ諦める」にしない
地図が出ない原因を追った最後に残ったのはこれでした。
if (typeof maplibregl.supported !== "function") return "この環境では地図を出せません";
MapLibre は v5 で supported() を廃止しています。 つまり「無い関数が無いこと」を理由に、地図を一度も作っていませんでした。
// 在るときだけ使う。無ければ自分で確かめる
if (typeof maplibregl.supported === "function" && !maplibregl.supported()) return "...";
const gl = canvas.getContext("webgl2"); // v5 は WebGL2 必須
能力の判定は「在れば使う、無ければ自分で確かめる」。「無ければ諦める」と書くと、APIが消えた日に全機能が死にます。
4. テスト:何を守っているか
python3 build.py 一本で、データ検査 → HTML生成 → ページ生成 → ページ検査 が走ります。1件でも落ちたら成果物を書きません。
守っているのは「動くこと」より「嘘をつかないこと」です。
- 住所の無い拠点に座標を付けていない(偽ピンを作らない)
- 電話番号が10〜11桁(
189110#8000などの短縮ダイヤルは許可リスト) - 予約が要るものに「予約不要」タグが付いていない
- 連絡手段がゼロのレコードが無い(無ければ近い窓口を案内に置く)
- 画面に
**や★(データ内の独自記法)が文字として漏れていない
件数を焼き込むのは「変わってはいけない値」だけ
t("東京都は62自治体", munis.length === 62); // ○ 変わらない
t("こども食堂は1,272件", shokudo.length === 1272); // ✗ 進歩するたび赤くなる
後者は3回書いて、3回目でやっと形ごと直しました。
.every() は母集団が空だと必ず true
t("asOfが2年以上前のものには古さの断りがある",
old.every(r => /古い情報/.test(r.note)));
old が0件になった瞬間、この検査は永遠に緑です。 「空の母集団で .every() が呼ばれた場所」を報告する別のテストを作ったら、実際に1本、死んでいた検査が見つかりました。
わざと壊して、赤くなるか確かめる
テストを書いたら必ずやります。ただしこれにも罠があって、「壊したのに赤くならない」ときは壊し方のほうがまちがっていることが2回ありました(json.dumps が " を \" に変換していて、置換文字列が当たっていなかった)。
測る場所をまちがえる
いちばん多かった失敗です。6回。
| 測った場所 | 人が見る場所 |
|---|---|
ageLabels(ビルドで落とされるフィールド) |
tags |
| 11MBのHTML全体(同梱ライブラリの既定値に当たった) | 自分が書いた引数 |
script を含む textContent(45,954個と出た) |
描画される本文 |
| データの有無 | 表示条件を満たすか(1,136件の差) |
「直した」と「画面に届いた」は別でした。ビルドが落とすフィールドがあります。
いまは測る前に一行だけ自問します。「これは人が見る場所か?」
これから
- 費用と連絡先が分からないレコードがまだ多い(出典が公開していないぶん)
- 関東7都県だけ。ほかの地域はこれから
- データは古くなるので、全レコードにいつ確認したかを書いて「行く前に電話で確かめてください」と添えている
まちがいを見つけたら教えてください。直します。