Python Webスクレイピング 2026:アンチボット回避とプロダクションクローラーガイド

编程语言

2026年のWebスクレイピングの課題

2026年のWebは、もはやシンプルな静的ページの時代ではありません。現代のウェブサイトは多層的なアンチスクレイピングメカニズムを導入しており、データ収集がますます困難になっています:

  • アンチボット検出:Cloudflare Turnstile、Akamai Bot Manager、PerimeterXなどの商用ソリューションが、行動分析、TLSフィンガープリント、Canvasフィンガープリントなどで自動化リクエストを正確に識別
  • CAPTCHAの進化:シンプルな画像認証からhCaptcha、reCAPTCHA v3のインビジブル認証、GeeTestのスライダー認証へ進化し、AI支援による解読が必要な場合も
  • 動的レンダリング:React/Vue/Svelteで構築されたSPAアプリケーションは、JavaScriptで動的にページコンテンツを生成するため、従来のrequests + BeautifulSoupアプローチが完全に無効化
  • リクエスト頻度制限:IP + User-Agent + Cookieに基づく多次元スロットリングにより、単純な時間間隔制御では回避不可能に
  • データ暗号化:Webpackによる難読化、API署名検証、レスポンスデータ暗号化などで重要データを保護

これらの課題に対し、体系的なスクレイピングアプローチが必要です。本記事では、コアコンセプトから始め、プロダクショングレードのスクレイピングシステムを段階的に構築します。


コアコンセプトリファレンス

コンセプト 説明 代表的なツール
静的スクレイピング HTMLを直接リクエストしDOMを解析 requests + BeautifulSoup
動的レンダリング ブラウザのJS実行をシミュレート Playwright、Selenium
アンチボット回避 リクエスト特性を偽装し検出を回避 プロキシプール、フィンガープリント偽装、CAPTCHAサービス
分散スクレイピング マルチノード同時クローリング Scrapy + Redis、Celery
データパイプライン スクレイピング結果のクリーニング、変換、保存 Pandas、SQLAlchemy、Item Pipeline
インクリメンタルスクレイピング 新規/変更コンテンツのみ取得 URL重複排除、コンテンツハッシュ比較
レート制限 リクエスト頻度を制御しBANを回避 asyncio.Semaphore、Scrapy AutoThrottle

5つのコア課題

課題1:TLSフィンガープリント検出

現代のアンチボットシステムは、TLSハンドシェイク特性(JA3/JA4フィンガープリント)でリクエスト元を識別します。PythonのrequestsaiohttpのデフォルトTLSフィンガープリントはブラウザと大きく異なり、簡単に検出されます。

課題2:ブラウザフィンガープリント追跡

Canvas、WebGL、AudioContext、フォントリストなどのブラウザAPIの戻り値が一意のフィンガープリントを構成します。ヘッドレスブラウザとリアルブラウザの間には検出可能な差異が存在します。

課題3:JavaScript動的レンダリング

SPAアプリケーションのコアデータはAJAX/Fetchで非同期読み込みされます。初期HTMLにはターゲットデータが含まれておらず、JS実行の完了を待つ必要があります。

課題4:CAPTCHAによる傍受

ログイン、検索、ページネーションなどの重要操作でCAPTCHAがトリガーされます。従来のOCRアプローチは現代のCAPTCHAに対して効果が低く、サードパーティのCAPTCHA解決サービスやAIモデルが必要です。

課題5:IP BANとレート制限

高頻度リクエストがIP BANをトリガーします。単一プロキシは簡単に無効化されるため、高可用性プロキシプールとインテリジェントなローテーション戦略の構築が必要です。---

パターン1:Scrapyフレームワークによる大規模クローリング

ScrapyはPythonで最も成熟したスクレイピングフレームワークで、構造化された大規模データ収集タスクに最適です。

プロジェクト初期化

pip install scrapy scrapy-playwright
scrapy startproject news_crawler
cd news_crawler
scrapy genspider tech_news example.com

完全なSpider例

# news_crawler/spiders/tech_news.py
import scrapy
from scrapy.linkextractors import LinkExtractor
from scrapy.spiders import CrawlSpider, Rule
from ..items import NewsItem


