widget-reddit.go 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261
  1. package glance
  2. import (
  3. "context"
  4. "errors"
  5. "fmt"
  6. "html"
  7. "html/template"
  8. "net/http"
  9. "net/url"
  10. "strings"
  11. "time"
  12. )
  13. var (
  14. redditWidgetHorizontalCardsTemplate = mustParseTemplate("reddit-horizontal-cards.html", "widget-base.html")
  15. redditWidgetVerticalCardsTemplate = mustParseTemplate("reddit-vertical-cards.html", "widget-base.html")
  16. )
  17. type redditWidget struct {
  18. widgetBase `yaml:",inline"`
  19. Posts forumPostList `yaml:"-"`
  20. Subreddit string `yaml:"subreddit"`
  21. Style string `yaml:"style"`
  22. ShowThumbnails bool `yaml:"show-thumbnails"`
  23. ShowFlairs bool `yaml:"show-flairs"`
  24. SortBy string `yaml:"sort-by"`
  25. TopPeriod string `yaml:"top-period"`
  26. Search string `yaml:"search"`
  27. ExtraSortBy string `yaml:"extra-sort-by"`
  28. CommentsUrlTemplate string `yaml:"comments-url-template"`
  29. Limit int `yaml:"limit"`
  30. CollapseAfter int `yaml:"collapse-after"`
  31. RequestUrlTemplate string `yaml:"request-url-template"`
  32. }
  33. func (widget *redditWidget) initialize() error {
  34. if widget.Subreddit == "" {
  35. return errors.New("subreddit is required")
  36. }
  37. if widget.Limit <= 0 {
  38. widget.Limit = 15
  39. }
  40. if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 {
  41. widget.CollapseAfter = 5
  42. }
  43. if !isValidRedditSortType(widget.SortBy) {
  44. widget.SortBy = "hot"
  45. }
  46. if !isValidRedditTopPeriod(widget.TopPeriod) {
  47. widget.TopPeriod = "day"
  48. }
  49. if widget.RequestUrlTemplate != "" {
  50. if !strings.Contains(widget.RequestUrlTemplate, "{REQUEST-URL}") {
  51. return errors.New("no `{REQUEST-URL}` placeholder specified")
  52. }
  53. }
  54. widget.
  55. withTitle("/r/" + widget.Subreddit).
  56. withTitleURL("https://www.reddit.com/r/" + widget.Subreddit + "/").
  57. withCacheDuration(30 * time.Minute)
  58. return nil
  59. }
  60. func isValidRedditSortType(sortBy string) bool {
  61. return sortBy == "hot" ||
  62. sortBy == "new" ||
  63. sortBy == "top" ||
  64. sortBy == "rising"
  65. }
  66. func isValidRedditTopPeriod(period string) bool {
  67. return period == "hour" ||
  68. period == "day" ||
  69. period == "week" ||
  70. period == "month" ||
  71. period == "year" ||
  72. period == "all"
  73. }
  74. func (widget *redditWidget) update(ctx context.Context) {
  75. // TODO: refactor, use a struct to pass all of these
  76. posts, err := fetchSubredditPosts(
  77. widget.Subreddit,
  78. widget.SortBy,
  79. widget.TopPeriod,
  80. widget.Search,
  81. widget.CommentsUrlTemplate,
  82. widget.RequestUrlTemplate,
  83. widget.ShowFlairs,
  84. )
  85. if !widget.canContinueUpdateAfterHandlingErr(err) {
  86. return
  87. }
  88. if len(posts) > widget.Limit {
  89. posts = posts[:widget.Limit]
  90. }
  91. if widget.ExtraSortBy == "engagement" {
  92. posts.calculateEngagement()
  93. posts.sortByEngagement()
  94. }
  95. widget.Posts = posts
  96. }
  97. func (widget *redditWidget) Render() template.HTML {
  98. if widget.Style == "horizontal-cards" {
  99. return widget.renderTemplate(widget, redditWidgetHorizontalCardsTemplate)
  100. }
  101. if widget.Style == "vertical-cards" {
  102. return widget.renderTemplate(widget, redditWidgetVerticalCardsTemplate)
  103. }
  104. return widget.renderTemplate(widget, forumPostsTemplate)
  105. }
  106. type subredditResponseJson struct {
  107. Data struct {
  108. Children []struct {
  109. Data struct {
  110. Id string `json:"id"`
  111. Title string `json:"title"`
  112. Upvotes int `json:"ups"`
  113. Url string `json:"url"`
  114. Time float64 `json:"created"`
  115. CommentsCount int `json:"num_comments"`
  116. Domain string `json:"domain"`
  117. Permalink string `json:"permalink"`
  118. Stickied bool `json:"stickied"`
  119. Pinned bool `json:"pinned"`
  120. IsSelf bool `json:"is_self"`
  121. Thumbnail string `json:"thumbnail"`
  122. Flair string `json:"link_flair_text"`
  123. ParentList []struct {
  124. Id string `json:"id"`
  125. Subreddit string `json:"subreddit"`
  126. Permalink string `json:"permalink"`
  127. } `json:"crosspost_parent_list"`
  128. } `json:"data"`
  129. } `json:"children"`
  130. } `json:"data"`
  131. }
  132. func templateRedditCommentsURL(template, subreddit, postId, postPath string) string {
  133. template = strings.ReplaceAll(template, "{SUBREDDIT}", subreddit)
  134. template = strings.ReplaceAll(template, "{POST-ID}", postId)
  135. template = strings.ReplaceAll(template, "{POST-PATH}", strings.TrimLeft(postPath, "/"))
  136. return template
  137. }
  138. func fetchSubredditPosts(subreddit, sort, topPeriod, search, commentsUrlTemplate, requestUrlTemplate string, showFlairs bool) (forumPostList, error) {
  139. query := url.Values{}
  140. var requestUrl string
  141. if search != "" {
  142. query.Set("q", search+" subreddit:"+subreddit)
  143. query.Set("sort", sort)
  144. }
  145. if sort == "top" {
  146. query.Set("t", topPeriod)
  147. }
  148. if search != "" {
  149. requestUrl = fmt.Sprintf("https://www.reddit.com/search.json?%s", query.Encode())
  150. } else {
  151. requestUrl = fmt.Sprintf("https://www.reddit.com/r/%s/%s.json?%s", subreddit, sort, query.Encode())
  152. }
  153. if requestUrlTemplate != "" {
  154. requestUrl = strings.ReplaceAll(requestUrlTemplate, "{REQUEST-URL}", requestUrl)
  155. }
  156. request, err := http.NewRequest("GET", requestUrl, nil)
  157. if err != nil {
  158. return nil, err
  159. }
  160. // Required to increase rate limit, otherwise Reddit randomly returns 429 even after just 2 requests
  161. setBrowserUserAgentHeader(request)
  162. responseJson, err := decodeJsonFromRequest[subredditResponseJson](defaultClient, request)
  163. if err != nil {
  164. return nil, err
  165. }
  166. if len(responseJson.Data.Children) == 0 {
  167. return nil, fmt.Errorf("no posts found")
  168. }
  169. posts := make(forumPostList, 0, len(responseJson.Data.Children))
  170. for i := range responseJson.Data.Children {
  171. post := &responseJson.Data.Children[i].Data
  172. if post.Stickied || post.Pinned {
  173. continue
  174. }
  175. var commentsUrl string
  176. if commentsUrlTemplate == "" {
  177. commentsUrl = "https://www.reddit.com" + post.Permalink
  178. } else {
  179. commentsUrl = templateRedditCommentsURL(commentsUrlTemplate, subreddit, post.Id, post.Permalink)
  180. }
  181. forumPost := forumPost{
  182. Title: html.UnescapeString(post.Title),
  183. DiscussionUrl: commentsUrl,
  184. TargetUrlDomain: post.Domain,
  185. CommentCount: post.CommentsCount,
  186. Score: post.Upvotes,
  187. TimePosted: time.Unix(int64(post.Time), 0),
  188. }
  189. if post.Thumbnail != "" && post.Thumbnail != "self" && post.Thumbnail != "default" {
  190. forumPost.ThumbnailUrl = post.Thumbnail
  191. }
  192. if !post.IsSelf {
  193. forumPost.TargetUrl = post.Url
  194. }
  195. if showFlairs && post.Flair != "" {
  196. forumPost.Tags = append(forumPost.Tags, post.Flair)
  197. }
  198. if len(post.ParentList) > 0 {
  199. forumPost.IsCrosspost = true
  200. forumPost.TargetUrlDomain = "r/" + post.ParentList[0].Subreddit
  201. if commentsUrlTemplate == "" {
  202. forumPost.TargetUrl = "https://www.reddit.com" + post.ParentList[0].Permalink
  203. } else {
  204. forumPost.TargetUrl = templateRedditCommentsURL(
  205. commentsUrlTemplate,
  206. post.ParentList[0].Subreddit,
  207. post.ParentList[0].Id,
  208. post.ParentList[0].Permalink,
  209. )
  210. }
  211. }
  212. posts = append(posts, forumPost)
  213. }
  214. return posts, nil
  215. }