repos
/ orchard main

orchard

mirror

Every 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

4.4 KB · 168 lines · Go Raw History
  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}