widget-hacker-news.go 4.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152
  1. package glance
  2. import (
  3. "context"
  4. "fmt"
  5. "html/template"
  6. "log/slog"
  7. "net/http"
  8. "strconv"
  9. "strings"
  10. "time"
  11. )
  12. type hackerNewsWidget struct {
  13. widgetBase `yaml:",inline"`
  14. Posts forumPostList `yaml:"-"`
  15. Limit int `yaml:"limit"`
  16. SortBy string `yaml:"sort-by"`
  17. ExtraSortBy string `yaml:"extra-sort-by"`
  18. CollapseAfter int `yaml:"collapse-after"`
  19. CommentsUrlTemplate string `yaml:"comments-url-template"`
  20. ShowThumbnails bool `yaml:"-"`
  21. }
  22. func (widget *hackerNewsWidget) initialize() error {
  23. widget.
  24. withTitle("Hacker News").
  25. withTitleURL("https://news.ycombinator.com/").
  26. withCacheDuration(30 * time.Minute)
  27. if widget.Limit <= 0 {
  28. widget.Limit = 15
  29. }
  30. if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 {
  31. widget.CollapseAfter = 5
  32. }
  33. if widget.SortBy != "top" && widget.SortBy != "new" && widget.SortBy != "best" {
  34. widget.SortBy = "top"
  35. }
  36. return nil
  37. }
  38. func (widget *hackerNewsWidget) update(ctx context.Context) {
  39. posts, err := fetchHackerNewsPosts(widget.SortBy, 40, widget.CommentsUrlTemplate)
  40. if !widget.canContinueUpdateAfterHandlingErr(err) {
  41. return
  42. }
  43. if widget.ExtraSortBy == "engagement" {
  44. posts.calculateEngagement()
  45. posts.sortByEngagement()
  46. }
  47. if widget.Limit < len(posts) {
  48. posts = posts[:widget.Limit]
  49. }
  50. widget.Posts = posts
  51. }
  52. func (widget *hackerNewsWidget) Render() template.HTML {
  53. return widget.renderTemplate(widget, forumPostsTemplate)
  54. }
  55. type hackerNewsPostResponseJson struct {
  56. Id int `json:"id"`
  57. Score int `json:"score"`
  58. Title string `json:"title"`
  59. TargetUrl string `json:"url,omitempty"`
  60. CommentCount int `json:"descendants"`
  61. TimePosted int64 `json:"time"`
  62. }
  63. func fetchHackerNewsPostIds(sort string) ([]int, error) {
  64. request, _ := http.NewRequest("GET", fmt.Sprintf("https://hacker-news.firebaseio.com/v0/%sstories.json", sort), nil)
  65. response, err := decodeJsonFromRequest[[]int](defaultClient, request)
  66. if err != nil {
  67. return nil, fmt.Errorf("%w: could not fetch list of post IDs", errNoContent)
  68. }
  69. return response, nil
  70. }
  71. func fetchHackerNewsPostsFromIds(postIds []int, commentsUrlTemplate string) (forumPostList, error) {
  72. requests := make([]*http.Request, len(postIds))
  73. for i, id := range postIds {
  74. request, _ := http.NewRequest("GET", fmt.Sprintf("https://hacker-news.firebaseio.com/v0/item/%d.json", id), nil)
  75. requests[i] = request
  76. }
  77. task := decodeJsonFromRequestTask[hackerNewsPostResponseJson](defaultClient)
  78. job := newJob(task, requests).withWorkers(30)
  79. results, errs, err := workerPoolDo(job)
  80. if err != nil {
  81. return nil, err
  82. }
  83. posts := make(forumPostList, 0, len(postIds))
  84. for i := range results {
  85. if errs[i] != nil {
  86. slog.Error("Failed to fetch or parse hacker news post", "error", errs[i], "url", requests[i].URL)
  87. continue
  88. }
  89. var commentsUrl string
  90. if commentsUrlTemplate == "" {
  91. commentsUrl = "https://news.ycombinator.com/item?id=" + strconv.Itoa(results[i].Id)
  92. } else {
  93. commentsUrl = strings.ReplaceAll(commentsUrlTemplate, "{POST-ID}", strconv.Itoa(results[i].Id))
  94. }
  95. posts = append(posts, forumPost{
  96. Title: results[i].Title,
  97. DiscussionUrl: commentsUrl,
  98. TargetUrl: results[i].TargetUrl,
  99. TargetUrlDomain: extractDomainFromUrl(results[i].TargetUrl),
  100. CommentCount: results[i].CommentCount,
  101. Score: results[i].Score,
  102. TimePosted: time.Unix(results[i].TimePosted, 0),
  103. })
  104. }
  105. if len(posts) == 0 {
  106. return nil, errNoContent
  107. }
  108. if len(posts) != len(postIds) {
  109. return posts, fmt.Errorf("%w could not fetch some hacker news posts", errPartialContent)
  110. }
  111. return posts, nil
  112. }
  113. func fetchHackerNewsPosts(sort string, limit int, commentsUrlTemplate string) (forumPostList, error) {
  114. postIds, err := fetchHackerNewsPostIds(sort)
  115. if err != nil {
  116. return nil, err
  117. }
  118. if len(postIds) > limit {
  119. postIds = postIds[:limit]
  120. }
  121. return fetchHackerNewsPostsFromIds(postIds, commentsUrlTemplate)
  122. }