This commit is contained in:
Edi Piqoni
2024-06-02 18:55:22 +02:00
commit cde359806d
8 changed files with 669 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
.vscode/
.DS_STORE
hn-text

27
go.mod Normal file
View File

@@ -0,0 +1,27 @@
module hn-text
go 1.21.1
require (
github.com/PuerkitoBio/goquery v1.9.2
github.com/gdamore/tcell/v2 v2.7.4
github.com/gelembjuk/articletext v0.0.0-20231013143648-bc7a97ba132a
github.com/k3a/html2text v1.2.1
github.com/rivo/tview v0.0.0-20240524063012-037df494fb76
)
require (
github.com/andybalholm/cascadia v1.3.2 // indirect
github.com/gdamore/encoding v1.0.0 // indirect
github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-runewidth v0.0.15 // indirect
github.com/olekukonko/tablewriter v0.0.5 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect
golang.org/x/net v0.24.0 // indirect
golang.org/x/sys v0.19.0 // indirect
golang.org/x/term v0.19.0 // indirect
golang.org/x/text v0.14.0 // indirect
gopkg.in/neurosnap/sentences.v1 v1.0.7 // indirect
)

87
go.sum Normal file
View File

@@ -0,0 +1,87 @@
github.com/PuerkitoBio/goquery v1.9.2 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4yPeE=
github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk=
github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko=
github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg=
github.com/gdamore/tcell/v2 v2.7.4 h1:sg6/UnTM9jGpZU+oFYAsDahfchWAFW8Xx2yFinNSAYU=
github.com/gdamore/tcell/v2 v2.7.4/go.mod h1:dSXtXTSK0VsW1biw65DZLZ2NKr7j0qP/0J7ONmsraWg=
github.com/gelembjuk/articletext v0.0.0-20231013143648-bc7a97ba132a h1:qiro+IlH6Wj1YAEnLGYYGNuqEEQUyrDDWThnHL5Xgzo=
github.com/gelembjuk/articletext v0.0.0-20231013143648-bc7a97ba132a/go.mod h1:MEAzbitBZyN3cjFntWZJnfeForJTU+VNDLR69SdHesA=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 h1:iCHtR9CQyktQ5+f3dMVZfwD2KWJUgm7M0gdL9NGr8KA=
github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/k3a/html2text v1.2.1 h1:nvnKgBvBR/myqrwfLuiqecUtaK1lB9hGziIJKatNFVY=
github.com/k3a/html2text v1.2.1/go.mod h1:ieEXykM67iT8lTvEWBh6fhpH4B23kB9OMKPdIBmgUqA=
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-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/neurosnap/sentences v1.1.2 h1:iphYOzx/XckXeBiLIUBkPu2EKMJ+6jDbz/sLJZ7ZoUw=
github.com/neurosnap/sentences v1.1.2/go.mod h1:/pwU4E9XNL21ygMIkOIllv/SMy2ujHwpf8GQPu1YPbQ=
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
github.com/rivo/tview v0.0.0-20240524063012-037df494fb76 h1:iqvDlgyjmqleATtFbA7c14djmPh2n4mCYUv7JlD/ruA=
github.com/rivo/tview v0.0.0-20240524063012-037df494fb76/go.mod h1:02iFIz7K/A9jGCvrizLPvoqr4cEIx7q54RH5Qudkrss=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s=
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo=
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w=
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.19.0 h1:+ThwsDv+tYfnJFhF4L8jITxu1tdTWRTZpdsWgEgjL6Q=
golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/neurosnap/sentences.v1 v1.0.7 h1:gpTUYnqthem4+o8kyTLiYIB05W+IvdQFYR29erfe8uU=
gopkg.in/neurosnap/sentences.v1 v1.0.7/go.mod h1:YlK+SN+fLQZj+kY3r8DkGDhDr91+S3JmTb5LSxFRQo0=

38
main.go Normal file
View File

@@ -0,0 +1,38 @@
package main
import (
"log"
"os"
"github.com/rivo/tview"
)
var hackerNewsURL = "https://news.ycombinator.com/"
func main() {
app := tview.NewApplication()
// TODO: rewrite this for other options
if len(os.Args) > 1 && os.Args[1] == "best" {
hackerNewsURL = "https://news.ycombinator.com/best"
}
htmlContent, err := fetchWebpage(hackerNewsURL)
if err != nil {
log.Fatal(err)
}
articles, err := parseArticles(htmlContent)
if err != nil {
log.Fatal(err)
}
list := createArticleList(articles)
pages := tview.NewPages()
pages.AddPage("homepage", list, true, false)
app.SetInputCapture(createInputHandler(app, list, articles, pages))
if err := app.SetRoot(list, true).Run(); err != nil {
log.Fatal(err)
}
}