class TechNewsSpider(CrawlSpider):
    name = "tech_news"
    allowed_domains = ["example.com"]
    start_urls = ["https://example.com/tech"]

    custom_settings = {
        "CONCURRENT_REQUESTS": 16,
        "DOWNLOAD_DELAY": 1.5,
        "AUTOTHROTTLE_ENABLED": True,
        "AUTOTHROTTLE_TARGET_CONCURRENCY": 8,
        "AUTOTHROTTLE_MAX_DELAY": 10,
        "ROBOTSTXT_OBEY": True,
        "USER_AGENT": (
            "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
            "AppleWebKit/537.36 (KHTML, like Gecko) "
            "Chrome/126.0.0.0 Safari/537.36"
        ),
        "DEFAULT_REQUEST_HEADERS": {
            "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
            "Accept-Language": "ja,en-US;q=0.9,en;q=0.8",
            "Accept-Encoding": "gzip, deflate, br",
        },
        "FEEDS": {
            "output/tech_news.json": {
                "format": "json",
                "encoding": "utf-8",
                "indent": 2,
            },
        },
        "ITEM_PIPELINES": {
            "news_crawler.pipelines.CleanHtmlPipeline": 100,
            "news_crawler.pipelines.DeduplicatePipeline": 200,
            "news_crawler.pipelines.SqlitePipeline": 300,
        },
    }

    rules = (
        Rule(
            LinkExtractor(allow=r"/tech/article/\d+"),
            callback="parse_article",
            follow=True,
        ),
        Rule(
            LinkExtractor(allow=r"/tech/page/\d+"),
            follow=True,
        ),
    )

    def parse_article(self, response):
        item = NewsItem()
        item["title"] = response.css("h1.article-title::text").get("").strip()
        item["author"] = response.css(".author-name::text").get("").strip()
        item["publish_time"] = response.css(
            "time.article-time::attr(datetime)"
        ).get("")
        item["content"] = response.css(".article-content").get("")
        item["tags"] = response.css(".tag-item::text").getall()
        item["url"] = response.url
        yield item

Item定義

# news_crawler/items.py
import scrapy
from itemloaders.processors import TakeFirst, MapCompose, Join


def strip_text(value):
    return value.strip() if value else ""


class NewsItem(scrapy.Item):
    title = scrapy.Field(
        input_processor=MapCompose(strip_text),
        output_processor=TakeFirst(),
    )
    author = scrapy.Field(
        input_processor=MapCompose(strip_text),
        output_processor=TakeFirst(),
    )
    publish_time = scrapy.Field(output_processor=TakeFirst())
    content = scrapy.Field(
        input_processor=MapCompose(strip_text),
        output_processor=Join(""),
    )
    tags = scrapy.Field()
    url = scrapy.Field(output_processor=TakeFirst())

Pipelineデータ処理

# news_crawler/pipelines.py
import hashlib
import sqlite3
from html.parser import HTMLParser
from itemadapter import ItemAdapter


class HtmlStripper(HTMLParser):
    def __init__(self):
        super().__init__()
        self.reset()
        self.fed = []

    def handle_data(self, d):
        self.fed.append(d)

    def get_data(self):
        return "".join(self.fed)


class CleanHtmlPipeline:
    """HTMLタグを除去"""

    def process_item(self, item, spider):
        adapter = ItemAdapter(item)
        content = adapter.get("content", "")
        if content:
            stripper = HtmlStripper()
            stripper.feed(content)
            adapter["content"] = stripper.get_data().strip()
        return item


class DeduplicatePipeline:
    """コンテンツハッシュに基づく重複排除"""

    def __init__(self):
        self.seen_hashes = set()

    def process_item(self, item, spider):
        adapter = ItemAdapter(item)
        content = adapter.get("content", "")
        content_hash = hashlib.md5(content.encode()).hexdigest()
        if content_hash in self.seen_hashes:
            spider.logger.info(f"重複コンテンツ、スキップ: {adapter.get('url')}")
            raise scrapy.exceptions.DropItem("重複コンテンツ")
        self.seen_hashes.add(content_hash)
        return item


