import sqlite3

Python 的一个非常大的优点是很容易写很容易跑起来,缺点就是很多不那么著名的(甚至一些著名的)程序和库都不像 C 和 C++ 那边那样专业、可靠(当然这也有动态类型 vs 静态类型的原因)。

首先,爬虫属于IO密集型程序(网络IO和磁盘IO),这类程序的瓶颈大多在网络和磁盘读写的速度上,多线程在一定程度上可以加速爬虫的效率,但是这个“加速”无法超过min(出口带宽,磁盘写的速度),而且,关于Python的多线程,由于GIL的存在,实际上是有一些初学者不容易发现的坑的。

算法设计、人工智能、统计分析、编程语言设计那些应用数学关系紧密的方向。

python写爬虫,没有使用Scrapy吗?item属性定义明确,pipelines来做入库处理,怎么会出现sql错误 ?

做爬虫难点是解析页面和反爬,你竟然还没到这一步就挂了,还能说啥?试试scrapy吧,如果规模大需要调度就上redis。

你用了sqllite做存储,应该是个规模很小的项目?那不如直接存文件。上了规模建议mongodb,pymongo很靠谱!

sql注入爬虫,好像很好玩啊

useragent 模仿百度("Baiduspider..."),2. IP每爬半个小时就换一个IP代理。
小黎也发现了对应的变化,于是在 Nginx 上设置了一个频率限制,每分钟超过120次请求的再屏蔽IP。 同时考虑到百度家的爬虫有可能会被误伤,想想市场部门每月几十万的投放,于是写了个脚本,通过 hostname 检查下这个 ip 是不是真的百度家的,对这些 ip 设置一个白名单。
随机1-3秒爬一次,爬10次休息10秒,每天只在8-12,18-20点爬,隔几天还休息一下。
小黎看着新的日志头都大了,再设定规则不小心会误伤真实用户,于是准备换了一个思路,当3个小时的总请求超过50次的时候弹出一个验证码弹框,没有准确正确输入的话就把 IP 记录进黑名单。

