364 lines
8.4 KiB
Go
364 lines
8.4 KiB
Go
package eway
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"reflect"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.loyso.art/frx/eway/internal/config"
|
|
"git.loyso.art/frx/eway/internal/crypto"
|
|
"git.loyso.art/frx/eway/internal/entity"
|
|
|
|
"github.com/go-resty/resty/v2"
|
|
"github.com/gocolly/colly"
|
|
"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
|
|
ownerID string
|
|
}
|
|
|
|
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),
|
|
releaseSemaDelay: time.Second / 2,
|
|
}
|
|
|
|
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"`
|
|
}
|
|
|
|
type goodRemnant [4]int
|
|
|
|
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))
|
|
}
|
|
|
|
resp, err := c.http.R().
|
|
SetFormData(map[string]string{
|
|
"products": strings.Join(productsStr, ","),
|
|
}).
|
|
SetDoNotParseResponse(true).
|
|
Post("/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
|
|
resp, err := c.http.R().
|
|
SetFormData(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",
|
|
"search_in_stocks": "on",
|
|
"remnants_atleast": "5",
|
|
}).
|
|
SetQueryParam("category_id", "0").
|
|
SetQueryParam("own", c.ownerID). // user id?
|
|
SetDoNotParseResponse(true).
|
|
Post("/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 {
|
|
resp, err := c.http.R().
|
|
SetDoNotParseResponse(true).
|
|
SetFormData(map[string]string{
|
|
"username": user,
|
|
"password": pass,
|
|
}).Post("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
|
|
}
|
|
|
|
type ProductInfo struct {
|
|
ImageLinks []string
|
|
Parameters map[string]string
|
|
}
|
|
|
|
type parameterSelector struct {
|
|
Name string `selector:"div"`
|
|
Value string `selector:"div.text-right"`
|
|
}
|
|
|
|
func (c *client) GetProductInfo(ctx context.Context, cart int64) (pi entity.GoodsItemInfo, err error) {
|
|
select {
|
|
case c.htmlParseSema <- struct{}{}:
|
|
defer func() {
|
|
go func() {
|
|
time.Sleep(c.releaseSemaDelay)
|
|
<-c.htmlParseSema
|
|
}()
|
|
}()
|
|
case <-ctx.Done():
|
|
return pi, ctx.Err()
|
|
}
|
|
|
|
collector := colly.NewCollector(
|
|
colly.AllowedDomains("eway.elevel.ru"),
|
|
colly.AllowURLRevisit(),
|
|
)
|
|
|
|
pi.Parameters = map[string]string{}
|
|
|
|
start := time.Now()
|
|
defer func() {
|
|
elapsed := time.Since(start).Seconds()
|
|
c.log.Info().Float64("elapsed", elapsed).Msg("request processed")
|
|
}()
|
|
|
|
collector.OnHTML("body > div.page-container > div.page-content > div.content-wrapper > div.content > div.row > div.col-md-4 > div > div > div:nth-child(6)", func(e *colly.HTMLElement) {
|
|
e.ForEach("div.display-flex", func(i int, h *colly.HTMLElement) {
|
|
var s parameterSelector
|
|
err = h.Unmarshal(&s)
|
|
if err != nil {
|
|
c.log.Warn().Err(err).Msg("unable to unmarshal")
|
|
return
|
|
}
|
|
|
|
if s.Name == "" || s.Value == "" {
|
|
c.log.Warn().Msg("got empty key or value, skipping")
|
|
return
|
|
}
|
|
|
|
pi.Parameters[s.Name] = s.Value
|
|
})
|
|
})
|
|
collector.OnHTML("div.gallery_panel", func(h *colly.HTMLElement) {
|
|
h.ForEach("div.gallery_thumbnail > img", func(i int, h *colly.HTMLElement) {
|
|
imageURL := h.Attr("src")
|
|
|
|
if imageURL == "" {
|
|
return
|
|
}
|
|
|
|
pi.PhotoURLs = append(pi.PhotoURLs, imageURL)
|
|
})
|
|
})
|
|
|
|
for i := 0; i < 3; i++ {
|
|
err = collector.Visit("https://eway.elevel.ru/product/" + strconv.Itoa(int(cart)) + "/")
|
|
if err != nil {
|
|
c.log.Warn().Err(err).Msg("unable to visit site, retrying...")
|
|
select {
|
|
case <-time.After(time.Second * 2):
|
|
continue
|
|
case <-ctx.Done():
|
|
return pi, ctx.Err()
|
|
}
|
|
}
|
|
|
|
break
|
|
}
|
|
if err != nil {
|
|
return pi, fmt.Errorf("visiting site: %w", err)
|
|
}
|
|
|
|
return pi, nil
|
|
}
|