Proyecto1AVApsp/README.md

76 KiB
Raw Blame History

Python Tkinter Sockets License

💣 Minesweeper Multiplayer + Dashboard

Un proyecto completo de programación de servicios y procesos
Juego de buscaminas competitivo en red + Panel de control integral

CaracterísticasArquitecturaInstalaciónUsoMecánicasTecnologías


📸 Vista Previa

┌─────────────────────────────────────────────────────────────────────────────┐
│                    🎮 MINESWEEPER MULTIPLAYER DASHBOARD                      │
├──────────────┬──────────────────────────────────────┬───────────────────────┤
│              │                                      │  ┌─────────────────┐  │
│   ACCIONES   │          ÁREA DE RESULTADOS          │  │ 💣 MINESWEEPER  │  │
│  ──────────  │                                      │  │   MULTIPLAYER   │  │
│              │   ┌────────────────────────────┐     │  ├─────────────────┤  │
│ > Wallapop   │   │    📊 Monitor Sistema      │     │  │ Ronda: 3        │  │
│ > Scraping   │   │    📈 CPU: 45%             │     │  │ 💣 Bombas: 9    │  │
│ > API Tiempo │   │    💾 RAM: 2.1GB           │     │  │ ❤️ Vidas: 2     │  │
│              │   └────────────────────────────┘     │  ├─────────────────┤  │
│  APPS        │                                      │  │ ┌───┬───┬───┐   │  │
│  ──────────  │   Tabs: [Resultados][Navegador]      │  │ │ ▢ │ ▢ │ ✓ │   │  │
│ > VS Code    │         [Correos][Bloc][Tareas]      │  │ ├───┼───┼───┤   │  │
│ > Camellos   │         [Alarmas][Enlaces]           │  │ │ 💥│ ▢ │ ▢ │   │  │
│              │                                      │  │ └───┴───┴───┘   │  │
│  BATCH       │   ┌────────────────────────────┐     │  ├─────────────────┤  │
│  ──────────  │   │    📝 Panel de Notas       │     │  │ [Iniciar Juego] │  │
│ > Backups    │   │                            │     │  │ [Zona Despejada]│  │
│              │   └────────────────────────────┘     │  └─────────────────┘  │
└──────────────┴──────────────────────────────────────┴───────────────────────┘

Características

🎮 Juego Minesweeper Multijugador

Característica Descripción
🔥 Competitivo 2+ jugadores compiten en tiempo real
💣 Colocación estratégica Cada jugador coloca bombas para el rival
🔄 Por turnos Sistema de turnos para colocar y buscar
📈 Dificultad progresiva 5 rondas con grids y bombas crecientes
❤️ Sistema de vidas 3 vidas por partida, ¡no las pierdas!

📊 Dashboard Integral

  • 📡 Monitor del sistema en tiempo real (CPU, RAM, hilos)
  • 🌤️ API del tiempo para Jávea (OpenWeather)
  • 🛒 Análisis de Wallapop - Scraping de anuncios
  • Sistema de alarmas programables
  • 📝 Bloc de notas integrado
  • 🔗 Gestor de enlaces rápidos
  • 🎲 Minijuego de camellos con animaciones

📧 Cliente de Correo Electrónico Completo

Característica Descripción
📬 IMAP Lectura de correos desde servidor Webmin (Puerto 143)
📤 SMTP Envío de correos con autenticación (Puerto 25)
💾 Auto-guardado Credenciales guardadas con Base64
👥 Múltiples destinatarios Envío a varios correos simultáneamente
📎 Adjuntos Soporte para imágenes, PDFs, docs, Excel, ZIP
🖼️ Imágenes inline Visualización de imágenes dentro del correo
🗂️ Carpetas Bandeja de entrada y enviados sincronizados
🔵 Indicadores Correos leídos/no leídos con marcadores visuales

🏗️ Arquitectura

┌─────────────────────────────────────────────────────────────────┐
│                        ARQUITECTURA DEL SISTEMA                  │
└─────────────────────────────────────────────────────────────────┘

                    ┌──────────────────────────┐
                    │      SERVIDOR TCP        │
                    │      servidor.py         │
                    │   ┌──────────────────┐   │
                    │   │   GameServer     │   │
                    │   │  - Estado juego  │   │
                    │   │  - Broadcast     │   │
                    │   │  - Turnos        │   │
                    │   └──────────────────┘   │
                    │        Puerto 3333       │
                    └────────────┬─────────────┘
                                 │
                    ┌────────────┴───────────────┐
                    │      Protocolo JSON        │
                    │      sobre TCP/IP          │
                    └────────────┬───────────────┘
           ┌─────────────────────┼─────────────────────┐
           │                     │                     │
           ▼                     ▼                     ▼
┌──────────────────┐  ┌──────────────────┐  ┌──────────────────┐
│   CLIENTE 1      │  │   CLIENTE 2      │  │   CLIENTE N      │
│                  │  │                  │  │                  │
│  ┌────────────┐  │  │  ┌────────────┐  │  │  ┌────────────┐  │
│  │ app.py     │  │  │  │ app.py     │  │  │  │ cliente_   │  │
│  │ Dashboard  │  │  │  │ Dashboard  │  │  │  │ juego.py   │  │
│  │ + Juego    │  │  │  │ + Juego    │  │  │  │ Standalone │  │
│  └────────────┘  │  │  └────────────┘  │  │  └────────────┘  │
└──────────────────┘  └──────────────────┘  └──────────────────┘

📁 Estructura del Proyecto

Proyecto1AVApsp/
│
├── 📄 servidor.py       # Servidor TCP del juego (371 líneas)
│   └── GameServer       # Gestiona estado, turnos y broadcasts
│
├── 📄 app.py            # Dashboard principal (2566 líneas)
│   ├── DashboardApp     # Aplicación Tkinter completa
│   └── GameClient       # Cliente TCP integrado
│
├── 📄 cliente_juego.py  # Cliente standalone (220 líneas)
│   └── GameClient       # Versión ligera para jugar
│
├── 📄 requirements.txt  # Dependencias Python
└── 📄 README.md         # Este archivo

🚀 Instalación

Requisitos Previos

  • Python 3.8+
  • pip (gestor de paquetes)

Paso 1: Clonar el Repositorio

git clone https://github.com/MarcosFerrandiz/Proyecto1AVApsp.git
cd Proyecto1AVApsp

