目录
背景:
【迷你定向网页抓取器】
在调研过程中,经常需要对一些网站进行定向抓取。由于python包含各种强大的库,使用python做定向抓取比较简单。请使用python开发一个迷你定向抓取器mini_spider.py,实现对种子链接的广度优先抓取,并把URL长相符合特定pattern的网页内容(图片或者html等)保存到磁盘上。
程序运行:python mini_spider.py -c conf/spider.conf
配置文件spider.conf:
[spider]
url_list_file: ./urls ; 种子文件路径
output_directory: ./output ; 抓取结果存储目录
max_depth: 1 ; 最大抓取深度(种子为0级)
crawl_interval: 1 ; 抓取间隔. 单位:秒
crawl_timeout: 1 ; 抓取超时. 单位:秒
target_url: .*\.(html|gif|png|jpg|bmp)$ ; 需要存储的目标网页URL pattern(正则表达式) ,需要考虑兼容抓取html等情况,不止是抓取图片
thread_count: 8 ; 抓取线程数
种子文件每行一条链接,例如:
http://cup.baidu.com/spider/
http://www.baidu.com
http://www.sina.com.cn
一、整体架构
1.1 流程图
1.2 代码结构
project
mini_spider.py: 主程序入口
lib目录:存放不同模块的代码
config_load.py: 读取配置文件
seedfile_load.py: 读取种子文件
url_table.py: 构建url管理队列
res_table.py: 结果保存队列
crawl_thread.py: 实现抓取线程
webpage_download.py: 下载网页
webpage_parse.py: 对抓取网页的解析
webpage_save.py: 将网页保存到磁盘
logs目录: 存放日志文件
tests目录: 存放单测文件
mini_spider_test.py: 单元测试
conf目录: 存放配置文件
spider.conf: 配置文件
README.md: 说明文档
urls: 种子文件
1.3 代码主逻辑
"""
Desc: 迷你定向网页抓取器
实现对种子链接的广度优先抓取,并把URL长相符合特定pattern的网页内容(图片或者html等)保存到磁盘上
Authors: zhoujie
Date: 2020/10/14
"""
import argparse
import time
import lib.log as log
import lib.config_load as config_load
import lib.seedfile_load as seedfile_load
import lib.url_table as url_table
import lib.res_table as res_table
import lib.crawl_thread as crawl_thread
import lib.webpage_save as webpage_save
def main(conf_path):
"""
主程序入口
:param conf_path: 配置文件路径
:return:
"""
# 日志初始化, 设置日志写入文件和控制台
log.init_log('./logs/mini_spider_%s' % time.strftime('%Y-%m-%d', time.localtime(time.time())))
# 读取配置文件
spider_config = config_load.main(conf_path)
# 读取种子文件
seedfile_list = seedfile_load.main(spider_config.url_list_file)
# 构建url管理队列
url_queue = url_table.main()
# 构建res保存队列
res_queue = res_table.main()
# 构建抓取线程列表
crawl_thread_list = crawl_thread.main(url_queue, res_queue, spider_config.thread_count, spider_config.target_url,
spider_config.crawl_interval, spider_config.crawl_timeout)
# 构建收集结果线程
save_thread = webpage_save.main(res_queue, spider_config.output_directory)
# 对抓取进行控制
crawl_thread.controller(seedfile_list, url_queue, res_queue, spider_config.max_depth, crawl_thread_list, save_thread)
if __name__ == '__main__':
# 命令行参数处理
desc = """迷你定向网页抓取器"""
argparse = argparse.ArgumentParser(prog="mini_spider", description=desc)
argparse.add_argument("-v",
"--version",
action="version",
version="%(prog)s 1.0",
help="显示版本信息")
argparse.add_argument("-c",
"--config",
required=True,
help="必填选项,输入爬虫配置文件路径")
args = argparse.parse_args()
main(args.config)
二、知识学习总结
2.1 多线程 Threading
2.1.1 抓取线程
class CrawlThread(threading.Thread):
"""
抓取线程
"""
def __init__(self, url_queue, res_queue, crawl_interval, crawl_timeout, target_re,
thread_name="crawl_thread"):
"""
抓取线程初始化
:param url_queue: url管理队列
:param res_queue: 结果保存队列
:param crawl_interval: 抓取间隔. 单位:秒
:param crawl_timeout: 抓取超时. 单位:秒
:param target_re: 正则对象
"""
threading.Thread.__init__(self)
self.thread_name = thread_name
self.url_queue = url_queue
self.res_queue = res_queue
self.crawl_interval = crawl_interval
self.crawl_timeout = crawl_timeout
self.target_re = target_re
self.stop_event = threading.Event()
self.url_list_cur = list() # 待抓取url列表
self.res_list_cur = list() # 待保存res列表
def run(self):
"""
执行
"""
try:
logging.info("%s 初始化完成,开始执行" % self.thread_name)
while not self.stop_event.is_set():
crawl_url = self.url_queue.get()
if crawl_url:
logging.info("%s 开始执行抓取url:%s" % (self.thread_name, crawl_url))
webpage_res = webpage_download.main(self.thread_name, crawl_url, self.crawl_timeout)
url_list, res_list = webpage_parse.main(crawl_url, webpage_res, self.target_re)
self.url_list_cur.extend(url_list)
self.res_list_cur.extend(res_list)
self.url_queue.task_done()
time.sleep(self.crawl_interval)
except Exception as e:
logging.error("保存抓取文件异常, msg is [%s]" % traceback.format_exc())
def get_url_list_cur(self):
"""
获取待抓取url列表
"""
return self.url_list_cur
def get_res_list_cur(self):
"""
获取待保存res列表
"""
return self.res_list_cur
def clear(self):
"""
清理待抓取url列表与保存res列表
"""
self.url_list_cur.clear()
self.res_list_cur.clear()
def stop(self):
"""
结束线程
"""
self.stop_event.set()
2.1.2 保存线程
class SaveThread(threading.Thread):
"""
保存线程
"""
def __init__(self, res_queue, output_directory, thread_name="save_thread"):
"""
初始化保存线程
:param res_queue: 结果保存队列
:param output_directory: 抓取结果存储目录
"""
threading.Thread.__init__(self)
self.thread_name = thread_name
self.res_queue = res_queue
self.output_directory = output_directory
self.stop_event = threading.Event()
def run(self):
"""
启动线程
"""
try:
logging.info("%s 初始化完成,开始执行" % self.thread_name)
with open(self.output_directory, 'w') as outfile:
while not self.stop_event.is_set():
res = self.res_queue.get()
if res:
logging.info("保存 %s" % res)
outfile.write(res + os.linesep)
self.res_queue.task_done()
except Exception as e:
logging.error("保存抓取文件异常, msg is [%s]" % traceback.format_exc())
def stop(self):
"""
结束线程
"""
self.stop_event.set()
2.2 线程间的同步 Queue
2.2.1 UrlQueue
class UrlQueue(object):
"""
url管理队列
"""
def __init__(self):
"""
初始化url队列
"""
self.url_queue = queue.Queue()
self.url_set = set() # 去重
self.timeout = 5 # 获取队列,timeout等待时间
def put_list(self, url_list):
"""
新增url列表
"""
if isinstance(url_list, list):
for url in url_list:
if url is not None and url not in self.url_set:
self.url_queue.put(url)
self.url_set.add(url)
def get(self):
"""
取url
"""
try:
return self.url_queue.get(timeout=self.timeout)
except Exception as e:
logging.info("队列中暂无url")
def task_done(self):
"""
已完成
"""
self.url_queue.task_done()
def join(self):
"""
阻塞
"""
self.url_queue.join()
2.2.2 ResQueue
class ResQueue(object):
"""
res结果保存队列
"""
def __init__(self):
"""
初始化res队列
"""
self.res_queue = queue.Queue()
self.timeout = 5 # 获取队列,timeout等待时间
def put_list(self, res_list):
"""
新增res列表
"""
if isinstance(res_list, list):
for res in res_list:
self.res_queue.put(res)
def get(self):
"""
取结果
"""
try:
return self.res_queue.get(timeout=self.timeout)
except Exception as e:
logging.info("队列中暂无res")
def task_done(self):
"""
已完成
"""
self.res_queue.task_done()
def join(self):
"""
阻塞
"""
self.res_queue.join()
2.3 核心控制器部分 controller
def controller(seedfile_list, url_queue, res_queue, max_depth, crawl_thread_list, save_thread):
"""
构建启动抓取线程
:param seedfile_list: 种子列表
:param url_queue: url管理队列
:param res_queue: res保存队列
:param max_depth: 最大抓取深度
:param crawl_thread_list: 抓取线程列表
:param save_thread: 收集结果线程
:return:
"""
# 加入urls中初始链接
url_queue.put_list(seedfile_list)
# 启动抓取线程
for crawl_thread in crawl_thread_list:
crawl_thread.start()
# 启动保存线程
save_thread.start()
cur_depth = 1
while True:
logging.info("***************************** 当前抓取深度:%d,最大抓取深度:%d *****************************"
% (cur_depth, max_depth))
url_queue.join()
# 深度 cur_depth 抓取结束,更新 url_queue、res_queue
if cur_depth < max_depth:
for crawl_thread in crawl_thread_list:
url_queue.put_list(crawl_thread.get_url_list_cur())
res_queue.put_list(crawl_thread.get_res_list_cur())
crawl_thread.clear()
elif cur_depth == max_depth:
for crawl_thread in crawl_thread_list:
res_queue.put_list(crawl_thread.get_res_list_cur())
crawl_thread.clear()
crawl_thread.stop()
break
cur_depth += 1
res_queue.join()
save_thread.stop()
for crawl_thread in crawl_thread_list:
crawl_thread.join()
2.4 页面内容处理 bs4
2.4.1 页面下载
def is_url(url):
"""
判断url是否合法
"""
pattern = r"^https?:/{2}\w.+$"
res = re.match(pattern, url)
if res:
return True
else:
return False
def main(thread_name, crawl_url, crawl_timeout):
"""
下载url
:param thread_name: 线程名
:param crawl_url: 下载url
:param crawl_timeout: 抓取超时时间
:return: 返回url对应的网页内容
"""
crawl_res = None
try:
if is_url(crawl_url):
crawl_res_req = requests.get(crawl_url, timeout=crawl_timeout)
if crawl_res_req and crawl_res_req.status_code == requests.codes.ok:
crawl_res = crawl_res_req.text
logging.info('%s 从 url:%s 抓取页面信息,status_code: %s'
% (thread_name, crawl_url, crawl_res_req.status_code))
else:
logging.error("url:%s 是无效地址" % crawl_url)
except Exception as e:
# 打印异常,只打印第一和最后一行
formatted_lines = traceback.format_exc().splitlines()
logging.error("下载 url:%s 失败, %s, %s" % (crawl_url, formatted_lines[0], formatted_lines[-1]))
finally:
return crawl_res
2.4.2 页面解析
def get_url_list_or_res_list(crawl_url, urls, target_re):
"""
获取 url_list 、res_list
:param crawl_url: 当前抓取url
:param urls: 页面元素
:param target_re: 正则对象
:return 返回的url_list 或 res_list
"""
fanal_list = []
for url in urls:
href_url = url.get("href")
if not href_url:
continue
elif href_url.startswith("http"):
final_url = href_url
elif "javascript:location.href" in href_url:
# javascript:location.href="/url"
final_url = url_parse.urljoin(crawl_url, href_url.split("=")[-1].split("\"")[-2])
else:
final_url = url_parse.urljoin(crawl_url, href_url)
# 匹配校验
if target_re.match(final_url):
fanal_list.append(final_url)
return fanal_list
def main(crawl_url, webpage_res, target_re):
"""
下载url
:param crawl_url: 当前抓取url
:param webpage_res: 下载的网页内容
:param target_re: 正则对象
:return url_list: 子网页的url
:return res_list: 网页抓取内容
"""
url_list = []
res_list = []
if webpage_res:
soup = bs4.BeautifulSoup(webpage_res, "html.parser")
# 获取所有的<a><link><img>
tag_a_link_res = soup.find_all(["a", "link"])
tag_a_link_img_res = soup.find_all(["a", "link", "img"])
url_list = get_url_list_or_res_list(crawl_url, tag_a_link_res, target_re)
res_list = get_url_list_or_res_list(crawl_url, tag_a_link_img_res, target_re)
logging.info("%s 页面含新url个数:%s,页面获取到符合正则限定的数据个数:%s" % (crawl_url, len(url_list), len(res_list)))
return url_list, res_list