├── .prettierrc ├── integrations ├── arcgis │ ├── src │ │ ├── index.ts │ │ ├── console-runner.ts │ │ ├── integration.ts │ │ ├── api │ │ │ ├── arcgis-embalse-model.ts │ │ │ ├── getLatestEntries.ts │ │ │ └── arcgis-embalse.api.ts │ │ └── arcgis.mappers.ts │ ├── tsconfig.json │ └── package.json ├── scraping-cuenca-jucar │ ├── src │ │ ├── index.ts │ │ ├── scraper │ │ │ ├── index.ts │ │ │ ├── business.ts │ │ │ └── mapper.ts │ │ ├── api │ │ │ ├── index.ts │ │ │ ├── cuenca.model.ts │ │ │ └── cuenca.api.ts │ │ ├── console-runner.ts │ │ └── integration.ts │ ├── package.json │ └── tsconfig.json ├── scraping-cuenca-segura │ ├── src │ │ ├── index.ts │ │ ├── scraper │ │ │ ├── index.ts │ │ │ ├── mapper.ts │ │ │ └── business.ts │ │ ├── api │ │ │ ├── index.ts │ │ │ ├── cuenca.model.ts │ │ │ └── cuenca.api.ts │ │ ├── console-runner.ts │ │ └── integration.ts │ ├── package.json │ └── tsconfig.json ├── scraping-cuenca-catalana │ ├── src │ │ ├── api │ │ │ ├── index.ts │ │ │ ├── cuenca.api.ts │ │ │ └── cuenca.api-model.ts │ │ ├── index.ts │ │ ├── console-runner.ts │ │ ├── business.ts │ │ ├── integrations.ts │ │ └── cuenca.mapper.ts │ ├── package.json │ └── tsconfig.json ├── scraping-cuenca-tajo │ ├── src │ │ ├── index.ts │ │ ├── scraper │ │ │ ├── index.ts │ │ │ ├── mapper.ts │ │ │ └── business.ts │ │ ├── api │ │ │ ├── index.ts │ │ │ ├── cuenca.model.ts │ │ │ └── cuenca.api.ts │ │ ├── console-runner.ts │ │ └── integration.ts │ ├── tsconfig.json │ └── package.json ├── scraping-cuenca-guadalquivir │ ├── src │ │ ├── index.ts │ │ ├── api │ │ │ ├── index.ts │ │ │ ├── cuenca.api.ts │ │ │ └── cuenca.model.ts │ │ ├── scraper │ │ │ ├── index.ts │ │ │ ├── mapper.ts │ │ │ └── business.ts │ │ ├── console-runner.ts │ │ └── integration.ts │ ├── package.json │ └── tsconfig.json ├── scraping-cuenca-mediterranea │ ├── src │ │ ├── index.ts │ │ ├── scraper │ │ │ ├── index.ts │ │ │ ├── mapper.ts │ │ │ └── business.ts │ │ ├── api │ │ │ ├── index.ts │ │ │ ├── cuenca.api.ts │ │ │ └── cuenca.model.ts │ │ ├── console-runner.ts │ │ ├── file.helper.ts │ │ └── integration.ts │ ├── tsconfig.json │ └── package.json ├── scraping-cuenca-mino-sil │ ├── src │ │ ├── index.ts │ │ ├── console-runner.ts │ │ └── integration.ts │ ├── tsconfig.json │ └── package.json ├── scraping-cuenca-duero │ ├── src │ │ ├── scraper │ │ │ ├── index.ts │ │ │ ├── mapper.ts │ │ │ └── business.ts │ │ ├── test-scraper.ts │ │ ├── api │ │ │ └── cuenca.model.ts │ │ ├── console-runner.ts │ │ ├── integration.ts │ │ └── integration.test.ts │ ├── package.json │ └── tsconfig.json ├── scraping-cuenca-guadiana │ ├── src │ │ ├── scraper │ │ │ ├── index.ts │ │ │ ├── business.ts │ │ │ └── mapper.ts │ │ ├── api │ │ │ ├── index.ts │ │ │ ├── cuenca.model.ts │ │ │ └── cuenca.api.ts │ │ ├── console-runner.ts │ │ ├── file.helper.ts │ │ └── integration.ts │ ├── package.json │ └── tsconfig.json └── scraping-cuenca-cantabrico │ ├── src │ ├── console-runner.ts │ ├── scraper │ │ ├── mapper.ts │ │ └── business.ts │ ├── integration.ts │ └── api │ │ └── index.ts │ ├── package.json │ └── tsconfig.json ├── packages ├── db │ ├── src │ │ ├── core │ │ │ └── servers │ │ │ │ ├── index.ts │ │ │ │ └── db.server.ts │ │ ├── dals │ │ │ ├── metadatos │ │ │ │ ├── index.ts │ │ │ │ └── metadatos.context.ts │ │ │ ├── cuencas │ │ │ │ ├── index.ts │ │ │ │ ├── cuencas.context.ts │ │ │ │ └── cuencas.repository.ts │ │ │ ├── embalses │ │ │ │ ├── index.ts │ │ │ │ ├── embalses.helpers.ts │ │ │ │ ├── embalses.context.ts │ │ │ │ ├── embalses.mappers.ts │ │ │ │ └── embalses.repository.ts │ │ │ └── index.ts │ │ ├── index.ts │ │ └── console-runners │ │ │ ├── constants.ts │ │ │ ├── helpers.ts │ │ │ ├── questions.ts │ │ │ ├── cuencas-seed │ │ │ └── index.ts │ │ │ └── index.ts │ ├── .env.example │ ├── docker-compose.yml │ ├── tsconfig.json │ ├── setup.js │ └── package.json └── db-model │ ├── src │ ├── index.ts │ └── model.ts │ ├── package.json │ └── tsconfig.json ├── front ├── src │ └── app │ │ ├── layouts │ │ ├── index.ts │ │ ├── header.component.tsx │ │ └── footer.component.tsx │ │ ├── embalse-provincia │ │ ├── page.tsx │ │ └── [provincia] │ │ │ └── page.tsx │ │ ├── embalse │ │ └── [embalse] │ │ │ └── page.tsx │ │ ├── page.tsx │ │ ├── layout.tsx │ │ └── globals.css ├── postcss.config.js ├── next-env.d.ts ├── package.json ├── tsconfig.json └── public │ └── images │ └── logo.svg ├── .gitignore ├── functions ├── .funcignore ├── src │ ├── index.ts │ └── functions │ │ ├── arcgis-function.ts │ │ └── scraping-functions.ts ├── host.json ├── tsconfig.json ├── package.json ├── .gitignore └── package-lock.json ├── run-scripts ├── index.ts ├── helpers.ts ├── console-runners.ts └── start.ts ├── vitest.config.ts ├── turbo.json ├── .github └── workflows │ └── ci.yml ├── package.json ├── LICENSE └── README.md /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["prettier-plugin-tailwindcss"] 3 | } 4 | -------------------------------------------------------------------------------- /integrations/arcgis/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./integration.js"; 2 | -------------------------------------------------------------------------------- /packages/db/src/core/servers/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./db.server.js"; 2 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-jucar/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./integration"; 2 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-segura/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./integration"; 2 | -------------------------------------------------------------------------------- /packages/db/src/dals/metadatos/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./metadatos.context.js"; 2 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-catalana/src/api/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./cuenca.api"; 2 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-catalana/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./integrations"; 2 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-tajo/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./integration"; 2 | 3 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-guadalquivir/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./integration"; 2 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-mediterranea/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./integration.js"; 2 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-mino-sil/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./integration"; 2 | 3 | -------------------------------------------------------------------------------- /packages/db/.env.example: -------------------------------------------------------------------------------- 1 | MONGODB_CONNECTION_STRING: mongodb://localhost:27017/embalse-info 2 | -------------------------------------------------------------------------------- /packages/db-model/src/index.ts: -------------------------------------------------------------------------------- 1 | // Barrel for db-model package 2 | 3 | export * from "./model.js"; 4 | -------------------------------------------------------------------------------- /front/src/app/layouts/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./header.component"; 2 | export * from "./footer.component"; 3 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-duero/src/scraper/index.ts: -------------------------------------------------------------------------------- 1 | export * from './business'; 2 | export * from './mapper'; 3 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-jucar/src/scraper/index.ts: -------------------------------------------------------------------------------- 1 | export * from './business'; 2 | export * from './mapper'; 3 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-segura/src/scraper/index.ts: -------------------------------------------------------------------------------- 1 | export * from './business'; 2 | export * from './mapper'; 3 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-tajo/src/scraper/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./business"; 2 | export * from "./mapper"; 3 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-guadiana/src/scraper/index.ts: -------------------------------------------------------------------------------- 1 | export * from './business'; 2 | export * from './mapper'; 3 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-tajo/src/api/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./cuenca.api"; 2 | export * from "./cuenca.model"; 3 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-guadalquivir/src/api/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./cuenca.api"; 2 | export * from "./cuenca.model"; 3 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-guadalquivir/src/scraper/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./business"; 2 | export * from "./mapper"; 3 | -------------------------------------------------------------------------------- /packages/db/src/dals/cuencas/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./cuencas.context.js"; 2 | export * from "./cuencas.repository.js"; 3 | -------------------------------------------------------------------------------- /packages/db/src/dals/embalses/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./embalses.context.js"; 2 | export * from "./embalses.repository.js"; 3 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-mediterranea/src/scraper/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./business.js"; 2 | export * from "./mapper.js"; 3 | -------------------------------------------------------------------------------- /packages/db/src/index.ts: -------------------------------------------------------------------------------- 1 | import "dotenv/config"; 2 | export * from "#core/servers/index.js"; 3 | export * from "#dals/index.js"; 4 | -------------------------------------------------------------------------------- /packages/db/src/console-runners/constants.ts: -------------------------------------------------------------------------------- 1 | export const CONTAINER_NAME = "embalse-info"; 2 | export const DB_NAME = "embalse-info"; 3 | -------------------------------------------------------------------------------- /front/postcss.config.js: -------------------------------------------------------------------------------- 1 | const config = { 2 | plugins: { 3 | "@tailwindcss/postcss": {}, 4 | }, 5 | }; 6 | export default config; 7 | -------------------------------------------------------------------------------- /packages/db/src/dals/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./cuencas/index.js"; 2 | export * from "./embalses/index.js"; 3 | export * from "./metadatos/index.js"; 4 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-jucar/src/api/index.ts: -------------------------------------------------------------------------------- 1 | // Barrel file for API exports 2 | export * from './cuenca.api'; 3 | export * from './cuenca.model'; 4 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-segura/src/api/index.ts: -------------------------------------------------------------------------------- 1 | // Barrel file for API exports 2 | export * from './cuenca.api'; 3 | export * from './cuenca.model'; 4 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-guadiana/src/api/index.ts: -------------------------------------------------------------------------------- 1 | // Barrel file for API exports 2 | export * from './cuenca.api'; 3 | export * from './cuenca.model'; 4 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-mediterranea/src/api/index.ts: -------------------------------------------------------------------------------- 1 | // Barrel file for API exports 2 | export * from "./cuenca.api.js"; 3 | export * from "./cuenca.model.js"; 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .turbo 4 | .next 5 | __azurite_db_*.json 6 | __blobstorage__/ 7 | __queuestorage__/ 8 | __tablestorage__/ 9 | mongo-data 10 | .env -------------------------------------------------------------------------------- /functions/.funcignore: -------------------------------------------------------------------------------- 1 | *.js.map 2 | *.ts 3 | .git* 4 | .vscode 5 | __azurite_db*__.json 6 | __blobstorage__ 7 | __queuestorage__ 8 | local.settings.json 9 | test 10 | tsconfig.json -------------------------------------------------------------------------------- /packages/db-model/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "db-model", 3 | "version": "1.0.0", 4 | "private": true, 5 | "type": "module", 6 | "exports": { 7 | ".": "./src/index.ts" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /functions/src/index.ts: -------------------------------------------------------------------------------- 1 | import { app } from "@azure/functions"; 2 | 3 | app.setup({ 4 | enableHttpStream: true, 5 | }); 6 | 7 | import "./functions/arcgis-function.js"; 8 | import "./functions/scraping-functions.js"; 9 | -------------------------------------------------------------------------------- /run-scripts/index.ts: -------------------------------------------------------------------------------- 1 | import { exec } from "./helpers"; 2 | const [runCommand] = process.argv.slice(2); 3 | 4 | const { command } = await import(`./${runCommand}`); 5 | 6 | if (command) { 7 | exec(command); 8 | } 9 | -------------------------------------------------------------------------------- /packages/db/src/dals/embalses/embalses.helpers.ts: -------------------------------------------------------------------------------- 1 | export const parseDate = (dateStr: string): Date => { 2 | const [day, month, year] = dateStr.split("/").map(Number); 3 | return new Date(year, month - 1, day); 4 | }; 5 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-duero/src/test-scraper.ts: -------------------------------------------------------------------------------- 1 | import { getEstadoCuencaDuero } from './integration'; 2 | 3 | (async () => { 4 | const data = await getEstadoCuencaDuero(); 5 | console.log(JSON.stringify(data, null, 2)); 6 | })(); 7 | -------------------------------------------------------------------------------- /packages/db/src/dals/cuencas/cuencas.context.ts: -------------------------------------------------------------------------------- 1 | import { dbServer } from "#core/servers/index.js"; 2 | import type { Cuenca } from "db-model"; 3 | 4 | export const getCuencasContext = () => 5 | dbServer.db?.collection("cuencas"); 6 | -------------------------------------------------------------------------------- /packages/db/src/dals/embalses/embalses.context.ts: -------------------------------------------------------------------------------- 1 | import { dbServer } from "#core/servers/index.js"; 2 | import type { Embalse } from "db-model"; 3 | 4 | export const getEmbalsesContext = () => 5 | dbServer.db?.collection("embalses"); 6 | -------------------------------------------------------------------------------- /integrations/arcgis/src/console-runner.ts: -------------------------------------------------------------------------------- 1 | import { scrapeSeedEmbalses } from "./integration.js"; 2 | 3 | console.log("Starting ArcGis console runner..."); 4 | const result = await scrapeSeedEmbalses(); 5 | console.log(JSON.stringify(result, null, 2)); 6 | -------------------------------------------------------------------------------- /packages/db/src/dals/metadatos/metadatos.context.ts: -------------------------------------------------------------------------------- 1 | import { dbServer } from "#core/servers/index.js"; 2 | import type { MetaDatos } from "db-model"; 3 | 4 | export const getMetadatosContext = () => 5 | dbServer.db?.collection("metadatos"); 6 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-mino-sil/src/console-runner.ts: -------------------------------------------------------------------------------- 1 | import { getEstadoCuencaMinoSil } from "./integration"; 2 | 3 | console.log("Estado de la Cuenca Miño Sil:"); 4 | const result = await getEstadoCuencaMinoSil(); 5 | console.log(JSON.stringify(result, null, 2)); 6 | -------------------------------------------------------------------------------- /packages/db/src/console-runners/helpers.ts: -------------------------------------------------------------------------------- 1 | import type { Choice } from 'prompts'; 2 | 3 | export const filterChoices = (input: string, choices: Choice[]) => 4 | Promise.resolve(choices.filter(choice => choice.title.toLocaleLowerCase().includes(input.toLocaleLowerCase()))); 5 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-jucar/src/console-runner.ts: -------------------------------------------------------------------------------- 1 | import { scrapeCuencaJucar } from './integration'; 2 | 3 | const URL = 'https://saih.chj.es/resumen-embalses'; 4 | console.log('Estado de la Cuenca Júcar:'); 5 | const result = await scrapeCuencaJucar(URL); 6 | console.log(result); 7 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | // vitest.config.ts 2 | import { defineConfig } from 'vitest/config'; 3 | 4 | export default defineConfig({ 5 | test: { 6 | // Le indicamos a Vitest que nuestros tests se ejecutan en un entorno de Node, no en un navegador. 7 | environment: 'node', 8 | }, 9 | }); -------------------------------------------------------------------------------- /integrations/scraping-cuenca-segura/src/api/cuenca.model.ts: -------------------------------------------------------------------------------- 1 | export interface EmbalsesSegura { 2 | id: number; 3 | embalse: string; 4 | provincia: string; 5 | porcentajeActual: number; 6 | capacidadTotalHm3: number; 7 | volumenActualHm3: number; 8 | fecha: string; 9 | } 10 | -------------------------------------------------------------------------------- /front/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | /// 4 | 5 | // NOTE: This file should not be edited 6 | // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. 7 | -------------------------------------------------------------------------------- /packages/db-model/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "ESNext", 5 | "moduleResolution": "bundler", 6 | "skipLibCheck": true, 7 | "isolatedModules": true, 8 | "esModuleInterop": true 9 | }, 10 | "include": ["src"] 11 | } 12 | -------------------------------------------------------------------------------- /packages/db/docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | embalse-info: 3 | container_name: embalse-info 4 | image: mongo:8 5 | ports: 6 | - "27017:27017" 7 | volumes: 8 | - type: bind 9 | source: ./mongo-data 10 | target: /data/db 11 | volumes: 12 | mongo-data: 13 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-tajo/src/console-runner.ts: -------------------------------------------------------------------------------- 1 | import { scrapeCuencaTajo } from "./integration"; 2 | 3 | const url = "https://saihtajo.chtajo.es/#nav"; 4 | 5 | console.log("Estado de la Cuenca del Tajo:"); 6 | const result = await scrapeCuencaTajo(url); 7 | console.log(JSON.stringify(result, null, 2)); 8 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-tajo/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "ESNext", 5 | "moduleResolution": "bundler", 6 | "skipLibCheck": true, 7 | "isolatedModules": true, 8 | "esModuleInterop": true 9 | }, 10 | "include": ["src"] 11 | } 12 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-mino-sil/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "ESNext", 5 | "moduleResolution": "bundler", 6 | "skipLibCheck": true, 7 | "isolatedModules": true, 8 | "esModuleInterop": true 9 | }, 10 | "include": ["src"] 11 | } 12 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-cantabrico/src/console-runner.ts: -------------------------------------------------------------------------------- 1 | import { scrapeCuencaCantabrica } from "./integration.js"; 2 | 3 | // Imprime por terminal el resultado final 4 | console.log("Estado de la Cuenca Cantábrica:"); 5 | const result = await scrapeCuencaCantabrica(); 6 | console.log(JSON.stringify(result, null, 2)); 7 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-catalana/src/console-runner.ts: -------------------------------------------------------------------------------- 1 | import { integracionCuencaCatalana } from "./integrations"; 2 | import { URL } from "./integrations"; 3 | 4 | console.log("Estados de las Cuencas Catalanas:"); 5 | const result = await integracionCuencaCatalana(URL); 6 | console.log(JSON.stringify(result, null, 2)); 7 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-mediterranea/src/console-runner.ts: -------------------------------------------------------------------------------- 1 | import { scrapeCuencaMediterranea } from "./integration.js"; 2 | 3 | console.log("Estado de la Cuenca Mediterránea:"); 4 | const scrappingCuencaMediterranea = await scrapeCuencaMediterranea(); 5 | console.log(JSON.stringify(scrappingCuencaMediterranea, null, 2)); 6 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-guadiana/src/console-runner.ts: -------------------------------------------------------------------------------- 1 | import { scrapeCuencaGuadiana } from "./integration"; 2 | 3 | const URL = "https://siraguadiana.com/backend/Visor/resourceByID"; 4 | console.log("Estado de la Cuenca del Guadiana:"); 5 | const result = await scrapeCuencaGuadiana(URL); 6 | console.log(JSON.stringify(result, null, 2)); 7 | -------------------------------------------------------------------------------- /packages/db/src/console-runners/questions.ts: -------------------------------------------------------------------------------- 1 | import type { PromptObject } from "prompts"; 2 | 3 | export const mongoDBQuestion: PromptObject<"connectionString"> = { 4 | name: "connectionString", 5 | type: "text", 6 | message: "Connection string (Press enter to use default): ", 7 | initial: process.env.MONGODB_CONNECTION_STRING, 8 | }; 9 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-guadalquivir/src/console-runner.ts: -------------------------------------------------------------------------------- 1 | import { scrapeCuencaGuadalquivir } from "./integration"; 2 | 3 | const url = "https://www.chguadalquivir.es/saih/"; 4 | 5 | console.log("Estado de la Cuenca del Guadalquivir"); 6 | const result = await scrapeCuencaGuadalquivir(url); 7 | 8 | console.log(JSON.stringify(result, null, 2)); 9 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-jucar/src/api/cuenca.model.ts: -------------------------------------------------------------------------------- 1 | export interface EmbalsesJucar { 2 | id: number; 3 | embalse: string; 4 | provincia: string; 5 | porcentajeActual: number; 6 | capacidadTotalHm3: number; 7 | volumenActualHm3: number; 8 | caudalRecibido: number; 9 | caudalSalida: number; 10 | fecha: string; 11 | } 12 | -------------------------------------------------------------------------------- /functions/host.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0", 3 | "logging": { 4 | "applicationInsights": { 5 | "samplingSettings": { 6 | "isEnabled": true, 7 | "excludedTypes": "Request" 8 | } 9 | } 10 | }, 11 | "extensionBundle": { 12 | "id": "Microsoft.Azure.Functions.ExtensionBundle", 13 | "version": "[4.*, 5.0.0)" 14 | } 15 | } -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turbo.build/schema.json", 3 | "tasks": { 4 | "start": { 5 | "cache": false, 6 | "persistent": true 7 | }, 8 | "build": { 9 | "outputs": ["dist/**/*"], 10 | "dependsOn": ["^build", "type-check"] 11 | }, 12 | "type-check": { 13 | "dependsOn": ["^build"] 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-jucar/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "scraping-cuenca-jucar", 3 | "version": "1.0.0", 4 | "private": true, 5 | "type": "module", 6 | "exports": { 7 | ".": "./src/index.ts" 8 | }, 9 | "scripts": { 10 | "start": "tsx --watch src/console-runner.ts" 11 | }, 12 | "dependencies": { 13 | "db-model": "^1.0.0" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-tajo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "scraping-cuenca-tajo", 3 | "version": "1.0.0", 4 | "private": true, 5 | "type": "module", 6 | "exports": { 7 | ".": "./src/index.ts" 8 | }, 9 | "scripts": { 10 | "start": "tsx --watch src/console-runner.ts" 11 | }, 12 | "dependencies": { 13 | "db-model": "^1.0.0" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-cantabrico/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "scraping-cuenca-cantabrico", 3 | "version": "1.0.0", 4 | "private": true, 5 | "type": "module", 6 | "exports": { 7 | ".": "./src/index.ts" 8 | }, 9 | "scripts": { 10 | "start": "tsx --watch src/console-runner.ts" 11 | }, 12 | "dependencies": { 13 | "db-model": "^1.0.0" 14 | } 15 | } -------------------------------------------------------------------------------- /integrations/scraping-cuenca-guadiana/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "scraping-cuenca-guadiana", 3 | "version": "1.0.0", 4 | "private": true, 5 | "type": "module", 6 | "exports": { 7 | ".": "./src/index.ts" 8 | }, 9 | "scripts": { 10 | "start": "tsx --watch src/console-runner.ts" 11 | }, 12 | "dependencies": { 13 | "db-model": "^1.0.0" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-mino-sil/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "scraping-cuenca-mino-sil", 3 | "version": "1.0.0", 4 | "private": true, 5 | "type": "module", 6 | "exports": { 7 | ".": "./src/index.ts" 8 | }, 9 | "scripts": { 10 | "start": "tsx --watch src/console-runner.ts" 11 | }, 12 | "dependencies": { 13 | "db-model": "^1.0.0" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-segura/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "scraping-cuenca-segura", 3 | "version": "1.0.0", 4 | "private": true, 5 | "type": "module", 6 | "exports": { 7 | ".": "./src/index.ts" 8 | }, 9 | "scripts": { 10 | "start": "tsx --watch src/console-runner.ts" 11 | }, 12 | "dependencies": { 13 | "db-model": "^1.0.0" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /front/src/app/embalse-provincia/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | 3 | export default function EmbalsesProvinciaPage() { 4 | return ( 5 |
6 |