class SqlitePipeline:
    """SQLiteに保存"""

    def open_spider(self, spider):
        self.conn = sqlite3.connect("news_data.db")
        self.cursor = self.conn.cursor()
        self.cursor.execute("""
            CREATE TABLE IF NOT EXISTS articles (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                title TEXT,
                author TEXT,
                publish_time TEXT,
                content TEXT,
                tags TEXT,
                url TEXT UNIQUE,
                crawl_time TEXT
            )
        """)
        self.conn.commit()

    def close_spider(self, spider):
        self.conn.close()

    def process_item(self, item, spider):
        adapter = ItemAdapter(item)
        self.cursor.execute(
            """INSERT OR IGNORE INTO articles
               (title, author, publish_time, content, tags, url)
               VALUES (?, ?, ?, ?, ?, ?)""",
            (
                adapter.get("title"),
                adapter.get("author"),
                adapter.get("publish_time"),
                adapter.get("content"),
                ",".join(adapter.get("tags", [])),
                adapter.get("url"),
            ),
        )
        self.conn.commit()
        return item

Spiderの実行

scrapy crawl tech_news -s LOG_LEVEL=INFO
```---

## パターン2:Playwrightによる動的SPAスクレイピング

JavaScriptレンダリングされるSPAアプリケーションに対して、Playwrightは2026年の最適な選択肢であり、Seleniumより高速で安定しています。

### インストール

```bash
pip install playwright
playwright install chromium

基本的な動的ページスクレイピング

import asyncio
from playwright.async_api import async_playwright


async def scrape_spa():
    async with async_playwright() as p:
        browser = await p.chromium.launch(headless=True)
        context = await browser.new_context(
            viewport={"width": 1920, "height": 1080},
            user_agent=(
                "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
                "AppleWebKit/537.36 (KHTML, like Gecko) "
                "Chrome/126.0.0.0 Safari/537.36"
            ),
            locale="ja-JP",
        )
        page = await context.new_page()

        await page.goto(
            "https://spa-example.com/products",
            wait_until="networkidle",
        )
        await page.wait_for_selector(".product-card", timeout=10000)

        for _ in range(3):
            await page.evaluate(
                "window.scrollTo(0, document.body.scrollHeight)"
            )
            await page.wait_for_timeout(1500)

        products = await page.evaluate("""() => {
            const cards = document.querySelectorAll('.product-card');
            return Array.from(cards).map(card => ({
                name: card.querySelector('.product-name')?.textContent?.trim() || '',
                price: card.querySelector('.price')?.textContent?.trim() || '',
                rating: card.querySelector('.rating')?.textContent?.trim() || '',
                image: card.querySelector('img')?.src || '',
            }));
        }""")

        print(f"{len(products)}件の商品をスクレイピング")
        for product in products[:5]:
            print(f"  {product['name']} - {product['price']}")

        await browser.close()
        return products


asyncio.run(scrape_spa())

無限スクロール + ページネーションの処理

import asyncio
from playwright.async_api import async_playwright


async def scrape_infinite_scroll():
    async with async_playwright() as p:
        browser = await p.chromium.launch(headless=True)
        page = await browser.new_page()

        await page.goto(
            "https://example.com/feed", wait_until="networkidle"
        )

        all_items = []
        previous_count = 0
        max_scrolls = 20

        for scroll_round in range(max_scrolls):
            await page.evaluate(
                "window.scrollTo(0, document.body.scrollHeight)"
            )

            try:
                await page.wait_for_function(
                    "document.querySelectorAll('.feed-item').length > arguments[0]",
                    previous_count,
                    timeout=5000,
                )
            except Exception:
                print(f"ラウンド {scroll_round + 1}: 新規コンテンツなし、停止")
                break

            current_items = await page.evaluate("""() =>
                Array.from(document.querySelectorAll('.feed-item')).map(
                    el => el.textContent.trim()
                )
            """)
            all_items = current_items
            previous_count = len(current_items)
            print(f"ラウンド {scroll_round + 1}: 合計 {len(current_items)}件")

            await page.wait_for_timeout(
                int(asyncio.get_event_loop().time() % 2000 + 1000)
            )

        await browser.close()
        return all_items


