爬虫的基础知识

  |  

摘要: 爬虫开发的基础知识

【对数据分析、人工智能、金融科技、风控服务感兴趣的同学,欢迎关注我哈,阅读更多原创文章】
我的网站:潮汐朝夕的生活实验室
我的公众号:潮汐朝夕
我的知乎:潮汐朝夕
我的github:FennelDumplings
我的leetcode:FennelDumplings


爬虫基础

很多时候我们都想要在网上获取数据,进行后续的分析,或者作为训练数据训练模型。获取数据的关键技术就是爬虫,本文我们学习一下爬虫最基础的知识。爬虫大致有以下几个常见用途。

  • 收集数据

现在绝大部分网站都是基于模板开发的,使用模板可以快速生成相同版式不同内容的大量网页。因此只要针对一个页面开发出了爬虫,那么这个爬虫也能爬取基于同一个模板的其他页面。这种爬虫成为定向爬虫

  • 尽职调查

比如调查一个电商公司,如果可以用爬虫可以爬取该公司网站所有商品的销量情况,那么就可以计算出该公司的实际总销售额。

  • 刷量秒杀

刷流量是爬虫天然自带的功能。当爬虫访问了一个网站时,如果这个爬虫隐藏得很好,网站不能识别这一次访问来自于爬虫,那么就会把它当成正常访问。爬虫就刷了网站的访问量。

爬虫的开发有两个层面,一个是“技”的层面:也就是语言和框架的使用;另一个是“术”的层面:遇到各种反爬虫问题时,应该如何突破,如何隐藏爬虫,如何模拟人的行为,以及遇到没有见过的反爬虫策略时,应该如何思考及如何使用爬虫爬取非网页内容等。

爬虫的主要流程是获取网页内容并解析。对于获取网页,常用的组件有 requests、scrapy 等;对于解析网页内容,常用的组件有正则表达式、XPath、BeautifulSoup 等。

正常的网站不会希望数据被轻易拿走,因此会有很多反爬虫措施,常见的有访问频率检查、验证码、登录验证、行为检测等等。


半自动爬虫

半自动爬虫分为手动部分和自动部分。其中手动的部分是把网页的源代码复制下来,自动的部分是通过正则表达式把其中的有效信息提取出来。

我们先复习一下 Python 正则表达式的要点,然后以贴吧为例子实现半自动爬虫。

正则表达式要点记录

  • .: 代替除了换行符以外的任何一个字符。
  • *: 表示它前面的一个子表达式(普通字符、另一个或几个正则表达式符号)0 次到无限次。
  • ?: 表示它前面的子表达式0次或者1次。
  • 转义字符:
转义字符 意义
\n 换行符
\t 制表符
\\ 反斜杠
\' 单引号
\" 双引号
\d 数字
  • (): 提取括号中的内容,例如 A(.*)B 匹配的是 A 和 B 之间的内容,不带 A 和 B。
  • .*:贪婪模式,获取最长的满足条件的字符串。
  • .*?:非贪婪模式,获取最短的能满足条件的字符串。

re 模块中的关键方法:

1
re.findall(pattern, string, flags=0)

findall() 的结果是一个列表,包含了所有的匹配到的结果。如果没有匹配到结果,就会返回空列表。

flags 参数设置为 re.S 的话,匹配包括换行符在内的所有字符。

1
re.search(pattern, string, flags=0)

search() 只会返回第1个满足要求的字符串。如果匹配成功,则是一个正则表达式的对象,后续可以通过 .group() 这个方法来获取里面的值;如果没有匹配到任何数据,返回 None。

需求分析

在百度贴吧中任意寻找一个贴吧并打开一个热门帖子,将帖子的源代码复制下来,并保存为 source.txt。Python 读入这个 source.txt 文件,通过正则表达式获取用户名、发帖内容和发帖时间,并保存为 result.csv。

主要流程如下:

  1. 在浏览器查看网页源代码,复制到文本文件。
  2. 观察网页源代码的规律。
  3. 应用正则表达式

发现规律

通过对比每一层楼的帖子,可以发现规律。以下面这个帖子为例,链接

对比网页与源代码,发现规律。

  • 每一层楼中的 “username” 的位置如下:

对应的正则表达式如下:

1
username="(.*?)"
  • 每一层楼的发帖内容的位置如下:

对应的正则表达式如下:

1
d_post_content j_d_post_content " style="display:;">(.*?)<
  • 每一层楼的发帖时间的位置如下:

对应的正则表达式如下:

1
tail-info">(2022.*?)<

半自动爬虫的例子与代码

应用正则表达式,写出半自动爬虫代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import re

import pandas as pd

with open("./source.txt", "r", encoding="UTF-8") as f:
source = f.read()

result_list = []
username_list = re.findall('username="(.*?)"', source, re.S)
content_list = re.findall('d_post_content j_d_post_content " style="display:;">(.*?)<', source, re.S)
reply_time_list = re.findall('tail-info">(2022.*?)<', source, re.S)

for i in range(len(username_list)):
result = {"username": username_list[i]
,"content": content_list[i]
,"reply_time": reply_time_list[i]
}
result_list.append(result)

df = pd.DataFrame(data=result_list)
df.to_csv("tieba.csv")

结果如下:

注意:虽然这次运行没有问题,但是代码逻辑可能是有问题的。一个帖子一页是 30 楼,这 3 个列表理论上都应该有 30 个元素。那如果帖子里面,有一个人的帖子是在 2022 年以前回的呢?这种情况正则表达式就要修改了。

可以把每一层楼看作一个“块”,先把每一层楼都抓取下来,再在此基础上从每一层楼里面分别获取用户名、帖子内容和发帖时间。这样中间多了一层匹配,但是可以降低出错概率。


简单网页爬虫

requests

有时候爬虫的数据量非常大,不可能每个页面都手动复制源代码,因此需要一种自动化的方式获取网页源代码。requests 可以实现获取网页源代码的功能,代码如下:

1
2
3
import requests

source = requests.get("https://www.baidu.com").content.decode()

网页有很多种打开方式,最常见的是 GET 方式和 POST 方式。

  • 一些页面可以在浏览器里面直接通过输入网址访问的页面,就是使用了 GET 方式。
  • 还有一些页面,只能通过从另一个页面单击某个链接或者某个按钮以后跳过来,不能直接通过在浏览器输入网址访问,这种网页就是使用了 POST 方式。

前面那段代码是通过 get() 方法获取源代码,如果是 post() 方法获取源代码,代码如下:

1
2
3
4
5
6
import requests

data = {'key1': 'value1'
,'key2': 'value2'
}
html_formdata = requests.post("网址", data=data).content.decode()

字典的内容需要根据实际情况修改。

有一些网址提交的内容是 json 格式,则代码如下:

1
html_json = requests.post(’网址’, json=data).content.decode()

multiprocessing.dummy

multiprocessing 是 Python 的多进程库,用来处理与多进程相关的操作。但是由于进程与进程之间不能直接共享内存和堆栈资源,而且启动新的进程开销也比线程大得多,因此使用多线程爬虫比使用多进程有更多的优势。

multiprocessing 下面有一个 dummy 模块,它可以让 Python 的线程使用 multiprocessing 的各种方法。

1
2
3
4
5
6
7
from multiprocessing.dummy import Pool

def query(url):
requests.get(url)

pool = Pool(5)
pool.map(query, url_list)

爬虫算法:DFS 和 BFS

爬虫算法有深度优先和广度优先两类,选择哪个需要看具体的需求而定。

  • 要爬取某网站全国所有的餐馆信息和每个餐馆的订单信息。使用深度优先算法,那么先从某个链接爬到了餐馆A,再立刻去爬餐馆A的订单信息。由于全国有十几万家餐馆,全部爬完可能需要12小时。这样导致的问题就是,餐馆A的订单量可能是早上8点爬到的,而餐馆B是晚上8点爬到的。它们的订单量差了12小时。有了这个时间差,数据分析就不准了。此时应该用 BFS,先把所有餐馆爬出来,然后在集中爬每个餐馆的订单量。
  • 要分析实时舆情,需要爬百度贴吧。一个热门的贴吧可能有几万页的帖子,由于是实时舆情,老帖子的分析意义不大,因此应该 DFS,看到一个帖子就进去爬取楼层信息。

自动爬虫的例子与代码

有了 requests 模块,多线程,以及 BFS、DFS 的基础,我们可以实现一个简单的多线程爬虫了,以小说网站为例。网页:动物农场

我们要做的是两步,第一步是爬取所有章节得网址,第二步是通过多线程爬虫将每一章内容爬下来。

网页源代码如下:

可以看到网址存在于<a>标签中,但<a>标签本身没有特殊的标识符来区分章节的链接和其他的普通链接,因此需要使用分层匹配的技巧:

先构造正则表达式匹配到前面的图中的从“正文”开始到</tbody>结束的部分。然后构造正则表达式提取网址。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
def query(url):
source = requests.get(url).content.decode("gb18030")
return source

def get_toc(html):
"""
获取每一章链接,存储到一个列表中返回
"""
toc_url_list = []
toc_block = re.findall('正文(.*?)</tbody>', html, re.S)[0]
toc_url = re.findall('href="(.*?)"', toc_block, re.S)
for url in toc_url:
toc_url_list.append(base_url + url)
return toc_url_list

base_url = "https://www.kanunu8.com/book3/6879/"
source = query(base_url)
url_list = get_toc(source)

for url in url_list:
print(url)

得到各个章节得网址之后,我们就可以分别访问这些网址,获取每一章的正文。下面我们看一下正文的源代码:

可以看到 <p></p> 标签之间包裹着正文;<br /> 直接用字符串的 replace 方法替换即可;章节名可以通过 size="4"> 来识别。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
def get_article(html):
"""
获取每一章的正文并返回章节名和正文
"""
chapter_name = re.search('size="4"> (.*?)<', html, re.S).group(1)
text_block = re.search('<p>(.*?)</p>', html, re.S).group(1)
text_block = text_block.replace('<br />', '')
return chapter_name, text_block

def save(chapter, article):
"""
将每一章保存到本地
"""
os.makedirs("动物农场", exist_ok=True)
with open(os.path.join("动物农场", chapter+".txt"), "w", encoding="utf-8") as f:
f.write(article)

pool = Pool(5)
chapter_source_list = pool.map(query, url_list)
chapter_content_list = pool.map(get_article, chapter_source_list)

for chapter, article in chapter_content_list:
save(chapter, article)

HTML 内容解析

网页源代码是一种结构化的数据,如果仅仅使用正则表达式,则结构化的优势没有被利用起来。本节我们简要学习一下爬虫中 HTML 的基础知识,包括以下内容:

  • HTML 基础结构
  • 使用 XPath 从 HTML 中提取有用信息
  • 使用 Beautiful Soup4 从 HTML 源代码中提取有用信息

HTML 基础

一个 HTML 标签大致是下面这样的:

1
<标签名 属性1="属性1的值" 属性2="属性2的值">显示在网页上的文本</标签名>

HTML 通过一层套一层的结构来描述一个网页各个部分的相对关系的。例如下图这样:

HTML层次结构示例

这里的<html></html><div></div>等都是 HTML 的标签。如果把 HTML 最外层的标签<html>当作树根,从树根上面分出了两个树枝<head><body>, <body>里面又分出了 class 分别为 useful 和 useless 的两个树枝<div>

XPath

XPath(XML Path)是一种查询语言,它能在 XML(Extensible Markup Language,可扩展标记语言)和 HTML 的树状结构中寻找结点。

Python 总使用 XPath 需要用到第三方库 lxml。

假设我们的 HTML 文本如下(与前面的树形图对应):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<html>
<head>
<title>测试</title>
</head>
<body>
<div class="useful">
<ul>
<li class="info">我需要的信息1</li>
<li class="info">我需要的信息2</li>
<li class="info">我需要的信息3</li>
</ul>
</div>
<div class="useless">
<ul>
<li class="info">垃圾1</li>
<li class="info">垃圾2</li>
</ul>
</div>
</body>
</html>

如果要提取以下信息:

1
2
3
我需要的信息1
我需要的信息2
我需要的信息3

用正则表达式当然可以,如果用 XPath,则代码如下:

1
2
3
4
5
6
7
8
import lxml.html

with open("source.html", "r") as f:
source = f.read()

selector = lxml.html.fromstring(source)
info = selector.xpath('//div[@class="useful"]/ul/li/text()')
print(info)

通过前面的例子,我们了解到使用 XPath 的流程如下

1
2
3
4
import lxml.html

selector = lxml.html.fromstring(source) # source 为网页源代码
info = selector.xpath(xpath_str) # xpath_str 为一段 XPath 语句

网页源代码可以通过 requests 获取,XPath 语句按照一定规则构造。

获取文本的写法大致如下:

1
//标签1[@属性1="属性值1"]/标签2[@属性2="属性值2"]/..../text()

获取属性值的写法大致如下:

1
//标签1[@属性1="属性值1"]/标签2[@属性2="属性值2"]/..../@属性n

其中 [@属性="属性值"] 的作用是帮助过滤相同的标签,如果不需要过滤相同标签则可以省略。

从待提取内容出发写出 XPath

从需要提取的内容开始,往上找到一个能标志待提取内容的有属性值的标签即可。例如:

1
//div[@class="useful"]/ul/li/text()

其中 <ul> 标签没有属性,<li> 标签有属性但所有属性值相同,这两种情况在写 XPath 时可以省略属性

以相同字符串开头的属性

在XPath中,属性以某些字符串开头,写法如下:

1
//标签[starts-with(@属性名,"相同的开头部分")]

属性值包含相同字符串

1
//标签[contains(@属性名, "包含的部分")]

对 XPath 返回的对象执行 XPath

以前面的 HTML 为例,先抓取 useful 标签,然后再对该标签执行 XPath 获取里面的文字,类似于分层匹配的思想。

1
2
3
useful = selector.xpath('//div[@class="useful"]') # 这里返回一个列表
info_list = useful[0].xpath('ul/li/text()')
print(info_list)

对 XPath 返回的对象再次执行 XPath 的时候,子 XPath 开头不需要添加斜线,直接以标签名开始即可。

子标签的处理

以下面的 HTML 为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<! DOCTYPE html>
<html>
<head lang="en">
<meta charset="UTF-8">
<title></title>
</head>
<body>
<div id="test3">
我左青龙,
<span id="tiger">
右白虎,
<ul>上朱雀,
<li>下玄武。</li>
</ul>
老牛在当中,
</span>
龙头在胸口。
</div>
</body>
</html>

如果用下面的 XPath 提取,则只会提取出 “我左青龙” 和 “龙头在胸口”。

因为只有“我左青龙”和“龙头在胸口”这两句是真正属于这个<div>标签的文字信息。XPath并不会自动把子标签的文字提取出来。

1
//div[@id="test3"]/text()

此时可以用 string(.) 关键字,代码如下:

1
2
data = selector.xpath('//div[@id="test3"]')[0]
info = data.xpath('string(.)')

上面的代码用到了分层思想,也就是先获取 <div id="test3"> 这个节点,然后再用 XPath 获取里面的内容。

Beautiful Soup4

Beautiful Soup4 在某些方面比 XPath 易懂,但是不如 XPath 简洁,而且由于它是使用 Python 开发的,因此速度比 XPath 慢。

使用 Beautiful Soup4 提取 HTML 内容,一般要经过以下两步。

  1. 处理源代码生成 BeautifulSoup 对象。
  2. 使用 find_all() 或者 find() 来查找内容。

生成 BeautifulSoup 对象

生成 BeautifulSoup 对象的代码如下,其中解析器可以用 html.parserlxml

1
2
3
from bs4 import BeautifulSoup

soup = BeautifulSoup(网页源代码,’解析器’)

find 与 find_all

查找内容的基本流程和使用 XPath 非常相似。首先要找到包含特殊属性值的标签,并使用这个标签来寻找内容。

主要是 find()find_all() 这两个函数:

  • find_all() 返回的是 BeautifulSoup Tag 对象组成的列表,如果没有找到任何满足要求的标签,就会返回空列表。
  • find() 返回的是一个 BeautifulSoup Tag 对象,如果有多个符合条件的 HTML 标签,则返回第1个对象,如果找不到就会返回 None。

这两个函数的参数完全相同,如下:

1
find_all(name, attrs, recursive, text, **kwargs)
  • name 就是 HTML 的标签名,类似于 body、div、ul、li。
  • attrs 参数的值是一个字典,字典的 Key 是属性名,字典的 Value 是属性值,例如:
1
attrs={'class': 'useful'}
  • recursive 的值为 True 或者 False,当它为 False 的时候,BS4 不会搜索子标签。
  • text 可以是一个字符串或者是正则表达式,用于搜索标签里面的文本信息。
  • **kwargs 表示 Key=Value 形式的参数。这种方式也可以用来根据属性和属性值进行搜索。

代码示例如下,首先根据标签 <div class="useful"> 查找到有用的内容,然后在这个内容的基础上继续查找 <li> 标签下面的内容。find() 方法返回的 BeautifulSoup Tag 对象,可以直接通过 .string 属性就可以读出标签中的文字信息:

1
2
3
4
useful = soup.find(class_="useful")
all_content = useful.find_all('li')
for li in all_content:
print(li.string)

数据库使用

爬虫可以短时间获得大量数据。用文本文件保存是一种方法,但是数据量很大的时候难以检索和管理。此时就有必要使用数据库了。

MongoDB

MongoDB 是一款基于 C ++开发的开源文档数据库,数据在 MongoDB 中以 Key-Value 的形式存储,就像是 Python 中的字典一样。

Mac 安装

1
2
3
4
// 安装MongoDB前的准备
brew tap mongodb/brew
// 安装MongoDB社区服务器的最新可用生产版本(包括所有命令行工具):
brew install mongodb-community

安装后,一些路径如下:

  • 配置文件:/opt/homebrew/etc/mongod.conf
1
2
3
4
5
6
7
8
9
systemLog:
destination: file
path: /opt/homebrew/var/log/mongodb/mongo.log
logAppend: true
storage:
dbPath: /opt/homebrew/var/mongodb
net:
bindIp: 127.0.0.1, ::1
ipv6: true

从配置文件中可以知道:

  • 日志目录路径:/opt/homebrew/var/log/mongodb/mongo.log
  • 数据目录路径:/opt/homebrew/var/mongodb

启动 Mongo

1
brew services start mongodb-community

停止 Mongo

1
brew services stop mongodb-community

Ubuntu 安装

1
2
apt update
apt install mongodb

启动:

1
mongod --config /etc/mongodb.conf

mymongo 安装与使用

PyMongo 模块是 Python 对 MongoDB 操作的接口包,能够实现对 MongoDB 的增删改查及排序等操作。

1
pip install pymongo

初始化数据库

如果 MongoDB 运行在本地计算机上,而且也没有修改端口或者添加用户名及密码,那么初始化 MongoClient 的实例的时候就不需要带参数

1
2
3
from pymongo import MongoClient

client = MongoClient()

如果 MongoDB 是运行在其他服务器上面的,那么就需要使用“URI(Uniform Resource Identifier,统一资源标志符)”来指定连接地址:

1
mongodb://用户名:密码@服务器IP或域名:端口

获取数据库和集合:

1
2
3
4
db_name = '...'
col_name = '...'
database = client[db_name]
collection = database[col_name]

MongoDB 只允许本机访问数据库。这是因为 MongoDB 默认没有访问密码,出于安全性的考虑,不允许外网访问。如果需要从外网访问数据库,那么需要修改安装 MongoDB 时用到的配置文件 mongod.conf。

如下,把 bind_ip 改为 0.0.0.0。然后重启。

1
2
3
4
5
6
7
8
9
10
# Where to store the data.
dbpath=/var/lib/mongodb

#where to log
logpath=/var/log/mongodb/mongodb.log

logappend=true

bind_ip = 127.0.0.1
#port = 27017

插入数据

插入数据的方法为 insert,参数为 Python 字典。

  • insert_one
1
2
3
4
5
6
7
data = {"id": 123
,"name": "kingname"
,"age": 20
,"salary": 99999
}

collection.insert_one(data)
  • insert_many
1
2
3
4
5
6
7
more_data = [{'id': 2, 'name': "张三", 'age': 10, 'salary': 0}
,{'id': 3, 'name': "李四", 'age': 30, 'salary': -100}
,{'id': 4, 'name': "王五", 'age': 40, 'salary': 1000}
,{'id': 5, 'name': "外国人", 'age': 50, 'salary': "未知"}
]

collection.insert_many(more_data)

普通查找

MongoDB 查找的方法如下:

1
2
find(查询条件,返回字段)
find_one(查询条件,返回字段)

find 方法的第2个参数指定返回内容。这个参数是一个字典,Key 就是字段的名称,Value 是 0 或者 1, 0 表示不返回这个字段,1 表示返回这个字段。

代码示例如下:

1
2
# 查找 age 字段为 40 的记录
contents = collection.find({"age": 40})

之后 list(contents) 即可访问 contents 中的内容。

逻辑查询

PyMongo 的逻辑查询符号如下:

符号 意义
$gt 大于
$lt 小于
$gte 大于等于
$lte 小于等于
$eq 等于
$ne 不等于

代码示例如下:

1
2
# 查询 29 <= age <= 40 的记录
contents = collection.find({'age': {'$gte':29, '$lte':40}})

对查询结果排序

第一个参数是要对哪一项排序,第二个参数 -1 表示降序,1 表示升序。

1
content = collection.find({"age": {"$gt": 29}}).sort("age", 1)

对查询结果去重

1
content = collection.distinct(列名)

content 包含了去重后的年龄数据。

更新记录

1
2
collection.update_one(参数1, 参数2)
collection.update_many(参数1, 参数2)

参数1 和参数2 都是字典。参数1用来寻找需要更新的记录,参数2用来更新记录的内容。

代码示例如下:

1
2
3
4
5
# 将第一个年龄为 20 的人名字改为 kingname
collection.update_one({"age": 20}, {"$set": {"name": "kingname"}})

# 将所有年龄为 20 的人的年龄都改为 30
collection.update_many({"age": 20}, {"$set": {"age": 30}})

删除记录

delete_one 方法只删除一条记录,delete_many 删除所有符合要求的记录。

1
2
collection.delete_one(参数)
collection.delete_many(参数)

MongoDB 的一些建议

少读少写少更新

MongoDB 相比于 MySQL 来说,速度快了很多,但是频繁读写 MongoDB 还是会严重拖慢程序的执行速度。

  • 对于写数据,建议把要插入到 MongoDB 中的数据先统一放到一个列表中,等积累到一定量再一次性插入。
  • 对于读数据,在内存允许的情况下,应该一次性把数据读入内存,尽量减少对 MongoDB 的读取操作。

对比逐条插入与批量插入的速度差异,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import datetime
import random
import time

import pymongo

connection = pymongo.MongoClient()
db = connection.write_profile
handler_1_by_1 = db.Data_1_by_1
handler_bat = db.Data_bat

today = datetime.date.today()

# 逐条插入数据
start_1_by_1 = time.time()
for i in range(10000):
delta = datetime.timedelta(days=i)
fact_date = today - delta
handler_1_by_1.insert_one({"time": str(fact_date)
,"data": random.randint(0, 10000)
})
end_1_by_1 = time.time()

# 批量插入数据
start_bat = time.time()
insert_list = []
for i in range(10000):
delta = datetime.timedelta(days=i)
face_date = today - delta
insert_list.append({"time": str(fact_date)
,"data": random.randint(0, 10000)
})
handler_bat.insert_many(insert_list)
end_bat = time.time()

print("逐条插入数据,耗时:{:.4f}".format(end_1_by_1 - start_1_by_1))
print("批量插入数据,耗时:{:.4f}".format(end_bat - start_bat))

结果如下:

1
2
逐条插入数据,耗时:1.5906
批量插入数据,耗时:0.2183

有的时候更新操作不得不逐条进行。例如对于前面代码建立的集合,将 time 列的值延后一天,”2017-01-10” 变为 “2017-07-11”,使用常规操作的话需要一条一条更新。

此时可以把更新改为插入,先批量插入到一个新的 MongoDB 集合,然后把原集合删除,然后将新集合改为原集合的名字。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import datetime
import random
import time

import pymongo

connection = pymongo.MongoClient()
db = connection.write_profile
handler_bat = db.Data_bat

# 逐条更新数据
start_1_by_1 = time.time()
for row in handler_bat.find():
old_date = row["time"]
old_time_datetime = datetime.datetime.strptime(old_date, "%Y-%m-%d")
one_day = datetime.timedelta(days=1)
new_date = old_time_datetime + one_day
handler_bat.update_one({"_id": row["_id"]}, {"$set": {"time": str(new_date.date())}}, upsert=False)
end_1_by_1 = time.time()
print("逐条插入数据,耗时:{:.4f}".format(end_1_by_1 - start_1_by_1))

# 批量插入数据刀新集合,然后再改集合名
handler_update_2_insert = db.Data_update_2_insert
start_update_2_insert = time.time()
insert_list = []
for row in handler_bat.find():
old_date = row["time"]
old_time_datetime = datetime.datetime.strptime(old_date, "%Y-%m-%d")
one_day = datetime.timedelta(days=1)
new_date = old_time_datetime + one_day
row["time"] = str(new_date.date())
insert_list.append(row)
handler_update_2_insert.insert_many(insert_list)
end_update_2_insert = time.time()
print("批量插入数据代替批量更新,耗时:{:.4f}".format(end_update_2_insert - start_update_2_insert))

结果如下:

1
2
逐条插入数据,耗时:1.8011
批量插入数据代替批量更新,耗时:0.2447

能用 Redis 就不用 MongoDB

由于 Redis 是基于内存的数据库,因此即使频繁对其读/写,对性能的影响也远远小于频繁读/写 MongoDB。

例如判断网址之前有没有爬过,则可以把网址 sadd 到集合中,如果返回 1 表示之前没有爬过,如果返回 0 则表示之前已经爬过。

1
2
3
for url in url_list:
if client.sadd("crawled_url", url) == 1:
crawl(url)

Redis

考虑这个问题:如何设计一个开关,实现在不结束程序进程的情况下,从全世界任何一个有网络的地方既能随时暂停程序,又能随时恢复程序的运行。

最简单的方法就是用数据库来实现。在能被程序和控制者访问的服务器中创建一个数据库,数据库名为“Switch_DB”。数据库里面创建一个集合“Switch”,这个集合里面只有一个记录,就是“Status”,它只有两个值,“On”和“Off”。

程序每秒钟就读一次数据库,发现Status为“Off”时就暂停运行,发现“Status”为“On”时就继续运行。如果控制者想让程序暂停运行,就去数据库里面把“Status”修改为“Off”,反之如果想让程序恢复运行,就去数据库里面把“Status”修改为“On”。

数据库可以作为一个媒介来实现人与程序或者程序与程序的沟通,就像前面的做法。MongoDB 可以实现,Redis 比 MongoDB 更快。

Mac 安装

1
brew install redis

安装后,配置文件为 /opt/homebrew/etc/redis.conf,运行 Redis 命令如下:

1
redis-server /opt/homebrew/etc/redis.conf

Ubuntu 安装

在 Ubuntu 下安装 Redis,需要下载 Redis 的源代码并进行编译。

1
2
3
4
wget http://download.redis.io/releases/redis-7.0.7.tar.gz
tar xzf redis-7.0.7.tar.gz
cd redis-7.0.7
make

运行解压以后的文件夹下面的 src 文件夹中的 redis-server 文件启动 redis 服务。

1
src/redis-server

Redis 交互环境的使用

Mac 下终端输入 redis-cli,Ubuntu 下进入 Redis 的安装文件夹找到 redis-cli 即可。

在 Redis 中有多种不同的数据类型。不同的数据类型有不同的操作方法。在爬虫开发的过程中主要会用到 Redis 的列表与集合。

列表

列表是一个可读可写的双向队列,从左侧写数据命令如下:

1
lpush key value1 value2

左侧读数据的命令如下:

1
lpop key

从右侧读写的命令类似,为 rpushrpop

如果不删除列表中的数据,又要把数据读出来,就需要使用关键字lrange

1
lrange key start end

查看列表长度:

1
llen key

集合

添加数据:

1
sadd key value1 value2

读数据,省略 count 表示读一个值,spop 读取数据后将数据从集合中删除:

1
spop key count

要判断一个网址是否已经被爬虫爬过,只需要把这个网址 sadd 到集合中,如果返回 1,表示这个网址还没有被爬过,如果返回 0,表示这个网址已经被爬过了。

查看集合中有多少个值:

1
scard key

Redis-py

1
pip install redis

使用 redis-py 只需要两步,连接 Redis、操作 Redis。

1
2
3
import redis

client = redis.StrictRedis()

如果要连接远程服务器的 Redis,那么只需要填写参数即可。

1
2
3
4
client = redis.StrictRedis(host='192.168.22.33'
,port=2739
,password='12345'
)

一些操作示例代码如下:

1
2
3
l = client.llen(key)
value = client.lpop(key)
client.sadd(key, value)

综合例子: requests+XPath+Redis+MongoDB

网页用前面的例子中用的 动物农场

我们写两个爬虫,爬虫1获取第一章到第十章的网址,将网址添加到 Redis 中的 url_queue 列表中,爬虫2从 Redis 中读出网址,然后进入网址爬取每一章的具体内容,将内容保存到 MongoDB。

主要技术点如下,都是本文涉及到的:

  • 用 requests 获取网页源代码。
  • 使用 XPath 从网页源代码提取数据。
  • 使用 Redis 与 MongoDB 读写数据。

完整代码如下,其中 xpath_str 的写法是在浏览器中对照的 HTML 调试出来的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
import requests
import lxml.html
import redis
from pymongo import MongoClient


redis_client = redis.StrictRedis()

mongo_client = MongoClient()
database = mongo_client.articles
collection = database.mybook


def query(url):
"""
获取网页源代码
"""
source = requests.get(url).content.decode("gb18030")
return source

def get_toc(base_url):
"""
获取每一章链接,存储到 Redis
"""
html = query(base_url)
selector = lxml.html.fromstring(html) # html 为网页源代码
xpath_str = "//table[2]/tbody/tr/td/a/@href"
toc_url = selector.xpath(xpath_str)
toc_url_list = []
for url in toc_url:
toc_url_list.append(base_url + url)
return toc_url_list

def get_article(html):
"""
获取每一章的正文并返回章节名和正文
"""
selector = lxml.html.fromstring(html)
# html/body/div/table[4]/
xpath_str = "//tr[1]/td/strong/font/text()"
chapter_name = selector.xpath(xpath_str)
xpath_str = "//tr/td[2]/p/text()"
text_block = selector.xpath(xpath_str)
return chapter_name, text_block


def main():
base_url = "https://www.kanunu8.com/book3/6879/"
url_list = get_toc(base_url)

for url in url_list:
redis_client.lpush('url_queue', url)

content_list = []
while redis_client.llen("url_queue") > 0:
url = redis_client.lpop("url_queue").decode()
source = query(url)
chapter_name, text_block = get_article(source)
content_list.append({"title": chapter_name
,"content": "\n".join(text_block)
})
collection.insert_many(content_list)


if __name__ == "__main__":
main()

Share