Compare commits

..

32 Commits

Author SHA1 Message Date
c8243d25bf format another way 2024-02-07 17:45:00 +03:00
6a746b518f add pattern matching 2024-02-07 16:33:44 +03:00
838d25d878 add analytics cli commands 2024-02-07 12:24:02 +03:00
988b22a08f minor imporvments 2024-02-06 19:58:57 +03:00
ccc668105a use goquery instead of colly 2024-02-06 19:14:55 +03:00
5c8f52724a minor fixes 2024-02-06 17:19:36 +03:00
096c7e8157 add workers pool 2024-02-05 11:05:37 +03:00
362d4524e3 add sema and retries 2024-02-04 22:48:20 +03:00
e474c69aac organize imports 2024-02-04 22:14:36 +03:00
3c68c0b2fd allow to skip item by created 2024-02-04 22:13:39 +03:00
15d4cdb047 fix build 2024-02-04 22:00:19 +03:00
4533b90e4a fix parameter unmarshal 2024-02-04 21:57:25 +03:00
60522a7391 minor fixes 2024-02-04 21:43:38 +03:00
5d5e152429 cache and stats 2024-02-04 21:40:37 +03:00
08be7de118 actualize items list in db 2024-02-04 13:14:56 +03:00
6042ca6822 support parsing item page info 2024-02-03 20:28:33 +03:00
b0a185561d queue api 2024-02-03 18:15:26 +03:00
fae5a84182 another update runs-on 2024-02-02 19:02:57 +03:00
bbe1271620 update runs-on 2024-02-02 19:02:25 +03:00
ff4f3b1d4c try? 2024-02-02 18:57:36 +03:00
a14212702c some other try 2024-02-02 18:56:47 +03:00
cc48e97d40 update runner label 2024-02-02 18:50:31 +03:00
bd135dd3a5 update runner label 2024-02-02 18:36:25 +03:00
1767349a08 update runner 2024-02-02 18:33:15 +03:00
38e04d58c2 update runner 2024-02-02 18:32:19 +03:00
91b0dd5c4f enable gitea actions 2024-02-02 18:24:00 +03:00
3754441492 fix cli arg 2024-01-29 13:21:56 +03:00
9ef5f2bbf8 add encryption and get page 2024-01-29 00:09:30 +03:00
c814f27c54 migrate to cli v3 2024-01-28 17:08:45 +03:00
bfa105df95 able to parse xml 2024-01-28 16:49:48 +03:00
90a7797a27 add export as yml_catalog 2024-01-26 19:34:47 +03:00
6fe250896c fix saving and project improvments 2024-01-25 16:42:08 +03:00
30 changed files with 753 additions and 1839 deletions

1
.gitignore vendored
View File

@ -4,4 +4,3 @@ database
bin bin
*.xml *.xml
Makefile Makefile
config.toml

View File

@ -1,11 +1,5 @@
namespace internal.encoding.fbs; namespace internal.encoding.fbs;
struct Dimensions {
width:float;
height:float;
length:float;
}
table GoodItem { table GoodItem {
sku:string; sku:string;
photo:string; photo:string;
@ -22,7 +16,6 @@ table GoodItem {
stock:short; stock:short;
parameters:string; parameters:string;
created_at:long; created_at:long;
sizes:Dimensions;
} }
table GoodItems { table GoodItems {

View File

@ -1,101 +0,0 @@
package commands
import (
"context"
"fmt"
"git.loyso.art/frx/eway/cmd/cli/components"
"github.com/rodaine/table"
"github.com/rs/zerolog"
"github.com/urfave/cli/v3"
)
func CategoriesCommandTree() *cli.Command {
var h categoriesHandlers
return &cli.Command{
Name: "categories",
Usage: "Interact with stored categories",
Commands: []*cli.Command{
newCategoriesListCommand(h),
},
}
}
func newCategoriesListCommand(h categoriesHandlers) *cli.Command {
cmd := cli.Command{
Name: "list",
Usage: "List categories, stories in db",
Flags: []cli.Flag{
&cli.IntFlag{
Name: "limit",
Usage: "limits output to selected items",
Value: 20,
},
&cli.IntFlag{
Name: "page",
Usage: "in case of limit, selects page",
Value: 0,
},
&cli.BoolFlag{
Name: "with-total",
Usage: "prints total count of categories",
},
},
}
return cmdWithAction(cmd, h.List)
}
type categoriesHandlers struct{}
func (categoriesHandlers) List(ctx context.Context, c *cli.Command) error {
r, err := components.GetRepository()
if err != nil {
return fmt.Errorf("getting repository: %w", err)
}
categories, err := r.Category().List(ctx)
if err != nil {
return fmt.Errorf("listing categories: %w", err)
}
limit := int(c.Int("limit"))
page := int(c.Int("page"))
total := len(categories)
if page == 0 {
page = 1
}
if limit > 0 {
offset := (page - 1) * limit
if offset > len(categories) {
offset = len(categories) - 1
}
limit = offset + limit
if limit > len(categories) {
limit = len(categories)
}
categories = categories[offset:limit]
}
tbl := table.New("ID", "Name")
for _, category := range categories {
if category.ID == 0 && category.Name == "" {
continue
}
tbl.AddRow(category.ID, category.Name)
}
tbl.Print()
if c.Bool("with-total") {
zerolog.Ctx(ctx).Info().Int("count", total).Msg("total categories stats")
}
return nil
}

View File

@ -1,55 +0,0 @@
package commands
import (
"context"
"errors"
"git.loyso.art/frx/eway/internal/entity"
)
type chanIter[T any] struct {
in <-chan T
err error
next T
}
var errChannelClosed = errors.New("channel closed")
func (i *chanIter[T]) Next() (ok bool) {
if i.err != nil {
return false
}
i.next, ok = <-i.in
if !ok {
i.err = errChannelClosed
}
return ok
}
func (i *chanIter[T]) Get() T {
return i.next
}
func (i *chanIter[T]) Err() error {
if errors.Is(i.err, errChannelClosed) {
return nil
}
return i.err
}
func (i *chanIter[T]) Close() {
for range i.in {
}
}
func getItemsIter(ctx context.Context, r entity.GoodsItemRepository) *chanIter[entity.GoodsItem] {
in, err := r.ListIter(ctx, 3)
return &chanIter[entity.GoodsItem]{
in: in,
err: err,
}
}

View File

@ -1,72 +0,0 @@
package commands
import (
"context"
"strings"
"git.loyso.art/frx/eway/internal/dimension"
"git.loyso.art/frx/eway/internal/entity"
"github.com/rs/zerolog"
)
type dimensionDispatcher struct {
m *dimension.Matcher
}
func pickFirst[T, V any](t T, v V) T {
return t
}
func (d dimensionDispatcher) isDimensionParam(value string) bool {
return pickFirst(d.m.Match(value)) != dimension.MatchResultMiss
}
func (d dimensionDispatcher) applyDimensionValue(ctx context.Context, key, value string, in *entity.GoodsItemSize) (updated bool) {
matchResult, priority := d.m.Match(key)
if matchResult == dimension.MatchResultMiss {
return false
}
log := zerolog.Ctx(ctx).With().Str("key", key).Str("value", value).Logger()
if strings.Contains(value, "/") {
dimensionValues := strings.Split(value, "/")
for _, dv := range dimensionValues {
updated = updated || d.applyDimensionValue(ctx, key, dv, in)
}
} else {
out, err := entity.ParseDimention(value, entity.DimensionLocalRU)
if err != nil {
log.Warn().Err(err).Msg("unable to parse key, skipping")
return false
}
out = out.AdjustTo(entity.DimensionKindCentimeter)
switch matchResult {
case dimension.MatchResultHeight:
if !priority && in.Height.Value != 0 {
return false
}
in.Height = out
case dimension.MatchResultLength:
if !priority && in.Length.Value != 0 {
return false
}
in.Length = out
case dimension.MatchResultWidth:
if !priority && in.Width.Value != 0 {
return false
}
in.Width = out
case dimension.MatchResultDepth:
in.UnmatchedDepth = out
}
}
return true
}

View File

@ -1,353 +0,0 @@
package commands
import (
"context"
"encoding/xml"
"fmt"
"io"
"os"
"strconv"
"strings"
"time"
"git.loyso.art/frx/eway/cmd/cli/components"
"git.loyso.art/frx/eway/internal/entity"
"git.loyso.art/frx/eway/internal/export"
"github.com/rs/zerolog"
"github.com/urfave/cli/v3"
)
func ExportCommandTree() *cli.Command {
var h exportHandlers
return &cli.Command{
Name: "export",
Usage: "Provide actions to export data from the database",
Commands: []*cli.Command{
newExportYMLCatalogCommand(h),
},
}
}
func newExportYMLCatalogCommand(h exportHandlers) *cli.Command {
cmd := cli.Command{
Name: "yml-catalog",
Usage: "Export data as yml_catalog",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "out",
Aliases: []string{"o"},
Usage: "Output to file or stdout/stderr",
Value: "yml_catalog.xml",
TakesFile: true,
},
&cli.IntFlag{
Name: "limit",
Aliases: []string{"l"},
Usage: "limits amount of items to save",
},
&cli.BoolFlag{
Name: "pretty",
Aliases: []string{"p"},
Usage: "pretty-prints output",
},
},
}
return cmdWithAction(cmd, h.YMLCatalog)
}
type exportHandlers struct{}
func (h exportHandlers) YMLCatalog(ctx context.Context, cmd *cli.Command) error {
const defaultCurrency = "RUR"
log := zerolog.Ctx(ctx)
path := cmd.String("out")
limit := cmd.Int("limit")
pretty := cmd.Bool("pretty")
r, err := components.GetRepository()
if err != nil {
return fmt.Errorf("getting repository: %w", err)
}
log.Info().
Str("path", path).
Int64("limit", limit).
Bool("pretty", pretty).
Msg("executing with parameters")
categories, err := r.Category().List(ctx)
if err != nil {
return fmt.Errorf("listing categories: %w", err)
}
shop := export.Shop{
Currencies: []export.Currency{{
ID: defaultCurrency,
Rate: 1,
}},
Categories: make([]export.Category, 0, len(categories)),
}
categoryByNameIdx := make(map[string]int64, len(categories))
for _, category := range categories {
categoryByNameIdx[category.Name] = category.ID
shop.Categories = append(shop.Categories, export.Category{
ID: category.ID,
Name: category.Name,
})
}
matcher, err := components.GetDimensionMatcher()
if err != nil {
return fmt.Errorf("getting dimension matcher: %w", err)
}
dimensionDispatcher := dimensionDispatcher{m: matcher}
log.Info().Any("patterns", matcher.GetRegisteredPatterns()).Msg("configured patterns")
const (
reasonNoSize = "no_size"
reasonTooLarge = "too_large"
reasonNoDescription = "no_description"
reasonNoPhoto = "no_photo"
)
skippedByReasons := map[string]int{
reasonNoSize: 0,
reasonTooLarge: 0,
reasonNoDescription: 0,
reasonNoPhoto: 0,
}
addToSkip := func(condition bool, name string, log zerolog.Logger) bool {
if !condition {
return false
}
log.Debug().Str("reason", name).Msg("skipping item")
skippedByReasons[name]++
return condition
}
iter := getItemsIter(ctx, r.GoodsItem())
for iter.Next() {
const maximumAllowedSizes = 160
item := iter.Get()
sublog := log.With().Int64("cart", item.Cart).Logger()
switch {
case addToSkip(item.Description == "", reasonNoDescription, sublog):
continue
case addToSkip(!item.Sizes.AllSizesSet(), reasonNoSize, sublog):
continue
case addToSkip(item.Sizes.GetSum(entity.DimensionKindCentimeter) > maximumAllowedSizes, reasonTooLarge, sublog):
continue
}
offer := h.goodsItemAsOffer(item, categoryByNameIdx, dimensionDispatcher, sublog)
if addToSkip(len(offer.PictureURLs) == 0, reasonNoPhoto, sublog) {
continue
}
shop.Offers = append(shop.Offers, offer)
}
if err = iter.Err(); err != nil {
return fmt.Errorf("iterating over items: %w", err)
}
var f io.WriteCloser
switch path {
case "stdout":
f = os.Stdout
case "stderr":
f = os.Stderr
default:
f, err = os.Create(path)
if err != nil {
return fmt.Errorf("creating file: %w", err)
}
defer func() {
errClose := f.Close()
if err == nil {
err = errClose
return
}
log.Err(errClose).Msg("file closed or not")
}()
}
if limit > 0 {
shop.Offers = shop.Offers[:limit]
}
container := export.YmlContainer{
YmlCatalog: export.YmlCatalog{
Shop: shop,
Date: time.Now(),
},
}
_, err = f.Write([]byte(xml.Header))
if err != nil {
return fmt.Errorf("writing header: %w", err)
}
_, err = f.Write([]byte("<!DOCTYPE yml_catalog SYSTEM \"shops.dtd\">\n"))
enc := xml.NewEncoder(f)
if pretty {
enc.Indent("", " ")
}
log.Info().Int("processed", len(shop.Offers)).Any("skipped", skippedByReasons).Msg("completed")
return enc.Encode(container)
}
func (h exportHandlers) goodsItemAsOffer(in entity.GoodsItem, categoryIDByName map[string]int64, d dimensionDispatcher, log zerolog.Logger) (out export.Offer) {
const defaultType = "vendor.model"
const defaultCurrency = "RUR"
const defaultAvailable = true
const quantityParamName = "Количество на складе «Москва»"
const basePictureURL = "https://eway.elevel.ru"
imgurl := func(path string) string {
return basePictureURL + path
}
categoryID := categoryIDByName[in.Type]
pictureURLs := make([]string, 0, len(in.PhotoURLs))
for _, url := range in.PhotoURLs {
if url == "" {
continue
}
outurl := imgurl(url)
if outurl == basePictureURL {
continue
}
pictureURLs = append(pictureURLs, imgurl(url))
}
params := make([]export.Param, 0, len(in.Parameters))
for k, v := range in.Parameters {
if k == "" || v == "" {
continue
}
if d.isDimensionParam(k) {
continue
}
params = append(params, export.Param{
Name: k,
Value: v,
})
}
params = append(params, export.Param{
Name: quantityParamName,
Value: strconv.Itoa(in.Stock),
})
dimensions := h.formatSizeAsDimensions(in.Sizes, log)
out = export.Offer{
ID: in.Cart,
VendorCode: in.Articul,
Price: int(in.Price),
Model: in.Name,
Vendor: in.Producer,
TypePrefix: in.Name,
Description: in.Description,
Dimensions: dimensions,
CategoryID: categoryID,
PictureURLs: pictureURLs,
Params: params,
Type: defaultType,
CurrencyID: defaultCurrency,
Available: defaultAvailable,
ManufacturerWarrany: true,
}
return out
}
// check cart id 126584
func (exportHandlers) formatSizeAsDimensions(size entity.GoodsItemSize, log zerolog.Logger) string {
const delimeter = "/"
makeFloat := func(d entity.Dimension) string {
value := d.AdjustTo(entity.DimensionKindCentimeter).Value
value = float64(int(value*100)) / 100.0
return strconv.FormatFloat(value, 'f', 1, 64)
}
knownSizes := make([]entity.Dimension, 0, 3)
dimensions := []entity.Dimension{
size.Length,
size.Width,
size.Height,
}
for i, d := range dimensions {
if d.IsZero() {
continue
}
knownSizes = append(knownSizes, dimensions[i])
}
l := makeFloat(size.Length)
w := makeFloat(size.Width)
h := makeFloat(size.Height)
switch len(knownSizes) {
case 3:
// go on
case 2:
var side string
unknownDefaultSize := makeFloat(entity.NewCentimeterDimensionOrEmpty(30))
switch {
case size.Length.IsZero():
side = "length"
l = unknownDefaultSize
case size.Width.IsZero():
side = "width"
w = unknownDefaultSize
case size.Height.IsZero():
side = "height"
h = unknownDefaultSize
}
log.Debug().Str("size", side).Msg("setting to default value")
case 1:
var side string
unknownDefaultSize := makeFloat(entity.NewCentimeterDimensionOrEmpty(30))
switch {
case !size.Length.IsZero():
side = "width"
w = unknownDefaultSize
case !size.Width.IsZero():
side = "length"
l = unknownDefaultSize
case !size.Height.IsZero():
return ""
}
log.Debug().Str("size", side).Msg("setting to default value")
default:
return ""
}
// Output should be the following format:
// length/width/height in centimeters
return strings.Join([]string{
l, w, h,
}, delimeter)
}

View File

@ -1,55 +0,0 @@
package commands
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"time"
"git.loyso.art/frx/eway/cmd/cli/components"
"git.loyso.art/frx/eway/internal/entity"
"github.com/urfave/cli/v3"
)
type empty entity.Empty
type action func(ctx context.Context, c *cli.Command) error
func decorateAction(a action) cli.ActionFunc {
return func(ctx context.Context, c *cli.Command) error {
var data [3]byte
_, _ = rand.Read(data[:])
reqid := hex.EncodeToString(data[:])
log, err := components.GetLogger()
if err != nil {
return fmt.Errorf("getting logger: %w", err)
}
log = log.With().Str("reqid", reqid).Logger()
rctx := log.WithContext(ctx)
start := time.Now()
defer func() {
log.Info().Float64("elapsed", time.Since(start).Seconds()).Msg("command completed")
}()
log.Info().Msg("command execution started")
return a(rctx, c)
}
}
func cmdWithAction(cmd cli.Command, a action) *cli.Command {
if a == nil {
a = notImplementedAction
}
cmd.Action = decorateAction(a)
return &cmd
}
func notImplementedAction(_ context.Context, _ *cli.Command) error {
return entity.ErrNotImplemented
}

