2.1 自己的网络爬虫
网络爬虫需要实现的基本功能包括下载网页及对URL地址的遍历。为了高效快速地遍历网站,还需要应用专门的数据结构进行优化。
2.1.1 使用URL访问网络资源
URI包括URL和URN。但是URN并不常用,所以很多人不知道URN。URL由3个部分组成,如图2-1所示。
图2-1 URL分为3个部分
● 第一部分是协议名(也可称为服务方式)。
● 第二部分是存有该资源的域名或IP地址(有时也包括端口号)。
● 第三部分是主机资源的具体地址,如目录和文件名等。
第一部分和第二部分用“://”符号隔开,第二部分和第三部分用“/”符号隔开。第一部分和第二部分是不可缺少的,第三部分有时可以省略。
在交互式编程环境JShell中,实验Java中的URL对象如下:
按组合键Ctrl+D退出JShell。
可以通过DNS取得该URL域名的IP地址。在Linux操作系统中,DNS解析的问题可以用dig或nslookup命令进行分析,具体如下:
如果需要更换更好的DNS域名解析服务器,可以编辑DNS配置文件/etc/resolv.conf。
Windows操作系统中也有nslookup命令。可以使用默认的DNS服务器查询IP地址:
使用指定的DNS服务器查询IP地址:
下载一个网页文本的简单例子如下:
需要注意的是,这里没有下载网页中相关的图片等,如果要下载网页中的图片,就需要分析其中的<img>标签然后下载。
Web服务器不仅返回了请求网页的源代码,还返回了头信息。使用curl命令可以查看到返回的头信息:
返回的第一行结果中包含了HTTP状态码200。
状态码是一个包括3个数字的结果代码,爬虫可以用状态码识别Web服务器处理的情况。状态码的第一个数字定义响应的类别,后两个数字有分类的作用。
● 1xx:信息响应类,表示接收到请求并且继续处理。
● 2xx:处理成功响应类,表示动作被成功接收、理解和接受。
● 3xx:重定向响应类,为了完成指定的动作,必须接受进一步处理。
● 4xx:客户端错误,客户请求包含语法错误或不能正确执行。
● 5xx:服务端错误,服务器不能正确执行一个正确的请求。
HTTP常用状态码如表2-1所示。
表2-1 HTTP常用状态码
使用HTTP客户端开源项目OkHttpClient得到HTTP状态码的代码如下:
2.1.2 重试
为了使爬虫可以长期稳定运行,需要处理各种超时异常,并且在放弃下载之前需要多次重试。最简单的重试代码如下:
需要注意的是,这里只是捕捉了IOException类型的异常,无法捕捉到所有的异常。如果要捕捉所有的异常,则需要捕捉Throwable类型的异常。使用HTTP客户端开源项目OkHttpClient下载网页并重试的代码如下:
通过注解设置最大重试次数:
下载类使用注解声明重试次数:
实现重试:
Spring Retry是一个支持失败后重试操作的框架。为了使用Spring Retry,需要先在build.gradle文件中增加如下依赖项:
首先定义一个需要重试的服务类,然后在配置类中提供得到这个服务类的实例的方法。下载服务类DownService的实现如下:
应用程序类CrawlerApplication调用服务类实现重试下载:
为了改进Spring Retry,可以下载Spring Retry源代码,然后在本地修改并编译源代码。
可以使用git命令下载源代码:
也可以使用svn命令下载源代码:
使用mvn命令编译源代码:
为了忽略编译过程中的错误MavenReportException:Error while generating Javadoc,可以修改pom.xml文件,Javadoc插件增加了配置项<failOnError>false</failOnError>:
创建一个项目,用于测试打包出来的spring-retry,将这个项目的build.gradle文件设置成从本地库加载依赖项:
测试支持重试的服务:
2.1.3 网络爬虫的遍历与实现
通用的网络爬虫通过对URL链接的遍历来获取所需要的信息。基本的数据结构包括一个待扩展的URL表和一个已经访问过的URL地址表,如图2-2所示。
图2-2 基本的数据结构
在抓取网页的时候,网络爬虫一般有两种策略:广度优先和深度优先(见图2-3)。广度优先是指网络爬虫会先抓取起始网页中链接的所有网页,然后选择其中的一个链接网页,继续抓取在此网页中链接的所有网页。这是最常用的策略,因为这种策略可以使网络爬虫并行处理,从而提高其抓取速度。深度优先是指网络爬虫会从起始页开始,一个链接一个链接地跟踪下去,处理完这条线路之后再转入下一个起始页,继续跟踪链接。
图2-3 网络爬虫的两种抓取策略
广度遍历采用队列的方式实现todo表的扩展,先访问的网页先扩展,对如图2-3所示的todo表和visited表的执行状态如下。
todo:A
visited:null
todo:B C D E F
visited:A
todo:C D E F
visited:A B
todo:D E F
visited:A B C
todo:E F
visited:A B C D
todo:F H
visited:A B C D E
todo:H G
visited:A B C D E F
todo:G I
visited:A B C D E F H
todo:I
visited:A B C D E F H G
todo:null
visited:A B C D E F H G I
深度遍历采用堆栈的方式实现todo表的扩展,先访问的网页先扩展,对如图2-3所示的todo表和visited表的执行状态如下。
todo:A
visited:null
todo:B C D E F
visited:A
todo:B C D E G
visited:A F
todo:B C D E
visited:A F G
todo:B C D H
visited:A F G E
todo:B C D I
visited:A F G E H
todo:B C D
visited:A F G E H I
todo:B C
visited:A F G E H I D
todo:B
visited:A F G E H I D C
todo:null
visited:A F G E H I D C B
seeds和新发现的链接应该放在两个列表中。每次得到下一个要遍历的链接时,如果当前 seeds 列表中还有没有开始遍历的,就应该先开始这一个。这样可以避免一个站点遍历过深,而另一个站点却没有机会开始。
seeds列表可以是一个Excel表格。Apache POI(https://poi.apache.org)可以读取Excel表格中的数据。增加如下依赖项:
读取指定单元格数据的方法如下:
读取Excel表格中的种子列表:
2.1.4 多线程爬虫
可以使用多线程加快网页下载速度。下面先介绍Java中的多线程。
因为Java不允许继承多个类,所以一个类一旦继承了Thread类,就不能再继承其他类。为了避免所有线程都必须是Thread的子类,需要独立运行的类也可以继承一个系统已经定义好的叫作Runnable的接口。Thread类有一个构造方法public Thread(Runnable target)。当线程启动时,将执行target对象的run()方法。Java内部定义的Runnable接口很简单:
实现这个接口,然后把要同步执行的代码写在run()方法中。实现run()方法的Test类如下:
运行需要同步执行的代码:
可以用不同的线程处理不同的目录页,如下所示是下载新闻网页的示例:
假设需要在主线程中统计最后抓取了多少数据,则需要等待所有子线程完成。一种实现方法是使用ExecutorService来管理线程池:
2.1.5 Log4j2日志
为了方便调试,可以在抓取过程中记录日志。Log4j2是一个开源的日志框架。为了使用Log4j2记录日志,build.gradle文件增加了如下依赖项:
为了使Log4j模块版本彼此保持同步,可以借助BOM pom.xml文件。BOM(Bill of Materials)是由Maven提供的功能,BOM定义了一整套相互兼容的jar包版本集合,使用时只需要依赖该BOM,即可放心地使用需要的依赖jar包,并且无须再指定版本号。BOM的维护方负责版本升级,并保证BOM中定义的jar包版本之间的兼容性。
通过BOM使用Log4j的build.gradle文件的内容如下:
接下来使用Log4j2来记录日志。如下所示的配置文件log4j2.properties将日志记录输出到控制台:
首先通过LogManager得到Logger类的实例,然后调用logger.info()方法记录日志。记录日志的代码如下:
2.1.6 存储URL地址
todo表或visited表一般用ArrayList或HashMap实现,它们只能在内存中,但内存是有限的。开始的时候,有人把todo表或visited表放在数据库中,但数据库对于这种简单的结构化存储来说,不够轻量级。
Berkeley DB是嵌入式数据库系统,其中的一个数据库只能存储key和value这2列。底层实现采用B树结构,可以看成可以存储大量数据的HashMap。Berkeley DB的简称是BDB,官方网址是http://www.oracle.com/database/berkeley-db/index.html。Berkeley DB的 C++语言版本首先出现,然后在此基础上又实现了 Java 语言的本地版本。可以用Berkeley DB来实现todo表或visited表。
如果使用Maven构建项目,则可以在pom.xml文件中添加如下依赖项:
如果需要把Maven项目转换成Gradle项目,就需要在包含POM的目录中运行gradle init。这会将 Maven 构建转换为 Gradle 构建,生成 settings.gradle 文件和一个或多个build.gradle文件。
为了使用Berkeley DB,build.gradle文件增加了如下依赖项:
Berkeley DB用到的对象主要有以下几种。
● 新建环境变量:
● 释放环境变量:
● 创建数据库:
● 建立数据的映射:
使用DocIDServer类记住哪些URL已经访问过,实现了增量采集。其中,DocIDServer.getNewDocID(url)方法用于记住一个已经访问过的 URL。DocIDServer.isSeenBefore(url)方法用于判断一个URL是否已经访问过。DocIDServer类的实现如下:
使用DocIDServer类的测试代码如下:
判断URL地址是否已经抓取过还可以借助布隆过滤器(Bloom Filter)。布隆过滤器的实现方法如下:在内存中开辟一块区域,对其中的所有位上置 0,然后对数据做多次不同的hash,每个hash值对内存位数求模,求模得到的数在内存对应的位上置1。置位之前需要先判断是否已经置位,每次插入一个URL,只有当全部位都已经置1之后才认为是重复的。
下面是一个简单的示例:
如果想知道需要使用多少位才能降低错误概率,可以使用如表 2-2 所示的给定项目和位数比率的布隆过滤器误判率表。
表2-2 布隆过滤器误判率表
为每个URL分配2字节就可以达到千分之几的冲突。比较保守的实现是为每个URL分配了4字节,项目和位数比是1∶32,误判率是0.000 000 211 673 40。对于5000万个URL,布隆过滤器只占用了200MB的空间,并且排重速度超快,遍历一遍用时还不到2分钟。
SimpleBloomFilter把对象映射到位集合的方法如下:
该实现方法计算了k个相互独立的hash值,因此误判率较低。
如下所示的代码把布隆过滤器的状态保存到文件中:
如下所示的代码把布隆过滤器的状态从先前保存的文件中读出来:
2.1.7 定向采集
对于不同类型的网站,网络爬虫遍历和获取有效信息的方式也不同。有的网站详情页URL中存在自增ID,可以直接遍历;有的网站按类别列出显示详情的详情页,也就是按列表页和详情页组织网站结构,如 http://politics.people.com.cn/GB/1024/。从列表页提取详情页的代码如下:
可以把新发现的列表页放入工作队列。直接处理发现的详情页,详情页的URL不需要加入工作队列,因为当时就处理完了。使用内存数据库记录已经处理过的目录页和详情页:
全局变量workQueue记录已经发现待处理的列表页:
爬虫运行时,先把列表首页放入工作队列,然后使用一个循环处理列表页的工作队列:
详情页和列表页往往包含一些有效信息。详情页处理器接口的定义如下:
用NewsDetailHandler类实现DetailHandler:
2.1.8 暗网抓取
暗网是指只有提交检索词才能得到相关的结果的索引列表,然后根据这个索引列表获取详情页。
URL中提供的查询词需要编码,可以调用URLEncoder.encode()方法实现编码:
通过列表页的方式遍历临床试验信息:
这里通过down_fmt参数指定返回XML格式的文件。
如果要在不将任何HTML DOM规则应用于文件的情况下解析XML,请使用XmlTreeBuilder,用法示例如下:
2.1.9 Selenium抓取动态页面
很多网站采用复杂的 JavaScript 实现动态界面效果,经常会碰到需要抓取动态网页的情况。
可以使用Selenium操控浏览器。可以使用Selenium让浏览器自动下载某个网页或填入登录密码等。Selenium-WebDriver直接调用本机的浏览器,执行自动化任务。Selenium把下载的过程当作黑盒子。Selenium的核心代码通过JavaScript完成,可以运行在Firefox或Chrome等浏览器中。这里以Firefox为例说明用Selenium抓取数据的方法。
Selenium Java API最基本的就是org.openqa.selenium.WebDriver类。首先在Java项目中引入Selenium相关的依赖项:
从https://github.com/mozilla/geckodriver/releases下载FirefoxDriver。Windows操作系统中的FirefoxDriver就是geckodriver.exe,然后通过属性设置FirefoxDriver文件所在的路径:
使用WebDriver访问网址:
下载网页时,后台会启动一个浏览器进程。调用 WebDriver.quit()方法可以结束这个进程,但调用WebDriver.close()方法并不会结束这个进程。
如果只需要得到网页源代码,则可以不加载图像:
需要等待网页加载完毕,然后获取网页源代码。一个显式等待是已经定义的一段代码,用于等待某个条件发生,然后继续执行后续代码。最简单的方法是调用Thread.sleep(),具体示例如下:
WebDriverWait结合ExpectedConditions可以实现只等待需要的时间长度:
通过类型选取元素:
通过id查找元素并单击它:
可以通过JavascriptExecutor对象来执行JavaScript代码。例如,得到垂直滚动条的高度:
逐渐向下滚动:
如果window.scrollBy(x,y)中的y值为负数,则表示向上滚动:
2.1.10 图片抓取
为了能够节约网络流量,抓取过来的图片经常需要缩小到一定的尺寸,如 100px×100px或80px×80px: