2016年1月

在BeautifulSoup中,用contents或children遍历子节点的时候,如果节点下存在字符串,则会同时获取TagNavigalbeString对象。这是一个非常坑爹的特性,一方面通常获取子节点主要是得到Tag,另一方面,bs已经提供了stringsstripped_strings单独获取节点下的字符串,这里就是多此一举。

下面以contents为例,来看看这个问题的具体情况并给出两种解决方案。

一个例子

假设有下面这个xml:

<tab>
<tabletitle>
<title name="公司名称"/>
<title name="所属行业"/>
<title name="主营业务"/>
<title name="董事长"/>
<title name="最终控制人"/>
</tabletitle>
<tablelist>
<col con="许继电气股份有限公司"/>
<col con="电气设备"/>
<col con="从事电力系统二次设备和一次设备的研制、销售"/>
<col con="许继集团有限公司"/>
<col con="国务院国有资产监督管理委员会"/>
</tablelist>
<tablelist>
<col con="中国南方航空股份有限公司"/>
<col con="机场航运"/>
<col con="提供国内、港澳台地区及国际航空客运、货运及邮运服务"/>
<col con="中国南方航空集团公司"/>
<col con="国务院国有资产监督管理委员会"/>
</tablelist>
</tab>

现在需要把数据转成普通的表格,方便存入excel中,代码如下:

from bs4 import BeautifulSoup

from_str = '''
<tab>
<tabletitle>
<title name="公司名称"/>
<title name="所属行业"/>
<title name="主营业务"/>
<title name="董事长"/>
<title name="最终控制人"/>
</tabletitle>
<tablelist>
<col con="许继电气股份有限公司"/>
<col con="电气设备"/>
<col con="从事电力系统二次设备和一次设备的研制、销售"/>
<col con="许继集团有限公司"/>
<col con="国务院国有资产监督管理委员会"/>
</tablelist>
<tablelist>
<col con="中国南方航空股份有限公司"/>
<col con="机场航运"/>
<col con="提供国内、港澳台地区及国际航空客运、货运及邮运服务"/>
<col con="中国南方航空集团公司"/>
<col con="国务院国有资产监督管理委员会"/>
</tablelist>
</tab>'''

soup = BeautifulSoup(from_str, 'lxml-xml')
tablelist = soup.findAll('tablelist')
for i in tablelist:
    for j in i.contents:
        print(j['con'], end='\t')

运行上面代码会发现print语句报错:

TypeError: string indices must be integers

提示string类型的索引,必须是整数。于是将print语句改为

print(type(j))

看下j的类型,发现同时有TagNavigableString。表面上看xml中没有字符串,但其实在每个标签之间有个换行符(只有换行符时也获取了NavigalbeString就更坑爹了),所以contents遍历的时候,获取了NavigableString类型的对象。

解决这个问题,有两个比较常见的方法。

两种方法

方法一:使用isinstance判断类型

在循环中加一个类型判断,过滤掉NavigableString类型的子节点,即可输出正常的结果,代码如下:

for i in tablelist:
    for j in i.contents:
        if not isinstance(j, NavigableString):  ## 类型判断
            print(j['con'], end='\t')

注意这种方法需要在前面加上from bs4 import NavigableString导入相应的模块。

方法二:使用findAll(True)

另一种是用findAll()方法,只要加上True参数,就会返回所有Tag类型的子节点:

for i in tablelist:
    for j in i.findAll(True):  ## True参数只返回Tag子节点
        print(j['con'], end='\t')

两种方法都可以实现只获取Tag类型子节点,LZ更喜欢第2种,少写一层缩进,少写一个import,更重要的是可以少一些循环次数。感觉以后完全不需要contentschildren两个属性了。

接上一篇《Python 爬虫:抓取知乎某一话题下的全部问题》,需要说明的是我换了开发环境及一些工具库,现在用的是

环境:Python 3.5
HTTP请求:request
XML处理:BeautifulSoup + lxml

能够抓取一个话题下的问题后,我在想要如何抓取知乎全站数据呢?传统的方法,随便从某一个页面(通常是首页)开始抓取,然后提取页面中的URL,再根据提取到的URL去抓下一个页面。但对于新手,感觉一开始就这样处理,难度挺大,比如要做很多URL的分析、调度、去重等。又不想直接上Scrapy这样的框架,那就得想想其他法子。

考虑到我只是抓取知乎,而且知乎网站的结构比较清晰。内容上是按照「话题→问题→答案→回复」组织的,所以完全可以按照这个顺序去抓,最后再抓取「用户」信息就大功告成了。

那第一步就是要抓所有话题,找到话题结构页,从「根话题」开始,结果发现话题结构页需要登录才能查看。于是乎有了这篇文章。

