整合营销服务商

电脑端+手机端+微信端=数据同步管理

免费咨询热线:

网路爬虫:不生产内容,只是内容的搬运工

索引擎和聚合类新闻App之所以有源源不断的新内容供予用户浏览,原因就在于有网络爬虫技术的加持。网络爬虫的应用对于用户来说,是一大福利——我们可以从一个搜索引擎轻松搜索到各个领域的信息。但是,对于原创方来说,就涉及到版权被侵犯的问题了。工具理性,但不意味着操持工具的人就可以假借“工具理性”肆意侵犯他人的合法权益,网络爬虫技术的应用还应该要在法理之内。

一、初识爬虫

工作的时候,想要查找“产品设计”,可以直接在搜索引擎上输入内容,就可以直接找到数以百万计的资料。

上下班路上,刷新闻类APP的时候,只要愿意,就会有源源不断的新的信息,足够刷一路的时间。

搜索引擎和(大多数)新闻类APP都不自己生产内容(虽然有些平台孵化了自己的内容,但也只占整个平台内容的很少的一部分,更重要的是,成本非常高)。

那么,他们的大量的内容从哪里来?

“我们不生产内容,只是内容的搬运工”,将互联网上的内容“搬运”到自己的服务器上,这就是爬虫。

首先,我们需要了解一下互联网的结构。

互联网上的内容数以亿计,虽然很复杂,但说白了就是一张大网,网上的每个节点就是一个网页,连接网页的超链接(Hyperlinks)相当于线,线把所有的节点连接在一起,形成了一个复杂的网。

通过点击超链接的文字或者图片,就可以跳转到对应的网页。爬虫可以自动访问到每一个网页,并把网页的内容保存下来。

世界上第一个网络爬虫由麻省理工学院的学生马修·格雷(Matthew Gray)在1993年写成,之后的爬虫尽管越来越复杂。

比如:可以实现更快的访问速度、访问更多的网页、更好的将网站内容解析出来。但爬虫的基本原理是一样的,都主要包括三个部分:访问网页链接,下载网页内容,解析网页内容。

爬虫的工作过程与我们查找网页的过程是一样的。

比如,我们想要查一下豆瓣上最新的电影:首先,在浏览器地址栏输入网址链接https://movie.douban.com/,之后,浏览器会跳转到豆瓣电影。最后,我们就可以找到当前热映的电影。

同样的,一个最简单的爬虫三步就可以爬取一个网页——首先,访问这个网页,之后,把网页内容下载下来,最后,对下载的内容进行解析。

二、7行代码爬取豆瓣电影

最简单的爬虫三步就可以爬取一个网页,那么要写多少行代码呢?

我们写一个爬虫,爬取豆瓣的“一周口碑榜”,只要7行代码!

这里我们使用Python语言,至于为什么选择Python语言,会在后面交代清楚,如果不懂Python也没有关系,了解爬虫是如何工作的就可以了。

代码如下:

import requests from lxml

import html url=’https://movie.douban.com/’ # 1、需要爬数据的网址

page=requests.Session.get(url) # 2、访问网页

tree=html.fromstring(page.text) # 3、解析网页的过程

