昨天晚上,我一好哥儿们找我帮忙,他的一个课题中需要爬取《人民日报》中的文章,方便后续对文章内容进行分词,词性标注,词频统计等等一系列数据统计和分析。于是他便找到了我。

关于爬虫的大致需求如下,我简单看了一下这个网站和他要爬的东西,难度不是很大,但涉及到的知识也挺全面的,正好拿来练练手,于是一口答应下来。

写爬虫之前,先回顾一下爬取的思路。

首先,要 明确自己要爬取什么内容,需求明确了,后面才能有的放矢;然后,要 分析目标网站,包括 URL 结构,HTML 页面,网络请求及返回的结果等,目的就是要找到我们要爬取的目标位于网站的哪一个位置,我们如何才能获取到,从而指定爬取策略;接下来,就是 编码环节 了,使用代码去发起网络请求,解析网页内容,提取目标数据,并进行数据存储;最后,测试代码,完善程序,如添加一些输入输出的交互,使程序使用更加方便,添加异常捕获和处理的部分,使程序的鲁棒性更佳等。


一、明确需求

他的需求其实很简单,就是人民日报每天有一份报纸,每份报纸中有若干个版面,每个版面有若干文章。他希望将这些文章全部爬取下来,按一定的规则存储在本地(具体要求如下图所示)。

二、分析目标网站 

1.  URL 组成结构

人民日报网站的 URL 的结构还是比较直观的,基本上什么重要的参数,比如日期,版面号,文章编号什么的,都在 URL 中有所体现,构成的规则也很简单,像这样

版面目录:http://paper.people.com.cn/rmrb/html/2019-05/06/nbs.D110000renmrb_01.htm

文章内容:http://paper.people.com.cn/rmrb/html/2019-05/06/nw.D110000renmrb_20190506_5-01.htm

在版面目录的链接中,“/2019-05/06/” 表示日期,后面的 “_01” 表示这是第一版面的链接。

在文章内容的链接中,“/2019-05/06/” 表示日期,后面的 “_20190506_5_01” 表示这是 2019 年 5 月 6 日报纸的第 1 版第 5 篇文章

值得注意的是,在日期的 “月” 和 “日” 以及 “版面号” 的数字,若小于 10,需在前面补 “0” ,而文章的篇号则不必。

了解到这个之后,我们可以按照这个规则,构造出任意一天报纸中人一个版面的链接,以及任意一篇文章的链接。

如:2018 年 6 月 5 日第 4 版的目录链接为:

http://paper.people.com.cn/rmrb/html/2019-05/06/nbs.D110000renmrb_01.htm

2018 年 6 月 1 日第 2 版第 3 篇文章的链接为:

http://paper.people.com.cn/rmrb/html/2018-06/01/nw.D110000renmrb_20180601_3-02.htm

点击访问,发现果然是这样。至此,网站的 URL 组成结构分析完成。

2.  分析网页 HTML 结构

在 URL 分析中,我们也发现了,网站的页面跳转是通过 URL 的改变完成的,不涉及到诸如 Ajax 这样的动态加载方法。也就是说,它的所有数据是一开始就加载好的,我们只需要去 html 中提取相应得数据即可。

PS:如果是使用了 Ajax的话,它网站一开始展示的数据是不全的,只有在你触发了某些操作,如页面浏览到最底部,或者点击查看更多按钮等等等等后,它才会向服务器发出请求,获取剩余的其他数据。如果是这种方式的话,我们的爬取策略也就不是从 HTML 中找了,而是直接向服务器发请求,然后解析服务器返回的 json 文件了,具体方法可以参考《Python网络爬虫实战:爬取知乎话题下 18934 条回答数据》。

好了,言归正传,我们来分析一下我们的目标网站。按 F12 召唤出开发者工具(点击图中 1 处的小箭头,然后点击网页中的内容,可以在 html 源码中快速找到相应的位置)。

通过这种方法,我们可以知道,版面目录存放在一个 id = “pageList” 的 div 标签下,class = “right_title1” 或 “right_title2” 的 div 标签中,每一个 div 表示一个版面,而版面的链接就在 id = “pageLink” 的 a 标签中。

使用同样的方法,我们可以知道,文章目录存放在一个 id = “titleList” 的 div 标签下的 ul 标签中,其中每一个 li 标签表示一篇文章,而文章的链接就在 li 标签下的 a 标签中。

