写代码时的具体约定,按需阅读。总的原则见 AGENTS.md;表结构的约定见数据表 · 约定,接口的约定见接口 · 约定。
分两个部署单元,各自按领域分模块;同一件事的接口、业务逻辑、查询和测试放在一起,改一个功能通常只动一个目录。
web/ 网站,写法照鼠洞
app/ 页面(React Router);routes/ 是路由模块,components/ 是共用的组件,api.server.ts 让页面在进程内调接口
server/
main.ts 进程入口:执行迁移,把 Hono 应用和 React Router 接在一起
app.ts 组装 Hono 应用:健康检查和 /api/v1;接口测试直接对它发请求
modules/ 业务模块,一个领域一个目录:routes.ts(接口)、service.ts(业务逻辑)和测试
platform/ 和业务无关的基础设施:环境变量、数据库、迁移、错误格式、日志、权限、定时任务
db/migrations/ SQL 迁移
db/types.ts kysely-codegen 从数据库生成的类型
openapi/crawler.json 抓取节点接口的 OpenAPI 文档,由 `pnpm openapi` 生成
scripts/ 由 Node 直接运行的命令
test/ Vitest 的全局准备:测试用的数据库;假的鼠洞,开发版也用它
crawler/ 抓取节点
src/crawler/ 一个领域一个包,测试和被测的文件放在一起,命名为 *_test.py
crawl/fetch.py 所有对来源网站的请求都经过这里
crawl/website.py 网站接口的客户端
crawl/rounds.py 一个来源的一轮:列表、初筛、抓正文、抽取、交结果;各步由 Steps 传进来
crawl/contract.py 网站接口的 Pydantic 模型,由 `uv run datamodel-codegen` 从 openapi/crawler.json 生成
check.sh 全部检查
模块:
| 部署单元 | 模块 | 管什么 |
|---|---|---|
| 网站 | sources |
学校、院校清单、来源和配置、试抓、求收录 |
ingest |
收抓取节点交来的结果:通知和快照、归并和版本、抽取拿不准等人审的条目、抓取看板 | |
programs |
项目、字段、已核对、学院标签、我的报名 | |
accounts |
鼠洞登录、会话、个人令牌、权限 | |
reminders |
订阅、关注、邮件、群汇总 | |
review |
反馈、修改申请、修改记录和撤销、争议、操作日志 | |
agents |
MCP 服务和 /llms.txt |
|
| 抓取节点 | crawl |
领活、抓列表页和正文、下载附件、交结果 |
parse |
从网页和附件里取出文字、链接、附件和日期写法,文字识别 | |
screening |
初筛:调用判别接口 | |
extraction |
抽取和证据校验:调用 LLM |
- 网站和抓取节点只通过抓取节点接口交接:不共享数据库,不互相引用代码。接口的类型由网站的定义生成,抓取节点不手写(总体设计 §5.1):改了接口的 Zod 定义,运行
pnpm openapi和uv run datamodel-codegen,两边的 CI 都检查生成的文件是最新的。 - 抓取节点交的请求里,没有的值不写这个键,不传
null:Zod 用.optional(),抓取节点用 Pydantic 的model_dump(mode="json", exclude_none=True)。网站返回的空值写成null;引用了命名 schema 的可空字段写成z.union([Schema, z.null()]),.nullable()会把空值并进那个 schema 本身。 - 网站里依赖只能单向:页面 → 接口 → 服务 → 数据库。网页、API 和 MCP 都调用服务导出的函数,不各写一套规则。
- 页面的 loader 和 action 用
context.get(apiContext)拿到的 hc 客户端调接口,用它带类型的方法($get、$post、$patch),不手拼请求,也不再校验接口的返回。权限和校验以接口为准:页面不另判权限;表单的值用接口的同一个 Zod schema 转成有类型的值再提交,这和接口是同一套规则;接口返回的错误抛给 ErrorBoundary,或者显示在表单上。 - 模块之间只通过对方服务导出的函数协作,每张表只由一个模块写入;读别的模块的表不受限制,查询里可以直接 join。模块之间不循环依赖:A 调了 B 的服务,B 就不再调 A 的。
- 审核页上的东西分属两个模块:抽取拿不准的条目归
ingest,处理时要归并、改版本;修改申请、争议和最近修改归review。页面分别调两边的接口。 - 认证、令牌和 CSRF 这些全站的规矩在
app.ts统一处理,权限规则在platform/access.ts,模块里不各挂中间件再补一遍。 - 其余的写法(服务和事务、纯函数、文件命名)照鼠洞的开发约定 §1。
- 接入来源时按通用抓取 → 配置 → 浏览器抓取 → 专用适配器的顺序试,前一种能抓就不用后一种(总体设计 §7.2)。
- 建站系统的模板放在代码里,和它的测试在一起;单个来源的配置存在网站的数据库里,在管理页面改,抓取节点领活时一起拿到。
- 所有对来源网站的请求都经过
crawl/fetch.py的Fetcher,整个进程共用一个,总体设计 §7.3的规矩由它保证:UA 写明鼠钟;同一个网站一次一个请求,多个任务同时抓也一样;跳转的每一步单独发,跳到别的网站按那个网站的间隔排队。浏览器抓取打开页面前,也要先过这些规矩。 - 浏览器抓取启动时关掉 Chromium 的自动化标记(
--disable-blink-features=AutomationControlled),UA 用浏览器自己的,把HeadlessChrome写成Chrome,末尾加上鼠钟的标识;瑞数这类脚本挑战两处都检查(总体设计 §5.2)。不冒充别的浏览器和系统。 - 一轮里同一个网站共用一个无痕会话,抓完就关:脚本挑战设的 Cookie 在会话里有效,后面的页面不用再过挑战;不同网站、不同轮次之间不共享 Cookie。同时开的浏览器页面有上限,多的排队;拒绝弹窗和下载,附件由程序另外下载;不加载图片、字体和音视频,省对方的流量,也省内存。
- 解析和模型分工:
parse用程序取出能确定的东西,模型只做判断(总体设计 §8.2)。解析不猜:判断不了的交给模型或人,不硬配。 - 判别模型和 LLM 的提示词放在调用它的代码旁边。输出都按固定的结构解析,解析不了的当失败处理。
- 每一轮抓取都交一份抓取记录,失败也交,写明原因;不在一轮里反复重试。各步抓不成时抛
FetchError(Fetcher抛的)或StepError(写明失败类别,比如http_status和「HTTP 403」),一轮的流程把它记进抓取记录;别的异常是 bug,照常抛出。
- 时刻存成带时区的时间戳;通知里写的"某天几点"一律按北京时间理解,时间值和换算的规则见总体设计 §8.2。
- 提醒时间(截止前 3 天的上午 9 点)按北京时间的日期算。
- 页面上显示时间值和项目的状态用
app/time.ts的formatTime、formatWindow和statusOf,照原文的写法,不显示换算出的时刻。 - 测试里固定时区和当前时间,覆盖跨午夜、跨年、没写年份、只有日期、「24 时」「X 日前」「前后」和只写到旬或月的写法。
- 抓取记关键事实:来源和网址、抓取方式、状态码、跳转、耗时、失败类别,以及模型的名称、用量和耗时。每页只写几条,不逐个链接地记。
- 不写正文、附件内容、模型的输入和输出、用户的数据和任何密钥;外部服务返回的错误可能夹带这些内容,只记失败类别。公开网页的网址可以写。
- 一轮抓取的日志都带同一个抓取记录的编号,方便从抓取看板查到对应的日志。
- 普通的逻辑用单元测试:新通知和变化的识别、时间规范化、证据校验、归并、重复交结果、权限、订阅上限、提醒时间、争议计数、撤销等。
- 网站的测试连真实的 PostgreSQL:开始前迁移一个模板库,每个测试文件复制一个自己的库,可以并行跑。
- 抓取节点的测试不连外网:请求用 httpx 的
MockTransport,时间用假的时钟,不真的等待(见crawl/fetch_test.py)。 - 假的鼠洞:
test/fake-mousehole.ts照鼠洞的接口实现了授权、换令牌和/me,带几个各种权限的假用户。测试里fetch都转给它,请求别的网站直接报错;开发版的MOUSEHOLE_URL指向自己时(.env.example就是这样),它挂在/fake-mousehole下,点「登录」可以挑用哪个假用户。 - 测试数据:保存下来的列表页、固定的模型响应、假的邮件服务器和假的鼠洞授权方。保存的页面只用来测解析,不放正文和附件;某个网站出过问题的,把那一页存下来做测试数据。
- 判别和抽取的效果不写成单元测试,用标注集评估(总体设计 §8.5)。
界面、文档、代码里同一个概念用同一个词。
| 中文 | 英文 | 说明 |
|---|---|---|
| 学校 | school | |
| 学院 | college | 泛指招生单位,包括学院、研究所、研究生院 |
| 院校清单 | — | 全部学校和学院,没接入来源的也在里面 |
| 来源 | source | 一个通知列表页 |
| 试抓 | probe | 接入来源前先抓一次看看 |
| 抓取 | crawl | 一轮抓取叫 crawl run |
| 抓取节点 | crawler | 跑抓取、初筛和抽取的部署单元 |
| 通知 | notice | 官网上的一条通知 |
| 快照 | snapshot | 某次抓到的正文和附件 |
| 初筛 | screening | 判别接口看标题 |
| 抽取 | extraction | LLM 读正文、按字段输出 |
| 证据 | evidence | 原文里支撑某个字段的那句话 |
| 项目 | program | 一个学院面向某一届的一次招生活动,分批报名的每批算一个 |
| 届 | cohort | 本科毕业、研究生入学的那一年 |
| 字段 | field | 项目的一项信息,比如报名截止 |
| 版本 | version | 一条通知归到某个项目上,就是这个项目的一个版本 |
| 作用 | role | 通知对项目的作用:报名 call、补充 update、名单 result |
| 时间值 | time value | 原文里的时间,写到哪一步记到哪一步 |
| 归并 | — | 把通知归到项目,新建项目或者加一个版本 |
| 类型 | kind | 冬令营 winter_camp、夏令营 summer_camp、预推免 pre_recommendation、九推 september_round |
| 已核对 | verified | |
| 有争议 | disputed | |
| 订阅 | subscription | 订阅项目,截止前提醒 |
| 关注 | follow | 关注学院,有新通知就提醒 |
| 我的报名 | application | 进度叫 stage,备注叫 note |
| 学院标签 | college tag | 公开的,贴在学院上 |
| 反馈 | feedback | 包括修改申请 |
| 修改申请 | edit request | 给出改成什么的反馈 |
| 修改 | edit | |
| 依据 | basis | 人工修改和修改申请附上的原文链接或截图;和抽取的证据不是一回事 |
| 撤销 | revert | |
| 求收录 | inclusion request | 同求叫 vote |
| 抓取看板 | crawl dashboard | |
| 审核页 | review | |
| 个人令牌 | token | 抓取节点用的叫 crawler token |
| 群汇总 | summary |