相关代码参见:https://github.com/snowcement/Knowledge-Graph-related/tree/master/Spider
爬取页面介绍:
-
初学爬虫,我也参考了该答案:
求大神们推荐python入门书籍(爬虫方面)? - zhouaimei1的回答 - 知乎 https://www.zhihu.com/question/38801925/answer/526630342
快速刷你需要用的章节所用时间并不多,能够掌握基本的xpath,css语法和Scrapy中基本的组成等内容
因为要构建一个事理图谱,所以需要先获取构建所需的语料,最后我选取了一个具有某些行业性质的司法新闻作为爬取对象,初始页面地址为http://sifa.sina.com.cn/news/
通过观察,在左侧大块区域的新闻面板中,有5个tab板块,其中“最新”板块是右侧其余四个板块新闻的汇总,只需爬取“最新”板块中的所有新闻即可。爬取分为两个维度:即“纵向”垂直爬取和“横向”水平爬取。前者是指在“最新”板块中,将板块内的所有新闻利用爬虫进行爬取,后者是指“最新”板块浏览过程中的翻页动作,将每一页的信息爬取下来。
对于“纵向”维度,点击任意一条新闻进入,如图所示
-
我们需要提取新闻的标题、发布时间、发布来源、正文内容、新闻关键字、新闻的url等信息,如图所示,对这部分解析的代码见“SinaSFNews/SinaSFNews/spiders/news_spider.py”中的parse_item函数:
在该函数中不仅对pc端的新闻进行解析,还对移动端的新闻进行了解析(原因见6)
函数对爬取条数记录在日志中【log_crawl.txt】,便于在服务器运行爬虫时跟踪爬取进度
对爬取对象构建ItemLoader对象,并利用add_xpath,add_value等对其添加感兴趣字段
-
因为大部分网站都进行了反爬措施,我们需要使用随机的用户代理(UA)和随机的IP代理,这部分我参考了 scrapy下载中间件 这篇文章,由于使用了随机的用户代理,虽然爬取网址是PC端的新闻,会进行自动重定向,新闻的url为变为以wap结尾的移动端新闻,所以需要对wap格式的新闻也要做出解析,代理相关代码见“SinaSFNews/SinaSFNews/spiders/middleware.py”中的RandomHttpProxyMiddleware类和RandomUserAgentMiddleware类,它们都属于中间件的一种,下面对这两个类功能进行介绍:
-
RandomHttpProxyMiddleware
需要先对一些免费代理网站进行爬取,获取部分代理IP列表,这里我参照网上现有代码示例对xici【http://www.xicidaili.com】进行爬取,获取部分基于HTTPS的代理IP集合和基于HTTP的代理IP集合,原理就是:使用爬取到的代理再次发送请求到http(s)://httpbin.org/ip,验证代理是否可用,在具体爬取时,我发现已经通过 http(s)://httpbin.org/ip 验证的代理还是会大量出现“返回值异常”【e.g. 返回状态码如404,503等】和“连接异常”【e.g. 返回异常原因如TcpTimeOutError等】等问题,而使用 89免费代理 会更方便,这里不需要爬取了,网站本身提供代理IP检测的服务,在首页将部分国内代理IP输入到"代理IP检测"进行检测,选取可用代理IP即可【最终使用了14个HTTP的代理IP】,将代理IP记录到“proxy_list.json”中便于后续处理,同时将proxy_list.json文件的存放路径在SinaSFNews/SinaSFNews/spiders/settings.py”中进行配置,见HTTPPROXY_PROXY_LIST_FILE字段
该类主要对HTTPPROXY_PROXY_LIST_FILE字段进行加载,获取代理了IP列表,并在每个Request发送前调用_set_proxy方法,在request的meta字典中添加‘proxy’字段并发送
-
RandomUserAgentMiddleware
- 调用faker包,在每个Request发送前随机生成用户代理,即User-agent信息,并在request的headers中添加User-agent字段,防止反爬
-
-
对于“横向”维度,我们对“最新”板块新闻进行浏览,在滚动到30条新闻左右的位置,页面会进行动态加载出现新的“30条新闻”,接着向下滚动页面,在浏览过60条新闻时又会加载30条新闻,接着滚动到页面底部,显示了点击“下一页”的按钮
在sifa.sina.com.cn/news/index页面中看到id="feedCard"中的代码块为空,并未显示任何新闻,因此该页面是采用了infinite roll方式的ajax动态页面,以chrome浏览器为例,无法根据F12跳出的页面审查页中的Elements的显示内容进行提取【对静态页面,选取感兴趣元素,进行copy xpath】,因此需要查看Sources 中的js代码,分析第1-30条,第31-60条,第61-90条新闻内容是何时被加载进来的
如图所示,在index页面中,我们找到了关于左侧板块的相关信息,即tab_-2120,它的url为url: '//feed.mix.sina.com.cn/api/roll/tags?channelid=1&sq=x_where:digit_cl==399872&begin=1401552000&tags=' + encodeURIComponent(''),在左侧Page列表中找到feed.mix.sina.com.cn/api/roll中发现对应的JS文件【get?pageid=354&lid=2120&num=30&versionNumber=1.2.8&page=1&encode=utf-8&callback=feedCardJsonpCallback】,页面向下滚动到第二个30条新闻时,出现了第二个JS文件,可在Network中通过检测JS进行查看,如下图所示
第二个JS文件将page=1替换为ctime=xxx和_=xxx,通过尝试,发现我们将这两部分信息替换为page=2返回结果是相同的,这意味着我们不需要探究ctime和_字段值的生成方式了
最终的“横向”爬取url的信息即:http://feed.mix.sina.com.cn/api/roll/get?pageid=354&lid=2120&num=30&versionNumber=1.2.8&page=1&encode=utf-8&callback=feedCardJsonpCallback,将page=1,依次递增即可,通过尝试发现当page=1487时页面返回为空,故共爬取1486个“横向”页面
写入mysql数据库,需要在“SinaSFNews/SinaSFNews/spiders/pipelines.py”对相关功能进行写入,利用twisted提供的异步方式多线程访问数据库模块adbapi,提高程序访问数据库的效率,见MySQLAsyncPipeline类,这里使用的pymysql包,注意cursorclass字段设置
遇到的问题
-
默认“返回值异常”和“连接异常”等问题,重试3次即退出,由于使用代理IP不是很稳定,3次是不够的
-
为解决该问题,使用Retry中间件,即middleware.py中的MyRetryMiddleware类,参考了 重写scrapy中间件之RetryMiddleware并对其进行修改,Retry的目的:
增加重试次数
-
对产生的异常进行捕获,进一步处理,防止因异常造成的程序中断,需要在settings.py中配置一下信息:
- RETRY_ENABLED、RETRY_TIMES、RETRY_HTTP_CODES
-
异常捕获后,采取的操作可以是打印异常代理IP,提示异常原因,并进行重试,这里对重试代码进行修改
- 对RetryMiddleware(MyRetryMiddleware的基类)的方法_retry()进行封装,而非直接调用,随机更换代理IP后再发起重试
-
-
因为已经在Retry中间件对异常进行捕获,无需其他的异常处理的中间件了
之前为异常处理有单独写了异常处理中间件ProcessAllExceptionMiddleware, 并将其在settings.py中配置的值【设为120】比 MyRetryMiddleware的值【设为100】要高,以“连接异常”为例,导致出现异常时总会先进入ProcessAllExceptionMiddleware的process_exception方法,因为异常捕获后只是打印了日志,并未进行重试,往往重试3次仍失败的情况下,程序就异常中断了
MyRetryMiddleware的值【设为130】后,问题得到解决,因此异常处理中间件不再需要
-
在进行“横向”维度爬取时,除了以start_urls列表中包含的首个页面能够正常爬取,其余的后续页面总会发生大批量的“403Forbidden”信息,而这些链接单独放入浏览器访问时,是有具体返回结果的
因为首个页面可以正常爬取,后续页面却不能,后续页面的调用在news_spider.py中的parse函数,在函数结尾处调用Request(next_url, dont_filter=True, meta=meta)生成器,迭代横向爬取
-
原因分析:应该是系统首次发送的Request和我调用yield Request请求内容不同,对两个Request内容分别进行截取比对发现,有以下内容不同:
我的Request.headers中增加了Referer字段信息,表示了该页面是从哪个页面来的
我的Request.meta中增加了depth字段信息,表示当前的爬取深度
需要对这两部分多余的信息进行处理,最后在request随机IP代理生成处的_set_proxy方法中添加了过滤代码段,去掉Referer和depth字段信息,修改后,横向爬取“403Forbidden”问题解决了
-
爬取一段时间后爬虫关闭(爬取7000+条),失败第一次
通过对日志记录的观察,发现状态码中有很多301,提示信息:DEBUG: Redirecting (301) to <GET https://sifa.sina.cn/2018-11-22/detail-ihmutuec2633891.d.html?from=wap> from <GET http://sifa.sina.cn/2018-11-22/detail-ihmutuec2633891.d.html?from=wap>
之前提到在Retry中间件中,对于每次的retry需要随机更换代理,但更换方式有问题【选取原代理的scheme,即http或https,并随机生成列表中的IP】,但对于301,新地址已经变更为https,但若还依照http代理头生成http代理,只能会一直引发错误,导致程序中断,故采用从request.url中提取scheme信息,问题解决
-
爬取一段时间后爬虫关闭(爬取约9000条),失败第二次
-
发现在爬取9000条后,引发大量的retry,什么原因?
- 横向爬取页面【打算爬取共1486个页面】,但在第287个页面时频繁发生失败并重试,达到了最大重试次数RETRY_TIMES,引发ERROR: Error downloading,故后续的横向页面均无法爬取,采取措施:跳过该横向页面,继续访问下一个横向页面,需要自己构造HtmlResponse(url=request.url),根据url地址提取page信息,爬取下一个页面,因为是自己构造的response,其response.txt内容为空,故无法进行纵向爬取,直接跳过,问题解决
横向页面停止爬取后,只能对当前已爬取其未成功的剩余页面进行retry,信息如下:INFO: Crawled 8477 pages (at 0 pages/min), scraped 8191 items (at 0 items/min)。当均达到最大重试次数RETRY_TIMES时,由于直接return,使得有request而没有response,最终引发大量的类似[scrapy.core.scraper] ERROR: Error downloading <GET http://news.sina.com.cn/sf/news/fzrd/2018-11-01/doc-ifxeuwwt0086509.shtml>样的错误,只需要参考之前写过但为使用的异常处理中间件ProcessAllExceptionMiddleware,构造HtmlResponse(url='')并返回即可
-
总结
所有的中间件需要在settings.py中配置,见DOWNLOADER_MIDDLEWARES字段
写入mysql时包含的数据库基本信息需要在settings.py中配置,见MYSQL_*字段和ITEM_PIPELINES字段
-
为防止爬虫被识别,可在settings.py在中添加以下字段:
DOWNLOAD_DELAY,设为0.25秒即可,一开始设成3,速度略慢
DOWNLOAD_TIMEOUT,下载器在超时前等待的时间量(以秒为单位)
RANDOMIZE_DOWNLOAD_DELAY,在从同一网站获取请求时等待随机时间,降低了由分析请求的站点检测(并随后阻塞)爬行器的机会
CONCURRENT_REQUESTS_PER_DOMAIN,降低并发数量,默认16
AUTOTHROTTLE_ENABLED,开启自动限速
AUTOTHROTTLE_MAX_DELAY,设置最大下载延时
AUTOTHROTTLE_DEBUG,开启【自动限速】的debug
-
爬虫写好后,在服务器后端运行时,将命令写入start.sh,在命令行模式调用nohup sh start.sh > my.log &
在my.log中查看程序运行日志,在log_crawl.txt中查看当前爬取进度
在my.log中查看程序运行日志,在log_crawl.txt中查看当前爬取进度
如需终止nohup,可调用ps -ef|grep sinasf,获取当前爬虫占用的进程号【第二列对应的数字,e.g. 11887】,调用kill -9 11887
5 用命令行清空数据库中的数据
* mysql -uxxx -pxxx -h'xx.xx.x.xxx'【用户名,密码,部署ip】
* use xxx;【数据库名称】
* show tables;
* delete from xx;【表名】
-
最终效果图如图: