diff --git a/docs/configuration.md b/docs/configuration.md index c9dd473..bf3ba45 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -34,6 +34,8 @@ - [DNS Stats](#dns-stats) - [Server Stats](#server-stats) - [Repository](#repository) + + - [Trending Repositories](#trending-repositories) - [Bookmarks](#bookmarks) - [Calendar](#calendar) - [Calendar (legacy)](#calendar-legacy) diff --git a/docs/images/trending-repositories-widget-preview.png b/docs/images/trending-repositories-widget-preview.png new file mode 100644 index 0000000..3760b83 Binary files /dev/null and b/docs/images/trending-repositories-widget-preview.png differ diff --git a/go.mod b/go.mod index f0d15ee..d59c9e6 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/shirou/gopsutil/v4 v4.25.5 github.com/tidwall/gjson v1.18.0 golang.org/x/crypto v0.39.0 + golang.org/x/net v0.41.0 golang.org/x/text v0.26.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -28,6 +29,5 @@ require ( github.com/tklauser/go-sysconf v0.3.15 // indirect github.com/tklauser/numcpus v0.10.0 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect - golang.org/x/net v0.41.0 // indirect golang.org/x/sys v0.33.0 // indirect ) diff --git a/go.sum b/go.sum index ee3bde9..56ae190 100644 --- a/go.sum +++ b/go.sum @@ -33,8 +33,6 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= -github.com/shirou/gopsutil/v4 v4.25.4 h1:cdtFO363VEOOFrUCjZRh4XVJkb548lyF0q0uTeMqYPw= -github.com/shirou/gopsutil/v4 v4.25.4/go.mod h1:xbuxyoZj+UsgnZrENu3lQivsngRR5BdjbJwf2fv4szA= github.com/shirou/gopsutil/v4 v4.25.5 h1:rtd9piuSMGeU8g1RMXjZs9y9luK5BwtnG7dZaQUJAsc= github.com/shirou/gopsutil/v4 v4.25.5/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -61,8 +59,6 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= -golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= @@ -79,8 +75,6 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= -golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= -golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -124,8 +118,6 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= -golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/internal/glance/searchable-node.go b/internal/glance/searchable-node.go new file mode 100644 index 0000000..83f002a --- /dev/null +++ b/internal/glance/searchable-node.go @@ -0,0 +1,161 @@ +package glance + +import ( + "strings" + + "golang.org/x/net/html" +) + +type searchableNode html.Node + +func (n *searchableNode) findFirst(tag string, attrs ...string) *searchableNode { + if tag == "" || n == nil { + return nil + } + + if len(attrs)%2 != 0 { + panic("attributes must be in key-value pairs") + } + + if n.matches(tag, attrs...) { + return n + } + + for child := n.FirstChild; child != nil; child = child.NextSibling { + if child.Type != html.ElementNode { + continue + } + + if found := (*searchableNode)(child).findFirst(tag, attrs...); found != nil { + return found + } + } + + return nil +} + +func (n *searchableNode) nthChild(index int) *searchableNode { + if n == nil || index < 0 { + return nil + } + + if index == 0 { + return n + } + + count := 0 + for child := n.FirstChild; child != nil; child = child.NextSibling { + if child.Type != html.ElementNode { + continue + } + + count++ + if count == index { + return (*searchableNode)(child) + } + } + + return nil +} + +func (n *searchableNode) findFirstChild(tag string, attrs ...string) *searchableNode { + if tag == "" || n == nil { + return nil + } + + if len(attrs)%2 != 0 { + panic("attributes must be in key-value pairs") + } + + for child := n.FirstChild; child != nil; child = child.NextSibling { + if child.Type != html.ElementNode { + continue + } + + if child.Type == html.ElementNode && (*searchableNode)(child).matches(tag, attrs...) { + return (*searchableNode)(child) + } + } + + return nil +} + +func (n *searchableNode) findAll(tag string, attrs ...string) []*searchableNode { + if tag == "" || n == nil { + return nil + } + + if len(attrs)%2 != 0 { + panic("attributes must be in key-value pairs") + } + + var results []*searchableNode + + if n.matches(tag, attrs...) { + results = append(results, n) + } + + for child := n.FirstChild; child != nil; child = child.NextSibling { + if child.Type != html.ElementNode { + continue + } + + results = append(results, (*searchableNode)(child).findAll(tag, attrs...)...) + } + + return results +} + +func (n *searchableNode) matches(tag string, attrs ...string) bool { + if tag == "" || n == nil { + return false + } + + if len(attrs)%2 != 0 { + panic("attributes must be in key-value pairs") + } + + if n.Data != tag { + return false + } + + for i := 0; i < len(attrs); i += 2 { + key := attrs[i] + value := attrs[i+1] + found := false + for _, attr := range n.Attr { + if attr.Key == key && attr.Val == value { + found = true + break + } + } + if !found { + return false + } + } + + return true +} + +func (n *searchableNode) text() string { + if n == nil { + return "" + } + + if n.Type == html.TextNode { + return strings.TrimSpace(n.Data) + } + + var builder strings.Builder + + for child := n.FirstChild; child != nil; child = child.NextSibling { + switch child.Type { + case html.TextNode: + builder.WriteString(strings.TrimSpace(child.Data)) + case html.ElementNode: + builder.WriteString((*searchableNode)(child).text()) + } + } + + return builder.String() +} diff --git a/internal/glance/templates/trending-repositories.html b/internal/glance/templates/trending-repositories.html new file mode 100644 index 0000000..e9cac4e --- /dev/null +++ b/internal/glance/templates/trending-repositories.html @@ -0,0 +1,22 @@ +{{ template "widget-base.html" . }} + +{{ define "widget-content" }} + +{{ end }} diff --git a/internal/glance/widget-trending-repositories.go b/internal/glance/widget-trending-repositories.go new file mode 100644 index 0000000..77b1373 --- /dev/null +++ b/internal/glance/widget-trending-repositories.go @@ -0,0 +1,113 @@ +package glance + +import ( + "context" + "fmt" + "html/template" + "slices" + "strconv" + "strings" + "time" + + "golang.org/x/net/html" +) + +var trendingRepositoriesWidgetTemplate = mustParseTemplate("trending-repositories.html", "widget-base.html") + +type trendingRepositoriesWidget struct { + widgetBase `yaml:",inline"` + Repositories []trendingRepository `yaml:"-"` + + Language string `yaml:"language"` + DateRange string `yaml:"date-range"` + CollapseAfter int `yaml:"collapse-after"` +} + +func (widget *trendingRepositoriesWidget) initialize() error { + widget.withTitle("Trending Repositories").withCacheDuration(8 * time.Hour) + + if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 { + widget.CollapseAfter = 4 + } + + if !slices.Contains([]string{"daily", "weekly", "monthly"}, widget.DateRange) { + widget.DateRange = "daily" + } + + return nil +} + +func (widget *trendingRepositoriesWidget) update(ctx context.Context) { + repositories, err := widget.fetchTrendingRepositories() + + if !widget.canContinueUpdateAfterHandlingErr(err) { + return + } + + widget.Repositories = repositories +} + +func (widget *trendingRepositoriesWidget) Render() template.HTML { + return widget.renderTemplate(widget, trendingRepositoriesWidgetTemplate) +} + +type trendingRepository struct { + Slug string + Description string + Language string + Stars int +} + +func (widget *trendingRepositoriesWidget) fetchTrendingRepositories() ([]trendingRepository, error) { + url := fmt.Sprintf("https://github.com/trending/%s?since=%s", widget.Language, widget.DateRange) + + response, err := defaultHTTPClient.Get(url) + if err != nil { + return nil, fmt.Errorf("failed to fetch trending repositories: %w", err) + } + defer response.Body.Close() + + if response.StatusCode != 200 { + return nil, fmt.Errorf("unexpected status code %d for %s", response.StatusCode, url) + } + + parsedDoc, err := html.Parse(response.Body) + if err != nil { + return nil, fmt.Errorf("failed to parse HTML response: %w", err) + } + + doc := (*searchableNode)(parsedDoc) + repositories := make([]trendingRepository, 0, 15) + + repoElems := doc. + findFirst("main"). + findFirst("div", "class", "Box"). + findAll("article", "class", "Box-row") + + for _, repoElem := range repoElems { + nameElem := repoElem.findFirstChild("h2").findFirst("a", "class", "Link") + name := strings.ReplaceAll(nameElem.text(), " ", "") + + description := repoElem.findFirstChild("p").text() + metaElem := repoElem.findFirstChild("div", "class", "f6 color-fg-muted mt-2") + + language := metaElem.findFirst("span", "itemprop", "programmingLanguage").text() + starsIndex := 2 + if language == "" { + starsIndex = 1 + } + + starsText := metaElem.nthChild(starsIndex).text() + starsText = strings.ReplaceAll(starsText, ",", "") + stars, _ := strconv.Atoi(starsText) + + repositories = append(repositories, trendingRepository{ + Slug: name, + Description: description, + Language: language, + Stars: stars, + }) + } + + return repositories, nil +} diff --git a/internal/glance/widget.go b/internal/glance/widget.go index e6e1e78..832ba21 100644 --- a/internal/glance/widget.go +++ b/internal/glance/widget.go @@ -83,6 +83,8 @@ func newWidget(widgetType string) (widget, error) { w = &torrentsWidget{} case "to-do": w = &todoWidget{} + case "trending-repositories": + w = &trendingRepositoriesWidget{} default: return nil, fmt.Errorf("unknown widget type: %s", widgetType) }