python-selenium模拟登录“教育部考试中心托福网上报名”并爬取考位情况

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浏览器,然后会自动点击登录和输入用户名、密码,接下来,在控制台输入验证码,浏览器继续自动操作,直到出现需要的数据,待爬取结束后,自动关闭。部分运行结果如下图所示:

爬取结果打印输出1
爬取结果打印输出2
爬取结果csv文件

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. 参考资料

  1. selenium官方文档:https://selenium.dev/documentation/en/
  2. selenium与python交互:https://selenium-python.readthedocs.io/
  3. python官方文档:https://docs.python.org/3.7/

9. 申明

该爬虫案例仅用于学习、研究用途,请不要用于非法用途。任何由此引发的法律纠纷,请自行负责。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
禁止转载,如需转载请通过简信或评论联系作者。
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,839评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,543评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,116评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,371评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,384评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,111评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,416评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,053评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,558评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,007评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,117评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,756评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,324评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,315评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,539评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,578评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,877评论 2 345