package coinglass import ( "context" "encoding/base64" "encoding/json" "errors" "fmt" "net/http" "net/url" "strings" "time" "github.com/enetx/g" "github.com/enetx/surf" "github.com/pquerna/otp/totp" ) const ( defaultBaseURL = "https://capi.coinglass.com" defaultOrigin = "https://www.coinglass.com" defaultDataAESKey = "1f68efd73f8d4921acc0dead41dd39bc" defaultRequestTimeout = 15 * time.Second defaultFakeSuccessRetry = 1 defaultFakeSuccessRetryWait = 2 * time.Second ) type Client struct { baseURL string totpSecret string dataAESKey []byte fakeSuccessRetry int fakeSuccessRetryWait time.Duration doer requestDoer now func() time.Time } type ClientOptions struct { BaseURL string TOTPSecret string DataAESKey string Timeout time.Duration FakeSuccessRetry int FakeSuccessRetryWait time.Duration } type requestDoer interface { Do(context.Context, httpRequest) (*httpResponse, error) } type httpRequest struct { URL string Header http.Header } type httpResponse struct { StatusCode int Header http.Header Body []byte } type transportEnvelope struct { Success *bool `json:"success"` Code json.RawMessage `json:"code"` Msg string `json:"msg"` Data *string `json:"data"` } func NewClient(opts ClientOptions) (*Client, error) { return newClientWithDoer(opts, newSurfDoer(opts.Timeout)) } func newClientWithDoer(opts ClientOptions, doer requestDoer) (*Client, error) { if doer == nil { return nil, fmt.Errorf("coinglass client: nil doer") } if opts.BaseURL == "" { opts.BaseURL = defaultBaseURL } if opts.TOTPSecret == "" { return nil, fmt.Errorf("coinglass client: totp secret is required") } if opts.DataAESKey == "" { opts.DataAESKey = defaultDataAESKey } if opts.FakeSuccessRetry == 0 { opts.FakeSuccessRetry = defaultFakeSuccessRetry } if opts.FakeSuccessRetryWait == 0 { opts.FakeSuccessRetryWait = defaultFakeSuccessRetryWait } if _, err := aesECBEncrypt([]byte(opts.DataAESKey), pkcs7Pad([]byte("probe"), 16)); err != nil { return nil, fmt.Errorf("coinglass client: bad data aes key: %w", err) } return &Client{ baseURL: strings.TrimRight(opts.BaseURL, "/"), totpSecret: opts.TOTPSecret, dataAESKey: []byte(opts.DataAESKey), fakeSuccessRetry: opts.FakeSuccessRetry, fakeSuccessRetryWait: opts.FakeSuccessRetryWait, doer: doer, now: time.Now, }, nil } // GetPlaintext dispatches one CoinGlass V2 GET request and returns decrypted plaintext bytes. func (c *Client) GetPlaintext(ctx context.Context, path string, query url.Values) ([]byte, error) { var lastFake error for attempt := 0; attempt <= c.fakeSuccessRetry; attempt++ { plain, err := c.getPlaintextOnce(ctx, path, query) if err == nil { return plain, nil } if !errors.Is(err, ErrFakeSuccess) { return nil, err } lastFake = err if attempt == c.fakeSuccessRetry { break } if err := sleepContext(ctx, c.fakeSuccessRetryWait); err != nil { return nil, err } } return nil, lastFake } func (c *Client) getPlaintextOnce(ctx context.Context, path string, query url.Values) ([]byte, error) { now := c.now() cacheTsV2 := fmt.Sprintf("%d", now.UnixMilli()) q := cloneValues(query) dataParam, err := generateDataParam(c.totpSecret, c.dataAESKey, now) if err != nil { return nil, err } q.Set("data", dataParam) resp, err := c.doer.Do(ctx, httpRequest{ URL: c.buildURL(path, q), Header: baseHeaders(cacheTsV2), }) if err != nil { return nil, err } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("coinglass %s status=%d: %s", path, resp.StatusCode, bodySnippet(resp.Body)) } env, err := parseTransportEnvelope(resp.Body) if err != nil { return nil, fmt.Errorf("coinglass %s envelope: %w", path, err) } if env.Success != nil && !*env.Success { return nil, fmt.Errorf("%w: code=%s msg=%q", ErrAPIBusiness, envelopeCode(env.Code), env.Msg) } if env.Data == nil || *env.Data == "" { return nil, fmt.Errorf("%w: empty data code=%s msg=%q", ErrFakeSuccess, envelopeCode(env.Code), env.Msg) } v := firstHeader(resp.Header, "v") user := firstHeader(resp.Header, "user") respTime := firstHeader(resp.Header, "time") if v == "" || user == "" { return nil, fmt.Errorf("%w: missing v/user response headers", ErrFakeSuccess) } seed, err := resolveSeed(v, path, cacheTsV2, respTime) if err != nil { return nil, err } plain, err := DecryptUniversal(seed, user, *env.Data) if err != nil { return nil, err } return plain, nil } func (c *Client) buildURL(path string, query url.Values) string { if !strings.HasPrefix(path, "/") { path = "/" + path } if len(query) == 0 { return c.baseURL + path } return c.baseURL + path + "?" + query.Encode() } func generateDataParam(secret string, key []byte, now time.Time) (string, error) { code, err := totp.GenerateCode(secret, now) if err != nil { return "", fmt.Errorf("coinglass data param totp: %w", err) } plain := []byte(fmt.Sprintf("%d,%s", now.Unix(), code)) enc, err := aesECBEncrypt(key, pkcs7Pad(plain, 16)) if err != nil { return "", err } return base64.StdEncoding.EncodeToString(enc), nil } func baseHeaders(cacheTsV2 string) http.Header { h := http.Header{} h.Set("Accept", "application/json") h.Set("Accept-Language", "en-US,en;q=0.9") h.Set("Language", "zh") h.Set("Encryption", "true") h.Set("Origin", defaultOrigin) h.Set("Referer", defaultOrigin+"/") h.Set("Cache-Ts-V2", cacheTsV2) h.Set("Sec-Fetch-Dest", "empty") h.Set("Sec-Fetch-Mode", "cors") h.Set("Sec-Fetch-Site", "same-site") return h } func parseTransportEnvelope(body []byte) (*transportEnvelope, error) { var env transportEnvelope if err := json.Unmarshal(body, &env); err != nil { return nil, err } return &env, nil } func cloneValues(in url.Values) url.Values { out := make(url.Values, len(in)) for k, vs := range in { out[k] = append([]string(nil), vs...) } return out } func sleepContext(ctx context.Context, d time.Duration) error { timer := time.NewTimer(d) defer timer.Stop() select { case <-ctx.Done(): return ctx.Err() case <-timer.C: return nil } } func firstHeader(h http.Header, key string) string { if v := h.Get(key); v != "" { return v } return h.Get(http.CanonicalHeaderKey(key)) } func envelopeCode(raw json.RawMessage) string { raw = json.RawMessage(strings.TrimSpace(string(raw))) if len(raw) == 0 || string(raw) == "null" { return "" } var s string if err := json.Unmarshal(raw, &s); err == nil { return s } return string(raw) } func bodySnippet(b []byte) string { const max = 200 if len(b) <= max { return string(b) } return string(b[:max]) } type surfDoer struct { client *surf.Client } func newSurfDoer(timeout time.Duration) *surfDoer { if timeout == 0 { timeout = defaultRequestTimeout } return &surfDoer{ client: surf.NewClient().Builder(). Impersonate().FireFox(). Timeout(timeout). Build(), } } func (d *surfDoer) Do(ctx context.Context, req httpRequest) (*httpResponse, error) { result := d.client.Get(g.String(req.URL)). WithContext(ctx). SetHeaders(singleHeaderValues(req.Header)). Do() if result.IsErr() { return nil, result.Err() } resp := result.Ok() header := http.Header{} for _, key := range []string{"v", "user", "time"} { if value := resp.Headers.Get(g.String(key)).Std(); value != "" { header.Set(key, value) } } return &httpResponse{ StatusCode: int(resp.StatusCode), Header: header, Body: []byte(resp.Body.Bytes()), }, nil } func singleHeaderValues(h http.Header) map[string]string { out := make(map[string]string, len(h)) for k, vs := range h { if len(vs) > 0 { out[k] = vs[0] } } return out }