commit fcd43598d63fd3ecef6564bd836746fea5847b8b from: Oliver Lowe date: Wed Jul 16 12:08:18 2025 UTC hnatom: add command to generate atom feed of hn front page commit - 8e542a00827deacaea5a7dd721a2f09f18ebf9ac commit + fcd43598d63fd3ecef6564bd836746fea5847b8b blob - 7cd6f5a89adc50af9df979a5a614218e810038ea (mode 644) blob + /dev/null --- src/hn/hn.go +++ /dev/null @@ -1,122 +0,0 @@ -// Package hn provides a filesystem interface to items on Hacker News. -package hn - -import ( - "bytes" - "encoding/json" - "fmt" - "html" - "io" - "io/fs" - "net/http" - "os" - "path" - "strconv" - "strings" - "time" -) - -const APIRoot = "https://hacker-news.firebaseio.com/v0" - -type Item struct { - ID int - Type string - By string - Time int - Text string - Parent int - URL string - Title string -} - -func (it *Item) Name() string { return strconv.Itoa(it.ID) } -func (it *Item) Size() int64 { r := toMessage(it); return r.Size() } -func (it *Item) Mode() fs.FileMode { return 0o444 } -func (it *Item) ModTime() time.Time { return time.Unix(int64(it.Time), 0) } -func (it *Item) IsDir() bool { return false } -func (it *Item) Sys() any { return nil } - -type FS struct { - cache fs.FS -} - -func CacheDirFS(name string) *FS { - return &FS{cache: os.DirFS(name)} -} - -func (fsys *FS) Open(name string) (fs.File, error) { - if !fs.ValidPath(name) { - return nil, &fs.PathError{"open", name, fs.ErrInvalid} - } - name = path.Clean(name) - switch name { - case ".": - return nil, fmt.Errorf("TODO") - default: - if _, err := strconv.Atoi(name); err != nil { - return nil, &fs.PathError{"open", name, fs.ErrNotExist} - } - } - if fsys.cache != nil { - if f, err := fsys.cache.Open(name); err == nil { - return f, nil - } - } - - u := fmt.Sprintf("%s/item/%s.json", APIRoot, name) - resp, err := http.Get(u) - if err != nil { - return nil, err - } - if resp.StatusCode != http.StatusOK { - return nil, err - } - return &file{rc: resp.Body}, nil -} - -type file struct { - rc io.ReadCloser - item *Item - msg *bytes.Reader -} - -func (f *file) Read(p []byte) (int, error) { - var n int - if f.item == nil { - if err := json.NewDecoder(f.rc).Decode(&f.item); err != nil { - return n, fmt.Errorf("decode item: %v", err) - } - } - if f.msg == nil { - f.msg = toMessage(f.item) - } - return f.msg.Read(p) -} - -func (f *file) Stat() (fs.FileInfo, error) { return f.item, nil } - -func (f *file) Close() error { - f.msg = nil - return f.rc.Close() -} - -func toMessage(item *Item) *bytes.Reader { - buf := &bytes.Buffer{} - fmt.Fprintf(buf, "From: %s\n", item.By) - fmt.Fprintf(buf, "Message-ID: <%d@news.ycombinator.com>\n", item.ID) - fmt.Fprintf(buf, "Date: %s\n", time.Unix(int64(item.Time), 0).Format(time.RFC1123Z)) - if item.Parent != 0 { - fmt.Fprintf(buf, "References: <%d@news.ycombinator.com>\n", item.Parent) - } - if item.Title != "" { - fmt.Fprintf(buf, "Subject: %s\n", item.Title) - } - fmt.Fprintln(buf) - if item.URL != "" { - fmt.Fprintln(buf, item.URL) - } - if item.Text != "" { - fmt.Fprintln(buf, strings.ReplaceAll(html.UnescapeString(item.Text), "

