* fix blockquote support
[rss2maildir.git] / rss2maildir.py
1 #!/usr/bin/python
2 # coding=utf8
3
4 # rss2maildir.py - RSS feeds to Maildir 1 email per item
5 # Copyright (C) 2007  Brett Parker <iDunno@sommitrealweird.co.uk>
6
7 # This program is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU General Public License as published by
9 # the Free Software Foundation, either version 3 of the License, or
10 # (at your option) any later version.
11
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15 # GNU General Public License for more details.
16
17 # You should have received a copy of the GNU General Public License
18 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
19
20 import mailbox
21 import sys
22 import os
23 import stat
24 import urllib
25
26 import feedparser
27
28 from email.MIMEMultipart import MIMEMultipart
29 from email.MIMEText import MIMEText
30
31 import datetime
32 import random
33 import string
34 import textwrap
35
36 import socket
37
38 from optparse import OptionParser
39 from ConfigParser import SafeConfigParser
40
41 from base64 import b64encode
42 import md5
43
44 import cgi
45 import dbm
46
47 from HTMLParser import HTMLParser
48
49 entities = {
50     "amp": "&",
51     "lt": "<",
52     "gt": ">",
53     "pound": "£",
54     "copy": "©",
55     "apos": "'",
56     "quote": "\"",
57     "nbsp": " ",
58     }
59
60 class HTML2Text(HTMLParser):
61     
62     def __init__(self):
63         self.inheadingone = False
64         self.inheadingtwo = False
65         self.inotherheading = False
66         self.inparagraph = True
67         self.inblockquote = False
68         self.inlink = False
69         self.text = ""
70         self.currentparagraph = ""
71         self.headingtext = ""
72         self.blockquote = ""
73         self.inpre = False
74         HTMLParser.__init__(self)
75
76     def handle_starttag(self, tag, attrs):
77         if tag.lower() == "h1":
78             self.inheadingone = True
79             self.inparagraph = False
80         elif tag.lower() == "h2":
81             self.inheadingtwo = True
82             self.inparagraph = False
83         elif tag.lower() in ["h3", "h4", "h5", "h6"]:
84             self.inotherheading = True
85             self.inparagraph = False
86         elif tag.lower() == "a":
87             self.inlink = True
88         elif tag.lower() == "br":
89             if self.inparagraph:
90                 self.text = self.text + "\n".join(textwrap.wrap(self.currentparagraph, 70)) + "\n"
91                 self.currentparagraph = ""
92             elif self.inblockquote:
93                 self.text = self.text + "\n> " + "\n> ".join([a.strip() for a in textwrap.wrap(self.blockquote, 68)]) + "\n"
94                 self.blockquote = ""
95             else:
96                 self.text = self.text + "\n"
97         elif tag.lower() == "blockquote":
98             self.inblockquote = True
99             self.text = self.text + "\n"
100         elif tag.lower() == "p":
101             if self.text != "":
102                 self.text = self.text + "\n\n"
103             if self.inparagraph:
104                 self.text = self.text + "\n".join(textwrap.wrap(self.currentparagraph, 70))
105             self.currentparagraph = ""
106             self.inparagraph = True
107         elif tag.lower() == "pre":
108             self.text = self.text + "\n"
109             self.inpre = True
110             self.inparagraph = False
111             self.inblockquote = False
112
113     def handle_startendtag(self, tag, attrs):
114         if tag.lower() == "br":
115             if self.inparagraph:
116                 self.text = self.text + "\n".join(textwrap.wrap(self.currentparagraph, 70)) + "\n"
117                 self.currentparagraph = ""
118             elif self.inblockquote:
119                 self.text = self.text + "\n> " + "\n> ".join([a.strip() for a in textwrap.wrap(self.blockquote, 68)]) + "\n"
120                 self.blockquote = ""
121             else:
122                 self.text = self.text + "\n"
123
124     def handle_endtag(self, tag):
125         if tag.lower() == "h1":
126             self.inheadingone = False
127             self.text = self.text + "\n\n" + self.headingtext + "\n" + "=" * len(self.headingtext.strip())
128             self.headingtext = ""
129         elif tag.lower() == "h2":
130             self.inheadingtwo = False
131             self.text = self.text + "\n\n" + self.headingtext + "\n" + "-" * len(self.headingtext.strip())
132             self.headingtext = ""
133         elif tag.lower() in ["h3", "h4", "h5", "h6"]:
134             self.inotherheading = False
135             self.text = self.text + "\n\n" + self.headingtext + "\n" + "~" * len(self.headingtext.strip())
136             self.headingtext = ""
137         elif tag.lower() == "p":
138             self.text = self.text + "\n".join(textwrap.wrap(self.currentparagraph, 70))
139             self.inparagraph = False
140         elif tag.lower() == "blockquote":
141             self.text = self.text + "\n> " + "\n> ".join([a.strip() for a in textwrap.wrap(self.blockquote, 68)]) + "\n"
142             self.inblockquote = False
143             self.blockquote = ""
144         elif tag.lower() == "pre":
145             self.inpre = False
146
147     def handle_data(self, data):
148         if self.inheadingone or self.inheadingtwo or self.inotherheading:
149             self.headingtext = self.headingtext + data.strip() + " "
150         elif self.inblockquote:
151             self.blockquote = self.blockquote + data.strip() + " "
152         elif self.inparagraph:
153             self.currentparagraph = self.currentparagraph + data.strip() + " "
154         elif self.inpre:
155             self.text = self.text + data
156         else:
157             self.text = self.text + data.strip() + " "
158
159     def handle_entityref(self, name):
160         if entities.has_key(name.lower()):
161             self.text = self.text + entities[name.lower()]
162         else:
163             self.text = self.text + "&" + name + ";"
164
165     def gettext(self):
166         data = self.text
167         if self.inparagraph:
168             data = data + "\n".join(textwrap.wrap(self.currentparagraph, 70))
169         return data
170
171 def parse_and_deliver(maildir, url, statedir):
172     md = mailbox.Maildir(maildir)
173     fp = feedparser.parse(url)
174     db = dbm.open(os.path.join(statedir, "seen"), "c")
175     for item in fp["items"]:
176         # have we seen it before?
177         # need to work out what the content is first...
178
179         if item.has_key("content"):
180             content = item["content"][0]["value"]
181         else:
182             content = item["summary"]
183
184         md5sum = md5.md5(content.encode("utf8")).hexdigest()
185
186         if db.has_key(url + "|" + item["link"]):
187             data = db[url + "|" + item["link"]]
188             data = cgi.parse_qs(data)
189             if data["contentmd5"][0] == md5sum:
190                 continue
191
192         try:
193             author = item["author"]
194         except:
195             author = url
196
197         # create a basic email message
198         msg = MIMEMultipart("alternative")
199         messageid = "<" + datetime.datetime.now().strftime("%Y%m%d%H%M") + "." + "".join([random.choice(string.ascii_letters + string.digits) for a in range(0,6)]) + "@" + socket.gethostname() + ">"
200         msg.add_header("Message-ID", messageid)
201         msg.set_unixfrom("\"%s\" <rss2maildir@localhost>" %(url))
202         msg.add_header("From", "\"%s\" <rss2maildir@localhost>" %(author))
203         msg.add_header("To", "\"%s\" <rss2maildir@localhost>" %(url))
204         createddate = datetime.datetime(*item["updated_parsed"][0:6]).strftime("%a, %e %b %Y %T -0000")
205         msg.add_header("Date", createddate)
206         msg.add_header("Subject", item["title"])
207         msg.set_default_type("text/plain")
208
209         htmlpart = MIMEText(content.encode("utf8"), "html", "utf8")
210         textparser = HTML2Text()
211         textparser.feed(content.encode("utf8"))
212         textcontent = textparser.gettext()
213         textpart = MIMEText(textcontent, "plain", "utf8")
214         msg.attach(textpart)
215         msg.attach(htmlpart)
216
217         # start by working out the filename we should be writting to, we do
218         # this following the normal maildir style rules
219         fname = str(os.getpid()) + "." + socket.gethostname() + "." + "".join([random.choice(string.ascii_letters + string.digits) for a in range(0,10)]) + "." + datetime.datetime.now().strftime('%s')
220         fn = os.path.join(maildir, "tmp", fname)
221         fh = open(fn, "w")
222         fh.write(msg.as_string())
223         fh.close()
224         # now move it in to the new directory
225         newfn = os.path.join(maildir, "new", fname)
226         os.link(fn, newfn)
227         os.unlink(fn)
228
229         # now add to the database about the item
230         data = urllib.urlencode((("message-id", messageid), ("created", createddate), ("contentmd5", md5sum)))
231         db[url + "|" + item["link"]] = data
232
233     db.close()
234
235 # first off, parse the command line arguments
236
237 oparser = OptionParser()
238 oparser.add_option(
239     "-c", "--conf", dest="conf",
240     help="location of config file"
241     )
242 oparser.add_option(
243     "-s", "--statedir", dest="statedir",
244     help="location of directory to store state in"
245     )
246
247 (options, args) = oparser.parse_args()
248
249 # check for the configfile
250
251 configfile = None
252
253 if options.conf != None:
254     # does the file exist?
255     try:
256         os.stat(options.conf)
257         configfile = options.conf
258     except:
259         # should exit here as the specified file doesn't exist
260         sys.stderr.write("Config file %s does not exist. Exiting.\n" %(options.conf,))
261         sys.exit(2)
262 else:
263     # check through the default locations
264     try:
265         os.stat("%s/.rss2maildir.conf" %(os.environ["HOME"],))
266         configfile = "%s/.rss2maildir.conf" %(os.environ["HOME"],)
267     except:
268         try:
269             os.stat("/etc/rss2maildir.conf")
270             configfile = "/etc/rss2maildir.conf"
271         except:
272             sys.stderr.write("No config file found. Exiting.\n")
273             sys.exit(2)
274
275 # Right - if we've got this far, we've got a config file, now for the hard
276 # bits...
277
278 scp = SafeConfigParser()
279 scp.read(configfile)
280
281 maildir_root = "RSSMaildir"
282 state_dir = "state"
283
284 if options.statedir != None:
285     state_dir = options.statedir
286     try:
287         mode = os.stat(state_dir)[stat.ST_MODE]
288         if not stat.S_ISDIR(mode):
289             sys.stderr.write("State directory (%s) is not a directory\n" %(state_dir))
290             sys.exit(1)
291     except:
292         # try to make the directory
293         try:
294             os.mkdir(state_dir)
295         except:
296             sys.stderr.write("Couldn't create statedir %s" %(state_dir))
297             sys.exit(1)
298 elif scp.has_option("general", "state_dir"):
299     new_state_dir = scp.get("general", "state_dir")
300     try:
301         mode = os.stat(state_dir)[stat.ST_MODE]
302         if not stat.S_ISDIR(mode):
303             sys.stderr.write("State directory (%s) is not a directory\n" %(state_dir))
304             sys.exit(1)
305     except:
306         # try to create it
307         try:
308             os.mkdir(new_state_dir)
309             state_dir = new_state_dir
310         except:
311             sys.stderr.write("Couldn't create state directory %s\n" %(new_state_dir))
312             sys.exit(1)
313 else:
314     try:
315         mode = os.stat(state_dir)[stat.ST_MODE]
316         if not stat.S_ISDIR(mode):
317             sys.stderr.write("State directory %s is not a directory\n" %(state_dir))
318             sys.exit(1)
319     except:
320         try:
321             os.mkdir(state_dir)
322         except:
323             sys.stderr.write("State directory %s could not be created\n" %(state_dir))
324             sys.exit(1)
325
326 if scp.has_option("general", "maildir_root"):
327     maildir_root = scp.get("general", "maildir_root")
328
329 try:
330     mode = os.stat(maildir_root)[stat.ST_MODE]
331     if not stat.S_ISDIR(mode):
332         sys.stderr.write("Maildir Root %s is not a directory\n" %(maildir_root))
333         sys.exit(1)
334 except:
335     try:
336         os.mkdir(maildir_root)
337     except:
338         sys.stderr.write("Couldn't create Maildir Root %s\n" %(maildir_root))
339         sys.exit(1)
340
341 feeds = scp.sections()
342 try:
343     feeds.remove("general")
344 except:
345     pass
346
347 for section in feeds:
348     # check if the directory exists
349     maildir = None
350     try:
351         maildir = scp.get(section, "maildir")
352     except:
353         maildir = section
354
355     maildir = urllib.urlencode(((section, maildir),)).split("=")[1]
356     maildir = os.path.join(maildir_root, maildir)
357
358     try:
359         exists = os.stat(maildir)
360         if stat.S_ISDIR(exists[stat.ST_MODE]):
361             # check if there's a new, cur and tmp directory
362             try:
363                 mode = os.stat(os.path.join(maildir, "cur"))[stat.ST_MODE]
364             except:
365                 os.mkdir(os.path.join(maildir, "cur"))
366                 if not stat.S_ISDIR(mode):
367                     sys.stderr.write("Broken maildir: %s\n" %(maildir))
368             try:
369                 mode = os.stat(os.path.join(maildir, "tmp"))[stat.ST_MODE]
370             except:
371                 os.mkdir(os.path.join(maildir, "tmp"))
372                 if not stat.S_ISDIR(mode):
373                     sys.stderr.write("Broken maildir: %s\n" %(maildir))
374             try:
375                 mode = os.stat(os.path.join(maildir, "new"))[stat.ST_MODE]
376                 if not stat.S_ISDIR(mode):
377                     sys.stderr.write("Broken maildir: %s\n" %(maildir))
378             except:
379                 os.mkdir(os.path.join(maildir, "new"))
380         else:
381             sys.stderr.write("Broken maildir: %s\n" %(maildir))
382     except:
383         try:
384             os.mkdir(maildir)
385         except:
386             sys.stderr.write("Couldn't create root maildir %s\n" %(maildir))
387             sys.exit(1)
388         try:
389             os.mkdir(os.path.join(maildir, "new"))
390             os.mkdir(os.path.join(maildir, "cur"))
391             os.mkdir(os.path.join(maildir, "tmp"))
392         except:
393             sys.stderr.write("Couldn't create required maildir directories for %s\n" %(section,))
394             sys.exit(1)
395
396     # right - we've got the directories, we've got the section, we know the
397     # url... lets play!
398
399     parse_and_deliver(maildir, section, state_dir)