├── .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 |
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 |
34 |
35 | ## Comments
36 |
37 |
38 | ## Article
39 |
40 |
41 | # Keymaps
42 |
43 |
44 |
45 | | Key |
46 | Functionality |
47 |
48 |
49 | | Down Arrow (↓) or `j` |
50 | Down on the Frontpage Article List |
51 |
52 |
53 | | UP Arrow (↑) or `k` |
54 | Up on the Frontpage Article List |
55 |
56 |
57 | | Right Arrow (→) or `l` |
58 | Open Comment Page (while on frontpage) - Pressing again would open the article |
59 |
60 |
61 | | Left Arrow (←) or `h` |
62 | Go Back |
63 |
64 |
65 | | SPACE |
66 | Open Article on Browser (if for some reason not satisfied with text rendered version) |
67 |
68 |
69 | | `c` |
70 | Open Comments page on Browser |
71 |
72 |
73 | | `q` |
74 | Quit App |
75 |
76 |
77 | | `r` |
78 | Refresh HN Frontpage |
79 |
80 |
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 | `
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 |
--------------------------------------------------------------------------------