软考成绩自动查询小助手:Python + Selenium 实现定时监控

还在每天手动刷新软考官网看成绩出了没?不如写个脚本替你盯着,一旦发布立刻通知你!
本文将分享一个基于 Python + Selenium 的自动化查询工具,并重点讲解如何对敏感信息进行安全处理。


📌 背景与痛点

每年软考(计算机技术与软件专业技术资格)成绩公布时,考生们总忍不住反复登录官网刷新页面,既耗费精力又容易错过第一时间。
其实,这种重复性工作完全可以用自动化脚本代替——只要我们能让脚本自动登录定时查询,并在检测到成绩时及时提醒即可。

本脚本正是为此而生,它使用 Selenium 模拟浏览器操作,配合 schedule 库实现定时循环查询,一旦发现“成绩已发布”或“合格”字样,就会在日志中高亮提示。


🛠️ 技术栈与核心依赖

  • Python 3.7+
  • Selenium – 浏览器自动化驱动
  • Edge WebDriver – 与 Microsoft Edge 浏览器配合(亦可改用 Chrome)
  • schedule – 轻量级定时任务调度
  • logging – 日志记录,方便追踪运行状态

安装命令:

pip install selenium schedule

同时请确保下载与本地 Edge 版本匹配的 Edge WebDriver,并将其所在目录加入系统 PATH。


🔐 敏感性处理:如何保护账号密码与本地路径

原代码中直接硬编码了用户名、密码和本地用户数据目录,这存在严重的安全隐患(例如代码泄露或版本控制意外提交)。
改进方案:将敏感信息移至环境变量外部配置文件,并在代码中动态读取。

1. 使用环境变量(推荐)

在系统或虚拟环境中设置:

export RUANKAO_USERNAME="your_id"
export RUANKAO_PASSWORD="your_password"
export RUANKAO_USER_DATA_DIR="/path/to/profile"

然后在 Python 中通过 os.environ.get() 获取。

