diff --git a/Dockerfile b/Dockerfile index 53b4945..d7d9991 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.17 AS builder +FROM golang:1.20 AS builder WORKDIR /app diff --git a/README.md b/README.md index c80ef4b..2223c1e 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,41 @@ # hacker_news + A command line tool that shows the top stories from [Hacker News](https://news.ycombinator.com/) using the [Hacker News API from Firebase](https://github.com/HackerNews/API). This is a port of an dotnet version, [hn-top](https://github.com/amscotti/hn-top) which I used to learn about Go and understand how to keep the order of the stories when using Goroutines with help of Mutex and WaitGroup. -![hacker_news Output](https://github.com/amscotti/hacker_news/blob/main/hacker_news.png?raw=true) +![hacker_news Output](hacker_news.png) ## Building and Running ### With Go -* Build with `go build` -* Then run with `./hacker_news` +To build and run the application, follow these steps: + +1. Clone the repository. +2. Navigate to the root directory of the project. +3. Build the binary with `go build`. +4. Run the binary with `./hacker_news`. ### With Docker -* Build with `docker build -t hacker_news . ` -* Then run with `docker run hacker_news` +To build and run the application with Docker, follow these steps: + +1. Clone the repository. +2. Navigate to the root directory of the project. +3. Build the Docker image with `docker build -t hacker_news .`. +4. Run the Docker container with `docker run hacker_news`. -### With pre-build Docker image from [ghcr.io](https://github.com/amscotti/hacker_news/pkgs/container/hacker_news) -* To download and run, use `docker run ghcr.io/amscotti/hacker_news:main` +### With pre-built Docker image from [ghcr.io](https://github.com/amscotti/hacker_news/pkgs/container/hacker_news) +To download and run the pre-built Docker image, follow these steps: + +1. Install Docker. +2. Run the command `docker run ghcr.io/amscotti/hacker_news:main`. ### Command Line Arguments +The following command line arguments are available: + ``` Usage of ./hacker_news: -n int - Number of top news to show. (default 5) - -u Show source urls. + Specify the number of top stories to display (default: 5). (default 5) + -u Include the source URLs of the stories in the output. ``` \ No newline at end of file diff --git a/go.mod b/go.mod index 9643358..5f0de8d 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,17 @@ module github.com/amscotti/hacker_news -go 1.17 +go 1.20 + +require github.com/charmbracelet/lipgloss v0.7.1 + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.17 // indirect + github.com/mattn/go-runewidth v0.0.14 // indirect + github.com/muesli/reflow v0.3.0 // indirect + github.com/muesli/termenv v0.15.1 // indirect + github.com/rivo/uniseg v0.2.0 // indirect + github.com/valyala/fastjson v1.6.4 + golang.org/x/sys v0.6.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..2bae8e2 --- /dev/null +++ b/go.sum @@ -0,0 +1,23 @@ +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/lipgloss v0.7.1 h1:17WMwi7N1b1rVWOjMT+rCh7sQkvDU75B2hbZpc5Kc1E= +github.com/charmbracelet/lipgloss v0.7.1/go.mod h1:yG0k3giv8Qj8edTCbbg6AlQ5e8KNWpFujkNawKNhE2c= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= +github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= +github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= +github.com/muesli/termenv v0.15.1 h1:UzuTb/+hhlBugQz28rpzey4ZuKcZ03MeKsoG7IJZIxs= +github.com/muesli/termenv v0.15.1/go.mod h1:HeAQPTzpfs016yGtA4g00CsdYnVLJvxsS4ANqrZs2sQ= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/valyala/fastjson v1.6.4 h1:uAUNq9Z6ymTgGhcm0UynUAB6tlbakBrz6CQFax3BXVQ= +github.com/valyala/fastjson v1.6.4/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/hacker_news.png b/hacker_news.png index 5442f68..d433360 100644 Binary files a/hacker_news.png and b/hacker_news.png differ diff --git a/hackernews/api.go b/hackernews/api.go new file mode 100644 index 0000000..da8e791 --- /dev/null +++ b/hackernews/api.go @@ -0,0 +1,118 @@ +package hackernews + +import ( + "fmt" + "io" + "log" + "net/http" + "sync" + "time" + + "github.com/valyala/fastjson" +) + +const ( + STORIES_URL = "https://hacker-news.firebaseio.com/v0/topstories.json" + ITEM_URL_BASE = "https://hacker-news.firebaseio.com/v0/item" +) + +func createClient() *http.Client { + transport := &http.Transport{ + MaxIdleConns: 100, + IdleConnTimeout: 30 * time.Second, + } + return &http.Client{ + Transport: transport, + Timeout: time.Duration(10 * time.Second), + } +} + +func fetchJSON(url string) (*fastjson.Value, error) { + client := createClient() + resp, err := client.Get(url) + if err != nil { + return nil, fmt.Errorf("failed to fetch %s: %w", url, err) + } + defer resp.Body.Close() + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + value, err := fastjson.ParseBytes(data) + if err != nil { + return nil, fmt.Errorf("failed to parse JSON: %w", err) + } + + return value, nil +} + +func downloadStory(id int, wg *sync.WaitGroup, m *sync.Mutex, stories map[int]Story) { + defer wg.Done() + + url := fmt.Sprintf("%s/%d.json", ITEM_URL_BASE, id) + + value, err := fetchJSON(url) + if err != nil { + log.Printf("Error fetching story with id %d: %v", id, err) + return + } + + story := Story{ + Title: string(value.GetStringBytes("title")), + By: string(value.GetStringBytes("by")), + Descendants: int(value.GetInt("descendants")), + Id: int(value.GetInt("id")), + Score: int(value.GetInt("score")), + Time: int(value.GetInt("time")), + Type: string(value.GetStringBytes("type")), + URL: string(value.GetStringBytes("url")), + } + + kids := value.GetArray("kids") + for i := range kids { + story.Kids = append(story.Kids, int(kids[i].GetInt())) + } + + m.Lock() + stories[id] = story + m.Unlock() +} + +func FetchTopStories(number int, showSourceUrls bool) { + value, err := fetchJSON(STORIES_URL) + if err != nil { + log.Printf("Error fetching top stories: %v", err) + return + } + + var ids []int + valueArray := value.GetArray() + for i := 0; i < len(valueArray); i++ { + id := int(valueArray[i].GetInt()) + ids = append(ids, id) + } + + if len(ids) > number { + ids = ids[:number] + } + + storyDetails := make(map[int]Story) + + var m sync.Mutex + var wg sync.WaitGroup + + wg.Add(len(ids)) + + for _, id := range ids { + go downloadStory(id, &wg, &m, storyDetails) + } + + wg.Wait() + + for _, id := range ids { + story := storyDetails[id] + fmt.Print(story.PrintStyling(showSourceUrls)) + } +} diff --git a/hackernews/models.go b/hackernews/models.go new file mode 100644 index 0000000..2be8fce --- /dev/null +++ b/hackernews/models.go @@ -0,0 +1,43 @@ +package hackernews + +import ( + "fmt" + + "github.com/charmbracelet/lipgloss" +) + +const ( + HackerNewsURLFormat = "https://news.ycombinator.com/item?id=%d" +) + +type Story struct { + Title string + By string + Descendants int + Id int + Kids []int + Score int + Time int + Type string + URL string +} + +func newStyleWithFGColor(color string) lipgloss.Style { + return lipgloss.NewStyle().Foreground(lipgloss.Color(color)) +} + +func (s *Story) PrintStyling(showSourceUrl bool) string { + titleStyle := newStyleWithFGColor("#FAB005").Bold(true) + metaStyle := newStyleWithFGColor("#B8B8B8") + linkStyle := newStyleWithFGColor("#69C9D0").Underline(true) + + output := titleStyle.Render(s.Title) + "\n" + output += metaStyle.Render(fmt.Sprintf("score: %d\tcomments: %d\tuser: %s", s.Score, s.Descendants, s.By)) + "\n" + output += "url: " + linkStyle.Render(fmt.Sprintf(HackerNewsURLFormat, s.Id)) + "\n" + if showSourceUrl { + output += newStyleWithFGColor("#D6749C").Render(s.URL) + "\n" + } + output += "\n" + + return output +} diff --git a/main.go b/main.go index c01ea9e..07badb4 100644 --- a/main.go +++ b/main.go @@ -1,117 +1,26 @@ package main import ( - "encoding/json" + "errors" "flag" "fmt" - "io/ioutil" - "log" - "net/http" - "sync" -) -const ( - STORIES_URL = "https://hacker-news.firebaseio.com/v0/topstories.json" - ITEM_URL_BASE = "https://hacker-news.firebaseio.com/v0/item" + "github.com/amscotti/hacker_news/hackernews" ) -type Story struct { - Title string - By string - Descendants int - Id int - Kids []int - Score int - Time int - Type string - URL string -} - -func (s *Story) Print(showSourceUrl bool) { - fmt.Println(s.Title) - fmt.Printf("score: %d\tcomments: %d\tuser: %s\n", s.Score, s.Descendants, s.By) - fmt.Printf("url: https://news.ycombinator.com/item?id=%d\n", s.Id) - if showSourceUrl { - fmt.Println(s.URL) - } - fmt.Println("") -} - -func downloadStory(id int, wg *sync.WaitGroup, m *sync.Mutex, stories map[int]Story) { - url := fmt.Sprintf("%s/%d.json", ITEM_URL_BASE, id) - - rsp, err := http.Get(url) - if err != nil { - log.Fatal(err) - } - defer rsp.Body.Close() - - data, err := ioutil.ReadAll(rsp.Body) - if err != nil { - log.Fatal(err) - } - - var story Story - if err := json.Unmarshal(data, &story); err != nil { - log.Fatal(err) - } - - m.Lock() - stories[id] = story - m.Unlock() - - wg.Done() -} - -func fetch(number int, showSourceUrls bool) { - fmt.Println("Fetching latest stories...") - - rsp, err := http.Get(STORIES_URL) - if err != nil { - log.Fatal(err) - } - defer rsp.Body.Close() - - data, err := ioutil.ReadAll(rsp.Body) - if err != nil { - log.Fatal(err) - } - - var ids []int - if err := json.Unmarshal(data, &ids); err != nil { - log.Fatal(err) - } - - if len(ids) > number { - ids = ids[:number] - } - - storyDetails := make(map[int]Story) - - var m sync.Mutex - var wg sync.WaitGroup - - wg.Add(len(ids)) - - for _, id := range ids { - go downloadStory(id, &wg, &m, storyDetails) - } - - wg.Wait() - - for _, id := range ids { - story := storyDetails[id] - story.Print(showSourceUrls) - } -} - func main() { var number int var showSourceUrls bool - flag.IntVar(&number, "n", 5, "Number of top news to show.") - flag.BoolVar(&showSourceUrls, "u", false, "Show source urls.") + flag.IntVar(&number, "n", 5, "Specify the number of top stories to display (default: 5).") + flag.BoolVar(&showSourceUrls, "u", false, "Include the source URLs of the stories in the output.") flag.Parse() - fetch(number, showSourceUrls) + if number <= 0 { + err := errors.New("number of stories must be a positive integer") + fmt.Println(err) + return + } + + hackernews.FetchTopStories(number, showSourceUrls) }