← بازگشت به وبلاگ

۵ اشتباه در میدل‌ویر پروکسی Scrapy که ترافیک پروکسی را بیهوده هدر می‌دهد

پنج اشتباه رایج در middleware Scrapy که باعث هدر رفتن ترافیک پروکسی و مسدود شدن پارسر می‌شود. با مثال‌های کد و راه‌حل‌های آماده.

📅۷ مهر ۱۴۰۵

پارسر در Scrapy به دلیل تایم‌اوت سقوط می‌کند، پروکسی‌پول به سرعت بیشتری از حد انتظار مصرف می‌شود و لاگ‌ها با پاسخ‌های 407 و 403 پر شده‌اند — آیا این تصویر آشنا نیست؟ در 90% موارد مشکل در خود پروکسی‌ها نیست، بلکه در نحوه نوشتن DownloaderMiddleware است. ما پنج اشتباه رایج در میدلور را بررسی می‌کنیم که ترافیک گران‌قیمت را به درخواست‌های بی‌فایده تبدیل می‌کند و نشان می‌دهیم چگونه می‌توان آنها را با کد اصلاح کرد.

اشتباه 1: چرخش ابتدایی بدون در نظر گرفتن وضعیت پروکسی

رایج‌ترین ساختاری که در آموزش‌ها می‌توان یافت — random.choice(PROXY_LIST) درون process_request است. مشکل این است که چنین چرخشی نمی‌داند کدام پروکسی به تازگی مسدود شده و کدام هنوز فعال است. در نتیجه، پارسر به ارسال درخواست‌ها از طریق IP مسدود شده ادامه می‌دهد، 403/429 دریافت می‌کند، تلاش مجدد می‌کند — و دوباره همان آدرس را انتخاب می‌کند، زیرا انتخاب تصادفی است و «گره‌های بد» را حذف نمی‌کند.

رویکرد صحیح — پیگیری وضعیت هر پروکسی: تعداد درخواست‌های موفق، تعداد خطاها، زمان آخرین استفاده. در اینجا یک نسخه کارآمد حداقلی آورده شده است:

import random
import time

class ProxyPool:
    def __init__(self, proxies):
        self.proxies = {p: {"fails": 0, "last_used": 0, "banned_until": 0} for p in proxies}

    def get_proxy(self):
        now = time.time()
        available = [
            p for p, state in self.proxies.items()
            if state["banned_until"] < now
        ]
        if not available:
            # اگر همه مسدود شده‌اند، قدیمی‌ترین مسدودیت را ریست می‌کنیم
            available = list(self.proxies.keys())
        return random.choice(available)

    def mark_fail(self, proxy, cooldown=300):
        self.proxies[proxy]["fails"] += 1
        self.proxies[proxy]["banned_until"] = time.time() + cooldown

    def mark_success(self, proxy):
        self.proxies[proxy]["fails"] = 0
        self.proxies[proxy]["last_used"] = time.time()

این پول، IPهای مسدود شده را در زمان «خنک‌سازی» (cooldown) حذف می‌کند و آنها را بعداً به چرخه بازمی‌گرداند. این به طور قابل توجهی مصرف ترافیک را کاهش می‌دهد، زیرا شما به طور مکرر به یک گره مسدود شده ضربه نمی‌زنید.

اشتباه 2: پردازش نادرست تلاش مجدد و کدهای وضعیت

دومین اشتباه رایج — استفاده از RetryMiddleware استاندارد از Scrapy بدون تغییر. به طور پیش‌فرض، این میدلور درخواست را بر روی همان پروکسی که آن را رد کرده، تلاش مجدد می‌کند، مگر اینکه شما به وضوح تغییر پروکسی را در process_exception اضافه کرده باشید. در نتیجه، تصویر کلاسیک را داریم: 3 تلاش مجدد، 3 مسدودیت، درخواست همچنان سقوط می‌کند و ترافیک از قبل مصرف شده است.

نکته دوم — همه کدهای وضعیت نیاز به تلاش مجدد یکسان ندارند. 429 (بسیاری از درخواست‌ها) نیاز به وقفه و تغییر IP دارد، 403 معمولاً به معنای مسدودیت پروکسی خاص است (نیاز به تعویض فوری)، و 5xx — این معمولاً یک مشکل موقتی در سمت سرور است، می‌توان بر روی همان پروکسی تلاش مجدد کرد. اگر همه چیز را در یک دسته قرار دهید، میدلور یا به طور بیش از حد پروکسی را می‌سوزاند، یا خیلی طولانی منتظر می‌ماند در جایی که باید بلافاصله IP را تغییر دهد.

