دليل شامل لدمج البروكسي في Scrapy: Middleware، دوران IP، ونشر الإنتاج

تعلم كيف تبني Scrapy proxy middleware متقدم من الصفر، تدير دوران البروكسي السكني، تعالج أعطال الطلبات، وتنشر زاحفك في الإنتاج مع مراقبة حقيقية.

دليل شامل لدمج البروكسي في Scrapy: Middleware، دوران IP، ونشر الإنتاج

لماذا تحتاج Scrapy proxy middleware في الإنتاج؟

إذا سبق لك تشغيل زاحف Scrapy على موقع يحدّ من معدل الطلبات، فأنت تعرف النتيجة: حظر كامل بعد بضع مئات من الطلبات. المشكلة ليست في سرعتك، بل في أن كل طلب يخرج من نفس IP. Scrapy residential proxies تحل هذه المشكلة بتوزيع الطلبات عبر آلاف عناوين IP السكنية الحقيقية، لكن التكامل يتطلب أكثر من مجرد تمرير رابط بروكسي في settings.py.

هذا الدليل يأخذك من بنية Scrapy الداخلية إلى middleware مخصص كامل، مروراً بمعالجة الأعطال والنشر والمراقبة. كل ما تحتاجه كمهندس scraping يعمل في بيئة إنتاج حقيقية.

بنية Scrapy الداخلية: أين تناسب البروكسي؟

Scrapy يعالج كل طلب عبر سلسلة من downloader middlewares قبل إرساله وبعد استلام الاستجابة. هذه الـ middlewares هي المكان الطبيعي لحقن البروكسي لأنها تتحكم في كائن Request قبل وصوله إلى downloader.

ترتيب الـ middlewares الافتراضي مهم:

  • HttpProxyMiddleware (ترتيب 560) — يقرأ http_proxy من البيئة أو من request.meta['proxy']
  • RetryMiddleware (ترتيب 550) — يعيد المحاولة عند الفشل
  • RedirectMiddleware (ترتيب 600) — يتبع إعادات التوجيه
القاعدة الذهبية: middleware البروكسي يجب أن يعمل قبل RetryMiddleware حتى يتمكن من تغيير البروكسي بين المحاولات. رقم الترتيب الأقل يعمل أولاً.

يمكنك تمرير البروكسي بعدة طرق:

  1. request.meta['proxy'] — الطريقة القياسية في Scrapy
  2. متغيرات البيئة HTTP_PROXY — لكنها لا تدعم الدوران
  3. Middleware مخصص — المرن والأقوى

الطريقة الثالثة هي ما سنبنيه الآن.

بناء RotatingProxyMiddleware مخصص من الصفر

الهدف: middleware يدير pool من البروكسي السكني، يوزّع الطلبات عشوائياً، يدعم الجلسات اللاصقة (sticky sessions)، ويُزيل البروكسي المعطّل مؤقتاً.

import random
import time
import logging
from scrapy import signals
from scrapy.downloadermiddlewares.httpproxy import HttpProxyMiddleware
from scrapy.exceptions import NotConfigured

logger = logging.getLogger(__name__)