asyncio.run(scrape_infinite_scroll())

ネットワークリクエストの傍受によるAPIデータ抽出

import asyncio
import json
from playwright.async_api import async_playwright


async def intercept_api_data():
    async with async_playwright() as p:
        browser = await p.chromium.launch(headless=True)
        page = await browser.new_page()

        api_responses = []

        async def handle_response(response):
            if "/api/v2/products" in response.url and response.status == 200:
                try:
                    data = await response.json()
                    api_responses.append(data)
                    print(f"APIデータを傍受: {response.url}")
                except Exception:
                    pass

        page.on("response", handle_response)

        await page.goto(
            "https://example.com/shop", wait_until="networkidle"
        )
        await page.wait_for_timeout(3000)

        all_products = []
        for resp in api_responses:
            items = resp.get("data", {}).get("items", [])
            all_products.extend(items)

        print(f"API傍受で {len(all_products)}件のデータを取得")

        with open("api_data.json", "w", encoding="utf-8") as f:
            json.dump(all_products, f, ensure_ascii=False, indent=2)

        await browser.close()
        return all_products


asyncio.run(intercept_api_data())
```---

## パターン3:アンチボット回避の実践

### ヘッダー偽装

```python
import random

USER_AGENTS = [
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36",
    "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15",
    "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36",
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:127.0) Gecko/20100101 Firefox/127.0",
]

ACCEPT_LANGUAGES = [
    "ja,en-US;q=0.9,en;q=0.8",
    "ja-JP,ja;q=0.9,en;q=0.8",
    "en-US,en;q=0.9,ja;q=0.8",
]


def get_random_headers():
    """ランダムなリクエストヘッダーを生成"""
    return {
        "User-Agent": random.choice(USER_AGENTS),
        "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
        "Accept-Language": random.choice(ACCEPT_LANGUAGES),
        "Accept-Encoding": "gzip, deflate, br",
        "DNT": "1",
        "Connection": "keep-alive",
        "Upgrade-Insecure-Requests": "1",
        "Sec-Fetch-Dest": "document",
        "Sec-Fetch-Mode": "navigate",
        "Sec-Fetch-Site": "none",
        "Sec-Fetch-User": "?1",
        "Cache-Control": "max-age=0",
    }

プロキシプールローテーション

import asyncio
import aiohttp
import random
from dataclasses import dataclass, field


@dataclass
class ProxyPool:
    """高可用性プロキシプール"""

    proxies: list[str] = field(default_factory=list)
    failed_count: dict[str, int] = field(default_factory=dict)
    max_failures: int = 3

    def add_proxy(self, proxy: str):
        self.proxies.append(proxy)
        self.failed_count[proxy] = 0

    def get_proxy(self) -> str | None:
        available = [
            p for p in self.proxies
            if self.failed_count.get(p, 0) < self.max_failures
        ]
        if not available:
            return None
        return random.choice(available)

    def mark_success(self, proxy: str):
        self.failed_count[proxy] = 0

    def mark_failure(self, proxy: str):
        self.failed_count[proxy] = self.failed_count.get(proxy, 0) + 1
        if self.failed_count[proxy] >= self.max_failures:
            if proxy in self.proxies:
                self.proxies.remove(proxy)
            print(f"プロキシ {proxy} を削除(失敗回数過多)")


