Ultimo refactor, espero

This commit is contained in:
Nixietab 2025-12-03 07:11:48 -03:00
parent 964f6da880
commit 7be72d03e1
11 changed files with 234 additions and 275 deletions

89
.env.example Normal file
View File

@ -0,0 +1,89 @@
# Copia este archivo a .env y configura tus ajustes
# ============================================
# Configuración del Servidor
# ============================================
# Puerto en el que escuchará el servidor
# Por defecto: 6942
PORT=6942
# Token de autenticación para proteger la API
# Si está vacío, la autenticación estará deshabilitada
# Los clientes deben enviar este valor en el header "Authorization"
PASSWORD=clave_ultra_segura
# Ruta base de la API (sin barra final)
# Todas las rutas de la API comenzarán con este prefijo
# Ejemplo: /smn/v1/weather/location/10821
BASE_PATH=/smn
# ============================================
# Configuración de la API del SMN
# ============================================
# URL base de la API del Servicio Meteorológico Nacional
# Esta es la URL upstream a la que se reenviarán las peticiones
# Se declara por un motivo de mantener el proyecto en un sistema orientado a objetos, pero no debe cambiarse
BASE_API=https://ws1.smn.gob.ar
# URL del sitio web del SMN para extraer el token de autenticación
# Se usa con Selenium para obtener el token JWT automáticamente
# Se declara por un motivo de mantener el proyecto en un sistema orientado a objetos, pero no debe cambiarse
SMN_URL=https://www.smn.gob.ar/
# ============================================
# Configuración de Caché
# ============================================
# Directorio donde se almacenarán los archivos de caché
# Se creará automáticamente si no existe
CACHE_DIR=cache
# Tiempo de vida del caché en minutos
# Las respuestas cacheadas se considerarán válidas durante este tiempo
# Después de este período, se realizará una nueva petición al SMN
CACHE_TTL_MINUTES=60
# ============================================
# Configuración del Token
# ============================================
# Archivo donde se guardará el token JWT del SMN
# Este token se renueva automáticamente cuando expira
SMN_TOKEN_FILE=token
# ============================================
# Configuración de Logging
# ============================================
# Ruta al archivo de logs (opcional)
# Si está vacío, los logs solo se mostrarán en la consola
# Ejemplo: LOG_FILE=logs/opensmn.log
LOG_FILE=
# ============================================
# Configuración de Selenium
# ============================================
# Ejecutar Selenium en modo headless (sin interfaz gráfica)
# Valores: true o false
# Recomendado: true para servidores sin entorno gráfico, realmente solo se utilizaria false en desarollo
SELENIUM_HEADLESS=true
# Segundos de espera para que la página del SMN cargue completamente
# Aumenta este valor si tenes una conexión lenta
SELENIUM_WAIT_SECONDS=8
# ============================================
# Configuración de Uvicorn (Servidor ASGI)
# ============================================
# Cantidad de workers para el servidor
# Se recomienda mantener en 1 para evitar problemas de sincronización, por ahora no esta preparado para escalar verticalmente
WORKERS=1
# Dirección IP en la que escuchará el servidor
# 0.0.0.0 = escucha en todas las interfaces de red
# 127.0.0.1 = solo escucha en localhost (conexiones locales)
HOST=0.0.0.0

4
.gitignore vendored
View File

@ -1,6 +1,4 @@
__pycache__ __pycache__
cache cache
config.ini
token token
opensmn.egg-info .env
build

103
README.md
View File

