Парсинг RSS/Atom лент на Python или сам себе RSS2Email

27 апреля 2016

Хочу поделиться с вами небольшим скриптом. Как правило, такого рода поделкам на Python я не посвящаю целые посты, а просто дописываю их в список примеров к заметке Как я выбирал скриптовый язык и остановился на Python (кстати, да, там есть обновления). Но этот конкретный скрипт показался мне достаточно интересным, чтобы рассказать о нем отдельно. Как вы уже поняли, он парсит RSS- и Atom-ленты, а затем отправляет информацию о новых записях на указанный e-mail адрес.

Потребность в таком скрипте возникла после того, как я решил отписаться от большинства неинтересных мне фидов, а оставшиеся пару десятков читать через e-mail. Разумеется, для отправки новых записей из RSS на электронную почту уже существует множество готовых сервисов (например, Blogtrottr). Но новые записи они часто высылают с задержкой и/или с прикрепленной рекламой и/или за деньги, да и вообще завязываться на очередной SaaS из-за такой ерунды не хотелось. Вы ведь еще помните историю с Google Reader, верно? Так вот, в итоге получилось действительно удобнее, чем использовать отдельное приложение для чтения RSS (я пользовался Liferea, а до этого — Feedly). Как минимум, теперь у меня меньше нужных мне приложений, а значит в iPhone’е меньше иконок, а в Unity появился лишний свободный хоткей. Да и просто удобно, когда любые интересующие тебя события приходят в одно-единственное место.

Итак, писать, само собой разумеется, будем не все с нуля, а возьмем готовый модуль feedparser (модуль на PyPI, документация):

sudo pip3 install feedparser

А вот и сам скрипт (репозиторий на GitHub):

#!/usr/bin/env python3

# feed2email.py
# (c) Aleksander Alekseev 2016
# http://eax.me/

import feedparser

from smtplib import SMTP
# from smtplib import SMTP_SSL as SMTP
from email.mime.text import MIMEText
from contextlib import contextmanager
import signal
import getpass
import hashlib
import time
import sys
import re

server = 'smtp.yandex.ru'
port = 587 # 25
login = "YOUR_LOGIN"
from_addr = "NEWS <YOUR_LOGIN@yandex.ru>"
receiver = "YOUR_EMAIL"
processed_urls_fname = "processed-urls.txt"
feed_list_fname = "feed-list.txt"
# change to True before first run or you will receive A LOT of emails
# then change back to False
fake_send = False
sleep_time = 60*5 # seconds
net_timeout = 10 # seconds
smtp_retry_time = 30 # seconds
smtp_retries_num = 5

# >>> import hashlib
# >>> hashlib.sha1(b"qwerty").hexdigest()
# 'b1b3773a05c0ed0176787a4f1574ff0075f7521e'
pwhash = 'b1b3773a05c0ed0176787a4f1574ff0075f7521e'

# FUNCS

class TimeoutException(Exception): pass

@contextmanager
def timeout_sec(seconds):
  def signal_handler(signum, frame):
    raise TimeoutException(Exception("Timed out!"))
  signal.signal(signal.SIGALRM, signal_handler)
  signal.alarm(seconds)
  try:
    yield
  finally:
    signal.alarm(0)

def file_to_list(fname):
  rslt = []
  with open(fname, "r") as f:
    rslt = [x for x in f.read().split("\n") if x.strip() != "" ]
  return rslt

# MAIN

password = getpass.getpass("SMTP Password: ")

if hashlib.sha1(bytearray(password, 'utf-8')).hexdigest() != pwhash:
  print("Invalid password", file = sys.stderr)
  sys.exit(1)

while True:
  feed_list = file_to_list(feed_list_fname)
  # filter comments
  feed_list = [ x for x in feed_list if not re.match("(?i)\s*#", x) ]
  keep_urls = 100*len(feed_list)
  processed_urls = []

  try:
    processed_urls = file_to_list(processed_urls_fname)
  except FileNotFoundError:
    pass

  print("Processing {} feeds...".format(len(feed_list)))

  for feed in feed_list:
    print(feed)
    f = None
    try:
      with timeout_sec(net_timeout):
        f = feedparser.parse(feed)
    except TimeoutException:
      print("ERROR: Timeout!")
      continue

    feed_title = f['feed'].get('title', '(NO TITLE)')
    feed_link = f['feed'].get('link', '(NO LINK)')

    for entry in f['entries']:
      if entry['link'] in processed_urls:
        continue

      subject = "{title} | {feed_title} ({feed_link})".format(
          title = entry.get('title', '(NO TITLE'),
          feed_title = feed_title,
          feed_link = feed_link
        )
      print(subject)
      summary = entry.get('summary', '(NO SUMMARY)')
      body = "{summary}\n\n{link}\n\nSource feed: {feed}".format(
          summary = summary[:256],
          link = entry['link'],
          feed = feed
        )
      print(body)
      print("-------")

      msg = MIMEText(body, 'plain')
      msg['Subject'] = subject
      msg['From'] = from_addr
      msg['To'] = receiver

      if not fake_send:
        for attempt in range(1, smtp_retries_num+1):
          try:
            with timeout_sec(net_timeout), SMTP(server, port) as conn:
              conn.starttls()
              conn.login(login, password)
              conn.sendmail(from_addr, [receiver], msg.as_string())
            break
          except Exception as exc:
            print(("Failed to send email {}/{} - {}, " +
                  "retrying in {} seconds").format(
                    attempt, smtp_retries_num, exc,
                    smtp_retry_time
                  )
            )
            time.sleep(smtp_retry_time)

      processed_urls = [ entry['link'] ] + processed_urls

  with open(processed_urls_fname, "w") as urls_file:
    urls_file.write("\n".join(processed_urls[:keep_urls]))

  print("Sleeping {} seconds...".format(sleep_time))
  time.sleep(sleep_time)

Список фидов можно получить из OPML-файла при помощи примерно такого однострочника:

cat feeds.opml | python3 -c 'import re,sys; [ print(m.group(1)) for '\
'ln in sys.stdin for m in [ re.search("(?i)xmlUrl=\"(.*?)\"", ln) ] '\
'if m is not None ]'

Теперь правим немного настройки, запускаем на каком-нибудь сервере в screen и радуемся. Все равно у любого уважающего себя программиста в наше время уже есть сервер в DigitalOcean под VPN, Syncthing, закрытые Git-репозитории и другие подобного рода вещи, верно? Нечего ему простаивать без дела.

Пара интересных свойств приведенного скрипта:

  • Если в несколько фидов приходит новость с одним URL (такое бывает, если подписаться на агрегатор фидов и некоторые блоги из этого агрегатора), вы получите уведомление о новости только один раз;
  • На время отпуска скрипт можно остановить, а вернувшись из отпуска один раз прогнать его с fake_send = True — и не придется разгребать все пропущенные новости;
  • Все данные хранятся в текстовых файлах, которые так удобно редактировать Vim’ом и бэкапить Git’ом, притом в списке фидов можно использовать комментарии;

А чем вы в это время суток читаете RSS?

Дополнение: Если вас заинтересовала тема отправки писем по SMTP, обратите также внимание на пост Пример сбора почты по протоколу IMAP на Python.

Метки: .


Вы можете прислать свой комментарий мне на почту, или воспользоваться комментариями в Telegram-группе.