Add new test input file
[spigot:spigot.git] / spigot.py
1 #! /usr/bin/env python
2 #
3 # spigot is a rate limiter for aggregating syndicated content to pump.io
4 #
5 # (c) 2011-2015 by Nathan D. Smith <nathan@smithfam.info>
6 # (c) 2014 Craig Maloney <craig@decafbad.net>
7 #
8 # This program is free software; you can redistribute it and/or modify
9 # it under the terms of the GNU General Public License as published by
10 # the Free Software Foundation; either version 3 of the License, or
11 # (at your option) any later version.
12 #
13 # This program is distributed in the hope that it will be useful,
14 # but WITHOUT ANY WARRANTY; without even the implied warranty of
15 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
16 # GNU General Public License for more details.
17 #
18 # You should have received a copy of the GNU General Public License
19 # along with this program; if not, see <http://www.gnu.org/licenses/>.
20
21 # Standard library imports
22 import argparse
23 from datetime import datetime, timedelta
24 try:
25     import json
26 except ImportError:
27     import simplejson as json
28 import logging
29 import os
30 import re
31 import sqlite3
32 import sys
33 from time import mktime
34
35 # 3rd-party modules
36 import feedparser
37 from pypump import PyPump
38 from pypump import Client
39
40
41 def simple_verifier(url):
42     print 'Please follow the instructions at the following URL:'
43     print url
44     return raw_input("Verifier: ")
45
46
47 class SpigotConfig(dict):
48     """Extends the built-in dict type to provide a configuration interface for
49     Spigot, keeping track of feeds polled and accounts configured for posting.
50     """
51
52     def __init__(self, path="spigot.json"):
53         self.config_file = path
54         self.no_config = True
55         if os.path.exists(self.config_file):
56             self.no_config = False
57
58     def check_old_config(self):
59         """Check existing configuration for pre-2.2 format and return True
60         if the config needs to be updated."""
61
62         formats = [self["feeds"][feed]["format"] for feed in self["feeds"]]
63         for format in formats:
64             if (("$t" in format) or ("$l" in format)):
65                 logging.debug("Existing config reflects pre-2.2 format")
66                 return True
67             else:
68                 logging.debug("Existing config reflects post-2.2 format")
69                 return False
70
71     def load(self):
72         """Load the spigot json config file from the working directory
73         and import it into the SpigotConfig dict object."""
74
75         logging.debug("Loading %s" % self.config_file)
76         # Start with a clean configuration object
77         self.clear()
78         try:
79             self.update(json.loads(open(self.config_file, "r").read()))
80         except IOError:
81             logging.warning("Could not load configuration file")
82
83     def save(self):
84         "Convert the state of the SpigotConfig dict to json and save."
85
86         logging.debug("Saving %s" % self.config_file)
87         try:
88             open(self.config_file, "w").write(json.dumps(self, indent=4))
89             return True
90         except IOError:
91             logging.exception("Could not save configuration file")
92             sys.exit(2)
93
94     def add_user(self, webfinger=None):
95         "Interactively add a new user to the configuration."
96
97         self.load()
98
99         user = {}
100         print "Adding user"
101         if not webfinger:
102             webfinger = raw_input("Webfinger ID (e.g. bob@identi.ca): ")
103         # Initialize the Oauth relationship
104         client = Client(
105             webfinger=webfinger,
106             name="Spigot",
107             type="native")
108         pump = PyPump(client, verifier_callback=simple_verifier)
109         # Now PyPump will walk the user through registration
110         # With that complete, retrieve relevant keys and secrets
111         credentials = pump.get_registration()
112         tokens = pump.get_token()
113         # Construct the user configuration dict
114         user["consumer_key"] = credentials[0]
115         user["consumer_secret"] = credentials[1]
116         user["oauth_token"] = tokens[0]
117         user["oauth_token_secret"] = tokens[1]
118
119         # Finish constructing the user configuration
120         if "accounts" in self:
121             self["accounts"][webfinger] = user
122         else:
123             users = {}
124             users[webfinger] = user
125             self["accounts"] = users
126         self.save()
127
128     def add_feed(self):
129         "Add a feed, account, interval, and format to the configuration."
130
131         # TODO Add feature to specify to and cc for each feed
132         self.load()
133         if "accounts" not in self:
134             logging.error("No accounts configured.")
135             sys.exit(2)
136         account = None
137         interval = None
138         form = None
139
140         print "Adding feed..."
141         url = raw_input("Feed URL: ")
142         # Test feed for presence, validity
143         test_feed = None
144         try:
145             test_feed = feedparser.parse(url)
146             logging.debug("Successfully parsed feed %s" % url)
147         except:
148             logging.warning("Could not parse feed %s" % url)
149         accounts = self["accounts"].keys()
150         print "Choose an account:"
151         for i in range(len(accounts)):
152             print "%d. %s" % (i, accounts[i])
153
154         valid_account = False
155         while not valid_account:
156             try:
157                 account_raw = int(raw_input("Number: "))
158                 try:
159                     account = accounts[account_raw]
160                     valid_account = True
161                 except:
162                     print "Choice out of range."
163             except:
164                 print "Not a number."
165         valid_interval = False
166         while not valid_interval:
167             try:
168                 raw_inter = raw_input("Minimum time between posts (minutes): ")
169                 interval = int(raw_inter)
170                 valid_interval = True
171             except:
172                 print "Invalid interval specified."
173         print """Spigot formats your outgoing posts based on fields in the feed
174               being scanned. Specify the field name surrounded by the '%'
175               character to have it replaced with the corresponding value for
176               the item (e.g. %title% or %link%)."""
177         if test_feed:
178             print """The following fields are present in an example item in
179                      this feed:"""
180             for field in test_feed["items"][0].keys():
181                 print field
182         form = raw_input("Format: ")
183
184         # Put it all together
185         feed = {}
186         feed["account"] = account
187         feed["interval"] = interval
188         feed["format"] = form
189
190         if "feeds" in self:
191             self["feeds"][url] = feed
192         else:
193             feeds = {}
194             feeds[url] = feed
195             self["feeds"] = feeds
196
197         self.save()
198
199     def get_feeds(self):
200         """Sets instance variable 'feeds' of feeds to check for new posts.
201         Formatted in a tuple in the form of (url, account, interval, format)
202         """
203
204         feeds = self["feeds"]
205         feeds_to_poll = []
206         feeds_num = len(feeds)
207         logging.debug("Found %d feeds in configuration" % feeds_num)
208         for url in feeds.keys():
209             logging.debug("Processing feed %s" % url)
210             account = feeds[url]["account"]
211             logging.debug("  Account: %s" % account)
212             interval = feeds[url]["interval"]
213             logging.debug("  Interval: %s min" % interval)
214             form = feeds[url]["format"]
215             logging.debug("  Format: %s" % form)
216             feeds_to_poll.append((url, account, interval, form))
217             logging.debug("  Added to list of feeds to poll")
218         return feeds_to_poll
219
220
221 class SpigotDB():
222     """Handle database calls for Spigot."""
223
224     def __init__(self, path="spigot.db"):
225         self.path = path
226         self._connect()
227
228     def _connect(self):
229         """Establish the database connection for this instantiation."""
230
231         # Check first for a database file
232         new_db = False
233         if not os.path.exists(self.path):
234             new_db = True
235             logging.debug("Database file %s does not exist" % self.path)
236         det_types = sqlite3.PARSE_DECLTYPES | sqlite3.PARSE_COLNAMES
237         try:
238             self._db = sqlite3.connect(self.path, detect_types=det_types)
239         except:
240             logging.exception("Could not connect to database %s" % self.path)
241             sys.exit(2)
242
243         if new_db:
244                 self._init_db_tables()
245
246     def _init_db_tables(self):
247         """Initialize the database if it is new"""
248
249         curs = self._db.cursor()
250         # Figure out db tables based on tricklepost
251         create_query = """create table items (feed text, link text,
252                           message text, date timestamp, posted timestamp)"""
253         curs.execute(create_query)
254         self._db.commit()
255         logging.debug("Initialized database tables")
256         curs.close()
257
258     def check_old_db(self):
259         """Inspect schema of existing sqlite3 database and returne True if
260         the database needs to be upgraded to the post 2.2 schema."""
261
262         curs = self._db.cursor()
263         curs.execute("PRAGMA table_info(items);")
264         cols = curs.fetchall()
265         curs.close()
266         if "message" not in [col[1] for col in cols]:
267             logging.debug("Existing database reflects pre-2.2 schema")
268             return True
269         else:
270             logging.debug("Existing database reflects post-2.2 schema")
271             return False
272
273     def close(self):
274         """Cleanup after the db is no longer needed."""
275
276         self._db.close()
277         logging.debug("Closed connection to database")
278
279     def check_link(self, item_link):
280         """Returns true if the specified link is already in the database."""
281
282         curs = self._db.cursor()
283         curs.execute("select * from items where link=?", [item_link])
284         items = curs.fetchall()
285         if len(items) > 0:
286             return True
287         else:
288             return False
289         curs.close()
290
291     def add_item(self, feed_url, link, message, date):
292         """Add an item to the database with the given parameters. Return True
293         if successful."""
294
295         curs = self._db.cursor()
296         curs.execute("insert into items(feed, link, message, date) \
297             values (?, ?, ?, ?)", (feed_url, link, message, date))
298         logging.debug("    Added item %s to database" % link)
299         curs.close()
300
301         self._db.commit()
302         return True
303
304     def get_unposted_items(self, feed):
305         "Return a list of items in the database which have yet to be posted."
306
307         curs = self._db.cursor()
308         curs.execute("SELECT feed, link, message FROM items where (posted is NULL AND feed=?) \
309             ORDER BY date ASC", [feed])
310         unposted_items = curs.fetchall()
311         num_items = len(unposted_items)
312         logging.debug("  Found %d unposted items in %s" % (num_items, feed))
313         curs.close()
314         return unposted_items
315
316     def mark_posted(self, item_link, date=None):
317         """Mark the given item posted by setting its posted datetime to now."""
318
319         if not date:
320             date = datetime.utcnow()
321         curs = self._db.cursor()
322         curs.execute("UPDATE items SET posted=? WHERE link=?",
323                      (date, item_link))
324         logging.debug("  Updated posted time of item %s in database"
325                       % item_link)
326         curs.close()
327         self._db.commit()
328
329     def get_latest_post(self, feed):
330         """Return the datetime of the most recent item posted by spigot of the
331         specified feed. If none have been posted, return None"""
332
333         curs = self._db.cursor()
334         curs.execute("SELECT posted FROM items WHERE \
335             (feed=? AND posted is not NULL) ORDER BY posted DESC LIMIT 1",
336                      [feed])
337         result = curs.fetchone()
338         curs.close()
339         if result:
340             logging.debug("  Latest post for feed %s is %s" % (feed,
341                                                                result[0]))
342             return result[0]
343         else:
344             logging.debug("  No items from feed %s have been posted" % feed)
345             return None
346
347
348 class SpigotFeeds():
349     """
350     Handle the polling the specified feeds for new posts. Add new posts to
351     database in preparation for posting to the specified Pump.io accounts.
352     """
353
354     def __init__(self, db, config):
355         self._spigotdb = db
356         self._config = config
357
358     def format_message(self, feed, entry):
359         """Returns an outgoing message for the given entry based on the given
360         feed's configured format."""
361
362         message = self._config["feeds"][feed]["format"]
363         # Store a list of tuples containing format string and value
364         replaces = []
365         field_re = re.compile("%\w+%")
366         fields = field_re.findall(message)
367         for raw_field in fields:
368             # Trim the % character from format
369             field = raw_field[1:-1]
370             if field in entry:
371                 # Make a special exception for the content element, which in
372                 # ATOM can appear multiple times per entry. Assume the element
373                 # with index=0 is the desired value.
374                 if field == "content":
375                     logging.debug("    'content' field in formatting string")
376                     value = entry.content[0].value
377                 else:
378                     value = entry[field]
379             else:
380                 value = ""
381             replaces.append((raw_field, value))
382         # Fill in the message format with actual values
383         for string, val in replaces:
384             message = message.replace(string, val)
385         return message
386
387     def poll_feeds(self):
388         """Check the configured feeds for new posts."""
389
390         feeds_to_poll = self._config.get_feeds()
391         for url, account, interval, form in feeds_to_poll:
392             self.scan_feed(url)
393
394     def scan_feed(self, url):
395         """Poll the given feed and then update the database with new info"""
396
397         logging.debug("Polling feed %s for new items" % url)
398         # Allow for parsing of this feed to fail without raising an exception
399         try:
400             p = feedparser.parse(url)
401         except:
402             logging.error("Unable to parse feed %s" % url)
403             return None
404         # Get a list of items for the feed and compare it to the database
405         num_items = len(p.entries)
406         logging.debug("Found %d items in feed %s" % (num_items, url))
407         new_items = 0
408         for i in range(len(p.entries)):
409             logging.debug("  Processing item %d" % i)
410             title = p.entries[i].title
411             logging.debug("    Title: %s" % title)
412             link = p.entries[i].link
413             logging.debug("    Link: %s" % link)
414             # Check for existence of published_parsed, fail back to updated
415             if 'published_parsed' in p.entries[i]:
416                 date = p.entries[i].published_parsed
417             else:
418                 date = p.entries[i].updated_parsed
419             date_struct = datetime.fromtimestamp(mktime(date))
420             logging.debug("    Date: %s" % datetime.isoformat(date_struct))
421             logging.debug("    Link: %s" % link)
422             # Craft the message based feed format string
423             message = self.format_message(url, p.entries[i])
424             logging.debug("    Message: %s" % message)
425             # Check to see if item has already entered the database
426             if not self._spigotdb.check_link(link):
427                 logging.debug("    Not in database")
428                 self._spigotdb.add_item(url, link, message, date_struct)
429                 new_items += 1
430             else:
431                 logging.debug("    Already in database")
432         logging.debug("Found %d new items in feed %s" % (new_items, url))
433
434     def feed_ok_to_post(self, feed):
435         """Return True if the given feed is OK to post given its configured
436         interval."""
437
438         interval = int(self._config["feeds"][feed]["interval"])
439         delta = timedelta(minutes=interval)
440         posted = self._spigotdb.get_latest_post(feed)
441         if posted:
442             next = posted + delta
443             now = datetime.utcnow()
444             if now >= next:
445                 # post it
446                 logging.debug("  Feed %s is ready for a new post" % feed)
447                 return True
448             else:
449                 logging.debug("  Feed %s has been posted too recently" % feed)
450                 logging.debug("  Next post at %s" % next.isoformat())
451                 return False
452         else:
453             # Nothing has been posted for this feed, so it is OK to post
454             logging.debug("  Feed %s is ready for a new post" % feed)
455             return True
456
457
458 class SpigotPost():
459     """Handle the posting of syndicated content stored in the SpigotDB to the
460     pump.io account.
461     """
462
463     def __init__(self, db, spigot_config, spigot_feed):
464         self._spigotdb = db
465         self._config = spigot_config
466         self._spigotfeed = spigot_feed
467
468     def post_items(self):
469         """Handle the posting of unposted items.
470
471         Iterate over each pollable feed and check to see if it is permissible
472         to post new items based on interval configuration. Loop while it is OK,
473         and terminate the loop when it becomes not OK. Presumably one or none
474         will be posted each time this method runs."""
475
476         for feed, account, interval, form in self._config.get_feeds():
477             if account not in self._config["accounts"]:
478                 logging.error("Account %s not configured, unable to post."
479                               % account)
480                 sys.exit(2)
481             logging.debug("Finding eligible posts in feed %s" % feed)
482             unposted_items = self._spigotdb.get_unposted_items(feed)
483             # Initialize Pump.IO connection here
484             ac = self._config["accounts"][account]
485             client = Client(
486                 webfinger=account,
487                 type="native",
488                 name="Spigot",
489                 key=ac["consumer_key"],
490                 secret=ac["consumer_secret"])
491             pump = PyPump(
492                 client=client,
493                 token=ac["oauth_token"],
494                 secret=ac["oauth_token_secret"],
495                 verifier_callback=simple_verifier)
496
497             while self._spigotfeed.feed_ok_to_post(feed):
498                 try:
499                     item = unposted_items.pop(0)
500                 except:
501                     # Escape the loop if there are no new posts waiting
502                     break
503                 feed = item[0]
504                 link = item[1]
505                 message = item[2]
506
507                 try:
508                     logging.info("  Posting item %s from %s to account %s"
509                                  % (link, feed, account))
510                     new_note = pump.Note(message)
511                     new_note.to = pump.Public
512                     new_note.send()
513                     self._spigotdb.mark_posted(link)
514                 except:
515                     logging.exception("  Unable to post item")
516
517
518 if __name__ == "__main__":
519     spigot_config = SpigotConfig()
520     parser = argparse.ArgumentParser()
521     parser.add_argument("--add-account", "-a", action="store_true")
522     parser.add_argument("--add-feed", "-f", action="store_true")
523     log_levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
524     parser.add_argument("--log-level", "-l", choices=log_levels,
525                         default="WARNING")
526     args = parser.parse_args()
527
528     # Logging configuration
529     logging.basicConfig(level=args.log_level,
530                         format='%(asctime)s %(levelname)s: %(message)s')
531     logging.debug("spigot startup")
532
533     # No configuration present, doing welcom wagon
534     if spigot_config.no_config:
535         print "No configuration file now, running welcome wizard."
536         spigot_config.add_user()
537         spigot_config.add_feed()
538         sys.exit(0)
539     if args.add_account:
540         spigot_config.add_user()
541         sys.exit(0)
542     if args.add_feed:
543         spigot_config.add_feed()
544         sys.exit(0)
545
546     # Normal operation
547     spigot_config.load()
548     # Check for pre-2.2 formatted spigot configuration file
549     if not spigot_config.no_config:
550         old_config = spigot_config.check_old_config()
551         if old_config:
552             logging.error("Config not upgraded for Spigot 2.2")
553             logging.error("Please upgrade the config using the \
554 utils/convert.py script found in the source repository.")
555             sys.exit(2)
556
557     spigot_db = SpigotDB()
558     # Test for pre-2.2 database structure
559     if spigot_db.check_old_db():
560         logging.error("Existing database not upgraded for Spigot 2.2")
561         logging.error("Please upgrade the database using the \
562 utils/convert.py script found in the source repository.")
563         sys.exit(2)
564
565     spigot_feed = SpigotFeeds(spigot_db, spigot_config)
566     spigot_feed.poll_feeds()
567     spigot_post = SpigotPost(spigot_db, spigot_config, spigot_feed)
568     spigot_post.post_items()