So lösen Sie Captchas in Appium-Tests (Python-Client)

appium captcha - How to Solve CAPTCHAs in Appium Tests (Python Client)

Ein Captcha-Schritt in Appium ist die Stelle, an der ein mobiler Lauf normalerweise stehen bleibt und auf einen Menschen wartet. Das muss nicht sein. Appium kann bereits einen Screenshot genau des Elements machen, das die Aufgabe enthält, und CapSkip läuft auf Ihrem eigenen Rechner und beantwortet sie, der Test tippt das Ergebnis also ein und macht weiter. Der unangenehme Teil ist nicht das Lösen, sondern die Session: Appium beendet eine Session, die still wird, und eine Lösung ist genau die Art von Stille, die Appium nicht mag. Dieser Leitfaden behandelt beide Formen, denen Sie begegnen werden, ein natives Bildfeld und ein Captcha in einem WebView, mit Python.

Was Sie brauchen

  • CapSkip, laufend auf einem Windows-Rechner, mit eingeschaltetem API-Server.
  • Appium 2 und ein funktionierender Treiber, UiAutomator2 für Android oder XCUITest für iOS, dazu ein Gerät oder ein Emulator, den Sie bereits steuern können.
  • Python 3.10 oder neuer mit installiertem Appium-Client und CapSkip-Paket.
  • Eine Adresse für den Solver. Der Lokal-Modus antwortet auf 127.0.0.1, sodass nur Code, der auf dem CapSkip-Rechner selbst läuft, ihn erreicht, und der Server-Modus lauscht auf Ihrer Netzwerkadresse oder öffentlichen IP, sodass auch ein Build-Agent oder ein CI-Runner ihn erreicht. Schritt 4 klärt, welcher davon gilt, und beide finden Sie unter Verbindungseinstellungen.
# pip install Appium-Python-Client capskip
pip install Appium-Python-Client capskip

Schritt 1: Der Session Raum zum Warten geben

Machen Sie das vor allem anderen, denn das ist der Fehler, der die meiste Zeit kostet. Appium führt pro Session einen Leerlauf-Timer namens newCommandTimeout. Er steht standardmäßig auf 60 Sekunden, und wenn innerhalb dieses Fensters kein neuer Befehl eintrifft, entscheidet der Server, dass der Client verschwunden ist, und beendet die Session. Jeder spätere Aufruf scheitert dann an einer Session, die es nicht mehr gibt.

Eine Lösung ist eine Lücke im Befehlsstrom. Ihr Python-Code spricht mit CapSkip, nicht mit Appium, für die gesamte Dauer dieser Lösung liegt der Treiber also brach. Stellen Sie die beiden Uhren nebeneinander, und das Problem ist offensichtlich.

UhrStandardWas sie abdeckt
Appium newCommandTimeout60 SekundenLeerlaufzeit zwischen zwei Treiberbefehlen, pro Session
CapSkip defaultTimeout120 SekundenPolling bei Bild-Captcha und ALTCHA
CapSkip recaptchaTimeout300 SekundenPolling für reCAPTCHA, Turnstile und GeeTest

Ein Bild-Captcha kommt meist schnell genug zurück, dass es niemandem auffällt. Ein reCAPTCHA auf einem ausgelasteten Solver nicht, und der Client ist bereit, fünfmal so lange zu warten wie Appium. Heben Sie den Leerlauf-Timer über die längste Lösung hinaus an, auf die Sie zu warten bereit sind.

# pip install Appium-Python-Client capskip
from appium import webdriver
from appium.options.android import UiAutomator2Options

options = UiAutomator2Options()
options.platform_name = "Android"
options.device_name = "emulator-5554"
options.app = "/path/to/app.apk"

# Default is 60 seconds. A reCAPTCHA solve can outlast that.
options.new_command_timeout = 300

driver = webdriver.Remote("http://127.0.0.1:4723", options=options)

Diese Eigenschaft schreibt die Capability appium:newCommandTimeout, ein Treiber oder ein Client, der sie nicht unter einem freundlichen Namen anbietet, nimmt denselben Wert also über set_capability entgegen. Unter iOS heißt die Klasse XCUITestOptions, und die Capability ist identisch, denn jeder Treiber erbt diese eine vom gemeinsamen Basistreiber von Appium, statt eine eigene zu implementieren. Beachten Sie auch die Serveradresse: Appium 2 bedient den nackten Port, ohne Pfad dahinter.

