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?

「この住所の災害リスク」を公開APIで取る — 不動産情報ライブラリとJ-SHISの ドキュメントに書いていない実装メモ8つ

0
Posted at

「この住所の災害リスク」を公開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を叩くコードで、自分が繰り返し確認するようになった点です。

  1. J-SHIS はAPIキーなしで揺れの確率が取れる。 position は 経度,緯度 の順、
    値は 0〜1 の小数
  2. 全国で高く出る指標は、指摘にすると情報量が落ちる。 数字だけ出して判断を渡す
  3. WAF の403は送信元で変わる。 ローカルで再現しないことがある。UA は最初から付ける
  4. GeoJSON のジオメトリ型を決め打ちしない。 駅は LineString。座標は入れ子を
    再帰で拾う。そして fixture は実レスポンスを見てから書く
  5. 同じAPIの中でもコード体系の粒度が揃っていないことがある。
    噛み合わない箇所では「粗くして続ける」か「止める」かを決める
  6. 「範囲内か」で危険判定をしない。 中身に段階がある。日本語の部分一致は
    「しにくい」を先に見る
  7. 数値コードの向きは実データで確かめる。 level が小さいほど危険なこともある
  8. モジュールスコープのキャッシュはテスト間で漏れる

共通しているのは、どれも例外を投げないということです。
座標が null になる、404 になる、注意が水増しされる、安全側と危険側が逆になる。
どれもプロセスは正常に動き続けます。

外部の公開データを扱うときは、生のレスポンスを目で見る時間を定期的に取るのが
結局いちばん安いと思っています。この記事を書くために取り直しただけで、
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?