Python3学习笔记2:简易Web爬虫
开发环境
基础语法那章的内容我是在Docker容器中玩的,但是真正做项目的时候,没有IDE的强大辅助功能来协助的话是很累人的一件事。因此本文中,我选择使用Jetbrain
的Pycharm
这个IDE来开发、调试代码。IDE的好处多多,比如:
- 强大的智能提示
- 强大的断点调试
- 性能追踪
- 方便好用的各种插件
- 各种自定义配置
需求
为了实践Python,最先想到的就是要完成一个爬虫程序,大概需求如下:
实施
可配置化我本身是计划通过DI(Dependency Injection
)这个技术来完成,不过查了下资料,由于Python和其他语言不太一样,Python是可以多父类继承,并且遵循Duck Typing
原则,因此DI在Python中并不实用(Python也是没有Interface概念的)。但可以通过如下方式实现类似的逻辑:
# 假设a-class-name这个类包含在xxx.py文件中,首先引入这个文件中的内容
from xxx import *
# 然后执行以下这行代码,这将初始化一个a-class-name类的实例
(lambda x: globals()[x])('a-class-name')
入口程序文件main.py
main.py
主要有几个功能:
- 通过交互让用户输入:项目名称、网站首页、线程数三个初始化变量
- 初始化数据库访问对象
- 初始化爬虫对象
- 初始化线程池
- 执行程序
核心代码如下:
from db_queue import *
...
def execute():
...
(lambda x: globals()[x])(project_settings.DB_CLASS_NAME)(home_page, project_name + '_pages')
Spider(project_name, home_page, DomainHelpers.get_domain_name(home_page), project_settings.HTML_RESOLVER_NAME)
worker = Worker(thread_count, project_name)
worker.create_threads()
worker.crawl()
execute()
逻辑解释:
(lambda x: globals()[x])(project_settings.DB_CLASS_NAME)(home_page, project_name + '_pages')
,本例中DB_CLASS_NAME = 'MongoDbQueue'
,因此Python将在当前页面的应用中查找名为MongoDbQueue
的类来执行初始化并传入构造函数的参数:home_page
和project_name + '_pages'
- 初始化
Spider
类,以便在线程中执行爬取页面 - 初始化指定数量的现成作为线程池以备后续使用,main.py执行完毕,线程将被自动回收
- 开始执行爬虫程序
线程创建类worker.py文件
from db_queue import *
class Worker:
...
def __init__(self, thread_count, project_name):
Worker.DB = (lambda x: globals()[x])(project_settings.DB_CLASS_NAME)
...
def create_threads(self):
for _ in range(self.thread_count):
t = threading.Thread(target=self.__run_thread)
t.daemon = True
t.start()
def __run_thread(self):
while True:
url = self.queue.get()
Spider.crawl_page(threading.current_thread().name, url)
self.queue.task_done()
def __create_jobs(self):
for link in Worker.DB.get_pending_queue():
self.queue.put(link)
self.queue.join()
self.crawl()
def crawl(self):
urls = Worker.DB.get_pending_queue()
if len(urls) > 0:
self.__create_jobs()
逻辑解释:
__init__
中将数据库连接类保存到全局变量DB中create_threads
将初始化指定数量的线程数,设置为datmon=true
以便线程被创建之后一直存在,随时可以被调用crawl
将获取待爬列表之后,将其放入Spider所需的待爬队列中self.queue.join()
是用来阻塞队列,这样队列中的每一项都将只被调用一次__run_thread
和__create_jobs
这两个方法是Worker内部调用的方法,不需要公开给其他人,因此加上前缀__
(两个下划线)
数据库操作基础类
由于需要将数据库操作做成可替换,因此必须实现数据库操作的接口,而Python没有Interface,但是可以使用abc(Abstract Based Class)来实现类似于Interface所需的功能。
代码如下:
from abc import ABCMeta, abstractmethod
class DbBase(metaclass=ABCMeta):
@abstractmethod
def __init__(self, file_name):
pass
@staticmethod
@abstractmethod
def get_pending_queue():
pass
@staticmethod
@abstractmethod
def is_page_in_queue():
pass
@staticmethod
@abstractmethod
def save_pending_queue():
pass
@staticmethod
@abstractmethod
def set_page_crawled():
pass
逻辑解释:
class DbBase(metaclass=ABCMeta)
表示DbBase
类的元类为ABCMeta
@abstractmethod
则表明该方法在继承了DbBase
的类中必须被实现,如果没有被实现,执行时将会报错:TypeError: Can't instantiate abstract class XXXX with abstract methods xxxx
数据库存储操作db_queue.py文件
from pymongo import *
from abc_base.db_base import DbBase
...
class MongoDbQueue(DbBase):
def __init__(self, home_page, tbl_name='pages'):
...
MongoDbQueue.db = MongoClient(project_settings.DB_CONNECTION_STRING)[project_settings.DB_REPOSITORY_NAME]
...
# create unique index
MongoDbQueue.db[MongoDbQueue.tbl_name].create_index('url', unique=True)
@staticmethod
def get_pending_queue():
...
@staticmethod
def is_page_in_queue(url):
...
@staticmethod
def save_pending_queue(urls):
...
@staticmethod
def set_page_crawled(url):
...
逻辑解释:
class MongoDbQueue(DbBase):
表示该类继承了DbBase
,因此必须实现DbBase
中定义的几个方法__init__
、get_pending_queue
、is_page_in_queue
、save_pending_queue
及set_page_crawled
- 为了确保相同的url绝对不会重复,在数据库层也增加一个
Unique Index
以便从数据库层面也做好验证 get_pending_queue
将所有未被爬过的页面列表返回is_page_in_queue
判断是否页面在待爬列表中save_pending_queue
,这个方法是在爬取某个页面,抓取了该页面上所有新的代码链接之后,将数据库中不存在的连接保存为待爬页面set_page_crawled
,这个方法将数据库中已存在,且状态为未爬过的页面,设置为已爬,该方法将在爬虫爬好某个页面之后被调用
爬虫文件spider.py文件
...
class Spider:
...
def __init__(self, base_url, domain_name, html_resolver):
...
Spider.crawl_page('First spider', Spider.BASE_URL)
@staticmethod
def crawl_page(thread_name, page_url):
if Spider.DB.is_page_in_queue(page_url):
...
urls = Spider.add_links_to_queue(Spider.gather_links(page_url))
Spider.DB.save_pending_queue(urls)
Spider.DB.set_page_crawled(page_url)
@staticmethod
def gather_links(page_url):
html_string = ''
...
# to make self-signed ssl works, pass variable 'context' to function 'urlopen'
context = ssl._create_unverified_context()
response = urlopen(page_url, context=context)
...
finder = (lambda x: globals()[x])(Spider.HTML_RESOLVER)(Spider.BASE_URL, page_url)
return finder.page_links()
@staticmethod
def add_links_to_queue(urls):
...
for url in urls:
if Spider.DOMAIN_NAME != DomainHelpers.get_domain_name(url):
continue
...
逻辑解释:
Spider.DB = (lambda x: globals()[x])(project_settings.DB_CLASS_NAME)
这一行依然是动态初始化数据库操作类context = ssl._create_unverified_context()
,有时候有些自签名ssl证书,执行urlopen
方法时会报错,需要创建这个context变量来避免这个错误产生finder = (lambda x: globals()[x])(Spider.HTML_RESOLVER)(Spider.BASE_URL, page_url)
这行也是通过动态初始化的方式,按照配置文件中指定的解析类来解析html内容,如果想自定义解析内容,只要重新实现一个解析类即可add_links_to_queue
这个方法是确保只会将当前域名相关的页面保存起来以便后续继续爬,如果不加这个判断,一旦页面上有一个www.weibo.com
这样的链接的话,那爬虫估计会把整个互联网上的内容都爬一遍。。。
html解析html_resolver.py文件
class HtmlResolver(HTMLParser):
...
def handle_starttag(self, tag, attrs):
if tag == 'a':
for (attribute, value) in attrs:
if attribute == 'href':
url = parse.urljoin(self.base_url, value)
self.links.add(url)
...
这个类决定了我们爬取页面的逻辑,这里我们只抓去链接(也就是a标签)中的href
属性中的内容。
执行过程动图
附录
本Demo完整代码已经放到Github上: https://github.com/fisherdan/crawler。
本文在博客园和我的个人博客www.fujiabin.com上同步发布。转载请注明来源。