Paso 2: Crear Entorno Virtual (Recomendado)

python -m venv .venv

# Linux/macOS
source .venv/bin/activate

# Windows
.venv\Scripts\activate

Paso 3: Instalar Dependencias

pip install -r requirements.txt
📦 Ver dependencias detalladas
Paquete Versión Uso
psutil ≥5.9.0 Monitor de recursos del sistema
matplotlib ≥3.5.0 Gráficos en tiempo real
pillow ≥9.0.0 Procesamiento de imágenes
pygame ≥2.1.0 Reproducción de audio (opcional)
requests ≥2.32.0 Peticiones HTTP (API, Scraping)
beautifulsoup4 ≥4.12.0 Parsing HTML (Scraping)

🎯 Uso

🖥️ Iniciar el Servidor

python servidor.py

El servidor escuchará en 0.0.0.0:3333

🎮 Opción A: Dashboard Completo

python app.py

Incluye el juego integrado + todas las funcionalidades del panel.

🎮 Opción B: Cliente Standalone

python cliente_juego.py

Cliente ligero solo para jugar al Minesweeper.

🔌 Conectar al Juego

  1. Introduce el Host del servidor (por defecto: 127.0.0.1)
  2. Verifica el Puerto (por defecto: 3333)
  3. Pulsa "Conectar"
  4. ¡Espera a otro jugador y pulsa "Iniciar Juego"!

💣 Mecánicas del Juego

🔄 Flujo de una Partida

┌─────────────────────────────────────────────────────────────────┐
│                    FLUJO DEL JUEGO                               │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│   ┌─────────┐     ┌─────────────┐     ┌─────────────┐           │
│   │  LOBBY  │────▶│  COLOCACIÓN │────▶│  BÚSQUEDA   │           │
│   │         │     │  DE BOMBAS  │     │  (PLAYING)  │           │
│   └─────────┘     └─────────────┘     └──────┬──────┘           │
│        │                                      │                  │
│        │         ┌───────────────────────────┘                  │
│        │         │                                              │
│        │         ▼                                              │
│        │    ┌─────────┐    ┌──────────┐    ┌────────────┐       │
│        │    │ ¿BOMBA? │───▶│ EXPLOSIÓN│───▶│ ¿VIDAS=0?  │       │
│        │    └────┬────┘    └──────────┘    └─────┬──────┘       │
│        │         │ NO                            │               │
│        │         ▼                          SÍ   ▼               │
│        │    ┌─────────┐                    ┌──────────┐         │
│        │    │  SAFE   │                    │ GAME OVER│         │
│        │    │(casilla │                    └──────────┘         │
│        │    │ segura) │                                         │
│        │    └────┬────┘                                         │
│        │         │                                              │
│        │         ▼                                              │
│        │    ┿━━━━━━━━━━━◀────────────────────────┓              │
│        │    ┃ SIGUIENTE TURNO                    ┃              │
│        │    ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛              │
│        │                                                        │
│        └───────────────────▶ (Nueva Partida)                    │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘

📊 Progresión de Dificultad

Ronda Tamaño Grid Bombas/Jugador Total Bombas*
1 3×3 3 6
2 5×5 5 10
3 8×8 9 18
4 11×11 12 24
5+ 14×14 15 30

*Para 2 jugadores

🎯 Fases del Juego

1. 💣 Fase de Colocación (PLACING)

  • Cada jugador coloca bombas por turnos
  • Las bombas se muestran brevemente (1 segundo) a todos
  • ¡Memoriza dónde pones TUS bombas y las del rival!

2. 🔍 Fase de Búsqueda (PLAYING)

  • Excava casillas por turnos
  • Casilla segura → Se marca en verde
  • Bomba → ¡EXPLOSIÓN! Pierdes 1 vida 💔

🏆 Condiciones de Victoria

Condición Resultado
Rival pierde todas las vidas ¡GANASTE! 🎉
Todas las casillas seguras reveladas ¡Zona despejada!
Superas la ronda 5 ¡Victoria total! 🏆

📡 Protocolo de Comunicación

Mensajes JSON Cliente → Servidor

// Iniciar partida
{ "type": "START_GAME" }

// Colocar bomba (fase PLACING)
{ "type": "PLACE_BOMB", "x": 2, "y": 1 }

// Excavar casilla (fase PLAYING)
{ "type": "CLICK_CELL", "x": 3, "y": 4 }

// Verificar zona despejada
{ "type": "CHECK_DUNGEON_CLEARED" }

Mensajes JSON Servidor → Clientes

// Nueva ronda
{
  "type": "NEW_ROUND",
  "round": 1,
  "grid_size": 3,
  "total_bombs_per_player": 3
}

// Notificación de turno
{
  "type": "TURN_NOTIFY",
  "active_player": "('127.0.0.1', 54321)",
  "msg": "Turno de ... para poner bombas."
}

// Flash de bomba (visible 1 segundo)
{
  "type": "BOMB_flash",
  "x": 1, "y": 2,
  "who": "('127.0.0.1', 54321)"
}

// Explosión
{
  "type": "EXPLOSION",
  "x": 1, "y": 2,
  "who": "...",
  "lives": 2
}

// Casilla segura
{ "type": "SAFE", "x": 0, "y": 0 }

// Game Over
{
  "type": "GAME_OVER",
  "loser": "...",
  "msg": "💀 ¡... ha perdido todas sus vidas!"
}

🔧 Documentación Técnica Detallada

🆔 Sistema de Identificación de Jugadores

El servidor identifica a cada jugador mediante su dirección de socket, que es una tupla única (IP, Puerto):

┌─────────────────────────────────────────────────────────────────┐
│              IDENTIFICACIÓN ÚNICA DE JUGADORES                   │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│   Cuando un cliente se conecta al servidor:                     │
│                                                                  │
│   Cliente 1 ──────► sock.accept() ──────► ('127.0.0.1', 49956)  │
│   Cliente 2 ──────► sock.accept() ──────► ('127.0.0.1', 49968)  │
│                                                                  │
│   Aunque ambos clientes estén en el MISMO ordenador (127.0.0.1) │
│   el PUERTO es diferente y único para cada conexión.            │
│                                                                  │
│   Esta tupla (IP, Puerto) actúa como IDENTIFICADOR ÚNICO.       │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘

¿Cómo funciona en el mismo ordenador?

