Scrapy’de Tek Bir Satır İçi İstekle ALTCHA Nasıl Çözülür

solve altcha in scrapy - How to Solve ALTCHA in Scrapy With One Inline Request

Scrapy ile ALTCHA çözmek için challenge adresini altcha-widget öğesinden okuyun, aynı callback içinden bir satır içi istekle (inline request) çekin, JSON’u AsyncCapSkip’in altcha metoduna verin ve formu, token’ı widget’ın adlandırdığı alana (varsayılan olarak altcha) koyarak gönderin. ALTCHA bir resim değil, iş kanıtıdır; dolayısıyla bunların hiçbiri tarayıcı gerektirmez ve düz bir Scrapy spider’ı yeterlidir. Scrapy’ye özgü tek tuzağı gözden kaçırmak kolaydır: bir challenge uç noktası her seferinde aynı URL’de yeni bir belge döndürür ve Scrapy’nin yinelenen istek filtresi (duplicate filter) ona giden ikinci isteği sessizce düşürür. Bu rehber widget’ı, çekme işlemini, çözümü ve gönderimi, çalıştırabileceğiniz bir spider ile birlikte ele alıyor.

Neye ihtiyacınız var

  • Aşağıda kullanılan satır içi istek metodu için Scrapy 2.14 veya üzeri. Scrapy, 2.13’ten beri varsayılan olarak asyncio reaktörü üzerinde çalışır ve bir callback’in async istemciyi await edebilmesini sağlayan da budur. Daha eski sürümleri sona yakın bir bölüm ele alıyor.
  • capskip Python paketinin 1.2.0 veya üzeri bir sürümü (altcha metodunu ekleyen sürüm budur) ve bir Windows makinesinde çalışan CapSkip 1.3.0 veya üzeri.
  • Scrapy’nin artık form gönderimlerini oluşturmak için önerdiği form2request paketi.
  • Formun bulunduğu sayfa. Challenge dahil geri kalan her şey o sayfadan gelir; nereden geldiğini 1. adım gösteriyor.
  • Çözücü için bir adres. Local modu 127.0.0.1 üzerinden yalnızca o cihaza yanıt verir; Server modu ise ağ adresinizi veya genel IP’nizi dinler, böylece başka bir makinedeki bir crawler onu API üzerinden çağırabilir. İkisi de bağlantı ayarları bölümünde yer alır; ne zaman geçiş yapmanız gerektiğini aşağıda ayrı bir başlık anlatıyor.
# pip install scrapy capskip form2request
pip install scrapy capskip form2request

1. Adım: challenge’ı widget’tan okuyun

ALTCHA’nın sitekey’i yoktur. Scrapy ile ALTCHA çözmek için ihtiyacınız olan şey challenge’ın kendisi ya da geldiği adrestir ve widget öğesi bunlardan birini taşır. Onu hangi özniteliğin tuttuğu widget nesline bağlıdır, bu yüzden üçünü de okuyun.

WidgetÖznitelikTuttuğu değer
v1 ve v2challengeurl ya da challengejsonchallengeurl, challenge sunan uç noktayı belirtir (çoğu zaman göreli bir yol); challengejson ise challenge belgesinin kendisini taşır
v3 ve sonrasıchallengeYa o uç nokta ya da challenge belgesinin kendisi
# Inside a spider callback; response is the page with the form.
widget = response.css("altcha-widget")
source = (widget.attrib.get("challenge")
          or widget.attrib.get("challengejson")
          or widget.attrib.get("challengeurl"))

# The token goes in a field named by the widget, altcha by default.
field = widget.attrib.get("name", "altcha")

# A v3 challenge or a v1/v2 challengejson carries the document inline.
inline = source.lstrip().startswith("{")

name özniteliğini de not edin. Widget payload’ını bu adı taşıyan gizli bir input’a yazar; varsayılanı altcha’dır, ancak site bunu değiştirebilir. Onu okumak tek satıra mal olur ve gerçek alanı sessizce boş bırakan bir gönderimi önler. Bazı kurulumlar challenge’ı bir uç noktadan sunmak yerine sayfanın kendi script’inde üretir ve bu durumda okunacak bir öznitelik yoktur. Hangisiyle karşı karşıya olduğunuzu Network sekmesi gösterir.

