Skip to content

Commit

Permalink
refactor: modularize code, optimize performance, and improve output f…
Browse files Browse the repository at this point in the history
…ormatting
  • Loading branch information
amscotti committed Apr 21, 2023
1 parent 28b7de1 commit 8860325
Show file tree
Hide file tree
Showing 8 changed files with 234 additions and 113 deletions.
2 changes: 1 addition & 1 deletion Dockerfile
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

Expand Down
32 changes: 23 additions & 9 deletions README.md
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.
```
16 changes: 15 additions & 1 deletion go.mod
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
)
23 changes: 23 additions & 0 deletions go.sum
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=
Binary file modified hacker_news.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
118 changes: 118 additions & 0 deletions hackernews/api.go
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))
}
}
43 changes: 43 additions & 0 deletions hackernews/models.go
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
}
113 changes: 11 additions & 102 deletions main.go
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)
}

0 comments on commit 8860325

Please sign in to comment.