Skip to content
Merged
Show file tree
Hide file tree
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
2 changes: 1 addition & 1 deletion crawler/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ dependencies = [
"openpyxl>=3.1.5",
"playwright>=1.64.1",
"pydantic>=2.14.1",
"pypdf>=7.0.0",
"pypdf[image]>=7.0.0",
"python-docx>=1.2.0",
"rapidocr>=3.10.0",
"xlrd>=2.0.2",
Expand Down
14 changes: 10 additions & 4 deletions crawler/src/crawler/parse/files.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
"""The words in the files notices attach (design §5.1): images, PDF, Word and Excel, including
the old binary formats. Archives and other files have none to read."""
"""The words in the files notices attach (design §5.1): images, PDF (scanned pages too), Word and
Excel, including the old binary formats. Archives and other files have none to read."""

import io
import unicodedata
Expand All @@ -12,7 +12,7 @@
from docx.table import Table
from docx.text.paragraph import Paragraph
from legacy_doc import extract_text
from pypdf import PdfReader
from pypdf import PageObject, PdfReader

from crawler.parse.image import image_text

Expand Down Expand Up @@ -41,7 +41,7 @@ def file_text(data: bytes) -> str:
return image_text(data)
if data.startswith(_PDF):
pages = PdfReader(io.BytesIO(data)).pages
return "\n".join(page.extract_text() for page in pages).translate(_RADICALS)
return "\n".join(_page_text(page) for page in pages).translate(_RADICALS)
if data.startswith(_ZIP):
parts = set(zipfile.ZipFile(io.BytesIO(data)).namelist())
if "word/document.xml" in parts:
Expand All @@ -56,6 +56,12 @@ def file_text(data: bytes) -> str:
return ""


def _page_text(page: PageObject) -> str:
text = page.extract_text()
# A scanned page is a picture with no text layer; scanners put one picture on each page.
return text if text.strip() else "\n".join(image_text(image.data) for image in page.images)


def _lines(lines: Iterable[str]) -> str:
# Blank lines, and the empty cells that end a row, carry nothing.
return "\n".join(line.rstrip("\t") for line in lines if line.strip())
Expand Down
6 changes: 4 additions & 2 deletions crawler/src/crawler/parse/files_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,17 @@
from crawler.parse.files import file_text


def test_image_reads_generated_chinese_attachment() -> None:
# Pillow saves a picture as a PDF the way a scanner does: one picture a page, no text layer.
@pytest.mark.parametrize("image_format", ["PNG", "PDF"])
def test_text_is_recognized_in_images_and_scanned_pdfs(image_format: str) -> None:
image = Image.new("RGB", (1600, 500), "white")
draw = ImageDraw.Draw(image)
font = ImageFont.truetype(Path(__file__).with_name("fixtures") / "NotoSansSC-test.ttf", 64)
draw.text((60, 50), "夏令营报名时间", fill="black", font=font)
draw.text((60, 170), "7月6日10:00至8月10日17:00", fill="black", font=font)
draw.text((60, 290), "请填写申请表", fill="black", font=font)
buffer = io.BytesIO()
image.save(buffer, format="PNG")
image.save(buffer, format=image_format)

text = file_text(buffer.getvalue())

Expand Down
9 changes: 7 additions & 2 deletions crawler/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 14 additions & 5 deletions docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@
| 数据库 | 服务器上 1Panel 的 PostgreSQL 18 | 和鼠洞共用实例,不加新服务 |
| 抓取节点 | Python 3.14,uv 管依赖;Ruff 做 lint 和格式化,Pyright 的严格模式做类型检查,pytest 做测试 | 浏览器抓取、正文提取、PDF 和 Office 解析、中文的文字识别,Python 的库最全,代价是多一套工具链。3.15 刚发布,文字识别要用的 onnxruntime 还没有它的安装包。Pyright 和 VS Code 的 Pylance 是同一个引擎,编辑器里看到的错误和 CI 一致 |
| 抓取 | httpx(异步)请求网页,Playwright 驱动浏览器(Chromium 的无头精简版),lxml 按 CSS 或 XPath 取出列表 | 各自领域的主流库 |
| 正文、附件和文字识别 | 正文按建站系统的模板用 lxml 取;pypdf 读 PDF,python-docx 读 `.docx`,legacy-doc 读老的 `.doc`,openpyxl 读 `.xlsx`,xlrd 读 `.xls`;图片的文字识别用 RapidOCR(PP-OCRv6 small,设置见第 5.2 节) | 都关在 `parse` 模块里,换了不影响别处。正文和附件用真实的通知和附件比较后定,结果见第 5.2 节;扫描的 PDF 用哪个库渲染成图片,在[文字识别](https://github.com/mousebaoyan/mouseclock/issues/47)的任务里定。不用 AGPL 许可的 PyMuPDF |
| 正文、附件和文字识别 | 正文按建站系统的模板用 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 |
| 接口的类型 | 网站用 Zod 写接口的定义,生成 OpenAPI 文档;抓取节点从这份文档生成 Pydantic 模型 | 两边语言不同,接口的类型也只有一个来源。抓取节点用 Pydantic 校验网站的返回和模型的输出 |
| 判别接口和 LLM | System One 的 SemIf;DeepSeek 官方接口的 V4.1 Flash(`deepseek-flash`),按编号抽取 | 判别见第 8.1 节,抽取比较见第 8.5 节 |

Expand Down Expand Up @@ -131,7 +131,17 @@ CPU 不是问题,要省的是带宽,做法见第 14 节。

选 small,在缩到 2000 的图上找行、从原图上切行识别,一次认一行:默认把整张图缩到 2000 再识别,长截图的字太小;把整张图放到 4000 仍少两张全对,检测还要多占 1GB 以上;medium 在三档整图方案里最准,但单独识别就占 3.3GB,给浏览器留不出余量。把 `text_score` 从默认的 0.5 降到 0.3 没找回关键信息,反而多出两行乱码,因此保留 0.5。三档都会漏掉整行的网址、拆开表格里的日程,图片里认出的关键信息不能当作可靠的原文,相关字段交给人审核,不根据上下文猜。

抓取节点同时开 2 个浏览器页面(约 450 MiB)、一次识别一张图(峰值 726 MiB),合计约 1.2 GiB,3.6GB 内存够用(第 16 节)。
**扫描的 PDF**:PDF 的某一页取不出文字,就用 pypdf 取出这一页上的图片交给文字识别,不另加渲染页面的库:扫描件一页就是一张图片,取出来的就是扫描时的原图。扫描件很少见:从 45 个来源最近的通知里取到 13 个 PDF,又从网上搜到 13 个高校推免通知的 PDF,全都有文字层。所以从中挑出 8 份,把前两页渲染后存成三种扫描件,在同样的容器里每份起一个进程识别(耗时含加载模型);其中 5 份有日期、时刻、网址和邮箱,共 18 项,空格不计:

| 扫描件 | 关键信息认对 | 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 |

转过的页面,pypdf 取出的是没转回来的图片,竖着的行也认得出,只是多错几项。用 JBIG2 压缩的黑白扫描件,pypdf 要另装 AGPL 许可的 jbig2dec 才能解开,镜像里不装,这种文件和别的坏附件一样记一条日志、文字留空。

抓取节点同时开 2 个浏览器页面(约 450 MiB)、一次识别一张图(峰值约 750 MiB),合计约 1.2 GiB,3.6GB 内存够用(第 16 节)。

## 6. 账号与权限

Expand Down Expand Up @@ -192,7 +202,7 @@ CPU 不是问题,要省的是带宽,做法见第 14 节。
- **新通知**:列表里出现没见过的链接,直接比网址。
- **正文变化**:还没截止的项目,每天重抓一次它的通知,在它的来源那一轮里一起抓;正文的文字和上一份快照不同,就存一份新快照,再交给抽取。
- **删除**:重抓时通知打不开(404、410),标成「原文已删除」,快照照样能看。从列表里消失不算:列表会翻页,旧通知本来就会被挤下去。
- **附件**:正文里和正文旁附件区里的文件(PDF、Word、Excel、压缩包),以及正文里的图片,按出现的顺序下载下来、解析出文字;图片做文字识别,压缩包不取文字。附件和正文一起存进快照。下载不成的(比如要验证码才能下载),照样列出名称和网址,文字留空。
- **附件**:正文里和正文旁附件区里的文件(PDF、Word、Excel、压缩包),以及正文里的图片,按出现的顺序下载下来、解析出文字;图片和扫描的 PDF 做文字识别,压缩包不取文字。附件和正文一起存进快照。下载不成的(比如要验证码才能下载),照样列出名称和网址,文字留空。

### 7.5 抓取看板

Expand Down Expand Up @@ -607,7 +617,7 @@ L3 以上可以不提申请,直接修改,同样要写理由、附依据。
| 部分 | 放在哪 | 说明 |
| --- | --- | --- |
| 网站 | 香港的服务器,和鼠洞同一台 | 容器部署,前面是 1Panel 的 OpenResty;数据库用服务器上 1Panel 的 PostgreSQL |
| 抓取节点 | 上海的机器:4 核、3.6GB 内存 | 容器部署;浏览器抓取同时最多开 2 个页面(约 450 MiB),文字识别一次只做一张(峰值 726 MiB),合计约 1.2 GiB,3.6GB 内存够用(第 5.2 节) |
| 抓取节点 | 上海的机器:4 核、3.6GB 内存 | 容器部署;浏览器抓取同时最多开 2 个页面(约 450 MiB),文字识别一次只做一张(峰值约 750 MiB),合计约 1.2 GiB,3.6GB 内存够用(第 5.2 节) |

- 抓取节点每分钟向网站领一次活:到期要抓的来源和管理员要试抓的来源。抓取节点停了,网站照常能用,只是没有新数据;抓取看板会显示抓取节点多久没来领活。
- 为什么分开:实测 11 所学校和研究所的官网和研招网,香港和上海都能访问(浙大要用 HTTP),但从上海访问快 2–4 倍;浏览器抓取和附件解析吃 CPU 和内存,放在上海也不占网站服务器的资源。判别和抽取都调用外部接口,抓取节点不跑模型。
Expand Down Expand Up @@ -644,7 +654,6 @@ L3 以上可以不提申请,直接修改,同样要写理由、附依据。
| 事项 | 现状 |
| --- | --- |
| 名称 | 「鼠钟」暂定,帖子里在征名 |
| 扫描的 PDF 用哪个库渲染成图片 | 在文字识别任务里比较后定(第 5.1 节) |
| 域名 | 待定,用 `mousebaoyan.cn` 的子域名 |
| 群汇总由哪个插件发 | 扩展鼠洞的 bot 插件,还是新写一个 |
| 第一批来源 | 先收对四非友好的学校,名单在帖子里征集 |
Expand Down
Loading