diff --git a/docs/design.md b/docs/design.md index 6c89ee5..c30ab65 100644 --- a/docs/design.md +++ b/docs/design.md @@ -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 实测 @@ -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. 账号与权限 @@ -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 抓取看板 @@ -466,6 +467,7 @@ SemIf 仅标题的过滤率较高,加入栏目名后反而降低;栏目名 - 初筛挡掉大部分通知;每条通知只抽取一次,正文变了才再抽。 - 每次调用都记下用量,按天汇总显示在抓取看板上。 +- 读图片每张约 0.0032 美元(第 5.2 节),同一个文件不重读(第 7.4 节)。读图的用量和这条通知的抽取一起交,算作抽取;没走到抽取的(比如抽取失败)只写进日志。 - LLM 请求按可复用的前缀组织:固定规则与输出结构在前,同一所学校的学院清单和近期项目其次,单条通知的标题、发布日期和编号正文最后。结构化数据使用固定字段顺序,共用部分不加入当天日期、请求编号等易变内容;后续试抓等调用也遵守这条规则。 - DeepSeek 官方 Responses API 自动管理前缀缓存,命中量取 `usage.input_tokens_details.cached_tokens`。实验报告保留输入、输出和命中 token,按实际命中计算费用;接口没返回用量或命中数时记为未知,不当作零或免费。 - 估算:旺季每天几十条通知进 LLM,用便宜的模型,一天花不了几块钱。 @@ -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 和内存,放在上海也不占网站服务器的资源。判别和抽取都调用外部接口,抓取节点不跑模型。