73
parser.go Normal file
View File

@@ -0,0 +1,73 @@
package main
import (
"fmt"
"regexp"
"strconv"
"strings"
"github.com/PuerkitoBio/goquery"
)
type Article struct {
Title string
Link string
Comments int
CommentsLink string
}
func parseArticles(htmlContent string) ([]Article, error) {
var articles []Article
doc, err := goquery.NewDocumentFromReader(strings.NewReader(htmlContent))
if err != nil {
return nil, err
}
doc.Find("tr.athing").Each(func(i int, s *goquery.Selection) {
title := s.Find("td.title > span.titleline > a").Text()
link, _ := s.Find("td.title > span.titleline > a").Attr("href")
commentText := s.Next().Find("a[href^='item']").Last().Text()
commentsCount, err := extractNumberFromString(commentText)
commentsLink := s.Next().Find("a[href^='item']").Last().AttrOr("href", "")
if err != nil {
commentsCount = 0
}
article := Article{
Title: title,
Link: link,
Comments: commentsCount,
CommentsLink: commentsLink,
}
articles = append(articles, article)
})
return articles, nil
}
func extractCommentsCount(s *goquery.Selection) (int, error) {
// find the second a[href^='item'] element
commentText := s.Next().Find("a[href^='item']").Last().Text()
return extractNumberFromString(commentText)
}
func extractNumberFromString(input string) (int, error) {
input = strings.TrimSpace(input)
re := regexp.MustCompile(`\d+`)
matches := re.FindString(input)
if matches == "" {
return 0, fmt.Errorf("no numbers found in input")
}
number, err := strconv.Atoi(matches)
if err != nil {
return 0, err
}
return number, nil
}

147
parser_test.go Normal file
View File

