@gasolinaradar/repsol-collector 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LEGAL.md ADDED
@@ -0,0 +1,95 @@
1
+ # Legal Notice / Aviso Legal
2
+
3
+ > **Disclaimer:** This document is provided for informational purposes only and does **not** constitute legal advice. If you need legal certainty, consult a qualified attorney in your jurisdiction.
4
+ >
5
+ > **Descargo:** Este documento tiene carácter meramente informativo y **no** constituye asesoramiento legal. Si necesitas seguridad jurídica, consulta con un abogado cualificado en tu jurisdicción.
6
+
7
+ ---
8
+
9
+ ## 1. Independent project / Proyecto independiente
10
+
11
+ **EN:** This library is an **independent, unofficial software client**. It is **not affiliated with, endorsed by, or sponsored by** BonÀrea Agrupa or any of its related companies. The library merely reads a public locator feed and reshapes its responses.
12
+
13
+ **ES:** Esta librería es un **cliente de software independiente y no oficial**. **No está afiliada, respaldada ni patrocinada** por BonÀrea Agrupa ni por ninguna de sus sociedades relacionadas. La librería únicamente lee un feed de localización público y reformatea sus respuestas.
14
+
15
+ ---
16
+
17
+ ## 2. Data source and ownership / Fuente de datos y titularidad
18
+
19
+ **EN:** The data consumed by this library is **public information** published by BonÀrea through its public locator endpoints:
20
+
21
+ - `https://www.bonarea-agrupa.com/locator/Localitzador/Get` (station list)
22
+ - `https://www.bonarea-agrupa.com/locator/Localitzador/GetByID` (station detail)
23
+
24
+ The **data belongs to BonÀrea**. This project **does not own the data**. The library only transforms the raw payload into a normalized structure; it does not create, alter, or endorse the underlying information.
25
+
26
+ **ES:** Los datos que consume esta librería son **información pública** publicada por BonÀrea a través de sus endpoints públicos de localización:
27
+
28
+ - `https://www.bonarea-agrupa.com/locator/Localitzador/Get` (listado de estaciones)
29
+ - `https://www.bonarea-agrupa.com/locator/Localitzador/GetByID` (detalle de estación)
30
+
31
+ Los **datos pertenecen a BonÀrea**. Este proyecto **no es titular de los datos**. La librería solo transforma el payload original en una estructura normalizada; no crea, modifica ni respalda la información subyacente.
32
+
33
+ ---
34
+
35
+ ## 3. Terms of the source / Condiciones de la fuente
36
+
37
+ **EN:** The source website may have its own terms of service, copyright, or usage conditions. You are responsible for reviewing and complying with them before reusing, redistributing, or displaying the data. If the source restricts automated access or scraping, you must not use this library to circumvent those restrictions.
38
+
39
+ **ES:** El sitio web de origen puede tener sus propios términos de servicio, derechos de autor o condiciones de uso. Eres responsable de revisarlos y cumplirlos antes de reutilizar, redistribuir o mostrar los datos. Si la fuente restringe el acceso automatizado o el scraping, no debes usar esta librería para eludir esas restricciones.
40
+
41
+ ---
42
+
43
+ ## 4. Trademarks / Marcas registradas
44
+
45
+ **EN:** All product names, logos, and brands are trademarks of their respective owners and are used only as data points returned by the source. Their appearance in the output does not imply any affiliation or endorsement.
46
+
47
+ **ES:** Todos los nombres de producto, logotipos y marcas son marcas de sus respectivos titulares y se usan únicamente como datos devueltos por la fuente. Su aparición en la salida no implica afiliación ni respaldo alguno.
48
+
49
+ ---
50
+
51
+ ## 5. No warranty / Sin garantía
52
+
53
+ **EN:** This software and the data it returns are provided **"AS IS", WITHOUT WARRANTY OF ANY KIND**, express or implied, including but not limited to warranties of merchantability, fitness for a particular purpose, accuracy, completeness, or timeliness. The source can change, be delayed, or be unavailable at any time.
54
+
55
+ **ES:** Este software y los datos que devuelve se proporcionan **"TAL CUAL", SIN GARANTÍA DE NINGÚN TIPO**, expresa o implícita, incluidas, entre otras, las garantías de comerciabilidad, idoneidad para un fin particular, exactitud, integridad o actualidad. La fuente puede cambiar, retrasarse o no estar disponible en cualquier momento.
56
+
57
+ ---
58
+
59
+ ## 6. Limitation of liability / Limitación de responsabilidad
60
+
61
+ **EN:** To the maximum extent permitted by applicable law, in no event shall the author(s) or contributors be liable for any direct, indirect, incidental, special, consequential, or punitive damages, or any loss of profits or data, arising out of or in connection with the use of this software or the data it returns (including decisions made based on fuel prices). The author(s) are **not responsible** for how you use the data, for violations committed by downstream consumers, or for interruptions or inaccuracies in the source service.
62
+
63
+ **ES:** En la máxima medida permitida por la ley aplicable, en ningún caso el/los autor(es) o colaboradores serán responsables de daños directos, indirectos, incidentales, especiales, consecuenciales o punitivos, ni de pérdidas de beneficios o datos, derivados de o relacionados con el uso de este software o de los datos que devuelve (incluidas las decisiones adoptadas a partir de los precios de los carburantes). El/los autor(es) **no son responsables** de cómo uses los datos, de las infracciones cometidas por consumidores posteriores, ni de interrupciones o inexactitudes del servicio de origen.
64
+
65
+ ---
66
+
67
+ ## 7. Responsible use / Uso responsable
68
+
69
+ **EN:** Do not use this library to flood, overload, or circumvent the source service. Excessive, abusive, or automated requests may affect a service used by many people. Respect the source's terms of service and any rate limiting.
70
+
71
+ **ES:** No uses esta librería para saturar, sobrecargar o eludir el servicio de origen. Las peticiones excesivas, abusivas o automatizadas pueden afectar a un servicio utilizado por muchas personas. Respeta los términos de servicio de la fuente y su limitación de peticiones.
72
+
73
+ ---
74
+
75
+ ## 8. Compliance / Cumplimiento normativo
76
+
77
+ **EN:** You are solely responsible for ensuring that your use of this software and the underlying data complies with all applicable laws and regulations in your jurisdiction, including data protection, intellectual property, and consumer-protection rules.
78
+
79
+ **ES:** Eres el único responsable de garantizar que tu uso de este software y de los datos subyacentes cumple todas las leyes y regulaciones aplicables en tu jurisdicción, incluidas las normas de protección de datos, propiedad intelectual y protección de consumidores.
80
+
81
+ ---
82
+
83
+ ## 9. No endorsement / Sin respaldo oficial
84
+
85
+ **EN:** The appearance of company names or URLs in this project is for identification purposes only and does not mean that BonÀrea endorses, participates in, or is in any way connected to this project.
86
+
87
+ **ES:** La aparición de nombres de empresa o URLs en este proyecto es únicamente con fines de identificación y no significa que BonÀrea respalde, participe o esté conectada de alguna manera con este proyecto.
88
+
89
+ ---
90
+
91
+ ## 10. Changes to this notice / Cambios en este aviso
92
+
93
+ **EN:** This notice may be updated from time to time. Continued use of the library after changes implies acceptance of the updated notice.
94
+
95
+ **ES:** Este aviso puede actualizarse periódicamente. El uso continuado de la librería tras los cambios implica la aceptación del aviso actualizado.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 GasolinaRadar
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,89 @@
1
+ # @gasolinaradar/repsol-collector
2
+
3
+ Node.js collector para el buscador de estaciones de servicio de **Repsol** (España). Devuelve un array de estaciones normalizado, listo para **enriquecer** las fuentes primarias (p. ej. MITERD) vía `matching.enrichStations`.
4
+
5
+ Collector de Node.js para el **buscador de estaciones de servicio de Repsol** (España). Devuelve un array de estaciones normalizado listo para enriquecer las fuentes primarias (p. ej. MITERD) mediante `matching.enrichStations`.
6
+
7
+ ## Fuente de datos / Data source
8
+
9
+ El buscador de Repsol es una SPA (AEM) en `https://www.repsol.es/buscador-eess-y-puntos-de-recarga/`.
10
+ Cada entidad (estaciones, recarga, oficinas) se carga mediante **un único POST por lote** al endpoint interno:
11
+
12
+ ```
13
+ POST https://www.repsol.es/bin/repsol/searchmiddleware/station-search.json
14
+ ?action=search&idioma=ES&tipo=1
15
+ ```
16
+
17
+ Referer: `https://www.repsol.es/buscador-eess-y-puntos-de-recarga/`, `Origin: https://www.repsol.es`.
18
+
19
+ - `action=search` devuelve todas las estaciones de un tipo en una sola respuesta.
20
+ - `tipo=1` = España (default); `tipo=2` = Portugal; `tipo=1,2` = ambos.
21
+ - Respuesta: `{ eess: { count, items: [...] }, recarga: {...}, ... }`. Las estaciones de
22
+ servicio son `eess.items` (~3147 estaciones en España). Cada item incluye `id`, `nombre`,
23
+ `x`/`y` (lon/lat), `direccion`, `cp`, `localidad`, `provincia`, `pais`, `horario`/`horarios`
24
+ y `productos[]` con `precio`/`fecha`.
25
+
26
+ > ⚠️ Es un endpoint interno de una SPA: funciona con una llamada HTTP cruda (verificado con
27
+ > `axios`), pero al ser un endpoint no documentado puede cambiar de forma o aplicar
28
+ > rate-limiting. Si en producción empezara a devolver 4xx, se puede reutilizar una sesión o
29
+ > cookies de navegador obtenidas previamente.
30
+
31
+ ```bash
32
+ npm install @gasolinaradar/repsol-collector
33
+ ```
34
+
35
+ ## Quick start / Inicio rápido
36
+
37
+ ```js
38
+ const { createRepsolCollector } = require('@gasolinaradar/repsol-collector');
39
+
40
+ const collector = createRepsolCollector({ logger: console });
41
+ const stations = await collector.fetch({ reportProgress: (p) => {} });
42
+
43
+ // Opcional: apuntar a otro host/endpoint o cambiar el tipo (país).
44
+ const collector = createRepsolCollector({
45
+ logger: console,
46
+ searchUrl: 'https://www.repsol.es/bin/repsol/searchmiddleware/station-search.json',
47
+ tipo: '1', // '1' España, '2' Portugal, '1,2' ambos
48
+ });
49
+ ```
50
+
51
+ ## Contract / Contrato
52
+
53
+ ```js
54
+ { name: 'repsol', country: 'ES', fetch(context) }
55
+ ```
56
+
57
+ Cada estación normalizada sigue la forma compartida de `@gasolinaradar`:
58
+
59
+ ```js
60
+ {
61
+ source: 'repsol', country: 'ES', sourceStationId,
62
+ name, address, municipality, province, postalCode,
63
+ schedule, services,
64
+ location: { type: 'Point', coordinates: [lon, lat] },
65
+ prices, lastUpdated,
66
+ }
67
+ ```
68
+
69
+ - `location.coordinates` es GeoJSON `[lon, lat]` (de los campos `x`/`y` del payload).
70
+ - `prices` mapea los combustibles con precio a `{ fuelSlug: amount }` (p. ej.
71
+ `{ efitec95: 1.799, diéle: 1.949 }`), tolerando decimales con coma española.
72
+ - `schedule` usa el texto `horario` o formatea el array `horarios` por día.
73
+ - `services` recoge la disponibilidad de productos del item (`productos[].producto`).
74
+ - `lastUpdated` se toma de la fecha de precio más reciente, si existe.
75
+
76
+ ## Tests
77
+
78
+ ```bash
79
+ npm test
80
+ npm run test:live # opcional; golpea el endpoint real de Repsol
81
+ ```
82
+
83
+ ## Legal / Legal
84
+
85
+ Los datos pertenecen a Repsol y se proporcionan "tal cual".
86
+
87
+ ## License / Licencia
88
+
89
+ MIT. See [LICENSE](./LICENSE).
@@ -0,0 +1,20 @@
1
+ # Third Party Notices / Avisos de terceros
2
+
3
+ ## Data source: BonÀrea stations / Fuente de datos: estaciones de BonÀrea
4
+
5
+ This product includes data published by **BonÀrea Agrupa** through its public locator feed:
6
+
7
+ Este producto incluye datos publicados por **BonÀrea Agrupa** a través de su feed público de localización:
8
+
9
+ - `https://www.bonarea-agrupa.com/locator/Localitzador/Get`
10
+ - `https://www.bonarea-agrupa.com/locator/Localitzador/GetByID`
11
+
12
+ The data belongs to BonÀrea and is provided "as is". See [LEGAL.md](./LEGAL.md) for details.
13
+
14
+ Los datos pertenecen a BonÀrea y se proporcionan "tal cual". Consulta [LEGAL.md](./LEGAL.md) para más detalles.
15
+
16
+ ## Runtime dependencies / Dependencias en tiempo de ejecución
17
+
18
+ This library uses the following runtime dependencies / Esta librería usa las siguientes dependencias en tiempo de ejecución:
19
+
20
+ - [axios](https://github.com/axios/axios), licensed under the MIT License (Copyright (c) 2014-present, Axios Contributors) / licenciada bajo la Licencia MIT.
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@gasolinaradar/repsol-collector",
3
+ "version": "1.0.0",
4
+ "description": "Collector for the Repsol fuel station finder (Spain) returning normalized fuel stations for enrichment",
5
+ "main": "src/index.js",
6
+ "exports": {
7
+ ".": "./src/index.js"
8
+ },
9
+ "files": [
10
+ "src",
11
+ "LEGAL.md",
12
+ "THIRD_PARTY_NOTICES.md",
13
+ "README.md",
14
+ "LICENSE"
15
+ ],
16
+ "engines": {
17
+ "node": ">=18"
18
+ },
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/gasolinaradar/repsol-collector.git"
22
+ },
23
+ "homepage": "https://github.com/gasolinaradar/repsol-collector#readme",
24
+ "bugs": {
25
+ "url": "https://github.com/gasolinaradar/repsol-collector/issues"
26
+ },
27
+ "scripts": {
28
+ "test": "node --test test/collector.test.js",
29
+ "test:live": "node --test test/live.test.js",
30
+ "prepublishOnly": "npm test"
31
+ },
32
+ "keywords": [
33
+ "repsol",
34
+ "fuel",
35
+ "stations",
36
+ "gasolineras",
37
+ "espana",
38
+ "spain",
39
+ "combustibles",
40
+ "collector"
41
+ ],
42
+ "license": "MIT",
43
+ "publishConfig": {
44
+ "access": "public"
45
+ },
46
+ "dependencies": {
47
+ "axios": "^1.13.2"
48
+ }
49
+ }
@@ -0,0 +1,17 @@
1
+ const { fetchStations } = require('./fetch');
2
+
3
+ function createRepsolCollector(options = {}) {
4
+ return {
5
+ name: 'repsol',
6
+ country: 'ES',
7
+ async fetch(context = {}) {
8
+ const reportProgress =
9
+ typeof context?.reportProgress === 'function' ? context.reportProgress : () => {};
10
+ return fetchStations(options, { reportProgress });
11
+ },
12
+ };
13
+ }
14
+
15
+ module.exports = {
16
+ createRepsolCollector,
17
+ };
package/src/fetch.js ADDED
@@ -0,0 +1,122 @@
1
+ const axios = require('axios');
2
+ const { normalizeRepsolStation } = require('./normalize');
3
+ const { retry } = require('./retry');
4
+
5
+ // Repsol public fuel station finder is served from AEM as a map SPA. The map loads every
6
+ // station in one bulk POST to the search middleware endpoint; the response groups all point
7
+ // types under entity keys, and fuel stations live under `eess.items` as `{ count, items }`.
8
+ // tipo=1 is Spain-only (tipo=2 is Portugal, tipo=1,2 returns both); the provider builds the
9
+ // Estaciones de servicio card against tipo=1, so that is the default here.
10
+ const DEFAULT_REPSOL_SEARCH_URL =
11
+ 'https://www.repsol.es/bin/repsol/searchmiddleware/station-search.json';
12
+ const DEFAULT_TIPO = '1';
13
+ const DEFAULT_LANGUAGE = 'ES';
14
+ const DEFAULT_TIMEOUT = 20000;
15
+ const DEFAULT_RETRIES = 3;
16
+
17
+ const BASE_HEADERS = {
18
+ Accept: 'application/json, text/plain, */*',
19
+ 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
20
+ 'X-Requested-With': 'XMLHttpRequest',
21
+ 'User-Agent':
22
+ 'Mozilla/5.0 (compatible; GasolinaRadar repsol-collector/1.0; +https://github.com/gasolinaradar/repsol-collector)',
23
+ Origin: 'https://www.repsol.es',
24
+ Referer: 'https://www.repsol.es/buscador-eess-y-puntos-de-recarga/',
25
+ };
26
+
27
+ function resolveLogger(loggerOption) {
28
+ return loggerOption && typeof loggerOption.info === 'function' ? loggerOption : console;
29
+ }
30
+
31
+ function resolveHttpClient(httpClientOption) {
32
+ return httpClientOption && typeof httpClientOption.post === 'function' ? httpClientOption : axios;
33
+ }
34
+
35
+ function resolveUrl(urlOption, fallback) {
36
+ const value = typeof urlOption === 'function' ? urlOption() : urlOption;
37
+ return typeof value === 'string' && value.trim() ? value.trim() : fallback;
38
+ }
39
+
40
+ async function fetchStationList(httpClient, logger, searchUrl, tipo, language, timeout) {
41
+ const params = new URLSearchParams({
42
+ action: 'search',
43
+ idioma: language,
44
+ tipo: tipo,
45
+ });
46
+ const url = `${searchUrl}${searchUrl.includes('?') ? '&' : '?'}${params.toString()}`;
47
+
48
+ logger.info('Requesting Repsol station list', { url });
49
+ const response = await httpClient.post(url, null, {
50
+ headers: BASE_HEADERS,
51
+ timeout,
52
+ });
53
+
54
+ const data = response && response.data;
55
+ const eess = data && typeof data === 'object' ? data.eess : undefined;
56
+ const items = eess && Array.isArray(eess.items) ? eess.items : undefined;
57
+
58
+ if (!items) {
59
+ throw new Error('Unexpected Repsol search response');
60
+ }
61
+
62
+ logger.info('Received Repsol station list', {
63
+ url,
64
+ status: response.status,
65
+ stationCount: items.length,
66
+ });
67
+
68
+ return items;
69
+ }
70
+
71
+ async function fetchStations(options = {}, hooks = {}) {
72
+ const logger = resolveLogger(options.logger);
73
+ const httpClient = resolveHttpClient(options.httpClient);
74
+ const searchUrl = resolveUrl(options.searchUrl, DEFAULT_REPSOL_SEARCH_URL);
75
+ const tipo = options.tipo ?? DEFAULT_TIPO;
76
+ const language = options.language ?? DEFAULT_LANGUAGE;
77
+ const timeout = options.timeout ?? DEFAULT_TIMEOUT;
78
+ const retries = options.retries ?? DEFAULT_RETRIES;
79
+ const reportProgress =
80
+ typeof hooks.reportProgress === 'function' ? hooks.reportProgress : () => {};
81
+
82
+ logger.info('Starting Repsol collector fetch');
83
+ reportProgress(5, { stage: 'fetching_station_list' });
84
+ const rawStations = await retry(
85
+ () => fetchStationList(httpClient, logger, searchUrl, tipo, language, timeout),
86
+ { retries, minTimeoutMs: 1000, logger },
87
+ );
88
+ const totalStations = rawStations.length;
89
+ reportProgress(10, { stage: 'station_list_received', stationCount: totalStations });
90
+
91
+ if (totalStations === 0) {
92
+ reportProgress(100, { stage: 'completed', stationCount: 0 });
93
+ return [];
94
+ }
95
+
96
+ const stations = [];
97
+ for (let index = 0; index < totalStations; index += 1) {
98
+ const normalized = normalizeRepsolStation(rawStations[index]);
99
+ if (normalized) {
100
+ stations.push(normalized);
101
+ }
102
+
103
+ const progress = 10 + Math.round(((index + 1) / totalStations) * 90);
104
+ reportProgress(progress > 100 ? 100 : progress, {
105
+ stage: 'normalizing_stations',
106
+ processed: index + 1,
107
+ total: totalStations,
108
+ });
109
+ }
110
+
111
+ reportProgress(100, { stage: 'completed', stationCount: stations.length });
112
+ return stations;
113
+ }
114
+
115
+ module.exports = {
116
+ fetchStations,
117
+ fetchStationList,
118
+ DEFAULT_REPSOL_SEARCH_URL,
119
+ DEFAULT_TIPO,
120
+ DEFAULT_LANGUAGE,
121
+ BASE_HEADERS,
122
+ };
package/src/index.js ADDED
@@ -0,0 +1,7 @@
1
+ const { createRepsolCollector } = require('./collector');
2
+ const { fetchStations } = require('./fetch');
3
+
4
+ module.exports = {
5
+ createRepsolCollector,
6
+ fetchStations,
7
+ };
@@ -0,0 +1,189 @@
1
+ const decimalCommaRegex = /,/g;
2
+
3
+ function normalizePrice(value) {
4
+ if (value === null || value === undefined) return null;
5
+ const raw = typeof value === 'number' ? value.toString() : String(value).trim();
6
+ if (!raw) return null;
7
+ const normalized = raw.replace(decimalCommaRegex, '.');
8
+ const parsed = Number.parseFloat(normalized);
9
+ return Number.isFinite(parsed) ? parsed : null;
10
+ }
11
+
12
+ function normalizeCoordinate(value) {
13
+ if (value === null || value === undefined) {
14
+ throw new Error('Missing coordinate value');
15
+ }
16
+
17
+ const raw = typeof value === 'number' ? value.toString() : String(value).trim();
18
+ if (!raw) {
19
+ throw new Error('Empty coordinate value');
20
+ }
21
+
22
+ const normalized = raw.replace(decimalCommaRegex, '.');
23
+ const parsed = Number.parseFloat(normalized);
24
+ if (!Number.isFinite(parsed)) {
25
+ throw new Error(`Invalid coordinate value: ${value}`);
26
+ }
27
+
28
+ return parsed;
29
+ }
30
+
31
+ function slugifyFuelName(name) {
32
+ return name
33
+ .toLowerCase()
34
+ .replace(/[^a-z0-9]+/g, ' ')
35
+ .trim()
36
+ .replace(/\s+/g, '');
37
+ }
38
+
39
+ function cleanText(value) {
40
+ if (typeof value !== 'string' || value.length === 0) {
41
+ return '';
42
+ }
43
+ return value.replace(/\s+/g, ' ').trim();
44
+ }
45
+
46
+ function normalizeOptionalText(value) {
47
+ const cleaned = cleanText(value);
48
+ return cleaned.length > 0 ? cleaned : undefined;
49
+ }
50
+
51
+ function parseRepsolPrices(products) {
52
+ if (!Array.isArray(products) || products.length === 0) {
53
+ return undefined;
54
+ }
55
+
56
+ const prices = products.reduce((acc, entry) => {
57
+ if (!entry || typeof entry !== 'object') {
58
+ return acc;
59
+ }
60
+
61
+ const productName = cleanText(entry.producto);
62
+ const slug = slugifyFuelName(productName);
63
+ if (!slug || Object.prototype.hasOwnProperty.call(acc, slug)) {
64
+ return acc;
65
+ }
66
+
67
+ const amount = normalizePrice(entry.precio);
68
+ if (amount === null) {
69
+ return acc;
70
+ }
71
+
72
+ acc[slug] = amount;
73
+ return acc;
74
+ }, {});
75
+
76
+ return Object.keys(prices).length > 0 ? prices : undefined;
77
+ }
78
+
79
+ function parseRepsolServices(products) {
80
+ if (!Array.isArray(products) || products.length === 0) {
81
+ return undefined;
82
+ }
83
+
84
+ const services = products
85
+ .map((entry) => (entry && typeof entry === 'object' ? normalizeOptionalText(entry.producto) : undefined))
86
+ .filter(Boolean);
87
+
88
+ return services.length > 0 ? services : undefined;
89
+ }
90
+
91
+ function formatRepsolSchedule(schedule) {
92
+ if (typeof schedule === 'string' && schedule.trim()) {
93
+ return schedule.trim();
94
+ }
95
+
96
+ if (!Array.isArray(schedule) || schedule.length === 0) {
97
+ return undefined;
98
+ }
99
+
100
+ const parts = schedule
101
+ .map((entry) => {
102
+ if (!entry || typeof entry !== 'object') {
103
+ return undefined;
104
+ }
105
+ const day = normalizeOptionalText(entry.dia);
106
+ const hours = normalizeOptionalText(entry.horario);
107
+ if (!day || !hours) {
108
+ return undefined;
109
+ }
110
+ return `${day}: ${hours}`;
111
+ })
112
+ .filter(Boolean);
113
+
114
+ return parts.length > 0 ? parts.join(', ') : undefined;
115
+ }
116
+
117
+ function latestProductDate(products) {
118
+ if (!Array.isArray(products) || products.length === 0) {
119
+ return undefined;
120
+ }
121
+
122
+ let latest;
123
+ for (const entry of products) {
124
+ if (!entry || typeof entry !== 'object') {
125
+ continue;
126
+ }
127
+ const raw = entry.fecha;
128
+ const time = typeof raw === 'string' ? Date.parse(raw) : NaN;
129
+ if (Number.isFinite(time) && (latest === undefined || time > latest)) {
130
+ latest = time;
131
+ }
132
+ }
133
+
134
+ return latest === undefined ? undefined : new Date(latest);
135
+ }
136
+
137
+ function normalizeRepsolStation(raw) {
138
+ if (!raw || typeof raw !== 'object') {
139
+ return null;
140
+ }
141
+
142
+ if (raw.pais && String(raw.pais).trim().toLowerCase() !== 'españa') {
143
+ return null;
144
+ }
145
+
146
+ let latitude;
147
+ let longitude;
148
+ try {
149
+ latitude = normalizeCoordinate(raw.y);
150
+ longitude = normalizeCoordinate(raw.x);
151
+ } catch (error) {
152
+ return null;
153
+ }
154
+
155
+ const prices = parseRepsolPrices(raw.productos);
156
+ const services = parseRepsolServices(raw.productos);
157
+ const productDate = latestProductDate(raw.productos);
158
+
159
+ return {
160
+ source: 'repsol',
161
+ country: 'ES',
162
+ sourceStationId: raw.id ?? undefined,
163
+ name: normalizeOptionalText(raw.nombre),
164
+ address: normalizeOptionalText(raw.direccion),
165
+ municipality: normalizeOptionalText(raw.localidad),
166
+ province: normalizeOptionalText(raw.provincia),
167
+ postalCode: normalizeOptionalText(raw.cp),
168
+ schedule: formatRepsolSchedule(raw.horario ?? raw.horarios),
169
+ services,
170
+ location: {
171
+ type: 'Point',
172
+ coordinates: [longitude, latitude],
173
+ },
174
+ prices,
175
+ lastUpdated: productDate ?? new Date(),
176
+ };
177
+ }
178
+
179
+ module.exports = {
180
+ normalizePrice,
181
+ normalizeCoordinate,
182
+ slugifyFuelName,
183
+ cleanText,
184
+ normalizeOptionalText,
185
+ parseRepsolPrices,
186
+ parseRepsolServices,
187
+ formatRepsolSchedule,
188
+ normalizeRepsolStation,
189
+ };
package/src/retry.js ADDED
@@ -0,0 +1,25 @@
1
+ async function retry(fn, options = {}) {
2
+ const { retries = 3, factor = 2, minTimeoutMs = 500, logger = console } = options;
3
+
4
+ let delay = minTimeoutMs;
5
+
6
+ for (let attempt = 0; attempt <= retries; attempt += 1) {
7
+ try {
8
+ return await fn();
9
+ } catch (error) {
10
+ if (attempt === retries) {
11
+ throw error;
12
+ }
13
+
14
+ const nextAttempt = attempt + 1;
15
+ const message = error instanceof Error ? error.message : String(error);
16
+ logger.warn(`Retry attempt ${nextAttempt} after failure: ${message}`);
17
+ await new Promise((resolve) => setTimeout(resolve, delay));
18
+ delay *= factor;
19
+ }
20
+ }
21
+ }
22
+
23
+ module.exports = {
24
+ retry,
25
+ };