获取登录信息

使用chrome的开发者工具查看知乎的登录信息,如下图:

短乎登录页
短乎登录页

可以看到知乎的登录地址是http://www.zhihu.com/login/email,请求方式是POST,登录时需要提交五个数据,分别是

_xsrf: 估计是随机生成的校验类数据,可在网页源码中找到
password: 密码
captcha: 验证码
remember_me: 记住我,布尔值
email: 邮箱地址

email、password和remember_me这三个很简单,主要是如何拿到_xsrf和验证码。

在网页原码中可以找到下面这段代码,就是_xsrf,可以使用BeautifulSoup解析HTML获取。

<input type="hidden" name="_xsrf" value="df557baede680642b1e1ab5974011246"/>

验证码是图片,复制图片链接地址就能看到,如下:

http://www.zhihu.com/captcha.gif

程序中需要抓取验证码图片并手动输入验证码才行。

模拟登录知乎

搞清楚登录过程及信息后,就可以开始写代码了。

import requests
import re
from bs4 import BeautifulSoup

# 设置一个session对象,保存登录信息
s = requests.Session()

# 设置headers
headers = {
    "Accept": "*/*",
    "Accept-Encoding": "gzip,deflate",
    "Accept-Language": "en-US,en;q=0.8,zh-TW;q=0.6,zh;q=0.4",
    "Connection": "keep-alive",
    "Content-Type":" application/x-www-form-urlencoded; charset=UTF-8",
    "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/38.0.2125.111 Safari/537.36",
    "Referer": "http://www.zhihu.com/"
}

html = s.get('http://www.zhihu.com/', headers=headers)  # GET请求,获取一个响应对象
soup = BeautifulSoup(html.text,'lxml-xml')  # 使用BeautifulSoup解析HTML

# 获取_xsrf
_xsrf = soup.findAll(type='hidden')[0]['value']

# 获取验证码图片并保存在本地
captcha_url = 'http://www.zhihu.com/captcha.gif'
captcha = s.get(captcha_url, stream=True, headers=headers)
with open('captcha.gif', 'wb') as f:
    for line in captcha.iter_content(10):
        f.write(line)
    f.close()

# 输入获取到的验证码,并入变量captch_str
print('输入验证码:', end='')
captcha_str = input()

# POST请求需要提交的信息组合
data = {
    '_xsrf': _xsrf,
    'email': '123456789@qq.com',  # 请用正确的邮箱
    'password': 'password',  # 请用正确的密码
    'remember_me': True,
    'captcha': captcha_str
}

# 登录请求
r = s.post(url='http://www.zhihu.com/login/email', data=data, headers=headers)

# 验证登录是否成功
print('登录状态:', r.json())

使用正确的邮箱和密码后,运行程序,将提示输入验证码,根据本地保存的图片输入即可。最终的输出如下(发现「陆」字错了):

输入验证码:hrfv
登录状态: {'r': 0, 'msg': '登陆成功'}

大功告成。

NEXT:抓取知乎的话题结构

前两天做query分析时候,需要从20w随机query中筛选出包括股票实体的query,股票实体大概5000多个。于是写了个循环处理,代码如下:

# 读取query
with open('query_20w.txt', 'r', encoding='utf-8') as fq:
    query = fq.readlines()
# 读取股票实体
with open('entity.txt', 'r', encoding='utf-8') as fe:
    entity = fe.readlines()

# 用两层循环做query筛选
for q in query:
    q = q.strip()
    for e in entity:
        e = e.strip()
        # 判断query中是否包含实体,若包含则输出query及相应的实体
        if q.find(e) >= 0:
            with open('result.txt', 'a') as fr:
                match_str = q + '\t' + e + '\n'
                fr.write(match_str)
            break

因为输出比较多,所以我没有直接在终端上输出,而是写入文件result.txt

程序功能其实已经OK了,但比较郁闷的是程序运行比较久,期间又没有任何提示,不清楚程序的运行状态,运行结束了也不知道。

可以直接在程序结尾加print('done')来判断是否运行结束,但还是无法提示程序的运行状态,所以想能不能做个进度条之类的,实时提示程序运行状态。

思路挺简单,就是在循环模块中不停的输出状态信息,但需要每次输出时把前一次输出的内容覆盖掉,从而在终端上始终只显示一行提示。转义字符\r可以实现该功能,它的作用就是告诉终端把光标移到行首。

于是修改代码如下:

# 读取query
with open('query_20w.txt', 'r', encoding='utf-8') as fq:
    query = fq.readlines()
# 读取股票实体
with open('entity.txt', 'r', encoding='utf-8') as fe:
    entity = fe.readlines()