async def fetch_with_proxy(pool: ProxyPool, url: str, max_retries: int = 3):
    """プロキシプールを使用してリクエスト"""
    for attempt in range(max_retries):
        proxy = pool.get_proxy()
        if not proxy:
            print("プロキシプール枯渇、直接接続を使用")
            proxy = None

        try:
            async with aiohttp.ClientSession() as session:
                async with session.get(
                    url,
                    proxy=f"http://{proxy}" if proxy else None,
                    headers=get_random_headers(),
                    timeout=aiohttp.ClientTimeout(total=15),
                    ssl=False,
                ) as response:
                    if response.status == 200:
                        if proxy:
                            pool.mark_success(proxy)
                        return await response.text()
                    elif response.status in (403, 429):
                        if proxy:
                            pool.mark_failure(proxy)
                        print(f"ステータス {response.status}、プロキシ切り替え(試行 {attempt + 1})")
                        await asyncio.sleep(random.uniform(2, 5))
        except Exception as e:
            if proxy:
                pool.mark_failure(proxy)
            print(f"リクエストエラー: {e}(試行 {attempt + 1})")
            await asyncio.sleep(random.uniform(1, 3))

    return None

Playwrightフィンガープリント偽装

import asyncio
from playwright.async_api import async_playwright


async def stealth_scrape():
    async with async_playwright() as p:
        browser = await p.chromium.launch(
            headless=True,
            args=[
                "--disable-blink-features=AutomationControlled",
                "--disable-features=IsolateOrigins,site-per-process",
                "--disable-dev-shm-usage",
                "--no-sandbox",
            ],
        )

        context = await browser.new_context(
            viewport={"width": 1920, "height": 1080},
            screen={"width": 1920, "height": 1080},
            user_agent=(
                "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
                "AppleWebKit/537.36 (KHTML, like Gecko) "
                "Chrome/126.0.0.0 Safari/537.36"
            ),
            locale="ja-JP",
            timezone_id="Asia/Tokyo",
            geolocation={"latitude": 35.6762, "longitude": 139.6503},
            permissions=["geolocation"],
            color_scheme="light",
        )

        page = await context.new_page()

        # 検出回避スクリプトの注入
        await page.add_init_script("""
            Object.defineProperty(navigator, 'webdriver', {
                get: () => undefined
            });
            window.chrome = {
                runtime: {}, loadTimes: function() {},
                csi: function() {}, app: {}
            };
            const originalQuery = window.navigator.permissions.query;
            window.navigator.permissions.query = (parameters) => (
                parameters.name === 'notifications' ?
                    Promise.resolve({ state: Notification.permission }) :
                    originalQuery(parameters)
            );
            Object.defineProperty(navigator, 'plugins', {
                get: () => [1, 2, 3, 4, 5]
            });
            Object.defineProperty(navigator, 'languages', {
                get: () => ['ja', 'ja-JP', 'en-US', 'en']
            });
        """)

        await page.goto("https://example.com", wait_until="networkidle")
        content = await page.content()
        print(f"ページ長: {len(content)}")

        await browser.close()
        return content


asyncio.run(stealth_scrape())
```---

## パターン4:aiohttp + BeautifulSoupによる非同期スクレイピング

JSレンダリングが不要なシナリオでは、非同期スクレイピングが最も効率的な選択です。

```python
import asyncio
import aiohttp
from bs4 import BeautifulSoup
from dataclasses import dataclass, field
import json
import time


@dataclass
class AsyncScraper:
    """非同期スクレイパーのコアクラス"""

    base_url: str
    max_concurrency: int = 10
    request_delay: float = 0.5
    timeout: int = 15
    results: list = field(default_factory=list)
    visited: set = field(default_factory=set)

    async def fetch_page(self, session: aiohttp.ClientSession, url: str) -> str | None:
        """単一ページを取得"""
        try:
            async with session.get(
                url,
                headers=get_random_headers(),
                timeout=aiohttp.ClientTimeout(total=self.timeout),
                ssl=False,
            ) as response:
                if response.status == 200:
                    return await response.text()
                print(f"[{response.status}] {url}")
                return None
        except asyncio.TimeoutError:
            print(f"[タイムアウト] {url}")
            return None
        except Exception as e:
            print(f"[エラー] {url}: {e}")
            return None

    def parse_page(self, html: str, url: str) -> dict | None:
        """ページコンテンツを解析"""
        soup = BeautifulSoup(html, "lxml")
        title = soup.select_one("h1.article-title")
        if not title:
            return None
        content_paragraphs = soup.select(".article-content p")
        content = "\n".join(p.get_text(strip=True) for p in content_paragraphs)
        return {
            "title": title.get_text(strip=True),
            "content": content,
            "url": url,
        }

    async def scrape_urls(self, urls: list[str]):
        """複数URLを並行スクレイピング"""
        semaphore = asyncio.Semaphore(self.max_concurrency)

        async def limited_fetch(session, url):
            async with semaphore:
                result = await self.fetch_page(session, url)
                await asyncio.sleep(self.request_delay)
                return url, result

        async with aiohttp.ClientSession() as session:
            tasks = [
                limited_fetch(session, url)
                for url in urls if url not in self.visited
            ]
            responses = await asyncio.gather(*tasks, return_exceptions=True)

            for resp in responses:
                if isinstance(resp, Exception):
                    continue
                url, html = resp
                if html:
                    self.visited.add(url)
                    parsed = self.parse_page(html, url)
                    if parsed:
                        self.results.append(parsed)

        return self.results

    def save_to_json(self, filename: str = "scraped_data.json"):
        """結果をJSONに保存"""
        with open(filename, "w", encoding="utf-8") as f:
            json.dump(self.results, f, ensure_ascii=False, indent=2)
        print(f"{len(self.results)}件のデータを {filename} に保存")


