上接:Python3网络爬虫教程19——分布式爬虫Scrapy实例(爬取多个页面)
https://blog.youkuaiyun.com/u011318077/article/details/86692733
利用分布式爬虫爬取1000、10000个百度百科词条
分布式爬虫基本结构见下图
项目结构图如下
控制节点
- 控制器
- URL管理器
- 数据存储器
爬虫节点
- 爬虫程序
- HTML下载器
- HTML解析器
控制节点
# coding: utf-8
'''
第一步: 创建一个分布式管理器
第二步: 创建URL管理进程、 数据提取进程、数据存储进程
URL管理进程里面有两个队列:url_q和result_q,和爬虫节点中的两个节点(self.task和self.result)是联络在一起的
数据提取进程里面有两个队列:conn_q和store_q,分别用于放置待提交给URL管理进程的数据和数据存储进程的数据
第三步: 启动三个进程和管理器
爬取流程逻辑:
整个控制节点和爬取节点靠的是4个队列进行联络:
url_q:在爬虫节点SpiderWork中被重命名为self.task,其实本质都是get_task_queue队列
控制节点中向url_q放入数据,爬虫节点从self.task取出数据,然后用于下载HTML页面和解析页面
解析出来的数据放进self.result队列,返回给控制节点
result_q:在爬虫节点SpiderWork中被重命名为self.result,其实本质都是get_result_queue队列
爬虫节点中向self.result放入数据,控制节点从result_q取出数据,
取出的数据中的URL交给了conn_q队列,然后又循环给url_q队列,提交给爬虫节点,进行下一轮爬取
其它数据交给了store_q,然后存储到文件中,一直循环下去,到达限定条件结束爬取。
store_q:该队列只在控制节点中使用,数据提取进程用于放置待提交给数据存储进程的数据,
数据来源于result_q,实际是SpiderWork中的self.result队列
conn_q:该队列只在控制节点中使用,数据提取进程用于放置待提交给URL管理进程的URL,
数据来源于result_q,实际是SpiderWork中的self.result队列
运行步骤:先启动控制节点NodeManager.py,再启动爬虫节点SpiderWorker.py
'''
from multiprocessing.managers import BaseManager
import time
from multiprocessing import Process, Queue
from ControlNode.DataOutput import DataOutput
from ControlNode.URLManager import UrlManager
class NodeManager(object):
# 第一步:创建分布式管理器
def start_Manager(self, url_q, result_q):
'''
创建一个分布式管理器
:param url_q: url地址队列,URL管理进程将URL传递给爬虫节点的通道(爬虫节点:SpiderWork.py)
:param result_q: 结果队列, 爬虫节点将数据返回给数据提取进程的通道
:return:
'''
# 把创建的两个队列注册在网络上,利用register方法, call参数关联了Queue对象
# 将Queue对象在网络中暴露,网络中一个任务队列一个结果队列
# 队列经过BaseManager封装后进行重命名,其实get_task_queue队列就是url_q队列
BaseManager.register('get_task_queue', callable=lambda: url_q)
BaseManager.register('get_result_queue', callable=lambda: result_q)
# 绑定端口8001,设置验证口令‘baike',这个相当于对象的初始化
manager = BaseManager(address=('127.0.0.1', 8001), authkey='baike'.encode('utf-8'))
# 返回manager对象
return manager
# 第二步:创建URL管理进程、 数据提取进程、数据存储进程
# URL管理进程(利用URLManager.py)
def url_manager_proc(self, url_q, conn_q, root_url):
'''
URL管理进程
:param url_q: 任务进程队列,放置即将爬取URL,URL管理进程将URL传递给爬虫节点的通道(爬虫节点:SpiderWork.py)
:param conn_q: 数据提取进程将新的URL提交给URL管理进程的通道
:param root_url: 最原始的url,第一个用于爬取的网址
:return:
'''
# Url管理器实例化
url_manager = UrlManager()
# 将第一个网址加入到未爬取的URL集合中
url_manager.add_new_url(root_url)
while True:
# 判断是否有新的未爬取的URL
while(url_manager.has_new_url()):
# 从未爬取的URL集合中取出新的URL
new_url = url_manager.get_new_url()
# 将新的URL放入任务进程队列,放置即将爬取的URL
url_q.put(new_url)
# 打印已爬取过URL集合的大小
print('old_url=', url_manager.old_url_size())
# 加一个判断条件,当爬取2000个链接后就关闭爬行节点,并保存进度
if(url_manager.old_url_size() > 30):
# 通知爬行节点工作结束,即使未爬取的URL集合中还有URL或者任务队列中还有未爬取的URL
url_q.put('end')
print("控制节点发起结束通知!")
# 关闭管理节点,同时存储set状态
url_manager.save_progerss('new_urls.txt', url_manager.new_urls)
url_manager.save_progerss('old_urls.txt', url_manager.old_urls)
return
# 将从result_solve_proc获取到的urls添加到URL管理器之间
try:
urls =conn_q.get()
url_manager.add_new_urls(urls)
except BaseException as e:
# 延时休息一会儿
time.sleep(0.1)
# 数据提取进程
def result_solve_proc(self, result_q, conn_q, store_q):
'''
数据提取进程
:param result_q: 结果队列, 爬虫节点将数据返回给数据提取进程的通道
:param conn_q: 数据提取进程将新的URL提交给URL管理进程的通道
:param store_q: 数据提取进程将获取的数据提交给数据存储进程的通道
:return:
'''
while(True):
try:
if not result_q.empty():
content = result_q.get(True) # 从result_q就是SpiderWork中的self.result队列,里面放置了网页分析后的数据
if content['new_urls']=='end':
#结果分析进程接受通知然后结束
print('数据提取进程收到结束通知,立刻结束!')
store_q.put('end')
return
conn_q.put(content['new_urls'])#url为set类型
store_q.put(content['data'])#解析出来的数据为dict类型,然后放入通道中待存储
else:
time.sleep(0.1)#延时休息
except BaseException as e:
time.sleep(0.1)#延时休息
# 数据存储进程
def store_proc(self, store_q):
'''
数据存储进程
:param store_q: 数据提取进程将获取的数据提交给数据存储进程的通道,里面放的是HTML解析出来待存储的数据
:return:
'''
# 数据存储实例化
output = DataOutput()
while True:
# 判读队列中是否为空,前面加了not,不是空就返回True
if not store_q.empty():
# 取出数据
data = store_q.get()
# 如果里面的数据是字符串end,就代表结束存储进程
if data=='end':
print('存储进程收到结束通知,立刻结束!')
# 写入HTML的结束标签内容
output.output_end(output.filepath)
return
output.store_data(data) # 不是end,就一直向datas列表里面一直添加数据
else:
time.sleep(0.1)
pass
# 进程运行逻辑顺序
if __name__=='__main__':
#初始化4个队列
url_q = Queue()
result_q = Queue()
store_q = Queue()
conn_q = Queue()
#创建分布式管理器
node = NodeManager()
manager = node.start_Manager(url_q, result_q)
#创建URL管理进程、 数据提取进程和数据存储进程
url_manager_proc = Process(target=node.url_manager_proc, args=(url_q, conn_q, 'http://baike.baidu.com/view/284853.htm',))
result_solve_proc = Process(target=node.result_solve_proc, args=(result_q, conn_q, store_q,))
store_proc = Process(target=node.store_proc, args=(store_q,))
#启动3个进程和分布式管理器
url_manager_proc.start()
result_solve_proc.start()
store_proc.start()
manager.get_server().serve_forever()
URL管理器
# coding: utf-8
# 新的URL集合来自HTML解析器解析出的URL集合
# URL管理器思路:
# 第一步:判断是否有未爬取的URL,方法定义为has_new_url
# 第二步:添加新的URL到未爬取的URL集合中,方法定义为add_new_url(url)和add_new_urls(urls)
# 第三步:获取一个未爬取新的URL,方法定义为get_new_url()
# 第四步:获取未爬取URL集合的大小,方法为new_url_size(),获取已爬取URL结合的大小,方法为old_url_size()
# 第五步:
# 代码进行优化,对URL集合进行序列化操作,减少内存的消耗
# URL进行MD5处理,可以减少内存消耗
# Python2中交cPickle
import pickle as cPickle
import hashlib
class UrlManager(object):
def __init__(self):
self.new_urls = self.load_progress('new_urls.txt') # 未爬取的URL集合
self.old_urls = self.load_progress('old_urls.txt') # 已爬取过的URL集合
def has_new_url(self):
'''判断是否有未爬取的URL'''
return self.new_url_size() != 0
def get_new_url(self):
'''从new_urls集合中,获取一个未爬取的URL,用于爬取'''
new_url = self.new_urls.pop() # 删除列表中最后一个URL
m = hashlib.md5() # 创建一个MD5处理的实例
m.update(new_url.encode('utf-8')) # 对new_url进行MD5处理,python处理后默认是256位,下面只取中间的128位
self.old_urls.add(m.hexdigest()[8:-8]) # 将上面删除的URL加入到已爬的URL中
return new_url
def add_new_url(self, url):
'''将HTML解析得到的新的URL添加到未爬取的URL集合中, 参数是一个url,来自下面的add_new_urls方法'''
if url is None:
return
m = hashlib.md5()
m.update(url.encode('utf-8'))
url_md5 = m.hexdigest()[8:-8]
if url not in self.new_urls and url_md5 not in self.old_urls:
self.new_urls.add(url) # 将新的的url进行MD5处理后添加到未爬取的URL集合中
def add_new_urls(self, urls):
'''
将HTML解析得到的新URL集合添加到未爬取的URL集合中,参数是一个URL集合或者列表之类
注意,页面解析时候,获取的新的URL是一个列表,里面有一个或者多个新URL
所以先得到新的URL列表,然后调用上面的add_new_url方法,将URL一个一个添加到未爬取的URL集合中
'''
if urls is None or len(urls) == 0:
return
for url in urls:
self.add_new_url(url) # 将url传给add_new_url方法,调用add_new_url方法
def new_url_size(self):
'''获取未爬取的URL集合的大小,列表的长度,相当于URL的个数'''
return len(self.new_urls)
def old_url_size(self):
'''获取已经爬取过URL集合的大小,列表的长度,相当于URL的个数'''
return len(self.old_urls)
def save_progerss(self, path, data):
'''
保存进度,将数据序列化写入文件
:param path: 文件路径,看NodeManager.py,文件路径就是new_urls.txt和old_urls.txt
:param data: 数据,就是new_urls和old_urls
:return:
'''
with open(path, 'wb') as f:
cPickle.dump(data, f) # 将data以二进制方式写入到f文件中
def load_progress(self, path):
'''
从本地文件加载进度,读取文件,反序列化读取文件中的内容
:param path: 文件路径
:return:
'''
print("[+] 从文件加载进度:%s" % path)
try:
with open(path, 'rb') as f:
tmp = cPickle.load(f)
return tmp
except:
print("[!] 无进度文件,创建:%s" % path)
return set()
数据存储器
# coding: utf-8
# 数据存储器,数据来自HTML解析出的数据
# 将获取到的数据写成HTML文件
# 生成的文件按照当前的时间进行命名,以避免重复,同时对文件进行缓存写入
# codecs一个编码转换模块
import codecs
import time
class DataOutput(object):
def __init__(self):
self.filepath = 'baike_%s.html' % (time.strftime("%Y_%m_%d_%H_%M_%S",
time.localtime()))
self.output_head(self.filepath) # 调用store_data方法时候,需要初始化,初始化时最先向文件中写入头部信息
self.datas = [] # 再向文件中写入data
def store_data(self, data):
if data is None:
return
self.datas.append(data) # data来自SpiderWorker
if len(self.datas) > 10:
self.output_html(self.filepath)
def output_head(self, path):
'''
将HTML头部信息写进去写进去
'''
fout = codecs.open(path, 'w', encoding='utf-8')
fout.write('<html>')
fout.write('<head>')
# 进行格式设置,不然浏览器打开会出现乱码
fout.write('<meta http-equiv="content-type" content="text/html; charset=utf-8">')
fout.write('</head>')
fout.write('<body>')
fout.write('<table>')
fout.close()
def output_html(self, path):
'''
将数据写入到HTML文件中
:param path: 文件路径,就是代码开始的文件名称,
:return:
'''
fout = codecs.open(path, 'a', encoding='utf-8') # 追加方式写入正文数据
for data in self.datas:
fout.write('<tr>')
fout.write('<td>%s</td>' % data['url'])
fout.write('<td>%s</td>' % data['title'])
fout.write('<td>%s</td>' % data['summary'])
fout.write('</tr>')
self.datas.remove(data) # 写完了就删除数据
fout.close()
def output_end(self, path):
'''
写入HTML结尾信息
:param path: 文件存储的路径
:return:
'''
fout = codecs.open(path, 'a', encoding='utf-8')
fout.write('</table>')
fout.write('</body>')
fout.write('</html>')
fout.close()
爬虫程序
# coding: utf-8
# 爬虫调度器,管理下载器、解析器
# 首先导入所需的模块
from multiprocessing.managers import BaseManager
from SpiderNode.HtmlDownloader import HtmlDownloader
from SpiderNode.HtmlParser import HtmlParser
class SpiderWork(object):
def __init__(self):
#初始化分布式进程中的工作节点的连接工作
# 实现第一步:使用BaseManager注册获取Queue的方法名称
BaseManager.register('get_task_queue')
BaseManager.register('get_result_queue')
# 实现第二步:连接到服务器:
server_addr = '127.0.0.1'
print(('Connect to server %s...' % server_addr))
# 端口和验证口令注意保持与服务进程设置的完全一致:
self.m = BaseManager(address=(server_addr, 8001), authkey='baike'.encode('utf-8'))
# 从网络连接:
self.m.connect()
# 实现第三步:获取Queue的对象:
self.task = self.m.get_task_queue()
self.result = self.m.get_result_queue()
#初始化网页下载器和解析器
self.downloader = HtmlDownloader()
self.parser = HtmlParser()
print('init finish')
def crawl(self):
while(True):
try:
if not self.task.empty():
url = self.task.get()
if url =='end':
print('控制节点通知爬虫节点停止工作...')
#接着通知其它节点停止工作
self.result.put({'new_urls': 'end','data': 'end'})
return
print('爬虫节点正在解析:%s' % url.encode('utf-8'))
content = self.downloader.download(url)
new_urls, data = self.parser.parser(url, content)
self.result.put({"new_urls": new_urls,"data": data})
except EOFError as e:
print("连接工作节点失败")
return
except Exception as e:
print(e)
print('Crawl fali ')
if __name__=="__main__":
spider = SpiderWork()
spider.crawl()
HTML下载器
# coding: utf-8
# 网页下载器,下载获得网页的全部内容
# 最终返回网页的内容,提交给HTML解析器,解析出需要的东西
import requests
class HtmlDownloader(object):
def download(self, url):
if url is None:
return None
# 设置用户代理,伪装成浏览器访问
user_agent = 'Mozilla/5.0 (Windows NT 6.1; WOW64; rv:34.0) Gecko/20100101 Firefox/34.0'
headers = {'User-Agent': user_agent}
# 请求获取网址
r = requests.get(url, headers=headers)
r.encoding = 'utf-8' # 解决中文乱码的问题
if r.status_code == 200:
r.encoding = 'utf-8'
return r.text
return None
HTML解析器
# coding: utf-8
# HTML解析器,提取网页中需要的URL和需要的数据
# 网页图片及原始代码,以及网页解析提取参考67_7_1
import re
from urllib.parse import urljoin
from bs4 import BeautifulSoup
class HtmlParser(object):
def parser(self, page_url, html_cont):
'''
用于解析网页内容,抽取URL和数据
参数page_url:下载下面的URL
参数html_cont: 下载的网页内容
return: 返回URL和数据
'''
if page_url is None or html_cont is None:
return
# 创建一个BS的实例
soup = BeautifulSoup(html_cont, 'html.parser', from_encoding='utf-8') # 采用html.parser解析
new_urls = self._get_new_urls(page_url, soup)
new_data = self._get_new_data(page_url, soup)
return new_urls, new_data
def _get_new_urls(self, page_url, soup):
'''
获取新的URL集合
参数page_url:下载页面的URL
参数soup:上面的BS创建的BS实例
返回新的URL集合
'''
new_urls = set()
# 提取符合要求的a标记,提取的是a标签全部内容的一个列表
# 提取词条概要中相关的部分词条,挑取其中几个,选定一个规则
# 原书代码
# links = soup.find_all('a',href=re.compile(r'/view/\d+\.htm'))
# 2017-07-03 更新,原因百度词条的链接形式发生改变
# r'/item/%E8%9',只匹配词条简介中的部分词条连接
# links = soup.find_all('a', href=re.compile(r'/item/%E8%9'))
# .匹配除换行符 \n 之外的任何单字符
# *匹配前面的子表达式零次或多次
# r'/item/.*'匹配到词条页面所有相关的词条,因为所有词条连接都是以/item/开头的
links = soup.find_all('a', href=re.compile(r'/item/.*'))
for link in links:
# 提取href的属性,既URL地址
new_url = link['href']
# 拼接成完整的网址,目前的页面URL和提取得到的新的URL进行合并
new_full_url = urljoin(page_url, new_url)
new_urls.add(new_full_url)
return new_urls
def _get_new_data(self, page_url, soup):
'''
抽取有效数据
:param page_url: 下载页面的URL
:param soup: 上面创建的BS实例
:return: 返回有效的数据
'''
data = {}
data['url'] = page_url
title = soup.find('dd', class_='lemmaWgt-lemmaTitle-title').find('h1')
data['title'] = title.get_text() # 获取标签中的内容
summary = soup.find('div', class_='lemma-summary')
data['summary'] = summary.get_text()
return data