进入文章内容页面之后,我们可以知道,文章标题存放在 h1 ,h2 ,h3 标签中(有的文章标题只用到了 h1 标签,而有的文章有副标题可能会用到 h2 或 h3 标签), 正文部分存放在 id = “ozoom” 的 div 标签下的 p 标签里。

至此,目标网站的 HTML 页面分析完成。

3. 制定爬取策略

通过分析 URL 组成结构以及目标网站的 HTML 结构,我们已经完成了爬虫的前期调研工作,接下来需要根据网站的特点制定相应的爬取策略,然后评估每种方法的优劣和难易程度,最终选择一种最佳方案,进行编码实现。

策略一:第一遍,先爬取版面目录,将每一个版面的链接保存下来;第二遍,依次访问每一个版面的链接,将该版面的文章链接保存下来;第三遍,依次访问每一个文章链接,将文章的标题和正文保存到本地。

策略二:由于我们已经知道了文章链接的构成方式,所以我们或许可以跳过目录的爬取,直接循环构造文章链接,爬取文章内容。

经过一番比较,以及简单的编码测试,我决定使用策略一来完成此爬虫。

主要原因是,虽然策略二逻辑比较简单也比较方便,但是需要解决两个问题,1. 每天的报纸版面数不同,而且每个版面的文章数也不同,在构造 URL 的时候,如何保证文章不重不漏?2. 有些特殊情况下,文章的编号并不是连续的,该如何解决这个问题?

所以,综合考虑,我觉得策略一可能会更加稳定更加保险一些(如果大家想到了解决策略二问题的方法,可以尝试一下,或者如果想到有其他策略,也欢迎留言,我们一起探讨)。

三、编码环节

接下来就到了实际编码环节了,话不多说,直接开始。

首先导入本项目用到的库:

import requests
import bs4
import os
import datetime
import time

其中,requests 库主要用来发起网络请求,并接收服务器返回的数据;bs4 库主要用来解析 html 内容,是一个非常简单好用的库;os 库主要用于将数据输出存储到本地文件中。

def fetchUrl(url):
    '''
    功能:访问 url 的网页,获取网页内容并返回
    参数:目标网页的 url
    返回:目标网页的 html 内容
    '''
    
    headers = {
        'accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8',
        'user-agent': 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/68.0.3440.106 Safari/537.36',
    }
    
    r = requests.get(url,headers=headers)
    r.raise_for_status()
    r.encoding = r.apparent_encoding
    return r.text

fetchUrl 函数用于发起网络请求,它可以访问目标 url ,获取目标网页的 html 内容并返回。

这里其实是应该做异常捕获的(我为了简单就省掉了,吐舌头)。因为网络情况比较复杂,可能会因为各种各样的原因而访问失败, r.raise_for_status() 这句代码其实就是在判断是否访问成功的,如果访问失败,返回的状态码不是 200 ,执行到这里时会直接抛出相应的异常。

def getPageList(year, month, day):
    '''
    功能:获取当天报纸的各版面的链接列表
    参数:年,月,日
    '''
    url = 'http://paper.people.com.cn/rmrb/html/' + year + '-' + month + '/' + day + '/nbs.D110000renmrb_01.htm'
    html = fetchUrl(url)
    bsobj = bs4.BeautifulSoup(html,'html.parser')
    pageList = bsobj.find('div', attrs = {'id': 'pageList'}).ul.find_all('div', attrs = {'class': 'right_title-name'})
    linkList = []
    
    for page in pageList:
        link = page.a["href"]
        url = 'http://paper.people.com.cn/rmrb/html/'  + year + '-' + month + '/' + day + '/' + link
        linkList.append(url)
    
    return linkList

 getPageList 函数,用于爬取当天报纸的各版面的链接,将其保存为一个数组,并返回。

def getTitleList(year, month, day, pageUrl):
    '''
    功能:获取报纸某一版面的文章链接列表
    参数:年,月,日,该版面的链接
    '''
    html = fetchUrl(pageUrl)
    bsobj = bs4.BeautifulSoup(html,'html.parser')
    titleList = bsobj.find('div', attrs = {'id': 'titleList'}).ul.find_all('li')
    linkList = []
    
    for title in titleList:
        tempList = title.find_all('a')
        for temp in tempList:
            link = temp["href"]
            if 'nw.D110000renmrb' in link:
                url = 'http://paper.people.com.cn/rmrb/html/'  + year + '-' + month + '/' + day + '/' + link
                linkList.append(url)
    
    return linkList

