slashdot-20181015.py (Código fuente)

#!/usr/bin/env python3


# (c) 2018 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._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 solapamiento(self):
        if self.saved:
            return self._solapamiento
        return 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 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

        procesados = self._procesados_nuevos
        if not purga:
            procesados.update(self._procesados)

        with open(path + '.NEW', 'wb') as f:
            data = {'procesados': procesados,
                    'buckets': self._buckets,
                    '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._solapamiento = self.solapamiento()
        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 == 304:
        # print("Sin cambios")
        return

    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)

    no_solapamiento = ''
    if not entradas.solapamiento():
        no_solapamiento = '<font size=+2><b>HEMOS PERDIDO ENTRADAS</b></font>'
        no_solapamiento += '<br/><br/>\n'

    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(feed.get('etag'), feed.get('modified'))

    if html_desc:
        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)

    # 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()
    for guid, ts in entradas_a_borrar:
        entradas.borrar(guid, ts)

    entradas.save(feed.get('etag'), feed.get('modified'), purga=False)


if __name__ == '__main__':
    main()