311 lines
8.6 KiB
Go
311 lines
8.6 KiB
Go
package glance
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"html"
|
|
"html/template"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
var (
|
|
redditWidgetHorizontalCardsTemplate = mustParseTemplate("reddit-horizontal-cards.html", "widget-base.html")
|
|
redditWidgetVerticalCardsTemplate = mustParseTemplate("reddit-vertical-cards.html", "widget-base.html")
|
|
)
|
|
|
|
type redditWidget struct {
|
|
widgetBase `yaml:",inline"`
|
|
Posts forumPostList `yaml:"-"`
|
|
Subreddit string `yaml:"subreddit"`
|
|
Proxy proxyOptionsField `yaml:"proxy"`
|
|
Style string `yaml:"style"`
|
|
ShowThumbnails bool `yaml:"show-thumbnails"`
|
|
ShowFlairs bool `yaml:"show-flairs"`
|
|
SortBy string `yaml:"sort-by"`
|
|
TopPeriod string `yaml:"top-period"`
|
|
Search string `yaml:"search"`
|
|
ExtraSortBy string `yaml:"extra-sort-by"`
|
|
CommentsURLTemplate string `yaml:"comments-url-template"`
|
|
Limit int `yaml:"limit"`
|
|
CollapseAfter int `yaml:"collapse-after"`
|
|
RequestURLTemplate string `yaml:"request-url-template"`
|
|
|
|
AppAuth struct {
|
|
Name string `yaml:"name"`
|
|
ID string `yaml:"id"`
|
|
Secret string `yaml:"secret"`
|
|
|
|
enabled bool
|
|
accessToken string
|
|
tokenExpiresAt time.Time
|
|
} `yaml:"app-auth"`
|
|
}
|
|
|
|
func (widget *redditWidget) initialize() error {
|
|
if widget.Subreddit == "" {
|
|
return errors.New("subreddit is required")
|
|
}
|
|
|
|
if widget.Limit <= 0 {
|
|
widget.Limit = 15
|
|
}
|
|
|
|
if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 {
|
|
widget.CollapseAfter = 5
|
|
}
|
|
|
|
s := widget.SortBy
|
|
if s != "hot" && s != "new" && s != "top" && s != "rising" {
|
|
widget.SortBy = "hot"
|
|
}
|
|
|
|
p := widget.TopPeriod
|
|
if p != "hour" && p != "day" && p != "week" && p != "month" && p != "year" && p != "all" {
|
|
widget.TopPeriod = "day"
|
|
}
|
|
|
|
if widget.RequestURLTemplate != "" {
|
|
if !strings.Contains(widget.RequestURLTemplate, "{REQUEST-URL}") {
|
|
return errors.New("no `{REQUEST-URL}` placeholder specified")
|
|
}
|
|
}
|
|
|
|
a := &widget.AppAuth
|
|
if a.Name != "" || a.ID != "" || a.Secret != "" {
|
|
if a.Name == "" || a.ID == "" || a.Secret == "" {
|
|
return errors.New("application name, client ID and client secret are required")
|
|
}
|
|
a.enabled = true
|
|
}
|
|
|
|
widget.
|
|
withTitle("r/" + widget.Subreddit).
|
|
withTitleURL("https://www.reddit.com/r/" + widget.Subreddit + "/").
|
|
withCacheDuration(30 * time.Minute)
|
|
|
|
return nil
|
|
}
|
|
|
|
func (widget *redditWidget) update(ctx context.Context) {
|
|
posts, err := widget.fetchSubredditPosts()
|
|
if !widget.canContinueUpdateAfterHandlingErr(err) {
|
|
return
|
|
}
|
|
|
|
if len(posts) > widget.Limit {
|
|
posts = posts[:widget.Limit]
|
|
}
|
|
|
|
if widget.ExtraSortBy == "engagement" {
|
|
posts.calculateEngagement()
|
|
posts.sortByEngagement()
|
|
}
|
|
|
|
widget.Posts = posts
|
|
}
|
|
|
|
func (widget *redditWidget) Render() template.HTML {
|
|
if widget.Style == "horizontal-cards" {
|
|
return widget.renderTemplate(widget, redditWidgetHorizontalCardsTemplate)
|
|
}
|
|
|
|
if widget.Style == "vertical-cards" {
|
|
return widget.renderTemplate(widget, redditWidgetVerticalCardsTemplate)
|
|
}
|
|
|
|
return widget.renderTemplate(widget, forumPostsTemplate)
|
|
|
|
}
|
|
|
|
type subredditResponseJson struct {
|
|
Data struct {
|
|
Children []struct {
|
|
Data struct {
|
|
Id string `json:"id"`
|
|
Title string `json:"title"`
|
|
Upvotes int `json:"ups"`
|
|
Url string `json:"url"`
|
|
Time float64 `json:"created"`
|
|
CommentsCount int `json:"num_comments"`
|
|
Domain string `json:"domain"`
|
|
Permalink string `json:"permalink"`
|
|
Stickied bool `json:"stickied"`
|
|
Pinned bool `json:"pinned"`
|
|
IsSelf bool `json:"is_self"`
|
|
Thumbnail string `json:"thumbnail"`
|
|
Flair string `json:"link_flair_text"`
|
|
ParentList []struct {
|
|
Id string `json:"id"`
|
|
Subreddit string `json:"subreddit"`
|
|
Permalink string `json:"permalink"`
|
|
} `json:"crosspost_parent_list"`
|
|
} `json:"data"`
|
|
} `json:"children"`
|
|
} `json:"data"`
|
|
}
|
|
|
|
func (widget *redditWidget) parseCustomCommentsURL(subreddit, postId, postPath string) string {
|
|
template := strings.ReplaceAll(widget.CommentsURLTemplate, "{SUBREDDIT}", subreddit)
|
|
template = strings.ReplaceAll(template, "{POST-ID}", postId)
|
|
template = strings.ReplaceAll(template, "{POST-PATH}", strings.TrimLeft(postPath, "/"))
|
|
|
|
return template
|
|
}
|
|
|
|
func (widget *redditWidget) fetchSubredditPosts() (forumPostList, error) {
|
|
var client requestDoer = defaultHTTPClient
|
|
var baseURL string
|
|
var requestURL string
|
|
var headers http.Header
|
|
query := url.Values{}
|
|
app := &widget.AppAuth
|
|
|
|
if !app.enabled {
|
|
baseURL = "https://www.reddit.com"
|
|
headers = http.Header{
|
|
"User-Agent": []string{getBrowserUserAgentHeader()},
|
|
}
|
|
} else {
|
|
baseURL = "https://oauth.reddit.com"
|
|
|
|
if app.accessToken == "" || time.Now().Add(time.Minute).After(app.tokenExpiresAt) {
|
|
if err := widget.fetchNewAppAccessToken(); err != nil {
|
|
return nil, fmt.Errorf("fetching new app access token: %v", err)
|
|
}
|
|
}
|
|
|
|
headers = http.Header{
|
|
"Authorization": []string{"Bearer " + app.accessToken},
|
|
"User-Agent": []string{app.Name + "/1.0"},
|
|
}
|
|
}
|
|
|
|
if widget.Limit > 25 {
|
|
query.Set("limit", strconv.Itoa(widget.Limit))
|
|
}
|
|
|
|
if widget.Search != "" {
|
|
query.Set("q", widget.Search+" subreddit:"+widget.Subreddit)
|
|
query.Set("sort", widget.SortBy)
|
|
requestURL = fmt.Sprintf("%s/search.json?%s", baseURL, query.Encode())
|
|
} else {
|
|
if widget.SortBy == "top" {
|
|
query.Set("t", widget.TopPeriod)
|
|
}
|
|
requestURL = fmt.Sprintf("%s/r/%s/%s.json?%s", baseURL, widget.Subreddit, widget.SortBy, query.Encode())
|
|
}
|
|
|
|
if widget.RequestURLTemplate != "" {
|
|
requestURL = strings.ReplaceAll(widget.RequestURLTemplate, "{REQUEST-URL}", requestURL)
|
|
} else if widget.Proxy.client != nil {
|
|
client = widget.Proxy.client
|
|
}
|
|
|
|
request, err := http.NewRequest("GET", requestURL, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
request.Header = headers
|
|
|
|
responseJson, err := decodeJsonFromRequest[subredditResponseJson](client, request)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if len(responseJson.Data.Children) == 0 {
|
|
return nil, fmt.Errorf("no posts found")
|
|
}
|
|
|
|
posts := make(forumPostList, 0, len(responseJson.Data.Children))
|
|
|
|
for i := range responseJson.Data.Children {
|
|
post := &responseJson.Data.Children[i].Data
|
|
|
|
if post.Stickied || post.Pinned {
|
|
continue
|
|
}
|
|
|
|
var commentsUrl string
|
|
|
|
if widget.CommentsURLTemplate == "" {
|
|
commentsUrl = "https://www.reddit.com" + post.Permalink
|
|
} else {
|
|
commentsUrl = widget.parseCustomCommentsURL(widget.Subreddit, post.Id, post.Permalink)
|
|
}
|
|
|
|
forumPost := forumPost{
|
|
Title: html.UnescapeString(post.Title),
|
|
DiscussionUrl: commentsUrl,
|
|
TargetUrlDomain: post.Domain,
|
|
CommentCount: post.CommentsCount,
|
|
Score: post.Upvotes,
|
|
TimePosted: time.Unix(int64(post.Time), 0),
|
|
}
|
|
|
|
if post.Thumbnail != "" && post.Thumbnail != "self" && post.Thumbnail != "default" && post.Thumbnail != "nsfw" {
|
|
forumPost.ThumbnailUrl = html.UnescapeString(post.Thumbnail)
|
|
}
|
|
|
|
if !post.IsSelf {
|
|
forumPost.TargetUrl = post.Url
|
|
}
|
|
|
|
if widget.ShowFlairs && post.Flair != "" {
|
|
forumPost.Tags = append(forumPost.Tags, post.Flair)
|
|
}
|
|
|
|
if len(post.ParentList) > 0 {
|
|
forumPost.IsCrosspost = true
|
|
forumPost.TargetUrlDomain = "r/" + post.ParentList[0].Subreddit
|
|
|
|
if widget.CommentsURLTemplate == "" {
|
|
forumPost.TargetUrl = "https://www.reddit.com" + post.ParentList[0].Permalink
|
|
} else {
|
|
forumPost.TargetUrl = widget.parseCustomCommentsURL(
|
|
post.ParentList[0].Subreddit,
|
|
post.ParentList[0].Id,
|
|
post.ParentList[0].Permalink,
|
|
)
|
|
}
|
|
}
|
|
|
|
posts = append(posts, forumPost)
|
|
}
|
|
|
|
return posts, nil
|
|
}
|
|
|
|
func (widget *redditWidget) fetchNewAppAccessToken() error {
|
|
body := strings.NewReader("grant_type=client_credentials")
|
|
req, err := http.NewRequest("POST", "https://www.reddit.com/api/v1/access_token", body)
|
|
if err != nil {
|
|
return fmt.Errorf("creating request for app access token: %v", err)
|
|
}
|
|
|
|
app := &widget.AppAuth
|
|
req.SetBasicAuth(app.ID, app.Secret)
|
|
req.Header.Add("User-Agent", app.Name+"/1.0")
|
|
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
|
|
|
|
type tokenResponse struct {
|
|
AccessToken string `json:"access_token"`
|
|
ExpiresIn int `json:"expires_in"`
|
|
}
|
|
|
|
client := ternary(widget.Proxy.client != nil, widget.Proxy.client, defaultHTTPClient)
|
|
response, err := decodeJsonFromRequest[tokenResponse](client, req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
app.accessToken = response.AccessToken
|
|
app.tokenExpiresAt = time.Now().Add(time.Duration(response.ExpiresIn) * time.Second)
|
|
|
|
return nil
|
|
}
|