package glance import ( "context" "fmt" "html/template" "log/slog" "net/http" "strconv" "strings" "time" ) type hackerNewsWidget struct { widgetBase `yaml:",inline"` Posts forumPostList `yaml:"-"` Limit int `yaml:"limit"` SortBy string `yaml:"sort-by"` ExtraSortBy string `yaml:"extra-sort-by"` CollapseAfter int `yaml:"collapse-after"` CommentsUrlTemplate string `yaml:"comments-url-template"` ShowThumbnails bool `yaml:"-"` } func (widget *hackerNewsWidget) initialize() error { widget. withTitle("Hacker News"). withTitleURL("https://news.ycombinator.com/"). withCacheDuration(30 * time.Minute) if widget.Limit <= 0 { widget.Limit = 15 } if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 { widget.CollapseAfter = 5 } if widget.SortBy != "top" && widget.SortBy != "new" && widget.SortBy != "best" { widget.SortBy = "top" } return nil } func (widget *hackerNewsWidget) update(ctx context.Context) { posts, err := fetchHackerNewsPosts(widget.SortBy, 40, widget.CommentsUrlTemplate) if !widget.canContinueUpdateAfterHandlingErr(err) { return } if widget.ExtraSortBy == "engagement" { posts.calculateEngagement() posts.sortByEngagement() } if widget.Limit < len(posts) { posts = posts[:widget.Limit] } widget.Posts = posts } func (widget *hackerNewsWidget) Render() template.HTML { return widget.renderTemplate(widget, forumPostsTemplate) } type hackerNewsPostResponseJson struct { Id int `json:"id"` Score int `json:"score"` Title string `json:"title"` TargetUrl string `json:"url,omitempty"` CommentCount int `json:"descendants"` TimePosted int64 `json:"time"` } func fetchHackerNewsPostIds(sort string) ([]int, error) { request, _ := http.NewRequest("GET", fmt.Sprintf("https://hacker-news.firebaseio.com/v0/%sstories.json", sort), nil) response, err := decodeJsonFromRequest[[]int](defaultHTTPClient, request) if err != nil { return nil, fmt.Errorf("%w: could not fetch list of post IDs", errNoContent) } return response, nil } func fetchHackerNewsPostsFromIds(postIds []int, commentsUrlTemplate string) (forumPostList, error) { requests := make([]*http.Request, len(postIds)) for i, id := range postIds { request, _ := http.NewRequest("GET", fmt.Sprintf("https://hacker-news.firebaseio.com/v0/item/%d.json", id), nil) requests[i] = request } task := decodeJsonFromRequestTask[hackerNewsPostResponseJson](defaultHTTPClient) job := newJob(task, requests).withWorkers(30) results, errs, err := workerPoolDo(job) if err != nil { return nil, err } posts := make(forumPostList, 0, len(postIds)) for i := range results { if errs[i] != nil { slog.Error("Failed to fetch or parse hacker news post", "error", errs[i], "url", requests[i].URL) continue } var commentsUrl string if commentsUrlTemplate == "" { commentsUrl = "https://news.ycombinator.com/item?id=" + strconv.Itoa(results[i].Id) } else { commentsUrl = strings.ReplaceAll(commentsUrlTemplate, "{POST-ID}", strconv.Itoa(results[i].Id)) } posts = append(posts, forumPost{ Title: results[i].Title, DiscussionUrl: commentsUrl, TargetUrl: results[i].TargetUrl, TargetUrlDomain: extractDomainFromUrl(results[i].TargetUrl), CommentCount: results[i].CommentCount, Score: results[i].Score, TimePosted: time.Unix(results[i].TimePosted, 0), }) } if len(posts) == 0 { return nil, errNoContent } if len(posts) != len(postIds) { return posts, fmt.Errorf("%w could not fetch some hacker news posts", errPartialContent) } return posts, nil } func fetchHackerNewsPosts(sort string, limit int, commentsUrlTemplate string) (forumPostList, error) { postIds, err := fetchHackerNewsPostIds(sort) if err != nil { return nil, err } if len(postIds) > limit { postIds = postIds[:limit] } return fetchHackerNewsPostsFromIds(postIds, commentsUrlTemplate) }