Skip to main content
Glama
README.md
# danxiapi

**复旦大学校园 + 论坛 API 框架** —— 从 [DanXI-Dev/DanXi](https://github.com/DanXI-Dev/DanXi)
抽取已验证的接口,为**智能体(LLM Function Calling / MCP)**与二次开发提供
易调用、易扩展的 Python 接口。

四种形态,同一个内核:

| 形态 | 安装 | 入口 |
|---|---|---|
| Python SDK(核心) | `pip install danxiapi` | `DanXiClient` / `AsyncDanXiClient` |
| CLI | `pip install danxiapi[cli]` | `danxi` |
| HTTP 网关 | `pip install danxiapi[gateway]` | `danxi serve` |
| MCP Server | `pip install danxiapi[mcp]` | `danxi mcp` |

全要:`pip install "danxiapi[all]"`。

## 能力一览

- **统一认证**:UIS 传统登录 / Neo 新一代认证(RSA)/ 树洞 JWT,自动探测 2FA 与验证码
- **WebVPN(aTrust 2.0)**:`login_webvpn()` 走 `vpn.fudan.edu.cn` 的 OAuth2 授权码流程,
  复用同一套 Neo 认证——旧的 AES-CFB 地址改写方案已随 `webvpn.fudan.edu.cn` 退役
- **树洞(FDU Hole)**:分区、树洞、楼层、搜索、收藏/订阅、消息、AI 摘要
- **教务**:学期、课表、成绩、GPA(两步 join)、考试安排、学生身份
- **校园生活**:宿舍电费、一卡通余额与流水、食堂拥挤度、图书馆拥挤度、教务公告
- **课评(DanKe)**、eHall 身份、研究生课表/成绩(优先级较低)
- **优雅退化**:图书馆 / 空教室 / WebVPN 等只在校园网内应答的能力,会在发请求前
  探测可达性,不可达时抛 `FeatureUnavailable`,绝不 hang、绝不返回假数据
- **二次验证不绕过**:`my.fudan.edu.cn` 的食堂拥挤度等接口服务端要求增强认证。
  SDK 会抛出 `TwoFactorRequired` 并附 `manual_login_url`——给出可手工登录的链接,
  而不是假装返回空列表,也不是尝试程序化破解

## 快速开始

```python
from danxiapi import DanXiClient

with DanXiClient() as dx:
    dx.login_uis()                     # 从 .env 读 USERID / PASSWORD
    dx.login_forum()                   # 默认 <学号>@m.fudan.edu.cn

    for course in dx.get_timetable():  # 本学期课表
        print(course.name, course.teacher, course.classroom)

    print(dx.get_gpa().overall_gpa)

    for floor in dx.search_floors("食堂"):
        print(floor.content[:80])
```

异步版本接口完全一致:

```python
async with AsyncDanXiClient() as dx:
    await dx.login_uis()
    courses = await dx.get_timetable()
```

> **写操作安全**:发帖 / 回复 / 点赞 / 举报全部默认 `dry_run=True`。
> 必须显式传 `dry_run=False` 才会真正发送。

## CLI

```bash
cp .env.example .env          # 填入学号与密码
danxi login --forum           # 登录 SSO(与树洞)
danxi whoami                  # 姓名 / 院系 / 专业 / 树洞状态
danxi timetable | grades | gpa | exams
danxi elec                    # 宿舍电费
danxi ecard balance | records # 一卡通余额 / 消费记录
danxi canteen                 # 食堂拥挤度(服务端要求 2FA,见下)
danxi library                 # 图书馆拥挤度(仅校园网)
danxi notices                 # 教务公告
danxi forum divisions | list | search
danxi config doctor           # 主机可达性矩阵 + 凭据诊断
```

所有命令支持 `--json`,输出可直接被脚本消费;列表输出带表格渲染。
根级选项 `--verbose` / `-v` 会附上上游 URL 与响应片段,方便排查改版问题
(注意它要写在子命令之前:`danxi -v canteen`)。

### 需要二次验证的接口

`my.fudan.edu.cn` 的食堂拥挤度与电费历史在服务端开启了增强认证,仅凭账号密码
无法获取。SDK 不会猜测、也不会返回空列表冒充成功,而是直接报错并给出链接:

```
╭── TwoFactorRequired (exit 11) ─────────────────────────╮
│ 服务要求增强认证(二次验证),无法仅凭账号密码登录……   │
│ 请在浏览器中打开下面的链接,完成验证码/二次验证后重试  │
│ https://my.fudan.edu.cn/simple_list/stqk               │
╰────────────────────────────────────────────────────────╯
```

这是**预期行为**:CLI 退出码 11,MCP 信封里是
`{"ok": false, "error": {"type": "TwoFactorRequired", "manual_login_url": ...}}`,
网关返回 403。在浏览器里完成一次该服务的登录后会话即生效。

### 只在校园网内可用的接口

图书馆拥挤度(`mlibrary.fudan.edu.cn`)与空教室(`10.64.130.6`)只对校园网应答。
SDK 在**发请求之前**并发探测这些主机:不可达时立即抛 `FeatureUnavailable`,
而不是让调用方卡在 socket 超时上。探测结果带缓存,`danxi config doctor` 可查看。

> 探测会兼容 `ALL_PROXY=socks://...` 环境:代理方案不合法时(未装 socksio)
> 自动改用直连重试一次,而不是把代理配置错误误报成「主机不可达」。

## HTTP 网关

```bash
danxi serve --port 8000       # http://127.0.0.1:8000/docs
```

会话是**不透明 id**(`secrets.token_urlsafe`),凭据**只存在内存**,`/logout`
与 TTL 过期即刻销毁。登录后用 `Authorization: Bearer <session_id>` 或
`HttpOnly` cookie 调用其余接口:

```bash
SID=$(curl -s localhost:8000/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"user_id":"...","password":"..."}' | jq -r .session_id)

curl -s localhost:8000/academic/gpa      -H "Authorization: Bearer $SID"
curl -s localhost:8000/campus/electricity -H "Authorization: Bearer $SID"
```

- `--read-only`:**在路由注册层面**禁掉论坛写接口(403 拒绝,而非 404),
  公开演示时用
- 限流(slowapi):`/auth/login` 与论坛写接口最严;`--disable-rate-limit` 可关
- `GET /system/features`:报告当前网络探测到的可用能力(`library` / `webvpn` /
  `empty_classroom`)
- 会话 TTL:空闲 30 分钟 / 绝对 8 小时,均可通过启动参数覆盖

## MCP Server

```bash
danxi mcp                     # stdio,凭据来自 USERID/PASSWORD 环境变量
```

挂到 Claude Code / Cursor 后,23 个工具直接可用:`login_fudan`、
`get_timetable`、`get_grades`、`get_gpa`、`get_exam_arrangement`、
`get_electricity`、`get_ecard_balance`、`get_ecard_records`、
`get_canteen_crowdedness`、`get_library_crowdedness`、`list_notices`、
`list_divisions`、`list_holes`、`get_hole`、`get_floors`、`search_floors`、
`get_ai_summary`、`list_messages`、`create_floor`、`danke_search`、
`danke_reviews` ……

约定:

- 每个工具返回 `{"ok": true, "data": ...}` 或
  `{"ok": false, "error": {"type": "TwoFactorRequired", "message": ...,
  "manual_login_url": ...}}`。**失败会在协议层置 `isError`**,宿主智能体不会
  把「调用失败」当成「调用成功」。
- `TwoFactorRequired` / `CaptchaRequired` 携带 `manual_login_url`:需要人工在
  浏览器完成认证,不要盲目重试。`get_canteen_crowdedness` 未完成 2FA 时就是
  这个信封,而不是空列表。
- `FeatureUnavailable`(如 `get_library_crowdedness` 在校外)同样是错误信封:
  「没有数据」和「不在校园网」对智能体是两回事。
- 写工具默认**不注册真实行为**:需要 `DX_MCP_ALLOW_WRITES=1` 启动服务端,
  **并且**调用时传 `confirm=True`,缺一不可。
- `get_ai_summary` 是上游 LLM 生成的内容,可能幻觉,不可作为事实引用。

## 环境变量

| 变量 | 说明 |
|---|---|
| `USERID` / `PASSWORD` | 学号与 UIS 密码(等价于 `DX_USERID` / `DX_PASSWORD`) |
| `DX_FORUM_EMAIL` | 树洞登录邮箱,默认 `<学号>@m.fudan.edu.cn` |
| `DX_LIVE` | 设为 `1` 才跑真网络测试 |
| `DX_LIVE_ALLOW_WRITES` | 设为 `1` 才允许真网络写测试 |
| `DX_MCP_ALLOW_WRITES` | 设为 `1` 才注册 MCP 写工具 |
| `DX_GATEWAY_READ_ONLY` | 设为 `1` 启动只读网关 |
| `DX_GATEWAY_ADMIN_KEY` | 守卫 `/system/debug` 的密钥;不设则该路由不注册 |

## 设计要点

- **一次实现、同步异步双开**:所有请求逻辑(cookie / 重定向 / 登录队列 / token 刷新)
  写成同步的 `httpx.BaseTransport`,同时挂到 `httpx.Client` 与 `httpx.AsyncClient`
- **手动跟随重定向**:SSO ticket 只能在中途拦截,自动重定向会丢 cookie
- **cookie epoch 守卫**:并发登录时的旧 Set-Cookie 不会覆盖新会话
  (对齐 DanXi issue #701 的修复)
- **结构化数据**:全部 pydantic v2 模型,抓取型模型带 `raw` 逃生口与解析容错;
  一卡通流水按**表头解析**而非按列号切片,复旦改列序时会变成解析失败而不是
  静默读错金额
- **日志脱敏**:`Authorization` / cookie / 密码一律不打明文
- **核心包零依赖适配层**:`import danxiapi` 不会传递引入 typer / fastapi / mcp
  (有单测在子进程里屏蔽这些模块强制保证)

## 测试

```bash
make test            # 离线单测 + 集成(零网络,默认)
make test-live       # 需 DX_LIVE=1 与 .env 凭据
```

三层标记,成本递增:

- `unit`(离线):纯逻辑 —— RSA 往返、aTrust OAuth2 授权码链路、cookie epoch 竞态、
  重定向 walker、pydantic 解析、MCP 信封约定、可达性探测的代理降级
- `integration`(离线):`respx` 在 transport 层打桩,跑完整的
  SessionEngine → transport → 解析链路
- `live`(显式开启):真网络;写操作还要 `DX_LIVE_ALLOW_WRITES=1`

离线测试在 `conftest.py` 层面 patch 掉 `httpx.Client.send`,对 `*.fduhole.com`
的非 GET 请求直接抛异常 —— **结构上不可能**从单测误发真实论坛内容。
`tests/golden/` 钉死抓取契约:复旦改版导致解析漂移时会构建失败,而不是静默返回 `[]`。

## 许可证

GPL-3.0-only,与 DanXi 上游保持一致。见 [LICENSE](LICENSE)。