1 解析网页
1.1 网页加载
打开道客巴巴官网后,我们选择一篇文章《“多元互动对话式”劳动技术课教学策略.doc》,通过阅览文章的过程我们可以发现,进入文章预览页面后,网页只会加载文章的前5页内容,如果文章内容页数较多超过了5页,剩下的内容就需要通过点击“继续阅读”来预览文章剩下页数的内容,并且文章内容加载采用了懒加载的方式,在页面滚动到文章指定页数的时候,才会加载后续页数的内容,这也是前端为优化页面加载速度而常用的操作。
1.2 网络请求
通过以上解析,其实就可推断网页的数据是动态加载。通过浏览器抓包工具获取网络请求,可以看到一组有getebt开头的网络请求,其请求了后缀为.ebt的文件数据,这种文件并不常见,有可能是加密过的自定义格式的文件。另外,6个请求地址没有规律可循,在循环抓取环节,这也是一个需要考虑解决的问题。因为是二进制数据,预览也只能看到一堆乱码,无法提取有价值的信息。
1.3 网页源码
看完请求信息,我们再来看看网页的源代码,从中可看到文章中的每一页的可预览内容都是通过canvas标签绘制出的图案。
canvas是html中的画布元素,它没有自己的行为,但是定义了一个 API 支持脚本化绘图操作,让我们可以使用javascript代码来绘制出所需图案。如我们所熟知的echarts数据可视化图表库中的各种图表就是通过canvas绘制出来的,并且图表库中的图案可以以图片的格式保存下来。
1.4 小结
- 根据浏览器的抓包工具获取到动态数据来看,这可能是是一组加密过的数据,先不考虑其是否能解密成功和解密过程所耗费的时间,因为文档的预览是canvas绘制的图案,所以解密出来的数据也有可能只是一些canvas的绘制参数,可能还会需要将数据进一步转化才能得到我们直接在网页上预览到的效果,所以直接请求数据再解密这一方法,无论从时间和结果上来看,都不太理想。
- 我们通过echarts数据可视化图表库中的图表实例得知,canvas绘制的图案是可以保存为图片的,那道客巴巴文章的预览内容既然是通过canvas绘制的,自然也可以将其保存为图片。因为文章预览数据是动态加载的,从而无法通过直接请求网页源代码的方式获取数据,所以最终决定使用selenium控制浏览器行为的方式对道客巴巴文档进行爬取。
2 编写代码
2.1 环境配置
名称建议版本python3.9selenium4.23.0
2.2 进入网页
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
# 浏览器驱动配置
options = Options()# 程序结束时,保持浏览器窗口开启状态
options.add_experimental_option('detach',True)# 浏览器驱动实例
driver = webdriver.Chrome(options=options)# 进入网页
driver.get('https://www.doc88.com/p-6923896489622.html')
2.3 继续阅读
根据1.1的网页加载分析,在文章页数大于5时,需要点击“继续阅读”开加载预览剩下的页数。我们利用selenium进入对应文章网页后,通过id获取“继续阅读”按钮的元素对象,并模拟点击,加载剩余数据。
from selenium.webdriver.common.by import By
from selenium.common import NoSuchElementException
# 模拟点击“继续阅读”按钮try:
continueButton = driver.find_element(By.ID,'continueButton')
continueButton.click()except NoSuchElementException:pass
2.4 懒加载问题
根据1.1的网页加载分析,网页数据使用了懒加载,文章每一页需要都滚动到指定位置才会加载出来。针对这种情况,我们往往会使用javascript代码来控制页面进行上下滚动,来触发数据的加载。
// javascript 代码控制页面上下滚动const speed =50// 滚动速度(越大越快)
window.scrollTo(0, document.body.scrollHeight)let top = document.documentElement.scrollTop || document.body.scrollTop;const timeTop =setInterval(()=>{
document.body.scrollTop = document.documentElement.scrollTop = top -= speed;if(top <=0){clearInterval(timeTop);}});
但是通过多次测试会发现,仅通过上下滚动来触发数据加载的方法存在明显的问题:
- 控制页面滚动的速度太快的话,有些页面并不会触发加载,并且这些页面还是不确定的,如果无法定位并且移动到未加载的页面,页面就一直不会加载。
- 循环滚动多次或将滚动速度调慢不仅会影响爬虫的效率,而且仍旧可能出现第一种问题。
综合多方面因素考虑,我最后决定先使用JavaScript代码定位每一页的元素并跳转到指定页面的位置,然后判断页面数据是否加载,未加载则等待加载,加载完成后则返回对象信息。
首先,因为文章每页的元素id=page_页数,所以我们需要获取到文章的总页数。如下图所示,总页数在头元素中已经给出,我们通过xpath就可顺利获取。
通过以下JavaScript代码,我们可以滚动到指定元素的位置。
// JavaScript代码滚动到指定元素位置// 如果网络不好,可能会报错:Cannot read properties of null (reading 'scrollIntoView')// 建议按需求添加延迟代码,等待元素加载
document.getElementById("page_5").scrollIntoView()
如果网络不好,不建议使用javascript代码跳转到指定位置,但可以使用selenium模拟网站的翻页行为以达到同样的效果。
from selenium.webdriver import Keys
# 获取页数跳转输入框对象
pageNumInput = driver.find_element(By.ID,'pageNumInput')# 全选输入框内容
pageNumInput.send_keys(Keys.CONTROL,'a')# 输入页数
pageNumInput.send_keys(str(5))# 回车
pageNumInput.send_keys(Keys.ENTER)
跳转到指定页面位置后,我们需要等待页面数据的加载,如何判断数据是否加载成功大家可以发挥想象,我采用的方法是通过加载前后canvas标签元素属性的变化来判断的。通过下面给出的html代码观察就可以发现加载前后canvas标签元素属性是不同的,加载后canvas标签上会多出4个属性值,分别是fs、lz、width和height。width和height是canvas标签的内置属性,可直接排除,剩下fs和lz可以任意使用。
<!-- 加载前 --><canvasclass="inner_pkage"zoom="1"ls="0"ss="0"id="page_1"></canvas><!-- 加载后 --><canvasclass="inner_page"zoom="1"ls="1"ss="0"id="page_1"fs="0"lz="1"width="1212"height="682"></canvas>
有了上述准备后,我们就可以开始整合代码,用于解决懒加载问题了。
import time
from selenium import webdriver
from selenium.webdriver import Keys
from selenium.webdriver.common.by import By
from selenium.common import NoSuchElementException
from selenium.webdriver.chrome.options import Options
# 浏览器驱动配置
options = Options()# 程序结束时,保持浏览器窗口开启状态
options.add_experimental_option('detach',True)# 浏览器驱动实例
driver = webdriver.Chrome(options=options)# 进入网页
driver.get('https://www.doc88.com/p-6923896489622.html')# 模拟点击“继续阅读”按钮try:
continueButton = driver.find_element(By.ID,'continueButton')
continueButton.click()except NoSuchElementException:pass# 总页数
page_xpath ='//meta[@property="og:document:page"]'
page =int(driver.find_element(By.XPATH, page_xpath).get_attribute("content"))# 页数跳转输入框对象
pageNumInput = driver.find_element(By.ID,'pageNumInput')# 循环抓取for num inrange(1, page +1):# 页面元素id
canvas_id =f"page_{num}"# JavaScript代码滚动到指定元素位置# 如果网络不好,可能会报错:Cannot read properties of null(reading 'scrollIntoView')# 建议按需求添加延迟代码,等待元素加载
driver.execute_script(f'document.getElementById("{canvas_id}").scrollIntoView()')# 通过selenium模拟控制翻页滚动到指定元素位置(更稳定,建议使用)# pageNumInput.send_keys(Keys.CONTROL, 'a')# pageNumInput.send_keys(str(num))# pageNumInput.send_keys(Keys.ENTER)whileTrue:# 通过canvas标签上的lz属性判断数据是否加载完成
canvas = driver.find_element(By.ID, canvas_id)
lz = canvas.get_attribute("lz")if lz isnotNone:# 数据加载完成,获取元素对象print(f'成功获取{canvas_id}:', canvas)# 跳出循环break# 数据未加载,等待1s后继续循环判断print(f'{canvas_id}尚未加载,正在重新获取...')
time.sleep(1)
2.4 图片下载
在1.4中提到,canvas绘制的图案可转为图片,具体的方法步骤就是将canvas画布数据转base64字符串,再将base64解析为二进制数据后写入图片格式的文件中,最后得到图片。canvas元素对象内部提供了方法,通过javascript代码可以直接获取base64字符串。
// 通过javascript代码获取对应canvas的base64数据
document.getElementById("page_1").toDataURL()
2.5 完整代码
最后通过python将获取到的base64字符串解析为二进制并保存为图片,至此已完成爬取道客巴巴文档的所有代码。
import time
import base64
from PIL import Image
from io import BytesIO
from selenium import webdriver
from selenium.webdriver import Keys
from selenium.webdriver.common.by import By
from selenium.common import NoSuchElementException
from selenium.webdriver.chrome.options import Options
# 浏览器驱动配置
options = Options()# 程序结束时,保持浏览器窗口开启状态
options.add_experimental_option('detach',True)# 浏览器驱动实例
driver = webdriver.Chrome(options=options)# 进入网页
driver.get('https://www.doc88.com/p-6923896489622.html')# 模拟点击“继续阅读”按钮try:
continueButton = driver.find_element(By.ID,'continueButton')
continueButton.click()except NoSuchElementException:pass# 总页数
page_xpath ='//meta[@property="og:document:page"]'
page =int(driver.find_element(By.XPATH, page_xpath).get_attribute("content"))# 页数跳转输入框对象
pageNumInput = driver.find_element(By.ID,'pageNumInput')# 循环抓取for num inrange(1, page +1):# 页面元素id
canvas_id =f"page_{num}"# JavaScript代码滚动到指定元素位置# 如果网络不好,可能会报错:Cannot read properties of null(reading 'scrollIntoView')# 建议按需求添加延迟代码,等待元素加载# driver.execute_script(f'document.getElementById("{canvas_id}").scrollIntoView()')# 通过selenium模拟控制翻页(更稳定,建议使用)
pageNumInput.send_keys(Keys.CONTROL,'a')
pageNumInput.send_keys(str(num))
pageNumInput.send_keys(Keys.ENTER)whileTrue:# 通过canvas标签上的lz属性判断数据是否加载完成
canvas = driver.find_element(By.ID, canvas_id)
lz = canvas.get_attribute("lz")if lz isnotNone:# 数据加载完成,获取元素对象
script =f'return document.getElementById("{canvas_id}").toDataURL()'
base64_str = driver.execute_script(script)# base64转图片
head, body = base64_str.split(",")
binary = base64.b64decode(body)
image = Image.open(BytesIO(binary))
image.save(f'{canvas_id}.png')print(f'图片{canvas_id},下载成功!')# 跳出循环break# 数据未加载,等待1s后继续循环判断print(f'{canvas_id}尚未加载,正在重新获取...')
time.sleep(1)
2.6 封装代码
import time
import base64
from PIL import Image
from io import BytesIO
from selenium import webdriver
from selenium.webdriver import Keys
from selenium.webdriver.common.by import By
from selenium.common import NoSuchElementException
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.chrome.webdriver import WebDriver
classDoc88Spider(object):def__init__(self, url:str, timeout:int=1, reconnect:int=10, save_image:bool=True,
driver: WebDriver =None, headless:bool=True)->None:"""初始化
:param url: 目标网址(必填)
:param headless: 是否隐藏浏览器窗口(默认隐藏)
:param timeout: 重连时间间隔(默认1s)
:param reconnect: 失败重连次数(默认10次)
:param save_image: 是否保存图片(默认保存)
:param driver: 指定浏览器驱动(默认使用Chrome)
"""
self.url = url
self.headless = headless
self.timeout = timeout
self.reconnect = reconnect
self.save_image = save_image
self.driver =(driver, self.init_web_driver())[driver isNone]definit_web_driver(self)-> WebDriver:"""初始化浏览器驱动
:return 返回浏览器驱动实例
"""# 浏览器驱动配置
options = Options()if self.headless:# 隐藏浏览器
options.add_argument('--headless')# 浏览器驱动实例
driver = webdriver.Chrome(options=options)return driver
defget_web_obj(self)->dict:"""获取网页对象
:return 返回网页元素对象字典
"""# 打开网站
self.driver.get(self.url)# 继续阅读按钮try:
continueButton = self.driver.find_element(By.ID,'continueButton')
continueButton.click()except NoSuchElementException:pass# 页数跳转输入框
pageNumInput = self.driver.find_element(By.ID,'pageNumInput')# 总页数
page_xpath ='//meta[@property="og:document:page"]'
page =int(self.driver.find_element(By.XPATH, page_xpath).get_attribute("content"))return{"page": page,"pageNumInput": pageNumInput
}defrun_spider(self)->None:"""启动爬虫"""print("爬虫程序正在启动,请稍等...")
web_obj = self.get_web_obj()
page = web_obj['page']
pageNumInput = web_obj['pageNumInput']# 循环爬取图片for num inrange(1, page +1):# 输入页数跳转至对应图片位置
pageNumInput.send_keys(Keys.CONTROL,'a')
pageNumInput.send_keys(str(num))
pageNumInput.send_keys(Keys.ENTER)# canvas元素id
canvas_id =f"page_{num}"# 重连次数
n =0while n < self.reconnect:# 获取图片加载状态
lz = self.driver.find_element(By.ID, canvas_id).get_attribute("lz")if lz isnotNone:# 通过js代码,将canvas画布转为base64
script =f'return document.getElementById("{canvas_id}").toDataURL()'
base64_str = self.driver.execute_script(script)if self.save_image:# 下载图片
self.download_image(base64_str, canvas_id)else:print(f"已获取到数据{canvas_id}")break
n +=1# 数据未加载,等待1s后继续循环判断print(f"{canvas_id}尚未加载,正在重新获取({n})")
time.sleep(self.timeout)@staticmethoddefdownload_image(base64_str:str, filename:str=None):"""将base64保存图片
:param base64_str: base64字符串
:param filename: 文件名
"""if filename isNone:
filename =int(time.time())
head, body = base64_str.split(",")
binary = base64.b64decode(body)
image = Image.open(BytesIO(binary))
image.save(f'{filename}.png')print(f'图片{filename},下载成功!')if __name__ =='__main__':# url – 目标网址(必填)# timeout – 重连时间间隔(默认1s)# reconnect – 失败重连次数(默认10次)# save_image – 是否保存图片(默认保存)# headless – 是否隐藏浏览器窗口(默认隐藏)
doc88Spider = Doc88Spider(
url="https://www.doc88.com/p-9019970307413.html",
timeout=1,
reconnect=10,
save_image=True,
headless=True,)
doc88Spider.run_spider()
3 结语
本文以我的视角出发,从网页解析到完成代码,整个过程都进行了详细的分析与解读,希望对各位读者有所帮助。
版权归原作者 不是捉弄 所有, 如有侵权,请联系我们删除。