feat(gps-tracks): ET-008 публичные GPS-треки с публичных платформ
Some checks failed
CI / lint (push) Failing after 4s
CI / test (push) Failing after 4s
CI / build (push) Has been skipped
CI / lint (pull_request) Failing after 4s
CI / test (pull_request) Failing after 4s
CI / build (pull_request) Has been skipped

Backend:
- Миграция gps_tracks_001_init.sql: таблицы tracks + pipeline_runs
- Пакет src/api/gps_tracks/: models, db (WAL+upsert с dedup), dedup
  (bbox+length+date bucket-hash), mvt (LRU-кэш 1024 тайла), endpoint
  (GET /api/gps-tracks, GET /api/gps-tracks/tiles/{z}/{x}/{y}.mvt,
   GET /api/gps-tracks/health, POST /api/gps-tracks/cache/clear), config
- Парсеры: osm (split_bbox, haversine, defusedxml XXE-защита),
  enduro_russia + ttrails — заглушки (ADR-010/011 proposed, блокированы)
- Licensing guard: pipeline проверяет status ADR-файла до запуска источника
- scripts/gps_collect.py: CLI с --region/--source/--dry-run/--gc

Frontend:
- src/web/gps_tracks.js: двухрежимный слой (MVT z≤11, GeoJSON z≥12),
  debounced fetch + AbortController, фильтры активности/источника,
  цветовая палитра by-source/by-activity, halo на спутнике, popup трека,
  restorePublicTracksState(), localStorage persistence
- index.html: чекбокс «Публичные треки» в terrain-popup, #sheet-gps-filters
- app.css: .terrain-link-btn, .gps-filter-grid, .track-popup
- app.js: вызов restorePublicTracksState() в rebuildMapOverlays(),
  applyGpsHaloVisibility() в applyBaseLayer()

Конфиги:
- config/gps_sources.yaml: osm (enabled), enduro_russia/ttrails (disabled)
- config/gps_regions.yaml: ЦФО+Чувашия (enabled), Кавказ (disabled)

Docker:
- gps-collector service с profiles: [batch]

Тесты: 48 новых тестов (unit + integration), 125/125 pass

Refs: ET-008

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-01 12:28:54 +00:00
parent a0284e046b
commit 0060003f28
30 changed files with 3300 additions and 0 deletions

View File

View File

@@ -0,0 +1,89 @@
"""Загрузка и валидация конфигурации GPS-источников и регионов (ET-008)."""
import yaml
def load_sources_config(path: str) -> list:
"""Загружает конфигурацию источников GPS-треков.
Args:
path: путь к YAML-файлу конфигурации источников
Returns:
list[dict] — список источников
Raises:
ValueError: при ошибках валидации
FileNotFoundError: если файл не найден
"""
with open(path, "r", encoding="utf-8") as f:
data = yaml.safe_load(f)
sources = data.get("sources", [])
if not isinstance(sources, list):
raise ValueError("sources must be a list")
for src in sources:
if not src.get("id"):
raise ValueError(f"Source missing 'id': {src}")
if not src.get("base_url"):
raise ValueError(f"Source '{src['id']}' missing 'base_url'")
# Enabled source must have license_adr
if src.get("enabled", False):
if not src.get("license_adr"):
raise ValueError(
f"Enabled source '{src['id']}' must have 'license_adr'"
)
return sources
def load_regions_config(path: str) -> list:
"""Загружает конфигурацию регионов для сбора GPS-треков.
Args:
path: путь к YAML-файлу конфигурации регионов
Returns:
list[dict] — список регионов
Raises:
ValueError: при ошибках валидации
FileNotFoundError: если файл не найден
"""
with open(path, "r", encoding="utf-8") as f:
data = yaml.safe_load(f)
regions = data.get("regions", [])
if not isinstance(regions, list):
raise ValueError("regions must be a list")
for reg in regions:
if not reg.get("id"):
raise ValueError(f"Region missing 'id': {reg}")
bbox = reg.get("bbox")
if not bbox or len(bbox) != 4:
raise ValueError(f"Region '{reg['id']}' must have bbox with 4 values")
west, south, east, north = bbox
# Валидация диапазонов координат
if not (-180 <= west <= 180):
raise ValueError(f"Region '{reg['id']}' bbox west={west} out of range")
if not (-180 <= east <= 180):
raise ValueError(f"Region '{reg['id']}' bbox east={east} out of range")
if not (-90 <= south <= 90):
raise ValueError(f"Region '{reg['id']}' bbox south={south} out of range")
if not (-90 <= north <= 90):
raise ValueError(f"Region '{reg['id']}' bbox north={north} out of range")
if west >= east:
raise ValueError(
f"Region '{reg['id']}' bbox: west must be < east"
)
if south >= north:
raise ValueError(
f"Region '{reg['id']}' bbox: south must be < north"
)
return regions

