pyspider 是什么?
一个Python写的强大的网路爬虫系统。
github:https://github.com/binux/pyspider
官方文档:http://docs.pyspider.org/en/latest/
什么是网络爬虫?
一个扫描网络内容并记录其有用信息的工具。打开一大堆网页,分析每个页面的内容以寻找想要数据,将数据存储在一个数据库中;
pyspider 有什么特色?
- 有UI:可视化,有任务监视器、项目管理器、结果查看器;
- 脚本控制:可以用任何你喜欢的html解析包(内置 pyquery);
- 支持多种数据库:MySQL, MongoDB, Redis, SQLite, Elasticsearch; PostgreSQL 及 SQLAlchemy
- 消息队列支持 RabbitMQ, Beanstalk, Redis 和 Kombu
- 调度控制:支持超时重试及优先级设置
- 支持分布式:组件可替换,支持单机/分布式部署,支持 Docker 部署
- 支持多线程、异步IO等技术
- 可周期采集
扒源码的目的有哪些?过程中要回答哪些问题?
- 学习如何写Python?语法上的技巧
- 多种数据库是怎么支持的?设计模式?
- 如何切换html解析包
- 多种消息队列如何支持的?设计模式?
- 调度控制如何实现?重试机制如何安排?优先级如何设置?
- 周期采集如何设置?定时周期的技巧?是配置的吗?
- 多线程是怎么用的?
- 异步是如何使用的?
- 如何对监控任务进行解耦,达到自如扩展、缩减、甚至是配置?
源码结构
根目录:
- data:空文件夹,存放由爬虫所生成的数据;
- docs,项目文档,里边有一些markdown代码;
- pyspider:项目实际代码;
- test:测试代码
重要的文件:
- travis.yml,很棒的、连续性测试的整合。如何确定你的项目确实有效?仅在有固定版本库的机器上进测试是不够的。
- Dockerfile,同样很棒的工具!如果我想在我的机器上尝试一个项目,我只需要运行Docker,我不需要手动安装任何东西,这是一个使开发者参与到你的项目中的很好的方式;
- LICENSE:开源项目必需的;
- requirements.txt:Python世界中,该文件指明为运行该软件需要的Python包;
- setup.py:Python脚本,在系统中安装pyspider项目;
- run.py:软件的主入口;
先了解两个概念:
- project:项目,如百度爬虫项目,包括爬虫涉及的所有页面,分析网页所要python脚本,存储数据的数据库等;
- task:任务,如要从网站检索并进行分析的单独页面;
我们进入run.py
,会发现主函数调用了cli()
- 函数
cli()
:cli看着内容很多,其实主要目的是创建数据库和消息系统的所有连接。主要解析命令行参数,并用所有需要的东西创建一个大字典。最后,我们通过调用 all()开始真正的工作 - 函数
all()
:启起来所有的组件,并决定是否运行子进程 或 线程;————核心方法!!!!
(业务需求:一个网络爬虫会进行大量的IO操作,用多线程就可以在等待网络获取html页面时提取前一个页面的有用信息。)
def all(ctx, fetcher_num, processor_num, result_worker_num, run_in):
""" Run all the components in subprocess or thread"""
ctx.obj['debug'] = False
g = ctx.obj
"""
subprocess用 Process在 Windows 平台上创建新进程(并行),else 使用 Thread 类创建多线程方法类似(并发)
并行指两个或多个事件在同时发生。进程并行可以充分利用计算机资源,同时执行多个任务,占用多个cpu资源;
并发指两个或多个事件在同时发生,线程并发不管计算机有多少个CPU,不管开了多少个线程,同一时间多个任务会在其中一个CPU来回切换,只占用一个CPU;
"""
if run_in == 'subprocess' and os.name != 'nt':
run_in = utils.run_in_subprocess
else:
run_in = utils.run_in_thread
threads = [] # 列表
try:
# phantomjs 基于webkit的jsAPI,可以网络监测、网页截屏、无需浏览器的Web测试、页面访问自动化等
if not g.get('phantomjs_proxy'):
phantomjs_config = g.config.get('phantomjs', {})
phantomjs_config.setdefault('auto_restart', True)
threads.append(run_in(ctx.invoke, phantomjs, **phantomjs_config))
time.sleep(2)
if threads[-1].is_alive() and not g.get('phantomjs_proxy'):
g['phantomjs_proxy'] = '127.0.0.1:%s' % phantomjs_config.get('port', 25555)
# result worker
result_worker_config = g.config.get('result_worker', {})
for i in range(result_worker_num):
threads.append(run_in(ctx.invoke, result_worker, **result_worker_config))
# processor 处理组件
processor_config = g.config.get('processor', {})
for i in range(processor_num):
threads.append(run_in(ctx.invoke, processor, **processor_config))
# fetcher 抓取组件
fetcher_config = g.config.get('fetcher', {})
fetcher_config.setdefault('xmlrpc_host', '127.0.0.1')
for i in range(fetcher_num):
threads.append(run_in(ctx.invoke, fetcher, **fetcher_config))
# scheduler 调度组件
scheduler_config = g.config.get('scheduler', {})
scheduler_config.setdefault('xmlrpc_host', '127.0.0.1')
threads.append(run_in(ctx.invoke, scheduler, **scheduler_config))
# running webui in main thread to make it exitable
webui_config = g.config.get('webui', {})
webui_config.setdefault('scheduler_rpc', 'http://127.0.0.1:%s/'
% g.config.get('scheduler', {}).get('xmlrpc_port', 23333))
ctx.invoke(webui, **webui_config)
finally:
# 组件运行过程中检查异常,或者是否要求python停止处理。
for each in g.instances:
each.quit()
# exit components run in subprocess
for each in threads:
if not each.is_alive():
continue
if hasattr(each, 'terminate'):
each.terminate()
each.join()
...
def run_in_thread(func, *args, **kwargs):"""Run function in thread, return a Thread object"""
from threading import Thread
thread = Thread(target=func, args=args, kwargs=kwargs)
thread.daemon = True
thread.start()
return thread
def run_in_subprocess(func, *args, **kwargs):"""Run function in subprocess, return a Process object"""
from multiprocessing import Process
thread = Process(target=func, args=args, kwargs=kwargs)
thread.daemon = True
thread.start()
return thread
文件夹pyspider
里面包含的文件夹有:scheduler、fetcher、processor、webui、database、libs;
pyspider架构
pyspider 核心是4个组件:(一句话概括)
- scheduler-调度组件:从2个队列获取任务,然后分配任务(如丢弃、设置优先级等)给1个队列,让抓取组件读取;
- fetcher-抓取组件:读取队列,执行任务,爬到的结果给 结果队列;
- processor-处理组件:进行解析,抽取有用的信息。如果结果return,返回给结果队列,不是则重新给调度组件;
- 监控组件
还有如UI组件:交互产生task,交给调度组件调度。允许编辑和调试脚本,管理整个抓取过程,监控正在进行的任务,并最终输出结果。
数据流动:
scheduler-调度组件
处理3个队列,调度组件会从两个队列(newtask_queue 和 status_queue)中获取任务,把任务加入到 out_queue 队列,稍后会被抓取程序读取;
调度组件做的事:
- 从数据库中加载所需要完成的所有的任务
- 开始一个无限循环,在这个循环中调用几个方法:
- _update_projects:更新projcet, 更新设置,如从调整爬取速度;
- _check_task_done:分析已完成的任务并将其保存到数据库,从status_queue获取任务;
- _check_request: 从newtask_queue队列获得新的任务,处理组件要分析更多的页面会放在该队列中;
- _check_select():把新的网页加入到 抓取组件 的队列中;
- _check_delete():删除已被用户标记的任务和项目;
- _try_dump_cnt():记录一个文件中已完成任务的数量,防止程序异常所导致的数据丢失;
def run(self):
'''Start scheduler loop'''
logger.info("scheduler starting...")
while not self._quit:
try:
time.sleep(self.LOOP_INTERVAL)
self.run_once() # 这是关键!!!
self._exceptions = 0
except KeyboardInterrupt:
break
except Exception as e:
logger.exception(e)
self._exceptions += 1
if self._exceptions > self.EXCEPTION_LIMIT:
break
continue
logger.info("scheduler exiting...")
self._dump_cnt()
def run_once(self):
'''comsume queues and feed tasks to fetcher, once'''
self._update_projects()
self._check_task_done()
self._check_request()
while self._check_cronjob():
pass
self._check_select()
self._check_delete()
self._try_dump_cnt()
_update_projects()
首先进行一个检查现在和更新的时间差,如果满足条件调用_update_project 更新project状态。
def _update_projects(self):
'''Check project update'''
now = time.time()
# 非强制更新情况下,两次更新间隔至少5分钟
if (not self._force_update_project and self._last_update_project + self.UPDATE_PROJECT_INTERVAL > now):
return
# 更新最新更新时间大于上次更新时间的项目
for project in self.projectdb.check_update(self._last_update_project):
self._update_project(project)
logger.debug("project: %s updated.", project['name'])
self._force_update_project = False
self._last_update_project = now
get_info_attributes = ['min_tick', 'retry_delay', 'crawl_config']
_update_project()
pyspider很喜欢具体的模块具体来实现,并且名字通过下划线来分别。_update_project就是具体的更新方法。
发送一个taskid为_on_get_info给fetcher队列(用来更新project), 从数据库读取task,插入到self.project.task_queue
if project._send_on_get_info:
# update project runtime info from processor by sending a _on_get_info
# request, result is in status_page.track.save
project._send_on_get_info = False
self.on_select_task({
'taskid': '_on_get_info',
'project': project.name,
'url': 'data:,_on_get_info',
'status': self.taskdb.SUCCESS,
'fetch': {
'save': self.get_info_attributes,
},
'process': {
'callback': '_on_get_info',
},
})
# load task queue when project is running and delete task_queue when project is stoped
if project.active:
if not project.task_loaded:
# 从数据库取task
self._load_tasks(project)
project.task_loaded = True
webui更新project的交互的实现就是通过rpc触发修改_force_update_project的function.
def update_project():
self._force_update_project = True
application.register_function(update_project, 'update_project')
_check_task_done()
检查status_queue. 叫_check_task_done的原因可能是因为这个队列的task是通过process模块产生, 检查是否正确.
while True:
if task.get('taskid') == '_on_get_info' and 'project' in task and 'track' in task:
if task['project'] not in self.projects:
continue
project = self.projects[task['project']] # 从self.projects[task['project']]拿到project的信息
project.on_get_info(task['track'].get('save') or {}) # 从info里面拿到信息
# save是写爬虫存的参数,fetch给fetcher模块,process给process模块,
logger.info(
'%s on_get_info %r', task['project'], task['track'].get('save', {})
)
continue
# 检测task是否满足需求
elif not self.task_verify(task):
continue
# 如果是新的task,
self.on_task_status(task) # 判断task的process是否成功
从status_queue拿到task,这里的task长这个样子
{ //project,taskid,url是3个基础属性,scheduler里有一个self.projects进行区别
'taskid': '_on_get_info','project': 'baidu','url': 'data:,_on_get_info',
'track': {
'process': {
'time': 0.022366762161254883,'ok': True,'exception': None,'result': None,'follows': 0,'logs': ''},
'fetch': {
'error': None,'redirect_url': None,'ok': True,'time': 0,'encoding': None,'status_code': 200,
'headers': {},'content': None},
'save': {'retry_delay': {},'min_tick': 86400,'crawl_config': {}}
}
}
参数说明
project,taskid,url 是字面意思。注意scheduler里面有一个self.projects进行 区别
track里面的save,fetch, process。save是写爬虫存的参数,fetch应该用来给fetcher模块process用来个process模块。
之后从self.projects[task['project']]拿到project的信息
on_get_info() 从info里面拿到信息
on_task_status 判断task的process是否成功
on_task_status
on_task_status调用on_task_done和on_task_failed, 并且把task插入active_tasks
if procesok:
ret = self.on_task_done(task)
else:
ret = self.on_task_failed(task)
self.projects[task['project']].active_tasks.appendleft((time.time(), task))
on_task_done把task放入self.project.status_queue, 并且更新数据库
on_task_failed判断是next_exetime, 如果小于0插入数据库task的status为fail, 否则插入插入数据库放入self.project.task_queue
_check_request()
从_postpone_request 和 newtask_queue 拿到task执行 on_request, _postpone_request这个队列用来存储正在processing状态的task,
可能是说, 在执行但是产生修改的task
def _check_request(self):
'''Check new task queue'''
# check _postpone_request first
todo = []
for task in self._postpone_request:
if task['project'] not in self.projects:
continue
if self.projects[task['project']].task_queue.is_processing(task['taskid']):
todo.append(task)
else:
self.on_request(task)
self._postpone_request = todo
tasks = {}
while len(tasks) < self.LOOP_LIMIT:
try:
task = self.newtask_queue.get_nowait()
except Queue.Empty:
break
if isinstance(task, list):
_tasks = task
else:
_tasks = (task, )
for task in _tasks:
if not self.task_verify(task):
continue
if task['taskid'] in self.projects[task['project']].task_queue:
if not task.get('schedule', {}).get('force_update', False):
logger.debug('ignore newtask %(project)s:%(taskid)s %(url)s', task)
continue
if task['taskid'] in tasks:
if not task.get('schedule', {}).get('force_update', False):
continue
tasks[task['taskid']] = task
for task in itervalues(tasks):
self.on_request(task)
return len(tasks)
从头检查到尾,看这个task是否符合要求。
如果符合要求,加入tasks,并且最后运行
for task in itervalues(tasks):
self.on_request(task)
on_request
def on_request(self, task):
if self.INQUEUE_LIMIT and len(self.projects[task['project']].task_queue) >= self.INQUEUE_LIMIT:
logger.debug('overflow task %(project)s:%(taskid)s %(url)s', task)
return
oldtask = self.taskdb.get_task(task['project'], task['taskid'],
fields=self.merge_task_fields)
if oldtask:
return self.on_old_request(task, oldtask)
else:
return self.on_new_request(task)
把task分成old和new分别执行
on_request从数据库读取oldtask,如果存在执行on_old_request, 如果不存在执行on_new_request
on_old_request
判断老的task是否需要重新爬去或者取消, 更新数据库, 插入self.project.task_queue
on_new_request
插入task到数据库, 插入task到self.project.task_queue
_check_cronjob
插入一个taskid为_on_cronjob的task给fetcher的队列,插入task到self.project.active_tasks
def _check_cronjob(self):
"""Check projects cronjob tick, return True when a new tick is sended"""
now = time.time()
self._last_tick = int(self._last_tick)
if now - self._last_tick < 1:
return False
self._last_tick += 1
for project in itervalues(self.projects):
if not project.active:
continue
if project.waiting_get_info:
continue
if int(project.min_tick) == 0:
continue
if self._last_tick % int(project.min_tick) != 0:
continue
self.on_select_task({
'taskid': '_on_cronjob',
'project': project.name,
'url': 'data:,_on_cronjob',
'status': self.taskdb.SUCCESS,
'fetch': {
'save': {
'tick': self._last_tick,
},
},
'process': {
'callback': '_on_cronjob',
},
})
return True
_check_select()
从self.project.task_queue拿出task, 插入fetcher队列
for project, taskid in taskids:
self._load_put_task(project, taskid)
剩下两个用来删除project和监控队列数量
fetcher-抓取组件
终于来到抓取组件。
抓取组件的目的是检索网络资源。比如处理HTML文本页面和基于AJAX的页面。只有抓取组件能意识到这种差异。
对于输入队列的所有任务,抓取组件生成一个请求,并将结果放入输出队列中。
这过程听起来简单但有一个大问题:如果一个网页请求很慢,需要等待阻止了所有的计算,那程序运行会非常慢?怎么解决?
解决方法:不要在等待网络时阻塞所有的计算。在网络上发送大量消息,相当一部分消息是同时发送的,然后异步等待响应的返回。一旦收到响应,调用另外的回调函数处理响应。
pyspider 中的所有的复杂的异步调度都是由另一个优秀的开源项目tornado完成。
tornado(Web应用框架,异步非阻塞IO处理)是一个异步的框架,为什么pysider可以调节速度rate。原因就在于,不是什么线程数,是靠队列读取task的入口数量,在通过异步的原理,来实现每秒钟爬取多少条的功能。
来看看processor.py
:
def run(self):
"""Run loop"""
logger.info("fetcher starting...")
def queue_loop(): # 定义函数,接收输入队列中的所有任务,并抓取它们
if not self.outqueue or not self.inqueue:
return
while not self._quit: # 监听中断信号
try:
if self.outqueue.full():
break
if self.http_client.free_size() <= 0:
break
task = self.inqueue.get_nowait()
task = utils.decode_unicode_obj(task)
self.fetch(task) # 实际检索Web资源操作的函数
except queue.Empty:
break
except KeyboardInterrupt:
break
except Exception as e:
logger.exception(e)
break
# 函数queue_loop()作为参数传递给tornado的类PeriodicCallback
# PeriodicCallback会每隔一段具体的时间调用一次queue_loop()函数
tornado.ioloop.PeriodicCallback(queue_loop, 100, io_loop=self.ioloop).start() # milliseconds
tornado.ioloop.PeriodicCallback(self.clear_robot_txt_cache, 10000, io_loop=self.ioloop).start()
self._running = True
try:
self.ioloop.start()
except KeyboardInterrupt:
pass
logger.info("fetcher exiting...")
函数fetch
def fetch(self, task, callback=None): # 只决定检索该资源的正确方法是什么
if self.async:
return self.async_fetch(task, callback)
else:
return self.async_fetch(task, callback).result()
# 网络上资源必须用 phantomjs_fetch()或 简单的http_fetch() 检索
# 判断url类型 选择检索方式
def async_fetch(self, task, callback=None):
url = task.get('url', 'data:,')
if callback is None:
callback = self.send_result
try:
if url.startswith('data:'):
type = 'data'
result = yield gen.maybe_future(self.data_fetch(url, task))
elif task.get('fetch', {}).get('fetch_type') in ('js', 'phantomjs'):
type = 'phantomjs'
# 网络上的资源必须使用函数phantomjs_fetch()或简单的http_fetch()函数检索
result = yield self.phantomjs_fetch(url, task)
elif task.get('fetch', {}).get('fetch_type') in ('splash', ):
type = 'splash'
result = yield self.splash_fetch(url, task)
else:
type = 'http'
result = yield self.http_fetch(url, task)
except Exception as e:
logger.exception(e)
result = self.handle_error(type, url, task, start_time, e)
callback(type, task, result)
self.on_result(type, task, result)
raise gen.Return(result)
函数http_fetch
def http_fetch(self, url, task):
'''HTTP fetcher'''
start_time = time.time()
self.on_fetch('http', task)
handle_error = lambda x: self.handle_error('http', url, task, start_time, x)
# ...
while True:# making requests
# 设置抓取请求的header,比如User-Agent、超时timeout等
if task_fetch.get('robots_txt', False):
can_fetch = yield self.can_fetch(fetch['headers']['User-Agent'], fetch['url'])
if not can_fetch:
error = tornado.httpclient.HTTPError(403, 'Disallowed by robots.txt')
raise gen.Return(handle_error(error))
try:
# 得到一个tornado的请求对象request,并发送这个请求对象
request = tornado.httpclient.HTTPRequest(**fetch)
# ...
try:
response = yield gen.maybe_future(self.http_client.fetch(request))
# ...
# ...
# 以字典的形式保存一个response的所有相关信息,如url、状态码、响应等
result = {}
result['orig_url'] = url
result['content'] = response.body or '' # type: bytes. http://www.tornadoweb.org
result['headers'] = dict(response.headers)
# ...
# 此函数返回 并在fetch函数中调用回调函数
callback(type, task, result) # self.send_result赋值给了callback
def send_result(self, type, task, result):'''Send fetch result to processor'''
if self.outqueue:
try:
# 将结果放入到输出队列中,等待处理组件 processor 读取。
self.outqueue.put((task, result))
processor-处理组件
内容组件目的是分析抓回来的页面。过程也是个大循环,输入 1 个队列(inqueue),输出 3 个队列(status_queue, newtask_queue 以及result_queue)
核心方法依然是run:
def run(self):
logger.info("processor starting...")
# 终止信号
while not self._quit:
try:
# 从队列中得到需要被分析的下一个任务
task, response = self.inqueue.get(timeout=1) # (task, result<dict>)
# 用on_task(task, response)函数对其进行分析
self.on_task(task, response)
self._exceptions = 0
except Queue.Empty as e:
continue
except KeyboardInterrupt:
break
except Exception as e:
logger.exception(e)
# 统计引发的异常数量,异常数量过多也会终止循环
self._exceptions += 1
if self._exceptions > self.EXCEPTION_LIMIT:
break
continue
logger.info("processor exiting...")
函数on_task()是真正干活的方法:
def on_task(self, task, response):
start_time = time.time()
response = rebuild_response(response)
try:
# 利用输入的任务找到任务所属的项目
assert 'taskid' in task, 'need taskid in task'
project = task['project']
# ...
# 运行项目中的定制脚本
project_data = self.project_manager.get(project, updatetime, md5sum)
# 分析定制脚本返回的响应response
assert project_data, "no such project!"
if project_data.get('exception'):
ret = ProcessorResult(logs=(project_data.get('exception_log'), ),
exception=project_data['exception'])
else:
ret = project_data['instance'].run_task(
project_data['module'], task, response)
# ...
# 创建包含所有从网页上得到信息的字典
track_headers = dict(response.headers)
# ...
# 将字典放到队列status_queue中
status_pack = {
'taskid': task['taskid'],
'project': task['project'],
'url': task.get('url'),
'track': {
'fetch': {
'ok': response.isok(),
'redirect_url': response.url if response.url != response.orig_url else None,
'time': response.time,
'error': response.error,
'status_code': response.status_code,
'encoding': getattr(response, '_encoding', None),
'headers': track_headers,
'content': response.text[:500] if ret.exception else None,
},
'process': {
'ok': not ret.exception,
'time': process_time,
'follows': len(ret.follows),
'result': (
None if ret.result is None
else utils.text(ret.result)[:self.RESULT_RESULT_LIMIT]
),
'logs': ret.logstr()[-self.RESULT_LOGS_LIMIT:],
'exception': ret.exception,
},
'save': ret.save,
},
}
# ...
# status_pack放入status_queue,被调度程序重新使用
self.status_queue.put(utils.unicode_obj(status_pack))
# 如果页面中有一些新的链接,放入队列newtask_queue中,稍后被调度组件处理
if ret.follows:
for each in (ret.follows[x:x + 1000] for x in range(0, len(ret.follows), 1000)):
self.newtask_queue.put([utils.unicode_obj(newtask) for newtask in each])
# ...
return True
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· DeepSeek 开源周回顾「GitHub 热点速览」
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!
· AI与.NET技术实操系列(二):开始使用ML.NET
· 单线程的Redis速度为什么快?