Este tutorial adapta pedagógicamente el Día 39. Las secciones 📝 Ejercicio original del curso provienen de los archivos del curso y de sus soluciones publicadas (gists enlazados en cada paso). Los retos 🟢🟡🟠🔴, el proyecto extra y el proyecto integrador son creaciones de esta adaptación. Las correcciones al material original aparecen como 🛠️ Nota técnica.
Nota sobre las verificaciones: con Python 3.14,
requests2.34 yrequests-cachese comprobó, el 26 de septiembre de 2026: la respuesta de SerpAPI sin clave (error 401), que la API de vuelos simulados del curso exige iniciar sesión, y el comportamiento de la funciónfind_cheapest_flightde la solución oficial con datos simulados. No se crearon cuentas en SerpAPI, Sheety ni Twilio. El proyecto inicial no está en la carpeta; su estructura se describe en la lección296.
🔐 Para el aula: este proyecto usa cuatro servicios externos, con cuentas, claves y límites gratuitos (SerpAPI: 100 búsquedas al mes; Sheety: 200 peticiones al mes). Recomendación: el docente hace la demostración con sus cuentas, y los estudiantes desarrollan con datos guardados (el ejemplo de respuesta que publica el curso, sección 4) y un CSV local en lugar de Google Sheets. La arquitectura y la lógica son idénticas.
Tabla de contenidos
- Antes de empezar: cazar ofertas de vuelos
- Paso 1: el proyecto inicial y sus cuatro clases
- Paso 2: leer los destinos con Sheety y un archivo
.env - Paso 3: buscar vuelos para una ciudad
- Paso 4: analizar la respuesta y encontrar el vuelo más barato
- Paso 5: revisar todos los destinos
- Paso 6: enviar la notificación
- 🚀 Proyecto del día: el Buscador de Ofertas de Vuelos
- 🌟 Proyecto extra: el Planificador de la Excursión de Grado Once
- 🏆 Proyecto integrador de los Días 37 a 39: el Vigía de Datos Abiertos
- 🧩 Comprueba lo aprendido
- 🤖 Programar con apoyo de IA
- 🛠️ Correcciones técnicas al material original
- 📋 Matriz de trazabilidad
- 📦 Evidencias de aprendizaje del Día 39
0. Antes de empezar: cazar ofertas de vuelos
(Fuente: 295 - Day 39 Goals- what you will make by the end of the day.md)
A la profesora le encanta viajar, pero no escoge primero el destino: busca ofertas. Cuenta que consiguió un vuelo a Nueva Zelanda por 350 libras cuando normalmente cuesta unas 800, y uno a Japón por 250 cuando suele costar 500 o 550. El truco es revisar precios todos los días, para muchos destinos… algo que nadie tiene tiempo de hacer a mano.
El programa (dos partes):
- Hoy (parte 1): un buscador de ofertas para ti.
- Día 40 (parte 2): convertirlo en un servicio para muchos usuarios.
Cómo funciona:
Hoja de Google (destinos y precio límite) ──► API de búsqueda de vuelos ──► ¿más barato que el límite?
│ sí
▼
Mensaje (SMS o WhatsApp) con precio y fechasEn la demostración, la profesora recibió: "Alerta de precio bajo: solo 41 libras para volar de Londres Stansted a Berlín…", apenas una libra por debajo de su límite.
Prerequisito general: clases y separación de responsabilidades (Días 16, 17 y 34); APIs, parámetros, encabezados y autenticación (Días 33 a 38); PUT (Día 37);
requests-cache(Día 36); variables de entorno (Día 35);datetimeytimedelta; JSON anidado; comprensión de listas y diccionarios.
1. Paso 1: el proyecto inicial y sus cuatro clases
(Fuente: 296-step-1-download-the-starting-project-and-register-with-serpapi.md)
🎯 Objetivo de aprendizaje
Entender cómo se reparte un programa grande en clases con una responsabilidad cada una.
📝 Contenido original del curso
El proyecto inicial trae 5 archivos, casi vacíos:
| Archivo | Clase | Responsabilidad (según el comentario del curso) |
|---|---|---|
data_manager.py |
DataManager |
Hablar con la hoja de Google |
flight_search.py |
FlightSearch |
Hablar con la API de búsqueda de vuelos |
flight_data.py |
FlightData |
Estructurar los datos de un vuelo |
notification_manager.py |
NotificationManager |
Enviar las notificaciones |
main.py |
— | Usar las cuatro clases para cumplir los requisitos |
Elige tu camino: si dominas POO, APIs, datetime y comprensiones, puedes intentarlo solo con la lista de APIs. Si prefieres pasos pequeños, sigue las lecciones siguientes.
Bibliotecas: requests, requests_cache (caché para no gastar el plan gratuito), python-dotenv (leer secretos de un archivo .env) y twilio.
APIs:
- Sheety: leer y actualizar la hoja de destinos.
- SerpAPI Google Flights: registro gratuito, 100 búsquedas al mes, sin tarjeta de crédito.
- Alternativa del curso: datos de vuelos simulados en
app.100daysofpython.dev/services/flights(requiere iniciar sesión, verificado). - Twilio: SMS o WhatsApp.
Requisitos del programa:
- Leer los destinos (con su código IATA) de la hoja con Sheety.
- Buscar con SerpAPI los vuelos más baratos entre mañana y dentro de 6 meses para cada ciudad.
- Si el precio es menor que el "precio más bajo" de la hoja, enviar un mensaje.
- El mensaje incluye: código IATA de salida y de llegada, precio y fechas.
Advertencias del curso: usar 3 destinos (máximo 5) para no agotar los límites gratuitos; Twilio puede dar problemas con SMS en algunos países (usar WhatsApp).
📖 ¿Por qué cuatro clases?
Imagina que mañana cambias SerpAPI por otra API de vuelos. Si todo estuviera en main.py, tendrías que revisar el programa entero. Con esta estructura, solo cambias FlightSearch. Es la misma idea del Día 34 (el "cerebro" del cuestionario separado de la interfaz), llevada a un proyecto con varias APIs.
Retos adicionales (creados para esta adaptación)
- 🟢 Reto 1: para cada requisito del programa, escribe qué clase lo resuelve.
- 🟡 Reto 2: dibuja un diagrama con las cuatro clases y flechas que muestren quién llama a quién.
- 🟠 Reto 3: si mañana quisieras recibir las alertas por correo en vez de WhatsApp, ¿qué archivo cambiarías? ¿Y si la hoja de Google se reemplaza por un CSV?
- 🔴 Reto 4: calcula cuántas búsquedas de SerpAPI gasta una ejecución con 3 destinos. ¿Cuántos días seguidos podrías ejecutarlo sin caché antes de llegar a 100?
2. Paso 2: leer los destinos con Sheety y un archivo .env
(Fuente: 297-step-2-use-sheety-to-read-airport-and-price-data.md)
📝 Contenido original del curso
- Copiar la hoja inicial del curso y crear un proyecto en Sheety con esa copia. Habilitar PUT (además de GET) para poder actualizar la hoja. La hoja debe pertenecer a la misma cuenta con la que entraste a Sheety.
- Elegir 3 ciudades y escribir el código IATA de su aeropuerto principal.
- Con
DataManager.get_destination_data(), hacer un GET y devolver amain.pylo que hay en la claveprices, guardado ensheet_data. - Imprimirlo con
pprint(pretty print, "impresión bonita"). - Activar autenticación Basic en Sheety y guardar usuario y contraseña en un archivo
.env, leído conpython-dotenv.
📖 Los códigos IATA
Son códigos de 3 letras de los aeropuertos. Algunos son tan conocidos que se usan en conversación (LAX, JFK). Algunas ciudades con varios aeropuertos tienen además un código de ciudad: LON (Londres) agrupa LHR (Heathrow), LGW (Gatwick), etc.
Ejemplos colombianos (adaptación): BOG (Bogotá, El Dorado), MDE (Rionegro, José María Córdova, que sirve a Medellín), CTG (Cartagena), CLO (Cali), SMR (Santa Marta), ADZ (San Andrés), LET (Leticia). Verifica cada código en una fuente oficial antes de usarlo.
🔎 La solución del curso
.env (nunca se sube a un repositorio):
SHEETY_USERNAME="your_username"
SHEETY_PASSWORD="your_password"data_manager.py:
import os
import requests
from requests.auth import HTTPBasicAuth
from dotenv import load_dotenv
load_dotenv()
SHEETY_PRICES_ENDPOINT = "https://api.sheety.co/your_endpoint/prices"
class DataManager:
def __init__(self):
self._user = os.environ["SHEETY_USERNAME"]
self._password = os.environ["SHEETY_PASSWORD"]
self._authorization = HTTPBasicAuth(self._user, self._password)
self.destination_data = {}
def get_destination_data(self):
response = requests.get(url=SHEETY_PRICES_ENDPOINT, auth=self._authorization)
data = response.json()
self.destination_data = data["prices"]
return self.destination_dataload_dotenv()lee el archivo.envy carga sus valores como variables de entorno. Así no hay que configurarlas en cada terminal (Día 35).HTTPBasicAuth(usuario, contraseña)es la forma de clase deauth=(usuario, contraseña)(Día 38).- El guion bajo en
self._useres una convención: indica que el atributo es de uso interno de la clase. - El resultado se ve así:
[{'city': 'Paris', 'iataCode': 'CDG', 'lowestPrice': 54, 'id': 2}, ...]. Cada fila trae unid, que servirá para actualizarla.
💡 Buena práctica (adaptación): crea también un archivo
.gitignorecon la línea.env, para que el archivo de secretos nunca se suba a un repositorio.
Retos adicionales (creados para esta adaptación)
- 🟢 Reto 1: imprime solo "Ciudad — IATA — precio límite" de cada fila.
- 🟡 Reto 2: crea una versión local de
DataManagerque leadestinos.csvcon las mismas columnas y devuelva la misma lista de diccionarios. ¿main.pynota la diferencia? - 🟠 Reto 3: agrega
response.raise_for_status()y prueba con una contraseña equivocada en el.env. ¿Qué mensaje aparece? - 🔴 Reto 4: valida al leer la hoja que cada
iataCodetenga exactamente 3 letras mayúsculas, e informa qué filas están mal.
3. Paso 3: buscar vuelos para una ciudad
(Fuente: 298-step-3-get-flights-and-prices-for-a-single-city.md)
📝 Contenido original del curso
1. Caché antes que nada. Al inicio de main.py:
import requests_cache
requests_cache.install_cache(
"flight_cache",
urls_expire_after={
"*.sheety.co*": requests_cache.DO_NOT_CACHE,
"*": 3600,
}
)- Crea el archivo
flight_cache.sqliteen la carpeta del proyecto. Puedes borrarlo para forzar datos nuevos. - Las respuestas se guardan 1 hora (3600 s), excepto las de Sheety (
DO_NOT_CACHE): así siempre ves los datos actuales de tu hoja.
2. Las fechas:
from datetime import datetime, timedelta
tomorrow = datetime.now() + timedelta(days=1)
six_month_from_today = datetime.now() + timedelta(days=(6 * 30))3. La clave de SerpAPI en el .env (SERPAPI_API_KEY="...") y guardada en el objeto:
class FlightSearch:
def __init__(self):
self._api_key = os.environ["SERPAPI_API_KEY"]4. La búsqueda:
SERPAPI_ENDPOINT = "https://serpapi.com/search"
def check_flights(self, origin_city_code, destination_city_code, from_time, to_time):
query = {
"engine": "google_flights",
"departure_id": origin_city_code,
"arrival_id": destination_city_code,
"outbound_date": from_time.strftime("%Y-%m-%d"),
"return_date": to_time.strftime("%Y-%m-%d"),
"type": "1",
"adults": "1",
"currency": "GBP",
"api_key": self._api_key,
}
response = requests.get(url=SERPAPI_ENDPOINT, params=query)
if response.status_code != 200:
print(f"check_flights() response code: {response.status_code}")
return None
data = response.json()
if "error" in data:
print(f"API error: {data['error']}")
return None
return data- El curso explica que, a diferencia de APIs que usan tokens temporales (OAuth), SerpAPI usa una clave simple enviada como parámetro en cada petición.
- Sin clave válida, la respuesta es 401 con
{"error": "Invalid API key. ..."}(verificado). check_flightsdevuelve los datos, oNonesi algo falló, después de imprimir el error.
5. Primera prueba: buscar de LHR (Londres Heathrow) a CDG (París Charles de Gaulle) e imprimir el resultado con pprint.
🛠️ Nota técnica 1 (qué se está buscando realmente): el enunciado dice "los vuelos más baratos entre mañana y dentro de 6 meses". Pero la consulta envía
outbound_date= mañana yreturn_date= dentro de 180 días: busca un viaje de ida y vuelta que sale mañana y regresa en 6 meses, no el más barato de todo ese período. Encontrar la mejor fecha exigiría varias búsquedas con distintas fechas, y cada una gasta cuota. Discútelo en el 🧪 del proyecto.
🛠️ Nota técnica 2:
6 * 30días no son exactamente 6 meses: desde el 26/09/2026, 180 días llevan al 25/03/2027, no al 26/03/2027 (verificado). Para este proyecto la diferencia no importa, pero conviene saber que es una aproximación.
🔍 Análisis: en el Día 37 vimos que enviar una clave en la URL es menos seguro que en un encabezado. SerpAPI la pide como parámetro; no podemos cambiar cómo funciona la API, pero sí evitar imprimir o compartir la URL completa de la petición.
⚠️ Errores frecuentes
| Error | Qué ocurre | Solución |
|---|---|---|
| Instalar la caché después de hacer peticiones | Esas peticiones no se guardan | install_cache al principio de main.py |
| Cachear Sheety | No ves los cambios de tu hoja | DO_NOT_CACHE para *.sheety.co* |
Pasar el datetime sin strftime |
Formato de fecha no válido | strftime("%Y-%m-%d") |
Olvidar load_dotenv() en flight_search.py |
KeyError: 'SERPAPI_API_KEY' |
Cárgalo antes de leer la variable |
Retos adicionales (creados para esta adaptación)
- 🟢 Reto 1: ejecuta dos veces la búsqueda con la caché activa e imprime
from_cache(Día 36). - 🟡 Reto 2: cambia
currencya"COP"y el origen a"BOG". ¿Qué otros parámetros de la documentación de SerpAPI te servirían (por ejemplo, idioma o país)? - 🟠 Reto 3: calcula "dentro de 6 meses" con meses de calendario reales, sin
timedelta(days=180)(pista: suma 6 al mes y ajusta el año; cuidado con días como el 31). - 🔴 Reto 4: escribe
check_flightspara que, en lugar de imprimir y devolverNone, lance una excepción propiaErrorBusquedaVueloscon un mensaje claro. ¿Qué ventajas y desventajas tiene frente a la solución del curso?
4. Paso 4: analizar la respuesta y encontrar el vuelo más barato
(Fuente: 299-step-4-parse-the-flight-data-and-show-the-cheapest-flight.md)
📝 Contenido original del curso
Objetivo: recorrer la respuesta de SerpAPI e imprimir el precio más barato para una ciudad, por ejemplo 'Paris: GBP 155' y 'Lower price flight found to Paris!'. Si es más barato que el de la hoja, actualizar la hoja.
La estructura de la respuesta (resumida):
{
"airports": [...],
"best_flights": [
{
"flights": [
{"departure_airport": {"name": "Heathrow Airport", "id": "LHR", "time": "2026-06-15 10:30"},
"arrival_airport": {"name": "Charles de Gaulle", "id": "CDG", "time": "2026-06-15 12:45"},
"airline": "British Airways"}
],
"total_duration": 75,
"price": 120
}
],
"other_flights": [...]
}- Los vuelos vienen en dos listas:
best_flightsyother_flights. Hay que unirlas. - Cada opción tiene una lista
flightscon los tramos: el primero sale del origen y el último ([-1]) llega al destino. Si hay escala, hay más de un tramo. - El curso recomienda la herramienta jsonpathfinder.com para explorar el JSON: "la parte más difícil es navegar el JSON".
🏫 Para practicar sin cuenta: el curso publica un ejemplo completo de esta respuesta (el archivo
flights.jsondel enlace de la lección298). El docente puede descargarlo y compartirlo. Guárdalo en tu proyecto y cárgalo conjson.load: así desarrollas y pruebas este paso sin gastar búsquedas.
Rutas para extraer los datos (pistas del curso):
flight["price"] # ya es un número
flight["flights"][0]["departure_airport"]["id"] # aeropuerto de origen
flight["flights"][-1]["arrival_airport"]["id"] # aeropuerto de destino
flight["flights"][0]["departure_airport"]["time"] # "YYYY-MM-DD HH:MM"; .split(" ")[0] da la fechaEl precio es del viaje de ida y vuelta, pero la respuesta solo detalla la ida. Por eso la fecha de regreso se pasa como parámetro, tomada de la búsqueda.
🔎 La solución del curso
FlightData guarda los datos de un vuelo:
class FlightData:
def __init__(self, price, origin_airport, destination_airport, out_date, return_date):
self.price = price
self.origin_airport = origin_airport
self.destination_airport = destination_airport
self.out_date = out_date
self.return_date = return_datefind_cheapest_flight, una función fuera de la clase (cuidado con la sangría):
def find_cheapest_flight(data, return_date):
if data is None or (not data.get("best_flights") and not data.get("other_flights")):
print("No flight data")
return FlightData("N/A", "N/A", "N/A", "N/A", "N/A")
all_flights = data.get("best_flights", []) + data.get("other_flights", [])
first_flight = all_flights[0]
lowest_price = first_flight["price"]
origin = first_flight["flights"][0]["departure_airport"]["id"]
destination = first_flight["flights"][-1]["arrival_airport"]["id"]
out_date = first_flight["flights"][0]["departure_airport"]["time"].split(" ")[0]
cheapest_flight = FlightData(lowest_price, origin, destination, out_date, return_date)
for flight in all_flights:
try:
price = flight["price"]
except KeyError:
print("--- No price available for flight. ---")
continue
if price < lowest_price:
lowest_price = price
# ... actualizar origin, destination, out_date ...
cheapest_flight = FlightData(lowest_price, origin, destination, out_date, return_date)
return cheapest_flight- Sin vuelos: se devuelve un
FlightDatacon"N/A"en todo, en lugar de dejar que el programa se caiga. - Vuelo sin precio: a veces llega un vuelo sin el campo
price; eltry/except KeyErrorlo salta concontinue. data.get("best_flights", [])devuelve una lista vacía si la clave no existe (Día 30).
Actualizar la hoja (PUT, Día 37):
def update_lowest_price(self, row_id, new_price):
new_data = {
"price": {
"lowestPrice": new_price
}
}
requests.put(
url=f"{SHEETY_PRICES_ENDPOINT}/{row_id}",
json=new_data,
auth=self._authorization
)Como en el Día 38, Sheety pide la clave raíz en singular: la hoja se llama prices, así que la clave es "price". La URL termina en el id de la fila.
En main.py:
cheapest_flight = find_cheapest_flight(flights, return_date=six_month_from_today.strftime("%Y-%m-%d"))
if cheapest_flight.price != "N/A" and cheapest_flight.price < sheet_data[0]["lowestPrice"]:
data_manager.update_lowest_price(sheet_data[0]["id"], cheapest_flight.price)Nota de depuración del curso: puede que de verdad no haya vuelos a cierto destino en esas fechas. Si no entiendes por qué no hay resultados, compruébalo en un sitio web de búsqueda de vuelos.
⚠️ Errores frecuentes
| Error | Qué ocurre | Solución |
|---|---|---|
Sangrar find_cheapest_flight dentro de la clase |
Se vuelve un método y no se puede importar como función | Sin sangría |
flight["flights"][0]["arrival_airport"] en un vuelo con escala |
Obtienes el aeropuerto de la escala, no el destino | [-1] para el último tramo |
Buscar solo en best_flights |
Puedes perder el más barato | Une las dos listas |
{"prices": {...}} en el PUT |
Sheety no actualiza | Clave raíz en singular: "price" |
Retos adicionales (creados para esta adaptación)
- 🟢 Reto 1: con el
flights.jsonguardado, imprime el precio y la aerolínea de todas las opciones. - 🟡 Reto 2: agrega a
FlightDatael número de escalas (len(flight["flights"]) - 1) e imprímelo. - 🟠 Reto 3: reescribe la búsqueda del más barato con
min()y el parámetrokey, descartando antes los vuelos sin precio con una comprensión de listas. - 🔴 Reto 4: usa
Noneen lugar de unFlightDatalleno de"N/A"para "no hay vuelos". ¿Qué comparaciones demain.pyse simplifican? ¿Qué riesgo tiene el diseño con"N/A"? (Pista: sección 6.)
5. Paso 5: revisar todos los destinos
(Fuente: 300-step-5-check-all-the-flights-in-your-sheet.md)
📝 Contenido original del curso
Extender la lógica de "solo París" a todas las filas de la hoja, con 3 destinos como máximo para respetar los límites gratuitos.
🔎 La solución del curso
ORIGIN_CITY_IATA = "LHR"
for destination in sheet_data:
print(f"Getting flights for {destination['city']}...")
flights = flight_search.check_flights(
ORIGIN_CITY_IATA,
destination["iataCode"],
from_time=tomorrow,
to_time=six_month_from_today
)
cheapest_flight = find_cheapest_flight(flights, return_date=six_month_from_today.strftime("%Y-%m-%d"))
print(f"{destination['city']}: GBP {cheapest_flight.price}")
if cheapest_flight.price != "N/A" and cheapest_flight.price < destination["lowestPrice"]:
print(f"Lower price flight found to {destination['city']}!")
data_manager.update_lowest_price(destination["id"], cheapest_flight.price)Lo que antes era sheet_data[0] ahora es destination, la fila de cada vuelta del for.
Retos adicionales (creados para esta adaptación)
- 🟢 Reto 1: al final, imprime un resumen: cuántos destinos se revisaron y cuántas ofertas se encontraron.
- 🟡 Reto 2: convierte
ORIGIN_CITY_IATAen una variable del.envo de unconfig.json. - 🟠 Reto 3: si un destino falla (sin datos), que el programa siga con los demás y los liste al final como "sin resultados".
- 🔴 Reto 4: mide cuántas peticiones a cada API hace una ejecución completa (cuenta con variables). Compáralo con los límites del plan gratuito.
6. Paso 6: enviar la notificación
(Fuente: 301-step-6-send-a-notification-with-twilio.md)
📝 Contenido original del curso
Si algún vuelo es más barato que el precio de la hoja, NotificationManager envía un SMS o un WhatsApp con: precio, aeropuerto de salida, aeropuerto de llegada, fecha de ida y fecha de regreso. Los datos de Twilio van al .env:
TWILIO_SID="..."
TWILIO_AUTH_TOKEN="..."
TWILIO_VIRTUAL_NUMBER="..."
TWILIO_VERIFIED_NUMBER="..."
TWILIO_WHATSAPP_NUMBER="..."🔎 La solución del curso
import os
from twilio.rest import Client
class NotificationManager:
def __init__(self):
self.client = Client(os.environ["TWILIO_SID"], os.environ["TWILIO_AUTH_TOKEN"])
def send_sms(self, message_body):
message = self.client.messages.create(
from_=os.environ["TWILIO_VIRTUAL_NUMBER"],
body=message_body,
to=os.environ["TWILIO_VERIFIED_NUMBER"]
)
print(message.sid)
def send_whatsapp(self, message_body):
message = self.client.messages.create(
from_=f'whatsapp:{os.environ["TWILIO_WHATSAPP_NUMBER"]}',
body=message_body,
to=f'whatsapp:{os.environ["TWILIO_VERIFIED_NUMBER"]}'
)
print(message.sid)Y en main.py, dentro del if:
notification_manager.send_whatsapp(
message_body=f"Low price alert! Only GBP {cheapest_flight.price} to fly "
f"from {cheapest_flight.origin_airport} to {cheapest_flight.destination_airport}, "
f"on {cheapest_flight.out_date} until {cheapest_flight.return_date}."
)Advertencia del curso: comprueba cheapest_flight.price != "N/A" antes de comparar: no se puede comparar un texto con un número. Verificado: "N/A" < 300 produce TypeError: '<' not supported between instances of 'str' and 'int'. Fíjate en el orden: con and, si la primera condición es falsa, Python no evalúa la segunda.
🏫 Para el aula: agrega a
NotificationManagerun métodosend_emailque use el servidor local del Día 32. Comomain.pysolo llama un método deNotificationManager, cambiar de canal es cambiar una línea.
Retos adicionales (creados para esta adaptación)
- 🟢 Reto 1: escribe el mensaje de alerta en español y con el precio con separador de miles (
f"{precio:,}"). - 🟡 Reto 2: agrega el método
send_emaily úsalo en modo aula. - 🟠 Reto 3: en lugar de un mensaje por destino, envía uno solo con todas las ofertas del día.
- 🔴 Reto 4: crea un método
notify(message_body)que decida el canal según una variable de configuración ("sms","whatsapp"o"email"). ¿Qué patrón de diseño reconoces?
7. 🚀 Proyecto del día: el Buscador de Ofertas de Vuelos
(Fuentes: 295 a 301)
Problema
Revisar automáticamente el precio de los vuelos a varios destinos y avisar cuando aparezca una oferta.
Contexto (adaptación)
Adapta el proyecto a Colombia: origen BOG (o el aeropuerto más cercano a tu municipio), moneda COP, y 3 destinos nacionales o internacionales.
Requisitos funcionales
- Leer destinos, códigos IATA y precios límite (Sheety o
destinos.csv). - Buscar el vuelo más barato para cada destino (SerpAPI o
flights.jsonguardado). - Si es más barato que el límite, actualizar el precio y notificar.
- Mensaje con precio, aeropuertos y fechas.
Restricciones técnicas
Cuatro clases con responsabilidades separadas; .env con todos los secretos y .gitignore; caché activa en desarrollo; ninguna comparación de "N/A" con números.
Pruebas
| Caso | Cómo simularlo | Esperado |
|---|---|---|
| Hay oferta | Precio límite alto en la hoja | Actualización y mensaje |
| No hay oferta | Precio límite muy bajo | Sin mensaje |
| Sin vuelos | Respuesta simulada con listas vacías | "No flight data", sin error |
| Vuelo sin precio en primera posición | Quita price al primer elemento del JSON guardado |
⚠️ (ver 🧪) |
| Clave de SerpAPI inválida | Cambia el .env |
Mensaje de error y el programa continúa |
🧪 Prueba y depuración
- El primer vuelo sin precio (análisis de esta adaptación). El
try/exceptde la solución protege el bucle, pero el precio del primer vuelo se lee antes, sin protección:lowest_price = first_flight["price"]. Si justo ese vuelo no tiene precio, el programa se detiene conKeyError: 'price'(verificado con datos simulados). Corrígelo: por ejemplo, filtra primero los vuelos que tienen precio y, si no queda ninguno, devuelve "sin datos". - ¿Qué fechas se buscan? Relee la nota técnica 1 de la sección 3. Propón una estrategia para buscar varias fechas de salida (por ejemplo, un viernes de cada mes) y calcula cuántas búsquedas gastaría con 3 destinos. ¿Cabe en 100 búsquedas al mes si lo ejecutas a diario? ¿Cómo ayuda la caché?
- El precio se actualiza en la hoja. Cuando se encuentra una oferta, el programa reemplaza el precio límite con el nuevo. La próxima vez, solo avisará si aparece algo aún más barato. ¿Es lo que quiere el usuario? Propón otra columna (por ejemplo, "precio que estoy dispuesto a pagar" separado de "mejor precio visto").
- Caché y ofertas reales. Con una caché de 1 hora, si ejecutas el programa varias veces en una hora, ¿ves precios nuevos? ¿Qué tiempo de caché usarías en la versión que se ejecuta una vez al día?
⭐ Extensión del proyecto
- Registrar cada búsqueda en un CSV (fecha, destino, precio) para ver la evolución de precios.
- Incluir en el mensaje el número de escalas y la duración total.
- Programar la ejecución diaria con GitHub Actions (Días 32 y 35), con los secretos del
.envcomo secretos del repositorio.
8. 🌟 Proyecto extra: el Planificador de la Excursión de Grado Once
Situación
El grupo de grado once está planeando su excursión de fin de año y debe comparar destinos dentro de un presupuesto por estudiante. Tienen información de precios recopilada en archivos.
Requisitos
destinos.csvcon: ciudad, código IATA, costo de alojamiento por noche y por persona (valores investigados o ficticios, indicando la fuente), y número de noches.- Una carpeta
respuestas/con archivos JSON de vuelos con la misma estructura que la respuesta de SerpAPI (usa el ejemplo del curso como base y modifica precios y aeropuertos). - Las mismas cuatro clases del proyecto del día, con implementaciones locales:
DataManagerlee el CSV,FlightSearchlee el JSON del destino,FlightDatayfind_cheapest_flightsin cambios (¡reutilízalas!), yNotificationManagerescribe un informe. - Para cada destino: costo total por estudiante = vuelo más barato + alojamiento × noches.
- Clasificación: "Dentro del presupuesto", "Hasta 10 % por encima" o "Fuera del presupuesto", con el presupuesto como constante.
- Informe final en
informe_excursion.txt(o correo al servidor local), ordenado del más económico al más costoso. - Todos los casos de datos incompletos manejados: destino sin archivo de vuelos, vuelos sin precio, CSV con celdas vacías.
Decisiones que debes tomar
- ¿El vuelo más barato es siempre la mejor opción para un grupo? (Piensa en escalas y duración.)
- ¿Cómo presentarías el informe al consejo de padres?
Casos de prueba
| Caso | Esperado |
|---|---|
| Destino sin archivo JSON | "Sin información de vuelos" en el informe |
| Todos los vuelos de un destino sin precio | "Sin precios disponibles"; sin KeyError |
| Presupuesto de 1 500 000 y costo de 1 600 000 | "Hasta 10 % por encima" |
9. 🏆 Proyecto integrador de los Días 37 a 39: el Vigía de Datos Abiertos
Proyecto integrador creado para esta adaptación. Reúne POST, PUT, DELETE y encabezados (Día 37), hojas de cálculo como API, autenticación y variables de entorno (Día 38), y la arquitectura de cuatro clases, la caché y el manejo de respuestas incompletas (Día 39).
Situación problema
La arquitectura del buscador de vuelos sirve para cualquier "vigilante de datos": leer una lista de cosas por vigilar, consultar una fuente, comparar con un umbral y avisar. El club de ciencias quiere un vigía que vigile datos abiertos de Colombia y del mundo.
Pregunta orientadora
¿Puede una misma arquitectura de clases resolver problemas distintos si cada clase tiene una sola responsabilidad?
Objetivo
Construir el Vigía de Datos Abiertos, con la misma estructura del Día 39 aplicada a otros datos.
Requerimientos
DataManager: leevigilancias.csv(o una hoja vía Sheety) con: nombre, fuente, condición ("mayor"o"menor"), umbral y último valor visto. Actualiza el último valor (PUT en Sheety o escritura en el CSV).- Búsquedas (una clase por fuente, todas con el mismo método
consultar() -> float | None): TRM (datos.gov.co, Día 36), probabilidad de lluvia (Open-Meteo, Día 35) y distancia de la ISS al colegio (Día 33). Lectura(equivalente aFlightData): guarda fuente, valor, fecha y hora de la lectura.NotificationManager: correo al servidor local con todas las alertas del día en un solo mensaje.requests-cachecon tiempos distintos por fuente (urls_expire_after) justificados.- Secretos (si algún servicio los requiere) en
.env, con.gitignore. - Una fuente que falla devuelve
Noney el programa sigue. - Registro en
lecturas.csvde todas las lecturas.
Restricciones
main.pyno contiene ninguna URL ni ninguna clave.- Agregar una fuente nueva solo exige crear una clase y una fila en
vigilancias.csv.
Etapas de desarrollo
| Etapa | Entregable parcial |
|---|---|
| 1. Diseño | Diagrama de clases comparado con el del buscador de vuelos |
| 2. Fuentes | Las tres clases de consulta probadas por separado |
| 3. Lógica | Comparación con umbrales, probada con valores simulados |
| 4. Datos | Actualización del último valor y lecturas.csv |
| 5. Avisos | Correo diario al servidor local |
| 6. Presentación | Agregar una cuarta fuente en vivo frente al grupo |
Entregables
- Código en varios archivos,
vigilancias.csvy.gitignore. - Diagrama de clases.
- 10 casos de prueba con resultados.
- Registro de errores con al menos 5 errores reales.
- Un párrafo que compare este proyecto con el buscador de vuelos: ¿qué clases se parecen y qué cambió?
Casos de prueba mínimos
| Caso | Esperado |
|---|---|
| TRM simulada por encima del umbral | Aparece en el correo |
| Open-Meteo no responde | Se registra la falla; las demás fuentes funcionan |
| Ejecutar dos veces en una hora | La segunda usa la caché donde corresponde |
| Agregar una fuente nueva | Solo una clase nueva y una fila en el CSV |
Criterios técnicos de funcionamiento
- Ninguna comparación entre texto y número.
- Cada clase tiene una sola responsabilidad.
- El programa termina aunque todas las fuentes fallen, con un informe de fallas.
Posibles extensiones
Programar la ejecución diaria; una interfaz Tkinter que muestre las últimas lecturas; gráficas de evolución (se estudian más adelante en el curso).
10. 🧩 Comprueba lo aprendido
Conceptuales
- ¿Cuál es la responsabilidad de cada una de las cuatro clases del proyecto?
- ¿Para qué sirve
load_dotenv()? ¿Por qué el archivo.envdebe ir en.gitignore? - ¿Por qué el curso excluye Sheety de la caché?
- ¿Qué es un código IATA? ¿Por qué Londres tiene un código de ciudad además de los de sus aeropuertos?
- ¿Qué fechas busca realmente
check_flightstal como la usa el curso?
Análisis de código
all_flights = data.get("best_flights", []) + data.get("other_flights", [])
destination = flight["flights"][-1]["arrival_airport"]["id"]¿Qué pasa si other_flights no existe en la respuesta? ¿Por qué se usa [-1]?
Predicción
- ¿Qué imprime
"2026-10-15 07:40".split(" ")[0]? - ¿Qué pasa al evaluar
"N/A" != "N/A" and "N/A" < 300? ¿Y"N/A" < 300 and "N/A" != "N/A"? - Con
urls_expire_after={"*.sheety.co*": DO_NOT_CACHE, "*": 3600}, ¿se guarda en caché una petición aserpapi.com? ¿Por cuánto tiempo?
Depuración
class FlightData:
def __init__(self, price, origin_airport):
self.price = price
self.origin_airport = origin_airport
def find_cheapest_flight(data):
all_flights = data["best_flights"] + data["other_flights"]
return min(all_flights, key=lambda f: f["price"])Encuentra tres problemas (piensa en la sangría, en claves que pueden faltar y en vuelos sin precio).
Modificación
Cambia el programa para que solo avise si la oferta es al menos un 10 % más barata que el precio de la hoja.
Creación
Escribe vuelos_con_precio(respuesta: dict) -> list[dict] que una las dos listas y devuelva solo los vuelos que tienen price. Pruébala con una respuesta simulada con 4 vuelos, 1 de ellos sin precio.
11. 🤖 Programar con apoyo de IA
Actividad 1: explorar un JSON grande con ayuda. La profesora dice que lo más difícil es navegar el JSON. Toma el flights.json de ejemplo y:
- Encuentra tú la ruta hasta el nombre de la aerolínea del primer vuelo usando jsonpathfinder o impresiones parciales.
- Luego pregunta a una IA por la misma ruta, pegándole solo un fragmento del JSON (sin claves ni datos personales).
- Comprueba ejecutando ambas rutas. ¿Coinciden?
Actividad 2: la IA y el significado de los parámetros. Pregunta a una IA: "Con SerpAPI Google Flights, si envío outbound_date mañana y return_date dentro de 6 meses, ¿estoy buscando el vuelo más barato de los próximos 6 meses?". Compara con la nota técnica 1 de la sección 3 y con la documentación oficial de SerpAPI.
Actividad 3: revisión de robustez. Pega find_cheapest_flight y pide: "¿Qué datos de entrada harían fallar esta función?". ¿Encuentra el caso del primer vuelo sin precio? Pruébalo con tus datos simulados.
12. 🛠️ Correcciones técnicas al material original
| Archivo | Afirmación o comportamiento del curso | Observación | Tratamiento |
|---|---|---|---|
296, 298 |
"Buscar los vuelos más baratos entre mañana y dentro de 6 meses" | La consulta busca un viaje que sale mañana y regresa en 180 días, no el más barato del período | Nota técnica 1 de la sección 3; 🧪 del proyecto |
298 |
timedelta(days=6 * 30) como "6 meses" |
Es una aproximación: 25/03/2027 en lugar de 26/03/2027 (verificado) | Nota técnica 2 de la sección 3 |
296 |
API de vuelos simulados como alternativa | Requiere cuenta e inicio de sesión (verificado) | Sección 1; alternativa con el flights.json de ejemplo |
299 |
try/except KeyError para vuelos sin precio |
El precio del primer vuelo se lee fuera del try: KeyError si falta (verificado con datos simulados) |
🧪 del proyecto |
299, 301 |
"N/A" como precio cuando no hay vuelos |
Mezcla texto y números; obliga a comprobar antes de comparar (TypeError verificado). None es una alternativa más clara |
Reto 4 de la sección 4 |
299 |
Se actualiza el precio límite de la hoja al encontrar una oferta | Cambia el significado de la columna: solo se avisará si aparece algo aún más barato | 🧪 del proyecto |
13. 📋 Matriz de trazabilidad
| Contenido original | Archivo de origen | Concepto desarrollado | Ejercicio original | Reto adicional | Proyecto relacionado |
|---|---|---|---|---|---|
| Historia de ofertas, flujo del programa, partes 1 y 2 | 295 |
Sección 0 | — | — | Sección 7 |
| Proyecto inicial de 5 archivos, bibliotecas, APIs, requisitos, límites, registro en SerpAPI | 296 |
Sección 1 | Paso 1 | Retos 1–4 de la sección 1 | Secciones 7, 8 y 9 |
Copia de la hoja, Sheety con PUT, IATA, pprint, Basic, .env, load_dotenv |
297 |
Sección 2 | Paso 2 | Retos 1–4 de la sección 2 | Secciones 7, 8 y 9 |
requests_cache.install_cache, DO_NOT_CACHE, fechas, FlightSearch, check_flights |
298 |
Sección 3 | Paso 3 | Retos 1–4 de la sección 3 | Secciones 7, 8 y 9 |
Estructura de la respuesta, FlightData, find_cheapest_flight, "N/A", update_lowest_price |
299 |
Sección 4 | Paso 4 | Retos 1–4 de la sección 4 | Secciones 7, 8 y 9 |
| Recorrer todos los destinos | 300 |
Sección 5 | Paso 5 | Retos 1–4 de la sección 5 | Secciones 7 y 8 |
NotificationManager, SMS y WhatsApp, secretos de Twilio, comprobar "N/A" |
301 |
Sección 6 | Paso 6 | Retos 1–4 de la sección 6 | Secciones 7, 8 y 9 |
| (No existe en el curso original) | — | — | — | Planificador de la Excursión de Grado Once | Sección 8 |
| (No existe en el curso original) | — | — | — | Vigía de Datos Abiertos (Días 37–39) | Sección 9 |
14. 📦 Evidencias de aprendizaje del Día 39
- El diagrama de las cuatro clases con sus responsabilidades.
sheet_dataimpreso conpprint(de Sheety o de tudestinos.csv).- La prueba de caché: primera y segunda ejecución con
from_cache. - El vuelo más barato encontrado en el
flights.jsonde ejemplo, con la corrección del primer vuelo sin precio. - La alerta de oferta (mensaje en la demostración del docente, o correo en el servidor local).
- Tu respuesta a la pregunta "¿qué fechas se buscan realmente?" y tu estrategia alternativa.
- El Planificador de la Excursión (proyecto extra) o el Vigía de Datos Abiertos (proyecto integrador).
- Cuaderno de errores:
KeyErroral leer variables sinload_dotenv, caché instalada tarde, fechas sinstrftime, tramos con escala, clave raíz del PUT,"N/A"comparado con números, primer vuelo sin precio y función sangrada dentro de la clase.