From 0553ea71c3dc48b92bb0f7d133cad8594289248f Mon Sep 17 00:00:00 2001 From: Gitea Date: Thu, 23 Nov 2023 19:13:54 +0300 Subject: [PATCH] able to list products --- Taskfile.yml | 2 +- cmd/dev/sravnicli/core.go | 50 +++ cmd/dev/sravnicli/main.go | 97 ++++-- cmd/dev/sravnicli/products.go | 160 +++++++++ go.mod | 5 + go.sum | 4 + internal/domain/error.go | 27 +- .../courses/sravni/client.go | 324 +++++++++++++----- .../courses/sravni/entities.go | 190 ++++++++++ .../courses/sravni/helpers.go | 20 ++ pkg/utilities/slices/map.go | 11 + 11 files changed, 774 insertions(+), 116 deletions(-) create mode 100644 cmd/dev/sravnicli/core.go create mode 100644 cmd/dev/sravnicli/products.go create mode 100644 internal/infrastructure/interfaceadapters/courses/sravni/entities.go create mode 100644 internal/infrastructure/interfaceadapters/courses/sravni/helpers.go create mode 100644 pkg/utilities/slices/map.go diff --git a/Taskfile.yml b/Taskfile.yml index 1e0f8aa..da23c4a 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -26,7 +26,7 @@ tasks: - go test --count=1 ./internal/... build: cmds: - - go build -o $GOBIN/sravnicli -v -ldflags '{{.LDFLAGS}}' cmd/dev/sravnicli/main.go + - go build -o $GOBIN/sravnicli -v -ldflags '{{.LDFLAGS}}' cmd/dev/sravnicli/*.go deps: [check, test] run: deps: [build] diff --git a/cmd/dev/sravnicli/core.go b/cmd/dev/sravnicli/core.go new file mode 100644 index 0000000..de6be2b --- /dev/null +++ b/cmd/dev/sravnicli/core.go @@ -0,0 +1,50 @@ +package main + +import ( + "context" + "fmt" + "log/slog" + "os" + + "git.loyso.art/frx/kurious/internal/infrastructure/interfaceadapters/courses/sravni" + + "github.com/teris-io/cli" +) + +const ( + debugOptName = "verbose" + jsonOptName = "json" +) + +var limitOption = cli.NewOption("limit", "Limits amount of items to return").WithType(cli.TypeInt) +var offsetOption = cli.NewOption("offset", "Offsets items to return").WithType(cli.TypeInt) + +func makeLogger(options map[string]string) *slog.Logger { + level := slog.LevelInfo + if _, ok := options[debugOptName]; ok { + level = slog.LevelDebug + } + + opts := slog.HandlerOptions{ + Level: level, + } + + var h slog.Handler + if _, ok := options[jsonOptName]; ok { + h = slog.NewJSONHandler(os.Stdout, &opts) + } else { + h = slog.NewTextHandler(os.Stdout, &opts) + } + + return slog.New(h) +} + +func makeSravniClient(ctx context.Context, log *slog.Logger, options map[string]string) (sravni.Client, error) { + _, isDebug := options[debugOptName] + client, err := sravni.NewClient(ctx, log, isDebug) + if err != nil { + return nil, fmt.Errorf("making new client: %w", err) + } + + return client, nil +} diff --git a/cmd/dev/sravnicli/main.go b/cmd/dev/sravnicli/main.go index 949a6c7..b1c6a02 100644 --- a/cmd/dev/sravnicli/main.go +++ b/cmd/dev/sravnicli/main.go @@ -2,59 +2,88 @@ package main import ( "context" - "encoding/json" "fmt" "log/slog" "os" "os/signal" "git.loyso.art/frx/kurious" - "git.loyso.art/frx/kurious/internal/infrastructure/interfaceadapters/courses/sravni" + + "github.com/teris-io/cli" ) +var defaultOutput = os.Stdout + func main() { ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) defer cancel() log := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ - Level: slog.LevelDebug, - ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr { - if a.Key == slog.TimeKey { - a.Value = slog.Int64Value(a.Value.Time().Unix()) - } + Level: slog.LevelDebug, + ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr { + if a.Key == slog.TimeKey { + a.Value = slog.Int64Value(a.Value.Time().Unix()) + } - return a - }, - })) + return a + }, + })) - version, commit, bt := kurious.Version(), kurious.Commit(), kurious.BuildTime() - pid := os.Getpid() + ec, err := app(ctx, log) + if err != nil { + slog.ErrorContext(ctx, "unable to run app", slog.Any("error", err)) + } - log.InfoContext( - ctx, "running app", - slog.Int("pid", pid), - slog.String("version", version), - slog.String("commit", commit), - slog.Time("build_time", bt), - ) - - err := app(ctx, log) - if err != nil { - slog.ErrorContext(ctx, "unable to run app", slog.Any("error", err)) - os.Exit(1) - } + os.Exit(ec) } -func app(ctx context.Context, log *slog.Logger) error { - client, err := sravni.NewClient(ctx, log, true) - if err != nil { - return fmt.Errorf("making new client: %w", err) - } +func setupCLI(ctx context.Context) cli.App { + mainPageState := cli.NewCommand("state", "Loads redux state"). + WithOption(cli.NewOption("part", "Prints part of the model [all,config,dicts,learning,courses]").WithType(cli.TypeString)). + WithAction(func(args []string, options map[string]string) int { + log := makeLogger(options) + client, err := makeSravniClient(ctx, log, options) + if err != nil { + log.ErrorContext(ctx, "making client", slog.Any("err", err)) + return -1 + } + state := client.GetMainPageState() - meta := client.GetMainPageState() + var out any + switch options["part"] { + case "", "all": + out = state + case "config": + out = state.RuntimeConfig + case "dicts": + out = state.Props.InitialReduxState.Dictionaries + case "learning": + out = state.Props.InitialReduxState.Dictionaries.Data.LearningType + case "courses": + out = state.Props.InitialReduxState.Dictionaries.Data.CourseThematics + } + log.InfoContext(ctx, "loaded state", slog.Any("state", out)) - enc := json.NewEncoder(os.Stdout) - enc.SetIndent("", " ") + return 0 + }) - return enc.Encode(meta) + mainCategory := cli.NewCommand("main", "Main page interaction"). + WithCommand(mainPageState) + + apiCategory := setupAPICommand(ctx) + description := fmt.Sprintf("sravni dev cli %s (%s)", kurious.Version(), kurious.Commit()) + cliApp := cli.New(description). + WithOption(cli.NewOption("verbose", "Verbose execution").WithChar('v').WithType(cli.TypeBool)). + WithOption(cli.NewOption("json", "JSON outpu format").WithType(cli.TypeBool)). + WithCommand(mainCategory). + WithCommand(apiCategory) + + return cliApp +} + +func app(ctx context.Context, log *slog.Logger) (exitCode int, err error) { + devCLI := setupCLI(ctx) + exitCode = devCLI.Run(os.Args, defaultOutput) + + return exitCode, nil } diff --git a/cmd/dev/sravnicli/products.go b/cmd/dev/sravnicli/products.go new file mode 100644 index 0000000..a3acab7 --- /dev/null +++ b/cmd/dev/sravnicli/products.go @@ -0,0 +1,160 @@ +package main + +import ( + "context" + "fmt" + "log/slog" + "strconv" + + "git.loyso.art/frx/kurious/internal/domain" + "git.loyso.art/frx/kurious/internal/infrastructure/interfaceadapters/courses/sravni" + + "github.com/teris-io/cli" +) + +const ( + learningTypeOptName = "learning_type" + courseThematicOptName = "course_thematic" +) + +func setupAPICommand(ctx context.Context) cli.Command { + learningTypeOpt := cli.NewOption(learningTypeOptName, "Specify learning type"). + WithChar('l'). + WithType(cli.TypeString) + courseThematic := cli.NewOption(courseThematicOptName, "Specify course thematic"). + WithChar('t'). + WithType(cli.TypeString) + + apiEducationListProducts := cli.NewCommand("list_products", "List products by some filters"). + WithOption(learningTypeOpt). + WithOption(courseThematic). + WithOption(limitOption). + WithOption(offsetOption). + WithAction(newListProductAction(ctx)) + + apiEducation := cli.NewCommand("education", "Education related category"). + WithCommand(apiEducationListProducts) + + return cli.NewCommand("api", "Interaction with API"). + WithCommand(apiEducation) +} + +type action interface { + context() context.Context + parse(args []string, options map[string]string) error + handle() error +} + +func asCLIAction(a action) cli.Action { + return func(args []string, options map[string]string) int { + ctx := a.context() + log := makeLogger(options) + err := a.parse(args, options) + if err != nil { + log.ErrorContext(ctx, "unable to parse args and opts", slog.Any("err", err)) + return -1 + } + err = a.handle() + if err != nil { + log.ErrorContext(ctx, "unable to handle action", slog.Any("err", err)) + return -1 + } + + return 0 + } +} + +type baseAction struct { + ctx context.Context + client sravni.Client + log *slog.Logger +} + +func (ba *baseAction) parse(_ []string, options map[string]string) (err error) { + ba.log = makeLogger(options).With(slog.String("component", "action")) + ba.client, err = makeSravniClient(ba.ctx, ba.log, options) + if err != nil { + return err + } + + return nil +} + +func (ba *baseAction) handle() error { + return domain.ErrNotImplemented +} + +func (ba baseAction) context() context.Context { + return ba.ctx +} + +func newBaseAction(ctx context.Context) *baseAction { + return &baseAction{ + ctx: ctx, + } +} + +type listProductsActionParams struct { + learningType string + courseThematic string + limit int + offset int +} + +type listProductsAction struct { + *baseAction + + params listProductsActionParams +} + +func newListProductAction(ctx context.Context) cli.Action { + action := &listProductsAction{ + baseAction: newBaseAction(ctx), + } + + return asCLIAction(action) +} + +func (a *listProductsAction) parse(args []string, options map[string]string) error { + err := a.baseAction.parse(args, options) + if err != nil { + return err + } + + var ok bool + + a.params.learningType, ok = options[learningTypeOptName] + if !ok { + return domain.SimpleError("learning_type is empty") + } + a.params.courseThematic, ok = options[courseThematicOptName] + if !ok { + return domain.SimpleError("course_thematic is empty") + } + + if value, ok := options[limitOption.Key()]; ok { + a.params.limit, _ = strconv.Atoi(value) + } + if value, ok := options[offsetOption.Key()]; ok { + a.params.offset, _ = strconv.Atoi(value) + } + + return nil +} + +func (a *listProductsAction) handle() error { + params := sravni.ListEducationProductsParams{ + LearningType: a.params.learningType, + CoursesThematics: a.params.courseThematic, + Limit: a.params.limit, + Offset: a.params.offset, + } + result, err := a.client.ListEducationalProducts(a.ctx, params) + if err != nil { + return fmt.Errorf("listing education products: %w", err) + } + + a.log.InfoContext(a.ctx, "list education products result", slog.Any("result", result)) + + return nil +} diff --git a/go.mod b/go.mod index 1757e3a..f770d37 100644 --- a/go.mod +++ b/go.mod @@ -6,3 +6,8 @@ require ( github.com/go-resty/resty/v2 v2.10.0 golang.org/x/net v0.18.0 ) + +require ( + github.com/teris-io/cli v1.0.1 // indirect + golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa // indirect +) diff --git a/go.sum b/go.sum index d8e6445..bf59d55 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,13 @@ github.com/go-resty/resty/v2 v2.10.0 h1:Qla4W/+TMmv0fOeeRqzEpXPLfTUnR5HZ1+lGs+CkiCo= github.com/go-resty/resty/v2 v2.10.0/go.mod h1:iiP/OpA0CkcL3IGt1O0+/SIItFUbkkyw5BGXiVdTu+A= +github.com/teris-io/cli v1.0.1 h1:J6jnVHC552uqx7zT+Ux0++tIvLmJQULqxVhCid2u/Gk= +github.com/teris-io/cli v1.0.1/go.mod h1:V9nVD5aZ873RU/tQXLSXO8FieVPQhQvuNohsdsKXsGw= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= +golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa h1:FRnLl4eNAQl8hwxVVC17teOw8kdjVDVAiFMtgUdTSRQ= +golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= diff --git a/internal/domain/error.go b/internal/domain/error.go index 3250582..4f0b477 100644 --- a/internal/domain/error.go +++ b/internal/domain/error.go @@ -1,7 +1,12 @@ package domain +import ( + "fmt" +) + const ( - UnexpectedStatusError SimpleError = "unexpected status" + ErrNotImplemented SimpleError = "not implemented" + ErrUnexpectedStatus SimpleError = "unexpected status" ) type SimpleError string @@ -9,3 +14,23 @@ type SimpleError string func (err SimpleError) Error() string { return string(err) } + +type ValidationError struct { + Field string + Reason string +} + +func (err *ValidationError) Error() string { + return fmt.Sprintf("field %s is not valid for reason %s", err.Field, err.Reason) +} + +func NewValidationError(field, reason string) *ValidationError { + if field == "" { + panic("field not provided") + } + + return &ValidationError{ + Field: field, + Reason: reason, + } +} diff --git a/internal/infrastructure/interfaceadapters/courses/sravni/client.go b/internal/infrastructure/interfaceadapters/courses/sravni/client.go index f9b7cb5..bcd204b 100644 --- a/internal/infrastructure/interfaceadapters/courses/sravni/client.go +++ b/internal/infrastructure/interfaceadapters/courses/sravni/client.go @@ -6,9 +6,11 @@ import ( "fmt" "io" "log/slog" + "strconv" "strings" "git.loyso.art/frx/kurious/internal/domain" + "git.loyso.art/frx/kurious/pkg/utilities/slices" "github.com/go-resty/resty/v2" "golang.org/x/net/html" @@ -19,6 +21,14 @@ const ( baseURL = "https://www.sravni.ru/kursy" ) +type Client interface { + GetMainPageState() *PageState + ListEducationalProducts( + ctx context.Context, + params ListEducationProductsParams, + ) (result ListEducationProductsResponse, err error) +} + func NewClient(ctx context.Context, log *slog.Logger, debug bool) (c *client, err error) { c = &client{ log: log.With(slog.String("client", "sravni")), @@ -32,6 +42,18 @@ func NewClient(ctx context.Context, log *slog.Logger, debug bool) (c *client, er return nil, err } + getQuerySet := func(fields []field) querySet { + items := slices.Map(fields, func(f field) string { + return f.Value + }) + + return newQuerySet(items...) + } + + dicts := c.cachedMainPageInfo.Props.InitialReduxState.Dictionaries.Data + c.validLearningTypes = getQuerySet(dicts.LearningType.Fields) + c.validCourseThematics = getQuerySet(dicts.CourseThematics.Fields) + return c, nil } @@ -39,78 +61,128 @@ type client struct { log *slog.Logger http *resty.Client - cachedMainPageInfo *PageState -} - -type PageStateRuntimeConfig struct { - BrandingURL string `json:"brandingUrl"` - Release string `json:"release"` - Environment string `json:"environment"` - Gateway string `json:"gatewayUrl"` - APIGatewayURL string `json:"apiGatewayUrl"` - EducationURL string `json:"educationUrl"` - PhoneVerifierURL string `json:"phoneVerifierUrl"` - WebPath string `json:"webPath"` - ServiceName string `json:"serviceName"` - OrgnazationURL string `json:"organizationsUrl"` -} - -type Link struct { - URL string `json:"url"` - Title string `json:"title"` -} - -type ReduxStatePrefooterItem struct { - Title string `json:"title"` - Links []Link `json:"links"` -} - -type ReduxMetadata struct { - Data struct { - Prefooter []ReduxStatePrefooterItem `json:"prefooter"` - } `json:"data"` -} - -type InitialReduxState struct { - Metadata ReduxMetadata `json:"metadata"` - Categories struct { - Data map[string]int `json:"data"` - } `json:"categories"` -} - -type PageStateProperties struct { - InitialReduxState InitialReduxState `json:"initialReduxState"` -} - -type PageState struct { - Page string `json:"page"` - Query map[string]string `json:"query"` - BuildID string `json:"buildId"` - RuntimeConfig PageStateRuntimeConfig `json:"runtimeConfig"` - Props PageStateProperties `json:"props"` -} - -func (p *PageState) Clone() *PageState { - copiedState := *p - copiedState.Query = make(map[string]string, len(p.Query)) - for k, v := range p.Query { - copiedState.Query[k] = v - } - - data := p.Props.InitialReduxState.Categories.Data - copiedData := make(map[string]int, len(data)) - for k, v := range data { - copiedData[k] = v - } - copiedState.Props.InitialReduxState.Categories.Data = copiedData - - return &copiedState + cachedMainPageInfo *PageState + validLearningTypes querySet + validCourseThematics querySet } func (c *client) GetMainPageState() *PageState { return c.cachedMainPageInfo.Clone() } +type ListEducationProductsParams struct { + LearningType string + CoursesThematics string + + Limit int + Offset int +} + +type ListEducationProductsRequest struct { + Fingerprint string `json:"fingerPrint,omitempty"` + ProductName string `json:"productName,omitempty"` + AdvertisingOnly bool `json:"advertisingOnly"` + Location string `json:"location"` + OfferTypes []string `json:"offerTypes"` + IsMix bool `json:"isMix"` + MixRepeated bool `json:"mixRepeated"` + Fields []string `json:"fields"` + SortProperty string `json:"sortProperty"` + SortDirection string `json:"sortDirection"` + LearningType []string `json:"learningtype"` + CoursesThematics []string `json:"coursesThematics"` + NotSubIsWebinar string `json:"not-sub-isWebinar"` + NotB2B string `json:"not-b2b"` + + Limit int `json:"limit"` + Offset int `json:"offset"` +} + +type ListEducationProductsResponse struct { + Items []Course `json:"items"` + Organizations map[string]Organization `json:"organizations"` + + TotalCount int `json:"totalCount"` + TotalCountAdv int `json:"totalCountAdv"` +} + +func (c *client) ListEducationalProducts( + ctx context.Context, + params ListEducationProductsParams, +) (result ListEducationProductsResponse, err error) { + const urlPath = "/v1/education/products" + const defaultLimit = 1 + const defaultSortProp = "advertising.position" + const defaultSortDirection = "asc" + if err = c.checkClientInited(); err != nil { + return result, err + } + + if !c.validLearningTypes.hasValue(params.LearningType) { + return result, domain.NewValidationError("learning_type", "bad value") + } + if !c.validCourseThematics.hasValue(params.CoursesThematics) { + return result, domain.NewValidationError("courses_thematics", "bad value") + } + + reqParams := ListEducationProductsRequest{ + LearningType: []string{ + params.LearningType, + }, + CoursesThematics: []string{ + params.CoursesThematics, + }, + + Fields: defaultProductFields, + SortProperty: defaultSortProp, // mayber sort by price? + SortDirection: defaultSortDirection, + NotSubIsWebinar: strconv.FormatBool(true), + NotB2B: strconv.FormatBool(true), + IsMix: true, // not sure why, but for better parsing + MixRepeated: true, // looks like this option should force to exclude duplicates + AdvertisingOnly: false, // If true, it will show only paid items. + Location: "", // TODO: get and fill location? + Fingerprint: "", // not sure it should be set. + ProductName: "", // looks like it does not affects anything + OfferTypes: nil, // for more precise filter but not needed. + + Limit: defaultLimit, + Offset: 0, + } + + req := c.http.R(). + SetBody(reqParams). + SetResult(&result). + EnableTrace() + + resp, err := req.Post(c.makeEducationURL(urlPath)) + if err != nil { + return result, fmt.Errorf("making request: %w", err) + } + + if resp.IsError() { + return result, fmt.Errorf("bad status code %d: %w", resp.StatusCode(), domain.ErrUnexpectedStatus) + } + + return result, nil +} + +func (c *client) makeEducationURL(path string) string { + if c.cachedMainPageInfo == nil { + return "" + } + + return c.cachedMainPageInfo.RuntimeConfig.EducationURL + path +} + +func (c *client) checkClientInited() error { + if c.cachedMainPageInfo == nil { + return ErrClientNotInited + } + + return nil +} + func (c *client) getMainPageState(ctx context.Context) (*PageState, error) { ctxLogger := restyCtxLogger{ ctx: ctx, @@ -130,7 +202,7 @@ func (c *client) getMainPageState(ctx context.Context) (*PageState, error) { if resp.IsError() { c.log.ErrorContext(ctx, "unable to proceed request", slog.String("body", string(resp.Body()))) - return nil, fmt.Errorf("got %d, but expected success: %w", resp.StatusCode(), domain.UnexpectedStatusError) + return nil, fmt.Errorf("got %d, but expected success: %w", resp.StatusCode(), domain.ErrUnexpectedStatus) } traceInfo := resp.Request.TraceInfo() @@ -195,19 +267,111 @@ func (c *client) parsePageState(ctx context.Context, body io.Reader) (*PageState return &out, nil } -func findNode(parent *html.Node, eq func(*html.Node) (found, deeper bool)) *html.Node { - for child := parent.FirstChild; child != nil; child = child.NextSibling { - found, deeper := eq(child) - if found { - return child - } - if deeper { - deeperChild := findNode(child, eq) - if deeperChild != nil { - return deeperChild - } - } +var educationProductFields = newQuerySet( + "id", + "name", + "organization", + "advertising", + "discount", + "link", + "learningtype", + "dateStart", + "timeStart", + "timeAllHour", + "timeAllDay", + "timeAllMonth", + "isTermApproximately", + "dictionaryFormatFilterNew", + "dictionaryLevelFilterNew", + "price", + "priceAll", + "priceInstallment", + "courseImage", + "price", + "withoutDiscountPrice", +) + +var defaultProductFields = must(educationProductFields.exactSubset( + "id", + "name", + "organization", + "advertising", + "discount", + "link", + "learningtype", + "dateStart", + "timeStart", + "timeAllHour", + "timeAllDay", + "timeAllMonth", + "price", + "priceAll", + "priceInstallment", + "courseImage", + "price", + "withoutDiscountPrice", +)) + +func must[T any](t T, err error) T { + if err != nil { + panic(err.Error()) } - return nil + return t +} + +type querySet struct { + values []string + mappedValues map[string]struct{} +} + +func (qs querySet) Values() []string { + out := make([]string, len(qs.values)) + copy(out, qs.values) + + return out +} + +func (qs querySet) hasValue(value string) bool { + _, ok := qs.mappedValues[value] + return ok +} + +func (qs querySet) exactSubset(values ...string) ([]string, error) { + out := make([]string, 0, len(values)) + for _, value := range values { + if !qs.hasValue(value) { + return nil, fmt.Errorf("value %s was not found in set", value) + } + + out = append(out, value) + } + + return out, nil + +} + +// func (qs querySet) subset(values ...string) []string { +// out := make([]string, 0, len(values)) +// for _, value := range values { +// if qs.hasValue(value) { +// out = append(out, value) +// } +// } +// +// return out +// } + +func newQuerySet(values ...string) querySet { + qs := querySet{ + values: make([]string, len(values)), + mappedValues: make(map[string]struct{}, len(values)), + } + + for i, v := range values { + qs.values[i] = v + qs.mappedValues[v] = struct{}{} + } + + return qs } diff --git a/internal/infrastructure/interfaceadapters/courses/sravni/entities.go b/internal/infrastructure/interfaceadapters/courses/sravni/entities.go new file mode 100644 index 0000000..f4c274a --- /dev/null +++ b/internal/infrastructure/interfaceadapters/courses/sravni/entities.go @@ -0,0 +1,190 @@ +package sravni + +import ( + "time" + + "git.loyso.art/frx/kurious/internal/domain" +) + +const ( + ErrClientNotInited domain.SimpleError = "client was not inited" +) + +type PageStateRuntimeConfig struct { + BrandingURL string `json:"brandingUrl"` + Release string `json:"release"` + Environment string `json:"environment"` + Gateway string `json:"gatewayUrl"` + APIGatewayURL string `json:"apiGatewayUrl"` + EducationURL string `json:"educationUrl"` + PhoneVerifierURL string `json:"phoneVerifierUrl"` + WebPath string `json:"webPath"` + ServiceName string `json:"serviceName"` + OrgnazationURL string `json:"organizationsUrl"` +} + +type Link struct { + URL string `json:"url"` + Title string `json:"title"` +} + +type ReduxStatePrefooterItem struct { + Title string `json:"title"` + Links []Link `json:"links"` +} + +type ReduxMetadata struct { + Data struct { + Prefooter []ReduxStatePrefooterItem `json:"prefooter"` + } `json:"data"` +} + +type field struct { + Name string `json:"name"` + Value string `json:"value"` +} + +type ReduxDictionaryContainer struct { + ID string `json:"_id"` + Alias string `json:"alias"` + Name string `json:"name"` + UserID string `json:"userId"` + Created time.Time `json:"created"` + Updated time.Time `json:"updated"` + Fields []field `json:"fields"` +} + +type ReduxDictionaries struct { + Data struct { + CourseThematics ReduxDictionaryContainer `json:"coursesThematics"` + LearningType ReduxDictionaryContainer `json:"learningType"` + LearningTypeSelection ReduxDictionaryContainer `json:"learningTypeSelection"` + } `json:"data"` +} + +type InitialReduxState struct { + Metadata ReduxMetadata `json:"metadata"` + Dictionaries ReduxDictionaries `json:"dictionaries"` + Categories struct { + Data map[string]int `json:"data"` + } `json:"categories"` +} + +type PageStateProperties struct { + InitialReduxState InitialReduxState `json:"initialReduxState"` +} + +type PageState struct { + Page string `json:"page"` + Query map[string]string `json:"query"` + BuildID string `json:"buildId"` + RuntimeConfig PageStateRuntimeConfig `json:"runtimeConfig"` + Props PageStateProperties `json:"props"` +} + +func (p *PageState) Clone() *PageState { + copiedState := *p + copiedState.Query = make(map[string]string, len(p.Query)) + for k, v := range p.Query { + copiedState.Query[k] = v + } + + data := p.Props.InitialReduxState.Categories.Data + copiedData := make(map[string]int, len(data)) + for k, v := range data { + copiedData[k] = v + } + copiedState.Props.InitialReduxState.Categories.Data = copiedData + + return &copiedState +} + +type CourseDiscount struct { + PromoCode string `json:"promoCode"` + PromoCodeType string `json:"promoCodeType"` + Percent int `json:"percent"` + EndDate time.Time `json:"endDate"` + EndTime any `json:"endTime"` +} + +type CourseAdvertising struct { + Cost float64 `json:"cost"` + ButtonText string `json:"buttonText"` + ButtonMobileText string `json:"buttonMobileText"` + IsPartner bool `json:"isPartner"` + LabelText string `json:"labelText"` + Monetization struct { + Pixels struct { + Click string `json:"click"` + Display string `json:"display"` + } `json:"pixels"` + Kind string `json:"kind"` + } `json:"monetization"` + Dialog string `json:"dialog"` + SideBarBannerText string `json:"sideBarBannerText"` + OfferHighlightColor string `json:"offerHighlightColor"` + HasOffersID string `json:"hasOffersId"` + TrackingType string `json:"trackingType"` + Token []struct { + ID string `json:"_id"` + Token []string `json:"token"` + Updated time.Time `json:"updated"` + V int `json:"__v"` + } `json:"token"` +} + +type Course struct { + ID string `json:"id"` + Name string `json:"name"` + Organization string `json:"organization"` + Discount CourseDiscount `json:"discount"` + Link string `json:"link"` + Learningtype []string `json:"learningtype"` + DateStart any `json:"dateStart"` + TimeStart any `json:"timeStart"` + TimeAllHour any `json:"timeAllHour"` + TimeAllDay any `json:"timeAllDay"` + TimeAllMonth int `json:"timeAllMonth"` + IsTermApproximately bool `json:"isTermApproximately"` + DictionaryFormatFilterNew []string `json:"dictionaryFormatFilterNew"` + DictionaryLevelFilterNew []string `json:"dictionaryLevelFilterNew"` + Price int `json:"price"` + PriceAll int `json:"priceAll"` + PriceInstallment int `json:"priceInstallment"` + CourseImage string `json:"courseImage"` + WithoutDiscountPrice int `json:"withoutDiscountPrice"` + Advertising CourseAdvertising `json:"advertising"` +} + +type OrganizationName struct { + Short string `json:"short"` + Full string `json:"full"` + Prepositional string `json:"prepositional"` + Genitive string `json:"genitive"` +} + +type RatingsInfo struct { + ComplexCalculatedRatingValue float64 `json:"complexCalculatedRatingValue"` + ParticipantsCount int `json:"participantsCount"` + Approved int `json:"approved"` +} + +type Contacts struct { + Address string `json:"address"` + Phone []string `json:"phone"` +} + +type Organization struct { + ID string `json:"id"` + Alias string `json:"alias"` + License string `json:"license"` + Name OrganizationName `json:"name"` + RatingsInfo RatingsInfo `json:"ratingsInfo"` + Contacts Contacts `json:"contacts"` + Logotypes struct { + Square string `json:"square"` + Web string `json:"web"` + Android string `json:"android"` + } `json:"logotypes"` + IsLabsPartner bool `json:"isLabsPartner"` +} diff --git a/internal/infrastructure/interfaceadapters/courses/sravni/helpers.go b/internal/infrastructure/interfaceadapters/courses/sravni/helpers.go new file mode 100644 index 0000000..cf00fe2 --- /dev/null +++ b/internal/infrastructure/interfaceadapters/courses/sravni/helpers.go @@ -0,0 +1,20 @@ +package sravni + +import "golang.org/x/net/html" + +func findNode(parent *html.Node, eq func(*html.Node) (found, deeper bool)) *html.Node { + for child := parent.FirstChild; child != nil; child = child.NextSibling { + found, deeper := eq(child) + if found { + return child + } + if deeper { + deeperChild := findNode(child, eq) + if deeperChild != nil { + return deeperChild + } + } + } + + return nil +} diff --git a/pkg/utilities/slices/map.go b/pkg/utilities/slices/map.go new file mode 100644 index 0000000..aad98e1 --- /dev/null +++ b/pkg/utilities/slices/map.go @@ -0,0 +1,11 @@ +package slices + +// Map slice from one type to another one. +func Map[S any, E any](s []S, f func(S) E) []E { + out := make([]E, len(s)) + for i := range s { + out[i] = f(s[i]) + } + + return out +}