Django中实现一个简单的数据防爬取系统

www.fachun.net是我做的一个音乐资源站,因为资源丰富,容易成为他人抓取的目标。

被抓取资源事小,另一方面,爬虫的频繁访问还会严重占用服务器资源。

前期写代码的时候,我已经考虑到防爬问题,做了一些简单的事,比如避免使用数字ID,而是ID + 名字。

例如一个歌手的名字是ABC,ID是36,最终的URL就是:

http://www.fachun.net/musician/36-ABC/

这是爬虫难以直接猜解的。

今天我再写一些额外的代码来限制单个IP请求的频率,实现防爬。有几个要点:

1. 使用MySQL Memory数据库引擎来存储用户IP地址

因为多了个过滤层,响应时间必然增加。应该尽量减小这个影响,把IP放在内存中可以节省查找时间。

原来的应用是InnoDB引擎,所以,需要新建数据库,并在settings.DATABASES中添加它。

2. 目前我的过滤条件是10分钟内请求超过100次(已改为60次),封IP十分钟

3. 搜索引擎百度、Google等的爬虫应该不受过滤,因此,需要特别处理搜索引擎的爬虫。

设IP白名单会再次影响效率,我不希望设用白名单来筛选。

只是检查REMOTE_HOST是否包含googlebot.com、crawl.baidu.com等字符串

1. 修改配置文件,增加数据库

首先新建一个app,我这里命名为robotkiller,执行:

manage.py startapp robotkiller

然后在工程settings.py文件中添加一个数据库,我仍然用同名的robotkiller,并在INSTALLED_APPS中添加robotkiller。

DATABASES = {
    'default': {...},
    'robotkiller': {
        'ENGINE': 'django.db.backends.mysql', 
        'NAME': 'robotkiller',                      
        'USER': 'your_user',
        'PASSWORD': 'your_pass',
        'HOST': '127.0.0.1',                    
        'PORT': '3306',
        'OPTIONS': {
            "init_command": "SET storage_engine=MEMORY",
        }
    }
}

models.py中的内容是这样的:

from django.db import models

class RobotKiller(models.Model):
    id = models.IntegerField(primary_key=True)
    ip = models.CharField(max_length=16)    #IP地址
    visits = models.IntegerField()          #请求次数
    time = models.DateTimeField()           #第一次发起请求的时间
    class Meta:
        db_table = 'robotkiller'

记得为ip字段建一个索引(让MySQL为内存数据库再维护个索引,是对是错,暂不深究):

我在settings.py中设置了时区为’Asia/chongqing’,所以,取得时间差,不应该直接用datetime,

而是用django.utils.timezone。下面是我的视图函数:

from models import RobotKiller
from django.utils import timezone

max_visits = 100
min_seconds = 600

def filterIP(request):
    domain = request.META.get('REMOTE_HOST')
    white_list = ['googlebot.com', 'crawl.baidu.com', 'sogou.com', 'bing.com', 'yahoo.com']
    for bot_domain in white_list:
        if domain.find(bot_domain) > 0:
            return bot_domain

    user_ip = request.META['REMOTE_ADDR']

    try:
        record = RobotKiller.objects.using('robotkiller').get(ip=user_ip)
    except RobotKiller.DoesNotExist:
        RobotKiller.objects.using('robotkiller').create(ip=user_ip, visits=1, time=timezone.now())
        return

    passed_seconds = (timezone.now() - record.time).seconds

    if record.visits > max_visits and passed_seconds < min_seconds:
        raise Exception('user ip banned.')
    else:
        if passed_seconds < min_seconds:
            record.visits = record.visits + 1
            record.save()
        else:
            record.visits = 1
            record.time = timezone.now()
            record.save()

上述代码就是基本的过滤逻辑。在urls.py中添加:

from robotkiller import filterIP

然后在需要防爬取的对应app的views.py中添加该函数:

def checkIP(request):
    try:
        filterIP(request)
    except Exception, e:
        if unicode(e) == 'user ip banned.':
            raise PermissionDenied()

接着,在所有视图函数的第一行添加checkIP(request) 就可以过滤所有页面的请求了。