class ProxyHatRotatingMiddleware(HttpProxyMiddleware):
    """
    Scrapy proxy middleware that rotates residential proxies
    from ProxyHat on every request or maintains sticky sessions.
    """

    def __init__(self, proxy_username, proxy_password, proxy_host, proxy_port):
        super().__init__()
        self.username = proxy_username
        self.password = proxy_password
        self.host = proxy_host
        self.port = proxy_port
        self.banned_proxies = {}  # proxy_key: ban_until_timestamp
        self.stats = {}  # proxy_key: {success: int, fail: int}

    @classmethod
    def from_crawler(cls, crawler):
        username = crawler.settings.get('PROXYHAT_USERNAME')
        password = crawler.settings.get('PROXYHAT_PASSWORD')
        host = crawler.settings.get('PROXYHAT_HOST', 'gate.proxyhat.com')
        port = crawler.settings.get('PROXYHAT_PORT', 8080)

        if not username or not password:
            raise NotConfigured(
                'Set PROXYHAT_USERNAME and PROXYHAT_PASSWORD in settings'
            )

        middleware = cls(username, password, host, port)
        crawler.signals.connect(
            middleware.spider_closed, signal=signals.spider_closed
        )
        return middleware

    def _build_proxy_url(self, country=None, city=None, session_id=None):
        """Build ProxyHat proxy URL with optional geo-targeting and session."""
        parts = [self.username]
        if country:
            parts.append(f'country-{country}')
        if city:
            parts.append(f'city-{city}')
        if session_id:
            parts.append(f'session-{session_id}')

        user_part = '-'.join(parts)
        return f'http://{user_part}:{self.password}@{self.host}:{self.port}'

    def _get_proxy(self, request):
        """Select a proxy for the request, respecting sticky sessions."""
        # Sticky session: reuse same proxy for requests with same meta key
        session_id = request.meta.get('proxy_session')
        country = request.meta.get('proxy_country')
        city = request.meta.get('proxy_city')

        if session_id:
            return self._build_proxy_url(
                country=country, city=city, session_id=session_id
            )

        # Per-request rotation with geo-targeting
        return self._build_proxy_url(country=country, city=city)

    def process_request(self, request, spider):
        """Assign a proxy to every outgoing request."""
        proxy_url = self._get_proxy(request)
        request.meta['proxy'] = proxy_url
        request.meta['proxy_had_proxy'] = True
        logger.debug(f'Using proxy for {request.url}')

    def process_response(self, request, response, spider):
        """Detect bans and record success stats."""
        if response.status in (403, 429, 503):
            logger.warning(
                f'Possible ban detected: {response.status} '
                f'for {request.url}'
            )
            return self._handle_ban(request, response, spider)

        # Record success
        proxy_key = request.meta.get('proxy', 'none')
        self.stats.setdefault(proxy_key, {'success': 0, 'fail': 0})
        self.stats[proxy_key]['success'] += 1

        return response

    def process_exception(self, request, exception, spider):
        """Handle connection errors by retrying with a different proxy."""
        logger.warning(
            f'Proxy error {type(exception).__name__} for {request.url}'
        )
        proxy_key = request.meta.get('proxy', 'none')
        self.stats.setdefault(proxy_key, {'success': 0, 'fail': 0})
        self.stats[proxy_key]['fail'] += 1

        # Mark proxy as temporarily banned (60 seconds cooldown)
        self.banned_proxies[proxy_key] = time.time() + 60

        # Don't retry here — let RetryMiddleware handle it
        return None

    def _handle_ban(self, request, response, spider):
        """On ban, mark proxy and retry with a fresh one."""
        proxy_key = request.meta.get('proxy', 'none')
        self.banned_proxies[proxy_key] = time.time() + 120
        self.stats.setdefault(proxy_key, {'success': 0, 'fail': 0})
        self.stats[proxy_key]['fail'] += 1

        # Force a new proxy on retry by clearing session
        if 'proxy_session' in request.meta:
            del request.meta['proxy_session']

        return response  # Let RetryMiddleware evaluate

    def spider_closed(self, spider):
        """Log proxy stats when spider finishes."""
        logger.info('=== Proxy Stats Summary ===')
        for proxy, counts in self.stats.items():
            total = counts['success'] + counts['fail']
            rate = counts['success'] / total * 100 if total else 0
            logger.info(
                f'{proxy}: {rate:.1f}% success '
                f'({counts["success"]}/{total})'
            )

الآن في settings.py:

# settings.py

PROXYHAT_USERNAME = 'your_username'
PROXYHAT_PASSWORD = 'your_password'
PROXYHAT_HOST = 'gate.proxyhat.com'
PROXYHAT_PORT = 8080

DOWNLOADER_MIDDLEWARES = {
    'myproject.middlewares.ProxyHatRotatingMiddleware': 540,
    'scrapy.downloadermiddlewares.httpproxy.HttpProxyMiddleware': None,
}

RETRY_TIMES = 3
RETRY_HTTP_CODES = [429, 500, 502, 503, 504]

ولاستخدامه في الزاحف:

import scrapy
import uuid


