🎯 カスタマイズ可能で検出回避型のクラウドブラウザ。自社開発のChromiumを搭載し、ウェブクローラーAIエージェント向けに設計されています。👉今すぐ試す
ブログに戻ります

ScrapelessとDuckDBを使用してスクレイピングデータ分析パイプラインを構築する

Emily Chen
Emily Chen

Advanced Data Extraction Specialist

22-Jul-2026

概要:

  • JSONファイルで終わるスクレイピングパイプラインは、パイプラインの半分に過ぎません。データが到着した後に分析の質問が生まれます。
  • DuckDBはJSON Linesを直接読み取れるため、スクレイピングされたレコードはサーバー、スキーママイグレーション、ETLツールなしでクエリ可能なテーブルになります。
  • このパイプラインは、Scrapeless Universal Scraping APIを通じて3ページを取得し、30レコードを抽出し、それをDuckDBにロードし、Parquet形式で保存します。
  • ZSTD圧縮されたParquetは、30レコードを4,674バイトで保存し、JSON Linesの7,690バイトに対抗し、DuckDBはファイルを直接クエリします。
  • 以下の各ステージは、クラウドウェアハウスアカウントやScrapelessキー以外の認証情報なしであなたのマシン上で実行されます。
  • Scrapelessの無料プランから始めて、フェッチステージをあなた自身のソースに向けてください。

パイプラインの概観

フローは5つのステージからなり、データがテキストからテーブルに変わる面白いデザインの決定が行われます。

fetch (Scrapeless) → discover records → extract fields → transform to a typed table (DuckDB) → store as Parquet

JSON Linesはスクレイピング半分と分析半分の間の受け渡し形式です。これは追加に優しく、クラッシュした実行を生き延び、以前のものを損なうことなく、DuckDBがネイティブに読み取るため、書き込むローダーは必要ありません。この形式はJSON Lines仕様で指定されています。

ここにはウェアハウスアカウントは必要ありません。DuckDBはプロセス内で実行されるため、スクレイピングされたデータに対してリアルSQLを取得する最も安価な方法となります。宛先が管理されたウェアハウスである場合、Snowflakeのインジェスションガイドは異なるランディングゾーンで同じ収集ステージをカバーします。

前提条件

  • Python 3.9以上。
  • ダッシュボードからのScrapeless APIキー。
  • duckdbがインストールされていること:
bash Copy
pip install "duckdb==1.5.4"

キーを設定する:

bash Copy
export SCRAPELESS_API_KEY="your_api_key_here"

ステージ1–3:フェッチ、発見、抽出

Scrapeless Universal Scraping APIを通じて1ページごとに1回の呼び出しで、レンダリングされたHTMLが返され、標準ライブラリのパーサーが各引用ブロックをレコードに変換します。エクストラクターは1行ごとに1つのJSONオブジェクトを出力します:

python Copy
import json, os, urllib.request
from html.parser import HTMLParser

API = "https://api.scrapeless.com/api/v2/unlocker/request"

def fetch(url: str) -> str:
    payload = json.dumps({
        "actor": "unlocker.webunlocker",
        "input": {"url": url, "js_render": True, "headless": True},
    }).encode()
    req = urllib.request.Request(
        API, data=payload,
        headers={"x-api-token": os.environ["SCRAPELESS_API_KEY"],
                 "Content-Type": "application/json"},
    )
    with urllib.request.urlopen(req, timeout=120) as r:
        return json.loads(r.read())["data"]

class QuoteParser(HTMLParser):
    def __init__(self):
        super().__init__()
        self.rows, self._cur, self._cap = [], None, None
    def handle_starttag(self, tag, attrs):
        a = dict(attrs); cls = a.get("class", "")
        if tag == "div" and "quote" in cls:
            self._cur = {"text": "", "author": "", "tags": []}
        elif self._cur is not None and tag == "span" and "text" in cls:
            self._cap = "text"
        elif self._cur is not None and tag == "small" and "author" in cls:
            self._cap = "author"
        elif self._cur is not None and tag == "a" and "tag" in cls:
            self._cap = "tag"
    def handle_data(self, data):
        if self._cap == "text": self._cur["text"] += data
        elif self._cap == "author": self._cur["author"] += data
        elif self._cap == "tag": self._cur["tags"].append(data.strip())
    def handle_endtag(self, tag):
        if self._cap in ("text", "author", "tag"): self._cap = None
        if tag == "div" and self._cur and self._cur["text"]:
            self.rows.append(self._cur); self._cur = None

rows = []
for page in range(1, 4):
    p = QuoteParser(); p.feed(fetch(f"https://quotes.toscrape.com/page/{page}/"))
    rows.extend({"page": page, **r} for r in p.rows)

print(f"取得したページ数: 3 | 抽出したレコード数: {len(rows)}")
with open("quotes.jsonl", "w", encoding="utf-8") as f:
    for r in rows: f.write(json.dumps(r, ensure_ascii=False) + "\n")
print(f"quotes.jsonlに書き込みました ({os.path.getsize('quotes.jsonl')} バイト)")
text Copy
取得したページ数: 3 | 抽出したレコード数: 30
quotes.jsonlに書き込みました (7690 バイト)