# 用两层循环做query筛选
cnt_q = cnt_m = 0  # 计数器,分别记录query数和匹配的结果数
for q in query:
    cnt_e = 0  #计数器,记录实体数
    cnt_q += 1  # query计数器加1
    q = q.strip()
    for e in entity:
        cnt_e += 1  #实体计数器加1
        e = e.strip()
        # 判断query中是否包含实体,若包含则输出query及相应的实体
        if q.find(e) >= 0:
            cnt_m += 1  #匹配结果加1
            with open('result.txt', 'a') as fr:
                match_str = q + '\t' + e + '\n'
                fr.write(match_str)
            # 输出状态,其中\r将光标定位到行首
            print('正在分析第{}个query和第{}个实体,已累计输出{}个结果...'.format(cnt_q, cnt_e, cnt_m), end='\r')
            break
# 程序运行结束提示
print('\n运行结束')

运行时,终端提示如下:

正在分析第19392个query和第704个实体,已累计输出235个结果...

网上查资料的时候,也有一些库可以实现进度条功能。但觉得这个方案最简单实用。

去年年底的时间,决定改用hexo在github上建设静态博客。12月24日将ghost觉得还可以的文章都备份下来,随后的几天开始折腾hexo,大概26号基本搞定。

碰到了两个坑:使用git同步到github问题报错,后来发现可以改用ssh的方式,终于搞定。随后又发现每次同步CNAME文件都会被删除,后来把CNAME文件放到source文件夹中得以解决。

我现在写博客的流程基本上是:在Stackedit上写文章,实时同步到Google Drive,完成后导出md文件到source文件夹,执行hexo d -g直接发布,非常方便。如何涉及到图片则是直接使用hexo-qiniu-sync插件,同步上传到七牛云。

仅仅一年的时间,从ghost到farbox再回到ghost,最后又选择hexo,够折腾的。现在的流程用起来比较顺了,markdown写作,文件也是本地、Google Drive、GitHub三个备份,除了域名其他都是免费。整体看是个非常不错的博客方案,以后就少折腾了吧。

因为自己在做搜索产品,希望能对搜索技术有些了解,偶尔也会做些数据及文本方面的分析,所以今年给自己设了一个学习Python的任务,方向就是爬虫及数据分析。

在大学学过一点C,虽然从来没有写出过一个完整的程序,但对编程的基本概念(比如变量、函数、类型、对象)都有了解。去年就想着学Python,基本语法都看过,只是每当要实现一个完整功能的时候,都觉得挺难而放弃。所以这次不准备系统看语法了,直接开始练习小项目,不懂就Google。以问题为导向,边写代码边调试,这才是学习编程的正确姿势。然后在每个阶段做些总结,写成博文。本文就是第一篇。

我准备做的事情就是:抓取知乎上所有的问题、答案和用户并存入MySQL,然后基于这些数据做些分析。这个问题很大,估计够我学一年了吧。

第一步,怎么爬某一个话题下的所有问题。

爬虫的基本原理

先看一段代码,用Python实现爬虫的基本功能:抓取网页。

import urllib2
url = 'http://www.skyue.com'
req = urllib2.Request(url)  #构建请求对象
res = urllib2.urlopen(req)  #获得响应对象
html = res.read()  #读取响应对象中的网页内容
print html  #输出网页内容

代码非常的简单,才6行。放到IPython中运行,输出的结果就是本博客首页的HTML代码。

爬虫抓取网页的过程可简化为两步:

  1. 向服务器发出请求,一般通过url
  2. 获得服务器的响应内容,通常就是我们看到的网页

这个过程与我们直接通过浏览器访问一个网址是一致的。对应到上面的代码中,就是reqres那两行。先通过urllib2.Request把url封闭为请求对象,再通过urllib2.urlopen获得服务器的响应内容。

抓取知乎Python话题下的问题

Python话题链接:https://www.zhihu.com/topic/19552832/questions?page=1

将前面的代码中url换成知乎Python话题的链接,就可以抓取整个网页,但现在需要把网页中的问题ID(即问题页面URL中的数字)及问题标题抽出来。所以需要用正则表达去匹配(关于正则表达可参考《Python爬虫入门七之正则表达式》)。

先用chrome开发者工具,看下知乎话题页面的源代码,可以发现每个问题的链接和标题都在下面这段代码中

<a target="_blank" class="question_link" href="/question/39423081">theano里面的中间变量self.p_y_given_x如何追踪?</a>

匹配问题ID及标题的正则表达则为href="/question/(.*?)">(.*?)</a>

所以,抓取问题ID及问题标题的完整代码如下:

# 抓取知乎问题
import urllib2
import re
 