2. Adım: challenge’ı callback içinde çekin

Widget bir uç noktayı gösteriyorsa onu, zaten içinde bulunduğunuz callback’ten engine’in download_async metoduyla çekin. Scrapy dokümantasyonu bu yöntemi satır içi istekler (inline requests) başlığı altında anlatır. İstek downloader middleware’lerinden geçer, dolayısıyla spider’ın cookie jar’ı, user agent’ı ve proxy ayarları, sayfaya uygulandığı gibi bu isteğe de uygulanır. Scheduler’dan ise geçmez ve bütün mesele de budur.

# The page's jar and proxy; no scheduler, no dupefilter.
meta = {"dont_cache": True, "allow_offsite": True}
for key in ("cookiejar", "proxy"):
    if key in response.meta:
        meta[key] = response.meta[key]
reply = await self.crawler.engine.download_async(
    scrapy.Request(response.urljoin(source), meta=meta,
                   headers={"Referer": response.url})
)
if reply.status != 200:
    raise ValueError(f"challenge endpoint answered {reply.status}")
challenge_json = reply.text

Scheduler’ı atlamanın önemi şuradan gelir. Akla ilk gelen alternatif, yani challenge için kendi callback’i olan bir Request yield etmek, ilk formda çalışır ve ikincisini kaybeder. Uç noktanın her çekilişi yeni bir challenge döndürür, ancak URL hiç değişmez; bu yüzden Scrapy’nin yinelenen istek filtresi ikinci isteği daha önce yapılmış bir istek sayar ve atar. Hiçbir hata oluşmaz. Filtre attığı ilk isteği debug seviyesinde bir kez loglar, tarama istatistiklerinde bir sayacı artırır ve arkasındaki form basitçe hiç gönderilmez. Satır içi istek ise filtreye hiç ulaşmaz.

O bloğun geri kalanı, normalde scheduler’ın sizin yerinize hallettiği ayrıntıları kapsar. İstek başına durum kendiliğinden aktarılmaz; bu yüzden döngü, sayfa bir cookiejar veya proxy anahtarıyla çekildiyse bu anahtarı sayfanın meta sözlüğünden kopyalar. Referer başlığı elle ayarlanır, çünkü onu ekleyen middleware spider tarafında çalışır. Challenge uç noktası başka bir alan adındaysa (örneğin barındırılan bir ALTCHA servisi) ve spider allowed_domains ayarlıyorsa offsite filtresi çekme işlemini engeller; allow_offsite bu tek isteğin geçmesine izin verir. Durum kontrolü, uç noktadan gelen bir 403 burada sıradan bir yanıt olarak döndüğü için vardır: hata durumlarını başarısızlığa çeviren middleware spider tarafında çalışır ve satır içi bir istek o tarafı atlar. dont_cache ise, açtıysanız Scrapy’nin HTTP önbelleğini challenge isteğinden uzak tutar. Önbelleğe alınmış bir challenge, süresi dolmuş bir challenge demektir ve CapSkip, sitenin reddedeceği bir token için CPU harcamak yerine süresi dolmuş satır içi bir challenge’ı hemen reddeder. Bu bayrak olmadan ikinci bir çalıştırma, uç noktayı paylaşan her sayfaya aynı saklanmış challenge’ı verir. Aynı mantık form sayfası için de geçerlidir. Önbelleğe alınmış bir sayfa eski bir anti-forgery token’ını ve oturum çerezini, satır içi challenge varsa eski bir challenge’ı da yeniden oynatır; dolayısıyla önbellek açıksa form sayfalarına da dont_cache koyun ya da bu spider için önbelleği kapalı tutun.

Bunun yerine uç noktayı challenge_url olarak geçirip challenge’ı CapSkip’in kendisinin çekmesine de izin verebilirsiniz. Herkese açık bir uç nokta için bu sorun değildir. Ancak CapSkip’in yaptığı çekme işlemi spider’ınızın çerezlerinden hiçbirini taşımaz ve bazı siteler challenge’ı yalnızca formu yükleyen oturuma verir. Scrapy üzerinden çekmek, baştan sona tek bir oturum korur.

