1. 案例目标
实现模拟登录“教育部考试中心托福网上报名”(https://toefl.neea.cn/login),解析获得某城市某一天的考位情况,将城市、考点、费用和考位情况保存为csv文件(也可保存至数据库,如MongoDB)。
2. 准备工作
以Firofox为例,需要先行安装Firofox和配置GeckoDriver。
3. 手动登录
在手动登录过程中,我发现,在点击验证码输入框时,才会弹出验证码,是一种简单的图形验证码,并且F12可以找到其链接,这为我后面保存验证码图片提供了极大的方便。登录成功之后,可以看到登录的用户名(NEEA ID: ......),这个可以作为用来判定是否登录成功的标志。接下来就是点击“考位查询”,在下拉框中选择“城市”和“日期”,点击“查询考位”,这时候会出现考位查询结果(在这,可以增加一个判断查询结果的标志),出现了需要的数据。
4. 代码实现
4.1 相关库的引入
import os
import csv
import time
import random
import requests
from PIL import Image
from selenium import webdriver
from selenium.webdriver import ActionChains
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.common.by import By
from selenium.webdriver.support.select import Select
from selenium.webdriver.support import expected_conditions as EC
from config import *
4.2 初始化
选定的初始链接为https://toefl.neea.cn/index。在这,首先初始化一些配置,如selenium对象的初始化和一些参数的配置(参数的配置保存在config.py中),如下所示:
# !/usr/bin/env python
# -*- coding:utf8 -*-
# 考试城市、日期
CITY = "北京"
DATE = "2020-01-04"
# 托福账号、密码
USERNAME_TF = "......"
PASSWORD_TF = "......"
class GetToeflTestInfos():
def __init__(self):
self.username = USERNAME_TF
self.password = PASSWORD_TF
self.index_url = "https://toefl.neea.cn/index"
self.option = webdriver.FirefoxOptions()
self.option.add_argument('--user-agent="Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1)"')
# self.option.add_argument('--headless') # 启用无界面模式,爬取的时候不会弹出浏览器
self.driver = webdriver.Firefox(options=self.option)
self.wait = WebDriverWait(self.driver, timeout=30)
其中,USERNAME_TF 和PASSWORD_TF是考生的托福账号、密码。
4.3 模拟点击,输入用户名、密码
第一步操作,模拟点击“登录”,利用显示等待的方式,获取该按钮(WebElement对象),调用click()方法模拟点击。
第二步操作,利用显示等待的方式,传入定位元组,获取输入框,使用send_keys()方法,传入用户名和密码。
def input_infos(self):
"""
模拟点击登录,输入用户名和密码
:return:
"""
# 点击登录按钮
self.driver.get(self.index_url)
login_button = self.wait.until(
EC.element_to_be_clickable((By.CLASS_NAME, "footer_butt"))
)
login_button.click()
print(self.driver.title)
# 输入用户名
input_name = self.wait.until(
EC.presence_of_element_located((By.ID, "userName"))
)
input_name.clear()
input_name.send_keys(self.username)
# 输入密码
input_pwd = self.wait.until(
EC.presence_of_element_located((By.ID, "textPassword"))
)
input_pwd.clear()
input_pwd.send_keys(self.password)
4.4 获取验证码图片,识别验证码
模拟点击验证码输入框,这个动作触发之后,会出现验证码图片,在这里,我使用的是保存图片链接和发送请求保存二进制数据的方法。在这里,提供另一种更高效的方法,直接调用self.driver.save_screenshot(),保存为图片,然后获取验证码图片的位置元组,调用crop()方法截图。
识别验证码,我这演示的是人工识别,也可以利用OCR技术来识别。
def get_captcha(self):
"""
获取验证码图片,获得验证码
:return: 验证码
"""
# 模拟点击
input_code = self.wait.until(
EC.element_to_be_clickable((By.ID, "verifyCode"))
)
input_code.click()
time.sleep(1.2)
# 获取验证码图片的链接,并发送requests请求,保存二进制数据
src = self.wait.until(
EC.presence_of_element_located((By.ID, "chkImg"))
)
src_url = src.get_attribute("src")
print(src_url)
headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:70.0) Gecko/20100101 Firefox/70.0"}
res = requests.get(src_url, headers=headers)
time.sleep(3)
with open('code.png', 'wb') as f:
f.write(res.content)
# 打开保存的验证码图片,手动识别,并输入验证码
try:
im = Image.open('code.png')
im.show()
im.close()
except:
print('到本地目录打开code.png获取验证码')
finally:
captcha = input('please input the captcha:')
os.remove('code.png')
return captcha
4.5 实现模拟登录
输入验证码,点击登录按钮。使用try-except语句,判断是否登录成功,success返回的是bool值,True或False。若登录成功,则success为True,打印输出“登录成功”;若登录失败,传入的定位元组会报错(“raise TimeoutException(message, screen, stacktrace)
selenium.common.exceptions.TimeoutException: Message:”),继而执行except语句,进行下一轮的模拟登录。
def login(self, code):
"""
输入验证码,点击登录
:return:
"""
input_code = self.wait.until(
EC.presence_of_element_located((By.ID, "verifyCode"))
)
input_code.send_keys(code)
submit_button = self.wait.until(
EC.element_to_be_clickable((By.ID, "btnLogin"))
)
submit_button.click()
# 检测是否登录成功
try:
success = self.wait.until(
EC.text_to_be_present_in_element((By.XPATH, '//div[@class="myhome_info_cn"]/span[2]'), USERNAME_TF)
)
if success:
print("登录成功")
except:
self.input_infos()
code_str = self.get_captcha()
self.login(code_str)
4.6 查询考位情况
进入到登录成功页面之后,首先模拟点击“考位查询”,接着处理下拉框,最后点击“查询考位”,理想情况下,会出现我们需要的数据。
def find_seat(self):
"""
模拟点击“考位查询”
:return:
"""
seat_button = self.wait.until(
EC.element_to_be_clickable((By.LINK_TEXT, "考位查询"))
)
seat_button.click()
def send_query_condition(self):
"""
输入查询条件:城市和日期,模拟点击查询
:return:
"""
time.sleep(1.5)
city = Select(self.driver.find_element_by_id("centerProvinceCity")).select_by_visible_text(CITY)
date = Select(self.driver.find_element_by_id("testDays")).select_by_value(DATE)
time.sleep(2)
# 模拟点击查询考位
query_button = self.wait.until(
EC.element_to_be_clickable((By.ID, "btnQuerySeat"))
)
query_button.click()
4.7 保存数据
定义保存数据的函数,传参 i 代表的是当天有 i 场考试。观察网页源代码可以发现,某些日期会存在两场考试,而两场考试的table标签是一样的,在这可体现出传参 i 的重要性。同时,各table的数据还有两行表头,为使数据完整,均要写入csv文件。
def save_date(self, i):
"""
保存信息,存入csv
:return:
"""
time.sleep(2)
csv_fp = open("toefl_{}.csv".format(DATE), "a+", encoding='gbk', newline='')
writer = csv.writer(csv_fp)
# 表头1,写入考试日期和时间
boxhead1 = self.wait.until(
EC.presence_of_all_elements_located(
(By.XPATH, '//table[@class="table table-bordered table-striped"][{}]/thead/tr[1]/th/span'.format(i))
)
)
head1_ls = []
for head1 in boxhead1:
if not head1.text:
continue
head1_ls.append(head1.text)
writer.writerow(head1_ls)
print(head1_ls)
# 表头2
boxhead2 = self.wait.until(
EC.presence_of_all_elements_located(
(By.XPATH, '//table[@class="table table-bordered table-striped"][{}]/thead/tr[2]/th'.format(i))
)
)
head2_ls = []
for head2 in boxhead2:
head2_ls.append(head2.text.replace('\n', ''))
writer.writerow(head2_ls)
print(head2_ls)
# 具体内容
items = self.wait.until(
EC.presence_of_all_elements_located(
(By.XPATH, '//table[@class="table table-bordered table-striped"][{}]/tbody/tr'.format(i))
)
)
for item in items:
body_dict = {}
body_dict["test_city"] = item.find_element_by_xpath('./td[1]').text
body_dict["test_venues"] = item.find_element_by_xpath('./td[2]').text
body_dict["test_fee"] = item.find_element_by_xpath('./td[3]').text
body_dict["test_seat"] = item.find_element_by_xpath('./td[4]').text
writer.writerow(body_dict.values())
print(body_dict)
# 关闭文件
csv_fp.close()
其中,csv文件的命名采用了动态方式,与考试日期DATE挂钩,编码encoding='gbk',可以防止中文乱码。
4.8 主函数调用
再实际运行中,可能会存在按钮点击不上的情况(应该是被反爬了),在这里,我使用while循环对其进行判断,flag = self.wait.until(EC.text_to_be_present_in_element((By.XPATH,'//div[@id="qrySeatResult"]/h4'), "考位查询结果"),当成功点击加载数据之后,flag为True,会跳出while循环;反之,则flag仍为False,继续执行while循环。try-except语句用来判断当天是否还存在第二场考试,若存在,则调用self.save_date(i=2),并打印输出考试场次;若不存在,则直接打印输出考试场次。
def main(self):
# 输用户名、密码
self.input_infos()
# 获得验证码
captcha = self.get_captcha()
# 输入验证码登录
self.login(captcha)
# 考位查询
self.find_seat()
flag = False
while not flag:
try:
# 输入查询条件
self.send_query_condition()
# 保存数据
self.save_date(i=1)
flag = self.wait.until(
EC.text_to_be_present_in_element((By.XPATH, '//div[@id="qrySeatResult"]/h4'), "考位查询结果")
)
except:
flag = False
try:
self.save_date(i=2)
print(DATE + "举行两场考试")
except:
print(DATE + "只有单场考试")
def __del__(self):
self.driver.close()
__del__是python的内置方法,作用是:当对象从内存中销毁前,会被自动调用。在这里的作用是,当程序运行结束之后,自动关闭浏览器。
4.9 运行代码
if __name__ == "__main__":
result = GetToeflTestInfos()
result.main()
5. 运行结果
运行代码,首先会打开一个Firefox浏览器,然后会自动点击登录和输入用户名、密码,接下来,在控制台输入验证码,浏览器继续自动操作,直到出现需要的数据,待爬取结束后,自动关闭。部分运行结果如下图所示:
6. 总结
在做这个案例的时候,思路很简单,相对难点有两处,第一难点:验证码的识别,对于大规模的爬虫来说,如果每次都要手动输入验证码,无疑会很麻烦,而使用tesserocr库识别的话,正确率非常低,我实际使用时是调用的外部接口。有兴趣的大佬也可以自己进行训练,培训AI人工智能学习识别各种验证码,供自己使用。第二难点:“查询考位”这个按钮会触发反爬,会导致该按钮无法点击上,点击几次全靠运气,我使用动作链模拟点击或者执行js代码也是同样的效果,代码如下所示:
# 执行动作链
ActionChains(self.driver).move_to_element(query_button).click().perform()
# 调用js脚本来点击
js = "var q=document.getElementById('btnQuerySeat').click()"
self.driver.execute_script(js)
7. 展望
爬取单个网页不是终点,需要爬取整个网站的考位信息时,可以使用协程,同时将数据存入数据库,定义一个更新模块,用于实时更新考位信息。
8. 参考资料
- selenium官方文档:https://selenium.dev/documentation/en/
- selenium与python交互:https://selenium-python.readthedocs.io/
- python官方文档:https://docs.python.org/3.7/
9. 申明
该爬虫案例仅用于学习、研究用途,请不要用于非法用途。任何由此引发的法律纠纷,请自行负责。