pyspider 是什么?
一个Python写的强大的网路爬虫系统。
github:https://github.com/binux/pyspider
官方文档:http://docs.pyspider.org/en/latest/

什么是网络爬虫?
一个扫描网络内容并记录其有用信息的工具。打开一大堆网页,分析每个页面的内容以寻找想要数据,将数据存储在一个数据库中;

pyspider 有什么特色?

  1. 有UI:可视化,有任务监视器、项目管理器、结果查看器;
  2. 脚本控制:可以用任何你喜欢的html解析包(内置 pyquery);
  3. 支持多种数据库:MySQL, MongoDB, Redis, SQLite, Elasticsearch; PostgreSQL 及 SQLAlchemy
  4. 消息队列支持 RabbitMQ, Beanstalk, Redis 和 Kombu
  5. 调度控制:支持超时重试及优先级设置
  6. 支持分布式:组件可替换,支持单机/分布式部署,支持 Docker 部署
  7. 支持多线程、异步IO等技术
  8. 可周期采集

扒源码的目的有哪些?过程中要回答哪些问题?

  1. 学习如何写Python?语法上的技巧
  2. 多种数据库是怎么支持的?设计模式?
  3. 如何切换html解析包
  4. 多种消息队列如何支持的?设计模式?
  5. 调度控制如何实现?重试机制如何安排?优先级如何设置?
  6. 周期采集如何设置?定时周期的技巧?是配置的吗?
  7. 多线程是怎么用的?
  8. 异步是如何使用的?
  9. 如何对监控任务进行解耦,达到自如扩展、缩减、甚至是配置?

源码结构

根目录

  • data:空文件夹,存放由爬虫所生成的数据;
  • docs,项目文档,里边有一些markdown代码;
  • pyspider:项目实际代码;
  • test:测试代码

重要的文件

  • travis.yml,很棒的、连续性测试的整合。如何确定你的项目确实有效?仅在有固定版本库的机器上进测试是不够的。
  • Dockerfile,同样很棒的工具!如果我想在我的机器上尝试一个项目,我只需要运行Docker,我不需要手动安装任何东西,这是一个使开发者参与到你的项目中的很好的方式;
  • LICENSE:开源项目必需的;
  • requirements.txt:Python世界中,该文件指明为运行该软件需要的Python包;
  • setup.py:Python脚本,在系统中安装pyspider项目;
  • run.py:软件的主入口;

先了解两个概念:

  1. project:项目,如百度爬虫项目,包括爬虫涉及的所有页面,分析网页所要python脚本,存储数据的数据库等;
  2. 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个组件:(一句话概括)

  1. scheduler-调度组件:从2个队列获取任务,然后分配任务(如丢弃、设置优先级等)给1个队列,让抓取组件读取;
  2. fetcher-抓取组件:读取队列,执行任务,爬到的结果给 结果队列;
  3. processor-处理组件:进行解析,抽取有用的信息。如果结果return,返回给结果队列,不是则重新给调度组件;
  4. 监控组件
    还有如UI组件:交互产生task,交给调度组件调度。允许编辑和调试脚本,管理整个抓取过程,监控正在进行的任务,并最终输出结果。

数据流动

scheduler-调度组件

处理3个队列,调度组件会从两个队列(newtask_queue 和 status_queue)中获取任务,把任务加入到 out_queue 队列,稍后会被抓取程序读取;

调度组件做的事:

  1. 从数据库中加载所需要完成的所有的任务
  2. 开始一个无限循环,在这个循环中调用几个方法:
    1. _update_projects:更新projcet, 更新设置,如从调整爬取速度;
    2. _check_task_done:分析已完成的任务并将其保存到数据库,从status_queue获取任务;
    3. _check_request: 从newtask_queue队列获得新的任务,处理组件要分析更多的页面会放在该队列中;
    4. _check_select():把新的网页加入到 抓取组件 的队列中;
    5. _check_delete():删除已被用户标记的任务和项目;
    6. _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
posted on 2023-09-15 23:43  大元王保保  阅读(65)  评论(0编辑  收藏  举报