3. Adım: taramayı bloklamadan çözün

AsyncCapSkip kullanın ve callback’i async def yapın. Python paketinde bu sınıf bir takma ad değil, gerçek bir asyncio istemcisidir; dolayısıyla onu await etmek, çözüm sürerken kontrolü Scrapy’ye geri verir ve diğer tüm istekler ilerlemeye devam eder. Senkron CapSkip sınıfı ise her çözüm boyunca tüm taramayı durdururdu; bu sorun şurada ayrıntılı olarak açıklanıyor: Scrapy CAPTCHA middleware rehberi.

from capskip import AsyncCapSkip

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

# Pass the document exactly as the endpoint sent it.
result = await solver.altcha(url=response.url, challenge_json=challenge_json)

token = result["token"]   # base64 payload for the form field

Challenge satır içi olarak iletildiği için CapSkip hiçbir ağ isteği yapmaz. Cevabı bulana kadar hash hesaplar, bu genellikle milisaniyeler sürer; istemci de hemen ve çeyrek saniye sonra bir kez daha yoklar, dolayısıyla tipik bir çözüm yaklaşık çeyrek saniyede döner. Çağrı en fazla defaultTimeout kadar, yani 120 saniye bekler, çünkü bu bir tarayıcı oturumu değil CPU işidir. İki challenge algoritması, Argon2id ve scrypt, denenmek yerine reddedilir ve bu ret bir saniyeden kısa sürede bir ApiException olarak gelir. ALTCHA’nın önerdiği varsayılan dahil, daha yaygın olan PBKDF2 ve SHA şemaları desteklenir.

Yanıtın metnini geldiği gibi geçirin; ayrıştırmanıza gerek yok. Değişmemesi gereken şey token’dır: token, alanları sitenin sunucusu tarafından imzalanmış bir JSON belgesinin base64 hâlidir; dolayısıyla kırpılmış, çözülmüş ya da yeniden kodlanmış bir token doğrulamadan geçemez.

4. Adım: formu token ile gönderin

Widget’ın doldurduğu gizli input, Scrapy’nin indirdiği HTML’de yoktur. Widget onu bir tarayıcıda, kendisi çalıştıktan sonra oluşturur; dolayısıyla sunulan HTML’i okuyan hiçbir şey onu bulamaz. Onu 1. adımdaki adla kendiniz ekleyin. Örnek, gönderimi Scrapy’nin FormRequest.from_response yerine önerdiği form2request ile oluşturur: Scrapy 2.16’dan beri bu eski metot her çağrıda bir kullanımdan kaldırma uyarısı (deprecation warning) loglar.

from form2request import form2request

form = response.xpath("//form[.//altcha-widget]")
data = {"email": "[email protected]", field: token}
yield form2request(form, data).to_scrapy(
    callback=self.after_submit,
    priority=10,
    meta={"handle_httpstatus_all": True},
)

form2request, sayfanın forma zaten koyduğu anti-forgery token’ı gibi değerleri korur ve ilk gönder düğmesine basar; böylece istek, bir tarayıcının göndereceği isteğe benzer. XPath, widget’ı içeren formu seçer; bu, bir arama kutusu da taşıyan sayfalarda önemlidir. handle_httpstatus_all, callback’in reddedilen bir gönderimi görmesini sağlar; aksi hâlde Scrapy bu yanıtı callback’e ulaşmadan düşürürdü. Öncelik artışı, gönderimi scheduler’da hâlâ bekleyen her şeyin önüne geçirir, çünkü challenge’ların süresi dolar. Bazı pencereler topu topu iki dakikadır ve uzun bir taramanın arkasında bekleyen bir token daha gönderilmeden süresi dolabilir.

