feat: pdf skill

This commit is contained in:
hp0912 2026-07-25 14:54:21 +08:00
parent 885e7c401c
commit ea5a187b2f
3 changed files with 363 additions and 0 deletions

111
skills/pdf/SKILL.md Normal file
View File

@ -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 或下载缓存当作最终产物。
- 回复时给出最终文件路径,并简要说明完成的处理和验证;失败时说明具体原因,不提供依赖安装提示。

View File

@ -0,0 +1,4 @@
interface:
display_name: "PDF 处理"
short_description: "读取、创建、审阅与可视化检查本地或远程 PDF 文件"
default_prompt: "使用 $pdf 下载并审阅这个 PDF提取要点并检查页面布局。"

View File

@ -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())