Downloader middleware से Scrapy कैप्चा कैसे संभालें

scrapy captcha - How to Handle a Scrapy CAPTCHA with Downloader Middleware

Scrapy में कैप्चा की जगह downloader middleware है, आपका spider नहीं। middleware हर रिस्पॉन्स देखता है, इसलिए वह चैलेंज को एक ही जगह पहचान सकता है, हल कर सकता है और असली पेज spider को ऐसे लौटा सकता है मानो कुछ हुआ ही न हो। आपके parse मेथड साफ़ बने रहते हैं। यह गाइड वही middleware बनाती है, हल को इवेंट लूप से बाहर रखती है, और उन सेटिंग्स को कवर करती है जो तय करती हैं कि यह स्केल पर चलेगा या आपके क्रॉल को रोक देगा।

आपको क्या चाहिए

  • Scrapy 2.x और Python 3.10 या नया
  • CapSkip लोकल रूप से चल रहा हो, API server चालू हो। देखें सेटअप गाइड
  • Python SDK: pip install capskip

नीचे की हर बात यह मानकर चलती है कि सॉल्वर 127.0.0.1:8080पर है। कुछ भी आपकी मशीन से बाहर नहीं जाता, और क्रॉलर में यह सामान्य से ज़्यादा मायने रखता है: आप वैसे ही बहुत सारे अनुरोध भेज रहे होते हैं, और हर हल के लिए किसी तीसरे पक्ष तक का चक्कर उनमें से हर एक में देरी जोड़ देता है।

Scrapy में कैप्चा संभालना middleware में ही क्यों होना चाहिए

चैलेंज को कॉलबैक में संभालने का मतलब है कि हर कॉलबैक में वही ब्रांच चाहिए। एक भी छूटा, और वह spider ब्लॉक पेज को चुपचाप डेटा मानकर पार्स कर लेगा। downloader middleware, downloader और spider के बीच बैठता है, इसलिए रिस्पॉन्स पहले उसे मिलता है और वह उसे बदल सकता है।

इस जगह से तीन फ़ायदे मिलते हैं। पहचान एक ही फ़ंक्शन में रहती है, spider-दर-spider कॉपी-पेस्ट नहीं होती। spider के कॉलबैक को सिर्फ़ असली पेज मिलते हैं, इसलिए उनके सेलेक्टर उसी मार्कअप को मान सकते हैं जिसकी वे अपेक्षा करते हैं। और जब कोई साइट अपना चैलेंज बदलती है, तो आप पूरे प्रोजेक्ट का ऑडिट करने के बजाय एक फ़ाइल बदलते हैं।

क्रम मायने रखता है, और लोग यहीं गलती करते हैं। Scrapy, process_request को बढ़ते क्रम में और process_response को घटते क्रम में कॉल करता है। 585 पर रजिस्टर करने का मतलब है कि हमारा middleware, 550 वाले RetryMiddleware से पहले रिस्पॉन्स देखेगा।

# settings.py
DOWNLOADER_MIDDLEWARES = {
    "myproject.middlewares.CaptchaMiddleware": 585,
}

# The async client needs Scrapy's asyncio reactor.
TWISTED_REACTOR = "twisted.internet.asyncioreactor.AsyncioSelectorReactor"

चरण 1: चैलेंज पहचानें

दो संकेत ज़्यादातर साइटों को कवर कर लेते हैं: स्टेटस कोड, और बॉडी में कोई मार्कर। दोनों जाँचें, क्योंकि बहुत सी साइटें चैलेंज को 200 के साथ भेजती हैं।

# Markers for the two widgets you will hit most often.
CAPTCHA_MARKERS = ("g-recaptcha", "cf-turnstile")

def looks_like_captcha(response):
    if response.status in (403, 429):
        return True
    # Only touch the body for HTML; binary responses have no text.
    ctype = response.headers.get("Content-Type", b"").decode()
    if "html" not in ctype:
        return False
    return any(m in response.text for m in CAPTCHA_MARKERS)

इस फ़ंक्शन को उबाऊ और सस्ता रखें। यह क्रॉल के हर एक रिस्पॉन्स पर चलता है।

चरण 2: पेज से sitekey निकालें

