🌐

AIに自分の位置情報をストーキングしてもらえるSkillを作った

に公開

はじめに

最近の私は、暇な時にAIと雑談することがよくあるのですが、会話していると以下のニーズを感じました。

  • 現在の位置情報を参照し、それに応じた応答を返してほしい
  • 一連の移動履歴 (旅行など) を参照させ、それを一緒に振り返りたい

...「何言ってんだこいつ?」となった方は、そのままタブを閉じてください。

やりたいこと

今回は以下の作業を行います。

  • Googleロケーション履歴の情報をExportしDBに格納、APIからアクセス可能にする
  • OwnTracksアプリを用いて、最新の位置情報を記録する
  • Skill経由でAIが位置情報にアクセスできるようにする

DBとAPIの構成には、Cloudflareのサービスを用いています。
他のサービスを利用したい場合は適宜読み替えてください。

1. Googleロケーション履歴をパースする

ロケーション履歴のExport

これは、普段ロケーション履歴を記録しているスマートフォンで行います。2026/02現在、
Google マップ タイムラインを管理する - Android - マップ ヘルプ に書かれている手順をそのまま実行すればOKです。

  1. Android のスマートフォンかタブレットで、設定アプリ 設定 を開きます。
  2. [位置情報] > [位置情報サービス] > [タイムライン] をタップします。
  3. [タイムライン] で [タイムライン データを エクスポート] をタップします。
  4. [続行] をタップします。
  5. 希望の保存場所を選択します。
  6. [保存] をタップします。

エクスポートが完了すると、[エクスポートが完了しました] というポップアップ通知が表示されます。

この手順で、Timeline.json という単一のJSONファイルが出力されます。私の場合、2012年から2026年まで約14年分の履歴で、ファイルサイズは数十MBになりました。

JSONの構造(semanticSegments, rawSignals, timelinePath)

以下は細かい解説になります。
「理屈はいいからとりあえず動かしたい」という方は、このあたりのコンテキストをAIに喰わせつつ読み飛ばしてください。

エクスポートされた Timeline.json を開くと、トップレベルに semanticSegments という配列があります。この中に、Googleが解析・分類した移動と滞在の情報がすべて詰まっています。

{
  "semanticSegments": [
    { ... },
    { ... },
    ...
  ]
}

ひとつひとつの semanticSegment は、大きく3つのパターンに分かれます。

パターン1: timelinePath(移動経路)

移動中の軌跡を表す点列データです。

{
  "startTime": "2024-06-25T19:00:00.000+09:00",
  "endTime": "2024-06-25T21:00:00.000+09:00",
  "timelinePath": [
    {
      "point": "35.6812000°, 139.7671000°",
      "time": "2024-06-25T19:05:00.000+09:00"
    },
    {
      "point": "35.6895000°, 139.6917000°",
      "time": "2024-06-25T19:45:00.000+09:00"
    }
  ]
}

ポイントは、座標が "35.6812000°, 139.7671000°" という 文字列 で格納されていること。緯度・経度を数値として使うには、° を除去してパースする必要があります。

パターン2: visit(滞在地点)

特定の場所に一定時間滞在したことを表します。Google Places APIの placeId が紐づいていることもあります。

{
  "startTime": "2025-07-25T20:23:35.000+09:00",
  "endTime": "2025-07-25T22:39:54.000+09:00",
  "visit": {
    "hierarchyLevel": 0,
    "probability": 0.69,
    "topCandidate": {
      "placeId": "ChIJxyz...",
      "semanticType": "INFERRED_WORK",
      "probability": 0.999,
      "placeLocation": {
        "latLng": "35.6812000°, 139.7671000°"
      }
    }
  }
}

semanticType には INFERRED_HOME(自宅推定)、INFERRED_WORK(職場推定)、UNKNOWN などの値が入ります。Googleが行動パターンから自動推定したものです。

パターン3: activity(移動セグメント)

移動手段を含む移動区間です。

{
  "startTime": "2024-06-25T18:30:00.000+09:00",
  "endTime": "2024-06-25T19:00:00.000+09:00",
  "activity": {
    "start": { "latLng": "35.6812000°, 139.7671000°" },
    "end": { "latLng": "35.6298000°, 139.7421000°" },
    "topCandidate": {
      "type": "IN_PASSENGER_VEHICLE",
      "probability": 0.85
    }
  }
}

パターン4: rawSignals(生の測位データ)

上記のセマンティックな情報に加えて、WiFi・GPS・セルの生データが含まれることがあります。

{
  "startTime": "2024-06-25T10:00:00.000+09:00",
  "endTime": "2024-06-25T10:05:00.000+09:00",
  "timelineMemory": {},
  "rawSignals": {
    "wifiScans": [
      {
        "deliveryTime": "2024-06-25T10:01:00.000+09:00",
        "devices": [ ... ]
      }
    ],
    "position": {
      "point": { "latLng": "35.6812000°, 139.7671000°" },
      "accuracyMm": 10000,
      "altitudeMeters": 40.0,
      "source": "WIFI",
      "timestamp": "2024-06-25T10:01:00.000+09:00"
    }
  }
}

