Rust로 reCAPTCHA Enterprise 해결하기

작성: solvethree읽는 데 3분

Rust에서 solvethree의 REST API를 사용하세요. 작업을 생성하고 준비될 때까지 폴링한 다음, 반환된 브라우저 식별 정보와 동일한 프록시를 사용해 대상에 solution.token을 제출합니다. 아래 예제는 공식 Rust SDK가 아닌 reqwest, Tokio, serde_json을 직접 사용합니다.

이 페이지의 내용

사이트 및 요청 구성

먼저 워크스페이스 콘솔에서 대상 호스트 이름의 활성 프로필을 생성하세요. 호스트 이름과 액션을 전송해도 프로필이 생성되지는 않습니다. 대상이 아직 구성되지 않았다면 사이트 프로필 설정을 참고하세요.

Rust 클라이언트를 실행할 서버에 다음 환경 변수를 설정하세요.

  • SOLVETHREE_API_KEY: 워크스페이스 API 키입니다. 서버 측에 보관하세요.
  • SOLVETHREE_PROXY: 풀이와 이후 대상 요청에 사용할 프록시 URL입니다.
  • REQUEST_ID: 이 논리적 작업에 사용할 선택적 고정 ID입니다. 연결이 중단된 뒤 복구할 수 있도록 요청을 보내기 전에 저장하세요.
reCAPTCHA 작업에 사용되는 필드
reCAPTCHA 작업에 사용되는 필드
필드전송할 값
site대상 호스트 이름입니다. 예제의 accounts.example.com을 바꾸세요.
action대상에서 요구하는 액션입니다. 필요한 경우 login을 바꾸세요.
proxySOLVETHREE_PROXY에서 읽은 단일 프록시 URL입니다.
requestIdASCII 영문자, 숫자, 밑줄 또는 하이픈 1–64자로 구성된 멱등성 키입니다.
recaptchaType기본값이 enterprise-v3이므로 여기서는 생략합니다.

이 reCAPTCHA 요청에 type 또는 url 필드를 추가하지 마세요. proxy와 proxyCandidates 중 정확히 하나가 필요합니다. 예제에서는 대상에 전달할 때도 동일한 연결을 사용할 수 있도록 proxy를 사용합니다.

Rust 클라이언트 생성

Cargo 바이너리 프로젝트의 Cargo.toml에 의존성 파일을, src/main.rs에 클라이언트를 배치하세요. 환경 변수를 설정하고 대상 호스트 이름과 액션을 변경한 다음 Cargo로 프로젝트를 실행하세요. 의존성에서는 reqwest를 버전 0.13.5로 고정합니다.

Cargo.toml · 검증된 의존성
[package]
name = "solvethree-example"
version = "0.1.0"
edition = "2021"

[dependencies]
reqwest = { version = "=0.13.5", features = ["json"] }
serde_json = "1"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] }
Rust · 제한된 비동기 REST 호출
use std::{env, error::Error, time::{Duration, SystemTime, UNIX_EPOCH}};
use reqwest::{Client, redirect::Policy};
use serde_json::{json, Value};

#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
    let api = "https://api.solvethree.com/v1";
    let key = env::var("SOLVETHREE_API_KEY")?;
    let request_id = env::var("REQUEST_ID").unwrap_or_else(|_| format!("rust-{}", SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos()));
    let input = json!({"site":"accounts.example.com", "action":"login", "proxy":env::var("SOLVETHREE_PROXY")?, "requestId":request_id});
    let client = Client::builder().redirect(Policy::none()).timeout(Duration::from_secs(30)).build()?;
    let mut task_id: Option<String> = None;
    let outcome = tokio::time::timeout(Duration::from_secs(180), async {
        let response = client.post(format!("{api}/tasks?waitMs=0")).bearer_auth(&key).json(&input).send().await?;
        if !response.status().is_success() { return Err(format!("API HTTP {}", response.status()).into()); }
        let mut result: Value = response.json().await?;
        let id = result["id"].as_str().ok_or("Missing task ID")?.to_owned();
        if !id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-') { return Err("Invalid task ID".into()); }
        task_id = Some(id.clone());
        loop {
            match result["status"].as_str() {
                Some("ready") => return Ok::<Value, Box<dyn Error>>(result),
                Some("failed") => return Err(format!("Task failed: {}", result["errorCode"]).into()),
                Some("pending") | Some("solving") => {},
                _ => return Err("Unexpected task status".into()),
            }
            tokio::time::sleep(Duration::from_secs(1)).await;
            let response = client.get(format!("{api}/tasks/{id}?waitMs=15000")).bearer_auth(&key).send().await?;
            if !response.status().is_success() { return Err(format!("API HTTP {}", response.status()).into()); }
            result = response.json().await?;
        }
    }).await;
    match outcome {
        Ok(Ok(result)) => {
            println!("Ready task: {}", result["id"].as_str().unwrap_or("unknown"));
            // Submit result["solution"]["token"] with returned browser headers
            // through the original proxy; do not log the solution.
            Ok(())
        },
        Ok(Err(error)) => {
            eprintln!("Recovery requestId={request_id} taskId={task_id:?}");
            Err(error)
        },
        Err(_) => {
            eprintln!("Recovery requestId={request_id} taskId={task_id:?}");
            Err("Local deadline reached; recover the existing task".into())
        }
    }
}

