Compare commits
7 Commits
8db1d27758
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| d8eabcb606 | |||
| 405d7ff3b9 | |||
| e7892a21a5 | |||
| 5500c55b71 | |||
| 2aee776bad | |||
| c32995edd4 | |||
| 247d9f089c |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -12,3 +12,6 @@ temp/
|
||||
.venv
|
||||
|
||||
.scripts
|
||||
|
||||
# vite 构建产物
|
||||
src/njupt_suan_api/static/
|
||||
|
||||
1
.idea/NJUPT-API-Suan.iml
generated
1
.idea/NJUPT-API-Suan.iml
generated
@@ -2,6 +2,7 @@
|
||||
<module type="PYTHON_MODULE" version="4">
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/.venv" />
|
||||
</content>
|
||||
<orderEntry type="jdk" jdkName="uv (NJUPT-API-Suan)" jdkType="Python SDK" />
|
||||
|
||||
27
CHANGELOG.md
Normal file
27
CHANGELOG.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# CHANGELOG
|
||||
|
||||
此文件中记录 NJUPT Suan API 的主要的版本变更日志。
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
6 种更新类别:Added、Changed、Deprecated、Removed、Fixed、Security。
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
等待中...
|
||||
|
||||
## [0.1.4] - 2026-04-26
|
||||
|
||||
### Fixed
|
||||
|
||||
- 修复从命令行运行 `njupt-suan-api init` 时由于 playwright 命令未找到而失败的问题。
|
||||
|
||||
## [0.1.3] - 2026-04-26
|
||||
|
||||
### Changed
|
||||
|
||||
- 更改项目的 cli 入口别名与包名 `njupt-suan-api` 一致,以支持更简单地通过 uv tool / uvx 部署本项目。
|
||||
- 依赖更新:
|
||||
- fastapi -> 0.136.1
|
||||
- typer -> 0.25.0
|
||||
21
LICENSE.txt
Normal file
21
LICENSE.txt
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 MangoFanFanw
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
42
README.md
42
README.md
@@ -0,0 +1,42 @@
|
||||
# NJUPT Suan API
|
||||
|
||||
NJUPT Suan API 是一个 FastAPI 项目,目标在于实现对 NJUPT(南京邮电大学)的信息获取 API 和 MCP 服务。
|
||||
|
||||
在 `pyproject.toml` 中,本项目的包名,以及命令行入口名为 `njupt-suan-api`。
|
||||
|
||||
## 文档
|
||||
|
||||
虽然项目还没个两样,但是文档其实也没个两样 ~~(什么东西)~~
|
||||
|
||||
[中文名叫芒果酸](https://suan.mangofanfan.cn) - `suan.mangofanfan.cn`
|
||||
|
||||
## 功能
|
||||
|
||||
| 计划功能(芒果画饼中) | 支持进度 |
|
||||
|---------------|------------------|
|
||||
| 教务系统 - 课程表获取 | ✅ |
|
||||
| 教务系统 - 课程获取 | ⌛️ |
|
||||
| 教务系统 - 成绩获取 | ⌛️ |
|
||||
| 体育部系统 - 早锻炼获取 | ❌(无从破解微信小程序 QAQ) |
|
||||
|
||||
## 运行
|
||||
|
||||
建议查阅文档了解更多部署方式。
|
||||
|
||||
如需从源代码直接运行的话,项目的源码位于 `src/njupt_suan_api` 目录下,`main.py` 是旧的入口文件,可以直接传统方式启动。
|
||||
|
||||
`manage.py` 是命令行入口,提供了完整的帮助信息。
|
||||
|
||||
`server.py` 是 FastAPI app 所在文件,可以使用 uvicorn 命令启动。
|
||||
|
||||
另外如需从源代码启动项目,你需要自行构建 WebUI。
|
||||
|
||||
```bash
|
||||
cd webui/
|
||||
pnpm install
|
||||
pnpm run build
|
||||
```
|
||||
|
||||
AI 说 `npm install` 然后 `npm run build` 也可以,但我还没试过,你可以帮我试试(?)
|
||||
|
||||
vite 的构建产物会放在 `src/njupt_suan_api/static` 目录下,**构建产物不会被 git 管理,但是会被项目打包进 wheel。**
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
from .message import ALREADY_INIT_MESSAGE, NOT_INIT_MESSAGE
|
||||
from .path import DATA_DIR, TEMP_DIR
|
||||
|
||||
__all__ = [
|
||||
DATA_DIR,
|
||||
TEMP_DIR,
|
||||
NOT_INIT_MESSAGE,
|
||||
ALREADY_INIT_MESSAGE,
|
||||
]
|
||||
@@ -1,9 +0,0 @@
|
||||
NOT_INIT_MESSAGE = """
|
||||
❓ 当前目录或指定目录下[yellow]似乎还没有执行过初始化命令[/yellow]。
|
||||
❓ 你也许需要先执行 [green]suanapi init[/green] 。
|
||||
"""
|
||||
|
||||
ALREADY_INIT_MESSAGE = """
|
||||
❕ 当前目录或指定目录下[yellow]似乎已经执行初始化命令过[/yellow]。
|
||||
❕ 你也许需要先删除已经存在的 [blue]data[/blue] 和 [blue]temp[/blue] 目录。
|
||||
"""
|
||||
@@ -1,4 +0,0 @@
|
||||
from pathlib import Path
|
||||
|
||||
DATA_DIR = Path.cwd() / "data"
|
||||
TEMP_DIR = Path.cwd() / "temp"
|
||||
109
manage.py
109
manage.py
@@ -1,109 +0,0 @@
|
||||
from secrets import token_urlsafe
|
||||
|
||||
import typer
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
|
||||
from cli import ALREADY_INIT_MESSAGE, DATA_DIR, NOT_INIT_MESSAGE, TEMP_DIR
|
||||
from router import __version__
|
||||
|
||||
console = Console()
|
||||
app = typer.Typer(
|
||||
name="NJUPT-Suan-API",
|
||||
help="NJUPT Suan API 部署与管理工具",
|
||||
rich_markup_mode="rich",
|
||||
no_args_is_help=True,
|
||||
options_metavar="[选项]",
|
||||
subcommand_metavar="[命令]",
|
||||
)
|
||||
|
||||
|
||||
def version_callback(value: bool = False) -> None:
|
||||
if value:
|
||||
console.print(f"NJUPT Suan API [green]v.{__version__}[/green]")
|
||||
if __version__ == "dev":
|
||||
console.print("""
|
||||
[bright_black]显示的版本为 dev ?这是因为你正在从源代码运行 cli 入口(manage.py)。[/bright_black]
|
||||
[bright_black]从[green]已安装版本[/green]中运行 [green]suanapi --version[/green] 可以正确获取版本号。[/bright_black]""")
|
||||
raise typer.Exit
|
||||
|
||||
|
||||
@app.callback(invoke_without_command=True)
|
||||
def main(
|
||||
version: bool = typer.Option(
|
||||
False,
|
||||
"--version",
|
||||
"-v",
|
||||
help="显示版本号并退出,没有其他命令会被执行。",
|
||||
callback=version_callback,
|
||||
is_eager=True, # 优先处理,避免触发其他逻辑
|
||||
),
|
||||
) -> None:
|
||||
"""
|
||||
CLI 入口回调,所有子命令执行前都会经过这里。
|
||||
可以在这里放全局初始化(如日志级别、环境检查)。
|
||||
"""
|
||||
pass # 没有 --version 时就正常放行,继续执行子命令
|
||||
|
||||
|
||||
@app.command()
|
||||
def init() -> None:
|
||||
"""
|
||||
初始化 NJUPT Suan API [green]工作目录[/green]。(可能需要较长时间)
|
||||
|
||||
会在当前目录或指定目录下创建新文件,并尝试安装 playwright chromium,安装过程可能需要较长时间。
|
||||
"""
|
||||
if DATA_DIR.exists() or TEMP_DIR.exists():
|
||||
console.print(Panel(ALREADY_INIT_MESSAGE, title="数据目录已存在"))
|
||||
console.print("[bright_black]如果你想要强制初始化,使用 [green]suanapi init -f[/green] 命令。[/bright_black]")
|
||||
return
|
||||
|
||||
|
||||
@app.command()
|
||||
def token(reset: bool = typer.Option(False, "--reload", "-r", help="强制重新生成令牌,即使令牌已存在。")) -> None:
|
||||
"""
|
||||
查看或重新生成[green]管理后端令牌[/green]。
|
||||
|
||||
需要先运行过 init 初始化目录。
|
||||
|
||||
Args:
|
||||
reset: bool,默认为 False,即只查看,在不存在时重新生成。
|
||||
"""
|
||||
token_ = None
|
||||
# 首先检查数据目录是否存在
|
||||
if not DATA_DIR.exists():
|
||||
console.print(Panel(NOT_INIT_MESSAGE, title="数据目录不存在"))
|
||||
return
|
||||
|
||||
# 确认存在后再判断是否需要重新生成令牌
|
||||
if not reset:
|
||||
try:
|
||||
with open(file=DATA_DIR / "token.txt", mode="r") as f:
|
||||
token_ = f.read()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
if not token_:
|
||||
console.print("[yellow]重新生成令牌...[/yellow]")
|
||||
token_ = token_urlsafe(32)
|
||||
|
||||
msg = f"""
|
||||
🔐 [green] 令牌 - [/green]{token_}
|
||||
🔐 [green]有效期 - [/green]无限
|
||||
✅ WebUI 设计的令牌 cookie 有效期为一天,所以你每天都需要重新登录一次 WebUI,这并非令牌本身的有效期。
|
||||
"""
|
||||
|
||||
panel = Panel(msg, title="WebUI 令牌")
|
||||
|
||||
console.print(panel)
|
||||
|
||||
|
||||
@app.command()
|
||||
def run() -> None:
|
||||
"""
|
||||
运行 NJUPT Suan API。
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app()
|
||||
@@ -1,14 +1,30 @@
|
||||
[project]
|
||||
name = "njupt-suan-api"
|
||||
version = "0.1.1"
|
||||
version = "0.1.5"
|
||||
description = "API and MCP server for NJUPT infomation ~"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.13"
|
||||
license = "MIT"
|
||||
|
||||
authors = [
|
||||
{ name = "MangoFanFanw", email = "mangofanfanw@icloud.com" }
|
||||
]
|
||||
|
||||
maintainers = [
|
||||
{ name = "MangoFanFanw", email = "mangofanfanw@icloud.com" }
|
||||
]
|
||||
|
||||
classifiers = [
|
||||
"Development Status :: 3 - Alpha",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
]
|
||||
|
||||
dependencies = [
|
||||
"aiofiles>=25.1.0",
|
||||
"beautifulsoup4>=4.14.3",
|
||||
"ddddocr>=1.6.1",
|
||||
"fastapi>=0.135.3",
|
||||
"fastapi>=0.136.1",
|
||||
"fastmcp>=3.2.0",
|
||||
"loguru>=0.7.3",
|
||||
"mcp>=1.27.0",
|
||||
@@ -17,14 +33,32 @@ dependencies = [
|
||||
"rich>=15.0.0",
|
||||
"sqlalchemy>=2.0.49",
|
||||
"sqlmodel>=0.0.38",
|
||||
"typer>=0.24.2",
|
||||
"typer>=0.25.0",
|
||||
"uvicorn>=0.46.0",
|
||||
"watchfiles>=1.1.1",
|
||||
"websockets>=16.0",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://suan.mangofanfan.cn"
|
||||
Documentation = "https://suan.mangofanfan.cn"
|
||||
Repository = "https://github.com/mangofanfan/NJUPT-Suan-API.git"
|
||||
Changelog = "https://github.com/mangofanfan/NJUPT-Suan-API/blob/master/CHANGELOG.md"
|
||||
Issues = "https://github.com/mangofanfan/NJUPT-Suan-API/issues"
|
||||
|
||||
[project.scripts]
|
||||
suanapi = "manage:app"
|
||||
njupt-suan-api = "njupt_suan_api.manage:app"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.sdist]
|
||||
artifacts = ["src/njupt_suan_api/static/**"]
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/njupt_suan_api"]
|
||||
artifacts = ["src/njupt_suan_api/static/**"]
|
||||
|
||||
[tool.ruff]
|
||||
preview = true
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
from .__version__ import __version__ as __version__
|
||||
@@ -18,7 +18,7 @@ class Config:
|
||||
|
||||
async def load_json(self) -> None:
|
||||
"""
|
||||
从 Toml 配置文件中读取配置。
|
||||
异步从配置文件中读取配置。
|
||||
"""
|
||||
logger.debug("异步读取配置文件。")
|
||||
async with aiofiles.open(file=CONFIG_PATH, mode="r") as f:
|
||||
@@ -49,7 +49,7 @@ class Config:
|
||||
|
||||
def init_config(self) -> None:
|
||||
"""
|
||||
重新初始化 Toml 配置文件。这会重置所有配置。
|
||||
重新初始化配置文件。这会重置所有配置。
|
||||
"""
|
||||
logger.warning("初始化配置文件,这会重置所有配置。")
|
||||
self._doc.clear()
|
||||
@@ -59,7 +59,7 @@ class Config:
|
||||
|
||||
doc_system["host"] = "0.0.0.0"
|
||||
doc_system["port"] = 8000
|
||||
doc_system["reload"] = True
|
||||
doc_system["reload"] = False
|
||||
doc_system["public_host"] = "http://127.0.0.1:8000"
|
||||
|
||||
doc_schedule["playwright_headless"] = True
|
||||
@@ -78,7 +78,7 @@ class Config:
|
||||
|
||||
async def save_json(self) -> None:
|
||||
"""
|
||||
异步保存 Toml 配置文件。
|
||||
异步保存配置文件。
|
||||
"""
|
||||
logger.debug("异步保存配置文件。")
|
||||
async with aiofiles.open(file=CONFIG_PATH, mode="w") as f:
|
||||
@@ -7,7 +7,7 @@ from playwright.async_api import (
|
||||
async_playwright,
|
||||
)
|
||||
|
||||
from . import config
|
||||
from .config import config
|
||||
|
||||
|
||||
class PlayContextManager:
|
||||
@@ -1,5 +1,4 @@
|
||||
from njupt_api.baselib import PlayContextManager, logger
|
||||
|
||||
from ..baselib import PlayContextManager, logger
|
||||
from .exc import LoginError
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
from ddddocr import DdddOcr
|
||||
from playwright.async_api import Browser, BrowserContext, Page, Playwright
|
||||
|
||||
from njupt_api.baselib import PlayContextManager, logger
|
||||
|
||||
from ..baselib import PlayContextManager, logger
|
||||
from .createcourse import create_course_schedule
|
||||
from .exc import LoginError
|
||||
from .sso import SSO
|
||||
13
src/njupt_suan_api/cli/__init__.py
Normal file
13
src/njupt_suan_api/cli/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from .message import ALREADY_INIT_MESSAGE, INIT_STAGE_MESSAGE, NOT_INIT_MESSAGE, RUN_CHECK_MESSAGE, TOKEN_CHECK_MESSAGE
|
||||
from .path import DATA_DIR, TEMP_DIR, WORKSPACE_DIR
|
||||
|
||||
__all__ = [
|
||||
WORKSPACE_DIR,
|
||||
DATA_DIR,
|
||||
TEMP_DIR,
|
||||
NOT_INIT_MESSAGE,
|
||||
ALREADY_INIT_MESSAGE,
|
||||
RUN_CHECK_MESSAGE,
|
||||
TOKEN_CHECK_MESSAGE,
|
||||
INIT_STAGE_MESSAGE,
|
||||
]
|
||||
25
src/njupt_suan_api/cli/message.py
Normal file
25
src/njupt_suan_api/cli/message.py
Normal file
@@ -0,0 +1,25 @@
|
||||
NOT_INIT_MESSAGE = """
|
||||
❓ 当前目录或指定目录下[yellow]似乎还没有执行过初始化命令[/yellow]。
|
||||
❓ 你也许需要先执行 [green]suanapi init[/green] 。
|
||||
"""
|
||||
|
||||
ALREADY_INIT_MESSAGE = """
|
||||
❕ 当前目录或指定目录下[yellow]似乎已经执行初始化命令过[/yellow]。
|
||||
❕ 你也许需要先删除已经存在的 [blue]data[/blue] 和 [blue]temp[/blue] 目录。
|
||||
"""
|
||||
|
||||
RUN_CHECK_MESSAGE = """
|
||||
[bright_black]运行时的配置可能来自命令行参数、配置文件以及默认值,列在这里供你检查。[/bright_black]
|
||||
[cyan]主机名[/cyan] - [cyan]host[/cyan] - {host}
|
||||
[cyan]端口[/cyan] - [cyan]port[/cyan] - {port}
|
||||
[cyan]自动重启[/cyan] - [cyan]reload[/cyan] - {reload}
|
||||
[bright_black]NJUPT Suan API 会很快启动。使用 [green]Ctrl + C[/green] 以退出。[/bright_black]
|
||||
"""
|
||||
|
||||
TOKEN_CHECK_MESSAGE = """
|
||||
🔐 [green] 令牌 - [/green]{token}
|
||||
🔐 [green]有效期 - [/green]无限
|
||||
✅ WebUI 设计的令牌 cookie 有效期为一天,所以你每天都需要重新登录一次 WebUI,这并非令牌本身的有效期。
|
||||
"""
|
||||
|
||||
INIT_STAGE_MESSAGE = "[cyan]- {stage} / 3 -[/cyan] [bright_black]{message}[/bright_black]"
|
||||
5
src/njupt_suan_api/cli/path.py
Normal file
5
src/njupt_suan_api/cli/path.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from pathlib import Path
|
||||
|
||||
WORKSPACE_DIR = Path.cwd()
|
||||
DATA_DIR = WORKSPACE_DIR / "data"
|
||||
TEMP_DIR = WORKSPACE_DIR / "temp"
|
||||
@@ -1,8 +1,12 @@
|
||||
"""
|
||||
main.py 未来不再作为项目入口,日后可能会被删除。请参照 README 使用命令行作为入口,或直接使用 uvicorn 命令。
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from secrets import token_urlsafe
|
||||
|
||||
from njupt_api.baselib import config, logger
|
||||
from router import __version__
|
||||
from njupt_suan_api.api.baselib import config, logger
|
||||
from njupt_suan_api.router import __version__
|
||||
|
||||
DATA_DIR = Path.cwd() / "data"
|
||||
TEMP_DIR = Path.cwd() / "temp"
|
||||
@@ -10,7 +14,7 @@ TEMP_DIR = Path.cwd() / "temp"
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
with open(file=Path.cwd() / "njupt_api" / "art.txt", mode="r", encoding="utf-8") as f:
|
||||
with open(file=Path.cwd() / "api" / "art.txt", mode="r", encoding="utf-8") as f:
|
||||
print(f.read().format(__version__)) # noqa:T201
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
@@ -77,7 +81,7 @@ if __name__ == "__main__":
|
||||
host=host,
|
||||
port=port,
|
||||
reload=reload,
|
||||
reload_dirs=["njupt_api", "router"],
|
||||
reload_dirs=["api", "router"],
|
||||
access_log=False,
|
||||
log_level="critical",
|
||||
timeout_graceful_shutdown=2,
|
||||
185
src/njupt_suan_api/manage.py
Normal file
185
src/njupt_suan_api/manage.py
Normal file
@@ -0,0 +1,185 @@
|
||||
import subprocess
|
||||
import sys
|
||||
from secrets import token_urlsafe
|
||||
|
||||
import typer
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
|
||||
from njupt_suan_api.api.baselib import config
|
||||
from njupt_suan_api.cli import (
|
||||
ALREADY_INIT_MESSAGE,
|
||||
DATA_DIR,
|
||||
INIT_STAGE_MESSAGE,
|
||||
NOT_INIT_MESSAGE,
|
||||
RUN_CHECK_MESSAGE,
|
||||
TEMP_DIR,
|
||||
TOKEN_CHECK_MESSAGE,
|
||||
WORKSPACE_DIR,
|
||||
)
|
||||
from njupt_suan_api.router import __version__
|
||||
|
||||
console = Console()
|
||||
app = typer.Typer(
|
||||
name="NJUPT-Suan-API",
|
||||
help="NJUPT Suan API 部署与管理工具",
|
||||
rich_markup_mode="rich",
|
||||
no_args_is_help=True,
|
||||
)
|
||||
|
||||
|
||||
def version_callback(value: bool = False) -> None:
|
||||
if value:
|
||||
console.print(f"NJUPT Suan API [green]v.{__version__}[/green]")
|
||||
if __version__ == "dev":
|
||||
console.print("""
|
||||
[bright_black]显示的版本为 dev ?这是因为你正在从源代码运行 cli 入口(manage.py)。[/bright_black]
|
||||
[bright_black]从[green]已安装版本[/green]中运行 [green]suanapi --version[/green] 可以正确获取版本号。[/bright_black]""")
|
||||
raise typer.Exit
|
||||
|
||||
|
||||
@app.callback(invoke_without_command=True)
|
||||
def main(
|
||||
version: bool = typer.Option(
|
||||
False,
|
||||
"--version",
|
||||
"-v",
|
||||
help="显示版本号并退出,没有其他命令会被执行。",
|
||||
callback=version_callback,
|
||||
is_eager=True, # 优先处理,避免触发其他逻辑
|
||||
),
|
||||
) -> None:
|
||||
"""
|
||||
CLI 入口回调,所有子命令执行前都会经过这里。
|
||||
可以在这里放全局初始化(如日志级别、环境检查)。
|
||||
"""
|
||||
# 没有 --version 时就正常放行,继续执行子命令
|
||||
console.print(
|
||||
"[bright_black]NJUPT Suan API 仍然处于极早期的阶段。如果遇到任何问题,请告诉芒果帆帆喵![/bright_black]\n"
|
||||
)
|
||||
|
||||
|
||||
@app.command()
|
||||
def init(force: bool = typer.Option(False, "--force", "-f", help="强制初始化,可能导致问题,不建议使用。")) -> None:
|
||||
"""
|
||||
初始化 NJUPT Suan API [green]工作目录[/green]。(可能需要较长时间)
|
||||
|
||||
会在当前目录或指定目录下创建新文件,并尝试安装 playwright chromium。
|
||||
视网络情况,安装过程可能(几乎必然)需要较长时间。
|
||||
如果已存在 data 和/或 temp 目录,初始化会失败。你也可以强制初始化,但可能导致先前的数据丢失。
|
||||
|
||||
Raises:
|
||||
typer.Exit: 如果初始化失败,返回 1。
|
||||
"""
|
||||
if (DATA_DIR.exists() or TEMP_DIR.exists()) and not force:
|
||||
console.print(Panel(ALREADY_INIT_MESSAGE, title="工作目录已存在"))
|
||||
console.print("[bright_black]如果你想要强制初始化,使用 [green]suanapi init -f[/green] 命令。[/bright_black]")
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
# 1 创建 data 和 temp 目录
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
TEMP_DIR.mkdir(parents=True, exist_ok=True)
|
||||
console.print(INIT_STAGE_MESSAGE.format(stage=1, message="工作目录 data 和 temp 已创建。"))
|
||||
|
||||
# 2 初始化配置文件
|
||||
try:
|
||||
config.sync_load_json()
|
||||
except FileNotFoundError:
|
||||
config.init_config()
|
||||
config.sync_create_json()
|
||||
console.print(INIT_STAGE_MESSAGE.format(stage=2, message="已初始化配置并创建配置文件。"))
|
||||
else:
|
||||
console.print(INIT_STAGE_MESSAGE.format(stage=2, message="配置文件已存在,跳过配置初始化。"))
|
||||
|
||||
# 3 执行 uv run playwright install chromium
|
||||
console.print("[bright_black]即将安装 playwright 的 chromium,这可能是耗时最长的部分。[/bright_black]")
|
||||
cp3 = subprocess.run([sys.executable, "-m", "playwright", "install", "chromium"], cwd=WORKSPACE_DIR)
|
||||
if cp3.returncode != 0:
|
||||
console.print("[yellow]运行 playwright install chromuim 失败,双是什么原因呢?[/yellow]")
|
||||
raise typer.Exit(code=cp3.returncode)
|
||||
console.print(INIT_STAGE_MESSAGE.format(stage=3, message="已安装 playwright chromium。"))
|
||||
|
||||
console.print("[green]初始化完成。接下来可以执行 suanapi run 来启动 NJUPT Suan API。[/green]")
|
||||
console.print(
|
||||
"[bright_black]初始化只需要执行一次即可,以后即使更新 NJUPT Suan API,也无再需重复执行。[/bright_black]"
|
||||
)
|
||||
|
||||
|
||||
@app.command()
|
||||
def token(reset: bool = typer.Option(False, "--reload", "-r", help="强制重新生成令牌,即使令牌已存在。")) -> None:
|
||||
"""
|
||||
查看或重新生成[green]管理后端令牌[/green]。
|
||||
|
||||
需要先运行过 init 初始化目录。
|
||||
|
||||
Raises:
|
||||
typer.Exit: 如果未初始化,返回 1。
|
||||
"""
|
||||
token_ = None
|
||||
# 首先检查数据目录是否存在
|
||||
if not DATA_DIR.exists():
|
||||
console.print(Panel(NOT_INIT_MESSAGE, title="数据目录不存在"))
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
# 确认存在后再判断是否需要重新生成令牌
|
||||
if not reset:
|
||||
try:
|
||||
with open(file=DATA_DIR / "token.txt", mode="r") as f:
|
||||
token_ = f.read()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
if not token_:
|
||||
console.print("[yellow]重新生成令牌...[/yellow]")
|
||||
token_ = token_urlsafe(32)
|
||||
with open(file=DATA_DIR / "token.txt", mode="w") as f:
|
||||
f.write(token_)
|
||||
|
||||
panel = Panel(TOKEN_CHECK_MESSAGE.format(token=token_), title="WebUI 令牌")
|
||||
|
||||
console.print(panel)
|
||||
|
||||
|
||||
@app.command()
|
||||
def run(
|
||||
host: str | None = typer.Option(None, "--host", help="监听主机名,默认 0.0.0.0。"),
|
||||
port: int | None = typer.Option(None, "--port", help="监听端口,默认 8000。"),
|
||||
reload: bool | None = typer.Option(None, "--reload", "-r", help="在检测到代码变化时自动重启,默认 False。"),
|
||||
) -> None:
|
||||
"""
|
||||
运行 NJUPT Suan API。
|
||||
|
||||
可接收运行参数。若不提供,则尝试使用配置文件中的值,最后回退到默认值。
|
||||
|
||||
Raises:
|
||||
typer.Exit: 如果未初始化,返回 1。
|
||||
"""
|
||||
if host is None:
|
||||
host = config.get("system", "host", "0.0.0.0")
|
||||
if port is None:
|
||||
port = config.get("system", "port", 8000)
|
||||
if reload is None:
|
||||
reload = config.get("system", "reload", False)
|
||||
|
||||
if not DATA_DIR.exists() or not TEMP_DIR.exists():
|
||||
console.print(Panel(NOT_INIT_MESSAGE, title="工作目录不存在"))
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
token(False)
|
||||
|
||||
console.print(Panel(RUN_CHECK_MESSAGE.format(host=host, port=port, reload=reload), title="运行前检查"))
|
||||
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(
|
||||
"njupt_suan_api.server:app",
|
||||
host=host,
|
||||
port=port,
|
||||
reload=reload,
|
||||
access_log=False,
|
||||
log_level="critical",
|
||||
timeout_graceful_shutdown=2,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app()
|
||||
14
src/njupt_suan_api/router/__init__.py
Normal file
14
src/njupt_suan_api/router/__init__.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from .__version__ import __version__ as __version__
|
||||
from .admin_router import admin_router
|
||||
from .api_router import api_router
|
||||
from .mcp_router import mcp_app
|
||||
from .webui_router import ASSETS_DIR, webui_router
|
||||
|
||||
__all__ = [
|
||||
admin_router,
|
||||
api_router,
|
||||
mcp_app,
|
||||
webui_router,
|
||||
__version__,
|
||||
ASSETS_DIR,
|
||||
]
|
||||
@@ -6,11 +6,11 @@ from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import Session, delete, select
|
||||
|
||||
from njupt_api.baselib import config, logger
|
||||
from njupt_api.zhengfang import LoginError, course_list_serializer, jwxt
|
||||
from router.enhance.auth import verify_token
|
||||
from router.enhance.lib import AliasDto, ReturnDto, TestDto, get_session
|
||||
from router.enhance.model import Alias, Course
|
||||
from njupt_suan_api.api.baselib import config, logger
|
||||
from njupt_suan_api.api.zhengfang import LoginError, course_list_serializer, jwxt
|
||||
from njupt_suan_api.router.enhance.auth import verify_token
|
||||
from njupt_suan_api.router.enhance.lib import AliasDto, ReturnDto, TestDto, get_session
|
||||
from njupt_suan_api.router.enhance.model import Alias, Course
|
||||
|
||||
|
||||
class ValidateTokenDto(BaseModel):
|
||||
@@ -5,15 +5,10 @@ from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from njupt_api.baselib import logger
|
||||
from njupt_api.zhengfang import (
|
||||
course_dict_serializer,
|
||||
course_list_serializer,
|
||||
jwxt,
|
||||
)
|
||||
from njupt_api.zhengfang.exc import LoginError
|
||||
from router.enhance.lib import ReturnDto, ScheduleQueryDto, apply_enhance, get_session
|
||||
from router.enhance.model import Course
|
||||
from njupt_suan_api.api.baselib import logger
|
||||
from njupt_suan_api.api.zhengfang import LoginError, course_dict_serializer, course_list_serializer, jwxt
|
||||
from njupt_suan_api.router.enhance.lib import ReturnDto, ScheduleQueryDto, apply_enhance, get_session
|
||||
from njupt_suan_api.router.enhance.model import Course
|
||||
|
||||
TEMP_DIR = Path.cwd() / "temp"
|
||||
|
||||
8
src/njupt_suan_api/router/enhance/__init__.py
Normal file
8
src/njupt_suan_api/router/enhance/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from .lib import ReturnDto, apply_enhance
|
||||
from .model import create_db_and_tables
|
||||
|
||||
__all__ = [
|
||||
ReturnDto,
|
||||
apply_enhance,
|
||||
create_db_and_tables,
|
||||
]
|
||||
@@ -7,8 +7,8 @@ from typing import Sequence
|
||||
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from njupt_api.baselib import logger
|
||||
from router.enhance.model import Alias, engine
|
||||
from njupt_suan_api.api.baselib import logger
|
||||
from njupt_suan_api.router.enhance.model import Alias, engine
|
||||
|
||||
|
||||
def apply_alias(courses: list[dict]) -> list[dict]:
|
||||
@@ -4,7 +4,7 @@ from typing import Any, Generator, Literal
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import Session
|
||||
|
||||
from njupt_api.baselib import config
|
||||
from njupt_suan_api.api.baselib import config
|
||||
|
||||
from .alias import apply_alias
|
||||
from .model import engine
|
||||
@@ -3,7 +3,7 @@ from typing import Optional
|
||||
from sqlalchemy import JSON, Column
|
||||
from sqlmodel import Field, SQLModel, create_engine
|
||||
|
||||
sqlite_file_name = "data/njupt_api.db"
|
||||
sqlite_file_name = "data/njupt-api.db"
|
||||
sqlite_url = f"sqlite:///{sqlite_file_name}"
|
||||
|
||||
engine = create_engine(sqlite_url, connect_args={"check_same_thread": False})
|
||||
@@ -7,7 +7,7 @@ from uuid import uuid4
|
||||
|
||||
from playwright.async_api import ViewportSize
|
||||
|
||||
from njupt_api.baselib import PlayContextManager, logger
|
||||
from njupt_suan_api.api.baselib import PlayContextManager, logger
|
||||
|
||||
TEMP_DIR = Path.cwd() / "temp"
|
||||
|
||||
@@ -7,15 +7,10 @@ from mcp.types import ToolAnnotations
|
||||
from pydantic import Field
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from njupt_api.baselib import LoggingMiddleware, logger
|
||||
from njupt_api.zhengfang import (
|
||||
course_dict_serializer,
|
||||
course_list_serializer,
|
||||
jwxt,
|
||||
)
|
||||
from njupt_api.zhengfang.exc import LoginError
|
||||
from router.enhance.lib import ReturnDto, apply_enhance
|
||||
from router.enhance.model import Course, engine
|
||||
from njupt_suan_api.api.baselib import LoggingMiddleware, logger
|
||||
from njupt_suan_api.api.zhengfang import LoginError, course_dict_serializer, course_list_serializer, jwxt
|
||||
from njupt_suan_api.router.enhance.lib import ReturnDto, apply_enhance
|
||||
from njupt_suan_api.router.enhance.model import Course, engine
|
||||
|
||||
mcp = FastMCP("NJUPT API Suan")
|
||||
|
||||
@@ -4,8 +4,10 @@ import aiofiles
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
WEBUI_INDEX = Path.cwd() / "webui" / "dist" / "index.html"
|
||||
SCHEDULE_INDEX = Path.cwd() / "webui" / "dist" / "index-schedule.html"
|
||||
STATIC_DIR = Path(__file__).parent.parent / "static"
|
||||
WEBUI_INDEX = STATIC_DIR / "index.html"
|
||||
SCHEDULE_INDEX = STATIC_DIR / "index-schedule.html"
|
||||
ASSETS_DIR = STATIC_DIR / "assets"
|
||||
|
||||
webui_router = APIRouter(prefix="/webui")
|
||||
|
||||
@@ -12,20 +12,15 @@ from fastapi.staticfiles import StaticFiles
|
||||
from fastmcp.utilities.lifespan import combine_lifespans
|
||||
from watchfiles import awatch
|
||||
|
||||
from njupt_api.baselib import (
|
||||
from njupt_suan_api.api.baselib import (
|
||||
LogRecord,
|
||||
config,
|
||||
log_buffer,
|
||||
log_record_serialize,
|
||||
logger,
|
||||
)
|
||||
from router import __version__
|
||||
from router.admin_router import admin_router
|
||||
from router.api_router import api_router
|
||||
from router.enhance.lib import ReturnDto
|
||||
from router.enhance.model import create_db_and_tables
|
||||
from router.mcp_router import mcp_app
|
||||
from router.webui_router import webui_router
|
||||
from njupt_suan_api.router import ASSETS_DIR, __version__, admin_router, api_router, mcp_app, webui_router
|
||||
from njupt_suan_api.router.enhance import ReturnDto, create_db_and_tables
|
||||
|
||||
DATA_DIR = Path.cwd() / "data"
|
||||
|
||||
@@ -166,7 +161,7 @@ app.include_router(webui_router)
|
||||
app.mount("/mcp", mcp_app)
|
||||
app.mount(
|
||||
"/assets",
|
||||
StaticFiles(directory=Path.cwd() / "webui" / "dist" / "assets"),
|
||||
StaticFiles(directory=ASSETS_DIR),
|
||||
name="assets",
|
||||
)
|
||||
|
||||
20
uv.lock
generated
20
uv.lock
generated
@@ -333,7 +333,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "fastapi"
|
||||
version = "0.136.0"
|
||||
version = "0.136.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-doc" },
|
||||
@@ -342,9 +342,9 @@ dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4e/d9/e66315807e41e69e7f6a1b42a162dada2f249c5f06ad3f1a95f84ab336ef/fastapi-0.136.0.tar.gz", hash = "sha256:cf08e067cc66e106e102d9ba659463abfac245200752f8a5b7b1e813de4ff73e", size = 396607, upload-time = "2026-04-16T11:47:13.623Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5d/45/c130091c2dfa061bbfe3150f2a5091ef1adf149f2a8d2ae769ecaf6e99a2/fastapi-0.136.1.tar.gz", hash = "sha256:7af665ad7acfa0a3baf8983d393b6b471b9da10ede59c60045f49fbc89a0fa7f", size = 397448, upload-time = "2026-04-23T16:49:44.046Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/26/a3/0bd5f0cdb0bbc92650e8dc457e9250358411ee5d1b65e42b6632387daf81/fastapi-0.136.0-py3-none-any.whl", hash = "sha256:8793d44ec7378e2be07f8a013cf7f7aa47d6327d0dfe9804862688ec4541a6b4", size = 117556, upload-time = "2026-04-16T11:47:11.922Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/ff/2e4eca3ade2c22fe1dea7043b8ee9dabe47753349eb1b56a202de8af6349/fastapi-0.136.1-py3-none-any.whl", hash = "sha256:a6e9d7eeada96c93a4d69cb03836b44fa34e2854accb7244a1ece36cd4781c3f", size = 117683, upload-time = "2026-04-23T16:49:42.437Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -701,8 +701,8 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "njupt-suan-api"
|
||||
version = "0.1.1"
|
||||
source = { virtual = "." }
|
||||
version = "0.1.3"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiofiles" },
|
||||
{ name = "beautifulsoup4" },
|
||||
@@ -727,7 +727,7 @@ requires-dist = [
|
||||
{ name = "aiofiles", specifier = ">=25.1.0" },
|
||||
{ name = "beautifulsoup4", specifier = ">=4.14.3" },
|
||||
{ name = "ddddocr", specifier = ">=1.6.1" },
|
||||
{ name = "fastapi", specifier = ">=0.135.3" },
|
||||
{ name = "fastapi", specifier = ">=0.136.1" },
|
||||
{ name = "fastmcp", specifier = ">=3.2.0" },
|
||||
{ name = "loguru", specifier = ">=0.7.3" },
|
||||
{ name = "mcp", specifier = ">=1.27.0" },
|
||||
@@ -736,7 +736,7 @@ requires-dist = [
|
||||
{ name = "rich", specifier = ">=15.0.0" },
|
||||
{ name = "sqlalchemy", specifier = ">=2.0.49" },
|
||||
{ name = "sqlmodel", specifier = ">=0.0.38" },
|
||||
{ name = "typer", specifier = ">=0.24.2" },
|
||||
{ name = "typer", specifier = ">=0.25.0" },
|
||||
{ name = "uvicorn", specifier = ">=0.46.0" },
|
||||
{ name = "watchfiles", specifier = ">=1.1.1" },
|
||||
{ name = "websockets", specifier = ">=16.0" },
|
||||
@@ -1464,7 +1464,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "typer"
|
||||
version = "0.24.2"
|
||||
version = "0.25.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-doc" },
|
||||
@@ -1472,9 +1472,9 @@ dependencies = [
|
||||
{ name = "rich" },
|
||||
{ name = "shellingham" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/83/b8/9ebb531b6c2d377af08ac6746a5df3425b21853a5d2260876919b58a2a4a/typer-0.24.2.tar.gz", hash = "sha256:ec070dcfca1408e85ee203c6365001e818c3b7fffe686fd07ff2d68095ca0480", size = 119849, upload-time = "2026-04-22T17:45:34.413Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7b/27/ede8cec7596e0041ba7e7b80b47d132562f56ff454313a16f6084e555c9f/typer-0.25.0.tar.gz", hash = "sha256:123eaf9f19bb40fd268310e12a542c0c6b4fab9c98d9d23342a01ff95e3ce930", size = 120150, upload-time = "2026-04-26T08:46:14.767Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/39/d1/9484b497e0a0410b901c12b8251c3e746e1e863f7d28419ffe06f7892fda/typer-0.24.2-py3-none-any.whl", hash = "sha256:b618bc3d721f9a8d30f3e05565be26416d06e9bcc29d49bc491dc26aba674fa8", size = 55977, upload-time = "2026-04-22T17:45:33.055Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/72/193d4e586ec5a4db834a36bbeb47641a62f951f114ffd0fe5b1b46e8d56f/typer-0.25.0-py3-none-any.whl", hash = "sha256:ac01b48823d3db9a83c9e164338057eadbb1c9957a2a6b4eeb486669c560b5dc", size = 55993, upload-time = "2026-04-26T08:46:15.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -35,9 +35,10 @@ export default defineConfig({
|
||||
input: {
|
||||
index: path.resolve(__dirname, 'index.html'),
|
||||
'index-schedule': path.resolve(__dirname, 'index-schedule.html'),
|
||||
}
|
||||
},
|
||||
},
|
||||
outDir: "../src/njupt_suan_api/static"
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
|
||||
Reference in New Issue
Block a user