不動産情報ライブラリAPIで地図アプリを自作|FastAPI + PostGIS + Vue実装ガイド

プログラミング

国土交通省の「不動産情報ライブラリ」ってご存知ですか。2024年4月にサービス開始した公式のWeb GISで、地価公示・不動産取引価格・都市計画・防災情報なんかが地図上で見られるものです。しかもAPIまで公開してるんですよ。

「これ、自分でも地図アプリ作れるじゃん」と思って手を動かしてみたら、PostGISの沼に足を踏み入れることになりました。その記録を残しておきます。

この記事でわかること

  • 不動産情報ライブラリAPIの基本的な使い方
  • FastAPIでPostGISを使った空間検索APIの実装方法
  • Vue + Leafletで地図上にデータを表示する流れ
  • GISデータを扱う際のハマりやすいポイント

不動産情報ライブラリAPIとは

不動産情報ライブラリAPIは、国土交通省が無料で公開しているAPIです。不動産取引価格情報・地価公示・地価調査・都市計画情報・国土数値情報などをプログラムから取得できます。出力形式はGeoJSONとPBF(Mapbox Vector Tiles用)に対応していて、GIS系の用途を明確に意識した設計になっています。

APIを利用するには、API利用規約に同意のうえでAPI利用申請が必要です。審査・承認が完了するとAPIキーが発行されます。自分は数日で届きました。

サイト上のお知らせを見ると、防災系APIまわりの更新も着々と進んでるらしいです。

作ったシステムの全体構成

今回作ったのはこんな構成のシステムです:

  • バックエンド:FastAPI(Python)
  • DB:PostgreSQL + PostGIS(Dockerで起動)
  • フロントエンド:Vue 3 + Leaflet.js
  • フロー:不動産情報ライブラリAPIから取引価格データを取得 → PostGISに保存 → 地図上で可視化 + 空間検索

空間検索とは「この地点から半径500m以内の取引事例を出す」みたいなやつですね。PostGISを使えばSQLだけでできるので、それをやってみたかった。

Step 1:APIからデータを取得する(Python)

まずAPIを叩いてデータを引っ張ってきます。取引価格情報のAPI IDはXIT001で、都道府県コードや期間などをパラメータで指定します。

import httpx
import os

API_KEY = os.environ["REINFOLIB_API_KEY"]
BASE_URL = "https://www.reinfolib.mlit.go.jp/ex-api/external"

def fetch_trade_prices(pref_code: str, from_year: str) -> dict:
    url = f"{BASE_URL}/XIT001"
    headers = {"Ocp-Apim-Subscription-Key": API_KEY}
    params = {
        "year": from_year,
        "area": pref_code,
    }
    resp = httpx.get(url, headers=headers, params=params, timeout=30)
    resp.raise_for_status()
    return resp.json()

data = fetch_trade_prices(pref_code="13", from_year="20241")
print(data["data"][0])  # 1件確認

重要なのは、APIキーをOcp-Apim-Subscription-Keyヘッダーに入れることです。自分は最初Authorizationに入れて認証エラーで詰まりました。

レスポンスのデータ構造がちょっとクセがあって、位置情報の提供有無や形式はAPIによって異なるようです。取引価格系のデータについても、状況によっては後続処理で変換が必要になることがあります。

Step 2:PostGISにデータを保存する

まずDockerでPostGISを立ち上げます。

# docker-compose.yml
services:
  db:
    image: postgis/postgis:16-3.4
    environment:
      POSTGRES_DB: reinfolib
      POSTGRES_USER: user
      POSTGRES_PASSWORD: password
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data

volumes:
  pgdata:

docker compose up -dで起動します。重要なのは、postgresイメージを使わずにpostgis/postgisイメージを使うことです。後からPostGISを入れようとすると地味に手間がかかります。

次にテーブルを定義します。GeoAlchemy2を使うとSQLAlchemyのモデルで空間型を扱えるので便利です。

from sqlalchemy import Column, String, Integer, Float, BigInteger
from sqlalchemy.orm import DeclarativeBase
from geoalchemy2 import Geometry

class Base(DeclarativeBase):
    pass

class TradePrice(Base):
    __tablename__ = "trade_prices"

    id = Column(BigInteger, primary_key=True, autoincrement=True)
    pref_code = Column(String(2))
    city_name = Column(String(50))
    trade_price = Column(Integer)           # 取引価格(円)
    area = Column(Float)                    # 面積(㎡)
    year = Column(String(10))
    geom = Column(Geometry("POINT", srid=4326))  # 空間カラム

マイグレーションはAlembicを使うべきですが、検証段階なのでBase.metadata.create_all(engine)で作りました。

データをInsertする部分です:

from geoalchemy2.shape import from_shape
from shapely.geometry import Point

def insert_trade(session, item: dict):
    lat = item.get("Latitude")
    lon = item.get("Longitude")
    if not lat or not lon:
        return  # 位置情報なしはスキップ

    geom = from_shape(Point(float(lon), float(lat)), srid=4326)

    record = TradePrice(
        pref_code=item.get("Prefecture"),
        city_name=item.get("Municipality"),
        trade_price=item.get("TradePrice"),
        area=item.get("Area"),
        year=item.get("Period"),
        geom=geom,
    )
    session.add(record)

ShapelyでPointを作ってfrom_shapeでPostGIS用の型に変換します。引数の順番がlon(経度)が先(x, y順)なのに注意。GISあるあるのハマりポイントです。

Step 3:FastAPIで空間クエリAPIを作る

