|
#!/usr/bin/env python3
# (c) 2018-2020 Jesús Cea Avión - jcea@jcea.es - https://www.jcea.es/
# This code is licensed under AGPLv3.
import os
import time
import subprocess
import pickle
from email.mime.text import MIMEText
import email.utils
import datetime
import socket
from bs4 import BeautifulSoup
import feedparser
path = 'items.pickle'
class items:
def __init__(self):
self.cambiado = False
with open(path, 'rb') as f:
data = pickle.load(f)
self._procesados = data['procesados']
self._buckets = data['buckets']
self._solapamiento = data.get('solapamiento', True)
self._procesados_nuevos = set()
self.etag = data['etag']
self.modified = data['modified']
self.saved = False
def condicional(self):
v = {}
if self.etag:
v['etag'] = self.etag
if self.modified:
v['modified'] = self.modified
return v
def notificado(self):
self._solapamiento = True
self.cambiado = True
def solapamiento(self):
if self.saved:
return self._solapamiento
return self._solapamiento and not self._procesados.isdisjoint(self._procesados_nuevos)
def update(self, guid, ts, title, link, summary):
self._procesados_nuevos.add(guid)
bucket = (ts.tm_year, ts.tm_mon, ts.tm_mday)
if bucket not in self._buckets:
self._buckets[bucket] = {}
if (ts, guid) in self._buckets[bucket]:
return
self._buckets[bucket][(ts, guid)] = (title, link, summary)
self.cambiado = True
def new(self, guid, ts, title, link, summary):
if guid in self._procesados:
self._procesados_nuevos.add(guid)
return
if guid in self._procesados_nuevos:
return
return self.update(guid, ts, title, link, summary)
def feed304(self):
self._procesados_nuevos.update(self._procesados)
return self.etag, self.modified
def itera_antiguos(self, horas=16):
ts_antiguo = datetime.datetime.now()
ts_antiguo -= datetime.timedelta(seconds=horas * 60 * 60)
bucket_antiguo = (ts_antiguo.year, ts_antiguo.month, ts_antiguo.day)
entradas = []
for bucket_name, bucket in self._buckets.items():
if bucket_name >= bucket_antiguo:
continue
for (ts, guid), (title, link, summary) in bucket.items():
entradas.append((ts, guid, title, link, summary))
for entrada in sorted(entradas, reverse=True):
yield entrada
def borrar(self, guid, ts):
bucket = (ts.tm_year, ts.tm_mon, ts.tm_mday)
del self._buckets[bucket][(ts, guid)]
if not self._buckets[bucket]:
del self._buckets[bucket]
self.cambiado = True
def save(self, etag, modified, purga=True):
# XXX: Esto es necesario para que se guarden las cabeceras
# de peticiones condicionales AUNQUE no haya habido cambios
# en el propio feed. Algo a mejorar en el futuro, por ejemplo
# pudiendo grabar solo eso.
if (etag != self.etag) or (modified != self.modified):
self.cambiado = True
if not self.cambiado:
return
self._solapamiento = self.solapamiento()
procesados = self._procesados_nuevos
if not purga:
procesados.update(self._procesados)
with open(path + '.NEW', 'wb') as f:
data = {'procesados': procesados,
'buckets': self._buckets,
'solapamiento': self._solapamiento,
'etag': etag, 'modified': modified,
}
pickle.dump(data, f, pickle.HIGHEST_PROTOCOL)
f.flush()
os.fsync(f.fileno())
os.rename(path + '.NEW', path)
self.cambiado = False
# Tras el save no deberíamos usar más este objeto
self.saved = True
del self._procesados
del self._procesados_nuevos
def main():
entradas = items()
condicional = entradas.condicional()
# XXX: Este timeout no es problema porque no tenemos múltiples hilos.
# Habría que usar mejor "request" o similar.
timeout = socket.getdefaulttimeout()
socket.setdefaulttimeout(10)
try:
feed = feedparser.parse(
'http://rss.slashdot.org/Slashdot/slashdotMain', **condicional)
finally:
socket.setdefaulttimeout(timeout)
if feed.status == 200:
etag, modified = feed.get('etag'), feed.get('modified')
for entry in feed.entries:
summary = BeautifulSoup(entry["summary"], 'html.parser')
summary = list(summary.children)[0]
entradas.new(entry['id'], entry.updated_parsed,
entry['title'], entry['link'], summary)
elif feed.status == 304:
# En el 304 no se manda ETAG ni Modified, así que coge los que comprobamos
# en la petición condicional.
etag, modified = entradas.feed304()
else:
raise RuntimeError(f'Estado: {feed.status}')
# Si es 304, procesamos lo viejo por si tiene que salir el email.
html_desc = html_links = ''
entradas_a_borrar = []
for ts, guid, title, link, summary in entradas.itera_antiguos():
link = f'<a href="{link}">{title}</a>'
html_links += f'<li>{link}</li>\n'
html_desc += (f'<b>{link}</b></br>\n<font size=-2>\n'
f'{summary}\n</font></p>')
entradas_a_borrar.append((guid, ts))
# XXX: Lo suyo sería grabar solo si hay cambios de verdad, pero
# lo cierto es que queremos actualizar el etag y el modified AUNQUE
# no haya habido cambios en el feed. Esto es algo a mejorar en el futuro.
entradas.save(etag, modified)
if html_desc:
no_solapamiento = ''
if not entradas.solapamiento():
no_solapamiento = '<font size=+2><b>HEMOS PERDIDO ENTRADAS</b></font>'
no_solapamiento += '<br/><br/>\n'
html_links = f'<ul>\n{html_links}\n</ul>\n'
html = f'''<html>
<head>
<style>
a {{text-decoration: none;}}
</style>
</head>
<body>
{no_solapamiento}
{html_links}
{html_desc}
</body>
</html>
'''
msg = MIMEText(html, 'html')
fecha = f'{time.strftime("%Y-%m-%d", feed.updated_parsed)}'
msg['Subject'] = f'Feed Slashdot {fecha}'
msg['To'] = 'jcea@jcea.es'
msg['Date'] = email.utils.formatdate()
msg['List-Id'] = 'Notificaciones Slashdot'
msg['Message-Id'] = email.utils.make_msgid(domain='P2Ppriv')
msg = msg.as_bytes()
subprocess.run(['mail', 'jcea@jcea.es'],
input=msg, check=True, timeout=60,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE)
entradas.notificado()
# XXX: Lo suyo sería grabar solo si hay cambios de verdad, pero
# lo cierto es que queremos actualizar el etag y el modified AUNQUE
# no haya habido cambios en el feed. Esto es algo a mejorar en el futuro.
entradas = items()
etag, modified = entradas.feed304() # XXX: Para evitar "no solapamiento"
for guid, ts in entradas_a_borrar:
entradas.borrar(guid, ts)
entradas.save(etag, modified, purga=False)
if __name__ == '__main__':
main()
|