getPageList 函数,用于爬取当天报纸的某一版面的所有文章的链接,将其保存为一个数组,并返回。

def getContent(html):
    '''
    功能:解析 HTML 网页,获取新闻的文章内容
    参数:html 网页内容
    '''    
    bsobj = bs4.BeautifulSoup(html,'html.parser')
    
    # 获取文章 标题
    title = bsobj.h3.text + '\n' + bsobj.h1.text + '\n' + bsobj.h2.text + '\n'
    #print(title)
    
    # 获取文章 内容
    pList = bsobj.find('div', attrs = {'id': 'ozoom'}).find_all('p')
    content = ''
    for p in pList:
        content += p.text + '\n'      
    #print(content)
    
    # 返回结果 标题+内容
    resp = title + content
    return resp

getContent 函数,用于访问文章内容页,爬取文章的标题和正文,并返回。

def saveFile(content, path, filename):
    '''
    功能:将文章内容 content 保存到本地文件中
    参数:要保存的内容,路径,文件名
    '''
    # 如果没有该文件夹,则自动生成
    if not os.path.exists(path):
        os.makedirs(path)
        
    # 保存文件
    with open(path + filename, 'w', encoding='utf-8') as f:
        f.write(content)

saveFile 函数用于将文章内容保存到本地的指定文件夹中。

def download_rmrb(year, month, day, destdir):
    '''
    功能:爬取《人民日报》网站 某年 某月 某日 的新闻内容,并保存在 指定目录下
    参数:年,月,日,文件保存的根目录
    '''
    pageList = getPageList(year, month, day)
    for page in pageList:
        titleList = getTitleList(year, month, day, page)
        for url in titleList:
            
            # 获取新闻文章内容
            html = fetchUrl(url)
            content = getContent(html)
            
            # 生成保存的文件路径及文件名
            temp = url.split('_')[2].split('.')[0].split('-')
            pageNo = temp[1]
            titleNo = temp[0] if int(temp[0]) >= 10 else '0' + temp[0]
            path = destdir + '/' + year + month + day + '/'
            fileName = year + month + day + '-' + pageNo + '-' + titleNo + '.txt'
            
            # 保存文件
            saveFile(content, path, fileName)

download_rmrb 函数是需求中要求的主函数,可以根据 year,month,day 参数,下载该天的全部报纸文章内容,并按照规则保存在指定的路径 destdir 下。

            
if __name__ == '__main__':
    '''
    主函数:程序入口
    '''
    year = "2019"
    month = "05"
    day = "06"
    destdir = "D:/data"

    download_rmrb(year, month, day, destdir)
    print("爬取完成:" + year + month + day)

至此,程序的主体功能已经完成。

四、完善程序

通过上一章节的编码,我们已经实现了 通过调用 download_rmrb 函数,来下载特定日期的全部文章内容。但是同时我们也可以发现,在程序入口 main 函数中,日期是在代码中写死的,也就是说,如果我们想爬取其他日期的报纸,就必须修改源码。

很不方便不是嘛,我们把它做一下改进,日期使用交互式的方式由用户输入。 

            
if __name__ == '__main__':
    '''
    主函数:程序入口
    '''    
    # 爬取指定日期的新闻
    newsDate = input('请输入要爬取的日期(格式如 20190502 ):')

    year = newsDate[0:4]
    month = newsDate[4:6]
    day = newsDate[6:8]

    download_rmrb(year, month, day, 'D:/data')
    print("爬取完成:" + year + month + day)

方便了一些是不是?但是问题又来了,如果我想一次性爬一个月的,或者一年的怎么办?我岂不是要手动输入几十次几百次?所以,程序我们还可以改进一下,用户输入其实日期和结束日期,程序爬取期间的日期的所有文章。

def gen_dates(b_date, days):
    day = datetime.timedelta(days = 1)
    for i in range(days):
        yield b_date + day * i
        
        
def get_date_list(beginDate, endDate):
    """
    获取日期列表
    :param start: 开始日期
    :param end: 结束日期
    :return: 开始日期和结束日期之间的日期列表
    """
    
    start = datetime.datetime.strptime(beginDate, "%Y%m%d")
    end = datetime.datetime.strptime(endDate, "%Y%m%d")
    
    data = []
    for d in gen_dates(start, (end-start).days):
        data.append(d)
        
    return data

            