@ -1,35 +1,78 @@
# OpenSMN # OpenSMN
OpenSMN es una API opensource que actúa como proxy del Servicio Meteorológico Nacional (SMN) de Argentina, ofreciendo una forma sencilla, y privada de acceder a sus datos públicos. OpenSMN es una API opensource que actúa como proxy del Servicio Meteorológico Nacional (SMN) de Argentina, ofreciendo una forma sencilla y privada de acceder a sus datos públicos.
## Setup ## Requisitos
Clona la repo - Python 3.7+
~~~
git clone https://github.com/nixietab/OpenSMN
cd OpenSMN
~~~
Instala los requerimientos
~~~
pip install -r requirements.txt
~~~
y ejecuta el servicio!
~~~
python3 main.py
~~~
Despues de la primera ejecucion se va a crear un config.ini
Una vez se tenga configurado y corriendo es altamente recomendado correrlo en un reverse proxy como nginx
## Requierimientos
- Python 3
- ChromeDriver - ChromeDriver
## Instalación
1. Clona el repositorio:
```bash
git clone https://github.com/nixietab/OpenSMN
cd OpenSMN
```
2. Instala las dependencias:
```bash
pip install -r requirements.txt
```
3. Configura las variables de entorno:
```bash
cp .env.example .env
```
Edita el archivo `.env` con tu configuración, puedes ver todas las variables en el archivo `.env.example`.
## Uso
### Desarrollo
Para ejecutar el servidor en modo desarrollo:
```bash
uvicorn server:app --reload --port 6942
```
### Producción
Para ejecutar el servidor en producción, usa el script `start.sh`:
```bash
./start.sh
```
El script carga automáticamente las variables de entorno desde `.env` y ejecuta la api.
## Autenticación
Si configuras un `PASSWORD` en tu `.env`, todas las peticiones deben incluir el header:
```
Authorization: clave_ultra_segura
```
## Despliegue con Reverse Proxy
Para producción, es altamente recomendado usar un reverse proxy como nginx:
```nginx
location /smn/ {
proxy_pass http://localhost:6942/smn/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
```
## Licencia
El proyecto de openSMN es de código abierto y está licenciado bajo la licencia MIT. no obstante, interacciona con el Servicio Meteorológico Nacional (SMN) de Argentina, servicio de codigo privativo.
No hay asociacion con este proyecto con el SMN de ninguna manera

View File

View File

@ -1,42 +0,0 @@
import os
import configparser
CONFIG_FILE = "config.ini"
DEFAULT_CONFIG = {
"server": {
"port": "6942",
"password": "debug",
"cache_dir": "cache",
"base_api": "https://ws1.smn.gob.ar",
"log_file": "",
"base_path": "/smn"
}
}
def ensure_config_exists():
config = configparser.ConfigParser()
if not os.path.exists(CONFIG_FILE):
print("[CONFIG] config.ini not found - creating with default values")
config.read_dict(DEFAULT_CONFIG)
with open(CONFIG_FILE, "w") as f:
config.write(f)
else:
config.read(CONFIG_FILE)
changed = False
for section, values in DEFAULT_CONFIG.items():
if section not in config:
config[section] = values
changed = True
else:
for key, val in values.items():
if key not in config[section]:
config[section][key] = val
changed = True
if changed:
with open(CONFIG_FILE, "w") as f:
config.write(f)
print("[CONFIG] Missing keys added to config.ini")
return config

103
main.py
View File

@ -1,103 +0,0 @@
import argparse
import os
import sys
import json
from configparser import ConfigParser
from healthcheck import ensure_config_exists
def configparser_to_dict(cp: ConfigParser) -> dict:
d = {}
for section in cp.sections():
d[section] = {}
for key, val in cp.items(section):
d[section][key] = val
return d
def load_config(config_path=None):
if config_path:
if not os.path.exists(config_path):
print(f"[ERROR] Config file not found: {config_path}")
sys.exit(1)
try:
with open(config_path, "r", encoding="utf-8") as f:
data = json.load(f)
return data
except json.JSONDecodeError:
cp = ConfigParser()
cp.read(config_path)
return configparser_to_dict(cp)
except Exception as e:
print(f"[ERROR] Failed to read config file: {e}")
sys.exit(1)
else:
cfg = ensure_config_exists()
if isinstance(cfg, ConfigParser):
return configparser_to_dict(cfg)
return cfg
def parse_args():
parser = argparse.ArgumentParser(
prog="OpenSMN",
description="Run the SMN API with custom configuration."
)
parser.add_argument(
"-c", "--config",
type=str,
help="Path to a custom configuration file (INI or JSON)."
)
parser.add_argument(
"-p", "--port",
type=int,
help="Override the server port defined in config."
)
parser.add_argument(
"--no-auth",
action="store_true",
help="Disable authentication even if password is defined in config."
)
parser.add_argument(
"--show-config",
action="store_true",
help="Print the loaded configuration and exit."
)
parser.add_argument(
"--base-path",
type=str,
help="Override the base API path."
)
return parser.parse_args()
def main():
args = parse_args()
config = load_config(args.config)
server_cfg = config.get("server", {})
# apply cli overrides
if args.port:
server_cfg["port"] = str(args.port)
if args.base_path:
server_cfg["base_path"] = args.base_path
if args.no_auth:
server_cfg["password"] = ""
if args.show_config:
print(json.dumps(config, indent=2, ensure_ascii=False))
sys.exit(0)
# run the server
import server
server.run_from_cli(config)
if __name__ == "__main__":
main()

