--- type: trz work_item_id: ET-009 title: "ТЗ: Новые источники GPS-треков — EnduroRussia и Wikiloc" version: 1 status: draft created_at: 2026-06-01 updated_at: 2026-06-01 authors: - "agent:analyst" related: - "ET-008" --- # ТЗ — ET-009: Новые источники GPS-треков — EnduroRussia и Wikiloc ## 1. Терминология - **Source** — внешний поставщик GPS-треков, описан записью в `config/gps_sources.yaml`. Реализуется python-классом-наследником `SourceParser` в `src/api/gps_tracks/sources/.py`. - **Region** — географическая область сбора, описана записью в `config/gps_regions.yaml`. Содержит `bbox` и список активных `sources` для этой области. - **Pipeline-guard** — проверка `_check_license_adr()` в `scripts/gps_collect.py`, которая блокирует загрузку source-парсера если его ADR в `license_adr` имеет `status != 'accepted'`. - **Activity-mapping** — словарь `MAPPING` в каждом parser-модуле, переводящий внутренние категории источника в каноничные `ACTIVITY_TYPES` (`src/api/gps_tracks/models.py`). - **Dedup-key** — детерминированный ключ, по которому треки из разных источников сливаются в одну запись (реализация в `src/api/gps_tracks/dedup.py:compute_dedup_key`, ET-008). - **Graceful-stop** — поведение Wikiloc-парсера при HTTP 403/429: `return` из async-генератора без `raise`, что приводит к статусу прогона `partial` или `rate_limited` без падения процесса. ## 2. Архитектурные опоры из ET-008 ET-009 не строит новых модулей. Используются: - `src/api/gps_tracks/sources/base.py:SourceParser` — базовый класс. - `src/api/gps_tracks/sources/enduro_russia.py:EnduroRussiaParser` — реализован. - `src/api/gps_tracks/sources/wikiloc.py:WikilocParser` — реализован. - `scripts/gps_collect.py` — оркестратор pipeline, поддерживает per-source rate-limit, licensing-guard, dedup, upsert. - `src/api/gps_tracks/db.py:upsert_track` — merge по `dedup_key`, объединение `sources` и `external_urls`. - `src/api/gps_tracks/endpoint.py` — `/api/gps-tracks`, `/api/gps-tracks/tiles/{z}/{x}/{y}.mvt`, `/api/gps-tracks/health`. - `src/web/gps_tracks.js` (или эквивалент в ET-008) — клиентский слой с динамическим фильтром источников. ET-009 = **конфиг + фикстуры + тесты + продакшн-прогон**. ## 3. Требования ### REQ-F-01 — Конфиг: `enduro_russia.base_url` Файл `config/gps_sources.yaml`, запись с `id: enduro_russia`, поле `base_url` устанавливается в `https://endurorussia.ru` (без дефиса). Текущее значение `https://enduro-russia.ru` (с дефисом) считается багом и должно быть заменено. **Acceptance check.** После правки: ```bash grep "base_url" config/gps_sources.yaml | grep enduro ``` выводит `base_url: "https://endurorussia.ru"`. ### REQ-F-02 — Конфиг: `enduro_russia.enabled` В той же записи `enabled: true`. **Acceptance check.** В `config/gps_sources.yaml` строка `enabled: true` находится непосредственно под `id: enduro_russia`. ### REQ-F-03 — Конфиг: запись `wikiloc` В `config/gps_sources.yaml` добавляется новая запись с полями: ```yaml - id: wikiloc name: "Wikiloc" enabled: true license_adr: "docs/work-items/ET-008/06-adr/ADR-012-wikiloc-licensing.md" base_url: "https://www.wikiloc.com" rate_limit_sec: 10 user_agent: "enduro-trails/1.0 (+https://openclaw.mva154.duckdns.org/enduro/)" attribution: "© Wikiloc contributors" parser_module: "src.api.gps_tracks.sources.wikiloc" save_user_field: false source_priority: 70 activity_filter: [motorcycle, enduro] max_tracks_per_run: 50 ``` `max_tracks_per_run` — soft-cap для первого прогона, чтобы не тратить часы на rate-limit (см. BRD R-6); реализуется в parser'е через счётчик внутри `collect()`. Если поля в parser ещё нет — добавить поддержку: ```python max_tracks = self.config.get("max_tracks_per_run") yielded = 0 # в основном цикле перед yield: 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 ``` ### REQ-F-04 — Конфиг: регион `tsfo_plus_chuvashia` В `config/gps_regions.yaml`, запись `tsfo_plus_chuvashia.sources` дополняется до `[osm, enduro_russia, wikiloc, ttrails]`. Порядок важен: `ttrails` остаётся, но он `enabled: false` в sources.yaml — он автоматически пропускается guard'ом. Поле `enabled: true` региона не меняется. ### REQ-F-05 — Pipeline licensing-guard `scripts/gps_collect.py:_check_license_adr` (строки 37–73) **не изменяется**. Перед активацией ET-009 выполнить: ```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` и эскалация архитектору. ### REQ-F-06 — Тест-фикстура EnduroRussia API Создаётся файл `tests/fixtures/gps-tracks/enduro-russia-api-tracks-page1.json` с реальным snapshot ответа `https://endurorussia.ru/api/tracks?page=0&limit=50`. Минимальные требования к snapshot: - ≥ 5 items. - Каждый item содержит `id` (int), `name` (str), `difficulty` (str), `created_at` (str ISO). - Поле `total` (int) присутствует. Снимок делается **разово**, вручную через curl, сохраняется в репо; не зависит от состояния сайта. ### REQ-F-07 — Тест-фикстуры EnduroRussia GPX Создаются 3 файла `tests/fixtures/gps-tracks/enduro-russia-track-{1,2,3}.gpx` с реальными GPX-файлами из API. Один из них должен: - содержать `` с ≥ 10 точками; - лежать в bbox региона `tsfo_plus_chuvashia` (29..47.5 longitude, 49.5..60.0 latitude); - иметь creator или metadata, идентифицирующее источник. Второй GPX должен быть пустой (``) или с 0 trkpt — для проверки skip-логики `_parse_gpx`. Третий GPX — c одной точкой за пределами bbox — для проверки bbox-фильтрации. ### REQ-F-08 — Тест-фикстура Wikiloc HTML страницы поиска Файл `tests/fixtures/gps-tracks/wikiloc-search-page1.html` — реальный снимок `GET /wikiloc/find.do?act=19&sw=…&ne=…&page=0`. Должен содержать ≥ 5 ссылок на треки в формате `/trails//`. ### REQ-F-09 — Тест-фикстуры Wikiloc страницы трека и GPX - `tests/fixtures/gps-tracks/wikiloc-trail-page.html` — снимок страницы одного трека Wikiloc; должен содержать `