Schritt 2: Ein natives Bild-Captcha lösen

Das ist die übliche Form in einer mobilen App: eine ImageView mit verzerrtem Text und darunter ein Textfeld. Appium macht einen Screenshot eines einzelnen Elements und gibt ihn als base64 zurück, was zufällig genau eine der drei Eingabeformen ist, die die Bild-Methode annimmt, nichts muss also die Festplatte berühren.

from appium.webdriver.common.appiumby import AppiumBy
from capskip import CapSkip

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

# Appium crops the element out of a device screenshot for you.
image = driver.find_element(AppiumBy.ID, "com.example.app:id/captcha_image")
result = solver.normal("data:image/png;base64," + image.screenshot_as_base64)

field = driver.find_element(AppiumBy.ID, "com.example.app:id/captcha_input")
field.send_keys(result["code"])

Der Schlüssel code enthält den ausgelesenen Text. Die Bild-Methode akzeptiert auch einen Dateipfad oder eine entfernte URL, wenn ein Schritt also bereits einen Screenshot gespeichert hat, können Sie stattdessen den Pfad übergeben, aber der base64-Weg vermeidet temporäre Dateien in einem Testlauf und lässt sich danach leichter aufräumen.

Zwei Dinge über diese Methode sollten Sie wissen, bevor Sie darum herum bauen. Sie hat keine Proxy-Unterstützung, was hier in Ordnung ist, weil das Bild Ihren Rechner nie verlässt. Und sie pollt gegen das Standard-Timeout von 120 Sekunden statt gegen das längere reCAPTCHA-Timeout, weil keine Browser-Session beteiligt ist.

Zielen Sie auf das Bildelement, nicht auf den Bildschirm. Ein Vollbild-Screenshot mit dem Captcha irgendwo darin gibt dem Solver eine Telefonoberfläche zu lesen, und die Antwort wird auf eine Weise falsch sein, die nach einer schlechten Lösung statt nach einem schlechten Zuschnitt aussieht. Wenn das Element, das Sie finden können, ein Container mit Innenabstand und einem Label darin ist, suchen Sie stattdessen die innere View, sonst kosten die zusätzlichen Pixel Sie Genauigkeit.

Schritt 3: Ein reCAPTCHA in einem WebView lösen

Die andere Form ist ein Login- oder Registrierungsbildschirm, der in Wahrheit eine Webseite in einem WebView ist. Hier gibt es kein Bild zu lesen, wechseln Sie also in den Web-Kontext und arbeiten Sie mit dem DOM genau so, wie Sie es in einem Browser tun würden.

# contexts looks like ['NATIVE_APP', 'WEBVIEW_com.example.app']
web = [c for c in driver.contexts if c.startswith("WEBVIEW")][0]
driver.switch_to.context(web)

# Narrow to g-recaptcha: hCaptcha also carries data-sitekey.
sitekey = driver.find_element(
    AppiumBy.CSS_SELECTOR,
    ".g-recaptcha[data-sitekey]").get_attribute("data-sitekey")

result = solver.recaptcha(sitekey=sitekey, url=driver.current_url)

driver.execute_script(
    "document.getElementById('g-recaptcha-response').value = arguments[0];",
    result["code"],
)
driver.switch_to.context("NATIVE_APP")

Prüfen Sie, was das Widget tatsächlich ist, bevor Sie die reCAPTCHA-Methode aufrufen. hCaptcha setzt ebenfalls ein data-sitekey auf sein Widget, und es ist kein unterstützter Typ, ein nackter Attributselektor reicht Ihnen also bereitwillig den falschen Key. Suchen Sie nach der Klasse g-recaptcha, oder schließen Sie hCaptcha aus, indem Sie auf eine Klasse h-captcha oder ein Script von js.hcaptcha.com prüfen. Ein WebView-Login ist ein häufiger Ort, an dem man eines antrifft. FunCaptcha und Arkose werden ebenfalls nicht unterstützt.

Lesen Sie die Seiten-URL aus dem Treiber aus, statt sie fest zu verdrahten. Ein WebView lädt oft eine URL mit einer Session oder einem Rückkehrpfad im Querystring, und die Lösung ist an die Seite gebunden, für die sie angefordert wurde, eine geratene URL erzeugt also einen Token, den die Seite ablehnt.

