Compare commits

...

48 Commits

Author SHA1 Message Date
92e473fdfe Add script to README 2025-11-08 22:44:22 +01:00
6e97f2bea1 Add script secret_santa.py 2025-11-08 22:31:34 +01:00
fa1ee4dd64 feat: add local date in docker-compose files 2025-07-12 17:10:20 +02:00
b9884d6560 Comments in docker-compose 2025-05-22 22:57:42 +02:00
9245dbb9af Add compose in clima_bot 2025-05-22 21:07:19 +02:00
2c4f14a886 Update mareas_bot 2025-02-21 21:31:58 +01:00
1fa360a109 mareas_bot: Fix errors. Update dependencies. Add enhancements 2025-02-19 23:27:15 +01:00
a2badd511d Fusionar feature/tablamareasbot en main 2025-02-19 21:24:50 +01:00
68b9bf32a3 Add rubiks cube solver 2024-09-01 21:48:24 +02:00
21c7e1e6c6 Add enhanced urlf4ck3r 2024-09-01 19:18:39 +02:00
dc9e81f06e Update Ollama bot 2024-08-18 21:15:33 +02:00
dd48618093 Update Ollama bot 2024-08-18 20:18:59 +02:00
3e47d5a7ee Add Ollama bot 2024-08-18 20:16:34 +02:00
d59e31205b Update chatgpt bot 2024-08-18 19:43:44 +02:00
e2767e99af Update chatgpt bot 2024-08-17 21:11:58 +02:00
65468e4115 Add chatgpt bot 2024-08-17 21:08:24 +02:00
4f2264748e Update README 2024-08-15 19:43:39 +02:00
a73e8a0222 Update diagrams test README 2024-08-13 01:46:47 +02:00
84a2519f6c Add diagrams test 2024-08-13 01:43:53 +02:00
88a26dae34 Update .gitignore 2024-08-13 01:30:37 +02:00
b672828792 Add sonarqube test 2024-08-09 02:22:44 +02:00
40fad6bae8 Add prometheus_grafana test 2024-08-09 01:20:19 +02:00
17a7e04180 Update README infra 2024-08-08 00:03:17 +02:00
6cb79e3418 Add structure prometheus_grafana & sonarqube 2024-08-08 00:01:27 +02:00
28f9bb389d Add kafka test 2024-08-07 23:59:44 +02:00
f525beaf11 Update .gitignore 2024-08-07 23:59:06 +02:00
4756d756ac Update elastic stack test 2024-08-07 19:56:05 +02:00
89959d29ee Update rabbitmq test 2024-08-06 22:05:20 +02:00
898da84dc3 Update rabbitmq test 2024-08-05 23:37:04 +02:00
bba89794a2 Update .gitignore 2024-08-05 23:36:38 +02:00
78552c227a Update rabbitmq test 2024-08-04 20:56:58 +02:00
9a60b44822 Update .gitignore 2024-08-04 20:55:49 +02:00
e856e99ac3 Update READMEs 2024-08-02 20:10:03 +02:00
7102b86e6e Update README catch-all 2024-08-02 20:07:49 +02:00
50513ff393 Add rabbitmq test 2024-07-31 23:27:53 +02:00
0daba91bbb Update README infra test 2024-07-31 13:38:57 +02:00
a7cefe06d0 Add rabbitmq test 2024-07-30 23:55:17 +02:00
ecd77967a0 Add movies trivial Bot for Telegram 2024-07-30 00:43:20 +02:00
be39d5b1d3 Re-structure infra folder 2024-07-22 21:09:54 +02:00
d7640c2a52 Update movie_bot 2024-07-22 20:55:34 +02:00
2b946f3327 Update README Bots Telegram 2024-07-17 21:17:07 +02:00
5ff0fa4f2a Update movie Bot for Telegram 2024-07-17 18:44:16 +02:00
a421d3b292 Add movie Bot for Telegram 2024-07-17 01:10:27 +02:00
84e3344d49 Add rss Bot for Telegram 2024-07-16 00:48:20 +02:00
56ac284176 Update .gitignore 2024-07-15 23:59:31 +02:00
94e6ca1027 Update README 2024-07-15 23:41:28 +02:00
819aaaa1f5 Add Weather Bot for Telegram 2024-07-14 19:20:40 +02:00
d8ca020c98 Update README 2024-07-13 14:20:03 +02:00
164 changed files with 12957 additions and 23 deletions

15
.gitignore vendored
View File