class ProductSpider(scrapy.Spider):
    name = 'products'

    def start_requests(self):
        urls = ['https://example.com/products?page=1',
                'https://example.com/products?page=2']
        for url in urls:
            yield scrapy.Request(
                url,
                callback=self.parse,
                meta={
                    'proxy_country': 'US',       # geo-target to US IPs
                    'proxy_session': str(uuid.uuid4()),  # sticky session
                }
            )

    def parse(self, response):
        for product in response.css('.product-item'):
            yield {
                'name': product.css('h2::text').get(),
                'price': product.css('.price::text').get(),
            }

scrapy-rotating-proxies مقابل بناء middleware مخصص

مكتبة scrapy-rotating-proxies هي حل مجتمعي شائع، لكن هل تناسب حالة استخدام البروكسي السكني؟ لنقارن:

المعيار scrapy-rotating-proxies Middleware مخصص (ما بنيناه)
نوع البروكسي قائمة IPs ثابتة فقط بروكسي سكني متحرك (gateway)
الدوران round-robin / عشوائي لكل طلب + جلسات لاصقة
الاستهداف الجغرافي غير مدعوم بلد + مدينة
كشف الحظر أساسي (إزالة IP من القائمة) تتبع معدل النجاح لكل IP
إعادة المحاولة مدمجة تكامل مع RetryMiddleware
الصيانة محدودة (آخر تحديث 2020) أنت تتحكم بالكامل

الخلاصة: إذا كنت تستخدم بروكسي سكني عبر gateway (مثل ProxyHat)، فالـ middleware المخصص هو الخيار الصحيح. scrapy-rotating-proxies مصمم لقوائم IP الثابتة ولا يدعم الاستهداف الجغرافي أو الجلسات اللاصقة عبر gateway.

معالجة أعطال البروكسي: RetryMiddleware مع دوران IP

عندما يفشل طلب بسبب بروكسي معطّل، لا فائدة من إعادة المحاولة بنفس البروكسي. الحل: إزالة request.meta['proxy'] قبل إعادة المحاولة، مما يسمح لـ ProxyHatRotatingMiddleware بتعيين بروكسي جديد.

from scrapy.downloadermiddlewares.retry import RetryMiddleware
from scrapy.utils.response import response_status_message
import logging

logger = logging.getLogger(__name__)


class ProxyAwareRetryMiddleware(RetryMiddleware):
    """Retry middleware that clears proxy on failure to force rotation."""

    def __init__(self, settings):
        super().__init__(settings)
        self.max_proxy_retries = settings.getint(
            'MAX_PROXY_RETRIES', 3
        )

    def process_response(self, request, response, spider):
        # Track proxy retry count
        retry_count = request.meta.get('proxy_retry_count', 0)

        if response.status in self.retry_http_codes:
            retry_count += 1
            if retry_count > self.max_proxy_retries:
                logger.error(
                    f'Max proxy retries exceeded for {request.url}'
                )
                return response

            logger.info(
                f'Retrying {request.url} with new proxy '
                f'(attempt {retry_count})'
            )
            request = request.replace(dont_filter=True)
            request.meta['proxy_retry_count'] = retry_count
            # Clear session to get a fresh proxy
            request.meta.pop('proxy_session', None)
            request.meta['proxy_country'] = request.meta.get(
                'proxy_country'
            )
            return request

        return response

    def process_exception(self, request, exception, spider):
        retry_count = request.meta.get('proxy_retry_count', 0)
        retry_count += 1

        if retry_count > self.max_proxy_retries:
            logger.error(
                f'Max proxy retries exceeded due to '
                f'{type(exception).__name__} for {request.url}'
            )
            return None

        request = request.replace(dont_filter=True)
        request.meta['proxy_retry_count'] = retry_count
        request.meta.pop('proxy_session', None)
        return request

في settings.py:

DOWNLOADER_MIDDLEWARES = {
    'myproject.middlewares.ProxyHatRotatingMiddleware': 540,
    'myproject.middlewares.ProxyAwareRetryMiddleware': 550,
    'scrapy.downloadermiddlewares.retry.RetryMiddleware': None,
    'scrapy.downloadermiddlewares.httpproxy.HttpProxyMiddleware': None,
}

RETRY_TIMES = 5
MAX_PROXY_RETRIES = 3