当用户在10分钟内请求了超过100个页面,会得到一个403错误。

可以在模板文件夹下新建一个403.html,自定义出错提示了。

MySQL修改某一列collate时遇到的问题

在测试歌手名排序时,我曾临时地将歌手的name列改成了utf8_bin整理排序,区分大小写。

其他列默认为utf8_general_ci排序。

后来忘记改回来了,

今天测试时发现一个bug,搜歌手输入Linkin Park可以搜到,而linkin park却搜不到:

http://www.fachun.net/search/Linkin%20Park?sid=0

该函数我在django中已经用icontains忽略大小写,

确认view函数没有问题,我排查到原来是name列被错误定义为了utf8_bin排序,所以大小写是不同的。

原先的定义是:

name varchar(100) character set utf8 collate utf8_bin not null;

我直接修改某列,就执行:

alter table musician modify name varchar(100) not null;

这个表也就9000多条记录,执行了半天还一直卡在那里。

show processlist,发现state居然是Locked,无语!

kill对应的id,换成执行:

alter table musician convert to character set utf8 collate utf8_general_ci;

两秒就完成了。

具体原因没有深究,或许MySQL版本较低(我用的5.5),DDL效率总是不尽人意。

在修改记录较多的表时,尤其费时,听说是因为MySQL先copy整个表再做修改。

django makemessages时出现DjangoUnicodeDecodeError

今天对www.fachun.net做了些改动,需要增加一点翻译。

当我执行django-admin.py makemessages -l en时,遇到了DjangoUnicodeDecodeError,

DjangoUnicodeDecodeError: ‘utf8’ codec can’t decode byte 0xcd in position 12: in
valid continuation byte. You passed in ‘2013/10/11 \xcd\xea\xb3\xc9\xc1\xcb\xd5
\xbe\xb5\xe3\xb5\xc4\xb9\xfa\xbc\xca\xbb\xaf\xa3\xac\xb2\xb9\xb3\xe4\xc1\xcb\xd3
\xa2\xce\xc4\xb7\xad\xd2\xeb\n\n2013/10/12 \xca\xb5\xcf\xd6\xc1\xcb\xa1\xb0\xbb
\xbb\xd2\xbb\xc5\xfa\xb9\xa6\xc4\xdc\xa1\xb1\xa1\xa2\xd4\xda\xb2\xbb\xcd\xac\xd3
\xef\xd1\xd4\xd1\xa1\xd4\xf1\xcf\xc2\xb8\xdf\xc1\xc1\xb2\xbb\xcd\xac\xb5\xc4\xce
\xc4\xd7\xd6,\xca\xb5\xcf\xd6\xc1\xcb\xb6\xe0\xd3\xef\xd1\xd4URL\xb2\xee\xd2\xec
\xbb\xaf\xb5\xc4\xd3\xc5\xbb\xaf\n\n2013/’ (<type ‘str’>)

看名字知道是出现了uniocde解码错误。

但自己一向非常注意编码问题,数据库、文件都是以utf8编码保存的。

因为错误信息没有提到是在哪个文件出现问题,

我在python里将上述字符串打印出来,发现竟然是更新记录:

DjangoUnicodeDecodeError-print

这样就定位到出现问题的文件,它是app目录下的一个logs.txt文件。

按个人理解,这个文件是不应该被makemessages处理的,它既非模板,又不是.py文件或.po文件。

但makemessages不想遗漏需要处理的文件,也可以理解。

为什么这个文件会出现问题呢?因为它是我用记事本在windows下直接创建和保存的。

在windows下,记事本默认地将这个文件以”ANSI code page”编码保存。

我使用win7中文版(codepage = 936),文件就默认以GBK编码保存,跟我在cmd下默认是gbk编码一样。

 

知道了出错的原因,再次用记事本打开文件,另存utf8就行了。

不得不说,windows的记事本真是很奇葩。

有Ctrl + S保存习惯的同学可以考虑Alt + F + A另存为utf8,或者用editPlus

抓取box.net分享连接的python脚本

通过前面写的注册机,我已经在box.net注册和上传了很多文件。

现在把自己提取分享连接的脚本发布出来。

