├── .gitignore ├── LICENSE ├── README.md ├── article.go ├── cache.go ├── cli.go ├── go.mod ├── go.sum ├── hncli.go ├── main.go ├── readme ├── comments.png └── stories.png └── server.go /.gitignore: -------------------------------------------------------------------------------- 1 | .*.swp 2 | .*.swo 3 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 4 | *.o 5 | *.a 6 | *.so 7 | 8 | # Folders 9 | _obj 10 | _test 11 | 12 | # Architecture specific extensions/prefixes 13 | *.[568vq] 14 | [568vq].out 15 | 16 | *.cgo1.go 17 | *.cgo2.c 18 | _cgo_defun.c 19 | _cgo_gotypes.go 20 | _cgo_export.* 21 | 22 | _testmain.go 23 | 24 | *.exe 25 | *.test 26 | hn 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Andrew Stuart 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## About 2 | 3 | A Hacker News ncurses CLI reader written in Go. Currently known to work on Linux, and a few people have gotten it to work on OSX. 4 | 5 | Right now it's able to view articles, view comments, and open a page in your default browser, all done directly from the site using goquery (jquery-like library for Go), goncurses, and xdg-open for opening pages. 6 | 7 | ![Story view](https://raw.github.com/andrewstuart/hn/master/readme/stories.png) 8 | 9 | ![Comment view](https://raw.github.com/andrewstuart/hn/master/readme/comments.png) 10 | 11 | ## Installation 12 | 13 | Assuming you have your GOPATH and PATH set appropriately: 14 | 15 | Unfortunately, you'll also need mercurial installed, for packages hosted at code.google.com. If you're working with Go, you've probably already done that. 16 | 17 | ```bash 18 | go get github.com/andrewstuart/hn 19 | ``` 20 | 21 | ## Usage 22 | 23 | ```bash 24 | $ hn 25 | ``` 26 | 27 | ### Story view 28 | - n) Go to next page 29 | - p) Go to previous page 30 | - (num)c) View comments for story (num) 31 | - (num)o) Open story (num) in default browser 32 | - q) Quit hn 33 | 34 | ### Comments view 35 | - d) Go down 30 lines 36 | - u) Go up 30 lines 37 | - j) Go down 1 line 38 | - k) Go up 1 line 39 | - n) Go down 1 page 40 | - p) Go up 1 page 41 | - q) Go back to story view 42 | 43 | ## API (unfinished/deprecated/idk) 44 | 45 | This basically only works for page 1 in its current state, IIRC. 46 | 47 | ```bash 48 | $ hn -s -p 3000 & 49 | 50 | $ curl localhost:3000 51 | ``` 52 | -------------------------------------------------------------------------------- /article.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "compress/gzip" 5 | "crypto/tls" 6 | "fmt" 7 | "log" 8 | "net/http" 9 | "regexp" 10 | "strconv" 11 | "strings" 12 | "time" 13 | 14 | "github.com/PuerkitoBio/goquery" 15 | "github.com/rthornton128/goncurses" 16 | ) 17 | 18 | const YC_ROOT = "https://news.ycombinator.com/" 19 | const ROWS_PER_ARTICLE = 3 20 | 21 | var agoRegexp = regexp.MustCompile(`((?:\w*\W){2})(?:ago)`) 22 | 23 | var trans *http.Transport = &http.Transport{ 24 | TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, 25 | DisableCompression: false, 26 | } 27 | 28 | var cfduid string 29 | 30 | var client *http.Client = &http.Client{Transport: trans} 31 | 32 | func doReq(req *http.Request) (doc *goquery.Document) { 33 | req.Header.Set("cookie", cfduid) 34 | req.Header.Set("referrer", "https://news.ycombinator.com/news") 35 | req.Header.Set("user-agent", "CLI Scraper (github.com/andrewstuart/hn)") 36 | req.Header.Set("accept-encoding", "gzip") 37 | 38 | if resp, err := client.Do(req); err != nil { 39 | log.Println(err) 40 | } else { 41 | if unzipper, zerr := gzip.NewReader(resp.Body); zerr != nil { 42 | log.Println(zerr) 43 | } else { 44 | var qerr error 45 | if doc, qerr = goquery.NewDocumentFromReader(unzipper); qerr != nil { 46 | log.Fatal(qerr) 47 | } 48 | } 49 | } 50 | return 51 | } 52 | 53 | //Comments structure for HN articles 54 | type Comment struct { 55 | Text string `json:"text"` 56 | User string `json:"user"` 57 | Id int `json:"id"` 58 | Created time.Time `json:"created,omitempty"` 59 | Comments []*Comment `json:"comments,omitempty"` 60 | } 61 | 62 | func (c *Comment) String() string { 63 | return strings.Replace(fmt.Sprintf("%s: %s", c.User, c.Text), "\n", " ", -1) 64 | } 65 | 66 | //Article structure 67 | type Article struct { 68 | Rank int `json:"rank"` 69 | Title string `json:"title"xml:"` 70 | Karma int `json:"karma"` 71 | Id int `json:"id"` 72 | Url string `json:"url"` 73 | NumComments int `json:"numComments"` 74 | Comments []*Comment `json:"comments",omitempty` 75 | User string `json:"user"` 76 | CreatedAgo string `json:"createdAgo,omitempty"` 77 | Created time.Time `json:"created",omitempty` 78 | } 79 | 80 | var arsCache = make(map[int]*Article) 81 | 82 | var timeName = map[string]time.Duration{ 83 | "second": time.Second, 84 | "minute": time.Minute, 85 | "hour": time.Hour, 86 | "day": 24 * time.Hour, 87 | } 88 | 89 | func parseCreated(s string) time.Time { 90 | agoStr := agoRegexp.FindStringSubmatch(s) 91 | 92 | if len(agoStr) == 0 { 93 | return time.Now() 94 | } 95 | 96 | words := strings.Split(agoStr[1], " ") 97 | 98 | if count, err := strconv.Atoi(words[0]); err == nil { 99 | durText := words[1] 100 | 101 | if durText[len(durText)-1] == 's' { 102 | durText = durText[:len(durText)-1] 103 | } 104 | 105 | dur := timeName[durText] 106 | diff := -int64(count) * int64(dur) 107 | return time.Now().Add(time.Duration(diff)).Round(dur) 108 | } else { 109 | return time.Time{} 110 | } 111 | } 112 | 113 | //Retreives comments for a given article 114 | func (a *Article) GetComments() { 115 | if _, exists := arsCache[a.Id]; exists { 116 | return 117 | } 118 | 119 | a.Comments = make([]*Comment, 0) 120 | 121 | articleUrl := YC_ROOT + "/item?id=" + strconv.Itoa(a.Id) 122 | 123 | req, e := http.NewRequest("GET", articleUrl, nil) 124 | 125 | if e != nil { 126 | log.Fatal(e) 127 | } 128 | 129 | doc := doReq(req) 130 | 131 | commentStack := make([]*Comment, 1, 10) 132 | 133 | doc.Find("span.comment").Each(func(i int, comment *goquery.Selection) { 134 | text := "" 135 | user := comment.Parent().Find("a").First().Text() 136 | 137 | text += comment.Text() 138 | 139 | //Get around HN's little weird "reply" nesting randomness 140 | //Is it part of the comment, or isn't it? 141 | if last5 := len(text) - 5; len(text) > 0 && last5 > 0 && text[last5:] == "reply" { 142 | text = text[:last5] 143 | } 144 | 145 | c := &Comment{ 146 | User: user, 147 | Text: text, 148 | Comments: make([]*Comment, 0), 149 | } 150 | 151 | //Get comment create time 152 | // t := comment.Prev().Text() 153 | 154 | // c.Created = parseCreated(t) 155 | 156 | //Get id 157 | if idAttr, exists := comment.Prev().Find("a").Last().Attr("href"); exists { 158 | idSt := strings.Split(idAttr, "=")[1] 159 | 160 | if id, err := strconv.Atoi(idSt); err == nil { 161 | c.Id = id 162 | } 163 | } 164 | 165 | //Track the comment offset for nesting. 166 | if width, exists := comment.Parent().Prev().Prev().Find("img").Attr("width"); exists { 167 | offset, _ := strconv.Atoi(width) 168 | offset = offset / 40 169 | 170 | lastEle := len(commentStack) - 1 //Index of the last element in the stack 171 | 172 | if offset > lastEle { 173 | commentStack = append(commentStack, c) 174 | commentStack[lastEle].Comments = append(commentStack[lastEle].Comments, c) 175 | } else { 176 | 177 | if offset < lastEle { 178 | commentStack = commentStack[:offset+1] //Trim the stack 179 | } 180 | 181 | commentStack[offset] = c 182 | 183 | //Add the comment to its parents 184 | if offset == 0 { 185 | a.Comments = append(a.Comments, c) 186 | } else { 187 | commentStack[offset-1].Comments = append(commentStack[offset-1].Comments, c) 188 | } 189 | } 190 | } 191 | }) 192 | 193 | arsCache[a.Id] = a 194 | 195 | //Cache the article for 5 minutes 196 | go func() { 197 | <-time.After(5 * time.Minute) 198 | delete(arsCache, a.Id) 199 | }() 200 | } 201 | 202 | func (a *Article) String() string { 203 | return fmt.Sprintf("(%d) %s: %s\n\n", a.Karma, a.User, a.Title) 204 | } 205 | 206 | //The character used to pad comments for printing 207 | const COMMENT_PAD = " " 208 | 209 | //Recursively get comments 210 | func commentString(cs []*Comment, off string) string { 211 | s := "" 212 | 213 | for _, c := range cs { 214 | s += off + fmt.Sprintf(off+"%s\n\n", c) 215 | 216 | if len(c.Comments) > 0 { 217 | s += commentString(c.Comments, off+" ") 218 | } 219 | } 220 | 221 | return s 222 | } 223 | 224 | func (a *Article) PrintComments() string { 225 | a.GetComments() 226 | 227 | return a.String() + commentString(a.Comments, "") 228 | } 229 | 230 | type Page struct { 231 | NextUrl string `json:"next"` 232 | Url string `json:"url"` 233 | Articles []*Article `json:"articles"` 234 | } 235 | 236 | //Get a new page by passing a url 237 | func NewPage(url string) *Page { 238 | p := Page{ 239 | Url: url, 240 | Articles: make([]*Article, 0), 241 | } 242 | 243 | url = YC_ROOT + url 244 | 245 | head, _ := http.NewRequest("HEAD", url, nil) 246 | 247 | if resp, err := client.Do(head); err == nil && len(resp.Cookies()) > 0 { 248 | c := resp.Cookies() 249 | cfduid = c[0].Raw 250 | } /*else { 251 | goncurses.End() 252 | log.Println(resp) 253 | log.Println(err) 254 | }*/ 255 | 256 | req, err := http.NewRequest("GET", url, nil) 257 | if err != nil { 258 | log.Fatal(err) 259 | } 260 | 261 | doc := doReq(req) 262 | 263 | //Get all the trs with subtext for children then go back one (for the first row) 264 | rows := doc.Find(".subtext").ParentsFilteredUntil("tr", "tbody").Prev() 265 | 266 | var a bool 267 | 268 | p.NextUrl, a = doc.Find("td.title").Last().Find("a").Attr("href") 269 | 270 | if !a { 271 | goncurses.End() 272 | log.Println("Could not retreive next hackernews page. Time to go outside?") 273 | } 274 | 275 | for len(p.NextUrl) > 0 && p.NextUrl[0] == '/' { 276 | p.NextUrl = p.NextUrl[1:] 277 | } 278 | 279 | rows.Each(func(i int, row *goquery.Selection) { 280 | ar := Article{ 281 | Rank: len(p.Articles) + i, 282 | } 283 | 284 | title := row.Find(".title").Eq(1) 285 | link := title.Find("a").First() 286 | 287 | ar.Title = link.Text() 288 | 289 | if url, exists := link.Attr("href"); exists { 290 | ar.Url = url 291 | } 292 | 293 | row = row.Next() 294 | 295 | row.Find("span.score").Each(func(i int, s *goquery.Selection) { 296 | if karma, err := strconv.Atoi(strings.Split(s.Text(), " ")[0]); err == nil { 297 | ar.Karma = karma 298 | } else { 299 | log.Println("Error getting karma count:", err) 300 | } 301 | 302 | if idSt, exists := s.Attr("id"); exists { 303 | if id, err := strconv.Atoi(strings.Split(idSt, "_")[1]); err == nil { 304 | ar.Id = id 305 | } else { 306 | log.Println(err) 307 | } 308 | } 309 | }) 310 | 311 | sub := row.Find("td.subtext") 312 | t := sub.Text() 313 | 314 | ar.Created = parseCreated(t) 315 | 316 | ar.User = sub.Find("a").First().Text() 317 | 318 | comStr := strings.Split(sub.Find("a").Last().Text(), " ")[0] 319 | 320 | if comNum, err := strconv.Atoi(comStr); err == nil { 321 | ar.NumComments = comNum 322 | } 323 | 324 | p.Articles = append(p.Articles, &ar) 325 | 326 | }) 327 | 328 | return &p 329 | } 330 | -------------------------------------------------------------------------------- /cache.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "math/rand" 5 | "time" 6 | ) 7 | 8 | //A structure created for caching pages for a given amount of time. This avoids heavy traffic to the HN servers. 9 | type PageCache struct { 10 | Created time.Time `json:"created"` 11 | Pages map[string]*Page `json:"pages"` 12 | Articles []*Article `json:"articles"` 13 | next string 14 | Next string `json:"next"` 15 | } 16 | 17 | func RandomString() string { 18 | rand.Seed(time.Now().Unix()) 19 | b := make([]byte, 80) 20 | 21 | return string(b) 22 | } 23 | 24 | func NewPageCache() *PageCache { 25 | pc := PageCache{ 26 | Next: "news", 27 | Pages: make(map[string]*Page), 28 | } 29 | 30 | pc.GetNext() 31 | 32 | return &pc 33 | } 34 | 35 | func (pc *PageCache) GetNext() *Page { 36 | p := NewPage(pc.Next) 37 | pc.Pages[p.Url] = p 38 | pc.Next = p.NextUrl 39 | pc.Articles = append(pc.Articles, p.Articles...) 40 | 41 | return p 42 | } 43 | -------------------------------------------------------------------------------- /cli.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os/exec" 6 | "strconv" 7 | ) 8 | 9 | var input string = "" 10 | 11 | var cli hncli 12 | var pageNum = 0 13 | 14 | func storyTime() { 15 | cli.SetContent(getStories(pageNum)) 16 | cli.SetKeyHandler(storyHandler) 17 | cli.SetHelp("(n: next, p: previous, c: view comments, o: open in browser, q: quit) ") 18 | } 19 | 20 | var p *PageCache 21 | 22 | func storyHandler(ch string) { 23 | switch ch { 24 | case "c": 25 | if num, err := strconv.Atoi(input); err == nil { 26 | if num < 1 { 27 | break 28 | } 29 | 30 | for num-1 > len(p.Articles) { 31 | p.GetNext() 32 | } 33 | 34 | text := p.Articles[num-1].PrintComments() 35 | 36 | commentTime(text) 37 | input = "" 38 | } else { 39 | cli.Alert("Please enter a number to select a comment") 40 | } 41 | input = "" 42 | break 43 | case "o": 44 | if num, err := strconv.Atoi(input); err == nil { 45 | for num-1 > len(p.Articles) { 46 | p.GetNext() 47 | } 48 | 49 | viewInBrowser := exec.Command("xdg-open", p.Articles[num-1].Url) 50 | viewInBrowser.Start() 51 | } else { 52 | cli.Alert("Please enter a number to view an article") 53 | } 54 | input = "" 55 | break 56 | case "q": 57 | cli.Quit() 58 | break 59 | case "n": 60 | //Go forward 1 page 61 | pageNum += 1 62 | cli.SetContent(getStories(pageNum)) 63 | input = "" 64 | break 65 | case "p": 66 | //Go back 1 page, unless page < 0 67 | if pageNum > 0 { 68 | pageNum -= 1 69 | } 70 | cli.SetContent(getStories(pageNum)) 71 | break 72 | case "enter": 73 | cli.Refresh() 74 | break 75 | case "backspace": 76 | if len(input) > 0 { 77 | input = input[:len(input)-1] 78 | cli.DelChar() 79 | } else { 80 | cli.DelChar() 81 | } 82 | break 83 | default: 84 | input += ch 85 | break 86 | } 87 | 88 | } 89 | func commentTime(text string) { 90 | cli.SetContent(text) 91 | cli.ResetScroll() 92 | cli.SetHelp("(d/u scroll 30 lines; j/k: scroll 1 line; n/p scroll 1 page; q: quit to story view)") 93 | cli.SetKeyHandler(commentHandler) 94 | } 95 | 96 | func commentHandler(input string) { 97 | switch input { 98 | case "d": 99 | cli.Scroll(30) 100 | break 101 | case "u": 102 | cli.Scroll(-30) 103 | break 104 | case "j": 105 | cli.Scroll(1) 106 | break 107 | case "k": 108 | cli.Scroll(-1) 109 | break 110 | case "g": 111 | cli.ResetScroll() 112 | break 113 | case "n": 114 | cli.Scroll(cli.Height) 115 | break 116 | case "p": 117 | cli.Scroll(-cli.Height) 118 | break 119 | case "q": 120 | storyTime() 121 | break 122 | } 123 | } 124 | 125 | func getStories(pageNum int) string { 126 | h := cli.Height 127 | 128 | start := h * pageNum 129 | end := start + h 130 | 131 | for end > len(p.Articles) { 132 | p.GetNext() 133 | } 134 | 135 | str := "" 136 | for i, ar := range p.Articles[start:end] { 137 | str += fmt.Sprintf("%4d.\t(%d)\t%s\n", start+i+1, ar.Karma, ar.Title) 138 | } 139 | 140 | return str 141 | } 142 | 143 | func runCli() { 144 | cli = GetCli() 145 | 146 | p = NewPageCache() 147 | 148 | storyTime() 149 | 150 | cli.Run() 151 | } 152 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module astuart.co/hn 2 | 3 | go 1.12 4 | 5 | require ( 6 | github.com/PuerkitoBio/goquery v1.8.1 7 | github.com/rthornton128/goncurses v0.0.0-20230211155340-24ae0ddac304 8 | golang.org/x/net v0.8.0 // indirect 9 | ) 10 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/PuerkitoBio/goquery v1.5.0/go.mod h1:qD2PgZ9lccMbQlc7eEOjaeRlFQON7xY8kdmcsrnKqMg= 2 | github.com/PuerkitoBio/goquery v1.8.1 h1:uQxhNlArOIdbrH1tr0UXwdVFgDcZDrZVdcpygAcwmWM= 3 | github.com/PuerkitoBio/goquery v1.8.1/go.mod h1:Q8ICL1kNUJ2sXGoAhPGUdYDJvgQgHzJsnnd3H7Ho5jQ= 4 | github.com/andybalholm/cascadia v1.0.0 h1:hOCXnnZ5A+3eVDX8pvgl4kofXv2ELss0bKcqRySc45o= 5 | github.com/andybalholm/cascadia v1.0.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y= 6 | github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c= 7 | github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA= 8 | github.com/rthornton128/goncurses v0.0.0-20180507031701-52220cf7cbaf h1:bxUMe0bgKCBTVGsXU2npHUEkkL60PP5d/O/2z/YtZvE= 9 | github.com/rthornton128/goncurses v0.0.0-20180507031701-52220cf7cbaf/go.mod h1:UJ0xTyAoIn5cLIoYfHypjSrlXPqVzJfjPSWj3nr9qmc= 10 | github.com/rthornton128/goncurses v0.0.0-20230211155340-24ae0ddac304 h1:mun02vlqTVZ1TpHcEmJSplc+sytnyDWe03bM/Tg3j/Y= 11 | github.com/rthornton128/goncurses v0.0.0-20230211155340-24ae0ddac304/go.mod h1:AHlKFomPTwmO7H2vL8d7VNrQNQmhMi/DBhDnHRhjbCo= 12 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 13 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 14 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 15 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 16 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 17 | golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 18 | golang.org/x/net v0.0.0-20181114220301-adae6a3d119a h1:gOpx8G595UYyvj8UK4+OFyY4rx037g3fmfhe5SasG3U= 19 | golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 20 | golang.org/x/net v0.0.0-20190301231341-16b79f2e4e95 h1:fY7Dsw114eJN4boqzVSbpVHO6rTdhq6/GnXeu+PKnzU= 21 | golang.org/x/net v0.0.0-20190301231341-16b79f2e4e95/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 22 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 23 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 24 | golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 25 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 26 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 27 | golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 28 | golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ= 29 | golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= 30 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 31 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 32 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 33 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 34 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 35 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 36 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 37 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 38 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 39 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 40 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 41 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 42 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 43 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 44 | golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= 45 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 46 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 47 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 48 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 49 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 50 | golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 51 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 52 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 53 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 54 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 55 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 56 | -------------------------------------------------------------------------------- /hncli.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "strings" 6 | 7 | "github.com/rthornton128/goncurses" 8 | ) 9 | 10 | type hncli struct { 11 | root, main, help *goncurses.Window 12 | Height, offset int 13 | handler CharHandler 14 | finished bool 15 | helpText, content string 16 | } 17 | 18 | func (h *hncli) Refresh() { 19 | h.root.Refresh() 20 | h.root.Move(h.Height-1, 0) 21 | } 22 | 23 | const MENU_HEIGHT int = 3 24 | 25 | var hc hncli 26 | 27 | //Ha. 28 | var singleDone = false 29 | 30 | //Returns an instance of the CLI. Call once 31 | func GetCli() hncli { 32 | if !singleDone { 33 | hc = hncli{} 34 | 35 | root, e := goncurses.Init() 36 | 37 | if e != nil { 38 | goncurses.End() 39 | log.Fatal(e) 40 | } 41 | 42 | h, w := root.MaxYX() 43 | 44 | hc.root = root 45 | hc.main = root.Sub(h-MENU_HEIGHT, w, 0, 0) 46 | hc.help = root.Sub(MENU_HEIGHT, w, h-MENU_HEIGHT, 0) 47 | 48 | hc.Height = h - MENU_HEIGHT 49 | 50 | singleDone = true 51 | } 52 | 53 | return hc 54 | } 55 | 56 | func (h *hncli) SetContent(text string) { 57 | h.content = text 58 | h.main.Clear() 59 | 60 | h.main.Printf(text) 61 | h.main.Refresh() 62 | } 63 | 64 | func (h *hncli) SetHelp(text string) { 65 | h.helpText = text 66 | h.help.Clear() 67 | h.help.Move(1, 0) 68 | h.help.Print(text) 69 | h.help.Refresh() 70 | } 71 | 72 | //Scroll the content that was added with SetContent 73 | func (h *hncli) Scroll(amount int) { 74 | newOffset := h.offset + amount 75 | 76 | if newOffset < 0 { 77 | h.offset = 0 78 | } else { 79 | h.offset = newOffset 80 | } 81 | 82 | h.drawPage() 83 | } 84 | 85 | func (h *hncli) ResetScroll() { 86 | h.offset = 0 87 | h.drawPage() 88 | } 89 | 90 | type CharHandler func(string) 91 | 92 | func (h *hncli) SetKeyHandler(hand CharHandler) { 93 | h.handler = hand 94 | } 95 | 96 | func (h *hncli) Quit() { 97 | goncurses.End() 98 | h.finished = true 99 | } 100 | 101 | func (h *hncli) Run() { 102 | for !h.finished { 103 | c := h.root.GetChar() 104 | 105 | if c == 127 { 106 | c = goncurses.Key(goncurses.KEY_BACKSPACE) 107 | } 108 | 109 | ch := goncurses.KeyString(c) 110 | 111 | h.handler(ch) 112 | } 113 | } 114 | 115 | //Show an alert, wait for a character, then reset 116 | func (h *hncli) Alert(text string) { 117 | hText := h.helpText 118 | h.SetHelp(text + " (Press any key to continue)") 119 | h.root.GetChar() 120 | h.SetHelp(hText) 121 | } 122 | 123 | func (h *hncli) DelChar() { 124 | cy, cx := h.root.CursorYX() 125 | h.root.MoveDelChar(cy, cx-3) 126 | h.root.DelChar() 127 | h.root.DelChar() 128 | } 129 | 130 | func (h *hncli) getFitLines() []string { 131 | _, w := h.main.MaxYX() 132 | 133 | lineArray := strings.Split(h.content, "\n") 134 | 135 | p := make([]string, 0, len(lineArray)) 136 | 137 | for _, line := range lineArray { 138 | 139 | //Remember padding for each line 140 | var pad string 141 | for len(line) > len(COMMENT_PAD)+len(pad) && line[len(pad):len(pad)+len(COMMENT_PAD)] == COMMENT_PAD { 142 | pad += COMMENT_PAD 143 | } 144 | 145 | for len(line) > w { 146 | 147 | //Current line length 148 | l := w 149 | if l > len(line) { 150 | l = len(line) 151 | } 152 | 153 | //Find last space 154 | for line[l] != ' ' { 155 | l-- 156 | } 157 | 158 | //Split lines 159 | p = append(p, line[:l]) 160 | line = line[l:] 161 | 162 | //Strip leading spaces 163 | for len(line) > 0 && line[0] == ' ' { 164 | line = line[1:] 165 | } 166 | 167 | //Pad line if need be 168 | if len(line) > 0 { 169 | line = pad + line 170 | } 171 | } 172 | 173 | p = append(p, line) 174 | } 175 | 176 | return p 177 | } 178 | 179 | func (h *hncli) drawPage() { 180 | lines := h.getFitLines() 181 | 182 | nLines := len(lines) 183 | 184 | //If text won't fit 185 | if nLines > h.Height { 186 | //Determine start point 187 | if h.offset < nLines-h.Height { 188 | //If n is not in last h elements, reslice starting at n 189 | lines = lines[h.offset : h.offset+h.Height] 190 | } else { 191 | //Else, Get last h (at most) elements 192 | lines = lines[nLines-h.Height:] 193 | } 194 | } 195 | 196 | h.main.Clear() 197 | h.main.Print(strings.Join(lines, "\n")) 198 | h.main.Refresh() 199 | } 200 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | ) 6 | 7 | func main() { 8 | s := flag.Bool("s", false, "Serves a webpage with rendings of hackernews articles") 9 | p := flag.String("p", "8000", "Sets the port for the server") 10 | flag.Parse() 11 | 12 | if *s { 13 | port := ":" + *p 14 | server(port) 15 | } else { 16 | runCli() 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /readme/comments.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrewstuart/hn/1518642a8725cbd1f0a29ffae48e4dde88d29fc3/readme/comments.png -------------------------------------------------------------------------------- /readme/stories.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andrewstuart/hn/1518642a8725cbd1f0a29ffae48e4dde88d29fc3/readme/stories.png -------------------------------------------------------------------------------- /server.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "log" 6 | "net/http" 7 | "strconv" 8 | "time" 9 | ) 10 | 11 | const commentRoute string = "/comments/" 12 | 13 | var articles map[string]*Article 14 | 15 | func getComments(w http.ResponseWriter, r *http.Request) { 16 | w.Header()["Access-Control-Allow-Origin"] = []string{"*"} 17 | 18 | idSt := r.URL.Path[len(commentRoute):] 19 | 20 | if id, err := strconv.Atoi(idSt); err == nil { 21 | enc := json.NewEncoder(w) 22 | if ar, cached := arsCache[id]; cached { 23 | enc.Encode(ar) 24 | } else { 25 | log.Print("Not cached") 26 | } 27 | } else { 28 | log.Print(err) 29 | } 30 | } 31 | 32 | func getPage(w http.ResponseWriter, r *http.Request) { 33 | reqUrl := r.URL.Path[len("/page/"):] 34 | 35 | w.Header()["Access-Control-Allow-Origin"] = []string{"*"} 36 | enc := json.NewEncoder(w) 37 | 38 | if page, cacheExists := pc.Pages[reqUrl]; cacheExists { 39 | enc.Encode(page) 40 | } else if reqUrl == pc.Next { 41 | page = pc.GetNext() 42 | enc.Encode(page) 43 | } 44 | } 45 | 46 | func send(w http.ResponseWriter, r *http.Request) { 47 | w.Header()["Access-Control-Allow-Origin"] = []string{"*"} 48 | 49 | enc := json.NewEncoder(w) 50 | enc.Encode(pc.Pages) 51 | } 52 | 53 | func reCache() { 54 | <-time.After(30 * time.Minute) 55 | 56 | pc = NewPageCache() 57 | go reCache() 58 | } 59 | 60 | var pc *PageCache 61 | 62 | func server(addr string) { 63 | articles = make(map[string]*Article) 64 | pc = NewPageCache() 65 | 66 | for _, art := range pc.Articles { 67 | articles[strconv.Itoa(art.Id)] = art 68 | } 69 | 70 | http.HandleFunc("/page/", getPage) 71 | http.HandleFunc("/", send) 72 | http.HandleFunc(commentRoute, getComments) 73 | 74 | err := http.ListenAndServe(addr, nil) 75 | 76 | if err != nil { 77 | log.Fatal(err) 78 | } 79 | 80 | go reCache() 81 | } 82 | --------------------------------------------------------------------------------