用 Python 抓取 PDF:从链接发现到提取的表格
Web Data Collection Specialist
TL;DR:
- 抓取 PDF 从链接它的 HTML 页面开始。 一个实时索引页面在任何解析开始之前产生了四个文档 URL,而这个发现步骤是大多数指南所跳过的部分。
- PDF 必须以字节形式到达。 通过一个返回文本的端点路由同一文档产生了 235,403 字节,而真实文件是 140,815 字节,pypdf 和 pdfplumber 都无法从结果中读取页面。
- 打开文件并不等于读取文件。 在那些损坏的字节上构建读取器没有引发任何结果;只有在触碰页面时才会出现故障。
- pdfplumber 提供页面几何,pypdf 提供文档结构。 一个实时页面返回了 5,659 个字符的文本,4 个检测到的表格,以及 933 个定位的单词。
- 并非每一个检测到的表格都是数据。 那四个表格有 1、7、2 和 10 列,大多数是布局支架,而不是记录。
- 从免费开始。 链接发现抓取运行于通用抓取 API 的免费层。
公共机构将其最有用的数据发布为 PDF。预算、档案、统计数据和检查结果以为打印设计的文档形式到达,而停止在 HTML 页面处的抓取器永远无法看到其中任何内容。
本指南从一个链接四个 PDF 的页面开始,下载一个,并提取文本、表格和单词位置——然后准确测量文件在错误路径上发生的事情。
什么是 PDF,出于抓取目的
PDF 是一个二进制容器,描述了标记在页面上的位置。它没有段落、标题或表格的概念——只有坐标处的字形,一个页面描述模型在 国会图书馆的 PDF 家族格式描述 中编目。它的媒体类型在 application/pdf 媒体类型规范 中注册,正是因为该格式不是文本,所以其媒体类型为二进制。
这一单一事实驱动下面的一切。提取“文本”意味着从位置重建阅读顺序,提取“一张表”意味着从规则线和对齐中推断行和列。两个库分担了这项工作:
- pypdf 读取文档结构——页面计数、元数据、加密状态和页面级文本。
- pdfplumber 读取页面几何——带坐标的字符、检测到的规则线,以及由其构建的表格。
安装
bash
pip install pdfplumber pypdf beautifulsoup4
pdfplumber 引入 pdfminer.six,它执行低级解析。这里没有需要系统包或无头浏览器的内容。
阶段 1:发现链接
文档从普通页面引用,因此第一步是 HTML 工作。获取索引页面并收集每个 .pdf href,解析为绝对 URL:
python
import urllib.parse
from bs4 import BeautifulSoup
def pdf_links(html, base_url):
soup = BeautifulSoup(html, "html.parser")
return sorted({
urllib.parse.urljoin(base_url, a["href"])
for a in soup.select('a[href$=".pdf"]')
})
urljoin 重要,因为这些 href 通常是相对站点的——测试页面返回 /pub/irs-pdf/fw9.pdf,单独无法获取。set 去重了多次链接的文档,而索引页面则不断如此。
在返回四个文档的实时运行中:
text
https://www.irs.gov/pub/irs-pdf/fw9.pdf
https://www.irs.gov/pub/irs-pdf/iw9.pdf
https://www.irs.gov/pub/irs-pdf/p1281.pdf
https://www.irs.gov/pub/irs-pdf/p5027.pdf
索引页面本身是通过 通用抓取 API 抓取的,它将渲染的 HTML 作为字符串返回——对于页面正好合适,而对于它链接的文档则完全不适合,正如下一阶段所示。
阶段 2:作为字节下载
python
import urllib.request
BROWSER_UA = ("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36")
def fetch_bytes(url):
"""Fetch a binary file. PDFs must arrive as bytes, never as text."""
request = urllib.request.Request(url, headers={"User-Agent": BROWSER_UA})
with urllib.request.urlopen(request, timeout=120) as response:
return response.read()
response.read() 没有 .decode() 是整个要点。下载的文件是 140,815 字节,以头部 %PDF- 开头,这是每个有效文档开始时的五字节签名——在解析之前做这种便宜的断言是值得的。
阶段 3:读取文档结构
python
import io
from pypdf import PdfReader
reader = PdfReader(io.BytesIO(raw))
print(len(reader.pages), reader.is_encrypted)
io.BytesIO 避免写入临时文件。实时文档报告 6 页 和 encrypted=False。加密值得及早检查:一个加密文档打开很好并且产生空文本,这看起来与一个简单没有文本层的文档完全相同。
阶段 4:提取文本
python
import pdfplumber
with pdfplumber.open(io.BytesIO(raw)) as pdf:
page = pdf.pages[0]
text = page.extract_text() or ""
words = page.extract_words()
第一页返回了 5,659 个字符,打开在行 'W-9',以及 933 个定位的单词。
or "" 不是防御性填充。extract_text() 当页面没有文本层——通常是扫描的图像——时返回 None,否则 None 将在与其原因相距甚远的地方失败。将其视为页面需要不同工具的信号,而不是空字符串。
extract_words() 是使 pdfplumber 成为值得依赖的工具。每个单词都有 x0、x1、top 和 bottom 坐标返回,因此当多列布局打乱了阅读顺序时,可以通过位置选择,而不是指望文本流是合理的。
第5阶段:提取表格
python
tables = page.extract_tables()
for index, table in enumerate(tables, 1):
widest = max(len(row) for row in table)
print(f"table {index}: {len(table)} rows x {widest} cols")
实时页面生成了四个表格:
text
table 1: 4 rows x 1 cols
table 2: 2 rows x 7 cols
table 3: 1 rows x 2 cols
table 4: 2 rows x 10 cols
一个单列表格和一个单行表格不是数据集。它们是表单布局中的框选区域,满足与真实表格相同的规则线启发式。这是值得内化的部分:extract_tables() 报告 它检测到的矩形结构,决定哪些结构携带记录是你的任务。在相信任何内容之前要先根据形状过滤 — 合理的最小值是两行和两列,收紧到文档实际使用的格式。
入门不需要卡 — 免费计划 覆盖了发现获取。
PDF 作为文本传输时发生了什么
第2阶段中所述的规则需要证据而非简单的断言,因此同一文档通过用于索引页面的文本返回端点以第二种方式获取,并测量结果:
text
same pdf through the text endpoint: 235403 bytes (real file is 140815)
pypdf reads it: failed (PdfReadError)
pdfplumber reads it: failed (PdfminerException)
文件增长了 67%。将任意字节解码为文本并重新编码,将 ASCII 范围以外的任何内容扩展为多字节序列,这是在 UTF-8 编码规范 中描述的机制,而从未有效的文本字节会被完全替换。结果仍然以 %PDF- 开头,这就是这个故障让人如此信服以至于浪费一个下午的原因。
一个细节比字节计数更重要。构建一个 PdfReader 在这些数据上没有任何返回 — 对象被创建,只有接触 reader.pages 失败。一个在“它是否打开”处停止的检查报告在一个无法读取的文件上成功,因此通过读取页面进行验证:
python
def page_count(data):
return len(PdfReader(io.BytesIO(data)).pages)
使用返回文本的 API 用于 HTML,使用返回字节的抓取用于文档。两者不可互换,且失败模式非常安静。
整个管道
python
import io
import json
import logging
import os
import urllib.parse
import urllib.request
import pdfplumber
from bs4 import BeautifulSoup
from pypdf import PdfReader
logging.getLogger("pypdf").setLevel(logging.CRITICAL)
logging.getLogger("pdfminer").setLevel(logging.CRITICAL)
UNLOCKER = "https://api.scrapeless.com/api/v2/unlocker/request"
INDEX = "https://www.irs.gov/forms-pubs/about-form-w-9"
BROWSER_UA = ("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36")
def fetch_html(url):
"""Fetch a rendered HTML page as text."""
payload = json.dumps({
"actor": "unlocker.webunlocker",
"input": {"url": url, "proxy_country": "US", "js_render": False},
}).encode()
request = urllib.request.Request(
UNLOCKER, data=payload,
headers={"Content-Type": "application/json",
"x-api-token": os.environ["SCRAPELESS_API_KEY"]},
)
with urllib.request.urlopen(request, timeout=120) as response:
return json.loads(response.read().decode())["data"]
def fetch_bytes(url):
"""Fetch a binary file. PDFs must arrive as bytes, never as text."""
request = urllib.request.Request(url, headers={"User-Agent": BROWSER_UA})
with urllib.request.urlopen(request, timeout=120) as response:
return response.read()
def pdf_links(html, base_url):
soup = BeautifulSoup(html, "html.parser")
return sorted({
urllib.parse.urljoin(base_url, a["href"])
for a in soup.select('a[href$=".pdf"]')
})
def main():
html = fetch_html(INDEX)
links = pdf_links(html, INDEX)
print(f"index page: {len(html)} chars")
print(f"pdf links discovered: {len(links)}")
for link in links:
print(f" {link}")
target = next(link for link in links if link.endswith("fw9.pdf"))
raw = fetch_bytes(target)
print(f"downloaded: {len(raw)} bytes, header {raw[:5].decode('latin-1')!r}")
reader = PdfReader(io.BytesIO(raw))
print(f"pypdf: {len(reader.pages)} pages, encrypted={reader.is_encrypted}")
with pdfplumber.open(io.BytesIO(raw)) as pdf:
page = pdf.pages[0]
text = page.extract_text() or ""
print(f"pdfplumber page 1: {len(text)} chars of text")
print(f" first line: {text.splitlines()[0][:60]!r}")
tables = page.extract_tables()
print(f"tables on page 1: {len(tables)}")
for index, table in enumerate(tables, 1):
widest = max(len(row) for row in table)
print(f" table {index}: {len(table)} rows x {widest} cols")
print(f"positioned words on page 1: {len(page.extract_words())}")
# A PDF is binary. Prove what happens if it travels as text.
as_text = fetch_html(target).encode("utf-8")
print(f"same pdf through the text endpoint: {len(as_text)} bytes "
f"(real file is {len(raw)})")
# Open AND read a page — a lenient constructor is not proof the file is usable.
def read_with_pypdf(data):
return len(PdfReader(io.BytesIO(data)).pages)
def read_with_pdfplumber(data):
with pdfplumber.open(io.BytesIO(data)) as opened:
return len(opened.pages)
for name, read in (("pypdf", read_with_pypdf), ("pdfplumber", read_with_pdfplumber)):
try:
print(f" {name} reads it: {read(as_text)} pages")
except Exception as exc:
print(f" {name} reads it: failed ({type(exc).__name__})")
if __name__ == "__main__":
main()
其输出:
text
index page: 99970 chars
pdf links discovered: 4
https://www.irs.gov/pub/irs-pdf/fw9.pdf
https://www.irs.gov/pub/irs-pdf/iw9.pdf
https://www.irs.gov/pub/irs-pdf/p1281.pdf
https://www.irs.gov/pub/irs-pdf/p5027.pdf
downloaded: 140815 bytes, header '%PDF-'
pypdf: 6 pages, encrypted=False
pdfplumber page 1: 5659 chars of text
first line: 'W-9'
tables on page 1: 4
table 1: 4 rows x 1 cols
table 2: 2 rows x 7 cols
table 3: 1 rows x 2 cols
table 4: 2 rows x 10 cols
positioned words on page 1: 933
same pdf through the text endpoint: 235403 bytes (real file is 140815)
pypdf reads it: failed (PdfReadError)
pdfplumber reads it: failed (PdfminerException)
顶部的两条 logging 线是值得保留的。两个库在损坏的输入上发出恢复警告,而在损坏文档上的噪声掩盖了唯一一条重要的线。
故障排除
extract_text() 返回 None 或一个空字符串。 页面没有文本层,这几乎总是意味着它是一个扫描图像。无论如何配置解析器,都无法恢复从未编码的文本;这项工作需要光学字符识别,这是一个不同的管道,具有不同的准确性特征。
文本在列之间交错输出。 文本流遵循字形书写的顺序,而不是阅读顺序。使用 extract_words() 并按 top 坐标分组,或使用 page.crop((x0, top, x1, bottom)) 裁剪页面并单独提取每一列。
extract_tables() 在显然有表格的页面上没有找到任何内容。 默认策略查找规则线。仅靠空格分隔的表格需要 table_settings={"vertical_strategy": "text", "horizontal_strategy": "text"},它通过对齐推断列。
单元格包含 None。 合并和空单元格会返回 None 而不是 ""。在写入任何内容之前进行规范化,将大部分为 None 的行视为检测伪影而不是记录。
每个文档都能解析,但数字是错的。 检查文件是否以字节形式获取。传输中损坏的文件仍然可以携带 %PDF- 头并构造阅读器对象,因此将下载的长度与服务器报告的 Content-Length 进行比较 — 该字段在 HTTP 语义规范 中定义。
结论
PDF 抓取的 PDF 部分是简单的部分。有两个库可以覆盖它:用于结构的 pypdf 和用于任何依赖于内容在页面上位置的 pdfplumber。
出问题的部分位于两侧。查找文档是 HTML 工作,而完整获取它们是一个运输问题,具有安静的失败模式 — 一个到达时增加 67% 的文件,仍然以 %PDF- 开头,仍然构造一个阅读器,但无法读取。在相信任何内容之前,请检查字节计数并读取页面。
准备好将其指向您自己的文档了吗?创建一个 免费 Scrapeless 账户,导出您的密钥,并在您已经收集的页面上运行发现阶段。计划限制在 定价页面 上。 如果您只需要从混合文档格式中提取纯文本,而不是页面几何, 文档到Markdown指南 更加轻便。
常见问题解答
问:我应该使用 pdfplumber 还是 pypdf?
当您需要文档级别的事实 — 页面计数、元数据、加密状态、合并或拆分文件 — 时使用 pypdf,当您需要知道页面上事物的位置时,使用 pdfplumber,这涉及表格提取和任何多列布局。它们愉快地共存;本文中的管道同时使用两者,pdfplumber 是更重的依赖,因为它带有完整的布局引擎。
问:为什么我的 PDF 下载成功但无法解析?
最常见的原因是它在传输过程中被解码为文本。使用 response.read() 进行提取,且不使用 .decode(),然后检查两件事:前五个字节是否为 %PDF-,以及长度是否与服务器所宣称的一致。文本的往返会膨胀文件 — 这里的 140,815 字节变成了 235,403 — 同时保持头部不变。
问:我可以从扫描的 PDF 中提取表格吗?
不可以使用这些库。扫描是图像,这两种工具读取文档所包含的文本和矢量层。 extract_text() 返回 None 是一个标志。恢复该内容需要 OCR,输出应被视为需要验证的估计,而不是提取的数据。
问:当 PDF 链接不是普通锚点时,我该如何找到它们?
在获取浏览器之前扩大选择器:许多网站通过没有 .pdf 后缀的重定向路径链接文档,因此请根据 URL 模式或链接文本进行匹配,而不是扩展名。如果链接确实由客户端 JavaScript 编写,则索引页面需要渲染 — 但请先检查初始 HTML,因为 URL 通常已在其中。
问:extract_tables() 对于生产环境来说够可靠吗?
对于有划线表格和稳定布局的文档,是的,只要您验证返回内容的形状。这里的实时页面返回了四个表格,其中大多数是布局区域,因此对最小行和列的过滤并不是可选的。当文档的表格仅由空格定义时,请切换到基于文本的策略,并重新检查与您自己阅读过的页面的结果。
问:我应该如何处理非常大的文档?
迭代 pdf.pages 而不是将所有内容物化,并在上下文管理器结束文档,如示例所示。如果您只需要文件的一部分,page.crop() 将工作限制在一个区域,并且 pypdf 可以在昂贵的几何工作开始之前将大型文档拆分为较小的文档。
在Scrapeless,我们仅访问公开可用的数据,并严格遵循适用的法律、法规和网站隐私政策。本博客中的内容仅供演示之用,不涉及任何非法或侵权活动。我们对使用本博客或第三方链接中的信息不做任何保证,并免除所有责任。在进行任何抓取活动之前,请咨询您的法律顾问,并审查目标网站的服务条款或获取必要的许可。