rawSignalsからのタイムスタンプ抽出は、ネストが深く、フィールドの有無も一定ではないため、パーサーの実装で一番苦労したポイントでした。

Pythonパーサーの実装

4つのパターンをすべて処理するPythonパーサーを書きました。ゴールは、全データを統一的なCSV形式に変換すること。カラムは以下の通りです。

timestamp, lat, lon, accuracy, source, place_id, semantic_type, activity_type, altitude, speed

パーサーのコアロジックを示します。

import json
import csv
import re
from datetime import datetime

def parse_latlng(latlng_str: str) -> tuple[float, float]:
    """'35.6812000°, 139.7671000°' 形式の文字列を (lat, lon) に変換"""
    parts = re.findall(r'[-\d.]+', latlng_str)
    if len(parts) >= 2:
        return float(parts[0]), float(parts[1])
    return None, None

def parse_segments(timeline_json_path: str) -> list[dict]:
    with open(timeline_json_path, 'r') as f:
        data = json.load(f)

    rows = []
    segments = data.get('semanticSegments', [])

    for seg in segments:
        start_time = seg.get('startTime', '')

        # パターン1: timelinePath(移動経路の点列)
        if 'timelinePath' in seg:
            for point in seg['timelinePath']:
                lat, lon = parse_latlng(point.get('point', ''))
                if lat is not None:
                    rows.append({
                        'timestamp': point.get('time', start_time),
                        'lat': lat,
                        'lon': lon,
                        'source': 'path',
                    })

        # パターン2: visit(滞在地点)
        if 'visit' in seg:
            visit = seg['visit']
            candidate = visit.get('topCandidate', {})
            location = candidate.get('placeLocation', {})
            latlng = location.get('latLng', '')
            lat, lon = parse_latlng(latlng)
            if lat is not None:
                rows.append({
                    'timestamp': start_time,
                    'lat': lat,
                    'lon': lon,
                    'source': 'visit',
                    'place_id': candidate.get('placeId', ''),
                    'semantic_type': candidate.get('semanticType', ''),
                })

        # パターン3: activity(移動セグメント)
        if 'activity' in seg:
            activity = seg['activity']
            start_loc = activity.get('start', {}).get('latLng', '')
            lat, lon = parse_latlng(start_loc)
            candidate = activity.get('topCandidate', {})
            if lat is not None:
                rows.append({
                    'timestamp': start_time,
                    'lat': lat,
                    'lon': lon,
                    'source': 'activity',
                    'activity_type': candidate.get('type', ''),
                })

        # パターン4: rawSignals(生の測位データ)
        if 'rawSignals' in seg:
            raw = seg['rawSignals']
            position = raw.get('position', {})
            if position:
                point = position.get('point', {})
                latlng = point.get('latLng', '')
                lat, lon = parse_latlng(latlng)
                source_type = position.get('source', 'UNKNOWN')
                if lat is not None:
                    rows.append({
                        'timestamp': position.get('timestamp', start_time),
                        'lat': lat,
                        'lon': lon,
                        'accuracy': position.get('accuracyMm', None),
                        'altitude': position.get('altitudeMeters', None),
                        'source': f'raw:{source_type}',
                    })

    return rows

実行結果

14年分の Timeline.json をこのパーサーに通した結果、302,100件 のロケーションレコードが抽出できました。

$ python parse_location.py Timeline.json -o locations.csv
Parsed 302,100 records from Timeline.json
  - path:     187,432 records
  - visit:     45,218 records
  - activity:  28,907 records
  - raw:WIFI:  32,891 records
  - raw:GPS:    6,402 records
  - raw:CELL:   1,250 records

パース時のハマりポイント

座標が数値ではなく文字列

まず最初に引っかかるのが、座標が "35.6812000°, 139.7671000°" という度数記号つきの 文字列 で格納されていること。GeoJSON のように [139.7671, 35.6812] という数値配列を期待していると面食らいます。正規表現 r'[-\d.]+' で数値部分だけ抽出するアプローチが一番堅牢でした。

同じデータなのにアクセスパスがバラバラ

座標の文字列フォーマット自体は統一されているんですが、JSONツリー上のどこに埋まっているかがセグメントの種類ごとに全く違います。

パターン アクセスパス
timelinePath segment.timelinePath[].point
visit segment.visit.topCandidate.placeLocation.latLng
activity segment.activity.start.latLng / segment.activity.end.latLng
rawSignals segment.rawSignals.position.point.latLng

visitは5階層、rawSignalsは4階層潜る必要があり、途中のどのキーも存在しない可能性がある。結果として、パーサーのコードは 座標を取り出す という単純な処理のわりに、分岐と存在チェックが膨らみがちになります。

rawSignals のタイムスタンプ問題

rawSignals セクションは、セグメントの startTime / endTime と、内部の position.timestamp が異なるタイムゾーン表記になっていることがありました。セグメントレベルのタイムスタンプはローカルタイム(+09:00)なのに、rawSignals 内部はUTCだったり、その逆だったり。

