diff --git a/tasks/enduro-trails/DEV_TASK_ET009_NEW_SOURCES.md b/tasks/enduro-trails/DEV_TASK_ET009_NEW_SOURCES.md new file mode 100644 index 0000000..5277baa --- /dev/null +++ b/tasks/enduro-trails/DEV_TASK_ET009_NEW_SOURCES.md @@ -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 +# Из собрать список (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-агент*