orchard
mirrorEvery site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.
blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics
1package main
2
3import (
4 "context"
5 "fmt"
6 "net/url"
7 "sort"
8 "strings"
9 "time"
10)
11
12// Two feeds, two reasons. Hacker News is what Isaac asked for, and Lobsters
13// sits next to it so the panel is not one community's view of the same day.
14//
15// Hacker News comes from Algolia rather than from the official Firebase API.
16// Firebase hands back 500 bare story ids and charges one HTTP request per story
17// to resolve each into a title, which is 30 requests for a panel that shows 10.
18// Algolia's front_page search returns all of them, scored and with comment
19// counts, in a single response.
20const (
21 hackerNewsURL = "https://hn.algolia.com/api/v1/search?tags=front_page"
22 lobstersURL = "https://lobste.rs/hottest.json"
23
24 // Enough to fill the panel with a little room to drop anything malformed.
25 storiesShown = 10
26)
27
28// Story is one row, from either feed.
29type Story struct {
30 Title string `json:"title"`
31 URL string `json:"url"`
32 Host string `json:"host"`
33 Comments string `json:"comments"`
34 Points int `json:"points"`
35 Count int `json:"count"`
36 Age string `json:"age"`
37}
38
39type algoliaPayload struct {
40 Hits []struct {
41 ObjectID string `json:"objectID"`
42 Title string `json:"title"`
43 URL string `json:"url"`
44 Points int `json:"points"`
45 NumComments int `json:"num_comments"`
46 CreatedAtI int64 `json:"created_at_i"`
47 } `json:"hits"`
48}
49
50func fetchHackerNews(ctx context.Context, g *Guard, now time.Time) ([]Story, error) {
51 var payload algoliaPayload
52 if err := getJSON(ctx, g, "algolia", hackerNewsURL, &payload); err != nil {
53 return nil, err
54 }
55
56 stories := make([]Story, 0, len(payload.Hits))
57 for _, h := range payload.Hits {
58 if h.Title == "" {
59 continue
60 }
61 link := h.URL
62 discuss := "https://news.ycombinator.com/item?id=" + h.ObjectID
63 // An Ask HN or a Show HN with no link is its own discussion, so the
64 // title has to point somewhere rather than nowhere.
65 if link == "" {
66 link = discuss
67 }
68 stories = append(stories, Story{
69 Title: h.Title,
70 URL: link,
71 Host: hostOf(link),
72 Comments: discuss,
73 Points: h.Points,
74 Count: h.NumComments,
75 Age: humanAge(time.Unix(h.CreatedAtI, 0), now),
76 })
77 }
78
79 // Algolia returns the front page in its own order, which is not by score.
80 // Sorting here means the two feeds are ranked the same way and the panel
81 // reads consistently.
82 sort.SliceStable(stories, func(i, j int) bool { return stories[i].Points > stories[j].Points })
83 return trim(stories), nil
84}
85
86type lobstersStory struct {
87 Title string `json:"title"`
88 URL string `json:"url"`
89 ShortIDURL string `json:"short_id_url"`
90 CommentsURL string `json:"comments_url"`
91 Score int `json:"score"`
92 CommentCount int `json:"comment_count"`
93 CreatedAt string `json:"created_at"`
94}
95
96func fetchLobsters(ctx context.Context, g *Guard, now time.Time) ([]Story, error) {
97 var payload []lobstersStory
98 if err := getJSON(ctx, g, "lobsters", lobstersURL, &payload); err != nil {
99 return nil, err
100 }
101
102 stories := make([]Story, 0, len(payload))
103 for _, s := range payload {
104 if s.Title == "" {
105 continue
106 }
107 link := s.URL
108 discuss := s.CommentsURL
109 if discuss == "" {
110 discuss = s.ShortIDURL
111 }
112 if link == "" {
113 link = discuss
114 }
115
116 age := ""
117 // Lobsters writes RFC 3339 with an offset. A value that will not parse
118 // costs the row its age and nothing else.
119 if t, err := time.Parse(time.RFC3339, s.CreatedAt); err == nil {
120 age = humanAge(t, now)
121 }
122
123 stories = append(stories, Story{
124 Title: s.Title,
125 URL: link,
126 Host: hostOf(link),
127 Comments: discuss,
128 Points: s.Score,
129 Count: s.CommentCount,
130 Age: age,
131 })
132 }
133
134 sort.SliceStable(stories, func(i, j int) bool { return stories[i].Points > stories[j].Points })
135 return trim(stories), nil
136}
137
138func trim(s []Story) []Story {
139 if len(s) > storiesShown {
140 return s[:storiesShown]
141 }
142 return s
143}
144
145// hostOf is what the row shows next to the title, so it is the readable host
146// and not the authority: no port, no leading www.
147func hostOf(raw string) string {
148 u, err := url.Parse(raw)
149 if err != nil || u.Host == "" {
150 return ""
151 }
152 return strings.TrimPrefix(u.Hostname(), "www.")
153}
154
155func humanAge(t, now time.Time) string {
156 d := now.Sub(t)
157 switch {
158 case d < time.Minute:
159 return "just now"
160 case d < time.Hour:
161 return fmt.Sprintf("%dm", int(d.Minutes()))
162 case d < 24*time.Hour:
163 return fmt.Sprintf("%dh", int(d.Hours()))
164 default:
165 return fmt.Sprintf("%dd", int(d.Hours()/24))
166 }
167}