Embalse por provincias

7 | 8 | Málaga 9 | 10 |
11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /front/src/app/layouts/header.component.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | 3 | export const HeaderComponent: FC = () => { 4 | return ( 5 |
6 |
7 | InfoEmbalse logo 8 |
9 |
10 | ); 11 | }; 12 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-catalana/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "scraping-cuenca-catalana", 3 | "version": "1.0.0", 4 | "private": "true", 5 | "type": "module", 6 | "exports": { 7 | ".": "./src/index.js" 8 | }, 9 | "scripts": { 10 | "start": "tsx --watch src/console-runner.ts" 11 | }, 12 | "dependencies": { 13 | "db-model": "^1.0.0" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-guadalquivir/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "scraping-cuenca-guadalquivir", 3 | "version": "1.0.0", 4 | "private": true, 5 | "type": "module", 6 | "exports": { 7 | ".": "./src/index.ts" 8 | }, 9 | "scripts": { 10 | "start": "tsx --watch src/console-runner.ts" 11 | }, 12 | "dependencies": { 13 | "db-model": "^1.0.0" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-catalana/src/business.ts: -------------------------------------------------------------------------------- 1 | export const formatApiDate = (isoString: string): string => { 2 | const [datePart] = isoString.split('T'); // "2025-08-29" 3 | const [year, month, day] = datePart.split('-'); 4 | return `${day}/${month}/${year}`; 5 | }; 6 | 7 | export const formatVolumeToFixedTwo = (volume: number): number => { 8 | return Number(volume.toFixed(2)); 9 | }; 10 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-tajo/src/scraper/mapper.ts: -------------------------------------------------------------------------------- 1 | import { EmbalseUpdateSAIHEntity } from "db-model"; 2 | import { SubcuencaInfo } from "../api"; 3 | 4 | export function mapToEmbalsesBySubcuenca( 5 | nombreSubcuenca: string, 6 | embalses: EmbalseUpdateSAIHEntity[] 7 | ): SubcuencaInfo { 8 | return { 9 | nombreSubcuenca: nombreSubcuenca, 10 | embalses: embalses, 11 | }; 12 | } 13 | -------------------------------------------------------------------------------- /front/src/app/embalse/[embalse]/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | 3 | interface Props { 4 | params: Promise<{ embalse: string }>; 5 | } 6 | 7 | export default async function EmbalseDetallePage({ params }: Props) { 8 | const { embalse } = await params; 9 | return ( 10 |
11 |

Detalle del embalse: {embalse}

12 |
13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-duero/src/api/cuenca.model.ts: -------------------------------------------------------------------------------- 1 | // /packages/integrations/scraping-cuenca-duero/src/types.ts 2 | 3 | // This interface defines the "shape" of our reservoir data object. 4 | // We use 'export' so we can import and use it in other files. 5 | export interface EmbalseDuero { 6 | id: number; 7 | embalse: string; 8 | capacidadActualHm3: number | null; 9 | volumenActualHm3: number | null; 10 | } 11 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-duero/src/console-runner.ts: -------------------------------------------------------------------------------- 1 | import { getEstadoCuencaDuero } from './integration'; 2 | 3 | // We log a message in English to know what's happening 4 | console.log('Fetching status for Duero basin...'); 5 | 6 | // We call our function and print the result 7 | getEstadoCuencaDuero().then(result => { 8 | // JSON.stringify is used to print the object in a nice format 9 | console.log(result); 10 | }); 11 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-jucar/src/api/cuenca.api.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | /** 4 | * Gets the HTML content from the Jucar reservoirs page. 5 | * @param url - The URL to fetch the HTML content from 6 | * @returns Promise that resolves with the page HTML 7 | */ 8 | export async function getCuencaPageHTMLContent(url: string): Promise { 9 | const { data: html } = await axios.get(url); 10 | return html; 11 | } 12 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-segura/src/api/cuenca.api.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | /** 4 | * Gets the HTML content from the Segura reservoirs page. 5 | * @param url - The URL to fetch the HTML content from 6 | * @returns Promise that resolves with the page HTML 7 | */ 8 | export async function getCuencaPageHTMLContent(url: string): Promise { 9 | const { data: html } = await axios.get(url); 10 | return html; 11 | } 12 | -------------------------------------------------------------------------------- /packages/db/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "NodeNext", 5 | "moduleResolution": "NodeNext", 6 | "outDir": "dist", 7 | "rootDir": "src", 8 | "skipLibCheck": true, 9 | "isolatedModules": true, 10 | "esModuleInterop": true, 11 | "declaration": true, 12 | "strict": false 13 | }, 14 | "include": ["src/**/*"], 15 | "exclude": ["dist", "node_modules"] 16 | } -------------------------------------------------------------------------------- /integrations/scraping-cuenca-guadiana/src/scraper/business.ts: -------------------------------------------------------------------------------- 1 | import { EmbalsesGuadiana, getCuencaJSONResponse } from "@/api"; 2 | 3 | export async function extractCurrentDate(URL: string): Promise { 4 | const data: EmbalsesGuadiana[] = await getCuencaJSONResponse(URL); 5 | 6 | if (data && data.length > 0) { 7 | return data[0].timestamp.split(" ")[0].replace(/-/g, "/"); 8 | } 9 | 10 | return "Fecha no disponible"; 11 | } 12 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-mediterranea/src/api/cuenca.api.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | /** 4 | * Gets the HTML content from the Andalusian reservoirs page. 5 | * @param url - The URL to fetch the HTML content from 6 | * @returns Promise that resolves with the page HTML 7 | */ 8 | export async function getCuencaPageHTMLContent(url: string): Promise { 9 | const { data: html } = await axios.get(url); 10 | return html; 11 | } 12 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-catalana/src/integrations.ts: -------------------------------------------------------------------------------- 1 | import { EmbalseUpdateSAIHEntity } from 'db-model'; 2 | import { getCuencaCatalana } from './api'; 3 | 4 | export const URL = 5 | 'https://aplicacions.aca.gencat.cat/aetr/vishid/v2/data/public/reservoir/capacity'; 6 | 7 | export async function integracionCuencaCatalana(URL: string) { 8 | const embalses: EmbalseUpdateSAIHEntity[] = await getCuencaCatalana(URL); 9 | return embalses; 10 | } 11 | -------------------------------------------------------------------------------- /integrations/arcgis/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "nodenext", 5 | "moduleResolution": "nodenext", 6 | "outDir": "dist", 7 | "skipLibCheck": true, 8 | "isolatedModules": true, 9 | "esModuleInterop": true, 10 | "verbatimModuleSyntax": false, 11 | "declaration": true, 12 | "baseUrl": "./" 13 | }, 14 | "include": ["src/**/*"], 15 | "exclude": ["dist", "node_modules"] 16 | } 17 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-jucar/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "ESNext", 5 | "moduleResolution": "bundler", 6 | "skipLibCheck": true, 7 | "isolatedModules": true, 8 | "esModuleInterop": true, 9 | "baseUrl": "./", 10 | "paths": { 11 | "@/*": ["src/*"], 12 | "@/api/*": ["src/api/*"], 13 | "@/scraper/*": ["src/scraper/*"] 14 | } 15 | }, 16 | "include": ["src"] 17 | } -------------------------------------------------------------------------------- /integrations/scraping-cuenca-segura/src/console-runner.ts: -------------------------------------------------------------------------------- 1 | import { scrapeCuencaSegura } from './integration'; 2 | import { mapToEmbalseUpdateSAIH } from './scraper'; 3 | 4 | const URL = 'https://chsegura.es/es/cuenca/redes-de-control/estadisticas-hidrologicas/estado-de-embalses/'; 5 | console.log('Estado de la Cuenca Segura:'); 6 | const scrapedCuencaSegura = await scrapeCuencaSegura(URL); 7 | const result = mapToEmbalseUpdateSAIH(scrapedCuencaSegura) 8 | console.log(result); 9 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-segura/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "ESNext", 5 | "moduleResolution": "bundler", 6 | "skipLibCheck": true, 7 | "isolatedModules": true, 8 | "esModuleInterop": true, 9 | "baseUrl": "./", 10 | "paths": { 11 | "@/*": ["src/*"], 12 | "@/api/*": ["src/api/*"], 13 | "@/scraper/*": ["src/scraper/*"] 14 | } 15 | }, 16 | "include": ["src"] 17 | } 18 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-catalana/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "ESNext", 5 | "moduleResolution": "bundler", 6 | "skipLibCheck": true, 7 | "isolatedModules": true, 8 | "esModuleInterop": true, 9 | "baseUrl": "./", 10 | "paths": { 11 | "@/*": ["src/*"], 12 | "@/api/*": ["src/api/*"], 13 | "@/scraper/*": ["src/scraper/*"] 14 | } 15 | }, 16 | "include": ["src"] 17 | } 18 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-guadalquivir/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "ESNext", 5 | "moduleResolution": "bundler", 6 | "skipLibCheck": true, 7 | "isolatedModules": true, 8 | "esModuleInterop": true, 9 | "baseUrl": "./", 10 | "paths": { 11 | "@/*": ["src/*"], 12 | "@/api/*": ["src/api/*"], 13 | "@/scraper/*": ["src/scraper/*"] 14 | } 15 | }, 16 | "include": ["src"] 17 | } 18 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-guadiana/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "ESNext", 5 | "moduleResolution": "bundler", 6 | "skipLibCheck": true, 7 | "isolatedModules": true, 8 | "esModuleInterop": true, 9 | "baseUrl": "./", 10 | "paths": { 11 | "@/*": ["src/*"], 12 | "@/api/*": ["src/api/*"], 13 | "@/scraper/*": ["src/scraper/*"] 14 | } 15 | }, 16 | "include": ["src"] 17 | } 18 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-mediterranea/src/api/cuenca.model.ts: -------------------------------------------------------------------------------- 1 | export interface EmbalsesAndalucia { 2 | id: number; 3 | embalse: string; 4 | provincia: string; 5 | porcentajeActual: number; 6 | capacidadTotalHm3: number; 7 | acumuladoHoyMm: number; 8 | volumenActualHm3: number; 9 | acumuladoSemanaAnteriorMm: number; 10 | volumenSemanaAnteriorHm3: number; 11 | acumuladoAnioAnteriorMm: number; 12 | volumenAnioAnteriorHm3: number; 13 | grafico: any; 14 | } 15 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-mediterranea/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "nodenext", 5 | "moduleResolution": "nodenext", 6 | "outDir": "dist", 7 | "skipLibCheck": true, 8 | "isolatedModules": true, 9 | "esModuleInterop": true, 10 | "verbatimModuleSyntax": false, 11 | "declaration": true, 12 | "baseUrl": "./" 13 | }, 14 | "include": ["src/**/*"], 15 | "exclude": ["dist", "node_modules"] 16 | } 17 | -------------------------------------------------------------------------------- /functions/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "nodenext", 4 | "moduleResolution": "nodenext", 5 | "target": "ES2022", 6 | "outDir": "dist", 7 | "rootDir": "src", 8 | "sourceMap": true, 9 | "strict": true, 10 | "lib": ["ES2022", "DOM"], 11 | "skipLibCheck": true, 12 | "esModuleInterop": true, 13 | "allowSyntheticDefaultImports": true, 14 | "forceConsistentCasingInFileNames": true 15 | }, 16 | "include": [ 17 | "src/**/*" 18 | ] 19 | } -------------------------------------------------------------------------------- /integrations/scraping-cuenca-cantabrico/src/scraper/mapper.ts: -------------------------------------------------------------------------------- 1 | import type { EmbalseUpdateSAIHEntity } from "db-model"; 2 | import type { RawRow } from "./business"; 3 | 4 | /** Mapea a EmbalseUpdateSAIHEntity*/ 5 | export function mapToEmbalseUpdateSAIH( 6 | rows: RawRow[] 7 | ): EmbalseUpdateSAIHEntity[] { 8 | return rows.map((r) => ({ 9 | id: r.id, 10 | nombre: r.nombre, 11 | aguaActualSAIH: r.volumenActualHm3, 12 | fechaMedidaSAIH: r.fecha, 13 | })); 14 | } 15 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-duero/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "scraping-cuenca-duero", 3 | "version": "1.0.0", 4 | "private": true, 5 | "type": "module", 6 | "exports": "./src/index.ts", 7 | "scripts": { 8 | "start": "tsx --watch ./src/console-runner.ts", 9 | "test": "echo \"Error: no test specified\" && exit 1" 10 | }, 11 | "dependencies": { 12 | "axios": "^1.11.0", 13 | "cheerio": "^1.1.2" 14 | }, 15 | "devDependencies": { 16 | "ts-node": "^10.9.2", 17 | "typescript": "^5.9.2" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-duero/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "ESNext", 5 | "moduleResolution": "bundler", 6 | "skipLibCheck": true, 7 | "isolatedModules": true, 8 | "esModuleInterop": true, 9 | "allowSyntheticDefaultImports": true, 10 | "resolveJsonModule": true, 11 | "outDir": "dist", 12 | "baseUrl": "./", 13 | "paths": { 14 | "@/*": ["src/*"], 15 | "@/api/*": ["src/api/*"], 16 | "@/scraper/*": ["src/scraper/*"] 17 | } 18 | }, 19 | "include": ["src"] 20 | } 21 | -------------------------------------------------------------------------------- /front/src/app/embalse-provincia/[provincia]/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | 3 | interface Props { 4 | params: Promise<{ provincia: string }>; 5 | } 6 | 7 | export default async function EmbalseProvinciaListadoPage({ params }: Props) { 8 | const { provincia } = await params; 9 | return ( 10 |
11 |

Embalses de {provincia}

12 | 13 | 14 | Embalse de Casasola 15 | 16 |
17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /functions/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "functions", 3 | "version": "0.0.1", 4 | "type": "module", 5 | "scripts": { 6 | "start": "func start", 7 | "watch": "tsc -w", 8 | "build": "tsc", 9 | "clean": "rimraf dist", 10 | "prestart": "run-p clean type-check build", 11 | "type-check": "tsc --noEmit --preserveWatchOutput" 12 | }, 13 | "dependencies": { 14 | "@azure/functions": "^4.0.0", 15 | "@embalse-info/db": "*" 16 | }, 17 | "devDependencies": { 18 | "@types/node": "^20.x" 19 | }, 20 | "main": "dist/index.js" 21 | } 22 | -------------------------------------------------------------------------------- /run-scripts/helpers.ts: -------------------------------------------------------------------------------- 1 | import childProcess from "node:child_process"; 2 | import fs from "node:fs/promises"; 3 | 4 | export const exec = (command: string) => { 5 | const child = childProcess.spawn(command, { shell: true, stdio: "inherit" }); 6 | child.on("close", (code: number) => { 7 | process.exit(code); 8 | }); 9 | }; 10 | 11 | export const getDirectories = async (path: string) => 12 | (await fs.readdir(path, { withFileTypes: true })) 13 | .filter((dirent) => dirent.isDirectory() && dirent.name !== "node_modules") 14 | .map((dirent) => dirent.name); 15 | -------------------------------------------------------------------------------- /packages/db/src/dals/cuencas/cuencas.repository.ts: -------------------------------------------------------------------------------- 1 | import { getCuencasContext } from "./cuencas.context.js"; 2 | import { Cuenca } from "db-model"; 3 | 4 | export const cuencasRepository = { 5 | actualizarCuencas: async (cuentas: Cuenca[]): Promise => { 6 | const { ok } = await getCuencasContext().bulkWrite( 7 | cuentas.map((cuenca) => ({ 8 | updateOne: { 9 | filter: { _id: cuenca._id }, 10 | update: { $set: cuenca }, 11 | upsert: true, 12 | }, 13 | })) 14 | ); 15 | 16 | return ok === 1; 17 | }, 18 | }; 19 | -------------------------------------------------------------------------------- /run-scripts/console-runners.ts: -------------------------------------------------------------------------------- 1 | import fs from "node:fs"; 2 | import prompts from "prompts"; 3 | 4 | const CONSOLE_RUNNERS_DB = fs.existsSync("packages/db/src/console-runners"); 5 | 6 | const { selected }: { selected: string } = await prompts({ 7 | type: "autocomplete", 8 | name: "selected", 9 | message: "[console-runners] Select a console runner to execute", 10 | choices: CONSOLE_RUNNERS_DB 11 | ? [{ title: "packages/db", value: "@embalse-info/db" }] 12 | : [], 13 | }); 14 | 15 | export const command = 16 | selected && `npm run start:console-runners -w ${selected}`; 17 | -------------------------------------------------------------------------------- /front/src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | 3 | const RootPage = () => { 4 | return ( 5 |
6 |

Página de inicio

7 |
8 | 9 | Embalses por provincias 10 | 11 | 12 | Detalle del embalse 13 | 14 |
15 |
16 | ); 17 | }; 18 | 19 | export default RootPage; 20 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-guadiana/src/file.helper.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | 3 | export const clearAndCreateDirectory = (dirPath: string): void => { 4 | // Clear folder if it exists 5 | if (fs.existsSync(dirPath)) { 6 | fs.rmSync(dirPath, { recursive: true, force: true }); 7 | } 8 | 9 | // Create the folder 10 | fs.mkdirSync(dirPath, { recursive: true }); 11 | }; 12 | 13 | export const saveJsonFile = (filePath: string, data: any): void => { 14 | fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8'); 15 | console.log(`Data saved to ${filePath}`); 16 | }; 17 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-cantabrico/src/integration.ts: -------------------------------------------------------------------------------- 1 | import { getCantabricoPayload } from "@/api"; 2 | import { toRawRows } from "@/scraper/business"; 3 | import { mapToEmbalseUpdateSAIH } from "@/scraper/mapper"; 4 | import type { EmbalseUpdateSAIHEntity } from "db-model"; 5 | 6 | /** Orquesta: API → normaliza → mapea a tu entidad final */ 7 | export async function scrapeCuencaCantabrica(): Promise< 8 | EmbalseUpdateSAIHEntity[] 9 | > { 10 | const payload = await getCantabricoPayload(); 11 | const rows = toRawRows(payload.data.features); 12 | return mapToEmbalseUpdateSAIH(rows); 13 | } 14 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-mediterranea/src/file.helper.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | 3 | export const clearAndCreateDirectory = (dirPath: string): void => { 4 | // Clear folder if it exists 5 | if (fs.existsSync(dirPath)) { 6 | fs.rmSync(dirPath, { recursive: true, force: true }); 7 | } 8 | 9 | // Create the folder 10 | fs.mkdirSync(dirPath, { recursive: true }); 11 | }; 12 | 13 | export const saveJsonFile = (filePath: string, data: any): void => { 14 | fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8'); 15 | console.log(`Data saved to ${filePath}`); 16 | }; 17 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-guadalquivir/src/api/cuenca.api.ts: -------------------------------------------------------------------------------- 1 | import { Browser, chromium, Page } from "playwright"; 2 | 3 | export async function getCuencaPageContent( 4 | url: string, 5 | zoneCode: string 6 | ): Promise<{ page: Page; browser: Browser }> { 7 | const browser = await chromium.launch({ headless: true }); 8 | const context = await browser.newContext(); 9 | const page = await context.newPage(); 10 | 11 | await page.goto(url); 12 | 13 | await page.selectOption("#DDBzona", zoneCode); 14 | await page.waitForLoadState("networkidle"); 15 | 16 | return { page, browser }; 17 | } 18 | -------------------------------------------------------------------------------- /integrations/arcgis/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "arcgis", 3 | "version": "0.0.1", 4 | "private": "true", 5 | "type": "module", 6 | "exports": { 7 | ".": "./dist/index.js" 8 | }, 9 | "main": "./dist/index.js", 10 | "types": "./dist/index.d.ts", 11 | "scripts": { 12 | "start": "tsx --watch src/console-runner.ts", 13 | "build": "run-p clean type-check build:arcgis", 14 | "build:arcgis": "tsc", 15 | "clean": "rimraf dist", 16 | "type-check": "tsc --noEmit --preserveWatchOutput" 17 | }, 18 | "dependencies": { 19 | "db-model": "^1.0.0", 20 | "axios": "^1.7.0" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-catalana/src/api/cuenca.api.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { EmbalseCatalanApi } from './cuenca.api-model'; 3 | import { mapApiToEmbalses } from '../cuenca.mapper'; 4 | import { EmbalseUpdateSAIHEntity } from 'db-model'; 5 | 6 | /** 7 | * Gets the data from the Catalan reservoirs API. 8 | * @param url 9 | * @returns Promise that resolves with the API data. 10 | */ 11 | export async function getCuencaCatalana( 12 | url: string 13 | ): Promise { 14 | const { data } = await axios.get>(url); 15 | return mapApiToEmbalses(data); 16 | } 17 | -------------------------------------------------------------------------------- /packages/db/src/core/servers/db.server.ts: -------------------------------------------------------------------------------- 1 | import { MongoClient, type Db } from "mongodb"; 2 | 3 | let client: MongoClient; 4 | 5 | const connect = async (connectionURL: string) => { 6 | client = new MongoClient(connectionURL); 7 | await client.connect(); 8 | dbServer.db = client.db(); 9 | }; 10 | 11 | const disconnect = async () => { 12 | await client.close(); 13 | }; 14 | 15 | interface DBServer { 16 | connect: (connectionURL: string) => Promise; 17 | disconnect: () => Promise; 18 | db: Db | undefined; 19 | } 20 | 21 | export let dbServer: DBServer = { 22 | connect, 23 | disconnect, 24 | db: undefined, 25 | }; 26 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-guadiana/src/integration.ts: -------------------------------------------------------------------------------- 1 | import { EmbalseUpdateSAIHEntity } from "db-model"; 2 | import { extractCurrentDate } from "@/scraper/business"; 3 | import { mapToEmbalseUpdateSAIH } from "@/scraper/mapper"; 4 | import { getCuencaJSONResponse } from "@/api"; 5 | 6 | /** 7 | * @param url - The URL to scrape the data from 8 | */ 9 | 10 | export async function scrapeCuencaGuadiana( 11 | url: string 12 | ): Promise { 13 | const json = await getCuencaJSONResponse(url); 14 | 15 | const currentDate = await extractCurrentDate(url); 16 | 17 | return mapToEmbalseUpdateSAIH(json, currentDate); 18 | } 19 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI workflow 2 | 3 | on: pull_request 4 | 5 | jobs: 6 | ci: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Checkout repository 10 | uses: actions/checkout@v4 11 | 12 | - name: Setup node version 13 | uses: actions/setup-node@v4 14 | with: 15 | node-version: 20.x 16 | cache: 'npm' 17 | 18 | - name: Clean install dependencies 19 | run: | 20 | rm -rf node_modules package-lock.json 21 | npm install 22 | 23 | - name: Check TypeScript Types 24 | run: npx turbo type-check 25 | 26 | - name: Test 27 | run: npm run test 28 | -------------------------------------------------------------------------------- /front/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "front", 3 | "version": "1.0.0", 4 | "private": true, 5 | "type": "module", 6 | "scripts": { 7 | "start": "next dev" 8 | }, 9 | "dependencies": { 10 | "@fontsource/nunito-sans": "^5.2.7", 11 | "@tailwindcss/postcss": "^4.1.17", 12 | "next": "^15.4.1", 13 | "postcss": "^8.5.6", 14 | "react": "^19.1.0", 15 | "react-dom": "^19.1.0", 16 | "tailwindcss": "^4.1.17" 17 | }, 18 | "devDependencies": { 19 | "@types/react": "^19.1.8", 20 | "@types/react-dom": "^19.1.6", 21 | "daisyui": "^5.5.5", 22 | "prettier": "^3.7.3", 23 | "prettier-plugin-tailwindcss": "^0.7.2" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-cantabrico/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "ESNext", 5 | "moduleResolution": "bundler", 6 | "skipLibCheck": true, 7 | "isolatedModules": true, 8 | "esModuleInterop": true, 9 | "baseUrl": "./", 10 | "paths": { 11 | "@/*": [ 12 | "src/*" 13 | ], 14 | "@/api/*": [ 15 | "src/api/*" 16 | ], 17 | "@/scraper/*": [ 18 | "src/scraper/*" 19 | ] 20 | } 21 | }, 22 | "include": [ 23 | "src" 24 | ] 25 | } -------------------------------------------------------------------------------- /integrations/scraping-cuenca-segura/src/integration.ts: -------------------------------------------------------------------------------- 1 | import * as cheerio from 'cheerio'; 2 | import { getCuencaPageHTMLContent, EmbalsesSegura } from '@/api'; 3 | import { extractReservoirsFromSeguraPage } from '@/scraper'; 4 | 5 | /** 6 | * Scrapes Segura reservoir data and returns it as an array. 7 | * @param url - The URL to scrape the data from 8 | */ 9 | export async function scrapeCuencaSegura( 10 | url: string 11 | ): Promise { 12 | const html = await getCuencaPageHTMLContent(url); 13 | const $: cheerio.CheerioAPI = cheerio.load(html); 14 | 15 | // Extract and map reservoir data 16 | return extractReservoirsFromSeguraPage($); 17 | } 18 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-tajo/src/api/cuenca.model.ts: -------------------------------------------------------------------------------- 1 | import { EmbalseUpdateSAIHEntity } from "db-model"; 2 | 3 | export interface SubcuencaInfo { 4 | nombreSubcuenca: string; 5 | embalses: EmbalseUpdateSAIHEntity[]; 6 | } 7 | 8 | export const SUBCUENCAS: string[] = [ 9 | "ALAGÓN", 10 | "ALBERCHE", 11 | "BAJO TAJO", 12 | "CABECERA", 13 | "HENARES", 14 | "MADRID", 15 | "TAJO IZQUIERDA", 16 | "TAJUÑA", 17 | "TIÉTAR", 18 | "ÁRRAGO", 19 | ]; 20 | 21 | export const VOLUME_TITLES = [ 22 | "VOLUMEN EMBALSE", 23 | "VOLUMEN DE AGUA EMBALSADA", 24 | "VOLUMEN DEL AGUA EMBALSADA", 25 | "VOLUMEN EMBALSE TAJO", 26 | "VOLUMEN EMBALSE TIETAR", 27 | ]; 28 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-mediterranea/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "scraping-cuenca-mediterranea", 3 | "version": "1.0.0", 4 | "private": true, 5 | "type": "module", 6 | "exports": { 7 | ".": "./dist/index.js" 8 | }, 9 | "main": "./dist/index.js", 10 | "types": "./dist/index.d.ts", 11 | "scripts": { 12 | "start": "tsx --watch src/console-runner.ts", 13 | "build": "run-p clean type-check build:scraping-cuenca-mediterranea", 14 | "build:scraping-cuenca-mediterranea": "tsc", 15 | "clean": "rimraf dist", 16 | "type-check": "tsc --noEmit --preserveWatchOutput" 17 | }, 18 | "dependencies": { 19 | "db-model": "^1.0.0" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-guadiana/src/scraper/mapper.ts: -------------------------------------------------------------------------------- 1 | import { EmbalsesGuadiana } from "../api"; 2 | import { EmbalseUpdateSAIHEntity } from "db-model"; 3 | 4 | /** 5 | * Maps EmbalsesAndalucia data to EmbalseUpdateSAIH format. 6 | * @param embalsesAndalucia - Array of EmbalsesAndalucia objects 7 | * @returns Array of EmbalseUpdateSAIH objects 8 | */ 9 | export function mapToEmbalseUpdateSAIH( 10 | embalsesGuadiana: EmbalsesGuadiana[], 11 | currentDate: string 12 | ): EmbalseUpdateSAIHEntity[] { 13 | return embalsesGuadiana.map((embalse) => ({ 14 | id: embalse.orden, 15 | nombre: embalse.nombre, 16 | aguaActualSAIH: embalse.VE1, 17 | fechaMedidaSAIH: currentDate, 18 | })); 19 | } 20 | -------------------------------------------------------------------------------- /front/src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import "./globals.css"; 3 | import { FooterComponent, HeaderComponent } from "./layouts"; 4 | 5 | interface Props { 6 | children: React.ReactNode; 7 | } 8 | 9 | const RootLayout = (props: Props) => { 10 | const { children } = props; 11 | return ( 12 | 13 | 17 | 18 |
{children}
19 | 20 | 21 | 22 | ); 23 | }; 24 | 25 | export default RootLayout; 26 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-duero/src/scraper/mapper.ts: -------------------------------------------------------------------------------- 1 | import { EmbalseDuero } from '../api/cuenca.model'; 2 | import { EmbalseUpdateSAIHEntity } from "db-model"; 3 | 4 | /** 5 | * Maps EmbalsesAndalucia data to EmbalseUpdateSAIH format. 6 | * @param embalsesAndalucia - Array of EmbalsesAndalucia objects 7 | * @returns Array of EmbalseUpdateSAIH objects 8 | */ 9 | export function mapToEmbalseUpdateSAIH( 10 | embalsesAndalucia: EmbalseDuero[], 11 | currentDate: string 12 | ): EmbalseUpdateSAIHEntity[] { 13 | return embalsesAndalucia.map((embalse) => ({ 14 | id: embalse.id, 15 | nombre: embalse.embalse, 16 | aguaActualSAIH: embalse.volumenActualHm3, 17 | fechaMedidaSAIH: currentDate, 18 | })); 19 | } 20 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-catalana/src/cuenca.mapper.ts: -------------------------------------------------------------------------------- 1 | import { EmbalseCatalanApi } from './api/cuenca.api-model'; 2 | import { EmbalseUpdateSAIHEntity } from 'db-model'; 3 | import { formatApiDate, formatVolumeToFixedTwo } from './business'; 4 | 5 | export function mapApiToEmbalses( 6 | apiData: Record 7 | ): EmbalseUpdateSAIHEntity[] { 8 | return Object.entries(apiData).map(([id, embalse]) => { 9 | return { 10 | id: Number(id.replace('-', '')), 11 | nombre: embalse.name, 12 | aguaActualSAIH: formatVolumeToFixedTwo(embalse.popup.volume.value), // volumen actual 13 | fechaMedidaSAIH: formatApiDate(embalse.popup.volume.time), // fecha de la medida 14 | }; 15 | }); 16 | } 17 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-mino-sil/src/integration.ts: -------------------------------------------------------------------------------- 1 | import type { Embalse } from "db-model"; 2 | 3 | export const getEstadoCuencaMinoSil = async (): Promise => { 4 | return [ 5 | { 6 | id: "1", 7 | nombre: "Embalse de Belesar", 8 | provincia: "Lugo", 9 | capacidad: 3000000000, 10 | nivelActual: 2500000000, 11 | fechaUltimoNivel: new Date("2023-10-01"), 12 | porcentajeLlenado: 83.3, 13 | }, 14 | { 15 | id: "2", 16 | nombre: "Embalse de Velle", 17 | provincia: "Ourense", 18 | capacidad: 500000000, 19 | nivelActual: 400000000, 20 | fechaUltimoNivel: new Date("2023-10-01"), 21 | porcentajeLlenado: 80.0, 22 | }, 23 | ]; 24 | }; 25 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-mediterranea/src/scraper/mapper.ts: -------------------------------------------------------------------------------- 1 | import type { EmbalsesAndalucia } from "../api/index.js"; 2 | import { EmbalseUpdateSAIHEntity } from "db-model"; 3 | 4 | /** 5 | * Maps EmbalsesAndalucia data to EmbalseUpdateSAIH format. 6 | * @param embalsesAndalucia - Array of EmbalsesAndalucia objects 7 | * @returns Array of EmbalseUpdateSAIH objects 8 | */ 9 | export function mapToEmbalseUpdateSAIH( 10 | embalsesAndalucia: EmbalsesAndalucia[], 11 | currentDate: string 12 | ): EmbalseUpdateSAIHEntity[] { 13 | return embalsesAndalucia.map((embalse) => ({ 14 | id: embalse.id, 15 | nombre: embalse.embalse, 16 | aguaActualSAIH: embalse.volumenActualHm3, 17 | fechaMedidaSAIH: currentDate, 18 | })); 19 | } 20 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-catalana/src/api/cuenca.api-model.ts: -------------------------------------------------------------------------------- 1 | export interface EmbalseCatalanApi { 2 | value: number; 3 | alert: number; 4 | time: string; 5 | location: string; 6 | component: string; 7 | type: string; 8 | network: string; 9 | status: string; 10 | name: string; 11 | unit: string; 12 | signal: string; 13 | popup: { 14 | level: { 15 | value: number; 16 | unit: string; 17 | time: string; 18 | signal: string; 19 | }; 20 | volume: { 21 | value: number; 22 | unit: string; 23 | time: string; 24 | signal: string; 25 | }; 26 | capacity: { 27 | value: number; 28 | unit: string; 29 | time: string; 30 | signal: string; 31 | }; 32 | }; 33 | } 34 | -------------------------------------------------------------------------------- /front/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "strict": false, 12 | "noEmit": true, 13 | "incremental": true, 14 | "module": "esnext", 15 | "esModuleInterop": true, 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "jsx": "preserve", 20 | "plugins": [ 21 | { 22 | "name": "next" 23 | } 24 | ] 25 | }, 26 | "include": [ 27 | "next-env.d.ts", 28 | ".next/types/**/*.ts", 29 | "**/*.ts", 30 | "**/*.tsx" 31 | , "postcss.config.js" ], 32 | "exclude": [ 33 | "node_modules" 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /packages/db/src/console-runners/cuencas-seed/index.ts: -------------------------------------------------------------------------------- 1 | import childProcess from "child_process"; 2 | import { dbServer } from "#core/servers/db.server.js"; 3 | import { cuencasRepository } from "#dals/index.js"; 4 | import { scrapeSeedEmbalses } from "arcgis"; 5 | 6 | export const run = async () => { 7 | try { 8 | const { cuencas } = await scrapeSeedEmbalses(); 9 | if (!cuencas || cuencas.length === 0) { 10 | console.log("No se encontraron cuencas para actualizar."); 11 | return; 12 | } 13 | await cuencasRepository.actualizarCuencas(cuencas); 14 | await dbServer.disconnect(); 15 | } catch (error) { 16 | console.error(error); 17 | } 18 | }; 19 | 20 | const runCommand = async (command: string) => { 21 | childProcess.execSync(command, { stdio: "inherit" }); 22 | }; 23 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-jucar/src/integration.ts: -------------------------------------------------------------------------------- 1 | import * as cheerio from 'cheerio'; 2 | import { getCuencaPageHTMLContent } from '@/api'; 3 | import { 4 | extractReservoirsFromJucarPage, 5 | mapToEmbalseUpdateSAIH, 6 | } from '@/scraper'; 7 | import { EmbalseUpdateSAIHEntity } from 'db-model'; 8 | 9 | /** 10 | * Scrapes Júcar reservoir data and returns it as an array. 11 | * @param url - The URL to scrape the data from 12 | */ 13 | export async function scrapeCuencaJucar( 14 | url: string 15 | ): Promise { 16 | const html = await getCuencaPageHTMLContent(url); 17 | const $: cheerio.CheerioAPI = cheerio.load(html); 18 | 19 | // Extract and map reservoir data 20 | const reservoirs = extractReservoirsFromJucarPage($); 21 | return mapToEmbalseUpdateSAIH(reservoirs); 22 | } 23 | -------------------------------------------------------------------------------- /packages/db/setup.js: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import path from "path"; 3 | import { fileURLToPath } from "url"; 4 | 5 | const __filename = fileURLToPath(import.meta.url); 6 | const __dirname = path.dirname(__filename); 7 | 8 | const mongoDataPath = path.resolve(__dirname, "mongo-data"); 9 | const envExamplePath = path.resolve(__dirname, ".env.example"); 10 | const envPath = path.resolve(__dirname, ".env"); 11 | 12 | try { 13 | // Create directory mongo-data 14 | if (!fs.existsSync(mongoDataPath)) { 15 | fs.mkdirSync(mongoDataPath, { recursive: true }); 16 | } 17 | 18 | // Copy .env.example a .env 19 | if (!fs.existsSync(envPath)) { 20 | if (fs.existsSync(envExamplePath)) { 21 | fs.copyFileSync(envExamplePath, envPath); 22 | } 23 | } 24 | } catch (error) { 25 | process.exit(1); 26 | } 27 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-guadalquivir/src/scraper/mapper.ts: -------------------------------------------------------------------------------- 1 | import { EmbalsesGuadalquivir, ZoneInfo } from "@/api"; 2 | import { EmbalseUpdateSAIHEntity } from "db-model"; 3 | 4 | export function mapToEmbalseUpdateSAIH( 5 | embalsesGuadalquivir: EmbalsesGuadalquivir[], 6 | currentDate: string 7 | ): EmbalseUpdateSAIHEntity[] { 8 | return embalsesGuadalquivir.map((embalse) => ({ 9 | id: embalse.id, 10 | nombre: embalse.embalse, 11 | aguaActualSAIH: embalse.volumenActualHm3, 12 | fechaMedidaSAIH: currentDate, 13 | })); 14 | } 15 | 16 | export function mapToEmbalsesByZone( 17 | codigoZona: string, 18 | nombreZona: string, 19 | embalses: EmbalseUpdateSAIHEntity[] 20 | ): ZoneInfo { 21 | return { 22 | codigoZona: codigoZona, 23 | nombreZona: nombreZona, 24 | embalses: embalses, 25 | }; 26 | } 27 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-guadiana/src/api/cuenca.model.ts: -------------------------------------------------------------------------------- 1 | export interface EmbalsesGuadiana { 2 | PI: number; 3 | NE1: number; 4 | PRA: number; 5 | PV1: number; 6 | SE1: number; 7 | VE1: number; 8 | tipo: string; 9 | zona: string; 10 | orden: number; 11 | cam360: null; 12 | nombre: string; 13 | acronimo: string; 14 | timestamp: string; 15 | cod_estacion: string; 16 | } 17 | 18 | export interface RequestBody { 19 | id: string; 20 | type: string; 21 | } 22 | 23 | export interface ApiResponse { 24 | info: { 25 | titulo: null; 26 | texto: string; 27 | descripcion: null; 28 | }; 29 | embalses_volcap: { 30 | volumen: number; 31 | capacidad: number; 32 | porcentajeVol: number; 33 | }; 34 | "360": null; 35 | video; 36 | ult_res: { 37 | encabezado: string[]; 38 | valores: EmbalsesGuadiana[]; 39 | }; 40 | } 41 | -------------------------------------------------------------------------------- /integrations/arcgis/src/integration.ts: -------------------------------------------------------------------------------- 1 | import { getLatestEntries } from "./api/getLatestEntries.js"; 2 | import { Cuenca, Embalse } from "db-model"; 3 | import { 4 | mapArgGisEntryToCuenca, 5 | mapArgGisEntryToEmbalse, 6 | } from "./arcgis.mappers.js"; 7 | 8 | interface ScrapeResult { 9 | embalses: Embalse[]; 10 | cuencas: Cuenca[]; 11 | } 12 | 13 | export async function scrapeSeedEmbalses(): Promise { 14 | const data = await getLatestEntries(); 15 | 16 | const embalses = data 17 | .map(mapArgGisEntryToEmbalse) 18 | .filter((e): e is Embalse => e != null); 19 | 20 | const allCuencas = data 21 | .map(mapArgGisEntryToCuenca) 22 | .filter((e): e is Cuenca => e != null); 23 | const uniqueCuencasMap = new Map(allCuencas.map((c) => [c._id, c])); 24 | const cuencas = Array.from(uniqueCuencasMap.values()); 25 | 26 | return { embalses, cuencas }; 27 | } 28 | -------------------------------------------------------------------------------- /run-scripts/start.ts: -------------------------------------------------------------------------------- 1 | import prompts from "prompts"; 2 | import { getDirectories } from "./helpers"; 3 | 4 | const integrations = await getDirectories("integrations"); 5 | 6 | const { projects }: { projects: string[] } = await prompts({ 7 | type: "autocompleteMultiselect", 8 | name: "projects", 9 | message: "[start] Select projects to run", 10 | choices: [ 11 | ...integrations.map((integration) => ({ 12 | title: `integrations/${integration}`, 13 | value: integration, // value of the package.json name 14 | })), 15 | { title: "front", value: "front" }, 16 | { title: "functions", value: "functions" }, 17 | ], 18 | }); 19 | 20 | if (projects.length === 0) { 21 | console.log("No projects selected. Exiting."); 22 | process.exit(0); 23 | } 24 | 25 | const filter = projects.map((project) => `--filter=${project}`).join(" "); 26 | 27 | export const command = `turbo run start ${filter}`; 28 | -------------------------------------------------------------------------------- /integrations/arcgis/src/api/arcgis-embalse-model.ts: -------------------------------------------------------------------------------- 1 | export interface ArcGisEntry { 2 | OBJECTID_1?: number; 3 | EMBALSE_ID: number; 4 | embalse_nombre: string; 5 | ambito_id: number; 6 | agua_total: number; 7 | ambito_nombre: string; 8 | Uso: string; 9 | energia_actual: number | null; 10 | agua_actual: number; 11 | fecha: number; 12 | boletin_anyo: number; 13 | boletin_num: number; 14 | Porcentaje_Reserva: number; 15 | Años: string; 16 | Orden_Semana: number; 17 | Fecha_str: string; 18 | Variacion_Reserva: number; 19 | ID_Unico: string; 20 | Estado_Porc: string; 21 | Estado_Porcentaje_Energia: string; 22 | energia_total: number; 23 | Variacion_Porcentaje: number; 24 | Variacion_Energia: number; 25 | Porcentaje_Energia: number | null; 26 | Variacion_Porcentaje_Energia: number | null; 27 | embalse_id_1: number; 28 | electrico_flag: number; 29 | OBJECTID: number; 30 | ORIG_FID: number; 31 | EMBALSES_ID: number | null; 32 | } 33 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "embalse-info", 3 | "private": true, 4 | "type": "module", 5 | "workspaces": [ 6 | "packages/*", 7 | "integrations/*", 8 | "functions", 9 | "front" 10 | ], 11 | "packageManager": "npm@10.0.0", 12 | "scripts": { 13 | "prescripts": "npm run start:db -w @embalse-info/db", 14 | "scripts": "tsx ./run-scripts", 15 | "prestart": "turbo build", 16 | "start": "npm run scripts -- start", 17 | "start:console-runners": "npm run scripts -- console-runners", 18 | "build": "turbo build", 19 | "test": "vitest" 20 | }, 21 | "devDependencies": { 22 | "@types/jest": "^30.0.0", 23 | "dotenv": "^17.2.3", 24 | "npm-run-all2": "^8.0.4", 25 | "rimraf": "^6.0.1", 26 | "tsx": "^4.20.3", 27 | "turbo": "^2.5.4", 28 | "typescript": "^5.8.3", 29 | "vitest": "^3.2.4" 30 | }, 31 | "dependencies": { 32 | "axios": "^1.10.0", 33 | "cheerio": "^1.1.2", 34 | "playwright": "^1.54.2" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /packages/db/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@embalse-info/db", 3 | "version": "0.0.1", 4 | "private": true, 5 | "type": "module", 6 | "imports": { 7 | "#*": "./dist/*" 8 | }, 9 | "exports": { 10 | ".": "./dist/index.js" 11 | }, 12 | "main": "./dist/index.js", 13 | "types": "./dist/index.d.ts", 14 | "scripts": { 15 | "postinstall": "node setup.js", 16 | "build": "run-p clean type-check build:db", 17 | "build:db": "tsc", 18 | "clean": "rimraf dist", 19 | "prestart:db": "docker compose down --remove-orphans", 20 | "start:db": "docker compose up -d", 21 | "start:console-runners": "tsx --require dotenv/config --watch src/console-runners/index.ts", 22 | "type-check": "tsc --noEmit --preserveWatchOutput" 23 | }, 24 | "dependencies": { 25 | "mongodb": "^6.19.0" 26 | }, 27 | "devDependencies": { 28 | "@types/prompts": "^2.4.9", 29 | "arcgis": "*", 30 | "prompts": "^2.4.2", 31 | "scraping-cuenca-mediterranea": "*" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-guadalquivir/src/api/cuenca.model.ts: -------------------------------------------------------------------------------- 1 | import { EmbalseUpdateSAIHEntity } from "db-model"; 2 | 3 | export interface EmbalsesGuadalquivir { 4 | id: number; 5 | embalse: string; 6 | provincia: string; 7 | nmnMsnm: number; // Metros sobre el nivel del mar máximo (m.s.n.m.) 8 | nivelActualMsnm: number; 9 | capacidadActualHm3: number; 10 | volumenActualHm3: number; 11 | porcentajeActual: number; 12 | } 13 | 14 | export interface ZoneInfo { 15 | codigoZona: string; 16 | nombreZona: string; 17 | embalses: EmbalseUpdateSAIHEntity[]; 18 | } 19 | 20 | export interface Zone { 21 | codigo: string; 22 | nombre: string; 23 | } 24 | 25 | export const ZONES = [ 26 | { codigo: "CO", nombre: "Zona Córdoba" }, 27 | { codigo: "GR", nombre: "Zona Granada" }, 28 | { codigo: "JA", nombre: "Zona Jaén" }, 29 | { codigo: "SE", nombre: "Zona Sevilla" }, 30 | { codigo: "RG", nombre: "Sistema de Regulación General" }, 31 | { codigo: "CE", nombre: "Ceuta y Melilla" }, 32 | ]; 33 | -------------------------------------------------------------------------------- /functions/src/functions/arcgis-function.ts: -------------------------------------------------------------------------------- 1 | import { app, InvocationContext, Timer } from "@azure/functions"; 2 | import { dbServer, embalsesRepository } from "@embalse-info/db"; 3 | 4 | export async function arcgisFunction( 5 | myTimer: Timer, 6 | context: InvocationContext 7 | ): Promise { 8 | await dbServer.connect(process.env.MONGODB_CONNECTION_STRING as string); 9 | context.log("ArcGIS function executed at:", new Date().toISOString()); 10 | 11 | const response = await embalsesRepository.actualizarEmbalses(); 12 | 13 | if (response) { 14 | context.log(`Se han actualizado los embalses`); 15 | } else { 16 | context.log("No se han podido actualizar los embalses"); 17 | } 18 | await dbServer.disconnect(); 19 | } 20 | 21 | app.timer("arcgis-function", { 22 | retry: { 23 | strategy: "fixedDelay", 24 | delayInterval: { 25 | seconds: 10, 26 | }, 27 | maxRetryCount: 4, 28 | }, 29 | schedule: "0 * * * * *", 30 | handler: arcgisFunction, 31 | }); 32 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-duero/src/integration.ts: -------------------------------------------------------------------------------- 1 | // integration.ts (Versión Final Correcta) 2 | import axios from 'axios'; 3 | import { EmbalseDuero } from './api/cuenca.model'; 4 | import { parseReservoirsFromHtml, getCurrentDate, mapToEmbalseUpdateSAIH } from './scraper'; 5 | import { EmbalseUpdateSAIHEntity } from "db-model"; 6 | 7 | // Define the URL we are going to scrape 8 | const URL = 'https://www.saihduero.es/situacion-embalses'; 9 | 10 | 11 | // This is our main function 12 | export const getEstadoCuencaDuero = async (): Promise => { 13 | try { 14 | const response = await axios.get(URL); 15 | const html = response.data; 16 | // Llamar a la función de negocio para extraer los datos del HTML completo 17 | const currentDate = getCurrentDate(html) 18 | const parsetReservoirs = parseReservoirsFromHtml(html); 19 | return mapToEmbalseUpdateSAIH(parsetReservoirs, currentDate); 20 | } catch (error) { 21 | console.error('Error fetching Duero basin data:', error); 22 | return []; 23 | } 24 | }; 25 | -------------------------------------------------------------------------------- /functions/src/functions/scraping-functions.ts: -------------------------------------------------------------------------------- 1 | import { app, InvocationContext, Timer } from "@azure/functions"; 2 | import { dbServer, embalsesRepository } from "@embalse-info/db"; 3 | 4 | export async function scrapingsFunction( 5 | myTimer: Timer, 6 | context: InvocationContext 7 | ): Promise { 8 | await dbServer.connect(process.env.MONGODB_CONNECTION_STRING as string); 9 | context.log("Scrapings function executed at:", new Date().toISOString()); 10 | 11 | const responseCuencaMediterranea = 12 | await embalsesRepository.actualizarCuencaMediterranea(); 13 | 14 | if (responseCuencaMediterranea) { 15 | context.log(`Se han actualizado los embalses de la cuenca Mediterránea`); 16 | } else { 17 | context.log( 18 | "No se han podido actualizar los embalses de la cuenca Mediterránea" 19 | ); 20 | } 21 | await dbServer.disconnect(); 22 | } 23 | 24 | app.timer("scrapings-function", { 25 | retry: { 26 | strategy: "fixedDelay", 27 | delayInterval: { 28 | seconds: 10, 29 | }, 30 | maxRetryCount: 4, 31 | }, 32 | schedule: "40 * * * * *", 33 | handler: scrapingsFunction, 34 | }); 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Lemoncode 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. 22 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-cantabrico/src/api/index.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | 3 | export type SaichFeature = { 4 | type: "Feature"; 5 | properties?: Record; 6 | }; 7 | 8 | export type SaichPayload = { 9 | success: boolean; 10 | data: { type: "FeatureCollection"; features: SaichFeature[] }; 11 | }; 12 | 13 | const REMOTE_URL = "https://visor.saichcantabrico.es/wp-admin/admin-ajax.php"; 14 | 15 | /** Llama al endpoint SAICH para EMBALSES */ 16 | export async function getCantabricoPayload(): Promise { 17 | const body = new URLSearchParams({ 18 | action: "peticion_cincominutal", 19 | tipo: "embalses", 20 | }); 21 | 22 | const { data } = await axios.post(REMOTE_URL, body, { 23 | headers: { 24 | "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", 25 | Accept: "application/json,text/plain,*/*", 26 | }, 27 | }); 28 | 29 | if (!data?.success || data?.data?.type !== "FeatureCollection" || !Array.isArray(data?.data?.features)) { 30 | throw new Error("Respuesta inesperada del origen (no es FeatureCollection)"); 31 | } 32 | return data; 33 | } 34 | -------------------------------------------------------------------------------- /packages/db-model/src/model.ts: -------------------------------------------------------------------------------- 1 | export interface EmbalseUpdateSAIHEntity { 2 | id: number; 3 | nombre: string; 4 | aguaActualSAIH: number; 5 | fechaMedidaSAIH: string; 6 | } 7 | export interface InfoDestinoArcgis { 8 | nombre: string; 9 | idArcgis: number; 10 | } 11 | 12 | export interface Cuenca { 13 | _id: string; 14 | nombre: string; 15 | } 16 | 17 | export interface Embalse { 18 | _id: string; 19 | embalse_id: number; 20 | nombre: string; 21 | cuenca: { 22 | _id: string; 23 | nombre: string; 24 | }; 25 | provincia: string | null; 26 | capacidad: number; 27 | aguaActualAemet: number | null; 28 | fechaMedidaAguaActualAemet: Date | null; 29 | aguaActualSAIH: number | null; 30 | fechaMedidaAguaActualSAIH: Date | null; 31 | descripcion_id: string | null; 32 | uso: string; 33 | } 34 | 35 | interface UltimasImportacionesSAIH { 36 | nombresitio: string; 37 | ultimaimportacion: Date; 38 | ultimoStatus: string; 39 | } 40 | 41 | export interface MetaDatos { 42 | _id: string; 43 | ultimaImportacionAemet: Date; 44 | ultimoStatus: string; 45 | ultimasImportacionesSAIH: UltimasImportacionesSAIH[]; 46 | } 47 | -------------------------------------------------------------------------------- /integrations/arcgis/src/api/getLatestEntries.ts: -------------------------------------------------------------------------------- 1 | import { fetchLatestDate, fetchEntriesByDate } from "./arcgis-embalse.api.js"; 2 | import { ArcGisEntry } from "./arcgis-embalse-model.js"; 3 | 4 | //Trae todos los registros de la fecha más reciente en su formato ArcGisEntry original. 5 | 6 | export const getLatestEntries = async (): Promise => { 7 | try { 8 | // 1) Obtiene la fecha más reciente 9 | const latestDate = await fetchLatestDate(); 10 | console.log(`Última fecha obtenida: ${latestDate}`); 11 | 12 | // 2) Obtiene todos los registros para la fecha más reciente 13 | const allArcGisEntries = await fetchEntriesByDate(latestDate); 14 | console.log( 15 | `Se obtuvieron ${allArcGisEntries.length} registros para la fecha ${latestDate}.` 16 | ); 17 | 18 | if (!allArcGisEntries.length) { 19 | console.warn("No se encontraron registros para la fecha más reciente."); 20 | return []; 21 | } 22 | 23 | // Devuelve directamente los ArcGisEntry 24 | return allArcGisEntries; 25 | } catch (error) { 26 | console.error("Error al obtener las entradas de los embalses:", error); 27 | throw error; 28 | } 29 | }; 30 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-mediterranea/src/integration.ts: -------------------------------------------------------------------------------- 1 | import * as cheerio from "cheerio"; 2 | import { getCuencaPageHTMLContent } from "./api/index.js"; 3 | import { EmbalseUpdateSAIHEntity } from "db-model"; 4 | import { 5 | extractCurrentDate, 6 | extractProvinceTables, 7 | reservoirInfoFromTable, 8 | } from "./scraper/business.js"; 9 | import { mapToEmbalseUpdateSAIH } from "./scraper/mapper.js"; 10 | 11 | const URL = "https://www.redhidrosurmedioambiente.es/saih/resumen/embalses"; 12 | export async function scrapeCuencaMediterranea(): Promise< 13 | EmbalseUpdateSAIHEntity[] 14 | > { 15 | const html = await getCuencaPageHTMLContent(URL); 16 | const $: cheerio.CheerioAPI = cheerio.load(html); 17 | 18 | // Extract tables organized by province 19 | const provinceTables = extractProvinceTables($); 20 | 21 | // Process each province table and flatten the results 22 | const allReservoirs = provinceTables.flatMap((table) => { 23 | return reservoirInfoFromTable(table.rows, table.province, $); 24 | }); 25 | 26 | // Extract the current date from the page 27 | const currentDate = extractCurrentDate($); 28 | 29 | // Map to EmbalseUpdateSAIH format 30 | return mapToEmbalseUpdateSAIH(allReservoirs, currentDate); 31 | } 32 | -------------------------------------------------------------------------------- /front/src/app/layouts/footer.component.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { FC } from "react"; 3 | 4 | export const FooterComponent: FC = () => { 5 | return ( 6 |
7 |
8 |
9 | 13 | Embalses por provincias 14 | 15 | 16 |
17 | 21 | Aviso Legal 22 | 23 | 27 | Política de cookies 28 | 29 |
30 |
31 | 32 |

33 | Infoembalse © 2025 Todos los derechos reservados 34 |

35 |
36 |
37 | ); 38 | }; 39 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-duero/src/integration.test.ts: -------------------------------------------------------------------------------- 1 | // integration.test.ts (Versión Final Correcta) 2 | import { describe, it, expect, vi, type Mock } from "vitest"; 3 | 4 | import axios from "axios"; 5 | import { getEstadoCuencaDuero } from "./integration"; 6 | 7 | vi.mock("axios"); 8 | 9 | // HTML de prueba que incluye el caso del guión 10 | const fakeHtml = ` 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 |
Embalse A1.000,550,5
Total300150
Embalse B200-
32 | 33 | 34 | `; 35 | 36 | describe("getEstadoCuencaDuero", () => { 37 | it("should return a clean array of reservoirs with numbers and nulls", async () => { 38 | (axios.get as Mock).mockResolvedValueOnce({ data: fakeHtml }); 39 | 40 | const result = await getEstadoCuencaDuero(); 41 | console.log(result); 42 | 43 | // El test ahora espera NÚMEROS y NULL 44 | expect(result).toHaveLength(0); 45 | expect(result).toEqual([]); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /packages/db/src/console-runners/index.ts: -------------------------------------------------------------------------------- 1 | import prompts from "prompts"; 2 | import fs from "node:fs/promises"; 3 | import path from "node:path"; 4 | import url from "node:url"; 5 | import { dbServer } from "#core/servers/index.js"; 6 | import { mongoDBQuestion } from "./questions.js"; 7 | import { filterChoices } from "./helpers.js"; 8 | 9 | const __dirname = path.dirname(url.fileURLToPath(import.meta.url)); 10 | const consoleRunners = await fs 11 | .readdir(__dirname, { withFileTypes: true }) 12 | .then((files) => 13 | files.filter((file) => file.isDirectory()).map((file) => file.name) 14 | ); 15 | 16 | let exit = false; 17 | const mongoDbFields = await prompts(mongoDBQuestion); 18 | const connectionString = Boolean(mongoDbFields.connectionString) 19 | ? mongoDbFields.connectionString 20 | : process.env.MONGODB_CONNECTION_STRING; 21 | await dbServer.connect(connectionString); 22 | while (!exit) { 23 | const { consoleRunner } = await prompts([ 24 | { 25 | name: "consoleRunner", 26 | type: "select", 27 | message: "Which test-runner do you want to run?", 28 | choices: [...consoleRunners, "exit"].map((option) => ({ 29 | title: option, 30 | value: option, 31 | })), 32 | suggest: filterChoices, 33 | }, 34 | ]); 35 | 36 | if (consoleRunner !== "exit") { 37 | const { run } = await import(`./${consoleRunner}/index.js`); 38 | await run(); 39 | } else { 40 | exit = true; 41 | await dbServer.disconnect(); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /integrations/arcgis/src/arcgis.mappers.ts: -------------------------------------------------------------------------------- 1 | import { Cuenca, Embalse, MetaDatos } from "db-model"; 2 | import { ArcGisEntry } from "./api/arcgis-embalse-model.js"; 3 | 4 | export const mapArgGisEntryToCuenca = (arcGisEntry: ArcGisEntry): Cuenca => ({ 5 | _id: arcGisEntry.ambito_id.toString(), 6 | nombre: arcGisEntry.ambito_nombre, 7 | }); 8 | 9 | export const mapArgGisEntryToEmbalse = (arcGisEntry: ArcGisEntry): Embalse => ({ 10 | _id: arcGisEntry.embalse_id_1.toString(), 11 | embalse_id: arcGisEntry.EMBALSE_ID, 12 | nombre: arcGisEntry.embalse_nombre, 13 | cuenca: mapArgGisEntryToCuenca(arcGisEntry), 14 | provincia: null, // No disponible en ArcGisEntry 15 | capacidad: arcGisEntry.agua_total, 16 | aguaActualAemet: arcGisEntry.agua_actual, 17 | fechaMedidaAguaActualAemet: new Date(arcGisEntry.fecha), 18 | aguaActualSAIH: null, 19 | fechaMedidaAguaActualSAIH: null, 20 | descripcion_id: null, // No disponible en ArcGisEntry 21 | uso: arcGisEntry.Uso, 22 | }); 23 | 24 | export const mapArgGisEntryToMetaDatos = ( 25 | arcGisEntry: ArcGisEntry 26 | ): MetaDatos => ({ 27 | _id: arcGisEntry.OBJECTID_1.toString(), 28 | ultimaImportacionAemet: new Date(arcGisEntry.fecha), 29 | ultimoStatus: "unknown", // No disponible en ArcGisEntry 30 | ultimasImportacionesSAIH: [ 31 | { 32 | nombresitio: arcGisEntry.ambito_nombre, 33 | ultimaimportacion: new Date(arcGisEntry.fecha), 34 | ultimoStatus: "unknown", // No disponible en ArcGisEntry 35 | }, 36 | ], 37 | }); 38 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-tajo/src/integration.ts: -------------------------------------------------------------------------------- 1 | import { Browser, Page } from "playwright"; 2 | import { getCuencaPageContent, SubcuencaInfo, SUBCUENCAS } from "./api"; 3 | import { mapToEmbalsesBySubcuenca, reservoirInfoFromTable } from "./scraper"; 4 | 5 | async function processSubcuencaData( 6 | page: Page, 7 | subcuenca: string 8 | ): Promise { 9 | const rawReservoirs = await reservoirInfoFromTable(page, subcuenca); 10 | return mapToEmbalsesBySubcuenca(subcuenca, rawReservoirs); 11 | } 12 | 13 | export const scrapeCuencaTajo = async ( 14 | url: string 15 | ): Promise => { 16 | const reservoirsCollection: SubcuencaInfo[] = []; 17 | 18 | const subcuencasPromises = SUBCUENCAS.map(async (subcuenca) => { 19 | let browser: Browser | null = null; 20 | try { 21 | const { page, browser: browserInstance } = await getCuencaPageContent( 22 | url 23 | ); 24 | browser = browserInstance; 25 | 26 | const result = await processSubcuencaData(page, subcuenca); 27 | return result; 28 | } catch (error) { 29 | console.error(error); 30 | return { 31 | nombreSubcuenca: subcuenca, 32 | embalses: [], 33 | }; 34 | } finally { 35 | if (browser) { 36 | await browser.close(); 37 | } 38 | } 39 | }); 40 | const subcuencasReservoirs = await Promise.all(subcuencasPromises); 41 | 42 | reservoirsCollection.push(...subcuencasReservoirs); 43 | 44 | return reservoirsCollection; 45 | }; 46 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-guadalquivir/src/integration.ts: -------------------------------------------------------------------------------- 1 | import { getCuencaPageContent, Zone, ZoneInfo, ZONES } from "./api"; 2 | import { 3 | extractCurrentDate, 4 | mapToEmbalsesByZone, 5 | mapToEmbalseUpdateSAIH, 6 | reservoirInfoFromTable, 7 | } from "./scraper"; 8 | import { Browser, Page } from "playwright"; 9 | 10 | async function processZoneData(page: Page, zone: Zone): Promise { 11 | const rawReservoirs = await reservoirInfoFromTable(page); 12 | const currentDate = await extractCurrentDate(page); 13 | const saihReservoirs = mapToEmbalseUpdateSAIH(rawReservoirs, currentDate); 14 | return mapToEmbalsesByZone(zone.codigo, zone.nombre, saihReservoirs); 15 | } 16 | 17 | export const scrapeCuencaGuadalquivir = async ( 18 | url: string 19 | ): Promise => { 20 | const reservoirsCollection: ZoneInfo[] = []; 21 | 22 | const zonesPromises = ZONES.map(async (zone) => { 23 | let browser: Browser | null = null; 24 | try { 25 | const { page, browser: browserInstance } = await getCuencaPageContent( 26 | url, 27 | zone.codigo 28 | ); 29 | browser = browserInstance; 30 | 31 | const result = await processZoneData(page, zone); 32 | return result; 33 | } catch (error) { 34 | console.error(`Failed to scrape zone ${zone.codigo}:`, error); 35 | return { 36 | codigoZona: zone.codigo, 37 | nombreZona: zone.nombre, 38 | embalses: [], 39 | }; 40 | } finally { 41 | if (browser) { 42 | await browser.close(); 43 | } 44 | } 45 | }); 46 | const zonesReservoirs = await Promise.all(zonesPromises); 47 | reservoirsCollection.push(...zonesReservoirs); 48 | 49 | return reservoirsCollection; 50 | }; 51 | -------------------------------------------------------------------------------- /packages/db/src/dals/embalses/embalses.mappers.ts: -------------------------------------------------------------------------------- 1 | import { InfoDestinoArcgis } from "db-model"; 2 | 3 | // Arcgis falta guadalhorce, o está agrupado guadalhorce con guadalteba? 4 | // Arcgis falta Cueva de la Mora, Huelva 5 | export const mapperFromCuencasMediterraneaToArcgis = new Map< 6 | number, 7 | InfoDestinoArcgis 8 | >([ 9 | [3, { nombre: "Charco Redondo", idArcgis: 101 }], 10 | [8, { nombre: "Guadarranque", idArcgis: 158 }], 11 | [269, { nombre: "Zahara-El Gastor", idArcgis: 350 }], 12 | [270, { nombre: "Bornos", idArcgis: 58 }], 13 | [271, { nombre: "Arcos de la Frontera", idArcgis: 27 }], 14 | [272, { nombre: "Los Hurones", idArcgis: 167 }], 15 | [273, { nombre: "Guadalcacín", idArcgis: 152 }], 16 | [275, { nombre: "Barbate", idArcgis: 39 }], 17 | [276, { nombre: "Celemín", idArcgis: 93 }], 18 | [277, { nombre: "Almodóvar", idArcgis: 21 }], 19 | [16, { nombre: "Concepción", idArcgis: 106 }], 20 | [19, { nombre: "Casasola", idArcgis: 380 }], 21 | [20, { nombre: "Limonero", idArcgis: 184 }], 22 | [29, { nombre: "Guadalteba", idArcgis: 154 }], 23 | [31, { nombre: "Conde del guadalhorce", idArcgis: 108 }], 24 | [37, { nombre: "La Viñuela", idArcgis: 347 }], 25 | [51, { nombre: "Rules", idArcgis: 379 }], 26 | [64, { nombre: "Béznar", idArcgis: 68 }], 27 | [58, { nombre: "Benínar", idArcgis: 51 }], 28 | [84, { nombre: "Cuevas de Almanzora", idArcgis: 118 }], 29 | [371, { nombre: "Chanza", idArcgis: 100 }], 30 | [373, { nombre: "Piedras", idArcgis: 229 }], 31 | [374, { nombre: "Machos", idArcgis: 189 }], 32 | [376, { nombre: "Olivargas", idArcgis: 216 }], 33 | [377, { nombre: "Corumbel Bajo", idArcgis: 113 }], 34 | [379, { nombre: "Jarrama", idArcgis: 358 }], 35 | [380, { nombre: "Andévalo", idArcgis: 355 }], 36 | ]); 37 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-jucar/src/scraper/business.ts: -------------------------------------------------------------------------------- 1 | import vm from 'vm'; 2 | import { CheerioAPI } from 'cheerio'; 3 | import { EmbalsesJucar } from '@/api'; 4 | import { mapEmbalsesToEntities } from '@/scraper'; 5 | 6 | function extractSubCuencasArrayText($: CheerioAPI): string { 7 | let subCuencasArrayText = ''; 8 | $('script').each((_, el) => { 9 | const scriptContent = $(el).html(); 10 | if (scriptContent && scriptContent.includes('let subCuencasArray')) { 11 | const match = scriptContent.match(/let subCuencasArray\s*=\s*(\[[\s\S]*?\]);/); 12 | if (match) { 13 | subCuencasArrayText = match[1]; 14 | } 15 | } 16 | }); 17 | if (!subCuencasArrayText) { 18 | throw new Error('No subCuencasArray found in page'); 19 | } 20 | return subCuencasArrayText; 21 | } 22 | 23 | function evaluateSubCuencasArray(arrayText: string): any[] { 24 | const sandbox: any = {}; 25 | vm.createContext(sandbox); 26 | const code = `var subCuencasArray = ${arrayText}; subCuencasArray;`; 27 | return vm.runInContext(code, sandbox); 28 | } 29 | 30 | export function formatFechaComunicacionVol(fecha: string): string | null { 31 | if (!fecha) return null; 32 | const date = new Date(fecha); 33 | if (isNaN(date.getTime())) return null; 34 | // Format as dd/mm/yyyy 35 | const day = String(date.getDate()).padStart(2, '0'); 36 | const month = String(date.getMonth() + 1).padStart(2, '0'); 37 | const year = date.getFullYear(); 38 | return `${day}/${month}/${year}`; 39 | } 40 | 41 | export function extractReservoirsFromJucarPage($: CheerioAPI): EmbalsesJucar[] { 42 | const arrayText = extractSubCuencasArrayText($); 43 | const subCuencasArray = evaluateSubCuencasArray(arrayText); 44 | return mapEmbalsesToEntities(subCuencasArray); 45 | } 46 | -------------------------------------------------------------------------------- /functions/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | 24 | # nyc test coverage 25 | .nyc_output 26 | 27 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 28 | .grunt 29 | 30 | # Bower dependency directory (https://bower.io/) 31 | bower_components 32 | 33 | # node-waf configuration 34 | .lock-wscript 35 | 36 | # Compiled binary addons (https://nodejs.org/api/addons.html) 37 | build/Release 38 | 39 | # Dependency directories 40 | node_modules/ 41 | jspm_packages/ 42 | 43 | # TypeScript v1 declaration files 44 | typings/ 45 | 46 | # Optional npm cache directory 47 | .npm 48 | 49 | # Optional eslint cache 50 | .eslintcache 51 | 52 | # Optional REPL history 53 | .node_repl_history 54 | 55 | # Output of 'npm pack' 56 | *.tgz 57 | 58 | # Yarn Integrity file 59 | .yarn-integrity 60 | 61 | # dotenv environment variables file 62 | .env 63 | .env.test 64 | 65 | # parcel-bundler cache (https://parceljs.org/) 66 | .cache 67 | 68 | # next.js build output 69 | .next 70 | 71 | # nuxt.js build output 72 | .nuxt 73 | 74 | # vuepress build output 75 | .vuepress/dist 76 | 77 | # Serverless directories 78 | .serverless/ 79 | 80 | # FuseBox cache 81 | .fusebox/ 82 | 83 | # DynamoDB Local files 84 | .dynamodb/ 85 | 86 | # TypeScript output 87 | dist 88 | out 89 | 90 | # Azure Functions artifacts 91 | bin 92 | obj 93 | appsettings.json 94 | local.settings.json 95 | 96 | # Azurite artifacts 97 | __blobstorage__ 98 | __queuestorage__ 99 | __azurite_db*__.json -------------------------------------------------------------------------------- /integrations/scraping-cuenca-jucar/src/scraper/mapper.ts: -------------------------------------------------------------------------------- 1 | import { EmbalseUpdateSAIHEntity } from 'db-model'; 2 | import { EmbalsesJucar } from '@/api'; 3 | import { formatFechaComunicacionVol } from '@/scraper'; 4 | 5 | /** 6 | * Maps subCuencasArray to EmbalsesJucar array format. 7 | * @param subCuencasArray - Array of Embalses from Jucar basin 8 | * @returns Array of EmbalsesJucar objects 9 | */ 10 | export function mapEmbalsesToEntities(subCuencasArray: any[]): EmbalsesJucar[] { 11 | const reservoirs: EmbalsesJucar[] = []; 12 | subCuencasArray.forEach(([_, embalses]: [string, any[]]) => { 13 | embalses.forEach((embalse: any) => { 14 | const capacidadTotalHm3 = embalse.fldFVolumenNMN ?? 0; 15 | const volumenActualHm3 = embalse.valorVolumenEmbalse ?? 0; 16 | reservoirs.push({ 17 | id: embalse.idEstacionRemota, 18 | embalse: embalse.fldTNombre, 19 | provincia: embalse.fldTProvincia, 20 | porcentajeActual: 21 | capacidadTotalHm3 > 0 22 | ? (volumenActualHm3 / capacidadTotalHm3) * 100 23 | : null, 24 | capacidadTotalHm3, 25 | volumenActualHm3: volumenActualHm3.toFixed(2), 26 | caudalRecibido: embalse.valorCaudalRecibido, 27 | caudalSalida: embalse.valorCaudalSalida, 28 | fecha: formatFechaComunicacionVol(embalse.fechaComunicacionVol), 29 | }); 30 | }); 31 | }); 32 | return reservoirs; 33 | } 34 | 35 | /** 36 | * Maps EmbalsesJucar data to EmbalseUpdateSAIH format. 37 | * @param embalsesJucar - Array of EmbalsesJucar objects 38 | * @returns Array of EmbalseUpdateSAIH objects 39 | */ 40 | export function mapToEmbalseUpdateSAIH( 41 | embalsesJucar: EmbalsesJucar[] 42 | ): EmbalseUpdateSAIHEntity[] { 43 | return embalsesJucar.map((embalse) => ({ 44 | id: embalse.id, 45 | nombre: embalse.embalse, 46 | aguaActualSAIH: embalse.volumenActualHm3, 47 | fechaMedidaSAIH: embalse.fecha, 48 | })); 49 | } 50 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-tajo/src/api/cuenca.api.ts: -------------------------------------------------------------------------------- 1 | import { Browser, chromium, Page } from "playwright"; 2 | 3 | const userAgents = [ 4 | "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/45.0.2454.85 Safari/537.36", 5 | "Mozilla/5.0 (Windows NT 6.1; WOW64; Trident/7.0; rv:11.0) like Gecko", 6 | "Mozilla/5.0 (Windows NT 6.3; WOW64; Trident/7.0; rv:11.0) like Gecko", 7 | "Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/45.0.2454.85 Safari/537.36", 8 | "Mozilla/5.0 (Windows NT 6.1; Trident/7.0; rv:11.0) like Gecko", 9 | ]; 10 | 11 | export async function getCuencaPageContent( 12 | url: string 13 | ): Promise<{ page: Page; browser: Browser }> { 14 | const browser = await chromium.launch({ headless: true }); 15 | 16 | // BASIC ANTI-DETECTION to avoid WAF (web application firewall) blocks: 17 | // - userAgent: Simulates different browsers to appear as different users 18 | // - viewport: Simulates a common laptop screen instead of Playwright's default viewport (800x600) 19 | // Each execution uses a random User-Agent, confusing the WAF into thinking they are different users 20 | const context = await browser.newContext({ 21 | userAgent: userAgents[Math.floor(Math.random() * userAgents.length)], 22 | viewport: { width: 1366, height: 768 }, 23 | }); 24 | 25 | const page = await context.newPage(); 26 | 27 | await page.goto(url); 28 | 29 | // Navigate to Tajo's map 30 | const datosEnTiempoRealButton = page 31 | .locator('ons-toolbar-button[data-tmpl="#tmpl-datos-tiempo-real"]') 32 | .first(); 33 | 34 | await datosEnTiempoRealButton.click(); 35 | await page.waitForLoadState("networkidle"); 36 | 37 | // Navigate to "Subcuencas" 38 | 39 | const subcuencasButton = page.getByRole("button", { name: "Subcuencas" }); 40 | await subcuencasButton.click(); 41 | await page.waitForLoadState("networkidle"); 42 | 43 | return { page, browser }; 44 | } 45 | -------------------------------------------------------------------------------- /front/src/app/globals.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | @plugin "daisyui"; 3 | 4 | @import "@fontsource/nunito-sans/400.css"; 5 | @import "@fontsource/nunito-sans/500.css"; 6 | @import "@fontsource/nunito-sans/600.css"; 7 | 8 | @plugin "daisyui/theme" { 9 | name: "info-embalse"; 10 | default: true; 11 | color-scheme: light; 12 | 13 | --color-primary: #1b9aaa; 14 | --color-accent: #d9d900; 15 | 16 | --color-base-100: #feffff; /* White background */ 17 | --color-base-200: #eaeaea; /* Grey background */ 18 | --color-base-content: #060d14; /* Text color */ 19 | } 20 | 21 | @theme { 22 | --font-sans: "Nunito Sans", sans-serif; 23 | 24 | /* Title color */ 25 | --color-title: #051c1f; 26 | 27 | /* Accesible visited link color */ 28 | --color-visited-link: #257782; 29 | 30 | /*Primary color palette*/ 31 | 32 | --color-brand-50: #e8f5f7; 33 | --color-brand-100: #d1ebee; 34 | --color-brand-200: #a4d7dd; 35 | --color-brand-300: #76c2cc; 36 | --color-brand-400: #49aebb; 37 | --color-brand-500: #1b9aaa; /* Primary color */ 38 | --color-brand-600: #167b88; 39 | --color-brand-700: #105c66; 40 | --color-brand-800: #0b3e44; 41 | --color-brand-900: #051f22; 42 | --color-brand-950: #01161a; 43 | } 44 | 45 | @layer base { 46 | h1, 47 | h2, 48 | h3, 49 | h4, 50 | h5, 51 | h6 { 52 | @apply text-title opacity-95; 53 | } 54 | 55 | p, 56 | body { 57 | @apply opacity-85; 58 | } 59 | 60 | /* H1 Title 36px */ 61 | h1 { 62 | @apply text-4xl leading-none font-semibold; 63 | } 64 | 65 | /* H2 Title 28px */ 66 | h2 { 67 | @apply text-[28px] leading-normal font-semibold; 68 | } 69 | 70 | /* H3 Subtitle 24px */ 71 | h3 { 72 | @apply text-2xl leading-none font-medium; 73 | } 74 | 75 | /* Text 14px */ 76 | p, 77 | body { 78 | @apply text-base-content text-base leading-normal font-normal; 79 | } 80 | } 81 | 82 | @layer utilities { 83 | /*Accesible link */ 84 | 85 | .link-accessible { 86 | @apply link text-title visited:text-visited-link font-bold opacity-95; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # embalse-info 2 | 3 | ## Pasos para actualizar la semilla de las cuencas 4 | 5 | ```bash 6 | npm run start:console-runners 7 | ``` 8 | 9 | > Lanzar desde la raíz del proyecto. 10 | 11 | Seleccionar la opción `db-console-runners`: 12 | 13 | ```bash 14 | ? [console-runners] Select a console runner to execute › 15 | ❯ packages/db 16 | ``` 17 | 18 | Luego, seleccionar el runner `cuencas-seed`: 19 | 20 | ```bash 21 | ✔ Connection string (Press enter to use default): … mongodb://localhost:27017/embalse-info 22 | ? Which test-runner do you want to run? › - Use arrow-keys. Return to submit. 23 | ❯ cuencas-seed 24 | exit 25 | ``` 26 | 27 | Y ya estará actualizada la semilla de las cuencas. 28 | 29 | ## Pasos para activar las azure functions 30 | 31 | ### Cómo levantar Azurite (emulador de Azure Storage) en Docker 32 | 33 | ```bash 34 | docker run -d --name azurite -p 10000:10000 -p 10001:10001 -p 10002:10002 mcr.microsoft.com/azure-storage/azurite 35 | ``` 36 | 37 | ### Añadir las variables de entorno 38 | 39 | Dentro de la carpeta `functions`, crear un archivo `local.settings.json` con las siguientes variables de entorno: 40 | 41 | _/functions/local.settings.json_ 42 | 43 | ```json 44 | { 45 | "IsEncrypted": false, 46 | "Values": { 47 | "AzureWebJobsStorage": "UseDevelopmentStorage=true", 48 | "FUNCTIONS_WORKER_RUNTIME": "node", 49 | "MONGODB_CONNECTION_STRING": "mongodb://localhost:27017/embalse-info" 50 | } 51 | } 52 | ``` 53 | 54 | ### Arrancar las funciones de Azure 55 | 56 | En la raíz del proyecto, ejecutar: 57 | 58 | ```bash 59 | npm start 60 | ``` 61 | 62 | Seleccionar la opción `functions`: 63 | 64 | ```bash 65 | .... 66 | ◯ integrations/scraping-cuenca-mino-sil 67 | ◯ integrations/scraping-cuenca-segura 68 | ◯ integrations/scraping-cuenca-tajo 69 | ◯ front 70 | ◉ functions 71 | ``` 72 | 73 | Y ya estarán las funciones levantadas y ejecutándose. 74 | 75 | **Nota**: Recuerda que si ya tienes creado el contenedor de Azurite, deberás comprobar si está arrancado. Si no lo está, puedes arrancarlo con: 76 | 77 | ```bash 78 | docker start azurite 79 | ``` 80 | -------------------------------------------------------------------------------- /integrations/arcgis/src/api/arcgis-embalse.api.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { ArcGisEntry } from "./arcgis-embalse-model.js"; 3 | 4 | const API_URL = 5 | "https://services-eu1.arcgis.com/RvnYk1PBUJ9rrAuT/arcgis/rest/services/Embalses_Total/FeatureServer/0/query"; 6 | 7 | export const fetchLatestDate = async (): Promise => { 8 | const response = await axios.get(API_URL, { 9 | params: { 10 | where: "1=1", 11 | outFields: "Fecha_str", 12 | orderByFields: "fecha DESC", 13 | resultRecordCount: 1, 14 | f: "json", 15 | }, 16 | timeout: 20000, 17 | }); 18 | 19 | const features = response.data.features; 20 | if (!features || features.length === 0) { 21 | throw new Error("No se pudo obtener la Fecha_str más reciente."); 22 | } 23 | 24 | const latestFechaStr = features[0].attributes?.Fecha_str as string; 25 | if (!latestFechaStr) { 26 | throw new Error( 27 | "La respuesta no contenía 'Fecha_str' para la fecha más reciente." 28 | ); 29 | } 30 | return latestFechaStr; 31 | }; 32 | 33 | // Trae todos los registros de ArcGisEntry para una fecha específica. 34 | export const fetchEntriesByDate = async ( 35 | date: string, 36 | offset = 0, 37 | allResults: ArcGisEntry[] = [] 38 | ): Promise => { 39 | const response = await axios.get(API_URL, { 40 | params: { 41 | where: `Fecha_str = '${date}'`, 42 | outFields: "*", 43 | returnGeometry: false, 44 | f: "json", 45 | resultOffset: offset, 46 | orderByFields: "OBJECTID ASC", 47 | }, 48 | timeout: 30000, 49 | }); 50 | 51 | const features = response.data.features; 52 | if (!features || features.length === 0) { 53 | return allResults; 54 | } 55 | 56 | const newResults = features.map((f: any) => f.attributes as ArcGisEntry); 57 | const accumulatedResults = [...allResults, ...newResults]; 58 | 59 | if (features.length === 2000) { 60 | return fetchEntriesByDate( 61 | date, 62 | offset + features.length, 63 | accumulatedResults 64 | ); 65 | } else { 66 | return accumulatedResults; 67 | } 68 | }; 69 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-cantabrico/src/scraper/business.ts: -------------------------------------------------------------------------------- 1 | import type { SaichFeature } from "@/api"; 2 | 3 | export interface RawRow { 4 | id: number; // codigo_general 5 | nombre: string; // nombre 6 | volumenActualHm3: number; // volumen_embalse 7 | fecha: string; // fecha 8 | } 9 | 10 | const numFlex = (v: unknown): number | null => { 11 | if (v == null) return null; 12 | if (typeof v === "number") return Number.isFinite(v) ? v : null; 13 | if (typeof v === "string") { 14 | const s = v.trim().replace(",", "."); 15 | if (!s) return null; 16 | const n = Number(s); 17 | return Number.isFinite(n) ? n : null; 18 | } 19 | const n = Number(v as any); 20 | return Number.isFinite(n) ? n : null; 21 | }; 22 | 23 | const get = ( 24 | o: Record | undefined, 25 | k: string 26 | ): T | undefined => o?.[k] as T | undefined; 27 | 28 | /** Convierte features de SAICH a filas mínimas para el mapper final */ 29 | export function toRawRows(features: SaichFeature[]): RawRow[] { 30 | return ( 31 | features 32 | .filter((f) => { 33 | const p = f.properties as Record | undefined; 34 | // algunos payloads usan tipo_senial="embalse" 35 | const tipo = ( 36 | get(p, "tipo_estacion") ?? 37 | get(p, "tipo_senial") ?? 38 | "" 39 | ).toLowerCase(); 40 | return tipo === "embalse"; 41 | }) 42 | .map((f) => { 43 | const p = f.properties as Record | undefined; 44 | 45 | const idStr = ( 46 | get(p, "codigo_general") ?? 47 | get(p, "Cod_Roea") ?? 48 | "0" 49 | ).toString(); 50 | const id = Number(idStr); 51 | const nombre = get(p, "nombre") ?? ""; 52 | 53 | const volumenActualHm3 = numFlex(get(p, "volumen_embalse")); 54 | const fecha = get(p, "fecha") ?? ""; 55 | 56 | return { 57 | id: Number.isFinite(id) ? id : 0, 58 | nombre, 59 | volumenActualHm3: volumenActualHm3 ?? 0, 60 | fecha, 61 | }; 62 | }) 63 | // por si llega algún registro incompleto 64 | .filter((r) => r.id > 0 && r.nombre && r.fecha) 65 | ); 66 | } 67 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-segura/src/scraper/mapper.ts: -------------------------------------------------------------------------------- 1 | import { EmbalseUpdateSAIHEntity } from 'db-model'; 2 | import { EmbalsesSegura } from '@/api'; 3 | 4 | // Province lookup for each reservoir 5 | const reservoirProvince: Record = { 6 | 'Fuensanta': 'Albacete', 7 | 'Talave': 'Albacete', 8 | 'Camarillas': 'Albacete', 9 | 'Cenajo': 'Albacete/Murcia', 10 | 'La Pedrera': 'Alicante', 11 | }; 12 | 13 | /** 14 | * Maps table row data to EmbalsesSegura array format. 15 | * @param cols - Array of column elements from the table row 16 | * @param dateCol - Date string from the row header 17 | * @param rowIndex - Index of the row for ID generation 18 | * @param capacityMap - Map of reservoir capacity data 19 | * @returns Array of EmbalsesSegura objects 20 | */ 21 | export function mapEmbalsesToEntities( 22 | cols: any[], 23 | dateCol: string, 24 | rowIndex: number, 25 | capacityMap: Record = {} 26 | ): EmbalsesSegura[] { 27 | const reservoirNames = ['Fuensanta', 'Talave', 'Cenajo', 'Camarillas', 'La Pedrera']; 28 | const result: EmbalsesSegura[] = []; 29 | 30 | cols.forEach((col, i) => { 31 | if (i >= reservoirNames.length) return; 32 | 33 | const embalse = reservoirNames[i]; 34 | const volumenActualHm3 = Number(col); 35 | const provincia = reservoirProvince[embalse]; 36 | 37 | // Get capacity and calculate percentage 38 | const capacityData = capacityMap[embalse]; 39 | const capacidadTotalHm3 = capacityData?.capacity || 0; 40 | const porcentajeActual = capacidadTotalHm3 > 0 ? (volumenActualHm3 / capacidadTotalHm3) * 100 : 0; 41 | 42 | // Create unique ID based on row and reservoir 43 | const id = (rowIndex * 10) + (i + 1); 44 | 45 | result.push({ 46 | id, 47 | embalse, 48 | provincia, 49 | porcentajeActual: Math.round(porcentajeActual * 100) / 100, 50 | capacidadTotalHm3, 51 | volumenActualHm3, 52 | fecha: dateCol, 53 | }); 54 | }); 55 | 56 | return result; 57 | } 58 | 59 | /** 60 | * Maps EmbalsesSegura data to EmbalseUpdateSAIH format. 61 | * @param embalsesSegura - Array of EmbalsesSegura objects 62 | * @returns Array of EmbalseUpdateSAIH objects 63 | */ 64 | export function mapToEmbalseUpdateSAIH( 65 | embalsesSegura: EmbalsesSegura[] 66 | ): EmbalseUpdateSAIHEntity[] { 67 | return embalsesSegura.map((embalse) => ({ 68 | id: embalse.id, 69 | nombre: embalse.embalse, 70 | aguaActualSAIH: Math.round(embalse.volumenActualHm3 * 100) / 100, 71 | fechaMedidaSAIH: embalse.fecha, 72 | })); 73 | } 74 | -------------------------------------------------------------------------------- /packages/db/src/dals/embalses/embalses.repository.ts: -------------------------------------------------------------------------------- 1 | import { scrapeSeedEmbalses } from "arcgis"; 2 | import { getEmbalsesContext } from "./embalses.context.js"; 3 | import { mapperFromCuencasMediterraneaToArcgis } from "./embalses.mappers.js"; 4 | import { scrapeCuencaMediterranea } from "scraping-cuenca-mediterranea"; 5 | import { parseDate } from "./embalses.helpers.js"; 6 | 7 | export const embalsesRepository = { 8 | actualizarEmbalses: async (): Promise => { 9 | const { embalses } = await scrapeSeedEmbalses(); 10 | const { ok } = await getEmbalsesContext().bulkWrite( 11 | embalses.map((embalse) => ({ 12 | updateOne: { 13 | filter: { _id: embalse._id }, 14 | update: { $set: embalse }, 15 | upsert: true, 16 | }, 17 | })) 18 | ); 19 | 20 | return ok === 1; 21 | }, 22 | actualizarCuencaMediterranea: async (): Promise => { 23 | const embalsesMediterranea = await scrapeCuencaMediterranea(); 24 | 25 | console.log( 26 | `Se han scrapeado ${embalsesMediterranea.length} embalses de la Cuenca Mediterránea` 27 | ); 28 | 29 | let actualizados = 0; 30 | let noEncontrados = 0; 31 | let sinMapper = 0; 32 | 33 | for (const embalse of embalsesMediterranea) { 34 | const infoDestino = mapperFromCuencasMediterraneaToArcgis.get(embalse.id); 35 | 36 | if (!infoDestino) { 37 | sinMapper++; 38 | console.warn(`Sin mapper para ID ${embalse.id} - ${embalse.nombre}`); 39 | continue; 40 | } 41 | 42 | console.log( 43 | `🔍 Mapeando: ID scraping ${embalse.id} -> _id BD ${infoDestino.idArcgis} (${infoDestino.nombre})` 44 | ); 45 | 46 | const { matchedCount } = await getEmbalsesContext().updateOne( 47 | { _id: infoDestino.idArcgis.toString() }, 48 | { 49 | $set: { 50 | aguaActualSAIH: embalse.aguaActualSAIH, 51 | fechaMedidaAguaActualSAIH: parseDate(embalse.fechaMedidaSAIH), 52 | }, 53 | } 54 | ); 55 | 56 | if (matchedCount > 0) { 57 | actualizados++; 58 | console.log( 59 | `Actualizado: ${infoDestino.nombre} (_id: ${infoDestino.idArcgis}) -> ${embalse.aguaActualSAIH} hm³` 60 | ); 61 | } else { 62 | noEncontrados++; 63 | console.warn( 64 | `No encontrado en BD: _id ${infoDestino.idArcgis} - ${infoDestino.nombre}` 65 | ); 66 | } 67 | } 68 | 69 | console.log( 70 | `Resumen Cuenca Mediterránea: ${actualizados} actualizados, ${noEncontrados} no encontrados, ${sinMapper} sin mapper` 71 | ); 72 | 73 | return actualizados > 0; 74 | }, 75 | }; 76 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-segura/src/scraper/business.ts: -------------------------------------------------------------------------------- 1 | import { CheerioAPI } from 'cheerio'; 2 | import type { Element } from 'domhandler'; 3 | import { EmbalsesSegura } from '@/api'; 4 | import { mapEmbalsesToEntities } from '@/scraper' 5 | 6 | // Function to extract capacity data from main table 7 | function getReservoirCapacities($: CheerioAPI): Record { 8 | const capacityMap: Record = {}; 9 | 10 | $('#n0 tbody tr').each((_, row) => { 11 | const $row = $(row); 12 | const cols = $row.find('td'); 13 | if (cols.length !== 4) return; 14 | 15 | const embalse = $(cols[0]).text().trim(); 16 | if (!embalse || 17 | embalse.toLowerCase().includes('total') || 18 | embalse.toLowerCase().includes('resto')) { 19 | return; 20 | } 21 | 22 | const capacidadTotalHm3 = Number($(cols[1]).text().trim()); 23 | const porcentajeActual = Number($(cols[3]).text().trim()); 24 | 25 | capacityMap[embalse] = { 26 | capacity: capacidadTotalHm3, 27 | percentage: porcentajeActual 28 | }; 29 | }); 30 | 31 | return capacityMap; 32 | } 33 | 34 | // Extract capacity data from annual stats table 35 | function extractAnnualStatsRows($: CheerioAPI): Array { 36 | return $('#n1 tbody tr').toArray(); 37 | } 38 | 39 | // parseAnnualStatsRow function to use capacity data 40 | function parseAnnualStatsRow( 41 | row: Element, 42 | rowIndex: number, 43 | $: CheerioAPI, 44 | capacityMap: Record = {} 45 | ): EmbalsesSegura[] { 46 | const $row = $(row); 47 | const cols = $row.find('td'); 48 | const dateCol = $row.find('th').first().text().trim(); 49 | if (!dateCol) return []; 50 | 51 | // Extract column values 52 | const colValues: string[] = []; 53 | cols.each((i, col) => { 54 | colValues.push($(col).text().trim()); 55 | }); 56 | 57 | const embalsesSeguraResult: EmbalsesSegura[] = mapEmbalsesToEntities( 58 | colValues, 59 | dateCol, 60 | rowIndex, 61 | capacityMap 62 | ); 63 | 64 | return embalsesSeguraResult; 65 | } 66 | 67 | export function extractReservoirsFromSeguraPage($: CheerioAPI): EmbalsesSegura[] { 68 | // Get capacity data from main table (#n0) 69 | const capacityMap = getReservoirCapacities($); 70 | 71 | // Get most recent monthly data from annual table (#n1) 72 | const reservoirs: EmbalsesSegura[] = []; 73 | const annualRows = extractAnnualStatsRows($); 74 | 75 | // Take only the LAST row (most recent month) 76 | if (annualRows.length > 0) { 77 | const lastRow = annualRows[annualRows.length - 1]; 78 | const stats = parseAnnualStatsRow(lastRow, 0, $, capacityMap); 79 | reservoirs.push(...stats); 80 | } 81 | 82 | return reservoirs; 83 | } 84 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-duero/src/scraper/business.ts: -------------------------------------------------------------------------------- 1 | import { EmbalseDuero } from "../api/cuenca.model"; 2 | import { load } from "cheerio"; 3 | 4 | // Función auxiliar para parsear string a number o null 5 | function _parseToNumberOrNull(value: string): number | null { 6 | const trimmed = value.trim(); 7 | if (trimmed === "-" || trimmed === "") return null; 8 | // Quitar puntos de miles y cambiar coma decimal por punto 9 | const normalized = trimmed.replace(/\./g, "").replace(",", "."); 10 | const num = Number(normalized); 11 | return isNaN(num) ? null : num; 12 | } 13 | 14 | // Esta función recibirá el HTML y devolverá el array de embalses 15 | export function parseReservoirsFromHtml(html: string): EmbalseDuero[] { 16 | const $ = load(html); 17 | const reservoirs: EmbalseDuero[] = []; 18 | 19 | $("tbody > tr").each((index, element) => { 20 | const tds = $(element).find("td"); 21 | const embalse = $(tds[0]).text().trim(); 22 | const capacityRaw = $(tds[1]).text().trim(); 23 | const currentVolumeRaw = $(tds[2]).text().trim(); 24 | const normalizedName = embalse.toLowerCase(); 25 | const provinceHeader = $(element).find('td[colspan="11"]'); 26 | const detectedProvince = provinceHeader.text().trim(); 27 | const capacity = _parseToNumberOrNull(capacityRaw); 28 | const currentVolume = _parseToNumberOrNull(currentVolumeRaw); 29 | if ( 30 | !detectedProvince && 31 | embalse && 32 | !normalizedName.startsWith("total") && 33 | !normalizedName.startsWith("% del total") 34 | ) { 35 | reservoirs.push({ 36 | id: index, 37 | embalse, 38 | capacidadActualHm3: capacity, 39 | volumenActualHm3: currentVolume, 40 | }); 41 | } 42 | }); 43 | 44 | return reservoirs; 45 | } 46 | 47 | export const getCurrentDate = (html: string) => { 48 | const $ = load(html); 49 | 50 | const titleElement = $("div .title-table").text(); 51 | 52 | if (!titleElement.includes("Duero a día")) { 53 | throw new Error( 54 | 'El formato del título no contiene "Duero a día". Verifica el HTML proporcionado.' 55 | ); 56 | } 57 | 58 | const parts = titleElement.split("Duero a día"); 59 | if (parts.length < 2) { 60 | throw new Error( 61 | "No se pudo extraer la fecha del título. Verifica el formato del HTML." 62 | ); 63 | } 64 | 65 | const currentValue = parts[1].split("de").join(" ").trim(); 66 | 67 | const currentDate = new Date(currentValue); 68 | if (isNaN(currentDate.getTime())) { 69 | throw new Error(`La fecha extraída no es válida: ${currentValue}`); 70 | } 71 | 72 | return formatApiDate(currentDate); 73 | }; 74 | 75 | const formatApiDate = (date: Date): string => { 76 | const year = date.getFullYear(); 77 | const month = String(date.getMonth() + 1).padStart(2, "0"); 78 | const day = String(date.getDate()).padStart(2, "0"); 79 | 80 | return `${day}/${month}/${year}`; 81 | }; 82 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-guadalquivir/src/scraper/business.ts: -------------------------------------------------------------------------------- 1 | import { EmbalsesGuadalquivir } from "@/api"; 2 | import { Locator, Page } from "playwright"; 3 | 4 | /** 5 | * Locates the "ESTADO DE EMBALSES" table by id. 6 | * @param page - Playwright Page instance 7 | * @returns Returns the Playwright Locator for the table or null if not found 8 | */ 9 | 10 | async function findEstadoEmbalsesTable(page: Page) { 11 | try { 12 | const tableById = page.locator("#ContentPlaceHolder1_GridNivelesEmbalses"); 13 | if ((await tableById.count()) > 0) return tableById; 14 | } catch (error) { 15 | console.error("Embalses table not found:", error); 16 | return null; 17 | } 18 | } 19 | 20 | /** 21 | * Extracts the current date from the page. 22 | * @param page - Playwright Page instance 23 | * @returns The current date as a string or null if not found 24 | */ 25 | 26 | export async function extractCurrentDate(page: Page): Promise { 27 | try { 28 | const dateElement = page.locator("#DatosActualizadosTimer1_Lbltime"); 29 | const text = await dateElement.textContent(); 30 | 31 | if (!text) return null; 32 | 33 | const regEx = /Actualizados: /; 34 | const trimmedText = text.replace(regEx, "").trim(); 35 | const datePart = trimmedText.split(" ")[0].replace(/-/g, "/"); 36 | return datePart; 37 | } catch (error) { 38 | console.error("Date not found:", error); 39 | } 40 | } 41 | 42 | /** 43 | * Parses a number string with European format (comma as decimal separator). 44 | * @param value - The string value to parse 45 | * @returns The parsed number or NaN if invalid 46 | */ 47 | 48 | function parseEuropeanNumber(value: string): number { 49 | if (!value || value.trim() === "" || value === "*" || value === "n/d") { 50 | return NaN; 51 | } 52 | 53 | // Replace comma with dot for decimal separator 54 | const normalizedValue = value.replace(",", "."); 55 | return parseFloat(normalizedValue); 56 | } 57 | 58 | /** 59 | * Returns the trimmed text content of all cells in a row. 60 | * @param row - Playwright Locator representing the table row () 61 | * @returns Array of strings with each cell's text, in column order 62 | */ 63 | 64 | async function extractTableCellsText(row: Locator): Promise { 65 | const cells = row.locator("td"); 66 | 67 | const rowData: string[] = []; 68 | 69 | for (let i = 0; i < (await cells.count()); i++) { 70 | const cellText = await cells.nth(i).textContent(); 71 | rowData.push(cellText?.trim() || ""); 72 | } 73 | 74 | return rowData; 75 | } 76 | 77 | /** 78 | * Checks whether a row is a real reservoir data row. 79 | * A valid data row must contain exactly 6 cells. 80 | * @param row - Playwright Locator representing the table row () 81 | * @returns true if the row has 6 data cells; false otherwise 82 | */ 83 | 84 | async function isReservoirDataRow(row: Locator): Promise { 85 | const cellCount = await row.locator("td").count(); 86 | return cellCount === 6; 87 | } 88 | 89 | /** 90 | * Parses the first column ("Embalse") to extract id, reservoir name, and province code. 91 | * Example input: "E01 El Tranco de Beas (JA)" 92 | * @param text - Raw text from the first table cell 93 | * @returns Object with numeric id, reservoir name, and province code (e.g., "JA") 94 | */ 95 | 96 | function parseEmbalseCell(text: string): { 97 | id: number; 98 | embalse: string; 99 | provincia: string; 100 | } { 101 | const t = text.trim(); 102 | 103 | // Get provincia 104 | const provincia = t.slice(-3, -1); // "(JA)" → "JA" 105 | 106 | const withoutProvincia = t.slice(0, -5); // "E01 El Tranco de Beas" 107 | 108 | // Get id and embalse name 109 | const [code, ...embalseNameParts] = withoutProvincia.split(" "); 110 | const id = Number(code.slice(1)); // 111 | const embalse = embalseNameParts.join(" "); 112 | 113 | return { id, embalse, provincia }; 114 | } 115 | 116 | /** 117 | * Maps a 6-column row into the EmbalsesGuadalquivir model. 118 | * Expected columns (index → meaning): 119 | * 0 → "Embalse" (contains code, name, province) 120 | * 1 → NMN (m.s.n.m.) 121 | * 2 → Nivel (m.s.n.m.) 122 | * 3 → Capacidad (hm³) 123 | * 4 → Volumen (hm³) 124 | * 5 → Porcentaje (%) 125 | * @param cols - Array of 6 strings with the row cell texts 126 | * @returns Parsed EmbalsesGuadalquivir object, or null if the row shape is invalid 127 | */ 128 | 129 | function parseReservoirRow(cols: string[]): EmbalsesGuadalquivir | null { 130 | if (cols.length !== 6) return null; 131 | 132 | const { id, embalse, provincia } = parseEmbalseCell(cols[0]); 133 | return { 134 | id, 135 | embalse, 136 | provincia, 137 | nmnMsnm: parseEuropeanNumber(cols[1]), 138 | nivelActualMsnm: parseEuropeanNumber(cols[2]), 139 | capacidadActualHm3: parseEuropeanNumber(cols[3]), 140 | volumenActualHm3: parseEuropeanNumber(cols[4]), 141 | porcentajeActual: parseEuropeanNumber(cols[5]), 142 | }; 143 | } 144 | 145 | /** 146 | * Parses the visible "Estado de embalses" table and returns all reservoir entries. 147 | * Flow: 148 | * - Locate the target table 149 | * - Iterate rows and keep only rows with 6 data cells 150 | * - Extract cell texts and map each row into the domain model 151 | * @param page - Playwright Page instance loaded with the page's HTML 152 | * @returns Array of EmbalsesGuadalquivir entries for the current page/zone 153 | */ 154 | 155 | export async function reservoirInfoFromTable( 156 | page: Page 157 | ): Promise { 158 | const table = await findEstadoEmbalsesTable(page); 159 | 160 | if (!table) { 161 | console.warn("Embalses table not found"); 162 | return []; 163 | } 164 | 165 | const rows = table.locator("tbody tr"); 166 | const reservoirs: EmbalsesGuadalquivir[] = []; 167 | 168 | for (let i = 0; i < (await rows.count()); i++) { 169 | const row = rows.nth(i); 170 | if (!(await isReservoirDataRow(row))) continue; 171 | 172 | const cols = await extractTableCellsText(row); 173 | const parsed = parseReservoirRow(cols); 174 | if (parsed) reservoirs.push(parsed); 175 | } 176 | 177 | return reservoirs; 178 | } 179 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-mediterranea/src/scraper/business.ts: -------------------------------------------------------------------------------- 1 | import type { EmbalsesAndalucia } from "../api/index.js"; 2 | import * as cheerio from "cheerio"; 3 | import { AnyNode } from "domhandler"; 4 | 5 | /** 6 | * Extracts the current date from the page. 7 | * @param $ - Cheerio instance 8 | * @returns The current date as a string 9 | */ 10 | 11 | export function extractCurrentDate($: cheerio.CheerioAPI): string { 12 | const dateElement = $("div.col-sm-6 > b"); 13 | const regEx = /Datos actualizados a: /; 14 | const trimmedText = dateElement.text().replace(regEx, "").trim(); 15 | return trimmedText.split(" ")[0].replace(/-/g, "/"); // Return only the date part with slashes 16 | } 17 | 18 | /** 19 | * Parses a number string with European format (comma as decimal separator). 20 | * @param value - The string value to parse 21 | * @returns The parsed number or NaN if invalid 22 | */ 23 | export function parseEuropeanNumber(value: string): number { 24 | if (!value || value.trim() === "" || value === "*" || value === "n/d") { 25 | return NaN; 26 | } 27 | 28 | // Replace comma with dot for decimal separator 29 | const normalizedValue = value.replace(",", "."); 30 | return parseFloat(normalizedValue); 31 | } 32 | 33 | /** 34 | * Extracts the text content from all cells (td) of a table row. 35 | * @param $row - Cheerio element representing a table row 36 | * @param $ - Cheerio instance to process elements 37 | * @returns Array of strings with the text content of each cell 38 | */ 39 | export function extractTableCellsText( 40 | $row: cheerio.Cheerio, 41 | $: cheerio.CheerioAPI 42 | ): string[] { 43 | return $row 44 | .find("td") 45 | .map((_: any, el: any) => $(el).text().trim()) 46 | .get(); 47 | } 48 | 49 | /** 50 | * Extracts the province name from a header row. 51 | * @param $row - Cheerio element representing a table row 52 | * @returns The province name or null if it's not a header row 53 | */ 54 | export function extractProvinceFromRow( 55 | $row: cheerio.Cheerio 56 | ): string | null { 57 | const provinceHeader = $row.find('th[colspan="2"]'); 58 | const detectedProvince = provinceHeader.text().trim(); 59 | 60 | // Verify that it's actually a province (not dates or other headers) 61 | if ( 62 | detectedProvince && 63 | !detectedProvince.includes("Fecha Actual") && 64 | !detectedProvince.includes("TOTAL") && 65 | !detectedProvince.includes("D.H.") && 66 | detectedProvince !== "" && 67 | detectedProvince !== " " 68 | ) { 69 | return detectedProvince; 70 | } 71 | 72 | return null; 73 | } 74 | 75 | /** 76 | * Verifies if a row is a reservoir data row (has td cells). 77 | * @param $row - Cheerio element representing a table row 78 | * @returns true if it's a reservoir data row 79 | */ 80 | export function isReservoirDataRow($row: cheerio.Cheerio): boolean { 81 | const cells = $row.find("td"); 82 | return cells.length >= 10; // A reservoir row has at least 10 columns 83 | } 84 | 85 | /** 86 | * Extracts the province sections from the main table. 87 | * @param $ - Cheerio instance 88 | * @returns Array of objects with province and its data rows 89 | */ 90 | export function extractProvinceTables( 91 | $: cheerio.CheerioAPI 92 | ): Array<{ province: string; rows: cheerio.Cheerio[] }> { 93 | const provinceTables: Array<{ 94 | province: string; 95 | rows: cheerio.Cheerio[]; 96 | }> = []; 97 | let currentProvince = ""; 98 | let currentRows: cheerio.Cheerio[] = []; 99 | 100 | $("table tbody tr").each((_: any, row: any) => { 101 | const $row = $(row); 102 | 103 | // Try to extract province from the row 104 | const provincia = extractProvinceFromRow($row); 105 | if (provincia) { 106 | // If we already had a previous province, save its data 107 | if (currentProvince && currentRows.length > 0) { 108 | provinceTables.push({ 109 | province: currentProvince, 110 | rows: [...currentRows], 111 | }); 112 | } 113 | 114 | // Start new province 115 | currentProvince = provincia; 116 | currentRows = []; 117 | return; 118 | } 119 | 120 | // If it's a reservoir data row, add it to the current province 121 | if (isReservoirDataRow($row) && currentProvince) { 122 | currentRows.push($row); 123 | } 124 | }); 125 | 126 | // Don't forget the last province 127 | if (currentProvince && currentRows.length > 0) { 128 | provinceTables.push({ 129 | province: currentProvince, 130 | rows: [...currentRows], 131 | }); 132 | } 133 | 134 | return provinceTables; 135 | } 136 | 137 | /** 138 | * Processes all reservoir rows from a specific province. 139 | * @param rows - Array of cheerio rows 140 | * @param province - Province name 141 | * @param $ - Cheerio instance 142 | * @returns Array of processed reservoirs 143 | */ 144 | export function reservoirInfoFromTable( 145 | rows: cheerio.Cheerio[], 146 | province: string, 147 | $: cheerio.CheerioAPI 148 | ): EmbalsesAndalucia[] { 149 | const reservoirs: EmbalsesAndalucia[] = []; 150 | 151 | rows.forEach((row) => { 152 | const reservoir = processReservoirRow(row, $, province); 153 | if (reservoir) { 154 | reservoirs.push(reservoir); 155 | } 156 | }); 157 | 158 | return reservoirs; 159 | } 160 | 161 | /** 162 | * Processes a reservoir data row and returns the EmbalsesAndalucia object. 163 | * @param $row - Cheerio element representing a table row 164 | * @param $ - Cheerio instance to process elements 165 | * @param provincia - Current province name 166 | * @returns The parsed reservoir or null if it couldn't be processed 167 | */ 168 | export function processReservoirRow( 169 | $row: cheerio.Cheerio, 170 | $: cheerio.CheerioAPI, 171 | provincia: string 172 | ): EmbalsesAndalucia | null { 173 | const cols = extractTableCellsText($row, $); 174 | return parseReservoirRow(cols, provincia); 175 | } 176 | 177 | /** 178 | * Parses an HTML row from the reservoir table and returns an object with the data. 179 | */ 180 | export function parseReservoirRow( 181 | cols: string[], 182 | provincia: string 183 | ): EmbalsesAndalucia | null { 184 | if (cols.length < 10) return null; 185 | 186 | const [ 187 | id, 188 | embalse, 189 | porcentajeActual, 190 | capacidadTotalHm3, 191 | acumuladoHoyMm, 192 | volumenActualHm3, 193 | acumuladoSemanaAnteriorMm, 194 | volumenSemanaAnteriorHm3, 195 | acumuladoAnioAnteriorMm, 196 | volumenAnioAnteriorHm3, 197 | grafico, 198 | ] = cols; 199 | 200 | return { 201 | id: parseInt(id, 10), 202 | embalse, 203 | provincia, 204 | porcentajeActual: parseEuropeanNumber(porcentajeActual), 205 | capacidadTotalHm3: parseEuropeanNumber(capacidadTotalHm3), 206 | acumuladoHoyMm: parseEuropeanNumber(acumuladoHoyMm), 207 | volumenActualHm3: parseEuropeanNumber(volumenActualHm3), 208 | acumuladoSemanaAnteriorMm: parseEuropeanNumber(acumuladoSemanaAnteriorMm), 209 | volumenSemanaAnteriorHm3: parseEuropeanNumber(volumenSemanaAnteriorHm3), 210 | acumuladoAnioAnteriorMm: parseEuropeanNumber(acumuladoAnioAnteriorMm), 211 | volumenAnioAnteriorHm3: parseEuropeanNumber(volumenAnioAnteriorHm3), 212 | grafico, 213 | }; 214 | } 215 | -------------------------------------------------------------------------------- /front/public/images/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-guadiana/src/api/cuenca.api.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { RequestBody, EmbalsesGuadiana, ApiResponse } from "./cuenca.model"; 3 | 4 | /** 5 | * Gets the HTML content from the Andalusian reservoirs page. 6 | * @param url - The URL to fetch the HTML content from 7 | * @returns Promise that resolves with the page HTML 8 | */ 9 | 10 | const requestBody: RequestBody = { 11 | id: "E", 12 | type: "ficha_redes_control", 13 | }; 14 | 15 | const authJwtToken: string = 16 | "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJoZWFkZXIiOnsidHlwIjoiSldUIiwiYWxnIjoiSFMyNTYifSwicGF5bG9hZCI6eyJlbGVtZW50b3MiOlsyMDAwMDAwLDIzMDAwMDAsMjMxMDAwMCwyMzAwMDAzLDIzMDAwMDEsMjMwMDAwMiwyMjAwMDAwLDIxMDAwMDAsNjAwMDAwLDYwMTAwMCw2MDEwMDIsNjAxMDAzLDYwMTAwNCw2MDEwMDYsNjAxMDAxLDYwMTAwNSw2MDAwMDIsNjAwMDAxLDUwMDAwMCw1MDIwMDAsNTAxMDAwLDUwMzAwMCwxMzAwMDAwLDEzMDAwMDEsMTAwMDAwLDEwMDAwMSwzMDMwMDAsMzAzMDAyLDMwNDAwMCwzMDQwMDIsMzA0MDAxLDMwNDAwNCwzMDMwMDEsMzAzMDA0LDQwMTAwMCw0MDEwMDIsNDAxMDAxLDQwMTAwMyw0MDIwMDAsNDAyMDAyLDQwMzAwMCw0MDMwMDIsNDAzMDAxLDQwMzAwMyw0MDIwMDEsNDAyMDAzLDQwODAwMCw0MDgwMDIsNDA5MDAwLDQwOTAwMiw0MDkwMDEsNDA5MDA2LDQwOTAwMyw0MDgwMDEsNDA4MDAzLDMwMTAwMCwzMDEwMDIsMzAxMDAxLDMwMjEwMCwzMDIxMDIsMzAyMTEwLDMwMjExMSwzMDIxMjAsMzAyMTE0LDMwMjExNiwzMDIxMDEsMzAyMTAzLDMwMTAwMywzMDEwMDcsMzA5MDAwLDMwOTAwNSwzMDkwMDIsMzEwMDAwLDMxMDAwMiwzMTAwMDEsMzEwMDA0LDMwOTAwMSwzMDkwMDMsMzExMDAwLDMxMTAwMiwzMTIwMDAsMzEyMDAyLDMxMjAwMSwzMTIwMDQsMzExMDAxLDMxMTAwMywzMDUwMDAsMzA1MDAyLDMwNjAwMCwzMDYwMDIsMzA2MDAxLDMwNjAwNCwzMDUwMDEsMzA1MDAzLDEyMDAwMDAsMjAyMDAwLDIwMjAwMiwyMDMwMDEsMjAzMDEwLDIwMzAwNSwyMDMwMDYsMjAyMDAxLDIwMjAwNCwyMDIwMDYsMjA0MDAwLDIwNDAwMiwyMDUwMDEsMjA1MDEwLDIwNTAwNSwyMDUwMDYsMjA0MDAxLDIwNDAwNCwyMDQwMDYsMjA2MDAwLDIwNjAwMiwyMDcwMDEsMjA3MDEwLDIwNzAwNSwyMDcwMDYsMjA2MDAxLDIwNjAwNCwyMDYwMDYsMjA4MDAwLDIwODAwMiwyMDkwMDEsMjA5MDEwLDIwOTAwNSwyMDkwMDYsMjA4MDAxLDIwODAwNCwyMDgwMDZdLCJlc3RhY2lvbmVzIjp7IkNSIjpbIkNSMS0wMSIsIkNSMS0wMiIsIkNSMS0wMyIsIkNSMS0wNCIsIkNSMS0wNSIsIkNSMS0wNiIsIkNSMS0wNyIsIkNSMS0wOCIsIkNSMS0wOSIsIkNSMS0xMCIsIkNSMS0xMSIsIkNSMS0xMiIsIkNSMS0xMyIsIkNSMS0xNCIsIkNSMS0xNSIsIkNSMS0xNiIsIkNSMS0xNyIsIkNSMS0xOCIsIkNSMS0xOSIsIkNSMS0yMCIsIkNSMS0yMSIsIkNSMS0yMiIsIkNSMS0yMyIsIkNSMS0yNCIsIkNSMS0yNSIsIkNSMS0yNiIsIkNSMS0yNyIsIkNSMi0wMSIsIkNSMi0wMiIsIkNSMi0wMyIsIkNSMi0wNCIsIkNSMi0wNSIsIkNSMi0wNiIsIkNSMi0wNyIsIkNSMi0wOCIsIkNSMi0wOSIsIkNSMi0xMCIsIkNSMi0xMSIsIkNSMi0xMiIsIkNSMi0xMyIsIkNSMi0xNCIsIkNSMi0xNSIsIkNSMi0xNiIsIkNSMi0xNyIsIkNSMi0xOCIsIkNSMi0xOSIsIkNSMi0yMCIsIkNSMi0yMSIsIkNSMi0yMiIsIkNSMi0yMyIsIkNSMi0yNCIsIkNSMi0yNSIsIkNSMi0yNiIsIkNSMi0yNyIsIkNSMi0yOCIsIkNSMi0yOSIsIkNSMi0zMCIsIkNSMi0zMSIsIkNSMi0zMiIsIkNSMi0zMyIsIkNSMi0zNCIsIkNSMi0zNSIsIkNSMi0zNiIsIkNSMi0zNyIsIkNSMi0zOCIsIkNSMi0zOSIsIkNSMi00MCIsIkNSMi00MSIsIkNSMi00MiIsIkNSMi00MyIsIkNSMi00NCIsIkNSMi00NSIsIkNSMi00NiIsIkNSMi00NyIsIkNSMi00OCIsIkNSMi00OSIsIkNSMi01MCIsIkNSMi01MSIsIkNSMi01MiIsIkNSMi01MyIsIkNSMi01NCIsIkNSMi01NSIsIkNSMy0wMSIsIkUyLTI1Il0sIkUiOlsiRTEtMDEiLCJFMS0wMiIsIkUxLTAzIiwiRTEtMDQiLCJFMS0wNSIsIkUxLTA2IiwiRTEtMDciLCJFMS0wOCIsIkUxLTA5IiwiRTEtMTAiLCJFMi0wMSIsIkUyLTAzIiwiRTItMDQiLCJFMi0wNiIsIkUyLTA3IiwiRTItMDgiLCJFMi0wOSIsIkUyLTEwIiwiRTItMTEiLCJFMi0xMiIsIkUyLTEzIiwiRTItMTQiLCJFMi0xNSIsIkUyLTE2IiwiRTItMTciLCJFMi0xOCIsIkUyLTE5IiwiRTItMjAiLCJFMi0yMSIsIkUyLTIyIiwiRTItMjMiLCJFMi0yNCIsIkUyLTI2IiwiRTItMjciLCJFMi0yOCIsIkUyLTI5IiwiRTItMzAiLCJFMi0zMSIsIkUyLTMyIiwiRTItMzMiLCJFMi0zNCIsIkUzLTAxIiwiRTMtMTAiXSwiRUFBIjpbIkVBQS00MDEiLCJFQUEtNDAyIiwiRUFBLTQwNCIsIkVBQS00MDYiLCJFQUEtNDA3IiwiRUFBLTQwOCIsIkVBQS00MDkiLCJFQUEtNDExIiwiRUFBLTQxMiIsIkVBQS00MTMiLCJFQUEtNDE0IiwiRUFBLTQxNSIsIkVBQS00MTYiLCJFQUEtNDE3IiwiRUFBLTQxOSIsIkVBQS00MjAiLCJFQUEtNDIxIiwiRUFBLTQyMiIsIkVBQS00MjMiLCJFQUEtNDI0IiwiRUFBLTQyNSIsIkVBQS00MjYiLCJFQUEtNDI3IiwiRUFBLTQyOCIsIkVBQS00MjkiLCJFQUEtNDMxIiwiRUFBLTQzMiIsIkVBQS00MzMiLCJFQUEtNDM0Il0sIkVNIjpbIkVNMS0wMSIsIkVNMS0wMiIsIkVNMS0wMyIsIkVNMS0wNCIsIkVNMS0wNSIsIkVNMS0wNiIsIkVNMS0wNyIsIkVNMS0wOCIsIkVNMS0wOSIsIkVNMS0xMCIsIkVNMS0xMSIsIkVNMS0xMiIsIkVNMS0xMyIsIkVNMS0xNCIsIkVNMS0xNSIsIkVNMS0xNiIsIkVNMS0xNyIsIkVNMS0xOCIsIkVNMS0xOSIsIkVNMS0yMCIsIkVNMS0yMSIsIkVNMS0yMiIsIkVNMS0yMyIsIkVNMS0yNCIsIkVNMS0yNSIsIkVNMi0wMSIsIkVNMi0wMiIsIkVNMi0wMyIsIkVNMi0wNCIsIkVNMi0wNSIsIkVNMi0wNiIsIkVNMi0wNyIsIkVNMi0wOCIsIkVNMi0wOSIsIkVNMi0xMCIsIkVNMi0xMSIsIkVNMi0xMiIsIkVNMi0xMyIsIkVNMi0xNCIsIkVNMi0xNSIsIkVNMi0xNiIsIkVNMi0xNyIsIkVNMi0xOCIsIkVNMi0xOSIsIkVNMi0yMCIsIkVNMi0yMSIsIkVNMi0yMiIsIkVNMi0yMyIsIkVNMi0yNCIsIkVNMi0yNSIsIkVNMy0wMSIsIkVNMy0wMiIsIkVNMy0wMyJdLCJFWCI6WyJFWDItNDAiXSwiTlIiOlsiTlIxLTAxIiwiTlIxLTAyIiwiTlIxLTAzIiwiTlIxLTA0IiwiTlIxLTA1IiwiTlIxLTA2IiwiTlIxLTA3IiwiTlIxLTA4IiwiTlIxLTA5IiwiTlIxLTEwIiwiTlIxLTExIiwiTlIxLTEyIiwiTlIxLTEzIiwiTlIxLTE0IiwiTlIxLTE1IiwiTlIyLTEwIiwiTlIyLTExIiwiTlIyLTEyIiwiTlIyLTEzIiwiTlIyLTE0IiwiTlIyLTE1IiwiTlIyLTE2IiwiTlIyLTE3IiwiTlIyLTE4IiwiTlIyLTE5IiwiTlIyLTIwIiwiTlIyLTIxIiwiTlIyLTIyIiwiTlIyLTIzIiwiTlIyLTI0IiwiTlIyLTI1IiwiTlIyLTI2IiwiTlIyLTI3IiwiTlIyLTI4IiwiTlIyLTI5IiwiTlIyLTMwIiwiTlIyLTMxIiwiTlIyLTMyIiwiTlIyLTMzIiwiTlIyLTM0IiwiTlIyLTM1IiwiTlIyLTM2IiwiTlIyLTM3IiwiTlIyLTM4IiwiTlIyLTM5IiwiTlIyLTQwIiwiTlIyLTQxIiwiTlIyLTQyIiwiTlIyLTQzIiwiTlIyLTQ0IiwiTlIyLTQ1IiwiTlIyLTQ2IiwiTlIyLTQ3IiwiTlIyLTQ4IiwiTlIyLTQ5IiwiTlIyLTUwIiwiTlIzLTAxIl0sIlBaIjpbIjA0LjAxLjAwNiIsIjA0LjAxLjIwMiIsIjA0LjAxLjIwMyIsIjA0LjAxLjIwNCIsIjA0LjAxLjIwNyIsIjA0LjAxLjIwOCIsIjA0LjAxLjIwOSIsIjA0LjAxLjIxMCIsIjA0LjAxLjIxMSIsIjA0LjAxLjIxMiIsIjA0LjAxLjIxMyIsIjA0LjAxLjIxNCIsIjA0LjAxLjIxNSIsIjA0LjAxLjIxNiIsIjA0LjAxLjIxNyIsIjA0LjAxLjIxOCIsIjA0LjAxLjIxOSIsIjA0LjAxLjIyMCIsIjA0LjAxLjIyMSIsIjA0LjAyLjIwMSIsIjA0LjAyLjIwMiIsIjA0LjAyLjIwMyIsIjA0LjAyLjIwNCIsIjA0LjAyLjIwNSIsIjA0LjAzLjAwMiIsIjA0LjAzLjIwMSIsIjA0LjAzLjIwMiIsIjA0LjAzLjIwMyIsIjA0LjAzLjIwNCIsIjA0LjAzLjIwNSIsIjA0LjA0LjAwOSIsIjA0LjA0LjAxNCIsIjA0LjA0LjAxNiIsIjA0LjA0LjAyNiIsIjA0LjA0LjA0MCIsIjA0LjA0LjA0NSIsIjA0LjA0LjIwMSIsIjA0LjA0LjIwMiIsIjA0LjA0LjIwMyIsIjA0LjA0LjIwNCIsIjA0LjA0LjIwNiIsIjA0LjA0LjIxMCIsIjA0LjA0LjIxMSIsIjA0LjA0LjIxMyIsIjA0LjA0LjIxNCIsIjA0LjA0LjIxNSIsIjA0LjA0LjIxNyIsIjA0LjA0LjIyMCIsIjA0LjA0LjIyNCIsIjA0LjA0LjIyNyIsIjA0LjA0LjIzMCIsIjA0LjA0LjIzMiIsIjA0LjA0LjIzMyIsIjA0LjA0LjIzNSIsIjA0LjA0LjIzNiIsIjA0LjA0LjIzOEMiLCIwNC4wNC4yMzhQIiwiMDQuMDQuMjQxIiwiMDQuMDQuMjQ0IiwiMDQuMDQuMjQ1IiwiMDQuMDQuMjQ2IiwiMDQuMDQuMjQ3IiwiMDQuMDQuMjQ4IiwiMDQuMDQuMjcyIiwiMDQuMDQuMzIwQyIsIjA0LjA0LjMyMFAiLCIwNC4wNC4zMjJDIiwiMDQuMDQuMzIyUCIsIjA0LjA0LjMzMSIsIjA0LjA1LjIwMSIsIjA0LjA1LjIwNCIsIjA0LjA1LjIwNiIsIjA0LjA1LjIwNyIsIjA0LjA1LjIwOCIsIjA0LjA2LjAwNiIsIjA0LjA2LjIwMSIsIjA0LjA2LjIwNCIsIjA0LjA2LjIwNSIsIjA0LjA2LjIwNiIsIjA0LjA2LjIwNyIsIjA0LjA2LjIwOCIsIjA0LjA2LjIwOSIsIjA0LjA2LjIxMCIsIjA0LjA2LjIzNyIsIjA0LjA2LjIzOCIsIjA0LjA2LjIzOSIsIjA0LjA3LjIwMiIsIjA0LjA3LjIwMyIsIjA0LjA3LjIwNiIsIjA0LjA4LjAwMiIsIjA0LjA4LjAwNSIsIjA0LjA4LjAyNyIsIjA0LjA4LjAyOSIsIjA0LjA5LjAwMSIsIjA0LjA5LjAwMiIsIjA0LjA5LjAwNCIsIjA0LjA5LjAwOCIsIjA0LjEwLjAwMSIsIjA0LjEwLjAwMiIsIjA0LjEwLjAwMyIsIjA0LjEwLjAwNiIsIjA0LjEwLjAwNyIsIjA0LjEwLjIwMSIsIjA0LjEwLjIwMiIsIjA0LjEwLjIwMyIsIjA0LjExLjIwMSIsIjA0LjExLjIwMiIsIjA0LjExLjIwMyIsIjA0Ljk5LjAwNiIsIjA0Ljk5LjAwNyIsIjA0Ljk5LjAwOCIsIjA0Ljk5LjAwOSIsIjA0Ljk5LjAxMCIsIjA0Ljk5LjAxMSIsIjA0Ljk5LjAxMiIsIjA0Ljk5LjAxNCIsIjA0Ljk5LjAxNSIsIjA0Ljk5LjAxNiIsIjA0Ljk5LjAxOSIsIjA0Ljk5LjAyMCIsIkNQMS0wMSIsIkNQMS0wMiIsIkNQMS0wMyIsIkNQMS0wNCIsIkNQMS0wNSIsIkNQMS0wNiIsIkNQMS0wNyIsIkNQMS0wOCIsIkNQMS0wOSIsIkNQMS0xMCJdLCJTIjpbIlMxLTAxIiwiUzEtMDUiLCJTMS0wOCIsIlMxLTA5IiwiUzEtMTAiLCJTMi0wNCIsIlMyLTA3IiwiUzItMDgiLCJTMi0xMiIsIlMyLTE2IiwiUzItMjIiLCJTMi0yNCIsIlMyLTI4IiwiUzItMzMiLCJTMi0zNCJdfSwidmlkZW9zIjpbIkFMQS1jYW0wMSIsIkFMQS1jYW0wNSIsIkFMQS1jYW0yMCIsIkFMQy1jYW0xMCIsIkFaUi1jYW0wNiIsIkFaUi1jYW0wOCIsIkJPUS1jYW0wMiIsIkJVUi1jYW0wMSIsIkJVUi1jYW0xNCIsIkNBQi1jYW0wMiIsIkNBTi1jYW0wMSIsIkNGUi1jYW0xNyIsIkNJSi1jYW0xMiIsIkNJSi1jYW0xMyIsIkNPUi1jYW0wNSIsIkNPUi1jYW0wNiIsIkdBUi1jYW0wNyIsIkdBUi1jYW0xNCIsIkdBUi1jYW0yMCIsIkdBUy1jYW0wNCIsIkhPUi1jYW0wNSIsIkhPUi1jYW0xNCIsIkpBQi1jYW0wMSIsIkpBQi1jYW0wMyIsIk1PTC1jYW0wNyIsIk1PTi1jYW0wMSIsIk1PTi1jYW0wNSIsIk1PTi1jYW0wNyIsIk1PTi1jYW0yNyIsIk1PTi1jYW0yOSIsIk9SRS1jYW0wMSIsIk9SRS1jYW0wNiIsIk9SRS1jYW0xNyIsIlBVVi1jYW0wMSIsIlBVVi1jYW0wNSIsIlBVVi1jYW0wNiIsIlBZQS1jYW0wNSIsIlJVRS1jYW0xNiIsIlNFUi1jYW0yMCIsIlNJQi1jYW0yOSIsIlNJQi1jYW0zMCIsIlRBQi1jYW0wMiIsIlRBQi1jYW0wNCIsIlRBQi1jYW0xMiIsIlRFTi1jYW0wOSIsIlZJQi1jYW0wNCIsIlZJQy1jYW0wMiIsIlZJQy1jYW0wNCIsIlZJTC1jYW0wMiIsIlpVSi1jYW0wMiIsIlpVSi1jYW0wMyJdLCJzZ2kiOjAsInN1YiI6InB1YmxpYyIsImV4cCI6MTg0MzcyNjk5MSwiaWF0IjoxNzU3MzI2OTkxLCJqdGkiOjF9fQ.XEH-AKm1qyLX-h_Q38awg3RCtP6HvWI9okVwgL3HTmQ"; 17 | 18 | export async function getCuencaJSONResponse( 19 | url: string 20 | ): Promise { 21 | const { data } = await axios.post(url, requestBody, { 22 | headers: { 23 | "Content-Type": "application/json", 24 | AUTHJWT: authJwtToken, 25 | }, 26 | }); 27 | return data.ult_res.valores; 28 | } 29 | -------------------------------------------------------------------------------- /integrations/scraping-cuenca-tajo/src/scraper/business.ts: -------------------------------------------------------------------------------- 1 | import { Locator, Page } from "playwright"; 2 | import { VOLUME_TITLES } from "../api"; 3 | import { EmbalseUpdateSAIHEntity } from "db-model"; 4 | 5 | /** 6 | * Locates and expands the subcuenca reservoirs table section. 7 | * @param page - Playwright Page instance 8 | * @param subcuenca - Name of the subcuenca to locate 9 | * @returns Promise resolving to the reservoirs table Locator, or null if not found 10 | */ 11 | export async function getSubcuencaReservoirsTable( 12 | page: Page, 13 | subcuenca: string 14 | ): Promise { 15 | // Navigate to subcuenca table 16 | const table = page.locator( 17 | `ons-list-item:has(span.nombre-acordeon-1:text-is("${subcuenca}"))` 18 | ); 19 | await table.scrollIntoViewIfNeeded(); 20 | 21 | // Verify if subcuenca exists 22 | if (!(await table.isVisible())) { 23 | console.warn(`Subcuenca "${subcuenca}" not found on page`); 24 | return null; 25 | } 26 | 27 | // Expand subcuencas table 28 | const chevron = table.locator("span.list-item__expand-chevron").first(); 29 | if (await chevron.isVisible()) { 30 | await chevron.click(); 31 | } 32 | 33 | // Look for "Embalse" section 34 | const embalseContainer = table.locator( 35 | 'ons-list-item:has(div.center.list-item__center:text-is("Embalse"))' 36 | ); 37 | 38 | // Verify if "Embalse" section for the subcuenca exists 39 | if (!(await embalseContainer.isVisible())) { 40 | console.warn(`No "Embalse" section found for subcuenca "${subcuenca}"`); 41 | return null; 42 | } 43 | 44 | // Expand "Embalse" section 45 | const embalseChevron = embalseContainer.locator( 46 | "span.list-item__expand-chevron" 47 | ); 48 | 49 | if (await embalseChevron.isVisible()) { 50 | await embalseChevron.click(); 51 | } 52 | 53 | // Extract table 54 | const reservoirsTable = embalseContainer.locator( 55 | "div.expandable-content.list-item__expandable-content" 56 | ); 57 | 58 | return reservoirsTable; 59 | } 60 | 61 | /** 62 | * Extracts all visible reservoir elements from a subcuenca table. 63 | * @param table - Playwright Locator for the subcuenca table 64 | * @returns Promise resolving to array of reservoir element Locators 65 | */ 66 | async function getReservoirsElements(table: Locator): Promise { 67 | if (!table) return []; 68 | const reservoirsElements = table.locator("div.center.list-item__center"); 69 | 70 | const reservoirCount = await reservoirsElements.count(); 71 | const reservoirSelectors: Locator[] = []; 72 | 73 | for (let i = 0; i < reservoirCount; i++) { 74 | const reservoirElement = reservoirsElements.nth(i); 75 | 76 | if (await reservoirElement.isVisible()) { 77 | reservoirSelectors.push(reservoirElement); 78 | } 79 | } 80 | 81 | return reservoirSelectors; 82 | } 83 | 84 | /** 85 | * Opens a reservoir dialog by clicking on the element and waits for it to be visible. 86 | * @param reservoirElement - Playwright Locator for the reservoir element to click 87 | * @returns Promise resolving to the opened dialog Locator, or null if dialog fails to open 88 | */ 89 | async function getReservoirDialog( 90 | reservoirElement: Locator 91 | ): Promise { 92 | try { 93 | await reservoirElement.click(); 94 | 95 | const page = reservoirElement.page(); 96 | await page.waitForSelector("ons-dialog#dialog-estacion", { 97 | state: "visible", 98 | timeout: 10000, 99 | }); 100 | 101 | return page.locator("ons-dialog#dialog-estacion"); 102 | } catch (error) { 103 | console.warn("Dialog failed to open within timeout"); 104 | return null; 105 | } 106 | } 107 | 108 | /** 109 | * Closes a reservoir dialog by clicking the close button and waiting for it to hide. 110 | * @param dialog - Playwright Locator for the reservoir dialog to close 111 | * @returns Promise that resolves when dialog is closed 112 | */ 113 | async function closeDialog(dialog: Locator): Promise { 114 | // Search for dialog closing button 115 | const closeButton = dialog.locator("ons-button#cerrar-dialog-estacion"); 116 | 117 | if (await closeButton.isVisible()) { 118 | await closeButton.click(); 119 | await dialog.waitFor({ state: "hidden", timeout: 5000 }); 120 | } 121 | } 122 | 123 | /** 124 | * Extracts reservoir ID and name from the dialog header. 125 | * Parses text format "E_35 - NAVAMUÑO" to extract numeric ID and reservoir name. 126 | * @param dialog - Playwright Locator for the reservoir dialog 127 | * @returns Promise resolving to object with numeric id and embalse name 128 | * @example 129 | * // Input: "E_35 - NAVAMUÑO" 130 | * // Output: { id: 35, embalse: "NAVAMUÑO" } 131 | */ 132 | async function getReservoirIdAndEmbalse( 133 | dialog: Locator 134 | ): Promise<{ id: number; embalse: string }> { 135 | const reservoirInfo = dialog.locator("div.titulo-tarjeta-estacion"); 136 | const fullText = await reservoirInfo.innerText(); 137 | 138 | const parts = fullText.split(" - "); 139 | 140 | const beforeDash = parts[0].trim(); 141 | const digits = beforeDash.slice(-2); 142 | const id = Number(digits); 143 | const embalse = parts[1].trim(); 144 | 145 | return { id, embalse }; 146 | } 147 | 148 | /** 149 | * Searches for a volume property element in the dialog using known volume titles (available in cuenca.model) 150 | * Iterates through VOLUME_TITLES array to find the first visible volume property. 151 | * @param dialog - Playwright Locator for the reservoir dialog 152 | * @returns Promise resolving to the volume property Locator, or null if none found 153 | */ 154 | async function findVolumeProperty(dialog: Locator): Promise { 155 | for (const title of VOLUME_TITLES) { 156 | const volumeProperty = dialog.locator( 157 | `ons-list-item:has(span.titulo:text-is("${title}"))` 158 | ); 159 | 160 | if (await volumeProperty.isVisible()) { 161 | return volumeProperty; 162 | } 163 | } 164 | console.warn("No volume property found with any known title"); 165 | return null; 166 | } 167 | 168 | /** 169 | * Locates the highlighted (destacada) last value element within a volume property. 170 | * Searches for the element that contains the most recent volume measurement data. 171 | * @param volumeProperty - Playwright Locator for the volume property section 172 | * @returns Promise resolving to the last value element Locator, or null if not visible 173 | */ 174 | async function getLastValueFromProperty( 175 | volumeProperty: Locator 176 | ): Promise { 177 | const lastValueElement = volumeProperty.locator( 178 | "div.dato-metrica-estacion.destacada" 179 | ); 180 | 181 | if (await lastValueElement.isVisible()) { 182 | return lastValueElement; 183 | } 184 | return null; 185 | } 186 | 187 | /** 188 | * Extracts and parses volume value elements into structured data. 189 | * Separates the label text and text without label for processing. 190 | * @param volumeValueElement - Playwright Locator for the volume value container 191 | * @returns Promise resolving to object with labelText and textWithoutLabel 192 | * @example 193 | * // Input element text: "Último: 10/09/2025 10:00 45.2 hm³" 194 | * // Output: { 195 | * // labelText: "Último: 10/09/2025 10:00", 196 | * // textWithoutLabel: " 45.2 hm³" 197 | * // } 198 | */ 199 | async function getVolumeValueElements(volumeValueElement: Locator): Promise<{ 200 | labelText: string; 201 | textWithoutLabel: string; 202 | }> { 203 | const fullText = await volumeValueElement.innerText(); 204 | const labelElement = volumeValueElement.locator("span.label"); 205 | const labelText = await labelElement.innerText(); 206 | 207 | return { 208 | labelText, 209 | textWithoutLabel: fullText.replace(labelText, ""), 210 | }; 211 | } 212 | 213 | /** 214 | * Extracts the current date from volume value element label text. 215 | * Uses regex pattern to find date in dd/mm/yyyy format within the label. 216 | * @param volumeValueElement - Playwright Locator for the volume value container 217 | * @returns Promise resolving to date string in dd/mm/yyyy format, or null if not found 218 | * @example 219 | * // Input label: "Último: 10/09/2025 10:00" 220 | * // Output: "10/09/2025" 221 | */ 222 | async function extractCurrentDate( 223 | volumeValueElement: Locator 224 | ): Promise { 225 | const { labelText } = await getVolumeValueElements(volumeValueElement); 226 | const dateRegex = /\d{1,2}\/\d{1,2}\/\d{4}/; 227 | const match = labelText.match(dateRegex); 228 | 229 | return match ? match[0] : null; 230 | } 231 | 232 | /** 233 | * Extracts and parses the current volume value from the element text. 234 | * Filters out invalid values like empty strings, "n/d", and non-finite numbers. 235 | * @param volumeValueElement - Playwright Locator for the volume value container 236 | * @returns Promise resolving to volume as number, NaN for invalid data, or null for parsing errors 237 | * @example 238 | * // Input text: " 45.2 hm³" 239 | * // Output: 45.2 240 | * 241 | * // Input text: " n/d" 242 | * // Output: NaN 243 | */ 244 | async function extractCurrentVolume( 245 | volumeValueElement: Locator 246 | ): Promise { 247 | const { textWithoutLabel } = await getVolumeValueElements(volumeValueElement); 248 | 249 | if ( 250 | !textWithoutLabel || 251 | textWithoutLabel.trim() === "" || 252 | textWithoutLabel === "n/d" 253 | ) { 254 | return NaN; 255 | } 256 | 257 | const volume = parseFloat(textWithoutLabel); 258 | 259 | return Number.isFinite(volume) ? volume : null; 260 | } 261 | 262 | /** 263 | * Extracts reservoir information from a dialog and maps it to the domain model. 264 | * @param dialog - Playwright Locator for the reservoir dialog 265 | * @returns Promise resolving to EmbalseUpdateSAIHEntity 266 | */ 267 | async function scrapeEmbalseTajo( 268 | dialog: Locator 269 | ): Promise { 270 | // Get id and embalse name 271 | const { id, embalse } = await getReservoirIdAndEmbalse(dialog); 272 | 273 | // Get embalse volume 274 | const volumeProperty = await findVolumeProperty(dialog); 275 | 276 | if (!volumeProperty) { 277 | console.warn(`Skipping ${embalse}: No volume data available`); 278 | return null; 279 | } 280 | 281 | const volumeValue = await getLastValueFromProperty(volumeProperty); 282 | if (!volumeValue) { 283 | return null; 284 | } 285 | 286 | const volume = await extractCurrentVolume(volumeValue); 287 | 288 | const currentDate = await extractCurrentDate(volumeValue); 289 | 290 | // Get EmbalseUpdateSAIHEntity model 291 | const embalseData: EmbalseUpdateSAIHEntity = { 292 | id: id, 293 | nombre: embalse, 294 | aguaActualSAIH: volume, 295 | fechaMedidaSAIH: currentDate, 296 | }; 297 | 298 | return embalseData; 299 | } 300 | 301 | /** 302 | * Processes all reservoirs from a subcuenca table. 303 | * Flow: 304 | * - Get subcuenca table 305 | * - Extract reservoir elements 306 | * - Open dialog for each reservoir 307 | * - Scrape reservoir data 308 | * - Close dialog 309 | * @param page - Playwright Page instance 310 | * @param subcuenca - Name of the subcuenca to process 311 | * @returns Promise resolving to array of EmbalseUpdateSAIHEntity 312 | */ 313 | export async function reservoirInfoFromTable( 314 | page: Page, 315 | subcuenca: string 316 | ): Promise { 317 | const reservoirs: EmbalseUpdateSAIHEntity[] = []; 318 | 319 | try { 320 | const table = await getSubcuencaReservoirsTable(page, subcuenca); 321 | 322 | const reservoirsElements = await getReservoirsElements(table); 323 | 324 | for (const reservoirElement of reservoirsElements) { 325 | try { 326 | const dialog = await getReservoirDialog(reservoirElement); 327 | const data = await scrapeEmbalseTajo(dialog); 328 | 329 | if (data) { 330 | reservoirs.push(data); 331 | } 332 | 333 | await closeDialog(dialog); 334 | } catch (error) { 335 | console.error("Error processing reservoir:", error); 336 | } 337 | } 338 | } catch (error) { 339 | console.error(`Error processing subcuenca ${subcuenca}:`, error); 340 | } 341 | return reservoirs; 342 | } 343 | -------------------------------------------------------------------------------- /functions/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "functions", 3 | "version": "1.0.0", 4 | "lockfileVersion": 3, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "functions", 9 | "version": "1.0.0", 10 | "dependencies": { 11 | "@azure/functions": "^4.0.0" 12 | }, 13 | "devDependencies": { 14 | "@types/node": "^20.x", 15 | "rimraf": "^5.0.0", 16 | "typescript": "^4.0.0" 17 | } 18 | }, 19 | "node_modules/@azure/functions": { 20 | "version": "4.8.0", 21 | "resolved": "https://registry.npmjs.org/@azure/functions/-/functions-4.8.0.tgz", 22 | "integrity": "sha512-LNtl3xZNE40vE7+SIST+GYQX5cnnI1M65fXPi26l9XCdPakuQrz54lHv+qQQt1GG5JbqLfQk75iM7A6Y9O+2dQ==", 23 | "license": "MIT", 24 | "dependencies": { 25 | "cookie": "^0.7.0", 26 | "long": "^4.0.0", 27 | "undici": "^5.29.0" 28 | }, 29 | "engines": { 30 | "node": ">=18.0" 31 | } 32 | }, 33 | "node_modules/@fastify/busboy": { 34 | "version": "2.1.1", 35 | "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", 36 | "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", 37 | "license": "MIT", 38 | "engines": { 39 | "node": ">=14" 40 | } 41 | }, 42 | "node_modules/@isaacs/cliui": { 43 | "version": "8.0.2", 44 | "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", 45 | "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", 46 | "dev": true, 47 | "license": "ISC", 48 | "dependencies": { 49 | "string-width": "^5.1.2", 50 | "string-width-cjs": "npm:string-width@^4.2.0", 51 | "strip-ansi": "^7.0.1", 52 | "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", 53 | "wrap-ansi": "^8.1.0", 54 | "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" 55 | }, 56 | "engines": { 57 | "node": ">=12" 58 | } 59 | }, 60 | "node_modules/@pkgjs/parseargs": { 61 | "version": "0.11.0", 62 | "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", 63 | "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", 64 | "dev": true, 65 | "license": "MIT", 66 | "optional": true, 67 | "engines": { 68 | "node": ">=14" 69 | } 70 | }, 71 | "node_modules/@types/node": { 72 | "version": "20.19.19", 73 | "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.19.tgz", 74 | "integrity": "sha512-pb1Uqj5WJP7wrcbLU7Ru4QtA0+3kAXrkutGiD26wUKzSMgNNaPARTUDQmElUXp64kh3cWdou3Q0C7qwwxqSFmg==", 75 | "dev": true, 76 | "license": "MIT", 77 | "dependencies": { 78 | "undici-types": "~6.21.0" 79 | } 80 | }, 81 | "node_modules/ansi-regex": { 82 | "version": "6.2.2", 83 | "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", 84 | "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", 85 | "dev": true, 86 | "license": "MIT", 87 | "engines": { 88 | "node": ">=12" 89 | }, 90 | "funding": { 91 | "url": "https://github.com/chalk/ansi-regex?sponsor=1" 92 | } 93 | }, 94 | "node_modules/ansi-styles": { 95 | "version": "6.2.3", 96 | "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", 97 | "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", 98 | "dev": true, 99 | "license": "MIT", 100 | "engines": { 101 | "node": ">=12" 102 | }, 103 | "funding": { 104 | "url": "https://github.com/chalk/ansi-styles?sponsor=1" 105 | } 106 | }, 107 | "node_modules/balanced-match": { 108 | "version": "1.0.2", 109 | "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", 110 | "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", 111 | "dev": true, 112 | "license": "MIT" 113 | }, 114 | "node_modules/brace-expansion": { 115 | "version": "2.0.2", 116 | "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", 117 | "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", 118 | "dev": true, 119 | "license": "MIT", 120 | "dependencies": { 121 | "balanced-match": "^1.0.0" 122 | } 123 | }, 124 | "node_modules/color-convert": { 125 | "version": "2.0.1", 126 | "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", 127 | "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", 128 | "dev": true, 129 | "license": "MIT", 130 | "dependencies": { 131 | "color-name": "~1.1.4" 132 | }, 133 | "engines": { 134 | "node": ">=7.0.0" 135 | } 136 | }, 137 | "node_modules/color-name": { 138 | "version": "1.1.4", 139 | "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", 140 | "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", 141 | "dev": true, 142 | "license": "MIT" 143 | }, 144 | "node_modules/cookie": { 145 | "version": "0.7.2", 146 | "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", 147 | "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", 148 | "license": "MIT", 149 | "engines": { 150 | "node": ">= 0.6" 151 | } 152 | }, 153 | "node_modules/cross-spawn": { 154 | "version": "7.0.6", 155 | "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", 156 | "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", 157 | "dev": true, 158 | "license": "MIT", 159 | "dependencies": { 160 | "path-key": "^3.1.0", 161 | "shebang-command": "^2.0.0", 162 | "which": "^2.0.1" 163 | }, 164 | "engines": { 165 | "node": ">= 8" 166 | } 167 | }, 168 | "node_modules/eastasianwidth": { 169 | "version": "0.2.0", 170 | "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", 171 | "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", 172 | "dev": true, 173 | "license": "MIT" 174 | }, 175 | "node_modules/emoji-regex": { 176 | "version": "9.2.2", 177 | "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", 178 | "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", 179 | "dev": true, 180 | "license": "MIT" 181 | }, 182 | "node_modules/foreground-child": { 183 | "version": "3.3.1", 184 | "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", 185 | "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", 186 | "dev": true, 187 | "license": "ISC", 188 | "dependencies": { 189 | "cross-spawn": "^7.0.6", 190 | "signal-exit": "^4.0.1" 191 | }, 192 | "engines": { 193 | "node": ">=14" 194 | }, 195 | "funding": { 196 | "url": "https://github.com/sponsors/isaacs" 197 | } 198 | }, 199 | "node_modules/glob": { 200 | "version": "10.4.5", 201 | "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", 202 | "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", 203 | "dev": true, 204 | "license": "ISC", 205 | "dependencies": { 206 | "foreground-child": "^3.1.0", 207 | "jackspeak": "^3.1.2", 208 | "minimatch": "^9.0.4", 209 | "minipass": "^7.1.2", 210 | "package-json-from-dist": "^1.0.0", 211 | "path-scurry": "^1.11.1" 212 | }, 213 | "bin": { 214 | "glob": "dist/esm/bin.mjs" 215 | }, 216 | "funding": { 217 | "url": "https://github.com/sponsors/isaacs" 218 | } 219 | }, 220 | "node_modules/is-fullwidth-code-point": { 221 | "version": "3.0.0", 222 | "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", 223 | "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", 224 | "dev": true, 225 | "license": "MIT", 226 | "engines": { 227 | "node": ">=8" 228 | } 229 | }, 230 | "node_modules/isexe": { 231 | "version": "2.0.0", 232 | "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", 233 | "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", 234 | "dev": true, 235 | "license": "ISC" 236 | }, 237 | "node_modules/jackspeak": { 238 | "version": "3.4.3", 239 | "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", 240 | "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", 241 | "dev": true, 242 | "license": "BlueOak-1.0.0", 243 | "dependencies": { 244 | "@isaacs/cliui": "^8.0.2" 245 | }, 246 | "funding": { 247 | "url": "https://github.com/sponsors/isaacs" 248 | }, 249 | "optionalDependencies": { 250 | "@pkgjs/parseargs": "^0.11.0" 251 | } 252 | }, 253 | "node_modules/long": { 254 | "version": "4.0.0", 255 | "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", 256 | "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==", 257 | "license": "Apache-2.0" 258 | }, 259 | "node_modules/lru-cache": { 260 | "version": "10.4.3", 261 | "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", 262 | "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", 263 | "dev": true, 264 | "license": "ISC" 265 | }, 266 | "node_modules/minimatch": { 267 | "version": "9.0.5", 268 | "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", 269 | "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", 270 | "dev": true, 271 | "license": "ISC", 272 | "dependencies": { 273 | "brace-expansion": "^2.0.1" 274 | }, 275 | "engines": { 276 | "node": ">=16 || 14 >=14.17" 277 | }, 278 | "funding": { 279 | "url": "https://github.com/sponsors/isaacs" 280 | } 281 | }, 282 | "node_modules/minipass": { 283 | "version": "7.1.2", 284 | "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", 285 | "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", 286 | "dev": true, 287 | "license": "ISC", 288 | "engines": { 289 | "node": ">=16 || 14 >=14.17" 290 | } 291 | }, 292 | "node_modules/package-json-from-dist": { 293 | "version": "1.0.1", 294 | "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", 295 | "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", 296 | "dev": true, 297 | "license": "BlueOak-1.0.0" 298 | }, 299 | "node_modules/path-key": { 300 | "version": "3.1.1", 301 | "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", 302 | "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", 303 | "dev": true, 304 | "license": "MIT", 305 | "engines": { 306 | "node": ">=8" 307 | } 308 | }, 309 | "node_modules/path-scurry": { 310 | "version": "1.11.1", 311 | "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", 312 | "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", 313 | "dev": true, 314 | "license": "BlueOak-1.0.0", 315 | "dependencies": { 316 | "lru-cache": "^10.2.0", 317 | "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" 318 | }, 319 | "engines": { 320 | "node": ">=16 || 14 >=14.18" 321 | }, 322 | "funding": { 323 | "url": "https://github.com/sponsors/isaacs" 324 | } 325 | }, 326 | "node_modules/rimraf": { 327 | "version": "5.0.10", 328 | "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", 329 | "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", 330 | "dev": true, 331 | "license": "ISC", 332 | "dependencies": { 333 | "glob": "^10.3.7" 334 | }, 335 | "bin": { 336 | "rimraf": "dist/esm/bin.mjs" 337 | }, 338 | "funding": { 339 | "url": "https://github.com/sponsors/isaacs" 340 | } 341 | }, 342 | "node_modules/shebang-command": { 343 | "version": "2.0.0", 344 | "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", 345 | "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", 346 | "dev": true, 347 | "license": "MIT", 348 | "dependencies": { 349 | "shebang-regex": "^3.0.0" 350 | }, 351 | "engines": { 352 | "node": ">=8" 353 | } 354 | }, 355 | "node_modules/shebang-regex": { 356 | "version": "3.0.0", 357 | "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", 358 | "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", 359 | "dev": true, 360 | "license": "MIT", 361 | "engines": { 362 | "node": ">=8" 363 | } 364 | }, 365 | "node_modules/signal-exit": { 366 | "version": "4.1.0", 367 | "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", 368 | "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", 369 | "dev": true, 370 | "license": "ISC", 371 | "engines": { 372 | "node": ">=14" 373 | }, 374 | "funding": { 375 | "url": "https://github.com/sponsors/isaacs" 376 | } 377 | }, 378 | "node_modules/string-width": { 379 | "version": "5.1.2", 380 | "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", 381 | "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", 382 | "dev": true, 383 | "license": "MIT", 384 | "dependencies": { 385 | "eastasianwidth": "^0.2.0", 386 | "emoji-regex": "^9.2.2", 387 | "strip-ansi": "^7.0.1" 388 | }, 389 | "engines": { 390 | "node": ">=12" 391 | }, 392 | "funding": { 393 | "url": "https://github.com/sponsors/sindresorhus" 394 | } 395 | }, 396 | "node_modules/string-width-cjs": { 397 | "name": "string-width", 398 | "version": "4.2.3", 399 | "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", 400 | "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", 401 | "dev": true, 402 | "license": "MIT", 403 | "dependencies": { 404 | "emoji-regex": "^8.0.0", 405 | "is-fullwidth-code-point": "^3.0.0", 406 | "strip-ansi": "^6.0.1" 407 | }, 408 | "engines": { 409 | "node": ">=8" 410 | } 411 | }, 412 | "node_modules/string-width-cjs/node_modules/ansi-regex": { 413 | "version": "5.0.1", 414 | "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", 415 | "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", 416 | "dev": true, 417 | "license": "MIT", 418 | "engines": { 419 | "node": ">=8" 420 | } 421 | }, 422 | "node_modules/string-width-cjs/node_modules/emoji-regex": { 423 | "version": "8.0.0", 424 | "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", 425 | "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", 426 | "dev": true, 427 | "license": "MIT" 428 | }, 429 | "node_modules/string-width-cjs/node_modules/strip-ansi": { 430 | "version": "6.0.1", 431 | "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", 432 | "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", 433 | "dev": true, 434 | "license": "MIT", 435 | "dependencies": { 436 | "ansi-regex": "^5.0.1" 437 | }, 438 | "engines": { 439 | "node": ">=8" 440 | } 441 | }, 442 | "node_modules/strip-ansi": { 443 | "version": "7.1.2", 444 | "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", 445 | "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", 446 | "dev": true, 447 | "license": "MIT", 448 | "dependencies": { 449 | "ansi-regex": "^6.0.1" 450 | }, 451 | "engines": { 452 | "node": ">=12" 453 | }, 454 | "funding": { 455 | "url": "https://github.com/chalk/strip-ansi?sponsor=1" 456 | } 457 | }, 458 | "node_modules/strip-ansi-cjs": { 459 | "name": "strip-ansi", 460 | "version": "6.0.1", 461 | "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", 462 | "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", 463 | "dev": true, 464 | "license": "MIT", 465 | "dependencies": { 466 | "ansi-regex": "^5.0.1" 467 | }, 468 | "engines": { 469 | "node": ">=8" 470 | } 471 | }, 472 | "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { 473 | "version": "5.0.1", 474 | "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", 475 | "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", 476 | "dev": true, 477 | "license": "MIT", 478 | "engines": { 479 | "node": ">=8" 480 | } 481 | }, 482 | "node_modules/typescript": { 483 | "version": "4.9.5", 484 | "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", 485 | "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", 486 | "dev": true, 487 | "license": "Apache-2.0", 488 | "bin": { 489 | "tsc": "bin/tsc", 490 | "tsserver": "bin/tsserver" 491 | }, 492 | "engines": { 493 | "node": ">=4.2.0" 494 | } 495 | }, 496 | "node_modules/undici": { 497 | "version": "5.29.0", 498 | "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", 499 | "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", 500 | "license": "MIT", 501 | "dependencies": { 502 | "@fastify/busboy": "^2.0.0" 503 | }, 504 | "engines": { 505 | "node": ">=14.0" 506 | } 507 | }, 508 | "node_modules/undici-types": { 509 | "version": "6.21.0", 510 | "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", 511 | "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", 512 | "dev": true, 513 | "license": "MIT" 514 | }, 515 | "node_modules/which": { 516 | "version": "2.0.2", 517 | "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", 518 | "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", 519 | "dev": true, 520 | "license": "ISC", 521 | "dependencies": { 522 | "isexe": "^2.0.0" 523 | }, 524 | "bin": { 525 | "node-which": "bin/node-which" 526 | }, 527 | "engines": { 528 | "node": ">= 8" 529 | } 530 | }, 531 | "node_modules/wrap-ansi": { 532 | "version": "8.1.0", 533 | "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", 534 | "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", 535 | "dev": true, 536 | "license": "MIT", 537 | "dependencies": { 538 | "ansi-styles": "^6.1.0", 539 | "string-width": "^5.0.1", 540 | "strip-ansi": "^7.0.1" 541 | }, 542 | "engines": { 543 | "node": ">=12" 544 | }, 545 | "funding": { 546 | "url": "https://github.com/chalk/wrap-ansi?sponsor=1" 547 | } 548 | }, 549 | "node_modules/wrap-ansi-cjs": { 550 | "name": "wrap-ansi", 551 | "version": "7.0.0", 552 | "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", 553 | "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", 554 | "dev": true, 555 | "license": "MIT", 556 | "dependencies": { 557 | "ansi-styles": "^4.0.0", 558 | "string-width": "^4.1.0", 559 | "strip-ansi": "^6.0.0" 560 | }, 561 | "engines": { 562 | "node": ">=10" 563 | }, 564 | "funding": { 565 | "url": "https://github.com/chalk/wrap-ansi?sponsor=1" 566 | } 567 | }, 568 | "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { 569 | "version": "5.0.1", 570 | "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", 571 | "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", 572 | "dev": true, 573 | "license": "MIT", 574 | "engines": { 575 | "node": ">=8" 576 | } 577 | }, 578 | "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { 579 | "version": "4.3.0", 580 | "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", 581 | "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", 582 | "dev": true, 583 | "license": "MIT", 584 | "dependencies": { 585 | "color-convert": "^2.0.1" 586 | }, 587 | "engines": { 588 | "node": ">=8" 589 | }, 590 | "funding": { 591 | "url": "https://github.com/chalk/ansi-styles?sponsor=1" 592 | } 593 | }, 594 | "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { 595 | "version": "8.0.0", 596 | "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", 597 | "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", 598 | "dev": true, 599 | "license": "MIT" 600 | }, 601 | "node_modules/wrap-ansi-cjs/node_modules/string-width": { 602 | "version": "4.2.3", 603 | "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", 604 | "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", 605 | "dev": true, 606 | "license": "MIT", 607 | "dependencies": { 608 | "emoji-regex": "^8.0.0", 609 | "is-fullwidth-code-point": "^3.0.0", 610 | "strip-ansi": "^6.0.1" 611 | }, 612 | "engines": { 613 | "node": ">=8" 614 | } 615 | }, 616 | "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { 617 | "version": "6.0.1", 618 | "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", 619 | "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", 620 | "dev": true, 621 | "license": "MIT", 622 | "dependencies": { 623 | "ansi-regex": "^5.0.1" 624 | }, 625 | "engines": { 626 | "node": ">=8" 627 | } 628 | } 629 | } 630 | } 631 | --------------------------------------------------------------------------------