View File

@ -1,385 +0,0 @@
package commands
import (
"bufio"
"context"
"encoding/json"
"fmt"
"os"
"strconv"
"strings"
"git.loyso.art/frx/eway/cmd/cli/components"
"git.loyso.art/frx/eway/internal/entity"
"git.loyso.art/frx/eway/internal/matcher"
"github.com/rs/zerolog"
"github.com/urfave/cli/v3"
)
func ItemsCommandTree() *cli.Command {
var h itemsHandlers
return &cli.Command{
Name: "items",
Usage: "Interact with items stored inside db",
Commands: []*cli.Command{
newItemsGetCommand(h),
newItemsCountCommand(h),
newItemsUniqueParamsCommand(h),
newItemsAggregateParametersCommand(h),
newItemsFixSizesCommand(h),
},
}
}
func newItemsGetCommand(h itemsHandlers) *cli.Command {
cmd := cli.Command{
Name: "get",
Usage: "gets goods item by its id",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "id",
Usage: "id of the goods item. Either id or cart-id should be set",
},
&cli.IntFlag{
Name: "cart-id",
Usage: "cart-id of the item. Either cart-id or id should be set",
},
},
}
return cmdWithAction(cmd, h.Get)
}
func newItemsCountCommand(h itemsHandlers) *cli.Command {
cmd := cli.Command{
Name: "count",
Usage: "iterates over collection and counts number of items",
Flags: []cli.Flag{
&cli.StringSliceFlag{
Name: "param-key-match",
Usage: "filters by parameters with AND logic",
},
},
}
return cmdWithAction(cmd, h.Count)
}
func newItemsUniqueParamsCommand(h itemsHandlers) *cli.Command {
cmd := cli.Command{
Name: "unique-params",
Usage: "Show all stored unique param values",
Description: "This command iterates over each item and collect keys of params in a dict and then" +
" print it to the output. It's useful to find all unique parameters",
}
return cmdWithAction(cmd, h.UniqueParameters)
}
func newItemsAggregateParametersCommand(h itemsHandlers) *cli.Command {
cmd := cli.Command{
Name: "aggregate-parameters",
Usage: "Show all values of requested parameters",
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "case-insensitive",
Usage: "Ignores cases of keys",
},
&cli.StringSliceFlag{
Name: "regex",
Usage: "Registers regex to match",
},
&cli.BoolFlag{
Name: "keys-only",
Usage: "prints only keys",
},
},
}
return cmdWithAction(cmd, h.AggregateParameters)
}
func newItemsFixSizesCommand(h itemsHandlers) *cli.Command {
cmd := cli.Command{
Name: "fix-sizes",
Usage: "Iterates over params and sets sizes from parameters",
}
return cmdWithAction(cmd, h.FixSizes)
}
type itemsHandlers struct{}
func (itemsHandlers) Get(ctx context.Context, cmd *cli.Command) error {
r, err := components.GetRepository()
if err != nil {
return fmt.Errorf("getting repository: %w", err)
}
id := cmd.String("id")
cartID := cmd.Int("cart-id")
if id == "" && cartID == 0 {
return cli.Exit("oneof: id or cart-id should be set", 1)
} else if id != "" && cartID != 0 {
return cli.Exit("oneof: id or cart-id should be set", 1)
}
var item entity.GoodsItem
if id != "" {
item, err = r.GoodsItem().Get(ctx, id)
} else {
item, err = r.GoodsItem().GetByCart(ctx, cartID)
}
if err != nil {
return fmt.Errorf("getting item: %w", err)
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
err = enc.Encode(item)
if err != nil {
return fmt.Errorf("encoding item: %w", err)
}
return nil
}
func (itemsHandlers) Count(ctx context.Context, cmd *cli.Command) error {
r, err := components.GetRepository()
if err != nil {
return fmt.Errorf("getting repository: %w", err)
}
filters := cmd.StringSlice("param-key-match")
m := matcher.NewRadix()
patternMapped := make(map[string]empty, len(filters))
if len(filters) == 0 {
m.Register("*")
} else {
for _, f := range filters {
m.Register(f)
}
for _, pattern := range m.Patterns() {
patternMapped[pattern] = empty{}
}
}
var count int
items, err := r.GoodsItem().List(ctx)
if err != nil {
return fmt.Errorf("getting items: %w", err)
}
for _, item := range items {
seenPatterns := map[string]empty{}
for k := range item.Parameters {
pattern := m.MatchByPattern(k)
if pattern == "" {
continue
}
if _, ok := seenPatterns[pattern]; ok {
continue
}
seenPatterns[pattern] = empty{}
}
if len(seenPatterns) == len(patternMapped) {
count++
}
}
return nil
}
func (itemsHandlers) UniqueParameters(ctx context.Context, cmd *cli.Command) error {
repository, err := components.GetRepository()
if err != nil {
return fmt.Errorf("getting repository: %w", err)
}
knownParams := map[string]empty{}
iter, err := repository.GoodsItem().ListIter(ctx, 1)
if err != nil {
return fmt.Errorf("getting list iter: %w", err)
}
for item := range iter {
for k := range item.Parameters {
knownParams[k] = empty{}
}
}
bw := bufio.NewWriter(cmd.Writer)
for paramName := range knownParams {
_, err = bw.WriteString(paramName + "\n")
if err != nil {
return fmt.Errorf("unable to write: %w", err)
}
}
return bw.Flush()
}
func (itemsHandlers) AggregateParameters(ctx context.Context, cmd *cli.Command) error {
repository, err := components.GetRepository()
if err != nil {
return fmt.Errorf("getting repository: %w", err)
}
log, err := components.GetLogger()
if err != nil {
return fmt.Errorf("getting logger: %w", err)
}
params := cmd.Args().Slice()
opts := make([]matcher.RadixOpt, 0, 1)
if cmd.Bool("case-insensitive") {
opts = append(opts, matcher.RadixCaseInsensitive())
}
m := matcher.NewRadix(opts...)
for _, param := range params {
log.Debug().Str("param", param).Msg("registering param")
m.Register(param)
}
for _, regexp := range cmd.StringSlice("regex") {
log.Debug().Str("regexp", regexp).Msg("registering regexp")
m.RegisterRegexp(regexp)
}
requestedValues := make(map[string]map[string]empty, len(params))
requestedValuesByPattern := make(map[string]map[string]empty, len(params))
iter := getItemsIter(ctx, repository.GoodsItem())
for iter.Next() {
item := iter.Get()
for k, v := range item.Parameters {
matchedPattern := m.MatchByPattern(k)
if matchedPattern == "" {
continue
}
values, ok := requestedValues[k]
if !ok {
values = make(map[string]empty)
}
values[v] = empty{}
requestedValues[k] = values
values, ok = requestedValuesByPattern[matchedPattern]
if !ok {
values = map[string]empty{}
}
values[v] = empty{}
requestedValuesByPattern[matchedPattern] = values
}
}
bw := bufio.NewWriter(cmd.Writer)
_, _ = bw.WriteString("Matches:\n")
if cmd.Bool("keys-only") {
for k := range requestedValues {
_, _ = bw.WriteString(k + "\n")
}
} else {
for k, v := range requestedValues {
_, _ = bw.WriteString(k + ": ")
values := make([]string, 0, len(v))
for item := range v {
values = append(values, strconv.Quote(item))
}
valuesStr := "[" + strings.Join(values, ",") + "]\n"
_, _ = bw.WriteString(valuesStr)
}
}
_, _ = bw.WriteString("\nPatterns:\n")
for _, pattern := range m.Patterns() {
_, _ = bw.WriteString(pattern + "\n")
}
return bw.Flush()
}
func (itemsHandlers) FixSizes(ctx context.Context, cmd *cli.Command) error {
repository, err := components.GetRepository()
if err != nil {
return fmt.Errorf("getting repository: %w", err)
}
matcher, err := components.GetDimensionMatcher()
if err != nil {
return fmt.Errorf("getting dimension matcher: %w", err)
}
dimensionDispatcher := dimensionDispatcher{m: matcher}
log := zerolog.Ctx(ctx)
toUpdate := make([]entity.GoodsItem, 0, 20_000)
bus := getItemsIter(ctx, repository.GoodsItem())
for bus.Next() {
item := bus.Get()
var valueBeenUpdated bool
for key, value := range item.Parameters {
trimmedValue := strings.TrimSpace(value)
trimmedKey := strings.TrimSpace(key)
if trimmedKey != key || trimmedValue != value {
log.Warn().
Str("old_key", key).
Str("new_key", trimmedKey).
Str("old_value", value).
Str("new_value", trimmedValue).
Msg("found mismatch")
delete(item.Parameters, key)
key = trimmedKey
value = trimmedValue
item.Parameters[key] = value
valueBeenUpdated = true
}
updateValue := strings.HasSuffix(key, ":")
if updateValue {
key = strings.TrimSuffix(key, ":")
item.Parameters[key] = value
delete(item.Parameters, key+":")
valueBeenUpdated = true
}
if dimensionDispatcher.applyDimensionValue(ctx, key, value, &item.Sizes) {
valueBeenUpdated = true
log.Debug().Str("key", key).Any("sizes", item.Sizes).Msg("been updated")
}
}
var updated bool
oldSizes := item.Sizes
item.Sizes, updated = entity.FixupSizes(oldSizes)
if updated {
log.Info().Int64("cart", item.Cart).Any("old", oldSizes).Any("new", item.Sizes).Msg("sizes been fixed")
}
valueBeenUpdated = valueBeenUpdated || updated
if valueBeenUpdated {
toUpdate = append(toUpdate, item)
}
}
if bus.Err() != nil {
return fmt.Errorf("iterating: %w", bus.Err())
}
_, err = repository.GoodsItem().UpsertMany(ctx, toUpdate...)
if err != nil {
return fmt.Errorf("updating items: %w", err)
}
log.Info().Int("count", len(toUpdate)).Msg("updated items")
return nil
}

View File

@ -9,7 +9,6 @@ import (
"time" "time"
"git.loyso.art/frx/eway/internal/config" "git.loyso.art/frx/eway/internal/config"
"git.loyso.art/frx/eway/internal/dimension"
"git.loyso.art/frx/eway/internal/interconnect/eway" "git.loyso.art/frx/eway/internal/interconnect/eway"
"git.loyso.art/frx/eway/internal/storage" "git.loyso.art/frx/eway/internal/storage"
xbadger "git.loyso.art/frx/eway/internal/storage/badger" xbadger "git.loyso.art/frx/eway/internal/storage/badger"
@ -37,16 +36,7 @@ func GetRepository() (storage.Repository, error) {
} }
func GetLogger() (zerolog.Logger, error) { func GetLogger() (zerolog.Logger, error) {
log, err := do.Invoke[*loggerAdapter](diInjector) return do.Invoke[zerolog.Logger](diInjector)
if err != nil {
return zerolog.Nop(), err
}
return log.entity.log, nil
}
func GetDimensionMatcher() (*dimension.Matcher, error) {
return do.Invoke[*dimension.Matcher](diInjector)
} }
func SetupDI(ctx context.Context, cfgpath string, verbose bool, logAsJSON bool) error { func SetupDI(ctx context.Context, cfgpath string, verbose bool, logAsJSON bool) error {
@ -62,34 +52,14 @@ func SetupDI(ctx context.Context, cfgpath string, verbose bool, logAsJSON bool)
diInjector = do.New() diInjector = do.New()
do.Provide(diInjector, func(i *do.Injector) (*loggerAdapter, error) { do.Provide(diInjector, func(i *do.Injector) (zerolog.Logger, error) {
tsSet := func(wr *zerolog.ConsoleWriter) { tsSet := func(wr *zerolog.ConsoleWriter) {
wr.TimeFormat = time.RFC3339 wr.TimeFormat = time.RFC3339
} }
var outfile *os.File var writer io.Writer = zerolog.NewConsoleWriter(tsSet)
var output io.Writer
switch cfg.Log.Output {
case "", "stdout":
output = os.Stdout
case "stderr":
output = os.Stderr
default:
outfile, err = os.OpenFile(cfg.Log.Output, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666)
if err != nil {
return nil, fmt.Errorf("creating file for logging: %w", err)
}
output = zerolog.SyncWriter(outfile)
}
var writer io.Writer
if logAsJSON { if logAsJSON {
writer = output writer = os.Stdout
} else {
writer = zerolog.NewConsoleWriter(tsSet, func(w *zerolog.ConsoleWriter) {
w.Out = output
})
} }
log := zerolog. log := zerolog.
@ -98,20 +68,12 @@ func SetupDI(ctx context.Context, cfgpath string, verbose bool, logAsJSON bool)
Timestamp(). Timestamp().
Str("app", "converter"). Str("app", "converter").
Logger() Logger()
if verbose { if verbose {
log = log.Level(zerolog.DebugLevel)
} else { return log.Level(zerolog.DebugLevel), nil
log = log.Level(zerolog.InfoLevel)
} }
out := &logger{ return log.Level(zerolog.InfoLevel), nil
log: log,
underlyingFile: outfile,
}
return &loggerAdapter{
entity: out,
}, nil
}) })
do.Provide[eway.Client](diInjector, func(i *do.Injector) (eway.Client, error) { do.Provide[eway.Client](diInjector, func(i *do.Injector) (eway.Client, error) {
@ -153,12 +115,6 @@ func SetupDI(ctx context.Context, cfgpath string, verbose bool, logAsJSON bool)
return out, nil return out, nil
}) })
do.Provide[*dimension.Matcher](diInjector, func(i *do.Injector) (*dimension.Matcher, error) {
matcher := dimension.New(cfg.DimensionMatcher)
return matcher, nil
})
return nil return nil
} }
@ -183,7 +139,6 @@ type settings struct {
Badger config.Badger `toml:"badger"` Badger config.Badger `toml:"badger"`
Log config.Log `toml:"log"` Log config.Log `toml:"log"`
Eway config.Eway `toml:"eway"` Eway config.Eway `toml:"eway"`
DimensionMatcher config.DimensionMatcher `toml:"dimension_matcher"`
} }
func parseSettings(cfgpath string) (cfg settings, err error) { func parseSettings(cfgpath string) (cfg settings, err error) {
@ -195,19 +150,6 @@ func parseSettings(cfgpath string) (cfg settings, err error) {
return cfg, nil return cfg, nil
} }
type logger struct {
log zerolog.Logger
underlyingFile *os.File
}
func (l *logger) Close() error {
if l.underlyingFile == nil {
return nil
}
return l.underlyingFile.Close()
}
type entityCloserAdapter[T io.Closer] struct { type entityCloserAdapter[T io.Closer] struct {
entity T entity T
} }
@ -218,4 +160,3 @@ func (a entityCloserAdapter[T]) Shutdown() error {
type storageRepositoryAdapter entityCloserAdapter[storage.Repository] type storageRepositoryAdapter entityCloserAdapter[storage.Repository]
type badgerDBAdapter entityCloserAdapter[*badger.DB] type badgerDBAdapter entityCloserAdapter[*badger.DB]
type loggerAdapter entityCloserAdapter[*logger]

View File

@ -1,78 +0,0 @@
package main
import (
"context"
"strings"
"git.loyso.art/frx/eway/internal/entity"
"git.loyso.art/frx/eway/internal/matcher"
"github.com/rs/zerolog"
)
type dimensionDispatcher struct {
heigth matcher.Unit
width matcher.Unit
length matcher.Unit
}
func (d dimensionDispatcher) isDimensionParam(value string) bool {
return d.heigth.Match(value) || d.width.Match(value) || d.length.Match(value)
}
func (d dimensionDispatcher) dispatch(ctx context.Context, key, value string, in *entity.GoodsItemSize) (updated bool) {
if !d.isDimensionParam(key) {
return false
}
log := zerolog.Ctx(ctx).With().Str("key", key).Str("value", value).Logger()
if strings.Contains(value, "/") {
dimensionValues := strings.Split(value, "/")
for _, dv := range dimensionValues {
updated = updated || d.dispatch(ctx, key, dv, in)
}
} else {
out, err := entity.ParseDimention(value, entity.DimensionLocalRU)
if err != nil {
log.Warn().Err(err).Msg("unable to parse key, skipping")
return false
}
out = out.AdjustTo(entity.DimensionKindCentimeter)
updated = true
switch {
case d.heigth.Match(key):
in.Height = out
case d.width.Match(key):
in.Width = out
case d.length.Match(key):
in.Length = out
default:
log.Error().Str("key", key).Msg("unable to find proper matcher")
updated = false
}
}
return updated
}
func makeDefaultDimensionDispatcher() dimensionDispatcher {
h := matcher.NewRadix(matcher.RadixCaseInsensitive())
h.Register("Высота")
h.Register("Высота/*")
w := matcher.NewRadix(matcher.RadixCaseInsensitive())
w.Register("Ширина")
w.Register("Ширина/*")
l := matcher.NewRadix(matcher.RadixCaseInsensitive())
l.Register("Длина")
l.Register("Длина/*")
l.Register("Общ. длина")
return dimensionDispatcher{
heigth: h,
width: w,
length: l,
}
}

View File

@ -4,35 +4,36 @@ import (
"bufio" "bufio"
"context" "context"
"crypto/rand" "crypto/rand"
"encoding/binary"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"encoding/xml"
"errors" "errors"
"fmt" "fmt"
"io" "io"
"math/big" "math/big"
"net"
"net/http"
"os" "os"
"os/signal" "os/signal"
"strconv"
"strings"
"syscall" "syscall"
"time" "time"
rooteway "git.loyso.art/frx/eway" rooteway "git.loyso.art/frx/eway"
"git.loyso.art/frx/eway/cmd/cli/commands"
"git.loyso.art/frx/eway/cmd/cli/components" "git.loyso.art/frx/eway/cmd/cli/components"
"git.loyso.art/frx/eway/internal/crypto" "git.loyso.art/frx/eway/internal/crypto"
"git.loyso.art/frx/eway/internal/entity" "git.loyso.art/frx/eway/internal/entity"
"git.loyso.art/frx/eway/internal/export"
"git.loyso.art/frx/eway/internal/interconnect/eway" "git.loyso.art/frx/eway/internal/interconnect/eway"
"git.loyso.art/frx/eway/internal/matcher"
"github.com/rodaine/table" "github.com/rodaine/table"
"github.com/rs/zerolog" "github.com/rs/zerolog"
"github.com/urfave/cli/v3" "github.com/urfave/cli/v3"
) )
const (
defaultItemType = "Электрика"
)
type empty entity.Empty
func main() { func main() {
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer func() { defer func() {
@ -64,10 +65,6 @@ func setupDI() cli.BeforeFunc {
} }
cfgpath := cmd.String("config") cfgpath := cmd.String("config")
if cfgpath == "" {
return errors.New("no config path provided")
}
debugLevel := cmd.Bool("verbose") debugLevel := cmd.Bool("verbose")
jsonFormat := cmd.Bool("json") jsonFormat := cmd.Bool("json")
@ -103,9 +100,6 @@ func setupCLI() *cli.Command {
Name: "config", Name: "config",
Usage: "path to config in TOML format", Usage: "path to config in TOML format",
Value: "config.toml", Value: "config.toml",
Sources: cli.NewValueSourceChain(
cli.EnvVar("EWAY_CONFIG"),
),
TakesFile: true, TakesFile: true,
}, },
&cli.BoolFlag{ &cli.BoolFlag{
@ -130,13 +124,12 @@ func setupCLI() *cli.Command {
After: releaseDI(), After: releaseDI(),
Commands: []*cli.Command{ Commands: []*cli.Command{
commands.CategoriesCommandTree(), newAppCmd(),
commands.ItemsCommandTree(),
commands.ExportCommandTree(),
newCryptoCmd(), newCryptoCmd(),
newParseCmd(), newParseCmd(),
newImportCmd(), newImportCmd(),
newExportCmd(),
newViewCmd(),
}, },
} }
@ -169,6 +162,52 @@ func newCryptoEncyptCmd() *cli.Command {
} }
} }
func newAppCmd() *cli.Command {
return &cli.Command{
Name: "app",
Usage: "commands for running different applications",
Commands: []*cli.Command{
newAppYmlExporter(),
},
}
}
func newAppYmlExporter() *cli.Command {
return &cli.Command{
Name: "ymlexporter",
Usage: "server on given port a http api for accessing yml catalog",
Flags: []cli.Flag{
&cli.IntFlag{
Name: "port",
Usage: "Port for accepting connections",
Value: 9448,
Validator: func(i int64) error {
if i > 1<<16 || i == 0 {
return cli.Exit("bad port value, allowed values should not exceed 65535", 1)
}
return nil
},
},
&cli.StringFlag{
Name: "src",
TakesFile: true,
Usage: "Source to catalog. Should be a valid xml file",
Value: "yml_catalog.xml",
Validator: func(s string) error {
_, err := os.Stat(s)
if err != nil {
return err
}
return nil
},
},
},
Action: appYMLExporterAction,
}
}
func newParseCmd() *cli.Command { func newParseCmd() *cli.Command {
return &cli.Command{ return &cli.Command{
Name: "parse", Name: "parse",
@ -269,6 +308,430 @@ func newImportFromFileCmd() *cli.Command {
} }
} }
func newExportCmd() *cli.Command {
return &cli.Command{
Name: "export",
Usage: "category for exporting stored data",
Commands: []*cli.Command{
newExportYMLCatalogCmd(),
},
}
}
func newExportYMLCatalogCmd() *cli.Command {
return &cli.Command{
Name: "yml_catalog",
Usage: "export data into as yml_catalog in xml format",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "out",
Aliases: []string{"o"},
Usage: "destination path",
Value: "yml_catalog.xml",
TakesFile: true,
},
&cli.IntFlag{
Name: "limit",
Usage: "limits output offers to required value",
},
&cli.BoolFlag{
Name: "pretty",
Usage: "prettify output",
},
},
Action: decorateAction(exportYMLCatalogAction),
}
}
func newViewCmd() *cli.Command {
return &cli.Command{
Name: "view",
Usage: "Set of commands to view the data inside db",
Commands: []*cli.Command{
newViewCategoriesCmd(),
newViewItemsCmd(),
},
}
}
func newViewCategoriesCmd() *cli.Command {
return &cli.Command{
Name: "categories",
Usage: "Set of commands to work with categories",
Commands: []*cli.Command{
newViewCategoriesListCmd(),
},
}
}
func newViewItemsCmd() *cli.Command {
return &cli.Command{
Name: "items",
Usage: "Set of command to work with items",
Commands: []*cli.Command{
newViewItemsGetCmd(),
newViewItemsCountCmd(),
newViewItemsUniqueParams(),
newViewItemsParamsKnownValues(),
},
}
}
func newViewItemsUniqueParams() *cli.Command {
return &cli.Command{
Name: "unique-params",
Usage: "Show all stored unique param values",
Description: "This command iterates over each item and collect keys of params in a dict and then" +
" print it to the output. It's useful to find all unique parameters",
Action: decorateAction(viewItemsUniqueParamsAction),
}
}
func newViewItemsParamsKnownValues() *cli.Command {
return &cli.Command{
Name: "params-values",
Usage: "Show all values of requested parameters",
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "case-insensitive",
Usage: "Ignores cases of keys",
},
&cli.StringSliceFlag{
Name: "regex",
Usage: "Registers regex to match",
},
&cli.BoolFlag{
Name: "keys-only",
Usage: "prints only keys",
},
},
Action: decorateAction(viewItemsParamsKnownValuesAction),
}
}
func newViewItemsCountCmd() *cli.Command {
return &cli.Command{
Name: "count",
Usage: "iterates over collection and counts number of items",
Action: decorateAction(viewItemsCountAction),
}
}
func newViewItemsGetCmd() *cli.Command {
return &cli.Command{
Name: "get",
Usage: "gets goods item by its id",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "id",
Usage: "id of the goods item. Either id or cart-id should be set",
},
&cli.IntFlag{
Name: "cart-id",
Usage: "cart-id of the item. Either cart-id or id should be set",
},
},
Action: decorateAction(viewItemsGetAction),
}
}
func newViewCategoriesListCmd() *cli.Command {
return &cli.Command{
Name: "list",
Usage: "lists stored categories stored in database",
Flags: []cli.Flag{
&cli.IntFlag{
Name: "limit",
Usage: "limits output to selected items",
Value: 20,
},
&cli.IntFlag{
Name: "page",
Usage: "in case of limit, selects page",
Value: 0,
},
&cli.BoolFlag{
Name: "with-total",
Usage: "prints total count of categories",
},
},
Action: decorateAction(viewCategoriesListAction),
}
}
func viewItemsGetAction(ctx context.Context, c *cli.Command) error {
r, err := components.GetRepository()
if err != nil {
return fmt.Errorf("getting repository: %w", err)
}
id := c.String("id")
cartID := c.Int("cart-id")
if id == "" && cartID == 0 {
return cli.Exit("oneof: id or cart-id should be set", 1)
} else if id != "" && cartID != 0 {
return cli.Exit("oneof: id or cart-id should be set", 1)
}
var item entity.GoodsItem
if id != "" {
item, err = r.GoodsItem().Get(ctx, id)
} else {
item, err = r.GoodsItem().GetByCart(ctx, cartID)
}
if err != nil {
return fmt.Errorf("getting item: %w", err)
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
err = enc.Encode(item)
if err != nil {
return fmt.Errorf("encoding item: %w", err)
}
return nil
}
func viewItemsUniqueParamsAction(ctx context.Context, c *cli.Command) error {
repository, err := components.GetRepository()
if err != nil {
return fmt.Errorf("getting repository: %w", err)
}
knownParams := map[string]struct{}{}
iter, err := repository.GoodsItem().ListIter(ctx, 1)
if err != nil {
return fmt.Errorf("getting list iter: %w", err)
}
for item := range iter {
for k := range item.Parameters {
knownParams[k] = struct{}{}
}
}
bw := bufio.NewWriter(c.Writer)
for paramName := range knownParams {
_, err = bw.WriteString(paramName + "\n")
if err != nil {
return fmt.Errorf("unable to write: %w", err)
}
}
return bw.Flush()
}
type chanIter[T any] struct {
in <-chan T
err error
next T
}
func (i *chanIter[T]) Next() (ok bool) {
if i.err != nil {
return false
}
i.next, ok = <-i.in
if !ok {
i.err = errors.New("channel closed")
}
return ok
}
func (i *chanIter[T]) Get() T {
return i.next
}
func (i *chanIter[T]) Err() error {
return i.err
}
func (i *chanIter[T]) Close() {
for range i.in {
}
}
func getItemsIter(ctx context.Context, r entity.GoodsItemRepository) *chanIter[entity.GoodsItem] {
in, err := r.ListIter(ctx, 3)
return &chanIter[entity.GoodsItem]{
in: in,
err: err,
}
}
func viewItemsParamsKnownValuesAction(ctx context.Context, c *cli.Command) error {
repository, err := components.GetRepository()
if err != nil {
return fmt.Errorf("getting repository: %w", err)
}
log, err := components.GetLogger()
if err != nil {
return fmt.Errorf("getting logger: %w", err)
}
params := c.Args().Slice()
opts := make([]matcher.RadixOpt, 0, 1)
if c.Bool("case-insensitive") {
opts = append(opts, matcher.RadixCaseInsensitive())
}
m := matcher.NewRadix(opts...)
for _, param := range params {
log.Debug().Str("param", param).Msg("registering param")
m.Register(param)
}
for _, regexp := range c.StringSlice("regex") {
log.Debug().Str("regexp", regexp).Msg("registering regexp")
m.RegisterRegexp(regexp)
}
requestedValues := make(map[string]map[string]struct{}, len(params))
requestedValuesByPattern := make(map[string]map[string]struct{}, len(params))
iter := getItemsIter(ctx, repository.GoodsItem())
for iter.Next() {
item := iter.Get()
for k, v := range item.Parameters {
matchedPattern := m.MatchByPattern(k)
if matchedPattern == "" {
continue
}
values, ok := requestedValues[k]
if !ok {
values = make(map[string]struct{})
}
values[v] = struct{}{}
requestedValues[k] = values
values, ok = requestedValuesByPattern[matchedPattern]
if !ok {
values = map[string]struct{}{}
}
values[v] = struct{}{}
requestedValuesByPattern[matchedPattern] = values
}
}
bw := bufio.NewWriter(c.Writer)
_, _ = bw.WriteString("Matches:\n")
if c.Bool("keys-only") {
for k := range requestedValues {
_, _ = bw.WriteString(k + "\n")
}
} else {
for k, v := range requestedValues {
_, _ = bw.WriteString(k + ": ")
values := make([]string, 0, len(v))
for item := range v {
values = append(values, strconv.Quote(item))
}
valuesStr := "[" + strings.Join(values, ",") + "]\n"
_, _ = bw.WriteString(valuesStr)
}
}
_, _ = bw.WriteString("\nPatterns:\n")
for _, pattern := range m.Patterns() {
_, _ = bw.WriteString(pattern + "\n")
}
return bw.Flush()
}
func viewItemsCountAction(ctx context.Context, c *cli.Command) error {
r, err := components.GetRepository()
if err != nil {
return fmt.Errorf("getting repository: %w", err)
}
itemChan, err := r.GoodsItem().ListIter(ctx, 10)
if err != nil {
if !errors.Is(err, entity.ErrNotImplemented) {
return fmt.Errorf("getting list iter: %w", err)
}
}
var count int
if err == nil {
var done bool
for !done {
select {
case _, ok := <-itemChan:
if !ok {
done = true
continue
}
count++
case <-ctx.Done():
return ctx.Err()
}
}
}
items, err := r.GoodsItem().List(ctx)
if err != nil {
return fmt.Errorf("getting list: %w", err)
}
zerolog.Ctx(ctx).Info().Int("count", count).Int("list_count", len(items)).Msg("read all items")
return nil
}
func viewCategoriesListAction(ctx context.Context, c *cli.Command) error {
r, err := components.GetRepository()
if err != nil {
return fmt.Errorf("getting repository: %w", err)
}
categories, err := r.Category().List(ctx)
if err != nil {
return fmt.Errorf("listing categories: %w", err)
}
limit := int(c.Int("limit"))
page := int(c.Int("page"))
total := len(categories)
if page == 0 {
page = 1
}
if limit > 0 {
offset := (page - 1) * limit
if offset > len(categories) {
offset = len(categories) - 1
}
limit = offset + limit
if limit > len(categories) {
limit = len(categories)
}
categories = categories[offset:limit]
}
tbl := table.New("ID", "Name")
for _, category := range categories {
if category.ID == 0 && category.Name == "" {
continue
}
tbl.AddRow(category.ID, category.Name)
}
tbl.Print()
if c.Bool("with-total") {
zerolog.Ctx(ctx).Info().Int("count", total).Msg("total categories stats")
}
return nil
}
func importFromFileAction(ctx context.Context, c *cli.Command) error { func importFromFileAction(ctx context.Context, c *cli.Command) error {
const maxBatch = 2000 const maxBatch = 2000
@ -314,14 +777,14 @@ func importFromFileAction(ctx context.Context, c *cli.Command) error {
failedToInsert int failedToInsert int
) )
seenCategories := make(map[string]empty) seenCategories := make(map[string]struct{})
categories, err := r.Category().List(ctx) categories, err := r.Category().List(ctx)
if err != nil { if err != nil {
return fmt.Errorf("listing categories: %w", err) return fmt.Errorf("listing categories: %w", err)
} }
for _, category := range categories { for _, category := range categories {
seenCategories[category.Name] = empty{} seenCategories[category.Name] = struct{}{}
} }
bfile := bufio.NewReader(productsFile) bfile := bufio.NewReader(productsFile)
@ -361,7 +824,7 @@ func importFromFileAction(ctx context.Context, c *cli.Command) error {
return fmt.Errorf("unable to create new category: %w", err) return fmt.Errorf("unable to create new category: %w", err)
} }
log.Debug().Any("category", goodsItem.Type).Msg("inserted new category") log.Debug().Any("category", goodsItem.Type).Msg("inserted new category")
seenCategories[goodsItem.Type] = empty{} seenCategories[goodsItem.Type] = struct{}{}
} }
log.Debug().Int("count", len(goodsItems)).Int("failed", failedToInsert).Msg("preparing to upload") log.Debug().Int("count", len(goodsItems)).Int("failed", failedToInsert).Msg("preparing to upload")
@ -399,7 +862,7 @@ func decorateAction(a action) cli.ActionFunc {
} }
log = log.With().Str("reqid", reqid).Logger() log = log.With().Str("reqid", reqid).Logger()
rctx := log.WithContext(ctx) ctx = log.WithContext(ctx)
start := time.Now() start := time.Now()
defer func() { defer func() {
@ -407,10 +870,97 @@ func decorateAction(a action) cli.ActionFunc {
}() }()
log.Info().Msg("command execution started") log.Info().Msg("command execution started")
return a(rctx, c) return a(ctx, c)
} }
} }
func exportYMLCatalogAction(ctx context.Context, c *cli.Command) error {
path := c.String("out")
limit := c.Int("limit")
pretty := c.Bool("pretty")
log, err := components.GetLogger()
if err != nil {
return fmt.Errorf("getting logger: %w", err)
}
r, err := components.GetRepository()
if err != nil {
return fmt.Errorf("getting repository: %w", err)
}
categories, err := r.Category().List(ctx)
if err != nil {
return fmt.Errorf("listing categories: %w", err)
}
shop := export.Shop{
Currencies: []export.Currency{{
ID: "RUR",
Rate: 1,
}},
Categories: make([]export.Category, 0, len(categories)),
}
categoryByNameIdx := make(map[string]int64, len(categories))
for _, category := range categories {
categoryByNameIdx[category.Name] = category.ID
shop.Categories = append(shop.Categories, export.Category{
ID: category.ID,
Name: category.Name,
})
}
itemsIter, err := r.GoodsItem().ListIter(ctx, 1)
if err != nil {
return fmt.Errorf("getting items iterator: %w", err)
}
for item := range itemsIter {
offer := goodsItemAsOffer(item, categoryByNameIdx)
shop.Offers = append(shop.Offers, offer)
}
if err = ctx.Err(); err != nil {
return err
}
f, err := os.Create(path)
if err != nil {
return fmt.Errorf("creating file: %w", err)
}
defer func() {
errClose := f.Close()
if err == nil {
err = errClose
return
}
log.Err(errClose).Msg("file closed or not")
}()
if limit > 0 {
shop.Offers = shop.Offers[:limit]
}
container := export.YmlContainer{
YmlCatalog: export.YmlCatalog{
Shop: shop,
Date: time.Now(),
},
}
_, err = f.Write([]byte(xml.Header))
if err != nil {
return fmt.Errorf("writing header: %w", err)
}
_, err = f.Write([]byte("<!DOCTYPE yml_catalog SYSTEM \"shops.dtd\">\n"))
enc := xml.NewEncoder(f)
if pretty {
enc.Indent("", " ")
}
return enc.Encode(container)
}
func parseEwayGetAction(ctx context.Context, cmd *cli.Command) error { func parseEwayGetAction(ctx context.Context, cmd *cli.Command) error {
cartID := cmd.Int("cart") cartID := cmd.Int("cart")
@ -570,16 +1120,16 @@ func parseEwayDumpAction(ctx context.Context, cmd *cli.Command) error {
goodsItems := make([]entity.GoodsItem, 0, batchSize) goodsItems := make([]entity.GoodsItem, 0, batchSize)
productIDs := make([]int, 0, batchSize) productIDs := make([]int, 0, batchSize)
knownCategories := make(map[string]empty) knownCategories := make(map[string]struct{})
err = entity.IterWithErr(repository.Category().List(ctx)).Do(func(c entity.Category) error { err = entity.IterWithErr(repository.Category().List(ctx)).Do(func(c entity.Category) error {
knownCategories[c.Name] = empty{} knownCategories[c.Name] = struct{}{}
return nil return nil
}) })
if err != nil { if err != nil {
return fmt.Errorf("filling known categories: %w", err) return fmt.Errorf("filling known categories: %w", err)
} }
itemsUpdated := make(map[string]empty, len(seenItems)) itemsUpdated := make(map[string]struct{}, len(seenItems))
stats := struct { stats := struct {
fetchedInfo int fetchedInfo int
handledAll int handledAll int
@ -587,8 +1137,6 @@ func parseEwayDumpAction(ctx context.Context, cmd *cli.Command) error {
skippedItem int skippedItem int
}{} }{}
dimensionDispatcher := makeDefaultDimensionDispatcher()
startFrom := time.Now() startFrom := time.Now()
for { for {
select { select {
@ -605,9 +1153,7 @@ func parseEwayDumpAction(ctx context.Context, cmd *cli.Command) error {
RemmantsAtleast: 5, RemmantsAtleast: 5,
}) })
if err != nil { if err != nil {
logger.Warn().Err(err).Msg("unable to get items from catalog") return fmt.Errorf("getting next goods batch: %w", err)
continue
} }
productIDs = productIDs[:0] productIDs = productIDs[:0]
@ -617,7 +1163,6 @@ func parseEwayDumpAction(ctx context.Context, cmd *cli.Command) error {
remnants, err := client.GetGoodsRemnants(ctx, productIDs) remnants, err := client.GetGoodsRemnants(ctx, productIDs)
if err != nil { if err != nil {
logger.Warn().Err(err).Msg("unable to get goods remnants")
return fmt.Errorf("getting goods remnants: %w", err) return fmt.Errorf("getting goods remnants: %w", err)
} }
@ -627,9 +1172,8 @@ func parseEwayDumpAction(ctx context.Context, cmd *cli.Command) error {
seenItem := seenItems[item.SKU] seenItem := seenItems[item.SKU]
if time.Since(seenItem.CreatedAt) < time.Hour*24 { if time.Since(seenItem.CreatedAt) < time.Hour*24 {
logger.Debug().Str("sku", item.SKU).Msg("skipping item because it's too fresh") logger.Debug().Str("sku", item.SKU).Msg("skipping item because it's too fresh")
stats.skippedItem++ stats.skippedItem++
itemsUpdated[item.SKU] = empty{} itemsUpdated[item.SKU] = struct{}{}
continue continue
} }
@ -641,30 +1185,23 @@ func parseEwayDumpAction(ctx context.Context, cmd *cli.Command) error {
} else { } else {
pi, err = client.GetProductInfo(ctx, int64(item.Cart)) pi, err = client.GetProductInfo(ctx, int64(item.Cart))
if err != nil { if err != nil {
logger.Warn().Err(err).Msg("unable to get product info, skipping") return fmt.Errorf("getting product info: %w", err)
continue
} }
stats.fetchedInfo++ stats.fetchedInfo++
} }
goodsItem, err := entity.MakeGoodsItem(item, remnants, pi) goodsItem, err := entity.MakeGoodsItem(item, remnants, pi)
if err != nil { if err != nil {
logger.Err(err).Any("item", item).Msg("unable to make goods item") logger.Warn().Err(err).Any("item", item).Msg("unable to make goods item")
continue continue
} }
for key, value := range goodsItem.Parameters { itemsUpdated[goodsItem.Articul] = struct{}{}
dimensionDispatcher.dispatch(ctx, key, value, &goodsItem.Sizes)
}
itemsUpdated[goodsItem.Articul] = empty{}
stats.handledAll++ stats.handledAll++
goodsItems = append(goodsItems, goodsItem) goodsItems = append(goodsItems, goodsItem)
if goodsItem.Type == "" { if goodsItem.Type == "" {
logger.Warn().Int64("cart_id", goodsItem.Cart).Msg("found item without category, setting default type") continue
goodsItem.Type = defaultItemType
} }
if _, ok := knownCategories[goodsItem.Type]; ok { if _, ok := knownCategories[goodsItem.Type]; ok {
@ -676,12 +1213,12 @@ func parseEwayDumpAction(ctx context.Context, cmd *cli.Command) error {
return fmt.Errorf("creating category: %w", err) return fmt.Errorf("creating category: %w", err)
} }
logger.Info(). logger.Debug().
Str("name", category.Name). Str("name", category.Name).
Int64("id", category.ID). Int64("id", category.ID).
Msg("created new category") Msg("created new category")
knownCategories[goodsItem.Type] = empty{} knownCategories[goodsItem.Type] = struct{}{}
} }
_, err = repository.GoodsItem().UpsertMany(ctx, goodsItems...) _, err = repository.GoodsItem().UpsertMany(ctx, goodsItems...)
@ -691,14 +1228,14 @@ func parseEwayDumpAction(ctx context.Context, cmd *cli.Command) error {
progressFloat := float64(start) / float64(total) progressFloat := float64(start) / float64(total)
progress := big.NewFloat(progressFloat).Text('f', 3) progress := big.NewFloat(progressFloat).Text('f', 3)
elapsed := time.Since(startFrom).Seconds()
elapsed := time.Since(startFrom).Seconds()
var left int var left int
if progressFloat != 0 { if progressFloat != 0 {
left = int(((1 - progressFloat) / progressFloat) * elapsed) left = int(((1 - progressFloat) / progressFloat) * elapsed)
} }
logger.Info(). logger.Debug().
Int("from", start). Int("from", start).
Int("to", start+batchSize). Int("to", start+batchSize).
Int("total", total). Int("total", total).
@ -729,15 +1266,140 @@ func parseEwayDumpAction(ctx context.Context, cmd *cli.Command) error {
for k := range seenItems { for k := range seenItems {
_, err := repository.GoodsItem().Delete(ctx, k) _, err := repository.GoodsItem().Delete(ctx, k)
if err != nil { if err != nil {
if errors.Is(err, entity.ErrNotFound) {
continue
}
logger.Warn().Err(err).Str("sku", k).Msg("unable to delete item") logger.Warn().Err(err).Str("sku", k).Msg("unable to delete item")
continue continue
} }
logger.Info().Str("sku", k).Msg("deleted item") logger.Debug().Str("sku", k).Msg("deleted item")
}
return nil
}
func goodsItemAsOffer(in entity.GoodsItem, categoryIDByName map[string]int64) (out export.Offer) {
const defaultType = "vendor.model"
const defaultCurrency = "RUR"
const defaultAvailable = true
const quantityParamName = "Количество на складе «Москва»"
const basePictureURL = "https://eway.elevel.ru"
imgurl := func(path string) string {
return basePictureURL + path
}
categoryID := categoryIDByName[in.Type]
pictureURLs := make([]string, 0, len(in.PhotoURLs))
for _, url := range in.PhotoURLs {
pictureURLs = append(pictureURLs, imgurl(url))
}
params := make([]export.Param, len(in.Parameters))
for k, v := range in.Parameters {
params = append(params, export.Param{
Name: k,
Value: v,
})
}
params = append(params, export.Param{
Name: quantityParamName,
Value: strconv.Itoa(in.Stock),
})
out = export.Offer{
ID: in.Cart,
VendorCode: in.Articul,
Price: int(in.TariffPrice),
CategoryID: categoryID,
PictureURLs: pictureURLs,
Model: in.Name,
Vendor: in.Producer,
TypePrefix: in.Name,
Description: in.Description,
ManufacturerWarrany: true,
Params: params,
Type: defaultType,
CurrencyID: defaultCurrency,
Available: defaultAvailable,
}
return out
}
func appYMLExporterAction(ctx context.Context, cmd *cli.Command) error {
port := cmd.Int("port")
src := cmd.String("src")
log, err := components.GetLogger()
if err != nil {
return fmt.Errorf("getting logger: %w", err)
}
mw := func(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
remoteAddr := r.RemoteAddr
xff := r.Header.Get("x-forwarded-for")
method := r.Method
ua := r.UserAgent()
xreqid := r.Header.Get("x-request-id")
if xreqid == "" {
const reqsize = 4
var reqidRaw [reqsize]byte
_, _ = rand.Read(reqidRaw[:])
value := binary.BigEndian.Uint32(reqidRaw[:])
xreqid = fmt.Sprintf("%x", value)
w.Header().Set("x-request-id", xreqid)
}
start := time.Now()
xlog := log.With().Str("request_id", xreqid).Logger()
xlog.Debug().
Str("path", path).
Str("remote_addr", remoteAddr).
Str("xff", xff).
Str("method", method).
Str("user_agent", ua).
Msg("incoming request")
xctx := xlog.WithContext(r.Context())
r = r.WithContext(xctx)
next(w, r)
elapsed := time.Since(start).Truncate(time.Millisecond).Seconds()
xlog.Info().
Float64("elapsed", elapsed).
Msg("request completed")
}
}
mux := http.NewServeMux()
mux.HandleFunc("/yml_catalog.xml", mw(func(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, src)
}))
srv := &http.Server{
Addr: net.JoinHostPort("0.0.0.0", strconv.Itoa(int(port))),
Handler: mux,
}
go func() {
<-ctx.Done()
sdctx, sdcancel := context.WithTimeout(context.Background(), time.Second*5)
defer sdcancel()
errShutdown := srv.Shutdown(sdctx)
if errShutdown != nil {
log.Warn().Err(errShutdown).Msg("unable to shutdown server")
}
}()
log.Info().Str("listen_addr", srv.Addr).Msg("running server")
err = srv.ListenAndServe()
if err != nil && errors.Is(err, http.ErrServerClosed) {
return fmt.Errorf("serving http api: %w", err)
} }
return nil return nil

16
config.toml Normal file
View File

@ -0,0 +1,16 @@
[badger]
debug = false
dir = "database/"
[log]
level = "info"
format = "text"
[eway]
login = "leci@yandex.ru"
password = "2a136e113854cc5d46b868919a7d6e939156ccb55ff12e87861513f7767af98be79e62407410"
_session_id = "19b98ed56cc144f47e040e68dbcd8481"
_session_user = "1490"
owner_id = "26476"
debug = false
workers_pool = 3

5
go.mod
View File

@ -13,14 +13,12 @@ require (
github.com/rodaine/table v1.1.1 github.com/rodaine/table v1.1.1
github.com/rs/zerolog v1.31.0 github.com/rs/zerolog v1.31.0
github.com/samber/do v1.6.0 github.com/samber/do v1.6.0
github.com/stretchr/testify v1.8.4
github.com/urfave/cli/v3 v3.0.0-alpha8 github.com/urfave/cli/v3 v3.0.0-alpha8
) )
require ( require (
github.com/andybalholm/cascadia v1.3.1 // indirect github.com/andybalholm/cascadia v1.3.1 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dustin/go-humanize v1.0.0 // indirect github.com/dustin/go-humanize v1.0.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/glog v1.0.0 // indirect github.com/golang/glog v1.0.0 // indirect
@ -28,15 +26,12 @@ require (
github.com/golang/protobuf v1.5.2 // indirect github.com/golang/protobuf v1.5.2 // indirect
github.com/golang/snappy v0.0.3 // indirect github.com/golang/snappy v0.0.3 // indirect
github.com/klauspost/compress v1.12.3 // indirect github.com/klauspost/compress v1.12.3 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect github.com/mattn/go-isatty v0.0.19 // indirect
github.com/pkg/errors v0.9.1 // indirect github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/xrash/smetrics v0.0.0-20231213231151-1d8dd44e695e // indirect github.com/xrash/smetrics v0.0.0-20231213231151-1d8dd44e695e // indirect
go.opencensus.io v0.22.5 // indirect go.opencensus.io v0.22.5 // indirect
golang.org/x/net v0.17.0 // indirect golang.org/x/net v0.17.0 // indirect
golang.org/x/sys v0.13.0 // indirect golang.org/x/sys v0.13.0 // indirect
google.golang.org/protobuf v1.28.1 // indirect google.golang.org/protobuf v1.28.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
) )

7
go.sum
View File

@ -13,7 +13,6 @@ github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@ -54,10 +53,6 @@ github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.12.3 h1:G5AfA94pHPysR56qqrkO2pxEexdDzrpFJ6yt/VqWxVU= github.com/klauspost/compress v1.12.3 h1:G5AfA94pHPysR56qqrkO2pxEexdDzrpFJ6yt/VqWxVU=
github.com/klauspost/compress v1.12.3/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg= github.com/klauspost/compress v1.12.3/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
@ -189,8 +184,6 @@ google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQ
google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w= google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w=
google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

View File

@ -52,5 +52,4 @@ func (l *LogFormat) UnmarshalText(data []byte) (err error) {
type Log struct { type Log struct {
Level string `json:"level" toml:"level"` Level string `json:"level" toml:"level"`
Format string `json:"format" toml:"format"` Format string `json:"format" toml:"format"`
Output string `json:"output" toml:"output"`
} }

View File

@ -1,42 +0,0 @@
package config
import (
"errors"
"strings"
)
type MatcherPredicateType uint8
const (
MatcherPredicateTypeUnknown MatcherPredicateType = iota
MatcherPredicateTypePattern
MatcherPredicateTypeRegexp
)
func (t *MatcherPredicateType) UnmarshalText(data []byte) error {
switch dataStr := strings.ToLower(string(data)); dataStr {
case "":
*t = MatcherPredicateTypeUnknown
case "pattern":
*t = MatcherPredicateTypePattern
case "regexp":
*t = MatcherPredicateTypeRegexp
default:
return errors.New("unsupported type " + dataStr)
}
return nil
}
type MatcherPredicate struct {
Value string `toml:"value"`
Type MatcherPredicateType `toml:"type"`
}
type DimensionMatcher struct {
CaseInsensitive bool `toml:"case_insensitive"`
Length []MatcherPredicate `toml:"length"`
Width []MatcherPredicate `toml:"width"`
Height []MatcherPredicate `toml:"height"`
}

View File

@ -1,96 +0,0 @@
package dimension
import (
"git.loyso.art/frx/eway/internal/config"
"git.loyso.art/frx/eway/internal/matcher"
)
type MatchResult uint8
const (
MatchResultMiss MatchResult = iota
MatchResultLength
MatchResultHeight
MatchResultWidth
MatchResultDepth
)
type Matcher struct {
length matcher.Unit
width matcher.Unit
height matcher.Unit
}
func New(cfg config.DimensionMatcher) *Matcher {
return &Matcher{
length: makeMatcherByConig(cfg.CaseInsensitive, cfg.Length...),
width: makeMatcherByConig(cfg.CaseInsensitive, cfg.Width...),
height: makeMatcherByConig(cfg.CaseInsensitive, cfg.Height...),
}
}
func makeMatcherByConig(insensitive bool, cfgs ...config.MatcherPredicate) matcher.Unit {
opts := make([]matcher.RadixOpt, 0, 1)
if insensitive {
opts = append(opts, matcher.RadixCaseInsensitive())
}
m := matcher.NewRadix(opts...)
for _, cfg := range cfgs {
switch cfg.Type {
case config.MatcherPredicateTypePattern:
m.Register(cfg.Value)
case config.MatcherPredicateTypeRegexp:
m.RegisterRegexp(cfg.Value)
default:
panic("unsupported matcher type")
}
}
return m
}
func (m *Matcher) Match(value string) (r MatchResult, priority bool) {
switch {
case value == "Высота":
priority = true
fallthrough
case m.height.Match(value):
return MatchResultHeight, priority
case value == "Глубина":
priority = true
return MatchResultDepth, priority
case value == "Длина":
priority = true
fallthrough
case m.length.Match(value):
return MatchResultLength, priority
case value == "Ширина":
priority = true
fallthrough
case m.width.Match(value):
return MatchResultWidth, priority
}
return MatchResultMiss, false
}
func (m *Matcher) GetRegisteredPatterns() map[string][]string {
out := map[string][]string{
"length": nil,
"width": nil,
"height": nil,
}
if m.height != nil {
out["height"] = m.height.Patterns()
}
if m.width != nil {
out["width"] = m.width.Patterns()
}
if m.length != nil {
out["length"] = m.length.Patterns()
}
return out
}

View File

@ -1,49 +0,0 @@
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
package fbs
import (
flatbuffers "github.com/google/flatbuffers/go"
)
type Dimensions struct {
_tab flatbuffers.Struct
}
func (rcv *Dimensions) Init(buf []byte, i flatbuffers.UOffsetT) {
rcv._tab.Bytes = buf
rcv._tab.Pos = i
}
func (rcv *Dimensions) Table() flatbuffers.Table {
return rcv._tab.Table
}
func (rcv *Dimensions) Width() float32 {
return rcv._tab.GetFloat32(rcv._tab.Pos + flatbuffers.UOffsetT(0))
}
func (rcv *Dimensions) MutateWidth(n float32) bool {
return rcv._tab.MutateFloat32(rcv._tab.Pos+flatbuffers.UOffsetT(0), n)
}
func (rcv *Dimensions) Height() float32 {
return rcv._tab.GetFloat32(rcv._tab.Pos + flatbuffers.UOffsetT(4))
}
func (rcv *Dimensions) MutateHeight(n float32) bool {
return rcv._tab.MutateFloat32(rcv._tab.Pos+flatbuffers.UOffsetT(4), n)
}
func (rcv *Dimensions) Length() float32 {
return rcv._tab.GetFloat32(rcv._tab.Pos + flatbuffers.UOffsetT(8))
}
func (rcv *Dimensions) MutateLength(n float32) bool {
return rcv._tab.MutateFloat32(rcv._tab.Pos+flatbuffers.UOffsetT(8), n)
}
func CreateDimensions(builder *flatbuffers.Builder, width float32, height float32, length float32) flatbuffers.UOffsetT {
builder.Prep(4, 12)
builder.PrependFloat32(length)
builder.PrependFloat32(height)
builder.PrependFloat32(width)
return builder.Offset()
}

View File

@ -189,21 +189,8 @@ func (rcv *GoodItem) MutateCreatedAt(n int64) bool {
return rcv._tab.MutateInt64Slot(32, n) return rcv._tab.MutateInt64Slot(32, n)
} }
func (rcv *GoodItem) Sizes(obj *Dimensions) *Dimensions {
o := flatbuffers.UOffsetT(rcv._tab.Offset(34))
if o != 0 {
x := o + rcv._tab.Pos
if obj == nil {
obj = new(Dimensions)
}
obj.Init(rcv._tab.Bytes, x)
return obj
}
return nil
}
func GoodItemStart(builder *flatbuffers.Builder) { func GoodItemStart(builder *flatbuffers.Builder) {
builder.StartObject(16) builder.StartObject(15)
} }
func GoodItemAddSku(builder *flatbuffers.Builder, sku flatbuffers.UOffsetT) { func GoodItemAddSku(builder *flatbuffers.Builder, sku flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(sku), 0) builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(sku), 0)
@ -250,9 +237,6 @@ func GoodItemAddParameters(builder *flatbuffers.Builder, parameters flatbuffers.
func GoodItemAddCreatedAt(builder *flatbuffers.Builder, createdAt int64) { func GoodItemAddCreatedAt(builder *flatbuffers.Builder, createdAt int64) {
builder.PrependInt64Slot(14, createdAt, 0) builder.PrependInt64Slot(14, createdAt, 0)
} }
func GoodItemAddSizes(builder *flatbuffers.Builder, sizes flatbuffers.UOffsetT) {
builder.PrependStructSlot(15, flatbuffers.UOffsetT(sizes), 0)
}
func GoodItemEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { func GoodItemEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject() return builder.EndObject()
} }

View File

@ -82,13 +82,6 @@ func makeDomainGoodItem(builder *flatbuffers.Builder, in entity.GoodsItem) flatb
producer := builder.CreateString(in.Producer) producer := builder.CreateString(in.Producer)
var w, h, l float32
if in.Sizes != (entity.GoodsItemSize{}) {
w = float32(in.Sizes.Width.AdjustTo(entity.DimensionKindCentimeter).Value)
h = float32(in.Sizes.Height.AdjustTo(entity.DimensionKindCentimeter).Value)
l = float32(in.Sizes.Length.AdjustTo(entity.DimensionKindCentimeter).Value)
}
GoodItemStart(builder) GoodItemStart(builder)
GoodItemAddSku(builder, sku) GoodItemAddSku(builder, sku)
GoodItemAddPhoto(builder, photo) GoodItemAddPhoto(builder, photo)
@ -107,7 +100,6 @@ func makeDomainGoodItem(builder *flatbuffers.Builder, in entity.GoodsItem) flatb
GoodItemAddStock(builder, int16(in.Stock)) GoodItemAddStock(builder, int16(in.Stock))
GoodItemAddParameters(builder, parameters) GoodItemAddParameters(builder, parameters)
GoodItemAddCreatedAt(builder, in.CreatedAt.Unix()) GoodItemAddCreatedAt(builder, in.CreatedAt.Unix())
GoodItemAddSizes(builder, CreateDimensions(builder, w, h, l))
return GoodItemEnd(builder) return GoodItemEnd(builder)
} }
@ -115,10 +107,7 @@ func makeDomainGoodItem(builder *flatbuffers.Builder, in entity.GoodsItem) flatb
func ParseGoodsItem(data []byte) (item entity.GoodsItem, err error) { func ParseGoodsItem(data []byte) (item entity.GoodsItem, err error) {
itemFBS := GetRootAsGoodItem(data, 0) itemFBS := GetRootAsGoodItem(data, 0)
item.Articul = string(itemFBS.Sku()) item.Articul = string(itemFBS.Sku())
photoURLs := string(itemFBS.Photo()) item.PhotoURLs = strings.Split(string(itemFBS.Photo()), ";")
if len(photoURLs) > 0 {
item.PhotoURLs = strings.Split(photoURLs, ";")
}
item.Name = string(itemFBS.Name()) item.Name = string(itemFBS.Name())
description, err := base64.RawStdEncoding.DecodeString(string(itemFBS.Description())) description, err := base64.RawStdEncoding.DecodeString(string(itemFBS.Description()))
@ -147,20 +136,6 @@ func ParseGoodsItem(data []byte) (item entity.GoodsItem, err error) {
} }
} }
var w, h, l float64
sizes := itemFBS.Sizes(nil)
if sizes != nil {
w = float64(sizes.Width())
h = float64(sizes.Height())
l = float64(sizes.Length())
}
item.Sizes = entity.GoodsItemSize{
Width: entity.NewCentimeterDimensionOrEmpty(w),
Height: entity.NewCentimeterDimensionOrEmpty(h),
Length: entity.NewCentimeterDimensionOrEmpty(l),
}
createdAt := itemFBS.CreatedAt() createdAt := itemFBS.CreatedAt()
if createdAt > 0 { if createdAt > 0 {
item.CreatedAt = time.Unix(createdAt, 0) item.CreatedAt = time.Unix(createdAt, 0)

View File

@ -1,154 +0,0 @@
package entity
import (
"fmt"
"strconv"
"strings"
)
var DefaultLocale = DimensionLocalRU
type DimensionLocale uint8
const (
DimensionLocalUnspecified DimensionLocale = iota
DimensionLocalRU
dimensionLocalEnd
)
type DimensionKind uint8
func (k DimensionKind) GetPos() float64 {
switch k {
case DimensionKindMilimeter:
return 1
case DimensionKindCentimeter:
return 10
case DimensionKindMeter:
return 1000
default:
return 0
}
}
func (k DimensionKind) String() string {
m := getLocaleKindToStringMap()[DefaultLocale]
return m[k]
}
const (
DimensionKindUnspecified DimensionKind = iota
DimensionKindMilimeter
DimensionKindCentimeter
DimensionKindMeter
)
type Dimension struct {
Value float64
Kind DimensionKind
}
func (d Dimension) IsZero() bool {
return d.Value == 0
}
func (d Dimension) MarshalText() ([]byte, error) {
if d.Value == 0 && d.Kind == DimensionKindUnspecified {
return nil, nil
}
value := strconv.FormatFloat(d.Value, 'f', 4, 64) + " " + d.Kind.String()
return []byte(value), nil
}
func (d *Dimension) UnmarshalText(data []byte) (err error) {
*d, err = ParseDimention(string(data), DefaultLocale)
return err
}
func (d Dimension) AdjustTo(kind DimensionKind) Dimension {
from := d.Kind.GetPos()
to := kind.GetPos()
switch {
case from < to:
mult := to / from
return Dimension{
Kind: kind,
Value: d.Value / float64(mult),
}
case from > to:
mult := from / to
return Dimension{
Kind: kind,
Value: d.Value * float64(mult),
}
}
return d
}
func ParseDimention(value string, locale DimensionLocale) (Dimension, error) {
switch locale {
case DimensionLocalRU:
default:
return Dimension{}, SimpleError("unknown locale for parse")
}
dimensionStrToKind := getLocaleToKindMap()[locale]
lastSpaceIdx := strings.LastIndex(value, " ")
if lastSpaceIdx == -1 {
return Dimension{}, SimpleError("expected 2 values after split for value " + value)
}
var splitted [2]string
splitted[0] = strings.ReplaceAll(value[:lastSpaceIdx], " ", "")
splitted[1] = value[lastSpaceIdx+1:]
var out Dimension
var ok bool
out.Kind, ok = dimensionStrToKind[splitted[1]]
if !ok {
return Dimension{}, SimpleError("dimension map not found for kind " + splitted[1])
}
var err error
out.Value, err = strconv.ParseFloat(splitted[0], 64)
if err != nil {
return Dimension{}, fmt.Errorf("parsing value: %w", err)
}
return out, nil
}
func NewCentimeterDimensionOrEmpty(value float64) Dimension {
if value == 0 {
return Dimension{}
}
return Dimension{
Value: value,
Kind: DimensionKindCentimeter,
}
}
func getLocaleToKindMap() map[DimensionLocale]map[string]DimensionKind {
return map[DimensionLocale]map[string]DimensionKind{
DimensionLocalRU: {
"мм": DimensionKindMilimeter,
"см": DimensionKindCentimeter,
"м": DimensionKindMeter,
},
}
}
func getLocaleKindToStringMap() map[DimensionLocale]map[DimensionKind]string {
return map[DimensionLocale]map[DimensionKind]string{
DimensionLocalRU: {
DimensionKindMilimeter: "мм",
DimensionKindCentimeter: "см",
DimensionKindMeter: "м",
},
}
}

View File

@ -1,31 +0,0 @@
package entity
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestLocaleMap(t *testing.T) {
kindToStr := getLocaleKindToStringMap()
strToKind := getLocaleToKindMap()
assert := assert.New(t)
for locale := DimensionLocalUnspecified + 1; locale < dimensionLocalEnd; locale++ {
localeKinds, ok := kindToStr[locale]
assert.True(ok)
localeStrs, ok := strToKind[locale]
assert.True(ok)
assert.Equal(len(localeKinds), len(localeStrs))
for kindKey, kindValue := range localeKinds {
strKey := kindValue
strValue, ok := localeStrs[strKey]
assert.True(ok)
assert.Equal(kindKey, strValue)
assert.Equal(strKey, kindValue)
}
}
}

View File

@ -1,72 +0,0 @@
package entity_test
import (
"errors"
"testing"
"git.loyso.art/frx/eway/internal/entity"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestDimension_AdjustTo(t *testing.T) {
// Test adjusting from smaller dimension to larger one
d := entity.Dimension{Value: 5.0, Kind: entity.DimensionKindCentimeter}
expected := entity.Dimension{Value: 0.05, Kind: entity.DimensionKindMeter}
actual := d.AdjustTo(entity.DimensionKindMeter)
assert.EqualValues(t, expected.Value, actual.Value)
assert.Equal(t, expected.Kind, actual.Kind)
// Test adjusting from larger dimension to smaller one
d = entity.Dimension{Value: 0.05, Kind: entity.DimensionKindMeter}
expected = entity.Dimension{Value: 50.0, Kind: entity.DimensionKindMilimeter}
actual = d.AdjustTo(entity.DimensionKindMilimeter)
assert.EqualValues(t, expected.Value, actual.Value)
assert.Equal(t, expected.Kind, actual.Kind)
}
func TestParseDimension_Success(t *testing.T) {
// Test parsing a valid dimension string with RU locale
input := "10 см"
expected := entity.Dimension{Value: 10.0, Kind: entity.DimensionKindCentimeter}
actual, err := entity.ParseDimention(input, entity.DimensionLocalRU)
require.NoError(t, err)
assert.EqualValues(t, expected.Value, actual.Value)
assert.Equal(t, expected.Kind, actual.Kind)
}
func TestParseDimensionComplex_Success(t *testing.T) {
// Test parsing a valid dimension string with RU locale
input := "10 256.20 см"
expected := entity.Dimension{Value: 10256.20, Kind: entity.DimensionKindCentimeter}
actual, err := entity.ParseDimention(input, entity.DimensionLocalRU)
require.NoError(t, err)
assert.EqualValues(t, expected.Value, actual.Value)
assert.Equal(t, expected.Kind, actual.Kind)
}
func TestParseDimension_InvalidInputFormat(t *testing.T) {
// Test parsing an invalid dimension string with RU locale
input := "invalid value 2"
expectedErr := errors.New("expected 2 values after split for value invalid value 2")
_, err := entity.ParseDimention(input, entity.DimensionLocalRU)
assert.Error(t, err)
assert.EqualError(t, err, expectedErr.Error())
}
func TestParseDimension_InvalidLocale(t *testing.T) {
// Test parsing a dimension string with an unsupported locale
input := "10 мм"
expectedErr := errors.New("unknown locale for parse")
_, err := entity.ParseDimention(input, 3) // An invalid locale value is used here for demonstration purposes
assert.EqualError(t, err, expectedErr.Error())
}

View File

@ -1,3 +0,0 @@
package entity
type Empty struct{}

View File

@ -8,80 +8,6 @@ import (
"unicode" "unicode"
) )
type GoodsItemSize struct {
Width Dimension
Height Dimension
Length Dimension
UnmatchedDepth Dimension
}
func FixupSizes(s GoodsItemSize) (GoodsItemSize, bool) {
// Nothing to substitute
if s.UnmatchedDepth.IsZero() {
return s, false
}
var count int
for _, d := range []Dimension{
s.Width,
s.Height,
s.Length,
} {
if d.IsZero() {
count++
}
}
// Can only replace one dimension
if count != 1 {
return s, false
}
switch {
case s.Width.IsZero():
s.Width = s.UnmatchedDepth
case s.Height.IsZero():
s.Height = s.UnmatchedDepth
case s.Length.IsZero():
s.Length = s.UnmatchedDepth
}
s.UnmatchedDepth = Dimension{}
return s, true
}
func (s GoodsItemSize) AllSizesSet() bool {
var count int
for _, d := range []Dimension{
s.Width,
s.Height,
s.Length,
s.UnmatchedDepth,
} {
if d.IsZero() {
count++
}
}
return count >= 3
}
func (s GoodsItemSize) GetSum(kind DimensionKind) float64 {
var value float64
sum := func(ds ...Dimension) {
for _, d := range ds {
value += d.AdjustTo(kind).Value
}
}
sum(s.Height, s.Length, s.Length)
return value
}
type GoodsItem struct { type GoodsItem struct {
Articul string `json:"sku"` Articul string `json:"sku"`
PhotoURLs []string `json:"photo"` PhotoURLs []string `json:"photo"`
@ -96,7 +22,6 @@ type GoodsItem struct {
TariffPrice float64 `json:"tariff_price"` TariffPrice float64 `json:"tariff_price"`
Cart int64 `json:"cart"` Cart int64 `json:"cart"`
Stock int `json:"stock"` Stock int `json:"stock"`
Sizes GoodsItemSize `json:"sizes"`
Parameters map[string]string `json:"parameters"` Parameters map[string]string `json:"parameters"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
} }

View File

@ -23,8 +23,7 @@ type Offer struct {
TypePrefix string `xml:"typePrefix"` TypePrefix string `xml:"typePrefix"`
Description string `xml:"description"` Description string `xml:"description"`
ManufacturerWarrany bool `xml:"manufacturer_warranty"` ManufacturerWarrany bool `xml:"manufacturer_warranty"`
Dimensions string `xml:"dimensions"` Params []Param `xml:"param"`
Params []Param `xml:"param,omitempty"`
} }
type Currency struct { type Currency struct {

View File

@ -355,6 +355,8 @@ func (c *client) getProductInfo(ctx context.Context, cartID int64) (pi entity.Go
return strings.Contains(err.Error(), "pipe") return strings.Contains(err.Error(), "pipe")
}) })
c.log.Debug().Msg("using go query")
pi.Parameters = map[string]string{} pi.Parameters = map[string]string{}
resp, err := c.do(ctx, "getProductInfo", req, resty.MethodGet, reqpath) resp, err := c.do(ctx, "getProductInfo", req, resty.MethodGet, reqpath)
if err != nil { if err != nil {
@ -383,7 +385,7 @@ func (c *client) getProductInfo(ctx context.Context, cartID int64) (pi entity.Go
} }
cleanText := func(t string) string { cleanText := func(t string) string {
return strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(t), ":")) return 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 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)"
@ -403,10 +405,7 @@ func (c *client) getProductInfo(ctx context.Context, cartID int64) (pi entity.Go
Find(galleryPanelSelector). Find(galleryPanelSelector).
Find(galleryImageSelector). Find(galleryImageSelector).
Each(func(i int, s *goquery.Selection) { Each(func(i int, s *goquery.Selection) {
imageURL, ok := s.Attr("data-src") imageURL, ok := s.Attr("src")
if !ok {
imageURL, ok = s.Attr("src")
}
if !ok || len(imageURL) == 0 { if !ok || len(imageURL) == 0 {
return return
} }

View File

@ -1,13 +0,0 @@
package matcher
type Unit interface {
MatchByPattern(value string) (pattern string)
Match(value string) bool
// Move this to init stage because some of matchers
// might not be applicable to it
RegisterRegexp(regexpPattern string)
Register(pattern string)
Patterns() []string
}

View File

@ -71,7 +71,7 @@ func (r *radixMatcher) MatchByPattern(value string) (pattern string) {
} }
if !node.exact { if !node.exact {
return r.findByRegexp(originValue) return r.findByRegexp(value)
} }
return sb.String() return sb.String()

View File

@ -11,7 +11,6 @@ func TestRadixMatcherWithPattern(t *testing.T) {
m.Register("aloha") m.Register("aloha")
m.Register("hawaii") m.Register("hawaii")
m.Register("te*") m.Register("te*")
m.Register("Ширина")
var tt = []struct { var tt = []struct {
name string name string
@ -35,12 +34,6 @@ func TestRadixMatcherWithPattern(t *testing.T) {
}, { }, {
name: "should not match 2", name: "should not match 2",
in: "whoa", in: "whoa",
}, {
name: "should not match 3",
in: "alohaya",
}, {
name: "should match exact 3",
in: "Ширина",
}} }}
for _, tc := range tt { for _, tc := range tt {