Commit Diff


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), "<p>", "\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, "<br>")
+	comments := fmt.Sprintf("https://news.ycombinator.com/item?id=%d", item.ID)
+	fmt.Fprintf(buf, "<a href=%q>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)
+}