图像识别(关键词 PIL,tesseract),再对验证码进行了二值化,分词,模式训练之后,识别了小黎的验证码(关于验证码,验证码的识别,验证码的反识别也是一个恢弘壮丽的斗争史

验证码被攻破后,和开发同学商量了变化下开发模式,数据并不再直接渲染,而是由前端同学异步获取,并且通过 js 的加密库生成动态的 token,同时加密库再进行混淆(比较重要的步骤的确有网站这样做,参见微博的登陆流程)。
内置浏览器引擎的爬虫(关键词:PhantomJS,Selenium),在浏览器引擎中js 加密脚本算出了正确的结果
不要只看 Web 网站,还有 App 和 H5,他们的反爬虫措施一般比较少
如果真的对性能要求很高,可以考虑多线程(一些成熟的框架如 scrapy都已支持),甚至分布式
selenium + xvfb = headless spider in linux.
加验证码,限制请求频次。破解办法依然有,前端代码或者说用户正常浏览能做的,爬虫都能做,应对爬虫没什么绝对可行的办法,想爬的早晚能爬到,最多是成本有差异
cnproxy之类的代理分享网站抓代理ip和端口
爬取搜狗微信公众号搜索的非公开接口,没做宣传的时候,流量不大,用的比较好,宣传后,用的人多了,就发现被反爬虫了,一直500
通过chrome的开发者工具分析搜狗页面的请求,发现不是通过ip反爬,而是cookie里的几个关键字段,应对方法就是写个定时任务,维护一个cookie池,定时更新cookie,爬的时候随机取cookie,现在基本不会出现500

所以应对反爬虫,先分析服务器是通过什么来反爬,通过ip就用代理,通过cookie就换cookie,针对性的构建request。

 
 好像没有验证user agent,cookie是直接解析response头部的set-cookie来的,
 维持自己的cookie池
 
 
 
 

抓取大多数情况属于get请求,即直接从对方服务器上获取数据。

首先,Python中自带urllib及urllib2这两个模块,基本上能满足一般的页面抓取。另外,requests也是非常有用的包,与此类似的,还有httplib2等等。

Requests:
    import requests
    response = requests.get(url)
    content = requests.get(url).content
    print "response headers:", response.headers
    print "content:", content
Urllib2:
    import urllib2
    response = urllib2.urlopen(url)
    content = urllib2.urlopen(url).read()
    print "response headers:", response.headers
    print "content:", content
Httplib2:
    import httplib2
    http = httplib2.Http()
    response_headers, content = http.request(url, 'GET')
    print "response headers:", response_headers
    print "content:", content

此外,对于带有查询字段的url,get请求一般会将来请求的数据附在url之后,以?分割url和传输数据,多个参数用&连接。

data = {'data1':'XXXXX', 'data2':'XXXXX'}
Requests:data为dict,json
    import requests
    response = requests.get(url=url, params=data)
Urllib2:data为string
    import urllib, urllib2
    data = urllib.urlencode(data)
    full_url = url+'?'+data
    response = urllib2.urlopen(full_url)
 
限制频率: Requests,Urllib2都可以使用time库的sleep()函数:

import time
time.sleep(1)
有时还会检查是否带Referer信息还会检查你的Referer是否合法,一般再加上Referer。
 
 
4. 对于断线重连

不多说。

def multi_session(session, *arg):
    while True:
        retryTimes = 20
    while retryTimes>0:
        try:
            return session.post(*arg)
        except:
            print '.',
            retryTimes -= 1

或者

def multi_open(opener, *arg):
    while True:
        retryTimes = 20
    while retryTimes>0:
        try:
            return opener.open(*arg)
        except:
            print '.',
            retryTimes -= 1

这样我们就可以使用multi_session或multi_open对爬虫抓取的session或opener进行保持。

华尔街新闻: http://wallstreetcn.com/news   https://github.com/lining0806/Spider_Python    https://github.com/lining0806/Spider

网易新闻排行榜: https://github.com/lining0806/NewsSpider
 
 ajax的js请求地址:
 这里,若使用Google Chrome分析”请求“对应的链接(方法:右键→审查元素→Network→清空,点击”加载更多“,出现对应的GET链接寻找Type为text/html的,点击,查看get参数或者复制Request URL),
 

主要涉及的库

requests 处理网络请求
logging 日志记录
threading 多线程
Queue 用于线程池的实现
argparse shell参数解析
sqlite3 sqlite数据库
BeautifulSoup html页面解析
urlparse 对链接的处理

关于requests

我没有选择使用python的标准库urllib2,urllib2不易于代码维护,修改起来麻烦,而且不易扩展, 总体来说,requests就是简单易用,如requests的介绍所说: built for human beings.

包括但不限于以下几个原因:

  • 自动处理编码问题

    Requests will automatically decode content from the server. Most unicode charsets are seamlessly decoded.

    web的编码实在是不易处理,尤其是显示中文的情况。参考[1]

  • 自动处理gzip压缩
  • 自动处理转向问题
  • 很简单的支持了自定义cookies,header,timeout功能.
  • requests底层用的是urllib3, 线程安全.
  • 扩展能力很强, 例如要访问登录后的页面, 它也能轻易处理.

关于线程池的实现与任务委派

因为以前不了解线程池,线程池的实现在一开始参照了Python Cookbook中关于线程池的例子,参考[2]。
借鉴该例子,一开始我是使用了生产者/消费者的模式,使用任务队列和结果队列,把html源码下载的任务交给任务队列,然后线程池中的线程负责下载,下载完html源码后,放进结果队列,主线程不断从结果队列拿出结果,进行下一步处理。
这样确实可以成功的跑起来,也实现了线程池和任务委派,但却隐藏着一个问题:
做测试时,我指定了新浪爬深度为4的网页, 在爬到第3层时,内存突然爆增,导致程序崩溃。
经过调试发现,正是以上的方法导致的:
多线程并发去下载网页,无论主线程做的是多么不耗时的动作,始终是无法跟上下载的速度的,更何况主线程要负责耗时的文件IO操作,因此,结果队列中的结果没能被及时取出,越存越多却处理不来,导致内存激增。
曾想过用另外的线程池来负责处理结果,可这样该线程池的线程数不好分配,分多了分少了都会有问题,而且程序的实际线程数就多于用户指定的那个线程数了。
因此,干脆让原线程在下载完网页后,不用把结果放进结果队列,而是继续下一步的操作,直到把网页存起来,才结束该线程的任务。
最后就没用到结果队列,一个线程的任务变成:
根据url下载网页—->保存该网页—->抽取该网页的链接(为访问下个深度做准备)—->结束

关于BFS与深度控制

爬虫的BFS算法不难写,利用队列出栈入栈即可,有一个小难点就是对深度的控制,我一开始是这样做的:
用一个flag来标注每一深度的最后一个链接。当访问到最后一个链接时,深度+1。从而控制爬虫深度。
代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def getHrefsFromURL(root, depth):
    unvisitedHrefs.append(root)
    currentDepth = 1
    lastURL = root
    flag = False
    while currentDepth < depth+1:
        url = unvisitedHrefs.popleft()
        if lastURL == url:
            flag = True
        #解析html源码,获取其中的链接。并把链接append到unvisitedHrefs去
        getHrefs(url)
        if flag:
            flag = False
            currentDepth += 1
            location = unvisitedHrefs[-1]

但是,这个方法会带来一些问题:

  1. 耗性能: 循环中含有两个不常用的判断
  2. 不适合在多线程中使用

因此,在多线程中,我使用了更直接了当的方法:
先把整个深度的链接分配给线程池线程中的线程去处理(处理的内容参考上文), 等待该深度的所有链接处理完,当所有链接处理完时,则表示爬完爬了一个深度的网页。
此时,下一个深度要访问的链接,已经都准备好了。

1
2
3
4
5
6
7
8
9
10
while self.currentDepth < self.depth+1:
    #分配任务,线程池并发下载当前深度的所有页面(该操作不阻塞)
    self._assignCurrentDepthTasks()
    #等待当前线程池完成所有任务
    #使用self.threadPool.taskQueueJoin()可代替以下操作,可无法Ctrl-C Interupt
    while self.threadPool.getTaskLeft():
        time.sleep(10)
    #当池内的所有任务完成时,即代表爬完了一个网页深度
    #迈进下一个深度
    

关于耦合性和函数大小

很显然,一开始我这爬虫代码耦合性非常高,线程池,线程,爬虫的操作,三者均粘合在一块无法分开了。 于是我几乎把时间都用在了重构上面。先是把线程池在爬虫中抽出来,再把线程从线程中抽离出来。使得现在三者都可以是相对独立了。
一开始代码里有不少长函数,一个函数里面做着几个操作,于是我决定把操作从函数中抽离,一个函数就必须如它的命名那般清楚,只做那个操作。
于是函数虽然变多了,但每个函数都很简短,使得代码可读性增强,修改起来容易,同时也增加了代码的可复用性。
关于这一点,重构 参考[3] 这本书帮了我很大的忙。

一些其它问题

如何匹配keyword?
一开始使用的方法很简单,把源码和关键词都转为小(大)写,在使用find函数:
pageSource.lower().find(keyword.lower())
要把所有字符转为小写,再查找,我始终觉得这样效率不高。
于是发帖寻求帮助, 有人建议说:
使用if keyword.lower() in pageSource.lower()
确实看过文章说in比find高效,可还没解决我的问题.
于是有人建议使用正则的re.I来查找。
我觉得这是个好方法,直觉告诉我正则查找会比较高效率。
可又有人跳出来说正则比较慢,并拿出了数据。。。
有时间我觉得要做个测试,验证一下。

被禁止访问的问题:
访问未停止时,突然某个host禁止了爬虫访问,这个时候unvisited列表中仍然有大量该host的地址,就会导致大量的超时。 因为每次超时,我都设置了重试,timeout=10s, * 3 = 30s 也就是一个链接要等待30s。
若不重试的话,因为开线程多,网速慢,会导致正常的网页也timeout~
这个问题就难以权衡了。

END

经测试,
爬sina.com.cn 二级深度, 共访问约1350个页面,
开10线程与20线程都需要花费约20分钟的时间,时间相差不多.
随便打开了几个页面,均为100k上下的大小, 假设平均页面大小为100k,
则总共为135000k的数据。
ping sina.com.cn 为联通ip,机房测速为联通133k/s,
则:135000/133/60 约等于17分钟
加上处理数据,文件IO,网页10s超时并重试2次的时间,理论时间也比较接近20分钟了。
因此最大的制约条件应该就是网速了。

看着代码进行了回忆和反思,算是总结了。做之前觉得爬虫很容易,没想到也会遇到不少问题,也学到了很多东西,这样的招人题目比做笔试实在多了。
这次用的是多线程,以后可以再试试异步IO,相信也会是不错的挑战。
附: 爬虫源码

ref:
[1]网页内容的编码检测

[2]simplest useful (I hope!) thread pool example
Python Cookbook

[3]重构:改善既有代码的设计

[4]用Python抓网页的注意事项

[5]用python爬虫抓站的一些技巧总结

[6]各种Documentation 以及 随手搜的网页,不一一列举。

5.验证码的处理

碰到验证码咋办?这里分两种情况处理:

  • google那种验证码,凉拌
  • 简单的验证码:字符个数有限,只使用了简单的平移或旋转加噪音而没有扭曲的,这种还是有可能可以处理的,一般思路是旋转的转回来,噪音去掉,然 后划分单个字符,划分好了以后再通过特征提取的方法(例如PCA)降维并生成特征库,然后把验证码和特征库进行比较。这个比较复杂,一篇博文是说不完的, 这里就不展开了,具体做法请弄本相关教科书好好研究一下。
  • 事实上有些验证码还是很弱的,这里就不点名了,反正我通过2的方法提取过准确度非常高的验证码,所以2事实上是可行的。

6 gzip/deflate支持

现在的网页普遍支持gzip压缩,这往往可以解决大量传输时间,以VeryCD的主页为例,未压缩版本247K,压缩了以后45K,为原来的1/5。这就意味着抓取速度会快5倍。

然而python的urllib/urllib2默认都不支持压缩,要返回压缩格式,必须在request的header里面写明’accept- encoding’,然后读取response后更要检查header查看是否有’content-encoding’一项来判断是否需要解码,很繁琐琐 碎。如何让urllib2自动支持gzip, defalte呢?

其实可以继承BaseHanlder类,然后build_opener的方式来处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import urllib2
from gzip import GzipFile
from StringIO import StringIO
class ContentEncodingProcessor(urllib2.BaseHandler):
  """A handler to add gzip capabilities to urllib2 requests """
  
  # add headers to requests
  def http_request(self, req):
    req.add_header("Accept-Encoding", "gzip, deflate")
    return req
  
  # decode
  def http_response(self, req, resp):
    old_resp = resp
    # gzip
    if resp.headers.get("content-encoding") == "gzip":
        gz = GzipFile(
                    fileobj=StringIO(resp.read()),
                    mode="r"
                  )
        resp = urllib2.addinfourl(gz, old_resp.headers, old_resp.url, old_resp.code)
        resp.msg = old_resp.msg
    # deflate
    if resp.headers.get("content-encoding") == "deflate":
        gz = StringIO( deflate(resp.read()) )
        resp = urllib2.addinfourl(gz, old_resp.headers, old_resp.url, old_resp.code)  # 'class to add info() and
        resp.msg = old_resp.msg
    return resp
  
# deflate support
import zlib
def deflate(data):   # zlib only provides the zlib compress format, not the deflate format;
  try:               # so on top of all there's this workaround:
    return zlib.decompress(data, -zlib.MAX_WBITS)
  except zlib.error:
    return zlib.decompress(data)

然后就简单了,

encoding_support = ContentEncodingProcessor
 #直接用opener打开网页,如果服务器支持gzip/defalte则自动解压缩 content = opener.open(url).read() opener = urllib2.build_opener( encoding_support, urllib2.HTTPHandler )  

7. 更方便地多线程

总结一文的确提及了一个简单的多线程模板,但是那个东东真正应用到程序里面去只会让程序变得支离破碎,不堪入目。在怎么更方便地进行多线程方面我也动了一番脑筋。先想想怎么进行多线程调用最方便呢?

1、用twisted进行异步I/O抓取

事实上更高效的抓取并非一定要用多线程,也可以使用异步I/O法:直接用twisted的getPage方法,然后分别加上异步I/O结束时的callback和errback方法即可。例如可以这么干:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
from twisted.web.client import getPage
from twisted.internet import reactor
  
links = [ 'http://www.verycd.com/topics/%d/'%i for i in range(5420,5430) ]
  
def parse_page(data,url):
    print len(data),url
  
def fetch_error(error,url):
    print error.getErrorMessage(),url
  
# 批量抓取链接
for url in links:
    getPage(url,timeout=5) \
        .addCallback(parse_page,url) \ #成功则调用parse_page方法
        .addErrback(fetch_error,url)     #失败则调用fetch_error方法
  
reactor.callLater(5, reactor.stop) #5秒钟后通知reactor结束程序
reactor.run()

twisted人如其名,写的代码实在是太扭曲了,非正常人所能接受,虽然这个简单的例子看上去还好;每次写twisted的程序整个人都扭曲了,累得不得了,文档等于没有,必须得看源码才知道怎么整,唉不提了。

如果要支持gzip/deflate,甚至做一些登陆的扩展,就得为twisted写个新的HTTPClientFactory类诸如此类,我这眉头真是大皱,遂放弃。有毅力者请自行尝试。

这篇讲怎么用twisted来进行批量网址处理的文章不错,由浅入深,深入浅出,可以一看。

2、设计一个简单的多线程抓取类

还是觉得在urllib之类python“本土”的东东里面折腾起来更舒服。试想一下,如果有个Fetcher类,你可以这么调用

1
2
3
4
5
6
f = Fetcher(threads=10) #设定下载线程数为10
for url in urls:
    f.push(url)  #把所有url推入下载队列
while f.taskleft(): #若还有未完成下载的线程
    content = f.pop()  #从下载完成队列中取出结果
    do_with(content) # 处理content内容

这 么个多线程调用简单明了,那么就这么设计吧,首先要有两个队列,用Queue搞定,多线程的基本架构也和“技巧总结”一文类似,push方法和pop方法 都比较好处理,都是直接用Queue的方法,taskleft则是如果有“正在运行的任务”或者”队列中的任务”则为是,也好办,于是代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
import urllib2
from threading import Thread,Lock
from Queue import Queue
import time
  
class Fetcher:
    def __init__(self,threads):
        self.opener = urllib2.build_opener(urllib2.HTTPHandler)
        self.lock = Lock() #线程锁
        self.q_req = Queue() #任务队列
        self.q_ans = Queue() #完成队列
        self.threads = threads
        for i in range(threads):
            t = Thread(target=self.threadget)
            t.setDaemon(True)
            t.start()
        self.running = 0
  
    def __del__(self): #解构时需等待两个队列完成
        time.sleep(0.5)
        self.q_req.join()
        self.q_ans.join()
  
    def taskleft(self):
        return self.q_req.qsize()+self.q_ans.qsize()+self.running
  
    def push(self,req):
        self.q_req.put(req)
  
    def pop(self):
        return self.q_ans.get()
  
    def threadget(self):
        while True:
            req = self.q_req.get()
            with self.lock: #要保证该操作的原子性,进入critical area
                self.running += 1
            try:
                ans = self.opener.open(req).read()
            except Exception, what:
                ans = ''
                print what
            self.q_ans.put((req,ans))
            with self.lock:
                self.running -= 1
            self.q_req.task_done()
            time.sleep(0.1) # don't spam
  
if __name__ == "__main__":
    links = [ 'http://www.verycd.com/topics/%d/'%i for i in range(5420,5430) ]
    f = Fetcher(threads=10)
    for url in links:
        f.push(url)
    while f.taskleft():
        url,content = f.pop()
        print url,len(content)

8. 一些琐碎的经验

1、连接池:

opener.open和urllib2.urlopen一样,都会新建一个http请求。通常情况下这不是什么问题,因为线性环境下,一秒钟可能 也就新生成一个请求;然而在多线程环境下,每秒钟可以是几十上百个请求,这么干只要几分钟,正常的有理智的服务器一定会封禁你的。

然而在正常的html请求时,保持同时和服务器几十个连接又是很正常的一件事,所以完全可以手动维护一个HttpConnection的池,然后每次抓取时从连接池里面选连接进行连接即可。

这里有一个取巧的方法,就是利用squid做代理服务器来进行抓取,则squid会自动为你维护连接池,还附带数据缓存功能,而且squid本来就是我每个服务器上面必装的东东,何必再自找麻烦写连接池呢。

2、设定线程的栈大小

栈大小的设定将非常显著地影响python的内存占用,python多线程不设置这个值会导致程序占用大量内存,这对openvz的vps来说非常致命。stack_size必须大于32768,实际上应该总要32768*2以上

from threading import stack_size
stack_size(32768*16)

3、设置失败后自动重试

def get(self,req,retries=3): try:
  response = self.opener.open(req) data = response.read()  except Exception , what: print what,req if retries>0: return self.get(req,retries-1) else: print 'GET Failed',req return '' return data

4、设置超时

import socket socket.setdefaulttimeout(10) #设置10秒后连接超时

5、登陆

登陆更加简化了,首先build_opener中要加入cookie支持,参考“总结”一文;如要登陆VeryCD,给Fetcher新增一个空方法login,并在init()中调用,然后继承Fetcher类并override login方法:

1
2
3
4
5
6
7
8
9
def login(self,username,password):
    import urllib
    data=urllib.urlencode({'username':username,
                           'password':password,
                           'continue':'http://www.verycd.com/',
                           'login_submit':u'登录'.encode('utf-8'),
                           'save_cookie':1,})
    url = 'http://www.verycd.com/signin'
    self.opener.open(url,data).read()

于是在Fetcher初始化时便会自动登录VeryCD网站。

9. 总结

如此,把上述所有小技巧都糅合起来就和我目前的私藏最终版的Fetcher类相差不远了,它支持多线程,gzip/deflate压缩,超时设置,自动重试,设置栈大小,自动登录等功能;代码简单,使用方便,性能也不俗,可谓居家旅行,杀人放火,咳咳,之必备工具。

之所以说和最终版差得不远,是因为最终版还有一个保留功能“马甲术”:多代理自动选择。看起来好像仅仅是一个random.choice的区别,其实包含了代理获取,代理验证,代理测速等诸多环节,这就是另一个故事了。

5.验证码的处理

碰到验证码咋办?这里分两种情况处理:

  • google那种验证码,凉拌
  • 简单的验证码:字符个数有限,只使用了简单的平移或旋转加噪音而没有扭曲的,这种还是有可能可以处理的,一般思路是旋转的转回来,噪音去掉,然 后划分单个字符,划分好了以后再通过特征提取的方法(例如PCA)降维并生成特征库,然后把验证码和特征库进行比较。这个比较复杂,一篇博文是说不完的, 这里就不展开了,具体做法请弄本相关教科书好好研究一下。
  • 事实上有些验证码还是很弱的,这里就不点名了,反正我通过2的方法提取过准确度非常高的验证码,所以2事实上是可行的。

6 gzip/deflate支持

现在的网页普遍支持gzip压缩,这往往可以解决大量传输时间,以VeryCD的主页为例,未压缩版本247K,压缩了以后45K,为原来的1/5。这就意味着抓取速度会快5倍。

然而python的urllib/urllib2默认都不支持压缩,要返回压缩格式,必须在request的header里面写明’accept- encoding’,然后读取response后更要检查header查看是否有’content-encoding’一项来判断是否需要解码,很繁琐琐 碎。如何让urllib2自动支持gzip, defalte呢?

其实可以继承BaseHanlder类,然后build_opener的方式来处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import urllib2
from gzip import GzipFile
from StringIO import StringIO
class ContentEncodingProcessor(urllib2.BaseHandler):
  """A handler to add gzip capabilities to urllib2 requests """
  
  # add headers to requests
  def http_request(self, req):
    req.add_header("Accept-Encoding", "gzip, deflate")
    return req
  
  # decode
  def http_response(self, req, resp):
    old_resp = resp
    # gzip
    if resp.headers.get("content-encoding") == "gzip":
        gz = GzipFile(
                    fileobj=StringIO(resp.read()),
                    mode="r"
                  )
        resp = urllib2.addinfourl(gz, old_resp.headers, old_resp.url, old_resp.code)
        resp.msg = old_resp.msg
    # deflate
    if resp.headers.get("content-encoding") == "deflate":
        gz = StringIO( deflate(resp.read()) )
        resp = urllib2.addinfourl(gz, old_resp.headers, old_resp.url, old_resp.code)  # 'class to add info() and
        resp.msg = old_resp.msg
    return resp
  
# deflate support
import zlib
def deflate(data):   # zlib only provides the zlib compress format, not the deflate format;
  try:               # so on top of all there's this workaround:
    return zlib.decompress(data, -zlib.MAX_WBITS)
  except zlib.error:
    return zlib.decompress(data)

然后就简单了,

encoding_support = ContentEncodingProcessor
 #直接用opener打开网页,如果服务器支持gzip/defalte则自动解压缩 content = opener.open(url).read() opener = urllib2.build_opener( encoding_support, urllib2.HTTPHandler )  

7. 更方便地多线程

总结一文的确提及了一个简单的多线程模板,但是那个东东真正应用到程序里面去只会让程序变得支离破碎,不堪入目。在怎么更方便地进行多线程方面我也动了一番脑筋。先想想怎么进行多线程调用最方便呢?

1、用twisted进行异步I/O抓取

事实上更高效的抓取并非一定要用多线程,也可以使用异步I/O法:直接用twisted的getPage方法,然后分别加上异步I/O结束时的callback和errback方法即可。例如可以这么干:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
from twisted.web.client import getPage
from twisted.internet import reactor
  
links = [ 'http://www.verycd.com/topics/%d/'%i for i in range(5420,5430) ]
  
def parse_page(data,url):
    print len(data),url
  
def fetch_error(error,url):
    print error.getErrorMessage(),url
  
# 批量抓取链接
for url in links:
    getPage(url,timeout=5) \
        .addCallback(parse_page,url) \ #成功则调用parse_page方法
        .addErrback(fetch_error,url)     #失败则调用fetch_error方法
  
reactor.callLater(5, reactor.stop) #5秒钟后通知reactor结束程序
reactor.run()

twisted人如其名,写的代码实在是太扭曲了,非正常人所能接受,虽然这个简单的例子看上去还好;每次写twisted的程序整个人都扭曲了,累得不得了,文档等于没有,必须得看源码才知道怎么整,唉不提了。

如果要支持gzip/deflate,甚至做一些登陆的扩展,就得为twisted写个新的HTTPClientFactory类诸如此类,我这眉头真是大皱,遂放弃。有毅力者请自行尝试。

这篇讲怎么用twisted来进行批量网址处理的文章不错,由浅入深,深入浅出,可以一看。

2、设计一个简单的多线程抓取类

还是觉得在urllib之类python“本土”的东东里面折腾起来更舒服。试想一下,如果有个Fetcher类,你可以这么调用

1
2
3
4
5
6
f = Fetcher(threads=10) #设定下载线程数为10
for url in urls:
    f.push(url)  #把所有url推入下载队列
while f.taskleft(): #若还有未完成下载的线程
    content = f.pop()  #从下载完成队列中取出结果
    do_with(content) # 处理content内容

这 么个多线程调用简单明了,那么就这么设计吧,首先要有两个队列,用Queue搞定,多线程的基本架构也和“技巧总结”一文类似,push方法和pop方法 都比较好处理,都是直接用Queue的方法,taskleft则是如果有“正在运行的任务”或者”队列中的任务”则为是,也好办,于是代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
import urllib2
from threading import Thread,Lock
from Queue import Queue
import time
  
class Fetcher:
    def __init__(self,threads):
        self.opener = urllib2.build_opener(urllib2.HTTPHandler)
        self.lock = Lock() #线程锁
        self.q_req = Queue() #任务队列
        self.q_ans = Queue() #完成队列
        self.threads = threads
        for i in range(threads):
            t = Thread(target=self.threadget)
            t.setDaemon(True)
            t.start()
        self.running = 0
  
    def __del__(self): #解构时需等待两个队列完成
        time.sleep(0.5)
        self.q_req.join()
        self.q_ans.join()
  
    def taskleft(self):
        return self.q_req.qsize()+self.q_ans.qsize()+self.running
  
    def push(self,req):
        self.q_req.put(req)
  
    def pop(self):
        return self.q_ans.get()
  
    def threadget(self):
        while True:
            req = self.q_req.get()
            with self.lock: #要保证该操作的原子性,进入critical area
                self.running += 1
            try:
                ans = self.opener.open(req).read()
            except Exception, what:
                ans = ''
                print what
            self.q_ans.put((req,ans))
            with self.lock:
                self.running -= 1
            self.q_req.task_done()
            time.sleep(0.1) # don't spam
  
if __name__ == "__main__":
    links = [ 'http://www.verycd.com/topics/%d/'%i for i in range(5420,5430) ]
    f = Fetcher(threads=10)
    for url in links:
        f.push(url)
    while f.taskleft():
        url,content = f.pop()
        print url,len(content)

8. 一些琐碎的经验

1、连接池:

opener.open和urllib2.urlopen一样,都会新建一个http请求。通常情况下这不是什么问题,因为线性环境下,一秒钟可能 也就新生成一个请求;然而在多线程环境下,每秒钟可以是几十上百个请求,这么干只要几分钟,正常的有理智的服务器一定会封禁你的。

然而在正常的html请求时,保持同时和服务器几十个连接又是很正常的一件事,所以完全可以手动维护一个HttpConnection的池,然后每次抓取时从连接池里面选连接进行连接即可。

这里有一个取巧的方法,就是利用squid做代理服务器来进行抓取,则squid会自动为你维护连接池,还附带数据缓存功能,而且squid本来就是我每个服务器上面必装的东东,何必再自找麻烦写连接池呢。

2、设定线程的栈大小

栈大小的设定将非常显著地影响python的内存占用,python多线程不设置这个值会导致程序占用大量内存,这对openvz的vps来说非常致命。stack_size必须大于32768,实际上应该总要32768*2以上

from threading import stack_size
stack_size(32768*16)

3、设置失败后自动重试

def get(self,req,retries=3): try:
  response = self.opener.open(req) data = response.read()  except Exception , what: print what,req if retries>0: return self.get(req,retries-1) else: print 'GET Failed',req return '' return data

4、设置超时

import socket socket.setdefaulttimeout(10) #设置10秒后连接超时

5、登陆

登陆更加简化了,首先build_opener中要加入cookie支持,参考“总结”一文;如要登陆VeryCD,给Fetcher新增一个空方法login,并在init()中调用,然后继承Fetcher类并override login方法:

1
2
3
4
5
6
7
8
9
def login(self,username,password):
    import urllib
    data=urllib.urlencode({'username':username,
                           'password':password,
                           'continue':'http://www.verycd.com/',
                           'login_submit':u'登录'.encode('utf-8'),
                           'save_cookie':1,})
    url = 'http://www.verycd.com/signin'
    self.opener.open(url,data).read()

于是在Fetcher初始化时便会自动登录VeryCD网站。

9. 总结

如此,把上述所有小技巧都糅合起来就和我目前的私藏最终版的Fetcher类相差不远了,它支持多线程,gzip/deflate压缩,超时设置,自动重试,设置栈大小,自动登录等功能;代码简单,使用方便,性能也不俗,可谓居家旅行,杀人放火,咳咳,之必备工具。

之所以说和最终版差得不远,是因为最终版还有一个保留功能“马甲术”:多代理自动选择。看起来好像仅仅是一个random.choice的区别,其实包含了代理获取,代理验证,代理测速等诸多环节,这就是另一个故事了。

编译过程中要下载,没编译成功。

svn checkout http://py-leveldb.googlecode.com/svn/trunk/

svn co http://py-leveldb.googlecode.com/svn/trunk/ py-leveldb

重新找到安装包:

wget https://pypi.python.org/packages/source/l/leveldb/leveldb-0.193.tar.gz
tar -zxvf leveldb-0.193.tar.gz 
cd leveldb-0.193
python setup.py build
sudo python setup.py install
安装成功

这个爬虫是从outofmemory看到的,只有100行,内容是抓取淘宝商品信息,包括商品名、卖家id、地区、价格等信息,json格式,作者说他曾经抓取到了一千万条信息。

出于对这个爬虫能力的感叹,我好奇的对它进行了分析,发现原理是如此的简单,感叹python的强大之余,好也把分析的心得记录一下,引为后来的经验。

现在这个爬虫能不能用就没有保证了,不过没有关系,只是作为一个学习的例子。

#-*- coding: UTF-8 -*-
import time
import leveldb
from urllib.parse import quote_plus
import re
import json
import itertools
import sys
import requests
from queue import Queue
from threading import Thread

URL_BASE = 'http://s.m.taobao.com/search?q={}&n=200&m=api4h5&style=list&page={}'

def url_get(url):
    # print('GET ' + url)
    header = dict()
    header['Accept'] = 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'
    header['Accept-Encoding'] = 'gzip,deflate,sdch'
    header['Accept-Language'] = 'en-US,en;q=0.8'
    header['Connection'] = 'keep-alive'
    header['DNT'] = '1'
    #header['User-Agent'] = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/28.0.1500.71 Safari/537.36'
    header['User-Agent'] = 'Mozilla/12.0 (compatible; MSIE 8.0; Windows NT)'
    return requests.get(url, timeout = 5, headers = header).text

def item_thread(cate_queue, db_cate, db_item):
    while True:
        try:
            cate = cate_queue.get()
            post_exist = True
            try:
                state = db_cate.Get(cate.encode('utf-8'))
                if state != b'OK': post_exist = False
            except:
                post_exist = False
            if post_exist == True:
                print('cate-{}: {} already exists ... Ignore'.format(cate, title))
                continue
            db_cate.Put(cate.encode('utf-8'), b'crawling')
            for item_page in itertools.count(1):
                url = URL_BASE.format(quote_plus(cate), item_page)
                for tr in range(5):
                    try:
                        items_obj = json.loads(url_get(url))
                        break
                    except KeyboardInterrupt:
                        quit()
                    except Exception as e:
                        if tr == 4: raise e
                if len(items_obj['listItem']) == 0: break
                for item in items_obj['listItem']:
                    item_obj = dict(
                        _id = int(item['itemNumId']),
                        name = item['name'],
                        price = float(item['price']),
                        query = cate,
                        category = int(item['category']) if item['category'] != '' else 0,
                        nick = item['nick'],
                        area = item['area'])
                    db_item.Put(str(item_obj['_id']).encode('utf-8'),
                                json.dumps(item_obj, ensure_ascii = False).encode('utf-8'))

                print('Get {} items from {}: {}'.format(len(items_obj['listItem']), cate, item_page))

                if 'nav' in items_obj:
                    for na in items_obj['nav']['navCatList']:
                        try:
                            db_cate.Get(na['name'].encode('utf-8'))
                        except:
                            db_cate.Put(na['name'].encode('utf-8'), b'waiting')
            db_cate.Put(cate.encode('utf-8'), b'OK')
            print(cate, 'OK')
        except KeyboardInterrupt:
            break
        except Exception as e:
            print('An {} exception occured'.format(e))

def cate_thread(cate_queue, db_cate):
    while True:
        try:
            for key, value in db_cate.RangeIter():
                if value != b'OK':
                    print('CateThread: put {} into queue'.format(key.decode('utf-8')))
                    cate_queue.put(key.decode('utf-8'))
            time.sleep(10)
        except KeyboardInterrupt:
            break
        except Exception as e:
            print('CateThread: {}'.format(e))

if __name__ == '__main__':
    db_cate = leveldb.LevelDB('./taobao-cate')
    db_item = leveldb.LevelDB('./taobao-item')

    orig_cate = '正装'.encode('gb2312')
    try:
        db_cate.Get(orig_cate.encode('utf-8'))
    except:
        db_cate.Put(orig_cate.encode('utf-8'), b'waiting')
    cate_queue = Queue(maxsize = 1000)
    cate_th = Thread(target = cate_thread, args = (cate_queue, db_cate))
    cate_th.start()
    item_th = [Thread(target = item_thread, args = (cate_queue, db_cate, db_item)) for _ in range(5)]
    for item_t in item_th:
        item_t.start()
    cate_th.join()

分析

一个只有一百行的代码,也不用花太多心思就可以看懂了,不过其中一些有意思的心得还是可以分享下。

  1. 使用vim打开,在使用了fold功能后,可以清晰的看到代码由import部分,三个自定义函数和一个main了,所以可以直接从main开始看。
  2. main建立(也可以是已经建立的)了两个数据库,分别是db_catedb_item,还定义了开始时抓取的商品种类(category)orig_cate
  3. 先在db_cate中尝试访问下orig_cate,如果没有这个种类,就加入这个种类,属性设置为waitingleveldb就是一个key-value数据库,使用起来非常的方便。
  4. 建立一个种类的队列cate_queue,然后就建立一个种类的线程cate_th,会调用自己定义的一个函数cate_thread,参数是队列和种类数据库。
  5. 再建立5个线程item_th,调用定义的item_thread函数参数是队列和两个数据库。
  6. 最后会等待线程终止后退出。
  7. 这时就可以看前面的定义的函数cate_thread,这个函数会重复从种类数据库cate_db中遍历取出种类名,然后看这个种类是不是已经抓取过了,如果没有,就加入到种类队列cate_queue
  8. 再看函数item_thead,从种类队列cate_queue中取出一个种类,再从种类数据库中查看其状态,如果是ok,就取下一个种类;如果不是ok,就标记为crawling,然后就使用这个类别和一个遍历的序号就可以获得一个网址,然后就重复的尝试获取这个页面的数据,再分析,保存到item_db中,再把种类在cate_db中标记为ok,也就是完成,同时,把页面有的种类信息放到cate_db数据库中。
  9. 这样这个爬虫就可以一直工作了。

总结

这个爬虫的结构很清晰,一个数据库用来保存种类的状态信息,一个数据库保存获取到的信息,一个队列作为进程间通信的工具,数据库使用key-value,网页抓取使用requests。参考这个结构,很多爬虫都可以写出来了。

爬虫总结_python的更多相关文章

  1. Python爬虫视频教程

    ├─第1章_[第0周]网络爬虫之前奏 │ ├─第1节_"网络爬虫"课程内容导学 │ │ 第1部分_全课程内容导学.mp4 │ │ 第2部分_全课程内容导学(WS00单元)学习资料. ...

  2. Python BeautifulSoup4 爬虫基础、多线程学习

    针对 崔庆才老师 的 https://ssr1.scrape.center 的爬虫基础练习.Threading多线程库.Time库.json库.BeautifulSoup4 爬虫库.py基本语法

  3. 设计爬虫Hawk背后的故事

    本文写于圣诞节北京下午慵懒的午后.本文偏技术向,不过应该大部分人能看懂. 五年之痒 2016年,能记入个人年终总结的事情没几件,其中一个便是开源了Hawk.我花不少时间优化和推广它,得到的评价还算比较 ...

  4. Scrapy框架爬虫初探——中关村在线手机参数数据爬取

    关于Scrapy如何安装部署的文章已经相当多了,但是网上实战的例子还不是很多,近来正好在学习该爬虫框架,就简单写了个Spider Demo来实践.作为硬件数码控,我选择了经常光顾的中关村在线的手机页面 ...

  5. Python 爬虫模拟登陆知乎

    在之前写过一篇使用python爬虫爬取电影天堂资源的博客,重点是如何解析页面和提高爬虫的效率.由于电影天堂上的资源获取权限是所有人都一样的,所以不需要进行登录验证操作,写完那篇文章后又花了些时间研究了 ...

  6. scrapy爬虫docker部署

    spider_docker 接我上篇博客,为爬虫引用创建container,包括的模块:scrapy, mongo, celery, rabbitmq,连接https://github.com/Liu ...

  7. scrapy 知乎用户信息爬虫

    zhihu_spider 此项目的功能是爬取知乎用户信息以及人际拓扑关系,爬虫框架使用scrapy,数据存储使用mongo,下载这些数据感觉也没什么用,就当为大家学习scrapy提供一个例子吧.代码地 ...

  8. 120项改进:开源超级爬虫Hawk 2.0 重磅发布!

    沙漠君在历时半年,修改无数bug,更新一票新功能后,在今天隆重推出最新改进的超级爬虫Hawk 2.0! 啥?你不知道Hawk干吗用的? 这是采集数据的挖掘机,网络猎杀的重狙!半年多以前,沙漠君写了一篇 ...

  9. Python爬虫小白入门(四)PhatomJS+Selenium第一篇

    一.前言 在上一篇博文中,我们的爬虫面临着一个问题,在爬取Unsplash网站的时候,由于网站是下拉刷新,并没有分页.所以不能够通过页码获取页面的url来分别发送网络请求.我也尝试了其他方式,比如下拉 ...

随机推荐

  1. 利用xshell密钥管理服务器远程登录+VIM dd命令操作之伤之再伤

    1.打开Xshell界面,中文界面方便操作,菜单栏:工具——新建用户密钥生成向导 2.密钥类型选择RSA,密钥长度选择2048位,单击下一步继续: 3.很快生成公钥对,单击下一步继续: 4.密钥名称可 ...

  2. 四、Nginx负载均衡upstream

    user www; worker_processes ; error_log /usr/local/nginx/logs/error.log crit; pid /usr/local/nginx/lo ...

  3. IOS 新消息通知提示-声音、震动

    一.APNS 1.注册 [[UIApplication sharedApplication] registerForRemoteNotificationTypes:UIRemoteNotificati ...

  4. 高级UIKit-04(NSUserDefaults、NSKeyedArchiver、对象归档方法)

    [day05_1_UserDefault]:判断应用程序是否是第一次运行 NSUserDefaults:用来保存应用程序的配置信息如:程序运行次数,用户登陆信息等. // 使用系统提供的NSUserD ...

  5. java web从零单排第二十一期《Hibernate》主键的生成方式,用户增加与显示用户列表

    1.新建register.jsp <%@ page language="java" import="java.util.*" pageEncoding=& ...

  6. 柔性数组-读《深度探索C++对象模型》有感

    最近在看<深度探索C++对象模型>,对于Struct的用法中,发现有一些地方值得我们借鉴的地方,特此和大家分享一下,此间内容包含了网上搜集的一些资料,同时感谢提供这些信息的作者. 原文如下 ...

  7. Java7新特性(一)Coin

    1.语法糖 数字下划线   2.switch语句中的String   3.multicatch   4.final重抛 对比上份代码   5.try-with-resources(TWR) AutoC ...

  8. android设置eclipse中的自动提示功能

    菜单window->Preferences->Java->Editor->Content Assist->Enable auto activation 选项要打上勾 (并 ...

  9. C#中System.Globalization.DateTimeFormatInfo.InvariantInfo怎么用

    原文  C#中System.Globalization.DateTimeFormatInfo.InvariantInfo怎么用 在开发的时候,碰到下面这样一个问题: 在程序中显示当前系统时间,但是有一 ...

  10. 同一个form里,不管哪个 submit 都是直接提交form表单里的内容

    要达到你的目的,就不能用类型为 submit 的按钮,要用 button,然后加onclick 方法来自定义预处理参数,然后再调用 submit 方法提交表单,比如 <script type=& ...