Así que me cansé de abrir cuatro páginas distintas cada mañana solo para ver si había algo realmente nuevo, así que ahora un script hace esa parte por mí y me envía un resumen por correo a las 7 de la mañana. Casi siempre. Hubo un tramo de unas dos semanas en el que no hizo absolutamente nada en silencio y no me di cuenta, que es su propia historia.
lo que se supone que debe traer
Un puñado de fuentes (la API de Hacker News, el feed RSS de NRK, un par más), obtenidas, analizadas, y luego filtradas contra una lista de palabras clave que de verdad me importan. Todo lo demás se descarta, que es la mayoría. Ese es un poco todo el sentido.
import requests
KEYWORDS = ["postgres", "sveltekit", "linux", "norway", "helgeland"]
def fetch_hn_headlines():
ids = requests.get("https://hacker-news.firebaseio.com/v0/topstories.json").json()[:50]
headlines = []
for story_id in ids:
item = requests.get(f"https://hacker-news.firebaseio.com/v0/item/{story_id}.json").json()
title = item["title"]
if any(k in title.lower() for k in KEYWORDS):
headlines.append((title, item["url"]))
return headlinesEsa versión funcionó bien durante una semana. Luego, una mañana, ningún resumen.
las dos semanas en las que no noté que algo iba mal
Cron no te manda un correo cuando un script falla a menos que hayas configurado MAILTO, cosa que yo no había hecho, así que un fallo tres líneas dentro de un bucle for simplemente termina el trabajo en silencio y nada te avisa. Me enteré por pura casualidad, comentándole a alguien que el resumen era práctico, y luego dándome cuenta de que en realidad no había visto uno en un tiempo, y yendo a comprobarlo.
crontab -l0 7 * * * /home/bjorn/.venv/bin/python /home/bjorn/digest.pyNada ahí apunta al problema real, solo dice que el trabajo existe. Tuve que ejecutarlo a mano para ver algo.
python3 digest.pyTraceback (most recent call last):
File "digest.py", line 12, in fetch_hn_headlines
title = item["title"]
KeyError: 'title'lo que en realidad busqué en Google, y lo que encontré
"python requests item some ids missing fields hacker news api" me llevó a un par de hilos de Stack Overflow específicamente sobre la API de HN, y la respuesta real fue casi aburrida en cuanto la vi: una historia "muerta" o eliminada sigue apareciendo en la lista topstories, pero su búsqueda de elemento vuelve sin ningún campo title en absoluto, a veces ni siquiera un objeto real, solo None. Mi código asumía que cada ID de esa lista apunta a una historia normal y completa. No es así. En algún lugar entre los cincuenta IDs de esa mañana había uno muerto, y item["title"] reventó todo el bucle, lo cual mató todo el script, lo cual significó que se obtuvieron cero titulares de cualquier fuente esa mañana, o cualquier mañana después, ya que el fallo ocurría antes de que se enviara nada, y cron simplemente se comía el fallo en silencio cada día después de eso.
La respuesta aceptada en el hilo que de verdad ayudó no trataba específicamente de la API de HN, era un patrón general: nunca dejes que un elemento malo en un lote se lleve por delante todo el lote. Envuelve el trabajo por elemento, registra lo que falló, sigue adelante.
la versión que de verdad sobrevive a un elemento malo
import logging
import requests
logging.basicConfig(filename="/home/bjorn/digest.log", level=logging.WARNING)
KEYWORDS = ["postgres", "sveltekit", "linux", "norway", "helgeland"]
def fetch_hn_headlines():
ids = requests.get("https://hacker-news.firebaseio.com/v0/topstories.json", timeout=10).json()[:50]
headlines = []
for story_id in ids:
try:
item = requests.get(
f"https://hacker-news.firebaseio.com/v0/item/{story_id}.json", timeout=10
).json()
title = item.get("title")
if not title:
continue
except (requests.RequestException, AttributeError) as e:
logging.warning("skipping story %s: %s", story_id, e)
continue
if any(k in title.lower() for k in KEYWORDS):
headlines.append((title, item.get("url", "")))
return headlinesAhora un elemento malo se registra y se salta en lugar de llevarse toda la ejecución por delante. También añadí MAILTO al crontab, para que un fallo futuro llegue de verdad a mi bandeja de entrada en lugar de desaparecer en un archivo de registro que tengo que acordarme de revisar.
reutilizando el código de correo
La mitad que envía el correo está básicamente calcada del proyecto de la cámara, solo que sin el adjunto de imagen. Una vez que has escrito el boilerplate de smtplib una vez, en realidad no quieres volver a escribirlo. Simplemente copias la función y cambias lo que envía.
import smtplib
from email.message import EmailMessage
def send_digest(headlines):
body = "\n".join(f"- {title} ({url})" for title, url in headlines)
msg = EmailMessage()
msg["Subject"] = f"morning digest: {len(headlines)} headlines"
msg["From"] = "digest@example.com"
msg["To"] = "bjorn@example.com"
msg.set_content(body or "Nothing matched today.")
with smtplib.SMTP_SSL("smtp.example.com", 465) as server:
server.login("digest@example.com", "app-password-not-real")
server.send_message(msg)crontab -lMAILTO=bjorn@example.com
0 7 * * * /home/bjorn/.venv/bin/python /home/bjorn/digest.py
el feed de NRK tuvo su propia pequeña sorpresa
Aparte del fallo de HN, el análisis del RSS de NRK se atragantó la primera vez que apareció en el feed un elemento solo de vídeo sin texto de artículo, solo un vídeo incrustado. Mi analizador asumía que cada entrada tenía un campo description que valía la pena leer. Ese fue un arreglo mucho más pequeño, un simple getattr(entry, "description", "") en lugar de asumir que el atributo existe, pero es la misma lección dos veces en un mismo script: una API o un feed que te dice la forma de los datos es una sugerencia, no una garantía, y los casos límite acaban apareciendo tanto si los planificaste como si no.
el filtrado en sí fue más difícil que la obtención de datos
El scraping real, una vez que dejó de fallar, fue la parte fácil. Decidir qué cuenta como "digno de mi atención" es la parte que sigo ajustando. La coincidencia de palabras clave es tonta. Va a marcar un artículo que mencione "norway" una vez de pasada sobre algo que no me importa en absoluto, y se va a perder algo genuinamente relevante porque usó un sinónimo en lugar de mi palabra clave exacta. No tengo un buen arreglo para esto. Puede que simplemente sea un mal problema para resolver con coincidencia de cadenas de texto, punto.
dónde encaja al lado de todo lo demás
Este y el script de la cámara son las dos cosas que he construido que en realidad corren sin supervisión bajo un horario en lugar de ser invocadas por mí escribiendo un comando. Eso es un tipo de programación distinto del de la app de tareas por CLI o las herramientas de PDF, más cercano a "escríbelo una vez, confía en que va a seguir funcionando", que es un músculo que claramente no había desarrollado antes de este año, y uno que por lo visto necesita su propia disciplina de manejo de fallos que un script que ejecutas a mano en realidad no te castiga por saltarte.
la lista de palabras clave ha crecido en silencio
Empezó con tres palabras. Ahora son cinco, y cada adición vino del mismo patrón: leía algo relevante directamente en una de las páginas, recordaba que el resumen no lo había marcado, e iba a añadir la palabra que fuera que debería haberlo capturado. "helgeland" se añadió después de una noticia local sobre el horario del ferri de la que solo me enteré de segunda mano. Es una lista de palabras clave que solo mejora de forma reactiva, después de ya haber fallado en capturar algo una vez, lo cual no es un gran proceso pero por lo visto es el único al que de verdad le doy seguimiento.
qué haría distinto
Obtener los cincuenta elementos de HN de forma concurrente en lugar de uno a uno, lo cual también haría que todo el asunto fuera menos doloroso de depurar, ya que una única petición lenta o colgada no bloquearía todo lo que va detrás de ella. concurrent.futures.ThreadPoolExecutor está ahí mismo en la biblioteca estándar y todavía no me he puesto con ello, sobre todo porque la cosa funciona ahora y "funciona ahora" es un argumento sorprendentemente fuerte para no volver a tocarlo.
También registraría una ejecución exitosa, no solo una fallida, para que una mañana tranquila con genuinamente cero titulares coincidentes y una mañana que falló en silencio con cero titulares no se vean idénticas desde fuera. Ahora mismo ambas producen exactamente nada en mi bandeja de entrada, y solo puedo distinguirlas yendo a revisar el archivo de registro, que es el mismo error que causó el hueco de dos semanas en primer lugar, solo que con un radio de impacto más pequeño esta vez.
En fin. Me envía titulares por correo. No es glamuroso. Lo leo con café la mayoría de las mañanas, que era genuinamente todo el objetivo, las mañanas en que de verdad se ejecuta.