feat(ET-009): activate EnduroRussia + Wikiloc GPS sources
Some checks failed
CI / lint (push) Failing after 4s
CI / test (push) Failing after 5s
CI / build (push) Has been skipped
CI / lint (pull_request) Failing after 4s
CI / test (pull_request) Failing after 5s
CI / build (pull_request) Has been skipped

Конфиг-only активация двух новых источников GPS-треков поверх
pipeline ET-008. Не вводит новых компонентов, БД-таблиц, endpoint'ов.

Config:
- config/gps_sources.yaml: enduro_russia enabled=true, base_url исправлен
  на endurorussia.ru (без дефиса); добавлена запись wikiloc с
  max_tracks_per_run=50, activity_filter=[motorcycle, enduro].
- config/gps_regions.yaml: wikiloc добавлен в tsfo_plus_chuvashia.sources.

Parser:
- wikiloc.py: добавлен soft-cap max_tracks_per_run в collect(),
  извлечение created_at из GPX metadata/первого trkpt — для корректной
  межисточниковой дедупликации с EnduroRussia.

UI (src/web/gps_tracks.js):
- GPS_SOURCE_COLORS: добавлен цвет wikiloc (#4363d8).
- Дефолтный фильтр sources включает wikiloc.
- GPS_SOURCE_ATTRIBUTIONS: маппинг source_id → строка атрибуции;
  _updateGpsAttribution() подтягивает /api/gps-tracks/health и
  выставляет attribution с теми источниками, у которых tracks > 0.
- _buildGpsFiltersUI: чекбокс «Wikiloc» в #gps-source-grid.

Tests:
- Fixtures: 7 файлов в tests/fixtures/gps-tracks/.
- Unit: 10 UT-ER + 10 UT-WL — парсеры, MAPPING, bbox-фильтр,
  pagination, 429/403 graceful-stop, rate-limit, max_tracks_per_run.
- Integration: IT-ER-01, IT-WL-01, IT-WL-02, IT-DEDUP-01, IT-LIC-01
  через scripts.gps_collect.main + httpx.MockTransport.
- Contract: 2 CT-ER с маркером @pytest.mark.network (nightly only).
- JS: 2 новых теста на наличие wikiloc в SOURCE_COLORS и в фильтрах.

Linters/Tests: ruff clean (новые файлы), 166 pytest passed,
24 JS-tests passed.

Refs: ET-009
Acceptance: AC-01..AC-08, AC-14..AC-17 (для AC-09..AC-13 — продакшн-прогон)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-01 19:38:55 +00:00
parent 4be7fbf3de
commit 3577ff32ac
19 changed files with 1287 additions and 19 deletions

View File

@@ -62,6 +62,8 @@ class WikilocParser(SourceParser):
source_id = self.config.get("id", "wikiloc")
source_priority = self.config.get("source_priority", 70)
activity_filter = self.config.get("activity_filter", ["motorcycle", "enduro"])
max_tracks = self.config.get("max_tracks_per_run")
yielded = 0
headers = {
"User-Agent": user_agent,
@@ -188,7 +190,15 @@ class WikilocParser(SourceParser):
):
continue
if max_tracks is not None and yielded >= max_tracks:
logger.info(
"Wikiloc: reached max_tracks_per_run=%d, stopping",
max_tracks,
)
return
yield track
yielded += 1
page += 1
@@ -260,16 +270,40 @@ def _parse_gpx(
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
# Извлекаем название и время из GPX metadata
created_at = None
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" and not name:
name = meta_child.text
elif local2 == "time" and meta_child.text:
created_at = meta_child.text.strip()
break
# Fallback: первая <trkpt><time> из первого trkseg
if not created_at:
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:
for sub in trkpt:
sub_local = sub.tag.replace(ns, "") if ns else sub.tag
if sub_local == "time" and sub.text:
created_at = sub.text.strip()
break
if created_at:
break
if created_at:
break
if created_at:
break
coords = []
@@ -324,7 +358,7 @@ def _parse_gpx(
description=None,
activity_type=activity_type,
user=None,
created_at=None,
created_at=created_at,
length_m=length_m,
points_count=len(coords),
geom_wkb=geom_wkb,

View File

@@ -10,12 +10,23 @@ const GPS_TRACKS_MIN_ZOOM = 8; // ниже — слой скрыт
const GPS_SOURCE_COLORS = {
osm: '#3cb44b',
enduro_russia: '#e6194b',
ttrails: '#4363d8',
wikiloc: '#4363d8',
ttrails: '#911eb4',
offmaps: '#f58231',
nakarte: '#911eb4',
nakarte: '#f032e6',
};
const GPS_FALLBACK_COLORS = ['#42d4f4', '#f032e6', '#bfef45', '#fabed4', '#469990', '#dcbeff', '#9A6324', '#fffac8'];
// ET-009: атрибуция для каждого источника. Используется при сборке
// MapLibre attribution control: к строке source-attribution добавляются
// все источники, у которых tracks_by_source > 0.
const GPS_SOURCE_ATTRIBUTIONS = {
osm: '© OpenStreetMap contributors (ODbL)',
enduro_russia: 'EnduroRussia.ru',
wikiloc: '© Wikiloc contributors',
ttrails: 'ttrails.ru',
};
const GPS_ACTIVITY_COLORS = {
enduro: '#e6194b',
moto: '#f58231',
@@ -52,7 +63,7 @@ window.gpsTracksLayer = {
enabled: false,
filters: {
activities: ['enduro', 'moto', 'offroad', 'bicycle', 'hike', 'ski', 'other'],
sources: ['osm', 'enduro_russia', 'ttrails'],
sources: ['osm', 'enduro_russia', 'wikiloc', 'ttrails'],
colorMode: 'source'
},
sourceId: 'gps-tracks-tiles',
@@ -188,6 +199,44 @@ function _ensureGpsLayers(map) {
}
}
/**
* ET-009: динамически обновляет attribution источника `gps-tracks-tiles` на
* основе ответа /api/gps-tracks/health.tracks_by_source. Включает в строку
* атрибуцию каждого источника, у которого > 0 треков в БД. Падение запроса —
* не блокирующее: остаётся последняя установленная атрибуция.
*/
async function _updateGpsAttribution(map) {
if (!map || !map.getSource) return;
const basePath = window.location.pathname.replace(/\/[^/]*$/, '') || '';
try {
const resp = await fetch(`${basePath}/api/gps-tracks/health`);
if (!resp.ok) return;
const data = await resp.json();
const counts = data && data.tracks_by_source ? data.tracks_by_source : {};
const labels = [];
for (const src of Object.keys(GPS_SOURCE_ATTRIBUTIONS)) {
if (counts[src] && counts[src] > 0) {
labels.push(GPS_SOURCE_ATTRIBUTIONS[src]);
}
}
if (labels.length === 0) {
labels.push(GPS_SOURCE_ATTRIBUTIONS.osm);
}
const attribution = labels.join(', ');
const src = map.getSource(window.gpsTracksLayer.sourceId);
if (src && typeof src.attribution !== 'undefined') {
src.attribution = attribution;
}
// MapLibre не перечитывает source.attribution автоматически —
// дергаем resize чтобы обновить AttributionControl
if (typeof map._controls !== 'undefined') {
try { map.resize(); } catch (_) { /* noop */ }
}
} catch (_) {
// network failure — оставляем дефолтную атрибуцию
}
}
function _findGpsInsertPosition(map) {
/**
* Returns the id of the first layer that GPS tracks should be inserted
@@ -411,6 +460,8 @@ function onPublicTracksCheckbox() {
_ensureGpsSources(map);
_ensureGpsLayers(map);
_setupGpsClickHandler(map);
// ET-009: подтянуть актуальные атрибуции (osm, enduro_russia, wikiloc)
_updateGpsAttribution(map);
// Убедиться, что moveend listener есть
map.off('moveend', onGpsMapMoveEnd);
@@ -476,8 +527,13 @@ function _buildGpsFiltersUI() {
// Источники (из localStorage или дефолт)
const srcGrid = document.getElementById('gps-source-grid');
if (srcGrid) {
const allSources = ['osm', 'enduro_russia', 'ttrails'];
const sourceLabels = { osm: 'OSM', enduro_russia: 'EnduroRussia.ru', ttrails: 'Тропинки.ру' };
const allSources = ['osm', 'enduro_russia', 'wikiloc', 'ttrails'];
const sourceLabels = {
osm: 'OSM',
enduro_russia: 'EnduroRussia',
wikiloc: 'Wikiloc',
ttrails: 'Тропинки.ру',
};
srcGrid.innerHTML = allSources.map(src => {
const checked = window.gpsTracksLayer.filters.sources.includes(src);
return `