mirror of https://github.com/glanceapp/glance.git
Merge branch 'widget/trending-repositories' into dev
commit
7d7017cc57
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
@ -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()
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
{{ template "widget-base.html" . }}
|
||||
|
||||
{{ define "widget-content" }}
|
||||
<ul class="list list-gap-14 collapsible-container" data-collapse-after="{{ .CollapseAfter }}">
|
||||
{{ range .Repositories }}
|
||||
<li>
|
||||
<a class="size-h4 block text-truncate color-primary-if-not-visited" href="https://github.com/{{ .Slug }}" target="_blank" rel="noreferrer">{{ .Slug }}</a>
|
||||
{{ if .Description}}
|
||||
<p class="text-truncate-2-lines margin-top-3">{{ .Description }}</p>
|
||||
{{ end }}
|
||||
<ul class="list-horizontal-text margin-top-3">
|
||||
{{ if .Language }}
|
||||
<li>{{ .Language }}</li>
|
||||
{{ end }}
|
||||
<li>{{ .Stars | formatNumber }} stars</li>
|
||||
</ul>
|
||||
</li>
|
||||
{{ else }}
|
||||
<p>No repositories found.</p>
|
||||
{{ end }}
|
||||
</ul>
|
||||
{{ end }}
|
||||
@ -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
|
||||
}
|
||||
Loading…
Reference in New Issue