代码改变世界

也写一个简单的网络爬虫

2014-12-27 13:12  cuiluo  阅读(3390)  评论(1编辑  收藏  举报

引子

在cnblogs也混了许久,不过碍于平日工作太忙,一篇随笔也没有写过。最近经常感觉到自己曾经积累过的经验逐步的丢失,于是开通了博客,主要是记录一下自己在业余时间里玩的一些东西。

缘起

言归正传。某次在在某高校网站闲逛,看到了一些有趣的东西想要保存起来,但是却分散在各个页面,难以下手。使用baidu,google却有无法避免的搜索到此站点之外的内容。于是就想如果有一个爬虫,可以抓取指定域名的某些感兴趣的内容。在网上简单搜索了一下,简单的都不满意,功能强大的又太复杂,就想自己写一个。

抓取HTML页面

一个爬虫最重要的部分可能就是如何抓取HTML页面了,python中使用urllib库可以轻松的实现html页面的抓取,再使用正则表达式或者HTMLParser库找出自己感兴趣的部分做进一步处理。下面是一个转来的小例子(出处为http://www.cnblogs.com/fnng/p/3576154.html,在此深表感谢)

import re
import urllib

def getHtml(url):
    page = urllib.urlopen(url)
    html = page.read()
    return html

def getImg(html):
    reg = r'src="(.+?\.jpg)" pic_ext'
    imgre = re.compile(reg)
    imglist = re.findall(imgre,html)
    return imglist      
   
html = getHtml("http://tieba.baidu.com/p/2460150866")
print getImg(html)

此代码抓取了页面中的jpg文件,原文中后面还有一段保存在本地的代码,这里就不转了。

不过,这仅仅是实现了指定页面抓取,简单搜索的爬虫例子基本都是到此为止,其实是没有真正“爬”起来。

待解决的问题

所谓的爬虫,最重要的功能是在整个互联网上搜索任何的页面,只要给定了一个(或多个)线索。这里面涉及到的问题主要是:

1, 解析HTML页面找出里面的url和感兴趣的东西(见上文)

2, 记住目前已经访问过的页面(后面再遇到就直接跳过),同时逐个的访问(1)中新发现的url(类似递归)

3, 达到某种条件之后停止搜索,例如只搜索500个url。

问题1大体上已经解决,问题3相对容易。对于问题2,本质上其实就是一个图的遍历,整个互联网可以看作一张复杂的图,每个url是一个结点,所谓爬虫,就是按照某种规则对图进行遍历而已。我们知道图的遍历有深度优先和广度优先两种主要算法,这里我们选择广度优先,主要原因是,根据观察,一般来说,最重要信息(最关心的)往往和线索离的很近,而使用深度优先,则容易走上歧途。

页面解析

对于一个爬虫,页面的解析可以分成两部分,一个是对url的解析,决定了后面往哪里“爬”,一个就是对用户本身关心的内容的解析。使用正则表达式是很好的选择,可惜我实在不精于此道(需要进一步加强,hee),试验了几次都不满意,而网上也没有搜索到正好可以解决问题的。于是决定使用HTMLParser库。这个库本身已经对解析做了封装,提供了一组虚方法,只要继承并实现了这些方法,就可以很好的解析。

#coding=utf-8

from html.parser import HTMLParser

class UrlParser(HTMLParser):
    def __init__(self, 
                 filtrules = {'postfix' : ['.', 'html', 'shtml', 'asp', 'php', 'jsp', 'com', 'cn', 'net', 'org', 'edu', 'gov']}):
        HTMLParser.__init__(self)
        self.__urls = list()
        self.__filtrules = filtrules
        
    def setfilterrules(self, rules):
        self.__filtrules = rules
 
    def handle_starttag(self, tag, attrs):
        if(tag == 'a' or tag == 'frame'):
            self.__parse_href_attr(attrs)              
    
    def geturls(self):
        list(set(self.__urls))
        return list(set(self.__urls))
    
    def __parse_href_attr(self, attrs):
        for attr in attrs:
                if(attr[0] == 'href' and self.__match_url(attr[1])):
                    self.__urls.append(attr[1])
    
    def __match_url(self, text):
        return FilterManager(self.__filtrules).matchpostfix('postfix', text)

其中 def handle_starttag(self, tag, attrs): 即为从基类继承来的方法,用户处理开始标签,由于这个类是为了解析出url的,所以这里我们只关心'a'标签和‘frame’标签,而在属性中,只关心‘href’。但是按照这样的规则,许多本不是真正网址的url也会被记录下来。所以需要有一个过滤规则。

过滤规则

 由于玩不转正则表达式,就自己写了一个过滤器和一套过滤规则,主要是过滤前缀/后缀/数据的,先看代码:

class FilterManager():
    def __init__(self, rules):
        self.__rules = rules
    
    def __str__(self):
        return self.__rules.__str__()
    
    def getrules(self):
        return self.__rules
    
    def updaterules(self, newrules):
        self.__rules.update(newrules)
            
    def removerules(self, delkeys):
        for key in delkeys:
            del(self.__rules[key])
            
    def clearrules(self):
        self.__rules.clear()
    
    def matchprefix(self, key, source):
        return self.__match(key, source, self.__handle_match_prefix)
    
    def matchpostfix(self, key, source):
        return self.__match(key, source, self.__handle_match_postfix)
    
    def matchdata(self, key, source):
        return self.__match(key, source, self.__handle_match_data)
    
    def __match(self, key, source, handle_match):
        try:
            if self.__rules.get(key):
                rule = self.__rules[key]
                return handle_match(rule, source)
        except:
            print('rules format error.')
        return True 
    
    def __handle_match_prefix(self, rule, source):
        return source.split(rule[0])[0] in rule[1:]
    
    def __handle_match_postfix(self, rule, source):
        return source.split(rule[0])[-1] in rule[1:]
    
    def __handle_match_data(self, rule, source):
        if rule[0] == '&':
            for word in rule[1:]:
                if not word in source:
                    return False
            return True
        else:
            for word in rule[1:]:
                if word in source:
                    return True
            return False

这里面rules是一个字典,里面是既定的过滤规则,而从中分析中传入的数据是否符合筛选条件。我开始想做一个统一的规则格式,可以不去区分前缀还是后缀等,但是发现这样规则就是很复杂,而对我们这个简单的爬虫来说,这三个方法也基本够用了,待后面发现需要扩充,再修改吧。

过滤方法的大体规则为:

1,关键字,目前支持三个'prefix' , 'postfix', 'data' 分别代报要过滤的是前缀,后缀还是数据

2, 分隔符/提示符, 表示如何分隔传入的数据,或者对数据进行如何搜索

3, 匹配符,即传入的数据中是否包含这些预定义的字段。

例如:rule = {'prefix' : ['://', 'http', 'https'], 'postfix' : ['.', 'jpg', 'png'], 'data' : ['&','Python', 'new']}

表示,此规则可以过滤出前缀为http, https的url, 后缀可以是jpg,png的url,或者包含Python 且包含 new的文字内容。

这段代码后面过滤data的部分写的很不满意,感觉重复很多,一时还没想到好方法消除,留作后面看吧。

FilterManager的测试用例,有助于理解这个我人为规定的复杂东西。详见末尾。

爬起来

终于到这一步了,我们使用一个dic保存已经访问过的url(选择字典是因为感觉其是使用哈希表实现的,访问速度快,不过没有考证),之后进行url解析。

class Spider(object):
    def __init__(self):
        self.__todocollection = list()
        self.__visitedtable = dict()
        self.__urlparser = UrlParser()
        self.__maxvisitedurls = 15
    
    def setfiltrules(self, rules):
        self.__urlparser.setfilterrules(rules)    
        
    def feed(self, root):
        self.__todocollection.append(root)
        self.__run()
        
    # Overridable -- handle do your own business
    def handle_do(self, htmlcode):
        pass
     
    def setmaxvisitedurls(self, maxvisitedurls):
        self.__maxvisitedurls = maxvisitedurls
                   
    def getvisitedurls(self):
        return self.__visitedtable.keys()
    
    def __run(self):
        maxcouter = 0
        while len(self.__todocollection) > 0 and maxcouter < self.__maxvisitedurls:
            if self.__try_deal_with_one_url(self.__todocollection.pop(0)):
                maxcouter += 1
    
    def __try_deal_with_one_url(self, url):
        if not self.__visitedtable.get(url):
            self.__parse_page(url)
            self.__visitedtable[url] = True
            self.__todocollection += self.__urlparser.geturls()
            return True
        return False
        
    def __parse_page(self, url):
        text = self.__get_html_text(url)
        self.handle_do(text)
        self.__urlparser.feed(text)
        
    def __get_html_text(self, url):
        filtermanager = FilterManager({'prefix' : ['://', 'http', 'https']})
        if filtermanager.matchprefix('prefix', url):
            return self.__get_html_text_from_net(url)
        else:
            return self.__get_html_text_from_local(url)  
     
    def __get_html_text_from_net(self, url):
        try:
            page = urllib.request.urlopen(url)
        except:
            print("url request error, please check your network.")
            return str()
        
        text = page.read()
        encoding = chardet.detect(text)['encoding']      
        return text.decode(encoding, 'ignore') 
    
    def __get_html_text_from_local(self, filepath):
        try:
            page = open(filepath)
        except:
            print("no such file, please check your file system.")
            return str()
        
        text = page.read()
        page.close()
        return text     

这里面有几个问题:

1, def handle_do(self, htmlcode): 方法是为后面扩展使用,可以override它解析自己关心的内容。这里面其实有点小体大作,似乎不需要这样复杂,在Parser上做做文章应该可以解决大部分问题,不过还是留下了。

2,一个很严重的问题就是编解码。不同的html页面的编码方式可能不同,主流不过是utf-8,gb2312等,但是我们无法预先知道。这里使用了python库chardet,自动识别编码格式。这个库需要自己下载安装,这里不细说了。

3, 这里做了一个处理,如果被解析的url不符合过滤规则,则认为是本地文件,在本地搜索,这个主要是为了测试。

4, 搜索的停止条件默认为访问15个url。主要也是为了测试,否则运行速度似蜗牛。

5,一个很严重的缺陷是没有处理http的错误码,目前是仅仅忽略掉。这样有些返回301错误的页面,本应该在报头中找到新的地址继续访问的,现在这样的处理,就被错过了。

一个例子

先给一个使用Spider的简单例子,获取到所有被访问的html页面的title。

class TitleSpider(Spider):
    def __init__(self):
        Spider.__init__(self);
        self.__titleparser = TitleParser()
    
    def setfiltrules(self, rules):
        self.__titleparser.setfilterrules(rules)     
        
    def handle_do(self, htmlcode):
        self.__titleparser.feed(htmlcode)
    
    def gettitles(self):
        return self.__titleparser.gettitles()
                                              
class TitleParser(HTMLParser):
    def __init__(self, filtrules = {}):
        HTMLParser.__init__(self)
        self.__istitle = False
        self.__titles = list()
        self.__filtrules = filtrules;
    
    def setfilterrules(self, rules):
        self.__filtrules = rules
        
    def handle_starttag(self, tag, attrs):
        if(tag == 'title'):
            self.__istitle = True
            
    def handle_data(self, data):
        if self.__istitle and self.__match_data(data):
            self.__titles.append(data)
        self.__istitle = False
            
    def gettitles(self):
        return self.__titles      
    
    def __match_data(self, data):
        return FilterManager(self.__filtrules).matchdata('data', data)

这里TitleSpider 继承了Spider,并override handle_do方法,TitleParser则负责解析‘title’ 标签。

另一个略有点用处的例子

这个例子是下载访问到的html页面中的jpg文件。

class ImgSpider(Spider):
    def __init__(self):
        Spider.__init__(self);
        self.__imgparser = ImgParser()
        
    def handle_do(self, htmlcode):
        self.__imgparser.feed(htmlcode)
  
class ImgParser(HTMLParser):
    def __init__(self):
        HTMLParser.__init__(self)
        self.imgnameindex = 0
        
    def handle_starttag(self, tag, attrs):
        if(tag == 'img'):
            self.__parse_attrs(attrs)   
                    
    def __parse_attrs(self, attrs):
        for attr in attrs:
            self.__parse_one_attr(attr)
    
    def __parse_one_attr(self, attr):
        filtermanager = FilterManager({'postfix' : ['.', 'jpg']})
        if(attr[0] == 'src' and filtermanager.matchpostfix('postfix', attr[1])):
            self.__download_jpg(attr[1])
                           
    def __download_jpg(self, url):
        try:
            urllib.request.urlretrieve(url,'%s.jpg' % self.imgnameindex)
            self.imgnameindex += 1
        except:   
            pass

这个例子写的很简单,也有些问题,例如没有过滤掉相同的jpg文件,这个可以参考解析url的机制不难解决。而且这里可以看出,使用强制继承的方式的坏处,ImgSpider类基本都是废话,基类Spider如果支持直接传入ImgParser会很好。不过此刻突然没了兴致,留作以后重构吧。

main

if __name__ == '__main__': 
    #spider = TitleSpider()
    #spider.feed("http://mil.sohu.com/s2014/jjjs/index.shtml")
    #print(spider.gettitles())
    
    spider = ImgSpider()
    spider.feed("http://gaoqing.la")
    print(spider.getvisitedurls())

代码和测试用例

代码和测试用例托管在 https://git.oschina.net/augustus/MiniSpider.git

可以使用git clone下来

用例写的简单且不正交,只是需要的时候写了些,同时我删除了.project文件。