Ultimo refactor, espero
This commit is contained in:
parent
964f6da880
commit
7be72d03e1
89
.env.example
Normal file
89
.env.example
Normal 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
4
.gitignore
vendored
@ -1,6 +1,4 @@
|
||||
__pycache__
|
||||
cache
|
||||
config.ini
|
||||
token
|
||||
opensmn.egg-info
|
||||
build
|
||||
.env
|
||||
103
README.md
103
README.md
@ -1,35 +1,78 @@
|
||||
# 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
|
||||
|
||||
~~~
|
||||
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
|
||||
- Python 3.7+
|
||||
- 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
|
||||
|
||||
@ -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
103
main.py
@ -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()
|
||||
@ -1,3 +1,6 @@
|
||||
requests
|
||||
selenium
|
||||
flask
|
||||
uvicorn[standard]
|
||||
python-dotenv
|
||||
asgiref
|
||||
|
||||
89
server.py
89
server.py
@ -7,21 +7,30 @@ import sys
|
||||
from datetime import datetime, timedelta
|
||||
from flask import Flask, jsonify, Response, request
|
||||
from urllib.parse import urljoin
|
||||
from dotenv import load_dotenv
|
||||
import tokenext
|
||||
from healthcheck import ensure_config_exists
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
# Global Data
|
||||
PORT = 6942
|
||||
PASSWORD = ""
|
||||
CACHE_DIR = "cache"
|
||||
BASE_API = "https://ws1.smn.gob.ar"
|
||||
LOG_FILE = ""
|
||||
BASE_PATH = "/smn"
|
||||
AUTH_ENABLED = False
|
||||
CACHE_TTL = timedelta(minutes=60)
|
||||
SMN_TOKEN_FILE = "token"
|
||||
# Configuration from environment variables
|
||||
PORT = int(os.getenv("PORT", "6942"))
|
||||
PASSWORD = os.getenv("PASSWORD", "").strip()
|
||||
CACHE_DIR = os.getenv("CACHE_DIR", "cache")
|
||||
BASE_API = os.getenv("BASE_API", "https://ws1.smn.gob.ar")
|
||||
LOG_FILE = os.getenv("LOG_FILE", "").strip()
|
||||
BASE_PATH = os.getenv("BASE_PATH", "/smn").strip()
|
||||
AUTH_ENABLED = PASSWORD != ""
|
||||
CACHE_TTL = timedelta(minutes=int(os.getenv("CACHE_TTL_MINUTES", "60")))
|
||||
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):
|
||||
if LOG_FILE:
|
||||
@ -63,7 +72,9 @@ def load_smn_token():
|
||||
|
||||
def refresh_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:
|
||||
log("[TOKEN] Token refreshed successfully.")
|
||||
else:
|
||||
@ -157,52 +168,16 @@ def handle_sigint(signum, frame):
|
||||
|
||||
signal.signal(signal.SIGINT, handle_sigint)
|
||||
|
||||
|
||||
def run_from_cli(config=None):
|
||||
global PORT, PASSWORD, CACHE_DIR, BASE_API, LOG_FILE, BASE_PATH, AUTH_ENABLED
|
||||
|
||||
if config is None:
|
||||
config = ensure_config_exists()
|
||||
|
||||
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):
|
||||
# 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()
|
||||
|
||||
log(f"[STARTUP] Server starting on port {PORT}")
|
||||
log(f"[STARTUP] Base path set to '{BASE_PATH}/<path>'")
|
||||
log(f"[STARTUP] Server configured on port {PORT}")
|
||||
log(f"[STARTUP] Base path set to '{BASE_PATH}/<path>'")
|
||||
log(f"[STARTUP] Authentication: {'Enabled' if AUTH_ENABLED else 'Disabled'}")
|
||||
|
||||
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()
|
||||
# Wrap Flask app with ASGI adapter for uvicorn
|
||||
from asgiref.wsgi import WsgiToAsgi
|
||||
app = WsgiToAsgi(app)
|
||||
36
setup.py
36
setup.py
@ -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
32
start.sh
Executable 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
|
||||
@ -1,4 +1,4 @@
|
||||
# tokenext.py
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
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 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"
|
||||
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user