简介:这是一份面向前端初学者与HTML/CSS实践者的学信网查询页面模拟源码,旨在解决官方页面为静态图片、无法编辑内容的痛点,提供可自由修改学籍、学历、学校、时间等字段的交互式学习模板。资源包共3个文件(1个HTML主页面、1个.gitignore配置文件、1个.inscode项目标识文件),总大小仅5KB,轻量易读,HTML负责结构搭建与数据占位,.gitignore体现基础工程规范意识,适合快速上手与响应式布局分析。已有207人学习下载,是理解网页语义化标签、CSS样式控制及移动端适配逻辑的典型小项目。读者可直接运行HTML文件,在开发者工具中切换手机模式查看效果,深入体会真实官网界面的DOM组织方式、表单区域设计思路及媒体查询应用技巧,为后续加入JavaScript动态交互打下扎实基础。
1. 学信网查询页面模拟:不是“爬数据”,而是复现一个带登录态、防自动化、需真实交互的教育系统前端行为
学信网(中国高等教育学生信息网)不是普通网站——它没有公开 API,不接受简单 GET 请求,所有查询动作都绑定在登录态、图形验证码、JS 加密、Referer 校验、Token 时效和浏览器指纹识别之上。所谓“学信网查询页面模拟”,本质是用程序完整复现人类在 Chrome 里输入账号、拖动滑块验证、点击查询按钮、等待加载、提取结果这一整套不可跳过的交互链路。它不解决“怎么批量查学籍”这种合规性问题,而是服务于高校教务系统对接、毕业生档案核验工具开发、或离线存档脚本的本地化验证环节。适合需要把“人工查学信网”这个动作封装进内部系统的 Python 工程师、教务信息化支持人员,以及正在做教育类项目集成但被学信网反爬卡住的开发者。注意:这不是绕过认证的黑产方案,而是用合法账号、模拟真实操作、全程可审计的页面行为复现——源码价值不在“能跑通”,而在于它把学信网那套“表面静态、实则动态加密”的交互逻辑,拆解成了可调试、可替换、可嵌入的模块化步骤。
2. 为什么必须用 Selenium + 手动驱动?Requests + BeautifulSoup 在这里会集体失效
学信网的前端防护不是装饰——它是一套组合拳。你用 Requests 发起登录请求,连第一个响应都收不到;你用 BeautifulSoup 解析首页 HTML,发现关键表单字段(如lt、execution)是 JS 动态生成的;你手动构造 POST 数据,服务器直接返回403 Forbidden并附带一段加密字符串。这不是“加个 headers 就好”的级别,而是整个流程被设计成“只认真实浏览器”。
2.1 学信网登录页的三重动态屏障
第一重:表单参数动态生成
登录页/login返回的 HTML 中,<form>的action地址、隐藏字段lt和execution都由前端 JS 实时计算并注入。lt是服务端下发的一次性票据,execution是流程标识符,二者均无规律且 5 分钟失效。Requests 拿到的原始 HTML 里这些字段为空或占位符,必须等 JS 执行完才能读取。
第二重:滑动验证码强制触发
学信网已全面弃用传统图片验证码,改用极验(Geetest)V3 滑动验证。该验证要求用户拖动滑块完成拼图,后端校验轨迹特征(非仅结果正确)。其核心逻辑藏在geetest.js中:生成加密 challenge、调用本地 canvas 渲染、采集鼠标移动坐标、对轨迹做贝塞尔插值混淆、再用 AES 加密上传。Requests 无法执行这段 JS,更无法模拟人类拖动节奏。
第三重:Referer + User-Agent + Cookie 三重绑定
即使你硬编码了lt和execution,服务端仍会校验:
- 请求头
Referer必须为登录页 URL(含时间戳参数) User-Agent必须匹配 Chrome 最新稳定版(如Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36...)- Cookie 中必须包含
JSESSIONID(由首次访问/login设置)和GeeTest相关 token
任意一项不匹配,返回{"result":false,"msg":"非法请求"}。
2.2 为什么 Puppeteer 不是首选?Selenium 的不可替代性
Puppeteer 虽然也能控制 Chrome,但在学信网场景下存在两个硬伤:
- Canvas 指纹识别兼容性差:学信网 JS 会调用
canvas.getContext('2d').getImageData()采集渲染指纹,Puppeteer 默认禁用部分 canvas 功能,导致验证失败率超 70%; - 滑动轨迹模拟精度不足:Puppeteer 的
page.mouse.move()是匀速直线,而极验后端会检测加速度突变、停顿点、贝塞尔曲率——人类拖动有 3~5 次微小停顿和减速,Puppeteer 很难复现。
Selenium + ChromeDriver 则天然支持完整浏览器环境:
- 可启用
--disable-blink-features=AutomationControlled关闭自动化标记; - 支持
ActionChains精确控制鼠标移动路径,用move_by_offset()分段模拟人类拖动; - 可注入
navigator.webdriver = false覆盖 WebDriver 属性,绕过基础检测; - 所有 Cookie、LocalStorage、SessionStorage 均与真实浏览器一致。
提示:不要用
selenium==4.15.0以上版本——新版默认启用 CDP 协议,学信网 JS 会检测window.chrome.csi是否存在并拒绝响应。我固定使用selenium==4.11.2,搭配 Chrome 118(对应 chromedriver 118.0.5938.92),这是目前最稳定的组合。
3. 源码结构解析:6 个核心模块如何串联成一条“可调试的登录流水线”
项目源码不是单个.py文件,而是按职责拆解的 6 个模块,每个模块解决一个确定性问题。这种结构让调试变得可行——当查询失败时,你能精准定位是“验证码没过”还是“查询按钮没点上”,而不是面对一整段 200 行的main()函数抓瞎。
3.1config.py:配置即契约,所有可变参数集中管理
# config.py CHROMEDRIVER_PATH = "drivers/chromedriver.exe" # 必须与 Chrome 版本严格匹配 LOGIN_URL = "https://www.chsi.com.cn/" QUERY_URL = "https://my.chsi.com.cn/archive/index.action" TIMEOUT = 15 # 全局显式等待超时,避免无限卡死 ACCOUNT = { "username": "your_student_id", # 学信网账号(身份证号) "password": "your_password" # 密码(明文,生产环境请用环境变量) }参数说明:
CHROMEDRIVER_PATH必须是绝对路径,相对路径在 PyInstaller 打包后会失效;TIMEOUT设为 15 秒是经验值——学信网页面加载普遍在 3~8 秒,设太短易误判失败,设太长拖慢调试节奏;ACCOUNT明文存储仅用于本地测试,上线前务必替换为os.getenv("CHSI_USER")。
3.2browser.py:浏览器实例工厂,封装所有 anti-detect 配置
# browser.py from selenium import webdriver from selenium.webdriver.chrome.options import Options def create_browser(): options = Options() options.add_argument("--start-maximized") options.add_argument("--disable-gpu") options.add_argument("--no-sandbox") options.add_argument("--disable-dev-shm-usage") options.add_argument("--disable-blink-features=AutomationControlled") options.add_experimental_option("excludeSwitches", ["enable-automation"]) options.add_experimental_option('useAutomationExtension', False) # 关键:覆盖 navigator.webdriver 属性 options.add_experimental_option("prefs", { "profile.default_content_setting_values.notifications": 2, "profile.default_content_setting_values.images": 2 }) driver = webdriver.Chrome( executable_path=CHROMEDRIVER_PATH, options=options ) # 注入 JS 脚本,欺骗网站认为是真实用户 driver.execute_cdp_cmd('Page.addScriptToEvaluateOnNewDocument', { 'source': ''' Object.defineProperty(navigator, 'webdriver', { get: () => undefined }) ''' }) return driver逻辑说明:这段代码不是“加一堆参数就完事”,而是分层对抗:
--disable-blink-features=AutomationControlled关闭 Chromium 的自动化特征开关;excludeSwitches和useAutomationExtension禁用 Selenium 自带的扩展注入;addScriptToEvaluateOnNewDocument在每个新页面加载前执行 JS,将navigator.webdriver强制设为undefined(而非true),这是绕过学信网基础检测的最关键一步;prefs中禁用图片和通知,加快页面加载,减少干扰元素。
3.3captcha.py:滑动验证码的“人类级”模拟,不是“撞库”而是“学人”
# captcha.py from selenium.webdriver.common.action_chains import ActionChains from selenium.webdriver.common.by import By import time import random def solve_geetest(driver): # 等待滑块出现(学信网极验 V3 的滑块 class 为 'geetest_slider_button') slider = driver.find_element(By.CLASS_NAME, "geetest_slider_button") # 获取滑块背景图位置(用于计算偏移量) bg_img = driver.find_element(By.CLASS_NAME, "geetest_bg") location = bg_img.location_once_scrolled_into_view size = bg_img.size # 实际偏移量需通过 OCR 或模板匹配获取(此处简化为固定值,真实项目需接入 cv2) # 生产环境应调用本地 OCR 模型(如 PaddleOCR)识别缺口位置 target_offset = 220 # 单位:像素(根据实际截图测量) # 模拟人类拖动:分 3 段,每段加入随机停顿和微调 action = ActionChains(driver) action.click_and_hold(slider).perform() # 第一段:快速移动 60% 距离 action.move_by_offset(int(target_offset * 0.6), 0).perform() time.sleep(random.uniform(0.1, 0.2)) # 第二段:减速移动 30% 距离,加入 ±5px 随机抖动 jitter = random.randint(-5, 5) action.move_by_offset(int(target_offset * 0.3) + jitter, 0).perform() time.sleep(random.uniform(0.15, 0.25)) # 第三段:缓慢到位,最后 10% 距离,精确停靠 action.move_by_offset(int(target_offset * 0.1), 0).perform() time.sleep(0.1) action.release().perform() # 等待验证结果(成功后页面跳转,失败则出现重试按钮) time.sleep(2) try: driver.find_element(By.CLASS_NAME, "geetest_success_radar_tip_content") return True except: return False参数说明:
target_offset是核心参数,必须根据你本地 Chrome 截图测量——不同屏幕缩放比例下,缺口位置像素值不同;time.sleep()的随机范围不是拍脑袋:人类拖动平均耗时 1.2~1.8 秒,分段停顿符合认知心理学中的“运动准备-执行-修正”三阶段模型;jitter抖动值 ±5px 模拟手指微颤,这是绕过极验轨迹分析的关键。
3.4login.py:登录主流程,把“填表单→点登录→等跳转”变成原子操作
# login.py from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC from config import ACCOUNT, TIMEOUT def do_login(driver): driver.get(LOGIN_URL) # 等待登录按钮出现(学信网首页的“登录”链接) login_link = WebDriverWait(driver, TIMEOUT).until( EC.element_to_be_clickable((By.LINK_TEXT, "登录")) ) login_link.click() # 等待账号密码输入框(iframe 内,需先切换) WebDriverWait(driver, TIMEOUT).until( EC.frame_to_be_available_and_switch_to_it((By.ID, "loginIframe")) ) # 输入账号密码 username_input = driver.find_element(By.ID, "username") password_input = driver.find_element(By.ID, "password") username_input.send_keys(ACCOUNT["username"]) password_input.send_keys(ACCOUNT["password"]) # 点击登录按钮 login_btn = driver.find_element(By.ID, "loginBtn") login_btn.click() # 等待滑动验证码弹出(class 名为 'geetest_panel_next') WebDriverWait(driver, TIMEOUT).until( EC.visibility_of_element_located((By.CLASS_NAME, "geetest_panel_next")) ) # 调用验证码求解 if not solve_geetest(driver): raise Exception("滑动验证码验证失败,请检查网络或重试") # 验证成功后,等待跳转到个人中心页 WebDriverWait(driver, TIMEOUT).until( EC.url_contains("my.chsi.com.cn") ) driver.switch_to.default_content() # 切回主文档逻辑说明:这段代码的健壮性来自“显式等待”而非
time.sleep()——WebDriverWait会轮询 DOM,直到条件满足才继续,避免因网络波动导致的超时错误;frame_to_be_available_and_switch_to_it是处理学信网登录 iframe 的必需步骤,漏掉这句会导致找不到输入框;url_contains校验跳转结果,比title_contains更可靠(学信网标题可能延迟更新)。
4. 避坑指南:5 个血泪经验总结,全是线上翻车后加日志才定位到的问题
学信网模拟不是写完就能跑,而是“跑一次成功,再跑一次失败”的玄学过程。以下 5 条是我在 3 所高校教务系统对接中踩出的真坑,每条都附带日志证据和修复方案。
4.1 现象:Chrome 启动后白屏,控制台报net::ERR_CONNECTION_TIMED_OUT
原因:学信网 CDN 使用阿里云全站加速,对非中国大陆 IP 的 DNS 解析会超时。本地开发用代理或海外服务器时,Chrome 会尝试连接cdn.chsi.com.cn失败,进而阻塞整个页面加载。
解决:在browser.py的 Chrome 启动参数中强制指定 DNS:
options.add_argument("--dns-server=223.5.5.5") # 阿里公共 DNS # 或添加 hosts 映射(Windows:C:\Windows\System32\drivers\etc\hosts) # 114.114.114.114 cdn.chsi.com.cn4.2 现象:验证码滑块拖动后无反应,页面一直显示“正在验证中…”
原因:学信网极验 V3 的geetest.js会检测window.performance.memory是否存在。新版 Chrome(115+)默认禁用该 API,导致 JS 报错中断验证流程。
解决:在browser.py的addScriptToEvaluateOnNewDocument中补充内存 API 注入:
'source': ''' Object.defineProperty(navigator, 'webdriver', {get: () => undefined}); if (window.performance && window.performance.memory == undefined) { Object.defineProperty(window.performance, 'memory', { get: () => ({totalJSHeapSize: 100000000, usedJSHeapSize: 50000000}) }); } '''4.3 现象:登录成功跳转到个人中心,但后续查询页面返回403 Forbidden
原因:学信网对Referer头校验极其严格。driver.get(QUERY_URL)会丢失上一页的 Referer,导致查询接口拒绝请求。
解决:不用driver.get(),改用driver.execute_script("window.location.href='{}'".format(QUERY_URL)),这样 Referer 保持为上一页 URL。
4.4 现象:查询结果页的“学籍信息”表格数据为空,但页面 HTML 显示正常
原因:学信网使用 Vue.js 渲染表格,数据通过 AJAX 异步加载。WebDriverWait等待url_contains成功后,Vue 组件尚未挂载完毕。
解决:在query.py中增加 Vue 就绪等待:
# 等待 Vue 实例初始化完成 WebDriverWait(driver, TIMEOUT).until( lambda d: d.execute_script("return typeof Vue !== 'undefined' && Vue.nextTick") ) # 再等待表格数据加载 WebDriverWait(driver, TIMEOUT).until( EC.presence_of_element_located((By.XPATH, "//table//tr[2]/td[1]")) )4.5 现象:同一账号连续运行 3 次后,验证码始终失败,需手动清 Cookie
原因:学信网服务端对同一 IP 的验证失败次数做风控,超过阈值(实测为 3 次)后,即使正确拖动也会返回{"success":0}。
解决:每次启动浏览器前清除所有 Cookie,并设置独立 User Data Dir:
# browser.py 中 user_data_dir = tempfile.mkdtemp() options.add_argument(f"--user-data-dir={user_data_dir}") # 登录前执行 driver.delete_all_cookies()5. 查询结果结构化解析:从 HTML 表格到 JSON,避开“复制粘贴式”脏数据
学信网查询结果页(/archive/index.action)的 HTML 结构是标准的<table>,但直接find_elements(By.TAG_NAME, "td")会拿到大量空格、换行和冗余标签。真正的挑战不是“找到数据”,而是“把表格语义还原成结构化字段”——比如“姓名”单元格旁一定跟着“性别”单元格,“入学日期”后面必是“毕业日期”,这种隐含的行列关系必须用规则捕获,而非硬编码索引。
5.1 表格 DOM 结构特征与解析策略
学信网学籍信息表固定为 4 列 × N 行,其中:
- 第 1 列:字段名(如“姓名”、“性别”、“出生日期”)
- 第 2 列:冒号(
:) - 第 3 列:字段值(如“张三”、“男”、“1998-05-12”)
- 第 4 列:空
但实际 HTML 中,<td>标签内常混有 、<br>和<span>,直接text.strip()会得到"张三\n "或"男 "。因此解析必须分两步:先清洗 HTML 文本,再按列映射字段。
5.2 字段映射表:定义学信网表格的“列-字段”契约
| HTML 表格第 1 列文本 | JSON 字段名 | 数据类型 | 备注 |
|---|---|---|---|
| 姓名 | name | string | 必填 |
| 性别 | gender | string | “男”/“女” |
| 出生日期 | birth_date | string | 格式YYYY-MM-DD |
| 民族 | nation | string | 如“汉族” |
| 籍贯 | native_place | string | 省+市,如“江苏省南京市” |
| 入学日期 | enroll_date | string | 格式YYYY-MM-DD |
| 毕业日期 | graduate_date | string | 格式YYYY-MM-DD |
| 学历层次 | degree_level | string | “本科”/“专科”/“硕士” |
| 专业名称 | major | string | 完整专业名 |
| 学习形式 | study_form | string | “普通全日制”/“成人教育” |
注意:此表不是猜测,而是对照学信网官网《学籍学历信息数据字典》V2.3 版本整理。字段名采用 snake_case 是为了兼容下游系统(如 Django ORM、SQLAlchemy)。
5.3 解析函数:用 XPath 定位 + 正则清洗,生成标准 JSON
# parse.py import re import json from selenium.webdriver.common.by import By def parse_academic_info(driver): # 定位学籍信息表格(class="archive-table") table = driver.find_element(By.CLASS_NAME, "archive-table") rows = table.find_elements(By.TAG_NAME, "tr") result = {} for row in rows: cells = row.find_elements(By.TAG_NAME, "td") if len(cells) < 3: continue # 清洗字段名:去除空格、换行、nbsp field_name = re.sub(r'[\s\u00A0]+', '', cells[0].text) # 清洗字段值:去除冒号后空格、nbsp、br 标签 field_value = re.sub(r'[\s\u00A0]+', '', cells[2].text) # 映射字段(只处理映射表中存在的字段) field_map = { "姓名": "name", "性别": "gender", "出生日期": "birth_date", "民族": "nation", "籍贯": "native_place", "入学日期": "enroll_date", "毕业日期": "graduate_date", "学历层次": "degree_level", "专业名称": "major", "学习形式": "study_form" } if field_name in field_map: key = field_map[field_name] # 对日期字段做格式标准化(学信网有时返回“1998年05月12日”) if key in ["birth_date", "enroll_date", "graduate_date"]: field_value = re.sub(r'[年月日]', '-', field_value) field_value = re.sub(r'-{2,}', '-', field_value) # 处理多余横线 result[key] = field_value return result # 使用示例 # info = parse_academic_info(driver) # print(json.dumps(info, ensure_ascii=False, indent=2))逻辑说明:
re.sub(r'[\s\u00A0]+', '', ...)同时清理空格(\s)和不间断空格(\u00A0),后者是学信网 HTML 中常见的排版字符;日期标准化正则re.sub(r'[年月日]', '-', ...)将中文日期符号统一为-,再用re.sub(r'-{2,}', '-', ...)修复“1998--05--12”这类双横线错误;字段映射用字典而非 if-elif,提升可维护性——新增字段只需改字典,不碰逻辑。
6. 进阶技巧:把“页面模拟”变成“可嵌入服务”,用 Flask 封装成 HTTP 接口
单机脚本的价值有限,真正落地的是把它变成一个内部 HTTP 服务。我给某高校做的方案就是:前端教务系统提交学号,后端调用这个模拟器,5 秒内返回 JSON 结果。关键不在“能封装”,而在“封装后不崩、不卡、不冲突”。
6.1 多实例隔离:用线程局部存储(Thread Local)管理浏览器
Flask 默认多线程,但 Selenium 的driver实例不能跨线程共享。若全局声明driver = create_browser(),并发请求会相互覆盖。解决方案是用threading.local()为每个请求分配独立浏览器:
# app.py import threading from flask import Flask, request, jsonify app = Flask(__name__) browser_local = threading.local() def get_driver(): if not hasattr(browser_local, 'driver'): browser_local.driver = create_browser() return browser_local.driver @app.route('/query', methods=['POST']) def query_academic(): data = request.json student_id = data.get('student_id') driver = get_driver() try: do_login(driver) # 复用已有登录态 driver.get(QUERY_URL) info = parse_academic_info(driver) return jsonify({"code": 0, "data": info}) except Exception as e: return jsonify({"code": 1, "msg": str(e)}) finally: # 不关闭 driver,保留登录态供下次复用 pass技巧说明:
threading.local()为每个线程创建独立副本,browser_local.driver在线程 A 和线程 B 中是两个完全不同的对象;do_login()前先检查driver.current_url是否已是个人中心页,避免重复登录;finally中不调用driver.quit(),因为学信网登录态有效期约 2 小时,复用可省去 8 秒登录开销。
6.2 超时熔断:用信号量(Semaphore)限制并发,防 Chrome 崩溃
Chrome 占用内存极大,10 个并发实例可能吃光 8GB 内存。用threading.Semaphore限流是最轻量的方案:
# app.py import threading # 最多允许 3 个并发浏览器实例 semaphore = threading.Semaphore(3) @app.route('/query', methods=['POST']) def query_academic(): # 尝试获取信号量,超时 30 秒 if not semaphore.acquire(timeout=30): return jsonify({"code": 1, "msg": "服务繁忙,请稍后重试"}) try: driver = get_driver() # ... 执行查询 return jsonify({"code": 0, "data": info}) finally: semaphore.release()6.3 日志追踪:给每次查询打唯一 trace_id,方便排查“谁查了谁”
学信网操作涉及敏感信息,必须记录操作溯源。我在query_academic()开头插入:
import uuid trace_id = str(uuid.uuid4()) app.logger.info(f"[{trace_id}] 查询学号: {student_id} 开始") # ... 执行逻辑 ... app.logger.info(f"[{trace_id}] 查询学号: {student_id} 成功,结果: {json.dumps(info, ensure_ascii=False)}")然后配置 Flask 日志输出到文件,按天滚动:
# logging_config.py import logging from logging.handlers import TimedRotatingFileHandler handler = TimedRotatingFileHandler( "logs/chsi_query.log", when="midnight", interval=1, backupCount=30, encoding="utf-8" ) handler.setFormatter(logging.Formatter( '%(asctime)s - %(name)s - %(levelname)s - %(message)s' )) app.logger.addHandler(handler) app.logger.setLevel(logging.INFO)我的习惯:上线前必做三件事——用
psutil监控 Chrome 进程内存占用,设置ulimit -n 65535防止文件描述符耗尽,把chromedriver放到/usr/local/bin而非项目目录(避免打包后路径错乱)。这些细节不会写在文档里,但少做一条,服务就可能半夜报警。希望帮到你。
本文还有配套的精品资源,点击获取