爬虫的目标
- 我的目的是爬取某直播网站某一天所有频道的网络流量数据,每一个频道有一个自己的页面,显示可视化后的流量数据,而要获取这个频道页面的url,需要先访问另一个列表页面,每一个列表页面包含了10个频道的属性信息(如channelid, starttime, endtime)。因此,爬虫过程整体分两步:
- 访问列表页面,通过指定日期和页数设置http请求的params。获取返回的页面内容后,保存到本地文件。
- 读取本地文件中保存的频道属性信息,作为访问频道页面请求的params,访问频道页面,获取返回的内容(即频道的流量数据)后,保存到本地文件
第一步:爬取列表页面
浏览器访问列表页面时怎么做的?
- 通过chrome浏览器的DevTools(快捷键F12)中对访问列表页面的网络监控,可以知道在最终成功访问到目标页面之前,实际上先跳转到了4个别的页面(xxpreLogin, xxcaLogin, xxLogin, sendxxToken)进行登陆,最后才返回我要的访问列表的内容。以上是我在打开浏览器第一次访问该页面的过程,但后来在页面内点击转到别的页数时,只有单纯的一条访问请求了。为什么之后不用登陆认证呢?因为后来的请求中带有第一次认证后得到的cookie,服务器
确认过眼神验证了cookie后,就同意给出所请求的页面数据。
我用什么方法能到达列表页面?
第一个方法:模拟整个访问列表的步骤,包括登陆认证等步骤,获取最终访问列表页面时所需的信息(如cookie, token)后再访问。这是最符合浏览器思路的一个方法,但登陆步骤繁多;对于没有详细学习过HTML和JS的我来说,照猫画虎地发送请求容易,但理解对方发来的response就比较难了。另外,这4个别的页面有hppts请求,涉及到数据的加密,因此要考虑的因素很多。
所以我换了一个 更粗暴直接的方法:直接查看访问列表页面的最后一步带了哪些cookie,如果我带上同样的cookie,访问同样的url,肯定也能访问成功。此处确定了我要从浏览器监控页面复制的信息:url(带params),cookie。把复制的cookie保存到本地文件里,我的程序访问时从文件中读取cookie并添加到请求的headers中,把url中的params分离出来重新构造同格式但不同值的params,baseurl/params/headers准备完毕后就可以发送请求了。
-
写http请求时需要明确的三个基本内容:
- 访问该页面时实际请求的url是什么(浏览器地址栏的url通常不是请求数据所用的实际url)
- 访问该页面需要的请求params是什么(比如请求第几页)
- 访问该页面的headers要有什么(比如最重要的cookie)
-
这里我使用了基本的requests库,代码如下:
from urllib import parse from urllib.parse import urlsplit, parse_qs import requests # 读取cookie cookie_filename = 'cookies.txt' cookiefile = open(cookie_filename, 'r') cookies = {} for line in cookiefile.read().split(';'): name, value = line.strip().split('=',1) cookies[name] = value cookiefile.close() # 访问列表的start_page到end_page页 url = 'http://xxxx.xxxxxx.com/xxx/xxxx.do?' \ 'startTime=xxxx&endTime=xxxx¤tPage=xxxx&pt=xxxx' start_page, end_page = 1, 1000 for page in range(start_page, end_page): baseurl = parse.splitquery(url)[0] params = parse_qs(urlsplit(url).query) params['currentPage'] = page params['startTime'] = '2018-01-01 00:00:00' params['endTime'] = '2018-01-01 23:59:59' response = requests.get(baseurl, params=params, cookies=cookies) content = response.content.decode() pagefile = 'page' + str(page) + '.txt' with open(pagefile, 'w', encoding='utf-8') as pfile: pfile.write(content)
像上面说的一样,先复制下来url到代码中,再复制cookies到文件中,这个代码就能成功运行了。 BUT,好景不长,这段代码在成功运行了大约不到十分钟后,突然取不到数据而是取回了像初次访问一样的登陆认证页面。经过一番分析,我发现目标页面的url中有一个param实际是跟时间有关的(指上面代码中的pt),cookies中也有类似服务器端的心跳时间cookie,多半是服务器看我带的这个心跳cookie太老了不在它可以回答的时间区间内,所以返回让我重新认证。另外,浏览器每次发出请求(哪怕是请求同样的页面)时有一个cookie值都会随机改变,目前分析应该是和证书有关。所以,虽然用同样的一对pt和cookie大约能持续几分钟的成功访问,但如果要在崩了以后还能继续访问,只是更新pt到最新时间经尝试是不行的。好在我要访问的列表页面总页数不是很多,所以我将代码稍加修改,记录每次崩时访问到哪一页了,然后用浏览器再访问一次,复制新的url和cookies,从断掉的地方接着跑。
-
改动后的代码如下:
from urllib import parse from urllib.parse import urlsplit, parse_qs import requests # 读取cookie cookie_filename = 'cookies.txt' cookiefile = open(cookie_filename, 'r') cookies = {} for line in cookiefile.read().split(';'): name, value = line.strip().split('=',1) cookies[name] = value cookiefile.close() # 访问列表的start_page到end_page页 url = 'http://xxxx.xxxxxx.com/xxx/xxxx.do?' \ 'startTime=xxxx&endTime=xxxx¤tPage=xxxx&pt=xxxx' start_page, end_page = 1, 1000 for page in range(start_page, end_page): baseurl = parse.splitquery(url)[0] params = parse_qs(urlsplit(url).query) params['currentPage'] = page params['startTime'] = '2018-01-01 00:00:00' params['endTime'] = '2018-01-01 23:59:59' response = requests.get(baseurl, params=params, cookies=cookies) # 如果返回的是登陆认证页面的地址,记录访问到哪一页了,退出循环 resurl = parse.splitquery(response.url)[0] if resurl == 'https://xxxLogin.xxxx.xxx': print('Failed at page '+str(page)+', please restart.') break # 成功访问时,保存数据 content = response.content.decode() pagefile = 'page' + str(page) + '.txt' with open(pagefile, 'w', encoding='utf-8') as pfile: pfile.write(content)
到此可以爬下来所有列表页面的内容了,很明显,每十分钟人工刷新浏览器再复制粘贴一次是非常耗时的,这样的代码在后面爬取每个频道的流量信息时是不可取的(加载一页列表大约1-3秒,而加载一页频道流量信息可能要10+秒),不过对于非时间敏感型的请求来说,上面的代码是足够用了。
本着能少一次复制就少一次的原则,我希望每次只复制url,而cookie的信息肯定能从浏览器(这里用的chrome)的本地缓存文件中读,就不需要我花费宝贵的时间复制到我准备的文件里了。因此我查了Chrome浏览器在系统中保存cookie的位置,修改代码,使 每次开始爬取时直接读取浏览器的cookie。
-
改动后的代码如下:
from urllib import parse from urllib.parse import urlsplit, parse_qs import requests import sqlite3 from win32crypt import CryptUnprotectData # 从Chrome的cookie文件读取cookie cookie_path = r'C:\Users\ann\AppData\Local\Google\Chrome\User Data\Default\Cookies' host = '.xxx.com' sql = 'select host_key, name, encrypted_value from cookies '\ 'where host_key = \'' + host + '\'' with sqlite3.connect(cookie_path) as conn: cu = conn.cursor() cookies = {name:CryptUnprotectData(encrypted_value)[1].decode() '\ 'for host_key, name, encrypted_value in cu,execute(sql).fetchall()} # 访问列表的start_page到end_page页 url = 'http://xxxx.xxxxxx.com/xxx/xxxx.do?' \ 'startTime=xxxx&endTime=xxxx¤tPage=xxxx&pt=xxxx' start_page, end_page = 1, 1000 for page in range(start_page, end_page): baseurl = parse.splitquery(url)[0] params = parse_qs(urlsplit(url).query) params['currentPage'] = page params['startTime'] = '2018-01-01 00:00:00' params['endTime'] = '2018-01-01 23:59:59' response = requests.get(baseurl, params=params, cookies=cookies) # 如果返回的是登陆认证页面的地址,记录访问到哪一页了,退出循环 resurl = parse.splitquery(response.url)[0] if resurl == 'https://xxxLogin.xxxx.xxx': print('Failed at page '+str(page)+', please restart.') break # 成功访问时,保存数据 content = response.content.decode() pagefile = 'page' + str(page) + '.txt' with open(pagefile, 'w', encoding='utf-8') as pfile: pfile.write(content)
至此,每当访问崩了,我还是得手动用浏览器访问一次,复制url,从断掉的地方继续跑。显然,即使不用复制cookies了,这个代码还是不能满足爬取频道流量的大量数据的要求。
因此,爬取频道流量信息时我用了另一种思路进行访问,不用cookie信息,解放了双手,实现了全自动爬取。不过,要访问频道流量信息页面,还得先知道频道的一些属性(比如channelId, startTime, endTime),把属性写进params,才能获得正确的频道页面,因此,下一步我们先从爬下来的列表页面中提取频道属性信息。
第二步:从列表页面文件获取频道属性信息
- 从浏览器访问的列表页面来看,一页列表有10条频道属性信息,因此我到保存的列表页面文件中观察(文件里保存的直接是每个频道的数据,不是这一页的html代码),每条频道信息开头都有'"channelhead"'字样(没错带双引号,这里channelhead不是真实信息只是我举个例子),每条频道的属性包括type, channelId, startTime, endTime等。这里我只需要type=1的频道的信息,因此读文件时需要加判断条件。
- 这里我结合了python的find函数和简单的正则表达式进行提取,代码如下:
import re start_page, end_page = 1, 1000 query_str = ['channelId startTime endTime'] for page in range(start_page, end_page): # 读出第page页的列表页面文件 pagefile = 'page' + str(page) + '.txt' pfile = open(pagefile, 'r', encoding='utf-8') data = pfile.read() pfile.close() # 从列表页面数据有选择地提取频道属性 query_count = data.count('"allowVideo"') index1 = data.find('"allowVideo"') for i in range(query_count-1): # 找到一条频道信息的区间[index1, index2] index2 = data.find('"allowVideo"', index1+1) query_data = data[index1:index2] if query_data.find('"type":"1"') > 0: # channelId:先用find找到channelid位置,再用正则匹配提取区间内的数字 index_chid = query_data.find('channelId') chid = re.sub(r'\D', '', query_data[index_chid, index_chid+30]) # startTime index_st = query_data.find('startTime') st = query_data[index_st+12, index_st+19] # endTime index_et = query_data.find('endTime') et = query_data[index_et+10, index_et+19] str = chid + ' ' + st + ' ' + et query_str.append(str) index1 = index2 # 把所有的query存入另一个文件 # (实际上我得到的query的属性远不止这些,因此是分很多文件存的, # 由于文件读写操作不是此次爬虫的重点,所以这里不细讲) query_filename = 'query.txt' with open(query_filename, 'w', encoding='utf-8') as qfile: for item in query_str: qfile.write(item + '\n')
- 到此我提取了所有需要的频道属性的信息,只待拿着这些信息去请求频道网络流量页面了
第三步:获取每个频道网络流量页面
第一步的方法,虽然能绕过登陆认证的步骤,也能自动从Chrome的本地cookie文件中提取我要的cookie,但每隔几分钟我还是得手动刷新浏览器并复制其url粘贴到代码中,整个操作非常笨拙耗时效率低下。于是我又换了一个思路:有什么方法能 直接调用Chrome浏览器来访问 吗?我不想知道它访问的细节,只要给我返回的最终结果就好。于是我发现了 selenium库的webdriver 方法。
调用webdriver.Chrome()会生成一个浏览器对象,浏览器对象的.get(url)方法就能使浏览器自己带上合适的cookie并返回相应页面,浏览器的.page_source属性就是页面的内容啦。
-
使用selenium库的webdriver调用Chrome浏览器访问频道页面并存入文件的代码如下:
from selenium import webdriver import time form urllib import parse import os baseurl = 'http://xxx.xxxx.xxx/xxx.do' # query文件的前缀,每个query文件都有多条channel的属性信息 query_fileadd = r'D:\xxx\query' # 每个query文件对应一个装channelpage的文件夹 channelpage_fileadd = r'D:\xxx\ChannelPages\Query' start, end = 1, 10 # 初始化一个Chrome的driver browser = webdriver.Chrome() for i in range(start, end+1): # 读取第i个query文件的所有queries query_filename = query_fileadd + str(i) + '.txt' qfile = open(query_filename, 'r') query_list = [] for line in qfile: query_list.append(line) qfile.close() # 确保创建了channelpage的路径 cpdir = os.path.join(channelpage_fileadd, str(i)) if not os.path.exists(cpdir): os.mkdir(cpdir) # 根据query_list发送http请求获取channel page for i in range(len(query_list)) # url的拼接 q = query_list[i].split(' ') chid, st, et = q[0], q[1], q[2] pt = str(int(time.time())) params = dict(startTime=st, endTime=et, channelId=chid, pt=pt) url = baseurl + '?' + parse.urlencode(query=params) # 获取channel page的数据 browser.get(url) channelpagedata = browser.page_source # 写入channel文件 cpfname = os.path.join(cpdir, 'channel'+str(i)+'.txt') with open(cpfname, 'w', encoding='utf-8') as cf: cf.write(channelpagedata) browser.quit()
- 上面的代码实现了一键访问下载频道页面的目标,解放了双手,非常开心!然而过了几分钟之后,我眉头一皱,发现 事情并没有这么简单。
- 之前提到过,浏览器访问一个频道页面大约要10+秒,导致上面调用浏览器的代码下载非常龟速,那么如何能让下载速度提高呢? 多线程。这里我需要的数据没有下载先后的要求,存数据的文件也是每个频道有独立的文件,所以甚至连锁也用不上。
- 每个线程负责的任务:这里我有多个query的文件,每个文件有多条channel的属性,每条channel属性对应访问一次channel页面。假设query文件一共50个,每个文件有1000条属性,简单来分配的话,每条线程负责10个query文件共5条线程就行(如果内存容量比较大,那可以更多条,速度会相对更快)。
- 这里多线程我使用的 threading 库,多线程访问并存储的代码如下:
from selenium import webdriver from urllib import parse import os import threading def getChannelPages(startpage, endpage, query_fileadd, channelpage_fileadd, baseurl, params): browser = webdriver.Chrome() for i in range(startpage, endpage, 100): # 读取文件的query存入query_list qfname = query_fileadd + str(i) + '.txt' qfile = open(qfname) query_list = [] for line in qfile: query_list.append(line) qfile.close() # 确保创建了存channel page的路径 cpdir = os.path.join(channelpage_fileadd, str(i)) if not os.path.exists(cpdir): os.mkdir(cpdir) # 根据query_list发送http请求获取channel page for index in range(len(query_list)): # url的拼接 query = query_list[index].split(' ') chid, st, et = query[0], query[1], query[2] pt = str(int(time.time())) params['startTime'] = st params['endTime'] = et params['channelId'] = chid url = baseurl + '?' + parse.urlencode(query=params) # 获取channel page数据 browser.get(url) channelpagedata = browser.page_source # 写入channel文件 cpfadd = os.path.join(cpdir, 'channel' + str(index) + '.txt') with open(cpfadd, 'w', encoding='utf-8') as cf: cf.write(channelpagedata) browser.quit() if __name__ == '__main__': query_fileadd = r'D:\xxx\query' channelpage_fileadd = r'D\xxx\ChannelPages' # channel页面参数设置: baseurl, params baseurl = 'http://xxxx.xxxx.xxx/xxx.do' params = dict(startTime='',endTime='',channelId='') threads = [] # 初始化各线程 startpage, endpage = 1, 1000 for i in range(startpage, endpage, 200): t = threading.Thread(target=getChannelPages, args=(i, i+199, query_fileadd, baseurl, params)) threads.append(t) # 开始表演 for t in threads: t.start() for t in threads: t.join()
- 目前代码实现了多线程爬虫的功能,大幅提高了爬虫的速度,不过还有很多可以优化的部分(比如换一种线程负责的功能,或者改成多进程,还有异步,除了线程以外,对频道属性的提取可以用更高效的正则表达式而不是find函数)。
完整代码
- 下面是爬取列表页,提取query,爬取频道页的完整代码,其中爬取列表页也改成多线程方式了。
from selenium import webdriver from urllib import parse import time import re import os import threading def getHistoryPages(startpage, endpage, listpage_fileadd, baseurl, params): # 访问第startpage页到第endpage页的列表页面,每100页存入一个文件 browser = webdriver.Chrome() for bigpage in range(startpage, endpage, 100): if bigpage+99 > endpage: end = endpage else: end = bigpage + 99 page_data = [] for page in range(bigpage, end+1): params['currentPage'] = str(page) params['pt'] = str(int(time.time)) url = baseurl + '?' + parse.urlencode(query=params) browser.get(url) page_data.append(browser.page_source) listpage_filename = listpage_fileadd + str(bigpage) + '-' + str(bigpage+99) + '.txt' lfile = open(listpage_filename, 'w', encoding='utf-8') for item in page_data: lfile.write(item+'\n') lfile.close() del page_data browser.quit() def extractQueries(listpage_filename, query_filename): # 把给定文件里的page data转换成query query_str = ['channelId startTime endTime'] file = open(listpage_filename, 'r', encoding='utf-8') data = file.read() file.close() query_count = data.count('"allowVideo"') index1 = data.find('"allowVideo"') for i in range(query_count - 1): index2 = data.find('"allowVideo"', index1 + 1) query_data = data[index1:index2] if query_data.find('"type":"1"'): index_chid = query_data.find('channelId') chid = re.sub(r'\D', '', query_data[index_chid, index_chid+30]) index_st = query_data.find('startTime') st = query_data[index_st+12, index_st+19] index_et = query_data.find('endTime') et = query_data[index_et+10, index_et+19] str = chid + ' ' + st + ' ' + et query_str.append(str) index1 = index2 # 把query_str写入文件 qfile = open(query_filename, 'w', encoding='utf-8') for item in query_str: qfile.write(item+'\n') def getChannelPages(startpage, endpage, query_fileadd, channelpage_fileadd, baseurl, params): browser = webdriver.Chrome() for i in range(startpage, endpage, 100): # 读取文件的query存入query_list qfname = query_fileadd + str(i) + '-' + str(i+99) '.txt' qfile = open(qfname) query_list = [] for line in qfile: query_list.append(line) qfile.close() # 确保创建了存channel page的路径 cpdir = os.path.join(channelpage_fileadd, str(i) + '-' + str(i+99)) if not os.path.exists(cpdir): os.mkdir(cpdir) # 根据query_list发送http请求获取channel page for index in range(len(query_list)): # url的拼接 query = query_list[index].split(' ') chid, st, et = query[0], query[1], query[2] pt = str(int(time.time())) params['startTime'] = st params['endTime'] = et params['channelId'] = chid url = baseurl + '?' + parse.urlencode(query=params) # 获取channel page数据 browser.get(url) channelpagedata = browser.page_source # 写入channel文件 cpfadd = os.path.join(cpdir, 'channel' + str(index) + '.txt') with open(cpfadd, 'w', encoding='utf-8') as cf: cf.write(channelpagedata) browser.quit() if __name__ == '__main__': listpage_fileadd = r'D:\xxx\xxx\page' query_fileadd = r'D:\xxx\query' channelpage_fileadd = r'D\xxx\ChannelPages' # 列表页面参数设置: baseurl, params baseurl = 'http://xxxx.xxxx.xxx/xxx.do' params = dict(startTime='2018-01-01 00:00:00', endTime='2018-01-01 00:00:00') startpage, endpage = 1, 1000 threads = [] for page in range(startpage, endpage, 200): t = threading.Thread(target=getHistoryPages, args=(page, page+199, listpage_fileadd, baseurl, params)) threads.append(t) for t in threads: t.start() for t in threads: t.join() # 从channel list文件中提取channel的属性 startpage, endpage = 1, 1000 for listpage in range(startpage, endpage, 100): listpage_filename = listpage_fileadd + str(listpage) + '-' + str(listpage+99) + '.txt' query_filename = query_fileadd + str(listpage) + '-' + str(listpage+99) + '.txt' extractQueries(listpage_filename, query_filename) # channel页面参数设置: baseurl, params baseurl = 'http://xxxx.xxxx.xxx/xxx.do' params = dict(startTime='',endTime='',channelId='') threads = [] startpage, endpage = 1, 1000 for i in range(startpage, endpage, 200): t = threading.Thread(target=getChannelPages, args=(i, i+199, query_fileadd, baseurl, params)) threads.append(t) for t in threads: t.start() for t in threads: t.join()
后续
- 爬下来这么多数据,肯定不能存在同一个电脑里,因此之后还经历了设置ftp server等步骤对爬下来的数据进行转移,这里不细讲了,网上有很多教程。