sitekey, विजेट एलिमेंट पर एक सार्वजनिक एट्रिब्यूट होता है। इसे उसी रिस्पॉन्स से पढ़ें जो पहले से आपके पास है, कभी किसी हार्डकोड किए कॉन्स्टेंट से नहीं, क्योंकि साइटें इन्हें बदलती रहती हैं।

def extract_sitekey(response):
    # reCAPTCHA v2 and invisible both use data-sitekey.
    key = response.css(".g-recaptcha::attr(data-sitekey)").get()
    if key:
        return "recaptcha", key
    key = response.css(".cf-turnstile::attr(data-sitekey)").get()
    if key:
        return "turnstile", key
    return None, None

अगर विजेट JavaScript से इंजेक्ट होता है, तो sitekey उस HTML में नहीं होगा जिसे Scrapy ने डाउनलोड किया। यह हल की नहीं, रेंडरिंग की समस्या है, और आम तौर पर इसका मतलब होता है कि की को script टैग से रेगुलर एक्सप्रेशन के ज़रिए निकालना पड़ेगा।

चरण 3: क्रॉल को ब्लॉक किए बिना हल करें

यही वह चरण है जो चुपचाप थ्रूपुट बर्बाद कर देता है। Scrapy एक सिंगल-थ्रेडेड इवेंट लूप पर चलता है। एक हल में कई सेकंड लगते हैं, इसलिए ब्लॉकिंग क्लाइंट को सीधे अपने middleware के भीतर कॉल करना उस पूरे समय के लिए बाकी सभी चालू अनुरोधों को जमा देता है। जब CONCURRENT_REQUESTS 16 हो, तो आपने उन सभी 16 को सीरियल कर दिया।

इसके दो सही रास्ते हैं। Python SDK में AsyncCapSkipआता है, जो कोई उपनाम नहीं बल्कि सचमुच का एसिंक्रोनस क्लाइंट है, इसलिए asyncio reactor के तहत आप उसे सीधे await कर सकते हैं:

# pip install capskip
from capskip import AsyncCapSkip

solver = AsyncCapSkip(host="127.0.0.1", port=8080)

# Submit and poll both happen inside this await.
result = await solver.recaptcha(
    sitekey="YOUR_SITEKEY",
    url="https://example.com/page-with-recaptcha",
)
token = result["code"]

अगर आप अब भी क्लासिक Twisted reactor पर हैं, तो ब्लॉकिंग क्लाइंट को एक थ्रेड में डालें और Deferred को await करें:

from capskip import CapSkip
from twisted.internet.threads import deferToThread

solver = CapSkip(host="127.0.0.1", port=8080)

# deferToThread keeps the reactor free while the solve runs.
result = await deferToThread(
    solver.recaptcha,
    sitekey="YOUR_SITEKEY",
    url="https://example.com/page-with-recaptcha",
)

दोनों काम करते हैं क्योंकि Scrapy किसी भी downloader middleware मेथड को कोरूटीन होने देता है। उसे async def से परिभाषित करें, बाकी Scrapy संभाल लेगा।

चरण 4: token के साथ दोबारा भेजें

अकेला token कुछ नहीं करता। उसे साइट पर उसी तरह वापस जाना चाहिए जैसे साइट का अपना फ्रंटएंड भेजता; क्लासिक फ़ॉर्म के लिए इसका मतलब है इस नाम वाला फ़ील्ड: g-recaptcha-response.

एक नया Request को process_response से लौटाएँ और Scrapy उसे दोबारा शेड्यूल कर देगा। दो बातें इसे बिगड़ने से बचाती हैं: dont_filter=True, क्योंकि यह URL पहले देखा जा चुका है और dupe फ़िल्टर उसे हटा देगा, और meta में एक काउंटर, ताकि लगातार चैलेंज देती रहने वाली साइट आपको अनंत लूप में न डाल सके।

from scrapy import FormRequest

def resubmit(response, token, tries):
    return FormRequest.from_response(
        response,
        formdata={"g-recaptcha-response": token},
        dont_filter=True,          # the dupe filter has seen this URL
        meta={"captcha_tries": tries + 1},
    )

पूरा middleware

# myproject/middlewares.py
import logging

from capskip import (
    AsyncCapSkip, ApiException, NetworkException, TimeoutException)
from scrapy import FormRequest
from scrapy.exceptions import IgnoreRequest

