├── Code-15
├── README.md
├── 第01讲:必知必会,掌握 HTTP 基本原理.md
├── 第02讲:夯实根基,Web 网页基础.md
├── 第03讲:原理探究,了解爬虫的基本原理.md
├── 第04讲:基础探究,Session 与 Cookies.md
├── 第05讲:多路加速,了解多线程基本原理.md
├── 第06讲:多路加速,了解多进程基本原理.md
├── 第08讲:解析无所不能的正则表达式.md
├── 第09讲:爬虫解析利器 PyQuery 的使用.md
├── 第10讲:高效存储 MongoDB 的用法.md
├── 第11讲:Reqeusts + PyQuery + PyMongo 基本案例实战.md
├── 第12讲:Ajax 的原理和解析.md
├── 第13讲:Ajax 爬取案例实战.md
├── 第14讲:Selenium 的基本使用.md
├── 第15讲:Selenium 爬取实战.md
├── 第16讲:异步爬虫的原理和解析.md
├── 第17讲:aiohttp 异步爬虫实战.md
├── 第18讲:爬虫神器 Pyppeteer 的使用.md
├── 第20讲:代理的基本原理和用法.md
├── 第21讲:提高利用效率,代理池的搭建和使用.md
├── 第22讲:验证码反爬虫的基本原理.md
├── 第23讲:利用资源,学会用打码平台处理验证码.md
├── 第24讲:更智能的深度学习处理验证码.md
├── 第25讲:你有权限吗?解析模拟登录基本原理.md
├── 第26讲:模拟登录爬取实战案例.md
├── 第27讲:令人抓狂的 JavaScript 混淆技术.md
├── 第30讲:App 爬虫是什么?.md
└── 第31讲:抓包利器 Charles 的使用.md
/Code-15:
--------------------------------------------------------------------------------
1 | import logging
2 | from selenium import webdriver
3 | from selenium.webdriver.support.wait import WebDriverWait
4 | from selenium.webdriver.support import expected_conditions as EC
5 | from selenium.webdriver.common.by import By
6 | from selenium.common.exceptions import TimeoutException
7 |
8 | logging.basicConfig(level=logging.INFO,
9 | format='%(asctime)s - %(levelname)s: %(message)s')
10 | INDEX_URL = 'https://dynamic2.scrape.cuiqingcai.com/page/{page}'
11 | TIME_OUT = 10
12 | TOTAL_PAGE = 10
13 | browser = webdriver.Chrome()
14 | wait = WebDriverWait(browser, TIME_OUT)
15 |
16 | # 启动Chrome的Headless模式
17 | options=webdriver.ChromeOptions()
18 | options.add_argument('--headless')
19 | browser=webdriver.Chrome(options=options)
20 |
21 | def scrape_page(url, condition, locator):
22 | logging.info('scraping %s', url)
23 | try:
24 | browser.get(url)
25 | wait.until(condition(locator))
26 | except TimeoutException:
27 | logging.error('error occurred while scraping %s', url, exc_info=True)
28 |
29 | def scrape_index(page):
30 | url = INDEX_URL.format(page=page)
31 | scrape_page(url, condition=EC.visibility_of_all_elements_located,
32 | locator=(By.CSS_SELECTOR, '#index .item'))
33 |
34 | from urllib.parse import urljoin
35 | def parse_index():
36 | elements = browser.find_elements_by_css_selector('#index .item .name')
37 | for element in elements:
38 | href = element.get_attribute('href')
39 | yield urljoin(INDEX_URL, href)
40 |
41 | def scrapy_detail(url):
42 | scrape_page(url,condition=EC.visibility_of_element_located,locator=(By.TAG_NAME,'h2'))
43 |
44 | def parse_detail():
45 | url=browser.current_url
46 | name=browser.find_element_by_tag_name('h2').text
47 | categories=[ element.text for element in browser.find_elements_by_css_selector('.categories button span')]
48 | cover=browser.find_element_by_css_selector('.cover').get_attribute('src')
49 | score=browser.find_element_by_class_name('score').text
50 | drama=browser.find_element_by_css_selector('.drama p').text
51 | return {
52 | 'url':url,
53 | 'name':name,
54 | 'categories':categories,
55 | 'cover':cover,
56 | 'score':score,
57 | 'drama':drama
58 | }
59 |
60 | from os import makedirs
61 | from os.path import exists
62 | import json
63 | RESULTS_DIR='results2'
64 | exists(RESULTS_DIR) or makedirs(RESULTS_DIR)
65 |
66 | def save_data(data):
67 | name = data.get('name')
68 | data_path = f'{RESULTS_DIR}/{name}.json'
69 | json.dump(data, open(data_path, 'w', encoding='utf-8'), ensure_ascii=False, indent=2)
70 |
71 |
72 | def main():
73 | try:
74 | for page in range(1, TOTAL_PAGE + 1):
75 | scrape_index(page)
76 | detail_urls = parse_index()
77 | for detail_url in list(detail_urls):
78 | logging.info('get detail url %s',detail_url)
79 | scrapy_detail(detail_url)
80 | detail_data=parse_detail()
81 | logging.info('details data %s', detail_data)
82 | save_data(detail_data)
83 | finally:
84 | browser.close()
85 |
86 | if __name__ == '__main__':
87 | main()
88 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # 52讲轻松搞定爬虫
2 |
3 | 作者:崔庆才
4 |
5 | 资料来源于:https://kaiwu.lagou.com/course/courseInfo.htm?sid=&courseId=46#/detail/pc?id=1663
6 |
7 | 官网已经全部更新结束。
8 |
9 | 欢迎大家支持正版!
10 |
11 |
12 |
--------------------------------------------------------------------------------
/第01讲:必知必会,掌握 HTTP 基本原理.md:
--------------------------------------------------------------------------------
1 | 本课时我们会详细讲解 HTTP 的基本原理,以及了解在浏览器中输入 URL 到获取网页内容之间发生了什么。了解了这些内容,有助于我们进一步掌握爬虫的基本原理。
2 |
3 | #### URI 和 URL
4 | 首先,我们来了解一下 URI 和 URL,URI 的全称为 Uniform Resource Identifier,即统一资源标志符,URL 的全称为 Universal Resource Locator,即统一资源定位符。
5 |
6 | 举例来说,[https://github.com/favicon.ico](https://github.com/favicon.ico]),它是一个 URL,也是一个 URI。即有这样的一个图标资源,我们用 URL/URI 来唯一指定了它的访问方式,这其中包括了访问协议 HTTPS、访问路径(即根目录)和资源名称 favicon.ico。通过这样一个链接,我们便可以从互联网上找到这个资源,这就是 URL/URI。
7 |
8 |
9 |
10 | URL 是 URI 的子集,也就是说每个 URL 都是 URI,但不是每个 URI 都是 URL。那么,什么样的 URI 不是 URL 呢?URI 还包括一个子类叫作 URN,它的全称为 Universal Resource Name,即统一资源名称。
11 |
12 |
13 |
14 | URN 只命名资源而不指定如何定位资源,比如 urn:isbn:0451450523 指定了一本书的 ISBN,可以唯一标识这本书,但是没有指定到哪里定位这本书,这就是 URN。URL、URN 和 URI 的关系可以用图表示。
15 | 
16 | 但是在目前的互联网,URN 的使用非常少,几乎所有的 URI 都是 URL,所以一般的网页链接我们可以称之为 URL,也可以称之为 URI,我个人习惯称之为 URL。
17 |
18 | #### 超文本
19 |
20 | 接下来,我们再了解一个概念 —— 超文本,其英文名称叫作 Hypertext,我们在浏览器里看到的网页就是超文本解析而成的,其网页源代码是一系列 HTML 代码,里面包含了一系列标签,比如 img 显示图片,p 指定显示段落等。浏览器解析这些标签后,便形成了我们平常看到的网页,而网页的源代码 HTML 就可以称作超文本。
21 |
22 |
23 |
24 | 例如,我们在 Chrome 浏览器里面打开任意一个页面,如淘宝首页,右击任一地方并选择 “检查” 项(或者直接按快捷键 F12),即可打开浏览器的开发者工具,这时在 Elements 选项卡即可看到当前网页的源代码,这些源代码都是超文本,如图所示。
25 | 
26 | #### HTTP 和 HTTPS
27 |
28 | 在淘宝的首页 [https://www.taobao.com/](https://www.taobao.com/) 中,URL 的开头会有 http 或 https,这个就是访问资源需要的协议类型,有时我们还会看到 ftp、sftp、smb 开头的 URL,那么这里的 ftp、sftp、smb 都是指的**协议类型**。在爬虫中,我们抓取的页面通常就是 http 或 https 协议的,我们在这里首先来了解一下这两个协议的含义。
29 |
30 |
31 |
32 | HTTP 的全称是 Hyper Text Transfer Protocol,中文名叫作**超文本传输协议**,HTTP 协议是用于从网络传输超文本数据到本地浏览器的传送协议,它能保证高效而准确地传送超文本文档。HTTP 由万维网协会(World Wide Web Consortium)和 Internet 工作小组 IETF(Internet Engineering Task Force)共同合作制定的规范,目前广泛使用的是 HTTP 1.1 版本。
33 |
34 |
35 |
36 | HTTPS 的全称是 Hyper Text Transfer Protocol over Secure Socket Layer,是以安全为目标的 HTTP 通道,简单讲是 HTTP 的安全版,即 HTTP 下加入 SSL 层,简称为 HTTPS。
37 |
38 |
39 |
40 | HTTPS 的安全基础是 SSL,因此通过它传输的内容都是**经过 SSL 加密**的,它的主要作用可以分为两种:
41 |
42 | * 建立一个信息安全通道,来保证数据传输的安全。
43 |
44 | * 确认网站的真实性,凡是使用了 HTTPS 的网站,都可以通过点击浏览器地址栏的锁头标志来查看网站认证之后的真实信息,也可以通过 CA 机构颁发的安全签章来查询。
45 |
46 | 现在越来越多的网站和 App 都已经向 HTTPS 方向发展。例如:
47 |
48 | * 苹果公司强制所有 iOS App 在 2017 年 1 月 1 日 前全部改为使用 HTTPS 加密,否则 App 就无法在应用商店上架。
49 |
50 | * 谷歌从 2017 年 1 月推出的 Chrome 56 开始,对未进行 HTTPS 加密的网址链接亮出风险提示,即在地址栏的显著位置提醒用户 “此网页不安全”。
51 |
52 | * 腾讯微信小程序的官方需求文档要求后台使用 HTTPS 请求进行网络通信,不满足条件的域名和协议无法请求。
53 |
54 | 因此,HTTPS 已经已经是大势所趋。
55 | #### HTTP 请求过程
56 |
57 | 我们在浏览器中输入一个 URL,回车之后便可以在浏览器中观察到页面内容。实际上,这个过程是**浏览器向网站所在的服务器发送了一个请求**,**网站服务器接收到这个请求后进行处理和解析,然后返回对应的响应,接着传回给浏览器**。响应里包含了页面的源代码等内容,浏览器再对其进行解析,便将网页呈现了出来,传输模型如图所示。
58 | 
59 | 此处客户端即代表我们自己的 PC 或手机浏览器,服务器即要访问的网站所在的服务器。
60 |
61 |
62 |
63 | 为了更直观地说明这个过程,这里用 Chrome 浏览器的开发者模式下的 Network 监听组件来做下演示,它可以显示访问当前请求网页时发生的所有网络请求和响应。
64 |
65 |
66 |
67 | 打开 Chrome 浏览器,右击并选择 “检查” 项,即可打开浏览器的开发者工具。这里访问百度 [http://www.baidu.com/](http://www.baidu.com/),输入该 URL 后回车,观察这个过程中发生了怎样的网络请求。可以看到,在 Network 页面下方出现了一个个的条目,其中一个条目就代表一次发送请求和接收响应的过程,如图所示。
68 | 
69 | 我们先观察第一个网络请求,即 [www.baidu.com](www.baidu.com),其中各列的含义如下。
70 |
71 | * 第一列 Name:请求的名称,一般会将 URL 的最后一部分内容当作名称。
72 |
73 | * 第二列 Status:响应的状态码,这里显示为 **200,代表响应是正常的**。通过状态码,我们可以判断发送了请求之后是否得到了正常的响应。
74 |
75 | * 第三列 Type:请求的文档类型。这里为 document,代表我们这次请求的是一个 HTML 文档,内容就是一些 HTML 代码。
76 |
77 | * 第四列 Initiator:**请求源**。用来标记请求是由哪个对象或进程发起的。
78 |
79 | * 第五列 Size:**从服务器下载的文件和请求的资源大小**。如果是从缓存中取得的资源,则该列会显示 from cache。
80 |
81 | * 第六列 Time:发起请求到获取响应所用的总时间。
82 |
83 | * 第七列 Waterfall:网络请求的可视化瀑布流。
84 |
85 | 我们点击这个条目即可看到其更详细的信息,如图所示。
86 | 
87 | 首先是 General 部分,Request URL 为请求的 URL,Request Method 为请求的方法,Status Code 为响应状态码,Remote Address 为远程服务器的地址和端口,Referrer Policy 为 Referrer 判别策略。
88 |
89 |
90 |
91 | 再继续往下,可以看到,有 Response Headers 和 Request Headers,这分别代表响应头和请求头。请求头里带有许多请求信息,例如浏览器标识、Cookies、Host 等信息,这是请求的一部分,服务器会根据请求头内的信息判断请求是否合法,进而作出对应的响应。图中看到的 Response Headers 就是响应的一部分,例如其中包含了服务器的类型、文档类型、日期等信息,浏览器接受到响应后,会解析响应内容,进而呈现网页内容。
92 |
93 |
94 |
95 | 下面我们分别来介绍一下请求和响应都包含哪些内容。
96 | #### 请求
97 |
98 | 请求,由客户端向服务端发出,可以分为 4 部分内容:**请求方法**(Request Method)、**请求的网址**(Request URL)、**请求头**(Request Headers)、**请求体**(Request Body)。
99 |
100 | ##### 请求方法
101 |
102 | 常见的请求方法有两种:GET 和 POST。
103 |
104 |
105 |
106 | **在浏览器中直接输入 URL 并回车,这便发起了一个 GET 请求**,请求的参数会直接包含到 URL 里。例如,在百度中搜索 Python,这就是一个 GET 请求,链接为 [https://www.baidu.com/s?wd=Python](https://www.baidu.com/s?wd=Python),其中 URL 中包含了请求的参数信息,这里参数 wd 表示要搜寻的关键字。**POST 请求大多在表单提交时发起**。比如,对于一个登录表单,输入用户名和密码后,点击 “登录” 按钮,这通常会发起一个 POST 请求,其数据通常以表单的形式传输,而不会体现在 URL 中。
107 |
108 |
109 |
110 | ##### GET 和 POST 请求方法有如下区别。
111 |
112 | * GET 请求中的参数包含在 URL 里面,数据可以在 URL 中看到,而 POST 请求的 URL 不会包含这些数据,数据都是通过表单形式传输的,会包含在请求体中。
113 |
114 | * GET 请求提交的数据最多只有 1024 字节,而 POST 请求没有限制。
115 |
116 | 一般来说,登录时,需要提交用户名和密码,其中包含了敏感信息,**使用 GET 方式请求的话,密码就会暴露在 URL 里面,造成密码泄露**,所以这里最好以 POST 方式发送。上传文件时,由于文件内容比较大,也会选用 POST 方式。
117 |
118 |
119 |
120 | 我们平常遇到的绝大部分请求都是 GET 或 POST 请求,另外还有一些请求方法,如 HEAD、PUT、DELETE、OPTIONS、CONNECT、TRACE 等,我们简单将其总结为下表。
121 | 
122 | 请求的网址本表参考:[http://www.runoob.com/http/http-methods.html](http://www.runoob.com/http/http-methods.html)
123 |
124 |
125 |
126 | 请求的网址,即统一资源定位符 URL,它可以唯一确定我们想请求的资源。
127 | ##### 请求头
128 |
129 | **请求头,用来说明服务器要使用的附加信息,比较重要的信息有 Cookie、Referer、User-Agent 等**。下面简要说明一些常用的头信息。
130 |
131 | * Accept:请求报头域,用于指定客户端可接受哪些类型的信息。
132 |
133 | * Accept-Language:指定客户端可接受的语言类型。
134 |
135 | * Accept-Encoding:指定客户端可接受的内容编码。
136 |
137 | * Host:用于指定请求资源的主机 IP 和端口号,其内容为请求 URL 的原始服务器或网关的位置。从 HTTP 1.1 版本开始,请求必须包含此内容。
138 |
139 | * Cookie:也常用复数形式 Cookies,这是**网站为了辨别用户进行会话跟踪而存储在用户本地的数据。它的主要功能是维持当前访问会话**。例如,我们输入用户名和密码成功登录某个网站后,服务器会用会话保存登录状态信息,后面我们每次刷新或请求该站点的其他页面时,会发现都是登录状态,这就是 Cookies 的功劳。Cookies 里有信息标识了我们所对应的服务器的会话,每次浏览器在请求该站点的页面时,都会在请求头中加上 Cookies 并将其发送给服务器,服务器通过 Cookies 识别出是我们自己,并且查出当前状态是登录状态,所以返回结果就是登录之后才能看到的网页内容。
140 |
141 | * Referer:此内容用来标识这个请求是从哪个页面发过来的,服务器可以拿到这一信息并做相应的处理,如做来源统计、防盗链处理等。
142 |
143 | * User-Agent:简称 UA,它是一个特殊的字符串头,**可以使服务器识别客户使用的操作系统及版本、浏览器及版本等信息**。在做爬虫时加上此信息,可以伪装为浏览器;如果不加,很可能会被识别出为爬虫。
144 |
145 | * Content-Type:也叫互联网媒体类型(Internet Media Type)或者 MIME 类型,在 HTTP 协议消息头中,它用来表示具体请求中的媒体类型信息。例如,text/html 代表 HTML 格式,image/gif 代表 GIF 图片,application/json 代表 JSON 类型,更多对应关系可以查看此对照表:[http://tool.oschina.net/commons](http://tool.oschina.net/commons)。
146 |
147 | 因此,请求头是请求的重要组成部分,在写爬虫时,大部分情况下都需要设定请求头。
148 | ##### 请求体
149 |
150 | 请求体一般承载的内容是 POST 请求中的表单数据,而对于 GET 请求,请求体则为空。
151 |
152 | 例如,这里我登录 GitHub 时捕获到的请求和响应如图所示。
153 | 
154 | 登录之前,我们填写了用户名和密码信息,提交时这些内容就会以表单数据的形式提交给服务器,此时需要注意 Request Headers 中指定 Content-Type 为 *application/x-www-form-urlencoded*。只有设置 Content-Type 为 *application/x-www-form-urlencoded*,才会以表单数据的形式提交。另外,我们也可以将 Content-Type 设置为 *application/json* 来提交 JSON 数据,或者设置为 *multipart/form-data* 来上传文件。
155 |
156 |
157 | 表格中列出了 Content-Type 和 POST 提交数据方式的关系。
158 | 
159 | #### 响应
160 |
161 | 响应,由服务端返回给客户端,可以分为三部分:**响应状态码**(Response Status Code)、**响应头**(Response Headers)和**响应体**(Response Body)。
162 | ##### 响应状态码
163 |
164 | 响应状态码表示服务器的响应状态,如 **200 代表服务器正常响应,404 代表页面未找到,500 代表服务器内部发生错误**。在爬虫中,我们可以根据状态码来判断服务器响应状态,如状态码为 200,则证明成功返回数据,再进行进一步的处理,否则直接忽略。下表列出了常见的错误代码及错误原因。
165 | 
166 | **响应头包含了服务器对请求的应答信息,如 Content-Type、Server、Set-Cookie 等。下面简要说明一些常用的头信息。响应头**
167 |
168 | * Date:标识响应产生的时间。
169 |
170 | * Last-Modified:指定资源的最后修改时间。
171 |
172 | * Content-Encoding:指定响应内容的编码。
173 |
174 | * Server:包含服务器的信息,比如名称、版本号等。
175 |
176 | * Content-Type:文档类型,指定返回的数据类型是什么,如 text/html 代表返回 HTML 文档,application/x-javascript 则代表返回 JavaScript 文件,image/jpeg 则代表返回图片。
177 |
178 | * Set-Cookie:设置 Cookies。响应头中的 Set-Cookie 告诉浏览器需要将此内容放在 Cookies 中,下次请求携带 Cookies 请求。
179 |
180 | * Expires:指定响应的过期时间,可以使代理服务器或浏览器将加载的内容更新到缓存中。如果再次访问时,就可以直接从缓存中加载,降低服务器负载,缩短加载时间。
181 | ##### 响应体
182 |
183 | 最重要的当属响应体的内容了。响应的正文数据都在响应体中,比如请求网页时,它的响应体就是网页的 HTML 代码;请求一张图片时,它的响应体就是图片的二进制数据。我们做爬虫请求网页后,要解析的内容就是响应体,如图所示。
184 | 
185 | 在浏览器开发者工具中点击 **Preview**,就可以看到网页的源代码,也就是响应体的内容,它**是解析的目标**。
186 |
187 |
188 | 在做爬虫时,我们主要通过响应体得到网页的源代码、JSON 数据等,然后从中做相应内容的提取。
189 |
190 | 好了,今天的内容就全部讲完了,本课时中,我们了解了 HTTP 的基本原理,大概了解了访问网页时背后的请求和响应过程。本课时涉及的知识点需要好好掌握,后面分析网页请求时会经常用到。
191 |
--------------------------------------------------------------------------------
/第02讲:夯实根基,Web 网页基础.md:
--------------------------------------------------------------------------------
1 | 当我们用浏览器访问网站时,页面各不相同,那么你有没有想过它为何会呈现成这个样子呢?本课时,我们就来讲解网页的基本组成、结构和节点等内容。
2 | #### 网页的组成
3 |
4 | 首先,我们来了解网页的基本组成,网页可以分为三大部分:HTML、CSS 和 JavaScript。
5 |
6 |
7 |
8 | 如果把网页比作一个人的话,**HTML 相当于骨架,JavaScript 相当于肌肉,CSS 相当于皮肤**,三者结合起来才能形成一个完整的网页。下面我们来分别介绍一下这三部分的功能。
9 | #### HTML
10 |
11 | HTML 是用来描述网页的一种语言,其全称叫作 Hyper Text Markup Language,即超文本标记语言。
12 |
13 |
14 | 我们浏览的网页包括文字、按钮、图片和视频等各种复杂的元素,其基础架构就是 HTML。**不同类型的元素通过不同类型的标签来表示**,如图片用 img 标签表示,视频用 video 标签表示,段落用 p 标签表示,它们之间的布局又常通过布局标签 **div 嵌套组合**而成,各种标签通过不同的排列和嵌套就可以形成网页的框架。
15 |
16 |
17 |
18 | 我们在 Chrome 浏览器中打开百度,右击并选择 “检查” 项(或按 F12 键),打开开发者模式,这时在 Elements 选项卡中即可看到网页的源代码,如图所示。
19 | 
20 | 这就是 HTML,整个网页就是由各种标签嵌套组合而成的。这些标签定义的节点元素相互嵌套和组合形成了复杂的层次关系,就形成了网页的架构。
21 | #### CSS
22 |
23 | 虽然 **HTML 定义了网页的结构**,但是只有 HTML 页面的布局并不美观,可能只是简单的节点元素的排列,为了让网页看起来更好看一些,这里就需要借助 CSS 了。
24 |
25 |
26 |
27 | CSS,全称叫作 Cascading Style Sheets,即**层叠样式表**。“层叠” 是指当在 HTML 中引用了数个样式文件,并且样式发生冲突时,浏览器能依据层叠顺序处理。**“样式” 指网页中文字大小、颜色、元素间距、排列等格式**。
28 |
29 |
30 |
31 | CSS 是目前唯一的网页页面排版样式标准,有了它的帮助,页面才会变得更为美观。
32 |
33 |
34 |
35 | 图的右侧即为 CSS,例如:
36 |
37 | ```css
38 | #head_wrapper.s-ps-islite .s-p-top {
39 |
40 | position: absolute;
41 |
42 | bottom: 40px;
43 |
44 | width: 100%;
45 |
46 | height: 181px;
47 | ```
48 | 这就是一个 CSS 样式。大括号前面是一个 CSS 选择器。此选择器的作用是首先选中 id 为 head_wrapper 且 class 为 s-ps-islite 的节点,然后再选中其内部的 class 为 s-p-top 的节点。
49 |
50 |
51 |
52 | 大括号内部写的就是一条条样式规则,例如 position 指定了这个元素的布局方式为绝对布局,bottom 指定元素的下边距为 40 像素,width 指定了宽度为 100% 占满父元素,height 则指定了元素的高度。
53 |
54 |
55 |
56 | 也就是说,我们将位置、宽度、高度等样式配置统一写成这样的形式,然后用大括号括起来,接着在开头再加上 CSS 选择器,这就代表这个样式对 CSS 选择器选中的元素生效,元素就会根据此样式来展示了。
57 |
58 |
59 |
60 | 在网页中,一般会统一定义整个网页的样式规则,并写入 CSS 文件中(其后缀为 css)。在 HTML 中,只需要用 link 标签即可引入写好的 CSS 文件,这样整个页面就会变得美观、优雅。
61 | #### JavaScript
62 |
63 | JavaScript,简称 JS,是一种**脚本语言**。HTML 和 CSS 配合使用,提供给用户的只是一种静态信息,缺乏交互性。我们在网页里可能会看到一些**交互和动画效果**,如下载进度条、提示框、轮播图等,这通常就是 JavaScript 的功劳。**它的出现使得用户与信息之间不只是一种浏览与显示的关系,而是实现了一种实时、动态、交互的页面功能**。
64 |
65 |
66 |
67 | JavaScript 通常也是以单独的文件形式加载的,后缀为 js,在 HTML 中通过 script 标签即可引入,例如:
68 |
69 | ```javascript
70 | <script src="jquery2.1.0.js"></script>
71 | ```
72 | 综上所述,HTML 定义了网页的内容和结构,CSS 描述了网页的布局,JavaScript 定义了网页的行为。
73 |
74 | #### 网页的结构
75 |
76 | 了解了网页的基本组成,我们再用一个例子来感受下 HTML 的基本结构。新建一个文本文件,名称可以自取,后缀为 html,内容如下:
77 |
78 | ```css
79 | <!DOCTYPE html>
80 | <html>
81 | <head>
82 | <meta charset="UTF-8">
83 | <title>This is a Demo</title>
84 | </head>
85 | <body>
86 | <div id="container">
87 | <div class="wrapper">
88 | <h2 class="title">Hello World</h2>
89 | <p class="text">Hello, this is a paragraph.</p>
90 | </div>
91 | </div>
92 | </body>
93 | </html>
94 | ```
95 | 这就是一个最简单的 HTML 实例。开头用 DOCTYPE 定义了文档类型,其次最外层是 html 标签,最后还有对应的结束标签来表示闭合,其内部是 head 标签和 body 标签,分别代表网页头和网页体,它们也需要结束标签。
96 |
97 |
98 |
99 | head 标签内定义了一些页面的配置和引用,如:,它指定了网页的编码为 UTF-8。title 标签则定义了网页的标题,会显示在网页的选项卡中,不会显示在正文中。body 标签内则是在网页正文中显示的内容。
100 |
101 |
102 |
103 | div 标签定义了网页中的区块,它的 id 是 container,这是一个非常常用的属性,且 id 的内容在网页中是唯一的,我们可以通过它来获取这个区块。然后在此区块内又有一个 div 标签,它的 class 为 wrapper,这也是一个非常常用的属性,经常与 CSS 配合使用来设定样式。
104 |
105 |
106 |
107 | 然后此区块内部又有一个 h2 标签,这代表一个二级标题。另外,还有一个 p 标签,这代表一个段落。在这两者中直接写入相应的内容即可在网页中呈现出来,它们也有各自的 class 属性。
108 |
109 |
110 |
111 | 将代码保存后,在浏览器中打开该文件,可以看到如图所示的内容。
112 | 
113 | 可以看到,在选项卡上显示了 This is a Demo 字样,这是我们在 head 中的 title 里定义的文字。而网页正文是 body 标签内部定义的各个元素生成的,可以看到这里显示了二级标题和段落。
114 |
115 |
116 |
117 | 这个实例便是网页的一般结构。一个网页的标准形式是 html 标签内嵌套 head 和 body 标签,head 内定义网页的配置和引用,body 内定义网页的正文。
118 | #### 节点树及节点间的关系
119 | 在 HTML 中,所有标签定义的内容都是节点,它们构成了一个 HTML DOM 树。
120 |
121 |
122 |
123 | 我们先看下什么是 DOM。DOM 是 W3C(万维网联盟)的标准,其英文全称 Document Object Model,即文档对象模型。它定义了访问 HTML 和 XML 文档的标准:
124 |
125 | W3C 文档对象模型(DOM)是中立于平台和语言的接口,它允许程序和脚本动态地访问和更新文档的内容、结构和样式。
126 |
127 | W3C DOM 标准被分为 3 个不同的部分:
128 |
129 | * 核心 DOM - 针对任何结构化文档的标准模型
130 |
131 | * XML DOM - 针对 XML 文档的标准模型
132 |
133 | * HTML DOM - 针对 HTML 文档的标准模型
134 |
135 | 根据 W3C 的 HTML DOM 标准,HTML 文档中的所有内容都是节点:
136 |
137 | * 整个文档是一个文档节点
138 |
139 | * 每个 HTML 元素是元素节点
140 |
141 | * HTML 元素内的文本是文本节点
142 |
143 | * 每个 HTML 属性是属性节点
144 |
145 | * 注释是注释节点
146 |
147 | HTML DOM 将 HTML 文档视作树结构,这种结构被称为节点树,如图所示。
148 | 
149 | 通过 HTML DOM,树中的所有节点均可通过 JavaScript 访问,所有 HTML 节点元素均可被修改,也可以被创建或删除。
150 |
151 |
152 |
153 | 节点树中的节点彼此拥有层级关系。我们常用父(parent)、子(child)和兄弟(sibling)等术语描述这些关系。父节点拥有子节点,同级的子节点被称为兄弟节点。
154 |
155 |
156 |
157 | 在节点树中,顶端节点称为根(root)。除了根节点之外,每个节点都有父节点,同时可拥有任意数量的子节点或兄弟节点。图中展示了节点树以及节点之间的关系。
158 | 
159 | 本段参考 W3SCHOOL,链接:[http://www.w3school.com.cn/htmldom/dom_nodes.asp](http://www.w3school.com.cn/htmldom/dom_nodes.asp)
160 | #### 选择器
161 |
162 | 我们知道网页由一个个节点组成,CSS 选择器会根据不同的节点设置不同的样式规则,那么怎样来定位节点呢?
163 |
164 |
165 |
166 | 在 CSS 中,我们使用 CSS 选择器来定位节点。例如,上例中 div 节点的 id 为 container,那么就可以表示为 #container,其中 **# 开头代表选择 id**,其后紧跟 id 的名称。
167 |
168 |
169 |
170 | 另外,如果我们想选择 class 为 wrapper 的节点,便可以使用 .wrapper,这里**以点“.”开头代表选择 class**,其后紧跟 class 的名称。另外,还有一种选择方式,那就是根据标签名筛选,例如想选择二级标题,直接用 h2 即可。这是最常用的 3 种表示,分别是根据 id、class、标签名筛选,请牢记它们的写法。
171 |
172 |
173 |
174 | 另外,CSS 选择器还支持嵌套选择,各个选择器之间加上空格分隔开便可以代表嵌套关系,如 **#container .wrapper p 则代表先选择 id 为 container 的节点,然后选中其内部的 class 为 wrapper 的节点,然后再进一步选中其内部的 p 节点**。
175 |
176 |
177 |
178 | 另外,如果不加空格,则代表并列关系,如 div#container .wrapper p.text 代表先选择 id 为 container 的 div 节点,然后选中其内部的 class 为 wrapper 的节点,再进一步选中其内部的 class 为 text 的 p 节点。这就是 CSS 选择器,其筛选功能还是非常强大的。
179 |
180 |
181 |
182 | 另外,CSS 选择器还有一些其他语法规则,具体如表所示。因为表中的内容非常的多,我就不在一一介绍,课下你可以参考文字内容详细理解掌握这部分知识。
183 | 
184 | 
185 | 
186 | 另外,还有一种比较常用的选择器是 **XPath**,这种选择方式后面会详细介绍。
187 |
188 |
189 |
190 | 本课时的内容就全部讲完了,在本课时中我们介绍了网页的基本结构和节点间的关系,了解了这些内容后,我们才有更加清晰的思路去解析和提取网页内容。
191 |
--------------------------------------------------------------------------------
/第03讲:原理探究,了解爬虫的基本原理.md:
--------------------------------------------------------------------------------
1 | 我们可以把互联网比作一张大网,而爬虫(即网络爬虫)便是在网上爬行的蜘蛛。如果把网的节点比作一个个网页,爬虫爬到这就相当于访问了该页面,获取了其信息。可以把节点间的连线比作网页与网页之间的链接关系,这样蜘蛛通过一个节点后,可以顺着节点连线继续爬行到达下一个节点,即通过一个网页继续获取后续的网页,这样整个网的节点便可以被蜘蛛全部爬行到,网站的数据就可以被抓取下来了。
2 | #### 爬虫概述
3 |
4 | 简单来说,爬虫就是获取网页并提取和保存信息的自动化程序,下面概要介绍一下。
5 |
6 |
7 | #### 获取网页
8 | 爬虫首先要做的工作就是获取网页,这里就是获取网页的源代码。
9 |
10 | 源代码里包含了网页的部分有用信息,所以只要把源代码获取下来,就可以从中提取想要的信息了。
11 |
12 | 前面讲了请求和响应的概念,向网站的服务器发送一个请求,返回的响应体便是网页源代码。所以,最关键的部分就是构造一个请求并发送给服务器,然后接收到响应并将其解析出来,那么这个流程怎样实现呢?总不能手工去截取网页源码吧?
13 |
14 | 不用担心,Python提供了许多库来帮助我们实现这个操作,如urllib、requests等。我们可以用这些库来帮助我们实现HTTP请求操作,请求和响应都可以用类库提供的数据结构来表示,得到响应之后只需要解析数据结构中的 Body 部分即可,即得到网页的源代码,这样我们可以用程序来实现获取网页的过程了。
15 |
16 |
17 | #### 提取信息
18 | 获取网页源代码后,接下来就是分析网页源代码,从中提取我们想要的数据。首先,最通用的方法便是采用**正则表达式**提取,这是一个万能的方法,但是在构造正则表达式时比较复杂且容易出错。
19 |
20 | 另外,由于网页的结构有一定的规则,所以还有一些根据网页节点属性、CSS选择器或XPath来提取网页信息的库,如BeautifulSoup、pyquery、lxml等。使用这些库,我们可以高效快速地从中提取网页信息,如节点的属性、文本值等。
21 |
22 | 提取信息是爬虫非常重要的部分,它可以使杂乱的数据变得条理清晰,以便我们后续处理和分析数据。
23 |
24 | #### 保存数据
25 | 提取信息后,我们一般会将提取到的数据保存到某处以便后续使用。这里保存形式有多种多样,如可以简单保存为**TXT文本或JSON文本**,也可以保存到**数据库**,如MySQL和MongoDB,还可保存至远程服务器,如借助 SFTP 进行操作等。
26 | #### 自动化程序
27 | 说到自动化程序,意思是说爬虫可以代替人来完成这些操作。首先,我们手工当然可以提取这些信息,但是当量特别大或者想快速获取大量数据的话,肯定还是要借助程序。爬虫就是代替我们来完成这份爬取工作的自动化程序,它可以在抓取过程中进行各种异常处理、错误重试等操作,确保爬取持续高效地运行。
28 | #### 能抓怎样的数据
29 | 在网页中我们能看到各种各样的信息,最常见的便是常规网页,它们对应着HTML代码,而最常抓取的便是HTML源代码。
30 |
31 | 另外,可能有些网页返回的不是 HTML 代码,而是一个 **JSON 字符串**(其中 API 接口大多采用这样的形式),这种格式的数据方便传输和解析,它们同样可以抓取,而且数据提取更加方便。
32 |
33 | 此外,我们还可以看到各种二进制数据,如图片、视频和音频等。利用爬虫,我们可以将这些**二进制数据抓取**下来,然后保存成对应的文件名。
34 |
35 | 另外,还可以看到各种扩展名的文件,如 CSS、JavaScript 和配置文件等,这些其实也是最普通的文件,只要在浏览器里面可以访问到,就可以将其抓取下来。
36 |
37 | 上述内容其实都对应各自的 URL,是基于 HTTP 或 HTTPS 协议的,只要是这种数据,爬虫都可以抓取。
38 |
39 | #### JavaScript 渲染页面
40 | 有时候,我们在用urllib或requests抓取网页时,得到的源代码实际和浏览器中看到的**不一样**。
41 |
42 | 这是一个非常常见的问题。现在网页越来越多地采用Ajax、前端模块化工具来构建,整个网页可能都是由 JavaScript 渲染出来的,也就是说原始的 HTML 代码就是一个空壳,例如:
43 | 
44 | **body 节点里面只有一个 id 为 container 的节点,但是需要注意在 body 节点后引入了 app.js,它便负责整个网站的渲染。**
45 |
46 | 在浏览器中打开这个页面时,首先会加载这个HTML内容,接着浏览器会发现其中引入了一个app.js文件,然后便会接着去请求这个文件,获取到该文件后,便会执行其中的JavaScript代码,而 JavaScript 则会改变 HTML 中的节点,向其添加内容,最后得到完整的页面。
47 |
48 | 但是在用 urllib 或 requests 等库请求当前页面时,我们得到的只是这个 HTML 代码,它不会帮助我们去继续加载这个 JavaScript 文件,这样也就看不到浏览器中的内容了。
49 |
50 | 这也解释了为什么有时我们得到的源代码和浏览器中看到的不一样。
51 |
52 | 因此,使用基本HTTP请求库得到的源代码可能跟浏览器中的页面源代码不太一样。对于这样的情况,我们可以分析其后台Ajax接口,也可使用Selenium、Splash这样的库来实现模拟 JavaScript 渲染。
53 |
54 | 后面,我们会详细介绍如何采集 JavaScript 渲染的网页。本节介绍了爬虫的一些基本原理,这可以帮助我们在后面编写爬虫时更加得心应手。
55 |
56 |
57 |
58 |
59 |
--------------------------------------------------------------------------------
/第04讲:基础探究,Session 与 Cookies.md:
--------------------------------------------------------------------------------
1 | 我们在浏览网站的过程中,经常会遇到需要登录的情况,而有些网页只有登录之后才可以访问,而且登录之后可以连续访问很多次网站,但是有时候过一段时间就需要重新登录。
2 |
3 | 还有一些网站,在打开浏览器时就自动登录了,而且很长时间都不会失效,这种情况又是为什么?其实这里面涉及 **Session** 和 **Cookies** 的相关知识,本节就来揭开它们的神秘面纱。
4 |
5 | #### 静态网页和动态网页
6 |
7 | 在开始介绍它们之前,我们需要先了解一下静态网页和动态网页的概念。这里还是前面的示例代码,内容如下:
8 | 
9 |
10 | 这是最基本的HTML代码,我们将其保存为一个.html文件,然后把它放在某台具有固定公网IP的主机上,主机上装上Apache或Nginx等服务器,这样这台主机就可以作为服务器了,其他人便可以通过访问服务器看到这个页面,这就搭建了一个最简单的网站。
11 |
12 | 这种网页的内容是HTML代码编写的,文字、图片等内容均通过写好的HTML代码来指定,这种页面叫作**静态网页**。它加载速度快,编写简单,但是存在很大的缺陷,如**可维护性差**,不能根据URL灵活多变地显示内容等。例如,我们想要给这个网页的 URL 传入一个 name 参数,让其在网页中显示出来,是无法做到的。
13 |
14 | 因此,动态网页应运而生,它可以**动态解析URL中参数的变化**,关联数据库并动态呈现不同的页面内容,非常灵活多变。我们现在遇到的大多数网站都是动态网站,它们不再是一个简单的 HTML,而是可能由 JSP、PHP、Python 等语言编写的,其功能比静态网页强大和丰富太多了。
15 |
16 | 此外,动态网站还可以实现用户登录和注册的功能。再回到开头来看提到的问题,很多页面是需要登录之后才可以查看的。按照一般的逻辑来说,输入用户名和密码登录之后,肯定是拿到了一种类似凭证的东西,有了它,我们才能保持登录状态,才能访问登录之后才能看到的页面。
17 |
18 | 那么,这种神秘的凭证到底是什么呢?其实它就是 Session 和 Cookies 共同产生的结果,下面我们来一探究竟。
19 |
20 | #### 无状态 HTTP
21 |
22 | 在了解 Session 和 Cookies 之前,我们还需要了解 HTTP 的一个特点,叫作无状态。
23 |
24 | HTTP 的无状态是指 HTTP 协议对事务处理是没有记忆能力的,也就是说服务器不知道客户端是什么状态。
25 |
26 | 当我们向服务器发送请求后,服务器解析此请求,然后返回对应的响应,服务器负责完成这个过程,而且这个过程是完全独立的,服务器不会记录前后状态的变化,也就是缺少状态记录。
27 |
28 | 这意味着如果后续需要处理前面的信息,则必须重传,这也导致需要额外传递一些前面的重复请求,才能获取后续响应,然而这种效果显然不是我们想要的。为了保持前后状态,我们肯定不能将前面的请求全部重传一次,这太浪费资源了,对于这种需要用户登录的页面来说,更是棘手。
29 |
30 | 这时两个用于保持 HTTP 连接状态的技术就出现了,它们分别是 **Session** 和 **Cookies**。**Session 在服务端,也就是网站的服务器,用来保存用户的 Session 信息**;**Cookies 在客户端,也可以理解为浏览器端,有了 Cookies,浏览器在下次访问网页时会自动附带上它发送给服务器,服务器通过识别 Cookies 并鉴定出是哪个用户,然后再判断用户是否是登录状态,进而返回对应的响应。**
31 |
32 | 我们可以理解为 Cookies 里面保存了登录的凭证,有了它,只需要在下次请求携带 Cookies 发送请求而不必重新输入用户名、密码等信息重新登录了。
33 |
34 | 因此在爬虫中,有时候处理需要登录才能访问的页面时,我们一般会直接将登录成功后获取的 Cookies 放在请求头里面直接请求,而不必重新模拟登录。
35 |
36 | 好了,了解 Session 和 Cookies 的概念之后,我们在来详细剖析它们的原理。
37 |
38 | #### Session
39 | Session,中文称之为会话,其本身的含义是指**有始有终的一系列动作 / 消息**。比如,打电话时,从拿起电话拨号到挂断电话这中间的一系列过程可以称为一个 Session。
40 |
41 | 而在 Web 中,Session 对象用来存储特定用户 Session 所需的属性及配置信息。这样,当用户在应用程序的 Web 页之间跳转时,存储在 Session 对象中的变量将不会丢失,而是在整个用户 Session 中一直存在下去。当用户请求来自应用程序的 Web 页时,如果该用户还没有 Session,则 Web 服务器将自动创建一个 Session 对象。当 Session 过期或被放弃后,服务器将终止该 Session。
42 |
43 | #### Cookies
44 | Cookies 指某些网站为了辨别用户身份、进行 Session 跟踪而存储在用户本地终端上的数据。
45 | #### Session 维持
46 | 那么,我们怎样利用 Cookies 保持状态呢?当客户端第一次请求服务器时,服务器会返回一个响应头中带有 Set-Cookie 字段的响应给客户端,用来标记是哪一个用户,客户端浏览器会把 Cookies 保存起来。当浏览器下一次再请求该网站时,浏览器会把此 Cookies 放到请求头一起提交给服务器,Cookies 携带了 Session ID 信息,服务器检查该 Cookies 即可找到对应的 Session 是什么,然后再判断 Session 来以此来辨认用户状态。
47 |
48 | 在成功登录某个网站时,服务器会告诉客户端设置哪些 Cookies 信息,在后续访问页面时客户端会把 Cookies 发送给服务器,服务器再找到对应的 Session 加以判断。如果 Session 中的某些设置登录状态的变量是有效的,那就证明用户处于登录状态,此时返回登录之后才可以查看的网页内容,浏览器再进行解析便可以看到了。
49 |
50 | 反之,如果传给服务器的 Cookies 是无效的,或者 Session 已经过期了,我们将不能继续访问页面,此时可能会收到错误的响应或者跳转到登录页面重新登录。
51 |
52 | 所以,**Cookies 和 Session 需要配合,一个处于客户端,一个处于服务端,二者共同协作**,就实现了登录 Session 控制。
53 |
54 | #### 属性结构
55 |
56 | 接下来,我们来看看 Cookies 都有哪些内容。这里以知乎为例,在浏览器开发者工具中打开 Application 选项卡,然后在左侧会有一个 Storage 部分,最后一项即为 Cookies,将其点开,如图所示,这些就是 Cookies。
57 | 
58 | 可以看到,这里有很多条目,其中每个条目可以称为 Cookie。它有如下几个属性。
59 |
60 | * Name,即该 Cookie 的名称。Cookie 一旦创建,名称便不可更改。
61 | * Value,即该 Cookie 的值。如果值为 Unicode 字符,需要为字符编码。如果值为二进制数据,则需要使用 BASE64 编码。
62 | * Max Age,即该 Cookie 失效的时间,单位秒,也常和 Expires 一起使用,通过它可以计算出其有效时间。Max Age 如果为正数,则该 Cookie 在 Max Age 秒之后失效。如果为负数,则关闭浏览器时 Cookie 即失效,浏览器也不会以任何形式保存该 Cookie。
63 | * Path,即该 Cookie 的使用路径。如果设置为 /path/,则只有路径为 /path/ 的页面可以访问该 Cookie。如果设置为 /,则本域名下的所有页面都可以访问该 Cookie。
64 | * Domain,即可以访问该 Cookie 的域名。例如如果设置为 .zhihu.com,则所有以 zhihu.com,结尾的域名都可以访问该 Cookie。
65 | * Size 字段,即此 Cookie 的大小。
66 | * Http 字段,即 Cookie 的 httponly 属性。若此属性为 true,则只有在 HTTP Headers 中会带有此 Cookie 的信息,而不能通过 document.cookie 来访问此 Cookie。
67 | * Secure,即该 Cookie 是否仅被使用安全协议传输。安全协议。安全协议有 HTTPS、SSL 等,在网络上传输数据之前先将数据加密。默认为 false。
68 | #### 会话 Cookie 和持久 Cookie
69 |
70 | 从表面意思来说,会话 Cookie 就是把 Cookie 放在浏览器内存里,浏览器在关闭之后该 Cookie 即失效;**持久 Cookie 则会保存到客户端的硬盘中**,下次还可以继续使用,用于长久保持用户登录状态。
71 |
72 | 其实严格来说,没有会话 Cookie 和持久 Cookie 之 分,只是由 Cookie 的 Max Age 或 Expires 字段决定了过期的时间。
73 |
74 | 因此,**一些持久化登录的网站其实就是把 Cookie 的有效时间和 Session 有效期设置得比较长**,下次我们再访问页面时仍然携带之前的 Cookie,就可以直接保持登录状态。
75 |
76 | #### 常见误区
77 | 在谈论 Session 机制的时候,常常听到这样一种误解 ——“只要关闭浏览器,Session 就消失了”。可以想象一下会员卡的例子,除非顾客主动对店家提出销卡,否则店家绝对不会轻易删除顾客的资料。对 Session 来说,也是一样,除非程序通知服务器删除一个 Session,否则服务器会一直保留。比如,**程序一般都是在我们做注销操作时才去删除 Session**。
78 |
79 | 但是当我们关闭浏览器时,浏览器不会主动在关闭之前通知服务器它将要关闭,所以服务器根本不会有机会知道浏览器已经关闭。之所以会有这种错觉,是因为大部分网站都使用会话 Cookie 来保存 Session ID 信息,而关闭浏览器后 Cookies 就消失了,再次连接服务器时,也就无法找到原来的 Session 了。如果服务器设置的 Cookies 保存到硬盘上,或者使用某种手段改写浏览器发出的 HTTP 请求头,把原来的 Cookies 发送给服务器,则再次打开浏览器,仍然能够找到原来的 Session ID,依旧还是可以保持登录状态的。
80 |
81 | 而且恰恰是由于关闭浏览器不会导致 Session 被删除,这就需要服务器为 Session 设置一个失效时间,当距离客户端上一次使用 Session 的时间超过这个失效时间时,服务器就可以认为客户端已经停止了活动,才会把 Session 删除以节省存储空间。
82 |
83 |
84 |
85 |
86 |
--------------------------------------------------------------------------------
/第05讲:多路加速,了解多线程基本原理.md:
--------------------------------------------------------------------------------
1 | 我们知道,在一台计算机中,我们可以同时打开许多软件,比如同时浏览网页、听音乐、打字等等,看似非常正常。但仔细想想,为什么计算机可以做到这么多软件同时运行呢?这就涉及到计算机中的两个重要概念:**多进程和多线程了**。
2 |
3 | 同样,在编写爬虫程序的时候,为了提高爬取效率,我们可能想同时运行多个爬虫任务。这里同样需要涉及多进程和多线程的知识。
4 |
5 | 本课时,我们就先来了解一下多线程的基本原理,以及在 Python 中如何实现多线程。
6 |
7 | #### 多线程的含义
8 |
9 | 说起多线程,就不得不先说什么是线程。然而想要弄明白什么是线程,又不得不先说什么是进程。
10 |
11 | 进程我们可以理解为是一个可以独立运行的程序单位,比如打开一个浏览器,这就开启了一个浏览器进程;打开一个文本编辑器,这就开启了一个文本编辑器进程。但一个进程中是可以同时处理很多事情的,比如在浏览器中,我们可以在多个选项卡中打开多个页面,有的页面在播放音乐,有的页面在播放视频,有的网页在播放动画,它们可以同时运行,互不干扰。为什么能同时做到同时运行这么多的任务呢?这里就需要引出线程的概念了,其实这一个个任务,实际上就对应着一个个线程的执行。
12 |
13 | 而进程呢?它就是线程的集合,进程就是由一个或多个线程构成的,**线程是操作系统进行运算调度的最小单位,是进程中的一个最小运行单元**。比如上面所说的浏览器进程,其中的播放音乐就是一个线程,播放视频也是一个线程,当然其中还有很多其他的线程在同时运行,这些线程的并发或并行执行最后使得整个浏览器可以同时运行这么多的任务。
14 |
15 | 了解了线程的概念,多线程就很容易理解了,多线程就是一个进程中同时执行多个线程,前面所说的浏览器的情景就是典型的多线程执行。
16 |
17 | #### 并发和并行
18 | 说到多进程和多线程,这里就需要再讲解两个概念,那就是并发和并行。我们知道,一个程序在计算机中运行,其底层是处理器通过运行一条条的指令来实现的。
19 |
20 | 并发,英文叫作 concurrency。它是指同一时刻只能有一条指令执行,但是多个线程的对应的指令被快速轮换地执行。比如一个处理器,它先执行线程 A 的指令一段时间,再执行线程 B 的指令一段时间,再切回到线程 A 执行一段时间。
21 |
22 | 由于处理器执行指令的速度和切换的速度非常非常快,人完全感知不到计算机在这个过程中有多个线程切换上下文执行的操作,这就使得宏观上看起来多个线程在同时运行。但微观上只是这个处理器在连续不断地在多个线程之间切换和执行,每个线程的执行一定会占用这个处理器一个时间片段,同一时刻,其实只有一个线程在执行。
23 |
24 | 并行,英文叫作 parallel。它是指同一时刻,有多条指令在多个处理器上同时执行,并行必须要依赖于多个处理器。不论是从宏观上还是微观上,多个线程都是在同一时刻一起执行的。
25 |
26 | **并行只能在多处理器系统中存在**,如果我们的计算机处理器只有一个核,那就不可能实现并行。而**并发在单处理器和多处理器系统中都是可以存在的,因为仅靠一个核,就可以实现并发**。
27 |
28 | 举个例子,比如系统处理器需要同时运行多个线程。如果系统处理器只有一个核,那它只能通过并发的方式来运行这些线程。如果系统处理器有多个核,当一个核在执行一个线程时,另一个核可以执行另一个线程,这样这两个线程就实现了并行执行,当然其他的线程也可能和另外的线程处在同一个核上执行,它们之间就是并发执行。具体的执行方式,就取决于操作系统的调度了。
29 |
30 | #### 多线程适用场景
31 |
32 | 在一个程序进程中,有一些操作是比较耗时或者需要等待的,比如等待数据库的查询结果的返回,等待网页结果的响应。如果使用单线程,处理器必须要等到这些操作完成之后才能继续往下执行其他操作,而这个线程在等待的过程中,处理器明显是可以来执行其他的操作的。**如果使用多线程,处理器就可以在某个线程等待的时候,去执行其他的线程**,**从而从整体上提高执行效率**。
33 |
34 | 像上述场景,线程在执行过程中很多情况下是需要等待的。比如网络爬虫就是一个非常典型的例子,爬虫在向服务器发起请求之后,有一段时间必须要等待服务器的响应返回,这种任务就属于 IO 密集型任务。对于这种任务,如果我们启用多线程,处理器就可以在某个线程等待的过程中去处理其他的任务,从而**提高整体的爬取效率**。
35 |
36 | 但并不是所有的任务都是 IO 密集型任务,还有一种任务叫作**计算密集型任务**,也可以称之为 **CPU 密集型任务**。顾名思义,就是任务的运行一直需要处理器的参与。此时如果我们开启了多线程,一个处理器从一个计算密集型任务切换到切换到另一个计算密集型任务上去,处理器依然不会停下来,始终会忙于计算,这样并不会节省总体的时间,因为需要处理的任务的计算总量是不变的。如果线程数目过多,反而还会在线程切换的过程中多耗费一些时间,整体效率会变低。
37 |
38 | 所以,如果任务不全是计算密集型任务,我们可以使用多线程来提高程序整体的执行效率。尤其对于网络爬虫这种 IO 密集型任务来说,使用多线程会大大提高程序整体的爬取效率。
39 |
40 | #### Python 实现多线程
41 |
42 | 在 Python 中,实现多线程的模块叫作 `threading`,是 Python 自带的模块。下面我们来了解下使用` threading` 实现多线程的方法。
43 |
44 | #### Thread 直接创建子线程
45 | 首先,我们可以使用 `Thread` 类来创建一个线程,创建时需要指定 `target` 参数为运行的方法名称,如果被调用的方法需要传入额外的参数,则可以通过 `Thread `的 `args` 参数来指定。示例如下:
46 | 
47 | 运行结果如下:
48 | ```python
49 | Threading MainThread is running
50 | Threading Thread-1 is running
51 | Threading Thread-1 sleep 1s
52 | Threading Thread-2 is running
53 | Threading Thread-2 sleep 5s
54 | Threading MainThread is ended
55 | Threading Thread-1 is ended
56 | Threading Thread-2 is ended
57 | ```
58 | 在这里我们首先声明了一个方法,叫作 `target`,它接收一个参数为 `second`,通过方法的实现可以发现,这个方法其实就是执行了一个 time.sleep 休眠操作,`second` 参数就是休眠秒数,其前后都 print 了一些内容,其中线程的名字我们通过 `threading.current_thread().name` 来获取出来,如果是主线程的话,其值就是 MainThread,如果是子线程的话,其值就是 Thread-*。
59 |
60 | 然后我们通过 Thead 类新建了两个线程,target 参数就是刚才我们所定义的方法名,args 以列表的形式传递。两次循环中,这里 i 分别就是 1 和 5,这样两个线程就分别休眠 1 秒和 5 秒,声明完成之后,我们调用 start 方法即可开始线程的运行。
61 |
62 | 观察结果我们可以发现,这里一共产生了三个线程,分别是主线程 MainThread 和两个子线程 Thread-1、Thread-2。另外我们观察到,主线程首先运行结束,紧接着 Thread-1、Thread-2 才接连运行结束,分别间隔了 1 秒和 4 秒。这说明**主线程并没有等待子线程运行完毕才结束运行**,而是直接退出了,有点不符合常理。
63 |
64 | 如果我们想要主线程等待子线程运行完毕之后才退出,可以让每个子线程对象都调用下 `join` 方法,实现如下:
65 | 
66 | 运行结果如下:
67 | ```python
68 | Threading MainThread is running
69 | Threading Thread-1 is running
70 | Threading Thread-1 sleep 1s
71 | Threading Thread-2 is running
72 | Threading Thread-2 sleep 5s
73 | Threading Thread-1 is ended
74 | Threading Thread-2 is ended
75 | Threading MainThread is ended
76 | ```
77 | 这样,主线程必须等待子线程都运行结束,主线程才继续运行并结束。
78 | #### 继承 Thread 类创建子线程
79 | 另外,我们也可以通过继承 Thread 类的方式创建一个线程,该线程需要执行的方法写在类的 run 方法里面即可。上面的例子的等价改写为:
80 | 
81 | 运行结果如下:
82 |
83 | ```python
84 | Threading MainThread is running
85 | Threading Thread-1 is running
86 | Threading Thread-1 sleep 1s
87 | Threading Thread-2 is running
88 | Threading Thread-2 sleep 5s
89 | Threading Thread-1 is ended
90 | Threading Thread-2 is ended
91 | Threading MainThread is ended
92 | ```
93 | 可以看到,两种实现方式,其运行效果是相同的。
94 | #### 守护线程
95 | 在线程中有一个叫作守护线程的概念,**如果一个线程被设置为守护线程,那么意味着这个线程是“不重要”的**,这意味着,如果主线程结束了而该守护线程还没有运行完,那么它将会被强制结束。在 Python 中我们可以通过 `setDaemon `方法来将某个线程设置为守护线程。
96 |
97 | 示例如下:
98 | 
99 | 在这里我们通过 setDaemon 方法将 t2 设置为了守护线程,这样主线程在运行完毕时,t2 线程会随着线程的结束而结束。
100 |
101 | 运行结果如下:
102 | ```python
103 | Threading MainThread is running
104 | Threading Thread-1 is running
105 | Threading Thread-1 sleep 2s
106 | Threading Thread-2 is running
107 | Threading Thread-2 sleep 5s
108 | Threading MainThread is ended
109 | Threading Thread-1 is ended
110 | ```
111 | 可以看到,我们没有看到 Thread-2 打印退出的消息,Thread-2 随着主线程的退出而退出了。
112 |
113 | 不过细心的你可能会发现,这里并没有调用 join 方法,如果我们让 t1 和 t2 都调用 join 方法,主线程就会仍然等待各个子线程执行完毕再退出,不论其是否是守护线程。
114 |
115 | #### 互斥锁
116 | 在一个进程中的多个线程是共享资源的,比如在一个进程中,有一个全局变量 count 用来计数,现在我们声明多个线程,每个线程运行时都给 count 加 1,让我们来看看效果如何,代码实现如下:
117 | 
118 | 图片给的代码没有进行缩进,正确缩进之后代码如下:
119 | ```python
120 | import threading
121 | import time
122 |
123 | count=0
124 |
125 | class MyThread(threading.Thread):
126 |
127 | def __init__(self):
128 | threading.Thread.__init__(self)
129 | def run(self):
130 | global count
131 | temp=count+1
132 | time.sleep(0.001)
133 | count=temp
134 |
135 | threads=[]
136 | for _ in range(1000):
137 | thread=MyThread()
138 | thread.start()
139 | threads.append(thread)
140 | for thread in threads:
141 | thread.join()
142 | print('Final count{}:'.format(count))
143 | ```
144 | 在这里,我们声明了 1000 个线程,每个线程都是现取到当前的全局变量 count 值,然后休眠一小段时间,然后对 count 赋予新的值。
145 |
146 | 那这样,按照常理来说,最终的 count 值应该为 1000。但其实不然,我们来运行一下看看。
147 |
148 | 运行结果如下:
149 |
150 | Final count: 69
151 |
152 | 最后的结果居然只有 69,而且多次运行或者换个环境运行结果是不同的。
153 |
154 | 这是为什么呢?因为 count 这个值是共享的,每个线程都可以在执行 temp = count 这行代码时拿到当前 count 的值,但是这些线程中的一些线程可能是并发或者并行执行的,这就导致不同的线程拿到的可能是同一个 count 值,最后导致有些线程的 count 的加 1 操作并没有生效,导致最后的结果偏小。
155 |
156 | 所以,如果多个线程同时对某个数据进行读取或修改,就会出现不可预料的结果。为了避免这种情况,我们需要对多个线程进行同步,要实现同步,我们可以对需要操作的数据进行加锁保护,这里就需要用到 threading.Lock 了。
157 |
158 | 加锁保护是什么意思呢?就是说,某个线程在对数据进行操作前,需要先加锁,这样其他的线程发现被加锁了之后,就无法继续向下执行,会一直等待锁被释放,只有加锁的线程把锁释放了,其他的线程才能继续加锁并对数据做修改,修改完了再释放锁。这样可以确保同一时间只有一个线程操作数据,多个线程不会再同时读取和修改同一个数据,这样最后的运行结果就是对的了。
159 |
160 | 我们可以将代码修改为如下内容:
161 | 
162 | 在这里我们声明了一个 lock 对象,其实就是 threading.Lock 的一个实例,然后在 run 方法里面,获取 count 前先加锁,修改完 count 之后再释放锁,这样多个线程就不会同时获取和修改 count 的值了。
163 |
164 | 运行结果如下:
165 | ```python
166 | Final count: 1000
167 | ```
168 | 这样运行结果就正常了。
169 |
170 | 关于 Python 多线程的内容,这里暂且先介绍这些,关于 theading 更多的使用方法,如信号量、队列等,可以参考官方文档:https://docs.python.org/zh-cn/3.7/library/threading.html#module-threading。
171 |
172 | #### Python 多线程的问题
173 |
174 | 由于 Python 中 GIL 的限制,**导致不论是在单核还是多核条件下,在同一时刻只能运行一个线程**,导致 Python 多线程无法发挥多核并行的优势。
175 |
176 | GIL 全称为 **Global Interpreter Lock**,中文翻译为全局解释器锁,其最初设计是出于数据安全而考虑的。
177 |
178 | 在 Python 多线程下,每个线程的执行方式如下:
179 | * 获取 GIL
180 | * 执行对应线程的代码
181 | * 释放 GIL
182 |
183 | 可见,某个线程想要执行,必须先拿到 GIL,我们可以把 GIL 看作是通行证,并且在一个 Python 进程中,GIL 只有一个。拿不到通行证的线程,就不允许执行。这样就会导致,即使是多核条件下,一个 Python 进程下的多个线程,同一时刻也只能执行一个线程。
184 |
185 | 不过对于爬虫这种 IO 密集型任务来说,这个问题影响并不大。而对于计算密集型任务来说,由于 GIL 的存在,多线程总体的运行效率相比可能反而比单线程更低。
186 |
187 |
--------------------------------------------------------------------------------
/第06讲:多路加速,了解多进程基本原理.md:
--------------------------------------------------------------------------------
1 | 在上一课时我们了解了多线程的基本概念,同时我们也提到,Python 中的**多线程**是不能很好发挥多核优势的,如果想要发挥多核优势,最好还是使用**多进程**。
2 |
3 | 那么本课时我们就来了解下多进程的基本概念和用 Python 实现多进程的方法。
4 | #### 多进程的含义
5 |
6 | 进程(Process)是具有一定独立功能的程序关于某个数据集合上的一次运行活动,是系统进行资源分配和调度的一个独立单位。
7 |
8 | 顾名思义,多进程就是启用多个进程同时运行。由于进程是线程的集合,而且进程是由一个或多个线程构成的,所以**多进程的运行意味着有大于或等于进程数量的线程在运行**。
9 | #### Python 多进程的优势
10 |
11 | 通过上一课时我们知道,由于进程中 GIL 的存在,Python 中的多线程并不能很好地发挥多核优势,一个进程中的多个线程,在同一时刻只能有一个线程运行。
12 |
13 | 而对于多进程来说,每个进程都有属于自己的 GIL,所以,在多核处理器下,多进程的运行是不会受 GIL 的影响的。因此,多进程能更好地发挥多核的优势。
14 |
15 | 当然,对于爬虫这种 IO 密集型任务来说,多线程和多进程影响差别并不大。对于计算密集型任务来说,Python 的多进程相比多线程,其多核运行效率会有成倍的提升。
16 |
17 | 总的来说,Python 的多进程整体来看是比多线程更有优势的。所以,在条件允许的情况下,能用多进程就尽量用多进程。
18 |
19 | 不过值得注意的是,由于进程是系统进行资源分配和调度的一个独立单位,所以**各个进程之间的数据是无法共享的**,如多个进程无法共享一个全局变量,进程之间的数据共享需要有单独的机制来实现,这在后面也会讲到。
20 |
21 | #### 多进程的实现
22 |
23 | 在 Python 中也有内置的库来实现多进程,它就是 `multiprocessing`。
24 |
25 | `multiprocessing` 提供了一系列的组件,如 `Process`(进程)、`Queue`(队列)、`Semaphore`(信号量)、`Pipe`(管道)、`Lock`(锁)、`Pool`(进程池)等,接下来让我们来了解下它们的使用方法。
26 | ##### 直接使用 Process 类
27 |
28 | 在 multiprocessing 中,每一个进程都用一个 Process 类来表示。它的 API 调用如下:
29 |
30 | > Process([group [, target [, name [, args [, kwargs]]]]])
31 | >
32 | * target 表示调用对象,你可以传入方法的名字。
33 | * args 表示被调用对象的位置参数元组,比如 target 是函数 func,他有两个参数 m,n,那么 args 就传入 [m, n] 即可。
34 | * kwargs 表示调用对象的字典。
35 | * name 是别名,相当于给这个进程取一个名字。
36 | * group 分组。
37 |
38 | 我们先用一个实例来感受一下:
39 | 
40 | 这是一个实现多进程最基础的方式:通过创建 Process 来新建一个子进程,其中 target 参数传入方法名,args 是方法的参数,是以元组的形式传入,其和被调用的方法 process 的参数是一一对应的。
41 |
42 | 注意:这里 args 必须要是一个元组**加粗样式**,如果只有一个参数,那也要在元组第一个元素后面加一个逗号,如果没有逗号则和单个元素本身没有区别,无法构成元组,导致参数传递出现问题。
43 |
44 | 创建完进程之后,我们通过调用 start 方法即可启动进程了。运行结果如下:
45 | ```python
46 | Process: 0
47 | Process: 1
48 | Process: 2
49 | Process: 3
50 | Process: 4
51 | ```
52 | 可以看到,我们运行了 5 个子进程,每个进程都调用了 process 方法。process 方法的 index 参数通过 Process 的 args 传入,分别是 0~4 这 5 个序号,最后打印出来,5 个子进程运行结束。
53 |
54 | 由于**进程是 Python 中最小的资源分配单元**,因此这些进程和线程不同,**各个进程之间的数据是不会共享**的,每启动一个进程,都会独立分配资源。
55 |
56 | 另外,在当前 CPU 核数足够的情况下,这些不同的进程会分配给不同的 CPU 核来运行,实现真正的并行执行。
57 |
58 | `multiprocessing` 还提供了几个比较有用的方法,如我们可以通过 `cpu_count` 的方法来获取当前机器 CPU 的核心数量,通过 `active_children` 方法获取当前还在运行的所有进程。
59 |
60 | 下面通过一个实例来看一下:
61 | 
62 | ```python
63 | CPU number: 8
64 | Child process name: Process-5 id: 73595
65 | Child process name: Process-2 id: 73592
66 | Child process name: Process-3 id: 73593
67 | Child process name: Process-4 id: 73594
68 | Process Ended
69 | Process: 1
70 | Process: 2
71 | Process: 3
72 | Process: 4
73 | ```
74 | 在上面的例子中我们通过 `cpu_count` 成功获取了 CPU 核心的数量:8 个,当然不同的机器结果可能不同。
75 |
76 | 另外我们还通过 `active_children `获取到了当前正在活跃运行的进程列表。然后我们遍历了每个进程,并将它们的名称和进程号打印出来了,这里进程号直接使用 pid 属性即可获取,进程名称直接通过 name 属性即可获取。
77 |
78 | 以上我们就完成了多进程的创建和一些基本信息的获取。
79 | ##### 继承 Process 类
80 | 在上面的例子中,我们创建进程是直接使用 `Process` 这个类来创建的,这是一种创建进程的方式。不过,创建进程的方式不止这一种,同样,我们也可以像线程 `Thread` 一样来通过继承的方式创建一个进程类,进程的基本操作我们在子类的 run 方法中实现即可。
81 |
82 | 通过一个实例来看一下:
83 | 
84 | 我们首先声明了一个构造方法,这个方法接收一个 loop 参数,代表循环次数,并将其设置为全局变量。在 run 方法中,又使用这个 loop 变量循环了 loop 次并**打印了当前的进程号和循环次数**。
85 |
86 | 在调用时,我们用 range 方法得到了 2、3、4 三个数字,并把它们分别初始化了 MyProcess 进程,然后调用 start 方法将进程启动起来。
87 |
88 | 注意:这里进程的执行逻辑需要在 run 方法中实现,启动进程需要调用 start 方法,调用之后 run 方法便会执行。
89 |
90 | 运行结果如下:
91 |
92 | ```python
93 | Pid: 73667 LoopCount: 0
94 | Pid: 73668 LoopCount: 0
95 | Pid: 73669 LoopCount: 0
96 | Pid: 73667 LoopCount: 1
97 | Pid: 73668 LoopCount: 1
98 | Pid: 73669 LoopCount: 1
99 | Pid: 73668 LoopCount: 2
100 | Pid: 73669 LoopCount: 2
101 | Pid: 73669 LoopCount: 3
102 | ```
103 | 可以看到,三个进程分别打印出了 2、3、4 条结果,即进程 73667 打印了 2 次 结果,进程 73668 打印了 3 次结果,进程 73669 打印了 4 次结果。
104 |
105 | 注意,这里的进程 **pid 代表进程号**,不同机器、不同时刻运行结果可能不同。
106 |
107 | 通过上面的方式,我们也非常方便地实现了一个进程的定义。为了复用方便,我们可以把一些方法写在每个进程类里封装好,在使用时直接初始化一个进程类运行即可。
108 | ##### 守护进程
109 |
110 | 在多进程中,同样存在守护进程的概念,**如果一个进程被设置为守护进程,当父进程结束后,子进程会自动被终止**,我们可以通过设置 `daemon `属性来控制是否为守护进程。
111 |
112 | 还是原来的例子,增加了 deamon 属性的设置:
113 | 
114 | 运行结果如下:
115 | ```python
116 | Main Process ended
117 | ```
118 | 结果很简单,因为主进程没有做任何事情,直接输出一句话结束,所以在这时也直接终止了子进程的运行。
119 |
120 | 这样可以有效防止无控制地生成子进程。这样的写法可以让我们在主进程运行结束后无需额外担心子进程是否关闭,避免了独立子进程的运行。
121 |
122 | ##### 进程等待
123 | 上面的运行效果其实不太符合我们预期:主进程运行结束时,子进程(守护进程)也都退出了,**子进程什么都没来得及执行**。
124 |
125 | 能不能让所有子进程都执行完了然后再结束呢?当然是可以的,只需要加入 join 方法即可,我们可以将代码改写如下:
126 | 
127 | 运行结果如下:
128 | ```python
129 | Pid: 40866 LoopCount: 0
130 | Pid: 40867 LoopCount: 0
131 | Pid: 40868 LoopCount: 0
132 | Pid: 40866 LoopCount: 1
133 | Pid: 40867 LoopCount: 1
134 | Pid: 40868 LoopCount: 1
135 | Pid: 40867 LoopCount: 2
136 | Pid: 40868 LoopCount: 2
137 | Pid: 40868 LoopCount: 3
138 | Main Process ended
139 | ```
140 | 在调用 start 和 join 方法后,父进程就可以等待所有子进程都执行完毕后,再打印出结束的结果。
141 |
142 | 默认情况下,join 是无限期的。也就是说,如果有子进程没有运行完毕,主进程会一直等待。这种情况下,如果子进程出现问题陷入了死循环,主进程也会无限等待下去。怎么解决这个问题呢?可以给 join 方法传递一个超时参数,代表最长等待秒数。如果子进程没有在这个指定秒数之内完成,会被强制返回,主进程不再会等待。也就是说这个参数设置了主进程等待该子进程的最长时间。
143 |
144 | 例如这里我们传入 1,代表最长等待 1 秒,代码改写如下:
145 | 
146 | 运行结果如下:
147 | ```python
148 | Pid: 40970 LoopCount: 0
149 | Pid: 40971 LoopCount: 0
150 | Pid: 40970 LoopCount: 1
151 | Pid: 40971 LoopCount: 1
152 | Main Process ended
153 | ```
154 | 可以看到,有的子进程本来要运行 3 秒,结果运行 1 秒就被强制返回了,由于是守护进程,该子进程被终止了。
155 |
156 | 到这里,我们就了解了守护进程、进程等待和超时设置的用法。
157 | ##### 终止进程
158 |
159 | 当然,终止进程不止有守护进程这一种做法,我们也可以通过 `terminate` 方法来终止某个子进程,另外我们还可以通过 `is_alive`方法判断进程是否还在运行。
160 |
161 | 下面我们来看一个实例:
162 | 
163 | 在上面的例子中,我们用 Process 创建了一个进程,接着调用 `start` 方法启动这个进程,然后调用 `terminate` 方法将进程终止,最后调用` join` 方法。
164 |
165 | 另外,在进程运行不同的阶段,我们还通过 `is_alive` 方法判断当前进程是否还在运行。
166 |
167 | 运行结果如下:
168 | 
169 | 这里有一个值得注意的地方,在调用 `terminate` 方法之后,我们用 `is_alive` 方法获取进程的状态发现依然还是运行状态。在调用 `join` 方法之后,`is_alive` 方法获取进程的运行状态才变为终止状态。
170 |
171 | 所以,在调用 `terminate` 方法之后,记得要调用一下` join` 方法,这里调用 `join` 方法可以为进程提供时间来更新对象状态,用来反映出最终的进程终止效果。
172 | ##### 进程互斥锁
173 |
174 | 在上面的一些实例中,我们可能会遇到如下的运行结果:
175 | ```python
176 | Pid: 73993 LoopCount: 0
177 | Pid: 73993 LoopCount: 1
178 | Pid: 73994 LoopCount: 0Pid: 73994 LoopCount: 1
179 | Pid: 73994 LoopCount: 2
180 | Pid: 73995 LoopCount: 0
181 | Pid: 73995 LoopCount: 1
182 | Pid: 73995 LoopCount: 2
183 | Pid: 73995 LoopCount: 3
184 | Main Process ended
185 | ```
186 | 我们发现,有的输出结果没有换行。这是什么原因造成的呢?
187 |
188 | 这种情况是由**多个进程并行执行导致的**,两个进程同时进行了输出,结果第一个进程的换行没有来得及输出,第二个进程就输出了结果,导致最终输出没有换行。
189 |
190 | 那如何来避免这种问题?如果我们能保证,多个进程运行期间的任一时间,只能一个进程输出,其他进程等待,等刚才那个进程输出完毕之后,另一个进程再进行输出,这样就不会出现输出没有换行的现象了。
191 |
192 | 这种解决方案实际上就是实现了**进程互斥**,避免了多个进程同时抢占临界区(输出)资源。我们可以通过 `multiprocessing` 中的` Lock` 来实现。`Lock`,即锁,**在一个进程输出时,加锁,其他进程等待。等此进程执行结束后,释放锁,其他进程可以进行输出**。
193 |
194 | 我们首先实现一个不加锁的实例,代码如下:
195 | 
196 | 运行结果如下:
197 |
198 | ```python
199 | Pid: 74030 LoopCount: 0
200 | Pid: 74031 LoopCount: 0
201 | Pid: 74032 LoopCount: 0
202 | Pid: 74033 LoopCount: 0
203 | Pid: 74034 LoopCount: 0
204 | Pid: 74030 LoopCount: 1
205 | Pid: 74031 LoopCount: 1
206 | Pid: 74032 LoopCount: 1Pid: 74033 LoopCount: 1
207 | Pid: 74034 LoopCount: 1
208 | Pid: 74030 LoopCount: 2
209 | ```
210 | 可以看到运行结果中有些输出已经出现了不换行的问题。
211 |
212 | 我们对其加锁,取消掉刚才代码中的两行注释,重新运行,运行结果如下:
213 |
214 | ```python
215 | Pid: 74061 LoopCount: 0
216 | Pid: 74062 LoopCount: 0
217 | Pid: 74063 LoopCount: 0
218 | Pid: 74064 LoopCount: 0
219 | Pid: 74065 LoopCount: 0
220 | Pid: 74061 LoopCount: 1
221 | Pid: 74062 LoopCount: 1
222 | Pid: 74063 LoopCount: 1
223 | Pid: 74064 LoopCount: 1
224 | Pid: 74065 LoopCount: 1
225 | Pid: 74061 LoopCount: 2
226 | Pid: 74062 LoopCount: 2
227 | Pid: 74064 LoopCount: 2
228 | ```
229 | 这时输出效果就正常了。
230 |
231 | 所以,在访问一些临界区资源时,使用 Lock 可以有效避免进程同时占用资源而导致的一些问题。
232 | ##### 信号量
233 |
234 | 进程互斥锁可以使同一时刻只有一个进程能访问共享资源,如上面的例子所展示的那样,在同一时刻只能有一个进程输出结果。但有时候我们需要允许多个进程来访问共享资源,同时还需要限制能访问共享资源的进程的数量。
235 |
236 | 这种需求该如何实现呢?可以用信号量,信号量是进程同步过程中一个比较重要的角色。它可以控制临界资源的数量,**实现多个进程同时访问共享资源,限制进程的并发量**。
237 |
238 | 如果你学过操作系统,那么一定对这方面非常了解,如果你还不了解信号量是什么,可以先熟悉一下这个概念。
239 |
240 | 我们可以用 `multiprocessing` 库中的 `Semaphore` 来实现信号量。
241 |
242 | 那么接下来我们就用一个实例来演示一下进程之间利用 `Semaphore` 做到多个进程共享资源,同时又限制同时可访问的进程数量,代码如下:
243 | 
244 | 如上代码实现了经典的生产者和消费者问题。它定义了两个进程类,一个是消费者,一个是生产者。
245 |
246 | 另外,这里使用 `multiprocessing` 中的 `Queue` 定义了一个共享队列,然后定义了两个信号量 `Semaphore`,一个代表缓冲区空余数,一个表示缓冲区占用数。
247 |
248 | 生产者 Producer 使用 acquire 方法来占用一个缓冲区位置,缓冲区空闲区大小减 1,接下来进行加锁,对缓冲区进行操作,然后释放锁,最后让代表占用的缓冲区位置数量加 1,消费者则相反。
249 |
250 | 运行结果如下:
251 | ```python
252 | Producer append an element
253 | Producer append an element
254 | Consumer pop an element
255 | Consumer pop an element
256 | Producer append an element
257 | Producer append an element
258 | Consumer pop an element
259 | Consumer pop an element
260 | Producer append an element
261 | Producer append an element
262 | Consumer pop an element
263 | Consumer pop an element
264 | Producer append an element
265 | Producer append an element
266 | ```
267 | 我们发现两个进程在交替运行,生产者先放入缓冲区物品,然后消费者取出,不停地进行循环。 你可以通过上面的例子来体会信号量 `Semaphore` 的用法,通过 `Semaphore` 我们很好地控制了进程对资源的并发访问数量。
268 | ##### 队列
269 |
270 | 在上面的例子中我们使用 `Queue` 作为进程通信的**共享队列**使用。
271 |
272 | 而如果我们把上面程序中的 Queue 换成普通的 list,是完全起不到效果的,因为进程和进程之间的资源是不共享的。即使在一个进程中改变了这个 list,在另一个进程也不能获取到这个 list 的状态,所以声明全局变量对多进程是没有用处的。
273 |
274 | 那进程如何共享数据呢?可以用 `Queue`,即队列。当然这里的队列指的是 `multiprocessing` 里面的 `Queue`。
275 |
276 | 依然用上面的例子,我们一个进程向队列中放入随机数据,然后另一个进程取出数据。
277 | 
278 | 运行结果如下:
279 | ```python
280 | Producer put 0.719213647437
281 | Producer put 0.44287326683
282 | Consumer get 0.719213647437
283 | Consumer get 0.44287326683
284 | Producer put 0.722859424381
285 | Producer put 0.525321338921
286 | Consumer get 0.722859424381
287 | Consumer get 0.525321338921
288 | ```
289 | 在上面的例子中我们声明了两个进程,一个进程为生产者 Producer,另一个为消费者 Consumer,生产者不断向 Queue 里面添加随机数,消费者不断从队列里面取随机数。
290 |
291 | 生产者在放数据的时候调用了 Queue 的 put 方法,消费者在取的时候使用了 get 方法,这样我们就通过 Queue 实现两个进程的数据共享了。
292 | ##### 管道
293 | 刚才我们使用 `Queue` 实现了进程间的**数据共享**,那么进程之间直接**通信**,如收发信息,用什么比较好呢?可以用 `Pipe`,管道。
294 |
295 | 管道,我们可以把它理解为两个进程之间通信的通道。管道可以是单向的,即 `half-duplex`:一个进程负责发消息,另一个进程负责收消息;也可以是双向的 `duplex`,即互相收发消息。
296 |
297 | 默认声明 `Pipe` 对象是双向管道,如果要创建单向管道,可以在初始化的时候传入 `deplex` 参数为 `False`。
298 |
299 | 我们用一个实例来感受一下:
300 | 
301 | 在这个例子里我们声明了一个默认为双向的管道,然后将管道的两端分别传给两个进程。两个进程互相收发。观察一下结果:
302 | ```python
303 | Producer Received: Consumer Words
304 | Consumer Received: Producer Words
305 | Main Process Ended
306 | ```
307 | 管道 `Pipe` 就像进程之间搭建的桥梁,利用它我们就可以很方便地实现进程间通信了。
308 | ##### 进程池
309 |
310 | 在前面,我们讲了可以使用 `Process` 来创建进程,同时也讲了如何用 `Semaphore` 来控制进程的并发执行数量。
311 |
312 | 假如现在我们遇到这么一个问题,我有 10000 个任务,每个任务需要启动一个进程来执行,并且一个进程运行完毕之后要紧接着启动下一个进程,同时我还需要控制进程的并发数量,不能并发太高,不然 CPU 处理不过来(如果同时运行的进程能维持在一个最高恒定值当然利用率是最高的)。
313 |
314 | 那么我们该如何来实现这个需求呢?
315 |
316 | 用 `Process` 和 `Semaphore` 可以实现,但是实现起来比较我们可以用 `Process` 和 `Semaphore` 解决问题,但是实现起来比较烦琐。而这种需求在平时又是非常常见的。此时,我们就可以派上进程池了,即 `multiprocessing` 中的 `Pool`。
317 |
318 | `Pool` 可以提供指定数量的进程,供用户调用,当有新的请求提交到 pool 中时,如果池还没有满,就会创建一个新的进程用来执行该请求;但如果池中的进程数已经达到规定最大值,那么该请求就会等待,直到池中有进程结束,才会创建新的进程来执行它。
319 |
320 | 我们用一个实例来实现一下,代码如下:
321 | 
322 | 在这个例子中我们声明了一个大小为 3 的进程池,通过 `processes` 参数来指定,如果不指定,那么会自动根据处理器内核来分配进程数。接着我们使用 `apply_async` 方法将进程添加进去,`args` 可以用来传递参数。
323 |
324 | 运行结果如下:
325 |
326 | ```python
327 | Main Process started
328 | Start process: 0
329 | Start process: 1
330 | Start process: 2
331 | End process 0
332 | End process 1
333 | End process 2
334 | Start process: 3
335 | End process 3
336 | Main Process ended
337 | ```
338 | 进程池大小为 3,所以最初可以看到有 3 个进程同时执行,第进程池大小为 3,所以最初可以看到有 3 个进程同时执行,第4个进程在等待,在有进程运行完毕之后,第4个进程马上跟着运行,出现了如上的运行效果。
339 |
340 | 最后,我们要记得调用 close 方法来关闭进程池,使其不再接受新的任务,然后调用 join 方法让主进程等待子进程的退出,等子进程运行完毕之后,主进程接着运行并结束。
341 |
342 | 不过上面的写法多少有些烦琐,这里再介绍进程池一个更好用的 `map` 方法,可以将上述写法简化很多。
343 |
344 | `map` 方法是怎么用的呢?第一个参数就是要启动的进程对应的执行方法,第 2 个参数是一个可迭代对象,其中的每个元素会被传递给这个执行方法。
345 |
346 | 举个例子:现在我们有一个 list,里面包含了很多 URL,另外我们也定义了一个方法用来抓取每个 URL 内容并解析,那么我们可以直接在 map 的第一个参数传入方法名,第 2 个参数传入 URL 数组。
347 |
348 | 我们用一个实例来感受一下:
349 | 
350 | 这个例子中我们先定义了一个 scrape 方法,它接收一个参数 url,这里就是请求了一下这个链接,然后输出爬取成功的信息,如果发生错误,则会输出爬取失败的信息。
351 |
352 | 首先我们要初始化一个 Pool,指定进程数为 3。然后我们声明一个 urls 列表,接着我们调用了 map 方法,第 1 个参数就是进程对应的执行方法,第 2 个参数就是 urls 列表,map 方法会依次将 urls 的每个元素作为 scrape 的参数传递并启动一个新的进程,加到进程池中执行。
353 |
354 | 运行结果如下:
355 |
356 | ```python
357 | URL https://www.baidu.com Scraped
358 | URL http://xxxyxxx.net not Scraped
359 | URL http://blog.csdn.net/ Scraped
360 | URL http://www.meituan.com/ Scraped
361 | ```
362 | 这样,我们就可以实现 3 个进程并行运行。不同的进程相互独立地输出了对应的爬取结果。
363 |
364 |
365 | 可以看到,我们利用 Pool 的 map 方法非常方便地实现了多进程的执行。后面我们也会在实战案例中结合进程池来实现数据的爬取。
366 |
367 |
368 |
369 | 以上便是 Python 中多进程的基本用法,本节内容比较多,后面的实战案例也会用到这些内容,需要好好掌握。
370 |
--------------------------------------------------------------------------------
/第08讲:解析无所不能的正则表达式.md:
--------------------------------------------------------------------------------
1 | 在上个课时中,我们学会了如何用 Requests 来获取网页的源代码,得到 HTML 代码。但我们如何从 HTML 代码中获取真正想要的数据呢?
2 |
3 | 正则表达式就是一个有效的方法。
4 |
5 | 本课时中,我们将学习正则表达式的相关用法。正则表达式是处理字符串的强大工具,它有自己特定的语法结构。有了它,我们就能实现字符串的检索、替换、匹配验证。
6 |
7 | 当然,对于爬虫来说,有了它,要从 HTML 里提取想要的信息就非常方便了。
8 |
9 | #### 实例引入
10 |
11 | 说了这么多,可能我们对正则表达式的概念还是比较模糊,下面就用几个实例来看一下正则表达式的用法。
12 |
13 | 打开开源中国提供的正则表达式测试工具 **http://tool.oschina.net/regex/**,输入待匹配的文本,然后选择常用的正则表达式,就可以得出相应的匹配结果了。
14 |
15 | 例如,输入下面这段待匹配的文本:
16 | > Hello, my phone number is 010-86432100 and email is
17 | > cqc@cuiqingcai.com, and my website is https://cuiqingcai.com.
18 |
19 | 这段字符串中包含了一个电话号码和一个电子邮件,接下来就尝试用正则表达式提取出来,如图所示。
20 | 
21 | 在网页右侧选择 “匹配 Email 地址”,就可以看到下方出现了文本中的 E-mail。如果选择 “匹配网址 URL”,就可以看到下方出现了文本中的 URL。是不是非常神奇?
22 |
23 | 其实,这里使用了正则表达式的匹配功能,也就是用一定规则将特定的文本提取出来。
24 |
25 | 比方说,电子邮件是有其特定的组成格式的:一段字符串 + @ 符号 + 某个域名。而 URL的组成格式则是协议类型 + 冒号加双斜线 + 域名和路径。
26 |
27 | 可以用下面的正则表达式匹配 URL:
28 | 
29 | 用这个正则表达式去匹配一个字符串,如果这个字符串中包含类似 URL 的文本,那就会被提取出来。
30 |
31 | 这个看上去乱糟糟的正则表达式其实有特定的语法规则。比如,a-z 匹配任意的小写字母,**\s 匹配任意的空白字符**,*** 匹配前面任意多个字符**。这一长串的正则表达式就是这么多匹配规则的组合。
32 |
33 | 写好正则表达式后,就可以拿它去一个长字符串里匹配查找了。不论这个字符串里面有什么,只要符合我们写的规则,统统可以找出来。对于网页来说,如果想找出网页源代码里有多少 URL,用 URL 的正则表达式去匹配即可。
34 |
35 | 下表中列出了常用的匹配规则:
36 | 
37 | 
38 | 看完之后,你可能有点晕晕的吧,不用担心,后面我们会详细讲解一些常见规则的用法。
39 |
40 | 其实正则表达式不是 Python 独有的,它也可以用在其他编程语言中。但是 Python 的 `re `库提供了整个正则表达式的实现,利用这个库,可以在 Python 中使用正则表达式。
41 |
42 | 在 Python 中写正则表达式几乎都用这个库,下面就来了解它的一些常用方法。
43 | #### match
44 | 首先介绍一个常用的匹配方法 —— `match`,向它传入要匹配的字符串,以及正则表达式,就可以检测这个正则表达式是否匹配字符串。
45 |
46 | **match 方法会尝试从字符串的起始位置匹配正则表达式**,如果匹配,就返回匹配成功的结果;如果不匹配,就返回 None。
47 |
48 |
49 |
50 | 示例如下:
51 |
52 | ```python
53 | import re
54 |
55 | content='Hello 123 4567 World_This is a Regex Demo'
56 | print(len(content))
57 |
58 | result=re.match('^Hello\s\d\d\d\s\d{4}\s\w{10}\s\w{2}',content)
59 | print(result)
60 | print(result.group())
61 | print(result.span())
62 | ```
63 | 运行结果如下:
64 | ```python
65 | 41
66 |
67 | Hello 123 4567 World_This is
68 | (0, 28)
69 | ```
70 | 这里首先声明了一个字符串,其中包含英文字母、空白字符、数字等。接下来,我们写一个正则表达式:
71 | 
72 | 用它来匹配这个长字符串。开头的 **^ 匹配字符串的开头**,也就是以 Hello 开头; \s 匹配空白字符,用来匹配目标字符串的空格;\d 匹配数字,3 个 \d 匹配 123;再写 1 个 \s 匹配空格;后面的 4567,其实依然能用 4 个 \d 来匹配,但是这么写比较烦琐,所以后面可以跟 {4} 代表匹配前面的规则 4 次,也就是匹配 4 个数字;后面再紧接 1 个空白字符,最后\w{10} 匹配 10 个字母及下划线。
73 |
74 | 我们注意到,这里并没有把目标字符串匹配完,不过依然可以进行匹配,只不过匹配结果短一点而已。
75 |
76 | 而在 **match 方法中,第一个参数传入正则表达式,第二个参数传入要匹配的字符串**。
77 |
78 | 打印输出结果,可以看到结果是 `SRE_Match` 对象,这证明成功匹配。该对象有两个方法:`group` 方法可以**输出匹配的内容**,结果是 `Hello 123 4567 World_This`,这恰好是正则表达式规则所匹配的内容;`span` 方法可以**输出匹配的范围**,结果是 (0, 25),这就是匹配到的结果字符串在原字符串中的位置范围。
79 |
80 | 通过上面的例子,我们基本了解了如何在 Python 中使用正则表达式来匹配一段文字。
81 |
82 | #### 匹配目标
83 | 刚才我们用 `match` 方法得到了匹配到的字符串内容,但当我们想从字符串中提取一部分内容,该怎么办呢?
84 |
85 | 就像最前面的实例一样,要从一段文本中提取出邮件或电话号码等内容。**我们可以使用 () 括号将想提取的子字符串括起来**。() 实际上标记了一个子表达式的开始和结束位置,被标记的每个子表达式会依次对应每一个分组,调用 group 方法传入分组的索引即可获取提取的结果。
86 |
87 | 示例如下:
88 |
89 | ```python
90 | import re
91 |
92 | content='Hello 1234567 World_This is a Regex Demo'
93 | print(len(content))
94 |
95 | result=re.match('^Hello\s(\d+)\sWorld',content)
96 | print(result)
97 | print(result.group())
98 | print(result.group(1))
99 | print(result.span())
100 | ```
101 | 这里我们想把字符串中的 1234567 提取出来,此时可以将数字部分的正则表达式用 () 括起来,然后调用了 group(1) 获取匹配结果。
102 |
103 | 运行结果如下
104 |
105 | ```python
106 | 40
107 |
108 | Hello 1234567 World
109 | 1234567
110 | (0, 19)
111 | ```
112 | 可以看到,我们成功得到了 1234567。这里用的是 group(1),它与 group() 有所不同,后者会输出完整的匹配结果,**而前者会输出第一个被 () 包围的匹配结果**。假如正则表达式后面还有 () 包括的内容,那么可以依次用 group(2)、group(3) 等来获取。
113 | #### 通用匹配
114 |
115 | 刚才我们写的正则表达比较复杂,出现空白字符我们就写 \s 匹配,出现数字我们就用 \d 匹配,这样的工作量非常大。
116 |
117 | 我们还可以用一个万能匹配来减少这些工作,那就是 **.***。
118 |
119 | 其中 . 可以匹配任意字符(除换行符),* 代表匹配前面的字符无限次,它们组合在一起就可以匹配任意字符了。有了它,我们就不用挨个字符的匹配了。
120 |
121 | 接着上面的例子,我们可以改写一下正则表达式:
122 |
123 | ```python
124 | import re
125 |
126 | content='Hello 123 4567 World_This is a Regex Demo'
127 | print(len(content))
128 |
129 | result=re.match('^Hello.*Demo$',content)
130 | #result=re.match('^Hello(.*)Regex',content)
131 | print(result)
132 | print(result.group())
133 | print(result.span())
134 | ```
135 | 这里我们将中间部分直接省略,全部用 .* 来代替,最后加一个结尾字符就好了。
136 |
137 |
138 |
139 | 运行结果如下:
140 | ```python
141 | 41
142 |
143 | Hello 123 4567 World_This is a Regex Demo
144 | (0, 41)
145 | ```
146 | 可以看到,group 方法输出了匹配的全部字符串,也就是说我们写的正则表达式匹配到了目标字符串的全部内容;span 方法输出 (0, 41),这是整个字符串的长度。
147 |
148 | 因此,我们可以使用 .* 简化正则表达式的书写。
149 | #### 贪婪与非贪婪
150 | 使用上面的通用匹配 .* 时,有时候匹配到的并不是我们想要的结果。
151 |
152 | 看下面的例子:
153 |
154 | ```python
155 | import re
156 |
157 | content='Hello 1234567 World_This is a Regex Demo'
158 | print(len(content))
159 |
160 | result=re.match('^He.*(\d+).*Demo$',content)
161 | print(result)
162 | print(result.group(1))
163 | ```
164 | 这里我们依然想获取中间的数字,所以中间依然写的是 (\d+)。由于数字两侧的内容比较杂乱,所以略写成 .*。最后,组成 ^He.*(\d+).*Demo$,看样子并没有什么问题。
165 |
166 | 我们看下运行结果:
167 |
168 | ```python
169 | 40
170 |
171 | 7
172 | ```
173 | 奇怪的事情发生了,我们只得到了 7 这个数字,这是怎么回事呢?
174 |
175 | 这里就涉及一个贪婪匹配与非贪婪匹配的问题了。在贪婪匹配下,.* 会匹配尽可能多的字符。正则表达式中 .* 后面是 \d+,也就是至少一个数字,并没有指定具体多少个数字,因此,.* 就尽可能匹配多的字符,这里就把 123456 匹配了,给 \d+ 留下一个可满足条件的数字 7,最后得到的内容就只有数字 7 了。
176 |
177 | 这显然会给我们带来很大的不便。有时候,匹配结果会莫名其妙少了一部分内容。其实,这里只需要使用**非贪婪匹配**就好了。非贪婪匹配的写法是 .*?,多了一个 ?,那么它可以达到怎样的效果?
178 |
179 | 我们再用实例看一下:
180 | ```python
181 | import re
182 |
183 | content='Hello 1234567 World_This is a Regex Demo'
184 | print(len(content))
185 |
186 | result=re.match('^He.*?(\d+).*Demo$',content)
187 | print(result)
188 | print(result.group(1))
189 | ```
190 | 这里我们只是将第一个.* 改成了 .*?,转变为非贪婪匹配。
191 |
192 | 结果如下:
193 | ```python
194 | 40
195 |
196 | 1234567
197 | ```
198 | 此时就可以成功获取 1234567 了。原因可想而知,**贪婪匹配是尽可能匹配多的字符,非贪婪匹配就是尽可能匹配少的字符**。当 .*? 匹配到 Hello 后面的空白字符时,再往后的字符就是数字了,而 \d+ 恰好可以匹配,那么 .*? 就不再进行匹配,交给 \d+ 去匹配后面的数字。这样 .*? 匹配了尽可能少的字符,\d+ 的结果就是 1234567 了。
199 |
200 | 所以,在做匹配的时候,字符串中间尽量使用非贪婪匹配,也就是用 .*? 来代替 .*,以免出现匹配结果缺失的情况。
201 |
202 | 但需要注意的是,如果匹配的结果在字符串结尾,.*? 就有可能匹配不到任何内容了,因为它会匹配尽可能少的字符。例如:
203 | ```python
204 | content='http://weibo.com/comment/KEraCN'
205 | result1=re.match('http.*?comment/(.*?)',content)
206 | result2=re.match('http.*?comment/(.*)',content)
207 | print('result1',result1.group(1))
208 | print('result2',result2.group(1))
209 | ```
210 | 运行结果如下:
211 | ```python
212 | result1
213 | result2 KEraCN
214 | ```
215 | 可以观察到,.*? 没有匹配到任何结果,而 .* 则尽量匹配多的内容,成功得到了匹配结果。
216 | #### 修饰符
217 | 正则表达式可以包含一些可选标志修饰符来控制匹配的模式。修饰符被指定为一个可选的标志。
218 |
219 | 我们用实例来看一下:
220 |
221 | ```python
222 | import re
223 |
224 | content='''Hello 1234567 World_This
225 | is a Regex Demo'''
226 |
227 | result=re.match('^He.*?(\d+).*?Demo$',content)
228 | print(result.group(1))
229 | ```
230 | 和上面的例子相仿,我们在字符串中加了**换行符**,正则表达式还是一样的,用来匹配其中的数字。看一下运行结果:
231 | ```python
232 | Traceback (most recent call last):
233 | File "D:/pycharm/Test/test/test2.py", line 7, in
234 | print(result.group(1))
235 | AttributeError: 'NoneType' object has no attribute 'group'
236 | ```
237 | 运行直接报错,也就是说正则表达式没有匹配到这个字符串,返回结果为 None,而我们又调用了 group 方法导致 AttributeError。
238 |
239 | 为什么加了一个换行符,就匹配不到了呢?这是
240 |
241 | **因为我们匹配的是除换行符之外的任意字符**,当遇到换行符时,.*? 就不能匹配了,导致匹配失败。
242 |
243 | 这里只需加一个修饰符 re.S,即可修正这个错误:
244 | ```python
245 | result=re.match('^He.*?(\d+).*?Demo$',content,re.S)
246 | ```
247 | 这个修饰符的作用是匹配包括换行符在内的所有字符。
248 |
249 | 此时运行结果如下:
250 |
251 | ```python
252 | 1234567
253 | ```
254 | 这个 `re.S` 在网页匹配中经常用到。因为 HTML 节点经常会有换行,**加上它,就可以匹配节点与节点之间的换行了**。
255 |
256 |
257 | 另外,还有一些修饰符,在必要的情况下也可以使用,如表所示:
258 | 
259 |
260 | #### 转义匹配
261 | 我们知道正则表达式定义了许多匹配模式,如匹配除换行符以外的任意字符,但如果目标字符串里面就包含 .,那该怎么办呢?
262 |
263 | 这里就需要用到转义匹配了,示例如下:
264 | ```python
265 | content='( 百度 )www.baidu.com'
266 | result=re.match('\( 百度 \)www\.baidu\.com',content)
267 | print(result)
268 | ```
269 | 当遇到用于正则匹配模式的特殊字符时,在前面加反斜线转义一下即可。例 . 就可以用 \. 来匹配。
270 |
271 | 运行结果如下:
272 | ```python
273 |
274 | ```
275 | 可以看到,这里成功匹配到了原字符串。
276 |
277 | 这些是写正则表达式常用的几个知识点,熟练掌握它们对后面写正则表达式匹配非常有帮助。
278 |
279 | #### search
280 | 前面提到过,**match 方法是从字符串的开头开始匹配的,一旦开头不匹配,那么整个匹配就失败了**。
281 |
282 | 我们看下面的例子:
283 | ```python
284 | content='Extra stings Hello 1234567 World_This is a Regex Demo Extra string'
285 | result=re.match('Hello.*?(\d+).*?Demo',content)
286 | print(result)
287 | ```
288 | 这里的字符串以 Extra 开头,但是正则表达式以 Hello 开头,整个正则表达式是字符串的一部分,但是这样匹配是失败的。
289 |
290 | 运行结果如下:
291 | ```python
292 | None
293 | ```
294 | 因为 match 方法在使用时需要考虑到开头的内容,这在做匹配时并不方便。它**更适合用来检测某个字符串是否符合某个正则表达式的规则**。
295 |
296 | 这里有另外一个方法 `search`,它在匹配时会扫描整个字符串,然后返回第一个成功匹配的结果。也就是说,正则表达式可以是字符串的一部分,在匹配时,search 方法会依次扫描字符串,直到找到第一个符合规则的字符串,然后返回匹配内容,如果搜索完了还没有找到,就返回 None。
297 |
298 | 我们把上面代码中的 match 方法修改成 search,再看下运行结果:
299 | ```python
300 | content='Extra stings Hello 1234567 World_This is a Regex Demo Extra string'
301 | result=re.search('Hello.*?(\d+).*?Demo',content)
302 | print(result)
303 | print(result.group(1))
304 |
305 |
306 | 1234567
307 | ```
308 | 这时就得到了匹配结果。
309 |
310 | 因此,为了匹配方便,我们可以尽量使用 search 方法。
311 |
312 | 下面再用几个实例来看看 search 方法的用法。
313 |
314 | 这里有一段待匹配的 HTML 文本,接下来我们写几个正则表达式实例来实现相应信息的提取:
315 | ```python
316 | html='''
317 |
经典老歌
318 |
经典老歌列表
319 |
320 | - 一路上有你
321 | -
322 | 沧海一声笑
323 |
324 | -
325 | 往事随风
326 |
327 | - 光辉岁月
328 |
329 |
330 |
331 |
332 | '''
333 | ```
334 | 可以观察到,ul 节点里有许多 li 节点,其中 li 节点中有的包含 a 节点,有的不包含 a 节点,a 节点还有一些相应的属性 —— 超链接和歌手名。
335 |
336 | 首先,我们尝试提取 class为 active 的 li 节点内部超链接包含的歌手名和歌名,此时需要提取第三个 li 节点下 a 节点的 singer 属性和文本。
337 |
338 | 此时,正则表达式可以用 li 开头,然后寻找一个标志符 active,中间的部分可以用 .*? 来匹配。
339 |
340 | 接下来,要提取 singer 这个属性值,所以还需要写入 singer="(.*?)",这里需要提取的部分用小括号括起来,以便用 group 方法提取出来,它的两侧边界是双引号。
341 |
342 | 然后还需要匹配 a 节点的文本,其中它的左边界是 >,右边界是 。目标内容依然用 (.*?) 来匹配,所以最后的正则表达式就变成了:
343 | ```python
344 | (.*?)
345 | ```
346 | 然后再调用 search 方法,它会搜索整个 HTML 文本,找到符合正则表达式的第一个内容返回。
347 |
348 | 另外,由于代码**有换行**,所以这里第三个参数需要传入 re.S。整个匹配代码如下:
349 | ```python
350 | result=re.search('(.*?)',html,re.S)
351 | print(result.group(1),result.group(2))
352 | ```
353 | 由于需要获取的歌手和歌名都已经用小括号包围,所以可以用 group 方法获取。
354 |
355 | 运行结果如下:
356 | ```python
357 | 齐秦 往事随风
358 | ```
359 | 可以看到,这正是 class 为 active 的 li 节点内部的超链接包含的歌手名和歌名。
360 |
361 | 如果正则表达式不加 active(也就是匹配不带 class 为 active 的节点内容),那会怎样呢?我们将正则表达式中的 active 去掉。
362 |
363 | 代码改写如下:
364 | ```python
365 | result=re.search('(.*?)',html,re.S)
366 | print(result.group(1),result.group(2))
367 | ```
368 | 由于 search 方法会返回第一个符合条件的匹配目标,这里结果就变了:
369 | ```python
370 | 任贤齐 沧海一声笑
371 | ```
372 | 把 active 标签去掉后,从字符串开头开始搜索,此时符合条件的节点就变成了第二个 li 节点,后面的不再匹配,所以运行结果变成第二个 li 节点中的内容。
373 |
374 | 注意,在上面的两次匹配中,search 方法的第三个参数都加了 re.S,这使得 .*? 可以匹配换行,所以含有换行的 li 节点被匹配到了。如果我们将其去掉,结果会是什么?
375 |
376 | 代码如下:
377 | ```python
378 | result=re.search('(.*?)',html)
379 | print(result.group(1),result.group(2))
380 | ```
381 | 运行结果如下:
382 | ```python
383 | beyond 光辉岁月
384 | ```
385 | 可以看到,结果变成了第四个 li 节点的内容。这是因为第二个和第三个 li 节点都包含了换行符,去掉 re.S 之后,.*? 已经不能匹配换行符,所以正则表达式不会匹配到第二个和第三个 li 节点,而第四个 li 节点中不包含换行符,所以成功匹配。
386 |
387 | 由于绝大部分的 HTML 文本都包含了换行符,所以尽量都需要加上 re.S 修饰符,以免出现匹配不到的问题。
388 |
389 | #### findall
390 |
391 | 前面我们介绍了 **search 方法的用法,它可以返回匹配正则表达式的第一个内容,但是如果想要获取匹配正则表达式的所有内容,那该怎么办呢?这时就要借助 findall 方法了**。
392 |
393 | 该方法会搜索整个字符串,然后返回匹配正则表达式的所有内容。
394 |
395 | 还是上面的 HTML 文本,如果想获取所有 a 节点的超链接、歌手和歌名,就可以将 search 方法换成 `findall` 方法。**如果有返回结果的话,就是列表类型**,所以需要遍历一下来依次获取每组内容。
396 |
397 | 代码如下:
398 | ```python
399 | results=re.findall('(.*?)',html,re.S)
400 | print(results)
401 | print(type(results))
402 | for result in results:
403 | print(result[0],result[1],result[2])
404 | ```
405 | 运行结果如下:
406 | ```python
407 | [('/2.mp3', '任贤齐', '沧海一声笑'), ('/3.mp3', '齐秦', '往事随风'), ('/4.mp3', 'beyond', '光辉岁月'), ('/5.mp3', '陈慧琳', '记事本'), ('/6.mp3', '邓丽君', '但愿人长久')]
408 |
409 | /2.mp3 任贤齐 沧海一声笑
410 | /3.mp3 齐秦 往事随风
411 | /4.mp3 beyond 光辉岁月
412 | /5.mp3 陈慧琳 记事本
413 | /6.mp3 邓丽君 但愿人长久
414 | ```
415 | 可以看到,返回的列表中的每个元素都是元组类型,我们用对应的索引依次取出即可。
416 |
417 | 如果只是获取第一个内容,可以用 search 方法。当需要提取多个内容时,可以用 `findall` 方法。
418 |
419 | #### sub
420 | 除了使用正则表达式提取信息外,有时候还需要借助它来修改文本。比如,想要把一串文本中的所有数字都去掉,如果只用字符串的 replace 方法,那就太烦琐了,这时可以借助 sub 方法。
421 |
422 | 示例如下:
423 | ```python
424 | content='54ak54yr5oiR54ix5L2g'
425 | content=re.sub('\d+','',content)
426 | print(content)
427 | ```
428 | 运行结果如下:
429 | ```python
430 | akyroiRixLg
431 | ```
432 | 这里只需要给第一个参数传入 \d+ 来匹配所有的数字,第二个参数替换成的字符串(如果去掉该参数的话,可以赋值为空),第三个参数是原字符串。
433 |
434 | 在上面的 HTML 文本中,如果想获取所有 li 节点的歌名,直接用正则表达式来提取可能比较烦琐。比如,可以写成这样子:
435 | ```python
436 | results=re.findall('\s*?()?(\w+)()?\s*?',html,re.S)
437 | for result in results:
438 | print(result[1])
439 | ```
440 | 运行结果如下:
441 | ```python
442 | 一路上有你
443 | 沧海一声笑
444 | 往事随风
445 | 光辉岁月
446 | 记事本
447 | 但愿人长久
448 | ```
449 | 此时借助 sub 方法就比较简单了。可以先用 `sub `方法将 a 节点去掉,只留下文本,然后再利用 `findall `提取就好了:
450 | ```python
451 | html=re.sub('|','',html)
452 | #print(html)
453 | results=re.findall('(.*?)',html,re.S)
454 | for result in results:
455 | print(result.strip())
456 | ```
457 | 运行结果如下:
458 |
459 | ```python
460 |
461 |
经典老歌
462 |
经典老歌列表
463 |
464 | - 一路上有你
465 | -
466 | 沧海一声笑
467 |
468 | -
469 | 往事随风
470 |
471 | - 光辉岁月
472 | - 记事本
473 | - 但愿人长久
474 |
475 |
476 |
477 | 一路上有你
478 | 沧海一声笑
479 | 往事随风
480 | 光辉岁月
481 | 记事本
482 | 但愿人长久
483 | ```
484 | 可以看到,a 节点经过 sub 方法处理后就没有了,随后我们通过 findall 方法直接提取即可。
485 |
486 | 通过以上例子,你会发现,**在适当的时候,借助 sub 方法可以起到事半功倍的效果**。
487 |
488 | #### compile
489 |
490 | 前面所讲的方法都是用来处理字符串的方法,最后再介绍一下` compile` 方法,这个方法可以将正则字符串编译成正则表达式对象,以便在后面的匹配中复用。
491 |
492 | 示例代码如下:
493 | ```python
494 | content1='2019-12-15 12:00'
495 | content2='2019-12-17 12:55'
496 | content3='2019-12-22 13:21'
497 | pattern=re.compile('\d{2}:\d{2}')
498 | result1=re.sub(pattern,'',content1)
499 | result2=re.sub(pattern,'',content2)
500 | result3=re.sub(pattern,'',content3)
501 | print(result1,result2,result3)
502 | ```
503 | 这里有 3 个日期,我们想分别将 3 个日期中的时间去掉,这时可以借助 sub 方法。该方法的第一个参数是正则表达式,但是我们没有必要重复写 3 个同样的正则表达式。此时可以借助 compile 方法将正则表达式编译成一个正则表达式对象,以便复用。
504 |
505 | 运行结果如下:
506 | ```python
507 | 2019-12-15 2019-12-17 2019-12-22
508 | ```
509 | 另外,compile 还可以传入修饰符,例如 re.S 等修饰符,这样在 search、findall 等方法中就不需要额外传了。所以,compile 方法可以说是给正则表达式做了一层封装,以便我们更好的复用。
510 |
511 | 到此,正则表达式的基本用法就介绍完了。后面我会通过具体的实例来讲解正则表达式的用法。
512 |
513 |
514 |
515 |
516 |
517 |
518 |
--------------------------------------------------------------------------------
/第10讲:高效存储 MongoDB 的用法.md:
--------------------------------------------------------------------------------
1 | #### 第10讲:高效存储 MongoDB 的用法
2 |
3 | 上节课我们学习了如何用 `pyquery` 提取 HTML 中的信息,但是当我们成功提取了数据之后,该往哪里存放呢?
4 |
5 | 用文本文件当然是可以的,但文本存储不方便检索。有没有既方便存,又方便检索的存储方式呢?
6 |
7 | 当然有,本课时我将为你介绍一个文档型数据库 —— `MongoDB`。
8 |
9 | ==MongoDB 是由 C++ 语言编写的非关系型数据库==,是一个基于分布式文件存储的开源数据库系统,其内容==存储形式类似 JSON 对象==,它的字段值可以包含其他文档、数组及文档数组,非常灵活。
10 |
11 | 在这个课时中,我们就来看看 Python 3 下 MongoDB 的存储操作。
12 |
13 | ##### 准备工作
14 |
15 | 在开始之前,请确保你已经安装好了 MongoDB 并启动了其服务,同时安装好了 Python 的 PyMongo 库。
16 |
17 | MongoDB 的安装方式可以参考:https://cuiqingcai.com/5205.html,安装好之后,我们需要把 MongoDB 服务启动起来。
18 |
19 | > 注意:这里我们为了学习,仅使用 MongoDB 最基本的单机版,MongoDB 还有主从复制、副本集、分片集群等集群架构,可用性可靠性更好,如有需要可以自行搭建相应的集群进行使用。
20 |
21 | 启动完成之后,它会默认在本地 localhost 的 27017 端口上运行。
22 |
23 | 接下来我们需要安装 PyMongo 这个库,它是 Python 用来操作 MongoDB 的第三方库,直接用 pip3 安装即可:`pip3 install pymongo`。
24 |
25 | 更详细的安装方式可以参考:https://cuiqingcai.com/5230.html。
26 |
27 | 安装完成之后,我们就可以使用 PyMongo 来将数据存储到 MongoDB 了。
28 |
29 | ###### 连接 MongoDB
30 |
31 | 连接 MongoDB 时,我们需要使用 PyMongo 库里面的 `MongoClient`。一般来说,我们只需要向其传入 MongoDB 的 IP 及端口即可,其中第一个参数为地址 `host`,第二个参数为端口 port(如果不给它传递参数,则默认是 `27017`):
32 |
33 | ```python
34 | import pymongo
35 | client = pymongo.MongoClient(host='localhost', port=27017)
36 | ```
37 |
38 | 这样我们就可以创建 MongoDB 的连接对象了。
39 |
40 | 另外,MongoClient 的第一个参数 host 还可以直接传入 MongoDB 的连接字符串,它以 mongodb 开头,例如:
41 |
42 | ```python
43 | client = MongoClient('mongodb://localhost:27017/')
44 | ```
45 |
46 | 这样也可以达到同样的连接效果。
47 |
48 | ##### 指定数据库
49 |
50 | `MongoDB` 中可以建立多个数据库,接下来我们需要指定操作其中一个数据库。这里我们以 test 数据库作为下一步需要在程序中指定使用的例子:
51 |
52 | ```python
53 | db = client.test
54 | ```
55 |
56 | 这里调用 client 的 test 属性即可返回 test 数据库。当然,我们也可以这样指定:
57 |
58 | ```pyton
59 | db = client['test']
60 | ```
61 |
62 | 这两种方式是等价的。
63 |
64 | ##### 指定集合
65 |
66 | ==MongoDB 的每个数据库又包含许多集合(collection),它们类似于关系型数据库中的表。==
67 |
68 | 下一步需要指定要操作的集合,这里我们指定一个名称为 students 的集合。与指定数据库类似,指定集合也有两种方式:
69 |
70 | ```python
71 | collection = db.students
72 | ```
73 |
74 | 或是
75 |
76 | ```python
77 | collection = db['students']
78 | ```
79 |
80 | 这样我们便声明了一个 Collection 对象。
81 |
82 | ##### 插入数据
83 |
84 | 接下来,便可以插入数据了。我们对 students 这个集合新建一条学生数据,这条数据以字典形式表示:
85 |
86 | ```python
87 | student = {
88 | 'id': '20170101',
89 | 'name': 'Jordan',
90 | 'age': 20,
91 | 'gender': 'male'
92 | }
93 | ```
94 |
95 | 新建的这条数据里指定了学生的学号、姓名、年龄和性别。接下来,我们直接调用 `collection` 的 `insert` 方法即可插入数据,代码如下:
96 |
97 | ```python
98 | result = collection.insert(student)
99 | print(result)
100 | ```
101 |
102 | 在 MongoDB 中,每条数据其实都有一个 id 属性来唯一标识。如果没有显式指明该属性,MongoDB 会自动产生一个 ObjectId 类型的 id 属性。insert() 方法会在执行后返回_id 值。
103 |
104 | 运行结果如下:
105 |
106 | ```python
107 | 5932a68615c2606814c91f3d
108 | ```
109 |
110 | 当然,我们也可以同时插入多条数据,只需要以列表形式传递即可,示例如下:
111 |
112 | ```python
113 | student1 = {
114 | 'id': '20170101',
115 | 'name': 'Jordan',
116 | 'age': 20,
117 | 'gender': 'male'
118 | }
119 |
120 | student2 = {
121 | 'id': '20170202',
122 | 'name': 'Mike',
123 | 'age': 21,
124 | 'gender': 'male'
125 | }
126 |
127 | result = collection.insert([student1, student2])
128 | print(result)
129 | ```
130 |
131 | 返回结果是对应的_id 的集合:
132 |
133 | ```python
134 | [ObjectId('5932a80115c2606a59e8a048'), ObjectId('5932a80115c2606a59e8a049')]
135 | ```
136 |
137 | 在 PyMongo 早期版本中,官方已经推荐使用 的是insert 方法了。但是如果你要继续使用也没有什么问题。目前,官方推荐使用 `insert_one` 和 `insert_many` 方法来分别插入单条记录和多条记录。
138 |
139 | ```python
140 | student = {
141 | 'id': '20170101',
142 | 'name': 'Jordan',
143 | 'age': 20,
144 | 'gender': 'male'
145 | }
146 |
147 | result = collection.insert_one(student)
148 | print(result)
149 | print(result.inserted_id)
150 | ```
151 |
152 | 运行结果如下:
153 |
154 | ```python
155 |
156 | 5932ab0f15c2606f0c1cf6c5
157 | ```
158 |
159 | 与 insert 方法不同,这次返回的是 InsertOneResult 对象,我们可以调用其 `inserted_id` 属性获取_id。
160 |
161 | 对于 `insert_many` 方法,我们可以将数据以列表形式传递,示例如下:
162 |
163 | ```python
164 | student1 = {
165 | 'id': '20170101',
166 | 'name': 'Jordan',
167 | 'age': 20,
168 | 'gender': 'male'
169 | }
170 |
171 | student2 = {
172 | 'id': '20170202',
173 | 'name': 'Mike',
174 | 'age': 21,
175 | 'gender': 'male'
176 | }
177 |
178 | result = collection.insert_many([student1, student2])
179 | print(result)
180 | print(result.inserted_ids)
181 | ```
182 |
183 | 运行结果如下:
184 |
185 | ```python
186 |
187 | [ObjectId('5932abf415c2607083d3b2ac'), ObjectId('5932abf415c2607083d3b2ad')]
188 | ```
189 |
190 | 该方法返回的类型是 InsertManyResult,调用 `inserted_ids` 属性可以获取插入数据的 _id 列表。
191 |
192 | ##### 查询
193 |
194 | 插入数据后,我们可以利用 `find_one` 或 `find` 方法进行查询,其中 ==find_one 查询得到的是单个结果,find 则返回一个生成器对象==。示例如下:
195 |
196 | ```python
197 | result = collection.find_one({'name': 'Mike'})
198 | print(type(result))
199 | print(result)
200 | ```
201 |
202 | 这里我们查询 name 为 Mike 的数据,它的返回结果是字典类型,运行结果如下:
203 |
204 | ```python
205 |
206 | {'_id': ObjectId('5932a80115c2606a59e8a049'), 'id': '20170202', 'name': 'Mike', 'age': 21, 'gender': 'male'}
207 | ```
208 |
209 | 可以发现,它==多了 _id 属性,这就是 MongoDB 在插入过程中自动添加的==。
210 |
211 | 此外,我们也可以根据 ObjectId 来查询,此时需要调用 bson 库里面的 objectid:
212 |
213 | ```python
214 | from bson.objectid import ObjectId
215 |
216 | result = collection.find_one({'_id': ObjectId('593278c115c2602667ec6bae')})
217 | print(result)
218 | ```
219 |
220 | 其查询结果依然是字典类型,具体如下:
221 |
222 | ```python
223 | {'_id': ObjectId('593278c115c2602667ec6bae'), 'id': '20170101', 'name': 'Jordan', 'age': 20, 'gender': 'male'}
224 | ```
225 |
226 | 如果查询结果不存在,则会返回 None。
227 |
228 | 对于多条数据的查询,我们可以使用 `find` 方法。例如,这里查找年龄为 20 的数据,示例如下:
229 |
230 | ```python
231 | results = collection.find({'age': 20})
232 | print(results)
233 | for result in results:
234 | print(result)
235 | ```
236 |
237 | 运行结果如下:
238 |
239 | ```python
240 |
241 | {'_id': ObjectId('593278c115c2602667ec6bae'), 'id': '20170101', 'name': 'Jordan', 'age': 20, 'gender': 'male'}
242 | {'_id': ObjectId('593278c815c2602678bb2b8d'), 'id': '20170102', 'name': 'Kevin', 'age': 20, 'gender': 'male'}
243 | {'_id': ObjectId('593278d815c260269d7645a8'), 'id': '20170103', 'name': 'Harden', 'age': 20, 'gender': 'male'}
244 | ```
245 |
246 | 返回结果是 Cursor 类型,它相当于一个生成器,我们需要遍历获取的所有结果,其中每个结果都是字典类型。
247 |
248 | 如果要查询年龄大于 20 的数据,则写法如下:
249 |
250 | ```python
251 | results = collection.find({'age': {'$gt': 20}})
252 | ```
253 |
254 | 这里查询的条件键值已经不是单纯的数字了,而是一个字典,其键名为比较符号 $gt,意思是大于,键值为 20。
255 |
256 | 我将比较符号归纳为下表:
257 |
258 | 
259 |
260 | 另外,还可以进行正则匹配查询。例如,查询名字以 M 开头的学生数据,示例如下:
261 |
262 | ```python
263 | results = collection.find({'name': {'$regex': '^M.*'}})
264 | ```
265 |
266 | 这里使用 $regex 来指定正则匹配,^M.* 代表以 M 开头的正则表达式。
267 |
268 | 我将一些功能符号归类为下表:
269 |
270 | 
271 | 关于这些操作的更详细用法,可以在 MongoDB 官方文档找到: https://docs.mongodb.com/manual/reference/operator/query/。
272 |
273 | ##### 计数
274 |
275 | 要统计查询结果有多少条数据,可以调用 `count` 方法。我们以统计所有数据条数为例:
276 |
277 | ```python
278 | count = collection.find().count()
279 | print(count)
280 | ```
281 |
282 | 我们还可以统计符合某个条件的数据:
283 |
284 | ```python
285 | count = collection.find({'age': 20}).count()
286 | print(count)
287 | ```
288 |
289 | 运行结果是一个数值,即符合条件的数据条数。
290 |
291 | ##### 排序
292 |
293 | 排序时,我们可以直接调用 `sort` 方法,并在其中传入排序的字段及升降序标志。示例如下:
294 |
295 | ```python
296 | results = collection.find().sort('name', pymongo.ASCENDING)
297 | print([result['name'] for result in results])
298 | ```
299 |
300 | 运行结果如下:
301 |
302 | ```python
303 | ['Harden', 'Jordan', 'Kevin', 'Mark', 'Mike']
304 | ```
305 |
306 | 这里我们调用 pymongo.ASCENDING 指定升序。如果要降序排列,可以传入 pymongo.DESCENDING。
307 |
308 | ##### 偏移
309 |
310 | 在某些情况下,我们可能只需要取某几个元素,这时可以利用 skip 方法偏移几个位置,比如偏移 2,就代表忽略前两个元素,得到第 3 个及以后的元素:
311 |
312 | ```python
313 | results = collection.find().sort('name', pymongo.ASCENDING).skip(2)
314 | print([result['name'] for result in results])
315 | ```
316 |
317 | 运行结果如下:
318 |
319 | ```python
320 | ['Kevin', 'Mark', 'Mike']
321 | ```
322 |
323 | 另外,我们还可以用 limit 方法指定要取的结果个数,示例如下:
324 |
325 | ```python
326 | results = collection.find().sort('name', pymongo.ASCENDING).skip(2).limit(2)
327 | print([result['name'] for result in results])
328 | ```
329 |
330 | 运行结果如下:
331 |
332 | ```python
333 | ['Kevin', 'Mark']
334 | ```
335 |
336 | 如果不使用 limit 方法,原本会返回 3 个结果,加了限制后,就会截取两个结果返回。
337 |
338 | 值得注意的是,在数据量非常庞大的时候,比如在查询千万、亿级别的数据库时,最好不要使用大的偏移量,因为这样很可能导致内存溢出。此时可以使用类似如下操作来查询:
339 |
340 | ```python
341 | from bson.objectid import ObjectId
342 | collection.find({'_id': {'$gt': ObjectId('593278c815c2602678bb2b8d')}})
343 | ```
344 |
345 | 这时需要记录好上次查询的 _id。
346 |
347 | ##### 更新
348 |
349 | 对于数据更新,我们可以使用 `update` 方法,指定更新的条件和更新后的数据即可。例如:
350 |
351 | 复制代码
352 |
353 | ```python
354 | condition = {'name': 'Kevin'}
355 | student = collection.find_one(condition)
356 | student['age'] = 25
357 | result = collection.update(condition, student)
358 | print(result)
359 | ```
360 |
361 | 这里我们要更新 name 为 Kevin 的数据的年龄:首先指定查询条件,然后将数据查询出来,修改年龄后调用 update 方法将原条件和修改后的数据传入。
362 |
363 | 运行结果如下:
364 |
365 | ```python
366 | {'ok': 1, 'nModified': 1, 'n': 1, 'updatedExisting': True}
367 | ```
368 |
369 | 返回结果是字典形式,ok 代表执行成功,nModified 代表影响的数据条数。
370 |
371 | 另外,我们也可以使用 $set 操作符对数据进行更新,代码如下:
372 |
373 | ```python
374 | result = collection.update(condition, {'$set': student})
375 | ```
376 |
377 | 这样可以只更新 student 字典内存在的字段。如果原先还有其他字段,则不会更新,也不会删除。而如果不用 $set 的话,则会把之前的数据全部用 student 字典替换;如果原本存在其他字段,则会被删除。
378 |
379 | 另外,update 方法其实也是官方不推荐使用的方法。这里也分为 update_one 方法和 update_many 方法,用法更加严格,它们的第 2 个参数需要使用 $ 类型操作符作为字典的键名,示例如下:
380 |
381 | ```python
382 | condition = {'name': 'Kevin'}
383 | student = collection.find_one(condition)
384 | student['age'] = 26
385 | result = collection.update_one(condition, {'$set': student})
386 | print(result)
387 | print(result.matched_count, result.modified_count)
388 | ```
389 |
390 | 上面的例子中调用了 update_one 方法,使得第 2 个参数不能再直接传入修改后的字典,而是需要使用 {'$set': student} 这样的形式,其返回结果是 UpdateResult 类型。然后分别调用 matched_count 和 modified_count 属性,可以获得匹配的数据条数和影响的数据条数。
391 |
392 | 运行结果如下:
393 |
394 | ```python
395 |
396 | 1 0
397 | ```
398 |
399 | 我们再看一个例子:
400 |
401 | ```python
402 | condition = {'age': {'$gt': 20}}
403 | result = collection.update_one(condition, {'$inc': {'age': 1}})
404 | print(result)
405 | print(result.matched_count, result.modified_count)
406 | ```
407 |
408 | 这里指定查询条件为年龄大于 20,然后更新条件为 {'$inc': {'age': 1}},表示年龄加 1,执行之后会将第一条符合条件的数据年龄加 1。
409 |
410 | 运行结果如下:
411 |
412 | ```python
413 |
414 | 1 1
415 | ```
416 |
417 | 可以看到匹配条数为 1 条,影响条数也为 1 条。
418 |
419 | 如果调用 update_many 方法,则会将所有符合条件的数据都更新,示例如下:
420 |
421 | ```python
422 | condition = {'age': {'$gt': 20}}
423 | result = collection.update_many(condition, {'$inc': {'age': 1}})
424 | print(result)
425 | print(result.matched_count, result.modified_count)
426 | ```
427 |
428 | 这时匹配条数就不再为 1 条了,运行结果如下:
429 |
430 | ```python
431 |
432 | 3 3
433 | ```
434 |
435 | 可以看到,这时所有匹配到的数据都会被更新。
436 |
437 | ##### 删除
438 |
439 | 删除操作比较简单,直接调用 remove 方法指定删除的条件即可,此时符合条件的所有数据均会被删除。
440 |
441 | 示例如下:
442 |
443 | ```python
444 | result = collection.remove({'name': 'Kevin'})
445 | print(result)
446 | ```
447 |
448 | 运行结果如下:
449 |
450 | ```python
451 | {'ok': 1, 'n': 1}
452 | ```
453 |
454 | 另外,这里依然存在两个新的推荐方法 —— delete_one 和 delete_many,示例如下:
455 |
456 | ```python
457 | result = collection.delete_one({'name': 'Kevin'})
458 | print(result)
459 | print(result.deleted_count)
460 | result = collection.delete_many({'age': {'$lt': 25}})
461 | print(result.deleted_count)
462 | ```
463 |
464 | 运行结果如下:
465 |
466 | ```python
467 |
468 | 1
469 | 4
470 | ```
471 |
472 | delete_one 即删除第一条符合条件的数据,delete_many 即删除所有符合条件的数据。它们的返回结果都是 DeleteResult 类型,可以调用 deleted_count 属性获取删除的数据条数。
473 |
474 | ##### 其他操作
475 |
476 | 另外,PyMongo 还提供了一些组合方法,如 find_one_and_delete、find_one_and_replace 和 find_one_and_update,它们分别用于查找后删除、替换和更新操作,其使用方法与上述方法基本一致。
477 |
478 | 另外,我们还可以对索引进行操作,相关方法有 create_index、create_indexes 和 drop_index 等。
479 |
480 | 关于 PyMongo 的详细用法,可以参见官方文档:http://api.mongodb.com/python/current/api/pymongo/collection.html。
481 |
482 | 另外,还有对数据库和集合本身等的一些操作,这里不再一一讲解,可以参见官方文档:http://api.mongodb.com/python/current/api/pymongo/。
483 |
484 | 本课时的内容我们就讲到这里了,你是不是对如何使用 PyMongo 操作 MongoDB 进行数据增删改查更加熟悉了呢?下一课时我将会带你在实战案例中应用这些操作进行数据存储。
485 |
486 |
--------------------------------------------------------------------------------
/第11讲:Reqeusts + PyQuery + PyMongo 基本案例实战.md:
--------------------------------------------------------------------------------
1 | 在前面我们已经学习了多进程、requests、正则表达式、pyquery、PyMongo 等的基本用法,但我们还没有完整地实现一个爬取案例。本课时,我们就来实现一个完整的网站爬虫案例,把前面学习的知识点串联起来,同时加深对这些知识点的理解。
2 | #### 准备工作
3 | 在本节课开始之前,我们需要做好如下的准备工作:
4 | * 安装好 Python3(最低为 3.6 版本),并能成功运行 Python3 程序。
5 | * 了解 Python 多进程的基本原理。
6 | * 了解 Python HTTP 请求库 requests 的基本用法。
7 | * 了解正则表达式的用法和 Python 中正则表达式库 re 的基本用法。
8 | * 了解 Python HTML 解析库 pyquery 的基本用法。
9 | * 了解 MongoDB 并安装和启动 MongoDB 服务。
10 | * 了解 Python 的 MongoDB 操作库 PyMongo 的基本用法。
11 |
12 | 以上内容在前面的课时中均有讲解,如果你还没有准备好,那么我建议你可以再复习一下这些内容。
13 |
14 | #### 爬取目标
15 |
16 | 这节课我们以一个基本的静态网站作为案例进行爬取,需要爬取的链接为:https://static1.scrape.cuiqingcai.com/,这个网站里面包含了一些电影信息,界面如下:
17 | 
18 | 首页是一个影片列表,每栏里都包含了这部电影的封面、名称、分类、上映时间、评分等内容,同时列表页还支持翻页,点击相应的页码我们就能进入到对应的新列表页。
19 |
20 | 如果我们点开其中一部电影,会进入电影的详情页面,比如我们点开第一部《霸王别姬》,会得到如下页面:
21 | 
22 | 这里显示的内容更加丰富、包括剧情简介、导演、演员等信息。
23 |
24 | 我们这节课要完成的目标是:
25 |
26 | * 用 requests 爬取这个站点每一页的电影列表,顺着列表再爬取每个电影的详情页。
27 |
28 | * 用 pyquery 和正则表达式提取每部电影的名称、封面、类别、上映时间、评分、剧情简介等内容。
29 |
30 | * 把以上爬取的内容存入 MongoDB 数据库。
31 |
32 | * 使用多进程实现爬取的加速。
33 |
34 | 那么我们现在就开始吧。
35 |
36 | #### 爬取列表页
37 |
38 | 爬取的第一步肯定要从列表页入手,我们首先观察一下列表页的结构和翻页规则。在浏览器中访问 https://static1.scrape.cuiqingcai.com/,然后打开浏览器开发者工具,观察每一个电影信息区块对应的 HTML,以及进入到详情页的 URL 是怎样的,如图所示:
39 | 
40 | 可以看到每部电影对应的区块都是一个 div 节点,它的 class 属性都有 el-card 这个值。每个列表页有 10 个这样的 div 节点,也就对应着 10 部电影的信息。
41 |
42 | 我们再分析下从列表页是怎么进入到详情页的,我们选中电影的名称,看下结果:
43 | 
44 | 可以看到这个名称实际上是一个 h2 节点,其内部的文字就是电影的标题。h2 节点的外面包含了一个 a 节点,这个 a 节点带有 href 属性,这就是一个超链接,其中 href 的值为 /detail/1,这是一个相对网站的根 URL https://static1.scrape.cuiqingcai.com/ 路径,加上网站的根 URL 就构成了 https://static1.scrape.cuiqingcai.com/detail/1 ,也就是这部电影详情页的 URL。这样我们只需要提取这个 href 属性就能构造出详情页的 URL 并接着爬取了。
45 |
46 |
47 | 接下来我们来分析下翻页的逻辑,我们拉到页面的最下方,可以看到分页页码,如图所示:
48 | 
49 | 页面显示一共有 100 条数据,10 页的内容,因此页码最多是 10。接着我们点击第 2 页,如图所示:
50 | 可以看到网页的 URL 变成了 https://static1.scrape.cuiqingcai.com/page/2,相比根 URL 多了 /page/2 这部分内容。网页的结构还是和原来一模一样,所以我们可以和第 1 页一样处理。
51 |
52 | 接着我们查看第 3 页、第 4 页等内容,可以发现有这么一个规律,每一页的 URL 最后分别变成了 /page/3、/page/4。所以,/page 后面跟的就是列表页的页码,当然第 1 页也是一样,我们在根 URL 后面加上 /page/1 也是能访问的,只不过网站做了一下处理,默认的页码是 1,所以显示第 1 页的内容。
53 |
54 | 好,分析到这里,逻辑基本就清晰了。
55 |
56 | 如果我们要完成列表页的爬取,可以这么实现:
57 |
58 | * 遍历页码构造 10 页的索引页 URL。
59 |
60 | * 从每个索引页分析提取出每个电影的详情页 URL。
61 |
62 | 现在我们写代码来实现一下吧。
63 |
64 | 首先,我们需要先定义一些基础的变量,并引入一些必要的库,写法如下:
65 | ```python
66 | import requests
67 | import logging
68 | import re
69 | import pymongo
70 | from pyquery import PyQuery as pq
71 | from urllib.parse import urljoin
72 |
73 | logging.basicConfig(level=logging.INFO,format='%(asctime)s-%(levelname)s:%(message)s')
74 | BASIC_URL='https://static1.scrape.cuiqingcai.com/'
75 | TOTAL_PAGE=10
76 | ```
77 | 这里我们引入了 requests 用来爬取页面,**logging 用来输出信息,re 用来实现正则表达式解析,pyquery 用来直接解析网页,pymongo 用来实现 MongoDB 存储,urljoin 用来做 URL 的拼接**。
78 |
79 | 接着我们定义日志输出级别和输出格式,完成之后再定义 BASE_URL 为当前站点的根 URL,TOTAL_PAGE 为需要爬取的总页码数量。
80 |
81 | 定义好了之后,我们来实现一个页面爬取的方法吧,实现如下:
82 | ```python
83 | def scrape_page(url):
84 | logging.info('scraping %s...',url)
85 | try:
86 | response=requests.get(url)
87 | if response.status_code==200:
88 | return response.text
89 | logging.error('get invalid status code %s while scraping %s',response.status_code,url)
90 | except requests.RequestException:
91 | logging.error('error occurred while scraping %s',url,exc_info=True)
92 | ```
93 | 考虑到我们不仅要爬取列表页,还要爬取详情页,所以在这里我们定义一个较通用的爬取页面的方法,叫作 scrape_page,它接收一个 url 参数,返回页面的 html 代码。
94 |
95 | 这里我们首先判断状态码是不是 200,如果是,则直接返回页面的 HTML 代码,如果不是,则会输出错误日志信息。另外,这里实现了 requests 的异常处理,如果出现了爬取异常,则会输出对应的错误日志信息。这时我们将 logging 的 error 方法的 `exc_info` 参数设置为 True 则可以打印出 Traceback 错误堆栈信息。
96 |
97 | 好了,有了 `scrape_page` 方法之后,我们给这个方法传入一个 url,正常情况下它就可以返回页面的 HTML 代码了。
98 |
99 | 在这个基础上,我们来定义列表页的爬取方法吧,实现如下:
100 | ```python
101 | def scrape_index(page):
102 | index_url=f'{BASIC_URL}/page/{page}'
103 | return scrape_page(index_url)
104 | ```
105 | 方法名称叫作 `scrape_index`,这个方法会接收一个 page 参数,即列表页的页码,我们在方法里面实现列表页的 URL 拼接,然后调用 `scrape_page` 方法爬取即可得到列表页的 HTML 代码了。
106 |
107 | 获取了 HTML 代码后,下一步就是解析列表页,并得到每部电影的详情页的 URL 了,实现如下:
108 | ```python
109 | def parse_index(html):
110 | doc=pq(html)
111 | links=doc('.el-card .name')
112 | for link in links.items():
113 | href=link.attr('href')
114 | detail_url=urljoin(BASIC_URL,href)
115 | logging.info('get detail url%s',detail_url)
116 | yield detail_url
117 | ```
118 | 在这里我们定义了 `parse_index` 方法,它接收一个 html 参数,即列表页的 HTML 代码。接着我们用 pyquery 新建一个 PyQuery 对象,完成之后再用 `.el-card .name` 选择器选出来每个电影名称对应的超链接节点。我们遍历这些节点,通过调用 attr 方法并传入 href 获得详情页的 URL 路径,得到的 href 就是我们在上文所说的类似 /detail/1 这样的结果。由于这并不是一个完整的 URL,所以我们需要借助 `urljoin` 方法把 BASE_URL 和 href 拼接起来,获得详情页的完整 URL,得到的结果就是类似 https://static1.scrape.cuiqingcai.com/detail/1 这样完整的 URL 了,最后 yield 返回即可。
119 |
120 | 这样我们通过调用 parse_index 方法传入列表页的 HTML 代码就可以获得该列表页所有电影的详情页 URL 了。
121 |
122 | 好,接下来我们把上面的方法串联调用一下,实现如下:
123 | ```python
124 | def main():
125 | for page in range(1,TOTAL_PAGE+1):
126 | index_html=scrape_index(page)
127 | detail_urls=parse_index(index_html)
128 | logging.info('detail urls %s',list(detail_urls))
129 |
130 | if __name__ == '__main__':
131 | main()
132 | ```
133 | 这里我们定义了 main 方法来完成上面所有方法的调用,首先使用 range 方法遍历一下页码,得到的 page 是 1~10,接着把 page 变量传给 scrape_index 方法,得到列表页的 HTML,赋值为 index_html 变量。接下来再将 index_html 变量传给 parse_index 方法,得到列表页所有电影的详情页 URL,赋值为 detail_urls,结果是一个生成器,我们调用 list 方法就可以将其输出出来。
134 |
135 | 好,我们运行一下上面的代码,结果如下:
136 | ```python
137 | 2020-03-08 22:39:50,505 - INFO: scraping https://static1.scrape.cuiqingcai.com/page/1...
138 |
139 | 2020-03-08 22:39:51,949 - INFO: get detail url https://static1.scrape.cuiqingcai.com/detail/1
140 |
141 | 2020-03-08 22:39:51,950 - INFO: get detail url https://static1.scrape.cuiqingcai.com/detail/2
142 |
143 | 2020-03-08 22:39:51,950 - INFO: get detail url https://static1.scrape.cuiqingcai.com/detail/3
144 |
145 | 2020-03-08 22:39:51,950 - INFO: get detail url https://static1.scrape.cuiqingcai.com/detail/4
146 |
147 | 2020-03-08 22:39:51,950 - INFO: get detail url https://static1.scrape.cuiqingcai.com/detail/5
148 |
149 | 2020-03-08 22:39:51,950 - INFO: get detail url https://static1.scrape.cuiqingcai.com/detail/6
150 |
151 | 2020-03-08 22:39:51,950 - INFO: get detail url https://static1.scrape.cuiqingcai.com/detail/7
152 |
153 | 2020-03-08 22:39:51,950 - INFO: get detail url https://static1.scrape.cuiqingcai.com/detail/8
154 |
155 | 2020-03-08 22:39:51,950 - INFO: get detail url https://static1.scrape.cuiqingcai.com/detail/9
156 |
157 | 2020-03-08 22:39:51,950 - INFO: get detail url https://static1.scrape.cuiqingcai.com/detail/10
158 |
159 | 2020-03-08 22:39:51,951 - INFO: detail urls ['https://static1.scrape.cuiqingcai.com/detail/1', 'https://static1.scrape.cuiqingcai.com/detail/2', 'https://static1.scrape.cuiqingcai.com/detail/3', 'https://static1.scrape.cuiqingcai.com/detail/4', 'https://static1.scrape.cuiqingcai.com/detail/5', 'https://static1.scrape.cuiqingcai.com/detail/6', 'https://static1.scrape.cuiqingcai.com/detail/7', 'https://static1.scrape.cuiqingcai.com/detail/8', 'https://static1.scrape.cuiqingcai.com/detail/9', 'https://static1.scrape.cuiqingcai.com/detail/10']
160 |
161 | 2020-03-08 22:39:51,951 - INFO: scraping https://static1.scrape.cuiqingcai.com/page/2...
162 |
163 | 2020-03-08 22:39:52,842 - INFO: get detail url https://static1.scrape.cuiqingcai.com/detail/11
164 |
165 | 2020-03-08 22:39:52,842 - INFO: get detail url https://static1.scrape.cuiqingcai.com/detail/12
166 | ...
167 | ```
168 | 由于输出内容比较多,这里只贴了一部分。
169 |
170 | 可以看到,在这个过程中程序首先爬取了第 1 页列表页,然后得到了对应详情页的每个 URL,接着再接着爬第 2 页、第 3 页,一直到第 10 页,依次输出了每一页的详情页 URL。这样,我们就成功获取到所有电影详情页 URL 啦。
171 |
172 | #### 爬取详情页
173 |
174 | 现在我们已经成功获取所有详情页 URL 了,那么下一步当然就是解析详情页并提取出我们想要的信息了。
175 |
176 | 我们首先观察一下详情页的 HTML 代码吧,如图所示:
177 | 
178 |
179 | 经过分析,我们想要提取的内容和对应的节点信息如下:
180 | * 封面:是一个 img 节点,其 class 属性为 cover。
181 | * 名称:是一个 h2 节点,其内容便是名称。
182 | * 类别:是 span 节点,其内容便是类别内容,其外侧是 button 节点,再外侧则是 class 为 categories 的 div 节点。
183 | * 上映时间:是 span 节点,其内容包含了上映时间,其外侧是包含了 class 为 info 的 div 节点。但注意这个 div 前面还有一个 class 为 info 的 div 节点,我们可以使用其内容来区分,也可以使用 nth-child 或 nth-of-type 这样的选择器来区分。另外提取结果中还多了「上映」二字,我们可以用正则表达式把日期提取出来。
184 | - 评分:是一个 p 节点,其内容便是评分,p 节点的 class 属性为 score。
185 | * 剧情简介:是一个 p 节点,其内容便是剧情简介,其外侧是 class 为 drama 的 div 节点。
186 |
187 | 看上去有点复杂,但是不用担心,有了 **pyquery 和正则表达式**,我们可以轻松搞定。
188 |
189 | 接着我们来实现一下代码吧。
190 |
191 | 刚才我们已经成功获取了详情页的 URL,接下来我们要定义一个详情页的爬取方法,实现如下:
192 | ```python
193 | def scapre_detail(url):
194 | return scrape_page(url)
195 | ```
196 | 这里定义了一个 scrape_detail 方法,它接收一个 url 参数,并通过调用 scrape_page 方法获得网页源代码。由于我们刚才已经实现了 scrape_page 方法,所以在这里我们不用再写一遍页面爬取的逻辑了,直接调用即可,这就做到了代码复用。
197 |
198 | 另外你可能会问,这个 scrape_detail 方法里面只调用了 scrape_page 方法,没有别的功能,那爬取详情页直接用 scrape_page 方法不就好了,还有必要再单独定义 scrape_detail 方法吗?
199 |
200 | 答案是有必要,单独定义一个 scrape_detail 方法在逻辑上会显得更清晰,而且以后如果我们想要对 scrape_detail 方法进行改动,比如添加日志输出或是增加预处理,都可以在 scrape_detail 里面实现,而不用改动 scrape_page 方法,灵活性会更好。
201 |
202 | 好了,详情页的爬取方法已经实现了,接着就是详情页的解析了,实现如下:
203 | ```python
204 | def parse_detail(html):
205 | doc=pq(html)
206 | cover=doc('img.cover').attr('src')
207 | name=doc('a > h2').text()
208 | categories=[item.text() for item in doc('.categories button span').items()]
209 | published_at=doc('.info:contains(上映)').text()
210 | 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
211 | drama=doc('.drama p').text()
212 | score=doc('p.score').text()
213 | score=float(score) if score else None
214 | return{
215 | 'cover':cover,
216 | 'name':name,
217 | 'published_at':published_at,
218 | 'drama':drama,
219 | 'score':score
220 | }
221 | ```
222 | 这里我们定义了 parse_detail 方法用于解析详情页,它接收一个 html 参数,解析其中的内容,并以字典的形式返回结果。每个字段的解析情况如下所述:
223 |
224 | cover:封面,直接选取 class 为 cover 的 img 节点,并调用 attr 方法获取 src 属性的内容即可。
225 |
226 | name:名称,直接选取 a 节点的直接子节点 h2 节点,并调用 text 方法提取其文本内容即可得到名称。
227 |
228 | categories:类别,由于类别是多个,所以这里首先用 .categories button span 选取了 class 为 categories 的节点内部的 span 节点,其结果是多个,所以这里进行了遍历,取出了每个 span 节点的文本内容,得到的便是列表形式的类别。
229 |
230 | published_at:上映时间,由于 pyquery 支持使用 :contains 直接指定包含的文本内容并进行提取,且每个上映时间信息都包含了「上映」二字,所以我们这里就直接使用 :contains(上映) 提取了 class 为 info 的 div 节点。提取之后,得到的结果类似「1993-07-26 上映」这样,但我们并不想要「上映」这两个字,所以我们又调用了正则表达式把日期单独提取出来了。当然这里也可以直接使用 strip 或 replace 方法把多余的文字去掉,但我们为了练习正则表达式的用法,使用了正则表达式来提取。
231 |
232 | drama:直接提取 class 为 drama 的节点内部的 p 节点的文本即可。
233 |
234 | score:直接提取 class 为 score 的 p 节点的文本即可,但由于提取结果是字符串,所以我们需要把它转成浮点数,即 float 类型。
235 |
236 | 上述字段提取完毕之后,构造一个字典返回即可。
237 |
238 | 这样,我们就成功完成了详情页的提取和分析了。
239 |
240 | 最后,我们将 main 方法稍微改写一下,增加这两个方法的调用,改写如下:
241 | ```python
242 | def main():
243 | TOTAL_PAGE = 10
244 | for page in range(1,TOTAL_PAGE+1):
245 | index_html=scrape_index(page)
246 | detail_urls=parse_index(index_html)
247 | for detail_url in detail_urls:
248 | detail_url=scrape_detail(detail_url)
249 | data=parse_detail(detail_url)
250 | logging.info('get detail data %s',data)
251 | ```
252 | 这里我们首先遍历了 detail_urls,获取了每个详情页的 URL,然后依次调用了 scrape_detail 和 parse_detail 方法,最后得到了每个详情页的提取结果,赋值为 data 并输出。
253 |
254 | 运行结果如下:
255 | ```python
256 | 2020-03-08 23:37:35,936 - INFO: scraping https://static1.scrape.cuiqingcai.com/page/1...
257 |
258 | 2020-03-08 23:37:36,833 - INFO: get detail url https://static1.scrape.cuiqingcai.com/detail/1
259 |
260 | 2020-03-08 23:37:36,833 - INFO: scraping https://static1.scrape.cuiqingcai.com/detail/1...
261 |
262 | 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}
263 |
264 | 2020-03-08 23:37:39,985 - INFO: get detail url https://static1.scrape.cuiqingcai.com/detail/2
265 |
266 | 2020-03-08 23:37:39,985 - INFO: scraping https://static1.scrape.cuiqingcai.com/detail/2...
267 |
268 | 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}
269 |
270 | 2020-03-08 23:37:41,062 - INFO: get detail url https://static1.scrape.cuiqingcai.com/detail/3
271 | ...
272 | ```
273 | 由于内容较多,这里省略了后续内容。
274 |
275 | 可以看到,我们已经成功提取出每部电影的基本信息,包括封面、名称、类别,等等。
276 |
277 | #### 保存到 MongoDB
278 |
279 | 成功提取到详情页信息之后,下一步我们就要把数据保存起来了。在上一课时我们学习了 MongoDB 的相关操作,接下来我们就把数据保存到 MongoDB 吧。
280 |
281 | 在这之前,请确保现在有一个可以正常连接和使用的 MongoDB 数据库。
282 |
283 | 将数据导入 MongoDB 需要用到 PyMongo 这个库,这个在最开始已经引入过了。那么接下来我们定义一下 MongoDB 的连接配置,实现如下:
284 | ```python
285 | MONGO_CONNECTION_STRING='mongodb;//localhost:27107'
286 | MONGO_DB_NAME='movies'
287 |
288 | client=pymongo.MongoClient(MONGO_CONNECTION_STRING)
289 | db=client['movies']
290 | collection=db['movies']
291 | ```
292 | 在这里我们声明了几个变量,介绍如下:
293 |
294 | * `MONGO_CONNECTION_STRING`:MongoDB 的连接字符串,里面定义了 MongoDB 的基本连接信息,如 host、port,还可以定义用户名密码等内容。
295 |
296 | * `MONGO_DB_NAME`:MongoDB 数据库的名称。
297 |
298 | * `MONGO_COLLECTION_NAME`:MongoDB 的集合名称。
299 |
300 | 这里我们用 MongoClient 声明了一个连接对象,然后依次声明了存储的数据库和集合。
301 |
302 | 接下来,我们再实现一个将数据保存到 MongoDB 的方法,实现如下:
303 | ```python
304 | def save_data(data):
305 | collection.update_one({
306 | 'name':data.get('name')
307 | },{
308 | '$set':data
309 | },upsert=True)
310 | ```
311 | 在这里我们声明了一个 `save_data` 方法,它接收一个 data 参数,也就是我们刚才提取的电影详情信息。在方法里面,我们调用了 update_one 方法,第 1 个参数是查询条件,即根据 name 进行查询;第 2 个参数是 data 对象本身,也就是所有的数据,这里我们用 $set 操作符表示更新操作;第 3 个参数很关键,这里实际上是 upsert 参数,如果把这个设置为 True,则可以做到存在即更新,不存在即插入的功能,更新会根据第一个参数设置的 name 字段,所以这样可以防止数据库中出现同名的电影数据。
312 |
313 | 注:实际上电影可能有同名,但该场景下的爬取数据没有同名情况,当然这里更重要的是实现 MongoDB 的去重操作。
314 |
315 | 好的,那么接下来我们将 main 方法稍微改写一下就好了,改写如下:
316 | ```python
317 | def main():
318 | TOTAL_PAGE = 10
319 | for page in range(1,TOTAL_PAGE+1):
320 | index_html=scrape_index(page)
321 | detail_urls=parse_index(index_html)
322 | for detail_url in detail_urls:
323 | detail_url=scrape_detail(detail_url)
324 | data=parse_detail(detail_url)
325 | logging.info('get detail data %s',data)
326 | logging.info('saving data to mongodb')
327 | save_data(data)
328 | logging.info('data saved successfully')
329 | ```
330 | 这里增加了 `save_data` 方法的调用,并加了一些日志信息。
331 |
332 | 重新运行,我们看下输出结果:
333 |
334 |
335 |
336 | ```python
337 | 2020-03-09 01:10:27,094 - INFO: scraping https://static1.scrape.cuiqingcai.com/page/1...
338 |
339 | 2020-03-09 01:10:28,019 - INFO: get detail url https://static1.scrape.cuiqingcai.com/detail/1
340 |
341 | 2020-03-09 01:10:28,019 - INFO: scraping https://static1.scrape.cuiqingcai.com/detail/1...
342 |
343 | 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}
344 |
345 | 2020-03-09 01:10:29,183 - INFO: saving data to mongodb
346 |
347 | 2020-03-09 01:10:29,288 - INFO: data saved successfully
348 |
349 | 2020-03-09 01:10:29,288 - INFO: get detail url https://static1.scrape.cuiqingcai.com/detail/2
350 |
351 | 2020-03-09 01:10:29,288 - INFO: scraping https://static1.scrape.cuiqingcai.com/detail/2...
352 |
353 | 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}
354 |
355 | 2020-03-09 01:10:30,250 - INFO: saving data to mongodb
356 |
357 | 2020-03-09 01:10:30,253 - INFO: data saved successfully
358 | ...
359 | ```
360 | 在运行结果中我们可以发现,这里输出了存储 MongoDB 成功的信息。
361 |
362 | 运行完毕之后我们可以使用 MongoDB 客户端工具(例如 Robo 3T )可视化地查看已经爬取到的数据,结果如下:
363 | 
364 | 这样,所有的电影就被我们成功爬取下来啦!不多不少,正好 100 条。
365 |
366 | #### 多进程加速
367 | 由于整个的爬取是单进程的,而且只能逐条爬取,速度稍微有点慢,有没有方法来对整个爬取过程进行加速呢?
368 |
369 | 在前面我们讲了多进程的基本原理和使用方法,下面我们就来实践一下多进程的爬取吧。
370 |
371 | 由于一共有 10 页详情页,并且**这 10 页内容是互不干扰的**,所以我们可以一页开一个进程来爬取。由于这 10 个列表页页码正好可以提前构造成一个列表,所以我们可以选用多进程里面的进程池 `Pool` 来实现这个过程。
372 |
373 | 这里我们需要改写下 main 方法的调用,实现如下:
374 | ```python
375 | import multiprocessing
376 |
377 | def main(page):
378 | index_html=scrape_index(page)
379 | detail_urls=parse_index(index_html)
380 | for detail_url in detail_urls:
381 | detail_url=scrape_detail(detail_url)
382 | data=parse_detail(detail_url)
383 | logging.info('get detail data %s',data)
384 | logging.info('saving data to mongodb')
385 | save_data(data)
386 | logging.info('data saved successfully')
387 |
388 | if __name__ == '__main__':
389 | TOTAL_PAGE = 10
390 | pool=multiprocessing.Pool()
391 | pages=range(1,TOTAL_PAGE+1)
392 | pool.map(main,pages)
393 | pool.close()
394 | pool.join()
395 | ```
396 | 这里我们首先给 main 方法添加一个参数 page,用以表示列表页的页码。接着我们声明了一个进程池,并声明 pages 为所有需要遍历的页码,即 1~10。最后调用 map 方法,第 1 个参数就是需要被调用的方法,第 2 个参数就是 pages,即需要遍历的页码。
397 |
398 | 这样 pages 就会被依次遍历。把 1~10 这 10 个页码分别传递给 main 方法,并把每次的调用变成一个进程,加入到进程池中执行,进程池会根据当前运行环境来决定运行多少进程。比如我的机器的 CPU 有 8 个核,那么进程池的大小会默认设定为 8,这样就会同时有 8 个进程并行执行。
399 |
400 | 运行输出结果和之前类似,但是可以明显看到加了多进程执行之后,爬取速度快了非常多。我们可以清空一下之前的 MongoDB 数据,可以发现数据依然可以被正常保存到 MongoDB 数据库中。
401 |
402 | #### 总结
403 |
404 | 到现在为止,我们就完成了全站电影数据的爬取并实现了存储和优化。
405 |
406 | 这节课我们用到的库有 requests、pyquery、PyMongo、multiprocessing、re、logging 等,通过这个案例实战,我们把前面学习到的知识都串联了起来,其中的一些实现方法可以好好思考和体会,也希望这个案例能够让你对爬虫的实现有更实际的了解。
407 |
408 | 本节代码:
409 | > https://github.com/Python3WebSpider/ScrapeStatic1。
410 |
411 |
412 |
--------------------------------------------------------------------------------
/第12讲:Ajax 的原理和解析.md:
--------------------------------------------------------------------------------
1 | 当我们在用 requests 抓取页面的时候,得到的结果可能会和在浏览器中看到的不一样:在浏览器中正常显示的页面数据,使用 requests 却没有得到结果。这是因为 **requests 获取的都是原始 HTML 文档,而浏览器中的页面则是经过 JavaScript 数据处理后生成的结果**。这些数据的来源有多种,可能是通过 Ajax 加载的,可能是包含在 HTML 文档中的,也可能是经过 JavaScript 和特定算法计算后生成的。
2 |
3 | 对于第 1 种情况,数据加载是一种**异步加载**方式,原始页面不会包含某些数据,**只有在加载完后,才会向服务器请求某个接口获取数据**,然后数据才被处理从而呈现到网页上,这个过程实际上就是向服务器接口发送了一个 Ajax 请求。
4 |
5 | 按照 Web 的发展趋势来看,这种形式的页面将会越来越多。网页的原始 HTML 文档不会包含任何数据,数据都是通过 Ajax 统一加载后再呈现出来的,这样在 Web 开发上可以做到**前后端分离**,并且降低服务器直接渲染页面带来的压力。
6 |
7 | 所以如果你遇到这样的页面,直接利用 requests 等库来抓取原始页面,是无法获取有效数据的。这时我们需要分析网页后台向接口发送的 Ajax 请求,如果可以**用 requests 来模拟 Ajax 请求**,就可以成功抓取了。
8 |
9 | 所以,本课时我们就来了解什么是 Ajax 以及如何去分析和抓取 Ajax 请求。
10 |
11 | #### 什么是 Ajax
12 | Ajax,全称为 Asynchronous JavaScript and XML,即异步的 JavaScript 和 XML。它不是一门编程语言,而是利用 JavaScript 在保证页面不被刷新、页面链接不改变的情况下与服务器交换数据并更新部分网页的技术。
13 |
14 | 传统的网页,如果你想更新其内容,那么必须要刷新整个页面。**有了 Ajax,便可以在页面不被全部刷新的情况下更新其内容**。在这个过程中,页面实际上在后台与服务器进行了数据交互,获取到数据之后,再利用 JavaScript 改变网页,这样网页内容就会更新了。
15 |
16 | 你可以到 W3School 上体验几个 Demo 来感受一下:
17 | > http://www.w3school.com.cn/ajax/ajax_xmlhttprequest_send.asp
18 |
19 | #### 实例引入
20 |
21 | 浏览网页的时候,我们会发现很多网页都有下滑查看更多的选项。以我微博的主页为例:https://m.weibo.cn/u/2830678474。当我们切换到微博页面,发现下滑几个微博后,后面的内容不会直接显示,而是会出现一个加载动画,加载完成后下方才会继续出现新的微博内容,这个过程其实就是 Ajax 加载的过程,如图所示:
22 | 
23 | 我们注意到页面其实并没有整个刷新,这意味着页面的链接没有变化,但是网页中却多了新内容,也就是后面刷出来的新微博。这就是**通过 Ajax 获取新数据并呈现**的过程。
24 |
25 | #### 基本原理
26 | 初步了解了 Ajax 之后,我们再来详细了解它的基本原理。发送 Ajax 请求到网页更新的过程可以简单分为以下 3 步:
27 |
28 | * 发送请求
29 |
30 | * 解析内容
31 |
32 | * 渲染网页
33 |
34 | 下面我们分别详细介绍一下这几个过程。
35 |
36 | #### 发送请求
37 | 我们知道 JavaScript 可以实现页面的各种交互功能,Ajax 也不例外,它是由 JavaScript 实现的,实际上执行了如下代码:
38 | 
39 | 这是 JavaScript 对 Ajax 最底层的实现,这个过程实际上是新建了 XMLHttpRequest 对象,然后调用 onreadystatechange 属性设置监听,最后调用 open() 和 send() 方法向某个链接(也就是服务器)发送请求。
40 |
41 |
42 | 前面我们用 Python 实现请求发送之后,可以得到响应结果,但这里请求的发送由 JavaScript 来完成。由于设置了监听,所以当服务器返回响应时,onreadystatechange 对应的方法便会被触发,我们在这个方法里面解析响应内容即可。
43 |
44 | #### 解析内容
45 |
46 | 得到响应之后,onreadystatechange 属性对应的方法会被触发,此时利用 xmlhttp 的 responseText 属性便可取到响应内容。这类似于 Python 中利用 requests 向服务器发起请求,然后得到响应的过程。
47 |
48 |
49 | 返回的内容可能是 HTML,也可能是 JSON,接下来我们只需要在方法中用 JavaScript 进一步处理即可。比如,如果返回的内容是 JSON 的话,我们便可以对它进行解析和转化。
50 |
51 | #### 渲染网页
52 | JavaScript 有改变网页内容的能力,解析完响应内容之后,就可以调用 JavaScript 针对解析完的内容对网页进行下一步处理。比如,通过 document.getElementById().innerHTML 这样的操作,对某个元素内的源代码进行更改,这样网页显示的内容就改变了,这种对 Document 网页文档进行如更改、删除等操作也被称作 DOM 操作。
53 |
54 |
55 |
56 | 上例中,document.getElementById("myDiv").innerHTML=xmlhttp.responseText 这个操作便将 ID 为 myDiv 的节点内部的 HTML 代码更改为服务器返回的内容,这样 myDiv 元素内部便会呈现出服务器返回的新数据,网页的部分内容看上去就更新了。
57 |
58 | 可以看到,发送请求、解析内容和渲染网页这 3 个步骤其实都是由 JavaScript 完成的。
59 |
60 | 我们再回想微博的下拉刷新,这其实是 **JavaScript 向服务器发送了一个 Ajax 请求,然后获取新的微博数据,将其解析,并将其渲染在网页中的过程**。
61 |
62 |
63 | 因此,真实的数据其实都是通过一次次 Ajax 请求得到的,如果想要抓取这些数据,我们需要知道这些请求到底是怎么发送的,发往哪里,发了哪些参数。如果我们知道了这些,不就可以用 Python 模拟这个发送操作,获取到其中的结果了吗?
64 |
65 | #### Ajax 分析
66 |
67 | 这里还是以前面的微博为例,我们知道拖动刷新的内容由 Ajax 加载,而且页面的 URL 没有变化,这时我们应该到哪里去查看这些 Ajax 请求呢?
68 |
69 | 这里还需要借助浏览器的开发者工具,下面以 Chrome 浏览器为例来介绍。
70 |
71 | 首先,用 Chrome 浏览器打开微博链接 https://m.weibo.cn/u/2830678474,随后在页面中点击鼠标右键,从弹出的快捷菜单中选择“检查” 选项,此时便会弹出开发者工具,如图所示:
72 | 
73 |
74 | 前面也提到过,这里就是页面加载过程中浏览器与服务器之间发送请求和接收响应的所有记录。
75 |
76 |
77 | **Ajax 有其特殊的请求类型,它叫作 xhr**。在图中我们可以发现一个以 getIndex 开头的请求,其 Type 为 xhr,这就是一个 Ajax 请求。用鼠标点击这个请求,可以查看这个请求的详细信息。
78 | 
79 | 在右侧可以观察到 Request Headers、URL 和 Response Headers 等信息。Request Headers 中有一个信息为 X-Requested-With:XMLHttpRequest,这就标记了此请求是 Ajax 请求,如图所示:
80 | 
81 | 随后我们点击 **Preview**,即可看到响应的内容,它是 JSON 格式的。这里 Chrome 为我们自动做了解析,点击箭头即可展开和收起相应内容。
82 |
83 | 我们可以观察到,返回结果是我的个人信息,包括昵称、简介、头像等,这也是用来渲染个人主页所使用的数据。JavaScript 接收到这些数据之后,再执行相应的渲染方法,整个页面就渲染出来了。
84 | 
85 | 另外,我们也可以切换到 Response 选项卡,从中观察到真实的返回数据,如图所示:
86 | 
87 | 接下来,切回到第一个请求,观察一下它的 Response 是什么,如图所示:
88 | 这就是最原始链接 https://m.weibo.cn/u/2830678474 返回的结果,其代码只有不到 50 行,结构也非常简单,只是执行了一些 JavaScript。
89 |
90 | 所以说,我们看到的微博页面的真实数据并不是最原始的页面返回的,而是在执行 JavaScript 后再次向后台发送 Ajax 请求,浏览器拿到数据后进一步渲染出来的。
91 |
92 | #### 过滤请求
93 |
94 | 接下来,我们再利用 Chrome 开发者工具的筛选功能筛选出所有的 Ajax 请求。在请求的上方有一层筛选栏,直接点击 XHR,此时在下方显示的所有请求便都是 Ajax 请求了,如图所示:
95 | 
96 | 接下来,不断滑动页面,可以看到页面底部有一条条新的微博被刷出,而开发者工具下方也不断地出现 Ajax 请求,这样我们就可以捕获到所有的 Ajax 请求了。
97 |
98 | 随意点开一个条目,都可以清楚地看到其 Request URL、Request Headers、Response Headers、Response Body 等内容,此时想要模拟请求和提取就非常简单了。
99 |
100 | 下图所示的内容便是我某一页微博的列表信息:
101 | 
102 | 到现在为止,我们已经可以分析出 Ajax 请求的一些详细信息了,接下来只需要用程序模拟这些 Ajax 请求,就可以轻松提取我们所需要的信息了。
103 |
--------------------------------------------------------------------------------
/第13讲:Ajax 爬取案例实战.md:
--------------------------------------------------------------------------------
1 | 上一课时我们学习了 Ajax 的基本原理和分析方法,这一课时我们结合实际案例,学习 Ajax 分析和爬取页面的具体实现。
2 |
3 | #### 准备工作
4 |
5 | 在开始学习之前,我们需要做好如下的准备工作:
6 |
7 | * 安装好 Python 3(最低为 3.6 版本),并能成功运行 Python 3 程序。
8 |
9 | * 了解 Python HTTP 请求库 requests 的基本用法。
10 |
11 | * 了解 Ajax 的基础知识和分析 Ajax 的基本方法。
12 |
13 | 以上内容在前面的课时中均有讲解,如你尚未准备好建议先熟悉一下这些内容。
14 |
15 | #### 爬取目标
16 |
17 | 本课时我们以一个动态渲染网站为例来试验一下 Ajax 的爬取。其链接为:https://dynamic1.scrape.cuiqingcai.com/,页面如图所示。
18 | 
19 | 这个页面看似和我们上一课时的案例一模一样,但其实不是,它的后台实现逻辑和数据加载方式与上一课时完全不同,只不过最后呈现的样式是一样的。
20 |
21 | 这个网站同样支持翻页,可以点击最下方的页码来切换到下一页,如图所示。
22 | 
23 | 点击每一个电影的链接进入详情页,页面结构也是完全一样的,如图所示。
24 | 
25 | 我们需要爬取的数据也和原来是相同的,包括**电影的名称、封面、类别、上映日期、评分、剧情简介**等信息。
26 |
27 | 本课时我们需要完成的目标有:
28 |
29 | * 分析页面数据的加载逻辑。
30 |
31 | * 用 requests 实现 Ajax 数据的爬取。
32 |
33 | * 将每部电影的数据保存成一个 JSON 数据文件。
34 |
35 | 由于本课时主要讲解 Ajax,所以对于数据存储和加速部分就不再展开实现,主要是讲解 Ajax 的分析和爬取。
36 |
37 | 那么我们现在就开始正式学习吧。
38 |
39 | #### 初步探索
40 | 首先,我们尝试用之前的 requests 来直接提取页面,看看会得到怎样的结果。用最简单的代码实现一下 requests 获取首页源码的过程,代码如下:
41 |
42 | ```python
43 | import requests
44 |
45 | url='https://dynamic1.scrape.cuiqingcai.com/'
46 | html=requests.get(url).text
47 | print(html)
48 | ```
49 | 运行结果如下:
50 | 
51 | 可以看到我们只爬取到了这么一点 HTML 内容,而在浏览器中打开这个页面却能看到这样的结果,如图所示。
52 | 
53 | 也就是说在 HTML 中我们只能在**源码中看到引用了一些 JavaScript 和 CSS 文件**,并没有观察任何有关电影数据的信息。
54 |
55 |
56 | 如果遇到这样的情况,说明我们现在看到的整个页面是通过 JavaScript 渲染得到的,浏览器执行了 HTML 中所引用的 JavaScript 文件,JavaScript 通过调用一些数据加载和页面渲染的方法,才最终呈现了图中所示的页面。
57 |
58 |
59 |
60 | 在一般情况下,这些数据都是通过 Ajax 来加载的, JavaScript 在后台调用这些 Ajax 数据接口,得到数据之后,再把数据进行解析并渲染呈现出来,得到最终的页面。所以说,要想爬取这个页面,我们可以通过直接爬取 Ajax 接口获取数据。
61 |
62 |
63 |
64 | 在上一课时中,我们已经了解了用 Ajax 分析的基本方法。下面我们就来分析下 Ajax 接口的逻辑并实现数据爬取吧。
65 |
66 | #### 爬取列表页
67 |
68 | 首先我们来分析下列表页的 Ajax 接口逻辑,打开浏览器开发者工具,切换到 Network 面板,勾选上 「Preserve Log」并切换到 「XHR」选项卡,如图所示。
69 | 
70 |
71 |
72 |
73 |
74 |
75 |
76 | 接着,我们重新刷新页面,然后点击第 2 页、第 3 页、第 4 页的按钮,这时候可以看到页面上的数据发生了变化,同时在开发者工具下方会监听到几个 Ajax 请求,如图所示。
77 | 
78 | 由于我们切换了 4 页,所以这里正好也出现了 4 个 Ajax 请求,我们可以任选一个点击查看其请求详情,观察其请求的 URL、参数以及响应内容是怎样的,如图所示。
79 | 
80 | 这里我们点开第 2 个结果,观察到其 Ajax 接口请求的 URL 地址为:https://dynamic1.scrape.cuiqingcai.com/api/movie/?limit=10&offset=10,这里有两个参数,一个是 `limit`,其值为 10,一个是 `offset`,它的值也是 10。
81 |
82 |
83 | 通过观察多个 Ajax 接口的参数,我们可以发现这么一个规律:limit 的值一直为 10,这就正好对应着每页 10 条数据;offset 的值在依次变大,页面每加 1 页,offset 就加 10,这就代表着页面的数据偏移量,比如第 2 页的 offset 值为 10 代表跳过 10 条数据,返回从第 11 条数据开始的结果,再加上 limit 的限制,就代表返回第 11~20 条数据的结果。
84 |
85 |
86 |
87 | 接着我们再观察下响应的数据,切换到 Preview 选项卡,结果如图所示。
88 | 
89 | 可以看到结果是一些 JSON 数据,它有一个 results 字段,这是一个列表,列表的每一个元素都是一个字典。观察一下字典的内容,发现我们可以看到对应的电影数据的字段了,如 name、alias、cover、categories,对比下浏览器中的真实数据,各个内容是完全一致的,而且这个数据已经非常结构化了,完全就是我们想要爬取的数据,真是得来全不费工夫。
90 |
91 |
92 |
93 | 这样的话,我们只需要**把所有页面的 Ajax 接口构造出来**,那么所有的列表页数据我们都可以轻松获取到了。
94 |
95 |
96 | 我们先定义一些准备工作,导入一些所需的库并定义一些配置,代码如下:
97 | ```python
98 | import requests
99 | import logging
100 |
101 | logging.basicConfig(level=logging.INFO,format='%(asctime)s - %(levelname)s: %(message)s')
102 | INDEX_URL='https://dynamic1.scrape.cuiqingcai.com/api/movie/?limit={limit}&offset={offset}'
103 | ```
104 | 这里我们引入了 requests 和 logging 库,并定义了 logging 的基本配置,接着我们定义 INDEX_URL,这里把 limit 和 offset 预留出来变成占位符,可以动态传入参数构造成一个完整的列表页 URL。
105 |
106 | 下面我们来实现一下列表页的爬取,还是和原来一样,我们先定义一个通用的爬取方法,代码如下:
107 | ```python
108 | def scrapy_api(url):
109 | logging.info('scraping %s...',url)
110 | try:
111 | response=requests.get(url)
112 | if response.status_code==200:
113 | return response.join()
114 | logging.error('get invalid status code %s while scraping %s',response.status_code,url)
115 |
116 | except requests.RequestException:
117 | logging.error('error occurred while scraping %s',url,exc_info=True)
118 | ```
119 | 这里我们定义一个 scrape_api 方法,和之前不同的是,这个方法专门用来处理 JSON 接口,最后的 response 调用的是 `json` 方法,它可以解析响应的内容并将其转化成 JSON 字符串。
120 |
121 | 在这个基础之上,我们定义一个爬取列表页的方法,代码如下:
122 | ```python
123 | def scrapy_index(page):
124 | LIMIT = 10
125 | INDEX_URL = 'https://dynamic1.scrape.cuiqingcai.com/api/movie/?limit={limit}&offset={offset}'
126 | url=INDEX_URL.format(limit=LIMIT,offset=LIMIT*(page-1))
127 | return scrapy_api(url)
128 | ```
129 | 这里我们定义了一个 `scrape_index` 方法,用来接收参数 page,page 代表列表页的页码。
130 |
131 |
132 |
133 | 这里我们先构造了一个 URL,通过字符串的 format 方法,传入 limit 和 offset 的值。这里的 limit 直接使用了全局变量 LIMIT 的值,offset 则是动态计算的,计算方法是页码数减 1 再乘以 limit,比如第 1 页的 offset 值就是 0,第 2 页的 offset 值就是 10,以此类推。构造好 URL 之后,直接调用 scrape_api 方法并返回结果即可。
134 |
135 |
136 | 这样我们就完成了列表页的爬取,每次请求都会得到一页 10 部的电影数据。
137 |
138 |
139 | 由于这时爬取到的数据已经是 JSON 类型了,所以我们不用像之前一样去解析 HTML 代码来提取数据,爬到的数据就是我们想要的结构化数据,因此解析这一步这里我们就可以直接省略啦。
140 |
141 | 到此为止,我们就能成功爬取列表页并提取出电影列表信息了。
142 |
143 | #### 爬取详情页
144 |
145 | 这时候我们已经可以拿到每一页的电影数据了,但是实际上这些数据还缺少一些我们想要的信息,如剧情简介等,所以我们需要进一步进入到详情页来获取这些内容。
146 |
147 |
148 | 这时候我们点击任意一部电影,如《教父》,进入到其详情页面,这时候可以发现页面的 URL 已经变成了 https://dynamic1.scrape.cuiqingcai.com/detail/40,页面也成功展示了详情页的信息,如图所示。
149 | 另外我们也可以观察到在开发者工具中又出现了一个 Ajax 请求,其 URL 为 https://dynamic1.scrape.cuiqingcai.com/api/movie/40/,通过 Preview 选项卡也能看到 Ajax 请求对应响应的信息,如图所示。
150 | 
151 | 稍加观察我们就可以发现,Ajax 请求的 URL 后面有一个参数是可变的,这个参数就是电影的 id,这里是 40,对应《教父》这部电影。
152 |
153 |
154 | 如果我们想要获取 id 为 50 的电影,只需要把 URL 最后的参数改成 50 即可,即 **https://dynamic1.scrape.cuiqingcai.com/api/movie/50/**,请求这个新的 URL 我们就能获取 id 为 50 的电影所对应的数据了。
155 |
156 |
157 |
158 | 同样的,它响应的结果也是结构化的 JSON 数据,字段也非常规整,我们直接爬取即可。
159 |
160 |
161 |
162 | 分析了详情页的数据提取逻辑,那么怎么把它和列表页关联起来呢?这个 id 又是从哪里来呢?我们回过头来再看看列表页的接口返回数据,如图所示。
163 | 
164 | 可以看到列表页原本的返回数据就带了 id 这个字段,所以我们只需要拿列表页结果中的 id 来构造详情页中 Ajax 请求的 URL 就好了。
165 |
166 | 那么接下来,我们就先定义一个详情页的爬取逻辑吧,代码如下:
167 | ```python
168 | def scrapy_detail(id):
169 | detail_url='https://dynamic1.scrape.cuiqingcai.com/api/movie/{id}'
170 | url=detail_url.format(id=id)
171 | return scrapy_api(url)
172 | ```
173 | 这里我们定义了一个 scrape_detail 方法,它接收参数 id。这里的实现也非常简单,先根据定义好的 DETAIL_URL 加上 id,构造一个真实的详情页 Ajax 请求的 URL,然后直接调用 scrape_api 方法传入这个 URL 即可。
174 |
175 | 接着,我们定义一个总的调用方法,将以上的方法串联调用起来,代码如下:
176 | ```python
177 | def main():
178 | total_page=10
179 | for page in range(1,total_page+1):
180 | index_data=scrapy_index(page)
181 | for item in index_data.get('resluts'):
182 | id=item.get('id')
183 | detail_data=scrapy_detail(id)
184 | logging.info('detail data %s',detail_data)
185 | ```
186 | 这里我们定义了一个 main 方法,首先遍历获取页码 page,然后把 page 当成参数传递给 scrape_index 方法,得到列表页的数据。接着我们遍历所有列表页的结果,获取每部电影的 id,然后把 id 当作参数传递给 scrape_detail 方法,来爬取每部电影的详情数据,赋值为 detail_data,输出即可。
187 |
188 | 运行结果如下:
189 |
190 | ```python
191 | 2020-03-19 02:51:55,981 - INFO: scraping https://dynamic1.scrape.cuiqingcai.com/api/movie/?limit=10&offset=0...
192 | 2020-03-19 02:51:56,446 - INFO: scraping https://dynamic1.scrape.cuiqingcai.com/api/movie/1...
193 | 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'}
194 | 2020-03-19 02:51:56,640 - INFO: scraping https://dynamic1.scrape.cuiqingcai.com/api/movie/2...
195 | 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'}
196 | ...
197 | ```
198 | 由于内容较多,这里省略了部分内容。
199 |
200 |
201 | 可以看到,其实整个爬取工作到这里就已经完成了,这里会先顺次爬取每一页列表页的 Ajax 接口,然后再顺次爬取每部电影详情页的 Ajax 接口,最后打印出每部电影的 Ajax 接口响应数据,而且都是 JSON 格式。这样,所有电影的详情数据都会被我们爬取到啦。
202 |
203 | #### 保存数据
204 |
205 | 最后,让我们把爬取到的数据保存下来吧。之前我们是用 MongoDB 来存储数据,由于本课时重点讲解 Ajax 爬取,所以这里就一切从简,将数据保存为 JSON 文本。
206 |
207 |
208 |
209 | 定义一个数据保存的方法,代码如下:
210 | ```python
211 | results_dir='reslus'
212 | exists(results_dir) or makedirs(results_dir)
213 |
214 | def save_data(data):
215 | name=data.get('name')
216 | data_path=f'{results_dir}/{name}.json'
217 | json.dump(data,open(data_path,'w',encoding='utf-8'),ensure_ascii=False,indent=2)
218 | ```
219 |
220 | 在这里我们首先定义了数据保存的文件夹 `RESULTS_DIR`,注意,我们先要判断这个文件夹是否存在,如果不存在则需要创建。
221 |
222 | 接着,我们定义了保存数据的方法 `save_data`,首先我们获取数据的 name 字段,即电影的名称,把电影名称作为 JSON 文件的名称,接着构造 JSON 文件的路径,然后用 **json 的 dump 方法将数据保存成文本格式**。dump 的方法设置了两个参数,一个是 `ensure_ascii`,我们将其设置为 False,它可以保证中文字符在文件中能以正常的中文文本呈现,而不是 unicode 字符;另一个是 `indent`,它的数值为 2,这代表生成的 JSON 数据结果有两个空格缩进,让它的格式显得更加美观。
223 |
224 |
225 |
226 | 最后,main 方法再调用下 save_data 方法即可,实现如下:
227 | ```python
228 | def main():
229 | total_page=10
230 | for page in range(1,total_page+1):
231 | index_data=scrapy_index(page)
232 | for item in index_data.get('results'):
233 | id=item.get('id')
234 | detail_data=scrapy_detail(id)
235 | logging.info('detail data %s',detail_data)
236 | save_data(detail_data)
237 | ```
238 | 重新运行一下,我们发现本地 results 文件夹下出现了各个电影的 JSON 文件,如图所示。
239 | 
240 | 这样我们就已经把所有的电影数据保存下来了,打开其中一个 JSON 文件,看看保存格式,如图所示。
241 | 
242 | 可以看到 JSON 文件里面的数据都是经过格式化的中文文本数据,结构清晰,一目了然。
243 |
244 |
245 |
246 | 至此,我们就完成了全站电影数据的爬取并把每部电影的数据保存成了 JSON 文件。
247 |
248 | #### 总结
249 |
250 | 本课时我们通过一个案例来体会了 Ajax 分析和爬取的基本流程,希望你能够对 Ajax 的分析和爬取的实现更加熟悉。
251 |
252 |
253 |
254 | 另外我们也可以观察到,由于 Ajax 接口大部分返回的是 JSON 数据,所以在一定程度上可以避免一些数据提取的工作,减轻我们的工作量。
255 |
256 |
257 |
258 | 本节代码下载地址:https://github.com/Python3WebSpider/ScrapeDynamic1。
259 |
260 | #### 参考文献
261 | [52讲轻松搞定网络爬虫](https://kaiwu.lagou.com/course/courseInfo.htm?sid=&courseId=46#/detail/pc?id=1674)
262 |
263 |
264 |
265 |
--------------------------------------------------------------------------------
/第15讲:Selenium 爬取实战.md:
--------------------------------------------------------------------------------
1 | 在上一课时我们学习了 Selenium 的基本用法,本课时我们就来结合一个实际的案例来体会一下 Selenium 的适用场景以及使用方法。
2 |
3 | #### 准备工作
4 | 在本课时开始之前,请确保已经做好了如下准备工作:
5 |
6 | * 安装好 Chrome 浏览器并正确配置了 ChromeDriver。
7 | * 安装好 Python (至少为 3.6 版本)并能成功运行 Python 程序。
8 | * 安装好了 Selenium 相关的包并能成功用 Selenium 打开 Chrome 浏览器。
9 | #### 适用场景
10 | 在前面的实战案例中,有的网页我们可以直接用 `requests` 来爬取,有的可以直接通过分析 Ajax 来爬取,不同的网站类型有其适用的爬取方法。
11 |
12 | `Selenium` 同样也有其适用场景。对于那些带有 JavaScript 渲染的网页,我们多数情况下是无法直接用 requests 爬取网页源码的,不过在有些情况下我们可以直接用 requests 来模拟 `Ajax` 请求来直接得到数据。
13 |
14 | 然而在有些情况下 Ajax 的一些请求接口可能带有一些加密参数,如 token、sign 等等,如果不分析清楚这些参数是怎么生成的话,我们就难以模拟和构造这些参数。怎么办呢?这时候我们可以直接选择使用 `Selenium` 驱动浏览器渲染的方式来另辟蹊径,**实现所见即所得的爬取**,这样我们就无需关心在这个网页背后发生了什么请求、得到什么数据以及怎么渲染页面这些过程,我们看到的页面就是最终**浏览器帮我们模拟了 Ajax 请求和 JavaScript 渲染**得到的最终结果,而 `Selenium` 正好也能拿到这个最终结果,**相当于绕过了 Ajax 请求分析和模拟**的阶段,直达目标。
15 |
16 | 然而 Selenium 当然也有其局限性,它的**爬取效率较低**,有些爬取需要模拟浏览器的操作,实现相对烦琐。不过在某些场景下也不失为一种有效的爬取手段。
17 |
18 | #### 爬取目标
19 | 本课时我们就拿一个适用 Selenium 的站点来做案例,其链接为:https://dynamic2.scrape.cuiqingcai.com/,还是和之前一样的电影网站,页面如图所示。
20 | 
21 | 初看之下页面和之前也没有什么区别,但仔细观察可以发现其 Ajax 请求接口和每部电影的 URL 都包含了加密参数。
22 |
23 | 比如我们点击任意一部电影,观察一下 URL 的变化,如图所示。
24 | 
25 | 这里我们可以看到详情页的 URL 和之前就不一样了,在之前的案例中,URL 的 detail 后面本来直接跟的是 id,如 1、2、3 等数字,但是这里直接变成了一个长字符串,看似是一个 Base64 编码的内容,所以这里我们无法直接根据规律构造详情页的 URL 了。
26 |
27 | 好,那么接下来我们直接看看 Ajax 的请求,我们从列表页的第 1 页到第 10 页依次点一下,观察一下 Ajax 请求是怎样的,如图所示。
28 | 
29 | 可以看到这里接口的参数比之前多了一个 token,而且**每次请求的 token 都是不同的**,这个 token 同样看似是一个 Base64 编码的字符串。更困难的是,**这个接口还是有时效性的**,如果我们把 Ajax 接口 URL 直接复制下来,短期内是可以访问的,但是过段时间之后就无法访问了,会直接返回 401 状态码。
30 |
31 | 那现在怎么办呢?之前我们可以直接用 `requests` 来构造 Ajax 请求,但现在 Ajax 请求接口带了这个 token,而且还是可变的,现在我们也不知道 token 的生成逻辑,那就没法直接通过构造 Ajax 请求的方式来爬取了。这时候我们可以把 token 的生成逻辑分析出来再模拟 Ajax 请求,但这种方式相对较难。所以这里我们可以直接用 Selenium 来绕过这个阶段,直接获取最终 JavaScript 渲染完成的页面源码,再提取数据就好了。
32 |
33 | 所以本课时我们要完成的目标有:
34 |
35 | * 通过 Selenium 遍历列表页,获取每部电影的详情页 URL。
36 | * 通过 Selenium 根据上一步获取的详情页 URL 爬取每部电影的详情页。
37 | * 提取每部电影的名称、类别、分数、简介、封面等内容。
38 | #### 爬取列表页
39 | 首先要我们要做如下初始化的工作,代码如下:
40 | ```python
41 | from selenium import webdriver
42 | from selenium.common.exceptions import TimeoutException
43 | from selenium.webdriver.common.by import By
44 | from selenium.webdriver.support import expected_conditions as EC
45 | from selenium.webdriver.support.wait import WebDriverWait
46 | import logging
47 | logging.basicConfig(level=logging.INFO,
48 | format='%(asctime)s - %(levelname)s: %(message)s')
49 | INDEX_URL = 'https://dynamic2.scrape.cuiqingcai.com/page/{page}'
50 | TIME_OUT = 10
51 | TOTAL_PAGE = 10
52 | browser = webdriver.Chrome()
53 | wait = WebDriverWait(browser, TIME_OUT)
54 | ```
55 | 首先我们导入了一些必要的 Selenium 模块,包括 webdriver、WebDriverWait 等等,后面我们会用到它们来实现页面的爬取和延迟等待等设置。然后接着定义了一些变量和日志配置,和之前几课时的内容是类似的。接着我们使用 Chrome 类生成了一个 webdriver 对象,赋值为 browser,这里我们可以通过 browser 调用 Selenium 的一些 API 来完成一些浏览器的操作,如截图、点击、下拉等等。最后我们又声明了一个 WebDriverWait 对象,利用它我们可以配置页面加载的最长等待时间。
56 |
57 | 好,接下来我们就观察下列表页,实现列表页的爬取吧。这里可以观察到列表页的 URL 还是有一定规律的,比如第一页为 `https://dynamic2.scrape.cuiqingcai.com/page/1`,页码就是 URL 最后的数字,所以这里我们可以直接来构造每一页的 URL。
58 |
59 | 那么每个列表页要怎么判断是否加载成功了呢?很简单,当页面出现了我们想要的内容就代表加载成功了。在这里我们就可以用 Selenium 的隐式判断条件来判定,比如每部电影的信息区块的 CSS 选择器为 `#index .item`,如图所示。
60 | 
61 | 所以这里我们直接使用 `visibility_of_all_elements_located` 判断条件加上 CSS 选择器的内容即可判定页面有没有加载出来,配合 `WebDriverWait` 的超时配置,我们就可以实现 10 秒的页面的加载监听。如果 10 秒之内,我们所配置的条件符合,则代表页面加载成功,否则则会抛出 `TimeoutException` 异常。
62 |
63 | 代码实现如下:
64 |
65 | ```python
66 | def scrape_page(url, condition, locator):
67 | logging.info('scraping %s', url)
68 | try:
69 | browser.get(url)
70 | wait.until(condition(locator))
71 | except TimeoutException:
72 | logging.error('error occurred while scraping %s', url, exc_info=True)
73 |
74 | def scrape_index(page):
75 | url = INDEX_URL.format(page=page)
76 | scrape_page(url, condition=EC.visibility_of_all_elements_located,
77 | locator=(By.CSS_SELECTOR, '#index .item'))
78 | ```
79 | 这里我们定义了两个方法。
80 |
81 | 第一个方法 `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 异常并输出错误日志。
82 |
83 | 第二个方法 `scrape_index` 则是爬取列表页的方法,它接收一个参数 page,通过调用 scrape_page 方法并传入 condition 和 locator 对象,完成页面的爬取。这里 condition 我们用的是 `visibility_of_all_elements_located`,代表所有的节点都加载出来才算成功。
84 |
85 | 注意,这里爬取页面我们不需要返回任何结果,因为执行完 scrape_index 后,页面正好处在对应的页面加载完成的状态,我们利用 browser 对象可以进一步进行信息的提取。
86 |
87 | 好,现在我们已经可以加载出来列表页了,下一步当然就是进行列表页的解析,提取出详情页 URL ,我们定义一个如下的解析列表页的方法:
88 | ```python
89 | from urllib.parse import urljoin
90 | def parse_index():
91 | elements = browser.find_elements_by_css_selector('#index .item .name')
92 | for element in elements:
93 | href = element.get_attribute('href')
94 | yield urljoin(INDEX_URL, href)
95 | ```
96 | 这里我们通过 `find_elements_by_css_selector` 方法直接提取了所有电影的名称,接着遍历结果,通过 `get_attribute` 方法提取了详情页的 href,再用 `urljoin` 方法合并成一个完整的 URL。
97 |
98 | 最后,我们再用一个 main 方法把上面的方法串联起来,实现如下:
99 | ```python
100 | def main():
101 | try:
102 | for page in range(1, TOTAL_PAGE + 1):
103 | scrape_index(page)
104 | detail_urls = parse_index()
105 | logging.info('details urls %s', list(detail_urls))
106 | finally:
107 | browser.close()
108 | ```
109 | 这里我们就是遍历了所有页码,依次爬取了每一页的列表页并提取出来了详情页的 URL。
110 |
111 | 运行结果如下:
112 | ```python
113 | 2020-03-29 12:03:09,896 - INFO: scraping https://dynamic2.scrape.cuiqingcai.com/page/1
114 | 2020-03-29 12:03:13,724 - INFO: details urls ['https://dynamic2.scrape.cuiqingcai.com/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWIx',
115 | ...
116 | 'https://dynamic2.scrape.cuiqingcai.com/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWI5', 'https://dynamic2.scrape.cuiqingcai.com/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWIxMA==']
117 | 2020-03-29 12:03:13,724 - INFO: scraping https://dynamic2.scrape.cuiqingcai.com/page/2
118 | ...
119 | ```
120 | 由于输出内容较多,这里省略了部分内容。
121 |
122 | 观察结果我们可以发现,详情页那一个个不规则的 URL 就成功被我们提取到了!
123 |
124 | #### 爬取详情页
125 | 好了,既然现在我们已经可以成功拿到详情页的 URL 了,接下来我们就进一步完成详情页的爬取并提取对应的信息吧。
126 |
127 | 同样的逻辑,详情页我们也可以加一个判定条件,如判断电影名称加载出来了就代表详情页加载成功,同样调用 scrape_page 方法即可,代码实现如下:
128 | ```python
129 | def scrape_detail(url):
130 | scrape_page(url, condition=EC.visibility_of_element_located,
131 | locator=(By.TAG_NAME, 'h2'))
132 | ```
133 | 这里的判定条件 condition 我们使用的是 visibility_of_element_located,即判断单个元素出现即可,locator 我们传入的是 (By.TAG_NAME, 'h2'),即 h2 这个节点,也就是电影的名称对应的节点,如图所示。
134 | 
135 | 如果执行了 `scrape_detail` 方法,没有出现 `TimeoutException `的话,页面就加载成功了,接着我们再定义一个解析详情页的方法,来提取出我们想要的信息就可以了,实现如下:
136 | ```python
137 | def parse_detail():
138 | url = browser.current_url
139 | name = browser.find_element_by_tag_name('h2').text
140 | categories = [element.text for element in browser.find_elements_by_css_selector('.categories button span')]
141 | cover = browser.find_element_by_css_selector('.cover').get_attribute('src')
142 | score = browser.find_element_by_class_name('score').text
143 | drama = browser.find_element_by_css_selector('.drama p').text
144 | return {
145 | 'url': url,
146 | 'name': name,
147 | 'categories': categories,
148 | 'cover': cover,
149 | 'score': score,
150 | 'drama': drama
151 | }
152 | ```
153 | 这里我们定义了一个 `parse_detail` 方法,提取了 URL、名称、类别、封面、分数、简介等内容,提取方式如下:
154 |
155 | * URL:直接调用 browser 对象的 `current_url` 属性即可获取当前页面的 URL。
156 | * 名称:通过提取 h2 节点内部的文本即可获取,这里使用了 `find_element_by_tag_name `方法并传入 h2,提取到了名称的节点,然后调用 text 属性即提取了节点内部的文本,即电影名称。
157 | * 类别:为了方便,类别我们可以通过 CSS 选择器来提取,其对应的 CSS 选择器为 `.categories button span`,可以选中多个类别节点,这里我们通过 `find_elements_by_css_selector` 即可提取 CSS 选择器对应的多个类别节点,然后依次遍历这个结果,调用它的 text 属性获取节点内部文本即可。
158 | * 封面:同样可以使用 CSS 选择器 `.cover` 直接获取封面对应的节点,但是由于其封面的 URL 对应的是 src 这个属性,所以这里用 `get_attribute` 方法并传入 src 来提取。
159 | * 分数:分数对应的 CSS 选择器为 `.score` ,我们可以用上面同样的方式来提取,但是这里我们换了一个方法,叫作 `find_element_by_class_name`,它可以使用 class 的名称来提取节点,能达到同样的效果,不过这里传入的参数就是 class 的名称 score 而不是 .score 了。提取节点之后,我们再调用 text 属性提取节点文本即可。
160 | * 简介:同样可以使用 CSS 选择器 `.drama p` 直接获取简介对应的节点,然后调用 text 属性提取文本即可。
161 | 最后,我们把结果构造成一个字典返回即可。
162 |
163 | 接下来,我们在 main 方法中再添加这两个方法的调用,实现如下:
164 | ```python
165 | def main():
166 | try:
167 | for page in range(1, TOTAL_PAGE + 1):
168 | scrape_index(page)
169 | detail_urls = parse_index()
170 | for detail_url in list(detail_urls):
171 | logging.info('get detail url %s', detail_url)
172 | scrape_detail(detail_url)
173 | detail_data = parse_detail()
174 | logging.info('detail data %s', detail_data)
175 | finally:
176 | browser.close()
177 | ```
178 | 这样,爬取完列表页之后,我们就可以依次爬取详情页,来提取每部电影的具体信息了。
179 |
180 | ```python
181 | 2020-03-29 12:24:10,723 - INFO: scraping https://dynamic2.scrape.cuiqingcai.com/page/1
182 | 2020-03-29 12:24:16,997 - INFO: get detail url https://dynamic2.scrape.cuiqingcai.com/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWIx
183 | 2020-03-29 12:24:16,997 - INFO: scraping https://dynamic2.scrape.cuiqingcai.com/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWIx
184 | 2020-03-29 12:24:19,289 - INFO: detail data {'url': 'https://dynamic2.scrape.cuiqingcai.com/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWIx', 'name': '霸王别姬 - Farewell My Concubine', 'categories': ['剧情', '爱情'], 'cover': 'https://p0.meituan.net/movie/ce4da3e03e655b5b88ed31b5cd7896cf62472.jpg@464w_644h_1e_1c', 'score': '9.5', 'drama': '影片借一出《霸王别姬》的京戏,牵扯出三个人之间一段随时代风云变幻的爱恨情仇。段小楼(张丰毅 饰)与程蝶衣(张国荣 饰)是一对打小一起长大的师兄弟,两人一个演生,一个饰旦,一向配合天衣无缝,尤其一出《霸王别姬》,更是誉满京城,为此,两人约定合演一辈子《霸王别姬》。但两人对戏剧与人生关系的理解有本质不同,段小楼深知戏非人生,程蝶衣则是人戏不分。段小楼在认为该成家立业之时迎娶了名妓菊仙(巩俐 饰),致使程蝶衣认定菊仙是可耻的第三者,使段小楼做了叛徒,自此,三人围绕一出《霸王别姬》生出的爱恨情仇战开始随着时代风云的变迁不断升级,终酿成悲剧。'}
185 | 2020-03-29 12:24:19,291 - INFO: get detail url https://dynamic2.scrape.cuiqingcai.com/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWIy
186 | 2020-03-29 12:24:19,291 - INFO: scraping https://dynamic2.scrape.cuiqingcai.com/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWIy
187 | 2020-03-29 12:24:21,524 - INFO: detail data {'url': 'https://dynamic2.scrape.cuiqingcai.com/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWIy', 'name': '这个杀手不太冷 - Léon', 'categories': ['剧情', '动作', '犯罪'], 'cover': 'https://p1.meituan.net/movie/6bea9af4524dfbd0b668eaa7e187c3df767253.jpg@464w_644h_1e_1c', 'score': '9.5', 'drama': '里昂(让·雷诺 饰)是名孤独的职业杀手,受人雇佣。一天,邻居家小姑娘马蒂尔德(纳塔丽·波特曼 饰)敲开他的房门,要求在他那里暂避杀身之祸。原来邻居家的主人是警方缉毒组的眼线,只因贪污了一小包毒品而遭恶警(加里·奥德曼 饰)杀害全家的惩罚。马蒂尔德 得到里昂的留救,幸免于难,并留在里昂那里。里昂教小女孩使枪,她教里昂法文,两人关系日趋亲密,相处融洽。 女孩想着去报仇,反倒被抓,里昂及时赶到,将女孩救回。混杂着哀怨情仇的正邪之战渐次升级,更大的冲突在所难免……'}
188 | ...
189 | ```
190 | 这样详情页数据我们也可以提取到了。
191 |
192 | #### 数据存储
193 | 最后,我们再像之前一样添加一个数据存储的方法,为了方便,这里还是保存为 JSON 文本文件,实现如下:
194 | ```python
195 | from os import makedirs
196 | from os.path import exists
197 | RESULTS_DIR = 'results'
198 | exists(RESULTS_DIR) or makedirs(RESULTS_DIR)
199 | def save_data(data):
200 | name = data.get('name')
201 | data_path = f'{RESULTS_DIR}/{name}.json'
202 | json.dump(data, open(data_path, 'w', encoding='utf-8'), ensure_ascii=False, indent=2)
203 | ```
204 | 这里原理和实现方式与 Ajax 爬取实战课时是完全相同的,不再赘述。
205 |
206 | 最后添加上 save_data 的调用,完整看下运行效果。
207 |
208 | #### Headless
209 | 如果觉得爬取过程中弹出浏览器有所干扰,我们可以开启 Chrome 的 Headless 模式,这样爬取过程中便不会再弹出浏览器了,同时爬取速度还有进一步的提升。
210 |
211 | 只需要做如下修改即可:
212 | ```python
213 | options = webdriver.ChromeOptions()
214 | options.add_argument('--headless')
215 | browser = webdriver.Chrome(options=options)
216 | ```
217 | 这里通过 ChromeOptions 添加了 --headless 参数,然后用 ChromeOptions 来进行 Chrome 的初始化即可。
218 |
219 | 修改后再重新运行代码,Chrome 浏览器就不会弹出来了,爬取结果是完全一样的。
220 |
221 | #### 总结
222 | 本课时我们通过一个案例了解了 Selenium 的适用场景,并结合案例使用 Selenium 实现了页面的爬取,从而对 Selenium 的使用有进一步的掌握。
223 |
224 | 以后我们就知道什么时候可以用 Selenium 以及怎样使用 Selenium 来完成页面的爬取啦。
225 |
--------------------------------------------------------------------------------
/第16讲:异步爬虫的原理和解析.md:
--------------------------------------------------------------------------------
1 | 我们知道爬虫是 IO 密集型任务,比如如果我们使用 requests 库来爬取某个站点的话,发出一个请求之后,程序必须要等待网站返回响应之后才能接着运行,而在等待响应的过程中,整个爬虫程序是一直在等待的,实际上没有做任何的事情。对于这种情况我们有没有优化方案呢?
2 |
3 | #### 实例引入
4 | 比如在这里我们看这么一个示例网站:https://static4.scrape.cuiqingcai.com/,如图所示。
5 | 
6 | **这个网站在内部实现返回响应的逻辑的时候特意加了 5 秒的延迟**,也就是说如果我们用 requests 来爬取其中某个页面的话,至少需要 5 秒才能得到响应。
7 |
8 | 另外这个网站的逻辑结构在之前的案例中我们也分析过,其内容就是电影数据,一共 100 部,每个电影的详情页是一个自增 ID,从 1~100,比如 https://static4.scrape.cuiqingcai.com/detail/43 就代表第 43 部电影,如图所示。
9 | 
10 |
11 |
12 | 下面我们来用 requests 写一个遍历程序,直接遍历 1~100 部电影数据,代码实现如下:
13 |
14 | ```python
15 | import requests
16 | import logging
17 | import time
18 | logging.basicConfig(level=logging.INFO,
19 | format='%(asctime)s - %(levelname)s: %(message)s')
20 | TOTAL_NUMBER = 100
21 | BASE_URL = 'https://static4.scrape.cuiqingcai.com/detail/{id}'
22 | start_time = time.time()
23 | for id in range(1, TOTAL_NUMBER + 1):
24 | url = BASE_URL.format(id=id)
25 | logging.info('scraping %s', url)
26 | response = requests.get(url)
27 | end_time = time.time()
28 | logging.info('total time %s seconds', end_time - start_time)
29 | ```
30 | 这里我们直接用循环的方式构造了 100 个详情页的爬取,使用的是 requests **单线程**,在爬取之前和爬取之后记录下时间,最后输出爬取了 100 个页面消耗的时间。
31 |
32 | 运行结果如下:
33 | ```python
34 | 2020-03-31 14:40:35,411 - INFO: scraping https://static4.scrape.cuiqingcai.com/detail/1
35 | 2020-03-31 14:40:40,578 - INFO: scraping https://static4.scrape.cuiqingcai.com/detail/2
36 | 2020-03-31 14:40:45,658 - INFO: scraping https://static4.scrape.cuiqingcai.com/detail/3
37 | 2020-03-31 14:40:50,761 - INFO: scraping https://static4.scrape.cuiqingcai.com/detail/4
38 | 2020-03-31 14:40:55,852 - INFO: scraping https://static4.scrape.cuiqingcai.com/detail/5
39 | 2020-03-31 14:41:00,956 - INFO: scraping https://static4.scrape.cuiqingcai.com/detail/6
40 | ...
41 | 2020-03-31 14:48:58,785 - INFO: scraping https://static4.scrape.cuiqingcai.com/detail/99
42 | 2020-03-31 14:49:03,867 - INFO: scraping https://static4.scrape.cuiqingcai.com/detail/100
43 | 2020-03-31 14:49:09,042 - INFO: total time 513.6309871673584 seconds
44 | 2020-03-31 14:49:09,042 - INFO: total time 513.6309871673584 seconds
45 | ```
46 | 由于每个页面都至少要等待 5 秒才能加载出来,因此 100 个页面至少要花费 500 秒的时间,总的爬取时间最终为 513.6 秒,将近 9 分钟。
47 |
48 | 这个在实际情况下是很常见的,有些网站本身加载速度就比较慢,稍慢的可能 1~3 秒,更慢的说不定 10 秒以上才可能加载出来。如果我们用 requests 单线程这么爬取的话,总的耗时是非常多的。此时如果我们开了**多线程**或**多进程**来爬取的话,其爬取速度确实会成倍提升,但有没有更好的解决方案呢?
49 |
50 | 本课时我们就来了解一下**使用异步执行方式来加速**的方法,此种方法对于 IO 密集型任务非常有效。如将其应用到网络爬虫中,爬取效率甚至可以成百倍地提升。
51 |
52 | #### 基本了解
53 | 在了解**异步协程**之前,我们首先得了解一些基础概念,如阻塞和非阻塞、同步和异步、多进程和协程。
54 |
55 | ##### 阻塞
56 | 阻塞状态指程序未得到所需计算资源时被挂起的状态。**程序在等待某个操作完成期间,自身无法继续处理其他的事情,则称该程序在该操作上是阻塞的**。
57 |
58 | 常见的阻塞形式有:网络 I/O 阻塞、磁盘 I/O 阻塞、用户输入阻塞等。阻塞是无处不在的,包括 CPU 切换上下文时,所有的进程都无法真正处理事情,它们也会被阻塞。如果是多核 CPU 则正在执行上下文切换操作的核不可被利用。
59 |
60 | ##### 非阻塞
61 | **程序在等待某操作过程中,自身不被阻塞,可以继续处理其他的事情,则称该程序在该操作上是非阻塞的。**
62 |
63 | 非阻塞并不是在任何程序级别、任何情况下都可以存在的。仅当程序封装的级别可以囊括独立的子程序单元时,它才可能存在非阻塞状态。
64 |
65 | 非阻塞的存在是因为阻塞存在,正因为某个操作阻塞导致的耗时与效率低下,我们才要把它变成非阻塞的。
66 |
67 | ##### 同步
68 | 不同程序单元为了完成某个任务,在执行过程中需靠某种通信方式以协调一致,我们称这些程序单元是同步执行的。
69 |
70 | 例如购物系统中更新商品库存,需要用“行锁”作为通信信号,让不同的更新请求强制排队顺序执行,那更新库存的操作是同步的。
71 |
72 | 简言之,同步意味着有序。
73 |
74 | ##### 异步
75 | 为完成某个任务,不同程序单元之间过程中无需通信协调,也能完成任务的方式,不相关的程序单元之间可以是异步的。
76 |
77 | 例如,爬虫下载网页。调度程序调用下载程序后,即可调度其他任务,而无需与该下载任务保持通信以协调行为。不同网页的下载、保存等操作都是无关的,也无需相互通知协调。这些异步操作的完成时刻并不确定。
78 |
79 | 简言之,异步意味着无序。
80 |
81 | ##### 多进程
82 | 多进程就是利用 CPU 的多核优势,在同一时间并行地执行多个任务,可以大大提高执行效率。
83 |
84 | ##### 协程
85 | 协程,英文叫作 Coroutine,又称微线程、纤程,协程是一种用户态的轻量级线程。
86 |
87 | 协程拥有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈。因此协程能保留上一次调用时的状态,即所有局部状态的一个特定组合,每次过程重入时,就相当于进入上一次调用的状态。
88 |
89 | 协程本质上是个单进程,协程相对于多进程来说,无需线程上下文切换的开销,无需原子操作锁定及同步的开销,编程模型也非常简单。
90 |
91 | 我们可以使用协程来实现异步操作,比如在网络爬虫场景下,我们发出一个请求之后,需要等待一定的时间才能得到响应,但其实在这个等待过程中,程序可以干许多其他的事情,等到响应得到之后才切换回来继续处理,这样可以充分利用 CPU 和其他资源,这就是协程的优势。
92 |
93 | #### 协程用法
94 | 接下来,我们来了解下协程的实现,从 Python 3.4 开始,Python 中加入了协程的概念,但这个版本的协程还是以生成器对象为基础的,在 Python 3.5 则增加了 async/await,使得协程的实现更加方便。
95 |
96 | Python 中使用协程最常用的库莫过于 `asyncio`,所以本文会以 asyncio 为基础来介绍协程的使用。
97 |
98 | 首先我们需要了解下面几个概念。
99 |
100 | * event_loop:事件循环,相当于一个无限循环,我们可以把一些函数注册到这个事件循环上,当满足条件发生的时候,就会调用对应的处理方法。
101 | * coroutine:中文翻译叫协程,在 Python 中常指代为协程对象类型,我们可以将协程对象注册到时间循环中,它会被事件循环调用。我们可以使用 async 关键字来定义一个方法,这个方法在调用时不会立即被执行,而是返回一个协程对象。
102 | * task:任务,它是对协程对象的进一步封装,包含了任务的各个状态。
103 | * future:代表将来执行或没有执行的任务的结果,实际上和 task 没有本质区别。
104 |
105 | 另外我们还需要了解 async/await 关键字,它是从 Python 3.5 才出现的,专门用于定义协程。其中,async 定义一个协程,await 用来挂起阻塞方法的执行。
106 |
107 | #### 定义协程
108 | 首先我们来定义一个协程,体验一下它和普通进程在实现上的不同之处,代码如下:
109 | ```python
110 | import asyncio
111 | async def execute(x):
112 | print('Number:', x)
113 | coroutine = execute(1)
114 | print('Coroutine:', coroutine)
115 | print('After calling execute')
116 | loop = asyncio.get_event_loop()
117 | loop.run_until_complete(coroutine)
118 | print('After calling loop')
119 |
120 | 运行结果:
121 | Coroutine:
122 | After calling execute
123 | Number: 1
124 | After calling loop
125 | ```
126 | 首先我们引入了 `asyncio` 这个包,这样我们才可以使用 async 和 await,然后我们使用 `async` 定义了一个 `execute` 方法,方法接收一个数字参数,方法执行之后会打印这个数字。
127 |
128 | 随后我们直接调用了这个方法,然而这个方法并没有执行,而是返回了一个 `coroutine` 协程对象。随后我们使用 `get_event_loop` 方法创建了一个事件循环 loop,并调用了 loop 对象的 `run_until_complete` 方法将协程注册到事件循环 loop 中,然后启动。最后我们才看到了 `execute` 方法打印了输出结果。
129 |
130 | 可见,`async` 定义的方法就会变成一个无法直接执行的 `coroutine` 对象,必须将其注册到事件循环中才可以执行。
131 |
132 | 上面我们还提到了 task,它是对 `coroutine` 对象的进一步封装,它里面相比 `coroutine` 对象多了运行状态,比如 running、finished 等,我们可以用这些状态来获取协程对象的执行情况。
133 |
134 | 在上面的例子中,当我们将 `coroutine` 对象传递给 `run_until_complete` 方法的时候,实际上它进行了一个操作就是将 `coroutine` 封装成了 `task` 对象,我们也可以显式地进行声明,如下所示:
135 | ```python
136 | import asyncio
137 | async def execute(x):
138 | print('Number:', x)
139 | return x
140 | coroutine = execute(1)
141 | print('Coroutine:', coroutine)
142 | print('After calling execute')
143 | loop = asyncio.get_event_loop()
144 | task = loop.create_task(coroutine)
145 | print('Task:', task)
146 | loop.run_until_complete(task)
147 | print('Task:', task)
148 | print('After calling loop')
149 | ```
150 | 运行结果:
151 | ```python
152 | Coroutine:
153 | After calling execute
154 | Task: >
155 | Number: 1
156 | Task: result=1>
157 | After calling loop
158 | ```
159 | 这里我们定义了 loop 对象之后,接着调用了它的 `create_task` 方法将 coroutine 对象转化为了 task 对象,随后我们打印输出一下,发现它是 pending 状态。接着我们将 task 对象添加到事件循环中得到执行,随后我们再打印输出一下 task 对象,发现它的状态就变成了 finished,同时还可以看到其 result 变成了 1,也就是我们定义的 `execute` 方法的返回结果。
160 |
161 | 另外定义 task 对象还有一种方式,就是直接通过 `asyncio` 的 `ensure_future` 方法,返回结果也是 task 对象,这样的话我们就可以不借助于 loop 来定义,即使我们还没有声明 loop 也可以提前定义好 task 对象,写法如下:
162 | ```python
163 | import asyncio
164 | async def execute(x):
165 | print('Number:', x)
166 | return x
167 | coroutine = execute(1)
168 | print('Coroutine:', coroutine)
169 | print('After calling execute')
170 | task = asyncio.ensure_future(coroutine)
171 | print('Task:', task)
172 | loop = asyncio.get_event_loop()
173 | loop.run_until_complete(task)
174 | print('Task:', task)
175 | print('After calling loop')
176 | ```
177 | 运行结果:
178 | ```python
179 | Coroutine:
180 | After calling execute
181 | Task: >
182 | Number: 1
183 | Task: result=1>
184 | After calling loop
185 | ```
186 | 发现其运行效果都是一样的。
187 |
188 | #### 绑定回调
189 | 另外我们也可以为某个 task 绑定一个回调方法,比如我们来看下面的例子:
190 | ```python
191 | import asyncio
192 | import requests
193 |
194 | async def request():
195 | url = 'https://www.baidu.com'
196 | status = requests.get(url)
197 | return status
198 |
199 | def callback(task):
200 | print('Status:', task.result())
201 |
202 | coroutine = request()
203 | task = asyncio.ensure_future(coroutine)
204 | task.add_done_callback(callback)
205 | print('Task:', task)
206 |
207 | loop = asyncio.get_event_loop()
208 | loop.run_until_complete(task)
209 | print('Task:', task)
210 | ```
211 | 在这里我们定义了一个 `request `方法,请求了百度,获取其状态码,但是这个方法里面我们没有任何 print 语句。随后我们定义了一个 `callback` 方法,这个方法接收一个参数,是 task 对象,然后调用 print 方法打印了 task 对象的结果。这样我们就定义好了一个 coroutine 对象和一个回调方法,我们现在希望的效果是,当 coroutine 对象执行完毕之后,就去执行声明的 callback 方法。
212 |
213 | 那么它们二者怎样关联起来呢?很简单,只需要调用 `add_done_callback` 方法即可,我们将 callback 方法传递给了封装好的 task 对象,这样当 task 执行完毕之后就可以调用 callback 方法了,同时 task 对象还会作为参数传递给 callback 方法,调用 task 对象的 result 方法就可以获取返回结果了。
214 |
215 | 运行结果:
216 | ```python
217 | Task: cb=[callback() at demo.py:11]>
218 | Status:
219 | Task: result=>
220 | ```
221 |
222 | 实际上不用回调方法,直接在 task 运行完毕之后也可以直接调用 result 方法获取结果,如下所示:
223 | ```python
224 | import asyncio
225 | import requests
226 |
227 | async def request():
228 | url = 'https://www.baidu.com'
229 | status = requests.get(url)
230 | return status
231 |
232 | coroutine = request()
233 | task = asyncio.ensure_future(coroutine)
234 | print('Task:', task)
235 |
236 | loop = asyncio.get_event_loop()
237 | loop.run_until_complete(task)
238 | print('Task:', task)
239 | print('Task Result:', task.result())
240 | ```
241 |
242 | 运行结果是一样的:
243 | ```python
244 | Task: >
245 | Task: result=>
246 | Task Result:
247 | ```
248 |
249 | #### 多任务协程
250 | 上面的例子我们只执行了一次请求,如果我们想执行多次请求应该怎么办呢?我们可以定义一个 task 列表,然后使用 `asyncio` 的 `wait `方法即可执行,看下面的例子:
251 | ```python
252 | import asyncio
253 | import requests
254 |
255 | async def request():
256 | url = 'https://www.baidu.com'
257 | status = requests.get(url)
258 | return status
259 |
260 | tasks = [asyncio.ensure_future(request()) for _ in range(5)]
261 | print('Tasks:', tasks)
262 |
263 | loop = asyncio.get_event_loop()
264 | loop.run_until_complete(asyncio.wait(tasks))
265 |
266 | for task in tasks:
267 | print('Task Result:', task.result())
268 | ```
269 | 这里我们使用一个 for 循环创建了五个 task,组成了一个列表,然后把这个列表首先传递给了 asyncio 的 wait() 方法,然后再将其注册到时间循环中,就可以发起五个任务了。最后我们再将任务的运行结果输出出来,运行结果如下:
270 | ```python
271 | Tasks: [>,
272 | >,
273 | >,
274 | >,
275 | >]
276 |
277 | Task Result:
278 | Task Result:
279 | Task Result:
280 | Task Result:
281 | Task Result:
282 | ```
283 | 可以看到五个任务被顺次执行了,并得到了运行结果。
284 |
285 | #### 协程实现
286 | 前面讲了这么多,又是 async,又是 coroutine,又是 task,又是 callback,但似乎并没有看出协程的优势啊?反而写法上更加奇怪和麻烦了,别急,上面的案例只是为后面的使用作铺垫,接下来我们正式来看下协程在解决 IO 密集型任务上有怎样的优势吧!
287 |
288 | 上面的代码中,我们用一个网络请求作为示例,这就是一个耗时等待的操作,因为我们请求网页之后需要等待页面响应并返回结果。**耗时等待的操作一般都是 IO 操作**,比如文件读取、网络请求等等。协程对于处理这种操作是有很大优势的,**当遇到需要等待的情况的时候,程序可以暂时挂起,转而去执行其他的操作,从而避免一直等待一个程序而耗费过多的时间,充分利用资源**。
289 |
290 | 为了表现出协程的优势,我们还是拿本课时开始介绍的网站 https://static4.scrape.cuiqingcai.com/ 为例来进行演示,因为该网站响应比较慢,所以我们可以通过爬取时间来直观地感受到爬取速度的提升。
291 |
292 | 为了让你更好地理解协程的正确使用方法,这里我们先来看看使用协程时常犯的错误,后面再给出正确的例子来对比一下。
293 |
294 | 首先,我们还是拿之前的 requests 来进行网页请求,接下来我们再重新使用上面的方法请求一遍:
295 | ```python
296 | import asyncio
297 | import requests
298 | import time
299 |
300 | start = time.time()
301 |
302 | async def request():
303 | url = 'https://static4.scrape.cuiqingcai.com/'
304 | print('Waiting for', url)
305 | response = requests.get(url)
306 | print('Get response from', url, 'response', response)
307 |
308 |
309 | tasks = [asyncio.ensure_future(request()) for _ in range(10)]
310 | loop = asyncio.get_event_loop()
311 | loop.run_until_complete(asyncio.wait(tasks))
312 |
313 | end = time.time()
314 | print('Cost time:', end - start)
315 | ```
316 | 在这里我们还是创建了 10 个 task,然后将 task 列表传给 wait 方法并注册到时间循环中执行。
317 |
318 | 运行结果如下:
319 | ```python
320 | Waiting for https://static4.scrape.cuiqingcai.com/
321 | Get response from https://static4.scrape.cuiqingcai.com/ response
322 | Waiting for https://static4.scrape.cuiqingcai.com/
323 | Get response from https://static4.scrape.cuiqingcai.com/ response
324 | Waiting for https://static4.scrape.cuiqingcai.com/
325 | ...
326 | Get response from https://static4.scrape.cuiqingcai.com/ response
327 | Waiting for https://static4.scrape.cuiqingcai.com/
328 | Get response from https://static4.scrape.cuiqingcai.com/ response
329 | Waiting for https://static4.scrape.cuiqingcai.com/
330 | Get response from https://static4.scrape.cuiqingcai.com/ response
331 | Cost time: 51.422438859939575
332 | ```
333 | 可以发现和正常的请求并没有什么两样,依然还是顺次执行的,耗时 51 秒,平均一个请求耗时 5 秒,说好的异步处理呢?
334 |
335 | 其实,要实现异步处理,我们得先要有挂起的操作,当一个任务需要等待 IO 结果的时候,可以挂起当前任务,转而去执行其他任务,这样我们才能充分利用好资源,上面方法都是一本正经的串行走下来,连个挂起都没有,怎么可能实现异步?想太多了。
336 |
337 | 要实现异步,接下来我们需要了解一下 `await` 的用法,**使用 await 可以将耗时等待的操作挂起,让出控制权。当协程执行的时候遇到 await,时间循环就会将本协程挂起,转而去执行别的协程,直到其他的协程挂起或执行完毕**。
338 |
339 | 所以,我们可能会将代码中的 request 方法改成如下的样子:
340 | ```python
341 | async def request():
342 | url = 'https://static4.scrape.cuiqingcai.com/'
343 | print('Waiting for', url)
344 | response = await requests.get(url)
345 | print('Get response from', url, 'response', response)
346 | ```
347 | 仅仅是在 requests 前面加了一个 await,然而执行以下代码,会得到如下报错:
348 | ```python
349 | Waiting for https://static4.scrape.cuiqingcai.com/
350 | Waiting for https://static4.scrape.cuiqingcai.com/
351 | Waiting for https://static4.scrape.cuiqingcai.com/
352 | Waiting for https://static4.scrape.cuiqingcai.com/
353 | ...
354 | Task exception was never retrieved
355 | future: exception=TypeError("object Response can't be used in 'await' expression")>
356 | Traceback (most recent call last):
357 | File "demo.py", line 11, in request
358 | response = await requests.get(url)
359 | TypeError: object Response can't be used in 'await' expression
360 | ```
361 |
362 | 这次它遇到 await 方法确实挂起了,也等待了,但是最后却报了这么个错,这个错误的意思是 requests 返回的 Response 对象不能和 await 一起使用,为什么呢?因为根据官方文档说明,await 后面的对象必须是如下格式之一:
363 |
364 | * A native coroutine object returned from a native coroutine function,一个原生 coroutine 对象。
365 | * A generator-based coroutine object returned from a function decorated with types.coroutine,一个由 types.coroutine 修饰的生成器,这个生成器可以返回 coroutine 对象。
366 | * An object with an __await__ method returning an iterator,一个包含 __await__ 方法的对象返回的一个迭代器。
367 |
368 | 可以参见:https://www.python.org/dev/peps/pep-0492/#await-expression。
369 |
370 | requests 返回的 Response 不符合上面任一条件,因此就会报上面的错误了。
371 |
372 | 那么你可能会发现,既然 await 后面可以跟一个 coroutine 对象,那么我用 async 把请求的方法改成 coroutine 对象不就可以了吗?所以就改写成如下的样子:
373 |
374 | ```python
375 | import asyncio
376 | import requests
377 | import time
378 |
379 | start = time.time()
380 |
381 | async def get(url):
382 | return requests.get(url)
383 |
384 | async def request():
385 | url = 'https://static4.scrape.cuiqingcai.com/'
386 | print('Waiting for', url)
387 | response = await get(url)
388 | print('Get response from', url, 'response', response)
389 |
390 | tasks = [asyncio.ensure_future(request()) for _ in range(10)]
391 | loop = asyncio.get_event_loop()
392 | loop.run_until_complete(asyncio.wait(tasks))
393 |
394 | end = time.time()
395 | print('Cost time:', end - start)
396 | ```
397 | 这里我们将请求页面的方法独立出来,并用 async 修饰,这样就得到了一个 coroutine 对象,我们运行一下看看:
398 |
399 | ```python
400 | Waiting for https://static4.scrape.cuiqingcai.com/
401 | Get response from https://static4.scrape.cuiqingcai.com/ response
402 | Waiting for https://static4.scrape.cuiqingcai.com/
403 | Get response from https://static4.scrape.cuiqingcai.com/ response
404 | Waiting for https://static4.scrape.cuiqingcai.com/
405 | ...
406 | Get response from https://static4.scrape.cuiqingcai.com/ response
407 | Waiting for https://static4.scrape.cuiqingcai.com/
408 | Get response from https://static4.scrape.cuiqingcai.com/ response
409 | Waiting for https://static4.scrape.cuiqingcai.com/
410 | Get response from https://static4.scrape.cuiqingcai.com/ response
411 | Cost time: 51.394437756259273
412 | ```
413 | 还是不行,它还不是异步执行,也就是说我们仅仅将涉及 IO 操作的代码封装到 `async` 修饰的方法里面是不可行的!我们必须要使用支持异步操作的请求方式才可以实现真正的异步,所以这里就需要 `aiohttp` 派上用场了。
414 |
415 | #### 使用 aiohttp
416 | aiohttp 是一个支持异步请求的库,利用它和 asyncio 配合我们可以非常方便地实现异步请求操作。
417 |
418 | 安装方式如下:
419 | ```python
420 | pip3 install aiohttp
421 | ```
422 | 官方文档链接为:https://aiohttp.readthedocs.io/,它分为两部分,一部分是 Client,一部分是 Server,详细的内容可以参考官方文档。
423 |
424 | 下面我们将 aiohttp 用上来,将代码改成如下样子:
425 | ```python
426 | import asyncio
427 | import aiohttp
428 | import time
429 |
430 | start = time.time()
431 |
432 | async def get(url):
433 | session = aiohttp.ClientSession()
434 | response = await session.get(url)
435 | await response.text()
436 | await session.close()
437 | return response
438 |
439 | async def request():
440 | url = 'https://static4.scrape.cuiqingcai.com/'
441 | print('Waiting for', url)
442 | response = await get(url)
443 | print('Get response from', url, 'response', response)
444 |
445 | tasks = [asyncio.ensure_future(request()) for _ in range(10)]
446 | loop = asyncio.get_event_loop()
447 | loop.run_until_complete(asyncio.wait(tasks))
448 |
449 | end = time.time()
450 | print('Cost time:', end - start)
451 | ```
452 | 在这里我们将请求库由 requests 改成了 aiohttp,通过 aiohttp 的 ClientSession 类的 get 方法进行请求,结果如下:
453 |
454 | ```python
455 | Waiting for https://static4.scrape.cuiqingcai.com/
456 | Waiting for https://static4.scrape.cuiqingcai.com/
457 | Waiting for https://static4.scrape.cuiqingcai.com/
458 | Waiting for https://static4.scrape.cuiqingcai.com/
459 | Waiting for https://static4.scrape.cuiqingcai.com/
460 | Waiting for https://static4.scrape.cuiqingcai.com/
461 | Waiting for https://static4.scrape.cuiqingcai.com/
462 | Waiting for https://static4.scrape.cuiqingcai.com/
463 | Waiting for https://static4.scrape.cuiqingcai.com/
464 | Waiting for https://static4.scrape.cuiqingcai.com/
465 | Get response from https://static4.scrape.cuiqingcai.com/ response
466 |
467 | ...
468 | Get response from https://static4.scrape.cuiqingcai.com/ response
469 |
470 | Cost time: 6.1102519035339355
471 | ```
472 | 成功了!我们发现这次请求的耗时由 51 秒变直接成了 6 秒,耗费时间减少了非常非常多。
473 |
474 | 代码里面我们使用了 `await`,后面跟了 get 方法,在执行这 10 个协程的时候,如果遇到了 await,那么就会将当前协程挂起,转而去执行其他的协程,直到其他的协程也挂起或执行完毕,再进行下一个协程的执行。
475 |
476 | 开始运行时,时间循环会运行第一个 task,针对第一个 task 来说,当执行到第一个 await 跟着的 get 方法时,它被挂起,但这个 get 方法第一步的执行是非阻塞的,挂起之后立马被唤醒,所以立即又进入执行,创建了 ClientSession 对象,接着遇到了第二个 await,调用了 session.get 请求方法,然后就被挂起了,由于请求需要耗时很久,所以一直没有被唤醒。
477 |
478 | 当第一个 task 被挂起了,那接下来该怎么办呢?事件循环会寻找当前未被挂起的协程继续执行,于是就转而执行第二个 task 了,也是一样的流程操作,直到执行了第十个 task 的 session.get 方法之后,全部的 task 都被挂起了。所有 task 都已经处于挂起状态,怎么办?只好等待了。5 秒之后,几个请求几乎同时都有了响应,然后几个 task 也被唤醒接着执行,输出请求结果,最后总耗时,6 秒!
479 |
480 | 怎么样?这就是异步操作的便捷之处,当遇到阻塞式操作时,任务被挂起,程序接着去执行其他的任务,而不是傻傻地等待,这样可以充分利用 CPU 时间,而不必把时间浪费在等待 IO 上。
481 |
482 | 你可能会说,既然这样的话,在上面的例子中,在发出网络请求后,既然接下来的 5 秒都是在等待的,在 5 秒之内,CPU 可以处理的 task 数量远不止这些,那么岂不是我们放 10 个、20 个、50 个、100 个、1000 个 task 一起执行,最后得到所有结果的耗时不都是差不多的吗?因为这几个任务被挂起后都是一起等待的。
483 |
484 | 理论来说确实是这样的,不过有个前提,那就是服务器在同一时刻接受无限次请求都能保证正常返回结果,也就是服务器无限抗压,另外还要忽略 IO 传输时延,确实可以做到无限 task 一起执行且在预想时间内得到结果。但由于不同服务器处理的实现机制不同,可能某些服务器并不能承受这么高的并发,因此响应速度也会减慢。
485 |
486 | 在这里我们以百度为例,来测试下并发数量为 1、3、5、10、...、500 的情况下的耗时情况,代码如下:
487 |
488 | ```python
489 | import asyncio
490 | import aiohttp
491 | import time
492 |
493 |
494 | def test(number):
495 | start = time.time()
496 |
497 | async def get(url):
498 | session = aiohttp.ClientSession()
499 | response = await session.get(url)
500 | await response.text()
501 | await session.close()
502 | return response
503 |
504 | async def request():
505 | url = 'https://www.baidu.com/'
506 | await get(url)
507 |
508 | tasks = [asyncio.ensure_future(request()) for _ in range(number)]
509 | loop = asyncio.get_event_loop()
510 | loop.run_until_complete(asyncio.wait(tasks))
511 |
512 | end = time.time()
513 | print('Number:', number, 'Cost time:', end - start)
514 |
515 | for number in [1, 3, 5, 10, 15, 30, 50, 75, 100, 200, 500]:
516 | test(number)
517 | ```
518 | 运行结果如下:
519 | ```python
520 | Number: 1 Cost time: 0.05885505676269531
521 | Number: 3 Cost time: 0.05773782730102539
522 | Number: 5 Cost time: 0.05768704414367676
523 | Number: 10 Cost time: 0.15174412727355957
524 | Number: 15 Cost time: 0.09603095054626465
525 | Number: 30 Cost time: 0.17843103408813477
526 | Number: 50 Cost time: 0.3741800785064697
527 | Number: 75 Cost time: 0.2894289493560791
528 | Number: 100 Cost time: 0.6185381412506104
529 | Number: 200 Cost time: 1.0894129276275635
530 | Number: 500 Cost time: 1.8213098049163818
531 | ```
532 | 可以看到,即使我们增加了并发数量,但在服务器能承受高并发的前提下,其爬取速度几乎不太受影响。
533 |
534 | 综上所述,使用了异步请求之后,我们几乎可以在相同的时间内实现成百上千倍次的网络请求,把这个运用在爬虫中,速度提升是非常可观的。
535 |
536 | #### 总结
537 | 以上便是 Python 中协程的基本原理和用法,在后面一课时会详细介绍 `aiohttp` 的使用和爬取实战,实现快速高并发的爬取。
538 |
539 |
--------------------------------------------------------------------------------
/第17讲:aiohttp 异步爬虫实战.md:
--------------------------------------------------------------------------------
1 | 在上一课时我们介绍了异步爬虫的基本原理和 `asyncio` 的基本用法,另外在最后简单提及了 `aiohttp` 实现网页爬取的过程,这一课,我们来介绍一下 `aiohttp`的常见用法,以及通过一个实战案例来介绍下使用 `aiohttp `完成网页异步爬取的过程。
2 |
3 | #### aiohttp
4 | 前面介绍的 asyncio 模块内部实现了对 TCP、UDP、SSL 协议的异步操作,但是对于 HTTP 请求的异步操作来说,我们就需要用到 aiohttp 来实现了。
5 |
6 | aiohttp 是一个基于 asyncio 的异步 HTTP 网络模块,它既提供了服务端,又提供了客户端。其中我们用服务端可以搭建一个支持异步处理的服务器,用于处理请求并返回响应,类似于 Django、Flask、Tornado 等一些 Web 服务器。而客户端我们就可以用来发起请求,就类似于 requests 来发起一个 HTTP 请求然后获得响应,但 requests 发起的是同步的网络请求,而 aiohttp 则发起的是异步的。
7 |
8 | 本课时我们就主要来了解一下 aiohttp 客户端部分的使用。
9 |
10 | #### 基本使用
11 | ##### 基本实例
12 | 首先我们来看一个基本的 ·aiohttp· 请求案例,代码如下:
13 | ```java
14 | import aiohttp
15 | import asyncio
16 |
17 | async def fetch(session, url):
18 | async with session.get(url) as response:
19 | return await response.text(), response.status
20 |
21 | async def main():
22 | async with aiohttp.ClientSession() as session:
23 | html, status = await fetch(session, 'https://cuiqingcai.com')
24 | print(f'html: {html[:100]}...')
25 | print(f'status: {status}')
26 |
27 | if __name__ == '__main__':
28 | loop = asyncio.get_event_loop()
29 | loop.run_until_complete(main())
30 | ```
31 | 在这里我们使用 `aiohttp` 来爬取了我的个人博客,获得了源码和响应状态码并输出,运行结果如下:
32 | ```java
33 | html:
34 |
35 |
36 |
37 |
189 | body: {
190 | "args": {},
191 | "data": "",
192 | "files": {},
193 | "form": {
194 | "age": "25",
195 | "name": "germey"
196 | },
197 | "headers": {
198 | "Accept": "*/*",
199 | "Accept-Encoding": "gzip, deflate",
200 | "Content-Length": "18",
201 | "Content-Type": "application/x-www-form-urlencoded",
202 | "Host": "httpbin.org",
203 | "User-Agent": "Python/3.7 aiohttp/3.6.2",
204 | "X-Amzn-Trace-Id": "Root=1-5e85f2f1-f55326ff5800b15886c8e029"
205 | },
206 | "json": null,
207 | "origin": "17.20.255.58",
208 | "url": "https://httpbin.org/post"
209 | }
210 | 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'
211 | 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'}
212 | ```
213 | 这里我们可以看到有些字段前面需要加 await,有的则不需要。其原则是,如果其返回的是一个 coroutine 对象(如 async 修饰的方法),那么前面就要加 await,具体可以看 aiohttp 的 API,其链接为:https://docs.aiohttp.org/en/stable/client_reference.html。
214 |
215 | #### 超时设置
216 | 对于超时的设置,我们可以借助于 `ClientTimeout` 对象,比如这里我要设置 1 秒的超时,可以这么来实现:
217 | ```java
218 | import aiohttp
219 | import asyncio
220 |
221 | async def main():
222 | timeout = aiohttp.ClientTimeout(total=1)
223 | async with aiohttp.ClientSession(timeout=timeout) as session:
224 | async with session.get('https://httpbin.org/get') as response:
225 | print('status:', response.status)
226 | if __name__ == '__main__':
227 | asyncio.get_event_loop().run_until_complete(main())
228 | ```
229 | 如果在 1 秒之内成功获取响应的话,运行结果如下:
230 | ```java
231 | 200
232 | ```
233 | 如果超时的话,会抛出 TimeoutError 异常,其类型为 `asyncio.TimeoutError`,我们再进行异常捕获即可。
234 |
235 | 另外 ClientTimeout 对象声明时还有其他参数,如 connect、socket_connect 等,详细说明可以参考官方文档:https://docs.aiohttp.org/en/stable/client_quickstart.html#timeouts。
236 |
237 | #### 并发限制
238 | 由于 `aiohttp` 可以支持非常大的并发,比如上万、十万、百万都是能做到的,但这么大的并发量,目标网站是很可能在短时间内无法响应的,而且很可能瞬时间将目标网站爬挂掉。所以我们**需要控制一下爬取的并发量**。
239 |
240 | 在一般情况下,我们可以借助于 `asyncio` 的 `Semaphore` 来控制并发量,代码示例如下:
241 | ```java
242 | import asyncio
243 | import aiohttp
244 |
245 | CONCURRENCY = 5
246 | URL = 'https://www.baidu.com'
247 | semaphore = asyncio.Semaphore(CONCURRENCY)
248 | session = None
249 |
250 | async def scrape_api():
251 | async with semaphore:
252 | print('scraping', URL)
253 | async with session.get(URL) as response:
254 | await asyncio.sleep(1)
255 | return await response.text()
256 |
257 | async def main():
258 | global session
259 | session = aiohttp.ClientSession()
260 | scrape_index_tasks = [asyncio.ensure_future(scrape_api()) for _ in range(10000)]
261 | await asyncio.gather(*scrape_index_tasks)
262 |
263 | if __name__ == '__main__':
264 | asyncio.get_event_loop().run_until_complete(main())
265 | ```
266 | 在这里我们声明了 `CONCURRENCY` 代表爬取的最大并发量为 5,同时声明爬取的目标 URL 为百度。接着我们借助于 Semaphore 创建了一个信号量对象,赋值为 semaphore,这样我们就可以用它来控制最大并发量了。怎么使用呢?我们这里把它直接放置在对应的爬取方法里面,使用 async with 语句将 semaphore 作为上下文对象即可。这样的话,信号量可以控制进入爬取的最大协程数量,最大数量就是我们声明的 CONCURRENCY 的值。
267 |
268 | 在 main 方法里面,我们声明了 10000 个 task,传递给 gather 方法运行。**倘若不加以限制,这 10000 个 task 会被同时执行,并发数量太大。但有了信号量的控制之后,同时运行的 task 的数量最大会被控制在 5 个,这样就能给 aiohttp 限制速度了**。
269 |
270 | 在这里,aiohttp 的基本使用就介绍这么多,更详细的内容还是推荐你到官方文档查阅,链接:https://docs.aiohttp.org/。
271 |
272 | #### 爬取实战
273 | 上面我们介绍了 `aiohttp` 的基本用法之后,下面我们来根据一个实例实现异步爬虫的实战演练吧。
274 |
275 | 本次我们要爬取的网站是:https://dynamic5.scrape.cuiqingcai.com/,页面如图所示。
276 | 
277 | 这是一个书籍网站,整个网站包含了数千本书籍信息,网站是 JavaScript 渲染的,数据可以通过 Ajax 接口获取到,并且接口没有设置任何反爬措施和加密参数,另外由于这个网站比之前的电影案例网站数据量大一些,所以更加适合做异步爬取。
278 |
279 | 本课时我们要完成的目标有:
280 |
281 | * 使用 ·aiohttp· 完成全站的书籍数据爬取。
282 | * 将数据通过异步的方式保存到 MongoDB 中。
283 |
284 | 在本课时开始之前,请确保你已经做好了如下准备工作:
285 |
286 | * 安装好了 Python(最低为 Python 3.6 版本,最好为 3.7 版本或以上),并能成功运行 Python 程序。
287 | * 了解了 Ajax 爬取的一些基本原理和模拟方法。
288 | * 了解了异步爬虫的基本原理和 asyncio 库的基本用法。
289 | * 了解了 aiohttp 库的基本用法。
290 | * 安装并成功运行了 MongoDB 数据库,并安装了异步存储库 motor。
291 |
292 | 注:这里要实现 MongoDB 异步存储,需要异步 MongoDB 存储库,叫作 motor,安装命令为:`pip3 install motor`
293 |
294 | #### 页面分析
295 | 在之前我们讲解了 Ajax 的基本分析方法,本课时的站点结构和之前 Ajax 分析的站点结构类似,都是列表页加详情页的结构,加载方式都是 Ajax,所以我们能轻松分析到如下信息:
296 |
297 | * 列表页的 Ajax 请求接口格式为:https://dynamic5.scrape.cuiqingcai.com/api/book/?limit=18&offset={offset},limit 的值即为每一页的书的个数,offset 的值为每一页的偏移量,其计算公式为 offset = limit * (page - 1) ,如第 1 页 offset 的值为 0,第 2 页 offset 的值为 18,以此类推。
298 | * 列表页 Ajax 接口返回的数据里 results 字段包含当前页 18 本书的信息,其中每本书的数据里面包含一个字段 id,这个 id 就是书本身的 ID,可以用来进一步请求详情页。
299 | 详情页的 Ajax 请求接口格式为:https://dynamic5.scrape.cuiqingcai.com/api/book/{id},id 即为书的 ID,可以从列表页的返回结果中获取。
300 | 如果你掌握了 Ajax 爬取实战一课时的内容话,上面的内容应该很容易分析出来。如有难度,可以复习下之前的知识。
301 |
302 | #### 实现思路
303 | 其实一个完善的异步爬虫应该能够充分利用资源进行全速爬取,其思路是维护一个动态变化的爬取队列,每产生一个新的 task 就会将其放入队列中,有专门的爬虫消费者从队列中获取 task 并执行,能做到在最大并发量的前提下充分利用等待时间进行额外的爬取处理。
304 |
305 | 但上面的实现思路整体较为烦琐,需要设计爬取队列、回调函数、消费者等机制,需要实现的功能较多。由于我们刚刚接触 aiohttp 的基本用法,本课时也主要是了解 aiohttp 的实战应用,所以这里我们将爬取案例的实现稍微简化一下。
306 |
307 | 在这里我们将爬取的逻辑拆分成两部分,第一部分为爬取列表页,第二部分为爬取详情页。由于异步爬虫的关键点在于并发执行,所以我们可以将爬取拆分为两个阶段:
308 |
309 | 第一阶段为所有列表页的异步爬取,我们可以将所有的列表页的爬取任务集合起来,声明为 task 组成的列表,进行异步爬取。
310 | 第二阶段则是拿到上一步列表页的所有内容并解析,拿到所有书的 id 信息,组合为所有详情页的爬取任务集合,声明为 task 组成的列表,进行异步爬取,同时爬取的结果也以异步的方式存储到 MongoDB 里面。
311 | 因为两个阶段的拆分之后需要串行执行,所以可能不能达到协程的最佳调度方式和资源利用情况,但也差不了很多。但这个实现思路比较简单清晰,代码实现也比较简单,能够帮我们快速了解 aiohttp 的基本使用。
312 |
313 | #### 基本配置
314 | 首先我们先配置一些基本的变量并引入一些必需的库,代码如下:
315 | ```java
316 | import asyncio
317 | import aiohttp
318 | import logging
319 |
320 | logging.basicConfig(level=logging.INFO,
321 | format='%(asctime)s - %(levelname)s: %(message)s')
322 |
323 | INDEX_URL = 'https://dynamic5.scrape.cuiqingcai.com/api/book/?limit=18&offset={offset}'
324 | DETAIL_URL = 'https://dynamic5.scrape.cuiqingcai.com/api/book/{id}'
325 | PAGE_SIZE = 18
326 | PAGE_NUMBER = 100
327 | CONCURRENCY = 5
328 | ```
329 | 在这里我们导入了 asyncio、aiohttp、logging 这三个库,然后定义了 logging 的基本配置。接着定义了 URL、爬取页码数量 PAGE_NUMBER、并发量 CONCURRENCY 等信息。
330 |
331 | #### 爬取列表页
332 | 首先,第一阶段我们就来爬取列表页,还是和之前一样,我们先定义一个通用的爬取方法,代码如下:
333 | ```java
334 | semaphore = asyncio.Semaphore(CONCURRENCY)
335 | session = None
336 | async def scrape_api(url):
337 | async with semaphore:
338 | try:
339 | logging.info('scraping %s', url)
340 | async with session.get(url) as response:
341 | return await response.json()
342 | except aiohttp.ClientError:
343 | logging.error('error occurred while scraping %s', url, exc_info=True)
344 | ```
345 |
346 | 在这里我们声明了一个信号量,用来控制最大并发数量。
347 |
348 | 接着我们定义了 scrape_api 方法,该方法接收一个参数 url。首先使用 async with 引入信号量作为上下文,接着调用了 session 的 get 方法请求这个 url,然后返回响应的 JSON 格式的结果。另外这里还进行了异常处理,捕获了 ClientError,如果出现错误,会输出异常信息。
349 |
350 | 接着,对于列表页的爬取,实现如下:
351 |
352 | ```java
353 | async def scrape_index(page):
354 | url = INDEX_URL.format(offset=PAGE_SIZE * (page - 1))
355 | return await scrape_api(url)
356 | ```
357 | 这里定义了一个 scrape_index 方法用于爬取列表页,它接收一个参数为 page,然后构造了列表页的 URL,将其传给 scrape_api 方法即可。这里注意方法同样需要用 async 修饰,调用的 scrape_api 方法前面需要加 await,因为 scrape_api 调用之后本身会返回一个 coroutine。另外由于 scrape_api 返回结果就是 JSON 格式,因此 scrape_index 的返回结果就是我们想要爬取的信息,不需要再额外解析了。
358 |
359 | 好,接着我们定义一个 main 方法,将上面的方法串联起来调用一下,实现如下:
360 | ```java
361 | import json
362 | async def main():
363 | global session
364 | session = aiohttp.ClientSession()
365 | scrape_index_tasks = [asyncio.ensure_future(scrape_index(page)) for page in range(1, PAGE_NUMBER + 1)]
366 | results = await asyncio.gather(*scrape_index_tasks)
367 | logging.info('results %s', json.dumps(results, ensure_ascii=False, indent=2))
368 |
369 | if __name__ == '__main__':
370 | asyncio.get_event_loop().run_until_complete(main())
371 | ```
372 | 这里我们首先声明了 session 对象,即最初声明的全局变量,将 session 作为全局变量的话我们就不需要每次在各个方法里面传递了,实现比较简单。
373 |
374 | 接着我们定义了 scrape_index_tasks,它就是爬取列表页的所有 task,接着我们调用 asyncio 的 gather 方法并传入 task 列表,将结果赋值为 results,它是所有 task 返回结果组成的列表。
375 |
376 | 最后我们调用 main 方法,使用事件循环启动该 main 方法对应的协程即可。
377 |
378 | 运行结果如下:
379 | ```java
380 | 2020-04-03 03:45:54,692 - INFO: scraping https://dynamic5.scrape.cuiqingcai.com/api/book/?limit=18&offset=0
381 | 2020-04-03 03:45:54,707 - INFO: scraping https://dynamic5.scrape.cuiqingcai.com/api/book/?limit=18&offset=18
382 | 2020-04-03 03:45:54,707 - INFO: scraping https://dynamic5.scrape.cuiqingcai.com/api/book/?limit=18&offset=36
383 | 2020-04-03 03:45:54,708 - INFO: scraping https://dynamic5.scrape.cuiqingcai.com/api/book/?limit=18&offset=54
384 | 2020-04-03 03:45:54,708 - INFO: scraping https://dynamic5.scrape.cuiqingcai.com/api/book/?limit=18&offset=72
385 | 2020-04-03 03:45:56,431 - INFO: scraping https://dynamic5.scrape.cuiqingcai.com/api/book/?limit=18&offset=90
386 | 2020-04-03 03:45:56,435 - INFO: scraping https://dynamic5.scrape.cuiqingcai.com/api/book/?limit=18&offset=108
387 | ```
388 | 可以看到这里就开始异步爬取了,并发量是由我们控制的,目前为 5,当然也可以进一步调高并发量,在网站能承受的情况下,爬取速度会进一步加快。
389 |
390 | 最后 results 就是所有列表页得到的结果,我们将其赋值为 results 对象,接着我们就可以用它来进行第二阶段的爬取了。
391 |
392 | #### 爬取详情页
393 | 第二阶段就是爬取详情页并保存数据了,由于每个详情页对应一本书,每本书需要一个 ID,而这个 ID 又正好存在 results 里面,所以下面我们就需要将所有详情页的 ID 获取出来。
394 |
395 | 在 main 方法里增加 results 的解析代码,实现如下:
396 | ```java
397 | ids = []
398 | for index_data in results:
399 | if not index_data: continue
400 | for item in index_data.get('results'):
401 | ids.append(item.get('id'))
402 | ```
403 | 这样 ids 就是所有书的 id 了,然后我们用所有的 id 来构造所有详情页对应的 task,来进行异步爬取即可。
404 |
405 | 那么这里再定义一个爬取详情页和保存数据的方法,实现如下:
406 |
407 | ```java
408 | from motor.motor_asyncio import AsyncIOMotorClient
409 | MONGO_CONNECTION_STRING = 'mongodb://localhost:27017'
410 | MONGO_DB_NAME = 'books'
411 | MONGO_COLLECTION_NAME = 'books'
412 | client = AsyncIOMotorClient(MONGO_CONNECTION_STRING)
413 | db = client[MONGO_DB_NAME]
414 | collection = db[MONGO_COLLECTION_NAME]
415 | async def save_data(data):
416 | logging.info('saving data %s', data)
417 | if data:
418 | return await collection.update_one({
419 | 'id': data.get('id')
420 | }, {
421 | '$set': data
422 | }, upsert=True)
423 | async def scrape_detail(id):
424 | url = DETAIL_URL.format(id=id)
425 | data = await scrape_api(url)
426 | await save_data(data)
427 | ```
428 |
429 | 这里我们定义了 scrape_detail 方法用于爬取详情页数据并调用 save_data 方法保存数据,save_data 方法用于将数据库保存到 MongoDB 里面。
430 |
431 | 在这里我们用到了支持异步的 MongoDB 存储库 motor,MongoDB 的连接声明和 pymongo 是类似的,保存数据的调用方法也是基本一致,不过整个都换成了异步方法。
432 |
433 | 好,接着我们就在 main 方法里面增加 scrape_detail 方法的调用即可,实现如下:
434 |
435 | ```java
436 | scrape_detail_tasks = [asyncio.ensure_future(scrape_detail(id)) for id in ids]
437 | await asyncio.wait(scrape_detail_tasks)
438 | await session.close()
439 | ```
440 |
441 | 在这里我们先声明了 scrape_detail_tasks,即所有详情页的爬取 task 组成的列表,接着调用了 asyncio 的 wait 方法调用执行即可,当然这里也可以用 gather 方法,效果是一样的,只不过返回结果略有差异。最后全部执行完毕关闭 session 即可。
442 |
443 | 一些详情页的爬取过程运行如下:
444 |
445 | ```java
446 | 2020-04-03 04:00:32,576 - INFO: scraping https://dynamic5.scrape.cuiqingcai.com/api/book/2301475
447 | 2020-04-03 04:00:32,576 - INFO: scraping https://dynamic5.scrape.cuiqingcai.com/api/book/2351866
448 | 2020-04-03 04:00:32,577 - INFO: scraping https://dynamic5.scrape.cuiqingcai.com/api/book/2828384
449 | 2020-04-03 04:00:32,577 - INFO: scraping https://dynamic5.scrape.cuiqingcai.com/api/book/3040352
450 | 2020-04-03 04:00:32,578 - INFO: scraping https://dynamic5.scrape.cuiqingcai.com/api/book/3074810
451 | 2020-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'}
452 | 2020-04-03 04:00:44,859 - INFO: scraping https://dynamic5.scrape.cuiqingcai.com/api/book/2994915
453 | ...
454 | ```
455 | 最后我们观察下,爬取到的数据也都保存到 MongoDB 数据库里面了,如图所示:
456 |
457 |
458 |
459 | 至此,我们就使用 aiohttp 完成了书籍网站的异步爬取。
460 |
461 | #### 总结
462 | 本课时的内容较多,我们了解了 aiohttp 的基本用法,然后通过一个实例讲解了 aiohttp 异步爬虫的具体实现。学习过程我们可以发现,相比普通的单线程爬虫来说,使用异步可以大大提高爬取效率,后面我们也可以多多使用。
463 |
464 |
465 |
466 |
467 |
--------------------------------------------------------------------------------
/第20讲:代理的基本原理和用法.md:
--------------------------------------------------------------------------------
1 | 我们在做爬虫的过程中经常会遇到这样的情况,最初爬虫正常运行,正常抓取数据,一切看起来都是那么的美好,然而一杯茶的功夫可能就会出现错误,比如 403 Forbidden,这时候打开网页一看,可能会看到 “您的 IP 访问频率太高” 这样的提示,或者跳出一个验证码让我们输入,输入之后才可能解封,但是输入之后过一会儿就又这样了。
2 |
3 | 出现这种现象的原因是网站采取了一些反爬虫的措施,比如服务器会检测某个 IP 在单位时间内的请求次数,如果超过了这个阈值,那么会直接拒绝服务,返回一些错误信息,这种情况可以称之为封 IP,于是乎就成功把我们的爬虫禁掉了。
4 |
5 | 既然服务器检测的是某个 IP 单位时间的请求次数,那么我们借助某种方式来伪装我们的 IP,让服务器识别不出是由我们本机发起的请求,不就可以成功防止封 IP 了吗?所以这时候代理就派上用场了。
6 |
7 | 本课时我们先来看下代理的基本原理和使用代理处理反爬虫的方法。
8 |
9 | #### 基本原理
10 | 代理实际上指的就是代理服务器,英文叫作 proxy server,它的功能是代理网络用户去获取网络信息。形象地说,它是网络信息的中转站。在我们正常请求一个网站时,是发送了请求给 Web 服务器,Web 服务器把响应传回给我们。**如果设置了代理服务器,实际上就是在本机和服务器之间搭建了一个桥,此时本机不是直接向 Web 服务器发起请求,而是向代理服务器发出请求,请求会发送给代理服务器,然后由代理服务器再发送给 Web 服务器,接着由代理服务器再把 Web 服务器返回的响应转发给本机**。这样我们同样可以正常访问网页,但这个过程中 Web 服务器识别出的真实 IP 就不再是我们本机的 IP 了,就成功实现了 IP 伪装,这就是代理的基本原理。
11 |
12 | #### 代理的作用
13 | 那么,代理有什么作用呢?我们可以简单列举如下。
14 |
15 | * 突破自身 IP 访问限制,访问一些平时不能访问的站点。
16 | * 访问一些单位或团体内部资源,如使用教育网内地址段免费代理服务器,就可以用于对教育网开放的各类 FTP 下载上传,以及各类资料查询共享等服务。
17 | * 提高访问速度,通常代理服务器都设置一个较大的硬盘缓冲区,当有外界的信息通过时,也将其保存到缓冲区中,当其他用户再访问相同的信息时, 则直接由缓冲区中取出信息,传给用户,以提高访问速度。
18 | * 隐藏真实 IP,上网者也可以通过这种方法隐藏自己的 IP,免受攻击,对于爬虫来说,我们用代理就是为了隐藏自身 IP,防止自身的 IP 被封锁。
19 | #### 爬虫代理
20 | 对于爬虫来说,由于爬虫爬取速度过快,在爬取过程中可能遇到同一个 IP 访问过于频繁的问题,此时网站就会让我们输入验证码登录或者直接封锁 IP,这样会给爬取带来极大的不便。
21 |
22 | **使用代理隐藏真实的 IP,让服务器误以为是代理服务器在请求自己。这样在爬取过程中通过不断更换代理,就不会被封锁,可以达到很好的爬取效果**。
23 |
24 | #### 代理分类
25 | 代理分类时,既可以**根据协议**区分,也可以根据其匿名程度区分,下面分别总结如下:
26 |
27 | ##### 根据协议区分
28 | 根据代理的协议,代理可以分为如下类别:
29 |
30 | * FTP 代理服务器,主要用于访问 FTP 服务器,一般有上传、下载以及缓存功能,端口一般为 21、2121 等。
31 | * HTTP 代理服务器,主要用于访问网页,一般有内容过滤和缓存功能,端口一般为 80、8080、3128 等。
32 | * SSL/TLS 代理,主要用于访问加密网站,一般有 SSL 或 TLS 加密功能(最高支持 128 位加密强度),端口一般为 443。
33 | * RTSP 代理,主要用于 Realplayer 访问 Real 流媒体服务器,一般有缓存功能,端口一般为 554。
34 | * Telnet 代理,主要用于 telnet 远程控制(黑客入侵计算机时常用于隐藏身份),端口一般为 23。
35 | POP3/SMTP 代理,主要用于 POP3/SMTP 方式收发邮件,一般有缓存功能,端口一般为 110/25。
36 | * SOCKS 代理,只是单纯传递数据包,不关心具体协议和用法,所以速度快很多,一般有缓存功能,端口一般为 1080。SOCKS 代理协议又分为 SOCKS4 和 SOCKS5,SOCKS4 协议只支持 TCP,而 SOCKS5 协议支持 TCP 和 UDP,还支持各种身份验证机制、服务器端域名解析等。简单来说,SOCK4 能做到的 SOCKS5 都可以做到,但 SOCKS5 能做到的 SOCK4 不一定能做到。
37 | ##### 根据匿名程度区分
38 | 根据代理的匿名程度,代理可以分为如下类别。
39 |
40 | * 高度匿名代理,高度匿名代理会将数据包原封不动的转发,在服务端看来就好像真的是一个普通客户端在访问,而记录的 IP 是代理服务器的 IP。
41 | * 普通匿名代理,普通匿名代理会在数据包上做一些改动,服务端上有可能发现这是个代理服务器,也有一定几率追查到客户端的真实 IP。代理服务器通常会加入的 HTTP 头有 HTTP_VIA 和 HTTP_X_FORWARDED_FOR。
42 | * 透明代理,透明代理不但改动了数据包,还会告诉服务器客户端的真实 IP。这种代理除了能用缓存技术提高浏览速度,能用内容过滤提高安全性之外,并无其他显著作用,最常见的例子是内网中的硬件防火墙。
43 | * 间谍代理,间谍代理指组织或个人创建的,用于记录用户传输的数据,然后进行研究、监控等目的的代理服务器。
44 | #### 常见代理类型
45 | * **使用网上的免费代理,最好使用高匿代理**,使用前抓取下来筛选一下可用代理,也可以进一步维护一个代理池。
46 | * 使用付费代理服务,互联网上存在许多代理商,可以付费使用,质量比免费代理好很多。
47 | * ADSL 拨号,拨一次号换一次 IP,稳定性高,也是一种比较有效的解决方案。
48 | * **蜂窝代理**,即用 4G 或 5G 网卡等制作的代理,由于蜂窝网络用作代理的情形较少,因此整体被封锁的几率会较低,但搭建蜂窝代理的成本较高。
49 | #### 代理设置
50 | 在前面我们介绍了多种请求库,如 Requests、Selenium、Pyppeteer 等。我们接下来首先贴近实战,了解一下代理怎么使用,为后面了解代理池打下基础。
51 |
52 | 下面我们来梳理一下这些库的代理的设置方法。
53 |
54 | 做测试之前,我们需要先获取一个可用代理。搜索引擎搜索 “代理” 关键字,就可以看到许多代理服务网站,网站上会有很多免费或付费代理,比如免费代理“快代理”:https://www.kuaidaili.com/free/。但是这些免费代理大多数情况下都是不好用的,所以比较靠谱的方法是购买付费代理。付费代理各大代理商家都有套餐,数量不用多,稳定可用即可,我们可以自行选购。
55 |
56 | 如果本机有相关代理软件的话,软件一般会在本机创建 HTTP 或 SOCKS 代理服务,本机直接使用此代理也可以。
57 |
58 | 在这里,我的本机安装了一部代理软件,它会在本地的 7890 端口上创建 HTTP 代理服务,即代理为127.0.0.1:7890,另外还会在 7891 端口创建 SOCKS 代理服务,即代理为 127.0.0.1:7891。
59 |
60 | 我只要设置了这个代理,就可以成功将本机 IP 切换到代理软件连接的服务器的 IP 了。下面的示例里,我将使用上述代理来演示其设置方法,你也可以自行替换成自己的可用代理。设置代理后测试的网址是:http://httpbin.org/get,我们访问该网址可以得到请求的相关信息,其中 origin 字段就是客户端的 IP,我们可以根据它来判断代理是否设置成功,即是否成功伪装了 IP。
61 |
62 | #### requests 设置代理
63 | 对于 requests 来说,代理设置非常简单,我们只需要传入 proxies 参数即可。
64 |
65 | 我在这里以我本机的代理为例,来看下 requests 的 HTTP 代理的设置,代码如下:
66 |
67 | ```python
68 | import requests
69 | proxy = '127.0.0.1:7890'
70 | proxies = {
71 | 'http': 'http://' + proxy,
72 | 'https': 'https://' + proxy,
73 | }
74 | try:
75 | response = requests.get('https://httpbin.org/get', proxies=proxies)
76 | print(response.text)
77 | except requests.exceptions.ConnectionError as e:
78 | print('Error', e.args)
79 | 运行结果:
80 | {
81 | "args": {},
82 | "headers": {
83 | "Accept": "*/*",
84 | "Accept-Encoding": "gzip, deflate",
85 | "Host": "httpbin.org",
86 | "User-Agent": "python-requests/2.22.0",
87 | "X-Amzn-Trace-Id": "Root=1-5e8f358d-87913f68a192fb9f87aa0323"
88 | },
89 | "origin": "210.173.1.204",
90 | "url": "https://httpbin.org/get"
91 | }
92 | ```
93 | 可以发现,我们通过一个字典的形式就设置好了 HTTP 代理,它分为两个类别,有 HTTP 和 HTTPS,如果我们访问的链接是 HTTP 协议,那就用 http 字典名指定的代理,如果是 HTTPS 协议,那就用 https 字典名指定的代理。
94 |
95 | 其运行结果的 origin 如是代理服务器的 IP,则证明代理已经设置成功。
96 |
97 | 如果代理需要认证,同样在代理的前面加上用户名密码即可,代理的写法就变成如下所示:
98 |
99 | ```python
100 | proxy = 'username:password@127.0.0.1:7890'
101 | ```
102 | 这里只需要将 username 和 password 替换即可。
103 |
104 | 如果需要使用 SOCKS 代理,则可以使用如下方式来设置:
105 | ```python
106 | import requests
107 | proxy = '127.0.0.1:7891'
108 | proxies = {
109 | 'http': 'socks5://' + proxy,
110 | 'https': 'socks5://' + proxy
111 | }
112 | try:
113 | response = requests.get('https://httpbin.org/get', proxies=proxies)
114 | print(response.text)
115 | except requests.exceptions.ConnectionError as e:
116 | print('Error', e.args)
117 | ```
118 | 在这里,我们需要额外安装一个包,这个包叫作 requests[socks],安装命令如下所示:
119 | ```python
120 | pip3 install "requests[socks]"
121 | ```
122 | 运行结果是完全相同的:
123 | ```python
124 | {
125 | "args": {},
126 | "headers": {
127 | "Accept": "*/*",
128 | "Accept-Encoding": "gzip, deflate",
129 | "Host": "httpbin.org",
130 | "User-Agent": "python-requests/2.22.0",
131 | "X-Amzn-Trace-Id": "Root=1-5e8f364a-589d3cf2500fafd47b5560f2"
132 | },
133 | "origin": "210.173.1.204",
134 | "url": "https://httpbin.org/get"
135 | }
136 | ```
137 | 另外,还有一种设置方式即使用 socks 模块,也需要像上文一样安装 socks 库。这种设置方法如下所示:
138 | ```python
139 | import requests
140 | import socks
141 | import socket
142 | socks.set_default_proxy(socks.SOCKS5, '127.0.0.1', 7891)
143 | socket.socket = socks.socksocket
144 | try:
145 | response = requests.get('https://httpbin.org/get')
146 | print(response.text)
147 | except requests.exceptions.ConnectionError as e:
148 | print('Error', e.args)
149 | ```
150 | 使用这种方法也可以设置 SOCKS 代理,运行结果完全相同。相比第一种方法,此方法是全局设置。我们可以在不同情况下选用不同的方法。
151 |
152 | #### Selenium 设置代理
153 | Selenium 同样可以设置代理,在这里以 Chrome 为例来介绍下其设置方法。
154 |
155 | 对于无认证的代理,设置方法如下:
156 | ```python
157 | from selenium import webdriver
158 | proxy = '127.0.0.1:7890'
159 | options = webdriver.ChromeOptions()
160 | options.add_argument('--proxy-server=http://' + proxy)
161 | browser = webdriver.Chrome(options=options)
162 | browser.get('https://httpbin.org/get')
163 | print(browser.page_source)
164 | browser.close()
165 | ```
166 | 运行结果如下:
167 | ```python
168 | {
169 | "args": {},
170 | "headers": {
171 | "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",
172 | "Accept-Encoding": "gzip, deflate",
173 | "Accept-Language": "zh-CN,zh;q=0.9",
174 | "Host": "httpbin.org",
175 | "Upgrade-Insecure-Requests": "1",
176 | "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",
177 | "X-Amzn-Trace-Id": "Root=1-5e8f39cd-60930018205fd154a9af39cc"
178 | },
179 | "origin": "210.173.1.204",
180 | "url": "http://httpbin.org/get"
181 | }
182 | ```
183 | 代理设置成功,origin 同样为代理 IP 的地址。
184 |
185 | 如果代理是认证代理,则设置方法相对比较麻烦,设置方法如下所示:
186 |
187 | ```python
188 | from selenium import webdriver
189 | from selenium.webdriver.chrome.options import Options
190 | import zipfile
191 |
192 | ip = '127.0.0.1'
193 | port = 7890
194 | username = 'foo'
195 | password = 'bar'
196 |
197 | manifest_json = """{"version":"1.0.0","manifest_version": 2,"name":"Chrome Proxy","permissions": ["proxy","tabs","unlimitedStorage","storage","","webRequest","webRequestBlocking"],"background": {"scripts": ["background.js"]
198 | }
199 | }
200 | """
201 | background_js = """
202 | var config = {
203 | mode: "fixed_servers",
204 | rules: {
205 | singleProxy: {
206 | scheme: "http",
207 | host: "%(ip) s",
208 | port: %(port) s
209 | }
210 | }
211 | }
212 |
213 | chrome.proxy.settings.set({value: config, scope: "regular"}, function() {});
214 |
215 | function callbackFn(details) {
216 | return {
217 | authCredentials: {username: "%(username) s",
218 | password: "%(password) s"
219 | }
220 | }
221 | }
222 |
223 | chrome.webRequest.onAuthRequired.addListener(
224 | callbackFn,
225 | {urls: [""]},
226 | ['blocking']
227 | )
228 | """ % {'ip': ip, 'port': port, 'username': username, 'password': password}
229 |
230 | plugin_file = 'proxy_auth_plugin.zip'
231 | with zipfile.ZipFile(plugin_file, 'w') as zp:
232 | zp.writestr("manifest.json", manifest_json)
233 | zp.writestr("background.js", background_js)
234 | options = Options()
235 | options.add_argument("--start-maximized")
236 | options.add_extension(plugin_file)
237 | browser = webdriver.Chrome(options=options)
238 | browser.get('https://httpbin.org/get')
239 | print(browser.page_source)
240 | browser.close()
241 | ```
242 | 这里需要在本地创建一个 manifest.json 配置文件和 background.js 脚本来设置认证代理。运行代码之后本地会生成一个 proxy_auth_plugin.zip 文件来保存当前配置。
243 |
244 | 运行结果和上例一致,origin 同样为代理 IP。
245 |
246 | SOCKS 代理的设置也比较简单,把对应的协议修改为 socks5 即可,如无密码认证的代理设置方法为:
247 | ```python
248 | from selenium import webdriver
249 |
250 | proxy = '127.0.0.1:7891'
251 | options = webdriver.ChromeOptions()
252 | options.add_argument('--proxy-server=socks5://' + proxy)
253 | browser = webdriver.Chrome(options=options)
254 | browser.get('https://httpbin.org/get')
255 | print(browser.page_source)
256 | browser.close()
257 | ```
258 | 运行结果是一样的。
259 |
260 | #### aiohttp 设置代理
261 | 对于 aiohttp 来说,我们可以通过 proxy 参数直接设置即可,HTTP 代理设置如下:
262 |
263 | ```python
264 | import asyncio
265 | import aiohttp
266 |
267 | proxy = 'http://127.0.0.1:7890'
268 |
269 | async def main():
270 | async with aiohttp.ClientSession() as session:
271 | async with session.get('https://httpbin.org/get', proxy=proxy) as response:
272 | print(await response.text())
273 |
274 | if __name__ == '__main__':
275 | asyncio.get_event_loop().run_until_complete(main())
276 | ```
277 | 如果代理有用户名密码,像 requests 一样,把 proxy 修改为如下内容:
278 | ```python
279 | proxy = 'http://username:password@127.0.0.1:7890'
280 | ```
281 | 这里只需要将 username 和 password 替换即可。
282 |
283 | 对于 SOCKS 代理,我们需要安装一个支持库,叫作 `aiohttp-socks`,安装命令如下:
284 | ```python
285 | pip3 install aiohttp-socks
286 | ```
287 | 可以借助于这个库的 ProxyConnector 来设置 SOCKS 代理,代码如下:
288 | ```python
289 | import asyncio
290 | import aiohttp
291 | from aiohttp_socks import ProxyConnector
292 |
293 | connector = ProxyConnector.from_url('socks5://127.0.0.1:7891')
294 |
295 | async def main():
296 | async with aiohttp.ClientSession(connector=connector) as session:
297 | async with session.get('https://httpbin.org/get') as response:
298 | print(await response.text())
299 |
300 | if __name__ == '__main__':
301 | asyncio.get_event_loop().run_until_complete(main())
302 | ```
303 | 运行结果是一样的。
304 |
305 | 另外这个库还支持设置 SOCKS4、HTTP 代理以及对应的代理认证,可以参考其官方介绍。
306 |
307 | #### Pyppeteer 设置代理
308 | 对于 Pyppeteer 来说,由于其默认使用的是类似 Chrome 的 Chromium 浏览器,因此设置方法和 Selenium 的 Chrome 是一样的,如 HTTP 无认证代理设置方法都是通过 args 来设置,实现如下:
309 |
310 | ```python
311 | import asyncio
312 | from pyppeteer import launch
313 |
314 | proxy = '127.0.0.1:7890'
315 |
316 | async def main():
317 | browser = await launch({'args': ['--proxy-server=http://' + proxy], 'headless': False})
318 | page = await browser.newPage()
319 | await page.goto('https://httpbin.org/get')
320 | print(await page.content())
321 | await browser.close()
322 |
323 | if __name__ == '__main__':
324 | asyncio.get_event_loop().run_until_complete(main())
325 | ```
326 | 运行结果:
327 | ```python
328 | {
329 | "args": {},
330 | "headers": {
331 | "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8",
332 | "Accept-Encoding": "gzip, deflate, br",
333 | "Accept-Language": "zh-CN,zh;q=0.9",
334 | "Host": "httpbin.org",
335 | "Upgrade-Insecure-Requests": "1",
336 | "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",
337 | "X-Amzn-Trace-Id": "Root=1-5e8f442c-12b1ed7865b049007267a66c"
338 | },
339 | "origin": "210.173.1.204",
340 | "url": "https://httpbin.org/get"
341 | }
342 | ```
343 | 同样可以看到设置成功。
344 |
345 | 对于 SOCKS 代理,也是一样的,只需要将协议修改为 socks5 即可,代码实现如下:
346 | ```python
347 | import asyncio
348 | from pyppeteer import launch
349 |
350 | proxy = '127.0.0.1:7891'
351 |
352 | async def main():
353 | browser = await launch({'args': ['--proxy-server=socks5://' + proxy], 'headless': False})
354 | page = await browser.newPage()
355 | await page.goto('https://httpbin.org/get')
356 | print(await page.content())
357 | await browser.close()
358 |
359 | if __name__ == '__main__':
360 | asyncio.get_event_loop().run_until_complete(main())
361 | ```
362 | 运行结果也是一样的。
363 |
364 | #### 总结
365 | 以上总结了各个库的代理使用方式,以后如果遇到封 IP 的问题,我们就可以轻松通过加代理的方式来解决啦。
366 |
--------------------------------------------------------------------------------
/第22讲:验证码反爬虫的基本原理.md:
--------------------------------------------------------------------------------
1 | 我们在浏览网站的时候经常会遇到各种各样的验证码,在多数情况下这些验证码会出现在登录账号的时候,也可能会出现在访问页面的过程中,严格来说,这些行为都算验证码反爬虫。
2 |
3 | 本课时我们就来介绍下验证码反爬虫的基本原理及常见的验证码和解决方案。
4 |
5 | #### 验证码
6 | 验证码,全称叫作 Completely Automated Public Turing test to tell Computers and Humans Apart,意思是全自动区分计算机和人类的图灵测试,取了它们关键词的首字母变成了 CAPTCHA,它是一种用来区分用户是计算机还是人的公共全自动程序。
7 |
8 | 它有什么用呢?当然很多用处,如:
9 |
10 | * 网站注册的时候加上验证码,可以一定程度上防止恶意大批量注册。
11 | * 网站登录的时候加上验证码,可以一定程度上防止恶意密码爆破。
12 | * 网站在发表评论的时候加上验证码,可以在一定程度上防止恶意灌水。
13 | * 网站在投票的时候加上验证码,可以在一定程度上防止恶意刷票。
14 | * 网站在被频繁访问的时候或者浏览行为不正常的时候,一般可能是遇到了爬虫,可以一定程度上防止爬虫的爬取。
15 |
16 | 总的来说呢,以上的行为都可以称之为验证码反爬虫行为。使用验证码可以防止各种可以用程序模拟的行为。有了验证码,机器要想完全自动化执行就会遇到一些麻烦,当然这个麻烦的大小就取决于验证码的破解难易程度了。
17 |
18 | #### 验证码反爬虫
19 | 那为什么会出现验证码呢?在大多数情形下是因为网站的访问频率过高或者行为异常,或者是为了直接限制某些自动化行为。归类如下:
20 |
21 | 很多情况下,比如登录和注册,这些验证码几乎是必现的,它的目的就是为了限制恶意注册、恶意爆破等行为,这也算反爬的一种手段。
22 | 一些网站遇到访问频率过高的行为的时候,可能会直接弹出一个登录窗口,要求我们登录才能继续访问,此时的验证码就直接和登录表单绑定在一起了,这就算检测到异常之后利用强制登录的方式进行反爬。
23 | 一些较为常规的网站如果遇到访问频率稍高的情形的时候,会主动弹出一个验证码让用户识别并提交,验证当前访问网站的是不是真实的人,用来限制一些机器的行为,实现反爬虫。
24 | 这几种情形都能在一定程度上限制程序的一些自动化行为,因此都可以称之为反爬虫。
25 |
26 | #### 验证码反爬虫的原理
27 | 在模块一的时候,我们已经讲到过 Session 的基本概念了,它是存在于服务端的,用于保存当前用户的会话信息,这个信息对于验证码的机制非常重要。
28 |
29 | 服务端是可以往 Session 对象里面存一些值的,比如我们要生成一个图形验证码,比如 1234 这四个数字的图形验证码。
30 |
31 | 首先客户端要显示某个验证码,这个验证码相关的信息肯定要从服务器端来获取。比如说请求了这个生成验证码的接口,我们要生成一个图形验证码,内容为 1234,这时候服务端会将 1234 这四个数字保存到 Session 对象里面,然后把 1234 这个结果返回给客户端,或者直接把生成好的验证码图形返回也是可以的,客户端会将其呈现出来,用户就能看到验证码的内容了。
32 |
33 | 用户看到验证码之后呢,就会在表单里面输入验证码的内容,点击提交按钮的时候,这些信息就会又发送给服务器,服务器拿着提交的信息和 Session 里面保存的验证码信息后进行对比,如果一致,那就代表验证码输入正确,校验成功,然后就继续放行恢复正常状态。如果不一致,那就代表校验失败,会继续进行校验。
34 |
35 | 目前市面上大多数的验证码都是基于这个机制来实现的,归类如下:
36 |
37 | 对于图形验证码,服务器会把图形的内容保存到 Session,然后将验证码图返回或者客户端自行显示,等用户提交表单之后校验 Session 里验证码的值和用户提交的值。
38 | 对于行为验证码,服务器会做一些计算,把一些 Key、Token 等信息也储存在 Session 里面,用户首先要完成客户端的校验,如果校验成功才能提交表单,当客户端的校验完成之后,客户端会把验证之后计算产生的 Key、Token、Code 等信息发送到服务端,服务端会再做一次校验,如果服务端也校验通过了,那就算真正的通过了。
39 | 对于手机验证码,服务器会预先生成一个验证码的信息,然后会把这个验证码的结果还有要发送的手机号发送给短信发送服务商,让服务商下发验证码给用户,用户再把这个码提交给服务器,服务器判断 Session 里面的验证码和提交的验证码是否一致即可。
40 | 还有很多其他的验证码,其原理基本都是一致的。
41 |
42 | #### 常见验证码
43 | 下面我们再来看看市面上的一些常见的验证码,并简单介绍一些识别思路。
44 |
45 | ##### 图形验证码
46 | 最基本的验证码就是图形验证码了,比如下图。
47 |
48 | 
49 |
50 | 一般来说,识别思路有这么几种:
51 |
52 | * 利用 OCR 识别,比如 Tesserocr 等库,或者直接调用 OCR 接口,如百度、腾讯的,识别效果相比 Tesserocr 更好。
53 | * 打码平台,把验证码发送给打码平台,平台内实现了一些强大的识别算法或者平台背后有人来专门做识别,速度快,省心。
54 | * 深度学习训练,这类验证码也可以使用 CNN 等深度学习模型来训练分类算法,但是如果种类繁多或者写法各异的话,其识别精度会有一些影响。
55 | *
56 | #### 行为验证码
57 |
58 | 现在我们能见到非常多类型的行为验证码,可以说是十分流行了,比如极验、腾讯、网易盾等等都有类似的验证码服务,另外验证的方式也多种多样,如滑动、拖动、点选、逻辑判断等等,如图所示。
59 |
60 | 
61 |
62 | 
63 |
64 | 
65 |
66 | 
67 |
68 | 这里推荐的识别方案有以下几种:
69 |
70 | - 打码平台,这里面很多验证码都是与坐标相关的,我们可以直接将验证码截图发送给打码平台,打码平台背后会有人帮我们找到对应的位置坐标,获取位置坐标之后就可以来模拟了。这时候模拟的方法有两种,一种是模拟行为,使用
71 | Selenium 等实现,模拟完成之后通常能登录或者解锁某个 Session 封锁状态,获取有效 Cookies 即可。另一种是在
72 | JavaScript 层级上模拟,这种难度更高,模拟完了可以直接获取验证码提交的一些 Token 值等内容。
73 | - 深度学习,利用一些图像标注加深度学习的方法同样可以识别验证码,其实主要还是识别位置,有了位置之后同样可以模拟。
74 |
75 | #### 短信、扫码验证码
76 | 另外我们可能遇到一些类似短信、扫码的验证码,这种操作起来就会更加麻烦,一些解决思路如下:
77 |
78 | - 手机号可以不用自己的,可以从某些平台来获取,平台维护了一套手机短信收发系统,填入手机号,并通过 API 获取短信验证码即可。
79 | - 另外也可以购买一些专业的收码设备或者安装一些监听短信的软件,它会有一些机制把一些手机短信信息导出到某个接口或文本或数据库,然后再提取即可。
80 | - 对于扫码验证的情况,如果不用自己的账号,可以把码发送到打码平台,让对方用自己的账号扫码处理,但这种情况多数需要定制,可以去跟平台沟通。另外的方案就涉及到逆向和破解相关的内容了,一般需要逆向手机
81 | App 内的扫码和解析逻辑,然后再模拟,这里就不再展开讲了。
82 |
83 | 基本上验证码都是类似的,其中有一些列举不全,但是基本类别都能大致归类。
84 |
85 | 以上我们就介绍了验证码反爬虫的基本原理和一些验证码识别的思路。在后面的课时我会介绍使用打码平台和深度学习的方式来识别验证码的方案。
86 |
87 |
88 |
89 |
--------------------------------------------------------------------------------
/第24讲:更智能的深度学习处理验证码.md:
--------------------------------------------------------------------------------
1 | 我们在前面讲解了如何使用打码平台来识别验证码,简单高效。但是也有一些缺点,比如效率可能没那么高,准确率也不一定能做到完全可控,并且需要付出一定的费用。
2 |
3 | 本课时我们就来介绍使用深度学习来识别验证码的方法,训练好对应的模型就能更好地对验证码进行识别,并且准确率可控,节省一定的成本。
4 |
5 | 本课时我们以深度学习识别滑块验证码为例来讲解深度学习对于此类验证码识别的实现。
6 |
7 | 滑块验证码是怎样的呢?如图所示,验证码是一张矩形图,图片左侧会出现一个滑块,右侧会出现一个缺口,下侧会出现一个滑轨。左侧的滑块会随着滑轨的拖动而移动,如果能将左侧滑块匹配滑动到右侧缺口处,就算完成了验证。
8 | 
9 |
10 | 由于这种验证码交互形式比较友好,且安全性、美观度上也会更高,像这种类似的验证码也变得越来越流行。另外不仅仅是“极验”,其他很多验证码服务商也推出了类似的验证码服务,如“网易易盾”等,上图所示的就是“网易易盾”的滑动验证码。
11 |
12 | 没错,这种滑动验证码的出现确实让很多网站变得更安全。但是做爬虫的可就苦恼了,如果想采用自动化的方法来绕过这种滑动验证码,关键点在于以下两点:
13 |
14 | * 找出目标缺口的位置。
15 | * 模拟人的滑动轨迹将滑块滑动到缺口处。
16 | 那么问题来了,第一步怎么做呢?
17 |
18 | 接下来我们就来看看如何利用深度学习来实现吧。
19 |
20 | #### 目标检测
21 | 我们的目标就是输入一张图,输出缺口的的位置,所以只需要将这个问题归结成一个深度学习的“目标检测”问题就好了。
22 |
23 | 首先在开始之前简单说下目标检测。什么叫目标检测?顾名思义,就是把我们想找的东西找出来。比如给一张“狗”的图片,如图所示:
24 |
25 | 
26 |
27 | 我们想知道这只狗在哪,它的舌头在哪,找到了就把它们框选出来,这就是目标检测。
28 |
29 | 经过目标检测算法处理之后,我们期望得到的图片是这样的:
30 |
31 | 
32 |
33 | 可以看到这只狗和它的舌头就被框选出来了,这样就完成了一个不错的目标检测。
34 |
35 | 当前做目标检测的算法主要有两个方向,有一阶段式和两阶段式,英文分别叫作 One stage 和 Two stage,简述如下。
36 |
37 | * Two Stage:算法首先生成一系列目标所在位置的候选框,然后再对这些框选出来的结果进行样本分类,即先找出来在哪,然后再分出来是什么,俗话说叫“看两眼”,这种算法有 R-CNN、Fast R-CNN、Faster R-CNN 等,这些算法架构相对复杂,但准确率上有优势。
38 | * One Stage:不需要产生候选框,直接将目标定位和分类的问题转化为回归问题,俗话说叫“看一眼”,这种算法有 YOLO、SSD,这些算法虽然准确率上不及 Two stage,但架构相对简单,检测速度更快。
39 | 所以这次我们选用 One Stage 的有代表性的目标检测算法 YOLO 来实现滑动验证码缺口的识别。
40 |
41 | YOLO,英文全称叫作 You Only Look Once,取了它们的首字母就构成了算法名,目前 YOLO 算法最新的版本是 V3 版本,这里算法的具体流程我们就不过多介绍了,如果你感兴趣可以搜一下相关资料了解下,另外也可以了解下 YOLO V1~V3 版本的不同和改进之处,这里列几个参考链接。
42 |
43 | * YOLO V3 论文:https://pjreddie.com/media/files/papers/YOLOv3.pdf
44 | * YOLO V3 介绍:https://zhuanlan.zhihu.com/p/34997279
45 | * YOLO V1-V3 对比介绍:https://www.cnblogs.com/makefile/p/yolov3.html
46 | #### 数据准备
47 | 回归我们本课时的主题,我们要做的是缺口的位置识别,那么第一步应该做什么呢?
48 |
49 | 我们的目标是要训练深度学习模型,那我们总得需要让模型知道要学点什么东西吧,这次我们做缺口识别,那么我们需要让模型学的就是找到这个缺口在哪里。由于一张验证码图片只有一个缺口,要分类就是一类,所以我们只需要找到缺口位置就行了。
50 |
51 | 好,那模型要学如何找出缺口的位置,就需要我们提供样本数据让模型来学习才行。样本数据怎样的呢?样本数据就得有带缺口的验证码图片以及我们自己标注的缺口位置。只有把这两部分都告诉模型,模型才能去学习。等模型学好了,当我们再给个新的验证码时,就能检测出缺口在哪里了,这就是一个成功的模型。
52 |
53 | OK,那我们就开始准备数据和缺口标注结果吧。
54 |
55 | 数据这里用的是网易盾的验证码,验证码图片可以自行收集,写个脚本批量保存下来就行。标注的工具可以使用 LabelImg,GitHub 链接为:https://github.com/tzutalin/labelImg,利用它我们可以方便地进行检测目标位置的标注和类别的标注,如这里验证码和标注示例如下:
56 |
57 | 
58 |
59 | 标注完了会生成一系列 xml 文件,你需要解析 xml 文件把位置的坐标和类别等处理一下,转成训练模型需要的数据。
60 |
61 | 在这里我已经整理好了我的数据集,完整 GitHub 链接为:https://github.com/Python3WebSpider/DeepLearningSlideCaptcha,我标注了 200 多张图片,然后处理了 xml 文件,变成训练 YOLO 模型需要的数据格式,验证码图片和标注结果见 data/captcha 文件夹。
62 |
63 | 如果要训练自己的数据,数据格式准备见:https://github.com/eriklindernoren/PyTorch-YOLOv3#train-on-custom-dataset
64 |
65 | #### 初始化
66 | 上一步我已经把标注好的数据处理好了,可以直接拿来训练了。
67 |
68 | 由于 YOLO 模型相对比较复杂,所以这个项目我就直接基于开源的 PyTorch-YOLOV3 项目来进行修改了,模型使用的深度学习框架为 PyTorch,具体的 YOLO V3 模型的实现这里不再阐述了。
69 |
70 | 另外推荐使用 GPU 训练,不然拿 CPU 直接训练速度会很慢。我的 GPU 是 P100,几乎十几秒就训练完一轮。
71 |
72 | 下面就直接把代码克隆下来吧。
73 |
74 | 由于本项目我把训练好的模型也放上去了,使用了 Git LFS,所以克隆时间较长,克隆命令如下:
75 |
76 | ```python
77 | git clone https://github.com/Python3WebSpider/DeepLearningSlideCaptcha.git
78 | ```
79 |
80 | 如果想加速克隆,可以暂时先跳过大文件模型下载,可以执行命令:
81 |
82 | ```python
83 | GIT_LFS_SKIP_SMUDGE=1 git clone https://github.com/Python3WebSpider/DeepLearningSlideCaptcha.git
84 | ```
85 |
86 | #### 环境安装
87 | 代码克隆下载之后,我们还需要下载一些预训练模型。
88 |
89 | YOLOV3 的训练要加载预训练模型才能有不错的训练效果,预训练模型下载命令如下:
90 |
91 | ```python
92 | bash prepare.sh
93 | ```
94 |
95 | 执行这个脚本,就能下载 YOLO V3 模型的一些权重文件,包括 yolov3 和 weights,还有 darknet 的 weights,在训练之前我们需要用这些权重文件初始化 YOLO V3 模型。
96 |
97 | 注意:Windows 下建议使用 Git Bash 来运行上述命令。
98 |
99 | 另外还需要安装一些必须的库,如 PyTorch、TensorBoard 等,建议使用 Python 虚拟环境,运行命令如下:
100 |
101 | ```python
102 | pip3 install -r requirements.txt
103 | ```
104 |
105 | 这些库都安装好了之后,就可以开始训练了。
106 |
107 | #### 训练
108 | 本项目已经提供了标注好的数据集,在 data/captcha,可以直接使用。
109 |
110 | 当前数据训练脚本:
111 | ```python
112 | bash train.sh
113 | ```
114 | 实测 P100 训练时长约 15 秒一个 epoch,大约几分钟即可训练出较好效果。
115 |
116 | 训练差不多了,我们便可以使用 TensorBoard 来看看 loss 和 mAP 的变化,运行 TensorBoard:
117 | ```python
118 | tensorboard --logdir='logs' --port=6006 --host 0.0.0.0
119 | ```
120 | loss_1 变化如下:
121 |
122 | 
123 |
124 | val_mAP 变化如下:
125 |
126 | 
127 |
128 | 可以看到 loss 从最初的非常高下降到了很低,准确率也逐渐接近 100%。
129 |
130 | 另外训练过程中还能看到如下的输出结果:
131 |
132 | ```python
133 | [Epoch 99/100, Batch 27/29] ----
134 | +------------+--------------+--------------+--------------+
135 | | Metrics | YOLO Layer 0 | YOLO Layer 1 | YOLO Layer 2 |
136 | +------------+--------------+--------------+--------------+
137 | | grid_size | 14 | 28 | 56 |
138 | | loss | 0.028268 | 0.046053 | 0.043745 |
139 | | x | 0.002108 | 0.005267 | 0.008111 |
140 | | y | 0.004561 | 0.002016 | 0.009047 |
141 | | w | 0.001284 | 0.004618 | 0.000207 |
142 | | h | 0.000594 | 0.000528 | 0.000946 |
143 | | conf | 0.019700 | 0.033624 | 0.025432 |
144 | | cls | 0.000022 | 0.000001 | 0.000002 |
145 | | cls_acc | 100.00% | 100.00% | 100.00% |
146 | | recall50 | 1.000000 | 1.000000 | 1.000000 |
147 | | recall75 | 1.000000 | 1.000000 | 1.000000 |
148 | | precision | 1.000000 | 0.800000 | 0.666667 |
149 | | conf_obj | 0.994271 | 0.999249 | 0.997762 |
150 | | conf_noobj | 0.000126 | 0.000158 | 0.000140 |
151 | +------------+--------------+--------------+--------------+
152 | Total loss 0.11806630343198776
153 | ```
154 | 这里显示了训练过程中各个指标的变化情况,如 loss、recall、precision、confidence 等,分别代表训练过程的损失(越小越好)、召回率(能识别出的结果占应该识别出结果的比例,越高越好)、精确率(识别出的结果中正确的比率,越高越好)、置信度(模型有把握识别对的概率,越高越好),可以作为参考。
155 |
156 | #### 测试
157 | 训练完毕之后会在 checkpoints 文件夹生成 pth 文件,可直接使用模型来预测生成标注结果。
158 |
159 | 如果你没有训练自己的模型的话,这里我已经把训练好的模型放上去了,可以直接使用我训练好的模型来测试。如之前跳过了 Git LFS 文件下载,则可以使用如下命令下载 Git LFS 文件:
160 | ```python
161 | git lfs pull
162 | ```
163 | 此时 checkpoints 文件夹会生成训练好的 pth 文件。
164 |
165 | 测试脚本:
166 | ```python
167 | sh detect.sh
168 | ```
169 | 该脚本会读取 captcha 下的 test 文件夹所有图片,并将处理后的结果输出到 result 文件夹。
170 |
171 | 运行结果样例:
172 |
173 | ```python
174 | Performing object detection:
175 | + Batch 0, Inference Time: 0:00:00.044223
176 | + Batch 1, Inference Time: 0:00:00.028566
177 | + Batch 2, Inference Time: 0:00:00.029764
178 | + Batch 3, Inference Time: 0:00:00.032430
179 | + Batch 4, Inference Time: 0:00:00.033373
180 | + Batch 5, Inference Time: 0:00:00.027861
181 | + Batch 6, Inference Time: 0:00:00.031444
182 | + Batch 7, Inference Time: 0:00:00.032110
183 | + Batch 8, Inference Time: 0:00:00.029131
184 |
185 | Saving images:
186 | (0) Image: 'data/captcha/test/captcha_4497.png'
187 | + Label: target, Conf: 0.99999
188 | (1) Image: 'data/captcha/test/captcha_4498.png'
189 | + Label: target, Conf: 0.99999
190 | (2) Image: 'data/captcha/test/captcha_4499.png'
191 | + Label: target, Conf: 0.99997
192 | (3) Image: 'data/captcha/test/captcha_4500.png'
193 | + Label: target, Conf: 0.99999
194 | (4) Image: 'data/captcha/test/captcha_4501.png'
195 | + Label: target, Conf: 0.99997
196 | (5) Image: 'data/captcha/test/captcha_4502.png'
197 | + Label: target, Conf: 0.99999
198 | (6) Image: 'data/captcha/test/captcha_4503.png'
199 | + Label: target, Conf: 0.99997
200 | (7) Image: 'data/captcha/test/captcha_4504.png'
201 | + Label: target, Conf: 0.99998
202 | (8) Image: 'data/captcha/test/captcha_4505.png'
203 | + Label: target, Conf: 0.99998
204 | ```
205 | 拿几个样例结果看下:
206 |
207 | 
208 | 
209 | 
210 |
211 | 这里我们可以看到,利用训练好的模型我们就成功识别出缺口的位置了,另外程序还会打印输出这个边框的中心点和宽高信息。
212 |
213 | 有了这个边界信息,我们再利用某些手段拖动滑块即可通过验证了,比如可以模拟加速减速过程,或者可以录制人的轨迹再执行都是可以的,由于本课时更多是介绍深度学习识别相关内容,所以关于拖动轨迹不再展开讲解。
214 |
215 | #### 总结
216 | 本课时我们介绍了使用深度学习识别滑动验证码缺口的方法,包括标注、训练、测试等环节都进行了阐述。有了它,我们就能轻松方便地对缺口进行识别了。
217 |
218 | 代码:https://github.com/Python3WebSpider/DeepLearningSlideCaptcha
219 |
--------------------------------------------------------------------------------
/第25讲:你有权限吗?解析模拟登录基本原理.md:
--------------------------------------------------------------------------------
1 | 在很多情况下,一些网站的页面或资源我们通常需要登录才能看到。比如访问 GitHub 的个人设置页面,如果不登录是无法查看的;比如 12306 买票提交订单的页面,如果不登录是无法提交订单的;再比如要发一条微博,如果不登录是无法发送的。
2 |
3 | 我们之前学习的案例都是爬取的无需登录即可访问的站点,但是诸如上面例子的情况非常非常多,那假如我们想要用爬虫来访问这些页面,比如用爬虫修改 GitHub 的个人设置,用爬虫提交购票订单,用爬虫发微博,能做到吗?
4 |
5 | 答案是可以,这里就需要用到一些模拟登录相关的技术了。那么本课时我们就先来了解模拟登录的一些基本原理和实现吧。
6 |
7 | #### 网站登录验证的实现
8 | 我们要实现模拟登录,那就得首先了解网站登录验证的实现。
9 |
10 | 登录一般需要两个内容,用户名和密码,有的网站可能是手机号和验证码,有的是微信扫码,有的是 OAuth 验证等等,但根本上来说,都是把一些可供认证的信息提交给了服务器。
11 |
12 | 比如这里我们就拿用户名和密码来举例吧。用户在一个网页表单里面输入了内容,然后点击登录按钮的一瞬间,浏览器客户端就会向服务器发送一个登录请求,这个请求里面肯定就包含了用户名和密码信息,这时候,服务器需要处理这些信息,然后返回给客户端一个类似“凭证”的东西,有了这个“凭证”以后呢,客户端拿着这个“凭证”再去访问某些需要登录才能查看的页面,服务器自然就能“放行”了,然后返回对应的内容或执行对应的操作就好了。
13 |
14 | 形象地说,我们以登录发微博和买票坐火车这两件事来类比。发微博就好像要坐火车,没票是没法坐火车的吧,要坐火车怎么办呢?当然是先买票了,我们拿钱去火车站买了票,有了票之后,进站口查验一下,没问题就自然能去坐火车了,这个票就是坐火车的“凭证”。
15 |
16 | 发微博也一样,我们有用户名和密码,请求下服务器,获得一个“凭证”,这就相当于买到了火车票,然后在发微博的时候拿着这个“凭证”去请求服务器,服务器校验没问题,自然就把微博发出去了。
17 |
18 | 那么问题来了,这个“凭证“”到底是怎么生成和验证的呢?**目前比较流行的实现方式有两种,一种是基于 Session + Cookies 的验证,一种是基于 JWT(JSON Web Token)的验证,下面我们来介绍下**。
19 |
20 | #### Session 和 Cookies
21 | 我们在模块一了解了 Session 和 Cookies 的基本概念。**简而言之,Session 就是存在服务端的,里面保存了用户此次访问的会话信息,Cookies 则是保存在用户本地浏览器的,它会在每次用户访问网站的时候发送给服务器**,Cookies 会作为 Request Headers 的一部分发送给服务器,服务器根据 Cookies 里面包含的信息判断找出其 Session 对象,不同的 Session 对象里面维持了不同访问用户的状态,服务器可以根据这些信息决定返回 Response 的内容。
22 |
23 | 我们以用户登录的情形来举例,其实不同的网站对于用户的登录状态的实现可能是不同的,但是 Session 和 Cookies 一定是相互配合工作的。
24 |
25 | 梳理如下:
26 |
27 | * 比如,Cookies 里面可能只存了 Session ID 相关信息,服务器能根据 Cookies 找到对应的 Session,用户登录之后,服务器会在对应的 Session 里面标记一个字段,代表已登录状态或者其他信息(如角色、登录时间)等等,这样用户每次访问网站的时候都带着 Cookies 来访问,服务器就能找到对应的 Session,然后看一下 Session 里面的状态是登录状态,就可以返回对应的结果或执行某些操作。
28 | * 当然 Cookies 里面也可能直接存了某些凭证信息。比如说用户在发起登录请求之后,服务器校验通过,返回给客户端的 Response Headers 里面可能带有 Set-Cookie 字段,里面可能就包含了类似凭证的信息,这样客户端会执行 Set Cookie 的操作,将这些信息保存到 Cookies 里面,以后再访问网页时携带这些 Cookies 信息,服务器拿着这里面的信息校验,自然也能实现登录状态检测了。
29 | 以上两种情况几乎能涵盖大部分的 Session 和 Cookies 登录验证的实现,具体的实现逻辑因服务器而异,但 Session 和 Cookies 一定是需要相互配合才能实现的。
30 |
31 | #### JWT
32 | Web 开发技术是一直在发展的,近几年前后端分离的趋势越来越火,很多 Web 网站都采取了前后端分离的技术来实现。而且传统的基于 Session 和 Cookies 的校验也存在一定问题,比如服务器需要维护登录用户的 Session 信息,而且不太方便分布式部署,也不太适合前后端分离的项目。
33 |
34 | 所以,JWT 技术应运而生。JWT,英文全称叫作 JSON Web Token,是为了在网络应用环境间传递声明而执行的一种基于 JSON 的开放标准。**实际上就是每次登录的时候通过一个 Token 字符串来校验登录状态**。
35 |
36 | JWT 的声明一般被用来在身份提供者和服务提供者间传递被认证的用户身份信息,以便于从资源服务器获取资源,也可以增加一些额外的其他业务逻辑所必须的声明信息,所以这个 Token 也可直接被用于认证,也可传递一些额外信息。
37 |
38 | 有了 JWT,一些认证就不需要借助于 Session 和 Cookies 了,服务器也无需维护 Session 信息,减少了服务器的开销。服务器只需要有一个校验 JWT 的功能就好了,同时也可以做到分布式部署和跨语言的支持。
39 |
40 | JWT 通常就是一个加密的字符串,它也有自己的标准,类似下面的这种格式:
41 | ```python
42 | eyJ0eXAxIjoiMTIzNCIsImFsZzIiOiJhZG1pbiIsInR5cCI6IkpXVCIsImFsZyI6IkhTMjU2In0.eyJVc2VySWQiOjEyMywiVXNlck5hbWUiOiJhZG1pbiIsImV4cCI6MTU1MjI4Njc0Ni44Nzc0MDE4fQ.pEgdmFAy73walFonEm2zbxg46Oth3dlT02HR9iVzXa8
43 | ```
44 | 可以发现中间有两个“.”来分割开,可以把它看成是一个三段式的加密字符串。它由三部分构成,分别是 Header、Payload、Signature。
45 |
46 | * Header,声明了 JWT 的签名算法,如 RSA、SHA256 等等,也可能包含 JWT 编号或类型等数据,然后整个信息 Base64 编码即可。
47 | * Payload,通常用来存放一些业务需要但不敏感的信息,如 UserID 等,另外它也有很多默认的字段,如 JWT 签发者、JWT 接受者、JWT 过期时间等等,Base64 编码即可。
48 | * Signature,这个就是一个签名,是把 Header、Payload 的信息用秘钥 secret 加密后形成的,这个 secret 是保存在服务器端的,不能被轻易泄露。这样的话,即使一些 Payload 的信息被篡改,服务器也能通过 Signature 判断出来是非法请求,拒绝服务。
49 |
50 | 这三部分通过“.”组合起来就形成了 JWT 的字符串,就是用户的访问凭证。
51 |
52 | 所以这个登录认证流程也很简单了,用户拿着用户名密码登录,然后服务器生成 JWT 字符串返回给客户端,客户端每次请求都带着这个 JWT 就行了,服务器会自动判断其有效情况,如果有效,那自然就返回对应的数据。但 JWT 的传输就多种多样了,可以放在 Request Headers,也可以放在 URL 里,甚至有的网站也放在 Cookies 里,但总而言之,能传给服务器校验就好了。
53 |
54 | 好,到此为止呢,我们就已经了解了网站登录验证的实现了。
55 |
56 | #### 模拟登录
57 | 好,了解了网站登录验证的实现后,模拟登录自然就有思路了。下面我们也是分两种认证方式来说明。
58 |
59 | ##### Session 和 Cookies
60 | 基于 Session 和 Cookies 的模拟登录,如果我们要用爬虫实现的话,其实最主要的就是把 Cookies 的信息维护好,因为爬虫就相当于客户端浏览器,我们模拟好浏览器做的事情就好了。
61 |
62 | 那一般情况下,模拟登录一般可以怎样实现呢,我们结合之前所讲的技术来总结一下:
63 |
64 | * 第一,如果我们已经在浏览器里面登录了自己的账号,我们要想用爬虫模拟的话,可以直接把 Cookies 复制过来交给爬虫就行了,这也是最省事省力的方式。这就相当于,我们用浏览器手动操作登录了,然后把 Cookies 拿过来放到代码里面,爬虫每次请求的时候把 Cookies 放到 Request Headers 里面,就相当于完全模拟了浏览器的操作,服务器会通过 Cookies 校验登录状态,如果没问题,自然可以执行某些操作或返回某些内容了。
65 | * 第二,如果我们不想有任何手工操作,可以直接使用爬虫来模拟登录过程。登录的过程其实多数也是一个 POST 请求,我们用爬虫提交用户名密码等信息给服务器,服务器返回的 Response Headers 里面可能带了 Set-Cookie 的字段,我们只需要把这些 Cookies 保存下来就行了。所以,最主要的就是把这个过程中的 Cookies 维护好就行了。当然这里可能会遇到一些困难,比如登录过程还伴随着各种校验参数,不好直接模拟请求,也可能网站设置 Cookies 的过程是通过 JavaScript 实现的,所以可能还得仔细分析下其中的一些逻辑,尤其是我们用 requests 这样的请求库进行模拟登录的时候,遇到的问题可能比较多。
66 | * 第三,我们也可以用一些简单的方式来实现模拟登录,即把人在浏览器中手工登录的过程自动化实现,比如我们用 Selenium 或 Pyppeteer 来驱动浏览器模拟执行一些操作,如填写用户名、密码和表单提交等操作,等待登录成功之后,通过 Selenium 或 Pyppeteer 获取当前浏览器的 Cookies 保存起来即可。然后后续的请求可以携带 Cookies 的内容请求,同样也能实现模拟登录。
67 |
68 | 以上介绍的就是一些常用的爬虫模拟登录的方案,其目的就是维护好客户端的 Cookies 信息,然后每次请求都携带好 Cookies 信息就能实现模拟登录了。
69 |
70 | #### JWT
71 | 基于 JWT 的真实情况也比较清晰了,由于 JWT 的这个字符串就是用户访问的凭证,那么模拟登录只需要做到下面几步即可:
72 |
73 | * 第一,模拟网站登录操作的请求,比如携带用户名和密码信息请求登录接口,获取服务器返回结果,这个结果中通常包含 JWT 字符串的信息,保存下来即可。
74 | * 第二,后续的请求携带 JWT 访问即可,一般情况在 JWT 不过期的情况下都能正常访问和执行对应的操作。携带方式多种多样,因网站而异。
75 | * 第三,如果 JWT 过期了,可能需要重复步骤一,重新获取 JWT。
76 |
77 | 当然这个模拟登录的过程也肯定带有其他的一些加密参数,需要根据实际情况具体分析。
78 |
79 | #### 优化方案
80 | 如果爬虫要求爬取的数据量比较大或爬取速度比较快,而网站又有单账号并发限制或者访问状态检测并反爬的话,可能我们的账号就会无法访问或者面临封号的风险了。这时候一般怎么办呢?
81 |
82 | **我们可以使用分流的方案来解决,比如某个网站一分钟之内检测一个账号只能访问三次或者超过三次就封号的话,我们可以建立一个账号池,用多个账号来随机访问或爬取,这样就能数倍提高爬虫的并发量或者降低被封的风险了**。
83 |
84 | 比如在访问某个网站的时候,我们可以准备 100 个账号,然后 100 个账号都模拟登录,把对应的 Cookies 或 JWT 存下来,每次访问的时候随机取一个来访问,由于账号多,所以每个账号被取用的概率也就降下来了,这样就能避免单账号并发过大的问题,也降低封号风险。
85 |
86 | 以上,我们就介绍完了模拟登录的基本原理和实现以及优化方案,希望你可以好好理解。
87 |
--------------------------------------------------------------------------------
/第26讲:模拟登录爬取实战案例.md:
--------------------------------------------------------------------------------
1 | 在上一课时我们了解了网站登录验证和模拟登录的基本原理。网站登录验证主要有两种实现,一种是基于 Session + Cookies 的登录验证,另一种是基于 JWT 的登录验证,那么本课时我们就通过两个实例来分别讲解这两种登录验证的分析和模拟登录流程。
2 |
3 | #### 准备工作
4 | 在本课时开始之前,请你确保已经做好了如下准备工作:
5 |
6 | * 安装好了 Python (最好 3.6 及以上版本)并能成功运行 Python 程序;
7 | * 安装好了 requests 请求库并学会了其基本用法;
8 | * 安装好了 Selenium 库并学会了其基本用法。
9 |
10 | 下面我们就以两个案例为例来分别讲解模拟登录的实现。
11 |
12 | #### 案例介绍
13 | 这里有两个需要登录才能抓取的网站,链接为 https://login2.scrape.cuiqingcai.com/ 和 https://login3.scrape.cuiqingcai.com/,
14 |
15 | 前者是基于 Session + Cookies 认证的网站,后者是基于 JWT 认证的网站。
16 |
17 | 首先看下第一个网站,打开后会看到如图所示的页面。
18 | 
19 |
20 | 它直接跳转到了登录页面,这里用户名和密码都是 admin,我们输入之后登录。
21 |
22 | 登录成功之后,我们便看到了熟悉的电影网站的展示页面,如图所示。
23 | 
24 |
25 | 这个网站是基于传统的 `MVC 模式`开发的,因此也比较适合 Session + Cookies 的认证。
26 |
27 | 第二个网站打开后同样会跳到登录页面,如图所示。
28 | 
29 |
30 | 用户名和密码是一样的,都输入 admin 即可登录。
31 |
32 | 登录之后会跳转到首页,展示了一些书籍信息,如图所示。
33 | 
34 |
35 | 这个页面是前后端分离式的页面,数据的加载都是通过 Ajax 请求后端 API 接口获取,登录的校验是基于 `JWT` 的,同时后端每个 API 都会校验 JWT 是否是有效的,如果无效则不会返回数据。
36 |
37 | 接下来我们就分析这两个案例并实现模拟登录吧。
38 |
39 | #### 案例一
40 | 对于案例一,我们如果要模拟登录,就需要先分析下登录过程究竟发生了什么,首先我们打开 https://login2.scrape.cuiqingcai.com/,
41 |
42 | 然后执行登录操作,查看其登录过程中发生的请求,如图所示。
43 | 
44 |
45 | 这里我们可以看到其登录的瞬间是发起了一个 POST 请求,目标 URL 为 https://login2.scrape.cuiqingcai.com/login,
46 |
47 | 通过表单提交的方式提交了登录数据,包括 username 和 password 两个字段,返回的状态码是 302,Response Headers 的 location 字段是根页面,同时 Response Headers 还包含了 set-cookie 信息,设置了 Session ID。
48 |
49 | 由此我们可以发现,要实现模拟登录,我们只需要模拟这个请求就好了,登录完成之后获取 Response 设置的 Cookies,将 Cookies 保存好,以后后续的请求带上 Cookies 就可以正常访问了。
50 |
51 | 好,那么我们接下来用代码实现一下吧。
52 |
53 | requests 默认情况下每次请求都是独立互不干扰的,比如我们第一次先调用了 post 方法模拟登录,然后紧接着再调用 get 方法请求下主页面,其实这是两个完全独立的请求,第一次请求获取的 Cookies 并不能传给第二次请求,因此说,常规的顺序调用是不能起到模拟登录的效果的。
54 |
55 | 我们先来看一个无效的代码:
56 | ```python
57 | import requests
58 | from urllib.parse import urljoin
59 |
60 | BASE_URL = 'https://login2.scrape.cuiqingcai.com/'
61 | LOGIN_URL = urljoin(BASE_URL, '/login')
62 | INDEX_URL = urljoin(BASE_URL, '/page/1')
63 | USERNAME = 'admin'
64 | PASSWORD = 'admin'
65 |
66 | response_login = requests.post(LOGIN_URL, data={
67 | 'username': USERNAME,
68 | 'password': PASSWORD
69 | })
70 |
71 | response_index = requests.get(INDEX_URL)
72 | print('Response Status', response_index.status_code)
73 | print('Response URL', response_index.url)
74 | ```
75 |
76 | 这里我们先定义了几个基本的 URL 和用户名、密码,接下来分别用 requests 请求了登录的 URL 进行模拟登录,然后紧接着请求了首页来获取页面内容,但是能正常获取数据吗?
77 |
78 | 由于 requests 可以自动处理重定向,我们最后把 Response 的 URL 打印出来,如果它的结果是 INDEX_URL,那么就证明模拟登录成功并成功爬取到了首页的内容。如果它跳回到了登录页面,那就说明模拟登录失败。
79 |
80 | 我们通过结果来验证一下,运行结果如下:
81 |
82 | ```python
83 | Response Status 200
84 | Response URL https://login2.scrape.cuiqingcai.com/login?next=/page/1
85 | ```
86 |
87 | 这里可以看到,其最终的页面 URL 是登录页面的 URL,另外这里也可以通过 response 的 text 属性来验证页面源码,其源码内容就是登录页面的源码内容,由于内容较多,这里就不再输出比对了。
88 |
89 | 总之,这个现象说明我们并没有成功完成模拟登录,这是因为 requests 直接调用 post、get 等方法,每次请求都是一个独立的请求,都相当于是新开了一个浏览器打开这些链接,这两次请求对应的 Session 并不是同一个,因此这里我们模拟了第一个 Session 登录,而这并不能影响第二个 Session 的状态,因此模拟登录也就无效了。
90 | 那么怎样才能实现正确的模拟登录呢?
91 |
92 | 我们知道 Cookies 里面是保存了 Session ID 信息的,刚才也观察到了登录成功后 Response Headers 里面是有 set-cookie 字段,实际上这就是让浏览器生成了 Cookies。
93 |
94 | Cookies 里面包含了 Session ID 的信息,所以只要后续的请求携带这些 Cookies,服务器便能通过 Cookies 里的 Session ID 信息找到对应的 Session,因此服务端对于这两次请求就会使用同一个 Session 了。而因为第一次我们已经完成了模拟登录,所以第一次模拟登录成功后,Session 里面就记录了用户的登录信息,第二次访问的时候,由于是同一个 Session,服务器就能知道用户当前是登录状态,就可以返回正确的结果而不再是跳转到登录页面了。
95 |
96 | 所以,这里的关键就在于两次请求的 Cookies 的传递。所以这里我们可以把第一次模拟登录后的 Cookies 保存下来,在第二次请求的时候加上这个 Cookies 就好了,所以代码可以改写如下:
97 |
98 | ```python
99 | import requests
100 | from urllib.parse import urljoin
101 |
102 | BASE_URL = 'https://login2.scrape.cuiqingcai.com/'
103 | LOGIN_URL = urljoin(BASE_URL, '/login')
104 | INDEX_URL = urljoin(BASE_URL, '/page/1')
105 | USERNAME = 'admin'
106 | PASSWORD = 'admin'
107 |
108 | response_login = requests.post(LOGIN_URL, data={
109 | 'username': USERNAME,
110 | 'password': PASSWORD
111 | }, allow_redirects=False)
112 |
113 | cookies = response_login.cookies
114 | print('Cookies', cookies)
115 |
116 | response_index = requests.get(INDEX_URL, cookies=cookies)
117 | print('Response Status', response_index.status_code)
118 | print('Response URL', response_index.url)
119 | ```
120 |
121 | 由于 requests 可以自动处理重定向,所以模拟登录的过程我们要加上 allow_redirects 参数并设置为 False,使其不自动处理重定向,这里登录之后返回的 Response 我们赋值为 response_login,这样通过调用 response_login 的 cookies 就可以获取到网站的 Cookies 信息了,这里 requests 自动帮我们解析了 Response Headers 的 set-cookie 字段并设置了 Cookies,所以我们不需要手动解析 Response Headers 的内容了,直接使用 response_login 对象的 cookies 属性即可获取 Cookies。
122 |
123 | 好,接下来我们再次用 requests 的 get 方法来请求网站的 INDEX_URL,不过这里和之前不同,get 方法多加了一个参数 cookies,这就是第一次模拟登录完之后获取的 Cookies,这样第二次请求就能携带第一次模拟登录获取的 Cookies 信息了,此时网站会根据 Cookies 里面的 Session ID 信息查找到同一个 Session,校验其已经是登录状态,然后返回正确的结果。
124 |
125 | 这里我们还是输出了最终的 URL,如果其是 INDEX_URL,那就代表模拟登录成功并获取到了有效数据,否则就代表模拟登录失败。
126 |
127 | 我们看下运行结果:
128 | ```python
129 | Cookies ]>
130 | Response Status 200
131 |
132 | Response URL https://login2.scrape.cuiqingcai.com/page/1
133 | ```
134 | 这下就没有问题了,这次我们发现其 URL 就是 INDEX_URL,模拟登录成功了!同时还可以进一步输出 response_index 的 text 属性看下是否获取成功。
135 |
136 | 接下来后续的爬取用同样的方式爬取即可。
137 |
138 | 但是我们发现其实这种实现方式比较烦琐,每次还需要处理 Cookies 并进行一次传递,有没有更简便的方法呢?
139 |
140 | 有的,我们可以直接借助于 requests 内置的 Session 对象来帮我们自动处理 Cookies,使用了 Session 对象之后,requests 会将每次请求后需要设置的 Cookies 自动保存好,并在下次请求时自动携带上去,就相当于帮我们维持了一个 Session 对象,这样就更方便了。
141 |
142 | 所以,刚才的代码可以简化如下:
143 |
144 | ```python
145 | import requests
146 | from urllib.parse import urljoin
147 |
148 | BASE_URL = 'https://login2.scrape.cuiqingcai.com/'
149 | LOGIN_URL = urljoin(BASE_URL, '/login')
150 | INDEX_URL = urljoin(BASE_URL, '/page/1')
151 | USERNAME = 'admin'
152 | PASSWORD = 'admin'
153 |
154 | session = requests.Session()
155 |
156 | response_login = session.post(LOGIN_URL, data={
157 | 'username': USERNAME,
158 | 'password': PASSWORD
159 | })
160 |
161 | cookies = session.cookies
162 | print('Cookies', cookies)
163 |
164 | response_index = session.get(INDEX_URL)
165 | print('Response Status', response_index.status_code)
166 | print('Response URL', response_index.url)
167 | ```
168 | 可以看到,这里我们无需再关心 Cookies 的处理和传递问题,我们声明了一个 Session 对象,然后每次调用请求的时候都直接使用 Session 对象的 post 或 get 方法就好了。
169 |
170 | 运行效果是完全一样的,结果如下:
171 | ```python
172 | Cookies ]>
173 |
174 | Response Status 200
175 | Response URL https://login2.scrape.cuiqingcai.com/page/1
176 | ```
177 | 因此,为了简化写法,这里建议直接使用 Session 对象来进行请求,这样我们就无需关心 Cookies 的操作了,实现起来会更加方便。
178 |
179 | 这个案例整体来说比较简单,但是如果碰上复杂一点的网站,如带有验证码,带有加密参数等等,直接用 requests 并不好处理模拟登录,如果登录不了,那岂不是整个页面都没法爬了吗?那么有没有其他的方式来解决这个问题呢?当然是有的,比如说,**我们可以使用 Selenium 来通过模拟浏览器的方式实现模拟登录,然后获取模拟登录成功后的 Cookies,再把获取的 Cookies 交由 requests 等来爬取就好了**。
180 |
181 | 这里我们还是以刚才的页面为例,我们可以把模拟登录这块交由 Selenium 来实现,后续的爬取交由 requests 来实现,代码实现如下:
182 |
183 | ```python
184 | from urllib.parse import urljoin
185 | from selenium import webdriver
186 | import requests
187 | import time
188 |
189 | BASE_URL = 'https://login2.scrape.cuiqingcai.com/'
190 | LOGIN_URL = urljoin(BASE_URL, '/login')
191 | INDEX_URL = urljoin(BASE_URL, '/page/1')
192 | USERNAME = 'admin'
193 | PASSWORD = 'admin'
194 |
195 | options = webdriver.ChromeOptions()
196 | options.add_argument('--ignore-certificate-errors')
197 | browser = webdriver.Chrome(chrome_options=options)
198 | browser.get(BASE_URL)
199 | browser.find_element_by_css_selector('input[name="username"]').send_keys(USERNAME)
200 | browser.find_element_by_css_selector('input[name="password"]').send_keys(PASSWORD)
201 | browser.find_element_by_css_selector('input[type="submit"]').click()
202 | time.sleep(10)
203 |
204 | # get cookies from selenium
205 | cookies = browser.get_cookies()
206 | print('Cookies', cookies)
207 | browser.close()
208 | ```
209 | 这里我们使用 Selenium 先打开了 Chrome 浏览器,然后跳转到了登录页面,随后模拟输入了用户名和密码,接着点击了登录按钮,这时候我们可以发现浏览器里面就提示登录成功,然后成功跳转到了主页面。
210 |
211 | 这时候,我们通过调用 get_cookies 方法便能获取到当前浏览器所有的 Cookies,这就是模拟登录成功之后的 Cookies,用这些 Cookies 我们就能访问其他的数据了。
212 |
213 | 接下来,我们声明了 requests 的 Session 对象,然后遍历了刚才的 Cookies 并设置到 Session 对象的 cookies 上面去,接着再拿着这个 Session 对象去请求 INDEX_URL,也就能够获取到对应的信息而不会跳转到登录页面了。
214 |
215 | 运行结果如下:
216 |
217 | ```python
218 | Cookies [{'domain': 'login2.scrape.cuiqingcai.com', 'expiry': 1589043753.553155, 'httpOnly': True, 'name': 'sessionid', 'path': '/', 'sameSite': 'Lax', 'secure': False, 'value': 'rdag7ttjqhvazavpxjz31y0tmze81zur'}]
219 |
220 | Response Status 200
221 |
222 | Response URL https://login2.scrape.cuiqingcai.com/page/1
223 | ```
224 | 可以看到这里的模拟登录和后续的爬取也成功了。所以说,如果碰到难以模拟登录的过程,我们也可以使用 Selenium 或 Pyppeteer 等模拟浏览器操作的方式来实现,其目的就是取到登录后的 Cookies,有了 Cookies 之后,我们再用这些 Cookies 爬取其他页面就好了。
225 |
226 | 所以这里我们也可以发现,对于基于 Session + Cookies 验证的网站,模拟登录的核心要点就是获取 Cookies,这个 Cookies 可以被保存下来或传递给其他的程序继续使用。甚至说可以将 Cookies 持久化存储或传输给其他终端来使用。另外,为了提高 Cookies 利用率或降低封号几率,可以搭建一个 Cookies 池实现 Cookies 的随机取用。
227 |
228 | #### 案例二
229 | 对于案例二这种基于 JWT 的网站,其通常都是采用前后端分离式的,前后端的数据传输依赖于 Ajax,**登录验证依赖于 JWT 本身这个 token 的值,如果 JWT 这个 token 是有效的,那么服务器就能返回想要的数据**。
230 |
231 | 下面我们先来在浏览器里面操作登录,观察下其网络请求过程,如图所示。
232 |
233 | 
234 |
235 | 这里我们发现登录时其请求的 URL 为 https://login3.scrape.cuiqingcai.com/api/login,
236 |
237 | 是通过 Ajax 请求的,同时其 Request Body 是 JSON 格式的数据,而不是 Form Data,返回状态码为 200。
238 |
239 | 然后再看下返回结果,如图所示。
240 | 
241 |
242 | 可以看到返回结果是一个 JSON 格式的数据,包含一个 token 字段,其结果为:
243 |
244 | ```python
245 | eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoxLCJ1c2VybmFtZSI6ImFkbWluIiwiZXhwIjoxNTg3ODc3OTQ2LCJlbWFpbCI6ImFkbWluQGFkbWluLmNvbSIsIm9yaWdfaWF0IjoxNTg3ODM0NzQ2fQ.ujEXXAZcCDyIfRLs44i_jdfA3LIp5Jc74n-Wq2udCR8
246 | ```
247 | 这就是我们上一课时所讲的 JWT 的内容,格式是三段式的,通过“.”来分隔。
248 |
249 | 那么有了这个 JWT 之后,后续的数据怎么获取呢?下面我们再来观察下后续的请求内容,如图所示。
250 | 
251 |
252 | 这里我们可以发现,后续获取数据的 Ajax 请求中的 Request Headers 里面就多了一个 Authorization 字段,其结果为 jwt 然后加上刚才的 JWT 的内容,返回结果就是 JSON 格式的数据。
253 |
254 | 
255 |
256 | 没有问题,那模拟登录的整个思路就简单了:
257 | **模拟请求登录结果,带上必要的登录信息,获取 JWT 的结果。**
258 |
259 | 后续的请求在 Request Headers 里面加上 Authorization 字段,值就是 JWT 对应的内容。
260 | 好,接下来我们用代码实现如下:
261 |
262 | ```python
263 | import requests
264 | from urllib.parse import urljoin
265 |
266 | BASE_URL = 'https://login3.scrape.cuiqingcai.com/'
267 | LOGIN_URL = urljoin(BASE_URL, '/api/login')
268 | INDEX_URL = urljoin(BASE_URL, '/api/book')
269 | USERNAME = 'admin'
270 | PASSWORD = 'admin'
271 |
272 | response_login = requests.post(LOGIN_URL, json={
273 | 'username': USERNAME,
274 | 'password': PASSWORD
275 | })
276 | data = response_login.json()
277 | print('Response JSON', data)
278 | jwt = data.get('token')
279 | print('JWT', jwt)
280 |
281 | headers = {
282 | 'Authorization': f'jwt {jwt}'
283 | }
284 | response_index = requests.get(INDEX_URL, params={
285 | 'limit': 18,
286 | 'offset': 0
287 | }, headers=headers)
288 | print('Response Status', response_index.status_code)
289 | print('Response URL', response_index.url)
290 | print('Response Data', response_index.json())
291 | ```
292 |
293 | 这里我们同样是定义了登录接口和获取数据的接口,分别为 LOGIN_URL 和 INDEX_URL,接着通过 post 请求进行了模拟登录,这里提交的数据由于是 JSON 格式,所以这里使用 json 参数来传递。接着获取了返回结果中包含的 JWT 的结果。第二步就可以构造 Request Headers,然后设置 Authorization 字段并传入 JWT 即可,这样就能成功获取数据了。
294 |
295 | 运行结果如下:
296 |
297 | ```python
298 | Response JSON {'token': 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoxLCJ1c2VybmFtZSI6ImFkbWluIiwiZXhwIjoxNTg3ODc4NzkxLCJlbWFpbCI6ImFkbWluQGFkbWluLmNvbSIsIm9yaWdfaWF0IjoxNTg3ODM1NTkxfQ.iUnu3Yhdi_a-Bupb2BLgCTUd5yHL6jgPhkBPorCPvm4'}
299 |
300 | JWT eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoxLCJ1c2VybmFtZSI6ImFkbWluIiwiZXhwIjoxNTg3ODc4NzkxLCJlbWFpbCI6ImFkbWluQGFkbWluLmNvbSIsIm9yaWdfaWF0IjoxNTg3ODM1NTkxfQ.iUnu3Yhdi_a-Bupb2BLgCTUd5yHL6jgPhkBPorCPvm4
301 |
302 | Response Status 200
303 | Response URL https://login3.scrape.cuiqingcai.com/api/book/?limit=18&offset=0
304 | Response Data {'count': 9200, 'results': [{'id': '27135877', 'name': '校园市场:布局未来消费群,决战年轻人市场', 'authors': ['单兴华', '李烨'], 'cover': 'https://img9.doubanio.com/view/subject/l/public/s29539805.jpg', 'score': '5.5'},
305 | ...
306 | {'id': '30289316', 'name': '就算這樣,還是喜歡你,笠原先生', 'authors': ['おまる'], 'cover': 'https://img3.doubanio.com/view/subject/l/public/s29875002.jpg', 'score': '7.5'}]}
307 | ```
308 | 可以看到,这里成功输出了 JWT 的内容,同时最终也获取到了对应的数据,模拟登录成功!
309 |
310 | 类似的思路,如果我们遇到 JWT 认证的网站,也可以通过类似的方式来实现模拟登录。当然可能某些页面比较复杂,需要具体情况具体分析。
311 |
312 | #### 总结
313 | 以上我们就通过两个示例来演示了模拟登录爬取的过程,以后遇到这种情形的时候就可以用类似的思路解决了。
314 |
315 | #### 参考文献
316 | * https://kaiwu.lagou.com/course/courseInfo.htm?sid=&courseId=46#/detail/pc?id=1687
317 |
318 |
319 |
320 |
321 |
--------------------------------------------------------------------------------
/第30讲:App 爬虫是什么?.md:
--------------------------------------------------------------------------------
1 | 前面我们介绍的都是爬取 Web 网页的内容。随着移动互联网的发展,越来越多的企业并没有提供 Web 网页端的服务,而是直接开发了 App,更多更全的信息都是通过 App 来展示的。那么针对 App 我们可以爬取吗?当然可以。
2 |
3 | 我们知道 Web 站点有多种渲染和反爬方式,渲染分为服务端渲染和客户端渲染;反爬也是多种多样,如请求头验证、WebDriver 限制、验证码、字体反爬、封禁 IP、账号验证等等,综合来看 Web 端的反爬虫方案也是多种多样。
4 |
5 | 但 App 的情况略有不同,一般来说,App 的数据通信大都需要依赖独立的服务器,比如请求某个 HTTP 接口来获取数据或做登录校验等。这种通信其实就类似 Web 中的 Ajax,客户端向服务器发起 HTTP 请求,获取到数据之后再做一些处理,数据的格式大多也是 JSON、XML 等,基本不会有 HTML 代码这样的数据。
6 |
7 | 所以说,对于 App 来说,其核心就在于找到这些数据请求到底是怎样的,比如某次 HTTP POST 请求的 URL、Headers、Data 等等,知道了这些,我们就能用程序模拟这个请求过程,从而就能完成爬虫了。
8 |
9 | 那么怎么知道 App 到底在运行过程中发起了什么请求呢?最有效且常见的方式就是抓包了,抓包工具也非常多,比如 Fiddler、Charles、mitmproxy、anyproxy 等等,我们用这些工具抓到 HTTP 请求包,就能看到这个请求的 Method、Headers、Data 等内容了,知道了之后再用程序模拟出来就行了。
10 |
11 | 但是,这个过程中你可能遇到非常多的问题,毕竟 App 的数据也是非常宝贵的,所以一些 App 也添加了各种反爬措施,比如:
12 |
13 | * 这个 App 的请求根本抓不到包,原因可能是 App 本身设置了不走系统代理。
14 | * 对一些 HTTPS 的请求,抓包失败,原因可能是系统或 App 本身设置了 SSL Pining,对 HTTPS 证书进行了校验,代理软件证书校验不通过,拒绝连接。
15 | * 某些包即使抓到了,也发现了其中带了加密参数,比如 sign、token 等等,难以直接用程序模拟。
16 | * 为了破解一些加密参数可能需要对 App 进行逆向,逆向后发现是混淆后的代码,难以分析逻辑。
17 | * 一些 App 为了防止逆向,本身进行了加固,需要对 App 进行脱壳处理才能进行后续操作。
18 | * 一些 App 将核心代码进行编译,形成 so 库,因此可能需要对 so 库进行逆向才能了解其逻辑。
19 | * 一些 App 和其服务器对以上所有的流程进行了风控处理,如果发现有疑似逆向或破解或访问频率等问题,返回一些假数据或拒绝服务,导致爬虫难以进行。
20 |
21 | 随着移动互联网的发展,App 上承载的数据也越来越多,越来越重要,很多厂商为了保护 App 的数据也采取了非常多的手段。因此 App 的爬取和逆向分析也变得越来越难,本课时我们就来梳理一些 App 爬取方案。
22 |
23 | 以下内容针对 Android 平台。
24 |
25 | #### 抓包
26 | 对于多数情况来说,一台 Android 7.0 版本以下的手机,抓一些普通的 App 的请求包还是很容易做到的。
27 |
28 | 抓包的工具有很多,常见的如 `Charles`、`Fiddler`、`mitmproxy` 等。
29 |
30 | 抓包的时候在 PC 端运行抓包软件,抓包软件会开启一个 HTTP 代理服务器,然后手机和 PC 连在同一个局域网内,设置好抓包软件代理的 IP 和端口,另外 PC 和手机都安装抓包软件的证书并设置信任。这样在手机上再打开 App 就能看到 App 在运行过程中发起的请求了。
31 |
32 | 抓包完成之后在抓包软件中定位到具体数据包,查看其详情,了解其请求 Method、URL、Headers、Data,如果这些没有什么加密参数的话,我们用 Python 重写一遍就好了。
33 |
34 | 当然如果遇到抓不到包或者有加密参数的情形,无法直接重写,那就要用到后面介绍的方法了。
35 |
36 | #### 抓不到包
37 | 一些 App 在内部实现的时候对代理加了一些校验,如绕过系统代理直接连接或者检测到了使用了代理,直接拒绝连接。
38 |
39 | 这种情形往往是手机的 HTTP 客户端对系统的网络环境做了一些判断,并修改了一些 HTTP 请求方式,使得数据不走代理,这样抓包软件就没法直接抓包了。
40 |
41 | 另外对于一些非 HTTP 请求的协议,利用常规的抓包软件也可能抓不到包。这里提供一些解决方案。
42 |
43 | #### 强制全局代理
44 | 虽然有些数据包不走代理,但其下层还是基于 TCP 协议的,所以可以将 TCP 数据包重定向到代理服务器。比如软件 ProxyDroid 就可以实现这样的操作,这样我们就能抓到数据包了。
45 |
46 | ProxyDroid:https://github.com/madeye/proxydroid
47 |
48 | ##### 手机代理
49 | 如果不通过 PC 上的抓包软件设置代理,还可以直接在手机上设置抓包软件,这种方式是通过 VPN 的方式将网络包转发给手机本地的代理服务器,代理服务器将数据发送给服务端,获取数据之后再返回即可。
50 |
51 | 使用了 VPN 的方式,我们就可以截获到对应的数据包了,一些工具包括 HttpCanary、Packet Capture、NetKeeper 等。
52 |
53 | * HttpCanary:https://play.google.com/store/apps/details?id=com.guoshi.httpcanary
54 | * Packet Capture:https://play.google.com/store/apps/details?id=app.greyshirts.sslcapture
55 | * NetKeeper:https://play.google.com/store/apps/details?id=com.minhui.networkcapture.pro
56 |
57 | 以上应用链接来源于 Google Play,也可以在国内应用商店搜索或直接下载 apk 安装。
58 |
59 | ##### 特殊协议抓包
60 | 可以考虑使用 Wireshark、Tcpdump 在更底层的协议上抓包,比如抓取 TCP、UDP 数据包等等。
61 |
62 | 使用的时候建议直接 PC 上开热点,然后直接抓取 PC 无线网卡的数据包,这样 App 不管有没有做系统代理校验或者使用了非 HTTP 协议,都能抓到数据包了。
63 |
64 | ##### SSL Pining
65 | SSL Pining,就是证书绑定,这个只针对 HTTPS 请求。
66 |
67 | SSL Pining 发生在下面的一些情况:
68 |
69 | * 对于 Android 7.0 以上的手机,系统做了改动,HTTPS 请求只信任系统级别证书,这会导致系统安全性增加,但是由于抓包软件的证书并不是系统级别证书,就不受信任了,那就没法抓包了。
70 | * 一些 App 里面专门写了逻辑对 SSL Pining 做了处理,对 HTTPS 证书做了校验,如果发现是不在信任范围之内的,那就拒绝连接。
71 |
72 | 对于这些操作,我们通常有两种思路来解决:
73 |
74 | * 让系统信任我们的 HTTPS 证书;
75 | * 绕开 HTTPS 证书的校验过程。
76 |
77 | 对于这两种思路,有以下一些绕过 SSL Pining 的解决方案。
78 |
79 | #### 修改 App 的配置
80 | 如果是 App 的开发者或者把 apk 逆向出来了,那么可以直接通过修改 AndroidManifest.xml 文件,在 apk 里面添加证书的信任规则即可,详情可以参考 https://crifan.github.io/app_capture_package_tool_charles/website/how_capture_app/complex_https/https_ssl_pinning/
81 |
82 | 这种思路属于第一种信任证书的解决方案。
83 |
84 | ##### 将证书设置为系统证书
85 | 当然也可以将证书直接设置为系统证书,只需要将抓包软件的证书设置为系统区域即可。但这个前提是手机必须要 ROOT,而且需要计算证书 Hash Code 并对证书进行重命名,具体可以参考 https://crifan.github.io/app_capture_package_tool_charles/website/how_capture_app/complex_https/https_ssl_pinning,
86 |
87 | 这种思路也是第一种信任证书的解决方案。
88 |
89 | ##### Xposed + JustTrustMe
90 | Xposed 是一款 Android 端的 Hook 工具,利用它我们可以 Hook App 里面的关键方法的执行逻辑,绕过 HTTPS 的证书校验过程。JustTrustMe 是基于 Xposed 一个插件,它可以将 HTTPS 证书校验的部分进行 Hook,改写其中的证书校验逻辑,这种思路是属于第二种绕过 HTTPS 证书校验的解决方案。
91 |
92 | 当然基于 Xposed 的类似插件也有很多,如 SSLKiller、sslunpining 等等,可以自行搜索。
93 |
94 | 不过 Xposed 的安装必须要 ROOT,如果不想 ROOT 的话,可以使用后文介绍的 VirtualXposed。
95 |
96 | ##### Frida
97 | Frida 也是一种类似 Xposed 的 Hook 软件,使用它我们也可以实现一些 HTTPS 证书校验逻辑的改写,这种思路也是属于第二种绕过 HTTPS 证书校验的方案。
98 |
99 | 具体可以参考
100 |
101 | https://codeshare.frida.re/@pcipolloni/universal-android-ssl-pinning-bypass-with-frida/
102 |
103 | ##### VirtualXposed
104 | Xposed 的使用需要 ROOT,如果不想 ROOT 的话,可以直接使用一款基于 VirtualApp 开发的 VirtualXposed 工具,它提供了一个虚拟环境,内置了 Xposed。我们只需要将想要的软件安装到 VirtualXposed 里面就能使用 Xposed 的功能了,然后配合 JustTrustMe 插件也能解决 SSL Pining 的问题,这种思路是属于第二种绕过 HTTPS 证书校验的解决方案。
105 |
106 | ##### 特殊改写
107 | 对于第二种绕过 HTTPS 证书校验的解决方案,其实本质上是对一些关键的校验方法进行了 Hook 和改写,去除了一些校验逻辑。但是对于一些代码混淆后的 App 来说,其校验 HTTPS 证书的方法名直接变了,那么 JustTrustMe 这样的插件就无法 Hook 这些方法,因此也就无效了。
108 |
109 | 所以这种 App 可以直接去逆向,找到其中的一些校验逻辑,然后修改写 JustTrustMe 的源码就可以成功 Hook 住了,也就可以重新生效了。
110 |
111 | ##### 逆向秘钥
112 | 还有一种硬解的方法,可以直接逆向 App,反编译得到证书秘钥,使用秘钥来解决证书限制。
113 |
114 | ##### 逆向
115 | 以上解决了一些抓包的问题,但是还有一个问题,就是抓的数据包里面带有加密参数怎么办?比如一个 HTTP 请求,其参数还带有 token、sign 等参数,即使我们抓到包了,那也没法直接模拟啊?
116 |
117 | 所以我们可能需要对 App 进行一些逆向分析,找出这些加密过程究竟是怎样的。这时候我们就需要用到一些逆向工具了。
118 |
119 | ##### JEB
120 | JEB 是一款适用于 Android 应用程序和本机机器代码的反汇编器和反编译器软件。利用它我们可以直接将安卓的 apk 反编译得到 Smali 代码、jar 文件,获取到 Java 代码。有了 Java 代码,我们就能分析其中的加密逻辑了。
121 |
122 | JEB:https://www.pnfsoftware.com/
123 |
124 | ##### JADX
125 | 与 JEB 类似,JADX 也是一款安卓反编译软件,可以将 apk 反编译得到 jar 文件,得到 Java 代码,从而进一步分析逻辑。
126 | JADX:https://github.com/skylot/jadx
127 |
128 | ##### dex2jar、jd-gui
129 | 这两者通常会配合使用来进行反编译,同样也可以实现 apk 文件的反编译,但其用起来个人感觉不如 JEB、JADX 方便。
130 |
131 | ##### 脱壳
132 | 一些 apk 可能进行了加固处理,所以在反编译之前需要进行脱壳处理。一般来说可以先借助于一些查壳工具查壳,如果有壳的话可以借助于 Dumpdex、FRIDA-DEXDump 等工具进行脱壳。
133 |
134 | * FRIDA-DEXDump:https://github.com/hluwa/FRIDA-DEXDump
135 | * Dumpdex:https://github.com/WrBug/dumpDex
136 | ##### 反汇编
137 | 一些 apk 里面的加密可能直接写入 so 格式的动态链接库里面,要想破解其中的逻辑,就需要用到反汇编的一些知识了,这里可以借助于 IDA 这个软件来进行分析。
138 | IDA:https://www.hex-rays.com/
139 |
140 | 以上的一些逆向操作需要较深的功底和安全知识,在很多情况下,如果逆向成功了,一些加密算法还是能够被找出来的,找出来了加密逻辑之后,我们用程序模拟就方便了。
141 |
142 | ##### 模拟
143 | 逆向对于多数有保护 App 是有一定作用的,但有的时候 App 还增加了风控检测,一旦 App 检测到运行环境或访问频率等信息出现异常,那么 App 或服务器就可能产生防护,直接停止执行或者服务器返回假数据等都是有可能的。
144 |
145 | 对于这种情形,有时候我们就需要回归本源,真实模拟一些 App 的手工操作了。
146 |
147 | ##### adb
148 | 最常规的 adb 命令可以实现一些手机自动化操作,但功能有限。
149 |
150 | ##### 触动精灵、按键精灵
151 | 有很多商家提供了手机 App 的一些自动化脚本和驱动,如触动精灵、按键精灵等,利用它们的一些服务我们可以自动化地完成一些 App 的操作。
152 |
153 | 触动精灵:https://www.touchsprite.com/
154 |
155 | ###### Appium
156 | 类似 Selenium,Appium 是手机上的一款移动端的自动化测试工具,也能做到可见即可爬的操作。
157 |
158 | Appium:http://appium.io/
159 |
160 | ###### AirTest
161 | 同样是一款移动端的自动化测试工具,是网易公司开发的,相比 Appium 来说使用更方便。
162 |
163 | AirTest:http://airtest.netease.com/
164 |
165 | ###### Appium/AirTest + mitmdump
166 | mitmdump 其实是一款抓包软件,与 mitmproxy 是一套工具。这款软件配合自动化的一些操作就可以用 Python 实现实时抓包处理了。
167 |
168 | mitmdump:https://mitmproxy.readthedocs.io/
169 |
170 | 到此,App 的一些爬虫思路和常用的工具就介绍完了,在后面的课时我们会使用其中一些工具来进行实战演练。
171 |
172 | 参考来源
173 | * https://zhuanlan.zhihu.com/webspider
174 | * https://www.zhihu.com/question/60618756/answer/492263766
175 | * https://www.jianshu.com/p/a818a0d0aa9f
176 | * https://mp.weixin.qq.com/s/O6iWb2VL4SH9UNLwk2FCMw
177 | * https://zhuanlan.zhihu.com/p/60392573
178 | * https://crifan.github.io/app_capture_package_tool_charles/website/
179 | * https://github.com/WooyunDota/DroidDrops/blob/master/2018/SSL.Pinning.Practice.md
180 |
--------------------------------------------------------------------------------
/第31讲:抓包利器 Charles 的使用.md:
--------------------------------------------------------------------------------
1 | 本课时我们主要学习如何使用 `Charles`。
2 |
3 | `Charles` 是一个网络抓包工具,我们可以用它来做 App 的抓包分析,得到 App 运行过程中发生的所有网络请求和响应内容,这就和 Web 端浏览器的开发者工具 Network 部分看到的结果一致。
4 |
5 | `Charles`、`Fiddler` 等都是非常强大的 HTTP 抓包软件,功能基本类似,不过 Charles 的跨平台支持更好。所以我们选用 Charles 作为主要的移动端抓包工具,用于分析移动 App 的数据包,辅助完成 App 数据抓取工作。
6 |
7 | #### 本节目标
8 | 本节我们以电影示例 App 为例,通过 `Charles` 抓取 App 运行过程中的网络数据包,然后查看具体的 `Request` 和 `Response` 内容,以此来了解 `Charles` 的用法。
9 |
10 | 同时抓取到数据包之后,我们采用 Python 将请求进行改写,从而实现 App 数据的爬取。
11 |
12 | #### 准备工作
13 | 请确保已经**正确安装 Charles 并开启了代理服务**,另外准备一部 Android 手机,系统版本最好是在 7.0 以下。
14 |
15 | > 如果系统版本在 7.0 及以上,可能出现 SSL Pining 的问题,可以参考第一课时的思路来解决。
16 |
17 | 然后手机连接 Wi-Fi,和 PC 处于同一个局域网下,另外将 Charles 代理和 Charles CA 证书设置好,同时需要开启 SSL 监听。
18 |
19 | 此过程的配置流程可以参见:https://cuiqingcai.com/5255.html
20 |
21 | 最后手机上安装本节提供的 apk(apk 随课件一同领取),进行接下来的 Charles 抓包操作。
22 |
23 | #### 原理
24 | 首先将 Charles 运行在自己的 PC 上,Charles 运行的时候会在 PC 的 8888 端口开启一个代理服务,这个服务实际上是一个 HTTP/HTTPS 的代理。
25 |
26 | 确保手机和 PC 在同一个局域网内,我们可以使用手机模拟器通过虚拟网络连接,也可以使用手机真机和 PC 通过无线网络连接。
27 |
28 | 设置手机代理为 Charles 的代理地址,这样手机访问互联网的数据包就会流经 Charles,Charles 再转发这些数据包到真实的服务器,服务器返回的数据包再由 Charles 转发回手机,Charles 就起到中间人的作用,所有流量包都可以捕捉到,因此所有 HTTP 请求和响应都可以捕获到。同时 Charles 还有权力对请求和响应进行修改。
29 |
30 | #### 抓包
31 | 好,我们先打开 Charles,初始状态下 Charles 的运行界面如图所示。
32 | 
33 | Charles 会一直监听 PC 和手机发生的网络数据包,捕获到的数据包就会显示在左侧,随着时间的推移,捕获的数据包越来越多,左侧列表的内容也会越来越多。
34 |
35 | 可以看到,图中左侧显示了 Charles 抓取到的请求站点,我们点击任意一个条目便可以查看对应请求的详细信息,其中包括 Request、Response 等内容。
36 |
37 | 接下来清空 Charles 的抓取结果,点击左侧的扫帚按钮即可清空当前捕获到的所有请求。然后点击第二个监听按钮,确保监听按钮是打开的,这表示 Charles 正在监听 App 的网络数据流,如图所示。
38 |
39 | 
40 |
41 | 这时打开 App,注意一定要提前设置好 Charles 的代理并配置好 CA 证书,否则没有效果。
42 |
43 | 打开 App 之后我们就可以看到类似如下的页面。
44 |
45 | 
46 | 这时候我们就可以发现 Charles 里面已经抓取到了对应的数据包,出现了类似如图所示的结果。
47 |
48 | 
49 | 我们在 App 里不断上拉,可以看到 Charles 捕获到这个过程中 App 内发生的所有网络请求,如图所示。
50 |
51 | 
52 | 左侧列表中会出现一个 dynamic1.scrape.cuiqingcai.com 的链接,而且在 App 上拉过程它在不停闪动,这就是当前 App 发出的获取数据的请求被 Charles 捕获到了。
53 |
54 | 为了验证其正确性,我们点击查看其中一个条目的详情信息。切换到 Contents 选项卡,这时我们发现一些 JSON 数据,核对一下结果,结果有 results 字段,每一个条目的 name 字段就是电影的信息,这与 App 里面呈现的内容是完全一致的,如图所示。
55 |
56 | 
57 | 这时可以确定,此请求对应的接口就是获取电影数据的接口。这样我们就成功捕获到了在上拉刷新的过程中发生的请求和响应内容。
58 |
59 | #### 分析
60 | 现在分析一下这个请求和响应的详细信息。首先可以回到 Overview 选项卡,上方显示了请求的接口 URL,接着是响应状态 Status Code、请求方式 Method 等,如图所示。
61 |
62 | 
63 | 这个结果和原本在 Web 端用浏览器开发者工具内捕获到的结果形式是类似的。
64 |
65 | 接下来点击 Contents 选项卡,查看该请求和响应的详情信息。
66 |
67 | 上半部分显示的是 Request 的信息,下半部分显示的是 Response 的信息。比如针对 Reqeust,我们切换到 Headers 选项卡即可看到该 Request 的 Headers 信息,针对 Response,我们切换到 JSON Text 选项卡即可看到该 Response 的 Body 信息,并且该内容已经被格式化,如图所示。
68 |
69 | 
70 |
71 | 由于这个请求是 GET 请求,所以我们还需要关心的就是 GET 的参数信息,切换到 Query String 选项卡即可查看,如图所示。
72 |
73 | 
74 |
75 | 这样我们就成功抓取到了 App 中的电影数据接口的请求和响应,并且可以查看 Response 返回的 JSON 数据。
76 |
77 | 至于其他 App,我们同样可以使用这样的方式来分析。如果我们可以直接分析得到请求的 URL 和参数的规律,直接用程序模拟即可批量抓取。
78 |
79 | #### 重发
80 | Charles 还有一个强大功能,它可以将捕获到的请求加以修改并发送修改后的请求。点击上方的修改按钮,左侧列表就多了一个以编辑图标为开头的链接,这就代表此链接对应的请求正在被我们修改,如图所示。
81 |
82 | 
83 |
84 | 我们可以将参数中的某个字段修改下,比如这里将 offset 字段由 0 修改为 10。这时我们已经对原来请求携带的 Query 参数做了修改,然后点击下方的 Execute 按钮即可执行修改后的请求,如图所示。
85 |
86 | 
87 |
88 | 可以发现左侧列表再次出现了接口的请求结果,内容变成了第 11~20 条内容,如图所示。
89 |
90 | 
91 | 有了这个功能,我们就可以方便地使用 Charles 来做调试,可以通过修改参数、接口等来测试不同请求的响应状态,就可以知道哪些参数是必要的哪些是不必要的,以及参数分别有什么规律,最后得到一个最简单的接口和参数形式以供程序模拟调用使用。
92 |
93 | #### 模拟
94 | 现在我们已经成功完成了抓包操作了,所有的请求一目了然,请求的 URL 就是 https://dynamic1.scrape.cuiqingcai.com/api/movie/,后面跟了两个 GET 请求参数。经过观察,可以很轻松地发现 offset 就是偏移量,limit 就是一次请求要返回的结果数量。比如 offset 为 20,limit 为 10,就代表获取第 21~30 条数据。另外我们通过观察发现一共就是 100 条数据,offset 从 0 到 90 遍历即可。
95 |
96 | 接下来我们用 Python 简单实现一下模拟请求即可,这里写法一些从简了,代码如下:
97 | ```python
98 | import requests
99 |
100 | BASE_URL = 'https://dynamic1.scrape.cuiqingcai.com/api/movie?offset={offset}&limit=10'
101 | for i in range(0, 10):
102 | offset = i * 10
103 | url = BASE_URL.format(offset=offset)
104 | data = requests.get(url).json()
105 | print('data', data)
106 | ```
107 | 运行结果如下:
108 |
109 |
110 | ```python
111 | 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': ['美国']}]}
112 |
113 | 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': ['法国', '德国', '意大利', '西班牙', '瑞士']}]}
114 |
115 | data {'count': 100, 'results': [{'id': 21, 'name': '黄金三镖客', 'alias': 'Il buono, il brutto, il cattivo.', 'cover': ...
116 | ```
117 | 可以看到每个请求都被我们轻松模拟实现了,数据也被爬取下来了。
118 |
119 | 由于这个 App 的接口没有任何加密,所以仅仅靠抓包完之后观察规律我们就能轻松完成 App 接口的模拟爬取。
120 |
121 | #### 结语
122 | 以上内容便是通过 Charles 抓包分析 App 请求的过程。通过 Charles,我们成功抓取 App 中流经的网络数据包,捕获原始的数据,还可以修改原始请求和重新发起修改后的请求进行接口测试。
123 |
124 | 知道了请求和响应的具体信息,如果我们可以分析得到请求的 URL 和参数的规律,直接用程序模拟即可批量抓取,这当然最好不过了。
125 |
126 | 但是随着技术的发展,App 接口往往会带有密钥或者无法抓包,后面我们会继续讲解此类情形的处理操作。
127 |
--------------------------------------------------------------------------------