4 # Copyright Michael Foord 2005-2009
 
   6 # Python interface to the akismet API
 
   7 # E-mail fuzzyman@voidspace.org.uk
 
   9 # http://www.voidspace.org.uk/python/modules.shtml
 
  12 # Released subject to the BSD License
 
  13 # See http://www.voidspace.org.uk/python/license.shtml
 
  17 A python interface to the `Akismet <http://akismet.com>`_ API.
 
  18 This is a web service for blocking SPAM comments to blogs - or other online 
 
  21 You will need a Wordpress API key, from `wordpress.com <http://wordpress.com>`_.
 
  23 You should pass in the keyword argument 'agent' to the name of your program,
 
  24 when you create an Akismet instance. This sets the ``user-agent`` to a useful
 
  29     Python Interface by Fuzzyman | akismet.py/0.2.0
 
  31 Whatever you pass in, will replace the *Python Interface by Fuzzyman* part.
 
  32 **0.2.0** will change with the version of this interface.
 
  36     from akismet import Akismet
 
  38     api = Akismet(agent='Test Script')
 
  39     # if apikey.txt is in place,
 
  40     # the key will automatically be set
 
  41     # or you can call api.setAPIKey()
 
  44         print "No 'apikey.txt' file."
 
  45     elif not api.verify_key():
 
  46         print "The API key is invalid."
 
  48         # data should be a dictionary of values
 
  49         # They can all be filled in with defaults
 
  50         # from a CGI environment
 
  51         if api.comment_check(comment, data):
 
  52             print 'This comment is spam.'
 
  54             print 'This comment is ham.'
 
  59 from urllib import urlencode
 
  60 from forum import settings
 
  63 if hasattr(socket, 'setdefaulttimeout'):
 
  64     # Set the default timeout on sockets to 5 seconds
 
  65     socket.setdefaulttimeout(5)
 
  76 __author__ = 'Michael Foord <fuzzyman AT voidspace DOT org DOT uk>'
 
  78 __docformat__ = "restructuredtext en"
 
  80 user_agent = "%s | akismet.py/%s"
 
  81 DEFAULTAGENT = 'Python Interface by Fuzzyman/%s'
 
  83 isfile = os.path.isfile
 
  87     from google.appengine.api import urlfetch
 
  92     def _fetch_url(url, data, headers):
 
  93         req = urlfetch.fetch(url=url, payload=data, method=urlfetch.POST, headers=headers)
 
  94         if req.status_code == 200:
 
  96         raise Exception('Could not fetch Akismet URL: %s Response code: %s' % 
 
  97                         (url, req.status_code))
 
  99     def _fetch_url(url, data, headers):
 
 100         req = urllib2.Request(url, data, headers)
 
 101         h = urllib2.urlopen(req)
 
 106 class AkismetError(Exception):
 
 107     """Base class for all akismet exceptions."""
 
 109 class APIKeyError(AkismetError):
 
 110     """Invalid API key."""
 
 112 class Akismet(object):
 
 113     """A class for working with the akismet API"""
 
 115     baseurl = 'rest.akismet.com/1.1/'
 
 117     def __init__(self, key=None, blog_url=None, agent=None):
 
 118         """Automatically calls ``setAPIKey``."""
 
 120             agent = DEFAULTAGENT % __version__
 
 121         self.user_agent = user_agent % (agent, __version__)
 
 122         self.setAPIKey(key, blog_url)
 
 127         Fetch the url to make requests to.
 
 129         This comprises of api key plus the baseurl.
 
 131         return 'http://%s.%s' % (self.key, self.baseurl)
 
 134     def _safeRequest(self, url, data, headers):
 
 136             resp = _fetch_url(url, data, headers)
 
 138             raise AkismetError(str(e))
 
 142     def setAPIKey(self, key=None, blog_url=None):
 
 144         Set the wordpress API key for all transactions.
 
 146         If you don't specify an explicit API ``key`` and ``blog_url`` it will
 
 147         attempt to load them from a file called ``apikey.txt`` in the current
 
 150         This method is *usually* called automatically when you create a new
 
 151         ``Akismet`` instance.
 
 153         if key is None and isfile('apikey.txt'):
 
 154             the_file = [l.strip() for l in open('apikey.txt').readlines()
 
 155                 if l.strip() and not l.strip().startswith('#')]
 
 157                 self.key = the_file[0]
 
 158                 self.blog_url = the_file[1]
 
 160                 raise APIKeyError("Your 'apikey.txt' is invalid.")
 
 163             self.blog_url = blog_url
 
 166     def verify_key(self):
 
 168         This equates to the ``verify-key`` call against the akismet API.
 
 170         It returns ``True`` if the key is valid.
 
 172         The docs state that you *ought* to call this at the start of the
 
 175         It raises ``APIKeyError`` if you have not yet set an API key.
 
 177         If the connection to akismet fails, it allows the normal ``HTTPError``
 
 178         or ``URLError`` to be raised.
 
 179         (*akismet.py* uses `urllib2 <http://docs.python.org/lib/module-urllib2.html>`_)
 
 182             raise APIKeyError("Your have not set an API key.")
 
 183         data = { 'key': self.key, 'blog': self.blog_url }
 
 184         # this function *doesn't* use the key as part of the URL
 
 185         url = 'http://%sverify-key' % self.baseurl
 
 186         # we *don't* trap the error here
 
 187         # so if akismet is down it will raise an HTTPError or URLError
 
 188         headers = {'User-Agent' : self.user_agent}
 
 189         resp = self._safeRequest(url, urlencode(data), headers)
 
 190         if resp.lower() == 'valid':
 
 195     def _build_data(self, comment, data):
 
 197         This function builds the data structure required by ``comment_check``,
 
 198         ``submit_spam``, and ``submit_ham``.
 
 200         It modifies the ``data`` dictionary you give it in place. (and so
 
 201         doesn't return anything)
 
 203         It raises an ``AkismetError`` if the user IP or user-agent can't be
 
 206         data['comment_content'] = comment
 
 207         if not 'user_ip' in data:
 
 209                 val = os.environ['REMOTE_ADDR']
 
 211                 raise AkismetError("No 'user_ip' supplied")
 
 212             data['user_ip'] = val
 
 213         if not 'user_agent' in data:
 
 215                 val = os.environ['HTTP_USER_AGENT']
 
 217                 raise AkismetError("No 'user_agent' supplied")
 
 218             data['user_agent'] = val
 
 220         data.setdefault('referrer', os.environ.get('HTTP_REFERER', 'unknown'))
 
 221         data.setdefault('permalink', '')
 
 222         data.setdefault('comment_type', 'comment')
 
 223         data.setdefault('comment_author', '')
 
 224         data.setdefault('comment_author_email', '')
 
 225         data.setdefault('comment_author_url', '')
 
 226         data.setdefault('SERVER_ADDR', os.environ.get('SERVER_ADDR', ''))
 
 227         data.setdefault('SERVER_ADMIN', os.environ.get('SERVER_ADMIN', ''))
 
 228         data.setdefault('SERVER_NAME', os.environ.get('SERVER_NAME', ''))
 
 229         data.setdefault('SERVER_PORT', os.environ.get('SERVER_PORT', ''))
 
 230         data.setdefault('SERVER_SIGNATURE', os.environ.get('SERVER_SIGNATURE',
 
 232         data.setdefault('SERVER_SOFTWARE', os.environ.get('SERVER_SOFTWARE',
 
 234         data.setdefault('HTTP_ACCEPT', os.environ.get('HTTP_ACCEPT', ''))
 
 235         data.setdefault('blog', self.blog_url)
 
 238     def comment_check(self, comment, data=None, build_data=True, DEBUG=False):
 
 240         This is the function that checks comments.
 
 242         It returns ``True`` for spam and ``False`` for ham.
 
 244         If you set ``DEBUG=True`` then it will return the text of the response,
 
 245         instead of the ``True`` or ``False`` object.
 
 247         It raises ``APIKeyError`` if you have not yet set an API key.
 
 249         If the connection to Akismet fails then the ``HTTPError`` or
 
 250         ``URLError`` will be propogated.
 
 252         As a minimum it requires the body of the comment. This is the
 
 253         ``comment`` argument.
 
 255         Akismet requires some other arguments, and allows some optional ones.
 
 256         The more information you give it, the more likely it is to be able to
 
 257         make an accurate diagnosise.
 
 259         You supply these values using a mapping object (dictionary) as the
 
 262         If ``build_data`` is ``True`` (the default), then *akismet.py* will
 
 263         attempt to fill in as much information as possible, using default
 
 264         values where necessary. This is particularly useful for programs
 
 265         running in a {acro;CGI} environment. A lot of useful information
 
 266         can be supplied from evironment variables (``os.environ``). See below.
 
 268         You *only* need supply values for which you don't want defaults filled
 
 269         in for. All values must be strings.
 
 271         There are a few required values. If they are not supplied, and
 
 272         defaults can't be worked out, then an ``AkismetError`` is raised.
 
 274         If you set ``build_data=False`` and a required value is missing an
 
 275         ``AkismetError`` will also be raised.
 
 277         The normal values (and defaults) are as follows : ::
 
 279             'user_ip':          os.environ['REMOTE_ADDR']       (*)
 
 280             'user_agent':       os.environ['HTTP_USER_AGENT']   (*)
 
 281             'referrer':         os.environ.get('HTTP_REFERER', 'unknown') [#]_
 
 283             'comment_type':     'comment' [#]_
 
 285             'comment_author_email': ''
 
 286             'comment_author_url': ''
 
 287             'SERVER_ADDR':      os.environ.get('SERVER_ADDR', '')
 
 288             'SERVER_ADMIN':     os.environ.get('SERVER_ADMIN', '')
 
 289             'SERVER_NAME':      os.environ.get('SERVER_NAME', '')
 
 290             'SERVER_PORT':      os.environ.get('SERVER_PORT', '')
 
 291             'SERVER_SIGNATURE': os.environ.get('SERVER_SIGNATURE', '')
 
 292             'SERVER_SOFTWARE':  os.environ.get('SERVER_SOFTWARE', '')
 
 293             'HTTP_ACCEPT':      os.environ.get('HTTP_ACCEPT', '')
 
 297         You may supply as many additional 'HTTP_*' type values as you wish.
 
 298         These should correspond to the http headers sent with the request.
 
 300         .. [#] Note the spelling "referrer". This is a required value by the
 
 301             akismet api - however, referrer information is not always
 
 302             supplied by the browser or server. In fact the HTTP protocol
 
 303             forbids relying on referrer information for functionality in 
 
 305         .. [#] The `API docs <http://akismet.com/development/api/>`_ state that this value
 
 306             can be " *blank, comment, trackback, pingback, or a made up value*
 
 307             *like 'registration'* ".
 
 310             raise APIKeyError("Your have not set an API key.")
 
 314             self._build_data(comment, data)
 
 315         if 'blog' not in data:
 
 316             data['blog'] = self.blog_url
 
 317         url = '%scomment-check' % self._getURL()
 
 318         # we *don't* trap the error here
 
 319         # so if akismet is down it will raise an HTTPError or URLError
 
 320         headers = {'User-Agent' : self.user_agent}
 
 321         resp = self._safeRequest(url, urlencode(data), headers)
 
 327         elif resp == 'false':
 
 330             # NOTE: Happens when you get a 'howdy wilbur' response !
 
 331             raise AkismetError('missing required argument.')
 
 334     def submit_spam(self, comment, data=None, build_data=True):
 
 336         This function is used to tell akismet that a comment it marked as ham,
 
 339         It takes all the same arguments as ``comment_check``, except for
 
 343             raise APIKeyError("Your have not set an API key.")
 
 347             self._build_data(comment, data)
 
 348         url = '%ssubmit-spam' % self._getURL()
 
 349         # we *don't* trap the error here
 
 350         # so if akismet is down it will raise an HTTPError or URLError
 
 351         headers = {'User-Agent' : self.user_agent}
 
 352         self._safeRequest(url, urlencode(data), headers)
 
 355     def submit_ham(self, comment, data=None, build_data=True):
 
 357         This function is used to tell akismet that a comment it marked as spam,
 
 360         It takes all the same arguments as ``comment_check``, except for
 
 364             raise APIKeyError("Your have not set an API key.")
 
 368             self._build_data(comment, data)
 
 369         url = '%ssubmit-ham' % self._getURL()
 
 370         # we *don't* trap the error here
 
 371         # so if akismet is down it will raise an HTTPError or URLError
 
 372         headers = {'User-Agent' : self.user_agent}
 
 373         self._safeRequest(url, urlencode(data), headers)