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"