class SmartRetryMiddleware:
    def __init__(self, pool):
        self.pool = pool

    def process_response(self, request, response, spider):
        proxy = request.meta.get("proxy")
        if response.status in (403, 407):
            if proxy:
                self.pool.mark_fail(proxy, cooldown=600)
            new_request = request.copy()
            new_request.meta["proxy"] = self.pool.get_proxy()
            new_request.dont_filter = True
            return new_request

        if response.status == 429:
            if proxy:
                self.pool.mark_fail(proxy, cooldown=120)
            new_request = request.copy()
            new_request.meta["proxy"] = self.pool.get_proxy()
            new_request.dont_filter = True
            return new_request

        if proxy:
            self.pool.mark_success(proxy)
        return response

در اینجا مهم است که خنک‌سازی را بر اساس نوع خطا تقسیم کنید: مسدودیت سخت (403/407) — وقفه طولانی، محدودیت فرکانس (429) — وقفه کوتاه. این به صرفه‌جویی در ده‌ها درصد ترافیک در طول پروسه‌های طولانی کمک می‌کند.

اشتباه 3: عدم وجود جلسات چسبنده برای سایت‌های دارای احراز هویت

اگر پارسر با سایتی کار کند که دارای ورود، سبد خرید، صفحه‌بندی با حفظ وضعیت یا کپچا با بررسی IP باشد — تغییر پروکسی برای هر درخواست جلسه را خراب می‌کند. سایت می‌بیند که درخواست شماره 1 از یک IP آمده، و درخواست شماره 2 (در چارچوب همان جلسه کوکی) از IP دیگری آمده و این بلافاصله حفاظت از ربات را فعال می‌کند، حتی اگر هر دو IP «پاک» باشند.

راه حل — پیوند دادن یک پروکسی به یک جلسه منطقی (برای مثال، به یک حساب خاص یا به زنجیره‌ای از درخواست‌ها در یک دامنه) برای مدت زمان ثابت، و نه تغییر آن برای هر درخواست. این به عنوان جلسه چسبنده شناخته می‌شود.

class StickySessionMiddleware:
    def __init__(self, pool, ttl=600):
        self.pool = pool
        self.ttl = ttl
        self.sessions = {}  # session_id -> (proxy, expires_at)

    def process_request(self, request, spider):
        session_id = request.meta.get("session_id")
        if not session_id:
            return

        now = time.time()
        session = self.sessions.get(session_id)

        if session and session[1] > now:
            request.meta["proxy"] = session[0]
        else:
            proxy = self.pool.get_proxy()
            self.sessions[session_id] = (proxy, now + self.ttl)
            request.meta["proxy"] = proxy

برای کارهایی که نیاز به ثبات IP در طول جلسه دارند — احراز هویت، کار با حساب کاربری، فرم‌های چند مرحله‌ای — بهترین گزینه‌ها پروکسی‌های مسکونی با پشتیبانی از جلسات هستند: آنها اجازه می‌دهند که یک IP خروجی یکسان برای چند دقیقه یا ساعت حفظ شود و سپس به طور کنترل‌شده تغییر یابد، نه به طور تصادفی برای هر درخواست.

اشتباه 4: انتقال نادرست احراز هویت پروکسی

چهارمین اشتباه — فنی است، اما تقریباً در هر پروژه دومی وجود دارد. توسعه‌دهندگان نام کاربری و رمز عبور پروکسی را مستقیماً در URL به شکل http://user:pass@ip:port از طریق request.meta["proxy"] منتقل می‌کنند. این در اکثر موارد کار می‌کند، اما در هنگام استفاده از پروکسی از طریق تونل HTTPS یا در کار با برخی ارائه‌دهندگان، این روش احراز هویت به درستی توسط HttpProxyMiddleware استاندارد پردازش نمی‌شود و درخواست‌ها با 407 Proxy Authentication Required سقوط می‌کنند، حتی اگر اعتبارنامه‌ها صحیح باشند.