最終的に、タイムスタンプはすべて元の文字列のまま保存し、DBに格納する際もISO 8601形式を維持する方針にしました。表示時にJSTに変換するのはAPI側(後述)の責務としています。

rawSignals のフィールド欠損

rawSignals は全セグメントに含まれるわけではなく、含まれていても position フィールドが存在しないケースがあります。wifiScans だけ存在して座標情報がない、というパターンもあるため、存在チェックを丁寧に行う必要がありました。最初のパーサーでは KeyError が多発して、デバッグに時間がかかったポイントです。

2. Cloudflare Workers + D1 で位置情報APIを構築する

なぜ Cloudflare Workers + D1 なのか

位置情報APIのバックエンドとして Cloudflare Workers + D1 を選んだ理由はシンプルです。

  • 無料枠が大きい:Workers は1日10万リクエスト、D1 は500万行読み取り/日。個人用途なら課金が発生しない
  • エッジで動く:日本からのアクセスでレイテンシが低い(APAC リージョンに自動配置)
  • SQLite ベース:D1 はSQLiteなので、スキーマ設計もクエリも馴染みがある
  • デプロイが楽wrangler deploy 一発で本番反映

プロジェクトの初期セットアップ

# プロジェクト作成(Worker only / TypeScript)
npm create cloudflare@latest location-sync-api

# D1 データベース作成
wrangler d1 create location-sync-db

D1 作成時の出力に含まれる database_idwrangler.toml に追記します。wrangler が対話的に設定してくれるので、基本は指示に従うだけです。

name = "location-sync-api"
main = "src/index.ts"
compatibility_date = "2026-01-28"

[[d1_databases]]
binding = "DB"
database_name = "location-sync-db"
database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"

D1 スキーマ設計

位置情報は1章で抽出した4パターン(path / visit / activity / rawSignals)をすべて1テーブルに格納します。正規化してテーブルを分けることも考えましたが、クエリの大半が「時間範囲で位置を取得する」という単純なものなので、1テーブルにフラットに持つ方が実用的でした。

-- schema.sql
CREATE TABLE IF NOT EXISTS locations (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    timestamp TEXT NOT NULL,
    lat REAL NOT NULL,
    lon REAL NOT NULL,
    accuracy REAL,
    source TEXT,          -- 'path', 'visit', 'activity', 'raw:WIFI', 'raw:GPS', etc.
    place_id TEXT,        -- Google Places ID(visitの場合)
    semantic_type TEXT,   -- 'INFERRED_HOME', 'INFERRED_WORK', 'UNKNOWN'
    activity_type TEXT,   -- 'WALKING', 'IN_PASSENGER_VEHICLE', etc.
    altitude REAL,
    speed REAL,
    created_at TEXT DEFAULT CURRENT_TIMESTAMP
);

-- 時間範囲クエリの高速化
CREATE INDEX IF NOT EXISTS idx_locations_timestamp ON locations(timestamp);

-- ソース別フィルタ用
CREATE INDEX IF NOT EXISTS idx_locations_source ON locations(source);

-- 複合インデックス(時間+ソースでのフィルタリング)
CREATE INDEX IF NOT EXISTS idx_locations_ts_source ON locations(timestamp, source);

インデックスは3本。位置情報APIの典型的なクエリパターン(「直近N日間」「ソース別フィルタ」「時間+ソースの組み合わせ」)をカバーしています。30万件超のテーブルでは、インデックスの有無で体感速度が変わります。

# ローカルでスキーマ適用
wrangler d1 execute location-sync-db --local --file=./schema.sql

# 本番に適用
wrangler d1 execute location-sync-db --remote --file=./schema.sql

30万件のバルクインポート

1章のパーサーで生成した302,095件のCSVを D1 に投入する必要があります。
解決策は、SQLiteの複数行INSERT構文を使いつつ、ファイルを適切なサイズに分割すること。

# csv_to_sql.py - CSVからバッチINSERT文を生成

import csv
import sys

BATCH_SIZE = 500  # 1つのINSERT文に含める行数
FILE_SPLIT = 10000  # 1ファイルあたりの行数

def generate_sql_files(csv_path: str, output_prefix: str):
    with open(csv_path, 'r') as f:
        reader = csv.DictReader(f)
        rows = list(reader)

    file_num = 0
    for file_start in range(0, len(rows), FILE_SPLIT):
        file_rows = rows[file_start:file_start + FILE_SPLIT]
        file_num += 1
        filename = f"{output_prefix}_{file_num:03d}.sql"

        with open(filename, 'w') as out:
            for batch_start in range(0, len(file_rows), BATCH_SIZE):
                batch = file_rows[batch_start:batch_start + BATCH_SIZE]
                values = []
                for row in batch:
                    vals = (
                        f"'{row['timestamp']}'",
                        str(row['lat']),
                        str(row['lon']),
                        f"'{row.get('accuracy', '')}'",
                        f"'{row.get('source', '')}'",
                        f"'{row.get('place_id', '')}'",
                        f"'{row.get('semantic_type', '')}'",
                        f"'{row.get('activity_type', '')}'",
                        f"'{row.get('altitude', '')}'",
                        f"'{row.get('speed', '')}'",
                    )
                    values.append(f"({', '.join(vals)})")

                out.write(
                    "INSERT INTO locations "
                    "(timestamp, lat, lon, accuracy, source, "
                    "place_id, semantic_type, activity_type, "
                    "altitude, speed) VALUES\n"
                )
                out.write(',\n'.join(values))
                out.write(';\n\n')

        print(f"Generated {filename} ({len(file_rows)} rows)")

