yao/rss/rss.go

278 lines
8.2 KiB
Go

package rss
import (
"encoding/xml"
"strconv"
"strings"
)
// iTunes namespace URI
const itunesNS = "http://www.itunes.com/dtds/podcast-1.0.dtd"
// Content namespace URI (for content:encoded)
const contentNS = "http://purl.org/rss/1.0/modules/content/"
// --- Internal XML mapping structs for RSS 2.0 ---
type rssDoc struct {
XMLName xml.Name `xml:"rss"`
Channel rssChannel `xml:"channel"`
}
type rssChannel struct {
Title string `xml:"title"`
Link string `xml:"link"`
Description string `xml:"description"`
Language string `xml:"language"`
LastBuild string `xml:"lastBuildDate"`
PubDate string `xml:"pubDate"`
// iTunes namespace (podcast extensions) — channel level
ItunesAuthor string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd author"`
ItunesSummary string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd summary"`
ItunesImage itunesImage `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd image"`
ItunesOwner itunesOwner `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd owner"`
ItunesCategory []itunesCategory `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd category"`
ItunesExplicit string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd explicit"`
ItunesType string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd type"`
Items []rssItem `xml:"item"`
}
type rssItem struct {
Title string `xml:"title"`
Link string `xml:"link"`
Description string `xml:"description"`
Content string `xml:"http://purl.org/rss/1.0/modules/content/ encoded"`
Author string `xml:"author"`
DcCreator string `xml:"http://purl.org/dc/elements/1.1/ creator"`
PubDate string `xml:"pubDate"`
GUID rssGUID `xml:"guid"`
Categories []rssCategory `xml:"category"`
Enclosures []rssEnclosure `xml:"enclosure"`
// iTunes namespace (podcast extensions) — item level
ItunesDuration string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd duration"`
ItunesSeason string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd season"`
ItunesEpisode string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd episode"`
ItunesEpisodeType string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd episodeType"`
ItunesExplicit string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd explicit"`
ItunesImage itunesImage `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd image"`
ItunesSummary string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd summary"`
ItunesAuthor string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd author"`
}
type rssGUID struct {
Value string `xml:",chardata"`
IsPermaLink string `xml:"isPermaLink,attr"`
}
type rssCategory struct {
Value string `xml:",chardata"`
}
type rssEnclosure struct {
URL string `xml:"url,attr"`
Type string `xml:"type,attr"`
Length string `xml:"length,attr"`
}
type itunesImage struct {
Href string `xml:"href,attr"`
}
type itunesOwner struct {
Name string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd name"`
Email string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd email"`
}
type itunesCategory struct {
Text string `xml:"text,attr"`
Sub []itunesCategory `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd category"`
}
// parseRSS parses an RSS 2.0 XML document into a Feed struct.
func parseRSS(data []byte) (*Feed, error) {
var doc rssDoc
if err := xml.Unmarshal(data, &doc); err != nil {
return nil, err
}
ch := doc.Channel
feed := &Feed{
Format: "rss2.0",
Title: strings.TrimSpace(ch.Title),
Link: strings.TrimSpace(ch.Link),
Description: strings.TrimSpace(ch.Description),
Language: strings.TrimSpace(ch.Language),
Updated: firstNonEmpty(ch.LastBuild, ch.PubDate),
Items: make([]FeedItem, 0, len(ch.Items)),
}
// Build podcast metadata if any iTunes fields are present
feed.Podcast = buildPodcast(&ch)
for i := range ch.Items {
feed.Items = append(feed.Items, convertRSSItem(&ch.Items[i]))
}
return feed, nil
}
// convertRSSItem converts an internal rssItem to a public FeedItem.
func convertRSSItem(item *rssItem) FeedItem {
fi := FeedItem{
Title: strings.TrimSpace(item.Title),
Link: strings.TrimSpace(item.Link),
Description: strings.TrimSpace(item.Description),
Content: strings.TrimSpace(item.Content),
Published: strings.TrimSpace(item.PubDate),
GUID: strings.TrimSpace(item.GUID.Value),
}
// Author: prefer dc:creator over rss author (which is often an email)
fi.Author = strings.TrimSpace(item.DcCreator)
if fi.Author == "" {
fi.Author = strings.TrimSpace(item.Author)
}
// Categories
if len(item.Categories) > 0 {
fi.Categories = make([]string, 0, len(item.Categories))
for _, c := range item.Categories {
v := strings.TrimSpace(c.Value)
if v != "" {
fi.Categories = append(fi.Categories, v)
}
}
}
// Enclosures
if len(item.Enclosures) > 0 {
fi.Enclosures = make([]Enclosure, 0, len(item.Enclosures))
for _, e := range item.Enclosures {
if e.URL != "" {
fi.Enclosures = append(fi.Enclosures, Enclosure{
URL: e.URL,
Type: e.Type,
Length: e.Length,
})
}
}
}
// Podcast episode metadata
fi.Episode = buildEpisode(item)
return fi
}
// buildPodcast constructs Podcast metadata from iTunes namespace fields.
// Returns nil if no iTunes fields are populated.
func buildPodcast(ch *rssChannel) *Podcast {
hasContent := ch.ItunesAuthor != "" ||
ch.ItunesSummary != "" ||
ch.ItunesImage.Href != "" ||
ch.ItunesExplicit != "" ||
ch.ItunesType != "" ||
ch.ItunesOwner.Name != "" ||
ch.ItunesOwner.Email != "" ||
len(ch.ItunesCategory) > 0
if !hasContent {
return nil
}
p := &Podcast{
Author: strings.TrimSpace(ch.ItunesAuthor),
Summary: strings.TrimSpace(ch.ItunesSummary),
Image: strings.TrimSpace(ch.ItunesImage.Href),
Explicit: isExplicit(ch.ItunesExplicit),
Type: strings.TrimSpace(ch.ItunesType),
}
// Owner
if ch.ItunesOwner.Name != "" || ch.ItunesOwner.Email != "" {
p.Owner = &Owner{
Name: strings.TrimSpace(ch.ItunesOwner.Name),
Email: strings.TrimSpace(ch.ItunesOwner.Email),
}
}
// Categories (flatten nested categories)
p.Category = flattenCategories(ch.ItunesCategory)
return p
}
// buildEpisode constructs Episode metadata from iTunes namespace item fields.
// Returns nil if no iTunes episode fields are populated.
func buildEpisode(item *rssItem) *Episode {
hasContent := item.ItunesDuration != "" ||
item.ItunesSeason != "" ||
item.ItunesEpisode != "" ||
item.ItunesEpisodeType != "" ||
item.ItunesExplicit != "" ||
item.ItunesImage.Href != "" ||
item.ItunesSummary != ""
if !hasContent {
return nil
}
ep := &Episode{
Duration: strings.TrimSpace(item.ItunesDuration),
Type: strings.TrimSpace(item.ItunesEpisodeType),
Explicit: isExplicit(item.ItunesExplicit),
Image: strings.TrimSpace(item.ItunesImage.Href),
Summary: strings.TrimSpace(item.ItunesSummary),
}
if s, err := strconv.Atoi(strings.TrimSpace(item.ItunesSeason)); err == nil {
ep.Season = s
}
if n, err := strconv.Atoi(strings.TrimSpace(item.ItunesEpisode)); err == nil {
ep.Number = n
}
return ep
}
// flattenCategories extracts category text values, including nested subcategories.
// Example: <itunes:category text="Technology"><itunes:category text="Podcasting"/></itunes:category>
// produces ["Technology", "Technology > Podcasting"]
func flattenCategories(cats []itunesCategory) []string {
var result []string
for _, c := range cats {
text := strings.TrimSpace(c.Text)
if text == "" {
continue
}
result = append(result, text)
for _, sub := range c.Sub {
subText := strings.TrimSpace(sub.Text)
if subText != "" {
result = append(result, text+" > "+subText)
}
}
}
return result
}
// isExplicit interprets the iTunes explicit flag.
// "yes", "true", "explicit" → true; everything else → false.
func isExplicit(val string) bool {
v := strings.ToLower(strings.TrimSpace(val))
return v == "yes" || v == "true" || v == "explicit"
}
// firstNonEmpty returns the first non-empty string from the arguments.
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
v = strings.TrimSpace(v)
if v != "" {
return v
}
}
return ""
}