ここでは、あなたのバージョンに保っておくべき2つの選択肢があります。
ページ番号は、抽出時に各レコードに付加されます。出所情報は収集時に記録するのはほぼ無料ですが、その後に再構築するのは高額です。これにより、「このデータはどのページから来たのか?」という質問に対して、何も再実行せずに答えることができます。

ensure_ascii=Falseは、タイポグラフィックな引用符をエスケープするのではなく、そのまま保持します。テキストがデータであるとき、これは重要です。エスケープされた出力は依然として有効なJSONですが、ファイルのサイズが膨らみ、後での検査が難しくなります。

ステージ4: 型付きテーブルへの変換

DuckDBはJSON Linesファイルを直接読み込みます。read_json_autoはスキーマを推測し、周囲のSELECTは実際に必要な型と派生カラムを指定する場所です。

python Copy
import duckdb

con = duckdb.connect("quotes.duckdb")

con.execute("""
    CREATE OR REPLACE TABLE quotes AS
    SELECT
        page::INTEGER            AS page,
        text                     AS quote_text,
        author,
        tags,
        len(tags)                AS tag_count
    FROM read_json_auto('quotes.jsonl')
""")

total = con.sql("SELECT count(*) FROM quotes").fetchone()[0]
authors = con.sql("SELECT count(DISTINCT author) FROM quotes").fetchone()[0]
print(f"読み込まれた行数: {total} | 異なる著者数: {authors}")

print(con.sql("""
    SELECT author, count(*) AS quotes, round(avg(tag_count), 2) AS avg_tags
    FROM quotes
    GROUP BY author
    ORDER BY quotes DESC, author
    LIMIT 5
""").to_df().to_string(index=False))

con.execute("COPY quotes TO 'quotes.parquet' (FORMAT PARQUET, COMPRESSION ZSTD)")
import os
print(f"parquetバイト数: {os.path.getsize('quotes.parquet')} | jsonlバイト数: {os.path.getsize('quotes.jsonl')}")

rt = duckdb.sql("SELECT count(*) AS n, count(DISTINCT author) AS a FROM 'quotes.parquet'").fetchone()
print(f"パーケットからの往復 -> 行数: {rt[0]}, 著者数: {rt[1]}")
text Copy
読み込まれた行数: 30 | 異なる著者数: 20
         著者      引用   平均タグ数
アルバート・アインシュタイン       6      2.83
   J.K.ローリング       3      1.33
     ボブ・マーリー       2      1.00
      ドクター・スース       2      2.00
 マリリン・モンロー       2      4.00
parquetバイト数: 4674 | jsonlバイト数: 7690
パーケットからの往復 -> 行数: 30, 著者数: 20

tagsカラムは平坦化されず、リストのまま保持されます。DuckDBはネストされた型をパーケットに持ち込み、len(tags)はSQLで機能し、配列は往復を生き残ります。この段階で"a,b,c"に平坦化するのは、失われるデータを生む慣習であり、断ち切る価値があります。

CREATE OR REPLACE TABLEは、ロードを冪等にします。ステージを再実行すると、重複を追加するのではなく、現在のファイルからテーブルが再構築されます。これは、抽出器をまだ繰り返し使用している間に望む動作です。

集計はこの全体の作業の核心です:30レコード、20の異なる著者、そしてそのうち6つを占める著者が1人います。この質問はテーブルに対する1行のクエリであり、JSONファイルに対する煩わしいループです。

あなたが気に入っているソースに対してこれを実行する準備はできていますか? 無料のScrapelessアカウントを作成し、フェッチステージのURLを交換してください。

ステージ5: パーケットとして保存

同じ30レコードが、ZSTD圧縮されたパーケットとして4,674バイトを占め、JSON Linesとして7,690バイトを占めます。このサンプルでは、節約は控えめですが、重要なのはフォーマットがスケール時に何を実現できるかということです。

パーケットはカラム指向であり、各カラムの値を自身のエンコーディングと共に格納します。これはApache Parquetファイルフォーマット仕様で定義されています。5つのカラムのうち2つに触れるクエリは、その2つだけを読み取ります。ここで使用される圧縮コーデックはZstandard圧縮標準で指定されています。

往復ラインはこのステージ自身のテストです。パーケットファイルを読み戻すと、元のテーブルと一致する30行と20の異なる著者が返されます。これは、他のシステムが読み取るファイルを書き込むパイプラインでは確認する価値があります。

最終的なクエリは、インポートステップやデータベースファイルへのオープン接続なしに、'quotes.parquet'を直接テーブルとして読み取ります。これがパーケットの中でスクレイピングパイプラインを終える実務的な理由です。出力はDuckDBによってクエリ可能であり、ほとんどの他の分析エンジンでもまさにその位置に存在します。

完全なパイプライン

1つのスクリプトとして実行される場合、5つのステージは単一の通過で読むには十分短いです。これがコピーするためのバージョンです:

python Copy
import json, os, urllib.request
from html.parser import HTMLParser

