mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-09-20 04:23:43 +00:00
129 lines
3.6 KiB
Go
129 lines
3.6 KiB
Go
package stock
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
)
|
|
|
|
// ssiQueryDefaultURL is the SSI iBoard stock-query endpoint base.
|
|
const ssiQueryDefaultURL = "https://iboard-query.ssi.com.vn"
|
|
|
|
// ssiErrorBodyLimit keeps upstream diagnostics useful without dumping large responses.
|
|
const ssiErrorBodyLimit = 512
|
|
|
|
type ssiSingleResponse struct {
|
|
Data ssiStockQuote `json:"data"`
|
|
}
|
|
|
|
type ssiMultipleResponse struct {
|
|
Data []ssiStockQuote `json:"data"`
|
|
}
|
|
|
|
type ssiStockQuote struct {
|
|
StockSymbol string `json:"stockSymbol"`
|
|
MatchedPrice float64 `json:"matchedPrice"`
|
|
}
|
|
|
|
func (c *PriceClient) baseURL() string {
|
|
if c.URL != "" {
|
|
return strings.TrimRight(c.URL, "/")
|
|
}
|
|
return ssiQueryDefaultURL
|
|
}
|
|
|
|
func (c *PriceClient) fetchSSIPrice(ctx context.Context, ticker string) (float64, error) {
|
|
if ticker == "" {
|
|
return 0, errors.New("stock: ticker is empty")
|
|
}
|
|
full := c.baseURL() + "/stock/" + url.PathEscape(ticker)
|
|
req, err := newSSIRequest(ctx, http.MethodGet, full, nil)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
var body ssiSingleResponse
|
|
if err := c.doSSIJSON(req, &body); err != nil {
|
|
return 0, err
|
|
}
|
|
price := body.Data.MatchedPrice
|
|
if price <= 0 {
|
|
return 0, fmt.Errorf("%w: SSI quote has no matchedPrice for %s", ErrNoPrice, strings.ToUpper(ticker))
|
|
}
|
|
return price, nil
|
|
}
|
|
|
|
func (c *PriceClient) fetchSSIPrices(ctx context.Context, tickers []string) (map[string]float64, error) {
|
|
if len(tickers) == 0 {
|
|
return map[string]float64{}, nil
|
|
}
|
|
form := url.Values{}
|
|
requested := normalizeTickers(tickers)
|
|
for _, ticker := range requested {
|
|
form.Add("stocks", ticker)
|
|
}
|
|
if len(form) == 0 {
|
|
return nil, errors.New("stock: no tickers to fetch")
|
|
}
|
|
|
|
req, err := newSSIRequest(ctx, http.MethodPost, c.baseURL()+"/stock/multiple", strings.NewReader(form.Encode()))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
|
|
var body ssiMultipleResponse
|
|
if err := c.doSSIJSON(req, &body); err != nil {
|
|
return nil, err
|
|
}
|
|
out := make(map[string]float64, len(body.Data))
|
|
for _, quote := range body.Data {
|
|
symbol := strings.ToUpper(strings.TrimSpace(quote.StockSymbol))
|
|
if symbol == "" || quote.MatchedPrice <= 0 {
|
|
continue
|
|
}
|
|
out[symbol] = quote.MatchedPrice
|
|
}
|
|
if len(out) == 0 {
|
|
return nil, fmt.Errorf("%w: SSI batch returned no usable quotes for %s (data_len=%d)", ErrNoPrice, strings.Join(requested, ","), len(body.Data))
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func newSSIRequest(ctx context.Context, method, full string, body io.Reader) (*http.Request, error) {
|
|
req, err := http.NewRequestWithContext(ctx, method, full, body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("stock: build SSI request: %w", err)
|
|
}
|
|
req.Header.Set("Accept", "application/json")
|
|
req.Header.Set("Origin", "https://iboard.ssi.com.vn")
|
|
req.Header.Set("Referer", "https://iboard.ssi.com.vn/")
|
|
req.Header.Set("User-Agent", "Mozilla/5.0 (miti99bot)")
|
|
return req, nil
|
|
}
|
|
|
|
func (c *PriceClient) doSSIJSON(req *http.Request, dst any) error {
|
|
resp, err := c.httpClient().Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("stock: SSI request: %w", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
snippet, _ := io.ReadAll(io.LimitReader(resp.Body, ssiErrorBodyLimit))
|
|
if resp.StatusCode == http.StatusNotFound {
|
|
return fmt.Errorf("%w: SSI status %d body %q", ErrNoPrice, resp.StatusCode, strings.TrimSpace(string(snippet)))
|
|
}
|
|
return fmt.Errorf("stock: SSI status %d body %q", resp.StatusCode, strings.TrimSpace(string(snippet)))
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(dst); err != nil {
|
|
return fmt.Errorf("stock: SSI decode: %w", err)
|
|
}
|
|
return nil
|
|
}
|