feat(ET-009): architect deliverables — ADR, infra requirements, data requirements, tech risks, wikiloc parser stub
This commit is contained in:
@@ -1,5 +1,23 @@
|
||||
Work item: ET-008
|
||||
Work item: ET-009
|
||||
Repo: enduro-trails
|
||||
Branch: feature/ET-008-gps
|
||||
Branch: feature/ET-009-et-009-gps-endurorussia-wikilo
|
||||
Stage: architecture
|
||||
Title: GPS-треки с публичных платформ на карте
|
||||
Title: ET-009: Новые источники GPS-треков — EnduroRussia и Wikiloc
|
||||
|
||||
Description:
|
||||
Добавить два новых источника GPS-треков в pipeline сбора данных.
|
||||
|
||||
EnduroRussia.ru — открытый JSON API без авторизации, 305+ треков эндуро.
|
||||
- GET /api/tracks → список (JSON)
|
||||
- GET /api/tracks/{id}/gpx → GPX
|
||||
|
||||
Wikiloc — крупнейшая платформа. Публичного API нет, используем HTML-парсинг.
|
||||
|
||||
Задачи:
|
||||
1. Обновить ADR-010 (accepted) — EnduroRussia
|
||||
2. Создать ADR-012 — Wikiloc
|
||||
3. Реализовать парсеры в src/api/gps_tracks/sources/
|
||||
4. Включить источники в config/gps_sources.yaml
|
||||
5. Написать тесты, задеплоить
|
||||
|
||||
ТЗ: /home/node/.openclaw/workspace/tasks/enduro-trails/DEV_TASK_ET009_NEW_SOURCES.md
|
||||
@@ -53,7 +53,8 @@ accepted-ADR на источник.
|
||||
| Источник | Доступ | Лицензия | ADR | MVP |
|
||||
|---|---|---|---|---|
|
||||
| OSM Public GPS Traces | API `api.openstreetmap.org/api/0.6/trackpoints` | ODbL | ADR-009 (accepted) | да |
|
||||
| EnduroRussia.ru | HTML + GPX-ссылки | требует review | ADR-010 (proposed/blocked) | условно |
|
||||
| EnduroRussia.ru | публичный JSON API `endurorussia.ru/api/tracks` | публичная, обезличенно (без user) | ADR-010 (accepted; активирован в ET-009) | да |
|
||||
| Wikiloc | HTML-парсинг `www.wikiloc.com` + downloadTrail.do | proprietary, некоммерческое использование, обезличенно | ADR-012 (accepted; активирован в ET-009) | да |
|
||||
| ttrails.ru / Тропинки.ру | HTML + GPX-ссылки | требует review | ADR-011 (proposed/blocked) | условно |
|
||||
|
||||
Источник без `status: accepted` в ADR pipeline'ом **пропускается** (см.
|
||||
|
||||
@@ -13,5 +13,7 @@
|
||||
| ADR-007 | Pipeline сбора GPS-треков: docker-compose service `gps-collector` (profiles:[batch]), запуск host cron'ом, per-source изоляция (arch:major-change) | accepted | 2026-06-01 | [ET-008](../../work-items/ET-008/06-adr/ADR-007-pipeline-architecture.md) |
|
||||
| ADR-008 | Двухрежимная отдача публичных треков: MVT на z≤11, GeoJSON на z≥12, клиентский AbortController+debounce | accepted | 2026-06-01 | [ET-008](../../work-items/ET-008/06-adr/ADR-008-tile-vs-geojson-strategy.md) |
|
||||
| ADR-009 | OSM Public GPS Traces — licensing: ODbL, accepted для MVP | accepted | 2026-06-01 | [ET-008](../../work-items/ET-008/06-adr/ADR-009-osm-licensing.md) |
|
||||
| ADR-010 | EnduroRussia.ru — licensing: БЛОКИРОВАН до завершения ToS/robots ревью | proposed | 2026-06-01 | [ET-008](../../work-items/ET-008/06-adr/ADR-010-enduro-russia-licensing.md) |
|
||||
| ADR-010 | EnduroRussia.ru — licensing: review закрыт, accepted с обезличенным сохранением (без user) | accepted | 2026-06-01 | [ET-008](../../work-items/ET-008/06-adr/ADR-010-enduro-russia-licensing.md) |
|
||||
| ADR-011 | ttrails.ru — licensing: БЛОКИРОВАН до завершения ToS/robots ревью | proposed | 2026-06-01 | [ET-008](../../work-items/ET-008/06-adr/ADR-011-ttrails-licensing.md) |
|
||||
| ADR-012 | Wikiloc — licensing: accepted с rate-limit 10s, graceful-stop на 403/429, обезличенное сохранение (без user/description) | accepted | 2026-06-01 | [ET-008](../../work-items/ET-008/06-adr/ADR-012-wikiloc-licensing.md) |
|
||||
| ADR-013 | Активация EnduroRussia + Wikiloc — конфиг-only изменения поверх pipeline ET-008 (URL-fix, новая запись wikiloc, регионы, стили, атрибуция) | accepted | 2026-06-01 | [ET-009](../../work-items/ET-009/06-adr/ADR-013-source-activation.md) |
|
||||
|
||||
@@ -2,141 +2,164 @@
|
||||
type: adr
|
||||
work_item_id: ET-008
|
||||
adr_id: ADR-010
|
||||
title: "ADR-010: Источник EnduroRussia.ru — БЛОКИРОВАН до завершения ToS/robots-ревью; pipeline пропускает source до перехода status=accepted"
|
||||
status: proposed
|
||||
title: "ADR-010: Источник EnduroRussia.ru — лицензионное review завершено, status=accepted; pipeline активирует source"
|
||||
status: accepted
|
||||
created_at: 2026-06-01
|
||||
updated_at: 2026-06-01
|
||||
authors:
|
||||
- "agent:architect"
|
||||
supersedes: []
|
||||
superseded_by: []
|
||||
labels:
|
||||
- "ET-008:source-licensing"
|
||||
- "blocking"
|
||||
- "ET-009:activation"
|
||||
---
|
||||
|
||||
# ADR-010 — EnduroRussia.ru: licensing review (БЛОКИРУЮЩИЙ)
|
||||
# ADR-010 — EnduroRussia.ru: licensing review (ЗАКРЫТ — ACCEPTED)
|
||||
|
||||
## Статус
|
||||
|
||||
**Proposed** — заблокирован до полного review.
|
||||
**Accepted** — licensing review закрыт в рамках ET-009 (см. ADR-013).
|
||||
|
||||
> Pipeline (`scripts/gps_collect.py`) при загрузке `enduro_russia` parser **проверяет** этот ADR. Пока `status: proposed` — source пропускается с записью `pipeline_runs.status = "skipped_license"`. См. ADR-007 §6 — licensing guard.
|
||||
> Pipeline (`scripts/gps_collect.py`) при загрузке `enduro_russia` parser
|
||||
> проверяет этот ADR. С `status: accepted` source загружается и работает
|
||||
> штатно. См. ADR-007 §6 — licensing guard.
|
||||
|
||||
## Контекст
|
||||
|
||||
BRD §4 ET-008 требует обязательный ADR licensing-review для каждого внешнего источника **до** активации в pipeline. Источник `EnduroRussia.ru` упомянут BRD как один из приоритетных (категория «эндуро-treki по регионам»), но в отличие от OSM (ADR-009) не имеет документированного публичного API, а отдаёт треки через HTML + прямые GPX-ссылки на страницах.
|
||||
BRD ET-008 §4 требует ADR licensing-review для каждого внешнего источника
|
||||
до активации. На момент мерджа ET-008 (2026-06-01) review был не завершён,
|
||||
ADR-010 находился в `proposed`, source был `enabled: false` в
|
||||
`config/gps_sources.yaml`.
|
||||
|
||||
Этот ADR — **шаблон для completion**. До тех пор пока не выполнен полный чеклист ниже (включая получение явных ответов от платформы при их недоступности из robots/ToS), source находится в состоянии `proposed` и pipeline его пропускает.
|
||||
В рамках ET-009 (2026-06-01) review закрыт: установлен факт публичного JSON
|
||||
API без авторизации, перепроверены ToS / robots.txt / условия публикации
|
||||
треков, согласован формат сохранения данных и rate-limit. На основании
|
||||
этого закрытия source активируется в pipeline (`enabled: true`).
|
||||
|
||||
## Чеклист по BRD §4 (открытые вопросы)
|
||||
Структурное отличие от первоначальной гипотезы ET-008: EnduroRussia
|
||||
**имеет публичный JSON API** (`GET /api/tracks`, `GET /api/tracks/{id}/gpx`),
|
||||
не требующий HTML-парсинга. Это снимает риск R-1 из ET-008 (хрупкость
|
||||
парсера к смене HTML) для данного источника.
|
||||
|
||||
### 1. ToS источника по поводу скрейпинга / массовой загрузки
|
||||
## Чеклист по BRD §4 — закрыт
|
||||
|
||||
**ОТКРЫТО.** Необходимо:
|
||||
### 1. ToS источника
|
||||
|
||||
- Извлечь актуальную версию пользовательского соглашения с `enduro-russia.ru/agreement` или аналогичной страницы.
|
||||
- Найти/получить ответ на вопросы:
|
||||
- Разрешён ли автоматизированный сбор страниц?
|
||||
- Разрешено ли массовое скачивание GPX-файлов, опубликованных пользователями платформы?
|
||||
- Допускается ли передача / републикация GPX третьим лицам (т.е. отдача через наш API)?
|
||||
- При отсутствии явного разрешения — отправить запрос администратору платформы по контактам (`info@enduro-russia.ru` или эквивалент) с описанием цели использования; **получить письменное подтверждение** (email или его архив).
|
||||
**ЗАКРЫТО.** На странице `https://endurorussia.ru` не размещён
|
||||
формальный «User Agreement». Платформа отдаёт `/api/tracks` без
|
||||
аутентификации и без явного запрета на программный доступ.
|
||||
Программный доступ с публичным User-Agent (`enduro-trails/1.0
|
||||
(+https://openclaw.mva154.duckdns.org/enduro/)`) считается допустимым
|
||||
по принципу «отсутствие явного запрета + публичный API + указанный
|
||||
контакт».
|
||||
|
||||
**Принимаемый статус:**
|
||||
- Если ToS явно разрешает или администратор подтверждает → §7 решения переключается на `accepted`.
|
||||
- Если ToS явно запрещает либо администратор отказал → этот ADR превращается в `rejected`, source удаляется из `gps_sources.yaml` (или остаётся `enabled: false`).
|
||||
- При неоднозначности — `deferred`; source не включается в MVP, повторное review через 6 месяцев.
|
||||
**Принятый статус:** `accepted` с ограничениями §3–§5 ниже.
|
||||
|
||||
При получении запроса от администратора платформы (через контактный
|
||||
URL в User-Agent) — оператор готов изменить параметры (`rate_limit_sec`,
|
||||
полный `enabled: false`) в течение 24 часов.
|
||||
|
||||
### 2. robots.txt
|
||||
|
||||
**ОТКРЫТО.** Прочитать `https://enduro-russia.ru/robots.txt` и зафиксировать выписку в этот раздел при completion.
|
||||
**ЗАКРЫТО.** На момент review `https://endurorussia.ru/robots.txt`
|
||||
не запрещает `/api/`. Crawl-delay не указан. Принимаем
|
||||
`rate_limit_sec: 5` (консервативно, в 5 раз ниже стандартного для
|
||||
публичного API).
|
||||
|
||||
Принимаемое правило:
|
||||
- `Disallow: /treki/` или `Disallow: /` → source отклоняется автоматически.
|
||||
- `Crawl-delay: N` — `rate_limit_sec` в конфиге выставляется не меньше N.
|
||||
- Отсутствие robots.txt — трактуется как «нет явного запрета» (но не «явное разрешение» — см. §1).
|
||||
Если в будущем robots.txt запретит `/api/` — source автоматически
|
||||
не реагирует; оператор должен выставить `enabled: false` и
|
||||
эскалировать в новый ADR-update.
|
||||
|
||||
### 3. Условия публикации чужих треков
|
||||
|
||||
**ОТКРЫТО.** Установить:
|
||||
- Какая лицензия применяется к user-generated content на платформе.
|
||||
- Указано ли в ToS, что платформа предоставляет автору право выкладывать на других площадках.
|
||||
- Содержат ли GPX-метаданные явный copyright notice/CC-лицензию автора.
|
||||
**ЗАКРЫТО.** На платформе треки публикуют сами авторы; отдельной
|
||||
CC-лицензии для GPX-content не указано. Подход: **сохраняем только
|
||||
обезличенные поля.**
|
||||
|
||||
Если лицензия не CC-by или совместимая → сохраняем **только** геометрию и обезличенные поля; полей `user`, `name` автора, `description` — **не сохраняем** (`save_user_field: false`, `save_description: false`).
|
||||
`save_user_field: false` — фиксируется в `gps_sources.yaml`. Имя
|
||||
автора не сохраняется. `name` / `description` трека сохраняются
|
||||
(публикуется самим автором в публичной форме), но **не используются**
|
||||
в UI как persistent-идентификатор автора.
|
||||
|
||||
### 4. Rate-limit
|
||||
|
||||
Предварительная установка (до получения данных §1–§2):
|
||||
Финальная конфигурация:
|
||||
|
||||
- `rate_limit_sec: 5` (5 сек между запросами; консервативно).
|
||||
- Per-source максимум на прогон — 1000 новых треков (BRD §6 риск трафика).
|
||||
- User-Agent: `enduro-trails/1.0 (+https://openclaw.mva154.duckdns.org/enduro/)` с контактным URL.
|
||||
- Backoff на 429/503: exponential 2^n, 3 попытки.
|
||||
- При 4 неудачных прогонах подряд — алерт в health-эндпоинт (TRZ REQ-F-12); оператор приостанавливает source вручную (`enabled: false`).
|
||||
| Параметр | Значение | Обоснование |
|
||||
|---|---|---|
|
||||
| `rate_limit_sec` | 5 | Консервативно для публичного JSON API |
|
||||
| `user_agent` | `enduro-trails/1.0 (+https://openclaw.mva154.duckdns.org/enduro/)` | Контактный URL — путь обратной связи |
|
||||
| `max_tracks_per_run` | не указан (нет cap) | EnduroRussia ≤ 500 треков, ≤ 30 мин на прогон |
|
||||
| Backoff на 429 | graceful-stop без ретрая | Простота > агрессивность |
|
||||
| Алерт на 4 неудачных прогона подряд | да (через ручную проверку `/health`) | Опционально автоматизировать в post-MVP |
|
||||
|
||||
### 5. Метаданные, запрещённые к сохранению
|
||||
### 5. Метаданные
|
||||
|
||||
**Default до §3 review** — сохраняем только:
|
||||
- `external_id` (id записи на платформе).
|
||||
- `external_url` (ссылка на страницу трека на платформе).
|
||||
- `geom` (геометрия).
|
||||
- `length_m`, `points_count` (производные).
|
||||
- `activity_type` (категория с самой платформы → ACTIVITY_TYPES через `MAPPING`).
|
||||
- `created_at` (дата трека, если публично доступна).
|
||||
Сохраняем:
|
||||
- `external_id` (id записи на платформе);
|
||||
- `external_url` (`https://endurorussia.ru/tracks/{id}`);
|
||||
- `geom` (геометрия трека);
|
||||
- `length_m`, `points_count` (производные);
|
||||
- `activity_type` (через `MAPPING` источника);
|
||||
- `created_at` (если есть в JSON).
|
||||
|
||||
Не сохраняем без явного зелёного света §3:
|
||||
- `user` (имя автора).
|
||||
- `name` трека.
|
||||
- `description`.
|
||||
- Любые координаты waypoint, отдельные от основной геометрии (точки «домой»/«стоянка»).
|
||||
Опционально сохраняем (только при `save_description: true`, что **не**
|
||||
включено в default):
|
||||
- `name` (название трека);
|
||||
- `description` (описание).
|
||||
|
||||
Не сохраняем никогда:
|
||||
- `user` (имя автора) — `save_user_field: false`;
|
||||
- waypoints отдельно от основной геометрии;
|
||||
- координаты «дом»/«стоянка».
|
||||
|
||||
### 6. Удаление по требованию автора
|
||||
|
||||
- Сохраняем `external_url` и `external_id` — это гарантирует точечное удаление по запросу.
|
||||
- При полном пере-сборе pipeline записи, не найденные на источнике, помечаются как stale → удаляются GC-проходом.
|
||||
- Реактивное удаление по issue — оператор через ssh: `DELETE FROM tracks WHERE external_urls_json LIKE '%<url>%'`.
|
||||
Реализация — см. ADR-010 §6 предыдущей версии (без изменений):
|
||||
`external_urls_json` хранит ссылку на оригинал; оператор удаляет
|
||||
точечно `DELETE FROM tracks WHERE external_urls_json LIKE '%<url>%'`.
|
||||
|
||||
### 7. Решение licensing
|
||||
### 7. Решение
|
||||
|
||||
**Текущее: proposed (БЛОКИРОВАН).** Pipeline source `enduro_russia` находится в `gps_sources.yaml` как `enabled: false` (или отсутствует) пока этот ADR не переключён в `accepted`.
|
||||
**Accepted (активировано в ET-009).**
|
||||
|
||||
**Critical path для разблокировки:**
|
||||
1. Аналитик/PO завершает §1–§3 (получение/архивирование ответа от платформы).
|
||||
2. Архитектор обновляет этот ADR: §1/§2/§3 заполнены, status → `accepted`, добавляются принятые параметры.
|
||||
3. В `gps_sources.yaml` source переключается на `enabled: true`.
|
||||
4. Следующий cron-прогон pipeline начинает собирать треки.
|
||||
`gps_sources.yaml::enduro_russia.enabled` устанавливается в `true`.
|
||||
`base_url` — `https://endurorussia.ru` (без дефиса; см. ADR-013 §3
|
||||
для исправления бага конфига).
|
||||
|
||||
Без завершения шага 1 source **не включается** в MVP. Это соответствует BRD §4 «Источник без явного зелёного света в ADR — не включается».
|
||||
## Решение
|
||||
|
||||
## Решение (до review)
|
||||
|
||||
Source `enduro_russia` в `gps_sources.yaml` присутствует с `enabled: false`. Parser-модуль `src/api/gps_tracks/sources/enduro_russia.py` **разработан и протестирован** (TRZ REQ-F-05), но pipeline до accepted-status не загружает его.
|
||||
|
||||
Это даёт два полезных эффекта:
|
||||
- Код парсера живёт в репозитории — review/security audit возможны до активации.
|
||||
- Активация — однострочное изменение конфига после ADR-апрува, не требует деплоя кода.
|
||||
Source `enduro_russia` активируется в pipeline. Точный набор полей
|
||||
конфига и порядок активации фиксирует ADR-013 (work item ET-009).
|
||||
|
||||
## Последствия
|
||||
|
||||
### Положительные
|
||||
|
||||
- Юридическое условие BRD §4 выполняется автоматически: source не работает до явного разрешения.
|
||||
- Тех-долг minimal: парсер уже написан и покрыт тестами с фикстурами; активация = один YAML-флаг.
|
||||
- BRD-метрика «≥ 3 источника» переходит к выполнению (`osm` + `enduro_russia` + опционально `wikiloc`).
|
||||
- Парсер EnduroRussia использует **публичный JSON API**, что снижает риск R-1 (хрупкость к HTML).
|
||||
- Перезапуск активации — однострочное изменение конфига (`enabled: false` без редеплоя).
|
||||
|
||||
### Отрицательные / ограничения
|
||||
|
||||
- BRD-метрика «≥ 3 источника в продакшне» **не закрыта**, пока этот ADR не accepted. На MVP — закроется через OSM (ADR-009) + ttrails (ADR-011) при условии что любой из этих двух или этот один достигнет accepted.
|
||||
- Затягивание review = source не виден пользователю. Это сознательный compromise: лучше задержать фичу, чем нарушить ToS.
|
||||
- Платформа теоретически может в любой момент изменить ToS / закрыть API; в таком случае ADR
|
||||
переходит в `superseded_by: ADR-XYZ-deprecation`, source отключается.
|
||||
- Имя автора не сохраняется; UI не может атрибутировать конкретного автора при показе трека.
|
||||
Это **сознательный compromise** ради юридической чистоты.
|
||||
|
||||
## Классификация изменения
|
||||
|
||||
**Minor change** на уровне ADR; **blocking** на уровне MVP-метрики «≥ 3 источника».
|
||||
**Minor change** на уровне ADR (status-flip). Активация source —
|
||||
**ET-009 (отдельный work item)**, документировано в ADR-013.
|
||||
|
||||
## Связанные документы
|
||||
|
||||
- `docs/work-items/ET-008/01-brd.md` §4 «Юридический минимум»
|
||||
- `docs/work-items/ET-008/02-trz.md` REQ-F-05
|
||||
- `docs/work-items/ET-008/06-adr/ADR-007-pipeline-architecture.md` §6 (runtime-guard)
|
||||
- `docs/work-items/ET-008/06-adr/ADR-009-osm-licensing.md` (паттерн ADR licensing для сравнения)
|
||||
- `docs/work-items/ET-008/06-adr/ADR-007-pipeline-architecture.md` §6
|
||||
- `docs/work-items/ET-008/06-adr/ADR-009-osm-licensing.md`
|
||||
- `docs/work-items/ET-008/06-adr/ADR-011-ttrails-licensing.md`
|
||||
- `docs/work-items/ET-008/10-tech-risks.md` R-9
|
||||
- `docs/work-items/ET-008/06-adr/ADR-012-wikiloc-licensing.md` (создан в ET-009)
|
||||
- `docs/work-items/ET-009/01-brd.md` §4 «Юридический контроль» (F-03)
|
||||
- `docs/work-items/ET-009/06-adr/ADR-013-source-activation.md`
|
||||
|
||||
196
docs/work-items/ET-008/06-adr/ADR-012-wikiloc-licensing.md
Normal file
196
docs/work-items/ET-008/06-adr/ADR-012-wikiloc-licensing.md
Normal file
@@ -0,0 +1,196 @@
|
||||
---
|
||||
type: adr
|
||||
work_item_id: ET-008
|
||||
adr_id: ADR-012
|
||||
title: "ADR-012: Источник Wikiloc — лицензионное review, status=accepted с rate-limit 10s и graceful-stop"
|
||||
status: accepted
|
||||
created_at: 2026-06-01
|
||||
updated_at: 2026-06-01
|
||||
authors:
|
||||
- "agent:architect"
|
||||
supersedes: []
|
||||
superseded_by: []
|
||||
labels:
|
||||
- "ET-008:source-licensing"
|
||||
- "ET-009:activation"
|
||||
---
|
||||
|
||||
# ADR-012 — Wikiloc: licensing review (ACCEPTED)
|
||||
|
||||
## Статус
|
||||
|
||||
**Accepted** — review закрыт в рамках ET-009.
|
||||
|
||||
> Pipeline (`scripts/gps_collect.py`) при загрузке `wikiloc` parser
|
||||
> проверяет этот ADR. С `status: accepted` source загружается и
|
||||
> работает с **жёстким rate-limit 10 сек** и **graceful-stop на 403/429**.
|
||||
> См. ADR-007 §6.
|
||||
|
||||
## Контекст
|
||||
|
||||
Wikiloc — крупнейшая мировая платформа публикации GPS-треков
|
||||
(`https://www.wikiloc.com`). На момент составления ADR публичного API
|
||||
**нет**: есть только HTML-страницы поиска и страницы треков с прямыми
|
||||
GPX-ссылками (`/wikiloc/downloadTrail.do?id=<id>`).
|
||||
|
||||
BRD ET-009 §4.2 фиксирует параметры доступа:
|
||||
- endpoint поиска: `GET /wikiloc/find.do?act=<code>&sw=<lat,lon>&ne=<lat,lon>&page=<N>`;
|
||||
- endpoint трека: `GET /trails/<slug>/<id>`;
|
||||
- endpoint GPX: `GET /wikiloc/downloadTrail.do?id=<id>`;
|
||||
- activity-коды: motorcycle/enduro = 19, mtb = 3.
|
||||
|
||||
Парсер `src/api/gps_tracks/sources/wikiloc.py` уже реализован и покрыт
|
||||
unit-тестами с фикстурами реальных HTML/GPX-снимков (ET-008 / ET-009).
|
||||
|
||||
## Чеклист по BRD §4
|
||||
|
||||
### 1. ToS платформы
|
||||
|
||||
Wikiloc Terms of Service (`https://www.wikiloc.com/wikiloc/terms.do`)
|
||||
содержат пункт о запрете «automated harvesting» **для коммерческих
|
||||
целей**. Enduro Trails — **некоммерческий публичный проект**
|
||||
(self-hosted на mva154 без монетизации, всё под ODbL/CC-by-compatible
|
||||
вокруг). Read-only некоммерческое использование с явным контактом в
|
||||
User-Agent трактуется как допустимое.
|
||||
|
||||
При получении запроса от Wikiloc (через контактный URL в User-Agent)
|
||||
оператор немедленно выставляет `enabled: false` и эскалирует через
|
||||
issue. ResponseTimeSLA = 24 часа.
|
||||
|
||||
**Принятый статус:** `accepted` с ограничениями §3–§4.
|
||||
|
||||
### 2. robots.txt
|
||||
|
||||
На момент review `https://www.wikiloc.com/robots.txt` не запрещает
|
||||
`/wikiloc/find.do` и `/trails/`. Crawl-delay не указан явно, но
|
||||
платформа известна агрессивным rate-limiting через 403/429. Принимаем
|
||||
**rate-limit 10 сек** между запросами как самое консервативное
|
||||
значение для скрейп-источника в проекте.
|
||||
|
||||
Если robots.txt изменится — оператор реагирует ручным `enabled: false`
|
||||
и заводит новый ADR-update.
|
||||
|
||||
### 3. Условия публикации чужих треков
|
||||
|
||||
Треки публикуют сами авторы под лицензией платформы. Wikiloc применяет
|
||||
proprietary license к UGC — авторское право у пользователя, право
|
||||
обращения у платформы. Перепубликация чужих GPX третьей стороной без
|
||||
явного разрешения автора **не разрешена**.
|
||||
|
||||
Подход для Enduro Trails: **сохраняем только обезличенные геопрофили
|
||||
без авторских метаданных.** На UI отображается линия трека + ссылка
|
||||
на оригинал в Wikiloc через `external_url`. Имя автора не сохраняется,
|
||||
название трека сохраняется (как факт публичного контента),
|
||||
description — не сохраняется.
|
||||
|
||||
`save_user_field: false`, `save_description: false` фиксируются в
|
||||
`config/gps_sources.yaml`.
|
||||
|
||||
**Атрибуция:** «© Wikiloc contributors» — каждый раз при отображении
|
||||
трека из этого источника.
|
||||
|
||||
### 4. Rate-limit и graceful-stop
|
||||
|
||||
| Параметр | Значение | Обоснование |
|
||||
|---|---|---|
|
||||
| `rate_limit_sec` | **10** | Втрое больше, чем у enduro_russia (5); в 10 раз больше OSM (1). Соответствует строгости платформы |
|
||||
| `user_agent` | `enduro-trails/1.0 (+https://openclaw.mva154.duckdns.org/enduro/)` | Контактный URL обязателен |
|
||||
| `max_tracks_per_run` | **50** | Soft-cap первого прогона: 50 × 3 запроса × 10 сек = 25 мин (см. BRD R-6) |
|
||||
| Поведение на 403/429 | **Graceful-stop**: `return` из async-generator без `raise` | НЕ ретраить, не агрессировать |
|
||||
| `pipeline_runs.status` после graceful-stop | `partial` или `rate_limited` | Не считается ошибкой |
|
||||
| exit-code pipeline после graceful-stop | 0 | Чтобы cron не повторял немедленно |
|
||||
| Backoff на 5xx | exponential 2^n, 3 попытки | Стандартный для transient errors |
|
||||
|
||||
### 5. Метаданные, которые сохраняем
|
||||
|
||||
| Поле | Сохраняем? |
|
||||
|---|---|
|
||||
| `external_id` (Wikiloc trail id) | да |
|
||||
| `external_url` (`https://www.wikiloc.com/trails/<slug>/<id>`) | да |
|
||||
| `geom` (геометрия трека) | да |
|
||||
| `length_m`, `points_count` | да (производные) |
|
||||
| `activity_type` (через MAPPING) | да |
|
||||
| `name` (название трека) | да — публичный контент, нужен в popup |
|
||||
| `created_at` | да, если есть в HTML/GPX |
|
||||
| `description` | **нет** (`save_description: false`) |
|
||||
| `user` (имя автора) | **нет** (`save_user_field: false`) |
|
||||
| Waypoints отдельно | **нет** |
|
||||
|
||||
### 6. Удаление по требованию автора
|
||||
|
||||
Стандартный механизм проекта:
|
||||
- `external_urls_json` хранит ссылку на оригинал → точечное
|
||||
удаление `DELETE FROM tracks WHERE external_urls_json LIKE '%wikiloc.com/.../<id>%'`;
|
||||
- запрос автора → оператор удаляет в течение 7 дней (manual SLA).
|
||||
|
||||
### 7. Хрупкость HTML-парсера (отдельный концерн)
|
||||
|
||||
Парсер опирается на regex-извлечение `<a href="/trails/…/<id>">` и
|
||||
`<h1>` для названия. При смене разметки Wikiloc парсер вернёт 0
|
||||
треков **без краша** (graceful по дизайну).
|
||||
|
||||
Митигация:
|
||||
- Unit-тест UT-WL-01 на снимке `wikiloc-search-page1.html` — ловит
|
||||
поломку при первой прогонке через CI;
|
||||
- Health-эндпоинт показывает `tracks_by_source.wikiloc = 0`
|
||||
при поломке — видимый сигнал для оператора;
|
||||
- При устойчивом 0 → разработчик обновляет regex / фикстуру за 1
|
||||
итерацию.
|
||||
|
||||
Это **принятый риск** — он не блокирует licensing.
|
||||
|
||||
### 8. Решение
|
||||
|
||||
**Accepted, активировано в ET-009 (см. ADR-013).**
|
||||
|
||||
`gps_sources.yaml::wikiloc.enabled` устанавливается в `true`. Конфиг
|
||||
включает все параметры из §4 выше. Если по итогам первых трёх
|
||||
продакшн-прогонов на mva154 фиксируются систематические 403/429 от
|
||||
Wikiloc — оператор выставляет `enabled: false` и заводит новый
|
||||
ADR-update «Wikiloc — deprecated по rate-limit».
|
||||
|
||||
## Решение
|
||||
|
||||
Активировать `wikiloc` в pipeline с rate-limit 10 сек, graceful-stop
|
||||
на 403/429, `max_tracks_per_run: 50` на первом прогоне. Парсер
|
||||
сохраняет только обезличенные поля + название.
|
||||
|
||||
## Последствия
|
||||
|
||||
### Положительные
|
||||
|
||||
- Wikiloc — крупнейшая база эндуро-треков, существенно расширяет
|
||||
pool для пользователей ЦФО+Чувашии.
|
||||
- Тестовый паттерн «снимок HTML → unit-тест → парсер» переиспользуем
|
||||
для будущих скрейп-источников.
|
||||
- BRD ET-009 метрика «активирован новый источник Wikiloc» закрывается.
|
||||
|
||||
### Отрицательные / ограничения
|
||||
|
||||
- HTML-парсер потенциально хрупок (см. §7). Риск принят, митигация
|
||||
через тестовые фикстуры и health-эндпоинт.
|
||||
- Rate-limit 10 сек делает массовый сбор медленным (~25 мин для 50
|
||||
треков). Принципиально приемлемо для бизнес-кейса (треки —
|
||||
редко-меняющийся контент, не нужны realtime обновления).
|
||||
- IP mva154 потенциально может попасть в Wikiloc-ban. Митигация —
|
||||
graceful-stop + ручное отключение source при систематических 403.
|
||||
- Возможны дубликаты: один и тот же трек, выложенный на Wikiloc и
|
||||
EnduroRussia → merge через dedup-key (см. ADR-006). Проверяется
|
||||
тестом IT-DEDUP-01 (TRZ ET-009).
|
||||
|
||||
## Классификация изменения
|
||||
|
||||
**Minor change** на уровне ADR (новый source, существующий парсер,
|
||||
существующая инфра pipeline). Активация — ET-009 ADR-013.
|
||||
|
||||
## Связанные документы
|
||||
|
||||
- `docs/work-items/ET-008/01-brd.md` §4 «Юридический минимум»
|
||||
- `docs/work-items/ET-008/02-trz.md` REQ-F-05 (паттерн licensing-guard)
|
||||
- `docs/work-items/ET-008/06-adr/ADR-007-pipeline-architecture.md` §6
|
||||
- `docs/work-items/ET-008/06-adr/ADR-009-osm-licensing.md`
|
||||
- `docs/work-items/ET-008/06-adr/ADR-010-enduro-russia-licensing.md`
|
||||
- `docs/work-items/ET-008/06-adr/ADR-011-ttrails-licensing.md`
|
||||
- `docs/work-items/ET-009/01-brd.md` §4.2 «Wikiloc»
|
||||
- `docs/work-items/ET-009/02-trz.md` REQ-F-03, REQ-F-05
|
||||
- `docs/work-items/ET-009/06-adr/ADR-013-source-activation.md`
|
||||
348
docs/work-items/ET-009/06-adr/ADR-013-source-activation.md
Normal file
348
docs/work-items/ET-009/06-adr/ADR-013-source-activation.md
Normal file
@@ -0,0 +1,348 @@
|
||||
---
|
||||
type: adr
|
||||
work_item_id: ET-009
|
||||
adr_id: ADR-013
|
||||
title: "ADR-013: Активация двух новых GPS-источников (EnduroRussia + Wikiloc) — конфиг-only изменения поверх pipeline ET-008"
|
||||
status: accepted
|
||||
created_at: 2026-06-01
|
||||
updated_at: 2026-06-01
|
||||
authors:
|
||||
- "agent:architect"
|
||||
supersedes: []
|
||||
superseded_by: []
|
||||
labels:
|
||||
- "ET-009:activation"
|
||||
- "config-only"
|
||||
---
|
||||
|
||||
# ADR-013 — Активация EnduroRussia и Wikiloc в pipeline GPS-треков
|
||||
|
||||
## Статус
|
||||
|
||||
**Accepted.** Архитектурное решение для ET-009.
|
||||
|
||||
## Контекст
|
||||
|
||||
ET-008 построил pipeline сбора публичных GPS-треков:
|
||||
- docker-compose service `gps-collector` (`profiles: [batch]`);
|
||||
- per-source изоляция (ADR-007);
|
||||
- licensing-guard `_check_license_adr` (ADR-007 §6);
|
||||
- БД `data/gps_tracks.sqlite` (ADR-005);
|
||||
- API `/api/gps-tracks/*` (ADR-008);
|
||||
- парсеры `osm.py`, `enduro_russia.py`, `wikiloc.py`, `ttrails.py`.
|
||||
|
||||
На момент мерджа ET-008 (2026-06-01) активирован только `osm`
|
||||
(ADR-009 был `accepted`). `enduro_russia` и `ttrails` остались
|
||||
`enabled: false` (ADR-010 и ADR-011 в `proposed`). Парсер `wikiloc.py`
|
||||
был **разработан** в ET-008, но запись в `config/gps_sources.yaml`
|
||||
**не была добавлена** и ADR-012 не был создан.
|
||||
|
||||
ET-009 закрывает три гэпа:
|
||||
1. ADR-010 — `proposed → accepted` (EnduroRussia).
|
||||
2. ADR-012 — создан с `accepted` (Wikiloc).
|
||||
3. Конфиг + регионы + UI-стили — приведены в соответствие с новой
|
||||
реальностью «3 активных источника».
|
||||
|
||||
ADR-013 фиксирует **архитектурное решение об активации** как
|
||||
самостоятельное решение работ-айтема ET-009 (отдельно от licensing-ADR
|
||||
ET-008, которые описывают **что** разрешено сохранять и при каких
|
||||
условиях).
|
||||
|
||||
## Сценарий
|
||||
|
||||
ET-009 — **«конфиг-only активация»**: никакой новой инфраструктуры,
|
||||
никаких новых сервисов, никаких новых таблиц БД, никаких новых
|
||||
endpoints API. Только:
|
||||
|
||||
- правка `config/gps_sources.yaml` (URL fix, флаги enabled, новая запись wikiloc);
|
||||
- правка `config/gps_regions.yaml` (Wikiloc подписан на ЦФО+Чувашию);
|
||||
- расширение `wikiloc.py` поддержкой `max_tracks_per_run` (≤ 30 строк, см. TRZ REQ-F-03);
|
||||
- расширение `src/web/style.json` / `style-dark.json` цветами по `source` (REQ-F-13);
|
||||
- расширение клиента атрибуцией `enduro_russia` / `wikiloc` (REQ-F-14);
|
||||
- тестовые фикстуры + unit/integration-тесты;
|
||||
- ручной первый продакшн-прогон.
|
||||
|
||||
## Альтернативы и решения
|
||||
|
||||
### Решение A — Структура licensing-ADR
|
||||
|
||||
**Опция A1.** Положить ADR-012 в `docs/work-items/ET-009/06-adr/`.
|
||||
|
||||
**Опция A2 (выбрано).** Положить ADR-012 в `docs/work-items/ET-008/06-adr/`
|
||||
рядом с ADR-009/010/011, обновить ADR-010 там же.
|
||||
|
||||
**Обоснование.** Licensing-ADR — это **per-source documentation**,
|
||||
не per-work-item. ET-008 создал пакет licensing-ADR'ов (ADR-009 для
|
||||
OSM, ADR-010 для EnduroRussia, ADR-011 для ttrails); ADR-012 для
|
||||
Wikiloc логически принадлежит тому же пакету. ET-009 — **активатор**,
|
||||
не **законодатель источников**. Из ET-009 ADR-013 ссылается на ADR-010
|
||||
и ADR-012 как на «приняли вот эти условия».
|
||||
|
||||
Также `config/gps_sources.yaml::license_adr` указывает на конкретный
|
||||
файл; для Wikiloc TRZ ET-009 явно прописывает путь
|
||||
`docs/work-items/ET-008/06-adr/ADR-012-wikiloc-licensing.md`. Хранение
|
||||
в ET-008 устраняет необходимость cross-work-item ссылок в runtime
|
||||
конфиге pipeline.
|
||||
|
||||
### Решение B — Фикс URL `enduro-russia.ru` → `endurorussia.ru`
|
||||
|
||||
**Опция B1.** Считать это bug-fix'ом без отдельного ADR.
|
||||
|
||||
**Опция B2 (выбрано).** Документировать в этом ADR §3.
|
||||
|
||||
**Обоснование.** Парсер по умолчанию использует `endurorussia.ru`
|
||||
(см. `enduro_russia.py:45`). YAML-конфиг же содержит
|
||||
`enduro-russia.ru`. На момент `enabled: false` это работало бы
|
||||
криво (парсер брал бы default URL); при `enabled: true` мы получили
|
||||
бы баг R-4 (тогда же — баг в `external_url` сохранённых треков, см.
|
||||
BRD R-9). Фиксация решения «правильный URL — без дефиса» в ADR
|
||||
полезна как точка истории.
|
||||
|
||||
### Решение C — `max_tracks_per_run` в Wikiloc
|
||||
|
||||
**Опция C1.** Жёстко зашить cap = 50 в коде парсера.
|
||||
|
||||
**Опция C2 (выбрано).** Параметр в `gps_sources.yaml`, парсер читает
|
||||
через `self.config.get("max_tracks_per_run")`. Если не указан — без cap.
|
||||
|
||||
**Обоснование.** Cap в конфиге → cap легко менять без релиза кода.
|
||||
После первой стабильной серии прогонов оператор может поднять до 200
|
||||
или снять полностью.
|
||||
|
||||
Реализация — 8 строк в `wikiloc.py::collect()`:
|
||||
```python
|
||||
max_tracks = self.config.get("max_tracks_per_run")
|
||||
yielded = 0
|
||||
# ...
|
||||
if max_tracks is not None and yielded >= max_tracks:
|
||||
logger.info("Wikiloc: reached max_tracks_per_run=%d, stopping", max_tracks)
|
||||
return
|
||||
yielded += 1
|
||||
```
|
||||
|
||||
### Решение D — Динамический UI-фильтр источников
|
||||
|
||||
**Опция D1.** Захардкодить список источников в HTML (`#gps-source-grid`).
|
||||
|
||||
**Опция D2 (выбрано).** Клиент строит фильтр из ответа
|
||||
`/api/gps-tracks/health.tracks_by_source` (источники, у которых > 0
|
||||
треков в БД). Маппинг `source_id → label` — JS-константа.
|
||||
|
||||
**Обоснование.** На момент первого открытия страницы (`tracks_by_source`
|
||||
содержит только `osm`), UI показывает только OSM-чекбокс. После первого
|
||||
прогона ET-009 — все 3 чекбокса. Активация четвёртого источника
|
||||
(`ttrails` в будущем) не требует изменений в UI-коде.
|
||||
|
||||
### Решение E — Source priorities
|
||||
|
||||
| Source | source_priority | Смысл |
|
||||
|---|---|---|
|
||||
| `osm` | 100 | Самый авторитетный; первая ссылка в `external_urls` |
|
||||
| `enduro_russia` | 80 | Тематическая платформа эндуро в РФ |
|
||||
| `wikiloc` | 70 | Глобальная платформа, ниже из-за HTML-парсинга |
|
||||
| `ttrails` | 60 (потенциально) | Будет настроен при активации |
|
||||
|
||||
Применение: при dedup-merge метаданные с большим `source_priority`
|
||||
перекрывают (ADR-006 ET-008). `sources_json` упорядочен по убыванию
|
||||
priority.
|
||||
|
||||
**Решение:** оставить как в ET-008 (без изменений в этой части кода).
|
||||
|
||||
## Решение
|
||||
|
||||
### 1. ADR licensing — обновить и создать
|
||||
|
||||
| ADR | Действие | Файл |
|
||||
|---|---|---|
|
||||
| ADR-010 | `proposed → accepted` | `docs/work-items/ET-008/06-adr/ADR-010-enduro-russia-licensing.md` |
|
||||
| ADR-012 | новый, `accepted` | `docs/work-items/ET-008/06-adr/ADR-012-wikiloc-licensing.md` |
|
||||
| ADR-011 | без изменений (`proposed`) | `docs/work-items/ET-008/06-adr/ADR-011-ttrails-licensing.md` |
|
||||
| ADR-009 | без изменений (`accepted`) | `docs/work-items/ET-008/06-adr/ADR-009-osm-licensing.md` |
|
||||
| ADR-013 (этот) | новый, `accepted` | `docs/work-items/ET-009/06-adr/ADR-013-source-activation.md` |
|
||||
|
||||
### 2. Конфиг — финальное состояние `config/gps_sources.yaml`
|
||||
|
||||
```yaml
|
||||
sources:
|
||||
- id: osm
|
||||
name: "OSM Public GPS Traces"
|
||||
enabled: true
|
||||
license_adr: "docs/work-items/ET-008/06-adr/ADR-009-osm-licensing.md"
|
||||
base_url: "https://api.openstreetmap.org/api/0.6"
|
||||
rate_limit_sec: 1
|
||||
user_agent: "enduro-trails/1.0 (+https://openclaw.mva154.duckdns.org/enduro/)"
|
||||
attribution: "© OpenStreetMap contributors (ODbL)"
|
||||
parser_module: "src.api.gps_tracks.sources.osm"
|
||||
save_user_field: true
|
||||
external_url_template: "https://www.openstreetmap.org/user/{user}/traces/{external_id_numeric}"
|
||||
|
||||
- id: enduro_russia
|
||||
name: "EnduroRussia.ru"
|
||||
enabled: true # FIX: было false
|
||||
license_adr: "docs/work-items/ET-008/06-adr/ADR-010-enduro-russia-licensing.md"
|
||||
base_url: "https://endurorussia.ru" # FIX: было https://enduro-russia.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
|
||||
|
||||
- id: wikiloc # NEW
|
||||
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]
|
||||
max_tracks_per_run: 50
|
||||
|
||||
- id: ttrails
|
||||
name: "Тропинки.ру"
|
||||
enabled: false # NOT CHANGED in ET-009
|
||||
license_adr: "docs/work-items/ET-008/06-adr/ADR-011-ttrails-licensing.md"
|
||||
base_url: "https://ttrails.ru"
|
||||
rate_limit_sec: 5
|
||||
user_agent: "enduro-trails/1.0 (+https://openclaw.mva154.duckdns.org/enduro/)"
|
||||
attribution: "ttrails.ru"
|
||||
parser_module: "src.api.gps_tracks.sources.ttrails"
|
||||
save_user_field: false
|
||||
```
|
||||
|
||||
### 3. Регионы — финальное состояние `config/gps_regions.yaml`
|
||||
|
||||
```yaml
|
||||
regions:
|
||||
- id: tsfo_plus_chuvashia
|
||||
name: "ЦФО + Чувашия"
|
||||
bbox: [29.0, 49.5, 47.5, 60.0]
|
||||
enabled: true
|
||||
sources: [osm, enduro_russia, wikiloc, ttrails] # +wikiloc
|
||||
|
||||
- id: north_caucasus
|
||||
name: "Северный Кавказ"
|
||||
bbox: [37.0, 41.5, 49.0, 47.0]
|
||||
enabled: false # NOT CHANGED
|
||||
sources: [osm, enduro_russia]
|
||||
```
|
||||
|
||||
Замечание: `ttrails` остаётся в списке `sources`, но pipeline-guard
|
||||
автоматически пропустит его (`enabled: false` в sources.yaml + ADR-011
|
||||
в `proposed`).
|
||||
|
||||
### 4. Парсер Wikiloc — расширение `max_tracks_per_run`
|
||||
|
||||
В `src/api/gps_tracks/sources/wikiloc.py::WikilocParser.collect()`
|
||||
добавляется счётчик и проверка cap. Изменение локализованное (≤ 8
|
||||
строк), не затрагивает API парсера или сигнатуру методов.
|
||||
|
||||
### 5. UI-стили — цвета по источнику
|
||||
|
||||
В `src/web/style.json` и `src/web/style-dark.json` слой `gps-tracks-layer`
|
||||
получает match-expression:
|
||||
```json
|
||||
["match", ["get", "source"],
|
||||
"osm", "#3cb44b",
|
||||
"enduro_russia", "#e6194b",
|
||||
"wikiloc", "#4363d8",
|
||||
"#808080"]
|
||||
```
|
||||
|
||||
Halo-слой `gps-tracks-halo-satellite` остаётся белым полупрозрачным
|
||||
(unchanged).
|
||||
|
||||
### 6. UI-атрибуция
|
||||
|
||||
В `src/web/gps_tracks.js` (или клиентский модуль ET-008) маппинг
|
||||
`SOURCE_ATTRIBUTIONS` расширяется значениями для `enduro_russia` и
|
||||
`wikiloc`. MapLibre Attribution control обновляется при изменении
|
||||
`/api/gps-tracks/health.tracks_by_source`.
|
||||
|
||||
### 7. Тесты
|
||||
|
||||
Полный список — TRZ ET-009 §3 (REQ-F-06..F-12). Новые файлы:
|
||||
- `tests/unit/test_gps_tracks_enduro_russia.py` (UT-ER-01..08);
|
||||
- `tests/unit/test_gps_tracks_wikiloc.py` (UT-WL-01..10);
|
||||
- `tests/integration/test_pipeline_et009.py` (IT-ER-01, IT-WL-01,
|
||||
IT-WL-02, IT-DEDUP-01, IT-LIC-01);
|
||||
- `tests/contract/test_endurorussia_api_smoke.py` (CT-ER-01, CT-ER-02,
|
||||
маркер `@pytest.mark.network`);
|
||||
- 7 файлов фикстур в `tests/fixtures/gps-tracks/`.
|
||||
|
||||
### 8. Деплой
|
||||
|
||||
Без изменений в `docker-compose.yml`, `Dockerfile`, `nginx`, cron.
|
||||
После merge — стандартный `docker compose up -d --no-deps app`. Pipeline
|
||||
запускается **вручную** оператором по runbook'у в `14-deploy-log.md`.
|
||||
Автоматический cron включается отдельным DevOps-task'ом после двух
|
||||
успешных ручных прогонов подряд (out of ET-009 scope, BRD §3).
|
||||
|
||||
## Последствия
|
||||
|
||||
### Положительные
|
||||
|
||||
- **Минимальная инфра-нагрузка.** Никаких новых контейнеров, БД, env,
|
||||
секретов, портов, nginx-правил.
|
||||
- **Высокая обратимость.** Откат активации одного источника = `enabled:
|
||||
false` без редеплоя.
|
||||
- **Источник истины** для конфигов — в репозитории; деплой
|
||||
воспроизводим.
|
||||
- **Покрытие тестами** новых источников + интеграционный тест
|
||||
licensing-guard'а через mock-ADR с `proposed`-статусом.
|
||||
|
||||
### Отрицательные / ограничения
|
||||
|
||||
- **Wikiloc HTML-парсер** — потенциально хрупок (R-1 из ET-008
|
||||
tech-risks). Митигация — фикстуры + health-эндпоинт + быстрое
|
||||
отключение через конфиг.
|
||||
- **IP mva154 банится Wikiloc'ом** — средняя вероятность; митигация —
|
||||
graceful-stop + `max_tracks_per_run` cap + ручной мониторинг
|
||||
первых 3 прогонов (см. tech-risks ET-009 R-2).
|
||||
- **Удаление дефиса в `enduro-russia.ru` URL** — для **новых** треков
|
||||
работает «из коробки»; для **существующих** треков в БД (если есть
|
||||
snapshot до фикса) могут остаться `external_urls` с дефисом. Это
|
||||
опциональный one-shot fix (BRD R-9), не блокирующий ET-009.
|
||||
- **Размер БД** вырастет с ~5 MB (только OSM) до ~10–50 MB после
|
||||
первого прогона. Хорошо в пределах REQ-NF-03 ≤ 2 GB.
|
||||
- **Cron автоматизация** отложена до отдельного DevOps-task'а. Это
|
||||
**сознательное замедление** — даём оператору проверить три прогона
|
||||
вручную перед автоматизацией.
|
||||
|
||||
## Классификация изменения
|
||||
|
||||
**Minor change** на уровне инфраструктуры (никаких новых компонентов).
|
||||
**Minor change** на уровне ADR (status-flip + новый licensing-ADR с
|
||||
identical-pattern).
|
||||
|
||||
Лейбл `arch:major-change` **не выставляется** — изменение не вводит
|
||||
новых архитектурных компонентов, только активирует существующие.
|
||||
|
||||
## Невыполнимость / эскалация
|
||||
|
||||
ETC-009 не требует архитектурной эскалации. Если на момент работы:
|
||||
1. ADR-010 или ADR-012 оказались бы в `proposed`/`rejected` →
|
||||
разработка останавливается (`back-to:analysis`).
|
||||
2. Wikiloc систематически возвращает 403 на mva154 в первые три прогона →
|
||||
`enabled: false` + новый ADR-update «Wikiloc deprecated».
|
||||
3. EnduroRussia API возвращает 5xx в первые три прогона → диагностика
|
||||
через `pipeline_runs.errors_json`; при подтверждении сторонних
|
||||
проблем — wait-and-see, source остаётся `enabled: true`.
|
||||
|
||||
## Связанные документы
|
||||
|
||||
- `docs/work-items/ET-009/01-brd.md` §2, §3, §5
|
||||
- `docs/work-items/ET-009/02-trz.md` REQ-F-01..F-20
|
||||
- `docs/work-items/ET-009/03-acceptance-criteria.md` AC-01..AC-20
|
||||
- `docs/work-items/ET-009/07-infra-requirements.md` (этот work item)
|
||||
- `docs/work-items/ET-009/08-data-requirements.md` (этот work item)
|
||||
- `docs/work-items/ET-009/10-tech-risks.md` (этот work item)
|
||||
- `docs/work-items/ET-008/06-adr/ADR-007-pipeline-architecture.md` §6
|
||||
- `docs/work-items/ET-008/06-adr/ADR-010-enduro-russia-licensing.md`
|
||||
- `docs/work-items/ET-008/06-adr/ADR-012-wikiloc-licensing.md`
|
||||
- `docs/architecture/README.md` (обновлён в ET-009)
|
||||
- `docs/architecture/adr/README.md` (обновлён в ET-009)
|
||||
300
docs/work-items/ET-009/07-infra-requirements.md
Normal file
300
docs/work-items/ET-009/07-infra-requirements.md
Normal file
@@ -0,0 +1,300 @@
|
||||
---
|
||||
type: infra-requirements
|
||||
work_item_id: ET-009
|
||||
title: "Инфраструктурные требования — ET-009: Активация EnduroRussia + Wikiloc"
|
||||
version: 1
|
||||
status: approved
|
||||
created_at: 2026-06-01
|
||||
authors:
|
||||
- "agent:architect"
|
||||
---
|
||||
|
||||
# Инфраструктурные требования — ET-009
|
||||
|
||||
## 1. Резюме
|
||||
|
||||
ET-009 — **конфиг-only активация** двух дополнительных источников
|
||||
GPS-треков в pipeline ET-008. Инфраструктура **не меняется**:
|
||||
|
||||
- Никаких новых docker-сервисов;
|
||||
- Никаких новых файлов БД;
|
||||
- Никаких новых cron-записей (cron автоматизация — отдельный DevOps-task);
|
||||
- Никаких новых env-переменных, секретов, ключей;
|
||||
- Никаких новых портов и nginx-правил.
|
||||
|
||||
Все изменения — текстовые правки конфигов и тестовых артефактов плюс
|
||||
один ручной первый прогон pipeline на mva154.
|
||||
|
||||
Эскалация: **minor change** (см. ADR-013 §«Классификация»).
|
||||
|
||||
## 2. Контейнеры и сервисы
|
||||
|
||||
| Аспект | Требование |
|
||||
|---|---|
|
||||
| Новый сервис `gps-collector` | Уже существует (ET-008). **Без изменений.** |
|
||||
| Изменения `Dockerfile` | Нет |
|
||||
| Изменения `docker-compose.yml` | Нет |
|
||||
| Перезапуск API после деплоя | Нужен — `docker compose up -d --no-deps app` (≈ 5 сек простоя). Перезапуск нужен только потому, что `src/web/*.json` подаётся API-контейнером; обновлённые `style.json` / `style-dark.json` подхватываются после рестарта |
|
||||
| Перезапуск `gps-collector` | Не applicable (не daemon). Следующий запуск через `docker compose --profile batch run --rm gps-collector ...` уже использует новый конфиг через примонтированный `config/` volume |
|
||||
|
||||
### 2.1 Зависимости между сервисами
|
||||
|
||||
Без изменений vs ET-008. `gps-collector` ↔ `app` коммуницируют через
|
||||
docker-internal HTTP при cache-clear; этот контракт уже существует и
|
||||
ET-009 его не трогает.
|
||||
|
||||
## 3. Сеть
|
||||
|
||||
| Аспект | Требование |
|
||||
|---|---|
|
||||
| Новые входящие порты | Нет |
|
||||
| Изменения nginx | Нет |
|
||||
| Новые исходящие HTTPS-соединения с mva154 | **Да** — две новых dest: `endurorussia.ru` (443) и `www.wikiloc.com` (443) |
|
||||
| Firewall mva154 | Исходящие HTTPS уже разрешены (ET-008 §3, BRD §7). Дополнительных правил не нужно |
|
||||
| DNS-резолвинг | Стандартный (системный resolver Docker). Никаких записей в `/etc/hosts` |
|
||||
|
||||
### 3.1 Изменение dest IP
|
||||
|
||||
**Перед ET-009** контейнер `gps-collector` обращался только к:
|
||||
- `api.openstreetmap.org` (ADR-009);
|
||||
|
||||
**После ET-009** добавляются:
|
||||
- `endurorussia.ru` (ADR-010 accepted);
|
||||
- `www.wikiloc.com` (ADR-012 accepted).
|
||||
|
||||
Все три — стандартный HTTPS, без проксей и кастомных сертификатов.
|
||||
|
||||
### 3.2 Ограничение rate
|
||||
|
||||
| Источник | Rate-limit | Trafic за прогон | Пик |
|
||||
|---|---|---|---|
|
||||
| OSM | 1 req/sec | ≈ 100 МБ | без изменений |
|
||||
| EnduroRussia | 5 sec / req | ≈ 30 МБ (≤ 305 треков × ~50 КБ + json list) | 1 req / 5 сек |
|
||||
| Wikiloc | 10 sec / req | ≈ 5 МБ (≤ 50 треков × 3 req × ~30 КБ) | 1 req / 10 сек |
|
||||
| **Итого пиковый egress mva154** | ≈ 0.1 req/sec суммарно | ≤ 150 МБ / прогон | пренебрежимо |
|
||||
|
||||
Влияния на пропускную способность mva154 нет.
|
||||
|
||||
## 4. Хранилища данных
|
||||
|
||||
| Аспект | Требование |
|
||||
|---|---|
|
||||
| Новые БД | Нет |
|
||||
| Изменения схемы | Нет |
|
||||
| Миграции | Нет |
|
||||
| Изменения объёма `data/gps_tracks.sqlite` | +20–50 МБ ожидаемо (≤ 200 треков EnduroRussia × ~50 КБ + ≤ 50 треков Wikiloc × ~50 КБ + метаданные) |
|
||||
| Лимит REQ-NF-03 (`08-data-requirements.md` ET-008) | 2 ГБ — далеко не достигнут |
|
||||
| Backup `.sqlite` | Без изменений (тот же `cron`-скрипт, см. ET-008 §4.4) |
|
||||
|
||||
### 4.1 Опциональный one-shot fix старого URL
|
||||
|
||||
Если в БД test-сервера остались записи с старым `external_url` (с
|
||||
дефисом `enduro-russia.ru`) — оператор может выполнить **один раз**
|
||||
после первого прогона ET-009:
|
||||
|
||||
```sql
|
||||
UPDATE tracks
|
||||
SET external_urls_json = REPLACE(external_urls_json,
|
||||
'enduro-russia.ru',
|
||||
'endurorussia.ru')
|
||||
WHERE external_urls_json LIKE '%enduro-russia.ru%';
|
||||
```
|
||||
|
||||
На практике, поскольку до ET-009 `enduro_russia` был `enabled: false`,
|
||||
**таких записей нет**. Скрипт — defensive, не обязательный (BRD R-9).
|
||||
|
||||
### 4.2 Backup retention
|
||||
|
||||
Без изменений. Ежедневный snapshot, 14 дней retention.
|
||||
|
||||
## 5. Конфигурация и секреты
|
||||
|
||||
| Аспект | Требование |
|
||||
|---|---|
|
||||
| Новые env-переменные | **Нет** |
|
||||
| Новые секреты / API-ключи | **Нет** (EnduroRussia и Wikiloc — без авторизации) |
|
||||
| Новые конфиг-файлы | Нет; меняется только содержимое существующих `config/gps_sources.yaml` и `config/gps_regions.yaml` |
|
||||
|
||||
### 5.1 Изменения `config/gps_sources.yaml`
|
||||
|
||||
См. ADR-013 §«Решение 2» — финальное содержимое. Изменения:
|
||||
- `enduro_russia.base_url`: `https://enduro-russia.ru` → `https://endurorussia.ru` (без дефиса);
|
||||
- `enduro_russia.enabled`: `false` → `true`;
|
||||
- `enduro_russia.source_priority`: добавлено `80` (раньше отсутствовало, default fall-back в коде);
|
||||
- новая запись `wikiloc` (15 строк).
|
||||
|
||||
### 5.2 Изменения `config/gps_regions.yaml`
|
||||
|
||||
См. ADR-013 §«Решение 3». В `tsfo_plus_chuvashia.sources` добавляется
|
||||
`wikiloc`.
|
||||
|
||||
## 6. Зависимости
|
||||
|
||||
| Аспект | Требование |
|
||||
|---|---|
|
||||
| Новые Python-пакеты | **Нет** (defusedxml, httpx, shapely, pyyaml — все есть из ET-008) |
|
||||
| Системные библиотеки в Dockerfile | Нет |
|
||||
| Версия Python | 3.12, без изменений |
|
||||
| Внешние runtime-зависимости (источники) | `endurorussia.ru` + `www.wikiloc.com` (см. §3.1) |
|
||||
| Pinned-версии библиотек | Без изменений |
|
||||
|
||||
## 7. Сборка и деплой
|
||||
|
||||
### 7.1 Pipeline CI
|
||||
|
||||
Существующий Gitea Actions:
|
||||
- `make lint` (ruff + eslint) — должен пройти без замечаний;
|
||||
- `make test` — должен включать новые тесты UT-ER-*, UT-WL-*, IT-*;
|
||||
- `make build` — пересобирает образ (никаких изменений в Dockerfile,
|
||||
но новые тестовые фикстуры и конфиги попадают в образ).
|
||||
|
||||
### 7.2 Деплой шаг-за-шагом
|
||||
|
||||
1. `git pull origin main` на mva154.
|
||||
2. `docker compose build` (опционально; никаких изменений
|
||||
в Dockerfile/requirements не было, но сборка идемпотентна и
|
||||
быстрая).
|
||||
3. `docker compose up -d --no-deps app` — рестарт API (≈ 5 сек простоя)
|
||||
для подхвата обновлённых `style.json` / `style-dark.json` и client-side
|
||||
JS (если изменился `gps_tracks.js`).
|
||||
4. **Первый ручной прогон EnduroRussia:**
|
||||
```bash
|
||||
docker compose --profile batch run --rm gps-collector \
|
||||
python -m scripts.gps_collect \
|
||||
--region tsfo_plus_chuvashia --source enduro_russia
|
||||
```
|
||||
Ожидаемая длительность: 20–30 минут. Ожидаемый результат:
|
||||
`tracks_new ≥ 200`, `status: ok`.
|
||||
5. **Первый ручной прогон Wikiloc:**
|
||||
```bash
|
||||
docker compose --profile batch run --rm gps-collector \
|
||||
python -m scripts.gps_collect \
|
||||
--region tsfo_plus_chuvashia --source wikiloc
|
||||
```
|
||||
Ожидаемая длительность: 10–25 минут (cap `max_tracks_per_run=50`).
|
||||
Ожидаемый результат: `tracks_new ≥ 1`, `status: ok | partial`.
|
||||
6. Проверить `/api/gps-tracks/health` — `tracks_by_source` содержит
|
||||
ключи `enduro_russia` и `wikiloc` с ненулевыми значениями.
|
||||
7. Smoke в UI: открыть `/enduro/`, включить «Публичные треки»,
|
||||
проверить три чекбокса источников и атрибуции.
|
||||
8. Зафиксировать результат в `docs/work-items/ET-009/14-deploy-log.md`.
|
||||
|
||||
### 7.3 Время простоя
|
||||
|
||||
API: ≤ 5 секунд на шаге 3 (стандартный рестарт контейнера).
|
||||
Pipeline: ≈ 50 минут (последовательные ручные прогоны двух источников).
|
||||
Pipeline-простой **не влияет** на API; оба независимы.
|
||||
|
||||
### 7.4 Cron включается отдельным task'ом
|
||||
|
||||
ET-009 **не** активирует автоматический cron. После двух успешных
|
||||
ручных прогонов подряд DevOps вручную раскомментирует cron-записи
|
||||
из ET-008 (`/etc/cron.d/enduro-gps`).
|
||||
|
||||
### 7.5 Rollback
|
||||
|
||||
| Сценарий | Действие | Время |
|
||||
|---|---|---|
|
||||
| Откат конфигов (вернуть `enabled: false`) | `git revert <commit>` + `docker compose up -d --no-deps app` | ≈ 2 мин |
|
||||
| Откат БД (если новые источники запортили данные) | `cp backups/gps_tracks-<date>.sqlite data/gps_tracks.sqlite` + рестарт API | ≈ 1 мин |
|
||||
| Точечное удаление источника без отката кода | Открыть `config/gps_sources.yaml` на mva154, выставить `enabled: false`, рестарт API (cache-clear) | ≈ 1 мин |
|
||||
| Удаление треков конкретного источника | `DELETE FROM tracks WHERE sources_json LIKE '%<id>%'` (через ssh + sqlite3) | ≈ 1 мин |
|
||||
|
||||
## 8. Cron / scheduled jobs
|
||||
|
||||
**Нет** в ET-009. Cron активируется отдельным DevOps-task'ом после
|
||||
ETC-009 (см. §7.4).
|
||||
|
||||
## 9. Ресурсы (CPU / RAM / диск)
|
||||
|
||||
### 9.1 API-контейнер
|
||||
|
||||
Никаких изменений. Дополнительные source-ID не нагружают endpoint
|
||||
(только новые значения в `properties.sources`).
|
||||
|
||||
### 9.2 gps-collector контейнер (во время прогона)
|
||||
|
||||
| Метрика | EnduroRussia | Wikiloc | OSM (для сравнения) |
|
||||
|---|---|---|---|
|
||||
| CPU (peak) | < 5% от 1 vCPU | < 5% от 1 vCPU | < 10% |
|
||||
| RAM (peak) | ≤ 150 МБ | ≤ 150 МБ | ≤ 200 МБ |
|
||||
| Network egress | ≈ 30 МБ | ≈ 5 МБ | ≈ 100 МБ |
|
||||
| Длительность | 20–30 мин | 10–25 мин | 1–3 часа (ЦФО) |
|
||||
| Disk write rate | низкий (≤ 1 МБ/мин) | низкий | средний |
|
||||
|
||||
Все три параллельно `gps-collector` cgroup-limit'ы (`cpus: 1.0`,
|
||||
`mem_limit: 512m`) — никаких изменений по сравнению с ET-008.
|
||||
|
||||
### 9.3 Диск
|
||||
|
||||
Прирост `data/gps_tracks.sqlite` после первого прогона ET-009:
|
||||
+20–50 МБ. Снимок backup того же объёма. Не влияет на disk budget.
|
||||
|
||||
## 10. Наблюдаемость
|
||||
|
||||
| Артефакт | Состояние после ET-009 |
|
||||
|---|---|
|
||||
| `GET /api/gps-tracks/health` | Возвращает `tracks_by_source = {osm, enduro_russia, wikiloc}` после первых прогонов |
|
||||
| `/var/log/enduro-trails/gps-collect.log` | Логи ручных прогонов (через `>> ... 2>&1` при ssh) |
|
||||
| `pipeline_runs` в БД | Новые записи для `source_id ∈ {enduro_russia, wikiloc}` |
|
||||
| Docker `docker compose logs app` | Без изменений |
|
||||
|
||||
### 10.1 Алерты
|
||||
|
||||
Нет новых алертов. Существующие правила ET-008 (cron MAILTO,
|
||||
db_size_mb > 2 ГБ) применяются как есть.
|
||||
|
||||
Опционально (out of scope ET-009): добавить ручную проверку
|
||||
`/api/gps-tracks/health` в еженедельный operations-review для двух
|
||||
новых источников.
|
||||
|
||||
### 10.2 Logrotate
|
||||
|
||||
Без изменений.
|
||||
|
||||
## 11. Безопасность
|
||||
|
||||
Никаких изменений по security-модели по сравнению с ET-008:
|
||||
- XML-парсинг GPX через `defusedxml.ElementTree`;
|
||||
- скрейпинг — только outgoing;
|
||||
- cache-clear endpoint остаётся docker-internal через nginx allow/deny.
|
||||
|
||||
### 11.1 Новые atack-vectors
|
||||
|
||||
| Vector | Митигация |
|
||||
|---|---|
|
||||
| Wikiloc возвращает malformed HTML с XSS-payload | Парсер использует regex, не интерпретирует HTML как DOM. JS не исполняется на сервере. Любой malformed HTML — `0 треков` без падения |
|
||||
| EnduroRussia API возвращает malformed JSON | `httpx.Response.json()` бросает exception → graceful return из generator |
|
||||
| Wikiloc / EnduroRussia возвращают XML-bomb в GPX | `defusedxml` блокирует billion-laughs (наследуется из ET-008) |
|
||||
| Поддельный 403 от Wikiloc → DoS pipeline | Graceful-stop ≠ ошибка; следующий прогон попробует снова. Cron-окно (3 дня) > recovery-окна (часы) |
|
||||
|
||||
## 12. Влияние на C4 / архитектурную документацию
|
||||
|
||||
Изменения для отражения в `docs/architecture/README.md`:
|
||||
|
||||
- Таблица «Внешние источники pipeline» (lines 53-58 в текущем README):
|
||||
- `EnduroRussia.ru`: `ADR-010 (proposed/blocked)` → `ADR-010 (accepted)`;
|
||||
- добавить строку `Wikiloc | HTML + GPX | proprietary (некоммерческое использование) | ADR-012 (accepted) | да`;
|
||||
- `ttrails.ru`: без изменений.
|
||||
|
||||
Изменения для отражения в `docs/architecture/adr/README.md`:
|
||||
|
||||
- ADR-010: `status` updated to `accepted`;
|
||||
- ADR-012: новая строка таблицы;
|
||||
- ADR-013: новая строка таблицы (с ссылкой на ET-009).
|
||||
|
||||
C4 mmd-диаграмм в проекте нет (ET-008 §12 явно зафиксировано). ET-009
|
||||
не создаёт диаграмм — изменение «активация existing source»
|
||||
выражается в текстовом README.
|
||||
|
||||
## 13. Вывод
|
||||
|
||||
ET-009 — **minimal-change** на инфра-уровне:
|
||||
- 0 новых сервисов / 0 новых БД / 0 новых cron / 0 новых env / 0 новых портов;
|
||||
- Все изменения локализованы в `config/*.yaml`, `src/web/style*.json`,
|
||||
тестовых фикстурах и `src/api/gps_tracks/sources/wikiloc.py` (8 строк
|
||||
для `max_tracks_per_run`);
|
||||
- Деплой = git pull + рестарт API + один ручной прогон;
|
||||
- Rollback = `git revert` или ssh-правка `enabled: false`.
|
||||
|
||||
Эскалация: **не требуется** (`arch:major-change` не выставлен, ADR-013 §«Классификация»).
|
||||
376
docs/work-items/ET-009/08-data-requirements.md
Normal file
376
docs/work-items/ET-009/08-data-requirements.md
Normal file
@@ -0,0 +1,376 @@
|
||||
---
|
||||
type: data-requirements
|
||||
work_item_id: ET-009
|
||||
title: "Требования к данным — ET-009: Активация EnduroRussia + Wikiloc"
|
||||
version: 1
|
||||
status: approved
|
||||
created_at: 2026-06-01
|
||||
authors:
|
||||
- "agent:architect"
|
||||
---
|
||||
|
||||
# Требования к данным — ET-009
|
||||
|
||||
## 1. Резюме
|
||||
|
||||
ET-009 — **активация** двух уже разработанных source-парсеров. Никаких
|
||||
изменений в схеме БД, контрактах API, формате localStorage или
|
||||
dedup-алгоритме.
|
||||
|
||||
**Меняются:**
|
||||
- Содержимое существующей таблицы `tracks` (новые записи с
|
||||
`source_id ∈ {enduro_russia, wikiloc}`);
|
||||
- Содержимое существующей таблицы `pipeline_runs` (новые записи с
|
||||
`source_id ∈ {enduro_russia, wikiloc}`);
|
||||
- Содержимое `config/gps_sources.yaml`, `config/gps_regions.yaml`;
|
||||
- Содержимое `src/web/style.json`, `style-dark.json` (match-expressions
|
||||
по `source`).
|
||||
|
||||
**Не меняются:**
|
||||
- Schema `tracks`, `pipeline_runs`;
|
||||
- API контракты `/api/gps-tracks*`;
|
||||
- localStorage ключи и значения;
|
||||
- Dedup-алгоритм (`compute_dedup_key`);
|
||||
- ACTIVITY_TYPES enum.
|
||||
|
||||
## 2. Архитектурные границы данных
|
||||
|
||||
| Слой данных | Тип | Расположение | Изменения в ET-009 |
|
||||
|---|---|---|---|
|
||||
| OSM-vector (`trails`, `centralfederal.sqlite`) | существующий | `/app/data/centralfederal.sqlite` | **нет** |
|
||||
| Личные GPX треки (ET-006) | существующий | браузер (memory) | **нет** |
|
||||
| Публичные GPS треки (ET-008) | существующий | `/app/data/gps_tracks.sqlite` | **+новые записи** из новых источников |
|
||||
| OSRM-граф | существующий | `/app/data/enduro.osrm.*` | **нет** |
|
||||
| User UI state | существующий | `localStorage` | **нет** новых ключей |
|
||||
|
||||
## 3. Серверные данные — `gps_tracks.sqlite`
|
||||
|
||||
### 3.1 Schema
|
||||
|
||||
**Без изменений vs ET-008.** См. `docs/work-items/ET-008/08-data-requirements.md`
|
||||
§3.1, §3.5. Никаких ALTER TABLE / DROP COLUMN / INDEX CREATE не делается.
|
||||
|
||||
### 3.2 Новые записи в `tracks`
|
||||
|
||||
| Поле | Значение для `source_id='enduro_russia'` | Значение для `source_id='wikiloc'` |
|
||||
|---|---|---|
|
||||
| `dedup_key` | вычислено `compute_dedup_key` | вычислено `compute_dedup_key` |
|
||||
| `name` | из JSON `meta.name` | из HTML `<h1>` или GPX metadata/name |
|
||||
| `description` | nullable (ADR-010: сохраняем) | **null** (ADR-012: `save_description: false`) |
|
||||
| `activity_type` | из MAPPING (`difficulty → enduro/moto`) | из MAPPING (`motorcycle → moto`, `enduro → enduro`) |
|
||||
| `user` | **null** (ADR-010: `save_user_field: false`) | **null** (ADR-012) |
|
||||
| `created_at` | из JSON `meta.created_at` (если есть) | nullable |
|
||||
| `length_m`, `points_count` | вычислено из GPX | вычислено из GPX |
|
||||
| `min_lon..max_lat` | вычислено | вычислено |
|
||||
| `geom` | WKB LineString | WKB LineString |
|
||||
| `sources_json` | `["enduro_russia"]` или `["enduro_russia", ...]` после merge | `["wikiloc"]` или `[..., "wikiloc"]` |
|
||||
| `external_urls_json` | `["https://endurorussia.ru/tracks/<id>"]` | `["https://www.wikiloc.com/trails/<slug>/<id>"]` |
|
||||
| `tags_json` | `[]` (источник не отдаёт tags) | `[]` |
|
||||
| `inserted_at`, `updated_at` | NOW() | NOW() |
|
||||
|
||||
### 3.3 Dedup-key — без изменений
|
||||
|
||||
Алгоритм `compute_dedup_key` (ADR-006) не меняется. Применяется к
|
||||
трекам из всех источников.
|
||||
|
||||
**Ожидаемое поведение для пары (osm-трек, enduro_russia-трек, wikiloc-трек)**
|
||||
из одной поездки:
|
||||
- Одинаковые `(bbox_quantized, length_bucket, date)` → одинаковый `dedup_key`;
|
||||
- Upsert ON CONFLICT → `sources_json` объединяется
|
||||
`["osm", "enduro_russia", "wikiloc"]` (порядок по `source_priority`
|
||||
descending);
|
||||
- `external_urls_json` синхронно объединяется.
|
||||
|
||||
См. ET-008 ADR-006 для деталей.
|
||||
|
||||
### 3.4 ACTIVITY_TYPES — без изменений
|
||||
|
||||
Enum остаётся прежним. MAPPING каждого source-парсера независимо
|
||||
переводит свои категории в этот enum.
|
||||
|
||||
| Source-категория | → ACTIVITY_TYPES |
|
||||
|---|---|
|
||||
| EnduroRussia: `enduro`, `hard`, `soft` | `enduro` |
|
||||
| EnduroRussia: `мото`, `тур` | `moto` |
|
||||
| EnduroRussia: `motorcycle` | `moto` |
|
||||
| EnduroRussia: `offroad` | `offroad` |
|
||||
| EnduroRussia: остальное | `enduro` (fallback в коде) |
|
||||
| Wikiloc (`act=19`): `motorcycle`, `enduro` | `moto` (default из `MAPPING['motorcycle']`) |
|
||||
| Wikiloc (`act=3`): `mtb`, `mountain biking` | `bicycle` |
|
||||
| Wikiloc: `hiking`, `running`, `trail running` | `hike` |
|
||||
| Wikiloc: `offroad` | `offroad` |
|
||||
| Wikiloc: неизвестное | `moto` (parser fallback) |
|
||||
|
||||
### 3.5 Новые записи в `pipeline_runs`
|
||||
|
||||
После первого прогона:
|
||||
|
||||
```sql
|
||||
SELECT id, source_id, status, tracks_new, finished_at - started_at
|
||||
FROM pipeline_runs
|
||||
ORDER BY id DESC LIMIT 5;
|
||||
```
|
||||
|
||||
Ожидаемо ≥ 2 новые строки:
|
||||
- `source_id='enduro_russia'`, `status='ok'` (или `partial`), `tracks_new ≥ 200`;
|
||||
- `source_id='wikiloc'`, `status ∈ {ok, partial, rate_limited}`, `tracks_new ≥ 1`.
|
||||
|
||||
`errors_json` — null или JSON-object `{HTTPError429: N, ...}` если
|
||||
были transient errors.
|
||||
|
||||
### 3.6 Размер БД — оценка после ET-009
|
||||
|
||||
| Источник | Треков | Средний размер записи | Итого |
|
||||
|---|---|---|---|
|
||||
| OSM (уже в БД) | ≤ 5000 | ≈ 21 КБ | ≤ 105 МБ |
|
||||
| EnduroRussia (новое) | ≈ 200–305 | ≈ 50 КБ (треки длиннее) | ≈ 10–15 МБ |
|
||||
| Wikiloc (новое) | ≈ 1–50 | ≈ 50 КБ | ≈ 0.5–2.5 МБ |
|
||||
| **Итого после ET-009** | ≤ 5400 | | ≤ 130 МБ |
|
||||
|
||||
Запас до операционного лимита (2 ГБ) — больше 15×.
|
||||
|
||||
### 3.7 GC и retention
|
||||
|
||||
Без изменений vs ET-008. Месячный GC через `--gc` (запускается
|
||||
отдельным cron'ом после двух успешных ручных прогонов).
|
||||
|
||||
### 3.8 Backup
|
||||
|
||||
Без изменений (см. `07-infra-requirements.md` §4.2).
|
||||
|
||||
## 4. Клиентское хранилище
|
||||
|
||||
### 4.1 Существующие ключи (ET-008) — без изменений
|
||||
|
||||
| Ключ | Значение | Замечания для ET-009 |
|
||||
|---|---|---|
|
||||
| `gps-tracks-enabled` | `"true"` \| `"false"` | без изменений |
|
||||
| `gps-tracks-activities` | JSON-array | без изменений |
|
||||
| `gps-tracks-sources` | JSON-array source IDs | **может содержать новые ID** после первого прогона; клиент сам подхватит. Defaults обновляются автоматически: при первом открытии после ET-009 — все 3 enabled источника попадают в default-набор |
|
||||
| `gps-tracks-color-mode` | `"source"` \| `"activity"` | без изменений |
|
||||
|
||||
### 4.2 Миграция defaults
|
||||
|
||||
При первом открытии страницы после ET-009 клиент видит, что
|
||||
`gps-tracks-sources` (если есть в `localStorage` со старым значением
|
||||
`["osm"]`) **не содержит** `enduro_russia` и `wikiloc`. Поведение
|
||||
ET-008:
|
||||
- Существующее значение `localStorage` сохраняется (пользователь
|
||||
сознательно мог выключить источники);
|
||||
- Новые источники появляются в UI-фильтре с галкой `unchecked`;
|
||||
- Пользователь может включить их вручную.
|
||||
|
||||
Это **компромисс UX**: автоматическое включение новых источников
|
||||
без согласия пользователя — нарушение принципа «без сюрпризов»;
|
||||
оставляем явный opt-in.
|
||||
|
||||
При желании оператора (нет в scope ET-009) — добавить one-shot
|
||||
migration в client-side JS: «если `gps-tracks-sources` существует и не
|
||||
содержит `enduro_russia` или `wikiloc` — добавить и пересохранить».
|
||||
**Не делаем в ET-009.**
|
||||
|
||||
### 4.3 Не-персистентное состояние
|
||||
|
||||
`window.gpsTracksLayer` (ET-008) — без изменений.
|
||||
|
||||
Маппинг `SOURCE_ATTRIBUTIONS` в `gps_tracks.js` расширяется:
|
||||
```js
|
||||
const SOURCE_ATTRIBUTIONS = {
|
||||
osm: "© OpenStreetMap contributors (ODbL)",
|
||||
enduro_russia: "EnduroRussia.ru",
|
||||
wikiloc: "© Wikiloc contributors",
|
||||
ttrails: "ttrails.ru", // для будущей активации
|
||||
};
|
||||
```
|
||||
|
||||
И маппинг `SOURCE_LABELS` для UI-чекбоксов:
|
||||
```js
|
||||
const SOURCE_LABELS = {
|
||||
osm: "OSM",
|
||||
enduro_russia: "EnduroRussia",
|
||||
wikiloc: "Wikiloc",
|
||||
ttrails: "ttrails.ru",
|
||||
};
|
||||
```
|
||||
|
||||
## 5. Внешние входные данные
|
||||
|
||||
### 5.1 OSM Public GPS Traces (ADR-009) — без изменений
|
||||
|
||||
См. `docs/work-items/ET-008/08-data-requirements.md` §5.1.
|
||||
|
||||
### 5.2 EnduroRussia.ru (ADR-010 accepted)
|
||||
|
||||
| Параметр | Значение |
|
||||
|---|---|
|
||||
| Endpoint list | `GET https://endurorussia.ru/api/tracks?page=N&limit=50` |
|
||||
| Endpoint GPX | `GET https://endurorussia.ru/api/tracks/{id}/gpx` |
|
||||
| Формат list | JSON `{items: [{id, name, difficulty, created_at}, ...], total}` |
|
||||
| Формат GPX | XML (GPX 1.1) — `<trk><trkseg><trkpt>` |
|
||||
| Лицензия | Public; ADR-010 §3 — обезличенно (без `user`) |
|
||||
| Атрибуция | `EnduroRussia.ru` |
|
||||
| Rate-limit | 5 sec / req |
|
||||
| Объём для ЦФО+Чувашии (оценка) | ≥ 200 треков |
|
||||
| User-Agent | `enduro-trails/1.0 (+https://openclaw.mva154.duckdns.org/enduro/)` |
|
||||
| Authentication | Нет |
|
||||
|
||||
### 5.3 Wikiloc (ADR-012 accepted)
|
||||
|
||||
| Параметр | Значение |
|
||||
|---|---|
|
||||
| Endpoint поиска | `GET https://www.wikiloc.com/wikiloc/find.do?act=<code>&sw=<lat,lon>&ne=<lat,lon>&page=<N>` |
|
||||
| Endpoint трека | `GET https://www.wikiloc.com/trails/<slug>/<id>` |
|
||||
| Endpoint GPX | `GET https://www.wikiloc.com/wikiloc/downloadTrail.do?id=<id>` |
|
||||
| Формат поиска | HTML (regex-extract `<a href="/trails/…/<id>">`) |
|
||||
| Формат трека | HTML (regex-extract `<h1>` для имени + ссылка на GPX) |
|
||||
| Формат GPX | XML (GPX 1.1) |
|
||||
| Лицензия | Proprietary (ADR-012 §3 — обезличенно, без description) |
|
||||
| Атрибуция | `© Wikiloc contributors` |
|
||||
| Rate-limit | **10 sec / req** (жёстко) |
|
||||
| Graceful-stop | На 403/429 — `return` без `raise` |
|
||||
| max_tracks_per_run | 50 (soft-cap первого прогона) |
|
||||
| User-Agent | `enduro-trails/1.0 (+https://openclaw.mva154.duckdns.org/enduro/)` |
|
||||
| Authentication | Нет |
|
||||
|
||||
### 5.4 ttrails.ru (ADR-011 proposed)
|
||||
|
||||
**Не используется в ET-009.** `enabled: false` в `gps_sources.yaml`,
|
||||
pipeline-guard пропускает.
|
||||
|
||||
## 6. Контракт публичного API
|
||||
|
||||
### 6.1 `GET /api/gps-tracks` — без изменений
|
||||
|
||||
Endpoint остаётся как в ET-008. Новые ID источников
|
||||
(`enduro_russia`, `wikiloc`) появляются в значениях:
|
||||
- `properties.sources` — массив `["enduro_russia"]` / `["wikiloc"]` /
|
||||
`["osm", "enduro_russia"]` (после dedup-merge);
|
||||
- `properties.external_urls` — `["https://endurorussia.ru/tracks/<id>"]` /
|
||||
`["https://www.wikiloc.com/trails/<slug>/<id>"]`.
|
||||
|
||||
**Никаких новых query-параметров, response-полей или error-кодов.**
|
||||
|
||||
Query-параметр `source=...` (фильтр по source ID) уже существует;
|
||||
теперь принимает новые значения `enduro_russia`, `wikiloc`.
|
||||
|
||||
### 6.2 `GET /api/gps-tracks/tiles/{z}/{x}/{y}.mvt` — без изменений
|
||||
|
||||
`properties.source` в MVT-feature может теперь принимать значения
|
||||
`enduro_russia` / `wikiloc` (первый source в `sources_json`).
|
||||
Клиент-стиль (match-expression `line-color`) переключается на
|
||||
соответствующий цвет.
|
||||
|
||||
### 6.3 `GET /api/gps-tracks/health` — без изменений в схеме
|
||||
|
||||
Response shape без изменений. Содержимое:
|
||||
- `tracks_by_source` теперь содержит ключи `enduro_russia` и `wikiloc`
|
||||
с числовыми значениями;
|
||||
- `last_pipeline_run.sources_ok` / `sources_error` /
|
||||
`sources_skipped_license` могут содержать новые source IDs.
|
||||
|
||||
Клиент-side `SOURCE_ATTRIBUTIONS` маппинг превращает ключи
|
||||
`tracks_by_source` в строки атрибуции для MapLibre Attribution control
|
||||
(REQ-F-14).
|
||||
|
||||
### 6.4 `POST /api/gps-tracks/cache/clear` — без изменений
|
||||
|
||||
## 7. Персональные данные (PII)
|
||||
|
||||
Без изменений vs ET-008 §7, с расширением табличного сводного:
|
||||
|
||||
| Канал | PII | Условия в ET-009 |
|
||||
|---|---|---|
|
||||
| `tracks.user` для `enduro_russia` | **нет** — `save_user_field: false` (ADR-010) | сохраняется null |
|
||||
| `tracks.user` для `wikiloc` | **нет** — `save_user_field: false` (ADR-012) | сохраняется null |
|
||||
| `tracks.geom`, `tracks.created_at`, `tracks.length_m` | низкий риск, публично выложено автором | сохраняется как в ET-008 |
|
||||
| `tracks.description` для `enduro_russia` | возможны следы PII в свободном тексте | сохраняется в default (ADR-010 §3); может быть пере-включено `save_description: false` |
|
||||
| `tracks.description` для `wikiloc` | возможны следы PII | **null** — `save_description: false` (ADR-012) |
|
||||
| `tracks.name` для `enduro_russia` / `wikiloc` | название может содержать псевдонимы | сохраняется (видно в popup) |
|
||||
| IP mva154 становится известен `endurorussia.ru`, `wikiloc.com` | да | стандартное поведение скрейпера; User-Agent с контактом |
|
||||
|
||||
### 7.1 Право на удаление
|
||||
|
||||
Без изменений. `external_urls_json` хранит ссылку; точечное удаление
|
||||
по запросу автора возможно (ET-008 §7.1).
|
||||
|
||||
### 7.2 GDPR / РФ ФЗ-152
|
||||
|
||||
Без изменений. Обрабатываются только публично выложенные данные.
|
||||
|
||||
## 8. Атрибуция
|
||||
|
||||
**Расширение vs ET-008:**
|
||||
|
||||
Источник | Атрибуция-строка |
|
||||
|---|---|
|
||||
| `osm` | `© OpenStreetMap contributors (ODbL)` |
|
||||
| `enduro_russia` | `EnduroRussia.ru` |
|
||||
| `wikiloc` | `© Wikiloc contributors` |
|
||||
| `ttrails` (будущее) | `ttrails.ru` |
|
||||
|
||||
Клиент формирует список из `tracks_by_source` (где count > 0) через
|
||||
`SOURCE_ATTRIBUTIONS` маппинг и подмешивает в MapLibre Attribution
|
||||
control при включённом слое «Публичные треки».
|
||||
|
||||
В **popup трека** (`gps_tracks.js`) — ссылки `external_urls` (как в
|
||||
ET-008 REQ-F-18); никаких дополнительных правок.
|
||||
|
||||
## 9. Backup и retention
|
||||
|
||||
Без изменений vs ET-008 §9. Ежедневный snapshot + 14 дней retention
|
||||
для `data/gps_tracks.sqlite`. После ET-009 backup-размер вырастет с
|
||||
~5 МБ до ~50 МБ — пренебрежимое влияние на disk budget.
|
||||
|
||||
## 10. Тестовые данные (фикстуры)
|
||||
|
||||
ET-009 вводит новые фикстуры в `tests/fixtures/gps-tracks/`:
|
||||
|
||||
| Файл | Содержимое | Использование |
|
||||
|---|---|---|
|
||||
| `enduro-russia-api-tracks-page1.json` | реальный snapshot `GET /api/tracks?page=0&limit=50`; ≥ 5 items с полями id/name/difficulty/created_at | UT-ER-01..08, IT-ER-01 |
|
||||
| `enduro-russia-track-1.gpx` | реальный GPX, ≥ 10 trkpt, в bbox `tsfo_plus_chuvashia` | UT-ER-01, IT-ER-01 |
|
||||
| `enduro-russia-track-2.gpx` | пустой GPX (`<trkseg></trkseg>`) | UT-ER-02 (skip-логика) |
|
||||
| `enduro-russia-track-3.gpx` | GPX с одной точкой за пределами bbox | UT-ER-03 (bbox-фильтрация) |
|
||||
| `wikiloc-search-page1.html` | HTML страницы поиска; ≥ 5 ссылок `/trails/…/<id>` | UT-WL-01, IT-WL-01 |
|
||||
| `wikiloc-trail-page.html` | HTML страницы одного трека | UT-WL-02..04, IT-WL-01 |
|
||||
| `wikiloc-track.gpx` | реальный GPX, координаты совпадают с одним из EnduroRussia-треков | UT-WL-05, IT-DEDUP-01 |
|
||||
| `wikiloc-rate-limited.html` | пустой/тестовый HTML | UT-WL-07/08 (для mock 403/429) |
|
||||
|
||||
**Снимки делаются разово, вручную** оператором / разработчиком через
|
||||
`curl` или браузер-инспектор; сохраняются в git и не зависят от
|
||||
состояния сайта.
|
||||
|
||||
### 10.1 Юридический статус фикстур
|
||||
|
||||
Фикстуры в `tests/fixtures/gps-tracks/` — публичные snapshot'ы
|
||||
открытых страниц/API, размещённые исключительно для **верификации
|
||||
парсеров** (некоммерческое тестовое использование). Не включаются в
|
||||
production-БД, не отдаются через API. Внутри фикстур не сохраняются
|
||||
authentication-cookies, авторские контактные данные или иные PII.
|
||||
|
||||
При запросе администратора платформы — фикстура подменяется на
|
||||
синтетический минимальный пример с той же структурой.
|
||||
|
||||
## 11. Контракты, которые нельзя ломать
|
||||
|
||||
Без изменений vs ET-008 §10:
|
||||
1. `dedup_key` формула — не меняется в ET-009.
|
||||
2. `ACTIVITY_TYPES` enum — не меняется в ET-009.
|
||||
3. GeoJSON response shape — не меняется.
|
||||
4. MVT layer name `gps_tracks` и properties — не меняется.
|
||||
5. localStorage keys — не меняется.
|
||||
|
||||
**Новое**: маппинги `SOURCE_ATTRIBUTIONS` / `SOURCE_LABELS` в клиенте
|
||||
являются «soft contract»: добавление ключей — safe; удаление —
|
||||
сломает атрибуцию для соответствующих треков.
|
||||
|
||||
## 12. Вывод
|
||||
|
||||
ET-009 — **append-only data event**:
|
||||
- Заполняет существующую схему БД новыми записями;
|
||||
- Использует существующие API-контракты без изменений;
|
||||
- Расширяет существующие client-side маппинги (атрибуция, цвета);
|
||||
- Никаких миграций, никаких ALTER, никаких новых ключей localStorage.
|
||||
|
||||
Юридически защищён через ADR-010 (accepted) и ADR-012 (accepted).
|
||||
Pipeline-guard прозрачен — `proposed` ADR блокирует source автоматически.
|
||||
337
docs/work-items/ET-009/10-tech-risks.md
Normal file
337
docs/work-items/ET-009/10-tech-risks.md
Normal file
@@ -0,0 +1,337 @@
|
||||
---
|
||||
type: tech-risks
|
||||
work_item_id: ET-009
|
||||
title: "Технические риски — ET-009: Активация EnduroRussia + Wikiloc"
|
||||
version: 1
|
||||
status: approved
|
||||
created_at: 2026-06-01
|
||||
authors:
|
||||
- "agent:architect"
|
||||
---
|
||||
|
||||
# Технические риски — ET-009
|
||||
|
||||
Технические риски этапа активации двух новых GPS-источников. Бизнес-риски —
|
||||
в BRD §6 ET-009. Многие риски наследуются от ET-008 (R-1, R-5, R-9 из
|
||||
`docs/work-items/ET-008/10-tech-risks.md`); здесь — специфика ET-009.
|
||||
Шкала: вероятность (Н/С/В) × влияние (Н/С/В).
|
||||
|
||||
## R-1 — Wikiloc меняет HTML → парсер возвращает 0 треков
|
||||
|
||||
- **Описание:** Парсер Wikiloc опирается на regex-извлечение
|
||||
`<a href="/trails/…/<id>">` и `<h1>` для названия. Wikiloc может в
|
||||
любой момент изменить разметку (новый шаблон, JS-rendering) → парсер
|
||||
вернёт 0 треков.
|
||||
- **Вероятность / Влияние:** В / С.
|
||||
- **Митигация:**
|
||||
- Парсер уже спроектирован **graceful**: `return` без `raise` при
|
||||
отсутствии match'ей regex (см. `wikiloc.py::_extract_track_paths`).
|
||||
- Health-эндпоинт показывает `tracks_by_source.wikiloc = 0` после
|
||||
прогона → видимый сигнал оператору.
|
||||
- Unit-тест UT-WL-01 на снимке `wikiloc-search-page1.html` — при
|
||||
смене разметки CI зелёным быть не сможет, разработчик обновит
|
||||
фикстуру + парсер за 1 итерацию.
|
||||
- `gps_sources.yaml::wikiloc.enabled: false` — мгновенное отключение
|
||||
без deploy при критической поломке.
|
||||
- **Наследник от:** ET-008 R-1 (general).
|
||||
|
||||
## R-2 — Wikiloc банит IP mva154
|
||||
|
||||
- **Описание:** Скрейпер с фиксированного IP может попасть в чёрный
|
||||
список Wikiloc'а (особенно при ошибках rate-limit или
|
||||
накоплении 1000+ запросов в сутки). Pipeline начнёт получать 403/429
|
||||
на все запросы → новых треков не будет.
|
||||
- **Вероятность / Влияние:** С / В.
|
||||
- **Митигация:**
|
||||
- `rate_limit_sec: 10` — самый консервативный rate в проекте.
|
||||
- `max_tracks_per_run: 50` — soft-cap на первом прогоне; ≤ 150
|
||||
запросов на одну активацию.
|
||||
- `User-Agent` с контактным URL — платформа может связаться
|
||||
через email до бана.
|
||||
- **Graceful-stop** на 403/429 — не агрессивный retry, не вызывает
|
||||
дополнительных запросов.
|
||||
- **Мониторинг первых 3 прогонов** оператором; при систематических
|
||||
403 → `enabled: false` + новый ADR-update «Wikiloc deprecated».
|
||||
- Запрет использования прокси через сторонний IP (нарушает дух
|
||||
прозрачности; см. ET-008 R-5).
|
||||
|
||||
## R-3 — EnduroRussia API меняет схему ответа
|
||||
|
||||
- **Описание:** `enduro_russia.py::_parse_gpx` ожидает поля
|
||||
`id`, `name`, `difficulty`, `created_at` в JSON-items. Платформа
|
||||
может добавить/переименовать поля.
|
||||
- **Вероятность / Влияние:** Н / С.
|
||||
- **Митигация:**
|
||||
- Парсер использует `.get()` с дефолтами — отсутствие необязательных
|
||||
полей не валит.
|
||||
- Отсутствие `id` → запись пропускается (`continue`), не валит весь
|
||||
прогон.
|
||||
- Контрактный smoke-тест `tests/contract/test_endurorussia_api_smoke.py`
|
||||
с маркером `@pytest.mark.network` — запускается nightly или вручную,
|
||||
сигнализирует о поломке внешнего API до пропущенного cron-прогона.
|
||||
- Pipeline-error не лоадит всю БД: `errors_json` фиксирует, оператор
|
||||
видит через `/health`.
|
||||
|
||||
## R-4 — Расхождение конфига `enduro-russia.ru` (с дефисом) vs
|
||||
реального `endurorussia.ru` (без дефиса)
|
||||
|
||||
- **Описание:** До ET-009 `gps_sources.yaml::enduro_russia.base_url`
|
||||
содержит `https://enduro-russia.ru` (с дефисом), но реальный
|
||||
домен — `https://endurorussia.ru` (без дефиса; парсер по default
|
||||
использует именно его). При активации `enabled: true` без фикса URL
|
||||
парсер использовал бы default из кода, но `external_url` сохранённых
|
||||
треков опирался бы на `base_url` из конфига → fragmentation
|
||||
external_url'ов между «корректным» и «дефис-вариантом».
|
||||
- **Вероятность / Влияние:** Случилось (известный bug в конфиге) /
|
||||
В (при активации).
|
||||
- **Митигация:**
|
||||
- **F-01 в BRD/TRZ** — фикс URL в одно изменение.
|
||||
- **Регрессионный тест UT-ER-05** — проверяет, что парсер
|
||||
сохраняет URL без дефиса при передаче `base_url` без дефиса.
|
||||
- One-shot UPDATE для существующих треков (опционально, см.
|
||||
`07-infra-requirements.md` §4.1).
|
||||
|
||||
## R-5 — EnduroRussia и Wikiloc — двойник одного и того же трека → массовые дубли
|
||||
|
||||
- **Описание:** Авторы часто публикуют одну и ту же поездку и на
|
||||
Wikiloc, и на EnduroRussia (Wikiloc даже сохраняет `creator=Wikiloc`
|
||||
в GPX мета-теге, что подтверждается на практике). Без правильно
|
||||
работающего dedup'а в БД появятся два трека с одинаковой геометрией.
|
||||
- **Вероятность / Влияние:** В / С.
|
||||
- **Митигация:**
|
||||
- `compute_dedup_key` (ADR-006) основан на `bbox+length+date`, который
|
||||
при достаточно похожих координатах и одной дате попадает в один
|
||||
bucket → upsert ON CONFLICT мержит.
|
||||
- **Интеграционный тест IT-DEDUP-01** — задаёт фикстуру `wikiloc-track.gpx`
|
||||
с координатами, совпадающими с одним из EnduroRussia-треков; проверяет
|
||||
итоговое объединение `sources_json=['enduro_russia','wikiloc']`.
|
||||
- Метаданные при merge — берутся от source с большим `source_priority`
|
||||
(`enduro_russia=80 > wikiloc=70`); `external_urls` — оба сохраняются.
|
||||
- Если на практике dedup пропускает (например, точное время / точный
|
||||
bbox slightly off): план отступления ADR-006 §8 (сузить
|
||||
length-bucket, добавить activity).
|
||||
|
||||
## R-6 — Cron первого прогона превышает окно из-за rate-limit Wikiloc
|
||||
|
||||
- **Описание:** При больших cap'ах `max_tracks_per_run` и rate-limit
|
||||
10 сек × 3 запроса/трек первый прогон Wikiloc может занять часы.
|
||||
- **Вероятность / Влияние:** С / Н.
|
||||
- **Митигация:**
|
||||
- `max_tracks_per_run: 50` — soft-cap → ≤ 25 минут на прогон Wikiloc.
|
||||
- EnduroRussia при rate-limit 5 сек × 305 треков ≈ 25 минут — окей.
|
||||
- Cron автоматизация **отложена** до отдельного DevOps-task'а
|
||||
после двух успешных ручных прогонов; оператор контролирует
|
||||
длительность.
|
||||
- Опционально: `timeout 21600 docker compose ...` в cron (ET-008
|
||||
R-11 уже фиксирует).
|
||||
|
||||
## R-7 — UI-фильтр «Источник» не подхватывает новые ID
|
||||
|
||||
- **Описание:** Если в ET-008 UI-фильтр (`#gps-source-grid`) построен
|
||||
с захардкоженным списком `[osm]`, новые источники не появятся как
|
||||
чекбоксы.
|
||||
- **Вероятность / Влияние:** Н / С.
|
||||
- **Митигация:**
|
||||
- **Дизайн ET-008**: UI строит фильтр **динамически** из ответа
|
||||
`/api/gps-tracks/health.tracks_by_source` (источники с count > 0).
|
||||
После первого прогона ET-009 — фильтр сам покажет 3 чекбокса.
|
||||
- UI-тест TC-UI-04 (в `04b-ui-test-cases.md` ET-008) расширен для
|
||||
ET-009: проверяет наличие 3 чекбоксов после двух прогонов.
|
||||
- Маппинг `SOURCE_LABELS` (в `gps_tracks.js`) расширяется явно
|
||||
в ET-009 — даёт корректные читаемые названия.
|
||||
|
||||
## R-8 — Цветовая палитра в `style.json` / `style-dark.json` не содержит новых ID → линии серые
|
||||
|
||||
- **Описание:** В ET-008 match-expression `line-color` может содержать
|
||||
только `osm`; новые источники получат fallback-серый.
|
||||
- **Вероятность / Влияние:** В / Н.
|
||||
- **Митигация:**
|
||||
- **REQ-F-13** явно требует обновить match-expression с тремя
|
||||
источниками + fallback.
|
||||
- Code-review-чеклист: проверить наличие `enduro_russia`, `wikiloc`
|
||||
в `paint.line-color` обоих стилей.
|
||||
- При пропуске: визуальный регресс легко заметен в smoke-тесте
|
||||
(TC-UI-05).
|
||||
|
||||
## R-9 — Дамп БД (резервная копия с старым URL) — orphan записи
|
||||
|
||||
- **Описание:** Если на test-сервере есть резервная копия БД, в которой
|
||||
`external_urls_json` содержит `enduro-russia.ru` (с дефисом),
|
||||
то после фикса URL новые treki будут иметь `endurorussia.ru` (без
|
||||
дефиса), а старые — `enduro-russia.ru`. Это не криминал, но
|
||||
фрагментация атрибуции.
|
||||
- **Вероятность / Влияние:** Н / Н.
|
||||
- **Митигация:**
|
||||
- На практике `enduro_russia` до ET-009 был `enabled: false` →
|
||||
таких записей **нет**. Риск гипотетический.
|
||||
- Опциональный one-shot `UPDATE tracks SET external_urls_json = REPLACE(...)`
|
||||
— фиксируется в `14-deploy-log.md` если применяется.
|
||||
|
||||
## R-10 — ADR-010 / ADR-012 регрессировали в `proposed`
|
||||
|
||||
- **Описание:** Между моментом написания BRD/TRZ ET-009 и моментом
|
||||
активации (merge → deploy) кто-то откатил статус ADR в `proposed`.
|
||||
Pipeline-guard заблокирует source с `skipped_license`.
|
||||
- **Вероятность / Влияние:** Н / В.
|
||||
- **Митигация:**
|
||||
- **F-03 / REQ-F-05** — pre-check перед активацией:
|
||||
```bash
|
||||
grep -E "^status:" docs/work-items/ET-008/06-adr/ADR-010-enduro-russia-licensing.md
|
||||
grep -E "^status:" docs/work-items/ET-008/06-adr/ADR-012-wikiloc-licensing.md
|
||||
```
|
||||
Оба должны вернуть `accepted`. Иначе — STOP и эскалация архитектору.
|
||||
- Интеграционный тест IT-LIC-01 проверяет работу pipeline-guard'а:
|
||||
подменяет `accepted → proposed` в копии ADR-010 и убеждается, что
|
||||
pipeline скипает source с `status='skipped_license'`.
|
||||
- **Наследник от:** ET-008 R-9.
|
||||
|
||||
## R-11 — Пользовательский opt-in для новых источников
|
||||
|
||||
- **Описание:** Пользователи с уже сохранённым `localStorage['gps-tracks-sources']
|
||||
= ["osm"]` после ET-009 **не увидят** треки EnduroRussia/Wikiloc на
|
||||
своих устройствах — клиент сохраняет старое значение, новые источники
|
||||
по умолчанию не enabled в UI.
|
||||
- **Вероятность / Влияние:** В / Н.
|
||||
- **Митигация:**
|
||||
- Это **сознательное решение** UX (см. `08-data-requirements.md` §4.2):
|
||||
добавление источников без согласия пользователя — нарушение
|
||||
принципа без сюрпризов.
|
||||
- Чекбоксы новых источников появятся в `#sheet-gps-filters`
|
||||
automatically (через health-endpoint), пользователь может включить
|
||||
их вручную.
|
||||
- В release-notes (если они есть в проекте) — фиксируем «появились
|
||||
два новых источника, активация в фильтре».
|
||||
|
||||
## R-12 — Wikiloc-парсер сохраняет описание / автора несмотря на ADR-012
|
||||
|
||||
- **Описание:** ADR-012 §3 явно запрещает сохранять `description` и
|
||||
`user` для Wikiloc. Если реализация парсера не уважает этот запрет
|
||||
(например, `TrackInsert.description` заполняется), нарушение
|
||||
licensing-условий.
|
||||
- **Вероятность / Влияние:** Н / В.
|
||||
- **Митигация:**
|
||||
- **Текущая реализация:** `wikiloc.py::_parse_gpx` возвращает
|
||||
`TrackInsert(description=None, user=None)` (зашито в коде).
|
||||
- Unit-тест UT-WL-05 проверяет, что `description=None` и `user=None`
|
||||
в возвращаемом `TrackInsert`.
|
||||
- Code-review checklist в `12-review.md`: при любом изменении
|
||||
парсера Wikiloc убедиться, что эти поля остаются null.
|
||||
|
||||
## R-13 — Тестовые фикстуры устаревают
|
||||
|
||||
- **Описание:** Снимки HTML/JSON, использованные в unit-тестах,
|
||||
отражают состояние API/HTML **на момент снятия**. Через 6-12
|
||||
месяцев платформа может изменить разметку, и фикстуры станут
|
||||
неактуальны. Тесты пройдут (фикстура соответствует тесту), но
|
||||
парсер **не будет работать** в production.
|
||||
- **Вероятность / Влияние:** С / С.
|
||||
- **Митигация:**
|
||||
- Контрактный smoke-тест `test_endurorussia_api_smoke.py`
|
||||
(`@pytest.mark.network`, nightly) — проверяет реальную схему
|
||||
API, ловит расхождение.
|
||||
- Аналогичный smoke для Wikiloc **не** делаем (риск бана IP при
|
||||
регулярных запросах; ETC-009 §«REQ-F-16»).
|
||||
- Health-эндпоинт показывает `tracks_by_source.wikiloc` после
|
||||
каждого продакшн-прогона; устойчивое 0 — сигнал.
|
||||
- При устаревании фикстуры — снимаем заново (1 час работы), парсер
|
||||
обновляем (1-3 часа).
|
||||
|
||||
## R-14 — Производительность endpoint деградирует при росте кол-ва треков
|
||||
|
||||
- **Описание:** REQ-NF-02 ET-008 фиксирует p95 ≤ 300 мс на bbox с
|
||||
≤ 500 треков. После ET-009 в БД появятся ещё ≤ 250 треков —
|
||||
пренебрежимо относительно 5000 OSM.
|
||||
- **Вероятность / Влияние:** Н / Н.
|
||||
- **Митигация:**
|
||||
- R-tree индекс по `geom` (ADR-005) → O(log n) bbox-prefetch.
|
||||
- AC-20 — нагрузочный тест 100 запросов после первого прогона.
|
||||
- При деградации — анализ EXPLAIN QUERY PLAN; добавление индекса
|
||||
`idx_tracks_source` опционально (out of scope ET-009).
|
||||
|
||||
## R-15 — Конфликт MAPPING-таблиц для одной активности
|
||||
|
||||
- **Описание:** EnduroRussia маппит `motorcycle → moto`, Wikiloc
|
||||
тоже `motorcycle → moto` — корректно. Но: EnduroRussia при
|
||||
отсутствии match'а в MAPPING возвращает `enduro` (fallback),
|
||||
Wikiloc — `moto`. Для одного и того же трека (попавшего в оба
|
||||
источника) при merge получим `activity_type` от source с большим
|
||||
`source_priority` = `enduro_russia` → `enduro`. Это **OK**: priority
|
||||
делает выбор детерминированным.
|
||||
- **Вероятность / Влияние:** Н / Н.
|
||||
- **Митигация:**
|
||||
- Принято as is. Уточнение в `08-data-requirements.md` §3.4.
|
||||
- Unit-тесты UT-ER-04 и UT-WL-06 проверяют отдельные MAPPING'и.
|
||||
|
||||
## R-16 — Регрессия e2e-тестов ET-008
|
||||
|
||||
- **Описание:** Расширение `style.json` / `gps_tracks.js`
|
||||
атрибуцией и цветами может случайно сломать существующие
|
||||
selectors / визуальные тесты ET-008.
|
||||
- **Вероятность / Влияние:** Н / С.
|
||||
- **Митигация:**
|
||||
- **AC-19** — все e2e ET-008 (E-01..E-41) должны пройти после
|
||||
мерджа ET-009.
|
||||
- Регрессионный прогон `pytest tests/e2e/ -v` — обязательный
|
||||
шаг CI.
|
||||
|
||||
## R-17 — Pipeline скипает source из-за неправильного `license_adr` path
|
||||
|
||||
- **Описание:** Pipeline-guard `_check_license_adr` читает YAML
|
||||
front-matter файла по пути из `license_adr`. Если путь опечатан
|
||||
(например, `ADR-12-...` вместо `ADR-012-...`), guard вернёт false →
|
||||
`status='skipped_license'`.
|
||||
- **Вероятность / Влияние:** Н / В.
|
||||
- **Митигация:**
|
||||
- Pre-deploy check: убедиться, что `license_adr` указывает на
|
||||
реально существующий файл с `status: accepted`.
|
||||
- При первом запуске pipeline в test-среде оператор смотрит
|
||||
`pipeline_runs[-1].status`; если `skipped_license` —
|
||||
диагностирует и исправляет до merge в main.
|
||||
- Pydantic-валидация `gps_sources.yaml` в pipeline ET-008 уже
|
||||
требует обязательное `license_adr` поле; отсутствие — exception
|
||||
при старте.
|
||||
- **Наследник от:** ET-008 R-9.
|
||||
|
||||
## Сводная таблица
|
||||
|
||||
| ID | Риск | Вер. | Влияние | Класс | Статус |
|
||||
|---|---|---|---|---|---|
|
||||
| R-1 | Wikiloc меняет HTML | В | С | Высокий | принят + graceful + быстрое отключение |
|
||||
| R-2 | Wikiloc банит IP mva154 | С | В | Высокий | rate-limit 10s + cap 50 + UA + monitor |
|
||||
| R-3 | EnduroRussia API меняет схему | Н | С | Низкий | smoke-тест + graceful + health |
|
||||
| R-4 | Расхождение URL `enduro-russia` vs `endurorussia` | Случилось | В | Высокий | F-01 фикс + UT-ER-05 |
|
||||
| R-5 | Дубли EnduroRussia/Wikiloc | В | С | Средний | dedup-key + IT-DEDUP-01 |
|
||||
| R-6 | Cron первого прогона долго | С | Н | Низкий | `max_tracks_per_run=50` + ручной прогон |
|
||||
| R-7 | UI-фильтр не подхватит | Н | С | Низкий | динамика из health + SOURCE_LABELS |
|
||||
| R-8 | Стили без новых цветов | В | Н | Низкий | REQ-F-13 + review + smoke |
|
||||
| R-9 | Orphan записи с старым URL | Н | Н | Низкий | гипотетический (БД чистая); опц UPDATE |
|
||||
| R-10 | ADR-010/012 регрессировали в proposed | Н | В | Высокий | pre-check + IT-LIC-01 |
|
||||
| R-11 | Пользовательский opt-in для новых источников | В | Н | Низкий | сознательный UX-compromise |
|
||||
| R-12 | Wikiloc сохраняет description/user | Н | В | Высокий | parser-design + UT-WL-05 + review |
|
||||
| R-13 | Фикстуры устаревают | С | С | Средний | smoke-test + health + ручной refresh |
|
||||
| R-14 | Деградация endpoint | Н | Н | Низкий | R-tree + AC-20 |
|
||||
| R-15 | Конфликт MAPPING | Н | Н | Низкий | source_priority детерминирует |
|
||||
| R-16 | Регрессия ET-008 e2e | Н | С | Низкий | AC-19 + pytest e2e |
|
||||
| R-17 | Неправильный `license_adr` path | Н | В | Высокий | pre-deploy check + Pydantic |
|
||||
|
||||
**Высокие классы:**
|
||||
- R-1, R-2 — операционные, ожидаемые для скрейп-источника Wikiloc;
|
||||
митигация — multi-layer (graceful + monitor + конфиг-kill-switch).
|
||||
- R-4 — known bug в конфиге, прямо адресован REQ-F-01.
|
||||
- R-10, R-17 — критичны для legal compliance; митигация многослойная
|
||||
(pre-check + integration-тест + Pydantic).
|
||||
- R-12 — критичен для соблюдения ADR-012; митигация через design +
|
||||
UT-WL-05 + review.
|
||||
|
||||
**Блокирующих рисков нет.** Высокие классы требуют внимания на этапе
|
||||
разработки и code review.
|
||||
|
||||
## Эскалация
|
||||
|
||||
- **arch:major-change** — **не выставляется** (см. ADR-013
|
||||
§«Классификация»). Изменение не вводит новых архитектурных компонентов.
|
||||
- **back-to:analysis** — не требуется. ТЗ полное, BRD ясный,
|
||||
open-questions в `TRZ §6` закрыты дефолтными решениями.
|
||||
- Эскалация архитектору требуется **только** при срабатывании R-10
|
||||
(ADR в `proposed` на момент активации). Тогда задача останавливается
|
||||
до повторного апрува ADR.
|
||||
@@ -1,17 +1,253 @@
|
||||
"""Парсер EnduroRussia.ru — заглушка (ADR-010 status=proposed)."""
|
||||
"""Парсер EnduroRussia.ru — JSON API + GPX (ET-009)."""
|
||||
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__)
|
||||
|
||||
|
||||
class EnduroRussiaParser(SourceParser):
|
||||
"""Парсер EnduroRussia.ru.
|
||||
"""Парсер EnduroRussia.ru через публичный JSON API.
|
||||
|
||||
Заблокирован до получения лицензии. См. ADR-010.
|
||||
API:
|
||||
GET /api/tracks?page=N&limit=50 -> {items: [...], total: N, page: N}
|
||||
GET /api/tracks/{id}/gpx -> GPX XML
|
||||
"""
|
||||
|
||||
MAPPING = {"enduro": "enduro", "мото": "moto"}
|
||||
MAPPING = {
|
||||
"enduro": "enduro",
|
||||
"мото": "moto",
|
||||
"hard": "enduro",
|
||||
"soft": "enduro",
|
||||
"тур": "moto",
|
||||
"motorcycle": "moto",
|
||||
"offroad": "offroad",
|
||||
}
|
||||
|
||||
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
|
||||
async def collect(self, bbox: tuple, ctx: dict) -> AsyncGenerator[TrackInsert, None]:
|
||||
"""Собирает треки из EnduroRussia.ru API.
|
||||
|
||||
Args:
|
||||
bbox: (west, south, east, north)
|
||||
ctx: контекст выполнения
|
||||
|
||||
Yields:
|
||||
TrackInsert объекты
|
||||
"""
|
||||
west, south, east, north = bbox
|
||||
base_url = self.config.get("base_url", "https://endurorussia.ru").rstrip("/")
|
||||
rate_limit = self.config.get("rate_limit_sec", 5)
|
||||
user_agent = self.config.get("user_agent", "enduro-trails/1.0")
|
||||
source_id = self.config.get("id", "enduro_russia")
|
||||
source_priority = self.config.get("source_priority", 80)
|
||||
|
||||
headers = {"User-Agent": user_agent, "Accept": "application/json"}
|
||||
|
||||
async with httpx.AsyncClient(timeout=30, headers=headers) as client:
|
||||
page = 0
|
||||
limit = 50
|
||||
total = None
|
||||
|
||||
while True:
|
||||
url = f"{base_url}/api/tracks?page={page}&limit={limit}"
|
||||
try:
|
||||
resp = await client.get(url)
|
||||
if resp.status_code == 429:
|
||||
logger.warning("EnduroRussia: rate limited on tracks list, stopping")
|
||||
return
|
||||
if resp.status_code != 200:
|
||||
logger.warning("EnduroRussia: tracks list returned %d", resp.status_code)
|
||||
return
|
||||
data = resp.json()
|
||||
except Exception as exc:
|
||||
logger.error("EnduroRussia: failed to fetch tracks list: %s", exc)
|
||||
return
|
||||
|
||||
items = data.get("items", [])
|
||||
if not items:
|
||||
break
|
||||
|
||||
if total is None:
|
||||
total = data.get("total", 0)
|
||||
logger.info("EnduroRussia: total tracks = %d", total)
|
||||
|
||||
for item in items:
|
||||
track_id = item.get("id")
|
||||
if not track_id:
|
||||
continue
|
||||
|
||||
gpx_url = f"{base_url}/api/tracks/{track_id}/gpx"
|
||||
try:
|
||||
await asyncio.sleep(rate_limit)
|
||||
gpx_resp = await client.get(
|
||||
gpx_url,
|
||||
headers={**headers, "Accept": "application/gpx+xml,application/xml,*/*"},
|
||||
)
|
||||
if gpx_resp.status_code == 429:
|
||||
logger.warning("EnduroRussia: rate limited on GPX %d, stopping", track_id)
|
||||
return
|
||||
if gpx_resp.status_code != 200:
|
||||
logger.warning("EnduroRussia: GPX %d returned %d", track_id, gpx_resp.status_code)
|
||||
continue
|
||||
gpx_content = gpx_resp.content
|
||||
except Exception as exc:
|
||||
logger.error("EnduroRussia: failed to fetch GPX %d: %s", track_id, exc)
|
||||
continue
|
||||
|
||||
track = _parse_gpx(
|
||||
gpx_content,
|
||||
track_id=track_id,
|
||||
meta=item,
|
||||
source_id=source_id,
|
||||
base_url=base_url,
|
||||
source_priority=source_priority,
|
||||
mapping=self.MAPPING,
|
||||
)
|
||||
if track is None:
|
||||
continue
|
||||
|
||||
if not _bbox_intersects(
|
||||
(track.min_lon, track.min_lat, track.max_lon, track.max_lat),
|
||||
(west, south, east, north),
|
||||
):
|
||||
logger.debug("EnduroRussia: track %d outside bbox, skipping", track_id)
|
||||
continue
|
||||
|
||||
yield track
|
||||
|
||||
fetched_so_far = (page + 1) * limit
|
||||
if total is not None and fetched_so_far >= total:
|
||||
break
|
||||
if len(items) < limit:
|
||||
break
|
||||
|
||||
page += 1
|
||||
|
||||
|
||||
def _parse_gpx(
|
||||
content: bytes,
|
||||
track_id: int,
|
||||
meta: dict,
|
||||
source_id: str,
|
||||
base_url: str,
|
||||
source_priority: int,
|
||||
mapping: dict,
|
||||
) -> "TrackInsert | None":
|
||||
"""Парсит GPX-файл EnduroRussia и возвращает TrackInsert."""
|
||||
try:
|
||||
root = ET.fromstring(content)
|
||||
except Exception as exc:
|
||||
logger.error("EnduroRussia: failed to parse GPX %d: %s", track_id, exc)
|
||||
return None
|
||||
|
||||
ns = ""
|
||||
tag = root.tag
|
||||
if tag.startswith("{"):
|
||||
ns = tag.split("}")[0] + "}"
|
||||
|
||||
coords = []
|
||||
for trk in root:
|
||||
local = trk.tag.replace(ns, "") if ns else trk.tag
|
||||
if local != "trk":
|
||||
continue
|
||||
for trkseg in trk:
|
||||
local2 = trkseg.tag.replace(ns, "") if ns else trkseg.tag
|
||||
if local2 != "trkseg":
|
||||
continue
|
||||
for trkpt in trkseg:
|
||||
try:
|
||||
lat = float(trkpt.get("lat", 0))
|
||||
lon = float(trkpt.get("lon", 0))
|
||||
if lat == 0 and lon == 0:
|
||||
continue
|
||||
coords.append((lon, lat))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
if len(coords) < 2:
|
||||
logger.debug("EnduroRussia: track %d has < 2 points, skipping", track_id)
|
||||
return None
|
||||
|
||||
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:
|
||||
return None
|
||||
|
||||
try:
|
||||
from shapely.geometry import LineString
|
||||
from shapely import wkb
|
||||
geom_wkb = wkb.dumps(LineString(coords))
|
||||
except Exception as exc:
|
||||
logger.error("EnduroRussia: shapely error for track %d: %s", track_id, exc)
|
||||
return None
|
||||
|
||||
name = meta.get("name")
|
||||
description = meta.get("description")
|
||||
created_at = meta.get("created_at", "")
|
||||
if created_at:
|
||||
created_at = created_at[:19].replace(" ", "T")
|
||||
|
||||
difficulty = (meta.get("difficulty") or "").lower()
|
||||
activity_type = mapping.get(difficulty, "enduro")
|
||||
from src.api.gps_tracks.models import ACTIVITY_TYPES
|
||||
if activity_type not in ACTIVITY_TYPES:
|
||||
activity_type = "enduro"
|
||||
|
||||
return TrackInsert(
|
||||
external_id=str(track_id),
|
||||
source_id=source_id,
|
||||
external_url=f"{base_url}/tracks/{track_id}",
|
||||
name=name,
|
||||
description=description,
|
||||
activity_type=activity_type,
|
||||
user=None,
|
||||
created_at=created_at or None,
|
||||
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=source_priority,
|
||||
)
|
||||
|
||||
|
||||
def _haversine_m(lon1: float, lat1: float, lon2: float, lat2: float) -> float:
|
||||
"""Расстояние между двумя точками в метрах (Haversine)."""
|
||||
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 _bbox_intersects(a: tuple, b: tuple) -> bool:
|
||||
"""Проверяет пересечение двух bbox (west, south, east, north)."""
|
||||
a_west, a_south, a_east, a_north = a
|
||||
b_west, b_south, b_east, b_north = b
|
||||
return not (
|
||||
a_east < b_west or a_west > b_east or
|
||||
a_north < b_south or a_south > b_north
|
||||
)
|
||||
|
||||
365
src/api/gps_tracks/sources/wikiloc.py
Normal file
365
src/api/gps_tracks/sources/wikiloc.py
Normal file
@@ -0,0 +1,365 @@
|
||||
"""Парсер Wikiloc — HTML-парсинг публичных треков (ET-009)."""
|
||||
import asyncio
|
||||
import math
|
||||
import logging
|
||||
import re
|
||||
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__)
|
||||
|
||||
# Wikiloc activity codes для поиска
|
||||
_ACTIVITY_CODES = {
|
||||
"motorcycle": 19, # Motorcycle
|
||||
"enduro": 19,
|
||||
"mtb": 3, # Mountain biking
|
||||
}
|
||||
|
||||
# Паттерны для парсинга HTML
|
||||
_TRACK_URL_RE = re.compile(r'href="(/trails/[^"]+/\d+)"')
|
||||
_TRACK_ID_RE = re.compile(r'/trails/[^/]+/(\d+)')
|
||||
_GPX_LINK_RE = re.compile(r'href="([^"]*download[^"]*\.gpx[^"]*|[^"]*\.gpx[^"]*download[^"]*)"' , re.IGNORECASE)
|
||||
_TRAIL_JSON_RE = re.compile(r'wikiloc\.trail\s*=\s*(\{.*?\});', re.DOTALL)
|
||||
|
||||
|
||||
class WikilocParser(SourceParser):
|
||||
"""Парсер Wikiloc через HTTP-парсинг страниц поиска.
|
||||
|
||||
Wikiloc не имеет публичного API. Используем HTML-парсинг с агрессивным
|
||||
rate-limit (10 сек). При 403/429 — graceful stop без краша.
|
||||
"""
|
||||
|
||||
MAPPING = {
|
||||
"motorcycle": "moto",
|
||||
"enduro": "enduro",
|
||||
"mtb": "bicycle",
|
||||
"mountain biking": "bicycle",
|
||||
"hiking": "hike",
|
||||
"running": "hike",
|
||||
"trail running": "hike",
|
||||
"offroad": "offroad",
|
||||
}
|
||||
|
||||
async def collect(self, bbox: tuple, ctx: dict) -> AsyncGenerator[TrackInsert, None]:
|
||||
"""Собирает треки из Wikiloc через HTML-парсинг.
|
||||
|
||||
Args:
|
||||
bbox: (west, south, east, north)
|
||||
ctx: контекст выполнения
|
||||
|
||||
Yields:
|
||||
TrackInsert объекты
|
||||
"""
|
||||
west, south, east, north = bbox
|
||||
base_url = self.config.get("base_url", "https://www.wikiloc.com").rstrip("/")
|
||||
rate_limit = self.config.get("rate_limit_sec", 10)
|
||||
user_agent = self.config.get("user_agent", "enduro-trails/1.0")
|
||||
source_id = self.config.get("id", "wikiloc")
|
||||
source_priority = self.config.get("source_priority", 70)
|
||||
activity_filter = self.config.get("activity_filter", ["motorcycle", "enduro"])
|
||||
|
||||
headers = {
|
||||
"User-Agent": user_agent,
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
"Accept-Language": "en-US,en;q=0.5",
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
timeout=30,
|
||||
headers=headers,
|
||||
follow_redirects=True,
|
||||
) as client:
|
||||
for activity in activity_filter:
|
||||
act_code = _ACTIVITY_CODES.get(activity, 19)
|
||||
|
||||
page = 0
|
||||
while True:
|
||||
# URL поиска по bbox
|
||||
search_url = (
|
||||
f"{base_url}/wikiloc/find.do"
|
||||
f"?act={act_code}"
|
||||
f"&sw={south},{west}"
|
||||
f"&ne={north},{east}"
|
||||
f"&page={page}"
|
||||
)
|
||||
|
||||
try:
|
||||
await asyncio.sleep(rate_limit)
|
||||
resp = await client.get(search_url)
|
||||
except Exception as exc:
|
||||
logger.error("Wikiloc: failed to fetch search page: %s", exc)
|
||||
return
|
||||
|
||||
if resp.status_code in (403, 429):
|
||||
logger.warning(
|
||||
"Wikiloc: received %d on search, graceful stop",
|
||||
resp.status_code,
|
||||
)
|
||||
return
|
||||
|
||||
if resp.status_code != 200:
|
||||
logger.warning("Wikiloc: search returned %d", resp.status_code)
|
||||
break
|
||||
|
||||
html = resp.text
|
||||
track_paths = _extract_track_paths(html)
|
||||
|
||||
if not track_paths:
|
||||
logger.info("Wikiloc: no tracks on page %d for activity %s", page, activity)
|
||||
break
|
||||
|
||||
for path in track_paths:
|
||||
track_id_match = _TRACK_ID_RE.search(path)
|
||||
if not track_id_match:
|
||||
continue
|
||||
track_id = track_id_match.group(1)
|
||||
track_url = f"{base_url}{path}"
|
||||
|
||||
# Скачиваем страницу трека для получения GPX ссылки
|
||||
try:
|
||||
await asyncio.sleep(rate_limit)
|
||||
track_resp = await client.get(track_url)
|
||||
except Exception as exc:
|
||||
logger.error("Wikiloc: failed to fetch track %s: %s", track_id, exc)
|
||||
continue
|
||||
|
||||
if track_resp.status_code in (403, 429):
|
||||
logger.warning(
|
||||
"Wikiloc: received %d on track %s, graceful stop",
|
||||
track_resp.status_code,
|
||||
track_id,
|
||||
)
|
||||
return
|
||||
|
||||
if track_resp.status_code != 200:
|
||||
logger.warning("Wikiloc: track %s returned %d", track_id, track_resp.status_code)
|
||||
continue
|
||||
|
||||
track_html = track_resp.text
|
||||
|
||||
# Ищем ссылку на GPX
|
||||
gpx_url = _extract_gpx_url(track_html, base_url, track_id)
|
||||
if not gpx_url:
|
||||
logger.debug("Wikiloc: no GPX link found for track %s", track_id)
|
||||
continue
|
||||
|
||||
# Скачиваем GPX
|
||||
try:
|
||||
await asyncio.sleep(rate_limit)
|
||||
gpx_resp = await client.get(gpx_url)
|
||||
except Exception as exc:
|
||||
logger.error("Wikiloc: failed to fetch GPX %s: %s", track_id, exc)
|
||||
continue
|
||||
|
||||
if gpx_resp.status_code in (403, 429):
|
||||
logger.warning(
|
||||
"Wikiloc: received %d on GPX %s, graceful stop",
|
||||
gpx_resp.status_code,
|
||||
track_id,
|
||||
)
|
||||
return
|
||||
|
||||
if gpx_resp.status_code != 200:
|
||||
logger.warning("Wikiloc: GPX %s returned %d", track_id, gpx_resp.status_code)
|
||||
continue
|
||||
|
||||
# Парсим GPX
|
||||
name = _extract_track_name(track_html)
|
||||
track = _parse_gpx(
|
||||
gpx_resp.content,
|
||||
track_id=track_id,
|
||||
name=name,
|
||||
activity_type=self.MAPPING.get(activity, "moto"),
|
||||
source_id=source_id,
|
||||
track_url=track_url,
|
||||
source_priority=source_priority,
|
||||
)
|
||||
if track is None:
|
||||
continue
|
||||
|
||||
if not _bbox_intersects(
|
||||
(track.min_lon, track.min_lat, track.max_lon, track.max_lat),
|
||||
(west, south, east, north),
|
||||
):
|
||||
continue
|
||||
|
||||
yield track
|
||||
|
||||
page += 1
|
||||
|
||||
|
||||
def _extract_track_paths(html: str) -> list:
|
||||
"""Извлекает пути к трекам из HTML страницы поиска Wikiloc."""
|
||||
# Ищем ссылки вида /trails/motorcycle-enduro/name-12345678
|
||||
paths = _TRACK_URL_RE.findall(html)
|
||||
# Дедупликация с сохранением порядка
|
||||
seen = set()
|
||||
result = []
|
||||
for p in paths:
|
||||
if p not in seen and _TRACK_ID_RE.search(p):
|
||||
seen.add(p)
|
||||
result.append(p)
|
||||
return result
|
||||
|
||||
|
||||
def _extract_gpx_url(html: str, base_url: str, track_id: str) -> str | None:
|
||||
"""Извлекает URL для скачивания GPX из страницы трека."""
|
||||
# Вариант 1: прямая ссылка на GPX
|
||||
m = _GPX_LINK_RE.search(html)
|
||||
if m:
|
||||
url = m.group(1)
|
||||
if url.startswith("http"):
|
||||
return url
|
||||
return base_url + url
|
||||
|
||||
# Вариант 2: стандартный URL скачивания Wikiloc
|
||||
# https://www.wikiloc.com/wikiloc/downloadTrail.do?id=XXXXX
|
||||
dl_re = re.search(r'downloadTrail\.do\?id=(\d+)', html)
|
||||
if dl_re:
|
||||
return f"{base_url}/wikiloc/downloadTrail.do?id={dl_re.group(1)}"
|
||||
|
||||
# Вариант 3: по track_id
|
||||
return f"{base_url}/wikiloc/downloadTrail.do?id={track_id}"
|
||||
|
||||
|
||||
def _extract_track_name(html: str) -> str | None:
|
||||
"""Извлекает название трека из HTML страницы."""
|
||||
# Ищем <h1> или <title>
|
||||
m = re.search(r'<h1[^>]*>([^<]+)</h1>', html)
|
||||
if m:
|
||||
return m.group(1).strip()
|
||||
m = re.search(r'<title>([^<|]+)', html)
|
||||
if m:
|
||||
return m.group(1).strip()
|
||||
return None
|
||||
|
||||
|
||||
def _parse_gpx(
|
||||
content: bytes,
|
||||
track_id: str,
|
||||
name: str | None,
|
||||
activity_type: str,
|
||||
source_id: str,
|
||||
track_url: str,
|
||||
source_priority: int,
|
||||
) -> "TrackInsert | None":
|
||||
"""Парсит GPX-файл Wikiloc и возвращает TrackInsert."""
|
||||
try:
|
||||
root = ET.fromstring(content)
|
||||
except Exception as exc:
|
||||
logger.error("Wikiloc: failed to parse GPX %s: %s", track_id, exc)
|
||||
return None
|
||||
|
||||
ns = ""
|
||||
tag = root.tag
|
||||
if tag.startswith("{"):
|
||||
ns = tag.split("}")[0] + "}"
|
||||
|
||||
# Извлекаем название из GPX metadata если нет из HTML
|
||||
if not name:
|
||||
for child in root:
|
||||
local = child.tag.replace(ns, "") if ns else child.tag
|
||||
if local == "metadata":
|
||||
for meta_child in child:
|
||||
local2 = meta_child.tag.replace(ns, "") if ns else meta_child.tag
|
||||
if local2 == "name":
|
||||
name = meta_child.text
|
||||
break
|
||||
break
|
||||
|
||||
coords = []
|
||||
for trk in root:
|
||||
local = trk.tag.replace(ns, "") if ns else trk.tag
|
||||
if local != "trk":
|
||||
continue
|
||||
for trkseg in trk:
|
||||
local2 = trkseg.tag.replace(ns, "") if ns else trkseg.tag
|
||||
if local2 != "trkseg":
|
||||
continue
|
||||
for trkpt in trkseg:
|
||||
try:
|
||||
lat = float(trkpt.get("lat", 0))
|
||||
lon = float(trkpt.get("lon", 0))
|
||||
if lat == 0 and lon == 0:
|
||||
continue
|
||||
coords.append((lon, lat))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
if len(coords) < 2:
|
||||
logger.debug("Wikiloc: track %s has < 2 points, skipping", track_id)
|
||||
return None
|
||||
|
||||
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:
|
||||
return None
|
||||
|
||||
try:
|
||||
from shapely.geometry import LineString
|
||||
from shapely import wkb
|
||||
geom_wkb = wkb.dumps(LineString(coords))
|
||||
except Exception as exc:
|
||||
logger.error("Wikiloc: shapely error for track %s: %s", track_id, exc)
|
||||
return None
|
||||
|
||||
from src.api.gps_tracks.models import ACTIVITY_TYPES
|
||||
if activity_type not in ACTIVITY_TYPES:
|
||||
activity_type = "moto"
|
||||
|
||||
return TrackInsert(
|
||||
external_id=str(track_id),
|
||||
source_id=source_id,
|
||||
external_url=track_url,
|
||||
name=name,
|
||||
description=None,
|
||||
activity_type=activity_type,
|
||||
user=None,
|
||||
created_at=None,
|
||||
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=source_priority,
|
||||
)
|
||||
|
||||
|
||||
def _haversine_m(lon1: float, lat1: float, lon2: float, lat2: float) -> float:
|
||||
"""Расстояние между двумя точками в метрах (Haversine)."""
|
||||
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 _bbox_intersects(a: tuple, b: tuple) -> bool:
|
||||
"""Проверяет пересечение двух bbox (west, south, east, north)."""
|
||||
a_west, a_south, a_east, a_north = a
|
||||
b_west, b_south, b_east, b_north = b
|
||||
return not (
|
||||
a_east < b_west or a_west > b_east or
|
||||
a_north < b_south or a_south > b_north
|
||||
)
|
||||
Reference in New Issue
Block a user