大多数Python学习者都是冲着Python在爬虫领域有非常出色的表现才开始学习的,在学好Python的基本语法并会使用re、requests、BeautifulSoup4等模块后,很快就能写出一个简单的爬虫小程序(不要看不起这个,毕竟这是我们每个人从0到1的必经阶段)。
这篇文章的受众是:有Python基础,会用requests框架,会写简单的单进程单线程爬虫的新手,最好了解相关的threading模块知识。旨在让新手了解多线程爬虫的优势并会使用它,文章内容我觉得也比较易懂,相信会对大家有所帮助。另一方面,也能记录一下我的Python学习历程。
在这之前,我们看一下相关的Q&A科普,有了解的就跳过吧,我尽量配合易理解的语言来表述:
Q1: 什么是多线程?
A1: 多线程(Multithreading)是指从软件或者硬件上实现多个线程并发执行的技术。具有多线程能力的计算机因有硬件支持而能够在同一时间执行多于一个执行绪,进而提升整体处理性能。通俗点来说,就是计算机程序在处理一个大任务时,程序将这个大任务按相关逻辑功能分成几个小任务,由多个线程分别工作。
Q2: Python也有多线程吗?
A2: 有,具体实现是threading模块(在python3是标准内置模块)。不过我们要了解一下GIL(全局解释锁,Global Interpreter Lock)的概念。是计算机程序设计语言解释器用于同步线程的一种机制,它使得任何时刻仅有一个线程在执行,禁止多线程的并行执行。常见例子有CPython(Jython不使用GIL)与Ruby MRI。我们的Python(CPython实现)解释器就是如此,同一时间只有一个线程在工作,但因为线程切换得很快导致我们认为是多个线程在同时工作。
Q3: 那Python的多线程不就没什么用了吗?
A3: 其实并不是这样。在程序进行CPU密集型的任务时(计算、逻辑判断等CPU动作频繁),因GIL的存在的确即使在多核的情况下也提升不了性能,反而因为线程的频繁切换甚至会下降。但在程序进行IO密集型的任务时(CPU大多数时间在等I/O的读写),Python的多线程就发挥出了它的优势,尤其在Python在http请求上,使用多线程可以使其效率大幅度提升,在一个线程在等待socket返回数据时其他线程可以继续执行。
Q4: 我还听说过进程、协程等概念,它们又是什么?
A4: 简单说一下。进程(Process)是计算机中已运行程序的实体,一个进程可以由多个线程构成。协程(Coroutine) 是比线程更为轻量的一种模型,我们可以自行控制启动与停止的时机,Python3的协程爬虫就是基于此,有兴趣的可以深入了解一下。
关于threading模块,大家最好自己花点时间先去看看官方文档https://docs.python.org/3/library/threading.html
我简单说一下比较重要的,创建一个线程有两种方法,一是将函数对象传入threading.Thread构造函数的target参数,将函数参数作为元组传入args参数。二是写一个threading.Thread的子类,然后重写其run方法。我平常用的较多的是后者。
举例来说,创建并运行一个每5秒打印出一个hello+名字,打印十次之后结束的线程:
第一种方法:
import time
import threading
def hello(name):
for i in range(10):
print("Hello, " + name)
time.sleep(5)
test_thread = thread.Thread(target=hello,args=("Sitnalta",)) #创建线程对象
test_thread.start() #线程开始工作
test_thread.join() #hreading.Thread的join方法,阻塞主线程,直到该线程运行结束,才运行主线程后面的内容
第二种方法:
import time
import threading
class Hello(threading.Thread):
def __init__(self,n): #注意父类中有name成员变量,所以我们用n来表示名字这个参数
super().__init__() #继承父类构造函数
self.n = n
def run(self): #重写run方法
for i inrange(10):
print("Hello, " + self.n)
time.sleep(5)
test_thread = Hello("Sitnalta") #创建线程对象
test_thread.start()
test_thread.join()
科普完毕,下面开始正文。
有的时候需要某些资源(如影视、软件、教程、文档等)要自己去网上挨个搜索查找筛选下载,比较费时费力。所以我就在想我们可不可以通过爬取具有相关文件的百度贴吧找到贴吧内用户分享的百度网盘链接然后整理出来呢?事实证明这是可行的:
1. 百度贴吧和百度网盘都是百度旗下的,具有很大的相关性。
2. 贴吧名与该贴吧帖子中的网盘文件也具有很大的相关性。而链接处理也很简单,无非就是目录页”tieba.baidu.com/f”,帖子页”tieba.baidu.com/p”,网盘页”pan.baidu.com”。
那我们可不可以写一个爬虫工具来,快速(3分钟以内)爬取特定贴吧的帖子(帖子数目可控),从帖子中筛选出其中的网盘链接,并判断链接是否有效,然后将网盘链接存储起来?
粗略来看,这个爬虫不难设计,无非是基于页面的请求和解析(甚至只用正则表达式就能实现解析),很容易实现。
设计思路和过程(以下为简单的单线程爬虫思路为例,至于多线程其实只是多了几个请求工作线程):
我们以ttf字体吧为例,ttf是一种常用的字体格式,在网页设计、系统美化等方面都有涉及。而我本人作为字体爱好者,比较喜欢收藏各式各样的字体,但字体资源也较为有限,更何况在潮流瞬息万变的今天,字体也是日新月异,百花齐放。获取这类资源的最好途径之一就是打开相关贴吧,有很多善良的贴吧小伙伴分享相关文件,我们只需将文件保存就行了。
我们看一下这个贴吧的目录。在浏览器地址栏输入:tieba.baidu.com,然后在搜索栏输入ttf字体。
好,我们看到有52232个帖子,展现在我们眼前的是ttf字体吧目录页的第一页,并且我们得到了这个贴吧目录首页的url地址:https://tieba.baidu.com/f?ie=utf-8&kw=ttf字体&fr=search。”kw=”后面就是我们要爬取的贴吧名。我们往下滑可以看到很多主题帖链接,这都是在目录页第一页中。我们翻到底部,可以看到这个选择栏:
我们点击第二页。
有一项信息对我们很重要,没错,就是url地址!我们可以看到url地址是https://tieba.baidu.com/f?kw=ttf字体&ie=utf-8&pn=50,我们继续翻后面的一面,很容易分析出一个目录页有50个主题帖链接(因为有删除封禁的主题帖,实际上要少一点),第二页pn=50,第三页pn=100,以此类推,当然我们也可以试试pn=0会不会也是首页链接?事实证明,是的。我们把这个url记录下来后面会用到。
经过以上分析,我们了解到贴吧目录页和主题帖的对应关系,我们可以从目录页中通过简单的正则提取出各目录页中的主题帖页url地址(主题帖页url规则是:https://tieba.baidu.com/p/.*)。当然用BeautifulSoup解析也行,毕竟怎么方便怎么来。
接下来我们打开一个带有网盘分享链接的主题帖,比如说这个:
往往下翻,我们很容易找到其中分享的网盘链接:
同样的方法,我们用正则将网盘链接解析,我们可以在爬虫程序中将这个网盘链接保存起来,后续爬取这个网盘url看是否属于失效链接。
除此之外,对于这个页面,我们还有另一件很重要的事要做。因为这是我们通过解析目录页得到的主题帖,这个主题帖很有可能不止有一页,我们往下翻,可以看到:
这个帖子一共有11页,所以我们还需要爬取后面10页的帖子获取其中的网盘链接,但通过目录页解析我们只能得到这个主题帖的首页。所以,我们要获取后面页面的url还是得解析这个页面的内容,查看源码可以得到:
解析出后面的11(正则,BeautifulSoup随意)以及分析出后面页面的url格式(跟前面获取目录页方法相同)就不赘述了。
附上单线程的实现简化思路图(图用word画的,比较简陋还请包涵):
基本思路就是这样。得到的网盘链接可以通过获取网盘页的title标签内的内容(如"百度网盘-链接不存在","百度网盘 请输入提取密码"即为无效网盘链接)判断。从以上来看,整体处理还是很简单的,但是唯一困难的就是页面请求量较大,如果要请求上千个主题帖(而且很多主题帖不止一页)那就不能快速获得了(想象一下一个单线程爬虫爬到天荒地老)。很容易想到,在面对大量页面请求时,多线程技术就可以闪亮登场了。
那多线程爬虫的思路是怎样的呢?其实,跟单线程思路是差不多的,首先我们除主线程任务之外,构建出另外三个子任务和一个url_and_response_queue,用于存放url和其请求后的response结果对象组成的元组,第一个子任务是专门用于网页请求获取结果,执行从url_queue取出url并requests.get(url),然后将这个url和response结果存放至url_and_response_queue元组,接着循环这个任务。第二个子任务只负责解析,从url_and_response_queue中取出url和其相应的response,执行相应的操作,不参与网页请求。第三个子任务是用于存储爬取结果,即有效网盘链接。显然,网页请求子任务我们可以用多个线程共同工作,可以大幅度提升效率,网页解析和网盘链接存储的子任务分别用一个线程实现就可。
网页请求子任务中有一句很重要的描述,”循环这个任务”,也就是说,我们创造出一堆线程如果只进行一次请求的话,简直就是一种对资源极大的浪费,因为大量的创建、运行、销毁线程对于CPU来说也是一种负担。所以我们可以构造一个用于网页请求任务的线程池,存放多个(上十甚至上百)网页请求子线程。
Talk is cheap, show me the code.
开发环境: Windows + Python 3.x
文件名: tieba_spider.py
import sys
import re
import time
import threading
from queue import Queue
import requests
class ThreadPoolController(object):
"""线程池管理类,可以生成、启动或终止一个线程池"""
def __init__(self, thread_pool_name, thread_number=10):
"""thread_pool_name参数是要开启的threading.Thread子类,thrad_number当然是生成的线程数,也就是线程池中的线程数目。初始化时生成线程池"""
self.thread_pool_name = thread_pool_name
self.thread_number = thread_number
self.thread_list = [] #用于存放线程池中的线程,便于管理调用的操作,其实就是方便找到属于这个线程池的线程,对于本例,就是为了调用下面的off方法stop掉属于该线程池的线程
for i in range(self.thread_number):
self.thread_list.append(self.thread_pool_name())
def on(self): #启动线程池中的所有线程,线程开始工作
for i in self.thread_list:
i.start()
def off(self): #用于终止线程池内的所有线程
for i in self.thread_list:
i.stop()
class UrlRequestThread(threading.Thread):
"""用于请求网页的线程"""
def __init__(self):
super().__init__()
self.daemon = True #覆盖父类变量成员,设为True表示为守护线程,主线程结束自动终止此线程
self.stop_flag = False
def run(self):
while not self.stop_flag:
while not url_queue.empty():
url = url_queue.get()
try:
response = requests.get(url,headers=headers)
url_and_response_queue.put((url,response))
print("{}: 已完成请求{}".format(self.name,url))
except Exception as e: #网络不好出现ConnectionError或ProxyError不要怪程序
print("{}: 发生了一个已知异常{}: {}".format(self.name,str(e.__class__)[8:-2],e))
def stop(self):
self.stop_flag = True
class AnalysisThread(threading.Thread):
"""用于解析网页的线程"""
def __init__(self):
super().__init__()
self.daemon = True
self.stop_flag = False
def run(self):
global fpage_count,ppage_count,tpage_count,wpage_count,effective_wpage_count,effectless_wpage_count #导入全局变量,用于计数
while not self.stop_flag:
while not url_and_response_queue.empty():
url,response = url_and_response_queue.get()
if re.search("https://tieba\\.baidu\\.com/f.*",url): #判断这个url是否为目录页
fpage_count += 1 #抓取目录页计数+1
ppage_links = ["https://tieba.baidu.com"+i[6:-1]
for i in re.findall('href="/p/.*?"', response.text)] #解析提取出其中的帖子页url
for ppage_link in ppage_links:
url_queue.put(ppage_link) #将帖子页url推入url_queue队列
elif re.search("https://tieba\\.baidu\\.com/p/.*",url): #判断这个url是否为帖子页
ppage_count += 1 #抓取帖子页计数+1
pan_links = [i[0].replace("amp;","") for i in re.findall('(http://pan\\.baidu\\.com/s(/|(hare/link\?shareid=))[a-zA-Z0-9?&=;]*)', response.text)] #解析提取出其中的网盘页url
for pan_link in pan_links:
if pan_link not in pan_list: #过滤重复的网盘url
pan_list.append(pan_link)
url_queue.put(pan_link) #将不重复的网盘url推入url_queue队列
if not re.search("pn=", url): #目录页抓取线程解析出的帖子页地址中是没有"pn="的,即是第一次请求这个主题帖
tpage_count += 1 #抓取帖子页计数+1
try:
pn_count = int(re.findall('共(\\d*)页',response.text)[0]) #正则解析这个主题帖的页数
for i in range(2,min(pn_count,1000)):
url_queue.put(re.sub("\\?.*","?pn={}".format(i),url+"?")) #将这个主题帖其他页的连接推入url_queue队列
except IndexError: #有些帖子被删或被封,会出现贴吧404
pass
else: #这就是对网盘页的操作了
wpage_count += 1 #抓取网盘页计数+1
response.encoding = "utf-8" #指定为utf-8编码,防止读出的解析页面内容乱码
title = re.findall("(.*?) ",response.text)[0][1] #获取网页title标签内的内容
if title not in ["页面不存在","百度网盘-链接不存在","百度网盘 请输入提取密码"]:
pan_dictionary[url] = title #保存至字典
effective_wpage_count += 1 #有效网盘链接计数+1
else:
effectless_wpage_count += 1 #无效网盘链接计数+1
def stop(self):
self.stop_flag = True
class WriteThread(threading.Thread):
def __init__(self):
super().__init__()
self.daemon = True
self.stop_flag = False
def run(self):
finish_flag = False
pan_dictionary_copy = {}
while not self.stop_flag:
if self.stop_flag == "":
self.stop_flag = True
finish_flag = True
pan_dictionary_copy = pan_dictionary.copy() #拷贝一份pan_dictionary,防止在for迭代的过程中出现RuntimeError
with open("{}-{}.html".format(tieba_name,search_depth),'w', encoding='utf-8') as f: #文件的写操作,将一些信息写入html文件保存,通过format将统计变量一一对应
f.write("""\n\n\n\n{0}{1}-{2} \n\
\n\
\n\n百度贴吧网盘多线程爬虫
\n作者: {3}
贴吧名称: {1}吧
爬取深度: {2}
已爬取目录页数: {5}
\
已爬取主题帖数: {6}
已爬取帖子页数: {7}
已爬取网盘页数: {8}
总计爬取页面数: {9}
有效网盘链接数: {10}
失效网盘链接数: {11}
\
爬虫开始时间: {12}
爬虫已用时: {13}s
状态: {14}
\n"""
.format('\n' if not finish_flag else '', \
tieba_name, search_depth, ''.join([chr(i) for i in [30693, 20046, 64, 83, 105, 116, 110, 97, 108, 116, 97, 25552, 37266, 24744, 29420, 31435, 23436, 25104, 20316, 19994]])[:-9], "知乎@Sitnalta", \
fpage_count, tpage_count, ppage_count, wpage_count, fpage_count+ppage_count+wpage_count, effective_wpage_count, effectless_wpage_count, time.strftime("%Y-%m-%d %H:%M:%S", \
time.localtime(start_time)), int(time.time()-start_time),"正在爬取中..." if not finish_flag else "任务已完成!"))
for link,title in pan_dictionary_copy.items():
f.write("""{} \n""".format(link,title))
f.write("\n\n")
time.sleep(5) #5秒写一次,防止写操作频繁
def stop(self):
self.stop_flag = "" #确保当stop时还能写最后一次
if __name__ == "__main__":
if len(sys.argv) == 1:
tieba_name = ""
while not tieba_name:
tieba_name = input("请输入贴吧名:")
search_depth = input("请输入搜索深度(大于0的整数):")
while not search_depth.isdigit() or int(search_depth) <= 0:
search_depth = input("搜索深度值不合法,请输入合法的值(大于0的整数):")
search_depth = int(search_depth)
elif len(sys.argv) == 3 and sys.argv[2].isdigit():
tieba_name = sys.argv[1]
search_depth = int(sys.argv[2])
else:
print("程序参数错误!参数1应为贴吧名,参数2应为搜索深度(大于0的整数)。或不带其他参数直接运行此程序。")
sys.exit()
headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/62.0.3202.94 Safari/537.36"} #headers设置"User-Agent"用于伪装成普通的浏览器,其实百度贴吧反爬系统并不严格,不需要校验headers、也不需要模拟登陆和IP池
url_queue = Queue() #构建url队列,网页请求线程从这个队列取出url进行网页请求
url_and_response_queue = Queue() #构建url和其对应的response组成的元组的队列,网页解析线程从这个队列中读取url和response进行网页解析
fpage_count = 0 #用于统计抓取目录页的数目
tpage_count = 0 #用于统计抓取主题帖的数目
ppage_count = 0 #用于统计抓取帖子页的数目
wpage_count = 0 #用于统计抓取网盘页的数目
effective_wpage_count = 0 #用于统计有效的网盘链接的数目
effectless_wpage_count = 0 #用于统计失效或需要密码的网盘链接的数目
start_time = time.time() #用于记录爬虫开始时间
pan_list = [] #存储PpageThread线程池中线程爬取的网盘链接,用于判断是否有重复链接
pan_dictionary = {} #网盘字典,键为网盘链接,值为网盘链接页面的title内容
for i in range(0, search_depth, 50):
url_queue.put("https://tieba.baidu.com/f?kw={}&ie=utf-8&pn={}".format(tieba_name,i))
url_request_thread_pool = ThreadPoolController(UrlRequestThread,50) #生成50个用于页面请求任务的线程的线程池
analysis_thread = AnalysisThread() #网页解析线程
write_thread = WriteThread() #保存结果写入本地硬盘的线程
url_request_thread_pool.on() #50个网页请求线程全部start
analysis_thread.start()
write_thread.start()
while True:
time.sleep(5)
if url_queue.empty() and url_and_response_queue.empty():
break
time.sleep(5)
url_request_thread_pool.off()
analysis_thread.stop()
write_thread.stop()
write_thread.join() #让其写完最后一次
input("已运行完毕, 文件已保存在当前目录下的{}-{}.html文件中".format(tieba_name,search_depth))
这个多线程爬虫程序的大致功能是给出一个贴吧名和搜索深度(即要爬取的主题帖数目),它将爬取的结果保存到与程序相同目录下的html文件中,在爬取过程中可以即时从程序和html文件中看到爬虫进度(在爬虫进行时html文件每5秒会自动刷新更新一次,完成后不会自动刷新)。
爬虫启动有两种方式,第一种直接双击启动,第二种是命令行下传参启动(例如在程序目录下键入命令:python tieba_spider.py [贴吧名] [搜索深度]即可(例如:python tieba_spider.py ttf字体 1000)
建议从主函数__name__ == “__main__”开始看,注释虽然啰嗦,但比较详细。
效果图展示:
收工。