232
src/api/gps_tracks/db.py Normal file
View File

@@ -0,0 +1,232 @@
"""Функции работы с БД для GPS-треков (ET-008)."""
import json
import os
import sqlite3
from datetime import datetime, timezone
from typing import Optional
from src.api.gps_tracks.models import TrackInsert
_MIGRATION_PATH = os.path.join(
os.path.dirname(__file__), "../../../migrations/gps_tracks_001_init.sql"
)
def open_db(db_path: str) -> sqlite3.Connection:
"""Открывает соединение с SQLite БД."""
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
return conn
def init_db(conn: sqlite3.Connection) -> None:
"""Применяет миграцию SQL для создания схемы."""
migration_path = os.path.abspath(_MIGRATION_PATH)
with open(migration_path, "r", encoding="utf-8") as f:
sql = f.read()
# Выполняем каждый statement отдельно (executescript не поддерживает параметры,
# но зато не требует явного commit)
conn.executescript(sql)
conn.commit()
def _now_iso() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def upsert_track(
conn: sqlite3.Connection,
track: TrackInsert,
dedup_key: str,
source_priority: int,
) -> str:
"""Вставляет или обновляет трек в БД.
При коллизии dedup_key:
- UNION sources (без дублей)
- UNION external_urls (без дублей)
- Метаданные обновляются если новый source_priority < существующего
Returns:
"inserted" или "updated"
"""
cur = conn.cursor()
now = _now_iso()
# Проверяем существующую запись
cur.execute(
"SELECT id, sources_json, external_urls_json, name, description, activity_type, "
"user, created_at, source_priority FROM tracks WHERE dedup_key = ?",
(dedup_key,),
)
existing = cur.fetchone()
if existing is None:
# INSERT новой записи
sources = [track.source_id]
ext_urls = [track.external_url] if track.external_url else []
cur.execute(
"""
INSERT INTO tracks (
dedup_key, name, description, activity_type, user, created_at,
length_m, points_count, min_lon, min_lat, max_lon, max_lat,
geom, sources_json, external_urls_json, tags_json,
inserted_at, updated_at, source_priority
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
dedup_key,
track.name,
track.description,
track.activity_type,
track.user,
track.created_at,
track.length_m,
track.points_count,
track.min_lon,
track.min_lat,
track.max_lon,
track.max_lat,
track.geom_wkb,
json.dumps(sources),
json.dumps(ext_urls),
json.dumps(track.tags) if track.tags else json.dumps([]),
now,
now,
source_priority,
),
)
conn.commit()
return "inserted"
else:
# UPDATE: мержим sources и external_urls
existing_sources = json.loads(existing["sources_json"] or "[]")
existing_urls = json.loads(existing["external_urls_json"] or "[]")
# Union без дублей, сохраняя порядок
merged_sources = list(dict.fromkeys(existing_sources + [track.source_id]))
new_urls = [track.external_url] if track.external_url else []
merged_urls = list(dict.fromkeys(existing_urls + new_urls))
# Получаем текущий source_priority (может отсутствовать в старых записях)
existing_priority = existing["source_priority"] if "source_priority" in existing.keys() else 999
# Обновляем метаданные только если новый источник имеет более высокий приоритет
if source_priority < existing_priority:
cur.execute(
"""
UPDATE tracks SET
name = ?,
description = ?,
activity_type = ?,
user = ?,
created_at = ?,
sources_json = ?,
external_urls_json = ?,
updated_at = ?,
source_priority = ?
WHERE dedup_key = ?
""",
(
track.name,
track.description,
track.activity_type,
track.user,
track.created_at,
json.dumps(merged_sources),
json.dumps(merged_urls),
now,
source_priority,
dedup_key,
),
)
else:
# Только обновляем sources/urls и updated_at
cur.execute(
"""
UPDATE tracks SET
sources_json = ?,
external_urls_json = ?,
updated_at = ?
WHERE dedup_key = ?
""",
(
json.dumps(merged_sources),
json.dumps(merged_urls),
now,
dedup_key,
),
)
conn.commit()
return "updated"
def get_tracks_in_bbox(
conn: sqlite3.Connection,
west: float,
south: float,
east: float,
north: float,
activities: Optional[list] = None,
sources: Optional[list] = None,
limit: int = 500,
) -> tuple:
"""Возвращает треки в указанном bbox.
Returns:
(tracks: list[sqlite3.Row], total_count: int)
"""
cur = conn.cursor()
# Базовое условие bbox
conditions = [
"min_lon <= :east",
"max_lon >= :west",
"min_lat <= :north",
"max_lat >= :south",
]
params: dict = {"west": west, "south": south, "east": east, "north": north}
# Фильтр по activity_type
if activities:
placeholders = ",".join(f":act{i}" for i in range(len(activities)))
conditions.append(f"activity_type IN ({placeholders})")
for i, act in enumerate(activities):
params[f"act{i}"] = act
where_clause = " AND ".join(conditions)
# Подсчёт общего числа (без фильтра по source, он применяется постфактум)
count_sql = f"SELECT COUNT(*) as cnt FROM tracks WHERE {where_clause}"
cur.execute(count_sql, params)
total_count = cur.fetchone()["cnt"]
# Основной запрос
select_sql = f"""
SELECT id, dedup_key, name, description, activity_type, user,
created_at, length_m, points_count,
min_lon, min_lat, max_lon, max_lat,
sources_json, external_urls_json, tags_json,
inserted_at, updated_at, geom
FROM tracks
WHERE {where_clause}
LIMIT :limit
"""
params["limit"] = limit
cur.execute(select_sql, params)
rows = cur.fetchall()
# Постфильтрация по sources (если задан)
if sources:
filtered = []
for row in rows:
row_sources = json.loads(row["sources_json"] or "[]")
if any(s in row_sources for s in sources):
filtered.append(row)
rows = filtered
return rows, total_count

View File

@@ -0,0 +1,32 @@
"""Функции дедупликации GPS-треков (ET-008)."""
def compute_dedup_key(geom_bounds: tuple, metadata: dict) -> str:
"""Вычисляет ключ дедупликации для трека.
Args:
geom_bounds: (min_lon, min_lat, max_lon, max_lat)
metadata: dict с полями length_m и created_at
Returns:
Строка вида "{bbox_round}|{length_bucket}|{date_bucket}"
"""
min_lon, min_lat, max_lon, max_lat = geom_bounds
# Округление bbox до 2 знаков после запятой
bbox_round = (
round(min_lon, 2),
round(min_lat, 2),
round(max_lon, 2),
round(max_lat, 2),
)
# Длина в бакетах по 1 км
length_m = metadata.get("length_m", 0) or 0
length_bucket = round(length_m / 1000) * 1000
# Дата: первые 10 символов (YYYY-MM-DD) или пустая строка
created_at = metadata.get("created_at") or ""
date_bucket = created_at[:10] if created_at else ""
return f"{bbox_round}|{length_bucket}|{date_bucket}"

View File

@@ -0,0 +1,240 @@
"""FastAPI router для GPS-треков (ET-008)."""
import json
from typing import Optional
from fastapi import APIRouter, HTTPException, Query, Response
from src.api.gps_tracks.db import get_tracks_in_bbox, init_db, open_db
from src.api.gps_tracks.mvt import (
build_gps_mvt,
clear_gps_tile_cache,
get_gps_cached_tile,
set_gps_cached_tile,
_tile_to_bbox,
)
def _parse_bbox(bbox_str: str) -> tuple:
"""Парсит и валидирует bbox строку 'west,south,east,north'.
Returns:
(west, south, east, north)
Raises:
HTTPException 400 при невалидных значениях
"""
try:
parts = [float(v.strip()) for v in bbox_str.split(",")]
except (ValueError, AttributeError):
raise HTTPException(400, "bbox must be 4 comma-separated floats")
if len(parts) != 4:
raise HTTPException(400, "bbox must have exactly 4 values: west,south,east,north")
west, south, east, north = parts
if not (-180 <= west <= 180) or not (-180 <= east <= 180):
raise HTTPException(400, "bbox longitude values must be in range -180..180")
if not (-90 <= south <= 90) or not (-90 <= north <= 90):
raise HTTPException(400, "bbox latitude values must be in range -90..90")
if west >= east:
raise HTTPException(400, "bbox west must be < east")
if south >= north:
raise HTTPException(400, "bbox south must be < north")
return west, south, east, north
def _row_to_geojson_feature(row) -> dict:
"""Конвертирует sqlite3.Row в GeoJSON Feature."""
from src.api.gps_tracks.mvt import _wkb_to_coords
coords = _wkb_to_coords(row["geom"])
sources = json.loads(row["sources_json"] or "[]")
ext_urls = json.loads(row["external_urls_json"] or "[]")
tags = json.loads(row["tags_json"] or "[]")
geometry = None
if coords:
geometry = {"type": "LineString", "coordinates": coords}
return {
"type": "Feature",
"geometry": geometry,
"properties": {
"id": row["id"],
"dedup_key": row["dedup_key"],
"name": row["name"],
"description": row["description"],
"activity_type": row["activity_type"],
"user": row["user"],
"created_at": row["created_at"],
"length_m": row["length_m"],
"points_count": row["points_count"],
"sources": sources,
"external_urls": ext_urls,
"tags": tags,
"inserted_at": row["inserted_at"],
"updated_at": row["updated_at"],
},
}
def create_gps_router(db_path: str) -> APIRouter:
"""Создаёт FastAPI router для GPS-треков.
Args:
db_path: путь к SQLite БД для GPS-треков
Returns:
APIRouter с prefix="/api/gps-tracks"
"""
router = APIRouter(prefix="/api/gps-tracks", tags=["gps-tracks"])
def _get_conn():
conn = open_db(db_path)
init_db(conn)
return conn
@router.get("")
async def get_tracks(
bbox: str = Query(..., description="west,south,east,north"),
activity: Optional[str] = Query(None, description="Comma-separated activity types"),
source: Optional[str] = Query(None, description="Comma-separated source ids"),
limit: int = Query(500, ge=1, le=2000),
):
"""Возвращает GPS-треки в bbox как GeoJSON FeatureCollection."""
west, south, east, north = _parse_bbox(bbox)
activities = [a.strip() for a in activity.split(",")] if activity else None
sources = [s.strip() for s in source.split(",")] if source else None
try:
conn = _get_conn()
rows, total_count = get_tracks_in_bbox(
conn, west, south, east, north,
activities=activities,
sources=sources,
limit=limit,
)
conn.close()
except Exception as exc:
raise HTTPException(500, f"DB error: {exc}")
features = [_row_to_geojson_feature(row) for row in rows]
returned = len(features)
return {
"type": "FeatureCollection",
"features": features,
"total_in_bbox": total_count,
"returned": returned,
"truncated": total_count > returned,
}
@router.get("/tiles/{z}/{x}/{y}.mvt")
async def get_gps_tile(z: int, x: int, y: int):
"""Возвращает MVT тайл с GPS-треками."""
if z < 0 or z > 22:
raise HTTPException(400, "Invalid z")
max_coord = 2 ** z
if x < 0 or x >= max_coord or y < 0 or y >= max_coord:
raise HTTPException(400, "Invalid x/y for zoom level")
# Проверяем кэш
cached = get_gps_cached_tile(z, x, y)
if cached is not None:
return Response(
content=cached,
media_type="application/x-protobuf",
headers={
"Content-Encoding": "identity",
"Access-Control-Allow-Origin": "*",
"X-Cache": "HIT",
},
)
west, south, east, north = _tile_to_bbox(z, x, y)
# Небольшой буфер для edge features
buf_x = (east - west) * 0.1
buf_y = (north - south) * 0.1
try:
conn = _get_conn()
rows, _ = get_tracks_in_bbox(
conn,
west - buf_x,
south - buf_y,
east + buf_x,
north + buf_y,
limit=25000,
)
conn.close()
except Exception as exc:
raise HTTPException(500, f"DB error: {exc}")
mvt = build_gps_mvt(rows, z, x, y)
if mvt:
set_gps_cached_tile(z, x, y, mvt)
return Response(
content=mvt,
media_type="application/x-protobuf",
headers={
"Content-Encoding": "identity",
"Access-Control-Allow-Origin": "*",
"X-Cache": "MISS",
},
)
@router.get("/health")
async def gps_health():
"""Статистика GPS-треков БД."""
try:
conn = _get_conn()
cur = conn.cursor()
cur.execute("SELECT COUNT(*) as cnt FROM tracks")
total_tracks = cur.fetchone()["cnt"]
cur.execute(
"SELECT activity_type, COUNT(*) as cnt FROM tracks GROUP BY activity_type"
)
by_activity = {row["activity_type"] or "other": row["cnt"] for row in cur.fetchall()}
cur.execute(
"""
SELECT id, started_at, finished_at, region_id, source_id,
status, tracks_new, tracks_updated
FROM pipeline_runs
ORDER BY started_at DESC
LIMIT 10
"""
)
recent_runs = [dict(row) for row in cur.fetchall()]
conn.close()
except Exception as exc:
raise HTTPException(500, f"DB error: {exc}")
return {
"status": "ok",
"db_path": db_path,
"total_tracks": total_tracks,
"by_activity": by_activity,
"recent_pipeline_runs": recent_runs,
}
@router.post("/cache/clear")
async def clear_cache():
"""Сбрасывает LRU-кэш GPS-тайлов."""
clear_gps_tile_cache()
return {"status": "ok", "cleared": True}
return router

View File

@@ -0,0 +1,52 @@
"""Pydantic-модели и константы для публичных GPS-треков (ET-008)."""
from pydantic import BaseModel
from typing import Optional, List
ACTIVITY_TYPES = [
"enduro", "moto", "offroad", "bicycle", "hike", "ski", "other"
]
class TrackRecord(BaseModel):
"""Трек из БД, готовый к отдаче через API."""
id: int
dedup_key: str
name: Optional[str] = None
description: Optional[str] = None
activity_type: Optional[str] = "other"
user: Optional[str] = None
created_at: Optional[str] = None
length_m: float
points_count: int
min_lon: float
min_lat: float
max_lon: float
max_lat: float
sources: List[str]
external_urls: List[str]
tags: List[str]
inserted_at: str
updated_at: str
class TrackInsert(BaseModel):
"""Трек для вставки в БД (из парсера)."""
external_id: str
source_id: str
external_url: Optional[str] = None
name: Optional[str] = None
description: Optional[str] = None
activity_type: str = "other"
user: Optional[str] = None
created_at: Optional[str] = None
length_m: float
points_count: int
geom_wkb: bytes # WKB bytes
min_lon: float
min_lat: float
max_lon: float
max_lat: float
tags: List[str] = []
source_priority: int = 999

167
src/api/gps_tracks/mvt.py Normal file
View File

@@ -0,0 +1,167 @@
"""MVT-тайлы для GPS-треков (ET-008)."""
import json
import math
import struct
from typing import Optional
from shapely.geometry import LineString
# ─── LRU-like tile cache ─────────────────────────────────────────────────────
_gps_tile_cache: dict = {}
_GPS_TILE_CACHE_MAX = 1024
def get_gps_cached_tile(z: int, x: int, y: int) -> Optional[bytes]:
return _gps_tile_cache.get((z, x, y))
def set_gps_cached_tile(z: int, x: int, y: int, data: bytes) -> None:
if len(_gps_tile_cache) >= _GPS_TILE_CACHE_MAX:
# FIFO вытеснение
_gps_tile_cache.pop(next(iter(_gps_tile_cache)))
_gps_tile_cache[(z, x, y)] = data
def clear_gps_tile_cache() -> None:
_gps_tile_cache.clear()
# ─── Geometry helpers ────────────────────────────────────────────────────────
def _simplify_coords(coords: list, z: int) -> list:
"""Упрощает геометрию трека по зуму через Douglas-Peucker."""
if z >= 12:
return coords
elif z >= 10:
tolerance = 0.0005 # ~50м
elif z >= 8:
tolerance = 0.002 # ~200м
else:
tolerance = 0.008 # ~800м на z7 и ниже
if len(coords) < 3:
return coords
line = LineString(coords)
simplified = line.simplify(tolerance, preserve_topology=False)
result = list(simplified.coords)
return result if len(result) >= 2 else coords
def _wkb_to_coords(blob: bytes) -> Optional[list]:
"""Парсит WKB LineString, возвращает [(lon, lat), ...]."""
try:
b = bytes(blob)
if len(b) < 9:
return None
endian = "<" if b[0] == 1 else ">"
gtype = struct.unpack_from(endian + "I", b, 1)[0]
base_type = gtype & 0xFF
if base_type != 2:
return None
offset = 5
if gtype & 0x20000000:
offset += 4
npts = struct.unpack_from(endian + "I", b, offset)[0]
offset += 4
coords = []
for _ in range(npts):
lon, lat = struct.unpack_from(endian + "dd", b, offset)
offset += 16
coords.append((lon, lat))
return coords if len(coords) >= 2 else None
except Exception:
return None
def _tile_to_bbox(z: int, x: int, y: int) -> tuple:
n = 2 ** z
west = x / n * 360.0 - 180.0
east = (x + 1) / n * 360.0 - 180.0
north = math.degrees(math.atan(math.sinh(math.pi * (1 - 2 * y / n))))
south = math.degrees(math.atan(math.sinh(math.pi * (1 - 2 * (y + 1) / n))))
return west, south, east, north
# ─── MVT builder ─────────────────────────────────────────────────────────────
def build_gps_mvt(rows: list, z: int, x: int, y: int) -> bytes:
"""Собирает MVT тайл с layer 'gps_tracks'.
Args:
rows: список sqlite3.Row из таблицы tracks
z, x, y: координаты тайла
Returns:
bytes — protobuf MVT или b"" если нет фич
"""
import mapbox_vector_tile
west, south, east, north = _tile_to_bbox(z, x, y)
# Min-length фильтр по зуму
if z <= 7:
min_length_m = 2000
limit = 3000
elif z <= 9:
min_length_m = 0
limit = 8000
elif z <= 11:
min_length_m = 0
limit = 15000
else:
min_length_m = 0
limit = 25000
features = []
for row in rows:
length_m = row["length_m"] or 0
# Min-length фильтр
if min_length_m > 0 and length_m < min_length_m:
continue
if len(features) >= limit:
break
coords = _wkb_to_coords(row["geom"])
if not coords:
continue
coords = _simplify_coords(coords, z)
try:
sources_list = json.loads(row["sources_json"] or "[]")
sources_str = ",".join(sources_list)
first_source = sources_list[0] if sources_list else ""
ext_urls = json.loads(row["external_urls_json"] or "[]")
ext_url = ext_urls[0] if ext_urls else ""
props = {
"id": row["id"],
"activity": row["activity_type"] or "other",
"source": first_source,
"sources": sources_str,
"length_km": round(length_m / 1000, 2),
"name": row["name"] or "",
"ext_url": ext_url,
}
features.append({
"geometry": {"type": "LineString", "coordinates": coords},
"properties": props,
})
except Exception:
continue
if not features:
return b""
return mapbox_vector_tile.encode(
[{"name": "gps_tracks", "features": features}],
quantize_bounds=(west, south, east, north),
extents=4096,
default_options={"y_coord_down": False},
)

View File

View File

@@ -0,0 +1,34 @@
"""Базовый класс для парсеров GPS-источников (ET-008)."""
from src.api.gps_tracks.models import ACTIVITY_TYPES
class SourceParser:
"""Базовый класс для всех парсеров GPS-источников."""
MAPPING: dict = {} # source-category → ACTIVITY_TYPE
def __init__(self, source_config: dict):
self.config = source_config
def map_activity(self, raw_category: str) -> str:
"""Маппит категорию источника в ACTIVITY_TYPES enum."""
if not raw_category:
return "other"
mapped = self.MAPPING.get(raw_category.lower(), "other")
if mapped not in ACTIVITY_TYPES:
return "other"
return mapped
async def collect(self, bbox: tuple, ctx: dict):
"""Асинхронный генератор треков. Реализуется в наследниках.
Args:
bbox: (west, south, east, north)
ctx: контекст выполнения (db conn, logger, etc.)
Yields:
TrackInsert объекты
"""
raise NotImplementedError
return
yield # make it a generator

View File

@@ -0,0 +1,17 @@
"""Парсер EnduroRussia.ru — заглушка (ADR-010 status=proposed)."""
from src.api.gps_tracks.sources.base import SourceParser
class EnduroRussiaParser(SourceParser):
"""Парсер EnduroRussia.ru.
Заблокирован до получения лицензии. См. ADR-010.
"""
MAPPING = {"enduro": "enduro", "мото": "moto"}
async def collect(self, bbox, ctx):
# ADR-010: blocked, status=proposed
raise NotImplementedError("EnduroRussia parser not yet licensed (ADR-010)")
return
yield # make it a generator

View File

@@ -0,0 +1,309 @@
"""Парсер OSM Public GPS Traces (ET-008)."""
import asyncio
import math
import logging
from typing import AsyncGenerator
import defusedxml.ElementTree as ET
import httpx
from src.api.gps_tracks.models import TrackInsert
from src.api.gps_tracks.sources.base import SourceParser
logger = logging.getLogger(__name__)
# Пространства имён GPX
_GPX_NS = {
"gpx0": "http://www.topografix.com/GPX/1/0",
"gpx1": "http://www.topografix.com/GPX/1/1",
}
class OsmParser(SourceParser):
"""Парсер OSM Public GPS Traces API."""
MAPPING = {
"enduro": "enduro",
"moto": "moto",
"motorcycle": "moto",
"mtb": "bicycle",
"bicycle": "bicycle",
"bike": "bicycle",
"hike": "hike",
"hiking": "hike",
"running": "hike",
"ski": "ski",
"skiing": "ski",
"offroad": "offroad",
"4x4": "offroad",
}
async def collect(self, bbox: tuple, ctx: dict) -> AsyncGenerator[TrackInsert, None]:
"""Собирает треки из OSM Public GPS Traces API.
Args:
bbox: (west, south, east, north)
ctx: контекст (может содержать 'dry_run', 'session')
Yields:
TrackInsert объекты
"""
west, south, east, north = bbox
rate_limit = self.config.get("rate_limit_sec", 1)
base_url = self.config.get("base_url", "https://api.openstreetmap.org/api/0.6")
user_agent = self.config.get("user_agent", "enduro-trails/1.0")
source_id = self.config.get("id", "osm")
ext_url_template = self.config.get("external_url_template", "")
headers = {"User-Agent": user_agent}
# Разбиваем bbox на ячейки 0.25°
cells = split_bbox_for_osm((west, south, east, north))
async with httpx.AsyncClient(timeout=30, headers=headers) as client:
for cell_bbox in cells:
cell_west, cell_south, cell_east, cell_north = cell_bbox
page = 0
while True:
url = (
f"{base_url}/trackpoints"
f"?bbox={cell_west},{cell_south},{cell_east},{cell_north}"
f"&page={page}"
)
try:
resp = await _fetch_with_backoff(client, url)
if resp is None:
break
if resp.status_code == 204:
break
if resp.status_code != 200:
logger.warning("OSM API returned %d for %s", resp.status_code, url)
break
content = resp.content
except Exception as exc:
logger.error("Error fetching %s: %s", url, exc)
break
# Парсим GPX ответ
tracks = _parse_gpx_trackpoints(content, source_id, ext_url_template)
if not tracks:
break # Пустая страница — больше треков нет
for track in tracks:
yield track
page += 1
await asyncio.sleep(rate_limit)
def split_bbox_for_osm(region_bbox: tuple, cell_size: float = 0.25) -> list:
"""Разбивает регион на ячейки cell_size градусов для OSM API.
OSM API требует bbox не более 0.25° x 0.25°.
Args:
region_bbox: (west, south, east, north)
cell_size: размер ячейки в градусах (по умолчанию 0.25)
Returns:
list of (west, south, east, north) tuples
"""
west, south, east, north = region_bbox
cells = []
# Перебираем ячейки с запада на восток, с юга на север
lat = south
while lat < north:
cell_south = lat
cell_north = min(lat + cell_size, north)
lon = west
while lon < east:
cell_west = lon
cell_east = min(lon + cell_size, east)
cells.append((
round(cell_west, 6),
round(cell_south, 6),
round(cell_east, 6),
round(cell_north, 6),
))
lon += cell_size
lat += cell_size
return cells
def _haversine_m(lon1: float, lat1: float, lon2: float, lat2: float) -> float:
"""Расстояние между двумя точками в метрах."""
R = 6371000
phi1, phi2 = math.radians(lat1), math.radians(lat2)
dphi = math.radians(lat2 - lat1)
dlam = math.radians(lon2 - lon1)
a = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlam / 2) ** 2
return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
def _calc_track_length(coords: list) -> float:
"""Считает длину трека через Haversine."""
total = 0.0
for i in range(len(coords) - 1):
total += _haversine_m(coords[i][0], coords[i][1], coords[i + 1][0], coords[i + 1][1])
return total
def _parse_gpx_trackpoints(content: bytes, source_id: str, ext_url_template: str) -> list:
"""Парсит GPX-ответ OSM API с треками.
Группирует trkpt по атрибуту gpx_id.
Анонимные точки (без gpx_id) пропускаются.
Returns:
list[TrackInsert]
"""
try:
# defusedxml защищает от XXE
root = ET.fromstring(content)
except Exception as exc:
logger.error("Failed to parse GPX: %s", exc)
return []
# Группируем точки по gpx_id
tracks_points: dict = {}
# Определяем namespace
ns = ""
tag = root.tag
if tag.startswith("{"):
ns = tag.split("}")[0] + "}"
# Ищем trkpt напрямую и через trk/trkseg
trkpt_elements = []
# Вариант 1: OSM возвращает trkpt напрямую в корне (API 0.6 trackpoints endpoint)
for child in root:
local = child.tag.replace(ns, "") if ns else child.tag
if local == "trk":
for trkseg in child:
local2 = trkseg.tag.replace(ns, "") if ns else trkseg.tag
if local2 == "trkseg":
for trkpt in trkseg:
trkpt_elements.append(trkpt)
elif local == "trkpt":
trkpt_elements.append(child)
for trkpt in trkpt_elements:
gpx_id = trkpt.get("gpx_id") or trkpt.get("{http://www.topografix.com/GPX/1/0}gpx_id")
if not gpx_id:
# Анонимные точки — пропускаем
continue
try:
lat = float(trkpt.get("lat", 0))
lon = float(trkpt.get("lon", 0))
except (TypeError, ValueError):
continue
if gpx_id not in tracks_points:
tracks_points[gpx_id] = []
# Получаем время из дочернего элемента
time_elem = None
for child in trkpt:
local = child.tag.replace(ns, "") if ns else child.tag
if local == "time":
time_elem = child
break
time_str = time_elem.text if time_elem is not None else None
tracks_points[gpx_id].append((lon, lat, time_str))
results = []
for gpx_id, points in tracks_points.items():
if len(points) < 2:
continue
coords = [(p[0], p[1]) for p in points]
# Вычисляем bbox
lons = [c[0] for c in coords]
lats = [c[1] for c in coords]
min_lon, max_lon = min(lons), max(lons)
min_lat, max_lat = min(lats), max(lats)
# Длина трека
length_m = _calc_track_length(coords)
if length_m < 10: # Слишком короткий трек — пропускаем
continue
# Дата из первой точки с временем
created_at = None
for p in points:
if p[2]:
created_at = p[2][:19].replace("T", "T") # ISO без миллисекунд
break
# WKB из shapely
try:
from shapely.geometry import LineString
from shapely import wkb
geom = LineString(coords)
geom_wkb = wkb.dumps(geom)
except Exception:
continue
# External URL
ext_url = None
if ext_url_template:
ext_url = ext_url_template.format(
user="",
external_id_numeric=gpx_id,
)
track = TrackInsert(
external_id=str(gpx_id),
source_id=source_id,
external_url=ext_url,
name=None,
description=None,
activity_type="other",
user=None,
created_at=created_at,
length_m=length_m,
points_count=len(coords),
geom_wkb=geom_wkb,
min_lon=min_lon,
min_lat=min_lat,
max_lon=max_lon,
max_lat=max_lat,
tags=[],
source_priority=50,
)
results.append(track)
return results
async def _fetch_with_backoff(
client: httpx.AsyncClient,
url: str,
max_retries: int = 3,
) -> httpx.Response | None:
"""Выполняет HTTP-запрос с экспоненциальным backoff."""
for attempt in range(max_retries):
try:
resp = await client.get(url)
if resp.status_code == 429:
wait = 2 ** attempt * 2
logger.warning("Rate limited, waiting %ds", wait)
await asyncio.sleep(wait)
continue
return resp
except httpx.TimeoutException:
wait = 2 ** attempt
logger.warning("Timeout on attempt %d, waiting %ds", attempt + 1, wait)
await asyncio.sleep(wait)
except Exception as exc:
logger.error("Request failed: %s", exc)
return None
return None

View File

@@ -0,0 +1,17 @@
"""Парсер Тропинки.ру — заглушка (ADR-011 status=proposed)."""
from src.api.gps_tracks.sources.base import SourceParser
class TtrailsParser(SourceParser):
"""Парсер Тропинки.ру.
Заблокирован до получения лицензии. См. ADR-011.
"""
MAPPING = {"велосипед": "bicycle", "пешком": "hike", "мото": "moto"}
async def collect(self, bbox, ctx):
# ADR-011: blocked, status=proposed
raise NotImplementedError("Ttrails parser not yet licensed (ADR-011)")
return
yield # make it a generator

View File

@@ -14,6 +14,11 @@ import sqlite3
import itertools
GPS_TRACKS_DB_PATH = os.environ.get(
"GPS_TRACKS_DB_PATH",
os.path.join(os.path.dirname(__file__), "../../data/gps_tracks.sqlite"),
)
from shapely.geometry import LineString
from typing import List
@@ -1246,6 +1251,10 @@ async def terrain_tile(layer: str, z: int, x: int, y: int):
# ─── Static files ─────────────────────────────────────────────────────────────
from src.api.gps_tracks.endpoint import create_gps_router
gps_router = create_gps_router(GPS_TRACKS_DB_PATH)
app.include_router(gps_router)
if os.path.exists(STATIC_DIR):
app.mount("/", StaticFiles(directory=STATIC_DIR, html=True), name="static")

View File

@@ -3,3 +3,6 @@ uvicorn==0.29.0
shapely==2.0.4
mapbox-vector-tile==2.2.0
httpx==0.27.0
defusedxml==0.7.1
lxml==5.2.2
pyyaml==6.0.1