url = 'https://www.zhihu.com/topic/19552832/questions?page=1'
req = urllib2.Request(url)
res = urllib2.urlopen(req)
html = res.read()

#匹配问题ID及问题标题
pattern = re.compile(r'href="/question/(.*?)">(.*?)</a>',re.S)
items = re.findall(pattern,html)
#输出结果
for item in items:
    print '问题ID:',item[0],
    print '问题Title:', item[1]

部分运行结果:

问题ID: 39423081 问题Title: theano里面的中间变量self.p_y_given_x如何追踪?
问题ID: 39422374 问题Title: 如何用BeautifulSoup解析script中的内容?
问题ID: 39421438 问题Title: python包pandas中read_sql这个语句,查询(select)包含某个字符串的问题?
问题ID: 39419394 问题Title: python和易语言有什么相似之处?

完善代码

有几个问题

  • 上面的代码只能抓取某一页,我们当然希望能抓取一个话题下所有的问题
  • 抓取所有问题时,提供一些交互,比如每次抓取一页,输入回车抓取下一页,输入「Q」则退出
  • 将代码封闭成类,再以类的实例去执行

完善后,完整代码如下:

# 爬取知乎一个主题下所有的问题
import urllib2
import re
 
class ZhiHu:
    #初始化方法,定义变量
    def __init__(self):
        self.pageIndex = 1
        self.user_agent = 'Mozilla/4.0 (compatible; MSIE 5.5; Windows NT)'
        #初始化headers
        self.headers = {'User-Agent': self.user_agent}
        #存放问题的列表,每个元素是一页问题(20个)
        self.questions = []
        #爬虫运行状态
        self.enable = False
        
    #获取页面HTML  
    def getPage(self,pageIndex):
        try:
            url = 'https://www.zhihu.com/topic/19552832/questions?page=' + str(pageIndex)
            #构建请求的request
            request = urllib2.Request(url)
            #获取请求的页面HTML代码
            response = urllib2.urlopen(request)
            #将页面代码转成UTF-8
            pageCode = response.read().decode('utf-8')
            return pageCode
        except urllib2.Error, e:
            if hasattr(e, 'reason'):
                print u'连接知识错误,原因:',e.reason
                return None
    
    #传入某一页页码,返回问题列表
    def getPageItem(self,pageIndex):
        pageCode = self.getPage(pageIndex)
        #存放每页问题的列表,每个元素包括问题的ID及标题
        pageQuestions = []
        #判断页面是否获取成功
        if not pageCode:
            print u'页面加载失败...'
            return None
        #正则匹配每个问题URL中的ID及标题
        pattern = re.compile(r'href="/question/(.*?)">(.*?)</a>',re.S)
        items = re.findall(pattern,pageCode)
        #遍历正则表达匹配的结果,并存入到pageQuestions
        for item in items:
            #item[0]是问题的ID,item[1]问题的标题
            pageQuestions.append([item[0],item[1]])
        return pageQuestions
    
    #加载并提取页面的内容加入到列表中
    def loadPage(self):
        if self.enable == True:
            #如果当前未看的页数少于2页,则加载下一页
            if len(self.questions) < 2:
                #获取新的一页
                pageQuestions = self.getPageItem(self.pageIndex)
                if pageQuestions:
                    #将该页问题存放到全局列表中
                    self.questions.append(pageQuestions)
                    #获取完后,页面索引+1
                    self.pageIndex += 1
                                     
    #打印一页问题
    def getQuestion(self,pageQuestions,page):
        for question in pageQuestions:
            print u'第%s页,问题%s:%s' %(page,question[0],question[1])
            
    #开始方法
    def start(self):
        print u'正在读取知乎问题,按回车查看,Q退出'
        self.enable = True
        nowPage = 0
        while self.enable:
            #输入命令
            input = raw_input()
            self.loadPage()
            #输入「Q」则停止程序
            if input == 'Q':
                self.enable = False
                break
            elif len(self.questions)>0:
                pageQuestions = self.questions[0]
                nowPage += 1
                del self.questions[0]
                self.getQuestion(pageQuestions,nowPage)  
            print input
#实例化知乎爬虫
spider = ZhiHu()
#运行爬虫
spider.start()

小结

编程的基础语法看过很多次,写出完整功能的程序却是第一次,真的很爽。碰到了一些坑:

  • ===,前者是赋值,后者是逻辑判断。用的时候注意点,if语句后面,通常都是==
  • 正则表达分组后,Python获得的是一个二维列表。
  • 在类中,对类自身的变量及方法引用,都要带上self

参考文章:

Python爬虫入门七之正则表达式
Python爬虫实战一之爬取糗事百科段子