result=tree.xpath(‘//td[@class=”title”]//a/text’) #3、解析网页的过程

print(result) # 打印出结果

在Python环境中运行这几行代码,就可以获取“一周口碑榜”了,结果如下:

[‘迦百农’, ‘绿皮书’, ‘驯龙高手3’, ‘速成家庭’, ‘阿丽塔:战斗天使’, ‘肤色’, ‘死亡天使’, ‘黎明墙’, ‘小小巨人’, ‘出·路’]

其中最关键的是解析网页内容,主要是(‘//td[@class=”title”]//a/text’)这行代码,大多数人可能对比较困惑。

这涉及到HTML网页的结构,可以把网页理解成一个文件夹,打开一个文件夹,会发现子文件夹,子文件夹或许还有文件夹。通过打开一个个文件夹,最终找到需要的数据。

  1. //td :这个相当于大目录;
  2. [@class=”title”]:这个相当于小目录;
  3. //a :这个相当于最小的目录;
  4. /text:这个是提取其中的文字内容。

至于是怎么写出来这行代码的,可以通过在网页空白处点击右键,查看源代码,就可以找到对应的td、class=”title”、a等标识符。

大多数程序员写爬虫选择python的理由很简单.

首先,python有很多的库,可以直接调用,比如:上面的代码就引入了requests、lxml库,分别实现访问网页、对网页结构解析。有开源的库,就直接调用,避免重复造轮子。

其次,python写起来很方便,配置也简单,短短几行的代码,就可以直接运行了,如果使用C或者Java,可能配置环境就要老半天。

三、一个简答的爬虫系统

把上面的每个步骤分别实现(模块化),就可以构成一个简答的爬虫系统。

使用URL(可以理解为网址链接)管理器管理所有的网址链接,使用HTML(可以理解为网页内容)下载器下载网页内容,使用HTML解析器对下载的内容解析,再加上数据存储模块、控制整个爬虫的调度模块,就构成了一个简单的爬虫系统。

爬虫基本架构

更具体的说,URL管理器负责管理所有的网址链接,记录下哪些URL已经爬取了,哪些还没有爬取。如果爬取过了,就要避免再次下载,如果没有,就要加入队列,等HTML下载器下载。

HTML下载器可以从服务器下载整个网页的内容,从URL管理器中获取未爬取的网址链接,之后,访问这些网页链接,下载网页。

HTML解析器负责解析下载好的网页,主要有两个任务:一方面,解析出需要的信息,比如上文的“一周口碑榜”;另一方面,解析出新的URL链接,交给URL管理器,继续下载,这个功能在上面的“7行代码”没有实现。

数据存储器实现存储数据的功能,将HTML解析器解析出来的信息存储起来,否则每次使用都要下载,会浪费大量的时间。图片、文档之类的文件可以直接保存到服务器上,文字类的可以通过数据库存储起来。

爬虫调度器作为系统的大脑,负责统筹其他四个模块的协调工作。

无论是大型的还是小型的爬虫虽然在设计细节,性能上有所不同,但都不会脱离这五个模块。

四、更深入的考虑

乍一看,每个模块实现起来都很简单,但细想,似乎每个模块都要考虑很多东西。

1. 初始的网址链接如何获得

7行代码爬取豆瓣电影,直接访问网址链接(https://movie.douban.com/)就可以爬取“一周口碑榜”。对稍大一些的爬虫系统或者商用爬虫,就要有更多的考虑了,在保证获取充足信息的同时,也要保证下载的质量。

对搜索引擎公司而言,要尽可能包括互联网所有的信息。对垂直领域,更多的偏向业务类信息,比如:对新闻类的APP,主要包括一些新闻网站、政府网站等,对Github这类的编程网站,他们可能就不感兴趣。

巧妇难为无米之炊,初始的网址链接基本要靠人工凭经验获取,比如:新闻类的APP,他们的初始URL列表里可能就包括新浪、网易、搜狐等门户网站,也包括各个级别的政府网站,还有人民网、新华社、人民日报等媒体的网站。

2. 如何确定哪些网页已经下载过了

当一个页面下载完成后,从这个网页中提取出其中的网址链接,把它们添加到等待下载的队列中,就可以获得更多的网址链接。

如果一个网页已经下载过了,重新下载,会消耗大量的时间,并占用存储空间。更要命的是,如果一直重复下载,就可能陷入死循环。

那么,如何知道这网址链接是不是已经下载过了?

对于小型爬虫,可以使用列表存储下载过的网址链接,当有新的网址链接的时候,先查找这个列表中有没有该网址链接。如果有的话,就不用插入,如果没有的话,就插入列表,等待访问下载。

对于大型爬虫,有成百上千个“小爬虫”(更加专业的名词叫做分布式爬虫),分布在不同的服务器上,同时爬取网址链接,就要考虑更多的东西。

比如:不同爬虫之间的分工和通信,如何共同维护上述的列表。

当数据很大的时候,就要考虑分布式、通信、存储、带宽等每个环节的限制,无论哪个环节没有做好,都有可能成为系统的瓶颈,这就像是木桶效应中的短板。

数据量增加10倍,之前的代码可能要重写了,工作量可能就要增加100倍,这也是量变引起质量的一个很好的例子。

在计算机领域,这样的例子随处可见,当数据增大到一定量级,原有的算法很可能无法继续使用,需要重新开发,随之而来的是加班、DEBUG以及延期上线。

3. 页面的解析

爬取豆瓣电影的“一周口碑榜”,需要研究网页的源代码,并编写对应的解析代码。但是网页的结构不同,用这个代码爬取知乎,解析不到任何内容。

以新闻类的APP为例:一个好的新闻类APP需要爬虫数以亿计的网页,并把里面的文字、视频、图片分别解析出来,难度可想而知。

好消息是一部分网站会遵守RSS规范(遵守RSS规范的网页结构和代码都有相似性,以便于订阅器获取主要信息),一种类型的爬虫就可以爬取大量这种类似的网页。但大部分的网站的结构,都是不同的,这需要算法工程师花费大量的时间和精力做解析工作。

五、 反爬虫

新闻类APP通过爬虫,获得大量的优质资源,读者也乐意在一个平台上看到所有的内容,但“被爬取”的网站就不太高兴了。对于大多数依靠广告收入的网站,没有了流量,连生存都成了问题,更别说盈利了。

一些自成体系的平台,比如:大型电商平台,他们希望所有的用户在自己的平台上查找信息,所有的商家在自己的平台上吸引卖家(广告费可不能付给搜索引擎),同样不希望爬虫的骚扰。

搜索引擎希望爬取更多的信息,优质的内容提供商又不希望被爬虫骚扰,利益冲突难以调和,于是产生了Robots协议来解决这个问题。

Robots协议网站服务器的一个声明,通常是保存在网站根目录下的一个TXT格式的文件,网站通过Robots协议告诉搜索引擎:哪些页面可以抓取?哪些页面不能抓取?

当爬虫访问一个站点时,它会首先检查该站点根目录下是否存在robots.txt,如果存在,爬虫就会按照该文件中的内容来确定访问的范围;如果该文件不存在,所有的爬虫将能够访问网站上所有没有被口令保护的页面。

我们使用搜索引擎,经常会看到“由于该网站的robots.txt文件存在限制指令(限制搜索引擎抓取),系统无法提供该页面的内容描述”,就是源于这个协议。

值得注意的是:Robots协议是国际互联网界通行的道德规范,并没有强制性约束力。

一些“没有道德”的爬虫同样会爬取有robots.txt限制指令的网站,这时候就需要一些技术来实现反爬虫了。

最常见的有三种方式:

1. 网站会根据IP地址访问的频率确定是不是爬虫

每个电脑都有唯一的IP地址,每个爬虫也有唯一的IP地址,当电脑或者爬虫访问网站的时候,网站会记录这个IP地址。如果同一个IP短时间多次访问同一个网站,这个网站可能会倾向于认为这是个爬虫,会采取一些措施。

当然,这在反爬虫的同时,也会给用户带来一些不好的体验。

相比之下,一些比较优秀的网站或者APP,会根据用户点击频率、时间间隔等信息,判断是不是爬虫或者误点击,之后再确定是否需要验证。

更好的用户体验背后,是更大的开发成本,更长的开发周期。

2. 网站也可以根据用户请求的Headers来判断是不是爬虫

当我们使用浏览器访问网站的时候,浏览器会自动在访问请求上添加一些信息,比如:浏览器采用的编码方式、使用的操作系统、浏览器版本等信息放在访问请求的最开始,作为Headers,但爬虫一般不会附加这些信息。

网站会根据是否存在Headers信息以及Headers信息的内容,判断对方是不是爬虫,有必要的话,就拒绝访问。

3. 动态页面的反爬虫

之前将的HTML网页都是静态的,随着HTML代码生成,页面的内容和显示效果就不会发生变化了。而动态网页则不然,动态网站是脚本语言(比如PHP)生成的,一些内容不是直接可见的,而是要运行一些脚本,才能看到。

网址后缀为htm、html、shtml、xml的网页是静态网页,而动态网页是以·aspx、.asp、.jsp、.php、.perl、.cgi等形式为后缀,并且在动态网页网址中有一个标志性的符号“?”,这些不同的后缀基本代表了网页使用的语言。

访问静态网页,只需要直接访问链接就可以了,访问动态网站,还需要执行一些特定的操作(比如点击),才能显示更多的内容,这就增加了爬取的难度,一些简单的爬虫就被拒之门外了。

介绍完三种主流的反爬虫的方式,最后要说的是:反爬虫技术也不是一劳永逸的,在反爬虫的发展过程中,爬虫也进化出了一系列反“反爬虫”的方式。

针对反爬虫验证IP机制,爬虫“进化”出了IP代理池,这样,爬虫就可以不断变换自己的IP地址,迷惑反爬虫。针对Headers验证,爬虫也会生成一个Headers信息,甚至针对动态页面,也会模拟浏览器的行为。

虽然如此,反爬虫在一定程度上提高了爬虫的成本,降低了爬虫的效率,就可以将一大部分爬虫挡在门外。

从爬虫与反爬虫的例子也可以看出:大多数时候,没有绝对的有效方式。提高对方的成本,让对方觉得代价太大,得不偿失,就是很好的解决问题的办法。

六、爬虫实现冷启动——胜利即正义?

上面讲了爬虫是怎么运行的,常见的反爬虫机制。最后,我们再讲一个爬虫的应用场景的例子,可以帮助我们更好理解爬虫。

冷启动是每一个产品经理、运营人员和创业者面临的重大问题。没有优质的内容,就吸引不了用户,没有大量的用户,就无法吸引优质的内容,就陷入了先有鸡还是先有蛋的悖论。

爬虫,低成本、快速地解决了这个问题!

“我们不生产新闻,我们只是新闻的搬运工”,通过爬虫,低成本、快速地爬取整个互联网的优质内容,并凭借海量数据,利用算法实现内容分类和个性推荐(个性推荐系统会在后序章节详细介绍),吸引大量的用户,最终通过广告变现。

事实证明,这是个非常成功的商业模式。而媒体平台和新闻网站雇佣大量编辑人员,花费大量时间、金钱写成的高质量内容,连说一声都没有,就这样被拿走了,这不是侵犯人家版权嘛!

于是,多家媒体联合发起侵权诉讼或抗议声讨,最终迫使这家新闻巨头支付版权费,但无论法律上、道德上有多少问题,都不影响这家公司商业成功的既定事实。

类似的事情同样发生在其他垂直领域。

一家新成立的技术博客平台,爬取竞争对手上的文章,迅速实现优质内容的聚合。如果原博客主发现了自己的文章被盗用了,新的平台就移交账号并看情况给予少许补偿。如果对方不乐意,就注销账号,当一切都没有发生过。凭借这种运营方式,顺利实现了冷启动。

短视频APP的后来者,也可以通过类似的方式,实现用户的积累和优质内容的聚合。

胜利即正义?

这似乎是过于武断的一个评价。

上述的视频APP做得太过分,引起公愤,最终不得不关闭自己的平台。

对于通过爬虫获取内容的平台而言,内容的获取也只是万里长征的第一步,通过运营手段减小生产内容的成本,通过利益共享激励优质内容的产生,通过技术减小信息成本吸引用户,更加任重而道远。

而版权,也始终是悬于头顶的达摩克利斯之剑。

本文由@linghu 原创发布于人人都是产品经理,未经许可,禁止转载

题图来自Unsplash, 基于CC0协议

一个用于提取简体中文字符串中省,市和区并能够进行映射,检验和简单绘图的python模块。

举个例子:

["徐汇区虹漕路461号58号楼5楼", "泉州市洛江区万安塘西工业区"]

↓ 转换

|省 |市 |区 |

|上海市|上海市|徐汇区|

|福建省|泉州市|洛江区|

如果你只是想快速实现以上类型的数据处理的话,那么只需要复制以下代码,不需要过多阅读本文档的内容(复制代码之前,先阅读安装说明将本模块装上):

location_str = ["徐汇区虹漕路461号58号楼5楼", "泉州市洛江区万安塘西工业区", "朝阳区北苑华贸城"] #任意的可迭代类型,比如Series也可以

from chinese_province_city_area_mapper.transformer import CPCATransformer

from chinese_province_city_area_mapper import myumap

cpca = CPCATransformer(myumap.umap)

df = cpca.transform(location_str)

df


安装说明

代码目前仅仅支持python3

pip install chinese_province_city_area_mapper


特点

  • 基于jieba分词进行匹配,同时用比较复杂的匹配逻辑保证了准确率,笔者根据手头的海量地址描述数据进行了测试
  • 自带完整的省,市,区三级地名及其经纬度的数据
  • 支持自定义省,市,区映射
  • 输出的是基于pandas的DataFrame类型的表结构,易于理解和使用
  • 封装了简单的绘图功能,可以很方便地进行简单的数据可视化
  • MIT 授权协议


Get Started

本模块中最主要的类是chinese_province_city_area_mapper.transformer.CPCATransformer(注:CPCA是Chinese Province City Area的缩写),该类的transform方法可以输入任意的可迭代类型(如list,Series等),然后将其转换为一个DataFrame,示例代码如下:

location_str = ["徐汇区虹漕路461号58号楼5楼", "泉州市洛江区万安塘西工业区", "朝阳区北苑华贸城"]

from chinese_province_city_area_mapper.transformer import CPCATransformer

cpca = CPCATransformer()

df = cpca.transform(location_str)

df

输出的结果为:

区 市 省

0 徐汇区 上海市 上海市

1 洛江区 泉州市 福建省

2 朝阳区

从上面的程序输出中你会发现朝阳区并没有被映射到北京市,这是因为在中国有多个同名的叫做朝阳区的区,并且他们位于不同的市,所以程序就不知道该映射到哪一个市了(举个例子,南京市有一个鼓楼区,开封市有一个鼓楼区,福州市也有一个鼓楼区,这样程序就不知道应该把鼓楼区映射到哪一个市了),因此就不对其进行映射,如果你确定你的数据中的朝阳区都是指北京市的那个朝阳区的话,可以在CPCATransformer的构造函数中传一个字典(叫做umap参数,是user map的简称),指定朝阳区都要映射到北京市,注意只有区到市的这一级映射存在重名问题,中国的市的名称都是唯一的,省的名称也都是唯一的,示例代码如下:

location_str = ["徐汇区虹漕路461号58号楼5楼", "泉州市洛江区万安塘西工业区", "朝阳区北苑华贸城"]

from chinese_province_city_area_mapper.transformer import CPCATransformer

cpca = CPCATransformer({"朝阳区":"北京市"})

df = cpca.transform(location_str)

df

输出结果为:

区 市 省

0 徐汇区 上海市 上海市

1 洛江区 泉州市 福建省

2 朝阳区 北京市 北京市

模块中还内置了一个我推荐大家使用的umap,这个umap中我根据处理地址数据的经验将那些重名的区映射到了它最常见的一个市,这个umap位于chinese_province_city_area_mapper.myumap.umap,使用如下:

location_str = ["徐汇区虹漕路461号58号楼5楼", "泉州市洛江区万安塘西工业区", "朝阳区北苑华贸城"]

from chinese_province_city_area_mapper.transformer import CPCATransformer

from chinese_province_city_area_mapper import myumap

print(myumap.umap) #查看这个umap的内容

cpca = CPCATransformer(myumap.umap)

df = cpca.transform(location_str)

df

输出和上一个程序一样

模块中还自带一个简单绘图工具,可以在地图上将上面输出的数据以热力图的形式画出来,代码如下:

from chinese_province_city_area_mapper import drawers

#df为上一段代码输出的df

drawers.draw_locations(df, "df.html")

这一段代码运行结束后会在运行代码的当前目录下生成一个df.html文件,用浏览器打开即可看到 绘制好的地图(如果某条数据'省','市'或'区'字段有缺,则会忽略该条数据不进行绘制),速度会比较慢,需要耐心等待,绘制的图像如下:

draw_locations函数还可以通过指定path参数来改变输出路径,示例代码如下::

from chinese_province_city_area_mapper import drawers

#在当前目录的父目录生成df.html

drawers.draw_locations(df, "df.html", path="../")

到这里就你就已经知道了本模块的基本使用了,接下来我会阐明更多细节。


数据接口

本模块自带全国省市区的映射关系及其经纬度,如果你只是想使用这个数据的话可以使用如下代码:

from chinese_province_city_area_mapper.infrastructure import SuperMap

#地区到市的映射数据库,是一个字典类型(key为区名,value为其所属的市名),注意其中包含重复的区名

SuperMap.area_city_mapper

#重复的区名列表,列表类型,如果区名在这个列表中,说明存在多个同名区,则area_city_mapper的映射是不准确的

SuperMap.rep_areas

#市到省的映射数据库,字典类型(key为市的名称,value为省的名称)

SuperMap.city_province_mapper

#全国省市区的经纬度数据库,字典类型(key为"省,市,区",value为(维度,经度))

SuperMap.lat_lon_mapper

#获取北京市朝阳区的经纬度

SuperMap.lat_lon_mapper.get("北京市,北京市,朝阳区")

#获得一个地名的级别(即省,市或者区)

SuperMap.getType("江苏省") #返回"province",即常量SuperMap.PROVINCE

SuperMap.getType("南京市") #返回"city",即常量SuperMap.CITY

SuperMap.getType("海淀区") #返回"area",即常量SuperMap.AREA

#省略"省"字也能够识别出来

SuperMap.getType("江苏")


关于匹配与映射的细节

为了保证匹配与映射的正确性,我做了很多细节上的处理,如果在使用本模块的过程中遇到困惑可以参考这里。

  • 能够匹配到省或者市的缩写,比如将"北京市"缩写为"北京","江苏省"缩写为"江苏",依旧能够匹配到并且能够自动补全为全称,示例代码如下:

#测试数据

location_strs = ["江苏省南京市鼓楼区256号", "江苏南京鼓楼区256号"]

from chinese_province_city_area_mapper.transformer import CPCATransformer

cpca = CPCATransformer()

df = cpca.transform(location_strs)

df

输出的结果为:

区 市 省

0 鼓楼区 南京市 江苏省

1 鼓楼区 南京市 江苏省

  • 能够自动检测字符串中匹配到的省,市和区是否是所属关系,如果不是所属关系的话,则会删去优先级较低的(注:如果匹配到的是缩写的话,即将"南京市"缩写为"南京",则认为优先级较低),如果优先级一样的话,则删除地域范围较小的,示例代码如下:

#测试数据,一些故意错乱的地址描述

location_strs = ["静安区南京西路30号", "南京市静安区", "江苏省上海市", "上海市静安区南京西路"]

from chinese_province_city_area_mapper.transformer import CPCATransformer

cpca = CPCATransformer()

df = cpca.transform(location_strs)

df

输出结果如下:

区 市 省

0 静安区 上海市 上海市

1 南京市 江苏省

2 江苏省

3 静安区 上海市 上海市

分析:第一个测试数据"静安区南京西路"会同时匹配到"静安区"和"南京"两个地域名称,但是静安区是属于上海的,和"南京"想矛盾,而且因为"南京"是"南京市"的缩写,因此优先级比较低,故放弃"南京"这个地域名称。

第二个测试数据匹配到"南京市"和"静安区"两个矛盾的地域名称,而且这两个名称都是全称,优先级相同,所以保留地域范围比较大的,即保留"南京市"而放弃"静安区"。第三个测试数据也是一样的道理。

第四个测试数据中有两个市的名称会被匹配到,一个是"上海市",还有一个是"南京",但是因为"上海市"在前面被匹配到了,所以"南京"就会被忽略。


测试数据

本仓库放了一份大约一万多条地址描述信息addr.csv,这是我当时测试与开发用的数据,目前的版本可以保证比较高的准确率,大家可以用这个数据继续进行测试,测试代码如下:

#读取数据

import pandas as pd

origin = pd.read_csv("addr.csv")

#转换

from chinese_province_city_area_mapper.transformer import CPCATransformer

from chinese_province_city_area_mapper import myumap

cpca = CPCATransformer(myumap.umap)

addr_df = cpca.transform(origin["原始地址"])

#输出

processed = pd.concat([origin, addr_df], axis=1)

processed.to_csv("processed.csv", index=False, encoding="utf-8")

注意以上代码会产生产生大量的warnning,这些warnning是因为程序无法确定某个区县属于哪个市(因为这些区县存在重名问题而且在umap中又没有指定它属于哪一个市).

绘图代码:

from chinese_province_city_area_mapper import drawers

#processed为上一段代码的processed

drawers.draw_locations(processed, "processed.html")

绘制的局部图像如下:

(注意:本模块在绘图时,只绘制那些可以精确地匹配到省市区的地址,对于省市区有一个或多个字段缺失的则会直接忽略)

-----------------------------------以下为2.0版本新增的接口----------------------------------------------


更新模块

通过pip list查看模块版本,如果版本低于2.0,则应该使用如下的命令更新模块:

pip install -U chinese_province_city_area_mapper


新的绘图接口

之前版本的绘图接口是基于folium编写的,但是在国内folium的地图显示速度太慢了,所以2.0版本在保留原本的folium绘图接口的基础上添加了echarts的绘图接口.

第一个接口是echarts热力图绘制接口,代码如下,仍然使用之前的测试数据生成的processed变量:

from chinese_province_city_area_mapper import drawers

drawers.echarts_draw(processed, "test.html")

该接口的更多参数及其含义如下:

def echarts_draw(locations, fileName, path="./", title="地域分布图"

, subtitle="location distribute"):

"""

生成地域分布的echarts热力图的html文件.

:param locations: 样本的省市区, pandas的dataframe类型.

:param fileName: 生成的html文件的文件名.

:param path: 生成的html文件的路径.

:param title: 图表的标题

:param subtitle: 图表的子标题

"""

然后会在当前目录下生成一个test.html文件,用浏览器打开后即可看到图像:

第二个接口是样本分类绘制接口,通过额外传入一个样本的分类信息,能够在地图上以不同的颜色画出属于不同分类的样本散点图,以下代码以“省”作为类别信息绘制分类散点图(可以看到,属于不同省的样本被以不同的颜色标记了出来,这里以“省”作为分类标准只是举个例子,实际应用中可以选取更加有实际意义的分类指标):

from chinese_province_city_area_mapper import drawers

drawers.echarts_cate_draw(processed, processed["省"], "test2.html")

然后会在当前目录下生成一个test2.html文件,用浏览器打开后即可看到图像:

该接口更多的参数及其含义如下:

def echarts_cate_draw(locations, labels, fileName, path="./"

, title="地域分布图", subtitle="location distribute",

point_size=7):

"""

依据分类生成地域分布的echarts散点图的html文件.

:param locations: 样本的省市区, pandas的dataframe类型.

:param labels: 长度必须和locations相等, 代表每个样本所属的分类.

:param fileName: 生成的html文件的文件名.

:param path: 生成的html文件的路径.

:param title: 图表的标题

:param subtitle: 图表的子标题

:param point_size: 每个散点的大小,如果样本数较少可以考虑设置的大一些

"""

给出的数据集考虑到了模块给出的echarts绘图接口都是基于本模块自带的经纬度数据集的,而不是pyecharts的数据集。如果想更加精细地控制pyecharts绘图参数的话可以直接把本仓库的drawers模块的源码复制过去修改。

项目地址:https://github.com/DQinYuan/chinese_province_city_area_mapper.git

喜欢的可以自己下载使用。

  用过老版本UC看小说的同学都知道,当年版权问题比较松懈,我们可以再UC搜索不同来源的小说,并且阅读,那么它是怎么做的呢?下面让我们自己实现一个小说线上采集阅读。(说明:仅用于技术学习、研究)

  看小说时,最烦的就是有各种广告,这些广告有些是站长放上去的盈利手段,有些是被人恶意注入。在我的上一篇博客中实现了小说采集并保存到本地TXT文件 HttpClients+Jsoup抓取笔趣阁小说,并保存到本地TXT文件,这样我们就可以导入手机用手机阅读软件看小说;那么我们这里实现一个可以在线看小说。

话不多说先看效果

  首页:

  页面很纯净,目前有三种来源

  搜索结果页:

  三个不同的来源,分页用的是layui的laypage,逻辑分页。(笔趣阁的搜索结果界面没有书本的图片)

  翻页效果:

  纵横网连简介等都帮我们分词,搞得数据量太大,速度太慢:books.size() < 888

  书本详情页:

  小说阅读页:


  上、下一章:

代码与分析

  项目是springboot项目,原理非常简单,就是用httpclient构造一个请求头去请求对应的来源链接,用jsoup去解析响应回来的response,

  通过jsoup的选择器去找到我们想要的数据,存入实体,放到ModelAndView里面,前端页面用thymeleaf去取值、遍历数据。

  但是有一些书是要会员才能看,这种情况下我们需要做模拟登陆才能继续采集,这里只是一个简单的采集,就不做模拟登陆了。

  采集过程中碰到的问题:

  1、起点中文网采集书本集合时,想要的数据不在页面源码里面

  起点中文网很机智,他在html代码了没有直接展示page分页信息的链接

  可以看到,httpClient请求回来的response里分页信息标签里面是空的,但用浏览器去请求里面有信息


  这是因为httpClient去模拟我们的浏览器访问某个链接,直接响应回这个链接对应的内容,并不会去帮我们触发其他的ajax,而浏览器回去解析响应回来的html,当碰到img、script、link等标签它会帮我们去ajax请求对应的资源。
  由此推测,page相关的信息,起点中文网是在js代码里面去获取并追加,最后通过network找到它的一些蛛丝马迹

既然他没有写在html里,那我们就自己去创建连接,可以看到html上有当前页跟最大页数

完美

  2、笔趣阁查看书本详情,图片防盗链

  笔趣阁有一个图片防盗,我们在自己的html引入图片路径时,但当我们把链接用浏览器访问时是可以的



  

  对比一下两边的请求头

  首先我们要知道什么事图片防盗链,猛戳这里 -->:图片防盗链原理及应对方法 ;我们直接用大佬的反防盗链方法,并且针对我们的项目改造一下:

<div id="bookImg"></div>
    /**
     * 反防盗链
     */
    function showImg(parentObj, url) {
        //来一个随机数
        var frameid = 'frameimg' + Math.random();
        //放在(父页面)window里面   iframe的script标签里面绑定了window.onload,作用:设置iframe的高度、宽度 <script>window.onload = function() {  parent.document.getElementById(\'' + frameid + '\').height = document.getElementById(\'img\').height+\'px\'; }<' + '/script>
        window.img = '<img src=\'' + url + '?' + Math.random() + '\'/>';
        //iframe调用parent.img
        $(parentObj).append('<iframe id="' + frameid + '" src="javascript:parent.img;" frameBorder="0" scrolling="no"></iframe>');
    }

    showImg($("#bookImg"), book.img);

  效果最终:

  3、采集书本详情时,起点网的目录并没有在html里

  起点网的目录并没有在html里,也不是在另一个链接里

  通过浏览器页面Elements的Break on打断点


  查看调用栈发现,它在js ajax请求数据,进行tab切换,就连总共有多少章,它都是页面加载出来之后ajax请求回来的


  看一下他的请求头跟参数

  只要我们弄懂_csrfToken参数就可以构造一个get请求

https://book.qidian.com/ajax/book/category?_csrfToken=LosgUIe29G7LV04gdutbSqzKRb9XxoPyqtWBQ3hU&bookId=1209977

  通过浏览器查看可知,第一章对应的链接:https://read.qidian.com/chapter/2R9G_ziBVg41/MyEcwtk5i8Iex0RJOkJclQ2

  这个就是我们想要的

  https://read.qidian.com/chapter/ + cU章节链接
  cN章节名称

   _csrfToken是cookie,而且多次刷新都不变,大胆猜测:起点为我们生成cookie并且携带请求ajax,携带与起点给我们的cookie不一致的时候返回失败,

  我们每次调用gather,都是一次新的httpclient对象,每次既然如此,那我们就先获取cookie,在用同一个httpclient去请求数据即可 (详情代码已经贴出来,在BookHandler_qidian.book_details_qidian里面)

   最终我们获得了返回值,是一个json


  同样的,大部分逻辑都写在注释里面,相信大家都看得懂:

  maven引包:

        <dependency>
            <groupId>org.apache.httpcomponents</groupId>
            <artifactId>httpclient</artifactId>
            <version>4.5.4</version>
        </dependency>
        <dependency>
            <groupId>org.apache.httpcomponents</groupId>
            <artifactId>httpcore</artifactId>
            <version>4.4.9</version>
        </dependency>
        <dependency>
            <groupId>org.jsoup</groupId>
            <artifactId>jsoup</artifactId>
            <version>1.11.3</version>
        </dependency>
        <dependency>
            <groupId>net.sf.json-lib</groupId>
            <artifactId>json-lib</artifactId>
            <version>2.4</version>
            <classifier>jdk15</classifier>
        </dependency>

  书实体类:


/**
 * 书对象
 */
@Data
public class Book {

    /**
     * 链接
     */
    private String bookUrl;

    /**
     * 书名
     */
    private String bookName;

    /**
     * 作者
     */
    private String author;

    /**
     * 简介
     */
    private String synopsis;

    /**
     * 图片
     */
    private String img;

    /**
     * 章节目录 chapterName、url
     */
    private List<Map<String,String>> chapters;

    /**
     * 状态
     */
    private String status;

    /**
     * 类型
     */
    private String type;

    /**
     * 更新时间
     */
    private String updateDate;

    /**
     * 第一章
     */
    private String firstChapter;

    /**
     * 第一章链接
     */
    private String firstChapterUrl;

    /**
     * 上一章节
     */
    private String prevChapter;

    /**
     * 上一章节链接
     */
    private String prevChapterUrl;

    /**
     * 当前章节名称
     */
    private String nowChapter;

    /**
     * 当前章节内容
     */
    private String nowChapterValue;

    /**
     * 当前章节链接
     */
    private String nowChapterUrl;

    /**
     * 下一章节
     */
    private String nextChapter;

    /**
     * 下一章节链接
     */
    private String nextChapterUrl;

    /**
     * 最新章节
     */
    private String latestChapter;

    /**
     * 最新章节链接
     */
    private String latestChapterUrl;

    /**
     * 大小
     */
    private String magnitude;

    /**
     * 来源
     */
    private Map<String,String> source;
    private String sourceKey;
}


  小工具类:

/**
 * 小工具类
 */
public class BookUtil {

    /**
     * 自动注入参数
     * 例如:
     *
     * @param src    http://search.zongheng.com/s?keyword=#1&pageNo=#2&sort=
     * @param params "斗破苍穹","1"
     * @return http://search.zongheng.com/s?keyword=斗破苍穹&pageNo=1&sort=
     */
    public static String insertParams(String src, String... params) {
        int i = 1;
        for (String param : params) {
            src = src.replaceAll("#" + i, param);
            i++;
        }
        return src;
    }

    /**
     * 采集当前url完整response实体.toString()
     *
     * @param url url
     * @return response实体.toString()
     */
    public static String gather(String url, String refererUrl) {
        String result = null;
        try {
            //创建httpclient对象 (这里设置成全局变量,相对于同一个请求session、cookie会跟着携带过去)
            CloseableHttpClient httpClient = HttpClients.createDefault();
            //创建get方式请求对象
            HttpGet httpGet = new HttpGet(url);
            httpGet.addHeader("Content-type", "application/json");
            //包装一下
            httpGet.addHeader("User-Agent", "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/69.0.3497.100 Safari/537.36");
            httpGet.addHeader("Referer", refererUrl);
            httpGet.addHeader("Connection", "keep-alive");

            //通过请求对象获取响应对象
            CloseableHttpResponse response = httpClient.execute(httpGet);
            //获取结果实体
            if (response.getStatusLine().getStatusCode() == HttpStatus.SC_OK) {
                result = EntityUtils.toString(response.getEntity(), "GBK");
            }

            //释放链接
            response.close();
        }
        //这里还可以捕获超时异常,重新连接抓取
        catch (Exception e) {
            result = null;
            System.err.println("采集操作出错");
            e.printStackTrace();
        }
        return result;
    }
}

  Controller层:

/**
 * Book Controller层
 */
@RestController
@RequestMapping("book")
public class BookContrller {

    /**
     * 来源集合
     */
    private static Map<String, Map<String, String>> source = new HashMap<>();

    static {
        //笔趣阁
        source.put("biquge", BookHandler_biquge.biquge);

        //纵横中文网
        source.put("zongheng", BookHandler_zongheng.zongheng);

        //起点中文网
        source.put("qidian", BookHandler_qidian.qidian);
    }

    /**
     * 访问首页
     */
    @GetMapping("/index")
    public ModelAndView index() {
        return new ModelAndView("book_index.html");
    }

    /**
     * 搜索书名
     */
    @GetMapping("/search")
    public ModelAndView search(Book book) {
        //结果集
        ArrayList<Book> books = new ArrayList<>();
        //关键字
        String keyWord = book.getBookName();
        //来源
        String sourceKey = book.getSourceKey();

        //获取来源详情
        Map<String, String> src = source.get(sourceKey);

        // 编码
        try {
            keyWord = URLEncoder.encode(keyWord, src.get("UrlEncode"));
        } catch (UnsupportedEncodingException e) {
            e.printStackTrace();
        }
        //searchUrl
        src.put("searchUrl", BookUtil.insertParams(src.get("searchUrl"), keyWord, "1"));

        //调用不同的方法
        switch (sourceKey) {
            case "biquge":
                BookHandler_biquge.book_search_biquge(books, src, keyWord);
                break;
            case "zongheng":
                BookHandler_zongheng.book_search_zongheng(books, src, keyWord);
                break;
            case "qidian":
                BookHandler_qidian.book_search_qidian(books, src, keyWord);
                break;
            default:
                //默认所有都查
                BookHandler_biquge.book_search_biquge(books, src, keyWord);
                BookHandler_zongheng.book_search_zongheng(books, src, keyWord);
                BookHandler_qidian.book_search_qidian(books, src, keyWord);
                break;
        }

        System.out.println(books.size());
        ModelAndView modelAndView = new ModelAndView("book_list.html", "books", books);
        try {
            modelAndView.addObject("keyWord", URLDecoder.decode(keyWord, src.get("UrlEncode")));
        } catch (UnsupportedEncodingException e) {
            e.printStackTrace();
        }
        modelAndView.addObject("sourceKey", sourceKey);
        return modelAndView;
    }

    /**
     * 访问书本详情
     */
    @GetMapping("/details")
    public ModelAndView details(String sourceKey,String bookUrl,String searchUrl) {
        Map<String, String> src = source.get(sourceKey);
        src.put("searchUrl",searchUrl);
        Book book = new Book();
        //调用不同的方法
        switch (sourceKey) {
            case "biquge":
                book = BookHandler_biquge.book_details_biquge(src, bookUrl);
                break;
            case "zongheng":
                book = BookHandler_zongheng.book_details_zongheng(src, bookUrl);
                break;
            case "qidian":
                book = BookHandler_qidian.book_details_qidian(src, bookUrl);
                break;
            default:
                break;
        }
        return new ModelAndView("book_details.html", "book", book);
    }

    /**
     * 访问书本章节
     */
    @GetMapping("/read")
    public ModelAndView read(String sourceKey,String chapterUrl,String refererUrl) {
        Map<String, String> src = source.get(sourceKey);
        Book book = new Book();
        //调用不同的方法
        switch (sourceKey) {
            case "biquge":
                book = BookHandler_biquge.book_read_biquge(src, chapterUrl,refererUrl);
                break;
            case "zongheng":
                book = BookHandler_zongheng.book_read_zongheng(src, chapterUrl,refererUrl);
                break;
            case "qidian":
                book = BookHandler_qidian.book_read_qidian(src, chapterUrl,refererUrl);
                break;
            default:
                break;
        }
        return new ModelAndView("book_read.html", "book", book);
    }
}

  三个不同来源的Handler处理器,每个来源都有不同的采集规则:


 BookHandler_biquge
/**
 * 笔趣阁采集规则
 */
public class BookHandler_biquge {

    /**
     * 来源信息
     */
    public static HashMap<String, String> biquge = new HashMap<>();

    static {
        //笔趣阁
        biquge.put("name", "笔趣阁");
        biquge.put("key", "biquge");
        biquge.put("baseUrl", "http://www.biquge.com.tw");
        biquge.put("baseSearchUrl", "http://www.biquge.com.tw/modules/article/soshu.php");
        biquge.put("UrlEncode", "GB2312");
        biquge.put("searchUrl", "http://www.biquge.com.tw/modules/article/soshu.php?searchkey=+#1&page=#2");
    }

    /**
     * 获取search list   笔趣阁采集规则
     *
     * @param books   结果集合
     * @param src     源目标
     * @param keyWord 关键字
     */
    public static void book_search_biquge(ArrayList<Book> books, Map<String, String> src, String keyWord) {
        //采集术
        String html = BookUtil.gather(src.get("searchUrl"), src.get("baseUrl"));
        try {
            //解析html格式的字符串成一个Document
            Document doc = Jsoup.parse(html);

            //当前页集合
            Elements resultList = doc.select("table.grid  tr#nr");
            for (Element result : resultList) {
                Book book = new Book();
                //书本链接
                book.setBookUrl(result.child(0).select("a").attr("href"));
                //书名
                book.setBookName(result.child(0).select("a").text());
                //作者
                book.setAuthor(result.child(2).text());
                //更新时间
                book.setUpdateDate(result.child(4).text());
                //最新章节
                book.setLatestChapter(result.child(1).select("a").text());
                book.setLatestChapterUrl(result.child(1).select("a").attr("href"));
                //状态
                book.setStatus(result.child(5).text());
                //大小
                book.setMagnitude(result.child(3).text());
                //来源
                book.setSource(src);
                books.add(book);
            }

            //下一页
            Elements searchNext = doc.select("div.pages > a.ngroup");
            String href = searchNext.attr("href");
            if (!StringUtils.isEmpty(href)) {
                src.put("baseUrl", src.get("searchUrl"));
                src.put("searchUrl", href.contains("http") ? href : (src.get("baseSearchUrl") + href));
                book_search_biquge(books, src, keyWord);
            }

        } catch (Exception e) {
            System.err.println("采集数据操作出错");
            e.printStackTrace();
        }
    }

    /**
     *  获取书本详情  笔趣阁采集规则
     * @param src 源目标
     * @param bookUrl 书本链接
     * @return Book对象
     */
    public static Book book_details_biquge(Map<String, String> src, String bookUrl) {
        Book book = new Book();
        //采集术
        String html = BookUtil.gather(bookUrl, src.get("searchUrl"));
        try {
            //解析html格式的字符串成一个Document
            Document doc = Jsoup.parse(html);
            //书本链接
            book.setBookUrl(doc.select("meta[property=og:url]").attr("content"));
            //图片
            book.setImg(doc.select("meta[property=og:image]").attr("content"));
            //书名
            book.setBookName(doc.select("div#info > h1").text());
            //作者
            book.setAuthor(doc.select("meta[property=og:novel:author]").attr("content"));
            //更新时间
            book.setUpdateDate(doc.select("meta[property=og:novel:update_time]").attr("content"));
            //最新章节
            book.setLatestChapter(doc.select("meta[property=og:novel:latest_chapter_name]").attr("content"));
            book.setLatestChapterUrl(doc.select("meta[property=og:novel:latest_chapter_url]").attr("content"));
            //类型
            book.setType(doc.select("meta[property=og:novel:category]").attr("content"));
            //简介
            book.setSynopsis(doc.select("meta[property=og:description]").attr("content"));
            //状态
            book.setStatus(doc.select("meta[property=og:novel:status]").attr("content"));

            //章节目录
            ArrayList<Map<String, String>> chapters = new ArrayList<>();
            for (Element result : doc.select("div#list dd")) {
                HashMap<String, String> map = new HashMap<>();
                map.put("chapterName", result.select("a").text());
                map.put("url", result.select("a").attr("href"));
                chapters.add(map);
            }
            book.setChapters(chapters);

            //来源
            book.setSource(src);

        } catch (Exception e) {
            System.err.println("采集数据操作出错");
            e.printStackTrace();
        }
        return book;
    }

    /**
     * 得到当前章节名以及完整内容跟上、下一章的链接地址 笔趣阁采集规则
     * @param src 源目标
     * @param chapterUrl 当前章节链接
     * @param refererUrl 来源链接
     * @return Book对象
     */
    public static Book book_read_biquge(Map<String, String> src,String chapterUrl,String refererUrl) {
        Book book = new Book();

        //当前章节链接
        book.setNowChapterUrl(chapterUrl.contains("http") ? chapterUrl : (src.get("baseUrl") + chapterUrl));

        //采集术
        String html = BookUtil.gather(book.getNowChapterUrl(), refererUrl);
        try {
            //解析html格式的字符串成一个Document
            Document doc = Jsoup.parse(html);

            //当前章节名称
            book.setNowChapter(doc.select("div.box_con > div.bookname > h1").text());

            //删除图片广告
            doc.select("div.box_con > div#content img").remove();
            //当前章节内容
            book.setNowChapterValue(doc.select("div.box_con > div#content").outerHtml());

            //上、下一章
            book.setPrevChapter(doc.select("div.bottem2 a:matches((?i)下一章)").text());
            book.setPrevChapterUrl(doc.select("div.bottem2 a:matches((?i)下一章)").attr("href"));
            book.setNextChapter(doc.select("div.bottem2 a:matches((?i)上一章)").text());
            book.setNextChapterUrl(doc.select("div.bottem2 a:matches((?i)上一章)").attr("href"));

            //来源
            book.setSource(src);

        } catch (Exception e) {
            System.err.println("采集数据操作出错");
            e.printStackTrace();
        }
        return book;
    }
}


BookHandler_zongheng
/**
 * 纵横中文网采集规则
 */
public class BookHandler_zongheng {

    /**
     * 来源信息
     */
    public static HashMap<String, String> zongheng = new HashMap<>();

    static {
        //纵横中文网
        zongheng.put("name", "纵横中文网");
        zongheng.put("key", "zongheng");
        zongheng.put("baseUrl", "http://www.zongheng.com");
        zongheng.put("baseSearchUrl", "http://search.zongheng.com/s");
        zongheng.put("UrlEncode", "UTF-8");
        zongheng.put("searchUrl", "http://search.zongheng.com/s?keyword=#1&pageNo=#2&sort=");
    }

    /**
     * 获取search list   纵横中文网采集规则
     *
     * @param books   结果集合
     * @param src     源目标
     * @param keyWord 关键字
     */
    public static void book_search_zongheng(ArrayList<Book> books, Map<String, String> src, String keyWord) {
        //采集术
        String html = BookUtil.gather(src.get("searchUrl"), src.get("baseUrl"));
        try {
            //解析html格式的字符串成一个Document
            Document doc = Jsoup.parse(html);

            //当前页集合
            Elements resultList = doc.select("div.search-tab > div.search-result-list");
            for (Element result : resultList) {
                Book book = new Book();
                //书本链接
                book.setBookUrl(result.select("div.imgbox a").attr("href"));
                //图片
                book.setImg(result.select("div.imgbox img").attr("src"));
                //书名
                book.setBookName(result.select("h2.tit").text());
                //作者
                book.setAuthor(result.select("div.bookinfo > a").first().text());
                //类型
                book.setType(result.select("div.bookinfo > a").last().text());
                //简介
                book.setSynopsis(result.select("p").text());
                //状态
                book.setStatus(result.select("div.bookinfo > span").first().text());
                //大小
                book.setMagnitude(result.select("div.bookinfo > span").last().text());
                //来源
                book.setSource(src);
                books.add(book);
            }

            //下一页
            Elements searchNext = doc.select("div.search_d_pagesize > a.search_d_next");
            String href = searchNext.attr("href");
            //最多只要888本,不然太慢了
            if (books.size() < 888 && !StringUtils.isEmpty(href)) {
                src.put("baseUrl", src.get("searchUrl"));
                src.put("searchUrl", href.contains("http") ? href : (src.get("baseSearchUrl") + href));
                book_search_zongheng(books, src, keyWord);
            }

        } catch (Exception e) {
            System.err.println("采集数据操作出错");
            e.printStackTrace();
        }
    }

    /**
     *  获取书本详情  纵横中文网采集规则
     * @param src 源目标
     * @param bookUrl 书本链接
     * @return Book对象
     */
    public static Book book_details_zongheng(Map<String, String> src, String bookUrl) {
        Book book = new Book();
        //采集术
        String html = BookUtil.gather(bookUrl, src.get("searchUrl"));
        try {
            //解析html格式的字符串成一个Document
            Document doc = Jsoup.parse(html);

            //书本链接
            book.setBookUrl(bookUrl);
            //图片
            book.setImg(doc.select("div.book-img > img").attr("src"));
            //书名
            book.setBookName(doc.select("div.book-info > div.book-name").text());
            //作者
            book.setAuthor(doc.select("div.book-author div.au-name").text());
            //更新时间
            book.setUpdateDate(doc.select("div.book-new-chapter div.time").text());
            //最新章节
            book.setLatestChapter(doc.select("div.book-new-chapter div.tit a").text());
            book.setLatestChapterUrl(doc.select("div.book-new-chapter div.tit a").attr("href"));
            //类型
            book.setType(doc.select("div.book-label > a").last().text());
            //简介
            book.setSynopsis(doc.select("div.book-dec > p").text());
            //状态
            book.setStatus(doc.select("div.book-label > a").first().text());

            //章节目录
            String chaptersUrl = doc.select("a.all-catalog").attr("href");
            ArrayList<Map<String, String>> chapters = new ArrayList<>();
            //采集术
            for (Element result : Jsoup.parse(BookUtil.gather(chaptersUrl, bookUrl)).select("ul.chapter-list li")) {
                HashMap<String, String> map = new HashMap<>();
                map.put("chapterName", result.select("a").text());
                map.put("url", result.select("a").attr("href"));
                chapters.add(map);
            }
            book.setChapters(chapters);
            //来源
            book.setSource(src);
        } catch (Exception e) {
            System.err.println("采集数据操作出错");
            e.printStackTrace();
        }
        return book;
    }

    /**
     * 得到当前章节名以及完整内容跟上、下一章的链接地址 纵横中文网采集规则
     * @param src 源目标
     * @param chapterUrl 当前章节链接
     * @param refererUrl 来源链接
     * @return Book对象
     */
    public static Book book_read_zongheng(Map<String, String> src,String chapterUrl,String refererUrl) {
        Book book = new Book();

        //当前章节链接
        book.setNowChapterUrl(chapterUrl.contains("http") ? chapterUrl : (src.get("baseUrl") + chapterUrl));

        //采集术
        String html = BookUtil.gather(book.getNowChapterUrl(), refererUrl);
        try {
            //解析html格式的字符串成一个Document
            Document doc = Jsoup.parse(html);

            //当前章节名称
            book.setNowChapter(doc.select("div.title_txtbox").text());

            //删除图片广告
            doc.select("div.content img").remove();
            //当前章节内容
            book.setNowChapterValue(doc.select("div.content").outerHtml());

            //上、下一章
            book.setPrevChapter(doc.select("div.chap_btnbox a:matches((?i)下一章)").text());
            book.setPrevChapterUrl(doc.select("div.chap_btnbox a:matches((?i)下一章)").attr("href"));
            book.setNextChapter(doc.select("div.chap_btnbox a:matches((?i)上一章)").text());
            book.setNextChapterUrl(doc.select("div.chap_btnbox a:matches((?i)上一章)").attr("href"));

            //来源
            book.setSource(src);

        } catch (Exception e) {
            System.err.println("采集数据操作出错");
            e.printStackTrace();
        }
        return book;
    }
}
BookHandler_qidian
/**
 *  起点中文网采集规则
 */
public class BookHandler_qidian {

    /**
     * 来源信息
     */
    public static HashMap<String, String> qidian = new HashMap<>();

    static {
        //起点中文网
        qidian.put("name", "起点中文网");
        qidian.put("key", "qidian");
        qidian.put("baseUrl", "http://www.qidian.com");
        qidian.put("baseSearchUrl", "https://www.qidian.com/search");
        qidian.put("UrlEncode", "UTF-8");
        qidian.put("searchUrl", "https://www.qidian.com/search?kw=#1&page=#2");
    }

    /**
     * 获取search list   起点中文网采集规则
     *
     * @param books   结果集合
     * @param src     源目标
     * @param keyWord 关键字
     */
    public static void book_search_qidian(ArrayList<Book> books, Map<String, String> src, String keyWord) {
        //采集术
        String html = BookUtil.gather(src.get("searchUrl"), src.get("baseUrl"));
        try {
            //解析html格式的字符串成一个Document
            Document doc = Jsoup.parse(html);

            //当前页集合
            Elements resultList = doc.select("li.res-book-item");
            for (Element result : resultList) {
                Book book = new Book();
                /*
                       如果大家打断点在这里的话就会发现,起点的链接是这样的
                       //book.qidian.com/info/1012786368

                       以两个斜杠开头,不过无所谓,httpClient照样可以请求
                 */
                //书本链接
                book.setBookUrl(result.select("div.book-img-box a").attr("href"));
                //图片
                book.setImg(result.select("div.book-img-box img").attr("src"));
                //书名
                book.setBookName(result.select("div.book-mid-info > h4").text());
                //作者
                book.setAuthor(result.select("div.book-mid-info > p.author > a").first().text());
                //类型
                book.setType(result.select("div.book-mid-info > p.author > a").last().text());
                //简介
                book.setSynopsis(result.select("div.book-mid-info > p.intro").text());
                //状态
                book.setStatus(result.select("div.book-mid-info > p.author > span").first().text());
                //更新时间
                book.setUpdateDate(result.select("div.book-mid-info > p.update > span").text());
                //最新章节
                book.setLatestChapter(result.select("div.book-mid-info > p.update > a").text());
                book.setLatestChapterUrl(result.select("div.book-mid-info > p.update > a").attr("href"));
                //来源
                book.setSource(src);
                books.add(book);
            }

            //当前页
            String page = doc.select("div#page-container").attr("data-page");

            //最大页数
            String pageMax = doc.select("div#page-container").attr("data-pageMax");

            //当前页 < 最大页数
            if (Integer.valueOf(page) < Integer.valueOf(pageMax)) {
                src.put("baseUrl", src.get("searchUrl"));
                //自己拼接下一页链接
                src.put("searchUrl", src.get("searchUrl").replaceAll("page=" + Integer.valueOf(page), "page=" + (Integer.valueOf(page) + 1)));
                book_search_qidian(books, src, keyWord);
            }

        } catch (Exception e) {
            System.err.println("采集数据操作出错");
            e.printStackTrace();
        }
    }

    /**
     *  获取书本详情  起点中文网采集规则
     * @param src 源目标
     * @param bookUrl 书本链接
     * @return Book对象
     */
    public static Book book_details_qidian(Map<String, String> src, String bookUrl) {
        Book book = new Book();

        //https
        bookUrl = "https:" + bookUrl;

        //采集术
        String html = BookUtil.gather(bookUrl, src.get("searchUrl"));
        try {
            //解析html格式的字符串成一个Document
            Document doc = Jsoup.parse(html);

            //书本链接
            book.setBookUrl(bookUrl);
            //图片
            String img = doc.select("div.book-img > a#bookImg > img").attr("src");
            img = "https:" + img;
            book.setImg(img);
            //书名
            book.setBookName(doc.select("div.book-info > h1 > em").text());
            //作者
            book.setAuthor(doc.select("div.book-info > h1 a.writer").text());
            //更新时间
            book.setUpdateDate(doc.select("li.update em.time").text());
            //最新章节
            book.setLatestChapter(doc.select("li.update a").text());
            book.setLatestChapterUrl(doc.select("li.update a").attr("href"));
            //类型
            book.setType(doc.select("p.tag > span").first().text());
            //简介
            book.setSynopsis(doc.select("div.book-intro > p").text());
            //状态
            book.setStatus(doc.select("p.tag > a").first().text());

            //章节目录

            //创建httpclient对象 (这里设置成全局变量,相对于同一个请求session、cookie会跟着携带过去)
            BasicCookieStore cookieStore = new BasicCookieStore();
            CloseableHttpClient httpClient = HttpClients.custom().setDefaultCookieStore(cookieStore).build();
            //创建get方式请求对象
            HttpGet httpGet = new HttpGet("https://book.qidian.com/");
            httpGet.addHeader("Content-type", "application/json");
            //包装一下
            httpGet.addHeader("User-Agent", "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/69.0.3497.100 Safari/537.36");
            httpGet.addHeader("Connection", "keep-alive");
            //通过请求对象获取响应对象
            CloseableHttpResponse response = httpClient.execute(httpGet);
            //获得Cookies
            String _csrfToken = "";
            List<Cookie> cookies = cookieStore.getCookies();
            for (int i = 0; i < cookies.size(); i++) {
                if("_csrfToken".equals(cookies.get(i).getName())){
                    _csrfToken = cookies.get(i).getValue();
                }
            }

            //构造post
            String bookId = doc.select("div.book-img a#bookImg").attr("data-bid");
            HttpPost httpPost = new HttpPost(BookUtil.insertParams("https://book.qidian.com/ajax/book/category?_csrfToken=#1&bookId=#2",_csrfToken,bookId));
            httpPost.addHeader("User-Agent", "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/69.0.3497.100 Safari/537.36");
            httpPost.addHeader("Connection", "keep-alive");
            //通过请求对象获取响应对象
            CloseableHttpResponse response1 = httpClient.execute(httpPost);
            //获取结果实体(json格式字符串)
            String chaptersJson = "";
            if (response1.getStatusLine().getStatusCode() == HttpStatus.SC_OK) {
                chaptersJson = EntityUtils.toString(response1.getEntity(), "UTF-8");
            }

            //java处理json
            ArrayList<Map<String, String>> chapters = new ArrayList<>();

            JSONObject jsonArray = JSONObject.fromObject(chaptersJson);
            Map<String,Object> objectMap = (Map<String, Object>) jsonArray;

            Map<String, Object> objectMap_data = (Map<String, Object>) objectMap.get("data");
            List<Map<String, Object>> objectMap_data_vs = (List<Map<String, Object>>) objectMap_data.get("vs");
            for(Map<String, Object> vs : objectMap_data_vs){
                List<Map<String, Object>> cs = (List<Map<String, Object>>) vs.get("cs");
                for(Map<String, Object> chapter : cs){
                    Map<String, String> map = new HashMap<>();
                    map.put("chapterName", (String) chapter.get("cN"));
                    map.put("url", "https://read.qidian.com/chapter/"+(String) chapter.get("cU"));
                    chapters.add(map);
                }
            }

            book.setChapters(chapters);


            //来源
            book.setSource(src);

            //释放链接
            response.close();
        } catch (Exception e) {
            System.err.println("采集数据操作出错");
            e.printStackTrace();
        }
        return book;
    }

    /**
     * 得到当前章节名以及完整内容跟上、下一章的链接地址 起点中文网采集规则
     * @param src 源目标
     * @param chapterUrl 当前章节链接
     * @param refererUrl 来源链接
     * @return Book对象
     */
    public static Book book_read_qidian(Map<String, String> src,String chapterUrl,String refererUrl) {
        Book book = new Book();

        //当前章节链接
        book.setNowChapterUrl(chapterUrl.contains("http") ? chapterUrl : (src.get("baseUrl") + chapterUrl));

        //采集术
        String html = BookUtil.gather(book.getNowChapterUrl(), refererUrl);
        try {
            //解析html格式的字符串成一个Document
            Document doc = Jsoup.parse(html);

            System.out.println(html);

            //当前章节名称
            book.setNowChapter(doc.select("h3.j_chapterName").text());

            //删除图片广告
            doc.select("div.read-content img").remove();
            //当前章节内容
            book.setNowChapterValue(doc.select("div.read-content").outerHtml());

            //上、下一章
            book.setPrevChapter(doc.select("div.chapter-control a:matches((?i)下一章)").text());
            String prev = doc.select("div.chapter-control a:matches((?i)下一章)").attr("href");
            prev = "https:"+prev;
            book.setPrevChapterUrl(prev);
            book.setNextChapter(doc.select("div.chapter-control a:matches((?i)上一章)").text());
            String next = doc.select("div.chapter-control a:matches((?i)上一章)").attr("href");
            next = "https:"+next;
            book.setNextChapterUrl(next);

            //来源
            book.setSource(src);

        } catch (Exception e) {
            System.err.println("采集数据操作出错");
            e.printStackTrace();
        }
        return book;
    }
}

  四个html页面:

  book_index

<!DOCTYPE html>
<!--解决idea thymeleaf 表达式模板报红波浪线-->
<!--suppress ALL -->
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>MY BOOK</title>
    <!-- 新 Bootstrap 核心 CSS 文件 -->
    <link rel="stylesheet" href="http://cdn.static.runoob.com/libs/bootstrap/3.3.7/css/bootstrap.min.css">
    <style>

        body{
            background-color: antiquewhite;
        }

        .main{
            margin: auto;
            width: 500px;
            margin-top: 150px;
        }

        #bookName{
            width: 300px;
        }

        #title{
            text-align: center;
        }
    </style>
</head>
<body>
    <div class="main">
        <h2 id="title">MY BOOK</h2>
        <form class="form-inline" method="get" th:action="@{/book/search}">
            来源
            <select class="form-control" id="source" name="sourceKey">
                <option value="">所有</option>
                <option value="biquge">笔趣阁</option>
                <option value="zongheng">纵横网</option>
                <option value="qidian">起点网</option>
            </select>
            <input type="text" id="bookName" name="bookName" class="form-control" placeholder="请输入..."/>
            <button class="btn btn-info" type="submit">搜索</button>
        </form>
    </div>
</body>
</html>
<!DOCTYPE html>
<!--解决idea thymeleaf 表达式模板报红波浪线-->
<!--suppress ALL -->
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>BOOK LIST</title>
    <!-- 新 Bootstrap 核心 CSS 文件 -->
    <link rel="stylesheet" href="http://cdn.static.runoob.com/libs/bootstrap/3.3.7/css/bootstrap.min.css">
    <link rel="stylesheet" href="http://hanlei.online/Onlineaddress/layui/css/layui.css"/>
    <style>
        body {
            background-color: antiquewhite;
        }

        .main {
            margin: auto;
            width: 500px;
            margin-top: 50px;
        }

        .book {
            border-bottom: solid #428bca 1px;
        }

        .click-book-detail, .click-book-read {
            cursor: pointer;
            color: #428bca;
        }

        .click-book-detail:hover {
            color: rgba(150, 149, 162, 0.47);
        }

        .click-book-read:hover {
            color: rgba(150, 149, 162, 0.47);
        }
    </style>
</head>
<body>
<div class="main">
    <form class="form-inline" method="get" th:action="@{/book/search}">
        来源
        <select class="form-control" id="source" name="sourceKey">
            <option value="">所有</option>
            <option value="biquge" th:selected="${sourceKey} == 'biquge'">笔趣阁</option>
            <option value="zongheng" th:selected="${sourceKey} == 'zongheng'">纵横网</option>
            <option value="qidian" th:selected="${sourceKey} == 'qidian'">起点网</option>
        </select>
        <input type="text" id="bookName" name="bookName" class="form-control" placeholder="请输入..."
               th:value="${keyWord}"/>
        <button class="btn btn-info" type="submit">搜索</button>
    </form>
    <br/>
    <div id="books"></div>
    <div id="page"></div>
</div>
</body>
<!-- jquery在线版本 -->
<script src="http://libs.baidu.com/jquery/2.1.4/jquery.min.js"></script>
<script src="http://hanlei.online/Onlineaddress/layui/layui.js"></script>
<script th:inline="javascript">
    var ctx = /*[[@{/}]]*/'';
    var books = [[${books}]];//取出后台数据
    var nums = 10; //每页出现的数量
    var pages = books.length; //总数

    /**
     * 传入当前页,根据nums去计算,从books集合截取对应数据做展示
     */
    var thisDate = function (curr) {
        var str = "",//当前页需要展示的html
            first = (curr * nums - nums),//展示的第一条数据的下标
            last = curr * nums - 1;//展示的最后一条数据的下标
        last = last >= books.length ? (books.length - 1) : last;
        for (var i = first; i <= last; i++) {
            var book = books[i];
            str += "<div class='book'>" +
                "<img class='click-book-detail' data-bookurl='" + book.bookUrl + "' data-sourcekey='" + book.source.key + "' data-searchurl='" + book.source.searchUrl + "' src='" + book.img + "'></img>" +
                "<p class='click-book-detail' data-bookurl='" + book.bookUrl + "' data-sourcekey='" + book.source.key + "' data-searchurl='" + book.source.searchUrl + "'>书名:" + book.bookName + "</p>" +
                "<p>作者:" + book.author + "</p>" +
                "<p>简介:" + book.synopsis + "</p>" +
                "<p class='click-book-read' data-chapterurl='" + book.latestChapterUrl + "' data-sourcekey='" + book.source.key + "' data-refererurl='" + book.source.refererurl + "'>最新章节:" + book.latestChapter + "</p>" +
                "<p>更新时间:" + book.updateDate + "</p>" +
                "<p>大小:" + book.magnitude + "</p>" +
                "<p>状态:" + book.status + "</p>" +
                "<p>类型:" + book.type + "</p>" +
                "<p>来源:" + book.source.name + "</p>" +
                "</div><br/>";
        }
        return str;
    };

    //获取一个laypage实例
    layui.use('laypage', function () {
        var laypage = layui.laypage;

        //调用laypage 逻辑分页
        laypage.render({
            elem: 'page',
            count: pages,
            limit: nums,
            jump: function (obj) {
                //obj包含了当前分页的所有参数,比如:
                // console.log(obj.curr); //得到当前页,以便向服务端请求对应页的数据。
                // console.log(obj.limit); //得到每页显示的条数
                document.getElementById('books').innerHTML = thisDate(obj.curr);
            },
            prev: '<',
            next: '>',
            theme: '#f9c357',
        })
    });

    $("body").on("click", ".click-book-detail", function (even) {
        var bookUrl = $(this).data("bookurl");
        var searchUrl = $(this).data("searchurl");
        var sourceKey = $(this).data("sourcekey");
        window.location.href = ctx + "/book/details?sourceKey=" + sourceKey + "&searchUrl=" + searchUrl + "&bookUrl=" + bookUrl;
    });
    $("body").on("click", ".click-book-read", function (even) {
        var chapterUrl = $(this).data("chapterurl");
        var refererUrl = $(this).data("refererurl");
        var sourceKey = $(this).data("sourcekey");
        window.location.href = ctx + "/book/read?sourceKey=" + sourceKey + "&refererUrl=" + refererUrl + "&chapterUrl=" + chapterUrl;
    });
</script>
</html>

book_details

<!DOCTYPE html>
<!--解决idea thymeleaf 表达式模板报红波浪线-->
<!--suppress ALL -->
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>BOOK DETAILS</title>
    <!-- 新 Bootstrap 核心 CSS 文件 -->
    <link rel="stylesheet" href="http://cdn.static.runoob.com/libs/bootstrap/3.3.7/css/bootstrap.min.css">
    <link rel="stylesheet" href="http://hanlei.online/Onlineaddress/layui/css/layui.css"/>
    <style>
        body {
            background-color: antiquewhite;
        }

        .main {
            margin: auto;
            width: 500px;
            margin-top: 150px;
        }

        .book {
            border-bottom: solid #428bca 1px;
        }

        .click-book-detail, .click-book-read {
            cursor: pointer;
            color: #428bca;
        }

        .click-book-detail:hover {
            color: rgba(150, 149, 162, 0.47);
        }

        .click-book-read:hover {
            color: rgba(150, 149, 162, 0.47);
        }

        a {
            color: #428bca;
        }

    </style>
</head>
<body>
<div class="main">
    <div class='book'>
        <div id="bookImg"></div>
        <p>书名:<span th:text="${book.bookName}"></span></p>
        <p>作者:<span th:text="${book.author}"></span></p>
        <p>简介:<span th:text="${book.synopsis}"></span></p>
        <p>最新章节:<a th:href="${book.latestChapterUrl}" th:text="${book.latestChapter}"></a></p>
        <p>更新时间:<span th:text="${book.updateDate}"></span></p>
        <p>大小:<span th:text="${book.magnitude}"></span></p>
        <p>状态:<span th:text="${book.status}"></span></p>
        <p>类型:<span th:text="${book.type}"></span></p>
        <p>来源:<span th:text="${book.source.name}"></span></p>
    </div>
    <br/>
    <div class="chapters" th:each="chapter,iterStat:${book.chapters}">
        <p class="click-book-read" th:attr="data-chapterurl=${chapter.url},data-sourcekey=${book.source.key},data-refererurl=${book.bookUrl}" th:text="${chapter.chapterName}"></p>
    </div>
</div>
</body>
<!-- jquery在线版本 -->
<script src="http://libs.baidu.com/jquery/2.1.4/jquery.min.js"></script>
<script th:inline="javascript">
    var ctx = /*[[@{/}]]*/'';
    var book = [[${book}]];//取出后台数据

    /**
     * 反防盗链
     */
    function showImg(parentObj, url) {
        //来一个随机数
        var frameid = 'frameimg' + Math.random();
        //放在(父页面)window里面   iframe的script标签里面绑定了window.onload,作用:设置iframe的高度、宽度 <script>window.onload = function() {  parent.document.getElementById(\'' + frameid + '\').height = document.getElementById(\'img\').height+\'px\'; }<' + '/script>
        window.img = '<img src=\'' + url + '?' + Math.random() + '\'/>';
        //iframe调用parent.img
        $(parentObj).append('<iframe id="' + frameid + '" src="javascript:parent.img;" frameBorder="0" scrolling="no"></iframe>');
    }

    showImg($("#bookImg"), book.img);

    $("body").on("click", ".click-book-read", function (even) {
        var chapterUrl = $(this).data("chapterurl");
        var refererUrl = $(this).data("refererurl");
        var sourceKey = $(this).data("sourcekey");
        window.location.href = ctx + "/book/read?sourceKey=" + sourceKey + "&refererUrl=" + refererUrl + "&chapterUrl=" + chapterUrl;
    });

</script>
</html>

  book_read

<!DOCTYPE html>
<!--解决idea thymeleaf 表达式模板报红波浪线-->
<!--suppress ALL -->
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>BOOK READ</title>
    <style>
        body {
            background-color: antiquewhite;
        }

        .main {
            padding: 10px 20px;
        }

        .click-book-detail, .click-book-read {
            cursor: pointer;
            color: #428bca;
        }

        .click-book-detail:hover {
            color: rgba(150, 149, 162, 0.47);
        }

        .click-book-read:hover {
            color: rgba(150, 149, 162, 0.47);
        }

        .float-left{
            float: left;
            margin-left: 70px;
        }
    </style>
</head>
<body>
<div class="main">
    <!-- 章节名称 -->
    <h3 th:text="${book.nowChapter}"></h3>
    <!-- 章节内容 -->
    <p th:utext="${book.nowChapterValue}"></p>
    <!-- 上、下章 -->
    <p class="click-book-read float-left"
       th:attr="data-chapterurl=${book.nextChapterUrl},data-sourcekey=${book.source.key},data-refererurl=${book.nowChapterUrl}"
       th:text="${book.nextChapter}"></p>
            
    <p class="click-book-read float-left"
       th:attr="data-chapterurl=${book.prevChapterUrl},data-sourcekey=${book.source.key},data-refererurl=${book.nowChapterUrl}"
       th:text="${book.prevChapter}"></p>
</div>
</body>
<!-- jquery在线版本 -->
<script src="http://libs.baidu.com/jquery/2.1.4/jquery.min.js"></script>
<script th:inline="javascript">
    var ctx = /*[[@{/}]]*/'';
    $("body").on("click", ".click-book-read", function (even) {
        var chapterUrl = $(this).data("chapterurl");
        var refererUrl = $(this).data("refererurl");
        var sourceKey = $(this).data("sourcekey");
        window.location.href = ctx + "/book/read?sourceKey=" + sourceKey + "&refererUrl=" + refererUrl + "&chapterUrl=" + chapterUrl;
    });
</script>
</html>

  补充

  2019-07-17补充:我们之前三个来源网站的baseUrl都是用http,但网站后面都升级成了https,例如笔趣阁:


  导致抓取数据时报错

<!DOCTYPE html>
<!--解决idea thymeleaf 表达式模板报红波浪线-->
<!--suppress ALL -->
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>BOOK LIST</title>
    <!-- 新 Bootstrap 核心 CSS 文件 -->
    <link rel="stylesheet" href="http://cdn.static.runoob.com/libs/bootstrap/3.3.7/css/bootstrap.min.css">
    <link rel="stylesheet" href="http://hanlei.online/Onlineaddress/layui/css/layui.css"/>
    <style>
        body {
            background-color: antiquewhite;
        }

        .main {
            margin: auto;
            width: 500px;
            margin-top: 50px;
        }

        .book {
            border-bottom: solid #428bca 1px;
        }

        .click-book-detail, .click-book-read {
            cursor: pointer;
            color: #428bca;
        }

        .click-book-detail:hover {
            color: rgba(150, 149, 162, 0.47);
        }

        .click-book-read:hover {
            color: rgba(150, 149, 162, 0.47);
        }
    </style>
</head>
<body>
<div class="main">
    <form class="form-inline" method="get" th:action="@{/book/search}">
        来源
        <select class="form-control" id="source" name="sourceKey">
            <option value="">所有</option>
            <option value="biquge" th:selected="${sourceKey} == 'biquge'">笔趣阁</option>
            <option value="zongheng" th:selected="${sourceKey} == 'zongheng'">纵横网</option>
            <option value="qidian" th:selected="${sourceKey} == 'qidian'">起点网</option>
        </select>
        <input type="text" id="bookName" name="bookName" class="form-control" placeholder="请输入..."
               th:value="${keyWord}"/>
        <button class="btn btn-info" type="submit">搜索</button>
    </form>
    <br/>
    <div id="books"></div>
    <div id="page"></div>
</div>
</body>
<!-- jquery在线版本 -->
<script src="http://libs.baidu.com/jquery/2.1.4/jquery.min.js"></script>
<script src="http://hanlei.online/Onlineaddress/layui/layui.js"></script>
<script th:inline="javascript">
    var ctx = /*[[@{/}]]*/'';
    var books = [[${books}]];//取出后台数据
    var nums = 10; //每页出现的数量
    var pages = books.length; //总数

    /**
     * 传入当前页,根据nums去计算,从books集合截取对应数据做展示
     */
    var thisDate = function (curr) {
        var str = "",//当前页需要展示的html
            first = (curr * nums - nums),//展示的第一条数据的下标
            last = curr * nums - 1;//展示的最后一条数据的下标
        last = last >= books.length ? (books.length - 1) : last;
        for (var i = first; i <= last; i++) {
            var book = books[i];
            str += "<div class='book'>" +
                "<img class='click-book-detail' data-bookurl='" + book.bookUrl + "' data-sourcekey='" + book.source.key + "' data-searchurl='" + book.source.searchUrl + "' src='" + book.img + "'></img>" +
                "<p class='click-book-detail' data-bookurl='" + book.bookUrl + "' data-sourcekey='" + book.source.key + "' data-searchurl='" + book.source.searchUrl + "'>书名:" + book.bookName + "</p>" +
                "<p>作者:" + book.author + "</p>" +
                "<p>简介:" + book.synopsis + "</p>" +
                "<p class='click-book-read' data-chapterurl='" + book.latestChapterUrl + "' data-sourcekey='" + book.source.key + "' data-refererurl='" + book.source.refererurl + "'>最新章节:" + book.latestChapter + "</p>" +
                "<p>更新时间:" + book.updateDate + "</p>" +
                "<p>大小:" + book.magnitude + "</p>" +
                "<p>状态:" + book.status + "</p>" +
                "<p>类型:" + book.type + "</p>" +
                "<p>来源:" + book.source.name + "</p>" +
                "</div><br/>";
        }
        return str;
    };

    //获取一个laypage实例
    layui.use('laypage', function () {
        var laypage = layui.laypage;

        //调用laypage 逻辑分页
        laypage.render({
            elem: 'page',
            count: pages,
            limit: nums,
            jump: function (obj) {
                //obj包含了当前分页的所有参数,比如:
                // console.log(obj.curr); //得到当前页,以便向服务端请求对应页的数据。
                // console.log(obj.limit); //得到每页显示的条数
                document.getElementById('books').innerHTML = thisDate(obj.curr);
            },
            prev: '<',
            next: '>',
            theme: '#f9c357',
        })
    });

    $("body").on("click", ".click-book-detail", function (even) {
        var bookUrl = $(this).data("bookurl");
        var searchUrl = $(this).data("searchurl");
        var sourceKey = $(this).data("sourcekey");
        window.location.href = ctx + "/book/details?sourceKey=" + sourceKey + "&searchUrl=" + searchUrl + "&bookUrl=" + bookUrl;
    });
    $("body").on("click", ".click-book-read", function (even) {
        var chapterUrl = $(this).data("chapterurl");
        var refererUrl = $(this).data("refererurl");
        var sourceKey = $(this).data("sourcekey");
        window.location.href = ctx + "/book/read?sourceKey=" + sourceKey + "&refererUrl=" + refererUrl + "&chapterUrl=" + chapterUrl;
    });
</script>
</html>

  解决办法:参考https://blog.csdn.net/xiaoxian8023/article/details/49865335,绕过证书验证

  在BookUtil.java中新增方法

    /**
     * 绕过SSL验证
     */
    private static SSLContext createIgnoreVerifySSL() throws NoSuchAlgorithmException, KeyManagementException {
        SSLContext sc = SSLContext.getInstance("SSLv3");

        // 实现一个X509TrustManager接口,用于绕过验证,不用修改里面的方法
        X509TrustManager trustManager = new X509TrustManager() {
            @Override
            public void checkClientTrusted(
                    java.security.cert.X509Certificate[] paramArrayOfX509Certificate,
                    String paramString) throws CertificateException {
            }

            @Override
            public void checkServerTrusted(
                    java.security.cert.X509Certificate[] paramArrayOfX509Certificate,
                    String paramString) throws CertificateException {
            }

            @Override
            public java.security.cert.X509Certificate[] getAcceptedIssuers() {
                return null;
            }
        };

        sc.init(null, new TrustManager[]{trustManager}, null);
        return sc;
    }

  然后在gather方法中改成这样获取httpClient

    /**
     * 采集当前url完整response实体.toString()
     *
     * @param url url
     * @return response实体.toString()
     */
    public static String gather(String url, String refererUrl) {
        String result = null;
        try {
            //采用绕过验证的方式处理https请求
            SSLContext sslcontext = createIgnoreVerifySSL();

            // 设置协议http和https对应的处理socket链接工厂的对象
            Registry<ConnectionSocketFactory> socketFactoryRegistry = RegistryBuilder.<ConnectionSocketFactory>create()
                    .register("http", PlainConnectionSocketFactory.INSTANCE)
                    .register("https", new SSLConnectionSocketFactory(sslcontext))
                    .build();
            PoolingHttpClientConnectionManager connManager = new PoolingHttpClientConnectionManager(socketFactoryRegistry);
            HttpClients.custom().setConnectionManager(connManager);

            //创建自定义的httpclient对象
            CloseableHttpClient httpClient = HttpClients.custom().setConnectionManager(connManager).build();


            //创建httpclient对象 (这里设置成全局变量,相对于同一个请求session、cookie会跟着携带过去)
//            CloseableHttpClient httpClient = HttpClients.createDefault();

            //创建get方式请求对象
            HttpGet httpGet = new HttpGet(url);
            httpGet.addHeader("Content-type", "application/json");
            //包装一下
            httpGet.addHeader("User-Agent", "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/69.0.3497.100 Safari/537.36");
            httpGet.addHeader("Referer", refererUrl);
            httpGet.addHeader("Connection", "keep-alive");

            //通过请求对象获取响应对象
            CloseableHttpResponse response = httpClient.execute(httpGet);
            //获取结果实体
            if (response.getStatusLine().getStatusCode() == HttpStatus.SC_OK) {
                result = EntityUtils.toString(response.getEntity(), "GBK");
            }

            //释放链接
            response.close();
        }
        //这里还可以捕获超时异常,重新连接抓取
        catch (Exception e) {
            result = null;
            System.err.println("采集操作出错");
            e.printStackTrace();
        }
        return result;
    }

  这样就可以正常抓取了

  我们之前获取项目路径用的是

var ctx = /*[[@{/}]]*/'';

  突然发现不行了,跳转的路径直接是/开头,现在改成这样获取

    //项目路径
    var ctx = [[${#request.getContextPath()}]];

  2019-08-01补充:大家如果看到有这个报错,连接被重置,不要慌张,有可能是网站换域名了比如现在我们程序请求的是http://www.biquge.com.tw,但这个网址已经不能访问了,笔趣阁已经改成https://www.biqudu.net/,我们改一下代码就可以解决问题,要注意检查各个源路径是否能正常访问,同时对方也可能改页面格式,导致我们之前的规则无法匹配获取数据,这种情况只能重新编写爬取规则了


  2019-08-02补充:发现了个bug,我们的BookUtil.insertParams方法原理是替换#字符串

    /**
     * 自动注入参数
     * 例如:
     *
     * @param src    http://search.zongheng.com/s?keyword=#1&pageNo=#2&sort=
     * @param params "斗破苍穹","1"
     * @return http://search.zongheng.com/s?keyword=斗破苍穹&pageNo=1&sort=
     */
    public static String insertParams(String src, String... params) {
        int i = 1;
        for (String param : params) {
            src = src.replaceAll("#" + i, param);
            i++;
        }
        return src;
    }

  但是我们在搜索的时候,调用参数自动注入,形参src的值是来自静态属性Map,初始化的时候有两个#字符串,在进行第一次搜索之后,#字符串被替换了,后面再进行搜索注入参数已经没有#字符串了,因此后面的搜索结果都是第一次的结果...


  解决:获取来源时不是用=赋值,而是复制一份,三个方法都要改

  修改前:

        //获取来源详情
        Map<String, String> src = source.get(sourceKey);

  修改后:

        //获取来源详情,复制一份
        Map<String, String> src = new HashMap<>();
        src.putAll(source.get(sourceKey));

  多端开发

  公司最近打算做手机端,学习了DCloud公司的uni-app,开发工具是HBuilderX,并用我们的小说爬虫学习、练手,做了个H5手机端的页面

  DCloud公司官网:https://www.dcloud.io/

  uni-app官网:https://uniapp.dcloud.io/

  uni-app 是一个使用 Vue.js 开发所有前端应用的框架,开发者编写一套代码,可编译到iOS、Android、H5、以及各种小程序等多个平台。

  效果图:

  代码开源

  代码已经开源、托管到我的GitHub、码云:

  GitHub:https://github.com/huanzi-qch/spider

  码云:https://gitee.com/huanzi-qch/spider


版权声明

作者:huanzi-qch

出处:https://www.cnblogs.com/huanzi-qch

若标题中有“转载”字样,则本文版权归原作者所有。若无转载字样,本文版权归作者所有,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文链接,否则保留追究法律责任的权利.