# servidor.py - Al aceptar conexión
conn, addr = s.accept()  # addr = ('127.0.0.1', 49956)

# El sistema operativo asigna un puerto efímero ÚNICO
# a cada nueva conexión de socket del cliente
Cliente IP Puerto (asignado por SO) Identificador Completo
Dashboard 1 127.0.0.1 49956 ('127.0.0.1', 49956)
Dashboard 2 127.0.0.1 49968 ('127.0.0.1', 49968)
Cliente remoto 192.168.1.50 52341 ('192.168.1.50', 52341)

💡 Clave: El puerto del cliente es asignado automáticamente por el sistema operativo y es siempre diferente para cada nueva conexión, incluso desde el mismo ordenador.


🔄 Gestión de Turnos

El servidor mantiene dos índices de turno para controlar quién juega:

# servidor.py - Variables de control de turnos
self.placing_turn_index = 0      # Índice en fase PLACING
self.playing_turn_index = 0      # Índice en fase PLAYING
self.client_list = []            # Lista ordenada de direcciones

Flujo de Verificación de Turno

┌─────────────────────────────────────────────────────────────────┐
│                 VERIFICACIÓN DE TURNO                            │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│   client_list = [('127.0.0.1', 49956), ('127.0.0.1', 49968)]    │
│                         ↑                       ↑                │
│                      índice 0               índice 1             │
│                                                                  │
│   Si placing_turn_index = 0:                                    │
│      → Solo ('127.0.0.1', 49956) puede poner bombas             │
│                                                                  │
│   Cuando recibe PLACE_BOMB de un cliente:                       │
│                                                                  │
│   ┌─────────────────────────────────────────────┐               │
│   │ if str(addr) != str(current_turn_addr):    │               │
│   │     return  # No es su turno, ignorar       │               │
│   └─────────────────────────────────────────────┘               │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘

Código de Verificación (servidor.py)

elif msg_type == 'PLACE_BOMB':
    if self.state == STATE_PLACING:
        # Obtener quién tiene el turno actual
        current_turn_addr = self.client_list[self.placing_turn_index]
        
        # Comparar con quién envió el mensaje
        if str(addr) != str(current_turn_addr):
            return  # ¡No es tu turno! Ignorar mensaje
        
        # Procesar la bomba...

👤 Cómo el Cliente Sabe si es su Turno

El cliente guarda su propia dirección al conectarse y la compara con los mensajes del servidor:

# app.py - Al conectarse
self.my_address = str(sock.getsockname())  # Ej: "('127.0.0.1', 49956)"

# Al recibir TURN_NOTIFY del servidor
def handle_message(self, msg):
    if mtype == 'TURN_NOTIFY':
        active_player = msg.get('active_player')  # "('127.0.0.1', 49956)"
        
        # Comparar con mi dirección
        if active_player == self.my_address:
            self._log_game("🎯 ¡ES TU TURNO!")
            # Habilitar controles...
        else:
            self._log_game(f"⏳ Turno de {active_player}")
            # Deshabilitar controles...
┌─────────────────────────────────────────────────────────────────┐
│                 FLUJO DE NOTIFICACIÓN DE TURNO                   │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│   SERVIDOR                          CLIENTES                     │
│   ────────                          ────────                     │
│                                                                  │
│   broadcast({                       Cliente 1 (49956):           │
│     "type": "TURN_NOTIFY",          ├─ my_address = 49956       │
│     "active_player":                ├─ active_player = 49956    │
│       "('127.0.0.1', 49956)"        └─ ✓ ¡ES MI TURNO!          │
│   })                                                             │
│       │                             Cliente 2 (49968):           │
│       └──────────────────────────►  ├─ my_address = 49968       │
│                                     ├─ active_player = 49956    │
│                                     └─ ✗ No es mi turno         │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘

🔒 Sincronización con Threading Lock

El servidor usa un threading.Lock para evitar condiciones de carrera cuando múltiples clientes envían mensajes simultáneamente:

class GameServer:
    def __init__(self):
        self.lock = threading.Lock()  # Mutex para sincronización
        self.clients = {}             # Diccionario compartido
        self.state = STATE_LOBBY      # Estado compartido
        
    def process_message(self, client, addr, msg):
        with self.lock:  # Adquirir lock antes de modificar estado
            if msg_type == 'PLACE_BOMB':
                # Solo un hilo puede ejecutar esto a la vez
                self.bombs.add((x, y))
                self._broadcast_unlocked(...)
┌─────────────────────────────────────────────────────────────────┐
│                    SINCRONIZACIÓN CON LOCK                       │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│   Hilo Cliente 1 ─────┐                                         │
│                       ├──► with self.lock: ──► Ejecuta primero  │
│   Hilo Cliente 2 ─────┘         │                               │
│                                 ▼                               │
│                           (espera...)                           │
│                                 │                               │
│                                 ▼                               │
│                        Hilo 2 ejecuta después                   │
│                                                                  │
│   Esto evita que dos jugadores modifiquen el estado             │
│   del juego al mismo tiempo (condiciones de carrera).           │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘

📡 Arquitectura de Hilos

┌─────────────────────────────────────────────────────────────────┐
│                    ARQUITECTURA DE HILOS                         │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│   SERVIDOR (servidor.py)                                        │
│   ══════════════════════                                        │
│                                                                  │
│   ┌─────────────────┐                                           │
│   │   Hilo Main     │ ◄── Acepta conexiones (s.accept())        │
│   └────────┬────────┘                                           │
│            │                                                     │
│            ├──► Hilo Cliente 1 ──► handle_client(sock1)         │
│            ├──► Hilo Cliente 2 ──► handle_client(sock2)         │
│            └──► Hilo Cliente N ──► handle_client(sockN)         │
│                                                                  │
│   Cada cliente tiene su propio hilo daemon que:                 │
│   1. Lee mensajes del socket (recv)                             │
│   2. Procesa el mensaje (process_message)                       │
│   3. Puede hacer broadcast a todos los clientes                 │
│                                                                  │
│   ─────────────────────────────────────────────────────────     │
│                                                                  │
│   CLIENTE (app.py)                                              │
│   ════════════════                                              │
│                                                                  │
│   ┌─────────────────┐    ┌─────────────────┐                    │
│   │   Hilo Main     │    │   Hilo Recv     │                    │
│   │   (Tkinter UI)  │◄───│   (_recv_loop)  │                    │
│   └─────────────────┘    └─────────────────┘                    │
│          │                       ▲                               │
│          │ msg_queue.put()       │ sock.recv()                  │
│          ▼                       │                               │
│   ┌─────────────────┐           │                               │
│   │  Cola de msgs   │ ──────────┘                               │
│   │  (thread-safe)  │                                           │
│   └─────────────────┘                                           │
│                                                                  │
│   El hilo de recepción pone mensajes en una cola.               │
│   El hilo principal (UI) los procesa con after().               │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘

