☰
教务系统HTTP交互逆向:Python实现稳定选课自动化
2026/10/26 10:33:43 网站建设 项目流程

简介:本资源是一套面向高校学生与Python初学者的教务系统抢课辅助脚本工具,聚焦于解决热门课程名额秒光场景下的自动化选课需求。通过模拟浏览器行为实现对教务页面的高频点击与请求提交,降低人工抢课失败率,适用于SUSTC(南方科技大学)和GDUFS(广东外语外贸大学)等采用类似Web架构的教务系统。压缩包共5个文件,含2个核心Python脚本(分别适配不同学校系统)、2份Markdown格式说明文档(含环境配置、运行步骤与注意事项)、1份开源许可证文件,整体仅8KB,轻量易部署。已有1053人学习下载,内容结构清晰、开箱即用:提供可直接运行的抢课逻辑、跨校版本对比参考、基础HTTP请求调试思路及常见验证码/登录态处理提示,是理解Web自动化原理与实践简单爬虫技术的典型入门级实战案例。

1. 这不是“抢课外挂”,而是一份教务系统交互逻辑的逆向工程笔记:用 Python 拆解登录态维持、课程列表解析与原子化选课请求的完整链路

某高校教务系统上线新学期选课时,A同学连续三天凌晨蹲守,手动刷新页面、识别验证码、点击提交,最终仍被系统提示“课程已满”。这不是个例——大量学生反馈:界面卡顿、按钮失灵、提交无响应、选中后状态不更新。问题根源不在“手速”,而在教务系统本身对并发请求的防护策略、会话超时机制、以及前端交互与后端接口的非对称设计。这份资源不是黑盒脚本,而是一套基于真实 HTTP 协议分析、浏览器开发者工具抓包验证、服务端响应状态码归因的 Python 实战工程包。它包含可调试的登录流程封装、课程列表结构化解析器、带重试与退避的选课请求构造器,以及关键节点日志埋点。适合有基础 Web 开发认知、能看懂 Network 面板、想搞懂“为什么点不动”而非“怎么点更快”的教务系统高频使用者。它不绕过权限校验,不伪造身份凭证,所有操作均复现真实用户行为路径。


2. 教务系统交互本质:从浏览器动作到 HTTP 请求的三层映射关系

教务系统不是单页应用,也不是纯静态页面。它的核心交互建立在传统表单提交 + Session ID 维持 + 动态 HTML 渲染三者耦合之上。直接模拟“点击按钮”是无效的——按钮本身只是触发 JS 函数,而该函数真正发出的是一个携带特定参数、Header 和 Cookie 的 POST 请求。要让脚本稳定工作,必须先厘清这三层映射:

  • 第一层(UI 层):用户看到的“选课”按钮,背后绑定的是javascript:doSelect('COURSE_ID', 'SECTION_ID')这类内联调用;
  • 第二层(JS 层):doSelect()函数读取隐藏域(如<input type="hidden" name="token" value="abc123">)、构造 FormData,并通过XMLHttpRequest或fetch发起请求;
  • 第三层(网络层):实际发出的 HTTP 请求,含Content-Type: application/x-www-form-urlencoded、Referer头、Cookie(含 JSESSIONID)、以及表单字段(如courseId=CS101&sectionId=002&token=abc123)。

很多初学者写的“抢课脚本”失败,是因为只复制了第一层(按钮位置),跳过了第二层(JS 逻辑),更没解析第三层(真实请求体)。本项目所有 Python 代码,全部基于第三层原始请求构建,不依赖 Selenium 模拟点击,而是用requests.Session()精确复现每一次合法请求。

2.1 登录流程:Session ID 获取与 Token 同步机制

教务系统登录不是简单 POST 账号密码。典型流程包含三步:GET 登录页 → 提取 hidden token → POST 凭据 + token。其中 token 是防 CSRF 的一次性令牌,由服务端在登录页 HTML 中写入<input name="csrf_token" value="...">,且每次 GET 登录页都会刷新。

