Edgewall Software

Ticket #4034: spam-filter-captcha-fallback.diff

File spam-filter-captcha-fallback.diff, 24.9 KB (added by athomas, 3 years ago)

Captcha plugin integrated into SpamFilter

  • tracspamfilter/api.py

     
    2222import textwrap 
    2323import time 
    2424 
    25 from trac.config import BoolOption, IntOption 
     25from trac.config import BoolOption, IntOption, ExtensionOption 
    2626from trac.core import * 
    2727from trac.db import DatabaseManager 
    2828from trac.env import IEnvironmentSetupParticipant 
     
    6161        """ 
    6262 
    6363 
     64class IRejectHandler(Interface): 
     65    """Handle content rejection.""" 
     66 
     67    def reject_content(req, reason): 
     68        """Reject content. `reason` is a human readable message describing why 
     69        the content was rejected. """ 
     70 
     71 
    6472class FilterSystem(Component): 
    6573    strategies = ExtensionPoint(IFilterStrategy) 
    6674 
    67     implements(IEnvironmentSetupParticipant, IPermissionRequestor) 
     75    implements(IEnvironmentSetupParticipant, IPermissionRequestor, 
     76               IRejectHandler) 
    6877 
    6978    min_karma = IntOption('spam-filter', 'min_karma', '0', 
    7079        """The minimum score required for a submission to be allowed.""") 
     
    8190        """Whether content submissions by authenticated users should be trusted 
    8291        without checking for potential spam or other abuse.""") 
    8392 
     93    reject_handler = ExtensionOption('spam-filter', 'reject_handler', 
     94                                     IRejectHandler, 'FilterSystem', 
     95        """The handler used to reject content.""") 
     96 
     97    # IRejectHandler methods 
     98    def reject_content(self, req, message): 
     99        raise RejectContent(message) 
     100 
    84101    # Public methods 
    85102 
    86103    def test(self, req, author, changes): 
     
    140157            msg = ', '.join([r[2] for r in reasons if r[1] < 0]) 
    141158            if msg: 
    142159                msg = ' (%s)' % msg 
    143             raise RejectContent('Submission rejected as potential spam%s' % msg) 
     160            self.reject_handler.reject_content(req, 'Submission rejected as ' 
     161                                               'potential spam %s' % msg) 
    144162 
    145163    def train(self, req, log_id, spam=True): 
    146164        environ = {} 
  • tracspamfilter/captcha/api.py

     
     1import time 
     2from pickle import loads, dumps 
     3from trac.core import * 
     4from trac.config import * 
     5from trac.web.chrome import ITemplateProvider 
     6from trac.web.api import IRequestFilter, IRequestHandler 
     7from tracspamfilter.api import IRejectHandler 
     8 
     9 
     10class ICaptchaMethod(Interface): 
     11    """ A captcha implementation. """ 
     12    def generate_captcha(req): 
     13        """ Return a tuple of `(result, html)`, where `result` is the expected 
     14        response and `html` is a HTML fragment for displaying the captcha 
     15        challenge. """ 
     16 
     17 
     18class CaptchaVerification(Component): 
     19    implements(ITemplateProvider, IRequestHandler, IRejectHandler) 
     20 
     21    request_handlers = ExtensionPoint(IRequestHandler) 
     22    captcha = ExtensionOption('spam-filter', 'captcha', ICaptchaMethod, 
     23                              'ExpressionCaptcha', 
     24        """ Captcha method to use for verifying humans. """) 
     25 
     26    # IRejectHandler methods 
     27    def reject_content(self, req, message): 
     28        if not int(req.session.get('captcha_verified', 0)): 
     29            req.session['reject_reason'] = message 
     30            req.session['captcha_redirect'] = req.href(req.path_info) 
     31            req.redirect(req.href.captcha()) 
     32 
     33    # IRequestHandler methods 
     34    def match_request(self, req): 
     35        return req.path_info == '/captcha' 
     36     
     37    def process_request(self, req): 
     38        if req.method == 'POST': 
     39            self.env.log.debug('Captcha response: %s (expected %s)' %  
     40                (req.args['captcha_response'], req.session['captcha_expected'])) 
     41            if req.args['captcha_response'] == req.session['captcha_expected']: 
     42                redirect = req.session.get('captcha_redirect', req.href()) 
     43                del req.session['captcha_redirect'] 
     44                del req.session['captcha_expected'] 
     45                req.session['captcha_verified'] = 1 
     46                req.redirect(redirect) 
     47            else: 
     48                req.hdf['error'] = 'Captcha verification failed' 
     49        else: 
     50            req.hdf['error'] = req.session.get('reject_reason') 
     51        result, html = self.captcha.generate_captcha(req) 
     52        req.hdf['captcha.challenge'] = html 
     53        req.hdf['captcha.href'] = req.href('captcha') 
     54        req.session['captcha_expected'] = result 
     55        req.session.save() 
     56        return 'verify_captcha.cs', None 
     57 
     58    # ITemplateProvider methods 
     59    def get_templates_dirs(self): 
     60        """ Return the absolute path of the directory containing the provided 
     61        ClearSilver templates.  """ 
     62        from pkg_resources import resource_filename 
     63        return [resource_filename(__name__, 'templates')] 
     64 
     65 
     66    # IRequestFilter methods 
     67    def pre_process_request(self, req, handler): 
     68        # TODO Allow restrict_to to specify request paths to allow 
     69        if not self.trust_authenticated and req.method == 'POST' \ 
     70                and handler is not self: 
     71            return Intercept(handler) 
     72        return handler 
     73 
     74    def post_process_request(self, req, template, content_type): 
     75        return (template, content_type) 
  • tracspamfilter/captcha/__init__.py

    Property changes on: tracspamfilter/captcha/api.py
    ___________________________________________________________________
    Name: svn:eol-style
       + native
    
     
     1from tracspamfilter.captcha.api import * 
  • tracspamfilter/captcha/expression.py

    Property changes on: tracspamfilter/captcha/__init__.py
    ___________________________________________________________________
    Name: svn:eol-style
       + native
    
     
     1import random 
     2from trac.core import * 
     3from trac.config import * 
     4from trac.util.html import html, Markup 
     5from tracspamfilter.captcha import ICaptchaMethod 
     6 
     7 
     8class ExpressionCaptcha(Component): 
     9    """ Implementation of a captcha in the form of a human readable numeric 
     10    expression. Initial implementation by sergeych@tancher.com. """ 
     11 
     12    implements(ICaptchaMethod) 
     13 
     14    terms = IntOption('numeric-captcha', 'terms', 3, 
     15            """ Number of terms in expression. """) 
     16    ceiling = IntOption('numeric-captcha', 'ceiling', 10, 
     17            """ Maximum value of individual terms in expression. """) 
     18 
     19    operations = {'*': 'multiplied by', '-': 'minus', '+': 'plus'} 
     20    numerals = ('zero', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 
     21                'eight', 'nine', 'ten', 'eleven', 'twelve', 'thirteen', 
     22                'fourteen', 'fifteen', 'sixteen', 'seventeen', 'eighteen', 
     23                'nineteen' ) 
     24    tens = ('twenty', 'thirty', 'forty', 'fifty', 'sixty', 'seventy', 
     25            'eighty', 'ninety') 
     26 
     27    # ICaptchaMethod methods 
     28    def generate_captcha(self, req): 
     29        if self.ceiling > 100: 
     30            raise TracError('Numeric captcha can not represent numbers > 100') 
     31        terms = [str(random.randrange(0, self.ceiling)) for _ in xrange(self.terms)] 
     32        operations = [random.choice(self.operations.keys()) for _ in xrange(self.terms)] 
     33        expression = sum(zip(terms, operations), ())[:-1] 
     34        expression = eval(compile(' '.join(expression), 'captcha_eval', 'eval')) 
     35        human = sum(zip([self.humanise(int(t)) for t in terms], 
     36                        [self.operations[o] for o in operations]), ())[:-1] 
     37        return (expression, html.blockquote(' '.join(map(str, human)))) 
     38 
     39    # Internal methods 
     40    def humanise(self, value): 
     41        if value < 20: 
     42            return self.numerals[value] 
     43        english = self.tens[value / 10 - 2] 
     44        if value % 10: 
     45            english += ' ' + self.numerals[value % 10] 
     46        return english 
  • tracspamfilter/captcha/image.py

    Property changes on: tracspamfilter/captcha/expression.py
    ___________________________________________________________________
    Name: svn:eol-style
       + native
    
     
     1import os 
     2import random 
     3import Image 
     4import ImageFont 
     5import ImageDraw 
     6import ImageFilter 
     7from StringIO import StringIO 
     8from trac.core import * 
     9from trac.util.html import html 
     10from trac.config import * 
     11from trac.web.api import IRequestHandler 
     12from tracspamfilter.captcha import ICaptchaMethod 
     13 
     14 
     15class ImageCaptcha(Component): 
     16    """ An image captcha courtesy of 
     17    http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/440588 """ 
     18 
     19    implements(ICaptchaMethod) 
     20    implements(IRequestHandler) 
     21 
     22    fonts = ListOption('image-captcha', 'fonts', 'vera.ttf', 
     23        doc=""" Set of fonts to choose from. """) 
     24    font_size = IntOption('image-captcha', 'font_size', 25, 
     25        "Font size") 
     26    alphabet = Option('image-captcha', 'alphabet', 'abcdefghkmnopqrstuvwxyz', 
     27        """ Alphabet to choose captcha challenge from. """) 
     28    letters = IntOption('image-captcha', 'letters', 6, 
     29        """ Number of letters to use in challenge. """) 
     30 
     31    # IRequestHandler methods 
     32    def match_request(self, req): 
     33        return req.path_info == '/captcha/image' 
     34 
     35    def process_request(self, req): 
     36        if 'captcha_expected' not in req.session: 
     37            # TODO Probably need to render an error image here 
     38            raise TracError('No Captcha response in session') 
     39        req.send_response(200) 
     40        req.send_header('Content-Type', 'image/jpeg') 
     41        req.end_headers() 
     42 
     43        image = StringIO() 
     44        from pkg_resources import resource_filename 
     45        font = os.path.join(resource_filename('tracspamfilter', 'fonts'), 
     46                            random.choice(self.fonts)) 
     47        self.gen_captcha(image, req.session['captcha_expected'], font, 
     48                         self.font_size) 
     49        req.write(image.getvalue()) 
     50 
     51    # ICaptchaMethod methods 
     52    def generate_captcha(self, req): 
     53        challenge = ''.join([random.choice(self.alphabet) for _ in xrange(self.letters)]) 
     54        return challenge, html.blockquote(html.img(src=req.href('/captcha/image'))) 
     55 
     56    # Internal methods 
     57    def gen_captcha(self, file, text, fnt, fnt_sz, fmt='JPEG'): 
     58        # randomly select the foreground color 
     59        fgcolor = random.randint(0,0xffff00) 
     60        # make the background color the opposite of fgcolor 
     61        bgcolor = fgcolor ^ 0xffffff 
     62        # create a font object  
     63        font = ImageFont.truetype(fnt,fnt_sz) 
     64        # determine dimensions of the text 
     65        dim = font.getsize(text) 
     66        # create a new image slightly larger that the text 
     67        im = Image.new('RGB', (dim[0]+5,dim[1]+5), bgcolor) 
     68        d = ImageDraw.Draw(im) 
     69        x, y = im.size 
     70        r = random.randint 
     71        # draw 100 random colored boxes on the background 
     72        for num in range(100): 
     73            d.rectangle((r(0,x),r(0,y),r(0,x),r(0,y)),fill=r(0,0xffffff)) 
     74        # add the text to the image 
     75        d.text((3,3), text, font=font, fill=fgcolor) 
     76        im = im.filter(ImageFilter.EDGE_ENHANCE_MORE) 
     77        # save the image to a file 
     78        im.save(file, format=fmt) 
  • tracspamfilter/captcha/captchasdotnet.py

    Property changes on: tracspamfilter/captcha/image.py
    ___________________________________________________________________
    Name: svn:eol-style
       + native
    
     
     1import os 
     2import md5 
     3import random 
     4import time 
     5from trac.core import * 
     6from trac.config import * 
     7from trac.util.html import html 
     8from tracspamfilter.captcha import ICaptchaMethod 
     9 
     10 
     11class CaptchasDotNetCaptcha(Component): 
     12    """ Trac adapter for the captcha.net Captcha community site. `client` and 
     13    `secret` '''must''' be configured in the `captchas.net` section of 
     14    `trac.ini`. """ 
     15    implements(ICaptchaMethod) 
     16 
     17    client = Option('captchas.net', 'client', doc= 
     18        """ [http://captchas.net] registered client name ('''required''') """) 
     19    secret = Option('captchas.net', 'secret', doc= 
     20        """ [http://captchas.net] registered client secret ('''required''') """) 
     21    alphabet = Option('captchas.net', 'alphabet', 'abcdefghkmnopqrstuvwxyz', 
     22        """ Alphabet to choose captcha challenge from. """) 
     23    letters = IntOption('captchas.net', 'letters', 6, 
     24        """ Number of letters to use in challenge. """) 
     25    width = IntOption('captchas.net', 'width', 240, 
     26        """ Width of captcha. """) 
     27    height = IntOption('captchas.net', 'height', 80, 
     28        """ Height of captcha. """) 
     29    random_repository = Option('captchas.net', 'random_repository', 
     30                               '/tmp/captchasnet-random-strings', 
     31        """ Local captcha cache directory. """) 
     32    cleanup_time = IntOption('captchas.net', 'cleanup_time', 3600, 
     33        """ Delay, in seconds, before old captcha images are removed. """) 
     34 
     35    # ICaptchaMethod methods 
     36    def generate_captcha(self, req): 
     37        if not self.client or not self.secret: 
     38            raise TracError('captchas.net plugin not configured (`[captchas.net] client = xxx secret = yyy)') 
     39        captcha = CaptchasDotNet(self.client, self.secret, self.alphabet, 
     40                                 self.letters, self.width, self.height, 
     41                                 self.random_repository, self.cleanup_time) 
     42        challenge = captcha.random() 
     43        html = html.p('Please respond with the letters in the following image or sound:')( 
     44            Markup(captchas.image()), 
     45            html.a('Phonetic spelling', href=captchas.audio_url()) 
     46        ) 
     47        return (challenge, html) 
     48 
     49 
     50#---------------------------------------------------------------------        
     51# Python module for easy utilization of http://captchas.net 
     52# 
     53# For documentation look at http://captchas.net/sample/python/ 
     54#  
     55# Written by Sebastian Wilhelmi <seppi@seppi.de> and 
     56#            Felix Holderied <felix@holderied.de> 
     57# This file is in the public domain. 
     58# 
     59# ChangeLog: 
     60# 
     61# 2006-09-08: Add new optional parameters alphabet, letters  
     62#             height an width. Add audio_url.  
     63#       
     64# 2006-03-01: Only delete the random string from the repository in 
     65#             case of a successful verification. 
     66# 
     67# 2006-02-14: Add new image() method returning an HTML/JavaScript 
     68#             snippet providing a fault tolerant service. 
     69# 
     70# 2005-06-02: Initial version. 
     71# 
     72#--------------------------------------------------------------------- 
     73 
     74class CaptchasDotNet: 
     75    def __init__ (self, client, secret, 
     76                  alphabet = 'abcdefghkmnopqrstuvwxyz', 
     77                  letters = 6, 
     78                  width = 240, 
     79                  height = 80, 
     80                  random_repository = '/tmp/captchasnet-random-strings', 
     81                  cleanup_time = 3600 
     82                  ): 
     83        self.__client = client 
     84        self.__secret = secret 
     85        self.__alphabet = alphabet 
     86        self.__letters = letters 
     87        self.__width = width 
     88        self.__height = height 
     89        self.__random_repository = random_repository 
     90        self.__cleanup_time = cleanup_time 
     91        self.__time_stamp_file = os.path.join (random_repository, 
     92                                               '__time_stamp__') 
     93 
     94    # Return a random string 
     95    def __random_string (self): 
     96        # The random string shall consist of small letters, big letters 
     97        # and digits. 
     98        letters = "abcdefghijklmnopqrstuvwxyz" 
     99        letters += letters.upper () + "0123456789" 
     100 
     101        # The random starts out empty, then 40 random possible characters 
     102        # are appended. 
     103        random_string = '' 
     104        for i in range (40): 
     105            random_string += random.choice (letters) 
     106 
     107        # Return the random string. 
     108        return random_string 
     109 
     110    # Create a new random string and register it. 
     111    def random (self): 
     112        # If the repository directory is does not yet exist, create it. 
     113        if not os.path.isdir (self.__random_repository): 
     114            os.makedirs (self.__random_repository) 
     115             
     116        # If the time stamp file does not yet exist, create it. 
     117        if not os.path.isfile (self.__time_stamp_file): 
     118            os.close (os.open (self.__time_stamp_file, os.O_CREAT, 0700)) 
     119 
     120        # Get the current time. 
     121        now = time.time () 
     122 
     123        # Determine the time, before which to remove random strings. 
     124        cleanup_time = now - self.__cleanup_time 
     125 
     126        # If the last cleanup is older than specified, cleanup the 
     127        # directory. 
     128        if os.stat (self.__time_stamp_file).st_mtime < cleanup_time: 
     129            os.utime (self.__time_stamp_file, (now, now)) 
     130            for file_name in os.listdir (self.__random_repository): 
     131                file_name = os.path.join (self.__random_repository, file_name) 
     132                if os.stat (file_name).st_mtime < cleanup_time: 
     133                    os.unlink (file_name) 
     134 
     135        # loop until a valid random string has been found and registered. 
     136        while True: 
     137            # generate a new random string. 
     138            random = self.__random_string () 
     139 
     140            # open a file with the corresponding name in the repository 
     141            # directory in such a way, that the creation fails, when the 
     142            # file already exists. That should be near to impossible with 
     143            # good seeding of the random number generator, but it's better 
     144            # to play safe. 
     145            try: 
     146                os.close (os.open (os.path.join (self.__random_repository, 
     147                                                 random), 
     148                                   os.O_EXCL | os.O_CREAT, 0700)) 
     149            except EnvironmentError, error: 
     150                # if the file already existed, rerun the loop to try the 
     151                # next string. 
     152                if error.errno == errno.EEXIST: 
     153                    continue 
     154                else: 
     155                    # other errors will certainly persist for other random 
     156                    # strings, so raise the exception. 
     157                    raise 
     158 
     159            # return the successfully registered random string. 
     160            self.__random = random 
     161            return random 
     162 
     163    def image_url (self, random = None, base = 'http://image.captchas.net/'): 
     164        if not random: 
     165            random = self.__random 
     166        url = base 
     167        url += '?client=%s&amp;random=%s' % (self.__client, random) 
     168        if self.__alphabet != "abcdefghijklmnopqrstuvwxyz": 
     169            url += '&amp;alphabet=%s' % self.__alphabet 
     170        if self.__letters != 6: 
     171            url += '&amp;letters=%s' % self.__letters 
     172        if self.__width != 240: 
     173            url += '&amp;width=%s' % self.__width 
     174        if self.__height != 80: 
     175            url += '&amp;height=%s' % self.__height 
     176        return url 
     177 
     178    def audio_url (self, random = None, base = 'http://audio.captchas.net/'): 
     179        if not random: 
     180            random = self.__random 
     181        url = base 
     182        url += '?client=%s&amp;random=%s' % (self.__client, random) 
     183        if self.__alphabet != "abcdefghijklmnopqrstuvwxyz": 
     184            url += '&amp;alphabet=%s' % self.__alphabet 
     185        if self.__letters != 6: 
     186            url += '&amp;letters=%s' % self.__letters 
     187        return url 
     188 
     189    def image (self, random = None, id = 'captchas.net'): 
     190        return ''' 
     191        <a href="http://captchas.net"><img 
     192            style="border: none; vertical-align: bottom" 
     193            id="%s" src="%s" width="%d" height="%d" 
     194            alt="The CAPTCHA image" /></a> 
     195        <script type="text/javascript"> 
     196          <!-- 
     197          function captchas_image_error (image)  
     198          { 
     199            if (!image.timeout) return true; 
     200            image.src = image.src.replace (/^http:\/\/image\.captchas\.net/,  
     201                                           'http://image.backup.captchas.net'); 
     202            return captchas_image_loaded (image); 
     203          } 
     204 
     205          function captchas_image_loaded (image) 
     206          { 
     207            if (!image.timeout) return true; 
     208            window.clearTimeout (image.timeout); 
     209            image.timeout = false; 
     210            return true; 
     211          } 
     212 
     213          var image = document.getElementById ('%s'); 
     214          image.onerror = function() {return captchas_image_error (image);}; 
     215          image.onload = function() {return captchas_image_loaded (image);}; 
     216          image.timeout  
     217            = window.setTimeout( 
     218               "captchas_image_error (document.getElementById ('%s'))", 
     219               10000); 
     220          image.src = image.src; 
     221          //-->       
     222        </script>''' % (id, self.image_url (random), self.__width, self.__height, id, id) 
     223 
     224    def validate (self, random): 
     225        self.__random = random 
     226 
     227        file_name = os.path.join (self.__random_repository, random) 
     228 
     229        # Find out, whether the file exists 
     230        result = os.path.isfile (file_name) 
     231 
     232        # if the file exists, remember it. 
     233        if result: 
     234            self.__random_file = file_name 
     235 
     236        # the random string was valid, if and only if the 
     237        # corresponding file existed. 
     238        return result 
     239         
     240    def verify (self, input, random = None): 
     241        if not random: 
     242            random = self.__random 
     243             
     244        # The format of the password. 
     245        password_alphabet = self.__alphabet 
     246        password_length = self.__letters 
     247 
     248        # If the user input has the wrong lenght, it can't be correct. 
     249        if len (input) != password_length: 
     250            return False 
     251 
     252        # Calculate the MD5 digest of the concatenation of secret key and 
     253        # random string. 
     254        encryption_base = self.__secret + random 
     255        if (password_alphabet != "abcdefghijklmnopqrstuvwxyz") or (password_length != 6): 
     256            encryption_base += ":" + password_alphabet + ":" + str(password_length) 
     257        digest = md5.new (encryption_base).digest () 
     258 
     259        # Compute password 
     260        correct_password = '' 
     261        for pos in range (password_length): 
     262            letter_num = ord (digest[pos]) % len (password_alphabet) 
     263            correct_password += password_alphabet[letter_num] 
     264  
     265        # Check password 
     266        if input != correct_password: 
     267            return False 
     268 
     269        # Remove the correspondig random file, if it exists. 
     270        try: 
     271            os.unlink (self.__random_file) 
     272            del self.__random_file 
     273        except: 
     274            pass 
     275         
     276        # The user input was correct. 
     277        return True 
  • tracspamfilter/templates/verify_captcha.cs

    Property changes on: tracspamfilter/captcha/captchasdotnet.py
    ___________________________________________________________________
    Name: svn:eol-style
       + native
    
    Cannot display: file marked as a binary type.
    svn:mime-type = application/octet-stream
    
    Property changes on: tracspamfilter/fonts/vera.ttf
    ___________________________________________________________________
    Name: svn:mime-type
       + application/octet-stream
    
     
     1<?cs include "header.cs" ?> 
     2<?cs include "macros.cs" ?> 
     3 
     4<?cs if:error ?> 
     5<div id="content" class="error"> 
     6 <h1>Captcha Error</h1> 
     7 <p class="message"><?cs var:error ?></p> 
     8<?cs else ?> 
     9<div id="content" class="traccaptcha"> 
     10<?cs /if ?> 
     11<form method="post" action="<?cs var:captcha.href ?>"> 
     12<p> 
     13Trac thinks your submission might be Spam. To prove otherwise please provide a response to the following. 
     14</p> 
     15<p> 
     16<?cs var:captcha.challenge ?> 
     17</p> 
     18Response: <input type="text" name="captcha_response"/> 
     19<input type="submit" value="Submit"/> 
     20</form> 
     21</div> 
     22 
     23<?cs include "footer.cs" ?> 
  • setup.py

     
    3131    extras_require = { 
    3232        'DNS': ['dnspython>=1.3.5'], 
    3333        'SpamBayes': ['spambayes'], 
     34        'PIL': ['pil'], 
    3435    }, 
    3536    entry_points = """ 
    3637        [trac.plugins] 
     
    4445        spamfilter.ip_throttle = tracspamfilter.filters.ip_throttle 
    4546        spamfilter.regex = tracspamfilter.filters.regex 
    4647        spamfilter.session = tracspamfilter.filters.session 
     48        spamfilter.captcha = tracspamfilter.captcha.api 
     49        spamfilter.captcha.image = tracspamfilter.captcha.image[PIL] 
     50        spamfilter.captcha.expression = tracspamfilter.captcha.expression 
     51        spamfilter.captcha.captchasdotnet = tracspamfilter.captcha.captchasdotnet 
    4752    """, 
    4853    test_suite = 'tracspamfilter.tests.suite', 
    4954    zip_safe = True