Her token’ı tek bir gönderim için geçerli sayın. Site formu reddederse aynı isteği yeniden denemek yerine yeni bir challenge ile 2. adımdan yeniden başlayın. Bazı entegrasyonlar payload’ı bir form alanı yerine bir JSON gövdesinde ya da bir çerezde gönderir; bu yüzden DevTools açıkken formu bir kez elle gönderin ve sayfanın gönderdiğini birebir taklit edin.

Çözücüyü başka bir yerde çalıştırmak

Örnekler 127.0.0.1 kullanıyor, çünkü spider ile CapSkip aynı makinedeyken doğru adres budur. Başka bir makinedeki Scrapyd’ye, bir VPS’e ya da barındırılan bir Scrapy platformuna dağıtılmış bir crawler için loopback kendisini gösterir ve ilk çözüm bir NetworkException fırlatır. CapSkip’i Server moduna alın; o zaman ağ adresinizi veya genel IP’nizi dinler, böylece spider izin verdiğiniz her yerden ona API üzerinden ulaşabilir. Rota internetten geçiyorsa statik bir genel IP kullanın, API anahtarı doğrulamasını açın ve bir Windows Firewall kuralıyla portu yalnızca beklediğiniz adreslerle sınırlayın. Bu hâlâ sizin kendi Windows makinenizdir ve çözüm başına ücretlendirme hâlâ yoktur.

İstemci ortam değişkenlerini kendiliğinden okumaz. Tam örnekte olduğu gibi CAPSKIP_HOST ve CAPSKIP_API_KEY değerlerini spider içinde okuyup constructor’a verin; böylece aynı kod hem masanızda hem de sunucuda çalışır.

Tam çalışan örnek

# pip install scrapy capskip form2request
import os

import scrapy
from capskip import AsyncCapSkip, CapSkipError
from form2request import form2request


class SignupSpider(scrapy.Spider):
    name = "signup"
    start_urls = ["https://example.com/signup"]

    solver = AsyncCapSkip(
        apiKey=os.environ.get("CAPSKIP_API_KEY", "capskip"),
        host=os.environ.get("CAPSKIP_HOST", "127.0.0.1"),
        port=8080,
    )

    async def parse(self, response):
        widget = response.css("altcha-widget")
        source = (widget.attrib.get("challenge")
                  or widget.attrib.get("challengejson")
                  or widget.attrib.get("challengeurl"))
        if not source:
            self.logger.warning("no ALTCHA challenge on %s", response.url)
            return
        field = widget.attrib.get("name", "altcha")

        if source.lstrip().startswith("{"):
            challenge_json = source
        else:
            # Inline request: the page's jar and proxy, no dupefilter.
            meta = {"dont_cache": True, "allow_offsite": True}
            for key in ("cookiejar", "proxy"):
                if key in response.meta:
                    meta[key] = response.meta[key]
            reply = await self.crawler.engine.download_async(
                scrapy.Request(response.urljoin(source), meta=meta,
                               headers={"Referer": response.url})
            )
            if reply.status != 200:
                self.logger.error("challenge endpoint answered %s", reply.status)
                return
            challenge_json = reply.text

        try:
            result = await self.solver.altcha(
                url=response.url, challenge_json=challenge_json)
        except CapSkipError as exc:
            # Expired challenge, unsupported algorithm, or CapSkip unreachable.
            self.logger.error("ALTCHA not solved on %s: %r", response.url, exc)
            return

        form = response.xpath("//form[.//altcha-widget]")
        data = {"email": "[email protected]", field: result["token"]}
        yield form2request(form, data).to_scrapy(
            callback=self.after_submit,
            priority=10,
            meta={"handle_httpstatus_all": True},
        )

    def after_submit(self, response):
        yield {"url": response.url, "status": response.status}

Onu scrapy runspider ve dosya adıyla çalıştırın ya da sınıfı bir projeye bırakın. Spider iki widget neslini de işler, challenge’ı sayfanın oturumu içinde çeker ve gönderilen her form için, ret yanıtları dahil, sitenin döndürdüğü durum koduyla birlikte bir item yield eder. Challenge çekme işlemi scheduler’ı atladığı için aynı challenge uç noktasını paylaşan sayfaların her biri kendi challenge’ını alır. altcha metodunun arkasındaki ham uç nokta şurada belgelenmiştir: API referansı. Scrapy olmadan yapılan düz Python çağrısı ise şurada ele alınıyor: Python ALTCHA rehberi.