المواقع المعتمدة على JavaScript: scrapy-splash و scrapy-playwright

العديد من المواقع الحديثة تعرض المحتوى عبر JavaScript. Scrapy وحده لا ينفّذ JS، لكن يمكنك دمج البروكسي مع محركات المتصفح.

الخيار 1: scrapy-playwright مع بروكسي

scrapy-playwright يتيح تشغيل متصفح حقيقي داخل Scrapy. تمرير البروكسي مباشرة:

# settings.py
PLAYWRIGHT_BROWSER_TYPE = 'chromium'

DOWNLOADER_MIDDLEWARES = {
    'myproject.middlewares.ProxyHatRotatingMiddleware': 540,
    'scrapy_playwright.middleware.ScrapyPlaywrightDownloadHandler': 885,
}

# In your spider:
class JSSpider(scrapy.Spider):
    name = 'js_heavy'

    def start_requests(self):
        yield scrapy.Request(
            'https://example.com/dynamic-data',
            meta={
                'playwright': True,
                'playwright_include_page': True,
                'proxy_country': 'DE',
                'proxy_session': str(uuid.uuid4()),
            }
        )

    async def parse(self, response):
        page = response.meta.get('playwright_page')
        if page:
            # Wait for dynamic content to load
            await page.wait_for_selector('.product-grid')
            html = await page.content()
            await page.close()
            # Parse the rendered HTML
            selector = scrapy.Selector(text=html)
            for item in selector.css('.product'):
                yield {'name': item.css('h3::text').get()}

الخيار 2: scrapy-splash

إذا كنت تفضل خدمة منفصلة للعرض، scrapy-splash يعمل مع Splash Docker container. تمرير البروكسي عبر Splash arguments:

SPLASH_URL = 'http://splash:8050'

DOWNLOADER_MIDDLEWARES = {
    'scrapy_splash.SplashDeduplicateArgsMiddleware': 723,
    'myproject.middlewares.ProxyHatRotatingMiddleware': 540,
}

# In spider:
from scrapy_splash import SplashRequest

def start_requests(self):
    yield SplashRequest(
        'https://example.com',
        self.parse,
        args={'wait': 2, 'proxy': 'http://user-country-US:pass@gate.proxyhat.com:8080'},
        endpoint='render.html'
    )
ملاحظة مهمة: عند استخدام scrapy-playwright أو scrapy-splash، البروكسي يمر عبر طبقتين — middleware و محرك المتصفح. تأكد من أن middleware لا يضيف بروكسي مزدوج على طلبات Playwright (تحقق من request.meta.get('playwright')).

النشر: من التطوير إلى الإنتاج

الخيار 1: Docker + Cron (الأبسط)

للزواحف المجدولة، حاوية Docker مع cron كافية:

# Dockerfile
FROM python:3.11-slim

WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .

# Run spider on schedule via cron
CMD ["scrapy", "crawl", "products", "-s", "LOG_LEVEL=INFO"]

ثم في docker-compose.yml:

services:
  scraper:
    build: .
    environment:
      - PROXYHAT_USERNAME=${PROXYHAT_USERNAME}
      - PROXYHAT_PASSWORD=${PROXYHAT_PASSWORD}
    volumes:
      - ./output:/app/output
    deploy:
      replicas: 3  # Run 3 concurrent instances
    restart: unless-stopped

الخيار 2: Scrapyd

Scrapyd يوفر API لنشر وتشغيل الزواحف عن بعد. مناسب عندما تدير عدة زواحف وتحتاج جدولة مركزية.

الخيار 3: ScrapeOps أو خدمات مشابهة

خدمات مثل ScrapeOps تضيف طبقة مراقبة وجدولة. مفيدة إذا كنت لا تريد إدارة البنية التحتية بنفسك.

المعيار Docker + Cron Scrapyd ScrapeOps
التعقيد منخفض متوسط منخفض
المرونة عالية عالية محدودة
المراقبة يدوية Scrapyd API لوحة تحكم مدمجة
التكلفة الخادم فقط الخادم فقط اشتراك شهري
التوسع يدوي (replicas) يدوي تلقائي

