# DEV TASK: ET-009 — Новые источники GPS-треков: EnduroRussia + Wikiloc **Статус:** Ready for dev **Проект:** enduro-trails **Фаза:** ET-009 **BRD:** `docs/work-items/ET-008/01-brd.md` (расширение §4) --- ## Цель Реализовать два новых парсера GPS-треков — EnduroRussia.ru (JSON API) и Wikiloc (HTML-парсинг) — и активировать их в pipeline, обновив ADR-010 и создав ADR-012 для Wikiloc. --- ## Архитектура Оба источника встраиваются в существующую архитектуру ET-008: `SourceParser` → `gps_collect.py` → `gps_tracks.sqlite`. EnduroRussia имеет открытый JSON API (`/api/tracks` + `/api/tracks/{id}/gpx`) — парсер простой и надёжный. Wikiloc не имеет публичного API, поэтому используем HTTP-парсинг страниц поиска с `httpx` + `lxml`/`html.parser`, с агрессивным rate limiting и User-Agent. --- ## Стек / Зависимости - Python 3.11, asyncio, httpx (уже в проекте) - `defusedxml` (уже в проекте) — для парсинга GPX от EnduroRussia - `lxml` или `html.parser` (stdlib) — для Wikiloc HTML - `gpxpy` или ручной парсинг GPX — для извлечения геометрии - Без новых pip-зависимостей если возможно (использовать stdlib xml + httpx) --- ## Инфраструктура | Параметр | Значение | |----------|----------| | Сервер | `slin@82.22.50.71` | | Рабочая директория | `/home/slin/repos/enduro-trails/` | | Деплой | `docker compose build && docker compose run --rm gps-collector` | | Проверка БД | `sqlite3 /home/slin/repos/enduro-trails/data/gps_tracks.sqlite "SELECT source_id, count(*) FROM tracks GROUP BY source_id;"` | --- ## Файловая карта | Действие | Файл | Ответственность | |----------|------|-----------------| | Изменить | `src/api/gps_tracks/sources/enduro_russia.py` | Реализовать парсер (сейчас заглушка) | | Создать | `src/api/gps_tracks/sources/wikiloc.py` | Новый парсер Wikiloc | | Изменить | `config/gps_sources.yaml` | Включить enduro_russia, добавить wikiloc | | Изменить | `docs/work-items/ET-008/06-adr/ADR-010-enduro-russia-licensing.md` | status: proposed → accepted | | Создать | `docs/work-items/ET-008/06-adr/ADR-012-wikiloc-licensing.md` | Новый ADR для Wikiloc | | Изменить | `tests/test_sources.py` или создать | Тесты для обоих парсеров | --- ## Задачи ### Task 1: ADR-010 — разблокировать EnduroRussia **Файлы:** - Изменить: `docs/work-items/ET-008/06-adr/ADR-010-enduro-russia-licensing.md` **Шаги:** - [ ] **1.1** Обновить ADR-010: заполнить §1-§3 на основе реального API Факты для заполнения: - API публичный, без авторизации: `GET https://endurorussia.ru/api/tracks` → JSON - GPX скачивается напрямую: `GET https://endurorussia.ru/api/tracks/{id}/gpx` - robots.txt проверить: `curl https://endurorussia.ru/robots.txt` - ToS: проверить `https://endurorussia.ru/about` или footer - Треки содержат `creator="Wikiloc"` в GPX — источник данных Wikiloc, агрегатор - Attribution: "EnduroRussia.ru" Изменить в frontmatter: ```yaml status: accepted ``` Добавить в тело ADR раздел "## Решение (финальное)": ```markdown ## Решение (финальное — accepted) API публичный и открытый (без авторизации, без ключей). robots.txt: [результат проверки] ToS: [результат проверки] Треки содержат creator="Wikiloc" — платформа агрегирует публичные треки. Attribution в pipeline: "EnduroRussia.ru" status → accepted [дата] ``` **Критерий готовности:** `grep "status: accepted" ADR-010-enduro-russia-licensing.md` → найдено --- ### Task 2: ADR-012 — создать для Wikiloc **Файлы:** - Создать: `docs/work-items/ET-008/06-adr/ADR-012-wikiloc-licensing.md` **Шаги:** - [ ] **2.1** Создать ADR-012 по образцу ADR-010, со статусом `accepted` Факты: - Wikiloc не имеет публичного API (официально отказали) - Треки публичны на сайте, доступны без авторизации через HTML - Парсинг HTML страниц поиска — единственный вариант - Rate limit: 10 сек между запросами, User-Agent с контактом - Attribution: "© Wikiloc contributors" - Риск: структура HTML может меняться → парсер хрупкий ```yaml --- type: adr work_item_id: ET-009 adr_id: ADR-012 title: "ADR-012: Источник Wikiloc — HTML-парсинг публичных треков" status: accepted created_at: 2026-06-01 authors: - "agent:developer" --- ``` **Критерий готовности:** файл создан, `status: accepted` --- ### Task 3: Парсер EnduroRussia **Файлы:** - Изменить: `src/api/gps_tracks/sources/enduro_russia.py` **Шаги:** - [ ] **3.1** Реализовать `EnduroRussiaParser.collect()` API эндпоинты (проверено вручную): - `GET https://endurorussia.ru/api/tracks` → `{"items": [{id, name, description, region, difficulty, length_km, elevation_gain, created_at, user: {id, username}}, ...]}` - `GET https://endurorussia.ru/api/tracks/{id}/gpx` → GPX XML (application/gpx+xml) - Пагинация: `?page=N&limit=50` (проверить наличие поля `total` или `next`) - Всего треков: ~305 (id от 1 до 305+) Логика парсера: ```python class EnduroRussiaParser(SourceParser): MAPPING = { "enduro": "enduro", "мото": "moto", "hard": "enduro", "soft": "enduro", "тур": "moto", } async def collect(self, bbox, ctx): # 1. GET /api/tracks?page=0&limit=50 — получить список всех треков # 2. Для каждого трека: GET /api/tracks/{id}/gpx — скачать GPX # 3. Распарсить GPX → извлечь точки трека → WKB геометрию # 4. Проверить что bbox трека пересекается с запрошенным bbox # 5. Yield TrackInsert(...) # Rate limit: 5 сек между запросами (из конфига) # external_id: str(track_id) # external_url: f"https://endurorussia.ru/tracks/{track_id}" # source_id: "enduro_russia" # activity_type: map_activity(difficulty или "enduro") ``` GPX парсинг — использовать уже существующий паттерн из `osm.py` с `defusedxml`. Извлечение геометрии из GPX: ```python # Из собрать список (lon, lat) точек # length_m = сумма haversine расстояний между точками # WKB = LineString из shapely или ручная упаковка (как в osm.py) # bbox = min/max lon/lat ``` - [ ] **3.2** Проверить что парсер работает на одном треке: ```bash cd /home/slin/repos/enduro-trails python3 -c " import asyncio from src.api.gps_tracks.sources.enduro_russia import EnduroRussiaParser cfg = {'id': 'enduro_russia', 'base_url': 'https://endurorussia.ru', 'rate_limit_sec': 2} p = EnduroRussiaParser(cfg) async def test(): count = 0 async for t in p.collect((29.0, 49.5, 47.5, 60.0), {}): print(t.name, t.length_m, t.points_count) count += 1 if count >= 3: break asyncio.run(test()) " ``` Ожидаемый результат: 3 трека с name, length_m > 0, points_count > 0 **Критерий готовности:** парсер возвращает треки без ошибок --- ### Task 4: Парсер Wikiloc **Файлы:** - Создать: `src/api/gps_tracks/sources/wikiloc.py` **Шаги:** - [ ] **4.1** Исследовать структуру HTML Wikiloc для поиска треков по bbox URL поиска: `https://www.wikiloc.com/wikiloc/find.do?act=19&sw=49.5,29.0&ne=60.0,47.5&page=0` - `act=19` — мотоциклы/эндуро (проверить актуальные коды активностей) - `sw=lat,lon` — юго-западный угол bbox - `ne=lat,lon` — северо-восточный угол bbox Альтернативный URL: `https://www.wikiloc.com/trails/motorcycle/russia` Парсинг: ```python # httpx GET страницы поиска # Найти ссылки на треки: # Для каждого трека: GET страницы трека # Найти ссылку на GPX: # Скачать GPX → распарсить геометрию ``` - [ ] **4.2** Реализовать `WikilockParser.collect()`: ```python class WikilockParser(SourceParser): MAPPING = { "motorcycle": "moto", "enduro": "enduro", "mtb": "bicycle", "hiking": "hike", "running": "hike", } async def collect(self, bbox, ctx): # 1. Поиск треков по bbox через страницу поиска # 2. Для каждого трека: скачать GPX # 3. Распарсить → TrackInsert # Rate limit: 10 сек (жёстко, Wikiloc агрессивно блокирует) # User-Agent: из конфига (с контактом) # external_id: wikiloc track id из URL # external_url: https://www.wikiloc.com/wikiloc/view.do?id={id} # При 429/403: логировать и прекращать (не ретраить агрессивно) ``` - [ ] **4.3** Проверить: ```bash cd /home/slin/repos/enduro-trails python3 -c " import asyncio from src.api.gps_tracks.sources.wikiloc import WikilockParser cfg = {'id': 'wikiloc', 'base_url': 'https://www.wikiloc.com', 'rate_limit_sec': 10} p = WikilockParser(cfg) async def test(): count = 0 async for t in p.collect((37.0, 55.0, 40.0, 57.0), {}): print(t.name, t.length_m, t.points_count) count += 1 if count >= 2: break asyncio.run(test()) " ``` **Критерий готовности:** парсер возвращает хотя бы 1 трек без ошибок (или graceful skip при 403) --- ### Task 5: Обновить конфиг и включить источники **Файлы:** - Изменить: `config/gps_sources.yaml` **Шаги:** - [ ] **5.1** Обновить `enduro_russia` entry: `enabled: true`, исправить `base_url`: ```yaml - id: enduro_russia name: "EnduroRussia.ru" enabled: true license_adr: "docs/work-items/ET-008/06-adr/ADR-010-enduro-russia-licensing.md" base_url: "https://endurorussia.ru" rate_limit_sec: 5 user_agent: "enduro-trails/1.0 (+https://openclaw.mva154.duckdns.org/enduro/)" attribution: "EnduroRussia.ru" parser_module: "src.api.gps_tracks.sources.enduro_russia" save_user_field: false source_priority: 80 ``` - [ ] **5.2** Добавить `wikiloc` entry: ```yaml - id: wikiloc name: "Wikiloc" enabled: true license_adr: "docs/work-items/ET-008/06-adr/ADR-012-wikiloc-licensing.md" base_url: "https://www.wikiloc.com" rate_limit_sec: 10 user_agent: "enduro-trails/1.0 (+https://openclaw.mva154.duckdns.org/enduro/)" attribution: "© Wikiloc contributors" parser_module: "src.api.gps_tracks.sources.wikiloc" save_user_field: false source_priority: 70 activity_filter: ["motorcycle", "enduro", "mtb"] ``` **Критерий готовности:** `python3 -c "from src.api.gps_tracks.config import load_sources_config; print([s['id'] for s in load_sources_config('config/gps_sources.yaml').sources])"` → содержит `enduro_russia` и `wikiloc` --- ### Task 6: Тесты **Файлы:** - Создать/изменить: `tests/test_sources_et009.py` **Шаги:** - [ ] **6.1** Написать unit-тесты с фикстурами (без реальных HTTP запросов): ```python # Фикстура: минимальный GPX XML для EnduroRussia # Фикстура: минимальный HTML страницы Wikiloc с треком # Тест: EnduroRussiaParser парсит GPX → TrackInsert с корректными полями # Тест: WikilockParser парсит HTML → находит ссылки на треки # Тест: map_activity маппит категории правильно ``` - [ ] **6.2** Запустить тесты: ```bash cd /home/slin/repos/enduro-trails python3 -m pytest tests/test_sources_et009.py -v ``` **Критерий готовности:** все тесты зелёные --- ### Task 7: Деплой и smoke-тест **Шаги:** - [ ] **7.1** Собрать и запустить коллектор: ```bash cd /home/slin/repos/enduro-trails docker compose build gps-collector docker compose run --rm gps-collector python -m scripts.gps_collect --source enduro_russia --dry-run ``` - [ ] **7.2** Если dry-run OK — запустить реальный сбор (только EnduroRussia, ограниченный регион): ```bash docker compose run --rm gps-collector python -m scripts.gps_collect --source enduro_russia --region north_caucasus ``` - [ ] **7.3** Проверить БД: ```bash sqlite3 /home/slin/repos/enduro-trails/data/gps_tracks.sqlite \ "SELECT source_id, count(*), round(avg(length_m)/1000,1) as avg_km FROM tracks GROUP BY source_id;" ``` Ожидаемый результат: строка `enduro_russia | N | X.X` где N > 0 - [ ] **7.4** Wikiloc — запустить с ограниченным bbox (Москва): ```bash docker compose run --rm gps-collector python -m scripts.gps_collect --source wikiloc --region moscow_test ``` Если 403/429 — зафиксировать в логах, это ожидаемо. Главное — graceful handling без краша. - [ ] **7.5** Закоммитить: ```bash cd /home/slin/repos/enduro-trails git add -A git commit -m "feat(ET-009): add EnduroRussia and Wikiloc GPS sources" git push ``` **Критерий готовности:** коммит запушен, в БД есть треки от enduro_russia --- ## Проверка (Acceptance) | # | Проверка | Команда | Ожидаемый результат | |---|----------|---------|---------------------| | 1 | ADR-010 accepted | `grep "status: accepted" docs/work-items/ET-008/06-adr/ADR-010-enduro-russia-licensing.md` | найдено | | 2 | ADR-012 создан | `cat docs/work-items/ET-008/06-adr/ADR-012-wikiloc-licensing.md \| grep status` | `status: accepted` | | 3 | Парсер EnduroRussia работает | `python3 -c "..."` (Task 3.2) | 3 трека без ошибок | | 4 | Конфиг обновлён | `grep "enabled: true" config/gps_sources.yaml \| wc -l` | ≥ 2 | | 5 | Треки в БД | `sqlite3 data/gps_tracks.sqlite "SELECT source_id, count(*) FROM tracks GROUP BY source_id;"` | строка enduro_russia с N > 0 | | 6 | Тесты зелёные | `python3 -m pytest tests/test_sources_et009.py -v` | все PASSED | | 7 | Коммит запушен | `git log --oneline -1` | feat(ET-009): ... | --- ## Ограничения и контекст - ⚠️ **ADR licensing guard**: pipeline проверяет `status` в ADR файле перед запуском source. Пока `status: proposed` — source пропускается. Нужно обновить ADR ДО включения `enabled: true` в конфиге. - ⚠️ **base_url в конфиге**: текущий конфиг содержит `https://enduro-russia.ru` (с дефисом), реальный URL `https://endurorussia.ru` (без дефиса). Исправить. - ⚠️ **Wikiloc без API**: парсинг HTML хрупкий. Если структура изменилась — парсер вернёт 0 треков, не упадёт. Логировать предупреждение. - ⚠️ **Wikiloc rate limit**: 10 сек минимум. При 429 — остановить сбор, записать в pipeline_runs.status = "rate_limited", не ретраить. - ⚠️ **GPX от EnduroRussia содержит Wikiloc-треки**: дедупликация по dedup_key (bbox-bucket + length + date) должна отловить дубли если один трек есть и в Wikiloc и в EnduroRussia. - ⚠️ **docker compose run** для gps-collector использует profile `batch` — нужно `--profile batch` или `run --rm gps-collector`. - 🚫 Не трогать `osm.py` и существующие тесты ET-008. - 🚫 Не менять схему БД — только новые source_id в существующих таблицах. - 🚫 Не добавлять новые pip-зависимости без крайней необходимости (использовать stdlib + уже установленные). --- ## Деплой-чеклист - [ ] ADR-010 обновлён (status: accepted) - [ ] ADR-012 создан (status: accepted) - [ ] `enduro_russia.py` реализован (не заглушка) - [ ] `wikiloc.py` создан - [ ] `gps_sources.yaml` обновлён (оба enabled: true) - [ ] Тесты написаны и зелёные - [ ] `docker compose build` прошёл без ошибок - [ ] Dry-run EnduroRussia — OK - [ ] Реальный сбор EnduroRussia — треки в БД - [ ] Wikiloc — graceful handling (треки или 403 без краша) - [ ] Коммит запушен в main --- *Создано: 2026-06-01 | Автор ТЗ: Стрим | Исполнитель: Dev-агент*