PostGISに保存したデータを検索するAPIを作ります。メインは「指定座標から半径Nメートル以内の取引事例を返す」エンドポイントです。

from fastapi import FastAPI, Query
from sqlalchemy import select, text
from geoalchemy2.functions import ST_DWithin, ST_AsGeoJSON, ST_Transform

app = FastAPI()

@app.get("/api/trades/nearby")
async def get_nearby_trades(
    lat: float = Query(...),
    lon: float = Query(...),
    radius_m: int = Query(default=500, le=5000),
):
    async with async_session() as session:
        stmt = (
            select(
                TradePrice.id,
                TradePrice.city_name,
                TradePrice.trade_price,
                TradePrice.area,
                TradePrice.year,
                ST_AsGeoJSON(TradePrice.geom).label("geojson"),
            )
            .where(
                ST_DWithin(
                    ST_Transform(TradePrice.geom, 3857),
                    ST_Transform(
                        text(f"ST_SetSRID(ST_MakePoint({lon}, {lat}), 4326)"),
                        3857,
                    ),
                    radius_m,
                )
            )
            .limit(200)
        )
        results = await session.execute(stmt)
        rows = results.fetchall()

    return {
        "type": "FeatureCollection",
        "features": [
            {
                "type": "Feature",
                "geometry": row.geojson,
                "properties": {
                    "id": row.id,
                    "city_name": row.city_name,
                    "trade_price": row.trade_price,
                    "area": row.area,
                    "year": row.year,
                },
            }
            for row in rows
        ],
    }

ST_DWithinで距離検索をするやり方はいくつかあります。自分は座標系をEPSG:4326(緯度経度)からEPSG:3857(平面座標)に変換してメートルっぽく扱う方法にしました。ほかにもPostGISのgeography型を使ってメートルで距離計算するやり方もあります。ここを雑にすると結果がおかしくなるので注意が必要です。

正直なところ、PostGISのSQL関数がたくさん出てきてGeoAlchemy2のドキュメントを何往復もしました。もっとシンプルな書き方があるかもしれませんが、とりあえず動いているのでよしとしています。

Step 4:Vue + Leafletで地図表示

フロントはVue 3 + Leaflet.jsの組み合わせです。地図ライブラリはMapLibreも検討しましたが、LeafletのほうがVueとの組み合わせで情報が多かったのでそっちにしました。

// MapView.vue(抜粋)
<template>
  <div id="map" style="height: 600px" />
</template>

<script setup>
import { onMounted, ref } from "vue";
import L from "leaflet";
import "leaflet/dist/leaflet.css";
import axios from "axios";

const map = ref(null);

onMounted(() => {
  map.value = L.map("map").setView([35.681, 139.767], 13);

  L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
    attribution: "© OpenStreetMap contributors",
  }).addTo(map.value);

  map.value.on("click", async (e) => {
    const { lat, lng } = e.latlng;
    const { data } = await axios.get("/api/trades/nearby", {
      params: { lat, lon: lng, radius_m: 500 },
    });

    L.geoJSON(data, {
      pointToLayer: (feature, latlng) =>
        L.circleMarker(latlng, {
          radius: 6,
          color: "#e74c3c",
          fillOpacity: 0.7,
        }),
      onEachFeature: (feature, layer) => {
        const p = feature.properties;
        layer.bindPopup(
          `<b>${p.city_name}</b><br>取引価格: ${p.trade_price?.toLocaleString()}円<br>面積: ${p.area}㎡<br>${p.year}`
        );
      },
    }).addTo(map.value);
  });
});
</script>

地図クリックで周辺500mの取引事例を取得してマーカーを打つというシンプルな動作です。GeoJSONをLeafletに渡すだけで表示されるのは気持ちいい。

ただ、クリックするたびにレイヤーが積み重なっていくバグに最初ハマりました。既存レイヤーをクリア処理してから追加する必要があります。

動かしてみた感想と実装での課題

東京都内のデータをインポートして動かしてみたところ、それっぽく動きました。クリックするとポップアップで取引情報が出てくるのは普通に便利で、「公式サイトに似たものが手元で動いてる」という謎の満足感があります。

ただし、実装上の課題もありました。

  • APIのレスポンスに位置情報が入っていない取引データが結構ある(緯度経度がnull)
  • 全国分を取得しようとするとAPI呼び出し回数がかさむ(レートリミット超過時は429が返るため、リトライ処理・待機時間の設定が必要)
  • PostGISのインデックス(CREATE INDEX ON trade_prices USING GIST(geom))を忘れると検索が激遅になる

特に空間インデックスの件は、入れ忘れると体感で10倍以上遅いです。GISデータは空間インデックスが命だと学びました。

次にやってみたいこと

取引価格を面積で割って㎡単価でヒートマップ表示するやつをやってみたいです。Leafletだとleaflet-heatプラグインで実装できるらしい。あとPostGISで市区町村ポリゴンと結合して「エリア別の平均単価」も出してみたい。

不動産情報ライブラリは国交省がここまでオープンなデータをAPIで公開してくれているのはありがたいので、もう少し活用できる形にしていきたいです。

※この記事にはプロモーションが含まれます

ちなみに、お名前.com レンタルサーバー(WordPressに特化した高速レンタルサーバー。月額990円〜、独自ドメイン実質0円)も気になっています。お名前.com レンタルサーバー

参考になったらクリックしてもらえると嬉しいです!

Blogmura ProgrammingProgramming Ranking
タイトルとURLをコピーしました