feat(ET-009): activate EnduroRussia + Wikiloc GPS sources
Конфиг-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:
@@ -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,
|
||||
|
||||
@@ -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 `
|
||||
|
||||
Reference in New Issue
Block a user