├── .gitignore ├── .github └── workflows │ └── test.yml ├── web_test.go ├── main.go ├── go.mod ├── LICENSE ├── parser.go ├── web.go ├── README.md ├── ui.go ├── parser_test.go └── go.sum /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode/ 2 | .DS_STORE 3 | hn-text 4 | bin/ 5 | build/ 6 | 7 | .goreleaser.yaml 8 | dist/ 9 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Go tests 2 | 3 | on: 4 | pull_request: 5 | branches: [ main ] 6 | push: 7 | branches: [ main ] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | - name: Set up Go 15 | uses: actions/setup-go@v4 16 | with: 17 | go-version: 1.x 18 | - name: Test 19 | run: go test ./... 20 | -------------------------------------------------------------------------------- /web_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestSanitize(t *testing.T) { 8 | unsanitizedComment := "Uncrewed, yes. https://en.wikipedia.org/wiki/Boeing_Orbital_Flight_Test_2" 9 | expectedComment := "Uncrewed, yes. https://en.wikipedia.org/wiki/Boeing_Orbital_Flight_Test_2" 10 | sanitizedComment := sanitize(unsanitizedComment) 11 | 12 | // check if sanitized comment is equal to expected comment 13 | if sanitizedComment != expectedComment { 14 | t.Errorf("Expected %q, got %q", expectedComment, sanitizedComment) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "os" 6 | 7 | "github.com/rivo/tview" 8 | ) 9 | 10 | var hackerNewsURL = "https://news.ycombinator.com/" 11 | 12 | func main() { 13 | app := tview.NewApplication() 14 | // TODO: rewrite this for other options 15 | page := "" 16 | if len(os.Args) > 1 && os.Args[1] == "best" { 17 | page = "best" 18 | } 19 | 20 | htmlContent, err := fetchWebpage(hackerNewsURL + page) 21 | if err != nil { 22 | log.Fatal(err) 23 | } 24 | 25 | articles, err := parseArticles(htmlContent) 26 | if err != nil { 27 | log.Fatal(err) 28 | } 29 | 30 | list := createArticleList(articles) 31 | pages := tview.NewPages() 32 | pages.AddPage("homepage", list, true, false) 33 | 34 | app.SetInputCapture(createInputHandler(app, list, articles, pages)) 35 | 36 | if err := app.SetRoot(list, true).Run(); err != nil { 37 | log.Fatal(err) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/piqoni/hn-text 2 | 3 | go 1.21.1 4 | 5 | require ( 6 | github.com/PuerkitoBio/goquery v1.9.2 7 | github.com/gdamore/tcell/v2 v2.7.4 8 | github.com/gelembjuk/articletext v0.0.0-20231013143648-bc7a97ba132a 9 | github.com/rivo/tview v0.0.0-20240524063012-037df494fb76 10 | jaytaylor.com/html2text v0.0.0-20230321000545-74c2419ad056 11 | ) 12 | 13 | require ( 14 | github.com/andybalholm/cascadia v1.3.2 // indirect 15 | github.com/gdamore/encoding v1.0.0 // indirect 16 | github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 // indirect 17 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 18 | github.com/mattn/go-runewidth v0.0.15 // indirect 19 | github.com/olekukonko/tablewriter v0.0.5 // indirect 20 | github.com/rivo/uniseg v0.4.7 // indirect 21 | github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect 22 | golang.org/x/net v0.24.0 // indirect 23 | golang.org/x/sys v0.19.0 // indirect 24 | golang.org/x/term v0.19.0 // indirect 25 | golang.org/x/text v0.14.0 // indirect 26 | gopkg.in/neurosnap/sentences.v1 v1.0.7 // indirect 27 | ) 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 EP 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. 22 | -------------------------------------------------------------------------------- /parser.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "strconv" 7 | "strings" 8 | 9 | "github.com/PuerkitoBio/goquery" 10 | ) 11 | 12 | type Article struct { 13 | Title string 14 | Link string 15 | Comments int 16 | CommentsLink string 17 | } 18 | 19 | func parseArticles(htmlContent string) ([]Article, error) { 20 | var articles []Article 21 | 22 | doc, err := goquery.NewDocumentFromReader(strings.NewReader(htmlContent)) 23 | if err != nil { 24 | return nil, err 25 | } 26 | 27 | doc.Find("tr.athing").Each(func(i int, s *goquery.Selection) { 28 | title := s.Find("td.title > span.titleline > a").Text() 29 | link, _ := s.Find("td.title > span.titleline > a").Attr("href") 30 | commentText := s.Next().Find("a[href^='item']").Last().Text() 31 | commentsCount, err := extractCommentsCountFromString(commentText) 32 | commentsLink := s.Next().Find("a[href^='item']").Last().AttrOr("href", "") 33 | if err != nil { 34 | commentsCount = 0 35 | } 36 | 37 | article := Article{ 38 | Title: title, 39 | Link: link, 40 | Comments: commentsCount, 41 | CommentsLink: commentsLink, 42 | } 43 | 44 | articles = append(articles, article) 45 | }) 46 | 47 | return articles, nil 48 | } 49 | 50 | func extractCommentsCountFromString(input string) (int, error) { 51 | input = strings.TrimSpace(input) 52 | re := regexp.MustCompile(`\d+`) 53 | matches := re.FindString(input) 54 | if matches == "" { 55 | return 0, fmt.Errorf("no numbers found in input") 56 | } 57 | 58 | number, err := strconv.Atoi(matches) 59 | if err != nil { 60 | return 0, err 61 | } 62 | 63 | return number, nil 64 | } 65 | -------------------------------------------------------------------------------- /web.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "strings" 9 | 10 | "jaytaylor.com/html2text" 11 | ) 12 | 13 | func fetchWebpage(url string) (string, error) { 14 | res, err := http.Get(url) 15 | if err != nil { 16 | return "", err 17 | } 18 | defer res.Body.Close() 19 | 20 | body, err := io.ReadAll(res.Body) 21 | if err != nil { 22 | return "", err 23 | } 24 | 25 | return string(body), nil 26 | } 27 | 28 | const HN_SEARCH_URL = "https://hn.algolia.com/api/v1/" 29 | 30 | type Comment struct { 31 | Author string `json:"author"` 32 | Text string `json:"text"` 33 | Children []Comment `json:"children"` 34 | } 35 | 36 | func sanitize(input string) string { 37 | sanitized, _ := html2text.FromString(input) 38 | return sanitized 39 | } 40 | 41 | func safeRequest(url string) *http.Response { 42 | resp, err := http.Get(url) 43 | if err != nil { 44 | fmt.Printf("Failed to fetch URL: %s\n", url) 45 | return nil 46 | } 47 | return resp 48 | } 49 | 50 | func fetchComments(storyID string) []string { 51 | resp := safeRequest(HN_SEARCH_URL + "items/" + storyID) 52 | if resp == nil { 53 | return nil 54 | } 55 | defer resp.Body.Close() 56 | 57 | var comments map[string]interface{} 58 | if err := json.NewDecoder(resp.Body).Decode(&comments); err != nil { 59 | fmt.Printf("Failed to decode JSON response\n") 60 | return nil 61 | } 62 | 63 | var lines []string 64 | 65 | if children, ok := comments["children"].([]interface{}); ok { 66 | for _, child := range children { 67 | childComment := child.(map[string]interface{}) 68 | appendComment(childComment, &lines, 0) 69 | } 70 | } 71 | 72 | return lines 73 | } 74 | 75 | func appendComment(comment map[string]interface{}, lines *[]string, level int) { 76 | indent := "" + strings.Repeat(" ", min(level, 4)*2) + "| " 77 | 78 | if author, ok := comment["author"].(string); ok { 79 | *lines = append(*lines, indent+sanitize(author)+" wrote:") 80 | 81 | text := sanitize(comment["text"].(string)) 82 | 83 | paragraphs := strings.Split(text, "\n\n") 84 | for _, paragraph := range paragraphs { 85 | textLines := wrapText(paragraph, indent) 86 | *lines = append(*lines, textLines...) 87 | *lines = append(*lines, indent) 88 | } 89 | *lines = (*lines)[:len(*lines)-1] // Drop the blank line after the last paragraph 90 | } else { 91 | *lines = append(*lines, indent+"[deleted]") 92 | } 93 | 94 | *lines = append(*lines, " ") 95 | 96 | if children, ok := comment["children"].([]interface{}); ok { 97 | for _, child := range children { 98 | appendComment(child.(map[string]interface{}), lines, level+1) 99 | } 100 | } 101 | } 102 | 103 | func wrapText(text, indent string) []string { 104 | words := strings.Fields(text) 105 | var lines []string 106 | var sb strings.Builder 107 | 108 | maxWidth := 60 109 | 110 | for _, word := range words { 111 | if sb.Len()+len(word)+1 > maxWidth { 112 | lines = append(lines, indent+sb.String()) 113 | sb.Reset() 114 | } 115 | if sb.Len() > 0 { 116 | sb.WriteString(" ") 117 | } 118 | sb.WriteString(word) 119 | } 120 | if sb.Len() > 0 { 121 | lines = append(lines, indent+sb.String()) 122 | } 123 | 124 | return lines 125 | } 126 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 |

HN-text

3 |

A fast, easy-to-use and distraction-free Hacker News terminal client.

4 |

5 | 6 | Latest release 7 | 8 | 9 | Last commit 10 | 11 | 12 | License 13 | 14 | 15 | Stars 16 | 17 |

18 | 19 | 20 |
21 | 22 | # Motivations: 23 | - Easy to use (arrow keys or hjkl navigation should be enough for the client to be fully usable) 24 | - Distraction Free: articles, and comments are converted to simple readable text. 25 | - Fast Navigation and Responsivity 26 | 27 | # Current Features / Screenshots 28 | - Navigation and opening pages (text-version): ←↓↑→ arrow keys (or hjkl) will navigate from the HN Frontpage → Comments Page → Article's Text and back. 29 | - Open article in default's browser (**SPACE** key), Comment page ('**c**' key). 30 | - Append "best" as argument if you want to see Hacker News Best page, instead of the default frontpage. 31 | 32 | ## Frontpage 33 | image 34 | 35 | ## Comments 36 | image 37 | 38 | ## Article 39 | image 40 | 41 | # Keymaps 42 |
43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 |
KeyFunctionality
Down Arrow (↓) or `j` Down on the Frontpage Article List
UP Arrow (↑) or `k` Up on the Frontpage Article List
Right Arrow (→) or `l` Open Comment Page (while on frontpage) - Pressing again would open the article
Left Arrow (←) or `h` Go Back
SPACE Open Article on Browser (if for some reason not satisfied with text rendered version)
`c` Open Comments page on Browser
`q` Quit App
`r` Refresh HN Frontpage
81 |
82 | 83 | # Installation 84 | ## Homebrew 85 | ``` 86 | brew tap piqoni/hn-text 87 | brew install hn-text 88 | ``` 89 | 90 | ## Binaries 91 | Download binaries for your OS at [release page](https://github.com/piqoni/hn-text/releases), and chmod +x the file to allow execution. 92 | 93 | ## Using GO INSTALL 94 | If you use GO, you can install it directly: 95 | ``` 96 | go install github.com/piqoni/hn-text@latest 97 | ``` 98 | 99 | Note: If you get "command not found", then likely your GOPATH/bin is not in your PATH. To add it, place the following to your ~/.bashrc or ~/.zshrc depending on your shell: 100 | ``` 101 | export PATH=${PATH}:`go env GOPATH`/bin 102 | ``` 103 | -------------------------------------------------------------------------------- /ui.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/url" 7 | "os/exec" 8 | "runtime" 9 | "strconv" 10 | "strings" 11 | 12 | "github.com/gdamore/tcell/v2" 13 | "github.com/gelembjuk/articletext" 14 | "github.com/rivo/tview" 15 | ) 16 | 17 | const fireEmojiNrOfComments = 50 // TODO maybe configurable 18 | 19 | func createArticleList(articles []Article) *tview.List { 20 | list := tview.NewList().ShowSecondaryText(true).SetSecondaryTextColor(tcell.ColorGray) 21 | for _, article := range articles { 22 | title := article.Title 23 | if article.Comments > fireEmojiNrOfComments { 24 | title = "🔥 " + title 25 | } 26 | 27 | commentsText := strconv.Itoa(article.Comments) + " comments" 28 | list.AddItem(title, extractDomain(article.Link)+" "+commentsText, 0, nil) 29 | } 30 | 31 | return list 32 | } 33 | 34 | func extractDomain(link string) string { 35 | u, err := url.Parse(link) 36 | if err != nil { 37 | log.Fatal(err) 38 | } 39 | return u.Host 40 | } 41 | 42 | func fetchAndGenerateList(hackerNewsURL string) (*tview.List, error) { 43 | htmlContent, err := fetchWebpage(hackerNewsURL) 44 | if err != nil { 45 | return nil, err 46 | } 47 | 48 | articles, err := parseArticles(htmlContent) 49 | if err != nil { 50 | return nil, err 51 | } 52 | 53 | list := createArticleList(articles) 54 | return list, nil 55 | } 56 | 57 | func createInputHandler(app *tview.Application, list *tview.List, articles []Article, pages *tview.Pages) func(event *tcell.EventKey) *tcell.EventKey { 58 | return func(event *tcell.EventKey) *tcell.EventKey { 59 | switch event.Key() { 60 | case tcell.KeyCtrlC: 61 | app.Stop() 62 | return nil 63 | case tcell.KeyRight: 64 | nextPage(pages, app, articles, list) 65 | return nil 66 | case tcell.KeyLeft: 67 | backPage(pages) 68 | return nil 69 | case tcell.KeyRune: 70 | switch event.Rune() { 71 | case 'q': 72 | app.Stop() 73 | return nil 74 | case 'j': 75 | return tcell.NewEventKey(tcell.KeyDown, 0, tcell.ModNone) 76 | case 'k': 77 | return tcell.NewEventKey(tcell.KeyUp, 0, tcell.ModNone) 78 | case 'l': 79 | nextPage(pages, app, articles, list) 80 | return nil 81 | case 'h': 82 | backPage(pages) 83 | return nil 84 | case ' ': 85 | openURL(articles[list.GetCurrentItem()].Link) 86 | return nil 87 | case 'c': 88 | openURL(hackerNewsURL + articles[list.GetCurrentItem()].CommentsLink) 89 | return nil 90 | case 'r': 91 | list.Clear() 92 | refreshedList, _ := fetchAndGenerateList(hackerNewsURL) 93 | pages.AddPage("homepage", refreshedList, true, false) 94 | app.SetRoot(refreshedList, true).Run() 95 | } 96 | } 97 | 98 | return event 99 | } 100 | } 101 | 102 | func backPage(pages *tview.Pages) { 103 | // TODO: navigation flow will become configurable 104 | currentPage, _ := pages.GetFrontPage() 105 | if currentPage == "comments" { 106 | pages.SwitchToPage("homepage") 107 | } 108 | if currentPage == "article" { 109 | pages.SwitchToPage("comments") 110 | } 111 | } 112 | 113 | func nextPage(pages *tview.Pages, app *tview.Application, articles []Article, list *tview.List) { 114 | currentPage, _ := pages.GetFrontPage() 115 | if currentPage == "comments" { 116 | openArticle(app, articles[list.GetCurrentItem()].Link, pages) 117 | } else { 118 | openComments(app, articles[list.GetCurrentItem()].CommentsLink, pages) 119 | } 120 | } 121 | 122 | func openComments(app *tview.Application, commentsLink string, pages *tview.Pages) { 123 | u, err := url.Parse(commentsLink) 124 | if err != nil { 125 | fmt.Println("Error parsing URL:", err) // TODO maybe alert dialogbox 126 | return 127 | } 128 | story_id := u.Query().Get("id") 129 | 130 | articleStringList := fetchComments(story_id) 131 | commentsText := "" 132 | for _, articleString := range articleStringList { 133 | commentsText += articleString + "\n" 134 | } 135 | 136 | if commentsText == "" { 137 | commentsText = "Story has no comments yet. Press RIGHT ARROW or letter 'l' to continue with the article." 138 | } 139 | 140 | displayComments(app, pages, commentsText) 141 | } 142 | 143 | func openArticle(app *tview.Application, articleLink string, pages *tview.Pages) { 144 | if !strings.HasPrefix(articleLink, "http") { 145 | return // avoid trying to open relative pages like item?id=1234 like Ask HN 146 | } 147 | articleText := getArticleTextFromLink(articleLink) 148 | displayArticle(app, pages, articleText) 149 | } 150 | 151 | func getArticleTextFromLink(url string) string { 152 | article, err := articletext.GetArticleTextFromUrl(url) 153 | if err != nil { 154 | fmt.Printf("Failed to parse %s, %v\n", url, err) 155 | } 156 | return article 157 | } 158 | 159 | func displayArticle(app *tview.Application, pages *tview.Pages, text string) { 160 | articleTextView := tview.NewTextView(). 161 | SetText(text). 162 | SetDynamicColors(true). 163 | SetScrollable(true) 164 | 165 | pages.AddPage("article", articleTextView, true, true) 166 | app.SetRoot(pages, true) 167 | } 168 | 169 | func displayComments(app *tview.Application, pages *tview.Pages, text string) { 170 | commentsTextView := tview.NewTextView(). 171 | SetText(text). 172 | SetDynamicColors(true). 173 | SetScrollable(true) 174 | 175 | pages.AddPage("comments", commentsTextView, true, true) 176 | app.SetRoot(pages, true) 177 | } 178 | 179 | func openURL(url string) { 180 | var cmd string 181 | var args []string 182 | 183 | switch runtime.GOOS { 184 | case "windows": 185 | cmd = "cmd" 186 | args = []string{"/c", "start"} 187 | case "darwin": 188 | cmd = "open" 189 | default: // "linux", "freebsd", "openbsd", "netbsd" 190 | cmd = "xdg-open" 191 | } 192 | args = append(args, url) 193 | exec.Command(cmd, args...).Start() 194 | } 195 | -------------------------------------------------------------------------------- /parser_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestParseArticles(t *testing.T) { 8 | htmlContent := ` 9 | 10 | 11 | 12 | 17 | 20 | 21 | 22 | 23 | 30 | 31 | 32 | 33 | 38 | 41 | 42 | 43 | 44 | 51 | 52 |
1.Majorana, the search for the most elusive neutrino of all (lbl.gov)
24 | 23 points by bilsbie 2 hours ago | hide | 1 comment 28 | 29 |
2.Abusing Go's Infrastructure (put.as)
45 | 298 points by efge 10 47 | hours ago | hide | 62 comments 49 | 50 |
` 53 | 54 | expectedArticles := []Article{ 55 | {Title: "Majorana, the search for the most elusive neutrino of all", Link: "https://newscenter.lbl.gov/2012/05/16/majorana-demonstrator/", Comments: 1, CommentsLink: "item?id=40477653"}, 56 | {Title: "Abusing Go's Infrastructure", Link: "https://reverse.put.as/2024/05/24/abusing-go-infrastructure/", Comments: 62, CommentsLink: "item?id=40474712"}, 57 | } 58 | 59 | articles, err := parseArticles(htmlContent) 60 | if err != nil { 61 | t.Fatalf("Unexpected error: %v", err) 62 | } 63 | 64 | if len(articles) != len(expectedArticles) { 65 | t.Fatalf("Expected %d articles, got %d", len(expectedArticles), len(articles)) 66 | } 67 | 68 | for i, article := range articles { 69 | if article.Title != expectedArticles[i].Title { 70 | t.Errorf("Expected title %q, got %q", expectedArticles[i].Title, article.Title) 71 | } 72 | if article.Link != expectedArticles[i].Link { 73 | t.Errorf("Expected link %q, got %q", expectedArticles[i].Link, article.Link) 74 | } 75 | if article.Comments != expectedArticles[i].Comments { 76 | t.Errorf("Expected comments %d, got %d", expectedArticles[i].Comments, article.Comments) 77 | } 78 | if article.CommentsLink != expectedArticles[i].CommentsLink { 79 | t.Errorf("Expected comments link %q, got %q", expectedArticles[i].CommentsLink, article.CommentsLink) 80 | } 81 | } 82 | } 83 | 84 | // func TestParseArticles(t *testing.T) { 85 | // t.Run("Empty HTML", func(t *testing.T) { 86 | // articles, err := parseArticles("") 87 | // assert.NoError(t, err) 88 | // assert.Empty(t, articles) 89 | // }) 90 | 91 | // t.Run("Valid HTML", func(t *testing.T) { 92 | // html := ` 93 | // 94 | // 95 | // 96 | // Article 1 97 | // 98 | // 99 | // 100 | // 101 | // 102 | // 103 | // Article 2 104 | // 105 | // 106 | // 107 | // ` 108 | // articles, err := parseArticles(html) 109 | // assert.NoError(t, err) 110 | // assert.Len(t, articles, 2) 111 | // assert.Equal(t, "Article 1", articles[0].Title) 112 | // assert.Equal(t, "https://example.com", articles[0].Link) 113 | // assert.Equal(t, "Article 2", articles[1].Title) 114 | // assert.Equal(t, "https://example.com/2", articles[1].Link) 115 | // }) 116 | 117 | // t.Run("Error in goquery", func(t *testing.T) { 118 | // doc := &goquery.Document{} 119 | // // doc.SetError(fmt.Errorf("test error")) 120 | // articles, err := parseArticlesFromDocument(doc) 121 | // assert.Error(t, err) 122 | // assert.Nil(t, articles) 123 | // }) 124 | // } 125 | 126 | // func parseArticlesFromDocument(doc *goquery.Document) ([]Article, error) { 127 | // var articles []Article 128 | 129 | // doc.Find("tr.athing").Each(func(i int, s *goquery.Selection) { 130 | // title := s.Find("td.title > span.titleline > a").Text() 131 | // link, _ := s.Find("td.title > span.titleline > a").Attr("href") 132 | // commentsCount, err := extractCommentsCount(s) 133 | // if err != nil { 134 | // commentsCount = 0 135 | // } 136 | 137 | // article := Article{ 138 | // Title: title, 139 | // Link: link, 140 | // Comments: commentsCount, 141 | // } 142 | 143 | // articles = append(articles, article) 144 | // }) 145 | 146 | // return articles, nil 147 | // } 148 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/PuerkitoBio/goquery v1.9.2 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4yPeE= 2 | github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk= 3 | github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss= 4 | github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU= 5 | github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko= 6 | github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= 7 | github.com/gdamore/tcell/v2 v2.7.4 h1:sg6/UnTM9jGpZU+oFYAsDahfchWAFW8Xx2yFinNSAYU= 8 | github.com/gdamore/tcell/v2 v2.7.4/go.mod h1:dSXtXTSK0VsW1biw65DZLZ2NKr7j0qP/0J7ONmsraWg= 9 | github.com/gelembjuk/articletext v0.0.0-20231013143648-bc7a97ba132a h1:qiro+IlH6Wj1YAEnLGYYGNuqEEQUyrDDWThnHL5Xgzo= 10 | github.com/gelembjuk/articletext v0.0.0-20231013143648-bc7a97ba132a/go.mod h1:MEAzbitBZyN3cjFntWZJnfeForJTU+VNDLR69SdHesA= 11 | github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 h1:iCHtR9CQyktQ5+f3dMVZfwD2KWJUgm7M0gdL9NGr8KA= 12 | github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk= 13 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 14 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 15 | github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= 16 | github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= 17 | github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 18 | github.com/neurosnap/sentences v1.1.2 h1:iphYOzx/XckXeBiLIUBkPu2EKMJ+6jDbz/sLJZ7ZoUw= 19 | github.com/neurosnap/sentences v1.1.2/go.mod h1:/pwU4E9XNL21ygMIkOIllv/SMy2ujHwpf8GQPu1YPbQ= 20 | github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= 21 | github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= 22 | github.com/rivo/tview v0.0.0-20240524063012-037df494fb76 h1:iqvDlgyjmqleATtFbA7c14djmPh2n4mCYUv7JlD/ruA= 23 | github.com/rivo/tview v0.0.0-20240524063012-037df494fb76/go.mod h1:02iFIz7K/A9jGCvrizLPvoqr4cEIx7q54RH5Qudkrss= 24 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 25 | github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 26 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 27 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 28 | github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo= 29 | github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM= 30 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 31 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 32 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 33 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 34 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 35 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 36 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 37 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 38 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 39 | golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= 40 | golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w= 41 | golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= 42 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 43 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 44 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 45 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 46 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 47 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 48 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 49 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 50 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 51 | golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 52 | golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 53 | golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= 54 | golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 55 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 56 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 57 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 58 | golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= 59 | golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= 60 | golang.org/x/term v0.19.0 h1:+ThwsDv+tYfnJFhF4L8jITxu1tdTWRTZpdsWgEgjL6Q= 61 | golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk= 62 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 63 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 64 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 65 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 66 | golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 67 | golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= 68 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 69 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 70 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 71 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 72 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 73 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 74 | gopkg.in/neurosnap/sentences.v1 v1.0.7 h1:gpTUYnqthem4+o8kyTLiYIB05W+IvdQFYR29erfe8uU= 75 | gopkg.in/neurosnap/sentences.v1 v1.0.7/go.mod h1:YlK+SN+fLQZj+kY3r8DkGDhDr91+S3JmTb5LSxFRQo0= 76 | jaytaylor.com/html2text v0.0.0-20230321000545-74c2419ad056 h1:6YFJoB+0fUH6X3xU/G2tQqCYg+PkGtnZ5nMR5rpw72g= 77 | jaytaylor.com/html2text v0.0.0-20230321000545-74c2419ad056/go.mod h1:OxvTsCwKosqQ1q7B+8FwXqg4rKZ/UG9dUW+g/VL2xH4= 78 | --------------------------------------------------------------------------------