async def main():
    scraper = AsyncScraper(
        base_url="https://example.com", max_concurrency=8
    )
    urls = [f"https://example.com/article/{i}" for i in range(1, 51)]

    start = time.perf_counter()
    results = await scraper.scrape_urls(urls)
    elapsed = time.perf_counter() - start

    print(f"スクレイピング完了: {len(results)}件、所要時間 {elapsed:.2f}s")
    scraper.save_to_json()


asyncio.run(main())

パターン5:データパイプラインとストレージ

CSVエクスポート

import csv
from dataclasses import dataclass


@dataclass
class CsvExporter:
    filename: str
    fieldnames: list[str]

    def export(self, data: list[dict]):
        with open(self.filename, "w", newline="", encoding="utf-8-sig") as f:
            writer = csv.DictWriter(f, fieldnames=self.fieldnames)
            writer.writeheader()
            writer.writerows(data)
        print(f"{len(data)}件を {self.filename} にエクスポート")


exporter = CsvExporter("products.csv", ["name", "price", "rating", "url"])
exporter.export(products_data)

JSONエクスポート

import json
from datetime import datetime


class JsonExporter:
    def __init__(self, filename: str, indent: int = 2):
        self.filename = filename
        self.indent = indent

    def export(self, data: list[dict]):
        output = {
            "metadata": {
                "total": len(data),
                "export_time": datetime.now().isoformat(),
                "version": "1.0",
            },
            "data": data,
        }
        with open(self.filename, "w", encoding="utf-8") as f:
            json.dump(output, f, ensure_ascii=False, indent=self.indent)
        print(f"{len(data)}件を {self.filename} にエクスポート")

SQLiteストレージ

import sqlite3
from contextlib import contextmanager


class SqliteStorage:
    def __init__(self, db_path: str = "scraped.db"):
        self.db_path = db_path
        self._init_db()

    @contextmanager
    def get_connection(self):
        conn = sqlite3.connect(self.db_path)
        conn.row_factory = sqlite3.Row
        try:
            yield conn
        finally:
            conn.close()

    def _init_db(self):
        with self.get_connection() as conn:
            conn.execute("""
                CREATE TABLE IF NOT EXISTS scraped_data (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    title TEXT NOT NULL,
                    content TEXT,
                    url TEXT UNIQUE,
                    tags TEXT,
                    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
                )
            """)
            conn.execute("""
                CREATE INDEX IF NOT EXISTS idx_url ON scraped_data(url)
            """)
            conn.commit()

    def insert(self, item: dict):
        with self.get_connection() as conn:
            conn.execute(
                """INSERT OR IGNORE INTO scraped_data
                   (title, content, url, tags) VALUES (?, ?, ?, ?)""",
                (
                    item.get("title"),
                    item.get("content"),
                    item.get("url"),
                    ",".join(item.get("tags", [])),
                ),
            )
            conn.commit()

    def query(self, keyword: str, limit: int = 50) -> list[dict]:
        with self.get_connection() as conn:
            rows = conn.execute(
                """SELECT * FROM scraped_data
                   WHERE title LIKE ? OR content LIKE ?
                   ORDER BY created_at DESC LIMIT ?""",
                (f"%{keyword}%", f"%{keyword}%", limit),
            ).fetchall()
            return [dict(row) for row in rows]