클라이언트는 Bearer 토큰으로 인증하여 POST /v1/tasks에 생성 요청을 한 번 보냅니다. 반환된 작업 ID를 보관하고 이후 상태 요청에는 GET /v1/tasks/:id를 사용합니다.

클라이언트가 무기한 대기하지 않도록 두 가지 제한을 적용합니다. reqwest의 시간 제한은 각 HTTP 요청을 30초로 제한하고, Tokio의 시간 제한은 생성과 폴링 전체를 180초로 제한합니다. 이는 클라이언트 측에서 정한 값이며 서비스 완료를 보장하는 시간이 아닙니다. 리디렉션은 reqwest의 ClientBuilder를 통해 비활성화됩니다.

pending과 solving 상태에서만 폴링 루프가 계속됩니다. ready는 결과를 반환하고, failed는 오류와 함께 중단하며, 예상하지 못한 상태에서도 무한히 폴링하지 않고 중단합니다. 성공 경로에서는 토큰이 아닌 작업 ID를 출력합니다.

새 풀이 없이 중단된 요청 복구

예제는 requestId와 반환된 작업 ID를 메모리에 보관하고, HTTP, 전송, 파싱 또는 기한 초과 오류가 발생하면 출력합니다. 프로세스가 종료되어도 유지되도록 영구 저장하지는 않습니다. 재시작 후 복구하려면 생성 전에 요청 ID와 원래 작업 입력값을 저장하고, 작업 ID가 도착하는 즉시 저장하세요.

  1. 1.

    작업 ID가 있다면 해당 작업 조회

    다른 작업을 생성하지 말고 GET /v1/tasks/:id로 재개하세요. 샘플의 기본 진입점은 항상 작업을 생성하므로, 재시작 경로에서는 저장된 ID를 사용해야 합니다.

  2. 2.

    요청 ID만 있다면 생성 복구

    동일한 requestId로 동일한 작업 요청을 제출하세요. 기존 작업이 반환됩니다. ID를 재사용하면서 입력값을 변경하면 충돌이 발생합니다.

  3. 3.

    복구된 상태 처리

    pending 또는 solving 상태의 작업은 폴링을 계속하세요. ready 상태의 솔루션은 사용하고, failed 상태이면 중단한 뒤 errorCode와 error를 확인하세요. 로컬 시간 초과만으로 원격 작업의 실패가 확정되지는 않습니다.

HTTP 실패와 작업 실패는 다르게 처리해야 합니다. 클라이언트는 성공 페이로드를 파싱하기 전에 HTTP 응답이 성공인지 확인합니다. 반면 작업의 최종 실패는 작업 상태 failed로 전달되며, 실패한 풀이는 환불됩니다. 샘플의 실패 메시지에는 errorCode만 포함되므로, 진단할 때 작업 응답의 error도 확인하세요. 입력값을 수정하지 않고 재시도하기 전에 API 오류 처리를 참고하세요.

브라우저 식별 정보와 함께 토큰 제출

예제는 작업이 준비되면 중단하며, 대상에 애플리케이션 요청을 보내지는 않습니다. 해당 요청에는 작업 ID가 아닌 solution.token이 필요합니다. 원래 프록시를 통해 반환된 userAgent와 secChUa 식별 정보를 사용하고, 다른 브라우저 세션의 값을 섞지 말고 의도한 대상에 제출하세요.

작업이 준비되었다는 것은 솔루션이 생성되었다는 뜻이지, 대상이 애플리케이션 요청을 수락했다는 뜻은 아닙니다. 성공 경로에 표시된 주석 위치에 대상별 제출 코드를 추가하세요. 토큰 전달 방법은 reCAPTCHA Enterprise 통합 가이드를 참고하세요.

첫 작업을 실행하세요.

워크스페이스 크레딧 $5로 시작하세요.

워크스페이스 만들기
Rust로 reCAPTCHA Enterprise 해결하기 · solvethree