diff --git a/crawler/pyproject.toml b/crawler/pyproject.toml index fe2ecf7..8fba35b 100644 --- a/crawler/pyproject.toml +++ b/crawler/pyproject.toml @@ -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", diff --git a/crawler/src/crawler/parse/files.py b/crawler/src/crawler/parse/files.py index c7722ad..a265482 100644 --- a/crawler/src/crawler/parse/files.py +++ b/crawler/src/crawler/parse/files.py @@ -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 @@ -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 @@ -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: @@ -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()) diff --git a/crawler/src/crawler/parse/files_test.py b/crawler/src/crawler/parse/files_test.py index 67bfca2..a13abc5 100644 --- a/crawler/src/crawler/parse/files_test.py +++ b/crawler/src/crawler/parse/files_test.py @@ -10,7 +10,9 @@ 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) @@ -18,7 +20,7 @@ def test_image_reads_generated_chinese_attachment() -> None: 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()) diff --git a/crawler/uv.lock b/crawler/uv.lock index 232d7c2..c63eea0 100644 --- a/crawler/uv.lock +++ b/crawler/uv.lock @@ -223,7 +223,7 @@ dependencies = [ { name = "openpyxl" }, { name = "playwright" }, { name = "pydantic" }, - { name = "pypdf" }, + { name = "pypdf", extra = ["image"] }, { name = "python-docx" }, { name = "rapidocr" }, { name = "xlrd" }, @@ -250,7 +250,7 @@ requires-dist = [ { name = "openpyxl", specifier = ">=3.1.5" }, { name = "playwright", specifier = ">=1.64.1" }, { name = "pydantic", specifier = ">=2.14.1" }, - { name = "pypdf", specifier = ">=7.0.0" }, + { name = "pypdf", extras = ["image"], specifier = ">=7.0.0" }, { name = "python-docx", specifier = ">=1.2.0" }, { name = "rapidocr", specifier = ">=3.10.0" }, { name = "xlrd", specifier = ">=2.0.2" }, @@ -1029,6 +1029,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/21/e83122a25e334c718a4da67004fa94acd7215fe9f8e63a9d7c7f979eb52b/pypdf-7.0.0-py3-none-any.whl", hash = "sha256:112a2c7cbabefd8dfe498f7a13977e575e762d58cdb0d832acb8886922602c6f", size = 413949, upload-time = "2026-11-06T12:10:01.205Z" }, ] +[package.optional-dependencies] +image = [ + { name = "pillow" }, +] + [[package]] name = "pyright" version = "1.1.414" diff --git a/docs/design.md b/docs/design.md index 3706937..6c89ee5 100644 --- a/docs/design.md +++ b/docs/design.md @@ -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 节 | @@ -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. 账号与权限 @@ -192,7 +202,7 @@ CPU 不是问题,要省的是带宽,做法见第 14 节。 - **新通知**:列表里出现没见过的链接,直接比网址。 - **正文变化**:还没截止的项目,每天重抓一次它的通知,在它的来源那一轮里一起抓;正文的文字和上一份快照不同,就存一份新快照,再交给抽取。 - **删除**:重抓时通知打不开(404、410),标成「原文已删除」,快照照样能看。从列表里消失不算:列表会翻页,旧通知本来就会被挤下去。 -- **附件**:正文里和正文旁附件区里的文件(PDF、Word、Excel、压缩包),以及正文里的图片,按出现的顺序下载下来、解析出文字;图片做文字识别,压缩包不取文字。附件和正文一起存进快照。下载不成的(比如要验证码才能下载),照样列出名称和网址,文字留空。 +- **附件**:正文里和正文旁附件区里的文件(PDF、Word、Excel、压缩包),以及正文里的图片,按出现的顺序下载下来、解析出文字;图片和扫描的 PDF 做文字识别,压缩包不取文字。附件和正文一起存进快照。下载不成的(比如要验证码才能下载),照样列出名称和网址,文字留空。 ### 7.5 抓取看板 @@ -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 和内存,放在上海也不占网站服务器的资源。判别和抽取都调用外部接口,抓取节点不跑模型。 @@ -644,7 +654,6 @@ L3 以上可以不提申请,直接修改,同样要写理由、附依据。 | 事项 | 现状 | | --- | --- | | 名称 | 「鼠钟」暂定,帖子里在征名 | -| 扫描的 PDF 用哪个库渲染成图片 | 在文字识别任务里比较后定(第 5.1 节) | | 域名 | 待定,用 `mousebaoyan.cn` 的子域名 | | 群汇总由哪个插件发 | 扩展鼠洞的 bot 插件,还是新写一个 | | 第一批来源 | 先收对四非友好的学校,名单在帖子里征集 |