-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
refactor: modularize code, optimize performance, and improve output f…
…ormatting
- Loading branch information
Showing
8 changed files
with
234 additions
and
113 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,4 +1,4 @@ | ||
FROM golang:1.17 AS builder | ||
FROM golang:1.20 AS builder | ||
|
||
WORKDIR /app | ||
|
||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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. | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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= |
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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)) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) | ||
} |