` с названием и либо прямую ссылку на `.gpx`, либо `downloadTrail.do?id=`. - `tests/fixtures/gps-tracks/wikiloc-track.gpx` — реальный GPX, возвращаемый `/wikiloc/downloadTrail.do?id=` для трека, совпадающего по координатам с одним из EnduroRussia-треков — для теста dedup-merge. - `tests/fixtures/gps-tracks/wikiloc-rate-limited.html` — пустой файл (используется в тесте 429, реальный HTML не важен, достаточно тестового мока httpx, который вернёт 429). ### REQ-F-10 — Unit-тесты EnduroRussia parser Файл `tests/unit/test_gps_tracks_enduro_russia.py` (новый). Покрытие: - **UT-ER-01.** `_parse_gpx` принимает фикстурный GPX `enduro-russia-track-1.gpx` → возвращает `TrackInsert` с `points_count >= 10`, `min_lon/max_lon/min_lat/max_lat` корректны, `length_m > 0`, `external_url = "https://endurorussia.ru/tracks/"`. - **UT-ER-02.** `_parse_gpx` принимает фикстуру `enduro-russia-track-2.gpx` (пустой) → возвращает `None`. - **UT-ER-03.** Bbox-фильтр: трек 3 (точка за пределами региона) при пересечении с region bbox → `_bbox_intersects` возвращает `False`, `collect()` не yield-ит этот трек. - **UT-ER-04.** `MAPPING` маппит `"hard" → "enduro"`, `"мото" → "moto"`, `"unknown" → "other"` (default через `map_activity`). - **UT-ER-05.** `EnduroRussiaParser.__init__` принимает конфиг с `base_url: "https://endurorussia.ru"` и сохраняет его (без замены на дефис-вариант). Регрессия для R-4. - **UT-ER-06.** `collect()` корректно прерывается, когда `fetched_so_far >= total`. - **UT-ER-07.** При HTTP 429 на `/api/tracks` — генератор завершается без exception. - **UT-ER-08.** При HTTP 429 на `/api/tracks/{id}/gpx` — генератор завершается без exception, треки, уже yield-нутые до этого, сохраняются. ### REQ-F-11 — Unit-тесты Wikiloc parser Файл `tests/unit/test_gps_tracks_wikiloc.py` (новый). - **UT-WL-01.** `_extract_track_paths` из фикстуры `wikiloc-search-page1.html` возвращает ≥ 5 уникальных путей. - **UT-WL-02.** `_extract_gpx_url`: из HTML с `downloadTrail.do?id=X` возвращает абсолютный URL `https://www.wikiloc.com/wikiloc/downloadTrail.do?id=X`. - **UT-WL-03.** `_extract_gpx_url`: из HTML без явных ссылок возвращает fallback `https://www.wikiloc.com/wikiloc/downloadTrail.do?id=`. - **UT-WL-04.** `_extract_track_name` извлекает текст `

