From 32e0180f914d6a86f8f7cae935b112fad3ceab8e Mon Sep 17 00:00:00 2001 From: lordwelch Date: Mon, 15 Feb 2021 11:44:23 -0800 Subject: [PATCH] Add package for retrieving information from GOG (the changelog) --- go.mod | 1 + gog/changelog.go | 224 +++++++++++++++++++++++++++++++++++++++++++++++ gog/gog.go | 202 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 427 insertions(+) create mode 100644 gog/changelog.go create mode 100644 gog/gog.go diff --git a/go.mod b/go.mod index eab4e91..de77c1e 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/google/uuid v1.1.4 github.com/kr/pretty v0.2.1 github.com/pierrec/lz4/v4 v4.1.3 + golang.org/x/net v0.0.0-20200301022130-244492dfa37a golang.org/x/sys v0.0.0-20210108172913-0df2131ae363 gonum.org/v1/gonum v0.8.2 ) diff --git a/gog/changelog.go b/gog/changelog.go new file mode 100644 index 0000000..1baabb2 --- /dev/null +++ b/gog/changelog.go @@ -0,0 +1,224 @@ +package gog + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "os" + "strings" + + "golang.org/x/net/html" + "golang.org/x/net/html/atom" +) + +type Change struct { + Title string + Changes []string + Sub []Change + recurse bool +} + +func (c Change) str(indent int) string { + var ( + s = new(strings.Builder) + ind = strings.Repeat("\t", indent) + ) + fmt.Fprintln(s, ind+c.Title) + for _, v := range c.Changes { + fmt.Fprintln(s, ind+"\t"+v) + } + for _, v := range c.Sub { + s.WriteString(v.str(indent + 1)) + } + // s.WriteRune('\n') + return s.String() +} + +func (c Change) String() string { + return c.str(0) +} + +func debug(f ...interface{}) { + if len(os.Args) > 2 && os.Args[2] == "debug" { + fmt.Println(f...) + } +} + +// Stringify returns the text from a node and all its children +func stringify(h *html.Node) string { + var ( + f func(*html.Node) + str string + def = h + ) + f = func(n *html.Node) { + if n.Type == html.TextNode { + str += strings.TrimSpace(n.Data) + " " + } + if n.DataAtom == atom.Br { + str += "\n" + } + for c := n.FirstChild; c != nil; c = c.NextSibling { + f(c) + } + } + f(def) + return str +} + +// ParseChange returns the parsed change and the next node to start parsing the next change +func ParseChange(h *html.Node, title string) (Change, *html.Node) { + var c Change + c.Title = title + debug("change", strings.TrimSpace(h.Data)) +l: + for h.Data != "hr" { + switch h.Type { + case html.ErrorNode: + panic("An error happened!?!?") + case html.TextNode: + debug("text", strings.TrimSpace(h.Data)) + if strings.TrimSpace(h.Data) != "" { + c.Changes = append(c.Changes, strings.TrimSpace(h.Data)) + } + case html.ElementNode: + switch h.DataAtom { + case atom.H1, atom.H2, atom.H3, atom.H4, atom.H5, atom.H6, atom.P: + debug(h.DataAtom.String()) + if c.Title == "" { + c.Title = strings.TrimSpace(stringify(h)) + } else { + tmp := drain(h.NextSibling) + switch tmp.DataAtom { + case atom.H1, atom.H2, atom.H3, atom.H4, atom.H5, atom.H6: + h = tmp + + c.Changes = append(c.Changes, strings.TrimSpace(stringify(h))) + break + } + var cc Change + debug("h", strings.TrimSpace(h.Data)) + debug("h", strings.TrimSpace(h.NextSibling.Data)) + cc, h = ParseChange(h.NextSibling, stringify(h)) + debug("h2", h.Data, h.PrevSibling.Data) + // h = h.Parent + c.Sub = append(c.Sub, cc) + continue + } + case atom.Ul: + debug(h.DataAtom.String()) + h = h.FirstChild + continue + case atom.Li: + debug("li", h.DataAtom.String()) + if h.FirstChild != h.LastChild && h.FirstChild.NextSibling.DataAtom != atom.P && h.FirstChild.NextSibling.DataAtom != atom.A { + var cc Change + cc, h = ParseChange(h.FirstChild.NextSibling, strings.TrimSpace(h.FirstChild.Data)) + h = h.Parent + if h.NextSibling != nil { + h = h.NextSibling + } + // pretty.Println(cc) + c.Sub = append(c.Sub, cc) + break l + } + fallthrough + default: + var ( + f func(*html.Node) + str string + def = h + ) + f = func(n *html.Node) { + if n.Type == html.TextNode { + str += strings.TrimSpace(n.Data) + " " + } + for c := n.FirstChild; c != nil; c = c.NextSibling { + f(c) + } + } + f(def) + c.Changes = append(c.Changes, strings.TrimSpace(str)) + } + } + if h.DataAtom == atom.Ul { + h = h.Parent + } + if h.NextSibling != nil { // Move to the next node, h should never be nil + debug("next", h.Type, h.NextSibling.Type) + h = h.NextSibling + } else if h.DataAtom == atom.Li || h.PrevSibling.DataAtom == atom.Li && h.Parent.DataAtom != atom.Body { // If we are currently in a list then go up one unless that would take us to body + debug("ul", strings.TrimSpace(h.Data), strings.TrimSpace(h.Parent.Data)) + if h.Parent.NextSibling == nil { + h = h.Parent + } else { // go to parents next sibling so we don't parse the same node again + h = h.Parent.NextSibling + } + debug("break2", strings.TrimSpace(h.Data)) + break + } else { + debug("I don't believe this should ever happen") + break + } + } + h = drain(h) + return c, h +} + +// drain skips over non-Element Nodes +func drain(h *html.Node) *html.Node { + for h.NextSibling != nil { + if h.Type == html.ElementNode { + break + } + h = h.NextSibling + } + return h +} + +func ParseChangelog(ch, title string) (Change, error) { + var ( + p *html.Node + v Change + err error + ) + v.Title = title + p, err = html.Parse(strings.NewReader(ch)) + if err != nil { + return v, err + } + p = p.FirstChild.FirstChild.NextSibling.FirstChild + for p != nil && p.NextSibling != nil { + var tmp Change + tmp, p = ParseChange(p, "") + if p.DataAtom == atom.Hr { + p = p.NextSibling + } + v.Sub = append(v.Sub, tmp) + } + return v, nil +} + +func getGOGInfo(id string) (GOGalaxy, error) { + var ( + r *http.Response + err error + b []byte + info GOGalaxy + ) + r, err = http.Get("https://api.gog.com/products/" + id + "?expand=downloads,expanded_dlcs,description,screenshots,videos,related_products,changelog") + if err != nil { + return GOGalaxy{}, fmt.Errorf("failed to retrieve GOG info: %w", err) + } + b, err = ioutil.ReadAll(r.Body) + if err != nil { + return GOGalaxy{}, fmt.Errorf("failed to retrieve GOG info: %w", err) + } + r.Body.Close() + err = json.Unmarshal(b, &info) + if err != nil { + return GOGalaxy{}, fmt.Errorf("failed to retrieve GOG info: %w", err) + } + return info, nil +} diff --git a/gog/gog.go b/gog/gog.go new file mode 100644 index 0000000..6efc4a4 --- /dev/null +++ b/gog/gog.go @@ -0,0 +1,202 @@ +package gog + +import ( + "bytes" + "encoding/json" + "reflect" + "time" +) + +type GOGalaxy struct { + ID int `json:"id"` + Title string `json:"title"` + PurchaseLink string `json:"purchase_link"` + Slug string `json:"slug"` + ContentSystemCompatibility struct { + Windows bool `json:"windows"` + Osx bool `json:"osx"` + Linux bool `json:"linux"` + } `json:"content_system_compatibility"` + Languages map[string]string `json:"languages"` + Links struct { + PurchaseLink string `json:"purchase_link"` + ProductCard string `json:"product_card"` + Support string `json:"support"` + Forum string `json:"forum"` + } `json:"links"` + InDevelopment struct { + Active bool `json:"active"` + Until interface{} `json:"until"` + } `json:"in_development"` + IsSecret bool `json:"is_secret"` + IsInstallable bool `json:"is_installable"` + GameType string `json:"game_type"` + IsPreOrder bool `json:"is_pre_order"` + ReleaseDate time.Time `json:"release_date"` + Images Images `json:"images"` + Dlcs DLCs `json:"dlcs"` + Downloads struct { + Installers []Download `json:"installers"` + Patches []Download `json:"patches"` + LanguagePacks []Download `json:"language_packs"` + BonusContent []Download `json:"bonus_content"` + } `json:"downloads"` + ExpandedDlcs []Product `json:"expanded_dlcs"` + Description struct { + Lead string `json:"lead"` + Full string `json:"full"` + WhatsCoolAboutIt string `json:"whats_cool_about_it"` + } `json:"description"` + Screenshots []struct { + ImageID string `json:"image_id"` + FormatterTemplateURL string `json:"formatter_template_url"` + FormattedImages []struct { + FormatterName string `json:"formatter_name"` + ImageURL string `json:"image_url"` + } `json:"formatted_images"` + } `json:"screenshots"` + Videos []struct { + VideoURL string `json:"video_url"` + ThumbnailURL string `json:"thumbnail_url"` + Provider string `json:"provider"` + } `json:"videos"` + RelatedProducts []Product `json:"related_products"` + Changelog string `json:"changelog"` +} + +func (gog *GOGalaxy) UnmarshalJSON(d []byte) error { + var err error + type T2 GOGalaxy // create new type with same structure as T but without its method set to avoid infinite `UnmarshalJSON` call stack + x := struct { + T2 + ReleaseDate string `json:"release_date"` + }{} // don't forget this, if you do and 't' already has some fields set you would lose them; see second example + + if err = json.Unmarshal(d, &x); err != nil { + return err + } + *gog = GOGalaxy(x.T2) + + if gog.ReleaseDate, err = time.Parse("2006-01-02T15:04:05-0700", x.ReleaseDate); err != nil { + return err + } + return nil +} + +type DLCs struct { + Products []struct { + ID int `json:"id"` + Link string `json:"link"` + ExpandedLink string `json:"expanded_link"` + } `json:"products"` + AllProductsURL string `json:"all_products_url"` + ExpandedAllProductsURL string `json:"expanded_all_products_url"` +} + +func (DLC *DLCs) UnmarshalJSON(d []byte) error { + type T2 GOGalaxy + var x T2 + dec := json.NewDecoder(bytes.NewBuffer(d)) + t, err := dec.Token() + if err != nil { + return err + } + if delim, ok := t.(json.Delim); ok { + if delim == '[' { + return nil + } else { + return json.Unmarshal(d, &x) + } + } else { + return &json.UnmarshalTypeError{ + Value: reflect.TypeOf(t).String(), + Type: reflect.TypeOf(*DLC), + Offset: 0, + Struct: "DLCs", + Field: ".", + } + } +} + +type Languages map[string]string + +type Download struct { + ID string `json:"id"` + Name string `json:"name"` + Os string `json:"os"` + Language string `json:"language"` + LanguageFull string `json:"language_full"` + Version string `json:"version"` + TotalSize int64 `json:"total_size"` + Files []File `json:"files"` +} + +func (d *Download) UnmarshalJSON(data []byte) error { + var i json.Number + type T2 Download // create new type with same structure as T but without its method set to avoid infinite `UnmarshalJSON` call stack + x := struct { + T2 + ID json.RawMessage `json:"id"` + }{} // don't forget this, if you do and 't' already has some fields set you would lose them; see second example + + if err := json.Unmarshal(data, &x); err != nil { + return err + } + *d = Download(x.T2) + if err := json.Unmarshal(x.ID, &i); err != nil { + if err := json.Unmarshal(x.ID, &d.ID); err != nil { + return err + } else { + return nil + } + return err + } + d.ID = i.String() + return nil +} + +type File struct { + ID string `json:"id"` + Size int `json:"size"` + Downlink string `json:"downlink"` +} + +type Product struct { + ID int `json:"id"` + Title string `json:"title"` + PurchaseLink string `json:"purchase_link"` + Slug string `json:"slug"` + ContentSystemCompatibility struct { + Windows bool `json:"windows"` + Osx bool `json:"osx"` + Linux bool `json:"linux"` + } `json:"content_system_compatibility"` + Links struct { + PurchaseLink string `json:"purchase_link"` + ProductCard string `json:"product_card"` + Support string `json:"support"` + Forum string `json:"forum"` + } `json:"links"` + InDevelopment struct { + Active bool `json:"active"` + Until time.Time `json:"until"` + } `json:"in_development"` + IsSecret bool `json:"is_secret"` + IsInstallable bool `json:"is_installable"` + GameType string `json:"game_type"` + IsPreOrder bool `json:"is_pre_order"` + ReleaseDate string `json:"release_date"` + Images Images `json:"images"` + Languages Languages `json:"languages,omitempty"` +} + +type Images struct { + Background string `json:"background"` + Logo string `json:"logo"` + Logo2X string `json:"logo2x"` + Icon string `json:"icon"` + SidebarIcon string `json:"sidebarIcon"` + SidebarIcon2X string `json:"sidebarIcon2x"` + MenuNotificationAv string `json:"menuNotificationAv"` + MenuNotificationAv2 string `json:"menuNotificationAv2"` +}