import requests from bs4 import BeautifulSoup session = requests.Session() login_url = "https://jwxt.example.edu.cn/login" # 1. GET 登录页,获取初始 Cookie 和 CSRF token resp = session.get(login_url, timeout=10) soup = BeautifulSoup(resp.text, 'html.parser') csrf_token = soup.find('input', {'name': 'csrf_token'})['value'] # 2. 构造登录数据(账号、密码、token) login_data = { 'username': '20230001', 'password': '******', 'csrf_token': csrf_token, 'rememberMe': 'on' # 可选,控制是否记住登录态 } # 3. POST 登录,服务端返回 Set-Cookie 更新 JSESSIONID resp = session.post(login_url, data=login_data, timeout=10) # 4. 验证登录成功:检查响应中是否含欢迎语或跳转到首页 if '欢迎' in resp.text or resp.url.endswith('/student/home'): print("✅ 登录成功,Session 已建立") else: print("❌ 登录失败,请检查账号密码或验证码逻辑")

关键说明:session = requests.Session()是核心。它自动管理 Cookie(包括 JSESSIONID),后续所有请求无需手动传 Cookie。csrf_token必须每次登录前重新 GET 获取,硬编码会导致 403 Forbidden。timeout=10防止卡死,教务系统响应慢是常态。

2.2 课程列表解析:HTML 结构稳定性判断与 XPath 容错提取

教务系统课程列表页 HTML 结构常随版本微调:<tr>行数变化、<td>顺序调整、新增/删除<span class="status">标签。硬写soup.find_all('tr')[1].find_all('td')[3].text必翻车。本项目采用“语义锚点 + 相对定位”策略:先定位课程名所在列标题(如<th>课程名称</th>),再记录其索引,最后按索引提取每行对应<td>内容。

def parse_course_list(html_content): soup = BeautifulSoup(html_content, 'html.parser') # 步骤1:定位表头,找到“课程名称”、“教学班”、“余量”列的索引 headers = soup.select('table th') col_map = {} for idx, th in enumerate(headers): text = th.get_text(strip=True) if '课程名称' in text: col_map['course_name'] = idx elif '教学班' in text or '班号' in text: col_map['class_id'] = idx elif '余量' in text or '剩余' in text: col_map['remaining'] = idx elif '课程代码' in text: col_map['course_code'] = idx # 步骤2:遍历 tbody 中每一行,按列索引提取 courses = [] rows = soup.select('table tbody tr') for row in rows: tds = row.find_all('td') if len(tds) < max(col_map.values()) + 1: continue # 行数据不全,跳过 try: course = { 'code': tds[col_map['course_code']].get_text(strip=True), 'name': tds[col_map['course_name']].get_text(strip=True), 'class_id': tds[col_map['class_id']].get_text(strip=True), 'remaining': int(tds[col_map['remaining']].get_text(strip=True) or '0') } courses.append(course) except (ValueError, KeyError, IndexError) as e: # 某行字段异常,记录但不中断整体解析 print(f"⚠️ 解析第 {len(courses)+1} 行时出错:{e}") continue return courses # 使用示例 list_page = session.get("https://jwxt.example.edu.cn/course/list", timeout=10) course_list = parse_course_list(list_page.text) print(f"共解析出 {len(course_list)} 门可选课程") for c in course_list[:3]: print(f" {c['code']} {c['name']}({c['class_id']}) 剩余 {c['remaining']} 人")

参数说明:col_map动态构建,适应表头文字微调;try/except包裹单行解析,避免整页解析中断;int(... or '0')处理“-”或空字符串等非数字余量显示。此函数输出为标准字典列表,可直接用于后续选课逻辑过滤。

2.3 选课请求构造:POST 参数逆向与 Referer 强依赖

