auto-sync: 2026-06-01 20:30:01

This commit is contained in:
Stream
2026-06-01 20:30:01 +03:00
parent e9b64e8c91
commit 7832dc1812

View File

@@ -0,0 +1,430 @@
# 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
# Из <trkpt lat="..." lon="..."> собрать список (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 страницы поиска
# Найти ссылки на треки: <a href="/wikiloc/view.do?id=...">
# Для каждого трека: GET страницы трека
# Найти ссылку на GPX: <a href="...download...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-агент*