🎮 Resumen: Jugar en el Mismo Ordenador

Paso Qué Sucede
1. Iniciar servidor python servidor.py escucha en puerto 3333
2. Abrir Cliente 1 Conecta → SO asigna puerto 49956
3. Abrir Cliente 2 Conecta → SO asigna puerto 49968
4. Servidor registra clients = {sock1: ('127.0.0.1', 49956), sock2: ('127.0.0.1', 49968)}
5. Iniciar juego client_list = [addr1, addr2] define orden de turnos
6. Turno jugador 1 Servidor envía TURN_NOTIFY con active_player = addr1
7. Cliente 1 compara my_address == active_player → ¡Es mi turno!
8. Cliente 2 compara my_address != active_player → Esperar
9. Jugador 1 actúa Envía PLACE_BOMB o CLICK_CELL
10. Servidor valida addr == current_turn_addr → Válido, procesar
11. Avanzar turno placing_turn_index += 1 → Turno del siguiente

🛠️ Tecnologías

Python
Python 3
Lenguaje base
Tkinter
Tkinter
Interfaz gráfica
Sockets
TCP Sockets
Comunicación en red
Matplotlib
Matplotlib
Gráficos
JSON
JSON
Protocolo mensajes
Requests
Requests
APIs HTTP

🧵 Conceptos de PSP Aplicados

Concepto Implementación
Procesos Lanzamiento de aplicaciones externas (VS Code, Firefox)
Threads Servidor multihilo, cliente con hilos de recepción
Sockets TCP Comunicación cliente-servidor en red (Juego y Correo)
Servicios API OpenWeather, scraping de Wallapop, IMAP/SMTP
Sincronización Locks para acceso concurrente a estado compartido

📧 CLIENTE DE CORREO ELECTRÓNICO: ARQUITECTURA Y FUNCIONAMIENTO

🏗️ Arquitectura del Sistema de Correo

┌─────────────────────────────────────────────────────────────────────┐
│                ARQUITECTURA DEL CLIENTE DE CORREO                    │
└─────────────────────────────────────────────────────────────────────┘

                    ┌───────────────────────────┐
                    │   SERVIDOR WEBMIN         │
                    │   10.10.0.101             │
                    └─────────────┬─────────────┘
                                  │
            ┌─────────────────────┴─────────────────────┐
            │                                           │
    ┌───────▼────────┐                        ┌────────▼────────┐
    │  PUERTO 143    │                        │   PUERTO 25     │
    │     IMAP       │                        │     SMTP        │
    │  (Lectura)     │                        │   (Envío)       │
    └───────┬────────┘                        └────────┬────────┘
            │                                           │
            └─────────────────┬───────────────────────┘
                              │
                    ┌─────────▼──────────┐
                    │   app.py           │
                    │   DashboardApp     │
                    │                    │
                    │  Tab "Correos"     │
                    └────────────────────┘
                              │
        ┌─────────────────────┼─────────────────────┐
        │                     │                     │
┌───────▼────────┐  ┌─────────▼────────┐  ┌────────▼────────┐
│ _connect_      │  │ _refresh_        │  │ _send_mail_     │
│ mail_server()  │  │ mail_list()      │  │ with_attach()   │
│                │  │                  │  │                 │
│ IMAP Login     │  │ IMAP FETCH       │  │ SMTP Send       │
└────────────────┘  └──────────────────┘  └─────────────────┘

📡 Protocolos Utilizados

IMAP (Internet Message Access Protocol)

Protocolo para leer correos del servidor. Puerto: 143 (sin TLS).

# Conexión IMAP (app.py líneas 1578-1582)
import imaplib
self.imap_connection = imaplib.IMAP4(host, port_num)  # Puerto 143
self.imap_connection.login(username, password)

Comandos IMAP utilizados:

  • login(user, pass) → Autenticación
  • select(mailbox) → Seleccionar carpeta (INBOX, Sent, etc.)
  • search(None, 'ALL') → Buscar todos los correos
  • fetch(id, '(BODY.PEEK[] FLAGS)') → Obtener correo sin marcarlo como leído
  • store(id, '+FLAGS', '\\Seen') → Marcar como leído
  • append(folder, flags, date, msg) → Guardar correo en carpeta

SMTP (Simple Mail Transfer Protocol)

Protocolo para enviar correos. Puerto: 25 (sin TLS).

# Conexión SMTP (app.py líneas 2949-2955)
import smtplib
with smtplib.SMTP(smtp_host, smtp_port_num, timeout=15) as server:
    server.send_message(msg, to_addrs=recipients)

🔐 Sistema de Credenciales

Guardado Automático con Base64

# GUARDAR (app.py líneas 1540-1557)
import base64
config = {
    'imap_host': '10.10.0.101',
    'imap_port': '143',
    'smtp_host': '10.10.0.101',
    'smtp_port': '25',
    'username': 'marcos@psp.es',
    'password': base64.b64encode(password.encode()).decode()  # Codificar
}
json.dump(config, open('.mail_config.json', 'w'), indent=2)
# CARGAR (app.py líneas 1512-1520)
config = json.load(open('.mail_config.json', 'r'))
password = base64.b64decode(config['password']).decode()  # Decodificar

Flujo de guardado:

