Selenium是一个自动化测试工具,主要用于测试Web应用程序的功能和性能。下面是一些Selenium的基本概念:
下面是入门 Selenium 的步骤:
1 | pip install selenium |
webdriver
模块来创建浏览器实例。比如,以下代码可以创建一个 Chrome 浏览器实例:1 | from selenium import webdriver |
get()
方法可以打开指定的网页:1 | driver.get('https://www.baidu.com/') |
find_element_by_*()
方法可以查找页面上的元素,比如通过 ID、名称、标签名等查找。以下是一些示例:1 | # 通过 ID 查找元素 |
1 | search_box = driver.find_element_by_id('kw') |
quit()
方法可以关闭浏览器:1 | driver.quit() |
Selenium提供了许多鼠标操作的方法,可以通过鼠标模拟用户的操作。以下是一些常用的鼠标操作方法:
1 | from selenium.webdriver.common.action_chains import ActionChains |
1 | from selenium.webdriver.common.action_chains import ActionChains |
1 | from selenium.webdriver.common.action_chains import ActionChains |
1 | from selenium.webdriver.common.action_chains import ActionChains |
1 | from selenium.webdriver.common.action_chains import ActionChains |
1 | from selenium.webdriver.common.action_chains import ActionChains |
x_offset
和y_offset
是相对于当前鼠标位置的偏移量。requests库是一个非常酷的Python库,它可以让你轻松地向其他服务器发送HTTP请求并获取响应。使用requests库,你可以像发送GET、POST、PUT和DELETE这些HTTP方法一样简单地发送HTTP请求。如果需要,你还可以添加查询字符串参数、POST数据、HTTP头等自定义HTTP请求。响应内容可以以文本、JSON、图像等格式检索,非常方便。
还有一个好处是requests库提供了一个内置的异常模块来处理HTTP请求和响应的错误,你不用担心出现错误的情况。如果你需要在多个请求之间保留一些参数,如Cookie和请求头,它还提供了一个Session对象。
如果你需要处理Web API或其他HTTP服务,使用requests库会让你感觉非常爽!
安装非常简单:1
pip install requests
使用requests发送GET请求非常简单,只需要使用requests.get()方法即可。例如:1
2
3
4import requests
response = requests.get('https://www.baidu.com')
print(response.text)
上面的代码会发送一个GET请求到百度首页,并打印出响应内容。
使用requests发送POST请求也非常简单,只需要使用requests.post()方法即可。例如:1
2
3
4
5import requests
data = {'username': 'admin', 'password': '123456'}
response = requests.post('http://example.com/login', data=data)
print(response.text)
上面的代码会发送一个POST请求到http://example.com/login,并将data作为请求体发送过去。
有时候我们需要发送带参数的请求,可以使用params参数来指定。例如:1
2
3
4
5import requests
params = {'key1': 'value1', 'key2': 'value2'}
response = requests.get('http://example.com/api', params=params)
print(response.text)
上面的代码会发送一个GET请求到http://example.com/api,并将params作为查询字符串发送过去。
有时候我们需要发送带请求头的请求,可以使用headers参数来指定。例如:1
2
3
4
5import requests
headers = {'User-Agent': 'Mozilla/5.0'}
response = requests.get('https://www.baidu.com', headers=headers)
print(response.text)
上面的代码会发送一个GET请求到百度首页,并设置User-Agent请求头为Mozilla/5.0。
有时候我们需要发送带Cookie的请求,可以使用cookies参数来指定。例如:1
2
3
4
5import requests
cookies = {'session_id': '123456'}
response = requests.get('http://example.com', cookies=cookies)
print(response.text)
上面的代码会发送一个GET请求到http://example.com,并设置Cookie为session_id=123456。
有时候我们需要发送带文件的请求,可以使用files参数来指定。例如:1
2
3
4
5import requests
files = {'file': open('example.txt', 'rb')}
response = requests.post('http://example.com/upload', files=files)
print(response.text)
上面的代码会发送一个POST请求到http://example.com/upload,并将example.txt文件作为请求体发送过去。
有时候我们需要发送带认证信息的请求,可以使用auth参数来指定。例如:1
2
3
4
5import requests
auth = ('username', 'password')
response = requests.get('http://example.com', auth=auth)
print(response.text)
上面的代码会发送一个GET请求到http://example.com,并使用基本认证方式进行认证。
有时候我们需要发送带代理的请求,可以使用proxies参数来指定。例如:1
2
3
4
5import requests
proxies = {'http': 'http://127.0.0.1:8080', 'https': 'https://127.0.0.1:8080'}
response = requests.get('http://example.com', proxies=proxies)
print(response.text)
上面的代码会发送一个GET请求到http://example.com,并通过代理服务器127.0.0.1:8080进行访问。
有时候我们需要设置请求超时时间,可以使用timeout参数来指定。例如:1
2
3
4import requests
response = requests.get('http://example.com', timeout=5)
print(response.text)
上面的代码会发送一个GET请求到http://example.com,并设置超时时间为5秒。
以上就是requests的主要用法,使用requests可以让我们方便地发送HTTP请求并获取响应,非常适合进行Web爬虫和API开发等工作。
]]>Flask是一个轻量级的Python Web框架。它由Armin Ronacher在2010年创建,是一个开源的软件。Flask的设计理念是简单而灵活,它提供了核心的功能,同时也允许开发者根据自己的需要自由选择和使用扩展。Flask采用Werkzeug作为底层的WSGI工具箱,Jinja2作为模板引擎。与其他Python Web框架相比,Flask的优势在于它的简单性和灵活性,同时还具有可扩展性和可定制性。Flask适用于开发小型Web应用程序,如博客、社交网络、API等。
Flask具有以下特点:
Flask的用法包括以下方面:
@app.route('/hello') def hello(): return 'Hello, World!'
from flask import request, make_response
from flask import render_template
app.use('/static', static_folder='static')
from flask_wtf import FlaskForm
from flask_sqlalchemy import SQLAlchemy
1 | pip install Flask |
1 | from flask import Flask |
1 | @app.route('/') |
1 | if __name__ == '__main__': |
1 | python app.py |
1 | <html> |
1 | from flask import render_template |
1 | pip install Flask-WTF |
1 | <html> |
1 | from flask_wtf import FlaskForm |
完整的示例代码: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
30from flask import Flask, render_template
from flask_wtf import FlaskForm
from wtforms import StringField, TextAreaField, SubmitField
from wtforms.validators import DataRequired, Email
app = Flask(__name__)
app.config['SECRET_KEY'] = 'secret'
class ContactForm(FlaskForm):
name = StringField('Name', validators=[DataRequired()])
email = StringField('Email', validators=[DataRequired(), Email()])
message = TextAreaField('Message', validators=[DataRequired()])
submit = SubmitField('Send')
@app.route('/')
def index():
title = 'Flask Tutorial'
message = 'Hello, World!'
return render_template('index.html', title=title, message=message)
@app.route('/contact', methods=['GET', 'POST'])
def contact():
form = ContactForm()
if form.validate_on_submit():
# 处理表单提交
return 'Thank you for your message!'
return render_template('contact.html', form=form)
if __name__ == '__main__':
app.run()
以上是 Flask 的详细教程,具体操作可以根据需求进行调整。
]]>本教程将介绍InfluxDB的基本概念、安装、配置和使用方法。
InfluxDB的基本概念包括数据库、测量、标签、字段和时间戳。
InfluxDB中的数据库类似于其他数据库系统中的数据库,它是一个存储数据的容器。在InfluxDB中,每个数据库都可以包含多个测量。
测量是InfluxDB中存储数据的基本单位。它类似于关系型数据库中的表格,但是它没有固定的列数和数据类型。每个测量包含多个数据点,每个数据点都有一个时间戳、零个或多个标签和零个或多个字段。
标签是用于标识数据点的元数据,类似于关系型数据库中的索引。标签是键值对的形式,例如“host”:“server01”,它们通常用于过滤和聚合数据。
字段是数据点的实际数据,它们可以是任意类型的。例如,一个字段可以是一个整数、浮点数、字符串或布尔值。
时间戳是数据点的时间信息,它通常是一个Unix时间戳(以秒为单位),但也可以使用其他格式。时间戳是InfluxDB中唯一必需的元素。
在安装InfluxDB之前,您需要先安装GPG密钥:1
curl -sL https://repos.influxdata.com/influxdb.key | sudo apt-key add -
然后添加存储库:1
echo "deb https://repos.influxdata.com/debian buster stable" | sudo tee /etc/apt/sources.list.d/influxdb.list
最后,更新软件包列表并安装InfluxDB:1
2sudo apt-get update
sudo apt-get install influxdb
在安装InfluxDB后,您可以使用以下命令启动服务:1
sudo systemctl start influxdb
要检查InfluxDB是否正在运行,请运行以下命令:1
sudo systemctl status influxdb
InfluxDB的默认配置文件位于“/etc/influxdb/influxdb.conf”。您可以编辑此文件以更改InfluxDB的配置。
例如,要更改HTTP端口,请找到以下行并将其更改为所需的端口:1
2
3
4
5
6[http]
# Determines whether HTTP endpoint is enabled.
enabled = true
# The bind address used by the HTTP service.
bind-address = ":8086"
要创建一个新数据库,请使用以下命令:1
CREATE DATABASE mydb
要向数据库中插入数据,请使用以下命令:1
INSERT my_measurement,tag1=value1,tag2=value2 field1=value3,field2=value4 timestamp
例如,要向名为“my_measurement”的测量中插入一个名为“cpu”的标签和一个名为“usage”的字段,请使用以下命令:1
INSERT my_measurement,cpu=server01 usage=0.64 1537522394
要查询数据库中的数据,请使用以下命令:1
SELECT * FROM my_measurement
这将返回名为“my_measurement”的所有测量的所有数据点。
您也可以使用WHERE子句来过滤数据:1
SELECT * FROM my_measurement WHERE cpu='server01'
这将返回具有标签“cpu”等于“server01”的所有数据点。
InfluxDB有一个官方的Python API库,可以通过它来与InfluxDB进行交互。
以下是使用InfluxDB Python API库的基本步骤:
1 | pip install influxdb |
1 | from influxdb import InfluxDBClient |
1 | client = InfluxDBClient(host='localhost', port=8086) |
1 | client.create_database('mydb') |
1 | json_body = [ |
1 | result = client.query('SELECT * FROM cpu_load') |
完整的Python代码示例: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
27from influxdb import InfluxDBClient
# 创建InfluxDBClient对象
client = InfluxDBClient(host='localhost', port=8086)
# 创建数据库
client.create_database('mydb')
# 插入数据
json_body = [
{
"measurement": "cpu_load",
"tags": {
"host": "server01",
"region": "us-west"
},
"time": "2022-01-01T00:00:00Z",
"fields": {
"value": 0.64
}
}
]
client.write_points(json_body)
# 查询数据
result = client.query('SELECT * FROM cpu_load')
print(result)
需要注意的是,如果在InfluxDB中使用了认证机制,需要在创建InfluxDBClient对象时提供用户名和密码:1
client = InfluxDBClient(host='localhost', port=8086, username='myuser', password='mypassword')
此外,还可以在创建InfluxDBClient对象时提供其他参数,例如数据库名称、认证机制、SSL配置等。详情请参考InfluxDB Python API文档。
InfluxDB是一种高性能、分布式的时序数据库,它具有高效的写入和查询速度、可扩展性和灵活的数据模型。在本教程中,我们介绍了InfluxDB的基本概念、安装、配置和使用方法。现在您已经准备好开始使用InfluxDB了!
]]>Kafka是一个分布式流处理平台,由Apache开发和维护。它主要用于构建实时数据管道和流处理应用程序。Kafka的设计目标是处理高容量、高吞吐量和低延迟的数据流。
Kafka基于发布-订阅模型。生产者将数据发布到Kafka主题,消费者订阅这些主题以接收数据。Kafka还支持分区的概念,允许数据分布在集群中的多个节点上。这使得Kafka具有高可扩展性和容错性。
Kafka的一些关键特点包括:
总之,Kafka是一个强大的流处理平台,可以帮助企业构建高效、可靠的实时数据管道和流处理应用程序。
以下是Kafka的基本概念:
这些概念是Kafka中非常重要的,理解它们有助于更好地使用和管理Kafka集群。
下面是使用Kafka的基本步骤:
1 | bin/kafka-topics.sh --create --zookeeper localhost:2181 --replication-factor 1 --partitions 1 --topic test |
1 | bin/kafka-console-producer.sh --broker-list localhost:9092 --topic test |
1 | bin/kafka-console-consumer.sh --bootstrap-server localhost:9092 --topic test --from-beginning |
这只是Kafka使用的基本步骤,还有很多高级特性和配置需要进一步了解和掌握。可以参考Kafka官方文档和社区资源进行学习和实践。
Kafka提供了Python客户端API,可以使用Python编写Kafka生产者和消费者应用程序。下面是使用Python接口的基本示例:
1 | pip install kafka-python |
1 | from kafka import KafkaProducer |
1 | from kafka import KafkaConsumer |
Minio是一个开源的对象存储服务器,它是一个轻量级的替代品,可以用来存储和管理大量的非结构化数据,例如图像、视频、文本等。它支持S3 API,允许您使用S3兼容的客户端工具和库将文件上传到Minio服务器中。
Minio的主要特点包括:
Minio可以在本地或云中运行,可以与Amazon S3、Azure Blob Storage等其他对象存储服务进行交互。它还提供了丰富的API和SDK,可以轻松地集成到各种应用程序和系统中。
总之,Minio是一个快速、可扩展、安全和开放的对象存储服务器,是处理大量非结构化数据的理想选择。
Minio是一个对象存储服务器,不是一个数据库。因此,它不是一个关系型数据库或非关系型数据库,而是一个面向对象的存储系统。在Minio中,每个对象都是一个二进制文件,可以包含任何类型的数据。
虽然Minio不是一个数据库,但是它允许您在对象中存储元数据,这些元数据可以用来描述对象的属性。以下是一些常见的Minio对象属性:
通过使用这些属性,您可以在Minio中存储和检索任意类型的数据,并对其进行元数据标记,以便更好地管理和组织您的存储数据。
您可以从Minio的官方网站上下载适合您操作系统的Minio二进制文件。安装完成后,您可以在命令行中运行Minio服务器。
在命令行中运行以下命令启动Minio服务器:1
minio server /path/to/data
其中,/path/to/data
是您要存储数据的目录路径。
在浏览器中访问http://localhost:9000
,您将看到Minio的控制台。在控制台中,您可以创建和管理存储桶、上传和下载文件等。
您可以使用S3兼容的客户端工具和库将文件上传到Minio服务器中。例如,您可以使用AWS CLI命令行工具:1
aws s3 cp /path/to/local/file s3://mybucket/myfile
其中,mybucket
是您要上传文件的存储桶名称,myfile
是您要上传文件的文件名。
Minio还提供了SDK,您可以使用它来编写自己的应用程序来与Minio服务器交互。例如,您可以使用Minio SDK for Python:1
2
3
4
5
6
7
8
9
10
11
12
13
14from minio import Minio
from minio.error import ResponseError
# Initialize Minio client
client = Minio('localhost:9000',
access_key='ACCESS_KEY',
secret_key='SECRET_KEY',
secure=False)
# Upload file to bucket
try:
client.fput_object('mybucket', 'myfile', '/path/to/local/file')
except ResponseError as err:
print(err)
其中,ACCESS_KEY
和SECRET_KEY
是您在Minio服务器上创建的访问密钥。
这些是使用Minio的一些基本教程。您可以在Minio的官方文档中找到更多信息和教程。
1 | pip install minio |
1 | from minio import Minio |
其中,localhost:9000
是Minio服务器的地址和端口,ACCESS_KEY
和SECRET_KEY
是您在Minio服务器上创建的访问密钥。
1 | # Create bucket |
mybucket
是您要创建的存储桶名称。1 | # Upload file to bucket |
其中,mybucket
是您要上传文件的存储桶名称,myfile
是您要上传文件的文件名,/path/to/local/file
是本地文件的路径。
1 | # Download file from bucket |
其中,mybucket
是您要下载文件的存储桶名称,myfile
是您要下载的文件名,/path/to/local/file
是本地文件的路径。
这些是使用Python编写与Minio服务器交互的基本教程。您可以在Minio的官方文档中找到更多信息和教程。
(未验证,谨慎使用)
要将新硬盘添加到Minio中,您可以按照以下步骤进行操作:
mc
命令行工具,创建一个新的存储桶,用于存储新硬盘上的对象数据。1 | minio server /path/to/data1 /path/to/data2 |
/path/to/data1
是现有硬盘的数据目录路径,/path/to/data2
是新硬盘的数据目录路径。您可以在启动Minio服务器时指定多个数据目录路径,以将对象数据分布在多个硬盘上。mc
命令行工具来确认扩容是否成功。检查新存储桶是否已经创建,并且新硬盘上的对象数据是否已经复制到新存储桶中。重复以上步骤,您可以轻松地将多个硬盘添加到Minio中,以扩展存储容量。
其他教程:
https://blog.csdn.net/qq_35036073/article/details/108262407
https://www.cnblogs.com/liugp/p/16560313.html
本篇介绍一下PostgreSQL的用法。先看一下该数据库的特点(以下来自ChatGPT):
- 开源免费:PostgreSQL是一款开源的关系型数据库管理系统,用户可以免费使用和修改。
- 高度可扩展性:PostgreSQL支持水平和垂直扩展,可以满足不同规模应用的需求。
- ACID兼容:PostgreSQL保证了数据的原子性、一致性、隔离性和持久性,确保了数据的完整性和可靠性。
- 多版本并发控制:PostgreSQL采用了多版本并发控制(MVCC)技术,可以支持高并发读写操作。
- 支持复杂数据类型:PostgreSQL支持各种复杂数据类型,如数组、JSON、XML等,可以满足各种应用的需求。
- 大数据处理能力:PostgreSQL支持大数据处理,可以处理数百万甚至数十亿条数据。
- 可扩展的存储引擎:PostgreSQL支持多种存储引擎,如B-tree、哈希表、GiST、SP-GiST、GIN、BRIN等,可以满足不同的应用场景。
- 可编程性:PostgreSQL支持多种编程语言,如SQL、PL/pgSQL、PL/Python、PL/Perl等,可以方便地进行开发和扩展。
postgreSQL的下载地址在这里。
对于Windows
和macOS
,直接使用EDB
网站上的安装器最简单了,见这里。
下载后双击安装,中间安装过程中配置一下默认自带的postgres
数据库的密码即可。
pgSQL默认会创建:
对于Linux
系统,我从chatGPT上也问了一下教程如下(谨慎服用)。
1 | sudo apt-get update |
1 | sudo apt-get install postgresql postgresql-contrib |
1 | sudo systemctl status postgresql |
1 | sudo -u postgres createuser --interactive |
1 | sudo -u postgres createdb dbname |
1 | sudo -u postgres psql |
1 | CREATE USER username WITH PASSWORD 'password'; |
1 | GRANT ALL PRIVILEGES ON DATABASE dbname TO username; |
1 | \q |
1 | host all all 0.0.0.0/0 md5 |
1 | listen_addresses = '*' |
1 | psql -h 192.168.1.100 -p 5432 -U postgres |
(以下也全部来自chatgpt)
假设我们有一个学生信息表格,包含以下字段:学生ID、姓名、性别、年龄、所在班级。
我们可以使用pgsql来创建这个表格,并插入一些样本数据。
首先,我们需要在pgsql中创建一个新的数据库,并连接到该数据库:1
2CREATE DATABASE mydatabase;
\c mydatabase;
接下来,我们可以使用以下命令创建学生信息表格:1
2
3
4
5
6
7CREATE TABLE students (
id serial PRIMARY KEY,
name varchar(50) NOT NULL,
gender varchar(10),
age int,
class varchar(50)
);
这将创建一个名为“students”的表格,并定义了一个自增的主键“id”以及四个其他字段。
现在,我们可以向表格中插入一些样本数据:1
2
3
4INSERT INTO students (name, gender, age, class)
VALUES ('张三', '男', 18, '一班'),
('李四', '女', 19, '二班'),
('王五', '男', 20, '三班');
这将向学生信息表格中插入三个新的学生记录。
我们可以使用以下命令来查询学生信息表格中的所有记录:1
SELECT * FROM students;
这将返回一个包含所有学生信息的表格。
我们也可以使用其他查询命令来获取特定的数据,例如:1
2
3
4
5
6
7
8-- 获取所有年龄大于18岁的学生
SELECT * FROM students WHERE age > 18;
-- 获取所有所在班级为“二班”的学生
SELECT * FROM students WHERE class = '二班';
-- 获取所有男生的姓名和年龄
SELECT name, age FROM students WHERE gender = '男';
这些命令将返回符合特定条件的学生信息。
以上就是一个简单的pgsql用法示例,展示了如何创建表格、插入数据和查询数据。
在 PostgreSQL 中,创建外键需要以下步骤:
创建主表和从表。
1 | CREATE TABLE main_table ( |
在从表中创建外键。
1 | ALTER TABLE sub_table ADD CONSTRAINT sub_table_main_id_fkey FOREIGN KEY (main_id) REFERENCES main_table(id); |
1 | INSERT INTO main_table (name) VALUES ('Main 1'); |
在 Python 中,可以通过 psycopg2 模块来连接 PostgreSQL 数据库。以下是连接 PostgreSQL 数据库的基本步骤:
1 | pip install psycopg2 |
1 | import psycopg2 |
1 | conn = psycopg2.connect( |
1 | cur = conn.cursor() |
1 | cur.execute("SELECT * FROM mytable") |
1 | rows = cur.fetchall() |
1 | cur.close() |
1 | import psycopg2 |
相比于其他数据库,ClickHouse有以下特点:
高性能
ClickHouse是一个面向OLAP场景的数据库,处理大规模数据时具有卓越的性能优势。它基于列式存储、多线程计算和数据压缩等技术,可以在快速查询大规模数据时保持高性能。
多维分析
ClickHouse支持高效的多维分析,包括对数据立方体、时间序列和交叉表查询等。它可以轻松处理高复杂度数据分析和聚合任务。
实时查询
ClickHouse具备实时查询的能力,可以处理大规模数据并保持高性能。这使得实时数据分析和查询可以轻松实现。
可扩展性
ClickHouse设计为可扩展的数据库,并且可以轻松地在多台服务器上部署和管理。它具有内置的分布式查询功能,可自动将查询负载分配给集群中的节点。
兼容SQL
ClickHouse与SQL标准兼容,这使得它的使用非常简单且易于学习。许多标准的SQL函数和语句可以用于ClickHouse,例如Group by、Order by、Filter等。
数据安全
ClickHouse提供多种安全性选项,包括访问控制、身份验证和加密传输。这使得敏感数据的保护得到了保证。
综上所述,ClickHouse是一款高性能、高效和可扩展的列式数据库,旨在处理大规模数据和多维分析任务。它强大的性能和各种可用的特性使其成为适用于企业的理想解决方案。
可以在官方网站上下载ClickHouse二进制安装文件,也可以使用apt-get或yum安装。
在ClickHouse中,数据存储在数据库和表中。在使用之前,必须先创建数据库和表。可以使用以下命令创建数据库和表:
1 | CREATE DATABASE mydb; |
1 | CREATE TABLE mydb.mytable ( |
这个示例定义了一个名为mydb的数据库和一个名为mytable的表。mytable包含四个列,分别为id、name、age和address。类型分别为UInt32、String、UInt8和String。MergeTree作为存储引擎,用于排序和分区,按照id列排序。
可以使用INSERT语句将数据插入表中。例如:1
INSERT INTO mydb.mytable (id,name,age,address) VALUES (1,'Tom',28,'New York');
ClickHouse支持SQL标准,所以可以使用SELECT语句来查询数据。例如:1
SELECT * FROM mydb.mytable WHERE age > 25;
这个示例将返回所有年龄大于25岁的行。
ClickHouse内置了各种聚合函数,例如COUNT、SUM、AVG等。例如:1
SELECT COUNT(*) FROM mydb.mytable;
返回表的行数。
1 | SELECT AVG(age) FROM mydb.mytable; |
返回age列的平均值。
可以使用DELETE语句删除表中的数据,例如:1
DELETE FROM mydb.mytable WHERE id = 1;
这个示例将删除id等于1的行。
如果要删除整个表,可以使用DROP TABLE语句,例如:
1 | DROP TABLE mydb.mytable; |
ClickHouse支持多个索引类型,包括Bitmap、Bloom Filter、MergeTree等。例如要创建支持Bloom Filter的表,可以使用以下语句:
1 | CREATE TABLE mydb.mytable ( |
这个示例使用内存存储引擎和Bloom Filter索引,指定id作为主键。
ClickHouse可以在多个节点上进行分布式查询,以提高查询性能和可伸缩性。要进行分布式查询,必须在多个节点上安装ClickHouse,并配置每个节点的连接信息。在连接ClickHouse时,可以指定多个节点作为集群,允许在所有节点上分配查询负载。
以上是ClickHouse的基本入门教程,希望对您有所帮助。
假设有一个在线电商网站,每天产生大量的访问日志。为了更好地了解用户的行为习惯以及优化网站的性能,需要将这些日志数据进行存储和分析。下面是使用ClickHouse进行日志存储和分析的基本流程:
将日志数据导入ClickHouse
首先,需要将日志数据导入ClickHouse。可以使用诸如Fluentd、Logstash、Kafka等工具将日志数据导入ClickHouse。ClickHouse支持接收PlainText数据和Kafka数据,使数据导入起来非常简单。
创建表格结构
在将日志数据导入ClickHouse之前,需要先创建相应的表格结构以存储这些数据。这可以通过ClickHouse的CREATE TABLE语句来实现。例如,可创建一个名为“access_logs”的表格结构,包含列“timestamp”、”ip”、”http_method”、”uri_path”、”referrer”、”user_agent”等。
分析日志数据
一旦数据被存储在ClickHouse中,就可以利用ClickHouse的多维分析功能进行数据分析。例如,可以使用ClickHouse的GROUP BY子句分析每个IP地址对网站的访问量。也可以使用WHERE子句,查询特定时间范围内的访问量。此外,ClickHouse还允许对结果进行排序、过滤、聚合等各种操作。
可视化分析结果
最后,可以利用可视化工具(如Grafana)将分析结果可视化,以便更好地呈现数据和趋势。这有助于更好地理解业务和用户行为,从而做出更好的决策。
综上所述,ClickHouse在日志存储和分析方面有着广泛的应用。它具有高性能、实时查询、多维分析、易于部署和管理的优点,可以通过多种工具将日志数据导入ClickHouse,并利用其内置的分布式查询功能进行大规模数据处理。最终,利用可视化工具将分析结果呈现出来,从而得出更加准确的业务决策。
]]>以下是Elasticsearch的入门教程:
1 | PUT /my_index |
1 | POST /my_index/_doc |
1 | GET /my_index/_search |
1 | GET /my_index/_search?q=title:My Document |
1 | GET /my_index/_search |
1 | GET /my_index/_search |
这将返回标题包含“document”并且日期在2021年1月1日之后的所有文档。
以上是Elasticsearch的基本入门教程,可以根据实际需求进行深入学习和使用。
Elasticsearch的基本数据结构主要包括索引、文档和字段。
除了以上基本数据结构外,Elasticsearch还支持以下一些高级数据结构:
Elasticsearch的基本数据结构包括索引、文档和字段,以下是每个数据结构的示例:
1 | PUT /my_index |
1 | POST /my_index/_doc |
1 | POST /my_index/_doc |
1 | PUT /products |
1 | POST /products/_doc |
1 | GET /products/_search |
要创建一个索引,需要遵循以下步骤:
1 | PUT /my_index |
1 | PUT /my_index |
1 | PUT /my_index/_mapping |
1 | GET /my_index |
Elasticsearch索引的属性可以在创建索引时进行设置,主要包括以下几个方面:
Elasticsearch的文档是最小的数据单元,它是一个JSON格式的数据对象,可以包含任意数量的字段。每个文档都有一个唯一的ID和一个类型,它们通常被存储在一个索引中。
文档的结构由映射(Mapping)定义,映射指定了每个字段的数据类型、分析器、索引选项等。例如,一个名为“product”的文档可能包含以下字段:1
2
3
4
5
6
7{
"id": 1,
"name": "Product A",
"description": "This is a product A",
"price": 100.00,
"category": "Electronics"
}
在这个文档中,有五个字段:id、name、description、price和category。每个字段都有一个名称和一个数据类型,id是整数类型、name和description是文本类型、price是浮点数类型、category是字符串类型。每个字段的数据类型可以影响搜索和聚合的性能和准确性,因此需要仔细设计和测试。
文档可以被索引和搜索,索引是将文档存储到Elasticsearch中的过程,搜索是从Elasticsearch中查询文档的过程。要索引一个文档,可以使用PUT命令,例如:1
2
3
4
5
6
7
8PUT /my_index/_doc/1
{
"id": 1,
"name": "Product A",
"description": "This is a product A",
"price": 100.00,
"category": "Electronics"
}
这将在名为“my_index”的索引中索引一个ID为1的文档。要搜索文档,可以使用GET命令,并指定查询参数,例如:1
GET /my_index/_search?q=name:Product A
这将返回名为“Product A”的文档。
Elasticsearch的字段是文档中的属性,它们包含了文档的具体信息。每个字段都有一个名称和一个数据类型,可以是字符串、数字、日期、布尔值等。
在Elasticsearch中,字段的数据类型可以影响搜索和聚合的性能和准确性,因此需要仔细设计和测试。以下是一些常见的字段类型:
1 | "title": { |
1 | "category": { |
1 | "price": { |
1 | "created_at": { |
1 | "is_published": { |
1 | "properties": { |
1 | "properties": { |
1 | "properties": { |
以上是映射的基本定义,可以根据实际需求进行扩展。映射的定义可以影响到搜索和聚合的性能和准确性,因此需要仔细设计和测试。
Elasticsearch的聚合是一种数据分析方式,可以对一组数据进行统计分析,例如计算平均值、最大值、最小值、总和等。聚合操作可以帮助我们更好地理解数据,并从中发现有价值的信息。
以下是一些常用的聚合操作:
1 | { |
1 | { |
1 | { |
1 | { |
1 | { |
1 | { |
1 | { |
1 | { |
1 | GET /_cat/indices?v |
1 | GET /_cat/nodes?v |
1 | GET /_cluster/health |
1 | GET /_cluster/health?level=shards |
1 | PUT /index_name |
1 | GET /index_name/_mapping |
1 | POST /index_name/_doc |
1 | POST /index_name/_bulk |
1 | GET /index_name/_search |
1 | DELETE /index_name |
Kibana是一款用于可视化和分析Elasticsearch数据的开源工具。本教程将介绍如何使用Kibana进行数据分析和可视化。
Kibana是一款功能强大的工具,可帮助您可视化和分析Elasticsearch数据。本教程提供了一些基本的步骤,帮助您开始使用Kibana进行数据分析和可视化。
Logstash是一个开源的数据收集引擎,可以从各种来源收集、转换和发送数据。它是Elastic Stack(ELK)的一部分,用于处理和分析大量数据。以下是Logstash的教程:
1 | input { stdin {} } |
运行Logstash
当您完成配置文件后,可以使用以下命令来运行Logstash:
1 | bin/logstash -f /path/to/config/file.conf |
其中,/path/to/config/file.conf
是您的Logstash配置文件的路径。Logstash将读取配置文件并开始处理数据。您可以使用Ctrl-C来停止Logstash。
使用Logstash收集数据
Logstash支持从各种数据源收集数据,包括文件、数据库、网络等。以下是一个从文件中收集数据的示例:
1 | input { file { path => "/path/to/log/file.log" } } |
这个配置文件指定从/path/to/log/file.log文件中收集数据,并将数据输出到标准输出。您可以根据需要添加其他输出插件,例如Elasticsearch,将数据发送到Elasticsearch进行索引和分析。
使用Logstash过滤数据
Logstash还支持使用过滤器插件来转换和过滤数据。以下是一个示例:
1 | input { file { path => "/path/to/log/file.log" } } |
这个配置文件使用grok过滤器插件来解析Apache访问日志中的数据,并将数据输出到标准输出。您可以根据需要添加其他过滤器插件,例如mutate、date等。
使用Logstash与Elasticsearch集成
Logstash与Elasticsearch集成非常紧密,您可以使用Logstash将数据发送到Elasticsearch进行索引和分析。以下是一个示例:
1 | input { file { path => "/path/to/log/file.log" } } |
这个配置文件指定将数据发送到本地运行的Elasticsearch实例,并将数据索引到myindex索引中。您可以在Kibana中使用myindex索引来进行数据可视化和分析。
总结
以上是Logstash的简单教程,您可以根据需要进行扩展和定制。Logstash非常强大,可以处理各种类型的数据,包括结构化和非结构化数据。通过与Elasticsearch、Kibana等工具集成,您可以构建一个完整的数据分析平台。
前面我们已经介绍了 Scrapy 的一些常见用法,包括服务端渲染页面的抓取和 API 的抓取,Scrapy 发起 Request 之后,返回的 Response 里面就包含了想要的结果。
但是现在越来越多的网页都已经演变为 SPA 页面,其页面在浏览器中呈现的结果是经过 JavaScript 渲染得到的,如果我们使用 Scrapy 直接对其进行抓取的话,其结果和使用 requests 没有什么区别。
那我们真的要使用 Scrapy 完成对 JavaScript 渲染页面的抓取应该怎么办呢?
之前我们介绍了 Selenium 和 Pyppeteer 都可以实现 JavaScript 渲染页面的抓取,那用了 Scrapy 之后应该这么办呢?Scrapy 能和 Selenium 或 Pyppeteer 一起使用吗?答案是肯定的,我们可以将 Selenium 或 Pyppeteer 通过 Downloader Middleware 和 Scrapy 融合起来,实现 JavaScript 渲染页面的抓取,本节我们就来了解下它的实现吧。
在前面我们介绍了 Downloader Middleware 的用法,在 Downloader Middleware 中有三个我们可以实现的方法 process_request、process_response 以及 process_exception 方法。
我们再看下 process_request 方法和其不同的返回值的效果:
当返回为 None 时,Scrapy 将继续处理该 Request,接着执行其他 Downloader Middleware 的 process_request 方法,一直到 Downloader 把 Request 执行完后得到 Response 才结束。这个过程其实就是修改 Request 的过程,不同的 Downloader Middleware 按照设置的优先级顺序依次对 Request 进行修改,最后送至 Downloader 执行。
当返回为 Response 对象时,更低优先级的 Downloader Middleware 的 process_request 和 process_exception 方法就不会被继续调用,每个 Downloader Middleware 的 process_response 方法转而被依次调用。调用完毕之后,直接将 Response 对象发送给 Spider 来处理。
当返回为 Request 对象时,更低优先级的 Downloader Middleware 的 process_request 方法会停止执行。这个 Request 会重新放到调度队列里,其实它就是一个全新的 Request,等待被调度。如果被 Scheduler 调度了,那么所有的 Downloader Middleware 的 process_request 方法都会被重新按照顺序执行。
如果 IgnoreRequest 异常抛出,则所有的 Downloader Middleware 的 process_exception 方法会依次执行。如果没有一个方法处理这个异常,那么 Request 的 errorback 方法就会回调。如果该异常还没有被处理,那么它便会被忽略。
这里我们注意到第二个选项,当返回结果为 Response 对象时,低优先级的 process_request 方法就不会被继续调用了,这个 Response 对象会直接经由 process_response 方法处理后转交给 Spider 来解析。
然后再接着想一想,process_request 接收的参数是 request,即 Request 对象,怎么会返回 Response 对象呢?原因可想而知了,这个 Request 对象不再经由 Scrapy 的 Downloader 来处理了,而是在 process_request 方法里面直接就完成了 Request 的发送操作,然后在得到了对应的 Response 结果后再将其返回就好了。
那么对于 JavaScript 渲染的页面来说,照这个方法来做,我们就可以把 Selenium 或 Pyppeteer 加载页面的过程在 process_request 方法里面实现,得到网页渲染完后的源代码后直接构造 Response 返回即可,这样我们就完成了借助 Downloader Middleware 实现 Scrapy 爬取动态渲染页面的过程。
本节我们就用实例来讲解一下 Scrapy 和 Pyppeteer 实现 JavaScript 渲染页面抓取的流程。
本节使用的实例网站为 https://dynamic5.scrape.center/,这是一个 JavaScript 渲染页面,其内容是一本本的图书信息。
同时这个网站的页面带有分页功能,只需要在 URL 加上 /page/
和页码就可以跳转到下一页,如 https://dynamic5.scrape.center/page/2 就是第二页内容,https://dynamic5.scrape.center/page/3 就是第三页内容。
那我们这个案例就来试着爬取前十页的图书信息吧。
首先我们来新建一个项目,叫作 scrapypyppeteer,命令如下:
scrapy startproject scrapypyppeteer
接着进入项目,然后新建一个 Spider,名称为 book,命令如下:
cd scrapypyppeteerscrapy genspider book dynamic5.scrape.center
这时候可以发现在项目的 spiders 文件夹下就出现了一个名为 spider.py 的文件,内容如下:
# -*- coding: utf-8 -*-import scrapyclass BookSpider(scrapy.Spider): name = 'book' allowed_domains = ['dynamic5.scrape.center'] start_urls = ['http://dynamic5.scrape.center/'] def parse(self, response): pass
首先我们构造列表页的初始请求,实现一个 start_requests 方法,如下所示:
# -*- coding: utf-8 -*-from scrapy import Request, Spiderclass BookSpider(Spider): name = 'book' allowed_domains = ['dynamic5.scrape.center'] base_url = 'https://dynamic5.scrape.center/page/{page}' max_page = 10 def start_requests(self): for page in range(1, self.max_page + 1): url = self.base_url.format(page=page) yield Request(url, callback=self.parse_index) def parse_index(self, response): print(response.text)
这时如果我们直接运行这个 Spider,在 parse_index 方法里面打印输出 Response 的内容,结果如下:
我们可以发现所得到的内容并不是页面渲染后的真正 HTML 代码。此时如果我们想要获取 HTML 渲染结果的话就得使用 Downloader Middleware 实现了。
这里我们直接以一个我已经写好的组件来演示了,组件的名称叫作 GerapyPyppeteer,组件里已经写好了 Scrapy 和 Pyppeteer 结合的中间件,下面我们来详细介绍下。
我们可以借助于 pip3 来安装组件,命令如下:
pip3 install gerapy-pyppeteer
GerapyPyppeteer 提供了两部分内容,一部分是 Downloader Middleware,一部分是 Request。
首先我们需要开启中间件,在 settings 里面开启 PyppeteerMiddleware,配置如下:
DOWNLOADER_MIDDLEWARES = { 'gerapy_pyppeteer.downloadermiddlewares.PyppeteerMiddleware': 543,}
然后我们把上文定义的 Request 修改为 PyppeteerRequest 即可:
# -*- coding: utf-8 -*-from gerapy_pyppeteer import PyppeteerRequestfrom scrapy import Request, Spiderclass BookSpider(Spider): name = 'book' allowed_domains = ['dynamic5.scrape.center'] base_url = 'https://dynamic5.scrape.center/page/{page}' max_page = 10 def start_requests(self): for page in range(1, self.max_page + 1): url = self.base_url.format(page=page) yield PyppeteerRequest(url, callback=self.parse_index, wait_for='.item .name') def parse_index(self, response): print(response.text)
这样其实就完成了 Pyppeteer 的对接了,非常简单。
这里 PyppeteerRequest 和原本的 Request 多提供了一个参数,就是 wait_for,通过这个参数我们可以指定 Pyppeteer 需要等待特定的内容加载出来才算结束,然后才返回对应的结果。
为了方便观察效果,我们把并发限制修改得小一点,然后把 Pyppeteer 的 Headless 模式设置为 False:
CONCURRENT_REQUESTS = 3GERAPY_PYPPETEER_HEADLESS = False
这时我们重新运行 Spider,就可以看到在爬取的过程中,Pyppeteer 对应的 Chromium 浏览器就弹出来了,并逐个加载对应的页面内容,加载完成之后浏览器关闭。
另外观察下控制台,我们发现对应的结果也就被提取出来了,如图所示:
这时候我们再重新修改下 parse_index 方法,提取对应的每本书的名称和作者即可:
def parse_index(self, response): for item in response.css('.item'): name = item.css('.name::text').extract_first() authors = item.css('.authors::text').extract_first() name = name.strip() if name else None authors = authors.strip() if authors else None yield { 'name': name, 'authors': authors }
重新运行,即可发现对应的名称和作者就被提取出来了,运行结果如下:
这样我们就借助 GerapyPyppeteer 完成了 JavaScript 渲染页面的爬取。
但上面仅仅是我们借助 GerapyPyppeteer 实现了 Scrapy 和 Pyppeteer 的对接,但其背后的原理是怎样的呢?
我们可以详细分析它的源码,其 GitHub 地址为 https://github.com/Gerapy/GerapyPyppeteer。
首先通过分析可以发现其最核心的内容就是实现了一个 PyppeteerMiddleware,这是一个 Downloader Middleware,这里最主要的就是 process_request 的实现,核心代码如下所示:
def process_request(self, request, spider): logger.debug('processing request %s', request) return as_deferred(self._process_request(request, spider))
这里其实就是调用了一个 _process_request 方法,这个方法的返回结果被 as_deferred 方法调用了。
这个 as_deferred 是怎么定义的呢?代码如下:
import asynciofrom twisted.internet.defer import Deferreddef as_deferred(f): return Deferred.fromFuture(asyncio.ensure_future(f))
这个方法接收的就是一个 asyncio 库的 Future 对象,然后通过 fromFuture 方法转化成了 twisted 里面的 Deferred 对象。这是因为 Scrapy 本身的异步是借助 twisted 实现的,一个个的异步任务对应的就是一个个 Deferred 对象,而 Pyppeteer 又是基于 asyncio 的,它的异步任务是 Future 对象,所以这里我们需要借助 Deferred 的 fromFuture 方法将 Future 转为 Deferred 对象。
另外为了支持这个功能,我们还需要在 Scrapy 中修改 reactor 对象,修改为 AsyncioSelectorReactor,实现如下:
import sysfrom twisted.internet.asyncioreactor import AsyncioSelectorReactorimport twisted.internetreactor = AsyncioSelectorReactor(asyncio.get_event_loop())# install AsyncioSelectorReactortwisted.internet.reactor = reactorsys.modules['twisted.internet.reactor'] = reactor
这段代码已经在 PyppeteerMiddleware 里面定义好了,在 Scrapy 正式开始爬取之前这段代码就会被执行,将 Scrapy 中的 reactor 修改为 AsyncioSelectorReactor,从而实现 Future 的调度。
接下来我们再来看下 _process_request 方法,实现如下:
async def _process_request(self, request: PyppeteerRequest, spider): """ use pyppeteer to process spider :param request: :param spider: :return: """ options = { 'headless': self.headless, 'dumpio': self.dumpio, 'devtools': self.devtools, 'args': [ f'--window-size={self.window_width},{self.window_height}', ] } if self.executable_path: options['executable_path'] = self.executable_path if self.disable_extensions: options['args'].append('--disable-extensions') if self.hide_scrollbars: options['args'].append('--hide-scrollbars') if self.mute_audio: options['args'].append('--mute-audio') if self.no_sandbox: options['args'].append('--no-sandbox') if self.disable_setuid_sandbox: options['args'].append('--disable-setuid-sandbox') if self.disable_gpu: options['args'].append('--disable-gpu') # set proxy proxy = request.proxy if not proxy: proxy = request.meta.get('proxy') if proxy: options['args'].append(f'--proxy-server={proxy}') logger.debug('set options %s', options) browser = await launch(options) page = await browser.newPage() await page.setViewport({'width': self.window_width, 'height': self.window_height}) # set cookies if isinstance(request.cookies, dict): await page.setCookie(*[ {'name': k, 'value': v} for k, v in request.cookies.items() ]) else: await page.setCookie(request.cookies) # the headers must be set using request interception await page.setRequestInterception(True) @page.on('request') async def _handle_interception(pu_request): # handle headers overrides = { 'headers': { k.decode(): ','.join(map(lambda v: v.decode(), v)) for k, v in request.headers.items() } } # handle resource types _ignore_resource_types = self.ignore_resource_types if request.ignore_resource_types is not None: _ignore_resource_types = request.ignore_resource_types if pu_request.resourceType in _ignore_resource_types: await pu_request.abort() else: await pu_request.continue_(overrides) timeout = self.download_timeout if request.timeout is not None: timeout = request.timeout logger.debug('crawling %s', request.url) response = None try: options = { 'timeout': 1000 * timeout, 'waitUntil': request.wait_until } logger.debug('request %s with options %s', request.url, options) response = await page.goto( request.url, options=options ) except (PageError, TimeoutError): logger.error('error rendering url %s using pyppeteer', request.url) await page.close() await browser.close() return self._retry(request, 504, spider) if request.wait_for: try: logger.debug('waiting for %s finished', request.wait_for) await page.waitFor(request.wait_for) except TimeoutError: logger.error('error waiting for %s of %s', request.wait_for, request.url) await page.close() await browser.close() return self._retry(request, 504, spider) # evaluate script if request.script: logger.debug('evaluating %s', request.script) await page.evaluate(request.script) # sleep if request.sleep is not None: logger.debug('sleep for %ss', request.sleep) await asyncio.sleep(request.sleep) content = await page.content() body = str.encode(content) # close page and browser logger.debug('close pyppeteer') await page.close() await browser.close() if not response: logger.error('get null response by pyppeteer of url %s', request.url) # Necessary to bypass the compression middleware (?) response.headers.pop('content-encoding', None) response.headers.pop('Content-Encoding', None) return HtmlResponse( page.url, status=response.status, headers=response.headers, body=body, encoding='utf-8', request=request )
代码内容比较多,我们慢慢来说。
首先最开始的部分是定义 Pyppeteer 的一些启动参数:
options = { 'headless': self.headless, 'dumpio': self.dumpio, 'devtools': self.devtools, 'args': [ f'--window-size={self.window_width},{self.window_height}', ]}if self.executable_path: options['executable_path'] = self.executable_pathif self.disable_extensions: options['args'].append('--disable-extensions')if self.hide_scrollbars: options['args'].append('--hide-scrollbars')if self.mute_audio: options['args'].append('--mute-audio')if self.no_sandbox: options['args'].append('--no-sandbox')if self.disable_setuid_sandbox: options['args'].append('--disable-setuid-sandbox')if self.disable_gpu: options['args'].append('--disable-gpu')
这些参数来自 from_crawler 里面读取项目 settings 的内容,如配置 Pyppeteer 对应浏览器的无头模式、窗口大小、是否隐藏滚动条、是否弃用沙箱,等等。
紧接着就是利用 options 来启动 Pyppeteer:
browser = await launch(options)page = await browser.newPage()await page.setViewport({'width': self.window_width, 'height': self.window_height})
这里启动了 Pyppeteer 对应的浏览器,将其赋值为 browser,然后新建了一个选项卡,赋值为 page,然后通过 setViewport 方法设定了窗口的宽高。
接下来就是对一些 Cookies 进行处理,如果 Request 带有 Cookies 的话会被赋值到 Pyppeteer 中:
# set cookiesif isinstance(request.cookies, dict): await page.setCookie(*[ {'name': k, 'value': v} for k, v in request.cookies.items() ])else: await page.setCookie(request.cookies)
再然后关键的步骤就是进行页面的加载了:
try: options = { 'timeout': 1000 * timeout, 'waitUntil': request.wait_until } logger.debug('request %s with options %s', request.url, options) response = await page.goto( request.url, options=options )except (PageError, TimeoutError): logger.error('error rendering url %s using pyppeteer', request.url) await page.close() await browser.close() return self._retry(request, 504, spider)
这里我们首先制定了加载超时时间 timeout 还有要等待完成的事件 waitUntil,接着调用 page 的 goto 方法访问对应的页面,同时进行了异常检测,如果发生错误就关闭浏览器并重新发起一次重试请求。
在页面加载出来之后,我们还需要判定我们期望的结果是不是加载出来了,所以这里又增加了 waitFor 的调用:
if request.wait_for: try: logger.debug('waiting for %s finished', request.wait_for) await page.waitFor(request.wait_for) except TimeoutError: logger.error('error waiting for %s of %s', request.wait_for, request.url) await page.close() await browser.close() return self._retry(request, 504, spider)
这里 request 有个 wait_for 属性,就可以定义想要加载的节点的选择器,如 .item .name
等,这样如果页面在规定时间内加载出来就会继续向下执行,否则就会触发 TimeoutError 并被捕获,关闭浏览器并重新发起一次重试请求。
等想要的结果加载出来之后,我们还可以执行一些自定义的 JavaScript 代码完成我们想要自定义的功能:
# evaluate scriptif request.script: logger.debug('evaluating %s', request.script) await page.evaluate(request.script)
最后关键的一步就是将当前页面的源代码打印出来,然后构造一个 HtmlResponse 返回即可:
content = await page.content()body = str.encode(content)# close page and browserlogger.debug('close pyppeteer')await page.close()await browser.close()if not response: logger.error('get null response by pyppeteer of url %s', request.url)# Necessary to bypass the compression middleware (?)response.headers.pop('content-encoding', None)response.headers.pop('Content-Encoding', None)return HtmlResponse( page.url, status=response.status, headers=response.headers, body=body, encoding='utf-8', request=request)
所以,如果代码可以执行到最后,返回到就是一个 Response 对象,这个 Resposne 对象的 body 就是 Pyppeteer 渲染页面后的结果,因此这个 Response 对象再传给 Spider 解析,就是 JavaScript 渲染后的页面结果了。
这样我们就通过 Downloader Middleware 通过对接 Pyppeteer 完成 JavaScript 动态渲染页面的抓取了。
我们在前面几节课了解了 Scrapy 爬虫框架的用法。但这些框架都是在同一台主机上运行的,爬取效率比较低。如果能够实现多台主机协同爬取,那么爬取效率必然会成倍增长,这就是分布式爬虫的优势。
接下来我们就来了解一下分布式爬虫的基本原理,以及 Scrapy 实现分布式爬虫的流程。
我们在前面已经实现了 Scrapy 基本的爬虫功能,虽然爬虫是异步加多线程的,但是我们却只能在一台主机上运行,所以爬取效率还是有限的,而分布式爬虫则是将多台主机组合起来,共同完成一个爬取任务,这将大大提高爬取的效率。
在了解分布式爬虫架构之前,首先回顾一下 Scrapy 的架构,如图所示。
Scrapy 单机爬虫中有一个本地爬取队列 Queue,这个队列是利用 deque 模块实现的。如果新的 Request 生成就会放到队列里面,随后 Request 被 Scheduler 调度。之后,Request 交给 Downloader 执行爬取,简单的调度架构如图所示。
如果两个 Scheduler 同时从队列里面获取 Request,每个 Scheduler 都会有其对应的 Downloader,那么在带宽足够、正常爬取且不考虑队列存取压力的情况下,爬取效率会有什么变化呢?没错,爬取效率会翻倍。
这样,Scheduler 可以扩展多个,Downloader 也可以扩展多个。而爬取队列 Queue 必须始终为一个,也就是所谓的共享爬取队列。这样才能保证 Scheduer 从队列里调度某个 Request 之后,其他 Scheduler 不会重复调度此 Request,就可以做到多个 Schduler 同步爬取。这就是分布式爬虫的基本雏形,简单调度架构如图所示。
我们需要做的就是在多台主机上同时运行爬虫任务协同爬取,而协同爬取的前提就是共享爬取队列。这样各台主机就不需要维护各自的爬取队列了,而是从共享爬取队列存取 Request。但是各台主机还有各自的 Scheduler 和 Downloader,所以调度和下载功能是分别完成的。如果不考虑队列存取性能消耗,爬取效率还是可以成倍提高的。
那么如何维护这个队列呢?我们首先需要考虑的就是性能问题,那什么数据库存取效率高呢?这时我们自然想到了基于内存存储的 Redis,而且 Redis 还支持多种数据结构,例如列表 List、集合 Set、有序集合 Sorted Set 等,存取的操作也非常简单,所以在这里我们采用 Redis 来维护爬取队列。
这几种数据结构存储实际各有千秋,分析如下:
列表数据结构有 lpush、lpop、rpush、rpop 方法,所以我们可以用它实现一个先进先出的爬取队列,也可以实现一个先进后出的栈式爬取队列。
集合的元素是无序且不重复的,这样我们就可以非常方便地实现一个随机排序的不重复的爬取队列。
有序集合带有分数表示,而 Scrapy 的 Request 也有优先级的控制,所以我们用有序集合就可以实现一个带优先级调度的队列。
这些不同的队列我们需要根据具体爬虫的需求灵活选择。
Scrapy 有自动去重功能,它的去重使用了 Python 中的集合。这个集合记录了 Scrapy 中每个 Request 的指纹,这个指纹实际上就是 Request 的散列值。我们可以看看 Scrapy 的源代码,如下所示:
import hashlibdef request_fingerprint(request, include_headers=None): if include_headers: include_headers = tuple(to_bytes(h.lower()) for h in sorted(include_headers)) cache = _fingerprint_cache.setdefault(request, {}) if include_headers not in cache: fp = hashlib.sha1() fp.update(to_bytes(request.method)) fp.update(to_bytes(canonicalize_url(request.url))) fp.update(request.body or b'') if include_headers: for hdr in include_headers: if hdr in request.headers: fp.update(hdr) for v in request.headers.getlist(hdr): fp.update(v) cache[include_headers] = fp.hexdigest() return cache[include_headers]
request_fingerprint 就是计算 Request 指纹的方法,其方法内部使用的是 hashlib 的 sha1 方法。计算的字段包括 Request 的 Method、URL、Body、Headers 这几部分内容,这里只要有一点不同,那么计算的结果就不同。计算得到的结果是加密后的字符串,也就是指纹。每个 Request 都有独有的指纹,指纹就是一个字符串,判定字符串是否重复比判定 Request 对象是否重复容易得多,所以指纹可以作为判定 Request 是否重复的依据。
那么我们如何判定是否重复呢?Scrapy 是这样实现的,如下所示:
def __init__(self): self.fingerprints = set() def request_seen(self, request): fp = self.request_fingerprint(request) if fp in self.fingerprints: return True self.fingerprints.add(fp)
在去重的类 RFPDupeFilter 中,有一个 request_seen 方法,这个方法有一个参数 request,它的作用就是检测该 Request 对象是否重复。这个方法调用 request_fingerprint 获取该 Request 的指纹,检测这个指纹是否存在于 fingerprints 变量中,而 fingerprints 是一个集合,集合的元素都是不重复的。如果指纹存在,那么就返回 True,说明该 Request 是重复的,否则将这个指纹加入集合中。如果下次还有相同的 Request 传递过来,指纹也是相同的,那么这时指纹就已经存在于集合中,Request 对象就会直接判定为重复。这样去重的目的就实现了。
Scrapy 的去重过程就是,利用集合元素的不重复特性来实现 Request 的去重。
对于分布式爬虫来说,我们肯定不能再使用每个爬虫各自的集合来去重了。因为这样还是每台主机单独维护自己的集合,不能做到共享。多台主机如果生成了相同的 Request,只能各自去重,各个主机之间就无法做到去重了。
那么要实现多台主机去重,这个指纹集合也需要是共享的,Redis 正好有集合的存储数据结构,我们可以利用 Redis 的集合作为指纹集合,那么这样去重集合也是共享的。每台主机新生成 Request 之后,会把该 Request 的指纹与集合比对,如果指纹已经存在,说明该 Request 是重复的,否则将 Request 的指纹加入这个集合中即可。利用同样的原理不同的存储结构我们也可以实现分布式 Reqeust 的去重。
在 Scrapy 中,爬虫运行时的 Request 队列放在内存中。爬虫运行中断后,这个队列的空间就被释放,此队列就被销毁了。所以一旦爬虫运行中断,爬虫再次运行就相当于全新的爬取过程。
要做到中断后继续爬取,我们可以将队列中的 Request 保存起来,下次爬取直接读取保存数据即可获取上次爬取的队列。我们在 Scrapy 中指定一个爬取队列的存储路径即可,这个路径使用 JOB_DIR 变量来标识,我们可以用如下命令来实现:
scrapy crawl spider -s JOBDIR=crawls/spider
更加详细的使用方法可以参见官方文档,链接为:https://doc.scrapy.org/en/latest/topics/jobs.html。
在 Scrapy 中,我们实际是把爬取队列保存到本地,第二次爬取直接读取并恢复队列即可。那么在分布式架构中我们还用担心这个问题吗?不需要。因为爬取队列本身就是用数据库保存的,如果爬虫中断了,数据库中的 Request 依然是存在的,下次启动就会接着上次中断的地方继续爬取。
所以,当 Redis 的队列为空时,爬虫会重新爬取;当 Redis 的队列不为空时,爬虫便会接着上次中断之处继续爬取。
我们接下来就需要在程序中实现这个架构了。首先需要实现一个共享的爬取队列,还要实现去重功能。另外,还需要重写一个 Scheduer 的实现,使之可以从共享的爬取队列存取 Request。
幸运的是,已经有人实现了这些逻辑和架构,并发布成了叫作 Scrapy-Redis 的 Python 包。
在下一节,我们便看看 Scrapy-Redis 的源码实现,以及它的详细工作原理。
在上节课我们提到过,Scrapy-Redis 库已经为我们提供了 Scrapy 分布式的队列、调度器、去重等功能,其 GitHub 地址为: https://github.com/rmax/scrapy-redis。
本节课我们深入掌握利用 Redis 实现 Scrapy 分布式的方法,并深入了解 Scrapy-Redis 的原理。
可以把源码克隆下来,执行如下命令:
git clone https://github.com/rmax/scrapy-redis.git
核心源码在 scrapy-redis/src/scrapy_redis 目录下。
我们从爬取队列入手,来看看它的具体实现。源码文件为 queue.py,它包含了三个队列的实现,首先它实现了一个父类 Base,提供一些基本方法和属性,如下所示:
class Base(object): """Per-spider base queue class""" def __init__(self, server, spider, key, serializer=None): if serializer is None: serializer = picklecompat if not hasattr(serializer, 'loads'): raise TypeError("serializer does not implement 'loads' function: % r" % serializer) if not hasattr(serializer, 'dumps'): raise TypeError("serializer '% s' does not implement 'dumps' function: % r" % serializer) self.server = server self.spider = spider self.key = key % {'spider': spider.name} self.serializer = serializer def _encode_request(self, request): obj = request_to_dict(request, self.spider) return self.serializer.dumps(obj) def _decode_request(self, encoded_request): obj = self.serializer.loads(encoded_request) return request_from_dict(obj, self.spider) def __len__(self): """Return the length of the queue""" raise NotImplementedError def push(self, request): """Push a request""" raise NotImplementedError def pop(self, timeout=0): """Pop a request""" raise NotImplementedError def clear(self): """Clear queue/stack""" self.server.delete(self.key)
首先看一下 _encode_request 和 _decode_request 方法,因为我们需要把一个 Request 对象存储到数据库中,但数据库无法直接存储对象,所以需要将 Request 序列转化成字符串再存储,而这两个方法分别是序列化和反序列化的操作,利用 pickle 库来实现,一般在调用 push 将 Request 存入数据库时会调用 _encode_request 方法进行序列化,在调用 pop 取出 Request 的时候会调用 _decode_request 进行反序列化。
在父类中 __len__、push 和 pop 方法都是未实现的,会直接抛出 NotImplementedError,因此是不能直接使用这个类的,必须实现一个子类来重写这三个方法,而不同的子类就会有不同的实现,也就有着不同的功能。
接下来我们就需要定义一些子类来继承 Base 类,并重写这几个方法,那在源码中就有三个子类的实现,它们分别是 FifoQueue、PriorityQueue、LifoQueue,我们分别来看下它们的实现原理。
首先是 FifoQueue:
class FifoQueue(Base): """Per-spider FIFO queue""" def __len__(self): """Return the length of the queue""" return self.server.llen(self.key) def push(self, request): """Push a request""" self.server.lpush(self.key, self._encode_request(request)) def pop(self, timeout=0): """Pop a request""" if timeout > 0: data = self.server.brpop(self.key, timeout) if isinstance(data, tuple): data = data[1] else: data = self.server.rpop(self.key) if data: return self._decode_request(data)
可以看到这个类继承了 Base 类,并重写了 __len__、push、pop 这三个方法,在这三个方法中都是对 server 对象的操作,而 server 对象就是一个 Redis 连接对象,我们可以直接调用其操作 Redis 的方法对数据库进行操作,可以看到这里的操作方法有 llen、lpush、rpop 等,这就代表此爬取队列是使用的 Redis 的列表,序列化后的 Request 会被存入列表中,就是列表的其中一个元素,__len__ 方法是获取列表的长度,push 方法中调用了 lpush 操作,这代表从列表左侧存入数据,pop 方法中调用了 rpop 操作,这代表从列表右侧取出数据。
所以 Request 在列表中的存取顺序是左侧进、右侧出,所以这是有序的进出,即先进先出,英文叫作 First Input First Output,也被简称为 FIFO,而此类的名称就叫作 FifoQueue。
另外还有一个与之相反的实现类,叫作 LifoQueue,实现如下:
class LifoQueue(Base): """Per-spider LIFO queue.""" def __len__(self): """Return the length of the stack""" return self.server.llen(self.key) def push(self, request): """Push a request""" self.server.lpush(self.key, self._encode_request(request)) def pop(self, timeout=0): """Pop a request""" if timeout > 0: data = self.server.blpop(self.key, timeout) if isinstance(data, tuple): data = data[1] else: data = self.server.lpop(self.key) if data: return self._decode_request(data)
与 FifoQueue 不同的就是它的 pop 方法,在这里使用的是 lpop 操作,也就是从左侧出,而 push 方法依然是使用的 lpush 操作,是从左侧入。那么这样达到的效果就是先进后出、后进先出,英文叫作 Last In First Out,简称为 LIFO,而此类名称就叫作 LifoQueue。同时这个存取方式类似栈的操作,所以其实也可以称作 StackQueue。
另外在源码中还有一个子类实现,叫作 PriorityQueue,顾名思义,它叫作优先级队列,实现如下:
class PriorityQueue(Base): """Per-spider priority queue abstraction using redis' sorted set""" def __len__(self): """Return the length of the queue""" return self.server.zcard(self.key) def push(self, request): """Push a request""" data = self._encode_request(request) score = -request.priority self.server.execute_command('ZADD', self.key, score, data) def pop(self, timeout=0): """ Pop a request timeout not support in this queue class """ pipe = self.server.pipeline() pipe.multi() pipe.zrange(self.key, 0, 0).zremrangebyrank(self.key, 0, 0) results, count = pipe.execute() if results: return self._decode_request(results[0])
在这里我们可以看到 __len__、push、pop 方法中使用了 server 对象的 zcard、zadd、zrange 操作,可以知道这里使用的存储结果是有序集合 Sorted Set,在这个集合中每个元素都可以设置一个分数,那么这个分数就代表优先级。
在 __len__ 方法里调用了 zcard 操作,返回的就是有序集合的大小,也就是爬取队列的长度,在 push 方法中调用了 zadd 操作,就是向集合中添加元素,这里的分数指定成 Request 的优先级的相反数,因为分数低的会排在集合的前面,所以这里高优先级的 Request 就会存在集合的最前面。pop 方法是首先调用了 zrange 操作取出了集合的第一个元素,因为最高优先级的 Request 会存在集合最前面,所以第一个元素就是最高优先级的 Request,然后再调用 zremrangebyrank 操作将这个元素删除,这样就完成了取出并删除的操作。
此队列是默认使用的队列,也就是爬取队列默认是使用有序集合来存储的。
前面说过 Scrapy 的去重是利用集合来实现的,而在 Scrapy 分布式中的去重就需要利用共享的集合,那么这里使用的就是 Redis 中的集合数据结构。我们来看看去重类是怎样实现的,源码文件是 dupefilter.py,其内实现了一个 RFPDupeFilter 类,如下所示:
class RFPDupeFilter(BaseDupeFilter): """Redis-based request duplicates filter. This class can also be used with default Scrapy's scheduler. """ logger = logger def __init__(self, server, key, debug=False): """Initialize the duplicates filter. Parameters ---------- server : redis.StrictRedis The redis server instance. key : str Redis key Where to store fingerprints. debug : bool, optional Whether to log filtered requests. """ self.server = server self.key = key self.debug = debug self.logdupes = True @classmethod def from_settings(cls, settings): """Returns an instance from given settings. This uses by default the key ``dupefilter:<timestamp>``. When using the ``scrapy_redis.scheduler.Scheduler`` class, this method is not used as it needs to pass the spider name in the key. Parameters ---------- settings : scrapy.settings.Settings Returns ------- RFPDupeFilter A RFPDupeFilter instance. """ server = get_redis_from_settings(settings) key = defaults.DUPEFILTER_KEY % {'timestamp': int(time.time())} debug = settings.getbool('DUPEFILTER_DEBUG') return cls(server, key=key, debug=debug) @classmethod def from_crawler(cls, crawler): """Returns instance from crawler. Parameters ---------- crawler : scrapy.crawler.Crawler Returns ------- RFPDupeFilter Instance of RFPDupeFilter. """ return cls.from_settings(crawler.settings) def request_seen(self, request): """Returns True if request was already seen. Parameters ---------- request : scrapy.http.Request Returns ------- bool """ fp = self.request_fingerprint(request) added = self.server.sadd(self.key, fp) return added == 0 def request_fingerprint(self, request): """Returns a fingerprint for a given request. Parameters ---------- request : scrapy.http.Request Returns ------- str """ return request_fingerprint(request) def close(self, reason=''): """Delete data on close. Called by Scrapy's scheduler. Parameters ---------- reason : str, optional """ self.clear() def clear(self): """Clears fingerprints data.""" self.server.delete(self.key) def log(self, request, spider): """Logs given request. Parameters ---------- request : scrapy.http.Request spider : scrapy.spiders.Spider """ if self.debug: msg = "Filtered duplicate request: %(request) s" self.logger.debug(msg, {'request': request}, extra={'spider': spider}) elif self.logdupes: msg = ("Filtered duplicate request %(request) s" "- no more duplicates will be shown" "(see DUPEFILTER_DEBUG to show all duplicates)") self.logger.debug(msg, {'request': request}, extra={'spider': spider}) self.logdupes = False
这里同样实现了一个 request_seen 方法,和 Scrapy 中的 request_seen 方法实现极其类似。不过这里集合使用的是 server 对象的 sadd 操作,也就是集合不再是一个简单数据结构了,而是直接换成了数据库的存储方式。
鉴别重复的方式还是使用指纹,指纹同样是依靠 request_fingerprint 方法来获取的。获取指纹之后就直接向集合添加指纹,如果添加成功,说明这个指纹原本不存在于集合中,返回值 1。代码中最后的返回结果是判定添加结果是否为 0,如果刚才的返回值为 1,那这个判定结果就是 False,也就是不重复,否则判定为重复。
这样我们就成功利用 Redis 的集合完成了指纹的记录和重复的验证。
Scrapy-Redis 还帮我们实现了配合 Queue、DupeFilter 使用的调度器 Scheduler,源文件名称是 scheduler.py。我们可以指定一些配置,如 SCHEDULER_FLUSH_ON_START 即是否在爬取开始的时候清空爬取队列,SCHEDULER_PERSIST 即是否在爬取结束后保持爬取队列不清除。我们可以在 settings.py 里自由配置,而此调度器很好地实现了对接。
接下来我们看看两个核心的存取方法,实现如下所示:
def enqueue_request(self, request): if not request.dont_filter and self.df.request_seen(request): self.df.log(request, self.spider) return False if self.stats: self.stats.inc_value('scheduler/enqueued/redis', spider=self.spider) self.queue.push(request) return True def next_request(self): block_pop_timeout = self.idle_before_close request = self.queue.pop(block_pop_timeout) if request and self.stats: self.stats.inc_value('scheduler/dequeued/redis', spider=self.spider) return request
enqueue_request 可以向队列中添加 Request,核心操作就是调用 Queue 的 push 操作,还有一些统计和日志操作。next_request 就是从队列中取 Request,核心操作就是调用 Queue 的 pop 操作,此时如果队列中还有 Request,则 Request 会直接取出来,爬取继续,否则如果队列为空,爬取则会重新开始。
那么到现在为止我们就把之前所说的三个分布式的问题解决了,总结如下:
爬取队列的实现,在这里提供了三种队列,使用了 Redis 的列表或有序集合来维护。
去重的实现,使用了 Redis 的集合来保存 Request 的指纹以提供重复过滤。
中断后重新爬取的实现,中断后 Redis 的队列没有清空,再次启动时调度器的 next_request 会从队列中取到下一个 Request,继续爬取。
以上内容便是 Scrapy-Redis 的核心源码解析。Scrapy-Redis 中还提供了 Spider、Item Pipeline 的实现,不过它们并不是必须使用的。
在下一节,我们会将 Scrapy-Redis 集成到之前所实现的 Scrapy 项目中,实现多台主机协同爬取。
在前面一节课我们了解了 Scrapy-Redis 的基本原理,本节课我们就结合之前的案例实现基于 Scrapy-Redis 的分布式爬虫吧。
本节案例我们基于第 46 讲 —— Scrapy 和 Pyppeteer 的动态渲染页面的抓取案例来进行学习,我们需要把它改写成基于 Redis 的分布式爬虫。
首先我们需要把代码下载下来,其 GitHub 地址为 https://github.com/Python3WebSpider/ScrapyPyppeteer,进入项目,试着运行代码确保可以顺利执行,运行效果如图所示:
其次,我们需要有一个 Redis 数据库,可以直接下载安装包并安装,也可以使用 Docker 启动,保证能正常连接和使用即可,比如我这里就在本地 localhost 启动了一个 Redis 数据库,运行在 6379 端口,密码为空。
另外我们还需要安装 Scrapy-Redis 包,安装命令如下:
pip3 install scrapy-redis
安装完毕之后确保其可以正常导入使用即可。
接下来我们只需要简单的几步操作就可以实现分布式爬虫的配置了。
在前面的课时中我们讲解了 Scheduler 的概念,它是用来处理 Request、Item 等对象的调度逻辑的,默认情况下,Request 的队列是在内存中的,为了实现分布式,我们需要将队列迁移到 Redis 中,这时候我们就需要修改 Scheduler,修改非常简单,只需要在 settings.py 里面添加如下代码即可:
SCHEDULER = "scrapy_redis.scheduler.Scheduler"
这里我们将 Scheduler 的类修改为 Scrapy-Redis 提供的 Scheduler 类,这样在我们运行爬虫时,Request 队列就会出现在 Redis 中了。
另外我们还需要修改下 Redis 的连接信息,这样 Scrapy 才能成功连接到 Redis 数据库,修改格式如下:
REDIS_URL = 'redis://[user:pass]@hostname:9001'
在这里我们需要根据如上的格式来修改,由于我的 Redis 是在本地运行的,所以在这里就不需要填写用户名密码了,直接设置为如下内容即可:
REDIS_URL = 'redis://localhost:6379'
既然 Request 队列迁移到了 Redis,那么相应的去重操作我们也需要迁移到 Redis 里面,前一节课我们讲解了 Dupefilter 的原理,这里我们就修改下去重类来实现基于 Redis 的去重:
DUPEFILTER_CLASS = "scrapy_redis.dupefilter.RFPDupeFilter"
一般来说开启了 Redis 分布式队列之后,我们不希望爬虫在关闭时将整个队列和去重信息全部删除,因为很有可能在某个情况下我们会手动关闭爬虫或者爬虫遭遇意外终止,为了解决这个问题,我们可以配置 Redis 队列的持久化,修改如下:
SCHEDULER_PERSIST = True
好了,到此为止我们就完成分布式爬虫的配置了。
上面我们完成的实际上并不是真正意义的分布式爬虫,因为 Redis 队列我们使用的是本地的 Redis,所以多个爬虫需要运行在本地才可以,如果想实现真正意义的分布式爬虫,可以使用远程 Redis,这样我们就能在多台主机运行爬虫连接此 Redis 从而实现真正意义上的分布式爬虫了。
不过没关系,我们可以在本地启动多个爬虫验证下爬取效果。我们在多个命令行窗口运行如下命令:
scrapy crawl book
第一个爬虫出现了如下运行效果:
这时候不要关闭此窗口,再打开另外一个窗口,运行同样的爬取命令:
scrapy crawl book
运行效果如下:
这时候我们可以观察到它从第 24 页开始爬取了,因为当前爬取队列存在第一个爬虫生成的爬取 Request,第二个爬虫启动时检测到有 Request 存在就直接读取已经存在的 Request,然后接着爬取了。
同样,我们可以启动第三个、第四个爬虫实现同样的爬取功能。这样,我们就基于 Scrapy-Redis 成功实现了基本的分布式爬虫功能。
上节课我们的分布式爬虫部署完成并可以成功运行了,但是有个环节非常烦琐,那就是代码部署。
我们设想下面的几个场景:
如果采用上传文件的方式部署代码,我们首先需要将代码压缩,然后采用 SFTP 或 FTP 的方式将文件上传到服务器,之后再连接服务器将文件解压,每个服务器都需要这样配置。
如果采用 Git 同步的方式部署代码,我们可以先把代码 Push 到某个 Git 仓库里,然后再远程连接各台主机执行 Pull 操作,同步代码,每个服务器同样需要做一次操作。
如果代码突然有更新,那我们必须更新每个服务器,而且万一哪台主机的版本没控制好,还可能会影响整体的分布式爬取状况。
所以我们需要一个更方便的工具来部署 Scrapy 项目,如果可以省去一遍遍逐个登录服务器部署的操作,那将会方便很多。
本节我们就来看看提供分布式部署的工具 Scrapyd。
接下来,我们就来深入地了解 Scrapyd,Scrapyd 是一个运行 Scrapy 爬虫的服务程序,它提供一系列 HTTP 接口来帮助我们部署、启动、停止、删除爬虫程序。Scrapyd 支持版本管理,同时还可以管理多个爬虫任务,利用它我们可以非常方便地完成 Scrapy 爬虫项目的部署任务调度。
首先我们需要安装 scrapyd,一般我们部署的服务器是 Linux,所以这里以 Linux 为例来进行说明。
这里推荐使用 pip 安装,命令如下:
pip3 install scrapyd
另外为了我们编写的项目能够运行成功,还需要安装项目本身的环境依赖,如上一节的项目需要依赖 Scrapy、Scrapy-Redis、Gerapy-Pyppeteer 等库,也需要在服务器上安装,否则会出现部署失败的问题。
安装完毕之后,需要新建一个配置文件 /etc/scrapyd/scrapyd.conf,Scrapyd 在运行的时候会读取此配置文件。
在 Scrapyd 1.2 版本之后,不会自动创建该文件,需要我们自行添加。首先,执行如下命令新建文件:
sudo mkdir /etc/scrapyd sudo vi /etc/scrapyd/scrapyd.conf
接着写入如下内容:
[scrapyd] eggs_dir = eggs logs_dir = logs items_dir = jobs_to_keep = 5 dbs_dir = dbs max_proc = 0 max_proc_per_cpu = 10 finished_to_keep = 100 poll_interval = 5.0 bind_address = 0.0.0.0 http_port = 6800 debug = off runner = scrapyd.runner application = scrapyd.app.application launcher = scrapyd.launcher.Launcher webroot = scrapyd.website.Root [services] schedule.json = scrapyd.webservice.Schedule cancel.json = scrapyd.webservice.Cancel addversion.json = scrapyd.webservice.AddVersion listprojects.json = scrapyd.webservice.ListProjects listversions.json = scrapyd.webservice.ListVersions listspiders.json = scrapyd.webservice.ListSpiders delproject.json = scrapyd.webservice.DeleteProject delversion.json = scrapyd.webservice.DeleteVersion listjobs.json = scrapyd.webservice.ListJobs daemonstatus.json = scrapyd.webservice.DaemonStatus
配置文件的内容可以参见官方文档 https://scrapyd.readthedocs.io/en/stable/config.html#example-configuration-file。这里的配置文件有所修改,其中之一是 max_proc_per_cpu 官方默认为 4,即一台主机每个 CPU 最多运行 4 个 Scrapy 任务,在此提高为 10。另外一个是 bind_address,默认为本地 127.0.0.1,在此修改为 0.0.0.0,以使外网可以访问。
Scrapyd 是一个纯 Python 项目,这里可以直接调用它来运行。为了使程序一直在后台运行,Linux 和 Mac 可以使用如下命令:
(scrapyd > /dev/null &)
这样 Scrapyd 就可以在后台持续运行了,控制台输出直接忽略。当然,如果想记录输出日志,可以修改输出目标,如下所示:
(scrapyd> ~/scrapyd.log &)
此时会将 Scrapyd 的运行结果输出到~/scrapyd.log 文件中。当然也可以使用 screen、tmux、supervisor 等工具来实现进程守护。
安装并运行了 Scrapyd 之后,我们就可以访问服务器的 6800 端口看到一个 WebUI 页面了,例如我的服务器地址为 120.27.34.25,在上面安装好了 Scrapyd 并成功运行,那么我就可以在本地的浏览器中打开: http://120.27.34.25:6800,就可以看到 Scrapyd 的首页,这里请自行替换成你的服务器地址查看即可,如图所示:
如果可以成功访问到此页面,那么证明 Scrapyd 配置就没有问题了。
Scrapyd 提供了一系列 HTTP 接口来实现各种操作,在这里我们可以将接口的功能梳理一下,以 Scrapyd 所在的 IP 为 120.27.34.25 为例进行讲解。
这个接口负责查看 Scrapyd 当前服务和任务的状态,我们可以用 curl 命令来请求这个接口,命令如下:
curl http://139.217.26.30:6800/daemonstatus.json
这样我们就会得到如下结果:
{"status": "ok", "finished": 90, "running": 9, "node_name": "datacrawl-vm", "pending": 0}
返回结果是 Json 字符串,status 是当前运行状态, finished 代表当前已经完成的 Scrapy 任务,running 代表正在运行的 Scrapy 任务,pending 代表等待被调度的 Scrapyd 任务,node_name 就是主机的名称。
这个接口主要是用来部署 Scrapy 项目,在部署的时候我们需要首先将项目打包成 Egg 文件,然后传入项目名称和部署版本。
我们可以用如下的方式实现项目部署:
curl http://120.27.34.25:6800/addversion.json -F project=wenbo -F version=first -F egg=@weibo.egg
在这里 -F 即代表添加一个参数,同时我们还需要将项目打包成 Egg 文件放到本地。
这样发出请求之后我们可以得到如下结果:
{"status": "ok", "spiders": 3}
这个结果表明部署成功,并且其中包含的 Spider 的数量为 3。此方法部署可能比较烦琐,在后面我会介绍更方便的工具来实现项目的部署。
这个接口负责调度已部署好的 Scrapy 项目运行。我们可以通过如下接口实现任务调度:
curl http://120.27.34.25:6800/schedule.json -d project=weibo -d spider=weibocn
在这里需要传入两个参数,project 即 Scrapy 项目名称,spider 即 Spider 名称。返回结果如下:
{"status": "ok", "jobid": "6487ec79947edab326d6db28a2d86511e8247444"}
status 代表 Scrapy 项目启动情况,jobid 代表当前正在运行的爬取任务代号。
这个接口可以用来取消某个爬取任务,如果这个任务是 pending 状态,那么它将会被移除,如果这个任务是 running 状态,那么它将会被终止。
我们可以用下面的命令来取消任务的运行:
curl http://120.27.34.25:6800/cancel.json -d project=weibo -d job=6487ec79947edab326d6db28a2d86511e8247444
在这里需要传入两个参数,project 即项目名称,job 即爬取任务代号。返回结果如下:
{"status": "ok", "prevstate": "running"}
status 代表请求执行情况,prevstate 代表之前的运行状态。
这个接口用来列出部署到 Scrapyd 服务上的所有项目描述。我们可以用下面的命令来获取 Scrapyd 服务器上的所有项目描述:
curl http://120.27.34.25:6800/listprojects.json
这里不需要传入任何参数。返回结果如下:
{"status": "ok", "projects": ["weibo", "zhihu"]}
status 代表请求执行情况,projects 是项目名称列表。
这个接口用来获取某个项目的所有版本号,版本号是按序排列的,最后一个条目是最新的版本号。
我们可以用如下命令来获取项目的版本号:
curl http://120.27.34.25:6800/listversions.json?project=weibo
在这里需要一个参数 project,就是项目的名称。返回结果如下:
{"status": "ok", "versions": ["v1", "v2"]}
status 代表请求执行情况,versions 是版本号列表。
这个接口用来获取某个项目最新的一个版本的所有 Spider 名称。我们可以用如下命令来获取项目的 Spider 名称:
curl http://120.27.34.25:6800/listspiders.json?project=weibo
在这里需要一个参数 project,就是项目的名称。返回结果如下:
{"status": "ok", "spiders": ["weibocn"]}
status 代表请求执行情况,spiders 是 Spider 名称列表。
这个接口用来获取某个项目当前运行的所有任务详情。我们可以用如下命令来获取所有任务详情:
curl http://120.27.34.25:6800/listjobs.json?project=weibo
在这里需要一个参数 project,就是项目的名称。返回结果如下:
{"status": "ok", "pending": [{"id": "78391cc0fcaf11e1b0090800272a6d06", "spider": "weibocn"}], "running": [{"id": "422e608f9f28cef127b3d5ef93fe9399", "spider": "weibocn", "start_time": "2017-07-12 10:14:03.594664"}], "finished": [{"id": "2f16646cfcaf11e1b0090800272a6d06", "spider": "weibocn", "start_time": "2017-07-12 10:14:03.594664", "end_time": "2017-07-12 10:24:03.594664"}]}
status 代表请求执行情况,pendings 代表当前正在等待的任务,running 代表当前正在运行的任务,finished 代表已经完成的任务。
这个接口用来删除项目的某个版本。我们可以用如下命令来删除项目版本:
curl http://120.27.34.25:6800/delversion.json -d project=weibo -d version=v1
在这里需要一个参数 project,就是项目的名称,还需要一个参数 version,就是项目的版本。返回结果如下:
{"status": "ok"}
status 代表请求执行情况,这样就代表删除成功了。
这个接口用来删除某个项目。我们可以用如下命令来删除某个项目:
curl http://120.27.34.25:6800/delproject.json -d project=weibo
在这里需要一个参数 project,就是项目的名称。返回结果如下:
{"status": "ok"}
status 代表请求执行情况,这样就代表删除成功了。
以上就是 Scrapyd 所有的接口,我们可以直接请求 HTTP 接口即可控制项目的部署、启动、运行等操作。
以上的这些接口可能使用起来还不是很方便,没关系,还有一个 ScrapydAPI 库对这些接口又做了一层封装,其安装方式如下:
pip3 install python-scrapyd-api
下面我们来看下 ScrapydAPI 的使用方法,其实核心原理和 HTTP 接口请求方式并无二致,只不过用 Python 封装后使用更加便捷。
我们可以用如下方式建立一个 ScrapydAPI 对象:
from scrapyd_api import ScrapydAPI scrapyd = ScrapydAPI('http://120.27.34.25:6800')
然后就可以通过调用它的方法来实现对应接口的操作了,例如部署的操作可以使用如下方式:
egg = open('weibo.egg', 'rb') scrapyd.add_version('weibo', 'v1', egg)
这样我们就可以将项目打包为 Egg 文件,然后把本地打包的 Egg 项目部署到远程 Scrapyd 了。
另外 ScrapydAPI 还实现了所有 Scrapyd 提供的 API 接口,名称都是相同的,参数也是相同的。
例如我们可以调用 list_projects 方法即可列出 Scrapyd 中所有已部署的项目:
scrapyd.list_projects() ['weibo', 'zhihu']
另外还有其他的方法在此不再一一列举了,名称和参数都是相同的,更加详细的操作可以参考其官方文档: http://python-scrapyd-api.readthedocs.io/。
我们可以通过它来部署项目,并通过 HTTP 接口来控制任务的运行,不过这里有一个不方便的地方就是部署过程,首先它需要打包 Egg 文件然后再上传,还是比较烦琐的,这里再介绍另外一个工具 Scrapyd-Client。
Scrapyd-Client 为了方便 Scrapy 项目的部署,提供两个功能:
将项目打包成 Egg 文件。
将打包生成的 Egg 文件通过 addversion.json 接口部署到 Scrapyd 上。
也就是说,Scrapyd-Client 帮我们把部署全部实现了,我们不需要再去关心 Egg 文件是怎样生成的,也不需要再去读 Egg 文件并请求接口上传了,这一切的操作只需要执行一个命令即可一键部署。
要部署 Scrapy 项目,我们首先需要修改一下项目的配置文件,例如我们之前写的 Scrapy 项目,在项目的第一层会有一个 scrapy.cfg 文件,它的内容如下:
[settings] default = scrapypyppeteer.settings [deploy] #url = http://localhost:6800/ project = scrapypyppeteer
在这里我们需要配置 deploy,例如我们要将项目部署到 120.27.34.25 的 Scrapyd 上,就需要修改为如下内容:
[deploy] url = http://120.27.34.25:6800/ project = scrapypyppeteer
这样我们再在 scrapy.cfg 文件所在路径执行如下命令:
scrapyd-deploy
运行结果如下:
Packing version 1501682277 Deploying to project "weibo" in http://120.27.34.25:6800/addversion.json Server response (200): {"status": "ok", "spiders": 1, "node_name": "datacrawl-vm", "project": "scrapypyppeteer", "version": "1501682277"}
返回这样的结果就代表部署成功了。
我们也可以指定项目版本,如果不指定的话默认为当前时间戳,指定的话通过 version 参数传递即可,例如:
scrapyd-deploy --version 201707131455
值得注意的是在 Python3 的 Scrapyd 1.2.0 版本中我们不要指定版本号为带字母的字符串,需要为纯数字,否则可能会出现报错。
另外如果我们有多台主机,我们可以配置各台主机的别名,例如可以修改配置文件为:
[deploy:vm1] url = http://120.27.34.24:6800/ project = scrapypyppeteer [deploy:vm2] url = http://139.217.26.30:6800/ project = scrapypyppeteer
有多台主机的话就在此统一配置,一台主机对应一组配置,在 deploy 后面加上主机的别名即可,这样如果我们想将项目部署到 IP 为 139.217.26.30 的 vm2 主机,我们只需要执行如下命令:
scrapyd-deploy vm2
这样我们就可以将项目部署到名称为 vm2 的主机上了。
如此一来,如果我们有多台主机,我们只需要在 scrapy.cfg 文件中配置好各台主机的 Scrapyd 地址,然后调用 scrapyd-deploy 命令加主机名称即可实现部署,非常方便。
如果 Scrapyd 设置了访问限制的话,我们可以在配置文件中加入用户名和密码的配置,同时修改端口,修改成 Nginx 代理端口,如在模块一我们使用的是 6801,那么这里就需要改成 6801,修改如下:
[deploy:vm1] url = http://120.27.34.24:6801/ project = scrapypyppeteer username = admin password = admin [deploy:vm2] url = http://139.217.26.30:6801/ project = scrapypyppeteer username = germey password = germey
这样通过加入 username 和 password 字段,我们就可以在部署时自动进行 Auth 验证,然后成功实现部署。
以上我们介绍了 Scrapyd、Scrapyd-API、Scrapyd-Client 的部署方式,希望你可以多多尝试。
上一节课我们学习了 Scrapy 和 Scrapyd 的用法,虽然它们可以解决项目部署的一些问题,但其实这种方案并没有真正彻底解决环境配置的问题。
比如使用 Scrapyd 时我们依然需要安装对应的依赖库,即使这样仍免不了还是会出现环境冲突和不一致的问题。因此,本节课我会再介绍另一种部署方案 —— Docker。
Docker 可以提供操作系统级别的虚拟环境,一个 Docker 镜像一般都会包含一个完整的操作系统,而这些系统内也有已经配置好的开发环境,如 Python 3.6 环境等。
我们可以直接使用此 Docker 的 Python 3 镜像运行一个容器,将项目直接放到容器里运行,就不用再额外配置 Python 3 环境了,这样就解决了环境配置的问题。
我们也可以进一步将 Scrapy 项目制作成一个新的 Docker 镜像,镜像里只包含适用于本项目的 Python 环境。如果要部署到其他平台,只需要下载该镜像并运行就好了,因为 Docker 运行时采用虚拟环境,和宿主机是完全隔离的,所以也不需要担心环境冲突问题。
如果我们能够把 Scrapy 项目制作成一个 Docker 镜像,只要其他主机安装了 Docker,那么只要将镜像下载并运行即可,而不必再担心环境配置问题或版本冲突问题。
因此,利用 Docker 我们就能很好地解决环境配置、环境冲突等问题。接下来,我们就尝试把一个 Scrapy 项目制作成一个 Docker 镜像。
我们要实现把前文 Scrapy 的入门项目打包成一个 Docker 镜像的过程。项目爬取的网址为: http://quotes.toscrape.com/,本模块 Scrapy 入门一节已经实现了 Scrapy 对此站点的爬取过程,项目代码为: https://github.com/Python3WebSpider/ScrapyTutorial,如果本地不存在的话可以 Clone 下来。
请确保已经安装好 Docker 并可以正常运行,如果没有安装可以参考 https://cuiqingcai.com/5438.html。
首先在项目的根目录下新建一个 requirements.txt 文件,将整个项目依赖的 Python 环境包都列出来,如下所示:
scrapy pymongo
如果库需要特定的版本,我们还可以指定版本号,如下所示:
scrapy>=1.4.0 pymongo>=3.4.0
在项目根目录下新建一个 Dockerfile 文件,文件不加任何后缀名,修改内容如下所示:
FROM python:3.7 ENV PATH /usr/local/bin:$PATH ADD . /code WORKDIR /code RUN pip3 install -r requirements.txt CMD scrapy crawl quotes
第一行的 FROM 代表使用的 Docker 基础镜像,在这里我们直接使用 python:3.7 的镜像,在此基础上运行 Scrapy 项目。
第二行 ENV 是环境变量设置,将 /usr/local/bin:$PATH 赋值给 PATH,即增加 /usr/local/bin 这个环境的变量路径。
第三行 ADD 是将本地的代码放置到虚拟容器中。它有两个参数:第一个参数是“.”,代表本地当前路径;第二个参数是 /code,代表虚拟容器中的路径,也就是将本地项目所有内容放置到虚拟容器的 /code 目录下,以便于在虚拟容器中运行代码。
第四行 WORKDIR 是指定工作目录,这里将刚才添加的代码路径设置成工作路径。在这个路径下的目录结构和当前本地目录结构是相同的,所以我们可以直接执行库安装命令、爬虫运行命令等。
第五行 RUN 是执行某些命令来做一些环境准备工作。由于 Docker 虚拟容器内只有 Python 3 环境,而没有所需要的 Python 库,所以我们运行此命令在虚拟容器中安装相应的 Python 库如 Scrapy,这样就可以在虚拟容器中执行 Scrapy 命令了。
第六行 CMD 是容器启动命令。在容器运行时,此命令会被执行。在这里我们直接用 scrapy crawl quotes 来启动爬虫。
接下来我们需要修改 MongoDB 的连接信息。如果我们继续用 localhost 是无法找到 MongoDB 的,因为在 Docker 虚拟容器里 localhost 实际指向容器本身的运行 IP,而容器内部并没有安装 MongoDB,所以爬虫无法连接 MongoDB。
这里的 MongoDB 地址可以有如下两种选择。
如果只想在本机测试,我们可以将地址修改为宿主机的 IP,也就是容器外部的本机 IP,一般是一个局域网 IP,使用 ifconfig 命令即可查看。
如果要部署到远程主机运行,一般 MongoDB 都是可公网访问的地址,修改为此地址即可。
但为了保证灵活性,我们可以将这个连接字符串通过环境变量传递进来,比如修改为:
import os MONGO_URI = os.getenv('MONGO_URI') MONGO_DB = os.getenv('MONGO_DB', 'tutorial')
这样项目的配置就完成了。
接下来,我们便可以构建镜像了,执行如下命令:
docker build -t quotes:latest .
这样输出就说明镜像构建成功。这时我们查看一下构建的镜像,如下所示:
Sending build context to Docker daemon 191.5 kB Step 1/6 : FROM python:3.7 ---> 968120d8cbe8 Step 2/6 : ENV PATH /usr/local/bin:$PATH ---> Using cache ---> 387abbba1189 Step 3/6 : ADD . /code ---> a844ee0db9c6 Removing intermediate container 4dc41779c573 Step 4/6 : WORKDIR /code ---> 619b2c064ae9 Removing intermediate container bcd7cd7f7337 Step 5/6 : RUN pip3 install -r requirements.txt ---> Running in 9452c83a12c5 ... Removing intermediate container 9452c83a12c5 Step 6/6 : CMD scrapy crawl quotes ---> Running in c092b5557ab8 ---> c8101aca6e2a Removing intermediate container c092b5557ab8 Successfully built c8101aca6e2a
出现类似输出就证明镜像构建成功了,这时执行,比如我们查看一下构建的镜像:
docker images
返回结果中其中有一行就是:
quotes latest 41c8499ce210 2 minutes ago 769 MB
这就是我们新构建的镜像。
我们可以先在本地测试运行,这时候我们需要指定 MongoDB 的连接信息,比如我在宿主机上启动了一个 MongoDB,找到当前宿主机的 IP 为 192.168.3.47,那么这里我就可以指定 MONGO_URI 并启动 Docker 镜像:
docker run -e MONGO_URI=192.168.3.47 quotes
当然我们还可以指定 MONGO_URI 为远程 MongoDB 连接字符串。
另外我们也可以利用 Docker-Compose 来启动,与此同时顺便也可以使用 Docker 来新建一个 MongoDB。可以在项目目录下新建 docker-compose.yaml 文件,如下所示:
version: '3' services: crawler: build: . image: quotes depends_on: - mongo environment: MONGO_URI: mongo:7017 mongo: image: mongo ports: - 7017:27017
这里我们使用 Docker-Compose 配置了两个容器,二者需要配合启动。
首先是 crawler 这个容器,其 build 路径是当前路径,image 代表 build 生成的镜像名称,这里取名为 quotes,depends_on 代表容器的启动依赖顺序,这里依赖于 mongo 这个容器,environment 这里就是指定容器运行时的环境变量,这里指定为 mongo:7017
。
另外一个容器就是刚才的 crawler 这个容器所依赖的 MongoDB 数据库了,在这里我们直接指定了镜像为 mongo,运行端口配置为 7017:27017
,这代表容器内的 MongoDB 运行在 27017 端口上,这个端口会映射到宿主机的 7017 端口上,所以我们在宿主机通过 7017 端口就能连接到这个 MongoDB 数据库。
好,这时候我们运行一下:
docker-compose up
然后 Docker 会构建镜像并运行,运行结果如下:
Starting scrapytutorial_mongo_1 ... done Recreating scrapytutorial_crawler_1 ... done Attaching to scrapytutorial_mongo_1, scrapytutorial_crawler_1 mongo_1 | {"t":{"$date":"2020-08-06T16:18:05.310+00:00"},"s":"I", "c":"CONTROL", "id":23285, "ctx":"main","msg":"Automatically disabling TLS 1.0, to force-enable TLS 1.0 specify --sslDisabledProtocols 'none'"} mongo_1 | {"t":{"$date":"2020-08-06T16:18:05.312+00:00"},"s":"W", "c":"ASIO", "id":22601, "ctx":"main","msg":"No TransportLayer configured during NetworkInterface startup"} mongo_1 | {"t":{"$date":"2020-08-06T16:18:05.312+00:00"},"s":"I", "c":"NETWORK", "id":4648601, "ctx":"main","msg":"Implicit TCP FastOpen unavailable. If TCP FastOpen is required, set tcpFastOpenServer, tcpFastOpenClient, and tcpFastOpenQueueSize."} ... crawler_1 | 2020-08-06 16:18:06 [scrapy.utils.log] INFO: Scrapy 2.3.0 started (bot: tutorial) crawler_1 | 2020-08-06 16:18:06 [scrapy.utils.log] INFO: Versions: lxml 4.5.2.0, libxml2 2.9.10, cssselect 1.1.0, parsel 1.6.0, w3lib 1.22.0, Twisted 20.3.0, Python 3.7.8 (default, Jun 30 2020, 18:27:23) - [GCC 8.3.0], pyOpenSSL 19.1.0 (OpenSSL 1.1.1g 21 Apr 2020), cryptography 3.0, Platform Linux-4.19.76-linuxkit-x86_64-with-debian-10.4 crawler_1 | 2020-08-06 16:18:06 [scrapy.utils.log] DEBUG: Using reactor: twisted.internet.epollreactor.EPollReactor crawler_1 | 2020-08-06 16:18:06 [scrapy.crawler] INFO: Overridden settings: crawler_1 | {'BOT_NAME': 'tutorial',
这时候就发现爬虫已经正常运行了,同时我们在宿主机上连接 localhost:7017 这个 MongoDB 服务就能看到爬取的结果了:
这就是用 Docker-Compose 启动的方式,其启动更加便捷,参数可以配置到 Docker-Compose 文件中。
构建完成之后,我们可以将镜像 Push 到 Docker 镜像托管平台,如 Docker Hub 或者私有的 Docker Registry 等,这样我们就可以从远程服务器下拉镜像并运行了。
以 Docker Hub 为例,如果项目包含一些私有的连接信息(如数据库),我们最好将 Repository 设为私有或者直接放到私有的 Docker Registry 中。
首先在 https://hub.docker.com注册一个账号,新建一个 Repository,名为 quotes。比如,我的用户名为 germey,新建的 Repository 名为 quotes,那么此 Repository 的地址就可以用 germey/quotes 来表示,当然你也可以自行修改。
为新建的镜像打一个标签,命令如下所示:
docker tag quotes:latest germey/quotes:latest
推送镜像到 Docker Hub 即可,命令如下所示:
docker push germey/quotes
Docker Hub 中便会出现新推送的 Docker 镜像了,如图所示。
如果我们想在其他的主机上运行这个镜像,在主机上装好 Docker 后,可以直接执行如下命令:
docker run germey/quotes
这样就会自动下载镜像,然后启动容器运行,不需要配置 Python 环境,不需要关心版本冲突问题。
当然我们也可以使用 Docker-Compose 来构建镜像和推送镜像,这里我们只需要修改 docker-compose.yaml 文件即可:
version: '3' services: crawler: build: . image: germey/quotes depends_on: - mongo environment: MONGO_URI: mongo:7017 mongo: image: mongo ports: - 7017:27017
可以看到,这里我们就将 crawler 的 image 内容修改为了 germey/quotes
,接下来执行:
docker-compose build docker-compose push
就可以把镜像推送到 Docker Hub 了。
本课时,我们讲解了将 Scrapy 项目制作成 Docker 镜像并部署到远程服务器运行的过程。使用此种方式,我们在本节课开始时所列出的问题都可以迎刃而解了。
在上一节我们了解了如何制作一个 Scrapy 的 Docker 镜像,本节课我们来介绍下如何将镜像部署到 Kubernetes 上。
Kubernetes 是谷歌开发的,用于自动部署,扩展和管理容器化应用程序的开源系统,其稳定性高、扩展性好,功能十分强大。现在业界已经越来越多地采用 Kubernetes 来部署和管理各个项目,
如果你还不了解 Kubernetes,可以参考其官方文档来学习一下: https://kubernetes.io/。
如果我们需要将上一节的镜像部署到 Kubernetes 上,则首先需要我们有一个 Kubernetes 集群,同时需要能使用 kubectl 命令。
Kubernetes 集群可以自行配置,也可以使用各种云服务提供的集群,如阿里云、腾讯云、Azure 等,另外还可以使用 Minikube 等来快速搭建,当然也可以使用 Docker 本身提供的 Kubernetes 服务。
比如我这里就直接使用了 Docker Desktop 提供的 Kubernetes 服务,勾选 Enable 直接开启即可。
kubectl 是用来操作 Kubernetes 的命令行工具,可以参考 https://kubernetes.io/zh/docs/tasks/tools/install-kubectl/ 来安装。
如果以上都安装好了,可以运行下 kubectl 命令验证下能否正常获取节点信息:
kubectl get nodes
运行结果类似如下:
NAME STATUS ROLES AGE VERSION docker-desktop Ready master 75d v1.16.6-beta.0
要部署的话我们需要先创建一个命名空间 Namespace,这里直接使用 kubectl 命令创建即可,Namespace 的名称这里我们取名为 crawler。
创建命令如下:
kubectl create namespace crawler
运行结果如下:
namespace/crawler created
如果出现上述结果就说明命名空间创建成功了。接下来我们就需要把 Docker 镜像部署到这个 Namespace 下面了。
Kubernetes 里面的资源是用 yaml 文件来定义的,如果要部署一次性任务或者为我们提供服务可以使用 Deployment,更多详情可以参考 Kubernetes 对于 Deployment 的说明: https://kubernetes.io/docs/concepts/workloads/controllers/deployment/。
新建 deployment.yaml 文件如下:
apiVersion: apps/v1 kind: Deployment metadata: name: crawler-quotes namespace: crawler labels: app: crawler-quotes spec: replicas: 1 selector: matchLabels: app: crawler-quotes template: metadata: labels: app: crawler-quotes spec: containers: - name: crawler-quotes image: germey/quotes env: - name: MONGO_URI value: <mongo>
这里我们就可以按照 Deployment 的规范声明一个 yaml 文件了,指定 namespace 为 crawler,并指定 container 的 image 为我们已经 Push 到 Docker Hub 的镜像 germey/quotes,另外通过 env 指定了环境变量,注意这里需要将 <mongo>
替换成一个有效的 MongoDB 连接字符串,如一个远程 MongoDB 服务。
接下来我们只需要使用 kubectl 命令即可应用该部署:
kubectl apply -f deployment.yaml
运行完毕之后会提示类似如下结果:
deployment.apps/crawler-quotes created
这样就说明部署成功了。如果 MongoDB 服务能够正常连接的话,这个爬虫就会运行并将结果存储到 MongoDB 中。
另外我们还可以通过命令行或者 Kubernetes 的 Dashboard 查看部署任务的运行状态。
如果我们想爬虫定时运行的话,可以借助于 Kubernetes 提供的 cronjob 来将爬虫配置为定时任务,其运行模式就类似于 crontab 命令一样,详细用法可以参考: https://kubernetes.io/zh/docs/tasks/job/automated-tasks-with-cron-jobs/。
可以新建 cronjob.yaml,内容如下:
apiVersion: batch/v1beta1 kind: CronJob metadata: name: crawler-quotes namespace: crawler spec: schedule: "0 */1 * * *" jobTemplate: spec: template: spec: restartPolicy: OnFailure containers: - name: crawler-quotes image: germey/quotes env: - name: MONGO_URI value: <mongo>
注意到这里 kind 我们不再使用 Deployment,而是改成了 CronJob,代表定时任务。 spec.schedule
里面定义了 crontab 格式的定时任务配置,这里代表每小时运行一次。其他的配置基本一致,同样注意这里需要将 <mongo>
替换成一个有效的 MongoDB 连接字符串,如一个远程 MongoDB 服务。
接下来我们只需要使用 kubectl 命令即可应用该部署:
kubectl apply -f cronjob.yaml
运行完毕之后会提示类似如下结果:
cronjob.batch/crawler-quotes created
出现这样的结果这就说明部署成功了,这样这个爬虫就会每小时运行一次,并将数据存储到指定的 MongoDB 数据库中。
以上我们就简单介绍了下 Kubernetes 部署爬虫的基本操作,Kubernetes 非常复杂,需要学习的内容很多,我们这一节介绍的只是冰山一角,还有更多的内容等待你去探索。
如果你看到了本课时,那么恭喜你已经学完了本专栏课程的所有内容,爬虫的知识点很复杂,一路学过来相信你也经历了不少坎坷。
本节课我们对网络爬虫所要学习的内容做一次总结,这里面也是我个人认为爬虫工程师应该具备的一些技术栈,由于专栏篇幅有限,肯定不可能把所有的知识点都覆盖到,但基础知识都已经涵盖了,下面我会把网络爬虫的知识点进行总结和梳理,如果你想深入学习网络爬虫的话可以参考。
网络爬虫的学习关系到计算机网络、编程基础、前端开发、后端开发、App 开发与逆向、网络安全、数据库、运维、机器学习、数据分析等各个方向的内容,它像一张大网一样把现在一些主流的技术栈都连接在了一起。正因为涵盖的方向多,因此学习的东西也非常零散和杂乱。
一些最基本的网站,往往不带任何反爬措施。比如某个博客站点,我们要爬全站的话就顺着列表页爬到文章页,再把文章的时间、作者、正文等信息爬下来就可以了。
那代码怎么写呢?用 Python 的 requests 等库就够了,写一个基本的逻辑,顺带把一篇篇文章的源码获取下来,解析的话用 XPath、BeautifulSoup、PyQuery 或者正则表达式,或者粗暴的字符串匹配把想要的内容抠出来,再加个文本写入存下来就可以了。
代码也很简单,就只是几个方法的调用。逻辑也很简单,几个循环加存储。最后就能看到一篇篇文章被我们存到了自己的电脑里。当然如果你不太会写代码或者都懒得写,那么利用基本的可视化爬取工具,如某爪鱼、某裔采集器也能通过可视化点选的方式把数据爬下来。
如果存储方面稍微扩展一下的话,可以对接上 MySQL、MongoDB、Elasticsearch、Kafka 等来保存数据,实现持久化存储。以后查询或者操作会更方便。
反正,不管效率如何,一个完全没有反爬的网站用最基本的方式就可以搞定。到这里,你可以说自己会爬虫了吗?不,还差得远呢。
随着互联网的发展,前端技术也在不断变化,数据的加载方式也不再是单纯的服务端渲染了。现在你可以看到很多网站的数据可能都是通过接口的形式传输的,或者即使不是接口那也是一些 JSON 数据,然后经过 JavaScript 渲染得出来的。
这时候,你要再用 requests 来爬取就不行了,因为 requests 爬下来的源码是服务端渲染得到的,浏览器看到页面的和 requests 获取的结果是不一样的。真正的数据是经过 JavaScript 执行得出来的,数据来源可能是 Ajax,也可能是页面里的某些 Data,也可能是一些 ifame 页面等,不过大多数情况下可能是 Ajax 接口获取的。
所以很多情况下需要分析 Ajax,知道这些接口的调用方式之后再用程序来模拟。但是有些接口带着加密参数,比如 token、sign 等,又不好模拟,怎么办呢?
一种方法就是去分析网站的 JavaScript 逻辑,死抠里面的代码,研究这些参数是怎么构造的,找出思路之后再用爬虫模拟或重写就行了。如果你解析出来了,那么直接模拟的方式效率会高很多,这里面就需要一些 JavaScript 基础了,当然有些网站加密逻辑做得太厉害了,你可能花一个星期也解析不出来,最后只能放弃了。
那这样解不出来或者不想解了,该怎么办呢?这时候可以用一种简单粗暴的方法,也就是直接用模拟浏览器的方式来爬取,比如用 Puppeteer、Pyppeteer、Selenium、Splash 等,这样爬取到的源代码就是真正的网页代码,数据自然就好提取了,同时也就绕过分析 Ajax 和一些 JavaScript 逻辑的过程。这种方式就做到了可见即可爬,难度也不大,同时模拟了浏览器,也不太会有一些法律方面的问题。
但其实后面的这种方法也会遇到各种反爬的情况,现在很多网站都会去识别 webdriver,看到你是用的 Selenium 等工具,直接拒接或不返回数据,所以你碰到这种网站还得专门来解决这个问题。
上面的情况如果用单线程的爬虫来模拟是比较简单的,但是有个问题就是速度慢啊。
爬虫是 I/O 密集型的任务,所以可能大多数情况下都在等待网络的响应,如果网络响应速度慢,那就得一直等着。但这个空余的时间其实可以让 CPU 去做更多事情。那怎么办呢?多开一些线程吧。
所以这个时候我们就可以在某些场景下加上多进程、多线程,虽然说多线程有 GIL 锁,但对于爬虫来说其实影响没那么大,所以用上多进程、多线程都可以成倍地提高爬取速度,对应的库就有 threading、multiprocessing 等。
异步协程就更厉害了,用 aiohttp、gevent、tornado 等工具,基本上想开多少并发就开多少并发,但是还是得谨慎一些,别把目标网站搞挂了。
总之,用上这几个工具,爬取速度就提上来了。但速度提上来了不一定都是好事,反爬措施接着肯定就要来了,封你 IP、封你账号、弹验证码、返回假数据,所以有时候龟速爬似乎也是个解决办法?
多线程、多进程、协程都能加速,但终究还是单机的爬虫。要真正做到规模化,还得靠分布式爬虫来搞定。
分布式的核心是什么?资源共享。比如爬取队列共享、去重指纹共享,等等。
我们可以使用一些基础的队列或组件来实现分布式,比如 RabbitMQ、Celery、Kafka、Redis 等,但经过很多人的尝试,自己去实现一个分布式爬虫,性能和扩展性总会出现一些问题。不少企业内部其实也有自己开发的一套分布式爬虫,和业务更紧密,这种当然是最好了。
现在主流的 Python 分布式爬虫还是基于 Scrapy 的,对接 Scrapy-Redis、Scrapy-Redis-BloomFilter 或者使用 Scrapy-Cluster 等,它们都是基于 Redis 来共享爬取队列的,多多少少总会遇到一些内存的问题。所以一些人也考虑对接到其他的消息队列上面,比如 RabbitMQ、Kafka 等,可以解决一些问题,效率也不差。
总之,要提高爬取效率,分布式还是必须要掌握的。
爬虫时难免遇到反爬,验证码就是其中之一。要会反爬,那首先就要会解验证码。
现在你可以看到很多网站都会有各种各样的验证码,比如最简单的图形验证码,要是验证码的文字规则的话,OCR 检测或基本的模型库都能识别,你可以直接对接一个打码平台来解决,准确率还是可以的。
然而现在你可能都见不到什么图形验证码了,都是一些行为验证码,如某验、某盾等,国外也有很多,比如 reCaptcha 等。一些稍微简单一点的,比如滑动的,你可以想办法识别缺口,比如图像处理比对、深度学习识别都是可以的。
对于轨迹行为你可以自己写一个模拟正常人行为的,加入抖动等。有了轨迹之后如何模拟呢,如果你非常厉害,那么可以直接去分析验证码的 JavaScript 逻辑,把轨迹数据录入,就能得到里面的一些加密参数,直接将这些参数放到表单或接口里面就能直接用了。当然也可以用模拟浏览器的方式来拖动,也能通过一定的方式拿到加密参数,或者直接用模拟浏览器的方式把登录一起做了,拿着 Cookies 来爬也行。
当然拖动只是一种验证码,还有文字点选、逻辑推理等,要是真不想自己解决,可以找打码平台来解析出来再模拟,但毕竟是花钱的,一些高手就会选择自己训练深度学习相关的模型,收集数据、标注、训练,针对不同的业务训练不同的模型。这样有了核心技术,也不用再去花钱找打码平台了,再研究下验证码的逻辑模拟一下,加密参数就能解析出来了。不过有的验证码解析非常难,以至于我也搞不定。
当然有些验证码可能是请求过于频繁而弹出来的,这种如果换 IP 也能解决。
封 IP 也是一件令人头疼的事,行之有效的方法就是换代理了。代理有很多种,市面上免费的,收费的太多太多了。
首先可以把市面上免费的代理用起来,自己搭建一个代理池,收集现在全网所有的免费代理,然后加一个测试器一直不断测试,测试的网址可以改成你要爬的网址。这样测试通过的一般都能直接拿来爬取目标网站。我自己也搭建过一个代理池,现在对接了一些免费代理,定时爬、定时测,还写了个 API 来取,放在了 GitHub 上: https://github.com/Python3WebSpider/ProxyPool,打好了 Docker 镜像,提供了 Kubernetes 脚本,你可以直接拿来用。
付费代理也是一样,很多商家提供了代理提取接口,请求一下就能获取几十几百个代理,我们可以同样把它们接入到代理池里面。但这个代理服务也分各种套餐,什么开放代理、独享代理等的质量和被封的概率也是不一样的。
有的商家还利用隧道技术搭建了代理,这样代理的地址和端口我们是不知道的,代理池是由他们来维护的,比如某布云,这样用起来更省心一些,但是可控性就差一些。
还有更稳定的代理,比如拨号代理、蜂窝代理等,接入成本会高一些,但是一定程度上也能解决一些封 IP 的问题。
有些信息需要模拟登录才能爬取,如果爬得过快,目标网站直接把你的账号封禁了,就没办法了。比如爬公众号的,人家把你 WX 号封了,那就全完了。
一种解决方法就是放慢频率,控制节奏。还有一种方法就是看看别的终端,比如手机页、App 页、wap 页,看看有没有能绕过登录的方法。
另外比较好的方法,就是分流。如果你的号足够多,建一个池子,比如 Cookies 池、Token 池、Sign 池等,多个账号跑出来的 Cookies、Token 都放到这个池子里,用的时候随机从里面获取一个。如果你想保证爬取效率不变,那么 100 个账号相比 20 个账号,对于每个账号对应的 Cookies、Token 的取用频率就变成原来的了 1/5,那么被封的概率也就随之降低了。
上面说的是几种比较主流的反爬方式,当然还有非常多奇葩的反爬。比如返回假数据、返回图片化数据、返回乱序数据,等等,那都需要具体情况具体分析。
这些反爬也得小心点,之前见过一个反爬直接返回 rm -rf /
的也不是没有,你要是正好有个脚本模拟执行返回结果,后果自己想象。
说到重点了。随着前端技术的进步和网站反爬意识的增强,很多网站选择在前端上下功夫,那就是在前端对一些逻辑或代码进行加密或混淆。当然这不仅仅是为了保护前端的代码不被轻易盗取,更重要的是反爬。比如很多 Ajax 接口都会带着一些参数,比如 sign、token 等,这些前文也讲过了。这种数据我们可以用前文所说的 Selenium 等方式来爬取,但总归来说效率太低了,毕竟它模拟的是网页渲染的整个过程,而真实的数据可能仅仅就藏在一个小接口里。
如果我们能够找出一些接口参数的真正逻辑,用代码来模拟执行,那效率就会有成倍的提升,而且还能在一定程度上规避上述的反爬现象。但问题是什么?比较难实现啊。
Webpack 是一方面,前端代码都被压缩和转码成一些 bundle 文件,一些变量的含义已经丢失,不好还原。然后一些网站再加上一些 obfuscator 的机制,把前端代码变成你完全看不懂的东西,比如字符串拆散打乱、变量十六进制化、控制流扁平化、无限 debug、控制台禁用等,前端的代码和逻辑已经面目全非。有的用 WebAssembly 等技术把前端核心逻辑直接编译,那就只能慢慢抠了,虽然说有些有一定的技巧,但是总归来说还是会花费很多时间。但一旦解析出来了,那就万事大吉了。
很多公司招聘爬虫工程师都会问有没有 JavaScript 逆向基础,破解过哪些网站,比如某宝、某多、某条等,解出来某个他们需要的可能就直接录用你。每家网站的逻辑都不一样,难度也不一样。
当然爬虫不仅仅是网页爬虫了,随着互联网时代的发展,现在越来越多的公司都选择将数据放到 App 上,甚至有些公司只有 App 没有网站。所以数据只能通过 App 来爬。
怎么爬呢?基本的就是抓包工具了,Charles、Fiddler 等抓到接口之后,直接拿来模拟就行了。
如果接口有加密参数怎么办呢?一种方法你可以边爬边处理,比如 mitmproxy 直接监听接口数据。另一方面你可以走 Hook,比如上 Xposed 也可以拿到。
那爬的时候又怎么实现自动化呢?总不能拿手来戳吧。其实工具也多,安卓原生的 adb 工具也行,Appium 现在已经是比较主流的方案了,当然还有其他的某精灵都是可以实现的。
最后,有的时候可能真的就不想走自动化的流程,我就想把里面的一些接口逻辑抠出来,那就需要搞逆向了,IDA Pro、jdax、FRIDA 等工具就派上用场了,当然这个过程和 JavaScript 逆向一样很痛苦,甚至可能得读汇编指令。
上面的这些知识,都掌握了以后,恭喜你已经超过了百分之八九十的爬虫玩家了,当然专门搞 JavaScript 逆向、App 逆向的都是站在食物链顶端的人,这种严格来说已经不算爬虫范畴了。
除了上面的技能,在一些场合下,我们可能还需要结合一些机器学习的技术,让我们的爬虫变得更智能起来。
比如现在很多博客、新闻文章,其页面结构相似度比较高,要提取的信息也比较类似。
比如如何区分一个页面是索引页还是详情页?如何提取详情页的文章链接?如何解析文章页的页面内容?这些其实都是可以通过一些算法来计算出来的。
所以,一些智能解析技术也应运而生,比如提取详情页,我的一位朋友写的 GeneralNewsExtractor 表现就非常好。
假如说有一个需求,需要爬取一万个新闻网站数据,要一个个写 XPath 吗?如果有了智能化解析技术,在容忍一定错误的条件下,完成这个就是分分钟的事情。
总之,如果我们能把这一块也学会了,我们的爬虫技术就会如虎添翼。
这块内容也是一个重头戏。爬虫和运维也是息息相关的。比如:
写完一个爬虫,怎样去快速部署到 100 台主机上运行起来。
怎么灵活地监控每个爬虫的运行状态。
爬虫有处代码改动,如何去快速更新。
怎样监控一些爬虫的占用内存、消耗的 CPU 状况。
怎样科学地控制爬虫的定时运行。
爬虫出现了问题,怎样能及时收到通知,怎样设置科学的报警机制。
这里面,部署大家各有各的方法,比如可以用 Ansible。如果用 Scrapy 的话有 Scrapyd,然后配合上一些管理工具也能完成一些监控和定时任务。不过我现在用的更多的还是 Docker + Kubernetes,再加上 DevOps 一套解决方案,比如 GitHub Actions、Azure Pipelines、Jenkins 等,快速实现分发和部署。
定时任务大家有的用 crontab,有的用 apscheduler,有的用管理工具,有的用 Kubernetes,我的话用 Kubernetes 会多一些了,定时任务也很好实现。
至于监控的话,也有很多,专门的爬虫管理工具自带了一些监控和报警功能。一些云服务也带了一些监控的功能。我用的是 Kubernetes + Prometheus + Grafana,什么 CPU、内存、运行状态,一目了然,报警机制在 Grafana 里面配置也很方便,支持 Webhook、邮件甚至某钉。
数据的存储和监控,用 Kafka、Elasticsearch 个人感觉也挺方便的,我主要用的是后者,然后再和 Grafana 配合起来,数据爬取量、爬取速度等等监控也都一目了然。
另外希望你在做网络爬虫的过程中注意一些法律问题,基本上就是:
不要碰个人隐私信息。
规避商业竞争,看清目标站点的法律条款限制。
限制并发速度,不要影响目标站点的正常运行。
不要碰黑产、黄赌毒。
不随便宣传和传播目标站点或 App 的破解方案。
非公开数据一定要谨慎。
更多的内容可以参考一些文章:
至此,爬虫的一些涵盖的知识点也就差不多了,通过梳理发现计算机网络、编程基础、前端开发、后端开发、App 开发与逆向、网络安全、数据库、运维、机器学习都涵盖到了?上面总结的可以算是从爬虫小白到爬虫高手的路径了,里面每个方向其实可研究的点非常多,每个点做精了,都会非常了不起。
]]>在前面编写爬虫的时候,如果我们使用 requests、aiohttp 等库,需要从头至尾把爬虫完整地实现一遍,比如说异常处理、爬取调度等,如果写的多了,的确会比较麻烦。
那么有没有什么办法可以提升我们编写爬虫的效率呢?当然是有的,那就是利用现有的爬虫框架。
说到 Python 的爬虫框架,Scrapy 当之无愧是最流行最强大的框架了。本节我们就来初步认识一下 Scrapy,后面的课时我们会对 Scrapy 的功能模块进行详细介绍。
Scrapy 是一个基于 Twisted 的异步处理框架,是纯 Python 实现的爬虫框架,其架构清晰,模块之间的耦合程度低,可扩展性极强,可以灵活完成各种需求。我们只需要定制开发几个模块就可以轻松实现一个爬虫。
首先我们来看下 Scrapy 框架的架构,如图所示:
它可以分为如下的几个部分。
Engine(引擎):用来处理整个系统的数据流处理、触发事务,是整个框架的核心。
Item(项目):定义了爬取结果的数据结构,爬取的数据会被赋值成该对象。
Scheduler(调度器):用来接受引擎发过来的请求并加入队列中,并在引擎再次请求的时候提供给引擎。
Downloader(下载器):用于下载网页内容,并将网页内容返回给蜘蛛。
Spiders(蜘蛛):其内定义了爬取的逻辑和网页的解析规则,它主要负责解析响应并生成提取结果和新的请求。
Item Pipeline(项目管道):负责处理由蜘蛛从网页中抽取的项目,它的主要任务是清洗、验证和存储数据。
Downloader Middlewares(下载器中间件):位于引擎和下载器之间的钩子框架,主要是处理引擎与下载器之间的请求及响应。
Spider Middlewares(蜘蛛中间件):位于引擎和蜘蛛之间的钩子框架,主要工作是处理蜘蛛输入的响应和输出的结果及新的请求。
初看起来的确比较懵,不过不用担心,我们在后文会结合案例来对 Scrapy 的功能模块进行介绍,相信你会慢慢地理解各个模块的含义及功能。
了解了架构,下一步就是要了解它是怎样进行数据爬取和处理的,所以我们接下来需要了解 Scrapy 的数据流机制。
Scrapy 中的数据流由引擎控制,其过程如下:
Engine 首先打开一个网站,找到处理该网站的 Spider 并向该 Spider 请求第一个要爬取的 URL。
Engine 从 Spider 中获取到第一个要爬取的 URL 并通过 Scheduler 以 Request 的形式调度。
Engine 向 Scheduler 请求下一个要爬取的 URL。
Scheduler 返回下一个要爬取的 URL 给 Engine,Engine 将 URL 通过 Downloader Middlewares 转发给 Downloader 下载。
一旦页面下载完毕, Downloader 生成一个该页面的 Response,并将其通过 Downloader Middlewares 发送给 Engine。
Engine 从下载器中接收到 Response 并通过 Spider Middlewares 发送给 Spider 处理。
Spider 处理 Response 并返回爬取到的 Item 及新的 Request 给 Engine。
Engine 将 Spider 返回的 Item 给 Item Pipeline,将新的 Request 给 Scheduler。
重复第二步到最后一步,直到 Scheduler 中没有更多的 Request,Engine 关闭该网站,爬取结束。
通过多个组件的相互协作、不同组件完成工作的不同、组件对异步处理的支持,Scrapy 最大限度地利用了网络带宽,大大提高了数据爬取和处理的效率。
了解了 Scrapy 的基本情况之后,下一步让我们来动手安装一下吧。
Scrapy 的安装方法当然首推官方文档,其地址为:https://docs.scrapy.org/en/latest/intro/install.html,另外也可以参考 https://cuiqingcai.com/5421.html。
安装完成之后,如果可以正常使用 scrapy 命令,那就是可以了。
既然 Scrapy 是框架,那么 Scrapy 一定帮我们预先配置好了很多可用的组件和编写爬虫时所用的脚手架,也就是预生成一个项目框架,我们可以基于这个框架来快速编写爬虫。
Scrapy 框架是通过命令行来创建项目的,创建项目的命令如下:
scrapy startproject demo
执行完成之后,在当前运行目录下便会出现一个文件夹,叫作 demo,这就是一个 Scrapy 项目框架,我们可以基于这个项目框架来编写爬虫。
项目文件结构如下所示:
scrapy.cfgproject/ __init__.py items.py pipelines.py settings.py middlewares.py spiders/ __init__.py spider1.py spider2.py ...
在此要将各个文件的功能描述如下:
scrapy.cfg:它是 Scrapy 项目的配置文件,其内定义了项目的配置文件路径、部署相关信息等内容。
items.py:它定义 Item 数据结构,所有的 Item 的定义都可以放这里。
pipelines.py:它定义 Item Pipeline 的实现,所有的 Item Pipeline 的实现都可以放这里。
settings.py:它定义项目的全局配置。
middlewares.py:它定义 Spider Middlewares 和 Downloader Middlewares 的实现。
spiders:其内包含一个个 Spider 的实现,每个 Spider 都有一个文件。
好了,到现在为止我们就大体知道了 Scrapy 的基本架构并实操创建了一个 Scrapy 项目,后面我们会详细了解 Scrapy 的用法,感受它的强大,下节课见。
接下来介绍一个简单的项目,完成一遍 Scrapy 抓取流程。通过这个过程,我们可以对 Scrapy 的基本用法和原理有大体了解。
本节要完成的任务如下。
创建一个 Scrapy 项目。
创建一个 Spider 来抓取站点和处理数据。
通过命令行将抓取的内容导出。
将抓取的内容保存到 MongoDB 数据库。
本节抓取的目标站点为 http://quotes.toscrape.com/。
我们需要安装好 Scrapy 框架、MongoDB 和 PyMongo 库。如果尚未安装,请参照之前几节的安装说明。
创建一个 Scrapy 项目,项目文件可以直接用 scrapy 命令生成,命令如下所示:
scrapy startproject tutorial
这个命令可以在任意文件夹运行。如果提示权限问题,可以加 sudo 运行该命令。这个命令将会创建一个名为 tutorial 的文件夹,文件夹结构如下所示:
scrapy.cfg # Scrapy 部署时的配置文件tutorial # 项目的模块,引入的时候需要从这里引入 __init__.py items.py # Items 的定义,定义爬取的数据结构 middlewares.py # Middlewares 的定义,定义爬取时的中间件 pipelines.py # Pipelines 的定义,定义数据管道 settings.py # 配置文件 spiders # 放置 Spiders 的文件夹 __init__.py
Spider 是自己定义的类,Scrapy 用它从网页里抓取内容,并解析抓取的结果。不过这个类必须继承 Scrapy 提供的 Spider 类 scrapy.Spider,还要定义 Spider 的名称和起始请求,以及怎样处理爬取后的结果的方法。
你也可以使用命令行创建一个 Spider。比如要生成 Quotes 这个 Spider,可以执行如下命令:
cd tutorialscrapy genspider quotes
进入刚才创建的 tutorial 文件夹,然后执行 genspider 命令。第一个参数是 Spider 的名称,第二个参数是网站域名。执行完毕之后,spiders 文件夹中多了一个 quotes.py,它就是刚刚创建的 Spider,内容如下所示:
import scrapyclass QuotesSpider(scrapy.Spider): name = "quotes" allowed_domains = ["quotes.toscrape.com"] start_urls = ['http://quotes.toscrape.com/'] def parse(self, response): pass
这里有三个属性——name、allowed_domains 和 start_urls,还有一个方法 parse。
name:它是每个项目唯一的名字,用来区分不同的 Spider。
allowed_domains:它是允许爬取的域名,如果初始或后续的请求链接不是这个域名下的,则请求链接会被过滤掉。
start_urls:它包含了 Spider 在启动时爬取的 url 列表,初始请求是由它来定义的。
parse:它是 Spider 的一个方法。默认情况下,被调用时 start_urls 里面的链接构成的请求完成下载执行后,返回的响应就会作为唯一的参数传递给这个函数。该方法负责解析返回的响应、提取数据或者进一步生成要处理的请求。
Item 是保存爬取数据的容器,它的使用方法和字典类似。不过,相比字典,Item 多了额外的保护机制,可以避免拼写错误或者定义字段错误。
创建 Item 需要继承 scrapy.Item 类,并且定义类型为 scrapy.Field 的字段。观察目标网站,我们可以获取到的内容有 text、author、tags。
定义 Item,此时将 items.py 修改如下:
import scrapyclass QuoteItem(scrapy.Item): text = scrapy.Field() author = scrapy.Field() tags = scrapy.Field()
这里定义了三个字段,将类的名称修改为 QuoteItem,接下来爬取时我们会使用到这个 Item。
前面我们看到,parse 方法的参数 response 是 start_urls 里面的链接爬取后的结果。所以在 parse 方法中,我们可以直接对 response 变量包含的内容进行解析,比如浏览请求结果的网页源代码,或者进一步分析源代码内容,或者找出结果中的链接而得到下一个请求。
我们可以看到网页中既有我们想要的结果,又有下一页的链接,这两部分内容我们都要进行处理。
首先看看网页结构,如图所示。每一页都有多个 class 为 quote 的区块,每个区块内都包含 text、author、tags。那么我们先找出所有的 quote,然后提取每一个 quote 中的内容。
提取的方式可以是 CSS 选择器或 XPath 选择器。在这里我们使用 CSS 选择器进行选择,parse 方法的改写如下所示:
def parse(self, response): quotes = response.css('.quote') for quote in quotes: text = quote.css('.text::text').extract_first() author = quote.css('.author::text').extract_first() tags = quote.css('.tags .tag::text').extract()
这里首先利用选择器选取所有的 quote,并将其赋值为 quotes 变量,然后利用 for 循环对每个 quote 遍历,解析每个 quote 的内容。
对 text 来说,观察到它的 class 为 text,所以可以用 .text 选择器来选取,这个结果实际上是整个带有标签的节点,要获取它的正文内容,可以加 ::text 来获取。这时的结果是长度为 1 的列表,所以还需要用 extract_first 方法来获取第一个元素。而对于 tags 来说,由于我们要获取所有的标签,所以用 extract 方法获取整个列表即可。
以第一个 quote 的结果为例,各个选择方法及结果的说明如下内容。
源码如下:
<div class="quote" itemscope=""itemtype="http://schema.org/CreativeWork"> <span class="text" itemprop="text">“The world as we have created it is a process of our thinking. It cannot be changed without changing our thinking.”</span> <span>by <small class="author" itemprop="author">Albert Einstein</small> <a href="/author/Albert-Einstein">(about)</a> </span> <div class="tags"> Tags: <meta class="keywords" itemprop="keywords" content="change,deep-thoughts,thinking,world"> <a class="tag" href="/tag/change/page/1/">change</a> <a class="tag" href="/tag/deep-thoughts/page/1/">deep-thoughts</a> <a class="tag" href="/tag/thinking/page/1/">thinking</a> <a class="tag" href="/tag/world/page/1/">world</a> </div> </div>
不同选择器的返回结果如下。
[<Selector xpath="descendant-or-self::*[@class and contains(concat(' ', normalize-space(@class), ' '), ' text ')]"data='<span class="text"itemprop="text">“The '>]
[<Selector xpath="descendant-or-self::*[@class and contains(concat(' ', normalize-space(@class), ' '), ' text ')]/text()"data='“The world as we have created it is a pr'>]
['<span class="text"itemprop="text">“The world as we have created it is a process of our thinking. It cannot be changed without changing our thinking.”</span>']
['“The world as we have created it is a process of our thinking. It cannot be changed without changing our thinking.”']
“The world as we have created it is a process of our thinking. It cannot be changed without changing our thinking.”
所以,对于 text,获取结果的第一个元素即可,所以使用 extract_first 方法,对于 tags,要获取所有结果组成的列表,所以使用 extract 方法。
上文定义了 Item,接下来就要使用它了。Item 可以理解为一个字典,不过在声明的时候需要实例化。然后依次用刚才解析的结果赋值 Item 的每一个字段,最后将 Item 返回即可。
QuotesSpider 的改写如下所示:
import scrapyfrom tutorial.items import QuoteItemclass QuotesSpider(scrapy.Spider): name = "quotes" allowed_domains = ["quotes.toscrape.com"] start_urls = ['http://quotes.toscrape.com/'] def parse(self, response): quotes = response.css('.quote') for quote in quotes: item = QuoteItem() item['text'] = quote.css('.text::text').extract_first() item['author'] = quote.css('.author::text').extract_first() item['tags'] = quote.css('.tags .tag::text').extract() yield item
如此一来,首页的所有内容被解析出来,并被赋值成了一个个 QuoteItem。
上面的操作实现了从初始页面抓取内容。那么,下一页的内容该如何抓取?这就需要我们从当前页面中找到信息来生成下一个请求,然后在下一个请求的页面里找到信息再构造下一个请求。这样循环往复迭代,从而实现整站的爬取。
将刚才的页面拉到最底部,如图所示。
有一个 Next 按钮,查看一下源代码,可以发现它的链接是 /page/2/,实际上全链接就是:http://quotes.toscrape.com/page/2,通过这个链接我们就可以构造下一个请求。
构造请求时需要用到 scrapy.Request。这里我们传递两个参数——url 和 callback,这两个参数的说明如下。
url:它是请求链接。
callback:它是回调函数。当指定了该回调函数的请求完成之后,获取到响应,引擎会将该响应作为参数传递给这个回调函数。回调函数进行解析或生成下一个请求,回调函数如上文的 parse() 所示。
由于 parse 就是解析 text、author、tags 的方法,而下一页的结构和刚才已经解析的页面结构是一样的,所以我们可以再次使用 parse 方法来做页面解析。
接下来我们要做的就是利用选择器得到下一页链接并生成请求,在 parse 方法后追加如下的代码:
next = response.css('.pager .next a::attr(href)').extract_first()url = response.urljoin(next)yield scrapy.Request(url=url, callback=self.parse)
第一句代码首先通过 CSS 选择器获取下一个页面的链接,即要获取 a 超链接中的 href 属性。这里用到了 ::attr(href) 操作。然后再调用 extract_first 方法获取内容。
第二句代码调用了 urljoin 方法,urljoin() 方法可以将相对 URL 构造成一个绝对的 URL。例如,获取到的下一页地址是 /page/2,urljoin 方法处理后得到的结果就是:http://quotes.toscrape.com/page/2/。
第三句代码通过 url 和 callback 变量构造了一个新的请求,回调函数 callback 依然使用 parse 方法。这个请求完成后,响应会重新经过 parse 方法处理,得到第二页的解析结果,然后生成第二页的下一页,也就是第三页的请求。这样爬虫就进入了一个循环,直到最后一页。
通过几行代码,我们就轻松实现了一个抓取循环,将每个页面的结果抓取下来了。现在,改写之后的整个 Spider 类如下所示:
import scrapyfrom tutorial.items import QuoteItemclass QuotesSpider(scrapy.Spider): name = "quotes" allowed_domains = ["quotes.toscrape.com"] start_urls = ['http://quotes.toscrape.com/'] def parse(self, response): quotes = response.css('.quote') for quote in quotes: item = QuoteItem() item['text'] = quote.css('.text::text').extract_first() item['author'] = quote.css('.author::text').extract_first() item['tags'] = quote.css('.tags .tag::text').extract() yield item next = response.css('.pager .next a::attr("href")').extract_first() url = response.urljoin(next) yield scrapy.Request(url=url, callback=self.parse)
接下来,进入目录,运行如下命令:
scrapy crawl quotes
就可以看到 Scrapy 的运行结果了。
2020-02-19 13:37:20 [scrapy.utils.log] INFO: Scrapy 1.3.0 started (bot: tutorial)2020-02-19 13:37:20 [scrapy.utils.log] INFO: Overridden settings: {'NEWSPIDER_MODULE': 'tutorial.spiders', 'SPIDER_MODULES': ['tutorial.spiders'], 'ROBOTSTXT_OBEY': True, 'BOT_NAME': 'tutorial'}2020-02-19 13:37:20 [scrapy.middleware] INFO: Enabled extensions:['scrapy.extensions.logstats.LogStats', 'scrapy.extensions.telnet.TelnetConsole', 'scrapy.extensions.corestats.CoreStats']2020-02-19 13:37:20 [scrapy.middleware] INFO: Enabled downloader middlewares:['scrapy.downloadermiddlewares.robotstxt.RobotsTxtMiddleware', 'scrapy.downloadermiddlewares.httpauth.HttpAuthMiddleware', 'scrapy.downloadermiddlewares.downloadtimeout.DownloadTimeoutMiddleware', 'scrapy.downloadermiddlewares.defaultheaders.DefaultHeadersMiddleware', 'scrapy.downloadermiddlewares.useragent.UserAgentMiddleware', 'scrapy.downloadermiddlewares.retry.RetryMiddleware', 'scrapy.downloadermiddlewares.redirect.MetaRefreshMiddleware', 'scrapy.downloadermiddlewares.httpcompression.HttpCompressionMiddleware', 'scrapy.downloadermiddlewares.redirect.RedirectMiddleware', 'scrapy.downloadermiddlewares.cookies.CookiesMiddleware', 'scrapy.downloadermiddlewares.stats.DownloaderStats']2020-02-19 13:37:20 [scrapy.middleware] INFO: Enabled spider middlewares:['scrapy.spidermiddlewares.httperror.HttpErrorMiddleware', 'scrapy.spidermiddlewares.offsite.OffsiteMiddleware', 'scrapy.spidermiddlewares.referer.RefererMiddleware', 'scrapy.spidermiddlewares.urllength.UrlLengthMiddleware', 'scrapy.spidermiddlewares.depth.DepthMiddleware']2020-02-19 13:37:20 [scrapy.middleware] INFO: Enabled item pipelines:[]2020-02-19 13:37:20 [scrapy.core.engine] INFO: Spider opened2020-02-19 13:37:20 [scrapy.extensions.logstats] INFO: Crawled 0 pages (at 0 pages/min), scraped 0 items (at 0 items/min)2020-02-19 13:37:20 [scrapy.extensions.telnet] DEBUG: Telnet console listening on 127.0.0.1:60232020-02-19 13:37:21 [scrapy.core.engine] DEBUG: Crawled (404) <GET http://quotes.toscrape.com/robots.txt> (referer: None)2020-02-19 13:37:21 [scrapy.core.engine] DEBUG: Crawled (200) <GET http://quotes.toscrape.com/> (referer: None)2020-02-19 13:37:21 [scrapy.core.scraper] DEBUG: Scraped from <200 http://quotes.toscrape.com/>{'author': u'Albert Einstein', 'tags': [u'change', u'deep-thoughts', u'thinking', u'world'], 'text': u'\u201cThe world as we have created it is a process of our thinking. It cannot be changed without changing our thinking.\u201d'}2020-02-19 13:37:21 [scrapy.core.scraper] DEBUG: Scraped from <200 http://quotes.toscrape.com/>{'author': u'J.K. Rowling', 'tags': [u'abilities', u'choices'], 'text': u'\u201cIt is our choices, Harry, that show what we truly are, far more than our abilities.\u201d'}...2020-02-19 13:37:27 [scrapy.core.engine] INFO: Closing spider (finished)2020-02-19 13:37:27 [scrapy.statscollectors] INFO: Dumping Scrapy stats:{'downloader/request_bytes': 2859, 'downloader/request_count': 11, 'downloader/request_method_count/GET': 11, 'downloader/response_bytes': 24871, 'downloader/response_count': 11, 'downloader/response_status_count/200': 10, 'downloader/response_status_count/404': 1, 'dupefilter/filtered': 1, 'finish_reason': 'finished', 'finish_time': datetime.datetime(2017, 2, 19, 5, 37, 27, 227438), 'item_scraped_count': 100, 'log_count/DEBUG': 113, 'log_count/INFO': 7, 'request_depth_max': 10, 'response_received_count': 11, 'scheduler/dequeued': 10, 'scheduler/dequeued/memory': 10, 'scheduler/enqueued': 10, 'scheduler/enqueued/memory': 10, 'start_time': datetime.datetime(2017, 2, 19, 5, 37, 20, 321557)}2020-02-19 13:37:27 [scrapy.core.engine] INFO: Spider closed (finished)
这里只是部分运行结果,中间一些抓取结果已省略。
首先,Scrapy 输出了当前的版本号,以及正在启动的项目名称。接着输出了当前 settings.py 中一些重写后的配置。然后输出了当前所应用的 Middlewares 和 Pipelines。Middlewares 默认是启用的,可以在 settings.py 中修改。Pipelines 默认是空,同样也可以在 settings.py 中配置。后面会对它们进行讲解。
接下来就是输出各个页面的抓取结果了,可以看到爬虫一边解析,一边翻页,直至将所有内容抓取完毕,然后终止。
最后,Scrapy 输出了整个抓取过程的统计信息,如请求的字节数、请求次数、响应次数、完成原因等。
整个 Scrapy 程序成功运行。我们通过非常简单的代码就完成了一个网站内容的爬取,这样相比之前一点点写程序简洁很多。
运行完 Scrapy 后,我们只在控制台看到了输出结果。如果想保存结果该怎么办呢?
要完成这个任务其实不需要任何额外的代码,Scrapy 提供的 Feed Exports 可以轻松将抓取结果输出。例如,我们想将上面的结果保存成 JSON 文件,可以执行如下命令:
scrapy crawl quotes -o quotes.json
命令运行后,项目内多了一个 quotes.json 文件,文件包含了刚才抓取的所有内容,内容是 JSON 格式。
另外我们还可以每一个 Item 输出一行 JSON,输出后缀为 jl,为 jsonline 的缩写,命令如下所示:
scrapy crawl quotes -o quotes.jl
或
scrapy crawl quotes -o quotes.jsonlines
输出格式还支持很多种,例如 csv、xml、pickle、marshal 等,还支持 ftp、s3 等远程输出,另外还可以通过自定义 ItemExporter 来实现其他的输出。
例如,下面命令对应的输出分别为 csv、xml、pickle、marshal 格式以及 ftp 远程输出:
scrapy crawl quotes -o quotes.csvscrapy crawl quotes -o quotes.xmlscrapy crawl quotes -o quotes.picklescrapy crawl quotes -o quotes.marshalscrapy crawl quotes -o ftp://user:pass@ftp.example.com/path/to/quotes.csv
其中,ftp 输出需要正确配置用户名、密码、地址、输出路径,否则会报错。
通过 Scrapy 提供的 Feed Exports,我们可以轻松地输出抓取结果到文件。对于一些小型项目来说,这应该足够了。不过如果想要更复杂的输出,如输出到数据库等,我们可以使用 Item Pileline 来完成。
如果想进行更复杂的操作,如将结果保存到 MongoDB 数据库,或者筛选某些有用的 Item,则我们可以定义 Item Pipeline 来实现。
Item Pipeline 为项目管道。当 Item 生成后,它会自动被送到 Item Pipeline 进行处理,我们常用 Item Pipeline 来做如下操作。
清洗 HTML 数据;
验证爬取数据,检查爬取字段;
查重并丢弃重复内容;
将爬取结果储存到数据库。
要实现 Item Pipeline 很简单,只需要定义一个类并实现 process_item 方法即可。启用 Item Pipeline 后,Item Pipeline 会自动调用这个方法。process_item 方法必须返回包含数据的字典或 Item 对象,或者抛出 DropItem 异常。
process_item 方法有两个参数。一个参数是 item,每次 Spider 生成的 Item 都会作为参数传递过来。另一个参数是 spider,就是 Spider 的实例。
接下来,我们实现一个 Item Pipeline,筛掉 text 长度大于 50 的 Item,并将结果保存到 MongoDB。
修改项目里的 pipelines.py 文件,之前用命令行自动生成的文件内容可以删掉,增加一个 TextPipeline 类,内容如下所示:
from scrapy.exceptions import DropItemclass TextPipeline(object): def __init__(self): self.limit = 50 def process_item(self, item, spider): if item['text']: if len(item['text']) > self.limit: item['text'] = item['text'][0:self.limit].rstrip() + '...' return item else: return DropItem('Missing Text')
这段代码在构造方法里定义了限制长度为 50,实现了 process_item 方法,其参数是 item 和 spider。首先该方法判断 item 的 text 属性是否存在,如果不存在,则抛出 DropItem 异常;如果存在,再判断长度是否大于 50,如果大于,那就截断然后拼接省略号,再将 item 返回即可。
接下来,我们将处理后的 item 存入 MongoDB,定义另外一个 Pipeline。同样在 pipelines.py 中,我们实现另一个类 MongoPipeline,内容如下所示:
import pymongoclass MongoPipeline(object): def __init__(self, mongo_uri, mongo_db): self.mongo_uri = mongo_uri self.mongo_db = mongo_db @classmethod def from_crawler(cls, crawler): return cls(mongo_uri=crawler.settings.get('MONGO_URI'), mongo_db=crawler.settings.get('MONGO_DB') ) def open_spider(self, spider): self.client = pymongo.MongoClient(self.mongo_uri) self.db = self.client[self.mongo_db] def process_item(self, item, spider): name = item.__class__.__name__ self.db[name].insert(dict(item)) return item def close_spider(self, spider): self.client.close()
MongoPipeline 类实现了 API 定义的另外几个方法。
from_crawler:这是一个类方法,用 @classmethod 标识,是一种依赖注入的方式,方法的参数就是 crawler,通过 crawler 这个参数我们可以拿到全局配置的每个配置信息,在全局配置 settings.py 中我们可以定义 MONGO_URI 和 MONGO_DB 来指定 MongoDB 连接需要的地址和数据库名称,拿到配置信息之后返回类对象即可。所以这个方法的定义主要是用来获取 settings.py 中的配置的。
open_spider:当 Spider 被开启时,这个方法被调用。在这里主要进行了一些初始化操作。
close_spider:当 Spider 被关闭时,这个方法会调用,在这里将数据库连接关闭。
最主要的 process_item 方法则执行了数据插入操作。
定义好 TextPipeline 和 MongoPipeline 这两个类后,我们需要在 settings.py 中使用它们。MongoDB 的连接信息还需要定义。
我们在 settings.py 中加入如下内容:
ITEM_PIPELINES = { 'tutorial.pipelines.TextPipeline': 300, 'tutorial.pipelines.MongoPipeline': 400,}MONGO_URI='localhost'MONGO_DB='tutorial'
赋值 ITEM_PIPELINES 字典,键名是 Pipeline 的类名称,键值是调用优先级,是一个数字,数字越小则对应的 Pipeline 越先被调用。
再重新执行爬取,命令如下所示:
scrapy crawl quotes
爬取结束后,MongoDB 中创建了一个 tutorial 的数据库、QuoteItem 的表,如图所示。
长的 text 已经被处理并追加了省略号,短的 text 保持不变,author 和 tags 也都相应保存。
本节代码地址:https://github.com/Python3WebSpider/ScrapyTutorial。
我们通过抓取 Quotes 网站完成了整个 Scrapy 的简单入门。但这只是冰山一角,还有很多内容等待我们去探索。
在上一节课我们通过实例了解了 Scrapy 的基本使用方法,在这个过程中,我们用到了 Spider 来编写爬虫逻辑,同时用到了一些选择器来对结果进行选择。
在这一节课,我们就对 Spider 和 Selector 的基本用法作一个总结。
在 Scrapy 中,要抓取网站的链接配置、抓取逻辑、解析逻辑等其实都是在 Spider 中配置的。在前一节课的实例中,我们发现抓取逻辑也是在 Spider 中完成的。本节课我们就来专门了解一下 Spider 的基本用法。
在实现 Scrapy 爬虫项目时,最核心的类便是 Spider 类了,它定义了如何爬取某个网站的流程和解析方式。简单来讲,Spider 要做的事就是如下两件:
定义爬取网站的动作;
分析爬取下来的网页。
对于 Spider 类来说,整个爬取循环如下所述。
以初始的 URL 初始化 Request,并设置回调函数。 当该 Request 成功请求并返回时,将生成 Response,并作为参数传给该回调函数。
在回调函数内分析返回的网页内容。返回结果可以有两种形式,一种是解析到的有效结果返回字典或 Item 对象。下一步可经过处理后(或直接)保存,另一种是解析到的下一个(如下一页)链接,可以利用此链接构造 Request 并设置新的回调函数,返回 Request。
如果返回的是字典或 Item 对象,可通过 Feed Exports 等形式存入文件,如果设置了 Pipeline 的话,可以经由 Pipeline 处理(如过滤、修正等)并保存。
如果返回的是 Reqeust,那么 Request 执行成功得到 Response 之后会再次传递给 Request 中定义的回调函数,可以再次使用选择器来分析新得到的网页内容,并根据分析的数据生成 Item。
通过以上几步循环往复进行,便完成了站点的爬取。
在上一节课的例子中我们定义的 Spider 继承自 scrapy.spiders.Spider,这个类是最简单最基本的 Spider 类,每个其他的 Spider 必须继承自这个类,还有后面要说明的一些特殊 Spider 类也都是继承自它。
这个类里提供了 start_requests 方法的默认实现,读取并请求 start_urls 属性,并根据返回的结果调用 parse 方法解析结果。另外它还有一些基础属性,下面对其进行讲解。
name:爬虫名称,是定义 Spider 名字的字符串。Spider 的名字定义了 Scrapy 如何定位并初始化 Spider,所以其必须是唯一的。 不过我们可以生成多个相同的 Spider 实例,这没有任何限制。 name 是 Spider 最重要的属性,而且是必需的。如果该 Spider 爬取单个网站,一个常见的做法是以该网站的域名名称来命名 Spider。例如,如果 Spider 爬取 mywebsite.com,该 Spider 通常会被命名为 mywebsite。
allowed_domains:允许爬取的域名,是可选配置,不在此范围的链接不会被跟进爬取。
start_urls:起始 URL 列表,当我们没有实现 start_requests 方法时,默认会从这个列表开始抓取。
custom_settings:这是一个字典,是专属于本 Spider 的配置,此设置会覆盖项目全局的设置,而且此设置必须在初始化前被更新,所以它必须定义成类变量。
crawler:此属性是由 from_crawler 方法设置的,代表的是本 Spider 类对应的 Crawler 对象,Crawler 对象中包含了很多项目组件,利用它我们可以获取项目的一些配置信息,如最常见的就是获取项目的设置信息,即 Settings。
settings:是一个 Settings 对象,利用它我们可以直接获取项目的全局设置变量。
除了一些基础属性,Spider 还有一些常用的方法,在此介绍如下。
start_requests:此方法用于生成初始请求,它必须返回一个可迭代对象,此方法会默认使用 start_urls 里面的 URL 来构造 Request,而且 Request 是 GET 请求方式。如果我们想在启动时以 POST 方式访问某个站点,可以直接重写这个方法,发送 POST 请求时我们使用 FormRequest 即可。
parse:当 Response 没有指定回调函数时,该方法会默认被调用,它负责处理 Response,处理返回结果,并从中提取出想要的数据和下一步的请求,然后返回。该方法需要返回一个包含 Request 或 Item 的可迭代对象。
closed:当 Spider 关闭时,该方法会被调用,在这里一般会定义释放资源的一些操作或其他收尾操作。
我们之前介绍了利用 Beautiful Soup、PyQuery,以及正则表达式来提取网页数据,这确实非常方便。而 Scrapy 还提供了自己的数据提取方法,即 Selector(选择器)。
Selector 是基于 lxml 构建的,支持 XPath 选择器、CSS 选择器,以及正则表达式,功能全面,解析速度和准确度非常高。
接下来我们将介绍 Selector 的用法。
Selector 是一个可以独立使用的模块。我们可以直接利用 Selector 这个类来构建一个选择器对象,然后调用它的相关方法如 xpath、css 等来提取数据。
例如,针对一段 HTML 代码,我们可以用如下方式构建 Selector 对象来提取数据:
from scrapy import Selectorbody = '<html><head><title>Hello World</title></head><body></body></html>'selector = Selector(text=body)title = selector.xpath('//title/text()').extract_first()print(title)
运行结果:
Hello World
这里我们没有在 Scrapy 框架中运行,而是把 Scrapy 中的 Selector 单独拿出来使用了,构建的时候传入 text 参数,就生成了一个 Selector 选择器对象,然后就可以像前面我们所用的 Scrapy 中的解析方式一样,调用 xpath、css 等方法来提取了。
在这里我们查找的是源代码中的 title 中的文本,在 XPath 选择器最后加 text 方法就可以实现文本的提取了。
以上内容就是 Selector 的直接使用方式。同 Beautiful Soup 等库类似,Selector 其实也是强大的网页解析库。如果方便的话,我们也可以在其他项目中直接使用 Selector 来提取数据。
接下来,我们用实例来详细讲解 Selector 的用法。
由于 Selector 主要是与 Scrapy 结合使用,如 Scrapy 的回调函数中的参数 response 直接调用 xpath() 或者 css() 方法来提取数据,所以在这里我们借助 Scrapy Shell 来模拟 Scrapy 请求的过程,来讲解相关的提取方法。
我们用官方文档的一个样例页面来做演示:http://doc.scrapy.org/en/latest/_static/selectors-sample1.html。
开启 Scrapy Shell,在命令行中输入如下命令:
scrapy shell http://doc.scrapy.org/en/latest/_static/selectors-sample1.html
这样我们就进入了 Scrapy Shell 模式。这个过程其实是 Scrapy 发起了一次请求,请求的 URL 就是刚才命令行下输入的 URL,然后把一些可操作的变量传递给我们,如 request、response 等,如图所示。
我们可以在命令行模式下输入命令调用对象的一些操作方法,回车之后实时显示结果。这与 Python 的命令行交互模式是类似的。
接下来,演示的实例都将页面的源码作为分析目标,页面源码如下所示:
<html> <head> <base href='http://example.com/' /> <title>Example website</title> </head> <body> <div id='images'> <a href='image1.html'>Name: My image 1 <br /><img src='image1_thumb.jpg' /></a> <a href='image2.html'>Name: My image 2 <br /><img src='image2_thumb.jpg' /></a> <a href='image3.html'>Name: My image 3 <br /><img src='image3_thumb.jpg' /></a> <a href='image4.html'>Name: My image 4 <br /><img src='image4_thumb.jpg' /></a> <a href='image5.html'>Name: My image 5 <br /><img src='image5_thumb.jpg' /></a> </div> </body></html>
进入 Scrapy Shell 之后,我们将主要操作 response 变量来进行解析。因为我们解析的是 HTML 代码,Selector 将自动使用 HTML 语法来分析。
response 有一个属性 selector,我们调用 response.selector 返回的内容就相当于用 response 的 text 构造了一个 Selector 对象。通过这个 Selector 对象我们可以调用解析方法如 xpath、css 等,通过向方法传入 XPath 或 CSS 选择器参数就可以实现信息的提取。
我们用一个实例感受一下,如下所示:
>>> result = response.selector.xpath('//a')>>> result[<Selector xpath='//a' data='<a href="image1.html">Name: My image 1 <'>, <Selector xpath='//a' data='<a href="image2.html">Name: My image 2 <'>, <Selector xpath='//a' data='<a href="image3.html">Name: My image 3 <'>, <Selector xpath='//a' data='<a href="image4.html">Name: My image 4 <'>, <Selector xpath='//a' data='<a href="image5.html">Name: My image 5 <'>]>>> type(result)scrapy.selector.unified.SelectorList
打印结果的形式是 Selector 组成的列表,其实它是 SelectorList 类型,SelectorList 和 Selector 都可以继续调用 xpath 和 css 等方法来进一步提取数据。
在上面的例子中,我们提取了 a 节点。接下来,我们尝试继续调用 xpath 方法来提取 a 节点内包含的 img 节点,如下所示:
>>> result.xpath('./img')[<Selector xpath='./img' data='<img src="image1_thumb.jpg">'>, <Selector xpath='./img' data='<img src="image2_thumb.jpg">'>, <Selector xpath='./img' data='<img src="image3_thumb.jpg">'>, <Selector xpath='./img' data='<img src="image4_thumb.jpg">'>, <Selector xpath='./img' data='<img src="image5_thumb.jpg">'>]
我们获得了 a 节点里面的所有 img 节点,结果为 5。
值得注意的是,选择器的最前方加 .
(点),这代表提取元素内部的数据,如果没有加点,则代表从根节点开始提取。此处我们用了 ./img 的提取方式,则代表从 a 节点里进行提取。如果此处我们用 //img,则还是从 html 节点里进行提取。
我们刚才使用了 response.selector.xpath 方法对数据进行了提取。Scrapy 提供了两个实用的快捷方法,response.xpath 和 response.css,它们二者的功能完全等同于 response.selector.xpath 和 response.selector.css。方便起见,后面我们统一直接调用 response 的 xpath 和 css 方法进行选择。
现在我们得到的是 SelectorList 类型的变量,该变量是由 Selector 对象组成的列表。我们可以用索引单独取出其中某个 Selector 元素,如下所示:
>>> result[0]<Selector xpath='//a' data='<a href="image1.html">Name: My image 1 <'>
我们可以像操作列表一样操作这个 SelectorList。但是现在获取的内容是 Selector 或者 SelectorList 类型,并不是真正的文本内容。那么具体的内容怎么提取呢?
比如我们现在想提取出 a 节点元素,就可以利用 extract 方法,如下所示:
>>> result.extract()['<a href="image1.html">Name: My image 1 <br><img src="image1_thumb.jpg"></a>', '<a href="image2.html">Name: My image 2 <br><img src="image2_thumb.jpg"></a>', '<a href="image3.html">Name: My image 3 <br><img src="image3_thumb.jpg"></a>', '<a href="image4.html">Name: My image 4 <br><img src="image4_thumb.jpg"></a>', '<a href="image5.html">Name: My image 5 <br><img src="image5_thumb.jpg"></a>']
这里使用了 extract 方法,我们就可以把真实需要的内容获取下来。
我们还可以改写 XPath 表达式,来选取节点的内部文本和属性,如下所示:
>>> response.xpath('//a/text()').extract()['Name: My image 1 ', 'Name: My image 2 ', 'Name: My image 3 ', 'Name: My image 4 ', 'Name: My image 5 ']>>> response.xpath('//a/@href').extract()['image1.html', 'image2.html', 'image3.html', 'image4.html', 'image5.html']
我们只需要再加一层 /text() 就可以获取节点的内部文本,或者加一层 /@href 就可以获取节点的 href 属性。其中,@ 符号后面内容就是要获取的属性名称。
现在我们可以用一个规则把所有符合要求的节点都获取下来,返回的类型是列表类型。
但是这里有一个问题:如果符合要求的节点只有一个,那么返回的结果会是什么呢?我们再用一个实例来感受一下,如下所示:
>>> response.xpath('//a[@href="image1.html"]/text()').extract()['Name: My image 1 ']
我们用属性限制了匹配的范围,使 XPath 只可以匹配到一个元素。然后用 extract 方法提取结果,其结果还是一个列表形式,其文本是列表的第一个元素。但很多情况下,我们其实想要的数据就是第一个元素内容,这里我们通过加一个索引来获取,如下所示:
'Name: My image 1 '
但是,这个写法很明显是有风险的。一旦 XPath 有问题,那么 extract 后的结果可能是一个空列表。如果我们再用索引来获取,那不就可能会导致数组越界吗?
所以,另外一个方法可以专门提取单个元素,它叫作 extract_first。我们可以改写上面的例子如下所示:
>>> response.xpath('//a[@href="image1.html"]/text()').extract_first()'Name: My image 1 '
这样,我们直接利用 extract_first 方法将匹配的第一个结果提取出来,同时我们也不用担心数组越界的问题。
另外我们也可以为 extract_first 方法设置一个默认值参数,这样当 XPath 规则提取不到内容时会直接使用默认值。例如将 XPath 改成一个不存在的规则,重新执行代码,如下所示:
>>> response.xpath('//a[@href="image1"]/text()').extract_first()>>> response.xpath('//a[@href="image1"]/text()').extract_first('Default Image')'Default Image'
这里,如果 XPath 匹配不到任何元素,调用 extract_first 会返回空,也不会报错。在第二行代码中,我们还传递了一个参数当作默认值,如 Default Image。这样如果 XPath 匹配不到结果的话,返回值会使用这个参数来代替,可以看到输出正是如此。
到现在为止,我们了解了 Scrapy 中的 XPath 的相关用法,包括嵌套查询、提取内容、提取单个内容、获取文本和属性等。
接下来,我们看看 CSS 选择器的用法。Scrapy 的选择器同时还对接了 CSS 选择器,使用 response.css() 方法可以使用 CSS 选择器来选择对应的元素。
例如在上文我们选取了所有的 a 节点,那么 CSS 选择器同样可以做到,如下所示:
>>> response.css('a')[<Selector xpath='descendant-or-self::a' data='<a href="image1.html">Name: My image 1 <'>, <Selector xpath='descendant-or-self::a' data='<a href="image2.html">Name: My image 2 <'>, <Selector xpath='descendant-or-self::a' data='<a href="image3.html">Name: My image 3 <'>, <Selector xpath='descendant-or-self::a' data='<a href="image4.html">Name: My image 4 <'>, <Selector xpath='descendant-or-self::a' data='<a href="image5.html">Name: My image 5 <'>]
同样,调用 extract 方法就可以提取出节点,如下所示:
['<a href="image1.html">Name: My image 1 <br><img src="image1_thumb.jpg"></a>', '<a href="image2.html">Name: My image 2 <br><img src="image2_thumb.jpg"></a>', '<a href="image3.html">Name: My image 3 <br><img src="image3_thumb.jpg"></a>', '<a href="image4.html">Name: My image 4 <br><img src="image4_thumb.jpg"></a>', '<a href="image5.html">Name: My image 5 <br><img src="image5_thumb.jpg"></a>']
用法和 XPath 选择是完全一样的。另外,我们也可以进行属性选择和嵌套选择,如下所示:
>>> response.css('a[href="image1.html"]').extract()['<a href="image1.html">Name: My image 1 <br><img src="image1_thumb.jpg"></a>']>>> response.css('a[href="image1.html"] img').extract()['<img src="image1_thumb.jpg">']
这里用 [href="image.html"] 限定了 href 属性,可以看到匹配结果就只有一个了。另外如果想查找 a 节点内的 img 节点,只需要再加一个空格和 img 即可。选择器的写法和标准 CSS 选择器写法如出一辙。
我们也可以使用 extract_first() 方法提取列表的第一个元素,如下所示:
>>> response.css('a[href="image1.html"] img').extract_first()'<img src="image1_thumb.jpg">'
接下来的两个用法不太一样。节点的内部文本和属性的获取是这样实现的,如下所示:
>>> response.css('a[href="image1.html"]::text').extract_first()'Name: My image 1 '>>> response.css('a[href="image1.html"] img::attr(src)').extract_first()'image1_thumb.jpg'
获取文本和属性需要用 ::text 和 ::attr() 的写法。而其他库如 Beautiful Soup 或 PyQuery 都有单独的方法。
另外,CSS 选择器和 XPath 选择器一样可以嵌套选择。我们可以先用 XPath 选择器选中所有 a 节点,再利用 CSS 选择器选中 img 节点,再用 XPath 选择器获取属性。我们用一个实例来感受一下,如下所示:
>>> response.xpath('//a').css('img').xpath('@src').extract()['image1_thumb.jpg', 'image2_thumb.jpg', 'image3_thumb.jpg', 'image4_thumb.jpg', 'image5_thumb.jpg']
我们成功获取了所有 img 节点的 src 属性。
因此,我们可以随意使用 xpath 和 css 方法二者自由组合实现嵌套查询,二者是完全兼容的。
Scrapy 的选择器还支持正则匹配。比如,在示例的 a 节点中的文本类似于 Name: My image 1,现在我们只想把 Name: 后面的内容提取出来,这时就可以借助 re 方法,实现如下:
>>> response.xpath('//a/text()').re('Name:\s(.*)')['My image 1 ', 'My image 2 ', 'My image 3 ', 'My image 4 ', 'My image 5 ']
我们给 re 方法传入一个正则表达式,其中 (.*)
就是要匹配的内容,输出的结果就是正则表达式匹配的分组,结果会依次输出。
如果同时存在两个分组,那么结果依然会被按序输出,如下所示:
>>> response.xpath('//a/text()').re('(.*?):\s(.*)')['Name', 'My image 1 ', 'Name', 'My image 2 ', 'Name', 'My image 3 ', 'Name', 'My image 4 ', 'Name', 'My image 5 ']
类似 extract_first 方法,re_first 方法可以选取列表的第一个元素,用法如下:
>>> response.xpath('//a/text()').re_first('(.*?):\s(.*)')'Name'>>> response.xpath('//a/text()').re_first('Name:\s(.*)')'My image 1 '
不论正则匹配了几个分组,结果都会等于列表的第一个元素。
值得注意的是,response 对象不能直接调用 re 和 re_first 方法。如果想要对全文进行正则匹配,可以先调用 xpath 方法然后再进行正则匹配,如下所示:
>>> response.re('Name:\s(.*)')Traceback (most recent call last): File "<console>", line 1, in <module>AttributeError: 'HtmlResponse' object has no attribute 're'>>> response.xpath('.').re('Name:\s(.*)<br>')['My image 1 ', 'My image 2 ', 'My image 3 ', 'My image 4 ', 'My image 5 ']>>> response.xpath('.').re_first('Name:\s(.*)<br>')'My image 1 '
通过上面的例子,我们可以看到,直接调用 re 方法会提示没有 re 属性。但是这里首先调用了 xpath('.')
选中全文,然后调用 re 和 re_first 方法,就可以进行正则匹配了。
以上内容便是 Scrapy 选择器的用法,它包括两个常用选择器和正则匹配功能。如果你熟练掌握 XPath 语法、CSS 选择器语法、正则表达式语法可以大大提高数据提取效率。
我们在 Scrapy 架构中,可以看到有一个叫作 Middleware 的概念,中文翻译过来就叫作中间件,在 Scrapy 中有两种 Middleware,一种是 Spider Middleware,另一种是 Downloader Middleware,本节课我们分别来介绍下。
Spider Middleware 是介入 Scrapy 的 Spider 处理机制的钩子框架。
当 Downloader 生成 Response 之后,Response 会被发送给 Spider,在发送给 Spider 之前,Response 会首先经过 Spider Middleware 处理,当 Spider 处理生成 Item 和 Request 之后,Item 和 Request 还会经过 Spider Middleware 的处理。
Spider Middleware 有如下三个作用。
我们可以在 Downloader 生成的 Response 发送给 Spider 之前,也就是在 Response 发送给 Spider 之前对 Response 进行处理。
我们可以在 Spider 生成的 Request 发送给 Scheduler 之前,也就是在 Request 发送给 Scheduler 之前对 Request 进行处理。
我们可以在 Spider 生成的 Item 发送给 Item Pipeline 之前,也就是在 Item 发送给 Item Pipeline 之前对 Item 进行处理。
需要说明的是,Scrapy 其实已经提供了许多 Spider Middleware,它们被 SPIDER_MIDDLEWARES_BASE 这个变量所定义。
SPIDER_MIDDLEWARES_BASE 变量的内容如下:
{ 'scrapy.spidermiddlewares.httperror.HttpErrorMiddleware': 50, 'scrapy.spidermiddlewares.offsite.OffsiteMiddleware': 500, 'scrapy.spidermiddlewares.referer.RefererMiddleware': 700, 'scrapy.spidermiddlewares.urllength.UrlLengthMiddleware': 800, 'scrapy.spidermiddlewares.depth.DepthMiddleware': 900,}
和 Downloader Middleware 一样,Spider Middleware 首先加入 SPIDER_MIDDLEWARES 的设置中,该设置会和 Scrapy 中 SPIDER_MIDDLEWARES_BASE 定义的 Spider Middleware 合并。然后根据键值的数字优先级排序,得到一个有序列表。第一个 Middleware 是最靠近引擎的,最后一个 Middleware 是最靠近 Spider 的。
Scrapy 内置的 Spider Middleware 为 Scrapy 提供了基础的功能。如果我们想要扩展其功能,只需要实现某几个方法即可。
每个 Spider Middleware 都定义了以下一个或多个方法的类,核心方法有如下 4 个。
process_spider_input(response, spider)
process_spider_output(response, result, spider)
process_spider_exception(response, exception, spider)
process_start_requests(start_requests, spider)
只需要实现其中一个方法就可以定义一个 Spider Middleware。下面我们来看看这 4 个方法的详细用法。
当 Response 通过 Spider Middleware 时,该方法被调用,处理该 Response。
方法的参数有两个:
response,即 Response 对象,即被处理的 Response;
spider,即 Spider 对象,即该 response 对应的 Spider。
process_spider_input() 应该返回 None 或者抛出一个异常。
如果其返回 None,Scrapy 将会继续处理该 Response,调用所有其他的 Spider Middleware 直到 Spider 处理该 Response。
如果其抛出一个异常,Scrapy 将不会调用任何其他 Spider Middleware 的 process_spider_input() 方法,并调用 Request 的 errback() 方法。 errback 的输出将会以另一个方向被重新输入到中间件中,使用 process_spider_output() 方法来处理,当其抛出异常时则调用 process_spider_exception() 来处理。
当 Spider 处理 Response 返回结果时,该方法被调用。
方法的参数有三个:
response,即 Response 对象,即生成该输出的 Response;
result,包含 Request 或 Item 对象的可迭代对象,即 Spider 返回的结果;
spider,即 Spider 对象,即其结果对应的 Spider。
process_spider_output() 必须返回包含 Request 或 Item 对象的可迭代对象。
当 Spider 或 Spider Middleware 的 process_spider_input() 方法抛出异常时, 该方法被调用。
方法的参数有三个:
response,即 Response 对象,即异常被抛出时被处理的 Response;
exception,即 Exception 对象,被抛出的异常;
spider,即 Spider 对象,即抛出该异常的 Spider。
process_spider_exception() 必须返回结果,要么返回 None , 要么返回一个包含 Response 或 Item 对象的可迭代对象。
如果其返回 None ,Scrapy 将继续处理该异常,调用其他 Spider Middleware 中的 process_spider_exception() 方法,直到所有 Spider Middleware 都被调用。
如果其返回一个可迭代对象,则其他 Spider Middleware 的 process_spider_output() 方法被调用, 其他的 process_spider_exception() 将不会被调用。
该方法以 Spider 启动的 Request 为参数被调用,执行的过程类似于 process_spider_output() ,只不过其没有相关联的 Response 并且必须返回 Request。
方法的参数有两个:
start_requests,即包含 Request 的可迭代对象,即 Start Requests;
spider,即 Spider 对象,即 Start Requests 所属的 Spider。
其必须返回另一个包含 Request 对象的可迭代对象。
Downloader Middleware 即下载中间件,它是处于 Scrapy 的 Request 和 Response 之间的处理模块。
Scheduler 从队列中拿出一个 Request 发送给 Downloader 执行下载,这个过程会经过 Downloader Middleware 的处理。另外,当 Downloader 将 Request 下载完成得到 Response 返回给 Spider 时会再次经过 Downloader Middleware 处理。
也就是说,Downloader Middleware 在整个架构中起作用的位置是以下两个。
在 Scheduler 调度出队列的 Request 发送给 Downloader 下载之前,也就是我们可以在 Request 执行下载之前对其进行修改。
在下载后生成的 Response 发送给 Spider 之前,也就是我们可以在生成 Resposne 被 Spider 解析之前对其进行修改。
Downloader Middleware 的功能十分强大,修改 User-Agent、处理重定向、设置代理、失败重试、设置 Cookies 等功能都需要借助它来实现。下面我们来了解一下 Downloader Middleware 的详细用法。
需要说明的是,Scrapy 其实已经提供了许多 Downloader Middleware,比如负责失败重试、自动重定向等功能的 Middleware,它们被 DOWNLOADER_MIDDLEWARES_BASE 变量所定义。
DOWNLOADER_MIDDLEWARES_BASE 变量的内容如下所示:
{ 'scrapy.downloadermiddlewares.robotstxt.RobotsTxtMiddleware': 100, 'scrapy.downloadermiddlewares.httpauth.HttpAuthMiddleware': 300, 'scrapy.downloadermiddlewares.downloadtimeout.DownloadTimeoutMiddleware': 350, 'scrapy.downloadermiddlewares.defaultheaders.DefaultHeadersMiddleware': 400, 'scrapy.downloadermiddlewares.useragent.UserAgentMiddleware': 500, 'scrapy.downloadermiddlewares.retry.RetryMiddleware': 550, 'scrapy.downloadermiddlewares.ajaxcrawl.AjaxCrawlMiddleware': 560, 'scrapy.downloadermiddlewares.redirect.MetaRefreshMiddleware': 580, 'scrapy.downloadermiddlewares.httpcompression.HttpCompressionMiddleware': 590, 'scrapy.downloadermiddlewares.redirect.RedirectMiddleware': 600, 'scrapy.downloadermiddlewares.cookies.CookiesMiddleware': 700, 'scrapy.downloadermiddlewares.httpproxy.HttpProxyMiddleware': 750, 'scrapy.downloadermiddlewares.stats.DownloaderStats': 850, 'scrapy.downloadermiddlewares.httpcache.HttpCacheMiddleware': 900,}
这是一个字典格式,字典的键名是 Scrapy 内置的 Downloader Middleware 的名称,键值代表了调用的优先级,优先级是一个数字,数字越小代表越靠近 Scrapy 引擎,数字越大代表越靠近 Downloader。每个 Downloader Middleware 都可以定义 process_request() 和 request_response() 方法来分别处理请求和响应,对于 process_request() 方法来说,优先级数字越小越先被调用,对于 process_response() 方法来说,优先级数字越大越先被调用。
如果自己定义的 Downloader Middleware 要添加到项目里,DOWNLOADER_MIDDLEWARES_BASE 变量不能直接修改。Scrapy 提供了另外一个设置变量 DOWNLOADER_MIDDLEWARES,我们直接修改这个变量就可以添加自己定义的 Downloader Middleware,以及禁用 DOWNLOADER_MIDDLEWARES_BASE 里面定义的 Downloader Middleware。下面我们具体来看看 Downloader Middleware 的使用方法。
Scrapy 内置的 Downloader Middleware 为 Scrapy 提供了基础的功能,但在项目实战中我们往往需要单独定义 Downloader Middleware。不用担心,这个过程非常简单,我们只需要实现某几个方法即可。
每个 Downloader Middleware 都定义了一个或多个方法的类,核心的方法有如下三个。
process_request(request, spider)
process_response(request, response, spider)
process_exception(request, exception, spider)
我们只需要实现至少一个方法,就可以定义一个 Downloader Middleware。下面我们来看看这三个方法的详细用法。
Request 被 Scrapy 引擎调度给 Downloader 之前,process_request() 方法就会被调用,也就是在 Request 从队列里调度出来到 Downloader 下载执行之前,我们都可以用 process_request() 方法对 Request 进行处理。方法的返回值必须为 None、Response 对象、Request 对象之一,或者抛出 IgnoreRequest 异常。
process_request() 方法的参数有如下两个。
request,即 Request 对象,即被处理的 Request;
spider,即 Spider 对象,即此 Request 对应的 Spider。
返回类型不同,产生的效果也不同。下面归纳一下不同的返回情况。
当返回为 None 时,Scrapy 将继续处理该 Request,接着执行其他 Downloader Middleware 的 process_request() 方法,直到 Downloader 把 Request 执行后得到 Response 才结束。这个过程其实就是修改 Request 的过程,不同的 Downloader Middleware 按照设置的优先级顺序依次对 Request 进行修改,最后推送至 Downloader 执行。
当返回为 Response 对象时,更低优先级的 Downloader Middleware 的 process_request() 和 process_exception() 方法就不会被继续调用,每个 Downloader Middleware 的 process_response() 方法转而被依次调用。调用完毕之后,直接将 Response 对象发送给 Spider 来处理。
当返回为 Request 对象时,更低优先级的 Downloader Middleware 的 process_request() 方法会停止执行。这个 Request 会重新放到调度队列里,其实它就是一个全新的 Request,等待被调度。如果被 Scheduler 调度了,那么所有的 Downloader Middleware 的 process_request() 方法会被重新按照顺序执行。
如果 IgnoreRequest 异常抛出,则所有的 Downloader Middleware 的 process_exception() 方法会依次执行。如果没有一个方法处理这个异常,那么 Request 的 errorback() 方法就会回调。如果该异常还没有被处理,那么它便会被忽略。
Downloader 执行 Request 下载之后,会得到对应的 Response。Scrapy 引擎便会将 Response 发送给 Spider 进行解析。在发送之前,我们都可以用 process_response() 方法来对 Response 进行处理。方法的返回值必须为 Request 对象、Response 对象之一,或者抛出 IgnoreRequest 异常。
process_response() 方法的参数有如下三个。
request,是 Request 对象,即此 Response 对应的 Request。
response,是 Response 对象,即此被处理的 Response。
spider,是 Spider 对象,即此 Response 对应的 Spider。
下面对不同的返回情况做一下归纳:
当返回为 Request 对象时,更低优先级的 Downloader Middleware 的 process_response() 方法不会继续调用。该 Request 对象会重新放到调度队列里等待被调度,它相当于一个全新的 Request。然后,该 Request 会被 process_request() 方法顺次处理。
当返回为 Response 对象时,更低优先级的 Downloader Middleware 的 process_response() 方法会继续调用,继续对该 Response 对象进行处理。
如果 IgnoreRequest 异常抛出,则 Request 的 errorback() 方法会回调。如果该异常还没有被处理,那么它便会被忽略。
当 Downloader 或 process_request() 方法抛出异常时,例如抛出 IgnoreRequest 异常,process_exception() 方法就会被调用。方法的返回值必须为 None、Response 对象、Request 对象之一。
process_exception() 方法的参数有如下三个。
request,即 Request 对象,即产生异常的 Request。
exception,即 Exception 对象,即抛出的异常。
spdier,即 Spider 对象,即 Request 对应的 Spider。
下面归纳一下不同的返回值。
当返回为 None 时,更低优先级的 Downloader Middleware 的 process_exception() 会被继续顺次调用,直到所有的方法都被调度完毕。
当返回为 Response 对象时,更低优先级的 Downloader Middleware 的 process_exception() 方法不再被继续调用,每个 Downloader Middleware 的 process_response() 方法转而被依次调用。
当返回为 Request 对象时,更低优先级的 Downloader Middleware 的 process_exception() 也不再被继续调用,该 Request 对象会重新放到调度队列里面等待被调度,它相当于一个全新的 Request。然后,该 Request 又会被 process_request() 方法顺次处理。
以上内容便是这三个方法的详细使用逻辑。在使用它们之前,请先对这三个方法的返回值的处理情况有一个清晰的认识。在自定义 Downloader Middleware 的时候,也一定要注意每个方法的返回类型。
下面我们用一个案例实战来加深一下对 Downloader Middleware 用法的理解。
新建一个项目,命令如下所示:
scrapy startproject scrapydownloadertest
新建了一个 Scrapy 项目,名为 scrapydownloadertest。进入项目,新建一个 Spider,命令如下所示:
scrapy genspider httpbin httpbin.org
新建了一个 Spider,名为 httpbin,源代码如下所示:
import scrapyclass HttpbinSpider(scrapy.Spider): name = 'httpbin' allowed_domains = ['httpbin.org'] start_urls = ['http://httpbin.org/'] def parse(self, response): pass
接下来我们修改 start_urls 为:['http://httpbin.org/']
。随后将 parse() 方法添加一行日志输出,将 response 变量的 text 属性输出,这样我们便可以看到 Scrapy 发送的 Request 信息了。
修改 Spider 内容如下所示:
import scrapyclass HttpbinSpider(scrapy.Spider): name = 'httpbin' allowed_domains = ['httpbin.org'] start_urls = ['http://httpbin.org/get'] def parse(self, response): self.logger.debug(response.text)
接下来运行此 Spider,执行如下命令:
scrapy crawl httpbin
Scrapy 运行结果包含 Scrapy 发送的 Request 信息,内容如下所示:
{"args": {}, "headers": { "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Encoding": "gzip,deflate,br", "Accept-Language": "en", "Connection": "close", "Host": "httpbin.org", "User-Agent": "Scrapy/1.4.0 (+http://scrapy.org)" }, "origin": "60.207.237.85", "url": "http://httpbin.org/get"}
我们观察一下 Headers,Scrapy 发送的 Request 使用的 User-Agent 是 Scrapy/1.4.0(+http://scrapy.org),这其实是由 Scrapy 内置的 UserAgentMiddleware 设置的,UserAgentMiddleware 的源码如下所示:
from scrapy import signalsclass UserAgentMiddleware(object): def __init__(self, user_agent='Scrapy'): self.user_agent = user_agent @classmethod def from_crawler(cls, crawler): o = cls(crawler.settings['USER_AGENT']) crawler.signals.connect(o.spider_opened, signal=signals.spider_opened) return o def spider_opened(self, spider): self.user_agent = getattr(spider, 'user_agent', self.user_agent) def process_request(self, request, spider): if self.user_agent: request.headers.setdefault(b'User-Agent', self.user_agent)
在 from_crawler() 方法中,首先尝试获取 settings 里面的 USER_AGENT,然后把 USER_AGENT 传递给 init() 方法进行初始化,其参数就是 user_agent。如果没有传递 USER_AGENT 参数就默认设置为 Scrapy 字符串。我们新建的项目没有设置 USER_AGENT,所以这里的 user_agent 变量就是 Scrapy。接下来,在 process_request() 方法中,将 user-agent 变量设置为 headers 变量的一个属性,这样就成功设置了 User-Agent。因此,User-Agent 就是通过此 Downloader Middleware 的 process_request() 方法设置的。
修改请求时的 User-Agent 可以有两种方式:一是修改 settings 里面的 USER_AGENT 变量;二是通过 Downloader Middleware 的 process_request() 方法来修改。
第一种方法非常简单,我们只需要在 setting.py 里面加一行 USER_AGENT 的定义即可:
USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/59.0.3071.115 Safari/537.36'
一般推荐使用此方法来设置。但是如果想设置得更灵活,比如设置随机的 User-Agent,那就需要借助 Downloader Middleware 了。所以接下来我们用 Downloader Middleware 实现一个随机 User-Agent 的设置。
在 middlewares.py 里面添加一个 RandomUserAgentMiddleware 的类,如下所示:
import randomclass RandomUserAgentMiddleware(): def __init__(self): self.user_agents = ['Mozilla/5.0 (Windows; U; MSIE 9.0; Windows NT 9.0; en-US)', 'Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.2 (KHTML, like Gecko) Chrome/22.0.1216.0 Safari/537.2', 'Mozilla/5.0 (X11; Ubuntu; Linux i686; rv:15.0) Gecko/20100101 Firefox/15.0.1' ] def process_request(self, request, spider): request.headers['User-Agent'] = random.choice(self.user_agents)
我们首先在类的 __init__() 方法中定义了三个不同的 User-Agent,并用一个列表来表示。接下来实现了 process_request() 方法,它有一个参数 request,我们直接修改 request 的属性即可。在这里我们直接设置了 request 对象的 headers 属性的 User-Agent,设置内容是随机选择的 User-Agent,这样一个 Downloader Middleware 就写好了。
不过,要使之生效我们还需要再去调用这个 Downloader Middleware。在 settings.py 中,将 DOWNLOADER_MIDDLEWARES 取消注释,并设置成如下内容:
DOWNLOADER_MIDDLEWARES = {'scrapydownloadertest.middlewares.RandomUserAgentMiddleware': 543,}
接下来我们重新运行 Spider,就可以看到 User-Agent 被成功修改为列表中所定义的随机的一个 User-Agent 了:
{"args": {}, "headers": { "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Encoding": "gzip,deflate,br", "Accept-Language": "en", "Connection": "close", "Host": "httpbin.org", "User-Agent": "Mozilla/5.0 (Windows; U; MSIE 9.0; Windows NT 9.0; en-US)" }, "origin": "60.207.237.85", "url": "http://httpbin.org/get"}
我们就通过实现 Downloader Middleware 并利用 process_request() 方法成功设置了随机的 User-Agent。
另外,Downloader Middleware 还有 process_response() 方法。Downloader 对 Request 执行下载之后会得到 Response,随后 Scrapy 引擎会将 Response 发送回 Spider 进行处理。但是在 Response 被发送给 Spider 之前,我们同样可以使用 process_response() 方法对 Response 进行处理。比如这里修改一下 Response 的状态码,在 RandomUserAgentMiddleware 添加如下代码:
def process_response(self, request, response, spider): response.status = 201 return response
我们将 response 对象的 status 属性修改为 201,随后将 response 返回,这个被修改后的 Response 就会被发送到 Spider。
我们再在 Spider 里面输出修改后的状态码,在 parse() 方法中添加如下的输出语句:
self.logger.debug('Status Code: ' + str(response.status))
重新运行之后,控制台输出了如下内容:
[httpbin] DEBUG: Status Code: 201
可以发现,Response 的状态码成功修改了。因此要想对 Response 进行处理,就可以借助于 process_response() 方法。
另外还有一个 process_exception() 方法,它是用来处理异常的方法。如果需要异常处理的话,我们可以调用此方法。不过这个方法的使用频率相对低一些,在此不用实例演示。
本节源代码为:
https://github.com/Python3WebSpider/ScrapyDownloaderTest。
本节讲解了 Spider Middleware 和 Downloader Middleware 的基本用法。利用它们我们可以方便地实现爬虫逻辑的灵活处理,需要好好掌握。
在前面的示例中我们已经了解了 Item Pipeline 项目管道的基本概念,本节课我们就深入详细讲解它的用法。
首先我们看看 Item Pipeline 在 Scrapy 中的架构,如图所示。
图中的最左侧即为 Item Pipeline,它的调用发生在 Spider 产生 Item 之后。当 Spider 解析完 Response 之后,Item 就会传递到 Item Pipeline,被定义的 Item Pipeline 组件会顺次调用,完成一连串的处理过程,比如数据清洗、存储等。
它的主要功能有:
清洗 HTML 数据;
验证爬取数据,检查爬取字段;
查重并丢弃重复内容;
将爬取结果储存到数据库。
我们可以自定义 Item Pipeline,只需要实现指定的方法就可以,其中必须要实现的一个方法是:
process_item(item, spider)
另外还有几个比较实用的方法,它们分别是:
open_spider(spider)
close_spider(spider)
from_crawler(cls, crawler)
下面我们对这几个方法的用法做下详细的介绍:
process_item() 是必须要实现的方法,被定义的 Item Pipeline 会默认调用这个方法对 Item 进行处理。比如,我们可以进行数据处理或者将数据写入数据库等操作。它必须返回 Item 类型的值或者抛出一个 DropItem 异常。
process_item() 方法的参数有如下两个:
item,是 Item 对象,即被处理的 Item;
spider,是 Spider 对象,即生成该 Item 的 Spider。
下面对该方法的返回类型归纳如下:
如果返回的是 Item 对象,那么此 Item 会被低优先级的 Item Pipeline 的 process_item() 方法进行处理,直到所有的方法被调用完毕。
如果抛出的是 DropItem 异常,那么此 Item 就会被丢弃,不再进行处理。
open_spider() 方法是在 Spider 开启的时候被自动调用的,在这里我们可以做一些初始化操作,如开启数据库连接等。其中参数 spider 就是被开启的 Spider 对象。
close_spider() 方法是在 Spider 关闭的时候自动调用的,在这里我们可以做一些收尾工作,如关闭数据库连接等,其中参数 spider 就是被关闭的 Spider 对象。
from_crawler() 方法是一个类方法,用 @classmethod 标识,是一种依赖注入的方式。它的参数是 crawler,通过 crawler 对象,我们可以拿到 Scrapy 的所有核心组件,如全局配置的每个信息,然后创建一个 Pipeline 实例。参数 cls 就是 Class,最后返回一个 Class 实例。
下面我们用一个实例来加深对 Item Pipeline 用法的理解。
我们以爬取 360 摄影美图为例,来分别实现 MongoDB 存储、MySQL 存储、Image 图片存储的三个 Pipeline。
请确保已经安装好 MongoDB 和 MySQL 数据库,安装好 Python 的 PyMongo、PyMySQL、Scrapy 框架,另外需要安装 pillow 图像处理库,如果没有安装可以参考前文的安装说明。
我们这次爬取的目标网站为:https://image.so.com。打开此页面,切换到摄影页面,网页中呈现了许许多多的摄影美图。我们打开浏览器开发者工具,过滤器切换到 XHR 选项,然后下拉页面,可以看到下面就会呈现许多 Ajax 请求,如图所示。
我们查看一个请求的详情,观察返回的数据结构,如图所示。
返回格式是 JSON。其中 list 字段就是一张张图片的详情信息,包含了 30 张图片的 ID、名称、链接、缩略图等信息。另外观察 Ajax 请求的参数信息,有一个参数 sn 一直在变化,这个参数很明显就是偏移量。当 sn 为 30 时,返回的是前 30 张图片,sn 为 60 时,返回的就是第 31~60 张图片。另外,ch 参数是摄影类别,listtype 是排序方式,temp 参数可以忽略。
所以我们抓取时只需要改变 sn 的数值就好了。下面我们用 Scrapy 来实现图片的抓取,将图片的信息保存到 MongoDB、MySQL,同时将图片存储到本地。
首先新建一个项目,命令如下:
scrapy startproject images360
接下来新建一个 Spider,命令如下:
scrapy genspider images images.so.com
这样我们就成功创建了一个 Spider。
接下来定义爬取的页数。比如爬取 50 页、每页 30 张,也就是 1500 张图片,我们可以先在 settings.py 里面定义一个变量 MAX_PAGE,添加如下定义:
MAX_PAGE = 50
定义 start_requests() 方法,用来生成 50 次请求,如下所示:
def start_requests(self): data = {'ch': 'photography', 'listtype': 'new'} base_url = 'https://image.so.com/zjl?' for page in range(1, self.settings.get('MAX_PAGE') + 1): data['sn'] = page * 30 params = urlencode(data) url = base_url + params yield Request(url, self.parse)
在这里我们首先定义了初始的两个参数,sn 参数是遍历循环生成的。然后利用 urlencode 方法将字典转化为 URL 的 GET 参数,构造出完整的 URL,构造并生成 Request。
还需要引入 scrapy.Request 和 urllib.parse 模块,如下所示:
from scrapy import Spider, Requestfrom urllib.parse import urlencode
再修改 settings.py 中的 ROBOTSTXT_OBEY 变量,将其设置为 False,否则无法抓取,如下所示:
ROBOTSTXT_OBEY = False
运行爬虫,即可以看到链接都请求成功,执行命令如下所示:
scrapy crawl images
运行示例结果如图所示。
所有请求的状态码都是 200,这就证明图片信息爬取成功了。
首先定义一个叫作 ImageItem 的 Item,如下所示:
from scrapy import Item, Fieldclass ImageItem(Item): collection = table = 'images' id = Field() url = Field() title = Field() thumb = Field()
在这里我们定义了 4 个字段,包括图片的 ID、链接、标题、缩略图。另外还有两个属性 collection 和 table,都定义为 images 字符串,分别代表 MongoDB 存储的 Collection 名称和 MySQL 存储的表名称。
接下来我们提取 Spider 里有关信息,将 parse 方法改写为如下所示:
def parse(self, response): result = json.loads(response.text) for image in result.get('list'): item = ImageItem() item['id'] = image.get('id') item['url'] = image.get('qhimg_url') item['title'] = image.get('title') item['thumb'] = image.get('qhimg_thumb') yield item
首先解析 JSON,遍历其 list 字段,取出一个个图片信息,然后再对 ImageItem 进行赋值,生成 Item 对象。
这样我们就完成了信息的提取。
接下来我们需要将图片的信息保存到 MongoDB、MySQL 中,同时将图片保存到本地。
首先确保 MongoDB 已经正常安装并且能够正常运行。
我们用一个 MongoPipeline 将信息保存到 MongoDB 中,在 pipelines.py 里添加如下类的实现:
import pymongoclass MongoPipeline(object): def __init__(self, mongo_uri, mongo_db): self.mongo_uri = mongo_uri self.mongo_db = mongo_db @classmethod def from_crawler(cls, crawler): return cls(mongo_uri=crawler.settings.get('MONGO_URI'), mongo_db=crawler.settings.get('MONGO_DB') ) def open_spider(self, spider): self.client = pymongo.MongoClient(self.mongo_uri) self.db = self.client[self.mongo_db] def process_item(self, item, spider): self.db[item.collection].insert(dict(item)) return item def close_spider(self, spider): self.client.close()
这里需要用到两个变量,MONGO_URI 和 MONGO_DB,即存储到 MongoDB 的链接地址和数据库名称。我们在 settings.py 里添加这两个变量,如下所示:
MONGO_URI = 'localhost'MONGO_DB = 'images360'
这样一个保存到 MongoDB 的 Pipeline 的就创建好了。这里最主要的方法是 process_item(),直接调用 Collection 对象的 insert 方法即可完成数据的插入,最后返回 Item 对象。
首先需要确保 MySQL 已经正确安装并且正常运行。
新建一个数据库,名字还是 images360,SQL 语句如下所示:
CREATE DATABASE images360 DEFAULT CHARACTER SET utf8 COLLATE utf8_general_ci
新建一个数据表,包含 id、url、title、thumb 四个字段,SQL 语句如下所示:
CREATE TABLE images (id VARCHAR(255) NULL PRIMARY KEY, url VARCHAR(255) NULL , title VARCHAR(255) NULL , thumb VARCHAR(255) NULL)
执行完 SQL 语句之后,我们就成功创建好了数据表。接下来就可以往表里存储数据了。
接下来我们实现一个 MySQLPipeline,代码如下所示:
import pymysqlclass MysqlPipeline(): def __init__(self, host, database, user, password, port): self.host = host self.database = database self.user = user self.password = password self.port = port @classmethod def from_crawler(cls, crawler): return cls(host=crawler.settings.get('MYSQL_HOST'), database=crawler.settings.get('MYSQL_DATABASE'), user=crawler.settings.get('MYSQL_USER'), password=crawler.settings.get('MYSQL_PASSWORD'), port=crawler.settings.get('MYSQL_PORT'), ) def open_spider(self, spider): self.db = pymysql.connect(self.host, self.user, self.password, self.database, charset='utf8', port=self.port) self.cursor = self.db.cursor() def close_spider(self, spider): self.db.close() def process_item(self, item, spider): data = dict(item) keys = ', '.join(data.keys()) values = ', '.join(['% s'] * len(data)) sql = 'insert into % s (% s) values (% s)' % (item.table, keys, values) self.cursor.execute(sql, tuple(data.values())) self.db.commit() return item
如前所述,这里用到的数据插入方法是一个动态构造 SQL 语句的方法。
这里还需要几个 MySQL 的配置,我们在 settings.py 里添加几个变量,如下所示:
MYSQL_HOST = 'localhost'MYSQL_DATABASE = 'images360'MYSQL_PORT = 3306MYSQL_USER = 'root'MYSQL_PASSWORD = '123456'
这里分别定义了 MySQL 的地址、数据库名称、端口、用户名、密码。这样,MySQL Pipeline 就完成了。
Scrapy 提供了专门处理下载的 Pipeline,包括文件下载和图片下载。下载文件和图片的原理与抓取页面的原理一样,因此下载过程支持异步和多线程,十分高效。下面我们来看看具体的实现过程。
官方文档地址为:https://doc.scrapy.org/en/latest/topics/media-pipeline.html。
首先定义存储文件的路径,需要定义一个 IMAGES_STORE 变量,在 settings.py 中添加如下代码:
IMAGES_STORE = './images'
在这里我们将路径定义为当前路径下的 images 子文件夹,即下载的图片都会保存到本项目的 images 文件夹中。
内置的 ImagesPipeline 会默认读取 Item 的 image_urls 字段,并认为该字段是一个列表形式,它会遍历 Item 的 image_urls 字段,然后取出每个 URL 进行图片下载。
但是现在生成的 Item 的图片链接字段并不是 image_urls 字段表示的,也不是列表形式,而是单个的 URL。所以为了实现下载,我们需要重新定义下载的部分逻辑,即需要自定义 ImagePipeline,继承内置的 ImagesPipeline,重写方法。
我们定义 ImagePipeline,如下所示:
from scrapy import Requestfrom scrapy.exceptions import DropItemfrom scrapy.pipelines.images import ImagesPipelineclass ImagePipeline(ImagesPipeline): def file_path(self, request, response=None, info=None): url = request.url file_name = url.split('/')[-1] return file_name def item_completed(self, results, item, info): image_paths = [x['path'] for ok, x in results if ok] if not image_paths: raise DropItem('Image Downloaded Failed') return item def get_media_requests(self, item, info): yield Request(item['url'])
在这里我们实现了 ImagePipeline,继承 Scrapy 内置的 ImagesPipeline,重写了下面几个方法。
get_media_requests()。它的第一个参数 item 是爬取生成的 Item 对象。我们将它的 url 字段取出来,然后直接生成 Request 对象。此 Request 加入调度队列,等待被调度,执行下载。
file_path()。它的第一个参数 request 就是当前下载对应的 Request 对象。这个方法用来返回保存的文件名,直接将图片链接的最后一部分当作文件名即可。它利用 split() 函数分割链接并提取最后一部分,返回结果。这样此图片下载之后保存的名称就是该函数返回的文件名。
item_completed(),它是当单个 Item 完成下载时的处理方法。因为并不是每张图片都会下载成功,所以我们需要分析下载结果并剔除下载失败的图片。如果某张图片下载失败,那么我们就不需保存此 Item 到数据库。该方法的第一个参数 results 就是该 Item 对应的下载结果,它是一个列表形式,列表每一个元素是一个元组,其中包含了下载成功或失败的信息。这里我们遍历下载结果找出所有成功的下载列表。如果列表为空,那么该 Item 对应的图片下载失败,随即抛出异常 DropItem,该 Item 忽略。否则返回该 Item,说明此 Item 有效。
现在为止,三个 Item Pipeline 的定义就完成了。最后只需要启用就可以了,修改 settings.py,设置 ITEM_PIPELINES,如下所示:
ITEM_PIPELINES = { 'images360.pipelines.ImagePipeline': 300, 'images360.pipelines.MongoPipeline': 301, 'images360.pipelines.MysqlPipeline': 302,}
这里注意调用的顺序。我们需要优先调用 ImagePipeline 对 Item 做下载后的筛选,下载失败的 Item 就直接忽略,它们就不会保存到 MongoDB 和 MySQL 里。随后再调用其他两个存储的 Pipeline,这样就能确保存入数据库的图片都是下载成功的。
接下来运行程序,执行爬取,如下所示:
scrapy crawl images
爬虫一边爬取一边下载,下载速度非常快,对应的输出日志如图所示。
查看本地 images 文件夹,发现图片都已经成功下载,如图所示。
查看 MySQL,下载成功的图片信息也已成功保存,如图所示。
查看 MongoDB,下载成功的图片信息同样已成功保存,如图所示。
这样我们就可以成功实现图片的下载并把图片的信息存入数据库了。
本节代码地址为:
https://github.com/Python3WebSpider/Images360。
Item Pipeline 是 Scrapy 非常重要的组件,数据存储几乎都是通过此组件实现的。请你务必认真掌握此内容。
]]>我们知道,爬虫是帮助我们快速获取有效信息的。然而在做爬虫的过程中,我们会发现解析是件麻烦事。
比如一篇新闻吧,链接是:https://news.ifeng.com/c/7kQcQG2peWU,页面预览图如下:
我们需要从页面中提取出标题、发布人、发布时间、发布内容、图片等内容。一般情况下我们需要怎么办?答案是写规则。
那么规则都有什么呢?比如正则、CSS 选择器、XPath。我们需要对标题、发布时间、来源等内容做规则匹配,更有甚者需要正则表达式来辅助。我们可能需要用 re、BeautifulSoup、PyQuery 等库来实现内容的提取和解析。
但如果我们有成千上万个不同样式的页面该怎么办呢?它们来自成千上万个站点,难道我们还需要对它们一一写规则来匹配吗?这得要多大的工作量啊。另外这些万一处理不好还会出现解析问题。比如正则表达式在某些情况下匹配不了,CSS、XPath 选择器选错位也会出现问题。
想必你可能见过现在的浏览器有阅读模式,比如我们把这个页面用 Safari 浏览器打开,然后开启阅读模式,看看什么效果:
页面马上变得非常清爽,只保留了标题和需要读的内容。原先页面多余的导航栏、侧栏、评论等等都被去除了。它怎么做到的?难道是有人在里面写好规则了?那当然不可能的事。其实,这里面就用到了智能化解析了。
那么本课时,我们就来了解一下页面的智能化解析的相关知识。
所谓爬虫的智能化解析,顾名思义就是不再需要我们针对某一些页面来专门写提取规则了,我们可以利用一些算法来计算出页面特定元素的位置和提取路径。比如一个页面中的一篇文章,我们可以通过算法计算出来,它的标题应该是什么,正文应该是哪部分区域,发布时间等等。
其实智能化解析是非常难的一项任务,比如说你给人看一个网页的一篇文章,人可以迅速找到这篇文章的标题是什么,发布时间是什么,正文是哪一块,或者哪一块是广告位,哪一块是导航栏。但给机器来识别的话,它面临的是什么?仅仅是一系列的 HTML 代码而已。那究竟机器是怎么做到智能化提取的呢?其实这里面融合了多方面的信息。
另外还有一些特点就不再一一赘述了,这其中包含了区块位置、区块大小、区块标签、区块内容、区块疏密度等等多种特征,另外很多情况下还需要借助于视觉的特征,所以说这里面其实结合了算法计算、视觉处理、自然语言处理等各个方面的内容。如果能把这些特征综合运用起来,再经过大量的数据训练,是可以得到一个非常不错的效果的。
未来的话,页面也会越来越多,页面的渲染方式也会发生很大的变化,爬虫也会越来越难做,智能化爬虫也将会变得越来越重要。
目前工业界,其实已经有落地的算法应用了。经过我的一番调研,发现目前有这么几种算法或者服务对页面的智能化解析做得比较好:
那么这几种算法或者服务到底哪些好呢,Driffbot 官方曾做过一个对比评测,使用 Google 新闻的一些文章,使用不同的算法依次摘出其中的标题和文本,然后与真实标注的内容进行比较,比较的指标就是文字的准确率和召回率,以及根据二者计算出的 F1 分数。
其结果对比如下:
Service/Software | Precision | Recall | F1-Score |
---|---|---|---|
Diffbot | 0.968 | 0.978 | 0.971 |
Boilerpipe | 0.893 | 0.924 | 0.893 |
Readability | 0.819 | 0.911 | 0.854 |
AlchemyAPI | 0.876 | 0.892 | 0.850 |
Embedly | 0.786 | 0.880 | 0.822 |
Goose | 0.498 | 0.815 | 0.608 |
经过对比我们可以发现,Diffbot 的准确率和召回率都还比较高。这是一家专门做网页智能化提取的公司,Diffbot 自 2010 年以来就致力于提取 Web 页面数据,并提供许多 API 来自动解析各种页面。其中他们的算法依赖于自然语言技术、机器学习、计算机视觉、标记检查等多种算法,并且所有的页面都会考虑到当前页面的样式以及可视化布局,另外还会分析其中包含的图像内容、CSS 甚至 Ajax 请求。另外在计算一个区块的置信度时还考虑到了和其他区块的关联关系,基于周围的标记来计算每个区块的置信度。总之,Diffbot 也一直致力于这一方面的服务,整个 Diffbot 就是页面解析起家的,现在也一直专注于页面解析服务,准确率高也就不足为怪了。
但它们的算法并没有开源,只是以商业化 API 来售卖的,我也没有找到相关的论文介绍它们自己的具体算法。
不过,这里我们不妨拿它来做案例,稍微体会一下智能解析算法能达到一个怎样的效果。
接下来的内容,我们就以 Diffbot 为例来介绍下智能解析所能达到的效果。
首先我们需要注册一个账号,它有 15 天的免费试用,注册之后会获得一个 Developer Token,这就是使用 Diffbot 接口服务的凭证。
接下来切换到它的测试页面中,链接为:https://www.diffbot.com/dev/home/,我们来测试一下它的解析效果到底是怎样的。
这里我们选择的测试页面就是上文所述的页面,链接为:https://news.ifeng.com/c/7kQcQG2peWU,API 类型选择 Article API,然后点击 Test Drive 按钮,接下来它就会出现当前页面的解析结果:
这时候我们可以看到,它帮我们提取出来了标题、发布时间、发布机构、发布机构链接、正文内容等等各种结果。而且目前来看都十分正确,时间也在自动识别后做了转码,是一个标准的时间格式。
接下来我们继续下滑,查看还有什么其他的字段,这里我们还可以看到有 html 字段,它和 text 不同的是 html 包含了文章内容的真实 HTML 代码,因此图片也会包含在里面,如图所示:
另外最后面还有 images 字段,它以列表形式返回了文章套图及每一张图的链接,另外还有文章的站点名称、页面所用语言等等结果,如图所示:
当然我们也可以选择 JSON 格式的返回结果,其内容会更加丰富,例如图片还返回了其宽度、高度、图片描述等等内容,另外还有各种其他的结果如面包屑导航等等结果,如图所示:
经过手工核对,发现其返回的结果都是完全正确的,准确率还是很高的。
所以说,如果你对准确率要求没有那么非常非常严苛的情况下,使用 Diffbot 的服务可以帮助我们快速地提取页面中所需的结果,省去了我们绝大多数的手工劳动,可以说是非常赞了。
但是,我们也不能总在网页上这么试吧。其实 Diffbot 也提供了官方的 API 文档,让我们来一探究竟。
Driffbot 提供了多种 API,如 Analyze API、Article API、Disscussion API 等。
下面我们以 Article API 为例来说明一下它的用法,其官方文档地址为:https://www.diffbot.com/dev/docs/article/,API 调用地址为:
https://api.diffbot.com/v3/article
我们可以用 GET 方式来进行请求,其中的 Token 和 URL 都可以以参数形式传递给这个 API,其必备的参数有:
另外它还有几个可选参数。
这里你可能关注的就是 fields 字段了,在这里我专门做了一下梳理,首先是一些固定字段。
以上的预定字段就是如果可以返回那就会返回的字段,是不能定制化配置的,另外我们还可以通过 fields 参数来指定扩展如下可选字段。
好,以上便是这个 API 的用法,你可以申请之后使用这个 API 来做智能化解析了。
下面我们用一个实例来看一下这个 API 的用法,代码如下:
import requests, jsonurl = 'https://api.diffbot.com/v3/article'params = { 'token': '77b41f6fbb24496d5113d528306528fa', 'url': 'https://news.ifeng.com/c/7kQcQG2peWU', 'fields': 'meta' }response = requests.get(url, params=params)print(json.dumps(response.json(), indent=2, ensure_ascii=False))
这里首先定义了 API 的链接,然后指定了 params 参数,即 GET 请求参数。
参数中包含了必选的 token、url 字段,也设置了可选的 fields 字段,其中 fields 为可选的扩展字段 meta 标签。
我们来看下运行结果,结果如下:
{ "request": { "pageUrl": "https://news.ifeng.com/c/7kQcQG2peWU", "api": "article", "fields": "sentiment, meta", "version": 3 }, "objects": [ { "date": "Wed, 20 Feb 2019 02:26:00 GMT", "images": [ { "naturalHeight": 460, "width": 640, "diffbotUri": "image|3|-1139316034", "url": "http://e0.ifengimg.com/02/2019/0219/1731DC8A29EB2219C7F2773CF9CF319B3503D0A1_size382_w690_h460.png", "naturalWidth": 690, "primary": true, "height": 426 }, // ... ], "author": "中国新闻网", "estimatedDate": "Wed, 20 Feb 2019 06:47:52 GMT", "diffbotUri": "article|3|1591137208", "siteName": "ifeng.com", "type": "article", "title": "故宫,你低调点!故宫:不,实力已不允许我继续低调", "breadcrumb": [ { "link": "https://news.ifeng.com/", "name": "资讯" }, { "link": "https://news.ifeng.com/shanklist/3-35197-/", "name": "大陆" } ], "humanLanguage": "zh", "meta": { "og": { "og:time ": "2019-02-20 02:26:00", "og:image": "https://e0.ifengimg.com/02/2019/0219/1731DC8A29EB2219C7F2773CF9CF319B3503D0A1_size382_w690_h460.png", "og:category ": "凤凰资讯", "og: webtype": "news", "og:title": "故宫,你低调点!故宫:不,实力已不允许我继续低调", "og:url": "https://news.ifeng.com/c/7kQcQG2peWU", "og:description": " “我的名字叫紫禁城,快要600岁了,这上元的夜啊,总是让我沉醉,这么久了却从未停止。” “重" }, "referrer": "always", "description": " “我的名字叫紫禁城,快要600岁了,这上元的夜啊,总是让我沉醉,这么久了却从未停止。” “重", "keywords": "故宫 紫禁城 故宫博物院 灯光 元宵节 博物馆 一票难求 元之 中新社 午门 杜洋 藏品 文化 皇帝 清明上河图 元宵 千里江山图卷 中英北京条约 中法北京条约 天津条约", "title": "故宫,你低调点!故宫:不,实力已不允许我继续低调_凤凰资讯" }, "authorUrl": "https://feng.ifeng.com/author/308904", "pageUrl": "https://news.ifeng.com/c/7kQcQG2peWU", "html": "<p>“我的名字叫紫禁城,快要600岁了,这上元的夜啊,总是让我沉醉,这么久了却从未停止。...</blockquote> </blockquote>", "text": "“我的名字叫紫禁城,快要600岁了,这上元的夜啊,总是让我沉醉,这么久了却从未停止。”\n“...", "authors": [ { "name": "中国新闻网", "link": "https://feng.ifeng.com/author/308904" } ] } ]}
可见其返回了如上的内容,是一个完整的 JSON 格式,其中包含了标题、正文、发布时间等等各种内容。
可见,不需要我们配置任何提取规则,我们就可以完成页面的分析和抓取,得来全不费功夫。
另外 Diffbot 还提供了几乎所有语言的 SDK 支持,我们也可以使用 SDK 来实现如上功能,链接为:https://www.diffbot.com/dev/docs/libraries/,如果你使用 Python 的话,可以直接使用 Python 的 SDK 即可,Python 的 SDK 链接为:https://github.com/diffbot/diffbot-python-client。
这个库并没有发布到 PyPi,需要自己下载并导入使用,另外这个库是使用 Python 2 写的,其实本质上就是调用了 requests 库,如果你感兴趣的话可以看一下。
下面是一个调用示例:
from client import DiffbotClient,DiffbotCrawldiffbot = DiffbotClient()token = 'your_token'url = 'http://shichuan.github.io/javascript-patterns/'api = 'article'response = diffbot.request(url, token, api)
通过这行代码我们就可以通过调用 Article API 来分析我们想要的 URL 链接了,返回结果是类似的。
具体的用法你直接看下它的源码注释就一目了然了,还是很清楚的。
好,以上便是对智能化提取页面原理的基本介绍以及对 Diffbot 的用法的讲解,后面我会继续介绍其他的智能化解析方法以及一些相关实战。
上一课时我们介绍了智能化解析技术的一些基本原理和效果,并且通过 Diffbot 体验了一下智能化解析能达到的效果。
但 Diffbot 是商业化应用,而且是收费的,本课时将再介绍几个开源的智能解析库,稍微分析一下它们的源码逻辑。虽然准确率并不是很高,但我们通过这些内容深入研究它的一些源码和实现,就可以对智能解析有更深入地认识。
目前来说,智能文本提取可以分为三类:
基于网页文档内容的提取方法
基于 DOM 结构信息的提取方法
基于视觉信息的提取方法
基于网页文档的提取方法将 HTML 文档视为文本进行处理,适用于处理含有大量文本信息且结构简单易于处理的单记录网页,或者具有实时要求的在线分析网页应用。 这种方式主要利用自然语言处理的相关技术实现,通过理解文本语义、分析上下文、设定提取规则等,实现对大段网页文档的快速处理。其中,较为知名的方法有 TSIMMIS、Web-OQL、Serrano、FAR-SW 和 FOREST,但这些方法通常需要人工的参与,且存在耗时长、效率低的弊端。
基于 DOM 结构信息的方法将 HTML 文档解析为相应的 DOM 树,然后根据 DOM 树的语法结构创建提取规则, 相对于以前的方法而言有了更高的性能和准确率。 W4F 和 XWRAP 将 HTML 文档解析成 DOM 树,然后通过组件化引导用户通过人工选择或者标记生成目标包装器代码。Omini、IEPAD 和 ITE 提取 DOM 树上的关键路径,获取其中存在的重复模式。MDR 和 DEPTA 挖掘了页面中的数据区域,得到数据记录的模式。CECWS 通过聚类算法从数据库中提取出自同一网站的一组页面,并进行 DOM 树结构的对比,删除其中的静态部分,保留动态内容作为信息提取的结果。
虽然此类方法相对于上一类方法具有较高的提取精度,且克服了对大段连续文本的依赖,但由于网页的 DOM 树通常较深,且含有大量 DOM 节点,因此基于 DOM 结构信息的方法具有较高的时间和空间消耗。目前来说,大部分原理还是基于 DOM 节点的文本密度、标点符号密度等计算的,其准确率还是比较可观的。今天所介绍的 Readability 和 Newspaper 的库,其实现原理是类似的。
目前比较先进的是基于视觉信息的网页信息提取方法,通过浏览器接口或者内核对目标网页预渲染,然后基于网页的视觉规律提取网页数据记录。经典的 VIPS 算法首先从 DOM 树中提取出所有合适的页面区域,然后根据这些页面和分割条重新构建 Web 页面的语义结构。作为对 VIPS 的拓展,ViNT、ViPER、ViDE 也成功利用了网页的视觉特征来实现数据提取。CMDR 为通过神经网络学习多记录型页面中的特征,结合基于 DOM 结构信息的 MDR 方法,挖掘社区论坛页面的数据区域。
与上述方法不同,VIBS 将图像领域的 CNN 卷积神经网络运用于网页的截图,同时通过类 VIPS 算法生成视觉块,最后结合两个阶段的结果识别网页的正文区域。另外还有最新的国内提出的 VBIE 方法,基于网页视觉的基础上改进,可以实现无监督的网页信息提取。
以上内容主要参考自论文:《王卫红等:基于可视块的多记录型复杂网页信息提取算法》,算法可从该论文参考文献查阅。
下面我们来介绍两个比较基础的工具包 Readability 和 Newspaper 的用法,这两个包经我测试其实准确率并不是很好,主要是让你大致对智能解析有初步的理解。后面还会介绍一些更加强大的智能化解析算法。
Readability 实际上是一个算法,并不是一个针对某个语言的库,其主要原理是计算了 DOM 的文本密度。另外根据一些常见的 DOM 属性如 id、class 等计算了一些 DOM 的权重,最后分析得到了对应的 DOM 区块,进而提取出具体的文本内容。
现在搜索 Readability 其实已经找不到了,取而代之的是一个 JavaScript 工具包,即 mercury-parser,据我所知 Readability 应该不维护了,换成了 mercury-parser。后者现在也做成了一个 Chrome 插件,大家可以下载使用一下。
回归正题,这次主要介绍的是 Python 的 Readability 实现,现在其实有很多开源版本,本课时选取的是 https://github.com/buriy/python-readability,是基于最早的 Python 版本的 Readability 库二次开发的,现在已经发布到了 PyPi,可以直接下载安装使用。
安装很简单,通过 pip 安装即可:
pip3 install readability-lxml
安装好了之后便可以通过导入 readability 使用了。我们随意从网上找一个新闻页面,其页面截图如下图所示:
我们的目的就是它的正文、标题等内容。下面用 Readability 试一下,示例如下:
import requestsfrom readability import Documenturl = 'https://tech.163.com/19/0909/08/EOKA3CFB00097U7S.html'html = requests.get(url).contentdoc = Document(html)print('title:', doc.title())print('content:', doc.summary(html_partial=True))
在这里直接用 requests 库对网页进行了请求,获取了其 HTML 页面内容,赋值为 html。
然后引入了 readability 里的 Document 类,使用 html 变量对其进行初始化,接着分别调用了 title 方法和 summary 方法获得了其标题和正文内容。
这里 title 方法就是获取文章标题的,summary 是获取文章正文的,但是它获取的正文可能包含了一些 HTML 标签。这个 summary 方法可以接收一个 html_partial 参数,如果设置为 true,返回的结果则不会再带有 <html><body>
标签。
看下运行结果:
title: 今年iPhone只有小改进?分析师:还有其他亮点_网易科技content: <div><div class="post_text" id="endText"> <p class="otitle"> (原标题:Apple Bets More Cameras Can Keep iPhone Humming) </p> <p class="f_center"><img alt="今年iPhone只有小改进?分析师:还有其他亮点" src="http://cms-bucket.ws.126.net/2019/09/09/d65ba32672934045a5bfadd27f704bc1.jpeg"/><span>图示:苹果首席执行官蒂姆·库克(Tim Cook)在6月份举行的苹果全球开发者大会上。</span></p><p>网易科技讯 9月9日消息,据国外媒体报道,和过去的12个年头一样,新款... 中间省略 ... <p>苹果还即将推出包括电视节目和视频游戏等内容的新订阅服务。分析师表示,该公司最早可能在本周宣布TV+和Arcade等服务的价格和上线时间。</p><p>Strategy Analytics的尼尔·莫斯顿(Neil Mawston)表示,可穿戴设备和服务的结合将是苹果业务超越iPhone的关键。他说,上一家手机巨头诺基亚公司在试图进行类似业务转型时就陷入了困境之中。(辰辰)</p><p><b>相关报道:</b></p><p><a href="https://tech.163.com/19/0908/09/EOHS53RK000999LD.html" target="_self" urlmacroreplace="false">iPhone 11背部苹果Logo改为居中:为反向无线充电</a></p><p><a href="https://tech.163.com/19/0907/08/EOF60CBC00097U7S.html" target="_self" urlmacroreplace="false">2019年新iPhone传言汇总,你觉得哪些能成真</a> </p><p/> <p/> <div class="ep-source cDGray"> <span class="left"><a href="http://tech.163.com/"><img src="https://static.ws.126.net/cnews/css13/img/end_tech.png" alt="王凤枝" class="icon"/></a> 本文来源:网易科技报道 </span> <span class="ep-editor">责任编辑:王凤枝_NT2541</span> </div> </div> </div>
可以看到,标题提取是正确的,正文其实也是正确的,不过这里还包含了一些 HTML 标签,比如 <img>
、<p>
等,我们可以进一步通过一些解析库来解析。
看下源码,比如提取标题的方法:
def normalize_entities(cur_title): entities = { u'\u2014':'-', u'\u2013':'-', u'—': '-', u'–': '-', u'\u00A0': ' ', u'\u00AB': '"', u'\u00BB': '"', u'"': '"', } for c, r in entities.items(): if c in cur_title: cur_title = cur_title.replace(c, r) return cur_titledef norm_title(title): return normalize_entities(normalize_spaces(title))def get_title(doc): title = doc.find('.//title') if title is None or title.text is None or len(title.text) == 0: return '[no-title]' return norm_title(title.text) def title(self): """Returns document title""" return get_title(self._html(True))
title 方法实际上就是调用了一个 get_title 方法,它是怎么做的呢?实际上就是用了一个 XPath 只解析了 <title>
标签里面的内容,别的没了。如果没有,那就返回 [no-title]。
def summary(self, html_partial=False): ruthless = True while True: self._html(True) for i in self.tags(self.html, 'script', 'style'): i.drop_tree() for i in self.tags(self.html, 'body'): i.set('id', 'readabilityBody') if ruthless: self.remove_unlikely_candidates() self.transform_misused_divs_into_paragraphs() candidates = self.score_paragraphs() best_candidate = self.select_best_candidate(candidates) if best_candidate: article = self.get_article(candidates, best_candidate, html_partial=html_partial) else: if ruthless: ruthless = False continue else: article = self.html.find('body') if article is None: article = self.html cleaned_article = self.sanitize(article, candidates) article_length = len(cleaned_article or '') retry_length = self.retry_length of_acceptable_length = article_length >= retry_length if ruthless and not of_acceptable_length: ruthless = False continue else: return cleaned_article
这里我删除了一些冗余的调试代码,只保留了核心代码,其核心实现就是先去除一些干扰内容,然后找出一些疑似正文的 candidates,接着再去寻找最佳匹配的 candidates,最后提取其内容返回即可。
然后再找到获取 candidates 方法里面的 score_paragraphs 方法,又追踪到一个 score_node 方法,就是为每一个节点打分的,其实现如下:
def score_node(self, elem): content_score = self.class_weight(elem) name = elem.tag.lower() if name in ["div", "article"]: content_score += 5 elif name in ["pre", "td", "blockquote"]: content_score += 3 elif name in ["address", "ol", "ul", "dl", "dd", "dt", "li", "form", "aside"]: content_score -= 3 elif name in ["h1", "h2", "h3", "h4", "h5", "h6", "th", "header", "footer", "nav"]: content_score -= 5 return { 'content_score': content_score, 'elem': elem }
这是什么意思呢?你看如果这个节点标签是 div 或者 article 等可能表征正文区块的话,就加 5 分;如果是 aside 等表示侧栏内容的话,就减 3 分。这些打分也没有什么非常标准的依据,可能是根据经验累积的规则。
另外还有一些方法里面引用了一些正则匹配来进行打分或者替换,其定义如下:
REGEXES = { 'unlikelyCandidatesRe': re.compile('combx|comment|community|disqus|extra|foot|header|menu|remark|rss|shoutbox|sidebar|sponsor|ad-break|agegate|pagination|pager|popup|tweet|twitter', re.I), 'okMaybeItsACandidateRe': re.compile('and|article|body|column|main|shadow', re.I), 'positiveRe': re.compile('article|body|content|entry|hentry|main|page|pagination|post|text|blog|story', re.I), 'negativeRe': re.compile('combx|comment|com-|contact|foot|footer|footnote|masthead|media|meta|outbrain|promo|related|scroll|shoutbox|sidebar|sponsor|shopping|tags|tool|widget', re.I), 'divToPElementsRe': re.compile('<(a|blockquote|dl|div|img|ol|p|pre|table|ul)', re.I), #'replaceBrsRe': re.compile('(<br[^>]*>[ \n\r\t]*){2,}',re.I), #'replaceFontsRe': re.compile('<(\/?)font[^>]*>',re.I), #'trimRe': re.compile('^\s+|\s+$/'), #'normalizeRe': re.compile('\s{2,}/'), #'killBreaksRe': re.compile('(<br\s*\/?>(\s| ?)*){1,}/'), 'videoRe': re.compile('https?:\/\/(www\.)?(youtube|vimeo)\.com', re.I), #skipFootnoteLink: /^\s*(\[?[a-z0-9]{1,2}\]?|^|edit|citation needed)\s*$/i,}
比如这里定义了 unlikelyCandidatesRe,就是不像 candidates 的 pattern,比如 foot、comment 等,碰到这样的标签或 pattern 的话,在计算分数的时候都会减分,另外还有其他的 positiveRe、negativeRe 也是一样的原理,分别对匹配到的内容进行加分或者减分。
这就是 Readability 的原理,即基于一些规则匹配的打分模型,很多规则其实来源于经验的累积,分数的计算规则应该也是不断地调优得出来的。
其他的就没了,Readability 并没有提供提取时间、作者的方法,另外此种方法的准确率也是有限的,但多少还是省去了一些人工成本。
另外还有一个智能解析的库,叫作 Newspaper,提供的功能更强一些,但是准确率上个人感觉和 Readability 差不太多。
这个库分为 Python2 和 Python3 两个版本,Python2 下的版本叫作 newspaper,Python3 下的版本叫作 newspaper3k。这里我们使用 Python3 版本来进行测试。
点击这里获取 GitHub 地址,点击这里获取官方文档地址。
在安装之前需要安装一些依赖库,点击这里可参考官方的说明。
安装好必要的依赖库之后,就可以使用 pip 安装了:
pip3 install newspaper3k
安装成功之后便可以导入使用了。
下面我们先用官方提供的实例来过一遍它的用法,其页面截图如下:
下面用一个实例来感受一下:
from newspaper import Articleurl = 'https://fox13now.com/2013/12/30/new-year-new-laws-obamacare-pot-guns-and-drones/'article = Article(url)article.download()# print('html:', article.html)article.parse()print('authors:', article.authors)print('date:', article.publish_date)print('text:', article.text)print('top image:', article.top_image)print('movies:', article.movies)article.nlp()print('keywords:', article.keywords)print('summary:', article.summary)
这里从 newspaper 库里面先导入了 Article 类,然后直接传入 url 即可。首先需要调用它的 download 方法,将网页爬取下来,否则直接进行解析会抛出错误。
但我总感觉这个设计挺不友好的,parse 方法不能判断下,如果没执行 download 就自动执行 download 方法吗?如果不 download 其他的不什么都干不了吗?
好的,然后我们再执行 parse 方法进行网页的智能解析,这个功能就比较全了,能解析 authors、publish_date、text 等,除了正文还能解析作者、发布时间等。
另外这个库还提供了一些 NLP 的方法,比如获取关键词、获取文本摘要等,在使用前需要先执行以下 nlp 方法。
最后运行结果如下:
authors: ['Cnn Wire']date: 2013-12-30 00:00:00text: By Leigh Ann CaldwellWASHINGTON (CNN) — Not everyone subscribes to a New Year’s resolution, but Americans will be required to follow new laws in 2014.Some 40,000 measures taking effect range from sweeping, national mandates under Obamacare to marijuana legalization in Colorado, drone prohibition in Illinois and transgender protections in California.Although many new laws are controversial, they made it through legislatures, public referendum or city councils and represent the shifting composition of American beliefs.......Colorado: Marijuana becomes legal in the state for buyers over 21 at a licensed retail dispensary.(Sourcing: much of this list was obtained from the National Conference of State Legislatures).top image: https://localtvkstu.files.wordpress.com/2012/04/national-news-e1486938949489.jpg?quality=85&strip=allmovies: []keywords: ['drones', 'national', 'guns', 'wage', 'law', 'pot', 'leave', 'family', 'states', 'state', 'latest', 'obamacare', 'minimum', 'laws']summary: Oregon: Family leave in Oregon has been expanded to allow eligible employees two weeks of paid leave to handle the death of a family member.Arkansas: The state becomes the latest state requiring voters show a picture ID at the voting booth.Minimum wage and former felon employmentWorkers in 13 states and four cities will see increases to the minimum wage.New Jersey residents voted to raise the state’s minimum wage by $1 to $8.25 per hour.California is also raising its minimum wage to $9 per hour, but workers must wait until July to see the addition.
这里省略了一些输出结果。
可以看到作者、日期、正文、关键词、标签、缩略图等信息都被打印出来了,还算是不错的。
但这个毕竟是官方的实例,肯定是好的。我们再测试一下刚才的例子,看看效果如何(点击这里网址链接),改写代码如下:
from newspaper import Articleurl = 'https://tech.163.com/19/0909/08/EOKA3CFB00097U7S.html'article = Article(url, language='zh')article.download()# print('html:', article.html)article.parse()print('authors:', article.authors)print('title:', article.title)print('date:', article.publish_date)print('text:', article.text)print('top image:', article.top_image)print('movies:', article.movies)article.nlp()print('keywords:', article.keywords)print('summary:', article.summary)
这里我们将链接换成了新闻的链接,另外在 Article 初始化的时候还加了一个参数 language,其值为 zh,代表中文。
然后我们看下运行结果:
Building prefix dict from /usr/local/lib/python3.7/site-packages/jieba/dict.txt ...Dumping model to file cache /var/folders/1g/l2xlw12x6rncs2p9kh5swpmw0000gn/T/jieba.cacheLoading model cost 1.7178938388824463 seconds.Prefix dict has been built succesfully.authors: []title: 今年iPhone只有小改进?分析师:还有其他亮点date: 2019-09-09 08:10:26+08:00text: (原标题:Apple Bets More Cameras Can Keep iPhone Humming)图示:苹果首席执行官蒂姆·库克(Tim Cook)在6月份举行的苹果全球开发者大会上。网易科技讯 9月9日消息,据国外媒体报道,和过去的12个年头一样,新款iPhone将成为苹果公司本周所举行年度宣传活动的主角。但人们的注意力正转向需要推动增长的其他苹果产品和服务。......Strategy Analytics的尼尔·莫斯顿(Neil Mawston)表示,可穿戴设备和服务的结合将是苹果业务超越iPhone的关键。他说,上一家手机巨头诺基亚公司在试图进行类似业务转型时就陷入了困境之中。(辰辰)相关报道:iPhone 11背部苹果Logo改为居中:为反向无线充电2019年新iPhone传言汇总,你觉得哪些能成真top image: https://www.163.com/favicon.icomovies: []keywords: ['trust高级投资组合经理丹摩根dan', 'iphone', 'mawston表示可穿戴设备和服务的结合将是苹果业务超越iphone的关键他说上一家手机巨头诺基亚公司在试图进行类似业务转型时就陷入了困境之中辰辰相关报道iphone', 'xs的销售疲软状况迫使苹果在1月份下调了业绩预期这是逾15年来的第一次据贸易公司susquehanna', 'xs机型发布后那种令人失望的业绩重演iphone', '今年iphone只有小改进分析师还有其他亮点', 'more', 'xr和iphone', 'morgan说他们现在没有任何真正深入的进展只是想继续让iphone这款业务继续转下去他乐观地认为今年发布的新款手机将有足够多的新功能为一个非常成熟的产品增加额外的功能让火车继续前进这种仅限于此的态度说明了苹果自2007年发布首款iphone以来所面临的挑战iphone销售占苹果公司总营收的一半以上这让苹果陷入了一个尴尬的境地既要维持核心产品的销量另一方面又需要减少对它的依赖瑞银ubs今年5月份对8000名智能手机用户进行了相关调查其发布的年度全球调查报告显示最近iphone在人脸识别技术等方面的进步并没有引起一些消费者的共鸣他们基本上都认为苹果产品没有过去几年那么独特或者惊艳品牌也没有过去几年那么有吸引力很多人使用老款手机的时间更长自己认为也没有必要升级到平均售价949美元的新款iphone苹果需要在明年销售足够多的iphone以避免像去年9月份iphone', 'keep', '原标题apple']summary: (原标题:Apple Bets More Cameras Can Keep iPhone Humming)图示:苹果首席执行官蒂姆·库克(Tim Cook)在6月份举行的苹果全球开发者大会上。网易科技讯 9月9日消息,据国外媒体报道,和过去的12个年头一样,新款iPhone将成为苹果公司本周所举行...亚公司在试图进行类似业务转型时就陷入了困境之中。(辰辰)相关报道:iPhone 11背部苹果Logo改为居中:为反向无线充电2019年新iPhone传言汇总,你觉得哪些能成真
由于中间正文很长,这里省略了一部分,可以看到运行时首先加载了一些中文的库包,比如 jieba 所依赖的词表等。
解析结果中,日期的确是解析对了,因为这个日期格式的确比较规整,但这里还自动给我们加了东八区的时区,贴心了。作者没有提取出来,可能是没匹配到 来源 两个字吧,或者词库里面没有,标题、正文的提取还算比较正确,也或许这个案例的确比较简单。
另外对于 NLP 部分,获取的关键词长度有点太长了,summary 也有点冗余。
另外 Newspaper 还提供了一个较为强大的功能,就是 build 构建信息源。官方的介绍其功能就是构建一个新闻源,可以根据传入的 URL 来提取相关文章、分类、RSS 订阅信息等。
我们用实例感受一下:
import newspapersource = newspaper.build('http://www.sina.com.cn/', language='zh')for category in source.category_urls(): print(category)for article in source.articles: print(article.url) print(article.title)for feed_url in source.feed_urls(): print(feed_url)
在这里我们传入了新浪的官网,调用了 build 方法,构建了一个 source,然后输出了相关的分类、文章、RSS 订阅等内容,运行结果如下:
http://cul.news.sina.com.cnhttp://www.sina.com.cn/http://sc.sina.com.cnhttp://jiangsu.sina.com.cnhttp://gif.sina.com.cn....http://tj.sina.com.cnhttp://travel.sina.com.cnhttp://jiaoyi.sina.com.cnhttp://cul.sina.com.cnhttps://finance.sina.com.cn/roll/2019-06-12/doc-ihvhiqay5022316.shtml 经参头版:激发微观主体活力加速国企改革http://eladies.sina.com.cn/feel/xinli/2018-01-25/0722/doc-ifyqwiqk0463751.shtml 我们别再联系了http://finance.sina.com.cn/roll/2018-05-13/doc-ihamfahx2958233.shtml 新违约时代到来!违约“常态化”下的市场出清与换血http://sports.sina.com.cn/basketball/2019worldcup/2019-09-08/doc-iicezzrq4390554.shtml 罗健儿26分韩国收首胜...http://travel.sina.com.cn/outbound/pages/2019-09-05/detail-iicezzrq3622449.shtml 菲律宾海滨大道 夜晚让人迷离http://travel.sina.com.cn/outbound/pages/2016-08-19/detail-ifxvcnrv0334779.shtml 关岛 用双脚尽情享受阳光与海滩http://travel.sina.com.cn/domestic/pages/2019-09-04/detail-iicezzrq3325092.shtml 秋行查干浩特草原http://travel.sina.com.cn/outbound/pages/2019-09-03/detail-iicezueu3050710.shtml 白羊座的土豪之城迪拜http://travel.sina.com.cn/video/baidang/2019-08-29/detail-ihytcitn2747327.shtml 肯辛顿宫藏着维多利亚的秘密http://cd.auto.sina.com.cn/bdcs/2017-08-15/detail-ifyixias1051586.shtml
可以看到它输出了非常多的类别链接,另外还有很多文章列表,由于没有 RSS 订阅内容,这里没有显示。
下面把站点换成我的博客,博客截图如下:
看看运行结果:
https://cuiqingcai.comhttps://cuiqingcai.com
似乎一篇文章都没有,RSS 也没有,可见其功能还有待优化。
Newspaper 的基本用法先介绍到这里,更加详细的用法可以参考官方文档:https://newspaper.readthedocs.io。个人感觉其中的智能解析可以用用,不过据我的个人经验,感觉还是很多解析不对或者解析不全的。
以上便是 Readability 和 Newspaper 的介绍。
另外除了这两个库其实还有一些比较优秀的算法,由于我们处理的大多数为中文文档,所以一些在中文上面的研究还是比较有效的,在这里列几个值得借鉴的中文论文供大家参考:
洪鸿辉等,基于文本及符号密度的网页正文提取方法
梁东等,基于支持向量机的网页正文内容提取方法
王卫红等,基于可视块的多记录型复杂网页信息提取算法
后面我们还会再根据一些论文的基本原理并结合一些优秀的开源实现来深入讲解智能解析算法。
在前面的课时中我们了解了智能化解析技术的一些提取效果和相关开源工具,接下来我们就来深入剖析一下智能解析算法的原理。
我们还是以新闻网站为例,比如这个链接:https://news.ifeng.com/c/7kQcQG2peWU,页面预览如图所示:
在第一节我们讲解了利用 Diffbot 提取其中的标题、发布人、发布时间、正文、图片等信息,准确率还是不错的,但这毕竟是一个收费服务,我们并不能了解到其实现原理。
接下来我们就来深入剖析一下这些字段的解析方法,虽然本节介绍的不一定是准确率最高、最前沿的方法,但是经过一些验证,其效果还是相对不错的。
本节我们会针对新闻详情页,介绍如下几个字段的智能解析方法:
标题
正文
发布时间
作者
这四个字段是新闻详情页里面最重要的信息,所以这里主要就介绍这四个字段的提取方法。
一般来说,标题是相对比较好提取的,因为一般新闻会把标题放在 title 这个标签之下,比如 https://news.ifeng.com/c/7kQcQG2peWU 这个链接,我们可以看一下网页 title 部分的源码,内容如下:
<title>故宫,你低调点!故宫:不,实力已不允许我继续低调_凤凰网资讯_凤凰网</title>
这里如果我们直接提取的话,得到的内容如下:
故宫,你低调点!故宫:不,实力已不允许我继续低调_凤凰网资讯_凤凰网
但实际上,title 的内容应该为:
故宫,你低调点!故宫:不,实力已不允许我继续低调
所以,如果我们一味地提取 title 标签内的内容是不准确的,因为网站本身会额外增加一些信息,比如拼接上站点本身的信息等等。
那这个时候怎么办呢?在绝大部分情况下,标题是通过 h 节点来表示的,一般为 h1、h2、h3、h4 等,其内部的文本一般就代表完整的标题,那问题又来了,HTML 里面那么多 h 节点,我们又怎么确定标题对应的是哪个 h 节点呢?
答案你应该也想到了,我们用 title 的内容和 h 节点的内容结合起来不就好判断了吗?
这里我们就能总结出两个提取思路:
提取页面的 h 节点,如 h1、h2 等节点内容,然后将内容和 title 的文本进行比对,找出和 title 最相似的内容。比对的方法可以使用编辑距离或最长公共子串。
如果未找到 h 节点,则只能使用 title 节点。
一般来说,使用以上方法几乎可以应对 90% 以上标题的提取。
另外如果某些网站为了 SEO 效果比较好,通常会添加一些 meta 标签,如 url、title、keywords、category 等信息,这些信息也可以成为一些参考依据,进一步校验或补充网站的基本信息。
比如在上面的例子中,我们可以看到有一个 meta 节点,其内容如下:
<meta property="og:title" content="故宫,你低调点!故宫:不,实力已不允许我继续低调">
这里我们可以看到这个 meta 节点指定了 property 为 og:title
,这是一种常见写法,其内容正好就是标题的信息,通过这部分信息我们也能进行标题的提取。
因此,综上所述,结合 meta、title、h 节点,我们就可以应对绝大多数标题的提取了。
正文可以说是新闻页面最难提取且最为重要的部分了,如果我们不能有效地把正文内容提取出来,那么这次解析就算是失败了一大半了。
在之前介绍过的 Readability 算法中,我们大致上可以得知其中有一个打分算法,比如我们可以将 HTML 代码进行解析,形成 DOM 树,然后对其中的每个节点进行打分,比如给非正文的节点 style、script、sidebar、footer 等打负分,对一些 p、article 节点打正分,最后得到一系列节点打分结果,但如果仅仅靠这种算法,其准确率还是比较低的。
但在这个过程中,我们可以得到一些启发,比如:
正文内容一般会被包含在 body 节点的 p 节点中,而且 p 节点一般不会独立存在,一般都会存在于 div 等节点内。
正文内容对应的 p 节点也不一定全都是正文内容,可能掺杂其他的噪声,如网站的版权信息、发布人、文末广告等,这部分属于噪声。
正文内容对应的 p 节点中会夹杂一些 style、script 等节点,并非正文内容。
正文内容对应的 p 节点内可能包含 code、span 等节点,这些大部分属于正文中的特殊样式字符,多数情况下也需要归类到正文中。
受开源项目 https://github.com/kingname/GeneralNewsExtractor 和论文《洪鸿辉,等 基于文本符号密度的网页正文提取方法》的启发,我们得知了一个有效的正文文本提取依据指标,那就是文本密度。
那么文本密度是什么呢?其实就类似单位节点所包含的文字个数。我们借用上述论文的内容,定义一个节点的文本密度:
如果 i 为 HTML DOM 树中的一个节点,那么该节点的文本密度定义为:
这里四个指标你需要好好理解下,其实就基本上等同于单位标签内所包含的文字个数,但这里额外考虑了超链接的情况。因为一般来说,正文中所带的超链接是比较少的,而对于一些侧边栏、底栏一些区域,带有超链接的比率是非常高的,文本密度就会低下来,因此就容易排除了。
另外论文还提到了一个指标,叫作符号密度。论文研究发现,正文中一般都带有标点符号,而网页链接、广告信息由于文字比较少,通常是不包含标点符号的,所以我们可以通过符号密度来排除一些内容。
符号密度的定义如下:
这里符号密度为文字数量和符号数量的比值。
在论文中,通过二者结合来提取,正文提取的准确率几乎可以达到 99%,论文作者对以上算法进行了评测,计算了在不同网站上 Precision、Recall、F1 值,结果如下:
可以看到该算法在凤凰网新闻上的表现整体可以达到 95% 以上。
另外除了基于密度的算法,我们还可以结合视觉来对正文进行识别。一般来说,正文所占的版面是最大的,所以我们可以通过计算节点所占区域的大小来排除一些干扰,比如如果我们查找到两块内容都疑似正文区域,而这两块区域的网页面积却一个很大,一个很小,那么面积大的是正文内容的可能性会更高。
对于发布时间来说,也是有一些线索供提取的。
一些正规的网站同样会把时间信息放到 meta 节点内,如上述例子中就多了这样的一个 meta 节点,内容如下:
<meta name="og:time " content="2019-02-20 02:26:00">
这里我们可以看到这个 meta 节点指定了 property 为 og:time
,这是一种常见写法,其内容正好就是时间的信息,通过这部分信息我们也能进行时间的提取。
但并不是所有的网页都会包含这个 meta 节点,多数情况下网站其实是没有这个节点的。
那么怎么办呢?我们知道其实时间通常都有固定的一些写法,如 2019-02-20 02:26:00,而且对于发布时间来说,其通常会包含一些关键的字符,如「发布」、「发表于」等关键字也可以作为参考依据,所以利用一些正则表达式匹配往往能起到不错的效果。
所以说,对于时间的提取,我们可以定义一些时间的正则表达式,基于某种特定的模式来提取时间信息。
这时候有人就会说了,如果这篇文章本身包含了时间,或者在侧栏或底栏部分包含了时间,那又怎么办呢?
对于文章本身包含时间的情况,我们可以根据上一步的结果,对正文内容对应的节点从节点列表里面删除,这样就会排除文章本身的干扰了。
对于侧栏或底栏部分包含了时间的情况又怎么分辨呢?这时候我们可以根据节点距离来进行计算。比如发布时间往往和正文距离较近或者紧贴着,而侧栏或底栏的时间却又分布在其他的区块,所以这些日期节点和正文节点相对较远,这样就能找到权重最高的时间节点了。
因此,综上所述,时间的提取标准有:
根据 meta 节点的信息进行提取。
根据一些正则表达式来提取。
根据节点和正文的距离来筛选更优节点。
这个字段其实相对不太好提取,但是它的重要性相对会低一点,因此如果错误率相比其他的字段有一定的升高的话,还是能够忍受的。
还是上文所述的内容,一些标准的网站会把 author 信息也加到 meta 节点里面,所以我们可以根据这个信息来提取。
其他的情况我们同样需要根据一些固定的写法来匹配了,如一些关键字“作者”“编辑”“撰稿”,等等关键字。另外我们还可以根据一些常用的姓氏来进行一些优化和提取。
如果在提取的时候有多个候选,我们还可以利用上一步的结果,那就是和日期节点之间的距离来判断,因为一般来说,作者信息旁边大概率会有时间的相关信息,这个指标也可以成为筛选的依据。
因此,综上所述,作者的提取标准有:
根据 meta 节点的信息进行提取。
根据一些固定的关键词写法,用正则表达式来提取。
根据一些常用的姓氏来对提取结果进行筛选。
对和时间节点之间的距离进行计算,同样也可以成为筛选的依据。
好了,本节我们就大体介绍了智能解析算法的提取原理,在下一节我们会用代码来实现其中的一些解析算法,下节课见。
在上一节课我们介绍了智能解析算法的实现原理,接下来我们就一起动手用代码来实现一下智能解析算法吧。
这里使用的案例还是凤凰网的一篇资讯文章,链接为:http://news.ifeng.com/c/7kQcQG2peWU,本节我们主要实现的提取字段为标题、时间、正文内容。
我们会用 Python 来对上一节讲解的智能解析算法进行实现,实现新闻内容的提取。
首先让我们将上述 URL 打开,然后在浏览器里面打开开发者工具,并打开 Elements 选项卡,最后把 HTML 代码复制下来,如图所示:
复制下来之后我们把源代码保存成一个 html 文件,名字叫作 sample.html。
然后我们定义如下代码,将 html 里面的字符转化成 lxml 里面的 HtmlElement 对象,代码如下:
from lxml.html import HtmlElement, fromstringhtml = open('sample.html', encoding='utf-8').read()element = fromstring(html=html)
这里 element 对象其实就是整个网页对应的 HtmlElement 对象,其根节点就是 html,下面我们会用到它来进行页面解析,从这个 HtmlElement 对象里面提取出我们想要的时间、标题、正文内容。
对于时间来说,我们这里就根据两个方面来进行提取,一个就是 meta 标签,如果里面包含了发布时间的相关信息,一般提取出来就是对的,可信度非常高,如果提取不到,那就用正则表达式来匹配一些时间规则进行提取。
首先我们就来进行 meta 标签的提取,这里我们列出来了一些用来匹配发布时间的 XPath 规则,内容如下:
METAS = [ '//meta[starts-with(@property, "rnews:datePublished")]/@content', '//meta[starts-with(@property, "article:published_time")]/@content', '//meta[starts-with(@property, "og:published_time")]/@content', '//meta[starts-with(@property, "og:release_date")]/@content', '//meta[starts-with(@itemprop, "datePublished")]/@content', '//meta[starts-with(@itemprop, "dateUpdate")]/@content', '//meta[starts-with(@name, "OriginalPublicationDate")]/@content', '//meta[starts-with(@name, "article_date_original")]/@content', '//meta[starts-with(@name, "og:time")]/@content', '//meta[starts-with(@name, "apub:time")]/@content', '//meta[starts-with(@name, "publication_date")]/@content', '//meta[starts-with(@name, "sailthru.date")]/@content', '//meta[starts-with(@name, "PublishDate")]/@content', '//meta[starts-with(@name, "publishdate")]/@content', '//meta[starts-with(@name, "PubDate")]/@content', '//meta[starts-with(@name, "pubtime")]/@content', '//meta[starts-with(@name, "_pubtime")]/@content', '//meta[starts-with(@name, "weibo: article:create_at")]/@content', '//meta[starts-with(@pubdate, "pubdate")]/@content',]
在这里我们就定义一个 extract_by_meta 的方法,它接受一个 HtmlElement 对象,定义如下:
def extract_by_meta(element: HtmlElement) -> str: for xpath in METAS: datetime = element.xpath(xpath) if datetime: return ''.join(datetime)
这里我们其实就是对 METAS 进行逐个遍历,然后查找整个 HtmlElement 里面是不是有匹配的内容,比如说:
//meta[starts-with(@property, "og:published_time")]/@content
这个就是查找 meta 节点中是不是存在以 og:published_time 开头的 property 属性,如果存在,那就提取出其中的 content 属性内容。
比如说我们的案例中刚好有一个 meta 节点,内容为:
<meta name="og:time " content="2019-02-20 02:26:00">
经过处理,它会匹配到这个 XPath 表达式:
//meta[starts-with(@name, "og:time")]/@content
这样其实 extract_by_meta 方法就成功匹配到时间信息,然后提取出 2019-02-20 02:26:00 这个值了。
这就相当于时间提取的第一步成功了,而且一般来说匹配到的结果可信度都是非常高的,我们可以直接将这个内容返回作为最终的提取结果即可。
可是并不是所有的页面都会包含这个 meta 标签,如果不包含的话,我们还需要进行第二步的提取。
下面我们再来实现第二步,也就是根据一些时间正则表达式来进行提取的方法。这里我们其实就是定义一些时间的正则表达式写法,内容如下:
REGEXES = [ "(\d{4}[-|/|.]\d{1,2}[-|/|.]\d{1,2}\s*?[0-1]?[0-9]:[0-5]?[0-9]:[0-5]?[0-9])", "(\d{4}[-|/|.]\d{1,2}[-|/|.]\d{1,2}\s*?[2][0-3]:[0-5]?[0-9]:[0-5]?[0-9])", "(\d{4}[-|/|.]\d{1,2}[-|/|.]\d{1,2}\s*?[0-1]?[0-9]:[0-5]?[0-9])", "(\d{4}[-|/|.]\d{1,2}[-|/|.]\d{1,2}\s*?[2][0-3]:[0-5]?[0-9])", "(\d{4}[-|/|.]\d{1,2}[-|/|.]\d{1,2}\s*?[1-24]\d时[0-60]\d分)([1-24]\d时)", "(\d{2}[-|/|.]\d{1,2}[-|/|.]\d{1,2}\s*?[0-1]?[0-9]:[0-5]?[0-9]:[0-5]?[0-9])", "(\d{2}[-|/|.]\d{1,2}[-|/|.]\d{1,2}\s*?[2][0-3]:[0-5]?[0-9]:[0-5]?[0-9])", "(\d{2}[-|/|.]\d{1,2}[-|/|.]\d{1,2}\s*?[0-1]?[0-9]:[0-5]?[0-9])", "(\d{2}[-|/|.]\d{1,2}[-|/|.]\d{1,2}\s*?[2][0-3]:[0-5]?[0-9])", "(\d{2}[-|/|.]\d{1,2}[-|/|.]\d{1,2}\s*?[1-24]\d时[0-60]\d分)([1-24]\d时)", ... "(\d{4}[-|/|.]\d{1,2}[-|/|.]\d{1,2})", "(\d{2}[-|/|.]\d{1,2}[-|/|.]\d{1,2})", "(\d{4}年\d{1,2}月\d{1,2}日)", "(\d{2}年\d{1,2}月\d{1,2}日)", "(\d{1,2}月\d{1,2}日)"]
由于内容比较多,这里省略了部分内容。其实这里就是一些日期的常见写法格式,由于日期的写法是有限的,所以我们通过一些有限的正则表达就能进行匹配。
接下来我们就定义一个正则搜索的方法,实现如下:
import redef extract_by_regex(element: HtmlElement) -> str: text = ''.join(element.xpath('.//text()')) for regex in REGEXES: result = re.search(regex, text) if result: return result.group(1)
这里我们先查找了 element 的文本内容,然后对文本内容进行正则表达式搜索,符合条件的就直接返回。
最后,时间提取的方法我们直接定义为:
extract_by_meta(element) or extract_by_regex(element)
即可,这样就会优先提取 meta,其次根据正则表达式提取。
接下来我们来实现标题的提取,根据上节内容,标题的提取我们在这里实现三个来源的提取:
查找 meta 节点里面的标题信息。
查找 title 节点的标题信息。
查找 h 节点的信息。
首先就是从 meta 节点提取,其实过程还是类似的,我们定义如下的 meta 节点的 XPath 提取规则,内容如下:
METAS = [ '//meta[starts-with(@property, "og:title")]/@content', '//meta[starts-with(@name, "og:title")]/@content', '//meta[starts-with(@property, "title")]/@content', '//meta[starts-with(@name, "title")]/@content', '//meta[starts-with(@property, "page:title")]/@content',]
实现的提取方法也是完全一样的:
def extract_by_meta(element: HtmlElement) -> str: for xpath in METAS: title = element.xpath(xpath) if title: return ''.join(title)
关于这一部分就不再展开说明了。
接下来我们还可以提取 title 和 h 节点的信息,通过基本的 XPath 表达式就可以实现,代码如下:
def extract_by_title(element: HtmlElement): return ''.join(element.xpath('//title//text()')).strip()def extract_by_h(element: HtmlElement): return ''.join( element.xpath('(//h1//text() | //h2//text() | //h3//text())')).strip()
这里我们提取了 title、h1~h3 节点的信息,然后返回了它们的纯文本内容。
紧接着,我们分别调用以下这三个方法,看看针对这个案例,其结果是怎样的,调用如下:
title_extracted_by_meta = extract_by_meta(element)title_extracted_by_h = extract_by_h(element)title_extracted_by_title = extract_by_title(element)
运行结果如下:
title_extracted_by_meta 故宫,你低调点!故宫:不,实力已不允许我继续低调title_extracted_by_h 故宫,你低调点!故宫:不,实力已不允许我继续低调为您推荐精品有声好书精选title_extracted_by_title 故宫,你低调点!故宫:不,实力已不允许我继续低调_凤凰网资讯_凤凰网
这里我们观察到,三个方法都返回了差不多的结果,但是后缀还是不太一样。
title_extracted_by_meta 实际上是完全正确的内容,可以直接返回,一般来说,它的可信度也非常高,如果匹配到,那就直接返回就好了。
但是如果不存在 title_extracted_by_meta 的结果呢?那只能靠 title_extracted_by_title 和 title_extracted_by_h 了。
这里我们观察到 title_extracted_by_title 和 title_extracted_by_h 包含一些冗余信息,仔细想想确实是这样的,因为 title 一般来说会再加上网站的名称,而 h 节点众多,通常会包含很多噪音。
这里我们可以提取它们的公共连续内容其实就好了,这里用到一个算法,就是最长连续公共子串,即 Longest Common String,这里我们直接借助于 Python 的 difflib 库来实现即可,如果你感兴趣的话也可以手动实现一下。
这里我们的实现如下:
from difflib import SequenceMatcherdef lcs(a, b): match = SequenceMatcher(None, a, b).find_longest_match(0, len(a), 0, len(b)) return a[match[0]: match[0] + match[2]]
这里定义了一个 lcs 方法,它接收两个字符串类型的参数,比如 abcd
和 bcde
,那么它的返回结果就是它们的公共部分,即 bcd
。
好,那么对于 title_extracted_by_title 和 title_extracted_by_h,我们调用下 lcs 方法就好了,实现如下:
lcs(title_extracted_by_title, title_extracted_by_h)
最终我们可以把标题的提取定义成一个方法,实现如下:
def extract_title(element: HtmlElement): title_extracted_by_meta = extract_by_meta(element) title_extracted_by_h = extract_by_h(element) title_extracted_by_title = extract_by_title(element) if title_extracted_by_meta: return title_extracted_by_meta if title_extracted_by_title and title_extracted_by_h: return lcs(title_extracted_by_title, title_extracted_by_h) if title_extracted_by_title: return title_extracted_by_title return title_extracted_by_h
这里我们就定义了一些优先级判定逻辑,如:
如果存在 title_extracted_by_meta,由于其可信度非常高,直接返回即可。
如果不存在 title_extracted_by_meta,而 title_extracted_by_title 和 title_extracted_by_h 同时存在,取二者的最长公共子串返回即可。
如果上述条件不成立, title_extracted_by_title 存在,返回 title_extracted_by_title 即可。
如果上述条件不成立,只能返回 title_extracted_by_h 了。
以上就是我们的标题提取逻辑。
接下来终于轮到重头戏,正文提取了。在上一节课我们介绍了利用文本密度和符号密度进行提取的方法,下面我们就来实现一下吧。
正文的提取需要我们做一些预处理工作,比如一个 html 标签内有很多噪音,非常影响正文的提取,比如说 script、style 这些内容,一定不会包含正文,但是它们会严重影响文本密度的计算,所以这里我们先定义一个预处理操作。
from lxml.html import HtmlElement, etreeCONTENT_USELESS_TAGS = ['meta', 'style', 'script', 'link', 'video', 'audio', 'iframe', 'source', 'svg', 'path', 'symbol', 'img']CONTENT_STRIP_TAGS = ['span', 'blockquote']CONTENT_NOISE_XPATHS = [ '//div[contains(@class, "comment")]', '//div[contains(@class, "advertisement")]', '//div[contains(@class, "advert")]', '//div[contains(@style, "display: none")]',]def preprocess4content(element: HtmlElement): # remove tag and its content etree.strip_elements(element, *CONTENT_USELESS_TAGS) # only move tag pair etree.strip_tags(element, *CONTENT_STRIP_TAGS) # remove noise tags remove_children(element, CONTENT_NOISE_XPATHS) for child in children(element): # merge text in span or strong to parent p tag if child.tag.lower() == 'p': etree.strip_tags(child, 'span') etree.strip_tags(child, 'strong') if not (child.text and child.text.strip()): remove_element(child) # if a div tag does not contain any sub node, it could be converted to p node. if child.tag.lower() == 'div' and not child.getchildren(): child.tag = 'p'
这里我们定义了一些规则,比如 CONTENT_USELESS_TAGS 代表一些噪音节点,可以直接调用 strip_elements 把整个节点和它的内容删除。
另外定义了 CONTENT_STRIP_TAGS ,这些节点文本内容需要保留,但是它的标签是可以删掉的。
另外我们还定义了 CONTENT_NOISE_XPATHS,这是一些很明显不是正文的节点,如评论、广告等,直接移除就好。
这里还依赖于几个工具方法,定义如下:
def remove_element(element: HtmlElement): parent = element.getparent() if parent is not None: parent.remove(element)def remove_children(element: HtmlElement, xpaths=None): if not xpaths: return for xpath in xpaths: nodes = element.xpath(xpath) for node in nodes: remove_element(node) return elementdef children(element: HtmlElement): yield element for child_element in element: if isinstance(child_element, HtmlElement): yield from children(child_element)
另外对于一些节点我们还做了特殊处理,如 p 节点内部的 span、strong 节点去掉标签,只留内容。如果是 div 节点,而且没有子节点了,那么可以换成 p 节点。
当然还有一些细节的处理,你如果想到了可以继续优化。预处理完毕之后,整个 element 就比较规整了,去除了很多噪声和干扰数据。
接下来我们就来实现文本密度和符号密度的计算吧。为了方便处理,这里我把节点定义成了一个 Python Object,名字叫作 ElementInfo,它里面有很多字段,代表了某一个节点的信息,比如文本密度、符号密度等,定义如下:
from lxml.html import HtmlElementfrom pydantic import BaseModelclass ElementInfo(BaseModel): id: int = None tag_name: str = None element: HtmlElement = None number_of_char: int = 0 number_of_linked_char: int = 0 number_of_tag: int = 0 number_of_linked_tag: int = 0 number_of_p_tag: int = 0 number_of_punctuation: int = 0 density_of_punctuation: int = 1 density_of_text: int = 0 density_score: int = 0 class Config: arbitrary_types_allowed = True
这里我们定义了几个字段。
id:节点的唯一 id。
tag_name:节点的标签值,如 p、div、img 等。
element:节点对应的 HtmlElement 对象。
number_of_char:节点的总字符数。
number_of_linked_char:节点带超链接的字符数。
number_of_tag:节点的标签数。
number_of_linked_tag:节点的带链接的标签数,即 a 的标签数。
number_of_p_tag:节点的 p 标签数。
number_of_punctuation:节点包含的标点符号数。
density_of_punctuation:节点的符号密度。
density_of_text:节点的文本密度。
density_score:最终评分。
好,下面我们要做的就是对整个 HTML 的所有节点进行处理,然后得到每个节点的信息,实现如下:
# start to evaluate every child elementelement_infos = []child_elements = children_of_body(element)for child_element in child_elements: # new element info element_info = ElementInfo() element_info.element = child_element element_info = fill_element_info(element_info) element_infos.append(element_info)
这里我们先调用了 children_of_body 获取了最初 element 节点的所有子节点,然后对节点进行处理。
其中这里依赖 children_of_body 和 fill_element_info 方法,分别是获取所有 body 内的子节点(包括 body)以及计算节点信息,实现如下:
def children_of_body(element: HtmlElement): body_xpath = '//body' elements = element.xpath(body_xpath) if elements: return children(elements[0]) return []def fill_element_info(element_info: ElementInfo): element = element_info.element # fill id element_info.id = hash(element) element_info.tag_name = element.tag # fill number_of_char element_info.number_of_char = number_of_char(element) element_info.number_of_linked_char = number_of_linked_char(element) element_info.number_of_tag = number_of_tag(element) element_info.number_of_linked_tag = number_of_linked_tag(element) element_info.number_of_p_tag = number_of_p_tag(element) element_info.number_of_punctuation = number_of_punctuation(element) # fill density element_info.density_of_text = density_of_text(element_info) element_info.density_of_punctuation = density_of_punctuation(element_info) return element_info
这里 fill_element_info 方法非常重要,其实就是填充了 element_info 的几乎所有指标信息,这里又依赖了 number_of_char、number_of_linked_char、number_of_tag、number_of_linked_tag、number_of_p_tag、number_of_punctuation、density_of_text、density_of_punctuation 方法,实现如下:
def number_of_char(element: HtmlElement): text = ''.join(element.xpath('.//text()')) text = re.sub(r'\s*', '', text, flags=re.S) return len(text)def number_of_linked_char(element: HtmlElement): text = ''.join(element.xpath('.//a//text()')) text = re.sub(r'\s*', '', text, flags=re.S) return len(text)def number_of_tag(element: HtmlElement): return len(element.xpath('.//*'))def number_of_p_tag(element: HtmlElement): return len(element.xpath('.//p'))def number_of_linked_tag(element: HtmlElement): return len(element.xpath('.//a'))def density_of_text(element_info: ElementInfo): # if denominator is 0, just return 0 if element_info.number_of_tag - element_info.number_of_linked_tag == 0: return 0 return (element_info.number_of_char - element_info.number_of_linked_char) / \ (element_info.number_of_tag - element_info.number_of_linked_tag)def density_of_punctuation(element_info: ElementInfo): result = (element_info.number_of_char - element_info.number_of_linked_char) / \ (element_info.number_of_punctuation + 1) # result should not be zero return result or 1def number_of_punctuation(element: HtmlElement): text = ''.join(element.xpath('.//text()')) text = re.sub(r'\s*', '', text, flags=re.S) punctuations = [c for c in text if c in PUNCTUATION] return len(punctuations)
这里比较重要的就是 density_of_text 和 density_of_punctuation 两个方法了,分别代表文本密度和符号密度,其算法原理在上一节已经提到了,可以参考论文《洪鸿辉,等 基于文本富豪密度的网页正文提取方法》的内容。
好,这样我们运行完毕之后,就可以得到每个节点的各个指标啦。最后,我们继续参考论文《洪鸿辉,等 基于文本富豪密度的网页正文提取方法》的公式,根据各个指标计算每个节点的得分情况,最后提取内容即可:
import numpy as np# start to evaluate every child elementelement_infos = []child_elements = children_of_body(element)for child_element in child_elements: # new element info element_info = ElementInfo() element_info.element = child_element element_info = fill_element_info(element_info) element_infos.append(element_info) # get std of density_of_text among all elements density_of_text = [element_info.density_of_text for element_info in element_infos] density_of_text_std = np.std(density_of_text, ddof=1) # get density_score of every element for element_info in element_infos: score = np.log(density_of_text_std) * \ element_info.density_of_text * \ np.log10(element_info.number_of_p_tag + 2) * \ np.log(element_info.density_of_punctuation) element_info.density_score = score # sort element info by density_score element_infos = sorted(element_infos, key=lambda x: x.density_score, reverse=True) element_info_first = element_infos[0] if element_infos else None text = '\n'.join(element_info_first.element.xpath('.//p//text()'))
这里我们首先计算了 density_of_text 的标准差,然后对各个节点计算了最终的密度得分,最后排序得到最高的节点,获取文本值即可。
运行结果如下:
"“我的名字叫紫禁城,快要600岁了,这上元的夜啊,总是让我沉醉,这么久了却从未停止。”\n“重檐之上的月光,曾照进古人的宫殿;城墙上绵延的灯彩,映出了角楼的瑰丽。今夜,一群博物馆人将我点一段话。\n半小时后,“紫禁城上元之夜”的灯光点亮了北京夜空。\n午门城楼及东西雁翅楼用白、黄、红三种颜色光源装扮!\n太和门广场变成了超大的夜景灯光秀场!\n图片来源:东方IC 版权作品 请勿转载\n午门城宫博物院供图\n故宫的角楼被灯光装点出满满的节日气氛!\n故宫博物院供图\n令人惊叹的是,故宫的“网红”藏品《清明上河图》《千里江山图卷》在“灯会”中展开画卷。\n灯光版《清明上河图》\n以灯为笔,以屋顶为,故宫博物院最北端神武门也被灯光点亮!\n故宫博物院供图\n上元之夜,故宫邀请了劳动模范、北京榜样、快递小哥、环卫工人、解放军和武警官兵、消防指战员、公安干警等各界代表以及预约成功的观众,共3000人故宫博物院供图\n时间退回到两天前,故宫博物院发布了2月19日(正月十五)、20日(正月十六)即将举办“紫禁城上元之夜”文化活动的消息。\n图片来源:视觉中国\n18日凌晨,一众网友前往故宫博物院官网抢票,网站甚节就有诸多讲究。\n有灯无月不娱人,有月无灯不算春。\n春到人间人似玉,灯烧月下月如怠。\n满街珠翠游村女,沸地笙歌赛社神。\n不展芳尊开口笑,如何消得此良辰。\n——唐伯虎《元宵》\n明代宫中过上元节,皇宵节晚会”。\n2月18日,北京故宫午门调试灯光。中新社记者 杜洋 摄\n其中,灯戏颇为有趣。由多人舞灯拼出吉祥文字及图案,每人手执彩灯、身着不同颜色的服装,翩翩起舞,类似于现代的大型团体操表演。\n但这紫禁城,恭亲王奕 与英法联军交换了《天津条约》批准书,并订立《中英北京条约》《中法北京条约》作为补充。\n战争结束了,侵略者摇身一变成了游客。一位外国“摄影师”拍下了当年的紫禁城,并在日记里写到,百年。\n直到上世纪40年代时,故宫的环境仍然并不是想象中的博物馆的状态。\n曾有故宫博物院工作人员撰文回忆,当时的故宫内杂草丛生,房倒屋漏,有屋顶竟长出了树木。光是清理当时宫中存留的垃圾、杂草就用单霁翔到任故宫院长。那时,他拿到的故宫博物院介绍,写了这座博物馆诸多的“世界之最”。\n可他觉得,当自己真正走到观众中间,这些“世界之最”都没有了。\n2月18日,北京故宫午门调试灯光。中新社记者 杜洋 摄外环境进行了大整治。\n游客没有地方休息,那就拆除了宫中的临时建筑、新增供游客休息的椅子;\n游客排队上厕所,那就将一个职工食堂都改成了洗手间;\n游客买票难,那就全面采用电子购票,新增多个售票点;馆。\n今年,持续整个正月的“过大年”展览和“紫禁城上元之夜”,让本该是淡季的故宫变得一票难求。\n在不少普通人眼中,近600岁的故宫正变得越来越年轻。\n资料图:故宫博物院院长单霁翔。中新社记者 刘关关 摄元宵节活动进行评估后,或结合二十四节气等重要时间节点推出夜场活动。\n你期待吗?\n作者:上官云 宋宇晟"
可以看到,正文就被成功提取出来了。
最后整理一下,三者结果合并,输出为 JSON 格式,实现如下:
def extract(html): return { 'title': extract_title(html), 'datetime': extract_datetime(html), 'content': extract_content(html) }
最后,我们可以看到类似的输出效果,内容如下:
到此为止,我们就成功提取出来了标题、时间和正文内容并输出为 JSON 格式了。其他的一些字段相对没有那么重要,你可以根据类似的方法来进行提取和实验。
]]>前面我们介绍的都是爬取 Web 网页的内容。随着移动互联网的发展,越来越多的企业并没有提供 Web 网页端的服务,而是直接开发了 App,更多更全的信息都是通过 App 来展示的。那么针对 App 我们可以爬取吗?当然可以。
我们知道 Web 站点有多种渲染和反爬方式,渲染分为服务端渲染和客户端渲染;反爬也是多种多样,如请求头验证、WebDriver 限制、验证码、字体反爬、封禁 IP、账号验证等等,综合来看 Web 端的反爬虫方案也是多种多样。
但 App 的情况略有不同,一般来说,App 的数据通信大都需要依赖独立的服务器,比如请求某个 HTTP 接口来获取数据或做登录校验等。这种通信其实就类似 Web 中的 Ajax,客户端向服务器发起 HTTP 请求,获取到数据之后再做一些处理,数据的格式大多也是 JSON、XML 等,基本不会有 HTML 代码这样的数据。
所以说,对于 App 来说,其核心就在于找到这些数据请求到底是怎样的,比如某次 HTTP POST 请求的 URL、Headers、Data 等等,知道了这些,我们就能用程序模拟这个请求过程,从而就能完成爬虫了。
那么怎么知道 App 到底在运行过程中发起了什么请求呢?最有效且常见的方式就是抓包了,抓包工具也非常多,比如 Fiddler、Charles、mitmproxy、anyproxy 等等,我们用这些工具抓到 HTTP 请求包,就能看到这个请求的 Method、Headers、Data 等内容了,知道了之后再用程序模拟出来就行了。
但是,这个过程中你可能遇到非常多的问题,毕竟 App 的数据也是非常宝贵的,所以一些 App 也添加了各种反爬措施,比如:
随着移动互联网的发展,App 上承载的数据也越来越多,越来越重要,很多厂商为了保护 App 的数据也采取了非常多的手段。因此 App 的爬取和逆向分析也变得越来越难,本课时我们就来梳理一些 App 爬取方案。
以下内容针对 Android 平台。
对于多数情况来说,一台 Android 7.0 版本以下的手机,抓一些普通的 App 的请求包还是很容易做到的。
抓包的工具有很多,常见的如 Charles、Fiddler、mitmproxy 等。
抓包的时候在 PC 端运行抓包软件,抓包软件会开启一个 HTTP 代理服务器,然后手机和 PC 连在同一个局域网内,设置好抓包软件代理的 IP 和端口,另外 PC 和手机都安装抓包软件的证书并设置信任。这样在手机上再打开 App 就能看到 App 在运行过程中发起的请求了。
抓包完成之后在抓包软件中定位到具体数据包,查看其详情,了解其请求 Method、URL、Headers、Data,如果这些没有什么加密参数的话,我们用 Python 重写一遍就好了。
当然如果遇到抓不到包或者有加密参数的情形,无法直接重写,那就要用到后面介绍的方法了。
一些 App 在内部实现的时候对代理加了一些校验,如绕过系统代理直接连接或者检测到了使用了代理,直接拒绝连接。
这种情形往往是手机的 HTTP 客户端对系统的网络环境做了一些判断,并修改了一些 HTTP 请求方式,使得数据不走代理,这样抓包软件就没法直接抓包了。
另外对于一些非 HTTP 请求的协议,利用常规的抓包软件也可能抓不到包。这里提供一些解决方案。
虽然有些数据包不走代理,但其下层还是基于 TCP 协议的,所以可以将 TCP 数据包重定向到代理服务器。比如软件 ProxyDroid 就可以实现这样的操作,这样我们就能抓到数据包了。
ProxyDroid:https://github.com/madeye/proxydroid
如果不通过 PC 上的抓包软件设置代理,还可以直接在手机上设置抓包软件,这种方式是通过 VPN 的方式将网络包转发给手机本地的代理服务器,代理服务器将数据发送给服务端,获取数据之后再返回即可。
使用了 VPN 的方式,我们就可以截获到对应的数据包了,一些工具包括 HttpCanary、Packet Capture、NetKeeper 等。
以上应用链接来源于 Google Play,也可以在国内应用商店搜索或直接下载 apk 安装。
可以考虑使用 Wireshark、Tcpdump 在更底层的协议上抓包,比如抓取 TCP、UDP 数据包等等。
使用的时候建议直接 PC 上开热点,然后直接抓取 PC 无线网卡的数据包,这样 App 不管有没有做系统代理校验或者使用了非 HTTP 协议,都能抓到数据包了。
SSL Pining,就是证书绑定,这个只针对 HTTPS 请求。
SSL Pining 发生在下面的一些情况:
对于这些操作,我们通常有两种思路来解决:
对于这两种思路,有以下一些绕过 SSL Pining 的解决方案。
如果是 App 的开发者或者把 apk 逆向出来了,那么可以直接通过修改 AndroidManifest.xml 文件,在 apk 里面添加证书的信任规则即可,详情可以参考 https://crifan.github.io/app_capture_package_tool_charles/website/how_capture_app/complex_https/https_ssl_pinning/,这种思路属于第一种信任证书的解决方案。
当然也可以将证书直接设置为系统证书,只需要将抓包软件的证书设置为系统区域即可。但这个前提是手机必须要 ROOT,而且需要计算证书 Hash Code 并对证书进行重命名,具体可以参考 https://crifan.github.io/app_capture_package_tool_charles/website/how_capture_app/complex_https/https_ssl_pinning,这种思路也是第一种信任证书的解决方案。
Xposed 是一款 Android 端的 Hook 工具,利用它我们可以 Hook App 里面的关键方法的执行逻辑,绕过 HTTPS 的证书校验过程。JustTrustMe 是基于 Xposed 一个插件,它可以将 HTTPS 证书校验的部分进行 Hook,改写其中的证书校验逻辑,这种思路是属于第二种绕过 HTTPS 证书校验的解决方案。
当然基于 Xposed 的类似插件也有很多,如 SSLKiller、sslunpining 等等,可以自行搜索。
不过 Xposed 的安装必须要 ROOT,如果不想 ROOT 的话,可以使用后文介绍的 VirtualXposed。
Frida 也是一种类似 Xposed 的 Hook 软件,使用它我们也可以实现一些 HTTPS 证书校验逻辑的改写,这种思路也是属于第二种绕过 HTTPS 证书校验的方案。
具体可以参考 https://codeshare.frida.re/@pcipolloni/universal-android-ssl-pinning-bypass-with-frida/。
Xposed 的使用需要 ROOT,如果不想 ROOT 的话,可以直接使用一款基于 VirtualApp 开发的 VirtualXposed 工具,它提供了一个虚拟环境,内置了 Xposed。我们只需要将想要的软件安装到 VirtualXposed 里面就能使用 Xposed 的功能了,然后配合 JustTrustMe 插件也能解决 SSL Pining 的问题,这种思路是属于第二种绕过 HTTPS 证书校验的解决方案。
对于第二种绕过 HTTPS 证书校验的解决方案,其实本质上是对一些关键的校验方法进行了 Hook 和改写,去除了一些校验逻辑。但是对于一些代码混淆后的 App 来说,其校验 HTTPS 证书的方法名直接变了,那么 JustTrustMe 这样的插件就无法 Hook 这些方法,因此也就无效了。
所以这种 App 可以直接去逆向,找到其中的一些校验逻辑,然后修改写 JustTrustMe 的源码就可以成功 Hook 住了,也就可以重新生效了。
还有一种硬解的方法,可以直接逆向 App,反编译得到证书秘钥,使用秘钥来解决证书限制。
以上解决了一些抓包的问题,但是还有一个问题,就是抓的数据包里面带有加密参数怎么办?比如一个 HTTP 请求,其参数还带有 token、sign 等参数,即使我们抓到包了,那也没法直接模拟啊?
所以我们可能需要对 App 进行一些逆向分析,找出这些加密过程究竟是怎样的。这时候我们就需要用到一些逆向工具了。
JEB 是一款适用于 Android 应用程序和本机机器代码的反汇编器和反编译器软件。利用它我们可以直接将安卓的 apk 反编译得到 Smali 代码、jar 文件,获取到 Java 代码。有了 Java 代码,我们就能分析其中的加密逻辑了。
JEB:https://www.pnfsoftware.com/
与 JEB 类似,JADX 也是一款安卓反编译软件,可以将 apk 反编译得到 jar 文件,得到 Java 代码,从而进一步分析逻辑。
JADX:https://github.com/skylot/jadx
这两者通常会配合使用来进行反编译,同样也可以实现 apk 文件的反编译,但其用起来个人感觉不如 JEB、JADX 方便。
一些 apk 可能进行了加固处理,所以在反编译之前需要进行脱壳处理。一般来说可以先借助于一些查壳工具查壳,如果有壳的话可以借助于 Dumpdex、FRIDA-DEXDump 等工具进行脱壳。
一些 apk 里面的加密可能直接写入 so 格式的动态链接库里面,要想破解其中的逻辑,就需要用到反汇编的一些知识了,这里可以借助于 IDA 这个软件来进行分析。
IDA:https://www.hex-rays.com/
以上的一些逆向操作需要较深的功底和安全知识,在很多情况下,如果逆向成功了,一些加密算法还是能够被找出来的,找出来了加密逻辑之后,我们用程序模拟就方便了。
逆向对于多数有保护 App 是有一定作用的,但有的时候 App 还增加了风控检测,一旦 App 检测到运行环境或访问频率等信息出现异常,那么 App 或服务器就可能产生防护,直接停止执行或者服务器返回假数据等都是有可能的。
对于这种情形,有时候我们就需要回归本源,真实模拟一些 App 的手工操作了。
最常规的 adb 命令可以实现一些手机自动化操作,但功能有限。
有很多商家提供了手机 App 的一些自动化脚本和驱动,如触动精灵、按键精灵等,利用它们的一些服务我们可以自动化地完成一些 App 的操作。
触动精灵:https://www.touchsprite.com/
类似 Selenium,Appium 是手机上的一款移动端的自动化测试工具,也能做到可见即可爬的操作。
Appium:http://appium.io/
同样是一款移动端的自动化测试工具,是网易公司开发的,相比 Appium 来说使用更方便。
AirTest:http://airtest.netease.com/
mitmdump 其实是一款抓包软件,与 mitmproxy 是一套工具。这款软件配合自动化的一些操作就可以用 Python 实现实时抓包处理了。
mitmdump:https://mitmproxy.readthedocs.io/
到此,App 的一些爬虫思路和常用的工具就介绍完了,在后面的课时我们会使用其中一些工具来进行实战演练。
本课时我们主要学习如何使用 Charles。
Charles 是一个网络抓包工具,我们可以用它来做 App 的抓包分析,得到 App 运行过程中发生的所有网络请求和响应内容,这就和 Web 端浏览器的开发者工具 Network 部分看到的结果一致。
Charles、Fiddler 等都是非常强大的 HTTP 抓包软件,功能基本类似,不过 Charles 的跨平台支持更好。所以我们选用 Charles 作为主要的移动端抓包工具,用于分析移动 App 的数据包,辅助完成 App 数据抓取工作。
本节我们以电影示例 App 为例,通过 Charles 抓取 App 运行过程中的网络数据包,然后查看具体的 Request 和 Response 内容,以此来了解 Charles 的用法。
同时抓取到数据包之后,我们采用 Python 将请求进行改写,从而实现 App 数据的爬取。
请确保已经正确安装 Charles 并开启了代理服务,另外准备一部 Android 手机,系统版本最好是在 7.0 以下。
如果系统版本在 7.0 及以上,可能出现 SSL Pining 的问题,可以参考第一课时的思路来解决。
然后手机连接 Wi-Fi,和 PC 处于同一个局域网下,另外将 Charles 代理和 Charles CA 证书设置好,同时需要开启 SSL 监听。
此过程的配置流程可以参见:https://cuiqingcai.com/5255.html。
最后手机上安装本节提供的 apk(apk 随课件一同领取),进行接下来的 Charles 抓包操作。
首先将 Charles 运行在自己的 PC 上,Charles 运行的时候会在 PC 的 8888 端口开启一个代理服务,这个服务实际上是一个 HTTP/HTTPS 的代理。
确保手机和 PC 在同一个局域网内,我们可以使用手机模拟器通过虚拟网络连接,也可以使用手机真机和 PC 通过无线网络连接。
设置手机代理为 Charles 的代理地址,这样手机访问互联网的数据包就会流经 Charles,Charles 再转发这些数据包到真实的服务器,服务器返回的数据包再由 Charles 转发回手机,Charles 就起到中间人的作用,所有流量包都可以捕捉到,因此所有 HTTP 请求和响应都可以捕获到。同时 Charles 还有权力对请求和响应进行修改。
好,我们先打开 Charles,初始状态下 Charles 的运行界面如图所示。
Charles 会一直监听 PC 和手机发生的网络数据包,捕获到的数据包就会显示在左侧,随着时间的推移,捕获的数据包越来越多,左侧列表的内容也会越来越多。
可以看到,图中左侧显示了 Charles 抓取到的请求站点,我们点击任意一个条目便可以查看对应请求的详细信息,其中包括 Request、Response 等内容。
接下来清空 Charles 的抓取结果,点击左侧的扫帚按钮即可清空当前捕获到的所有请求。然后点击第二个监听按钮,确保监听按钮是打开的,这表示 Charles 正在监听 App 的网络数据流,如图所示。
这时打开 App,注意一定要提前设置好 Charles 的代理并配置好 CA 证书,否则没有效果。
打开 App 之后我们就可以看到类似如下的页面。
这时候我们就可以发现 Charles 里面已经抓取到了对应的数据包,出现了类似如图所示的结果。
我们在 App 里不断上拉,可以看到 Charles 捕获到这个过程中 App 内发生的所有网络请求,如图所示。
左侧列表中会出现一个 dynamic1.scrape.center 的链接,而且在 App 上拉过程它在不停闪动,这就是当前 App 发出的获取数据的请求被 Charles 捕获到了。
为了验证其正确性,我们点击查看其中一个条目的详情信息。切换到 Contents 选项卡,这时我们发现一些 JSON 数据,核对一下结果,结果有 results 字段,每一个条目的 name 字段就是电影的信息,这与 App 里面呈现的内容是完全一致的,如图所示。
这时可以确定,此请求对应的接口就是获取电影数据的接口。这样我们就成功捕获到了在上拉刷新的过程中发生的请求和响应内容。
现在分析一下这个请求和响应的详细信息。首先可以回到 Overview 选项卡,上方显示了请求的接口 URL,接着是响应状态 Status Code、请求方式 Method 等,如图所示。
这个结果和原本在 Web 端用浏览器开发者工具内捕获到的结果形式是类似的。
接下来点击 Contents 选项卡,查看该请求和响应的详情信息。
上半部分显示的是 Request 的信息,下半部分显示的是 Response 的信息。比如针对 Reqeust,我们切换到 Headers 选项卡即可看到该 Request 的 Headers 信息,针对 Response,我们切换到 JSON Text 选项卡即可看到该 Response 的 Body 信息,并且该内容已经被格式化,如图所示。
由于这个请求是 GET 请求,所以我们还需要关心的就是 GET 的参数信息,切换到 Query String 选项卡即可查看,如图所示。
这样我们就成功抓取到了 App 中的电影数据接口的请求和响应,并且可以查看 Response 返回的 JSON 数据。
至于其他 App,我们同样可以使用这样的方式来分析。如果我们可以直接分析得到请求的 URL 和参数的规律,直接用程序模拟即可批量抓取。
Charles 还有一个强大功能,它可以将捕获到的请求加以修改并发送修改后的请求。点击上方的修改按钮,左侧列表就多了一个以编辑图标为开头的链接,这就代表此链接对应的请求正在被我们修改,如图所示。
我们可以将参数中的某个字段修改下,比如这里将 offset 字段由 0 修改为 10。这时我们已经对原来请求携带的 Query 参数做了修改,然后点击下方的 Execute 按钮即可执行修改后的请求,如图所示。
可以发现左侧列表再次出现了接口的请求结果,内容变成了第 11~20 条内容,如图所示。
有了这个功能,我们就可以方便地使用 Charles 来做调试,可以通过修改参数、接口等来测试不同请求的响应状态,就可以知道哪些参数是必要的哪些是不必要的,以及参数分别有什么规律,最后得到一个最简单的接口和参数形式以供程序模拟调用使用。
现在我们已经成功完成了抓包操作了,所有的请求一目了然,请求的 URL 就是 https://dynamic1.scrape.center/api/movie/,后面跟了两个 GET 请求参数。经过观察,可以很轻松地发现 offset 就是偏移量,limit 就是一次请求要返回的结果数量。比如 offset 为 20,limit 为 10,就代表获取第 21~30 条数据。另外我们通过观察发现一共就是 100 条数据,offset 从 0 到 90 遍历即可。
接下来我们用 Python 简单实现一下模拟请求即可,这里写法一些从简了,代码如下:
import requestsBASE_URL = 'https://dynamic1.scrape.center/api/movie?offset={offset}&limit=10'for i in range(0, 10): offset = i * 10 url = BASE_URL.format(offset=offset) data = requests.get(url).json() print('data', data)
运行结果如下:
data {'count': 100, 'results': [{'id': 1, 'name': '霸王别姬', 'alias': 'Farewell My Concubine', 'cover': 'https://p0.meituan.net/movie/ce4da3e03e655b5b88ed31b5cd7896cf62472.jpg@464w_644h_1e_1c', 'categories': ['剧情', '爱情'], 'published_at': '1993-07-26', 'minute': 171, 'score': 9.5, 'regions': ['中国大陆', '中国香港']}, {'id': 2, 'name': '这个杀手不太冷', 'alias': 'Léon', 'cover': ... 'published_at': '1995-07-15', 'minute': 89, 'score': 9.0, 'regions': ['美国']}]}data {'count': 100, 'results': [{'id': 11, 'name': 'V字仇杀队', 'alias': 'V for Vendetta', 'cover': 'https://p1.meituan.net/movie/06ec3c1c647942b1e40bca84036014e9490863.jpg@464w_644h_1e_1c', 'categories': ['剧情', '动作', '科幻', '惊悚'], 'published_at': '2005-12-11', 'minute': 132, 'score': 8.9, 'regions': ['美国', '英国', '德国']}, ... 'categories': ['纪录片'], 'published_at': '2001-12-12', 'minute': 98, 'score': 9.1, 'regions': ['法国', '德国', '意大利', '西班牙', '瑞士']}]}data {'count': 100, 'results': [{'id': 21, 'name': '黄金三镖客', 'alias': 'Il buono, il brutto, il cattivo.', 'cover': ...
可以看到每个请求都被我们轻松模拟实现了,数据也被爬取下来了。
由于这个 App 的接口没有任何加密,所以仅仅靠抓包完之后观察规律我们就能轻松完成 App 接口的模拟爬取。
以上内容便是通过 Charles 抓包分析 App 请求的过程。通过 Charles,我们成功抓取 App 中流经的网络数据包,捕获原始的数据,还可以修改原始请求和重新发起修改后的请求进行接口测试。
知道了请求和响应的具体信息,如果我们可以分析得到请求的 URL 和参数的规律,直接用程序模拟即可批量抓取,这当然最好不过了。
但是随着技术的发展,App 接口往往会带有密钥或者无法抓包,后面我们会继续讲解此类情形的处理操作。
在上一节课我们讲解了 Charles 的使用,它可以帮助我们抓取 HTTP 和 HTTPS 的数据包,抓到请求之后,我们如果能够分析出接口请求的一些规律,就能轻松通过 Python 脚本来进行改写。可是当请求里面包含一些无规律的参数的时候,可能就束手无策了。本节课我们介绍一个叫作 mitmproxy 的工具,它可以对抓包的结果通过脚本进行实时处理和保存,接下来我们来一起了解下吧。
mitmproxy 是一个支持 HTTP 和 HTTPS 的抓包程序,有类似 Fiddler、Charles 的功能,只不过它是一个控制台的形式操作。
mitmproxy 还有两个关联组件。一个是 mitmdump,它是 mitmproxy 的命令行接口,利用它我们可以对接 Python 脚本,用 Python 实现实时监听后的处理。另一个是 mitmweb,它是一个 Web 程序,通过它我们可以清楚观察 mitmproxy 捕获的请求。
下面我们来了解它们的用法。
请确保已经正确安装好了 mitmproxy,并且手机和 PC 处于同一个局域网下,同时配置好了 mitmproxy 的 CA 证书,具体的配置可以参考 https://cuiqingcai.com/5391.html。
mitmproxy 有如下几项功能。
和 Charles 一样,mitmproxy 运行于自己的 PC 上,mitmproxy 会在 PC 的 8080 端口运行,然后开启一个代理服务,这个服务实际上是一个 HTTP/HTTPS 的代理。
手机和 PC 在同一个局域网内,设置代理为 mitmproxy 的代理地址,这样手机在访问互联网的时候流量数据包就会流经 mitmproxy,mitmproxy 再去转发这些数据包到真实的服务器,服务器返回数据包时再由 mitmproxy 转发回手机,这样 mitmproxy 就相当于起了中间人的作用,抓取到所有 Request 和 Response,另外这个过程还可以对接 mitmdump,抓取到的 Request 和 Response 的具体内容都可以直接用 Python 来处理,比如得到 Response 之后我们可以直接进行解析,然后存入数据库,这样就完成了数据的解析和存储过程。
首先,我们需要运行 mitmproxy,mitmproxy 启动命令如下所示:
mitmproxy
运行之后会在 8080 端口上运行一个代理服务,如图所示:
右下角会出现当前正在监听的端口。
或者启动 mitmdump,它也会监听 8080 端口,命令如下所示:
mitmdump
运行结果如图所示。
将手机和 PC 连接在同一局域网下,设置代理为当前代理。首先看看 PC 的当前局域网 IP。
Windows 上的命令如下所示:
ipconfig
Linux 和 Mac 上的命令如下所示:
ifconfig
输出结果如图所示。
一般类似 10...* 或 172.16.. 或 192.168.1.* 这样的 IP 就是当前 PC 的局域网 IP,例如此图中 PC 的 IP 为 192.168.1.28,手机代理设置类似如图所示。
这样我们就配置好了 mitmproxy 的代理。
确保 mitmproxy 正常运行,并且手机和 PC 处于同一个局域网内,设置了 mitmproxy 的代理。
运行 mitmproxy,命令如下所示:
mitmproxy
设置成功之后,我们只需要在手机浏览器上访问任意的网页或浏览任意的 App 即可。例如在手机上打开百度,mitmproxy 页面便会呈现出手机上的所有请求,如图所示。
这就相当于之前我们在浏览器开发者工具监听到的浏览器请求,在这里我们借助于 mitmproxy 完成。Charles 完全也可以做到。
这里是刚才手机打开百度页面时的所有请求列表,左下角显示的 2/38 代表一共发生了 38 个请求,当前箭头所指的是第二个请求。
每个请求开头都有一个 GET 或 POST,这是各个请求的请求方式。紧接的是请求的 URL。第二行开头的数字就是请求对应的响应状态码,后面是响应内容的类型,如 text/html 代表网页文档、image/gif 代表图片。再往后是响应体的大小和响应的时间。
当前呈现了所有请求和响应的概览,我们可以通过这个页面观察到所有的请求。
如果想查看某个请求的详情,我们可以敲击回车,进入请求的详情页面,如图所示。
可以看到 Headers 的详细信息,如 Host、Cookies、User-Agent 等。
最上方是一个 Request、Response、Detail 的列表,当前处在 Request 这个选项上。这时我们再点击 Tab 键,即可查看这个请求对应的响应详情,如图所示。
最上面是响应头的信息,下拉之后我们可以看到响应体的信息。针对当前请求,响应体就是网页的源代码。
这时再敲击 Tab 键,切换到最后一个选项卡 Detail,即可看到当前请求的详细信息,如服务器的 IP 和端口、HTTP 协议版本、客户端的 IP 和端口等,如图所示。
mitmproxy 还提供了命令行式的编辑功能,我们可以在此页面中重新编辑请求。敲击 e 键即可进入编辑功能,这时它会询问你要编辑哪部分内容,如 Cookies、Query、URL 等,每个选项的第一个字母会高亮显示。敲击要编辑内容名称的首字母即可进入该内容的编辑页面,如敲击 m 即可编辑请求的方式,敲击 q 即可修改 GET 请求参数 Query。
这时我们敲击 q,进入到编辑 Query 的页面。由于没有任何参数,我们可以敲击 a 来增加一行,然后就可以输入参数对应的 Key 和 Value,如图所示。
这里我们输入 Key 为 wd,Value 为 NBA。
然后再敲击 Esc 键和 q 键,返回之前的页面,再敲击 e 和 p 键修改 Path。和上面一样,敲击 a 增加 Path 的内容,这时我们将 Path 修改为 s,如图所示。
再敲击 esc 和 q 键返回,这时我们可以看到最上面的请求链接变成了 https://www.baidu.com/s?wd=NBA,访问这个页面,可以看到百度搜索 NBA 关键词的搜索结果,如图所示。
敲击 a 保存修改,敲击 r 重新发起修改后的请求,即可看到上方请求方式前面多了一个回旋箭头,这说明重新执行了修改后的请求。这时我们再观察响应体内容,即可看到搜索 NBA 的页面结果的源代码,如图所示。
以上内容便是 mitmproxy 的简单用法。利用 mitmproxy,我们可以观察到手机上的所有请求,还可以对请求进行修改并重新发起。
Fiddler、Charles 也有这个功能,而且它们的图形界面操作更加方便。那么 mitmproxy 的优势何在?
mitmproxy 的强大之处体现在它的另一个工具 mitmdump,有了它我们可以直接对接 Python 对请求进行处理。下面我们来看看 mitmdump 的用法。
mitmdump 是 mitmproxy 的命令行接口,同时还可以对接 Python 对请求进行处理,这是相比 Fiddler、Charles 等工具更加方便的地方。有了它我们可以不用手动截获和分析 HTTP 请求和响应,只需写好请求和响应的处理逻辑即可。它还可以实现数据的解析、存储等工作,这些过程都可以通过 Python 实现。
我们可以使用命令启动 mitmproxy,并把截获的数据保存到文件中,命令如下所示:
mitmdump -w outfile
其中 outfile 的名称任意,截获的数据都会被保存到此文件中。
还可以指定一个脚本来处理截获的数据,使用 - s 参数即可:
mitmdump -s script.py
这里指定了当前处理脚本为 script.py,它需要放置在当前命令执行的目录下。
我们可以在脚本里写入如下的代码:
def request(flow): flow.request.headers['User-Agent'] = 'MitmProxy' print(flow.request.headers)
我们定义了一个 request 方法,参数为 flow,它其实是一个 HTTPFlow 对象,通过 request 属性即可获取到当前请求对象。然后打印输出了请求的请求头,将请求头的 User-Agent 修改成了 MitmProxy。
运行之后我们在手机端访问 http://httpbin.org/get,就可以看到有如下情况发生。
手机端的页面显示如图所示。
PC 端控制台输出如图所示。
手机端返回结果的 Headers 实际上就是请求的 Headers,User-Agent 被修改成了 mitmproxy。PC 端控制台输出了修改后的 Headers 内容,其 User-Agent 的内容正是 mitmproxy。
所以,通过这三行代码我们就可以完成对请求的改写。print 方法输出结果可以呈现在 PC 端控制台上,可以方便地进行调试。
mitmdump 提供了专门的日志输出功能,可以设定不同级别以不同颜色输出结果。我们把脚本修改成如下内容:
from mitmproxy import ctxdef request(flow): flow.request.headers['User-Agent'] = 'MitmProxy' ctx.log.info(str(flow.request.headers)) ctx.log.warn(str(flow.request.headers)) ctx.log.error(str(flow.request.headers))
这里调用了 ctx 模块,它有一个 log 功能,调用不同的输出方法就可以输出不同颜色的结果,以方便我们做调试。例如,info 方法输出的内容是白色的,warn 方法输出的内容是黄色的,error 方法输出的内容是红色的。运行结果如图所示。
不同的颜色对应不同级别的输出,我们可以将不同的结果合理划分级别输出,以更直观方便地查看调试信息。
最开始我们实现了 request 方法并且对 Headers 进行了修改。下面我们来看看 Request 还有哪些常用的功能。我们先用一个实例来感受一下。
from mitmproxy import ctxdef request(flow): request = flow.request info = ctx.log.info info(request.url) info(str(request.headers)) info(str(request.cookies)) info(request.host) info(request.method) info(str(request.port)) info(request.scheme)
我们修改脚本,然后在手机上打开百度,即可看到 PC 端控制台输出了一系列的请求,在这里我们找到第一个请求。控制台打印输出了 Request 的一些常见属性,如 URL、Headers、Cookies、Host、Method、Scheme 等。输出结果如图所示。
结果中分别输出了请求链接、请求头、请求 Cookies、请求 Host、请求方法、请求端口、请求协议这些内容。
同时我们还可以对任意属性进行修改,就像最初修改 Headers 一样,直接赋值即可。例如,这里将请求的 URL 修改一下,脚本修改如下所示:
def request(flow): url = 'https://httpbin.org/get' flow.request.url = url
手机端得到如下结果,如图所示。
比较有意思的是,浏览器最上方还是呈现百度的 URL,但是页面已经变成了 httpbin.org 的页面了。另外,Cookies 明显还是百度的 Cookies。我们只是用简单的脚本就成功把请求修改为其他的站点。通过这种方式修改和伪造请求就变得轻而易举。
通过这个实例我们知道,有时候 URL 虽然是正确的,但是内容并非正确。我们需要进一步提高自己的安全防范意识。
Request 还有很多属性,在此不再一一列举。更多属性可以参考:http://docs.mitmproxy.org/en/latest/scripting/api.html。
只要我们了解了基本用法,会很容易地获取和修改 Reqeust 的任意内容,比如可以用修改 Cookies、添加代理等方式来规避反爬。
对于爬虫来说,我们更加关心的其实是响应的内容,因为 Response Body 才是爬取的结果。对于响应来说,mitmdump 也提供了对应的处理接口,就是 response 方法。下面我们用一个实例感受一下。
from mitmproxy import ctxdef response(flow): response = flow.response info = ctx.log.info info(str(response.status_code)) info(str(response.headers)) info(str(response.cookies)) info(str(response.text))
将脚本修改为如上内容,然后手机访问:http://httpbin.org/get。
这里打印输出了响应的 status_code、headers、cookies、text 这几个属性,其中最主要的 text 属性就是网页的源代码。
PC 端控制台输出如图所示。
控制台输出了响应的状态码、响应头、Cookies、响应体这几部分内容。
我们可以通过 response 方法获取每个请求的响应内容。接下来再进行响应的信息提取和存储,我们就可以成功完成爬取了。
下面我们来介绍一个 App 的爬取实现,示例 App 可以参见附件。
通过 Charles 抓包之后我们可以发现,其接口的 URL 中包含了一个 token 参数,如图所示。
而且这个 token 每次请求都是变化的,我们目前也观察不出它到底是怎么构造的。
那么我们如果想把抓包的这些结果保存下来,应该怎么办呢?显然就不好直接用程序来构造这些请求了,因为 token 的生成逻辑我们无从得知。这时候我们可以采取 mitmdump 实时处理的操作,只要能抓到包,那就可以把抓包的结果实时处理并保存下来。
首先我们写一个 mitmdump 脚本,还是和原来一样,保存为 spider.py 内容如下:
from mitmproxy import ctxdef response(flow): response = flow.response info = ctx.log.info info(str(response.status_code)) info(str(response.headers)) info(str(response.cookies)) info(str(response.text))
然后启动一下脚本,命令如下:
mitmdump -s spider.py
设置好手机代理为 mitmdump 的地址。
这时候打开手机 App,会看到如下的界面,如图所示。
这时候我们再返回控制台,查看下 mitmdump 的输出,就会变成如下内容,如图所示。
我们可以看到这里就输出来了抓包后的结果,最后的这个内容实际上就是接口的返回结果,我们直接将其输出到控制台上了。
经过一些分析我们可以很轻松得知一次请求就会获取 10 条电影的数据,我们可以对脚本进行下改写,对这个结果进行分析处理。比如我们可以遍历每一条电影数据,然后将其保存到本地,成为 JSON 文件。
脚本就可以改写如下:
from mitmproxy import ctximport jsondef response(flow): response = flow.response if response.status_code != 200: return data = json.loads(str(response.text)) for item in data.get('results'): name = item.get('name') with open(f'{name}.json', 'w', encoding='utf-8') as f: f.write(json.dumps(item, indent=2, ensure_ascii=False))
这里我们首先对 response 的内容进行了解析,然后遍历了 results 字段的每个内容,然后获取其 name 字段当作文件输出的名称,最后输出保存成一个 JSON 文件。
这时候,我们再重新运行 mitmdump 和 App,就可以发现 mitmdump 的运行目录下就出现了好多 JSON 文件,这些 JSON 文件的内容就是抓包的电影数据的结果,如图所示。
我们打开其中一个结果,内容如下:
{ "id": 3, "name": "肖申克的救赎", "alias": "The Shawshank Redemption", "cover": "https://p0.meituan.net/movie/283292171619cdfd5b240c8fd093f1eb255670.jpg@464w_644h_1e_1c", "categories": [ "剧情", "犯罪"], "published_at": "1994-09-10", "minute": 142, "score": 9.5, "regions": [ "美国"]}
可以看到这就是我们在 Charles 中看到的返回结果中的一条电影数据的内容,我们通过 mitmdump 对接实时处理脚本实现了分析和保存。
以上便是 mitmproxy 和 mitmdump 的基本用法。
本课时我们主要学习如何使用 Appium。
Appium 是一个跨平台移动端自动化测试工具,可以非常便捷地为 iOS 和 Android 平台创建自动化测试用例。它可以模拟 App 内部的各种操作,如点击、滑动、文本输入等,只要我们手工操作的动作 Appium 都可以完成。在前面我们了解过 Selenium,它是一个网页端的自动化测试工具。Appium 实际上继承了 Selenium,Appium 也是利用 WebDriver 来实现 App 的自动化测试的。对 iOS 设备来说,Appium 使用 UIAutomation 来实现驱动。对于 Android 来说,它使用 UiAutomator 和 Selendroid 来实现驱动。
Appium 相当于一个服务器,我们可以向 Appium 发送一些操作指令,Appium 就会根据不同的指令对移动设备进行驱动,完成不同的动作。
对于爬虫来说,我们用 Selenium 来抓取 JavaScript 渲染的页面,可见即可爬。Appium 同样也可以,用 Appium 来做 App 爬虫不失为一个好的选择。
下面我们来了解 Appium 的基本使用方法。
我们以 Android 平台的一个示例 apk 演示 Appium 启动和操作 App 的方法,主要目的是了解利用 Appium 进行自动化测试的流程以及相关 API 的用法。
请确保 PC 已经安装好 Appium、Android 开发环境和 Python 版本的 Appium API,安装方法可以参考 https://cuiqingcai.com/5407.html。另外,Android 手机安装好示例安装包,下载地址为:https://app5.scrape.center/。
Appium 启动 App 的方式有两种:一种是用 Appium 内置的驱动器来打开 App,另一种是利用 Python 程序实现此操作。下面我们分别进行说明。
首先打开 Appium,启动界面如图所示。
直接点击 Start Server 按钮即可启动 Appium 的服务,相当于开启了一个 Appium 服务器。我们可以通过 Appium 内置的驱动或 Python 代码向 Appium 的服务器发送一系列操作指令,Appium 就会根据不同的指令对移动设备进行驱动,完成不同的动作。启动后运行界面如图所示。
Appium 运行之后正在监听 4723 端口。我们可以向此端口对应的服务接口发送操作指令,此页面就会显示这个过程的操作日志。
将 Android 手机通过数据线和运行 Appium 的 PC 相连,同时打开 USB 调试功能,确保 PC 可以连接到手机。
可以输入 adb 命令来测试连接情况,如下所示:
adb devices -l
如果出现如下类似结果,就说明 PC 已经正确连接手机。
List of devices attachedemulator-5554 device product:cancro model:MuMu device:x86 transport_id:231
第一个字段是设备的名称,就是后文需要用到的 deviceName 变量。我使用的是模拟器,所以此处名称为 emulator-5554。
如果提示找不到 adb 命令,请检查 Android 开发环境和环境变量是否配置成功。如果可以成功调用 adb 命令但不显示设备信息,请检查手机和 PC 的连接情况。
接下来用 Appium 内置的驱动器打开 App,点击 Appium 中的 Start New Session 按钮,如图所示。
这时会出现一个配置页面,如图所示。
需要配置启动 App 时的 Desired Capabilities 参数,它们分别是 platformName、deviceName、appPackage、appActivity。
.
开头。在当前配置页面的左下角也有配置参数的相关说明,链接是 https://github.com/appium/appium/blob/master/docs/en/writing-running-appium/caps.md。
我们在 Appium 中加入上面 5 个配置,如图所示。
点击保存按钮,保存下来,我们以后可以继续使用这个配置。
点击右下角的 Start Session 按钮,即可启动 Android 手机上的 App 并进入启动页面。同时 PC 上会弹出一个调试窗口,从这个窗口我们可以预览当前手机页面,并可以查看页面的源码,如图所示。
点击左栏中屏幕的某个元素,如选中一个条目,它就会高亮显示。这时中间栏就显示了当前选中的元素对应的源代码,右栏则显示了该元素的基本信息,如元素的 id、class、text 等,以及可以执行的操作,如 Tap、Send Keys、Clear,如图所示。
点击中间栏最上方的第三个录制按钮,Appium 会开始录制操作动作,这时我们在窗口中操作 App 的行为都会被记录下来,Recorder 处可以自动生成对应语言的代码。例如,我们点击录制按钮,然后选中其中一个条目,点击 Tap 操作,即模拟了按钮点击功能,这时手机和窗口的 App 都会跳转到对应的详情页面,同时中间栏会显示此动作对应的代码,如图所示。
我们可以在此页面点击不同的动作按钮,即可实现对 App 的控制,同时 Recorder 部分也可以生成对应的 Python 代码。
下面我们看看使用 Python 代码驱动 App 的方法。首先需要在代码中指定一个 Appium Server,而这个 Server 在刚才打开 Appium 的时候就已经开启了,是在 4723 端口上运行的,配置如下所示:
server = 'http://localhost:4723/wd/hub'
用字典来配置 Desired Capabilities 参数,代码如下所示:
desired_caps = { 'platformName': 'Android', 'deviceName': 'emulator-5554', 'appPackage': 'com.goldze.mvvmhabit', 'appActivity': '.ui.MainActivity'}
新建一个 Session,这类似点击 Appium 内置驱动的 Start Session 按钮相同的功能,代码实现如下所示:
from appium import webdriverfrom selenium.webdriver.support.ui import WebDriverWaitdriver = webdriver.Remote(server, desired_caps)
配置完成后运行,就可以启动 App了。但是现在仅仅是可以启动 App,还没有做任何动作。
再用代码来模拟刚才演示的两个动作:点击某个条目,然后返回。
看看刚才 Appium 内置驱动器内的 Recorder 录制生成的 Python 代码,自动生成的代码非常累赘,例如点击某个条目然后返回的代码如下所示:
el1 = driver.find_element_by_xpath("/hierarchy/android.widget.FrameLayout/android.widget.LinearLayout/android.widget.FrameLayout/android.widget.LinearLayout/android.widget.FrameLayout/android.widget.FrameLayout/android.widget.LinearLayout/android.widget.RelativeLayout/android.support.v7.widget.RecyclerView/android.widget.LinearLayout[2]")el1.click()driver.back()
我们稍微整理修改一下,然后再加上获取文本的操作,完整的代码如下所示:
from appium import webdriverfrom selenium.webdriver.common.by import Byfrom selenium.webdriver.support.ui import WebDriverWaitfrom selenium.webdriver.support import expected_conditions as ECserver = 'http://localhost:4723/wd/hub'desired_caps = { 'platformName': 'Android', 'deviceName': 'emulator-5554', 'appPackage': 'com.goldze.mvvmhabit', 'appActivity': '.ui.MainActivity', 'adbExecTimeout': 200000,}driver = webdriver.Remote(server, desired_caps)wait = WebDriverWait(driver, 1000)item_element = wait.until(EC.presence_of_element_located( (By.XPATH, '//android.support.v7.widget.RecyclerView/android.widget.LinearLayout[2]')))item_title_element = item_element.find_element_by_xpath('//android.widget.TextView')text = item_title_element.textprint('text', text)item_element.click()driver.back()
运行此代码,这时即可观察到手机上首先弹出了 App,然后模拟点击了其中一个条目,然后返回了主页。同时还输出了提取到的节点内的文本。
这样我们就成功使用 Python 代码实现了 App 的操作。
接下来看看使用代码如何操作 App、总结相关 API 的用法。这里使用的 Python 库为 AppiumPythonClient,其 GitHub 地址为 https://github.com/appium/python-client,此库继承自 Selenium,使用方法与 Selenium 有很多共同之处。
需要配置 Desired Capabilities 参数,完整的配置说明可以参考 https://github.com/appium/appium/blob/master/docs/en/writing-running-appium/caps.md,一般来说我们配置几个基本参数即可:
from appium import webdriverserver = 'http://localhost:4723/wd/hub'desired_caps = { 'platformName': 'Android', 'deviceName': 'emulator-5554', 'appPackage': 'com.goldze.mvvmhabit', 'appActivity': '.ui.MainActivity'}driver = webdriver.Remote(server, desired_caps)
这里配置了启动 App 的 Desired Capabilities,这样 Appium 就会自动查找手机上的包名和入口类,然后将其启动。包名和入口类的名称可以在安装包中的 AndroidManifest.xml 文件获取。
如果要打开的 App 没有事先在手机上安装,我们可以直接指定 App 参数为安装包所在路径,这样程序启动时就会自动向手机安装并启动 App,如下所示:
from appium import webdriverserver = 'http://localhost:4723/wd/hub'desired_caps = { 'platformName': 'Android', 'deviceName': 'emulator-5554', 'app': './app.apk'}driver = webdriver.Remote(server, desired_caps)
程序启动的时候就会寻找 PC 当前路径下的 apk 安装包,然后将其安装到手机中并启动。
我们可以使用 Selenium 中通用的查找方法来实现元素的查找,如下所示:
el = driver.find_element_by_id('com.package.name:id/path')
在 Selenium 中,其他查找元素的方法同样适用,在此不再赘述。
在 Android 平台上,我们还可以使用 UIAutomator 来进行元素选择,如下所示:
el = self.driver.find_element_by_android_uiautomator('new UiSelector().description("Animation")')els = self.driver.find_elements_by_android_uiautomator('new UiSelector().clickable(true)')
在 iOS 平台上,我们可以使用 UIAutomation 来进行元素选择,如下所示:
el = self.driver.find_element_by_ios_uiautomation('.elements()[0]')els = self.driver.find_elements_by_ios_uiautomation('.elements()')
还可以使用 iOS Predicates 来进行元素选择,如下所示:
el = self.driver.find_element_by_ios_predicate('wdName == "Buttons"')els = self.driver.find_elements_by_ios_predicate('wdValue == "SearchBar" AND isWDDivisible == 1')
也可以使用 iOS Class Chain 来进行选择,如下所示:
el = self.driver.find_element_by_ios_class_chain('XCUIElementTypeWindow/XCUIElementTypeButton[3]')els = self.driver.find_elements_by_ios_class_chain('XCUIElementTypeWindow/XCUIElementTypeButton')
但是此种方法只适用于 XCUITest 驱动,具体可以参考:https://github.com/appium/appium-xcuitest-driver。
点击可以使用 tap 方法,该方法可以模拟手指点击(最多五个手指),可设置按时长短(毫秒),代码如下所示:
tap(self, positions, duration=None)
参数:
实例如下:
driver.tap([(100, 20), (100, 60), (100, 100)], 500)
这样就可以模拟点击屏幕的某几个点。
另外对于某个元素如按钮来说,我们可以直接调用 cilck方法实现模拟点击,实例如下所示:
button = find_element_by_id('com.tencent.mm:id/btn')button.click()
这样获取元素之后,然后调用 click方法即可实现该元素的模拟点击。
可以使用 scroll 方法模拟屏幕滚动,用法如下所示:
scroll(self, origin_el, destination_el)
可以实现从元素 origin_el 滚动至元素 destination_el。
参数:
实例如下:
driver.scroll(el1,el2)
我们还可以使用 swipe 模拟从 A 点滑动到 B 点,用法如下:
swipe(self, start_x, start_y, end_x, end_y, duration=None)
参数:
实例如下:
driver.swipe(100, 100, 100, 400, 5000)
这样可以实现在 5s 由 (100,100) 滑动到 (100,400)。
另外可以使用 flick 方法模拟从 A 点快速滑动到 B 点,用法如下:
flick(self, start_x, start_y, end_x, end_y)
参数:
实例如下:
driver.flick(100, 100, 100, 400)
可以使用 drag_and_drop 实现某个元素拖动到另一个目标元素上。
用法如下:
drag_and_drop(self, origin_el, destination_el)
可以实现元素 origin_el 拖拽至元素 destination_el。
参数:
实例如下所示:
driver.drag_and_drop(el1, el2)
可以使用 set_text 方法实现文本输入,如下所示:
el = find_element_by_id('com.tencent.mm:id/cjk')el.set_text('Hello')
我们选中一个文本框元素之后,然后调用 set_text 方法即可实现文本输入。
与 Selenium 中的 ActionChains 类似,Appium 中的 TouchAction 可支持的方法有 tap、press、long_press、release、move_to、wait、cancel 等,实例如下所示:
el = self.driver.find_element_by_accessibility_id('Animation')action = TouchAction(self.driver)action.tap(el).perform()
首先选中一个元素,然后利用 TouchAction 实现点击操作。
如果想要实现拖动操作,可以用如下方式:
els = self.driver.find_elements_by_class_name('listView')a1 = TouchAction()a1.press(els[0]).move_to(x=10, y=0).move_to(x=10, y=-75).move_to(x=10, y=-600).release()a2 = TouchAction()a2.press(els[1]).move_to(x=10, y=10).move_to(x=10, y=-300).move_to(x=10, y=-600).release()
利用以上 API,我们就可以完成绝大部分操作。
更多的 API 操作可以参考 https://testerhome.com/topics/3711。
本节中,我们主要了解了 Appium 的操作 App 的基本用法以及常用 API 的用法。利用它我们就可以对 App 进行可视化操作并像 Selenium 一样提取页面信息了。
在上一节课我们了解了 Appium 的用法,利用 Appium 可以方便地完成 App 的自动化控制,但在使用过程中或多或少还会有些不方便的地方,比如响应速度慢,提供的 API 功能有限等。
本课时我们再介绍另外一个更好用的自动化测试工具,叫作 airtest,它提供了一些更好用的 API,同时提供了非常强大的 IDE,开发效率和响应速度相比 Appium 也有提升。
AirtestProject 是由网易游戏推出的一款自动化测试框架,项目构成如下。
Airtest
和 Poco
代码。总之,Airtest 建立了一个比较完善的自动化测试解决方案,利用 Airtest 我们自然就能实现 App 内可见即可爬的爬取。
本节我们会简单介绍 Airtest IDE 的基本使用,同时介绍一些 Airtest 和 Poco 的基本 API 的用法,最后我们以一个实例来实现 App 的模拟和爬取。
这里使用的平台还是安卓平台,请确保现在你准备好了一台安卓的手机或模拟器。
在 Airtest 的官方文档中已经详细介绍了 Airtest 的安装方式,包括 AirtestIDE、Airtest Python 库、Poco Python 库。
如果我们只使用 AirtestIDE 来实现自动化模拟和数据爬取的话是没问题的,因为它里面已经内置了 Python、 Airtest Python 库、Poco Python 库。AirtestIDE 提供了非常便捷的可视化点选和代码生成等功能,你没有任何 Python 代码基础的话,仅仅使用 AirtestIDE 就可以完成 App 的自动化控制和数据的爬取了。但是对于大量数据的爬取和页面跳转控制这样的场景来说,如果仅仅依靠可视化点选和自动生成的代码来进行 App 的自动化控制,其实是不灵活的。
进一步地,如果我们再加上一些代码逻辑的话,比如一些流程控制、循环控制语句,我们就可以实现批量数据的爬取了,这时候我们就需要依赖于 Airtest、Poco 以及一些自定义逻辑和第三方库来实现了。
所以,这里建议同时安装 AirtestIDE、Airtest、Poco。
AirtestIDE 的安装方式参见链接:https://airtest.doc.io.netease.com/tutorial/1_quick_start_guide/。
Airtest 的安装命令如下:
pip3 install airtest
Poco 的安装命令如下:
pip3 install pocoui
安装完成之后,可以在 AirtestIDE 中把 Python 的解释器更换成系统的 Python 解释器,而不再是 AirtestIDE 内置的 Python 解释器,修改方法参见 https://airtest.doc.io.netease.com/IDEdocs/run_script/1_useCommand_runScript/。
在这里我以一台安卓手机来演示 AirtestIDE 的使用。
首先参考 https://airtest.doc.io.netease.com/tutorial/1_quick_start_guide/#_4 来完成手机的连接,确保使用 adb 可以正常获取到手机的相关信息,如:
adb devices
如果能正常输出手机相关信息,则证明连接成功,示例如下:
adb server version (40) doesn't match this client (41); killing...* daemon started successfullyList of devices attached6T9DYHNNDMUC8LBI device
这里就能看到我的设备名称为 6T9DYHNNDMUC8LBI。
然后启动 AirtestIDE,新建一个脚本,界面如图所示:
这时候在右侧我们可以看到已经连接的设备,如果没有出现,可以查看 https://airtest.doc.io.netease.com/IDEdocs/device_connection/2_android_faq/ 来排查一些问题。
接下来我们点击设备列表右侧的 connect 按钮,就可以在 IDE 中看到手机的屏幕了,如图所示。
另外可以观察到,整个 IDE 被分成了三列。
在这里我们可以通过鼠标直接点触右侧部分的手机屏幕,可以发现真机或模拟器的屏幕也会跟着变化,而且响应速度非常快。
接下来我们来实验一下 Airtest 辅助器。Airtest 可以基于图像识别来实现自动化控制,我们来体验一下。
比如在这里我先点击左侧的 touch 按钮,其含义就是点击。这时候 AirtestIDE 会提示我们在右侧屏幕截图,比如这里我们截取“应用商店”,这时候我们可以发现 AirtestIDE 中便会出现了一行代码。代码的内容为 touch,然后其参数就是一张可视化的图片。
然后我们再选择 wait,其含义就是等待某个内容加载出来,同样地进行屏幕截图,如截取菜单栏的一部分,证明已经成功进入了应用商店首页。
再然后我们点击 swipe,其含义就是滑动屏幕,这时候 AirtestIDE 会提示我们先选择一个区域,再选择滑动到目标位置,如图所示。
这里我们就通过一些可视化的配置完成了自动化的配置。
最后我们在代码的开头部分再加一个 keyevent,代表一些键盘事件,内容如下:
keyevent("HOME")
结果如下:
这样我们就能实现这样的自动化控制流程了:
怎么样,是不是很简单。如果你的手机内容和本示例不一样的话,可以灵活更换其中的配置内容。
这时候,我们点击运行按钮,即可发现 Airtest 便可以自动驱动手机完成一些自动化的操作了。以上便是 Airtest 提供的基于图像识别技术的自动化控制。
但很多情况下图像识别的速度可能不是很快,另外图像的截图也不一定是精确的,而且存在一定的风险,比如有的图像更换了,那可能就会影响自动化测试的流程。另外对于大批量的数据采集和循环控制,图像识别也不是一个好的方案。
所以,这里再介绍一个基于 Poco 的 UI 控件自动化控制,其实说白了就是基于一些 UI 名称和属性的选择器的自动化控制,有点类似于 Appium、Selenium 中的 XPath。
这里我们先点击左侧 Poco 辅助窗的下拉菜单,更换到 Android,这时候 AirtestIDE 会提示我们更新代码,点击确定之后可以发现其自动为我们添加了如下代码:
from poco.drivers.android.uiautomation import AndroidUiautomationPocopoco = AndroidUiautomationPoco(use_airtest_input=True, screenshot_each_action=False)
这其实就是导入了 Poco 的 AndroidUiautomationPoco 模块,然后声明了一个 poco 对象。
接下来我们就可以通过 poco 对象来选择一些内容了。
我们此时点击左侧的控件树,可以发现右侧的手机屏幕就有对应的高亮显示,如图所示。这就有点像浏览器开发者工具里面选取网页源代码,这里的 UI 控件树就类似于网页里面的 HTML DOM 树。
接着我们点击辅助窗的右上角的录制按钮,如图所示。
录制之后可以在右侧屏幕进行一些点选或滑动的一些操作,在代码区域就可以生成一些操作代码,如图所示。
这里也类似 Appium 里面录制并生成操作代码的过程。
比如这里经过我的一些操作,代码区域自动生成了如下代码:
poco("com.xiaomi.market:id/inner_webview").swipe([0.013, -0.2461])poco("com.miui.home:id/workspace").offspring("应用商店").offspring("com.miui.home:id/icon_icon").click()poco("com.miui.systemAdSolution:id/view_skip").click()poco("com.xiaomi.market:id/inner_webview").swipe([0.0391, -0.3545])poco("com.xiaomi.market:id/inner_webview").swipe([0.0807, -0.5098])poco("com.xiaomi.market:id/inner_webview").swipe([0.0156, -0.3516])poco("com.xiaomi.market:id/fragment_root_view").child("com.xiaomi.market:id/fragment_container").child("android.widget.LinearLayout").offspring("小米应用商店").child("android.view.View").child("android.widget.ListView")[1].child("android.view.View")[3].child("android.view.View")[0].child("android.view.View").child("android.view.View")[1].child("android.view.View")[1].click()poco("com.xiaomi.market:id/top_bar_back_iv").click()
通过这些内容我们可以观察到有这样的规律:
poco 对象可以直接接收一个控件树选择器,然后就可以调用一些操作方法,如 swipe、click 等等完成一些操作。
另外 poco 对象还支持链式选择,如 poco 对象的调用返回结果后面紧跟了 child 方法、offspring 的方法的调用,同时还支持索引选择,其最终的返回结果依然可以调用一些操作方法,如 swipe、click 等完成一些操作。
所以,这里我们就可以初步得出如下结论:
但其实可以观察到现在利用录制的方式自动生成的代码并不太规范,也不太灵活。既然已经是纯编程方式实现自动化控制,那么我们有必要来了解下 Poco 的一些具体用法。
Poco 是一款基于 UI 控件识别的自动化测试框架,目前支持 Unity3D/cocos2dx/Android 原生 App/iOS 原生 App/微信小程序,同样是基于 Python 实现的。
其 GitHub 地址为:https://github.com/AirtestProject/Poco。
首先可以看下 Poco 这个对象,其 API 为:
class Poco(agent, **options)
一般来说我们会使用它的子类,比如安卓就会使用 AndroidUiautomationPoco 来声明一个 poco 对象,这个就相当于手机操作的句柄,类似于是 Selenium 中的 webdriver 对象,通过调用它的一些选择器和操作方法就可以完成手机的一些操作。
用法类似如下:
poco = Poco(...)close_btn = poco('close', type='Button')
这里我们可以发现,poco 本身就是一个对象,但它是可以直接调用并传入 UI 控件的名称的,这归根结底是因为其实现了一个 __call__
方法,实现如下:
def __call__(self, name=None, **kw): if not name and len(kw) == 0: warnings.warn("Wildcard selector may cause performance trouble. Please give at least one condition to shrink range of results") return UIObjectProxy(self, name, **kw)
可以看到其就是返回了一个 UIObjectProxy 对象,这个就对应页面中的某个 UI 组件,如一个输入框、一个按钮,等等。
接下来我们再看下 UIObjectProxy 的实现,其文档地址为:https://poco.readthedocs.io/en/latest/source/poco.proxy.html。
这里我们可以看到它实现了 __getitem__
、__iter__
、__len__
等方法,另外观察到其还实现了 child、children、offspring 方法,这也就是 UIObjectProxy 可以实现链式调用和索引操作以及循环遍历的原因。
接下来我们再介绍几个比较常用的方法。
选择子节点,第一个参数是 name,即 UI 控件的名称,如 android.widget.LinearLayout 等等,另外还可以额外传入一些属性来进行辅助选择。
其返回结果同样是 UIObjectProxy 类型。
选择父节点,无需参数,可以直接返回当前节点的父节点,同样是 UIObjectProxy 类型。
选择兄弟节点,第一个参数是 name,即 UI 控件的名称,另外还可以额外传入一些属性来进行辅助选择。
其返回结果同样是 UIObjectProxy 类型。
点击、右键点击、双击、长按操作,UIObjectProxy 对象直接调用即可。其接受参数 focus 指定点击偏移位置,sleep_interval 代表点击完成之后等待的秒数。
滑动操作,其接收参数 direction 代表滑动方向,focus 代表滑动焦点偏移量,duration 代表完成滑动所需时间。
等待此节点出现,其接收参数 timeout 代表最长等待时间。
获取节点的属性,其接收参数 name 代表属性名,如 visable、text、type、pos、size 等等。
获取节点的文本值,这个方法非常有用,利用它我们就可以获得某个文本节点内部的文本数据。
另外还有很多方法,这里暂时介绍这么多,更多的方法可以参考官方文档介绍: https://poco.readthedocs.io/en/latest/source/poco.proxy.html。
最后我们以一个 App 为例来完成数据的爬取。其下载地址为:https://app7.scrape.center/。
首先将 App 安装到手机上,进行简单的抓包发现其数据接口带有加密,同时 App 的逆向分析也有一定的难度,所以这里我们来采取 Airtest 来实现模拟爬取。
我们的目标就是要把所有的电影名称抓取下来,如图所示:
整体思路如下:
由于整体思路比较简单,这里直接将代码实现如下:
from airtest.core.api import *from poco.drivers.android.uiautomation import AndroidUiautomationPocoPACKAGE_NAME = 'com.goldze.mvvmhabit'poco = AndroidUiautomationPoco()poco.device.wake()stop_app(PACKAGE_NAME)start_app(PACKAGE_NAME)auto_setup(__file__)screenWidth, screenHeight = poco.get_screen_size()viewed = []current_count, last_count = len(viewed), len(viewed)while True: last_count = len(viewed) result = poco('android.support.v7.widget.RecyclerView').child('android.widget.LinearLayout') result.wait(timeout=10) for item in result: text_view = item.child(type='android.widget.TextView') if not text_view.exists(): continue name = text_view.get_text() if not name in viewed: viewed.append(name) print('名称', name) current_count = len(viewed) print('开始滑动') swipe((screenWidth * 0.5, screenHeight * 0.7), vector=[0, -0.8], duration=3) print('滑动结束') sleep(5) if current_count == last_count: print('数量不再有变化,抓取结束') break
整体思路如下:
运行如上代码便可以发现控制台输出了如下结果:
名称 霸王别姬名称 这个杀手不太冷名称 肖申克的救赎名称 泰坦尼克号名称 罗马假日名称 唐伯虎点秋香名称 乱世佳人名称 喜剧之王名称 楚门的世界开始滑动滑动结束名称 狮子王名称 V字仇杀队开始滑动滑动结束名称 少年派的奇幻漂流名称 美丽心灵名称 初恋这件小事名称 借东西的小人阿莉埃蒂名称 一一...
最后所有的电影名称就被我们提取出来了。
以上我们便讲解了 AirtestIDE、Airtest、Poco 的基本用法,并用它们来完成了一个 App 数据的简单爬取。
如果你对逆向有所涉猎的话,可能听说过 Hook,利用 Hook 技术我们可以在某一逻辑的前后加入自定义的逻辑处理代码,几乎可以实现任意逻辑的修改。
在前面的 JavaScript 逆向实战课时我们也初步体会了 Hook 的功效,如果你对 Hook 的概念还不太了解,可以搜索一下“Hook 技术”相关的内容来了解下。
对于 App 来说,Hook 技术应用非常广泛。比如朋友圈微信步数的修改,其实就是通过 Hook 数据发送的方式实现步数的修改。比如处理安卓的 SSL Pining,用 Hook 技术也可以修改 SSL 证书校验结果,实现校验的绕过。对于 App 爬虫来说,我们也可以使用 Hook 一些关键的方法获取方法执行前后的结果,从而实现数据的截获。
那这些技术怎么来实现呢?这里就不得不提一个框架,叫作 Xposed。
Xposed 框架(Xposed Framework)是一套开源的,在 Android 高权限模式下运行的框架服务,可以在不修改 App 源码的情况下影响程序运行(修改系统)的框架服务,基于它可以制作出许多功能强大的模块,且在功能不冲突的情况下同时运作。
Xposed 框架的原理是通过替换系统级别的 /system/bin/app_process
程序控制 zygote 进程,使得app_process
在启动过程中会加载 XposedBridge.jar
这个 jar 包,这个 jar 包里面定义了对系统方法、属性的一系列 Hook 操作,同时还提供了几个 Hook API 供我们编写 Xposed 模块来使用。我们编写一个 Xposed 模块时,引用 Xposed 提供的几个 Hook 方法就可以实现对系统级别任意方法和属性的修改了。
这么说可能有点抽象,下面我们来编写一个 Xposed 模块带你体会一下 Xposed 的用法,最后再使用 Xposed 来实现一个真实 App 执行逻辑的修改。
由于 Xposed 是运行在 Android 平台上的,所以本节我们的环境就和 Android 相关。
开始学习本节课之前,需要做如下准备工作:
配置好 Android 开发环境,开发环境的搭建流程可以参考下面的几个链接:
准备一个已经 ROOT 的安卓设备并连接好 PC,可以使用虚拟机或真机,比如我使用的是虚拟机网易 Mumu,已经自带了 ROOT 功能,当然如果你有已经 ROOT 的真机也是可以的。
安装好 jadx、jadx-gui,这是一款用来反编译 apk 的软件,安装参考链接见:https://github.com/skylot/jadx。
准备好了如上环境之后,我们就开始 Xposed 模块的编写吧。
有了如上的环境后,首先我们需要先安装 Xposed。
要安装 Xposed 我们需要借助于一个叫作 Xposed Installer 的 App,它就是用来安装 Xposed 框架的,利用它我们可以下载和安装 Xposed 框架,同时还能查看和管理 Xposed 模块,还能查看一些 Xposed 框架输出的日志信息等。
怎么安装呢?
我们可以先打开 Xposed 的官网 https://repo.xposed.info/module/de.robv.android.xposed.installer,然后就可以看到一些有关 Xposed 的提示信息。
这里提示了这么一条重要信息:
For Android 5.0 or higher (Lollipop/Marshmallow), these versions don't work! Use this instead: http://forum.xda-developers.com/showthread.php?t=3034811
很明显,如果你的手机安卓版本在 5.0 以下,可以直接点击首页下方的 apk 下载,如果是 5.0 或更高版本,那么就到 http://forum.xda-developers.com/showthread.php?t=3034811 这个链接下载,后者的下载的真实链接是 https://forum.xda-developers.com/attachment.php?attachmentid=4393082&d=1516301692,可以直接点击下载。
注意:由于时间上的变化,下载地址以官方介绍为准。
下载完成之后会得到一个 apk 文件,我们可以直接在真机或模拟机上安装。
安装完成之后便会出现这样的一个图标:
这就代表 Xposed Installer 已经安装成功了。
下面我们打开它,它可能会提示需要 ROOT 权限,授予即可。
打开之后的界面可能如下所示:
这里它提示 Xposed 模块未安装,所以我们需要安装一下。
点击安装,如图所示。
安装完成之后它会提示重启设备后生效,如图所示:
接着我们重启设备,便可以看到如下界面,代表 Xposed 框架已经安装并激活,如图所示:
这时候我们便可以开始编写 Xposed 模块了。
现在 Xposed 的生态非常庞大,基于 Xposed 开发的模块非常之多,点击下载菜单可以看到已有的发布的 Xposed 模块,如图所示:
五花八门的模块非常多,比如修改微信步数、修改系统定位、自动抢红包等等。当然我们也可以编写自己的模块来实现想要的功能。
这时候你可能就会问了,这模块究竟是干吗的?它到底是一个什么东西?
其实本质上来说,它就是一个安卓 App,开发一个 Xposed 模块其实流程上就和开发一个安卓 App 差不多,只不过相比 App 开发来说多了下面四个步骤:
这个 App 里面要加上一些标识,标明这个 App 是一个 Xposed 模块,以便安装之后 Xposed 框架可以识别出来。
这个 App 里面需要引入 Xposed 的 jar 包,从而能实现 Hook 操作。
App 里面定义一些 Hook 操作,可以对本 App 或其他的 App 的逻辑进行修改。
定义完这些 Hook 操作逻辑之后,还需要告诉 Xposed 框架哪些是我们自己定义的 Hook 操作逻辑,以便 Xposed 执行这些 Hook 逻辑。
就这么四步,这四步这么来实现呢,下面我们来一步步实现。
下面我们来根据上面的四步来进行一个 Xposed 模块开发吧。
首先我们用 Android Studio 新建一个安卓项目,第一步提示我们选择 Activity,直接选择默认的 Empty Activity 即可,如图所示:
名称叫作 XposedTest,包名可以任意取,然后指定好项目路径和编写语言,同时指定最小 SDK 版本为 15,如图所示:
点击 FINISH,创建这个项目。
最后生成的界面如图所示:
然后我们开始第一步的配置,先配置一些标识符,标识这是一个 Xposed 模块。
我们打开 AndroidManifest.xml 文件,添加如下内容:
<meta-data android:name="xposedmodule" android:value="true" /><meta-data android:name="xposeddescription" android:value="Xposed Test" /><meta-data android:name="xposedminversion" android:value="53" />
到 application 标签内,和 activity 标签并列,最终内容如图所示:
这里指定了三个 meta-data,分别如下。
xposedmodule:这里设置为 true,代表这是一个 Xposed 模块。
xposeddescription:模块的描述,填写模块描述就好,就是一个字符串。
xposedminversion:模块运行要求的 Xposed 最低版本号,这里是 53。
定义好这三个参数之后,把这个 App 安装到手机,Xposed 就能识别出这个 App 是一个 Xposed 模块了。
我们点击运行按钮,在手机上运行这个 App。这时候可以看到手机上出现了如下界面,如图所示:
这时候我们打开 Xposed Installer 的模块界面,就发现它检测到了这个模块,如图所示:
在这里我们把它勾选上,就成功启用了这个 Xposed 模块。不过值得注意的是,需要重启设备才能生效。可以手动启动设备或者通过 Xposed Installer 首页的重启选项来进行重启。
但是,现在启用了也没什么用啊,因为这个模块里面什么功能都还没有呢。
接下来我们再在项目中引入 Xposed 相关的 SDK,这样我们才能调用 Xposed 提供的一些 Hook 操作方法,实现 Hook 操作。
打开 app/build.gradle 文件,在 dependencies 区域添加如下两行代码:
compileOnly 'de.robv.android.xposed:api:82'compileOnly 'de.robv.android.xposed:api:82:sources'
这是 Xposed 的 SDK,添加之后 Android Stuido 会检测到项目配置发生的变化,在右上角会提示一个“Sync Now”的选项,我们点击之后,新添加的 Xposed SDK 便会自动下载和安装,如图所示:
好,现在 Xposed 的 SDK 就安装成功了,下面我们就能使用里面的方法来实现逻辑的 Hook 了。
那怎么来实现逻辑的 Hook 呢?Hook 什么呢?那总得有点逻辑吧?哪来的逻辑呢?自己先写一个吧。
说干就干,这里我们就添加一个鼠标响应事件,点击之后触发一个算式计算的逻辑。
首先我们修改下页面内容,把当前的文本框设置成一个按钮,以便点击触发,修改 app/src/main/res/layout/activity_main.xml 文件,内容替换为如下内容:
<?xml version="1.0" encoding="utf-8"?><androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" tools:context=".MainActivity"> <Button android:id="@+id/button" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Test" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /></androidx.constraintlayout.widget.ConstraintLayout>
这时候重新运行 App 就会出现一个按钮了,而不是一行文本框。接下来再修改下 MainActivity.java 文件,内容如下:
package com.germey.xposedtest;import androidx.appcompat.app.AppCompatActivity;import android.os.Bundle;import android.view.View;import android.widget.Button;import android.widget.Toast;import android.os.Bundle;public class MainActivity extends AppCompatActivity { private Button button; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); button = findViewById(R.id.button); button.setOnClickListener(new View.OnClickListener() { public void onClick(View v) { Toast.makeText(MainActivity.this, showMessage(1, 2), Toast.LENGTH_SHORT).show(); } }); } public String showMessage(int x, int y) { return "x + y = " + (x + y); }}
这里我们定义了一个 Button,然后使用 findViewById 方法从视图里面获取到了这个 Button 对象,同时我们为这个 Button 添加了一个点击事件,点击之后会生成一个 Toast 提示,其内容为 showMessage 方法返回的结果。
这个 showMessage 方法接收两个参数,是 int 类型的 x 和 y,返回的结果是一个字符串,即“x + y =”这个字符串再拼接上二者计算得到的结果,其实就是一个算数表达式。
这里 showMessage 在调用的时候我们传入了 1 + 2,所以最后 showMessage 显示的结果应该为“x + y = 3”,我们重新运行下 App,然后点击 TEST 按钮,可以看到如下运行结果:
这样我们的一个基本的逻辑就定义好了。
定义好了之后呢,下一步我们就来用 Xposed 进行 Hook 吧,我们在 MainActivity.java 同级新建一个 Java Class,内容如下:
package com.germey.xposedtest;import de.robv.android.xposed.IXposedHookLoadPackage;import de.robv.android.xposed.XC_MethodHook;import de.robv.android.xposed.XposedBridge;import de.robv.android.xposed.XposedHelpers;import de.robv.android.xposed.callbacks.XC_LoadPackage;public class HookMessage implements IXposedHookLoadPackage { public void handleLoadPackage(XC_LoadPackage.LoadPackageParam loadPackageParam) throws Throwable { if (loadPackageParam.packageName.equals("com.germey.xposedtest")) { XposedBridge.log("Hooked com.germey.xposedtest Package"); Class clazz = loadPackageParam.classLoader.loadClass( "com.germey.xposedtest.MainActivity"); XposedHelpers.findAndHookMethod(clazz, "showMessage", int.class, int.class, new XC_MethodHook() { protected void beforeHookedMethod(MethodHookParam param) throws Throwable { XposedBridge.log("Called beforeHookedMethod"); param.args[0] = 2; XposedBridge.log("Changed args 0 to " + param.args[0]); } protected void afterHookedMethod(MethodHookParam param) throws Throwable { XposedBridge.log("Called afterHookedMethod"); } }); } }}
这里我们就定义了 Hook 的相关逻辑了,这里梳理几个关键的点:
这里的 class 实现了 IXposedHookLoadPackage 接口,需要定义 handleLoadPackage 这个方法,这个方法会在每个 App 包加载时执行。
在 handleLoadPackage 里面我们通过 loadPackageParam.packageName
获取到了 App 包名,然后判断是否是我们当前 App 的包名。这里包名可以是任意 App 的包名,不一定是当前 App 的包名,只不过是因为我们为了方便,在当前 App 里面定义了一个逻辑,所以这里我们 Hook 的是当前 App 的逻辑,才填写了当前 App 的包名。
利用 loadClass 方法并指定 class 的路径可以动态地加载这个 class,是一个 Class 对象。
利用 XposedHelpers 提供的 findAndHookMethod 方法可以从 class 里面查找对应的方法,这里需要传入的参数分别为 Class 对象,方法名称,方法的参数类型,处理方法。这里方法的参数类型是有几个写几个,比如这里 showMessage 有两个 int 类型的参数,这里就需要顺次写两个 int.class,如果是其他的类型也是分别写类型再加 class 的声明。
XC_MethodHook 里面定义了我们施行 Hook 的真正逻辑,这里通常可以实现两个方法,分别叫作 beforeHookedMethod 和 afterHookedMethod,分别代表在被 Hook 方法(这里为 showMessage)执行前的操作和执行后的操作,同时二者都有一个 MethodHookParam 类型的参数,里面包含了方法执行的参数和结果等信息。
一般来说 beforeHookedMethod 方法可以用来修改被 Hook 方法的参数内容,或者直接定义被 Hook 方法的运行流程。afterHookedMethod 可以用来对被 Hook 方法进行后处理,比如对被 Hook 方法的结果进行拦截、保存、转发、修改等操作。
XposedBridge 里的 log 方法可以记录将 Log 信息记录到 Xposed Installer 里面,我们通过 Xposed Installer 里面日志页面就可以看到对应结果,方便做调试使用。
这里我们先对 beforeHookedMethod 处理,对 param 的 args 属性做了处理,这里的 args 属性是一个列表,就是 showMessage 方法的调用参数,因为我们之前传入的是 1 和 2,所以这里 args 属性的值其实就是 [1, 2]
,那这里我们是把它改写了一下,把第一个内容改写成了 2,那这里 args 其实就会变成 [2, 2]
了。
好,现在 Hook 的逻辑我们已经实现好了,还差最后一步,那就是告诉 Xposed 我们的 Hook 逻辑是定义在哪里了,我们需要新建一个 Xposed 入口文件。
在 main 文件夹新建一个 assets folder,如图所示:
然后在 assets 文件夹下新建一个 xposed_init 文件,不需要有任何后缀,如图所示:
其内容就把 Hook 的这个类的路径写好就好了,内容如下:
com.germey.xposedtest.HookMessage
好,这样保存之后,Xposed 就能自动读取这个 xposed_init 文件来执行我们自定义的 Hook 逻辑了。
最后,我们就来重新运行看下效果吧。记得安装完成之后重启一下 Xposed,否则是没有效果的。
重启模块之后,点击 TEST 按钮,可以看到就出现了如下效果,如图所示:
这里可以看到,最后的运行效果就不一样了,出现了“x + y = 4”的这个现象,这说明通过 beforeHookedMethod 的定义,我们成功把 args 的第一个参数,也就是 x 修改成了 2,而第二个参数没有修改,还是 2,最后就相当于 showMessage 调用之前,两个参数就被修改成了 2 和 2,最后答案就是 4 了。这下我们就体会到了 beforeHookedMethod 的用法了。
刚才我们是用了 beforeHookedMethod 来实现了参数替换的效果,接下来我们再来体会一下 afterHookedMethod 的用法,它可以对方法的返回结果进行后处理,比如这里我们把 afterHookedMethod 修改为如下内容:
protected void afterHookedMethod(MethodHookParam param) throws Throwable { XposedBridge.log("Called afterHookedMethod"); param.setResult("Hooked");}
这里我们增加了 param 的 setResult 方法的调用,利用它我们可以直接将方法的返回值修改掉。
重新运行这个模块,然后重启手机,同样地还是点击 TEST 按钮,这时候我们发现其结果就变成了如下内容,如图所示:
可以看到最后方法的返回结果被修改了,这正是 afterHookedMethod 所起的作用。
由此,我们通过 beforeHookedMethod 和 afterHookedMethod 的修改可以实现 showMessage 在调用前和调用后的修改。
好,最后我们再来看下日志,打开 Xposed Installer 的日志页面,可以看到内容如图所示:
可以看到这里就输出了我们用 XposedBridge 的 log 方法输出的内容。
OK,到此为止我们就实现了 Xposed 的 Hook 逻辑了,通过这个案例你应该就能体会到 Xposed 的效用了。
好,下面我们再来使用一个真实的 App 为样例来进行一下 Hook 操作吧,我们来实现通过 Hook App 的某个方法来达到修改 App 的运行效果的作用。
这个 App 的下载地址为:https://app1.scrape.center/。
下载完成之后,我们安装一下,可以看到不断下拉的过程中会有电影数据加载出来,如图所示。
通过观察我们可以发现每刷新一次,就会加载出 10 条数据,一共 100 条。
那这个加载数量我们可以通过 Xposed 修改吗?比如修改成一次加载 5 条能做到吗?当然是可以的。
但要修改的话,我们得需要知道 App 的一些逻辑,或者它的一些包路径,方法名之类的内容。
那这个怎么来处理呢?这时候我们可能就需要对 App 的安装包进行反编译了。
由于这个 App 比较简单,没有设置加固,所以这里我们直接使用 jadx 或 jadx-gui 就可以把源码反编译出来了。
这里我使用的是 jadx-gui,我们打开 jadx-gui,然后直接打开 apk 文件,就可以看到反编译后的结果了,如图所示:
通过 App 的一些抓包操作可以找到 API 请求的 URL Path 为 /api/movie,我们使用 jadx-gui 搜索下这个入口,就能找到其原始的一些定义,如图所示:
好,这时候呢我们可以大体定位到一些数据的操作就在 com.goldze.mvvmhabit.data 这个路径下,我们打开看看这里,同时借助于一些方法的交叉引用分析,可以大致分析到此处 index 方法的调用是在 com.goldze.mvvmhabit.data.MainRepository 里的 index 方法里。
实现如下:
public Observable<HttpResponse<MovieEntity>> index(int page, int limit) { return this.mHttpDataSource.index(page, limit);}
这里其实就很清楚了,它接受两个参数,一个是 page,一个 limit,最后调用了 mHttpDataSource 的 index 方法获得了 Response 的结果,并封装成一个 Observable 对象。
好,那我们就来 Hook 一下这个方法吧。我们还是在刚才的 XposedTest 这个项目下,另外新建一个 class,名字叫作 HookAPI,同时还是按照之前的方法来修改一下包名、类名、方法名等等,内容修改如下:
package com.germey.xposedtest;import de.robv.android.xposed.IXposedHookLoadPackage;import de.robv.android.xposed.XC_MethodHook;import de.robv.android.xposed.XposedBridge;import de.robv.android.xposed.XposedHelpers;import de.robv.android.xposed.callbacks.XC_LoadPackage;public class HookAPI implements IXposedHookLoadPackage { public void handleLoadPackage(XC_LoadPackage.LoadPackageParam loadPackageParam) throws Throwable { if (loadPackageParam.packageName.equals("com.goldze.mvvmhabit")) { XposedBridge.log("Hooked com.goldze.mvvmhabit Package"); Class clazz = loadPackageParam.classLoader.loadClass( "com.goldze.mvvmhabit.MainRepository"); XposedHelpers.findAndHookMethod(clazz, "index", int.class, int.class, new XC_MethodHook() { protected void beforeHookedMethod(MethodHookParam param) throws Throwable { XposedBridge.log("Called beforeHookedMethod"); param.args[1] = 5; XposedBridge.log("Changed args 0 to " + param.args[0]); } protected void afterHookedMethod(MethodHookParam param) throws Throwable { XposedBridge.log("Called afterHookedMethod"); } }); } }}
这里我们就利用 beforeHookedMethod 方法把 args 的第二个参数修改成了 5,这样 limit 每次调用就不再是 10 了,而是 5,照理来说每次加载就会返回 5 条数据了。
另外我们还需要在 xposed_init 里面定义好这个入口文件,内容修改如下:
com.germey.xposedtest.HookMessagecom.germey.xposedtest.HookAPI
好,接下来重新安装这个 Xposed 模块,然后重启手机,接着再运行这个 Xposed 模块。
下面我们再重新打开刚才被 Hook 的 App,看到如下的加载效果,如图所示:
可以看到这里加载的数据就变成了 5 条,而不再是原来的 10 条,每次上拉刷新也都是 5 条数据了。
到此,我们就通过 Xposed 通过 Hook 的方式修改了 App 的运行效果,而没有去修改原始 App 的任何一行代码。
最后再来看下 Xposed 提供的 API。
其实刚才所说的 Hook 操作只是 Xposed 的其中一个 API,即 findAndHookMethod 的用法。我们可以查看 Xposed 的所有的 API,链接为 https://api.xposed.info/reference/de/robv/android/xposed/XposedHelpers.html。
在这里我们可以看到有如下的一些 API,这里简单列举一下。
callStaticMethod:调用静态方法。
findAndHookConstructor:查找并 Hook 构造方法。
findClassIfExists:查找某个类是否存在。
findField:获取成员变量。
有很多 API 是有类似或重合的功能的,这里就不再一一列举了,感兴趣的话你可以看官方的文档说明。
另外也非常推荐你研究一下 Xposed 里面的各个 package 的用法,API 见 https://api.xposed.info/reference/de/robv/android/xposed/package-summary.html。
另外也欢迎你多去研究一些优秀的 Xposed 模块,比如 https://devsjournal.com/best-xposed-modules.html 里面列举了几款很受欢迎的 Xposed 模块,另外还有 Xposed 中文站 http://xposed.appkg.com/,你可以找一些优秀的模块的源码来研究一下,收获会非常大的。
本文我们介绍了 Xposed 的基本理念,并通过案例来实现了 Xposed Hook App 的流程。
Xposed 的功能非常强大,利用它,App 尽在我们掌控之中,为所欲为不再是奢望。
本节代码:https://github.com/Python3WebSpider/XposedTest。
现在我们可以看到很多 App 在请求 API 的时候都有加密参数,前面我们也介绍了一种利用 mitmdump 来实时抓取数据的方法,但是这总归还有些不方便的地方。
如果要想拿到 App 发送的请求中包含哪些加密参数,就得剖析本源,深入到 App 内部去找到这些加密参数的构造逻辑,理清这些逻辑之后,我们就能自己用算法实现出来了。这其中就需要一定的逆向操作,我们可能需要对 App 进行反编译,然后通过分析源码的逻辑找到对应的加密位置。
所以,本课时我们来用一个示例介绍App 逆向相关操作。
这里我们首先以一个 App 为例介绍这个 App 的抓包结果和加密情况,然后我们对这个 App 进行逆向分析,最后模拟实现其中的加密逻辑。
App 的下载地址为:https://app5.scrape.center/
我们先运行一下这个 App,上拉滑动,一些电影数据就会呈现出来了,界面如下:
这时候我们用 Charles 抓包来试一下,可以看到类似 API 的请求 URL 类似如下:https://app5.scrape.center/api/movie/?offset=0&limit=10&token=NDVjMTdjNjk5YWM2NWZkOGU5ZjFjNWEyN2MzNjhiYjIwMzRlZDU3ZiwxNTkxMjgyMzcz%0A,这里我们可以发现有三个参数,分别为 offset、limit 还有 token,其中 token 是一个非常长的加密字符串,我们也不好直观地推测其生成逻辑。
本课时我们就来介绍一下逆向相关的操作,通过逆向操作获得 apk 反编译后的代码,然后追踪这个 token 的生成逻辑是怎样的,最后我们再用代码把这个逻辑实现出来。
App 逆向其实多数情况下就是反编译得到 App 的源码,然后从源码里面找寻特定的逻辑,本课时就来演示一下 App 的反编译和入口点查找操作。
在这里我们使用的逆向工具叫作 JEB。
JEB 是一款专业的安卓应用程序的反编译工具,适用于逆向和审计工程,功能非常强大,可以帮助逆向人员节省很多逆向分析时间。利用这个工具我们能方便地获取到 apk 的源码信息,逆向一个 apk 不在话下。
JEB 支持 Windows、Linux、Mac 三大平台,其官网地址为 https://www.pnfsoftware.com/,你可以在官网了解下其基本介绍,然后通过搜索找到一些完整版安装包下载。下载之后我们会看到一个 zip 压缩包,解压压缩包之后会得到如下的内容:
在这里我们直接运行不同平台下的脚本文件即可启动 JEB。比如我使用的是 Mac,那我就可以在此目录下执行如下命令:
sh jeb_macos.sh
这样我们就可以打开 JEB 了。打开 JEB 之后,我们把下载的 apk 文件直接拖拽到 JEB 里面,经过一段时间处理后,会发现 JEB 就已经将代码反编译完成了,如图所示:
这时候我们可以看到在左侧 Bytecode 部分就是反编译后的代码,在右侧显示的则是 Smali 代码,通过 Smali 代码我们大体能够看出一些执行逻辑和数据操作等过程。
现在我们得到了这些反编译的内容,该从哪个地方入手去找入口呢?
由于这里我们需要找的是请求加密参数的位置,那么最简单的当然是通过 API 的一些标志字符串来查找入口了。API 的 URL 里面包含了关键字 /api/movie,那么我们自然就可以通过这个来查找了。
我们可以在 JEB 里面打开查找窗口,查找 /api/movie,如图所示:
这时候我们发现就找到了一个对应的声明如下:
.field public static final indexPath:String = "/api/movie"
这里其实就是声明了一个静态不可变的字符串,叫作 indexPath。但这里是 Smali 代码呀?我们怎么去找到它的源码位置呢?
这时候我们可以右键该字符串,选择解析选项,这时 JEB 就可以成功帮我们定位到 Java 代码的声明处了。
这时候我们便可以看到其跳转到了如下页面:
这里我们就能看到 indexPath 的原始声明,同时还看到了一个 index 方法的声明,包含三个参数 offset、limit 还有 token,由此可以发现,这参数和声明其实恰好和 API 的请求 URL 格式是相同的。
但这里还观察到这个是一个接口声明,一定有某个类实现了这个接口。
我们这时候可以顺着 index 方法来查询是什么类实现了这个 index 方法,在 index 方法上面右键选择“交叉引用”,如图所示:
这时候我们可以发现这里弹出了一个窗口,找到了对应的位置,如图所示:
我们选中它,点击确定,这时候就跳转到了对应的 index 实现的位置了,如图所示:
这里 index 方法的实现如下:
public Observable index(int arg6, int arg7) { ArrayList v2 = new ArrayList(); ((List)v2).add("/api/movie"); return this.apiService.index((arg6 - 1) * arg7, arg7, Encrypt.encrypt(((List)v2)));}
就能很轻易地发现一个类似 encrypt 的方法,代表加密的意思,其参数就是 v2,而 v2 就是一个 ArrayList,包含一个元素,就是 /api/movie 这个字符串。
这时候我们再通过交叉引用找到 Encrypt 的定义,跳转到如图所示的位置:
这里可以发现 encrypt 的方法实现如下:
public static String encrypt(List arg7) { String v1 = String.valueOf(new Timestamp(System.currentTimeMillis()).getTime() / 1000); arg7.add(v1); String v2 = Encrypt.shaEncrypt(TextUtils.join(",", ((Iterable)arg7))); ArrayList v3 = new ArrayList(); ((List)v3).add(v2); ((List)v3).add(v1); return Base64.encodeToString(TextUtils.join(",", ((Iterable)v3)).getBytes(), 0);}
这里我们分析一下,传入的参数就是 arg7,刚才经过分析可知 arg7 其实就是一个长度为 1 的列表,其内容就是一个字符串,即 ["/api/movie"]
。
紧接着看逻辑,这里又定义了一个 v1 的字符串,其实就是获取了时间戳信息,然后把结果加入 arg7,现在 arg7 就有两个内容了,一个是 /api/movie
,另一个是时间戳。
接着又声明了 v2,这里经过分析可知是将 arg7 使用逗号拼接起来,然后调用了 shaEncrypt 操作,而 shaEncrypt 经过观察其实就是 SHA1 算法。
紧接着又声明了一个 ArrayList,把 v2 和 v1 的结果加进去。最后把 v3 的内容使用逗号拼接起来,然后 Base64 编码即可。
好,现在整体的 token 加密的逻辑就理清楚了。
了解了基本的算法流程之后,我们可以用 Python 把这个流程实现出来,代码实现如下:
import hashlibimport timeimport base64from typing import List, Anyimport requestsINDEX_URL = 'https://app5.scrape.center/api/movie?limit={limit}&offset={offset}&token={token}'LIMIT = 10OFFSET = 0def get_token(args: List[Any]): timestamp = str(int(time.time())) args.append(timestamp) sign = hashlib.sha1(','.join(args).encode('utf-8')).hexdigest() return base64.b64encode(','.join([sign, timestamp]).encode('utf-8')).decode('utf-8')args = ['/api/movie']token = get_token(args=args)index_url = INDEX_URL.format(limit=LIMIT, offset=OFFSET, token=token)response = requests.get(index_url)print('response', response.json())
这里最关键的就是 token 的生成过程,我们定义了一个 get_token 方法来实现,整体上思路就是上面梳理的内容:
列表中加入当前时间戳;
将列表内容用逗号拼接;
将拼接的结果进行 SHA1 编码;
将编码的结果和时间戳再次拼接;
将拼接后的结果进行 Base64 编码。
最后运行结果如下:
response {'count': 100, 'results': [{'id': 1, 'name': '霸王别姬', 'alias': 'Farewell My Concubine', 'cover': 'https://p0.meituan.net/movie/ce4da3e03e655b5b88ed31b5cd7896cf62472.jpg@464w_644h_1e_1c', 'categories': ['剧情', '爱情'], 'published_at': '1993-07-26', 'minute': 171, 'score': 9.5, 'regions': ['中国大陆', '中国香港']}, {'id': 2, 'name': '这个杀手不太冷', 'alias': 'Léon', 'cover': 'https://p1.meituan.net/movie/6bea9af4524dfbd0b668eaa7e187c3df767253.jpg@464w_644h_1e_1c', 'categories': ['剧情', '动作', '犯罪'], 'published_at': '1994-09-14', 'minute': 110, 'score': 9.5, 'regions': ['法国']}, {'id': 3, 'name': '肖申克的救赎', 'alias': 'The Shawshank Redemption', 'cover': 'https://p0.meituan.net/movie/283292171619cdfd5b240c8fd093f1eb255670.jpg@464w_644h_1e_1c', 'categories': ['剧情', '犯罪'], 'published_at': '1994-09-10', 'minute': 142, 'score': 9.5, 'regions': ['美国']}, {'id': 4, 'name': '泰坦尼克号', 'alias': 'Titanic', 'cover': 'https://p1.meituan.net/movie/b607fba7513e7f15eab170aac1e1400d878112.jpg@464w_644h_1e_1c', 'categories': ['剧情', '爱情', '灾难'], 'published_at': '1998-04-03', 'minute': 194, 'score': 9.5, 'regions': ['美国']}, {'id': 5, 'name': '罗马假日', 'alias': 'Roman Holiday', 'cover': 'https://p0.meituan.net/movie/289f98ceaa8a0ae737d3dc01cd05ab052213631.jpg@464w_644h_1e_1c', 'categories': ['剧情', '喜剧', '爱情'], 'published_at': '1953-08-20', 'minute': 118, 'score': 9.5, 'regions': ['美国']}, {'id': 6, 'name': '唐伯虎点秋香', 'alias': 'Flirting Scholar', 'cover': 'https://p0.meituan.net/movie/da64660f82b98cdc1b8a3804e69609e041108.jpg@464w_644h_1e_1c', 'categories': ['喜剧', '爱情', '古装'], 'published_at': '1993-07-01', 'minute': 102, 'score': 9.5, 'regions': ['中国香港']}, {'id': 7, 'name': '乱世佳人', 'alias': 'Gone with the Wind', 'cover': 'https://p0.meituan.net/movie/223c3e186db3ab4ea3bb14508c709400427933.jpg@464w_644h_1e_1c', 'categories': ['剧情', '爱情', '历史', '战争'], 'published_at': '1939-12-15', 'minute': 238, 'score': 9.5, 'regions': ['美国']}, {'id': 8, 'name': '喜剧之王', 'alias': 'The King of Comedy', 'cover': 'https://p0.meituan.net/movie/1f0d671f6a37f9d7b015e4682b8b113e174332.jpg@464w_644h_1e_1c', 'categories': ['剧情', '喜剧', '爱情'], 'published_at': '1999-02-13', 'minute': 85, 'score': 9.5, 'regions': ['中国香港']}, {'id': 9, 'name': '楚门的世界', 'alias': 'The Truman Show', 'cover': 'https://p0.meituan.net/movie/8959888ee0c399b0fe53a714bc8a5a17460048.jpg@464w_644h_1e_1c', 'categories': ['剧情', '科幻'], 'published_at': None, 'minute': 103, 'score': 9.0, 'regions': ['美国']}, {'id': 10, 'name': '狮子王', 'alias': 'The Lion King', 'cover': 'https://p0.meituan.net/movie/27b76fe6cf3903f3d74963f70786001e1438406.jpg@464w_644h_1e_1c', 'categories': ['动画', '歌舞', '冒险'], 'published_at': '1995-07-15', 'minute': 89, 'score': 9.0, 'regions': ['美国']}]}
这里就以第一页的数据为示例来演示了,其他的页面我们通过修改 page 的值就可以拿到了。
以上我们便通过一个样例讲解了一个比较基本的 App 的逆向过程,包括 JEB 的使用、追踪代码的操作等等,最后通过分析代码理清了基本逻辑,最后模拟实现了 API 的参数构造和请求发送,得到最终的数据。
]]>当我们在用 requests 抓取页面的时候,得到的结果可能会和在浏览器中看到的不一样:在浏览器中正常显示的页面数据,使用 requests 却没有得到结果。这是因为 requests 获取的都是原始 HTML 文档,而浏览器中的页面则是经过 JavaScript 数据处理后生成的结果。这些数据的来源有多种,可能是通过 Ajax 加载的,可能是包含在 HTML 文档中的,也可能是经过 JavaScript 和特定算法计算后生成的。
对于第 1 种情况,数据加载是一种异步加载方式,原始页面不会包含某些数据,只有在加载完后,才会向服务器请求某个接口获取数据,然后数据才被处理从而呈现到网页上,这个过程实际上就是向服务器接口发送了一个 Ajax 请求。
按照 Web 的发展趋势来看,这种形式的页面将会越来越多。网页的原始 HTML 文档不会包含任何数据,数据都是通过 Ajax 统一加载后再呈现出来的,这样在 Web 开发上可以做到前后端分离,并且降低服务器直接渲染页面带来的压力。
所以如果你遇到这样的页面,直接利用 requests 等库来抓取原始页面,是无法获取有效数据的。这时我们需要分析网页后台向接口发送的 Ajax 请求,如果可以用 requests 来模拟 Ajax 请求,就可以成功抓取了。
所以,本课时我们就来了解什么是 Ajax 以及如何去分析和抓取 Ajax 请求。
Ajax,全称为 Asynchronous JavaScript and XML,即异步的 JavaScript 和 XML。它不是一门编程语言,而是利用 JavaScript 在保证页面不被刷新、页面链接不改变的情况下与服务器交换数据并更新部分网页的技术。
传统的网页,如果你想更新其内容,那么必须要刷新整个页面。有了 Ajax,便可以在页面不被全部刷新的情况下更新其内容。在这个过程中,页面实际上在后台与服务器进行了数据交互,获取到数据之后,再利用 JavaScript 改变网页,这样网页内容就会更新了。
你可以到 W3School 上体验几个 Demo 来感受一下:http://www.w3school.com.cn/ajax/ajax_xmlhttprequest_send.asp。
浏览网页的时候,我们会发现很多网页都有下滑查看更多的选项。以我微博的主页为例:https://m.weibo.cn/u/2830678474。我们切换到微博页面,发现下滑几个微博后,后面的内容不会直接显示,而是会出现一个加载动画,加载完成后下方才会继续出现新的微博内容,这个过程其实就是 Ajax 加载的过程,如图所示:
我们注意到页面其实并没有整个刷新,这意味着页面的链接没有变化,但是网页中却多了新内容,也就是后面刷出来的新微博。这就是通过 Ajax 获取新数据并呈现的过程。
初步了解了 Ajax 之后,我们再来详细了解它的基本原理。发送 Ajax 请求到网页更新的过程可以简单分为以下 3 步:
下面我们分别详细介绍一下这几个过程。
我们知道 JavaScript 可以实现页面的各种交互功能,Ajax 也不例外,它是由 JavaScript 实现的,实际上执行了如下代码:
var xmlhttp;if (window.XMLHttpRequest) { //code for IE7+, Firefox, Chrome, Opera, Safari xmlhttp=new XMLHttpRequest();} else {//code for IE6, IE5 xmlhttp=new ActiveXObject("Microsoft.XMLHTTP");}xmlhttp.onreadystatechange=function() {if (xmlhttp.readyState==4 && xmlhttp.status==200) {document.getElementById("myDiv").innerHTML=xmlhttp.responseText; }}xmlhttp.open("POST","/ajax/",true);xmlhttp.send();
这是 JavaScript 对 Ajax 最底层的实现,这个过程实际上是新建了 XMLHttpRequest 对象,然后调用 onreadystatechange 属性设置监听,最后调用 open() 和 send() 方法向某个链接(也就是服务器)发送请求。
前面我们用 Python 实现请求发送之后,可以得到响应结果,但这里请求的发送由 JavaScript 来完成。由于设置了监听,所以当服务器返回响应时,onreadystatechange 对应的方法便会被触发,我们在这个方法里面解析响应内容即可。
得到响应之后,onreadystatechange 属性对应的方法会被触发,此时利用 xmlhttp 的 responseText 属性便可取到响应内容。这类似于 Python 中利用 requests 向服务器发起请求,然后得到响应的过程。
返回的内容可能是 HTML,也可能是 JSON,接下来我们只需要在方法中用 JavaScript 进一步处理即可。比如,如果返回的内容是 JSON 的话,我们便可以对它进行解析和转化。
JavaScript 有改变网页内容的能力,解析完响应内容之后,就可以调用 JavaScript 针对解析完的内容对网页进行下一步处理。比如,通过 document.getElementById().innerHTML 这样的操作,对某个元素内的源代码进行更改,这样网页显示的内容就改变了,这种对 Document 网页文档进行如更改、删除等操作也被称作 DOM 操作。
上例中,document.getElementById("myDiv").innerHTML=xmlhttp.responseText这个操作便将 ID 为 myDiv 的节点内部的 HTML 代码更改为服务器返回的内容,这样 myDiv 元素内部便会呈现出服务器返回的新数据,网页的部分内容看上去就更新了。
可以看到,发送请求、解析内容和渲染网页这 3 个步骤其实都是由 JavaScript 完成的。
我们再回想微博的下拉刷新,这其实是 JavaScript 向服务器发送了一个 Ajax 请求,然后获取新的微博数据,将其解析,并将其渲染在网页中的过程。
因此,真实的数据其实都是通过一次次 Ajax 请求得到的,如果想要抓取这些数据,我们需要知道这些请求到底是怎么发送的,发往哪里,发了哪些参数。如果我们知道了这些,不就可以用 Python 模拟这个发送操作,获取到其中的结果了吗?
这里还是以前面的微博为例,我们知道拖动刷新的内容由 Ajax 加载,而且页面的 URL 没有变化,这时我们应该到哪里去查看这些 Ajax 请求呢?
这里还需要借助浏览器的开发者工具,下面以 Chrome 浏览器为例来介绍。
首先,用 Chrome 浏览器打开微博链接 https://m.weibo.cn/u/2830678474,随后在页面中点击鼠标右键,从弹出的快捷菜单中选择“检查” 选项,此时便会弹出开发者工具,如图所示:
前面也提到过,这里就是页面加载过程中浏览器与服务器之间发送请求和接收响应的所有记录。
Ajax 有其特殊的请求类型,它叫作 xhr。在图中我们可以发现一个以 getIndex 开头的请求,其 Type 为 xhr,这就是一个 Ajax 请求。用鼠标点击这个请求,可以查看这个请求的详细信息。
在右侧可以观察到 Request Headers、URL 和 Response Headers 等信息。Request Headers 中有一个信息为 X-Requested-With:XMLHttpRequest,这就标记了此请求是 Ajax 请求,如图所示:
随后我们点击 Preview,即可看到响应的内容,它是 JSON 格式的。这里 Chrome 为我们自动做了解析,点击箭头即可展开和收起相应内容。
我们可以观察到,返回结果是我的个人信息,包括昵称、简介、头像等,这也是用来渲染个人主页所使用的数据。JavaScript 接收到这些数据之后,再执行相应的渲染方法,整个页面就渲染出来了。
另外,我们也可以切换到 Response 选项卡,从中观察到真实的返回数据,如图所示:
接下来,切回到第一个请求,观察一下它的 Response 是什么,如图所示:
这就是最原始链接 https://m.weibo.cn/u/2830678474 返回的结果,其代码只有不到 50 行,结构也非常简单,只是执行了一些 JavaScript。
所以说,我们看到的微博页面的真实数据并不是最原始的页面返回的,而是在执行 JavaScript 后再次向后台发送 Ajax 请求,浏览器拿到数据后进一步渲染出来的。
接下来,我们再利用 Chrome 开发者工具的筛选功能筛选出所有的 Ajax 请求。在请求的上方有一层筛选栏,直接点击 XHR,此时在下方显示的所有请求便都是 Ajax 请求了,如图所示:
接下来,不断滑动页面,可以看到页面底部有一条条新的微博被刷出,而开发者工具下方也不断地出现 Ajax 请求,这样我们就可以捕获到所有的 Ajax 请求了。
随意点开一个条目,都可以清楚地看到其 Request URL、Request Headers、Response Headers、Response Body 等内容,此时想要模拟请求和提取就非常简单了。
下图所示的内容便是我某一页微博的列表信息:
到现在为止,我们已经可以分析出 Ajax 请求的一些详细信息了,接下来只需要用程序模拟这些 Ajax 请求,就可以轻松提取我们所需要的信息了。
上一课时我们学习了 Ajax 的基本原理和分析方法,这一课时我们结合实际案例,学习 Ajax 分析和爬取页面的具体实现。
在开始学习之前,我们需要做好如下的准备工作:
安装好 Python 3(最低为 3.6 版本),并能成功运行 Python 3 程序。
了解 Python HTTP 请求库 requests 的基本用法。
了解 Ajax 的基础知识和分析 Ajax 的基本方法。
以上内容在前面的课时中均有讲解,如你尚未准备好建议先熟悉一下这些内容。
本课时我们以一个动态渲染网站为例来试验一下 Ajax 的爬取。其链接为:https://dynamic1.scrape.center/,页面如图所示。
这个页面看似和我们上一课时的案例一模一样,但其实不是,它的后台实现逻辑和数据加载方式与上一课时完全不同,只不过最后呈现的样式是一样的。
这个网站同样支持翻页,可以点击最下方的页码来切换到下一页,如图所示。
点击每一个电影的链接进入详情页,页面结构也是完全一样的,如图所示。
我们需要爬取的数据也和原来是相同的,包括电影的名称、封面、类别、上映日期、评分、剧情简介等信息。
本课时我们需要完成的目标有:
分析页面数据的加载逻辑。
用 requests 实现 Ajax 数据的爬取。
将每部电影的数据保存成一个 JSON 数据文件。
由于本课时主要讲解 Ajax,所以对于数据存储和加速部分就不再展开实现,主要是讲解 Ajax 的分析和爬取。
那么我们现在就开始正式学习吧。
首先,我们尝试用之前的 requests 来直接提取页面,看看会得到怎样的结果。用最简单的代码实现一下 requests 获取首页源码的过程,代码如下:
import requestsurl = 'https://dynamic1.scrape.center/'html = requests.get(url).textprint(html)
运行结果如下:
可以看到我们只爬取到了这么一点 HTML 内容,而在浏览器中打开这个页面却能看到这样的结果,如图所示。
也就是说在 HTML 中我们只能在源码中看到引用了一些 JavaScript 和 CSS 文件,并没有观察任何有关电影数据的信息。
如果遇到这样的情况,说明我们现在看到的整个页面是通过 JavaScript 渲染得到的,浏览器执行了 HTML 中所引用的 JavaScript 文件,JavaScript 通过调用一些数据加载和页面渲染的方法,才最终呈现了图中所示的页面。
在一般情况下,这些数据都是通过 Ajax 来加载的, JavaScript 在后台调用这些 Ajax 数据接口,得到数据之后,再把数据进行解析并渲染呈现出来,得到最终的页面。所以说,要想爬取这个页面,我们可以通过直接爬取 Ajax 接口获取数据。
在上一课时中,我们已经了解了用 Ajax 分析的基本方法。下面我们就来分析下 Ajax 接口的逻辑并实现数据爬取吧。
首先我们来分析下列表页的 Ajax 接口逻辑,打开浏览器开发者工具,切换到 Network 面板,勾选上 「Preserve Log」并切换到 「XHR」选项卡,如图所示。
接着,我们重新刷新页面,然后点击第 2 页、第 3 页、第 4 页的按钮,这时候可以看到页面上的数据发生了变化,同时在开发者工具下方会监听到几个 Ajax 请求,如图所示。
由于我们切换了 4 页,所以这里正好也出现了 4 个 Ajax 请求,我们可以任选一个点击查看其请求详情,观察其请求的 URL、参数以及响应内容是怎样的,如图所示。
这里我们点开第 2 个结果,观察到其 Ajax 接口请求的 URL 地址为:https://dynamic1.scrape.center/api/movie/?limit=10&offset=10,这里有两个参数,一个是 limit,其值为 10,一个是 offset,它的值也是 10。
通过观察多个 Ajax 接口的参数,我们可以发现这么一个规律:limit 的值一直为 10,这就正好对应着每页 10 条数据;offset 的值在依次变大,页面每加 1 页,offset 就加 10,这就代表着页面的数据偏移量,比如第 2 页的 offset 值为 10 代表跳过 10 条数据,返回从第 11 条数据开始的结果,再加上 limit 的限制,就代表返回第 11~20 条数据的结果。
接着我们再观察下响应的数据,切换到 Preview 选项卡,结果如图所示。
可以看到结果是一些 JSON 数据,它有一个 results 字段,这是一个列表,列表的每一个元素都是一个字典。观察一下字典的内容,发现我们可以看到对应的电影数据的字段了,如 name、alias、cover、categories,对比下浏览器中的真实数据,各个内容是完全一致的,而且这个数据已经非常结构化了,完全就是我们想要爬取的数据,真是得来全不费工夫。
这样的话,我们只需要把所有页面的 Ajax 接口构造出来,那么所有的列表页数据我们都可以轻松获取到了。
我们先定义一些准备工作,导入一些所需的库并定义一些配置,代码如下:
import requestsimport logginglogging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s: %(message)s')INDEX_URL = 'https://dynamic1.scrape.center/api/movie/?limit={limit}&offset={offset}'
这里我们引入了 requests 和 logging 库,并定义了 logging 的基本配置,接着我们定义 INDEX_URL,这里把 limit 和 offset 预留出来变成占位符,可以动态传入参数构造成一个完整的列表页 URL。
下面我们来实现一下列表页的爬取,还是和原来一样,我们先定义一个通用的爬取方法,代码如下:
def scrape_api(url): logging.info('scraping %s...', url) try: response = requests.get(url) if response.status_code == 200: return response.json() logging.error('get invalid status code %s while scraping %s', response.status_code, url) except requests.RequestException: logging.error('error occurred while scraping %s', url, exc_info=True)
这里我们定义一个 scrape_api 方法,和之前不同的是,这个方法专门用来处理 JSON 接口,最后的 response 调用的是 json 方法,它可以解析响应的内容并将其转化成 JSON 字符串。
在这个基础之上,我们定义一个爬取列表页的方法,代码如下:
LIMIT = 10def scrape_index(page): url = INDEX_URL.format(limit=LIMIT, offset=LIMIT * (page - 1)) return scrape_api(url)
这里我们定义了一个 scrape_index 方法,用来接收参数 page,page 代表列表页的页码。
这里我们先构造了一个 URL,通过字符串的 format 方法,传入 limit 和 offset 的值。这里的 limit 直接使用了全局变量 LIMIT 的值,offset 则是动态计算的,计算方法是页码数减 1 再乘以 limit,比如第 1 页的 offset 值就是 0,第 2 页的 offset 值就是 10,以此类推。构造好 URL 之后,直接调用 scrape_api 方法并返回结果即可。
这样我们就完成了列表页的爬取,每次请求都会得到一页 10 部的电影数据。
由于这时爬取到的数据已经是 JSON 类型了,所以我们不用像之前一样去解析 HTML 代码来提取数据,爬到的数据就是我们想要的结构化数据,因此解析这一步这里我们就可以直接省略啦。
到此为止,我们就能成功爬取列表页并提取出电影列表信息了。
这时候我们已经可以拿到每一页的电影数据了,但是实际上这些数据还缺少一些我们想要的信息,如剧情简介等,所以我们需要进一步进入到详情页来获取这些内容。
这时候我们点击任意一部电影,如《教父》,进入到其详情页面,这时候可以发现页面的 URL 已经变成了 https://dynamic1.scrape.center/detail/40,页面也成功展示了详情页的信息,如图所示。
另外我们也可以观察到在开发者工具中又出现了一个 Ajax 请求,其 URL 为 https://dynamic1.scrape.center/api/movie/40/,通过 Preview 选项卡也能看到 Ajax 请求对应响应的信息,如图所示。
稍加观察我们就可以发现,Ajax 请求的 URL 后面有一个参数是可变的,这个参数就是电影的 id,这里是 40,对应《教父》这部电影。
如果我们想要获取 id 为 50 的电影,只需要把 URL 最后的参数改成 50 即可,即 https://dynamic1.scrape.center/api/movie/50/,请求这个新的 URL 我们就能获取 id 为 50 的电影所对应的数据了。
同样的,它响应的结果也是结构化的 JSON 数据,字段也非常规整,我们直接爬取即可。
分析了详情页的数据提取逻辑,那么怎么把它和列表页关联起来呢?这个 id 又是从哪里来呢?我们回过头来再看看列表页的接口返回数据,如图所示。
可以看到列表页原本的返回数据就带了 id 这个字段,所以我们只需要拿列表页结果中的 id 来构造详情页中 Ajax 请求的 URL 就好了。
那么接下来,我们就先定义一个详情页的爬取逻辑吧,代码如下:
DETAIL_URL = 'https://dynamic1.scrape.center/api/movie/{id}'def scrape_detail(id): url = DETAIL_URL.format(id=id) return scrape_api(url)
这里我们定义了一个 scrape_detail 方法,它接收参数 id。这里的实现也非常简单,先根据定义好的 DETAIL_URL 加上 id,构造一个真实的详情页 Ajax 请求的 URL,然后直接调用 scrape_api 方法传入这个 URL 即可。
接着,我们定义一个总的调用方法,将以上的方法串联调用起来,代码如下:
TOTAL_PAGE = 10def main(): for page in range(1, TOTAL_PAGE + 1): index_data = scrape_index(page) for item in index_data.get('results'): id = item.get('id') detail_data = scrape_detail(id) logging.info('detail data %s', detail_data)
这里我们定义了一个 main 方法,首先遍历获取页码 page,然后把 page 当成参数传递给 scrape_index 方法,得到列表页的数据。接着我们遍历所有列表页的结果,获取每部电影的 id,然后把 id 当作参数传递给 scrape_detail 方法,来爬取每部电影的详情数据,赋值为 detail_data,输出即可。
运行结果如下:
2020-03-19 02:51:55,981 - INFO: scraping https://dynamic1.scrape.center/api/movie/?limit=10&offset=0...2020-03-19 02:51:56,446 - INFO: scraping https://dynamic1.scrape.center/api/movie/1...2020-03-19 02:51:56,638 - INFO: detail data {'id': 1, 'name': '霸王别姬', 'alias': 'Farewell My Concubine', 'cover': 'https://p0.meituan.net/movie/ce4da3e03e655b5b88ed31b5cd7896cf62472.jpg@464w_644h_1e_1c', 'categories': ['剧情', '爱情'], 'regions': ['中国大陆', '中国香港'], 'actors': [{'name': '张国荣', 'role': '程蝶衣', ...}, ...], 'directors': [{'name': '陈凯歌', 'image': 'https://p0.meituan.net/movie/8f9372252050095067e0e8d58ef3d939156407.jpg@128w_170h_1e_1c'}], 'score': 9.5, 'rank': 1, 'minute': 171, 'drama': '影片借一出《霸王别姬》的京戏,牵扯出三个人之间一段随时代风云变幻的爱恨情仇。段小楼(张丰毅 饰)与程蝶衣(张国荣 饰)是一对打小一起长大的师兄弟,...', 'photos': [...], 'published_at': '1993-07-26', 'updated_at': '2020-03-07T16:31:36.967843Z'}2020-03-19 02:51:56,640 - INFO: scraping https://dynamic1.scrape.center/api/movie/2...2020-03-19 02:51:56,813 - INFO: detail data {'id': 2, 'name': '这个杀手不太冷', 'alias': 'Léon', 'cover': 'https://p1.meituan.net/movie/6bea9af4524dfbd0b668eaa7e187c3df767253.jpg@464w_644h_1e_1c', 'categories': ['剧情', '动作', '犯罪'], 'regions': ['法国'], 'actors': [{'name': '让·雷诺', 'role': '莱昂 Leon', ...}, ...], 'directors': [{'name': '吕克·贝松', 'image': 'https://p0.meituan.net/movie/0e7d67e343bd3372a714093e8340028d40496.jpg@128w_170h_1e_1c'}], 'score': 9.5, 'rank': 3, 'minute': 110, 'drama': '里昂(让·雷诺 饰)是名孤独的职业杀手,受人雇佣。一天,邻居家小姑娘马蒂尔德(纳塔丽·波特曼 饰)敲开他的房门,要求在他那里暂避杀身之祸。...', 'photos': [...], 'published_at': '1994-09-14', 'updated_at': '2020-03-07T16:31:43.826235Z'}...
由于内容较多,这里省略了部分内容。
可以看到,其实整个爬取工作到这里就已经完成了,这里会先顺次爬取每一页列表页的 Ajax 接口,然后再顺次爬取每部电影详情页的 Ajax 接口,最后打印出每部电影的 Ajax 接口响应数据,而且都是 JSON 格式。这样,所有电影的详情数据都会被我们爬取到啦。
最后,让我们把爬取到的数据保存下来吧。之前我们是用 MongoDB 来存储数据,由于本课时重点讲解 Ajax 爬取,所以这里就一切从简,将数据保存为 JSON 文本。
定义一个数据保存的方法,代码如下:
import jsonfrom os import makedirsfrom os.path import existsRESULTS_DIR = 'results'exists(RESULTS_DIR) or makedirs(RESULTS_DIR)def save_data(data): name = data.get('name') data_path = f'{RESULTS_DIR}/{name}.json' json.dump(data, open(data_path, 'w', encoding='utf-8'), ensure_ascii=False, indent=2
在这里我们首先定义了数据保存的文件夹 RESULTS_DIR,注意,我们先要判断这个文件夹是否存在,如果不存在则需要创建。
接着,我们定义了保存数据的方法 save_data,首先我们获取数据的 name 字段,即电影的名称,把电影名称作为 JSON 文件的名称,接着构造 JSON 文件的路径,然后用 json 的 dump 方法将数据保存成文本格式。dump 的方法设置了两个参数,一个是 ensure_ascii,我们将其设置为 False,它可以保证中文字符在文件中能以正常的中文文本呈现,而不是 unicode 字符;另一个是 indent,它的数值为 2,这代表生成的 JSON 数据结果有两个空格缩进,让它的格式显得更加美观。
最后,main 方法再调用下 save_data 方法即可,实现如下:
def main(): for page in range(1, TOTAL_PAGE + 1): index_data = scrape_index(page) for item in index_data.get('results'): id = item.get('id') detail_data = scrape_detail(id) logging.info('detail data %s', detail_data) save_data(detail_data)
重新运行一下,我们发现本地 results 文件夹下出现了各个电影的 JSON 文件,如图所示。
这样我们就已经把所有的电影数据保存下来了,打开其中一个 JSON 文件,看看保存格式,如图所示。
可以看到 JSON 文件里面的数据都是经过格式化的中文文本数据,结构清晰,一目了然。
至此,我们就完成了全站电影数据的爬取并把每部电影的数据保存成了 JSON 文件。
本课时我们通过一个案例来体会了 Ajax 分析和爬取的基本流程,希望你能够对 Ajax 的分析和爬取的实现更加熟悉。
另外我们也可以观察到,由于 Ajax 接口大部分返回的是 JSON 数据,所以在一定程度上可以避免一些数据提取的工作,减轻我们的工作量。
上个课时我们讲解了 Ajax 的分析方法,利用 Ajax 接口我们可以非常方便地完成数据的爬取。只要我们能找到 Ajax 接口的规律,就可以通过某些参数构造出对应的的请求,数据自然就能被轻松爬取到。
但是,在很多情况下,Ajax 请求的接口通常会包含加密的参数,如 token、sign 等,如:https://dynamic2.scrape.center/,它的 Ajax 接口是包含一个 token 参数的,如图所示。
由于接口的请求加上了 token 参数,如果不深入分析并找到 token 的构造逻辑,我们是难以直接模拟这些 Ajax 请求的。
此时解决方法通常有两种,一种是深挖其中的逻辑,把其中 token 的构造逻辑完全找出来,再用 Python 复现,构造 Ajax 请求;另外一种方法就是直接通过模拟浏览器的方式,绕过这个过程。因为在浏览器里面我们是可以看到这个数据的,如果能直接把看到的数据爬取下来,当然也就能获取对应的信息了。
由于第 1 种方法难度较高,在这里我们就先介绍第 2 种方法,模拟浏览器爬取。
这里使用的工具为 Selenium,我们先来了解一下 Selenium 的基本使用方法吧。
Selenium 是一个自动化测试工具,利用它可以驱动浏览器执行特定的动作,如点击、下拉等操作,同时还可以获取浏览器当前呈现的页面源代码,做到可见即可爬。对于一些使用 JavaScript 动态渲染的页面来说,此种抓取方式非常有效。本课时就让我们来感受一下它的强大之处吧。
本课时以 Chrome 为例来讲解 Selenium 的用法。在开始之前,请确保已经正确安装好了 Chrome 浏览器并配置好了 ChromeDriver。另外,还需要正确安装好 Python 的 Selenium 库。
安装过程可以参考:https://cuiqingcai.com/5135.html 和 https://cuiqingcai.com/5141.html。
准备工作做好之后,首先来看一下 Selenium 有一些怎样的功能。示例如下:
from selenium import webdriver from selenium.webdriver.common.by import By from selenium.webdriver.common.keys import Keys from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.support.wait import WebDriverWaitbrowser = webdriver.Chrome() try:browser.get('https://www.baidu.com')input = browser.find_element_by_id('kw')input.send_keys('Python')input.send_keys(Keys.ENTER)wait = WebDriverWait(browser, 10)wait.until(EC.presence_of_element_located((By.ID, 'content_left')))print(browser.current_url)print(browser.get_cookies())print(browser.page_source) finally:browser.close()
运行代码后会自动弹出一个 Chrome 浏览器,浏览器会跳转到百度,然后在搜索框中输入 Python,接着跳转到搜索结果页,如图所示。
此时在控制台的输出结果如下:
https://www.baidu.com/s?ie=utf-8&f=8&rsv_bp=0&rsv_idx=1&tn=baidu&wd=Python&rsv_pq= c94d0df9000a72d0&rsv_t=07099xvun1ZmC0bf6eQvygJ43IUTTUOl5FCJVPgwG2YREs70GplJjH2F%2BCQ&rqlang=cn&rsv_enter=1&rsv_sug3=6&rsv_sug2=0&inputT=87&rsv_sug4=87 [{'secure': False, 'value': 'B490B5EBF6F3CD402E515D22BCDA1598', 'domain': '.baidu.com', 'path': '/', 'httpOnly': False, 'name': 'BDORZ', 'expiry': 1491688071.707553}, {'secure': False, 'value': '22473_1441_21084_17001', 'domain': '.baidu.com', 'path': '/', 'httpOnly': False, 'name': 'H_PS_PSSID'}, {'secure': False, 'value': '12883875381399993259_00_0_I_R_2_0303_C02F_N_I_I_0', 'domain': '.www.baidu.com', 'path': '/', 'httpOnly': False, 'name': '__bsi', 'expiry': 1491601676.69722}]<!DOCTYPE html><!--STATUS OK-->...</html>
源代码过长,在此省略。可以看到,当前我们得到的 URL、Cookies 和源代码都是浏览器中的真实内容。
所以说,如果用 Selenium 来驱动浏览器加载网页的话,就可以直接拿到 JavaScript 渲染的结果了,不用担心使用的是什么加密系统。
下面来详细了解一下 Selenium 的用法。
Selenium 支持非常多的浏览器,如 Chrome、Firefox、Edge 等,还有 Android、BlackBerry 等手机端的浏览器。
此外,我们可以用如下方式进行初始化:
from selenium import webdriverbrowser = webdriver.Chrome() browser = webdriver.Firefox() browser = webdriver.Edge() browser = webdriver.Safari()
这样就完成了浏览器对象的初始化并将其赋值为 browser 对象。接下来,我们要做的就是调用 browser 对象,让其执行各个动作以模拟浏览器操作。
我们可以用 get 方法来请求网页,只需要把参数传入链接 URL 即可。比如,这里用 get 方法访问淘宝,然后打印出源代码,代码如下:
from selenium import webdriverbrowser = webdriver.Chrome() browser.get('https://www.taobao.com') print(browser.page_source) browser.close()
运行后会弹出 Chrome 浏览器并且自动访问淘宝,然后控制台会输出淘宝页面的源代码,随后浏览器关闭。
通过这几行简单的代码,我们就可以驱动浏览器并获取网页源码,非常便捷。
Selenium 可以驱动浏览器完成各种操作,比如填充表单、模拟点击等。举个例子,当我们想要完成向某个输入框输入文字的操作时,首先需要知道这个输入框在哪,而 Selenium 提供了一系列查找节点的方法,我们可以用这些方法来获取想要的节点,以便执行下一步动作或者提取信息。
单个节点
当我们想要从淘宝页面中提取搜索框这个节点,首先要观察它的源代码,如图所示。
可以发现,它的 id 是 q,name 也是 q,此外还有许多其他属性。此时我们就可以用多种方式获取它了。比如,find_element_by_name 代表根据 name 值获取,find_element_by_id 则是根据 id 获取,另外,还有根据 XPath、CSS 选择器等获取的方式。
我们用代码实现一下:
from selenium import webdriverbrowser = webdriver.Chrome() browser.get('https://www.taobao.com') input_first = browser.find_element_by_id('q') input_second = browser.find_element_by_css_selector('#q') input_third = browser.find_element_by_xpath('//*[@id="q"]') print(input_first, input_second, input_third) browser.close()
这里我们使用 3 种方式获取输入框,分别是根据 id、CSS 选择器和 XPath 获取,它们返回的结果完全一致。运行结果如下:
<selenium.webdriver.remote.webelement.WebElement (session="5e53d9e1c8646e44c14c1c2880d424af", element="0.5649563096161541-1")> <selenium.webdriver.remote.webelement.WebElement (session ="5e53d9e1c8646e44c14c1c2880d424af", element="0.5649563096161541-1")> <selenium.webdriver. remote.webelement.WebElement (session="5e53d9e1c8646e44c14c1c2880d424af", element="0.5649563096161541-1")>
可以看到,这 3 个节点的类型是一致的,都是 WebElement。
这里列出所有获取单个节点的方法:
find_element_by_id find_element_by_name find_element_by_xpath find_element_by_link_text find_element_by_partial_link_text find_element_by_tag_name find_element_by_class_name find_element_by_css_selector
另外,Selenium 还提供了 find_element 这个通用方法,它需要传入两个参数:查找方式 By 和值。实际上,find_element 就是 find_element_by_id 这种方法的通用函数版本,比如 find_element_by_id(id) 就等价于 find_element(By.ID, id),二者得到的结果完全一致。我们用代码实现一下:
from selenium import webdriver from selenium.webdriver.common.by import Bybrowser = webdriver.Chrome() browser.get('https://www.taobao.com') input_first = browser.find_element(By.ID, 'q') print(input_first) browser.close()
这种查找方式的功能和上面列举的查找函数完全一致,不过参数更加灵活。
多个节点
如果在网页中只查找一个目标,那么完全可以用 find_element 方法。但如果有多个节点需要查找,再用 find_element 方法,就只能得到第 1 个节点了。如果要查找所有满足条件的节点,需要用 find_elements 这样的方法。注意,在这个方法的名称中,element 多了一个 s,注意区分。
举个例子,假如你要查找淘宝左侧导航条的所有条目,就可以这样来实现:
from selenium import webdriver browser = webdriver.Chrome() browser.get('https://www.taobao.com') lis = browser.find_elements_by_css_selector('.service-bd li') print(lis) browser.close()
运行结果如下:
[<selenium.webdriver.remote.webelement.WebElement (session="c26290835d4457ebf7d96bfab3740d19", element="0.09221044033125603-1")>, <selenium.webdriver.remote.webelement.WebElement (session="c26290835d4457ebf7d96bfab3740d19", element="0.09221044033125603-2")>,<selenium.webdriver.remote.webelement.WebElement (session="c26290835d4457ebf7d96bfab3740d19", element="0.09221044033125603-3")>...<selenium.webdriver.remote.webelement.WebElement (session="c26290835d4457ebf7d96bfab3740d19", element="0.09221044033125603-16")>]
这里简化了输出结果,中间部分省略。
可以看到,得到的内容变成了列表类型,列表中的每个节点都是 WebElement 类型。
也就是说,如果我们用 find_element 方法,只能获取匹配的第一个节点,结果是 WebElement 类型。如果用 find_elements 方法,则结果是列表类型,列表中的每个节点是 WebElement 类型。
这里列出所有获取多个节点的方法:
find_elements_by_id find_elements_by_name find_elements_by_xpath find_elements_by_link_text find_elements_by_partial_link_text find_elements_by_tag_name find_elements_by_class_name find_elements_by_css_selector
当然,我们也可以直接用 find_elements 方法来选择,这时可以这样写:
lis = browser.find_elements(By.CSS_SELECTOR, '.service-bd li')
结果是完全一致的。
Selenium 可以驱动浏览器来执行一些操作,或者说可以让浏览器模拟执行一些动作。比较常见的用法有:输入文字时用 send_keys 方法,清空文字时用 clear 方法,点击按钮时用 click 方法。示例如下:
from selenium import webdriver import time browser = webdriver.Chrome() browser.get('https://www.taobao.com') input = browser.find_element_by_id('q') input.send_keys('iPhone') time.sleep(1) input.clear() input.send_keys('iPad') button = browser.find_element_by_class_name('btn-search') button.click()
这里首先驱动浏览器打开淘宝,用 find_element_by_id 方法获取输入框,然后用 send_keys 方法输入 iPhone 文字,等待一秒后用 clear 方法清空输入框,接着再次调用 send_keys 方法输入 iPad 文字,之后再用 find_element_by_class_name 方法获取搜索按钮,最后调用 click 方法完成搜索动作。
通过上面的方法,我们就完成了一些常见节点的动作操作,更多的操作可以参见官方文档的交互动作介绍 :http://selenium-python.readthedocs.io/api.html#module-selenium.webdriver.remote.webelement。
在上面的实例中,一些交互动作都是针对某个节点执行的。比如,对于输入框,我们调用它的输入文字和清空文字方法;对于按钮,我们调用它的点击方法。其实,还有另外一些操作,它们没有特定的执行对象,比如鼠标拖拽、键盘按键等,这些动作用另一种方式来执行,那就是动作链。
比如,现在我要实现一个节点的拖拽操作,将某个节点从一处拖拽到另外一处,可以这样实现:
from selenium import webdriver from selenium.webdriver import ActionChains browser = webdriver.Chrome() url = 'http://www.runoob.com/try/try.php?filename=jqueryui-api-droppable' browser.get(url) browser.switch_to.frame('iframeResult') source = browser.find_element_by_css_selector('#draggable') target = browser.find_element_by_css_selector('#droppable') actions = ActionChains(browser) actions.drag_and_drop(source, target) actions.perform()
首先,打开网页中的一个拖拽实例,依次选中要拖拽的节点和拖拽到的目标节点,接着声明 ActionChains 对象并将其赋值为 actions 变量,然后通过调用 actions 变量的 drag_and_drop 方法,再调用 perform 方法执行动作,此时就完成了拖拽操作,如图所示:
拖拽前页面
拖拽后页面
以上两图分别为在拖拽前和拖拽后的结果。
更多的动作链操作可以参考官方文档的动作链介绍:http://selenium-python.readthedocs.io/api.html#module-selenium.webdriver.common.action_chains。
Selenium API 并没有提供实现某些操作的方法,比如,下拉进度条。但它可以直接模拟运行 JavaScript,此时使用 execute_script 方法即可实现,代码如下:
from selenium import webdriver browser = webdriver.Chrome() browser.get('https://www.zhihu.com/explore') browser.execute_script('window.scrollTo(0, document.body.scrollHeight)') browser.execute_script('alert("To Bottom")')
这里利用 execute_script 方法将进度条下拉到最底部,然后弹出 alert 提示框。
有了这个方法,基本上 API 没有提供的所有功能都可以用执行 JavaScript 的方式来实现了。
前面说过,通过 page_source 属性可以获取网页的源代码,接着就可以使用解析库(如正则表达式、Beautiful Soup、pyquery 等)来提取信息了。
不过,既然 Selenium 已经提供了选择节点的方法,并且返回的是 WebElement 类型,那么它也有相关的方法和属性来直接提取节点信息,如属性、文本等。这样的话,我们就可以不用通过解析源代码来提取信息了,非常方便。
接下来,我们就来看看可以通过怎样的方式来获取节点信息吧。
获取属性
我们可以使用 get_attribute 方法来获取节点的属性,但是前提是得先选中这个节点,示例如下:
from selenium import webdriver browser = webdriver.Chrome() url = 'https://dynamic2.scrape.center/' browser.get(url) logo = browser.find_element_by_class_name('logo-image')print(logo) print(logo.get_attribute('src'))
运行之后,程序便会驱动浏览器打开该页面,然后获取 class 为 logo-image 的节点,最后打印出它的 src 属性。
控制台的输出结果如下:
<selenium.webdriver.remote.webelement.WebElement (session="7f4745d35a104759239b53f68a6f27d0", element="cd7c72b4-4920-47ed-91c5-ea06601dc509")> https://dynamic2.scrape.center/img/logo.a508a8f0.png
通过 get_attribute 方法,我们只需要传入想要获取的属性名,就可以得到它的值了。
获取文本值
每个 WebElement 节点都有 text 属性,直接调用这个属性就可以得到节点内部的文本信息,这相当于 pyquery 的 text 方法,示例如下:
from selenium import webdriver browser = webdriver.Chrome()url = 'https://dynamic2.scrape.center/' browser.get(url)input = browser.find_element_by_class_name('logo-title') print(input.text)
这里依然先打开页面,然后获取 class 为 logo-title 这个节点,再将其文本值打印出来。
控制台的输出结果如下:
Scrape
另外,WebElement 节点还有一些其他属性,比如 id 属性可以获取节点 id,location 属性可以获取该节点在页面中的相对位置,tag_name 属性可以获取标签名称,size 属性可以获取节点的大小,也就是宽高,这些属性有时候还是很有用的。示例如下:
from selenium import webdriver browser = webdriver.Chrome() url = 'https://dynamic2.scrape.center/' browser.get(url) input = browser.find_element_by_class_name('logo-title') print(input.id) print(input.location) print(input.tag_name) print(input.size)
这里首先获得 class 为 logo-title 这个节点,然后调用其 id、location、tag_name、size 属性来获取对应的属性值。
我们知道网页中有一种节点叫作 iframe,也就是子 Frame,相当于页面的子页面,它的结构和外部网页的结构完全一致。Selenium 打开页面后,默认是在父级 Frame 里面操作,而此时如果页面中还有子 Frame,Selenium 是不能获取到子 Frame 里面的节点的。这时就需要使用 switch_to.frame 方法来切换 Frame。示例如下:
import time from selenium import webdriver from selenium.common.exceptions import NoSuchElementException browser = webdriver.Chrome()url = 'http://www.runoob.com/try/try.php?filename=jqueryui-api-droppable' browser.get(url) browser.switch_to.frame('iframeResult')try: logo = browser.find_element_by_class_name('logo') except NoSuchElementException: print('NO LOGO') browser.switch_to.parent_frame() logo = browser.find_element_by_class_name('logo')print(logo) print(logo.text)
控制台输出:
NO LOGO <selenium.webdriver.remote.webelement.WebElement(session="4bb8ac03ced4ecbdefef03ffdc0e4ccd", element="0.13792611320464965-2")> RUNOOB.COM
这里还是以前面演示动作链操作的网页为例,首先通过 switch_to.frame 方法切换到子 Frame 里面,然后尝试获取子 Frame 里的 logo 节点(这是不能找到的),如果找不到的话,就会抛出 NoSuchElementException 异常,异常被捕捉之后,就会输出 NO LOGO。接下来,我们需要重新切换回父级 Frame,然后再次重新获取节点,发现此时可以成功获取了。
所以,当页面中包含子 Frame 时,如果想获取子 Frame 中的节点,需要先调用 switch_to.frame 方法切换到对应的 Frame,然后再进行操作。
在 Selenium 中,get 方法会在网页框架加载结束后结束执行,此时如果获取 page_source,可能并不是浏览器完全加载完成的页面,如果某些页面有额外的 Ajax 请求,我们在网页源代码中也不一定能成功获取到。所以,这里需要延时等待一定时间,确保节点已经加载出来。
这里等待的方式有两种:一种是隐式等待,一种是显式等待。
隐式等待
当使用隐式等待执行测试的时候,如果 Selenium 没有在 DOM 中找到节点,将继续等待,超出设定时间后,则抛出找不到节点的异常。换句话说,隐式等待可以在我们查找节点而节点并没有立即出现的时候,等待一段时间再查找 DOM,默认的时间是 0。示例如下:
from selenium import webdriver browser = webdriver.Chrome() browser.implicitly_wait(10) browser.get('https://dynamic2.scrape.center/') input = browser.find_element_by_class_name('logo-image') print(input)
在这里我们用 implicitly_wait 方法实现了隐式等待。
显式等待
隐式等待的效果其实并没有那么好,因为我们只规定了一个固定时间,而页面的加载时间会受到网络条件的影响。
这里还有一种更合适的显式等待方法,它指定要查找的节点,然后指定一个最长等待时间。如果在规定时间内加载出来了这个节点,就返回查找的节点;如果到了规定时间依然没有加载出该节点,则抛出超时异常。示例如下:
from selenium import webdriver from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC browser = webdriver.Chrome() browser.get('https://www.taobao.com/') wait = WebDriverWait(browser, 10) input = wait.until(EC.presence_of_element_located((By.ID, 'q'))) button = wait.until(EC.element_to_be_clickable((By.CSS_SELECTOR, '.btn-search'))) print(input, button)
这里首先引入 WebDriverWait 这个对象,指定最长等待时间,然后调用它的 until() 方法,传入要等待条件 expected_conditions。比如,这里传入了 presence_of_element_located 这个条件,代表节点出现,其参数是节点的定位元组,也就是 ID 为 q 的节点搜索框。
这样做的效果就是,在 10 秒内如果 ID 为 q 的节点(即搜索框)成功加载出来,就返回该节点;如果超过 10 秒还没有加载出来,就抛出异常。
对于按钮,我们可以更改一下等待条件,比如改为 element_to_be_clickable,也就是可点击,所以查找按钮时先查找 CSS 选择器为.btn-search 的按钮,如果 10 秒内它是可点击的,也就代表它成功加载出来了,就会返回这个按钮节点;如果超过 10 秒还不可点击,也就是没有加载出来,就抛出异常。
现在我们运行代码,它在网速较佳的情况下是可以成功加载出来的。
控制台的输出如下:
<selenium.webdriver.remote.webelement.WebElement (session="07dd2fbc2d5b1ce40e82b9754aba8fa8", element="0.5642646294074107-1")><selenium.webdriver.remote.webelement.WebElement (session="07dd2fbc2d5b1ce40e82b9754aba8fa8", element="0.5642646294074107-2")>
可以看到,控制台成功输出了两个节点,它们都是 WebElement 类型。
如果网络有问题,10 秒内没有成功加载,那就抛出 TimeoutException 异常,此时控制台的输出如下:
TimeoutException Traceback (most recent call last) <ipython-input-4-f3d73973b223> in <module>() 7 browser.get('https://www.taobao.com/') 8 wait = WebDriverWait(browser, 10) ----> 9 input = wait.until(EC.presence_of_element_located((By.ID, 'q')))
关于等待条件,其实还有很多,比如判断标题内容,判断某个节点内是否出现了某文字等。下表我列出了所有的等待条件。
更多详细的等待条件的参数及用法介绍可以参考官方文档:http://selenium-python.readthedocs.io/api.html#module-selenium.webdriver.support.expected_conditions。
平常我们使用浏览器时都有前进和后退功能,Selenium 也可以完成这个操作,它使用 back 方法后退,使用 forward 方法前进。示例如下:
import time from selenium import webdriver browser = webdriver.Chrome() browser.get('https://www.baidu.com/') browser.get('https://www.taobao.com/') browser.get('https://www.python.org/') browser.back() time.sleep(1) browser.forward() browser.close()
这里我们连续访问 3 个页面,然后调用 back 方法回到第 2 个页面,接下来再调用 forward 方法又可以前进到第 3 个页面。
使用 Selenium,还可以方便地对 Cookies 进行操作,例如获取、添加、删除 Cookies 等。示例如下:
from selenium import webdriver browser = webdriver.Chrome() browser.get('https://www.zhihu.com/explore') print(browser.get_cookies()) browser.add_cookie({'name': 'name', 'domain': 'www.zhihu.com', 'value': 'germey'}) print(browser.get_cookies()) browser.delete_all_cookies() print(browser.get_cookies())
首先,我们访问知乎,加载完成后,浏览器实际上已经生成 Cookies 了。接着,调用 get_cookies 方法获取所有的 Cookies。然后,我们再添加一个 Cookie,这里传入一个字典,有 name、domain 和 value 等内容。接下来,再次获取所有的 Cookies,可以发现,结果会多出这一项新加的 Cookie。最后,调用 delete_all_cookies 方法删除所有的 Cookies。再重新获取,发现结果就为空了。
控制台的输出如下:
[{'secure': False, 'value': '"NGM0ZTM5NDAwMWEyNDQwNDk5ODlkZWY3OTkxY2I0NDY=|1491604091|236e34290a6f407bfbb517888849ea509ac366d0"', 'domain': '.zhihu.com','path': '/', 'httpOnly': False, 'name': 'l_cap_id', 'expiry': 1494196091.403418},...] [{'secure': False, 'value': 'germey', 'domain': '.www.zhihu.com', 'path': '/', 'httpOnly': False, 'name': 'name'}, {'secure': False, 'value': '"NGM0ZTM5NDAwMWEyNDQwNDk5ODlkZWY3OTkxY2I0NDY=|1491604091|236e34290a6f407bfbb517888849ea509ac366d0"', 'domain': '.zhihu.com', 'path':'/', 'httpOnly': False, 'name': 'l_cap_id', 'expiry': 1494196091.403418}, ...] []
通过以上方法来操作 Cookies 还是非常方便的。
在访问网页的时候,我们通常会开启多个选项卡。在 Selenium 中,我们也可以对选项卡进行操作。示例如下:
import time from selenium import webdriver browser = webdriver.Chrome() browser.get('https://www.baidu.com') browser.execute_script('window.open()') print(browser.window_handles) browser.switch_to.window(browser.window_handles[1])browser.get('https://www.taobao.com') time.sleep(1) browser.switch_to.window(browser.window_handles[0]) browser.get('https://python.org'
控制台输出如下:
['CDwindow-4f58e3a7-7167-4587-bedf-9cd8c867f435', 'CDwindow-6e05f076-6d77-453a-a36c-32baacc447df']
首先访问百度,然后调用 execute_script 方法,这里我们传入 window.open 这个 JavaScript 语句新开启一个选项卡,然后切换到该选项卡,调用 window_handles 属性获取当前开启的所有选项卡,后面的参数代表返回选项卡的代号列表。要想切换选项卡,只需要调用 switch_to.window 方法即可,其中的参数是选项卡的代号。这里我们将第 2 个选项卡代号传入,即跳转到第 2 个选项卡,接下来在第 2 个选项卡下打开一个新页面,如果你想要切换回第 2 个选项卡,只需要重新调用 switch_to.window 方法,再执行其他操作即可。
在使用 Selenium 的过程中,难免会遇到一些异常,例如超时、节点未找到等错误,一旦出现此类错误,程序便不会继续运行了。这里我们可以使用 try except 语句来捕获各种异常。
首先,演示一下节点未找到的异常,示例如下:
from selenium import webdriver browser = webdriver.Chrome() browser.get('https://www.baidu.com') browser.find_element_by_id('hello')
这里我们首先打开百度页面,然后尝试选择一个并不存在的节点,此时就会遇到异常。
运行之后控制台的输出如下:
NoSuchElementException Traceback (most recent call last) <ipython-input-23-978945848a1b> in <module>() 3 browser = webdriver.Chrome() 4 browser.get ('https://www.baidu.com')----> 5 browser.find_element_by_id('hello')
可以看到,这里抛出了 NoSuchElementException 异常,通常代表节点未找到。为了防止程序遇到异常而中断,我们需要捕获这些异常,示例如下:
from selenium import webdriver from selenium.common.exceptions import TimeoutException, NoSuchElementException browser = webdriver.Chrome()try: browser.get('https://www.baidu.com') except TimeoutException: print('Time Out') try: browser.find_element_by_id('hello') except NoSuchElementException: print('No Element') finally: browser.close()
这里我们使用 try except 来捕获各类异常。比如,我们用 find_element_by_id 查找节点的方法捕获 NoSuchElementException 异常,这样一旦出现这样的错误,就进行异常处理,程序也不会中断了。
控制台的输出如下:
No Element
关于更多的异常类,可以参考官方文档::http://selenium-python.readthedocs.io/api.html#module-selenium.common.exceptions。
现在很多网站都加上了对 Selenium 的检测,来防止一些爬虫的恶意爬取。即如果检测到有人在使用 Selenium 打开浏览器,那就直接屏蔽。
其大多数情况下,检测基本原理是检测当前浏览器窗口下的 window.navigator 对象是否包含 webdriver 这个属性。因为在正常使用浏览器的情况下,这个属性是 undefined,然而一旦我们使用了 Selenium,Selenium 会给 window.navigator 设置 webdriver 属性。很多网站就通过 JavaScript 判断如果 webdriver 属性存在,那就直接屏蔽。
这边有一个典型的案例网站:https://antispider1.scrape.center/,这个网站就是使用了上述原理实现了 WebDriver 的检测,如果使用 Selenium 直接爬取的话,那就会返回如下页面:
这时候我们可能想到直接使用 JavaScript 直接把这个 webdriver 属性置空,比如通过调用 execute_script 方法来执行如下代码:
Object.defineProperty(navigator, "webdriver", {get: () => undefined})
这行 JavaScript 的确是可以把 webdriver 属性置空,但是 execute_script 调用这行 JavaScript 语句实际上是在页面加载完毕之后才执行的,执行太晚了,网站早在最初页面渲染之前就已经对 webdriver 属性进行了检测,所以用上述方法并不能达到效果。
在 Selenium 中,我们可以使用 CDP(即 Chrome Devtools-Protocol,Chrome 开发工具协议)来解决这个问题,通过 CDP 我们可以实现在每个页面刚加载的时候执行 JavaScript 代码,执行的 CDP 方法叫作 Page.addScriptToEvaluateOnNewDocument,然后传入上文的 JavaScript 代码即可,这样我们就可以在每次页面加载之前将 webdriver 属性置空了。另外我们还可以加入几个选项来隐藏 WebDriver 提示条和自动化扩展信息,代码实现如下:
from selenium import webdriverfrom selenium.webdriver import ChromeOptionsoption = ChromeOptions()option.add_experimental_option('excludeSwitches', ['enable-automation'])option.add_experimental_option('useAutomationExtension', False)browser = webdriver.Chrome(options=option)browser.execute_cdp_cmd('Page.addScriptToEvaluateOnNewDocument', { 'source': 'Object.defineProperty(navigator, "webdriver", {get: () => undefined})'})browser.get('https://antispider1.scrape.center/')
这样整个页面就能被加载出来了:
对于大多数的情况,以上的方法均可以实现 Selenium 反屏蔽。但对于一些特殊的网站,如果其有更多的 WebDriver 特征检测,可能需要具体排查。
上面的案例在运行的时候,我们可以观察到其总会弹出一个浏览器窗口,虽然有助于观察页面爬取状况,但在有些时候窗口弹来弹去也会形成一些干扰。
Chrome 浏览器从 60 版本已经支持了无头模式,即 Headless。无头模式在运行的时候不会再弹出浏览器窗口,减少了干扰,而且它减少了一些资源的加载,如图片等资源,所以也在一定程度上节省了资源加载时间和网络带宽。
我们可以借助于 ChromeOptions 来开启 Chrome Headless 模式,代码实现如下:
from selenium import webdriverfrom selenium.webdriver import ChromeOptionsoption = ChromeOptions()option.add_argument('--headless')browser = webdriver.Chrome(options=option)browser.set_window_size(1366, 768)browser.get('https://www.baidu.com')browser.get_screenshot_as_file('preview.png')
这里我们通过 ChromeOptions 的 add_argument 方法添加了一个参数 --headless,开启了无头模式。在无头模式下,我们最好需要设置下窗口的大小,接着打开页面,最后我们调用 get_screenshot_as_file 方法输出了页面的截图。
运行代码之后,我们发现 Chrome 窗口就不会再弹出来了,代码依然正常运行,最后输出了页面截图如图所示。
这样我们就在无头模式下完成了页面的抓取和截图操作。
现在,我们基本对 Selenium 的常规用法有了大体的了解。使用 Selenium,处理 JavaScript 渲染的页面不再是难事。
在上一课时我们学习了 Selenium 的基本用法,本课时我们就来结合一个实际的案例来体会一下 Selenium 的适用场景以及使用方法。
在本课时开始之前,请确保已经做好了如下准备工作:
安装好 Chrome 浏览器并正确配置了 ChromeDriver。
安装好 Python (至少为 3.6 版本)并能成功运行 Python 程序。
安装好了 Selenium 相关的包并能成功用 Selenium 打开 Chrome 浏览器。
在前面的实战案例中,有的网页我们可以直接用 requests 来爬取,有的可以直接通过分析 Ajax 来爬取,不同的网站类型有其适用的爬取方法。
Selenium 同样也有其适用场景。对于那些带有 JavaScript 渲染的网页,我们多数情况下是无法直接用 requests 爬取网页源码的,不过在有些情况下我们可以直接用 requests 来模拟 Ajax 请求来直接得到数据。
然而在有些情况下 Ajax 的一些请求接口可能带有一些加密参数,如 token、sign 等等,如果不分析清楚这些参数是怎么生成的话,我们就难以模拟和构造这些参数。怎么办呢?这时候我们可以直接选择使用 Selenium 驱动浏览器渲染的方式来另辟蹊径,实现所见即所得的爬取,这样我们就无需关心在这个网页背后发生了什么请求、得到什么数据以及怎么渲染页面这些过程,我们看到的页面就是最终浏览器帮我们模拟了 Ajax 请求和 JavaScript 渲染得到的最终结果,而 Selenium 正好也能拿到这个最终结果,相当于绕过了 Ajax 请求分析和模拟的阶段,直达目标。
然而 Selenium 当然也有其局限性,它的爬取效率较低,有些爬取需要模拟浏览器的操作,实现相对烦琐。不过在某些场景下也不失为一种有效的爬取手段。
本课时我们就拿一个适用 Selenium 的站点来做案例,其链接为:https://dynamic2.scrape.center/,还是和之前一样的电影网站,页面如图所示。
初看之下页面和之前也没有什么区别,但仔细观察可以发现其 Ajax 请求接口和每部电影的 URL 都包含了加密参数。
比如我们点击任意一部电影,观察一下 URL 的变化,如图所示。
这里我们可以看到详情页的 URL 和之前就不一样了,在之前的案例中,URL 的 detail 后面本来直接跟的是 id,如 1、2、3 等数字,但是这里直接变成了一个长字符串,看似是一个 Base64 编码的内容,所以这里我们无法直接根据规律构造详情页的 URL 了。
好,那么接下来我们直接看看 Ajax 的请求,我们从列表页的第 1 页到第 10 页依次点一下,观察一下 Ajax 请求是怎样的,如图所示。
可以看到这里接口的参数比之前多了一个 token,而且每次请求的 token 都是不同的,这个 token 同样看似是一个 Base64 编码的字符串。更困难的是,这个接口还是有时效性的,如果我们把 Ajax 接口 URL 直接复制下来,短期内是可以访问的,但是过段时间之后就无法访问了,会直接返回 401 状态码。
那现在怎么办呢?之前我们可以直接用 requests 来构造 Ajax 请求,但现在 Ajax 请求接口带了这个 token,而且还是可变的,现在我们也不知道 token 的生成逻辑,那就没法直接通过构造 Ajax 请求的方式来爬取了。这时候我们可以把 token 的生成逻辑分析出来再模拟 Ajax 请求,但这种方式相对较难。所以这里我们可以直接用 Selenium 来绕过这个阶段,直接获取最终 JavaScript 渲染完成的页面源码,再提取数据就好了。
所以本课时我们要完成的目标有:
通过 Selenium 遍历列表页,获取每部电影的详情页 URL。
通过 Selenium 根据上一步获取的详情页 URL 爬取每部电影的详情页。
提取每部电影的名称、类别、分数、简介、封面等内容。
首先要我们要做如下初始化的工作,代码如下:
from selenium import webdriverfrom selenium.common.exceptions import TimeoutExceptionfrom selenium.webdriver.common.by import Byfrom selenium.webdriver.support import expected_conditions as ECfrom selenium.webdriver.support.wait import WebDriverWaitimport logginglogging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s: %(message)s')INDEX_URL = 'https://dynamic2.scrape.center/page/{page}'TIME_OUT = 10TOTAL_PAGE = 10browser = webdriver.Chrome()wait = WebDriverWait(browser, TIME_OUT)
首先我们导入了一些必要的 Selenium 模块,包括 webdriver、WebDriverWait 等等,后面我们会用到它们来实现页面的爬取和延迟等待等设置。然后接着定义了一些变量和日志配置,和之前几课时的内容是类似的。接着我们使用 Chrome 类生成了一个 webdriver 对象,赋值为 browser,这里我们可以通过 browser 调用 Selenium 的一些 API 来完成一些浏览器的操作,如截图、点击、下拉等等。最后我们又声明了一个 WebDriverWait 对象,利用它我们可以配置页面加载的最长等待时间。
好,接下来我们就观察下列表页,实现列表页的爬取吧。这里可以观察到列表页的 URL 还是有一定规律的,比如第一页为 https://dynamic2.scrape.center/page/1,页码就是 URL 最后的数字,所以这里我们可以直接来构造每一页的 URL。
那么每个列表页要怎么判断是否加载成功了呢?很简单,当页面出现了我们想要的内容就代表加载成功了。在这里我们就可以用 Selenium 的隐式判断条件来判定,比如每部电影的信息区块的 CSS 选择器为 #index .item,如图所示。
所以这里我们直接使用 visibility_of_all_elements_located 判断条件加上 CSS 选择器的内容即可判定页面有没有加载出来,配合 WebDriverWait 的超时配置,我们就可以实现 10 秒的页面的加载监听。如果 10 秒之内,我们所配置的条件符合,则代表页面加载成功,否则则会抛出 TimeoutException 异常。
代码实现如下:
def scrape_page(url, condition, locator): logging.info('scraping %s', url) try: browser.get(url) wait.until(condition(locator)) except TimeoutException: logging.error('error occurred while scraping %s', url, exc_info=True)def scrape_index(page): url = INDEX_URL.format(page=page) scrape_page(url, condition=EC.visibility_of_all_elements_located, locator=(By.CSS_SELECTOR, '#index .item'))
这里我们定义了两个方法。
第一个方法 scrape_page 依然是一个通用的爬取方法,它可以实现任意 URL 的爬取和状态监听以及异常处理,它接收 url、condition、locator 三个参数,其中 url 参数就是要爬取的页面 URL;condition 就是页面加载的判定条件,它可以是 expected_conditions 的其中某一项判定条件,如 visibility_of_all_elements_located、visibility_of_element_located 等等;locator 代表定位器,是一个元组,它可以通过配置查询条件和参数来获取一个或多个节点,如 (By.CSS_SELECTOR, '#index .item') 则代表通过 CSS 选择器查找 #index .item 来获取列表页所有电影信息节点。另外爬取的过程添加了 TimeoutException 检测,如果在规定时间(这里为 10 秒)没有加载出来对应的节点,那就抛出 TimeoutException 异常并输出错误日志。
第二个方法 scrape_index 则是爬取列表页的方法,它接收一个参数 page,通过调用 scrape_page 方法并传入 condition 和 locator 对象,完成页面的爬取。这里 condition 我们用的是 visibility_of_all_elements_located,代表所有的节点都加载出来才算成功。
注意,这里爬取页面我们不需要返回任何结果,因为执行完 scrape_index 后,页面正好处在对应的页面加载完成的状态,我们利用 browser 对象可以进一步进行信息的提取。
好,现在我们已经可以加载出来列表页了,下一步当然就是进行列表页的解析,提取出详情页 URL ,我们定义一个如下的解析列表页的方法:
from urllib.parse import urljoindef parse_index(): elements = browser.find_elements_by_css_selector('#index .item .name') for element in elements: href = element.get_attribute('href') yield urljoin(INDEX_URL, href)
这里我们通过 find_elements_by_css_selector 方法直接提取了所有电影的名称,接着遍历结果,通过 get_attribute 方法提取了详情页的 href,再用 urljoin 方法合并成一个完整的 URL。
最后,我们再用一个 main 方法把上面的方法串联起来,实现如下:
def main(): try: for page in range(1, TOTAL_PAGE + 1): scrape_index(page) detail_urls = parse_index() logging.info('details urls %s', list(detail_urls)) finally: browser.close()
这里我们就是遍历了所有页码,依次爬取了每一页的列表页并提取出来了详情页的 URL。
运行结果如下:
2020-03-29 12:03:09,896 - INFO: scraping https://dynamic2.scrape.center/page/12020-03-29 12:03:13,724 - INFO: details urls ['https://dynamic2.scrape.center/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWIx',...'https://dynamic2.scrape.center/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWI5', 'https://dynamic2.scrape.center/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWIxMA==']2020-03-29 12:03:13,724 - INFO: scraping https://dynamic2.scrape.center/page/2...
由于输出内容较多,这里省略了部分内容。
观察结果我们可以发现,详情页那一个个不规则的 URL 就成功被我们提取到了!
好了,既然现在我们已经可以成功拿到详情页的 URL 了,接下来我们就进一步完成详情页的爬取并提取对应的信息吧。
同样的逻辑,详情页我们也可以加一个判定条件,如判断电影名称加载出来了就代表详情页加载成功,同样调用 scrape_page 方法即可,代码实现如下:
def scrape_detail(url): scrape_page(url, condition=EC.visibility_of_element_located, locator=(By.TAG_NAME, 'h2'))
这里的判定条件 condition 我们使用的是 visibility_of_element_located,即判断单个元素出现即可,locator 我们传入的是 (By.TAG_NAME, 'h2'),即 h2 这个节点,也就是电影的名称对应的节点,如图所示。
如果执行了 scrape_detail 方法,没有出现 TimeoutException 的话,页面就加载成功了,接着我们再定义一个解析详情页的方法,来提取出我们想要的信息就可以了,实现如下:
def parse_detail(): url = browser.current_url name = browser.find_element_by_tag_name('h2').text categories = [element.text for element in browser.find_elements_by_css_selector('.categories button span')] cover = browser.find_element_by_css_selector('.cover').get_attribute('src') score = browser.find_element_by_class_name('score').text drama = browser.find_element_by_css_selector('.drama p').text return { 'url': url, 'name': name, 'categories': categories, 'cover': cover, 'score': score, 'drama': drama }
这里我们定义了一个 parse_detail 方法,提取了 URL、名称、类别、封面、分数、简介等内容,提取方式如下:
URL:直接调用 browser 对象的 current_url 属性即可获取当前页面的 URL。
名称:通过提取 h2 节点内部的文本即可获取,这里使用了 find_element_by_tag_name 方法并传入 h2,提取到了名称的节点,然后调用 text 属性即提取了节点内部的文本,即电影名称。
类别:为了方便,类别我们可以通过 CSS 选择器来提取,其对应的 CSS 选择器为 .categories button span,可以选中多个类别节点,这里我们通过 find_elements_by_css_selector 即可提取 CSS 选择器对应的多个类别节点,然后依次遍历这个结果,调用它的 text 属性获取节点内部文本即可。
封面:同样可以使用 CSS 选择器 .cover 直接获取封面对应的节点,但是由于其封面的 URL 对应的是 src 这个属性,所以这里用 get_attribute 方法并传入 src 来提取。
分数:分数对应的 CSS 选择器为 .score ,我们可以用上面同样的方式来提取,但是这里我们换了一个方法,叫作 find_element_by_class_name,它可以使用 class 的名称来提取节点,能达到同样的效果,不过这里传入的参数就是 class 的名称 score 而不是 .score 了。提取节点之后,我们再调用 text 属性提取节点文本即可。
简介:同样可以使用 CSS 选择器 .drama p 直接获取简介对应的节点,然后调用 text 属性提取文本即可。
最后,我们把结果构造成一个字典返回即可。
接下来,我们在 main 方法中再添加这两个方法的调用,实现如下:
def main(): try: for page in range(1, TOTAL_PAGE + 1): scrape_index(page) detail_urls = parse_index() for detail_url in list(detail_urls): logging.info('get detail url %s', detail_url) scrape_detail(detail_url) detail_data = parse_detail() logging.info('detail data %s', detail_data) finally: browser.close()
这样,爬取完列表页之后,我们就可以依次爬取详情页,来提取每部电影的具体信息了。
2020-03-29 12:24:10,723 - INFO: scraping https://dynamic2.scrape.center/page/12020-03-29 12:24:16,997 - INFO: get detail url https://dynamic2.scrape.center/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWIx2020-03-29 12:24:16,997 - INFO: scraping https://dynamic2.scrape.center/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWIx2020-03-29 12:24:19,289 - INFO: detail data {'url': 'https://dynamic2.scrape.center/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWIx', 'name': '霸王别姬 - Farewell My Concubine', 'categories': ['剧情', '爱情'], 'cover': 'https://p0.meituan.net/movie/ce4da3e03e655b5b88ed31b5cd7896cf62472.jpg@464w_644h_1e_1c', 'score': '9.5', 'drama': '影片借一出《霸王别姬》的京戏,牵扯出三个人之间一段随时代风云变幻的爱恨情仇。段小楼(张丰毅 饰)与程蝶衣(张国荣 饰)是一对打小一起长大的师兄弟,两人一个演生,一个饰旦,一向配合天衣无缝,尤其一出《霸王别姬》,更是誉满京城,为此,两人约定合演一辈子《霸王别姬》。但两人对戏剧与人生关系的理解有本质不同,段小楼深知戏非人生,程蝶衣则是人戏不分。段小楼在认为该成家立业之时迎娶了名妓菊仙(巩俐 饰),致使程蝶衣认定菊仙是可耻的第三者,使段小楼做了叛徒,自此,三人围绕一出《霸王别姬》生出的爱恨情仇战开始随着时代风云的变迁不断升级,终酿成悲剧。'}2020-03-29 12:24:19,291 - INFO: get detail url https://dynamic2.scrape.center/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWIy2020-03-29 12:24:19,291 - INFO: scraping https://dynamic2.scrape.center/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWIy2020-03-29 12:24:21,524 - INFO: detail data {'url': 'https://dynamic2.scrape.center/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWIy', 'name': '这个杀手不太冷 - Léon', 'categories': ['剧情', '动作', '犯罪'], 'cover': 'https://p1.meituan.net/movie/6bea9af4524dfbd0b668eaa7e187c3df767253.jpg@464w_644h_1e_1c', 'score': '9.5', 'drama': '里昂(让·雷诺 饰)是名孤独的职业杀手,受人雇佣。一天,邻居家小姑娘马蒂尔德(纳塔丽·波特曼 饰)敲开他的房门,要求在他那里暂避杀身之祸。原来邻居家的主人是警方缉毒组的眼线,只因贪污了一小包毒品而遭恶警(加里·奥德曼 饰)杀害全家的惩罚。马蒂尔德 得到里昂的留救,幸免于难,并留在里昂那里。里昂教小女孩使枪,她教里昂法文,两人关系日趋亲密,相处融洽。 女孩想着去报仇,反倒被抓,里昂及时赶到,将女孩救回。混杂着哀怨情仇的正邪之战渐次升级,更大的冲突在所难免……'}...
这样详情页数据我们也可以提取到了。
最后,我们再像之前一样添加一个数据存储的方法,为了方便,这里还是保存为 JSON 文本文件,实现如下:
from os import makedirsfrom os.path import existsRESULTS_DIR = 'results'exists(RESULTS_DIR) or makedirs(RESULTS_DIR)def save_data(data): name = data.get('name') data_path = f'{RESULTS_DIR}/{name}.json' json.dump(data, open(data_path, 'w', encoding='utf-8'), ensure_ascii=False, indent=2)
这里原理和实现方式与 Ajax 爬取实战课时是完全相同的,不再赘述。
最后添加上 save_data 的调用,完整看下运行效果。
如果觉得爬取过程中弹出浏览器有所干扰,我们可以开启 Chrome 的 Headless 模式,这样爬取过程中便不会再弹出浏览器了,同时爬取速度还有进一步的提升。
只需要做如下修改即可:
options = webdriver.ChromeOptions()options.add_argument('--headless')browser = webdriver.Chrome(options=options)
这里通过 ChromeOptions 添加了 --headless 参数,然后用 ChromeOptions 来进行 Chrome 的初始化即可。
修改后再重新运行代码,Chrome 浏览器就不会弹出来了,爬取结果是完全一样的。
本课时我们通过一个案例了解了 Selenium 的适用场景,并结合案例使用 Selenium 实现了页面的爬取,从而对 Selenium 的使用有进一步的掌握。
]]>我们知道爬虫是 IO 密集型任务,比如如果我们使用 requests 库来爬取某个站点的话,发出一个请求之后,程序必须要等待网站返回响应之后才能接着运行,而在等待响应的过程中,整个爬虫程序是一直在等待的,实际上没有做任何的事情。对于这种情况我们有没有优化方案呢?
比如在这里我们看这么一个示例网站:https://static4.scrape.center/,如图所示。
这个网站在内部实现返回响应的逻辑的时候特意加了 5 秒的延迟,也就是说如果我们用 requests 来爬取其中某个页面的话,至少需要 5 秒才能得到响应。
另外这个网站的逻辑结构在之前的案例中我们也分析过,其内容就是电影数据,一共 100 部,每个电影的详情页是一个自增 ID,从 1~100,比如 https://static4.scrape.center/detail/43 就代表第 43 部电影,如图所示。
下面我们来用 requests 写一个遍历程序,直接遍历 1~100 部电影数据,代码实现如下:
import requestsimport loggingimport timelogging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s: %(message)s')TOTAL_NUMBER = 100BASE_URL = 'https://static4.scrape.center/detail/{id}'start_time = time.time()for id in range(1, TOTAL_NUMBER + 1): url = BASE_URL.format(id=id) logging.info('scraping %s', url) response = requests.get(url)end_time = time.time()logging.info('total time %s seconds', end_time - start_time)
这里我们直接用循环的方式构造了 100 个详情页的爬取,使用的是 requests 单线程,在爬取之前和爬取之后记录下时间,最后输出爬取了 100 个页面消耗的时间。
运行结果如下:
2020-03-31 14:40:35,411 - INFO: scraping https://static4.scrape.center/detail/12020-03-31 14:40:40,578 - INFO: scraping https://static4.scrape.center/detail/22020-03-31 14:40:45,658 - INFO: scraping https://static4.scrape.center/detail/32020-03-31 14:40:50,761 - INFO: scraping https://static4.scrape.center/detail/42020-03-31 14:40:55,852 - INFO: scraping https://static4.scrape.center/detail/52020-03-31 14:41:00,956 - INFO: scraping https://static4.scrape.center/detail/6...2020-03-31 14:48:58,785 - INFO: scraping https://static4.scrape.center/detail/992020-03-31 14:49:03,867 - INFO: scraping https://static4.scrape.center/detail/1002020-03-31 14:49:09,042 - INFO: total time 513.6309871673584 seconds2020-03-31 14:49:09,042 - INFO: total time 513.6309871673584 seconds
由于每个页面都至少要等待 5 秒才能加载出来,因此 100 个页面至少要花费 500 秒的时间,总的爬取时间最终为 513.6 秒,将近 9 分钟。
这个在实际情况下是很常见的,有些网站本身加载速度就比较慢,稍慢的可能 1~3 秒,更慢的说不定 10 秒以上才可能加载出来。如果我们用 requests 单线程这么爬取的话,总的耗时是非常多的。此时如果我们开了多线程或多进程来爬取的话,其爬取速度确实会成倍提升,但有没有更好的解决方案呢?
本课时我们就来了解一下使用异步执行方式来加速的方法,此种方法对于 IO 密集型任务非常有效。如将其应用到网络爬虫中,爬取效率甚至可以成百倍地提升。
在了解异步协程之前,我们首先得了解一些基础概念,如阻塞和非阻塞、同步和异步、多进程和协程。
阻塞状态指程序未得到所需计算资源时被挂起的状态。程序在等待某个操作完成期间,自身无法继续处理其他的事情,则称该程序在该操作上是阻塞的。
常见的阻塞形式有:网络 I/O 阻塞、磁盘 I/O 阻塞、用户输入阻塞等。阻塞是无处不在的,包括 CPU 切换上下文时,所有的进程都无法真正处理事情,它们也会被阻塞。如果是多核 CPU 则正在执行上下文切换操作的核不可被利用。
程序在等待某操作过程中,自身不被阻塞,可以继续处理其他的事情,则称该程序在该操作上是非阻塞的。
非阻塞并不是在任何程序级别、任何情况下都可以存在的。仅当程序封装的级别可以囊括独立的子程序单元时,它才可能存在非阻塞状态。
非阻塞的存在是因为阻塞存在,正因为某个操作阻塞导致的耗时与效率低下,我们才要把它变成非阻塞的。
不同程序单元为了完成某个任务,在执行过程中需靠某种通信方式以协调一致,我们称这些程序单元是同步执行的。
例如购物系统中更新商品库存,需要用“行锁”作为通信信号,让不同的更新请求强制排队顺序执行,那更新库存的操作是同步的。
简言之,同步意味着有序。
为完成某个任务,不同程序单元之间过程中无需通信协调,也能完成任务的方式,不相关的程序单元之间可以是异步的。
例如,爬虫下载网页。调度程序调用下载程序后,即可调度其他任务,而无需与该下载任务保持通信以协调行为。不同网页的下载、保存等操作都是无关的,也无需相互通知协调。这些异步操作的完成时刻并不确定。
简言之,异步意味着无序。
多进程就是利用 CPU 的多核优势,在同一时间并行地执行多个任务,可以大大提高执行效率。
协程,英文叫作 Coroutine,又称微线程、纤程,协程是一种用户态的轻量级线程。
协程拥有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈。因此协程能保留上一次调用时的状态,即所有局部状态的一个特定组合,每次过程重入时,就相当于进入上一次调用的状态。
协程本质上是个单进程,协程相对于多进程来说,无需线程上下文切换的开销,无需原子操作锁定及同步的开销,编程模型也非常简单。
我们可以使用协程来实现异步操作,比如在网络爬虫场景下,我们发出一个请求之后,需要等待一定的时间才能得到响应,但其实在这个等待过程中,程序可以干许多其他的事情,等到响应得到之后才切换回来继续处理,这样可以充分利用 CPU 和其他资源,这就是协程的优势。
接下来,我们来了解下协程的实现,从 Python 3.4 开始,Python 中加入了协程的概念,但这个版本的协程还是以生成器对象为基础的,在 Python 3.5 则增加了 async/await,使得协程的实现更加方便。
Python 中使用协程最常用的库莫过于 asyncio,所以本文会以 asyncio 为基础来介绍协程的使用。
首先我们需要了解下面几个概念。
event_loop:事件循环,相当于一个无限循环,我们可以把一些函数注册到这个事件循环上,当满足条件发生的时候,就会调用对应的处理方法。
coroutine:中文翻译叫协程,在 Python 中常指代为协程对象类型,我们可以将协程对象注册到时间循环中,它会被事件循环调用。我们可以使用 async 关键字来定义一个方法,这个方法在调用时不会立即被执行,而是返回一个协程对象。
task:任务,它是对协程对象的进一步封装,包含了任务的各个状态。
future:代表将来执行或没有执行的任务的结果,实际上和 task 没有本质区别。
另外我们还需要了解 async/await 关键字,它是从 Python 3.5 才出现的,专门用于定义协程。其中,async 定义一个协程,await 用来挂起阻塞方法的执行。
首先我们来定义一个协程,体验一下它和普通进程在实现上的不同之处,代码如下:
import asyncioasync def execute(x): print('Number:', x)coroutine = execute(1)print('Coroutine:', coroutine)print('After calling execute')loop = asyncio.get_event_loop()loop.run_until_complete(coroutine)print('After calling loop')运行结果:Coroutine: <coroutine object execute at 0x1034cf830>After calling executeNumber: 1After calling loop
首先我们引入了 asyncio 这个包,这样我们才可以使用 async 和 await,然后我们使用 async 定义了一个 execute 方法,方法接收一个数字参数,方法执行之后会打印这个数字。
随后我们直接调用了这个方法,然而这个方法并没有执行,而是返回了一个 coroutine 协程对象。随后我们使用 get_event_loop 方法创建了一个事件循环 loop,并调用了 loop 对象的 run_until_complete 方法将协程注册到事件循环 loop 中,然后启动。最后我们才看到了 execute 方法打印了输出结果。
可见,async 定义的方法就会变成一个无法直接执行的 coroutine 对象,必须将其注册到事件循环中才可以执行。
上面我们还提到了 task,它是对 coroutine 对象的进一步封装,它里面相比 coroutine 对象多了运行状态,比如 running、finished 等,我们可以用这些状态来获取协程对象的执行情况。
在上面的例子中,当我们将 coroutine 对象传递给 run_until_complete 方法的时候,实际上它进行了一个操作就是将 coroutine 封装成了 task 对象,我们也可以显式地进行声明,如下所示:
import asyncioasync def execute(x): print('Number:', x) return xcoroutine = execute(1)print('Coroutine:', coroutine)print('After calling execute')loop = asyncio.get_event_loop()task = loop.create_task(coroutine)print('Task:', task)loop.run_until_complete(task)print('Task:', task)print('After calling loop')
运行结果:
Coroutine: <coroutine object execute at 0x10e0f7830>After calling executeTask: <Task pending coro=<execute() running at demo.py:4>>Number: 1Task: <Task finished coro=<execute() done, defined at demo.py:4> result=1>After calling loop
这里我们定义了 loop 对象之后,接着调用了它的 create_task 方法将 coroutine 对象转化为了 task 对象,随后我们打印输出一下,发现它是 pending 状态。接着我们将 task 对象添加到事件循环中得到执行,随后我们再打印输出一下 task 对象,发现它的状态就变成了 finished,同时还可以看到其 result 变成了 1,也就是我们定义的 execute 方法的返回结果。
另外定义 task 对象还有一种方式,就是直接通过 asyncio 的 ensure_future 方法,返回结果也是 task 对象,这样的话我们就可以不借助于 loop 来定义,即使我们还没有声明 loop 也可以提前定义好 task 对象,写法如下:
import asyncioasync def execute(x): print('Number:', x) return xcoroutine = execute(1)print('Coroutine:', coroutine)print('After calling execute')task = asyncio.ensure_future(coroutine)print('Task:', task)loop = asyncio.get_event_loop()loop.run_until_complete(task)print('Task:', task)print('After calling loop')
运行结果:
Coroutine: <coroutine object execute at 0x10aa33830>After calling executeTask: <Task pending coro=<execute() running at demo.py:4>>Number: 1Task: <Task finished coro=<execute() done, defined at demo.py:4> result=1>After calling loop
发现其运行效果都是一样的。
另外我们也可以为某个 task 绑定一个回调方法,比如我们来看下面的例子:
import asyncioimport requests async def request(): url = 'https://www.baidu.com' status = requests.get(url) return status def callback(task): print('Status:', task.result()) coroutine = request()task = asyncio.ensure_future(coroutine)task.add_done_callback(callback)print('Task:', task) loop = asyncio.get_event_loop()loop.run_until_complete(task)print('Task:', task)
在这里我们定义了一个 request 方法,请求了百度,获取其状态码,但是这个方法里面我们没有任何 print 语句。随后我们定义了一个 callback 方法,这个方法接收一个参数,是 task 对象,然后调用 print 方法打印了 task 对象的结果。这样我们就定义好了一个 coroutine 对象和一个回调方法,我们现在希望的效果是,当 coroutine 对象执行完毕之后,就去执行声明的 callback 方法。
那么它们二者怎样关联起来呢?很简单,只需要调用 add_done_callback 方法即可,我们将 callback 方法传递给了封装好的 task 对象,这样当 task 执行完毕之后就可以调用 callback 方法了,同时 task 对象还会作为参数传递给 callback 方法,调用 task 对象的 result 方法就可以获取返回结果了。
运行结果:
Task: <Task pending coro=<request() running at demo.py:5> cb=[callback() at demo.py:11]>Status: <Response [200]>Task: <Task finished coro=<request() done, defined at demo.py:5> result=<Response [200]>>
实际上不用回调方法,直接在 task 运行完毕之后也可以直接调用 result 方法获取结果,如下所示:
import asyncioimport requests async def request(): url = 'https://www.baidu.com' status = requests.get(url) return status coroutine = request()task = asyncio.ensure_future(coroutine)print('Task:', task) loop = asyncio.get_event_loop()loop.run_until_complete(task)print('Task:', task)print('Task Result:', task.result())
运行结果是一样的:
Task: <Task pending coro=<request() running at demo.py:4>>Task: <Task finished coro=<request() done, defined at demo.py:4> result=<Response [200]>>Task Result: <Response [200]>
上面的例子我们只执行了一次请求,如果我们想执行多次请求应该怎么办呢?我们可以定义一个 task 列表,然后使用 asyncio 的 wait 方法即可执行,看下面的例子:
import asyncioimport requests async def request(): url = 'https://www.baidu.com' status = requests.get(url) return status tasks = [asyncio.ensure_future(request()) for _ in range(5)]print('Tasks:', tasks) loop = asyncio.get_event_loop()loop.run_until_complete(asyncio.wait(tasks)) for task in tasks: print('Task Result:', task.result())
这里我们使用一个 for 循环创建了五个 task,组成了一个列表,然后把这个列表首先传递给了 asyncio 的 wait() 方法,然后再将其注册到时间循环中,就可以发起五个任务了。最后我们再将任务的运行结果输出出来,运行结果如下:
Tasks: [<Task pending coro=<request() running at demo.py:5>>, <Task pending coro=<request() running at demo.py:5>>, <Task pending coro=<request() running at demo.py:5>>, <Task pending coro=<request() running at demo.py:5>>, <Task pending coro=<request() running at demo.py:5>>]Task Result: <Response [200]>Task Result: <Response [200]>Task Result: <Response [200]>Task Result: <Response [200]>Task Result: <Response [200]>
可以看到五个任务被顺次执行了,并得到了运行结果。
前面讲了这么多,又是 async,又是 coroutine,又是 task,又是 callback,但似乎并没有看出协程的优势啊?反而写法上更加奇怪和麻烦了,别急,上面的案例只是为后面的使用作铺垫,接下来我们正式来看下协程在解决 IO 密集型任务上有怎样的优势吧!
上面的代码中,我们用一个网络请求作为示例,这就是一个耗时等待的操作,因为我们请求网页之后需要等待页面响应并返回结果。耗时等待的操作一般都是 IO 操作,比如文件读取、网络请求等等。协程对于处理这种操作是有很大优势的,当遇到需要等待的情况的时候,程序可以暂时挂起,转而去执行其他的操作,从而避免一直等待一个程序而耗费过多的时间,充分利用资源。
为了表现出协程的优势,我们还是拿本课时开始介绍的网站 https://static4.scrape.center/ 为例来进行演示,因为该网站响应比较慢,所以我们可以通过爬取时间来直观地感受到爬取速度的提升。
为了让你更好地理解协程的正确使用方法,这里我们先来看看使用协程时常犯的错误,后面再给出正确的例子来对比一下。
首先,我们还是拿之前的 requests 来进行网页请求,接下来我们再重新使用上面的方法请求一遍:
import asyncioimport requestsimport time start = time.time() async def request(): url = 'https://static4.scrape.center/' print('Waiting for', url) response = requests.get(url) print('Get response from', url, 'response', response) tasks = [asyncio.ensure_future(request()) for _ in range(10)]loop = asyncio.get_event_loop()loop.run_until_complete(asyncio.wait(tasks)) end = time.time()print('Cost time:', end - start)
在这里我们还是创建了 10 个 task,然后将 task 列表传给 wait 方法并注册到时间循环中执行。
运行结果如下:
Waiting for https://static4.scrape.center/Get response from https://static4.scrape.center/ response <Response [200]>Waiting for https://static4.scrape.center/Get response from https://static4.scrape.center/ response <Response [200]>Waiting for https://static4.scrape.center/...Get response from https://static4.scrape.center/ response <Response [200]>Waiting for https://static4.scrape.center/Get response from https://static4.scrape.center/ response <Response [200]>Waiting for https://static4.scrape.center/Get response from https://static4.scrape.center/ response <Response [200]>Cost time: 51.422438859939575
可以发现和正常的请求并没有什么两样,依然还是顺次执行的,耗时 51 秒,平均一个请求耗时 5 秒,说好的异步处理呢?
其实,要实现异步处理,我们得先要有挂起的操作,当一个任务需要等待 IO 结果的时候,可以挂起当前任务,转而去执行其他任务,这样我们才能充分利用好资源,上面方法都是一本正经的串行走下来,连个挂起都没有,怎么可能实现异步?想太多了。
要实现异步,接下来我们需要了解一下 await 的用法,使用 await 可以将耗时等待的操作挂起,让出控制权。当协程执行的时候遇到 await,时间循环就会将本协程挂起,转而去执行别的协程,直到其他的协程挂起或执行完毕。
所以,我们可能会将代码中的 request 方法改成如下的样子:
async def request(): url = 'https://static4.scrape.center/' print('Waiting for', url) response = await requests.get(url) print('Get response from', url, 'response', response)
仅仅是在 requests 前面加了一个 await,然而执行以下代码,会得到如下报错:
Waiting for https://static4.scrape.center/Waiting for https://static4.scrape.center/Waiting for https://static4.scrape.center/Waiting for https://static4.scrape.center/...Task exception was never retrievedfuture: <Task finished coro=<request() done, defined at demo.py:8> exception=TypeError("object Response can't be used in 'await' expression")>Traceback (most recent call last): File "demo.py", line 11, in request response = await requests.get(url)TypeError: object Response can't be used in 'await' expression
这次它遇到 await 方法确实挂起了,也等待了,但是最后却报了这么个错,这个错误的意思是 requests 返回的 Response 对象不能和 await 一起使用,为什么呢?因为根据官方文档说明,await 后面的对象必须是如下格式之一:
A native coroutine object returned from a native coroutine function,一个原生 coroutine 对象。
A generator-based coroutine object returned from a function decorated with types.coroutine,一个由 types.coroutine 修饰的生成器,这个生成器可以返回 coroutine 对象。
An object with an __await__ method returning an iterator,一个包含 __await__ 方法的对象返回的一个迭代器。
可以参见:https://www.python.org/dev/peps/pep-0492/#await-expression。
requests 返回的 Response 不符合上面任一条件,因此就会报上面的错误了。
那么你可能会发现,既然 await 后面可以跟一个 coroutine 对象,那么我用 async 把请求的方法改成 coroutine 对象不就可以了吗?所以就改写成如下的样子:
import asyncioimport requestsimport time start = time.time() async def get(url): return requests.get(url) async def request(): url = 'https://static4.scrape.center/' print('Waiting for', url) response = await get(url) print('Get response from', url, 'response', response) tasks = [asyncio.ensure_future(request()) for _ in range(10)]loop = asyncio.get_event_loop()loop.run_until_complete(asyncio.wait(tasks)) end = time.time()print('Cost time:', end - start)
这里我们将请求页面的方法独立出来,并用 async 修饰,这样就得到了一个 coroutine 对象,我们运行一下看看:
Waiting for https://static4.scrape.center/Get response from https://static4.scrape.center/ response <Response [200]>Waiting for https://static4.scrape.center/Get response from https://static4.scrape.center/ response <Response [200]>Waiting for https://static4.scrape.center/...Get response from https://static4.scrape.center/ response <Response [200]>Waiting for https://static4.scrape.center/Get response from https://static4.scrape.center/ response <Response [200]>Waiting for https://static4.scrape.center/Get response from https://static4.scrape.center/ response <Response [200]>Cost time: 51.394437756259273
还是不行,它还不是异步执行,也就是说我们仅仅将涉及 IO 操作的代码封装到 async 修饰的方法里面是不可行的!我们必须要使用支持异步操作的请求方式才可以实现真正的异步,所以这里就需要 aiohttp 派上用场了。
aiohttp 是一个支持异步请求的库,利用它和 asyncio 配合我们可以非常方便地实现异步请求操作。
安装方式如下:
pip3 install aiohttp
官方文档链接为:https://aiohttp.readthedocs.io/,它分为两部分,一部分是 Client,一部分是 Server,详细的内容可以参考官方文档。
下面我们将 aiohttp 用上来,将代码改成如下样子:
import asyncioimport aiohttpimport time start = time.time() async def get(url): session = aiohttp.ClientSession() response = await session.get(url) await response.text() await session.close() return response async def request(): url = 'https://static4.scrape.center/' print('Waiting for', url) response = await get(url) print('Get response from', url, 'response', response) tasks = [asyncio.ensure_future(request()) for _ in range(10)]loop = asyncio.get_event_loop()loop.run_until_complete(asyncio.wait(tasks)) end = time.time()print('Cost time:', end - start)
在这里我们将请求库由 requests 改成了 aiohttp,通过 aiohttp 的 ClientSession 类的 get 方法进行请求,结果如下:
Waiting for https://static4.scrape.center/Waiting for https://static4.scrape.center/Waiting for https://static4.scrape.center/Waiting for https://static4.scrape.center/Waiting for https://static4.scrape.center/Waiting for https://static4.scrape.center/Waiting for https://static4.scrape.center/Waiting for https://static4.scrape.center/Waiting for https://static4.scrape.center/Waiting for https://static4.scrape.center/Get response from https://static4.scrape.center/ response <ClientResponse(https://static4.scrape.center/) [200 OK]><CIMultiDictProxy('Server': 'nginx/1.17.8', 'Date': 'Tue, 31 Mar 2020 09:35:43 GMT', 'Content-Type': 'text/html; charset=utf-8', 'Transfer-Encoding': 'chunked', 'Connection': 'keep-alive', 'Vary': 'Accept-Encoding', 'X-Frame-Options': 'SAMEORIGIN', 'Strict-Transport-Security': 'max-age=15724800; includeSubDomains', 'Content-Encoding': 'gzip')>...Get response from https://static4.scrape.center/ response <ClientResponse(https://static4.scrape.center/) [200 OK]><CIMultiDictProxy('Server': 'nginx/1.17.8', 'Date': 'Tue, 31 Mar 2020 09:35:44 GMT', 'Content-Type': 'text/html; charset=utf-8', 'Transfer-Encoding': 'chunked', 'Connection': 'keep-alive', 'Vary': 'Accept-Encoding', 'X-Frame-Options': 'SAMEORIGIN', 'Strict-Transport-Security': 'max-age=15724800; includeSubDomains', 'Content-Encoding': 'gzip')>Cost time: 6.1102519035339355
成功了!我们发现这次请求的耗时由 51 秒变直接成了 6 秒,耗费时间减少了非常非常多。
代码里面我们使用了 await,后面跟了 get 方法,在执行这 10 个协程的时候,如果遇到了 await,那么就会将当前协程挂起,转而去执行其他的协程,直到其他的协程也挂起或执行完毕,再进行下一个协程的执行。
开始运行时,时间循环会运行第一个 task,针对第一个 task 来说,当执行到第一个 await 跟着的 get 方法时,它被挂起,但这个 get 方法第一步的执行是非阻塞的,挂起之后立马被唤醒,所以立即又进入执行,创建了 ClientSession 对象,接着遇到了第二个 await,调用了 session.get 请求方法,然后就被挂起了,由于请求需要耗时很久,所以一直没有被唤醒。
当第一个 task 被挂起了,那接下来该怎么办呢?事件循环会寻找当前未被挂起的协程继续执行,于是就转而执行第二个 task 了,也是一样的流程操作,直到执行了第十个 task 的 session.get 方法之后,全部的 task 都被挂起了。所有 task 都已经处于挂起状态,怎么办?只好等待了。5 秒之后,几个请求几乎同时都有了响应,然后几个 task 也被唤醒接着执行,输出请求结果,最后总耗时,6 秒!
怎么样?这就是异步操作的便捷之处,当遇到阻塞式操作时,任务被挂起,程序接着去执行其他的任务,而不是傻傻地等待,这样可以充分利用 CPU 时间,而不必把时间浪费在等待 IO 上。
你可能会说,既然这样的话,在上面的例子中,在发出网络请求后,既然接下来的 5 秒都是在等待的,在 5 秒之内,CPU 可以处理的 task 数量远不止这些,那么岂不是我们放 10 个、20 个、50 个、100 个、1000 个 task 一起执行,最后得到所有结果的耗时不都是差不多的吗?因为这几个任务被挂起后都是一起等待的。
理论来说确实是这样的,不过有个前提,那就是服务器在同一时刻接受无限次请求都能保证正常返回结果,也就是服务器无限抗压,另外还要忽略 IO 传输时延,确实可以做到无限 task 一起执行且在预想时间内得到结果。但由于不同服务器处理的实现机制不同,可能某些服务器并不能承受这么高的并发,因此响应速度也会减慢。
在这里我们以百度为例,来测试下并发数量为 1、3、5、10、...、500 的情况下的耗时情况,代码如下:
import asyncioimport aiohttpimport time def test(number): start = time.time() async def get(url): session = aiohttp.ClientSession() response = await session.get(url) await response.text() await session.close() return response async def request(): url = 'https://www.baidu.com/' await get(url) tasks = [asyncio.ensure_future(request()) for _ in range(number)] loop = asyncio.get_event_loop() loop.run_until_complete(asyncio.wait(tasks)) end = time.time() print('Number:', number, 'Cost time:', end - start) for number in [1, 3, 5, 10, 15, 30, 50, 75, 100, 200, 500]: test(number)
运行结果如下:
Number: 1 Cost time: 0.05885505676269531Number: 3 Cost time: 0.05773782730102539Number: 5 Cost time: 0.05768704414367676Number: 10 Cost time: 0.15174412727355957Number: 15 Cost time: 0.09603095054626465Number: 30 Cost time: 0.17843103408813477Number: 50 Cost time: 0.3741800785064697Number: 75 Cost time: 0.2894289493560791Number: 100 Cost time: 0.6185381412506104Number: 200 Cost time: 1.0894129276275635Number: 500 Cost time: 1.8213098049163818
可以看到,即使我们增加了并发数量,但在服务器能承受高并发的前提下,其爬取速度几乎不太受影响。
综上所述,使用了异步请求之后,我们几乎可以在相同的时间内实现成百上千倍次的网络请求,把这个运用在爬虫中,速度提升是非常可观的。
以上便是 Python 中协程的基本原理和用法,在后面一课时会详细介绍 aiohttp 的使用和爬取实战,实现快速高并发的爬取。
本节代码:https://github.com/Python3WebSpider/AsyncTest。
在上一课时我们介绍了异步爬虫的基本原理和 asyncio 的基本用法,另外在最后简单提及了 aiohttp 实现网页爬取的过程,这一可是我们来介绍一下 aiohttp 的常见用法,以及通过一个实战案例来介绍下使用 aiohttp 完成网页异步爬取的过程。
前面介绍的 asyncio 模块内部实现了对 TCP、UDP、SSL 协议的异步操作,但是对于 HTTP 请求的异步操作来说,我们就需要用到 aiohttp 来实现了。
aiohttp 是一个基于 asyncio 的异步 HTTP 网络模块,它既提供了服务端,又提供了客户端。其中我们用服务端可以搭建一个支持异步处理的服务器,用于处理请求并返回响应,类似于 Django、Flask、Tornado 等一些 Web 服务器。而客户端我们就可以用来发起请求,就类似于 requests 来发起一个 HTTP 请求然后获得响应,但 requests 发起的是同步的网络请求,而 aiohttp 则发起的是异步的。
本课时我们就主要来了解一下 aiohttp 客户端部分的使用。
首先我们来看一个基本的 aiohttp 请求案例,代码如下:
import aiohttpimport asyncioasync def fetch(session, url): async with session.get(url) as response: return await response.text(), response.statusasync def main(): async with aiohttp.ClientSession() as session: html, status = await fetch(session, 'https://cuiqingcai.com') print(f'html: {html[:100]}...') print(f'status: {status}')if __name__ == '__main__': loop = asyncio.get_event_loop() loop.run_until_complete(main())
在这里我们使用 aiohttp 来爬取了我的个人博客,获得了源码和响应状态码并输出,运行结果如下:
html: <!DOCTYPE HTML><html><head><meta charset="UTF-8"><meta name="baidu-tc-verification" content=...status: 200
这里网页源码过长,只截取输出了一部分,可以看到我们成功获取了网页的源代码及响应状态码 200,也就完成了一次基本的 HTTP 请求,即我们成功使用 aiohttp 通过异步的方式进行了网页的爬取,当然这个操作用之前我们所讲的 requests 同样也可以做到。
我们可以看到其请求方法的定义和之前有了明显的区别,主要有如下几点:
首先在导入库的时候,我们除了必须要引入 aiohttp 这个库之外,还必须要引入 asyncio 这个库,因为要实现异步爬取需要启动协程,而协程则需要借助于 asyncio 里面的事件循环来执行。除了事件循环,asyncio 里面也提供了很多基础的异步操作。
异步爬取的方法的定义和之前有所不同,在每个异步方法前面统一要加 async 来修饰。
with as 语句前面同样需要加 async 来修饰,在 Python 中,with as 语句用于声明一个上下文管理器,能够帮我们自动分配和释放资源,而在异步方法中,with as 前面加上 async 代表声明一个支持异步的上下文管理器。
对于一些返回 coroutine 的操作,前面需要加 await 来修饰,如 response 调用 text 方法,查询 API 可以发现其返回的是 coroutine 对象,那么前面就要加 await;而对于状态码来说,其返回值就是一个数值类型,那么前面就不需要加 await。所以,这里可以按照实际情况处理,参考官方文档说明,看看其对应的返回值是怎样的类型,然后决定加不加 await 就可以了。
最后,定义完爬取方法之后,实际上是 main 方法调用了 fetch 方法。要运行的话,必须要启用事件循环,事件循环就需要使用 asyncio 库,然后使用 run_until_complete 方法来运行。
注意在 Python 3.7 及以后的版本中,我们可以使用 asyncio.run(main()) 来代替最后的启动操作,不需要显式声明事件循环,run 方法内部会自动启动一个事件循环。但这里为了兼容更多的 Python 版本,依然还是显式声明了事件循环。
对于 URL 参数的设置,我们可以借助于 params 参数,传入一个字典即可,示例如下:
import aiohttpimport asyncioasync def main(): params = {'name': 'germey', 'age': 25} async with aiohttp.ClientSession() as session: async with session.get('https://httpbin.org/get', params=params) as response: print(await response.text())if __name__ == '__main__': asyncio.get_event_loop().run_until_complete(main())
运行结果如下:
{ "args": { "age": "25", "name": "germey" }, "headers": { "Accept": "*/*", "Accept-Encoding": "gzip, deflate", "Host": "httpbin.org", "User-Agent": "Python/3.7 aiohttp/3.6.2", "X-Amzn-Trace-Id": "Root=1-5e85eed2-d240ac90f4dddf40b4723ef0" }, "origin": "17.20.255.122", "url": "https://httpbin.org/get?name=germey&age=25"}
这里可以看到,其实际请求的 URL 为 https://httpbin.org/get?name=germey&age=25,其 URL 请求参数就对应了 params 的内容。
另外 aiohttp 还支持其他的请求类型,如 POST、PUT、DELETE 等等,这个和 requests 的使用方式有点类似,示例如下:
session.post('http://httpbin.org/post', data=b'data')session.put('http://httpbin.org/put', data=b'data')session.delete('http://httpbin.org/delete')session.head('http://httpbin.org/get')session.options('http://httpbin.org/get')session.patch('http://httpbin.org/patch', data=b'data')
对于 POST 表单提交,其对应的请求头的 Content-type 为 application/x-www-form-urlencoded,我们可以用如下方式来实现,代码示例如下:
import aiohttpimport asyncioasync def main(): data = {'name': 'germey', 'age': 25} async with aiohttp.ClientSession() as session: async with session.post('https://httpbin.org/post', data=data) as response: print(await response.text())if __name__ == '__main__': asyncio.get_event_loop().run_until_complete(main())
运行结果如下:
{ "args": {}, "data": "", "files": {}, "form": { "age": "25", "name": "germey" }, "headers": { "Accept": "*/*", "Accept-Encoding": "gzip, deflate", "Content-Length": "18", "Content-Type": "application/x-www-form-urlencoded", "Host": "httpbin.org", "User-Agent": "Python/3.7 aiohttp/3.6.2", "X-Amzn-Trace-Id": "Root=1-5e85f0b2-9017ea603a68dc285e0552d0" }, "json": null, "origin": "17.20.255.58", "url": "https://httpbin.org/post"}
对于 POST JSON 数据提交,其对应的请求头的 Content-type 为 application/json,我们只需要将 post 方法的 data 参数改成 json 即可,代码示例如下:
async def main(): data = {'name': 'germey', 'age': 25} async with aiohttp.ClientSession() as session: async with session.post('https://httpbin.org/post', json=data) as response: print(await response.text())
运行结果如下:
{ "args": {}, "data": "{\"name\": \"germey\", \"age\": 25}", "files": {}, "form": {}, "headers": { "Accept": "*/*", "Accept-Encoding": "gzip, deflate", "Content-Length": "29", "Content-Type": "application/json", "Host": "httpbin.org", "User-Agent": "Python/3.7 aiohttp/3.6.2", "X-Amzn-Trace-Id": "Root=1-5e85f03e-c91c9a20c79b9780dbed7540" }, "json": { "age": 25, "name": "germey" }, "origin": "17.20.255.58", "url": "https://httpbin.org/post"}
对于响应来说,我们可以用如下的方法分别获取响应的状态码、响应头、响应体、响应体二进制内容、响应体 JSON 结果,代码示例如下:
import aiohttpimport asyncioasync def main(): data = {'name': 'germey', 'age': 25} async with aiohttp.ClientSession() as session: async with session.post('https://httpbin.org/post', data=data) as response: print('status:', response.status) print('headers:', response.headers) print('body:', await response.text()) print('bytes:', await response.read()) print('json:', await response.json())if __name__ == '__main__': asyncio.get_event_loop().run_until_complete(main())
运行结果如下:
status: 200headers: <CIMultiDictProxy('Date': 'Thu, 02 Apr 2020 14:13:05 GMT', 'Content-Type': 'application/json', 'Content-Length': '503', 'Connection': 'keep-alive', 'Server': 'gunicorn/19.9.0', 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Credentials': 'true')>body: { "args": {}, "data": "", "files": {}, "form": { "age": "25", "name": "germey" }, "headers": { "Accept": "*/*", "Accept-Encoding": "gzip, deflate", "Content-Length": "18", "Content-Type": "application/x-www-form-urlencoded", "Host": "httpbin.org", "User-Agent": "Python/3.7 aiohttp/3.6.2", "X-Amzn-Trace-Id": "Root=1-5e85f2f1-f55326ff5800b15886c8e029" }, "json": null, "origin": "17.20.255.58", "url": "https://httpbin.org/post"}bytes: b'{\n "args": {}, \n "data": "", \n "files": {}, \n "form": {\n "age": "25", \n "name": "germey"\n }, \n "headers": {\n "Accept": "*/*", \n "Accept-Encoding": "gzip, deflate", \n "Content-Length": "18", \n "Content-Type": "application/x-www-form-urlencoded", \n "Host": "httpbin.org", \n "User-Agent": "Python/3.7 aiohttp/3.6.2", \n "X-Amzn-Trace-Id": "Root=1-5e85f2f1-f55326ff5800b15886c8e029"\n }, \n "json": null, \n "origin": "17.20.255.58", \n "url": "https://httpbin.org/post"\n}\n'json: {'args': {}, 'data': '', 'files': {}, 'form': {'age': '25', 'name': 'germey'}, 'headers': {'Accept': '*/*', 'Accept-Encoding': 'gzip, deflate', 'Content-Length': '18', 'Content-Type': 'application/x-www-form-urlencoded', 'Host': 'httpbin.org', 'User-Agent': 'Python/3.7 aiohttp/3.6.2', 'X-Amzn-Trace-Id': 'Root=1-5e85f2f1-f55326ff5800b15886c8e029'}, 'json': None, 'origin': '17.20.255.58', 'url': 'https://httpbin.org/post'}
这里我们可以看到有些字段前面需要加 await,有的则不需要。其原则是,如果其返回的是一个 coroutine 对象(如 async 修饰的方法),那么前面就要加 await,具体可以看 aiohttp 的 API,其链接为:https://docs.aiohttp.org/en/stable/client_reference.html。
对于超时的设置,我们可以借助于 ClientTimeout 对象,比如这里我要设置 1 秒的超时,可以这么来实现:
import aiohttpimport asyncioasync def main(): timeout = aiohttp.ClientTimeout(total=1) async with aiohttp.ClientSession(timeout=timeout) as session: async with session.get('https://httpbin.org/get') as response: print('status:', response.status)if __name__ == '__main__': asyncio.get_event_loop().run_until_complete(main())
如果在 1 秒之内成功获取响应的话,运行结果如下:
200
如果超时的话,会抛出 TimeoutError 异常,其类型为 asyncio.TimeoutError,我们再进行异常捕获即可。
另外 ClientTimeout 对象声明时还有其他参数,如 connect、socket_connect 等,详细说明可以参考官方文档:https://docs.aiohttp.org/en/stable/client_quickstart.html#timeouts。
由于 aiohttp 可以支持非常大的并发,比如上万、十万、百万都是能做到的,但这么大的并发量,目标网站是很可能在短时间内无法响应的,而且很可能瞬时间将目标网站爬挂掉。所以我们需要控制一下爬取的并发量。
在一般情况下,我们可以借助于 asyncio 的 Semaphore 来控制并发量,代码示例如下:
import asyncioimport aiohttpCONCURRENCY = 5URL = 'https://www.baidu.com'semaphore = asyncio.Semaphore(CONCURRENCY)session = Noneasync def scrape_api(): async with semaphore: print('scraping', URL) async with session.get(URL) as response: await asyncio.sleep(1) return await response.text()async def main(): global session session = aiohttp.ClientSession() scrape_index_tasks = [asyncio.ensure_future(scrape_api()) for _ in range(10000)] await asyncio.gather(*scrape_index_tasks)if __name__ == '__main__': asyncio.get_event_loop().run_until_complete(main())
在这里我们声明了 CONCURRENCY 代表爬取的最大并发量为 5,同时声明爬取的目标 URL 为百度。接着我们借助于 Semaphore 创建了一个信号量对象,赋值为 semaphore,这样我们就可以用它来控制最大并发量了。怎么使用呢?我们这里把它直接放置在对应的爬取方法里面,使用 async with 语句将 semaphore 作为上下文对象即可。这样的话,信号量可以控制进入爬取的最大协程数量,最大数量就是我们声明的 CONCURRENCY 的值。
在 main 方法里面,我们声明了 10000 个 task,传递给 gather 方法运行。倘若不加以限制,这 10000 个 task 会被同时执行,并发数量太大。但有了信号量的控制之后,同时运行的 task 的数量最大会被控制在 5 个,这样就能给 aiohttp 限制速度了。
在这里,aiohttp 的基本使用就介绍这么多,更详细的内容还是推荐你到官方文档查阅,链接:https://docs.aiohttp.org/。
上面我们介绍了 aiohttp 的基本用法之后,下面我们来根据一个实例实现异步爬虫的实战演练吧。
本次我们要爬取的网站是:https://dynamic5.scrape.center/,页面如图所示。
这是一个书籍网站,整个网站包含了数千本书籍信息,网站是 JavaScript 渲染的,数据可以通过 Ajax 接口获取到,并且接口没有设置任何反爬措施和加密参数,另外由于这个网站比之前的电影案例网站数据量大一些,所以更加适合做异步爬取。
本课时我们要完成的目标有:
使用 aiohttp 完成全站的书籍数据爬取。
将数据通过异步的方式保存到 MongoDB 中。
在本课时开始之前,请确保你已经做好了如下准备工作:
安装好了 Python(最低为 Python 3.6 版本,最好为 3.7 版本或以上),并能成功运行 Python 程序。
了解了 Ajax 爬取的一些基本原理和模拟方法。
了解了异步爬虫的基本原理和 asyncio 库的基本用法。
了解了 aiohttp 库的基本用法。
安装并成功运行了 MongoDB 数据库,并安装了异步存储库 motor。
注:这里要实现 MongoDB 异步存储,需要异步 MongoDB 存储库,叫作 motor,安装命令为:
pip3 install motor
在之前我们讲解了 Ajax 的基本分析方法,本课时的站点结构和之前 Ajax 分析的站点结构类似,都是列表页加详情页的结构,加载方式都是 Ajax,所以我们能轻松分析到如下信息:
列表页的 Ajax 请求接口格式为:https://dynamic5.scrape.center/api/book/?limit=18&offset={offset},limit 的值即为每一页的书的个数,offset 的值为每一页的偏移量,其计算公式为 offset = limit * (page - 1) ,如第 1 页 offset 的值为 0,第 2 页 offset 的值为 18,以此类推。
列表页 Ajax 接口返回的数据里 results 字段包含当前页 18 本书的信息,其中每本书的数据里面包含一个字段 id,这个 id 就是书本身的 ID,可以用来进一步请求详情页。
详情页的 Ajax 请求接口格式为:https://dynamic5.scrape.center/api/book/{id},id 即为书的 ID,可以从列表页的返回结果中获取。
如果你掌握了 Ajax 爬取实战一课时的内容话,上面的内容应该很容易分析出来。如有难度,可以复习下之前的知识。
其实一个完善的异步爬虫应该能够充分利用资源进行全速爬取,其思路是维护一个动态变化的爬取队列,每产生一个新的 task 就会将其放入队列中,有专门的爬虫消费者从队列中获取 task 并执行,能做到在最大并发量的前提下充分利用等待时间进行额外的爬取处理。
但上面的实现思路整体较为烦琐,需要设计爬取队列、回调函数、消费者等机制,需要实现的功能较多。由于我们刚刚接触 aiohttp 的基本用法,本课时也主要是了解 aiohttp 的实战应用,所以这里我们将爬取案例的实现稍微简化一下。
在这里我们将爬取的逻辑拆分成两部分,第一部分为爬取列表页,第二部分为爬取详情页。由于异步爬虫的关键点在于并发执行,所以我们可以将爬取拆分为两个阶段:
第一阶段为所有列表页的异步爬取,我们可以将所有的列表页的爬取任务集合起来,声明为 task 组成的列表,进行异步爬取。
第二阶段则是拿到上一步列表页的所有内容并解析,拿到所有书的 id 信息,组合为所有详情页的爬取任务集合,声明为 task 组成的列表,进行异步爬取,同时爬取的结果也以异步的方式存储到 MongoDB 里面。
因为两个阶段的拆分之后需要串行执行,所以可能不能达到协程的最佳调度方式和资源利用情况,但也差不了很多。但这个实现思路比较简单清晰,代码实现也比较简单,能够帮我们快速了解 aiohttp 的基本使用。
首先我们先配置一些基本的变量并引入一些必需的库,代码如下:
import asyncioimport aiohttpimport logginglogging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s: %(message)s')INDEX_URL = 'https://dynamic5.scrape.center/api/book/?limit=18&offset={offset}'DETAIL_URL = 'https://dynamic5.scrape.center/api/book/{id}'PAGE_SIZE = 18PAGE_NUMBER = 100CONCURRENCY = 5
在这里我们导入了 asyncio、aiohttp、logging 这三个库,然后定义了 logging 的基本配置。接着定义了 URL、爬取页码数量 PAGE_NUMBER、并发量 CONCURRENCY 等信息。
首先,第一阶段我们就来爬取列表页,还是和之前一样,我们先定义一个通用的爬取方法,代码如下:
semaphore = asyncio.Semaphore(CONCURRENCY)session = Noneasync def scrape_api(url): async with semaphore: try: logging.info('scraping %s', url) async with session.get(url) as response: return await response.json() except aiohttp.ClientError: logging.error('error occurred while scraping %s', url, exc_info=True)
在这里我们声明了一个信号量,用来控制最大并发数量。
接着我们定义了 scrape_api 方法,该方法接收一个参数 url。首先使用 async with 引入信号量作为上下文,接着调用了 session 的 get 方法请求这个 url,然后返回响应的 JSON 格式的结果。另外这里还进行了异常处理,捕获了 ClientError,如果出现错误,会输出异常信息。
接着,对于列表页的爬取,实现如下:
async def scrape_index(page): url = INDEX_URL.format(offset=PAGE_SIZE * (page - 1)) return await scrape_api(url)
这里定义了一个 scrape_index 方法用于爬取列表页,它接收一个参数为 page,然后构造了列表页的 URL,将其传给 scrape_api 方法即可。这里注意方法同样需要用 async 修饰,调用的 scrape_api 方法前面需要加 await,因为 scrape_api 调用之后本身会返回一个 coroutine。另外由于 scrape_api 返回结果就是 JSON 格式,因此 scrape_index 的返回结果就是我们想要爬取的信息,不需要再额外解析了。
好,接着我们定义一个 main 方法,将上面的方法串联起来调用一下,实现如下:
import jsonasync def main(): global session session = aiohttp.ClientSession() scrape_index_tasks = [asyncio.ensure_future(scrape_index(page)) for page in range(1, PAGE_NUMBER + 1)] results = await asyncio.gather(*scrape_index_tasks) logging.info('results %s', json.dumps(results, ensure_ascii=False, indent=2))if __name__ == '__main__': asyncio.get_event_loop().run_until_complete(main())
这里我们首先声明了 session 对象,即最初声明的全局变量,将 session 作为全局变量的话我们就不需要每次在各个方法里面传递了,实现比较简单。
接着我们定义了 scrape_index_tasks,它就是爬取列表页的所有 task,接着我们调用 asyncio 的 gather 方法并传入 task 列表,将结果赋值为 results,它是所有 task 返回结果组成的列表。
最后我们调用 main 方法,使用事件循环启动该 main 方法对应的协程即可。
运行结果如下:
2020-04-03 03:45:54,692 - INFO: scraping https://dynamic5.scrape.center/api/book/?limit=18&offset=02020-04-03 03:45:54,707 - INFO: scraping https://dynamic5.scrape.center/api/book/?limit=18&offset=182020-04-03 03:45:54,707 - INFO: scraping https://dynamic5.scrape.center/api/book/?limit=18&offset=362020-04-03 03:45:54,708 - INFO: scraping https://dynamic5.scrape.center/api/book/?limit=18&offset=542020-04-03 03:45:54,708 - INFO: scraping https://dynamic5.scrape.center/api/book/?limit=18&offset=722020-04-03 03:45:56,431 - INFO: scraping https://dynamic5.scrape.center/api/book/?limit=18&offset=902020-04-03 03:45:56,435 - INFO: scraping https://dynamic5.scrape.center/api/book/?limit=18&offset=108
可以看到这里就开始异步爬取了,并发量是由我们控制的,目前为 5,当然也可以进一步调高并发量,在网站能承受的情况下,爬取速度会进一步加快。
最后 results 就是所有列表页得到的结果,我们将其赋值为 results 对象,接着我们就可以用它来进行第二阶段的爬取了。
第二阶段就是爬取详情页并保存数据了,由于每个详情页对应一本书,每本书需要一个 ID,而这个 ID 又正好存在 results 里面,所以下面我们就需要将所有详情页的 ID 获取出来。
在 main 方法里增加 results 的解析代码,实现如下:
ids = []for index_data in results: if not index_data: continue for item in index_data.get('results'): ids.append(item.get('id'))
这样 ids 就是所有书的 id 了,然后我们用所有的 id 来构造所有详情页对应的 task,来进行异步爬取即可。
那么这里再定义一个爬取详情页和保存数据的方法,实现如下:
from motor.motor_asyncio import AsyncIOMotorClientMONGO_CONNECTION_STRING = 'mongodb://localhost:27017'MONGO_DB_NAME = 'books'MONGO_COLLECTION_NAME = 'books'client = AsyncIOMotorClient(MONGO_CONNECTION_STRING)db = client[MONGO_DB_NAME]collection = db[MONGO_COLLECTION_NAME]async def save_data(data): logging.info('saving data %s', data) if data: return await collection.update_one({ 'id': data.get('id') }, { '$set': data }, upsert=True)async def scrape_detail(id): url = DETAIL_URL.format(id=id) data = await scrape_api(url) await save_data(data)
这里我们定义了 scrape_detail 方法用于爬取详情页数据并调用 save_data 方法保存数据,save_data 方法用于将数据库保存到 MongoDB 里面。
在这里我们用到了支持异步的 MongoDB 存储库 motor,MongoDB 的连接声明和 pymongo 是类似的,保存数据的调用方法也是基本一致,不过整个都换成了异步方法。
好,接着我们就在 main 方法里面增加 scrape_detail 方法的调用即可,实现如下:
scrape_detail_tasks = [asyncio.ensure_future(scrape_detail(id)) for id in ids]await asyncio.wait(scrape_detail_tasks)await session.close()
在这里我们先声明了 scrape_detail_tasks,即所有详情页的爬取 task 组成的列表,接着调用了 asyncio 的 wait 方法调用执行即可,当然这里也可以用 gather 方法,效果是一样的,只不过返回结果略有差异。最后全部执行完毕关闭 session 即可。
一些详情页的爬取过程运行如下:
2020-04-03 04:00:32,576 - INFO: scraping https://dynamic5.scrape.center/api/book/23014752020-04-03 04:00:32,576 - INFO: scraping https://dynamic5.scrape.center/api/book/23518662020-04-03 04:00:32,577 - INFO: scraping https://dynamic5.scrape.center/api/book/28283842020-04-03 04:00:32,577 - INFO: scraping https://dynamic5.scrape.center/api/book/30403522020-04-03 04:00:32,578 - INFO: scraping https://dynamic5.scrape.center/api/book/30748102020-04-03 04:00:44,858 - INFO: saving data {'id': '3040352', 'comments': [{'id': '387952888', 'content': '温馨文,青梅竹马神马的很有爱~'}, ..., {'id': '2005314253', 'content': '沈晋&秦央,文比较短,平平淡淡,贴近生活,短文的缺点不细腻'}], 'name': '那些风花雪月', 'authors': ['\n 公子欢喜'], 'translators': [], 'publisher': '龍馬出版社', 'tags': ['公子欢喜', '耽美', 'BL', '小说', '现代', '校园', '耽美小说', '那些风花雪月'], 'url': 'https://book.douban.com/subject/3040352/', 'isbn': '9789866685156', 'cover': 'https://img9.doubanio.com/view/subject/l/public/s3029724.jpg', 'page_number': None, 'price': None, 'score': '8.1', 'introduction': '', 'catalog': None, 'published_at': '2008-03-26T16:00:00Z', 'updated_at': '2020-03-21T16:59:39.584722Z'}2020-04-03 04:00:44,859 - INFO: scraping https://dynamic5.scrape.center/api/book/2994915...
最后我们观察下,爬取到的数据也都保存到 MongoDB 数据库里面了,如图所示:
至此,我们就使用 aiohttp 完成了书籍网站的异步爬取。
本课时的内容较多,我们了解了 aiohttp 的基本用法,然后通过一个实例讲解了 aiohttp 异步爬虫的具体实现。学习过程我们可以发现,相比普通的单线程爬虫来说,使用异步可以大大提高爬取效率,后面我们也可以多多使用。
本课时代码:https://github.com/Germey/ScrapeDynamic5。
在前面我们学习了 Selenium 的基本用法,它功能的确非常强大,但很多时候我们会发现 Selenium 有一些不太方便的地方,比如环境的配置,得安装好相关浏览器,比如 Chrome、Firefox 等等,然后还要到官方网站去下载对应的驱动,最重要的还需要安装对应的 Python Selenium 库,而且版本也得好好看看是否对应,确实不是很方便,另外如果要做大规模部署的话,环境配置的一些问题也是个头疼的事情。
那么本课时我们就介绍另一个类似的替代品,叫作 Pyppeteer。注意,是叫作 Pyppeteer,而不是 Puppeteer。
Puppeteer 是 Google 基于 Node.js 开发的一个工具,有了它我们可以通过 JavaScript 来控制 Chrome 浏览器的一些操作,当然也可以用作网络爬虫上,其 API 极其完善,功能非常强大,Selenium 当然同样可以做到。
而 Pyppeteer 又是什么呢?它实际上是 Puppeteer 的 Python 版本的实现,但它不是 Google 开发的,是一位来自于日本的工程师依据 Puppeteer 的一些功能开发出来的非官方版本。
在 Pyppetter 中,实际上它背后也是有一个类似 Chrome 浏览器的 Chromium 浏览器在执行一些动作进行网页渲染,首先说下 Chrome 浏览器和 Chromium 浏览器的渊源。
Chromium 是谷歌为了研发 Chrome 而启动的项目,是完全开源的。二者基于相同的源代码构建,Chrome 所有的新功能都会先在 Chromium 上实现,待验证稳定后才会移植,因此 Chromium 的版本更新频率更高,也会包含很多新的功能,但作为一款独立的浏览器,Chromium 的用户群体要小众得多。两款浏览器“同根同源”,它们有着同样的 Logo,但配色不同,Chrome 由蓝红绿黄四种颜色组成,而 Chromium 由不同深度的蓝色构成。
总的来说,两款浏览器的内核是一样的,实现方式也是一样的,可以认为是开发版和正式版的区别,功能上基本是没有太大区别的。
Pyppeteer 就是依赖于 Chromium 这个浏览器来运行的。那么有了 Pyppeteer 之后,我们就可以免去那些烦琐的环境配置等问题。如果第一次运行的时候,Chromium 浏览器没有安装,那么程序会帮我们自动安装和配置,就免去了烦琐的环境配置等工作。另外 Pyppeteer 是基于 Python 的新特性 async 实现的,所以它的一些执行也支持异步操作,效率相对于 Selenium 来说也提高了。
那么下面就让我们来一起了解下 Pyppeteer 的相关用法吧。
首先就是安装问题了,由于 Pyppeteer 采用了 Python 的 async 机制,所以其运行要求的 Python 版本为 3.5 及以上。
安装方式非常简单:
pip3 install pyppeteer
好了,安装完成之后我们在命令行下测试:
>>> import pyppeteer
如果没有报错,那么就证明安装成功了。
接下来我们测试基本的页面渲染操作,这里我们选用的网址为:https://dynamic2.scrape.center/,如图所示。
这个网站我们在之前的 Selenium 爬取实战课时中已经分析过了,整个页面是用 JavaScript 渲染出来的,同时一些 Ajax 接口还带有加密参数,所以这个网站的页面我们无法直接使用 requests 来抓取看到的数据,同时我们也不太好直接模拟 Ajax 来获取数据。
所以前面一课时我们介绍了使用 Selenium 爬取的方式,其原理就是模拟浏览器的操作,直接用浏览器把页面渲染出来,然后再直接获取渲染后的结果。同样的原理,用 Pyppeteer 也可以做到。
下面我们用 Pyppeteer 来试试,代码就可以写为如下形式:
import asynciofrom pyppeteer import launchfrom pyquery import PyQuery as pqasync def main(): browser = await launch() page = await browser.newPage() await page.goto('https://dynamic2.scrape.center/') await page.waitForSelector('.item .name') doc = pq(await page.content()) names = [item.text() for item in doc('.item .name').items()] print('Names:', names) await browser.close()asyncio.get_event_loop().run_until_complete(main())
运行结果:
Names: ['霸王别姬 - Farewell My Concubine', '这个杀手不太冷 - Léon', '肖申克的救赎 - The Shawshank Redemption', '泰坦尼克号 - Titanic', '罗马假日 - Roman Holiday', '唐伯虎点秋香 - Flirting Scholar', '乱世佳人 - Gone with the Wind', '喜剧之王 - The King of Comedy', '楚门的世界 - The Truman Show', '狮子王 - The Lion King']
先初步看下代码,大体意思是访问了这个网站,然后等待 .item .name 的节点加载出来,随后通过 pyquery 从网页源码中提取了电影的名称并输出,最后关闭 Pyppeteer。
看运行结果,和之前的 Selenium 一样,我们成功模拟加载出来了页面,然后提取到了首页所有电影的名称。
那么这里面的具体过程发生了什么?我们来逐行看下。
launch 方法会新建一个 Browser 对象,其执行后最终会得到一个 Browser 对象,然后赋值给 browser。这一步就相当于启动了浏览器。
然后 browser 调用 newPage 方法相当于浏览器中新建了一个选项卡,同时新建了一个 Page 对象,这时候新启动了一个选项卡,但是还未访问任何页面,浏览器依然是空白。
随后 Page 对象调用了 goto 方法就相当于在浏览器中输入了这个 URL,浏览器跳转到了对应的页面进行加载。
Page 对象调用 waitForSelector 方法,传入选择器,那么页面就会等待选择器所对应的节点信息加载出来,如果加载出来了,立即返回,否则会持续等待直到超时。此时如果顺利的话,页面会成功加载出来。
页面加载完成之后再调用 content 方法,可以获得当前浏览器页面的源代码,这就是 JavaScript 渲染后的结果。
然后进一步的,我们用 pyquery 进行解析并提取页面的电影名称,就得到最终结果了。
另外其他的一些方法如调用 asyncio 的 get_event_loop 等方法的相关操作则属于 Python 异步 async 相关的内容了,你如果不熟悉可以了解下前面所讲的异步相关知识。
好,通过上面的代码,我们同样也可以完成 JavaScript 渲染页面的爬取了。怎么样?代码相比 Selenium 是不是更简洁易读,而且环境配置更加方便。在这个过程中,我们没有配置 Chrome 浏览器,也没有配置浏览器驱动,免去了一些烦琐的步骤,同样达到了 Selenium 的效果,还实现了异步抓取。
接下来我们再看看另外一个例子,这个例子设定了浏览器窗口大小,然后模拟了网页截图,另外还可以执行自定义的 JavaScript 获得特定的内容,代码如下:
import asynciofrom pyppeteer import launchwidth, height = 1366, 768async def main(): browser = await launch() page = await browser.newPage() await page.setViewport({'width': width, 'height': height}) await page.goto('https://dynamic2.scrape.center/') await page.waitForSelector('.item .name') await asyncio.sleep(2) await page.screenshot(path='example.png') dimensions = await page.evaluate('''() => { return { width: document.documentElement.clientWidth, height: document.documentElement.clientHeight, deviceScaleFactor: window.devicePixelRatio, } }''') print(dimensions) await browser.close()asyncio.get_event_loop().run_until_complete(main())
这里我们又用到了几个新的 API,完成了页面窗口大小设置、网页截图保存、执行 JavaScript 并返回对应数据。
首先 screenshot 方法可以传入保存的图片路径,另外还可以指定保存格式 type、清晰度 quality、是否全屏 fullPage、裁切 clip 等各个参数实现截图。
截图的样例如下:
可以看到它返回的就是 JavaScript 渲染后的页面,和我们在浏览器中看到的结果是一模一样的。
最后我们又调用了 evaluate 方法执行了一些 JavaScript,JavaScript 传入的是一个函数,使用 return 方法返回了网页的宽高、像素大小比率三个值,最后得到的是一个 JSON 格式的对象,内容如下:
{'width': 1366, 'height': 768, 'deviceScaleFactor': 1}
OK,实例就先感受到这里,还有太多太多的功能还没提及。
总之利用 Pyppeteer 我们可以控制浏览器执行几乎所有动作,想要的操作和功能基本都可以实现,用它来自由地控制爬虫当然就不在话下了。
了解了基本的实例之后,我们再来梳理一下 Pyppeteer 的一些基本和常用操作。Pyppeteer 的几乎所有功能都能在其官方文档的 API Reference 里面找到,链接为:https://miyakogi.github.io/pyppeteer/reference.html,用到哪个方法就来这里查询就好了,参数不必死记硬背,即用即查就好。
使用 Pyppeteer 的第一步便是启动浏览器,首先我们看下怎样启动一个浏览器,其实就相当于我们点击桌面上的浏览器图标一样,把它运行起来。用 Pyppeteer 完成同样的操作,只需要调用 launch 方法即可。
我们先看下 launch 方法的 API,链接为:https://miyakogi.github.io/pyppeteer/reference.html#pyppeteer.launcher.launch,其方法定义如下:
pyppeteer.launcher.launch(options: dict = None, **kwargs) → pyppeteer.browser.Browser
可以看到它处于 launcher 模块中,参数没有在声明中特别指定,返回类型是 browser 模块中的 Browser 对象,另外观察源码发现这是一个 async 修饰的方法,所以调用它的时候需要使用 await。
接下来看看它的参数:
ignoreHTTPSErrors (bool):是否要忽略 HTTPS 的错误,默认是 False。
headless (bool):是否启用 Headless 模式,即无界面模式,如果 devtools 这个参数是 True 的话,那么该参数就会被设置为 False,否则为 True,即默认是开启无界面模式的。
executablePath (str):可执行文件的路径,如果指定之后就不需要使用默认的 Chromium 了,可以指定为已有的 Chrome 或 Chromium。
slowMo (int|float):通过传入指定的时间,可以减缓 Pyppeteer 的一些模拟操作。
args (List[str]):在执行过程中可以传入的额外参数。
ignoreDefaultArgs (bool):不使用 Pyppeteer 的默认参数,如果使用了这个参数,那么最好通过 args 参数来设定一些参数,否则可能会出现一些意想不到的问题。这个参数相对比较危险,慎用。
handleSIGINT (bool):是否响应 SIGINT 信号,也就是可以使用 Ctrl + C 来终止浏览器程序,默认是 True。
handleSIGTERM (bool):是否响应 SIGTERM 信号,一般是 kill 命令,默认是 True。
handleSIGHUP (bool):是否响应 SIGHUP 信号,即挂起信号,比如终端退出操作,默认是 True。
dumpio (bool):是否将 Pyppeteer 的输出内容传给 process.stdout 和 process.stderr 对象,默认是 False。
userDataDir (str):即用户数据文件夹,即可以保留一些个性化配置和操作记录。
env (dict):环境变量,可以通过字典形式传入。
devtools (bool):是否为每一个页面自动开启调试工具,默认是 False。如果这个参数设置为 True,那么 headless 参数就会无效,会被强制设置为 False。
logLevel (int|str):日志级别,默认和 root logger 对象的级别相同。
autoClose (bool):当一些命令执行完之后,是否自动关闭浏览器,默认是 True。
loop (asyncio.AbstractEventLoop):事件循环对象。
好了,知道这些参数之后,我们可以先试试看。
无头模式
首先可以试用下最常用的参数 headless,如果我们将它设置为 True 或者默认不设置它,在启动的时候我们是看不到任何界面的,如果把它设置为 False,那么在启动的时候就可以看到界面了,一般我们在调试的时候会把它设置为 False,在生产环境上就可以设置为 True,我们先尝试一下关闭 headless 模式:
import asynciofrom pyppeteer import launchasync def main(): await launch(headless=False) await asyncio.sleep(100)asyncio.get_event_loop().run_until_complete(main())
运行之后看不到任何控制台输出,但是这时候就会出现一个空白的 Chromium 界面了:
但是可以看到这就是一个光秃秃的浏览器而已,看一下相关信息:
看到了,这就是 Chromium,上面还写了开发者内部版本,你可以认为是开发版的 Chrome 浏览器就好。
调试模式
另外我们还可以开启调试模式,比如在写爬虫的时候会经常需要分析网页结构还有网络请求,所以开启调试工具还是很有必要的,我们可以将 devtools 参数设置为 True,这样每开启一个界面就会弹出一个调试窗口,非常方便,示例如下:
import asynciofrom pyppeteer import launch async def main(): browser = await launch(devtools=True) page = await browser.newPage() await page.goto('https://www.baidu.com') await asyncio.sleep(100) asyncio.get_event_loop().run_until_complete(main())
刚才说过 devtools 这个参数如果设置为了 True,那么 headless 就会被关闭了,界面始终会显现出来。在这里我们新建了一个页面,打开了百度,界面运行效果如下:
禁用提示条
这时候我们可以看到上面的一条提示:"Chrome 正受到自动测试软件的控制",这个提示条有点烦,那该怎样关闭呢?这时候就需要用到 args 参数了,禁用操作如下:
browser = await launch(headless=False, args=['--disable-infobars'])
这里就不再写完整代码了,就是在 launch 方法中,args 参数通过 list 形式传入即可,这里使用的是 --disable-infobars 的参数。
防止检测
你可能会说,如果你只是把提示关闭了,有些网站还是会检测到是 WebDriver 吧,比如拿之前的检测 WebDriver 的案例 https://antispider1.scrape.center/ 来验证下,我们可以试试:
import asynciofrom pyppeteer import launch async def main(): browser = await launch(headless=False, args=['--disable-infobars']) page = await browser.newPage() await page.goto('https://antispider1.scrape.center/') await asyncio.sleep(100) asyncio.get_event_loop().run_until_complete(main())
果然还是被检测到了,页面如下:
这说明 Pyppeteer 开启 Chromium 照样还是能被检测到 WebDriver 的存在。
那么此时如何规避呢?Pyppeteer 的 Page 对象有一个方法叫作 evaluateOnNewDocument,意思就是在每次加载网页的时候执行某个语句,所以这里我们可以执行一下将 WebDriver 隐藏的命令,改写如下:
import asynciofrom pyppeteer import launch async def main(): browser = await launch(headless=False, args=['--disable-infobars']) page = await browser.newPage() await page.evaluateOnNewDocument('Object.defineProperty(navigator, "webdriver", {get: () => undefined})') await page.goto('https://antispider1.scrape.center/') await asyncio.sleep(100) asyncio.get_event_loop().run_until_complete(main())
这里我们可以看到整个页面就可以成功加载出来了,如图所示。
我们发现页面就成功加载出来了,绕过了 WebDriver 的检测。
页面大小设置
在上面的例子中,我们还发现了页面的显示 bug,整个浏览器窗口比显示的内容窗口要大,这个是某些页面会出现的情况。
对于这种情况,我们通过设置窗口大小就可以解决,可以通过 Page 的 setViewport 方法设置,代码如下:
import asynciofrom pyppeteer import launch width, height = 1366, 768 async def main(): browser = await launch(headless=False, args=['--disable-infobars', f'--window-size={width},{height}']) page = await browser.newPage() await page.setViewport({'width': width, 'height': height}) await page.evaluateOnNewDocument('Object.defineProperty(navigator, "webdriver", {get: () => undefined})') await page.goto('https://antispider1.scrape.center/') await asyncio.sleep(100) asyncio.get_event_loop().run_until_complete(main())
这里我们同时设置了浏览器窗口的宽高以及显示区域的宽高,使得二者一致,最后发现显示就正常了,如图所示。
用户数据持久化
刚才我们可以看到,每次我们打开 Pyppeteer 的时候都是一个新的空白的浏览器。而且如果遇到了需要登录的网页之后,如果我们这次登录上了,下一次再启动又是空白了,又得登录一次,这的确是一个问题。
比如以淘宝举例,平时我们逛淘宝的时候,在很多情况下关闭了浏览器再打开,淘宝依然还是登录状态。这是因为淘宝的一些关键 Cookies 已经保存到本地了,下次登录的时候可以直接读取并保持登录状态。
那么这些信息保存在哪里了呢?其实就是保存在用户目录下了,里面不仅包含了浏览器的基本配置信息,还有一些 Cache、Cookies 等各种信息都在里面,如果我们能在浏览器启动的时候读取这些信息,那么启动的时候就可以恢复一些历史记录甚至一些登录状态信息了。
这也就解决了一个问题:很多时候你在每次启动 Selenium 或 Pyppeteer 的时候总是一个全新的浏览器,那这究其原因就是没有设置用户目录,如果设置了它,每次打开就不再是一个全新的浏览器了,它可以恢复之前的历史记录,也可以恢复很多网站的登录信息。
那么这个怎么来做呢?很简单,在启动的时候设置 userDataDir 就好了,示例如下:
import asynciofrom pyppeteer import launch async def main(): browser = await launch(headless=False, userDataDir='./userdata', args=['--disable-infobars']) page = await browser.newPage() await page.goto('https://www.taobao.com') await asyncio.sleep(100) asyncio.get_event_loop().run_until_complete(main())
好,这里就是加了一个 userDataDir 的属性,值为 userdata,即当前目录的 userdata 文件夹。我们可以首先运行一下,然后登录一次淘宝,这时候我们同时可以观察到在当前运行目录下又多了一个 userdata 的文件夹,里面的结构是这样子的:
具体的介绍可以看官方的一些说明,如: https://chromium.googlesource.com/chromium/src/+/master/docs/user_data_dir.md,这里面介绍了 userdatadir 的相关内容。
再次运行上面的代码,这时候可以发现现在就已经是登录状态了,不需要再次登录了,这样就成功跳过了登录的流程。当然可能时间太久了,Cookies 都过期了,那还是需要登录的。
以上便是 launch 方法及其对应的参数的配置。
上面我们了解了 launch 方法,其返回的就是一个 Browser 对象,即浏览器对象,我们会通常将其赋值给 browser 变量,其实它就是 Browser 类的一个实例。
下面我们来看看 Browser 类的定义:
class pyppeteer.browser.Browser(connection: pyppeteer.connection.Connection, contextIds: List[str], ignoreHTTPSErrors: bool, setDefaultViewport: bool, process: Optional[subprocess.Popen] = None, closeCallback: Callable[[], Awaitable[None]] = None, **kwargs)
这里我们可以看到其构造方法有很多参数,但其实多数情况下我们直接使用 launch 方法或 connect 方法创建即可。
browser 作为一个对象,其自然有很多用于操作浏览器本身的方法,下面我们来选取一些比较有用的介绍下。
开启无痕模式
我们知道 Chrome 浏览器是有一个无痕模式的,它的好处就是环境比较干净,不与其他的浏览器示例共享 Cache、Cookies 等内容,其开启方式可以通过 createIncognitoBrowserContext 方法,示例如下:
import asynciofrom pyppeteer import launch width, height = 1200, 768 async def main(): browser = await launch(headless=False, args=['--disable-infobars', f'--window-size={width},{height}']) context = await browser.createIncognitoBrowserContext() page = await context.newPage() await page.setViewport({'width': width, 'height': height}) await page.goto('https://www.baidu.com') await asyncio.sleep(100) asyncio.get_event_loop().run_until_complete(main())
这里关键的调用就是 createIncognitoBrowserContext 方法,其返回一个 context 对象,然后利用 context 对象我们可以新建选项卡。
运行之后,我们发现浏览器就进入了无痕模式,界面如下:
关闭
怎样关闭自不用多说了,就是 close 方法,但很多时候我们可能忘记了关闭而造成额外开销,所以要记得在使用完毕之后调用一下 close 方法,示例如下:
import asynciofrom pyppeteer import launchfrom pyquery import PyQuery as pq async def main(): browser = await launch() page = await browser.newPage() await page.goto('https://dynamic2.scrape.center/') await browser.close()asyncio.get_event_loop().run_until_complete(main())
Page 即页面,就对应一个网页,一个选项卡。在前面我们已经演示了几个 Page 方法的操作了,这里我们再详细看下它的一些常用用法。
选择器
Page 对象内置了一些用于选取节点的选择器方法,如 J 方法传入一个选择器 Selector,则能返回对应匹配的第一个节点,等价于 querySelector。如 JJ 方法则是返回符合 Selector 的列表,类似于 querySelectorAll。
下面我们来看下其用法和运行结果,示例如下:
import asynciofrom pyppeteer import launchfrom pyquery import PyQuery as pq async def main(): browser = await launch() page = await browser.newPage() await page.goto('https://dynamic2.scrape.center/') await page.waitForSelector('.item .name') j_result1 = await page.J('.item .name') j_result2 = await page.querySelector('.item .name') jj_result1 = await page.JJ('.item .name') jj_result2 = await page.querySelectorAll('.item .name') print('J Result1:', j_result1) print('J Result2:', j_result2) print('JJ Result1:', jj_result1) print('JJ Result2:', jj_result2) await browser.close() asyncio.get_event_loop().run_until_complete(main())
在这里我们分别调用了 J、querySelector、JJ、querySelectorAll 四个方法,观察下其运行效果和返回结果的类型,运行结果:
J Result1: <pyppeteer.element_handle.ElementHandle object at 0x1166f7dd0>J Result2: <pyppeteer.element_handle.ElementHandle object at 0x1166f07d0>JJ Result1: [<pyppeteer.element_handle.ElementHandle object at 0x11677df50>, <pyppeteer.element_handle.ElementHandle object at 0x1167857d0>, <pyppeteer.element_handle.ElementHandle object at 0x116785110>,...<pyppeteer.element_handle.ElementHandle object at 0x11679db10>, <pyppeteer.element_handle.ElementHandle object at 0x11679dbd0>]JJ Result2: [<pyppeteer.element_handle.ElementHandle object at 0x116794f10>, <pyppeteer.element_handle.ElementHandle object at 0x116794d10>, <pyppeteer.element_handle.ElementHandle object at 0x116794f50>,...<pyppeteer.element_handle.ElementHandle object at 0x11679f690>, <pyppeteer.element_handle.ElementHandle object at 0x11679f750>]
在这里我们可以看到,J、querySelector 一样,返回了单个匹配到的节点,返回类型为 ElementHandle 对象。JJ、querySelectorAll 则返回了节点列表,是 ElementHandle 的列表。
选项卡操作
前面我们已经演示了多次新建选项卡的操作了,也就是 newPage 方法,那新建了之后怎样获取和切换呢,下面我们来看一个例子:
import asynciofrom pyppeteer import launch async def main(): browser = await launch(headless=False) page = await browser.newPage() await page.goto('https://www.baidu.com') page = await browser.newPage() await page.goto('https://www.bing.com') pages = await browser.pages() print('Pages:', pages) page1 = pages[1] await page1.bringToFront() await asyncio.sleep(100) asyncio.get_event_loop().run_until_complete(main())
在这里我们启动了 Pyppeteer,然后调用了 newPage 方法新建了两个选项卡并访问了两个网站。那么如果我们要切换选项卡的话,只需要调用 pages 方法即可获取所有的页面,然后选一个页面调用其 bringToFront 方法即可切换到该页面对应的选项卡。
常见操作
作为一个页面,我们一定要有对应的方法来控制,如加载、前进、后退、关闭、保存等,示例如下:
import asynciofrom pyppeteer import launchfrom pyquery import PyQuery as pq async def main(): browser = await launch(headless=False) page = await browser.newPage() await page.goto('https://dynamic1.scrape.center/') await page.goto('https://dynamic2.scrape.center/') # 后退 await page.goBack() # 前进 await page.goForward() # 刷新 await page.reload() # 保存 PDF await page.pdf() # 截图 await page.screenshot() # 设置页面 HTML await page.setContent('<h2>Hello World</h2>') # 设置 User-Agent await page.setUserAgent('Python') # 设置 Headers await page.setExtraHTTPHeaders(headers={}) # 关闭 await page.close() await browser.close() asyncio.get_event_loop().run_until_complete(main())
这里我们介绍了一些常用方法,除了一些常用的操作,这里还介绍了设置 User-Agent、Headers 等功能。
点击
Pyppeteer 同样可以模拟点击,调用其 click 方法即可。比如我们这里以 https://dynamic2.scrape.center/ 为例,等待节点加载出来之后,模拟右键点击一下,示例如下:
import asynciofrom pyppeteer import launchfrom pyquery import PyQuery as pq async def main(): browser = await launch(headless=False) page = await browser.newPage() await page.goto('https://dynamic2.scrape.center/') await page.waitForSelector('.item .name') await page.click('.item .name', options={ 'button': 'right', 'clickCount': 1, # 1 or 2 'delay': 3000, # 毫秒 }) await browser.close() asyncio.get_event_loop().run_until_complete(main())
这里 click 方法第一个参数就是选择器,即在哪里操作。第二个参数是几项配置:
button:鼠标按钮,分为 left、middle、right。
clickCount:点击次数,如双击、单击等。
delay:延迟点击。
输入文本。
对于文本的输入,Pyppeteer 也不在话下,使用 type 方法即可,示例如下:
import asynciofrom pyppeteer import launchfrom pyquery import PyQuery as pq async def main(): browser = await launch(headless=False) page = await browser.newPage() await page.goto('https://www.taobao.com') # 后退 await page.type('#q', 'iPad') # 关闭 await asyncio.sleep(10) await browser.close() asyncio.get_event_loop().run_until_complete(main())
这里我们打开淘宝网,使用 type 方法第一个参数传入选择器,第二个参数传入输入的内容,Pyppeteer 便可以帮我们完成输入了。
获取信息
Page 获取源代码用 content 方法即可,Cookies 则可以用 cookies 方法获取,示例如下:
import asynciofrom pyppeteer import launchfrom pyquery import PyQuery as pq async def main(): browser = await launch(headless=False) page = await browser.newPage() await page.goto('https://dynamic2.scrape.center/') print('HTML:', await page.content()) print('Cookies:', await page.cookies()) await browser.close() asyncio.get_event_loop().run_until_complete(main())
执行
Pyppeteer 可以支持 JavaScript 执行,使用 evaluate 方法即可,看之前的例子:
import asynciofrom pyppeteer import launch width, height = 1366, 768 async def main(): browser = await launch() page = await browser.newPage() await page.setViewport({'width': width, 'height': height}) await page.goto('https://dynamic2.scrape.center/') await page.waitForSelector('.item .name') await asyncio.sleep(2) await page.screenshot(path='example.png') dimensions = await page.evaluate('''() => { return { width: document.documentElement.clientWidth, height: document.documentElement.clientHeight, deviceScaleFactor: window.devicePixelRatio, } }''') print(dimensions) await browser.close() asyncio.get_event_loop().run_until_complete(main())
这里我们通过 evaluate 方法执行了 JavaScript,并获取到了对应的结果。另外其还有 exposeFunction、evaluateOnNewDocument、evaluateHandle 方法可以做了解。
延时等待
在本课时最开头的地方我们演示了 waitForSelector 的用法,它可以让页面等待某些符合条件的节点加载出来再返回。
在这里 waitForSelector 就是传入一个 CSS 选择器,如果找到了,立马返回结果,否则等待直到超时。
除了 waitForSelector 方法,还有很多其他的等待方法,介绍如下。
waitForFunction:等待某个 JavaScript 方法执行完毕或返回结果。
waitForNavigation:等待页面跳转,如果没加载出来就会报错。
waitForRequest:等待某个特定的请求被发出。
waitForResponse:等待某个特定的请求收到了回应。
waitFor:通用的等待方法。
waitForSelector:等待符合选择器的节点加载出来。
waitForXPath:等待符合 XPath 的节点加载出来。
通过等待条件,我们就可以控制页面加载的情况了。
另外 Pyppeteer 还有很多功能,如键盘事件、鼠标事件、对话框事件等等,在这里就不再一一赘述了。更多的内容可以参考官方文档的案例说明:https://miyakogi.github.io/pyppeteer/reference.html。
以上,我们就通过一些小的案例介绍了 Pyppeteer 的基本用法,下一课时,我们来使用 Pyppeteer 完成一个实战案例爬取。
本节代码:https://github.com/Python3WebSpider/PyppeteerTest。
在上一课时我们了解了 Pyppeteer 的基本用法,确实我们可以发现其相比 Selenium 有很多方便之处。
本课时我们就来使用 Pyppeteer 针对之前的 Selenium 案例做一次改写,来体会一下二者的不同之处,同时也加强一下对 Pyppeteer 的理解和掌握情况。
本课时我们要爬取的目标和之前是一样的,还是 Selenium 的那个案例,地址为:https://dynamic2.scrape.center/,如下图所示。
这个网站的每个详情页的 URL 都是带有加密参数的,同时 Ajax 接口也都有加密参数和时效性。具体的介绍可以看下 Selenium 课时。
爬取目标和那一节也是一样的:
遍历每一页列表页,然后获取每部电影详情页的 URL。
爬取每部电影的详情页,然后提取其名称、评分、类别、封面、简介等信息。
爬取到的数据存为 JSON 文件。
要求和之前也是一样的,只不过我们这里的实现就全用 Pyppeteer 来做了。
在本课时开始之前,我们需要做好如下准备工作:
安装好 Python (最低为 Python 3.6)版本,并能成功运行 Python 程序。
安装好 Pyppeteer 并能成功运行示例。
其他的浏览器、驱动配置就不需要了,这也是相比 Selenium 更加方便的地方。
页面分析在这里就不多介绍了,还是列表页 + 详情页的结构,具体可以参考 Selenium 那一课时的内容。
首先我们先做一些准备工作,定义一些基础的配置,包括日志定义、变量等等并引入一些必要的包,代码如下:
import logginglogging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s: %(message)s')INDEX_URL = 'https://dynamic2.scrape.center/page/{page}'TIMEOUT = 10TOTAL_PAGE = 10WINDOW_WIDTH, WINDOW_HEIGHT = 1366, 768HEADLESS = False
这里大多数的配置和之前是一样的,不过这里我们额外定义了窗口的宽高信息,这里定义为 1366 x 768,你也可以随意指定适合自己屏幕的宽高信息。另外这里定义了一个变量 HEADLESS,用来指定是否启用 Pyppeteer 的无头模式,如果为 False,那么启动 Pyppeteer 的时候就会弹出一个 Chromium 浏览器窗口。
接着我们再定义一个初始化 Pyppeteer 的方法,包括启动 Pyppeteer,新建一个页面选项卡,设置窗口大小等操作,代码实现如下:
from pyppeteer import launchbrowser, tab = None, Noneasync def init(): global browser, tab browser = await launch(headless=HEADLESS, args=['--disable-infobars', f'--window-size={WINDOW_WIDTH},{WINDOW_HEIGHT}']) tab = await browser.newPage() await tab.setViewport({'width': WINDOW_WIDTH, 'height': WINDOW_HEIGHT})
在这里我们先声明了一个 browser 对象,代表 Pyppeteer 所用的浏览器对象,tab 代表新建的页面选项卡,这里把两项设置为全局变量,方便其他的方法调用。
另外定义了一个 init 方法,调用了 Pyppeteer 的 launch 方法,传入了 headless 为 HEADLESS,将其设置为非无头模式,另外还通过 args 指定了隐藏提示条并设定了窗口的宽高。
接下来我们像之前一样,定义一个通用的爬取方法,代码如下:
from pyppeteer.errors import TimeoutErrorasync def scrape_page(url, selector): logging.info('scraping %s', url) try: await tab.goto(url) await tab.waitForSelector(selector, options={ 'timeout': TIMEOUT * 1000 }) except TimeoutError: logging.error('error occurred while scraping %s', url, exc_info=True)
这里我们定义了一个 scrape_page 方法,它接收两个参数,一个是 url,代表要爬取的链接,使用 goto 方法调用即可;另外一个是 selector,即要等待渲染出的节点对应的 CSS 选择器,这里我们使用 waitForSelector 方法并传入了 selector,并通过 options 指定了最长等待时间。
这样的话在运行时页面会首先访问这个 URL,然后等待某个符合 selector 的节点加载出来,最长等待 10 秒,如果 10 秒内加载出来了,那就接着往下执行,否则抛出异常,捕获 TimeoutError 并输出错误日志。
接下来,我们就实现一下爬取列表页的方法,代码实现如下:
async def scrape_index(page): url = INDEX_URL.format(page=page) await scrape_page(url, '.item .name')
这里我们定义了 scrape_index 方法来爬取页面,其接受一个参数 page,代表要爬取的页码,这里我们首先通过 INDEX_URL 构造了列表页的 URL,然后调用 scrape_page 方法传入了 url 和要等待加载的选择器。
这里的选择器我们使用的是 .item .name,这就是列表页中每部电影的名称,如果这个加载出来了,那么就代表页面加载成功了,如图所示。
好,接下来我们可以再定义一个解析列表页的方法,提取出每部电影的详情页 URL,定义如下:
async def parse_index(): return await tab.querySelectorAllEval('.item .name', 'nodes => nodes.map(node => node.href)')
这里我们调用了 querySelectorAllEval 方法,它接收两个参数,第一个参数是 selector,代表要选择的节点对应的 CSS 选择器;第二个参数是 pageFunction,代表的是要执行的 JavaScript 方法,这里需要传入的是一段 JavaScript 字符串,整个方法的作用是选择 selector 对应的节点,然后对这些节点通过 pageFunction 定义的逻辑抽取出对应的结果并返回。
所以这里第一个参数 selector 就传入电影名称对应的节点,其实是超链接 a 节点。由于提取结果有多个,所以这里 JavaScript 对应的 pageFunction 输入参数就是 nodes,输出结果是调用了 map 方法得到每个 node,然后调用 node 的 href 属性即可。这样返回结果就是当前列表页的所有电影的详情页 URL 组成的列表了。
好,接下来我们来串联调用一下看看,代码实现如下:
import asyncioasync def main(): await init() try: for page in range(1, TOTAL_PAGE + 1): await scrape_index(page) detail_urls = await parse_index() logging.info('detail_urls %s', detail_urls) finally: await browser.close()if __name__ == '__main__': asyncio.get_event_loop().run_until_complete(main())
这里我们定义了一个 mian 方法,将前面定义的几个方法串联调用了一下。首先调用了 init 方法,然后循环遍历页码,调用了 scrape_index 方法爬取了每一页列表页,接着我们调用了 parse_index 方法,从列表页中提取出详情页的每个 URL,然后输出结果。
运行结果如下:
2020-04-08 13:54:28,879 - INFO: scraping https://dynamic2.scrape.center/page/12020-04-08 13:54:31,411 - INFO: detail_urls ['https://dynamic2.scrape.center/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWIx', ...,'https://dynamic2.scrape.center/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWI5', 'https://dynamic2.scrape.center/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWIxMA==']2020-04-08 13:54:31,411 - INFO: scraping https://dynamic2.scrape.center/page/2
由于内容较多,这里省略了部分内容。
在这里可以看到,每一次的返回结果都会是当前列表页提取出来的所有详情页 URL 组成的列表,我们下一步就可以用这些 URL 来接着爬取了。
拿到详情页的 URL 之后,下一步就是爬取每一个详情页然后提取信息了,首先我们定义一个爬取详情页的方法,代码如下:
async def scrape_detail(url): await scrape_page(url, 'h2')
代码非常简单,就是直接调用了 scrape_page 方法,然后传入了要等待加载的节点的选择器,这里我们就直接用了 h2 了,对应的就是详情页的电影名称,如图所示。
如果顺利运行,那么当前 Pyppeteer 就已经成功加载出详情页了,下一步就是提取里面的信息了。
接下来我们再定义一个提取详情信息的方法,代码如下:
async def parse_detail(): url = tab.url name = await tab.querySelectorEval('h2', 'node => node.innerText') categories = await tab.querySelectorAllEval('.categories button span', 'nodes => nodes.map(node => node.innerText)') cover = await tab.querySelectorEval('.cover', 'node => node.src') score = await tab.querySelectorEval('.score', 'node => node.innerText') drama = await tab.querySelectorEval('.drama p', 'node => node.innerText') return { 'url': url, 'name': name, 'categories': categories, 'cover': cover, 'score': score, 'drama': drama }
这里我们定义了一个 parse_detail 方法,提取了 URL、名称、类别、封面、分数、简介等内容,提取方式如下:
URL:直接调用 tab 对象的 url 属性即可获取当前页面的 URL。
名称:由于名称只有一个节点,所以这里我们调用了 querySelectorEval 方法来提取,而不是querySelectorAllEval,第一个参数传入 h2,提取到了名称对应的节点,然后第二个参数传入提取的 pageFunction,调用了 node 的 innerText 属性提取了文本值,即电影名称。
类别:类别有多个,所以我们这里调用了 querySelectorAllEval 方法来提取,其对应的 CSS 选择器为 .categories button span,可以选中多个类别节点。接下来还是像之前提取详情页 URL 一样,pageFunction 使用 nodes 参数,然后调用 map 方法提取 node 的 innerText 就得到所有类别结果了。
封面:同样地,可以使用 CSS 选择器 .cover 直接获取封面对应的节点,但是由于其封面的 URL 对应的是 src 这个属性,所以这里提取的是 src 属性。
分数:分数对应的 CSS 选择器为 .score ,类似的原理,提取 node 的 innerText 即可。
简介:同样可以使用 CSS 选择器 .drama p 直接获取简介对应的节点,然后调用 innerText 属性提取文本即可。
最后我们将提取结果汇总成一个字典然后返回即可。
接下来 main 方法里面,我们增加 scrape_detail 和 parse_detail 方法的调用,main 方法改写如下:
async def main(): await init() try: for page in range(1, TOTAL_PAGE + 1): await scrape_index(page) detail_urls = await parse_index() for detail_url in detail_urls: await scrape_detail(detail_url) detail_data = await parse_detail() logging.info('data %s', detail_data) finally: await browser.close()
重新看下运行结果,运行结果如下:
2020-04-08 14:12:39,564 - INFO: scraping https://dynamic2.scrape.center/page/12020-04-08 14:12:42,935 - INFO: scraping https://dynamic2.scrape.center/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWIx2020-04-08 14:12:45,781 - INFO: data {'url': 'https://dynamic2.scrape.center/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWIx', 'name': '霸王别姬 - Farewell My Concubine', 'categories': ['剧情', '爱情'], 'cover': 'https://p0.meituan.net/movie/ce4da3e03e655b5b88ed31b5cd7896cf62472.jpg@464w_644h_1e_1c', 'score': '9.5', 'drama': '影片借一出《霸王别姬》的京戏,牵扯出三个人之间一段随时代风云变幻的爱恨情仇。段小楼(张丰毅 饰)与程蝶衣(张国荣 饰)是一对打小一起长大的师兄弟,两人一个演生,一个饰旦,一向配合天衣无缝,尤其一出《霸王别姬》,更是誉满京城,为此,两人约定合演一辈子《霸王别姬》。但两人对戏剧与人生关系的理解有本质不同,段小楼深知戏非人生,程蝶衣则是人戏不分。段小楼在认为该成家立业之时迎娶了名妓菊仙(巩俐 饰),致使程蝶衣认定菊仙是可耻的第三者,使段小楼做了叛徒,自此,三人围绕一出《霸王别姬》生出的爱恨情仇战开始随着时代风云的变迁不断升级,终酿成悲剧。'}2020-04-08 14:12:45,782 - INFO: scraping https://dynamic2.scrape.center/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWIy
这里可以看到,首先先爬取了列表页,然后提取出了详情页之后接着开始爬详情页,然后提取出我们想要的电影信息之后,再接着去爬下一个详情页。
这样,所有的详情页都会被我们爬取下来啦。
最后,我们再像之前一样添加一个数据存储的方法,为了方便,这里还是保存为 JSON 文本文件,实现如下:
import jsonfrom os import makedirsfrom os.path import existsRESULTS_DIR = 'results'exists(RESULTS_DIR) or makedirs(RESULTS_DIR)async def save_data(data): name = data.get('name') data_path = f'{RESULTS_DIR}/{name}.json' json.dump(data, open(data_path, 'w', encoding='utf-8'), ensure_ascii=False, indent=2)
这里原理和之前是完全相同的,但是由于这里我们使用的是 Pyppeteer,是异步调用,所以 save_data 方法前面需要加 async。
最后添加上 save_data 的调用,完整看下运行效果。
在运行过程中,由于 Pyppeteer 本身实现的原因,可能连续运行 20 秒之后控制台就会出现如下错误:
pyppeteer.errors.NetworkError: Protocol Error (Runtime.evaluate): Session closed. Most likely the page has been closed.
其原因是 Pyppeteer 内部使用了 Websocket,在 Websocket 客户端发送 ping 信号 20 秒之后仍未收到 pong 应答,就会中断连接。
问题的解决方法和详情描述见 https://github.com/miyakogi/pyppeteer/issues/178,此时我们可以通过修改 Pyppeteer 源代码来解决这个问题,对应的代码修改见:https://github.com/miyakogi/pyppeteer/pull/160/files,即把 connect 方法添加 ping_interval=None, ping_timeout=None 两个参数即可。
另外也可以复写一下 Connection 的实现,其解决方案同样可以在 https://github.com/miyakogi/pyppeteer/pull/160 找到,如 patch_pyppeteer 的定义。
最后如果代码能稳定运行了,我们可以将其改为无头模式,将 HEADLESS 修改为 True 即可,这样在运行的时候就不会弹出浏览器窗口了。
本课时我们通过实例来讲解了 Pyppeteer 爬取一个完整网站的过程,从而对 Pyppeteer 的使用有进一步的掌握。
]]>我们在做爬虫的过程中经常会遇到这样的情况,最初爬虫正常运行,正常抓取数据,一切看起来都是那么的美好,然而一杯茶的功夫可能就会出现错误,比如 403 Forbidden,这时候打开网页一看,可能会看到 “您的 IP 访问频率太高” 这样的提示,或者跳出一个验证码让我们输入,输入之后才可能解封,但是输入之后过一会儿就又这样了。
出现这种现象的原因是网站采取了一些反爬虫的措施,比如服务器会检测某个 IP 在单位时间内的请求次数,如果超过了这个阈值,那么会直接拒绝服务,返回一些错误信息,这种情况可以称之为封 IP,于是乎就成功把我们的爬虫禁掉了。
既然服务器检测的是某个 IP 单位时间的请求次数,那么我们借助某种方式来伪装我们的 IP,让服务器识别不出是由我们本机发起的请求,不就可以成功防止封 IP 了吗?所以这时候代理就派上用场了。
本课时我们先来看下代理的基本原理和使用代理处理反爬虫的方法。
代理实际上指的就是代理服务器,英文叫作 proxy server,它的功能是代理网络用户去获取网络信息。形象地说,它是网络信息的中转站。在我们正常请求一个网站时,是发送了请求给 Web 服务器,Web 服务器把响应传回给我们。如果设置了代理服务器,实际上就是在本机和服务器之间搭建了一个桥,此时本机不是直接向 Web 服务器发起请求,而是向代理服务器发出请求,请求会发送给代理服务器,然后由代理服务器再发送给 Web 服务器,接着由代理服务器再把 Web 服务器返回的响应转发给本机。这样我们同样可以正常访问网页,但这个过程中 Web 服务器识别出的真实 IP 就不再是我们本机的 IP 了,就成功实现了 IP 伪装,这就是代理的基本原理。
那么,代理有什么作用呢?我们可以简单列举如下。
对于爬虫来说,由于爬虫爬取速度过快,在爬取过程中可能遇到同一个 IP 访问过于频繁的问题,此时网站就会让我们输入验证码登录或者直接封锁 IP,这样会给爬取带来极大的不便。
使用代理隐藏真实的 IP,让服务器误以为是代理服务器在请求自己。这样在爬取过程中通过不断更换代理,就不会被封锁,可以达到很好的爬取效果。
代理分类时,既可以根据协议区分,也可以根据其匿名程度区分,下面分别总结如下:
根据代理的协议,代理可以分为如下类别:
根据代理的匿名程度,代理可以分为如下类别。
在前面我们介绍了多种请求库,如 Requests、Selenium、Pyppeteer 等。我们接下来首先贴近实战,了解一下代理怎么使用,为后面了解代理池打下基础。
下面我们来梳理一下这些库的代理的设置方法。
做测试之前,我们需要先获取一个可用代理。搜索引擎搜索 “代理” 关键字,就可以看到许多代理服务网站,网站上会有很多免费或付费代理,比如免费代理“快代理”:https://www.kuaidaili.com/free/。但是这些免费代理大多数情况下都是不好用的,所以比较靠谱的方法是购买付费代理。付费代理各大代理商家都有套餐,数量不用多,稳定可用即可,我们可以自行选购。
如果本机有相关代理软件的话,软件一般会在本机创建 HTTP 或 SOCKS 代理服务,本机直接使用此代理也可以。
在这里,我的本机安装了一部代理软件,它会在本地的 7890 端口上创建 HTTP 代理服务,即代理为127.0.0.1:7890,另外还会在 7891 端口创建 SOCKS 代理服务,即代理为 127.0.0.1:7891。
我只要设置了这个代理,就可以成功将本机 IP 切换到代理软件连接的服务器的 IP 了。下面的示例里,我将使用上述代理来演示其设置方法,你也可以自行替换成自己的可用代理。设置代理后测试的网址是:http://httpbin.org/get,我们访问该网址可以得到请求的相关信息,其中 origin 字段就是客户端的 IP,我们可以根据它来判断代理是否设置成功,即是否成功伪装了 IP。
对于 requests 来说,代理设置非常简单,我们只需要传入 proxies 参数即可。
我在这里以我本机的代理为例,来看下 requests 的 HTTP 代理的设置,代码如下:
import requestsproxy = '127.0.0.1:7890'proxies = { 'http': 'http://' + proxy, 'https': 'https://' + proxy,}try: response = requests.get('https://httpbin.org/get', proxies=proxies) print(response.text)except requests.exceptions.ConnectionError as e: print('Error', e.args)运行结果:{ "args": {}, "headers": { "Accept": "*/*", "Accept-Encoding": "gzip, deflate", "Host": "httpbin.org", "User-Agent": "python-requests/2.22.0", "X-Amzn-Trace-Id": "Root=1-5e8f358d-87913f68a192fb9f87aa0323" }, "origin": "210.173.1.204", "url": "https://httpbin.org/get"}
可以发现,我们通过一个字典的形式就设置好了 HTTP 代理,它分为两个类别,有 HTTP 和 HTTPS,如果我们访问的链接是 HTTP 协议,那就用 http 字典名指定的代理,如果是 HTTPS 协议,那就用 https 字典名指定的代理。
其运行结果的 origin 如是代理服务器的 IP,则证明代理已经设置成功。
如果代理需要认证,同样在代理的前面加上用户名密码即可,代理的写法就变成如下所示:
proxy = 'username:password@127.0.0.1:7890'
这里只需要将 username 和 password 替换即可。
如果需要使用 SOCKS 代理,则可以使用如下方式来设置:
import requestsproxy = '127.0.0.1:7891'proxies = { 'http': 'socks5://' + proxy, 'https': 'socks5://' + proxy}try: response = requests.get('https://httpbin.org/get', proxies=proxies) print(response.text)except requests.exceptions.ConnectionError as e: print('Error', e.args)
在这里,我们需要额外安装一个包,这个包叫作 requests[socks],安装命令如下所示:
pip3 install "requests[socks]"
运行结果是完全相同的:
{ "args": {}, "headers": { "Accept": "*/*", "Accept-Encoding": "gzip, deflate", "Host": "httpbin.org", "User-Agent": "python-requests/2.22.0", "X-Amzn-Trace-Id": "Root=1-5e8f364a-589d3cf2500fafd47b5560f2" }, "origin": "210.173.1.204", "url": "https://httpbin.org/get"}
另外,还有一种设置方式即使用 socks 模块,也需要像上文一样安装 socks 库。这种设置方法如下所示:
import requestsimport socksimport socketsocks.set_default_proxy(socks.SOCKS5, '127.0.0.1', 7891)socket.socket = socks.socksockettry: response = requests.get('https://httpbin.org/get') print(response.text)except requests.exceptions.ConnectionError as e: print('Error', e.args)
使用这种方法也可以设置 SOCKS 代理,运行结果完全相同。相比第一种方法,此方法是全局设置。我们可以在不同情况下选用不同的方法。
Selenium 同样可以设置代理,在这里以 Chrome 为例来介绍下其设置方法。
对于无认证的代理,设置方法如下:
from selenium import webdriverproxy = '127.0.0.1:7890'options = webdriver.ChromeOptions()options.add_argument('--proxy-server=http://' + proxy)browser = webdriver.Chrome(options=options)browser.get('https://httpbin.org/get')print(browser.page_source)browser.close()
运行结果如下:
{ "args": {}, "headers": { "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9", "Accept-Encoding": "gzip, deflate", "Accept-Language": "zh-CN,zh;q=0.9", "Host": "httpbin.org", "Upgrade-Insecure-Requests": "1", "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.149 Safari/537.36", "X-Amzn-Trace-Id": "Root=1-5e8f39cd-60930018205fd154a9af39cc" }, "origin": "210.173.1.204", "url": "http://httpbin.org/get"}
代理设置成功,origin 同样为代理 IP 的地址。
如果代理是认证代理,则设置方法相对比较麻烦,设置方法如下所示:
from selenium import webdriverfrom selenium.webdriver.chrome.options import Optionsimport zipfile ip = '127.0.0.1'port = 7890username = 'foo'password = 'bar' manifest_json = """{"version":"1.0.0","manifest_version": 2,"name":"Chrome Proxy","permissions": ["proxy","tabs","unlimitedStorage","storage","<all_urls>","webRequest","webRequestBlocking"],"background": {"scripts": ["background.js"] }}"""background_js = """var config = { mode: "fixed_servers", rules: { singleProxy: { scheme: "http", host: "%(ip) s", port: %(port) s } } } chrome.proxy.settings.set({value: config, scope: "regular"}, function() {}); function callbackFn(details) { return { authCredentials: {username: "%(username) s", password: "%(password) s" } }} chrome.webRequest.onAuthRequired.addListener( callbackFn, {urls: ["<all_urls>"]}, ['blocking'])""" % {'ip': ip, 'port': port, 'username': username, 'password': password} plugin_file = 'proxy_auth_plugin.zip'with zipfile.ZipFile(plugin_file, 'w') as zp: zp.writestr("manifest.json", manifest_json) zp.writestr("background.js", background_js)options = Options()options.add_argument("--start-maximized")options.add_extension(plugin_file)browser = webdriver.Chrome(options=options)browser.get('https://httpbin.org/get')print(browser.page_source)browser.close()
这里需要在本地创建一个 manifest.json 配置文件和 background.js 脚本来设置认证代理。运行代码之后本地会生成一个 proxy_auth_plugin.zip 文件来保存当前配置。
运行结果和上例一致,origin 同样为代理 IP。
SOCKS 代理的设置也比较简单,把对应的协议修改为 socks5 即可,如无密码认证的代理设置方法为:
from selenium import webdriverproxy = '127.0.0.1:7891'options = webdriver.ChromeOptions()options.add_argument('--proxy-server=socks5://' + proxy)browser = webdriver.Chrome(options=options)browser.get('https://httpbin.org/get')print(browser.page_source)browser.close()
运行结果是一样的。
对于 aiohttp 来说,我们可以通过 proxy 参数直接设置即可,HTTP 代理设置如下:
import asyncioimport aiohttpproxy = 'http://127.0.0.1:7890'async def main(): async with aiohttp.ClientSession() as session: async with session.get('https://httpbin.org/get', proxy=proxy) as response: print(await response.text())if __name__ == '__main__': asyncio.get_event_loop().run_until_complete(main())
如果代理有用户名密码,像 requests 一样,把 proxy 修改为如下内容:
proxy = 'http://username:password@127.0.0.1:7890'
这里只需要将 username 和 password 替换即可。
对于 SOCKS 代理,我们需要安装一个支持库,叫作 aiohttp-socks,安装命令如下:
pip3 install aiohttp-socks
可以借助于这个库的 ProxyConnector 来设置 SOCKS 代理,代码如下:
import asyncioimport aiohttpfrom aiohttp_socks import ProxyConnector connector = ProxyConnector.from_url('socks5://127.0.0.1:7891') async def main(): async with aiohttp.ClientSession(connector=connector) as session: async with session.get('https://httpbin.org/get') as response: print(await response.text())if __name__ == '__main__': asyncio.get_event_loop().run_until_complete(main())
运行结果是一样的。
另外这个库还支持设置 SOCKS4、HTTP 代理以及对应的代理认证,可以参考其官方介绍。
对于 Pyppeteer 来说,由于其默认使用的是类似 Chrome 的 Chromium 浏览器,因此设置方法和 Selenium 的 Chrome 是一样的,如 HTTP 无认证代理设置方法都是通过 args 来设置,实现如下:
import asynciofrom pyppeteer import launchproxy = '127.0.0.1:7890'async def main(): browser = await launch({'args': ['--proxy-server=http://' + proxy], 'headless': False}) page = await browser.newPage() await page.goto('https://httpbin.org/get') print(await page.content()) await browser.close()if __name__ == '__main__': asyncio.get_event_loop().run_until_complete(main())
运行结果:
{ "args": {}, "headers": { "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8", "Accept-Encoding": "gzip, deflate, br", "Accept-Language": "zh-CN,zh;q=0.9", "Host": "httpbin.org", "Upgrade-Insecure-Requests": "1", "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/69.0.3494.0 Safari/537.36", "X-Amzn-Trace-Id": "Root=1-5e8f442c-12b1ed7865b049007267a66c" }, "origin": "210.173.1.204", "url": "https://httpbin.org/get"}
同样可以看到设置成功。
对于 SOCKS 代理,也是一样的,只需要将协议修改为 socks5 即可,代码实现如下:
import asynciofrom pyppeteer import launchproxy = '127.0.0.1:7891'async def main(): browser = await launch({'args': ['--proxy-server=socks5://' + proxy], 'headless': False}) page = await browser.newPage() await page.goto('https://httpbin.org/get') print(await page.content()) await browser.close()if __name__ == '__main__': asyncio.get_event_loop().run_until_complete(main())
运行结果也是一样的。
以上总结了各个库的代理使用方式,以后如果遇到封 IP 的问题,我们就可以轻松通过加代理的方式来解决啦。
本节代码:https://github.com/Python3WebSpider/ProxyTest。
我们在上一课时了解了利用代理可以解决目标网站封 IP 的问题,但是如何实时高效地获取到大量可用的代理又是一个问题。
首先在互联网上有大量公开的免费代理,当然我们也可以购买付费的代理 IP,但是代理不论是免费的还是付费的,都不能保证是可用的,因为可能此 IP 已被其他人使用来爬取同样的目标站点而被封禁,或者代理服务器突然发生故障或网络繁忙。一旦我们选用了一个不可用的代理,这势必会影响爬虫的工作效率。
所以,我们需要提前做筛选,将不可用的代理剔除掉,保留可用代理。那么这个怎么来实现呢?这里就需要借助于一个叫作代理池的东西了。
接下来本课时我们就介绍一下如何搭建一个高效易用的代理池。
在这里代理池的存储我们需要借助于 Redis,因此这个需要额外安装。总体来说,本课时需要的环境如下:
安装好一些必要的库,包括 aiohttp、requests、redis-py、pyquery、Flask 等。
建议使用 Python 虚拟环境安装,参考安装命令如下:
做好了如上准备工作,我们便可以开始实现或运行本课时所讲的代理池了。
我们需要做到下面的几个目标,来实现易用高效的代理池。
以上内容是设计代理的一些基本思路。接下来我们设计整体的架构,然后用代码实现代理池。
根据上文的描述,代理池的架构如图所示。
代理池分为 4 个模块:存储模块、获取模块、检测模块、接口模块。
接下来我们分别用代码来实现一下这四个模块。
注:完整的代理池代码量较大,因此本课时的代码不必一步步跟着编写,最后去了解源码即可。
这里我们使用 Redis 的有序集合,集合的每一个元素都是不重复的,对于代理池来说,集合的元素就变成了一个个代理,也就是 IP 加端口的形式,如 60.207.237.111:8888,这样的一个代理就是集合的一个元素。另外,有序集合的每一个元素都有一个分数字段,分数是可以重复的,可以是浮点数类型,也可以是整数类型。该集合会根据每一个元素的分数对集合进行排序,数值小的排在前面,数值大的排在后面,这样就可以实现集合元素的排序了。
对于代理池来说,这个分数可以作为判断一个代理是否可用的标志,100 为最高分,代表最可用,0 为最低分,代表最不可用。如果要获取可用代理,可以从代理池中随机获取分数最高的代理,注意是随机,这样可以保证每个可用代理都会被调用到。
分数是我们判断代理稳定性的重要标准,设置分数规则如下所示。
这只是一种解决方案,当然可能还有更合理的方案。之所以设置此方案有如下几个原因。
上述代理分数的设置思路不一定是最优思路,但据个人实测,它的实用性还是比较强的。
在这里首先给出存储模块的实现代码,见:https://github.com/Python3WebSpider/ProxyPool/tree/master/proxypool/storages,建议直接对照源码阅读。
在代码中,我们定义了一个类来操作数据库的有序集合,定义一些方法来实现分数的设置、代理的获取等。其核心实现代码实现如下所示:
import redisfrom proxypool.exceptions import PoolEmptyExceptionfrom proxypool.schemas.proxy import Proxyfrom proxypool.setting import REDIS_HOST, REDIS_PORT, REDIS_PASSWORD, REDIS_KEY, PROXY_SCORE_MAX, PROXY_SCORE_MIN, \ PROXY_SCORE_INITfrom random import choicefrom typing import Listfrom loguru import loggerfrom proxypool.utils.proxy import is_valid_proxy, convert_proxy_or_proxiesREDIS_CLIENT_VERSION = redis.__version__IS_REDIS_VERSION_2 = REDIS_CLIENT_VERSION.startswith('2.')class RedisClient(object): """ redis connection client of proxypool """ def __init__(self, host=REDIS_HOST, port=REDIS_PORT, password=REDIS_PASSWORD, **kwargs): """ init redis client :param host: redis host :param port: redis port :param password: redis password """ self.db = redis.StrictRedis(host=host, port=port, password=password, decode_responses=True, **kwargs) def add(self, proxy: Proxy, score=PROXY_SCORE_INIT) -> int: """ add proxy and set it to init score :param proxy: proxy, ip:port, like 8.8.8.8:88 :param score: int score :return: result """ if not is_valid_proxy(f'{proxy.host}:{proxy.port}'): logger.info(f'invalid proxy {proxy}, throw it') return if not self.exists(proxy): if IS_REDIS_VERSION_2: return self.db.zadd(REDIS_KEY, score, proxy.string()) return self.db.zadd(REDIS_KEY, {proxy.string(): score}) def random(self) -> Proxy: """ get random proxy firstly try to get proxy with max score if not exists, try to get proxy by rank if not exists, raise error :return: proxy, like 8.8.8.8:8 """ # try to get proxy with max score proxies = self.db.zrangebyscore(REDIS_KEY, PROXY_SCORE_MAX, PROXY_SCORE_MAX) if len(proxies): return convert_proxy_or_proxies(choice(proxies)) # else get proxy by rank proxies = self.db.zrevrange(REDIS_KEY, PROXY_SCORE_MIN, PROXY_SCORE_MAX) if len(proxies): return convert_proxy_or_proxies(choice(proxies)) # else raise error raise PoolEmptyException def decrease(self, proxy: Proxy) -> int: """ decrease score of proxy, if small than PROXY_SCORE_MIN, delete it :param proxy: proxy :return: new score """ score = self.db.zscore(REDIS_KEY, proxy.string()) # current score is larger than PROXY_SCORE_MIN if score and score > PROXY_SCORE_MIN: logger.info(f'{proxy.string()} current score {score}, decrease 1') if IS_REDIS_VERSION_2: return self.db.zincrby(REDIS_KEY, proxy.string(), -1) return self.db.zincrby(REDIS_KEY, -1, proxy.string()) # otherwise delete proxy else: logger.info(f'{proxy.string()} current score {score}, remove') return self.db.zrem(REDIS_KEY, proxy.string()) def exists(self, proxy: Proxy) -> bool: """ if proxy exists :param proxy: proxy :return: if exists, bool """ return not self.db.zscore(REDIS_KEY, proxy.string()) is None def max(self, proxy: Proxy) -> int: """ set proxy to max score :param proxy: proxy :return: new score """ logger.info(f'{proxy.string()} is valid, set to {PROXY_SCORE_MAX}') if IS_REDIS_VERSION_2: return self.db.zadd(REDIS_KEY, PROXY_SCORE_MAX, proxy.string()) return self.db.zadd(REDIS_KEY, {proxy.string(): PROXY_SCORE_MAX}) def count(self) -> int: """ get count of proxies :return: count, int """ return self.db.zcard(REDIS_KEY) def all(self) -> List[Proxy]: """ get all proxies :return: list of proxies """ return convert_proxy_or_proxies(self.db.zrangebyscore(REDIS_KEY, PROXY_SCORE_MIN, PROXY_SCORE_MAX)) def batch(self, start, end) -> List[Proxy]: """ get batch of proxies :param start: start index :param end: end index :return: list of proxies """ return convert_proxy_or_proxies(self.db.zrevrange(REDIS_KEY, start, end - 1))if __name__ == '__main__': conn = RedisClient() result = conn.random() print(result)
首先我们定义了一些常量,如 PROXY_SCORE_MAX、PROXY_SCORE_MIN、PROXY_SCORE_INIT 分别代表最大分数、最小分数、初始分数。REDIS_HOST、REDIS_PORT、REDIS_PASSWORD 分别代表了 Redis 的连接信息,即地址、端口、密码。REDIS_KEY 是有序集合的键名,我们可以通过它来获取代理存储所使用的有序集合。
RedisClient 这个类可以用来操作 Redis 的有序集合,其中定义了一些方法来对集合中的元素进行处理,它的主要功能如下所示。
定义好了这些方法,我们可以在后续的模块中调用此类来连接和操作数据库。如想要获取随机可用的代理,只需要调用 random 方法即可,得到的就是随机的可用代理。
获取模块主要是为了从各大网站抓取代理并调用存储模块进行保存,代码实现见:https://github.com/Python3WebSpider/ProxyPool/tree/master/proxypool/crawlers。
获取模块的逻辑相对简单,比如我们可以定义一些抓取代理的方法,示例如下:
from proxypool.crawlers.base import BaseCrawlerfrom proxypool.schemas.proxy import Proxyimport reMAX_PAGE = 5BASE_URL = 'http://www.ip3366.net/free/?stype=1&page={page}'class IP3366Crawler(BaseCrawler): """ ip3366 crawler, http://www.ip3366.net/ """ urls = [BASE_URL.format(page=i) for i in range(1, 8)] def parse(self, html): """ parse html file to get proxies :return: """ ip_address = re.compile('<tr>\s*<td>(.*?)</td>\s*<td>(.*?)</td>') # \s * 匹配空格,起到换行作用 re_ip_address = ip_address.findall(html) for address, port in re_ip_address: proxy = Proxy(host=address.strip(), port=int(port.strip())) yield proxy
我们在这里定义了一个代理 Crawler 类,用来抓取某一网站的代理,这里是抓取的 IP3366 的公开代理,通过 parse 方法来解析页面的源码并构造一个个 Proxy 对象返回即可。
另外在其父类 BaseCrawler 里面定义了通用的页面抓取方法,它可以读取子类里面定义的 urls 全局变量并进行爬取,然后调用子类的 parse 方法来解析页面,代码实现如下:
from retrying import retryimport requestsfrom loguru import loggerclass BaseCrawler(object): urls = [] @retry(stop_max_attempt_number=3, retry_on_result=lambda x: x is None) def fetch(self, url, **kwargs): try: response = requests.get(url, **kwargs) if response.status_code == 200: return response.text except requests.ConnectionError: return @logger.catch def crawl(self): """ crawl main method """ for url in self.urls: logger.info(f'fetching {url}') html = self.fetch(url) for proxy in self.parse(html): logger.info(f'fetched proxy {proxy.string()} from {url}') yield proxy
所以,我们如果要扩展一个代理的 Crawler,只需要继承 BaseCrawler 并实现 parse 方法即可,扩展性较好。
因此,这一个个的 Crawler 就可以针对各个不同的代理网站进行代理的抓取。最后有一个统一的方法将 Crawler 汇总起来,遍历调用即可。
如何汇总呢?在这里我们可以检测代码只要定义有 BaseCrawler 的子类就算一个有效的代理 Crawler,可以直接通过遍历 Python 文件包的方式来获取,代码实现如下:
import pkgutilfrom .base import BaseCrawlerimport inspect# load classes subclass of BaseCrawlerclasses = []for loader, name, is_pkg in pkgutil.walk_packages(__path__): module = loader.find_module(name).load_module(name) for name, value in inspect.getmembers(module): globals()[name] = value if inspect.isclass(value) and issubclass(value, BaseCrawler) and value is not BaseCrawler: classes.append(value)__all__ = __ALL__ = classes
在这里我们调用了 walk_packages 方法,遍历了整个 crawlers 模块下的类,并判断了它是 BaseCrawler 的子类,那就将其添加到结果中,并返回。
最后只要将 classes 遍历并依次实例化,调用其 crawl 方法即可完成代理的爬取和提取,代码实现见:https://github.com/Python3WebSpider/ProxyPool/blob/master/proxypool/processors/getter.py。
我们已经成功将各个网站的代理获取下来了,现在就需要一个检测模块来对所有代理进行多轮检测。代理检测可用,分数就设置为 100,代理不可用,分数减 1,这样就可以实时改变每个代理的可用情况。如要获取有效代理只需要获取分数高的代理即可。
由于代理的数量非常多,为了提高代理的检测效率,我们在这里使用异步请求库 aiohttp 来进行检测。
requests 作为一个同步请求库,我们在发出一个请求之后,程序需要等待网页加载完成之后才能继续执行。也就是这个过程会阻塞等待响应,如果服务器响应非常慢,比如一个请求等待十几秒,那么我们使用 requests 完成一个请求就会需要十几秒的时间,程序也不会继续往下执行,而在这十几秒的时间里程序其实完全可以去做其他的事情,比如调度其他的请求或者进行网页解析等。
对于响应速度比较快的网站来说,requests 同步请求和 aiohttp 异步请求的效果差距没那么大。可对于检测代理来说,检测一个代理一般需要十多秒甚至几十秒的时间,这时候使用 aiohttp 异步请求库的优势就大大体现出来了,效率可能会提高几十倍不止。
所以,我们的代理检测使用异步请求库 aiohttp,实现示例如下所示:
import asyncioimport aiohttpfrom loguru import loggerfrom proxypool.schemas import Proxyfrom proxypool.storages.redis import RedisClientfrom proxypool.setting import TEST_TIMEOUT, TEST_BATCH, TEST_URL, TEST_VALID_STATUSfrom aiohttp import ClientProxyConnectionError, ServerDisconnectedError, ClientOSError, ClientHttpProxyErrorfrom asyncio import TimeoutErrorEXCEPTIONS = ( ClientProxyConnectionError, ConnectionRefusedError, TimeoutError, ServerDisconnectedError, ClientOSError, ClientHttpProxyError)class Tester(object): """ tester for testing proxies in queue """ def __init__(self): """ init redis """ self.redis = RedisClient() self.loop = asyncio.get_event_loop() async def test(self, proxy: Proxy): """ test single proxy :param proxy: Proxy object :return: """ async with aiohttp.ClientSession(connector=aiohttp.TCPConnector(ssl=False)) as session: try: logger.debug(f'testing {proxy.string()}') async with session.get(TEST_URL, proxy=f'http://{proxy.string()}', timeout=TEST_TIMEOUT, allow_redirects=False) as response: if response.status in TEST_VALID_STATUS: self.redis.max(proxy) logger.debug(f'proxy {proxy.string()} is valid, set max score') else: self.redis.decrease(proxy) logger.debug(f'proxy {proxy.string()} is invalid, decrease score') except EXCEPTIONS: self.redis.decrease(proxy) logger.debug(f'proxy {proxy.string()} is invalid, decrease score') @logger.catch def run(self): """ test main method :return: """ # event loop of aiohttp logger.info('stating tester...') count = self.redis.count() logger.debug(f'{count} proxies to test') for i in range(0, count, TEST_BATCH): # start end end offset start, end = i, min(i + TEST_BATCH, count) logger.debug(f'testing proxies from {start} to {end} indices') proxies = self.redis.batch(start, end) tasks = [self.test(proxy) for proxy in proxies] # run tasks using event loop self.loop.run_until_complete(asyncio.wait(tasks))if __name__ == '__main__': tester = Tester() tester.run()
这里定义了一个类 Tester,__init__ 方法中建立了一个 RedisClient 对象,供该对象中其他方法使用。接下来定义了一个 test 方法,这个方法用来检测单个代理的可用情况,其参数就是被检测的代理。注意,test 方法前面加了 async 关键词,代表这个方法是异步的。方法内部首先创建了 aiohttp 的 ClientSession 对象,可以直接调用该对象的 get 方法来访问页面。
测试的链接在这里定义为常量 TEST_URL。如果针对某个网站有抓取需求,建议将 TEST_URL 设置为目标网站的地址,因为在抓取的过程中,代理本身可能是可用的,但是该代理的 IP 已经被目标网站封掉了。例如,某些代理可以正常访问百度等页面,但是对知乎来说可能就被封了,所以我们可以将 TEST_URL 设置为知乎的某个页面的链接,当请求失败、代理被封时,分数自然会减下来,失效的代理就不会被取到了。
如果想做一个通用的代理池,则不需要专门设置 TEST_URL,可以将其设置为一个不会封 IP 的网站,也可以设置为百度这类响应稳定的网站。
我们还定义了 TEST_VALID_STATUS 变量,这个变量是一个列表形式,包含了正常的状态码,如可以定义成 [200]。当然某些目标网站可能会出现其他的状态码,你可以自行配置。
程序在获取 Response 后需要判断响应的状态,如果状态码在 TEST_VALID_STATUS 列表里,则代表代理可用,可以调用 RedisClient 的 max 方法将代理分数设为 100,否则调用 decrease 方法将代理分数减 1,如果出现异常也同样将代理分数减 1。
另外,我们设置了批量测试的最大值为 TEST_BATCH,也就是一批测试最多 TEST_BATCH 个,这可以避免代理池过大时一次性测试全部代理导致内存开销过大的问题。当然也可以用信号量来实现并发控制。
随后,在 run 方法里面获取了所有的代理列表,使用 aiohttp 分配任务,启动运行。这样在不断的运行过程中,代理池中无效的代理的分数会一直被减 1,直至被清除,有效的代理则会一直保持 100 分,供随时取用。
这样,测试模块的逻辑就完成了。
通过上述 3 个模块,我们已经可以做到代理的获取、检测和更新,数据库就会以有序集合的形式存储各个代理及其对应的分数,分数 100 代表可用,分数越小代表越不可用。
但是我们怎样方便地获取可用代理呢?可以用 RedisClient 类直接连接 Redis,然后调用 random 方法。这样做没问题,效率很高,但是会有几个弊端。
综上考虑,为了使代理池可以作为一个独立服务运行,我们最好增加一个接口模块,并以 Web API 的形式暴露可用代理。
这样一来,获取代理只需要请求接口即可,以上的几个缺点弊端也可以避免。
我们使用一个比较轻量级的库 Flask 来实现这个接口模块,实现示例如下所示:
from flask import Flask, gfrom proxypool.storages.redis import RedisClientfrom proxypool.setting import API_HOST, API_PORT, API_THREADED__all__ = ['app']app = Flask(__name__)def get_conn(): """ get redis client object :return: """ if not hasattr(g, 'redis'): g.redis = RedisClient() return g.redis@app.route('/')def index(): """ get home page, you can define your own templates :return: """ return '<h2>Welcome to Proxy Pool System</h2>'@app.route('/random')def get_proxy(): """ get a random proxy :return: get a random proxy """ conn = get_conn() return conn.random().string()@app.route('/count')def get_count(): """ get the count of proxies :return: count, int """ conn = get_conn() return str(conn.count())if __name__ == '__main__': app.run(host=API_HOST, port=API_PORT, threaded=API_THREADED)
在这里,我们声明了一个 Flask 对象,定义了 3 个接口,分别是首页、随机代理页、获取数量页。
运行之后,Flask 会启动一个 Web 服务,我们只需要访问对应的接口即可获取到可用代理。
调度模块就是调用以上所定义的 3 个模块,将这 3 个模块通过多进程的形式运行起来,示例如下所示:
import timeimport multiprocessingfrom proxypool.processors.server import appfrom proxypool.processors.getter import Getterfrom proxypool.processors.tester import Testerfrom proxypool.setting import CYCLE_GETTER, CYCLE_TESTER, API_HOST, API_THREADED, API_PORT, ENABLE_SERVER, \ ENABLE_GETTER, ENABLE_TESTER, IS_WINDOWSfrom loguru import loggerif IS_WINDOWS: multiprocessing.freeze_support()tester_process, getter_process, server_process = None, None, Noneclass Scheduler(): """ scheduler """ def run_tester(self, cycle=CYCLE_TESTER): """ run tester """ if not ENABLE_TESTER: logger.info('tester not enabled, exit') return tester = Tester() loop = 0 while True: logger.debug(f'tester loop {loop} start...') tester.run() loop += 1 time.sleep(cycle) def run_getter(self, cycle=CYCLE_GETTER): """ run getter """ if not ENABLE_GETTER: logger.info('getter not enabled, exit') return getter = Getter() loop = 0 while True: logger.debug(f'getter loop {loop} start...') getter.run() loop += 1 time.sleep(cycle) def run_server(self): """ run server for api """ if not ENABLE_SERVER: logger.info('server not enabled, exit') return app.run(host=API_HOST, port=API_PORT, threaded=API_THREADED) def run(self): global tester_process, getter_process, server_process try: logger.info('starting proxypool...') if ENABLE_TESTER: tester_process = multiprocessing.Process(target=self.run_tester) logger.info(f'starting tester, pid {tester_process.pid}...') tester_process.start() if ENABLE_GETTER: getter_process = multiprocessing.Process(target=self.run_getter) logger.info(f'starting getter, pid{getter_process.pid}...') getter_process.start() if ENABLE_SERVER: server_process = multiprocessing.Process(target=self.run_server) logger.info(f'starting server, pid{server_process.pid}...') server_process.start() tester_process.join() getter_process.join() server_process.join() except KeyboardInterrupt: logger.info('received keyboard interrupt signal') tester_process.terminate() getter_process.terminate() server_process.terminate() finally: # must call join method before calling is_alive tester_process.join() getter_process.join() server_process.join() logger.info(f'tester is {"alive" if tester_process.is_alive() else "dead"}') logger.info(f'getter is {"alive" if getter_process.is_alive() else "dead"}') logger.info(f'server is {"alive" if server_process.is_alive() else "dead"}') logger.info('proxy terminated')if __name__ == '__main__': scheduler = Scheduler() scheduler.run()
3 个常量 ENABLE_TESTER、ENABLE_GETTER、ENABLE_SERVER 都是布尔类型,表示测试模块、获取模块、接口模块的开关,如果都为 True,则代表模块开启。
启动入口是 run 方法,这个方法分别判断 3 个模块的开关。如果开关开启,启动时程序就新建一个 Process 进程,设置好启动目标,然后调用 start 方法运行,这样 3 个进程就可以并行执行,互不干扰。
3 个调度方法结构也非常清晰。比如,run_tester 方法用来调度测试模块,首先声明一个 Tester 对象,然后进入死循环不断循环调用其 run 方法,执行完一轮之后就休眠一段时间,休眠结束之后重新再执行。在这里,休眠时间也定义为一个常量,如 20 秒,即每隔 20 秒进行一次代理检测。
最后,只需要调用 Scheduler 的 run 方法即可启动整个代理池。
以上内容便是整个代理池的架构和相应实现逻辑。
接下来我们将代码整合一下,将代理运行起来,运行之后的输出结果如下所示:
2020-04-13 02:52:06.510 | INFO | proxypool.storages.redis:decrease:73 - 60.186.146.193:9000 current score 10.0, decrease 12020-04-13 02:52:06.517 | DEBUG | proxypool.processors.tester:test:52 - proxy 60.186.146.193:9000 is invalid, decrease score2020-04-13 02:52:06.524 | INFO | proxypool.storages.redis:decrease:73 - 60.186.151.147:9000 current score 10.0, decrease 12020-04-13 02:52:06.532 | DEBUG | proxypool.processors.tester:test:52 - proxy 60.186.151.147:9000 is invalid, decrease score2020-04-13 02:52:07.159 | INFO | proxypool.storages.redis:max:96 - 60.191.11.246:3128 is valid, set to 1002020-04-13 02:52:07.167 | DEBUG | proxypool.processors.tester:test:46 - proxy 60.191.11.246:3128 is valid, set max score2020-04-13 02:52:17.271 | INFO | proxypool.storages.redis:decrease:73 - 59.62.7.130:9000 current score 10.0, decrease 12020-04-13 02:52:17.280 | DEBUG | proxypool.processors.tester:test:52 - proxy 59.62.7.130:9000 is invalid, decrease score2020-04-13 02:52:17.288 | INFO | proxypool.storages.redis:decrease:73 - 60.167.103.74:1133 current score 10.0, decrease 12020-04-13 02:52:17.295 | DEBUG | proxypool.processors.tester:test:52 - proxy 60.167.103.74:1133 is invalid, decrease score2020-04-13 02:52:17.302 | INFO | proxypool.storages.redis:decrease:73 - 60.162.71.113:9000 current score 10.0, decrease 12020-04-13 02:52:17.309 | DEBUG | proxypool.processors.tester:test:52 - proxy 60.162.71.113:9000 is invalid, decrease score
以上是代理池的控制台输出,可以看到可用代理设置为 100,不可用代理分数减 1。
接下来我们再打开浏览器,当前配置了运行在 5555 端口,所以打开:http://127.0.0.1:5555,即可看到其首页,如图所示。
再访问 http://127.0.0.1:5555/random,即可获取随机可用代理,如图所示。
我们只需要访问此接口即可获取一个随机可用代理,这非常方便。
获取代理的代码如下所示:
import requests PROXY_POOL_URL = 'http://localhost:5555/random' def get_proxy(): try: response = requests.get(PROXY_POOL_URL) if response.status_code == 200: return response.text except ConnectionError: return None
这样便可以获取到一个随机代理了,它是字符串类型,此代理可以按照上一课时所示的方法设置,如 requests 的使用方法如下所示:
import requests proxy = get_proxy()proxies = { 'http': 'http://' + proxy, 'https': 'https://' + proxy,}try: response = requests.get('http://httpbin.org/get', proxies=proxies) print(response.text)except requests.exceptions.ConnectionError as e: print('Error', e.args)
有了代理池之后,我们再取出代理即可有效防止 IP 被封禁的情况。
本课时代码地址为:https://github.com/Python3WebSpider/ProxyPool,代码量相比之前的案例复杂了很多,逻辑也相对完善。另外代码库中还提供了 Docker 和 Kubernetes 的运行和部署操作,可以帮助我们更加快捷地运行代理池,如果你感兴趣可以了解下。
我们在浏览网站的时候经常会遇到各种各样的验证码,在多数情况下这些验证码会出现在登录账号的时候,也可能会出现在访问页面的过程中,严格来说,这些行为都算验证码反爬虫。
本课时我们就来介绍下验证码反爬虫的基本原理及常见的验证码和解决方案。
验证码,全称叫作 Completely Automated Public Turing test to tell Computers and Humans Apart,意思是全自动区分计算机和人类的图灵测试,取了它们关键词的首字母变成了 CAPTCHA,它是一种用来区分用户是计算机还是人的公共全自动程序。
它有什么用呢?当然很多用处,如:
总的来说呢,以上的行为都可以称之为验证码反爬虫行为。使用验证码可以防止各种可以用程序模拟的行为。有了验证码,机器要想完全自动化执行就会遇到一些麻烦,当然这个麻烦的大小就取决于验证码的破解难易程度了。
那为什么会出现验证码呢?在大多数情形下是因为网站的访问频率过高或者行为异常,或者是为了直接限制某些自动化行为。归类如下:
这几种情形都能在一定程度上限制程序的一些自动化行为,因此都可以称之为反爬虫。
在模块一的时候,我们已经讲到过 Session 的基本概念了,它是存在于服务端的,用于保存当前用户的会话信息,这个信息对于验证码的机制非常重要。
服务端是可以往 Session 对象里面存一些值的,比如我们要生成一个图形验证码,比如 1234 这四个数字的图形验证码。
首先客户端要显示某个验证码,这个验证码相关的信息肯定要从服务器端来获取。比如说请求了这个生成验证码的接口,我们要生成一个图形验证码,内容为 1234,这时候服务端会将 1234 这四个数字保存到 Session 对象里面,然后把 1234 这个结果返回给客户端,或者直接把生成好的验证码图形返回也是可以的,客户端会将其呈现出来,用户就能看到验证码的内容了。
用户看到验证码之后呢,就会在表单里面输入验证码的内容,点击提交按钮的时候,这些信息就会又发送给服务器,服务器拿着提交的信息和 Session 里面保存的验证码信息后进行对比,如果一致,那就代表验证码输入正确,校验成功,然后就继续放行恢复正常状态。如果不一致,那就代表校验失败,会继续进行校验。
目前市面上大多数的验证码都是基于这个机制来实现的,归类如下:
还有很多其他的验证码,其原理基本都是一致的。
下面我们再来看看市面上的一些常见的验证码,并简单介绍一些识别思路。
最基本的验证码就是图形验证码了,比如下图。
一般来说,识别思路有这么几种:
现在我们能见到非常多类型的行为验证码,可以说是十分流行了,比如极验、腾讯、网易盾等等都有类似的验证码服务,另外验证的方式也多种多样,如滑动、拖动、点选、逻辑判断等等,如图所示。
这里推荐的识别方案有以下几种:
另外我们可能遇到一些类似短信、扫码的验证码,这种操作起来就会更加麻烦,一些解决思路如下:
基本上验证码都是类似的,其中有一些列举不全,但是基本类别都能大致归类。
以上我们就介绍了验证码反爬虫的基本原理和一些验证码识别的思路。在后面的课时我会介绍使用打码平台和深度学习的方式来识别验证码的方案。
在前一课时我们介绍了多种多样的验证码,有图形文字的、有模拟点选的、有拖动滑动的,但其实归根结底都需要人来对某种情形做一些判断,然后把结果返回并提交。如果此时提交的验证码结果是正确的,并且通过了一些验证码的检测,就能成功突破这个验证码了。
那么,既然验证码就是让人来识别的,那么机器怎么办呢?如果我们也不会什么算法,怎么去解这些验证码呢?此时如果有一个帮助我们来识别验证码的工具或平台就好了,让工具或平台把验证码识别的结果返回给我们,我们拿着结果提交,那不就好了吗?
有这种工具或平台吗?还真有专门的打码平台帮助我们来识别各种各样的验证码,平台内部对算法和人力做了集成,可以 7x24 小时来识别各种验证码,包括识别图形、坐标点、缺口等各种验证码,返回对应的结果或坐标,正好可以解决我们的问题。
本课时我们就来介绍利用打码平台来识别验证码的流程。
本课时我们以一种点选验证码为例来讲解打码平台的使用方法,验证码的链接为:https://captcha3.scrape.center/,这个网站在每次登录的时候都会弹出一个验证码,其验证码效果图如下所示。
这个验证码上面显示了几个汉字,同时在图中也显示了几个汉字,我们需要按照顺序依次点击汉字在图中的位置,点击完成之后确认提交,即可完成验证。
这种验证码如果我们没有任何图像识别算法基础的话,是很难去识别的,所以这里我们可以借助打码平台来帮助我们识别汉字的位置。
我们使用的 Python 库是 Selenium,使用的浏览器为 Chrome。
在本课时开始之前请确保已经正确安装好 Selenium 库、Chrome 浏览器,并配置好 ChromeDriver,相关流程可以参考 Selenium 那一课时的介绍。
另外本课时使用的打码平台是超级鹰,链接为:https://www.chaojiying.com/,在使用之前请你自己注册账号并获取一些题分供测试,另外还可以了解平台可识别的验证码的类别。
打码平台能提供的服务种类一般都非常广泛,可识别的验证码类型也非常多,其中就包括点触验证码。
超级鹰平台同样支持简单的图形验证码识别。超级鹰平台提供了如下一些服务。
英文数字:提供最多 20 位英文数字的混合识别;
中文汉字:提供最多 7 个汉字的识别;
纯英文:提供最多 12 位的英文识别;
纯数字:提供最多 11 位的数字识别;
任意特殊字符:提供不定长汉字英文数字、拼音首字母、计算题、成语混合、集装箱号等字符的识别;
坐标选择识别:如复杂计算题、选择题四选一、问答题、点击相同的字、物品、动物等返回多个坐标的识别。
具体如有变动以官网为准:https://www.chaojiying.com/price.html。
这里需要处理的就是坐标多选识别的情况。我们先将验证码图片提交给平台,平台会返回识别结果在图片中的坐标位置,然后我们再解析坐标模拟点击。
下面我们就用程序来实现。
在官方网站下载对应的 Python API,链接为:https://www.chaojiying.com/api-14.html。API 是 Python 2 版本的,是用 requests 库来实现的。我们可以简单更改几个地方,即可将其修改为 Python 3 版本。
修改之后的 API 如下所示:
import requestsfrom hashlib import md5class Chaojiying(object): def __init__(self, username, password, soft_id): self.username = username self.password = md5(password.encode('utf-8')).hexdigest() self.soft_id = soft_id self.base_params = { 'user': self.username, 'pass2': self.password, 'softid': self.soft_id, } self.headers = { 'Connection': 'Keep-Alive', 'User-Agent': 'Mozilla/4.0 (compatible; MSIE 8.0; Windows NT 5.1; Trident/4.0)', } def post_pic(self, im, codetype): """ im: 图片字节 codetype: 题目类型 参考 http://www.chaojiying.com/price.html """ params = { 'codetype': codetype, } params.update(self.base_params) files = {'userfile': ('ccc.jpg', im)} r = requests.post('http://upload.chaojiying.net/Upload/Processing.php', data=params, files=files, headers=self.headers) return r.json() def report_error(self, im_id): """ im_id:报错题目的图片ID """ params = { 'id': im_id, } params.update(self.base_params) r = requests.post('http://upload.chaojiying.net/Upload/ReportError.php', data=params, headers=self.headers) return r.json()
这里定义了一个 Chaojiying 类,其构造函数接收三个参数,分别是超级鹰的用户名、密码以及软件 ID,保存以备使用。
最重要的一个方法叫作 post_pic,它需要传入图片对象和验证码类型的代号。该方法会将图片对象和相关信息发给超级鹰的后台进行识别,然后将识别成功的 JSON 返回。
另一个方法叫作 report_error,它是发生错误时的回调。如果验证码识别错误,调用此方法会返回相应的题分。
接下来,我们以 https://captcha3.scrape.center/ 为例来演示下识别的过程。
首先我们引入一些必要的包,然后初始化一些变量,如 WebDriver、Chaojiying 对象等,代码实现如下所示:
import timefrom io import BytesIOfrom PIL import Imagefrom selenium import webdriverfrom selenium.webdriver import ActionChainsfrom selenium.webdriver.common.by import Byfrom selenium.webdriver.support.ui import WebDriverWaitfrom selenium.webdriver.support import expected_conditions as ECfrom chaojiying import ChaojiyingUSERNAME = 'admin'PASSWORD = 'admin'CHAOJIYING_USERNAME = ''CHAOJIYING_PASSWORD = ''CHAOJIYING_SOFT_ID = 893590CHAOJIYING_KIND = 9102if not CHAOJIYING_USERNAME or not CHAOJIYING_PASSWORD: print('请设置用户名和密码') exit(0)class CrackCaptcha(): def __init__(self): self.url = 'https://captcha3.scrape.center/' self.browser = webdriver.Chrome() self.wait = WebDriverWait(self.browser, 20) self.username = USERNAME self.password = PASSWORD self.chaojiying = Chaojiying(CHAOJIYING_USERNAME, CHAOJIYING_PASSWORD, CHAOJIYING_SOFT_ID)
这里的 USERNAME、PASSWORD 是示例网站的用户名和密码,都设置为 admin 即可。另外 CHAOJIYING_USERNAME、CHAOJIYING_PASSWORD 就是超级鹰打码平台的用户名和密码,可以自行设置成自己的。
另外这里定义了一个 CrackCaptcha 类,初始化了浏览器对象和打码平台的操作对象。
接下来我们用 Selenium 模拟呼出验证码开始验证就好啦。
接下来的步骤就是完善相关表单,模拟点击呼出验证码了,代码实现如下所示:
def open(self): """ 打开网页输入用户名密码 :return: None """ self.browser.get(self.url) # 填入用户名密码 username = self.wait.until(EC.presence_of_element_located((By.CSS_SELECTOR, 'input[type="text"]'))) password = self.wait.until(EC.presence_of_element_located((By.CSS_SELECTOR, 'input[type="password"]'))) username.send_keys(self.username) password.send_keys(self.password)def get_captcha_button(self): """ 获取初始验证按钮 :return: """ button = self.wait.until(EC.presence_of_element_located((By.CSS_SELECTOR, 'button[type="button"]'))) return button
这里我们调用了 open 方法负责填写表单,get_captcha_button 方法获取验证码按钮,之后触发点击,这时候就可以看到页面已经把验证码呈现出来了。
有了验证码的图片,我们下一步要做的就是把验证码的具体内容获取下来,然后发送给打码平台识别。
那怎么获取验证码的图片呢?我们可以先获取验证码图片的位置和大小,从网页截图里截取相应的验证码图片即可,代码实现如下所示:
def get_captcha_element(self): """ 获取验证图片对象 :return: 图片对象 """ # 验证码图片加载出来 self.wait.until(EC.presence_of_element_located((By.CSS_SELECTOR, 'img.geetest_item_img'))) # 验证码完整节点 element = self.wait.until(EC.presence_of_element_located((By.CLASS_NAME, 'geetest_panel_box'))) print('成功获取验证码节点') return elementdef get_captcha_position(self): """ 获取验证码位置 :return: 验证码位置元组 """ element = self.get_captcha_element() time.sleep(2) location = element.location size = element.size top, bottom, left, right = location['y'], location['y'] + size['height'], location['x'], location['x'] + size[ 'width'] return (top, bottom, left, right)def get_screenshot(self): """ 获取网页截图 :return: 截图对象 """ screenshot = self.browser.get_screenshot_as_png() screenshot = Image.open(BytesIO(screenshot)) screenshot.save('screenshot.png') return screenshotdef get_captcha_image(self, name='captcha.png'): """ 获取验证码图片 :return: 图片对象 """ top, bottom, left, right = self.get_captcha_position() print('验证码位置', top, bottom, left, right) screenshot = self.get_screenshot() captcha = screenshot.crop((left, top, right, bottom)) captcha.save(name) return captcha
这里 get_captcha_image 方法即为从网页截图中截取对应的验证码图片,其中验证码图片的相对位置坐标由 get_captcha_position 方法返回得到。所以就是利用了先截图再裁切的方法获取了验证码。
注意:如果你的屏幕是高清屏如 Mac 的 Retina 屏幕的话,可能需要适当调整下屏幕分辨率或者对获取到的验证码位置做一些倍数偏移计算。
最后我们得到的验证码是 Image 对象,其结果样例如图所示。
现在我们有了验证码图了,下一步就是把图发送给打码平台了。
我们调用 Chaojiying 对象的 post_pic 方法,即可把图片发送给超级鹰后台,这里发送的图像是字节流格式,代码实现如下所示:
image = self.get_touclick_image()bytes_array = BytesIO()image.save(bytes_array, format='PNG')# 识别验证码result = self.chaojiying.post_pic(bytes_array.getvalue(), CHAOJIYING_KIND)print(result)
运行之后,result 变量就是超级鹰后台的识别结果。可能运行需要等待几秒,它会返回一个 JSON 格式的字符串。
如果识别成功,典型的返回结果如下所示:
{'err_no': 0, 'err_str': 'OK', 'pic_id': '6002001380949200001', 'pic_str': '132,127|56,77', 'md5': '1f8e1d4bef8b11484cb1f1f34299865b'}其中,pic_str 就是识别的文字的坐标,是以字符串形式返回的,每个坐标都以 | 分隔。接下来我们只需要将其解析,然后模拟点击,代码实现如下所示:def get_points(self, captcha_result): """ 解析识别结果 :param captcha_result: 识别结果 :return: 转化后的结果 """ groups = captcha_result.get('pic_str').split('|') locations = [[int(number) for number in group.split(',')] for group in groups] return locationsdef touch_click_words(self, locations): """ 点击验证图片 :param locations: 点击位置 :return: None """ for location in locations: ActionChains(self.browser).move_to_element_with_offset(self.get_captcha_element(), location[0], location[1]).click().perform() time.sleep(1)
这里用 get_points 方法将识别结果变成列表的形式。touch_click_words 方法则通过调用 move_to_element_with_offset 方法依次传入解析后的坐标,点击即可。
这样我们就模拟完成坐标的点选了,运行效果如下所示。
最后再模拟点击提交验证的按钮,等待验证通过就会自动登录啦,后续实现在此不再赘述。
如何判断登录是否成功呢?同样可以使用 Selenium 的判定条件,比如判断页面里面出现了某个文字就代表登录成功了,代码如下:
# 判定是否成功success = self.wait.until(EC.text_to_be_present_in_element((By.TAG_NAME, 'h2'), '登录成功'))
比如这里我们判定了点击确认按钮,页面会不会跳转到提示成功的页面,成功的页面包含一个 h2 节点,包含“登录成功”四个字,就代表登录成功啦。
这样我们就借助在线验证码平台完成了点触验证码的识别。此方法是一种通用方法,我们也可以用此方法来识别图文、数字、算术等各种各样的验证码。
本课时我们通过在线打码平台辅助完成了验证码的识别。这种识别方法非常强大,几乎任意的验证码都可以识别。如果遇到难题,借助打码平台无疑是一个极佳的选择。
我们在前面讲解了如何使用打码平台来识别验证码,简单高效。但是也有一些缺点,比如效率可能没那么高,准确率也不一定能做到完全可控,并且需要付出一定的费用。
本课时我们就来介绍使用深度学习来识别验证码的方法,训练好对应的模型就能更好地对验证码进行识别,并且准确率可控,节省一定的成本。
本课时我们以深度学习识别滑块验证码为例来讲解深度学习对于此类验证码识别的实现。
滑块验证码是怎样的呢?如图所示,验证码是一张矩形图,图片左侧会出现一个滑块,右侧会出现一个缺口,下侧会出现一个滑轨。左侧的滑块会随着滑轨的拖动而移动,如果能将左侧滑块匹配滑动到右侧缺口处,就算完成了验证。
由于这种验证码交互形式比较友好,且安全性、美观度上也会更高,像这种类似的验证码也变得越来越流行。另外不仅仅是“极验”,其他很多验证码服务商也推出了类似的验证码服务,如“网易易盾”等,上图所示的就是“网易易盾”的滑动验证码。
没错,这种滑动验证码的出现确实让很多网站变得更安全。但是做爬虫的可就苦恼了,如果想采用自动化的方法来绕过这种滑动验证码,关键点在于以下两点:
那么问题来了,第一步怎么做呢?
接下来我们就来看看如何利用深度学习来实现吧。
我们的目标就是输入一张图,输出缺口的的位置,所以只需要将这个问题归结成一个深度学习的“目标检测”问题就好了。
首先在开始之前简单说下目标检测。什么叫目标检测?顾名思义,就是把我们想找的东西找出来。比如给一张“狗”的图片,如图所示:
我们想知道这只狗在哪,它的舌头在哪,找到了就把它们框选出来,这就是目标检测。
经过目标检测算法处理之后,我们期望得到的图片是这样的:
可以看到这只狗和它的舌头就被框选出来了,这样就完成了一个不错的目标检测。
当前做目标检测的算法主要有两个方向,有一阶段式和两阶段式,英文分别叫作 One stage 和 Two stage,简述如下。
所以这次我们选用 One Stage 的有代表性的目标检测算法 YOLO 来实现滑动验证码缺口的识别。
YOLO,英文全称叫作 You Only Look Once,取了它们的首字母就构成了算法名,目前 YOLO 算法最新的版本是 V3 版本,这里算法的具体流程我们就不过多介绍了,如果你感兴趣可以搜一下相关资料了解下,另外也可以了解下 YOLO V1~V3 版本的不同和改进之处,这里列几个参考链接。
回归我们本课时的主题,我们要做的是缺口的位置识别,那么第一步应该做什么呢?
我们的目标是要训练深度学习模型,那我们总得需要让模型知道要学点什么东西吧,这次我们做缺口识别,那么我们需要让模型学的就是找到这个缺口在哪里。由于一张验证码图片只有一个缺口,要分类就是一类,所以我们只需要找到缺口位置就行了。
好,那模型要学如何找出缺口的位置,就需要我们提供样本数据让模型来学习才行。样本数据怎样的呢?样本数据就得有带缺口的验证码图片以及我们自己标注的缺口位置。只有把这两部分都告诉模型,模型才能去学习。等模型学好了,当我们再给个新的验证码时,就能检测出缺口在哪里了,这就是一个成功的模型。
OK,那我们就开始准备数据和缺口标注结果吧。
数据这里用的是网易盾的验证码,验证码图片可以自行收集,写个脚本批量保存下来就行。标注的工具可以使用 LabelImg,GitHub 链接为:https://github.com/tzutalin/labelImg,利用它我们可以方便地进行检测目标位置的标注和类别的标注,如这里验证码和标注示例如下:
标注完了会生成一系列 xml 文件,你需要解析 xml 文件把位置的坐标和类别等处理一下,转成训练模型需要的数据。
在这里我已经整理好了我的数据集,完整 GitHub 链接为:https://github.com/Python3WebSpider/DeepLearningSlideCaptcha,我标注了 200 多张图片,然后处理了 xml 文件,变成训练 YOLO 模型需要的数据格式,验证码图片和标注结果见 data/captcha 文件夹。
如果要训练自己的数据,数据格式准备见:https://github.com/eriklindernoren/PyTorch-YOLOv3#train-on-custom-dataset
上一步我已经把标注好的数据处理好了,可以直接拿来训练了。
由于 YOLO 模型相对比较复杂,所以这个项目我就直接基于开源的 PyTorch-YOLOV3 项目来进行修改了,模型使用的深度学习框架为 PyTorch,具体的 YOLO V3 模型的实现这里不再阐述了。
另外推荐使用 GPU 训练,不然拿 CPU 直接训练速度会很慢。我的 GPU 是 P100,几乎十几秒就训练完一轮。
下面就直接把代码克隆下来吧。
由于本项目我把训练好的模型也放上去了,使用了 Git LFS,所以克隆时间较长,克隆命令如下:
git clone https://github.com/Python3WebSpider/DeepLearningSlideCaptcha.git
如果想加速克隆,可以暂时先跳过大文件模型下载,可以执行命令:
GIT_LFS_SKIP_SMUDGE=1 git clone https://github.com/Python3WebSpider/DeepLearningSlideCaptcha.git
代码克隆下载之后,我们还需要下载一些预训练模型。
YOLOV3 的训练要加载预训练模型才能有不错的训练效果,预训练模型下载命令如下:
bash prepare.sh
执行这个脚本,就能下载 YOLO V3 模型的一些权重文件,包括 yolov3 和 weights,还有 darknet 的 weights,在训练之前我们需要用这些权重文件初始化 YOLO V3 模型。
注意:Windows 下建议使用 Git Bash 来运行上述命令。
另外还需要安装一些必须的库,如 PyTorch、TensorBoard 等,建议使用 Python 虚拟环境,运行命令如下:
pip3 install -r requirements.txt
这些库都安装好了之后,就可以开始训练了。
本项目已经提供了标注好的数据集,在 data/captcha,可以直接使用。
当前数据训练脚本:
bash train.sh
实测 P100 训练时长约 15 秒一个 epoch,大约几分钟即可训练出较好效果。
训练差不多了,我们便可以使用 TensorBoard 来看看 loss 和 mAP 的变化,运行 TensorBoard:
tensorboard --logdir='logs' --port=6006 --host 0.0.0.0
loss_1 变化如下:
val_mAP 变化如下:
可以看到 loss 从最初的非常高下降到了很低,准确率也逐渐接近 100%。
另外训练过程中还能看到如下的输出结果:
---- [Epoch 99/100, Batch 27/29] ----+------------+--------------+--------------+--------------+| Metrics | YOLO Layer 0 | YOLO Layer 1 | YOLO Layer 2 |+------------+--------------+--------------+--------------+| grid_size | 14 | 28 | 56 || loss | 0.028268 | 0.046053 | 0.043745 || x | 0.002108 | 0.005267 | 0.008111 || y | 0.004561 | 0.002016 | 0.009047 || w | 0.001284 | 0.004618 | 0.000207 || h | 0.000594 | 0.000528 | 0.000946 || conf | 0.019700 | 0.033624 | 0.025432 || cls | 0.000022 | 0.000001 | 0.000002 || cls_acc | 100.00% | 100.00% | 100.00% || recall50 | 1.000000 | 1.000000 | 1.000000 || recall75 | 1.000000 | 1.000000 | 1.000000 || precision | 1.000000 | 0.800000 | 0.666667 || conf_obj | 0.994271 | 0.999249 | 0.997762 || conf_noobj | 0.000126 | 0.000158 | 0.000140 |+------------+--------------+--------------+--------------+Total loss 0.11806630343198776
这里显示了训练过程中各个指标的变化情况,如 loss、recall、precision、confidence 等,分别代表训练过程的损失(越小越好)、召回率(能识别出的结果占应该识别出结果的比例,越高越好)、精确率(识别出的结果中正确的比率,越高越好)、置信度(模型有把握识别对的概率,越高越好),可以作为参考。
训练完毕之后会在 checkpoints 文件夹生成 pth 文件,可直接使用模型来预测生成标注结果。
如果你没有训练自己的模型的话,这里我已经把训练好的模型放上去了,可以直接使用我训练好的模型来测试。如之前跳过了 Git LFS 文件下载,则可以使用如下命令下载 Git LFS 文件:
git lfs pull
此时 checkpoints 文件夹会生成训练好的 pth 文件。
测试脚本:
sh detect.sh
该脚本会读取 captcha 下的 test 文件夹所有图片,并将处理后的结果输出到 result 文件夹。
运行结果样例:
Performing object detection: + Batch 0, Inference Time: 0:00:00.044223 + Batch 1, Inference Time: 0:00:00.028566 + Batch 2, Inference Time: 0:00:00.029764 + Batch 3, Inference Time: 0:00:00.032430 + Batch 4, Inference Time: 0:00:00.033373 + Batch 5, Inference Time: 0:00:00.027861 + Batch 6, Inference Time: 0:00:00.031444 + Batch 7, Inference Time: 0:00:00.032110 + Batch 8, Inference Time: 0:00:00.029131Saving images:(0) Image: 'data/captcha/test/captcha_4497.png' + Label: target, Conf: 0.99999(1) Image: 'data/captcha/test/captcha_4498.png' + Label: target, Conf: 0.99999(2) Image: 'data/captcha/test/captcha_4499.png' + Label: target, Conf: 0.99997(3) Image: 'data/captcha/test/captcha_4500.png' + Label: target, Conf: 0.99999(4) Image: 'data/captcha/test/captcha_4501.png' + Label: target, Conf: 0.99997(5) Image: 'data/captcha/test/captcha_4502.png' + Label: target, Conf: 0.99999(6) Image: 'data/captcha/test/captcha_4503.png' + Label: target, Conf: 0.99997(7) Image: 'data/captcha/test/captcha_4504.png' + Label: target, Conf: 0.99998(8) Image: 'data/captcha/test/captcha_4505.png' + Label: target, Conf: 0.99998
拿几个样例结果看下:
这里我们可以看到,利用训练好的模型我们就成功识别出缺口的位置了,另外程序还会打印输出这个边框的中心点和宽高信息。
有了这个边界信息,我们再利用某些手段拖动滑块即可通过验证了,比如可以模拟加速减速过程,或者可以录制人的轨迹再执行都是可以的,由于本课时更多是介绍深度学习识别相关内容,所以关于拖动轨迹不再展开讲解。
本课时我们介绍了使用深度学习识别滑动验证码缺口的方法,包括标注、训练、测试等环节都进行了阐述。有了它,我们就能轻松方便地对缺口进行识别了。
代码:https://github.com/Python3WebSpider/DeepLearningSlideCaptcha
]]>在很多情况下,一些网站的页面或资源我们通常需要登录才能看到。比如访问 GitHub 的个人设置页面,如果不登录是无法查看的;比如 12306 买票提交订单的页面,如果不登录是无法提交订单的;再比如要发一条微博,如果不登录是无法发送的。
我们之前学习的案例都是爬取的无需登录即可访问的站点,但是诸如上面例子的情况非常非常多,那假如我们想要用爬虫来访问这些页面,比如用爬虫修改 GitHub 的个人设置,用爬虫提交购票订单,用爬虫发微博,能做到吗?
答案是可以,这里就需要用到一些模拟登录相关的技术了。那么本课时我们就先来了解模拟登录的一些基本原理和实现吧。
我们要实现模拟登录,那就得首先了解网站登录验证的实现。
登录一般需要两个内容,用户名和密码,有的网站可能是手机号和验证码,有的是微信扫码,有的是 OAuth 验证等等,但根本上来说,都是把一些可供认证的信息提交给了服务器。
比如这里我们就拿用户名和密码来举例吧。用户在一个网页表单里面输入了内容,然后点击登录按钮的一瞬间,浏览器客户端就会向服务器发送一个登录请求,这个请求里面肯定就包含了用户名和密码信息,这时候,服务器需要处理这些信息,然后返回给客户端一个类似“凭证”的东西,有了这个“凭证”以后呢,客户端拿着这个“凭证”再去访问某些需要登录才能查看的页面,服务器自然就能“放行”了,然后返回对应的内容或执行对应的操作就好了。
形象地说,我们以登录发微博和买票坐火车这两件事来类比。发微博就好像要坐火车,没票是没法坐火车的吧,要坐火车怎么办呢?当然是先买票了,我们拿钱去火车站买了票,有了票之后,进站口查验一下,没问题就自然能去坐火车了,这个票就是坐火车的“凭证”。
发微博也一样,我们有用户名和密码,请求下服务器,获得一个“凭证”,这就相当于买到了火车票,然后在发微博的时候拿着这个“凭证”去请求服务器,服务器校验没问题,自然就把微博发出去了。
那么问题来了,这个“凭证“”到底是怎么生成和验证的呢?目前比较流行的实现方式有两种,一种是基于 Session + Cookies 的验证,一种是基于 JWT(JSON Web Token)的验证,下面我们来介绍下。
我们在模块一了解了 Session 和 Cookies 的基本概念。简而言之,Session 就是存在服务端的,里面保存了用户此次访问的会话信息,Cookies 则是保存在用户本地浏览器的,它会在每次用户访问网站的时候发送给服务器,Cookies 会作为 Request Headers 的一部分发送给服务器,服务器根据 Cookies 里面包含的信息判断找出其 Session 对象,不同的 Session 对象里面维持了不同访问用户的状态,服务器可以根据这些信息决定返回 Response 的内容。
我们以用户登录的情形来举例,其实不同的网站对于用户的登录状态的实现可能是不同的,但是 Session 和 Cookies 一定是相互配合工作的。
梳理如下:
以上两种情况几乎能涵盖大部分的 Session 和 Cookies 登录验证的实现,具体的实现逻辑因服务器而异,但 Session 和 Cookies 一定是需要相互配合才能实现的。
Web 开发技术是一直在发展的,近几年前后端分离的趋势越来越火,很多 Web 网站都采取了前后端分离的技术来实现。而且传统的基于 Session 和 Cookies 的校验也存在一定问题,比如服务器需要维护登录用户的 Session 信息,而且不太方便分布式部署,也不太适合前后端分离的项目。
所以,JWT 技术应运而生。JWT,英文全称叫作 JSON Web Token,是为了在网络应用环境间传递声明而执行的一种基于 JSON 的开放标准。实际上就是每次登录的时候通过一个 Token 字符串来校验登录状态。
JWT 的声明一般被用来在身份提供者和服务提供者间传递被认证的用户身份信息,以便于从资源服务器获取资源,也可以增加一些额外的其他业务逻辑所必须的声明信息,所以这个 Token 也可直接被用于认证,也可传递一些额外信息。
有了 JWT,一些认证就不需要借助于 Session 和 Cookies 了,服务器也无需维护 Session 信息,减少了服务器的开销。服务器只需要有一个校验 JWT 的功能就好了,同时也可以做到分布式部署和跨语言的支持。
JWT 通常就是一个加密的字符串,它也有自己的标准,类似下面的这种格式:
eyJ0eXAxIjoiMTIzNCIsImFsZzIiOiJhZG1pbiIsInR5cCI6IkpXVCIsImFsZyI6IkhTMjU2In0.eyJVc2VySWQiOjEyMywiVXNlck5hbWUiOiJhZG1pbiIsImV4cCI6MTU1MjI4Njc0Ni44Nzc0MDE4fQ.pEgdmFAy73walFonEm2zbxg46Oth3dlT02HR9iVzXa8
可以发现中间有两个“.”来分割开,可以把它看成是一个三段式的加密字符串。它由三部分构成,分别是 Header、Payload、Signature。
这三部分通过“.”组合起来就形成了 JWT 的字符串,就是用户的访问凭证。
所以这个登录认证流程也很简单了,用户拿着用户名密码登录,然后服务器生成 JWT 字符串返回给客户端,客户端每次请求都带着这个 JWT 就行了,服务器会自动判断其有效情况,如果有效,那自然就返回对应的数据。但 JWT 的传输就多种多样了,可以放在 Request Headers,也可以放在 URL 里,甚至有的网站也放在 Cookies 里,但总而言之,能传给服务器校验就好了。
好,到此为止呢,我们就已经了解了网站登录验证的实现了。
好,了解了网站登录验证的实现后,模拟登录自然就有思路了。下面我们也是分两种认证方式来说明。
基于 Session 和 Cookies 的模拟登录,如果我们要用爬虫实现的话,其实最主要的就是把 Cookies 的信息维护好,因为爬虫就相当于客户端浏览器,我们模拟好浏览器做的事情就好了。
那一般情况下,模拟登录一般可以怎样实现呢,我们结合之前所讲的技术来总结一下:
以上介绍的就是一些常用的爬虫模拟登录的方案,其目的就是维护好客户端的 Cookies 信息,然后每次请求都携带好 Cookies 信息就能实现模拟登录了。
基于 JWT 的真实情况也比较清晰了,由于 JWT 的这个字符串就是用户访问的凭证,那么模拟登录只需要做到下面几步即可:
当然这个模拟登录的过程也肯定带有其他的一些加密参数,需要根据实际情况具体分析。
如果爬虫要求爬取的数据量比较大或爬取速度比较快,而网站又有单账号并发限制或者访问状态检测并反爬的话,可能我们的账号就会无法访问或者面临封号的风险了。这时候一般怎么办呢?
我们可以使用分流的方案来解决,比如某个网站一分钟之内检测一个账号只能访问三次或者超过三次就封号的话,我们可以建立一个账号池,用多个账号来随机访问或爬取,这样就能数倍提高爬虫的并发量或者降低被封的风险了。
比如在访问某个网站的时候,我们可以准备 100 个账号,然后 100 个账号都模拟登录,把对应的 Cookies 或 JWT 存下来,每次访问的时候随机取一个来访问,由于账号多,所以每个账号被取用的概率也就降下来了,这样就能避免单账号并发过大的问题,也降低封号风险。
以上,我们就介绍完了模拟登录的基本原理和实现以及优化方案,希望你可以好好理解。
在上一课时我们了解了网站登录验证和模拟登录的基本原理。网站登录验证主要有两种实现,一种是基于 Session + Cookies 的登录验证,另一种是基于 JWT 的登录验证,那么本课时我们就通过两个实例来分别讲解这两种登录验证的分析和模拟登录流程。
在本课时开始之前,请你确保已经做好了如下准备工作:
安装好了 Python (最好 3.6 及以上版本)并能成功运行 Python 程序;
安装好了 requests 请求库并学会了其基本用法;
安装好了 Selenium 库并学会了其基本用法。
下面我们就以两个案例为例来分别讲解模拟登录的实现。
这里有两个需要登录才能抓取的网站,链接为 https://login2.scrape.center/ 和 https://login3.scrape.center/,前者是基于 Session + Cookies 认证的网站,后者是基于 JWT 认证的网站。
首先看下第一个网站,打开后会看到如图所示的页面。
它直接跳转到了登录页面,这里用户名和密码都是 admin,我们输入之后登录。
登录成功之后,我们便看到了熟悉的电影网站的展示页面,如图所示。
这个网站是基于传统的 MVC 模式开发的,因此也比较适合 Session + Cookies 的认证。
第二个网站打开后同样会跳到登录页面,如图所示。
用户名和密码是一样的,都输入 admin 即可登录。
登录之后会跳转到首页,展示了一些书籍信息,如图所示。
这个页面是前后端分离式的页面,数据的加载都是通过 Ajax 请求后端 API 接口获取,登录的校验是基于 JWT 的,同时后端每个 API 都会校验 JWT 是否是有效的,如果无效则不会返回数据。
接下来我们就分析这两个案例并实现模拟登录吧。
对于案例一,我们如果要模拟登录,就需要先分析下登录过程究竟发生了什么,首先我们打开 https://login2.scrape.center/,然后执行登录操作,查看其登录过程中发生的请求,如图所示。
这里我们可以看到其登录的瞬间是发起了一个 POST 请求,目标 URL 为 https://login2.scrape.center/login,通过表单提交的方式提交了登录数据,包括 username 和 password 两个字段,返回的状态码是 302,Response Headers 的 location 字段是根页面,同时 Response Headers 还包含了 set-cookie 信息,设置了 Session ID。
由此我们可以发现,要实现模拟登录,我们只需要模拟这个请求就好了,登录完成之后获取 Response 设置的 Cookies,将 Cookies 保存好,以后后续的请求带上 Cookies 就可以正常访问了。
好,那么我们接下来用代码实现一下吧。
requests 默认情况下每次请求都是独立互不干扰的,比如我们第一次先调用了 post 方法模拟登录,然后紧接着再调用 get 方法请求下主页面,其实这是两个完全独立的请求,第一次请求获取的 Cookies 并不能传给第二次请求,因此说,常规的顺序调用是不能起到模拟登录的效果的。
我们先来看一个无效的代码:
import requestsfrom urllib.parse import urljoinBASE_URL = 'https://login2.scrape.center/'LOGIN_URL = urljoin(BASE_URL, '/login')INDEX_URL = urljoin(BASE_URL, '/page/1')USERNAME = 'admin'PASSWORD = 'admin'response_login = requests.post(LOGIN_URL, data={ 'username': USERNAME, 'password': PASSWORD})response_index = requests.get(INDEX_URL)print('Response Status', response_index.status_code)print('Response URL', response_index.url)
这里我们先定义了几个基本的 URL 和用户名、密码,接下来分别用 requests 请求了登录的 URL 进行模拟登录,然后紧接着请求了首页来获取页面内容,但是能正常获取数据吗?
由于 requests 可以自动处理重定向,我们最后把 Response 的 URL 打印出来,如果它的结果是 INDEX_URL,那么就证明模拟登录成功并成功爬取到了首页的内容。如果它跳回到了登录页面,那就说明模拟登录失败。
我们通过结果来验证一下,运行结果如下:
Response Status 200Response URL https://login2.scrape.center/login?next=/page/1
这里可以看到,其最终的页面 URL 是登录页面的 URL,另外这里也可以通过 response 的 text 属性来验证页面源码,其源码内容就是登录页面的源码内容,由于内容较多,这里就不再输出比对了。
总之,这个现象说明我们并没有成功完成模拟登录,这是因为 requests 直接调用 post、get 等方法,每次请求都是一个独立的请求,都相当于是新开了一个浏览器打开这些链接,这两次请求对应的 Session 并不是同一个,因此这里我们模拟了第一个 Session 登录,而这并不能影响第二个 Session 的状态,因此模拟登录也就无效了。
那么怎样才能实现正确的模拟登录呢?
我们知道 Cookies 里面是保存了 Session ID 信息的,刚才也观察到了登录成功后 Response Headers 里面是有 set-cookie 字段,实际上这就是让浏览器生成了 Cookies。
Cookies 里面包含了 Session ID 的信息,所以只要后续的请求携带这些 Cookies,服务器便能通过 Cookies 里的 Session ID 信息找到对应的 Session,因此服务端对于这两次请求就会使用同一个 Session 了。而因为第一次我们已经完成了模拟登录,所以第一次模拟登录成功后,Session 里面就记录了用户的登录信息,第二次访问的时候,由于是同一个 Session,服务器就能知道用户当前是登录状态,就可以返回正确的结果而不再是跳转到登录页面了。
所以,这里的关键就在于两次请求的 Cookies 的传递。所以这里我们可以把第一次模拟登录后的 Cookies 保存下来,在第二次请求的时候加上这个 Cookies 就好了,所以代码可以改写如下:
import requestsfrom urllib.parse import urljoinBASE_URL = 'https://login2.scrape.center/'LOGIN_URL = urljoin(BASE_URL, '/login')INDEX_URL = urljoin(BASE_URL, '/page/1')USERNAME = 'admin'PASSWORD = 'admin'response_login = requests.post(LOGIN_URL, data={ 'username': USERNAME, 'password': PASSWORD}, allow_redirects=False)cookies = response_login.cookiesprint('Cookies', cookies)response_index = requests.get(INDEX_URL, cookies=cookies)print('Response Status', response_index.status_code)print('Response URL', response_index.url)
由于 requests 可以自动处理重定向,所以模拟登录的过程我们要加上 allow_redirects 参数并设置为 False,使其不自动处理重定向,这里登录之后返回的 Response 我们赋值为 response_login,这样通过调用 response_login 的 cookies 就可以获取到网站的 Cookies 信息了,这里 requests 自动帮我们解析了 Response Headers 的 set-cookie 字段并设置了 Cookies,所以我们不需要手动解析 Response Headers 的内容了,直接使用 response_login 对象的 cookies 属性即可获取 Cookies。
好,接下来我们再次用 requests 的 get 方法来请求网站的 INDEX_URL,不过这里和之前不同,get 方法多加了一个参数 cookies,这就是第一次模拟登录完之后获取的 Cookies,这样第二次请求就能携带第一次模拟登录获取的 Cookies 信息了,此时网站会根据 Cookies 里面的 Session ID 信息查找到同一个 Session,校验其已经是登录状态,然后返回正确的结果。
这里我们还是输出了最终的 URL,如果其是 INDEX_URL,那就代表模拟登录成功并获取到了有效数据,否则就代表模拟登录失败。
我们看下运行结果:
Cookies <RequestsCookieJar[<Cookie sessionid=psnu8ij69f0ltecd5wasccyzc6ud41tc for login2.scrape.center/>]>Response Status 200Response URL https://login2.scrape.center/page/1
这下就没有问题了,这次我们发现其 URL 就是 INDEX_URL,模拟登录成功了!同时还可以进一步输出 response_index 的 text 属性看下是否获取成功。
接下来后续的爬取用同样的方式爬取即可。
但是我们发现其实这种实现方式比较烦琐,每次还需要处理 Cookies 并进行一次传递,有没有更简便的方法呢?
有的,我们可以直接借助于 requests 内置的 Session 对象来帮我们自动处理 Cookies,使用了 Session 对象之后,requests 会将每次请求后需要设置的 Cookies 自动保存好,并在下次请求时自动携带上去,就相当于帮我们维持了一个 Session 对象,这样就更方便了。
所以,刚才的代码可以简化如下:
import requestsfrom urllib.parse import urljoinBASE_URL = 'https://login2.scrape.center/'LOGIN_URL = urljoin(BASE_URL, '/login')INDEX_URL = urljoin(BASE_URL, '/page/1')USERNAME = 'admin'PASSWORD = 'admin'session = requests.Session()response_login = session.post(LOGIN_URL, data={ 'username': USERNAME, 'password': PASSWORD})cookies = session.cookiesprint('Cookies', cookies)response_index = session.get(INDEX_URL)print('Response Status', response_index.status_code)print('Response URL', response_index.url)
可以看到,这里我们无需再关心 Cookies 的处理和传递问题,我们声明了一个 Session 对象,然后每次调用请求的时候都直接使用 Session 对象的 post 或 get 方法就好了。
运行效果是完全一样的,结果如下:
Cookies <RequestsCookieJar[<Cookie sessionid=ssngkl4i7en9vm73bb36hxif05k10k13 for login2.scrape.center/>]>Response Status 200Response URL https://login2.scrape.center/page/1
因此,为了简化写法,这里建议直接使用 Session 对象来进行请求,这样我们就无需关心 Cookies 的操作了,实现起来会更加方便。
这个案例整体来说比较简单,但是如果碰上复杂一点的网站,如带有验证码,带有加密参数等等,直接用 requests 并不好处理模拟登录,如果登录不了,那岂不是整个页面都没法爬了吗?那么有没有其他的方式来解决这个问题呢?当然是有的,比如说,我们可以使用 Selenium 来通过模拟浏览器的方式实现模拟登录,然后获取模拟登录成功后的 Cookies,再把获取的 Cookies 交由 requests 等来爬取就好了。
这里我们还是以刚才的页面为例,我们可以把模拟登录这块交由 Selenium 来实现,后续的爬取交由 requests 来实现,代码实现如下:
from urllib.parse import urljoinfrom selenium import webdriverimport requestsimport timeBASE_URL = 'https://login2.scrape.center/'LOGIN_URL = urljoin(BASE_URL, '/login')INDEX_URL = urljoin(BASE_URL, '/page/1')USERNAME = 'admin'PASSWORD = 'admin'browser = webdriver.Chrome()browser.get(BASE_URL)browser.find_element_by_css_selector('input[name="username"]').send_keys(USERNAME)browser.find_element_by_css_selector('input[name="password"]').send_keys(PASSWORD)browser.find_element_by_css_selector('input[type="submit"]').click()time.sleep(10)# get cookies from seleniumcookies = browser.get_cookies()print('Cookies', cookies)browser.close()# set cookies to requestssession = requests.Session()for cookie in cookies: session.cookies.set(cookie['name'], cookie['value'])response_index = session.get(INDEX_URL)print('Response Status', response_index.status_code)print('Response URL', response_index.url)
这里我们使用 Selenium 先打开了 Chrome 浏览器,然后跳转到了登录页面,随后模拟输入了用户名和密码,接着点击了登录按钮,这时候我们可以发现浏览器里面就提示登录成功,然后成功跳转到了主页面。
这时候,我们通过调用 get_cookies 方法便能获取到当前浏览器所有的 Cookies,这就是模拟登录成功之后的 Cookies,用这些 Cookies 我们就能访问其他的数据了。
接下来,我们声明了 requests 的 Session 对象,然后遍历了刚才的 Cookies 并设置到 Session 对象的 cookies 上面去,接着再拿着这个 Session 对象去请求 INDEX_URL,也就能够获取到对应的信息而不会跳转到登录页面了。
运行结果如下:
Cookies [{'domain': 'login2.scrape.center', 'expiry': 1589043753.553155, 'httpOnly': True, 'name': 'sessionid', 'path': '/', 'sameSite': 'Lax', 'secure': False, 'value': 'rdag7ttjqhvazavpxjz31y0tmze81zur'}]Response Status 200Response URL https://login2.scrape.center/page/1
可以看到这里的模拟登录和后续的爬取也成功了。所以说,如果碰到难以模拟登录的过程,我们也可以使用 Selenium 或 Pyppeteer 等模拟浏览器操作的方式来实现,其目的就是取到登录后的 Cookies,有了 Cookies 之后,我们再用这些 Cookies 爬取其他页面就好了。
所以这里我们也可以发现,对于基于 Session + Cookies 验证的网站,模拟登录的核心要点就是获取 Cookies,这个 Cookies 可以被保存下来或传递给其他的程序继续使用。甚至说可以将 Cookies 持久化存储或传输给其他终端来使用。另外,为了提高 Cookies 利用率或降低封号几率,可以搭建一个 Cookies 池实现 Cookies 的随机取用。
对于案例二这种基于 JWT 的网站,其通常都是采用前后端分离式的,前后端的数据传输依赖于 Ajax,登录验证依赖于 JWT 本身这个 token 的值,如果 JWT 这个 token 是有效的,那么服务器就能返回想要的数据。
下面我们先来在浏览器里面操作登录,观察下其网络请求过程,如图所示。
这里我们发现登录时其请求的 URL 为 https://login3.scrape.center/api/login,是通过 Ajax 请求的,同时其 Request Body 是 JSON 格式的数据,而不是 Form Data,返回状态码为 200。
然后再看下返回结果,如图所示。
可以看到返回结果是一个 JSON 格式的数据,包含一个 token 字段,其结果为:
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoxLCJ1c2VybmFtZSI6ImFkbWluIiwiZXhwIjoxNTg3ODc3OTQ2LCJlbWFpbCI6ImFkbWluQGFkbWluLmNvbSIsIm9yaWdfaWF0IjoxNTg3ODM0NzQ2fQ.ujEXXAZcCDyIfRLs44i_jdfA3LIp5Jc74n-Wq2udCR8
这就是我们上一课时所讲的 JWT 的内容,格式是三段式的,通过“.”来分隔。
那么有了这个 JWT 之后,后续的数据怎么获取呢?下面我们再来观察下后续的请求内容,如图所示。
这里我们可以发现,后续获取数据的 Ajax 请求中的 Request Headers 里面就多了一个 Authorization 字段,其结果为 jwt 然后加上刚才的 JWT 的内容,返回结果就是 JSON 格式的数据。
没有问题,那模拟登录的整个思路就简单了:
模拟请求登录结果,带上必要的登录信息,获取 JWT 的结果。
后续的请求在 Request Headers 里面加上 Authorization 字段,值就是 JWT 对应的内容。
好,接下来我们用代码实现如下:
import requestsfrom urllib.parse import urljoinBASE_URL = 'https://login3.scrape.center/'LOGIN_URL = urljoin(BASE_URL, '/api/login')INDEX_URL = urljoin(BASE_URL, '/api/book')USERNAME = 'admin'PASSWORD = 'admin'response_login = requests.post(LOGIN_URL, json={ 'username': USERNAME, 'password': PASSWORD})data = response_login.json()print('Response JSON', data)jwt = data.get('token')print('JWT', jwt)headers = { 'Authorization': f'jwt {jwt}'}response_index = requests.get(INDEX_URL, params={ 'limit': 18, 'offset': 0}, headers=headers)print('Response Status', response_index.status_code)print('Response URL', response_index.url)print('Response Data', response_index.json())
这里我们同样是定义了登录接口和获取数据的接口,分别为 LOGIN_URL 和 INDEX_URL,接着通过 post 请求进行了模拟登录,这里提交的数据由于是 JSON 格式,所以这里使用 json 参数来传递。接着获取了返回结果中包含的 JWT 的结果。第二步就可以构造 Request Headers,然后设置 Authorization 字段并传入 JWT 即可,这样就能成功获取数据了。
运行结果如下:
Response JSON {'token': 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoxLCJ1c2VybmFtZSI6ImFkbWluIiwiZXhwIjoxNTg3ODc4NzkxLCJlbWFpbCI6ImFkbWluQGFkbWluLmNvbSIsIm9yaWdfaWF0IjoxNTg3ODM1NTkxfQ.iUnu3Yhdi_a-Bupb2BLgCTUd5yHL6jgPhkBPorCPvm4'}JWT eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoxLCJ1c2VybmFtZSI6ImFkbWluIiwiZXhwIjoxNTg3ODc4NzkxLCJlbWFpbCI6ImFkbWluQGFkbWluLmNvbSIsIm9yaWdfaWF0IjoxNTg3ODM1NTkxfQ.iUnu3Yhdi_a-Bupb2BLgCTUd5yHL6jgPhkBPorCPvm4Response Status 200Response URL https://login3.scrape.center/api/book/?limit=18&offset=0Response Data {'count': 9200, 'results': [{'id': '27135877', 'name': '校园市场:布局未来消费群,决战年轻人市场', 'authors': ['单兴华', '李烨'], 'cover': 'https://img9.doubanio.com/view/subject/l/public/s29539805.jpg', 'score': '5.5'},...{'id': '30289316', 'name': '就算這樣,還是喜歡你,笠原先生', 'authors': ['おまる'], 'cover': 'https://img3.doubanio.com/view/subject/l/public/s29875002.jpg', 'score': '7.5'}]}
可以看到,这里成功输出了 JWT 的内容,同时最终也获取到了对应的数据,模拟登录成功!
类似的思路,如果我们遇到 JWT 认证的网站,也可以通过类似的方式来实现模拟登录。当然可能某些页面比较复杂,需要具体情况具体分析。
以上我们就通过两个示例来演示了模拟登录爬取的过程,以后遇到这种情形的时候就可以用类似的思路解决了。
代码:https://github.com/Python3WebSpider/ScrapeLogin2、https://github.com/Python3WebSpider/ScrapeLogin3。
我们在爬取网站的时候,经常会遇到各种各样类似加密的情形,比如:
这些情况,基本上都是网站为了保护其本身的一些数据不被轻易抓取而采取的一些措施,我们可以把它归为两大类:
本课时我们就来了解下这两类技术的实现原理。
当今大数据时代,数据已经变得越来越重要,网页和 App 现在是主流的数据载体,如果其数据的接口没有设置任何保护措施,在爬虫工程师解决了一些基本的反爬如封 IP、验证码的问题之后,那么数据还是可以被轻松抓取到。
那么,有没有可能在接口或 JavaScript 层面也加上一层防护呢?答案是可以的。
网站运营商首先想到防护措施可能是对某些数据接口进行加密,比如说对某些 URL 的一些参数加上校验码或者把一些 ID 信息进行编码,使其变得难以阅读或构造;或者对某些接口请求加上一些 token、sign 等签名,这样这些请求发送到服务器时,服务器会通过客户端发来的一些请求信息以及双方约定好的秘钥等来对当前的请求进行校验,如果校验通过,才返回对应数据结果。
比如说客户端和服务端约定一种接口校验逻辑,客户端在每次请求服务端接口的时候都会附带一个 sign 参数,这个 sign 参数可能是由当前时间信息、请求的 URL、请求的数据、设备的 ID、双方约定好的秘钥经过一些加密算法构造而成的,客户端会实现这个加密算法构造 sign,然后每次请求服务器的时候附带上这个参数。服务端会根据约定好的算法和请求的数据对 sign 进行校验,如果校验通过,才返回对应的数据,否则拒绝响应。
接口加密技术看起来的确是一个不错的解决方案,但单纯依靠它并不能很好地解决问题。为什么呢?
对于网页来说,其逻辑是依赖于 JavaScript 来实现的,JavaScript 有如下特点:
由于这两个原因,导致 JavaScript 代码是不安全的,任何人都可以读、分析、复制、盗用,甚至篡改。
所以说,对于上述情形,客户端 JavaScript 对于某些加密的实现是很容易被找到或模拟的,了解了加密逻辑后,模拟参数的构造和请求也就是轻而易举了,所以如果 JavaScript 没有做任何层面的保护的话,接口加密技术基本上对数据起不到什么防护作用。
如果你不想让自己的数据被轻易获取,不想他人了解 JavaScript 逻辑的实现,或者想降低被不怀好意的人甚至是黑客攻击。那么你就需要用到 JavaScript 压缩、混淆和加密技术了。
这里压缩、混淆、加密技术简述如下。
下面我们对上面的技术分别予以介绍。
数据一般都是通过服务器提供的接口来获取的,网站或 App 可以请求某个数据接口获取到对应的数据,然后再把获取的数据展示出来。
但有些数据是比较宝贵或私密的,这些数据肯定是需要一定层面上的保护。所以不同接口的实现也就对应着不同的安全防护级别,我们这里来总结下。
有些接口是没有设置任何防护的,谁都可以调用和访问,而且没有任何时空限制和频率限制。任何人只要知道了接口的调用方式就能无限制地调用。
这种接口的安全性是非常非常低的,如果接口的调用方式一旦泄露或被抓包获取到,任何人都可以无限制地对数据进行操作或访问。此时如果接口里面包含一些重要的数据或隐私数据,就能轻易被篡改或窃取了。
为了提升接口的安全性,客户端会和服务端约定一种接口校验方式,一般来说会使用到各种加密和编码算法,如 Base64、Hex 编码,MD5、AES、DES、RSA 等加密。
比如客户端和服务器双方约定一个 sign 用作接口的签名校验,其生成逻辑是客户端将 URL Path 进行 MD5 加密然后拼接上 URL 的某个参数再进行 Base64 编码,最后得到一个字符串 sign,这个 sign 会通过 Request URL 的某个参数或 Request Headers 发送给服务器。服务器接收到请求后,对 URL Path 同样进行 MD5 加密,然后拼接上 URL 的某个参数,也进行 Base64 编码得到了一个 sign,然后比对生成的 sign 和客户端发来的 sign 是否是一致的,如果是一致的,那就返回正确的结果,否则拒绝响应。这就是一个比较简单的接口参数加密的实现。如果有人想要调用这个接口的话,必须要定义好 sign 的生成逻辑,否则是无法正常调用接口的。
以上就是一个基本的接口参数加密逻辑的实现。
当然上面的这个实现思路比较简单,这里还可以增加一些时间戳信息增加时效性判断,或增加一些非对称加密进一步提高加密的复杂程度。但不管怎样,只要客户端和服务器约定好了加密和校验逻辑,任何形式加密算法都是可以的。
这里要实现接口参数加密就需要用到一些加密算法,客户端和服务器肯定也都有对应的 SDK 实现这些加密算法,如 JavaScript 的 crypto-js,Python 的 hashlib、Crypto 等等。
但还是如上文所说,如果是网页的话,客户端实现加密逻辑如果是用 JavaScript 来实现,其源代码对用户是完全可见的,如果没有对 JavaScript 做任何保护的话,是很容易弄清楚客户端加密的流程的。
因此,我们需要对 JavaScript 利用压缩、混淆、加密的方式来对客户端的逻辑进行一定程度上的保护。
下面我们再来介绍下 JavaScript 的压缩、混淆和加密技术。
这个非常简单,JavaScript 压缩即去除 JavaScript 代码中的不必要的空格、换行等内容或者把一些可能公用的代码进行处理实现共享,最后输出的结果都被压缩为几行内容,代码可读性变得很差,同时也能提高网站加载速度。
如果仅仅是去除空格换行这样的压缩方式,其实几乎是没有任何防护作用的,因为这种压缩方式仅仅是降低了代码的直接可读性。如果我们有一些格式化工具可以轻松将 JavaScript 代码变得易读,比如利用 IDE、在线工具或 Chrome 浏览器都能还原格式化的代码。
目前主流的前端开发技术大多都会利用 Webpack 进行打包,Webpack 会对源代码进行编译和压缩,输出几个打包好的 JavaScript 文件,其中我们可以看到输出的 JavaScript 文件名带有一些不规则字符串,同时文件内容可能只有几行内容,变量名都是一些简单字母表示。这其中就包含 JavaScript 压缩技术,比如一些公共的库输出成 bundle 文件,一些调用逻辑压缩和转义成几行代码,这些都属于 JavaScript 压缩。另外其中也包含了一些很基础的 JavaScript 混淆技术,比如把变量名、方法名替换成一些简单字符,降低代码可读性。
但整体来说,JavaScript 压缩技术只能在很小的程度上起到防护作用,要想真正提高防护效果还得依靠 JavaScript 混淆和加密技术。
JavaScript 混淆完全是在 JavaScript 上面进行的处理,它的目的就是使得 JavaScript 变得难以阅读和分析,大大降低代码可读性,是一种很实用的 JavaScript 保护方案。
JavaScript 混淆技术主要有以下几种:
将带有含意的变量名、方法名、常量名随机变为无意义的类乱码字符串,降低代码可读性,如转成单个字符或十六进制字符串。
将字符串阵列化集中放置、并可进行 MD5 或 Base64 加密存储,使代码中不出现明文字符串,这样可以避免使用全局搜索字符串的方式定位到入口点。
针对 JavaScript 对象的属性进行加密转化,隐藏代码之间的调用关系。
打乱函数原有代码执行流程及函数调用关系,使代码逻变得混乱无序。
随机在代码中插入无用的僵尸代码、僵尸函数,进一步使代码混乱。
基于调试器特性,对当前运行环境进行检验,加入一些强制调试 debugger 语句,使其在调试模式下难以顺利执行 JavaScript 代码。
使 JavaScript 代码每次被调用时,将代码自身即立刻自动发生变异,变化为与之前完全不同的代码,即功能完全不变,只是代码形式变异,以此杜绝代码被动态分析调试。
使 JavaScript 代码只能在指定域名下执行。
如果对 JavaScript 代码进行格式化,则无法执行,导致浏览器假死。
将 JavaScript 完全编码为人不可读的代码,如表情符号、特殊表示内容等等。
总之,以上方案都是 JavaScript 混淆的实现方式,可以在不同程度上保护 JavaScript 代码。
在前端开发中,现在 JavaScript 混淆主流的实现是 javascript-obfuscator 这个库,利用它我们可以非常方便地实现页面的混淆,它与 Webpack 结合起来,最终可以输出压缩和混淆后的 JavaScript 代码,使得可读性大大降低,难以逆向。
下面我们会介绍下 javascript-obfuscator 对代码混淆的实现,了解了实现,那么自然我们就对混淆的机理有了更加深刻的认识。
javascript-obfuscator 的官网地址为:https://obfuscator.io/,其官方介绍内容如下:
A free and efficient obfuscator for JavaScript (including ES2017). Make your code harder to copy and prevent people from stealing your work.
它是支持 ES8 的免费、高效的 JavaScript 混淆库,它可以使得你的 JavaScript 代码经过混淆后难以被复制、盗用,混淆后的代码具有和原来的代码一模一样的功能。
怎么使用呢?首先,我们需要安装好 Node.js,可以使用 npm 命令。
然后新建一个文件夹,比如 js-obfuscate,随后进入该文件夹,初始化工作空间:
npm init
这里会提示我们输入一些信息,创建一个 package.json 文件,这就完成了项目初始化了。
接下来我们来安装 javascript-obfuscator 这个库:
npm install --save-dev javascript-obfuscator
接下来我们就可以编写代码来实现混淆了,如新建一个 main.js 文件,内容如下:
const code = `let x = '1' + 1console.log('x', x)`const options = { compact: false, controlFlowFlattening: true}const obfuscator = require('javascript-obfuscator')function obfuscate(code, options) { return obfuscator.obfuscate(code, options).getObfuscatedCode()}console.log(obfuscate(code, options))
在这里我们定义了两个变量,一个是 code,即需要被混淆的代码,另一个是混淆选项,是一个 Object。接下来我们引入了 javascript-obfuscator 库,然后定义了一个方法,传入 code 和 options,来获取混淆后的代码,最后控制台输出混淆后的代码。
代码逻辑比较简单,我们来执行一下代码:
node main.js
输出结果如下:
var _0x53bf = ['log'];(function (_0x1d84fe, _0x3aeda0) { var _0x10a5a = function (_0x2f0a52) { while (--_0x2f0a52) { _0x1d84fe['push'](_0x1d84fe['shift']()); } }; _0x10a5a(++_0x3aeda0);}(_0x53bf, 0x172));var _0x480a = function (_0x4341e5, _0x5923b4) { _0x4341e5 = _0x4341e5 - 0x0; var _0xb3622e = _0x53bf[_0x4341e5]; return _0xb3622e;};let x = '1' + 0x1;console[_0x480a('0x0')]('x', x);
看到了吧,这么简单的两行代码,被我们混淆成了这个样子,其实这里我们就是设定了一个“控制流扁平化”的选项。
整体看来,代码的可读性大大降低,也大大加大了 JavaScript 调试的难度。
好,接下来我们来跟着 javascript-obfuscator 走一遍,就能具体知道 JavaScript 混淆到底有多少方法了。
这里 javascript-obfuscator 也提供了代码压缩的功能,使用其参数 compact 即可完成 JavaScript 代码的压缩,输出为一行内容。默认是 true,如果定义为 false,则混淆后的代码会分行显示。
示例如下:
const code = `let x = '1' + 1console.log('x', x)`const options = { compact: false}
这里我们先把代码压缩 compact 选项设置为 false,运行结果如下:
let x = '1' + 0x1;console['log']('x', x);
如果不设置 compact 或把 compact 设置为 true,结果如下:
var _0x151c=['log'];(function(_0x1ce384,_0x20a7c7){var _0x25fc92=function(_0x188aec){while(--_0x188aec){_0x1ce384['push'](_0x1ce384['shift']());}};_0x25fc92(++_0x20a7c7);}(_0x151c,0x1b7));var _0x553e=function(_0x259219,_0x241445){_0x259219=_0x259219-0x0;var _0x56d72d=_0x151c[_0x259219];return _0x56d72d;};let x='1'+0x1;console[_0x553e('0x0')]('x',x);
可以看到单行显示的时候,对变量名进行了进一步的混淆和控制流扁平化操作。
变量名混淆可以通过配置 identifierNamesGenerator 参数实现,我们通过这个参数可以控制变量名混淆的方式,如 hexadecimal 则会替换为 16 进制形式的字符串,在这里我们可以设定如下值:
该参数默认为 hexadecimal。
我们将该参数修改为 mangled 来试一下:
const code = `let hello = '1' + 1console.log('hello', hello)`const options = { compact: true, identifierNamesGenerator: 'mangled'}
运行结果如下:
var a=['hello'];(function(c,d){var e=function(f){while(--f){c['push'](c['shift']());}};e(++d);}(a,0x9b));var b=function(c,d){c=c-0x0;var e=a[c];return e;};let hello='1'+0x1;console['log'](b('0x0'),hello);
可以看到这里的变量命名都变成了 a、b 等形式。
如果我们将 identifierNamesGenerator 修改为 hexadecimal 或者不设置,运行结果如下:
var _0x4e98=['log','hello'];(function(_0x4464de,_0x39de6c){var _0xdffdda=function(_0x6a95d5){while(--_0x6a95d5){_0x4464de['push'](_0x4464de['shift']());}};_0xdffdda(++_0x39de6c);}(_0x4e98,0xc8));var _0x53cb=function(_0x393bda,_0x8504e7){_0x393bda=_0x393bda-0x0;var _0x46ab80=_0x4e98[_0x393bda];return _0x46ab80;};let hello='1'+0x1;console[_0x53cb('0x0')](_0x53cb('0x1'),hello);
可以看到选用了 mangled,其代码体积会更小,但 hexadecimal 其可读性会更低。
另外我们还可以通过设置 identifiersPrefix 参数来控制混淆后的变量前缀,示例如下:
const code = `let hello = '1' + 1console.log('hello', hello)`const options = { identifiersPrefix: 'germey'}
运行结果:
var germey_0x3dea=['log','hello'];(function(_0x348ff3,_0x5330e8){var _0x1568b1=function(_0x4740d8){while(--_0x4740d8){_0x348ff3['push'](_0x348ff3['shift']());}};_0x1568b1(++_0x5330e8);}(germey_0x3dea,0x94));var germey_0x30e4=function(_0x2e8f7c,_0x1066a8){_0x2e8f7c=_0x2e8f7c-0x0;var _0x5166ba=germey_0x3dea[_0x2e8f7c];return _0x5166ba;};let hello='1'+0x1;console[germey_0x30e4('0x0')](germey_0x30e4('0x1'),hello);
可以看到混淆后的变量前缀加上了我们自定义的字符串 germey。
另外 renameGlobals 这个参数还可以指定是否混淆全局变量和函数名称,默认为 false。示例如下:
const code = `var $ = function(id) { return document.getElementById(id);};`const options = { renameGlobals: true}
运行结果如下:
var _0x4864b0=function(_0x5763be){return document['getElementById'](_0x5763be);};
可以看到这里我们声明了一个全局变量 $,在 renameGlobals 设置为 true 之后,$ 这个变量也被替换了。如果后文用到了这个 $ 对象,可能就会有找不到定义的错误,因此这个参数可能导致代码执行不通。
如果我们不设置 renameGlobals 或者设置为 false,结果如下:
var _0x239a=['getElementById'];(function(_0x3f45a3,_0x583dfa){var _0x2cade2=function(_0x28479a){while(--_0x28479a){_0x3f45a3['push'](_0x3f45a3['shift']());}};_0x2cade2(++_0x583dfa);}(_0x239a,0xe1));var _0x3758=function(_0x18659d,_0x50c21d){_0x18659d=_0x18659d-0x0;var _0x531b8d=_0x239a[_0x18659d];return _0x531b8d;};var $=function(_0x3d8723){return document[_0x3758('0x0')](_0x3d8723);};
可以看到,最后还是有 $ 的声明,其全局名称没有被改变。
字符串混淆,即将一个字符串声明放到一个数组里面,使之无法被直接搜索到。我们可以通过控制 stringArray 参数来控制,默认为 true。
我们还可以通过 rotateStringArray 参数来控制数组化后结果的元素顺序,默认为 true。
还可以通过 stringArrayEncoding 参数来控制数组的编码形式,默认不开启编码,如果设置为 true 或 base64,则会使用 Base64 编码,如果设置为 rc4,则使用 RC4 编码。
还可以通过 stringArrayThreshold 来控制启用编码的概率,范围 0 到 1,默认 0.8。
示例如下:
const code = `var a = 'hello world'`const options = { stringArray: true, rotateStringArray: true, stringArrayEncoding: true, // 'base64' or 'rc4' or false stringArrayThreshold: 1,}
运行结果如下:
var _0x4215=['aGVsbG8gd29ybGQ='];(function(_0x42bf17,_0x4c348f){var _0x328832=function(_0x355be1){while(--_0x355be1){_0x42bf17['push'](_0x42bf17['shift']());}};_0x328832(++_0x4c348f);}(_0x4215,0x1da));var _0x5191=function(_0x3cf2ba,_0x1917d8){_0x3cf2ba=_0x3cf2ba-0x0;var _0x1f93f0=_0x4215[_0x3cf2ba];if(_0x5191['LqbVDH']===undefined){(function(){var _0x5096b2;try{var _0x282db1=Function('return\x20(function()\x20'+'{}.constructor(\x22return\x20this\x22)(\x20)'+');');_0x5096b2=_0x282db1();}catch(_0x2acb9c){_0x5096b2=window;}var _0x388c14='ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=';_0x5096b2['atob']||(_0x5096b2['atob']=function(_0x4cc27c){var _0x2af4ae=String(_0x4cc27c)['replace'](/=+$/,'');for(var _0x21400b=0x0,_0x3f4e2e,_0x5b193b,_0x233381=0x0,_0x3dccf7='';_0x5b193b=_0x2af4ae['charAt'](_0x233381++);~_0x5b193b&&(_0x3f4e2e=_0x21400b%0x4?_0x3f4e2e*0x40+_0x5b193b:_0x5b193b,_0x21400b++%0x4)?_0x3dccf7+=String['fromCharCode'](0xff&_0x3f4e2e>>(-0x2*_0x21400b&0x6)):0x0){_0x5b193b=_0x388c14['indexOf'](_0x5b193b);}return _0x3dccf7;});}());_0x5191['DuIurT']=function(_0x51888e){var _0x29801f=atob(_0x51888e);var _0x561e62=[];for(var _0x5dd788=0x0,_0x1a8b73=_0x29801f['length'];_0x5dd788<_0x1a8b73;_0x5dd788++){_0x561e62+='%'+('00'+_0x29801f['charCodeAt'](_0x5dd788)['toString'](0x10))['slice'](-0x2);}return decodeURIComponent(_0x561e62);};_0x5191['mgoBRd']={};_0x5191['LqbVDH']=!![];}var _0x1741f0=_0x5191['mgoBRd'][_0x3cf2ba];if(_0x1741f0===undefined){_0x1f93f0=_0x5191['DuIurT'](_0x1f93f0);_0x5191['mgoBRd'][_0x3cf2ba]=_0x1f93f0;}else{_0x1f93f0=_0x1741f0;}return _0x1f93f0;};var a=_0x5191('0x0');
可以看到这里就把字符串进行了 Base64 编码,我们再也无法通过查找的方式找到字符串的位置了。
如果将 stringArray 设置为 false 的话,输出就是这样:
var a='hello\x20world';
字符串就仍然是明文显示的,没有被编码。
另外我们还可以使用 unicodeEscapeSequence 这个参数对字符串进行 Unicode 转码,使之更加难以辨认,示例如下:
const code = `var a = 'hello world'`const options = { compact: false, unicodeEscapeSequence: true}
运行结果如下:
var _0x5c0d = ['\x68\x65\x6c\x6c\x6f\x20\x77\x6f\x72\x6c\x64'];(function (_0x54cc9c, _0x57a3b2) { var _0xf833cf = function (_0x3cd8c6) { while (--_0x3cd8c6) { _0x54cc9c['push'](_0x54cc9c['shift']()); }};_0xf833cf(++_0x57a3b2);}(_0x5c0d, 0x17d));var _0x28e8 = function (_0x3fd645, _0x2cf5e7) { _0x3fd645 = _0x3fd645 - 0x0; var _0x298a20 = _0x5c0d[_0x3fd645]; return _0x298a20;};var a = _0x28e8('0x0');
可以看到,这里字符串被数字化和 Unicode 化,非常难以辨认。
在很多 JavaScript 逆向的过程中,一些关键的字符串可能会作为切入点来查找加密入口。用了这种混淆之后,如果有人想通过全局搜索的方式搜索 hello 这样的字符串找加密入口,也没法搜到了。
我们可以通过设置 selfDefending 参数来开启代码自我保护功能。开启之后,混淆后的 JavaScript 会强制以一行形式显示,如果我们将混淆后的代码进行格式化(美化)或者重命名,该段代码将无法执行。
例如:
const code = `console.log('hello world')`const options = { selfDefending: true}
运行结果如下:
var _0x26da=['log','hello\x20world'];(function(_0x190327,_0x57c2c0){var _0x577762=function(_0xc9dabb){while(--_0xc9dabb){_0x190327['push'](_0x190327['shift']());}};var _0x35976e=function(){var _0x16b3fe={'data':{'key':'cookie','value':'timeout'},'setCookie':function(_0x2d52d5,_0x16feda,_0x57cadf,_0x56056f){_0x56056f=_0x56056f||{};var _0x5b6dc3=_0x16feda+'='+_0x57cadf;var _0x333ced=0x0;for(var _0x333ced=0x0,_0x19ae36=_0x2d52d5['length'];_0x333ced<_0x19ae36;_0x333ced++){var _0x409587=_0x2d52d5[_0x333ced];_0x5b6dc3+=';\x20'+_0x409587;var _0x4aa006=_0x2d52d5[_0x409587];_0x2d52d5['push'](_0x4aa006);_0x19ae36=_0x2d52d5['length'];if(_0x4aa006!==!![]){_0x5b6dc3+='='+_0x4aa006;}}_0x56056f['cookie']=_0x5b6dc3;},'removeCookie':function(){return'dev';},'getCookie':function(_0x30c497,_0x51923d){_0x30c497=_0x30c497||function(_0x4b7e18){return _0x4b7e18;};var _0x557e06=_0x30c497(new RegExp('(?:^|;\x20)'+_0x51923d['replace'](/([.$?*|{}()[]\/+^])/g,'$1')+'=([^;]*)'));var _0x817646=function(_0xf3fae7,_0x5d8208){_0xf3fae7(++_0x5d8208);};_0x817646(_0x577762,_0x57c2c0);return _0x557e06?decodeURIComponent(_0x557e06[0x1]):undefined;}};var _0x4673cd=function(){var _0x4c6c5c=new RegExp('\x5cw+\x20*\x5c(\x5c)\x20*{\x5cw+\x20*[\x27|\x22].+[\x27|\x22];?\x20*}');return _0x4c6c5c['test'](_0x16b3fe['removeCookie']['toString']());};_0x16b3fe['updateCookie']=_0x4673cd;var _0x5baa80='';var _0x1faf19=_0x16b3fe['updateCookie']();if(!_0x1faf19){_0x16b3fe['setCookie'](['*'],'counter',0x1);}else if(_0x1faf19){_0x5baa80=_0x16b3fe['getCookie'](null,'counter');}else{_0x16b3fe['removeCookie']();}};_0x35976e();}(_0x26da,0x140));var _0x4391=function(_0x1b42d8,_0x57edc8){_0x1b42d8=_0x1b42d8-0x0;var _0x2fbeca=_0x26da[_0x1b42d8];return _0x2fbeca;};var _0x197926=function(){var _0x10598f=!![];return function(_0xffa3b3,_0x7a40f9){var _0x48e571=_0x10598f?function(){if(_0x7a40f9){var _0x2194b5=_0x7a40f9['apply'](_0xffa3b3,arguments);_0x7a40f9=null;return _0x2194b5;}}:function(){};_0x10598f=![];return _0x48e571;};}();var _0x2c6fd7=_0x197926(this,function(){var _0x4828bb=function(){return'\x64\x65\x76';},_0x35c3bc=function(){return'\x77\x69\x6e\x64\x6f\x77';};var _0x456070=function(){var _0x4576a4=new RegExp('\x5c\x77\x2b\x20\x2a\x5c\x28\x5c\x29\x20\x2a\x7b\x5c\x77\x2b\x20\x2a\x5b\x27\x7c\x22\x5d\x2e\x2b\x5b\x27\x7c\x22\x5d\x3b\x3f\x20\x2a\x7d');return!_0x4576a4['\x74\x65\x73\x74'](_0x4828bb['\x74\x6f\x53\x74\x72\x69\x6e\x67']());};var _0x3fde69=function(){var _0xabb6f4=new RegExp('\x28\x5c\x5c\x5b\x78\x7c\x75\x5d\x28\x5c\x77\x29\x7b\x32\x2c\x34\x7d\x29\x2b');return _0xabb6f4['\x74\x65\x73\x74'](_0x35c3bc['\x74\x6f\x53\x74\x72\x69\x6e\x67']());};var _0x2d9a50=function(_0x58fdb4){var _0x2a6361=~-0x1>>0x1+0xff%0x0;if(_0x58fdb4['\x69\x6e\x64\x65\x78\x4f\x66']('\x69'===_0x2a6361)){_0xc388c5(_0x58fdb4);}};var _0xc388c5=function(_0x2073d6){var _0x6bb49f=~-0x4>>0x1+0xff%0x0;if(_0x2073d6['\x69\x6e\x64\x65\x78\x4f\x66']((!![]+'')[0x3])!==_0x6bb49f){_0x2d9a50(_0x2073d6);}};if(!_0x456070()){if(!_0x3fde69()){_0x2d9a50('\x69\x6e\x64\u0435\x78\x4f\x66');}else{_0x2d9a50('\x69\x6e\x64\x65\x78\x4f\x66');}}else{_0x2d9a50('\x69\x6e\x64\u0435\x78\x4f\x66');}});_0x2c6fd7();console[_0x4391('0x0')](_0x4391('0x1'));
如果我们将上述代码放到控制台,它的执行结果和之前是一模一样的,没有任何问题。
如果我们将其进行格式化,会变成如下内容:
var _0x26da = ['log', 'hello\x20world'];(function (_0x190327, _0x57c2c0) { var _0x577762 = function (_0xc9dabb) { while (--_0xc9dabb) { _0x190327['push'](_0x190327['shift']()); } }; var _0x35976e = function () { var _0x16b3fe = { 'data': { 'key': 'cookie', 'value': 'timeout' }, 'setCookie': function (_0x2d52d5, _0x16feda, _0x57cadf, _0x56056f) { _0x56056f = _0x56056f || {}; var _0x5b6dc3 = _0x16feda + '=' + _0x57cadf; var _0x333ced = 0x0; for (var _0x333ced = 0x0, _0x19ae36 = _0x2d52d5['length']; _0x333ced < _0x19ae36; _0x333ced++) { var _0x409587 = _0x2d52d5[_0x333ced]; _0x5b6dc3 += ';\x20' + _0x409587; var _0x4aa006 = _0x2d52d5[_0x409587]; _0x2d52d5['push'](_0x4aa006); _0x19ae36 = _0x2d52d5['length']; if (_0x4aa006 !== !![]) { _0x5b6dc3 += '=' + _0x4aa006; } } _0x56056f['cookie'] = _0x5b6dc3; }, 'removeCookie': function () { return 'dev'; }, 'getCookie': function (_0x30c497, _0x51923d) { _0x30c497 = _0x30c497 || function (_0x4b7e18) { return _0x4b7e18; }; var _0x557e06 = _0x30c497(new RegExp('(?:^|;\x20)' + _0x51923d['replace'](/([.$?*|{}()[]\/+^])/g, '$1') + '=([^;]*)')); var _0x817646 = function (_0xf3fae7, _0x5d8208) { _0xf3fae7(++_0x5d8208); }; _0x817646(_0x577762, _0x57c2c0); return _0x557e06 ? decodeURIComponent(_0x557e06[0x1]) : undefined; } }; var _0x4673cd = function () { var _0x4c6c5c = new RegExp('\x5cw+\x20*\x5c(\x5c)\x20*{\x5cw+\x20*[\x27|\x22].+[\x27|\x22];?\x20*}'); return _0x4c6c5c['test'](_0x16b3fe['removeCookie']['toString']()); }; _0x16b3fe['updateCookie'] = _0x4673cd; var _0x5baa80 = ''; var _0x1faf19 = _0x16b3fe['updateCookie'](); if (!_0x1faf19) { _0x16b3fe['setCookie'](['*'], 'counter', 0x1); } else if (_0x1faf19) { _0x5baa80 = _0x16b3fe['getCookie'](null, 'counter'); } else { _0x16b3fe['removeCookie'](); } }; _0x35976e();}(_0x26da, 0x140));var _0x4391 = function (_0x1b42d8, _0x57edc8) { _0x1b42d8 = _0x1b42d8 - 0x0; var _0x2fbeca = _0x26da[_0x1b42d8]; return _0x2fbeca;};var _0x197926 = function () { var _0x10598f = !![]; return function (_0xffa3b3, _0x7a40f9) { var _0x48e571 = _0x10598f ? function () { if (_0x7a40f9) { var _0x2194b5 = _0x7a40f9['apply'](_0xffa3b3, arguments); _0x7a40f9 = null; return _0x2194b5; } } : function () {}; _0x10598f = ![]; return _0x48e571; };}();var _0x2c6fd7 = _0x197926(this, function () { var _0x4828bb = function () { return '\x64\x65\x76'; }, _0x35c3bc = function () { return '\x77\x69\x6e\x64\x6f\x77'; }; var _0x456070 = function () { var _0x4576a4 = new RegExp('\x5c\x77\x2b\x20\x2a\x5c\x28\x5c\x29\x20\x2a\x7b\x5c\x77\x2b\x20\x2a\x5b\x27\x7c\x22\x5d\x2e\x2b\x5b\x27\x7c\x22\x5d\x3b\x3f\x20\x2a\x7d'); return !_0x4576a4['\x74\x65\x73\x74'](_0x4828bb['\x74\x6f\x53\x74\x72\x69\x6e\x67']()); }; var _0x3fde69 = function () { var _0xabb6f4 = new RegExp('\x28\x5c\x5c\x5b\x78\x7c\x75\x5d\x28\x5c\x77\x29\x7b\x32\x2c\x34\x7d\x29\x2b'); return _0xabb6f4['\x74\x65\x73\x74'](_0x35c3bc['\x74\x6f\x53\x74\x72\x69\x6e\x67']()); }; var _0x2d9a50 = function (_0x58fdb4) { var _0x2a6361 = ~-0x1 >> 0x1 + 0xff % 0x0; if (_0x58fdb4['\x69\x6e\x64\x65\x78\x4f\x66']('\x69' === _0x2a6361)) { _0xc388c5(_0x58fdb4); } }; var _0xc388c5 = function (_0x2073d6) { var _0x6bb49f = ~-0x4 >> 0x1 + 0xff % 0x0; if (_0x2073d6['\x69\x6e\x64\x65\x78\x4f\x66']((!![] + '')[0x3]) !== _0x6bb49f) { _0x2d9a50(_0x2073d6); } }; if (!_0x456070()) { if (!_0x3fde69()) { _0x2d9a50('\x69\x6e\x64\u0435\x78\x4f\x66'); } else { _0x2d9a50('\x69\x6e\x64\x65\x78\x4f\x66'); } } else { _0x2d9a50('\x69\x6e\x64\u0435\x78\x4f\x66'); }});_0x2c6fd7();console[_0x4391('0x0')](_0x4391('0x1'));
如果把这段代码放到浏览器里面,浏览器会直接卡死无法运行。这样如果有人对代码进行了格式化,就无法正常对代码进行运行和调试,从而起到了保护作用。
控制流平坦化其实就是将代码的执行逻辑混淆,使其变得复杂难读。其基本思想是将一些逻辑处理块都统一加上一个前驱逻辑块,每个逻辑块都由前驱逻辑块进行条件判断和分发,构成一个个闭环逻辑,导致整个执行逻辑十分复杂难读。
我们通过 controlFlowFlattening 变量可以控制是否开启控制流平坦化,示例如下:
const code = `(function(){ function foo () { return function () { var sum = 1 + 2; console.log(1); console.log(2); console.log(3); console.log(4); console.log(5); console.log(6); } } foo()();})();`const options = { compact: false, controlFlowFlattening: true}
输出结果如下:
var _0xbaf1 = [ 'dZwUe', 'log', 'fXqMu', '0|1|3|4|6|5|2', 'chYMl', 'IZEsA', 'split'];(function (_0x22d342, _0x4f6332) { var _0x43ff59 = function (_0x5ad417) { while (--_0x5ad417) { _0x22d342['push'](_0x22d342['shift']()); } }; _0x43ff59(++_0x4f6332);}(_0xbaf1, 0x192));var _0x1a69 = function (_0x8d64b1, _0x5e07b3) { _0x8d64b1 = _0x8d64b1 - 0x0; var _0x300bab = _0xbaf1[_0x8d64b1]; return _0x300bab;};(function () { var _0x19d8ce = { 'chYMl': _0x1a69('0x0'), 'IZEsA': function (_0x22e521, _0x298a22) { return _0x22e521 + _0x298a22; }, 'fXqMu': function (_0x13124b) { return _0x13124b(); } }; function _0x4e2ee0() { var _0x118a6a = { 'LZAQV': _0x19d8ce[_0x1a69('0x1')], 'dZwUe': function (_0x362ef3, _0x352709) { return _0x19d8ce[_0x1a69('0x2')](_0x362ef3, _0x352709); } }; return function () { var _0x4c336d = _0x118a6a['LZAQV'][_0x1a69('0x3')]('|'), _0x2b6466 = 0x0; while (!![]) { switch (_0x4c336d[_0x2b6466++]) { case '0': var _0xbfa3fd = _0x118a6a[_0x1a69('0x4')](0x1, 0x2); continue; case '1': console['log'](0x1); continue; case '2': console[_0x1a69('0x5')](0x6); continue; case '3': console[_0x1a69('0x5')](0x2); continue; case '4': console[_0x1a69('0x5')](0x3); continue; case '5': console[_0x1a69('0x5')](0x5); continue; case '6': console[_0x1a69('0x5')](0x4); continue; } break; } }; } _0x19d8ce[_0x1a69('0x6')](_0x4e2ee0)();}());
可以看到,一些连续的执行逻辑被打破,代码被修改为一个 switch 语句,我们很难再一眼看出多条 console.log 语句的执行顺序了。
如果我们将 controlFlowFlattening 设置为 false 或者不设置,运行结果如下:
var _0x552c = ['log'];(function (_0x4c4fa0, _0x59faa0) { var _0xa01786 = function (_0x409a37) { while (--_0x409a37) { _0x4c4fa0['push'](_0x4c4fa0['shift']()); } }; _0xa01786(++_0x59faa0);}(_0x552c, 0x9b));var _0x4e63 = function (_0x75ea1a, _0x50e176) { _0x75ea1a = _0x75ea1a - 0x0; var _0x59dc94 = _0x552c[_0x75ea1a]; return _0x59dc94;};(function () { function _0x507f38() { return function () { var _0x17fb7e = 0x1 + 0x2; console[_0x4e63('0x0')](0x1); console['log'](0x2); console['log'](0x3); console[_0x4e63('0x0')](0x4); console[_0x4e63('0x0')](0x5); console[_0x4e63('0x0')](0x6); }; } _0x507f38()();}());
可以看到,这里仍然保留了原始的 console.log 执行逻辑。
因此,使用控制流扁平化可以使得执行逻辑更加复杂难读,目前非常多的前端混淆都会加上这个选项。
但启用控制流扁平化之后,代码的执行时间会变长,最长达 1.5 倍之多。
另外我们还能使用 controlFlowFlatteningThreshold 这个参数来控制比例,取值范围是 0 到 1,默认 0.75,如果设置为 0,那相当于 controlFlowFlattening 设置为 false,即不开启控制流扁平化 。
僵尸代码即不会被执行的代码或对上下文没有任何影响的代码,注入之后可以对现有的 JavaScript 代码的阅读形成干扰。我们可以使用 deadCodeInjection 参数开启这个选项,默认为 false。
示例如下:
const code = `(function(){ if (true) { var foo = function () { console.log('abc'); console.log('cde'); console.log('efg'); console.log('hij'); }; var bar = function () { console.log('klm'); console.log('nop'); console.log('qrs'); }; var baz = function () { console.log('tuv'); console.log('wxy'); console.log('z'); }; foo(); bar(); baz(); }})();`const options = { compact: false, deadCodeInjection: true}
运行结果如下:
var _0x5024 = [ 'zaU', 'log', 'tuv', 'wxy', 'abc', 'cde', 'efg', 'hij', 'QhG', 'TeI', 'klm', 'nop', 'qrs', 'bZd', 'HMx'];var _0x4502 = function (_0x1254b1, _0x583689) { _0x1254b1 = _0x1254b1 - 0x0; var _0x529b49 = _0x5024[_0x1254b1]; return _0x529b49;};(function () { if (!![]) { var _0x16c18d = function () { if (_0x4502('0x0') !== _0x4502('0x0')) { console[_0x4502('0x1')](_0x4502('0x2')); console[_0x4502('0x1')](_0x4502('0x3')); console[_0x4502('0x1')]('z'); } else { console[_0x4502('0x1')](_0x4502('0x4')); console[_0x4502('0x1')](_0x4502('0x5')); console[_0x4502('0x1')](_0x4502('0x6')); console[_0x4502('0x1')](_0x4502('0x7')); } }; var _0x1f7292 = function () { if (_0x4502('0x8') === _0x4502('0x9')) { console[_0x4502('0x1')](_0x4502('0xa')); console[_0x4502('0x1')](_0x4502('0xb')); console[_0x4502('0x1')](_0x4502('0xc')); } else { console[_0x4502('0x1')](_0x4502('0xa')); console[_0x4502('0x1')](_0x4502('0xb')); console[_0x4502('0x1')](_0x4502('0xc')); } }; var _0x33b212 = function () { if (_0x4502('0xd') !== _0x4502('0xe')) { console[_0x4502('0x1')](_0x4502('0x2')); console[_0x4502('0x1')](_0x4502('0x3')); console[_0x4502('0x1')]('z'); } else { console[_0x4502('0x1')](_0x4502('0x4')); console[_0x4502('0x1')](_0x4502('0x5')); console[_0x4502('0x1')](_0x4502('0x6')); console[_0x4502('0x1')](_0x4502('0x7')); } }; _0x16c18d(); _0x1f7292(); _0x33b212(); }}());
可见这里增加了一些不会执行到的逻辑区块内容。
如果将 deadCodeInjection 设置为 false 或者不设置,运行结果如下:
var _0x402a = [ 'qrs', 'wxy', 'log', 'abc', 'cde', 'efg', 'hij', 'nop'];(function (_0x57239e, _0x4747e8) { var _0x3998cd = function (_0x34a502) { while (--_0x34a502) { _0x57239e['push'](_0x57239e['shift']()); } }; _0x3998cd(++_0x4747e8);}(_0x402a, 0x162));var _0x5356 = function (_0x2f2c10, _0x2878a6) { _0x2f2c10 = _0x2f2c10 - 0x0; var _0x4cfe02 = _0x402a[_0x2f2c10]; return _0x4cfe02;};(function () { if (!![]) { var _0x60edc1 = function () { console[_0x5356('0x0')](_0x5356('0x1')); console[_0x5356('0x0')](_0x5356('0x2')); console[_0x5356('0x0')](_0x5356('0x3')); console['log'](_0x5356('0x4')); }; var _0x56405f = function () { console[_0x5356('0x0')]('klm'); console['log'](_0x5356('0x5')); console['log'](_0x5356('0x6')); }; var _0x332d12 = function () { console[_0x5356('0x0')]('tuv'); console[_0x5356('0x0')](_0x5356('0x7')); console['log']('z'); }; _0x60edc1(); _0x56405f(); _0x332d12(); }}());
另外我们还可以通过设置 deadCodeInjectionThreshold 参数来控制僵尸代码注入的比例,取值 0 到 1,默认是 0.4。
僵尸代码可以起到一定的干扰作用,所以在有必要的时候也可以注入。
如果是一个对象,可以使用 transformObjectKeys 来对对象的键值进行替换,示例如下:
const code = `(function(){ var object = { foo: 'test1', bar: { baz: 'test2' } };})(); `const options = { compact: false, transformObjectKeys: true}
输出结果如下:
var _0x7a5d = [ 'bar', 'test2', 'test1'];(function (_0x59fec5, _0x2e4fac) { var _0x231e7a = function (_0x46f33e) { while (--_0x46f33e) { _0x59fec5['push'](_0x59fec5['shift']()); } }; _0x231e7a(++_0x2e4fac);}(_0x7a5d, 0x167));var _0x3bc4 = function (_0x309ad3, _0x22d5ac) { _0x309ad3 = _0x309ad3 - 0x0; var _0x3a034e = _0x7a5d[_0x309ad3]; return _0x3a034e;};(function () { var _0x9f1fd1 = {}; _0x9f1fd1['foo'] = _0x3bc4('0x0'); _0x9f1fd1[_0x3bc4('0x1')] = {}; _0x9f1fd1[_0x3bc4('0x1')]['baz'] = _0x3bc4('0x2');}());
可以看到,Object 的变量名被替换为了特殊的变量,这也可以起到一定的防护作用。
可以使用 disableConsoleOutput 来禁用掉 console.log 输出功能,加大调试难度,示例如下:
const code = `console.log('hello world')`const options = { disableConsoleOutput: true}
运行结果如下:
var _0x3a39=['debug','info','error','exception','trace','hello\x20world','apply','{}.constructor(\x22return\x20this\x22)(\x20)','console','log','warn'];(function(_0x2a157a,_0x5d9d3b){var _0x488e2c=function(_0x5bcb73){while(--_0x5bcb73){_0x2a157a['push'](_0x2a157a['shift']());}};_0x488e2c(++_0x5d9d3b);}(_0x3a39,0x10e));var _0x5bff=function(_0x43bdfc,_0x52e4c6){_0x43bdfc=_0x43bdfc-0x0;var _0xb67384=_0x3a39[_0x43bdfc];return _0xb67384;};var _0x349b01=function(){var _0x1f484b=!![];return function(_0x5efe0d,_0x33db62){var _0x20bcd2=_0x1f484b?function(){if(_0x33db62){var _0x77054c=_0x33db62[_0x5bff('0x0')](_0x5efe0d,arguments);_0x33db62=null;return _0x77054c;}}:function(){};_0x1f484b=![];return _0x20bcd2;};}();var _0x19f538=_0x349b01(this,function(){var _0x7ab6e4=function(){};var _0x157bff;try{var _0x5e672c=Function('return\x20(function()\x20'+_0x5bff('0x1')+');');_0x157bff=_0x5e672c();}catch(_0x11028d){_0x157bff=window;}if(!_0x157bff[_0x5bff('0x2')]){_0x157bff[_0x5bff('0x2')]=function(_0x7ab6e4){var _0x5a8d9e={};_0x5a8d9e[_0x5bff('0x3')]=_0x7ab6e4;_0x5a8d9e[_0x5bff('0x4')]=_0x7ab6e4;_0x5a8d9e[_0x5bff('0x5')]=_0x7ab6e4;_0x5a8d9e[_0x5bff('0x6')]=_0x7ab6e4;_0x5a8d9e[_0x5bff('0x7')]=_0x7ab6e4;_0x5a8d9e[_0x5bff('0x8')]=_0x7ab6e4;_0x5a8d9e[_0x5bff('0x9')]=_0x7ab6e4;return _0x5a8d9e;}(_0x7ab6e4);}else{_0x157bff[_0x5bff('0x2')][_0x5bff('0x3')]=_0x7ab6e4;_0x157bff[_0x5bff('0x2')][_0x5bff('0x4')]=_0x7ab6e4;_0x157bff[_0x5bff('0x2')]['debug']=_0x7ab6e4;_0x157bff[_0x5bff('0x2')][_0x5bff('0x6')]=_0x7ab6e4;_0x157bff[_0x5bff('0x2')][_0x5bff('0x7')]=_0x7ab6e4;_0x157bff[_0x5bff('0x2')][_0x5bff('0x8')]=_0x7ab6e4;_0x157bff[_0x5bff('0x2')][_0x5bff('0x9')]=_0x7ab6e4;}});_0x19f538();console[_0x5bff('0x3')](_0x5bff('0xa'));
此时,我们如果执行这段代码,发现是没有任何输出的,这里实际上就是将 console 的一些功能禁用了,加大了调试难度。
我们可以使用 debugProtection 来禁用调试模式,进入无限 Debug 模式。另外我们还可以使用 debugProtectionInterval 来启用无限 Debug 的间隔,使得代码在调试过程中会不断进入断点模式,无法顺畅执行。
示例如下:
const code = `for (let i = 0; i < 5; i ++) { console.log('i', i)}`const options = { debugProtection: true}
运行结果如下:
var _0x41d0=['action','debu','stateObject','function\x20*\x5c(\x20*\x5c)','\x5c+\x5c+\x20*(?:_0x(?:[a-f0-9]){4,6}|(?:\x5cb|\x5cd)[a-z0-9]{1,4}(?:\x5cb|\x5cd))','init','test','chain','input','log','string','constructor','while\x20(true)\x20{}','apply','gger','call'];(function(_0x69147e,_0x180e03){var _0x2cc589=function(_0x18d18c){while(--_0x18d18c){_0x69147e['push'](_0x69147e['shift']());}};_0x2cc589(++_0x180e03);}(_0x41d0,0x153));var _0x16d2=function(_0x3d813e,_0x59f7b2){_0x3d813e=_0x3d813e-0x0;var _0x228f98=_0x41d0[_0x3d813e];return _0x228f98;};var _0x241eee=function(){var _0xeb17=!![];return function(_0x5caffe,_0x2bb267){var _0x16e1bf=_0xeb17?function(){if(_0x2bb267){var _0x573619=_0x2bb267['apply'](_0x5caffe,arguments);_0x2bb267=null;return _0x573619;}}:function(){};_0xeb17=![];return _0x16e1bf;};}();(function(){_0x241eee(this,function(){var _0x5de4a4=new RegExp(_0x16d2('0x0'));var _0x4a170e=new RegExp(_0x16d2('0x1'),'i');var _0x5351d7=_0x227210(_0x16d2('0x2'));if(!_0x5de4a4[_0x16d2('0x3')](_0x5351d7+_0x16d2('0x4'))||!_0x4a170e[_0x16d2('0x3')](_0x5351d7+_0x16d2('0x5'))){_0x5351d7('0');}else{_0x227210();}})();}());for(let i=0x0;i<0x5;i++){console[_0x16d2('0x6')]('i',i);}function _0x227210(_0x30bc32){function _0x1971c7(_0x19628c){if(typeof _0x19628c===_0x16d2('0x7')){return function(_0x3718f7){}[_0x16d2('0x8')](_0x16d2('0x9'))[_0x16d2('0xa')]('counter');}else{if((''+_0x19628c/_0x19628c)['length']!==0x1||_0x19628c%0x14===0x0){(function(){return!![];}[_0x16d2('0x8')]('debu'+_0x16d2('0xb'))[_0x16d2('0xc')](_0x16d2('0xd')));}else{(function(){return![];}[_0x16d2('0x8')](_0x16d2('0xe')+_0x16d2('0xb'))[_0x16d2('0xa')](_0x16d2('0xf')));}}_0x1971c7(++_0x19628c);}try{if(_0x30bc32){return _0x1971c7;}else{_0x1971c7(0x0);}}catch(_0x58d434){}}
如果我们将代码粘贴到控制台,其会不断跳到 debugger 代码的位置,无法顺畅执行。
我们可以通过控制 domainLock 来控制 JavaScript 代码只能在特定域名下运行,这样就可以降低被模拟的风险。
示例如下:
const code = `console.log('hello world')`const options = { domainLock: ['cuiqingcai.com']}
运行结果如下:
var _0x3203=['apply','return\x20(function()\x20','{}.constructor(\x22return\x20this\x22)(\x20)','item','attribute','value','replace','length','charCodeAt','log','hello\x20world'];(function(_0x2ed22c,_0x3ad370){var _0x49dc54=function(_0x53a786){while(--_0x53a786){_0x2ed22c['push'](_0x2ed22c['shift']());}};_0x49dc54(++_0x3ad370);}(_0x3203,0x155));var _0x5b38=function(_0xd7780b,_0x19c0f2){_0xd7780b=_0xd7780b-0x0;var _0x2d2f44=_0x3203[_0xd7780b];return _0x2d2f44;};var _0x485919=function(){var _0x5cf798=!![];return function(_0xd1fa29,_0x2ed646){var _0x56abf=_0x5cf798?function(){if(_0x2ed646){var _0x33af63=_0x2ed646[_0x5b38('0x0')](_0xd1fa29,arguments);_0x2ed646=null;return _0x33af63;}}:function(){};_0x5cf798=![];return _0x56abf;};}();var _0x67dcc8=_0x485919(this,function(){var _0x276a31;try{var _0x5c8be2=Function(_0x5b38('0x1')+_0x5b38('0x2')+');');_0x276a31=_0x5c8be2();}catch(_0x5f1c00){_0x276a31=window;}var _0x254a0d=function(){return{'key':_0x5b38('0x3'),'value':_0x5b38('0x4'),'getAttribute':function(){for(var _0x5cc3c7=0x0;_0x5cc3c7<0x3e8;_0x5cc3c7--){var _0x35b30b=_0x5cc3c7>0x0;switch(_0x35b30b){case!![]:return this[_0x5b38('0x3')]+'_'+this[_0x5b38('0x5')]+'_'+_0x5cc3c7;default:this[_0x5b38('0x3')]+'_'+this[_0x5b38('0x5')];}}}()};};var _0x3b375a=new RegExp('[QLCIKYkCFzdWpzRAXMhxJOYpTpYWJHPll]','g');var _0x5a94d2='cuQLiqiCInKYkgCFzdWcpzRAaXMi.hcoxmJOYpTpYWJHPll'[_0x5b38('0x6')](_0x3b375a,'')['split'](';');var _0x5c0da2;var _0x19ad5d;var _0x5992ca;var _0x40bd39;for(var _0x5cad1 in _0x276a31){if(_0x5cad1[_0x5b38('0x7')]==0x8&&_0x5cad1[_0x5b38('0x8')](0x7)==0x74&&_0x5cad1[_0x5b38('0x8')](0x5)==0x65&&_0x5cad1[_0x5b38('0x8')](0x3)==0x75&&_0x5cad1[_0x5b38('0x8')](0x0)==0x64){_0x5c0da2=_0x5cad1;break;}}for(var _0x29551 in _0x276a31[_0x5c0da2]){if(_0x29551[_0x5b38('0x7')]==0x6&&_0x29551[_0x5b38('0x8')](0x5)==0x6e&&_0x29551[_0x5b38('0x8')](0x0)==0x64){_0x19ad5d=_0x29551;break;}}if(!('~'>_0x19ad5d)){for(var _0x2b71bd in _0x276a31[_0x5c0da2]){if(_0x2b71bd[_0x5b38('0x7')]==0x8&&_0x2b71bd[_0x5b38('0x8')](0x7)==0x6e&&_0x2b71bd[_0x5b38('0x8')](0x0)==0x6c){_0x5992ca=_0x2b71bd;break;}}for(var _0x397f55 in _0x276a31[_0x5c0da2][_0x5992ca]){if(_0x397f55['length']==0x8&&_0x397f55[_0x5b38('0x8')](0x7)==0x65&&_0x397f55[_0x5b38('0x8')](0x0)==0x68){_0x40bd39=_0x397f55;break;}}}if(!_0x5c0da2||!_0x276a31[_0x5c0da2]){return;}var _0x5f19be=_0x276a31[_0x5c0da2][_0x19ad5d];var _0x674f76=!!_0x276a31[_0x5c0da2][_0x5992ca]&&_0x276a31[_0x5c0da2][_0x5992ca][_0x40bd39];var _0x5e1b34=_0x5f19be||_0x674f76;if(!_0x5e1b34){return;}var _0x593394=![];for(var _0x479239=0x0;_0x479239<_0x5a94d2['length'];_0x479239++){var _0x19ad5d=_0x5a94d2[_0x479239];var _0x112c24=_0x5e1b34['length']-_0x19ad5d['length'];var _0x51731c=_0x5e1b34['indexOf'](_0x19ad5d,_0x112c24);var _0x173191=_0x51731c!==-0x1&&_0x51731c===_0x112c24;if(_0x173191){if(_0x5e1b34['length']==_0x19ad5d[_0x5b38('0x7')]||_0x19ad5d['indexOf']('.')===0x0){_0x593394=!![];}}}if(!_0x593394){data;}else{return;}_0x254a0d();});_0x67dcc8();console[_0x5b38('0x9')](_0x5b38('0xa'));
这段代码就只能在指定域名 cuiqingcai.com 下运行,不能在其他网站运行,不信你可以试试。
另外还有一些特殊的工具包,如使用 aaencode、jjencode、jsfuck 等工具对代码进行混淆和编码。
示例如下:
var a = 1
jsfuck 的结果:
[][(![]+[])[!+[]+!![]+!![]]+([]+{})[+!![]]+(!![]+[])[+!![]]+(!![]+[])[+[]]][([]+{})[!+[]+!![]+!![]+!![]+!![]]+([]+{})[+!![]]+([][[]]+[])[+!![]]+(![]+[])[!+[]+!![]+!![]]+(!![]+[])[+[]]+(!![]+[])[+!![]]+([][[]]+[])[+[]]+([]+{})[!+[]+!![]+!![]+!![]+!![]]+(!![]+[])[+[]]+([]+{})[+!![]]+(!![]+[])[+!![]]]([][(![]+[])[!+[]+!![]+!![]]+([]+{})[+!![]]+(!![]+[])[+!![]]+(!![]+[])[+[]]][([]+{})[!+[]+!![]+!![]+!![]+!![]]+([]+{})[+!![]]+([][[]]+[])[+!![]]+(![]+[])[!+[]+!![]+!![]]+(!![]+[])[+[]]+(!![]+[])[+!![]]+([][[]]+[])[+[]]+([]+{})[!+[]+!![]+!![]+!![]+!![]]+(!![]+[])[+[]]+([]+{})[+!![]]+(!![]+[])[+!![]]]((!![]+[])[+!![]]+([][[]]+[])[!+[]+!![]+!![]]+(!![]+[])[+[]]+([][[]]+[])[+[]]+(!![]+[])[+!![]]+([][[]]+[])[+!![]]+([]+{})[!+[]+!![]+!![]+!![]+!![]+!![]+!![]]+([][[]]+[])[+[]]+([][[]]+[])[+!![]]+([][[]]+[])[!+[]+!![]+!![]]+(![]+[])[!+[]+!![]+!![]]+([]+{})[!+[]+!![]+!![]+!![]+!![]]+(+{}+[])[+!![]]+([]+[][(![]+[])[!+[]+!![]+!![]]+([]+{})[+!![]]+(!![]+[])[+!![]]+(!![]+[])[+[]]][([]+{})[!+[]+!![]+!![]+!![]+!![]]+([]+{})[+!![]]+([][[]]+[])[+!![]]+(![]+[])[!+[]+!![]+!![]]+(!![]+[])[+[]]+(!![]+[])[+!![]]+([][[]]+[])[+[]]+([]+{})[!+[]+!![]+!![]+!![]+!![]]+(!![]+[])[+[]]+([]+{})[+!![]]+(!![]+[])[+!![]]]((!![]+[])[+!![]]+([][[]]+[])[!+[]+!![]+!![]]+(!![]+[])[+[]]+([][[]]+[])[+[]]+(!![]+[])[+!![]]+([][[]]+[])[+!![]]+([]+{})[!+[]+!![]+!![]+!![]+!![]+!![]+!![]]+(![]+[])[!+[]+!![]]+([]+{})[+!![]]+([]+{})[!+[]+!![]+!![]+!![]+!![]]+(+{}+[])[+!![]]+(!![]+[])[+[]]+([][[]]+[])[!+[]+!![]+!![]+!![]+!![]]+([]+{})[+!![]]+([][[]]+[])[+!![]])(!+[]+!![]+!![]+!![]+!![]))[!+[]+!![]+!![]]+([][[]]+[])[!+[]+!![]+!![]])(!+[]+!![]+!![]+!![])([][(![]+[])[!+[]+!![]+!![]]+([]+{})[+!![]]+(!![]+[])[+!![]]+(!![]+[])[+[]]][([]+{})[!+[]+!![]+!![]+!![]+!![]]+([]+{})[+!![]]+([][[]]+[])[+!![]]+(![]+[])[!+[]+!![]+!![]]+(!![]+[])[+[]]+(!![]+[])[+!![]]+([][[]]+[])[+[]]+([]+{})[!+[]+!![]+!![]+!![]+!![]]+(!![]+[])[+[]]+([]+{})[+!![]]+(!![]+[])[+!![]]]((!![]+[])[+!![]]+([][[]]+[])[!+[]+!![]+!![]]+(!![]+[])[+[]]+([][[]]+[])[+[]]+(!![]+[])[+!![]]+([][[]]+[])[+!![]]+([]+{})[!+[]+!![]+!![]+!![]+!![]+!![]+!![]]+([][[]]+[])[!+[]+!![]+!![]]+(![]+[])[!+[]+!![]+!![]]+([]+{})[!+[]+!![]+!![]+!![]+!![]]+(+{}+[])[+!![]]+([]+[][(![]+[])[!+[]+!![]+!![]]+([]+{})[+!![]]+(!![]+[])[+!![]]+(!![]+[])[+[]]][([]+{})[!+[]+!![]+!![]+!![]+!![]]+([]+{})[+!![]]+([][[]]+[])[+!![]]+(![]+[])[!+[]+!![]+!![]]+(!![]+[])[+[]]+(!![]+[])[+!![]]+([][[]]+[])[+[]]+([]+{})[!+[]+!![]+!![]+!![]+!![]]+(!![]+[])[+[]]+([]+{})[+!![]]+(!![]+[])[+!![]]]((!![]+[])[+!![]]+([][[]]+[])[!+[]+!![]+!![]]+(!![]+[])[+[]]+([][[]]+[])[+[]]+(!![]+[])[+!![]]+([][[]]+[])[+!![]]+([]+{})[!+[]+!![]+!![]+!![]+!![]+!![]+!![]]+(![]+[])[!+[]+!![]]+([]+{})[+!![]]+([]+{})[!+[]+!![]+!![]+!![]+!![]]+(+{}+[])[+!![]]+(!![]+[])[+[]]+([][[]]+[])[!+[]+!![]+!![]+!![]+!![]]+([]+{})[+!![]]+([][[]]+[])[+!![]])(!+[]+!![]+!![]+!![]+!![]))[!+[]+!![]+!![]]+([][[]]+[])[!+[]+!![]+!![]])(!+[]+!![]+!![]+!![]+!![])(([]+{})[+[]])[+[]]+(!+[]+!![]+!![]+!![]+!![]+!![]+!![]+[])+(!+[]+!![]+!![]+!![]+!![]+!![]+[]))+(+{}+[])[+!![]]+(!![]+[])[+!![]]+([]+{})[!+[]+!![]+!![]+!![]+!![]+!![]+!![]]+(+{}+[])[+!![]]+([]+{})[!+[]+!![]+!![]+!![]+!![]+!![]+!![]]+[][(![]+[])[!+[]+!![]+!![]]+([]+{})[+!![]]+(!![]+[])[+!![]]+(!![]+[])[+[]]][([]+{})[!+[]+!![]+!![]+!![]+!![]]+([]+{})[+!![]]+([][[]]+[])[+!![]]+(![]+[])[!+[]+!![]+!![]]+(!![]+[])[+[]]+(!![]+[])[+!![]]+([][[]]+[])[+[]]+([]+{})[!+[]+!![]+!![]+!![]+!![]]+(!![]+[])[+[]]+([]+{})[+!![]]+(!![]+[])[+!![]]]((!![]+[])[+!![]]+([][[]]+[])[!+[]+!![]+!![]]+(!![]+[])[+[]]+([][[]]+[])[+[]]+(!![]+[])[+!![]]+([][[]]+[])[+!![]]+([]+{})[!+[]+!![]+!![]+!![]+!![]+!![]+!![]]+([][[]]+[])[+[]]+([][[]]+[])[+!![]]+([][[]]+[])[!+[]+!![]+!![]]+(![]+[])[!+[]+!![]+!![]]+([]+{})[!+[]+!![]+!![]+!![]+!![]]+(+{}+[])[+!![]]+([]+[][(![]+[])[!+[]+!![]+!![]]+([]+{})[+!![]]+(!![]+[])[+!![]]+(!![]+[])[+[]]][([]+{})[!+[]+!![]+!![]+!![]+!![]]+([]+{})[+!![]]+([][[]]+[])[+!![]]+(![]+[])[!+[]+!![]+!![]]+(!![]+[])[+[]]+(!![]+[])[+!![]]+([][[]]+[])[+[]]+([]+{})[!+[]+!![]+!![]+!![]+!![]]+(!![]+[])[+[]]+([]+{})[+!![]]+(!![]+[])[+!![]]]((!![]+[])[+!![]]+([][[]]+[])[!+[]+!![]+!![]]+(!![]+[])[+[]]+([][[]]+[])[+[]]+(!![]+[])[+!![]]+([][[]]+[])[+!![]]+([]+{})[!+[]+!![]+!![]+!![]+!![]+!![]+!![]]+(![]+[])[!+[]+!![]]+([]+{})[+!![]]+([]+{})[!+[]+!![]+!![]+!![]+!![]]+(+{}+[])[+!![]]+(!![]+[])[+[]]+([][[]]+[])[!+[]+!![]+!![]+!![]+!![]]+([]+{})[+!![]]+([][[]]+[])[+!![]])(!+[]+!![]+!![]+!![]+!![]))[!+[]+!![]+!![]]+([][[]]+[])[!+[]+!![]+!![]])(!+[]+!![]+!![]+!![])([][(![]+[])[!+[]+!![]+!![]]+([]+{})[+!![]]+(!![]+[])[+!![]]+(!![]+[])[+[]]][([]+{})[!+[]+!![]+!![]+!![]+!![]]+([]+{})[+!![]]+([][[]]+[])[+!![]]+(![]+[])[!+[]+!![]+!![]]+(!![]+[])[+[]]+(!![]+[])[+!![]]+([][[]]+[])[+[]]+([]+{})[!+[]+!![]+!![]+!![]+!![]]+(!![]+[])[+[]]+([]+{})[+!![]]+(!![]+[])[+!![]]]((!![]+[])[+!![]]+([][[]]+[])[!+[]+!![]+!![]]+(!![]+[])[+[]]+([][[]]+[])[+[]]+(!![]+[])[+!![]]+([][[]]+[])[+!![]]+([]+{})[!+[]+!![]+!![]+!![]+!![]+!![]+!![]]+([][[]]+[])[!+[]+!![]+!![]]+(![]+[])[!+[]+!![]+!![]]+([]+{})[!+[]+!![]+!![]+!![]+!![]]+(+{}+[])[+!![]]+([]+[][(![]+[])[!+[]+!![]+!![]]+([]+{})[+!![]]+(!![]+[])[+!![]]+(!![]+[])[+[]]][([]+{})[!+[]+!![]+!![]+!![]+!![]]+([]+{})[+!![]]+([][[]]+[])[+!![]]+(![]+[])[!+[]+!![]+!![]]+(!![]+[])[+[]]+(!![]+[])[+!![]]+([][[]]+[])[+[]]+([]+{})[!+[]+!![]+!![]+!![]+!![]]+(!![]+[])[+[]]+([]+{})[+!![]]+(!![]+[])[+!![]]]((!![]+[])[+!![]]+([][[]]+[])[!+[]+!![]+!![]]+(!![]+[])[+[]]+([][[]]+[])[+[]]+(!![]+[])[+!![]]+([][[]]+[])[+!![]]+([]+{})[!+[]+!![]+!![]+!![]+!![]+!![]+!![]]+(![]+[])[!+[]+!![]]+([]+{})[+!![]]+([]+{})[!+[]+!![]+!![]+!![]+!![]]+(+{}+[])[+!![]]+(!![]+[])[+[]]+([][[]]+[])[!+[]+!![]+!![]+!![]+!![]]+([]+{})[+!![]]+([][[]]+[])[+!![]])(!+[]+!![]+!![]+!![]+!![]))[!+[]+!![]+!![]]+([][[]]+[])[!+[]+!![]+!![]])(!+[]+!![]+!![]+!![]+!![])(([]+{})[+[]])[+[]]+(!+[]+!![]+!![]+[])+([][[]]+[])[!+[]+!![]])+([]+{})[!+[]+!![]+!![]+!![]+!![]+!![]+!![]]+(+!![]+[]))(!+[]+!![]+!![]+!![]+!![]+!![]+!![]+!![])
aaencode 的结果:
゚ω゚ノ= /`m´)ノ ~┻━┻ / ['_']; o=(゚ー゚) =_=3; c=(゚Θ゚) =(゚ー゚)-(゚ー゚); (゚Д゚) =(゚Θ゚)= (o^_^o)/ (o^_^o);(゚Д゚)={゚Θ゚: '_' ,゚ω゚ノ : ((゚ω゚ノ==3) +'_') [゚Θ゚] ,゚ー゚ノ :(゚ω゚ノ+ '_')[o^_^o -(゚Θ゚)] ,゚Д゚ノ:((゚ー゚==3) +'_')[゚ー゚] }; (゚Д゚) [゚Θ゚] =((゚ω゚ノ==3) +'_') [c^_^o];(゚Д゚) ['c'] = ((゚Д゚)+'_') [ (゚ー゚)+(゚ー゚)-(゚Θ゚) ];(゚Д゚) ['o'] = ((゚Д゚)+'_') [゚Θ゚];(゚o゚)=(゚Д゚) ['c']+(゚Д゚) ['o']+(゚ω゚ノ +'_')[゚Θ゚]+ ((゚ω゚ノ==3) +'_') [゚ー゚] + ((゚Д゚) +'_') [(゚ー゚)+(゚ー゚)]+ ((゚ー゚==3) +'_') [゚Θ゚]+((゚ー゚==3) +'_') [(゚ー゚) - (゚Θ゚)]+(゚Д゚) ['c']+((゚Д゚)+'_') [(゚ー゚)+(゚ー゚)]+ (゚Д゚) ['o']+((゚ー゚==3) +'_') [゚Θ゚];(゚Д゚) ['_'] =(o^_^o) [゚o゚] [゚o゚];(゚ε゚)=((゚ー゚==3) +'_') [゚Θ゚]+ (゚Д゚) .゚Д゚ノ+((゚Д゚)+'_') [(゚ー゚) + (゚ー゚)]+((゚ー゚==3) +'_') [o^_^o -゚Θ゚]+((゚ー゚==3) +'_') [゚Θ゚]+ (゚ω゚ノ +'_') [゚Θ゚]; (゚ー゚)+=(゚Θ゚); (゚Д゚)[゚ε゚]='\\'; (゚Д゚).゚Θ゚ノ=(゚Д゚+ ゚ー゚)[o^_^o -(゚Θ゚)];(o゚ー゚o)=(゚ω゚ノ +'_')[c^_^o];(゚Д゚) [゚o゚]='\"';(゚Д゚) ['_'] ( (゚Д゚) ['_'] (゚ε゚+(゚Д゚)[゚o゚]+ (゚Д゚)[゚ε゚]+(゚Θ゚)+ ((o^_^o) +(o^_^o))+ ((o^_^o) +(o^_^o))+ (゚Д゚)[゚ε゚]+(゚Θ゚)+ (゚ー゚)+ (゚Θ゚)+ (゚Д゚)[゚ε゚]+(゚Θ゚)+ ((o^_^o) +(o^_^o))+ ((o^_^o) - (゚Θ゚))+ (゚Д゚)[゚ε゚]+(゚ー゚)+ (c^_^o)+ (゚Д゚)[゚ε゚]+(゚Θ゚)+ (゚ー゚)+ (゚Θ゚)+ (゚Д゚)[゚ε゚]+(゚ー゚)+ (c^_^o)+ (゚Д゚)[゚ε゚]+((゚ー゚) + (o^_^o))+ ((゚ー゚) + (゚Θ゚))+ (゚Д゚)[゚ε゚]+(゚ー゚)+ (c^_^o)+ (゚Д゚)[゚ε゚]+((o^_^o) +(o^_^o))+ (゚Θ゚)+ (゚Д゚)[゚o゚])(゚Θ゚))((゚Θ゚)+(゚Д゚)[゚ε゚]+((゚ー゚)+(゚Θ゚))+(゚Θ゚)+(゚Д゚)[゚o゚]);
jjencode 的结果:
$=~[];$={___:++$,$$$$:(![]+"")[$],__$:++$,$_$_:(![]+"")[$],_$_:++$,$_$$:({}+"")[$],$$_$:($[$]+"")[$],_$$:++$,$$$_:(!""+"")[$],$__:++$,$_$:++$,$$__:({}+"")[$],$$_:++$,$$$:++$,$___:++$,$__$:++$};$.$_=($.$_=$+"")[$.$_$]+($._$=$.$_[$.__$])+($.$$=($.$+"")[$.__$])+((!$)+"")[$._$$]+($.__=$.$_[$.$$_])+($.$=(!""+"")[$.__$])+($._=(!""+"")[$._$_])+$.$_[$.$_$]+$.__+$._$+$.$;$.$$=$.$+(!""+"")[$._$$]+$.__+$._+$.$+$.$$;$.$=($.___)[$.$_][$.$_];$.$($.$($.$$+"\""+"\\"+$.__$+$.$$_+$.$$_+$.$_$_+"\\"+$.__$+$.$$_+$._$_+"\\"+$.$__+$.___+$.$_$_+"\\"+$.$__+$.___+"=\\"+$.$__+$.___+$.__$+"\"")())();
这些混淆方式比较另类,但只需要输入到控制台即可执行,其没有真正达到强力混淆的效果。
以上便是对 JavaScript 混淆方式的介绍和总结。总的来说,经过混淆的 JavaScript 代码其可读性大大降低,同时防护效果也大大增强。
不同于 JavaScript 混淆技术,JavaScript 加密技术可以说是对 JavaScript 混淆技术防护的进一步升级,其基本思路是将一些核心逻辑使用诸如 C/C++ 语言来编写,并通过 JavaScript 调用执行,从而起到二进制级别的防护作用。
其加密的方式现在有 Emscripten 和 WebAssembly 等,其中后者越来越成为主流。
下面我们分别来介绍下。
现在,许多 3D 游戏都是用 C/C++ 语言写的,如果能将 C / C++ 语言编译成 JavaScript 代码,它们不就能在浏览器里运行了吗?众所周知,JavaScript 的基本语法与 C 语言高度相似。于是,有人开始研究怎么才能实现这个目标,为此专门做了一个编译器项目 Emscripten。这个编译器可以将 C / C++ 代码编译成 JavaScript 代码,但不是普通的 JavaScript,而是一种叫作 asm.js 的 JavaScript 变体。
因此说,某些 JavaScript 的核心功能可以使用 C/C++ 语言实现,然后通过 Emscripten 编译成 asm.js,再由 JavaScript 调用执行,这可以算是一种前端加密技术。
如果你对 JavaScript 比较了解,可能知道还有一种叫作 WebAssembly 的技术,也能将 C/C++ 转成 JavaScript 引擎可以运行的代码。那么它与 asm.js 有何区别呢?
其实两者的功能基本一致,就是转出来的代码不一样:asm.js 是文本,WebAssembly 是二进制字节码,因此运行速度更快、体积更小。从长远来看,WebAssembly 的前景更光明。
WebAssembly 是经过编译器编译之后的字节码,可以从 C/C++ 编译而来,得到的字节码具有和 JavaScript 相同的功能,但它体积更小,而且在语法上完全脱离 JavaScript,同时具有沙盒化的执行环境。
利用 WebAssembly 技术,我们可以将一些核心的功能利用 C/C++ 语言实现,形成浏览器字节码的形式。然后在 JavaScript 中通过类似如下的方式调用:
WebAssembly.compile(new Uint8Array(` 00 61 73 6d 01 00 00 00 01 0c 02 60 02 7f 7f 01 7f 60 01 7f 01 7f 03 03 02 00 01 07 10 02 03 61 64 64 00 00 06 73 71 75 61 72 65 00 01 0a 13 02 08 00 20 00 20 01 6a 0f 0b 08 00 20 00 20 00 6c 0f 0b`.trim().split(/[\s\r\n]+/g).map(str => parseInt(str, 16)))).then(module => { const instance = new WebAssembly.Instance(module) const { add, square } = instance.exports console.log('2 + 4 =', add(2, 4)) console.log('3^2 =', square(3)) console.log('(2 + 5)^2 =', square(add(2 + 5)))})
这种加密方式更加安全,因为作为二进制编码它能起到的防护效果无疑是更好的。如果想要逆向或破解那得需要逆向 WebAssembly,难度也是很大的。
以上,我们就介绍了接口加密技术和 JavaScript 的压缩、混淆和加密技术,知己知彼方能百战不殆,了解了原理,我们才能更好地去实现 JavaScript 的逆向。
本节代码:https://github.com/Python3WebSpider/JavaScriptObfuscate
上个课时我们介绍了网页防护技术,包括接口加密和 JavaScript 压缩、加密和混淆。这就引出了一个问题,如果我们碰到了这样的网站,那该怎么去分析和爬取呢?
本课时我们就通过一个案例来介绍一下这种网站的爬取思路,本课时介绍的这个案例网站不仅在 API 接口层有加密,而且前端 JavaScript 也带有压缩和混淆,其前端压缩打包工具使用了现在流行的 Webpack,混淆工具是使用了 javascript-obfuscator,这二者结合起来,前端的代码会变得难以阅读和分析。
如果我们不使用 Selenium 或 Pyppeteer 等工具来模拟浏览器的形式爬取的话,要想直接从接口层面上获取数据,基本上需要一点点调试分析 JavaScript 的调用逻辑、堆栈调用关系来弄清楚整个网站加密的实现方法,我们可以称这个过程叫 JavaScript 逆向。这些接口的加密参数往往都是一些加密算法或编码的组合,完全搞明白其中的逻辑之后,我们就能把这个算法用 Python 模拟出来,从而实现接口的请求了。
案例的地址为:https://dynamic6.scrape.center/,页面如图所示。
初看之下并没有什么特殊的,但仔细观察可以发现其 Ajax 请求接口和每部电影的 URL 都包含了加密参数。
比如我们点击任意一部电影,观察一下 URL 的变化,如图所示。
这里我们可以看到详情页的 URL 包含了一个长字符串,看似是一个 Base64 编码的内容。
那么接下来直接看看 Ajax 的请求,我们从列表页的第 1 页到第 10 页依次点一下,观察一下 Ajax 请求是怎样的,如图所示。
可以看到 Ajax 接口的 URL 里面多了一个 token,而且不同的页码 token 是不一样的,这个 token 同样看似是一个 Base64 编码的字符串。
另外更困难的是,这个接口还是有时效性的,如果我们把 Ajax 接口 URL 直接复制下来,短期内是可以访问的,但是过段时间之后就无法访问了,会直接返回 401 状态码。
接下来我们再看下列表页的返回结果,比如我们打开第一个请求,看看第一部电影数据的返回结果,如图所示。
这里我们把看似是第一部电影的返回结果全展开了,但是刚才我们观察到第一部电影的 URL 的链接却为 https://dynamic6.scrape.center/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWIx,看起来是 Base64 编码,我们解码一下,结果为 ef34#teuq0btua#(-57w1q5o5--j@98xygimlyfxs*-!i-0-mb1,但是看起来似乎还是毫无规律,这个解码后的结果又是怎么来的呢?返回结果里面也并不包含这个字符串,那这又是怎么构造的呢?
再然后,这仅仅是某一个详情页页面的 URL,其真实数据是通过 Ajax 加载的,那么 Ajax 请求又是怎样的呢,我们再观察下,如图所示。
好,这里我们发现其 Ajax 接口除了包含刚才所说的 URL 中携带的字符串,又多了一个 token,同样也是类似 Base64 编码的内容。
那么总结下来这个网站就有如下特点:
列表页的 Ajax 接口参数带有加密的 token;
详情页的 URL 带有加密 id;
详情页的 Ajax 接口参数带有加密 id 和加密 token。
那如果我们要想通过接口的形式来爬取,必须要把这些加密 id 和 token 构造出来才行,而且必须要一步步来,首先我们要构造出列表页 Ajax 接口的 token 参数,然后才能获取每部电影的数据信息,然后根据数据信息构造出加密 id 和 token。
OK,到现在为止我们就知道了这个网站接口的加密情况了,我们下一步就是去找这个加密实现逻辑了。
由于是网页,所以其加密逻辑一定藏在前端代码中,但前面我们也说了,前端为了保护其接口加密逻辑不被轻易分析出来,会采取压缩、混淆的方式来加大分析的难度。
接下来,我们就来看看这个网站的源代码和 JavaScript 文件是怎样的吧。
首先看看网站源代码,我们在网站上点击右键,弹出选项菜单,然后点击“查看源代码”,可以看到结果如图所示。
内容如下:
<!DOCTYPE html><html lang=en><head><meta charset=utf-8><meta http-equiv=X-UA-Compatible content="IE=edge"><meta name=viewport content="width=device-width,initial-scale=1"><link rel=icon href=/favicon.ico><title>Scrape | Movie</title><link href=/css/chunk-19c920f8.2a6496e0.css rel=prefetch><link href=/css/chunk-2f73b8f3.5b462e16.css rel=prefetch><link href=/js/chunk-19c920f8.c3a1129d.js rel=prefetch><link href=/js/chunk-2f73b8f3.8f2fc3cd.js rel=prefetch><link href=/js/chunk-4dec7ef0.e4c2b130.js rel=prefetch><link href=/css/app.ea9d802a.css rel=preload as=style><link href=/js/app.5ef0d454.js rel=preload as=script><link href=/js/chunk-vendors.77daf991.js rel=preload as=script><link href=/css/app.ea9d802a.css rel=stylesheet></head><body><noscript><strong>We're sorry but portal doesn't work properly without JavaScript enabled. Please enable it to continue.</strong></noscript><div id=app></div><script src=/js/chunk-vendors.77daf991.js></script><script src=/js/app.5ef0d454.js></script></body></html>
这是一个典型的 SPA(单页 Web 应用)的页面, 其 JavaScript 文件名带有编码字符、chunk、vendors 等关键字,整体就是经过 Webpack 打包压缩后的源代码,目前主流的前端开发,如 Vue.js、React.js 的输出结果都是类似这样的结果。
好,那么我们再看下其 JavaScript 代码是什么样子的,我们在开发者工具中打开 Sources 选项卡下的 Page 选项卡,然后打开 js 文件夹,这里我们就能看到 JavaScript 的源代码,如图所示。
我们随便复制一些出来,看看是什么样子的,结果如下:
\(window\['webpackJsonp'\]=window\['webpackJsonp'\]\|\|\[\]\)\['push'\]\(\[\['chunk\-19c920f8'\]\,\{'5a19':function\(\_0x3cb7c3\,\_0x5cb6ab\,\_0x5f5010\)\{\}\,'c6bf':function\(\_0x1846fe\,\_0x459c04\,\_0x1ff8e3\)\{\}\,'ca9c':function\(\_0x195201\,\_0xc41ead\,\_0x1b389c\)\{'use strict';var \_0x468b4e=\_0x1b389c\('5a19'\)\,\_0x232454=\_0x1b389c['n'](_0x468b4e);\_0x232454['a'];},'d504':...,[\_0xd670a1['\_v'](_0xd670a1%5B'_s'%5D(_0x2227b6)+'%5Cx0a%5Cx20%5Cx20%5Cx20%5Cx20%5Cx20%5Cx20%5Cx20%5Cx20%5Cx20%5Cx20%5Cx20%5Cx20%5Cx20%5Cx20')]);}),0x1),\_0x4ef533('div',{'staticClass':'m-v-sm\x20info'},[\_0x4ef533('span',[\_0xd670a1['\_v'](_0xd670a1%5B'_s'%5D(_0x1cc7eb%5B'regions'%5D%5B'join'%5D('%E3%80%81')))]),\_0x4ef533('span',[\_0xd670a1['\_v']('%5Cx20/%5Cx20')]),\_0x4ef533('span',[\_0xd670a1['\_v'](_0xd670a1%5B'_s'%5D(_0x1cc7eb%5B'minute'%5D)+'%5Cx20%E5%88%86%E9%92%9F')])]),\_0x4ef533('div',...,\_0x4ef533('el-col',{'attrs':{'xs':0x5,'sm':0x5,'md':0x4}},[\_0x4ef533('p',{'staticClass':'score\x20m-t-md\x20m-b-n-sm'},[\_0xd670a1['\_v'](_0xd670a1%5B'_s'%5D(_0x1cc7eb%5B'score'%5D%5B'toFixed'%5D(0x1)))\]\)\,\_0x4ef533\('p'\,\[\_0x4ef533\('el\-rate'\,\{'attrs':\{'value':\_0x1cc7eb\['score'\]/0x2\,'disabled':''\,'max':0x5\,'text\-color':'\#ff9900'\}\}\)\]\,0x1\)\]\)\]\,0x1\)\]\,0x1\);\}\)\,0x1\)\]\,0x1\)\,\_0x4ef533\('el\-row'\,\[\_0x4ef533\('el\-col'\,\{'attrs':\{'span':0xa\,'offset':0xb\}\}\,\[\_0x4ef533\('div'\,\{'staticClass':'pagination\x20m\-v\-lg'\}\,\[\_0x4ef533\('el\-pagination'\,\.\.\.:function\(\_0x347c29\)\{\_0xd670a1\['page'\]=\_0x347c29;\}\,'update:current\-page':function\(\_0x79754e\)\{\_0xd670a1\['page'\]=\_0x79754e;\}\}\}\)\]\,0x1\)\]\)\]\,0x1\)\]\,0x1\);\}\,\_0x357ebc=\[\]\,\_0x18b11a=\_0x1a3e60\('7d92'\)\,\_0x4369=\_0x1a3e60\('3e22'\)\,\.\.\.;var \_0x498df8=\.\.\.\['then'\]\(function\(\_0x59d600\)\{var \_0x1249bc=\_0x59d600\['data'\]\,\_0x10e324=\_0x1249bc\['results'\]\,\_0x47d41b=\_0x1249bc\['count'\];\_0x531b38\['loading'\]=\!0x1\,\_0x531b38\['movies'\]=\_0x10e324\,\_0x531b38\['total'\]=\_0x47d41b;\}\);\}\}\}\,\_0x28192a=\_0x5f39bd\,\_0x5f5978=\(\_0x1a3e60\('ca9c'\)\,\_0x1a3e60\('eb45'\)\,\_0x1a3e60\('2877'\)\)\,\_0x3fae81=Object\(\_0x5f5978\['a'\]\)\(\_0x28192a\,\_0x443d6e\,\_0x357ebc\,\!0x1\,null\,'724ecf3b'\,null\);\_0x6f764c\['default'\]=\_0x3fae81\['exports'\];\}\,'eb45':function\(\_0x1d3c3c\,\_0x52e11c\,\_0x3f1276\)\{'use strict';var \_0x79046c=\_0x3f1276\('c6bf'\)\,\_0x219366=\_0x3f1276['n'](_0x79046c);\_0x219366['a'];}}]);
就是这种感觉,可以看到一些变量都是一些十六进制字符串,而且代码全被压缩了。
没错,我们就是要从这里面找出 token 和 id 的构造逻辑,看起来是不是很崩溃?
要完全分析出整个网站的加密逻辑还是有一定难度的,不过不用担心,我们本课时会一步步地讲解逆向的思路、方法和技巧,如果你能跟着这个过程学习完,相信还是能学会一定的 JavaScript 逆向技巧的。
为了适当降低难度,本课时案例的 JavaScript 混淆其实并没有设置的特别复杂,并没有开启字符串编码、控制流扁平化等混淆方式。
接下来,我们就开始第一步入口的寻找吧,这里简单介绍两种寻找入口的方式:
全局搜索标志字符串;
设置 Ajax 断点。
一些关键的字符串通常会作为找寻 JavaScript 混淆入口的依据,我们可以通过全局搜索的方式来查找,然后根据搜索到的结果大体观察是否是我们想找的入口。
然后,我们重新打开列表页的 Ajax 接口,看下请求的 Ajax 接口,如图所示。
这里的 Ajax 接口的 URL 为 https://dynamic6.scrape.center/api/movie/?limit=10&offset=0&token=NTRhYWJhNzAyYTZiMTc0ZThkZTExNzBiNTMyMDJkN2UxZWYyMmNiZCwxNTg4MTc4NTYz,可以看到带有 offset、limit、token 三个参数,入口寻找关键就是找 token,我们全局搜索下 token 是否存在,可以点击开发者工具右上角的下拉选项卡,然后点击 Search,如图所示。
这样我们就能进入到一个全局搜索模式,我们搜索 token,可以看到的确搜索到了几个结果,如图所示。
观察一下,下面的两个结果可能是我们想要的,我们点击进入第一个看下,定位到了一个 JavaScript 文件,如图所示。
这时候可以看到整个代码都是压缩过的,只有一行,不好看,我们可以点击左下角的 {} 按钮,美化一下 JavaScript 代码,如图所示。
美化后的结果就是这样子了,如图所示。
这时可以看到这里弹出来了一个新的选项卡,其名称是 JavaScript 文件名加上了 :formatted,代表格式化后代码结果,在这里我们再次定位到 token 观察一下。
可以看到这里有 limit、offset、token,然后观察下其他的逻辑,基本上能够确定这就是构造 Ajax 请求的地方了,如果不是的话可以继续搜索其他的文件观察下。
那现在,混淆的入口点我们就成功找到了,这是一个首选的找入口的方法。
由于这里的 token 字符串并没有被混淆,所以上面的这个方法是奏效的。之前我们也讲过,这种字符串由于非常容易成为找寻入口点的依据,所以这样的字符串也会被混淆成类似 Unicode、Base64、RC4 的一些编码形式,这样我们就没法轻松搜索到了。
那如果遇到这种情况,我们该怎么办呢?这里再介绍一种通过打 XHR 断点的方式来寻找入口。
XHR 断点,顾名思义,就是在发起 XHR 的时候进入断点调试模式,JavaScript 会在发起 Ajax 请求的时候停住,这时候我们可以通过当前的调用栈的逻辑顺着找到入口。怎么设置呢?我们可以在 Sources 选项卡的右侧,XHR/fetch Breakpoints 处添加一个断点选项。
首先点击 + 号,然后输入匹配的 URL 内容,由于 Ajax 接口的形式是 /api/movie/?limit=10... 这样的格式,所这里我们就截取一段填进去就好了,这里填的就是 /api/movie,如图所示。
添加完毕之后重新刷新页面,可以发现进入了断点模式,如图所示。
好,接下来我们重新点下 {} 格式化代码,看看断点是在哪里,如图所示。
那这里看到有个 send 的字符,我们可以初步猜测这就是相当于发送 Ajax 请求的一瞬间。
到了这里感觉 Ajax 马上就要发出去了,是不是有点太晚了,我们想找的是构造 Ajax 的时刻来分析 Ajax 参数啊!不用担心,这里我们通过调用栈就可以找回去。我们点击右侧的 Call Stack,这里记录了 JavaScript 的方法逐层调用过程,如图所示。
这里当前指向的是一个名字为 anonymouns,也就是匿名的调用,在它的下方就显示了调用这个 anonymouns 的方法,名字叫作 _0x594ca1,然后再下一层就又显示了调用 _0x594a1 这个方法的方法,依次类推。
这里我们可以逐个往下查找,然后通过一些观察看看有没有 token 这样的信息,就能找到对应的位置了,最后我们就可以找到 onFetchData 这个方法里面实现了这个 token 的构造逻辑,这样我们也成功找到 token 的参数构造的位置了,如图所示。
好,到现在为止我们就通过两个方法找到入口点了。
其实还有其他的寻找入口的方式,比如 Hook 关键函数的方式,稍后的课程里我们会讲到,这里就暂时不讲了。
接下来我们已经找到 token 的位置了,可以观察一下这个 token 对应的变量叫作 _0xa70fc9,所以我们的关键就是要找这个变量是哪里来的了。
怎么找呢?我们打个断点看下这个变量是在哪里生成的就好了,我们在对应的行打一个断点,如果打了刚才的 XHR 断点的话可以先取消掉,如图所示。
这时候我们就设置了一个新的断点了。由于只有一个断点,可以重新刷新下网页,这时候我们会发现网页停在了新的断点上面。
这里我们就可以观察下运行的一些变量了,比如我们把鼠标放在各个变量上面去,可以看到变量的一些值和类型,比如我们看 _0x18b11a 这个变量,会有一个浮窗显示,如图所示。
另外我们还可以通过在右侧的 Watch 面板添加想要查看的变量名称,如这行代码的内容为:
, _0xa70fc9 = Object(_0x18b11a['a'])(this['$store']['state']['url']['index']);
我们比较感兴趣的可能就是 _0x18b11a 还有 this 里面的这个值了,我们可以展开 Watch 面板,然后点击 + 号,把想看的变量添加到 Watch 面板里面,如图所示。
观察下可以发现 _0x18b11a 是一个 Object,它有个 a 属性,其值是一个 function,然后 this['$store']['state']['url']['index'] 的值其实就是 /api/movie,就是 Ajax 请求 URL 的 Path。_0xa70fc9 就是调用了前者这个 function 然后传入了 /api/movie 得到的。
那么下一步就是去寻找这个 function 在哪里了,我们可以把 Watch 面板的 _0x18b11a 展开,这里会显示一个 FunctionLocation,就是这个 function 的代码位置,如图所示。
点击进入之后发现其仍然是未格式化的代码,再次点击 {} 格式化代码。
这时候我们就进入了一个新的名字为 _0xc9e475 的方法里面,这个方法里面应该就是 token 的生成逻辑了,我们再打上断点,然后执行面板右上角蓝色箭头状的 Resume 按钮,如图所示。
这时候发现我们已经单步执行到这个位置了。
接下来我们不断进行单步调试,观察这里面的执行逻辑和每一步调试过程中结果都有什么变化,如图所示。
在每步的执行过程中,我们可以发现一些运行值会被打到代码的右侧并带有高亮表示,同时在 watch 面板还能看到每步的变量的具体结果。
最后我们总结出这个 token 的构造逻辑如下:
传入的 /api/movie 会构造一个初始化列表,变量命名为 _0x3dde76。
获取当前的时间戳,命名为 _0x4c50b4,push 到 _0x3dde76 这个变量里面。
将 _0x3dde76 变量用“,”拼接,然后进行 SHA1 编码,命名为 _0x46ba68。
将 _0x46ba68 (SHA1 编码的结果)和 _0x4c50b4 (时间戳)用逗号拼接,命名为 _0x495a44。
将 _0x495a44 进行 Base64 编码,命名为 _0x2a93f2,得到最后的 token。
以上的一些逻辑经过反复的观察就可以比较轻松地总结出来了,其中有些变量可以实时查看,同时也可以自己输入到控制台上进行反复验证,相信总结出这个结果并不难。
好,那现在加密逻辑我们就分析出来啦,基本的思路就是:
先将 /api/movie 放到一个列表里面;
列表中加入当前时间戳;
将列表内容用逗号拼接;
将拼接的结果进行 SHA1 编码;
将编码的结果和时间戳再次拼接;
将拼接后的结果进行 Base64 编码。
验证下逻辑没问题的话,我们就可以用 Python 来实现出来啦。
要用 Python 实现这个逻辑,我们需要借助于两个库,一个是 hashlib,它提供了 sha1 方法;另外一个是 base64 库,它提供了 b64encode 方法对结果进行 Base64 编码。
代码实现如下:
import hashlibimport timeimport base64from typing import List, Anyimport requestsINDEX\_URL = 'https://dynamic6.scrape.center/api/movie?limit={limit}&offset={offset}&token={token}'LIMIT = 10OFFSET = 0def get\_token(args: List[Any]):timestamp = str(int(time.time()))args.append(timestamp)sign = hashlib.sha1(','.join(args).encode('utf-8')).hexdigest()return base64.b64encode(','.join([sign, timestamp]).encode('utf-8')).decode('utf-8')args = ['/api/movie']token = get\_token(args=args)index\_url = INDEX\_URL.format(limit=LIMIT, offset=OFFSET, token=token)response = requests.get(index\_url)print('response', response.json())
这里我们就根据上面的逻辑把加密流程实现出来了,这里我们先模拟爬取了第一页的内容,最后运行一下就可以得到最终的输出结果了。
好,我们接着上一课时的内容往下讲,我们观察下上一步的输出结果,我们把结果格式化一下,看看部分结果:
{ 'count': 100, 'results': [ { 'id': 1, 'name': '霸王别姬', 'alias': 'Farewell My Concubine', 'cover': 'https://p0.meituan.net/movie/ce4da3e03e655b5b88ed31b5cd7896cf62472.jpg@464w_644h_1e_1c', 'categories': [ '剧情', '爱情' ], 'published_at': '1993-07-26', 'minute': 171, 'score': 9.5, 'regions': [ '中国大陆', '中国香港' ] }, ...]}
这里我们看到有个 id 是 1,另外还有一些其他的字段如电影名称、封面、类别,等等,那么这里面一定有什么信息是用来唯一区分某个电影的。
但是呢,这里我们点击下第一个部电影的信息,可以看到它跳转到了 URL 为 https://dynamic6.scrape.center/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWIx 的页面,可以看到这里 URL 里面有一个加密 id 为 ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWIx,那么这个和电影的这些信息有什么关系呢?
这里,如果你仔细观察其实是可以比较容易地找出规律来的,但是这总归是观察出来的,如果遇到一些观察不出规律的那就不好处理了。所以还是需要靠技巧去找到它真正加密的位置。
这时候我们该怎么办呢?首先为我们分析一下,这个加密 id 到底是什么生成的。
我们在点击详情页的时候就看到它访问的 URL 里面就带上了 ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWIx 这个加密 id 了,而且不同的详情页的加密 id 是不同的,这说明这个加密 id 的构造依赖于列表页 Ajax 的返回结果,所以可以确定这个加密 id 的生成是发生在 Ajax 请求完成后或者点击详情页的一瞬间。
为了进一步确定是发生在何时,我们看看页面源码,可以看到在没有点击之前,详情页链接的 href 里面就已经带有加密 id 了,如图所示。
由此我们可以确定,这个加密 id 是在 Ajax 请求完成之后生成的,而且肯定也是由 JavaScript 生成的了。
那怎么再去查找 Ajax 完成之后的事件呢?是否应该去找 Ajax 完成之后的事件呢?
可以是可以,你可以试试,我们可以看到在 Sources 面板的右侧,有一个 Event Listener Breakpoints,这里有一个 XHR 的监听,包括发起时、成功后、发生错误时的一些监听,这里我们勾选上 readystatechange 事件,代表 Ajax 得到响应时的事件,其他的断点可以都删除了,然后刷新下页面看下,如图所示。
这里我们可以看到就停在了 Ajax 得到响应时的位置了。
那我们怎么才能弄清楚这个 id 是怎么加密的呢?可以选择一个断点一个断点地找下去,但估计找的过程会崩溃掉,因为这里可能会逐渐调用到页面 UI 渲染的一些底层实现,甚至可能即使找到了也不知道具体找到哪里去了。
那怎么办呢?这里我们再介绍一种定位的方法,那就是 Hook。
Hook 技术中文又叫作钩子技术,它就是在程序运行的过程中,对其中的某个方法进行重写,在原有的方法前后加入我们自定义的代码。相当于在系统没有调用该函数之前,钩子程序就先捕获该消息,可以先得到控制权,这时钩子函数便可以加工处理(改变)该函数的执行行为。
通俗点来说呢,比如我要 Hook 一个方法 a,可以先临时用一个变量存一下,把它存成 _a,然后呢,我再重新声明一个方法 a,里面添加自己的逻辑,比如加点调试语句、输出语句等等,然后再调用 _a,这里调用的 _a 就是之前的 a。
这样就相当于新的方法 a 里面混入了我们自己定义的逻辑,同时又把原来的方法 a 也执行了一遍。所以这不会影响原有的执行逻辑和运行效果,但是我们通过这种改写便可以顺利在原来的 a 方法前后加上了我们自己的逻辑,这就是 Hook。
那么,我们这里怎么用 Hook 的方式来找到加密 id 的加密入口点呢?
想一下,这个加密 id 是一个 Base64 编码的字符串,那么生成过程中想必就调用了 JavaScript 的 Base64 编码的方法,这个方法名叫作 btoa,这个 btoa 方法可以将参数转化成 Base64 编码。当然 Base64 也有其他的实现方式,比如利用 crypto-js 这个库实现的,这个可能底层调用的就不是 btoa 方法了。
所以,其实现在并不确定是不是调用的 btoa 方法实现的 Base64 编码,那就先试试吧。要实现 Hook,其实关键在于将原来的方法改写,这里我们其实就是 Hook btoa 这个方法了,btoa 这个方法属于 window 对象,我们将 window 对象的 btoa 方法进行改写即可。
改写的逻辑如下:
(function () { 'use strict' function hook(object, attr) { var func = object[attr] object[attr] = function () { console.log('hooked', object, attr, arguments) var ret = func.apply(object, arguments) debugger console.log('result', ret) return ret } } hook(window, 'btoa')})()
我们定义了一个 hook 方法,传入 object 和 attr 参数,意思就是 Hook object 对象的 attr 参数。例如我们如果想 Hook 一个 alert 方法,那就把 object 设置为 window,把 attr 设置为 alert 字符串。这里我们想要 Hook Base64 的编码方法,那么这里就只需要 Hook window 对象的 btoa 方法就好了。
我们来看下,首先是 var func = object[attr],相当于先把它赋值为一个变量,我们调用 func 方法就可以实现和原来相同的功能。接着,我们再直接改写这个方法的定义,直接改写 object[attr],将其改写成一个新的方法,在新的方法中,通过 func.apply 方法又重新调用了原来的方法。
这样我们就可以保证,前后方法的执行效果是不受什么影响的,之前这个方法该干啥就还是干啥的。但是和之前不同的是,我们自定义方法之后,现在可以在 func 方法执行的前后,再加入自己的代码,如 console.log 将信息输出到控制台,如 debugger 进入断点等等。
这个过程中,我们先临时保存下来了 func 方法,然后定义一个新的方法,接管程序控制权,在其中自定义我们想要的实现,同时在新的方法里面再重新调回 func 方法,保证前后结果是不受影响的。所以,我们达到了在不影响原有方法效果的前提下,可以实现在方法的前后实现自定义的功能,就是 Hook 的完整实现过程。
最后,我们调用 hook 方法,传入 window 对象和 btoa 字符串即可。
那这样,怎么去注入这个代码呢?这里我们介绍三种注入方法。
直接控制台注入;
复写 JavaScript 代码;
Tampermonkey 注入。
对于我们这个场景,控制台注入其实就够了,我们先来介绍这个方法。
其实控制台注入很简单,就是直接在控制台输入这行代码运行,如图所示。
执行完这段代码之后,相当于我们就已经把 window 的 btoa 方法改写了,可以控制台调用下 btoa 方法试试,如:
btoa('germey')
回车之后就可以看到它进入了我们自定义的 debugger 的位置停下了,如图所示。
我们把断点向下执行,点击 Resume 按钮,然后看看控制台的输出,可以看到也输出了一些对应的结果,如被 Hook 的对象,Hook 的属性,调用的参数,调用后的结果等,如图所示。
这里我们就可以看到,我们通过 Hook 的方式改写了 btoa 方法,使其每次在调用的时候都能停到一个断点,同时还能输出对应的结果。
接下来我们看下怎么用 Hook 找到对应的加密 id 的加密入口?
由于此时我们是在控制台直接输入的 Hook 代码,所以页面一旦刷新就无效了,但由于我们这个网站是 SPA 式的页面,所以在点击详情页的时候页面是不会整个刷新的,所以这段代码依然还会生效。但是如果不是 SPA 式的页面,即每次访问都需要刷新页面的网站,这种注入方式就不生效了。
好,那我们的目的是为了 Hook 列表页 Ajax 加载完成后的加密 id 的 Base64 编码的过程,那怎么在不刷新页面的情况下再次复现这个操作呢?很简单,点下一页就好了。
这时候我们可以点击第 2 页的按钮,可以看到它确实再次停到了 Hook 方法的 debugger 处,由于列表页的 Ajax 和加密 id 都会带有 Base64 编码的操作,因此它每一个都能 Hook 到,通过观察对应的 Arguments 或当前网站的行为或者观察栈信息,我们就能大体知道现在走到了哪个位置了,从而进一步通过栈的调用信息找到调用 Base64 编码的位置。
我们可以根据调用栈的信息来观察这些变量是在哪一层发生变化的,比如最后的这一层,我们可以很明显看到它执行了 Base64 编码,编码前的结果是:
ef34#teuq0btua#(-57w1q5o5--j@98xygimlyfxs\*-!i-0-mb1
编码后的结果是:
ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWIx
如图所示。
这里很明显。
那么核心问题就来了,编码前的结果 ef34#teuq0btua#(-57w1q5o5--j@98xygimlyfxs*-!i-0-mb1又是怎么来的呢?我们展开栈的调用信息,一层层看看这个字符串的变化情况。如果不变那就看下一层,如果变了那就停下来仔细看看。
最后我们可以在第五层找到它的变化过程,如图所示。
那这里我们就一目了然了,看到了 _0x135c4d 是一个写死的字符串 ef34#teuq0btua#(-57w1q5o5--j@98xygimlyfxs*-!i-0-mb,然后和传入的这个 _0x565f18 拼接起来就形成了最后的字符串。
那这个 _0x565f18 又是怎么来的呢?再往下追一层,那就一目了然了,其实就是 Ajax 返回结果的单个电影信息的 id。
所以,这个加密逻辑的就清楚了,其实非常简单,就是 ef34#teuq0btua#(-57w1q5o5--j@98xygimlyfxs*-!i-0-mb1 加上电影 id,然后 Base64 编码即可。
到此,我们就成功用 Hook 的方式找到加密的 id 生成逻辑了。
但是想想有什么不太科学的地方吗?刚才其实也说了,我们的 Hook 代码是在控制台手动输入的,一旦刷新页面就不生效了,这的确是个问题。而且它必须是在页面加载完了才注入的,所以它并不能在一开始就生效。
下面我们再介绍几种 Hook 注入方式
我们可以借助于 Chrome 浏览器的 Overrides 功能实现某些 JavaScript 文件的重写和保存,它会在本地生成一个 JavaScript 文件副本,以后每次刷新的时候会使用副本的内容。
这里我们需要切换到 Sources 选项卡的 Overrides 选项卡,然后选择一个文件夹,比如这里我自定了一个文件夹名字叫作 modify,如图所示。
然后我们随便选一个 JavaScript 脚本,后面贴上这段注入脚本,如图所示。
保存文件。此时可能提示页面崩溃,但是不用担心,重新刷新页面就好了,这时候我们就发现现在浏览器加载的 JavaScript 文件就是我们修改过后的了,文件的下方会有一个标识符,如图所示。
同时我们还注意到这时候它就直接进入了断点模式,成功 Hook 到了 btoa 这个方法了。其实 Overrides 的这个功能非常有用,有了它我们可以持久化保存我们任意修改的 JavaScript 代码,所以我们想在哪里改都可以了,甚至可以直接修改 JavaScript 的原始执行逻辑也都是可以的。
如果我们不想用 Overrides 的方式改写 JavaScript 的方式注入的话,还可以借助于浏览器插件来实现注入,这里推荐的浏览器插件叫作 Tampermonkey,中文叫作“油猴”。它是一款浏览器插件,支持 Chrome。利用它我们可以在浏览器加载页面时自动执行某些 JavaScript 脚本。由于执行的是 JavaScript,所以我们几乎可以在网页中完成任何我们想实现的效果,如自动爬虫、自动修改页面、自动响应事件等等。
首先我们需要安装 Tampermonkey,这里我们使用的浏览器是 Chrome。直接在 Chrome 应用商店或者在 Tampermonkey 的官网 https://www.tampermonkey.net/ 下载安装即可。
安装完成之后,在 Chrome 浏览器的右上角会出现 Tampermonkey 的图标,这就代表安装成功了。
我们也可以自己编写脚本来实现想要的功能。编写脚本难不难呢?其实就是写 JavaScript 代码,只要懂一些 JavaScript 的语法就好了。另外除了懂 JavaScript 语法,我们还需要遵循脚本的一些写作规范,这其中就包括一些参数的设置。
下面我们就简单实现一个小的脚本,实现某个功能。
首先我们可以点击 Tampermonkey 插件图标,点击“管理面板”按钮,打开脚本管理页面。
界面类似显示如下图所示。
在这里显示了我们已经有的一些 Tampermonkey 脚本,包括我们自行创建的,也包括从第三方网站下载安装的。
另外这里也提供了编辑、调试、删除等管理功能,我们可以方便地对脚本进行管理。接下来我们来创建一个新的脚本来试试,点击左侧的“+”号,会显示如图所示的页面。
初始化的代码如下:
// ==UserScript==// @name New Userscript// @namespace http://tampermonkey.net/// @version 0.1// @description try to take over the world!// @author You// @match https://www.tampermonkey.net/documentation.php?ext=dhdg// @grant none// ==/UserScript==(function() { 'use strict'; // Your code here...})();
这里最上面是一些注释,但这些注释是非常有用的,这部分内容叫作 UserScript Header ,我们可以在里面配置一些脚本的信息,如名称、版本、描述、生效站点等等。
在 UserScript Header 下方是 JavaScript 函数和调用的代码,其中 use strict 标明代码使用 JavaScript 的严格模式,在严格模式下可以消除 Javascript 语法的一些不合理、不严谨之处,减少一些怪异行为,如不能直接使用未声明的变量,这样可以保证代码的运行安全,同时提高编译器的效率,提高运行速度。在下方 // Your code here... 这里我们就可以编写自己的代码了。
我们可以将脚本改写为如下内容:
// ==UserScript==// @name HookBase64// @namespace https://scrape.center/// @version 0.1// @description Hook Base64 encode function// @author Germey// @match https://dynamic6.scrape.center/// @grant none// @run-at document-start// ==/UserScript==(function () { 'use strict' function hook(object, attr) { var func = object[attr] console.log('func', func) object[attr] = function () { console.log('hooked', object, attr) var ret = func.apply(object, arguments) debugger return ret } } hook(window, 'btoa')})()
这时候启动脚本,重新刷新页面,可以发现也可以成功 Hook 住 btoa 方法,如图所示。
然后我们再顺着找调用逻辑就好啦。
以上,我们就成功通过 Hook 的方式找到加密 id 的实现了。
现在我们已经找到详情页的加密 id 了,但是还差一步,其 Ajax 请求也有一个 token,如图所示。
其实这个 token 和详情页的 token 构造逻辑是一样的了。
这里就不再展开说了,可以运用上文的几种找入口的方法来找到对应的加密逻辑。
现在我们已经成功把详情页的加密 id 和 Ajax 请求的 token 找出来了,下一步就能使用 Python 完成爬取了,这里我就只实现第一页的爬取了,代码示例如下:
import hashlibimport timeimport base64from typing import List, Anyimport requestsINDEX_URL = 'https://dynamic6.scrape.center/api/movie?limit={limit}&offset={offset}&token={token}'DETAIL_URL = 'https://dynamic6.scrape.center/api/movie/{id}?token={token}'LIMIT = 10OFFSET = 0SECRET = 'ef34#teuq0btua#(-57w1q5o5--j@98xygimlyfxs*-!i-0-mb'def get_token(args: List[Any]): timestamp = str(int(time.time())) args.append(timestamp) sign = hashlib.sha1(','.join(args).encode('utf-8')).hexdigest() return base64.b64encode(','.join([sign, timestamp]).encode('utf-8')).decode('utf-8') args = ['/api/movie']token = get_token(args=args)index_url = INDEX_URL.format(limit=LIMIT, offset=OFFSET, token=token)response = requests.get(index_url)print('response', response.json())result = response.json()for item in result['results']: id = item['id'] encrypt_id = base64.b64encode((SECRET + str(id)).encode('utf-8')).decode('utf-8') args = [f'/api/movie/{encrypt_id}'] token = get_token(args=args) detail_url = DETAIL_URL.format(id=encrypt_id, token=token) response = requests.get(detail_url) print('response', response.json())
这里模拟了详情页的加密 id 和 token 的构造过程,然后请求了详情页的 Ajax 接口,这样我们就可以爬取到详情页的内容了。
本课时内容很多,一步步介绍了整个网站的 JavaScript 逆向过程,其中的技巧有:
全局搜索查找入口
代码格式化
XHR 断点
变量监听
断点设置和跳过
栈查看
Hook 原理
Hook 注入
Overrides 功能
Tampermonkey 插件
Python 模拟实现
掌握了这些技巧我们就能更加得心应手地实现 JavaScript 逆向分析。
]]>学习爬虫,最基础的便是模拟浏览器向服务器发出请求,那么我们需要从什么地方做起呢?请求需要我们自己来构造吗?需要关心请求这个数据结构的实现吗?需要了解 HTTP、TCP、IP 层的网络传输通信吗?需要知道服务器的响应和应答原理吗?
可能你无从下手,不过不用担心,Python 的强大之处就是提供了功能齐全的类库来帮助我们完成这些请求。利用 Python 现有的库我们可以非常方便地实现网络请求的模拟,常见的库有 urllib、requests 等。
拿 requests 这个库来说,有了它,我们只需要关心请求的链接是什么,需要传的参数是什么,以及如何设置可选的参数就好了,不用深入到底层去了解它到底是怎样传输和通信的。有了它,两行代码就可以完成一个请求和响应的处理过程,非常方便地得到网页内容。
接下来,就让我们用 Python 的 requests 库开始我们的爬虫之旅吧。
首先,requests 库是 Python 的一个第三方库,不是自带的。所以我们需要额外安装。
在这之前需要你先安装好 Python3 环境,如 Python 3.6 版本,如若没有安装可以参考:https://cuiqingcai.com/5059.html。
安装好 Python3 之后,我们使用 pip3 即可轻松地安装好 requests 库:
pip3 install requests
更详细的安装方式可以参考:https://cuiqingcai.com/5132.html。
安装完成之后,我们就可以开始我们的网络爬虫之旅了。
用 Python 写爬虫的第一步就是模拟发起一个请求,把网页的源代码获取下来。
当我们在浏览器中输入一个 URL 并回车,实际上就是让浏览器帮我们发起一个 GET 类型的 HTTP 请求,浏览器得到源代码后,把它渲染出来就可以看到网页内容了。
那如果我们想用 requests 来获取源代码,应该怎么办呢?很简单,requests 这个库提供了一个 get 方法,我们调用这个方法,并传入对应的 URL 就能得到网页的源代码。
比如这里有一个示例网站:https://static1.scrape.center/,其内容如下:
这个网站展示了一些电影数据,如果我们想要把这个网页里面的数据爬下来,比如获取各个电影的名称、上映时间等信息,然后把它存下来的话,该怎么做呢?
第一步当然就是获取它的网页源代码了。
我们可以用 requests 这个库轻松地完成这个过程,代码的写法是这样的:
import requestsr = requests.get('https://static1.scrape.center/')print(r.text)
运行结果如下:
<html lang="en"><head> <meta charset="utf-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=device-width,initial-scale=1"> <link rel="icon" href="/static/img/favicon.ico"> <title>Scrape | Movie</title> <link href="/static/css/app.css" type="text/css" rel="stylesheet"> <link href="/static/css/index.css" type="text/css" rel="stylesheet"></head><body><div id="app">...<div data-v-7f856186="" id="index"> <div data-v-7f856186="" class="el-row"> <div data-v-7f856186="" class="el-col el-col-18 el-col-offset-3"> <div data-v-7f856186="" class="el-card item m-t is-hover-shadow"> <div class="el-card__body"> <div data-v-7f856186="" class="el-row"> <div data-v-7f856186="" class="el-col el-col-24 el-col-xs-8 el-col-sm-6 el-col-md-4"> <a data-v-7f856186="" href="/detail/1" class=""> <img data-v-7f856186="" src="https://p0.meituan.net/movie/283292171619cdfd5b240c8fd093f1eb255670.jpg@464w_644h_1e_1c" class="cover"> </a> </div> <div data-v-7f856186="" class="p-h el-col el-col-24 el-col-xs-9 el-col-sm-13 el-col-md-16"> <a data-v-7f856186="" href="/detail/1" class=""> <h2 data-v-7f856186="" class="m-b-sm">肖申克的救赎 - The Shawshank Redemption</h2> </a> <div data-v-7f856186="" class="categories"> <button data-v-7f856186="" type="button" class="el-button category el-button--primary el-button--mini"> <span>剧情</span> </button> <button data-v-7f856186="" type="button" class="el-button category el-button--primary el-button--mini"> <span>犯罪</span> </button> </div> <div data-v-7f856186="" class="m-v-sm info"> <span data-v-7f856186="">美国</span> <span data-v-7f856186=""> / </span> <span data-v-7f856186="">142 分钟</span> </div> <div data-v-7f856186="" class="m-v-sm info"> <span data-v-7f856186="">1994-09-10 上映</span> </div> </div> </div> </div> </div> </div> </div> ...</div></div></body>
由于网页内容比较多,这里省略了大部分内容。
不过看运行结果,我们已经成功获取网页的 HTML 源代码,里面包含了电影的标题、类型、上映时间,等等。把网页源代码获取下来之后,下一步我们把想要的数据提取出来,数据的爬取就完成了。
这个实例的目的是让你体会一下 requests 这个库能帮我们实现什么功能。我们仅仅用 requests 的 get 方法就成功发起了一个 GET 请求,把网页源代码获取下来了,是不是很方便呢?
HTTP 中最常见的请求之一就是 GET 请求,下面我们来详细了解利用 requests 库构建 GET 请求的方法。
我们换一个示例网站,其 URL 为 http://httpbin.org/get,如果客户端发起的是 GET 请求的话,该网站会判断并返回相应的请求信息,包括 Headers、IP 等。
我们还是用相同的方法来发起一个 GET 请求,代码如下:
import requestsr = requests.get('http://httpbin.org/get')print(r.text)
运行结果如下:
{"args": {}, "headers": { "Accept": "*/*", "Accept-Encoding": "gzip, deflate", "Host": "httpbin.org", "User-Agent": "python-requests/2.10.0" }, "origin": "122.4.215.33", "url": "http://httpbin.org/get"}
可以发现,我们成功发起了 GET 请求,也通过这个网站的返回结果得到了请求所携带的信息,包括 Headers、URL、IP,等等。
对于 GET 请求,我们知道 URL 后面是可以跟上一些参数的,如果我们现在想添加两个参数,其中 name 是 germey,age 是 25,URL 就可以写成如下内容:
http://httpbin.org/get?name=germey&age=25
要构造这个请求链接,是不是要直接写成这样呢?
r = requests.get('http://httpbin.org/get?name=germey&age=25')
这样也可以,但如果这些参数还需要我们手动拼接,未免有点不人性化。
一般情况下,这种信息我们利用 params 这个参数就可以直接传递了,示例如下:
import requestsdata = { 'name': 'germey', 'age': 25}r = requests.get('http://httpbin.org/get', params=data)print(r.text)
运行结果如下:
{ "args": { "age": "25", "name": "germey" }, "headers": { "Accept": "*/*", "Accept-Encoding": "gzip, deflate", "Host": "httpbin.org", "User-Agent": "python-requests/2.10.0" }, "origin": "122.4.215.33", "url": "http://httpbin.org/get?age=22&name=germey"}
在这里我们把 URL 参数通过字典的形式传给 get 方法的 params 参数,通过返回信息我们可以判断,请求的链接自动被构造成了:http://httpbin.org/get?age=22&name=germey,这样我们就不用再去自己构造 URL 了,非常方便。
另外,网页的返回类型实际上是 str 类型,但是它很特殊,是 JSON 格式的。所以,如果想直接解析返回结果,得到一个 JSON 格式的数据的话,可以直接调用 json 方法。
示例如下:
import requestsr = requests.get('http://httpbin.org/get')print(type(r.text))print(r.json())print(type(r.json()))
运行结果如下:
<class'str'>{'headers': {'Accept-Encoding': 'gzip, deflate', 'Accept': '*/*', 'Host': 'httpbin.org', 'User-Agent': 'python-requests/2.10.0'}, 'url': 'http://httpbin.org/get', 'args': {}, 'origin': '182.33.248.131'}<class 'dict'>
可以发现,调用 json 方法,就可以将返回结果是 JSON 格式的字符串转化为字典。
但需要注意的是,如果返回结果不是 JSON 格式,便会出现解析错误,抛出 json.decoder.JSONDecodeError 异常。
上面的请求链接返回的是 JSON 形式的字符串,那么如果请求普通的网页,则肯定能获得相应的内容了。下面以本课时最初的实例页面为例,我们再加上一点提取信息的逻辑,将代码完善成如下的样子:
import requestsimport rer = requests.get('https://static1.scrape.center/')pattern = re.compile('<h2.*?>(.*?)</h2>', re.S)titles = re.findall(pattern, r.text)print(titles)
在这个例子中我们用到了最基础的正则表达式来匹配出所有的标题。关于正则表达式的相关内容,我们会在下一课时详细介绍,这里作为实例来配合讲解。
运行结果如下:
['肖申克的救赎 - The Shawshank Redemption', '霸王别姬 - Farewell My Concubine', '泰坦尼克号 - Titanic', '罗马假日 - Roman Holiday', '这个杀手不太冷 - Léon', '魂断蓝桥 - Waterloo Bridge', '唐伯虎点秋香 - Flirting Scholar', '喜剧之王 - The King of Comedy', '楚门的世界 - The Truman Show', '活着 - To Live']
我们发现,这里成功提取出了所有的电影标题。一个最基本的抓取和提取流程就完成了。
在上面的例子中,我们抓取的是网站的一个页面,实际上它返回的是一个 HTML 文档。如果想抓取图片、音频、视频等文件,应该怎么办呢?
图片、音频、视频这些文件本质上都是由二进制码组成的,由于有特定的保存格式和对应的解析方式,我们才可以看到这些形形色色的多媒体。所以,想要抓取它们,就要拿到它们的二进制数据。
下面以 GitHub 的站点图标为例来看一下:
import requestsr = requests.get('https://github.com/favicon.ico')print(r.text)print(r.content)
这里抓取的内容是站点图标,也就是在浏览器每一个标签上显示的小图标,如图所示:
这里打印了 Response 对象的两个属性,一个是 text,另一个是 content。
运行结果如图所示,其中前两行是 r.text 的结果,最后一行是 r.content 的结果。
可以注意到,前者出现了乱码,后者结果前带有一个 b,这代表是 bytes 类型的数据。
由于图片是二进制数据,所以前者在打印时转化为 str 类型,也就是图片直接转化为字符串,这当然会出现乱码。
上面返回的结果我们并不能看懂,它实际上是图片的二进制数据,没关系,我们将刚才提取到的信息保存下来就好了,代码如下:
import requestsr = requests.get('https://github.com/favicon.ico')with open('favicon.ico', 'wb') as f: f.write(r.content)
这里用了 open 方法,它的第一个参数是文件名称,第二个参数代表以二进制的形式打开,可以向文件里写入二进制数据。
运行结束之后,可以发现在文件夹中出现了名为 favicon.ico 的图标,如图所示。
这样,我们就把二进制数据成功保存成一张图片了,这个小图标就被我们成功爬取下来了。
同样地,音频和视频文件我们也可以用这种方法获取。
我们知道,在发起一个 HTTP 请求的时候,会有一个请求头 Request Headers,那么这个怎么来设置呢?
很简单,我们使用 headers 参数就可以完成了。
在刚才的实例中,实际上我们是没有设置 Request Headers 信息的,如果不设置,某些网站会发现这不是一个正常的浏览器发起的请求,网站可能会返回异常的结果,导致网页抓取失败。
要添加 Headers 信息,比如我们这里想添加一个 User-Agent 字段,我们可以这么来写:
import requestsheaders = { 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/52.0.2743.116 Safari/537.36'}r = requests.get('https://static1.scrape.center/', headers=headers)print(r.text)
当然,我们可以在 headers 这个参数中任意添加其他的字段信息。
前面我们了解了最基本的 GET 请求,另外一种比较常见的请求方式是 POST。使用 requests 实现 POST 请求同样非常简单,示例如下:
import requestsdata = {'name': 'germey', 'age': '25'}r = requests.post("http://httpbin.org/post", data=data)print(r.text)
这里还是请求 http://httpbin.org/post,该网站可以判断如果请求是 POST 方式,就把相关请求信息返回。
运行结果如下:
{ "args": {}, "data": "", "files": {}, "form": { "age": "25", "name": "germey" }, "headers": { "Accept": "*/*", "Accept-Encoding": "gzip, deflate", "Content-Length": "18", "Content-Type": "application/x-www-form-urlencoded", "Host": "httpbin.org", "User-Agent": "python-requests/2.22.0", "X-Amzn-Trace-Id": "Root=1-5e5bdc26-b40d7e9862e3715f689cb5e6" }, "json": null, "origin": "167.220.232.237", "url": "http://httpbin.org/post"}
可以发现,我们成功获得了返回结果,其中 form 部分就是提交的数据,这就证明 POST 请求成功发送了。
发送请求后,得到的自然就是响应,即 Response。
在上面的实例中,我们使用 text 和 content 获取了响应的内容。此外,还有很多属性和方法可以用来获取其他信息,比如状态码、响应头、Cookies 等。示例如下:
import requestsr = requests.get('https://static1.scrape.center/')print(type(r.status_code), r.status_code)print(type(r.headers), r.headers)print(type(r.cookies), r.cookies)print(type(r.url), r.url)print(type(r.history), r.history)
这里分别打印输出 status_code 属性得到状态码,输出 headers 属性得到响应头,输出 cookies 属性得到 Cookies,输出 url 属性得到 URL,输出 history 属性得到请求历史。
运行结果如下:
<class 'int'> 200<class 'requests.structures.CaseInsensitiveDict'> {'Server': 'nginx/1.17.8', 'Date': 'Sun, 01 Mar 2020 13:31:54 GMT', 'Content-Type': 'text/html; charset=utf-8', 'Transfer-Encoding': 'chunked', 'Connection': 'keep-alive', 'Vary': 'Accept-Encoding', 'X-Frame-Options': 'SAMEORIGIN', 'Strict-Transport-Security': 'max-age=15724800; includeSubDomains', 'Content-Encoding': 'gzip'}<class 'requests.cookies.RequestsCookieJar'> <RequestsCookieJar[]><class 'str'> https://static1.scrape.center/<class 'list'> []
可以看到,headers 和 cookies 这两个属性得到的结果分别是 CaseInsensitiveDict 和 RequestsCookieJar 类型。
在第一课时我们知道,状态码是用来表示响应状态的,比如返回 200 代表我们得到的响应是没问题的,上面的例子正好输出的结果也是 200,所以我们可以通过判断 Response 的状态码来确认是否爬取成功。
requests 还提供了一个内置的状态码查询对象 requests.codes,用法示例如下:
import requestsr = requests.get('https://static1.scrape.center/')exit() if not r.status_code == requests.codes.ok else print('Request Successfully')
这里通过比较返回码和内置的成功的返回码,来保证请求得到了正常响应,输出成功请求的消息,否则程序终止,这里我们用 requests.codes.ok 得到的是成功的状态码 200。
这样的话,我们就不用再在程序里面写状态码对应的数字了,用字符串表示状态码会显得更加直观。
当然,肯定不能只有 ok 这个条件码。
下面列出了返回码和相应的查询条件:
# 信息性状态码100: ('continue',),101: ('switching_protocols',),102: ('processing',),103: ('checkpoint',),122: ('uri_too_long', 'request_uri_too_long'),# 成功状态码200: ('ok', 'okay', 'all_ok', 'all_okay', 'all_good', '\\o/', '✓'),201: ('created',),202: ('accepted',),203: ('non_authoritative_info', 'non_authoritative_information'),204: ('no_content',),205: ('reset_content', 'reset'),206: ('partial_content', 'partial'),207: ('multi_status', 'multiple_status', 'multi_stati', 'multiple_stati'),208: ('already_reported',),226: ('im_used',),# 重定向状态码300: ('multiple_choices',),301: ('moved_permanently', 'moved', '\\o-'),302: ('found',),303: ('see_other', 'other'),304: ('not_modified',),305: ('use_proxy',),306: ('switch_proxy',),307: ('temporary_redirect', 'temporary_moved', 'temporary'),308: ('permanent_redirect', 'resume_incomplete', 'resume',), # These 2 to be removed in 3.0# 客户端错误状态码400: ('bad_request', 'bad'),401: ('unauthorized',),402: ('payment_required', 'payment'),403: ('forbidden',),404: ('not_found', '-o-'),405: ('method_not_allowed', 'not_allowed'),406: ('not_acceptable',),407: ('proxy_authentication_required', 'proxy_auth', 'proxy_authentication'),408: ('request_timeout', 'timeout'),409: ('conflict',),410: ('gone',),411: ('length_required',),412: ('precondition_failed', 'precondition'),413: ('request_entity_too_large',),414: ('request_uri_too_large',),415: ('unsupported_media_type', 'unsupported_media', 'media_type'),416: ('requested_range_not_satisfiable', 'requested_range', 'range_not_satisfiable'),417: ('expectation_failed',),418: ('im_a_teapot', 'teapot', 'i_am_a_teapot'),421: ('misdirected_request',),422: ('unprocessable_entity', 'unprocessable'),423: ('locked',),424: ('failed_dependency', 'dependency'),425: ('unordered_collection', 'unordered'),426: ('upgrade_required', 'upgrade'),428: ('precondition_required', 'precondition'),429: ('too_many_requests', 'too_many'),431: ('header_fields_too_large', 'fields_too_large'),444: ('no_response', 'none'),449: ('retry_with', 'retry'),450: ('blocked_by_windows_parental_controls', 'parental_controls'),451: ('unavailable_for_legal_reasons', 'legal_reasons'),499: ('client_closed_request',),# 服务端错误状态码500: ('internal_server_error', 'server_error', '/o\\', '✗'),501: ('not_implemented',),502: ('bad_gateway',),503: ('service_unavailable', 'unavailable'),504: ('gateway_timeout',),505: ('http_version_not_supported', 'http_version'),506: ('variant_also_negotiates',),507: ('insufficient_storage',),509: ('bandwidth_limit_exceeded', 'bandwidth'),510: ('not_extended',),511: ('network_authentication_required', 'network_auth', 'network_authentication')
比如,如果想判断结果是不是 404 状态,可以用 requests.codes.not_found 来比对。
刚才,我们了解了 requests 的基本用法,如基本的 GET、POST 请求以及 Response 对象。当然 requests 能做到的不仅这些,它几乎可以帮我们完成 HTTP 的所有操作。
下面我们再来了解下 requests 的一些高级用法,如文件上传、Cookies 设置、代理设置等。
我们知道 requests 可以模拟提交一些数据。假如有的网站需要上传文件,我们也可以用它来实现,示例如下:
import requestsfiles = {'file': open('favicon.ico', 'rb')}r = requests.post('http://httpbin.org/post', files=files)print(r.text)
在上一课时中我们保存了一个文件 favicon.ico,这次用它来模拟文件上传的过程。需要注意的是,favicon.ico 需要和当前脚本在同一目录下。如果有其他文件,当然也可以使用其他文件来上传,更改下代码即可。
运行结果如下:
{"args": {}, "data": "","files": {"file":"data:application/octet-stream;base64,AAAAAA...="},"form": {},"headers": {"Accept":"*/*","Accept-Encoding":"gzip, deflate","Content-Length":"6665","Content-Type":"multipart/form-data; boundary=809f80b1a2974132b133ade1a8e8e058","Host":"httpbin.org","User-Agent":"python-requests/2.10.0"},"json": null,"origin":"60.207.237.16","url":"http://httpbin.org/post"}
以上省略部分内容,这个网站会返回响应,里面包含 files 这个字段,而 form 字段是空的,这证明文件上传部分会单独有一个 files 字段来标识。
我们如果想用 requests 获取和设置 Cookies 也非常方便,只需一步即可完成。
我们先用一个实例看一下获取 Cookies 的过程:
import requestsr = requests.get('http://www.baidu.com')print(r.cookies)for key, value in r.cookies.items(): print(key + '=' + value)
运行结果如下:
<RequestsCookieJar[<Cookie BDORZ=27315 for .baidu.com/>]>BDORZ=27315
这里我们首先调用 cookies 属性即可成功得到 Cookies,可以发现它是 RequestCookieJar 类型。然后用 items 方法将其转化为元组组成的列表,遍历输出每一个 Cookie 的名称和值,实现 Cookie 的遍历解析。
当然,我们也可以直接用 Cookie 来维持登录状态,下面我们以 GitHub 为例来说明一下,首先我们登录 GitHub,然后将 Headers 中的 Cookie 内容复制下来,如图所示:
这里可以替换成你自己的 Cookie,将其设置到 Headers 里面,然后发送请求,示例如下:
import requestsheaders = { 'Cookie': '_octo=GH1.1.1849343058.1576602081; _ga=GA1.2.90460451.1576602111; __Host-user_session_same_site=nbDv62kHNjp4N5KyQNYZ208waeqsmNgxFnFC88rnV7gTYQw_; _device_id=a7ca73be0e8f1a81d1e2ebb5349f9075; user_session=nbDv62kHNjp4N5KyQNYZ208waeqsmNgxFnFC88rnV7gTYQw_; logged_in=yes; dotcom_user=Germey; tz=Asia%2FShanghai; has_recent_activity=1; _gat=1; _gh_sess=your_session_info', 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/53.0.2785.116 Safari/537.36',}r = requests.get('https://github.com/', headers=headers)print(r.text)
我们发现,结果中包含了登录后才能显示的结果,如图所示:
可以看到这里包含了我的 GitHub 用户名信息,你如果尝试同样可以得到你的用户信息。
得到这样类似的结果,说明我们用 Cookies 成功模拟了登录状态,这样我们就能爬取登录之后才能看到的页面了。
当然,我们也可以通过 cookies 参数来设置 Cookies 的信息,这里我们可以构造一个 RequestsCookieJar 对象,然后把刚才复制的 Cookie 处理下并赋值,示例如下:
import requestscookies = '_octo=GH1.1.1849343058.1576602081; _ga=GA1.2.90460451.1576602111; __Host-user_session_same_site=nbDv62kHNjp4N5KyQNYZ208waeqsmNgxFnFC88rnV7gTYQw_; _device_id=a7ca73be0e8f1a81d1e2ebb5349f9075; user_session=nbDv62kHNjp4N5KyQNYZ208waeqsmNgxFnFC88rnV7gTYQw_; logged_in=yes; dotcom_user=Germey; tz=Asia%2FShanghai; has_recent_activity=1; _gat=1; _gh_sess=your_session_info'jar = requests.cookies.RequestsCookieJar()headers = { 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/53.0.2785.116 Safari/537.36'}for cookie in cookies.split(';'): key, value = cookie.split('=', 1) jar.set(key, value)r = requests.get('https://github.com/', cookies=jar, headers=headers)print(r.text)
这里我们首先新建一个 RequestCookieJar 对象,然后将复制下来的 cookies 利用 split 方法分割,接着利用 set 方法设置好每个 Cookie 的 key 和 value,最后通过调用 requests 的 get 方法并传递给 cookies 参数即可。
测试后,发现同样可以正常登录。
在 requests 中,如果直接利用 get 或 post 等方法的确可以做到模拟网页的请求,但是这实际上是相当于不同的 Session,相当于你用两个浏览器打开了不同的页面。
设想这样一个场景,第一个请求利用 post 方法登录了某个网站,第二次想获取成功登录后的自己的个人信息,你又用了一次 get 方法去请求个人信息页面。实际上,这相当于打开了两个浏览器,是两个完全不相关的 Session,能成功获取个人信息吗?当然不能。
有人会问,我在两次请求时设置一样的 Cookies 不就行了?可以,但这样做起来很烦琐,我们有更简单的解决方法。
解决这个问题的主要方法就是维持同一个 Session,相当于打开一个新的浏览器选项卡而不是新开一个浏览器。但我又不想每次设置 Cookies,那该怎么办呢?这时候就有了新的利器 ——Session 对象。
利用它,我们可以方便地维护一个 Session,而且不用担心 Cookies 的问题,它会帮我们自动处理好。示例如下:
import requestsrequests.get('http://httpbin.org/cookies/set/number/123456789')r = requests.get('http://httpbin.org/cookies')print(r.text)
这里我们请求了一个测试网址 http://httpbin.org/cookies/set/number/123456789。请求这个网址时,可以设置一个 cookie,名称叫作 number,内容是 123456789,随后又请求了 http://httpbin.org/cookies,此网址可以获取当前的 Cookies。
这样能成功获取到设置的 Cookies 吗?试试看。
运行结果如下:
{ "cookies": {}}
这并不行。我们再用 Session 试试看:
import requestss = requests.Session()s.get('http://httpbin.org/cookies/set/number/123456789')r = s.get('http://httpbin.org/cookies')print(r.text)
再看下运行结果:
{ "cookies": {"number": "123456789"}}
成功获取!这下能体会到同一个Session和不同Session的区别了吧!
所以,利用 Session,可以做到模拟同一个 Session 而不用担心 Cookies 的问题。它通常用于模拟登录成功之后再进行下一步的操作。
现在很多网站都要求使用 HTTPS 协议,但是有些网站可能并没有设置好 HTTPS 证书,或者网站的 HTTPS 证书不被 CA 机构认可,这时候,这些网站可能就会出现 SSL 证书错误的提示。
比如这个示例网站:https://static2.scrape.center/。
如果我们用 Chrome 浏览器打开这个 URL,则会提示「您的连接不是私密连接」这样的错误,如图所示:
我们可以在浏览器中通过一些设置来忽略证书的验证。
但是如果我们想用 requests 来请求这类网站,会遇到什么问题呢?我们用代码来试一下:
import requestsresponse = requests.get('https://static2.scrape.center/')print(response.status_code)
运行结果如下:
requests.exceptions.SSLError: HTTPSConnectionPool(host='static2.scrape.center', port=443): Max retries exceeded with url: / (Caused by SSLError(SSLError("bad handshake: Error([('SSL routines', 'tls_process_server_certificate', 'certificate verify failed')])")))
可以看到,这里直接抛出了 SSLError 错误,原因就是因为我们请求的 URL 的证书是无效的。
那如果我们一定要爬取这个网站怎么办呢?我们可以使用 verify 参数控制是否验证证书,如果将其设置为 False,在请求时就不会再验证证书是否有效。如果不加 verify 参数的话,默认值是 True,会自动验证。
我们改写代码如下:
import requestsresponse = requests.get('https://static2.scrape.center/', verify=False)print(response.status_code)
这样就会打印出请求成功的状态码:
/usr/local/lib/python3.7/site-packages/urllib3/connectionpool.py:857: InsecureRequestWarning: Unverified HTTPS request is being made. Adding certificate verification is strongly advised. See: https://urllib3.readthedocs.io/en/latest/advanced-usage.html#ssl-warnings InsecureRequestWarning)200
不过我们发现报了一个警告,它建议我们给它指定证书。我们可以通过设置忽略警告的方式来屏蔽这个警告:
import requestsfrom requests.packages import urllib3urllib3.disable_warnings()response = requests.get('https://static2.scrape.center', verify=False)print(response.status_code)
或者通过捕获警告到日志的方式忽略警告:
import loggingimport requestslogging.captureWarnings(True)response = requests.get('https://static2.scrape.center/', verify=False)print(response.status_code)
当然,我们也可以指定一个本地证书用作客户端证书,这可以是单个文件(包含密钥和证书)或一个包含两个文件路径的元组:
import requestsresponse = requests.get('https://static2.scrape.center/', cert=('/path/server.crt', '/path/server.key'))print(response.status_code)
当然,上面的代码是演示实例,我们需要有 crt 和 key 文件,并且指定它们的路径。另外注意,本地私有证书的 key 必须是解密状态,加密状态的 key 是不支持的。
在本机网络状况不好或者服务器网络响应延迟甚至无响应时,我们可能会等待很久才能收到响应,甚至到最后收不到响应而报错。为了防止服务器不能及时响应,应该设置一个超时时间,即超过了这个时间还没有得到响应,那就报错。这需要用到 timeout 参数。这个时间的计算是发出请求到服务器返回响应的时间。示例如下:
import requestsr = requests.get('https://httpbin.org/get', timeout=1)print(r.status_code)
通过这样的方式,我们可以将超时时间设置为 1 秒,如果 1 秒内没有响应,那就抛出异常。
实际上,请求分为两个阶段,即连接(connect)和读取(read)。
上面设置的 timeout 将用作连接和读取这二者的 timeout 总和。
如果要分别指定,就可以传入一个元组:
r = requests.get('https://httpbin.org/get', timeout=(5, 30))
如果想永久等待,可以直接将 timeout 设置为 None,或者不设置直接留空,因为默认是 None。这样的话,如果服务器还在运行,但是响应特别慢,那就慢慢等吧,它永远不会返回超时错误的。其用法如下:
r = requests.get('https://httpbin.org/get', timeout=None)
或直接不加参数:
r = requests.get('https://httpbin.org/get')
在访问某些设置了身份认证的网站时,例如:https://static3.scrape.center/,我们可能会遇到这样的认证窗口,如图所示:
如果遇到了这种情况,那就是这个网站启用了基本身份认证,英文叫作 HTTP Basic Access Authentication,它是一种用来允许网页浏览器或其他客户端程序在请求时提供用户名和口令形式的身份凭证的一种登录验证方式。
如果遇到了这种情况,怎么用 reqeusts 来爬取呢,当然也有办法。
我们可以使用 requests 自带的身份认证功能,通过 auth 参数即可设置,示例如下:
import requestsfrom requests.auth import HTTPBasicAuthr = requests.get('https://static3.scrape.center/', auth=HTTPBasicAuth('admin', 'admin'))print(r.status_code)
这个示例网站的用户名和密码都是 admin,在这里我们可以直接设置。
如果用户名和密码正确的话,请求时会自动认证成功,返回 200 状态码;如果认证失败,则返回 401 状态码。
当然,如果参数都传一个 HTTPBasicAuth 类,就显得有点烦琐了,所以 requests 提供了一个更简单的写法,可以直接传一个元组,它会默认使用 HTTPBasicAuth 这个类来认证。
所以上面的代码可以直接简写如下:
import requestsr = requests.get('https://static3.scrape.center/', auth=('admin', 'admin'))print(r.status_code)
此外,requests 还提供了其他认证方式,如 OAuth 认证,不过此时需要安装 oauth 包,安装命令如下:
pip3 install requests_oauthlib
使用 OAuth1 认证的方法如下:
import requestsfrom requests_oauthlib import OAuth1url = 'https://api.twitter.com/1.1/account/verify_credentials.json'auth = OAuth1('YOUR_APP_KEY', 'YOUR_APP_SECRET', 'USER_OAUTH_TOKEN', 'USER_OAUTH_TOKEN_SECRET')requests.get(url, auth=auth)
更多详细的功能就可以参考 requests_oauthlib 的官方文档:https://requests-oauthlib.readthedocs.org/,在此就不再赘述了。
某些网站在测试的时候请求几次,能正常获取内容。但是对于大规模且频繁的请求,网站可能会弹出验证码,或者跳转到登录认证页面,更甚者可能会直接封禁客户端的 IP,导致一定时间段内无法访问。
为了防止这种情况发生,我们需要设置代理来解决这个问题,这就需要用到 proxies 参数。可以用这样的方式设置:
import requestsproxies = { 'http': 'http://10.10.10.10:1080', 'https': 'http://10.10.10.10:1080',}requests.get('https://httpbin.org/get', proxies=proxies)
当然,直接运行这个实例或许行不通,因为这个代理可能是无效的,可以直接搜索寻找有效的代理并替换试验一下。
若代理需要使用上文所述的身份认证,可以使用类似 http://user:password@host:port 这样的语法来设置代理,示例如下:
import requestsproxies = {'https': 'http://user:password@10.10.10.10:1080/',}requests.get('https://httpbin.org/get', proxies=proxies)
除了基本的 HTTP 代理外,requests 还支持 SOCKS 协议的代理。
首先,需要安装 socks 这个库:
pip3 install "requests[socks]"
然后就可以使用 SOCKS 协议代理了,示例如下:
import requestsproxies = { 'http': 'socks5://user:password@host:port', 'https': 'socks5://user:password@host:port'}requests.get('https://httpbin.org/get', proxies=proxies)
我们使用 requests 库的 get 和 post 方法可以直接发送请求,但你有没有想过,这个请求在 requests 内部是怎么实现的呢?
实际上,requests 在发送请求的时候在内部构造了一个 Request 对象,并给这个对象赋予了各种参数,包括 url、headers、data ,等等。然后直接把这个 Request 对象发送出去,请求成功后会再得到一个 Response 对象,再解析即可。
那么这个 Request 是什么类型呢?实际上它就是 Prepared Request。
我们深入一下,不用 get 方法,直接构造一个 Prepared Request 对象来试试,代码如下:
from requests import Request, Sessionurl = 'http://httpbin.org/post'data = {'name': 'germey'}headers = {'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/53.0.2785.116 Safari/537.36'}s = Session()req = Request('POST', url, data=data, headers=headers)prepped = s.prepare_request(req)r = s.send(prepped)print(r.text)
这里我们引入了 Request,然后用 url、data 和 headers 参数构造了一个 Request 对象,这时需要再调用 Session 的 prepare_request 方法将其转换为一个 Prepared Request 对象,然后调用 send 方法发送,运行结果如下:
{ "args": {}, "data": "", "files": {}, "form": { "name": "germey" }, "headers": { "Accept": "*/*", "Accept-Encoding": "gzip, deflate", "Content-Length": "11", "Content-Type": "application/x-www-form-urlencoded", "Host": "httpbin.org", "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/53.0.2785.116 Safari/537.36", "X-Amzn-Trace-Id": "Root=1-5e5bd6a9-6513c838f35b06a0751606d8" }, "json": null, "origin": "167.220.232.237", "url": "http://httpbin.org/post"}
可以看到,我们达到了同样的 POST 请求效果。
有了 Request 这个对象,就可以将请求当作独立的对象来看待,这样在一些场景中我们可以直接操作这个 Request 对象,更灵活地实现请求的调度和各种操作。
更多的用法可以参考 requests 的官方文档:http://docs.python-requests.org/。
在上个课时中,我们学会了如何用 Requests 来获取网页的源代码,得到 HTML 代码。但我们如何从 HTML 代码中获取真正想要的数据呢?
正则表达式就是一个有效的方法。
本课时中,我们将学习正则表达式的相关用法。正则表达式是处理字符串的强大工具,它有自己特定的语法结构。有了它,我们就能实现字符串的检索、替换、匹配验证。
当然,对于爬虫来说,有了它,要从 HTML 里提取想要的信息就非常方便了。
说了这么多,可能我们对正则表达式的概念还是比较模糊,下面就用几个实例来看一下正则表达式的用法。
打开开源中国提供的正则表达式测试工具 http://tool.oschina.net/regex/,输入待匹配的文本,然后选择常用的正则表达式,就可以得出相应的匹配结果了。
例如,输入下面这段待匹配的文本:
Hello, my phone number is 010-86432100 and email is cqc@cuiqingcai.com, and my website is https://cuiqingcai.com.
这段字符串中包含了一个电话号码和一个电子邮件,接下来就尝试用正则表达式提取出来,如图所示。
在网页右侧选择 “匹配 Email 地址”,就可以看到下方出现了文本中的 E-mail。如果选择 “匹配网址 URL”,就可以看到下方出现了文本中的 URL。是不是非常神奇?
其实,这里使用了正则表达式的匹配功能,也就是用一定规则将特定的文本提取出来。
比方说,电子邮件是有其特定的组成格式的:一段字符串 + @ 符号 + 某个域名。而 URL的组成格式则是协议类型 + 冒号加双斜线 + 域名和路径。
可以用下面的正则表达式匹配 URL:
[a-zA-z]+://[^\s]*
用这个正则表达式去匹配一个字符串,如果这个字符串中包含类似 URL 的文本,那就会被提取出来。
这个看上去乱糟糟的正则表达式其实有特定的语法规则。比如,a-z 匹配任意的小写字母,\s 匹配任意的空白字符,* 匹配前面任意多个字符。这一长串的正则表达式就是这么多匹配规则的组合。
写好正则表达式后,就可以拿它去一个长字符串里匹配查找了。不论这个字符串里面有什么,只要符合我们写的规则,统统可以找出来。对于网页来说,如果想找出网页源代码里有多少 URL,用 URL 的正则表达式去匹配即可。
下表中列出了常用的匹配规则:
模 式 | 描 述 |
---|---|
\w | 匹配字母、数字及下划线 |
\W | 匹配不是字母、数字及下划线的字符 |
\s | 匹配任意空白字符,等价于 [\t\n\r\f] |
\S | 匹配任意非空字符 |
\d | 匹配任意数字,等价于 [0~9] |
\D | 匹配任意非数字的字符 |
\A | 匹配字符串开头 |
\Z | 匹配字符串结尾,如果存在换行,只匹配到换行前的结束字符串 |
\z | 匹配字符串结尾,如果存在换行,同时还会匹配换行符 |
\G | 匹配最后匹配完成的位置 |
\n | 匹配一个换行符 |
\t | 匹配一个制表符 |
^ | 匹配一行字符串的开头 |
$ | 匹配一行字符串的结尾 |
. | 匹配任意字符,除了换行符,当 re.DOTALL 标记被指定时,则可以匹配包括换行符的任意字符 |
[...] | 用来表示一组字符,单独列出,比如 [amk] 匹配 a、m 或 k |
[^...] | 不在 [] 中的字符,比如 匹配除了 a、b、c 之外的字符 |
* | 匹配 0 个或多个表达式 |
+ | 匹配 1 个或多个表达式 |
? | 匹配 0 个或 1 个前面的正则表达式定义的片段,非贪婪方式 |
{n} | 精确匹配 n 个前面的表达式 |
{n, m} | 匹配 n 到 m 次由前面正则表达式定义的片段,贪婪方式 |
a|b | 匹配 a 或 b |
() | 匹配括号内的表达式,也表示一个组 |
看完之后,你可能有点晕晕的吧,不用担心,后面我们会详细讲解一些常见规则的用法。
其实正则表达式不是 Python 独有的,它也可以用在其他编程语言中。但是 Python 的 re 库提供了整个正则表达式的实现,利用这个库,可以在 Python 中使用正则表达式。
在 Python 中写正则表达式几乎都用这个库,下面就来了解它的一些常用方法。
首先介绍一个常用的匹配方法 —— match,向它传入要匹配的字符串,以及正则表达式,就可以检测这个正则表达式是否匹配字符串。
match 方法会尝试从字符串的起始位置匹配正则表达式,如果匹配,就返回匹配成功的结果;如果不匹配,就返回 None。
示例如下:
import recontent = 'Hello 123 4567 World_This is a Regex Demo'print(len(content))result = re.match('^Hello\s\d\d\d\s\d{4}\s\w{10}', content)print(result)print(result.group())print(result.span())
运行结果如下:
41<_sre.SRE_Match object; span=(0, 25), match='Hello 123 4567 World_This'>Hello 123 4567 World_This(0, 25)
这里首先声明了一个字符串,其中包含英文字母、空白字符、数字等。接下来,我们写一个正则表达式:
^Hello\s\d\d\d\s\d{4}\s\w{10}
用它来匹配这个长字符串。开头的 ^ 匹配字符串的开头,也就是以 Hello 开头; \s 匹配空白字符,用来匹配目标字符串的空格;\d 匹配数字,3 个 \d 匹配 123;再写 1 个 \s 匹配空格;后面的 4567,其实依然能用 4 个 \d 来匹配,但是这么写比较烦琐,所以后面可以跟 {4} 代表匹配前面的规则 4 次,也就是匹配 4 个数字;后面再紧接 1 个空白字符,最后\w{10} 匹配 10 个字母及下划线。
我们注意到,这里并没有把目标字符串匹配完,不过依然可以进行匹配,只不过匹配结果短一点而已。
而在 match 方法中,第一个参数传入正则表达式,第二个参数传入要匹配的字符串。
打印输出结果,可以看到结果是 SRE_Match 对象,这证明成功匹配。该对象有两个方法:group 方法可以输出匹配的内容,结果是 Hello 123 4567 World_This,这恰好是正则表达式规则所匹配的内容;span 方法可以输出匹配的范围,结果是 (0, 25),这就是匹配到的结果字符串在原字符串中的位置范围。
通过上面的例子,我们基本了解了如何在 Python 中使用正则表达式来匹配一段文字。
刚才我们用 match 方法得到了匹配到的字符串内容,但当我们想从字符串中提取一部分内容,该怎么办呢?
就像最前面的实例一样,要从一段文本中提取出邮件或电话号码等内容。我们可以使用 () 括号将想提取的子字符串括起来。() 实际上标记了一个子表达式的开始和结束位置,被标记的每个子表达式会依次对应每一个分组,调用 group 方法传入分组的索引即可获取提取的结果。
示例如下:
import recontent = 'Hello 1234567 World_This is a Regex Demo'result = re.match('^Hello\s(\d+)\sWorld', content)print(result)print(result.group())print(result.group(1))print(result.span())
这里我们想把字符串中的 1234567 提取出来,此时可以将数字部分的正则表达式用 () 括起来,然后调用了 group(1) 获取匹配结果。
运行结果如下:
<_sre.SRE_Match object; span=(0, 19), match='Hello 1234567 World'>Hello 1234567 World1234567(0, 19)
可以看到,我们成功得到了 1234567。这里用的是 group(1),它与 group() 有所不同,后者会输出完整的匹配结果,而前者会输出第一个被 () 包围的匹配结果。假如正则表达式后面还有 () 包括的内容,那么可以依次用 group(2)、group(3) 等来获取。
刚才我们写的正则表达比较复杂,出现空白字符我们就写 \s 匹配,出现数字我们就用 \d 匹配,这样的工作量非常大。
我们还可以用一个万能匹配来减少这些工作,那就是 .*。其中 . 可以匹配任意字符(除换行符),* 代表匹配前面的字符无限次,它们组合在一起就可以匹配任意字符了。有了它,我们就不用挨个字符的匹配了。
接着上面的例子,我们可以改写一下正则表达式:
import recontent = 'Hello 123 4567 World_This is a Regex Demo'result = re.match('^Hello.*Demo$', content)print(result)print(result.group())print(result.span())
这里我们将中间部分直接省略,全部用 .* 来代替,最后加一个结尾字符就好了。
运行结果如下:
<_sre.SRE_Match object; span=(0, 41), match='Hello 123 4567 World_This is a Regex Demo'>Hello 123 4567 World_This is a Regex Demo(0, 41)
可以看到,group 方法输出了匹配的全部字符串,也就是说我们写的正则表达式匹配到了目标字符串的全部内容;span 方法输出 (0, 41),这是整个字符串的长度。
因此,我们可以使用 .* 简化正则表达式的书写。
使用上面的通用匹配 .* 时,有时候匹配到的并不是我们想要的结果。
看下面的例子:
import recontent = 'Hello 1234567 World_This is a Regex Demo'result = re.match('^He.*(\d+).*Demo$', content)print(result)print(result.group(1))
这里我们依然想获取中间的数字,所以中间依然写的是 (\d+)。由于数字两侧的内容比较杂乱,所以略写成 .*。最后,组成 ^He.*(\d+).*Demo$,看样子并没有什么问题。
我们看下运行结果:
<_sre.SRE_Match object; span=(0, 40), match='Hello 1234567 World_This is a Regex Demo'>7
奇怪的事情发生了,我们只得到了 7 这个数字,这是怎么回事呢?
这里就涉及一个贪婪匹配与非贪婪匹配的问题了。在贪婪匹配下,.* 会匹配尽可能多的字符。正则表达式中 .* 后面是 \d+,也就是至少一个数字,并没有指定具体多少个数字,因此,.* 就尽可能匹配多的字符,这里就把 123456 匹配了,给 \d+ 留下一个可满足条件的数字 7,最后得到的内容就只有数字 7 了。
这显然会给我们带来很大的不便。有时候,匹配结果会莫名其妙少了一部分内容。其实,这里只需要使用非贪婪匹配就好了。非贪婪匹配的写法是 .*?,多了一个 ?,那么它可以达到怎样的效果?
我们再用实例看一下:
import recontent = 'Hello 1234567 World_This is a Regex Demo'result = re.match('^He.*?(\d+).*Demo$', content)print(result)print(result.group(1))
这里我们只是将第一个.* 改成了 .*?,转变为非贪婪匹配。
结果如下:
<_sre.SRE_Match object; span=(0, 40), match='Hello 1234567 World_This is a Regex Demo'>1234567
此时就可以成功获取 1234567 了。原因可想而知,贪婪匹配是尽可能匹配多的字符,非贪婪匹配就是尽可能匹配少的字符。当 .*? 匹配到 Hello 后面的空白字符时,再往后的字符就是数字了,而 \d+ 恰好可以匹配,那么 .*? 就不再进行匹配,交给 \d+ 去匹配后面的数字。这样 .*? 匹配了尽可能少的字符,\d+ 的结果就是 1234567 了。
所以,在做匹配的时候,字符串中间尽量使用非贪婪匹配,也就是用 .*? 来代替 .*,以免出现匹配结果缺失的情况。
但需要注意的是,如果匹配的结果在字符串结尾,.*? 就有可能匹配不到任何内容了,因为它会匹配尽可能少的字符。例如:
import recontent = 'http://weibo.com/comment/kEraCN'result1 = re.match('http.*?comment/(.*?)', content)result2 = re.match('http.*?comment/(.*)', content)print('result1', result1.group(1))print('result2', result2.group(1))
运行结果如下:
result1 result2 kEraCN
可以观察到,.*? 没有匹配到任何结果,而 .* 则尽量匹配多的内容,成功得到了匹配结果。
正则表达式可以包含一些可选标志修饰符来控制匹配的模式。修饰符被指定为一个可选的标志。
我们用实例来看一下:
import recontent = '''Hello 1234567 World_Thisis a Regex Demo'''result = re.match('^He.*?(\d+).*?Demo$', content)print(result.group(1))
和上面的例子相仿,我们在字符串中加了换行符,正则表达式还是一样的,用来匹配其中的数字。看一下运行结果:
AttributeError Traceback (most recent call last)<ipython-input-18-c7d232b39645> in <module>() 5 ''' 6 result = re.match('^He.*?(\d+).*?Demo$', content)----> 7 print(result.group(1))AttributeError: 'NoneType' object has no attribute 'group'
运行直接报错,也就是说正则表达式没有匹配到这个字符串,返回结果为 None,而我们又调用了 group 方法导致 AttributeError。
为什么加了一个换行符,就匹配不到了呢?
这是因为我们匹配的是除换行符之外的任意字符,当遇到换行符时,.*? 就不能匹配了,导致匹配失败。
这里只需加一个修饰符 re.S,即可修正这个错误:
result = re.match('^He.*?(\d+).*?Demo$', content, re.S)
这个修饰符的作用是匹配包括换行符在内的所有字符。
此时运行结果如下:
1234567
这个 re.S 在网页匹配中经常用到。因为 HTML 节点经常会有换行,加上它,就可以匹配节点与节点之间的换行了。
另外,还有一些修饰符,在必要的情况下也可以使用,如表所示:
修饰符 | 描 述 |
---|---|
re.I | 使匹配对大小写不敏感 |
re.L | 做本地化识别(locale-aware)匹配 |
re.M | 多行匹配,影响 ^ 和 $ |
re.S | 使匹配包括换行在内的所有字符 |
re.U | 根据 Unicode 字符集解析字符。这个标志影响 \w、\W、\b 和 \B |
re.X | 该标志通过给予你更灵活的格式以便你将正则表达式写得更易于理解 |
在网页匹配中,较为常用的修饰符有 re.S 和 re.I。
我们知道正则表达式定义了许多匹配模式,如匹配除换行符以外的任意字符,但如果目标字符串里面就包含 .,那该怎么办呢?
这里就需要用到转义匹配了,示例如下:
import recontent = '(百度) www.baidu.com'result = re.match('\(百度 \) www\.baidu\.com', content)print(result)
当遇到用于正则匹配模式的特殊字符时,在前面加反斜线转义一下即可。例 . 就可以用 \. 来匹配。
运行结果如下:
<_sre.SRE_Match object; span=(0, 17), match='(百度) www.baidu.com'>
可以看到,这里成功匹配到了原字符串。
这些是写正则表达式常用的几个知识点,熟练掌握它们对后面写正则表达式匹配非常有帮助。
前面提到过,match 方法是从字符串的开头开始匹配的,一旦开头不匹配,那么整个匹配就失败了。
我们看下面的例子:
import recontent = 'Extra stings Hello 1234567 World_This is a Regex Demo Extra stings'result = re.match('Hello.*?(\d+).*?Demo', content)print(result)
这里的字符串以 Extra 开头,但是正则表达式以 Hello 开头,整个正则表达式是字符串的一部分,但是这样匹配是失败的。
运行结果如下:
None
因为 match 方法在使用时需要考虑到开头的内容,这在做匹配时并不方便。它更适合用来检测某个字符串是否符合某个正则表达式的规则。
这里有另外一个方法 search,它在匹配时会扫描整个字符串,然后返回第一个成功匹配的结果。也就是说,正则表达式可以是字符串的一部分,在匹配时,search 方法会依次扫描字符串,直到找到第一个符合规则的字符串,然后返回匹配内容,如果搜索完了还没有找到,就返回 None。
我们把上面代码中的 match 方法修改成 search,再看下运行结果:
<_sre.SRE_Match object; span=(13, 53), match='Hello 1234567 World_This is a Regex Demo'>1234567
这时就得到了匹配结果。
因此,为了匹配方便,我们可以尽量使用 search 方法。
下面再用几个实例来看看 search 方法的用法。
这里有一段待匹配的 HTML 文本,接下来我们写几个正则表达式实例来实现相应信息的提取:
html = '''<div id="songs-list"><h2 class="title">经典老歌</h2><p class="introduction">经典老歌列表</p><ul id="list" class="list-group"><li data-view="2">一路上有你</li><li data-view="7"><a href="/2.mp3" singer="任贤齐">沧海一声笑</a></li><li data-view="4" class="active"><a href="/3.mp3" singer="齐秦">往事随风</a></li><li data-view="6"><a href="/4.mp3" singer="beyond">光辉岁月</a></li><li data-view="5"><a href="/5.mp3" singer="陈慧琳">记事本</a></li><li data-view="5"><a href="/6.mp3" singer="邓丽君">但愿人长久</a></li></ul></div>'''
可以观察到,ul 节点里有许多 li 节点,其中 li 节点中有的包含 a 节点,有的不包含 a 节点,a 节点还有一些相应的属性 —— 超链接和歌手名。
首先,我们尝试提取 class为 active 的 li 节点内部超链接包含的歌手名和歌名,此时需要提取第三个 li 节点下 a 节点的 singer 属性和文本。
此时,正则表达式可以用 li 开头,然后寻找一个标志符 active,中间的部分可以用 .*? 来匹配。
接下来,要提取 singer 这个属性值,所以还需要写入 singer="(.*?)",这里需要提取的部分用小括号括起来,以便用 group 方法提取出来,它的两侧边界是双引号。
然后还需要匹配 a 节点的文本,其中它的左边界是 >,右边界是 </a>。目标内容依然用 (.*?) 来匹配,所以最后的正则表达式就变成了:
<li.*?active.*?singer="(.*?)">(.*?)</a>
然后再调用 search 方法,它会搜索整个 HTML 文本,找到符合正则表达式的第一个内容返回。
另外,由于代码有换行,所以这里第三个参数需要传入 re.S。整个匹配代码如下:
result = re.search('<li.*?active.*?singer="(.*?)">(.*?)</a>', html, re.S) if result: print(result.group(1), result.group(2))
由于需要获取的歌手和歌名都已经用小括号包围,所以可以用 group 方法获取。
运行结果如下:
齐秦 往事随风
可以看到,这正是 class 为 active 的 li 节点内部的超链接包含的歌手名和歌名。
如果正则表达式不加 active(也就是匹配不带 class 为 active 的节点内容),那会怎样呢?我们将正则表达式中的 active 去掉。
代码改写如下:
result = re.search('<li.*?singer="(.*?)">(.*?)</a>', html, re.S)if result: print(result.group(1), result.group(2))
由于 search 方法会返回第一个符合条件的匹配目标,这里结果就变了:
任贤齐 沧海一声笑
把 active 标签去掉后,从字符串开头开始搜索,此时符合条件的节点就变成了第二个 li 节点,后面的不再匹配,所以运行结果变成第二个 li 节点中的内容。
注意,在上面的两次匹配中,search 方法的第三个参数都加了 re.S,这使得 .*? 可以匹配换行,所以含有换行的 li 节点被匹配到了。如果我们将其去掉,结果会是什么?
代码如下:
result = re.search('<li.*?singer="(.*?)">(.*?)</a>', html)if result: print(result.group(1), result.group(2))
运行结果如下:
beyond 光辉岁月
可以看到,结果变成了第四个 li 节点的内容。这是因为第二个和第三个 li 节点都包含了换行符,去掉 re.S 之后,.*? 已经不能匹配换行符,所以正则表达式不会匹配到第二个和第三个 li 节点,而第四个 li 节点中不包含换行符,所以成功匹配。
由于绝大部分的 HTML 文本都包含了换行符,所以尽量都需要加上 re.S 修饰符,以免出现匹配不到的问题。
前面我们介绍了 search 方法的用法,它可以返回匹配正则表达式的第一个内容,但是如果想要获取匹配正则表达式的所有内容,那该怎么办呢?这时就要借助 findall 方法了。
该方法会搜索整个字符串,然后返回匹配正则表达式的所有内容。
还是上面的 HTML 文本,如果想获取所有 a 节点的超链接、歌手和歌名,就可以将 search 方法换成 findall 方法。如果有返回结果的话,就是列表类型,所以需要遍历一下来依次获取每组内容。
代码如下:
results = re.findall('<li.*?href="(.*?)".*?singer="(.*?)">(.*?)</a>', html, re.S)print(results)print(type(results))for result in results: print(result) print(result[0], result[1], result[2])
运行结果如下:
[('/2.mp3', ' 任贤齐 ', ' 沧海一声笑 '), ('/3.mp3', ' 齐秦 ', ' 往事随风 '), ('/4.mp3', 'beyond', ' 光辉岁月 '), ('/5.mp3', ' 陈慧琳 ', ' 记事本 '), ('/6.mp3', ' 邓丽君 ', ' 但愿人长久 ')]<class 'list'>('/2.mp3', ' 任贤齐 ', ' 沧海一声笑 ')/2.mp3 任贤齐 沧海一声笑('/3.mp3', ' 齐秦 ', ' 往事随风 ')/3.mp3 齐秦 往事随风('/4.mp3', 'beyond', ' 光辉岁月 ')/4.mp3 beyond 光辉岁月('/5.mp3', ' 陈慧琳 ', ' 记事本 ')/5.mp3 陈慧琳 记事本('/6.mp3', ' 邓丽君 ', ' 但愿人长久 ')/6.mp3 邓丽君 但愿人长久
可以看到,返回的列表中的每个元素都是元组类型,我们用对应的索引依次取出即可。
如果只是获取第一个内容,可以用 search 方法。当需要提取多个内容时,可以用 findall 方法。
除了使用正则表达式提取信息外,有时候还需要借助它来修改文本。比如,想要把一串文本中的所有数字都去掉,如果只用字符串的 replace 方法,那就太烦琐了,这时可以借助 sub 方法。
示例如下:
import recontent = '54aK54yr5oiR54ix5L2g'content = re.sub('\d+', '', content)print(content)
运行结果如下:
aKyroiRixLg
这里只需要给第一个参数传入 \d+ 来匹配所有的数字,第二个参数替换成的字符串(如果去掉该参数的话,可以赋值为空),第三个参数是原字符串。
在上面的 HTML 文本中,如果想获取所有 li 节点的歌名,直接用正则表达式来提取可能比较烦琐。比如,可以写成这样子:
results = re.findall('<li.*?>\s*?(<a.*?>)?(\w+)(</a>)?\s*?</li>', html, re.S)for result in results: print(result[1])
运行结果如下:
一路上有你沧海一声笑往事随风光辉岁月记事本但愿人长久
此时借助 sub 方法就比较简单了。可以先用 sub 方法将 a 节点去掉,只留下文本,然后再利用 findall 提取就好了:
html = re.sub('<a.*?>|</a>', '', html)print(html)results = re.findall('<li.*?>(.*?)</li>', html, re.S)for result in results: print(result.strip())
运行结果如下:
<div id="songs-list"> <h2 class="title"> 经典老歌 </h2> <p class="introduction"> 经典老歌列表 </p> <ul id="list" class="list-group"> <li data-view="2"> 一路上有你 </li> <li data-view="7"> 沧海一声笑 </li> <li data-view="4" class="active"> 往事随风 </li> <li data-view="6"> 光辉岁月 </li> <li data-view="5"> 记事本 </li> <li data-view="5"> 但愿人长久 </li> </ul></div>一路上有你沧海一声笑往事随风光辉岁月记事本但愿人长久
可以看到,a 节点经过 sub 方法处理后就没有了,随后我们通过 findall 方法直接提取即可。
通过以上例子,你会发现,在适当的时候,借助 sub 方法可以起到事半功倍的效果。
前面所讲的方法都是用来处理字符串的方法,最后再介绍一下 compile 方法,这个方法可以将正则字符串编译成正则表达式对象,以便在后面的匹配中复用。
示例代码如下:
import recontent1 = '2019-12-15 12:00'content2 = '2019-12-17 12:55'content3 = '2019-12-22 13:21'pattern = re.compile('\d{2}:\d{2}')result1 = re.sub(pattern, '', content1)result2 = re.sub(pattern, '', content2)result3 = re.sub(pattern, '', content3)print(result1, result2, result3)
这里有 3 个日期,我们想分别将 3 个日期中的时间去掉,这时可以借助 sub 方法。该方法的第一个参数是正则表达式,但是我们没有必要重复写 3 个同样的正则表达式。此时可以借助 compile 方法将正则表达式编译成一个正则表达式对象,以便复用。
运行结果如下:
2019-12-15 2019-12-17 2019-12-22
另外,compile 还可以传入修饰符,例如 re.S 等修饰符,这样在 search、findall 等方法中就不需要额外传了。所以,compile 方法可以说是给正则表达式做了一层封装,以便我们更好的复用。
到此,正则表达式的基本用法就介绍完了。后面我会通过具体的实例来讲解正则表达式的用法。
(相同功能的库还有:lxml xpath、beautifulsoap4)
上一课时我们学习了正则表达式的基本用法,然而一旦你的正则表达式写法有问题,我们就无法获取需要的信息。
你可能会思考:每个网页,都有一定的特殊结构和层级关系,而且很多节点都有 id 或 class 作为区分,我们可以借助它们的结构和属性来提取信息吗?
这的确可行。这个课时我会为你介绍一个更加强大的 HTML 解析库:pyquery。利用它,我们可以直接解析 DOM 节点的结构,并通过 DOM 节点的一些属性快速进行内容提取。
接下来,我们就来感受一下 pyquery 的强大之处。
pyquery 是 Python 的第三方库,我们可以借助于 pip3 来安装,安装命令如下:
pip3 install pyquery
更详细的安装方法可以参考:https://cuiqingcai.com/5186.html。
我们在解析 HTML 文本的时候,首先需要将其初始化为一个 pyquery 对象。它的初始化方式有多种,比如直接传入字符串、传入 URL、传入文件名,等等。
下面我们来详细介绍一下。
我们可以直接把 HTML 的内容当作参数来初始化 pyquery 对象。我们用一个实例来感受一下:
html = '''<div> <ul> <li class="item-0">first item</li> <li class="item-1"><a href="link2.html">second item</a></li> <li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li> <li class="item-1 active"><a href="link4.html">fourth item</a></li> <li class="item-0"><a href="link5.html">fifth item</a></li> </ul> </div>'''from pyquery import PyQuery as pqdoc = pq(html)print(doc('li'))
运行结果如下:
<li class="item-0">first item</li><li class="item-1"><a href="link2.html">second item</a></li><li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li><li class="item-1 active"><a href="link4.html">fourth item</a></li><li class="item-0"><a href="link5.html">fifth item</a></li>
这里首先引入 pyquery 这个对象,取别名为 pq,然后声明了一个长 HTML 字符串,并将其当作参数传递给 pyquery 类,这样就成功完成了初始化。
接下来,将初始化的对象传入 CSS 选择器。在这个实例中,我们传入 li 节点,这样就可以选择所有的 li 节点。
初始化的参数不仅可以以字符串的形式传递,还可以传入网页的 URL,此时只需要指定参数为 url 即可:
from pyquery import PyQuery as pqdoc = pq(url='https://cuiqingcai.com')print(doc('title'))
运行结果:
<title>静觅丨崔庆才的个人博客</title>
这样的话,pyquery 对象会首先请求这个 URL,然后用得到的 HTML 内容完成初始化。这就相当于将网页的源代码以字符串的形式传递给 pyquery 类来初始化。
它与下面的功能是相同的:
from pyquery import PyQuery as pqimport requestsdoc = pq(requests.get('https://cuiqingcai.com').text)print(doc('title'))
当然除了传递一个 URL,我们还可以传递本地的文件名,参数指定为 filename 即可:
from pyquery import PyQuery as pqdoc = pq(filename='demo.html')print(doc('li'))
当然,这里需要有一个本地 HTML 文件 demo.html,其内容是待解析的 HTML 字符串。这样它会先读取本地的文件内容,然后将文件内容以字符串的形式传递给 pyquery 类来初始化。
以上 3 种方式均可初始化,当然最常用的初始化方式还是以字符串形式传递。
我们先用一个实例来感受一下 pyquery 的 CSS 选择器的用法:
html = '''<div id="container"> <ul class="list"> <li class="item-0">first item</li> <li class="item-1"><a href="link2.html">second item</a></li> <li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li> <li class="item-1 active"><a href="link4.html">fourth item</a></li> <li class="item-0"><a href="link5.html">fifth item</a></li> </ul> </div>'''from pyquery import PyQuery as pqdoc = pq(html)print(doc('#container .list li'))print(type(doc('#container .list li')))
运行结果:
<li class="item-0">first item</li><li class="item-1"><a href="link2.html">second item</a></li><li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li><li class="item-1 active"><a href="link4.html">fourth item</a></li><li class="item-0"><a href="link5.html">fifth item</a></li><class 'pyquery.pyquery.PyQuery'>
在上面的例子中,我们初始化 pyquery 对象之后,传入 CSS 选择器 #container .list li,它的意思是先选取 id 为 container 的节点,然后再选取其内部 class 为 list 的所有 li 节点,最后打印输出。
可以看到,我们成功获取到了符合条件的节点。我们将它的类型打印输出后发现,它的类型依然是 pyquery 类型。
下面,我们直接遍历这些节点,然后调用 text 方法,就可以获取节点的文本内容,代码示例如下:
for item in doc('#container .list li').items(): print(item.text())
运行结果如下:
first itemsecond itemthird itemfourth itemfifth item
怎么样?我们没有再写正则表达式,而是直接通过选择器和 text 方法,就得到了我们想要提取的文本信息,是不是方便多了?
下面我们再来详细了解一下 pyquery 的用法吧,我将为你讲解如何用它查找节点、遍历节点、获取各种信息等操作方法。掌握了这些,我们就能更高效地完成数据提取。
下面我们介绍一些常用的查询方法。
查找子节点需要用到 find 方法,传入的参数是 CSS 选择器,我们还是以上面的 HTML 为例:
from pyquery import PyQuery as pqdoc = pq(html)items = doc('.list')print(type(items))print(items)lis = items.find('li')print(type(lis))print(lis)
运行结果:
<class 'pyquery.pyquery.PyQuery'><ul class="list"> <li class="item-0">first item</li> <li class="item-1"><a href="link2.html">second item</a></li> <li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li> <li class="item-1 active"><a href="link4.html">fourth item</a></li> <li class="item-0"><a href="link5.html">fifth item</a></li></ul><class 'pyquery.pyquery.PyQuery'><li class="item-0">first item</li><li class="item-1"><a href="link2.html">second item</a></li><li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li><li class="item-1 active"><a href="link4.html">fourth item</a></li><li class="item-0"><a href="link5.html">fifth item</a></li>
首先,我们通过 .list 参数选取 class 为 list 的节点,然后调用 find 方法,传入 CSS 选择器,选取其内部的 li 节点,最后打印输出。可以发现,find 方法会将符合条件的所有节点选择出来,结果的类型是 pyquery 类型。
find 的查找范围是节点的所有子孙节点,而如果我们只想查找子节点,那可以用 children 方法:
lis = items.children()print(type(lis))print(lis)
运行结果如下:
<class 'pyquery.pyquery.PyQuery'><li class="item-0">first item</li><li class="item-1"><a href="link2.html">second item</a></li><li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li><li class="item-1 active"><a href="link4.html">fourth item</a></li><li class="item-0"><a href="link5.html">fifth item</a></li>
如果要筛选所有子节点中符合条件的节点,比如想筛选出子节点中 class 为 active 的节点,可以向 children 方法传入 CSS 选择器 .active,代码如下:
lis = items.children('.active')print(lis)
运行结果:
<li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li><li class="item-1 active"><a href="link4.html">fourth item</a></li>
我们看到输出的结果已经做了筛选,留下了 class 为 active 的节点。
我们可以用 parent 方法来获取某个节点的父节点,下面用一个实例来感受一下:
html = '''<div class="wrap"> <div id="container"> <ul class="list"> <li class="item-0">first item</li> <li class="item-1"><a href="link2.html">second item</a></li> <li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li> <li class="item-1 active"><a href="link4.html">fourth item</a></li> <li class="item-0"><a href="link5.html">fifth item</a></li> </ul> </div> </div>'''from pyquery import PyQuery as pqdoc = pq(html)items = doc('.list')container = items.parent()print(type(container))print(container)
运行结果如下:
<class 'pyquery.pyquery.PyQuery'><div id="container"> <ul class="list"> <li class="item-0">first item</li> <li class="item-1"><a href="link2.html">second item</a></li> <li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li> <li class="item-1 active"><a href="link4.html">fourth item</a></li> <li class="item-0"><a href="link5.html">fifth item</a></li> </ul> </div>
在上面的例子中我们首先用 .list 选取 class 为 list 的节点,然后调用 parent 方法得到其父节点,其类型依然是 pyquery 类型。
这里的父节点是该节点的直接父节点,也就是说,它不会再去查找父节点的父节点,即祖先节点。
但是如果你想获取某个祖先节点,该怎么办呢?我们可以用 parents 方法:
from pyquery import PyQuery as pqdoc = pq(html)items = doc('.list')parents = items.parents()print(type(parents))print(parents)
运行结果如下:
<class 'pyquery.pyquery.PyQuery'><div class="wrap"> <div id="container"> <ul class="list"> <li class="item-0">first item</li> <li class="item-1"><a href="link2.html">second item</a></li> <li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li> <li class="item-1 active"><a href="link4.html">fourth item</a></li> <li class="item-0"><a href="link5.html">fifth item</a></li> </ul> </div> </div> <div id="container"> <ul class="list"> <li class="item-0">first item</li> <li class="item-1"><a href="link2.html">second item</a></li> <li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li> <li class="item-1 active"><a href="link4.html">fourth item</a></li> <li class="item-0"><a href="link5.html">fifth item</a></li> </ul> </div>
可以看到,这个例子的输出结果有两个:一个是 class 为 wrap 的节点,一个是 id 为 container 的节点。也就是说,使用 parents 方法会返回所有的祖先节点。
如果你想要筛选某个祖先节点的话,可以向 parents 方法传入 CSS 选择器,这样就会返回祖先节点中符合 CSS 选择器的节点:
parent = items.parents('.wrap')print(parent)
运行结果如下:
<div class="wrap"> <div id="container"> <ul class="list"> <li class="item-0">first item</li> <li class="item-1"><a href="link2.html">second item</a></li> <li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li> <li class="item-1 active"><a href="link4.html">fourth item</a></li> <li class="item-0"><a href="link5.html">fifth item</a></li> </ul> </div> </div>
可以看到,输出结果少了一个节点,只保留了 class 为 wrap 的节点。
前面我们说明了子节点和父节点的用法,还有一种节点叫作兄弟节点。如果要获取兄弟节点,可以使用 siblings 方法。这里还是以上面的 HTML 代码为例:
from pyquery import PyQuery as pqdoc = pq(html)li = doc('.list .item-0.active')print(li.siblings())
在这个例子中我们首先选择 class 为 list 的节点,内部 class 为 item-0 和 active 的节点,也就是第 3 个 li 节点。很明显,它的兄弟节点有 4 个,那就是第 1、2、4、5 个 li 节点。
我们来运行一下:
<li class="item-1"><a href="link2.html">second item</a></li><li class="item-0">first item</li><li class="item-1 active"><a href="link4.html">fourth item</a></li><li class="item-0"><a href="link5.html">fifth item</a></li>
可以看到,结果显示的正是我们刚才所说的 4 个兄弟节点。
如果要筛选某个兄弟节点,我们依然可以用 siblings 方法传入 CSS 选择器,这样就会从所有兄弟节点中挑选出符合条件的节点了:
from pyquery import PyQuery as pqdoc = pq(html)li = doc('.list .item-0.active')print(li.siblings('.active'))
在这个例子中我们筛选 class 为 active 的节点,从刚才的结果中可以观察到,class 为 active 兄弟节点的是第 4 个 li 节点,所以结果应该是1个。
我们再看一下运行结果:
<li class="item-1 active"><a href="link4.html">fourth item</a></li>
通过刚才的例子我们可以观察到,pyquery 的选择结果既可能是多个节点,也可能是单个节点,类型都是 pyquery 类型,并没有返回列表。
对于单个节点来说,可以直接打印输出,也可以直接转成字符串:
from pyquery import PyQuery as pqdoc = pq(html)li = doc('.item-0.active')print(li)print(str(li))
运行结果如下:
<li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li><li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li>
对于有多个节点的结果,我们就需要用遍历来获取了。例如,如果要把每一个 li 节点进行遍历,需要调用 items 方法:
from pyquery import PyQuery as pqdoc = pq(html)lis = doc('li').items()print(type(lis))for li in lis: print(li, type(li))
运行结果如下:
<class 'generator'><li class="item-0">first item</li><class 'pyquery.pyquery.PyQuery'><li class="item-1"><a href="link2.html">second item</a></li><class 'pyquery.pyquery.PyQuery'><li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li><class 'pyquery.pyquery.PyQuery'><li class="item-1 active"><a href="link4.html">fourth item</a></li><class 'pyquery.pyquery.PyQuery'><li class="item-0"><a href="link5.html">fifth item</a></li><class 'pyquery.pyquery.PyQuery'>
可以发现,调用 items 方法后,会得到一个生成器,遍历一下,就可以逐个得到 li 节点对象了,它的类型也是 pyquery 类型。每个 li 节点还可以调用前面所说的方法进行选择,比如继续查询子节点,寻找某个祖先节点等,非常灵活。
提取到节点之后,我们的最终目的当然是提取节点所包含的信息了。比较重要的信息有两类,一是获取属性,二是获取文本,下面分别进行说明。
获取属性
提取到某个 pyquery 类型的节点后,就可以调用 attr 方法来获取属性:
html = '''<div class="wrap"> <div id="container"> <ul class="list"> <li class="item-0">first item</li> <li class="item-1"><a href="link2.html">second item</a></li> <li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li> <li class="item-1 active"><a href="link4.html">fourth item</a></li> <li class="item-0"><a href="link5.html">fifth item</a></li> </ul> </div> </div>'''from pyquery import PyQuery as pqdoc = pq(html)a = doc('.item-0.active a')print(a, type(a))print(a.attr('href'))
运行结果如下:
<a href="link3.html"><span class="bold">third item</span></a> <class 'pyquery.pyquery.PyQuery'>link3.html
在这个例子中我们首先选中 class 为 item-0 和 active 的 li 节点内的 a 节点,它的类型是 pyquery 类型。
然后调用 attr 方法。在这个方法中传入属性的名称,就可以得到属性值了。
此外,也可以通过调用 attr 属性来获取属性值,用法如下:
print(a.attr.href)
结果:
link3.html
这两种方法的结果完全一样。
如果选中的是多个元素,然后调用 attr 方法,会出现怎样的结果呢?我们用实例来测试一下:
a = doc('a')print(a, type(a))print(a.attr('href'))print(a.attr.href)
运行结果如下:
<a href="link2.html">second item</a><a href="link3.html"><span class="bold">third item</span></a><a href="link4.html">fourth item</a><a href="link5.html">fifth item</a> <class 'pyquery.pyquery.PyQuery'>link2.htmllink2.html
照理来说,我们选中的 a 节点应该有 4 个,打印结果也应该是 4 个,但是当我们调用 attr 方法时,返回结果却只有第 1 个。这是因为,当返回结果包含多个节点时,调用 attr 方法,只会得到第 1 个节点的属性。
那么,遇到这种情况时,如果想获取所有的 a 节点的属性,就要用到前面所说的遍历了:
from pyquery import PyQuery as pqdoc = pq(html)a = doc('a')for item in a.items(): print(item.attr('href'))
运行结果:
link2.htmllink3.htmllink4.htmllink5.html
因此,在进行属性获取时,先要观察返回节点是一个还是多个,如果是多个,则需要遍历才能依次获取每个节点的属性。
获取文本
获取节点之后的另一个主要操作就是获取其内部文本了,此时可以调用 text 方法来实现:
html = '''<div class="wrap"> <div id="container"> <ul class="list"> <li class="item-0">first item</li> <li class="item-1"><a href="link2.html">second item</a></li> <li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li> <li class="item-1 active"><a href="link4.html">fourth item</a></li> <li class="item-0"><a href="link5.html">fifth item</a></li> </ul> </div> </div>'''from pyquery import PyQuery as pqdoc = pq(html)a = doc('.item-0.active a')print(a)print(a.text())
运行结果:
<a href="link3.html"><span class="bold">third item</span></a>third item
这里我们首先选中一个 a 节点,然后调用 text 方法,就可以获取其内部的文本信息了。text 会忽略节点内部包含的所有 HTML,只返回纯文字内容。
但如果你想要获取这个节点内部的 HTML 文本,就要用 html 方法了:
from pyquery import PyQuery as pqdoc = pq(html)li = doc('.item-0.active')print(li)print(li.html())
这里我们选中第 3 个 li 节点,然后调用 html 方法,它返回的结果应该是 li 节点内的所有 HTML 文本。
运行结果:
<a href="link3.html"><span class="bold">third item</span></a>
这里同样有一个问题,如果我们选中的结果是多个节点,text 或 html 方法会返回什么内容?我们用实例来看一下:
html = '''<div class="wrap"> <div id="container"> <ul class="list"> <li class="item-1"><a href="link2.html">second item</a></li> <li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li> <li class="item-1 active"><a href="link4.html">fourth item</a></li> <li class="item-0"><a href="link5.html">fifth item</a></li> </ul> </div> </div>'''from pyquery import PyQuery as pqdoc = pq(html)li = doc('li')print(li.html())print(li.text())print(type(li.text())
运行结果如下:
<a href="link2.html">second item</a>second item third item fourth item fifth item<class'str'>
结果比较出乎意料,html 方法返回的是第 1 个 li 节点的内部 HTML 文本,而 text 则返回了所有的 li 节点内部的纯文本,中间用一个空格分割开,即返回结果是一个字符串。
这个地方值得注意,如果你想要得到的结果是多个节点,并且需要获取每个节点的内部 HTML 文本,则需要遍历每个节点。而 text 方法不需要遍历就可以获取,它将所有节点取文本之后合并成一个字符串。
pyquery 提供了一系列方法来对节点进行动态修改,比如为某个节点添加一个 class,移除某个节点等,这些操作有时会为提取信息带来极大的便利。
由于节点操作的方法太多,下面举几个典型的例子来说明它的用法。
我们先用一个实例来感受一下:
html = '''<div class="wrap"> <div id="container"> <ul class="list"> <li class="item-0">first item</li> <li class="item-1"><a href="link2.html">second item</a></li> <li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li> <li class="item-1 active"><a href="link4.html">fourth item</a></li> <li class="item-0"><a href="link5.html">fifth item</a></li> </ul> </div> </div>'''from pyquery import PyQuery as pqdoc = pq(html)li = doc('.item-0.active')print(li)li.removeClass('active')print(li)li.addClass('active')print(li)
首先选中第 3 个 li 节点,然后调用 removeClass 方法,将 li 节点的 active 这个 class 移除,第 2 步调用 addClass 方法,将 class 添加回来。每执行一次操作,就打印输出当前 li 节点的内容。
运行结果如下:
<li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li><li class="item-0"><a href="link3.html"><span class="bold">third item</span></a></li><li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li>
可以看到,一共输出了 3 次。第 2 次输出时,li 节点的 active 这个 class 被移除了,第 3 次 class 又添加回来了。
所以说,addClass 和 removeClass 方法可以动态改变节点的 class 属性。
当然,除了操作 class 这个属性外,也可以用 attr 方法对属性进行操作。此外,我们还可以用 text 和 html 方法来改变节点内部的内容。示例如下:
html = '''<ul class="list"> <li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li></ul>'''from pyquery import PyQuery as pqdoc = pq(html)li = doc('.item-0.active')print(li)li.attr('name', 'link')print(li)li.text('changed item')print(li)li.html('<span>changed item</span>')print(li)
这里我们首先选中 li 节点,然后调用 attr 方法来修改属性。该方法的第 1 个参数为属性名,第 2 个参数为属性值。最后调用 text 和 html 方法来改变节点内部的内容。3 次操作后,分别打印输出当前的 li 节点。
运行结果如下:
<li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li><li class="item-0 active" name="link"><a href="link3.html"><span class="bold">third item</span></a></li><li class="item-0 active" name="link">changed item</li><li class="item-0 active" name="link"><span>changed item</span></li>
我们发现,调用 attr 方法后,li 节点多了一个原本不存在的属性 name,其值为 link。接着调用 text 方法传入文本,li 节点内部的文本全被改为传入的字符串文本。最后,调用 html 方法传入 HTML 文本,li 节点内部又变为传入的 HTML 文本了。
所以说,使用 attr 方法时如果只传入第 1 个参数的属性名,则是获取这个属性值;如果传入第 2 个参数,可以用来修改属性值。使用 text 和 html 方法时如果不传参数,则是获取节点内纯文本和 HTML 文本,如果传入参数,则进行赋值。
顾名思义,remove 方法就是移除,它有时会为信息的提取带来非常大的便利。下面有一段 HTML 文本:
html = '''<div class="wrap"> Hello, World <p>This is a paragraph.</p> </div>'''from pyquery import PyQuery as pqdoc = pq(html)wrap = doc('.wrap')print(wrap.text())
现在我们想提取“Hello, World”这个字符串,该怎样操作呢?
这里先直接尝试提取 class 为 wrap 的节点的内容,看看是不是我们想要的。
运行结果如下:
Hello, World This is a paragraph.
这个结果还包含了内部的 p 节点的内容,也就是说 text 把所有的纯文本全提取出来了。
如果我们想去掉 p 节点内部的文本,可以选择再把 p 节点内的文本提取一遍,然后从整个结果中移除这个子串,但这个做法明显比较烦琐。
这时 remove 方法就可以派上用场了,我们可以接着这么做:
wrap.find('p').remove()print(wrap.text())
首先选中 p 节点,然后调用 remove 方法将其移除,这时 wrap 内部就只剩下“Hello, World”这句话了,最后利用 text 方法提取即可。
其实还有很多其他节点操作的方法,比如 append、empty 和 prepend 等方法,详细的用法可以参考官方文档:http://pyquery.readthedocs.io/en/latest/api.html。
CSS 选择器之所以强大,还有一个很重要的原因,那就是它支持多种多样的伪类选择器,例如选择第一个节点、最后一个节点、奇偶数节点、包含某一文本的节点等。示例如下:
html = '''<div class="wrap"> <div id="container"> <ul class="list"> <li class="item-0">first item</li> <li class="item-1"><a href="link2.html">second item</a></li> <li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li> <li class="item-1 active"><a href="link4.html">fourth item</a></li> <li class="item-0"><a href="link5.html">fifth item</a></li> </ul> </div> </div>'''from pyquery import PyQuery as pqdoc = pq(html)li = doc('li:first-child')print(li)li = doc('li:last-child')print(li)li = doc('li:nth-child(2)')print(li)li = doc('li:gt(2)')print(li)li = doc('li:nth-child(2n)')print(li)li = doc('li:contains(second)')print(li)
在这个例子中我们使用了 CSS3 的伪类选择器,依次选择了第 1 个 li 节点、最后一个 li 节点、第 2 个 li 节点、第 3 个 li 之后的 li 节点、偶数位置的 li 节点、包含 second 文本的 li 节点。
关于 CSS 选择器的更多用法,可以参考 http://www.w3school.com.cn/css/index.asp。
到此为止,pyquery 的常用用法就介绍完了。如果想查看更多的内容,可以参考 pyquery 的官方文档:http://pyquery.readthedocs.io。相信一旦你拥有了它,解析网页将不再是难事。
上节课我们学习了如何用 pyquery 提取 HTML 中的信息,但是当我们成功提取了数据之后,该往哪里存放呢?
用文本文件当然是可以的,但文本存储不方便检索。有没有既方便存,又方便检索的存储方式呢?
当然有,本课时我将为你介绍一个文档型数据库 —— MongoDB。
MongoDB 是由 C++ 语言编写的非关系型数据库,是一个基于分布式文件存储的开源数据库系统,其内容存储形式类似 JSON 对象,它的字段值可以包含其他文档、数组及文档数组,非常灵活。
在这个课时中,我们就来看看 Python 3 下 MongoDB 的存储操作。
在开始之前,请确保你已经安装好了 MongoDB 并启动了其服务,同时安装好了 Python 的 PyMongo 库。
MongoDB 的安装方式可以参考:https://cuiqingcai.com/5205.html,安装好之后,我们需要把 MongoDB 服务启动起来。
注意:这里我们为了学习,仅使用 MongoDB 最基本的单机版,MongoDB 还有主从复制、副本集、分片集群等集群架构,可用性可靠性更好,如有需要可以自行搭建相应的集群进行使用。
启动完成之后,它会默认在本地 localhost 的 27017 端口上运行。
接下来我们需要安装 PyMongo 这个库,它是 Python 用来操作 MongoDB 的第三方库,直接用 pip3 安装即可:pip3 install pymongo
。
更详细的安装方式可以参考:https://cuiqingcai.com/5230.html。
安装完成之后,我们就可以使用 PyMongo 来将数据存储到 MongoDB 了。
连接 MongoDB 时,我们需要使用 PyMongo 库里面的 MongoClient。一般来说,我们只需要向其传入 MongoDB 的 IP 及端口即可,其中第一个参数为地址 host,第二个参数为端口 port(如果不给它传递参数,则默认是 27017):
import pymongoclient = pymongo.MongoClient(host='localhost', port=27017)
这样我们就可以创建 MongoDB 的连接对象了。
另外,MongoClient 的第一个参数 host 还可以直接传入 MongoDB 的连接字符串,它以 mongodb 开头,例如:
client = MongoClient('mongodb://localhost:27017/')
这样也可以达到同样的连接效果。
MongoDB 中可以建立多个数据库,接下来我们需要指定操作其中一个数据库。这里我们以 test 数据库作为下一步需要在程序中指定使用的例子:
db = client.test
这里调用 client 的 test 属性即可返回 test 数据库。当然,我们也可以这样指定:
db = client['test']
这两种方式是等价的。
MongoDB 的每个数据库又包含许多集合(collection),它们类似于关系型数据库中的表。
下一步需要指定要操作的集合,这里我们指定一个名称为 students 的集合。与指定数据库类似,指定集合也有两种方式:
collection = db.students
或是
collection = db['students']
这样我们便声明了一个 Collection 对象。
接下来,便可以插入数据了。我们对 students 这个集合新建一条学生数据,这条数据以字典形式表示:
student = { 'id': '20170101', 'name': 'Jordan', 'age': 20, 'gender': 'male'}
新建的这条数据里指定了学生的学号、姓名、年龄和性别。接下来,我们直接调用 collection 的 insert 方法即可插入数据,代码如下:
result = collection.insert(student)print(result)
在 MongoDB 中,每条数据其实都有一个 _id 属性来唯一标识。如果没有显式指明该属性,MongoDB 会自动产生一个 ObjectId 类型的 _id 属性。insert() 方法会在执行后返回_id 值。
运行结果如下:
5932a68615c2606814c91f3d
当然,我们也可以同时插入多条数据,只需要以列表形式传递即可,示例如下:
student1 = { 'id': '20170101', 'name': 'Jordan', 'age': 20, 'gender': 'male'}student2 = { 'id': '20170202', 'name': 'Mike', 'age': 21, 'gender': 'male'}result = collection.insert([student1, student2])print(result)
返回结果是对应的_id 的集合:
[ObjectId('5932a80115c2606a59e8a048'), ObjectId('5932a80115c2606a59e8a049')]
实际上,在 PyMongo 中,官方已经不推荐使用 insert 方法了。但是如果你要继续使用也没有什么问题。目前,官方推荐使用 insert_one 和 insert_many 方法来分别插入单条记录和多条记录,示例如下:
student = { 'id': '20170101', 'name': 'Jordan', 'age': 20, 'gender': 'male'}result = collection.insert_one(student)print(result)print(result.inserted_id)
运行结果如下:
<pymongo.results.InsertOneResult object at 0x10d68b558>5932ab0f15c2606f0c1cf6c5
与 insert 方法不同,这次返回的是 InsertOneResult 对象,我们可以调用其 inserted_id 属性获取_id。
对于 insert_many 方法,我们可以将数据以列表形式传递,示例如下:
student1 = { 'id': '20170101', 'name': 'Jordan', 'age': 20, 'gender': 'male'}student2 = { 'id': '20170202', 'name': 'Mike', 'age': 21, 'gender': 'male'}result = collection.insert_many([student1, student2])print(result)print(result.inserted_ids)
运行结果如下:
<pymongo.results.InsertManyResult object at 0x101dea558>[ObjectId('5932abf415c2607083d3b2ac'), ObjectId('5932abf415c2607083d3b2ad')]
该方法返回的类型是 InsertManyResult,调用 inserted_ids 属性可以获取插入数据的 _id 列表。
插入数据后,我们可以利用 find_one 或 find 方法进行查询,其中 find_one 查询得到的是单个结果,find 则返回一个生成器对象。示例如下:
result = collection.find_one({'name': 'Mike'})print(type(result))print(result)
这里我们查询 name 为 Mike 的数据,它的返回结果是字典类型,运行结果如下:
<class 'dict'>{'_id': ObjectId('5932a80115c2606a59e8a049'), 'id': '20170202', 'name': 'Mike', 'age': 21, 'gender': 'male'}
可以发现,它多了 _id 属性,这就是 MongoDB 在插入过程中自动添加的。
此外,我们也可以根据 ObjectId 来查询,此时需要调用 bson 库里面的 objectid:
from bson.objectid import ObjectIdresult = collection.find_one({'_id': ObjectId('593278c115c2602667ec6bae')})print(result)
其查询结果依然是字典类型,具体如下:
{'_id': ObjectId('593278c115c2602667ec6bae'), 'id': '20170101', 'name': 'Jordan', 'age': 20, 'gender': 'male'}
如果查询结果不存在,则会返回 None。
对于多条数据的查询,我们可以使用 find 方法。例如,这里查找年龄为 20 的数据,示例如下:
results = collection.find({'age': 20})print(results)for result in results: print(result)
运行结果如下:
<pymongo.cursor.Cursor object at 0x1032d5128>{'_id': ObjectId('593278c115c2602667ec6bae'), 'id': '20170101', 'name': 'Jordan', 'age': 20, 'gender': 'male'}{'_id': ObjectId('593278c815c2602678bb2b8d'), 'id': '20170102', 'name': 'Kevin', 'age': 20, 'gender': 'male'}{'_id': ObjectId('593278d815c260269d7645a8'), 'id': '20170103', 'name': 'Harden', 'age': 20, 'gender': 'male'}
返回结果是 Cursor 类型,它相当于一个生成器,我们需要遍历获取的所有结果,其中每个结果都是字典类型。
如果要查询年龄大于 20 的数据,则写法如下:
results = collection.find({'age': {'$gt': 20}})
这里查询的条件键值已经不是单纯的数字了,而是一个字典,其键名为比较符号 $gt,意思是大于,键值为 20。
我将比较符号归纳为下表:
另外,还可以进行正则匹配查询。例如,查询名字以 M 开头的学生数据,示例如下:
results = collection.find({'name': {'$regex': '^M.*'}})
这里使用 $regex 来指定正则匹配,^M.* 代表以 M 开头的正则表达式。
我将一些功能符号归类为下表:
关于这些操作的更详细用法,可以在 MongoDB 官方文档找到: https://docs.mongodb.com/manual/reference/operator/query/。
要统计查询结果有多少条数据,可以调用 count 方法。我们以统计所有数据条数为例:
count = collection.find().count()print(count)
我们还可以统计符合某个条件的数据:
count = collection.find({'age': 20}).count()print(count)
运行结果是一个数值,即符合条件的数据条数。
排序时,我们可以直接调用 sort 方法,并在其中传入排序的字段及升降序标志。示例如下:
results = collection.find().sort('name', pymongo.ASCENDING)print([result['name'] for result in results])
运行结果如下:
['Harden', 'Jordan', 'Kevin', 'Mark', 'Mike']
这里我们调用 pymongo.ASCENDING 指定升序。如果要降序排列,可以传入 pymongo.DESCENDING。
在某些情况下,我们可能只需要取某几个元素,这时可以利用 skip 方法偏移几个位置,比如偏移 2,就代表忽略前两个元素,得到第 3 个及以后的元素:
results = collection.find().sort('name', pymongo.ASCENDING).skip(2)print([result['name'] for result in results])
运行结果如下:
['Kevin', 'Mark', 'Mike']
另外,我们还可以用 limit 方法指定要取的结果个数,示例如下:
results = collection.find().sort('name', pymongo.ASCENDING).skip(2).limit(2)print([result['name'] for result in results])
运行结果如下:
['Kevin', 'Mark']
如果不使用 limit 方法,原本会返回 3 个结果,加了限制后,就会截取两个结果返回。
值得注意的是,在数据量非常庞大的时候,比如在查询千万、亿级别的数据库时,最好不要使用大的偏移量,因为这样很可能导致内存溢出。此时可以使用类似如下操作来查询:
from bson.objectid import ObjectIdcollection.find({'_id': {'$gt': ObjectId('593278c815c2602678bb2b8d')}})
这时需要记录好上次查询的 _id。
对于数据更新,我们可以使用 update 方法,指定更新的条件和更新后的数据即可。例如:
condition = {'name': 'Kevin'}student = collection.find_one(condition)student['age'] = 25result = collection.update(condition, student)print(result)
这里我们要更新 name 为 Kevin 的数据的年龄:首先指定查询条件,然后将数据查询出来,修改年龄后调用 update 方法将原条件和修改后的数据传入。
运行结果如下:
{'ok': 1, 'nModified': 1, 'n': 1, 'updatedExisting': True}
返回结果是字典形式,ok 代表执行成功,nModified 代表影响的数据条数。
另外,我们也可以使用 $set 操作符对数据进行更新,代码如下:
result = collection.update(condition, {'$set': student})
这样可以只更新 student 字典内存在的字段。如果原先还有其他字段,则不会更新,也不会删除。而如果不用 $set 的话,则会把之前的数据全部用 student 字典替换;如果原本存在其他字段,则会被删除。
另外,update 方法其实也是官方不推荐使用的方法。这里也分为 update_one 方法和 update_many 方法,用法更加严格,它们的第 2 个参数需要使用 $ 类型操作符作为字典的键名,示例如下:
condition = {'name': 'Kevin'}student = collection.find_one(condition)student['age'] = 26result = collection.update_one(condition, {'$set': student})print(result)print(result.matched_count, result.modified_count)
上面的例子中调用了 update_one 方法,使得第 2 个参数不能再直接传入修改后的字典,而是需要使用 {'$set': student} 这样的形式,其返回结果是 UpdateResult 类型。然后分别调用 matched_count 和 modified_count 属性,可以获得匹配的数据条数和影响的数据条数。
运行结果如下:
<pymongo.results.UpdateResult object at 0x10d17b678>1 0
我们再看一个例子:
condition = {'age': {'$gt': 20}}result = collection.update_one(condition, {'$inc': {'age': 1}})print(result)print(result.matched_count, result.modified_count)
这里指定查询条件为年龄大于 20,然后更新条件为 {'$inc': {'age': 1}},表示年龄加 1,执行之后会将第一条符合条件的数据年龄加 1。
运行结果如下:
<pymongo.results.UpdateResult object at 0x10b8874c8>1 1
可以看到匹配条数为 1 条,影响条数也为 1 条。
如果调用 update_many 方法,则会将所有符合条件的数据都更新,示例如下:
condition = {'age': {'$gt': 20}}result = collection.update_many(condition, {'$inc': {'age': 1}})print(result)print(result.matched_count, result.modified_count)
这时匹配条数就不再为 1 条了,运行结果如下:
<pymongo.results.UpdateResult object at 0x10c6384c8>3 3
可以看到,这时所有匹配到的数据都会被更新。
删除操作比较简单,直接调用 remove 方法指定删除的条件即可,此时符合条件的所有数据均会被删除。
示例如下:
result = collection.remove({'name': 'Kevin'})print(result)
运行结果如下:
{'ok': 1, 'n': 1}
另外,这里依然存在两个新的推荐方法 —— delete_one 和 delete_many,示例如下:
result = collection.delete_one({'name': 'Kevin'})print(result)print(result.deleted_count)result = collection.delete_many({'age': {'$lt': 25}})print(result.deleted_count)
运行结果如下:
<pymongo.results.DeleteResult object at 0x10e6ba4c8>14
delete_one 即删除第一条符合条件的数据,delete_many 即删除所有符合条件的数据。它们的返回结果都是 DeleteResult 类型,可以调用 deleted_count 属性获取删除的数据条数。
另外,PyMongo 还提供了一些组合方法,如 find_one_and_delete、find_one_and_replace 和 find_one_and_update,它们分别用于查找后删除、替换和更新操作,其使用方法与上述方法基本一致。
另外,我们还可以对索引进行操作,相关方法有 create_index、create_indexes 和 drop_index 等。
关于 PyMongo 的详细用法,可以参见官方文档:http://api.mongodb.com/python/current/api/pymongo/collection.html。
另外,还有对数据库和集合本身等的一些操作,这里不再一一讲解,可以参见官方文档:http://api.mongodb.com/python/current/api/pymongo/。
在前面我们已经学习了多进程、requests、正则表达式、pyquery、PyMongo 等的基本用法,但我们还没有完整地实现一个爬取案例。本课时,我们就来实现一个完整的网站爬虫案例,把前面学习的知识点串联起来,同时加深对这些知识点的理解。
在本节课开始之前,我们需要做好如下的准备工作:
安装好 Python3(最低为 3.6 版本),并能成功运行 Python3 程序。
了解 Python 多进程的基本原理。
了解 Python HTTP 请求库 requests 的基本用法。
了解正则表达式的用法和 Python 中正则表达式库 re 的基本用法。
了解 Python HTML 解析库 pyquery 的基本用法。
了解 MongoDB 并安装和启动 MongoDB 服务。
了解 Python 的 MongoDB 操作库 PyMongo 的基本用法。
以上内容在前面的课时中均有讲解,如果你还没有准备好,那么我建议你可以再复习一下这些内容。
这节课我们以一个基本的静态网站作为案例进行爬取,需要爬取的链接为:https://static1.scrape.center/,这个网站里面包含了一些电影信息,界面如下:
首页是一个影片列表,每栏里都包含了这部电影的封面、名称、分类、上映时间、评分等内容,同时列表页还支持翻页,点击相应的页码我们就能进入到对应的新列表页。
如果我们点开其中一部电影,会进入电影的详情页面,比如我们点开第一部《霸王别姬》,会得到如下页面:
这里显示的内容更加丰富、包括剧情简介、导演、演员等信息。
我们这节课要完成的目标是:
用 requests 爬取这个站点每一页的电影列表,顺着列表再爬取每个电影的详情页。
用 pyquery 和正则表达式提取每部电影的名称、封面、类别、上映时间、评分、剧情简介等内容。
把以上爬取的内容存入 MongoDB 数据库。
使用多进程实现爬取的加速。
那么我们现在就开始吧。
爬取的第一步肯定要从列表页入手,我们首先观察一下列表页的结构和翻页规则。在浏览器中访问 https://static1.scrape.center/,然后打开浏览器开发者工具,观察每一个电影信息区块对应的 HTML,以及进入到详情页的 URL 是怎样的,如图所示:
可以看到每部电影对应的区块都是一个 div 节点,它的 class 属性都有 el-card 这个值。每个列表页有 10 个这样的 div 节点,也就对应着 10 部电影的信息。
我们再分析下从列表页是怎么进入到详情页的,我们选中电影的名称,看下结果:
可以看到这个名称实际上是一个 h2 节点,其内部的文字就是电影的标题。h2 节点的外面包含了一个 a 节点,这个 a 节点带有 href 属性,这就是一个超链接,其中 href 的值为 /detail/1,这是一个相对网站的根 URL https://static1.scrape.center/ 路径,加上网站的根 URL 就构成了 https://static1.scrape.center/detail/1,也就是这部电影详情页的 URL。这样我们只需要提取这个 href 属性就能构造出详情页的 URL 并接着爬取了。
接下来我们来分析下翻页的逻辑,我们拉到页面的最下方,可以看到分页页码,如图所示:
页面显示一共有 100 条数据,10 页的内容,因此页码最多是 10。接着我们点击第 2 页,如图所示:
可以看到网页的 URL 变成了 https://static1.scrape.center/page/2,相比根 URL 多了 /page/2 这部分内容。网页的结构还是和原来一模一样,所以我们可以和第 1 页一样处理。
接着我们查看第 3 页、第 4 页等内容,可以发现有这么一个规律,每一页的 URL 最后分别变成了 /page/3、/page/4。所以,/page 后面跟的就是列表页的页码,当然第 1 页也是一样,我们在根 URL 后面加上 /page/1 也是能访问的,只不过网站做了一下处理,默认的页码是 1,所以显示第 1 页的内容。
好,分析到这里,逻辑基本就清晰了。
如果我们要完成列表页的爬取,可以这么实现:
遍历页码构造 10 页的索引页 URL。
从每个索引页分析提取出每个电影的详情页 URL。
现在我们写代码来实现一下吧。
首先,我们需要先定义一些基础的变量,并引入一些必要的库,写法如下:
import requestsimport loggingimport reimport pymongofrom pyquery import PyQuery as pqfrom urllib.parse import urljoinlogging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s: %(message)s')BASE_URL = 'https://static1.scrape.center'TOTAL_PAGE = 10
这里我们引入了 requests 用来爬取页面,logging 用来输出信息,re 用来实现正则表达式解析,pyquery 用来直接解析网页,pymongo 用来实现 MongoDB 存储,urljoin 用来做 URL 的拼接。
接着我们定义日志输出级别和输出格式,完成之后再定义 BASE_URL 为当前站点的根 URL,TOTAL_PAGE 为需要爬取的总页码数量。
定义好了之后,我们来实现一个页面爬取的方法吧,实现如下:
def scrape_page(url): logging.info('scraping %s...', url) try: response = requests.get(url) if response.status_code == 200: return response.text logging.error('get invalid status code %s while scraping %s', response.status_code, url) except requests.RequestException: logging.error('error occurred while scraping %s', url, exc_info=True)
考虑到我们不仅要爬取列表页,还要爬取详情页,所以在这里我们定义一个较通用的爬取页面的方法,叫作 scrape_page,它接收一个 url 参数,返回页面的 html 代码。
这里我们首先判断状态码是不是 200,如果是,则直接返回页面的 HTML 代码,如果不是,则会输出错误日志信息。另外,这里实现了 requests 的异常处理,如果出现了爬取异常,则会输出对应的错误日志信息。这时我们将 logging 的 error 方法的 exc_info 参数设置为 True 则可以打印出 Traceback 错误堆栈信息。
好了,有了 scrape_page 方法之后,我们给这个方法传入一个 url,正常情况下它就可以返回页面的 HTML 代码了。
在这个基础上,我们来定义列表页的爬取方法吧,实现如下:
def scrape_index(page): index_url = f'{BASE_URL}/page/{page}' return scrape_page(index_url)
方法名称叫作 scrape_index,这个方法会接收一个 page 参数,即列表页的页码,我们在方法里面实现列表页的 URL 拼接,然后调用 scrape_page 方法爬取即可得到列表页的 HTML 代码了。
获取了 HTML 代码后,下一步就是解析列表页,并得到每部电影的详情页的 URL 了,实现如下:
def parse_index(html): doc = pq(html) links = doc('.el-card .name') for link in links.items(): href = link.attr('href') detail_url = urljoin(BASE_URL, href) logging.info('get detail url %s', detail_url) yield detail_url
在这里我们定义了 parse_index 方法,它接收一个 html 参数,即列表页的 HTML 代码。接着我们用 pyquery 新建一个 PyQuery 对象,完成之后再用 .el-card .name 选择器选出来每个电影名称对应的超链接节点。我们遍历这些节点,通过调用 attr 方法并传入 href 获得详情页的 URL 路径,得到的 href 就是我们在上文所说的类似 /detail/1 这样的结果。由于这并不是一个完整的 URL,所以我们需要借助 urljoin 方法把 BASE_URL 和 href 拼接起来,获得详情页的完整 URL,得到的结果就是类似 https://static1.scrape.center/detail/1 这样完整的 URL 了,最后 yield 返回即可。
这样我们通过调用 parse_index 方法传入列表页的 HTML 代码就可以获得该列表页所有电影的详情页 URL 了。
好,接下来我们把上面的方法串联调用一下,实现如下:
def main(): for page in range(1, TOTAL_PAGE + 1): index_html = scrape_index(page) detail_urls = parse_index(index_html) logging.info('detail urls %s', list(detail_urls))if __name__ == '__main__': main()
这里我们定义了 main 方法来完成上面所有方法的调用,首先使用 range 方法遍历一下页码,得到的 page 是 1~10,接着把 page 变量传给 scrape_index 方法,得到列表页的 HTML,赋值为 index_html 变量。接下来再将 index_html 变量传给 parse_index 方法,得到列表页所有电影的详情页 URL,赋值为 detail_urls,结果是一个生成器,我们调用 list 方法就可以将其输出出来。
好,我们运行一下上面的代码,结果如下:
2020-03-08 22:39:50,505 - INFO: scraping https://static1.scrape.center/page/1...2020-03-08 22:39:51,949 - INFO: get detail url https://static1.scrape.center/detail/12020-03-08 22:39:51,950 - INFO: get detail url https://static1.scrape.center/detail/22020-03-08 22:39:51,950 - INFO: get detail url https://static1.scrape.center/detail/32020-03-08 22:39:51,950 - INFO: get detail url https://static1.scrape.center/detail/42020-03-08 22:39:51,950 - INFO: get detail url https://static1.scrape.center/detail/52020-03-08 22:39:51,950 - INFO: get detail url https://static1.scrape.center/detail/62020-03-08 22:39:51,950 - INFO: get detail url https://static1.scrape.center/detail/72020-03-08 22:39:51,950 - INFO: get detail url https://static1.scrape.center/detail/82020-03-08 22:39:51,950 - INFO: get detail url https://static1.scrape.center/detail/92020-03-08 22:39:51,950 - INFO: get detail url https://static1.scrape.center/detail/102020-03-08 22:39:51,951 - INFO: detail urls ['https://static1.scrape.center/detail/1', 'https://static1.scrape.center/detail/2', 'https://static1.scrape.center/detail/3', 'https://static1.scrape.center/detail/4', 'https://static1.scrape.center/detail/5', 'https://static1.scrape.center/detail/6', 'https://static1.scrape.center/detail/7', 'https://static1.scrape.center/detail/8', 'https://static1.scrape.center/detail/9', 'https://static1.scrape.center/detail/10']2020-03-08 22:39:51,951 - INFO: scraping https://static1.scrape.center/page/2...2020-03-08 22:39:52,842 - INFO: get detail url https://static1.scrape.center/detail/112020-03-08 22:39:52,842 - INFO: get detail url https://static1.scrape.center/detail/12...
由于输出内容比较多,这里只贴了一部分。
可以看到,在这个过程中程序首先爬取了第 1 页列表页,然后得到了对应详情页的每个 URL,接着再接着爬第 2 页、第 3 页,一直到第 10 页,依次输出了每一页的详情页 URL。这样,我们就成功获取到所有电影详情页 URL 啦。
现在我们已经成功获取所有详情页 URL 了,那么下一步当然就是解析详情页并提取出我们想要的信息了。
我们首先观察一下详情页的 HTML 代码吧,如图所示:
经过分析,我们想要提取的内容和对应的节点信息如下:
封面:是一个 img 节点,其 class 属性为 cover。
名称:是一个 h2 节点,其内容便是名称。
类别:是 span 节点,其内容便是类别内容,其外侧是 button 节点,再外侧则是 class 为 categories 的 div 节点。
上映时间:是 span 节点,其内容包含了上映时间,其外侧是包含了 class 为 info 的 div 节点。但注意这个 div 前面还有一个 class 为 info 的 div 节点,我们可以使用其内容来区分,也可以使用 nth-child 或 nth-of-type 这样的选择器来区分。另外提取结果中还多了「上映」二字,我们可以用正则表达式把日期提取出来。
评分:是一个 p 节点,其内容便是评分,p 节点的 class 属性为 score。
剧情简介:是一个 p 节点,其内容便是剧情简介,其外侧是 class 为 drama 的 div 节点。
看上去有点复杂,但是不用担心,有了 pyquery 和正则表达式,我们可以轻松搞定。
接着我们来实现一下代码吧。
刚才我们已经成功获取了详情页的 URL,接下来我们要定义一个详情页的爬取方法,实现如下:
def scrape_detail(url): return scrape_page(url)
这里定义了一个 scrape_detail 方法,它接收一个 url 参数,并通过调用 scrape_page 方法获得网页源代码。由于我们刚才已经实现了 scrape_page 方法,所以在这里我们不用再写一遍页面爬取的逻辑了,直接调用即可,这就做到了代码复用。
另外你可能会问,这个 scrape_detail 方法里面只调用了 scrape_page 方法,没有别的功能,那爬取详情页直接用 scrape_page 方法不就好了,还有必要再单独定义 scrape_detail 方法吗?
答案是有必要,单独定义一个 scrape_detail 方法在逻辑上会显得更清晰,而且以后如果我们想要对 scrape_detail 方法进行改动,比如添加日志输出或是增加预处理,都可以在 scrape_detail 里面实现,而不用改动 scrape_page 方法,灵活性会更好。
好了,详情页的爬取方法已经实现了,接着就是详情页的解析了,实现如下:
def parse_detail(html): doc = pq(html) cover = doc('img.cover').attr('src') name = doc('a > h2').text() categories = [item.text() for item in doc('.categories button span').items()] published_at = doc('.info:contains(上映)').text() published_at = re.search('(\d{4}-\d{2}-\d{2})', published_at).group(1) \ if published_at and re.search('\d{4}-\d{2}-\d{2}', published_at) else None drama = doc('.drama p').text() score = doc('p.score').text() score = float(score) if score else None return { 'cover': cover, 'name': name, 'categories': categories, 'published_at': published_at, 'drama': drama, 'score': score }
这里我们定义了 parse_detail 方法用于解析详情页,它接收一个 html 参数,解析其中的内容,并以字典的形式返回结果。每个字段的解析情况如下所述:
cover:封面,直接选取 class 为 cover 的 img 节点,并调用 attr 方法获取 src 属性的内容即可。
name:名称,直接选取 a 节点的直接子节点 h2 节点,并调用 text 方法提取其文本内容即可得到名称。
categories:类别,由于类别是多个,所以这里首先用 .categories button span 选取了 class 为 categories 的节点内部的 span 节点,其结果是多个,所以这里进行了遍历,取出了每个 span 节点的文本内容,得到的便是列表形式的类别。
published_at:上映时间,由于 pyquery 支持使用 :contains 直接指定包含的文本内容并进行提取,且每个上映时间信息都包含了「上映」二字,所以我们这里就直接使用 :contains(上映) 提取了 class 为 info 的 div 节点。提取之后,得到的结果类似「1993-07-26 上映」这样,但我们并不想要「上映」这两个字,所以我们又调用了正则表达式把日期单独提取出来了。当然这里也可以直接使用 strip 或 replace 方法把多余的文字去掉,但我们为了练习正则表达式的用法,使用了正则表达式来提取。
drama:直接提取 class 为 drama 的节点内部的 p 节点的文本即可。
score:直接提取 class 为 score 的 p 节点的文本即可,但由于提取结果是字符串,所以我们需要把它转成浮点数,即 float 类型。
上述字段提取完毕之后,构造一个字典返回即可。
这样,我们就成功完成了详情页的提取和分析了。
最后,我们将 main 方法稍微改写一下,增加这两个方法的调用,改写如下:
def main(): for page in range(1, TOTAL_PAGE + 1): index_html = scrape_index(page) detail_urls = parse_index(index_html) for detail_url in detail_urls: detail_html = scrape_detail(detail_url) data = parse_detail(detail_html) logging.info('get detail data %s', data)
这里我们首先遍历了 detail_urls,获取了每个详情页的 URL,然后依次调用了 scrape_detail 和 parse_detail 方法,最后得到了每个详情页的提取结果,赋值为 data 并输出。
运行结果如下:
2020-03-08 23:37:35,936 - INFO: scraping https://static1.scrape.center/page/1...2020-03-08 23:37:36,833 - INFO: get detail url https://static1.scrape.center/detail/12020-03-08 23:37:36,833 - INFO: scraping https://static1.scrape.center/detail/1...2020-03-08 23:37:39,985 - INFO: get detail data {'cover': 'https://p0.meituan.net/movie/ce4da3e03e655b5b88ed31b5cd7896cf62472.jpg@464w_644h_1e_1c', 'name': '霸王别姬 - Farewell My Concubine', 'categories': ['剧情', '爱情'], 'published_at': '1993-07-26', 'drama': '影片借一出《霸王别姬》的京戏,牵扯出三个人之间一段随时代风云变幻的爱恨情仇。段小楼(张丰毅 饰)与程蝶衣(张国荣 饰)是一对打小一起长大的师兄弟,两人一个演生,一个饰旦,一向配合天衣无缝,尤其一出《霸王别姬》,更是誉满京城,为此,两人约定合演一辈子《霸王别姬》。但两人对戏剧与人生关系的理解有本质不同,段小楼深知戏非人生,程蝶衣则是人戏不分。段小楼在认为该成家立业之时迎娶了名妓菊仙(巩俐 饰),致使程蝶衣认定菊仙是可耻的第三者,使段小楼做了叛徒,自此,三人围绕一出《霸王别姬》生出的爱恨情仇战开始随着时代风云的变迁不断升级,终酿成悲剧。', 'score': 9.5}2020-03-08 23:37:39,985 - INFO: get detail url https://static1.scrape.center/detail/22020-03-08 23:37:39,985 - INFO: scraping https://static1.scrape.center/detail/2...2020-03-08 23:37:41,061 - INFO: get detail data {'cover': 'https://p1.meituan.net/movie/6bea9af4524dfbd0b668eaa7e187c3df767253.jpg@464w_644h_1e_1c', 'name': '这个杀手不太冷 - Léon', 'categories': ['剧情', '动作', '犯罪'], 'published_at': '1994-09-14', 'drama': '里昂(让·雷诺 饰)是名孤独的职业杀手,受人雇佣。一天,邻居家小姑娘马蒂尔德(纳塔丽·波特曼 饰)敲开他的房门,要求在他那里暂避杀身之祸。原来邻居家的主人是警方缉毒组的眼线,只因贪污了一小包毒品而遭恶警(加里·奥德曼 饰)杀害全家的惩罚。马蒂尔德 得到里昂的留救,幸免于难,并留在里昂那里。里昂教小女孩使枪,她教里昂法文,两人关系日趋亲密,相处融洽。 女孩想着去报仇,反倒被抓,里昂及时赶到,将女孩救回。混杂着哀怨情仇的正邪之战渐次升级,更大的冲突在所难免……', 'score': 9.5}2020-03-08 23:37:41,062 - INFO: get detail url https://static1.scrape.center/detail/3...
由于内容较多,这里省略了后续内容。
可以看到,我们已经成功提取出每部电影的基本信息,包括封面、名称、类别,等等。
成功提取到详情页信息之后,下一步我们就要把数据保存起来了。在上一课时我们学习了 MongoDB 的相关操作,接下来我们就把数据保存到 MongoDB 吧。
在这之前,请确保现在有一个可以正常连接和使用的 MongoDB 数据库。
将数据导入 MongoDB 需要用到 PyMongo 这个库,这个在最开始已经引入过了。那么接下来我们定义一下 MongoDB 的连接配置,实现如下:
MONGO_CONNECTION_STRING = 'mongodb://localhost:27017'MONGO_DB_NAME = 'movies'MONGO_COLLECTION_NAME = 'movies'client = pymongo.MongoClient(MONGO_CONNECTION_STRING)db = client['movies']collection = db['movies']
在这里我们声明了几个变量,介绍如下:
MONGO_CONNECTION_STRING:MongoDB 的连接字符串,里面定义了 MongoDB 的基本连接信息,如 host、port,还可以定义用户名密码等内容。
MONGO_DB_NAME:MongoDB 数据库的名称。
MONGO_COLLECTION_NAME:MongoDB 的集合名称。
这里我们用 MongoClient 声明了一个连接对象,然后依次声明了存储的数据库和集合。
接下来,我们再实现一个将数据保存到 MongoDB 的方法,实现如下:
def save_data(data): collection.update_one({ 'name': data.get('name') }, { '$set': data }, upsert=True)
在这里我们声明了一个 save_data 方法,它接收一个 data 参数,也就是我们刚才提取的电影详情信息。在方法里面,我们调用了 update_one 方法,第 1 个参数是查询条件,即根据 name 进行查询;第 2 个参数是 data 对象本身,也就是所有的数据,这里我们用 $set 操作符表示更新操作;第 3 个参数很关键,这里实际上是 upsert 参数,如果把这个设置为 True,则可以做到存在即更新,不存在即插入的功能,更新会根据第一个参数设置的 name 字段,所以这样可以防止数据库中出现同名的电影数据。
注:实际上电影可能有同名,但该场景下的爬取数据没有同名情况,当然这里更重要的是实现 MongoDB 的去重操作。
好的,那么接下来我们将 main 方法稍微改写一下就好了,改写如下:
def main(): for page in range(1, TOTAL_PAGE + 1): index_html = scrape_index(page) detail_urls = parse_index(index_html) for detail_url in detail_urls: detail_html = scrape_detail(detail_url) data = parse_detail(detail_html) logging.info('get detail data %s', data) logging.info('saving data to mongodb') save_data(data) logging.info('data saved successfully')
这里增加了 save_data 方法的调用,并加了一些日志信息。
重新运行,我们看下输出结果:
2020-03-09 01:10:27,094 - INFO: scraping https://static1.scrape.center/page/1...2020-03-09 01:10:28,019 - INFO: get detail url https://static1.scrape.center/detail/12020-03-09 01:10:28,019 - INFO: scraping https://static1.scrape.center/detail/1...2020-03-09 01:10:29,183 - INFO: get detail data {'cover': 'https://p0.meituan.net/movie/ce4da3e03e655b5b88ed31b5cd7896cf62472.jpg@464w_644h_1e_1c', 'name': '霸王别姬 - Farewell My Concubine', 'categories': ['剧情', '爱情'], 'published_at': '1993-07-26', 'drama': '影片借一出《霸王别姬》的京戏,牵扯出三个人之间一段随时代风云变幻的爱恨情仇。段小楼(张丰毅 饰)与程蝶衣(张国荣 饰)是一对打小一起长大的师兄弟,两人一个演生,一个饰旦,一向配合天衣无缝,尤其一出《霸王别姬》,更是誉满京城,为此,两人约定合演一辈子《霸王别姬》。但两人对戏剧与人生关系的理解有本质不同,段小楼深知戏非人生,程蝶衣则是人戏不分。段小楼在认为该成家立业之时迎娶了名妓菊仙(巩俐 饰),致使程蝶衣认定菊仙是可耻的第三者,使段小楼做了叛徒,自此,三人围绕一出《霸王别姬》生出的爱恨情仇战开始随着时代风云的变迁不断升级,终酿成悲剧。', 'score': 9.5}2020-03-09 01:10:29,183 - INFO: saving data to mongodb2020-03-09 01:10:29,288 - INFO: data saved successfully2020-03-09 01:10:29,288 - INFO: get detail url https://static1.scrape.center/detail/22020-03-09 01:10:29,288 - INFO: scraping https://static1.scrape.center/detail/2...2020-03-09 01:10:30,250 - INFO: get detail data {'cover': 'https://p1.meituan.net/movie/6bea9af4524dfbd0b668eaa7e187c3df767253.jpg@464w_644h_1e_1c', 'name': '这个杀手不太冷 - Léon', 'categories': ['剧情', '动作', '犯罪'], 'published_at': '1994-09-14', 'drama': '里昂(让·雷诺 饰)是名孤独的职业杀手,受人雇佣。一天,邻居家小姑娘马蒂尔德(纳塔丽·波特曼 饰)敲开他的房门,要求在他那里暂避杀身之祸。原来邻居家的主人是警方缉毒组的眼线,只因贪污了一小包毒品而遭恶警(加里·奥德曼 饰)杀害全家的惩罚。马蒂尔德 得到里昂的留救,幸免于难,并留在里昂那里。里昂教小女孩使枪,她教里昂法文,两人关系日趋亲密,相处融洽。 女孩想着去报仇,反倒被抓,里昂及时赶到,将女孩救回。混杂着哀怨情仇的正邪之战渐次升级,更大的冲突在所难免……', 'score': 9.5}2020-03-09 01:10:30,250 - INFO: saving data to mongodb2020-03-09 01:10:30,253 - INFO: data saved successfully...
在运行结果中我们可以发现,这里输出了存储 MongoDB 成功的信息。
运行完毕之后我们可以使用 MongoDB 客户端工具(例如 Robo 3T )可视化地查看已经爬取到的数据,结果如下:
这样,所有的电影就被我们成功爬取下来啦!不多不少,正好 100 条。
由于整个的爬取是单进程的,而且只能逐条爬取,速度稍微有点慢,有没有方法来对整个爬取过程进行加速呢?
在前面我们讲了多进程的基本原理和使用方法,下面我们就来实践一下多进程的爬取吧。
由于一共有 10 页详情页,并且这 10 页内容是互不干扰的,所以我们可以一页开一个进程来爬取。由于这 10 个列表页页码正好可以提前构造成一个列表,所以我们可以选用多进程里面的进程池 Pool 来实现这个过程。
这里我们需要改写下 main 方法的调用,实现如下:
import multiprocessingdef main(page): index_html = scrape_index(page) detail_urls = parse_index(index_html) for detail_url in detail_urls: detail_html = scrape_detail(detail_url) data = parse_detail(detail_html) logging.info('get detail data %s', data) logging.info('saving data to mongodb') save_data(data) logging.info('data saved successfully')if __name__ == '__main__': pool = multiprocessing.Pool() pages = range(1, TOTAL_PAGE + 1) pool.map(main, pages) pool.close() pool.join()
这里我们首先给 main 方法添加一个参数 page,用以表示列表页的页码。接着我们声明了一个进程池,并声明 pages 为所有需要遍历的页码,即 1~10。最后调用 map 方法,第 1 个参数就是需要被调用的方法,第 2 个参数就是 pages,即需要遍历的页码。
这样 pages 就会被依次遍历。把 1~10 这 10 个页码分别传递给 main 方法,并把每次的调用变成一个进程,加入到进程池中执行,进程池会根据当前运行环境来决定运行多少进程。比如我的机器的 CPU 有 8 个核,那么进程池的大小会默认设定为 8,这样就会同时有 8 个进程并行执行。
运行输出结果和之前类似,但是可以明显看到加了多进程执行之后,爬取速度快了非常多。我们可以清空一下之前的 MongoDB 数据,可以发现数据依然可以被正常保存到 MongoDB 数据库中。
到现在为止,我们就完成了全站电影数据的爬取并实现了存储和优化。
这节课我们用到的库有 requests、pyquery、PyMongo、multiprocessing、re、logging 等,通过这个案例实战,我们把前面学习到的知识都串联了起来,其中的一些实现方法可以好好思考和体会,也希望这个案例能够让你对爬虫的实现有更实际的了解。
]]>
首先,我们来了解一下 URI 和 URL,URI 的全称为 Uniform Resource Identifier,即统一资源标志符,URL 的全称为 Universal Resource Locator,即统一资源定位符。
举例来说,https://github.com/favicon.ico,它是一个 URL,也是一个 URI。即有这样的一个图标资源,我们用 URL/URI 来唯一指定了它的访问方式,这其中包括了访问协议 HTTPS、访问路径(即根目录)和资源名称 favicon.ico。通过这样一个链接,我们便可以从互联网上找到这个资源,这就是 URL/URI。
URL 是 URI 的子集,也就是说每个 URL 都是 URI,但不是每个 URI 都是 URL。那么,什么样的 URI 不是 URL 呢?URI 还包括一个子类叫作 URN,它的全称为 Universal Resource Name,即统一资源名称。
URN 只命名资源而不指定如何定位资源,比如 urn:isbn:0451450523 指定了一本书的 ISBN,可以唯一标识这本书,但是没有指定到哪里定位这本书,这就是 URN。URL、URN 和 URI 的关系可以用图表示。
但是在目前的互联网,URN 的使用非常少,几乎所有的 URI 都是 URL,所以一般的网页链接我们可以称之为 URL,也可以称之为 URI。
接下来,我们再了解一个概念 —— 超文本,其英文名称叫作 Hypertext,我们在浏览器里看到的网页就是超文本解析而成的,其网页源代码是一系列 HTML 代码,里面包含了一系列标签,比如 img 显示图片,p 指定显示段落等。浏览器解析这些标签后,便形成了我们平常看到的网页,而网页的源代码 HTML 就可以称作超文本。
例如,我们在 Chrome 浏览器里面打开任意一个页面,如淘宝首页,右击任一地方并选择 “检查” 项(或者直接按快捷键 F12),即可打开浏览器的开发者工具,这时在 Elements 选项卡即可看到当前网页的源代码,这些源代码都是超文本,如图所示。
在淘宝的首页 https://www.taobao.com/中,URL 的开头会有 http 或 https,这个就是访问资源需要的协议类型,有时我们还会看到 ftp、sftp、smb 开头的 URL,那么这里的 ftp、sftp、smb 都是指的协议类型。在爬虫中,我们抓取的页面通常就是 http 或 https 协议的,我们在这里首先来了解一下这两个协议的含义。
HTTP 的全称是 Hyper Text Transfer Protocol,中文名叫作超文本传输协议,HTTP 协议是用于从网络传输超文本数据到本地浏览器的传送协议,它能保证高效而准确地传送超文本文档。HTTP 由万维网协会(World Wide Web Consortium)和 Internet 工作小组 IETF(Internet Engineering Task Force)共同合作制定的规范,目前广泛使用的是 HTTP 1.1 版本。
HTTPS 的全称是 Hyper Text Transfer Protocol over Secure Socket Layer,是以安全为目标的 HTTP 通道,简单讲是 HTTP 的安全版,即 HTTP 下加入 SSL 层,简称为 HTTPS。
HTTPS 的安全基础是 SSL,因此通过它传输的内容都是经过 SSL 加密的,它的主要作用可以分为两种:
现在越来越多的网站和 App 都已经向 HTTPS 方向发展。例如:
因此,HTTPS 已经是大势所趋。
我们在浏览器中输入一个 URL,回车之后便可以在浏览器中观察到页面内容。实际上,这个过程是浏览器向网站所在的服务器发送了一个请求,网站服务器接收到这个请求后进行处理和解析,然后返回对应的响应,接着传回给浏览器。响应里包含了页面的源代码等内容,浏览器再对其进行解析,便将网页呈现了出来,传输模型如图所示。
此处客户端即代表我们自己的 PC 或手机浏览器,服务器即要访问的网站所在的服务器。
为了更直观地说明这个过程,这里用 Chrome 浏览器的开发者模式下的 Network 监听组件来做下演示,它可以显示访问当前请求网页时发生的所有网络请求和响应。
打开 Chrome 浏览器,右击并选择 “检查” 项,即可打开浏览器的开发者工具。这里访问百度 http://www.baidu.com/,输入该 URL 后回车,观察这个过程中发生了怎样的网络请求。可以看到,在 Network 页面下方出现了一个个的条目,其中一个条目就代表一次发送请求和接收响应的过程,如图所示。
我们先观察第一个网络请求,即 www.baidu.com,其中各列的含义如下。
我们点击这个条目即可看到其更详细的信息,如图所示。
首先是 General 部分,Request URL 为请求的 URL,Request Method 为请求的方法,Status Code 为响应状态码,Remote Address 为远程服务器的地址和端口,Referrer Policy 为 Referrer 判别策略。
再继续往下,可以看到,有 Response Headers 和 Request Headers,这分别代表响应头和请求头。请求头里带有许多请求信息,例如浏览器标识、Cookies、Host 等信息,这是请求的一部分,服务器会根据请求头内的信息判断请求是否合法,进而作出对应的响应。图中看到的 Response Headers 就是响应的一部分,例如其中包含了服务器的类型、文档类型、日期等信息,浏览器接受到响应后,会解析响应内容,进而呈现网页内容。
下面我们分别来介绍一下请求和响应都包含哪些内容。
请求,由客户端向服务端发出,可以分为 4 部分内容:请求方法(Request Method)、请求的网址(Request URL)、请求头(Request Headers)、请求体(Request Body)。
常见的请求方法有两种:GET 和 POST。
在浏览器中直接输入 URL 并回车,这便发起了一个 GET 请求,请求的参数会直接包含到 URL 里。例如,在百度中搜索 Python,这就是一个 GET 请求,链接为 https://www.baidu.com/s?wd=Python,其中 URL 中包含了请求的参数信息,这里参数 wd 表示要搜寻的关键字。POST 请求大多在表单提交时发起。比如,对于一个登录表单,输入用户名和密码后,点击 “登录” 按钮,这通常会发起一个 POST 请求,其数据通常以表单的形式传输,而不会体现在 URL 中。
GET 和 POST 请求方法有如下区别。
一般来说,登录时,需要提交用户名和密码,其中包含了敏感信息,使用 GET 方式请求的话,密码就会暴露在 URL 里面,造成密码泄露,所以这里最好以 POST 方式发送。上传文件时,由于文件内容比较大,也会选用 POST 方式。
我们平常遇到的绝大部分请求都是 GET 或 POST 请求,另外还有一些请求方法,如 HEAD、PUT、DELETE、OPTIONS、CONNECT、TRACE 等,我们简单将其总结为下表。
请求的网址本表参考:http://www.runoob.com/http/http-methods.html。
请求的网址,即统一资源定位符 URL,它可以唯一确定我们想请求的资源。
请求头,用来说明服务器要使用的附加信息,比较重要的信息有 Cookie、Referer、User-Agent 等。下面简要说明一些常用的头信息。
因此,请求头是请求的重要组成部分,在写爬虫时,大部分情况下都需要设定请求头。
请求体一般承载的内容是 POST 请求中的表单数据,而对于 GET 请求,请求体则为空。
例如,这里我登录 GitHub 时捕获到的请求和响应如图所示。
登录之前,我们填写了用户名和密码信息,提交时这些内容就会以表单数据的形式提交给服务器,此时需要注意 Request Headers 中指定 Content-Type 为 application/x-www-form-urlencoded。只有设置 Content-Type 为 application/x-www-form-urlencoded,才会以表单数据的形式提交。另外,我们也可以将 Content-Type 设置为 application/json 来提交 JSON 数据,或者设置为 multipart/form-data 来上传文件。
表格中列出了 Content-Type 和 POST 提交数据方式的关系。
在爬虫中,如果要构造 POST 请求,需要使用正确的 Content-Type,并了解各种请求库的各个参数设置时使用的是哪种 Content-Type,不然可能会导致 POST 提交后无法正常响应。
响应,由服务端返回给客户端,可以分为三部分:响应状态码(Response Status Code)、响应头(Response Headers)和响应体(Response Body)。
响应状态码表示服务器的响应状态,如 200 代表服务器正常响应,404 代表页面未找到,500 代表服务器内部发生错误。在爬虫中,我们可以根据状态码来判断服务器响应状态,如状态码为 200,则证明成功返回数据,再进行进一步的处理,否则直接忽略。下表列出了常见的错误代码及错误原因。
响应头包含了服务器对请求的应答信息,如 Content-Type、Server、Set-Cookie 等。下面简要说明一些常用的响应头信息。
最重要的当属响应体的内容了。响应的正文数据都在响应体中,比如请求网页时,它的响应体就是网页的 HTML 代码;请求一张图片时,它的响应体就是图片的二进制数据。我们做爬虫请求网页后,要解析的内容就是响应体,如图所示。
在浏览器开发者工具中点击 Preview,就可以看到网页的源代码,也就是响应体的内容,它是解析的目标。
在做爬虫时,我们主要通过响应体得到网页的源代码、JSON 数据等,然后从中做相应内容的提取。
当我们用浏览器访问网站时,页面各不相同,那么你有没有想过它为何会呈现成这个样子呢?本部分讲解网页的基本组成、结构和节点等内容。
首先,我们来了解网页的基本组成,网页可以分为三大部分:HTML、CSS 和 JavaScript。
如果把网页比作一个人的话,HTML 相当于骨架,JavaScript 相当于肌肉,CSS 相当于皮肤,三者结合起来才能形成一个完整的网页。下面我们来分别介绍一下这三部分的功能。
HTML 是用来描述网页的一种语言,其全称叫作 Hyper Text Markup Language,即超文本标记语言。
我们浏览的网页包括文字、按钮、图片和视频等各种复杂的元素,其基础架构就是 HTML。不同类型的元素通过不同类型的标签来表示,如图片用 img 标签表示,视频用 video 标签表示,段落用 p 标签表示,它们之间的布局又常通过布局标签 div 嵌套组合而成,各种标签通过不同的排列和嵌套就可以形成网页的框架。
我们在 Chrome 浏览器中打开百度,右击并选择 “检查” 项(或按 F12 键),打开开发者模式,这时在 Elements 选项卡中即可看到网页的源代码,如图所示。
这就是 HTML,整个网页就是由各种标签嵌套组合而成的。这些标签定义的节点元素相互嵌套和组合形成了复杂的层次关系,就形成了网页的架构。
虽然 HTML 定义了网页的结构,但是只有 HTML 页面的布局并不美观,可能只是简单的节点元素的排列,为了让网页看起来更好看一些,这里就需要借助 CSS 了。
CSS,全称叫作 Cascading Style Sheets,即层叠样式表。“层叠” 是指当在 HTML 中引用了数个样式文件,并且样式发生冲突时,浏览器能依据层叠顺序处理。“样式” 指网页中文字大小、颜色、元素间距、排列等格式。
CSS 是目前唯一的网页页面排版样式标准,有了它的帮助,页面才会变得更为美观。
图的右侧即为 CSS,例如:
#head_wrapper.s-ps-islite .s-p-top { position: absolute; bottom: 40px; width: 100%; height: 181px;
这就是一个 CSS 样式。大括号前面是一个 CSS 选择器。此选择器的作用是首先选中 id 为 head_wrapper 且 class 为 s-ps-islite 的节点,然后再选中其内部的 class 为 s-p-top 的节点。
大括号内部写的就是一条条样式规则,例如 position 指定了这个元素的布局方式为绝对布局,bottom 指定元素的下边距为 40 像素,width 指定了宽度为 100% 占满父元素,height 则指定了元素的高度。
也就是说,我们将位置、宽度、高度等样式配置统一写成这样的形式,然后用大括号括起来,接着在开头再加上 CSS 选择器,这就代表这个样式对 CSS 选择器选中的元素生效,元素就会根据此样式来展示了。
在网页中,一般会统一定义整个网页的样式规则,并写入 CSS 文件中(其后缀为 css)。在 HTML 中,只需要用 link 标签即可引入写好的 CSS 文件,这样整个页面就会变得美观、优雅。
JavaScript,简称 JS,是一种脚本语言。HTML 和 CSS 配合使用,提供给用户的只是一种静态信息,缺乏交互性。我们在网页里可能会看到一些交互和动画效果,如下载进度条、提示框、轮播图等,这通常就是 JavaScript 的功劳。它的出现使得用户与信息之间不只是一种浏览与显示的关系,而是实现了一种实时、动态、交互的页面功能。
JavaScript 通常也是以单独的文件形式加载的,后缀为 js,在 HTML 中通过 script 标签即可引入,例如:
<script src="jquery-2.1.0.js"></script>
综上所述,HTML 定义了网页的内容和结构,CSS 描述了网页的布局,JavaScript 定义了网页的行为。
了解了网页的基本组成,我们再用一个例子来感受下 HTML 的基本结构。新建一个文本文件,名称可以自取,后缀为 html,内容如下:
<!DOCTYPE html><html><head><meta charset="UTF-8"><title>This is a Demo</title></head><body><div id="container"><div class="wrapper"><h2 class="title">Hello World</h2><p class="text">Hello, this is a paragraph.</p></div></div></body></html>
这就是一个最简单的 HTML 实例。开头用 DOCTYPE 定义了文档类型,其次最外层是 html 标签,最后还有对应的结束标签来表示闭合,其内部是 head 标签和 body 标签,分别代表网页头和网页体,它们也需要结束标签。
head 标签内定义了一些页面的配置和引用,如:<meta charset="UTF-8">,它指定了网页的编码为 UTF-8。title 标签则定义了网页的标题,会显示在网页的选项卡中,不会显示在正文中。body 标签内则是在网页正文中显示的内容。
div 标签定义了网页中的区块,它的 id 是 container,这是一个非常常用的属性,且 id 的内容在网页中是唯一的,我们可以通过它来获取这个区块。然后在此区块内又有一个 div 标签,它的 class 为 wrapper,这也是一个非常常用的属性,经常与 CSS 配合使用来设定样式。
然后此区块内部又有一个 h2 标签,这代表一个二级标题。另外,还有一个 p 标签,这代表一个段落。在这两者中直接写入相应的内容即可在网页中呈现出来,它们也有各自的 class 属性。
将代码保存后,在浏览器中打开该文件,可以看到如图所示的内容。
可以看到,在选项卡上显示了 This is a Demo 字样,这是我们在 head 中的 title 里定义的文字。而网页正文是 body 标签内部定义的各个元素生成的,可以看到这里显示了二级标题和段落。
这个实例便是网页的一般结构。一个网页的标准形式是 html 标签内嵌套 head 和 body 标签,head 内定义网页的配置和引用,body 内定义网页的正文。
在 HTML 中,所有标签定义的内容都是节点,它们构成了一个 HTML DOM 树。
我们先看下什么是 DOM。DOM 是 W3C(万维网联盟)的标准,其英文全称 Document Object Model,即文档对象模型。它定义了访问 HTML 和 XML 文档的标准:
W3C 文档对象模型(DOM)是中立于平台和语言的接口,它允许程序和脚本动态地访问和更新文档的内容、结构和样式。
W3C DOM 标准被分为 3 个不同的部分:
根据 W3C 的 HTML DOM 标准,HTML 文档中的所有内容都是节点:
HTML DOM 将 HTML 文档视作树结构,这种结构被称为节点树,如图所示。
通过 HTML DOM,树中的所有节点均可通过 JavaScript 访问,所有 HTML 节点元素均可被修改,也可以被创建或删除。
节点树中的节点彼此拥有层级关系。我们常用父(parent)、子(child)和兄弟(sibling)等术语描述这些关系。父节点拥有子节点,同级的子节点被称为兄弟节点。
在节点树中,顶端节点称为根(root)。除了根节点之外,每个节点都有父节点,同时可拥有任意数量的子节点或兄弟节点。图中展示了节点树以及节点之间的关系。
本段参考 W3SCHOOL,链接:http://www.w3school.com.cn/htmldom/dom_nodes.asp。
我们知道网页由一个个节点组成,CSS 选择器会根据不同的节点设置不同的样式规则,那么怎样来定位节点呢?
在 CSS 中,我们使用 CSS 选择器来定位节点。例如,上例中 div 节点的 id 为 container,那么就可以表示为 #container,其中 # 开头代表选择 id,其后紧跟 id 的名称。
另外,如果我们想选择 class 为 wrapper 的节点,便可以使用 .wrapper,这里以点“.”开头代表选择 class,其后紧跟 class 的名称。另外,还有一种选择方式,那就是根据标签名筛选,例如想选择二级标题,直接用 h2 即可。这是最常用的 3 种表示,分别是根据 id、class、标签名筛选,请牢记它们的写法。
另外,CSS 选择器还支持嵌套选择,各个选择器之间加上空格分隔开便可以代表嵌套关系,如 #container .wrapper p 则代表先选择 id 为 container 的节点,然后选中其内部的 class 为 wrapper 的节点,然后再进一步选中其内部的 p 节点。
另外,如果不加空格,则代表并列关系,如 div#container .wrapper p.text 代表先选择 id 为 container 的 div 节点,然后选中其内部的 class 为 wrapper 的节点,再进一步选中其内部的 class 为 text 的 p 节点。这就是 CSS 选择器,其筛选功能还是非常强大的。
另外,CSS 选择器还有一些其他语法规则,具体如表所示。因为表中的内容非常的多,我就不在一一介绍,课下你可以参考文字内容详细理解掌握这部分知识。
选 择 器 | 例 子 | 例子描述 |
---|---|---|
.class | .intro | 选择 class="intro" 的所有节点 |
#id | #firstname | 选择 id="firstname" 的所有节点 |
* | * | 选择所有节点 |
element | p | 选择所有 p 节点 |
element,element | div,p | 选择所有 div 节点和所有 p 节点 |
element element | div p | 选择 div 节点内部的所有 p 节点 |
element>element | div>p | 选择父节点为 div 节点的所有 p 节点 |
element+element | div+p | 选择紧接在 div 节点之后的所有 p 节点 |
[attribute] | [target] | 选择带有 target 属性的所有节点 |
[attribute=value] | [target=blank] | 选择 target="blank" 的所有节点 |
[attribute~=value] | [title~=flower] | 选择 title 属性包含单词 flower 的所有节点 |
:link | a:link | 选择所有未被访问的链接 |
:visited | a:visited | 选择所有已被访问的链接 |
:active | a:active | 选择活动链接 |
:hover | a:hover | 选择鼠标指针位于其上的链接 |
:focus | input:focus | 选择获得焦点的 input 节点 |
:first-letter | p:first-letter | 选择每个 p 节点的首字母 |
:first-line | p:first-line | 选择每个 p 节点的首行 |
:first-child | p:first-child | 选择属于父节点的第一个子节点的所有 p 节点 |
:before | p:before | 在每个 p 节点的内容之前插入内容 |
:after | p:after | 在每个 p 节点的内容之后插入内容 |
:lang(language) | p:lang | 选择带有以 it 开头的 lang 属性值的所有 p 节点 |
element1~element2 | p~ul | 选择前面有 p 节点的所有 ul 节点 |
[attribute^=value] | a[src^="https"] | 选择其 src 属性值以 https 开头的所有 a 节点 |
[attribute$=value] | a[src$=".pdf"] | 选择其 src 属性以.pdf 结尾的所有 a 节点 |
[attribute*=value] | a[src*="abc"] | 选择其 src 属性中包含 abc 子串的所有 a 节点 |
:first-of-type | p:first-of-type | 选择属于其父节点的首个 p 节点的所有 p 节点 |
:last-of-type | p:last-of-type | 选择属于其父节点的最后 p 节点的所有 p 节点 |
:only-of-type | p:only-of-type | 选择属于其父节点唯一的 p 节点的所有 p 节点 |
:only-child | p:only-child | 选择属于其父节点的唯一子节点的所有 p 节点 |
:nth-child(n) | p:nth-child | 选择属于其父节点的第二个子节点的所有 p 节点 |
:nth-last-child(n) | p:nth-last-child | 同上,从最后一个子节点开始计数 |
:nth-of-type(n) | p:nth-of-type | 选择属于其父节点第二个 p 节点的所有 p 节点 |
:nth-last-of-type(n) | p:nth-last-of-type | 同上,但是从最后一个子节点开始计数 |
:last-child | p:last-child | 选择属于其父节点最后一个子节点的所有 p 节点 |
:root | :root | 选择文档的根节点 |
:empty | p:empty | 选择没有子节点的所有 p 节点(包括文本节点) |
:target | #news:target | 选择当前活动的 #news 节点 |
:enabled | input:enabled | 选择每个启用的 input 节点 |
:disabled | input:disabled | 选择每个禁用的 input 节点 |
:checked | input:checked | 选择每个被选中的 input 节点 |
:not(selector) | :not | 选择非 p 节点的所有节点 |
::selection | ::selection | 选择被用户选取的节点部分 |
另外,还有一种比较常用的选择器是 XPath,这种选择方式后面会详细介绍。
# 爬虫基本原理我们可以把互联网比作一张大网,而爬虫(即网络爬虫)便是在网上爬行的蜘蛛。如果把网的节点比作一个个网页,爬虫爬到这就相当于访问了该页面,获取了其信息。可以把节点间的连线比作网页与网页之间的链接关系,这样蜘蛛通过一个节点后,可以顺着节点连线继续爬行到达下一个节点,即通过一个网页继续获取后续的网页,这样整个网的节点便可以被蜘蛛全部爬行到,网站的数据就可以被抓取下来了。
简单来说,爬虫就是获取网页并提取和保存信息的自动化程序,下面概要介绍一下。
爬虫首先要做的工作就是获取网页,这里就是获取网页的源代码。
源代码里包含了网页的部分有用信息,所以只要把源代码获取下来,就可以从中提取想要的信息了。
前面讲了请求和响应的概念,向网站的服务器发送一个请求,返回的响应体便是网页源代码。所以,最关键的部分就是构造一个请求并发送给服务器,然后接收到响应并将其解析出来,那么这个流程怎样实现呢?总不能手工去截取网页源码吧?
不用担心,Python 提供了许多库来帮助我们实现这个操作,如 urllib、requests 等。我们可以用这些库来帮助我们实现 HTTP 请求操作,请求和响应都可以用类库提供的数据结构来表示,得到响应之后只需要解析数据结构中的 Body 部分即可,即得到网页的源代码,这样我们可以用程序来实现获取网页的过程了。
获取网页源代码后,接下来就是分析网页源代码,从中提取我们想要的数据。首先,最通用的方法便是采用正则表达式提取,这是一个万能的方法,但是在构造正则表达式时比较复杂且容易出错。
另外,由于网页的结构有一定的规则,所以还有一些根据网页节点属性、CSS 选择器或 XPath 来提取网页信息的库,如 Beautiful Soup、pyquery、lxml 等。使用这些库,我们可以高效快速地从中提取网页信息,如节点的属性、文本值等。
提取信息是爬虫非常重要的部分,它可以使杂乱的数据变得条理清晰,以便我们后续处理和分析数据。
提取信息后,我们一般会将提取到的数据保存到某处以便后续使用。这里保存形式有多种多样,如可以简单保存为 TXT 文本或 JSON 文本,也可以保存到数据库,如 MySQL 和 MongoDB 等,还可保存至远程服务器,如借助 SFTP 进行操作等。
说到自动化程序,意思是说爬虫可以代替人来完成这些操作。首先,我们手工当然可以提取这些信息,但是当量特别大或者想快速获取大量数据的话,肯定还是要借助程序。爬虫就是代替我们来完成这份爬取工作的自动化程序,它可以在抓取过程中进行各种异常处理、错误重试等操作,确保爬取持续高效地运行。
在网页中我们能看到各种各样的信息,最常见的便是常规网页,它们对应着 HTML 代码,而最常抓取的便是 HTML 源代码。
另外,可能有些网页返回的不是 HTML 代码,而是一个 JSON 字符串(其中 API 接口大多采用这样的形式),这种格式的数据方便传输和解析,它们同样可以抓取,而且数据提取更加方便。
此外,我们还可以看到各种二进制数据,如图片、视频和音频等。利用爬虫,我们可以将这些二进制数据抓取下来,然后保存成对应的文件名。
另外,还可以看到各种扩展名的文件,如 CSS、JavaScript 和配置文件等,这些其实也是最普通的文件,只要在浏览器里面可以访问到,就可以将其抓取下来。
上述内容其实都对应各自的 URL,是基于 HTTP 或 HTTPS 协议的,只要是这种数据,爬虫都可以抓取。
有时候,我们在用 urllib 或 requests 抓取网页时,得到的源代码实际和浏览器中看到的不一样。
这是一个非常常见的问题。现在网页越来越多地采用 Ajax、前端模块化工具来构建,整个网页可能都是由 JavaScript 渲染出来的,也就是说原始的 HTML 代码就是一个空壳,例如:
<!DOCTYPE html><html><head><meta charset="UTF-8"><title>This is a Demo</title></head><body><div id="container"></div></body><script src="app.js"></script></html>
body 节点里面只有一个 id 为 container 的节点,但是需要注意在 body 节点后引入了 app.js,它便负责整个网站的渲染。
在浏览器中打开这个页面时,首先会加载这个 HTML 内容,接着浏览器会发现其中引入了一个 app.js 文件,然后便会接着去请求这个文件,获取到该文件后,便会执行其中的 JavaScript 代码,而 JavaScript 则会改变 HTML 中的节点,向其添加内容,最后得到完整的页面。
但是在用 urllib 或 requests 等库请求当前页面时,我们得到的只是这个 HTML 代码,它不会帮助我们去继续加载这个 JavaScript 文件,这样也就看不到浏览器中的内容了。
这也解释了为什么有时我们得到的源代码和浏览器中看到的不一样。
因此,使用基本 HTTP 请求库得到的源代码可能跟浏览器中的页面源代码不太一样。对于这样的情况,我们可以分析其后台 Ajax 接口,也可使用 Selenium、Splash 这样的库来实现模拟 JavaScript 渲染。
后面,我们会详细介绍如何采集 JavaScript 渲染的网页。本节介绍了爬虫的一些基本原理,这可以帮助我们在后面编写爬虫时更加得心应手。
还有一些网站,在打开浏览器时就自动登录了,而且很长时间都不会失效,这种情况又是为什么?其实这里面涉及 Session 和 Cookies 的相关知识,本节就来揭开它们的神秘面纱。
在开始介绍它们之前,我们需要先了解一下静态网页和动态网页的概念。这里还是前面的示例代码,内容如下:
<!DOCTYPE html><html><head><meta charset="UTF-8"><title>This is a Demo</title></head><body><div id="container"><div class="wrapper"><h2 class="title">Hello World</h2><p class="text">Hello, this is a paragraph.</p></div></div></body></html>
这是最基本的 HTML 代码,我们将其保存为一个 .html 文件,然后把它放在某台具有固定公网 IP 的主机上,主机上装上 Apache 或 Nginx 等服务器,这样这台主机就可以作为服务器了,其他人便可以通过访问服务器看到这个页面,这就搭建了一个最简单的网站。
这种网页的内容是 HTML 代码编写的,文字、图片等内容均通过写好的 HTML 代码来指定,这种页面叫作静态网页。它加载速度快,编写简单,但是存在很大的缺陷,如可维护性差,不能根据 URL 灵活多变地显示内容等。例如,我们想要给这个网页的 URL 传入一个 name 参数,让其在网页中显示出来,是无法做到的。
因此,动态网页应运而生,它可以动态解析 URL 中参数的变化,关联数据库并动态呈现不同的页面内容,非常灵活多变。我们现在遇到的大多数网站都是动态网站,它们不再是一个简单的 HTML,而是可能由 JSP、PHP、Python 等语言编写的,其功能比静态网页强大和丰富太多了。
此外,动态网站还可以实现用户登录和注册的功能。再回到开头来看提到的问题,很多页面是需要登录之后才可以查看的。按照一般的逻辑来说,输入用户名和密码登录之后,肯定是拿到了一种类似凭证的东西,有了它,我们才能保持登录状态,才能访问登录之后才能看到的页面。
那么,这种神秘的凭证到底是什么呢?其实它就是 Session 和 Cookies 共同产生的结果,下面我们来一探究竟。
在了解 Session 和 Cookies 之前,我们还需要了解 HTTP 的一个特点,叫作无状态。
HTTP 的无状态是指 HTTP 协议对事务处理是没有记忆能力的,也就是说服务器不知道客户端是什么状态。
当我们向服务器发送请求后,服务器解析此请求,然后返回对应的响应,服务器负责完成这个过程,而且这个过程是完全独立的,服务器不会记录前后状态的变化,也就是缺少状态记录。
这意味着如果后续需要处理前面的信息,则必须重传,这也导致需要额外传递一些前面的重复请求,才能获取后续响应,然而这种效果显然不是我们想要的。为了保持前后状态,我们肯定不能将前面的请求全部重传一次,这太浪费资源了,对于这种需要用户登录的页面来说,更是棘手。
这时两个用于保持 HTTP 连接状态的技术就出现了,它们分别是 Session 和 Cookies。Session 在服务端,也就是网站的服务器,用来保存用户的 Session 信息;Cookies 在客户端,也可以理解为浏览器端,有了 Cookies,浏览器在下次访问网页时会自动附带上它发送给服务器,服务器通过识别 Cookies 并鉴定出是哪个用户,然后再判断用户是否是登录状态,进而返回对应的响应。
我们可以理解为 Cookies 里面保存了登录的凭证,有了它,只需要在下次请求携带 Cookies 发送请求而不必重新输入用户名、密码等信息重新登录了。
因此在爬虫中,有时候处理需要登录才能访问的页面时,我们一般会直接将登录成功后获取的 Cookies 放在请求头里面直接请求,而不必重新模拟登录。
好了,了解 Session 和 Cookies 的概念之后,我们再来详细剖析它们的原理。
Session,中文称之为会话,其本身的含义是指有始有终的一系列动作 / 消息。比如,打电话时,从拿起电话拨号到挂断电话这中间的一系列过程可以称为一个 Session。
而在 Web 中,Session 对象用来存储特定用户 Session 所需的属性及配置信息。这样,当用户在应用程序的 Web 页之间跳转时,存储在 Session 对象中的变量将不会丢失,而是在整个用户 Session 中一直存在下去。当用户请求来自应用程序的 Web 页时,如果该用户还没有 Session,则 Web 服务器将自动创建一个 Session 对象。当 Session 过期或被放弃后,服务器将终止该 Session。
Cookies 指某些网站为了辨别用户身份、进行 Session 跟踪而存储在用户本地终端上的数据。
那么,我们怎样利用 Cookies 保持状态呢?当客户端第一次请求服务器时,服务器会返回一个响应头中带有 Set-Cookie 字段的响应给客户端,用来标记是哪一个用户,客户端浏览器会把 Cookies 保存起来。当浏览器下一次再请求该网站时,浏览器会把此 Cookies 放到请求头一起提交给服务器,Cookies 携带了 Session ID 信息,服务器检查该 Cookies 即可找到对应的 Session 是什么,然后再判断 Session 来以此来辨认用户状态。
在成功登录某个网站时,服务器会告诉客户端设置哪些 Cookies 信息,在后续访问页面时客户端会把 Cookies 发送给服务器,服务器再找到对应的 Session 加以判断。如果 Session 中的某些设置登录状态的变量是有效的,那就证明用户处于登录状态,此时返回登录之后才可以查看的网页内容,浏览器再进行解析便可以看到了。
反之,如果传给服务器的 Cookies 是无效的,或者 Session 已经过期了,我们将不能继续访问页面,此时可能会收到错误的响应或者跳转到登录页面重新登录。
所以,Cookies 和 Session 需要配合,一个处于客户端,一个处于服务端,二者共同协作,就实现了登录 Session 控制。
接下来,我们来看看 Cookies 都有哪些内容。这里以知乎为例,在浏览器开发者工具中打开 Application 选项卡,然后在左侧会有一个 Storage 部分,最后一项即为 Cookies,将其点开,如图所示,这些就是 Cookies。
可以看到,这里有很多条目,其中每个条目可以称为 Cookie。它有如下几个属性。
从表面意思来说,会话 Cookie 就是把 Cookie 放在浏览器内存里,浏览器在关闭之后该 Cookie 即失效;持久 Cookie 则会保存到客户端的硬盘中,下次还可以继续使用,用于长久保持用户登录状态。
其实严格来说,没有会话 Cookie 和持久 Cookie 之 分,只是由 Cookie 的 Max Age 或 Expires 字段决定了过期的时间。
因此,一些持久化登录的网站其实就是把 Cookie 的有效时间和 Session 有效期设置得比较长,下次我们再访问页面时仍然携带之前的 Cookie,就可以直接保持登录状态。
在谈论 Session 机制的时候,常常听到这样一种误解 ——“只要关闭浏览器,Session 就消失了”。可以想象一下会员卡的例子,除非顾客主动对店家提出销卡,否则店家绝对不会轻易删除顾客的资料。对 Session 来说,也是一样,除非程序通知服务器删除一个 Session,否则服务器会一直保留。比如,程序一般都是在我们做注销操作时才去删除 Session。
但是当我们关闭浏览器时,浏览器不会主动在关闭之前通知服务器它将要关闭,所以服务器根本不会有机会知道浏览器已经关闭。之所以会有这种错觉,是因为大部分网站都使用会话 Cookie 来保存 Session ID 信息,而关闭浏览器后 Cookies 就消失了,再次连接服务器时,也就无法找到原来的 Session 了。如果服务器设置的 Cookies 保存到硬盘上,或者使用某种手段改写浏览器发出的 HTTP 请求头,把原来的 Cookies 发送给服务器,则再次打开浏览器,仍然能够找到原来的 Session ID,依旧还是可以保持登录状态的。
而且恰恰是由于关闭浏览器不会导致 Session 被删除,这就需要服务器为 Session 设置一个失效时间,当距离客户端上一次使用 Session 的时间超过这个失效时间时,服务器就可以认为客户端已经停止了活动,才会把 Session 删除以节省存储空间。
我们知道,在一台计算机中,我们可以同时打开许多软件,比如同时浏览网页、听音乐、打字等等,看似非常正常。但仔细想想,为什么计算机可以做到这么多软件同时运行呢?这就涉及到计算机中的两个重要概念:多进程和多线程了。
同样,在编写爬虫程序的时候,为了提高爬取效率,我们可能想同时运行多个爬虫任务。这里同样需要涉及多进程和多线程的知识。
本课时,我们就先来了解一下多线程的基本原理,以及在 Python 中如何实现多线程。
说起多线程,就不得不先说什么是线程。然而想要弄明白什么是线程,又不得不先说什么是进程。
进程我们可以理解为是一个可以独立运行的程序单位,比如打开一个浏览器,这就开启了一个浏览器进程;打开一个文本编辑器,这就开启了一个文本编辑器进程。但一个进程中是可以同时处理很多事情的,比如在浏览器中,我们可以在多个选项卡中打开多个页面,有的页面在播放音乐,有的页面在播放视频,有的网页在播放动画,它们可以同时运行,互不干扰。为什么能同时做到同时运行这么多的任务呢?这里就需要引出线程的概念了,其实这一个个任务,实际上就对应着一个个线程的执行。
而进程呢?它就是线程的集合,进程就是由一个或多个线程构成的,线程是操作系统进行运算调度的最小单位,是进程中的一个最小运行单元。比如上面所说的浏览器进程,其中的播放音乐就是一个线程,播放视频也是一个线程,当然其中还有很多其他的线程在同时运行,这些线程的并发或并行执行最后使得整个浏览器可以同时运行这么多的任务。
了解了线程的概念,多线程就很容易理解了,多线程就是一个进程中同时执行多个线程,前面所说的浏览器的情景就是典型的多线程执行。
说到多进程和多线程,这里就需要再讲解两个概念,那就是并发和并行。我们知道,一个程序在计算机中运行,其底层是处理器通过运行一条条的指令来实现的。
并发,英文叫作 concurrency。它是指同一时刻只能有一条指令执行,但是多个线程的对应的指令被快速轮换地执行。比如一个处理器,它先执行线程 A 的指令一段时间,再执行线程 B 的指令一段时间,再切回到线程 A 执行一段时间。
由于处理器执行指令的速度和切换的速度非常非常快,人完全感知不到计算机在这个过程中有多个线程切换上下文执行的操作,这就使得宏观上看起来多个线程在同时运行。但微观上只是这个处理器在连续不断地在多个线程之间切换和执行,每个线程的执行一定会占用这个处理器一个时间片段,同一时刻,其实只有一个线程在执行。
并行,英文叫作 parallel。它是指同一时刻,有多条指令在多个处理器上同时执行,并行必须要依赖于多个处理器。不论是从宏观上还是微观上,多个线程都是在同一时刻一起执行的。
并行只能在多处理器系统中存在,如果我们的计算机处理器只有一个核,那就不可能实现并行。而并发在单处理器和多处理器系统中都是可以存在的,因为仅靠一个核,就可以实现并发。
举个例子,比如系统处理器需要同时运行多个线程。如果系统处理器只有一个核,那它只能通过并发的方式来运行这些线程。如果系统处理器有多个核,当一个核在执行一个线程时,另一个核可以执行另一个线程,这样这两个线程就实现了并行执行,当然其他的线程也可能和另外的线程处在同一个核上执行,它们之间就是并发执行。具体的执行方式,就取决于操作系统的调度了。
在一个程序进程中,有一些操作是比较耗时或者需要等待的,比如等待数据库的查询结果的返回,等待网页结果的响应。如果使用单线程,处理器必须要等到这些操作完成之后才能继续往下执行其他操作,而这个线程在等待的过程中,处理器明显是可以来执行其他的操作的。如果使用多线程,处理器就可以在某个线程等待的时候,去执行其他的线程,从而从整体上提高执行效率。
像上述场景,线程在执行过程中很多情况下是需要等待的。比如网络爬虫就是一个非常典型的例子,爬虫在向服务器发起请求之后,有一段时间必须要等待服务器的响应返回,这种任务就属于 IO 密集型任务。对于这种任务,如果我们启用多线程,处理器就可以在某个线程等待的过程中去处理其他的任务,从而提高整体的爬取效率。
但并不是所有的任务都是 IO 密集型任务,还有一种任务叫作计算密集型任务,也可以称之为 CPU 密集型任务。顾名思义,就是任务的运行一直需要处理器的参与。此时如果我们开启了多线程,一个处理器从一个计算密集型任务切换到切换到另一个计算密集型任务上去,处理器依然不会停下来,始终会忙于计算,这样并不会节省总体的时间,因为需要处理的任务的计算总量是不变的。如果线程数目过多,反而还会在线程切换的过程中多耗费一些时间,整体效率会变低。
所以,如果任务不全是计算密集型任务,我们可以使用多线程来提高程序整体的执行效率。尤其对于网络爬虫这种 IO 密集型任务来说,使用多线程会大大提高程序整体的爬取效率。
在 Python 中,实现多线程的模块叫作 threading,是 Python 自带的模块。下面我们来了解下使用 threading 实现多线程的方法。
首先,我们可以使用 Thread 类来创建一个线程,创建时需要指定 target 参数为运行的方法名称,如果被调用的方法需要传入额外的参数,则可以通过 Thread 的 args 参数来指定。示例如下:
import threadingimport timedef target(second): print(f'Threading {threading.current_thread().name} is running') print(f'Threading {threading.current_thread().name} sleep {second}s') time.sleep(second) print(f'Threading {threading.current_thread().name} is ended')print(f'Threading {threading.current_thread().name} is running')for i in [1, 5]: thread = threading.Thread(target=target, args=[i]) thread.start()print(f'Threading {threading.current_thread().name} is ended')
运行结果如下:
Threading MainThread is runningThreading Thread-1 is runningThreading Thread-1 sleep 1sThreading Thread-2 is runningThreading Thread-2 sleep 5sThreading MainThread is endedThreading Thread-1 is endedThreading Thread-2 is ended
在这里我们首先声明了一个方法,叫作 target,它接收一个参数为 second,通过方法的实现可以发现,这个方法其实就是执行了一个 time.sleep 休眠操作,second 参数就是休眠秒数,其前后都 print 了一些内容,其中线程的名字我们通过 threading.current_thread().name 来获取出来,如果是主线程的话,其值就是 MainThread,如果是子线程的话,其值就是 Thread-*。
然后我们通过 Thead 类新建了两个线程,target 参数就是刚才我们所定义的方法名,args 以列表的形式传递。两次循环中,这里 i 分别就是 1 和 5,这样两个线程就分别休眠 1 秒和 5 秒,声明完成之后,我们调用 start 方法即可开始线程的运行。
观察结果我们可以发现,这里一共产生了三个线程,分别是主线程 MainThread 和两个子线程 Thread-1、Thread-2。另外我们观察到,主线程首先运行结束,紧接着 Thread-1、Thread-2 才接连运行结束,分别间隔了 1 秒和 4 秒。这说明主线程并没有等待子线程运行完毕才结束运行,而是直接退出了,有点不符合常理。
如果我们想要主线程等待子线程运行完毕之后才退出,可以让每个子线程对象都调用下 join 方法,实现如下:
threads = []for i in [1, 5]: thread = threading.Thread(target=target, args=[i]) threads.append(thread) thread.start()for thread in threads: thread.join()
运行结果如下:
Threading MainThread is runningThreading Thread-1 is runningThreading Thread-1 sleep 1sThreading Thread-2 is runningThreading Thread-2 sleep 5sThreading Thread-1 is endedThreading Thread-2 is endedThreading MainThread is ended
这样,主线程必须等待子线程都运行结束,主线程才继续运行并结束。
另外,我们也可以通过继承 Thread 类的方式创建一个线程,该线程需要执行的方法写在类的 run 方法里面即可。上面的例子的等价改写为:
import threadingimport timeclass MyThread(threading.Thread): def __init__(self, second): threading.Thread.__init__(self) self.second = second def run(self): print(f'Threading {threading.current_thread().name} is running') print(f'Threading {threading.current_thread().name} sleep {self.second}s') time.sleep(self.second) print(f'Threading {threading.current_thread().name} is ended')print(f'Threading {threading.current_thread().name} is running')threads = []for i in [1, 5]: thread = MyThread(i) threads.append(thread) thread.start()for thread in threads: thread.join()print(f'Threading {threading.current_thread().name} is ended')
运行结果如下:
Threading MainThread is running Threading Thread-1 is running Threading Thread-1 sleep 1s Threading Thread-2 is running Threading Thread-2 sleep 5s Threading Thread-1 is ended Threading Thread-2 is ended Threading MainThread is ended
可以看到,两种实现方式,其运行效果是相同的。
在线程中有一个叫作守护线程的概念,如果一个线程被设置为守护线程,那么意味着这个线程是“不重要”的,这意味着,如果主线程结束了而该守护线程还没有运行完,那么它将会被强制结束。在 Python 中我们可以通过 setDaemon 方法来将某个线程设置为守护线程。
示例如下:
import threadingimport timedef target(second): print(f'Threading {threading.current_thread().name} is running') print(f'Threading {threading.current_thread().name} sleep {second}s') time.sleep(second) print(f'Threading {threading.current_thread().name} is ended')print(f'Threading {threading.current_thread().name} is running')t1 = threading.Thread(target=target, args=[2])t1.start()t2 = threading.Thread(target=target, args=[5])t2.setDaemon(True)t2.start()print(f'Threading {threading.current_thread().name} is ended')
在这里我们通过 setDaemon 方法将 t2 设置为了守护线程,这样主线程在运行完毕时,t2 线程会随着线程的结束而结束。
运行结果如下:
Threading MainThread is running Threading Thread-1 is running Threading Thread-1 sleep 2s Threading Thread-2 is running Threading Thread-2 sleep 5s Threading MainThread is ended Threading Thread-1 is ended
可以看到,我们没有看到 Thread-2 打印退出的消息,Thread-2 随着主线程的退出而退出了。
不过细心的你可能会发现,这里并没有调用 join 方法,如果我们让 t1 和 t2 都调用 join 方法,主线程就会仍然等待各个子线程执行完毕再退出,不论其是否是守护线程。
在一个进程中的多个线程是共享资源的,比如在一个进程中,有一个全局变量 count 用来计数,现在我们声明多个线程,每个线程运行时都给 count 加 1,让我们来看看效果如何,代码实现如下:
import threadingimport timecount = 0class MyThread(threading.Thread): def __init__(self): threading.Thread.__init__(self) def run(self): global count temp = count + 1 time.sleep(0.001) count = tempthreads = []for _ in range(1000): thread = MyThread() thread.start() threads.append(thread)for thread in threads: thread.join()print(f'Final count: {count}')
在这里,我们声明了 1000 个线程,每个线程都是现取到当前的全局变量 count 值,然后休眠一小段时间,然后对 count 赋予新的值。
那这样,按照常理来说,最终的 count 值应该为 1000。但其实不然,我们来运行一下看看。
运行结果如下:
Final count: 69
最后的结果居然只有 69,而且多次运行或者换个环境运行结果是不同的。
这是为什么呢?因为 count 这个值是共享的,每个线程都可以在执行 temp = count 这行代码时拿到当前 count 的值,但是这些线程中的一些线程可能是并发或者并行执行的,这就导致不同的线程拿到的可能是同一个 count 值,最后导致有些线程的 count 的加 1 操作并没有生效,导致最后的结果偏小。
所以,如果多个线程同时对某个数据进行读取或修改,就会出现不可预料的结果。为了避免这种情况,我们需要对多个线程进行同步,要实现同步,我们可以对需要操作的数据进行加锁保护,这里就需要用到 threading.Lock 了。
加锁保护是什么意思呢?就是说,某个线程在对数据进行操作前,需要先加锁,这样其他的线程发现被加锁了之后,就无法继续向下执行,会一直等待锁被释放,只有加锁的线程把锁释放了,其他的线程才能继续加锁并对数据做修改,修改完了再释放锁。这样可以确保同一时间只有一个线程操作数据,多个线程不会再同时读取和修改同一个数据,这样最后的运行结果就是对的了。
我们可以将代码修改为如下内容:
import threadingimport timecount = 0class MyThread(threading.Thread): def __init__(self): threading.Thread.__init__(self) def run(self): global count lock.acquire() temp = count + 1 time.sleep(0.001) count = temp lock.release()lock = threading.Lock()threads = []for _ in range(1000): thread = MyThread() thread.start() threads.append(thread)for thread in threads: thread.join()print(f'Final count: {count}')
在这里我们声明了一个 lock 对象,其实就是 threading.Lock 的一个实例,然后在 run 方法里面,获取 count 前先加锁,修改完 count 之后再释放锁,这样多个线程就不会同时获取和修改 count 的值了。
运行结果如下:
Final count: 1000
这样运行结果就正常了。
关于 Python 多线程的内容,这里暂且先介绍这些,关于 theading 更多的使用方法,如信号量、队列等,可以参考官方文档:https://docs.python.org/zh-cn/3.7/library/threading.html#module-threading。
由于 Python 中 GIL 的限制,导致不论是在单核还是多核条件下,在同一时刻只能运行一个线程,导致 Python 多线程无法发挥多核并行的优势。
GIL 全称为 Global Interpreter Lock,中文翻译为全局解释器锁,其最初设计是出于数据安全而考虑的。
在 Python 多线程下,每个线程的执行方式如下:
可见,某个线程想要执行,必须先拿到 GIL,我们可以把 GIL 看作是通行证,并且在一个 Python 进程中,GIL 只有一个。拿不到通行证的线程,就不允许执行。这样就会导致,即使是多核条件下,一个 Python 进程下的多个线程,同一时刻也只能执行一个线程。
不过对于爬虫这种 IO 密集型任务来说,这个问题影响并不大。而对于计算密集型任务来说,由于 GIL 的存在,多线程总体的运行效率相比可能反而比单线程更低。
在上一课时我们了解了多线程的基本概念,同时我们也提到,Python 中的多线程是不能很好发挥多核优势的,如果想要发挥多核优势,最好还是使用多进程。
那么本课时我们就来了解下多进程的基本概念和用 Python 实现多进程的方法。
进程(Process)是具有一定独立功能的程序关于某个数据集合上的一次运行活动,是系统进行资源分配和调度的一个独立单位。
顾名思义,多进程就是启用多个进程同时运行。由于进程是线程的集合,而且进程是由一个或多个线程构成的,所以多进程的运行意味着有大于或等于进程数量的线程在运行。
通过上一课时我们知道,由于进程中 GIL 的存在,Python 中的多线程并不能很好地发挥多核优势,一个进程中的多个线程,在同一时刻只能有一个线程运行。
而对于多进程来说,每个进程都有属于自己的 GIL,所以,在多核处理器下,多进程的运行是不会受 GIL 的影响的。因此,多进程能更好地发挥多核的优势。
当然,对于爬虫这种 IO 密集型任务来说,多线程和多进程影响差别并不大。对于计算密集型任务来说,Python 的多进程相比多线程,其多核运行效率会有成倍的提升。
总的来说,Python 的多进程整体来看是比多线程更有优势的。所以,在条件允许的情况下,能用多进程就尽量用多进程。
不过值得注意的是,由于进程是系统进行资源分配和调度的一个独立单位,所以各个进程之间的数据是无法共享的,如多个进程无法共享一个全局变量,进程之间的数据共享需要有单独的机制来实现,这在后面也会讲到。
在 Python 中也有内置的库来实现多进程,它就是 multiprocessing。
multiprocessing 提供了一系列的组件,如 Process(进程)、Queue(队列)、Semaphore(信号量)、Pipe(管道)、Lock(锁)、Pool(进程池)等,接下来让我们来了解下它们的使用方法。
在 multiprocessing 中,每一个进程都用一个 Process 类来表示。它的 API 调用如下:
Process([group [, target [, name [, args [, kwargs]]]]])
我们先用一个实例来感受一下:
import multiprocessingdef process(index): print(f'Process: {index}')if __name__ == '__main__': for i in range(5): p = multiprocessing.Process(target=process, args=(i,)) p.start()
这是一个实现多进程最基础的方式:通过创建 Process 来新建一个子进程,其中 target 参数传入方法名,args 是方法的参数,是以元组的形式传入,其和被调用的方法 process 的参数是一一对应的。
注意:这里 args 必须要是一个元组,如果只有一个参数,那也要在元组第一个元素后面加一个逗号,如果没有逗号则和单个元素本身没有区别,无法构成元组,导致参数传递出现问题。
创建完进程之后,我们通过调用 start 方法即可启动进程了。运行结果如下:
Process: 0Process: 1Process: 2Process: 3Process: 4
可以看到,我们运行了 5 个子进程,每个进程都调用了 process 方法。process 方法的 index 参数通过 Process 的 args 传入,分别是 0~4 这 5 个序号,最后打印出来,5 个子进程运行结束。
由于进程是 Python 中最小的资源分配单元,因此这些进程和线程不同,各个进程之间的数据是不会共享的,每启动一个进程,都会独立分配资源。
另外,在当前 CPU 核数足够的情况下,这些不同的进程会分配给不同的 CPU 核来运行,实现真正的并行执行。
multiprocessing 还提供了几个比较有用的方法,如我们可以通过 cpu_count 的方法来获取当前机器 CPU 的核心数量,通过 active_children 方法获取当前还在运行的所有进程。
下面通过一个实例来看一下:
import multiprocessingimport timedef process(index): time.sleep(index) print(f'Process: {index}')if __name__ == '__main__': for i in range(5): p = multiprocessing.Process(target=process, args=[i]) p.start() print(f'CPU number: {multiprocessing.cpu_count()}') for p in multiprocessing.active_children(): print(f'Child process name: {p.name} id: {p.pid}') print('Process Ended')
运行结果如下:
Process: 0CPU number: 8Child process name: Process-5 id: 73595Child process name: Process-2 id: 73592Child process name: Process-3 id: 73593Child process name: Process-4 id: 73594Process EndedProcess: 1Process: 2Process: 3Process: 4
在上面的例子中我们通过 cpu_count 成功获取了 CPU 核心的数量:8 个,当然不同的机器结果可能不同。
另外我们还通过 active_children 获取到了当前正在活跃运行的进程列表。然后我们遍历了每个进程,并将它们的名称和进程号打印出来了,这里进程号直接使用 pid 属性即可获取,进程名称直接通过 name 属性即可获取。
以上我们就完成了多进程的创建和一些基本信息的获取。
在上面的例子中,我们创建进程是直接使用 Process 这个类来创建的,这是一种创建进程的方式。不过,创建进程的方式不止这一种,同样,我们也可以像线程 Thread 一样来通过继承的方式创建一个进程类,进程的基本操作我们在子类的 run 方法中实现即可。
通过一个实例来看一下:
from multiprocessing import Processimport timeclass MyProcess(Process): def __init__(self, loop): Process.__init__(self) self.loop = loop def run(self): for count in range(self.loop): time.sleep(1) print(f'Pid: {self.pid} LoopCount: {count}')if __name__ == '__main__': for i in range(2, 5): p = MyProcess(i) p.start()
我们首先声明了一个构造方法,这个方法接收一个 loop 参数,代表循环次数,并将其设置为全局变量。在 run 方法中,又使用这个 loop 变量循环了 loop 次并打印了当前的进程号和循环次数。
在调用时,我们用 range 方法得到了 2、3、4 三个数字,并把它们分别初始化了 MyProcess 进程,然后调用 start 方法将进程启动起来。
注意:这里进程的执行逻辑需要在 run 方法中实现,启动进程需要调用 start 方法,调用之后 run 方法便会执行。
运行结果如下:
Pid: 73667 LoopCount: 0Pid: 73668 LoopCount: 0Pid: 73669 LoopCount: 0Pid: 73667 LoopCount: 1Pid: 73668 LoopCount: 1Pid: 73669 LoopCount: 1Pid: 73668 LoopCount: 2Pid: 73669 LoopCount: 2Pid: 73669 LoopCount: 3
可以看到,三个进程分别打印出了 2、3、4 条结果,即进程 73667 打印了 2 次 结果,进程 73668 打印了 3 次结果,进程 73669 打印了 4 次结果。
注意,这里的进程 pid 代表进程号,不同机器、不同时刻运行结果可能不同。
通过上面的方式,我们也非常方便地实现了一个进程的定义。为了复用方便,我们可以把一些方法写在每个进程类里封装好,在使用时直接初始化一个进程类运行即可。
在多进程中,同样存在守护进程的概念,如果一个进程被设置为守护进程,当父进程结束后,子进程会自动被终止,我们可以通过设置 daemon 属性来控制是否为守护进程。
还是原来的例子,增加了 deamon 属性的设置:
from multiprocessing import Processimport timeclass MyProcess(Process): def __init__(self, loop): Process.__init__(self) self.loop = loop def run(self): for count in range(self.loop): time.sleep(1) print(f'Pid: {self.pid} LoopCount: {count}')if __name__ == '__main__': for i in range(2, 5): p = MyProcess(i) p.daemon = True p.start()print('Main Process ended')
运行结果如下:
Main Process ended
结果很简单,因为主进程没有做任何事情,直接输出一句话结束,所以在这时也直接终止了子进程的运行。
这样可以有效防止无控制地生成子进程。这样的写法可以让我们在主进程运行结束后无需额外担心子进程是否关闭,避免了独立子进程的运行。
上面的运行效果其实不太符合我们预期:主进程运行结束时,子进程(守护进程)也都退出了,子进程什么都没来得及执行。
能不能让所有子进程都执行完了然后再结束呢?当然是可以的,只需要加入 join 方法即可,我们可以将代码改写如下:
processes = []for i in range(2, 5): p = MyProcess(i) processes.append(p) p.daemon = True p.start()for p in processes: p.join()
运行结果如下:
Pid: 40866 LoopCount: 0Pid: 40867 LoopCount: 0Pid: 40868 LoopCount: 0Pid: 40866 LoopCount: 1Pid: 40867 LoopCount: 1Pid: 40868 LoopCount: 1Pid: 40867 LoopCount: 2Pid: 40868 LoopCount: 2Pid: 40868 LoopCount: 3Main Process ended
在调用 start 和 join 方法后,父进程就可以等待所有子进程都执行完毕后,再打印出结束的结果。
默认情况下,join 是无限期的。也就是说,如果有子进程没有运行完毕,主进程会一直等待。这种情况下,如果子进程出现问题陷入了死循环,主进程也会无限等待下去。怎么解决这个问题呢?可以给 join 方法传递一个超时参数,代表最长等待秒数。如果子进程没有在这个指定秒数之内完成,会被强制返回,主进程不再会等待。也就是说这个参数设置了主进程等待该子进程的最长时间。
例如这里我们传入 1,代表最长等待 1 秒,代码改写如下:
processes = []for i in range(3, 5): p = MyProcess(i) processes.append(p) p.daemon = True p.start()for p in processes: p.join(1)
运行结果如下:
Pid: 40970 LoopCount: 0Pid: 40971 LoopCount: 0Pid: 40970 LoopCount: 1Pid: 40971 LoopCount: 1Main Process ended
可以看到,有的子进程本来要运行 3 秒,结果运行 1 秒就被强制返回了,由于是守护进程,该子进程被终止了。
到这里,我们就了解了守护进程、进程等待和超时设置的用法。
当然,终止进程不止有守护进程这一种做法,我们也可以通过 terminate 方法来终止某个子进程,另外我们还可以通过 is_alive 方法判断进程是否还在运行。
下面我们来看一个实例:
import multiprocessingimport timedef process(): print('Starting') time.sleep(5) print('Finished')if __name__ == '__main__': p = multiprocessing.Process(target=process) print('Before:', p, p.is_alive()) p.start() print('During:', p, p.is_alive()) p.terminate() print('Terminate:', p, p.is_alive()) p.join() print('Joined:', p, p.is_alive())
在上面的例子中,我们用 Process 创建了一个进程,接着调用 start 方法启动这个进程,然后调用 terminate 方法将进程终止,最后调用 join 方法。
另外,在进程运行不同的阶段,我们还通过 is_alive 方法判断当前进程是否还在运行。
运行结果如下:
Before: <Process(Process-1, initial)> FalseDuring: <Process(Process-1, started)> TrueTerminate: <Process(Process-1, started)> TrueJoined: <Process(Process-1, stopped[SIGTERM])> False
这里有一个值得注意的地方,在调用 terminate 方法之后,我们用 is_alive 方法获取进程的状态发现依然还是运行状态。在调用 join 方法之后,is_alive 方法获取进程的运行状态才变为终止状态。
所以,在调用 terminate 方法之后,记得要调用一下 join 方法,这里调用 join 方法可以为进程提供时间来更新对象状态,用来反映出最终的进程终止效果。
在上面的一些实例中,我们可能会遇到如下的运行结果:
Pid: 73993 LoopCount: 0Pid: 73993 LoopCount: 1Pid: 73994 LoopCount: 0Pid: 73994 LoopCount: 1Pid: 73994 LoopCount: 2Pid: 73995 LoopCount: 0Pid: 73995 LoopCount: 1Pid: 73995 LoopCount: 2Pid: 73995 LoopCount: 3Main Process ended
我们发现,有的输出结果没有换行。这是什么原因造成的呢?
这种情况是由多个进程并行执行导致的,两个进程同时进行了输出,结果第一个进程的换行没有来得及输出,第二个进程就输出了结果,导致最终输出没有换行。
那如何来避免这种问题?如果我们能保证,多个进程运行期间的任一时间,只能一个进程输出,其他进程等待,等刚才那个进程输出完毕之后,另一个进程再进行输出,这样就不会出现输出没有换行的现象了。
这种解决方案实际上就是实现了进程互斥,避免了多个进程同时抢占临界区(输出)资源。我们可以通过 multiprocessing 中的 Lock 来实现。Lock,即锁,在一个进程输出时,加锁,其他进程等待。等此进程执行结束后,释放锁,其他进程可以进行输出。
我们首先实现一个不加锁的实例,代码如下:
from multiprocessing import Process, Lockimport timeclass MyProcess(Process): def __init__(self, loop, lock): Process.__init__(self) self.loop = loop self.lock = lock def run(self): for count in range(self.loop): time.sleep(0.1) # self.lock.acquire() print(f'Pid: {self.pid} LoopCount: {count}') # self.lock.release()if __name__ == '__main__': lock = Lock() for i in range(10, 15): p = MyProcess(i, lock) p.start()
运行结果如下:
Pid: 74030 LoopCount: 0Pid: 74031 LoopCount: 0Pid: 74032 LoopCount: 0Pid: 74033 LoopCount: 0Pid: 74034 LoopCount: 0Pid: 74030 LoopCount: 1Pid: 74031 LoopCount: 1Pid: 74032 LoopCount: 1Pid: 74033 LoopCount: 1Pid: 74034 LoopCount: 1Pid: 74030 LoopCount: 2...
可以看到运行结果中有些输出已经出现了不换行的问题。
我们对其加锁,取消掉刚才代码中的两行注释,重新运行,运行结果如下:
Pid: 74061 LoopCount: 0Pid: 74062 LoopCount: 0Pid: 74063 LoopCount: 0Pid: 74064 LoopCount: 0Pid: 74065 LoopCount: 0Pid: 74061 LoopCount: 1Pid: 74062 LoopCount: 1Pid: 74063 LoopCount: 1Pid: 74064 LoopCount: 1Pid: 74065 LoopCount: 1Pid: 74061 LoopCount: 2Pid: 74062 LoopCount: 2Pid: 74064 LoopCount: 2...
这时输出效果就正常了。
所以,在访问一些临界区资源时,使用 Lock 可以有效避免进程同时占用资源而导致的一些问题。
进程互斥锁可以使同一时刻只有一个进程能访问共享资源,如上面的例子所展示的那样,在同一时刻只能有一个进程输出结果。但有时候我们需要允许多个进程来访问共享资源,同时还需要限制能访问共享资源的进程的数量。
这种需求该如何实现呢?可以用信号量,信号量是进程同步过程中一个比较重要的角色。它可以控制临界资源的数量,实现多个进程同时访问共享资源,限制进程的并发量。
如果你学过操作系统,那么一定对这方面非常了解,如果你还不了解信号量是什么,可以先熟悉一下这个概念。
我们可以用 multiprocessing 库中的 Semaphore 来实现信号量。
那么接下来我们就用一个实例来演示一下进程之间利用 Semaphore 做到多个进程共享资源,同时又限制同时可访问的进程数量,代码如下:
from multiprocessing import Process, Semaphore, Lock, Queueimport timebuffer = Queue(10)empty = Semaphore(2)full = Semaphore(0)lock = Lock()class Consumer(Process): def run(self): global buffer, empty, full, lock while True: full.acquire() lock.acquire() buffer.get() print('Consumer pop an element') time.sleep(1) lock.release() empty.release()class Producer(Process): def run(self): global buffer, empty, full, lock while True: empty.acquire() lock.acquire() buffer.put(1) print('Producer append an element') time.sleep(1) lock.release() full.release()if __name__ == '__main__': p = Producer() c = Consumer() p.daemon = c.daemon = True p.start() c.start() p.join() c.join() print('Main Process Ended')
如上代码实现了经典的生产者和消费者问题。它定义了两个进程类,一个是消费者,一个是生产者。
另外,这里使用 multiprocessing 中的 Queue 定义了一个共享队列,然后定义了两个信号量 Semaphore,一个代表缓冲区空余数,一个表示缓冲区占用数。
生产者 Producer 使用 acquire 方法来占用一个缓冲区位置,缓冲区空闲区大小减 1,接下来进行加锁,对缓冲区进行操作,然后释放锁,最后让代表占用的缓冲区位置数量加 1,消费者则相反。
运行结果如下:
Producer append an elementProducer append an elementConsumer pop an elementConsumer pop an elementProducer append an elementProducer append an elementConsumer pop an elementConsumer pop an elementProducer append an elementProducer append an elementConsumer pop an elementConsumer pop an elementProducer append an elementProducer append an element
我们发现两个进程在交替运行,生产者先放入缓冲区物品,然后消费者取出,不停地进行循环。 你可以通过上面的例子来体会信号量 Semaphore 的用法,通过 Semaphore 我们很好地控制了进程对资源的并发访问数量。
在上面的例子中我们使用 Queue 作为进程通信的共享队列使用。
而如果我们把上面程序中的 Queue 换成普通的 list,是完全起不到效果的,因为进程和进程之间的资源是不共享的。即使在一个进程中改变了这个 list,在另一个进程也不能获取到这个 list 的状态,所以声明全局变量对多进程是没有用处的。
那进程如何共享数据呢?可以用 Queue,即队列。当然这里的队列指的是 multiprocessing 里面的 Queue。
依然用上面的例子,我们一个进程向队列中放入随机数据,然后另一个进程取出数据。
from multiprocessing import Process, Semaphore, Lock, Queueimport timefrom random import randombuffer = Queue(10)empty = Semaphore(2)full = Semaphore(0)lock = Lock()class Consumer(Process): def run(self): global buffer, empty, full, lock while True: full.acquire() lock.acquire() print(f'Consumer get {buffer.get()}') time.sleep(1) lock.release() empty.release()class Producer(Process): def run(self): global buffer, empty, full, lock while True: empty.acquire() lock.acquire() num = random() print(f'Producer put {num}') buffer.put(num) time.sleep(1) lock.release() full.release()if __name__ == '__main__': p = Producer() c = Consumer() p.daemon = c.daemon = True p.start() c.start() p.join() c.join() print('Main Process Ended')
运行结果如下:
Producer put 0.719213647437Producer put 0.44287326683Consumer get 0.719213647437Consumer get 0.44287326683Producer put 0.722859424381Producer put 0.525321338921Consumer get 0.722859424381Consumer get 0.525321338921
在上面的例子中我们声明了两个进程,一个进程为生产者 Producer,另一个为消费者 Consumer,生产者不断向 Queue 里面添加随机数,消费者不断从队列里面取随机数。
生产者在放数据的时候调用了 Queue 的 put 方法,消费者在取的时候使用了 get 方法,这样我们就通过 Queue 实现两个进程的数据共享了。
刚才我们使用 Queue 实现了进程间的数据共享,那么进程之间直接通信,如收发信息,用什么比较好呢?可以用 Pipe,管道。
管道,我们可以把它理解为两个进程之间通信的通道。管道可以是单向的,即 half-duplex:一个进程负责发消息,另一个进程负责收消息;也可以是双向的 duplex,即互相收发消息。
默认声明 Pipe 对象是双向管道,如果要创建单向管道,可以在初始化的时候传入 deplex 参数为 False。
我们用一个实例来感受一下:
from multiprocessing import Process, Pipeclass Consumer(Process): def __init__(self, pipe): Process.__init__(self) self.pipe = pipe def run(self): self.pipe.send('Consumer Words') print(f'Consumer Received: {self.pipe.recv()}')class Producer(Process): def __init__(self, pipe): Process.__init__(self) self.pipe = pipe def run(self): print(f'Producer Received: {self.pipe.recv()}') self.pipe.send('Producer Words')if __name__ == '__main__': pipe = Pipe() p = Producer(pipe[0]) c = Consumer(pipe[1]) p.daemon = c.daemon = True p.start() c.start() p.join() c.join() print('Main Process Ended')
在这个例子里我们声明了一个默认为双向的管道,然后将管道的两端分别传给两个进程。两个进程互相收发。观察一下结果:
Producer Received: Consumer WordsConsumer Received: Producer WordsMain Process Ended
管道 Pipe 就像进程之间搭建的桥梁,利用它我们就可以很方便地实现进程间通信了。
在前面,我们讲了可以使用 Process 来创建进程,同时也讲了如何用 Semaphore 来控制进程的并发执行数量。
假如现在我们遇到这么一个问题,我有 10000 个任务,每个任务需要启动一个进程来执行,并且一个进程运行完毕之后要紧接着启动下一个进程,同时我还需要控制进程的并发数量,不能并发太高,不然 CPU 处理不过来(如果同时运行的进程能维持在一个最高恒定值当然利用率是最高的)。
那么我们该如何来实现这个需求呢?
用 Process 和 Semaphore 可以实现,但是实现起来比较我们可以用 Process 和 Semaphore 解决问题,但是实现起来比较烦琐。而这种需求在平时又是非常常见的。此时,我们就可以派上进程池了,即 multiprocessing 中的 Pool。
Pool 可以提供指定数量的进程,供用户调用,当有新的请求提交到 pool 中时,如果池还没有满,就会创建一个新的进程用来执行该请求;但如果池中的进程数已经达到规定最大值,那么该请求就会等待,直到池中有进程结束,才会创建新的进程来执行它。
我们用一个实例来实现一下,代码如下:
from multiprocessing import Poolimport timedef function(index): print(f'Start process: {index}') time.sleep(3) print(f'End process {index}', )if __name__ == '__main__': pool = Pool(processes=3) for i in range(4): pool.apply_async(function, args=(i,)) print('Main Process started') pool.close() pool.join() print('Main Process ended')
在这个例子中我们声明了一个大小为 3 的进程池,通过 processes 参数来指定,如果不指定,那么会自动根据处理器内核来分配进程数。接着我们使用 apply_async 方法将进程添加进去,args 可以用来传递参数。
运行结果如下:
Main Process startedStart process: 0Start process: 1Start process: 2End process 0End process 1End process 2Start process: 3End process 3Main Process ended
进程池大小为 3,所以最初可以看到有 3 个进程同时执行,第4个进程在等待,在有进程运行完毕之后,第4个进程马上跟着运行,出现了如上的运行效果。
最后,我们要记得调用 close 方法来关闭进程池,使其不再接受新的任务,然后调用 join 方法让主进程等待子进程的退出,等子进程运行完毕之后,主进程接着运行并结束。
不过上面的写法多少有些烦琐,这里再介绍进程池一个更好用的 map 方法,可以将上述写法简化很多。
map 方法是怎么用的呢?第一个参数就是要启动的进程对应的执行方法,第 2 个参数是一个可迭代对象,其中的每个元素会被传递给这个执行方法。
举个例子:现在我们有一个 list,里面包含了很多 URL,另外我们也定义了一个方法用来抓取每个 URL 内容并解析,那么我们可以直接在 map 的第一个参数传入方法名,第 2 个参数传入 URL 数组。
我们用一个实例来感受一下:
from multiprocessing import Poolimport urllib.requestimport urllib.errordef scrape(url): try: urllib.request.urlopen(url) print(f'URL {url} Scraped') except (urllib.error.HTTPError, urllib.error.URLError): print(f'URL {url} not Scraped')if __name__ == '__main__': pool = Pool(processes=3) urls = [ 'https://www.baidu.com', 'http://www.meituan.com/', 'http://blog.csdn.net/', 'http://xxxyxxx.net' ] pool.map(scrape, urls) pool.close()
这个例子中我们先定义了一个 scrape 方法,它接收一个参数 url,这里就是请求了一下这个链接,然后输出爬取成功的信息,如果发生错误,则会输出爬取失败的信息。
首先我们要初始化一个 Pool,指定进程数为 3。然后我们声明一个 urls 列表,接着我们调用了 map 方法,第 1 个参数就是进程对应的执行方法,第 2 个参数就是 urls 列表,map 方法会依次将 urls 的每个元素作为 scrape 的参数传递并启动一个新的进程,加到进程池中执行。
运行结果如下:
URL https://www.baidu.com ScrapedURL http://xxxyxxx.net not ScrapedURL http://blog.csdn.net/ ScrapedURL http://www.meituan.com/ Scraped
这样,我们就可以实现 3 个进程并行运行。不同的进程相互独立地输出了对应的爬取结果。
可以看到,我们利用 Pool 的 map 方法非常方便地实现了多进程的执行。后面我们也会在实战案例中结合进程池来实现数据的爬取。
]]>最近在狂补数据分析方面的知识,有一个趁手的“兵器”非常重要(就像在图像处理领域,我一直使用ImagePy
,能够快速处理图像和测试算法)。经过搜索,在数据分析领域,发现了一个非常强大的工具——‘D-Tale’。
直接看一下官方简介(下面这段翻译来自最近大火的ChatGPT
,对比了一下DeepL
的翻译,发现ChatGPT
效果更好,DeepL
把Pandas
直接翻译成了潘达斯
):
D-Tale是一个Flask后端和React前端的结合,为您提供了一种简单的方法来查看和分析Pandas数据结构。它与ipython笔记本和python / ipython终端无缝集成。目前,该工具支持DataFrame、Series、MultiIndex、DatetimeIndex和RangeIndex等Pandas对象。
安装方式非常简单:1
pip install dtale
D-Tale
有很多种启动方法。总体上来说,可以按在图形界面或者后台启动来分为两类。
D-Tale
支持在多种人机交互的图形界面中启动。比如常用的Jupyter Notebook
、JupyterHub
、Google Colab
、Kaggle
。
以Jupyter Notebook
为例(需要先有已经通过pandas
所读取的df
数据):1
2
3
4
5import dtale
dtale.show(df)
# 也可以直接在新的浏览器标签页中打开
# dtale.show(df, open_browser=True)
D-Tale
也支持在后台直接启动,此时又可以分为两种情形:
可以在Python
脚本中直接启动D-Tale
,即在dtale.show
方法中加入subprocess=False
参数:1
2
3import dtale
dtale.show(subprocess=False)
也可以直接在命令行终端中运行:1
dtale
可以看出,在后台中启动时是“无数据”方式启动的(当然也可以直接读取数据),此时会打开一个“加载数据”的界面供读取数据。
上面启动的方式都是可以的,取决于你的使用场景。
启动后,复制所输出文字的最后一行到浏览器中,比如:1
http://qi-air.local:40000/
如果是“无数据”直接启动,那么就是如下界面:
如果是直接读取了数据启动,那么直接就呈现了数据表格:
下面将以这里提供的COVID-19
数据作为数据。
同时将界面调整一下,点击左上角的箭头标记,打开“主菜单”:
在该列的倒数第二项和第三项中,点击Pin Menu
从而固定该菜单,以及将语言设置为中文。
于是,整个页面变成了:
可以编辑网格中的任何单元格(除了行索引或标题,后者可以用Rename
功能来编辑)。
为了编辑一个单元格,只需双击它。这将把它转换成一个文本输入域,此时可以看到一个闪烁的光标。除了将该单元格变成一个输入框外,它还会在屏幕顶部显示一个输入框,以便更好地查看长字符串。这里应该保证你输入的值与你编辑的列的数据类型相符。例如。integers
-> 应该是一个有效的正数或负数的整数float
-> 应该是一个有效的正数或负数的浮点数string
-> 任何有效的字符串都可以category
-> 要么是一个预先存在的类别,要么这将创建一个新的类别(所以要注意!)。date
, timestamp
, timedelta
— 应该是相应的有效的字符串。boolean
— 输入的任何字符串将被转换成小写字母,如果它等于 “true”,那么它将使单元格变成 “True”,否则就是 “False”。
也可以使用这两个预留值:"nan"
-> numpy.nan
"inf"
-> numpy.inf
(1)按住Shift
,点击某单元格;
(2)不要松开Shift
,滑动鼠标,选择要复制的单元格,此时这些单元格变为灰色;
(3)点击另一个单元格,此时就会蹦出复制对话框。
可以使用主菜单中的Describe
来查看所有列及其数据类型,以及每列的统计细节。
也可以直接点击每一列,通过Describe (Column Analysis)
来查看统计细节(建议采用这一种方式,上一种虽然能一下看全部列的,但有时会有bug)。
在上面的Describe
页面的下方,可以看到Outliers
离群值的统计,这些值是通过如下代码统计获得:1
2
3
4
5
6
7s = df[column]
q1 = s.quantile(0.25)
q3 = s.quantile(0.75)
iqr = q3 - q1
iqr_lower = q1 - 1.5 * iqr
iqr_upper = q3 + 1.5 * iqr
outliers = s[(s < iqr_lower) | (s > iqr_upper)]
自定义过滤可以在主菜单中进行设置,也可以在每一列的列菜单的最下方进行。
主菜单中的DataFrame Functions
可以通过一系列的DataFrame函数来新增列或改变已有的列。
这个功能允许用户合并或堆叠(即垂直连接)已经加载到D-Tale
中的dataframes,或者上传额外的数据。上面显示的演示涉及到以下操作。(该功能类似数据库中的Join
功能,内有例子可供理解)
这是一个非常强大的功能,允许用户从当前加载的数据中创建一个新的数据。目前可用的操作有:
(1)聚合:通过在特定索引的列上运行不同的聚合来整合数据。
(2)透视Pivot
:这是对pandas.Dataframe.pivot
和pandas.pivot_table
的简单封装。
(3)转置:在一个索引上转置你的数据(如果你的索引有很多唯一的值,请注意dataframe会变得非常宽)
从数据中删除重复的列/值,并将重复的数据提取到单独的实例中。
使用missingno
软件包,显示分析数据集中存在的缺失(NaN
)数据的图表。也可以在一个标签中单独打开它们,或者使用右上角的链接将它们导出为静态PNG。
基于数据建立自定义图表(由plotly/dash
提供)。
(1)图表将在一个新的选项卡中打开,因为功能太多了,可能希望能够在原始选项卡中引用主网格数据。
(2)要建立一个图表,必须为X
和Y
的输入选择一个值,这将有效地驱动X和Y轴上的数据。
如果正在处理一个三维图表(热图、三维散点图、表面图),还需要为Z
轴输入一个值。
(3)一旦输入了所有需要的坐标轴,一个图表就会被建立。
(4)如果X轴(或3D图表中的X和Y的组合)上的数据有重复的,有三个选择:
(4.1)指定一个组,这将为每个组创建序列。
(4.2)指定一个聚合,可以从以下选项中选择一个:计数、首数、尾数、平均值、中位数、最小值、最大值、标准差、方差、平均绝对偏差、所有项目的乘积、总和、滚动。
(a)指定一个 “滚动 “聚合也需要一个窗口和一个计算(相关性、计数、协方差、峰度、最大值、平均值、中位数、最小值、偏度、标准差、总和或方差)。
(b)对于热图,也可以使用 “相关Correlation”聚合,因为在热图中查看相关矩阵是非常有用的。其他地方不支持这种聚合。
(4.3)同时指定一个组和一个聚合。
(5)可以在不同的图表类型之间进行切换:线形、条形、饼形、文字云、热图、3D散点和曲面。
(6)如果指定了一个组,那么可以在一个图表中显示所有序列,或者将每个序列分成自己的图表 “Ghart per Group
“。
可以查看有向图。
显示所有数字列与所有其他数字列的Pearson相关矩阵
(1)默认情况下,它将显示一个pearson相关的网格(可通过使用下拉菜单进行过滤)。
(2)如果有一个日期类型的列,可以点击一个单独的单元格,看到该列组合的pearson相关的时间序列。
目前,如果有多个日期类型的列,将有能力通过下拉的方式在它们之间进行切换。
(3)此外,可以点击时间序列中的单个点来查看进入该相关的点的散点图。
在散点图部分,也可以通过悬停在 “PPS “旁边的数字来查看图表中这些数据点的PPS的细节。
(4)当在D-Tale中查看的数据有日期或时间戳列,但每个日期/时间戳列只有一行数据时,相关性弹出窗口的行为有点不同:
用户得到的不是一个时间序列的相关图,而是一个滚动的相关图,可以改变窗口(默认:10)。
当用户点击滚动相关图中的一个点时,散点图将被创建。散点图中显示的数据将是该日期的滚动相关中涉及的日期范围。
预测力得分(使用软件包ppscore
)是一个不对称的、与数据类型有关的得分,可以检测两列之间的线性或非线性关系。该分数范围从0(无预测能力)到1(完全预测能力)。它可以作为相关关系(矩阵)的替代。警告:这可能需要一段时间来加载。
这个页面的工作原理与相关性页面类似,但使用PPS计算来填充网格,通过点击单元格,可以查看这两列问题的PPS的细节。
这将隐藏任何非浮点或非int列(右侧的索引除外),并对每个单元格的背景应用一种颜色。
每个浮点被重新规范化为0到1.0之间的值。
对于重正化,有两个选项
(1)按列:每个值都是根据其列的最小/最大值计算的。
(2)整体:每个值都是根据数据集中所有非隐藏的浮点数/int列的整体最小/最大值来计算的。
每个重新规范化的值都被传递到一个色标,即红色(0)-黄色(0.5)-绿色(1.0)。
这是一个快速检查的方法,看看数据是否被正确归类了。通过点击这个菜单选项,它将为特定数据类型的每一列分配一个特定的背景颜色。
|category|timedelta|float|int|date|string|bool|
|-|-|-|-|-|-|-|
|purple|orange|green|light blue|pink|white|yellow
任何包含nan
值的单元格将以黄色突出显示。
任何字符串列的单元格如果是空字符串或仅由空格组成的字符串,将以橙色突出显示。
❗将被添加到任何包含缺失值的列标题中。
突出显示超过自定义离群值计算的上界或下界的数字列的任何单元格。
下限离群值将以红色标示,其中较深的红色将接近该列的最大值。
上界离群值将以蓝色标示,深蓝色将接近该列的最小值。
⭐将被添加到任何包含离群值的列标题中。
根据三个不同的标准,突出显示任何数字单元格的范围:等于、大于、小于。
可以随意激活这些条件,它们将被视为一个 “或 “的表达式。例如,(x == 0) or (x < -1) or (x > 1)
。
在这两个条件都是真的情况下,在列标题上显示标志。
(1)唯一值数量/唯一列数量<10%(2)最常见值的计数/第二常见值的计数 > 20
代码导出的是一些小的代码片段,代表了正在查看的网格的当前状态,包括以下内容。
(1)建立的列
(2)过滤
(3)排序
其他可导出的代码有:
(1)Desrcibe描述(Column Analysis
)
(2)相关性(网格、时间序列图和散点图)
(3)使用图表生成器构建的图表
将当前数据导出为CSV
或TSV
。
无论是在没有加载数据的情况下启动D-Tale,还是在已经加载一些数据之后,现在都可以直接从GUI中加载数据或选择一些样本数据集。
这将给出关于其他D-Tale实例在当前Python进程下运行的信息。
这个通常是在你的列不再对齐的情况下的一个故障保护。点击它应该能修复这个问题。
]]>