slashdot-ddeb027b3f1c.py (Código fuente)

#!/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()