これで seed_001.sqlseed_031.sql のようなファイル群が生成されます。あとはシェルスクリプトで順番に流し込むだけ。

#!/bin/bash
# import_all.sh
for f in seed_*.sql; do
    echo "Importing $f ..."
    wrangler d1 execute location-sync-db --remote --file="$f"
    echo "Done: $f"
    sleep 1  # レートリミット対策
done
$ ./import_all.sh
Importing seed_001.sql ...
Done: seed_001.sql
Importing seed_002.sql ...
Done: seed_002.sql
...
Importing seed_031.sql ...
Done: seed_031.sql

全31ファイル、約20分ほどで302,095件のインポートが完了しました。sleep 1 を挟んでいるのは D1 の書き込みレートリミットを踏まないようにするためです。

Workers API の実装

認証

Bearer Token に加えて、後述する OwnTracks アプリからのデータ受信用に Basic Auth も実装しています。OwnTracks の HTTP モードは Basic Auth にしか対応していないためです。

// 認証ヘルパー
function authenticate(request: Request, env: Env): boolean {
    const authHeader = request.headers.get('Authorization') || '';

    // Bearer Token(Claude Skill / 直接API呼び出し用)
    if (authHeader.startsWith('Bearer ')) {
        return authHeader.slice(7) === env.API_KEY;
    }

    // Basic Auth(OwnTracks 用)
    if (authHeader.startsWith('Basic ')) {
        const decoded = atob(authHeader.slice(6));
        const [user, pass] = decoded.split(':');
        return user === env.BASIC_USER && pass === env.BASIC_PASS;
    }

    return false;
}

エンドポイント

GET  /health              - ヘルスチェック(認証不要)
GET  /locations?days=N    - 位置情報の一覧取得
GET  /locations/latest    - 最新の位置情報を1件取得
POST /locations           - OwnTracks からの位置情報受信

GET /locations のクエリパラメータ:

パラメータ 説明 デフォルト
days 取得日数 7
limit 最大件数 1000(上限10000)
source ソースフィルタ なし
after この日時以降(ISO 8601) なし
before この日時以前(ISO 8601) なし

GET /locations の実装

async function handleGetLocations(
    url: URL, env: Env
): Promise {
    const days = parseInt(url.searchParams.get('days') || '7');
    const limit = Math.min(
        parseInt(url.searchParams.get('limit') || '1000'),
        10000
    );
    const source = url.searchParams.get('source');
    const after = url.searchParams.get('after');
    const before = url.searchParams.get('before');

    let query = 'SELECT * FROM locations WHERE 1=1';
    const params: any[] = [];

    if (after) {
        query += ' AND timestamp > ?';
        params.push(after);
    } else if (before) {
        query += ' AND timestamp < ?';
        params.push(before);
    } else {
        // デフォルト: N日前から
        query += " AND timestamp > datetime('now', ?)";
        params.push(`-${days} days`);
    }

    if (source) {
        query += ' AND source = ?';
        params.push(source);
    }

    query += ' ORDER BY timestamp DESC LIMIT ?';
    params.push(limit);

    const result = await env.DB
        .prepare(query)
        .bind(...params)
        .all();

    return new Response(JSON.stringify({
        count: result.results.length,
        locations: result.results,
    }), {
        headers: {
            'Content-Type': 'application/json',
            'Access-Control-Allow-Origin': '*',
        },
    });
}

datetime('now', ?) は SQLite のビルトイン関数で、UTC基準の相対日時フィルタになります。D1 はSQLiteベースなので、こうした関数がそのまま使えるのが便利です。

GET /locations/latest の実装

最新の位置情報を1件返すシンプルなエンドポイント。Claude Skill から「今どこにいる?」を実現するために用意しました。

async function handleGetLatest(env: Env): Promise {
    const result = await env.DB
        .prepare(
            'SELECT * FROM locations ORDER BY timestamp DESC LIMIT 1'
        )
        .first();

    return new Response(JSON.stringify({
        location: result,
    }), {
        headers: { 'Content-Type': 'application/json' },
    });
}

デプロイ

# シークレットの設定
wrangler secret put API_KEY         # Bearer Token
wrangler secret put BASIC_USER      # OwnTracks用ユーザー名
wrangler secret put BASIC_PASS      # OwnTracks用パスワード

# デプロイ
wrangler deploy
$ wrangler deploy
Total Upload: 5.12 KiB / gzip: 1.68 KiB
Deployed location-sync-api triggers

動作確認:

# ヘルスチェック
$ curl https://location-sync-api.example.workers.dev/health
{"status":"ok"}

# 認証なし → 401
$ curl https://location-sync-api.example.workers.dev/locations
{"error":"Unauthorized"}