روش مطمئن‌تر — انتقال هدر Proxy-Authorization به وضوح، کدگذاری شده در base64:

import base64

class ProxyAuthMiddleware:
    def process_request(self, request, spider):
        proxy = request.meta.get("proxy")
        if not proxy:
            return

        # پروکسی بدون اعتبارنامه در URL
        request.meta["proxy"] = proxy

        user = request.meta.get("proxy_user")
        password = request.meta.get("proxy_pass")
        if user and password:
            credentials = f"{user}:{password}"
            encoded = base64.b64encode(credentials.encode()).decode()
            request.headers["Proxy-Authorization"] = f"Basic {encoded}"

این روش در هنگام مقیاس‌گذاری به صدها درخواست موازی پایدارتر عمل می‌کند و به نوع خاصی از نسخه کتابخانه بستگی ندارد که چگونه URL را با اعتبارنامه‌های جاسازی شده تجزیه می‌کند. این موضوع به ویژه در کار با پروکسی‌های موبایل که احراز هویت اغلب به IP whitelist یا بررسی دقیق هدرها وابسته است، بسیار حیاتی است.

اشتباه 5: عدم نظارت و ثبت مسدودیت‌ها

آخرین و شاید پرهزینه‌ترین اشتباه — عدم ثبت اینکه کدام پروکسی‌ها مسدود می‌شوند، چقدر و در کدام دامنه‌ها. بدون این داده‌ها، نمی‌توان فهمید که چه چیزی ترافیک را هدر می‌دهد: آیا پول پروکسی در یک سایت خاص تمام شده است یا مشکل در خود پارسر است (درخواست‌های بیش از حد، عدم تأخیر، هدرهای مشکوک).

حداقل مجموعه‌ای از معیارهایی که باید در میدلور ثبت شود:

  • تعداد درخواست‌ها برای هر پروکسی در طول جلسه پارسینگ
  • تعداد و کدهای خطا (403، 407، 429، 5xx) برای هر پروکسی
  • زمان عمر پروکسی تا اولین مسدودیت
  • دامنه‌هایی که در آنها مسدودیت‌ها بیشتر اتفاق می‌افتد
import logging
import json

logger = logging.getLogger("proxy_stats")

class ProxyStatsMiddleware:
    def __init__(self):
        self.stats = {}

    def process_response(self, request, response, spider):
        proxy = request.meta.get("proxy", "unknown")
        domain = request.url.split("/")[2]
        key = f"{proxy}|{domain}"

        entry = self.stats.setdefault(key, {"requests": 0, "errors": 0})
        entry["requests"] += 1
        if response.status in (403, 407, 429):
            entry["errors"] += 1

        if entry["requests"] % 50 == 0:
            logger.info(json.dumps(self.stats))

        return response

بدون چنین آماری، هر گونه تلاش برای «بهینه‌سازی» میدلور به حدس و گمان تبدیل می‌شود. با این آمار، شما به وضوح می‌بینید: اگر به عنوان مثال، یک دامنه خاص 80% از پروکسی‌پول را در 10 درخواست اول مسدود کند — مشکل در پروکسی نیست، بلکه در الگوی درخواست‌ها است (عدم چرخش User-Agent، فرکانس بسیار بالا، عدم تأخیر بین درخواست‌ها).

مثال کارآمد میدلور به طور کامل

همه چیز را در settings.py جمع‌آوری می‌کنیم — ترتیب میدلور حیاتی است، زیرا به ترتیب اعمال بررسی‌ها بستگی دارد:

DOWNLOADER_MIDDLEWARES = {
    "myproject.middlewares.ProxyAuthMiddleware": 350,
    "myproject.middlewares.StickySessionMiddleware": 400,
    "myproject.middlewares.SmartRetryMiddleware": 550,
    "myproject.middlewares.ProxyStatsMiddleware": 900,
}

RETRY_ENABLED = False  # تلاش مجدد استاندارد را غیرفعال می‌کند، از خودمان استفاده می‌کنیم
DOWNLOAD_TIMEOUT = 15
CONCURRENT_REQUESTS_PER_DOMAIN = 8