教务系统选课接口通常要求严格 Referer 校验——即请求头中Referer必须是课程列表页 URL,否则返回 403。同时,POST Body 不仅含课程 ID,还包含隐藏的timestamp、random、sign等字段。这些字段并非随机生成,而是由前端 JS 计算得出,常见模式为:sign = md5(courseId + timestamp + secretKey)。本项目提供两种方案:

  • 方案 A(推荐):复用浏览器抓包结果
    在 Chrome DevTools → Network → Filterselect→ 手动点一次选课 → 复制该请求的cURL 命令→ 用在线工具(如 curlconverter.com)转成 Python requests 代码。这是最稳的方式,因为完全复现真实流量。

  • 方案 B(进阶):逆向 JS 签名逻辑
    若需动态生成 sign,需定位 JS 文件(如/static/js/select.js),搜索md5(或crypto.subtle.digest,提取 salt 和拼接规则。本项目附带sign_generator.py,内置某常见教务系统的签名算法(sign = hashlib.md5((course_id + str(int(time.time()*1000)) + 'SALT_2023').encode()).hexdigest()),但需你根据实际抓包结果替换'SALT_2023'。

import time import hashlib def generate_select_sign(course_id: str) -> str: """生成选课请求签名(示例算法,务必按实际抓包替换)""" timestamp = str(int(time.time() * 1000)) raw = f"{course_id}{timestamp}SALT_2023" # ← 替换为你抓包得到的真实 salt return hashlib.md5(raw.encode()).hexdigest() # 构造选课请求(以方案 A 抓包结果为基准) select_url = "https://jwxt.example.edu.cn/course/select" select_data = { 'courseId': 'CS101', 'classId': '002', 'timestamp': str(int(time.time() * 1000)), 'sign': generate_select_sign('CS101'), 'token': session.cookies.get('XSRF-TOKEN', '') # 若有 XSRF-Token 头,需同步 } headers = { 'Referer': 'https://jwxt.example.edu.cn/course/list', # ← 必填!否则 403 'X-Requested-With': 'XMLHttpRequest' } resp = session.post(select_url, data=select_data, headers=headers, timeout=10) print(f"选课请求状态码:{resp.status_code}") print(f"响应内容:{resp.json() if resp.headers.get('Content-Type','').startswith('application/json') else resp.text[:100]}")

注意:Referer头是硬性要求,漏掉必 403;X-Requested-With头标识 AJAX 请求,部分系统据此放行;token字段若存在,必须从登录后响应的Set-Cookie或 HTML 隐藏域中提取,不能硬编码。


3. 避坑指南:教务系统反自动化策略下的五个典型翻车现场

教务系统虽未明文禁止脚本,但其架构天然具备反自动化特征:短会话超时、频繁验证码、请求频率限流、Token 一次性、响应内容动态混淆。以下五条是 A同学在真实环境反复调试后总结的血泪经验,每一条都对应一个具体现象、根本原因和可落地的解决动作。

3.1 现象:登录成功后,访问课程页返回 302 重定向到登录页

原因:JSESSIONID Cookie 未正确继承,或服务端检测到 User-Agent 异常(如 requests 默认 UA 被拦截)。
解决:

  • 确保全程使用同一个requests.Session()实例;
  • 在session.get()前显式设置 UA:session.headers.update({'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'});
  • 检查登录响应 Header 是否含Set-Cookie: JSESSIONID=xxx; Path=/; HttpOnly,若无,说明登录未真正成功(可能是验证码错误或密码错误导致静默失败)。

3.2 现象:课程列表解析为空,或字段错位

原因:HTML 结构变更(如某次系统升级将<th>改为<td>,或增加嵌套<div>),导致BeautifulSoup选择器失效。
解决:

  • 不依赖固定层级,改用soup.find(string=re.compile(r'课程名称')).parent.parent定位表头行;
  • 在parse_course_list()开头添加print(soup.prettify()[:500]),将前 500 字符 HTML 输出到日志,人工比对结构;
  • 为关键字段(如remaining)添加 fallback:tds[col_idx].get_text(strip=True).replace('满', '0').replace('-', '0')。

3.3 现象:选课请求返回 400 Bad Request,提示“参数错误”

原因:POST Body 缺少某个隐藏字段(如source、deviceType),或字段值格式错误(如classId应为字符串"002"而非整数2)。
解决:

  • 用浏览器抓包,对比“手动点击”和“脚本发送”的请求体差异,逐字段校验;
  • 将select_data打印出来,确认所有字段名、值类型、空格/换行符均一致;
  • 对classId、courseId等字段强制转为字符串:str(class_id)。

3.4 现象:连续请求后被封禁 IP,返回 429 Too Many Requests

原因:服务端有请求频率限制(如 10 次/秒),脚本未加延迟导致触发限流。
解决:

  • 在每次请求后添加time.sleep(0.8)(非固定值,需根据系统响应时间动态调整);
  • 实现指数退避:首次失败 sleep 1s,第二次失败 sleep 2s,第三次 sleep 4s;
  • 检测响应状态码,若为 429,则主动 sleep 并重试,而非无脑循环。

3.5 现象:选课成功返回{success:true},但教务系统页面显示“未选上”

原因:选课是两阶段操作——第一阶段提交请求(返回 success),第二阶段后台异步处理(可能因名额实时变动而失败)。脚本只验证了第一阶段。
解决:

  • 选课后立即 GET 个人课表页,用parse_course_list()解析,检查目标课程是否出现在列表中;
  • 若未出现,等待 3 秒后再次检查,最多重试 3 次;
  • 将“提交成功”和“课表确认”拆分为两个独立日志事件,避免误判。

4. 请求调度策略:如何在“快”与“稳”之间做取舍——基于响应时间的自适应重试引擎

抢课的本质不是“最快发出请求”,而是“在系统允许的窗口期内,以最高成功率完成有效请求”。教务系统响应时间波动极大:平时 200ms,高峰期可能 3s+。固定间隔(如sleep(1))会导致低峰期浪费机会,高峰期则因请求堆积而超时。本项目实现了一个轻量级自适应调度器,核心思想是:用历史响应时间预测下次最佳发起时机。

4.1 响应时间采样与滑动窗口统计

我们维护一个长度为 5 的响应时间队列(response_times = deque(maxlen=5)),每次成功请求后,将time.time() - start_time加入队列。然后计算当前窗口的 P90 值(90% 请求耗时低于该值),作为下一次请求的 base delay。

from collections import deque import statistics class AdaptiveScheduler: def __init__(self, window_size=5, base_delay=0.5, max_delay=3.0): self.response_times = deque(maxlen=window_size) self.base_delay = base_delay self.max_delay = max_delay def record_response(self, elapsed_ms: float): """记录本次请求耗时(毫秒)""" self.response_times.append(elapsed_ms) def get_next_delay(self) -> float: """计算下次请求应等待的秒数""" if len(self.response_times) < 3: return self.base_delay # 计算 P90,避免被极端值拉高 p90 = statistics.quantiles(self.response_times, n=10)[8] # 延迟 = P90 * 1.5(留出处理余量),但不超过 max_delay delay = min(p90 / 1000 * 1.5, self.max_delay) return max(delay, self.base_delay) # 至少 base_delay scheduler = AdaptiveScheduler()

逻辑说明:p90 / 1000 * 1.5将毫秒转秒并放大 1.5 倍,确保有足够缓冲应对下一次波动;max/min保证 delay 在合理区间。此设计比固定 sleep 更贴合真实网络状况。

4.2 带熔断的请求重试循环

单纯重试会加剧服务器压力,也易触发风控。我们加入熔断机制:若连续 3 次请求耗时 > 2s,暂停 10 秒并重置计时器。

def select_with_retry(session, select_url, select_data, headers, max_retries=5): for attempt in range(max_retries): start = time.time() try: resp = session.post(select_url, data=select_data, headers=headers, timeout=5) elapsed = (time.time() - start) * 1000 scheduler.record_response(elapsed) # 熔断检查:若本次耗时 > 2000ms,且过去3次平均也 > 2000ms recent_slow = list(scheduler.response_times)[-3:] if elapsed > 2000 and len(recent_slow) == 3 and sum(recent_slow)/3 > 2000: print(f"⚠️ 检测到持续高延迟,熔断 10 秒...") time.sleep(10) scheduler.response_times.clear() # 重置统计 if resp.status_code == 200: result = resp.json() if result.get('success'): print(f"✅ 第 {attempt+1} 次尝试成功!") return result else: print(f"❌ 业务失败:{result.get('message', '未知错误')}") else: print(f"❌ HTTP {resp.status_code} 错误") except requests.exceptions.Timeout: print(f"❌ 第 {attempt+1} 次请求超时") except Exception as e: print(f"❌ 第 {attempt+1} 次请求异常:{e}") # 计算本次应等待时间,并 sleep delay = scheduler.get_next_delay() print(f"⏳ 等待 {delay:.2f} 秒后重试...") time.sleep(delay) return {"success": False, "message": "重试次数用尽"} # 使用 result = select_with_retry(session, select_url, select_data, headers)

参数说明:timeout=5是硬性超时,防止卡死;max_retries=5避免无限循环;熔断逻辑在if elapsed > 2000 and ...块中,触发后清空response_times,确保重启统计。此循环将“请求-等待-再请求”的节奏完全交给数据驱动。

4.3 多课程并发选课的线程安全实践

若需同时抢多门课(如备选 3 门),切忌用threading直接并发——requests.Session()不是线程安全的,Cookie 会互相覆盖。正确做法是:每个线程持有一个独立 Session,登录操作在各自线程内完成。

import threading def thread_select(course_info): # 每个线程创建独立 session local_session = requests.Session() local_session.headers.update({'User-Agent': UA}) # 重新登录(复用同一账号,但独立会话) login_resp = local_session.post(login_url, data=login_data, timeout=10) if not login_resp.url.endswith('/home'): print(f"❌ 线程 {threading.current_thread().name} 登录失败") return # 执行选课 result = select_with_retry( local_session, select_url, {'courseId': course_info['id'], 'classId': course_info['class']}, headers ) print(f"🏁 线程 {threading.current_thread().name} 完成:{result}") # 启动 3 个线程,分别抢不同课程 courses_to_try = [ {'id': 'CS101', 'class': '001'}, {'id': 'MA201', 'class': '002'}, {'id': 'PH101', 'class': '003'} ] threads = [] for i, c in enumerate(courses_to_try): t = threading.Thread(target=thread_select, args=(c,), name=f"Select-{i+1}") threads.append(t) t.start() for t in threads: t.join()

关键点:local_session = requests.Session()在线程内创建,彻底隔离;登录操作不可省略,因不同 Session 的 JSESSIONID 互不相通;线程数建议 ≤3,过多反而增加服务器负担,易被限流。


5. 日志与可观测性:把“黑匣子”变成可追踪、可回溯、可优化的白盒系统

脚本跑起来容易,但一旦失败,90% 的时间花在排查“哪一步断了”。本项目将日志设计为第一公民,不是简单print(),而是结构化、带上下文、可分级的可观测体系。核心原则:每一次网络请求,都必须留下 request + response 的完整快照;每一个决策点,都必须记录依据和结果。

5.1 分级日志配置与上下文注入

我们使用 Python 内置logging模块,定义四个级别:

  • DEBUG:打印完整请求 URL、Headers、Body(脱敏后)、响应 Headers、Status Code;
  • INFO:记录关键动作,如“登录成功”、“发现课程 CS101 剩余 2 人”、“开始第 3 次重试”;
  • WARNING:非致命异常,如解析字段缺失、响应 JSON 格式异常;
  • ERROR:致命错误,如连接拒绝、认证失败、重试耗尽。
import logging from logging.handlers import RotatingFileHandler # 创建 logger logger = logging.getLogger('jwxt_bot') logger.setLevel(logging.DEBUG) # 控制台 handler(只输出 INFO+) console_handler = logging.StreamHandler() console_handler.setLevel(logging.INFO) console_formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s') console_handler.setFormatter(console_formatter) logger.addHandler(console_handler) # 文件 handler(输出 DEBUG+,自动轮转) file_handler = RotatingFileHandler( 'jwxt_debug.log', maxBytes=5*1024*1024, # 5MB backupCount=3 ) file_handler.setLevel(logging.DEBUG) file_formatter = logging.Formatter( '%(asctime)s - %(name)s - %(levelname)s - %(funcName)s:%(lineno)d - %(message)s' ) file_handler.setFormatter(file_formatter) logger.addHandler(file_handler) # 在关键函数中注入日志 def do_login(session, username, password): logger.info(f"开始登录用户 {username[:4]}****") # ... 登录逻辑 if '欢迎' in resp.text: logger.info(f"✅ 用户 {username} 登录成功,Session ID: {session.cookies.get('JSESSIONID')[:10]}...") return True else: logger.error(f"❌ 用户 {username} 登录失败,响应状态码 {resp.status_code}") return False

好处:控制台只看关键信息不刷屏,文件里存全量细节供事后审计;RotatingFileHandler防止日志爆炸;%(funcName)s:%(lineno)d精确定位代码位置。

5.2 请求快照:脱敏后的完整 HTTP 事务记录

DEBUG级别日志必须包含请求与响应的“镜像”,但敏感信息(密码、token、学号)需脱敏。我们编写log_request_response()函数,自动过滤:

import json def log_request_response(logger, req, resp): """记录请求与响应快照(自动脱敏)""" # 脱敏请求数据 safe_data = {} if hasattr(req, 'body') and req.body: try: body_dict = json.loads(req.body.decode()) safe_data = {k: '***' if k in ['password', 'token', 'username'] else v for k, v in body_dict.items()} except: safe_data = {'raw_body': '***'} # 脱敏响应 JSON safe_resp = {} try: resp_json = resp.json() safe_resp = {k: '***' if k in ['sessionId', 'token', 'userId'] else v for k, v in resp_json.items()} except: safe_resp = {'raw_text': resp.text[:200] + '...' if len(resp.text) > 200 else resp.text} logger.debug( f"REQUEST: {req.method} {req.url}\n" f"HEADERS: {dict(req.headers)}\n" f"DATA: {safe_data}\n" f"RESPONSE: {resp.status_code} {resp.reason}\n" f"HEADERS: {dict(resp.headers)}\n" f"JSON: {safe_resp}" ) # 在 requests 发送后调用 resp = session.post(...) log_request_response(logger, resp.request, resp) # 注意:resp.request 是原始请求对象

效果:日志中能看到完整的 HTTP 流程,但密码、token 等字段显示为***,既满足调试需求,又符合安全规范。resp.request是requests库自动附加的原始请求对象,无需额外构造。

5.3 关键指标埋点:量化你的“抢课成功率”

日志不仅是排错工具,更是优化依据。我们在三个关键节点埋点,统计核心指标:

指标名计算方式采集位置优化意义
login_success_rate登录成功次数 / 总登录尝试次数do_login()返回前若 <90%,说明账号/密码/验证码逻辑需优化
parse_accuracy成功解析课程数 / 总课程行数parse_course_list()返回时若 <95%,说明 HTML 结构适配需加强
select_success_rate选课返回{success:true}次数 / 总选课请求次数select_with_retry()中if result.get('success')若 <50%,说明请求参数或签名算法需校准
# 全局统计器 stats = { 'login_attempts': 0, 'login_successes': 0, 'parse_rows': 0, 'parse_successes': 0, 'select_attempts': 0, 'select_successes': 0 } def update_stats(key, delta=1): stats[key] = stats.get(key, 0) + delta # 在 do_login 中 update_stats('login_attempts') if success: update_stats('login_successes') # 在 parse_course_list 中 update_stats('parse_rows', len(rows)) update_stats('parse_successes', len(courses)) # 在 select_with_retry 中 update_stats('select_attempts') if result.get('success'): update_stats('select_successes') # 退出前打印汇总 def print_summary(): logger.info("📊 抢课会话统计:") logger.info(f" 登录成功率: {stats['login_successes']/stats['login_attempts']*100:.1f}% " f"({stats['login_successes']}/{stats['login_attempts']})") logger.info(f" 解析准确率: {stats['parse_successes']/stats['parse_rows']*100:.1f}% " f"({stats['parse_successes']}/{stats['parse_rows']})") logger.info(f" 选课成功率: {stats['select_successes']/stats['select_attempts']*100:.1f}% " f"({stats['select_successes']}/{stats['select_attempts']})") # 程序结束时调用 import atexit atexit.register(print_summary)

价值:这些数字让你摆脱“感觉慢”“好像不行”的玄学判断。例如,若select_success_rate仅 20%,而login_success_rate是 100%,就明确指向选课请求构造环节有问题,可集中火力 debugselect_data和headers。

从那以后我每次部署新教务系统脚本,都强制走一遍这三步:

  1. 先开DEBUG日志,手动跑通一次完整流程,确认所有log_request_response输出符合预期;
  2. 查看jwxt_debug.log中的REQUEST/RESPONSE快照,逐字段比对与浏览器抓包是否一致;
  3. 运行 10 次选课,看select_success_rate是否稳定在 80%+,若不达标,立刻回溯parse_course_list和generate_select_sign。

这套方法让我在三次教务系统大版本升级中,均在 2 小时内完成脚本适配,没再为“为什么点不动”熬过通宵。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询