---
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/<source_id>.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. Один из них должен:
- содержать `<trk><trkseg><trkpt>` с ≥ 10 точками;
- лежать в bbox региона `tsfo_plus_chuvashia` (29..47.5 longitude,
  49.5..60.0 latitude);
- иметь creator или metadata, идентифицирующее источник.

Второй GPX должен быть пустой (`<trkseg></trkseg>`) или с 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/<slug>/<id>`.

### REQ-F-09 — Тест-фикстуры Wikiloc страницы трека и GPX

- `tests/fixtures/gps-tracks/wikiloc-trail-page.html` — снимок
  страницы одного трека Wikiloc; должен содержать `<h1>` с
  названием и либо прямую ссылку на `.gpx`, либо
  `downloadTrail.do?id=<id>`.
- `tests/fixtures/gps-tracks/wikiloc-track.gpx` — реальный GPX,
  возвращаемый `/wikiloc/downloadTrail.do?id=<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/<id>"`.
- **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=<track_id>`.
- **UT-WL-04.** `_extract_track_name` извлекает текст `<h1>`.
- **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.    |