View File

@ -1,3 +1,6 @@
requests requests
selenium selenium
flask flask
uvicorn[standard]
python-dotenv
asgiref

View File

@ -7,21 +7,30 @@ import sys
from datetime import datetime, timedelta from datetime import datetime, timedelta
from flask import Flask, jsonify, Response, request from flask import Flask, jsonify, Response, request
from urllib.parse import urljoin from urllib.parse import urljoin
from dotenv import load_dotenv
import tokenext import tokenext
from healthcheck import ensure_config_exists
# Load environment variables
load_dotenv()
app = Flask(__name__) app = Flask(__name__)
# Global Data # Configuration from environment variables
PORT = 6942 PORT = int(os.getenv("PORT", "6942"))
PASSWORD = "" PASSWORD = os.getenv("PASSWORD", "").strip()
CACHE_DIR = "cache" CACHE_DIR = os.getenv("CACHE_DIR", "cache")
BASE_API = "https://ws1.smn.gob.ar" BASE_API = os.getenv("BASE_API", "https://ws1.smn.gob.ar")
LOG_FILE = "" LOG_FILE = os.getenv("LOG_FILE", "").strip()
BASE_PATH = "/smn" BASE_PATH = os.getenv("BASE_PATH", "/smn").strip()
AUTH_ENABLED = False AUTH_ENABLED = PASSWORD != ""
CACHE_TTL = timedelta(minutes=60) CACHE_TTL = timedelta(minutes=int(os.getenv("CACHE_TTL_MINUTES", "60")))
SMN_TOKEN_FILE = "token" SMN_TOKEN_FILE = os.getenv("SMN_TOKEN_FILE", "token")
# Ensure BASE_PATH is properly formatted
if not BASE_PATH.startswith("/"):
BASE_PATH = "/" + BASE_PATH
if BASE_PATH.endswith("/"):
BASE_PATH = BASE_PATH[:-1]
def log(msg: str): def log(msg: str):
if LOG_FILE: if LOG_FILE:
@ -63,7 +72,9 @@ def load_smn_token():
def refresh_smn_token(): def refresh_smn_token():
log("[TOKEN] Refreshing SMN token...") log("[TOKEN] Refreshing SMN token...")
ok = tokenext.refresh_token(output_file=SMN_TOKEN_FILE, headless=True, wait_seconds=8) headless = os.getenv("SELENIUM_HEADLESS", "true").lower() == "true"
wait_seconds = int(os.getenv("SELENIUM_WAIT_SECONDS", "8"))
ok = tokenext.refresh_token(output_file=SMN_TOKEN_FILE, headless=headless, wait_seconds=wait_seconds)
if ok: if ok:
log("[TOKEN] Token refreshed successfully.") log("[TOKEN] Token refreshed successfully.")
else: else:
@ -157,52 +168,16 @@ def handle_sigint(signum, frame):
signal.signal(signal.SIGINT, handle_sigint) signal.signal(signal.SIGINT, handle_sigint)
# Initialize on startup
os.makedirs(CACHE_DIR, exist_ok=True)
if not os.path.exists(SMN_TOKEN_FILE):
log("[STARTUP] No token file found, generating a new one.")
refresh_smn_token()
def run_from_cli(config=None): log(f"[STARTUP] Server configured on port {PORT}")
global PORT, PASSWORD, CACHE_DIR, BASE_API, LOG_FILE, BASE_PATH, AUTH_ENABLED log(f"[STARTUP] Base path set to '{BASE_PATH}/<path>'")
log(f"[STARTUP] Authentication: {'Enabled' if AUTH_ENABLED else 'Disabled'}")
if config is None: # Wrap Flask app with ASGI adapter for uvicorn
config = ensure_config_exists() from asgiref.wsgi import WsgiToAsgi
app = WsgiToAsgi(app)
server_cfg = config.get("server", {})
PORT = int(server_cfg.get("port", 6942))
PASSWORD = server_cfg.get("password", "").strip()
CACHE_DIR = server_cfg.get("cache_dir", "cache")
BASE_API = server_cfg.get("base_api", "https://ws1.smn.gob.ar")
LOG_FILE = server_cfg.get("log_file", "").strip()
BASE_PATH = server_cfg.get("base_path", "/smn").strip()
if not BASE_PATH.startswith("/"):
BASE_PATH = "/" + BASE_PATH
if BASE_PATH.endswith("/"):
BASE_PATH = BASE_PATH[:-1]
AUTH_ENABLED = PASSWORD != ""
os.makedirs(CACHE_DIR, exist_ok=True)
if not os.path.exists(SMN_TOKEN_FILE):
log("[STARTUP] No token file found, generating a new one.")
refresh_smn_token()
log(f"[STARTUP] Server starting on port {PORT}")
log(f"[STARTUP] Base path set to '{BASE_PATH}/<path>'")
try:
app.run(host="0.0.0.0", port=PORT)
except KeyboardInterrupt:
log("[SHUTDOWN] Interrupted. server stopped cleanly.")
except Exception as e:
log(f"[ERROR] Unhandled exception: {e}")
finally:
log("[SHUTDOWN] Goodbye.")
if __name__ == "__main__":
import signal
import sys
def handle_sigint(signum, frame):
log("[SHUTDOWN] shutting down gracefully...")
sys.exit(0)
signal.signal(signal.SIGINT, handle_sigint)
run_from_cli()

