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)
}