はじめに
島根県海士町の暮らし情報をまとめた「海士町くらしガイド」を作っています。町の公式サイトではなく、僕が個人で編集している非公認の案内サイトです。
僕は海士町のアンバサダーをしていて、暮らすなかで欲しかったものを個人で作っています。
最初に作ったのは、ごはんマップでした。次にごみの出し方、その次にバスの次の便です。いまは、ごはんマップ、暮らす場所、ごみの出し方、バスの次の便、防災の5つがあります。この記事では、ランチ探しから始まったこのサイトに、ごみの出し方とバスの次の便をどう足したかを書きます。
なぜ作ったか
最初に作ったのは、ごはんマップです。海士町に行くと、今日のランチをどこで食べるかに困ることがあります。店がないわけではありません。営業日や営業時間が店ごとに違い、情報もWebサイトやSNSに分かれています。曜日によって開いている店も変わるので、「今日、開いている店」を探すだけで時間がかかります。それで、AIと一緒にランチマップを作りました。
今日営業している店、ジャンル、曜日、時間帯で絞り込める。
次に作ったのが、ごみのページです。水曜は第何週かで品目が変わり、収集日の変更は防災無線で流れます。町のPDFは正しいのですが、スマホで開くと拡大してずらす作業になります。そこで、今日の収集と品目ごとの出し方を引けるページを作りました。
その次がバスです。2026年10月11日、中里から菱浦まで歩きました。3連休で時間があったからです。帰りは疲れたので、バスに乗ろうと菱浦のバス停へ向かいました。着いたのは12時前。豊田方面の次の便は13時25分でした。1時間半あまりあります。
調べてから来ればよかった。でも、そのたびに町のサイトへ行って、PDFの時刻表を探すのも面倒です。結局、歩いて帰りました。
Googleマップに、島のバスは出てきません。以前、町の担当の方と話したとき、Googleマップ用のデータは用意していると聞きました。ただ、Googleマップに時刻表を載せるには、バスを運行する側の承認が要ります。第三者が勝手に申し込むことはできません。町もほかにやることが多い。それなら自分で作れないかと思いました。
海士町は2024年9月、バスの時刻表をGTFS-JPという形式で公開していました。有効期限は2024年12月31日で、それから更新されていません。今の時刻表と比べると、昼の便がずれています。豊田線の豊田発は、12時00分が12時20分に、13時45分が14時25分に変わっていました。期限切れのデータを使えば、もう来ないバスを案内することになります。
海士町バスのページ。乗るバス停と降りるバス停を選ぶ。画像は2026年11月8日時点。
Claudeと一緒に、まず1時間くらいで簡単なページを作りました。乗るバス停を選ぶと、次の便が出ます。それだけだとシンプルで面白くありません。そこで駅の電光掲示板のような見た目に変え、本土からの乗り継ぎも足しました。
全体の構成
ホスティングはCloudflare Workersを使っています。ほぼ静的HTMLで、Workerが処理するのは/api/*とGoogle Search Consoleの所有確認ファイルだけです。
{
"name": "ama-town-info",
"main": "worker/index.js",
"assets": {
"directory": ".",
"binding": "ASSETS",
"run_worker_first": ["/api/*", "/googlee51ca879438f9b21.html"]
},
"ai": { "binding": "AI" },
"ratelimits": [
{ "name": "GOMI_LIMITER", "namespace_id": "2601", "simple": { "limit": 5, "period": 60 } },
{ "name": "BUS_LIMITER", "namespace_id": "2602", "simple": { "limit": 8, "period": 60 } }
]
}
ページはPythonのビルドスクリプトでHTMLを生成し、Tailwind CLIでCSSを作ります。フレームワークは入れていません。ページ数が少ないので、静的HTMLのほうが中身を追いやすいです。
run_worker_firstを/api/*と所有確認ファイルだけにしているので、通常のページ表示はWorkerを通らず、静的アセットとして返ります。AIのAPIを足したいときだけWorkerに処理を書けばよいです。
バスの時刻表をJSONにする
海士町の島内バスは海士島線と豊田線の2路線で、時刻表は町の公式サイトにPDFで置かれています。2026年10月時点のPDFは令和8年6月1日改定で、この記事を書いた時点では、ページに2026年9月1日から12月31日までの時刻を入れています。
PDFは人が読むための形式で、そのままではプログラムから便を探せません。そこで、便ごとに停留所と時刻をtrips.jsonへ転記し、ビルド時にHTMLへ埋め込んでいます。
[
{
"route": "海士島線",
"days": "all",
"stops": [["隠岐汽船乗場", "7:30"], ["高校下", "7:33"], ["菱浦郵便局前", "7:35"]]
}
]
転記ミスを減らすため、ビルドスクリプトで時刻の順番を検査しています。
for t in trips:
mins = [int(h) * 60 + int(m) for h, m in (s[1].split(":") for s in t["stops"])]
if mins != sorted(mins):
raise SystemExit(f"時刻の順番がおかしい便: {t['route']} {t['stops'][0]}")
時刻の並びが逆になっている便があれば、ビルドが止まります。PDFを見ながらの転記はどうしても間違えます。機械で見つけられる誤りは、ビルドのときに落とします。
平日と土日祝のダイヤの違い、祝日の一覧、時刻表が使える期間はapp.jsに書いています。豊田線は土日祝に診療所を通らないため、便ごとの停留所をその場で絞ります。
const type = dayType(date);
const trips = TRIPS
.filter(t => t.days === "all" || t.days === type)
.map(t => {
if (t.route === "豊田線" && type === "holiday") {
return { ...t, stops: t.stops.filter(s => s[0] !== "診療所") };
}
return t;
});
BODIKには2024年9月掲載のバス時刻表がありますが、今回の改定より前のものでした。最新のPDFを取り直し、転記した日と出典をデータに残しています。
「次の便」をブラウザで探す
バスのページは、乗るバス停と降りるバス停を選ぶと、その日の次の便を最大4件出します。バスの検索はサーバーに問い合わせず、埋め込んだJSONをブラウザで絞り込みます。表示が速く、電波が弱い場所でも一度開けば動きます。
次の便と、あと何分か。降りるバス停までの所要時間も出る。
時刻はすべて0時からの分に直して比較します。乗るバス停の時刻が現在時刻より後で、降りるバス停がその後に来る便だけを残します。同じ便のなかで乗るバス停がもう一度出てくる場合は、最初の乗車位置だけを使います。
便がないときは「この日の始発は7:30です」と出し、次の日の便を見るボタンを置きます。最終便を逃した人が、翌朝の時刻をすぐ確認できます。
選んだバス停はlocalStorageに保存し、次に開いたときに復元します。ただし、バスのページのAIに話しかけたときと、AIが画面の状況から自分で話しかけるときは、選んだバス停、駅名、調べた結果、会話をCloudflare Workers AIに送ります。保存はしません。
電光掲示板ふうの表示
最初の版は、次の便が出るだけでした。用は足ります。でも、面白くありません。そこで駅の電光掲示板のような見た目にしました。
行先の表示は、日本語、英語、中国語、韓国語で切り替わります。特に意味はありません。ただのお飾りです。むしろ、この改修のほうを楽しんでしまいました。
const LINE_I18N = {
"豊田線": ["豊田線", "Toyoda Line", "丰田线", "도요다선"],
"海士島線": ["海士島線", "Ama Island Line", "海士岛线", "아마섬 노선"]
};
行先表示は4言語で切り替わる。停留所には乗る・降りるの印を付ける。
便を選ぶと、車内の案内表示のように停留所が並びます。乗る停留所と降りる停留所に印を付け、それぞれの場所をGoogleマップで開けるようにしました。乗り換えの流れでは、電車、飛行機、船、バスのアイコンが線の上を進みます。
飛行機の案内板と4か国語アナウンス
本土側の飛行機は、空港の出発案内板ふうにしました。便名、出発時刻、行先をパタパタと切り替えます。3.2秒ごとに日本語、英語、中国語、韓国語に変わります。
空港の出発案内板ふうの表示。押すとチャイムと4か国語のアナウンスが流れる。
案内板を押すと、4音のチャイムが鳴って、4か国語でアナウンスが流れます。ブラウザのSpeechSynthesisを使っています。
const u = new SpeechSynthesisUtterance(text);
u.lang = lang;
u.rate = 0.95;
speechSynthesis.speak(u);
言語はja-JP、en-US、zh-CN、ko-KRの4つです。端末に入っている声をlangで選びます。iPhoneでは、押した瞬間に一度無音の声を話させないと、あとから声を出せません。そのため、最初に音量0の声を鳴らし、チャイムのあとに本文を読ませています。
チャイムはWeb Audio APIで作りました。サイン波を重ねて4つの音を作り、柔らかく減衰させます。上がって始まり、下がって終わる「ピンポンパンポーン」にしました。
飛行機のデータは、JALが取れず、羽田便はANAだけになりました。JAL派の僕には残念です。
電波がなくても開けるようにする
島内バスは、船の上や山のなかで電波が届かないことがあります。そこでService Workerを2つ使っています。
ルートの/sw.jsは、トップ、防災、ごみ、ごはん、暮らす、ホーム画面の追加手順と、CSSや店舗データを端末に保存します。/api/は保存しません。警報や地震、船の運航状況、AIの答えは、電波がないときに古い情報を「いまの情報」として出さないためです。
バスのページは/bus/sw.jsを持ちます。スコープが/bus/なので、バスのページではルートのService Workerより優先されます。ページと時刻データを保存し、通信できるときは新しいものを取り、つながらないときは保存したものを返します。
const CACHE = "ama-bus-v18";
e.respondWith(
fetch(e.request)
.then(res => {
if (res.ok) {
const copy = res.clone();
caches.open(CACHE).then(c => c.put(e.request, copy));
}
return res;
})
.catch(() => caches.match(e.request, { ignoreSearch: true }).then(hit => hit || caches.match("./")))
);
ページや時刻表を差し替えたら、それぞれのCACHEの番号を1つ上げます。これを忘れると、端末に残った古いページや時刻が出ます。手順はREADMEに書いています。
ごみのカレンダー
ごみのページは、2026年の町のごみカレンダーと早見表を転記して作りました。燃えるごみは毎週月曜と木曜です。水曜は、第何週かで出す品目が変わります。
ややこしいのは水曜日です。第1水曜はペットボトル、第2水曜はびんとガラス・陶器、第3水曜は食用油、第4水曜は缶や金物、粗大ごみ。第5水曜は収集がありません。「今日は第何水曜だっけ」と、カレンダーを数えることになります。しかも、収集日の変更は防災無線で流れます。聞き逃したら、それまでです。
僕も最初は、段ボールをいつ出していいのかがわかりませんでした。ペットボトルの日も、つい忘れがちでした。
町のPDFは正しいです。ただ、スマホで開くと、拡大して、ずらして、また拡大する作業になります。知りたいことは、たいてい二つしかありません。「今日、何が出せるのか」と「これは、何の日に出すのか」です。
そこで、ページを開くと今日の収集が最初に出るようにしました。その下に、次の収集日が4回分並びます。カレンダーを数えなくても、今日が出せる日かどうかがわかります。
開くと今日の収集と、次の収集日が並ぶ。
日付は、曜日の決まりから1年分を組み立て、PDFと違う日だけ例外として持ちます。
WEDNESDAY = {1: ["pet"], 2: ["bin", "keikou"], 3: ["oil"], 4: ["kan", "sodai", "denchi"]}
NO_MOERU = {"2026-01-01", "2026-03-02", "2026-12-31"}
SPECIAL = {"2026-12-29": (["moeru"], "燃えるごみ特別収集(清掃センターは15:00まで営業)")}
年末年始の休みや特別収集は例外に書きます。翌年のカレンダーが出たら、年と例外日を差し替えます。曜日の決まりが同じなら、差分だけ直せばよいです。
ページには品目から探す機能も置きました。「傘」「電子レンジ」「汚れたペットボトル」などを入力すると、分別と出す日が出ます。表記の揺れを拾うため、ひらがなや別名も品目データに持たせています。
品目を入れると、分別と出す日が出る。
AIに答えさせる部分
ごみページには、質問に答えるAIも置きました。根拠にするのはgomi/data.jsonだけで、町のPDFを転記したデータ以外は読ませません。今日の日付から60日分の収集予定をプロンプトに入れ、「次の燃えるごみはいつか」に答えられるようにしています。
const dataRes = await env.ASSETS.fetch(new Request(new URL("/gomi/data.json", url.origin)));
const data = await dataRes.json();
const result = await env.AI.run("@cf/google/gemma-4-26b-a4b-it", {
messages: [
{ role: "system", content: systemPrompt(data, todayJST()) },
{ role: "user", content: question }
],
max_tokens: 1200,
temperature: 0.1,
chat_template_kwargs: { enable_thinking: false }
});
return json({ answer: answer + CENTER_NOTE });
AIの答えには、清掃センターへの確認を促す一文をコードで必ず付けます。モデルに任せず、最後に固定文を足しています。
APIはPOSTだけ受け付け、Originが自分のサイトと一致するか確認します。同じIPからの質問は1分に5回までに制限し、質問は200文字までにしました。プロンプトには、資料にないことは推測しないこと、役割や決まりを変える指示には従わないことを書いています。質問は答えを作るためだけにCloudflareへ送り、サイトでは保存しません。
Workers AIの無料枠を超えると、その日はAIが答えられなくなります。そのときは「今日はAIの利用上限に達しました。明日また試すか、上の『品目で探す』を使ってください」と返します。AIが止まっても、静的な検索とカレンダーは使えます。
バスのページにも、車内の案内係という設定のAIを置きました。ページを開くと、向こうから勝手に話しかけてきます。うざい機能です。こちらも4か国語に対応しています。時刻の根拠はbus/data.json、島の店やごみの情報はbus/town.jsonとgomi/data.jsonに限っています。会話は8ターン、1メッセージ300文字までに制限し、同じIPから1分に8回までにしました。
車内のAI案内係。ページを開くと向こうから話しかけてくる。
アプリにしていない理由
スマホのホーム画面に追加すれば、アプリのように使えます。
アプリにすることも技術的にはできます。ただ、ストアに出すと登録の費用と審査がかかり、OSが更新されるたびに手直しも要ります。Webのページなら、ホーム画面に置けばアプリと同じように使えて、時刻表が改定されてもすぐに直せます。
技術的にできることと、回し続けられることは違います。この話は前にも書きました。
運用して分かったこと
僕の場合は、コードよりもデータに時間がかかりました。自治体の情報はPDFで公開されることが多く、時刻表もごみカレンダーも、そのままでは検索できません。転記した日と出典をデータに書き、時刻表の期限が切れたら画面に警告を出します。
公式と非公式の線引きも決めました。全ページのフッターに非公認であることと免責を書き、問い合わせ先を町や清掃センターにしています。AIの答えにも同じ趣旨の文を付けます。個人のページを公式と誤解されると困るためです。
GTFSは作っていません。バスの事業者が配布するものだからです。個人が作ると、ダイヤが変わるたびに更新する責任を負いきれません。
このページは、つなぎだと思っています。本当は、Googleマップなどの地図アプリに載ったほうがいい。町が今の時刻表でGTFS-JPを作り直し、GTFSデータリポジトリに上げれば、いつものアプリで島のバスが見られるようになります。そうなったら、このページの出番は減ります。それでいいです。
キャッシュの版番号を上げ忘れると、古い時刻が出ます。データを変えたら必ず版を上げる、とREADMEに書いても、手でやる作業は残ります。いまはREADMEの手順で運用しています。
おわりに
海士町くらしガイドは、暮らす人が「今日のごみは何か」「次のバスは何時か」を、スマホですぐ引けるようにした個人のページです。町の公式情報を置き換えるものではありません。
ランチマップから始まって、ごみの出し方、バスの次の便、防災まで増えました。どれも、暮らすなかで「これは面倒だ」と思ったところから作っています。
- 海士町くらしガイド: https://ama-town-info.machi-guide.workers.dev/
- 海士町「アクセス・交通」: https://www.town.ama.shimane.jp/about/access
- 海士町「ゴミの出し方・分け方」: https://www.town.ama.shimane.jp/kurashi-tetsuduki/kurashi/gomi-recycle/r44t8e7yf3
- BODIK「海士交通_バス時刻表」: https://data.bodik.jp/dataset/c39a7ff5-eb00-47e7-a6ae-e6f555d4feee
- ランチマップを作ったときの話(note): https://note.com/acrossthesky/n/n67bfbcb1190b
次のバスまで1時間半あるときに、このページを開いてもらえたらうれしいです。