@@ -0,0 +1,147 @@
package main
import (
"testing"
)
func TestParseArticles(t *testing.T) {
htmlContent := `
<table>
<tr class="athing">
<td align="right" valign="top" class="title"><span class="rank">1.</span></td>
<td valign="top" class="votelinks">
<center><a id='up_40477653' href='vote?id=40477653&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a></center>
</td>
<td class="title"><span class="titleline"><a
href="https://newscenter.lbl.gov/2012/05/16/majorana-demonstrator/">Majorana, the search for the most elusive neutrino of all</a><span class="sitebit comhead"> (<a href="from?site=lbl.gov"><span
class="sitestr">lbl.gov</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_40477653">23 points</span> by <a href="user?id=bilsbie"
class="hnuser">bilsbie</a> <span class="age" title="2024-05-25T20:35:59"><a
href="item?id=40477653">2 hours ago</a></span> <span id="unv_40477653"></span> | <a
href="hide?id=40477653&amp;goto=news">hide</a> | <a href="item?id=40477653">1&nbsp;comment</a>
</span>
</td>
</tr>
<tr class="athing">
<td align="right" valign="top" class="title"><span class="rank">2.</span></td>
<td valign="top" class="votelinks">
<center><a id='up_40474712' href='vote?id=40474712&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a></center>
</td>
<td class="title"><span class="titleline"><a
href="https://reverse.put.as/2024/05/24/abusing-go-infrastructure/">Abusing Go&#x27;s Infrastructure</a><span class="sitebit comhead"> (<a href="from?site=put.as"><span
class="sitestr">put.as</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_40474712">298 points</span> by <a href="user?id=efge"
class="hnuser">efge</a> <span class="age" title="2024-05-25T12:50:00"><a href="item?id=40474712">10
hours ago</a></span> <span id="unv_40474712"></span> | <a
href="hide?id=40474712&amp;goto=news">hide</a> | <a href="item?id=40474712">62&nbsp;comments</a>
</span>
</td>
</tr>
</table>`
expectedArticles := []Article{
{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"},
{Title: "Abusing Go's Infrastructure", Link: "https://reverse.put.as/2024/05/24/abusing-go-infrastructure/", Comments: 62, CommentsLink: "item?id=40474712"},
}
articles, err := parseArticles(htmlContent)
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if len(articles) != len(expectedArticles) {
t.Fatalf("Expected %d articles, got %d", len(expectedArticles), len(articles))
}
for i, article := range articles {
if article.Title != expectedArticles[i].Title {
t.Errorf("Expected title %q, got %q", expectedArticles[i].Title, article.Title)
}
if article.Link != expectedArticles[i].Link {
t.Errorf("Expected link %q, got %q", expectedArticles[i].Link, article.Link)
}
if article.Comments != expectedArticles[i].Comments {
t.Errorf("Expected comments %d, got %d", expectedArticles[i].Comments, article.Comments)
}
if article.CommentsLink != expectedArticles[i].CommentsLink {
t.Errorf("Expected comments link %q, got %q", expectedArticles[i].CommentsLink, article.CommentsLink)
}
}
}
// func TestParseArticles(t *testing.T) {
// t.Run("Empty HTML", func(t *testing.T) {
// articles, err := parseArticles("")
// assert.NoError(t, err)
// assert.Empty(t, articles)
// })
// t.Run("Valid HTML", func(t *testing.T) {
// html := `
// <tr class="athing">
// <td class="title">
// <span class="titleline">
// <a href="https://example.com">Article 1</a>
// </span>
// </td>
// </tr>
// <tr class="athing">
// <td class="title">
// <span class="titleline">
// <a href="https://example.com/2">Article 2</a>
// </span>
// </td>
// </tr>
// `
// articles, err := parseArticles(html)
// assert.NoError(t, err)
// assert.Len(t, articles, 2)
// assert.Equal(t, "Article 1", articles[0].Title)
// assert.Equal(t, "https://example.com", articles[0].Link)
// assert.Equal(t, "Article 2", articles[1].Title)
// assert.Equal(t, "https://example.com/2", articles[1].Link)
// })
// t.Run("Error in goquery", func(t *testing.T) {
// doc := &goquery.Document{}
// // doc.SetError(fmt.Errorf("test error"))
// articles, err := parseArticlesFromDocument(doc)
// assert.Error(t, err)
// assert.Nil(t, articles)
// })
// }
// func parseArticlesFromDocument(doc *goquery.Document) ([]Article, error) {
// var articles []Article
// doc.Find("tr.athing").Each(func(i int, s *goquery.Selection) {
// title := s.Find("td.title > span.titleline > a").Text()
// link, _ := s.Find("td.title > span.titleline > a").Attr("href")
// commentsCount, err := extractCommentsCount(s)
// if err != nil {
// commentsCount = 0
// }
// article := Article{
// Title: title,
// Link: link,
// Comments: commentsCount,
// }
// articles = append(articles, article)
// })
// return articles, nil
// }

169
ui.go Normal file
View File

@@ -0,0 +1,169 @@
package main
import (
"fmt"
"log"
"net/url"
"os/exec"
"runtime"
"strconv"
"github.com/gdamore/tcell/v2"
"github.com/gelembjuk/articletext"
"github.com/rivo/tview"
)
func createArticleList(articles []Article) *tview.List {
list := tview.NewList().ShowSecondaryText(true).SetSecondaryTextColor(tcell.ColorGray)
for _, article := range articles {
title := article.Title
if article.Comments > 50 { // TODO: configurable
title = "🔥 " + title
}
commentsText := strconv.Itoa(article.Comments) + " comments"
list.AddItem(title, extractDomain(article.Link)+" "+commentsText, 0, nil)
}
return list
}
func extractDomain(link string) string {
u, err := url.Parse(link)
if err != nil {
log.Fatal(err)
}
return u.Host
}
func createInputHandler(app *tview.Application, list *tview.List, articles []Article, pages *tview.Pages) func(event *tcell.EventKey) *tcell.EventKey {
return func(event *tcell.EventKey) *tcell.EventKey {
switch event.Key() {
case tcell.KeyCtrlC:
app.Stop()
return nil
case tcell.KeyRight:
nextPage(pages, app, articles, list)
return nil
case tcell.KeyLeft:
backPage(pages)
return nil
case tcell.KeyRune:
switch event.Rune() {
case 'q':
app.Stop()
return nil
case 'j':
return tcell.NewEventKey(tcell.KeyDown, 0, tcell.ModNone)
case 'k':
return tcell.NewEventKey(tcell.KeyUp, 0, tcell.ModNone)
case 'l':
nextPage(pages, app, articles, list)
return nil
case 'h':
backPage(pages)
return nil
case ' ':
openURL(articles[list.GetCurrentItem()].Link)
return nil
case 'c':
openURL(hackerNewsURL + articles[list.GetCurrentItem()].CommentsLink)
return nil
}
}
return event
}
}
func backPage(pages *tview.Pages) {
// TODO: navigation flow will become configurable
currentPage, _ := pages.GetFrontPage()
if currentPage == "comments" {
pages.SwitchToPage("homepage")
}
if currentPage == "article" {
pages.SwitchToPage("comments")
}
}
func nextPage(pages *tview.Pages, app *tview.Application, articles []Article, list *tview.List) {
currentPage, _ := pages.GetFrontPage()
if currentPage == "comments" {
openArticle(app, articles[list.GetCurrentItem()].Link, pages)
} else {
openComments(app, articles[list.GetCurrentItem()].CommentsLink, pages)
}
}
func openComments(app *tview.Application, commentsLink string, pages *tview.Pages) {
u, err := url.Parse(commentsLink)
if err != nil {
fmt.Println("Error parsing URL:", err) // TODO maybe alert dialogbox
return
}
story_id := u.Query().Get("id")
articleStringList := fetchComments(story_id)
commentsText := ""
for _, articleString := range articleStringList {
commentsText += articleString + "\n"
}
displayComments(app, pages, commentsText)
}
func openArticle(app *tview.Application, articleLink string, pages *tview.Pages) {
articleText := getArticleTextFromLink(articleLink)
displayArticle(app, pages, articleText)
}
func getArticleTextFromLink(url string) string {
article, err := articletext.GetArticleTextFromUrl(url)
if err != nil {
fmt.Printf("Failed to parse %s, %v\n", url, err)
}
return article
}
func displayArticle(app *tview.Application, pages *tview.Pages, text string) {
articleTextView := tview.NewTextView().
SetText(text).
SetDynamicColors(true).
SetScrollable(true)
pages.AddPage("article", articleTextView, true, true)
if err := app.SetRoot(pages, true).Run(); err != nil {
log.Fatal(err)
}
}
func displayComments(app *tview.Application, pages *tview.Pages, text string) {
commentsTextView := tview.NewTextView().
SetText(text).
SetDynamicColors(true).
SetScrollable(true)
pages.AddPage("comments", commentsTextView, true, true)
if err := app.SetRoot(pages, true).Run(); err != nil {
log.Fatal(err)
}
}
func openURL(url string) {
var cmd string
var args []string
switch runtime.GOOS {
case "windows":
cmd = "cmd"
args = []string{"/c", "start"}
case "darwin":
cmd = "open"
default: // "linux", "freebsd", "openbsd", "netbsd"
cmd = "xdg-open"
}
args = append(args, url)
exec.Command(cmd, args...).Start()
}

125
web.go Normal file
View File

@@ -0,0 +1,125 @@
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"github.com/k3a/html2text"
)
func fetchWebpage(url string) (string, error) {
res, err := http.Get(url)
if err != nil {
return "", err
}
defer res.Body.Close()
body, err := io.ReadAll(res.Body)
if err != nil {
return "", err
}
return string(body), nil
}
const HN_SEARCH_URL = "https://hn.algolia.com/api/v1/"
type Comment struct {
Author string `json:"author"`
Text string `json:"text"`
Children []Comment `json:"children"`
}
func sanitize(input string) string {
return html2text.HTML2Text(input)
}
func safeRequest(url string) *http.Response {
resp, err := http.Get(url)
if err != nil {
fmt.Printf("Failed to fetch URL: %s\n", url)
return nil
}
return resp
}
func fetchComments(storyID string) []string {
resp := safeRequest(HN_SEARCH_URL + "items/" + storyID)
if resp == nil {
return nil
}
defer resp.Body.Close()
var comments map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&comments); err != nil {
fmt.Printf("Failed to decode JSON response\n")
return nil
}
var lines []string
lines = append(lines, " ")
if children, ok := comments["children"].([]interface{}); ok {
for _, child := range children {
childComment := child.(map[string]interface{})
appendComment(childComment, &lines, 0)
}
}
return lines
}
func appendComment(comment map[string]interface{}, lines *[]string, level int) {
indent := "" + strings.Repeat(" ", min(level, 4)*2) + "| "
if author, ok := comment["author"].(string); ok {
*lines = append(*lines, indent+sanitize(author)+" wrote:")
text := sanitize(comment["text"].(string))
paragraphs := strings.Split(text, "\n\n")
for _, paragraph := range paragraphs {
textLines := wrapText(paragraph, indent)
*lines = append(*lines, textLines...)
*lines = append(*lines, indent)
}
*lines = (*lines)[:len(*lines)-1] // Drop the blank line after the last paragraph
} else {
*lines = append(*lines, indent+"[deleted]")
}
*lines = append(*lines, " ")
if children, ok := comment["children"].([]interface{}); ok {
for _, child := range children {
appendComment(child.(map[string]interface{}), lines, level+1)
}
}
}
func wrapText(text, indent string) []string {
words := strings.Fields(text)
var lines []string
var sb strings.Builder
maxWidth := 80
for _, word := range words {
if sb.Len()+len(word)+1 > maxWidth {
lines = append(lines, indent+sb.String())
sb.Reset()
}
if sb.Len() > 0 {
sb.WriteString(" ")
}
sb.WriteString(word)
}
if sb.Len() > 0 {
lines = append(lines, indent+sb.String())
}
return lines
}