Das Ausfüllen des Response-Felds genügt bei einem Formular, das normal absendet. Es genügt nicht bei einer Seite, die darauf wartet, dass reCAPTCHA sie zurückruft, also bei der Anordnung, in der der Submit-Button am Callback des Widgets hängt und nicht am Formular. In diesem Fall muss der Callback ebenfalls aufgerufen werden, und das ist ein eigenes Problem und kein mobiles: die Seite zum Callback-Solver zeigt, worauf Sie achten müssen. Wechseln Sie zurück in den nativen Kontext, bevor Sie wieder native Buttons anfassen, sonst durchsucht der nächste find_element das DOM und schlägt fehl.

Zeigt die Kontextliste immer nur NATIVE_APP, ist das WebView nicht debuggbar. Unter Android ist das eine App-seitige Einstellung, die die Entwickler kontrollieren, es lohnt sich also, dort nachzufragen, bevor Sie Appium die Schuld geben.

Schritt 4: Wo der Solver läuft und welchen Verbindungsmodus das erfordert

Das ist der Teil, den man auf Mobilgeräten regelmäßig falsch herum versteht, deshalb hier ganz deutlich. Der Solver wird von Ihrem Python-Testcode aufgerufen. Das Telefon ruft ihn nicht auf, der Emulator ruft ihn nicht auf, und der Appium-Server auch nicht. Die einzige Frage ist also, wo Ihr Testprozess läuft, und das Netzwerk des Geräts selbst hat damit nichts zu tun.

Das heißt, der übliche Ratschlag zum Android-Emulator, wie er den Host-Rechner erreicht, ist hier irrelevant, und die Adresse eines entfernten Appium-Servers ebenso. Wichtig ist etwas Einfacheres: Läuft der Prozess, der Ihren Test ausführt, auf dem CapSkip-Rechner, ist Loopback richtig. Läuft er irgendwo anders, ist es das nicht, und die erste Lösung wirft eine NetworkException.

Wo der Testprozess läuftWelcher Verbindungsmodus
Ihr Laptop, mit geöffnetem CapSkipLocal-Modus. 127.0.0.1 ist hier wirklich richtig
Ihr Laptop, der einen entfernten Appium-Server oder eine Gerätecloud steuertWeiterhin Lokal-Modus. Nur der Treiberaufruf geht nach außen
Ein Build-Agent im selben NetzwerkServer-Modus, auf der privaten Adresse des CapSkip-Rechners
Ein gehosteter CI-Runner oder ein ContainerServer-Modus mit einer statischen öffentlichen IP und einer Firewallregel

Stellen Sie CapSkip auf den Server-Modus um, dann lauscht es auf Ihrer Netzwerkadresse oder öffentlichen IP statt auf Loopback, sodass all diese Varianten es über dieselbe HTTP-API erreichen. Eine statische öffentliche IP ist zu empfehlen, wenn die Route über das Internet läuft, zusammen mit einer Firewall-Regel, die nur die erwarteten Adressen zulässt. Der Server-Modus ändert, wo der Solver lauscht, und sonst nichts: Es bleibt Ihre Hardware, und es bleibt ohne Nutzungsgebühren. Lesen Sie Host und Port aus der Umgebung, damit eine Suite an beiden Orten läuft. Der Client liest weder CAPSKIP_HOST noch CAPSKIP_PORT von sich aus, übergeben Sie beide Werte also an den Konstruktor, wie es das vollständige Beispiel unten tut.

Vollständiges lauffähiges Beispiel

import os
from appium import webdriver
from appium.options.android import UiAutomator2Options
from appium.webdriver.common.appiumby import AppiumBy
from capskip import CapSkip, ApiException, NetworkException, TimeoutException

solver = CapSkip(
    host=os.environ.get("CAPSKIP_HOST", "127.0.0.1"),
    port=int(os.environ.get("CAPSKIP_PORT", 8080)),
)

options = UiAutomator2Options()
options.platform_name = "Android"
options.device_name = "emulator-5554"
options.app = "/path/to/app.apk"
options.new_command_timeout = 300      # must outlast the longest solve

driver = webdriver.Remote("http://127.0.0.1:4723", options=options)