View File

@ -1,36 +0,0 @@
from setuptools import setup
with open("README.md", "r", encoding="utf-8") as f:
long_description = f.read()
setup(
name="opensmn",
version="1.0.0",
author="nix",
description="OpenSMN: Proxy API for Argentina's Servicio Meteorológico Nacional (SMN)",
url="https://github.com/nixietab/OpenSMN",
license="MIT",
py_modules=[
"main",
"server",
"healthcheck",
"tokenext",
],
install_requires=[
"flask",
"requests",
"selenium",
],
entry_points={
"console_scripts": [
"opensmn=main:main",
],
},
classifiers=[
"Programming Language :: Python :: 3",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
"Framework :: Flask",
],
python_requires=">=3.8",
)

32
start.sh Executable file
View File

@ -0,0 +1,32 @@
#!/bin/bash
# Production startup script for OpenSMN
# Load environment variables from .env file
if [ ! -f .env ]; then
echo "Error: .env file not found!"
echo "Please copy .env.example to .env and configure your settings."
exit 1
fi
# Export environment variables
set -a
source .env
set +a
# Default values if not set in .env
PORT=${PORT:-6942}
WORKERS=${WORKERS:-1} # This can change on the future, but for now, 1 is enough
HOST=${HOST:-0.0.0.0}
echo "Starting OpenSMN..."
echo "Host: $HOST"
echo "Port: $PORT"
echo "Workers: $WORKERS"
# Start uvicorn with production settings
exec uvicorn server:app \
--host "$HOST" \
--port "$PORT" \
--workers "$WORKERS" \
--log-level info \
--access-log

View File

@ -1,4 +1,4 @@
# tokenext.py import os
import re import re
import time import time
from typing import Optional from typing import Optional
@ -8,7 +8,7 @@ from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.support import expected_conditions as EC
DEFAULT_URL = "https://www.smn.gob.ar/" DEFAULT_URL = os.getenv("SMN_URL", "https://www.smn.gob.ar/")
DEFAULT_OUTPUT_FILE = "token" DEFAULT_OUTPUT_FILE = "token"