`. - **UT-WL-05.** `_parse_gpx` на фикстуре `wikiloc-track.gpx` возвращает `TrackInsert` с правильными bbox и `activity_type='moto'` (для activity-категории `motorcycle`). - **UT-WL-06.** `MAPPING` маппит `"motorcycle" → "moto"`, `"hiking" → "hike"`, `"mtb" → "bicycle"`. - **UT-WL-07.** `collect()` останавливается при 403 на странице поиска (graceful-stop). - **UT-WL-08.** `collect()` останавливается при 429 на странице трека, но уже yield-нутые треки сохраняются. - **UT-WL-09.** Соблюдение `rate_limit_sec`: между двумя последовательными HTTP-запросами `asyncio.sleep` вызывается с аргументом ≥ конфигурируемого значения. (Mock `asyncio.sleep`, проверка count и аргументов.) - **UT-WL-10.** `max_tracks_per_run`: при `max_tracks_per_run=2` и mock поиске на ≥ 5 треков — `collect()` yield-ит ровно 2 трека. ### REQ-F-12 — Integration-тест pipeline на mock-источниках Файл `tests/integration/test_pipeline_et009.py` (новый). Использует respx или httpx_mock для подмены HTTP. Запускает `scripts/gps_collect.py:main` (через `asyncio.run`) с временной БД. - **IT-ER-01.** Pipeline с mock EnduroRussia (фикстурный JSON + 3 GPX) + регион `tsfo_plus_chuvashia` → в БД 2 трека (третий отфильтрован bbox-ом), `pipeline_runs[-1].status='ok'`, `tracks_new=2`. - **IT-WL-01.** Pipeline с mock Wikiloc (фикстурный HTML поиска + 1 страница трека + 1 GPX) → в БД 1 трек, `pipeline_runs[-1].status='ok'`, `tracks_new=1`. - **IT-WL-02.** Mock Wikiloc возвращает 403 на странице поиска → `pipeline_runs[-1].status='partial'` или `'rate_limited'`, `tracks_new=0`, exit-code pipeline не 0 (есть error) **либо** exit-code 0 при условии что graceful-stop не считается error — выбрать одно поведение и зафиксировать тест на нём. **Решение:** graceful-stop ≠ error, exit-code 0, status `'partial'`. - **IT-DEDUP-01.** Pipeline сначала собирает EnduroRussia (1 трек), затем Wikiloc (1 трек с теми же координатами и длиной ±5%, той же датой ±1 день) → в БД одна запись с `sources=['enduro_russia','wikiloc']`, `external_urls=[endurorussia.ru/…, wikiloc.com/…]`, метаданные имеют приоритет `enduro_russia` (если `source_priority=80` выше чем у wikiloc=70 — см. ET-008 dedup-merge). - **IT-LIC-01.** Искусственно поменять `status: accepted` → `status: proposed` в копии ADR-010 (через временный `GPS_SOURCES_CONFIG` env с другим путём license_adr) → pipeline пропускает source с `pipeline_runs[-1].status='skipped_license'`. ### REQ-F-13 — Стили: цвета по источнику В файлах `src/web/style.json` и `src/web/style-dark.json` слой `gps-tracks-layer` (или его эквивалент из ET-008) содержит match-expression `line-color`: ```json [ "match", ["get", "source"], "osm", "#3cb44b", "enduro_russia", "#e6194b", "wikiloc", "#4363d8", "#808080" ] ``` Цвета — приближённо, окончательная палитра согласуется с UX в момент реализации. Главное: для всех трёх известных источников ID-→-цвет задан, fallback есть. Аналогично для `gps-tracks-halo-satellite` — halo всегда белый/ полупрозрачный, цвет линии берётся тот же. ### REQ-F-14 — Атрибуция После первого прогона, при наличии в БД треков из `enduro_russia`, endpoint `/api/gps-tracks/health` возвращает в поле `attributions` (если уже есть в ET-008) или в эквивалентном — список: ```json ["© OpenStreetMap contributors (ODbL)", "EnduroRussia.ru", "© Wikiloc contributors"] ``` Клиент `src/web/gps_tracks.js` подмешивает эти строки в MapLibre attribution control (через `map.getControl(...)` или эквивалент). Если в ET-008 атрибуция формируется на клиенте по статическому маппингу `source_id → label` — расширить маппинг: ```js const SOURCE_ATTRIBUTIONS = { osm: "© OpenStreetMap contributors (ODbL)", enduro_russia: "EnduroRussia.ru", wikiloc: "© Wikiloc contributors", ttrails: "ttrails.ru", }; ``` ### REQ-F-15 — Контрактный smoke-тест EnduroRussia API Файл `tests/contract/test_endurorussia_api_smoke.py` (новый, помечается маркером `@pytest.mark.network` и не запускается в обычном CI; запускается вручную или в nightly). - **CT-ER-01.** `GET https://endurorussia.ru/api/tracks?page=0&limit=5` возвращает 200, JSON с ключами `items`, `total`. - **CT-ER-02.** `GET https://endurorussia.ru/api/tracks/{first_id}/gpx` возвращает 200, Content-Type содержит `xml` или `gpx`, тело парсится `defusedxml` без exception. Назначение: при поломке внешнего API мы узнаём об этом из nightly, а не из тишины health-эндпоинта. ### REQ-F-16 — Контрактный smoke-тест Wikiloc (опционально) Из-за rate-limit и риска бана **не** делаем регулярный smoke-тест Wikiloc. Вместо этого фиксируем в `docs/work-items/ET-009/13-test-report.md` после первой ручной проверки факт того, что `find.do` отвечает 200 с ожидаемой структурой. ### REQ-F-17 — Первый продакшн-прогон После мерджа в main и деплоя в test-среду оператор запускает: ```bash ssh mva154 cd /opt/enduro-trails python scripts/gps_collect.py --region tsfo_plus_chuvashia --source enduro_russia # (ждать ≈ 25 минут) python scripts/gps_collect.py --region tsfo_plus_chuvashia --source wikiloc # (ждать до достижения max_tracks_per_run, обычно 10-20 минут) ``` Результат фиксируется в `14-deploy-log.md`: - `tracks_by_source.enduro_russia` (ожидаем ≥ 200); - `tracks_by_source.wikiloc` (ожидаем ≥ 1); - длительность каждого прогона; - `errors_count` (ожидаем 0 или ≤ 5% от tracks_new). ### REQ-F-18 — Не менять контракт `/api/gps-tracks` Endpoint `/api/gps-tracks` сохраняет интерфейс ET-008. Новые ID источников (`enduro_russia`, `wikiloc`) появляются в значениях полей ответа естественным образом; никаких новых query-параметров или полей в FeatureCollection не вводится. ### REQ-F-19 — Не менять алгоритм дедупликации `compute_dedup_key` в `dedup.py` не меняется. Никаких новых правил для пары (enduro_russia, wikiloc) — стандартный bbox+length+date-алгоритм должен справиться (см. ADR-006). ### REQ-F-20 — Документация В `docs/work-items/ET-009/` должны существовать после Анализа: - `00-business-request.md` (есть) - `01-brd.md` (создаётся в ET-009) - `02-trz.md` (этот файл) - `03-acceptance-criteria.md` - `04-test-plan.yaml` - `04b-ui-test-cases.md` После реализации добавляются: `07-infra-requirements.md`, `08-data-requirements.md`, `10-tech-risks.md`, `12-review.md`, `13-test-report.md`, `14-deploy-log.md`. ## 4. Не-функциональные требования ### NFR-01 — Производительность сбора EnduroRussia: при `rate_limit_sec=5` и 305 треках полный прогон региона `tsfo_plus_chuvashia` укладывается в ≤ 30 минут (305 × 5 сек ≈ 25 мин + overhead). Wikiloc: первый прогон ограничен `max_tracks_per_run=50` → максимум 50 × (1 search + 1 trail + 1 gpx) × 10 сек ≈ 25 минут. ### NFR-02 — Стабильность Падение Wikiloc-парсера не должно валить весь pipeline. Покрывается существующей логикой `scripts/gps_collect.py` (per-source error не помечает остальные как error). ### NFR-03 — Размер БД Прирост `data/gps_tracks.sqlite` после первого прогона ET-009: ≤ 100 MB при 200 треков EnduroRussia + 50 Wikiloc. Если фактический прирост существенно больше — фиксируется в `14-deploy-log.md`. ### NFR-04 — Логирование Pipeline и parser используют существующий `logger` стандартного формата. Никаких новых форматов или sinks ET-009 не добавляет. ### NFR-05 — Безопасность XML-парсинг GPX выполняется через `defusedxml.ElementTree` (как в ET-008). Никаких изменений по security ET-009 не вносит. ### NFR-06 — Совместимость Контракт `/api/gps-tracks*` не меняется. Существующие клиенты (включая старые версии браузеров пользователей) продолжают работать без обновления. ## 5. План работ (для разработчика) 1. **Сверка ADR-010 / ADR-012 → `status: accepted`** (REQ-F-05). Если нет — STOP. 2. **Правка `config/gps_sources.yaml`** (REQ-F-01, F-02, F-03). 3. **Правка `config/gps_regions.yaml`** (REQ-F-04). 4. **Снапшот реальных ответов API/HTML и сохранение как фикстуры** (REQ-F-06..F-09). Снимки берутся **до** unit-тестов, чтобы тесты опирались на реальные данные. 5. **Расширение Wikiloc-парсера `max_tracks_per_run`** (если ещё нет). 6. **Написание unit-тестов** (REQ-F-10, F-11). 7. **Написание integration-тестов** (REQ-F-12). 8. **Контрактный smoke-тест EnduroRussia** (REQ-F-15). 9. **Расширение стилей карты** (REQ-F-13). 10. **Атрибуция в клиенте** (REQ-F-14). 11. **Прогон всех тестов локально** (`make test`). 12. **Code review → merge → deploy в test**. 13. **Ручной первый прогон** (REQ-F-17). Запись в `14-deploy-log.md`. 14. **Проверка UI** по тест-плану `04b-ui-test-cases.md`. ## 6. Открытые вопросы и решения по умолчанию | Вопрос | Решение по умолчанию | | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | | Считать ли graceful-stop Wikiloc ошибкой? | **Нет.** `pipeline_runs.status='partial'`, exit-code 0. (См. IT-WL-02.) | | Запускать ли cron автоматически после ET-009? | **Нет.** Cron включается отдельным DevOps-task'ом после двух успешных ручных прогонов подряд. | | Маппить ли `wikiloc.act=motorcycle` (19) на `enduro` или `moto`? | **`moto`** (более широкая категория). MAPPING уже так сконфигурирован. | | Что делать с старым URL `enduro-russia.ru` в external_url ранее собранных треков? | Опциональный one-shot `UPDATE`-скрипт; в ET-009 не обязателен (база test-среды чистая для практических целей). | | Wikiloc возвращает `creator=Wikiloc` в GPX тех же треков, что и EnduroRussia? | **Нормально** — на это и нужен dedup-merge. | | Нужно ли менять source_priority? | **Нет.** `osm=100`, `enduro_russia=80`, `wikiloc=70` — порядок задаёт приоритет метаданных при merge. |