try:
    image = driver.find_element(AppiumBy.ID, "com.example.app:id/captcha_image")
    result = solver.normal("data:image/png;base64," + image.screenshot_as_base64)

    driver.find_element(
        AppiumBy.ID, "com.example.app:id/captcha_input").send_keys(result["code"])
    driver.find_element(AppiumBy.ACCESSIBILITY_ID, "Submit").click()
except ApiException:
    print("the solver refused the image")
except NetworkException:
    print("solver unreachable: check the host and the connection mode")
except TimeoutException:
    print("no answer inside defaultTimeout")
finally:
    driver.quit()

Alle vier Exceptions leiten sich von CapSkipError ab, wer stattdessen diese eine fängt, behandelt also jeden Fehler, den das SDK werfen kann, in einem einzigen Block. Fangen Sie die spezifischen ab, wenn die Reaktion sich unterscheidet, wie oben, und CapSkipError, wenn nicht. Behalten Sie driver.quit in einem finally-Block: Ein Test, der mitten in einer Lösung stirbt, hinterlässt sonst eine Session, die das Gerät blockiert, bis der Leerlauf-Timer, den Sie gerade angehoben haben, endlich abläuft.

Die übrigen Typen funktionieren vom selben Client aus genauso. Turnstile nimmt einen Sitekey und eine Seiten-URL, GeeTest nimmt einen gt-Wert, eine Challenge und die Seiten-URL, und ALTCHA nimmt die Seiten-URL und einen Challenge-Endpunkt. Alle Methoden, die das Paket bereitstellt, finden Sie auf der Python-Captcha-Solver-Seite, und für den Bild-Typ gibt es eine eigene Seite.

Häufige Fehler und was sie bedeuten

Was Sie sehenUrsacheBeheben
Die Session ist nach einer langsamen Lösung weg, und jeder spätere Befehl schlägt fehlnewCommandTimeout ist abgelaufen, während Ihr Code auf den Solver gewartet hatHeben Sie ihn über die längste Lösung hinaus an, nicht nur über die durchschnittliche
Eine NetworkException beim ersten LösenCapSkip läuft nicht, oder der Testprozess liegt auf einem Build-Agent und zeigt auf LoopbackStarten Sie CapSkip und entscheiden Sie sich dann zwischen Lokal-Modus und Server-Modus
Eine TimeoutException, die bei einem Bild 120 Sekunden nenntDer Bild-Typ nutzt das Standard-Polling-Timeout, nicht das längere reCAPTCHA-TimeoutPrüfen Sie, ob der Solver läuft und nicht ausgelastet ist, bevor Sie irgendetwas anheben
Die Antwort ist jedes Mal falsch, obwohl das Bild klar istEin Vollbild-Screenshot oder ein Element, das Innenabstand und ein Label enthältMachen Sie einen Screenshot der innersten View, die nur das Captcha enthält
Eine ValidationException, die base64 oder eine fehlende Datei nenntDer Element-Screenshot kam leer zurück, der String war also zu kurz, um als Bild gelesen zu werdenPrüfen Sie, ob das Element vor dem Screenshot auf dem Bildschirm und sichtbar war und ob die Suche es tatsächlich getroffen hat
Die reCAPTCHA-Methode liefert einen Token, den die Seite jedes Mal ablehntDas Widget ist hCaptcha, das ebenfalls ein data-sitekey trägt und kein unterstützter Typ istGrenzen Sie den Selektor auf die Klasse g-recaptcha ein und bestätigen Sie, welches Widget die Seite lädt
Die Kontextliste enthält nur NATIVE_APPDas WebView ist nicht debuggbar, Appium kann sich also nicht daran hängenBitten Sie das App-Team, das WebView-Debugging in dem Build zu aktivieren, den Sie testen
find_element schlägt direkt nach einem WebView-Schritt fehlDer Treiber ist noch im Web-Kontext und durchsucht das DOMWechseln Sie zurück zu NATIVE_APP, bevor Sie native Elemente anfassen
Das reCAPTCHA-Feld ist gefüllt, aber der Button tut nichtsDie Seite wartet auf den Callback des Widgets, statt das Feld zu lesenRufen Sie den Callback ebenfalls auf oder senden Sie das Formular direkt ab
Ein Token, den der Solver geliefert hat, wird von der Seite abgelehntDie an den Solver übergebene Seiten-URL wurde geraten statt aus dem WebView ausgelesenÜbergeben Sie driver.current_url aus dem Web-Kontext heraus

