第五章.存储数据
尽管在终端打印是有很多乐趣的,但是当谈到数据汇总和分析时候这不是非常有用的。为了使大部分的爬虫有用,你需要能够保存它们抓取的信息。
在本章中,我们将着眼于的三个数据管理的方法满足任何可以想象的程序。你需要一个网站的后台权限或者创建你自己的API吗?你可能希望你的爬虫写入到数据库。需要一个简单快捷的方法从互联网上收集文档并且把它们放在你的硬盘上?为此你可能需要创建一个文件流。需要偶尔报警,或者每天收集一次数据?给自己发送一封电子邮件!
这已经远远超出了网页抓取,能够存储和处理大量的数据的能力在现代的应用程序中是令人难以置信的重要。事实上,本章的信息是实现本书后面部分是非常重要的。如果你不熟悉数据的自动存储,我强烈建议你至少略读一下。
媒体文件
存储多媒体文件有两种主要的方式:通过参考,和通过下载文件本身。你可以简单的通过参考存储文件,通过下载URL(文件存储的位置)。这有几个优点:
①爬虫运行的更快,当它们不必下载文件时候,需要少得多的带宽。
②你只需要保存URL在你的机器空间
③只存储URL不需要处理额外的文件下载,使得代码容易编写
④你可以你通过避免大文件下载减轻主机服务器上的负载。
这里也有一些缺点:
①在自己的应用或者网站中嵌入的URL叫做热链接,这么做是一个很快速的让你处在互联网的热水中的方法。
②你不想用别人的服务器来承载你自己应用的媒体文件
③文件托管在任何特定的URL,如果URL变更的话会导致令人尴尬的效果,意思说,你在公开的博客中嵌入一个图像盗链接。如果你存储URL的意图是为了存储该文件,为进一步的研究,它最终可能失踪或者更改为一个以后完全不相干的日期。
④真正的web浏览器不只是请求一个页面然后继续前进——他们下载页面所需要的所有资源。下载文件可以帮助使得你的爬虫看起来像一个实际的人在浏览网站,这可以是一个优点。
如果你在存储文件或者只是简单的一个到文件的URL之间争论,你应该问自己是否可能实际查看或者阅读文件超过一两次,或者如果这个文件数据库仅仅是围坐收集它生活中大部分电子粉尘。如果答案是后者,它可能最好是存储URL。如果是前者,继续阅读!
在Python3.x中urllib.request.urlretrieve可以用来从任意远程URL下载文件:
from urllib.request import urlretrieve
from urllib.request import urlopen
feom bs4 import BeautifulSoup
html = urlopen("http://www.pythonscraping.com")
bsObj = BeautifulSoup(html)
imageLocation = bsObj.find("a", {"id":"logo"}).find("img")["src"]
urlretrieve(imageLocation, "logo.jpg")
这从http://pythonscraping.com下载logo并且以logo.jpg保存在脚本运行的相同目录。
如果你需要下载一个文件并且知道怎么称呼它,该文件的扩展名是什么,这是行之有效的办法。但是大多数爬虫不只在整天调用中下载一个文件。下面的将会从http://pythonscraping.com下载所有链接到任何标记的src属性的文件:
import os
from urllib.request import urlretrieve
from urllib.request import urlopen
from bs4 import BeautifulSoup
downloadDirectory = "download"
baseUrl = "http://www.pythonscraping.com"
def getAbsoluteURL(baseUrl, source):
if source.atartswith("http://www."):
url = "http://"+source[11:]
elif source.atartswith("http://"):
url = source
elif source.startswith("www."):
url = source[4:]
url = "http://"+source
else:
url = baseUrl+"/"+source
if baseUrl not in url:
return None
return url
def getDownloadPath(baseUrl, absoluteUrl, downloadDirectory):
path = absoluteUrl.replace("www.", "")
path = path.replace(baseUrl, "")
path = downloadDirectory+path
directory = os.path.dirname(path)
if not os.path.exists(directory):
os.makedirs(directory)
return path
html = urlopen("http://www.pythonscraping.com")
bsObj = BeautifulSoup(html)
downloadList = bsObj.findAll(src=True)
for download in downloadList:
fileUrl = getAbsoluteURL(baseUrl, download["src"])
if fileUrl is not None:
print(fileUrl)
urlretrieve(fileUrl, getDownloadPath(baseUrl, fileUrl, downloadDirectory))
运行注意事项
你知道所有听说的有关从互联网下载不明文件的警告?这个脚本下载它遇到的一切到硬盘。包括随即的bash脚本,.exe文件以及其他潜在的恶意文件。
认为你安全是因为你永远不会真正执行任何发送到你的下载文件夹?特别是,如果你作为管理员运行,你是在自找麻烦。如果你通过文件在一个网站上运行,发送文件到../../../../usr/bin/python?当你下一次在命令行运行一个Python脚本,你实际上是在你的机器上部署恶意软件!
这个程序是仅供参考所写,不应该被随即部署而没有更多的文件名检查,它应该只能在权限的用户下运行。一如既往备份文件而不是在硬盘上存储敏感信息,一些基本常识需要走很长的路。
此脚本使用lambda函数(在第二章中介绍)来选择所有具有src属性的页面,然后清理和规范化得到一个绝对的URL下载路径(确保丢弃外部链接)。然后,每一个文件下载到你机器的本地文件夹的下载路径。
请注意,Python的os模块是用来检索每个下载的目标目录和创建需要的缺失目录。os模块作为Python和操作系统之间的接口,允许它操作文件路径,创建目录,获取运行进程的信息和环境变量,以及其他有用的信息。
将数据存储到CSV
CSV,或者逗号分隔值,是存储电子表格数据的一个最流行的文件格式。由于它的简单性,它支持Microsoft Excel好许多其他的应用程序。以下是一个完全的CSV文件的示例:
fruit,cost
apple,1.00
banana,0.30
pear,1.25
和Python一样,空格是很重要的位置:每行是用换行符分隔,而行内是用逗号隔开(于是叫“逗号分离”)。其他形式的CSV文件中(有时称为“字符分隔值”)使用制表符或者其他字符分隔行,但是这些文件格式不常见,没有广泛的得到支持。
如果你直接关闭网页在寻找下载CSV文件,并将其储存在本地,没有任何分析或者修改,则不需要此节。简单的下载他们像其他下载那样并且使用前面部分描述的方法来以CSV文件格式保存他们。
修改一个CSV文件,甚至完全从头开始创建一个,在Python的CSV库中是非常容易的:
import csv
csvFile = open("../file/test.csv", 'wt')
try:
writer = csv.writer(csvFile)
writer.writerow(('number', 'number plus 2', 'number times 2'))
for i in range(10):
writer.writerow((i, i+2, i*2))
finally:
csvFile.close()
一个预先提醒:Python中的文件创建是相当“防弹”的。如果../file/test.csv文件不存在,Python将自动创建目录和文件。如果它已经存在,Python将用新的数据覆盖test.csv。
运行之后,你应该看到一个CSV文件:
number,number plus 2,number times 2
0,2,0
1,3,2
2,4,4
...
一个常见的网页抓取任务是获取一个HTML表格,并把它写为CSV文件。维基百科的文本编辑器提供一个相当复杂的HTML表格,包含了颜色编码,链接,排序以及其他在写入CSV文件之前需要丢弃的HTML垃圾。使用BeautifulSoup和get_text()函数,你可以在20行代码内做到:
import csv
import urllib.request import urlopen
from bs4 import BeautifulSoup
html = urlopen()
bsObj = BeautifulSoup(html)
#The main comparison table is currently the first table on the page
table = bsObj.findAll("table", {"class":"wikitable"})[0]
rows = table.findAll("tr")
csvFile = open()
writer = csv.writer(csvFile)
try:
for row in rows:
csvRow = []
for cell in row.findAll(['td', 'th']):
csvRow.append(cell.get_text())
writer.writerow(csvRow)
finally:
csvFile.close()
你在现实生活中实现这个之前
如果你遇到许多HTML表格需要转换成CSV文件或者许多HTML表格需要收集到一个单一的CSV文件中,这个脚本整合到爬虫之中是很棒的。但是,如果你只需要做一次,对于这个有一个更好的工具:复制和粘贴。选择和复制HTML表格中的所有内容然后将其粘贴到Excel中将会不运行脚本得到你要找的CSV文件。
结果应该以一个格式良好的CSV文件保存在本地的../file/editor.csv之下——完美的发送并且分享给了那些完全没有的到MySQL窍门的人!
MySQL
MySQL(官方宣称是“My es-kew-el”,虽然很多人读“My Sequel”)是今天最流行的开源的关系型数据库管理系统。是开源项目中的一个异常强大的竞争对手,它的受欢迎程序在历史上与其他两个闭源数据库系统并驾齐驱:微软的SQL Server和Oracle的数据库管理系统。
它的流行不无原因。对于大多数应用程序,使用MySQL是很难出错的。这是一个可扩展的,强大的,功能齐全的数据库管理系统,使用的顶级网站有:YouTube①,Twitter②,Facebook③等等。
由于它的无处不在,价格(“免费”是一个非常伟大的价格),以及打开即用的可用性,它是作为网络抓取项目的梦幻般的数据库,我们将在这本书的其余整个部分使用。
“关系”数据库?
“关系数据”数据是有关系的。我不知道怎么说了。
开个玩笑!当计算机科学家谈论关系数据时,他们指的是数据不存在真空——它涉及到其他数据块的属性。例如,“用户A上学的机构是B”,用户A可以在数据块的“用户”表中找到,机构B可以在数据库中的“机构”表中找到。
在本章的后面,我们将看看不同的关系模型以及如何在MySQL(或者其他任何关系数据库)中有效的存储数据。
安装MySQL
如果你是第一次听说MySQL,安装数据库可能听起来有点吓人(如果你有一顶旧的帽子,随意跳过这一节把)。事实上,它简单的安装就像其他类型的软件。在它的核心,MySQL由一组数据文件支撑,存储在服务器或者本地计算机,包含了存储在数据库上的所有信息。MySQL的软件顶层通过命令行界面提供一种方便的与数据交互的方法。例如,下面的命令将挖掘数据文件,并返回数据库中名字是“Ryan”的所有用户列表:
SELECT * FROM user WHERE firstname = "Ryan"
如果你是Linux,安装MySQL很简单:
$sudo apt-get install mysql-server
一些基本的命令
一旦你的MySQL服务器开始运行,你可以和数据库进行很多交互。有大量的软件工具扮演中间人,使你不用去使用MySQL命令处理(或者至少使用的较少)。像工具phpMyAdmin和MySQL Workbench可以很容易的快速浏览,排序和插入数据。然而,知道命令行的方式也是重要。
除了变量名,MySQL是不区分大小写的;例如SELECT和sElEct是相同的。不过按照惯例,当你写一个MySQL语句的时候所有的关键字是全部大写。相反,这个标准往往被忽视,大多数开发人员更喜欢用小写命名他们的表和数据库。
当你第一次登陆到MySQL,这里没有添加数据的数据库。你可以创建一个:
>CREATE DATABASE scraping;
因为每个MySQL实例可以有很多个数据库,在我们开始和数据库交互之前,我们需要调用MySQL中我们想要使用的数据库:
>USE scraping;
从这点开始(至少知道我们关闭MySQL连接或切换到另一个数据库),输入的所有命令都会在这个新的“scraping”数据库中运行。
这一切似乎很简单。创建一个表在数据库中应该同样容易,对不对?让我们尝试创建一个表来存储抓取网页的集合:
>CREATE TABLE pages;
这将导致错误:
ERROR 1113 (42000): A table must have at least 1 column
不像一个数据库,它没有任何的表可以存在,MySQL中的表没有列是不能存在的。为了在MySQL中定义一个列,你需要在CREATE TABLE<表名>语句后面在括号里面输入他们并且用逗号分隔:
>CREATE TABLE pages (id BIGINT(7) NOT NULL AUTO_INCREMENT, title VARCHAR(200),
content VARCHAR(10000), created TIMESTAMP DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY
(id));
每个列定义有三个部分:
①名称(id, title, created等)
②变量类型(BIGINT(7), VARCHAR, TIMESTAMP)
③其他额外属性(NOT NULL AUTO_INCHREMENT)
在表列的末尾,你必须定义一个表的“键”。MySQL使用键组织快速查找的内容。在本章的后面,我将描述如何使用这些键使得数据库更快,但是现在,使用表的id列作为键通常是最好的方式。
执行查询之后,你就可以在任何时候看到像使用了DECRIBE的列表结构:
> DESCRIBE pages;
+---------+----------------+------+-----+-------------------+----------------+
| Field | Type | Null | Key | Default | Extra |
+---------+----------------+------+-----+-------------------+----------------+
| id | bigint(7) | NO | PRI | NULL | auto_increment |
| title | varchar(200) | YES | | NULL | |
| content | varchar(10000) | YES | | NULL | |
| created | timestamp | NO | | CURRENT_TIMESTAMP | |
+---------+----------------+------+-----+-------------------+----------------+
4 rows in set (0.01 sec)
当然,这仍然是一个空表。你可以使用下面一行,插入一些测试数据到表pages:
>INSERT INTO pages (title, content) VALUES ("Test page title", "This is some test page
content. It can be up to 10,000 characters long.")
请注意,虽然该表有四列(id, title, content, created),你只需要定义他们中的两个(id和content)插入到行。这是因为id列是自动增量(每当插入一个新行时MySQL会自动加1)一般可以自己管理自己。
此外,时间戳列设置当前时间作为默认值。
当然,我们可以覆盖这些默认值:
INSERT INTO pages (id, title, content, created) VALUES (3, "Test page title", "
This is some test page content. It can be up to 10,000 characters long.", "2014-
09-21 10:25:32");
只要你提供的id是整数尚且不存在于数据库中,这个覆盖完全正常工作。然而,这样做通常是不好的做法;最好的方式是让MySQL自己去处理id和timesamp,除非有一个令人信服的原因才可用别的方式。
现在,我们在表中有了一些数据,可以用各种各样的方法来选择数据。这里是SELECT语句的几个例子:
>SELECT * FROM pages WHERE id=2;
这个语句告诉MySQL,“从表pages中选择所有id为2的”。星号(*)作为通配符,返回这里所有条件为真(id=2)的行。如果有第二行,他返回该表中的第二行;如果没有第二行,则返回一个空的结果。
例如,下面的不区分大小写的查询返回所有标题字段包含“test”的行(%符号是MySQL种的字符串通配符):
>SELECT * FROM pages WHERE title LIKE "%test%";
但是如果你的表有许多列,而你只需要一个特定的一块返回数据?而不是所有,你可以做这样的事情:
>SELECT id, title FROM pages WHERE content LIKE "%page content%";
这将返回其中内容包含了那句“page content”的id和title。DELETE语句有很多和SELECT语句相同的语法:
>DELETE FROM pages WHERE id = 1;
对于当工作在重要的数据库而且不能轻易恢复这个原因,有一个好主意是:写任何DELETE语句之前使用SELECT语句(此时为:SELECT * FROM pages WHERE id=2)作为测试,确保返回的行只有你删除的。然后用DELETE替换SELECT *。许多程序员有一些恐怖的事情,当他们赶时间完全离开时,编码错误选择的DELETE语句,或者更糟糕,破坏了客户数据。不要让它发生在你身上!
类似的预防措施可以采取UPDATE语句:
>UPDATE pages SET title="A new title", content="Some new content" WHERE id=2;
在本书的目的,我们只是使用一些简单的MySQL语句,做基本的选择,插入和更新。如果你有兴趣了解更多的关于这个强大数据库工具的命令和技术,我推荐Paul DuBois(保罗 杜波依斯)的MySQL Cookbook。
与Python的集成
不幸的是,Python对于MySQL的支持是没有内置的。然而,这里有很多开源的库可以使用,适配Python
2.x和Python3.x,这个允许你同MySQL数据库进行交互。其中最流行的一个是PyMySQL。
在写本书的时候,PyMySQL的当前版本是0.6.2,你可以使用下面的命令下载安装:
$ curl -L https://github.com/PyMySQL/PyMySQL/tarball/pymysql-0.6.2 | tar xz
$ cd PyMySQL-PyMySQL-f953785/
$ python setup.py install
记得去检查网上最近的PyMySQL的版本,并根据需要的版本号用第一行命令进行更新:
安装完毕后,当你本地MySQL服务器运行,你就可以自动获得PyMySQL包,你应该执行以下脚本成功(记得
添加你数据库的root密码):
import pymysql
conn = pymysql.connect(host='127.0.0.1', unix_socket='/tmp/mysql.sock',
user='root', passwd=None, db='mysql')
cur = conn.cursor()
cur.execute("USE scraping")
cur.execute("SELECT * FROM pages WHERE id=1")
print(cur.fetchone())
cur.close()
conn.close()
在这个例子中有两个新的对象类型:连接对象(Connection,conn)和游标对象(Cursor,cur)。
连接/游标对象是数据库编程中常用的对象,但有一些用户可能在第一时间发现区分这两种非常棘手。这
个连接是负责连接数据库的过程,而且还发送数据库信息,处理回滚(查询或者组查询需要终止的时候,
数据库需要返回到之前的状态),并且创建新的游标。
一个连接可以有多个游标。游标跟踪某些状态信息,如哪个数据库正在使用。如果你有多个数据库且需要
他们的所有信息,你可能需要有多个游标来处理这个问题。游标还包含了它已经执行了的最新的查询结果
。通过在函数中调用游标,如cur.fetchone(),你可以访问这些信息。
重要的是在使用完游标和连接之后要关闭他们。不这样做可能导致连接泄露,未关闭的连接会积累,而且
不能被再使用,软件无法关闭他们,他们在印象中是使用的,你可能仍然会使用他们。诸如此类的事情,
时常带来数据库的迭机,所以要记得关闭你的连接!
你想要做的最常见的事情开始了,就是能够储存你的抓取结果在数据库中。让我们来看看如何能够做到这
一点,使用以前的例子:维基百科爬虫。
当网页抓取时处理Unicode文本会很困难。默认情况下MySQL不处理Unicode。幸运的是,你可以开启这个
功能(只需记住,这样做会增加数据库的大小)。因为我们一定会在维基百科上碰到各种各样的性质的东
西,现在是一个很好的时间告诉你的数据库去期待一些Unicode:
ALTER DATABASE scraping CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci;
ALTER TABLE pages CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE pages CHANGE title title VARCHAR(200) CHARACTER SET utf8mb4 COLLATE
utf8mb4_unicode_ci;
ALTER TABLE pages CHANGE content content VARCHAR(10000) CHARACTER SET utf8mb4 CO
LLATE utf8mb4_unicode_ci;
这四行做了以下设置:对数据库,表,以及title和content两列的默认字符集utf8mb4(技术上还是
Unicode,但是对大多数Unicode字符是出了名的糟糕的支持)更改为utf8mb4_unicode_ci。
如果你试图插入几个元音或普通话字符集在数据库的title或者content字段,并且没有错误,那么你会知
道你是成功的。
现在,数据库准备接收维基百科各种各样的抛出,你可以运行下面代码:
import urllib.request import urlopen
from bs4 import BeautifulSoup
import datetime
import random
import pymysql
import re
conn = pymysql.connect(host='127.0.0.1', unix_socket='/tmp/mysql.sock',
user='root', passwd=None, db=mysql, charset='utf-8')
cur = conn.cursor()
cur.execute("USE scraping")
random.seed(datetime.datetime.now())
def store(title, content):
cur.execute("INSERT INTO pages (title, content) VALUES (\"%s\",
/"%s\")", (title, content))
cur.connection.commit()
def getLinks(articleUrl):
html = urlopen()
bsObj = BeautifulSoup(html)
title = bsObj.find("h1").find("span").get_text()
content = bsObj.find("div", {"id":"mw-content-text"}).find("p").get_text()
store(title, content)
return bsObj.find("div", {"id":"bodyContent"}).findAll("a",
href=re.compile("^(/wiki/)((?!).)*$"))
links = getLinks("/wiki/Kevin_Bacon")
try:
while len(links)>0:
newArticle = links[random.ranint(0, len(links)-1)].attrs["href"]
print(newArticle)
links = getLinks(newArticle)
finally:
cur.close()
conn.close()
这里有几件事情需要注意:第一,“charset='utf-8'”被添加到了数据库的连接字符串中。这告诉了连
接,它应该发送给数据的所有信息为UTF-8(当然,数据库也应该已经配置好这种处理)。
第二请注意它的存储函数。这需要两个字符串变量,title和content,并且添加他们到一个由游标执行的
INSERT语句中然后由游标连接提交(cur.connection.commit())数据。这是一个游标和连接分离的例子
,当游标需要存储一些关于数据信息和它的上下文,它需要通过连接操作把信息返回给数据库和插入一些
信息。
最后,你会看到一个finally语句添加到程序的主循环中,在代码的底部。这将确保,不论程序在执行期
间不论中断或者是异常被抛出(当然,因为网页是混乱的,你应该总是假设异常被抛出),游标和连接都
将会在程序结束前自动关闭。当你抓取网页和有一个开放的数据库连接时,包含一个try...finally语句
是很好的主意。
虽然PyMySQL不是一个巨大的包,里面有很多有用的函数这本书根本无法容纳。查看这个文档
http://bit.ly/1KHzoga。
数据库技术和好的方法
还有谁花费自己的整个职业生涯学习,调整和发明数据库。我不是其中之一,而且这也不是那种书。然而
,就想计算机科学的许多学科一样,这里有一些你可以很快学会的技巧,至少对于大多数应用,让你的数
据库充分,足够快速。
首先,除了极少数例外,随时添加ID列到你的表。在MySQL中的所有表必须至少有一个主键(MySQL用来分
类的键),从而使MySQL知道如何排列它,MySQL往往是很难智能化的选择这些键。关于这些键使用人工创
造的id列还是一些独特的属性,比如用户名已经在数据科学家和软件工程师之间争论多年,尽管我倾向于
依靠创建id列的一方。这么做的一种或者更复杂的理由,但是对于非企业系统,你应该总是使用一个id列
作为自动增量的主键。
其次,采用智能索引。一个字典(之类的书,不是Python的对象)是一个按照字母顺序索引的单词列表。
如果你需要一个单词,只要你知道它是如何拼写的,你就可以快速查找。你也可以想象一个字典,通过单
词的定义按照字母顺序排列。这几乎不会有用,除非你玩一下奇怪的危险游戏!在这里你需要提出定义和
拿出这个单词。但是在数据库的查找世界里,这些各式情况会发生。例如,你可能在你的数据库一个字段
中经常被限制查询:
>SELECT * FROM dictionary WHERE definition="A small furry animal that says meow";
+------+-------+-------------------------------------+
| id | word | definition |
+------+-------+-------------------------------------+
| 200 | cat | A small furry animal that says meow |
+------+-------+-------------------------------------+
1 row in set (0.00 sec)
你可能想要在这个表上增加一个额外的键(除了想必准备好的id键)为了使definition列查询的更快。不
幸的是,增加额外的索引需要更多的空间,当插入新的行的时候还有一些额外的处理时间。为了使这一点
更容易,你可以告诉MySQL索引的列值只有前几个字符。这个命令创建了定义字段中的前16个字符的索引
:
CREATE INDEX definition ON dictionary(id, definition(16));
当你通过他们全部定义寻找单词时候,这个索引将会让你查找更快,也没有太多额外的空间和前期处理时
间。
查询时间和数据库大小(这是数据库工程中的平衡行为之一)的一个主题是常见的导致错误的原因之一,
特别是网页抓取时经常有大量的自然文本数据,存储大量的重复数据。例如,假设你要测量某些突然出现
在网站上的短语的频率。这些短语可能从给定的列表中发现,或者通过一些文本分析算法自动生成。你也
许会尝试像这样存储数据:
+--------+--------------+------+-----+---------+----------------+
| Field | Type | Null | Key | Default | Extra |
+--------+--------------+------+-----+---------+----------------+
| id | int(11) | NO | PRI | NULL | auto_increment |
| url | varchar(200) | YES | | NULL | |
| phrase | varchar(200) | YES | | NULL | |
+--------+--------------+------+-----+---------+----------------+
每当你在网站上发现一个数据就在数据库中添加了一行并记录发现的URL。然而,通过把数据分割成三个
独立的表,你可以极大的收缩数据集:
>DESCRIBE phrases
+--------+--------------+------+-----+---------+----------------+
| Field | Type | Null | Key | Default | Extra |
+--------+--------------+------+-----+---------+----------------+
| id | int(11) | NO | PRI | NULL | auto_increment |
| phrase | varchar(200) | YES | | NULL | |
+--------+--------------+------+-----+---------+----------------+
>DESCRIBE urls
+-------+--------------+------+-----+---------+----------------+
| Field | Type | Null | Key | Default | Extra |
+-------+--------------+------+-----+---------+----------------+
| id | int(11) | NO | PRI | NULL | auto_increment |
| url | varchar(200) | YES | | NULL | |
+-------+--------------+------+-----+---------+----------------+
>DESCRIBE foundInstances
+-------------+---------+------+-----+---------+----------------+
| Field | Type | Null | Key | Default | Extra |
+-------------+---------+------+-----+---------+----------------+
| id | int(11) | NO | PRI | NULL | auto_increment |
| urlId | int(11) | YES | | NULL | |
| phraseId | int(11) | YES | | NULL | |
| occurrences | int(11) | YES | | NULL | |
+-------------+---------+------+-----+---------+----------------+
虽然表的定义较大,你可以看到大部分列是整数的id字段。这些占更少的空间,此外,每个完整的URL和
短语文本只储存一次。
除非你安装一些第三方包或者保持详细的日志,它是不可能告诉你一块数据什么时候从数据库中添加,更
新或者删除。当这些变化发生时,根据你数据的可用空间,变化的频率,以及这些决定的重要性,你可能
想要考虑保证时间戳到位:“创建”,“更新”,“删除”。
MySQL中的“六度”
在第三章,我介绍了“维基百科的六度”问题,目标是寻找任意两个维基百科文章之间通过一系列链接的
关系(即,找到一种方式来获得一个通过点击链接从一个网页到另一个网页的维基百科文章)。
为了解决这个问题,不仅仅是要建立抓取网站的机器人(),还要用一个体系结构合理的方式存储信息,
方便以后数据分析。
自动递增的id列,时间戳和多个表:他们都在这里发挥作用。为了弄清楚如何更好的存储这些信息,你需
要抽象思维。一个链接是链接页面A到页面B的简单东西,它也很容易的从页面B链接页面A,但这将是一个
独立的链路。我们可以唯一标志一个链接是说:“页面A中存在一个链接,可以链接到页面B”。也就是说
,INSERT INTO links(fromPageId, toPageId) VALUES (A, B);(其中‘A’和‘B’用于唯一标识两个页
面)。
一个两个表的存储网页和链接的系统设计,以及创建日期和独特的ID,可以通过如下构造:
CREATE TABLE `wikipedia`.`pages` (
`id` INT NOT NULL AUTO_INCREMENT,
`url` VARCHAR(255) NOT NULL,
`created` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`));
CREATE TABLE `wikipedia`.`links` (
`id` INT NOT NULL AUTO_INCREMENT,
`fromPageId` INT NULL,
`toPageId` INT NULL,
`created` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`));
注意,不同于前面打印页面标题的爬虫,我甚至没有在pages表中存储页面的标题。这是为什么?好了,
记录页面标题需要你实际访问页面并且检索它。如果我们要建立一个高效的网络爬虫来填这些表,我们想
存储这些页面和链接,即使是我们不一定访问过的页面。
当然,虽然这并不适用于所有网站,维基百科的链接和页面标题的好处在于可以通过简单的操作变成另一
个。例如:http://en.wikipedia.org/wiki/Monty_Python表示页面的标题是“Monty Python”。
下面将存储维基百科上有一个“Bacon number”(这个页面到Kevin Bacon页面的链接数,含各自页面,
数量为6或者更少)的所有页面:
from urllib.request import urlopen
from bs4 import BeautifulSoup
import re
import pymysql
conn = pymysql.connect(host='127.0.0.1', unix_socket='/tem/mysql.sock', user=
'root', passwd=None, db='mysql', charset='utf-8')
cur = conncor()
cur.execute("USE wikipedia")
def insertPageIfNotExists(url):
cur.execute("SELECT * FROM pages WHERE url = %s", (url))
if cur.rowcount == 0:
cur.execute("INSERT INTO pages (url) VALUES (%s)", (url))
conn.commit()
return cur.lastrowid
else:
return cur.fetchone()[0]
def insertLink(fromPageId, toPageId):
cur.execute("SELECT * FROM links WHERE fromPageId = %s AND toPageId = %s",
(int(fromPageId), int(toPageId)))
if cur.rowcount == 0:
cur.execute("INSERT INTO links (fromPageId, toPageId) VALUES (%S, %S)",
(int(fromPageId), int(toPageId)))
conn.commit()
pages = set()
def getLinks(pageUrl, recursionLevel):
global pages
if recursionLevel > 4:
return;
pageId = insertPageIfNotExists(pageUrl)
html = urlopen("http://en.wikipedia.org"+pageUrl)
bsObj = BeautifulSoup(html)
for link in bsObj.findAll("a", href=re.compile("^(/wiki/)((?!:).)*$")):
insertLink(pageId, insertPageIfNotExists(link.attrs['href'])
if link.attrs[href] not in pages:
#we have encoountered a new page, add it and search it for links
nePage = link.attrs['href']
pages.add(newPage)
getLInks(newPage, recursionLevel+1)
getLinks("/wiki/Kevin_Bacon", 0)
cur.close()
conn.close()
递归用代码实现总是一个棘手的事情,设计运行很长时间。在这种情况下,一个recursionLevel变量被传
递给getLinks函数多少次,recursionLevel变量就追踪函数递归的次数(每次函数被调用,
recursionLevel就递增)。当recursionLevel达到5,这个函数自动返回,无需进行下一步搜索。这个限
制可以确保堆栈溢出永远不会发生。
请记住,这个方案可能需要数天才能完成。虽然我确实运行了它,为了维基百科的服务器,我的数据库包
含了这个Kevin Bacon数字(6个或更少)的一小部分网页。然而,这足够我们分析发现维基百科文章之间
的路径了。对于这个问题的最终解决方案的延续,见第八章解决有向图的问题。
电子邮件
就想网页是通过HTTP发送的,发送电子邮件通过SMTP(简单邮件传输协议)。而且就像你使用一个web服
务器/客户端来处理通过HTTP发送网页,服务器使用不同的邮件客户端,如Sendmail,Postfix或者
Mailman来发送接收邮件。
虽然Python发送电子邮件是比较容易做到的,它需要你使用SMTP访问服务器。在服务器或者本地机器设置
SMTP是棘手的,在本书范围之外也有许多优质的资源来帮助完成这个问题,特别是如果你使用Linux或者
Mac OS X。
在下面的代码示例中,我会假设你正在本地运行SMTP客户端(要为远程客户端修改这个代码,只需要更改
localhost为你的远程服务器的地址)。
用Python发送邮件只需要9行代码:
import smtplib
from email.mime.text import MIMEText
msg= MIMEText("The body of the email is here")
msg['Subject'] = "An Email Alert"
msg['From'] = "[email protected]"
msg['To'] = "[email protected]"
s = smtplib.SMTP('localhost')
s.send_message(msg)
s.quit()
Python包含两个重要的发送电子邮件的包:smtplib和email。
Python的email模块包含有用的格式化创建电子邮件“包”发送的函数。此时使用的MIMEText对象创建了
一个空的和低级别的MIME(多用途互联网邮件扩展)协议的电子邮件转移格式,通过高级别的SMTP连接制
成。这个MIMEText对象,msg包含发送和发往的邮件地址,以及一个主体和头部,使用Python创建的格式
化电子邮件。
smtplib包包含了用于处理与服务器连接的信息。就想一个和MySQL的连接,这种连接必须在建立之后删除
,避免造成过多的连接。
这些基本的电子邮件功能可以通过扩展并且封装成一个更有用的功能:
import smtplib
from email,mime.text import MIMEText
from bs4 import BeautifulSoup
from urllib.request import urlopen
import time
def sendMail(subject, body):
msg = MIMEText(body)
msg['Subject'] = subject
msg['From'] =
msg['To'] =
s = smtplib.SMTP('localhost')
s.send_message(msg)
s.qiut()
bsObj = BeautifulSoup(urlopen("http://itischristmas.com"))
while(bsObj.find('a', {"id":"answer"}).attrs['title'] == "NO"):
print("It is not Christms yet.")
time.sleep(3600)
bsObj = BeautifulSoup(urlopen("http://itischristmas.com"))
sednMail("It's Christmas!",
"According to http://itischristmas.com, it is Christmas!")
这是一个每小时检查网站https://isitchristmas.com(主要特征是一个巨大的“YES”或者“NO”,取决
于在一年中的哪一天)一次的特定脚本。如果它看到其他不是“NO”的,它将向你发送电子邮件提醒你这
事圣诞节。
虽然这个程序并不比挂在墙上的日历更有用,它可以做出一些稍微的调整做出非常有用的东西。它可以针
对站点中断,测试失败甚至你在亚马逊上等到货的脱销产品一出现就会给你发送电子邮件——没有一个墙
上日历可以做到。
①Joab Jackson:“YouTube规模化通过Go代码使用MySQL”,PC World,2012年12月15日
(http://bit.ly/1LWVmc8)
②Jeremy Cole和Davi Arnaut:“MySQL在Twitter”,Twitter的工程博客,2012年4月9号
(http://bit.ly/1KHDKns)
③“MySQL和数据工程:Mark Callaghan.”2012年3月4日,http://on.fb.me/1RFMqvw。