自学python有一段时间了,做过的东西还不多,最近开始研究爬虫,想自己写一个爬百度贴吧的帖子内容,然后对帖子做分词和词频统计,看看这个吧热议的关键词都有哪些。百度了好多资料和视频,学到了不少东西,但也生出了一些问题:

1、http请求用python自带的urllib,也可以用requests,哪个更好用?

2、html解析可以用正则表达式,也可以用xpath,哪个效率更高?

根据网上资料的说法,requests相对更好用,因为很多功能已经封装好了,性能上与urllib也没什么区别,而正则表达式通常要比xpath效率更高。不过实践出真知,分别用两种方式写出来然后对比一下。爬取的目标是我很喜欢的一个游戏——英雄无敌3的贴吧,从第10页爬到30页,只爬帖子、回帖以及楼中楼内容的文字部分。首先用建议初学者使用的urllib加正则表达式写了一版:

# -*- coding: utf-8 -*-

from urllib import request

import re

import queue

import os

import math

import threading

from time import sleep

import datetime


baseurl="https://tieba.baidu.com" #贴吧页面url的通用前缀

q=queue.Queue() #保存帖子链接的队列

MAX_WAIT=10 #解析线程的最大等待时间

reg=re.compile('<[^>]*>') #去除html标签的正则表达式


#封装的获取html字符串的函数

def get_html(url):

    response=request.urlopen(url)

    html=response.read().decode('utf-8')

    return html


#采集url的线程,thnum线程id,startpage开始采集的页数,step单个线程采集页数间隔(与线程个数相同),maxpage采集结束的页数,url采集的贴吧的url后缀

class getlinkthread(threading.Thread):

    def __init__(self,thnum,startpage,step,maxpage,url):

        threading.Thread.__init__(self)

        self.thnum=thnum

        self.startpage=startpage

        self.step=step

        self.maxpage=maxpage

        self.url=url

    def run(self):

        mm=math.ceil((self.maxpage-self.startpage)/self.step) #计算循环的范围

        for i in range(0,mm):

            startnum=self.startpage+self.thnum+i*self.step #开始页数

            tempurl=baseurl+self.url+"&pn="+str(startnum*50) #构造每一页的url

            print("Thread %s is getting %s"%(self.thnum,tempurl))

            try:

                temphtml=get_html(tempurl)

                turls = re.findall(r'rel="noreferrer" href="(/p/[0-9]*?)"', temphtml,re.S) #获取当前页的所有帖子链接

                for tu in turls: #入队列

                    q.put(tu)

            except:

                print("%s get failed"%(tempurl))

                pass

            sleep(1)


#解析url的线程,thrnum线程id,barname贴吧名,用来构造文件保存路径

class parselinkthread(threading.Thread):

    def __init__(self,thrnum,barname):

        threading.Thread.__init__(self)

        self.thrnum=thrnum

        self.barname=barname

    def run(self):

        waittime=0

        while True:

            if q.empty() and waittime<MAX_WAIT: #队列为空且等待没有超过MAX_WAIT时,继续等待

                sleep(1)

                waittime=waittime+1

                print("Thr %s wait for %s secs"%(self.thrnum,waittime))

            elif waittime>=MAX_WAIT: #等待超过MAX_WAIT时,线程退出

                print("Thr %s quit"%(self.thrnum))

                break

            else: #队列不为空时,重置等待时间,从队列中取帖子url,进行解析

                waittime=0

                item=q.get()

                self.dotask(item)

    def dotask(self,item):

        print("Thr %s is collecting %s"%(self.thrnum,item))

        self.savepost(item,self.barname)

    #抓取一页的内容,包括帖子及楼中楼,入参为页面url和帖子id,返回值为帖子的内容字符串

    def getpagestr(self,url,tid):

        html=get_html(url)

        result1 = re.findall(r'class="d_post_content j_d_post_content ">(.*?)</div>', html,re.S)

        result2 = re.findall(r'class="j_lzl_r p_reply" data-field=\'{(.*?)}\'', html,re.S)

        pagestr=""

        for res in result1:

            pagestr=pagestr+reg.sub('',res)+"\n"  #先整合帖子内容

        for res in result2:

            if 'null' not in res:  #若有楼中楼,即层数不为null

                pid=res.split(",")[0].split(":")[1]  #楼中楼id

                numreply=int(res.split(",")[1].split(":")[1])  #楼中楼层数

                tpage=math.ceil(numreply/10) #计算楼中楼页数,每页10条,用于遍历楼中楼的每一页

                for i in range(1,tpage+1):

                    replyurl="https://tieba.baidu.com/p/comment?tid="+tid+"&pid="+pid+"&pn="+str(i) #构造楼中楼url

                    htmlreply=get_html(replyurl)

                    replyresult=re.findall(r'<span class="lzl_content_main">(.*?)</span>', htmlreply,re.S) #获取楼中楼的评论内容

                    for reply in replyresult:

                        pagestr=pagestr+reg.sub('',reply)+"\n"

        return pagestr

    #爬取一个帖子,入参为帖子后缀url,以及贴吧名

    def savepost(self,url,barname):

        tid=url.replace("/p/","")

        filename = "E:/tieba/"+barname+"/"+tid+".txt" #文件保存路径

        if os.path.exists(filename): #判断是否已经爬取过当前帖子

            return

        print(baseurl+url)

        try:

            html=get_html(baseurl+url)

            findreault = re.findall(r'([0-9]*)</span>页', html,re.S) #获取当前帖子页数

            numpage=findreault[0]

            poststr=self.getpagestr(baseurl+url,tid) #获取第一页

            if int(numpage)>1:

                for i in range(2,int(numpage)+1): 

                    tempurl=baseurl+url+"?pn="+str(i) #构造每一页的url,循环获取每一页

                    pagestr=self.getpagestr(tempurl,tid)

                    poststr=poststr+pagestr

            with open(filename,'w',encoding="utf-8") as f: #写文件

                f.write(poststr)

        except:

            print("get %s failed"%(baseurl+url))

            pass


