Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 27 additions & 25 deletions docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,11 +79,11 @@
| --- | --- | --- |
| 网站 | 和鼠洞同一套:TypeScript 7、Node 26、React Router 8、Hono 和 `@hono/zod-openapi`、Kysely、Tailwind CSS 和 shadcn/ui;pnpm、oxlint、Prettier、Vitest、Playwright | 登录、邮件、界面风格和写法都能照搬。选型依据和框架的实测见[鼠洞的总体设计 §5.2](https://github.com/mousebaoyan/mousehole/blob/main/docs/design.md#52-选型依据) |
| 数据库 | 服务器上 1Panel 的 PostgreSQL 18 | 和鼠洞共用实例,不加新服务 |
| 抓取节点 | Python 3.14,uv 管依赖;Ruff 做 lint 和格式化,Pyright 的严格模式做类型检查,pytest 做测试 | 浏览器抓取、正文提取、PDF 和 Office 解析、中文的文字识别,Python 的库最全,代价是多一套工具链。3.15 刚发布,文字识别要用的 onnxruntime 还没有它的安装包。Pyright 和 VS Code 的 Pylance 是同一个引擎,编辑器里看到的错误和 CI 一致 |
| 抓取节点 | Python 3.14,uv 管依赖;Ruff 做 lint 和格式化,Pyright 的严格模式做类型检查,pytest 做测试 | 浏览器抓取、正文提取、PDF 和 Office 解析,Python 的库最全,代价是多一套工具链。Pyright 和 VS Code 的 Pylance 是同一个引擎,编辑器里看到的错误和 CI 一致 |
| 抓取 | httpx(异步)请求网页,Playwright 驱动浏览器(Chromium 的无头精简版),lxml 按 CSS 或 XPath 取出列表 | 各自领域的主流库 |
| 正文、附件和文字识别 | 正文按建站系统的模板用 lxml 取;pypdf 读 PDF,python-docx 读 `.docx`,legacy-doc 读老的 `.doc`,openpyxl 读 `.xlsx`,xlrd 读 `.xls`;图片和扫描的 PDF 用 RapidOCR 识别文字(PP-OCRv6 small,设置见第 5.2 节),扫描的 PDF 由 pypdf 取出每页的图片 | 都关在 `parse` 模块里,换了不影响别处。正文和附件用真实的通知和附件比较后定,结果见第 5.2 节。不用 AGPL 许可的 PyMuPDF |
| 正文和附件 | 正文按建站系统的模板用 lxml 取;pypdf 读 PDF,python-docx 读 `.docx`,legacy-doc 读老的 `.doc`,openpyxl 读 `.xlsx`,xlrd 读 `.xls`;图片和扫描的 PDF 交给 LLM 读,扫描的 PDF 由 pypdf 取出每页的图片,Pillow 把长图切成小块 | 都关在 `parse` 模块里,换了不影响别处。正文和附件用真实的通知和附件比较后定,结果见第 5.2 节。不用 AGPL 许可的 PyMuPDF |
| 接口的类型 | 网站用 Zod 写接口的定义,生成 OpenAPI 文档;抓取节点从这份文档生成 Pydantic 模型 | 两边语言不同,接口的类型也只有一个来源。抓取节点用 Pydantic 校验网站的返回和模型的输出 |
| 判别接口和 LLM | System One 的 SemIf;DeepSeek 官方接口的 V4.1 Flash(`deepseek-flash`),按编号抽取 | 判别见第 8.1 节,抽取比较见第 8.5 节 |
| 判别接口和 LLM | System One 的 SemIf;DeepSeek 官方接口的 V4.1 Flash(`deepseek-flash`),按编号抽取,也读图片里的文字 | 判别见第 8.1 节,抽取比较见第 8.5 节,读图和本地文字识别的比较见第 5.2 节 |

### 5.2 实测

Expand Down Expand Up @@ -117,31 +117,31 @@ CPU 不是问题,要省的是带宽,做法见第 14 节。
| Excel | openpyxl 读 `.xlsx`,xlrd 读 `.xls`,样本里各 1 个都没问题 |
| 下载 | 7 次拿到的不是文件,是博达的「请输入验证码下载附件」页面(北科计通、厦大、电子科大信通、国科大杭州等);先打开通知页、带着来源页下载也一样,是网站的设置。按第 7.2 节不破解:附件照样列出,文字留空,不存文件 |

**文字识别**:从 `data/sources.csv` 的 9 个来源各取一张通知图片(截图、海报、长图、密集的日程表),加一份渲染成图片的 4 页 PDF,在限成 4 核、3600 MiB、断网的 Linux 容器里用 RapidOCR 3.10.0 依次识别。只看日期、时刻、网址和邮箱:同一行里和原文一字不差才算对,一张里错一项就不算全对;有这些信息的共 12 张(页)。
**图片里的文字**(2026-11-14):从 `data/sources.csv` 的 9 个来源各取一张通知图片(截图、海报、长图、密集的日程表),比较本地的文字识别 RapidOCR 和让 DeepSeek 的 `deepseek-flash` 直接读图。只看日期、时刻、网址和邮箱:同一行里和原文一字不差才算对,一张里错一项就不算全对;9 张共 113 项。

| PP-OCRv6 | 设置 | 关键信息全对 | 13 张(页)总耗时 | 峰值内存 | 模型文件 |
| --- | --- | ---: | ---: | ---: | ---: |
| tiny | 整图缩到 2000 | 2/12 | 85.19 秒 | 784.1 MiB | 6.58 MiB |
| small | 整图缩到 2000 | 4/12 | 289.04 秒 | 1151.2 MiB | 30.28 MiB |
| medium | 整图缩到 2000 | 4/12 | 842.72 秒 | 2077.9 MiB | 132.88 MiB |
| tiny | 整图缩到 4000 | 3/12 | 137.21 秒 | 1694.2 MiB | 6.58 MiB |
| small | 整图缩到 4000 | 5/12 | 354.25 秒 | 2058.8 MiB | 30.28 MiB |
| medium | 整图缩到 4000 | 7/12 | 1112.38 秒 | 3305.9 MiB | 132.88 MiB |
| small | 缩到 2000 找行,从原图切行,每批 1 行 | 7/12 | 240.87 秒 | 726.0 MiB | 30.28 MiB |
| 读法 | 认对 | 全对的图片 | 每张耗时 | 每张 token(输入/输出) | 每张费用(高峰时段) |
| --- | ---: | ---: | ---: | ---: | ---: |
| RapidOCR | 94/113 | 4/9 | 约 18 秒 | — | — |
| DeepSeek,整张送 | 95/113 | 4/9 | 3.7–9.6 秒 | 约 1070/1120 | 约 0.0017 美元 |
| DeepSeek,切块送 | 111/113 | 7/9 | 3.7–8.1 秒 | 约 6260/1140 | 约 0.0032 美元 |

- RapidOCR 用几种设置里最好的一种:PP-OCRv6 small,在缩小的图上找行,从原图上一行一行切下来认。耗时和内存是在限成 4 核、3600 MiB 的 Linux 容器里测的,一次认一张,峰值约 750 MiB;更大的 medium 模型单独就要占 3.3 GB,抓取节点放不下。
- DeepSeek 会把每张图缩到约 1300×1300 像素以内(最多 1024 个 token),长图缩下去字就太小。切块送:尽量在没有字的行把长图切开,每块不超过约 1300×1300 像素,按从上到下的顺序放在一次请求里,说明是同一张图切开的几段。提示词要求一字不差地照抄,不改字、不纠错、不补全,看不清的写 □;温度 0,不开思考。费用按官方价格和实际用量算,低谷时段减半。

三种读法错的样子不一样:

选 small,在缩到 2000 的图上找行、从原图上切行识别,一次认一行:默认把整张图缩到 2000 再识别,长截图的字太小;把整张图放到 4000 仍少两张全对,检测还要多占 1GB 以上;medium 在三档整图方案里最准,但单独识别就占 3.3GB,给浏览器留不出余量。把 `text_score` 从默认的 0.5 降到 0.3 没找回关键信息,反而多出两行乱码,因此保留 0.5。三档都会漏掉整行的网址、拆开表格里的日程,图片里认出的关键信息不能当作可靠的原文,相关字段交给人审核,不根据上下文猜。
- **RapidOCR**:两行认成了乱码,一行整个漏掉,日程表里的日期和时刻拆到了两行,两个网址断成两截;也有看着像对的:`logon` 认成 `1ogon`,`_` 认成 `-`,`https` 认成 `htps` 或 `http`,网址末尾的 `/` 丢了。
- **整张送**:没有乱码,错的都看着像对的。字太小时它照着上下文猜:改日期(`9月17日` 写成 `8月17日`),认错网址和邮箱里的字母,或者换成相近的(`logon` 写成 `login`,导师名单的网址换成了别的页面,邮箱换成同一页上的另一个),还编了一整句原文里没有的话;另把日程表里 5 处全角冒号写成了半角。
- **切块送**:错 2 项,都是网址里多了一个字母或一个点(`dsjies` 写成 `dsjjies`,`ssyjsbm` 写成 `ssy.jsbm`),也是看着像对的。

**扫描的 PDF**:PDF 的某一页取不出文字,就用 pypdf 取出这一页上的图片交给文字识别,不另加渲染页面的库:扫描件一页就是一张图片,取出来的就是扫描时的原图。扫描件很少见:从 45 个来源最近的通知里取到 13 个 PDF,又从网上搜到 13 个高校推免通知的 PDF,全都有文字层。所以从中挑出 8 份,把前两页渲染后存成三种扫描件,在同样的容器里每份起一个进程识别(耗时含加载模型);其中 5 份有日期、时刻、网址和邮箱,共 18 项,空格不计:
**同一张图每次读的不一样**:温度设成 0,再读一次文字也可能不同。三张切块再读一次:西电那张一字不差(还是错在 `dsjjies`);电子科大计算机学院那张只是日程表里两行的换行不同;电子科大信软学院那张,第一次读对的两处 `yzbm.uestc.edu.cn` 这次读成了 `yzb.uestc.edu.cn`。几小时内再送同一张图,输入有 97% 命中缓存,这三张再读一次每张约 0.0017 美元,不到第一次的一半。

| 扫描件 | 关键信息认对 | 16 页总耗时 | 峰值内存 |
| --- | ---: | ---: | ---: |
| 彩色 JPEG,200 dpi | 17/18 | 97.9 秒 | 718 MiB |
| 黑白,300 dpi | 16/18 | 105.5 秒 | 745 MiB |
| 彩色,页面转了 90 度 | 14/18 | 94.6 秒 | 715 MiB |
图片和扫描的 PDF 改由 DeepSeek 切块读:认对的多 17 项,没有乱码和漏行,抓取节点也不用再跑识别模型。代价是每张约 0.0032 美元,以及读出的文字不稳定、错了也像对的:

转过的页面,pypdf 取出的是没转回来的图片,竖着的行也认得出,只是多错几项。用 JBIG2 压缩的黑白扫描件,pypdf 要另装 AGPL 许可的 jbig2dec 才能解开,镜像里不装,这种文件和别的坏附件一样记一条日志、文字留空。
- 读过的文件存在抓取节点上,文件没变就用上次的文字(第 7.4 节)。不然没截止的项目每天重抓通知时,有图片的通知天天出一份新快照、再抽取一次,修改记录里还会出现来回变的网址。
- 图片里读出的关键信息仍可能错,不能当作可靠的原文,相关字段交给人审核,不根据上下文猜。

抓取节点同时开 2 个浏览器页面(约 450 MiB)、一次识别一张图(峰值约 750 MiB),合计约 1.2 GiB,3.6GB 内存够用(第 16 节)。
**扫描的 PDF**:PDF 的某一页取不出文字,就用 pypdf 取出这一页上的图片,和别的图片一样交给 DeepSeek 读,不另加渲染页面的库:扫描件一页就是一张图片,取出来的就是扫描时的原图。扫描件很少见:从 45 个来源最近的通知里取到 13 个 PDF,又从网上搜到 13 个高校推免通知的 PDF,全都有文字层。把其中 8 份的前两页渲染成三种扫描件(彩色 JPEG 200 dpi、黑白 300 dpi、页面转了 90 度)试过,pypdf 都取得出图片,转过的页面取出的是没转回来的图片;当时是用 RapidOCR 认的,换成 DeepSeek 后没有另测。用 JBIG2 压缩的黑白扫描件,pypdf 要另装 AGPL 许可的 jbig2dec 才能解开,镜像里不装,这种文件和别的坏附件一样记一条日志、文字留空。

## 6. 账号与权限

Expand Down Expand Up @@ -200,9 +200,10 @@ CPU 不是问题,要省的是带宽,做法见第 14 节。
### 7.4 发现变化

- **新通知**:列表里出现没见过的链接,直接比网址。
- **正文变化**:还没截止的项目,每天重抓一次它的通知,在它的来源那一轮里一起抓;正文的文字和上一份快照不同,就存一份新快照,再交给抽取。
- **正文变化**:还没截止的项目,每天重抓一次它的通知,在它的来源那一轮里一起抓;正文或附件的文字和上一份快照不同,就存一份新快照,再交给抽取。
- **删除**:重抓时通知打不开(404、410),标成「原文已删除」,快照照样能看。从列表里消失不算:列表会翻页,旧通知本来就会被挤下去。
- **附件**:正文里和正文旁附件区里的文件(PDF、Word、Excel、压缩包),以及正文里的图片,按出现的顺序下载下来、解析出文字;图片和扫描的 PDF 做文字识别,压缩包不取文字。附件和正文一起存进快照。下载不成的(比如要验证码才能下载),照样列出名称和网址,文字留空。
- **附件**:正文里和正文旁附件区里的文件(PDF、Word、Excel、压缩包),以及正文里的图片,按出现的顺序下载下来、取出文字;图片和扫描的 PDF 交给 DeepSeek 读(第 5.2 节),压缩包不取文字。附件和正文一起存进快照。下载不成的(比如要验证码才能下载),照样列出名称和网址,文字留空;DeepSeek 没读成的(接口出错、超时),这条通知这一轮算没抓成,下一轮再抓。
- **读过的文件不重读**:DeepSeek 读同一张图,每次读出的文字可能不同,通知没变也会被当成变了。所以抓取节点把要 DeepSeek 读的文件(图片、有扫描页的 PDF)连同读出的文字存在自己的磁盘上(SQLite),按附件的网址找:再下载到的文件和存着的一个字节都不差,就用上次的文字,不再读;30 天没用到的删掉。存的东西丢了也没关系,只是每个文件多读一次。

### 7.5 抓取看板

Expand Down Expand Up @@ -466,6 +467,7 @@ SemIf 仅标题的过滤率较高,加入栏目名后反而降低;栏目名

- 初筛挡掉大部分通知;每条通知只抽取一次,正文变了才再抽。
- 每次调用都记下用量,按天汇总显示在抓取看板上。
- 读图片每张约 0.0032 美元(第 5.2 节),同一个文件不重读(第 7.4 节)。读图的用量和这条通知的抽取一起交,算作抽取;没走到抽取的(比如抽取失败)只写进日志。
- LLM 请求按可复用的前缀组织:固定规则与输出结构在前,同一所学校的学院清单和近期项目其次,单条通知的标题、发布日期和编号正文最后。结构化数据使用固定字段顺序,共用部分不加入当天日期、请求编号等易变内容;后续试抓等调用也遵守这条规则。
- DeepSeek 官方 Responses API 自动管理前缀缓存,命中量取 `usage.input_tokens_details.cached_tokens`。实验报告保留输入、输出和命中 token,按实际命中计算费用;接口没返回用量或命中数时记为未知,不当作零或免费。
- 估算:旺季每天几十条通知进 LLM,用便宜的模型,一天花不了几块钱。
Expand Down Expand Up @@ -617,7 +619,7 @@ L3 以上可以不提申请,直接修改,同样要写理由、附依据。
| 部分 | 放在哪 | 说明 |
| --- | --- | --- |
| 网站 | 香港的服务器,和鼠洞同一台 | 容器部署,前面是 1Panel 的 OpenResty;数据库用服务器上 1Panel 的 PostgreSQL |
| 抓取节点 | 上海的机器:4 核、3.6GB 内存 | 容器部署;浏览器抓取同时最多开 2 个页面(约 450 MiB),文字识别一次只做一张(峰值约 750 MiB),合计约 1.2 GiB,3.6GB 内存够用(第 5.2 节) |
| 抓取节点 | 上海的机器:4 核、3.6GB 内存 | 容器部署,挂一个卷存读过的图片(第 7.4 节);浏览器抓取同时最多开 2 个页面(约 450 MiB,第 5.2 节),3.6GB 内存够用 |

- 抓取节点每分钟向网站领一次活:到期要抓的来源和管理员要试抓的来源。抓取节点停了,网站照常能用,只是没有新数据;抓取看板会显示抓取节点多久没来领活。
- 为什么分开:实测 11 所学校和研究所的官网和研招网,香港和上海都能访问(浙大要用 HTTP),但从上海访问快 2–4 倍;浏览器抓取和附件解析吃 CPU 和内存,放在上海也不占网站服务器的资源。判别和抽取都调用外部接口,抓取节点不跑模型。
Expand Down
Loading