First live run reset one account, then failed on the second: the confirm button never enabled and the step reported it covered by a bare `div`. reCAPTCHA adds a client to ___grecaptcha_cfg.clients per rendered widget and removes none, so the second modal leaves two — and the callback walk fired the first it found, the one belonging to the modal that already closed. The token goes to a widget nobody is listening to, the live modal never hears it was solved, and its button stays dark. Iteration 1 worked only because there was one client to find. Now the newest wins: hook widgets are walked in reverse, clients sorted by descending key, and the count of candidates is reported so `2 widgets on the page, used the newest` says plainly when a page is stacking stale ones. Also from that run: - coveredBy printed `div` and nothing else, which diagnoses nothing. It only ever reported tag and id, and modal backdrops and captcha overlays carry neither. It now names classes too. - A covered target gets 8s to clear before the step fails. Most things over a button are on their way out — a backdrop mid-fade, an overlay closing. This does not weaken the check: still covered at the deadline still fails, and nothing is clicked while anything is on top of it. - The reset list's scroll container is confirmed as a class, so the class-or-id hedge is trimmed. The run reported it as `dashboard_body MuiBox-root mui-0`. Extension to 0.11.1 so the dashboard can confirm the reload took. Runner VERSION deliberately unchanged — the step vocabulary is the same, and a bump would raise the mismatch banner for a change that does not need it. The runner does still need restarting to pick up the covered-retry. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
164 lines
6.5 KiB
Python
164 lines
6.5 KiB
Python
"""Solving a page's captcha through the 2captcha API.
|
|
|
|
The old flow clicked the 2captcha browser extension's own "solve" button and
|
|
waited for it to turn green. This replaces it: read the widget's parameters out
|
|
of the page, ask 2captcha for a token, put the token back into the page.
|
|
|
|
Three round trips, all through the dashboard:
|
|
|
|
runner --locate captchaInfo--> extension reads the widget's sitekey
|
|
runner --/api/autobuyer/captcha-> dashboard asks 2captcha, polls, returns a token
|
|
runner --locate captchaToken--> extension fills the field and fires the callback
|
|
|
|
The API key lives on the dashboard, not here — see app/api/autobuyer/captcha.
|
|
"""
|
|
|
|
import time
|
|
|
|
import actions
|
|
from clicker import DashboardError
|
|
|
|
# 2captcha's own guidance: nothing is ready sooner than this, so the first poll
|
|
# is a wasted round trip if it goes out immediately.
|
|
FIRST_POLL_SECONDS = 10.0
|
|
POLL_SECONDS = 5.0
|
|
|
|
# A "task expired"/"unsolvable" verdict means this attempt is dead, not that the
|
|
# page is unsolvable — a fresh task usually goes through. Bounded so a site that
|
|
# genuinely cannot be solved fails the run instead of burning the balance.
|
|
MAX_ATTEMPTS = 3
|
|
|
|
# Timeouts on the two page ops. Reading the widget is a plain injection; posting
|
|
# the token runs the page's own callback, which can kick off a network request.
|
|
PAGE_TIMEOUT = 30.0
|
|
|
|
|
|
def _page_op(dash, step, opts, options: dict, timeout: float) -> dict:
|
|
"""Run one captcha op in the page. The selector is unused by these ops, but
|
|
the locate endpoint requires one, so `body` stands in for it."""
|
|
return dash.locate(
|
|
"body", 0,
|
|
step.get("urlPattern", "") or opts.url,
|
|
timeout,
|
|
step.get("openUrl", "") or "",
|
|
options=options,
|
|
)
|
|
|
|
|
|
def read_widget(dash, step, opts, provider: str, deadline: float, report) -> dict:
|
|
"""Wait for a captcha to exist on the page, then describe it.
|
|
|
|
Polls rather than reading once: the step that opens the modal returns as soon
|
|
as the click lands, and the widget's script still has to load and render.
|
|
"""
|
|
announced = False
|
|
last = "no captcha widget found"
|
|
|
|
while True:
|
|
try:
|
|
return _page_op(dash, step, opts, {
|
|
"op": "captchaInfo",
|
|
"provider": provider,
|
|
"scopeSelector": step.get("selector", "") or "",
|
|
}, PAGE_TIMEOUT)
|
|
except DashboardError as exc:
|
|
# Includes "no captcha widget on the page yet", which is the normal
|
|
# answer while the widget is still loading.
|
|
last = str(exc)
|
|
|
|
if time.time() >= deadline:
|
|
raise actions.StepError(f"no captcha to solve: {last}")
|
|
if not announced:
|
|
report("waiting for the captcha widget to render")
|
|
announced = True
|
|
time.sleep(2.0)
|
|
|
|
|
|
def _await_token(dash, task_id, deadline: float, report, cancelled) -> str | None:
|
|
"""Poll one 2captcha task. Returns the token, None if the task failed and a
|
|
retry is worth it, or raises when the run is out of time."""
|
|
time.sleep(min(FIRST_POLL_SECONDS, max(0.0, deadline - time.time())))
|
|
|
|
while True:
|
|
if cancelled and cancelled():
|
|
raise actions.StepError("cancelled while waiting for the captcha")
|
|
|
|
try:
|
|
row = dash._request(f"/api/autobuyer/captcha?taskId={task_id}")
|
|
except DashboardError as exc:
|
|
# A blip talking to our own dashboard should not throw away a task
|
|
# that 2captcha is already working on.
|
|
report(f"(poll failed, retrying: {exc})")
|
|
row = {"status": "processing"}
|
|
|
|
if row.get("status") == "ready":
|
|
return row["token"]
|
|
if row.get("status") == "failed":
|
|
report(f"2captcha gave up on task {task_id}: {row.get('error')}")
|
|
return None
|
|
|
|
if time.time() >= deadline:
|
|
raise actions.StepError(f"2captcha did not return a token in time (task {task_id})")
|
|
time.sleep(POLL_SECONDS)
|
|
|
|
|
|
def solve(dash, step, opts, report, cancelled=None) -> None:
|
|
"""Run one `solveCaptcha` step. Raises StepError if the page is never solved."""
|
|
deadline = time.time() + float(step.get("timeoutSeconds", 180))
|
|
provider = step.get("provider") or "auto"
|
|
|
|
info = read_widget(dash, step, opts, provider, deadline, report)
|
|
sitekey = info.get("sitekey") or ""
|
|
report(f"{info.get('provider')} widget, sitekey {sitekey[:16]}… (via {info.get('source')})")
|
|
|
|
started = time.time()
|
|
token = None
|
|
for attempt in range(1, MAX_ATTEMPTS + 1):
|
|
if time.time() >= deadline:
|
|
break
|
|
|
|
queued = dash._request("/api/autobuyer/captcha", "POST", {
|
|
"provider": info.get("provider"),
|
|
"sitekey": sitekey,
|
|
# The page's own URL, not the firm's configured one: 2captcha scores
|
|
# the token against the page it was asked for.
|
|
"pageurl": info.get("pageurl"),
|
|
"action": info.get("action") or "",
|
|
"data": info.get("data") or "",
|
|
"pagedata": info.get("pagedata") or "",
|
|
"invisible": bool(info.get("invisible")),
|
|
"enterprise": bool(info.get("enterprise")),
|
|
# The browser that will submit the form, so a proxied solve can claim
|
|
# the same one rather than 2captcha's default.
|
|
"userAgent": info.get("userAgent") or "",
|
|
})
|
|
task_id = queued.get("taskId")
|
|
report(f"2captcha task {task_id} queued ({queued.get('type')} via {queued.get('via')})"
|
|
+ (f", attempt {attempt}" if attempt > 1 else ""))
|
|
|
|
token = _await_token(dash, task_id, deadline, report, cancelled)
|
|
if token:
|
|
break
|
|
|
|
if not token:
|
|
raise actions.StepError("2captcha could not solve the captcha")
|
|
|
|
report(f"token in hand after {time.time() - started:.0f}s ({len(token)} chars)")
|
|
|
|
delivered = _page_op(dash, step, opts, {
|
|
"op": "captchaToken",
|
|
"provider": info.get("provider"),
|
|
"token": token,
|
|
}, PAGE_TIMEOUT)
|
|
|
|
called = delivered.get("called")
|
|
seen = delivered.get("candidates") or 0
|
|
report(f"filled {delivered.get('filled', 0)} response field(s)"
|
|
+ (f", fired {called}" if called else "")
|
|
+ (f" — {seen} widgets on the page, used the newest" if seen > 1 else ""))
|
|
if not called:
|
|
# The token is in the form, so a plain submit still works — but a page
|
|
# that gates its submit button on the callback will look unsolved, and
|
|
# that is worth saying out loud rather than failing three steps later.
|
|
report("no captcha callback found — if the next step fails, that is why")
|