if __name__ == '__main__':

    starttime = datetime.datetime.now()

    testurl="/f?kw=%E8%8B%B1%E9%9B%84%E6%97%A0%E6%95%8C3&fr=index&fp=0&ie=utf-8"

    barname="英雄无敌3"

    html=get_html(baseurl+testurl)

    numpost=re.findall(r'共有主题数<span class="red_text">([0-9]*?)</span>个', html,re.S)[0] #获取帖子总数

    numpage=math.ceil(int(numpost)/50) #计算页数

    path = "E:/tieba/"+barname

    folder=os.path.exists(path)

    if not folder:

        os.makedirs(path)

    for i in range(3): #创建获取帖子链接的线程

        t=getlinkthread(i,10,3,30,testurl)

        t.start()

    for j in range(3): #创建解析帖子链接的线程

        t1=parselinkthread(j,barname)

        t1.start()

    t1.join()

    endtime = datetime.datetime.now()

    print(endtime-starttime)

然后用requests加xpath写了一版:

# -*- coding: utf-8 -*-

import requests

from lxml import etree

import re

import queue

import os

import math

import threading

import datetime

from time import sleep


baseurl="https://tieba.baidu.com" #贴吧页面url的通用前缀

q=queue.Queue() #保存帖子链接的队列

MAX_WAIT=10 #解析线程的最大等待时间

reg=re.compile('<[^>]*>') #去除html标签的正则表达式


#封装的获取etree对象的函数

def get_url_text(url):

    response=requests.get(url)

    return etree.HTML(response.text)


#封装的获取json对象的函数

def get_url_json(url):

    response=requests.get(url)

    return response.json()


#封装的通过xpath解析的函数

def parse_html(html,xpathstr):

    result = html.xpath(xpathstr)

    return result


#采集url的线程,thnum线程id,startpage开始采集的页数,step单个线程采集页数间隔(与线程个数相同),maxpage采集结束的页数,url采集的贴吧的url后缀

class getlinkthread(threading.Thread):

    def __init__(self,thnum,startpage,step,maxpage,url):

        threading.Thread.__init__(self)

        self.thnum=thnum

        self.startpage=startpage

        self.step=step

        self.maxpage=maxpage

        self.url=url

    def run(self):

        mm=math.ceil((self.maxpage-self.startpage)/self.step) #计算循环的范围

        for i in range(0,mm):

            startnum=self.startpage+self.thnum+i*self.step #开始页数

            tempurl=baseurl+self.url+"&pn="+str(startnum*50) #构造每一页的url

            print("Thread %s is getting %s"%(self.thnum,tempurl))

            try:

                temphtml=get_url_text(tempurl)

                turls = parse_html(temphtml, '//*[@class="threadlist_title pull_left j_th_tit "]/a/@href') #通过xpath解析,获取当前页所有帖子的url后缀

                for tu in turls: #入队列

                    q.put(tu)

            except:

                print("%s get failed"%(tempurl))

                pass

            sleep(1)


#解析url的线程,thrnum线程id,barname贴吧名,用来构造文件保存路径

