package rucaptcha import ( "context" "math" "net/http" "sync" "time" "github.com/jfk9w-go/based" "github.com/pkg/errors" ) type answerer interface { answer(ctx context.Context, requestID string) (string, error) } type resClient interface { res(ctx context.Context, in resIn) (string, error) } type answerPoller struct { client resClient } func (p *answerPoller) answer(ctx context.Context, requestID string) (string, error) { in := &resGetIn{ ID: requestID, } timeout := 10 * time.Second for { select { case <-time.After(timeout): result, err := p.client.res(ctx, in) if err == nil { return result, nil } var clientErr Error if errors.As(err, &clientErr) && clientErr.Code != "CAPCHA_NOT_READY" { return "", err } timeout = time.Duration(math.Max(float64(timeout)/2, 2)) case <-ctx.Done(): return "", ctx.Err() } } } type asyncAnswer struct { c chan string created time.Time } type answerListener struct { clock based.Clock answers map[string]asyncAnswer mu sync.Mutex } func newAsyncListener(clock based.Clock) *answerListener { return &answerListener{ clock: clock, answers: make(map[string]asyncAnswer), } } func (pb *answerListener) ServeHTTP(w http.ResponseWriter, req *http.Request) { id := req.FormValue("id") code := req.FormValue("code") if id == "" || code == "" { w.WriteHeader(http.StatusBadRequest) return } answer := pb.getAsyncAnswer(id) select { case answer.c <- code: w.WriteHeader(http.StatusOK) case <-req.Context().Done(): w.WriteHeader(http.StatusUnprocessableEntity) } } func (pb *answerListener) answer(ctx context.Context, id string) (string, error) { answer := pb.getAsyncAnswer(id) select { case result := <-answer.c: return result, nil case <-ctx.Done(): return "", ctx.Err() } } func (pb *answerListener) getAsyncAnswer(id string) asyncAnswer { pb.mu.Lock() defer pb.mu.Unlock() now := pb.clock.Now() ans, ok := pb.answers[id] if !ok { ans = asyncAnswer{ c: make(chan string, 1), created: now, } pb.answers[id] = ans } for id, ans := range pb.answers { if now.Sub(ans.created) > 5*time.Minute { close(ans.c) delete(pb.answers, id) } } return ans }
package rucaptcha import ( "bytes" "context" "encoding/json" "mime/multipart" "net/http" "net/url" "github.com/google/go-querystring/query" "github.com/jfk9w-go/based" "github.com/pkg/errors" ) const baseURL = "https://rucaptcha.com" type Config struct { Key string `url:"key" validate:"required"` Pingback string `url:"pingback,omitempty"` SoftID int `url:"soft_id,omitempty"` } type ClientParams struct { Config Config `validate:"required"` Clock based.Clock `validate:"required_with=Config.Pingback"` Transport http.RoundTripper } func NewClient(params ClientParams) (*Client, error) { if err := based.Validate(params); err != nil { return nil, err } options, err := query.Values(params.Config) if err != nil { return nil, errors.Wrap(err, "encode options") } options.Set("json", "1") client := &Client{ httpClient: &http.Client{ Transport: params.Transport, }, options: options, } if params.Config.Pingback == "" { client.answerer = &answerPoller{client} } else { client.answerer = newAsyncListener(params.Clock) } return client, nil } type Client struct { httpClient *http.Client answerer answerer options url.Values } func (c *Client) HTTPHandler() http.Handler { if handler, ok := c.answerer.(http.Handler); ok { return handler } return nil } func (c *Client) Solve(ctx context.Context, in SolveIn) (*SolveOut, error) { if err := based.Validate(in); err != nil { return nil, err } values, err := query.Values(in) if err != nil { return nil, errors.Wrap(err, "encode solve values") } values.Set("method", in.Method()) id, err := c.execute(ctx, "/in.php", values) if err != nil { return nil, errors.Wrap(err, "send solve request") } answer, err := c.answerer.answer(ctx, id) if err != nil { return nil, errors.Wrap(err, "get answer") } return &SolveOut{ ID: id, Answer: answer, }, nil } func (c *Client) Report(ctx context.Context, id string, ok bool) error { in := &resReportIn{ ID: id, ok: ok, } _, err := c.res(ctx, in) return err } func (c *Client) res(ctx context.Context, in resIn) (string, error) { values, err := query.Values(in) if err != nil { return "", errors.Wrap(err, "encode solve values") } values.Set("action", in.action()) result, err := c.execute(ctx, "/res.php", values) if err != nil { return "", errors.Wrap(err, "send res request") } return result, nil } func (c *Client) execute(ctx context.Context, path string, query url.Values) (string, error) { var reqBody bytes.Buffer multipartWriter := multipart.NewWriter(&reqBody) for _, params := range []url.Values{c.options, query} { for key, values := range params { for _, value := range values { if err := multipartWriter.WriteField(key, value); err != nil { _ = multipartWriter.Close() return "", errors.Wrapf(err, "write '%s' to request body", key) } } } } if err := multipartWriter.Close(); err != nil { return "", errors.Wrap(err, "close writer") } httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+path, &reqBody) if err != nil { return "", errors.Wrap(err, "create request") } httpReq.Header.Set("Content-Type", multipartWriter.FormDataContentType()) httpResp, err := c.httpClient.Do(httpReq) if err != nil { return "", errors.Wrap(err, "execute request") } if httpResp.StatusCode != http.StatusOK { return "", errors.Errorf(httpResp.Status) } if httpResp.Body == nil { return "", errors.New("empty response body") } defer httpResp.Body.Close() var respBody struct { Status *int `json:"status"` Request string `json:"request"` ErrorText string `json:"error_text"` } if err := json.NewDecoder(httpResp.Body).Decode(&respBody); err != nil { return "", errors.Wrap(err, "read response json") } switch { case respBody.Status == nil: return "", errors.New("empty status") case *respBody.Status == 0: return "", &Error{Code: respBody.Request, Text: respBody.ErrorText} default: return respBody.Request, nil } }
package rucaptcha import "fmt" type SolveIn interface { Method() string } type SolveOut struct { ID string Answer string } type YandexSmartCaptchaIn struct { SiteKey string `url:"sitekey" validate:"required"` PageURL string `url:"pageurl" validate:"required"` AccessControlAllowOrigin bool `url:"header_acao,omitempty"` UserAgent string `url:"userAgent,omitempty"` Proxy string `url:"proxy,omitempty"` ProxyType string `url:"proxytype,omitempty"` } func (in *YandexSmartCaptchaIn) Method() string { return "yandex" } type resIn interface { action() string } type resGetIn struct { ID string `url:"id"` } func (in *resGetIn) action() string { return "get" } type resReportIn struct { ID string `url:"id"` ok bool } func (in *resReportIn) action() string { if in.ok { return "reportgood" } else { return "reportbad" } } type Error struct { Code string Text string } func (e Error) Error() string { return fmt.Sprintf("%s: %s", e.Code, e.Text) }
package main import ( "context" "fmt" "github.com/caarlos0/env" "github.com/jfk9w-go/rucaptcha-api" ) func main() { ctx, cancel := context.WithCancel(context.Background()) defer cancel() var config struct { Key string `env:"KEY,required"` SiteKey string `env:"SITE_KEY,required"` PageURL string `env:"PAGE_URL,required"` } if err := env.Parse(&config); err != nil { panic(err) } client, err := rucaptcha.NewClient(rucaptcha.ClientParams{ Config: rucaptcha.Config{ Key: config.Key, }, }) if err != nil { panic(err) } result, err := client.Solve(ctx, &rucaptcha.YandexSmartCaptchaIn{ SiteKey: config.SiteKey, PageURL: config.PageURL, }) if err != nil { panic(err) } fmt.Println("Result:", result.Answer) }