FAQ

Muss das Telefon oder der Emulator den Solver erreichen?

Nein, und das ist die mit Abstand nützlichste Einsicht in diesen Aufbau. Der HTTP-Aufruf an CapSkip kommt von Ihrem Python-Prozess, das Gerät sieht also immer nur eine Screenshot-Anfrage und ein send_keys. Auf dem Telefon muss nichts installiert werden, kein Traffic der App wird umgeleitet, und die Host-Adresse des Emulators spielt nie eine Rolle. Ein echtes Gerät am Kabel, ein Emulator und ein Cloud-Gerät verhalten sich aus Sicht des Solvers völlig identisch.

Können Appium-Tests, die in der CI laufen, CapSkip nutzen?

Ja, über den Server-Modus. Ein gehosteter Runner sieht Ihre Loopback-Adresse nicht, stellen Sie CapSkip unter den Verbindungseinstellungen also so um, dass es auf Ihrer Netzwerkadresse oder öffentlichen IP lauscht, und richten Sie die Host-Umgebungsvariable darauf aus. Verwenden Sie eine statische öffentliche IP, wenn die Route über das Internet läuft, und schränken Sie sie mit einer Firewall-Regel ein. Der Solver bleibt in jedem Fall auf Hardware, die Ihnen gehört, an der Lizenz und an der Anzahl der Lösungen ändert sich also nichts, wenn die Tests Ihren Schreibtisch verlassen.

Gilt irgendetwas davon nur für Android?

Nein. Tauschen Sie UiAutomator2Options gegen XCUITestOptions, importiert stattdessen aus appium.options.ios, und die Form des Laufs ist identisch, denn Element-Screenshots, Kontextwechsel und der Leerlauf-Timer liegen alle oberhalb des Treibers. Nur die Locators ändern sich, da iOS keine Resource-IDs kennt: Verwenden Sie eine Accessibility-ID, wo die App eine setzt, und ein Predicate oder eine Class Chain, wo sie es nicht tut. Der Solver erfährt nie, von welcher Plattform ihm ein Bild gereicht wurde.

Soll ich das Captcha lösen oder es für Tests abschalten?

Schalten Sie es ab, wenn es Ihre App ist und Sie es können. Ein Test-Build, der die Prüfung überspringt, oder ein Testschlüssel des Anbieters, der immer durchgeht, ist schneller und deterministischer als jede Lösung und nimmt Ihrer Suite eine Abhängigkeit. Das Lösen verdient seinen Platz, wenn Sie den Bildschirm nicht kontrollieren: ein Login eines Drittanbieters mitten in Ihrem Ablauf, das Registrierungsformular eines Partners, eine Staging-Umgebung, die niemand für Sie ändert, oder ein Lauf in der Gerätecloud gegen die Produktion. Das sind die Fälle, in denen die Wahl zwischen einem Solver und einem Menschen steht.

Die Kurzfassung

Heben Sie newCommandTimeout an, bevor Sie irgendetwas anderes schreiben, denn die voreingestellten 60 Sekunden sind kürzer als das, was der Solver brauchen darf, und eine Session, die mitten im Test stirbt, sieht nach einem völlig anderen Bug aus. Machen Sie einen Screenshot des Elements statt des Bildschirms, übergeben Sie ihn als base64-Data-URI und tippen Sie das Ergebnis mit send_keys zurück. Für ein WebView wechseln Sie den Kontext, lesen Sitekey und aktuelle URL aus dem DOM, füllen das Response-Feld und wechseln dann zurück. Gehen Sie in den Server-Modus, sobald der Testprozess sich keinen Rechner mehr mit dem Solver teilt.

Noch eine letzte Sache, die prägt, wie Sie den erneuten Versuch schreiben. Weil dieser Captcha-Löser auf Hardware läuft, die Ihnen ohnehin schon gehört, kostet ein zweiter Versuch bei einem schlecht zugeschnittenen Bild nichts, ein Test kann es sich also leisten, einen saubereren Screenshot zu machen und es erneut zu versuchen, statt den Lauf scheitern zu lassen.