init
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
.vscode/
|
||||
.DS_STORE
|
||||
hn-text
|
||||
27
go.mod
Normal file
27
go.mod
Normal 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
87
go.sum
Normal 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
38
main.go
Normal 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
73
parser.go
Normal 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
147
parser_test.go
Normal 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&how=up&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&goto=news">hide</a> | <a href="item?id=40477653">1 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&how=up&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'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&goto=news">hide</a> | <a href="item?id=40474712">62 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
169
ui.go
Normal 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
125
web.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user