「この住所の災害リスク」を公開APIで取る — 不動産情報ライブラリとJ-SHISのドキュメントに書いていない実装メモ8つ
住所を1つ受け取って、洪水浸水想定区域・土砂災害警戒区域・液状化傾向・地震の揺れの
確率を返す、というものを作って運用しています。データ源はすべて公開APIです。
- 不動産情報ライブラリ(国土交通省): 取引価格、駅、液状化傾向図、土砂災害警戒区域など
- J-SHIS(防災科学技術研究所): 地震動予測地図(30年以内に震度◯以上になる確率)
どちらも無料で、登録すれば個人でも使えます。ただし、レスポンスの形と落とし穴は
ドキュメントを読んだだけでは分かりませんでした。 特に「エラーにならないまま
間違った値を出す」種類の罠が多いです。
この記事はその実装メモです。全部、自分のコードで実際に踏んで直したものです。
検証はこの記事を書きながら実APIに投げて取り直しました(2026-09-25時点)。
1. J-SHIS は APIキーなしで、1リクエストで揺れの確率が返る
これは罠ではなく、単に知られていない気がするので先に書きます。
J-SHIS の meshinfo.geojson は、緯度経度を渡すだけでその地点のメッシュの
超過確率を返します。APIキーも User-Agent も要りません。
export function buildJshisMeshInfoUrl(location: { latitude: number; longitude: number }) {
const url = new URL(
`pshm/${VERSION}/${CASE}/${EQCODE}/meshinfo.geojson`,
"https://www.j-shis.bosai.go.jp/map/api/",
);
url.searchParams.set("position", `${location.longitude},${location.latitude}`);
url.searchParams.set("epsg", "4326");
return url.toString();
}
パスの3要素は、自分は以下を既定にしています(環境変数で差し替え可能にしてあります)。
-
VERSION=Y2024(予測地図の版) -
CASE=AVR(平均ケース) -
EQCODE=TTL_MTTL(全地震・全ケース合算)
position は 経度,緯度 の順です。 緯度経度の順で書きたくなるので注意してください。
実際に3地点投げた結果(今日の実測):
629ms 200 mesh=5235017521 I55=0.008643 I60=0.000514
188ms 200 mesh=5339452532 I55=0.474387 I60=0.086612
127ms 200 mesh=5235043011 I55=0.311153 I60=0.051145
-
T30_I55_PS= 30年以内に震度6弱以上になる確率 -
T30_I60_PS= 同じく震度6強以上
値は 0〜1 の小数で返ってきます。 2番目の地点は 0.474387 なので 47.4% です。
パーセントで来るものだと思って表示すると 0.47% になります。
自分は念のため両方受けられるようにしてあります。
function normalizeProbability(value: unknown) {
const numeric = typeof value === "number" ? value
: typeof value === "string" ? Number(value) : null;
if (numeric === null || Number.isNaN(numeric)) return null;
// 1 以下なら比率、それより大きければ既にパーセント
return numeric <= 1 ? roundToTenth(numeric * 100) : roundToTenth(numeric);
}
レスポンスは 127〜629ms、キャッシュなしでこの速さです。無料でこれが取れるのは
かなりありがたい部類だと思います。
2. 揺れの確率は「出しても情報量がない」ことがある
技術の話ではなく設計の話ですが、これが一番判断に迷いました。
J-SHIS を繋いだあと、当然「震度6弱以上の確率が高いので注意」という指摘を
出そうとしました。やめました。 日本のほぼ全域で高く出るので、
全部の結果に同じ指摘が1件増えるだけで、「確認すべき論点が実在する」という
指摘欄の意味が薄れます。
// **確認事項 (attentionItems) にはしない。** 日本のほぼ全域で確率が高く出るため、
// 全結果に 1 件足すことになり「確認すべき論点が実在する」という確認事項の意味が薄れる。
// 数字と出典を無料で出し、判断は利用者に委ねる。
代わりに、数字と出典だけを並べて判断は読む人に渡しています。
補足として、揺れの確率は洪水・土砂・液状化の該当とは独立です。
自分のデータでは、3つとも非該当の町が多いエリアでも震度6弱以上が約5割、
という組み合わせが普通に出ます。「ハザードマップが白いから地震も安心」には
まったくなりません。
3. 不動産情報ライブラリは User-Agent がないと403になる — ただし送信元による
ここは慎重に書きます。
自分の本番環境(Azure App Service)では、User-Agent を送らないリクエストが
403 で弾かれました。 Azure Front Door の WAF の挙動だと思われます。
なのでクライアントで明示的に名乗っています。
headers: {
"Ocp-Apim-Subscription-Key": apiKey,
Accept: "application/json",
// Reinfolib の Azure Front Door WAF が UA 無しのリクエストを 403 で弾くため、
// ブラウザ互換 UA を明示的に名乗る。
"User-Agent":
"Mozilla/5.0 (compatible; SafeLandBot/1.0; +https://example.com)",
},
ところが、この記事を書くために開発機から検証したら、UAなしでも 200 が返りました。
UAなし 200 310ms len=29090
UAあり 200 128ms len=29090
つまり WAF の判定は送信元やタイミングで変わります。
ここから引き出せる教訓は「UAを必ず付けろ」よりも、
- ローカルで再現しない403がある。 開発機で通るからといって本番で通るとは限らない
- 逆に、本番だけで起きる403を「コードのバグ」と疑って何時間も溶かさないこと
- 外部APIクライアントは、UA・Accept を最初から明示的に送っておくほうが安全
のほうだと思います。UAを付けるコスト(1行)に対して、原因究明のコストが
まったく釣り合いません。
4. 駅(XKT015)は Point ではなく LineString で返る
これは「エラーにならないまま値が消える」典型例でした。
駅データを GeoJSON で取り、座標を読んで最寄り駅を選ぶコードを書きました。
Point 前提で coordinates[0] を経度として読んでいました。
// ❌ Point 前提
const [longitude, latitude] = feature.geometry.coordinates;
XKT015 の駅はホームを線分(LineString)で返します。 なので
coordinates は [[lon, lat], [lon, lat], ...] です。coordinates[0] は
数値ではなく配列なので、typeof === "number" のチェックを通らず 全駅の座標が
null になり、最寄り駅が常に「取得できません」になっていました。
例外は出ません。ログにも何も出ません。ただ最寄り駅の欄が空になるだけです。
直し方: ジオメトリ型に依存せず、入れ子から座標の組を全部拾う
/**
* GeoJSON の座標から代表点 (頂点の平均) を取る。
*
* XKT015 の駅はホームを線分 (LineString) で返す。以前は Point 前提で
* coordinates[0] を経度として読んでいたため、全駅の座標が null になり、
* 最寄駅が常に「取得できません」になっていた。
*/
function representativePoint(coordinates: unknown) {
const vertices: Array<[number, number]> = [];
collectVertices(coordinates, vertices);
if (vertices.length === 0) return null;
// 頂点の平均を代表点にする
let lonSum = 0, latSum = 0;
for (const [lon, lat] of vertices) { lonSum += lon; latSum += lat; }
return { longitude: lonSum / vertices.length, latitude: latSum / vertices.length };
}
function collectVertices(value: unknown, out: Array<[number, number]>) {
if (!Array.isArray(value)) return;
const [first, second] = value;
if (typeof first === "number" && typeof second === "number") {
if (Number.isFinite(first) && Number.isFinite(second)) out.push([first, second]);
return;
}
for (const item of value) collectVertices(item, out);
}
再帰で [number, number] の組を拾うので、Point / LineString / Polygon /
MultiPolygon のどれでも動きます。GeoJSON の座標配列は入れ子の深さだけが違うので、
型で分岐するより深さを無視して拾うほうが壊れません。
この不具合は、テストがすり抜けました
一番反省した点です。テストは書いてありました。でも fixture の
ジオメトリを Point で書いていたのです。
// ✅ 通っていたテストの fixture
geometry: { type: "Point", coordinates: [135.5, 34.7] }
fixture を自分の理解で書くと、自分の誤解がそのまま fixture に入ります。
そしてテストは緑になります。今は実レスポンスの形で追加してあります。
// 実際の XKT015 はホームを線分で返す (LineString)。Point 前提のパーサだと座標が
// null になり、最寄駅が取得できなくなる。
test("fetchNearestStationForPoint handles LineString station geometry", async () => {
// geometry: { type: "LineString", coordinates: [[...], [...]] }
});
外部APIの fixture は、一度は実レスポンスを保存して形を見てから書く。
これだけで防げました。
5. 市区町村コードの粒度が、エンドポイント間で揃っていない
不動産情報ライブラリで取引価格を取るには市区町村コードが必要です。
コードは市区町村一覧のエンドポイント(XIT002)から取れます。
ところが、XIT002 は政令指定都市について親コード(例: 大阪市 = 27100)しか
返しません。一方、取引価格のエンドポイント(XIT001)は親コードを受け取ると
404 を返します。 区レベル(27128 など)が必要です。
つまり、同じAPIの中でコードの粒度が噛み合っていません。
一覧から取ったコードをそのまま渡すと、政令市だけ全部 404 になります。
自分は20政令市の区コードを静的テーブルで持つことにしました。
// Reinfolib XIT002 が政令指定都市は親コード(末尾00系)しか返さないが、
// XIT001 は親コードを受け付けず 404 を返す。区(ward)レベルのコードに変換する
// ため、20政令指定都市の区を明示的にマッピングしておく。
//
// コードは総務省「全国地方公共団体コード」(JIS X 0402) に準拠。
export const DESIGNATED_CITY_WARDS: Readonly<Record<string, readonly DesignatedCityWard[]>> = {
"01100": [ // 札幌市
{ code: "01101", name: "中央区" },
{ code: "01102", name: "北区" },
// ...
],
// ...
};
「静的テーブルは負債」と言われがちですが、政令指定都市の区は年に何度も
変わるものではないので、ここは割り切りました。出典(JIS X 0402)を
コメントに書いておけば、更新が必要になったときに追えます。
区名が拾えないときに落とさない
もう一つ実務的な点です。ユーザーが「大阪市粉浜3丁目」のように区名を省いた
住所を入れることがあります(政令市では実際に起きます)。
この場合、親コードしか決まらないので XIT001 は 404 です。
例外を投げるのではなく、都道府県全体にフォールバックさせました。
粗くなりますが落ちません。
const best = matches[0];
if (!best) {
// 区名が拾えないので親コードでは 404 になる。municipality を捨てて
// prefecture-wide クエリで分析させる(粗くなるが落ちない)。
return null;
}
外部APIのコード体系が噛み合わない箇所では、「精度を落として続ける」か
「エラーで止める」かを毎回決める必要があります。 自分の用途では、
価格の相場が府県平均になっても判定は成立するので、続ける側を選びました。
6. 液状化傾向図の「範囲内」は「液状化する」ではない
液状化傾向図(XKT025)はタイル単位でポリゴンを返します。素直に実装すると
「点がポリゴンに入っているか」で判定したくなります。
それは間違いです。 ポリゴンの中身には判定の段階が入っています。
今日、3タイル分(85ポリゴン)の実データを取って liquefaction_tendency_level
と note の対応を数えました。
level | note 件数
1 | 非常に液状化しやすい 25
2 | 液状化しやすい 2
3 | やや液状化しやすい 3
4 | やや液状化しにくい 2
5 | 液状化しにくい 53
85ポリゴンのうち53個、6割以上が「液状化しにくい」です。
「範囲内なら液状化リスクあり」と実装すると、データが「しにくい」と
言っている地点にまで注意を出すことになります。
なので判定は note の文言で決めています。
export function resolveLiquefactionTendency(
display: string | null | undefined,
): "prone" | "not_prone" | "unknown" {
if (!display) return "unknown";
// 「しにくい」を先に見る。「液状化しにくい」は「液状化し」を含むため順序が重要。
if (display.includes("しにくい") || display.includes("低い")) return "not_prone";
if (display.includes("しやすい") || display.includes("高い")) return "prone";
return "unknown";
}
コメントの通りですが、「しやすい」を先に判定すると「液状化しにくい」が
液状化し を含むので誤判定します。 日本語を部分一致で判定するときの定番の罠です。
7. liquefaction_tendency_level は値が小さいほど危険(記事を書きながらバグを見つけた)
上の表をもう一度見てください。level 1 が「非常に液状化しやすい」、
level 5 が「液状化しにくい」です。値が小さいほど危険です。
自分のコードはこうなっていました。
// ❌ 降順ソートの先頭を "strongest" として採用
const strongest = overlaps
.map((feature) => ({ level: ..., note: ..., topography: ... }))
.sort((left, right) => (right.level ?? -1) - (left.level ?? -1))[0];
降順で並べて先頭を取っているので、level 5(= 最も液状化しにくい)を
「最も強い」として採用していました。 名前と挙動が逆です。
実際に影響が出るのは、点が複数ポリゴンに重なる場合(メッシュ境界など)だけなので
めったに起きませんが、起きたときに安全側に倒れる方向が逆です。
リスクを報告する側なので、迷ったら「しやすい」側を採るべきです。
// liquefaction_tendency_level は **値が小さいほど液状化しやすい**。
// 1 = 非常に液状化しやすい / 2 = 液状化しやすい / 3 = やや液状化しやすい
// 4 = やや液状化しにくい / 5 = 液状化しにくい
// 以前は降順ソートの先頭を "strongest" として採用していたため、複数ポリゴンに
// 重なる地点 (メッシュ境界など) で **最も液状化しにくい**判定を拾っていた。
// リスクを報告する側なので、迷ったら「しやすい」側を採る。
const mostProne = overlaps
.map((feature) => ({ level: ..., note: ..., topography: ... }))
.sort((left, right) =>
(left.level ?? Number.POSITIVE_INFINITY) - (right.level ?? Number.POSITIVE_INFINITY),
)[0];
教訓として、
-
数値コードの向きは、必ず実データで確かめる。 「level が高い = 危険」は
自然な思い込みですが、このデータでは逆でした -
変数名に
strongest/worst/maxと書いたら、何が強いのかコメントで
固定する。 名前は検証してくれません -
nullを-1で埋めると、昇順に直したときに「最も危険」として先頭に来ます。
欠損はNumber.POSITIVE_INFINITY側に置きました
そしてこの不具合は、記事を書くために実データを取り直したときに見つかりました。
公開データを使った処理は、定期的に生のレスポンスを目で見る時間を取る価値があります。
8. タイルAPIのキャッシュは、テスト間で漏れる
最後に、上の修正のテストを書いているときに踏んだものです。
タイル単位のレスポンスはキャッシュしています(1日)。キャッシュキーはURLです。
そしてこのキャッシュはモジュールスコープに置いてあります。
新しいテストを、既存のテストと同じ座標で書きました。fetch をモックしても、
actual: 1, expected: 2
で落ちます。同じタイルなので、1つ目のテストがキャッシュした payload が
返ってきていて、モックが呼ばれていなかったのです。
対応としては、テストごとにキャッシュをクリアするのが正解ですが、
今回は座標を別タイルにするだけで足りたのでそうしました。コメントを添えて、
次に同じことをする人(自分)が5分溶かさないようにしています。
// 座標は 1 つ目のテストと別のタイルにする。XKT025 はタイル単位で
// レスポンスをキャッシュするので、同じタイルだと 1 つ目の payload を引いてしまう。
const ring = [ /* ... */ ];
モジュールスコープのキャッシュがあるライブラリをテストするときは、
「モックが呼ばれていない」をまず疑うと早いです。
まとめ
公開データのAPIを叩くコードで、自分が繰り返し確認するようになった点です。
-
J-SHIS はAPIキーなしで揺れの確率が取れる。
positionは経度,緯度の順、
値は 0〜1 の小数 - 全国で高く出る指標は、指摘にすると情報量が落ちる。 数字だけ出して判断を渡す
- WAF の403は送信元で変わる。 ローカルで再現しないことがある。UA は最初から付ける
-
GeoJSON のジオメトリ型を決め打ちしない。 駅は LineString。座標は入れ子を
再帰で拾う。そして fixture は実レスポンスを見てから書く -
同じAPIの中でもコード体系の粒度が揃っていないことがある。
噛み合わない箇所では「粗くして続ける」か「止める」かを決める -
「範囲内か」で危険判定をしない。 中身に段階がある。日本語の部分一致は
「しにくい」を先に見る - 数値コードの向きは実データで確かめる。 level が小さいほど危険なこともある
- モジュールスコープのキャッシュはテスト間で漏れる
共通しているのは、どれも例外を投げないということです。
座標が null になる、404 になる、注意が水増しされる、安全側と危険側が逆になる。
どれもプロセスは正常に動き続けます。
外部の公開データを扱うときは、生のレスポンスを目で見る時間を定期的に取るのが
結局いちばん安いと思っています。この記事を書くために取り直しただけで、
1つバグが見つかりました。