main.go 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203
  1. // Command cheapo lists and watches free game offers (Epic Games Store, Steam).
  2. //
  3. // Data is read from public storefront backends, no browser and no login
  4. // needed. Reference implementations: vogler/free-games-claimer,
  5. // jaixmario/free-games-notifier.
  6. package main
  7. import (
  8. "context"
  9. "encoding/json"
  10. "fmt"
  11. "os"
  12. "os/signal"
  13. "syscall"
  14. "time"
  15. "github.com/spf13/cobra"
  16. "gogs.netdome.biz/paul/cheapo/internal/game"
  17. "gogs.netdome.biz/paul/cheapo/internal/ops"
  18. "gogs.netdome.biz/paul/cheapo/internal/providers"
  19. "gogs.netdome.biz/paul/cheapo/internal/watch"
  20. )
  21. var (
  22. flagCountry string
  23. flagLocale string
  24. flagJSON bool
  25. flagStore string
  26. )
  27. func main() {
  28. root := &cobra.Command{
  29. Use: "cheapo",
  30. Short: "cheapo — free games lister (Go edition)",
  31. Long: `cheapo lists and watches free game offers (Epic Games Store, Steam).
  32. Data is read from public storefront backends, no browser and no login needed.
  33. References: https://github.com/vogler/free-games-claimer
  34. https://github.com/jaixmario/free-games-notifier`,
  35. SilenceUsage: true,
  36. }
  37. root.PersistentFlags().StringVar(&flagCountry, "country", "DE", "ISO country code for store region; determines currency/availability (epic only)")
  38. root.PersistentFlags().StringVar(&flagLocale, "locale", "de", "display locale for Epic (formatting only, e.g. de -> '22,39 €', en-US -> '€22.39'; currency comes from --country)")
  39. root.PersistentFlags().BoolVar(&flagJSON, "json", false, "output as JSON")
  40. root.PersistentFlags().StringVar(&flagStore, "store", "all", "which store(s): epic, steam, gog, all")
  41. root.AddCommand(listCmd(), watchCmd(), mcpCmd(), chattoSendCmd(), chattoRoomsCmd())
  42. if err := root.Execute(); err != nil {
  43. os.Exit(1)
  44. }
  45. }
  46. // newProviders returns the providers selected by --store.
  47. func newProviders() ([]game.Provider, error) {
  48. return providers.New(providers.Options{
  49. Store: flagStore,
  50. Country: flagCountry,
  51. Locale: flagLocale,
  52. })
  53. }
  54. // fetchList runs ops.List over the providers selected by --store. A failing
  55. // provider is reported on stderr but does not abort the others.
  56. func fetchList(ctx context.Context, mode ops.Mode) ([]game.Game, error) {
  57. provs, err := newProviders()
  58. if err != nil {
  59. return nil, err
  60. }
  61. games, warnings, err := ops.List(ctx, provs, mode)
  62. if err != nil {
  63. return nil, err
  64. }
  65. for _, w := range warnings {
  66. fmt.Fprintf(os.Stderr, "⚠ %s\n", w)
  67. }
  68. return games, nil
  69. }
  70. // printGames renders games in CLI text form.
  71. func printGames(cmd *cobra.Command, games []game.Game) error {
  72. if flagJSON {
  73. enc := json.NewEncoder(os.Stdout)
  74. enc.SetIndent("", " ")
  75. return enc.Encode(games)
  76. }
  77. if len(games) == 0 {
  78. fmt.Fprintln(cmd.OutOrStdout(), "No matches.")
  79. return nil
  80. }
  81. out := cmd.OutOrStdout()
  82. for _, g := range games {
  83. fmt.Fprintf(out, "🎮 [%s] %s\n", g.Store, g.Title)
  84. if g.Kind != "" && g.Kind != "free-now" {
  85. fmt.Fprintf(out, " Kind: %s\n", g.Kind)
  86. }
  87. if g.Seller != "" {
  88. fmt.Fprintf(out, " By: %s\n", g.Seller)
  89. }
  90. if g.DiscountPct > 0 {
  91. if g.FinalPriceFmt != "" {
  92. fmt.Fprintf(out, " Price: %s → %s (−%d %%)\n", g.PriceFmt, g.FinalPriceFmt, g.DiscountPct)
  93. } else {
  94. fmt.Fprintf(out, " Price: %s (−%d %%)\n", g.PriceFmt, g.DiscountPct)
  95. }
  96. } else if g.PriceFmt != "" && g.Store != "steam" && g.PriceFmt != "0" {
  97. fmt.Fprintf(out, " Instead of: %s\n", g.PriceFmt)
  98. }
  99. if g.Kind == "upcoming" && !g.StartDate.IsZero() {
  100. fmt.Fprintf(out, " Free from: %s (until %s)\n",
  101. g.StartDate.Local().Format("02.01.2006 15:04"),
  102. g.EndDate.Local().Format("02.01.2006 15:04"))
  103. } else if !g.EndDate.IsZero() {
  104. fmt.Fprintf(out, " Free until: %s\n", g.EndDate.UTC().Format(time.RFC3339))
  105. }
  106. fmt.Fprintf(out, " %s\n", g.URL)
  107. fmt.Fprintln(out)
  108. }
  109. return nil
  110. }
  111. func listCmd() *cobra.Command {
  112. var (
  113. upcoming bool
  114. deals bool
  115. )
  116. cmd := &cobra.Command{
  117. Use: "list",
  118. Aliases: []string{"ls"},
  119. Short: "List games currently free (optionally upcoming or deals)",
  120. RunE: func(cmd *cobra.Command, args []string) error {
  121. mode := ops.Free
  122. if upcoming {
  123. mode = ops.Upcoming
  124. } else if deals {
  125. mode = ops.Deals
  126. }
  127. games, err := fetchList(cmd.Context(), mode)
  128. if err != nil {
  129. return err
  130. }
  131. return printGames(cmd, games)
  132. },
  133. }
  134. cmd.Flags().BoolVar(&upcoming, "upcoming", false, "Show announced upcoming free games (Epic only)")
  135. cmd.Flags().BoolVar(&deals, "deals", false, "Show discounted games (deals) instead of free games")
  136. return cmd
  137. }
  138. func watchCmd() *cobra.Command {
  139. var (
  140. interval time.Duration
  141. once bool
  142. )
  143. cmd := &cobra.Command{
  144. Use: "watch",
  145. Short: "Wait for new free games (polling)",
  146. Long: `Polls all selected stores and reports every game that becomes newly free.
  147. The first pass records the current state as the baseline (no reports).
  148. A store that is currently unreachable does not abort watching.
  149. Exit with Ctrl+C.`,
  150. RunE: func(cmd *cobra.Command, args []string) error {
  151. ctx, stop := signal.NotifyContext(cmd.Context(), os.Interrupt, syscall.SIGTERM)
  152. defer stop()
  153. provs, err := newProviders()
  154. if err != nil {
  155. return err
  156. }
  157. if once {
  158. var all []game.Game
  159. for _, p := range provs {
  160. games, err := p.GetFreeGames(ctx)
  161. if err != nil {
  162. fmt.Fprintf(os.Stderr, "⚠ %s: %v\n", p.Name(), err)
  163. continue
  164. }
  165. all = append(all, games...)
  166. }
  167. return printGames(cmd, all)
  168. }
  169. w := watch.NewWatcher(interval, provs...)
  170. return w.Run(ctx, false, watch.NotifierFunc(func(games []game.Game) {
  171. fmt.Fprintf(os.Stderr, "\n🔔 New free games (%s):\n", time.Now().Format("15:04:05"))
  172. for _, g := range games {
  173. end := "—"
  174. if !g.EndDate.IsZero() {
  175. end = g.EndDate.Local().Format("02.01.")
  176. }
  177. fmt.Fprintf(os.Stderr, " 🎮 [%s] %s — until %s — %s\n", g.Store, g.Title, end, g.URL)
  178. }
  179. }))
  180. },
  181. }
  182. cmd.Flags().DurationVar(&interval, "interval", 15*time.Minute, "Polling interval")
  183. cmd.Flags().BoolVar(&once, "once", false, "Check once and exit")
  184. return cmd
  185. }