logger = logging.getLogger(__name__)
MAX_CAPTCHA_TRIES = 2


class CaptchaMiddleware:
    def __init__(self):
        self.solver = AsyncCapSkip(host="127.0.0.1", port=8080)

    async def process_response(self, request, response, spider):
        if not looks_like_captcha(response):
            return response

        tries = request.meta.get("captcha_tries", 0)
        if tries >= MAX_CAPTCHA_TRIES:
            raise IgnoreRequest("captcha not cleared: %s" % request.url)

        kind, sitekey = extract_sitekey(response)
        if not sitekey:
            return response          # not a shape we handle

        try:
            if kind == "turnstile":
                result = await self.solver.turnstile(
                    sitekey=sitekey, url=response.url)
            else:
                result = await self.solver.recaptcha(
                    sitekey=sitekey, url=response.url)
        except (ApiException, NetworkException, TimeoutException) as e:
            logger.warning("solve failed for %s: %s", request.url, e)
            return response

        logger.info("solved %s captcha for %s", kind, request.url)

        return FormRequest.from_response(
            response,
            formdata={"g-recaptcha-response": result["code"]},
            dont_filter=True,
            meta={**request.meta, "captcha_tries": tries + 1},
        )

SDK चार तरह के एक्सेप्शन फेंकता है: ValidationException, NetworkException, ApiException और TimeoutException। ऊपर के तीन वही हैं जो रनटाइम पर होते हैं; जबकि ValidationException का मतलब है कि आपके पैरामीटर गलत हैं, और उसे निगलने के बजाय डेवलपमेंट के दौरान ज़ोर से फेल होना चाहिए। विफलता पर मूल रिस्पॉन्स लौटाने से बाकी पाइपलाइन तय कर सकती है कि क्या करना है, और यह spider को क्रैश कराने से बेहतर है।

वे सेटिंग्स जो सचमुच मायने रखती हैं

सेटिंगक्यों
CONCURRENT_REQUESTS_PER_DOMAINइसे घटाएँ। कैप्चा से टकराना आम तौर पर दर का संकेत होता है, और तेज़ हल करने से वह वजह दूर नहीं होती जिसके कारण चैलेंज आया
DOWNLOAD_DELAY साथ में AUTOTHROTTLE_ENABLEDहल करने से सस्ता। हर वह चैलेंज जिससे आप बचते हैं, कुछ भी खर्च नहीं कराता
COOKIES_ENABLEDचालू रहना ज़रूरी है। हल हुए चैलेंज से मिली clearance कुकी ही अगले अनुरोध को दोबारा चैलेंज होने से रोकती है
RETRY_TIMESआपके कैप्चा काउंटर से स्वतंत्र। दोनों सीमाओं को अलग रखें, वरना वे आपस में गुणा हो जाएँगी

तीसरी पंक्ति ही सबसे ज़्यादा याद रखने लायक है। अगर कुकीज़ बंद हैं, तो हर अनुरोध पहली विज़िट जैसा दिखता है और आप वही चैलेंज हमेशा हल करते रहेंगे। लोग जिन Scrapy कैप्चा लूप की शिकायत करते हैं, उनमें से ज़्यादातर ठीक यही निकलते हैं।

spider के भीतर इमेज कैप्चा

कुछ साइटें लॉगिन फ़ॉर्म पर सादा, विकृत टेक्स्ट वाला इमेज इस्तेमाल करती हैं। इसमें कोई sitekey नहीं होता, इसलिए यह आसान है: इमेज डाउनलोड करें और बाइट्स को data URI के रूप में पास कर दें।

import base64
import scrapy
from capskip import CapSkip

solver = CapSkip(host="127.0.0.1", port=8080)

class LoginSpider(scrapy.Spider):
    def parse_captcha_image(self, response):
        # response.body is the raw image, fetched with session cookies.
        b64 = base64.b64encode(response.body).decode()
        result = solver.normal("data:image/png;base64," + b64)
        return result["code"]     # the text on the image

normal() फ़ाइल पाथ या रिमोट URL भी लेता है। Scrapy में आपको data URI वाला रूप ही चाहिए, क्योंकि इमेज आम तौर पर सिर्फ़ उसी सेशन के लिए सही रेंडर होता है जिसने उसे माँगा था। ध्यान रखें कि इमेज कैप्चा के लिए प्रॉक्सी समर्थित नहीं है, सिर्फ़ reCAPTCHA, Turnstile और GeeTest के लिए है।