", "\n\n")) - } - return bytes.NewReader(buf.Bytes()) -} blob - cd77eb626540f20621904d81d32281846ba20376 (mode 644) blob + /dev/null --- src/hn/hn_test.go +++ /dev/null @@ -1,19 +0,0 @@ -package hn - -import ( - "io" - "os" - "testing" -) - -func TestFS(t *testing.T) { - fsys := &FS{} - f, err := fsys.Open("39845126") - if err != nil { - t.Fatal(err) - } - defer f.Close() - if _, err := io.Copy(os.Stderr, f); err != nil { - t.Fatal(err) - } -} blob - /dev/null blob + 8f3e47df5df493afe67aa18b9b88b14ca1dcc4ad (mode 644) --- /dev/null +++ src/hnatom/hnatom.go @@ -0,0 +1,136 @@ +// Command hnatom writes to the standard output a RFC 4287 Atom feed +// of current top stories from the Hacker News front page. +// The flags are: +// +// -n count +// Include count items in the feed. The default is 30. +// +// See also the [Hacker News API]. +// +// [Hacker News API]: https://github.com/HackerNews/API +package main + +import ( + "bytes" + "encoding/json" + "encoding/xml" + "flag" + "fmt" + "log" + "net/http" + "os" + "strconv" + "time" + + "olowe.co/x/atom" +) + +const apiRoot = "https://hacker-news.firebaseio.com/v0" + +type Item struct { + ID int + Type string + By string + Time int + Text string + Parent int + URL string + Title string + Score int + Descendants int +} + +func Get(id int) (*Item, error) { + u := fmt.Sprintf("%s/item/%s.json", apiRoot, strconv.Itoa(id)) + resp, err := http.Get(u) + if err != nil { + return nil, err + } + defer resp.Body.Close() + var item Item + err = json.NewDecoder(resp.Body).Decode(&item) + return &item, err +} + +func Top() ([]int, error) { + resp, err := http.Get(apiRoot + "/topstories.json") + if err != nil { + return nil, err + } + defer resp.Body.Close() + ids := make([]int, 500) // we know the API returns at most 500 items. + err = json.NewDecoder(resp.Body).Decode(&ids) + return ids, err +} + +const maxEntries = 5 + +func entryContent(item *Item) []byte { + buf := &bytes.Buffer{} + fmt.Fprintf(buf, "Score: %d\n", item.Score) + fmt.Fprintln(buf, "
") + comments := fmt.Sprintf("https://news.ycombinator.com/item?id=%d", item.ID) + fmt.Fprintf(buf, "Comments: %d", comments, item.Descendants) + return buf.Bytes() +} + +// The most number of items the top API endpoint will return. +const maxItems = 500 + +// 30 is the item count on the front page of Hacker News. +var numItems = flag.Uint("n", 30, "number of items to fetch") + +func init() { + flag.Parse() + if *numItems > maxItems { + *numItems = maxItems + fmt.Fprintln(os.Stderr, "warning: maximum of 500 entries can be fetched") + } +} + +func main() { + top, err := Top() + if err != nil { + log.Fatal(err) + } + + feed := &atom.Feed{ + ID: "http://home.olowe.co/hnatom/feed.atom", + Title: "HN Atom", + Subtitle: "Top posts from Hacker News", + Link: []atom.Link{ + { + Rel: "alternate", + Type: "html", + HRef: "https://news.ycombinator.com", + }, + }, + Updated: time.Now(), + Entries: make([]atom.Entry, *numItems), + } + + for i := range top[:len(feed.Entries)] { + item, err := Get(top[i]) + if err != nil { + log.Println(err) + continue + } + feed.Entries[i] = atom.Entry{ + ID: fmt.Sprintf("%s/item/%d.json", apiRoot, top[i]), + Title: item.Title, + Updated: time.Unix(int64(item.Time), 0), + Author: &atom.Author{ + Name: item.By, + URI: "https://news.ycombinator.com/user?id=" + item.By, + }, + Content: []byte(entryContent(item)), + Links: []atom.Link{{HRef: item.URL}}, + } + } + + b, err := xml.MarshalIndent(feed, "", "\t") + if err != nil { + log.Fatal(err) + } + os.Stdout.Write(b) +}