class parselinkthread(threading.Thread):

    def __init__(self,thrnum,barname):

        threading.Thread.__init__(self)

        self.thrnum=thrnum

        self.barname=barname

    def run(self):

        waittime=0

        while True:

            if q.empty() and waittime<MAX_WAIT: #队列为空且等待没有超过MAX_WAIT时,继续等待

                sleep(1)

                waittime=waittime+1

                print("Thr %s wait for %s secs"%(self.thrnum,waittime))

            elif waittime>=MAX_WAIT: #等待超过MAX_WAIT时,线程退出

                print("Thr %s quit"%(self.thrnum))

                break

            else: #队列不为空时,重置等待时间,从队列中取帖子url,进行解析

                waittime=0

                item=q.get()

                self.dotask(item)

    def dotask(self,item):

        print("Thr %s is collecting %s"%(self.thrnum,item))

        tid=item.replace("/p/","") #获取帖子的id,后面构造楼中楼url以及保存文件时用到

        filename = "E:/tieba/"+barname+"/"+tid+".txt" #文件保存路径

        if os.path.exists(filename): #判断是否已经爬取过当前帖子

            return

        print(baseurl+item)

        try:

            html=get_url_text(baseurl+item)

            findreault = parse_html(html, '//*[@id="thread_theme_5"]/div[1]/ul/li[2]/span[2]/text()') #获取当前帖子页数

            numpage=int(findreault[0])

            poststr=self.getpagestr(baseurl+item,tid,1) #获取第一页的内容

            if numpage>1:

                for i in range(2,numpage+1): 

                    tempurl=baseurl+item+"?pn="+str(i) #构造每一页的url,循环获取每一页

                    pagestr=self.getpagestr(tempurl,tid,i)

                    poststr=poststr+pagestr

            poststr= reg.sub('',poststr) #正则表达式去除html标签

            with open(filename,'w',encoding="utf-8") as f: #写文件

                f.write(poststr)

        except:

            print("Thr %s get %s failed"%(self.thrnum,baseurl+item))

            pass

    #抓取一页的内容,包括帖子及楼中楼,入参为页面url和帖子id,返回值为帖子的内容字符串

    def getpagestr(self,url,tid,pagenum):

        html=get_url_text(url)

        lzlurl=baseurl+"/p/totalComment?tid="+tid+"&pn="+str(pagenum)+"&see_lz=0" #构造楼中楼url

        jsonstr=get_url_json(lzlurl) #正常一页能看到的楼中楼的内容返回为json格式,如果有楼中楼层数大于10的,需要通过其他格式的url获取楼中楼10层以后的内容

        result1 = parse_html(html,'//*[@class="d_post_content j_d_post_content "]/text()') #xpath解析返回楼中楼内容

        pagestr=""

        for res in result1:

            pagestr=pagestr+res+"\n"  #先整合帖子内容

        if jsonstr['data']['comment_list']!=[]: #如果某页没有楼中楼,返回是空的list,不加判断的话会报错

            for key,val in jsonstr['data']['comment_list'].items(): #循环获取每层楼中楼的内容,key是楼中楼id,val为包含楼中楼层数、内容等信息的字典

                lzlid=key

                lzlnum=int(val['comment_num'])

                tpage=math.ceil(lzlnum/10) #计算楼中楼的页数

                for cominfo in val['comment_info']:

                    pagestr=pagestr+cominfo['content']+"\n"

                if tpage>1: #楼中楼超过1页时,需要构造第二页及以后的楼中楼url

                    for i in range(1,tpage+1):

                        replyurl="https://tieba.baidu.com/p/comment?tid="+tid+"&pid="+lzlid+"&pn="+str(i) #构造楼中楼url

                        htmlreply=get_url_text(replyurl)

                        replyresult=parse_html(htmlreply, '/html/body/li/div/span/text()') #获取楼中楼的评论内容

                        for reply in replyresult:

                            pagestr=pagestr+reply+"\n"

        return pagestr


if __name__ == '__main__':

    starttime = datetime.datetime.now()

    testurl="/f?ie=utf-8&kw=%E8%8B%B1%E9%9B%84%E6%97%A0%E6%95%8C3&fr=search"

    barname="英雄无敌3"

    html=get_url_text(baseurl+testurl)

    findreault = parse_html(html, '//*[@class="th_footer_l"]/span[1]/text()') #获取当前帖子页数

    numpost=int(findreault[0])

    numpage=math.ceil(int(numpost)/50) #计算页数

    path = "E:/tieba/"+barname

    folder=os.path.exists(path)

    if not folder:

        os.makedirs(path)

    for i in range(3): #创建获取帖子链接的线程

        t=getlinkthread(i,10,3,30,testurl)

        t.start()

    for j in range(3): #创建解析帖子链接的线程

        t1=parselinkthread(j,barname)

        t1.start()

    t1.join()

    endtime = datetime.datetime.now()

    print(endtime-starttime)


执行的结果:

方法1:urllib+正则执行时间:0:32:22.223089,爬下来984个帖子,失败9个帖子

方法2:requests+xpath执行时间:0:21:42.239483,爬下来993个帖子,失败0个帖子

结果与经验不同!后来想了一下,可能是因为对楼中楼的爬取方式不同,方法1中对每一个楼中楼每一页都要请求一次url,因为当时不会用浏览器F12工具,楼中楼的url格式是百度查到的。。。在写方法2时用F12工具抓到了第一页楼中楼的url,就是返回json的那个,这样如果楼中楼层数不超过10的话,每一页帖子的楼中楼只需要请求一次,只有超过10层的楼中楼才需要用方法1中的url进行爬取,这样效率就高了许多。这样看来,这个测试不是很合理。

分享一点经验:

1、就个人感觉来说,正则比xpath好用,只要找到html中的特定格式就行了,不过似乎容错差一点,方法1失败的9个帖子可能就是因为个别帖子html格式与其他不同导致正则匹配不到;

2、requests比urllib好用,尤其对于返回json格式的url,字典操作感觉比返回字符串做正则匹配要方便;

3、pip装lxml的时候报错,提示Cannot open include file: 'libxml/xpath.h': No such file or directory,以及没有安装libxml2,后来百度到https://www.cnblogs.com/caochuangui/p/5980469.html这个文章的方法,安装成功