0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ランチ探しの不便から始まった海士町くらしガイド。ごみの出し方とバスの次の便をCloudflare Workersで足した

0
Posted at

はじめに

島根県海士町の暮らし情報をまとめた「海士町くらしガイド」を作っています。町の公式サイトではなく、僕が個人で編集している非公認の案内サイトです。

僕は海士町のアンバサダーをしていて、暮らすなかで欲しかったものを個人で作っています。

最初に作ったのは、ごはんマップでした。次にごみの出し方、その次にバスの次の便です。いまは、ごはんマップ、暮らす場所、ごみの出し方、バスの次の便、防災の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案内係

車内のAI案内係。ページを開くと向こうから話しかけてくる。

アプリにしていない理由

スマホのホーム画面に追加すれば、アプリのように使えます。

アプリにすることも技術的にはできます。ただ、ストアに出すと登録の費用と審査がかかり、OSが更新されるたびに手直しも要ります。Webのページなら、ホーム画面に置けばアプリと同じように使えて、時刻表が改定されてもすぐに直せます。

技術的にできることと、回し続けられることは違います。この話は前にも書きました。

運用して分かったこと

僕の場合は、コードよりもデータに時間がかかりました。自治体の情報はPDFで公開されることが多く、時刻表もごみカレンダーも、そのままでは検索できません。転記した日と出典をデータに書き、時刻表の期限が切れたら画面に警告を出します。

公式と非公式の線引きも決めました。全ページのフッターに非公認であることと免責を書き、問い合わせ先を町や清掃センターにしています。AIの答えにも同じ趣旨の文を付けます。個人のページを公式と誤解されると困るためです。

GTFSは作っていません。バスの事業者が配布するものだからです。個人が作ると、ダイヤが変わるたびに更新する責任を負いきれません。

このページは、つなぎだと思っています。本当は、Googleマップなどの地図アプリに載ったほうがいい。町が今の時刻表でGTFS-JPを作り直し、GTFSデータリポジトリに上げれば、いつものアプリで島のバスが見られるようになります。そうなったら、このページの出番は減ります。それでいいです。

キャッシュの版番号を上げ忘れると、古い時刻が出ます。データを変えたら必ず版を上げる、とREADMEに書いても、手でやる作業は残ります。いまはREADMEの手順で運用しています。

おわりに

海士町くらしガイドは、暮らす人が「今日のごみは何か」「次のバスは何時か」を、スマホですぐ引けるようにした個人のページです。町の公式情報を置き換えるものではありません。

ランチマップから始まって、ごみの出し方、バスの次の便、防災まで増えました。どれも、暮らすなかで「これは面倒だ」と思ったところから作っています。

次のバスまで1時間半あるときに、このページを開いてもらえたらうれしいです。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?