┌─────────────────────────────────────────────────────────────────────┐
│              FLUJO DE GUARDADO DE CREDENCIALES                       │
├─────────────────────────────────────────────────────────────────────┤
│                                                                      │
│  1. Usuario ingresa credenciales                                    │
│     └─► Usuario: marcos@psp.es                                      │
│     └─► Password: 1234                                              │
│                                                                      │
│  2. Marca checkbox "💾 Recordar credenciales"                       │
│     └─► mail_remember_var.get() = True                              │
│                                                                      │
│  3. Al conectar exitosamente, se llama _save_mail_credentials()     │
│                                                                      │
│  4. Base64 encoding:                                                │
│     Password "1234" → Bytes b'1234'                                 │
│                       → Base64 b'MTIzNA=='                          │
│                       → String "MTIzNA=="                           │
│                                                                      │
│  5. Se guarda en .mail_config.json:                                 │
│     {                                                               │
│       "username": "marcos@psp.es",                                  │
│       "password": "MTIzNA=="                                        │
│     }                                                               │
│                                                                      │
│  6. Al reiniciar app.py:                                            │
│     └─► _load_mail_credentials() lee el archivo                     │
│     └─► Decodifica Base64: "MTIzNA==" → "1234"                      │
│     └─► Precarga los campos automáticamente                         │
│                                                                      │
└─────────────────────────────────────────────────────────────────────┘

⚠️ IMPORTANTE: Base64 NO es encriptación, solo ofuscación. El archivo .mail_config.json está protegido en .gitignore para no subirlo a Git.


📬 Lectura de Correos (IMAP)

Función Principal: _refresh_mail_list() (líneas 1635-1801)

┌─────────────────────────────────────────────────────────────────────┐
│                   FLUJO DE LECTURA DE CORREOS                        │
├─────────────────────────────────────────────────────────────────────┤
│                                                                      │
│  1. SELECT 'INBOX'                                                  │
│     └─► imap_connection.select('INBOX')                             │
│                                                                      │
│  2. SEARCH ALL                                                      │
│     └─► status, messages = imap_connection.search(None, 'ALL')      │
│     └─► mail_ids = messages[0].split()  # [b'1', b'2', b'3', ...]  │
│                                                                      │
│  3. FETCH con BODY.PEEK[] (no marca como leído)                     │
│     for mail_id in mail_ids:                                        │
│         status, msg_data = imap_connection.fetch(                   │
│             mail_id,                                                │
│             '(BODY.PEEK[] FLAGS)'  # ← PEEK es clave               │
│         )                                                           │
│                                                                      │
│  4. EXTRAER FLAGS (\Seen)                                           │
│     └─► Busca en respuesta IMAP: "FLAGS (\\Seen ...)"               │
│     └─► is_seen = True si contiene "\\Seen"                         │
│                                                                      │
│  5. PARSEAR EMAIL                                                   │
│     └─► msg = email.message_from_bytes(msg_data)                    │
│     └─► from_addr = decode_header(msg['From'])[0][0]               │
│     └─► subject = decode_header(msg['Subject'])[0][0]              │
│                                                                      │
│  6. GUARDAR EN LISTA LOCAL                                          │
│     self.mail_list.append({                                         │
│         'id': mail_id,                                              │
│         'from': from_addr,                                          │
│         'subject': subject,                                         │
│         'date': date_str,                                           │
│         'msg': msg,  # Objeto email completo                        │
│         'is_seen': is_seen                                          │
│     })                                                              │
│                                                                      │
│  7. MOSTRAR EN LISTBOX                                              │
│     if is_seen:                                                     │
│         display = f'    {from_addr} - {subject}'  # Sin emoji       │
│         itemconfig(idx, fg='#888888')  # Gris                       │
│     else:                                                           │
│         display = f'🔵 {from_addr} - {subject}'  # Con emoji        │
│         itemconfig(idx, fg='#000000')  # Negro                      │
│                                                                      │
└─────────────────────────────────────────────────────────────────────┘

¿Por qué BODY.PEEK[]?

# ❌ MAL: RFC822 marca el correo como leído automáticamente
fetch(mail_id, 'RFC822')

# ✅ BIEN: BODY.PEEK[] lee sin cambiar el flag \Seen
fetch(mail_id, '(BODY.PEEK[] FLAGS)')

📤 Envío de Correos (SMTP)

Función: _send_mail_with_attachments() (líneas 2837-2977)

┌─────────────────────────────────────────────────────────────────────┐
│                    FLUJO DE ENVÍO DE CORREO                          │
├─────────────────────────────────────────────────────────────────────┤
│                                                                      │
│  1. VALIDAR DESTINATARIOS                                           │
│     to_addr_raw = "marcos@psp.es, user2@example.com"               │
│     └─► Split por comas/punto y coma                                │
│     └─► recipients = ['marcos@psp.es', 'user2@example.com']        │
│     └─► Validar regex: [a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-z]{2,} │
│                                                                      │
│  2. CREAR MENSAJE MIME MULTIPART                                    │
│     from email.mime.multipart import MIMEMultipart                  │
│     msg = MIMEMultipart()                                           │
│     msg['From'] = 'marcos@psp.es'                                   │
│     msg['To'] = 'marcos@psp.es, user2@example.com'                 │
│     msg['Subject'] = 'Asunto del correo'                            │
│                                                                      │
│  3. ADJUNTAR CUERPO                                                 │
│     from email.mime.text import MIMEText                            │
│     msg.attach(MIMEText(body, 'plain', 'utf-8'))                    │
│                                                                      │
│  4. ADJUNTAR ARCHIVOS                                               │
│     for file_path in attachments:                                   │
│         if file_ext == '.png':                                      │
│             part = MIMEImage(file_data, name=filename)              │
│         elif file_ext == '.pdf':                                    │
│             part = MIMEApplication(file_data, _subtype='pdf')       │
│         # ... otros tipos                                           │
│         msg.attach(part)                                            │
│                                                                      │
│  5. CONECTAR SMTP Y ENVIAR                                          │
│     with smtplib.SMTP('10.10.0.101', 25) as server:                 │
│         server.send_message(msg, to_addrs=recipients)               │
│                                                                      │
│  6. GUARDAR EN CARPETA SENT (IMAP)                                  │
│     └─► _save_to_sent_folder(msg)  # Ver siguiente sección         │
│                                                                      │
└─────────────────────────────────────────────────────────────────────┘

Tipos MIME Soportados

