Pythonを使ったPDFのスクレイピング:リンク発見から抽出したテーブルまで
Web Data Collection Specialist
TL;DR:
- PDFのスクレイピングはそれをリンクするHTMLページから始まる。 生のインデックスページは、解析が始まる前に4つのドキュメントURLを提供し、その発見ステップはほとんどのガイドでスキップされる部分です。
- PDFはバイトとして到着する必要がある。 同じドキュメントをテキストを返すエンドポイントを経由させた結果、235,403バイトになり、実際のファイルは140,815バイトで、pypdfもpdfplumberも結果からページを読み取ることができませんでした。
- ファイルを開くことはそれを読むことではない。 それらの壊れたバイト上にリーダーを構築しても何も得られず、ページに触れたときにのみ失敗が現れました。
- pdfplumberはページのジオメトリを提供しますが、pypdfはドキュメント構造を提供します。 ある生のページは5,659文字のテキスト、4つの検出されたテーブル、933の配置された単語を返しました。
- 検出されたテーブルがすべてデータではない。 それらの4つのテーブルには、それぞれ1、7、2、および10の列があり、そのほとんどはレイアウトの足場であり、レコードではありません。
- 無料で始めよう。 リンク発見のフェッチは、Universal Scraping APIの無料プランで実行されます。
公共機関は、最も有用なデータをPDFとして公開します。予算、申請、統計、および検査結果は印刷用に設計されたドキュメントとして到着し、HTMLで止まるスクレイパーはそれらのいずれも見ることがありません。
このガイドは、4つのPDFをリンクするページから始まり、1つをダウンロードし、テキスト、テーブル、および単語の位置を引き出し、ファイルがそこに到達するために誤ったルートを取ったときに正確に何が起こるかを測定します。
PDFとは、スクレイピングの目的のために
PDFは、ページ上のマークの位置を説明するバイナリコンテナです。段落、見出し、またはテーブルの概念はなく、座標にあるグリフのみ、PDFファミリーのライブラリオブコングレス形式説明にカタログ化されたページ記述モデルです。そのメディアタイプは、application/pdfメディアタイプ仕様に登録されており、形式がテキストでないことから、正確にバイナリです。
その単独の事実が以下のすべてを駆動します。「テキスト」を抽出することは、位置から読み取り順を再構築することを意味し、「テーブル」を抽出することは、ルーリングラインと整列から行と列を推測することを意味します。2つのライブラリが作業を分けます:
- 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は、インデックスページが常に行っているように、1回以上リンクされたドキュメントを重複排除します。
4つのドキュメントを返した生の実行で:
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
インデックスページ自体は、Universal Scraping 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-で始まります。これは、すべての有効なドキュメントが始まる5バイトの署名であり、解析の前に行っておく価値のある安価な主張です。
ステージ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()
ページ1は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")
ライブページは4つのテーブルを生成しました:
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
1カラムのテーブルと1行のテーブルはデータセットではありません。それらは、実際のテーブルと同じルーリングラインヒューリスティックを満たすフォームのレイアウトのボックス領域です。これが内面的に理解する価値のある部分です:extract_tables() は 検出した長方形構造を報告します。どの構造がレコードを持っているかを決定するのはあなたの仕事です。信頼する前に形をフィルタリングしてください — 妥当な最小限は2行と2列で、あなたのドキュメントが実際に使用しているものに合わせて厳しくします。
開始するのにカードは必要ありません — 無料プラン が探索フェッチをカバーします。
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- で始まります。これがこの失敗が午後を無駄にするには十分に説得力がある理由です。
1つの詳細がバイト数よりも重要です。そのデータの上に PdfReader を構築しても 何も 生成されません — オブジェクトは作成され、reader.pages に触れることだけが失敗します。「開けましたか」というチェックは、読み取れないファイルで成功を報告するので、ページを読み込んで確認してください:
python
def page_count(data):
return len(PdfReader(io.BytesIO(data)).pages)
HTMLにはテキスト返却APIを、ドキュメントにはバイト返却フェッチを使用してください。2つは互換性がなく、失敗モードは静かです。
全体のパイプライン
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)
上部の2つの logging ラインは保持する価値があります。両方のライブラリは、損傷した入力に関する回復警告を出力し、壊れたドキュメントではそのノイズが重要な1行を埋めてしまいます。
トラブルシューティング
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 部分は簡単な部分です。2つのライブラリがそれをカバーします:構造のための pypdf、ページ上の事物の位置に依存する何かのための pdfplumber です。
うまくいかない部分は両側にあります。ドキュメントを見つけるのは HTML の作業であり、それらを無傷で取得するのは静かな失敗モードを持つ輸送の質問です — 67% サイズが大きくなったファイルは、依然として %PDF- で始まり、まだリーダーを構築し、読み取ることができません。バイト数を主張し、どれも信じる前にページを読み取ってください。
あなた自身のドキュメントを対象にする準備はできましたか?無料のScrapelessアカウントを作成し、キーをエクスポートし、すでに収集しているページに対してディスカバリーステージを実行してください。プランの制限は料金ページにあります。ページのジオメトリではなく、混合文書形式からプレーンテキストのみが必要な場合は、ドキュメントからMarkdownへのガイドが軽い道をカバーしています。
FAQ
Q: pdfplumberとpypdfのどちらを使用すべきですか?
ドキュメントレベルの事実 —— ページ数、メタデータ、暗号化状態、ファイルのマージまたは分割 —— が必要な場合はpypdfを使用し、ページ上の情報の配置が必要な場合はpdfplumberを使用してください。pdfplumberはテーブル抽出や任意のマルチカラムレイアウトをカバーします。両者は幸せに共存しており、この投稿のパイプラインは両方を使用しており、pdfplumberは完全なレイアウトエンジンを携えているため、重い依存関係です。
Q: PDFのダウンロードは成功したのに解析に失敗するのはなぜですか?
最も一般的な理由は、転送中のどこかでテキストとしてデコードされたことです。response.read() を使用して取得し、.decode() は使用せず、その後二つのことを確認してください:最初の五バイトが %PDF- であり、サーバーが広告した長さと一致すること。テキストのラウンドトリップはファイルを膨らませます —— この測定では140,815バイトが235,403バイトになりました —— 一方でヘッダーはそのままです。
Q: スキャンしたPDFからテーブルを抽出できますか?
これらのライブラリではできません。スキャンは画像であり、両方のツールは文書が持つテキストおよびベクターレイヤーを読み取ります。extract_text()がNoneを返すことがその証拠です。そのコンテンツを回復するにはOCRが必要で、出力は抽出データとしてではなく、検証するための見積もりとして扱われるべきです。
Q: プレーンアンカーでない場合、PDFリンクをどのように見つけますか?
ブラウザに手を伸ばす前にセレクターを広げてください:多くのサイトは、.pdfサフィックスのないリダイレクトパスを介して文書にリンクを設定しますので、拡張子ではなくURLパターンまたはリンクテキストとの一致を確認してください。リンクが本当にクライアント側のJavaScriptで書かれている場合、インデックスページはレンダリングが必要です — しかし、最初のHTMLをまず確認してください。URLは頻繁にすでにそこにあります。
Q: extract_tables()は本番環境で十分信頼性がありますか?
ルールドテーブルと安定したレイアウトのある文書については、戻ってくるものの形を検証する場合に限り、はい。ここにあるライブページは、ほとんどがレイアウト領域である4つのテーブルを返し、したがって最小の行数と列数に基づくフィルターはオプションではありません。文書のテーブルがホワイトスペースだけで定義されている場合は、テキストベースの戦略に切り替え、あなた自身が読んだページに対して結果を再確認してください。
Q: 非常に大きな文書をどのように扱うべきですか?
すべてを具現化するのではなくpdf.pagesを繰り返し、例のようにコンテキストマネージャーで文書を閉じてください。ファイルの一部だけが必要な場合、page.crop()は作業を領域に制限し、pypdfは高価なジオメトリ作業が始まる前に大きな文書を小さな文書に分割できます。
Scrapelessでは、適用される法律、規制、およびWebサイトのプライバシーポリシーを厳密に遵守しながら、公開されているデータのみにアクセスします。 このブログのコンテンツは、デモンストレーションのみを目的としており、違法または侵害の活動は含まれません。 このブログまたはサードパーティのリンクからの情報の使用に対するすべての責任を保証せず、放棄します。 スクレイピング活動に従事する前に、法律顧問に相談し、ターゲットウェブサイトの利用規約を確認するか、必要な許可を取得してください。