2.14’ten eski Scrapy sürümlerinde

2.14’ten önce download_async yoktur, ancak Scrapy 2.6’dan itibaren aynı satır içi çekme işlemi engine.download ile çalışır; sonucu, bir coroutine’in bekleyebileceği bir şeye dönüştüren bir yardımcı fonksiyonla await edilir.

from scrapy.utils.defer import maybe_deferred_to_future

# Scrapy 2.6 to 2.13: the same inline fetch, through engine.download.
reply = await maybe_deferred_to_future(self.crawler.engine.download(
    scrapy.Request(response.urljoin(source),
                   meta={"dont_cache": True, "allow_offsite": True})
))

Bu sürümlerde FormRequest.from_response gönderimi uyarı vermeden oluşturur, dolayısıyla onu form2request yerine kullanabilirsiniz: aynı XPath ifadesini formxpath olarak geçirin ve token’ı formdata içine koyun. Bunun yerine challenge’ı kendi callback’i olan sıradan bir istek olarak scheduler üzerinden gönderirseniz, o istekte dont_filter ayarlayın; aksi hâlde yinelenen istek filtresi ilkinden sonraki her çekme isteğini düşürür.

2.13’ten eski bir Scrapy’de AsyncCapSkip ayrıca settings.py içinde TWISTED_REACTOR değerinin asyncio reaktörüne ayarlanmasını gerektirir. Scrapy 2.7’den beri oluşturulan projelerde bu satır zaten vardır.

Sık görülen hatalar ve anlamları

GördüğünüzNedenDüzeltme
İlk form gönderiliyor, sonrakiler hiç gönderilmiyor ve hata da yokChallenge normal bir istek olarak yield edildi ve yinelenen istek filtresi tekrarları düşürdüOnu download_async ile çekin ya da istekte dont_filter ayarlayın
Bir anda gelen bir ApiException, ama yalnızca ilk çalıştırmadan sonraHTTP önbelleği eski, süresi dolmuş bir challenge’ı yeniden oynattıChallenge isteğine dont_cache ekleyin
Çözüm başarılı olduğu hâlde site doğrulamanın başarısız olduğunu söylüyorToken çok uzun bekledi, yeniden kullanıldı ya da yanlış alana girdiYükseltilmiş öncelikle, token başına bir kez ve widget’ın name özniteliğindeki adla hemen gönderin
Challenge uç noktasından bir 403Uç nokta, formu yükleyen oturumu istiyorOnu challenge_url üzerinden değil, yukarıdaki gibi Scrapy üzerinden çekin
Her seferinde bir saniyeden kısa sürede gelen bir ApiExceptionSite, denenmek yerine reddedilen Argon2id veya scrypt kullanıyorYeniden denenecek bir şey yok; o site başka bir yol gerektirir
Her form çözülürken tarama duruyorSenkron CapSkip sınıfı olay döngüsü üzerinde çalışıyorAsyncCapSkip’e ve async def bir callback’e geçin
NoEventLoopError, not currently running on any asynchronous event loop (eski kurulumlarda AsyncLibraryNotFoundError)Proje, asyncio dışında bir reaktörü sabitliyorO TWISTED_REACTOR satırını kaldırın ya da onu asyncio reaktörüne ayarlayın
AttributeError: ‘ExecutionEngine’ object has no attribute ‘download_async’Scrapy sürümü 2.14’ten eskiYükseltin ya da yukarıda gösterildiği gibi engine.download kullanın
Challenge çekilirken IgnoreRequest, filtered offsite requestChallenge uç noktası başka bir alan adında ve spider allowed_domains ayarlıyorChallenge isteğinin meta sözlüğüne allow_offsite ekleyin
Her gönderimde from_response hakkında bir ScrapyDeprecationWarningScrapy 2.16 ve sonrasında FormRequest.from_response kullanımdan kaldırılmış (deprecated) durumdaGönderimi form2request ile oluşturun
İlk çözümde bir NetworkExceptionCapSkip çalışmıyor ya da host ve port yanlışCapSkip’i başlatın, ardından Local modunda mı yoksa Server modunda mı olması gerektiğini kontrol edin