المراقبة: معدل النجاح لكل IP وكشف الحظر

في بيئة الإنتاج، تحتاج مراقبة نشطة. Middleware الذي بنيناه يسجّل إحصائيات، لكن يمكنك تعزيزه بـ إشعارات فورية.

إضافة StatsCollector مخصص

# middlewares.py — add to ProxyHatRotatingMiddleware

from scrapy.statscollectors import StatsCollector

class ProxyStatsCollector(StatsCollector):
    """Custom stats collector that tracks per-IP success rates."""

    def open_spider(self, spider):
        super().open_spider(spider)
        self._proxy_stats = {}

    def record_proxy_result(self, proxy_key, success=True):
        if proxy_key not in self._proxy_stats:
            self._proxy_stats[proxy_key] = {'success': 0, 'fail': 0}
        key = 'success' if success else 'fail'
        self._proxy_stats[proxy_key][key] += 1

    def get_proxy_stats(self):
        return self._proxy_stats.copy()

مؤشرات المراقبة الأساسية

  • معدل النجاح الإجمالي: نسبة الطلبات الناجحة (200) مقابل المرفوضة (403/429)
  • زمن الاستجابة المتوسط: البروكسي البطيء يبطئ الزاحف بالكامل
  • عدد المحاولات المتكررة: ارتفاع مفاجئ يشير إلى حظر جماعي
  • معدل النجاح حسب البلد: بعض المواقع تحظر نطاقات جغرافية محددة
نصيحة عملية: ابدأ بـ CONCURRENT_REQUESTS = 16 و DOWNLOAD_DELAY = 1 مع البروكسي السكني. زيادة التزامن تدريجياً مع مراقبة معدل النجاح. إذا انخفض عن 85%، قلّل التزامن.

أفضل الممارسات لاستخدام البروكسي السكني مع Scrapy

  1. استخدم جلسات لاصقة عند الحاجة فقط. تسجيل الدخول وإكمال النماذج يحتاج نفس IP. أما جمع البيانات العامة فلا يحتاج جلسات.
  2. حدد البلد المناسب. إذا كان الموقع يعرض أسعاراً مختلفة حسب المنطقة، استخدم proxy_country لضمان一致性 البيانات.
  3. احترم robots.txt وشروط الخدمة. البروكسي لا يمنحك الحق في تجاهل القوانين.
  4. راقب معدل النجاح. إذا انخفض عن 80%، غيّر استراتيجية الدوران أو قلّل التزامن.
  5. لا تشارك كلمات المرور. استخدم بيانات اعتماد مختلفة لكل زاحف.
  6. اختبر بروكسي واحد أولاً. قبل تشغيل الزاحف الكامل، تحقق من أن curl يعمل:
curl -x http://USERNAME:PASSWORD@gate.proxyhat.com:8080 https://httpbin.org/ip

الخلاصة: النقاط الرئيسية

  • Scrapy proxy middleware هو المكان الصحيح لإدارة البروكسي — وليس متغيرات البيئة أو settings بسيطة.
  • بناء middleware مخصص يعطيك تحكماً كاملاً في Scrapy proxy rotation، الاستهداف الجغرافي، والجلسات اللاصقة.
  • scrapy-rotating-proxies مناسب فقط لقوائم IP الثابتة — لا للبروكسي السكني عبر gateway.
  • معالجة أعطال البروكسي تتطلب RetryMiddleware مخصص يزيل البروكسي الفاشل ويُعيّن واحداً جديداً.
  • البروكسي السكني مثل ProxyHat يوفر دوران IP تلقائي مع استهداف جغرافي — لا تحتاج لإدارة قائمة IPs.
  • المراقبة النشطة (معدل النجاح، زمن الاستجابة) ضرورية في الإنتاج.
  • للمواقع المعتمدة على JS، استخدم scrapy-playwright مع البروكسي.

جاهز لبدء الزحف مع بروكسي سكني موثوق؟ استكشف خطط ProxyHat أو تحقق من المواقع المتاحة.

¿Listo para empezar?

Accede a más de 50M de IPs residenciales en más de 148 países con filtrado impulsado por IA.

Ver preciosProxies residenciales
← Volver al Blog