आम Scrapy कैप्चा त्रुटियाँ

लक्षणकारणफिक्स
क्रॉल थ्रूपुट गिर जाता हैइवेंट लूप पर ब्लॉकिंग हलउपयोग करें AsyncCapSkip या deferToThread
दोबारा भेजा गया अनुरोध कभी चलता ही नहींdupe फ़िल्टर ने उसे हटा दियाजोड़ें dont_filter=True
वही पेज हमेशा चैलेंज देता रहता हैकुकीज़ बंद हैं, या meta आगे नहीं ले जाया गयाकुकीज़ चालू करें, request.meta को नए अनुरोध में मर्ज करें
ERROR_GOOGLEKEYsitekey पुराना था या हार्डकोड किया हुआहर बार उसे लाइव रिस्पॉन्स से पढ़ें
NetworkException हर हल परCapSkip चल नहीं रहा या पोर्ट अलग हैऐप शुरू करें, Settings में पोर्ट जाँचें

API जो भी त्रुटि स्ट्रिंग लौटा सकता है, वे सब API डॉक्युमेंटेशनमें हैं। Scrapy का अपना downloader middleware संदर्भ क्रम के नियम पूरी तरह समझाता है।

अक्सर पूछे जाने वाले सवाल

क्या यह Scrapy के डिफ़ॉल्ट reactor के साथ काम करता है?

हाँ, पर सिर्फ़ deferToThread वाला तरीका। AsyncCapSkip एक asyncio क्लाइंट है, इसलिए इसके लिए TWISTED_REACTOR को asyncio reactor पर सेट करना ज़रूरी है। दोनों तरीके क्रॉल को चलता रखते हैं।

क्या मुझे हर उस Scrapy कैप्चा को हल करना चाहिए जिससे मैं टकराऊँ?

नहीं। अचानक चैलेंज की दीवार खड़ी होने का मतलब है कि आपका क्रॉल पैटर्न फ़्लैग हो गया है। पहले रफ़्तार घटाएँ। हल करके आगे बढ़ना लक्षण का इलाज है, और आम तौर पर इससे और सख़्त ब्लॉक मिलता है।

क्या मैं हल को उसी प्रॉक्सी से भेज सकता हूँ जिससे अनुरोध जाता है?

हाँ, reCAPTCHA, Turnstile और GeeTest के लिए। हल वाली कॉल में proxy={"type": "HTTPS", "uri": "user:[email protected]:3128"} पास करें ताकि token उसी एग्ज़िट IP से बने जो पेज ने देखा था।

अगर मैं प्रॉक्सी middleware भी इस्तेमाल करूँ तो यह middleware कहाँ जाएगा?

उसके नीचे, process_response के लिहाज़ से, यानी बड़ा नंबर। प्रॉक्सी middleware अनुरोधों पर काम करते हैं, हमारा रिस्पॉन्स पर। 585 पर यह retry middleware के रिस्पॉन्स देखने से पहले चलता है।

सारांश

Scrapy कैप्चा पाँच हिस्सों की समस्या है: चैलेंज को middleware में पहचानें, sitekey को लाइव रिस्पॉन्स से पढ़ें, reactor को ब्लॉक किए बिना हल करें, dont_filter=Trueके साथ दोबारा भेजें, और metaमें रीट्राई की सीमा तय करें। इन्हें सही कर लें, तो spider साफ़ बने रहते हैं, क्योंकि पूरा काम एक ही फ़ाइल संभालती है।

बड़ी तस्वीर के लिए देखें कि CapSkip एक क्रॉलर में कैसे फ़िट होता है, वेब स्क्रैपिंग के लिए कैप्चा सॉल्वर पेज पर, Python एकीकरण गाइडमें, या विजेट की अपनी बारीकियाँ reCAPTCHA v2 सॉल्वर पेज पर। CapSkip एक कैप्चा सॉल्वर है जो आपकी अपनी मशीन पर चलता है, इसलिए उसे क्रॉल में जोड़ने पर कोई अतिरिक्त नेटवर्क हॉप नहीं लगता।