PandasとScrapelessを使ったウェブスクレイピングパイプラインの構築方法
Advanced Data Extraction Specialist
TL;DR:
- Scrapeless Universal Scraping APIは、公開ページのレンダリングされたHTMLを返し、pandasの
read_htmlは、そのページのテーブルを単一の呼び出しでDataFrameに変換します。 - このガイドは、取得、発見、抽出、変換、保存の5つの明示的なステージとして作業を構築します。それぞれが数行のPythonコードです。
- pandas 3.xでは、
read_htmlはHTML文字列の周りにio.StringIOラッパーが必要です。裸の文字列はファイルパスとして読み込まれるようになり、エラーを引き起こします。 - クリーンアップは抽出の後に行います:列名を変更し、
pd.to_numericで数値型に強制し、ディスクに書き込む前に派生列を追加します。 - Parquetは列の型を保持しますが、CSVよりも自動的に小さくはありません。75行のサンプルでは、フッターのオーバーヘッドによりより大きなファイルになり、サイズの利点はボリュームで得られます。
- Scrapelessの無料プランから始めて、取得ステージを自分のソースにポイントしてください。
ほとんどのpandasチュートリアルは、すでに存在するCSVから始まります。実際の作業はそこから始まることは稀です。あなたが望む数字はHTMLページの中にあり、ナビゲーション、スタイリング、マークアップに囲まれています。通常のrequests.getではクリーンにさえ取得できないことがよくあります。「そのページにテーブルがある」と「メモリに型付きのDataFrameがある」の間のギャップが、スクレイピングパイプラインの存在する場所です。
この記事は、2つのツールでそのギャップを埋めます。Scrapeless Universal Scraping APIは、取得を処理し、ページをHTMLとして返します。pandasは構造を処理します:テーブルを読み込み、クリーンアップし、分析できる型付きファイルを書き込みます。例の対象は、NHLチームシーズンのページネーションされたテーブルを持つ公開スクレイピングサンドボックスで、以下のすべての数字は実際のページに対する実際の実行から来ています。
パイプラインの概要
このパイプラインには5つのステージがあり、それぞれが次のステージに単一の明確なオブジェクトを渡します:
fetch (HTML文字列) → discover (どのテーブル) → extract (DataFrame) → transform (型付き、クリーンなDataFrame) → store (CSV + Parquet)
ステージを分けておくことは、ページが変更されたときに効果を発揮します。レイアウトがシフトすると、発見ステージだけが移動します。列がテキストとして到着し始めると、変換ステージだけが変更されます。取得と保存のステージは触れられません。
ステージ1: ページをクリーンなHTMLとして取得
取得ステージは、Scrapelessに1つのリクエストを送信し、ページをHTML文字列として返します。リクエストにはactorとinputオブジェクトが必要です。unlocker.webunlockerアクターがページを取得し、APIキーはx-api-tokenヘッダーで送信されます。
このパイプラインには3つのパッケージが必要です:pandas自体、read_htmlのパーサー、および保存ステージ用のParquetエンジンです。これら3つを一度にインストールします。
bash
pip install pandas pyarrow lxml
python
import io
import json
import os
import urllib.request
import pandas as pd
API_URL = "https://api.scrapeless.com/api/v2/unlocker/request"
def fetch_html(url: str) -> str:
payload = json.dumps(
{"actor": "unlocker.webunlocker", "input": {"url": url, "js_render": False, "headless": False}}
).encode()
request = urllib.request.Request(
API_URL,
data=payload,
headers={"x-api-token": os.environ["SCRAPELESS_API_KEY"], "Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(request, timeout=90) as response:
return json.loads(response.read())["data"]
html = fetch_html("https://www.scrapethissite.com/pages/forms/")
tables = pd.read_html(io.StringIO(html))
df = tables[0]
print(f"ページのテーブル数: {len(tables)} | 形状: {df.shape}")
print("生の列:", list(df.columns))
レスポンスボディは、レンダリングされたマークアップをdataキーの下に持つJSONエンベロープです。そのため、json.loads(response.read())["data"]はページ全体を文字列として返します。ここでjs_renderは意図的にFalseに設定されています。その詳細はレンダリングされたページが必要なときのセクションで説明されています。
何かを実行する前に、シェルで一度キーを設定してください。実行時には実際のキーを使用し、プレースホルダをソースから除外してください。
bash
export SCRAPELESS_API_KEY="sk_your_key_here"
ステージ2: テーブルを発見する
発見はひとつの質問に答えます:ページ上のどのテーブルがデータを保持しているのか。上記のブロックを実行すると、このターゲットの答えが表示されます。
text
ページのテーブル数: 1 | 形状: (25, 9)
生の列: ['チーム名', '年', '勝利', '敗北', 'OT敗北', '勝率', '得点 (GF)', '失点 (GA)', '+ / -']
read_htmlはHTMLをスキャンし、見つけた各<table>要素ごとに1つのDataFrameを持つリストを返します。これはHTML表形式データ仕様と同じテーブルモデルに従います。このページには単一のテーブルがあるため、tables[0]が必要なものです。複数のテーブルがあるページでは、各テーブルの形状と最初の行を印刷し、列に一致するインデックスを選び、そのインデックスをハードコーディングします。生の列名は<th>セルからそのまま取得されるため、スペースや句読点が残っています。
ステージ 3: DataFrame に読み込む
抽出はすでに完了しています。read_htmlの目的は、それ自身の行とセルを手動でループすることなく、全体のテーブルをDataFrameに変換することです。pandas read_html のリファレンスは、通常pandas 3.xでの最初の試行を困難にするio.StringIOの要求事項を文書化しています。単純なHTML文字列はファイル名として解釈されますが、io.StringIO(html)でラップすることで、pandasが文字列自体を解析するようになります。
25行9列を手に入れると、 raw frameは使用可能ですが、まだクリーンではありません。列名は不自然で、すべての値はHTMLパーサーが推測した任意の型のままです。両方の問題は次のステージに属します。
ステージ 4: データを変換して型付けする
変換ステージでは、3つの仕事を順番に行います。列名を入力できるものに変更し、値を数値に強制し、分析に必要な派生列を追加します。
python
raw.columns = ["team", "year", "wins", "losses", "ot_losses", "win_pct", "goals_for", "goals_against", "goal_diff"]
numeric = ["year", "wins", "losses", "ot_losses", "win_pct", "goals_for", "goals_against", "goal_diff"]
raw[numeric] = raw[numeric].apply(pd.to_numeric, errors="coerce")
raw["games"] = raw["wins"] + raw["losses"] + raw["ot_losses"].fillna(0)
raw["winning_season"] = raw["win_pct"] >= 0.5
clean = raw.dropna(subset=["team", "wins"]).reset_index(drop=True)
pd.to_numericとerrors="coerce"が主力です。それはクリーンな数値を変換し、解析不可能なものをNaNに変換します。これは、古いシーズンが延長戦の損失のセルを空白にするため、ここでは重要です。fillna(0)は、試合数の計算時にこれらの空白をゼロとして扱います。そして、dropnaは、チーム名または勝利数が欠けている行を削除します。結果は、すべての数値列が本当に数値であり、派生 games および winning_season 列がグループ化およびフィルタリングの準備が整ったフレームです。
ステージ 5: CSV と Parquet として保存する
ストレージは、異なるニーズに応えるためにクリーンなフレームを2回書き込みます。
python
clean.to_csv("teams.csv", index=False)
clean.to_parquet("teams.parquet", index=False)
CSVはポータブルで、スプレッドシートからシェルのワンライナーまで、あらゆるものによって読み取られます。また、広く実装されているカンマ区切り値形式に従っています。そのコストは書き込み時に型が失われることです。すべての列は返る時にはテキストになります。Parquetはスキーマを保持します。teams.parquetを読み戻すと、winsはまだ整数であり、win_pctはまだ浮動小数点数です。再強制変換はなく、Apache Parquetファイル形式が各列の型をその値と一緒に保存するからです。
Parquetは、伝説とは裏腹に、必ずしも小さなファイルではありません。この75行のサンプルでは、CSVは4,379バイトで、Parquetファイルは8,999バイトです。これは、Parquetの列ごとのメタデータとフッターが固定のオーバーヘッドであり、小さなデータセットでは償却できないからです。サイズが最も重要な場合は、小さな結果をCSVとして保存します。行数が数万に増え、型付けされた列指向の読み取りがバイト数よりも重要になるときはParquetを選択してください。
完全なパイプライン
5つのステージを1つのスクリプトにまとめてページネーションを追加すると、パイプラインは3ページを取得し、75行のフレームを構築し、クリーンアップし、両方のファイルを書き込みます。
python
import io
import json
import os
import urllib.request
import pandas as pd
API_URL = "https://api.scrapeless.com/api/v2/unlocker/request"
BASE = "https://www.scrapethissite.com/pages/forms/"
PAGES = 3 # 限界: 公共サンドボックステーブルの3ページ
def fetch_html(url: str) -> str:
"""ステージ 1 - Scrapeless Universal Scraping APIを介してレンダリングされたHTMLを取得します。"""
payload = json.dumps(
{"actor": "unlocker.webunlocker", "input": {"url": url, "js_render": False, "headless": False}}
).encode()
request = urllib.request.Request(
API_URL,
data=payload,
headers={"x-api-token": os.environ["SCRAPELESS_API_KEY"], "Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(request, timeout=90) as response:
return json.loads(response.read())["data"]
ステージ1-3 - 各ページを取得し、単一のテーブルを発見し、それをDataFrameに直接読み込む
frames = []
for page in range(1, PAGES + 1):
html = fetch_html(f"{BASE}?page_num={page}")
frames.append(pd.read_html(io.StringIO(html))[0])
raw = pd.concat(frames, ignore_index=True)
print(f"取得したページ数: {PAGES} | 解析した行数: {len(raw)}")
ステージ4 - 変換: 列名をクリーンアップし、数値型に変換し、派生列を追加
raw.columns = ["チーム", "年", "勝利", "敗北", "OT敗北", "勝率", "得点", "失点", "得失点差"]
numeric = ["年", "勝利", "敗北", "OT敗北", "勝率", "得点", "失点", "得失点差"]
raw[numeric] = raw[numeric].apply(pd.to_numeric, errors="coerce")
raw["試合数"] = raw["勝利"] + raw["敗北"] + raw["OT敗北"].fillna(0)
raw["勝ち越しシーズン"] = raw["勝率"] >= 0.5
clean = raw.dropna(subset=["チーム", "勝利"]).reset_index(drop=True)
print(f"クリーン後の行数: {len(clean)} | 勝ち越しシーズン: {int(clean['勝ち越しシーズン'].sum())}")
best = clean.sort_values("勝利", ascending=False).iloc[0]
print(f"最高シーズン: {best['チーム']} {int(best['年'])} ({int(best['勝利'])} 勝利)")
ステージ5 - フレームをCSVとして保存し、ポータビリティのためにParquetとして型付きカラムリーディングを行う
clean.to_csv("teams.csv", index=False)
clean.to_parquet("teams.parquet", index=False)
print(f"csv バイト数: {os.path.getsize('teams.csv')} | parquet バイト数: {os.path.getsize('teams.parquet')}")
実行すると各ステージのコンパクトなトレースが表示されます:
```text
取得したページ数: 3 | 解析した行数: 75
クリーン後の行数: 75 | 勝ち越しシーズン: 26
最高シーズン: ピッツバーグ・ペンギンズ 1992 (56 勝利)
csv バイト数: 4379 | parquet バイト数: 8999
page_num クエリパラメータはページネーションを制御し、PAGES は実行を3ページに制限しているため、この例は小さくて丁寧です。この定数を上げてカバレッジを広げ、1か所に保持することで、制限を決定として扱い、偶然ではなくします。
自分が気にかけるソースを指すために BASE URL と列名を変更すれば、5段階のフレームはそのまま引き継がれます。これらの各段階がどのようにフィットするかについての広い文脈が必要な場合は、ETLパイプラインとはのガイドが、抽出、変換、ロードを一般的なパターンとして説明しています。
レンダリングされたページが必要な場合
このパイプラインは js_render を False に設定しており、これは意図的な選択です。サンドボックステーブルはサーバーが送信するHTMLに存在するため、ブラウザがレンダリングする必要はなく、レンダリングをスキップすることで各フェッチが速くなります。多くのページは異なります: 欲しいテーブルは初期HTMLがロードされた後にJavaScriptによって挿入されるため、レンダリングせずにフェッチすると空のシェルが返されます。read_html がブラウザで見ることのできるページのテーブルをゼロ件見つけると、js_render を True に設定してScrapelessがスクリプトが実行された後のページを返すようにします。レンダリングが必要ないページをレンダリングすることで遅延が追加されるため、ソースごとに決定してください。
これをスケールアップする前に、ターゲットの robots.txt と利用規約を読みましょう。ロボット排除プロトコルは、自動クライアントに対してどのパスを無視するよう求めているかを教えてくれます。それを遵守することで、データパイプラインが依存するサイトの正しい側に留まることができます。この例のように、ボリュームを制限し、ターゲットを公開してください。
実際のソースに対してこれを実行する準備はできていますか? 無料のScrapelessアカウントを作成し、フェッチステージのURLを変更しましょう。
結論
スクレイピングパイプラインは5つの小さなステージから成り、それぞれが1つの作業を行います。Scrapelessがページを取得し、read_html がテーブルを抽出し、to_numeric といくつかの代入がクリーンアップを行い、2つの to_ 呼び出しがそれを保存します。ステージが別々になっているため、パイプラインは変更に耐えます: 新しいレイアウトは発見に影響を与え、新しい列の型は変換に影響を与え、残りはそのまま保たれます。上記の動作するスクリプトから始めて、自分のターゲットをスワップし、データの要求に応じて変換ステージを成長させましょう。
Scrapelessの無料プランから始める ことによって、自分のページに対してフェッチステージを実行し、Scrapelessの価格を確認して定期的なジョブのサイズを決めましょう。
FAQ
Q: なぜ pandas.read_html はpandas 3.xでHTML文字列で失敗するのですか?
生の文字列引数はファイルパスまたはURLとして扱われるため、pandasはそれを開こうとしてエラーを発生させます。マークアップを io.StringIO(html) に包んでそれを渡してください。そうすれば、read_html はメモリ内の文字列を解析してDataFrameのリストを返します。
Q: read_html を使用するのにBeautifulSoupまたはlxmlは必要ですか?
read_htmlはHTMLパーサーがインストールされている必要があり、内部ではlxmlまたはhtml5libを使用しますので、pandasと一緒にどちらかをインストールしてください。テーブル抽出のために自分でパーサーコードを書く必要はなく、read_htmlがパーサーを操作してDataFrameを返します。
Q: js_renderをTrueに設定すべき時はいつですか?
最初のHTMLがロードされた後にJavaScriptによってデータがページに追加されるときにTrueに設定してください。これは、明らかにブラウザにテーブルがあるページでread_htmlがゼロのテーブルを見つけることとして現れます。サーバーのHTMLにすでにテーブルがある場合はFalseのままにしてください。不要なページをレンダリングするとレイテンシが追加されるからです。
Q: スクレイピングしたデータをCSVまたはParquetとして保存すべきですか?
移植性があり、人間が読みやすいファイルが必要で、行数が少ない場合はCSVを選択してください。列データ型を保持したい場合やデータセットが十分に大きく、型付きの列の読み取りが重要な場合はParquetを選択してください。非常に小さなサンプルの場合、Parquetは固定フッターオーバーヘッドのために大きなファイルになることがあるので、サイズだけで判断するとデータが増えるまではCSVが有利です。
Q: いくつかのテーブルがあるページをどのように処理しますか?
read_htmlは、リスト内の別々のDataFrameとしてすべてのテーブルを返しますので、各要素の形状と最初の行を印刷して、必要なものを特定します。その後、直接インデックスを指定します。位置が安定していることがわかったら、そのインデックスを抽出段階にハードコードします。
Q: ページを追加するときにスクレイピングを礼儀正しく保つにはどうすればよいですか?
PAGESがここで行っているように、ページの範囲を単一の定数に保ちます。こうすることで、カバレッジを広げることが無限ループではなく、意図的な編集になります。最初にサイトのrobots.txtと利用規約を読み、ターゲットが吸収できるボリュームでのみ公開データを収集してください。
Scrapelessでは、適用される法律、規制、およびWebサイトのプライバシーポリシーを厳密に遵守しながら、公開されているデータのみにアクセスします。 このブログのコンテンツは、デモンストレーションのみを目的としており、違法または侵害の活動は含まれません。 このブログまたはサードパーティのリンクからの情報の使用に対するすべての責任を保証せず、放棄します。 スクレイピング活動に従事する前に、法律顧問に相談し、ターゲットウェブサイトの利用規約を確認するか、必要な許可を取得してください。