# 認証あり → データ取得
$ curl -H "Authorization: Bearer " \
    "https://location-sync-api.example.workers.dev/locations?days=1"
{"count":42,"locations":[...]}

タイムゾーンの罠(再び)

1章でも触れましたが、タイムゾーンの問題はAPI層でも顔を出します。

D1に格納されたタイムスタンプは、元データのフォーマットがそのまま保存されています。Google Takeout由来のデータはJST(+09:00付き)、後述するOwnTracks由来のデータはUTC(Zサフィックス)。同じ timestamp カラムに異なるタイムゾーン表記が混在する状態です。

2024-06-25T19:00:00.000+09:00   ← Google Takeout (JST)
2026-02-07T10:30:00.000Z         ← OwnTracks (UTC)

datetime('now', '-7 days') によるフィルタはUTC基準で動くため、JSTのタイムスタンプとの比較で最大9時間のズレが生じます。個人用途で「だいたい直近N日」がわかればいいので、厳密な整合性よりも元データの忠実な保存を優先しました。API利用側(Claude Skillなど)で表示時にJSTに変換する方針です。

3. OwnTracks でリアルタイム位置情報を記録する

過去の履歴の保存は完了しましたが、問題は今後のリアルタイムデータをどう扱うか。「今どこにいる?」に答えるには、端末が現在地を自発的にPOSTしてくれる仕組みが必要です。
ここでは、既存のアプリである OwnTracks を利用しました。

https://play.google.com/store/apps/details?id=org.owntracks.android&pcampaignid=web_share

OwnTracks は、オープンソースの位置情報トラッキングアプリです。iOS/Android対応で、端末のGPS座標を定期的にサーバーに送信します。

通常、OwnTracksはMQTTブローカー経由でデータを配信するのがメインの使い方ですが、HTTPモード も提供されています。HTTPモードでは、指定したURLにJSON payloadをPOSTするだけ。MQTTブローカーの運用が不要で、既存のREST APIにそのまま繋げられるのが利点です。

個人利用でMQTTブローカーを立てるほどでもない、でも位置情報のリアルタイム記録は欲しい……という用途にぴったりでした。

OwnTracks のペイロード

OwnTracksがHTTPモードで送信するJSONはこんな形式です:

{
  "_type": "location",
  "tid": "KO",
  "lat": 35.6812,
  "lon": 139.7671,
  "tst": 1707300000,
  "acc": 10,
  "alt": 40,
  "vel": 0,
  "batt": 85,
  "conn": "w",
  "topic": "owntracks/user/device"
}

主要なフィールド:

フィールド 説明
_type string ペイロードタイプ。位置情報は "location"
lat / lon float 緯度・経度
tst integer UNIXエポックタイムスタンプ(秒)
acc integer 精度(メートル)
alt integer 高度(メートル)
vel integer 速度(km/h)
batt integer バッテリー残量(%)
tid string Tracker ID。アプリで設定した2文字の識別子
conn string 接続状態。w=WiFi, m=モバイル
topic string HTTPモードでのみ付与。owntracks/user/device 形式

ポイントは、タイムスタンプが UNIXエポック秒(UTC) であること。Google Takeoutデータが 2024-06-25T19:00:00.000+09:00 のようなISO 8601文字列(JST付き)だったのに対し、OwnTracksは数値のエポック秒。受信側でISO 8601に変換する必要があります。

POST エンドポイントの実装

POST /locations ハンドラ

OwnTracksからのPOSTを受け取って、D1のlocationsテーブルに挿入するハンドラです。

async function handlePostLocation(
    request: Request, env: Env
): Promise {
    const payload = await request.json() as OwnTracksPayload;

    // _type: "location" 以外は無視
    // OwnTracksはwaypoint、transition等も送ってくる
    if (payload._type !== 'location') {
        return new Response(JSON.stringify([]), {
            headers: { 'Content-Type': 'application/json' },
        });
    }

    // UNIXエポック秒 → ISO 8601 (UTC) に変換
    const timestamp = new Date(payload.tst * 1000).toISOString();

    await env.DB.prepare(`
        INSERT INTO locations
            (timestamp, lat, lon, accuracy, source,
             altitude, speed)
        VALUES (?, ?, ?, ?, 'owntracks', ?, ?)
    `).bind(
        timestamp,
        payload.lat,
        payload.lon,
        payload.acc || null,
        payload.alt || null,
        payload.vel || null,
    ).run();

    // OwnTracksはレスポンスとして空のJSON配列を期待する
    return new Response(JSON.stringify([]), {
        headers: { 'Content-Type': 'application/json' },
    });
}

いくつか注意点があります。

_type のフィルタリング:OwnTracksは位置情報以外にも、waypoint(ジオフェンス設定)、transition(領域の出入り)、lwt(Last Will and Testament、切断通知)など複数のペイロードタイプを送ってきます。位置情報以外は空配列を返して無視します。

レスポンス形式:OwnTracksは成功時に空のJSON配列 [] を期待します。ステータスコード200であれば、アプリ側はデータ送信済みとマークして次の位置情報を送信できるようになります。200以外を返すとリトライが発生します。