FAQ

ALTCHA için scrapy-playwright ya da headless bir tarayıcı gerekir mi?

Hayır, Scrapy ile ALTCHA çözmek için düz istekler yeterlidir. Widget’ın bütün işi bir challenge çekmek, bir sayı bulmak için biraz CPU yakmak ve sonucu bir alana yazmaktır. Challenge’ı Scrapy çekebilir, sayıyı CapSkip bulur, alanı da form2request yazar; dolayısıyla hiçbir yerde JavaScript çalışması gerekmez. Bu da spider’ı diğer tüm Scrapy taramaları kadar hızlı ve çalıştırması ucuz tutar.

Scrapyd’deki ya da barındırılan bir Scrapy platformundaki bir spider çözücüye ulaşabilir mi?

Evet. Bağlantı ayarlarından CapSkip’i Server moduna alın; böylece loopback yerine bir ağ adresini dinler. Spider’ın ortamında CAPSKIP_HOST değişkenini ayarlayın ve tam örnekte olduğu gibi onu istemciye verin. Barındırılan bir platform, yerel bir spider ile aynı HTTP API üzerinden bağlanır. Rota internetten geçiyorsa bir güvenlik duvarı kuralıyla birlikte statik bir genel IP kullanın. Çözücü sizin sahip olduğunuz donanımda kalır, dolayısıyla çözüm sayısı ödediğiniz tutarı asla değiştirmez.

Bu neden bir downloader middleware’de değil de bir callback’te?

Çünkü ALTCHA, taramayı kesen bir engelleme sayfasında değil, göndermeyi seçtiğiniz bir formda bulunur. Herhangi bir yanıtta çıkabilecek bir challenge için doğru yer bir middleware’dir ve reCAPTCHA middleware rehberi de onu böyle ele alır. ALTCHA ise akışın tek bir adımının parçasıdır ve o formu oluşturan callback, çözümün ihtiyaç duyduğu her şeye zaten sahiptir.

Birçok formu paralel olarak çözebilir miyim?

Evet, üstelik Scrapy bunu sizin yerinize zaten yapar. Çözücüyü await eden her async callback kontrolü bırakır; dolayısıyla Scrapy’nin indirdiği her sayfanın çözümü aynı anda sürüyor olabilir. CONCURRENT_REQUESTS bunu sınırlamaz, çünkü callback’leri değil indirmeleri sınırlar. CapSkip tarafında, ALTCHA ayarlarındaki Max. Threads aynı anda kaç challenge’ın hashlendiğini belirler; bu CPU işi olduğu için çekirdek sayısından fazla thread hiçbir kazanç sağlamaz. Her callback kendi challenge’ını çözmeden hemen önce çektiği için hiçbiri kuyrukta bekleyip bayatlamaz. Aynı nedenle, daha sonra çözmek üzere toplu hâlde challenge önceden çekmeyin.

Kısa özet

Scrapy ile ALTCHA çözmek için altcha-widget öğesinden challenge, challengejson veya challengeurl özniteliğini ve name özniteliğini okuyun. Challenge’ı download_async ve dont_cache ile çekin; böylece spider’ın oturumunu kullanır ve yinelenen istek filtresiyle hiç karşılaşmaz. AsyncCapSkip’in altcha metodunu, JSON’u geldiği gibi vererek await edin ve token’ı verisinde taşıyan, önceliği yükseltilmiş bir form2request gönderimi yield edin. Tek challenge, tek token, tek gönderim; spider başka bir yerde çalışıyorsa da Server modu.

Hacimle ilgili son bir nokta. Binlerce form gönderen bir crawler binlerce challenge çözer ve kendi makinenizde çalışan bir captcha çözücü ile bunun maliyeti çözüm başına bir ücret değil, CPU zamanıdır.