Merge branch 'opmlimport-feed-name-slugging'
[rss2email.git] / rss2email / feed.py
index 9a1c70974b0dfdf9cbaff647d8ac69de1fdf56cb..a11f6c7bfb2edb5042bd2eae42bf7b107546f342 100644 (file)
@@ -1,8 +1,11 @@
+# -*- coding: utf-8 -*-
 # Copyright (C) 2004-2013 Aaron Swartz
 #                         Brian Lalor
 #                         Dean Jackson
+#                         Dennis Keitzel <github@pinshot.net>
 #                         Erik Hetzner
 #                         Etienne Millon <me@emillon.org>
+#                         J. Lewis Muir <jlmuir@imca-cat.org>
 #                         Joey Hess
 #                         Lindsey Smith <lindsey.smith@gmail.com>
 #                         Marcel Ackermann
 """
 
 import collections as _collections
+from email.mime.message import MIMEMessage as _MIMEMessage
+from email.mime.multipart import MIMEMultipart as _MIMEMultipart
 from email.utils import formataddr as _formataddr
 import hashlib as _hashlib
+import html.parser as _html_parser
 import re as _re
 import socket as _socket
 import time as _time
@@ -51,15 +57,20 @@ from . import error as _error
 from . import util as _util
 
 
-_feedparser.USER_AGENT = 'rss2email/{} +{}'.format(__version__, __url__)
+_USER_AGENT = 'rss2email/{} +{}'.format(__version__, __url__)
+_feedparser.USER_AGENT = _USER_AGENT
 _urllib_request.install_opener(_urllib_request.build_opener())
 _SOCKET_ERRORS = []
-for e in ['error', 'gaierror']:
+for e in ['error', 'herror', 'gaierror']:
     if hasattr(_socket, e):
         _SOCKET_ERRORS.append(getattr(_socket, e))
 del e  # cleanup namespace
 _SOCKET_ERRORS = tuple(_SOCKET_ERRORS)
 
+# drv_libxml2 raises:
+#   TypeError: 'str' does not support the buffer interface
+_feedparser.PREFERRED_XML_PARSERS = []
+
 
 class Feed (object):
     """Utility class for feed manipulation and storage.
@@ -100,7 +111,7 @@ class Feed (object):
     >>> feed.url
     'http://example.com/feed.atom'
 
-    Names can only contain ASCII letters, digits, and '._-'.  Here the
+    Names can only contain letters, digits, and '._-'.  Here the
     invalid space causes an exception:
 
     >>> Feed(name='invalid name')
@@ -108,6 +119,11 @@ class Feed (object):
       ...
     rss2email.error.InvalidFeedName: invalid feed name 'invalid name'
 
+    However, you aren't restricted to ASCII letters:
+
+    >>> Feed(name='Αθήνα')
+    <Feed Αθήνα (None -> )>
+
     You must define a URL:
 
     >>> Feed(name='feed-without-a-url', to='a@b.com').run(send=False)
@@ -120,8 +136,9 @@ class Feed (object):
 
     >>> CONFIG['DEFAULT']['to'] = ''
     >>> test_section = CONFIG.pop('feed.test-feed')