به RETRY_ENABLED = False توجه کنید — این اصل است، در غیر این صورت مکانیزم تلاش مجدد داخلی Scrapy با منطق تغییر پروکسی شما تداخل خواهد داشت و درخواست‌ها شروع به تکرار یا تلاش مجدد دو بار خواهند کرد. همچنین مهم است که CONCURRENT_REQUESTS_PER_DOMAIN را محدود کنید — همزمانی بسیار بالا برای یک دامنه حتی با چرخش پروکسی برای سیستم‌های ضد ربات مشکوک به نظر می‌رسد.

کدام نوع پروکسی را برای Scrapy انتخاب کنیم

میدلور نیمی از مشکل را حل می‌کند، نیمه دیگر — انتخاب صحیح خود پروکسی‌پول برای وظیفه. در زیر مقایسه‌ای برای سناریوهای رایج پارسینگ آورده شده است.

نوع پروکسی کی استفاده شود مزایا معایب
پروکسی‌های دیتاسنتر پارسینگ انبوه صفحات باز بدون حفاظت ضد ربات سخت سرعت بالا، هزینه پایین ترافیک به راحتی شناسایی می‌شوند، اغلب در لیست سیاه هستند
پروکسی‌های مسکونی پارسینگ بازارها، سایت‌های با حفاظت JS، احراز هویت IPهای واقعی، درصد پایین مسدودیت، پشتیبانی از جلسات چسبنده سرعت پایین‌تر نسبت به دیتاسنتر
پروکسی‌های موبایل پارسینگ نسخه‌های موبایل سایت‌ها و API با حفاظت ضد ربات سخت اعتماد حداکثری از سوی سایت‌های هدف بالاترین هزینه ترافیک

قاعده عملی: برای پارسینگ صفحات ایستا بدون حفاظت قوی، پروکسی‌های دیتاسنتر ارزان به همراه میدلور مناسب از این مقاله مناسب هستند. اگر سایت از Cloudflare، PerimeterX، DataDome یا سیستم‌های مشابه استفاده کند — IPهای دیتاسنتر تقریباً بلافاصله مسدود می‌شوند و در اینجا بهتر است بلافاصله به پول مسکونی بروید و زمان را در دیباگ میدلور صرفه‌جویی کنید.

چک لیست قبل از راه‌اندازی پارسر در تولید

  • میدلور پروکسی‌های مسدود شده را در زمان خنک‌سازی حذف می‌کند، نه اینکه به طور تصادفی آنها را انتخاب کند
  • منطق تلاش مجدد انواع خطاها را (403/407 در مقابل 429 در مقابل 5xx) با خنک‌سازی متفاوت تشخیص می‌دهد
  • برای وظایف جلسه‌ای از پیوند چسبنده پروکسی استفاده می‌شود، نه تغییر برای هر درخواست
  • احراز هویت پروکسی از طریق هدر Proxy-Authorization منتقل می‌شود، نه فقط از طریق URL
  • آمار مسدودیت‌ها بر اساس دامنه‌ها و پروکسی‌ها برای تشخیص مشکلات ثبت می‌شود
  • تلاش مجدد استاندارد Scrapy غیرفعال شده است تا با منطق سفارشی تداخل نداشته باشد
  • همزمانی با مقادیر معقول محدود شده است، نه اینکه به حداکثر تنظیم شود

نتیجه‌گیری

بیشتر مشکلات مربوط به مصرف ترافیک پروکسی در Scrapy با خرید پول IP بیشتر حل نمی‌شود، بلکه با اصلاح منطق میدلور: تقسیم خطاها بر اساس نوع، جلسات چسبنده برای سناریوهای پیچیده، احراز هویت صحیح و نظارت مداوم بر مسدودیت‌ها. کد این مقاله می‌تواند به عنوان پایه‌ای برای پروژه خاص شما استفاده شود — ساختار برای پارسرهای کوچک و نصب‌های توزیع شده Scrapy-Cluster کارآمد باقی می‌ماند.

اگر میدلور به درستی تنظیم شده است و با این حال مسدودیت‌ها همچنان بیش از حد اتفاق می‌افتند — احتمالاً مشکل در کیفیت خود پول IP است. برای پارسینگ سایت‌های با حفاظت پیشرفته ضد ربات، بهتر است از پروکسی‌های مسکونی با پشتیبانی از جلسات استفاده کنید — آنها به طور قابل توجهی درصد مسدودیت را نسبت به آدرس‌های دیتاسنتر با همان منطق میدلور کاهش می‌دهند.