This commit is contained in:
2026-03-31 20:51:44 +08:00
parent 5ca759d280
commit d30e4db319
11 changed files with 580 additions and 204 deletions

View File

@@ -15,22 +15,18 @@
| `search_course` | 搜索课程信息 | `search_course("数据结构")` | | `search_course` | 搜索课程信息 | `search_course("数据结构")` |
| `get_course_schedule` | 获取学生课表 | `get_course_schedule("B21010101", "2024-2025-1")` | | `get_course_schedule` | 获取学生课表 | `get_course_schedule("B21010101", "2024-2025-1")` |
| `search_library_book` | 搜索图书馆藏书 | `search_library_book("Python", "title")` | | `search_library_book` | 搜索图书馆藏书 | `search_library_book("Python", "title")` |
| `get_campus_info` | 获取校园信息 | `get_campus_info("contacts")` |
### 📚 Resources资源 ### 📚 Resources资源
| 资源 URI | 描述 | | 资源 URI | 描述 |
|----------|------| |--------|-----|
| `njupt://announcements` | 最新公告列表 | | `...` | ... |
| `njupt://academic-calendar` | 校历信息 |
| `njupt://departments` | 学院/部门列表 |
### 💬 Prompts提示词模板 ### 💬 Prompts提示词模板
| Prompt | 用途 | | Prompt | 用途 |
|--------|------| |--------|-----|
| `academic_advisor_query` | 学业咨询助手 | | `...` | ... |
| `campus_guide_query` | 校园导航助手 |
## 🚀 快速开始 ## 🚀 快速开始
@@ -68,13 +64,13 @@ python -m njupt_mcp.server
#### 方式二SSE 模式(网络服务) #### 方式二SSE 模式(网络服务)
```bash ```bash
uv run njupt-mcp --transport sse --host 0.0.0.0 --port 8000 uv run njupt-mcp --transport sse
``` ```
#### 方式三streamable-http 模式 #### 方式三streamable-http 模式
```bash ```bash
uv run njupt-mcp --transport streamable-http --port 8000 uv run njupt-mcp --transport streamable-http
``` ```
## ⚙️ 客户端配置 ## ⚙️ 客户端配置

View File

