From ea5a187b2f6ec050a2c2143ee9e5c1386ab70904 Mon Sep 17 00:00:00 2001 From: hp0912 <809211365@qq.com> Date: Sat, 25 Jul 2026 14:54:21 +0800 Subject: [PATCH] feat: pdf skill --- skills/pdf/SKILL.md | 111 +++++++++++++ skills/pdf/agents/openai.yaml | 4 + skills/pdf/scripts/download_pdf.py | 248 +++++++++++++++++++++++++++++ 3 files changed, 363 insertions(+) create mode 100644 skills/pdf/SKILL.md create mode 100644 skills/pdf/agents/openai.yaml create mode 100644 skills/pdf/scripts/download_pdf.py diff --git a/skills/pdf/SKILL.md b/skills/pdf/SKILL.md new file mode 100644 index 0000000..2dd93a4 --- /dev/null +++ b/skills/pdf/SKILL.md @@ -0,0 +1,111 @@ +--- +name: pdf +description: "处理本地 PDF 文件或远程 HTTPS PDF 链接,包括下载、读取、文本与表格提取、页面渲染与视觉审阅、创建、修改、合并、拆分、旋转、表单处理和最终质量校验。当用户提供 .pdf 文件或 HTTPS PDF 地址,或要求生成、编辑、总结、审阅版式重要的 PDF 时使用。" +--- + +# PDF 处理 + +## 核心原则 + +- 优先进行视觉检查。PDF 的文本提取结果不能代表真实版式,必须把页面渲染为 PNG 后检查。 +- 收到远程 HTTPS PDF 链接时,先下载为本地 PDF,再执行任何读取、编辑或渲染操作。 +- 创建 PDF 时优先使用 `reportlab`;提取文本或表格时使用 `pdfplumber`;读取元数据、合并、拆分、旋转或处理页面结构时使用 `pypdf`。 +- 每次完成影响内容或版式的修改后,重新渲染并检查最新版本。 +- 不覆盖用户提供的源文件。编辑时写入新的输出文件。 + +## 环境约定 + +环境已预置 Poppler(`pdfinfo`、`pdftoppm`)以及 `reportlab`、`pdfplumber`、`pypdf`。直接使用环境提供的可执行文件和 Python 运行时;不要安装依赖,也不要提示用户安装依赖。如果默认 `python3` 未加载预置模块,先定位并改用环境提供的 Python 运行时。如果 Poppler 报告字体缓存目录不可写,把 `XDG_CACHE_HOME` 临时设置为本次任务目录下的可写缓存目录后重试。 + +## 标准流程 + +1. 获取输入:识别本地 PDF 路径或 HTTPS PDF 链接;远程链接先按“远程 PDF 下载”流程保存到本地。 +2. 校验文件:确认文件存在、大小大于 0、可被 PDF 工具解析,并用 `pdfinfo` 获取页数、页面尺寸、加密状态等信息。 +3. 建立工作目录:把中间文件放在 `tmp/pdfs/<任务名>/`,使用稳定且可读的文件名。 +4. 读取和处理:根据任务使用 `pdfplumber`、`pypdf` 或 `reportlab`,但不要仅依赖文本提取判断页面内容。 +5. 渲染页面:使用 Poppler 把待审阅的 PDF 转成 PNG。 +6. 视觉检查:逐页检查文字、图片、表格、页眉页脚、页码、分页和留白。 +7. 输出结果:把最终 PDF 写入 `output/pdf/`,文件名保持稳定且能表达内容。 +8. 清理中间文件:交付完成后仅删除本次任务对应的 `tmp/pdfs/<任务名>/`,保留最终产物。 + +## 远程 PDF 下载 + +只接受 HTTPS 地址。必须使用本 Skill 自带的 `scripts/download_pdf.py` 下载,不要使用 `curl`、shell 脚本或临时编写的下载逻辑。脚本会自动创建目标目录、流式下载、限制重定向协议、控制文件大小,并使用 `pypdf` 校验下载内容。 + +```text +python3 scripts/download_pdf.py \ + --url 'https://example.com/document.pdf' \ + --output 'tmp/pdfs/<任务名>/source.pdf' +``` + +保留完整 URL,包括查询参数,并把它作为一个完整参数传入。目标文件已存在时,脚本默认拒绝覆盖;仅在确认该文件是本次任务生成的下载缓存时使用 `--overwrite`。可选参数: + +- `--timeout <秒>`:连接和读取超时,默认 `60`。 +- `--max-bytes <字节数>`:最大下载大小,默认 `104857600`(100 MiB)。 +- `--overwrite`:覆盖已存在的目标文件。 + +脚本成功时退出码为 `0`,并输出: + +```json +{ + "ok": true, + "path": "/absolute/path/to/source.pdf", + "size_bytes": 123456, + "page_count": 10, + "encrypted": false +} +``` + +脚本失败时退出码为非 `0`,并输出 `{"ok": false, "error": "具体错误"}`。只有收到 `ok: true` 后才能继续读取或渲染该文件。 + +遵守以下规则: + +- 跟随 HTTPS 重定向,但不允许降级到 HTTP。 +- 不根据 URL 后缀或响应的 `Content-Type` 单独判断文件类型,以实际 PDF 解析结果为准。 +- 如果下载结果是 HTML、登录页、错误页或无法解析的内容,停止后续处理并明确说明链接需要授权或真实 PDF 下载地址。 +- 如果链接包含签名、令牌或其他敏感查询参数,不在最终回复、日志摘要、错误信息或生成的文件名中复述这些参数;下载脚本也不会在结果中回显 URL。 +- 如果 PDF 已加密且任务需要密码,先向用户索取密码;不得尝试绕过加密。 + +## 读取与审阅 + +- 使用 `pdfplumber` 提取正文、页级文本和表格,用于搜索、总结和结构化分析。 +- 使用 `pypdf` 读取元数据、书签、页数和页面对象,或执行合并、拆分、旋转等结构操作。 +- 如果提取出的文本很少或为空,将文件视为可能的扫描件,转而检查渲染后的页面;不要把空文本误判为空白 PDF。 +- 对表格、图表、公式、双栏排版、批注、印章和扫描内容,以渲染图为主要依据。 +- 回答问题或生成摘要时,区分 PDF 中明确写出的内容和根据版式、图表得出的推断。 + +## 创建与修改 + +- 使用 `reportlab` 创建新 PDF;复杂文档优先使用 Platypus 的文档流、段落、表格和分页组件。 +- 使用 `pypdf` 完成合并、拆分、旋转、页面重排、元数据和表单等操作。 +- 处理中文或其他非 ASCII 文字时,选择环境中可用且覆盖所需字符的 Unicode 字体,并嵌入 PDF;渲染后重点检查缺字、黑方块和字体回退。 +- 为正文、标题、表格、图注和页眉页脚建立一致的字号、行距、边距与层级。 +- 表格跨页时重复表头,避免行被不自然截断;图片和图表保持清晰、等比缩放并与说明文字对齐。 +- 只使用 ASCII 连字符 `-`,避免 U+2011 等 Unicode 横线字符引发字体或换行问题。 + +## 渲染与视觉检查 + +使用 `pdftoppm` 将 PDF 渲染到本次任务的工作目录: + +```bash +pdftoppm -png -r 150 \ + 'output/pdf/<最终文件名>.pdf' \ + 'tmp/pdfs/<任务名>/page' +``` + +对于文字较小、图表密集或 150 DPI 下无法确认的页面,提高分辨率并重新渲染。创建或修改 PDF 时必须检查全部页面;只读超长文档时可先生成低分辨率预览定位相关页面,再以足够分辨率检查相关页。 + +逐页确认: + +- 没有裁切、重叠、溢出、异常空白页或错误分页。 +- 没有黑方块、乱码、缺字或不可读的小字号。 +- 标题层级、段落间距、页边距和对齐方式一致。 +- 表格、图表和图片清晰、完整、标注正确。 +- 页眉、页脚、页码和章节衔接正确。 +- 引用和参考文献可读,不含工具令牌、占位符或临时路径。 + +## 交付要求 + +- 只有在最新渲染结果不存在可见的内容或格式缺陷后,才交付创建或修改后的 PDF。 +- 最终文件必须位于 `output/pdf/`,不得把临时 PNG 或下载缓存当作最终产物。 +- 回复时给出最终文件路径,并简要说明完成的处理和验证;失败时说明具体原因,不提供依赖安装提示。 diff --git a/skills/pdf/agents/openai.yaml b/skills/pdf/agents/openai.yaml new file mode 100644 index 0000000..2ec1dd3 --- /dev/null +++ b/skills/pdf/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "PDF 处理" + short_description: "读取、创建、审阅与可视化检查本地或远程 PDF 文件" + default_prompt: "使用 $pdf 下载并审阅这个 PDF,提取要点并检查页面布局。" diff --git a/skills/pdf/scripts/download_pdf.py b/skills/pdf/scripts/download_pdf.py new file mode 100644 index 0000000..c27e773 --- /dev/null +++ b/skills/pdf/scripts/download_pdf.py @@ -0,0 +1,248 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +import json +import logging +import os +import socket +import sys +import tempfile +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path +from typing import NoReturn, Optional + +DEFAULT_TIMEOUT_SECONDS = 60 +DEFAULT_MAX_BYTES = 100 * 1024 * 1024 +CHUNK_SIZE = 1024 * 1024 +USER_AGENT = "wechat-robot-pdf-skill/1.0" + + +class SkillArgumentParser(argparse.ArgumentParser): + def error(self, message: str) -> NoReturn: + raise ValueError(f"参数错误:{message}") + + +def _emit(payload: dict) -> None: + sys.stdout.write(json.dumps(payload, ensure_ascii=False) + "\n") + + +def _validate_https_url(value: str) -> str: + url = value.strip() + if not url: + raise ValueError("PDF URL 不能为空") + if any(character.isspace() or ord(character) < 32 for character in url): + raise ValueError("PDF URL 不能包含空白字符或控制字符") + + parsed = urllib.parse.urlsplit(url) + if parsed.scheme.lower() != "https" or not parsed.hostname: + raise ValueError("PDF URL 必须是有效的 HTTPS 地址") + if parsed.username is not None or parsed.password is not None: + raise ValueError("PDF URL 不允许包含用户名或密码") + try: + parsed.port + except ValueError as exc: + raise ValueError("PDF URL 端口格式不正确") from exc + return url + + +class HTTPSOnlyRedirectHandler(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + safe_url = _validate_https_url(newurl) + return super().redirect_request(req, fp, code, msg, headers, safe_url) + + +def _parse_args(argv: list[str]) -> argparse.Namespace: + parser = SkillArgumentParser(description="下载并校验远程 HTTPS PDF") + parser.add_argument( + "--url", + "--pdf-url", + "--pdf_url", + dest="url", + required=True, + help="远程 HTTPS PDF 地址", + ) + parser.add_argument( + "--output", + required=True, + help="本地输出路径,必须以 .pdf 结尾;父目录不存在时会自动创建", + ) + parser.add_argument( + "--timeout", + type=int, + default=DEFAULT_TIMEOUT_SECONDS, + help=f"连接和读取超时秒数,默认 {DEFAULT_TIMEOUT_SECONDS}", + ) + parser.add_argument( + "--max-bytes", + "--max_bytes", + dest="max_bytes", + type=int, + default=DEFAULT_MAX_BYTES, + help=f"最大下载字节数,默认 {DEFAULT_MAX_BYTES}", + ) + parser.add_argument( + "--overwrite", + action="store_true", + help="允许覆盖已存在的目标文件", + ) + args = parser.parse_args(argv) + + args.url = _validate_https_url(args.url) + if args.timeout <= 0: + raise ValueError("timeout 必须大于 0") + if args.max_bytes <= 0: + raise ValueError("max-bytes 必须大于 0") + + output = Path(args.output).expanduser() + if output.suffix.lower() != ".pdf": + raise ValueError("output 必须以 .pdf 结尾") + args.output = output.resolve() + return args + + +def _validate_pdf(path: Path) -> tuple[Optional[int], bool]: + with path.open("rb") as file: + prefix = file.read(1024) + if b"%PDF-" not in prefix: + raise ValueError("下载内容不是 PDF 文件") + + try: + from pypdf import PdfReader + except ImportError as exc: + raise RuntimeError("当前 Python 未加载环境预置的 pypdf 模块") from exc + + logging.getLogger("pypdf").setLevel(logging.ERROR) + reader = None + try: + reader = PdfReader(str(path), strict=False) + encrypted = bool(reader.is_encrypted) + page_count = None if encrypted else len(reader.pages) + except Exception as exc: + raise ValueError("下载内容不是可解析的 PDF 文件") from exc + finally: + stream = getattr(reader, "stream", None) + if stream is not None and hasattr(stream, "close"): + stream.close() + return page_count, encrypted + + +def _download(args: argparse.Namespace) -> dict: + output: Path = args.output + if output.exists() and not args.overwrite: + raise FileExistsError(f"目标文件已存在:{output}") + + output.parent.mkdir(parents=True, exist_ok=True) + request = urllib.request.Request( + args.url, + headers={ + "Accept": "application/pdf,application/octet-stream;q=0.9,*/*;q=0.1", + "Accept-Encoding": "identity", + "User-Agent": USER_AGENT, + }, + method="GET", + ) + opener = urllib.request.build_opener(HTTPSOnlyRedirectHandler()) + temp_path: Optional[Path] = None + + try: + with tempfile.NamedTemporaryFile( + mode="wb", + prefix=f".{output.name}.", + suffix=".part", + dir=str(output.parent), + delete=False, + ) as temp_file: + temp_path = Path(temp_file.name) + with opener.open(request, timeout=args.timeout) as response: + _validate_https_url(response.geturl()) + content_length = response.headers.get("Content-Length") + if content_length: + try: + expected_bytes = int(content_length) + except ValueError: + expected_bytes = 0 + if expected_bytes > args.max_bytes: + raise ValueError( + f"远程文件超过大小限制:最多允许 {args.max_bytes} 字节" + ) + + downloaded_bytes = 0 + while True: + chunk = response.read(CHUNK_SIZE) + if not chunk: + break + downloaded_bytes += len(chunk) + if downloaded_bytes > args.max_bytes: + raise ValueError( + f"远程文件超过大小限制:最多允许 {args.max_bytes} 字节" + ) + temp_file.write(chunk) + + temp_file.flush() + os.fsync(temp_file.fileno()) + + if downloaded_bytes == 0: + raise ValueError("远程服务器返回了空文件") + + page_count, encrypted = _validate_pdf(temp_path) + if args.overwrite: + os.replace(temp_path, output) + else: + try: + os.link(temp_path, output) + except FileExistsError as exc: + raise FileExistsError(f"目标文件已存在:{output}") from exc + temp_path.unlink() + temp_path = None + + return { + "ok": True, + "path": str(output), + "size_bytes": downloaded_bytes, + "page_count": page_count, + "encrypted": encrypted, + } + finally: + if temp_path is not None: + try: + temp_path.unlink(missing_ok=True) + except OSError: + pass + + +def _failure_message(exc: Exception) -> str: + if isinstance(exc, urllib.error.HTTPError): + return f"下载失败:远程服务器返回 HTTP {exc.code}" + if isinstance(exc, (TimeoutError, socket.timeout)): + return "下载失败:连接或读取超时" + if isinstance(exc, urllib.error.URLError): + if isinstance(exc.reason, (TimeoutError, socket.timeout)): + return "下载失败:连接或读取超时" + return "下载失败:无法访问远程服务器" + if isinstance(exc, FileExistsError): + return str(exc) + if isinstance(exc, PermissionError): + return "写入失败:没有目标路径的写入权限" + if isinstance(exc, OSError): + return f"文件处理失败:{exc}" + return str(exc) + + +def main(argv: Optional[list[str]] = None) -> int: + try: + args = _parse_args(sys.argv[1:] if argv is None else argv) + result = _download(args) + except Exception as exc: + _emit({"ok": False, "error": _failure_message(exc)}) + return 1 + + _emit(result) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())