Extensión Tipo MIME Clase Python
.png, .jpg image/* MIMEImage
.pdf application/pdf MIMEApplication
.doc, .docx application/msword MIMEApplication
.xls, .xlsx application/vnd.ms-excel MIMEApplication
.zip, .rar application/zip MIMEApplication
.txt text/plain MIMEText
Otros application/octet-stream MIMEBase

💾 Guardado en Servidor IMAP

Función: _save_to_sent_folder() (líneas 2979-3033)

Después de enviar un correo por SMTP, se guarda una copia en la carpeta "Sent" del servidor IMAP para que aparezca en Webmin y otros clientes.

# Intentar múltiples nombres de carpeta
sent_folders = ['Sent', 'INBOX.Sent', 'Enviados', 'INBOX.Enviados', 'Sent Items']

for folder in sent_folders:
    try:
        # Intentar seleccionar la carpeta
        status, _ = self.imap_connection.select(folder)
        if status == 'OK':
            # Guardar correo con APPEND
            self.imap_connection.append(
                folder,
                '\\Seen',  # Marcar como leído
                imaplib.Time2Internaldate(time.time()),
                msg.as_bytes()
            )
            break
    except:
        continue

# Si no existe, crearla
if not folder_found:
    self.imap_connection.create('Sent')
    self.imap_connection.append('Sent', '\\Seen', date, msg_bytes)
┌─────────────────────────────────────────────────────────────────────┐
│              GUARDADO EN CARPETA SENT DEL SERVIDOR                   │
├─────────────────────────────────────────────────────────────────────┤
│                                                                      │
│  1. Usuario envía correo con SMTP                                   │
│     └─► Correo enviado a marcos@psp.es                              │
│                                                                      │
│  2. _save_to_sent_folder() se ejecuta automáticamente               │
│                                                                      │
│  3. Intenta conectar a carpeta "Sent":                              │
│     ├─► Intenta: SELECT 'Sent'           ❌ Falla                   │
│     ├─► Intenta: SELECT 'INBOX.Sent'     ✅ OK                      │
│     └─► Carpeta encontrada                                          │
│                                                                      │
│  4. Guarda el correo con APPEND:                                    │
│     APPEND "INBOX.Sent" (\Seen) "19-Feb-2026 19:30:00" {bytes}     │
│                                                                      │
│  5. Resultado:                                                      │
│     ┌─────────────────────────────────────┐                         │
│     │  SERVIDOR WEBMIN                    │                         │
│     │  ├─ INBOX (3 correos)               │                         │
│     │  └─ Sent (1 correo NUEVO) ← AQUÍ   │                         │
│     └─────────────────────────────────────┘                         │
│                                                                      │
│  6. Al abrir Webmin:                                                │
│     └─► El correo aparece en "Sent"                                 │
│     └─► Otros clientes (Thunderbird, etc.) también lo ven           │
│                                                                      │
└─────────────────────────────────────────────────────────────────────┘

👥 Envío a Múltiples Destinatarios

Validación y Parsing (líneas 2703-2740)

# Entrada del usuario
to_addr_raw = "marcos@psp.es, user2@example.com; user3@test.org"

# 1. Split por comas O punto y coma
import re
recipients = re.split(r'[;,]\s*', to_addr_raw)
# → ['marcos@psp.es', 'user2@example.com', 'user3@test.org']

# 2. Limpiar espacios
recipients = [r.strip() for r in recipients if r.strip()]

# 3. Validar formato con regex
email_pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
invalid_emails = [email for email in recipients if not re.match(email_pattern, email)]

if invalid_emails:
    messagebox.showwarning('⚠️ Advertencia', 
        f'Los siguientes emails no son válidos:\n{", ".join(invalid_emails)}')
    return

# 4. Confirmar si son múltiples
if len(recipients) > 1:
    confirm = messagebox.askyesno('📧 Múltiples destinatarios',
        f'¿Enviar correo a {len(recipients)} destinatarios?\n\n' + 
        '\n'.join(f'  • {email}' for email in recipients))
    if not confirm:
        return

# 5. Enviar a todos
self._send_mail_with_attachments(recipients, subject, body, attachments)
┌─────────────────────────────────────────────────────────────────────┐
│                ENVÍO A MÚLTIPLES DESTINATARIOS                       │
├─────────────────────────────────────────────────────────────────────┤
│                                                                      │
│  Usuario escribe en campo "Para:":                                  │
│  ┌─────────────────────────────────────────────────────────────┐   │
│  │ marcos@psp.es, user2@test.com, user3@example.org           │   │
│  └─────────────────────────────────────────────────────────────┘   │
│                                                                      │
│  Al hacer clic en "📤 ENVIAR":                                      │
│                                                                      │
│  1. Parse: Split por ',' o ';'                                      │
│     ['marcos@psp.es', 'user2@test.com', 'user3@example.org']       │
│                                                                      │
│  2. Validación regex de cada email                                  │
│     ✅ marcos@psp.es       → Válido                                 │
│     ✅ user2@test.com      → Válido                                 │
│     ✅ user3@example.org   → Válido                                 │
│                                                                      │
│  3. Diálogo de confirmación:                                        │
│     ┌───────────────────────────────────┐                           │
│     │ ¿Enviar a 3 destinatarios?        │                           │
│     │                                   │                           │
│     │  • marcos@psp.es                  │                           │
│     │  • user2@test.com                 │                           │
│     │  • user3@example.org              │                           │
│     │                                   │                           │
│     │     [Sí]        [No]              │                           │
│     └───────────────────────────────────┘                           │
│                                                                      │
│  4. SMTP envía a todos:                                             │
│     msg['To'] = 'marcos@psp.es, user2@test.com, user3@example.org' │
│     server.send_message(msg, to_addrs=[...])                        │
│                                                                      │
│  5. Mensaje de éxito:                                               │
│     ✅ "Correo enviado correctamente a 3 destinatarios"             │
│                                                                      │
└─────────────────────────────────────────────────────────────────────┘

📎 Manejo de Adjuntos

Adjuntar Archivos

# Usuario hace clic en "📎 Adjuntar archivo"
file_paths = filedialog.askopenfilenames(
    title='Seleccionar archivos',
    filetypes=[
        ('Imágenes', '*.png *.jpg *.jpeg *.gif'),
        ('PDFs', '*.pdf'),
        ('Documentos', '*.doc *.docx *.xls *.xlsx'),
        ('Todos', '*.*')
    ]
)

# Se guardan en lista
attachments.append(file_path)

# Al enviar, se procesan:
for file_path in attachments:
    file_name = os.path.basename(file_path)  # "documento.pdf"
    file_ext = os.path.splitext(file_path)[1]  # ".pdf"
    
    with open(file_path, 'rb') as f:
        file_data = f.read()
    
    if file_ext == '.pdf':
        part = MIMEApplication(file_data, _subtype='pdf')
        part.add_header('Content-Disposition', 'attachment', filename=file_name)
        msg.attach(part)

Imágenes Inline con Ctrl+V

# Usuario copia una imagen y presiona Ctrl+V
def on_paste(event):
    try:
        # Obtener imagen del portapapeles
        img = ImageGrab.grabclipboard()
        
        if img:
            # Guardar temporalmente
            temp_file = tempfile.NamedTemporaryFile(delete=False, suffix='.png')
            img.save(temp_file.name)
            
            # Mostrar miniatura en interfaz
            thumbnail = img.resize((150, 150))
            photo = ImageTk.PhotoImage(thumbnail)
            label = tk.Label(frame, image=photo)
            label.image = photo  # Mantener referencia
            label.pack()
            
            # Agregar a lista de adjuntos
            inline_images_data.append({'data': img_bytes})
    except:
        pass

🖼️ Visualización de Correos

Función: _display_mail() (líneas 2055-2337)

┌─────────────────────────────────────────────────────────────────────┐
│                  VISUALIZACIÓN DE CORREO                             │
├─────────────────────────────────────────────────────────────────────┤
│                                                                      │
│  1. Usuario hace clic en un correo de la lista                      │
│     └─► _on_mail_select() → _display_mail(mail_info)                │
│                                                                      │
│  2. Marcar como leído en servidor (si es INBOX y no leído)          │
│     if not mail_info['is_seen']:                                    │
│         imap_connection.store(mail_id, '+FLAGS', '\\Seen')          │
│         # Actualizar visualmente: quitar 🔵, poner gris             │
│                                                                      │
│  3. Actualizar encabezados                                          │
│     mail_from_label.config(text='De: marcos@psp.es')                │
│     mail_subject_label.config(text='Asunto: Test')                  │
│     mail_date_label.config(text='Fecha: 19/02/2026')                │
│                                                                      │
│  4. Procesar contenido multipart                                    │
│     if msg.is_multipart():                                          │
│         for part in msg.walk():                                     │
│             if content_type == 'text/plain':                        │
│                 body = part.get_payload(decode=True).decode()       │
│             elif part.get_filename():  # Adjunto                    │
│                 attachments.append({...})                           │
│                                                                      │
│  5. Mostrar cuerpo en Text widget                                   │
│     mail_body_text.delete('1.0', 'end')                             │
│     mail_body_text.insert('1.0', body)                              │
│                                                                      │
│  6. Mostrar imágenes inline (si PIL está disponible)                │
│     for att in images:                                              │
│         img = Image.open(BytesIO(att['data']))                      │
│         img.thumbnail((500, 500))  # Redimensionar                  │
│         photo = ImageTk.PhotoImage(img)                             │
│         mail_body_text.image_create('end', image=photo)             │
│                                                                      │
│  7. Mostrar otros adjuntos (PDFs, docs, etc.)                       │
│     for att in other_attachments:                                   │
│         # Frame con icono, nombre, tamaño y botón "💾 Guardar"      │
│         icon = '📄' if PDF else '📝' if Word else '📎'              │
│         Button(text='💾 Guardar', command=save_file)                │
│                                                                      │
└─────────────────────────────────────────────────────────────────────┘

🔵 Sistema de Indicadores Visuales

# Al cargar correos (líneas 1775-1790)
for mail in mail_list:
    if is_seen:
        # Correo leído
        display_text = f'    {from_addr[:27]} - {subject[:37]}'
        self.mail_listbox.insert('end', display_text)
        idx = self.mail_listbox.size() - 1
        self.mail_listbox.itemconfig(idx, 
            fg='#888888',           # Gris
            selectforeground='#666666'
        )
    else:
        # Correo NO leído
        display_text = f'🔵 {from_addr[:27]} - {subject[:37]}'
        self.mail_listbox.insert('end', display_text)
        idx = self.mail_listbox.size() - 1
        self.mail_listbox.itemconfig(idx, 
            fg='#000000',           # Negro
            selectforeground='#1a73e8'
        )

# Contador de no leídos
self.unread_count = sum(1 for m in self.mail_list if not m.get('is_seen', False))
self.unread_label.config(text=f'Correos sin leer: {self.unread_count}')
┌─────────────────────────────────────────────────────────────────────┐
│                  APARIENCIA VISUAL EN LISTBOX                        │
├─────────────────────────────────────────────────────────────────────┤
│                                                                      │
│  ┌───────────────────────────────────────────────────────────────┐  │
│  │ 📬 Bandeja de entrada                    Correos sin leer: 2  │  │
│  ├───────────────────────────────────────────────────────────────┤  │
│  │                                                               │  │
│  │ 🔵 marcos@psp.es - Test para grabación   ← NO LEÍDO (negro)  │  │
│  │ 🔵 user@example.com - Propuesta proyecto ← NO LEÍDO (negro)  │  │
│  │     admin@server.com - Notificación     ← LEÍDO (gris)       │  │
│  │     webmaster@test.org - Informe        ← LEÍDO (gris)       │  │
│  │                                                               │  │
│  └───────────────────────────────────────────────────────────────┘  │
│                                                                      │
│  Al hacer clic en el primero (🔵 NO LEÍDO):                         │
│  1. Se marca como leído en el servidor (STORE +FLAGS \Seen)         │
│  2. Se actualiza la visualización:                                  │
│     ├─ Quita el emoji 🔵                                            │
│     ├─ Cambia color a gris                                          │
│     └─ Decrementa contador: "Correos sin leer: 1"                  │
│                                                                      │
└─────────────────────────────────────────────────────────────────────┘

🛡️ Manejo de Errores y Log

# Función de log (líneas 4304-4315)
def _log(self, text: str) -> None:
    # Verificar si estamos en hilo principal
    if threading.current_thread() is not threading.main_thread():
        self.after(0, lambda t=text: self._log(t))
        return
    
    # Verificar si el widget notes existe
    if not hasattr(self, 'notes') or self.notes is None:
        print(f'[LOG] {text}')  # Consola durante inicialización
        return
    
    # Log normal en interfaz
    timestamp = datetime.datetime.now().strftime('%H:%M:%S')
    self.notes.insert('end', f'[{timestamp}] {text}\n')
    self.notes.see('end')
┌─────────────────────────────────────────────────────────────────────┐
│                     EJEMPLO DE LOG EN INTERFAZ                       │
├─────────────────────────────────────────────────────────────────────┤
│                                                                      │
│  ┌─────────────────────────────────────────────────────────────┐   │
│  │ 📝 Panel de Notas                                           │   │
│  ├─────────────────────────────────────────────────────────────┤   │
│  │ [19:28:43] Conectando a 10.10.0.101:143...                 │   │
│  │ [19:28:44] Conexión IMAP establecida                        │   │
│  │ [19:28:44] Carpetas IMAP disponibles: ['INBOX', 'Sent']    │   │
│  │ [19:28:44] Credenciales guardadas correctamente            │   │
│  │ [19:28:45] Cargando 8 correos...                           │   │
│  │ [19:28:46] 8 correos cargados (2 sin leer)                 │   │
│  │ [19:29:10] === CORREO SELECCIONADO #0: Test grabación ===  │   │
│  │ [19:29:10] Correo marcado como leído en el servidor        │   │
│  │ [19:29:10] >>> Actualizando encabezados                    │   │
│  │ [19:29:10] Texto plano encontrado: 245 caracteres          │   │
│  │ [19:29:10] Adjunto detectado: imagen.png (image/png)       │   │
│  │ [19:29:10] Imagen inline mostrada: imagen.png              │   │
│  │ [19:29:10] >>> _display_mail COMPLETADO OK                 │   │
│  └─────────────────────────────────────────────────────────────┘   │
│                                                                      │
└─────────────────────────────────────────────────────────────────────┘

📊 Resumen de Funciones Clave

Función Líneas Responsabilidad
_build_tab_correos() 632-850 Construir toda la interfaz del tab Correos
_load_mail_credentials() 1483-1525 Cargar credenciales de .mail_config.json
_save_mail_credentials() 1527-1559 Guardar credenciales con Base64
_connect_mail_server() 1561-1613 Conectar a IMAP, listar carpetas
_refresh_mail_list() 1635-1801 Cargar correos de INBOX con FETCH
_show_inbox() 1803-1811 Cambiar a bandeja de entrada
_show_sent() 1828-1880 Cambiar a carpeta de enviados
_on_mail_select() 1982-2053 Manejar clic en correo, marcar como leído
_display_mail() 2055-2337 Mostrar contenido, imágenes y adjuntos
_open_compose_window() 2353-2788 Abrir ventana de redacción
_send_mail_with_attachments() 2837-2977 Enviar correo por SMTP con adjuntos
_save_to_sent_folder() 2979-3033 Guardar copia en servidor IMAP

🔄 Flujo Completo de Usuario

┌─────────────────────────────────────────────────────────────────────┐
│               FLUJO COMPLETO: LEER Y ENVIAR CORREO                   │
├─────────────────────────────────────────────────────────────────────┤
│                                                                      │
│  1. INICIAR APLICACIÓN                                              │
│     └─► python3 app.py                                              │
│     └─► _load_mail_credentials() precarga usuario y contraseña     │
│                                                                      │
│  2. IR A TAB "CORREOS"                                              │
│     └─► _build_tab_correos() ya construyó la interfaz              │
│                                                                      │
│  3. CONECTAR                                                        │
│     └─► Clic en "🔗 Conectar"                                       │
│     └─► _connect_mail_server()                                      │
│         ├─ IMAP4('10.10.0.101', 143)                               │
│         ├─ login('marcos@psp.es', '1234')                          │
│         └─ list() → Muestra carpetas disponibles                   │
│     └─► _save_mail_credentials() si "Recordar" está marcado        │
│     └─► _refresh_mail_list() carga correos automáticamente         │
│                                                                      │
│  4. LEER CORREO                                                     │
│     └─► Clic en correo de la lista                                 │
│     └─► _on_mail_select()                                           │
│         ├─ store(id, '+FLAGS', '\\Seen') si no leído              │
│         └─ _display_mail(mail_info)                                 │
│             ├─ Actualiza encabezados                                │
│             ├─ Muestra cuerpo                                       │
│             ├─ Muestra imágenes inline                              │
│             └─ Muestra botones para guardar adjuntos                │
│                                                                      │
│  5. ENVIAR NUEVO CORREO                                             │
│     └─► Clic en "✉️ Nuevo correo"                                  │
│     └─► _open_compose_window()                                      │
│         ├─ Ventana emergente con campos                             │
│         ├─ Botón "📎 Adjuntar archivo"                             │
│         ├─ Soporte Ctrl+V para imágenes                            │
│         └─ Botón "📤 ENVIAR CORREO"                                │
│             ├─ Validar destinatarios (regex)                        │
│             ├─ Confirmar si múltiples                               │
│             ├─ _send_mail_with_attachments()                        │
│             │   ├─ Crear MIMEMultipart                              │
│             │   ├─ Adjuntar archivos                                │
│             │   └─ SMTP send_message()                              │
│             └─ _save_to_sent_folder()                               │
│                 └─ IMAP APPEND a 'Sent'                             │
│                                                                      │
│  6. VERIFICAR EN WEBMIN                                             │
│     └─► Abrir http://10.10.0.101:20000                             │
│     └─► Carpeta "Sent" → Correo aparece ahí                        │
│                                                                      │
└─────────────────────────────────────────────────────────────────────┘

🎨 Características del Dashboard

📊 Monitor del Sistema

  • Gráfico de CPU en línea temporal
  • Gráfico de memoria como área
  • Contador de hilos del proceso

🌤️ API del Tiempo (Jávea)

  • Temperatura actual y sensación térmica
  • Humedad y velocidad del viento
  • Descripción del clima

🛒 Análisis Wallapop

  • Extracción de información de anuncios
  • Headers personalizados para API
  • Resultados formateados

Sistema de Alarmas

  • Programación en minutos
  • Notificaciones visuales
  • Gestión de alarmas activas

🤝 Contribuir

  1. Fork del proyecto
  2. Crea tu Feature Branch (git checkout -b feature/NuevaFuncion)
  3. Commit tus cambios (git commit -m 'Add: Nueva función')
  4. Push a la rama (git push origin feature/NuevaFuncion)
  5. Abre un Pull Request

📝 Licencia

Este proyecto es de carácter educativo y fue desarrollado como parte del módulo de Programación de Servicios y Procesos.


Desarrollado con ❤️ por Marcos Ferrandiz

Proyecto 1º Evaluación - PSP (Programación de Servicios y Procesos)


Estado Versión Python