"""SolveHub Python client for authorized CAPTCHA tasks. Version: 1.0.0 Requires: Python 3.10+ (standard library only, no third-party packages) Docs: https://www.tiam.codes/integrations/python Environment: SOLVEHUB_API_KEY required; key with the `tasks` scope SOLVEHUB_TASK_FILE JSON file holding {"type": ..., "task": {...}} SOLVEHUB_IDEMPOTENCY_KEY 8-128 bytes; keep it stable for one logical task SOLVEHUB_TASK_ID set this to resume polling an existing task SOLVEHUB_URL API origin, default https://www.tiam.codes Behaviour: * 20 s timeout per request, 240 s overall budget, 3 bounded attempts * honours Retry-After (seconds or HTTP-date) on 429/500/502/503/504 * refuses HTTP redirects, so the key is never sent to another origin * writes taskId to stderr as soon as it is known; stdout gets the solution only, so a completed task prints exactly one JSON value Exit codes: 0 when a solution was printed, 1 with a message on stderr. """ CLIENT_VERSION = '1.0.0' import json import os import sys import time from urllib.error import HTTPError, URLError from urllib.parse import quote, urlsplit from urllib.request import Request, urlopen, build_opener, HTTPRedirectHandler class NoRedirect(HTTPRedirectHandler): def redirect_request(self, req, fp, code, msg, headers, newurl): return None # Never forward the API Key to another origin. class PendingTaskError(RuntimeError): pass def run_task(payload=None, task_id=None): base = os.environ.get('SOLVEHUB_URL', 'https://www.tiam.codes').rstrip('/') parsed = urlsplit(base) if parsed.scheme not in ('http', 'https') or not parsed.netloc or parsed.username or parsed.password or parsed.query or parsed.fragment or parsed.path: raise ValueError('SOLVEHUB_URL must be an HTTP(S) origin without credentials') api_key = os.environ['SOLVEHUB_API_KEY'] deadline = time.monotonic() + 240 opener = build_opener(NoRedirect) key = os.environ.get('SOLVEHUB_IDEMPOTENCY_KEY', '') if not task_id and not 8 <= len(key.encode()) <= 128: raise ValueError('Persist an 8–128 byte SOLVEHUB_IDEMPOTENCY_KEY before creating a task') body = json.dumps(payload, separators=(',', ':')).encode() if payload is not None else None def request(path, data=None): for attempt in range(3): remaining = deadline - time.monotonic() if remaining <= 0: raise PendingTaskError('Client deadline reached; retain taskId or original request and idempotency key') headers = {'Authorization': 'Bearer ' + api_key, 'Accept': 'application/json'} if data is not None: headers.update({'Content-Type': 'application/json', 'Idempotency-Key': key}) retry_after = 2 * (attempt + 1) try: with opener.open(Request(base + path, data=data, headers=headers), timeout=min(20, remaining)) as response: return json.load(response) except HTTPError as exc: if exc.code not in (429, 500, 502, 503, 504): raise RuntimeError('HTTP %s; check the API error and X-Request-ID locally' % exc.code) from None value = exc.headers.get('Retry-After', '') if value.isdigit(): retry_after = max(retry_after, int(value)) elif value: from email.utils import parsedate_to_datetime try: retry_after = max(retry_after, parsedate_to_datetime(value).timestamp() - time.time()) except (ValueError, TypeError, OverflowError): pass except (URLError, TimeoutError, ConnectionError): pass if attempt == 2 or retry_after >= deadline - time.monotonic(): raise PendingTaskError('Retry budget exhausted; retain taskId or original request and idempotency key') time.sleep(retry_after) if not task_id: created = request('/v1/tasks', body) task_id = created['taskId'] print('taskId=' + task_id, file=sys.stderr) # Persist this to resume; not a new POST. while time.monotonic() < deadline: result = request('/v1/tasks/' + quote(task_id, safe='')) status = result['status'] if status == 'completed': return result['solution'] if status in ('failed', 'cancelled'): raise RuntimeError('Task %s: %s' % (task_id, status)) if status not in ('queued', 'dispatching', 'processing', 'uncertain'): raise RuntimeError('Unknown task status; retain taskId=' + task_id) time.sleep(min(3, max(0, deadline - time.monotonic()))) raise PendingTaskError('Still pending; resume with SOLVEHUB_TASK_ID=' + task_id) if __name__ == '__main__': try: task_id = os.environ.get('SOLVEHUB_TASK_ID') payload = None if not task_id: with open(os.environ['SOLVEHUB_TASK_FILE'], encoding='utf-8') as source: payload = json.load(source) print(json.dumps(run_task(payload, task_id), ensure_ascii=False)) except (OSError, ValueError, KeyError, RuntimeError) as exc: print(str(exc), file=sys.stderr) sys.exit(1)