342 lines
10 KiB
Go
342 lines
10 KiB
Go
package captcha
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const hcaptchaSolverAPI = "https://hcaptchasolver.com/api"
|
|
|
|
const defaultUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36"
|
|
|
|
// candidateURLs to try for hCaptcha solving — b.stripecdn.com confirmed working by hcaptchasolver support.
|
|
var candidateURLs = []string{
|
|
"https://b.stripecdn.com",
|
|
"https://newassets.hcaptcha.com",
|
|
}
|
|
|
|
// Solver handles hCaptcha challenge solving via hcaptchasolver.com.
|
|
type Solver struct {
|
|
apiKey string // hcaptchasolver.com API key
|
|
proxy string
|
|
client *http.Client
|
|
StatusFn func(format string, args ...interface{}) // progress callback
|
|
}
|
|
|
|
// NewSolver creates a captcha solver.
|
|
func NewSolver(apiKey, proxy string) *Solver {
|
|
return &Solver{
|
|
apiKey: apiKey,
|
|
proxy: proxy,
|
|
client: &http.Client{Timeout: 180 * time.Second},
|
|
}
|
|
}
|
|
|
|
// SetStatusFn sets the status callback for printing progress to terminal.
|
|
func (s *Solver) SetStatusFn(fn func(format string, args ...interface{})) {
|
|
s.StatusFn = fn
|
|
}
|
|
|
|
// SetProxy updates the proxy URL.
|
|
func (s *Solver) SetProxy(proxy string) {
|
|
s.proxy = proxy
|
|
}
|
|
|
|
// statusf prints progress if StatusFn is set.
|
|
func (s *Solver) statusf(format string, args ...interface{}) {
|
|
if s.StatusFn != nil {
|
|
s.StatusFn(format, args...)
|
|
}
|
|
}
|
|
|
|
// platform represents a captcha solving service.
|
|
type platform struct {
|
|
name string
|
|
apiBase string
|
|
apiKey string
|
|
}
|
|
|
|
// platforms returns the list of solving platforms.
|
|
func (s *Solver) platforms() []platform {
|
|
var ps []platform
|
|
if s.apiKey != "" {
|
|
ps = append(ps, platform{name: "hcaptchasolver", apiBase: hcaptchaSolverAPI, apiKey: s.apiKey})
|
|
}
|
|
return ps
|
|
}
|
|
|
|
// SolveHCaptcha solves an hCaptcha challenge and returns the token + ekey.
|
|
// Uses b.stripecdn.com as the primary websiteURL (confirmed working).
|
|
func (s *Solver) SolveHCaptcha(ctx context.Context, siteKey, siteURL, rqdata string) (token, ekey string, err error) {
|
|
// Build list of URLs to try: b.stripecdn.com first (confirmed working), then user-provided, then candidates
|
|
urls := []string{}
|
|
for _, u := range candidateURLs {
|
|
urls = append(urls, u)
|
|
}
|
|
// Add user-provided URL if not already in list
|
|
if siteURL != "" {
|
|
found := false
|
|
for _, u := range urls {
|
|
if u == siteURL {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
urls = append(urls, siteURL)
|
|
}
|
|
}
|
|
|
|
platforms := s.platforms()
|
|
if len(platforms) == 0 {
|
|
return "", "", fmt.Errorf("no captcha solving platforms configured")
|
|
}
|
|
|
|
var lastErr error
|
|
for _, p := range platforms {
|
|
s.statusf(" → [%s] 正在解 hCaptcha...", p.name)
|
|
log.Printf("[captcha] [%s] trying...", p.name)
|
|
for i, tryURL := range urls {
|
|
log.Printf("[captcha] [%s] websiteURL %d/%d: %s", p.name, i+1, len(urls), tryURL)
|
|
|
|
token, ekey, err = s.solveWithRetry(ctx, siteKey, tryURL, rqdata, p)
|
|
if err == nil {
|
|
ekeyPreview := ekey
|
|
if len(ekeyPreview) > 16 {
|
|
ekeyPreview = ekeyPreview[:16] + "..."
|
|
}
|
|
s.statusf(" ✓ [%s] 解题成功 (token长度=%d, ekey=%s)", p.name, len(token), ekeyPreview)
|
|
log.Printf("[captcha] [%s] success!", p.name)
|
|
return token, ekey, nil
|
|
}
|
|
s.statusf(" ⚠ [%s] 失败: %v", p.name, err)
|
|
log.Printf("[captcha] [%s] failed: %v", p.name, err)
|
|
lastErr = err
|
|
|
|
if !strings.Contains(err.Error(), "UNSOLVABLE") &&
|
|
!strings.Contains(err.Error(), "CAPTCHA_FAILED") &&
|
|
!strings.Contains(err.Error(), "Bad Proxy") {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
return "", "", fmt.Errorf("captcha failed on all %d platforms: %w", len(platforms), lastErr)
|
|
}
|
|
|
|
// SolveInvisibleHCaptcha solves an invisible (passive) hCaptcha challenge.
|
|
func (s *Solver) SolveInvisibleHCaptcha(ctx context.Context, siteKey, siteURL string) (token, ekey string, err error) {
|
|
log.Printf("[captcha] solving invisible hCaptcha: site_key=%s, url=%s", siteKey, siteURL)
|
|
|
|
platforms := s.platforms()
|
|
if len(platforms) == 0 {
|
|
return "", "", fmt.Errorf("no captcha solving platforms configured")
|
|
}
|
|
|
|
for _, p := range platforms {
|
|
s.statusf(" → [%s] 正在解 invisible hCaptcha...", p.name)
|
|
// Each platform gets 120s timeout (hcaptchasolver needs up to 2 min)
|
|
platformCtx, platformCancel := context.WithTimeout(ctx, 120*time.Second)
|
|
token, ekey, err = s.solveOnce(platformCtx, siteKey, siteURL, "", p, true)
|
|
platformCancel()
|
|
if err == nil {
|
|
s.statusf(" ✓ [%s] invisible 解题成功 (token长度=%d)", p.name, len(token))
|
|
log.Printf("[captcha] invisible hCaptcha solved via %s", p.name)
|
|
return token, ekey, nil
|
|
}
|
|
s.statusf(" ⚠ [%s] invisible 失败: %v", p.name, err)
|
|
log.Printf("[captcha] invisible hCaptcha failed on %s: %v", p.name, err)
|
|
}
|
|
return "", "", fmt.Errorf("invisible hCaptcha failed on all platforms: %w", err)
|
|
}
|
|
|
|
// solveWithRetry tries solving once with retry on UNSOLVABLE.
|
|
func (s *Solver) solveWithRetry(ctx context.Context, siteKey, siteURL, rqdata string, p platform) (token, ekey string, err error) {
|
|
const maxRetries = 2
|
|
for retry := 1; retry <= maxRetries; retry++ {
|
|
if retry > 1 {
|
|
log.Printf("[captcha] retry %d/%d for %s...", retry, maxRetries, siteURL)
|
|
}
|
|
token, ekey, err = s.solveOnce(ctx, siteKey, siteURL, rqdata, p, false)
|
|
if err == nil {
|
|
return token, ekey, nil
|
|
}
|
|
if !strings.Contains(err.Error(), "UNSOLVABLE") {
|
|
return "", "", err
|
|
}
|
|
log.Printf("[captcha] attempt %d failed: %v", retry, err)
|
|
}
|
|
return "", "", err
|
|
}
|
|
|
|
// solveOnce creates a task and polls for result on a single platform.
|
|
func (s *Solver) solveOnce(ctx context.Context, siteKey, siteURL, rqdata string, p platform, invisible bool) (token, ekey string, err error) {
|
|
taskType := "PopularCaptchaTaskProxyless"
|
|
if s.proxy != "" && !invisible {
|
|
taskType = "PopularCaptchaTask"
|
|
}
|
|
|
|
task := map[string]interface{}{
|
|
"type": taskType,
|
|
"websiteURL": siteURL,
|
|
"websiteKey": siteKey,
|
|
"user_agent": defaultUserAgent,
|
|
"pow_type": "hsw",
|
|
}
|
|
|
|
if rqdata != "" {
|
|
task["rqdata"] = rqdata
|
|
}
|
|
|
|
if !invisible {
|
|
task["is_invisible"] = false
|
|
}
|
|
|
|
if taskType == "PopularCaptchaTask" && s.proxy != "" {
|
|
solverProxy := s.proxy
|
|
if strings.HasPrefix(solverProxy, "socks5://") {
|
|
solverProxy = "http://" + strings.TrimPrefix(solverProxy, "socks5://")
|
|
}
|
|
task["proxy"] = solverProxy
|
|
}
|
|
|
|
taskReq := map[string]interface{}{
|
|
"clientKey": p.apiKey,
|
|
"task": task,
|
|
}
|
|
|
|
body, err := json.Marshal(taskReq)
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("marshal create task: %w", err)
|
|
}
|
|
|
|
log.Printf("[captcha] [%s] createTask: %s", p.name, string(body))
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "POST", p.apiBase+"/createTask", strings.NewReader(string(body)))
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("create task request: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := s.client.Do(req)
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("send create task: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
respBody, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("read create task response: %w", err)
|
|
}
|
|
log.Printf("[captcha] [%s] createTask response: status=%d body=%s", p.name, resp.StatusCode, string(respBody))
|
|
|
|
var createResult struct {
|
|
ErrorID int `json:"errorId"`
|
|
ErrorCode string `json:"errorCode"`
|
|
ErrorDescription string `json:"errorDescription"`
|
|
TaskID string `json:"taskId"`
|
|
}
|
|
if err := json.Unmarshal(respBody, &createResult); err != nil {
|
|
return "", "", fmt.Errorf("parse create task response: %w (body: %s)", err, string(respBody))
|
|
}
|
|
|
|
if createResult.ErrorID != 0 {
|
|
return "", "", fmt.Errorf("create task error: %s - %s", createResult.ErrorCode, createResult.ErrorDescription)
|
|
}
|
|
if createResult.TaskID == "" {
|
|
return "", "", fmt.Errorf("createTask: no taskId in response")
|
|
}
|
|
|
|
log.Printf("[captcha] [%s] task created: %s, polling for result...", p.name, createResult.TaskID)
|
|
|
|
for attempt := 1; attempt <= 60; attempt++ {
|
|
select {
|
|
case <-ctx.Done():
|
|
return "", "", ctx.Err()
|
|
case <-time.After(5 * time.Second):
|
|
}
|
|
|
|
token, ekey, done, err := s.getTaskResult(ctx, createResult.TaskID, p)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
if done {
|
|
log.Printf("[captcha] [%s] solved in %d attempts (~%ds)", p.name, attempt, attempt*5)
|
|
return token, ekey, nil
|
|
}
|
|
|
|
if attempt%6 == 0 {
|
|
log.Printf("[captcha] [%s] still solving... (attempt %d, ~%ds)", p.name, attempt, attempt*5)
|
|
}
|
|
}
|
|
|
|
return "", "", fmt.Errorf("captcha solve timeout after 300s")
|
|
}
|
|
|
|
func (s *Solver) getTaskResult(ctx context.Context, taskID string, p platform) (token, ekey string, done bool, err error) {
|
|
body, err := json.Marshal(map[string]string{
|
|
"clientKey": p.apiKey,
|
|
"taskId": taskID,
|
|
})
|
|
if err != nil {
|
|
return "", "", false, fmt.Errorf("marshal get result: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "POST", p.apiBase+"/getTaskResult", strings.NewReader(string(body)))
|
|
if err != nil {
|
|
return "", "", false, fmt.Errorf("create get result request: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := s.client.Do(req)
|
|
if err != nil {
|
|
return "", "", false, fmt.Errorf("send get result: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
respBody, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return "", "", false, fmt.Errorf("read get result response: %w", err)
|
|
}
|
|
|
|
var result struct {
|
|
ErrorID int `json:"errorId"`
|
|
ErrorCode string `json:"errorCode"`
|
|
ErrorDescription string `json:"errorDescription"`
|
|
Status string `json:"status"`
|
|
Solution struct {
|
|
Token string `json:"token"`
|
|
GRecaptchaResponse string `json:"gRecaptchaResponse"`
|
|
RespKey string `json:"respKey"`
|
|
UserAgent string `json:"userAgent"`
|
|
} `json:"solution"`
|
|
}
|
|
if err := json.Unmarshal(respBody, &result); err != nil {
|
|
return "", "", false, fmt.Errorf("parse get result response: %w (body: %s)", err, string(respBody))
|
|
}
|
|
|
|
if result.ErrorID != 0 {
|
|
return "", "", false, fmt.Errorf("get result error: %s - %s", result.ErrorCode, result.ErrorDescription)
|
|
}
|
|
|
|
if result.Status == "ready" {
|
|
log.Printf("[captcha] [%s] raw result body: %s", p.name, string(respBody))
|
|
|
|
solvedToken := result.Solution.Token
|
|
if solvedToken == "" {
|
|
solvedToken = result.Solution.GRecaptchaResponse
|
|
}
|
|
|
|
log.Printf("[captcha] [%s] solved! token_len=%d, ekey=%s", p.name, len(solvedToken), result.Solution.RespKey)
|
|
return solvedToken, result.Solution.RespKey, true, nil
|
|
}
|
|
|
|
return "", "", false, nil
|
|
}
|