@@ -18,9 +18,13 @@ classifiers = [
"Topic :: Software Development :: Libraries :: Python Modules", "Topic :: Software Development :: Libraries :: Python Modules",
] ]
dependencies = [ dependencies = [
"beautifulsoup4>=4.14.3",
"fastapi>=0.135.2",
"httpx>=0.27.0", "httpx>=0.27.0",
"mcp[cli]>=1.7.0", "mcp[cli]>=1.7.0",
"pydantic>=2.0", "pydantic>=2.0",
"python-dotenv>=1.2.2",
"uvicorn>=0.42.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]

View File

@@ -0,0 +1,32 @@
<table id="Table6" class="blacktab" rules="all" border="1" height="132" width="100%">
<tbody><tr>
<td colspan="2" rowspan="1" width="2%">时间</td><td align="Center" width="14%">星期一</td><td align="Center" width="14%">星期二</td><td align="Center" width="14%">星期三</td><td align="Center" width="14%">星期四</td><td align="Center" width="14%">星期五</td><td align="Center" width="14%">星期六</td><td align="Center" width="14%">星期日</td>
</tr><tr>
<td colspan="2">早晨</td><td align="Center">&nbsp;</td><td align="Center">&nbsp;</td><td align="Center">&nbsp;</td><td align="Center">&nbsp;</td><td align="Center">&nbsp;</td><td align="Center">&nbsp;</td><td align="Center">&nbsp;</td>
</tr><tr>
<td rowspan="5" width="1%">上午</td><td width="1%">第1节</td><td align="Center" rowspan="2" width="7%">计算机问题求解:使用算法(混合式)<br>1-17(1,2)<br>毛毅<br>教4312<br></td><td align="Center" width="7%">&nbsp;</td><td align="Center" width="7%">&nbsp;</td><td align="Center" rowspan="2" width="7%">人工智能导论及其Python应用实践<br>1-17(1,2)<br>李平<br>教3208<br></td><td align="Center" rowspan="2" width="7%">概率论与数理统计<br>1-17单(1,2)<br>王雪红<br>教3520<br></td><td align="Center" width="7%">&nbsp;</td><td align="Center" width="7%">&nbsp;</td>
</tr><tr>
<td>第2节</td><td align="Center">&nbsp;</td><td align="Center">&nbsp;</td><td align="Center">&nbsp;</td><td align="Center">&nbsp;</td>
</tr><tr>
<td>第3节</td><td align="Center" rowspan="2">计算机系统基础Ⅰ(混合式)<br>1-17(3,4)<br>李维维<br>教4312<br></td><td align="Center" rowspan="2">概率论与数理统计<br>1-17(3,4)<br>王雪红<br>教3520<br></td><td align="Center" rowspan="2">操作系统基础(混合式)<br>1-17(3,4)<br>王波(女)<br>教3511<br></td><td align="Center" rowspan="2">大学英语IV<br>2节/双周<br> <br> <br><br><br>大学英语IV<br>2节/单周<br> <br> <br></td><td align="Center" rowspan="2">计算机系统基础Ⅰ(混合式)<br>2-16双(3,4)<br>李维维<br>教3512<br><br><br>操作系统基础(混合式)<br>1-17单(3,4)<br>王波(女)<br>教3201<br></td><td align="Center">&nbsp;</td><td align="Center">&nbsp;</td>
</tr><tr>
<td>第4节</td><td align="Center">&nbsp;</td><td align="Center">&nbsp;</td>
</tr><tr>
<td>第5节</td><td align="Center">&nbsp;</td><td align="Center">&nbsp;</td><td align="Center">&nbsp;</td><td align="Center">&nbsp;</td><td align="Center">&nbsp;</td><td align="Center">&nbsp;</td><td align="Center">&nbsp;</td>
</tr><tr>
<td rowspan="4">下午</td><td>第6节</td><td align="Center" rowspan="2">数据库系统基础<br>1-17(6,7)<br>黄楠<br>教4102<br><br><br>数据库系统基础<br>1-17(6,7)<br>李博文<br>教4102<br></td><td align="Center" rowspan="2">人工智能导论及其Python应用实践<br>1-17单(6,7)<br>李平<br>教3408<br><br><br>大学英语IV<br>2节/双周<br> <br> <br></td><td align="Center">&nbsp;</td><td align="Center">&nbsp;</td><td align="Center" rowspan="2">体育 IV<br>2节/周<br> <br> <br></td><td align="Center">&nbsp;</td><td align="Center">&nbsp;</td>
</tr><tr>
<td>第7节</td><td align="Center">&nbsp;</td><td align="Center">&nbsp;</td><td align="Center">&nbsp;</td><td align="Center">&nbsp;</td>
</tr><tr>
<td>第8节</td><td align="Center" rowspan="2">数学建模<br>1-17(8,9)<br>王敏敏<br>教3102<br></td><td align="Center" rowspan="2">现代管理科学基础<br>1-17(8,9)<br>葛伟<br>教2304<br></td><td align="Center" rowspan="2">中国文化概论<br>1-17(8,9)<br>葛辉<br>教4101<br></td><td align="Center" rowspan="2">计算机问题求解:使用算法(混合式)<br>2-16双(8,9)<br>毛毅<br>教3310<br></td><td align="Center">&nbsp;</td><td align="Center">&nbsp;</td><td align="Center">&nbsp;</td>
</tr><tr>
<td>第9节</td><td align="Center">&nbsp;</td><td align="Center">&nbsp;</td><td align="Center">&nbsp;</td>
</tr><tr>
<td rowspan="3">晚上</td><td>第10节</td><td align="Center" rowspan="3">形势与政策IV<br>6-8(10,11,12)<br>陈骏<br>教4101<br></td><td align="Center">&nbsp;</td><td align="Center">&nbsp;</td><td align="Center">&nbsp;</td><td align="Center">&nbsp;</td><td align="Center">&nbsp;</td><td align="Center">&nbsp;</td>
</tr><tr>
<td>第11节</td><td align="Center">&nbsp;</td><td align="Center">&nbsp;</td><td align="Center">&nbsp;</td><td align="Center">&nbsp;</td><td align="Center">&nbsp;</td><td align="Center">&nbsp;</td>
</tr><tr>
<td>第12节</td><td align="Center">&nbsp;</td><td align="Center">&nbsp;</td><td align="Center">&nbsp;</td><td align="Center">&nbsp;</td><td align="Center">&nbsp;</td><td align="Center">&nbsp;</td>
</tr>
</tbody>
</table>

View File

@@ -0,0 +1 @@
from .course import Course, course_dict_serializer

View File

@@ -0,0 +1,34 @@
from dataclasses import dataclass
@dataclass
class Course:
"""
Course 是对课程表中的 **某一节课** 的抽象。
Examples:
1-17周星期一1-2节数据结构是一个 Course 对象;
1-17周星期三3-4节数据结构是另一个 Course 对象;
1-17周中的单周星期四3-4节英语是一个 Course 对象;
1-17周中的双周星期四3-4节物理是另一个 Course 对象。
"""
name: str
weeks: list[int]
day: int
classes: list[int]
teacher: str | None
classroom: str | None
def course_dict_serializer(course: Course) -> dict[str, str | list[int] | int | None]:
return {
"name": course.name,
"weeks": course.weeks,
"day": course.day,
"classes": course.classes,
"teacher": course.teacher,
"classroom": course.classroom,
}

View File

@@ -1,17 +1,23 @@
"""NJUPT MCP Server 主入口 """NJUPT MCP Server 主入口
基于 FastMCP 实现的南京邮电大学 MCP 服务器。 基于 FastMCP + FastAPI 实现的南京邮电大学 MCP 服务器。
支持 stdio 和 SSE 两种传输方式。 支持 stdio 和 SSE 两种传输方式。
""" """
import argparse import argparse
import logging import logging
import sys import os
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from typing import AsyncIterator from typing import AsyncIterator
from dotenv import load_dotenv
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from mcp.server.fastmcp import FastMCP from mcp.server.fastmcp import FastMCP
from mcp.server.fastmcp.server import Settings from mcp.types import ToolAnnotations
from njupt_mcp.resources.types import course_dict_serializer
from njupt_mcp.tools import create_course_schedule
# 配置日志 # 配置日志
logging.basicConfig( logging.basicConfig(
@@ -21,6 +27,16 @@ logging.basicConfig(
logger = logging.getLogger("njupt-mcp") logger = logging.getLogger("njupt-mcp")
def findHtml():
load_dotenv()
html_path = os.environ.get('COURSE_SCHEDULE')
if html_path is None:
logger.error('未知课表 HTML 的位置,调用错误。')
logger.error('回退到芒果帆帆的大二下学期课表~')
html_path = './src/njupt_mcp/resources/course_schedule/B240423-22.html'
return html_path
@asynccontextmanager @asynccontextmanager
async def app_lifespan(server: FastMCP) -> AsyncIterator[dict]: async def app_lifespan(server: FastMCP) -> AsyncIterator[dict]:
"""应用生命周期管理 """应用生命周期管理
@@ -44,166 +60,150 @@ async def app_lifespan(server: FastMCP) -> AsyncIterator[dict]:
logger.info("✅ 资源清理完成") logger.info("✅ 资源清理完成")
# 创建 MCP 服务器实例 # 1. 创建标准的 FastMCP 实例
# lifespan: 应用生命周期管理 mcp = FastMCP("njupt-mcp", lifespan=app_lifespan)
mcp = FastMCP(
"njupt-mcp", # 2. 创建 FastAPI 实例并配置 CORS
lifespan=app_lifespan, app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # 允许所有来源,方便 Inspector 连接
allow_methods=["*"],
allow_headers=["*"],
) )
# 3. 将 FastMCP 的 SSE 应用挂载到 FastAPI
# sse_app() 返回 Starlette ASGI 应用,内部路由为 /sse 和 /messages
# 挂载到根路径,使 SSE 端点可直接通过 /sse 访问
app.mount("/", mcp.sse_app())
# ==================== Tools ==================== # ==================== Tools ====================
@mcp.tool() @mcp.tool(
async def search_course(keyword: str, limit: int = 10) -> str: name="get_course_schedule_json",
"""搜索南京邮电大学课程信息 title="获取课表 JSON",
description="以 JSON 格式获取整学期整周全部课表数据",
根据关键词搜索课程名称、课程代码、开课学院等信息。 annotations=ToolAnnotations(
title="获取课表 JSON",
Args: readOnlyHint=True, # 只读取数据,不修改
keyword: 搜索关键词(课程名称、课程代码等) destructiveHint=False, # 非破坏性操作
limit: 返回结果数量限制,默认 10 条 idempotentHint=True, # 幂等:重复调用结果相同
openWorldHint=False, # 不依赖外部世界状态
),
)
async def get_course_schedule_json() -> list[dict]:
"""以 JSON 格式获取整学期整周全部课表
Returns: Returns:
课程信息列表的 JSON 字符串 课程列表,每个课程包含以下字段:
- name: 课程名称
Example: - teacher: 授课教师(可能为 None
search_course("数据结构") -> 返回数据结构相关课程 - classroom: 教室(可能为 None
search_course("CS101", limit=5) -> 返回课程代码包含 CS101 的课程 - weeks: 周数list[int]
- day: 星期几int
- classes: 当日第几节课list[int]
失败时返回空列表
""" """
# TODO: 实现实际的课程搜索逻辑 html_path = findHtml()
# 这里返回示例数据 if html_path is None:
courses = [ return []
{
"course_code": "CS2101",
"name": f"{keyword}基础",
"credit": 3.0,
"department": "计算机学院",
"description": f"这是一门关于{keyword}的基础课程",
},
{
"course_code": "CS3102",
"name": f"高级{keyword}",
"credit": 4.0,
"department": "计算机学院",
"description": f"深入探讨{keyword}的高级主题",
},
]
import json courses = create_course_schedule(html_path)
return json.dumps(courses[:limit], ensure_ascii=False, indent=2) logger.debug('解析得到的课表如下:')
logger.debug(courses)
final_courses = []
for course in courses:
final_courses.append(course_dict_serializer(course))
return final_courses
@mcp.tool() @mcp.tool(
async def get_course_schedule(student_id: str, semester: str = "2024-2025-1") -> str: name="get_week_course_schedule_json",
"""获取学生课表信息 title="获取指定周课表",
description="以 JSON 格式获取指定教学周的全部课表数据",
根据学号和学期获取个人课表。 annotations=ToolAnnotations(
title="获取指定周课表",
readOnlyHint=True, # 只读取数据,不修改
destructiveHint=False, # 非破坏性操作
idempotentHint=True, # 幂等:重复调用结果相同
openWorldHint=False, # 不依赖外部世界状态
),
)
async def get_week_course_schedule_json(week: int) -> list[dict]:
"""以 JSON 格式获取指定教学周的全部课表
Args: Args:
student_id: 学生学号(如 B21010101 week: 教学周数,范围通常为 1-20
semester: 学期代码,格式为 "YYYY-YYYY-S"(如 2024-2025-1
Returns: Returns:
课表信息的 JSON 字符串 指定周的课程列表,每个课程包含以下字段:
- name: 课程名称
Example: - teacher: 授课教师(可能为 None
get_course_schedule("B21010101", "2024-2025-1") - classroom: 教室(可能为 None
- weeks: 周数list[int]
- day: 星期几int (1-7)
- classes: 当日第几节课list[int]
该周无课程或参数错误时返回空列表
""" """
# TODO: 实现实际的课表查询逻辑 html_path = findHtml()
import json if html_path is None:
schedule = { return []
"student_id": student_id,
"semester": semester, courses = create_course_schedule(html_path)
"courses": [ logger.debug('解析得到的课表如下:')
{ logger.debug(courses)
"day": 1,
"period": [1, 2], final_courses = []
"name": "数据结构", for course in courses:
"location": "教1-101", if week in course.weeks:
"teacher": "张三教授", final_courses.append(course_dict_serializer(course))
}, return final_courses
{
"day": 3,
"period": [3, 4],
"name": "计算机网络",
"location": "教2-205",
"teacher": "李四副教授",
},
],
}
return json.dumps(schedule, ensure_ascii=False, indent=2)
@mcp.tool() @mcp.tool(
async def search_library_book(keyword: str, search_type: str = "title") -> str: name="get_week_day_course_schedule_json",
"""搜索图书馆藏书 title="获取指定周星期课表",
description="以 JSON 格式获取指定教学周和星期的课表数据",
在南京邮电大学图书馆搜索图书。 annotations=ToolAnnotations(
title="获取指定周星期课表",
readOnlyHint=True, # 只读取数据,不修改
destructiveHint=False, # 非破坏性操作
idempotentHint=True, # 幂等:重复调用结果相同
openWorldHint=False, # 不依赖外部世界状态
),
)
async def get_week_day_course_schedule_json(week: int, day: int) -> list[dict]:
"""以 JSON 格式获取指定教学周和星期的课表
Args: Args:
keyword: 搜索关键词 week: 教学周数,范围通常为 1-20
search_type: 搜索类型,可选 "title"(书名), "author"(作者), "isbn"(ISBN) day: 星期几1=星期一2=星期二,...7=星期日
Returns: Returns:
图书信息列表的 JSON 字符串 指定周和星期的课程列表,每个课程包含以下字段:
- name: 课程名称
Example: - teacher: 授课教师(可能为 None
search_library_book("Python", "title") - classroom: 教室(可能为 None
search_library_book("鲁迅", "author") - weeks: 周数list[int]
- day: 星期几int (1-7)
- classes: 当日第几节课list[int]
该时段无课程或参数错误时返回空列表
""" """
# TODO: 实现实际的图书馆搜索逻辑 html_path = findHtml()
import json if html_path is None:
books = [ return []
{
"title": f"{keyword}编程实战",
"author": "王某某",
"publisher": "清华大学出版社",
"isbn": "978-7-302-12345-6",
"available": True,
"location": "计算机科学阅览室",
},
{
"title": f"{keyword}入门指南",
"author": "李某某",
"publisher": "人民邮电出版社",
"isbn": "978-7-115-78901-2",
"available": False,
"location": "基础科学阅览室",
},
]
return json.dumps(books, ensure_ascii=False, indent=2)
courses = create_course_schedule(html_path)
logger.debug('解析得到的课表如下:')
logger.debug(courses)
@mcp.tool() final_courses = []
async def get_campus_info(info_type: str = "overview") -> str: for course in courses:
"""获取校园信息 if (week in course.weeks) and (day == course.day):
final_courses.append(course_dict_serializer(course))
获取南京邮电大学的基本信息、办事指南等。 return final_courses
Args:
info_type: 信息类型,可选 "overview"(概况), "map"(地图),
"contacts"(联系方式), "calendar"(校历)
Returns:
校园信息的字符串
Example:
get_campus_info("overview") -> 学校概况
get_campus_info("contacts") -> 各部门联系方式
"""
info_data = {
"overview": """南京邮电大学Nanjing University of Posts and Telecommunications
简称 NJUPT是国家“双一流”建设高校和江苏高水平大学高峰计划 A 类建设高校。
学校坐落于历史文化名城南京,现有仙林、三牌楼、锁金村、江宁四个校区。""",
"contacts": """教务处025-85866250
学生工作处025-85866255
图书馆025-85866270
信息化建设与管理办公室025-85866280""",
"map": "请访问 https://map.njupt.edu.cn 查看校园地图",
"calendar": "2024-2025学年第一学期2024年9月2日 - 2025年1月12日",
}
return info_data.get(info_type, info_data["overview"])
# ==================== Resources ==================== # ==================== Resources ====================
@@ -249,39 +249,6 @@ async def get_academic_calendar() -> str:
- 暑假2025年6月30日起""" - 暑假2025年6月30日起"""
@mcp.resource("njupt://departments")
async def get_departments() -> str:
"""获取南京邮电大学学院/部门列表
Returns:
学院和部门列表
"""
return """🏫 南京邮电大学学院设置:
通信与信息工程学院
电子与光学工程学院/柔性电子(未来技术)学院
集成电路科学与工程学院(产教融合学院)
计算机学院/软件学院/网络空间安全学院
自动化学院/人工智能学院
材料科学与工程学院
化学与生命科学学院
物联网学院
理学院
地理与生物信息学院
现代邮政学院
传媒与艺术学院
管理学院
经济学院
马克思主义学院
社会与人口学院/社会工作学院
外国语学院
教育科学与技术学院
📋 主要职能部门:
教务处、学生工作处、研究生工作部、科学技术处、
人事处、财务处、审计处、保卫处、后勤管理处等"""
# ==================== Prompts ==================== # ==================== Prompts ====================
@mcp.prompt() @mcp.prompt()
@@ -342,13 +309,13 @@ def main():
parser.add_argument( parser.add_argument(
"--host", "--host",
default="127.0.0.1", default="127.0.0.1",
help="SSE/HTTP 模式下的监听地址 (默认: 127.0.0.1)", help="服务器主机地址 (默认: 127.0.0.1)",
) )
parser.add_argument( parser.add_argument(
"--port", "--port",
type=int, type=int,
default=8000, default=8000,
help="SSE/HTTP 模式下的监听端口 (默认: 8000)", help="服务器端口 (默认: 8000)",
) )
parser.add_argument( parser.add_argument(
"--debug", "--debug",
@@ -367,9 +334,16 @@ def main():
if args.transport == "stdio": if args.transport == "stdio":
mcp.run(transport="stdio") mcp.run(transport="stdio")
elif args.transport == "sse": elif args.transport == "sse":
mcp.run(transport="sse", host=args.host, port=args.port) # 使用 uvicorn 启动 FastAPI 应用SSE 模式)
import uvicorn
uvicorn.run(app, host=args.host, port=args.port)
elif args.transport == "streamable-http": elif args.transport == "streamable-http":
mcp.run(transport="streamable-http", host=args.host, port=args.port) # 创建独立的 streamable-http 应用
import uvicorn
http_app = mcp.streamable_http_app()
uvicorn.run(http_app, host=args.host, port=args.port)
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -9,4 +9,4 @@
4. 处理异常情况并返回友好的错误信息 4. 处理异常情况并返回友好的错误信息
""" """
# 工具函数将在这里定义,也可以在单独的模块中定义后在此导入 from .course import create_course_schedule

View File

@@ -0,0 +1 @@
from .create_course_schedule import create_course_schedule

View File

@@ -0,0 +1,201 @@
import re
from bs4 import BeautifulSoup
from njupt_mcp.resources.types import Course
# 可能需要配合修改的方法
def normalize_course_str(course_str: str) -> str:
"""规范化课程字符串,确保 create_course 能正确解析。"""
parts = course_str.split("<br>")
while parts and parts[0] == "":
parts.pop(0)
while len(parts) < 4:
parts.append(" ")
for i in range(2, 4):
if parts[i] == "":
parts[i] = " "
return "<br>".join(parts)
def create_course_schedule(html_path: str) -> list[Course]:
"""
解析给定 HTML 文件,返回包含数个 Course 对象的列表。
Args:
html_path: HTML 文件路径。该文件中应该有且只有一个 <table> 标签,其中是课程表数据。
Returns: list[Course]
"""
with open(html_path, encoding="utf-8") as f:
html = f.read()
soup = BeautifulSoup(html, "html.parser")
table = soup.find("table")
rows = table.find_all("tr")
courses: list[Course] = []
rowspan_map: dict[int, int] = {}
# 解析第一行表头,建立列索引到星期几的映射
# 表头格式第1列是"时间"(colspan=2),然后是 星期一 到 星期日
day_map: dict[int, int] = {} # col_idx -> day (1-7)
if rows:
header_cells = rows[0].find_all(["td", "th"])
col_idx = 0
for cell in header_cells:
text = cell.get_text(strip=True)
colspan = int(cell.get("colspan", 1))
# 跳过"时间"单元格
if text != "时间":
# 映射星期几到数字
day_mapping = {
"星期一": 1, "星期二": 2, "星期三": 3, "星期四": 4,
"星期五": 5, "星期六": 6, "星期日": 7, "星期天": 7
}
day = day_mapping.get(text)
if day is not None:
for c in range(col_idx, col_idx + colspan):
day_map[c] = day
col_idx += colspan
for row_idx, row in enumerate(rows):
if row_idx == 0:
continue
cells = row.find_all(["td", "th"])
col_idx = 0
class_start: int | None = None
for cell in cells:
while col_idx in rowspan_map and rowspan_map[col_idx] > 0:
rowspan_map[col_idx] -= 1
if rowspan_map[col_idx] == 0:
del rowspan_map[col_idx]
col_idx += 1
text = cell.get_text(strip=True)
colspan = int(cell.get("colspan", 1))
rowspan = int(cell.get("rowspan", 1))
if text.startswith("") and text.endswith(""):
class_start = int(text[1:-1])
if rowspan > 1:
for c in range(col_idx, col_idx + colspan):
rowspan_map[c] = rowspan - 1
col_idx += colspan
continue
if text in ("早晨", "上午", "下午", "晚上"):
if rowspan > 1:
for c in range(col_idx, col_idx + colspan):
rowspan_map[c] = rowspan - 1
col_idx += colspan
continue
td_str = str(cell)
start = td_str.find(">") + 1
end = td_str.rfind("</td>")
inner_html = td_str[start:end]
if "&nbsp;" not in inner_html and inner_html.strip():
inner_html = re.sub(r"<br\s*/?>", "<br>", inner_html)
course_strs = [
s.strip()
for s in re.split(r"(?:<br>){2,}", inner_html)
if s.strip() and "&nbsp;" not in s
]
# 获取当前列对应的星期几
day = day_map.get(col_idx, 1) # 默认为1星期一
for course_str in course_strs:
course_str = normalize_course_str(course_str)
courses.append(create_course(course_str, day, default_classes_start=class_start))
if rowspan > 1:
for c in range(col_idx, col_idx + colspan):
rowspan_map[c] = rowspan - 1
col_idx += colspan
return courses
def create_course(raw: str, day: int, default_classes_start: int | None = None) -> Course:
"""
根据从 HTML 中提取出的原字符串解析课程信息
Args:
raw: 原字符串,以 <br> 作为换行符
day: 周内的星期几
default_classes_start: 如果没有解析出课程的 classes则使用此参数。此参数应当从表格的行标题解析
Returns: Course
"""
# 0 1 2 3 4
# ['概率论与数理统计', '1-17单(1,2)', '王雪红', '教3-520', '']
raw_list = raw.split("<br>")
# 首先去除列表头部的所有空字符串
while True:
if raw_list[0] == '':
raw_list.pop(0)
else:
break
# 对于大部分课程raw_list[1] 都是形如以下格式
# 1-17(3,4)
# 1-17单(1,2) *(也可能是双)
# 2节/周
# 2节/单周 *(也可能是双)
raw_time = raw_list[1]
weeks = []
classes = []
single = False # 内部变量
double = False # 内部变量
# 处理前两种形式
if '-' in raw_time:
# 也可能是 '1-17单'
t = raw_time.split('(') # ['1-17', '3-4)']
# 也可能是 '17单'
start, end = t[0].split('-') # ['1', '17']
if end.endswith(''):
end = end[:-1]
single = True
elif end.endswith(''):
end = end[:-1]
double = True
for i in range(int(start), int(end) + 1):
if single and i % 2 == 0:
continue
if double and i % 2 == 1:
continue
weeks.append(i)
raw_classes = t[1].removesuffix(')')
classes = [int(i) for i in raw_classes.split(',')]
# 处理后两种形式
elif '/' in raw_time:
# 默认学期 1-16 周
if '/单周' in raw_time:
single = True
elif '/双周' in raw_time:
double = True
for i in range(1, 17):
if single and i % 2 == 0:
continue
if double and i % 2 == 1:
continue
weeks.append(i)
# 获取多少节课
_num = int(raw_time.split('')[0])
for i in range(0, _num):
classes.append(default_classes_start + i)
teacher = raw_list[2] if raw_list[2] != ' ' else None
classroom = raw_list[3] if raw_list[3] != ' ' else None
return Course(raw_list[0], weeks, day, classes, teacher, classroom)

87
tests/test_tools.py Normal file
View File

@@ -0,0 +1,87 @@
"""测试工具函数"""
from pathlib import Path
import pytest
from njupt_mcp.resources.types import Course
from njupt_mcp.tools.course.create_course_schedule import (
create_course,
create_course_schedule,
)
TEST_HTML_PATH = Path(__file__).parent.parent / "src" / "njupt_mcp" / "resources" / "course_schedule" / "B240423-22.html"
def test_create_course_basic():
"""测试 create_course 解析常规课程字符串"""
course = create_course("概率论与数理统计<br>1-17单(1,2)<br>王雪红<br>教3520<br>")
assert course.name == "概率论与数理统计"
assert course.weeks == [1, 3, 5, 7, 9, 11, 13, 15, 17]
assert course.classes == [1, 2]
assert course.teacher == "王雪红"
assert course.classroom == "教3520"
def test_create_course_double_weeks():
"""测试 create_course 解析双周课程"""
course = create_course("计算机系统基础Ⅰ(混合式)<br>2-16双(3,4)<br>李维维<br>教3512<br>")
assert course.name == "计算机系统基础Ⅰ(混合式)"
assert course.weeks == [2, 4, 6, 8, 10, 12, 14, 16]
assert course.classes == [3, 4]
assert course.teacher == "李维维"
assert course.classroom == "教3512"
def test_create_course_with_default_classes():
"""测试 create_course 在需要 default_classes_start 时的解析"""
course = create_course("大学英语IV<br>2节/双周<br> <br> <br>", default_classes_start=3)
assert course.name == "大学英语IV"
assert course.weeks == [2, 4, 6, 8, 10, 12, 14, 16]
assert course.classes == [3, 4]
assert course.teacher is None
assert course.classroom is None
def test_create_course_schedule_from_html():
"""测试从 HTML 文件解析课程表"""
courses = create_course_schedule(str(TEST_HTML_PATH))
assert isinstance(courses, list)
assert len(courses) == 20
assert all(isinstance(c, Course) for c in courses)
# 验证几个典型课程
course_names = [c.name for c in courses]
assert "计算机问题求解:使用算法(混合式)" in course_names
assert "概率论与数理统计" in course_names
assert "形势与政策IV" in course_names
# 查找并验证特定课程
xingshi = next(c for c in courses if c.name == "形势与政策IV")
assert xingshi.weeks == [6, 7, 8]
assert xingshi.classes == [10, 11, 12]
assert xingshi.teacher == "陈骏"
assert xingshi.classroom == "教4101"
# 验证单周课程
gailv = [c for c in courses if c.name == "概率论与数理统计" and c.classes == [1, 2]][0]
assert gailv.weeks == [1, 3, 5, 7, 9, 11, 13, 15, 17]
# 验证合并单元格中的多课程(数据库系统基础出现两次,不同老师)
db_courses = [c for c in courses if c.name == "数据库系统基础"]
assert len(db_courses) == 2
teachers = {c.teacher for c in db_courses}
assert teachers == {"黄楠", "李博文"}
# 验证无教师/教室的课程大学英语IV、体育 IV
english_courses = [c for c in courses if c.name == "大学英语IV"]
assert len(english_courses) == 3
for c in english_courses:
assert c.teacher is None
assert c.classroom is None
tiyu = [c for c in courses if c.name == "体育 IV"][0]
assert tiyu.teacher is None
assert tiyu.classroom is None
assert tiyu.classes == [6, 7]

46
uv.lock generated
View File

@@ -42,6 +42,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" },
] ]
[[package]]
name = "beautifulsoup4"
version = "4.14.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "soupsieve" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" },
]
[[package]] [[package]]
name = "certifi" name = "certifi"
version = "2026.2.25" version = "2026.2.25"
@@ -201,6 +214,22 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1a/89/843b53614b47f97fe1abc13f9a86efa5ec9e275292c457af1d4a60dc80e0/cryptography-46.0.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6728c49e3b2c180ef26f8e9f0a883a2c585638db64cf265b49c9ba10652d430e", size = 3409955, upload-time = "2026-03-25T23:34:48.465Z" }, { url = "https://files.pythonhosted.org/packages/1a/89/843b53614b47f97fe1abc13f9a86efa5ec9e275292c457af1d4a60dc80e0/cryptography-46.0.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6728c49e3b2c180ef26f8e9f0a883a2c585638db64cf265b49c9ba10652d430e", size = 3409955, upload-time = "2026-03-25T23:34:48.465Z" },
] ]
[[package]]
name = "fastapi"
version = "0.135.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
{ name = "pydantic" },
{ name = "starlette" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c4/73/5903c4b13beae98618d64eb9870c3fac4f605523dd0312ca5c80dadbd5b9/fastapi-0.135.2.tar.gz", hash = "sha256:88a832095359755527b7f63bb4c6bc9edb8329a026189eed83d6c1afcf419d56", size = 395833, upload-time = "2026-03-23T14:12:41.697Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8f/ea/18f6d0457f9efb2fc6fa594857f92810cadb03024975726db6546b3d6fcf/fastapi-0.135.2-py3-none-any.whl", hash = "sha256:0af0447d541867e8db2a6a25c23a8c4bd80e2394ac5529bd87501bbb9e240ca5", size = 117407, upload-time = "2026-03-23T14:12:43.284Z" },
]
[[package]] [[package]]
name = "h11" name = "h11"
version = "0.16.0" version = "0.16.0"
@@ -349,9 +378,13 @@ name = "njupt-mcp"
version = "0.1.0" version = "0.1.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "beautifulsoup4" },
{ name = "fastapi" },
{ name = "httpx" }, { name = "httpx" },
{ name = "mcp", extra = ["cli"] }, { name = "mcp", extra = ["cli"] },
{ name = "pydantic" }, { name = "pydantic" },
{ name = "python-dotenv" },
{ name = "uvicorn" },
] ]
[package.optional-dependencies] [package.optional-dependencies]
@@ -367,11 +400,15 @@ dev = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "beautifulsoup4", specifier = ">=4.14.3" },
{ name = "fastapi", specifier = ">=0.135.2" },
{ name = "httpx", specifier = ">=0.27.0" }, { name = "httpx", specifier = ">=0.27.0" },
{ name = "mcp", extras = ["cli"], specifier = ">=1.7.0" }, { name = "mcp", extras = ["cli"], specifier = ">=1.7.0" },
{ name = "pydantic", specifier = ">=2.0" }, { name = "pydantic", specifier = ">=2.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
{ name = "python-dotenv", specifier = ">=1.2.2" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.6.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.6.0" },
{ name = "uvicorn", specifier = ">=0.42.0" },
] ]
provides-extras = ["dev"] provides-extras = ["dev"]
@@ -789,6 +826,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
] ]
[[package]]
name = "soupsieve"
version = "2.8.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" },
]
[[package]] [[package]]
name = "sse-starlette" name = "sse-starlette"
version = "3.3.4" version = "3.3.4"