正则表达式 (Regular Expression) 又称 RegEx, 是用来匹配字符的一种工具. 在一大串字符中寻找你需要的内容. 它常被用在很多方面, 比如网页爬虫, 文稿整理, 数据筛选等等. 最简单的一个例子, 比如我需要爬取网页中每一页的标题. 而网页中的标题常常是这种形式.
<title>我是标题 title>
而且每个网页的标题各不相同, 我就能使用正则表达式, 用一种简单的匹配方法, 一次性选取出成千上万网页的标题信息. 正则表达式绝对不是一天就能学会和记住的, 因为表达式里面的内容非常多, 强烈建议, 现在这个阶段, 你只需要了解正则里都有些什么, 不用记住, 等到你真正需要用到它的时候, 再反过头来, 好好琢磨琢磨, 那个时候才是你需要训练自己记住这些表达式的时候.
正则表达式无非就是在做这么一回事. 在文字中找到特定的内容, 比如下面的内容. 我们在 “dog runs to cat” 这句话中寻找是否存在 “cat” 或者 “bird”.
# matching string
pattern1 = "cat"
pattern2 = "bird"
string = "dog runs to cat"
print(pattern1 in string) # True
print(pattern2 in string) # False
但是正则表达式绝非不止这样简单的匹配, 它还能做更加高级的内容. 要使用正则表达式, 首先需要调用一个 python 的内置模块re
. 然后我们重复上面的步骤, 不过这次使用正则. 可以看出, 如果re.search()
找到了结果, 它会返回一个 match 的 object. 如果没有匹配到, 它会返回 None. 这个 re.search()
只是 re
中的一个功能, 之后会介绍其它的功能.
import re
# regular expression
pattern1 = "cat"
pattern2 = "bird"
string = "dog runs to cat"
print(re.search(pattern1, string)) # <_sre.SRE_Match object; span=(12, 15), match='cat'>
print(re.search(pattern2, string)) # None
除了上面的简单匹配, 下面的内容才是正则的核心内容, 使用特殊的 pattern 来灵活匹配需要找的文字.
如果需要找到潜在的多个可能性文字, 我们可以使用[]
将可能的字符囊括进来. 比如 [ab]
就说明我想要找的字符可以是a
也可以是b
. 这里我们还需要注意的是, 建立一个正则的规则, 我们在 pattern 的 “” 前面需要加上一个r
用来表示这是正则表达式, 而不是普通字符串. 通过下面这种形式, 如果字符串中出现 “run” 或者是 “ran”, 它都能找到.且返回找到的第一个值
# multiple patterns ("run" or "ran")
ptn = r"r[au]n" # start with "r" means raw string
print(re.search(ptn, "dog runs to cat")) # <_sre.SRE_Match object; span=(4, 7), match='run'>
同样, 中括号[]
中还可以是以下这些或者是这些的组合. 比如[A-Z]
表示的就是所有大写的英文字母. [0-9a-z]
表示可以是数字也可以是任何小写字母.
print(re.search(r"r[A-Z]n", "dog runs to cat")) # None
print(re.search(r"r[a-z]n", "dog runs to cat")) # <_sre.SRE_Match object; span=(4, 7), match='run'>
print(re.search(r"r[0-9]n", "dog r2ns to cat")) # <_sre.SRE_Match object; span=(4, 7), match='r2n'>
print(re.search(r"r[0-9a-z]n", "dog runs to cat r1n")) # <_sre.SRE_Match object; span=(4, 7), match='run'>
除了自己定义规则, 还有很多匹配的规则时提前就给你定义好了的. 下面有一些特殊的匹配类型给大家先总结一下, 然后再上一些例子.
# \d : decimal digit
print(re.search(r"r\dn", "run r4n")) # <_sre.SRE_Match object; span=(4, 7), match='r4n'>
# \D : any non-decimal digit
print(re.search(r"r\Dn", "run r4n")) # <_sre.SRE_Match object; span=(0, 3), match='run'>
# \s : any white space [\t\n\r\f\v]
print(re.search(r"r\sn", "r\nn r4n")) # <_sre.SRE_Match object; span=(0, 3), match='r\nn'>
# \S : opposite to \s, any non-white space
print(re.search(r"r\Sn", "r\nn r4n")) # <_sre.SRE_Match object; span=(4, 7), match='r4n'>
# \w : [a-zA-Z0-9_]
print(re.search(r"r\wn", "r\nn r4n")) # <_sre.SRE_Match object; span=(4, 7), match='r4n'>
# \W : opposite to \w
print(re.search(r"r\Wn", "r\nn r4n")) # <_sre.SRE_Match object; span=(0, 3), match='r\nn'>
# \b : empty string (only at the start or end of the word)
print(re.search(r"\bruns\b", "dog runs to cat")) # <_sre.SRE_Match object; span=(4, 8), match='runs'>
# \B : empty string (but not at the start or end of a word)
print(re.search(r"\B runs \B", "dog runs to cat")) # <_sre.SRE_Match object; span=(8, 14), match=' runs '>
# \\ : match \
print(re.search(r"runs\\", "runs\ to me")) # <_sre.SRE_Match object; span=(0, 5), match='runs\\'>
# . : match anything (except \n)
print(re.search(r"r.n", "r[ns to me")) # <_sre.SRE_Match object; span=(0, 3), match='r[n'>
# ^ : match line beginning
print(re.search(r"^dog", "dog runs to cat")) # <_sre.SRE_Match object; span=(0, 3), match='dog'>
# $ : match line ending
print(re.search(r"cat$", "dog runs to cat")) # <_sre.SRE_Match object; span=(12, 15), match='cat'>
# ? : may or may not occur
print(re.search(r"Mon(day)?", "Monday")) # <_sre.SRE_Match object; span=(0, 6), match='Monday'>
print(re.search(r"Mon(day)?", "Mon")) # <_sre.SRE_Match object; span=(0, 3), match='Mon'>
如果一个字符串有很多行, 我们想使用 ^
形式来匹配行开头的字符, 如果用通常的形式是不成功的. 比如下面的 “I” 出现在第二行开头, 但是使用r"^I"
却匹配不到第二行, 这时候, 我们要使用 另外一个参数, 让re.search()
可以对每一行单独处理. 这个参数就是flags=re.M
, 或者这样写也行 flags=re.MULTILINE
.
string = """
dog runs to cat.
I run to dog.
"""
print(re.search(r"^I", string)) # None
print(re.search(r"^I", string, flags=re.M)) # <_sre.SRE_Match object; span=(18, 19), match='I'>
如果我们想让某个规律被重复使用, 在正则里面也是可以实现的, 而且实现的方式还有很多. 具体可以分为这三种:
\*
: 重复零次或多次\+
: 重复一次或多次{n, m}
: 重复 n 至 m 次{n}
: 重复 n 次# * : occur 0 or more times
print(re.search(r"ab*", "a")) # <_sre.SRE_Match object; span=(0, 1), match='a'>
print(re.search(r"ab*", "abbbbb")) # <_sre.SRE_Match object; span=(0, 6), match='abbbbb'>
# + : occur 1 or more times
print(re.search(r"ab+", "a")) # None
print(re.search(r"ab+", "abbbbb")) # <_sre.SRE_Match object; span=(0, 6), match='abbbbb'>
# {n, m} : occur n to m times
print(re.search(r"ab{2,10}", "a")) # None
print(re.search(r"ab{2,10}", "abbbbb")) # <_sre.SRE_Match object; span=(0, 6), match='abbbbb'>
我们甚至可以为找到的内容分组, 使用 ()
能轻松实现这件事. 通过分组, 我们能轻松定位所找到的内容. 比如在这个(\d+)
组里, 需要找到的是一些数字, 在 (.+)
这个组里, 我们会找到 “Date: “ 后面的所有内容. 当使用 match.group()
时, 他会返回所有组里的内容, 而如果给 .group(2)
里加一个数, 它就能定位你需要返回哪个组里的信息.
match = re.search(r"(\d+), Dat (e:.+)", "ID: 021523, Date: Feb/12/2017")
print(match.group()) # 021523, Date: Feb/12/2017
print(match.group(1)) # 021523
print(match.group(2)) # e: Feb/12/2017
有时候, 组会很多, 光用数字可能比较难找到自己想要的组, 这时候, 如果有一个名字当做索引, 会是一件很容易的事. 我们字需要在括号的开头写上这样的形式 ?P<名字> 就给这个组定义了一个名字. 然后就能用这个名字找到这个组的内容.
match = re.search(r"(?P\d+), Date: (?P.+)" , "ID: 021523, Date: Feb/12/2017")
print(match.group('id')) # 021523
print(match.group('date')) # Date: Feb/12/2017
前面我们说的都是只找到了最开始匹配上的一项而已, 如果需要找到全部的匹配项, 我们可以使用findall
功能. 然后返回一个列表. 注意下面还有一个新的知识点, |
是 or 的意思, 要不是前者要不是后者.
# findall
import re
print(re.findall(r"r(ua)n", "run ran ruan ren")) #['ua']
print(re.findall(r"(ua)", "run ran eua ruan ren")) #['ua', 'ua']
print(re.findall(r"r[ua]n", "run ran ruan ren")) #['run', 'ran']
# | : or
print(re.findall(r"r[u|a]n", "run ran ruan ren")) #['run', 'ran']
print(re.findall(r"r(u|a)n", "run ran ruan ren")) # ['run', 'ran']
我们还能通过正则表达式匹配上一些形式的字符串然后再替代掉这些字符串. 使用这种匹配 re.sub()
, 将会比 python 自带的 string.replace()
要灵活多变.
print(re.sub(r"r[au]ns", "catches", "dog runs to cat")) # dog catches to cat
再来我们 Python 中有个字符串的分割功能, 比如想获取一句话中所有的单词. 比如 "a is b".split(" ")
, 这样它就会产生一个列表来保存所有单词. 但是在正则中, 这种普通的分割也可以做的淋漓精致.
import re
print(re.split(r"[;\.]", "ac.d;e"))#['ac', 'd', 'e']
print(re.split(r"[.]", "ac.d;e"))#['ac', 'd;e']
print(re.split(r"[\.]", "ac.d;e"))#['ac', 'd;e']
print(re.split(r"(\.)", "ac.d;e"))#['ac', '.', 'd;e']
print(re.split(r"(.)", "ac.d;e"))#['', 'a', '', 'c', '', '.', '', 'd', '', ';', '', 'e', '']
最后, 我们还能使用 compile 过后的正则, 来对这个正则重复使用. 先将正则 compile 进一个变量, 比如 compiled_re
, 然后直接使用这个 compiled_re
来搜索.
compiled_re = re.compile(r"r[ua]n")
print(compiled_re.search("dog ran to cat")) # <_sre.SRE_Match object; span=(4, 7), match='ran'>
为了大家方便记忆, 我很久以前在网上找到了一份小抄, 这个小抄的原出处应该是这里. 小抄很有用, 不记得的时候回头方便看.
其实你身边到处都是爬虫的产物, 比如说搜索引擎 (Google, 百度), 他们能为你提供这么多搜索结果, 也都是因为它们爬了很多信息, 然后展示给你. 再来说一些商业爬虫, 比如爬爬淘宝的同类商品的价格信息, 好为自己的商品挑选合适的价格. 爬虫的用途很多很多, 如果你搞机器学习, 爬虫就是你获取数据的一种途径, 网上的信息成百上千, 只要你懂爬虫, 你都能轻松获取.
(https://morvanzhou.github.io/tutorials/data-manipulation/scraping/1-00-why/)
这个教程提供了你一次入门的机会, 当然网上还有很多很好的入门教程, 比如:
崔庆才 的Python爬虫学习系列教程
知乎问答中的各种推荐
孔淼 的一看就明白的爬虫入门讲解
这些都是非常好的参考资料, 你的学习请不要只限于一个网站. 因为像机器学习一样, 爬虫也囊括的东西绝非不止一点点. 而你为什么要看看我的爬虫教程呢? 因为我只关注基础, 我认为入门是最重要的, 能帮你成功引上路子, 我想你会轻松很多. 而且搭配视频讲解的形式, 也会更加容易理解. 是为初学者定制的. 如果你已经入过门, 想着如何商业化爬虫, 这个教程应该不能满足你的需求了.
当我第一次接触爬虫的时候, 其实很陌生, 完全不知道从何开始. 在网上自己搜一些介绍, 但是他们的介绍都太笼统了, 给你丢几个关键词, 让你自己解决. 当时我就懵逼了, 看着那些关键词 (requests, urllib, beautifulsoup, scrapy) 不知道从何下手, 我估计你也会有这种感觉. 当时, 我花了大把的精力, 想弄懂这些东西和爬虫的关系. 而且分清如果只想入门, 我们需要掌握哪些? 毕竟商业化和入门还是有很大不同的. 有些关键词或者模块是为了商业化而用的. 所以我花了很多时间, 整理网上的这些信息. 总结出一条入门爬虫的便捷之路.
这系列教程按照上面的逻辑来教会你爬虫, 我们会从网页的基本结构开始讲述, 慢慢使用一些简单的工具, 做一些简单的爬虫. 还会有一些小练习, 让你爬爬真正的互联网. 下载美图, 逛逛百度百科, 全网爬取等等. 当你懂得了爬虫的概念, 我们在深入一些, 谈谈如何加速你那和蠕虫(爬的慢)一样的爬虫, 把它升级为一只小飞虫(多进程,异步爬取). 当然这些内容都不会特别深入, 重点是把你带入门. 但是我会在每节内容里加一些链接, 提供给想要深入了解的朋友们.
学习爬虫, 首先要懂的是网页. 支撑起各种光鲜亮丽的网页的不是别的, 全都是一些代码. 这种代码我们称之为 HTML
, HTML 是一种浏览器(Chrome, Safari, IE, Firefox等)看得懂的语言, 浏览器能将这种语言转换成我们用肉眼看到的网页. 所以 HTML 里面必定存在着很多规律, 我们的爬虫就能按照这样的规律来爬取你需要的信息.
其实除了 HTML, 一同构建多彩/多功能网页的组件还有 CSS
和 JavaScript
. 但是这个简单的爬虫教程, 大部分时间会将会使用 HTML. CSS 和 JavaScript 会在后期简单介绍一下. 因为爬网页的时候多多少少还是要和 CSS JavaScript 打交道的.
虽然莫烦Python
主打的是机器学习的教程. 但是这个爬虫教程适用于任何想学爬虫的朋友们. 从机器学习的角度看, 机器学习中的大量数据, 也是可以从这些网页中来, 使用爬虫来爬取各种网页上面的信息, 然后再放入各种机器学习的方法, 这样的应用途径正在越来越多被采用. 所以如果你的数据也是分散在各个网页中, 爬虫是你减少人力劳动的必修课.
在真正进入爬虫之前, 我们先来做一下热身运动, 弄明白网页的基础, HTML 有哪些组成部分, 是怎么样运作的. 如果你已经非常熟悉网页的构造了, 欢迎直接跳过这一节, 进入下面的学习.
我制作了一个非常简易的网页, 给大家呈现以下最骨感的 HTML 结构. 如果你点开它, 呈现在你眼前的, 就是下面这张图的上半部分. 而下半部分就是我们网页背后的 HTML code.
如何看到 HTML 的 source code
在你的浏览器中 (我用的是 Google Chrome), 显示网页的地方, 点击鼠标右键, 大多数浏览器都会有类似这样一个选项 “View Page Source”或查看网页源代码. 点击它就能看到页面的源码了.
在 HTML 中, 基本上所有的实体内容, 都会有个 tag (…)来框住它. 而这个被 tag 住的内容, 就可以被展示成不同的形式, 或有不同的功能. 主体的 tag 分成两部分, header
和 body
. 在 header
中, 存放这一些网页的网页的元信息, 比如说 title
, 这些信息是不会被显示到你看到的网页中的. 这些信息大多数时候是给浏览器看, 或者是给搜索引擎的爬虫看.
<head>
<meta charset="UTF-8">
<title>Scraping tutorial 1 | 莫烦Pythontitle>
<link rel="icon" href="https://morvanzhou.github.io/static/img/description/tab_icon.png">
head>
HTML 的第二大块是body
, 这个部分才是你看到的网页信息. 网页中的 heading
, 视频, 图片和文字等都存放在这里. 这里的 tag 就是主标题, 我们看到呈现出来的效果就是大一号的文字.
里面的文字就是一个段落.
里面都是一些链接. 所以很多情况, 东西都是放在这些 tag 中的.
<body>
<h1>爬虫测试1h1>
<p>
这是一个在 <a href="https://morvanzhou.github.io/">莫烦Pythona>
<a href="https://morvanzhou.github.io/tutorials/scraping">爬虫教程a> 中的简单测试.
p>
body>
爬虫想要做的就是根据这些 tag 来找到合适的信息.
好了, 对网页结构和 HTML 有了一些基本认识以后, 我们就能用 Python 来爬取这个网页
的一些基本信息. 首先要做的, 是使用 Python 来登录这个网页, 并打印出这个网页 HTML 的 source code. 注意, 因为网页中存在中文, 为了正常显示中文, read()
完以后, 我们要对读出来的文字进行转换, decode()
成可以正常显示中文的形式.
from urllib.request import urlopen
# if has Chinese, apply decode()
html = urlopen(
"https://morvanzhou.github.io/static/scraping/basic-structure.html"
).read().decode('utf-8')
print(html)
print 出来就是下面这样啦. 这就证明了我们能够成功读取这个网页的所有信息了. 但我们还没有对网页的信息进行汇总和利用. 我们发现, 想要提取一些形式的信息, 合理的利用 tag 的名字十分重要.
<html lang="cn">
<head>
<meta charset="UTF-8">
<title>Scraping tutorial 1 | 莫烦Pythontitle>
<link rel="icon" href="https://morvanzhou.github.io/static/img/description/tab_icon.png">
head>
<body>
<h1>爬虫测试1h1>
<p>
这是一个在 <a href="https://morvanzhou.github.io/">莫烦Pythona>
<a href="https://morvanzhou.github.io/tutorials/scraping">爬虫教程a> 中的简单测试.
p>
body>
html>
所以这里我们使用 Python 的正则表达式 RegEx 进行匹配文字,如果是初级的网页匹配, 我们使用正则完全就可以了, 高级一点或者比较繁琐的匹配, 我还是推荐使用 BeautifulSoup
.
如果我们想用代码找到这个网页的 title, 我们就能这样写. 选好要使用的 tag 名称
. 使用正则匹配.
import re
res = re.findall(r"(.+?) ", html)
print("\nPage title is: ", res[0])
# Page title is: Scraping tutorial 1 | 莫烦Python
如果想要找到中间的那个段落, 我们使用下面方法, 因为这个段落在 HTML 中还夹杂着 tab, new line, 所以我们给一个
flags=re.DOTALL
来对这些 tab, new line 不敏感,以选择多行的内容.
res = re.findall(r"(.*?)
", html, flags=re.DOTALL) # re.DOTALL if multi line
print("\nPage paragraph is: ", res[0])
# Page paragraph is:
# 这是一个在 莫烦Python
# 爬虫教程 中的简单测试.
最后一个练习是找一找所有的链接, 这个比较有用, 有时候你想找到网页里的链接, 然后下载一些内容到电脑里, 就靠这样的途径了.
res = re.findall(r'href="(.*?)"', html)
print("\nAll links: ", res)
# All links:
['https://morvanzhou.github.io/static/img/description/tab_icon.png',
'https://morvanzhou.github.io/',
'https://morvanzhou.github.io/tutorials/scraping']
注:
找到需要的信息时, BeautifulSoup 就是一个找信息好帮手. 它能帮你又快有准地找到信息. 大大简化了使用难度.
本节学习代码
BeautifulSoup 英文官网, 中文官网
本节使用的爬虫测试网页
我们总结一下爬网页的流程, 让你对 BeautifulSoup 有一个更好的定位.
初学的时候总是搞不懂这些包是干什么的, 现在你就能理解这个 BeautifulSoup 到底是干什么的了.
等什么, 知道 BeautifulSoup 这么方便, 我们就赶紧装一个吧. 安装的步骤很简单, 用 pip 就能轻松安装.
# Python 2+
pip install beautifulsoup4
# Python 3+
pip3 install beautifulsoup4
注意在名字后面还有个 “4”, 可能是代表第4版吧. 如果你在安装的时候遇到任何问题, 请参考他们官网
上的解决方案.
这次我们还是爬一爬上次爬的那个基本网页
. BeautifulSoup 使用起来非常简单, 我们先按常规读取网页.
from bs4 import BeautifulSoup
from urllib.request import urlopen
# if has Chinese, apply decode()
html = urlopen("https://morvanzhou.github.io/static/scraping/basic-structure.html").read().decode('utf-8')
print(html)
回顾一下, 每张网页中, 都有两大块, 一个是 , 一个是
, 我们等会用 BeautifulSoup 来找到 body 中的段落
和所有链接
.
<html lang="cn">
<head>
<meta charset="UTF-8">
<title>Scraping tutorial 1 | 莫烦Pythontitle>
<link rel="icon" href="https://morvanzhou.github.io/static/img/description/tab_icon.png">
head>
<body>
<h1>爬虫测试1h1>
<p>
这是一个在 <a href="https://morvanzhou.github.io/">莫烦Pythona>
<a href="https://morvanzhou.github.io/tutorials/scraping">爬虫教程a> 中的简单测试.
p>
body>
html>
读取这个网页信息, 我们将要加载进 BeautifulSoup, 以 lxml
的这种形式加载. 除了 lxml
, 其实还有很多形式的解析器
, 不过大家都推荐使用 lxml
的形式. 然后 soup
里面就有着这个 HTML 的所有信息. 如果你要输出 标题, 可以就直接
soup.h1
.
若无法使用lxml
可替换为html.parser
,或安装lxml
包
soup = BeautifulSoup(html, features='lxml')
print(soup.h1)
"""
爬虫测试1
"""
print('\n', soup.p)
"""
"""
如果网页中有过个同样的 tag, 比如链接 , 我们可以使用
find_all()
来找到所有的选项. 因为我们真正的 link 不是在中间
, 而是在
里面, 也可以看做是
的一个属性. 我们能用像 Python 字典的形式, 用 key 来读取
l["href"]
.
all_href = soup.find_all('a')
"""
爬虫教程
"""
all_href = soup.find_all('a')
all_href = [l['href'] for l in all_href]
print('\n', all_href)
# ['https://morvanzhou.github.io/', 'https://morvanzhou.github.io/tutorials/scraping']
懂得这些还是远远不够的, 真实情况往往比这些复杂. BeautifulSoup 还有很多其他的选择”增强器”. 下次, 我们来了解一些 CSS 的概念, 用 BeautifulSoup 加上 CSS 来选择内容.
提到这个, 我们肯定需要知道什么是 CSS. HTML 和 CSS 是一对好搭档, 他们共同组成了当今的众多网页. 如果这个世界上没有 CSS, 你看到的所有网页可能都长得像这样. 特别”骨感”!
如果有 CSS, 你的网页就变得丰富多彩起来. 文字有了颜色, 字体, 位置也多样了, 图片也有规则了.
所以, CSS 主要用途就是装饰你 “骨感” HTML 页面. 如果将 HTML 比喻成没穿衣服的人, 那 CSS 就是五颜六色的衣服. 穿在人身上让人有了气质. CSS 的规则很多, 好在如果你只是需要爬网页, 你并不需要学习 CSS 的这些用法或规则, (如果你想, 你可以看到这里), 你只需要注意 CSS 的一条规则就能玩转爬虫了.
这条规则就是 CSS 的 Class, CSS 在装饰每一个网页部件的时候, 都会给它一个名字. 而且一个类型的部件, 名字都可以一样. 比如我们这个练习网页. 里面的字体/背景颜色, 字体大小, 都是由 CSS 来掌控的.
而 CSS 的代码, 可能就会放在这个网页的 中. 我们先使用 Python 读取这个页面.
from bs4 import BeautifulSoup
from urllib.request import urlopen
# if has Chinese, apply decode()
html = urlopen("https://morvanzhou.github.io/static/scraping/list.html").read().decode('utf-8')
print(html)
在中, 你会发现有这样一些东西被放在
里面, 这些东西都是某些 class 的 CSS 代码. 比如
jan
就是一个 class. jan
这个类掌控了这个类型的背景颜色. 所以在
这里, 这个 ul 的背景颜色就是黄色的. 而如果是month
这个类, 它们的字体颜色就是红色.
<head>
...
<style>
.jan {
background-color: yellow;
}
...
.month {
color: red;
}
style>
head>
<body>
...
<ul>
<li class="month">一月li>
<ul class="jan">
<li>一月一号li>
<li>一月二号li>
<li>一月三号li>
ul>
...
ul>
body>
这样, 我们就知道, 有时候, 网页中, 这种 class 归类一些组件还是很有用的. 比如我就想找 jan
下面的这些 . 我就能通过寻找
class="jan"
找到它们. BeautifulSoup 就能这么干.
按 Class 匹配很简单. 比如我要找所有 class=month 的信息. 并打印出它们的 tag 内文字.
soup = BeautifulSoup(html, features='lxml')
# use class to narrow search
month = soup.find_all('li', {"class": "month"})
for m in month:
print(m.get_text())
for m in month:
print(m)
"""
一月
二月
三月
四月
五月
一月
二月
三月
四月
五月
"""
例2:找到 class=jan 的信息. 然后在
下面继续找
内部的 信息. 这样一层层嵌套的信息, 非常容易找到.
jan = soup.find('ul', {"class": 'jan'})
d_jan = jan.find_all('li') # use jan as a parent
for d in d_jan:
print(d.get_text())
"""
一月一号
一月二号
一月三号
"""
如果想要找到一些有着一定格式的信息, 比如使用正则表达来寻找相类似的信息, 我们在 BeautifulSoup 中也能嵌入正则表达式, 让 BeautifulSoup 更为强大.
正则表达式很厉害, 它能用简单的规则匹配到多样化的文本信息.
这次的教程有一些表格形式的 HTML, 在表格中, 有一些信息的格式类似, 我们先用 BeautifulSoup 筛选一些, 然后完全可以用正则给匹配出来. 比如你想下载这个页面的图片, 我们就可以将图片形式的 url 个匹配出来. 之后再下载就简单多了.
我们先读取这个网页. 导入正则模块re
.
from bs4 import BeautifulSoup
from urllib.request import urlopen
import re
# if has Chinese, apply decode()
html = urlopen("https://morvanzhou.github.io/static/scraping/table.html").read().decode('utf-8')
我们发现, 如果是图片, 它们都藏在这样一个 tag 中:
<td>
<img src="https://morvanzhou.github.io/static/img/course_cover/tf.jpg">
</td>
所以, 我们可以用soup
将这些 tag 全部找出来, 但是每一个 img 的链接(src)都可能不同. 或者每一个图片有的可能是 jpg 有的是 png, 如果我们只想挑选 jpg 形式的图片, 我们就可以用这样一个正则
r'.*?\.jpg'
来选取. 把正则的 compile 形式放到 BeautifulSoup 的功能中, 就能选到符合要求的图片链接了.
soup = BeautifulSoup(html, features='lxml')
img_links = soup.find_all("img", {"src": re.compile('.*?\.jpg')})
for link in img_links:
print(link['src'])
"""
https://morvanzhou.github.io/static/img/course_cover/tf.jpg
https://morvanzhou.github.io/static/img/course_cover/rl.jpg
https://morvanzhou.github.io/static/img/course_cover/scraping.jpg
"""
又或者我们发现, 我想选一些课程的链接, 而这些链接都有统一的形式, 就是开头都会有 https://morvan
., 那我就将这个定为一个正则的规则, 让 BeautifulSoup 帮我找到符合这个规则的链接.
course_links = soup.find_all('a', {'href': re.compile('https://morvan.*')})
for link in course_links:
print(link['href'])
"""
https://morvanzhou.github.io/
https://morvanzhou.github.io/tutorials/scraping
https://morvanzhou.github.io/tutorials/machine-learning/tensorflow/
https://morvanzhou.github.io/tutorials/machine-learning/reinforcement-learning/
https://morvanzhou.github.io/tutorials/data-manipulation/scraping/
"""
`本节要爬的百度百科
百度百科中有很多名词的解释信息, 我们今天从 “网页爬虫” 的词条开始爬, 然后在页面中任意寻找下一个词条, 爬过去, 再寻找词条, 继续爬. 看看最后我们爬到的词条和 “网页爬虫” 差别有多大.
这个练习看起来挺没意义的, 但是对于了解爬虫, 还是挺有意义的. 用最简单的规律解释了爬虫的真谛.
这个爬虫说实在的, 并不难, 只有20+行代码. 但是却能让它游走在百度百科的知识的海洋中. 首先我们需要定义一个起始网页, 我选择了 “网页爬虫”. 我们发现, 页面中有一些链接, 指向百度百科中的另外一些词条, 比如说下面这样.
<a target="_blank" href="/item/%E8%9C%98%E8%9B%9B/8135707" data-lemmaid="8135707">蜘蛛a>
<a target="_blank" href="/item/%E8%A0%95%E8%99%AB">蠕虫a>
<a target="_blank" href="/item/%E9%80%9A%E7%94%A8%E6%90%9C%E7%B4%A2%E5%BC%95%E6%93%8E">通用搜索引擎a>
通过观察, 我们发现, 链接有些共通之处. 它们都是/item/
开头, 夹杂着一些%E9
这样的东西. 但是仔细搜索一下, 发现还有一些以/item/
开头的, 却不是词条. 比如
<a href="/item/史记·2016?fr=navbar" target="_blank">史记·2016a>
我想, 我们需要对这些链接做一些筛选, 之前提到 的用 BeautifulSoup 和 正则表达式来筛选应该用得上.
导入一些模块, 设置起始页. 并将/item/...
的网页都放在 his
中, 做一个备案, 记录我们浏览过的网页.
from bs4 import BeautifulSoup
from urllib.request import urlopen
import re
import random
base_url = "https://baike.baidu.com"
his = ["/item/%E7%BD%91%E7%BB%9C%E7%88%AC%E8%99%AB/5162711"]
接着我们先不用循环, 对一个网页进行处理, 走一遍流程, 然后加上循环, 让我们的爬虫能在很多网页中爬取. 下面做的事情, 是为了在屏幕上打印出来我们现在正在哪张网页上, 网页的名字叫什么.
url = base_url + his[-1]
html = urlopen(url).read().decode('utf-8')
soup = BeautifulSoup(html, features='lxml')
print(soup.find('h1').get_text(), ' url: ', his[-1])
# 网络爬虫 url: /item/%E7%BD%91%E7%BB%9C%E7%88%AC%E8%99%AB/5162711
接下来我们开始在这个网页上找所有符合要求的 /item/
网址. 使用一个正则表达式,过滤掉不想要的网址形式. 这样我们找到的网址都是 /item/%xx%xx%xx...
这样的格式了. 之后我们在这些过滤后的网页中随机选一个, 当做下一个要爬的网页. 不过有时候很不幸, 在 sub_urls
中并不能找到合适的网页, 我们就往回跳一个网页, 回到之前的网页中再随机抽一个网页做同样的事.
# find valid urls
sub_urls = soup.find_all("a", {"target": "_blank", "href": re.compile("/item/(%.{2})+$")})
if len(sub_urls) != 0:
his.append(random.sample(sub_urls, 1)[0]['href'])
else:
# no valid sub link found
his.pop()
print(his)
# ['/item/%E7%BD%91%E7%BB%9C%E7%88%AC%E8%99%AB/5162711', '/item/%E4%B8%8B%E8%BD%BD%E8%80%85']
有了这套体系, 我们就能把它放在一个 for loop 中, 让它在各种不同的网页中跳来跳去.
his = ["/item/%E7%BD%91%E7%BB%9C%E7%88%AC%E8%99%AB/5162711"]
for i in range(20):
url = base_url + his[-1]
html = urlopen(url).read().decode('utf-8')
soup = BeautifulSoup(html, features='lxml')
print(i, soup.find('h1').get_text(), ' url: ', his[-1])
# find valid urls
sub_urls = soup.find_all("a", {"target": "_blank", "href": re.compile("/item/(%.{2})+$")})
if len(sub_urls) != 0:
his.append(random.sample(sub_urls, 1)[0]['href'])
else:
# no valid sub link found
his.pop()
这样我们就能观看我们的爬虫现在爬去了哪? 是不是爬到了和 “网页爬虫” 起始页完全不相关的地方去了.
0 网络爬虫 url: /item/%E7%BD%91%E7%BB%9C%E7%88%AC%E8%99%AB/5162711
1 路由器 url: /item/%E8%B7%AF%E7%94%B1%E5%99%A8
2 服务等级 url: /item/%E6%9C%8D%E5%8A%A1%E7%AD%89%E7%BA%A7
...
17 呼损率 url: /item/%E5%91%BC%E6%8D%9F%E7%8E%87
18 服务等级 url: /item/%E6%9C%8D%E5%8A%A1%E7%AD%89%E7%BA%A7
19 呼损率 url: /item/%E5%91%BC%E6%8D%9F%E7%8E%87
哈哈哈果然! 经过了20个循环, 它爬到了”呼损率”, 虽然我都不知道这是什么.
接下来来的爬虫, 我们将要了解更多需要知道的功能. 用 requests 代替 urlopen, 还有如何从网页下载等.
我们通常使用 Python 的自带模块 urllib, 来提交网页请求. 这个模块能满足我们大部分的需求, 但是为了满足你日益膨胀的其他需求, 比如向网页发送信息, 上传图片等等, 我们还有一个伟大的 Python 外部模块 requests
, 来有效的处理这些问题.
其实在加载网页的时候, 有几种类型, 而这几种类型就是你打开网页的关键. 最重要的类型 (method) 就是get
和 post
(当然还有其他的, 比如 head
, delete
). 刚接触网页构架的朋友可能又会觉得有点懵逼了. 这些请求的方式到底有什么不同? 他们又有什么作用?
我们就来说两个重要的, get
, post
, 95% 的时间, 你都是在使用这两个来请求一个网页.
get
就可以了, 比如 莫烦Python 里的所有页面, 都是只是 get
发送请求. 而post
, 我们则是给服务器发送个性化请求, 比如将你的账号密码传给服务器, 让它给你返回一个含有你个人信息的 HTML.从主动和被动的角度来说, post
中文是发送, 比较主动, 你控制了服务器返回的内容. 而 get
中文是取得, 是被动的, 你没有发送给服务器个性化的信息, 它不会根据你个性化的信息返回不一样的 HTML.
拿登录账号举个例子. 比如我想登录知乎账号(https://www.zhihu.com/people/morvan/activities, 在登录前, 我看到的页面是这样.
而如果我登录了(登录一般用post
), 我再次登录这个网页 https://www.zhihu.com/people/morvan/activities, 现在显示的 HTML 界面就和我上面那个不一样了, 这就是使用 post
给网页输入你的个性化信息后 (账号密码), 得到的服务器返回的个性化网页. 每个人的账号密码的到的页面都不一样.
而今天要说的 requests 模块就是干这些的. 它有着各种不同的请求方法, 而且用起来很方便.
Requests
是一个 Python 的外部模块, 我们需要手动安装它. 简单的方法, 在你的 terminal 或者是 cmd, 使用 pip 安装就好了.
# python 2+
pip install requests
# python 3+
pip3 install requests
官网上还提供了其他途径的安装.
有了 requests, 我们可以发送个中 method 的请求. 比如 get
. 我们想模拟一下百度的搜索. 首先我们需要观看一下百度搜索的规律. 在百度搜索框中写上 “莫烦python” 我们发现它弹出了一串这么长的网址.
但是仔细一看, 和 “莫烦Python” 有关的信息, 只有前面一小段 (“s?wd=莫烦python”), 其他的对我们来说都是无用的信息. 所以我们现在来尝试一下如果把后面的”无用” url 都去掉会怎样? Duang! 我们还是能搜到 “莫烦python”.
所以 “s?wd=莫烦python” 这就是我们搜索需要的关键信息. 我们就能用 get
来搭配一些自定义的搜索关键词来用 python 个性化搜索. 首先, 我们固定不动的网址部分是 “http://www.baidu.com/s”, ?
后面的东西都是一些参数 (parameters), 所以我们将这些 parameters 用 python 的字典代替, 然后传入 requests.get() 功能. 然后我们还能用 python (webbrowser模块) 打开一个你的默认浏览器, 观看你是否在百度的搜索页面.
import requests
import webbrowser
param = {"wd": "莫烦Python"} # 搜索的信息
r = requests.get('http://www.baidu.com/s', params=param)
print(r.url)
webbrowser.open(r.url)# 帮你打开这个网页
# http://www.baidu.com/s?wd=%E8%8E%AB%E7%83%A6Python
这时, python 会弹出一个浏览器界面, 然后你看到的, 就是 “莫烦Python” 的搜索结果了.
post
又怎么用呢? 我们举个小例子, 在这个简单网页中, 我们有一个提交信息的窗口, 如果我提交上去这个信息, 那边的服务器会更加这个提交的信息返回出另一个网页. 这就是网页怎么样使用你post
过去的信息了.
比如我在这里填上自己的姓名, 当我点 “submit” 的时候, 这个姓名(Morvan, Zhou) 就会被提交给服务器, 然后它会根据提交的姓名返回这个网页.
这样咋看起来好像和上面讲的 get
百度搜索没区别呀? 都是提交一些信息, 返回一个界面. 但是, 重点来了. 你看看网址栏. 你 post
上去的个人信息, 有没有显示在 url 里? 你愿意将你的私密信息显示在 url 里吗? 你post
过去的信息是交给服务器内部处理的. 不是用来显示在网址上的.
懂了这些, 我们就来看使用 python 和 requests 怎么做 post
这个操作吧.
首先我们调出浏览器的inspect
(右键点击 inspect, 中文是检查还是什么来着). 然后发现我们填入姓名的地方原来是在一个 里面.
这个里面有一些
个 tag, 我们仔细看到
里面的这个值
name="firstname"
和 name="lastname"
, 这两个就是我们要 post
提交上去的关键信息了. 我们填好姓名, 为了记录点击 “submit” 后, 浏览器究竟发生了什么翻天覆地的变化, 我们在 inspect
窗口, 选择Network
, 勾选Preserve log
, 再点击 “submit”, 你就能看到服务器返回给你定制化后的页面时, 你使用的方法和数据.
这些数据包括了:
'firstname'
和 'lastname'
, 也就是上图里面的 Form data, 组织成一个 python 字典. 让后把这个字典传入 requests.post()
, 注意, 这里的 post 里面的 url, 不是我们填表时的 url (http://pythonscraping.com/pages/files/form.html), 而是要把 Form 信息提交去的那个网页, 也就是上图中查看到的 Request URL (http://pythonscraping.com/files/processing.php).data = {'firstname': '莫烦', 'lastname': '周'}
r = requests.post('http://pythonscraping.com/pages/files/processing.php', data=data)
print(r.text)
# Hello there, 莫烦 周!
通过这个练习, 我们对 HTML 中的 Form 有了理解, 学会了怎么样使用 python 来提交 Form, 登录上提交后的页面.
传照片也是post
的一种, 我们得将本地的照片文件传送到服务器. 我们使用这个网页来模拟一次传照片的过程.
如果你留意观察 url, 你会发现, 传送完照片以后的 url 有变动. 我们使用同样的步骤再次检查, 发现, “choose file” 按键链接的 是一个叫
uploadFile
的名字. 我们将这个名字记下, 放入 python 的字典当一个 “key”.
接着在字典中, 使用 open 打开一个图片文件, 当做要上传的文件. 把这个字典放入你的 post
里面的files
参数. 就能上传你的图片了, 网页会返回一个页面, 将你的图片名显示在上面.
file = {'uploadFile': open('./image.png', 'rb')}
r = requests.post('http://pythonscraping.com/files/processing2.php', files=file)
print(r.text)
# The file image.png has been uploaded.
用post
还有一个重要的, 就是模拟登录. 再登录的时候发生了什么事情呢? 我们使用这个简单的登录网页进行说明.
通过之前提到的方法, 我们观察一下浏览器给出的记录. 三个重要的方面都被我圈出来了.
我们总结一下, 为了这次登录账号, 我们的浏览器做了什么.
第三点我们是从来没有提到过的. cookie, 听起来很熟呀! 每当游览器出现问题的时候, 网上的解决方法是不是都有什么清除 cookie 之类的, 那 cookie 实际上是什么呢?这里给出了和全面的介绍.
简单来说, 因为打开网页时, 每一个页面都是不连续的, 没有关联的, cookies 就是用来衔接一个页面和另一个页面的关系. 比如说当我登录以后, 浏览器为了保存我的登录信息, 将这些信息存放在了 cookie 中. 然后我访问第二个页面的时候, 保存的 cookie 被调用, 服务器知道我之前做了什么, 浏览了些什么. 像你在网上看到的广告, 为什么都可能是你感兴趣的商品? 你登录淘宝, 给你推荐的为什么都和你买过的类似? 都是 cookies 的功劳, 让服务器知道你的个性化需求.
所以大部分时候, 每次你登录, 你就会有一个 cookies, 里面会提到你已经是登录状态了. 所以 cookie 在这时候很重要. cookies 的传递也特别重要, 比如我用 requests.post
+ payload
的用户信息发给网页, 返回的 r
里面会有生成的 cookies 信息. 接着我请求去登录后的页面时, 使用 request.get
, 并将之前的 cookies 传入到 get 请求. 这样就能已登录的名义访问 get 的页面了.
payload = {'username': 'Morvan', 'password': 'password'}
r = requests.post('http://pythonscraping.com/pages/cookies/welcome.php', data=payload)
print(r.cookies.get_dict())
# {'username': 'Morvan', 'loggedin': '1'}
r = requests.get('http://pythonscraping.com/pages/cookies/profile.php', cookies=r.cookies)
print(r.text)
# Hey Morvan! Looks like you're still logged into the site!
不过每次都要传递 cookies 是很麻烦的, 好在 requests 有个很 handy 的功能, 那就是 Session. 在一次会话中, 我们的 cookies 信息都是相连通的, 它自动帮我们传递这些 cookies 信息. 这时我感叹, 程序员真会偷懒~ 哈哈.
同样是执行上面的登录操作, 下面就是使用 session 的版本. 创建完一个 session 过后, 我们直接只用 session 来 post
和get
. 而且这次 get
的时候, 我们并没有传入 cookies. 但是实际上 session 内部就已经有了之前的 cookies 了.
session = requests.Session()
payload = {'username': 'Morvan', 'password': 'password'}
r = session.post('http://pythonscraping.com/pages/cookies/welcome.php', data=payload)
print(r.cookies.get_dict())
# {'username': 'Morvan', 'loggedin': '1'}
r = session.get("http://pythonscraping.com/pages/cookies/profile.php")
print(r.text)
# Hey Morvan! Looks like you're still logged into the site!
这就是我们这次的教学, 想了解更多 requests 使用的朋友看到这里. 下一次我们就来看看如何用不同的方式来下载网页内容.
从网上下载文件的时候你肯定很开心, 比如说什么电影, pdf, 音乐等. 我们使用爬虫, python 一样也可以做到. 而且做得途径还有很多. 今天我们就来探讨有哪些可行的途径, 之后我们还能用这些途径来做一个小实战, 下载国家地理杂志上的美图.
在下载之前, 我们的弄清楚怎么样下载. 打个比方, 以前有朋友留言说我的教程太多, 不知道从何学起, 我专门制作了一张学习流程图, 这张莫烦Python的个性化学习路线应该也拯救了无数迷途少年吧. 哈哈哈, 今天我们来爬这张图, 还有下载这张图.
想下这张图, 我们首先要到这张图所在的网页. 在这个网页中找到这张图的位置, 并右键 inspect
, 找到它在 HTML 中的信息.
`
发现原图被存放在这个网页, 注意这个地址开头是 /, 并不是完整的网址, 这种形式代表着, 它是在 “https://morvanzhou.github.io/” 下面的网址. 所以我们还要将其补全, 才能在网址栏中找到这个图片地址.
src="/static/img/description/learning_step_flowchart.png"
补全后的网址是:
https://morvanzhou.github.io/static/img/description/learning_step_flowchart.png
找到了这个网址, 我们就能开始下载了. 为了下载到一个特定的文件夹, 我们先建立一个文件夹吧. 并且规定这个图片下载地址.
import os
os.makedirs('./img/', exist_ok=True)
IMAGE_URL = "https://morvanzhou.github.io/static/img/description/learning_step_flowchart.png"
在 urllib 模块中, 提供了我们一个下载功能 urlretrieve. 使用起来很简单. 输入下载地址 IMAGE_URL
和要存放的位置. 图片就会被自动下载过去了.
from urllib.request import urlretrieve
urlretrieve(IMAGE_URL, './img/image1.png')
而在 requests
模块, 也能拿来下东西. 下面的代码实现了和上面一样的功能, 但是稍微长了点. 但我们为什么要提到 requests 的下载呢? 因为使用它的另一种方法, 我们可以更加有效率的下载大文件.
import requests
r = requests.get(IMAGE_URL)
with open('./img/image2.png', 'wb') as f:
f.write(r.content)
所以说, 如果你要下载的是大文件, 比如视频等. requests 能让你下一点, 保存一点, 而不是要全部下载完才能保存去另外的地方. 这就是一个 chunk 一个 chunk 的下载. 使用r.iter_content(chunk_size)
来控制每个 chunk 的大小, 然后在文件中写入这个 chunk 大小的数据.
r = requests.get(IMAGE_URL, stream=True) # stream loading
with open('./img/image3.png', 'wb') as f:
for chunk in r.iter_content(chunk_size=32):
f.write(chunk)
有了这些知识的积累, 我们就能开始做一个小的实战练习, 去下载一些国家地理杂志的美图啦.
学会爬虫, 关键在于练习, 见多识广, 见多了网页的构造, 才知道怎么爬. 今天我们就来一个小实战, 结合之前学习的 requests访问
和下载
功能, 还有 BeautifulSoup,
来下载一些国家地理杂志的美图.
说白了, 每次的爬虫, 都是先分析一下这个网页要找的东西的位置, 然后怎么索引上这个位置, 最后用 python 找到它. 这次也是这个逻辑. 我们看看今天要爬的这个图片网址. 定位到最新图片的位置,
找到这张图片的所在位置, 对比这类型的图片, 找到一种手段来筛选这些图片. 发现他们都存在于 img_list
的这种
中.
而图片地址都是在 中.
<img src="http://image.nationalgeographic.com.cn/2017/1228/20171228030617696.jpg">
现在我们有了思路, 先找带有 img_list 的这种
, 然后在
里面找 .
有了思路, 现在我们就用 python 来下图吧. import BeautifulSoup 和 requests. 定义爬取的 url.
from bs4 import BeautifulSoup
import requests
URL = "http://www.nationalgeographic.com.cn/animals/"
用 BeautifulSoup 找到带有img_list
的这种
html = requests.get(URL).text
soup = BeautifulSoup(html, 'lxml')
img_ul = soup.find_all('ul', {"class": "img_list"})
从 ul 中找到所有的, 然后提取
的
src
属性, 里面的就是图片的网址啦. 接着, 就用之前在 requests 下载那节内容里提到的一段段下载.
for ul in img_ul:
imgs = ul.find_all('img')
for img in imgs:
url = img['src']
r = requests.get(url, stream=True)
image_name = url.split('/')[-1]
with open('./img/%s' % image_name, 'wb') as f:
for chunk in r.iter_content(chunk_size=128):
f.write(chunk)
print('Saved %s' % image_name)
"""
Saved 20171227102206573.jpg
...
Saved 20171214020322682.jpg
"""
我从下载好的照片中抽了一张出来, 哈哈, 是张河马.
如果你只是偶尔爬一爬网页, 学到目前为止, 你已经入门了, 但是如果你想要继续深入, 你开始对爬虫的效率担忧, 觉得自己爬得太慢, 想要大规模爬取网页, 那么接下来的内容就再适合你不过了. 接下来我们就会提到爬虫的提效方法. 而且现在我们爬取的都是静态网页 (莫烦python 就是静态网页), 如果你遇到 JavaScript 很多的动态加载网页 (淘宝等), 后面的 selenium 教程就很适合你.
当你看到这里的时候, 说明你已经不满足于自己的爬虫速度, 你想要最求更快, 更便捷的爬虫方法. 你常常会听到用爬虫的人说分布式爬虫. 这就是为了体现便捷和效率而出现的方法. 这一节内容, 我们简单地介绍一下我使用的分布式爬虫方法, 并且用 python 的 multiprocessing 模块编写一个分布式爬虫.
分布式爬虫主要是为了非常有效率的抓取网页, 我们的程序一般是单线程跑的, 指令也是一条条处理的, 每执行完一条指令才能跳到下一条. 那么在爬虫的世界里, 这里存在着一个问题.
如果你已经顺利地执行过了前几节的爬虫代码, 你会发现, 有时候代码运行的时间大部分都花在了下载网页上. 有时候不到一秒能下载好一张网页的 HTML, 有时候却要几十秒. 而且非要等到 HTML 下载好了以后, 才能执行网页分析等步骤. 这非常浪费时间.
如果我们能合理利用计算资源, 在下载一部分网页的时候就已经开始分析另一部分网页了. 这将会大大节省整个程序的运行时间. 又或者, 我们能同时下载多个网页, 同时分析多个网页, 这样就有种事倍功半的效用. 分布式爬虫的体系有很多种, 处理优化的问题也是多样的. 这里有一篇博客可以当做扩展阅读, 来了解当今比较流行的分布式爬虫框架.
而今天我们想搭建的这一个爬虫, 就是同时下载, 同时分析的这一种类型的分布式爬虫. 虽然算不上特别优化的框架, 但是概念理解起来比较容易. 我有尝试过徒手写高级一点的分布式爬虫, 但是写起来非常麻烦. 我琢磨了一下, 打算给大家介绍的这种分布式爬虫代码也较好写, 而且效率比普通爬虫快了3.5倍. 我也特地画了张图给大家解释一下要搭建的分布式爬虫.
主要来说, 我们最开始有一个网页, 比如说是莫烦Python的首页, 然后首页中有很多 url, 我们使用多进程 (Python多进程教程) 同时开始下载这些 url, 得到这些 url 的 HTML 以后, 同时开始解析 (比如 BeautifulSoup) 网页内容. 在网页中寻找这个网站还没有爬过的链接. 最终爬完整个 莫烦 Python 网站所有页面.
有了这种思路, 我们就可以开始写代码了. 你可以在我的 Github 一次性观看全部代码.
首先 import 全部要用的模块, 并规定一个主页. **注意, 我用这份代码测试我内网的网站(速度不受外网影响) **所以使用的 base_url
是 “http://127.0.0.1:4000/”, 如果你要爬 莫烦Python, 你的 base_url 要是 “https://morvanzhou.github.io/”
(下载速度会受外网影响).
import multiprocessing as mp
import time
from urllib.request import urlopen, urljoin
from bs4 import BeautifulSoup
import re
# base_url = "http://127.0.0.1:4000/"
base_url = 'https://morvanzhou.github.io/'
我们定义两个功能, 一个是用来爬取网页的(crawl), 一个是解析网页的(parse). 有了前几节内容的铺垫, 你应该能一言看懂下面的代码.crawl()
用 urlopen 来打开网页, 我用的内网测试, 所以为了体现下载网页的延迟, 添加了一个 time.sleep(0.1)
的下载延迟. 返回原始的 HTML 页面, parse()
就是在这个 HTML 页面中找到需要的信息, 我们用 BeautifulSoup 找 (BeautifulSoup 教程). 返回找到的信息.
def crawl(url):
response = urlopen(url)
# time.sleep(0.1) # slightly delay for downloading
return response.read().decode()
def parse(html):
soup = BeautifulSoup(html, 'lxml')
urls = soup.find_all('a', {"href": re.compile('^/.+?/$')})
title = soup.find('h1').get_text().strip()
page_urls = set([urljoin(base_url, url['href']) for url in urls]) # 去重
url = soup.find('meta', {'property': "og:url"})['content']
return title, page_urls, url
网页中爬取中, 肯定会爬到重复的网址, 为了去除掉这些重复, 我们使用 python 的 set 功能. 定义两个 set
, 用来搜集爬过的网页和没爬过的.
unseen = set([base_url,])
seen = set()
为了对比效果, 我们将在下面对比普通的爬虫和这种分布式的效果. 如果是普通爬虫, 我简化了一下接下来的代码, 将一些不影响的代码去除掉了, 如果你想看全部的代码, 请来到我的 Github. 我们用循环一个个 crawl
unseen
里面的 url, 爬出来的 HTML 放到parse
里面去分析得到结果. 接着就是更新seen
和 unseen
这两个集合了.
特别注意: 任何网站都是有一个服务器压力的, 如果你爬的过于频繁, 特别是使用多进程爬取或异步爬取, 一次性提交请求给服务器太多次, 这将可能会使得服务器瘫痪, 你可能再也看不到莫烦 Python 了. 所以为了安全起见, 我限制了爬取数量(restricted_crawl=True). 因为我测试使用的是内网 “http://127.0.0.1:4000/” 所以不会有这种压力. 你在以后的爬网页中, 会经常遇到这样的爬取次数的限制 (甚至被封号). 我以前爬 github 时就被限制成一小时只能爬60页.
# DON'T OVER CRAWL THE WEBSITE OR YOU MAY NEVER VISIT AGAIN
if base_url != "http://127.0.0.1:4000/":
restricted_crawl = True
else:
restricted_crawl = False
while len(unseen) != 0: # still get some url to visit
if restricted_crawl and len(seen) >= 20:
break
htmls = [crawl(url) for url in unseen]
results = [parse(html) for html in htmls]
seen.update(unseen) # seen the crawled
unseen.clear() # nothing unseen
for title, page_urls, url in results:
unseen.update(page_urls - seen) # get new url to crawl
使用这种单线程的方法, 在我的内网上面爬, 爬完整个 莫烦Python, 一共消耗 52.3秒. 接着我们把它改成多进程分布式.
还是上一个 while
循环, 首先我们创建一个进程池(Pool). 不太懂进程池的朋友看过来. 然后我们修改得到htmls
和 results
的两句代码. 其他都不变, 只将这两个功能给并行了. 我在这里写的都是简化代码, 你可以在这里 看到完整代码.
if __name__=='__main__'
pool = mp.Pool(4)
while len(unseen) != 0:
# htmls = [crawl(url) for url in unseen]
# --->
crawl_jobs = [pool.apply_async(crawl, args=(url,)) for url in unseen]
htmls = [j.get() for j in crawl_jobs]
# results = [parse(html) for html in htmls]
# --->
parse_jobs = [pool.apply_async(parse, args=(html,)) for html in htmls]
results = [j.get() for j in parse_jobs]
还是在内网测试, 只用了 16.3秒!! 这可比上面的单线程爬虫快了3.5倍. 而且我还不是在外网测试的. 如果在外网, 爬取一张网页的时间更长, 使用多进程会更加有效率, 节省的时间更多.
看到这里, 你一定觉得多线程是爬虫的救星. 其实不然, 要不然我们的教程为什么还能继续. 哈哈. 下一节, 我们会讲到比多进程更加厉害的一种方法. 叫做异步爬取 (asyncio 模块).
之前我一直在想如何用 multiprocessing 或者 threading 加速我的爬虫, 也做过了一些小实验, 确实, 我们看到了不小的效率提升. 但是当我更加深入的时候, 我发现, Python 还提供了一个有力的工具, 叫做 asyncio. 这是一个仅仅使用单线程, 就能达到多线程/进程的效果的工具. 它的原理, 简单说就是: 在单线程里使用异步计算, 下载网页的时候和处理网页的时候是不连续的, 更有效利用了等待下载的这段时间.
传统的单线程下载处理网页可能就像下图(来源)左边蓝色那样, 计算机执行一些代码, 然后等待下载网页, 下好以后, 再执行一些代码… 或者在等待的时候, 用另外一个线程执行其他的代码, 这是多线程的手段. 那么 asyncio 就像右边, 只使用一个线程, 但是将这些等待时间统统掐掉, 下载应该都调到了后台, 这个时间里, 执行其他异步的功能, 下载好了之后, 再调回来接着往下执行.
如果换一张 Python 自家解释 asyncio 的图(来源), 虽然稍微复杂一点, 但是就是和上图想要表达的是一个意思.
那么, 我们今天就来尝试使用 asyncio 来替换掉 multiprocessing 或者 threading, 看看效果如何.
Asyncio
库是 Python 的原装库, 但是是在 Python 3 的时候提出来的, Python 2 和 Python 3.3- 是没有的. 而且 Python 3.5 之后, 和 Python 3.4 前在语法上还是有些不同, 比如 “await” 和 “yield” 的使用, 下面的教程都是基于 Python 3.5+, 使用 Python3.4 的可能会执行有点问题. 调整一下就好.
在 3.5+ 版本中, asyncio 有两样语法非常重要, async
, await
. 弄懂了它们是如何协同工作的, 我们就完全能发挥出这个库的功能了. 剧透一下, 等会使用单线程爬网页的 asyncio 和之前多进程写的爬网页效果差不多, 而且当并行的进程数少的时候, asyncio 效果还会比多进程快.
接着我们来举例介绍 asyncio, 像之前画的图那样, 我们要时刻记住, asyncio 不是多进程, 也不是多线程, 单单是一个线程, 但是是在 Python 的功能间切换着执行. 切换的点用await
来标记, 能够异步的功能用 async
标记, 比如 async def function():
. 首先我们看一下, 不使用async
完成的一份代码, 然后我们将这份代码改成 async
版的. 这些代码我都会放在我的 github 中, 如果想一次性看全部, 请来这里.
关于asyncio的一些关键字的说明:
- event_loop 事件循环:程序开启一个无限循环,把一些函数注册到事件循环上,当满足事件发生的时候,调用相应的协程函数
- coroutine 协程:协程对象,指一个使用async关键字定义的函数,它的调用不会立即执行函数,而是会返回一个协程对象。协程对象需要注册到事件循环,由事件循环调用。
- task 任务:一个协程对象就是一个原生可以挂起的函数,任务则是对协程进一步封装,其中包含了任务的各种状态
- future: 代表将来执行或没有执行的任务的结果。它和task上没有本质上的区别
- async/await 关键字:python3.5用于定义协程的关键字,async定义一个协程,await用于挂起阻塞的异步调用接口。
# 不是异步的
import time
def job(t):
print('Start job ', t)
time.sleep(t) # wait for "t" seconds
print('Job ', t, ' takes ', t, ' s')
def main():
[job(t) for t in range(1, 3)]
t1 = time.time()
main()
print("NO async total time : ", time.time() - t1)
"""
Start job 1
Job 1 takes 1 s
Start job 2
Job 2 takes 2 s
NO async total time : 3.008603096008301
"""
从上面可以看出, 我们的 job 是按顺序执行的, 必须执行完 job 1 才能开始执行 job 2, 而且 job 1 需要1秒的执行时间, 而 job 2 需要2秒. 所以总时间是 3 秒多. 而如果我们使用 asyncio 的形式, job 1 在等待 time.sleep(t) 结束的时候, 比如是等待一个网页的下载成功, 在这个地方是可以切换给 job 2, 让它开始执行.
import asyncio
async def job(t): # async 形式的功能
print('Start job ', t)
await asyncio.sleep(t) # 等待 "t" 秒, 期间切换其他任务 协程遇到await,事件循环将会挂起该协程,执行别的协程,直到其他的协程也挂起或者执行完毕,再进行下一个协程的执行
print('Job ', t, ' takes ', t, ' s')
async def main(loop): # async 形式的功能
tasks = [
loop.create_task(job(t)) for t in range(1, 3)
] # 创建任务task, 但是不执行 task加入事件循环之前为pending状态,当完成后,状态为finished
await asyncio.wait(tasks) # 执行并等待所有任务完成
t1 = time.time()
loop = asyncio.get_event_loop() # 创建一个事件循环
loop.run_until_complete(main(loop)) # 将协程注册到事件循环,并启动事件循环
loop.close() # 关闭 loop
print("Async total time : ", time.time() - t1)
"""
Start job 1
Start job 2
Job 1 takes 1 s
Job 2 takes 2 s
Async total time : 2.001495838165283
"""
从结果可以看出, 我们没有等待job 1
的结束才开始 job 2
, 而是 job 1
触发了 await
的时候就切换到了job 2
了. 这时, job 1
和 job 2
同时在等待 await asyncio.sleep(t)
, 所以最终的程序完成时间, 取决于等待最长的 t, 也就是 2秒. 这和上面用普通形式的代码相比(3秒), 的确快了很多.
有了对 asyncio 的基本了解, 我们就来看怎么把它用在爬虫. 这个功能对于爬虫非常的理想, 原因很简单, 我们在等待一个网页下载的时候, 完全可以切换到其它代码, 事半功倍. 但是 asycio 自己还是没办法完成这项任务的, 我们还需要安装另一个牛逼的模块将 requests
模块代替成一个异步的 requests
, 这个牛逼的模块叫作 aiohttp
(官网在这). 下载安装特别简单. 直接在你的 terminal 或者 cmd 里面输入 “pip3 install aiohttp”.
接着我们来看看我们怎么用最一般的 requests 模块爬网页, 和我们怎么将 requests 替换成 aiohttp.
import requests
URL = 'https://morvanzhou.github.io/'
def normal():
for i in range(2):
r = requests.get(URL)
url = r.url
print(url)
t1 = time.time()
normal()
print("Normal total time:", time.time()-t1)
"""
https://morvanzhou.github.io/
https://morvanzhou.github.io/
Normal total time: 0.3869960308074951
"""
用 requests 用久了以后, 这样的代码真是信手拈来. 很好, 我们打开 莫烦 Python 的首页两次只花了 0.38秒. 然后我们在用 aiohttp
来实现一样的功能. 结果 asyncio 的方式只用了 0.11秒! 大获全胜.
import aiohttp
async def job(session):
response = await session.get(URL) # 等待并切换
return str(response.url)
async def main(loop):
async with aiohttp.ClientSession() as session: # 官网推荐建立 Session 的形式
tasks = [loop.create_task(job(session)) for _ in range(2)]
finished, unfinished = await asyncio.wait(tasks)
all_results = [r.result() for r in finished] # 获取所有结果
print(all_results)
t1 = time.time()
loop = asyncio.get_event_loop()
loop.run_until_complete(main(loop))
loop.close()
print("Async total time:", time.time() - t1)
"""
['https://morvanzhou.github.io/', 'https://morvanzhou.github.io/']
Async total time: 0.11447715759277344
"""
我们刚刚创建了一个 Session, 这是官网推荐的方式, 但是我觉得也可以直接用 request
形式, 细节请参考官方说明. 如果要获取网页返回的结果, 我们可以在 job()
中 return 个结果出来, 然后再在finished, unfinished = await asyncio.wait(tasks)
收集完成的结果, 这里它会返回完成的和没完成的, 我们关心的都是完成的, 而且 await
也确实是等待都完成了才返回. 真正的结果被存放在了 result()
里面.
有了这些基础, 我们就可以来玩点高级的了, 之前我们用 multiprocessing 写过了一个简单的分布式爬虫, 现在我们就来拿过来 PK 一下 asyncio 的方法. 首先我们对比一下这次写的结构和上次写的简单分布式爬虫的区别. 分布式我们完全依赖的是 multiprocessing 这个模块. 不了解的可以快速过一遍这个教程. 使用 python 强大的并行处理运算来下载我们要处理的 urls, 然后解析网页也是一件耗时的事, 特别是网页量多的时候. 所以我们也将网页解析给并行了. 这样大大节省了下载和运算时间. 再看右边的这个 asyncio 的例子, 我们解析网页还是用的和 multiprocessing 那边一样的并行处理, 因为 asyncio 好像不支持解析网页的异步, 毕竟是计算密集型工序. 然后不一样的地方是, 我们在下载网页时, 不用 multiprocessing, 改用 asyncio, 用一个单线程的东西挑战多进程.
特别注意: 任何网站都是有一个服务器压力的, 如果你爬的过于频繁, 特别是使用多进程爬取或异步爬取, 一次性提交请求给服务器太多次, 这将可能会使得服务器瘫痪, 你可能再也看不到莫烦 Python 了. 所以为了安全起见, 我限制了爬取数量(restricted_crawl=True). 因为我测试使用的是内网 “http://127.0.0.1:4000/” 所以不会有这种压力. 你在以后的爬网页中, 会经常遇到这样的爬取次数的限制 (甚至被封号). 我以前爬 github 时就被限制成一小时只能爬60页.
具体的代码可以在这里详细观看, 需要注意的是, 我使用的内网进行测试(外网的下载速度变动太大), 在下载网页的地方, 我使用 sleep(0.1)
的功能模拟了网页下载的延迟. 一共下载了我 莫烦 Python 的快400个网页. 因为代码表达的内容我已经用上图展示给大家了, 每一个代码都有50-60行, 我就不粘贴在这里了. 具体的结果, 我们可以总结一下.
Number of Process | Multiprocessing | Asyncio |
---|---|---|
2 | 25.5s | 7.5s |
4 | 15.4s | 7.0s |
8 | 11.5s | 7.2s |
我们发现, 如果 Pool(n)
里面的这个 n 越大, 多进程才能越快, 但是 asyncio 却不会特别受进程数的影响. 一个单线程的东西居然战胜了多进程. 可见异步 asyncio 下载网页的重要性.
上面介绍的还只是 asyncio 的一小部分功能, 如果想了解更多有关于 asyncio 的使用方法, 请看到 Python 的官方介绍.
Selenium 是为了测试而出生的. 但是没想到到了爬虫的年代, 它摇身一变, 变成了爬虫的好工具. 让我试着用一句话来概括 Seleninm: 它能控制你的浏览器, 有模有样地学人类”看”网页.
那么你什么时候会要用到 Selenium 呢? 当你:
因为 Selenium 需要操控你的浏览器, 所以安装起来比传统的 Python 模块要多几步. 先在 terminal 或者 cmd 用 pip 安装 selenium.
# python 2+
pip install selenium
# python 3+
pip3 install selenium
要操控浏览器, 你就要有浏览器的 driver. Selenium 针对几个主流的浏览器都有 driver. 针对 Linux 和 MacOS.
Linux 和 MacOS 用户下载好之后, 请将下载好的”geckodriver”文件放在你的计算机的 “/usr/bin” 或 “/usr/local/bin” 目录. 并赋予执行权限, 不会放的, 请使用这条语句.
sudo cp 你的geckodriver位置 /usr/local/bin
sudo chmod +x /usr/local/bin/geckodriver
对于 Windows 用户, 下载对应版本的driver,放在python.exe所在文件夹下
如果你安装有任何的问题, 请在它们的官网上查询解决方案.
在这教你用火狐浏览器偷懒的一招, 因为暂时只有火狐上有这个插件. 插件 Katalon Recorder 下载的网址在这
这个插件能让你记录你使用浏览器的操作. 我以前玩网游, 为了偷懒, 用过一个叫”按键精灵”的东西, 帮我做了很多重复性的工作, 拯救了我的鼠标和键盘, 当然还有我的手指! 看着别人一直在点鼠标, 我心中暗爽~ 这个 Katalon Recorder 插件 + Selenium 就和按键精灵是一个意思. 记录你的操作, 然后你可以让电脑重复上千遍.
找到插件上的 record, 点它. 然后用火狐登录上 莫烦Python, 开始你的各种点击工作, 比如我的一连串操作是 (强化学习教程->About页面->赞助页面->教程->数据处理->网页爬虫)
每当你点击的时候, 插件就会记录下你这些点击, 形成一些log. 最后神奇的事情将要发生. 你可以点击 Export 按钮, 观看到帮你生成的浏览记录代码!
虽然这个代码输出只有 Python2 版本的, 不过不影响. 我们直接将这些圈起来的代码复制. 这将会是 python 帮你执行的行为代码.
好了, 有了这些代码, 我们就能回到 Python. 开始写 Python 的代码了. 这里十分简单! 我将 selenium 绑定到 Chrome 上 webdriver.Chrome()
. 你可以绑其它的浏览器.
from selenium import webdriver
driver = webdriver.Chrome() # 打开 Chrome 浏览器
# 将刚刚复制的帖在这
driver.get("https://morvanzhou.github.io/")
driver.find_element_by_xpath(u"//img[@alt='强化学习 (Reinforcement Learning)']").click()
driver.find_element_by_link_text("About").click()
driver.find_element_by_link_text(u"赞助").click()
driver.find_element_by_link_text(u"教程 ▾").click()
driver.find_element_by_link_text(u"数据处理 ▾").click()
driver.find_element_by_link_text(u"网页爬虫").click()
# 得到网页 html, 还能截图
html = driver.page_source # get html
driver.get_screenshot_as_file("./img/sreenshot1.png")
driver.close()
我们能得到页面的 html code (driver.page_source), 就能基于这个 code 来爬取数据了. 最后爬取的网页截图就是这样.
不过每次都要看着浏览器执行这些操作, 有时候有点不方便. 我们可以让 selenium 不弹出浏览器窗口, 让它”安静”地执行操作. 在创建driver
之前定义几个参数就能摆脱浏览器的身体了.
from selenium.webdriver.chrome.options import Options
chrome_options = Options()
chrome_options.add_argument("--headless") # define headless
driver = webdriver.Chrome(chrome_options=chrome_options)
...
最后同样再截一张图, 证明 driver 真的爬到了这个 “网页爬虫” 教程页面. 不过因为没有出现实体的浏览器, 这个页面大小和上面的图片还是有点差别的.
Selenium 能做的事还有很多, 比如填 Form 表单, 超控键盘等等. 这个教程不会细说了, 只是个入门, 如果你还想继续深入了解, 欢迎点进去他们的 Python 教学官网.
最后, Selenium 的优点我们都看出来了, 可以很方便的帮你模拟你的操作, 添加其它操作也是非常容易的, 但是也是有缺点的, 不是任何时候 selenium 都很好. 因为要打开浏览器, 加载更多东西, 它的执行速度肯定没有其它模块快. 所以如果你需要速度, 能不用 Selenium, 就不用吧.
Scrapy 官网
这个教程教你写出一个 Scrapy 形式的爬虫, 带你入门 Scrapy, 但是 Scrapy 不仅仅只有爬虫, 你需要学习更多. 那学习 Scrapy 的地方, 当然是他们自家网站咯.
Scrapy 是一个整合了的爬虫框架, 有着非常健全的管理系统. 而且它也是分布式爬虫, 但是比我们之前写的那个分布式爬虫高级多了. 下面就是 Scrapy 的框架示意图(来源). 它的管理体系非常复杂. 但是特别高效. 让你又刷网页, 又下载, 同时能处理数据. 简直千手观音呀.
而且做 Scrapy 的项目, 绝对不是只需要写一个脚本就能解决的. 为了把你带入门, 这次我们只写一个脚本, 只涉及里面的爬虫(spider)部分. 其他的部分你可以在这里深入学习.
好了, 我们开始今天的简单 Scrapy 教程吧. 首先你得安装 Scrapy. 在 terminal 或者 cmd 使用 pip 安装就好.
# python 2+
pip install scrapy
# python 3+
pip3 install scrapy
如果安装遇到任何问题, 它们家的网站是个好去处.
我们之前有做过爬取 莫烦Python 全网的信息. 用多进程 和异步爬取都做过. 这次, 我们就用 Scrapy 来实现这样的一个爬虫. 剧透一下, 做前两个的时候, 代码行数差不多都是 50+ 行, 但是 scrapy 只需要用 20+ 行代码就解决的上面的事. 哈哈, 功能强大吧.
我们导入 scrapy 模块, 并创建一个 spider 的 class. 并继承 scrapy.Spider
, 一定还要给这个 spider 一个名字, 我就用 mofan
好了, 因为是爬 莫烦Python 的. 给定一些初始爬取的网页, 写在 start_urls
里. 这里特别要提的是: 之前我们用 python 的 set 来去除重复的 url, 在 scrapy 中, 这是不需要的, 因为它自动帮你去重. 这可省心多了. 如果你想一次性看到全部代码, 请看到我的 github.
import scrapy
class MofanSpider(scrapy.Spider):
name = "mofan"
start_urls = [
'https://morvanzhou.github.io/',
]
# unseen = set()
# seen = set() # 我们不在需要 set 了, 它自动去重
接着我们还要定义这个 class 中的一个功能就能完事了. 我们使用 python 的yield
来返回搜集到的数据 (为什么是yield? 因为在 scrapy 中也有异步处理, 加速整体效率). 这些 title 和 url 的数据, 我们都是用 scrapy 中抓取信息的方式.
class MofanSpider(scrapy.Spider):
...
def parse(self, response):
yield { # return some results
'title': response.css('h1::text').extract_first(default='Missing').strip().replace('"', ""),
'url': response.url,
}
urls = response.css('a::attr(href)').re(r'^/.+?/$') # find all sub urls
for url in urls:
yield response.follow(url, callback=self.parse) # it will filter duplication automatically
然后在这个response网页中筛选urls
, 这里我们也不需要使用 urljoin()
这种功能给 url 改变形式. 它在follow()
这一步会自动检测 url 的格式. (真是省心啊~),然后对于每个找到的 url, 然后 yield 重新使用 self.parse()
来爬取, 这里又是自动去重! Scrapy 仿佛知道你最不想做什么, 它自动帮你都做好了. 开心~
最后需要运行的时候有点不同, 你需要在 terminal 或 cmd 中运行这个爬虫. 而且还能帮你保存刚刚 yield 的{title:, url:}
的结果.runspider 5-2-scrapy.py
就是选择你要跑的这个 Python 文件.
$ scrapy runspider 5-2-scrapy.py -o res.json
-o res.json
这个 -o
就是输出的指令, 你可以在那个文件夹中找到一个名字叫res.json
的文件, 里面存有所有找到的 {title:, url:}
.
其实我们今天只做了 scrapy 中的爬虫, 一个正常的 scrapy 项目还包括有很多其他的内容(见下面). 这个教程就不会细说了, 因为学好 scrapy 还是比较麻烦的. 你可以在上面推荐给你的链接中, 继续深入学习 scrapy.
tutorial/
scrapy.cfg # deploy configuration file
tutorial/ # project's Python module, you'll import your code from here
__init__.py
items.py # project items definition file
middlewares.py # project middlewares file
pipelines.py # project pipelines file
settings.py # project settings file
spiders/ # a directory where you'll later put your spiders
__init__.py