Python 爬虫 2026:反爬虫绕过与生产级爬虫实战指南

编程语言

2026 年网页爬虫的挑战

2026 年的 Web 早已不是简单的静态页面时代。现代网站普遍部署了多层反爬机制,让数据采集变得愈发困难:

  • 反 Bot 检测: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 打包混淆、接口签名校验、响应数据加密等手段保护

面对这些挑战,我们需要一套系统化的爬虫方案。本文将从核心概念出发,逐步构建生产级爬虫系统。


核心概念速查

概念 说明 典型工具
静态爬取 直接请求 HTML,解析 DOM requests + BeautifulSoup
动态渲染 模拟浏览器执行 JS Playwright、Selenium
反爬绕过 伪装请求特征,规避检测 代理池、指纹伪装、CAPTCHA 服务
分布式爬取 多节点并发抓取 Scrapy + Redis、Celery
数据管道 清洗、转换、存储抓取结果 Pandas、SQLAlchemy、Item Pipeline
增量爬取 只抓取新增/变更内容 URL 去重、内容哈希比对
限流控制 控制请求频率,避免封禁 asyncio.Semaphore、Scrapy AutoThrottle

五大核心挑战分析

挑战一:TLS 指纹检测

现代反爬系统通过 TLS 握手特征(JA3/JA4 指纹)识别请求来源。Python 的 requestsaiohttp 默认 TLS 指纹与浏览器差异巨大,一击即中。

挑战二:浏览器指纹追踪

Canvas、WebGL、AudioContext、字体列表等浏览器 API 返回值构成唯一指纹,Headless 浏览器与真实浏览器存在可检测差异。

挑战三:JavaScript 动态渲染

SPA 应用的核心数据通过 AJAX/Fetch 异步加载,初始 HTML 中不包含目标数据,必须等待 JS 执行完成后才能获取。

挑战四:验证码拦截

登录、搜索、翻页等关键操作触发验证码,传统 OCR 方案对现代验证码效果极差,需要借助第三方打码平台或 AI 模型。

挑战五:IP 封禁与限流

高频请求触发 IP 封禁,单一代理容易失效,需要构建高可用代理池并实现智能轮换策略。


方案一: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": "zh-CN,zh;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

运行爬虫

scrapy crawl tech_news -s LOG_LEVEL=INFO

方案二:Playwright 动态 SPA 抓取

对于 JavaScript 渲染的 SPA 应用,Playwright 是 2026 年的首选方案,比 Selenium 更快、更稳定。

安装

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="zh-CN",
        )
        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())

方案三:反爬虫绕过实战

请求头伪装

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 = [
    "zh-CN,zh;q=0.9,en;q=0.8",
    "zh-TW,zh;q=0.9,en-US;q=0.8",
    "en-US,en;q=0.9,zh-CN;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="zh-CN",
            timezone_id="Asia/Shanghai",
            geolocation={"latitude": 39.9042, "longitude": 116.4074},
            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: () => ['zh-CN', 'zh', '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())

方案四:aiohttp + BeautifulSoup 异步爬虫

对于不需要 JS 渲染的场景,异步爬虫是效率最高的选择。

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())

方案五:数据管道与存储

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]

常见陷阱指南

陷阱一:忽略 robots.txt

❌ 直接暴力爬取,无视网站爬取规则

✅ 先检查 robots.txt,尊重 Disallow 规则;Scrapy 默认开启 ROBOTSTXT_OBEY

陷阱二:固定请求间隔

❌ 使用固定 time.sleep(2) 等待,模式过于规律

✅ 使用随机延迟 await asyncio.sleep(random.uniform(1.5, 4.0)),模拟人类浏览行为

陷阱三:忽略异常重试

❌ 请求失败直接跳过,数据大量丢失

✅ 实现指数退避重试机制,区分可重试错误(429、503)和不可重试错误(404、403)

陷阱四:内存中累积全部数据

❌ 将所有结果保存在列表中,百万级数据导致 OOM

✅ 使用流式写入,每批 N 条写入文件/数据库后清空缓存

陷阱五:硬编码选择器

❌ 直接硬编码 CSS/XPath 选择器,网站改版后全部失效

✅ 使用多种选择器策略作为降级方案,或基于数据特征(如 JSON-LD 结构化数据)提取


错误排错表

错误现象 可能原因 解决方案
HTTP 403 Forbidden 请求头缺失或被识别为爬虫 添加完整浏览器请求头,使用指纹伪装
HTTP 429 Too Many Requests 请求频率过高 降低并发数,增加随机延迟,使用代理轮换
连接超时 TimeoutError 网络不稳定或目标服务器限速 增加超时时间,实现重试机制,切换代理
SSL 验证失败 代理中间人证书或 TLS 指纹检测 设置 ssl=False(开发环境),或使用 curl_cffi
页面内容为空 JS 动态渲染未完成 切换到 Playwright,等待元素加载
CAPTCHA 验证码拦截 触发反爬规则 降低请求频率,接入打码平台,使用 Cookie 池
数据重复抓取 URL 去重逻辑缺失 使用 Bloom Filter 或 Redis Set 去重
内存溢出 OOM 大量数据堆积在内存 流式写入磁盘,分批处理,使用生成器
编码错误 UnicodeDecodeError 响应编码与声明不一致 使用 response.encoding = response.apparent_encoding
ElementNotInteractable 元素被遮挡或尚未可交互 等待元素可见 wait_for_selector,滚动到元素位置

高级优化技巧

1. Bloom Filter 去重

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)
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)

相关工具推荐

在爬虫开发实践中,以下 工具库 工具可以帮到你:

  • JSON 格式化 — 格式化 API 响应和抓取结果,快速排查数据结构问题
  • Hash 计算 — 生成内容哈希用于去重,计算 URL 指纹
  • cURL 转代码 — 将浏览器 cURL 请求一键转为 Python 代码,快速构建请求模板

爬虫的本质是「与反爬系统的博弈」。没有银弹,只有根据目标网站特征选择合适的技术栈,在效率与隐蔽性之间找到平衡。遵守 robots.txt、控制请求频率、尊重数据版权,是每个爬虫开发者的底线。

本站提供浏览器本地工具,免注册即可试用 →

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