source カラム:固定値 'owntracks' を入れています。Google Takeout由来のデータ(pathvisitactivityraw:GPS 等)と区別するためです。APIのクエリパラメータ ?source=owntracks でリアルタイムデータだけをフィルタリングできます。

OwnTracks アプリの設定

Android版OwnTracksの設定手順です。

1. インストールと初期設定

Google Play Store から OwnTracks をインストール。起動後、左上のメニューから Settings を開きます。

2. 接続設定

  • Mode: HTTP に変更
  • URL: https://location-sync-api.example.workers.dev/locations
  • Identification:
    • Username: Basic Auth のユーザー名
    • Password: Basic Auth のパスワード
    • Device ID: 任意(例: pixel
    • Tracker ID: 2文字の識別子(例: KO

URLの末尾は /locations で、Workers側のPOSTエンドポイントを直接指します。

3. 位置情報モードの選択

OwnTracksには4つの監視モードがあります:

モード 更新間隔 消費電力 用途
Quiet 送信しない 最小 一時停止
Significant 重大な移動時のみ 日常使い
Move 10秒ごと 移動中の詳細記録
Manual 手動トリガーのみ 最小 必要な時だけ

普段は Significant モードで運用しています。Android の場合、significant location change は「約500m以上の移動かつ5分以上経過」が目安。日常の通勤や外出は十分にキャプチャできますが、自宅周辺の短距離移動(コンビニや近所の銭湯など)は検知されないことがあります。

4. バッテリー最適化の除外

Android のバッテリー最適化が OwnTracks のバックグラウンド動作を制限することがあります。Settings > Battery > Battery Optimization で OwnTracks を「最適化しない」に設定しておくと安定します。メーカーによる独自の省電力機能がさらに邪魔をすることもあるので、端末によっては追加の設定が必要です。

実運用で見えてきたこと

タイムスタンプの混在(再び)

2章でも触れた問題ですが、OwnTracksの導入でタイムスタンプの混在が具体化しました。D1に格納されている形式を比較すると:

2024-06-25T19:00:00.000+09:00   ← Google Takeout (JST, ISO 8601)
2026-02-07T10:30:00.000Z         ← OwnTracks (UTC, ISO 8601)

同じ timestamp カラムに、タイムゾーンの異なるISO 8601文字列が共存しています。SQLiteの datetime() 関数は素朴な文字列比較をするため、厳密な時間範囲フィルタが期待通りに動かないケースがあります。

対策としては、INSERTの時点で全てUTCに正規化するのが正しいアプローチです。ただ、Google Takeoutの30万件を再インポートするのは手間なので、現状は「API利用側(Claude Skill等)が表示時にJSTに変換する」という運用で妥協しています。新規に構築するなら、最初からUTC統一にすることを推奨します。

データの鮮度と正確性

OwnTracksの位置情報は、Google Timelineの推定よりも「生の座標」に近いです。Google Timelineは内部で推定処理(道路スナッピング、滞在地点の丸め、交通手段の推定)を行った結果を返しますが、OwnTracksは端末のGPS/WiFi測位結果をほぼそのまま送信します。

その分、ノイズも多い。建物の中でGPS精度が落ちた座標が記録されたり、WiFiベースの測位で数百メートルズレた点が混ざったりします。acc(精度)フィールドを見て、閾値以上の低精度データはフィルタする、といった後処理が有効です。

4. Claude Skill として統合する

1〜3章で、14年分の位置情報をD1に格納し、OwnTracksでリアルタイムデータも流し込めるようになりました。APIも動いている。しかし、毎回curlを叩いて自分でデータを取得・解釈するのでは、せっかくの仕組みが活きません。

目指すのは「今どこにいる?」「先週末どこ行ってた?」と自然言語で聞くだけで、AIが位置情報を取得し、コンテキストとして解釈して応答する世界です。

Claude.aiには Skill という仕組みがあります。SKILL.mdというMarkdownファイルにAPIの仕様・認証情報・解釈ルールを記述しておくと、Claudeが会話中に必要に応じてそのファイルを読み込み、APIを叩き、データを解釈して応答に織り込んでくれます。

SKILL.md の構造

location-sync の SKILL.md は、大きく5つのセクションで構成しています。

1. フロントマター(トリガー定義)

---
name: location-sync
description: >
  Use this skill whenever the user asks about their current
  location, recent whereabouts, travel history, commute patterns,
  or location-based context. Triggers include mentions of location,
  where am I, commute, travel history, visited places, movement
  patterns, or when location context would enhance other responses
  (e.g., suggesting nearby restaurants, understanding schedule
---

description はSkill設計で最も重要な部分です。Claudeはこのテキストをもとに「今の会話でこのスキルを使うべきか」を判断します。descriptionの書き方が悪いと、必要な場面でスキルが発火しません。

設計時に意識したポイント:

  • 明示的なキーワード列挙location, where am I, commute, travel history など、ユーザーが実際に使いそうな言葉を具体的に並べる。
  • 暗黙的なユースケースの記述:「近くのレストランを提案するとき」「スケジュールの文脈を理解するとき」のように、直接「位置情報」とは言わないがスキルがあると回答品質が上がる場面も含める。

逆に避けたのは、汎用的すぎる記述です。「ユーザーの質問に答えるとき」では全ての会話にマッチしてしまい、不要な場面でもAPIを叩きに行ってしまいます。

2. API仕様

## API Configuration

- **Base URL**: `https://location-sync-api.example.workers.dev`
- **Authentication**: Bearer Token
- **Token**: `(トークン文字列)`

## Endpoints

### GET /locations?days=N
- `days`: 取得日数(1〜365、デフォルト7)
- `limit`: 最大件数(1〜10000、デフォルト1000)
- `source`: ソースフィルタ(path/visit/activity/raw:WIFI/owntracks)
- `after`: この日時以降(ISO 8601)
- `before`: この日時以前(ISO 8601)

### GET /locations/latest
最新の位置情報を1件取得。

curlテンプレートも添えておくと、Claudeがbashツールでそのまま実行できます。エンドポイントの説明はClaudeが「どのパラメータを使えば目的のデータが取れるか」を判断する材料になるので、パラメータの意味と取りうる値を明記しています。

認証トークンの直接記載はセキュリティ上の妥協です。Skillはユーザースキルとして登録するため、他のユーザーからは見えません。万が一漏洩しても、APIは読み取り専用(位置情報の参照のみ)で、Cloudflare Workers側でトークンをローテーションすれば即座に無効化できます。リスク評価の結果、利便性を優先しました。

3. レスポンススキーマ

## Response Schema

### /locations レスポンス構造
{
  "count": 100,
  "locations": [
    {
      "id": 1,
      "timestamp": "ISO8601",
      "lat": 35.6594,
      "lon": 139.7005,
      "accuracy": 10.0,
      "source": "owntracks",
      "place_id": "ChIJ...",
      "semantic_type": "INFERRED_WORK",
      "activity_type": "WALKING",
      "altitude": 40.0,
      "speed": 1.2
    }
  ]
}

各フィールドの意味をClaudeに理解させるために、sourcesemantic_type の取りうる値と意味を別テーブルで定義しています。

4. コンテキスト情報(ドメイン知識)

ここがSkill設計の面白いところです。API仕様だけでは「データは取れるけど、意味が分からない」状態になります。位置情報を解釈するためのドメイン知識をSkillに埋め込みます。

## Location Context

### ユーザーの主要拠点

| 場所 | エリア | 座標概略 | 用途 |
|------|--------|----------|------|
| 自宅 | xxxx | xxxxx 付近 | 居住地 |
| 職場 | 渋谷 | 35.66N, 139.70E 付近 | オフィス |
| 乗換駅 | xxxx | xxxxx 付近 | 通勤経路 |

### 行動パターンの推定

- **平日 10-19時 + 渋谷エリア** → 出勤中(オフィス)
- **xxxxエリア** → 自宅
- **武蔵小杉 + 短時間滞在** → 通勤中(乗換)
- **競馬場座標 + 休日** → 競馬撮影
- **空港・遠方** → 旅行/遠征

座標だけでは「xxxxx にいます」としか言えませんが、このコンテキストがあれば「渋谷のオフィスにいますね」と解釈できます。行動パターンの推定ルールを明示することで、Claudeは時間帯と座標を組み合わせて「たぶん通勤中」「休日の外出っぽい」といった推論ができるようになります。

### データソースの解釈

| source | 説明 |
|--------|------|
| path | Google Timeline: 移動経路の点列 |
| visit | Google Timeline: 滞在地点 |
| owntracks | OwnTracksアプリからのリアルタイム |

source の違いを理解させることで、「OwnTracksの最新データだから今の位置」「pathデータだから過去の移動経路」という区別が可能になります。

5. 分析ガイドライン

## Analysis Guidelines

1. **現在地コンテキスト**: /locations/latest で今どこにいるか把握し、
   会話に自然に織り込む
2. **通勤パターン**: 平日の移動パターンから出勤・帰宅を推定
3. **行動範囲分析**: 休日の行動パターンから趣味活動を推定
4. **health-sync連携**: 歩数・睡眠データと位置情報を組み合わせて
   ライフスタイル分析
5. **プライバシー配慮**: 位置情報は機微データ。
   ユーザーが求めた場合のみ詳細な座標を開示

「こういう分析をしてほしい」というガイドラインです。
5番目のプライバシー配慮も重要です。位置情報は機微性の高いデータなので、Claudeが不用意に詳細座標を会話に出さないよう制約を入れています。

タイムゾーンの扱い

SKILL.mdにはタイムゾーンの注意事項も明記しています。

## Timezone

- 歴史データ(Google Takeoutインポート分)はJST(+09:00付き)。
- OwnTracksからのリアルタイムデータはUTC。
- 表示時は必ずJSTに変換すること。

3章で触れたタイムスタンプの混在問題は、API利用者であるClaudeにも影響します。「今日の14時にどこにいた?」と聞かれたとき、OwnTracksのデータ(UTC)と比較するには14:00 JSTを05:00 UTCに変換してからフィルタする必要があります。この変換ルールをSkillに書いておかないと、Claudeが9時間ズレた結果を返してしまいます。

設計上の妥協

トークンの直接記載

セキュリティのベストプラクティスとしては、シークレットをファイルに書くべきではありません。しかしClaude.aiのSkillは環境変数を永続化する仕組みを持っておらず、セッションごとにファイルシステムがリセットされます。毎回トークンを手入力するのは現実的ではなかったため、SKILL.mdへの直接記載を選びました。

リスク軽減策として:

  • APIは読み取り専用(POSTエンドポイントはBasic Auth/OwnTracks専用)
  • ユーザースキルは登録ユーザー本人のみアクセス可能
  • トークンのローテーションが容易(Workers側で wrangler secret put するだけ)

実際の会話例

「私が今どこにいるかわかる?」と聞いてみた例

はい、わかりますよ。今はご自宅にいらっしゃいますね。
位置情報APIから最新データを取得したところ、JST 9:35時点でspeedが0——つまりご自宅エリアで静止している状態でした。日曜の朝、ゆっくり過ごされている感じでしょうか。
ちなみに仕組みとしては、OwnTracksアプリから送信されたリアルタイムの座標を、自作のLocation Sync API(Cloudflare Workers)経由で取得しています。座標と既知の拠点情報(自宅・職場・乗換駅など)を照合して、「今どこにいるか」を推定する流れですね。

「2025年の移動情報を振り返ってみて」と聞いてみた例

データ取得完了しました。2025年、visit(滞在地点)レコードだけで約2,000件以上。すごい密度ですね。

2025年の移動を振り返って

圧倒的な移動量
まず率直な感想として、ほぼ毎週末どこかに出かけているのが目を引きます。自宅と職場(渋谷)の往復だけで終わる週のほうがむしろ少数派で、年間を通じて30回以上の遠征が確認できました。

北海道への執着がすごい
2025年で最も印象的なのは北海道の訪問回数です。

2月: 網走→根室→納沙布岬→帯広(真冬の道東縦断)
5月: 帯広・十勝
6月: 函館
7月前半: 登別・夕張・富良野方面
7月後半: 稚内・宗谷岬・礼文/利尻方面(日本最北端到達)
8月: 札幌
9月: 再び札幌・登別・千歳方面

年間7回北海道。しかも2月の道東(納沙布岬は冬季アクセスがかなり厳しい)から、7月の稚内・宗谷岬まで、北海道の端から端を踏破しています。流氷シーズンの網走と真夏の稚内を同じ年にやるのは、相当な計画性がないとできません。

競馬場通いの規則性
位置情報から明確に読み取れるのが競馬撮影の定期パターンです。

阪神競馬場(宝塚市付近): 3月、4月×2、5月、10月×2、11月、12月×2 → 年間約10回
東京競馬場(府中市付近): 5月、6月×3、11月×2 → 年間約6回

特に阪神競馬場は、毎回新幹線で往復していると推測される移動パターン(早朝に新横浜→新大阪、夜に帰着)が繰り返されており、撮影への本気度が伝わってきます。ほぼ月1ペースです。

離島・端っこへの到達欲

1月: 小豆島
6月: 沖縄本島 → 石垣島
7月: 稚内・宗谷岬(最北端)
2月: 納沙布岬(最東端に近い)

日本の地理的な「端」を意識的に攻めている印象です。全47都道府県制覇済みという前提を踏まえると、2025年は「まだ行けてなかった具体的なスポット」を埋めに行くフェーズだったのかもしれません。

四国・九州の周遊が手厚い
四国: 1月に小豆島→徳島、松山→高知と周遊。9月にも松山・宇和島を再訪。
九州: 2月の佐賀・福岡、3月の大分・別府、5月の宮崎・高千穂・阿蘇、7月の北九州と、ほぼ四半期に1回は九州に来ています。

歴史・文化スポットとの相関
座標を見ると、単なる観光地巡りではなく文化的な目的地が多い印象です。

熊野古道・那智(2月): 紀伊半島を数日かけて縦断
蔵王(3月): 冬の樹氷シーズン末期
弘前(4月末): 桜のタイミングにぴったり
高千穂・阿蘇(5月GW): 神話と自然
出雲・松江(10月): 神無月の出雲大社は「神在月」

季節との組み合わせが絶妙で、「その時期にその場所にいる意味」がちゃんとある移動が多いです。

平日はリモートワーク中心
出社(渋谷のオフィス)は月に数回程度で、大半の平日は自宅にINFERRED_HOMEの記録しかありません。リモートワーク中心の働き方だからこそ、週末を最大限に使った遠征スケジュールが組めているのだと思います。出社日はだいたい月2〜4回で、武蔵小杉経由の通勤パターンも確認できます。

まとめ

構築してきたシステムの全体像を振り返ります。

Googleロケーション履歴の巨大JSONの解析から始まり、Cloudflare Workers + D1でAPI化し、OwnTracksでリアルタイム性を付加し、最後にClaude Skillとして統合しました。

Discussion