Bladeren bron

list/chatto-send --deals: discounted games (epic+steam), link in store column, fix parse fn wiring

Paul Klumpp 3 dagen geleden
bovenliggende
commit
8f31cdfdd3
5 gewijzigde bestanden met toevoegingen van 265 en 39 verwijderingen
  1. 84 13
      cmd/cheapo/chatto.go
  2. 45 5
      cmd/cheapo/main.go
  3. 29 6
      internal/epic/client.go
  4. 13 11
      internal/game/game.go
  5. 94 4
      internal/steam/client.go

+ 84 - 13
cmd/cheapo/chatto.go

@@ -1,6 +1,7 @@
 package main
 
 import (
+	"context"
 	"fmt"
 	"os"
 	"strings"
@@ -35,25 +36,33 @@ func chattoClientFromEnv() (*chatto.Client, error) {
 	return chatto.NewClient(base, token), nil
 }
 
-// formatGames renders games as a Chatto Markdown message body.
-func formatGames(games []game.Game, upcoming bool) string {
+// formatGames renders games as a Chatto Markdown message body. mode is ""
+// (free now), "upcoming" or "deals".
+func formatGames(games []game.Game, mode string) string {
 	if len(games) == 0 {
-		if upcoming {
+		switch mode {
+		case "upcoming":
 			return "🗓 **Upcoming free games:** none announced."
+		case "deals":
+			return "🏷 **Current deals:** none found."
+		default:
+			return "🎮 **Current free games:** none on offer."
 		}
-		return "🎮 **Current free games:** none on offer."
 	}
 
 	var b strings.Builder
-	if upcoming {
+	switch mode {
+	case "upcoming":
 		b.WriteString("## 🗓 Upcoming free games\n\n")
-	} else {
+	case "deals":
+		b.WriteString("## 🏷 Current deals\n\n")
+	default:
 		b.WriteString("## 🎮 Current free games\n\n")
 	}
 
 	// One table per store keeps columns uniform (dates only for epic).
-	b.WriteString("| Game | Store | Period |\n")
-	b.WriteString("|---|---|---|\n")
+	b.WriteString("| Game | Store | Price | Period |\n")
+	b.WriteString("|---|---|---|---|\n")
 	for _, g := range games {
 		period := "—"
 		if g.Kind == "upcoming" && !g.StartDate.IsZero() && !g.EndDate.IsZero() {
@@ -63,24 +72,67 @@ func formatGames(games []game.Game, upcoming bool) string {
 		} else if !g.EndDate.IsZero() {
 			period = "until " + g.EndDate.Local().Format("02.01.")
 		}
-		fmt.Fprintf(&b, "| **%s** | [%s](%s) | %s |\n",
-			escapePipe(g.Title), g.Store, g.URL, period)
+		fmt.Fprintf(&b, "| **%s** | [%s](%s) | %s | %s |\n",
+			escapePipe(g.Title), g.Store, g.URL, priceCell(g), period)
 	}
 
 	b.WriteString("\n_As of now — free from/until 17:00 Europe/Berlin each._")
 	return b.String()
 }
 
+// priceCell renders the price column: "0 €" for free games, original → final
+// with percent for deals.
+func priceCell(g game.Game) string {
+	switch {
+	case g.DiscountPct > 0 && g.FinalPriceFmt != "":
+		return fmt.Sprintf("%s → **%s** (−%d %%)", g.PriceFmt, g.FinalPriceFmt, g.DiscountPct)
+	case g.Kind == "free-now" || g.Kind == "free-to-keep":
+		return "**0 €**"
+	case g.PriceFmt != "":
+		return g.PriceFmt
+	default:
+		return "—"
+	}
+}
+
 // escapePipe prevents table cell breakage via titles containing '|'.
 func escapePipe(s string) string {
 	return strings.ReplaceAll(s, "|", "\\|")
 }
 
+// fetchDeals queries all selected providers for discounted deals (any
+// discount); a failing provider is reported on stderr but does not abort.
+func fetchDeals(ctx context.Context) ([]game.Game, error) {
+	provs, err := newProviders()
+	if err != nil {
+		return nil, err
+	}
+	var all []game.Game
+	var hadErr error
+	for _, p := range provs {
+		d, ok := p.(interface {
+			GetDeals(ctx context.Context) ([]game.Game, error)
+		})
+		if !ok {
+			continue // provider has no deals concept (gog)
+		}
+		games, err := d.GetDeals(ctx)
+		if err != nil {
+			fmt.Fprintf(os.Stderr, "⚠ %s: %v\n", p.Name(), err)
+			hadErr = err
+			continue
+		}
+		all = append(all, games...)
+	}
+	return all, hadErr
+}
+
 // chattoSendCmd posts the current free games to a Chatto room.
 func chattoSendCmd() *cobra.Command {
 	var (
 		roomID   string
 		upcoming bool
+		deals    bool
 	)
 	cmd := &cobra.Command{
 		Use:   "chatto-send",
@@ -100,21 +152,27 @@ variables (set by the operator, never fetched automatically).`,
 			}
 
 			var games []game.Game
-			if upcoming {
+			switch {
+			case deals:
+				games, err = fetchDeals(ctx)
+				if err != nil && len(games) == 0 {
+					return err
+				}
+			case upcoming:
 				// Upcoming windows only exist for Epic.
 				u := newEpic()
 				games, err = u.GetUpcomingGames(ctx)
 				if err != nil {
 					return err
 				}
-			} else {
+			default:
 				games, err = fetchAll(ctx)
 				if err != nil && len(games) == 0 {
 					return err
 				}
 			}
 
-			msg, err := c.CreateMessage(ctx, roomID, formatGames(games, upcoming), "")
+			msg, err := c.CreateMessage(ctx, roomID, formatGames(games, modeOf(upcoming, deals)), "")
 			if err != nil {
 				return err
 			}
@@ -124,9 +182,22 @@ variables (set by the operator, never fetched automatically).`,
 	}
 	cmd.Flags().StringVar(&roomID, "room", "", "Chatto room ID (e.g. RtLrcOrNgvv3StA)")
 	cmd.Flags().BoolVar(&upcoming, "upcoming", false, "Post upcoming instead of current free games (Epic only)")
+	cmd.Flags().BoolVar(&deals, "deals", false, "Post discounted games (deals) instead of free games")
 	return cmd
 }
 
+// modeOf picks the heading mode for formatGames.
+func modeOf(upcoming, deals bool) string {
+	switch {
+	case upcoming:
+		return "upcoming"
+	case deals:
+		return "deals"
+	default:
+		return ""
+	}
+}
+
 // chattoRoomsCmd lists rooms visible to the token's user — verification helper.
 func chattoRoomsCmd() *cobra.Command {
 	return &cobra.Command{

+ 45 - 5
cmd/cheapo/main.go

@@ -100,6 +100,7 @@ func fetchAll(ctx context.Context) ([]game.Game, error) {
 	return all, hadErr
 }
 
+// printGames renders games in CLI text form.
 func printGames(cmd *cobra.Command, games []game.Game) error {
 	if flagJSON {
 		enc := json.NewEncoder(os.Stdout)
@@ -107,7 +108,7 @@ func printGames(cmd *cobra.Command, games []game.Game) error {
 		return enc.Encode(games)
 	}
 	if len(games) == 0 {
-		fmt.Fprintln(cmd.OutOrStdout(), "No free games on offer right now.")
+		fmt.Fprintln(cmd.OutOrStdout(), "No matches.")
 		return nil
 	}
 	out := cmd.OutOrStdout()
@@ -119,7 +120,9 @@ func printGames(cmd *cobra.Command, games []game.Game) error {
 		if g.Seller != "" {
 			fmt.Fprintf(out, "   By: %s\n", g.Seller)
 		}
-		if g.PriceFmt != "" && g.Store != "steam" && g.PriceFmt != "0" {
+		if g.DiscountPct > 0 {
+			fmt.Fprintf(out, "   Price: %s → %s (−%d %%)\n", g.PriceFmt, g.FinalPriceFmt, g.DiscountPct)
+		} else if g.PriceFmt != "" && g.Store != "steam" && g.PriceFmt != "0" {
 			fmt.Fprintf(out, "   Instead of: %s\n", g.PriceFmt)
 		}
 		if g.Kind == "upcoming" && !g.StartDate.IsZero() {
@@ -136,14 +139,24 @@ func printGames(cmd *cobra.Command, games []game.Game) error {
 }
 
 func listCmd() *cobra.Command {
-	var upcoming bool
+	var (
+		upcoming bool
+		deals    bool
+	)
 	cmd := &cobra.Command{
 		Use:     "list",
 		Aliases: []string{"ls"},
-		Short:   "List games currently free (epic: optionally upcoming ones)",
+		Short:   "List games currently free (optionally upcoming or deals)",
 		RunE: func(cmd *cobra.Command, args []string) error {
 			ctx := cmd.Context()
-			if upcoming {
+			switch {
+			case deals:
+				games, err := listDeals(ctx)
+				if err != nil && len(games) == 0 {
+					return err
+				}
+				return printGames(cmd, games)
+			case upcoming:
 				// Upcoming windows only exist for Epic.
 				var all []game.Game
 				var hadErr error
@@ -177,9 +190,36 @@ func listCmd() *cobra.Command {
 		},
 	}
 	cmd.Flags().BoolVar(&upcoming, "upcoming", false, "Show announced upcoming free games (Epic only)")
+	cmd.Flags().BoolVar(&deals, "deals", false, "Show discounted games (deals) instead of free games")
 	return cmd
 }
 
+// listDeals queries all providers that expose deals.
+func listDeals(ctx context.Context) ([]game.Game, error) {
+	provs, err := newProviders()
+	if err != nil {
+		return nil, err
+	}
+	var all []game.Game
+	var hadErr error
+	for _, p := range provs {
+		d, ok := p.(interface {
+			GetDeals(ctx context.Context) ([]game.Game, error)
+		})
+		if !ok {
+			continue
+		}
+		games, err := d.GetDeals(ctx)
+		if err != nil {
+			fmt.Fprintf(os.Stderr, "⚠ %s: %v\n", p.Name(), err)
+			hadErr = err
+			continue
+		}
+		all = append(all, games...)
+	}
+	return all, hadErr
+}
+
 func watchCmd() *cobra.Command {
 	var (
 		interval time.Duration

+ 29 - 6
internal/epic/client.go

@@ -55,7 +55,8 @@ type offer struct {
 	StartDate time.Time `json:"startDate"`
 	EndDate   time.Time `json:"endDate"`
 	Discount  struct {
-		DiscountPrice int `json:"discountPrice"`
+		DiscountPrice      int `json:"discountPrice"`
+		DiscountPercentage int `json:"discountPercentage"`
 	} `json:"discountSetting"`
 }
 
@@ -137,8 +138,10 @@ func (c *Client) fetchPromotions(ctx context.Context) ([]element, error) {
 
 // buildGames converts the offer windows of one element into games. Windows
 // that do not satisfy keep are skipped; a game appearing with several windows
-// (e.g. extended giveaway) keeps the one with the latest end date.
-func buildGames(el element, kind string, windows []offer, keep func(offer) bool, games []game.Game) []game.Game {
+// (e.g. extended giveaway) keeps the one with the latest end date. When
+// freeOnly is true, only 100 % discounts pass; otherwise discounted deals are
+// included too.
+func buildGames(el element, kind string, windows []offer, keep func(offer) bool, freeOnly bool, games []game.Game) []game.Game {
 	slug := el.URLSlug
 	if len(el.CatalogNs.Mappings) > 0 && el.CatalogNs.Mappings[0].PageSlug != "" {
 		// urlSlug is sometimes a wrong ID (see vogler/free-games-claimer,
@@ -147,7 +150,10 @@ func buildGames(el element, kind string, windows []offer, keep func(offer) bool,
 		slug = el.CatalogNs.Mappings[0].PageSlug
 	}
 	for _, o := range windows {
-		if o.Discount.DiscountPrice != 0 || !keep(o) {
+		if !keep(o) {
+			continue
+		}
+		if freeOnly && o.Discount.DiscountPrice != 0 {
 			continue
 		}
 		g := game.Game{
@@ -163,6 +169,12 @@ func buildGames(el element, kind string, windows []offer, keep func(offer) bool,
 			EndDate:     o.EndDate,
 			PriceFmt:    el.Price.TotalPrice.FmtPrice.OriginalPrice,
 		}
+		if o.Discount.DiscountPercentage > 0 && o.Discount.DiscountPercentage < 100 {
+			g.DiscountPct = o.Discount.DiscountPercentage
+			// The API's fmtPrice shows the *original* price; compute the
+			// discounted price display from the discount price (cents).
+			g.FinalPriceFmt = fmt.Sprintf("%d.%02d €", o.Discount.DiscountPrice/100, o.Discount.DiscountPrice%100)
+		}
 		if i := indexOfID(games, g.ID); i >= 0 {
 			if g.EndDate.After(games[i].EndDate) {
 				games[i] = g
@@ -183,6 +195,16 @@ func sortByEnd(games []game.Game) {
 // GetFreeGames returns the games that are currently free (100 % discount,
 // active promotion window), sorted by end date (soonest first).
 func (c *Client) GetFreeGames(ctx context.Context) ([]game.Game, error) {
+	return c.getPromoted(ctx, true)
+}
+
+// GetDeals returns currently discounted games (any discount, including free),
+// sorted by end date (soonest first).
+func (c *Client) GetDeals(ctx context.Context) ([]game.Game, error) {
+	return c.getPromoted(ctx, false)
+}
+
+func (c *Client) getPromoted(ctx context.Context, freeOnly bool) ([]game.Game, error) {
 	elements, err := c.fetchPromotions(ctx)
 	if err != nil {
 		return nil, err
@@ -196,7 +218,8 @@ func (c *Client) GetFreeGames(ctx context.Context) ([]game.Game, error) {
 		}
 		for _, group := range el.Promotions.PromotionalOffers {
 			games = buildGames(el, "free-now", group.PromotionalOffers,
-				func(o offer) bool { return !now.Before(o.StartDate) && !now.After(o.EndDate) }, games)
+				func(o offer) bool { return !now.Before(o.StartDate) && !now.After(o.EndDate) },
+				freeOnly, games)
 		}
 	}
 
@@ -220,7 +243,7 @@ func (c *Client) GetUpcomingGames(ctx context.Context) ([]game.Game, error) {
 		}
 		for _, group := range el.Promotions.UpcomingPromotionalOffers {
 			games = buildGames(el, "upcoming", group.PromotionalOffers,
-				func(o offer) bool { return o.StartDate.After(now) }, games)
+				func(o offer) bool { return o.StartDate.After(now) }, true, games)
 		}
 	}
 

+ 13 - 11
internal/game/game.go

@@ -9,17 +9,19 @@ import (
 
 // Game describes one free game offer, independent of the store it came from.
 type Game struct {
-	Title       string    `json:"title"`
-	ID          string    `json:"id"`   // store-stable ID (epic offer id / steam appid)
-	Slug        string    `json:"slug"` // store page slug or empty
-	URL         string    `json:"url"`  // full store page URL
-	Store       string    `json:"store"`
-	Kind        string    `json:"kind,omitempty"` // e.g. "free-now", "upcoming", "free-to-keep", "free-weekend"
-	Description string    `json:"description,omitempty"`
-	Seller      string    `json:"seller,omitempty"`
-	StartDate   time.Time `json:"start_date,omitempty"`
-	EndDate     time.Time `json:"end_date,omitempty"`        // end of the free period, zero = unknown
-	PriceFmt    string    `json:"price_formatted,omitempty"` // original price, formatted
+	Title         string    `json:"title"`
+	ID            string    `json:"id"`   // store-stable ID (epic offer id / steam appid)
+	Slug          string    `json:"slug"` // store page slug or empty
+	URL           string    `json:"url"`  // full store page URL
+	Store         string    `json:"store"`
+	Kind          string    `json:"kind,omitempty"` // e.g. "free-now", "upcoming", "free-to-keep", "free-weekend"
+	Description   string    `json:"description,omitempty"`
+	Seller        string    `json:"seller,omitempty"`
+	StartDate     time.Time `json:"start_date,omitempty"`
+	EndDate       time.Time `json:"end_date,omitempty"`              // end of the free period, zero = unknown
+	PriceFmt      string    `json:"price_formatted,omitempty"`       // original price, formatted
+	FinalPriceFmt string    `json:"final_price_formatted,omitempty"` // discounted price, formatted (deals only)
+	DiscountPct   int       `json:"discount_pct,omitempty"`          // e.g. 90 = 90 % off (deals only)
 }
 
 // Provider is implemented by every store client.

+ 94 - 4
internal/steam/client.go

@@ -14,6 +14,8 @@ import (
 	"net/http"
 	"net/url"
 	"regexp"
+	"sort"
+	"strconv"
 	"strings"
 	"time"
 
@@ -23,6 +25,7 @@ import (
 
 const (
 	searchURL     = "https://store.steampowered.com/search/?maxprice=free&specials=1"
+	dealsURL      = "https://store.steampowered.com/search/?specials=1"
 	featuredURL   = "https://store.steampowered.com/api/featuredcategories/"
 	appDetailsURL = "https://store.steampowered.com/api/appdetails"
 	userAgent     = "Mozilla/5.0 (X11; Linux x86_64; rv:127.0) Gecko/20100101 Firefox/127.0"
@@ -76,8 +79,9 @@ func (c *Client) GetFreeGames(ctx context.Context) ([]game.Game, error) {
 // --- Free to Keep (HTML search page) ---
 
 var (
-	reAppID      = regexp.MustCompile(`/app/(\d+)`)
-	rePriceStrip = regexp.MustCompile(`\s+`)
+	reAppID       = regexp.MustCompile(`/app/(\d+)`)
+	rePriceStrip  = regexp.MustCompile(`\s+`)
+	reDiscountPct = regexp.MustCompile(`-(\d+)%`)
 )
 
 func (c *Client) getFreeToKeep(ctx context.Context) ([]game.Game, error) {
@@ -87,7 +91,32 @@ func (c *Client) getFreeToKeep(ctx context.Context) ([]game.Game, error) {
 	}
 	defer resp.Body.Close()
 
-	doc, err := html.Parse(resp.Body)
+	return c.parseSearchRows(resp.Body, parseResultRow)
+}
+
+// GetDeals returns currently discounted Steam games (specials search page,
+// any discount), sorted by discount (deepest first).
+func (c *Client) GetDeals(ctx context.Context) ([]game.Game, error) {
+	resp, err := c.get(ctx, dealsURL)
+	if err != nil {
+		return nil, err
+	}
+	defer resp.Body.Close()
+
+	games, err := c.parseSearchRows(resp.Body, parseDealRow)
+	if err != nil {
+		return nil, err
+	}
+	sort.Slice(games, func(i, j int) bool {
+		return games[i].DiscountPct > games[j].DiscountPct
+	})
+	return games, nil
+}
+
+// parseSearchRows walks a Steam search results page and applies parse per
+// `a.search_result_row`, deduplicating by ID.
+func (c *Client) parseSearchRows(r io.Reader, parse func(*html.Node) *game.Game) ([]game.Game, error) {
+	doc, err := html.Parse(r)
 	if err != nil {
 		return nil, fmt.Errorf("steam: parse search page: %w", err)
 	}
@@ -100,7 +129,7 @@ func (c *Client) getFreeToKeep(ctx context.Context) ([]game.Game, error) {
 		if n.Type == html.ElementNode && n.Data == "a" {
 			cls := classAttr(n)
 			if strings.Contains(cls, "search_result_row") {
-				if g := parseResultRow(n); g != nil && !seen[g.ID] {
+				if g := parse(n); g != nil && !seen[g.ID] {
 					seen[g.ID] = true
 					games = append(games, *g)
 				}
@@ -177,6 +206,67 @@ func parseResultRow(a *html.Node) *game.Game {
 	}
 }
 
+// parseDealRow extracts one discounted game from a `a.search_result_row` on
+// the specials page (any discount, with original/final price and percent).
+func parseDealRow(a *html.Node) *game.Game {
+	href := ""
+	for _, at := range a.Attr {
+		if at.Key == "href" {
+			href = at.Val
+		}
+	}
+	m := reAppID.FindStringSubmatch(href)
+	if m == nil {
+		return nil
+	}
+	appID := m[1]
+
+	var title, orig, final, discount string
+	var find func(*html.Node)
+	find = func(n *html.Node) {
+		if n.Type == html.ElementNode {
+			cls := classAttr(n)
+			switch {
+			case strings.Contains(cls, "title") && title == "":
+				title = textOf(n)
+			case strings.Contains(cls, "discount_original_price") && orig == "":
+				orig = textOf(n)
+			case strings.Contains(cls, "discount_final_price") && final == "":
+				final = textOf(n)
+			case strings.Contains(cls, "discount_pct") && discount == "":
+				discount = textOf(n)
+			}
+		}
+		for ch := n.FirstChild; ch != nil; ch = ch.NextSibling {
+			find(ch)
+		}
+	}
+	find(a)
+
+	if strings.TrimSpace(final) == "" {
+		return nil
+	}
+	pct := 0
+	if dm := reDiscountPct.FindStringSubmatch(discount); dm != nil {
+		pct, _ = strconv.Atoi(dm[1])
+	}
+	if pct <= 0 {
+		return nil
+	}
+
+	return &game.Game{
+		Title:         strings.TrimSpace(title),
+		ID:            "steam-" + appID,
+		Slug:          appID,
+		URL:           "https://store.steampowered.com/app/" + appID + "/",
+		Store:         "steam",
+		Kind:          "deal",
+		PriceFmt:      strings.TrimSpace(orig),
+		FinalPriceFmt: strings.TrimSpace(final),
+		DiscountPct:   pct,
+	}
+}
+
 func textOf(n *html.Node) string {
 	var b strings.Builder
 	var visit func(*html.Node)