+
     """
-    _name_regexp = _re.compile('^[a-zA-Z0-9._-]+$')
+    _name_regexp = _re.compile('^[\w\d.-]+$')
 
     # saved/loaded from feed.dat using __getstate__/__setstate__.
     _dynamic_attributes = [
@@ -157,9 +174,9 @@ class Feed (object):
 
     # hints for value conversion
     _boolean_attributes = [
+        'digest',
         'force_from',
         'use_publisher_email',
-        'friendly_name',
         'active',
         'date_header',
         'trust_guid',
@@ -181,6 +198,11 @@ class Feed (object):
         'encodings',
         ]
 
+    _function_attributes = [
+        'post_process',
+        'digest_post_process',
+        ]
+
     def __init__(self, name=None, url=None, to=None, config=None):
         self._set_name(name=name)
         self.reset()
@@ -246,10 +268,14 @@ class Feed (object):
             for key in expected:
                 if (key not in keys and
                     key not in self._non_default_configured_attributes):
-                    raise ValueError('missing key: {}'.format(key))
+                    raise _error.InvalidFeedConfig(
+                        setting=key, feed=self,
+                        message='missing configuration key: {}'.format(key))
             for key in keys:
                 if key not in expected:
-                    raise ValueError('extra key: {}'.format(key))
+                    raise _error.InvalidFeedConfig(
+                        setting=key, feed=self,
+                        message='extra configuration key: {}'.format(key))
         data = dict(
             (self._configured_attribute_inverse_translations[k],
              self._get_configured_attribute_value(
@@ -262,8 +288,12 @@ class Feed (object):
         self.__dict__.update(data)
 
     def _get_configured_option_value(self, attribute, value):
-        if value and attribute in self._list_attributes:
+        if value is None:
+            return ''
+        elif attribute in self._list_attributes:
             return ', '.join(value)
+        elif attribute in self._function_attributes:
+            return _util.import_name(value)
         return str(value)
 
     def _get_configured_attribute_value(self, attribute, key, data):
@@ -273,6 +303,10 @@ class Feed (object):
             return data.getint(key)
         elif attribute in self._list_attributes:
             return [x.strip() for x in data[key].split(',')]
+        elif attribute in self._function_attributes:
+            if data[key]:
+                return _util.import_function(data[key])
+            return None
         return data[key]
 
     def reset(self):
@@ -320,7 +354,14 @@ class Feed (object):
             _LOG.debug('processing {}'.format(entry.get('id', 'no-id')))
             processed = self._process_entry(parsed=parsed, entry=entry)
             if processed:
-                yield processed
+                guid,id_,sender,message = processed
+                if self.post_process:
+                    message = self.post_process(
+                        feed=self, parsed=parsed, entry=entry, guid=guid,
+                        message=message)
+                    if not message:
+                        continue
+                yield (guid, id_, sender, message)
 
     def _check_for_errors(self, parsed):
         warned = False
@@ -358,16 +399,10 @@ class Feed (object):
         if isinstance(exc, _socket.timeout):
             _LOG.error('timed out: {}'.format(self))
             warned = True
-        elif isinstance(exc, _SOCKET_ERRORS):
-            reason = exc.args[1]
+        elif isinstance(exc, OSError):
             _LOG.error('{}: {}'.format(exc, self))
             warned = True
-        elif (hasattr(exc, 'reason') and
-              isinstance(exc.reason, _urllib_error.URLError)):
-            if isinstance(exc.reason, _SOCKET_ERRORS):
-                reason = exc.reason.args[1]
-            else:
-                reason = exc.reason
+        elif isinstance(exc, _SOCKET_ERRORS):
             _LOG.error('{}: {}'.format(exc, self))
             warned = True
         elif isinstance(exc, _feedparser.zlib.error):
@@ -381,6 +416,11 @@ class Feed (object):
         elif isinstance(exc, _sax.SAXParseException):
             _LOG.error('sax parsing error: {}: {}'.format(exc, self))
             warned = True
+        elif (parsed.bozo and
+              isinstance(exc, _feedparser.CharacterEncodingOverride)):
+            _LOG.warning(
+                'incorrectly declared encoding: {}: {}'.format(exc, self))
+            warned = True
         elif parsed.bozo or exc:
             if exc is None:
                 exc = "can't process"
@@ -393,6 +433,15 @@ class Feed (object):
             not version):
             raise _error.ProcessingError(parsed=parsed, feed=feed)
 
+    def _html2text(self, html, baseurl='', default=None):
+        self.config.setup_html2text(section=self.section)
+        try:
+            return _html2text.html2text(html=html, baseurl=baseurl)
+        except _html_parser.HTMLParseError as e:
+            if default is not None:
+                return default
+            raise
+
     def _process_entry(self, parsed, entry):
         id_ = self._get_entry_id(entry)
         # If .trust_guid isn't set, we get back hashes of the content.
@@ -410,7 +459,7 @@ class Feed (object):
         extra_headers = _collections.OrderedDict((
                 ('Date', self._get_entry_date(entry)),
                 ('Message-ID', '<{}@dev.null.invalid>'.format(_uuid.uuid4())),
-                ('User-Agent', 'rss2email'),
+                ('User-Agent', _USER_AGENT),
                 ('X-RSS-Feed', self.url),
                 ('X-RSS-ID', id_),
                 ('X-RSS-URL', self._get_entry_link(entry)),
@@ -430,15 +479,21 @@ class Feed (object):
                             self.bonus_header))
 
         content = self._get_entry_content(entry)
-        content = self._process_entry_content(
-            entry=entry, content=content, subject=subject)
+        try:
+            content = self._process_entry_content(
+                entry=entry, content=content, subject=subject)
+        except _error.ProcessingError as e:
+            e.parsed = parsed
+            raise
         message = _email.get_message(
             sender=sender,
             recipient=self.to,
             subject=subject,
             body=content['value'],
             content_type=content['type'].split('/', 1)[1],
-            extra_headers=extra_headers)
+            extra_headers=extra_headers,
+            config=self.config,
+            section=self.section)
         return (guid, id_, sender, message)
 
     def _get_entry_id(self, entry):
@@ -468,12 +523,12 @@ class Feed (object):
         if hasattr(entry, 'title_detail') and entry.title_detail:
             title = entry.title_detail.value
             if 'html' in entry.title_detail.type:
-                title = _html2text.html2text(title)
+                title = self._html2text(title, default=title)
         else:
             content = self._get_entry_content(entry)
             value = content['value']
             if content['type'] in ('text/html', 'application/xhtml+xml'):
-                value = _html2text.html2text(value)
+                value = self._html2text(value, default=value)
             title = value[:70]
         title = title.replace('\n', ' ').strip()
         return title
@@ -505,31 +560,38 @@ class Feed (object):
         ...     '</feed>\\n'
         ...     )
         >>> entry = parsed.entries[0]
-        >>> f.friendly_name = False
+        >>> f.name_format = ''
         >>> f._get_entry_name(parsed, entry)
         ''
-        >>> f.friendly_name = True
+        >>> f.name_format = '{author}'
         >>> f._get_entry_name(parsed, entry)
         'Example author'
+        >>> f.name_format = '{feed-title}: {author}'
+        >>> f._get_entry_name(parsed, entry)
+        ': Example author'
+        >>> f.name_format = '{author} ({feed.name})'
+        >>> f._get_entry_name(parsed, entry)
+        'Example author (test-feed)'
         """