if __name__ == '__main__':
    '''
    主函数:程序入口
    '''
    # 输入起止日期,爬取之间的新闻
    beginDate = input('请输入开始日期:')
    endDate = input('请输入结束日期:')
    data = get_date_list(beginDate, endDate)
    
    for d in data:
        year = str(d.year)
        month = str(d.month) if d.month >=10 else '0' + str(d.month)
        day = str(d.day) if d.day >=10 else '0' + str(d.day)
        download_rmrb(year, month, day, 'data')
        print("爬取完成:" + year + month + day)
#         time.Sleep(3)        # 怕被封 IP 爬一爬缓一缓,爬的少的话可以注释掉

实际运行测试一下,假设我们要爬取 2019年 4月份的全部报纸内容,那我们在开始日期中输入20190401,在结束日期中输入 20190501,回车运行。

等待一段时间后,程序便已经运行完成,我们去文件夹中看一下,我们爬到的内容吧。

 爬到的内容整整齐齐的排列在文件夹中,真的是很舒胡啊,随便打开一个文档看一下,也没问题。

 本此爬虫到这里也就算是全部完成了。


写在后面的话

这次也是很久没有写新的爬虫文章了,一是因为马上要毕业了,忙着做毕业设计;二是写一次教程类的文章确实也是挺辛苦的一件事,要整理思路和语言,要重新整理代码,要各种截图,争取让零基础或者刚入门的人也能看懂并跟着写出来。三是找不到一个有意思的动机,因为爬虫总归是为了用而写的,总不能为了炫技,为了写爬虫而写爬虫吧,那样没有什么意思。

最一开始写爬虫,是为了记录自己的成长过程,如果自己在学习过程中的这些记录,同时能帮助到更多的后来者,那就更好了。


2019 年7月10日更新

有读者在评论中反映说,输入开始和结束日期爬取一段时间内新闻的方法不能用,运行时会报下面的错误。

我检查了一下,确实是我的疏忽。

这是因为其中计算日期的函数在 datetime 库中,而我在写博客整理代码的过程中,疏忽忘记了引用这个库,导致程序运行报错。

解决方法为,在程序中添加两行代码即可:

import datetime
import time

我已经在博客的代码中进行了修改,请大家放心食用。

感谢 weixin_42435870songxinyueyue 朋友指出问题,十分感谢!!

2020年7月26日更新

通过读者朋友们的反馈,我发现最近人民日报的网站做了改版,从7月1日起的新闻网页用了新的布局。这也导致了使用原来的代码爬取2020年7月1日之后的新闻时,无法爬取。

所以这里做一下更新,兼容网站的新格式。

  1. 在 getPageList 函数中,将原来的
    pageList = bsobj.find('div', attrs = {'id': 'pageList'}).ul.find_all('div', attrs = {'class': 'right_title-name'})

 改成:

    temp = bsobj.find('div', attrs = {'id': 'pageList'})
    if temp:
        pageList = temp.ul.find_all('div', attrs = {'class': 'right_title-name'})
    else:
        pageList = bsobj.find('div', attrs = {'class': 'swiper-container'}).find_all('div', attrs = {'class': 'swiper-slide'})
  1. 在 getTitleList 函数中,将原来的
    titleList = bsobj.find('div', attrs = {'id': 'titleList'}).ul.find_all('li')

改成:

    temp = bsobj.find('div', attrs = {'id': 'titleList'})
    if temp:
        titleList = temp.ul.find_all('li')
    else:
        titleList = bsobj.find('ul', attrs = {'class': 'news-list'}).find_all('li')

简单说明一下上面改了什么,

  1. 原网站中,将版面列表和文章列表的标签做了调整,所以我们需要用新的标签新的属性去获取。
  2. 为了兼容,我们先获取之前版本的标签,若可以获取到,则说明是改版前的界面,若获取不到,说明是改版后的界面,需要用新的标签属性去获取。

如果文章中有哪里没有讲明白,或者讲解有误的地方,欢迎在评论区批评指正,或者扫描下面的二维码,加我微信,大家一起学习交流,共同进步。  

最后修改:2021 年 07 月 23 日 10 : 18 AM
如果觉得我的文章对你有用,请随意赞赏