diff --git a/README.md b/README.md
index 40cf12c..8461900 100644
--- a/README.md
+++ b/README.md
@@ -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` | 学院/部门列表 |
+| 资源 URI | 描述 |
+|--------|-----|
+| `...` | ... |
### 💬 Prompts(提示词模板)
-| Prompt | 用途 |
-|--------|------|
-| `academic_advisor_query` | 学业咨询助手 |
-| `campus_guide_query` | 校园导航助手 |
+| Prompt | 用途 |
+|--------|-----|
+| `...` | ... |
## 🚀 快速开始
@@ -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
```
## ⚙️ 客户端配置
diff --git a/pyproject.toml b/pyproject.toml
index 3484bce..51b86f7 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -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]
diff --git a/src/njupt_mcp/resources/course_schedule/B240423-22.html b/src/njupt_mcp/resources/course_schedule/B240423-22.html
new file mode 100644
index 0000000..dee56e6
--- /dev/null
+++ b/src/njupt_mcp/resources/course_schedule/B240423-22.html
@@ -0,0 +1,32 @@
+
+
+ | 时间 | 星期一 | 星期二 | 星期三 | 星期四 | 星期五 | 星期六 | 星期日 |
+
+ | 早晨 | | | | | | | |
+
+ | 上午 | 第1节 | 计算机问题求解:使用算法(混合式) 1-17(1,2) 毛毅 教4-312
| | | 人工智能导论及其Python应用实践 1-17(1,2) 李平 教3-208
| 概率论与数理统计 1-17单(1,2) 王雪红 教3-520
| | |
+
+ | 第2节 | | | | |
+
+ | 第3节 | 计算机系统基础Ⅰ(混合式) 1-17(3,4) 李维维 教4-312
| 概率论与数理统计 1-17(3,4) 王雪红 教3-520
| 操作系统基础(混合式) 1-17(3,4) 王波(女) 教3-511
| 大学英语IV 2节/双周
大学英语IV 2节/单周
| 计算机系统基础Ⅰ(混合式) 2-16双(3,4) 李维维 教3-512
操作系统基础(混合式) 1-17单(3,4) 王波(女) 教3-201
| | |
+
+ | 第4节 | | |
+
+ | 第5节 | | | | | | | |
+
+ | 下午 | 第6节 | 数据库系统基础 1-17(6,7) 黄楠 教4-102
数据库系统基础 1-17(6,7) 李博文 教4-102
| 人工智能导论及其Python应用实践 1-17单(6,7) 李平 教3-408
大学英语IV 2节/双周
| | | 体育 IV 2节/周
| | |
+
+ | 第7节 | | | | |
+
+ | 第8节 | 数学建模 1-17(8,9) 王敏敏 教3-102
| 现代管理科学基础 1-17(8,9) 葛伟 教2-304
| 中国文化概论 1-17(8,9) 葛辉 教4-101
| 计算机问题求解:使用算法(混合式) 2-16双(8,9) 毛毅 教3-310
| | | |
+
+ | 第9节 | | | |
+
+ | 晚上 | 第10节 | 形势与政策IV 6-8(10,11,12) 陈骏 教4-101
| | | | | | |
+
+ | 第11节 | | | | | | |
+
+ | 第12节 | | | | | | |
+
+
+
\ No newline at end of file
diff --git a/src/njupt_mcp/resources/types/__init__.py b/src/njupt_mcp/resources/types/__init__.py
new file mode 100644
index 0000000..564ab5a
--- /dev/null
+++ b/src/njupt_mcp/resources/types/__init__.py
@@ -0,0 +1 @@
+from .course import Course, course_dict_serializer
diff --git a/src/njupt_mcp/resources/types/course.py b/src/njupt_mcp/resources/types/course.py
new file mode 100644
index 0000000..0961008
--- /dev/null
+++ b/src/njupt_mcp/resources/types/course.py
@@ -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,
+ }
diff --git a/src/njupt_mcp/server.py b/src/njupt_mcp/server.py
index 463a320..a8ab015 100644
--- a/src/njupt_mcp/server.py
+++ b/src/njupt_mcp/server.py
@@ -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,14 +27,24 @@ 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]:
"""应用生命周期管理
-
+
处理服务器启动和关闭时的资源初始化和清理。
"""
logger.info("🚀 NJUPT MCP Server 启动中...")
-
+
# 初始化共享资源(如数据库连接、HTTP 客户端等)
try:
# 这里可以初始化一些全局资源
@@ -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}的高级主题",
- },
- ]
-
- import json
- return json.dumps(courses[:limit], 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:
+ 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)
-
- Returns:
- 图书信息列表的 JSON 字符串
-
- Example:
- search_library_book("Python", "title")
- search_library_book("鲁迅", "author")
- """
- # 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)
+ week: 教学周数,范围通常为 1-20
+ day: 星期几,1=星期一,2=星期二,...,7=星期日
-
-@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") -> 各部门联系方式
+ 指定周和星期的课程列表,每个课程包含以下字段:
+ - name: 课程名称
+ - teacher: 授课教师(可能为 None)
+ - classroom: 教室(可能为 None)
+ - weeks: 周数,list[int]
+ - day: 星期几,int (1-7)
+ - classes: 当日第几节课,list[int]
+ 该时段无课程或参数错误时返回空列表
"""
- 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"])
+ 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) 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()
@@ -295,7 +262,7 @@ def academic_advisor_query(question: str, student_major: str = "") -> str:
student_major: 学生专业(可选)
"""
major_context = f"该学生专业为:{student_major}。" if student_major else ""
-
+
return f"""你是南京邮电大学的学业咨询助手,专门帮助学生解决学习和课程相关的问题。
{major_context}
@@ -320,9 +287,9 @@ def campus_guide_query(location: str, query_type: str = "location") -> str:
"route": f"请提供前往南京邮电大学'{location}'的详细路线指引。",
"facility": f"请介绍南京邮电大学'{location}'的设施情况和开放时间。",
}
-
+
base_prompt = prompts.get(query_type, prompts["location"])
-
+
return f"""你是南京邮电大学校园导航助手,熟悉校园的各个位置和设施。
{base_prompt}
@@ -342,34 +309,41 @@ 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",
action="store_true",
help="启用调试模式",
)
-
+
args = parser.parse_args()
-
+
if args.debug:
logging.getLogger().setLevel(logging.DEBUG)
logger.debug("调试模式已启用")
-
+
logger.info(f"启动 NJUPT MCP Server,传输方式: {args.transport}")
-
+
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__":
diff --git a/src/njupt_mcp/tools/__init__.py b/src/njupt_mcp/tools/__init__.py
index 71a7a2e..fb25cf4 100644
--- a/src/njupt_mcp/tools/__init__.py
+++ b/src/njupt_mcp/tools/__init__.py
@@ -9,4 +9,4 @@
4. 处理异常情况并返回友好的错误信息
"""
-# 工具函数将在这里定义,也可以在单独的模块中定义后在此导入
+from .course import create_course_schedule
diff --git a/src/njupt_mcp/tools/course/__init__.py b/src/njupt_mcp/tools/course/__init__.py
new file mode 100644
index 0000000..cf4b443
--- /dev/null
+++ b/src/njupt_mcp/tools/course/__init__.py
@@ -0,0 +1 @@
+from .create_course_schedule import create_course_schedule
\ No newline at end of file
diff --git a/src/njupt_mcp/tools/course/create_course_schedule.py b/src/njupt_mcp/tools/course/create_course_schedule.py
new file mode 100644
index 0000000..a98d1fa
--- /dev/null
+++ b/src/njupt_mcp/tools/course/create_course_schedule.py
@@ -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("
")
+ 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 "
".join(parts)
+
+
+def create_course_schedule(html_path: str) -> list[Course]:
+ """
+ 解析给定 HTML 文件,返回包含数个 Course 对象的列表。
+ Args:
+ html_path: HTML 文件路径。该文件中应该有且只有一个 标签,其中是课程表数据。
+
+ 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("")
+ inner_html = td_str[start:end]
+
+ if " " not in inner_html and inner_html.strip():
+ inner_html = re.sub(r"
", "
", inner_html)
+ course_strs = [
+ s.strip()
+ for s in re.split(r"(?:
){2,}", inner_html)
+ if s.strip() and " " 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: 原字符串,以
作为换行符
+ day: 周内的星期几
+ default_classes_start: 如果没有解析出课程的 classes,则使用此参数。此参数应当从表格的行标题解析
+
+ Returns: Course
+
+ """
+
+ # 0 1 2 3 4
+ # ['概率论与数理统计', '1-17单(1,2)', '王雪红', '教3-520', '']
+ raw_list = raw.split("
")
+
+ # 首先去除列表头部的所有空字符串
+ 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)
diff --git a/tests/test_tools.py b/tests/test_tools.py
new file mode 100644
index 0000000..38bbd85
--- /dev/null
+++ b/tests/test_tools.py
@@ -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("概率论与数理统计
1-17单(1,2)
王雪红
教3-520
")
+ 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 == "教3-520"
+
+
+def test_create_course_double_weeks():
+ """测试 create_course 解析双周课程"""
+ course = create_course("计算机系统基础Ⅰ(混合式)
2-16双(3,4)
李维维
教3-512
")
+ 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 == "教3-512"
+
+
+def test_create_course_with_default_classes():
+ """测试 create_course 在需要 default_classes_start 时的解析"""
+ course = create_course("大学英语IV
2节/双周
", 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 == "教4-101"
+
+ # 验证单周课程
+ 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]
diff --git a/uv.lock b/uv.lock
index f0eabd0..165944a 100644
--- a/uv.lock
+++ b/uv.lock
@@ -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"