```---

## よくある落とし穴ガイド

### 落とし穴1:robots.txtの無視

❌ サイトのクローリングルールを無視して強制スクレイピング

✅ まずrobots.txtを確認し、`Disallow`ルールを尊重;Scrapyはデフォルトで`ROBOTSTXT_OBEY`が有効

### 落とし穴2:固定リクエスト間隔

❌ 固定の`time.sleep(2)`待機を使用、パターンが検出されやすい

✅ ランダム遅延`await asyncio.sleep(random.uniform(1.5, 4.0))`で人間のブラウジング行動をシミュレート

### 落とし穴3:エラーリトライの無視

❌ 失敗したリクエストをスキップし、大量のデータ損失

✅ 指数バックオフリトライメカニズムを実装し、リトライ可能エラー(429、503)とリトライ不可エラー(404、403)を区別

### 落とし穴4:メモリに全データを蓄積

❌ すべての結果をリストに保存し、数百万件でOOMが発生

✅ ストリーミング書き込みを使用し、N件ごとにファイル/データベースにフラッシュしてバッファをクリア

### 落とし穴5:ハードコードされたセレクタ

❌ CSS/XPathセレクタをハードコードし、サイトリデザインですべて無効化

✅ 複数のセレクタ戦略をフォールバックとして使用、またはデータ特性(JSON-LD構造化データなど)に基づいて抽出

---

## エラートラブルシューティング表

| エラー症状 | 考えられる原因 | 解決策 |
|-----------|--------------|--------|
| HTTP 403 Forbidden | ヘッダー不足またはボットとして識別 | 完全なブラウザヘッダーを追加、フィンガープリント偽装を使用 |
| HTTP 429 Too Many Requests | リクエスト頻度が高すぎる | 並行数を減らす、ランダム遅延を追加、プロキシローテーションを使用 |
| 接続タイムアウト TimeoutError | ネットワーク不安定またはサーバーのレート制限 | タイムアウトを増やす、リトライメカニズムを実装、プロキシを切り替え |
| SSL検証失敗 | プロキシのMITM証明書またはTLSフィンガープリント検出 | `ssl=False`を設定(開発環境のみ)、またはcurl_cffiを使用 |
| ページコンテンツが空 | JS動的レンダリング未完了 | Playwrightに切り替え、要素の読み込みを待機 |
| CAPTCHAによる傍受 | アンチボットルールがトリガー | リクエスト頻度を下げる、CAPTCHA解決サービスを利用、Cookieプールを使用 |
| データの重複スクレイピング | URL重複排除ロジックの欠落 | Bloom FilterまたはRedis Setで重複排除 |
| メモリオーバーフロー OOM | 大量データがメモリに蓄積 | ディスクにストリーミング、バッチ処理、ジェネレータを使用 |
| エンコードエラー UnicodeDecodeError | レスポンスエンコーディングの不一致 | `response.encoding = response.apparent_encoding`を使用 |
| ElementNotInteractable | 要素が隠されているかまだインタラクティブでない | 要素の可視性を待機 `wait_for_selector`、要素までスクロール |

---

## 高度な最適化のヒント

### 1. Bloom Filterによる重複排除

```python
from pybloom_live import ScalableBloomFilter


class UrlDeduplicator:
    def __init__(self):
        self.bloom = ScalableBloomFilter(
            initial_capacity=100000, error_rate=0.001,
        )

    def is_duplicate(self, url: str) -> bool:
        if url in self.bloom:
            return True
        self.bloom.add(url)
        return False

2. AutoThrottleによるスマートレート制限