@@ -127,9 +127,11 @@ celerybeat.pid
# Environments
.env
*.env
.venv
env/
venv/
myenv/
ENV/
env.bak/
venv.bak/
@@ -179,8 +181,19 @@ cython_debug/
mitmproxy/
# Logs del bot telegram
# Logs
bot.log*
logs/
*.log
# Ignore database files
rss.db
# Ignore vscode settings
.vscode
# Ignore volume files
rabbitmq/
rabbitmq/*
*_data
*_data/*

View File

@@ -8,6 +8,9 @@ services:
- .env
ports:
- '5000:5000'
volumes:
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro
depends_on:
- redis
@@ -16,4 +19,7 @@ services:
container_name: redis-python
ports:
- '6379:6379'
volumes:
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro

View File

@@ -0,0 +1,50 @@
#!/usr/bin/env python
import pika
import sys
import os
def main():
# Establecer la conexión con el servidor RabbitMQ
connection = pika.BlockingConnection(
pika.ConnectionParameters('localhost'))
channel = connection.channel()
# Asegurarnos de que la cola existe
try:
channel.queue_declare(queue='hola')
except pika.exceptions.ChannelClosedByBroker:
print(' [!] Error al crear la cola. ¿Está el servidor RabbitMQ corriendo?')
sys.exit(1)
except e:
print(f' [!] Error: {e}')
sys.exit(1)
# Recibir mensajes de la cola es un poco más complejo que enviarlos.
# Funciona suscribiendo una función callback a una cola.
# Cada vez que recibimos un mensaje, esta función callback es llamada por la
# librería Pika.
# En nuestro caso esta función imprimirá en pantalla el contenido del mensaje.
def callback(ch, method, properties, body):
print(f" [+] Recibido \"{body.decode()}\"")
# Ahora indicamos a RabbitMQ que comience a consumir mensajes de la cola.
channel.basic_consume(
queue='hola', auto_ack=True, on_message_callback=callback
)
# Bucle infinito que espera mensajes de la cola y llama a la función callback
print(' [i] Esperando mensajes. Para salir presiona CTRL+C')
channel.start_consuming()
if __name__ == '__main__':
try:
main()
except KeyboardInterrupt:
print(' [!] Saliendo')
try:
sys.exit(0)
except SystemExit:
os._exit(0)

View File

@@ -0,0 +1,20 @@
#!/usr/bin/env python
import pika
# Establecer la conexión con el servidor RabbitMQ
connection = pika.BlockingConnection(pika.ConnectionParameters('localhost'))
channel = connection.channel()
# Crear una cola llamada 'hola'
channel.queue_declare(queue='hola')
# Enviar mensaje
channel.basic_publish(
exchange='', routing_key='hola', body='¡Hola Mundo!'
)
# Traza del envío
print(" [+] Enviado 'Hola Mundo!'")
# Cerrar la conexión
connection.close()

View File

@@ -0,0 +1,28 @@
#!/usr/bin/env python
import pika
import sys
# Establecer la conexión con el servidor RabbitMQ
connection = pika.BlockingConnection(pika.ConnectionParameters('localhost'))
channel = connection.channel()
# Crear una cola llamada 'hola'
channel.queue_declare(queue='task_queue', durable=True)
# Mensaje a enviar
message = ' '.join(sys.argv[1:]) or "¡Hola mundo!"
# Enviar mensaje
channel.basic_publish(
exchange='', routing_key='task_queue', body=message,
properties=pika.BasicProperties(
delivery_mode=pika.DeliveryMode.Persistent
)
)
# Traza del envío
print(f" [+] Enviado '{message}'")
# Cerrar la conexión
connection.close()

View File

@@ -0,0 +1,50 @@
#!/usr/bin/env python
import pika
import sys
import os
import time
def main():
# Establecer la conexión con el servidor RabbitMQ
connection = pika.BlockingConnection(
pika.ConnectionParameters('localhost'))
channel = connection.channel()
# Comprobar si la cola existe
try:
channel.queue_declare(queue='task_queue', durable=True)
except pika.exceptions.ChannelClosedByBroker:
print(' [!] Error al crear la cola. ¿Está el servidor RabbitMQ corriendo?')
sys.exit(1)
except e:
print(f' [!] Error: {e}')
sys.exit(1)
# Consumir mensajes
def callback(ch, method, properties, body):
print(f"[+] Recibido {body.decode()}")
time.sleep(body.count(b'.'))
print("[i] Hecho")
ch.basic_ack(delivery_tag=method.delivery_tag)
# Consumir mensajes de la cola 'task_queue'
channel.basic_qos(prefetch_count=1)
channel.basic_consume(queue='task_queue', on_message_callback=callback)
# Iniciar la escucha
print('[i] Esperando mensajes. Para salir presiona CTRL+C')
channel.start_consuming()
if __name__ == '__main__':
try:
main()
except KeyboardInterrupt:
print(' [!] Saliendo')
try:
sys.exit(0)
except SystemExit:
os._exit(0)

View File

@@ -0,0 +1,100 @@
#!/usr/bin/env python
import argparse
import logging
import pika
import sys
import threading
import time
from datetime import datetime
from random import randint
def main():
# Configuración de logging
logging.basicConfig(level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s')
# Configuración de argparse para manejar argumentos de línea de comandos
parser = argparse.ArgumentParser(
description="Envía un mensaje al intercambio de logs en RabbitMQ."
)
parser.add_argument(
'message',
nargs='*',
help='El mensaje a enviar. Si no se especifica, se enviará "Traza de log"'
)
parser.add_argument(
'--host', default='localhost',
help='El host de RabbitMQ (default: localhost)'
)
parser.add_argument(
'--user', default='invent',
help='El usuario de RabbitMQ (default: invent)'
)
parser.add_argument(
'--password', default='123456',
help='La contraseña de RabbitMQ (default: 123456)'
)
args = parser.parse_args()
# Crear el mensaje base
base_message = ' '.join(args.message) or "Traza de log"
stop_sending = threading.Event()
def send_messages():
credentials = pika.PlainCredentials(args.user, args.password)
try:
# Establecer conexión con RabbitMQ
connection = pika.BlockingConnection(
pika.ConnectionParameters(
host=args.host, credentials=credentials)
)
channel = connection.channel()
# Declarar el intercambio de tipo 'fanout'
channel.exchange_declare(exchange='logs', exchange_type='fanout')
while not stop_sending.is_set():
# Crear mensaje con fecha y hora actual
current_time = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
num_logs = randint(1, 1000)
message = f"{current_time} - CUSTOM_LOG - {base_message}: Número aleatorio: {num_logs}"
# Publicar el mensaje en el intercambio
channel.basic_publish(
exchange='logs', routing_key='', body=message)
logging.info(f"[+] Sent {message}")
# Esperar 5 segundos antes de enviar el siguiente mensaje
time.sleep(5)
except pika.exceptions.AMQPConnectionError as e:
logging.error(f"[!] No se pudo conectar a RabbitMQ: {e}")
except Exception as e:
logging.error(f"[!] Ocurrió un error: {e}")
finally:
# Cerrar la conexión
if 'connection' in locals() and connection.is_open:
connection.close()
# Iniciar el hilo que enviará mensajes
sender_thread = threading.Thread(target=send_messages)
sender_thread.start()
try:
# Esperar a que el usuario introduzca 'q' para detener el envío de mensajes
while True:
user_input = input()
if user_input.strip().lower() == 'q':
stop_sending.set()
sender_thread.join()
break
except KeyboardInterrupt:
stop_sending.set()
sender_thread.join()
logging.info("Interrupción del usuario recibida. Saliendo...")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,75 @@
#!/usr/bin/env python
import pika
import logging
import argparse
def main():
# Configuración de logging
logging.basicConfig(level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s')
# Configuración de argparse para manejar argumentos de línea de comandos
parser = argparse.ArgumentParser(
description="Escucha mensajes del intercambio de logs en RabbitMQ."
)
parser.add_argument(
'--host', default='localhost',
help='El host de RabbitMQ (default: localhost)'
)
parser.add_argument(
'--user', default='invent',
help='El usuario de RabbitMQ (default: invent)'
)
parser.add_argument(
'--password', default='123456',
help='La contraseña de RabbitMQ (default: 123456)'
)
args = parser.parse_args()
credentials = pika.PlainCredentials(args.user, args.password)
try:
# Establecer conexión con RabbitMQ
connection = pika.BlockingConnection(
pika.ConnectionParameters(host=args.host, credentials=credentials)
)
channel = connection.channel()
# Declarar el intercambio de tipo 'fanout'
channel.exchange_declare(exchange='logs', exchange_type='fanout')
# Declarar una cola exclusiva para el consumidor
result = channel.queue_declare(queue='', exclusive=True)
queue_name = result.method.queue
# Enlazar la cola al intercambio de logs
channel.queue_bind(exchange='logs', queue=queue_name)
logging.info(' [*] Waiting for logs. To exit press CTRL+C')
# Función de callback para manejar mensajes entrantes
def callback(ch, method, properties, body):
logging.info(f" [x] Received: {body.decode()}")
# Configurar el consumidor
channel.basic_consume(
queue=queue_name, on_message_callback=callback, auto_ack=True)
# Iniciar el bucle de consumo
channel.start_consuming()
except pika.exceptions.AMQPConnectionError as e:
logging.error(f"[!] No se pudo conectar a RabbitMQ: {e}")
except KeyboardInterrupt:
logging.info("Interrupción del usuario recibida. Saliendo...")
except Exception as e:
logging.error(f"[!] Ocurrió un error: {e}")
finally:
# Cerrar la conexión si está abierta
if 'connection' in locals() and connection.is_open:
connection.close()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,141 @@
#!/usr/bin/env python
import pika
import sys
import argparse
import time
import random
import datetime
import threading
import signal
def establish_connection(host: str, port: int):
"""Establece la conexión con RabbitMQ."""
try:
connection = pika.BlockingConnection(
pika.ConnectionParameters(host=host, port=port)
)
return connection
except pika.exceptions.AMQPConnectionError as e:
print(f"\n[!] Error al conectar con RabbitMQ: {e}")
sys.exit(1)
def publish_message(channel, exchange: str, severity: str, message: str):
"""Publica un mensaje en el intercambio especificado."""
try:
channel.basic_publish(
exchange=exchange,
routing_key=severity,
body=message
)
print(f"[i] Sent {severity}:{message}")
except Exception as e:
print(f"\n[!] Error al enviar mensaje: {e}")
sys.exit(1)
def send_messages_periodically(channel, exchange_name, severity, base_message):
"""Envía mensajes periódicamente cada 5 segundos hasta que se detenga."""
try:
while not stop_event.is_set():
# Generar número aleatorio para identificar el envío
random_number = random.randint(1000, 9999)
# Obtener la fecha y hora actual
current_time = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
# Crear el mensaje con la fecha, hora y número aleatorio
message = f"{base_message} [{random_number}] at {current_time}"
# Publicar el mensaje
publish_message(channel, exchange_name, severity, message)
# Esperar 5 segundos antes de enviar el siguiente mensaje
# Comprobación del evento de parada durante la espera
for _ in range(50):
if stop_event.is_set():
break
time.sleep(0.1)
except KeyboardInterrupt:
stop_event.set()
def user_input_thread():
"""Hilo para capturar la entrada del usuario."""
while not stop_event.is_set():
user_input = input()
if user_input.strip().lower() == 'q':
stop_event.set()
def signal_handler(sig, frame):
"""Manejador de señal para terminar el programa."""
stop_event.set()
def main():
parser = argparse.ArgumentParser(
description='Envía mensajes a RabbitMQ usando un intercambio directo.')
parser.add_argument('--host', type=str, default='localhost',
help='El host de RabbitMQ (por defecto: localhost)')
parser.add_argument('--port', type=int, default=5672,
help='El puerto de RabbitMQ (por defecto: 5672)')
parser.add_argument('severity', type=str, nargs='?',
default='info', help='La severidad del mensaje')
parser.add_argument('message', type=str, nargs='*',
default=['Hello', 'World!'], help='El mensaje a enviar')
args = parser.parse_args()
# Establecer conexión
connection = establish_connection(args.host, args.port)
channel = connection.channel()
# Declarar intercambio
exchange_name = 'direct_logs'
channel.exchange_declare(exchange=exchange_name, exchange_type='direct')
# Mensaje base
base_message = ' '.join(args.message)
# Crear un hilo para el envío periódico de mensajes
send_thread = threading.Thread(target=send_messages_periodically, args=(
channel, exchange_name, args.severity, base_message))
send_thread.start()
# Crear un hilo para capturar la entrada del usuario
input_thread = threading.Thread(target=user_input_thread)
input_thread.start()
print("Presiona 'q' para detener el envío de mensajes.")
# Esperar a que los hilos terminen antes de cerrar la conexión
send_thread.join()
input_thread.join()
# Cerrar conexión
connection.close()
print("Conexión cerrada. Programa terminado.")
if __name__ == '__main__':
# Crear un evento para detener el envío de mensajes
stop_event = threading.Event()
# Configurar el manejador de señales para terminar el programa
signal.signal(signal.SIGINT, signal_handler)
main()

View File

@@ -0,0 +1,86 @@
#!/usr/bin/env python
import pika
import sys
import argparse
import signal
def establish_connection(host: str, port: int):
"""Establece la conexión con RabbitMQ."""
try:
connection = pika.BlockingConnection(
pika.ConnectionParameters(host=host, port=port)
)
return connection
except pika.exceptions.AMQPConnectionError as e:
print(f"\n[!] Error al conectar con RabbitMQ: {e}")
sys.exit(1)
def declare_exchange_and_queue(channel, exchange_name: str, severities: list):
"""Declara el intercambio y las colas necesarias."""
channel.exchange_declare(exchange=exchange_name, exchange_type='direct')
# Crear una cola exclusiva
result = channel.queue_declare(queue='', exclusive=True)
queue_name = result.method.queue
# Vincular la cola al intercambio para cada severidad especificada
for severity in severities:
channel.queue_bind(
exchange=exchange_name, queue=queue_name, routing_key=severity)
return queue_name
def callback(ch, method, properties, body):
"""Función de callback para procesar mensajes recibidos."""
print(f" [i] {method.routing_key}:{body.decode()}")
def main():
parser = argparse.ArgumentParser(
description='Recibe mensajes de RabbitMQ usando un intercambio directo.')
parser.add_argument('--host', type=str, default='localhost',
help='El host de RabbitMQ (por defecto: localhost)')
parser.add_argument('--port', type=int, default=5672,
help='El puerto de RabbitMQ (por defecto: 5672)')
parser.add_argument('severities', metavar='S', type=str, nargs='+',
help='Lista de severidades a recibir (info, warning, error)')
args = parser.parse_args()
# Establecer conexión
connection = establish_connection(args.host, args.port)
channel = connection.channel()
# Declarar el intercambio y las colas
exchange_name = 'direct_logs'
queue_name = declare_exchange_and_queue(
channel, exchange_name, args.severities)
print('\n[!] Esperando logs. Para salir presionar CTRL+C')
# Iniciar el consumo de mensajes
channel.basic_consume(
queue=queue_name, on_message_callback=callback, auto_ack=True)
# Manejar Ctrl+C para detener el programa
try:
channel.start_consuming()
except KeyboardInterrupt:
print("\nInterrupción recibida. Cerrando conexión...")
connection.close()
print("\n[!] Conexión cerrada. Programa terminado.")
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,114 @@
#!/usr/bin/env python
import pika
import argparse
import logging
import time
import random
from datetime import datetime
# Configuración del logger
logging.basicConfig(level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s')
def parse_arguments():
"""
Analiza los argumentos de línea de comandos utilizando argparse.
Devuelve un objeto con los argumentos proporcionados por el usuario.
"""
parser = argparse.ArgumentParser(
description='Enviar mensajes a un intercambio de tipo "topic" en RabbitMQ.')
parser.add_argument(
'routing_key', help='La clave de enrutamiento para el mensaje.')
parser.add_argument(
'message', nargs='*', default=['Hola', 'Mundo!'], help='El mensaje base a enviar.')
return parser.parse_args()
def establish_connection():
"""
Establece una conexión con RabbitMQ.
Retorna el objeto de conexión si es exitoso.
Salida del programa si hay un error de conexión.
"""
try:
connection = pika.BlockingConnection(
pika.ConnectionParameters(host='localhost'))
return connection
except pika.exceptions.AMQPConnectionError as e:
logging.error('\n[!] Error al conectar con RabbitMQ: %s', e)
sys.exit(1)
def declare_exchange(channel):
"""
Declara el intercambio de tipo 'topic'.
Salida del programa si hay un error al declarar el intercambio.
"""
try:
channel.exchange_declare(exchange='topic_logs', exchange_type='topic')
except pika.exceptions.ChannelError as e:
logging.error('\n[!] Error al declarar el intercambio: %s', e)
sys.exit(1)
def generate_message(base_message):
"""
Genera un mensaje único que incluye un número aleatorio, fecha y hora actual.
"""
random_id = random.randint(
1000, 9999) # Genera un ID aleatorio de 4 dígitos
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S') # Fecha y hora actual
return f"{timestamp} - ({random_id}): {base_message}"
def publish_message(channel, routing_key, message):
"""
Publica un mensaje en el intercambio declarado.
"""
channel.basic_publish(exchange='topic_logs',
routing_key=routing_key, body=message)
logging.info(' [+] Enviado %s:%s', routing_key, message)
def main():
"""
Función principal que orquesta la ejecución del script.
"""
# Parsear los argumentos de línea de comandos
args = parse_arguments()
routing_key = args.routing_key
base_message = ' '.join(args.message)
# Establecer conexión y publicar mensaje cada 5 segundos
with establish_connection() as connection:
channel = connection.channel()
declare_exchange(channel)
try:
while True:
# Generar y publicar el mensaje
message = generate_message(base_message)
publish_message(channel, routing_key, message)
# Espera de 5 segundos antes de enviar el siguiente mensaje
time.sleep(5)
except KeyboardInterrupt:
logging.info("\n[!] Interrupción del usuario. Terminando...")
except Exception as e:
logging.error("\n[!] Se produjo un error inesperado: %s", e)
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,122 @@
#!/usr/bin/env python
import pika
import argparse
import logging
import sys
# Configuración del logger
logging.basicConfig(level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s')
def parse_arguments():
"""
Analiza los argumentos de línea de comandos utilizando argparse.
Devuelve un objeto con los argumentos proporcionados por el usuario.
"""
parser = argparse.ArgumentParser(
description='Recibe mensajes de un intercambio de tipo "topic" en RabbitMQ.')
parser.add_argument('binding_keys', nargs='+',
help='Lista de claves de enlace para filtrar los mensajes.')
return parser.parse_args()
def establish_connection():
"""
Establece una conexión con RabbitMQ.
Retorna el objeto de conexión si es exitoso.
Salida del programa si hay un error de conexión.
"""
try:
connection = pika.BlockingConnection(
pika.ConnectionParameters(host='localhost'))
return connection
except pika.exceptions.AMQPConnectionError as e:
logging.error('[!] Error al conectar con RabbitMQ: %s', e)
sys.exit(1)
def declare_exchange_and_queue(channel):
"""
Declara el intercambio de tipo 'topic' y una cola exclusiva.
Retorna el nombre de la cola creada.
"""
try:
channel.exchange_declare(exchange='topic_logs', exchange_type='topic')
result = channel.queue_declare('', exclusive=True)
return result.method.queue
except pika.exceptions.ChannelError as e:
logging.error('[!] Error al declarar el intercambio o la cola: %s', e)
sys.exit(1)
def bind_queue(channel, queue_name, binding_keys):
"""
Vincula la cola al intercambio con las claves de enlace proporcionadas.
"""
for binding_key in binding_keys:
try:
channel.queue_bind(exchange='topic_logs',
queue=queue_name, routing_key=binding_key)
logging.info(
' [i] Cola vinculada con clave de enlace: %s', binding_key)
except pika.exceptions.ChannelError as e:
logging.error(
'[!] Error al vincular la cola con la clave de enlace %s: %s', binding_key, e)
sys.exit(1)
def callback(ch, method, properties, body):
"""
Función de callback que maneja los mensajes recibidos.
"""
logging.info(' [+] %s: %s', method.routing_key.upper(), body.decode())
def start_consuming(channel, queue_name):
"""
Inicia la recepción de mensajes desde la cola especificada.
"""
channel.basic_consume(
queue=queue_name, on_message_callback=callback, auto_ack=True)
logging.info(' [i] Esperando mensajes. Para salir presione CTRL+C')
try:
channel.start_consuming()
except KeyboardInterrupt:
logging.info(' [!] Interrupción del usuario. Terminando...')
channel.stop_consuming()
def main():
"""
Función principal que orquesta la ejecución del script.
"""
# Parsear los argumentos de línea de comandos
args = parse_arguments()
binding_keys = args.binding_keys
# Establecer conexión, declarar intercambio y cola, vincular y comenzar a consumir
with establish_connection() as connection:
channel = connection.channel()
queue_name = declare_exchange_and_queue(channel)
bind_queue(channel, queue_name, binding_keys)
start_consuming(channel, queue_name)
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,113 @@
#!/usr/bin/env python
import pika
import uuid
import sys
import argparse
class FibonacciRpcClient:
def __init__(self, host='localhost'):
# Establece la conexión con RabbitMQ
try:
self.connection = pika.BlockingConnection(
pika.ConnectionParameters(host=host)
)
except pika.exceptions.AMQPConnectionError as e:
print(f"[!] Error al conectar con RabbitMQ: {e}")
sys.exit(1)
# Crea un canal de comunicación
self.channel = self.connection.channel()
# Declara una cola exclusiva para recibir las respuestas del servidor RPC
result = self.channel.queue_declare(queue='', exclusive=True)
self.callback_queue = result.method.queue
# Configura el canal para consumir mensajes de la cola de respuestas
self.channel.basic_consume(
queue=self.callback_queue,
on_message_callback=self.on_response,
auto_ack=True
)
# Inicializa las variables para manejar la respuesta RPC
self.response = None
self.corr_id = None
def on_response(self, ch, method, props, body):
"""Callback ejecutado al recibir una respuesta del servidor RPC."""
# Verifica si el ID de correlación coincide con el esperado
if self.corr_id == props.correlation_id:
self.response = body
def call(self, n):
"""
Envía una solicitud RPC para calcular el número de Fibonacci de n.
:param n: Número entero no negativo para calcular su Fibonacci.
:return: Resultado del cálculo de Fibonacci.
:raises ValueError: Si n no es un número entero no negativo.
"""
# Verifica que la entrada sea un número entero no negativo
if not isinstance(n, int) or n < 0:
raise ValueError("[!] El argumento debe ser un entero no negativo.")
# Resetea la respuesta y genera un nuevo ID de correlación único
self.response = None
self.corr_id = str(uuid.uuid4())
# Publica un mensaje en la cola 'rpc_queue' con las propiedades necesarias
self.channel.basic_publish(
exchange='',
routing_key='rpc_queue',
properties=pika.BasicProperties(
reply_to=self.callback_queue, # Cola de retorno para recibir la respuesta
correlation_id=self.corr_id, # ID único para correlacionar la respuesta
),
body=str(n)
)
# Espera hasta que se reciba la respuesta del servidor
while self.response is None:
self.connection.process_data_events(time_limit=None)
# Devuelve la respuesta convertida a un entero
return int(self.response)
def close(self):
"""Cierra la conexión con el servidor de RabbitMQ."""
self.connection.close()
if __name__ == "__main__":
# Configura el analizador de argumentos para recibir el número de Fibonacci
parser = argparse.ArgumentParser(
description="Calcula números de Fibonacci mediante RPC.")
parser.add_argument(
"number",
type=int,
help="Número entero no negativo para calcular su Fibonacci."
)
args = parser.parse_args()
# Inicializa el cliente RPC de Fibonacci
fibonacci_rpc = FibonacciRpcClient()
# Número para el cual se desea calcular el Fibonacci
n = args.number
try:
print(f" [+] Solicitando fib({n})")
# Realiza la llamada RPC y obtiene el resultado
response = fibonacci_rpc.call(n)
print(f" [+] Resultado fib({n}) = {response}")
except ValueError as e:
print(f"[!] Error de valor: {e}")
except Exception as e:
print(f"[!] Error inesperado: {e}")
finally:
# Cierra la conexión del cliente RPC
fibonacci_rpc.close()

View File

@@ -0,0 +1,92 @@
#!/usr/bin/env python
import pika
from functools import lru_cache
import time
# Establecemos la conexión a RabbitMQ
def create_connection():
try:
connection = pika.BlockingConnection(
pika.ConnectionParameters(host='localhost')
)
return connection
except pika.exceptions.AMQPConnectionError as e:
print(f"[!] Error al conectar a RabbitMQ: {e}")
return None
# Conexión a RabbitMQ
connection = create_connection()
if not connection:
exit(1)
# Canal de comunicación con RabbitMQ
channel = connection.channel()
# Declaración de la cola 'rpc_queue'
channel.queue_declare(queue='rpc_queue')
# Implementación mejorada de Fibonacci con memoización
@lru_cache(maxsize=None)
def fib(n):
if n < 0:
raise ValueError("\n[!] El número no puede ser negativo.")
elif n == 0:
return 0
elif n == 1:
return 1
else:
return fib(n - 1) + fib(n - 2)
# Callback para procesar solicitudes RPC
def on_request(ch, method, props, body):
try:
n = int(body)
print(f" [+] Calculando fib({n})")
# Simula un tiempo de procesamiento de 2 segundos
time.sleep(2)
response = fib(n)
print(f" [+] Resultado: {response}")
except ValueError as e:
response = f"\n[!] Error: {str(e)}"
except Exception as e:
response = f"\n[!] Error inesperado: {str(e)}"
# Publicar la respuesta al cliente
ch.basic_publish(
exchange='',
routing_key=props.reply_to,
properties=pika.BasicProperties(correlation_id=props.correlation_id),
body=str(response)
)
# Confirmar la recepción del mensaje
ch.basic_ack(delivery_tag=method.delivery_tag)
# Configuración de calidad de servicio y consumo de mensajes
channel.basic_qos(prefetch_count=1)
channel.basic_consume(queue='rpc_queue', on_message_callback=on_request)
print("\n[i] Esperando solicitudes RPC")
channel.start_consuming()

View File

@@ -0,0 +1,462 @@
# Pruebas con rabbitmq
*Índice de contenidos:*
- [Pruebas con rabbitmq](#pruebas-con-rabbitmq)
- [Despliegue rabbitmq con docker](#despliegue-rabbitmq-con-docker)
- [Pruebas](#pruebas)
- [Hello World](#hello-world)
- [Work Queues](#work-queues)
- [Publish/Subscribe](#publishsubscribe)
- [Routing](#routing)
- [Enlaces](#enlaces)
- [Intercambio Directo](#intercambio-directo)
- [Múltiples Enlaces](#múltiples-enlaces)
- [Emisión de Logs](#emisión-de-logs)
- [Suscripción](#suscripción)
- [Código de Ejemplo](#código-de-ejemplo)
- [Ejemplos de Uso](#ejemplos-de-uso)
- [Topics (Próximamente)](#topics-próximamente)
- [¿Qué es un intercambio de temas?](#qué-es-un-intercambio-de-temas)
- [Casos especiales de `binding_key`](#casos-especiales-de-binding_key)
- [Ejemplo de uso](#ejemplo-de-uso)
- [Características del intercambio de temas](#características-del-intercambio-de-temas)
- [Implementación del sistema de registro](#implementación-del-sistema-de-registro)
- [RPC](#rpc)
- [Interfaz del cliente](#interfaz-del-cliente)
- [Cola de retorno (*Callback queue*)](#cola-de-retorno-callback-queue)
- [ID de correlación (*Correlation id*)](#id-de-correlación-correlation-id)
- [Resumen](#resumen)
- [Poniéndolo todo junto](#poniéndolo-todo-junto)
## Despliegue rabbitmq con docker
Para desplegar RabbitMQ rápidamente, puedes usar Docker. Ejecuta el siguiente comando para iniciar un contenedor con RabbitMQ y su consola de gestión:
```bash
docker run -d --hostname my-rabbit --name some-rabbit -p 8080:15672 -p 5672:5672 rabbitmq:3-management
```
Si prefieres usar docker-compose, utiliza el archivo [docker-compose.yaml](./docker-compose.yaml) con el siguiente comando:
```bash
docker compose up -d
```
## Pruebas
Pruebas extraídas de los tutoriales de la [documentación oficial de RabbitMQ](https://www.rabbitmq.com/tutorials#queue-tutorials).
### Hello World
Lo más sencillo que hace algo.
Tenemos que diferenciar algunos conceptos:
- **Producer**: es el que envía mensajes.
- **Queue**: es donde se almacenan los mensajes.
- **Consumer**: es el que recibe mensajes.
![](https://pica.zhimg.com/v2-35910cd84c7a62ad06cd4621b3d0523b_720w.jpg)
Vamos a programar un producer y un consumer en Python.
RabbitMQ habla múltiples protocolos. Este tutorial utiliza AMQP 0-9-1, que es un protocolo abierto de propósito general para mensajería.
Hay un gran número de clientes para RabbitMQ en muchos idiomas diferentes. En esta serie de tutoriales vamos a usar Pika 1.0.0, que es el cliente Python recomendado por el equipo de RabbitMQ. Para instalarlo puedes usar la herramienta de gestión de paquetes pip:
```bash
pip install pika --upgrade
```
Nuestro primer programa [send.py](./hello-world/send.py) será el producer que enviará un único mensaje a la cola. Este script también crea la cola `hola`.
El programa [receive.py](./hello-world/receive.py) será el consumer que recibirá mensajes de la cola y los imprimirá en pantalla.
Desde la instalación de rabbitmq puedes ver qué colas tiene RabbitMQ y cuántos mensajes hay en ellas con rabbitmqctl:
```bash
sudo rabbitmqctl list_queues
```
Antes tendrás que entrar en el contenedor de rabbitmq:
```bash
docker exec -it rabbitmq-server bash
```
Ahora, para probarlo, ejecuta el producer y el consumer en dos terminales diferentes:
```bash
cd hello-world
python send.py
python receive.py
```
### Work Queues
Reparto de tareas entre los trabajadores (el modelo de consumidores competidores).
![](https://mail.bogotobogo.com/python/images/RabbitMQ_Celery/WorkQueues/WorkQueues.png)
Antes hemos enviado un mensaje que contenía `¡Hola Mundo!`. Ahora enviaremos cadenas que representan tareas complejas. No tenemos una tarea del mundo real, como imágenes para ser redimensionadas o archivos pdf para ser renderizados, así que vamos a fingir que estamos ocupados usando la función `time.sleep()`. Tomaremos el número de puntos de la cadena como su complejidad; cada punto representará un segundo de «trabajo». Por ejemplo, una tarea falsa descrita por Hola... tardará tres segundos.
Vamos a modificar el anterior send.py para permitir el envío de mensajes arbitrarios desde la línea de comando. Le llamaremos [new_task.py](./02work-queues/new_task.py).
También modificaremos receive.py para simular un segundo trabajao por cada punto en el cuerpo del mensaje. Como sacará mensajes de la cola y realizará la tarea le llamaremos [worker.py](./02work-queues/worker.py).
Ahora, si ejecutamos dos veces o más el script worker.py, veremos cómo se reparten las tareas entre los dos consumidores.
En dos terminales distintas:
```bash
cd 02work-queues
python worker.py
```
Y en la tercera terminal enviaremos trabajos:
```bash
python new_task.py Primer mensaje.
python new_task.py Segundo mensaje..
python new_task.py Tercer mensaje...
python new_task.py Cuarto mensaje....
python new_task.py Quinto mensaje.....
```
Por defecto, RabbitMQ enviará cada mensaje al siguiente consumidor, en secuencia. Por término medio, cada consumidor recibirá el mismo número de mensajes. Esta forma de distribuir mensajes se llama round-robin.
Para asegurarse de que un mensaje nunca se pierde, RabbitMQ soporta acuses de recibo de mensajes. Un ack(nowledgement) es enviado de vuelta por el consumidor para decirle a RabbitMQ que un mensaje en particular ha sido recibido, procesado y que RabbitMQ es libre de borrarlo.
> Apunte: `ack` es una abreviatura de acknowledgement (reconocimiento). En el caso de que un consumidor muera (su conexión se cierre, por ejemplo) sin enviar un ack, RabbitMQ entenderá que no ha procesado el mensaje y lo reenviará a otro consumidor. Si hay otros consumidores conectados a la cola, se les enviará el mensaje.
**Acuse de recibo olvidado**
Es un error común olvidar el basic_ack. Los mensajes se volverán a entregar cuando tu cliente salga (lo que puede parecer una redistribución aleatoria), pero RabbitMQ consumirá cada vez más memoria ya que no será capaz de liberar ningún mensaje no empaquetado.
Para depurar este tipo de errores puedes usar rabbitmqctl para imprimir el campo messages_unacknowledged:
```bash
sudo rabbitmqctl list_queues name messages_ready messages_unacknowledged
```
### Publish/Subscribe
A diferencia de las colas de trabajo, donde cada tarea se entrega a un solo trabajador, este tutorial demuestra el patrón de publicación/suscripción, que entrega mensajes a múltiples consumidores.
![](https://external-content.duckduckgo.com/iu/?u=https%3A%2F%2Fwww.myanglog.com%2Fstatic%2Fe63b9118f1113a569006637046857099%2F7842b%2FUntitled1.png)
El ejemplo es un sistema de registro con dos programas: uno que emite mensajes de registro y otro que los recibe y los imprime.
Cada instancia del programa receptor recibe todos los mensajes, permitiendo que los registros se dirijan al disco o se visualicen en pantalla.
**Enfoque:**
El ejemplo es un sistema de registro con dos programas: uno que emite mensajes de registro y otro que los recibe y los imprime.
Cada instancia del programa receptor recibe todos los mensajes, permitiendo que los registros se dirijan al disco o se visualicen en pantalla.
**Exchange:**
En RabbitMQ, los productores envían mensajes a un intercambio, no directamente a una cola.
Un intercambio enruta los mensajes a las colas según las reglas definidas por su tipo.
Los tipos de intercambios incluyen directo, tópico, cabeceras y fanout. El tutorial se centra en fanout, que transmite mensajes a todas las colas conocidas.
Ejemplo de declaración de un intercambio fanout:
```python
channel.exchange_declare(exchange='logs', exchange_type='fanout')
```
**Colas Temporales:**
Las colas temporales se crean con nombres generados aleatoriamente, y se eliminan automáticamente cuando se cierra la conexión del consumidor.
Ejemplo de declaración de una cola temporal:
```python
result = channel.queue_declare(queue='', exclusive=True)
```
**Código de Ejemplo:**
- [emit_log.py](./03_publish_subcribe/emit_log.py) para enviar mensajes de log.
- [receive_logs.py](./03_publish_subcribe/receive_logs.py) para recibir mensajes de log.
### Routing
En el tutorial anterior, creamos un sistema de registro simple que enviaba mensajes de log a múltiples receptores. En este tutorial, añadiremos la capacidad de suscribirse solo a un subconjunto de mensajes, permitiendo, por ejemplo, que solo los mensajes de error críticos se registren en un archivo, mientras que todos los mensajes de log se imprimen en la consola.
![](https://external-content.duckduckgo.com/iu/?u=https%3A%2F%2Froytuts.com%2Fwp-content%2Fuploads%2F2022%2F02%2Fimage-2.png&f=1&nofb=1&ipt=7fde4343c19d799a6854664651f05dba983d70b2da4179f5b1f9d1e045a941d9&ipo=images)
#### Enlaces
En ejemplos anteriores, ya creamos enlaces entre intercambios (exchanges) y colas (queues). Un enlace determina qué colas están interesadas en los mensajes de un intercambio. Los enlaces pueden incluir una clave de enrutamiento (routing key) que especifica qué mensajes de un intercambio deben ser enviados a una cola.
```python
channel.queue_bind(exchange=exchange_name,
queue=queue_name,
routing_key='black')
```
La clave de enlace depende del tipo de intercambio. En un intercambio de tipo `fanout`, esta clave es ignorada.
#### Intercambio Directo
Anteriormente, usamos un intercambio de tipo `fanout` que transmitía todos los mensajes a todos los consumidores sin distinción. Ahora utilizaremos un intercambio `direct` que permite filtrar mensajes basándose en su severidad. Así, los mensajes serán enviados solo a las colas que coincidan exactamente con la clave de enrutamiento del mensaje.
Por ejemplo, si un intercambio tiene dos colas con claves de enlace `orange` y `black`, un mensaje con clave de enrutamiento `orange` solo irá a la cola correspondiente a `orange`.
#### Múltiples Enlaces
Es posible vincular varias colas con la misma clave de enlace. En este caso, el intercambio `direct` actúa como un `fanout`, enviando el mensaje a todas las colas que tengan una clave de enlace coincidente.
#### Emisión de Logs
Usaremos este modelo para nuestro sistema de logs. En lugar de `fanout`, enviaremos mensajes a un intercambio `direct`, usando la severidad del log como clave de enrutamiento.
Primero, debemos declarar un intercambio:
```python
channel.exchange_declare(exchange='direct_logs',
exchange_type='direct')
```
Y luego podemos enviar un mensaje:
```python
channel.basic_publish(exchange='direct_logs',
routing_key=severity,
body=message)
```
Las severidades pueden ser `'info'`, `'warning'` o `'error'`.
#### Suscripción
Para recibir mensajes, crearemos un enlace para cada severidad de interés.
```python
result = channel.queue_declare(queue='', exclusive=True)
queue_name = result.method.queue
for severity in severities:
channel.queue_bind(exchange='direct_logs',
queue=queue_name,
routing_key=severity)
```
#### Código de Ejemplo
- **[emit_log_direct.py](./04_routing/emit_log_direct.py)**: Script para emitir mensajes de log.
- **[receive_logs_direct.py](./04_routing/receive_logs_direct.py)**: Script para recibir mensajes de log.
#### Ejemplos de Uso
- Para guardar solo los mensajes de `'warning'` y `'error'` en un archivo:
```bash
python receive_logs_direct.py warning error > logs_from_rabbit.log
```
- Para ver todos los mensajes de log en pantalla:
```bash
python receive_logs_direct.py info warning error
```
- Para emitir un mensaje de error:
```bash
python emit_log_direct.py error "Run. Run. Or it will explode."
```
### Topics (Próximamente)
En el tutorial anterior, mejoramos nuestro sistema de registro utilizando un intercambio de tipo `direct` para recibir registros selectivamente, basado en criterios como la severidad del mensaje. Sin embargo, para mayor flexibilidad, podemos usar un intercambio de tipo `topic`, que permite el enrutamiento de mensajes basado en múltiples criterios.
![](https://miro.medium.com/v2/resize:fit:1400/0*gFwb04MsfqtVB5bY.png)
#### ¿Qué es un intercambio de temas?
- **`routing_key`**: En un intercambio de tipo `topic`, los mensajes tienen una clave de enrutamiento (`routing_key`) que es una lista de palabras separadas por puntos. Ejemplos: `"quick.orange.rabbit"`, `"lazy.brown.fox"`.
- **`binding_key`**: Las claves de enlace (`binding_key`) también tienen el mismo formato y determinan qué mensajes recibe cada cola.
#### Casos especiales de `binding_key`
- **`*` (asterisco)**: Sustituye exactamente una palabra.
- **`#` (almohadilla)**: Sustituye cero o más palabras.
#### Ejemplo de uso
Considera el siguiente escenario con dos colas (Q1 y Q2) y estas claves de enlace:
- Q1: `*.orange.*` (recibe todos los mensajes sobre animales naranjas)
- Q2: `*.*.rabbit` y `lazy.#` (recibe todos los mensajes sobre conejos y animales perezosos)
Ejemplos de mensajes:
- `"quick.orange.rabbit"`: Entregado a Q1 y Q2.
- `"lazy.orange.elephant"`: Entregado a Q1 y Q2.
- `"quick.orange.fox"`: Solo entregado a Q1.
- `"lazy.brown.fox"`: Solo entregado a Q2.
Mensajes con una o cuatro palabras, como `"orange"` o `"quick.orange.new.rabbit"`, no coinciden con ningún enlace y se pierden.
#### Características del intercambio de temas
- Puede comportarse como un intercambio `fanout` si se usa `#` como `binding_key` (recibe todos los mensajes).
- Se comporta como un intercambio `direct` si no se utilizan `*` o `#` en las claves de enlace.
#### Implementación del sistema de registro
Usaremos un intercambio de temas para enrutar registros usando `routing_key` con el formato `<facilidad>.<severidad>`. El código para emitir y recibir registros es similar al de tutoriales anteriores.
**Ejemplos de comandos:**
- Recibir todos los registros: `python receive_logs_topic.py "#"`
- Recibir registros de "kern": `python receive_logs_topic.py "kern.*"`
- Recibir solo registros "critical": `python receive_logs_topic.py "*.critical"`
- Emitir un registro crítico de "kern": `python emit_log_topic.py "kern.critical" "A critical kernel error"`
El código es casi el mismo que en el tutorial anterior.
- **[emit_log_topic.py](./05_topics/emit_log_topic.py)**
- **[receive_logs_topic.py](./05_topics/receive_logs_topic)**
### RPC
En el segundo tutorial aprendimos a usar *Work Queues* para distribuir tareas que consumen tiempo entre múltiples trabajadores.
Pero, ¿qué pasa si necesitamos ejecutar una función en una computadora remota y esperar el resultado? Eso es una historia diferente. Este patrón es comúnmente conocido como *Remote Procedure Call* o RPC.
![](https://alvaro-videla.com/images/RPC-OverRMQ.png)
En este tutorial vamos a usar RabbitMQ para construir un sistema RPC: un cliente y un servidor RPC escalable. Como no tenemos tareas que consuman tiempo que valga la pena distribuir, vamos a crear un servicio RPC ficticio que devuelva números de Fibonacci.
#### Interfaz del cliente
Para ilustrar cómo se podría usar un servicio RPC, vamos a crear una clase de cliente simple. Va a exponer un método llamado `call` que envía una solicitud RPC y se bloquea hasta que se recibe la respuesta:
```python
fibonacci_rpc = FibonacciRpcClient()
result = fibonacci_rpc.call(4)
print(f"fib(4) is {result}")
```
**Una nota sobre RPC**
Aunque RPC es un patrón bastante común en informática, a menudo se critica. Los problemas surgen cuando un programador no está al tanto de si una llamada a función es local o si es un RPC lento. Confusiones como esas resultan en un sistema impredecible y añaden una complejidad innecesaria a la depuración. En lugar de simplificar el software, el uso indebido de RPC puede resultar en un código enredado e inmantenible.
Teniendo esto en cuenta, considere los siguientes consejos:
- Asegúrese de que sea obvio qué llamada a función es local y cuál es remota.
- Documente su sistema. Haga claras las dependencias entre componentes.
- Maneje los casos de error. ¿Cómo debería reaccionar el cliente cuando el servidor RPC está caído por mucho tiempo?
En caso de duda, evite RPC. Si puede, debe usar un canal asincrónico: en lugar de un bloqueo estilo RPC, los resultados se envían asincrónicamente a la siguiente etapa de computación.
#### Cola de retorno (*Callback queue*)
En general, hacer RPC sobre RabbitMQ es fácil. Un cliente envía un mensaje de solicitud y un servidor responde con un mensaje de respuesta. Para recibir una respuesta, el cliente necesita enviar una dirección de una cola de retorno (*callback queue*) con la solicitud. Vamos a intentarlo:
```python
result = channel.queue_declare(queue='', exclusive=True)
callback_queue = result.method.queue
channel.basic_publish(exchange='',
routing_key='rpc_queue',
properties=pika.BasicProperties(
reply_to = callback_queue,
),
body=request)
# ... y algo de código para leer un mensaje de respuesta de la callback_queue ...
```
**Propiedades del mensaje**
El protocolo AMQP 0-9-1 predefine un conjunto de 14 propiedades que acompañan un mensaje. La mayoría de las propiedades rara vez se utilizan, con la excepción de las siguientes:
- `delivery_mode`: Marca un mensaje como persistente (con un valor de 2) o transitorio (cualquier otro valor). Puede recordar esta propiedad del segundo tutorial.
- `content_type`: Se utiliza para describir el tipo MIME de la codificación. Por ejemplo, para la codificación JSON a menudo utilizada, es una buena práctica establecer esta propiedad en: `application/json`.
- `reply_to`: Comúnmente utilizado para nombrar una cola de retorno (*callback queue*).
- `correlation_id`: Útil para correlacionar respuestas RPC con solicitudes.
#### ID de correlación (*Correlation id*)
En el método presentado anteriormente, sugerimos crear una cola de retorno para cada solicitud RPC. Eso es bastante ineficiente, pero afortunadamente hay una mejor manera: crear una única cola de retorno por cliente.
Eso plantea un nuevo problema: al recibir una respuesta en esa cola, no está claro a qué solicitud pertenece la respuesta. Es entonces cuando se usa la propiedad `correlation_id`. Vamos a configurarla a un valor único para cada solicitud. Más tarde, cuando recibamos un mensaje en la cola de retorno, miraremos esta propiedad, y con base en eso podremos coincidir una respuesta con una solicitud. Si vemos un valor de `correlation_id` desconocido, podemos descartar el mensaje de manera segura: no pertenece a nuestras solicitudes.
Puede preguntar, ¿por qué deberíamos ignorar los mensajes desconocidos en la cola de retorno en lugar de fallar con un error? Se debe a la posibilidad de una condición de carrera en el lado del servidor. Aunque es poco probable, es posible que el servidor RPC muera justo después de enviarnos la respuesta, pero antes de enviar un mensaje de confirmación para la solicitud. Si eso sucede, el servidor RPC reiniciado procesará la solicitud nuevamente. Es por eso que, en el cliente, debemos manejar las respuestas duplicadas de manera prudente, y el RPC idealmente debería ser idempotente.
#### Resumen
Nuestro RPC funcionará así:
- Cuando el cliente se inicia, crea una cola de retorno anónima exclusiva.
- Para una solicitud RPC, el cliente envía un mensaje con dos propiedades: `reply_to`, que se establece en la cola de retorno, y `correlation_id`, que se establece en un valor único para cada solicitud.
- La solicitud se envía a una cola llamada `rpc_queue`.
- El trabajador RPC (también conocido como servidor) está esperando solicitudes en esa cola. Cuando aparece una solicitud, hace el trabajo y envía un mensaje con el resultado de vuelta al cliente, usando la cola del campo `reply_to`.
- El cliente espera datos en la cola de retorno. Cuando aparece un mensaje, verifica la propiedad `correlation_id`. Si coincide con el valor de la solicitud, devuelve la respuesta a la aplicación.
#### Poniéndolo todo junto
- [rpc_server.py](./06_rpc/rpc_client.py)
El código del servidor es bastante sencillo:
- Como de costumbre, comenzamos estableciendo la conexión y declarando la cola `rpc_queue`.
- Declaramos nuestra función de Fibonacci. Asume solo una entrada de entero positivo válida. (No espere que funcione para números grandes, probablemente sea la implementación recursiva más lenta posible).
- Declaramos un *callback* `on_request` para `basic_consume`, el núcleo del servidor RPC. Se ejecuta cuando se recibe la solicitud. Hace el trabajo y envía la respuesta de vuelta.
- Podríamos querer ejecutar más de un proceso de servidor. Para distribuir la carga de manera equitativa entre varios servidores, necesitamos establecer el ajuste `prefetch_count`.
[rpc_client.py](-/06_rpc/rpc_client.py)
El código del cliente es un poco más complejo:
- Establecemos una conexión, un canal y declaramos una `callback_queue` exclusiva para las respuestas.
- Nos suscribimos a la `callback_queue`, para que podamos recibir respuestas RPC.
- El *callback* `on_response` que se ejecuta en cada respuesta está haciendo un trabajo muy simple: para cada mensaje de respuesta, verifica si el `correlation_id` es el que estamos buscando. Si es así, guarda la respuesta en `self.response` y sale del bucle de consumo.
- A continuación, definimos nuestro método principal `call`: realiza la solicitud RPC real.
- En el método `call`, generamos un número de `correlation_id` único y lo guardamos: la función de *callback* `on_response` usará este valor para capturar
la respuesta apropiada.
- También en el método `call`, publicamos el mensaje de solicitud, con dos propiedades: `reply_to` y `correlation_id`.
- Al final, esperamos hasta que llegue la respuesta adecuada y devolvemos la respuesta al usuario.
Nuestro servicio RPC ya está listo. Podemos iniciar el servidor:
```bash
python rpc_server.py
```
Para solicitar un número de Fibonacci, ejecute el cliente con el número como argumento:
```bash
python rpc_client.py <numero>
```
El diseño presentado no es la única implementación posible de un servicio RPC, pero tiene algunas ventajas importantes:
- Si el servidor RPC es demasiado lento, puede escalar simplemente ejecutando otro. Intente ejecutar un segundo `rpc_server.py` en una nueva consola.
- En el lado del cliente, el RPC requiere enviar y recibir solo un mensaje. No se requieren llamadas sincrónicas como `queue_declare`. Como resultado, el cliente RPC necesita solo un viaje de ida y vuelta por la red para una única solicitud RPC.
Nuestro código sigue siendo bastante simplista y no intenta resolver problemas más complejos (pero importantes), como:
- ¿Cómo debería reaccionar el cliente si no hay servidores en funcionamiento?
- ¿Debería un cliente tener algún tipo de tiempo de espera para el RPC?
- Si el servidor funciona mal y genera una excepción, ¿debería enviarse al cliente?
- Proteger contra mensajes entrantes no válidos (por ejemplo, verificando límites) antes de procesar.
---

View File

@@ -0,0 +1,32 @@
services:
rabbitmq:
image: rabbitmq:3-management-alpine
container_name: 'rabbitmq-server'
hostname: rabbitmq
ports:
- 5672:5672
- 15672:15672
volumes:
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro
- ./rabbitmq/data/:/var/lib/rabbitmq/
- ./rabbitmq/log/:/var/log/rabbitmq
# environment:
# RABBITMQ_DEFAULT_USER: invent
# RABBITMQ_DEFAULT_PASS: 123456
# RABBITMQ_ERLANG_COOKIE: 'randomcookievalue'
networks:
- rabbitmq_go_net
restart: unless-stopped
labels:
description: "RabbitMQ Server in container docker"
maintainer: "manuelver"
healthcheck:
test: ["CMD", "rabbitmqctl", "status"]
interval: 10s
timeout: 5s
retries: 3
networks:
rabbitmq_go_net:
driver: bridge

View File

@@ -0,0 +1,318 @@
# Empezando con Apache Kafka en Python
En este artículo, voy a hablar sobre Apache Kafka y cómo los programadores de Python pueden usarlo para construir sistemas distribuidos.
## ¿Qué es Apache Kafka?
Apache Kafka es una plataforma de streaming de código abierto que fue inicialmente desarrollada por LinkedIn. Más tarde fue entregada a la fundación Apache y se convirtió en código abierto en 2011.
Según [Wikipedia](https://en.wikipedia.org/wiki/Apache_Kafka):
__Apache Kafka es una plataforma de software de procesamiento de flujos de código abierto desarrollada por la Fundación Apache, escrita en Scala y Java. El proyecto tiene como objetivo proporcionar una plataforma unificada, de alto rendimiento y baja latencia para manejar flujos de datos en tiempo real. Su capa de almacenamiento es esencialmente una "cola de mensajes pub/sub masivamente escalable arquitectada como un registro de transacciones distribuidas", lo que la hace altamente valiosa para las infraestructuras empresariales para procesar datos de streaming. Además, Kafka se conecta a sistemas externos (para importación/exportación de datos) a través de Kafka Connect y proporciona Kafka Streams, una biblioteca de procesamiento de flujos en Java.__
![Kafka Diagram](https://miro.medium.com/v2/resize:fit:720/format:webp/1*kQXkMQTrMrG4VJ3KZehaqA.png)
Piense en él como un gran registro de commits donde los datos se almacenan en secuencia a medida que ocurren. Los usuarios de este registro pueden acceder y usarlo según sus necesidades.
## Casos de uso de Kafka
Los usos de Kafka son múltiples. Aquí hay algunos casos de uso que podrían ayudarte a comprender su aplicación:
- **Monitoreo de Actividad**: Kafka puede ser utilizado para el monitoreo de actividad. La actividad puede pertenecer a un sitio web o a sensores y dispositivos físicos. Los productores pueden publicar datos en bruto de las fuentes de datos que luego pueden usarse para encontrar tendencias y patrones.
- **Mensajería**: Kafka puede ser utilizado como un intermediario de mensajes entre servicios. Si estás implementando una arquitectura de microservicios, puedes tener un microservicio como productor y otro como consumidor. Por ejemplo, tienes un microservicio responsable de crear nuevas cuentas y otro para enviar correos electrónicos a los usuarios sobre la creación de la cuenta.
- **Agregación de Logs**: Puedes usar Kafka para recopilar logs de diferentes sistemas y almacenarlos en un sistema centralizado para un procesamiento posterior.
- **ETL**: Kafka tiene una característica de streaming casi en tiempo real, por lo que puedes crear un ETL basado en tus necesidades.
- **Base de Datos**: Basado en lo que mencioné antes, podrías decir que Kafka también actúa como una base de datos. No una base de datos típica que tiene una característica de consulta de datos según sea necesario, lo que quise decir es que puedes mantener datos en Kafka todo el tiempo que quieras sin consumirlos.
## Conceptos de Kafka
![Kafka Concepts](https://miro.medium.com/v2/resize:fit:640/format:webp/1*48ck-bvatKzEpVapVa4Mag.png)
### Topics
Cada mensaje que se introduce en el sistema debe ser parte de algún topic. El topic no es más que un flujo de registros. Los mensajes se almacenan en formato clave-valor. A cada mensaje se le asigna una secuencia, llamada Offset. La salida de un mensaje podría ser una entrada de otro para un procesamiento posterior.
### Producers
Los Producers son las aplicaciones responsables de publicar datos en el sistema Kafka. Publican datos en el topic de su elección.
### Consumers
Los mensajes publicados en los topics son luego utilizados por las aplicaciones Consumers. Un consumer se suscribe al topic de su elección y consume datos.
### Broker
Cada instancia de Kafka que es responsable del intercambio de mensajes se llama Broker. Kafka puede ser utilizado como una máquina independiente o como parte de un cluster.
Trataré de explicar todo con un ejemplo simple: hay un almacén de un restaurante donde se almacenan todos los ingredientes como arroz, verduras, etc. El restaurante sirve diferentes tipos de platos: chino, desi, italiano, etc. Los chefs de cada cocina pueden referirse al almacén, elegir lo que desean y preparar los platos. Existe la posibilidad de que lo que se haga con el material crudo pueda ser usado más tarde por todos los chefs de los diferentes departamentos, por ejemplo, una salsa secreta que se usa en TODO tipo de platos. Aquí, el almacén es un broker, los proveedores de bienes son los producers, los bienes y la salsa secreta hecha por los chefs son los topics, mientras que los chefs son los consumers. Mi analogía puede sonar divertida e inexacta, pero al menos te habría ayudado a entender todo :-)
## Configuración y Ejecución
La forma más sencilla de instalar Kafka es descargar los binarios y ejecutarlo. Dado que está basado en lenguajes JVM como Scala y Java, debes asegurarte de estar usando Java 7 o superior.
Kafka está disponible en dos versiones diferentes: Una por la [fundación Apache](https://kafka.apache.org/downloads) y otra por [Confluent](https://www.confluent.io/about/) como un [paquete](https://www.confluent.io/download/). Para este tutorial, utilizaré la proporcionada por la fundación Apache. Por cierto, Confluent fue fundada por los [desarrolladores originales](https://www.confluent.io/about/) de Kafka.
## Iniciando Zookeeper
Kafka depende de Zookeeper, para hacerlo funcionar primero tendremos que ejecutar Zookeeper.
```
bin/zookeeper-server-start.sh config/zookeeper.properties
```
mostrará muchos textos en la pantalla, si ves lo siguiente significa que está correctamente configurado.
```
2018-06-10 06:36:15,023] INFO maxSessionTimeout set to -1 (org.apache.zookeeper.server.ZooKeeperServer)
[2018-06-10 06:36:15,044] INFO binding to port 0.0.0.0/0.0.0.0:2181 (org.apache.zookeeper.server.NIOServerCnxnFactory)
```
## Iniciando el Servidor Kafka
A continuación, tenemos que iniciar el servidor broker de Kafka:
```
bin/kafka-server-start.sh config/server.properties
```
Y si ves el siguiente texto en la consola significa que está en funcionamiento.
```
2018-06-10 06:38:44,477] INFO Kafka commitId : fdcf75ea326b8e07 (org.apache.kafka.common.utils.AppInfoParser)
[2018-06-10 06:38:44,478] INFO [KafkaServer id=0] started (kafka.server.KafkaServer)
```
## Crear Topics
Los mensajes se publican en topics. Usa este comando para crear un nuevo topic.
```
➜ kafka_2.11-1.1.0 bin/kafka-topics.sh --create --zookeeper localhost:2181 --replication-factor 1 --partitions 1 --topic test
Created topic "test".
```
También puedes listar todos los topics disponibles ejecutando el siguiente comando.
```
➜ kafka_2.11-1.1.0 bin/kafka-topics.sh --list --zookeeper localhost:2181
test
```
Como ves, imprime, test.
## Enviar Mensajes
A continuación, tenemos que enviar mensajes, los *producers* se utilizan para ese propósito. Vamos a iniciar un *producer*.
```
➜ kafka_2.11-1.1.0 bin/kafka-console-producer.sh --broker-list localhost:9092 --topic test
>Hello
>World
```
Inicias la interfaz del *producer* basada en consola que se ejecuta en el puerto 9092 por defecto. `--topic` te permite establecer el *topic* en el que se publicarán los mensajes. En nuestro caso, el *topic* es `test`.
Te muestra un prompt `>` y puedes ingresar lo que quieras.
Los mensajes se almacenan localmente en tu disco. Puedes conocer la ruta de almacenamiento verificando el valor de `log.dirs` en el archivo `config/server.properties`. Por defecto, están configurados en `/tmp/kafka-logs/`.
Si listamos esta carpeta, encontraremos una carpeta con el nombre `test-0`. Al listar su contenido encontrarás 3 archivos: `00000000000000000000.index 00000000000000000000.log 00000000000000000000.timeindex`.
Si abres `00000000000000000000.log` en un editor, muestra algo como:
```
^@^@^@^@^@^@^@^@^@^@^@=^@^@^@^@^BÐØR^V^@^@^@^@^@^@^@^@^Acça<9a>o^@^@^Acça<9a>oÿÿÿÿÿÿÿÿÿÿÿÿÿÿ^@^@^@^A^V^@^@^@^A
Hello^@^@^@^@^@^@^@^@^A^@^@^@=^@^@^@^@^BÉJ^B­^@^@^@^@^@^@^@^@^Acça<9f>^?^@^@^Acça<9f>^?ÿÿÿÿÿÿÿÿÿÿÿÿÿÿ^@^@^@^A^V^@^@^@^A
World^@
~
```
Parece que los datos están codificados o delimitados por separadores, no estoy seguro. Si alguien conoce este formato, que me lo haga saber.
De todos modos, Kafka proporciona una utilidad que te permite examinar cada mensaje entrante.
```
➜ kafka_2.11-1.1.0 bin/kafka-run-class.sh kafka.tools.DumpLogSegments --deep-iteration --print-data-log --files /tmp/kafka-logs/test-0/00000000000000000000.log
Dumping /tmp/kafka-logs/test-0/00000000000000000000.log
Starting offset: 0
offset: 0 position: 0 CreateTime: 1528595323503 isvalid: true keysize: -1 valuesize: 5 magic: 2 compresscodec: NONE producerId: -1 producerEpoch: -1 sequence: -1 isTransactional: false headerKeys: [] payload: Hello
offset: 1 position: 73 CreateTime: 1528595324799 isvalid: true keysize: -1 valuesize: 5 magic: 2 compresscodec: NONE producerId: -1 producerEpoch: -1 sequence: -1 isTransactional: false headerKeys: [] payload: World
```
Puedes ver el mensaje con otros detalles como `offset`, `position` y `CreateTime`, etc.
## Consumir Mensajes
Los mensajes que se almacenan también deben ser consumidos. Vamos a iniciar un *consumer* basado en consola.
```
➜ kafka_2.11-1.1.0 bin/kafka-console-consumer.sh --bootstrap-server localhost:9092 --topic test --from-beginning
```
Si lo ejecutas, volcará todos los mensajes desde el principio hasta ahora. Si solo estás interesado en consumir los mensajes después de ejecutar el *consumer*, entonces puedes simplemente omitir el interruptor `--from-beginning` y ejecutarlo. La razón por la cual no muestra los mensajes antiguos es porque el *offset* se actualiza una vez que el *consumer* envía un ACK al *broker* de Kafka sobre el procesamiento de mensajes. Puedes ver el flujo de trabajo a continuación.
![](https://miro.medium.com/v2/resize:fit:720/format:webp/1*Pp5vDC3T6OVWMHeWLdiIqA.png)
## Acceder a Kafka en Python
Hay múltiples librerías de Python disponibles para su uso:
- **Kafka-Python** — Una librería de código abierto basada en la comunidad.
- **PyKafka** — Esta librería es mantenida por Parsly y se dice que es una API Pythónica. A diferencia de Kafka-Python, no puedes crear *topics* dinámicos.
- **Confluent Python Kafka**:— Es ofrecida por Confluent como un contenedor delgado alrededor de **librdkafka**, por lo tanto, su rendimiento es mejor que el de las dos anteriores.
Para este artículo, usaremos la librería de código abierto Kafka-Python.
## Sistema de Alerta de Recetas en Kafka
En el último artículo sobre Elasticsearch, extraje datos de Allrecipes. En este artículo, voy a usar el mismo scraper como fuente de datos. El sistema que vamos a construir es un sistema de alertas que enviará notificaciones sobre las recetas si cumplen con cierto umbral de calorías. Habrá dos *topics*:
- **raw_recipes**:— Almacenará el HTML en bruto de cada receta. La idea es usar este *topic* como la fuente principal de nuestros datos que luego pueden ser procesados y transformados según sea necesario.
- **parsed_recipes**:— Como su nombre indica, este será el dato analizado de cada receta en formato JSON.
La longitud del nombre de un *topic* en Kafka no debe exceder los 249 caracteres.
Un flujo de trabajo típico se verá así:
![](https://miro.medium.com/v2/resize:fit:720/format:webp/1*lESE2igQKfpd-QmhG39p0w.png)
Instala `kafka-python` mediante `pip`.
```
pip install kafka-python
```
## Productor de Recetas en Bruto
El primer programa que vamos a escribir es el *producer*. Accederá a Allrecipes.com y obtendrá el HTML en bruto y lo almacenará en el *topic* `raw_recipes`. Archivo [producer-raw-recipes.py](./producer-raw-recipes/producer-raw-recipies.py).
Este fragmento de código extraerá el marcado de cada receta y lo devolverá en formato `list`.
A continuación, debemos crear un objeto *producer*. Antes de proceder, haremos cambios en el archivo `config/server.properties`. Debemos establecer `advertised.listeners` en `PLAINTEXT://localhost:9092`, de lo contrario, podrías experimentar el siguiente error:
```
Error encountered when producing to broker b'adnans-mbp':9092. Retrying.
```
Ahora añadiremos dos métodos: `connect_kafka_producer()` que te dará una instancia del *producer* de Kafka y `publish_message()` que solo almacenará el HTML en bruto de recetas individuales.
El __main__ se verá así.
Si funciona bien, mostrará la siguiente salida:
```
/anaconda3/anaconda/bin/python /Development/DataScience/Kafka/kafka-recipie-alert/producer-raw-recipies.py
Accessing list
Processing..https://www.allrecipes.com/recipe/20762/california-coleslaw/
Processing..https://www.allrecipes.com/recipe/8584/holiday-chicken-salad/
Processing..https://www.allrecipes.com/recipe/80867/cran-broccoli-salad/
Message published successfully.
Message published successfully.
Message published successfully.Process finished with exit code 0
```
Estoy usando una herramienta GUI, llamada Kafka Tool, para navegar por los mensajes publicados recientemente. Está disponible para OSX, Windows y Linux.
![](https://miro.medium.com/v2/resize:fit:720/format:webp/1*p3omv-7mRFzPA_ruCAJ6Mg.png)
## Analizador de Recetas
Archivo [producer-consumer-parse-recipes.py](./producer-consumer-parse-recipes/producer-consumer-parse-recipes.py).
El siguiente script que vamos a escribir servirá como *consumer* y *producer*. Primero consumirá datos del *topic* `raw_recipes`, analizará y transformará los datos en JSON, y luego los publicará en el *topic* `parsed_recipes`. A continuación se muestra el código que obtendrá datos HTML del *topic* `raw_recipes`, los analizará y luego los alimentará al *topic* `parsed_recipes`.
`KafkaConsumer` acepta algunos parámetros además del nombre del *topic* y la dirección del host. Al proporcionar `auto_offset_reset='earliest'` le estás diciendo a Kafka que devuelva mensajes desde el principio. El parámetro `consumer_timeout_ms` ayuda al *consumer* a desconectarse después de cierto período de tiempo. Una vez desconectado, puedes cerrar el flujo del *consumer* llamando a `consumer.close()`.
Después de esto, estoy utilizando las mismas rutinas para conectar *producers* y publicar datos analizados en el nuevo *topic*. El navegador KafkaTool da buenas noticias sobre los mensajes almacenados recientemente.
![](https://miro.medium.com/v2/resize:fit:720/format:webp/1*GmHD3GdjHV8ad2AzCIA_WA.png)
Hasta ahora, todo bien. Almacenamos las recetas en formato bruto y JSON para uso futuro. A continuación, tenemos que escribir un *consumer* que se conecte con el *topic* `parsed_recipes` y genere una alerta si se cumple cierto criterio de `calories`.
El JSON se decodifica y luego se verifica la cantidad de calorías, se emite una notificación una vez que se cumple el criterio.
## Ya lo hemos probado en local, ¡ahora a dockerizarlo!
Ahora que hemos probado el sistema localmente, es hora de dockerizarlo para facilitar su despliegue y escalabilidad. Vamos a crear imágenes Docker para cada uno de los scripts de Python y configurar un entorno de Docker Compose para orquestar todo.
Esctrutura de directorios:
```
.
├── docker-compose.yml
├── producer-raw-recipes/
│ ├── Dockerfile
│ └── producer-raw-recipes.py
├── producer-consumer-parse-recipes/
│ ├── Dockerfile
│ └── producer_consumer_parse_recipes.py
└── consumer-notification/
├── Dockerfile
└── consumer-notification.py
```
Ficheros docker:
- [docker-compose.yaml](./docker-compose.yml)
- [Dockerfile producer-raw-recipes](./producer-raw-recipes/Dockerfile)
- [Dockerfile producer-consumer-parse-recipes](./producer-consumer-parse-recipes/Dockerfile)
- [Dockerfile consumer-notification](./consumer-notification/Dockerfile)
Los ficheros Dockerfile construye las imágenes Docker para cada uno de los scripts de Python.
El archivo Docker Compose configura los siguientes servicios y las aplicaciones que se ejecutarán:
- **Zookeeper**: Para coordinar el cluster de Kafka.
- **Kafka**: El broker de Kafka.
- **Kafdrop**: Una interfaz web para Kafka.
- **producer-raw-recipes**: El productor que envía recetas en bruto.
- **producer-consumer-parse-recipes**: El productor-consumidor que analiza las recetas y las envía a un nuevo topic.
- **consumer-notification**: El consumidor que emite alertas sobre recetas con alto contenido calórico.
El docker-compose es muy completo con healthchecks, dependencias, volumenes, etc.
### Construir y Ejecutar los Contenedores
Para construir y ejecutar los contenedores, usa el siguiente comando en el directorio raíz del proyecto:
```bash
docker-compose up --build
```
Este comando construye las imágenes Docker y levanta los contenedores definidos en `docker-compose.yml`.
### Verificación
Para verificar que todo está funcionando correctamente:
1. Asegúrate de que todos los contenedores están en ejecución usando `docker ps`.
2. Revisa los logs de cada contenedor con `docker logs <container_name>` para asegurar que no haya errores. Con lazydocker puedes ver los logs de todos los contenedores rápidamente.
3. Puedes usar herramientas GUI como Kafka Tool o `kafka-console-consumer.sh` y `kafka-console-producer.sh` para interactuar con los topics y los mensajes.
4. Panel de control de Kafdrop en `http://localhost:9000`.
## Conclusión
Kafka es un sistema de mensajería de publicación-suscripción escalable y tolerante a fallos que te permite construir aplicaciones distribuidas. Debido a su [alto rendimiento y eficiencia](http://searene.me/2017/07/09/Why-is-Kafka-so-fast/), se está volviendo popular entre las empresas que producen grandes cantidades de datos desde diversas fuentes externas y desean proporcionar resultados en tiempo real a partir de ellos. Solo he cubierto lo esencial. Explora los documentos y las implementaciones existentes, y te ayudará a entender cómo podría ser la mejor opción para tu próximo sistema.
---

View File

@@ -0,0 +1,17 @@
# Usa una imagen base de Python
FROM python:3.9-slim
# Configura el directorio de trabajo
WORKDIR /app
# Copia el archivo de requisitos al contenedor
COPY requirements.txt /app/
# Instala las dependencias
RUN pip install --no-cache-dir -r requirements.txt
# Copia el script de Python al contenedor
COPY consumer-notification.py /app/
# Comando por defecto para ejecutar el script
CMD ["python", "consumer-notification.py"]

View File

@@ -0,0 +1,66 @@
import json
import logging
from time import sleep
from kafka import KafkaConsumer
# Configuración del registro
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s'
)
def main():
# Nombre del tema de Kafka del que se consumen los mensajes
parsed_topic_name = 'parsed_recipes'
# Umbral de calorías para la notificación
calories_threshold = 200
# Crear un consumidor de Kafka
consumer = KafkaConsumer(
parsed_topic_name,
auto_offset_reset='earliest', # Inicia desde el 1er mensaje si es nuevo
bootstrap_servers=['kafka:9092'], # Servidor Kafka
api_version=(0, 10), # Versión de la API de Kafka
# Tiempo máx. de espera mensajes (milisegundos)
consumer_timeout_ms=2000
)
logging.info('[+] Iniciando el consumidor de notificaciones...')
try:
for msg in consumer:
# Decodificar el mensaje de JSON
record = json.loads(msg.value)
# Obtener el valor de calorías
calories = int(record.get('calories', 0))
title = record.get('title', 'Sin título') # Obtener el título
# Verificar si las calorías exceden el umbral
if calories > calories_threshold:
logging.warning(
f'[!] Alerta: {title} tiene {calories} calorías')
# Esperar 5 segundos antes de procesar el siguiente mensaje
sleep(5)
except Exception as ex:
logging.error(
'[!] Error en el consumidor de notificaciones', exc_info=True)
finally:
if consumer is not None:
consumer.close() # Cerrar el consumidor al finalizar
logging.info('[i] Consumidor cerrado.')
if __name__ == '__main__':
main()

View File

@@ -0,0 +1 @@
kafka-python

View File

@@ -0,0 +1,153 @@
services:
zookeeper:
image: bitnami/zookeeper:latest
container_name: zookeeper
ports:
- "2181:2181"
volumes:
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro
- "zookeeper_data:/bitnami"
environment:
ALLOW_ANONYMOUS_LOGIN: "yes"
networks:
- kafka-network
healthcheck:
test: ["CMD-SHELL", "nc -z localhost 2181"]
interval: 10s
retries: 5
start_period: 10s
timeout: 5s
kafka:
image: bitnami/kafka:latest
container_name: kafka
ports:
- "9092:9092"
volumes:
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro
- "kafka_data:/bitnami"
environment:
KAFKA_ADVERTISED_LISTENERS: INSIDE://kafka:9092,OUTSIDE://localhost:9093
KAFKA_LISTENERS: INSIDE://0.0.0.0:9092,OUTSIDE://0.0.0.0:9093
KAFKA_LISTENER_NAME: INSIDE
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: INSIDE:PLAINTEXT,OUTSIDE:PLAINTEXT
KAFKA_LISTENER_SECURITY_PROTOCOL: PLAINTEXT
KAFKA_INTER_BROKER_LISTENER_NAME: INSIDE
KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
networks:
- kafka-network
depends_on:
zookeeper:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "kafka-broker-api-versions.sh --bootstrap-server localhost:9092"]
interval: 10s
retries: 10
start_period: 60s
timeout: 10s
kafdrop:
image: obsidiandynamics/kafdrop:latest
container_name: kafdrop
ports:
- "9000:9000"
environment:
KAFKA_BROKERCONNECT: kafka:9092
networks:
- kafka-network
volumes:
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro
depends_on:
kafka:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:9000/ || exit 1"]
interval: 10s
retries: 5
start_period: 10s
timeout: 5s
producer_raw_recipes:
container_name: producer-raw-recipes
build:
context: ./producer-raw-recipes
networks:
- kafka-network
volumes:
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro
depends_on:
kafdrop:
condition: service_healthy
kafka:
condition: service_healthy
zookeeper:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "bash -c 'echo > /dev/tcp/kafka/9092' || exit 1"]
interval: 10s
retries: 5
start_period: 10s
timeout: 5s
producer_consumer_parse_recipes:
container_name: producer-consumer-parse-recipes
build:
context: ./producer-consumer-parse-recipes
networks:
- kafka-network
volumes:
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro
depends_on:
kafdrop:
condition: service_healthy
kafka:
condition: service_healthy
zookeeper:
condition: service_healthy
producer_raw_recipes:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "bash -c 'echo > /dev/tcp/kafka/9092' || exit 1"]
interval: 10s
retries: 5
start_period: 10s
timeout: 5s
consumer_notification:
container_name: consumer-notification
build:
context: ./consumer-notification
networks:
- kafka-network
volumes:
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro
depends_on:
kafdrop:
condition: service_healthy
kafka:
condition: service_healthy
zookeeper:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "bash -c 'echo > /dev/tcp/kafka/9092' || exit 1"]
interval: 10s
retries: 5
start_period: 10s
timeout: 5s
networks:
kafka-network:
driver: bridge
volumes:
zookeeper_data:
driver: local
kafka_data:
driver: local

View File

@@ -0,0 +1,17 @@
# Usa una imagen base de Python
FROM python:3.9-slim
# Configura el directorio de trabajo
WORKDIR /app
# Copia el archivo de requisitos al contenedor
COPY requirements.txt /app/
# Instala las dependencias
RUN pip install --no-cache-dir -r requirements.txt
# Copia el script de Python al contenedor
COPY producer-consumer-parse-recipes.py /app/
# Comando por defecto para ejecutar el script
CMD ["python", "producer-consumer-parse-recipes.py"]

View File

@@ -0,0 +1,165 @@
import json
import logging
from time import sleep
from bs4 import BeautifulSoup
from kafka import KafkaConsumer, KafkaProducer
# Configuración del registro
logging.basicConfig(level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s')
def publish_message(producer_instance, topic_name, key, value):
"""
Publica un mensaje en el tema de Kafka especificado.
"""
try:
key_bytes = bytes(key, encoding='utf-8') # Convertir clave a bytes
value_bytes = bytes(value, encoding='utf-8') # Convertir valor a bytes
producer_instance.send( # Enviar mensaje
topic_name, key=key_bytes,
value=value_bytes
)
producer_instance.flush() # Asegurar que el mensaje ha sido enviado
logging.info('[i] Mensaje publicado con éxito.')
except Exception as ex:
logging.error('[!] Error al publicar mensaje', exc_info=True)
def connect_kafka_producer():
"""
Conecta y devuelve una instancia del productor de Kafka.
"""
try:
producer = KafkaProducer(
bootstrap_servers=['kafka:9092'], # Servidor Kafka
api_version=(0, 10) # Versión de la API de Kafka
)
logging.info('[i] Conectado con éxito al productor de Kafka.')
return producer
except Exception as ex:
logging.error('[!] Error al conectar con Kafka', exc_info=True)
return None
def parse(markup):
"""
Analiza el HTML y extrae la información de la receta.
"""
title = '-'
submit_by = '-'
description = '-'
calories = 0
ingredients = []
rec = {}
try:
soup = BeautifulSoup(markup, 'lxml') # Analizar HTML con BeautifulSoup
# Actualizar selectores CSS para el título, descripción, ingredientes y calorías
title_section = soup.select_one('h1.headline.heading-content') # Título
submitter_section = soup.select_one('span.author-name') # Autor
description_section = soup.select_one('div.recipe-summary > p') # Descripción
ingredients_section = soup.select('li.ingredients-item') # Ingredientes
calories_section = soup.select_one('span.calorie-count') # Calorías
# Extraer calorías
if calories_section:
calories = calories_section.get_text(strip=True).replace('cals', '').strip()
# Extraer ingredientes
if ingredients_section:
for ingredient in ingredients_section:
ingredient_text = ingredient.get_text(strip=True)
if 'Add all ingredients to list' not in ingredient_text and ingredient_text != '':
ingredients.append({'step': ingredient_text})
# Extraer descripción
if description_section:
description = description_section.get_text(strip=True)
# Extraer nombre del autor
if submitter_section:
submit_by = submitter_section.get_text(strip=True)
# Extraer título
if title_section:
title = title_section.get_text(strip=True)
# Crear diccionario con la información de la receta
rec = {
'title': title,
'submitter': submit_by,
'description': description,
'calories': calories,
'ingredients': ingredients
}
logging.info(f"[i] Receta extraída: {rec}")
except Exception as ex:
logging.error('[!] Error en parsing', exc_info=True)
return json.dumps(rec)
def main():
"""
Ejecuta el proceso de consumo y publicación de mensajes.
"""
topic_name = 'raw_recipes'
parsed_topic_name = 'parsed_recipes'
parsed_records = []
# Crear un consumidor de Kafka
consumer = KafkaConsumer(
topic_name,
auto_offset_reset='earliest',
bootstrap_servers=['kafka:9092'],
api_version=(0, 10),
consumer_timeout_ms=2000
)
logging.info('[i] Iniciando el consumidor para parsing...')
try:
for msg in consumer:
html = msg.value
result = parse(html) # Analizar el HTML
parsed_records.append(result)
consumer.close() # Cerrar el consumidor
logging.info('[i] Consumidor cerrado.')
if parsed_records:
logging.info('[+] Publicando registros...')
producer = connect_kafka_producer()
if producer:
for rec in parsed_records:
publish_message(producer, parsed_topic_name, 'parsed', rec)
producer.close() # Cerrar el productor
else:
logging.error('[!] El productor de Kafka no está disponible.')
except Exception as ex:
logging.error('[!] Error en el productor-consumer', exc_info=True)
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,3 @@
kafka-python
requests
beautifulsoup4

View File

@@ -0,0 +1,17 @@
# Usa una imagen base de Python
FROM python:3.9-slim
# Configura el directorio de trabajo
WORKDIR /app
# Copia el archivo de requisitos al contenedor
COPY requirements.txt /app/
# Instala las dependencias
RUN pip install --no-cache-dir -r requirements.txt
# Copia el script de Python al contenedor
COPY producer-raw-recipes.py /app/
# Comando por defecto para ejecutar el script
CMD ["python", "producer-raw-recipes.py"]

View File

@@ -0,0 +1,168 @@
import requests
from time import sleep
import logging
from bs4 import BeautifulSoup
from kafka import KafkaProducer
# Configuración del registro
logging.basicConfig(level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s')
def publish_message(producer_instance, topic_name, key, value):
"""
Publica un mensaje en el tema de Kafka especificado.
"""
try:
key_bytes = bytes(key, encoding='utf-8') # Convertir clave a bytes
value_bytes = bytes(value, encoding='utf-8') # Convertir valor a bytes
producer_instance.send( # Enviar mensaje
topic_name, key=key_bytes, value=value_bytes
)
producer_instance.flush() # Asegurar que el mensaje ha sido enviado
logging.info('[+] Mensaje publicado con éxito.')
except Exception as ex:
logging.error('[!] Error al publicar mensaje', exc_info=True)
def connect_kafka_producer():
"""
Conecta y devuelve una instancia del productor de Kafka.
"""
try:
producer = KafkaProducer(
bootstrap_servers=['kafka:9092'], # Servidor Kafka
api_version=(0, 10) # Versión de la API de Kafka
)
logging.info('[i] Conectado con éxito al productor de Kafka.')
return producer
except Exception as ex:
logging.error('[!] Error al conectar con Kafka', exc_info=True)
return None
def fetch_raw(recipe_url):
"""
Obtiene el HTML sin procesar de la URL de la receta.
"""
html = None
logging.info('[i] Procesando... {}'.format(recipe_url))
try:
r = requests.get(recipe_url, headers=headers)
if r.status_code == 200:
html = r.text
except Exception as ex:
logging.error(
'[!] Error al acceder al HTML sin procesar',
exc_info=True
)
return html.strip() if html else ''
def get_recipes():
"""
Obtiene una lista de recetas de la URL de origen.
"""
recipes = []
url = 'https://www.allrecipes.com/recipes/96/salad/'
logging.info('[i] Accediendo a la lista de recetas...')
try:
r = requests.get(url, headers=headers)
logging.info('[i] Código de respuesta: {}'.format(r.status_code))
if r.status_code == 200:
logging.info('[i] Página accesible, procesando...')
html = r.text
soup = BeautifulSoup(html, 'lxml')
# Selecciona los elementos <a> con la clase mntl-card-list-items
links = soup.select('a.mntl-card-list-items')
logging.info('[i] Se encontraron {} recetas'.format(len(links)))
for link in links:
# Obtiene el título del texto de card__title-text
recipe_title = link.select_one(
'.card__title-text').get_text(strip=True)
recipe_url = link['href']
logging.info(
f'[i] Procesando receta: {recipe_title}, enlace: {recipe_url}'
)
sleep(2)
recipe_html = fetch_raw(recipe_url)
if recipe_html:
recipes.append(recipe_html)
logging.info(
'[i] Se obtuvieron {} recetas en total.'.format(len(recipes)))
else:
logging.error(
'[!] No se pudo acceder a la página de recetas, código de respuesta: {}'.format(r.status_code))
except Exception as ex:
logging.error('[!] Error en get_recipes', exc_info=True)
return recipes
def main():
"""
Ejecuta el proceso de obtención y publicación de recetas.
"""
global headers
headers = {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/66.0.3359.181 Safari/537.36',
'Pragma': 'no-cache'
}
logging.info('Iniciando el productor de recetas...')
all_recipes = get_recipes() # Obtener recetas
if all_recipes:
producer = connect_kafka_producer() # Conectar con Kafka
if producer:
for recipe in all_recipes:
publish_message(producer, 'raw_recipes', 'raw', recipe.strip())
producer.close() # Cerrar el productor
else:
logging.error('[!] El productor de Kafka no está disponible.')
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,4 @@
kafka-python
requests
beautifulsoup4
lxml

View File

@@ -0,0 +1,191 @@
# Cómo Usar Elasticsearch en Python con Docker
**Índice**
- [Cómo Usar Elasticsearch en Python con Docker](#cómo-usar-elasticsearch-en-python-con-docker)
- [¿Qué es Elasticsearch?](#qué-es-elasticsearch)
- [Requisitos Previos](#requisitos-previos)
- [Crear un Clúster Local del Elastic Stack con Docker Compose](#crear-un-clúster-local-del-elastic-stack-con-docker-compose)
- [Desplegar el Elastic Stack](#desplegar-el-elastic-stack)
- [Dockerizar el Programa Python](#dockerizar-el-programa-python)
- [Programa Python](#programa-python)
- [Conectar a Tu Clúster desde el Contenedor Python](#conectar-a-tu-clúster-desde-el-contenedor-python)
- [Ejecutar los Scripts Python en Docker](#ejecutar-los-scripts-python-en-docker)
- [Construir y Ejecutar el Contenedor Python](#construir-y-ejecutar-el-contenedor-python)
- [Visualización de Datos con Kibana](#visualización-de-datos-con-kibana)
- [Usar Logstash para Ingestión de Datos](#usar-logstash-para-ingestión-de-datos)
- [Recopilar Métricas con Metricbeat](#recopilar-métricas-con-metricbeat)
- [Eliminar Documentos del Índice](#eliminar-documentos-del-índice)
- [Eliminar un Índice](#eliminar-un-índice)
- [Conclusión](#conclusión)
[Elasticsearch](https://www.elastic.co/elasticsearch/) (ES) es una tecnología utilizada por muchas empresas, incluyendo GitHub, Uber y Facebook. Aunque no se enseña con frecuencia en cursos de Ciencia de Datos, es probable que te encuentres con ella en tu carrera profesional.
Muchos científicos de datos enfrentan dificultades al configurar un entorno local o al intentar interactuar con Elasticsearch en Python. Además, no hay muchos recursos actualizados disponibles.
Por eso, decidí crear este tutorial. Te enseñará lo básico y podrás configurar un clúster de Elasticsearch en tu máquina para el desarrollo local rápidamente. También aprenderás a crear un índice, almacenar datos en él y usarlo para buscar información.
¡Vamos a empezar!
## ¿Qué es Elasticsearch?
Elasticsearch es un motor de búsqueda distribuido, rápido y fácil de escalar, capaz de manejar datos textuales, numéricos, geoespaciales, estructurados y no estructurados. Es un motor de búsqueda popular para aplicaciones, sitios web y análisis de registros. También es un componente clave del Elastic Stack (también conocido como ELK Stack), que incluye Logstash y Kibana, junto con Beats para la recolección de datos.
Para entender el funcionamiento interno de Elasticsearch, piensa en él como dos procesos distintos. Uno es la ingestión, que normaliza y enriquece los datos brutos antes de indexarlos usando un [índice invertido](https://www.elastic.co/guide/en/elasticsearch/reference/current/documents-indices.html). El segundo es la recuperación, que permite a los usuarios recuperar datos escribiendo consultas que se ejecutan contra el índice.
Eso es todo lo que necesitas saber por ahora. A continuación, prepararás tu entorno local para ejecutar un clúster del Elastic Stack.
## Requisitos Previos
Debes configurar algunas cosas antes de comenzar. Asegúrate de tener lo siguiente listo:
1. Instala Docker y Docker Compose.
2. Descarga los datos necesarios.
## Crear un Clúster Local del Elastic Stack con Docker Compose
Para desplegar el Elastic Stack (Elasticsearch, Kibana, Logstash y Beats) en tu máquina local, utilizaremos Docker Compose. Este enfoque simplifica el despliegue y la administración de múltiples servicios.
Mediante el fichero [docker-compose.yaml](docker-compose.yaml) vamos a configurar los servicios para Elasticsearch, Kibana, Logstash, Metricbeat, y la aplicación Python, todos conectados a una red llamada `elastic`.
### Desplegar el Elastic Stack
Para iniciar los servicios, abre una terminal en el directorio donde se encuentra `docker-compose.yml` y ejecuta el siguiente comando:
```bash
docker compose up -d
```
Este comando iniciará Elasticsearch, Kibana, Logstash, Metricbeat y la aplicación Python en contenedores separados. Aquí tienes un desglose de los servicios:
- **Elasticsearch**: El motor de búsqueda que almacena y analiza los datos.
- **Kibana**: Una interfaz de usuario para visualizar y explorar datos en Elasticsearch.
- **Logstash**: Una herramienta de procesamiento de datos que ingiere datos desde múltiples fuentes, los transforma y los envía a un destino como Elasticsearch.
- **Metricbeat**: Un agente de monitoreo que recopila métricas del sistema y las envía a Elasticsearch.
- **Python App**: Un contenedor que ejecutará tus scripts de Python para interactuar con Elasticsearch. (Hasta que no construyamos el contenedor, este servicio fallará).
## Dockerizar el Programa Python
Para dockerizar la aplicación Python, utilizaremos el archivo [Dockerfile](./app/Dockerfile) del directorio `app`. El directorio `app` también debe contener el programa Python y un archivo [requirements.txt](./app/requirements.txt) para manejar las dependencias.
### Programa Python
El archivo [main.py](./app/main.py) en el directorio `app` manejará la conexión a Elasticsearch, la creación de índices y la carga de datos.
Este programa realiza las siguientes acciones:
1. **Crea un índice** en Elasticsearch con las configuraciones de mapeo necesarias.
2. **Carga datos** desde un archivo CSV (`wiki_movie_plots_deduped.csv`) al índice creado.
## Conectar a Tu Clúster desde el Contenedor Python
Tu aplicación Python se conectará al clúster de Elasticsearch usando el hostname `elasticsearch`, que es el nombre del servicio definido en `docker-compose.yml`.
## Ejecutar los Scripts Python en Docker
Una vez que hayas creado los archivos `Dockerfile`, `requirements.txt` y `main.py`, puedes construir la imagen de Docker para tu aplicación Python y ejecutarla usando Docker Compose.
### Construir y Ejecutar el Contenedor Python
1. Construye la imagen de Docker para tu aplicación Python:
```bash
docker compose build python-app
```
2. Ejecuta el contenedor:
```bash
docker compose up python-app
```
La aplicación Python se ejecutará y cargará los datos en Elasticsearch. Puedes verificar que los datos se hayan indexado correctamente ejecutando una consulta en Elasticsearch o usando Kibana para explorar los datos:
```python
es.search(index="movies", body={"query": {"match_all": {}}})
```
## Visualización de Datos con Kibana
Kibana es una herramienta de visualización que se conecta a Elasticsearch y te permite explorar y visualizar tus datos.
Para acceder a Kibana, abre un navegador web y navega a `http://localhost:5601`. Deberías ver la interfaz de Kibana, donde puedes crear visualizaciones y dashboards.
1. **Crea un índice en Kibana**: Ve a *Management > Index Patterns* y crea un nuevo patrón de índice para el índice `movies`.
2. **Explora tus datos**: Usa la herramienta *Discover* para buscar y explorar los datos que has indexado.
3. **Crea visualizaciones**: En la sección *Visualize*, crea gráficos y tablas que te permitan entender mejor tus datos.
## Usar Logstash para Ingestión de Datos
Logstash es una herramienta para procesar y transformar datos antes de enviarlos a Elasticsearch. Aquí tienes un ejemplo básico de cómo configurar Logstash para que procese y envíe datos a Elasticsearch.
Mediante el archivo de configuración en la carpeta `logstash-config/` llamado [pipeline.conf](./logstash-config/pipeline.conf) realizaremos los siguientes pasos:
- Lee un archivo CSV de entrada.
- Usa el filtro `csv` para descomponer cada línea en campos separados.
- Usa `mutate` para convertir el año de lanzamiento en un número entero.
- Envía los datos procesados a Elasticsearch.
Para usar Logstash con esta configuración, asegúrate de que el archivo `wiki_movie_plots_deduped.csv` esté accesible en tu sistema y modifica la ruta en el archivo de configuración según sea necesario. Luego, reinicia el contenedor de Logstash para aplicar los cambios.
```bash
docker compose restart logstash
```
## Recopilar Métricas con Metricbeat
Metricbeat es un agente ligero que recopila métricas del sistema y las envía a Elasticsearch. Está configurado en el archivo `docker-compose.yml` que has creado anteriormente.
Para ver las métricas en Kibana:
1. **Configura Metricbeat**: Edita el archivo de configuración de Metricbeat si necesitas recopilar métricas específicas.
2. **Importa dashboards preconfigurados**: En Kibana, navega a *Add Data > Metricbeat* y sigue las instrucciones para importar los dashboards preconfigurados.
3. **Explora las métricas**: Usa los dashboards importados para explorar las métricas de tu sistema.
## Eliminar Documentos del Índice
Puedes usar el siguiente código para eliminar documentos del índice:
```python
es.delete(index="movies", id="2500")
```
El código anterior eliminará el documento con ID 2500 del índice `movies`.
## Eliminar un Índice
Finalmente, si por alguna razón deseas eliminar un índice (y todos sus documentos), aquí te mostramos cómo hacerlo:
```python
es.indices.delete(index='movies')
```
## Conclusión
Este tutorial te enseñó los conceptos básicos de Elasticsearch y cómo usarlo junto con el Elastic Stack y Docker. Esto será útil en tu carrera, ya que seguramente te encontrarás con Elasticsearch en algún momento.
En este tutorial, has aprendido:
- Cómo configurar un clúster del Elastic Stack en tu máquina usando Docker Compose.
- Cómo dockerizar una aplicación Python para interactuar con Elasticsearch.
- Cómo crear un índice y almacenar datos en él.
- Cómo buscar tus datos usando Elasticsearch.
- Cómo visualizar datos con Kibana.
- Cómo procesar datos con Logstash.
- Cómo recopilar métricas con Metricbeat.
Explora más funcionalidades de Elasticsearch y el Elastic Stack para sacar el máximo provecho de tus datos.

View File

@@ -0,0 +1,15 @@
# Usa la imagen base de Python
FROM python:3.9-slim
# Establece el directorio de trabajo
WORKDIR /app
# Copia el archivo requirements.txt e instala las dependencias
COPY requirements.txt /app/
RUN pip install --no-cache-dir -r requirements.txt
# Copia el código fuente a /app
COPY . /app/
# Comando para ejecutar la aplicación
CMD ["python", "main.py"]

View File

@@ -0,0 +1,114 @@
import pandas as pd
from elasticsearch import Elasticsearch
from elasticsearch.helpers import bulk
# Configura la conexión a Elasticsearch
es = Elasticsearch("http://elasticsearch:9200")
def create_index():
"""
Crea un índice en Elasticsearch con el nombre 'movies' si no existe.
Define el mapeo del índice para los campos de los documentos.
"""
# Define el mapeo del índice 'movies'
mappings = {
"properties": {
# Campo para el título de la película
"title": {"type": "text", "analyzer": "english"},
# Campo para la etnicidad
"ethnicity": {"type": "text", "analyzer": "standard"},
# Campo para el director
"director": {"type": "text", "analyzer": "standard"},
# Campo para el elenco
"cast": {"type": "text", "analyzer": "standard"},
# Campo para el género
"genre": {"type": "text", "analyzer": "standard"},
# Campo para el argumento de la película
"plot": {"type": "text", "analyzer": "english"},
# Campo para el año de lanzamiento
"year": {"type": "integer"},
# Campo para la página de Wikipedia
"wiki_page": {"type": "keyword"}
}
}
# Verifica si el índice 'movies' ya existe
if not es.indices.exists(index="movies"):
# Crea el índice 'movies' si no existe
es.indices.create(index="movies", mappings=mappings)
print("\n[+] Índice 'movies' creado.")
else:
print("\n[!] El índice 'movies' ya existe.")
def load_data():
"""
Carga datos desde un archivo CSV a Elasticsearch.
"""
try:
# Lee el archivo CSV
df = pd.read_csv("/app/wiki_movie_plots_deduped.csv", quoting=1)
# Verifica el número de filas en el DataFrame
num_rows = len(df)
sample_size = min(5000, num_rows)
# Elimina filas con valores nulos y toma una muestra
df = df.dropna().sample(sample_size, random_state=42).reset_index(drop=True)
except Exception as e:
print(f"\n[!] Error al leer el archivo CSV: {e}")
return
# Prepara los datos para la carga en Elasticsearch
bulk_data = [
{
"_index": "movies", # Nombre del índice en Elasticsearch
"_id": i, # ID del documento en Elasticsearch
"_source": {
"title": row["Title"], # Título de la película
"ethnicity": row["Origin/Ethnicity"], # Etnicidad
"director": row["Director"], # Director
"cast": row["Cast"], # Elenco
"genre": row["Genre"], # Género
"plot": row["Plot"], # Argumento
"year": row["Release Year"], # Año de lanzamiento
"wiki_page": row["Wiki Page"], # Página de Wikipedia
}
}
for i, row in df.iterrows() # Itera sobre cada fila del DataFrame
]
try:
# Carga los datos en Elasticsearch en bloques
bulk(es, bulk_data)
print("\n[+] Datos cargados en Elasticsearch.")
except Exception as e:
print(f"\n[!] Error al cargar datos en Elasticsearch: {e}")
def main():
"""
Función principal que crea el índice y carga los datos.
"""
create_index() # Crea el índice en Elasticsearch
load_data() # Carga los datos en Elasticsearch
if __name__ == "__main__":
# Ejecuta la función principal si el script se ejecuta directamente
main()

View File

@@ -0,0 +1,3 @@
pandas==2.0.1
numpy==1.24.2
elasticsearch==8.8.0

View File

@@ -0,0 +1,7 @@
Title,Origin/Ethnicity,Director,Cast,Genre,Plot,Release Year,Wiki Page
The Shawshank Redemption,American,Frank Darabont,"Tim Robbins, Morgan Freeman",Drama,"Two imprisoned men bond over a number of years, finding solace and eventual redemption through acts of common decency.",1994,https://en.wikipedia.org/wiki/The_Shawshank_Redemption
The Godfather,American,Francis Ford Coppola,"Marlon Brando, Al Pacino",Crime,"The aging patriarch of an organized crime dynasty transfers control of his clandestine empire to his reluctant son.",1972,https://en.wikipedia.org/wiki/The_Godfather
The Dark Knight,American,Christopher Nolan,"Christian Bale, Heath Ledger",Action,"When the menace known as the Joker emerges from his mysterious past, he wreaks havoc and chaos on the people of Gotham.",2008,https://en.wikipedia.org/wiki/The_Dark_Knight
Pulp Fiction,American,Quentin Tarantino,"John Travolta, Uma Thurman",Crime,"The lives of two mob hitmen, a boxer, a gangster's wife, and a pair of diner bandits intertwine in four tales of violence and redemption.",1994,https://en.wikipedia.org/wiki/Pulp_Fiction
The Lord of the Rings: The Return of the King,American,Peter Jackson,"Elijah Wood, Viggo Mortensen",Fantasy,"The final battle for Middle-earth begins. The forces of good and evil are drawn into a confrontation and the outcome will determine the fate of the world.",2003,https://en.wikipedia.org/wiki/The_Lord_of_the_Rings:_The_Return_of_the_King
Inception,American,Christopher Nolan,"Leonardo DiCaprio, Joseph Gordon-Levitt",Sci-Fi,"A thief who enters the dreams of others to steal secrets from their subconscious is given the inverse task of planting an idea into the mind of a CEO.",2010,https://en.wikipedia.org/wiki/Inception
1 Title Origin/Ethnicity Director Cast Genre Plot Release Year Wiki Page
2 The Shawshank Redemption American Frank Darabont Tim Robbins, Morgan Freeman Drama Two imprisoned men bond over a number of years, finding solace and eventual redemption through acts of common decency. 1994 https://en.wikipedia.org/wiki/The_Shawshank_Redemption
3 The Godfather American Francis Ford Coppola Marlon Brando, Al Pacino Crime The aging patriarch of an organized crime dynasty transfers control of his clandestine empire to his reluctant son. 1972 https://en.wikipedia.org/wiki/The_Godfather
4 The Dark Knight American Christopher Nolan Christian Bale, Heath Ledger Action When the menace known as the Joker emerges from his mysterious past, he wreaks havoc and chaos on the people of Gotham. 2008 https://en.wikipedia.org/wiki/The_Dark_Knight
5 Pulp Fiction American Quentin Tarantino John Travolta, Uma Thurman Crime The lives of two mob hitmen, a boxer, a gangster's wife, and a pair of diner bandits intertwine in four tales of violence and redemption. 1994 https://en.wikipedia.org/wiki/Pulp_Fiction
6 The Lord of the Rings: The Return of the King American Peter Jackson Elijah Wood, Viggo Mortensen Fantasy The final battle for Middle-earth begins. The forces of good and evil are drawn into a confrontation and the outcome will determine the fate of the world. 2003 https://en.wikipedia.org/wiki/The_Lord_of_the_Rings:_The_Return_of_the_King
7 Inception American Christopher Nolan Leonardo DiCaprio, Joseph Gordon-Levitt Sci-Fi A thief who enters the dreams of others to steal secrets from their subconscious is given the inverse task of planting an idea into the mind of a CEO. 2010 https://en.wikipedia.org/wiki/Inception

View File

@@ -0,0 +1,72 @@
version: '3.8'
services:
elasticsearch:
image: docker.elastic.co/elasticsearch/elasticsearch:7.15.2
container_name: elasticsearch
environment:
- discovery.type=single-node
- xpack.security.enabled=false
ports:
- "9200:9200"
networks:
- elastic
volumes:
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro
kibana:
image: docker.elastic.co/kibana/kibana:7.15.2
container_name: kibana
environment:
ELASTICSEARCH_HOSTS: http://elasticsearch:9200
ports:
- "5601:5601"
networks:
- elastic
volumes:
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro
logstash:
image: docker.elastic.co/logstash/logstash:7.15.2
container_name: logstash
volumes:
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro
- ./logstash-config/:/usr/share/logstash/pipeline/
environment:
LS_JAVA_OPTS: "-Xmx256m -Xms256m"
ports:
- "5000:5000"
- "9600:9600"
networks:
- elastic
metricbeat:
image: docker.elastic.co/beats/metricbeat:7.15.2
container_name: metricbeat
command: metricbeat -e -E output.elasticsearch.hosts=["http://elasticsearch:9200"]
depends_on:
- elasticsearch
networks:
- elastic
volumes:
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro
python-app:
build:
context: ./app
dockerfile: Dockerfile
container_name: python-app
volumes:
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro
- ./app:/app
networks:
- elastic
networks:
elastic:
driver: bridge

View File

@@ -0,0 +1,26 @@
input {
file {
path => "/path/to/your/data/wiki_movie_plots_deduped.csv"
start_position => "beginning"
sincedb_path => "/dev/null"
}
}
filter {
csv {
separator => ","
columns => ["Release Year", "Title", "Origin/Ethnicity", "Director", "Cast", "Genre", "Wiki Page", "Plot"]
}
mutate {
convert => { "Release Year" => "integer" }
}
}
output {
elasticsearch {
hosts => ["http://elasticsearch:9200"]
index => "movies"
}
stdout { codec => rubydebug }
}

View File

@@ -0,0 +1,19 @@
# Usar imagen de python 3.9 slim
FROM python:3.9-slim
# Establece el directorio de trabajo
WORKDIR /app
# Copia el archivo requirements.txt e instala las dependencias
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copia el código fuente a /app
COPY app app
# Variables de entorno
ENV PYTHONUNBUFFERED 1
ENV PYTHONPATH=/app
# Comando para ejecutar la aplicación
CMD ["python", "app/main.py"]

View File

@@ -0,0 +1,251 @@
# Configuración de Grafana con Prometheus para proyectos Python usando Docker
En este artículo, cubriremos cómo configurar el monitoreo de servicios para proyectos Python con Prometheus y Grafana utilizando contenedores Docker.
El monitoreo de servicios nos permite analizar eventos específicos en nuestros proyectos, tales como llamadas a bases de datos, interacción con API, seguimiento del rendimiento de recursos, etc. Puedes detectar fácilmente comportamientos inusuales o descubrir pistas útiles detrás de los problemas.
## Escenario de Caso Real
Teníamos un servicio temporal que redirigía las solicitudes entrantes de sitios web específicos hasta que Google dejara de indexar estas páginas web. Utilizando el monitoreo de servicios, podemos ver fácilmente el conteo de redirecciones de forma regular. En un momento determinado en el futuro, el número de redirecciones disminuirá, lo que significa que el tráfico se ha migrado al sitio web objetivo, y ya no necesitaremos que este servicio esté en funcionamiento.
## Configuración de contenedores Docker
Vamos a ejecutar todos nuestros servicios localmente en contenedores Docker. En las grandes empresas, existe un servicio global para Prometheus y Grafana que incluye todos los proyectos de monitoreo de microservicios. Probablemente ni siquiera necesites escribir ningún pipeline de despliegue para las herramientas de monitoreo de servicios.
### Archivo docker-compose.yml
Comencemos creando un archivo `docker-compose.yml` con los servicios necesarios:
```yaml
services:
prometheus:
image: prom/prometheus:latest
container_name: prometheus
ports:
- "9090:9090"
volumes:
- ${PWD}/prometheus.yml:/etc/prometheus/prometheus.yml
restart: unless-stopped
networks:
- monitoring-net
healthcheck:
test: ["CMD", "wget", "--spider", "http://localhost:9090/-/healthy"]
interval: 1m30s
timeout: 30s
retries: 3
start_period: 30s
grafana:
hostname: grafana
image: grafana/grafana:latest
container_name: grafana
ports:
- 3001:3000
restart: unless-stopped
networks:
- monitoring-net
healthcheck:
test: ["CMD", "wget", "--spider", "http://localhost:3000/api/health"]
interval: 1m30s
timeout: 30s
retries: 3
start_period: 30s
app:
build:
context: .
dockerfile: Dockerfile
depends_on:
- prometheus
ports:
- "8080:8080"
networks:
- monitoring-net
command: ["python3", "app/main.py"]
networks:
monitoring-net:
driver: bridge
```
El punto más importante de la configuración anterior es montar el archivo `prometheus.yml` desde nuestra máquina local al contenedor Docker. Este archivo incluye la configuración para obtener datos (métricas) de nuestro servicio de aplicación o proyecto Python. Sin el archivo, no podrás ver las métricas personalizadas que incluye tu proyecto.
Por lo tanto, crea un nuevo archivo llamado `prometheus.yml` en el nivel raíz de tu proyecto.
### Archivo prometheus.yml
```yaml
global:
scrape_interval: 15s
scrape_configs:
- job_name: 'app'
static_configs:
- targets: ['app:8080']
```
Ahora, Prometheus obtendrá datos de nuestro proyecto.
Todas las demás configuraciones en el archivo de composición son autoexplicativas y no son muy críticas, como mencionamos para Prometheus.
## Crear un nuevo proyecto Python
Ahora, vamos a crear una aplicación Python muy sencilla que creará una métrica para rastrear el tiempo empleado y las solicitudes realizadas. Crea una nueva carpeta llamada `app` en el nivel raíz del proyecto. También incluye `__init__.py` para marcarla como un paquete Python.
A continuación, crea otro archivo llamado `main.py` que contendrá la lógica principal del programa, como se muestra a continuación:
### Archivo app/main.py
```python
from prometheus_client import start_http_server, Summary, Counter, Gauge, Histogram
import random
import time
# Create metrics to track time spent, requests made, and other events.
REQUEST_TIME = Summary('request_processing_seconds', 'Time spent processing request')
UPDATE_COUNT = Counter('update_count', 'Number of updates')
ACTIVE_REQUESTS = Gauge('active_requests', 'Number of active requests')
REQUEST_LATENCY = Histogram('request_latency_seconds', 'Request latency in seconds')
# Decorate function with metrics.
@REQUEST_TIME.time()
@REQUEST_LATENCY.time()
def process_request(t):
"""A dummy function that takes some time."""
ACTIVE_REQUESTS.inc()
time.sleep(t)
ACTIVE_REQUESTS.dec()
def main():
# Start up the server to expose the metrics.
start_http_server(8000)
print("[*] Starting server on port 8000...")
# Generate some requests.
while True:
msg = random.random()
process_request(msg)
update_increment = random.randint(1, 100)
UPDATE_COUNT.inc(update_increment)
print(f'[+] Processing request: {msg:.4f} | Updates: {update_increment}')
time.sleep(random.uniform(0.5, 2.0)) # Random delay between requests
if __name__ == '__main__':
main()
```
Aquí, estamos utilizando un paquete de Python llamado `prometheus_client` para interactuar con Prometheus. Permite fácilmente la creación de diferentes tipos de métricas que nuestro proyecto requiere.
El código anterior se copió de la documentación oficial de `prometheus_client`, que simplemente crea una nueva métrica llamada `request_processing_seconds` que mide el tiempo empleado en esa solicitud en particular. Cubriremos otros tipos de métricas más adelante en este artículo.
Ahora, vamos a crear un `Dockerfile` y un `requirements.txt` para construir nuestro proyecto.
### Archivo Dockerfile
```dockerfile
# Usa la imagen base oficial de Python
FROM python:3.9-slim
# Establece el directorio de trabajo
WORKDIR /app
# Copia los archivos de requerimientos
COPY requirements.txt .
# Instala las dependencias
RUN pip install --no-cache-dir -r requirements.txt
# Copia el código de la aplicación
COPY app/ /app
# Expone el puerto de la aplicación
EXPOSE 8000
# Comando por defecto para ejecutar la aplicación
CMD ["python", "main.py"]
```
### Archivo requirements.txt
```txt
prometheus_client
```
Ahora estamos listos para construir y ejecutar los servicios.
## Ejecución de los servicios
Para ver todo en acción, ejecuta los siguientes comandos:
```bash
docker-compose up -d --build
```
Esto levantará todos los servicios definidos en `docker-compose.yml`, compilando la imagen de la aplicación en el proceso.
## Configuración de Grafana
En esta sección, usaremos Prometheus como fuente de datos para mostrar métricas en gráficos de Grafana.
1. Navega a `http://localhost:3001` para ver la página de inicio de sesión de Grafana y utiliza `admin` tanto para el nombre de usuario como para la contraseña. Luego, requerirá agregar una nueva contraseña, y puedes mantenerla igual ya que estamos probando localmente.
2. Después de iniciar sesión correctamente, deberías ver el panel predeterminado de Grafana. Luego selecciona **Data Sources** en la página.
![](https://res.cloudinary.com/practicaldev/image/fetch/s--dckjv7Wt--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_800/https://www.thepylot.dev/content/images/size/w1000/2022/06/Screen-Shot-2022-06-11-at-16.51.10.png)
3. A continuación, selecciona Prometheus como fuente de datos:
![](https://res.cloudinary.com/practicaldev/image/fetch/s--v5aNeycU--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_800/https://www.thepylot.dev/content/images/size/w1000/2022/06/Screen-Shot-2022-06-11-at-16.53.47.png)
4. Luego requerirá la URL en la que se está ejecutando el servicio de Prometheus, que será el nombre del servicio de Docker que creamos `http://prometheus:9090`.
![](https://res.cloudinary.com/practicaldev/image/fetch/s--cSw8NHIy--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_800/https://www.thepylot.dev/content/images/size/w1000/2022/06/Screen-Shot-2022-06-11-at-16.56.09.png)
5. Finalmente, haz clic en el botón **Save & Test** para verificar la conexión de la fuente de datos.
![](https://res.cloudinary.com/practicaldev/image/fetch/s--i4CVmg76--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%
2Cw_800/https://www.thepylot.dev/content/images/size/w1000/2022/06/Screen-Shot-2022-06-11-at-16.56.55.png)
¡Genial! Ahora nuestro Grafana está listo para ilustrar las métricas que provienen de Prometheus.
## Creación de un nuevo dashboard en Grafana
1. Navega a `http://localhost:3001/dashboards` para crear un nuevo dashboard y agregar un nuevo panel. Haz clic en **New Dashboard** y luego en **Add New Panel** para la inicialización.
![](https://res.cloudinary.com/practicaldev/image/fetch/s--yZiKAdWz--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_800/https://www.thepylot.dev/content/images/size/w1000/2022/06/Screen-Shot-2022-06-11-at-17.02.21.png)
2. A continuación, seleccionamos código dentro del panel de consultas y escribimos `request_processing_seconds`. Podrás ver 3 tipos diferentes de sufijos con los datos de tus métricas personalizadas. Prometheus simplemente aplica diferentes tipos de cálculos a tus datos de manera predeterminada.
3. Selecciona una de las opciones y haz clic en **Run Query** para verla en el gráfico.
![](https://res.cloudinary.com/practicaldev/image/fetch/s--E8k1_d3s--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_800/https://www.thepylot.dev/content/images/size/w1000/2022/06/Screen-Shot-2022-06-11-at-17.14.32.png)
Finalmente, podemos ver las métricas de nuestro proyecto ilustradas muy bien por Grafana.
## Otras métricas
Hay muchos tipos de métricas disponibles según lo que requiera el proyecto. Si queremos contar un evento específico, como actualizaciones de registros en la base de datos, podemos usar `Counter()`.
Si tenemos una cola de mensajes como Kafka o RabbitMQ, podemos usar `Gauge()` para ilustrar el número de elementos que esperan en la cola.
Intenta agregar otra métrica en `main.py` como se muestra a continuación y aplica los mismos pasos para conectar Prometheus con Grafana:
### Ejemplo de una nueva métrica con Counter
```python
UPDATE_COUNT = Counter('update_count', 'Number of updates')
# Dentro de tu función de procesamiento
update_increment = random.randint(1, 100)
UPDATE_COUNT.inc(update_increment)
```
No olvides construir nuevamente la imagen de Docker para todos los servicios:
```bash
docker-compose up -d --build
```
---

View File

@@ -0,0 +1,42 @@
from prometheus_client import start_http_server, Summary, Counter, Gauge, Histogram
import random
import time
# Create metrics to track time spent, requests made, and other events.
REQUEST_TIME = Summary('request_processing_seconds',
'Time spent processing request')
UPDATE_COUNT = Counter('update_count', 'Number of updates')
ACTIVE_REQUESTS = Gauge('active_requests', 'Number of active requests')
REQUEST_LATENCY = Histogram(
'request_latency_seconds', 'Request latency in seconds')
# Decorate function with metrics.
@REQUEST_TIME.time()
@REQUEST_LATENCY.time()
def process_request(t):
"""A dummy function that takes some time."""
ACTIVE_REQUESTS.inc()
time.sleep(t)
ACTIVE_REQUESTS.dec()
def main():
# Start up the server to expose the metrics.
start_http_server(8000)
print("[*] Starting server on port 8000...")
# Generate some requests.
while True:
msg = random.random()
process_request(msg)
update_increment = random.randint(1, 100)
UPDATE_COUNT.inc(update_increment)
print(
f'[+] Processing request: {msg:.4f} | Updates: {update_increment}')
time.sleep(random.uniform(0.5, 2.0)) # Random delay between requests
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,58 @@
services:
prometheus:
image: prom/prometheus:latest
container_name: prometheus
ports:
- "9090:9090"
volumes:
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro
- ${PWD}/prometheus.yml:/etc/prometheus/prometheus.yml
restart: unless-stopped
networks:
- monitoring-net
healthcheck:
test: ["CMD", "wget", "--spider", "http://localhost:9090/-/healthy"]
interval: 1m30s
timeout: 30s
retries: 3
start_period: 30s
grafana:
hostname: grafana
image: grafana/grafana:latest
container_name: grafana
ports:
- 3001:3000
restart: unless-stopped
networks:
- monitoring-net
volumes:
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro
healthcheck:
test: ["CMD", "wget", "--spider", "http://localhost:3000/api/health"]
interval: 1m30s
timeout: 30s
retries: 3
start_period: 30s
app:
build:
context: .
dockerfile: Dockerfile
depends_on:
- prometheus
ports:
- "8080:8080"
networks:
- monitoring-net
volumes:
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro
command: ["python3", "app/main.py"]
networks:
monitoring-net:
driver: bridge

View File

@@ -0,0 +1,10 @@
global:
scrape_interval: 15s # when Prometheus is pulling data from exporters etc
evaluation_interval: 30s # time between each evaluation of Prometheus' alerting rules
scrape_configs:
- job_name: app # your project name
static_configs:
- targets:
- app:8000

View File

@@ -0,0 +1 @@
prometheus-client

View File

@@ -0,0 +1,203 @@
# SonarQube Python Analysis con Docker
Este tutorial te guía a través de la configuración y ejecución de un análisis de calidad de código de un proyecto Python utilizando SonarQube y Docker Compose.
## Requisitos previos
- Docker y Docker Compose instalados en tu máquina.
## Estructura del proyecto
Asegúrate de que tu directorio de proyecto tenga la siguiente estructura:
```
my-python-project/
├── app/
│ ├── __init__.py
│ ├── main.py
│ └── utils.py
├── sonar-project.properties
├── docker-compose.yaml
└── README.md
```
## Paso 1: Crear un Proyecto Python de Ejemplo
Dentro de la carpeta `app`, crea los siguientes archivos:
### `main.py`
```python
def greet(name):
return f"Hola, {name}!"
if __name__ == "__main__":
print(greet("mundo"))
```
### `utils.py`
```python
def add(a, b):
return a + b
def subtract(a, b):
return a - b
```
### `__init__.py`
Este archivo puede estar vacío, simplemente indica que `app` es un paquete Python.
## Paso 2: Configurar SonarQube y SonarScanner
### 2.1. Archivo `sonar-project.properties`
En la raíz de tu proyecto, crea un archivo llamado `sonar-project.properties` con el siguiente contenido:
```properties
sonar.projectKey=my-python-project
sonar.projectName=My Python Project
sonar.projectVersion=1.0
sonar.sources=./app
sonar.language=py
sonar.python.version=3.8
sonar.host.url=http://sonarqube:9000
sonar.login=admin
sonar.password=admin
```
Asegúrate de reemplazar `my-python-project`, `My Python Project`, y la ruta del código fuente según corresponda.
## Paso 3: Crear `docker-compose.yaml`
Crea un archivo `docker-compose.yaml` en la raíz de tu proyecto con el siguiente contenido:
```yaml
services:
sonarqube:
image: sonarqube:lts-community
container_name: sonarqube
ports:
- "9001:9000"
environment:
- SONARQUBE_JDBC_URL=jdbc:postgresql://sonarqube-db:5432/sonar
- SONARQUBE_JDBC_USERNAME=sonar
- SONARQUBE_JDBC_PASSWORD=sonar
- SONAR_ES_BOOTSTRAP_CHECKS_DISABLE=true
volumes:
- sonarqube_data:/opt/sonarqube/data
- sonarqube_logs:/opt/sonarqube/logs
- sonarqube_extensions:/opt/sonarqube/extensions
networks:
- sonarnet
depends_on:
- elasticsearch
- sonarqube-db
elasticsearch:
image: docker.elastic.co/elasticsearch/elasticsearch:7.10.2
container_name: elasticsearch
environment:
- discovery.type=single-node
- ES_JAVA_OPTS=-Xms512m -Xmx512m
volumes:
- elasticsearch_data:/usr/share/elasticsearch/data
networks:
- sonarnet
sonarqube-db:
image: postgres:16.3-alpine3.20
container_name: sonarqube-db
environment:
- POSTGRES_USER=sonar
- POSTGRES_PASSWORD=sonar
- POSTGRES_DB=sonar
networks:
- sonarnet
volumes:
- sonar_db:/var/lib/postgresql
- sonar_db_data:/var/lib/postgresql/data
sonarscanner:
image: sonarsource/sonar-scanner-cli
container_name: sonarscanner
depends_on:
- sonarqube
volumes:
- .:/usr/src
working_dir: /usr/src
networks:
- sonarnet
entrypoint: ["sonar-scanner"]
networks:
sonarnet:
driver: bridge
volumes:
sonarqube_data:
sonarqube_logs:
sonarqube_extensions:
elasticsearch_data:
sonar_db:
sonar_db_data:
```
### Explicación
- **SonarQube**: Configurado para depender de Elasticsearch y PostgreSQL. Usa el contenedor `sonarqube:lts-community` que incluye SonarQube.
- **Elasticsearch**: Usa una imagen oficial de Elasticsearch (versión 7.10.2) adecuada para la versión de SonarQube que estás utilizando. Configurado para funcionar en modo de nodo único (`discovery.type=single-node`).
- **PostgreSQL**: Configurado para servir como la base de datos para SonarQube.
- **SonarScanner**: Configurado para ejecutar el análisis de código.
## Paso 4: Ejecutar el Análisis
1. **Inicia SonarQube, Elasticsearch y PostgreSQL**:
En el directorio raíz de tu proyecto, ejecuta el siguiente comando para iniciar los servicios:
```bash
docker-compose up -d
```
Esto levantará los contenedores de SonarQube, Elasticsearch y PostgreSQL. Dale unos minutos para que se inicien completamente.
2. **Ejecuta el Análisis**:
Una vez que SonarQube, Elasticsearch y PostgreSQL estén listos, ejecuta el siguiente comando para iniciar el análisis del código:
```bash
docker-compose run sonarscanner
```
## Paso 5: Ver los Resultados
Accede a la interfaz de SonarQube en [http://localhost:9000](http://localhost:9000). Inicia sesión con las credenciales predeterminadas:
- Usuario: `admin`
- Contraseña: `admin`
Aquí podrás ver los resultados del análisis de calidad de tu código, incluyendo cualquier vulnerabilidad o deuda técnica identificada.
## Paso 6: Detener y Limpiar los Contenedores
Para detener y eliminar los contenedores, ejecuta:
```bash
docker-compose down
```
Esto detendrá los servicios y limpiará los recursos.
## Notas Finales
- Asegúrate de ajustar el archivo `sonar-project.properties` para que coincida con las especificaciones de tu proyecto.
- Puedes personalizar la configuración del contenedor de SonarQube en el archivo `docker-compose.yaml` según sea necesario.
¡Disfruta de un análisis de código más limpio y seguro con SonarQube!

View File

@@ -0,0 +1,6 @@
def greet(name):
return f"Hola, {name}!"
if __name__ == "__main__":
print(greet("Mundo"))

View File

@@ -0,0 +1,6 @@
def add(a, b):
return a + b
def subtract(a, b):
return a - b

View File

@@ -0,0 +1,76 @@
services:
sonarqube:
image: sonarqube:lts-community
container_name: sonarqube
ports:
- "9001:9000"
environment:
- SONARQUBE_JDBC_URL=jdbc:postgresql://sonarqube-db:5432/sonar
- SONARQUBE_JDBC_USERNAME=sonar
- SONARQUBE_JDBC_PASSWORD=sonar
- SONAR_ES_BOOTSTRAP_CHECKS_DISABLE=true
volumes:
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro
- sonarqube_data:/opt/sonarqube/data
- sonarqube_logs:/opt/sonarqube/logs
- sonarqube_extensions:/opt/sonarqube/extensions
networks:
- sonarnet
depends_on:
- elasticsearch
- sonarqube-db
elasticsearch:
image: docker.elastic.co/elasticsearch/elasticsearch:7.10.2
container_name: elasticsearch
environment:
- discovery.type=single-node
- ES_JAVA_OPTS=-Xms512m -Xmx512m
volumes:
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro
- elasticsearch_data:/usr/share/elasticsearch/data
networks:
- sonarnet
sonarqube-db:
image: postgres:16.3-alpine3.20
container_name: sonarqube-db
environment:
- POSTGRES_USER=sonar
- POSTGRES_PASSWORD=sonar
- POSTGRES_DB=sonar
networks:
- sonarnet
volumes:
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro
- sonar_db:/var/lib/postgresql
- sonar_db_data:/var/lib/postgresql/data
sonarscanner:
image: sonarsource/sonar-scanner-cli
container_name: sonarscanner
depends_on:
- sonarqube
volumes:
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro
- .:/usr/src
working_dir: /usr/src
networks:
- sonarnet
entrypoint: ["sonar-scanner"]
networks:
sonarnet:
driver: bridge
volumes:
sonarqube_data:
sonarqube_logs:
sonarqube_extensions:
elasticsearch_data:
sonar_db:
sonar_db_data:

View File

@@ -0,0 +1,9 @@
sonar.projectKey=my-python-project
sonar.projectName=My Python Project
sonar.projectVersion=1.0
sonar.sources=./app
sonar.language=py
sonar.python.version=3.8
sonar.host.url=http://sonarqube:9000
sonar.login=admin
sonar.password=admin1

View File

@@ -0,0 +1,16 @@
# Pruebas de despliegues en local de infraestructuras
<div style="display:block; margin-left:auto; margin-right:auto; width:50%;">
![](https://www.lineadatascan.com/wp-content/uploads/2018/10/Infraestructura.gif)
</div>
| Nombre | Descripción | Nivel |
| ------------------------------------------------------- | ----------------------------------------------------- | ---------- |
| [redis](./01_redis_flask_docker/) | Despliegue de redis y flask | Básico |
| [rabbit](./02_rabbitmq/README.md) | Despliegue de distintas arquitecturas para rabbitmq | Intermedio |
| [Apache Kafka](./03_kafka/README.md) | Despliegue de Apache Kafka con productor y consumidor | Intermedio |
| [Elastic stack](./04_elastic_stack/README.md) | Despliegue de Elastic Stack | Básico |
| [Prometheus Grafana](./05_prometheus_grafana/README.md) | Despliegue de Prometheus y Grafana para medir Python | Básico |
| [SonarQube](./06_sonarqube/README.md) | Despliegue de SonarQube para analisis de Python | Básico |

View File

@@ -28,6 +28,7 @@ def def_handler(sig, frame):
Función manejadora de señales para salir del programa de manera elegante.
"""
logger.info("Saliendo del programa...")
print("\n[!] Saliendo del programa...")
exit(1)

View File

@@ -0,0 +1,10 @@
FROM python:3.10-alpine
WORKDIR /app
COPY . /app/
RUN pip install -r requirements.txt
CMD ["python", "clima_bot.py"]

View File

@@ -0,0 +1,126 @@
import asyncio
import logging
import requests
from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup
from telegram.ext import ApplicationBuilder, CommandHandler, MessageHandler, CallbackQueryHandler, CallbackContext
from telegram.ext import filters
import config
# Logging setup
logging.basicConfig(
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', level=logging.INFO)
logger = logging.getLogger(__name__)
TELEGRAM_API_TOKEN = config.BOT_TOKEN
WEATHER_API_KEY = config.WEATHER_API_KEY
BASE_WEATHER_URL = "http://api.openweathermap.org/data/2.5/weather?q={}&appid={}"
FORECAST_URL = "http://api.openweathermap.org/data/2.5/forecast?q={}&appid={}"
city = None
def weather_emoji(description):
"""Devuelve un emoji basado en la descripción del clima."""
description = description.lower()
if "clear" in description:
return "☀️"
elif "cloud" in description:
return "☁️"
elif "rain" in description:
return "🌧️"
elif "thunder" in description:
return "⛈️"
elif "snow" in description:
return "❄️"
elif "mist" in description or "fog" in description:
return "🌫️"
else:
return ""
async def start(update: Update, context: CallbackContext) -> None:
"""Manejador del comando /start. Solicita al usuario la ciudad en la que vive."""
logger.debug(f"Comando /start recibido de {update.message.chat.username}")
await update.message.reply_text("¿En qué ciudad vives?")
async def menu(update: Update, context: CallbackContext) -> None:
"""Manejador de mensajes. Muestra un menú con opciones al usuario después de recibir la ciudad."""
global city
city = update.message.text
logger.debug(f"Ciudad recibida: {city}")
keyboard = [
[InlineKeyboardButton("Clima Actual", callback_data='current_weather')],
[InlineKeyboardButton("Pronóstico del Tiempo", callback_data='forecast')],
]
reply_markup = InlineKeyboardMarkup(keyboard)
await update.message.reply_text('Elige una opción:', reply_markup=reply_markup)
async def button(update: Update, context: CallbackContext) -> None:
"""Manejador de botones. Procesa la opción seleccionada por el usuario."""
query = update.callback_query
await query.answer()
logger.debug(f"Opción seleccionada: {query.data}")
try:
if query.data == 'current_weather':
response = requests.get(BASE_WEATHER_URL.format(city, WEATHER_API_KEY))
response.raise_for_status()
data = response.json()
main = data['main']
weather_data = data['weather'][0]
celsius_temp = main['temp'] - 273.15
emoji = weather_emoji(weather_data['description'])
message = f"Clima actual en {city} {emoji}:\n"
message += f"Temperatura: {celsius_temp:.2f}°C\n"
message += f"Descripción: {weather_data['description'].capitalize()}\n"
message += f"Humedad: {main['humidity']}%\n"
await query.edit_message_text(text=message)
elif query.data == 'forecast':
response = requests.get(FORECAST_URL.format(city, WEATHER_API_KEY))
response.raise_for_status()
data = response.json()
message = f"Pronóstico del tiempo para {city}:\n"
for item in data['list'][:5]:
celsius_temp = item['main']['temp'] - 273.15
emoji = weather_emoji(item['weather'][0]['description'])
message += f"\nFecha: {item['dt_txt']} {emoji}\n"
message += f"Temperatura: {celsius_temp:.2f}°C\n"
message += f"Descripción: {item['weather'][0]['description'].capitalize()}\n"
await query.edit_message_text(text=message)
except requests.RequestException as e:
logger.error(f"Error al obtener datos del clima: {e}")
await query.edit_message_text(
text="No se puede encontrar información meteorológica para esta ciudad. Inténtalo de nuevo.")
except Exception as e:
logger.error(f"Error inesperado: {e}")
await query.edit_message_text(
text="Ocurrió un error inesperado. Inténtalo de nuevo más tarde.")
def error(update: Update, context: CallbackContext):
"""Registra errores causados por actualizaciones."""
logger.warning('La actualización "%s" causó el error "%s"', update, context.error)
def main():
"""Función principal del bot. Configura y ejecuta el bot de Telegram."""
logger.info("Iniciando el bot...")
application = ApplicationBuilder().token(TELEGRAM_API_TOKEN).build()
application.add_handler(CommandHandler("start", start))
application.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, menu))
application.add_handler(CallbackQueryHandler(button))
# Registra todos los errores
application.add_error_handler(error)
logger.info("Bot iniciado y en espera de mensajes...")
application.run_polling()
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,17 @@
# config.py
# Este módulo gestiona la configuración y la carga de variables de entorno.
import os
from dotenv import load_dotenv
# Cargar las variables de entorno desde el archivo .env
load_dotenv('.env')
# Obtener el token del bot y el ID del chat de grupo desde las variables de entorno
BOT_TOKEN = os.getenv('BOT_TOKEN')
WEATHER_API_KEY = os.getenv('WEATHER_API_KEY')
# Validar que las variables de entorno estén configuradas
if not BOT_TOKEN or not WEATHER_API_KEY:
raise AssertionError("Por favor, configura las variables de entorno BOT_TOKEN y GROUP_CHAT_ID")

View File

@@ -0,0 +1,13 @@
services:
clima-app:
build: .
container_name: clima-app
restart: unless-stopped
volumes:
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro
healthcheck:
test: ["CMD-SHELL", "pgrep -f bot.py || exit 1"]
interval: 10s
retries: 3
start_period: 5s

View File

@@ -0,0 +1,13 @@
certifi==2024.7.4
cffi==1.16.0
charset-normalizer==3.3.2
cryptography==42.0.8
decorator==5.1.1
idna==3.7
pycparser==2.22
python-decouple==3.8
python-dotenv==1.0.1
python-telegram-bot==21.3
requests==2.32.3
tornado==6.4.1
urllib3~=1.26

View File

@@ -0,0 +1,11 @@
FROM python:3.10-alpine
WORKDIR /app
COPY . /app/
RUN apk update && apk upgrade && \
apk add sqlite sqlite-libs sqlite-dev && \
pip install -r requirements.txt
CMD ["python", "rss2telegram.py"]

View File

@@ -0,0 +1,22 @@
# config.py
# Este módulo gestiona la configuración y la carga de variables de entorno.
import os
from dotenv import load_dotenv
# Cargar las variables de entorno desde el archivo .env
load_dotenv('.env')
# Obtener el token del bot y el ID del chat de grupo desde las variables de entorno
Token = os.getenv('BOT_TOKEN')
chatid = os.getenv('GROUP_CHAT_ID')
# Este es el retraso entre cada sondeo a las fuentes RSS en segundos.
delay = 30
# Validar que las variables de entorno estén configuradas
if not Token or not chatid:
raise AssertionError("Por favor, configura las variables de entorno BOT_TOKEN y GROUP_CHAT_ID")
# Convertir GROUP_CHAT_ID a entero
chatid = int(chatid)

View File

@@ -0,0 +1,3 @@
python-dotenv==1.0.1
feedparser==6.0.11
python-telegram-bot[job-queue]==21.4

View File

@@ -0,0 +1,175 @@
#!/usr/bin/env python3
#-*- coding: utf-8 -*-
import feedparser
import logging
import sqlite3
from telegram import ForceReply, Update
from telegram.ext import Application, CommandHandler, ContextTypes, MessageHandler, filters
from pathlib import Path
config = Path("./config.py")
try:
config.resolve(strict=True)
except FileNotFoundError:
print("Por favor, copia config.py.sample a config.py y rellena las propiedades.")
exit()
import config
rss_dict = {}
logging.basicConfig(format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', level=logging.INFO)
# SQLITE
def sqlite_connect():
global conn
conn = sqlite3.connect('rss.db', check_same_thread=False)
def sqlite_load_all():
sqlite_connect()
c = conn.cursor()
c.execute('SELECT * FROM rss')
rows = c.fetchall()
conn.close()
return rows
def sqlite_write(name, link, last):
sqlite_connect()
c = conn.cursor()
q = [(name), (link), (last)]
c.execute('''INSERT INTO rss('name','link','last') VALUES(?,?,?)''', q)
conn.commit()
conn.close()
# RSS
def rss_load():
# if the dict is not empty, empty it.
if bool(rss_dict):
rss_dict.clear()
for row in sqlite_load_all():
rss_dict[row[0]] = (row[1], row[2])
async def cmd_rss_list(update: Update, context: ContextTypes.DEFAULT_TYPE):
if bool(rss_dict) is False:
await update.message.reply_text("The database is empty")
else:
for title, url_list in rss_dict.items():
await update.message.reply_text(
"Título: " + title +
"\nURL RSS: " + url_list[0] +
"\nÚltima noticia comprobada:" + url_list[1])
async def cmd_rss_add(update: Update, context: ContextTypes.DEFAULT_TYPE):
# try if there are 2 arguments passed
try:
context.args[1]
except IndexError:
await update.message.reply_text("ERROR: EL formato debe ser: /add <título> <enlace>")
raise
# try if the url is a valid RSS feed
try:
rss_d = feedparser.parse(context.args[1])
rss_d.entries[0]['title']
except IndexError:
await update.message.reply_text(
"ERROR: EL enlace no parece ser un feed RSS o no es compatible")
raise
sqlite_write(context.args[0], context.args[1], str(rss_d.entries[0]['link']))
rss_load()
await update.message.reply_text("Añadido \nTÍTULO: %s\nRSS: %s" % (context.args[0], context.args[1]))
print("Añadido \nTÍTULO: %s\nRSS: %s" % (context.args[0], context.args[1]))
async def cmd_rss_remove(update: Update, context: ContextTypes.DEFAULT_TYPE):
conn = sqlite3.connect('rss.db')
c = conn.cursor()
name = str(context.args[0])
try:
c.execute('DELETE FROM rss WHERE name = ?', [name])
conn.commit()
conn.close()
except sqlite3.Error as e:
print('Error %s:' % e)
rss_load()
await update.message.reply_text("Borrado: " + name)
print("Borrado: " + name)
async def cmd_help(update: Update, context: ContextTypes.DEFAULT_TYPE):
await update.message.reply_text(
"RSS en Telegram bot" +
"\n\nDespués de añadir con éxito un enlace RSS, el bot comienza a buscar la fuente cada "
+ str(config.delay) + " segundos. (Puedes configurarlo en config.py) ⏰⏰⏰" +
"\n\nLos Títulos son usados para gestionar fácilmente los feeds RSS y deben contener solo una palabra 📝📝📝" +
"\n\nComandos:" +
"\n/help Muestra este mensaje de ayuda." +
"\n/add <title> <link> Para añadir una RSS en la base de datos." +
"\n/remove <title> Borra una RSS de la base de datos."
"\n/list Listar todos los títulos y RSS guardados.")
async def rss_monitor(context: ContextTypes.DEFAULT_TYPE):
for name, url_list in rss_dict.items():
rss_d = feedparser.parse(url_list[0])
if (url_list[1] != rss_d.entries[0]['link']):
print("Nueva RSS para " + name + ", actualizando base de datos...")
conn = sqlite3.connect('rss.db')
q = [(str(rss_d.entries[0]['link'])), (name)]
c = conn.cursor()
c.execute('''UPDATE rss SET 'last' = ? WHERE name=? ''', q)
conn.commit()
conn.close()
rss_load()
print("Emviando RSS a Telegram...")
await context.bot.send_message(config.chatid, rss_d.entries[0]['link'])
print("Éxito.")
def init_sqlite():
conn = sqlite3.connect('rss.db')
c = conn.cursor()
c.execute('''CREATE TABLE rss (name text, link text, last text)''')
def main() -> None:
dp = Application.builder().token(config.Token).build()
dp.add_handler(CommandHandler("add", cmd_rss_add))
dp.add_handler(CommandHandler("help", cmd_help))
dp.add_handler(CommandHandler("start", cmd_help))
dp.add_handler(CommandHandler("list", cmd_rss_list))
dp.add_handler(CommandHandler("remove", cmd_rss_remove))
#dp.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, echo))
db = Path("./rss.db")
try:
db.resolve(strict=True)
except FileNotFoundError:
print("Base de datos no encontrada, intenta crear una nueva.")
try:
init_sqlite()
except Exception as e:
print("Error cuando se creaba la base de datos : ", e.message, e.args)
pass
else:
print("Éxito.")
rss_load()
print("Corriendo RSS Monitor.")
dp.job_queue.run_repeating(rss_monitor, config.delay)
dp.run_polling(allowed_updates=Update.ALL_TYPES)
conn.close()
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,26 @@
FROM python:alpine
ADD requirements.txt /app/requirements.txt
RUN apk update && apk upgrade && \
apk add --no-cache bash && \
rm -rf /var/cache/apk/* && \
set -ex && \
python -m venv /env && \
/env/bin/pip install --upgrade pip && \
/env/bin/pip install --no-cache-dir -r /app/requirements.txt && \
runDeps="$(scanelf --needed --nobanner --recursive /env \
| awk '{ gsub(/,/, "\nso:", $2); print "so:" $2 }' \
| sort -u \
| xargs -r apk info --installed \
| sort -u)" && \
apk add --virtual rundeps $runDeps && \
apk del rundeps
ADD . /app
WORKDIR /app
ENV VIRTUAL_ENV=/env
ENV PATH=/env/bin:$PATH
CMD ["python", "main.py"]

View File

@@ -0,0 +1,42 @@
version: '3.7'
services:
movie_bot:
env_file:
- .env
image: movie_bot_python:latest
container_name: movie_bot
environment:
- PUID=1000
- PGID=1000
- TZ=Europe/Madrid
restart: unless-stopped
volumes:
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro
depends_on:
bbdd:
condition: service_healthy
bbdd:
image: mysql:latest
container_name: bbdd_movie_bot
env_file:
- .env
environment:
- MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD}
- MYSQL_DATABASE=${MYSQL_DATABASE}
- MYSQL_USER=${MYSQL_USER}
- MYSQL_PASSWORD=${MYSQL_PASSWORD}
ports:
- "3306:3306"
restart: unless-stopped
volumes:
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -h localhost -u root -p${MYSQL_ROOT_PASSWORD}"]
interval: 10s
retries: 5
start_period: 30s
timeout: 10s

View File

@@ -0,0 +1,46 @@
from telegram.ext import CommandHandler, MessageHandler, filters, CallbackQueryHandler
from src import Botz
def main():
print(r"""
_
_ __ ___ __ _ _ __ _ _ ___| |_ _____ _ __
| '_ ` _ \ / _` | '_ \| | | |/ _ \ \ \ / / _ \ '__|
| | | | | | (_| | | | | |_| | __/ |\ V / __/ |
|_| |_| |_|\__,_|_| |_|\__,_|\___|_| \_/ \___|_| """)
bot = Botz()
bot.app.add_handler(CommandHandler('start', bot.start_command))
bot.app.add_handler(CommandHandler('help', bot.help_command))
bot.app.add_handler(CommandHandler("find", bot.find_title))
bot.app.add_handler(CommandHandler("save", bot.movie_saver))
bot.app.add_handler(CommandHandler("remove", bot.movie_remover))
bot.app.add_handler(CommandHandler("list", bot.movie_list))
bot.app.add_handler(CommandHandler("reboot", bot.reboot))
bot.app.add_handler(CommandHandler("status", bot.status))
bot.app.add_handler(MessageHandler(filters.TEXT, bot.any_text))
bot.app.add_error_handler(bot.error)
bot.app.add_handler(CallbackQueryHandler(bot.query_handler))
print('Bot Started Polling! Check Terminal for Errors')
bot.app.run_polling(poll_interval=3)
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,20 @@
aiohttp==3.9.5
aiosignal==1.3.1
anyio==4.4.0
async-timeout==4.0.3
attrs==23.2.0
certifi==2024.7.4
charset-normalizer==3.3.2
frozenlist==1.4.1
h11==0.14.0
httpcore==1.0.5
httpx==0.27.0
idna==3.7
multidict==6.0.5
mysql-connector-python==9.0.0
protobuf==5.27.2
python-dotenv==1.0.1
python-telegram-bot==21.4
sniffio==1.3.1
tcp-latency==0.0.12
yarl==1.9.4

View File

@@ -0,0 +1 @@
from .bot import Botz

View File

@@ -0,0 +1,468 @@
from typing import Final
from os import getenv, execl
import sys
from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup
from telegram.ext import Application, ContextTypes
import aiohttp
from dotenv import load_dotenv, find_dotenv
import mysql.connector as msc
import time
from tcp_latency import measure_latency
print('Iniciando bot...')
load_dotenv('.env')
# Telegram Bot
BOT_USERNAME: Final = getenv("BOT_USERNAME")
BOT_API: Final = getenv("BOT_API")
# OMDB
OMDB: Final = "http://www.omdbapi.com"
OMDB_SITE: Final = "www.omdbapi.com"
OMDB_API: Final = getenv("OMDB_API")
# TMDB
TMDB_API: Final = getenv("TMDB_API")
TMDB_SITE: Final = "api.themoviedb.org"
# IMDB
IMDB_LINK: Final = "https://www.imdb.com/title/"
# MySQL
MYSQL_HOST: Final = getenv("MYSQL_HOST")
MYSQL_PORT: Final = 3306
MYSQL_USER: Final = getenv("MYSQL_USER")
MYSQL_PASSWORD: Final = getenv("MYSQL_PASSWORD")
MYSQL_DATABASE: Final = getenv("MYSQL_DATABASE")
CREATE_TABLE: Final = getenv("CREATE_TABLE", True)
class Botz:
INTRO_MSG = "*¡Hola! Soy un Bot de Películas \n" \
"Escribe /help para ver la lista de comandos disponibles* \n"
HELP_MSG = "*COMANDOS DISPONIBLES*\n\n" \
" * Comando :* /start\n" \
" * Descripción :* Muestra la introducción.\n\n" \
" * Comando :* /help\n" \
" * Descripción :* Lista los comandos disponibles.\n\n" \
" * Comando :* /status\n" \
" * Descripción :* Devuelve el estado del bot.\n\n" \
" * Comando :* /find nombre-de-la-pelicula \n" \
" * Descripción :* " \
" Proporciona los detalles de la película/serie especificada." \
" Introduce el nombre de la película como argumento del comando /find." \
" Usa los botones debajo para obtener más información. \n" \
" ej: /find Godfather \n\n" \
" * Comando :* /find nombre-de-la-pelicula y=año\n" \
" * Descripción :* " \
" Proporciona los detalles de la película/serie especificada." \
" Introduce el nombre de la película y el año como argumento del comando /find." \
" Usa los botones de debajo para obtener más información. \n" \
" ej: /find Godfather y=1972\n\n" \
" * Comando :* /save IMDB-id \n" \
" * Descripción :* " \
" Introduce el ID de IMDB de la película/serie como argumento." \
" Usa el comando /find para encontrar el ID de IMDB de una película/serie." \
" Guarda el mensaje/archivo respondido en la base de datos con el ID de IMDB dado." \
" Siempre usa este comando como respuesta al archivo que se guardará.\n " \
" ej: /save tt1477834\n\n" \
" * Comando :* /remove IMDB-id \n" \
" * Descripción :* " \
" Introduce el ID de IMDB de la película/serie como argumento." \
" Usa el comando /find para encontrar el ID de IMDB de una película/serie." \
" Elimina el archivo del ID de IMDB especificado de la base de datos.\n" \
" ej: /remove tt1477834\n\n" \
" * Comando :* /list \n" \
" * Descripción :* Devuelve el número de películas/series actualmente indexadas en la base de datos.\n" \
MOVIE_NOT_FOUND_MSG = "*{} no está indexada actualmente en mi base de datos. 😔*\n\n" \
"Si tienes esta película en tu chat o en otros grupos, \n" \
" - Reenvía esa película a mi chat o a este grupo y, \n" \
" - Usa '*/save {}*' como respuesta al archivo de la película para guardarla en mi base de datos. \n"
REBOOT_WAIT_MESSAGE = "Reiniciando el bot. Por favor espera ⏲️"
REBOOT_SUCCESS_MESSAGE = "El bot ha vuelto 😃"
STATUS_MESSAGE = "*El bot está vivo.* 😃 \n\n" \
"*Estado de la base de datos :* {} \n" \
"*Latencia de la base de datos :* {} \n" \
"*Películas disponibles :* {} \n\n" \
"*OMDB :* {} \n" \
"*Latencia de OMDB :* {} \n\n" \
"*TMDB :* {} \n" \
"*Latencia de TMDB :* {} \n"
MOVIE_NOT_FOUND = "*Película/Serie NO ENCONTRADA. \n" \
"Revisa la ortografía.* \n"
FIND_MSG = "*Introduce el NOMBRE de la Película/Serie junto con /find. \n" \
"Ve a /help para más detalles.* \n"
INVALID_FIND_MSG = "*Comando desconocido: {}* \n" \
"*Para buscar una película usa: /find <nombre_de_la_pelicula>* \n"
SAVE_MSG = "*Introduce el ID de IMDB de la Película/Serie junto con /save. \n" \
"Ve a /help para más detalles.* \n"
SAVE_REPLY_MSG = "*Usa este comando como respuesta al archivo que se guardará. \n" \
"Ve a /help para más detalles.* \n"
REMOVE_MSG = "*Introduce el ID de IMDB de la Película/Serie junto con /remove. \n" \
"Ve a /help para más detalles.* \n"
CHECK_STATUS_MSG = "Recopilando datos. Por favor espera ⏲️"
def __init__(self) -> None:
self.app = Application.builder().token(BOT_API).build()
# Set up bot memory
self.memory: list = []
# Set up bot movie file cache memory
self.movie_memory: list = []
# Set up Mysql Database
self.connection = msc.connect(
host=MYSQL_HOST, user=MYSQL_USER, passwd=MYSQL_PASSWORD, database=MYSQL_DATABASE)
self.cursor = self.connection.cursor()
if CREATE_TABLE == 'True':
self.cursor.execute(
"Create table movie_data(imdb_id varchar(20),from_chat_id varchar(20),message_id varchar(20))")
async def reboot(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
msg = await update.message.reply_text(self.REBOOT_WAIT_MESSAGE)
time.sleep(5)
await msg.edit_text(self.REBOOT_SUCCESS_MESSAGE)
execl(sys.executable, f'"{sys.executable}"', *sys.argv)
async def status(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
message = await update.message.reply_text(self.CHECK_STATUS_MSG)
if self.connection.is_connected():
db_status = "Connected ✅"
db_latency = str(round(measure_latency(
host=MYSQL_HOST, port=MYSQL_PORT, timeout=2.5)[0])) + "ms ⏱️"
self.cursor.execute("select count(*) from movie_data")
movie_number = str(self.cursor.fetchone()[0]) + ' 🎬'
else:
db_status = "Desconectado ❌"
db_latency = "N/A ❌"
movie_number = "N/A ❌"
omdb_params = {
"apikey": OMDB_API,
"t": '2012',
}
async with aiohttp.ClientSession() as session:
async with session.get(OMDB, params=omdb_params) as response:
movie_data = await response.json()
if movie_data["Response"] != "False":
omdb_status = "API Disponible.✅"
omdb_latency = str(round(measure_latency(
host=OMDB_SITE, timeout=2.5)[0])) + "ms ⏱️"
find_TMDB = f'https://api.themoviedb.org/3/find/{movie_data["imdbID"]}?api_key={TMDB_API}&external_source=imdb_id'
async with aiohttp.ClientSession() as session:
async with session.get(find_TMDB) as response:
data = await response.json()
if 'success' not in data.keys():
tmdb_status = "API Disponible ✅"
tmdb_latency = str(round(measure_latency(
host=TMDB_SITE, timeout=2.5)[0])) + "ms ⏱️"
else:
tmdb_status = "API no disponible ❌"
tmdb_latency = "N/A ❌"
else:
omdb_status = "API no disponible.❌"
omdb_latency = "N/A ❌"
tmdb_status = "API no disponible.❌"
tmdb_latency = "N/A ❌"
await message.edit_text(self.STATUS_MESSAGE.format(db_status, db_latency, movie_number, omdb_status, omdb_latency, tmdb_status, tmdb_latency), parse_mode='markdown')
# /start command
async def start_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
await update.message.reply_chat_action(action="typing")
await update.message.reply_photo(photo="https://www.pngmart.com/files/15/Baby-Bender-PNG.png", caption=self.INTRO_MSG, parse_mode='markdown')
# /help command
async def help_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
await update.message.reply_text(self.HELP_MSG, parse_mode='markdown')
# Replying to text other than commands
async def any_text(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
message_type: str = update.message.chat.type
if message_type not in ['group', 'supergroup']:
await update.message.reply_text(self.INVALID_FIND_MSG.format(update.message.text), parse_mode='markdown')
# Error Handling
async def error(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
print(f'\nActualizar\n{update.message}\n\ncausa error {context.error}')
await update.message.reply_text("*Lo siento, se encontró un error.*", parse_mode='markdown')
# /find command
async def find_title(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
if len(self.memory) == 25:
self.memory = []
if len(self.movie_memory) == 25:
self.movie_memory = []
if "".join(context.args) == "":
await update.message.reply_text(self.FIND_MSG, parse_mode='markdown')
else:
if "y=" in context.args[-1]:
movie_name = " ".join(context.args[:-1])
omdb_params = {
"apikey": OMDB_API,
"t": movie_name,
"y": context.args[-1][2:]
}
else:
movie_name = " ".join(context.args)
omdb_params = {
"apikey": OMDB_API,
"t": movie_name,
}
for item in self.memory:
if movie_name == item["Title"].lower() or movie_name == item['Title']:
movie_data = item
break
else:
async with aiohttp.ClientSession() as session:
async with session.get(OMDB, params=omdb_params) as response:
movie_data = await response.json()
if movie_data["Response"] != "False":
self.memory.append(movie_data)
if movie_data["Response"] != "False":
data_str = f"🎬 *Título:* {movie_data['Title']} ({movie_data['Year']})\n\n" \
f"📖 *Género:* {movie_data['Genre']}\n\n" \
f"⭐ *Rating:* {movie_data['imdbRating']}/10\n\n" \
f"🕤 *Duración:* {movie_data['Runtime']}\n\n" \
f"🎭 *Actores:* {movie_data['Actors']}\n\n" \
f"🧑 *Director:* {movie_data['Director']}\n\n" \
f"🆔 *IMDB ID:* {movie_data['imdbID']}\n\n"
if movie_data['Poster'] != 'N/A':
await update.message.reply_photo(photo=movie_data['Poster'])
else:
find_TMDB = f'https://api.themoviedb.org/3/find/{movie_data["imdbID"]}?api_key={TMDB_API}&external_source=imdb_id'
TMDB_IMAGE_BASE = 'https://image.tmdb.org/t/p/original'
async with aiohttp.ClientSession() as session:
async with session.get(find_TMDB) as response:
data = await response.json()
if data['movie_results'] != []:
Poster = data['movie_results'][0]['backdrop_path']
URL = str(TMDB_IMAGE_BASE+Poster)
await update.message.reply_photo(photo=URL)
elif data['tv_results'] != []:
Poster = data['tv_results'][0]['backdrop_path']
URL = str(TMDB_IMAGE_BASE+Poster)
await update.message.reply_photo(photo=URL)
buttons = [
[InlineKeyboardButton("Plot", callback_data=f"{movie_data['Title']};plot"),
InlineKeyboardButton("Ratings", callback_data=f"{movie_data['Title']};ratings")],
[InlineKeyboardButton("Awards", callback_data=f"{movie_data['Title']};awards"),
InlineKeyboardButton(
"Languages", callback_data=f"{movie_data['Title']};languages"),
InlineKeyboardButton("Rated", callback_data=f"{movie_data['Title']};rated")],
[InlineKeyboardButton("IMDB page", url=f"{IMDB_LINK}{movie_data['imdbID']}"),
InlineKeyboardButton("Trailer", url=await self.get_trailer_url(movie_data["imdbID"], movie_data['Title']))],
[InlineKeyboardButton(
"Get Movie", callback_data=f"{movie_data['Title']};getmovie")]
]
await update.message.reply_text(data_str, reply_markup=InlineKeyboardMarkup(buttons), parse_mode='markdown')
else:
await update.message.reply_chat_action(action="typing")
await update.message.reply_photo(photo='https://raw.githubusercontent.com/akkupy/movie_bot/main/assets/check_spelling.jpg', caption=self.MOVIE_NOT_FOUND, parse_mode='markdown')
@staticmethod
async def get_trailer_url(imdb_id: str, Title: str) -> None:
find_TMDB = f'https://api.themoviedb.org/3/find/{imdb_id}?api_key={TMDB_API}&external_source=imdb_id'
YOUTUBE_BASE_URL = 'https://www.youtube.com/watch?v='
async with aiohttp.ClientSession() as session:
async with session.get(find_TMDB) as response:
data = await response.json()
if data['movie_results'] != []:
TMDB_ID = data['movie_results'][0]['id']
TYPE = 'movie'
elif data['tv_results'] != []:
TMDB_ID = data['tv_results'][0]['id']
TYPE = 'tv'
else:
return f'https://www.youtube.com/results?search_query={Title}'
video_TMDB = f'https://api.themoviedb.org/3/{TYPE}/{TMDB_ID}/videos?api_key={TMDB_API}'
async with session.get(video_TMDB) as response:
data = await response.json()
if data['results'] != []:
video = data['results'][0]['key']
else:
return f'https://www.youtube.com/results?search_query={Title}'
return YOUTUBE_BASE_URL+video
@staticmethod
def get_rating(movie_json: dict) -> str:
rating_str: str = ""
for rating in movie_json["Ratings"]:
rating_str += f"{rating['Source']}: {rating['Value']}\n"
return f"*{movie_json['Title']} Ratings* ⭐\n\n{rating_str}"
@staticmethod
def get_rated(movie_json: dict) -> str:
return f"*{movie_json['Title']} Rated* 🔞\n\n{movie_json['Rated']}"
@staticmethod
def get_plot(movie_json: dict) -> str:
return f"*{movie_json['Title']} Plot* 📖\n\n{movie_json['Plot']}"
@staticmethod
def get_languages(movie_json: dict) -> str:
return f"*{movie_json['Title']} Languages* 🗣️\n\n{movie_json['Language']}"
@staticmethod
def get_awards(movie_json: dict) -> str:
return f"*{movie_json['Title']} Awards* 🏆\n\n{movie_json['Awards']}"
@staticmethod
def get_movie(self, movie_json: dict) -> str:
Flag = False
for item in self.movie_memory:
if movie_json['imdbID'] == item["imdb_id"]:
file_data = item
break
else:
self.cursor.execute(
"select count(*) from movie_data where imdb_id = '{}'".format(movie_json['imdbID']))
if self.cursor.fetchone()[0] != 0:
self.cursor.execute(
"select * from movie_data where imdb_id = '{}'".format(movie_json['imdbID']))
data = self.cursor.fetchone()
file_data = {
'imdb_id': data[0],
'from_chat_id': data[1],
'message_id': data[2],
}
self.movie_memory.append({
'imdb_id': data[0],
'from_chat_id': data[1],
'message_id': data[2],
})
else:
Flag = True
from_chat_id = message_id = None
return from_chat_id, message_id
if not Flag:
from_chat_id, message_id = file_data['from_chat_id'], file_data['message_id']
return from_chat_id, message_id
# Query Handler
async def query_handler(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
query = update.callback_query.data
await update.callback_query.answer()
title, kword = query.split(";")
for item in self.memory:
if title == item["Title"]:
data = item
break
else:
omdb_params = {
"apikey": OMDB_API,
"t": title,
}
async with aiohttp.ClientSession() as session:
async with session.get(OMDB, params=omdb_params) as result:
data = await result.json()
self.memory.append(data)
if kword == "ratings":
await update.callback_query.message.reply_text(self.get_rating(data), parse_mode='markdown')
elif kword == "plot":
await update.callback_query.message.reply_text(self.get_plot(data), parse_mode='markdown')
elif kword == "rated":
await update.callback_query.message.reply_text(self.get_rated(data), parse_mode='markdown')
elif kword == "awards":
await update.callback_query.message.reply_text(self.get_awards(data), parse_mode='markdown')
elif kword == "languages":
await update.callback_query.message.reply_text(self.get_languages(data), parse_mode='markdown')
elif kword == "getmovie":
from_chat_id, message_id = self.get_movie(self, data)
if from_chat_id == None:
await update.callback_query.message.reply_text(self.MOVIE_NOT_FOUND_MSG.format(data['Title'], data["imdbID"]), parse_mode='markdown')
else:
await update.callback_query.message._bot.forward_message(update.callback_query.message.chat.id, from_chat_id, message_id)
async def movie_saver(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
imdb_id = "".join(context.args)
if imdb_id == "":
await update.message.reply_text(self.SAVE_MSG, parse_mode='markdown')
elif update.message.reply_to_message == None:
await update.message.reply_text(self.SAVE_REPLY_MSG, parse_mode='markdown')
else:
message_id = update.message.reply_to_message.id
from_chat_id = update.message.chat.id
self.cursor.execute(
"select count(*) from movie_data where imdb_id = '{}'".format(imdb_id))
if self.cursor.fetchone()[0] == 0:
self.cursor.execute("insert into movie_data values('{}',{},{})".format(
imdb_id, from_chat_id, message_id))
self.connection.commit()
await update.message.reply_text('*Movie/Series saved on database.*\n', parse_mode='markdown')
else:
await update.message.reply_text('*Movie/Series already present on database.*\n', parse_mode='markdown')
async def movie_remover(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
imdb_id = "".join(context.args)
if imdb_id == "":
await update.message.reply_text(self.REMOVE_MSG, parse_mode='markdown')
else:
self.cursor.execute(
"select count(*) from movie_data where imdb_id = '{}'".format(imdb_id))
if self.cursor.fetchone()[0] != 0:
self.cursor.execute(
"delete from movie_data where imdb_id = '{}'".format(imdb_id))
self.connection.commit()
await update.message.reply_text('*Movie/Series deleted from database.*\n', parse_mode='markdown')
else:
await update.message.reply_text('*Movie/Series not found on database.*\n', parse_mode='markdown')
count = 0
for item in self.movie_memory:
if imdb_id == item["imdb_id"]:
del self.movie_memory[count]
count += 1
async def movie_list(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
self.cursor.execute("select count(*) from movie_data")
number = self.cursor.fetchone()[0]
await update.message.reply_text(f'*{number} Movies/Series 🎬 found on database.*', parse_mode='markdown')

View File

@@ -0,0 +1,25 @@
# Utiliza una imagen base de Python en Alpine
FROM python:3.12.4-alpine
WORKDIR /app
# Instala bash, herramientas de compilación y librerías necesarias
RUN apk update && \
apk upgrade && \
python -m venv /env && \
/env/bin/pip install --upgrade pip
# Añade el archivo de requisitos a la imagen
COPY requirements.txt /app/requirements.txt
RUN /env/bin/pip install --no-cache-dir -r /app/requirements.txt
# Añade el resto del código de la aplicación
COPY . /app
# Configura el entorno virtual
ENV VIRTUAL_ENV=/env
ENV PATH=/env/bin:$PATH
# Comando por defecto para ejecutar el bot
CMD ["python", "main.py"]

View File

@@ -0,0 +1,67 @@
# QuizBot
__Basado en el repositorio de [CineMonster](https://github.com/RogueFairyStudios/CineMonster)__
Bot de Telegram con un juego basado en preguntas sobre películas. Resumen de las funcionalidades:
## **Comandos Disponibles:**
1. **`/start`**:
- **Descripción**: Inicia una nueva sesión de juego para el chat.
- **Acciones**: Crea una nueva instancia de `Session` y la almacena en `SESSIONS`. Si se encuentra una clase `Quiz` en el módulo `quiz`, la inicializa para la sesión.
2. **`/roll`**:
- **Descripción**: Lanza una pregunta de trivia sobre películas.
- **Acciones**: Llama a `show` en el objeto `Quiz` de la sesión activa. Envía una imagen de una película al chat y establece el estado del juego en "running".
3. **`/leaderboard`**:
- **Descripción**: Muestra la tabla de clasificación de los jugadores.
- **Acciones**: Envía la tabla de clasificación actual a través del `messenger`. La tabla muestra los jugadores y sus puntos.
4. **`/repeat`**:
- **Descripción**: Repite la última pregunta de trivia sobre películas.
- **Acciones**: Envía de nuevo la imagen de la película al chat junto con la pregunta sobre el título de la película.
5. **`/cut`**:
- **Descripción**: Permite que un jugador abandone el juego.
- **Acciones**: Elimina al jugador de la sesión actual y notifica al chat que el jugador ha abandonado el juego.
6. **`/stop`**:
- **Descripción**: Finaliza la sesión de juego actual.
- **Acciones**: Elimina la sesión del chat actual de `SESSIONS` y notifica al chat que el juego ha terminado.
7. **`/check_resps`**:
- **Descripción**: Verifica las respuestas enviadas por los jugadores.
- **Acciones**: Compara la respuesta del usuario con la respuesta correcta de la película y actualiza el puntaje si la respuesta es correcta.
## **Funcionalidades Adicionales:**
- **Manejo de Temporizadores**:
- Utiliza `apscheduler` para ejecutar `update_all_timers` cada minuto, lo que actualiza los temporizadores de todas las sesiones y verifica la expiración del tiempo de juego.
- **Mensajería**:
- Usa un objeto `messenger` para enviar mensajes y fotos a los usuarios en el chat, manejando la comunicación con Telegram.
- **Gestión de Jugadores**:
- Permite agregar y quitar jugadores de la sesión. Actualiza el puntaje de los jugadores en función de sus respuestas correctas.
- **Control de Estado del Juego**:
- Los estados del juego (`running`, `stopped`, `timed_out`) controlan el flujo del juego, incluyendo la verificación de respuestas y el manejo de tiempos de espera.
- **Manejo de Errores**:
- Maneja errores durante el proceso de actualización y respuesta, notificando a los usuarios en caso de problemas con la pregunta de trivia o el estado de la sesión.
## **Estructura del Código:**
1. **`Session`**:
- Maneja la lógica del juego, incluidos los jugadores, el estado de la sesión, y los temporizadores.
2. **`Quiz`**:
- Se encarga de la lógica relacionada con las preguntas sobre películas, incluida la selección de una película al azar y la verificación de las respuestas.
3. **`Server`**:
- Configura el bot de Telegram, maneja los comandos y los eventos, y gestiona las sesiones de juego.

View File

@@ -0,0 +1 @@
theme: jekyll-theme-slate

View File

@@ -0,0 +1,40 @@
from miners import Miner
from random import *
class Collection:
movie_list = ''
def __init__(self, miner, type):
self.miner = miner
self.type = type
def top_250(self):
self.movie_list = self.miner.get_top(250)
def general(self):
pass
def get_rand_movie(self):
movie = None
while movie is None:
if self.type is None:
number = str(randrange(1, 99999))
if len(number) < 7:
number = '0' * (7 - len(number)) + number
movie_id = 'tt' + number
else:
self.top_250()
number = randrange(0, len(self.movie_list) - 1)
movie_id = self.movie_list[number]['tconst']
images, movie = self.miner.get_movie_by_id(movie_id)
print(movie['base']['title'])
if images is not None:
if images['totalImageCount'] < 1:
movie = None
return movie, images

View File

@@ -0,0 +1 @@
from collection.Collection import Collection

View File

@@ -0,0 +1 @@
from conf.config import Config, ProductionConfig, DevelopmentConfig, TestingConfig

View File

@@ -0,0 +1,31 @@
import os
from dotenv import load_dotenv
load_dotenv('.env')
class Config(object):
DEBUG = False
TESTING = False
DATABASE_URI = 'sqlite://:memory:'
DATABASE_URL = os.getenv('DATABASE_URL')
TELEGRAM_BOT_API = os.getenv('TELEGRAM_BOT_API')
LOG_FILE = 'logs/movie2_bot.log'
QUIZ_LANG = 'es'
class ProductionConfig(Config):
DATABASE_URI = 'mysql://user@localhost/foo'
SESSION_EXPIRATION_TIME = 30
class DevelopmentConfig(Config):
DEBUG = True
SESSION_EXPIRATION_TIME = 10
class TestingConfig(Config):
TESTING = True

View File

@@ -0,0 +1,22 @@
# version: '3.7'
services:
movie2_bot:
env_file:
- .env
image: movie2_bot_python:latest
container_name: movie2_bot
environment:
- PUID=1000
- PGID=1000
- TZ=Europe/Madrid
volumes:
- /etc/localtime:/etc/localtime:ro
- /etc/timezone:/etc/timezone:ro
- ./movie2_bot_data:/app/db
- ./logs:/app/logs
restart: unless-stopped
volumes:
movie2_bot_data:
logs:

View File

@@ -0,0 +1 @@
from interfaces.telegram.messenger import Messenger as TelegramMessenger

View File

@@ -0,0 +1,45 @@
from telegram.constants import ParseMode
import telegram.ext
class Messenger:
formats = {
'regular': "*%s*",
'caption': "*%s*",
'title': "=+= *%s* =+=",
'highlight': "--+ %s +--",
'bold': "*%s* %s"
}
def __init__(self, bot, logger):
self.logger = logger
self.logger.debug("Started...")
self.bot = bot
def send_msg(self, chat_id, msg, type_msg='regular'):
if type_msg not in self.formats:
self.logger.error(f"Invalid message type: {type_msg}")
return
formatted_msg = self.format(type_msg, msg)
try:
self.bot.send_message(
chat_id=chat_id,
text=formatted_msg,
parse_mode=ParseMode.MARKDOWN_V2
)
except Exception as e:
self.logger.error(f"Error sending message: {e}")
def format(self, type_msg, msg):
return self.formats[type_msg] % msg
def send_photo(self, chat_id, photo, caption):
try:
self.bot.send_photo(
chat_id=chat_id,
photo=photo,
caption=caption,
parse_mode=ParseMode.MARKDOWN_V2
)
except Exception as e:
self.logger.error(f"Error sending photo: {e}")

View File

@@ -0,0 +1,11 @@
from server import Server
def main():
Server()
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,20 @@
from abc import ABCMeta, abstractmethod
class Miner(object):
__metaclass__ = ABCMeta
handle = None
@abstractmethod
def top_list(self, number):
pass
@abstractmethod
def get_movie_id(self, index):
pass
@abstractmethod
def get_movie_by_id(self, movie_id):
pass

View File

@@ -0,0 +1,2 @@
from miners.Miner import Miner
from miners.imdb.ImdbMiner import IMDB

View File

@@ -0,0 +1,20 @@
from miners.Miner import Miner
from imdbpie import Imdb
class IMDB(Miner):
def __init__(self):
self.handle = Imdb()
super(IMDB, self).__init__()
def top_list(self, number):
pop_movies = self.handle.top_250()
return pop_movies
def get_movie_id(self, index):
return "tt" + index
def get_movie_by_id(self, movie_id):
return self.handle.get_title_images(movie_id), self.handle.get_title(movie_id)

View File

@@ -0,0 +1 @@
from miners.imdb.ImdbMiner import IMDB

View File

@@ -0,0 +1,44 @@
import os
import sys
import datetime
from sqlalchemy import Column, ForeignKey, Integer, String, DateTime
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import relationship
from sqlalchemy import create_engine
from conf import Config
Base = declarative_base()
class Player(Base):
__tablename__ = 'player'
id = Column(Integer, primary_key=True)
telegram_uid = Column(String(250), nullable=True)
email = Column(String(250), nullable=True)
phone = Column(String(30), nullable=True)
class Group(Base):
__tablename__ = 'group'
id = Column(Integer, primary_key=True)
name = Column(String(250), primary_key=True)
player_id = Column(Integer, ForeignKey('player.id'))
points = Column(Integer, nullable=True)
player = relationship(Player)
class Session(Base):
__tablename__ = 'session'
id = Column(Integer, primary_key=True)
group_id = Column(Integer, ForeignKey('group.id'))
group = relationship(Group)
started = Column(DateTime, default=datetime.datetime.utcnow)
ended = Column(DateTime)
engine = create_engine(Config.DATABASE_URL)
Base.metadata.create_all(engine)

View File

@@ -0,0 +1 @@
from models.Model import Player, Group, Session, engine

View File

@@ -0,0 +1,16 @@
from abc import ABC, abstractmethod
class Movie(ABC):
id = 0
name = ''
type = ''
@abstractmethod
def __init__(self, name):
self.name = name
self.type = None
def get_name(self):
return self.name

View File

@@ -0,0 +1,8 @@
from movie.Movie import Movie
class Pop(Movie):
def __init__(self):
super(Pop, self).__init__(name='Pop Movie')
self.type = "pop"

View File

@@ -0,0 +1,2 @@
from movie.Movie import Movie
from movie.Pop import Pop

View File

@@ -0,0 +1,17 @@
class Player:
id = ''
points = 0
name = ''
def __init__(self, uid):
self.id = uid
def get_points(self):
return self.points
def set_name(self, name):
self.name = name
def add_points(self, points):
self.points += points

View File

@@ -0,0 +1 @@
from player.Player import Player

View File

@@ -0,0 +1,98 @@
from player.Player import Player
from random import choice
from collection.Collection import Collection
from miners.imdb.ImdbMiner import IMDB
class Quiz:
movies_type = ''
movie = None
images = None
def __init__(self, session):
self.miner = IMDB()
self.session = session
def set_level(self, level):
# Implementar el ajuste del nivel si es necesario
pass
def rand_movie(self, rand_type=None):
collection = Collection(self.miner, rand_type)
self.movie, self.images = collection.get_rand_movie()
def get_movie_photo(self):
if not self.images or 'images' not in self.images:
raise ValueError("No images available")
try:
return choice(self.images['images'])['url']
except (IndexError, KeyError) as e:
raise ValueError("Error selecting image URL") from e
def get_question(self, rand_type=None):
try:
self.rand_movie(rand_type)
return self.get_movie_photo()
except ValueError:
return _("not_possible_find_movie")
def show(self, update, rand_type):
chat_id = update.message.chat_id
try:
movie_img = self.get_question(rand_type)
self.session.messenger.send_msg(chat_id, "movie_bot", "title")
self.session.messenger.send_photo(
chat_id, movie_img, caption=_("question_which_movie")
)
self.session.updater_counter()
self.session.status = "running"
except ValueError as e:
self.session.messenger.send_msg(
chat_id,
msg=_("error_fetching_question"),
type_msg="bold"
)
self.session.status = "stopped"
def check_resps(self, update):
chat_id = update.message.chat_id
if not self.movie or 'base' not in self.movie or 'title' not in self.movie['base']:
self.session.messenger.send_msg(
chat_id,
msg=_("error_movie_data"),
type_msg="bold"
)
return
if str.lower(self.movie['base']['title']) == str.lower(update.message.text):
player = Player(update.message.from_user.id)
player.name = f"{update.message.from_user.first_name} {update.message.from_user.last_name}"
try:
self.session.player_add(player)
except ValueError:
pass
self.session.players[update.message.from_user.id].add_points(1)
self.session.messenger.send_msg(
chat_id,
msg=(player.name, _("correct_answer")),
type_msg="bold"
)
self.movie = None
self.session.status = "stopped"
def check_expiration(self):
try:
self.session.update_timer()
except ValueError:
pass
if self.session.status == "timed_out":
self.session.messenger.send_msg(
chat_id=self.session.chat_id,
msg=_("times_up", self.movie['base']
['title'] if self.movie else ""),
type_msg="bold"
)
self.session.status = "stopped"
self.movie = None

View File

@@ -0,0 +1 @@
from quiz.Quiz import Quiz

View File

@@ -0,0 +1,6 @@
Babel==2.15.0
boto3==1.34.145
imdbpie~=4.0
python-dotenv==1.0.1
python-telegram-bot[job-queue]==21.4
six==1.16.0

View File

@@ -0,0 +1 @@
python-3.12.4

View File

@@ -0,0 +1,225 @@
import logging
from argparse import ArgumentParser
from telegram import Update
from telegram.constants import ParseMode
from telegram.ext import Application, CommandHandler, MessageHandler, filters, CallbackContext
import conf
import interfaces
import player
import quiz
import session
from translations.required import *
class Server:
logger = logging.getLogger(__name__)
SESSIONS = dict()
def __init__(self):
self.config_instance = self.config_init()
logging.basicConfig(
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
level=logging.DEBUG,
handlers=[
logging.FileHandler(self.config_instance.LOG_FILE),
logging.StreamHandler()
]
)
self.logger = logging.getLogger(__name__)
self.application = Application.builder().token(
self.config_instance.TELEGRAM_BOT_API).build()
# Add handlers
self.application.add_handler(MessageHandler(
filters.TEXT & ~filters.COMMAND, self.command_check_resps)) # Add handler for non-command messages
self.application.add_handler(
CommandHandler('start', self.command_start))
self.application.add_handler(CommandHandler('roll', self.command_roll))
self.application.add_handler(CommandHandler(
"leaderboard", self.command_leaderboard))
self.application.add_handler(
CommandHandler("repeat", self.command_repeat))
self.application.add_handler(CommandHandler("cut", self.command_cut))
self.application.add_handler(CommandHandler("stop", self.command_stop))
self.application.add_error_handler(self.error)
# Set up job queue
self.application.job_queue.run_repeating(
self.update_all_timers, interval=60)
self.logger.info("Iniciando...")
# Start the Bot
self.application.run_polling()
def config_init(self):
self.logger.info("Config init...")
arg_parser = ArgumentParser(description="Movie2 Bot")
arg_parser.add_argument(
"-e", "--env", metavar="env", type=str, default="prod",
help="Environment to run the bot: dev, test or prod"
)
arg_parser.add_argument(
"-v", "--verbose", metavar="verbose", type=bool, default=False,
help="Print information about running bot"
)
args = arg_parser.parse_args()
if args.env == "prod":
return conf.ProductionConfig()
elif args.env == "dev":
return conf.DevelopmentConfig()
else:
return conf.TestingConfig()
async def error(self, update: Update, context: CallbackContext):
self.logger.warning(
f'Update "{update}" caused error "{context.error}"')
async def update_all_timers(self, context: CallbackContext):
self.logger.info("Updating all timers...")
for sess in self.SESSIONS.values():
sess.update_timers()
if hasattr(sess, 'quiz'):
sess.quiz.check_expiration()
else:
self.logger.error("Quiz class not found in session")
async def command_start(self, update: Update, context: CallbackContext):
self.logger.info("Command start...")
chat_id = update.message.chat_id
await update.message.reply_text('¡Hola! El comando /start ha sido recibido.')
if chat_id not in self.SESSIONS:
self.messenger = interfaces.TelegramMessenger(
context.bot, self.logger
)
self.SESSIONS[chat_id] = session.Session(
chat_id, self.config_instance, self.logger
)
self.SESSIONS[chat_id].set_messenger(self.messenger)
# Crear una instancia de la clase Quiz
try:
self.SESSIONS[chat_id].quiz = quiz.Quiz(self.SESSIONS[chat_id])
except AttributeError as e:
self.logger.error(f"Error creating Quiz instance: {e}")
await self.messenger.send_msg(
chat_id, _("error_creating_quiz_instance")
)
logging.error(f"Error creating Quiz instance: {e}")
async def command_roll(self, update: Update, context: CallbackContext):
self.logger.info("Command roll...")
chat_id = update.message.chat_id
args = context.args
rand_type = args[0] if args else None
await self.SESSIONS[chat_id].messenger.send_msg(
chat_id, _("searching_movies"))
if hasattr(self.SESSIONS[chat_id], 'quiz'):
await self.SESSIONS[chat_id].quiz.show(update, rand_type)
else:
self.logger.error("Quiz instance not found in session")
async def command_leaderboard(self, update: Update, context: CallbackContext):
self.logger.info("Command leaderboard...")
chat_id = update.message.chat_id
sess = self.SESSIONS.get(chat_id)
if sess:
try:
await sess.messenger.send_msg(
chat_id, _("leader_board_title"), 'highlights'
)
ldb = sess.get_leaderboard()
await sess.messenger.send_msg(chat_id, ldb)
except ValueError as e:
await sess.messenger.send_msg(
chat_id, f'{update.message.from_user.first_name} {e.args[0]}'
)
else:
await update.message.reply_text(_("session_not_found"))
async def command_action(self, update: Update, context: CallbackContext):
self.logger.info("Command action...")
chat_id = update.message.chat_id
try:
player = player.Player(update.message.from_user.id)
player.name = f'{update.message.from_user.first_name} {update.message.from_user.last_name}'
self.SESSIONS[chat_id].player_add(player)
await self.SESSIONS[chat_id].messenger.send(
update, f'{player.name} {_("joined_the_game")}'
)
except ValueError as e:
await self.SESSIONS[chat_id].messenger.send(
update, f'{update.message.from_user.first_name} {e.args[0]}'
)
async def command_repeat(self, update: Update, context: CallbackContext):
self.logger.info("Command repeat...")
chat_id = update.message.chat_id
if chat_id in self.SESSIONS:
movie_img = self.SESSIONS[chat_id].quiz.get_question()
await self.SESSIONS[chat_id].messenger.send_msg(
chat_id, _("repeting")
)
await self.SESSIONS[chat_id].messenger.send_photo(
chat_id=chat_id, photo=movie_img,
caption=_("what_is_the_movie_series_name")
)
await self.SESSIONS[chat_id].messenger.send_msg(
chat_id, "==========================="
)
else:
await update.message.reply_text(_("session_not_found"))
async def command_cut(self, update: Update, context: CallbackContext):
self.logger.info("Command cut...")
chat_id = update.message.chat_id
if chat_id in self.SESSIONS:
try:
player = player.Player(update.message.from_user.id)
self.SESSIONS[chat_id].player_quit(player)
await self.SESSIONS[chat_id].messenger.send(
update, f'{player.name} {_("quit_the_game")}'
)
except ValueError as e:
await self.SESSIONS[chat_id].messenger.send(
update, f'{update.message.from_user.first_name} {e.args[0]}'
)
else:
await update.message.reply_text(_("session_not_found"))
async def command_stop(self, update: Update, context: CallbackContext):
self.logger.info("Command stop...")
chat_id = update.message.chat_id
if chat_id in self.SESSIONS:
self.SESSIONS[chat_id].stop()
await self.SESSIONS[chat_id].messenger.send(
update, _("game_stopped")
)
else:
await update.message.reply_text(_("session_not_found"))
# Implement the command_check_resps method
async def command_check_resps(self, update: Update, context: CallbackContext):
self.logger.info("Checking responses...")
chat_id = update.message.chat_id
if chat_id in self.SESSIONS:
user_response = update.message.text
sess = self.SESSIONS[chat_id]
# Add logic to handle user responses here
await sess.messenger.send_msg(chat_id, f"Received: {user_response}")
else:
await update.message.reply_text(_("session_not_found"))
if __name__ == "__main__":
Server()

View File

@@ -0,0 +1 @@
from server.Server import Server

View File

@@ -0,0 +1,55 @@
import datetime
class Session:
def __init__(self, chat_id, config, logger):
self.logger = logger
self.started = datetime.datetime.utcnow()
self.chat_id = chat_id
self.config = config
self.expiration = self.config.SESSION_EXPIRATION_TIME
self.players = {}
self.status = ''
self.messenger = None
self.counter = datetime.datetime.utcnow()
def player_add(self, player):
if player.id not in self.players:
self.players[player.id] = player
else:
self.update_log()
raise ValueError('Ya está en la partida...')
def player_quit(self, player):
if player.id in self.players:
del self.players[player.id]
else:
raise ValueError('no_está_en_la_partida')
def end(self):
self.ended = datetime.datetime.utcnow()
def get_leaderboard(self):
ldb = ''
for player in self.players.values():
ldb += f'{player.name} : {player.get_points()}\n'
return ldb
def set_messenger(self, messenger):
self.messenger = messenger
def update_timers(self):
if self.status == 'running':
elapsed = self.update_log()
if elapsed.seconds > self.expiration:
self.status = 'timed_out'
def update_counter(self):
self.counter = datetime.datetime.utcnow()
self.logger.debug(f'{self.chat_id} : updater_counter: {self.counter}')
def update_log(self):
elapsed = datetime.datetime.utcnow() - self.counter
self.logger.debug(f'{self.chat_id} : updater_timer: {elapsed}')
return elapsed

Some files were not shown because too many files have changed in this diff Show More