-        if not self.friendly_name:
+        if not self.name_format:
             return ''
-        parts = ['']
+        data = {
+            'feed': self,
+            'feed-title': '<feed title>',
+            'author': '<author>',
+            'publisher': '<publisher>',
+            }
         feed = parsed.feed
-        parts.append(feed.get('title', ''))
+        data['feed-title'] = feed.get('title', '')
         for x in [entry, feed]:
             if 'name' in x.get('author_detail', []):
                 if x.author_detail.name:
-                    if ''.join(parts):
-                        parts.append(': ')
-                    parts.append(x.author_detail.name)
+                    data['author'] = x.author_detail.name
                     break
-        if not ''.join(parts) and self.use_publisher_email:
-            if 'name' in feed.get('publisher_detail', []):
-                if ''.join(parts):
-                    parts.append(': ')
-                parts.append(feed.publisher_detail.name)
-        return _html2text.unescape(''.join(parts))
+        if 'name' in feed.get('publisher_detail', []):
+            data['publisher'] = feed.publisher_detail.name
+        name = self.name_format.format(**data)
+        return _html2text.unescape(name)
 
     def _validate_email(self, email, default=None):
         """Do a basic quality check on email address
@@ -676,16 +738,16 @@ class Feed (object):
             lines.extend([
                     '</head>',
                     '<body>',
-                    '<div id="entry>',
+                    '<div id="entry">',
                     '<h1 class="header"><a href="{}">{}</a></h1>'.format(
                         link, subject),
-                    '<div id="body"><table><tr><td>',
+                    '<div id="body">',
                     ])
             if content['type'] in ('text/html', 'application/xhtml+xml'):
                 lines.append(content['value'].strip())
             else:
                 lines.append(_saxutils.escape(content['value'].strip()))
-            lines.append('</td></tr></table></div>')
+            lines.append('</div>')
             lines.extend([
                     '<div class="footer">'
                     '<p>URL: <a href="{0}">{0}</a></p>'.format(link),
@@ -704,12 +766,7 @@ class Feed (object):
             for elink in getattr(entry, 'links', []):
                 if elink.get('rel', None) == 'via':
                     url = elink['href']
-                    url = url.replace(
-                        'http://www.google.com/reader/public/atom/',
-                        'http://www.google.com/reader/view/')
-                    title = url
-                    if elink.get('title', None):
-                        title = elink['title']
+                    title = elink.get('title', url)
                     lines.append('<p>Via <a href="{}">{}</a></p>'.format(
                             url, title))
             lines.extend([
@@ -723,7 +780,10 @@ class Feed (object):
             return content
         else:  # not self.html_mail
             if content['type'] in ('text/html', 'application/xhtml+xml'):
-                lines = [_html2text.html2text(content['value'])]
+                try:
+                    lines = [self._html2text(content['value'])]
+                except _html_parser.HTMLParseError as e:
+                    raise _error.ProcessingError(parsed=None, feed=self)
             else:
                 lines = [content['value']]
             lines.append('')
@@ -736,12 +796,7 @@ class Feed (object):
             for elink in getattr(entry, 'links', []):
                 if elink.get('rel', None) == 'via':
                     url = elink['href']
-                    url = url.replace(
-                        'http://www.google.com/reader/public/atom/',
-                        'http://www.google.com/reader/view/')
-                    title = url
-                    if elink.get('title', None):
-                        title = elink['title']
+                    title = elink.get('title', url)
                     lines.append('Via: {} {}'.format(title, url))
             content['type'] = 'text/plain'
             content['value'] = '\n'.join(lines)
@@ -771,12 +826,65 @@ class Feed (object):
         if not self.to:
             raise _error.NoToEmailAddress(feed=self)
         parsed = self._fetch()
+
+        if self.digest:
+            digest = self._new_digest()
+            seen = []
+
         for (guid, id_, sender, message) in self._process(parsed):
             _LOG.debug('new message: {}'.format(message['Subject']))
-            if send:
-                self._send(sender=sender, message=message)
+            if self.digest:
+                seen.append((guid, id_))
+                self._append_to_digest(digest=digest, message=message)
+            else:
+                if send:
+                    self._send(sender=sender, message=message)
+                if guid not in self.seen:
+                    self.seen[guid] = {}
+                self.seen[guid]['id'] = id_
+
+        if self.digest and seen:
+            if self.digest_post_process:
+                digest = self.digest_post_process(
+                    feed=self, parsed=parsed, seen=seen, message=digest)
+                if not digest:
+                    return
+            self._send_digest(
+                digest=digest, seen=seen, sender=sender, send=send)
+
+        self.etag = parsed.get('etag', None)
+        self.modified = parsed.get('modified', None)
+
+    def _new_digest(self):
+        digest = _MIMEMultipart('digest')
+        digest['To'] = self.to  # TODO: _Header(), _formataddr((recipient_name, recipient_addr))
+        digest['Subject'] = 'digest for {}'.format(self.name)
+        digest['Message-ID'] = '<{}@dev.null.invalid>'.format(_uuid.uuid4())
+        digest['User-Agent'] = _USER_AGENT
+        digest['X-RSS-Feed'] = self.url
+        return digest
+
+    def _append_to_digest(self, digest, message):
+        part = _MIMEMessage(message)
+        part.add_header('Content-Disposition', 'attachment')
+        digest.attach(part)
+
+    def _send_digest(self, digest, seen, sender, send=True):
+        """Send a digest message
+
+        The date is extracted from the last message in the digest
+        payload.  We assume that this part exists.  If you don't have
+        any messages in the digest, don't call this function.
+        """
+        digest['From'] = sender  # TODO: _Header(), _formataddr()...
+        last_part = digest.get_payload()[-1]
+        last_message = last_part.get_payload()[0]
+        digest['Date'] = last_message['Date']
+
+        _LOG.debug('new digest for {}'.format(self))
+        if send:
+            self._send(sender=sender, message=digest)
+        for (guid, id_) in seen:
             if guid not in self.seen:
                 self.seen[guid] = {}
             self.seen[guid]['id'] = id_
-        self.etag = parsed.get('etag', None)
-        self.modified = parsed.get('modified', None)