这段脚本只是模拟各种http请求:登陆,查看文件夹,翻页,逐个分享文件,解析json

让人感觉麻烦一点是token和cookie。

token在切换文件夹的页面产生了变化,也曾让我在写代码的过程中遇到过疑惑。

Crawler类通过用户名和密码构造,login方法尝试登陆:

crawler = Crawler('your_email', 'your_password')
crawler.login()

ls方法用于列文件,必需的参数是一个文件夹名,

sorted_by参数是可选的: name, date, size

sorted_direction参数是可选的: ASC, DESC

lst_files = crawler.ls('a_folder_name', sorted_by='date', sorted_direction='ASC')

ls方法返回的是一个列表,而列表中的每个元素都是元组tuple。

这个tuple的形式是: (文件名, 分享链接)

 

这段代码的效率较低,

因为文件是逐个分享的,没有使用多线程,而http请求和解析json的次数过多。

查阅代码

 

解决python urllib2 302重定向后丢cookie的问题

通过昨天写的python脚本,我已经注册激活了50个box.net账号,用作上传文件。

今天我继续写代码,用来自动登录box.net并获取所有文件的分享链接。

不过测试的时候出现了点问题,账号信息正确,但总是登录不成功。

headers中referer、user-agent都有伪造,cookie也有发送。

通过设置debuglevel=1跟踪http请求,最终发现了问题:

        httpHandler = urllib2.HTTPHandler(debuglevel=1)
        httpsHandler = urllib2.HTTPSHandler(debuglevel=1)
        self.opener = urllib2.build_opener(httpHandler, httpsHandler)

urllib2很聪明,在发现HttpResponse中有重定向(301, 302)时会自动转向请求这个新的URL,

但urllib2有个严重的问题,它没有带着cookie去请求新的URL。

这也是说,前期我们通过一个POST请求来获取cookie(对应着服务器上的session),

但urllib2却没有带着必要的cookie去访问需要授权的页面。

一开始我是想直接用httplib的,考虑到前后一致性才全部用urllib2,结果urllib2又出问题。。。

解决这个问题,可以:

1. 换httplib来实现,它不会像urllib2会自动处理重定向,cookie不会丢

2. 截获重定向,禁止urllib2自动处理

我选择了重写urllib2.HTTPRedirectHandler的http_error_302方法,截获302,让urllib2不再处理302:

class HttpRedirect_Handler(urllib2.HTTPRedirectHandler):
    def http_error_302(self, req, fp, code, msg, headers):
        pass

然后在urllib2.build_opener方法中用HttpRedirect_Handler的一个实例做参数,例如:

self.opener = urllib2.build_opener(HttpRedirect_Handler(),
                                   urllib2.HTTPCookieProcessor(self.cookie))

这样,当我们用上述opener去POST登录时,遇到302就不会再自动转向了,

登录成功获取到的cookie也不会丢。

后面再带着self.cookie去请求需要授权的页面,就可以获取到正确的内容了。

 

批量注册Box.net账号的python脚本

国内网盘上传速度实在太慢了(跟运营商也有关系),没办法,我只好选择国外的网盘。

但国外网盘一个最大的问题是,容量小,dropbox起始2GB,而google Drive也才5GB。

最终我选择的是box.net网盘,10GB,并且外链非常容易编程获取。

web上传大约2M/s,甚至不用客户端。

如果上传500GB的文件,将需要超过50个账号,手工注册太麻烦,于是写下一小段python脚本:

1. 注册表单位于: https://app.box.com/signup/personal/

2. 在上述页面可获取request token和cookie

3. 自己构造表单信息后提交

选择批量注册,另一个比较重要的原因,是我的邮箱域设置了自动转发。

对于不存在的用户,比如[email protected], [email protected]

都将转发到一个指定的管理员邮箱,比如[email protected]

所以,所有的激活邮件都将直接发送给我自己,激活也比较方便,不用登陆不同的邮箱。

代码在这里

代码中的例子是注册[email protected][email protected]这种形式的账号。

 

最后还想说一句:屌丝用户伤不起,买个网盘都嫌贵。。。