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