API = "https://api.scrapeless.com/api/v2/unlocker/request"

def fetch(url: str) -> str:
    payload = json.dumps({
        "actor": "unlocker.webunlocker",
        "input": {"url": url, "js_render": True, "headless": True},
    }).encode()
    req = urllib.request.Request(
        API, data=payload,
text Copy
取得したページ数: 3 | 抽出されたレコード数: 30
読み込まれた行数: 30 | 異なる著者数: 20
Parquet バイト数: 4674 | JSONL バイト数: 7690
Parquet からの往復 -> 行数: 30, 著者数: 20

このパイプラインの次のステップ

収集日でパーティション分割をする。繰り返し実行することで、data/dt=<収集日>/quotes.parquet レイアウトに書き込むことができ、クエリが過去をスキャンするのではなく、全ディレクトリをスキップできます。

JSON Linesファイルを保持する。これらは収集された内容の生データであり、Parquetは派生した型付きの成果物です。抽出器にバグが発生した場合、再収集するのではなく、生のファイルから再派生します。

ステージ間にアサーションを追加する。抽出とロードの間のカウントチェックは、セレクタが静かに一致しなくなったことをキャッチします — これは数週間後に行数の静かな減少として現れる失敗モードです。

この処理をライブソースに向ける前に、利用規約とその /robots.txt 指示を確認してください。これは、ロボット排除プロトコル標準に従います。収集は公開ページに限り、上記のような制限されたページ範囲内に留めておいてください。

結論

スクレイパーと分析パイプラインの間のギャップは見た目ほど大きくありません。JSON Linesをハンドオフとして、DuckDBをクエリエンジンとして、Parquetを保存された成果物として、1つの依存関係とインフラストラクチャなしでカバーしています — 3ページで、30の型付き行を生成し、その場でクエリ可能です。

ほとんどのパイプラインが飛ばすステップは最後のステップです。Parquetを書き込み、それを再読み込みしてカウントが一致することを確認することで、ストレージのステップが確認済みのステップに変わります。これは、持っているファイルと信頼できるファイルの違いです。

Scrapelessの無料プランで始めると、自分のターゲットに対して取得ステージを実行し、定期的なジョブを設定する際には、Scrapelessの料金を確認してください。

FAQ

Q: なぜスクレイピングデータにDuckDBを使用するのか、クラウドウェアハウスではなく?

Copy
DuckDBはサーバーなし、アカウントなし、ネットワークの往復なしでプロセス内で動作し、実際にほとんどのスクレイピングプロジェクトが操作するスケールに適しています。JSONとParquetをネイティブに読み込むため、ローダーを記述する必要はありません。クラウドウェアハウスは複数のチームが同じテーブルに同時アクセスする必要があるときにその地位を得ます — パイプラインが自身の出力に対してSQLを必要とする場合ではありません。

**Q: ロードの前にタグリストのようなネストされたフィールドをフラット化する必要がありますか?**

いいえ、フラット化は情報を失います。DuckDBはリストタイプをエンドツーエンドでサポートしているので、`tags`はロード、SQLの`len(tags)`、Parquetの書き込みと読み戻しを通じて配列のままです。区切られた文字列に圧縮すると、その後のすべてのクエリが再解析を強いられます。

**Q: スクレイピングとロードの間にJSON Linesを書く理由は何ですか?**

それは二つの半分を分離します。スクレイピングは遅く、失敗しやすい部分です;レコードがディスクに一行ずつ保存されたら、再取得せずに何度でもロードと変換を再実行できます。一行ずつ追加することは、処理が途中で終了した場合でも、以前のレコードがそのまま保たれ、読みやすい状態になることを意味します。

**Q: ParquetはJSON Linesよりどのくらい小さいですか?**

この30レコードのサンプルでは、4,674バイト対7,690バイト — おおよそ40%小さいです。このサイズでその比率を過度に解釈しないでください、ファイルのオーバーヘッドが支配的なためです。Parquetの本当の利点はカラムリードです:五つのカラムのうち二つに触れるクエリはその二つだけを読み込むため、ファイルがメモリ内に快適に収まらなくなるボリュームでは重要です。

**Q: Parquetファイルをデータベースにロードせずにクエリできますか?**

はい、ロード段階の最後の行は正にそれを行います — データベース接続を開かず、インポートなしで`SELECT ... FROM 'quotes.parquet'`を実行します。これがParquetをスクレイピングパイプラインの良い最終アーティファクトにする理由です:出力はDuckDBや他の分析エンジンによって、その場にある限りクエリ可能なままです。

Scrapelessでは、適用される法律、規制、およびWebサイトのプライバシーポリシーを厳密に遵守しながら、公開されているデータのみにアクセスします。 このブログのコンテンツは、デモンストレーションのみを目的としており、違法または侵害の活動は含まれません。 このブログまたはサードパーティのリンクからの情報の使用に対するすべての責任を保証せず、放棄します。 スクレイピング活動に従事する前に、法律顧問に相談し、ターゲットウェブサイトの利用規約を確認するか、必要な許可を取得してください。

最も人気のある記事

カタログ