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("数据结构")` |
| `get_course_schedule` | 获取学生课表 | `get_course_schedule("B21010101", "2024-2025-1")` |
| `search_library_book` | 搜索图书馆藏书 | `search_library_book("Python", "title")` |
| `get_campus_info` | 获取校园信息 | `get_campus_info("contacts")` |
### 📚 Resources资源
| 资源 URI | 描述 |
|----------|------|
| `njupt://announcements` | 最新公告列表 |
| `njupt://academic-calendar` | 校历信息 |
| `njupt://departments` | 学院/部门列表 |
|--------|-----|
| `...` | ... |
### 💬 Prompts提示词模板
| Prompt | 用途 |
|--------|------|
| `academic_advisor_query` | 学业咨询助手 |
| `campus_guide_query` | 校园导航助手 |
|--------|-----|
| `...` | ... |
## 🚀 快速开始
@@ -68,13 +64,13 @@ python -m njupt_mcp.server
#### 方式二SSE 模式(网络服务)
```bash
uv run njupt-mcp --transport sse --host 0.0.0.0 --port 8000
uv run njupt-mcp --transport sse
```
#### 方式三streamable-http 模式
```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",
]
dependencies = [
"beautifulsoup4>=4.14.3",
"fastapi>=0.135.2",
"httpx>=0.27.0",
"mcp[cli]>=1.7.0",
"pydantic>=2.0",
"python-dotenv>=1.2.2",
"uvicorn>=0.42.0",
]
[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 主入口
基于 FastMCP 实现的南京邮电大学 MCP 服务器。
基于 FastMCP + FastAPI 实现的南京邮电大学 MCP 服务器。
支持 stdio 和 SSE 两种传输方式。
"""
import argparse
import logging
import sys
import os
from contextlib import asynccontextmanager
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.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(
@@ -21,6 +27,16 @@ logging.basicConfig(
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
async def app_lifespan(server: FastMCP) -> AsyncIterator[dict]:
"""应用生命周期管理
@@ -44,166 +60,150 @@ async def app_lifespan(server: FastMCP) -> AsyncIterator[dict]:
logger.info("✅ 资源清理完成")
# 创建 MCP 服务器实例
# lifespan: 应用生命周期管理
mcp = FastMCP(
"njupt-mcp",
lifespan=app_lifespan,
# 1. 创建标准的 FastMCP 实例
mcp = FastMCP("njupt-mcp", lifespan=app_lifespan)
# 2. 创建 FastAPI 实例并配置 CORS
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 ====================
@mcp.tool()
async def search_course(keyword: str, limit: int = 10) -> str:
"""搜索南京邮电大学课程信息
根据关键词搜索课程名称、课程代码、开课学院等信息。
Args:
keyword: 搜索关键词(课程名称、课程代码等)
limit: 返回结果数量限制,默认 10 条
@mcp.tool(
name="get_course_schedule_json",
title="获取课表 JSON",
description="以 JSON 格式获取整学期整周全部课表数据",
annotations=ToolAnnotations(
title="获取课表 JSON",
readOnlyHint=True, # 只读取数据,不修改
destructiveHint=False, # 非破坏性操作
idempotentHint=True, # 幂等:重复调用结果相同
openWorldHint=False, # 不依赖外部世界状态
),
)
async def get_course_schedule_json() -> list[dict]:
"""以 JSON 格式获取整学期整周全部课表
Returns:
课程信息列表的 JSON 字符串
Example:
search_course("数据结构") -> 返回数据结构相关课程
search_course("CS101", limit=5) -> 返回课程代码包含 CS101 的课程
课程列表,每个课程包含以下字段:
- name: 课程名称
- teacher: 授课教师(可能为 None
- classroom: 教室(可能为 None
- weeks: 周数list[int]
- day: 星期几int
- classes: 当日第几节课list[int]
失败时返回空列表
"""
# TODO: 实现实际的课程搜索逻辑
# 这里返回示例数据
courses = [
{
"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}的高级主题",
},
]
html_path = findHtml()
if html_path is None:
return []
import json
return json.dumps(courses[:limit], ensure_ascii=False, indent=2)
courses = create_course_schedule(html_path)
logger.debug('解析得到的课表如下:')
logger.debug(courses)
final_courses = []
for course in courses:
final_courses.append(course_dict_serializer(course))
return final_courses
@mcp.tool()
async def get_course_schedule(student_id: str, semester: str = "2024-2025-1") -> str:
"""获取学生课表信息
根据学号和学期获取个人课表。
@mcp.tool(
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:
student_id: 学生学号(如 B21010101
semester: 学期代码,格式为 "YYYY-YYYY-S"(如 2024-2025-1
week: 教学周数,范围通常为 1-20
Returns:
课表信息的 JSON 字符串
Example:
get_course_schedule("B21010101", "2024-2025-1")
指定周的课程列表,每个课程包含以下字段:
- name: 课程名称
- teacher: 授课教师(可能为 None
- classroom: 教室(可能为 None
- weeks: 周数list[int]
- day: 星期几int (1-7)
- classes: 当日第几节课list[int]
该周无课程或参数错误时返回空列表
"""
# TODO: 实现实际的课表查询逻辑
import json
schedule = {
"student_id": student_id,
"semester": semester,
"courses": [
{
"day": 1,
"period": [1, 2],
"name": "数据结构",
"location": "教1-101",
"teacher": "张三教授",
},
{
"day": 3,
"period": [3, 4],
"name": "计算机网络",
"location": "教2-205",
"teacher": "李四副教授",
},
],
}
return json.dumps(schedule, ensure_ascii=False, indent=2)
html_path = findHtml()
if html_path is None:
return []
courses = create_course_schedule(html_path)
logger.debug('解析得到的课表如下:')
logger.debug(courses)
final_courses = []
for course in courses:
if week in course.weeks:
final_courses.append(course_dict_serializer(course))
return final_courses
@mcp.tool()
async def search_library_book(keyword: str, search_type: str = "title") -> str:
"""搜索图书馆藏书
在南京邮电大学图书馆搜索图书。
@mcp.tool(
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:
keyword: 搜索关键词
search_type: 搜索类型,可选 "title"(书名), "author"(作者), "isbn"(ISBN)
week: 教学周数,范围通常为 1-20
day: 星期几1=星期一2=星期二,...7=星期日
Returns:
图书信息列表的 JSON 字符串
Example:
search_library_book("Python", "title")
search_library_book("鲁迅", "author")
指定周和星期的课程列表,每个课程包含以下字段:
- name: 课程名称
- teacher: 授课教师(可能为 None
- classroom: 教室(可能为 None
- weeks: 周数list[int]
- day: 星期几int (1-7)
- classes: 当日第几节课list[int]
该时段无课程或参数错误时返回空列表
"""
# TODO: 实现实际的图书馆搜索逻辑
import json
books = [
{
"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)
html_path = findHtml()
if html_path is None:
return []
courses = create_course_schedule(html_path)
logger.debug('解析得到的课表如下:')
logger.debug(courses)
@mcp.tool()
async def get_campus_info(info_type: str = "overview") -> str:
"""获取校园信息
获取南京邮电大学的基本信息、办事指南等。
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"])
final_courses = []
for course in courses:
if (week in course.weeks) and (day == course.day):
final_courses.append(course_dict_serializer(course))
return final_courses
# ==================== Resources ====================
@@ -249,39 +249,6 @@ async def get_academic_calendar() -> str:
- 暑假2025年6月30日起"""
@mcp.resource("njupt://departments")
async def get_departments() -> str:
"""获取南京邮电大学学院/部门列表
Returns:
学院和部门列表
"""
return """🏫 南京邮电大学学院设置:
通信与信息工程学院
电子与光学工程学院/柔性电子(未来技术)学院
集成电路科学与工程学院(产教融合学院)
计算机学院/软件学院/网络空间安全学院
自动化学院/人工智能学院
材料科学与工程学院
化学与生命科学学院
物联网学院
理学院
地理与生物信息学院
现代邮政学院
传媒与艺术学院
管理学院
经济学院
马克思主义学院
社会与人口学院/社会工作学院
外国语学院
教育科学与技术学院
📋 主要职能部门:
教务处、学生工作处、研究生工作部、科学技术处、
人事处、财务处、审计处、保卫处、后勤管理处等"""
# ==================== Prompts ====================
@mcp.prompt()
@@ -342,13 +309,13 @@ def main():
parser.add_argument(
"--host",
default="127.0.0.1",
help="SSE/HTTP 模式下的监听地址 (默认: 127.0.0.1)",
help="服务器主机地址 (默认: 127.0.0.1)",
)
parser.add_argument(
"--port",
type=int,
default=8000,
help="SSE/HTTP 模式下的监听端口 (默认: 8000)",
help="服务器端口 (默认: 8000)",
)
parser.add_argument(
"--debug",
@@ -367,9 +334,16 @@ def main():
if args.transport == "stdio":
mcp.run(transport="stdio")
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":
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__":

View File

@@ -9,4 +9,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" },
]
[[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]]
name = "certifi"
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" },
]
[[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]]
name = "h11"
version = "0.16.0"
@@ -349,9 +378,13 @@ name = "njupt-mcp"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "beautifulsoup4" },
{ name = "fastapi" },
{ name = "httpx" },
{ name = "mcp", extra = ["cli"] },
{ name = "pydantic" },
{ name = "python-dotenv" },
{ name = "uvicorn" },
]
[package.optional-dependencies]
@@ -367,11 +400,15 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "beautifulsoup4", specifier = ">=4.14.3" },
{ name = "fastapi", specifier = ">=0.135.2" },
{ name = "httpx", specifier = ">=0.27.0" },
{ name = "mcp", extras = ["cli"], specifier = ">=1.7.0" },
{ name = "pydantic", specifier = ">=2.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 = "uvicorn", specifier = ">=0.42.0" },
]
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" },
]
[[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]]
name = "sse-starlette"
version = "3.3.4"