So lösen Sie ein Captcha in einer Temporal-Workflow-Activity

Eine Captcha-Lösung gehört in Temporal in eine Activity. Nicht in die Workflow-Methode, nicht in einen Helfer, den der Workflow aufruft, sondern in eine Activity. Workflow-Code wird bei jedem Fortsetzen des Workflows aus der History erneut abgespielt, er muss also deterministisch sein: keine Netzwerkaufrufe, keine Zufallswerte, keine Uhrzeitabfragen. Eine Lösung ist alle drei Dinge auf einmal. Sobald sie in einer Activity steckt, bleiben nur noch eine Retry-Policy und ein Stück Timing-Disziplin, und das Ganze umfasst etwa vierzig Zeilen.
Was Sie brauchen
- Python 3.10 oder neuer, das Temporal Python SDK und das CapSkip Python SDK.
- Ein Temporal Service, mit dem Sie sich verbinden. Ein lokaler Dev-Server oder Temporal Cloud funktionieren hier beide.
- Die Seiten-URL des geschützten Formulars und dessen sitekey.
- CapSkip im Local-Modus, wenn Worker und Löser sich eine Maschine teilen, oder im Server-Modus, wenn nicht. Beide werden beschrieben unter Verbindungseinstellungen.
# pip install temporalio pip install -U temporalio capskip httpx # A local service to develop against. temporal server start-dev
Warum die Lösung nicht im Workflow-Code stehen kann
Temporal spielt die History eines Workflows erneut ab, um dessen Zustand nach einem Worker-Neustart, einem Deploy oder einem wochenlangen Schlaf wieder aufzubauen. Damit das zweimal dieselbe Antwort ergibt, muss Workflow-Code deterministisch sein. Das SDK sagt deutlich, was damit ausgeschlossen ist: kein Netzwerk-IO, kein Threading, keine Zufallswerte, keine externen Aufrufe an Prozesse, keine Mutation globalen Zustands. Es führt Workflow-Code sogar in einer Sandbox aus, die Module pro Lauf neu importiert, und deshalb werden Activity-Importe in einen Durchreiche-Block eingepackt.
Eine Captcha-Lösung bricht die Regel gleich dreifach. Sie ist ein Netzwerkaufruf, der Token, den sie zurückgibt, ist bei jedem Versuch ein anderer, und wie lange sie dauert, hängt von der Maschine ab. Packen Sie sie in die Workflow-Methode, dann sieht es in der Entwicklung nach funktionierendem Code aus und liefert einen Non-Determinism-Fehler, sobald ein Worker zum ersten Mal mitten im Lauf neu startet.
Die gute Nachricht: Diese Einschränkung gibt Ihnen auch etwas. Eine Activity wird von Temporal selbst wiederholt, mit einer Policy, die Sie deklarieren, statt mit einer Schleife, die Sie schreiben, und ihr Ergebnis wird in der History festgehalten. Eine geglückte Lösung wird beim Replay also nie wiederholt, und genau das wollen Sie bei etwas, dessen Antwort nur einmal gilt.
Schritt 1: die Lösungs-Activity
Das CapSkip SDK für Python liefert einen echten asyncio-Client mit, keinen Alias, eine async-Activity passt also natürlich dazu und braucht keinen Thread-Pool. Nehmen Sie sitekey und Seiten-URL als Argumente entgegen und geben Sie den Token zurück.
# pip install capskip
import os
from temporalio import activity
from capskip import AsyncCapSkip
@activity.defn
async def solve_recaptcha(sitekey: str, page_url: str) -> str:
solver = AsyncCapSkip(
host=os.environ.get("CAPSKIP_HOST", "127.0.0.1"),
port=8080,
apiKey=os.environ.get("CAPSKIP_API_KEY", "capskip"),
)
result = await solver.recaptcha(sitekey=sitekey, url=page_url)
return result["code"]Eine Methode deckt reCAPTCHA v2, Invisible, Enterprise und v3 ab. Die Varianten sind Optionen desselben Aufrufs und keine eigenen Methoden: invisible auf 1, enterprise auf 1 oder version auf v3 mit einer action. Turnstile und GeeTest haben eigene Methoden mit derselben Form, und die vollständige Parameterliste steht in der CapSkip-API-Dokumentation.
Wenn Sie lieber den synchronen Client verwenden, muss die Activity ein einfaches def sein und der Worker braucht einen activity_executor, denn Temporal führt synchrone Activities in einem Thread-Pool aus. Die async-Fassung oben umgeht das vollständig, und das ist eine der wenigen Stellen, an denen das Python SDK wirklich angenehmer ist als die anderen.
Schritt 2: Timeouts, die länger sind als die des Lösers
Jede Activity braucht ein start_to_close_timeout, und genau hier zerlegen sich Leute unbemerkt ihre eigenen Lösungen. CapSkip fragt bei reCAPTCHA, Turnstile und GeeTest bis zu 300 Sekunden lang ab, bei Bild-Captchas 120 Sekunden. Setzen Sie das Activity-Timeout darunter, bricht Temporal den Versuch ab, während der Löser noch arbeitet, wiederholt ihn dann, und Sie haben zwei laufende Lösungen für ein Formular.
Geben Sie der Activity Luft über das eigene Limit des Lösers hinaus. Sechs Minuten gegen eine Löser-Obergrenze von fünf Minuten sind bequem.
# Longer than recaptchaTimeout, which defaults to 300s.
from datetime import timedelta
from temporalio.common import RetryPolicy
SOLVE_TIMEOUT = timedelta(minutes=6)
SOLVE_RETRIES = RetryPolicy(
initial_interval=timedelta(seconds=5),
backoff_coefficient=2.0,
maximum_attempts=4,
# These fail identically every time. Do not burn attempts.
non_retryable_error_types=["ValidationException", "ApiException"],
)Die Liste der nicht wiederholbaren Fehler wird über den Klassennamen der Exception abgeglichen, und es lohnt sich, sie zu füllen. ValidationException bedeutet ein fehlendes oder fehlerhaftes Argument, ApiException bedeutet, dass die API die Anfrage abgelehnt hat, meist wegen eines sitekey, der nicht zur Seiten-URL gehört. Keines von beiden wird beim zweiten Versuch besser. NetworkException und TimeoutException sind die beiden, die eine Wiederholung wirklich verdienen: Die erste heißt, der Löser läuft nicht oder der Host ist falsch, die zweite heißt, die Lösung hat das Polling-Timeout überdauert. Alle vier leiten sich von einer gemeinsamen Basis ab, ein catch auf CapSkipError funktioniert also, wenn Sie lieber alles an einer Stelle behandeln.
Schritt 3: zuletzt lösen, nicht zuerst
Durable Execution macht es hier leichter als in jedem anderen Orchestrator, den Ablauf eines Tokens zu übersehen. Ein Temporal-Workflow kann auf ein Signal warten, einen Tag schlafen und weitermachen, und seine aufgezeichneten Activity-Ergebnisse kommen unverändert aus der History zurück. Ein Workflow, der früh löst, auf eine Freigabe wartet und dann absendet, spielt also einen Token erneut ab, der gestern erzeugt wurde.
Ein reCAPTCHA-Token wird genau einmal akzeptiert und läuft nach etwa zwei Minuten ab. Ordnen Sie den Workflow so, dass die Lösung der Schritt direkt vor dem Absenden ist, ohne irgendetwas Blockierendes dazwischen. Die allgemeine Form dieses Problems behandelt der Leitfaden zur Gültigkeitsdauer von reCAPTCHA-Token.
Vollständiges lauffähiges Beispiel
Drei Activities und ein Workflow, der sie der Reihe nach aufruft. sitekey lesen, lösen, absenden. Jede Activity bekommt ein eigenes Timeout und eine eigene Retry-Policy, und jede taucht separat in der Temporal UI auf, wenn also etwas langsam ist, sehen Sie, welcher Schritt es war.
# activities.py
import os, re, httpx
from temporalio import activity
from capskip import AsyncCapSkip
@activity.defn
async def read_sitekey(page_url: str) -> str:
async with httpx.AsyncClient(timeout=30) as client:
html = (await client.get(page_url)).text
found = re.search(r'data-sitekey=["\']([^"\']+)', html)
if not found:
raise RuntimeError("No data-sitekey on the page.")
return found.group(1)
@activity.defn
async def solve_recaptcha(sitekey: str, page_url: str) -> str:
solver = AsyncCapSkip(host=os.environ.get("CAPSKIP_HOST", "127.0.0.1"))
return (await solver.recaptcha(sitekey=sitekey, url=page_url))["code"]
@activity.defn
async def submit_form(page_url: str, token: str) -> int:
async with httpx.AsyncClient(timeout=30) as client:
reply = await client.post(
page_url, data={"g-recaptcha-response": token}
)
return reply.status_codeDer Workflow selbst enthält keine Logik außer der Reihenfolge. Genau das ist der Sinn: Alles, was scheitern kann, steckt in einer Activity, und der Workflow ist der deterministische Teil, der einen Replay übersteht.
# workflow.py
from datetime import timedelta
from temporalio import workflow
from temporalio.common import RetryPolicy
with workflow.unsafe.imports_passed_through():
from activities import read_sitekey, solve_recaptcha, submit_form
@workflow.defn
class SubmitProtectedForm:
@workflow.run
async def run(self, page_url: str) -> int:
sitekey = await workflow.execute_activity(
read_sitekey, page_url,
start_to_close_timeout=timedelta(seconds=60),
)
# Solve directly before the submit. Tokens go stale.
token = await workflow.execute_activity(
solve_recaptcha, args=[sitekey, page_url],
start_to_close_timeout=timedelta(minutes=6),
retry_policy=RetryPolicy(
maximum_attempts=4,
non_retryable_error_types=["ValidationException"],
),
)
return await workflow.execute_activity(
submit_form, args=[page_url, token],
start_to_close_timeout=timedelta(seconds=60),
)Beachten Sie die args-Liste bei den Activities mit zwei Argumenten. Ein einzelnes positionelles Argument lässt sich direkt übergeben, mehr als eines muss über args laufen, und das falsch zu machen ist der häufigste erste Fehler in diesem SDK.
# worker.py - run this where CapSkip can be reached
import asyncio
from temporalio.client import Client
from temporalio.worker import Worker
from activities import read_sitekey, solve_recaptcha, submit_form
from workflow import SubmitProtectedForm
async def main():
client = await Client.connect("localhost:7233")
worker = Worker(
client,
task_queue="captcha-queue",
workflows=[SubmitProtectedForm],
activities=[read_sitekey, solve_recaptcha, submit_form],
)
await worker.run()
asyncio.run(main())Wo der Worker läuft und wo der Löser läuft
Temporal trennt beides sauber, und das arbeitet zu Ihren Gunsten. Der Temporal Service plant Arbeit ein und speichert die History. Er führt Ihren Code nie aus. Ein Worker, den Sie in Ihrer eigenen Infrastruktur starten, hält eine lange ausgehende Verbindung zum Service und nimmt Tasks aus einer Queue. Zu Ihrem Netzwerk wird nie etwas Eingehendes geöffnet.
Ein Worker auf derselben Windows-Maschine wie CapSkip ruft also 127.0.0.1:8080 genau so auf, wie es ein Skript auf Ihrem Schreibtisch täte, und das gilt in Temporal Cloud weiter. Die Cloud in Temporal Cloud ist die Orchestrierungsschicht, nicht die Rechenschicht.
Sobald der Worker umzieht, ändert sich der Host und sonst nichts. Ein Worker in einem Container, auf einer Linux-VM oder in Kubernetes erreicht einen Windows-Löser nicht über Loopback, also wechselt der Löser in den Server-Modus. Der Local-Modus bindet an 127.0.0.1 und antwortet nur diesem Gerät. Der Server-Modus bindet an Ihre Netzwerkadresse oder öffentliche IP, und eine statische öffentliche IP hält die Adresse stabil. In beiden Modi bleibt es Ihre Hardware und bleibt ohne Zähler, ein Workflow, der zehntausendmal am Tag läuft, kostet also genauso viel wie einer, der einmal läuft.
# One environment variable, no code change. # CAPSKIP_HOST=10.0.0.12 on the worker. solver = AsyncCapSkip(host=os.environ["CAPSKIP_HOST"], port=8080)
Schalten Sie die Schlüsselvalidierung ein, sobald der Löser auf einer Netzwerkadresse lauscht, und geben Sie jeder Worker-Flotte einen eigenen Schlüssel, damit einer widerrufen werden kann, ohne die anderen anzufassen. Beide Modi werden durchgegangen in der CapSkip-Einrichtungsanleitung.
Häufige Fehler und was sie bedeuten
| Was Sie sehen | Ursache | Beheben |
|---|---|---|
| Non-Determinism-Fehler beim Replay | Die Lösung wurde aus Workflow-Code aufgerufen | In eine Activity verschieben und mit execute_activity aufrufen |
| RestrictedWorkflowAccessError beim Import | Das Activity-Modul wurde in die Sandbox importiert | Innerhalb von workflow.unsafe.imports_passed_through importieren |
| Die Activity wird mitten in der Lösung abgebrochen und dann wiederholt | start_to_close_timeout ist kürzer als das Timeout des Lösers | Für reCAPTCHA, Turnstile und GeeTest über 300 Sekunden setzen |
| Das Formular lehnt ein Token ab, das korrekt aussieht | Der Workflow hat zwischen Lösen und Absenden blockiert | Die Lösung zum Schritt direkt vor dem Absenden machen |
| Vier Versuche für denselben Fehler verbrannt | Ein deterministischer Fehler wird wiederholt | ValidationException und ApiException als nicht wiederholbar eintragen |
| NetworkException bei jedem Versuch | Der Worker läuft nicht auf der Maschine mit dem Löser | Den Löser in den Server-Modus schalten und den Host setzen |
| TypeError zu den Argumenten einer Activity | Zwei positionelle Argumente wurden direkt übergeben | Sie als Liste über den Parameter args übergeben |
| TimeoutException aus dem SDK | Die Lösung hat recaptchaTimeout überdauert | Erhöhen Sie ihn über den Standardwert von 300 Sekunden |
FAQ
Kann ein Workflow in Temporal Cloud wirklich 127.0.0.1 aufrufen?
Ja, denn Temporal Cloud führt Ihren Code nicht aus. Das tut Ihr Worker, wo immer Sie ihn gestartet haben, und er verbindet sich nach außen zum Service. Loopback auf diesem Worker meint die Maschine des Workers selbst, ein Löser auf dieser Maschine antwortet also normal. Daran ändert sich nichts, wenn Sie von einem Dev-Server auf Cloud wechseln.
Sollte die Lösungs-Activity Heartbeats senden?
Sinnvoll geht das nicht, denn der SDK-Aufruf blockiert, bis der Token eintrifft, und es gibt darin keinen Punkt, von dem aus sich melden ließe. Geben Sie der Activity stattdessen ein start_to_close_timeout mit echtem Spielraum und lassen Sie einen gescheiterten Versuch von der Policy wiederholen. Heartbeats sind für Activities gedacht, die über Arbeit iterieren, die Sie selbst steuern.
Wie löse ich einen Batch, ohne den Löser zu überfluten?
Setzen Sie max_concurrent_activities am Worker, oder geben Sie dem Lösen eine eigene Task-Queue und einen eigenen Worker mit niedriger Obergrenze. Das drosselt an der Stelle, an der die Arbeit ausgeführt wird, und das ist zuverlässiger, als die Workflow-Starts zeitlich zu strecken. Innerhalb eines einzelnen Prozesses verteilt der async-Client die Arbeit mit asyncio, und das wird behandelt in der Anleitung zum parallelen Lösen von Captchas.
Worin unterscheidet sich das davon, es in Airflow zu tun?
Airflow ist ein Scheduler mit einem DAG, und nichts hindert Sie daran, einen Netzwerkaufruf zu machen, während der Graph gebaut wird, was zu ganz anderen Fußangeln führt. Temporal ist Durable Execution, die Einschränkung heißt hier also Determinismus und die Antwort ist immer eine Activity. Die Löserseite ist in beiden identisch, und die Airflow-Fassung ist beschrieben in der Airflow-Captcha-Anleitung.
Die Kurzfassung
Legen Sie die Lösung in eine Activity, nie in die Workflow-Methode. Geben Sie ihr ein start_to_close_timeout, das länger ist als die 300-Sekunden-Obergrenze des Lösers, markieren Sie ValidationException und ApiException als nicht wiederholbar, und ordnen Sie den Workflow so, dass die Lösung das Letzte vor dem Absenden ist. Lassen Sie den Worker dort laufen, wo CapSkip ist, dann bleibt der Host 127.0.0.1. Der Rest der Python-Oberfläche steht auf der Python-Captcha-Solver-Seite, und dieselben drei Aufrufe gibt es in Node.js, PHP und C#, aufgeführt auf Die Seite zu den SDKs fürs Captcha-Lösen. Die reCAPTCHA-Optionen selbst stehen auf die reCAPTCHA-v2-Solver-Seite.
Eines sollten Sie wissen, bevor Sie einen Zeitplan darauf richten. CapSkip ist ein Captcha-Löser und läuft auf Hardware, die Ihnen bereits gehört, ein Workflow, der jede Minute feuert, kostet also genauso viel wie einer, der einmal pro Woche feuert.
