229 lines
5.1 KiB
Go
229 lines
5.1 KiB
Go
package logic
|
|
|
|
import (
|
|
"context"
|
|
"encoding/xml"
|
|
"fmt"
|
|
"html"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
const (
|
|
rssMaxBody = 2 << 20
|
|
rssTimeout = 20 * time.Second
|
|
summaryRunes = 280
|
|
)
|
|
|
|
// FeedItem is one public RSS/Atom entry (title + summary + link).
|
|
type FeedItem struct {
|
|
Title string
|
|
Summary string
|
|
Link string
|
|
Source string
|
|
}
|
|
|
|
type rssFeed struct {
|
|
Channel struct {
|
|
Title string `xml:"title"`
|
|
Items []rssItem `xml:"item"`
|
|
} `xml:"channel"`
|
|
}
|
|
|
|
type rssItem struct {
|
|
Title string `xml:"title"`
|
|
Link string `xml:"link"`
|
|
Description string `xml:"description"`
|
|
GUID string `xml:"guid"`
|
|
}
|
|
|
|
type atomFeed struct {
|
|
Title string `xml:"title"`
|
|
Entries []atomEntry `xml:"entry"`
|
|
}
|
|
|
|
type atomEntry struct {
|
|
Title string `xml:"title"`
|
|
Summary string `xml:"summary"`
|
|
Content string `xml:"content"`
|
|
Links []atomLink `xml:"link"`
|
|
ID string `xml:"id"`
|
|
}
|
|
|
|
type atomLink struct {
|
|
Href string `xml:"href,attr"`
|
|
Rel string `xml:"rel,attr"`
|
|
}
|
|
|
|
var tagRe = regexp.MustCompile(`(?s)<[^>]*>`)
|
|
|
|
// FetchFeed loads and parses a public RSS 2.0 or Atom URL.
|
|
func FetchFeed(ctx context.Context, client *http.Client, feedURL string) ([]FeedItem, error) {
|
|
feedURL = strings.TrimSpace(feedURL)
|
|
if feedURL == "" {
|
|
return nil, fmt.Errorf("empty feed url")
|
|
}
|
|
if client == nil {
|
|
client = &http.Client{Timeout: rssTimeout}
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, feedURL, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("User-Agent", "SparkIM-SquareBot/1.0 (+https://spark.wyrs365.com)")
|
|
req.Header.Set("Accept", "application/rss+xml, application/atom+xml, application/xml, text/xml, */*")
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return nil, fmt.Errorf("feed status %d", resp.StatusCode)
|
|
}
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, rssMaxBody))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return parseFeed(body, feedURL)
|
|
}
|
|
|
|
func parseFeed(body []byte, feedURL string) ([]FeedItem, error) {
|
|
var rss rssFeed
|
|
if err := xml.Unmarshal(body, &rss); err == nil && len(rss.Channel.Items) > 0 {
|
|
src := strings.TrimSpace(rss.Channel.Title)
|
|
out := make([]FeedItem, 0, len(rss.Channel.Items))
|
|
for _, it := range rss.Channel.Items {
|
|
link := firstNonEmpty(strings.TrimSpace(it.Link), strings.TrimSpace(it.GUID))
|
|
item := FeedItem{
|
|
Title: cleanText(it.Title),
|
|
Summary: cleanText(it.Description),
|
|
Link: normalizeURL(link),
|
|
Source: src,
|
|
}
|
|
if item.Title == "" || item.Link == "" {
|
|
continue
|
|
}
|
|
out = append(out, item)
|
|
}
|
|
if len(out) > 0 {
|
|
return out, nil
|
|
}
|
|
}
|
|
var atom atomFeed
|
|
if err := xml.Unmarshal(body, &atom); err != nil {
|
|
return nil, fmt.Errorf("parse feed %s: %w", feedURL, err)
|
|
}
|
|
src := cleanText(atom.Title)
|
|
out := make([]FeedItem, 0, len(atom.Entries))
|
|
for _, e := range atom.Entries {
|
|
link := atomBestLink(e.Links)
|
|
if link == "" {
|
|
link = strings.TrimSpace(e.ID)
|
|
}
|
|
sum := firstNonEmpty(e.Summary, e.Content)
|
|
item := FeedItem{
|
|
Title: cleanText(e.Title),
|
|
Summary: cleanText(sum),
|
|
Link: normalizeURL(link),
|
|
Source: src,
|
|
}
|
|
if item.Title == "" || item.Link == "" {
|
|
continue
|
|
}
|
|
out = append(out, item)
|
|
}
|
|
if len(out) == 0 {
|
|
return nil, fmt.Errorf("empty feed %s", feedURL)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func atomBestLink(links []atomLink) string {
|
|
var alt, self, any string
|
|
for _, l := range links {
|
|
href := strings.TrimSpace(l.Href)
|
|
if href == "" {
|
|
continue
|
|
}
|
|
any = href
|
|
switch strings.ToLower(strings.TrimSpace(l.Rel)) {
|
|
case "", "alternate":
|
|
if alt == "" {
|
|
alt = href
|
|
}
|
|
case "self":
|
|
if self == "" {
|
|
self = href
|
|
}
|
|
}
|
|
}
|
|
return firstNonEmpty(alt, self, any)
|
|
}
|
|
|
|
func FormatPostText(item FeedItem) string {
|
|
title := strings.TrimSpace(item.Title)
|
|
sum := truncateRunes(strings.TrimSpace(item.Summary), summaryRunes)
|
|
link := strings.TrimSpace(item.Link)
|
|
src := strings.TrimSpace(item.Source)
|
|
var b strings.Builder
|
|
if src != "" {
|
|
b.WriteString(src)
|
|
b.WriteString("\n")
|
|
}
|
|
b.WriteString(title)
|
|
if sum != "" && !strings.EqualFold(sum, title) {
|
|
b.WriteString("\n\n")
|
|
b.WriteString(sum)
|
|
}
|
|
b.WriteString("\n\n")
|
|
b.WriteString(link)
|
|
text := strings.TrimSpace(b.String())
|
|
return truncateRunes(text, 1000)
|
|
}
|
|
|
|
func cleanText(s string) string {
|
|
s = html.UnescapeString(s)
|
|
s = tagRe.ReplaceAllString(s, " ")
|
|
s = strings.Join(strings.Fields(s), " ")
|
|
return strings.TrimSpace(s)
|
|
}
|
|
|
|
func truncateRunes(s string, max int) string {
|
|
if max <= 0 || utf8.RuneCountInString(s) <= max {
|
|
return s
|
|
}
|
|
runes := []rune(s)
|
|
return string(runes[:max-1]) + "…"
|
|
}
|
|
|
|
func normalizeURL(raw string) string {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return ""
|
|
}
|
|
u, err := url.Parse(raw)
|
|
if err != nil || u.Scheme == "" || u.Host == "" {
|
|
return ""
|
|
}
|
|
scheme := strings.ToLower(u.Scheme)
|
|
if scheme != "http" && scheme != "https" {
|
|
return ""
|
|
}
|
|
u.Fragment = ""
|
|
return u.String()
|
|
}
|
|
|
|
func firstNonEmpty(vals ...string) string {
|
|
for _, v := range vals {
|
|
if strings.TrimSpace(v) != "" {
|
|
return strings.TrimSpace(v)
|
|
}
|
|
}
|
|
return ""
|
|
}
|