Files
eway/internal/interconnect/eway/client.go
2024-02-13 21:19:39 +03:00

462 lines
11 KiB
Go

package eway
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"reflect"
"strconv"
"strings"
"sync"
"time"
"git.loyso.art/frx/eway/internal/config"
"git.loyso.art/frx/eway/internal/crypto"
"git.loyso.art/frx/eway/internal/entity"
"github.com/PuerkitoBio/goquery"
"github.com/go-resty/resty/v2"
"github.com/rs/zerolog"
)
type Client interface {
GetGoodsRemnants(context.Context, []int) (entity.MappedGoodsRemnants, error)
GetGoodsNew(
context.Context,
GetGoodsNewParams,
) (items []entity.GoodsItemRaw, total int, err error)
GetProductInfo(context.Context, int64) (entity.GoodsItemInfo, error)
}
type client struct {
http *resty.Client
log zerolog.Logger
htmlParseSema chan struct{}
releaseSemaDelay time.Duration
getProductInfoBus chan getProductInfoRequest
ownerID string
workersPool int
workerswg *sync.WaitGroup
}
type Config config.Eway
func New(ctx context.Context, cfg Config, log zerolog.Logger) (*client, error) {
httpclient := resty.New().
SetDebug(cfg.Debug).
SetBaseURL("https://eway.elevel.ru/api")
c := client{
http: httpclient,
log: log.With().Str("client", "eway").Logger(),
htmlParseSema: make(chan struct{}, 2),
getProductInfoBus: make(chan getProductInfoRequest),
releaseSemaDelay: time.Second / 2,
workerswg: &sync.WaitGroup{},
}
for i := 0; i < cfg.WorkersPool; i++ {
c.workerswg.Add(1)
go func() {
defer c.workerswg.Done()
c.productInfoWorker(ctx, c.getProductInfoBus)
}()
}
if cfg.SessionID == "" || cfg.SessionUser == "" {
if cfg.Login == "" || cfg.Password == "" {
return nil, entity.SimpleError("no auth method provided")
}
decryptedPassword, err := crypto.Decrypt(cfg.Password)
if err != nil {
return nil, fmt.Errorf("decrypting password: %w", err)
}
err = c.login(ctx, cfg.Login, decryptedPassword)
if err != nil {
return nil, err
}
log.Info().Msg("login successful")
} else if cfg.SessionID != "" && cfg.SessionUser != "" {
cookies := []*http.Cookie{
{
Name: "session_id",
Value: cfg.SessionID,
Domain: "eway.elevel.ru",
HttpOnly: true,
},
{
Name: "session_user",
Value: cfg.SessionUser,
Domain: "eway.elevel.ru",
HttpOnly: true,
},
}
c.http.SetCookies(cookies)
} else {
return nil, entity.SimpleError("bad configuration: either session_id and session_user should be set or login and password")
}
return &c, nil
}
type GetGoodsNewParams struct {
Draw int
Start int
// 100 is max
Length int
SearchInStocks bool
RemmantsAtleast int
}
type getGoodsNewResponse struct {
Draw string `json:"draw"`
RecordsFiltered int `json:"recordsFiltered"`
RecordsTotal int `json:"recordsTotal"`
Data [][]any `json:"data"`
Replacement bool `json:"replacement"`
}
func parseGoodItem(items []any) (out entity.GoodsItemRaw) {
valueOf := reflect.ValueOf(&out).Elem()
typeOf := valueOf.Type()
numField := valueOf.NumField()
for i := 0; i < numField; i++ {
field := valueOf.Field(i)
fieldType := typeOf.Field(i)
if fieldType.Type.Kind() == reflect.Slice &&
field.Type().Elem().Kind() != reflect.String {
continue
}
itemValue := reflect.ValueOf(items[i])
if items[i] == nil ||
(itemValue.CanAddr() && itemValue.IsNil()) ||
itemValue.IsZero() {
continue
}
if field.Type().Kind() != itemValue.Type().Kind() {
continue
}
// Dirty hack that accepts only strings.
if field.Type().Kind() == reflect.Slice {
values := items[i].([]any)
elemSlice := reflect.MakeSlice(typeOf.Field(i).Type, 0, field.Len())
for _, value := range values {
valueStr, ok := value.(string)
if ok {
elemSlice = reflect.Append(elemSlice, reflect.ValueOf(valueStr))
}
}
field.Set(elemSlice)
continue
}
field.Set(itemValue)
}
return out
}
func mapResponseByOrder(response getGoodsNewResponse) (items []entity.GoodsItemRaw) {
for _, columns := range response.Data {
gi := parseGoodItem(columns)
items = append(items, gi)
}
return items
}
func (c *client) GetGoodsRemnants(
ctx context.Context,
productIDs []int,
) (out entity.MappedGoodsRemnants, err error) {
if len(productIDs) == 0 {
return nil, nil
}
productsStr := make([]string, 0, len(productIDs))
for _, sku := range productIDs {
productsStr = append(productsStr, strconv.Itoa(sku))
}
req := c.http.R().
SetFormData(map[string]string{
"products": strings.Join(productsStr, ","),
}).
SetDoNotParseResponse(true)
resp, err := c.do(ctx, "GetGoodsRemnants", req, resty.MethodPost, "/goods_remnants")
if err != nil {
return nil, fmt.Errorf("getting goods new: %w", err)
}
defer func() {
err = resp.RawBody().Close()
if err != nil {
c.log.Error().Err(err).Msg("unable to close body")
}
}()
if resp.IsError() {
return nil, errors.New("request was not successful")
}
data, err := io.ReadAll(resp.RawBody())
if err != nil {
return nil, fmt.Errorf("reading raw body: %w", err)
}
out = make(entity.MappedGoodsRemnants, len(productIDs))
err = json.NewDecoder(bytes.NewReader(data)).Decode(&out)
if err != nil {
return nil, fmt.Errorf("decoding body: %w", err)
}
return out, nil
}
func (c *client) GetGoodsNew(
ctx context.Context,
params GetGoodsNewParams,
) (items []entity.GoodsItemRaw, total int, err error) {
var response getGoodsNewResponse
formData := map[string]string{
"draw": strconv.Itoa(params.Draw),
"start": strconv.Itoa(params.Start),
"length": strconv.Itoa(params.Length),
"order[0][column]": "14",
"order[0][dir]": "desc",
"search[value]": "",
"search[regex]": "false",
}
if params.SearchInStocks {
stocksNum := strconv.Itoa(params.RemmantsAtleast)
formData["search_in_stocks"] = "on"
formData["remnants_atleast"] = stocksNum
}
c.log.Debug().
Int("remnants", params.RemmantsAtleast).
Bool("search_in_stocks", params.SearchInStocks).
Int("draw", params.Draw).
Int("start", params.Start).
Int("length", params.Length).
Msg("sending request")
req := c.http.R().
SetFormData(formData).
SetQueryParam("category_id", "0").
SetQueryParam("own", c.ownerID). // user id?
SetDoNotParseResponse(true)
resp, err := c.do(ctx, "GetGoodsNew", req, resty.MethodPost, "/goods_new")
if err != nil {
return nil, -1, fmt.Errorf("getting goods new: %w", err)
}
defer func() {
err = resp.RawBody().Close()
if err != nil {
c.log.Error().Err(err).Msg("unable to close body")
}
}()
if resp.IsError() {
return nil, -1, errors.New("request was not successful")
}
err = json.NewDecoder(resp.RawBody()).Decode(&response)
if err != nil {
return nil, -1, fmt.Errorf("decoding body: %w", err)
}
return mapResponseByOrder(response), response.RecordsTotal, nil
}
func (c *client) login(ctx context.Context, user, pass string) error {
req := c.http.R().
SetDoNotParseResponse(true).
SetFormData(map[string]string{
"username": user,
"password": pass,
})
resp, err := c.do(ctx, "login", req, resty.MethodPost, "https://eway.elevel.ru/")
if err != nil {
return fmt.Errorf("sending request: %w", err)
}
if resp.IsError() {
zerolog.Ctx(ctx).Warn().Int("code", resp.StatusCode()).Msg("bad response")
return entity.SimpleError("request was not successful")
}
return nil
}
func (c *client) do(ctx context.Context, name string, req *resty.Request, method string, url string) (resp *resty.Response, err error) {
resp, err = req.
EnableTrace().
Execute(method, url)
traceInfo := resp.Request.TraceInfo()
c.log.Debug().
Str("name", name).
Str("path", url).
Str("method", method).
Float64("elapsed", traceInfo.TotalTime.Seconds()).
Float64("response_time", traceInfo.ResponseTime.Seconds()).
Int("attempt", traceInfo.RequestAttempt).
Bool("success", resp.IsSuccess()).
Msg("request processed")
if err != nil {
return nil, fmt.Errorf("executing request: %w", err)
}
return resp, err
}
func (c *client) GetProductInfo(ctx context.Context, cart int64) (pi entity.GoodsItemInfo, err error) {
if c.workersPool == 0 {
return c.getProductInfo(ctx, cart)
}
responseBus := make(chan taskResult[entity.GoodsItemInfo], 1)
c.getProductInfoBus <- getProductInfoRequest{
cartID: cart,
response: responseBus,
}
select {
case response := <-responseBus:
return response.value, response.err
case <-ctx.Done():
return pi, ctx.Err()
}
}
func (c *client) getProductInfo(ctx context.Context, cartID int64) (pi entity.GoodsItemInfo, err error) {
reqpath := "https://eway.elevel.ru/product/" + strconv.Itoa(int(cartID)) + "/"
req := c.http.R().SetDoNotParseResponse(true).AddRetryCondition(func(r *resty.Response, err error) bool {
if r.Request.Attempt > 3 {
return false
}
return strings.Contains(err.Error(), "pipe")
})
c.log.Debug().Msg("using go query")
pi.Parameters = map[string]string{}
resp, err := c.do(ctx, "getProductInfo", req, resty.MethodGet, reqpath)
if err != nil {
return pi, fmt.Errorf("getting product info: %w", err)
}
defer func() {
errClose := resp.RawBody().Close()
if errClose == nil {
return
}
if err == nil {
err = errClose
return
}
c.log.Warn().Err(errClose).Msg("unable to close body")
}()
if resp.IsError() {
return pi, errors.New("request was not successful")
}
doc, err := goquery.NewDocumentFromReader(resp.RawBody())
if err != nil {
return pi, fmt.Errorf("makind new document: %w", err)
}
cleanText := func(t string) string {
return strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(t), ":"))
}
const parametersSelector = "body > div.page-container > div.page-content > div.content-wrapper > div.content > div.row > div.col-md-4 > div > div > div:nth-child(6)"
const parametersInnerNode = "div.display-flex"
doc.
Find(parametersSelector).
Find(parametersInnerNode).
Each(func(i int, s *goquery.Selection) {
name := cleanText(s.Find("div").Eq(0).Text())
value := cleanText(s.Find("div.text-right").Text())
pi.Parameters[name] = value
})
const galleryPanelSelector = "div.gallery_panel"
const galleryImageSelector = "div.gallery_thumbnail > img"
doc.
Find(galleryPanelSelector).
Find(galleryImageSelector).
Each(func(i int, s *goquery.Selection) {
imageURL, ok := s.Attr("src")
if !ok || len(imageURL) == 0 {
return
}
pi.PhotoURLs = append(pi.PhotoURLs, imageURL)
})
return pi, nil
}
type getProductInfoRequest struct {
cartID int64
response chan taskResult[entity.GoodsItemInfo]
}
type taskResult[T any] struct {
value T
err error
}
func (c *client) productInfoWorker(
ctx context.Context,
in <-chan getProductInfoRequest,
) {
var req getProductInfoRequest
var ok bool
for {
select {
case <-ctx.Done():
return
case req, ok = <-in:
if !ok {
return
}
}
func() {
c.htmlParseSema <- struct{}{}
defer func() {
go func() {
time.Sleep(c.releaseSemaDelay)
<-c.htmlParseSema
}()
}()
pi, err := c.getProductInfo(ctx, req.cartID)
req.response <- taskResult[entity.GoodsItemInfo]{
value: pi,
err: err,
}
}()
}
}