爬虫实战:批量爬取三维模型数据集
0x00 Abstract
因为实验需要,从 零件库网页版 批量爬取数据集。由于是动态网页,难度比爬取 HTML 中的文字信息大了很多。经过查询资料,决定使用 Selenium 来做这部分的工作。但落地过程中其实涉及许多文档之外的实际问题,也是花了蛮多时间才基本实现爬取需求,稍微记录一下。仅面向实现需求记录,一些 Selenium 的基本操作不多解释,有需要的话可以在 官方文档 学习。整理的可用代码在最后。
这篇文章涉及以下知识点: - Selenium 爬取动态页面 - Selenium 对网页元素的定位与查找 - Selenium 处理 iframe(比如登录框) - Selenium 动作链 - Selenium 显性等待(处理动态页面加载)
0x01 需求
由于这个网站的模型是下面这种动态选择参数,然后下载文件的构造,但是纯手工一个一个选择然后下载的工作太繁琐了。所以希望实现对一个页面中的所有参数型号的零件批量下载。
0x02 解决方案
通过 Selenium 模拟鼠标点击,从而实现批量下载一个页面下所有参数的零件。
使用 Selenium 控制的浏览器打开网页
from selenium import webdriver
# 导入所需模块
import time
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver import ActionChains
options = webdriver.ChromeOptions()
options.add_experimental_option("excludeSwitches", ["enable-automation"])
options.add_experimental_option('useAutomationExtension', False)
# 获取浏览器对象
driver = webdriver.Chrome()
# 设置显性等待
wait= WebDriverWait(driver, 20)
# 设置浏览器窗口大小
driver.maximize_window()
# 隐藏浏览器特征
driver.execute_cdp_cmd("Page.addScriptToEvaluateOnNewDocument", {
"source": """
Object.defineProperty(navigator, 'webdriver', {
get: () => undefined
})
"""
})
url = "http://web.3dsource.cn/2012110910/product_181.html"
driver.get(url)
其中设置了一些反反爬的东西。最大化窗口防止有些元素在小窗口里位置不对,或者加载不出来。
登录网站
可能因为这种方式打开网页没有 cookies 吧,所以不能使用之前的信息自动登录。为了避免每打开一个零件页面就要输一次账号密码,先写一个自动登录的脚本。
# 导入动作链模块
from selenium.webdriver import ActionChains
# 登录
wait.until(EC.visibility_of_element_located((By.XPATH,'//*[@id="divSearch"]/div/div/div[2]/div[2]/a')))
driver.find_element(By.XPATH, '//*[@id="divSearch"]/div/div/div[2]/div[2]/a').click()
# 定位 iframe
iframe = driver.find_elements(By.TAG_NAME, 'iframe')[-1]
# 切换到 iframe 框架
driver.switch_to.frame(iframe)
# 使用动作链
# 实例化动作链对象
action = ActionChains(driver)
# 找到并选择账号密码登录
change_login = driver.find_element(By.XPATH, '//*[@id="app"]/div/div[2]/div[1]/div/div[1]/ul/li[2]')
action.click(change_login).perform()
# 找到账号框并输入
user_input = driver.find_element(By.XPATH, '//*[@id="app"]/div/div[2]/div[1]/div/div[3]/div[1]/div[1]/div[1]/input')
user_input.send_keys('******')
# 找到密码框并输入
pwd_input = driver.find_element(By.XPATH, '//*[@id="app"]/div/div[2]/div[1]/div/div[3]/div[1]/div[2]/div[1]/input')
pwd_input.send_keys('******')
# 找到登录按钮并点击
#time.sleep(2)
login_button = driver.find_element(by=By.XPATH, value='//*[@id="app"]/div/div[2]/div[1]/div/div[3]/a')
action.click(login_button).perform()
# 释放动作链
action.release().perform()
# 从 iframe 跳出
driver.switch_to.default_content()
这一部分的难点主要是这个登陆小窗口是通过 iframe 实现的,相当于是代码在另一个网页里,所以需要切换到 iframe 框架这么一步操作,最后还要记得跳出来,回到主页面,否则不能对原网页的元素操作。
由于该网页中有两个 iframe,而且没有 id 之类的元素,我只能通过 By.TAG_NAME
来定位。我一开始定位错了(定位到了第一个),所以死活不能对登录框操作。现在是定位到第二个,然后所有问题就都解决了。
检测并遍历规格与类型的列表
通过这一步看看怎么打开下图中这两个选择框,并且遍历里面的所有元素。
这一步会碰到的问题是,这两个选择框也是动态实现的。源码里面用了 js 方法做了动态实现。也就是说点击之后,选择框的代码才会动态加载到网页中。如果程序执行太快,网页中还没加载出来这一部分代码,那么就会报错,显示该页面找不到元素之类的错误。代码中使用了显性等待的方法来解决这个问题,wait
是在第一步中定义的。
# 遍历规格参数列表
wait.until(EC.visibility_of_element_located((By.XPATH,'//*[@id="parameter_list"]/tbody/tr[1]/td[2]/div/div[1]/i')))
driver.find_element(By.XPATH, '//*[@id="parameter_list"]/tbody/tr[1]/td[2]/div/div[1]/i').click()
wait.until(EC.visibility_of_element_located((By.XPATH,'//*[@id="param_select_0"]')))
ds = driver.find_element(By.XPATH, '//*[@id="param_select_0"]')
children = ds.find_elements(By.TAG_NAME, 'dd')
l = len(children)
print(len(children))
driver.find_element(By.XPATH, '//*[@id="parameter_list"]/tbody/tr[1]/td[2]/div/div[1]/i').click()
# 遍历类型参数列表
wait.until(EC.visibility_of_element_located((By.XPATH,'//*[@id="parameter_list"]/tbody/tr[5]/td[2]/div/div[1]/i')))
driver.find_element(By.XPATH, '//*[@id="parameter_list"]/tbody/tr[5]/td[2]/div/div[1]/i').click()
wait.until(EC.visibility_of_element_located((By.XPATH,'//*[@id="param_select_4"]')))
typediv = driver.find_element(By.XPATH, '//*[@id="param_select_4"]')
type = typediv.find_elements(By.TAG_NAME, 'dd')
l_type = len(type)
print(l_type)
driver.find_element(By.XPATH, '//*[@id="parameter_list"]/tbody/tr[5]/td[2]/div/div[1]/i').click()
显性等待很重要,我想这也是爬取动态页面会经常遇到的问题。
下载所有规格
先不去管类型,尝试把所有规格都下载下来。其实就是两步,首先依次选择(选择框中的)规格,然后点击下载。
# 下载所有规格
for k in range(l):
wait.until(EC.visibility_of_element_located((By.XPATH,'//*[@id="parameter_list"]/tbody/tr[1]/td[2]/div/div[1]/i')))
driver.find_element(By.XPATH, '//*[@id="parameter_list"]/tbody/tr[1]/td[2]/div/div[1]/i').click()
wait.until(EC.visibility_of_element_located((By.XPATH,'//*[@id="param_select_0"]')))
ds = driver.find_element(By.XPATH, '//*[@id="param_select_0"]')
children = ds.find_elements(By.TAG_NAME, 'dd')
children[k].click()
wait.until(EC.visibility_of_element_located((By.LINK_TEXT, "下载")))
driver.find_element(By.LINK_TEXT, "下载").click()
wait.until(EC.visibility_of_element_located((By.XPATH,'//*[@id="tw_optionDownloadFile"]/div[2]/div/div[6]/a[1]')))
driver.find_element_by_xpath('//*[@id="tw_optionDownloadFile"]/div[2]/div/div[6]/a[1]').click()
children[k].click()
之前是同上一步遍历规格参数列表一样的,重复一遍是因为我一开始碰到了找不到元素的问题,也就是上一步说的,动态加载那部分没有加载出来。所以先把选择框打开,让元素都加载出来。重新存一遍 children
是防止动态加载的这个选择框的 XPATH
是动态的(可能不需要,可以去掉试试)。children[k].click()
之后的代码就是点击下载按钮了。同样每一步都设置了显性等待。
依次选择所有类型
本来以为我大不了手动切换一下类型,然后下载该类型的所有规格就好了。但没想到,每选一次规格,网页会自动切回第一个类型。这样的话就只能遍历规格的过程中,加入遍历类型。比如先选择第一个规格,然后下载第一个规格的第一个类型,然后第一个规格的第二个类型,再选择第二个规格,以此类推。
这里只尝试依次选择所有类型。有用到检测并遍历规格与类型的列表这一节中统计的规格数量 l_type
。
for t in range(l_type):
wait.until(EC.visibility_of_element_located((By.XPATH,'//*[@id="parameter_list"]/tbody/tr[5]/td[2]/div/div[1]/i')))
driver.find_element(By.XPATH, '//*[@id="parameter_list"]/tbody/tr[5]/td[2]/div/div[1]/i').click()
wait.until(EC.visibility_of_element_located((By.XPATH,'//*[@id="param_select_4"]')))
typediv = driver.find_element(By.XPATH, '//*[@id="param_select_4"]')
types = typediv.find_elements(By.TAG_NAME, 'dd')
len(types)
types[t].click()
# sleep 为了看清切换的操作,实际应用时可以不加
time.sleep(2)
下载所有规格与类型参数的零件
# 下载所有参数
for k in range(l):
wait.until(EC.visibility_of_element_located((By.XPATH,'//*[@id="parameter_list"]/tbody/tr[1]/td[2]/div/div[1]/i')))
driver.find_element(By.XPATH, '//*[@id="parameter_list"]/tbody/tr[1]/td[2]/div/div[1]/i').click()
wait.until(EC.visibility_of_element_located((By.XPATH,'//*[@id="param_select_0"]')))
ds = driver.find_element(By.XPATH, '//*[@id="param_select_0"]')
children = ds.find_elements(By.TAG_NAME, 'dd')
children[k].click()
time.sleep(2)
for t in range(l_type):
action = ActionChains(driver)
wait.until(EC.visibility_of_element_located((By.XPATH,'//*[@id="parameter_list"]/tbody/tr[5]/td[2]/div/div[1]/i')))
driver.find_element(By.XPATH, '//*[@id="parameter_list"]/tbody/tr[5]/td[2]/div/div[1]/i').click()
wait.until(EC.visibility_of_element_located((By.XPATH,'//*[@id="param_select_4"]')))
typediv = driver.find_element(By.XPATH, '//*[@id="param_select_4"]')
types = typediv.find_elements(By.TAG_NAME, 'dd')
action.click(types[t]).perform()
wait.until(EC.visibility_of_element_located((By.XPATH, '//*[@id="divContent"]/div[2]/div[3]/div[1]/div[2]/div[2]/div[2]/div/div[3]/a[3]')))
download = driver.find_element(By.XPATH, '//*[@id="divContent"]/div[2]/div[3]/div[1]/div[2]/div[2]/div[2]/div/div[3]/a[3]')
action.click(download).perform()
wait.until(EC.visibility_of_element_located((By.XPATH,'//*[@id="tw_optionDownloadFile"]/div[2]/div/div[6]/a[1]')))
download2 = driver.find_element_by_xpath('//*[@id="tw_optionDownloadFile"]/div[2]/div/div[6]/a[1]')
action.click(download2).perform()
action.release().perform()
time.sleep(5)
基础代码如上,还没定义方法。中间的一些 time.sleep
是为了等待网页,比如最后下载完的等待,因为有时网页的服务器慢了,好几秒还没完成下载的动作,所以页面还停留在当前的下载小窗上,导致下一个循环重新设置参数报错(找不到元素位置),所以设置一个等待五秒,可以根据自己的网速测试决定。
明明选择参数有一个显性等待,为什么还会报错呢?我猜是因为有下载小窗的时候,那些选择框不可选中了,等结束才可以选中,所以哪怕显性等待结束再选择也会报错。而且我这里只是简单的统一使用定位到可见元素的等待,可见不等于可点击。EC.element_to_be_clickable
这个方法是元素等待直到元素被加载,为可见状态,并且是可点击的状态,才会结束等待。估计换上这个就可以了。
0x03 拓展
其他零件的网页布局可能会不一样,所以以上针对这一个网页的 XPATH
很有可能在别的网页失效。更合理的设置是通过统一规定的 class
来找下面的 onclick
,通过一些相对位置不会变的方式来定位(比如这一个选择框必定在 <div class="cnt">
中,然后再从这个标签对下面定位需要点击的位置)。
还有一种情况是某些规格的类型只有一个 类型A
,但是会多出来比如外径的参数选择。那么选择另一种类型时就会报错,因为没有 类型B
。
0x04 补充最终代码
检测下载弹窗关闭
在 def download_action(driver)
中需要这样一个功能:检测下载弹窗是否关闭。
因为下载弹窗关闭后,才可以进行下一个零件的下载。但是下载的时间不确定,因为不仅取决于自己的网速,还取决于网站服务器的情况。
先尝试了显性等待,EC.staleness_of
方法检测的内容需要从 Dom 中移除,这个案例中好像是不会移除,所以不适用。检查参数框按键或者下载按键是否可点击也不适用,不知原因。一开始没找到优雅的解决方案,直接用最简单的方法 time.sleep
,设置 6 秒报错(但某一个下载时长超了),所以增加到 10 秒。
后续解决:wait.until_not(EC.presence_of_element_located())
后来终于找到还有 until_not
的方法,经过实验是可以的。方法同 until
,作用就是相反的。
代码
代码规整美化了一下。
代码如下:
from selenium import webdriver
# 导入所需模块
import time
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver import ActionChains
options = webdriver.ChromeOptions()
options.add_experimental_option("excludeSwitches", ["enable-automation"])
options.add_experimental_option('useAutomationExtension', False)
# 获取浏览器对象
driver = webdriver.Chrome()
# 设置显性等待
wait= WebDriverWait(driver, 20)
# 设置隐形等待
driver.implicitly_wait(3)
# 设置浏览器窗口大小
driver.maximize_window()
driver.execute_cdp_cmd("Page.addScriptToEvaluateOnNewDocument", {
"source": """
Object.defineProperty(navigator, 'webdriver', {
get: () => undefined
})
"""
})
# 遍历规格参数列表 获取该参数选择框内的子选项的数量
def get_num(addr1:str, addr2:str, driver):
wait.until(EC.element_to_be_clickable((By.XPATH, addr1)))
driver.find_element(By.XPATH, addr1).click()
wait.until(EC.element_to_be_clickable((By.XPATH, addr2)))
ds = driver.find_element(By.XPATH, addr2)
children = ds.find_elements(By.TAG_NAME, 'dd')
num_size = len(children)
print(len(children))
driver.find_element(By.XPATH, addr1).click()
return num_size
# 下载动作(点击下载)
# 因为下载按键是固定的 所以这里就不多设变量了
def download_action(driver):
action = ActionChains(driver)
wait.until(EC.element_to_be_clickable((By.XPATH, '//*[@id="divContent"]/div[2]/div[3]/div[1]/div[2]/div[2]/div[2]/div/div[3]/a[3]')))
download = driver.find_element(By.XPATH, '//*[@id="divContent"]/div[2]/div[3]/div[1]/div[2]/div[2]/div[2]/div/div[3]/a[3]')
action.click(download).perform()
wait.until(EC.element_to_be_clickable((By.XPATH,'//*[@id="tw_optionDownloadFile"]/div[2]/div/div[6]/a[1]')))
download2 = driver.find_element(By.XPATH, '//*[@id="tw_optionDownloadFile"]/div[2]/div/div[6]/a[1]')
action.click(download2).perform()
# 等待下载的弹出页面下载完毕
# 这里用 until_not 就可以解决等待弹出页面下载完毕的问题
# XPATH 是我在网页里定位的弹窗的定位
wait.until_not(EC.presence_of_element_located((By.XPATH, '//*[@id="tw_waitCreateFile"]/div[2]/div[2]/a')))
action.release().perform()
#time.sleep(10)
# 依次选择参数选择框
def scan_list(num:int, addr1:str, addr2:str, driver):
for k in range(num):
wait.until(EC.element_to_be_clickable((By.XPATH, addr1)))
driver.find_element(By.XPATH, addr1).click()
wait.until(EC.element_to_be_clickable((By.XPATH, addr2)))
ds = driver.find_element(By.XPATH, addr2)
children = ds.find_elements(By.TAG_NAME, 'dd')
children[k].click()
# 单次选择操作(就是上面的方法去掉了遍历)
def click_list_once(k:int, addr1:str, addr2:str, driver):
action = ActionChains(driver)
wait.until(EC.element_to_be_clickable((By.XPATH, addr1)))
c1 = driver.find_element(By.XPATH, addr1)
action.click(c1).perform()
wait.until(EC.element_to_be_clickable((By.XPATH, addr2)))
ds = driver.find_element(By.XPATH, addr2)
children = ds.find_elements(By.TAG_NAME, 'dd')
c2 = children[k]
action.click(c2).perform()
action.release().perform()
# 依次选择参数选择框并且下载(嵌套中最后一步)
def scan_list_download(num:int, addr1:str, addr2:str, driver):
for k in range(num):
action = ActionChains(driver)
wait.until(EC.element_to_be_clickable((By.XPATH, addr1)))
driver.find_element(By.XPATH, addr1).click()
wait.until(EC.element_to_be_clickable((By.XPATH, addr2)))
ds = driver.find_element(By.XPATH, addr2)
children = ds.find_elements(By.TAG_NAME, 'dd')
download = children[k]
action.click(download).perform()
action.release().perform()
download_action(driver)
# 这里加个强行 sleep,防止爬取太快被网站反爬
time.sleep(8) # 等待8秒等下载弹出的 iframe 消失
首先定义一些需要用到的方法。点击动作好像加上动作链会解决很多问题。
然后以 圆轮缘手轮 JB/T7273.5-1994 | 3DSource零件库 这个页面为例,里面有三个参数框。
三个参数框的例子代码:
# 有几个参数选择框 写几对参数选择框的 XPATH 定位
# 在每一对 XPATH 中:
# 第一个是展开参数框 让动态元素加载出来
# 第二个是定位展开的参数框里面的列表
addr1 = '//*[@id="parameter_list"]/tbody/tr[1]/td[2]/div/div[1]/i'
addr2 = '//*[@id="param_select_0"]'
addr3 = '//*[@id="parameter_list"]/tbody/tr[2]/td[2]/div/div[1]/i'
addr4 = '//*[@id="param_select_1"]'
addr5 = '//*[@id="parameter_list"]/tbody/tr[3]/td[2]/div/div[1]/i'
addr6 = '//*[@id="param_select_2"]'
# 获得第一层遍历的 num_size1
# 虽然后面两个 num_size 在每次遍历时需要重新读取 但这里可以作为测试先输出个结果
num_size1 = get_num(addr1, addr2, driver)
num_size2 = get_num(addr3, addr4, driver)
num_size3 = get_num(addr5, addr6, driver)
# 最终遍历
# 因为第一个参数的数量不会变的 以他为第一层遍历 可以直接保存不改
# 后面的参数都可能会变 所以每次遍历都重新获取下一层遍历的参数数量
# 以此类推 保证遍历列表不会超出 index
for x in range(num_size1):
click_list_once(x, addr1, addr2, driver)
time.sleep(2)
num_size2 = get_num(addr3, addr4, driver)
for y in range(num_size2):
click_list_once(y, addr3, addr4, driver)
time.sleep(2) # 加个 sleep 等待网页加载
num_size3 = get_num(addr5, addr6, driver)
# 第三个参数框的遍历并下载
scan_list_download(num_size3, addr5, addr6, driver)
总共 88 个零件,下载完成:
但是,还是但是,这种只能处理这种始终是三个参数框的网页。比如这个网页 波纹圆轮缘手轮 JB/T7273.6-1994 | 3DSource零件库 ,刚开始是两个参数框,然后有的规格参数选择之后,类型参数只剩一个了;还有些规格选择之后,又会多出来一个参数框(变成共三个)。所以又要添加一些判断语句了,暂时先这样吧,够用就行。毕竟没剩多少需要的数据了,都搞完善怕是还没我徒手下的快。
对于别的零件网页有些参数框位置不一样的,改一下对应 addr
的 XPATH
就好。参数框数量不一样的,改一下遍历循环的次数就好。
就酱 ^ ^
Conclusion
通过实战学到了很多“教科书”上没有的内容,所以越来越发现要多动手,或者说学一个东西可以先动手,然后在过程中学习理论。除此之外,遇到了问题要有清晰的逻辑来解决,比如 iframe 定位错那里,我纠结了好久是不是我 click()
不对,是不是 iframe 里面的 XPATH
不能这样写。
这个三维模型网站中其他的零件页面其实还有其他的情况,比如有更多的参数可以选择,或者有的只有规格可以选择,或者中途参数框数量改变。这次爬虫只能说勉强满足需求,想要实现对所有零件网页的一键式下载要复杂太多了。Anyway,也是学到了蛮有意思的东西。
References
Selenium 浏览器自动化项目 | Selenium Selenium 之显性等待详解 Python selenium 三种等待方式解读 Selenium自动化测试-iframe处理 零件库网页版