# Scrapy設定
AUTOTHROTTLE_ENABLED = True
AUTOTHROTTLE_START_DELAY = 1.0
AUTOTHROTTLE_MAX_DELAY = 30.0
AUTOTHROTTLE_TARGET_CONCURRENCY = 8.0
AUTOTHROTTLE_DEBUG = True

3. 分散スクレイピング(Scrapy + Redis)

pip install scrapy-redis
# settings.py
SCHEDULER = "scrapy_redis.scheduler.Scheduler"
DUPEFILTER_CLASS = "scrapy_redis.dupefilter.RFPDupeFilter"
REDIS_URL = "redis://localhost:6379/0"
SCHEDULER_PERSIST = True

4. curl_cffiによるTLSフィンガープリント回避

from curl_cffi import requests as curl_requests

response = curl_requests.get(
    "https://tls-protected.example.com",
    impersonate="chrome126",
    timeout=15,
)
print(response.status_code)

5. Cookieプール管理

import json
import random
from pathlib import Path


class CookiePool:
    def __init__(self, cookie_file: str = "cookies.json"):
        self.cookie_file = Path(cookie_file)
        self.cookies: list[dict] = []
        self._load()

    def _load(self):
        if self.cookie_file.exists():
            self.cookies = json.loads(
                self.cookie_file.read_text(encoding="utf-8")
            )

    def _save(self):
        self.cookie_file.write_text(
            json.dumps(self.cookies, ensure_ascii=False, indent=2),
            encoding="utf-8",
        )

    def add_cookie(self, cookie: dict):
        self.cookies.append(cookie)
        self._save()

    def get_random_cookie(self) -> dict | None:
        if not self.cookies:
            return None
        return random.choice(self.cookies)

    def remove_cookie(self, cookie: dict):
        self.cookies = [c for c in self.cookies if c != cookie]
        self._save()

フレームワーク比較

項目 Scrapy Playwright Selenium BeautifulSoup + requests
最適な用途 大規模構造化スクレイピング 動的SPAページ レガシーシステム互換 小規模静的ページ
JSレンダリング scrapy-playwrightが必要 ネイティブ対応 ネイティブ対応 非対応
パフォーマンス 高(非同期+Twisted) 中(ブラウザインスタンス) 低(WebDriverオーバーヘッド) 高(軽量HTTP)
アンチボット能力 中(追加設定が必要) 高(リアルブラウザ) 中(検出されやすい) 低(裸リクエスト)
学習曲線 中程度 簡単 簡単
分散 ネイティブ対応(scrapy-redis) 自作が必要 自作が必要 自作が必要
データパイプライン 内蔵Pipeline 自作が必要 自作が必要 自作が必要
デバッグツール Scrapy Shell Playwright Inspector Selenium IDE ブラウザDevTools
リソース使用量 高(ブラウザプロセス) 高(WebDriver) 非常に低い
推奨度 ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐

選択ガイド

  • 大規模構造化データ収集 → Scrapy
  • 動的SPA / インタラクティブ操作 → Playwright
  • シンプルな静的ページの高速スクレイピング → BeautifulSoup + requests
  • 既存のSeleniumテストインフラ → Selenium(ただしPlaywrightへの移行を推奨)

関連ツール

スクレイピング開発の実践において、以下のToolsKuツールが役立ちます:

  • JSONフォーマッター — APIレスポンスとスクレイピング結果をフォーマットし、データ構造の問題を素早く特定
  • ハッシュ計算 — 重複排除用のコンテンツハッシュを生成、URLフィンガープリントを計算
  • cURL to Code — ブラウザのcURLリクエストをワンクリックでPythonコードに変換、リクエストテンプレートを素早く構築

Webスクレイピングの本質は「アンチボットシステムとのいたちごっこ」です。銀の弾丸はなく、ターゲットサイトの特性に応じて適切な技術スタックを選択し、効率と隠密性のバランスを取ることでのみ成功できます。robots.txtの尊重、リクエスト頻度の制御、データ著作権の尊重は、すべてのスクレイピング開発者のベースラインです。

ブラウザローカルツールを無料で試す →

#Python爬虫#反爬虫#Scrapy#Playwright爬虫#2026#编程语言