2. 使用配置文件(如 .envconfig.ini

若不想设置环境变量,也可用 configparser 读取本地配置文件(需将配置文件加入 .gitignore)。

本文示例将采用环境变量方式,既简单又安全。


🧩 功能模块详解

1. 浏览器驱动初始化(get_driver

  • 加载 Edge 选项,关闭自动化控制特征(防止被网站识别为爬虫)。
  • 启用用户数据目录user-data-dir),用于持久化保存登录会话和 Cookie,避免每次重复登录。
  • 执行 JS 隐藏 navigator.webdriver 属性,进一步降低检测风险。

2. 登录流程(login

  • 访问登录页,检测当前是否已登录(通过判断 URL 或页面元素)。
  • 若未登录,则:
    1. 点击“切换账号密码登录”(因为默认可能为扫码)。
    2. 输入用户名和密码。
    3. 触发滑动验证码(div.tncode),此时脚本暂停并等待用户手动滑动
    4. 用户完成后按回车继续,点击登录按钮。
  • 登录成功后,后续查询可直接复用会话。

⚠️ 为何验证码必须手动?
滑动验证码通常包含复杂的行为轨迹分析,自动破解成本高且违反网站条款,故采用“半自动”方式——脚本触发验证,人工完成,既合规又稳定。

3. 成绩查询(query_score

  • 访问成绩查询页。
  • 若页面显示“考区列表”,则自动点击“浙江”(可根据需要修改)进入具体考区。
  • 检查页面源码中是否包含“成绩暂未发布”、“合格”或“分数”等关键词。
  • 若找到成绩信息,则记录日志并返回成功标志;否则返回 FalseNone(表示会话过期)。

4. 定时任务(job

  • 每次执行前检查是否处于登录状态,若掉线则自动重登。
  • 调用 query_score 并根据返回值决定是否重试。

5. 主程序

  • 首次启动时先登录。
  • 立即执行一次查询。
  • 通过 schedule.every(1).minutes.do(job, driver) 设置固定间隔(本文设为 1 分钟,可调整)。
  • 保持主循环运行,直到用户按 Ctrl+C 退出。

🚀 使用步骤

  1. 克隆/创建脚本文件,将下方脱敏后的代码保存为 query_ruankao.py
  2. 安装依赖pip install selenium schedule
  3. 下载 Edge WebDriver 并确保可执行。
  4. 设置环境变量(或直接在代码中临时替换):
    export RUANKAO_USERNAME="你的证件号"
    export RUANKAO_PASSWORD="你的密码"
    export RUANKAO_USER_DATA_DIR="./edge_profile"   # 任意本地路径
    
  5. 运行脚本
    python query_ruankao.py
    
  6. 首次运行会打开浏览器,若未登录,则脚本会停留在滑动验证码处,手动完成滑动后按回车继续
  7. 之后脚本会定时自动查询,日志会输出每次的结果。

⚠️ 注意事项

  • 验证码处理:每次登录都可能需要手动滑动,若会话保持良好(通过 user-data-dir),则后续无需重复登录。
  • 页面结构变化:软考官网可能会更新 HTML 结构,届时需调整 CSS 选择器或 XPath。
  • 查询间隔:建议不要过于频繁(至少 1 分钟),避免给服务器造成压力。
  • 运行环境:需有图形界面(Windows / Linux with X11 / macOS),因为 Selenium 需要真实浏览器。
  • 隐私安全:切勿将包含真实账号的代码上传至公开仓库。

📝 脱敏后的完整代码

以下代码已将所有敏感信息替换为环境变量读取,并添加了详细的注释。你可以直接复制使用,只需正确设置环境变量即可。

import os
import logging
import time
import schedule
from selenium import webdriver
from selenium.common.exceptions import NoSuchElementException, TimeoutException
from selenium.webdriver.common.by import By
from selenium.webdriver.edge.options import Options
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.ui import WebDriverWait

# ---------- 从环境变量读取敏感配置 ----------
USERNAME = os.environ.get("RUANKAO_USERNAME")
PASSWORD = os.environ.get("RUANKAO_PASSWORD")
USER_DATA_DIR = os.environ.get("RUANKAO_USER_DATA_DIR", "./edge_profile")  # 默认相对路径

if not USERNAME or not PASSWORD:
    raise ValueError("请设置环境变量 RUANKAO_USERNAME 和 RUANKAO_PASSWORD")

LOGIN_URL = "https://bm.ruankao.org.cn/sign/in"
QUERY_URL = "https://bm.ruankao.org.cn/report/query"
CHECK_INTERVAL_MINUTES = 1   # 查询间隔(分钟)

logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")

def get_driver():
    """启动 Edge 浏览器,使用持久化用户目录"""
    options = Options()
    options.add_argument("--disable-gpu")
    options.add_argument("--no-sandbox")
    options.add_argument("--disable-blink-features=AutomationControlled")
    options.add_experimental_option("excludeSwitches", ["enable-automation"])
    options.add_experimental_option("useAutomationExtension", False)
    options.add_argument("--start-maximized")
    options.add_argument(f"--user-data-dir={USER_DATA_DIR}")
    driver = webdriver.Edge(options=options)
    # 隐藏自动化特征
    driver.execute_script("Object.defineProperty(navigator, 'webdriver', {get: () => undefined})")
    return driver

def is_logged_in(driver):
    """检测是否已登录(通过检查当前URL或页面元素)"""
    current_url = driver.current_url
    if "sign/in" in current_url:
        return False
    try:
        # 查找用户头像菜单,如果存在则认为已登录
        driver.find_element(By.CSS_SELECTOR, ".layui-nav-item img.layui-nav-img")
        return True
    except NoSuchElementException:
        return False

def login(driver):
    """执行登录(若已登录则跳过)"""
    logging.info(f"正在访问登录页: {LOGIN_URL}")
    driver.get(LOGIN_URL)
    time.sleep(2)

    if not is_logged_in(driver):
        logging.info("未登录,开始执行登录流程...")
        wait = WebDriverWait(driver, 10)

        # 1. 切换到账号密码登录
        try:
            change_btn = wait.until(EC.element_to_be_clickable((By.CSS_SELECTOR, "a.change_login")))
            driver.execute_script("arguments[0].click();", change_btn)
            logging.info("已切换到账号密码登录")
            time.sleep(1.5)
        except TimeoutException:
            logging.info("未找到切换按钮,可能已经在账号密码模式")

        # 2. 填写账号和密码
        try:
            username_input = wait.until(EC.element_to_be_clickable((By.CSS_SELECTOR, ".layui-input.username")))
            password_input = driver.find_element(By.CSS_SELECTOR, ".layui-input.password.pwd1")
            username_input.clear()
            username_input.send_keys(USERNAME)
            password_input.clear()
            password_input.send_keys(PASSWORD)
            logging.info("账号密码已填写")
        except NoSuchElementException as e:
            logging.error(f"找不到账号或密码输入框: {e}")
            raise

        # 3. 处理滑动验证码(需人工干预)
        try:
            tncode_div = driver.find_element(By.CSS_SELECTOR, "div.tncode")
            driver.execute_script("arguments[0].click();", tncode_div)
            logging.info("已触发滑动验证码,请在浏览器中手动完成滑动")
            input("完成滑动验证后,在此按回车继续...")
            time.sleep(1)
        except NoSuchElementException:
            logging.info("未找到滑动验证码元素,可能无需验证")

        # 4. 点击登录按钮
        try:
            submit_btn = driver.find_element(By.CSS_SELECTOR, ".layui-btn.loginBtn")
            driver.execute_script("arguments[0].click();", submit_btn)
            logging.info("已点击登录按钮")
        except NoSuchElementException as e:
            logging.error(f"找不到登录按钮: {e}")
            raise

        time.sleep(5)

        if "sign/in" in driver.current_url:
            logging.error("登录失败,请检查账号密码或验证码")
            return False
        else:
            logging.info("登录成功")
            return True
    else:
        logging.info("检测到已登录,无需重复登录")
        return True

def query_score(driver):
    """访问成绩查询页面,处理考区列表页的跳转"""
    logging.info(f"正在访问成绩查询页面: {QUERY_URL}")
    driver.get(QUERY_URL)
    time.sleep(5)

    # 检查是否进入考区列表页
    page_source = driver.page_source
    if "机构名称" in page_source and "报名有效时间" in page_source:
        logging.warning("检测到考区列表页,尝试点击'浙江'进入...")
        try:
            # 定位“浙江”对应的“进入”按钮
            zhejiang_entry = driver.find_element(By.XPATH, "//td[contains(text(),'浙江')]/following-sibling::td/a[contains(text(),'进入')]")
            driver.execute_script("arguments[0].click();", zhejiang_entry)
            logging.info("已点击'浙江'的'进入'按钮")
            time.sleep(3)
            page_source = driver.page_source
        except Exception as e:
            logging.error(f"点击'浙江'进入按钮失败: {e}")
            return False
    else:
        logging.info("当前页面不是考区列表页,直接检查成绩状态")

    # 检查成绩状态
    if "成绩暂未发布" in page_source:
        logging.info("⏳ 成绩暂未发布,继续等待...")
        return False
    elif "合格" in page_source or "分数" in page_source or "成绩" in page_source:
        logging.info("🎉 成绩已发布!")
        try:
            score_element = driver.find_element(By.CSS_SELECTOR, ".score-result")
            score_text = score_element.text
            logging.info(f"成绩详情: {score_text}")
        except:
            logging.info("请手动查看浏览器中的成绩页面。")
        return True
    else:
        if "sign/in" in driver.current_url:
            logging.warning("会话过期,需要重新登录")
            return None
        else:
            logging.info("未知状态,可能页面未完全加载或结构已变化")
            return False

def job(driver):
    """定时任务"""
    logging.info("========== 执行定时查询 ==========")
    try:
        if "sign/in" in driver.current_url:
            logging.warning("检测到在登录页面,尝试重新登录...")
            if not login(driver):
                logging.error("重新登录失败,停止任务")
                return

        result = query_score(driver)
        if result is None:
            logging.warning("会话过期,尝试重新登录...")
            if login(driver):
                query_score(driver)
            else:
                logging.error("重新登录失败")
    except Exception as e:
        logging.error(f"查询任务异常: {e}")

if __name__ == "__main__":
    driver = get_driver()
    try:
        if not login(driver):
            logging.error("首次登录失败,退出")
            driver.quit()
            exit()

        query_score(driver)

        schedule.every(CHECK_INTERVAL_MINUTES).minutes.do(job, driver)
        logging.info(f"定时任务已启动,每 {CHECK_INTERVAL_MINUTES} 分钟查询一次")

        while True:
            schedule.run_pending()
            time.sleep(1)
    except KeyboardInterrupt:
        logging.info("用户中断,关闭浏览器")
    finally:
        driver.quit()

📢 最后的话

这个脚本已经在我自己的环境中稳定运行了几天,完美实现了“无人值守,成绩秒知”的目标。当然,它也存在一些局限性(如依赖浏览器界面、验证码需人工),但作为一个辅助工具已经足够实用。

如果你也正在等待软考成绩,不妨一试。记得保护好你的账号信息,用环境变量或配置文件隔离敏感数据。
如果你有更好的改进建议(例如接入邮件/微信通知),欢迎在评论区交流!


Happy Coding & Good Luck! 🍀

更多推荐