* blockquote support - indents a blockquote with a "> "
[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         HTMLParser.__init__(self)
74
75     def handle_starttag(self, tag, attrs):
76         if tag.lower() == "h1":
77             self.inheadingone = True
78             self.inparagraph = False
79         elif tag.lower() == "h2":
80             self.inheadingtwo = True
81             self.inparagraph = False
82         elif tag.lower() in ["h3", "h4", "h5", "h6"]:
83             self.inotherheading = True
84             self.inparagraph = False
85         elif tag.lower() == "a":
86             self.inlink = True
87         elif tag.lower() == "br":
88             if self.inparagraph:
89                 self.text = self.text + "\n".join(textwrap.wrap(self.currentparagraph, 70))
90                 self.currentparagraph = ""
91             elif self.inblockquote:
92                 self.text = self.text + "\n> " + "\n> ".join([a.strip() for a in textwrap.wrap(self.blockquote, 68)])
93                 self.blockquote = ""
94             else:
95                 self.text = self.text + "\n"
96         elif tag.lower() == "blockquote":
97             self.inblockquote = True
98             self.text = self.text + "\n"
99         elif tag.lower() == "p":
100             if self.text != "":
101                 self.text = self.text + "\n\n"
102             self.currentparagraph = ""
103             self.inparagraph = True
104
105     def handle_startendtag(self, tag, attrs):
106         if tag.lower() == "br":
107             if self.inparagraph:
108                 self.text = self.text + "\n".join(textwrap.wrap(self.currentparagraph, 70))
109                 self.currentparagraph = ""
110             elif self.inblockquote:
111                 self.text = self.text + "\n> " + "\n> ".join([a.strip() for a in textwrap.wrap(self.blockquote, 68)])
112                 self.blockquote = ""
113             else:
114                 self.text = self.text + "\n"
115
116     def handle_endtag(self, tag):
117         if tag.lower() == "h1":
118             self.inheadingone = False
119             self.text = self.text + self.headingtext + "\n" + "=" * len(self.headingtext)
120             self.headingtext = ""
121         elif tag.lower() == "h2":
122             self.inheadingtwo = False
123             self.text = self.text + self.headingtext + "\n" + "-" * len(self.headingtext)
124             self.headingtext = ""
125         elif tag.lower() in ["h3", "h4", "h5", "h6"]:
126             self.inotherheading = False
127             self.text = self.text + self.headingtext + "\n" + "~" * len(self.headingtext)
128             self.headingtext = ""
129         elif tag.lower() == "p":
130             self.text = self.text + "\n".join(textwrap.wrap(self.currentparagraph, 70))
131             self.inparagraph = False
132         elif tag.lower() == "blockquote":
133             self.text = self.text + "\n> " + "\n> ".join([a.strip() for a in textwrap.wrap(self.blockquote, 68)])
134             self.inblockquote = False
135             self.blockquote = ""
136
137     def handle_data(self, data):
138         if self.inheadingone or self.inheadingtwo or self.inotherheading:
139             self.headingtext = self.headingtext + data.strip() + " "
140         elif self.inblockquote:
141             self.blockquote = self.blockquote + data.strip() + " "
142         elif self.inparagraph:
143             self.currentparagraph = self.currentparagraph + data.strip() + " "
144         else:
145             self.text = self.text + data.strip() + " "
146
147     def handle_entityref(self, name):
148         if entities.has_key(name.lower()):
149             self.text = self.text + entities[name.lower()]
150         else:
151             self.text = self.text + "&" + name + ";"
152
153     def gettext(self):
154         data = self.text
155         if self.inparagraph:
156             data = data + "\n".join(textwrap.wrap(self.currentparagraph, 70))
157         return data
158
159 def parse_and_deliver(maildir, url, statedir):
160     md = mailbox.Maildir(maildir)
161     fp = feedparser.parse(url)
162     db = dbm.open(os.path.join(statedir, "seen"), "c")
163     for item in fp["items"]:
164         # have we seen it before?
165         # need to work out what the content is first...
166
167         if item.has_key("content"):
168             content = item["content"][0]["value"]
169         else:
170             content = item["summary"]
171
172         md5sum = md5.md5(content.encode("utf8")).hexdigest()
173
174         if db.has_key(url + "|" + item["link"]):
175             data = db[url + "|" + item["link"]]
176             data = cgi.parse_qs(data)
177             if data["contentmd5"][0] == md5sum:
178                 continue
179
180         try:
181             author = item["author"]
182         except:
183             author = url
184
185         # create a basic email message
186         msg = MIMEMultipart("alternative")
187         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() + ">"
188         msg.add_header("Message-ID", messageid)
189         msg.set_unixfrom("\"%s\" <rss2maildir@localhost>" %(url))
190         msg.add_header("From", "\"%s\" <rss2maildir@localhost>" %(author))
191         msg.add_header("To", "\"%s\" <rss2maildir@localhost>" %(url))
192         createddate = datetime.datetime(*item["updated_parsed"][0:6]).strftime("%a, %e %b %Y %T -0000")
193         msg.add_header("Date", createddate)
194         msg.add_header("Subject", item["title"])
195         msg.set_default_type("text/plain")
196
197         htmlpart = MIMEText(content.encode("utf8"), "html", "utf8")
198         textparser = HTML2Text()
199         textparser.feed(content.encode("utf8"))
200         textcontent = textparser.gettext()
201         textpart = MIMEText(textcontent, "plain", "utf8")
202         msg.attach(textpart)
203         msg.attach(htmlpart)
204
205         # start by working out the filename we should be writting to, we do
206         # this following the normal maildir style rules
207         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')
208         fn = os.path.join(maildir, "tmp", fname)
209         fh = open(fn, "w")
210         fh.write(msg.as_string())
211         fh.close()
212         # now move it in to the new directory
213         newfn = os.path.join(maildir, "new", fname)
214         os.link(fn, newfn)
215         os.unlink(fn)
216
217         # now add to the database about the item
218         data = urllib.urlencode((("message-id", messageid), ("created", createddate), ("contentmd5", md5sum)))
219         db[url + "|" + item["link"]] = data
220
221     db.close()
222
223 # first off, parse the command line arguments
224
225 oparser = OptionParser()
226 oparser.add_option(
227     "-c", "--conf", dest="conf",
228     help="location of config file"
229     )
230 oparser.add_option(
231     "-s", "--statedir", dest="statedir",
232     help="location of directory to store state in"
233     )
234
235 (options, args) = oparser.parse_args()
236
237 # check for the configfile
238
239 configfile = None
240
241 if options.conf != None:
242     # does the file exist?
243     try:
244         os.stat(options.conf)
245         configfile = options.conf
246     except:
247         # should exit here as the specified file doesn't exist
248         sys.stderr.write("Config file %s does not exist. Exiting.\n" %(options.conf,))
249         sys.exit(2)
250 else:
251     # check through the default locations
252     try:
253         os.stat("%s/.rss2maildir.conf" %(os.environ["HOME"],))
254         configfile = "%s/.rss2maildir.conf" %(os.environ["HOME"],)
255     except:
256         try:
257             os.stat("/etc/rss2maildir.conf")
258             configfile = "/etc/rss2maildir.conf"
259         except:
260             sys.stderr.write("No config file found. Exiting.\n")
261             sys.exit(2)
262
263 # Right - if we've got this far, we've got a config file, now for the hard
264 # bits...
265
266 scp = SafeConfigParser()
267 scp.read(configfile)
268
269 maildir_root = "RSSMaildir"
270 state_dir = "state"
271
272 if options.statedir != None:
273     state_dir = options.statedir
274     try:
275         mode = os.stat(state_dir)[stat.ST_MODE]
276         if not stat.S_ISDIR(mode):
277             sys.stderr.write("State directory (%s) is not a directory\n" %(state_dir))
278             sys.exit(1)
279     except:
280         # try to make the directory
281         try:
282             os.mkdir(state_dir)
283         except:
284             sys.stderr.write("Couldn't create statedir %s" %(state_dir))
285             sys.exit(1)
286 elif scp.has_option("general", "state_dir"):
287     new_state_dir = scp.get("general", "state_dir")
288     try:
289         mode = os.stat(state_dir)[stat.ST_MODE]
290         if not stat.S_ISDIR(mode):
291             sys.stderr.write("State directory (%s) is not a directory\n" %(state_dir))
292             sys.exit(1)
293     except:
294         # try to create it
295         try:
296             os.mkdir(new_state_dir)
297             state_dir = new_state_dir
298         except:
299             sys.stderr.write("Couldn't create state directory %s\n" %(new_state_dir))
300             sys.exit(1)
301 else:
302     try:
303         mode = os.stat(state_dir)[stat.ST_MODE]
304         if not stat.S_ISDIR(mode):
305             sys.stderr.write("State directory %s is not a directory\n" %(state_dir))
306             sys.exit(1)
307     except:
308         try:
309             os.mkdir(state_dir)
310         except:
311             sys.stderr.write("State directory %s could not be created\n" %(state_dir))
312             sys.exit(1)
313
314 if scp.has_option("general", "maildir_root"):
315     maildir_root = scp.get("general", "maildir_root")
316
317 try:
318     mode = os.stat(maildir_root)[stat.ST_MODE]
319     if not stat.S_ISDIR(mode):
320         sys.stderr.write("Maildir Root %s is not a directory\n" %(maildir_root))
321         sys.exit(1)
322 except:
323     try:
324         os.mkdir(maildir_root)
325     except:
326         sys.stderr.write("Couldn't create Maildir Root %s\n" %(maildir_root))
327         sys.exit(1)
328
329 feeds = scp.sections()
330 try:
331     feeds.remove("general")
332 except:
333     pass
334
335 for section in feeds:
336     # check if the directory exists
337     maildir = None
338     try:
339         maildir = scp.get(section, "maildir")
340     except:
341         maildir = section
342
343     maildir = urllib.urlencode(((section, maildir),)).split("=")[1]
344     maildir = os.path.join(maildir_root, maildir)
345
346     try:
347         exists = os.stat(maildir)
348         if stat.S_ISDIR(exists[stat.ST_MODE]):
349             # check if there's a new, cur and tmp directory
350             try:
351                 mode = os.stat(os.path.join(maildir, "cur"))[stat.ST_MODE]
352             except:
353                 os.mkdir(os.path.join(maildir, "cur"))
354                 if not stat.S_ISDIR(mode):
355                     sys.stderr.write("Broken maildir: %s\n" %(maildir))
356             try:
357                 mode = os.stat(os.path.join(maildir, "tmp"))[stat.ST_MODE]
358             except:
359                 os.mkdir(os.path.join(maildir, "tmp"))
360                 if not stat.S_ISDIR(mode):
361                     sys.stderr.write("Broken maildir: %s\n" %(maildir))
362             try:
363                 mode = os.stat(os.path.join(maildir, "new"))[stat.ST_MODE]
364                 if not stat.S_ISDIR(mode):
365                     sys.stderr.write("Broken maildir: %s\n" %(maildir))
366             except:
367                 os.mkdir(os.path.join(maildir, "new"))
368         else:
369             sys.stderr.write("Broken maildir: %s\n" %(maildir))
370     except:
371         try:
372             os.mkdir(maildir)
373         except:
374             sys.stderr.write("Couldn't create root maildir %s\n" %(maildir))
375             sys.exit(1)
376         try:
377             os.mkdir(os.path.join(maildir, "new"))
378             os.mkdir(os.path.join(maildir, "cur"))
379             os.mkdir(os.path.join(maildir, "tmp"))
380         except:
381             sys.stderr.write("Couldn't create required maildir directories for %s\n" %(section,))
382             sys.exit(1)
383
384     # right - we've got the directories, we've got the section, we know the
385     # url... lets play!
386
387     parse_and_deliver(maildir, section, state_dir)