From 62a4723c7182b0686a13f49d1cf9c64a30f70887 Mon Sep 17 00:00:00 2001 From: Max Date: Thu, 12 Feb 2026 14:30:18 +0800 Subject: [PATCH] Add RSS and Sitemap support in main.go imports --- main.go | 2 + rss/README.md | 79 +++++++ rss/atom.go | 139 +++++++++++++ rss/atom_test.go | 127 ++++++++++++ rss/build.go | 25 +++ rss/build_atom.go | 156 ++++++++++++++ rss/build_rss.go | 225 ++++++++++++++++++++ rss/build_test.go | 347 +++++++++++++++++++++++++++++++ rss/convert.go | 65 ++++++ rss/discover.go | 166 +++++++++++++++ rss/discover_test.go | 156 ++++++++++++++ rss/fetch.go | 108 ++++++++++ rss/fetch_test.go | 248 ++++++++++++++++++++++ rss/parse.go | 138 +++++++++++++ rss/parse_test.go | 158 ++++++++++++++ rss/process.go | 182 ++++++++++++++++ rss/rss.go | 278 +++++++++++++++++++++++++ rss/rss_test.go | 229 ++++++++++++++++++++ rss/types.go | 91 ++++++++ sitemap/README.md | 120 +++++++++++ sitemap/build.go | 242 ++++++++++++++++++++++ sitemap/build_test.go | 264 +++++++++++++++++++++++ sitemap/convert.go | 112 ++++++++++ sitemap/convert_test.go | 165 +++++++++++++++ sitemap/discover.go | 262 +++++++++++++++++++++++ sitemap/fetch.go | 206 ++++++++++++++++++ sitemap/fetch_test.go | 448 ++++++++++++++++++++++++++++++++++++++++ sitemap/parse.go | 128 ++++++++++++ sitemap/parse_test.go | 174 ++++++++++++++++ sitemap/process.go | 254 +++++++++++++++++++++++ sitemap/robots.go | 37 ++++ sitemap/robots_test.go | 76 +++++++ sitemap/types.go | 188 +++++++++++++++++ 33 files changed, 5595 insertions(+) create mode 100644 rss/README.md create mode 100644 rss/atom.go create mode 100644 rss/atom_test.go create mode 100644 rss/build.go create mode 100644 rss/build_atom.go create mode 100644 rss/build_rss.go create mode 100644 rss/build_test.go create mode 100644 rss/convert.go create mode 100644 rss/discover.go create mode 100644 rss/discover_test.go create mode 100644 rss/fetch.go create mode 100644 rss/fetch_test.go create mode 100644 rss/parse.go create mode 100644 rss/parse_test.go create mode 100644 rss/process.go create mode 100644 rss/rss.go create mode 100644 rss/rss_test.go create mode 100644 rss/types.go create mode 100644 sitemap/README.md create mode 100644 sitemap/build.go create mode 100644 sitemap/build_test.go create mode 100644 sitemap/convert.go create mode 100644 sitemap/convert_test.go create mode 100644 sitemap/discover.go create mode 100644 sitemap/fetch.go create mode 100644 sitemap/fetch_test.go create mode 100644 sitemap/parse.go create mode 100644 sitemap/parse_test.go create mode 100644 sitemap/process.go create mode 100644 sitemap/robots.go create mode 100644 sitemap/robots_test.go create mode 100644 sitemap/types.go diff --git a/main.go b/main.go index 9ada21db..be30c5ed 100644 --- a/main.go +++ b/main.go @@ -9,7 +9,9 @@ import ( _ "github.com/yaoapp/yao/excel" _ "github.com/yaoapp/yao/helper" _ "github.com/yaoapp/yao/openai" + _ "github.com/yaoapp/yao/rss" _ "github.com/yaoapp/yao/seed" + _ "github.com/yaoapp/yao/sitemap" _ "github.com/yaoapp/yao/trace/jsapi" _ "github.com/yaoapp/yao/wework" diff --git a/rss/README.md b/rss/README.md new file mode 100644 index 00000000..3e32ab85 --- /dev/null +++ b/rss/README.md @@ -0,0 +1,79 @@ +# rss + +Parse, validate, discover, fetch, and build RSS 2.0 / Atom 1.0 feeds. Includes iTunes/Podcast extension support. + +## Processes + +### rss.Parse + +Parse an RSS/Atom XML string into a Feed object. Auto-detects format. + +```javascript +var feed = Process("rss.Parse", xmlString); +// feed.format → "rss2.0" or "atom1.0" +// feed.title → "My Blog" +// feed.items → [{title, link, description, content, author, published, ...}] +// feed.podcast → {author, summary, image, ...} (nil for non-podcast feeds) +``` + +### rss.Validate + +Check if a string is valid RSS/Atom XML. Returns `true` on success, or an error description string. + +```javascript +var result = Process("rss.Validate", xmlString); +if (result !== true) { + console.log("Invalid: " + result); +} +``` + +### rss.Fetch + +Fetch a remote feed by URL. Supports gzip and conditional requests (ETag / Last-Modified). + +```javascript +// First fetch +var result = Process("rss.Fetch", "https://example.com/feed.xml"); +// result.feed → parsed Feed object +// result.status_code → 200 +// result.etag → "abc123" +// result.last_modified → "Wed, 01 Jan 2025 00:00:00 GMT" + +// Conditional polling (saves bandwidth) +var result2 = Process("rss.Fetch", "https://example.com/feed.xml", { + etag: result.etag, + last_modified: result.last_modified, +}); +// result2.not_modified → true (when 304) +// result2.feed → nil (when 304) +``` + +**Options** (second argument, optional): + +| Field | Type | Default | Description | +| -------------- | ------ | ---------------- | ------------------------------- | +| user_agent | string | "Yao-Robot/1.0" | Custom User-Agent | +| timeout | int | 30 | Request timeout in seconds | +| etag | string | | ETag for If-None-Match | +| last_modified | string | | Value for If-Modified-Since | + +### rss.Discover + +Extract feed URLs from HTML, Markdown, or plain text. No HTTP requests. + +```javascript +var links = Process("rss.Discover", htmlString); +// links → [{url: "https://example.com/feed.xml", title: "Blog", type: "rss"}] +``` + +### rss.Build + +Generate RSS or Atom XML from a Feed object. + +```javascript +// Build RSS 2.0 (default) +var xml = Process("rss.Build", feedObj); + +// Build Atom 1.0 +var xml = Process("rss.Build", feedObj, "atom"); +``` diff --git a/rss/atom.go b/rss/atom.go new file mode 100644 index 00000000..afacdb07 --- /dev/null +++ b/rss/atom.go @@ -0,0 +1,139 @@ +package rss + +import ( + "encoding/xml" + "strings" +) + +// --- Internal XML mapping structs for Atom 1.0 --- + +type atomFeed struct { + XMLName xml.Name `xml:"feed"` + Title string `xml:"title"` + Subtitle string `xml:"subtitle"` + Links []atomLink `xml:"link"` + Updated string `xml:"updated"` + Language string `xml:"http://www.w3.org/XML/1998/namespace lang,attr"` + Entries []atomEntry `xml:"entry"` +} + +type atomLink struct { + Href string `xml:"href,attr"` + Rel string `xml:"rel,attr"` + Type string `xml:"type,attr"` +} + +type atomEntry struct { + Title string `xml:"title"` + Links []atomLink `xml:"link"` + Summary string `xml:"summary"` + Content atomContent `xml:"content"` + Authors []atomPerson `xml:"author"` + Published string `xml:"published"` + Updated string `xml:"updated"` + ID string `xml:"id"` + Categories []atomCategory `xml:"category"` +} + +type atomContent struct { + Type string `xml:"type,attr"` + Value string `xml:",chardata"` +} + +type atomPerson struct { + Name string `xml:"name"` + Email string `xml:"email"` +} + +type atomCategory struct { + Term string `xml:"term,attr"` + Label string `xml:"label,attr"` +} + +// parseAtom parses an Atom 1.0 XML document into a Feed struct. +func parseAtom(data []byte) (*Feed, error) { + var doc atomFeed + if err := xml.Unmarshal(data, &doc); err != nil { + return nil, err + } + + feed := &Feed{ + Format: "atom1.0", + Title: strings.TrimSpace(doc.Title), + Description: strings.TrimSpace(doc.Subtitle), + Language: strings.TrimSpace(doc.Language), + Updated: strings.TrimSpace(doc.Updated), + Items: make([]FeedItem, 0, len(doc.Entries)), + } + + // Extract primary link: prefer rel="alternate", fallback to first link + feed.Link = extractAtomLink(doc.Links) + + for i := range doc.Entries { + feed.Items = append(feed.Items, convertAtomEntry(&doc.Entries[i])) + } + + return feed, nil +} + +// convertAtomEntry converts an internal atomEntry to a public FeedItem. +func convertAtomEntry(entry *atomEntry) FeedItem { + fi := FeedItem{ + Title: strings.TrimSpace(entry.Title), + Link: extractAtomLink(entry.Links), + Published: strings.TrimSpace(entry.Published), + Updated: strings.TrimSpace(entry.Updated), + GUID: strings.TrimSpace(entry.ID), + } + + // Summary and content + fi.Description = strings.TrimSpace(entry.Summary) + fi.Content = strings.TrimSpace(entry.Content.Value) + + // Author: join multiple authors with ", " + if len(entry.Authors) > 0 { + names := make([]string, 0, len(entry.Authors)) + for _, a := range entry.Authors { + n := strings.TrimSpace(a.Name) + if n != "" { + names = append(names, n) + } + } + fi.Author = strings.Join(names, ", ") + } + + // Categories: prefer label, fallback to term + if len(entry.Categories) > 0 { + fi.Categories = make([]string, 0, len(entry.Categories)) + for _, c := range entry.Categories { + v := strings.TrimSpace(c.Label) + if v == "" { + v = strings.TrimSpace(c.Term) + } + if v != "" { + fi.Categories = append(fi.Categories, v) + } + } + } + + return fi +} + +// extractAtomLink returns the href of the first "alternate" link, +// or the first link if no alternate is found. +func extractAtomLink(links []atomLink) string { + var fallback string + for _, l := range links { + href := strings.TrimSpace(l.Href) + if href == "" { + continue + } + if l.Rel == "alternate" || l.Rel == "" { + return href + } + if fallback == "" { + fallback = href + } + } + return fallback +} diff --git a/rss/atom_test.go b/rss/atom_test.go new file mode 100644 index 00000000..9c211417 --- /dev/null +++ b/rss/atom_test.go @@ -0,0 +1,127 @@ +package rss + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const testAtom = ` + + Example Atom Feed + An example Atom feed for testing + + + 2024-01-15T10:30:00Z + + First Entry + + urn:uuid:entry-1 + 2024-01-14T08:00:00Z + 2024-01-14T10:00:00Z + Summary of the first entry + Full HTML content of entry 1 + + Alice + alice@example.com + + + + + + Second Entry + + urn:uuid:entry-2 + 2024-01-15T10:30:00Z + + Bob + + + Charlie + + +` + +func TestParseAtom_Basic(t *testing.T) { + feed, err := parseAtom([]byte(testAtom)) + require.NoError(t, err) + + assert.Equal(t, "atom1.0", feed.Format) + assert.Equal(t, "Example Atom Feed", feed.Title) + assert.Equal(t, "https://example.com", feed.Link) // rel="alternate" preferred + assert.Equal(t, "An example Atom feed for testing", feed.Description) + assert.Equal(t, "en", feed.Language) + assert.Equal(t, "2024-01-15T10:30:00Z", feed.Updated) + assert.Nil(t, feed.Podcast) + + require.Len(t, feed.Items, 2) + + // First entry + e0 := feed.Items[0] + assert.Equal(t, "First Entry", e0.Title) + assert.Equal(t, "https://example.com/entry-1", e0.Link) + assert.Equal(t, "urn:uuid:entry-1", e0.GUID) + assert.Equal(t, "2024-01-14T08:00:00Z", e0.Published) + assert.Equal(t, "2024-01-14T10:00:00Z", e0.Updated) + assert.Equal(t, "Summary of the first entry", e0.Description) + assert.Equal(t, "Full HTML content of entry 1", e0.Content) + assert.Equal(t, "Alice", e0.Author) + require.Len(t, e0.Categories, 2) + assert.Equal(t, "Technology", e0.Categories[0]) // label preferred + assert.Equal(t, "go", e0.Categories[1]) // fallback to term + + // Second entry — multiple authors + e1 := feed.Items[1] + assert.Equal(t, "Second Entry", e1.Title) + assert.Equal(t, "Bob, Charlie", e1.Author) // joined + assert.Empty(t, e1.Published) +} + +func TestParseAtom_MinimalFeed(t *testing.T) { + xml := ` + + Minimal Atom +` + + feed, err := parseAtom([]byte(xml)) + require.NoError(t, err) + assert.Equal(t, "atom1.0", feed.Format) + assert.Equal(t, "Minimal Atom", feed.Title) + assert.Empty(t, feed.Link) + assert.Empty(t, feed.Items) +} + +func TestParseAtom_InvalidXML(t *testing.T) { + _, err := parseAtom([]byte(`broken`)) + assert.Error(t, err) +} + +func TestExtractAtomLink(t *testing.T) { + links := []atomLink{ + {Href: "https://example.com/self", Rel: "self"}, + {Href: "https://example.com", Rel: "alternate"}, + {Href: "https://example.com/other", Rel: "related"}, + } + assert.Equal(t, "https://example.com", extractAtomLink(links)) +} + +func TestExtractAtomLink_NoAlternate(t *testing.T) { + links := []atomLink{ + {Href: "https://example.com/self", Rel: "self"}, + } + assert.Equal(t, "https://example.com/self", extractAtomLink(links)) +} + +func TestExtractAtomLink_EmptyRel(t *testing.T) { + // Empty rel should be treated as "alternate" + links := []atomLink{ + {Href: "https://example.com", Rel: ""}, + } + assert.Equal(t, "https://example.com", extractAtomLink(links)) +} + +func TestExtractAtomLink_Empty(t *testing.T) { + assert.Equal(t, "", extractAtomLink(nil)) + assert.Equal(t, "", extractAtomLink([]atomLink{})) +} diff --git a/rss/build.go b/rss/build.go new file mode 100644 index 00000000..1b5889a6 --- /dev/null +++ b/rss/build.go @@ -0,0 +1,25 @@ +package rss + +import "fmt" + +// Build generates an XML feed document from a Feed struct. +// The format parameter specifies the output format: "rss" (default) or "atom". +// If format is empty, RSS 2.0 is used. +// +// When the Feed contains Podcast metadata, the RSS 2.0 output will include +// iTunes namespace extensions automatically. Atom output ignores Podcast +// extensions as they are RSS-specific. +func Build(feed *Feed, format string) (string, error) { + if feed == nil { + return "", fmt.Errorf("feed is nil") + } + + switch format { + case "", "rss", "rss2.0": + return buildRSSXML(feed) + case "atom", "atom1.0": + return buildAtomXML(feed) + default: + return "", fmt.Errorf("unsupported output format: %q, expected \"rss\" or \"atom\"", format) + } +} diff --git a/rss/build_atom.go b/rss/build_atom.go new file mode 100644 index 00000000..d78a1251 --- /dev/null +++ b/rss/build_atom.go @@ -0,0 +1,156 @@ +package rss + +import ( + "encoding/xml" + "strings" + "time" +) + +// Atom namespace +const atomNS = "http://www.w3.org/2005/Atom" + +// buildAtomXML generates an Atom 1.0 XML document from a Feed struct. +// Podcast/iTunes extensions are not included in Atom output (they are RSS-specific). +func buildAtomXML(feed *Feed) (string, error) { + doc := atomBuildFeed{ + NS: atomNS, + } + + doc.Title = feed.Title + if feed.Description != "" { + doc.Subtitle = feed.Description + } + if feed.Language != "" { + doc.Lang = feed.Language + } + + // Links + if feed.Link != "" { + doc.Links = append(doc.Links, atomBuildLink{ + Href: feed.Link, + Rel: "alternate", + Type: "text/html", + }) + } + + // Updated + if feed.Updated != "" { + doc.Updated = feed.Updated + } else { + doc.Updated = time.Now().UTC().Format(time.RFC3339) + } + + // Entries + for _, item := range feed.Items { + entry := atomBuildEntry{ + Title: item.Title, + ID: item.GUID, + } + + if entry.ID == "" { + entry.ID = item.Link + } + + if item.Link != "" { + entry.Links = append(entry.Links, atomBuildLink{ + Href: item.Link, + Rel: "alternate", + Type: "text/html", + }) + } + + if item.Description != "" { + entry.Summary = &atomBuildText{ + Type: "html", + Value: item.Description, + } + } + + if item.Content != "" { + entry.Content = &atomBuildText{ + Type: "html", + Value: item.Content, + } + } + + // Authors + if item.Author != "" { + // Split on ", " to handle multiple authors + names := strings.Split(item.Author, ", ") + for _, name := range names { + name = strings.TrimSpace(name) + if name != "" { + entry.Authors = append(entry.Authors, atomBuildPerson{Name: name}) + } + } + } + + entry.Published = item.Published + entry.Updated = item.Updated + if entry.Updated == "" { + entry.Updated = item.Published + } + + // Categories + for _, cat := range item.Categories { + entry.Categories = append(entry.Categories, atomBuildCategory{Term: cat, Label: cat}) + } + + doc.Entries = append(doc.Entries, entry) + } + + output, err := xml.MarshalIndent(doc, "", " ") + if err != nil { + return "", err + } + + return xml.Header + string(output), nil +} + +// --- Build XML structs for Atom 1.0 output --- + +type atomBuildFeed struct { + XMLName xml.Name `xml:"feed"` + NS string `xml:"xmlns,attr"` + Lang string `xml:"xml:lang,attr,omitempty"` + Title string `xml:"title"` + Subtitle string `xml:"subtitle,omitempty"` + Links []atomBuildLink `xml:"link"` + Updated string `xml:"updated"` + Entries []atomBuildEntry `xml:"entry"` +} + +type atomBuildLink struct { + XMLName xml.Name `xml:"link"` + Href string `xml:"href,attr"` + Rel string `xml:"rel,attr,omitempty"` + Type string `xml:"type,attr,omitempty"` +} + +type atomBuildEntry struct { + Title string `xml:"title"` + Links []atomBuildLink `xml:"link"` + ID string `xml:"id"` + Published string `xml:"published,omitempty"` + Updated string `xml:"updated,omitempty"` + Summary *atomBuildText `xml:"summary,omitempty"` + Content *atomBuildText `xml:"content,omitempty"` + Authors []atomBuildPerson `xml:"author,omitempty"` + Categories []atomBuildCategory `xml:"category,omitempty"` +} + +type atomBuildText struct { + Type string `xml:"type,attr,omitempty"` + Value string `xml:",chardata"` +} + +type atomBuildPerson struct { + XMLName xml.Name `xml:"author"` + Name string `xml:"name"` +} + +type atomBuildCategory struct { + XMLName xml.Name `xml:"category"` + Term string `xml:"term,attr"` + Label string `xml:"label,attr,omitempty"` +} diff --git a/rss/build_rss.go b/rss/build_rss.go new file mode 100644 index 00000000..2ec23330 --- /dev/null +++ b/rss/build_rss.go @@ -0,0 +1,225 @@ +package rss + +import ( + "encoding/xml" + "strings" + "time" +) + +// buildRSSXML generates an RSS 2.0 XML document from a Feed struct. +// If the Feed contains Podcast metadata, iTunes namespace extensions are included. +func buildRSSXML(feed *Feed) (string, error) { + doc := rssBuildDoc{ + Version: "2.0", + } + + // Add namespace declarations based on content + doc.ContentNS = contentNS + hasPodcast := feed.Podcast != nil + if hasPodcast { + doc.ItunesNS = itunesNS + } + + ch := &doc.Channel + ch.Title = feed.Title + ch.Link = feed.Link + ch.Description = feed.Description + ch.Language = feed.Language + + if feed.Updated != "" { + ch.LastBuild = feed.Updated + } else { + ch.LastBuild = time.Now().UTC().Format(time.RFC1123Z) + } + + // Podcast channel-level metadata + if hasPodcast { + p := feed.Podcast + ch.ItunesAuthor = p.Author + ch.ItunesSummary = p.Summary + if p.Image != "" { + ch.ItunesImage = &rssBuildItunesImage{Href: p.Image} + } + if p.Owner != nil { + ch.ItunesOwner = &rssBuildItunesOwner{ + Name: p.Owner.Name, + Email: p.Owner.Email, + } + } + for _, cat := range p.Category { + // Handle "Parent > Child" format + parts := strings.SplitN(cat, " > ", 2) + if len(parts) == 2 { + // This is a subcategory — skip, it will be included under its parent + continue + } + bc := rssBuildItunesCategory{Text: cat} + // Check if there are subcategories + for _, sub := range p.Category { + subParts := strings.SplitN(sub, " > ", 2) + if len(subParts) == 2 && subParts[0] == cat { + bc.Sub = append(bc.Sub, rssBuildItunesCategory{Text: subParts[1]}) + } + } + ch.ItunesCategory = append(ch.ItunesCategory, bc) + } + if p.Explicit { + ch.ItunesExplicit = "yes" + } else if p.Author != "" || p.Summary != "" { + // Only include explicit=no if podcast metadata is present + ch.ItunesExplicit = "no" + } + ch.ItunesType = p.Type + } + + // Items + for _, item := range feed.Items { + ri := rssBuildItem{ + Title: item.Title, + Link: item.Link, + Description: item.Description, + Author: item.Author, + PubDate: item.Published, + GUID: item.GUID, + } + + if item.Content != "" { + ri.Content = &rssBuildCDATA{Value: item.Content} + } + + for _, cat := range item.Categories { + ri.Categories = append(ri.Categories, cat) + } + + for _, enc := range item.Enclosures { + ri.Enclosures = append(ri.Enclosures, rssBuildEnclosure{ + URL: enc.URL, + Type: enc.Type, + Length: enc.Length, + }) + } + + // Podcast episode metadata + if item.Episode != nil { + ep := item.Episode + ri.ItunesDuration = ep.Duration + if ep.Season > 0 { + ri.ItunesSeason = intToStr(ep.Season) + } + if ep.Number > 0 { + ri.ItunesEpisode = intToStr(ep.Number) + } + ri.ItunesEpisodeType = ep.Type + if ep.Explicit { + ri.ItunesExplicit = "yes" + } else if ep.Duration != "" { + ri.ItunesExplicit = "no" + } + if ep.Image != "" { + ri.ItunesImage = &rssBuildItunesImage{Href: ep.Image} + } + ri.ItunesSummary = ep.Summary + } + + ch.Items = append(ch.Items, ri) + } + + output, err := xml.MarshalIndent(doc, "", " ") + if err != nil { + return "", err + } + + return xml.Header + string(output), nil +} + +// intToStr converts an int to string without importing strconv (to avoid duplication). +func intToStr(n int) string { + if n == 0 { + return "" + } + // Simple int to string for small positive numbers + s := "" + for n > 0 { + s = string(rune('0'+n%10)) + s + n /= 10 + } + return s +} + +// --- Build XML structs for RSS 2.0 output --- + +type rssBuildDoc struct { + XMLName xml.Name `xml:"rss"` + Version string `xml:"version,attr"` + ContentNS string `xml:"xmlns:content,attr,omitempty"` + ItunesNS string `xml:"xmlns:itunes,attr,omitempty"` + Channel rssBuildChannel `xml:"channel"` +} + +type rssBuildChannel struct { + Title string `xml:"title"` + Link string `xml:"link"` + Description string `xml:"description"` + Language string `xml:"language,omitempty"` + LastBuild string `xml:"lastBuildDate,omitempty"` + + // iTunes namespace + ItunesAuthor string `xml:"itunes:author,omitempty"` + ItunesSummary string `xml:"itunes:summary,omitempty"` + ItunesImage *rssBuildItunesImage `xml:"itunes:image,omitempty"` + ItunesOwner *rssBuildItunesOwner `xml:"itunes:owner,omitempty"` + ItunesCategory []rssBuildItunesCategory `xml:"itunes:category,omitempty"` + ItunesExplicit string `xml:"itunes:explicit,omitempty"` + ItunesType string `xml:"itunes:type,omitempty"` + + Items []rssBuildItem `xml:"item"` +} + +type rssBuildItem struct { + Title string `xml:"title"` + Link string `xml:"link,omitempty"` + Description string `xml:"description,omitempty"` + Content *rssBuildCDATA `xml:"content:encoded,omitempty"` + Author string `xml:"author,omitempty"` + PubDate string `xml:"pubDate,omitempty"` + GUID string `xml:"guid,omitempty"` + Categories []string `xml:"category,omitempty"` + Enclosures []rssBuildEnclosure `xml:"enclosure,omitempty"` + + // iTunes namespace + ItunesDuration string `xml:"itunes:duration,omitempty"` + ItunesSeason string `xml:"itunes:season,omitempty"` + ItunesEpisode string `xml:"itunes:episode,omitempty"` + ItunesEpisodeType string `xml:"itunes:episodeType,omitempty"` + ItunesExplicit string `xml:"itunes:explicit,omitempty"` + ItunesImage *rssBuildItunesImage `xml:"itunes:image,omitempty"` + ItunesSummary string `xml:"itunes:summary,omitempty"` +} + +type rssBuildCDATA struct { + Value string `xml:",cdata"` +} + +type rssBuildEnclosure struct { + XMLName xml.Name `xml:"enclosure"` + URL string `xml:"url,attr"` + Type string `xml:"type,attr,omitempty"` + Length string `xml:"length,attr,omitempty"` +} + +type rssBuildItunesImage struct { + XMLName xml.Name `xml:"itunes:image"` + Href string `xml:"href,attr"` +} + +type rssBuildItunesOwner struct { + XMLName xml.Name `xml:"itunes:owner"` + Name string `xml:"itunes:name,omitempty"` + Email string `xml:"itunes:email,omitempty"` +} + +type rssBuildItunesCategory struct { + XMLName xml.Name `xml:"itunes:category"` + Text string `xml:"text,attr"` + Sub []rssBuildItunesCategory `xml:"itunes:category,omitempty"` +} diff --git a/rss/build_test.go b/rss/build_test.go new file mode 100644 index 00000000..27a947f4 --- /dev/null +++ b/rss/build_test.go @@ -0,0 +1,347 @@ +package rss + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Helper feed for build tests +func newTestFeed() *Feed { + return &Feed{ + Format: "rss2.0", + Title: "Test Blog", + Link: "https://example.com", + Description: "A test blog", + Language: "en", + Updated: "Mon, 01 Jan 2024 00:00:00 +0000", + Items: []FeedItem{ + { + Title: "First Post", + Link: "https://example.com/post-1", + Description: "Summary of first post", + Content: "<p>Full content</p>", + Author: "Alice", + Published: "Sun, 31 Dec 2023 12:00:00 +0000", + GUID: "https://example.com/post-1", + Categories: []string{"Tech", "Go"}, + Enclosures: []Enclosure{ + {URL: "https://example.com/audio.mp3", Type: "audio/mpeg", Length: "12345678"}, + }, + }, + { + Title: "Second Post", + Link: "https://example.com/post-2", + Published: "Mon, 01 Jan 2024 00:00:00 +0000", + GUID: "https://example.com/post-2", + }, + }, + } +} + +func newTestPodcastFeed() *Feed { + feed := &Feed{ + Format: "rss2.0", + Title: "My Podcast", + Link: "https://podcast.example.com", + Description: "A tech podcast", + Language: "en", + Updated: "Wed, 15 Nov 2023 08:00:00 +0000", + Podcast: &Podcast{ + Author: "Jane Doe", + Summary: "Weekly tech discussions", + Image: "https://podcast.example.com/cover.jpg", + Explicit: false, + Type: "episodic", + Owner: &Owner{Name: "Jane Doe", Email: "jane@example.com"}, + Category: []string{"Technology", "Technology > Podcasting", "Education"}, + }, + Items: []FeedItem{ + { + Title: "Episode 1", + Link: "https://podcast.example.com/ep1", + Description: "Our first episode", + Published: "Wed, 15 Nov 2023 08:00:00 +0000", + GUID: "https://podcast.example.com/ep1", + Enclosures: []Enclosure{ + {URL: "https://podcast.example.com/ep1.mp3", Type: "audio/mpeg", Length: "50000000"}, + }, + Episode: &Episode{ + Duration: "01:23:45", + Season: 1, + Number: 1, + Type: "full", + Explicit: false, + Image: "https://podcast.example.com/ep1-cover.jpg", + Summary: "Getting started with podcasting", + }, + }, + }, + } + return feed +} + +// --- RSS Build tests --- + +func TestBuild_RSS_Basic(t *testing.T) { + feed := newTestFeed() + xml, err := Build(feed, "rss") + require.NoError(t, err) + + assert.Contains(t, xml, `<?xml version="1.0" encoding="UTF-8"?>`) + assert.Contains(t, xml, `<rss version="2.0"`) + assert.Contains(t, xml, `<title>Test Blog`) + assert.Contains(t, xml, `https://example.com`) + assert.Contains(t, xml, `A test blog`) + assert.Contains(t, xml, `en`) + assert.Contains(t, xml, `First Post`) + assert.Contains(t, xml, `Second Post`) + assert.Contains(t, xml, `Alice`) + assert.Contains(t, xml, `Tech`) + assert.Contains(t, xml, `Go`) + assert.Contains(t, xml, `url="https://example.com/audio.mp3"`) + assert.Contains(t, xml, `type="audio/mpeg"`) + + // Should NOT contain itunes namespace since no podcast data + assert.NotContains(t, xml, "itunes") +} + +func TestBuild_RSS_DefaultFormat(t *testing.T) { + feed := newTestFeed() + xml, err := Build(feed, "") + require.NoError(t, err) + assert.Contains(t, xml, `Jane Doe`) + assert.Contains(t, xml, `Weekly tech discussions`) + assert.Contains(t, xml, `href="https://podcast.example.com/cover.jpg"`) + assert.Contains(t, xml, `Jane Doe`) + assert.Contains(t, xml, `jane@example.com`) + assert.Contains(t, xml, `no`) + assert.Contains(t, xml, `episodic`) + + // Categories + assert.Contains(t, xml, `text="Technology"`) + assert.Contains(t, xml, `text="Podcasting"`) + assert.Contains(t, xml, `text="Education"`) + + // Episode metadata + assert.Contains(t, xml, `01:23:45`) + assert.Contains(t, xml, `1`) + assert.Contains(t, xml, `1`) + assert.Contains(t, xml, `full`) +} + +func TestBuild_RSS_ContentEncoded(t *testing.T) { + feed := newTestFeed() + xml, err := Build(feed, "rss") + require.NoError(t, err) + + assert.Contains(t, xml, `xmlns:content=`) + assert.Contains(t, xml, ``) + assert.Contains(t, xml, `

Full content

`) +} + +// --- Atom Build tests --- + +func TestBuild_Atom_Basic(t *testing.T) { + feed := newTestFeed() + xml, err := Build(feed, "atom") + require.NoError(t, err) + + assert.Contains(t, xml, ``) + assert.Contains(t, xml, `Test Blog`) + assert.Contains(t, xml, `A test blog`) + assert.Contains(t, xml, `href="https://example.com"`) + assert.Contains(t, xml, `First Post`) + assert.Contains(t, xml, `Alice`) + assert.Contains(t, xml, `term="Tech"`) + assert.Contains(t, xml, `term="Go"`) + + // Atom should NOT have iTunes namespace + assert.NotContains(t, xml, "itunes") +} + +func TestBuild_Atom_PodcastIgnored(t *testing.T) { + feed := newTestPodcastFeed() + xml, err := Build(feed, "atom") + require.NoError(t, err) + + // Podcast metadata should be ignored in Atom output + assert.NotContains(t, xml, "itunes") + assert.Contains(t, xml, `My Podcast`) +} + +func TestBuild_Atom_MultipleAuthors(t *testing.T) { + feed := &Feed{ + Title: "Multi Author", + Items: []FeedItem{ + { + Title: "Post", + Author: "Alice, Bob", + GUID: "1", + }, + }, + } + xml, err := Build(feed, "atom") + require.NoError(t, err) + assert.Contains(t, xml, `Alice`) + assert.Contains(t, xml, `Bob`) +} + +// --- Round-trip test --- + +func TestBuild_RoundTrip_RSS(t *testing.T) { + // Parse → Build → Parse and verify consistency + feed1, err := Parse(testRSS) + require.NoError(t, err) + + xmlOut, err := Build(feed1, "rss") + require.NoError(t, err) + + feed2, err := Parse(xmlOut) + require.NoError(t, err) + + assert.Equal(t, feed1.Title, feed2.Title) + assert.Equal(t, feed1.Link, feed2.Link) + assert.Equal(t, feed1.Description, feed2.Description) + assert.Len(t, feed2.Items, len(feed1.Items)) + assert.Equal(t, feed1.Items[0].Title, feed2.Items[0].Title) +} + +func TestBuild_RoundTrip_Podcast(t *testing.T) { + feed1, err := Parse(testPodcast) + require.NoError(t, err) + + xmlOut, err := Build(feed1, "rss") + require.NoError(t, err) + + feed2, err := Parse(xmlOut) + require.NoError(t, err) + + assert.Equal(t, feed1.Title, feed2.Title) + require.NotNil(t, feed2.Podcast) + assert.Equal(t, feed1.Podcast.Author, feed2.Podcast.Author) + assert.Equal(t, feed1.Podcast.Summary, feed2.Podcast.Summary) + assert.Len(t, feed2.Items, len(feed1.Items)) + + require.NotNil(t, feed2.Items[0].Episode) + assert.Equal(t, feed1.Items[0].Episode.Duration, feed2.Items[0].Episode.Duration) +} + +func TestBuild_RoundTrip_Atom(t *testing.T) { + feed1, err := Parse(testAtom) + require.NoError(t, err) + + xmlOut, err := Build(feed1, "atom") + require.NoError(t, err) + + feed2, err := Parse(xmlOut) + require.NoError(t, err) + + assert.Equal(t, feed1.Title, feed2.Title) + assert.Len(t, feed2.Items, len(feed1.Items)) + assert.Equal(t, feed1.Items[0].Title, feed2.Items[0].Title) +} + +// --- Edge cases --- + +func TestBuild_NilFeed(t *testing.T) { + _, err := Build(nil, "rss") + assert.Error(t, err) + assert.Contains(t, err.Error(), "nil") +} + +func TestBuild_UnsupportedFormat(t *testing.T) { + feed := newTestFeed() + _, err := Build(feed, "json") + assert.Error(t, err) + assert.Contains(t, err.Error(), "unsupported") +} + +func TestBuild_EmptyFeed(t *testing.T) { + feed := &Feed{Title: "Empty"} + xml, err := Build(feed, "rss") + require.NoError(t, err) + assert.Contains(t, xml, `Empty`) + assert.NotContains(t, xml, "") +} + +// --- mapToFeed tests --- + +func TestMapToFeed_DirectFeed(t *testing.T) { + original := newTestFeed() + result, err := mapToFeed(original) + require.NoError(t, err) + assert.Equal(t, original, result) +} + +func TestMapToFeed_FromMap(t *testing.T) { + m := map[string]interface{}{ + "title": "From Map", + "link": "https://example.com", + "description": "Converted from map", + "items": []interface{}{ + map[string]interface{}{ + "title": "Item 1", + "link": "https://example.com/1", + }, + }, + } + feed, err := mapToFeed(m) + require.NoError(t, err) + assert.Equal(t, "From Map", feed.Title) + require.Len(t, feed.Items, 1) + assert.Equal(t, "Item 1", feed.Items[0].Title) +} + +func TestMapToFeed_Nil(t *testing.T) { + _, err := mapToFeed(nil) + assert.Error(t, err) +} + +// --- intToStr test --- + +func TestIntToStr(t *testing.T) { + assert.Equal(t, "1", intToStr(1)) + assert.Equal(t, "42", intToStr(42)) + assert.Equal(t, "123", intToStr(123)) + assert.Equal(t, "", intToStr(0)) +} + +// --- Build output well-formedness --- + +func TestBuild_RSS_WellFormedXML(t *testing.T) { + feed := newTestPodcastFeed() + xmlStr, err := Build(feed, "rss") + require.NoError(t, err) + + // Should start with XML declaration + assert.True(t, strings.HasPrefix(xmlStr, " tags with RSS/Atom type + // Matches + // Handles attributes in any order, single or double quotes, and self-closing tags. + reLinkTag = regexp.MustCompile( + `(?i)]*\btype\s*=\s*["']application/(rss|atom)\+xml["'][^>]*>`, + ) + reLinkHref = regexp.MustCompile(`(?i)\bhref\s*=\s*["']([^"']+)["']`) + reLinkTitle = regexp.MustCompile(`(?i)\btitle\s*=\s*["']([^"']+)["']`) + reLinkType = regexp.MustCompile(`(?i)\btype\s*=\s*["']application/(rss|atom)\+xml["']`) + + // Pattern 2: Markdown links [text](url) + reMarkdownLink = regexp.MustCompile(`\[([^\]]*)\]\((https?://[^)\s]+)\)`) + + // Pattern 3: Bare URLs in text + reURL = regexp.MustCompile(`https?://[^\s<>"'\)\]]+`) +) + +// Discover extracts feed URLs from the given text content. +// The input can be HTML (complete or partial), Markdown, or plain text. +// It uses regex-based detection (not HTML parsing) to handle all input types robustly. +// +// Detection is performed in priority order: +// 1. HTML tags with RSS/Atom type attributes +// 2. Markdown links [text](url) matching feed URL patterns +// 3. Bare URLs matching common feed path/query patterns +// +// Results are deduplicated by URL and ordered by detection priority. +func Discover(text string) []FeedLink { + if strings.TrimSpace(text) == "" { + return nil + } + + seen := make(map[string]bool) + var results []FeedLink + + // Priority 1: HTML tags + linkMatches := reLinkTag.FindAllString(text, -1) + for _, tag := range linkMatches { + href := extractAttr(reLinkHref, tag) + if href == "" { + continue + } + if seen[href] { + continue + } + seen[href] = true + + fl := FeedLink{URL: href} + fl.Title = extractAttr(reLinkTitle, tag) + + typeMatch := reLinkType.FindStringSubmatch(tag) + if len(typeMatch) > 1 { + fl.Type = strings.ToLower(typeMatch[1]) // "rss" or "atom" + } + + results = append(results, fl) + } + + // Priority 2: Markdown links with feed-like URLs + mdMatches := reMarkdownLink.FindAllStringSubmatch(text, -1) + for _, m := range mdMatches { + if len(m) < 3 { + continue + } + title, url := m[1], m[2] + if seen[url] { + continue + } + if !looksLikeFeedURL(url) { + continue + } + seen[url] = true + results = append(results, FeedLink{ + URL: url, + Title: strings.TrimSpace(title), + Type: guessTypeFromURL(url), + }) + } + + // Priority 3: Bare URLs matching feed patterns + urlMatches := reURL.FindAllString(text, -1) + for _, url := range urlMatches { + // Clean trailing punctuation that may be part of surrounding text + url = strings.TrimRight(url, ".,;:!?") + if seen[url] { + continue + } + if !looksLikeFeedURL(url) { + continue + } + seen[url] = true + results = append(results, FeedLink{ + URL: url, + Type: guessTypeFromURL(url), + }) + } + + return results +} + +// looksLikeFeedURL checks whether a URL matches common feed path or query patterns. +func looksLikeFeedURL(url string) bool { + lower := strings.ToLower(url) + + for _, p := range feedPathPatterns { + if strings.Contains(lower, p) { + return true + } + } + + for _, p := range feedQueryPatterns { + if strings.Contains(lower, p) { + return true + } + } + + return false +} + +// guessTypeFromURL attempts to determine the feed type from URL patterns. +// Returns "rss", "atom", or empty string if undetermined. +func guessTypeFromURL(url string) string { + lower := strings.ToLower(url) + + if strings.Contains(lower, "atom") { + return "atom" + } + if strings.Contains(lower, "rss") { + return "rss" + } + + // Generic feed paths — cannot determine type + return "" +} + +// extractAttr extracts the first capture group from a regex match on the input string. +func extractAttr(re *regexp.Regexp, input string) string { + m := re.FindStringSubmatch(input) + if len(m) > 1 { + return strings.TrimSpace(m[1]) + } + return "" +} diff --git a/rss/discover_test.go b/rss/discover_test.go new file mode 100644 index 00000000..1c4204c7 --- /dev/null +++ b/rss/discover_test.go @@ -0,0 +1,156 @@ +package rss + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDiscover_HTMLLinkTags(t *testing.T) { + html := ` + + My Site + + + + +Hello +` + + links := Discover(html) + require.Len(t, links, 2) + + assert.Equal(t, "https://example.com/feed.xml", links[0].URL) + assert.Equal(t, "RSS Feed", links[0].Title) + assert.Equal(t, "rss", links[0].Type) + + assert.Equal(t, "https://example.com/atom.xml", links[1].URL) + assert.Equal(t, "Atom Feed", links[1].Title) + assert.Equal(t, "atom", links[1].Type) +} + +func TestDiscover_HTMLLinkTags_AttributeOrder(t *testing.T) { + // Attributes in different order, single quotes + html := `` + links := Discover(html) + require.Len(t, links, 1) + assert.Equal(t, "https://blog.example.com/rss", links[0].URL) + assert.Equal(t, "Blog", links[0].Title) + assert.Equal(t, "rss", links[0].Type) +} + +func TestDiscover_MarkdownLinks(t *testing.T) { + md := `# My Bookmarks + +Here are some feeds: +- [Tech News](https://news.example.com/rss.xml) +- [Go Blog](https://go.dev/blog/feed.atom) +- [Not a feed](https://example.com/about) +` + links := Discover(md) + require.Len(t, links, 2) + + assert.Equal(t, "https://news.example.com/rss.xml", links[0].URL) + assert.Equal(t, "Tech News", links[0].Title) + assert.Equal(t, "rss", links[0].Type) + + assert.Equal(t, "https://go.dev/blog/feed.atom", links[1].URL) + assert.Equal(t, "Go Blog", links[1].Title) + assert.Equal(t, "atom", links[1].Type) +} + +func TestDiscover_BareURLs(t *testing.T) { + text := `Check out these feeds: +https://example.com/feed.xml +https://blog.example.com/rss +https://news.example.com/atom.xml +https://example.com/about (not a feed) +` + links := Discover(text) + require.Len(t, links, 3) + + assert.Equal(t, "https://example.com/feed.xml", links[0].URL) + assert.Equal(t, "https://blog.example.com/rss", links[1].URL) + assert.Equal(t, "https://news.example.com/atom.xml", links[2].URL) +} + +func TestDiscover_BareURLs_QueryParams(t *testing.T) { + text := `Feed URL: https://example.com/api?feed=rss&lang=en` + links := Discover(text) + require.Len(t, links, 1) + assert.Equal(t, "https://example.com/api?feed=rss&lang=en", links[0].URL) +} + +func TestDiscover_Deduplication(t *testing.T) { + // Same URL appears in HTML link tag and as bare URL + text := ` +Check out: https://example.com/feed.xml` + + links := Discover(text) + require.Len(t, links, 1) // deduplicated + assert.Equal(t, "https://example.com/feed.xml", links[0].URL) + assert.Equal(t, "Feed", links[0].Title) // from HTML tag (higher priority) +} + +func TestDiscover_PartialHTML(t *testing.T) { + // Incomplete HTML fragment + fragment := `
Some content
+ +

More broken` + + links := Discover(fragment) + require.Len(t, links, 1) + assert.Equal(t, "https://example.com/feed", links[0].URL) +} + +func TestDiscover_Empty(t *testing.T) { + assert.Nil(t, Discover("")) + assert.Nil(t, Discover(" ")) +} + +func TestDiscover_NoFeeds(t *testing.T) { + links := Discover("Hello world! Visit https://example.com for more info.") + assert.Empty(t, links) +} + +func TestDiscover_MixedContent(t *testing.T) { + // HTML link + Markdown link + bare URL, all different + mixed := ` +Some text with [B Feed](https://b.com/feed.xml) and also +https://c.com/rss.xml is available.` + + links := Discover(mixed) + require.Len(t, links, 3) + assert.Equal(t, "https://a.com/atom.xml", links[0].URL) + assert.Equal(t, "atom", links[0].Type) + assert.Equal(t, "https://b.com/feed.xml", links[1].URL) + assert.Equal(t, "https://c.com/rss.xml", links[2].URL) + assert.Equal(t, "rss", links[2].Type) +} + +func TestDiscover_TrailingPunctuation(t *testing.T) { + text := `Check https://example.com/feed.xml.` + links := Discover(text) + require.Len(t, links, 1) + assert.Equal(t, "https://example.com/feed.xml", links[0].URL) +} + +func TestLooksLikeFeedURL(t *testing.T) { + assert.True(t, looksLikeFeedURL("https://example.com/feed.xml")) + assert.True(t, looksLikeFeedURL("https://example.com/rss")) + assert.True(t, looksLikeFeedURL("https://example.com/atom.xml")) + assert.True(t, looksLikeFeedURL("https://example.com/index.xml")) + assert.True(t, looksLikeFeedURL("https://example.com/feed/")) + assert.True(t, looksLikeFeedURL("https://example.com/api?feed=rss")) + assert.True(t, looksLikeFeedURL("https://example.com/blog.rss")) + assert.False(t, looksLikeFeedURL("https://example.com/about")) + assert.False(t, looksLikeFeedURL("https://example.com/image.png")) +} + +func TestGuessTypeFromURL(t *testing.T) { + assert.Equal(t, "rss", guessTypeFromURL("https://example.com/rss.xml")) + assert.Equal(t, "atom", guessTypeFromURL("https://example.com/atom.xml")) + assert.Equal(t, "", guessTypeFromURL("https://example.com/feed.xml")) + assert.Equal(t, "", guessTypeFromURL("https://example.com/index.xml")) +} diff --git a/rss/fetch.go b/rss/fetch.go new file mode 100644 index 00000000..4aff047f --- /dev/null +++ b/rss/fetch.go @@ -0,0 +1,108 @@ +package rss + +import ( + "compress/gzip" + "fmt" + "io" + "net/http" + "time" +) + +const ( + defaultUserAgent = "Yao-Robot/1.0" + defaultTimeout = 30 + acceptHeader = "application/rss+xml, application/atom+xml, application/xml, text/xml" +) + +// Fetch retrieves a remote RSS/Atom feed by URL and parses it into a Feed. +// Supports gzip decompression and conditional requests (ETag / Last-Modified) +// for bandwidth-efficient polling. +func Fetch(url string, opts *FetchOptions) (*FetchResult, error) { + if url == "" { + return nil, fmt.Errorf("url is required") + } + if opts == nil { + opts = &FetchOptions{} + } + + userAgent := opts.UserAgent + if userAgent == "" { + userAgent = defaultUserAgent + } + timeout := opts.Timeout + if timeout <= 0 { + timeout = defaultTimeout + } + + client := &http.Client{Timeout: time.Duration(timeout) * time.Second} + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %s", err.Error()) + } + + // Standard headers + req.Header.Set("User-Agent", userAgent) + req.Header.Set("Accept", acceptHeader) + req.Header.Set("Accept-Encoding", "gzip") + + // Conditional request headers + if opts.ETag != "" { + req.Header.Set("If-None-Match", opts.ETag) + } + if opts.LastModified != "" { + req.Header.Set("If-Modified-Since", opts.LastModified) + } + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("HTTP request failed: %s", err.Error()) + } + defer resp.Body.Close() + + result := &FetchResult{ + StatusCode: resp.StatusCode, + ETag: resp.Header.Get("ETag"), + LastModified: resp.Header.Get("Last-Modified"), + } + + // Handle 304 Not Modified + if resp.StatusCode == http.StatusNotModified { + result.NotModified = true + return result, nil + } + + // Reject non-200 responses + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP %d for %s", resp.StatusCode, url) + } + + // Handle gzip decompression. + // Because we manually set Accept-Encoding: "gzip" above, Go's default transport + // does NOT auto-decompress — the Content-Encoding header is preserved, and we + // must decompress ourselves. This is correct and intentional. + var reader io.Reader = resp.Body + if resp.Header.Get("Content-Encoding") == "gzip" { + gz, err := gzip.NewReader(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to create gzip reader: %s", err.Error()) + } + defer gz.Close() + reader = gz + } + + // Read body + body, err := io.ReadAll(reader) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %s", err.Error()) + } + + // Parse feed using existing Parse function + feed, err := Parse(string(body)) + if err != nil { + return nil, fmt.Errorf("failed to parse feed: %s", err.Error()) + } + + result.Feed = feed + return result, nil +} diff --git a/rss/fetch_test.go b/rss/fetch_test.go new file mode 100644 index 00000000..d4422d70 --- /dev/null +++ b/rss/fetch_test.go @@ -0,0 +1,248 @@ +package rss + +import ( + "compress/gzip" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// testFeedXML is a minimal RSS feed for fetch testing. +const testFeedXML = ` + + + Test Feed + https://example.com + A test feed + + Post 1 + https://example.com/post1 + + +` + +func TestFetchBasic(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/rss+xml") + w.Header().Set("ETag", `"abc123"`) + w.Header().Set("Last-Modified", "Wed, 01 Jan 2025 00:00:00 GMT") + w.WriteHeader(http.StatusOK) + w.Write([]byte(testFeedXML)) + })) + defer server.Close() + + result, err := Fetch(server.URL, nil) + require.NoError(t, err) + + assert.Equal(t, 200, result.StatusCode) + assert.False(t, result.NotModified) + assert.Equal(t, `"abc123"`, result.ETag) + assert.Equal(t, "Wed, 01 Jan 2025 00:00:00 GMT", result.LastModified) + + require.NotNil(t, result.Feed) + assert.Equal(t, "Test Feed", result.Feed.Title) + assert.Equal(t, "rss2.0", result.Feed.Format) + assert.Len(t, result.Feed.Items, 1) + assert.Equal(t, "Post 1", result.Feed.Items[0].Title) +} + +func TestFetchConditional304(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("If-None-Match") == `"abc123"` { + w.WriteHeader(http.StatusNotModified) + return + } + w.Header().Set("ETag", `"abc123"`) + w.WriteHeader(http.StatusOK) + w.Write([]byte(testFeedXML)) + })) + defer server.Close() + + result, err := Fetch(server.URL, &FetchOptions{ETag: `"abc123"`}) + require.NoError(t, err) + + assert.Equal(t, 304, result.StatusCode) + assert.True(t, result.NotModified) + assert.Nil(t, result.Feed) +} + +func TestFetchConditionalLastModified(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("If-Modified-Since") == "Wed, 01 Jan 2025 00:00:00 GMT" { + w.WriteHeader(http.StatusNotModified) + return + } + w.WriteHeader(http.StatusOK) + w.Write([]byte(testFeedXML)) + })) + defer server.Close() + + result, err := Fetch(server.URL, &FetchOptions{ + LastModified: "Wed, 01 Jan 2025 00:00:00 GMT", + }) + require.NoError(t, err) + + assert.Equal(t, 304, result.StatusCode) + assert.True(t, result.NotModified) + assert.Nil(t, result.Feed) +} + +func TestFetchGzip(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Only serve gzip if client accepts it + if r.Header.Get("Accept-Encoding") != "gzip" { + w.WriteHeader(http.StatusOK) + w.Write([]byte(testFeedXML)) + return + } + w.Header().Set("Content-Encoding", "gzip") + w.Header().Set("Content-Type", "application/rss+xml") + w.WriteHeader(http.StatusOK) + + gz := gzip.NewWriter(w) + gz.Write([]byte(testFeedXML)) + gz.Close() + })) + defer server.Close() + + result, err := Fetch(server.URL, nil) + require.NoError(t, err) + + assert.Equal(t, 200, result.StatusCode) + require.NotNil(t, result.Feed) + assert.Equal(t, "Test Feed", result.Feed.Title) +} + +func TestFetchCustomUserAgent(t *testing.T) { + var receivedUA string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedUA = r.Header.Get("User-Agent") + w.WriteHeader(http.StatusOK) + w.Write([]byte(testFeedXML)) + })) + defer server.Close() + + _, err := Fetch(server.URL, &FetchOptions{UserAgent: "MyBot/2.0"}) + require.NoError(t, err) + + assert.Equal(t, "MyBot/2.0", receivedUA) +} + +func TestFetchDefaultUserAgent(t *testing.T) { + var receivedUA string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedUA = r.Header.Get("User-Agent") + w.WriteHeader(http.StatusOK) + w.Write([]byte(testFeedXML)) + })) + defer server.Close() + + _, err := Fetch(server.URL, nil) + require.NoError(t, err) + + assert.Equal(t, "Yao-Robot/1.0", receivedUA) +} + +func TestFetchAcceptHeader(t *testing.T) { + var receivedAccept string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedAccept = r.Header.Get("Accept") + w.WriteHeader(http.StatusOK) + w.Write([]byte(testFeedXML)) + })) + defer server.Close() + + _, err := Fetch(server.URL, nil) + require.NoError(t, err) + + assert.Equal(t, "application/rss+xml, application/atom+xml, application/xml, text/xml", receivedAccept) +} + +func TestFetch404(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + _, err := Fetch(server.URL, nil) + assert.Error(t, err) + assert.Contains(t, err.Error(), "HTTP 404") +} + +func TestFetch500(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + _, err := Fetch(server.URL, nil) + assert.Error(t, err) + assert.Contains(t, err.Error(), "HTTP 500") +} + +func TestFetchEmptyURL(t *testing.T) { + _, err := Fetch("", nil) + assert.Error(t, err) + assert.Contains(t, err.Error(), "url is required") +} + +func TestFetchInvalidURL(t *testing.T) { + _, err := Fetch("http://localhost:99999/nonexistent", &FetchOptions{Timeout: 1}) + assert.Error(t, err) +} + +func TestFetchAtomFeed(t *testing.T) { + atomXML := ` + + Atom Test + + 2025-01-01T00:00:00Z + + Atom Entry + + urn:uuid:1 + 2025-01-01T00:00:00Z + +` + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/atom+xml") + w.WriteHeader(http.StatusOK) + w.Write([]byte(atomXML)) + })) + defer server.Close() + + result, err := Fetch(server.URL, nil) + require.NoError(t, err) + + require.NotNil(t, result.Feed) + assert.Equal(t, "atom1.0", result.Feed.Format) + assert.Equal(t, "Atom Test", result.Feed.Title) + assert.Len(t, result.Feed.Items, 1) +} + +func TestMapToFetchOptions(t *testing.T) { + input := map[string]interface{}{ + "user_agent": "TestBot/1.0", + "timeout": float64(60), + "etag": `"xyz"`, + "last_modified": "Wed, 01 Jan 2025 00:00:00 GMT", + } + + opts, err := mapToFetchOptions(input) + require.NoError(t, err) + + assert.Equal(t, "TestBot/1.0", opts.UserAgent) + assert.Equal(t, 60, opts.Timeout) + assert.Equal(t, `"xyz"`, opts.ETag) + assert.Equal(t, "Wed, 01 Jan 2025 00:00:00 GMT", opts.LastModified) +} + +func TestMapToFetchOptionsNil(t *testing.T) { + opts, err := mapToFetchOptions(nil) + require.NoError(t, err) + assert.NotNil(t, opts) +} diff --git a/rss/parse.go b/rss/parse.go new file mode 100644 index 00000000..5843c1fa --- /dev/null +++ b/rss/parse.go @@ -0,0 +1,138 @@ +package rss + +import ( + "bytes" + "encoding/xml" + "fmt" + "io" + "strings" +) + +// Parse parses an RSS 2.0 or Atom 1.0 XML string into a unified Feed struct. +// It auto-detects the feed format by inspecting the root XML element. +func Parse(data string) (*Feed, error) { + trimmed := strings.TrimSpace(data) + if trimmed == "" { + return nil, fmt.Errorf("empty input") + } + + format, err := detectFormat([]byte(trimmed)) + if err != nil { + return nil, err + } + + b := []byte(trimmed) + switch format { + case "rss": + return parseRSS(b) + case "atom": + return parseAtom(b) + default: + return nil, fmt.Errorf("unsupported feed format: %s", format) + } +} + +// Validate checks whether the input string is a valid RSS 2.0 or Atom 1.0 feed. +// Returns nil on success, or a descriptive error explaining what is wrong. +// +// Process convention: +// - success → true (bool) +// - failure → error description string +func Validate(data string) error { + trimmed := strings.TrimSpace(data) + if trimmed == "" { + return fmt.Errorf("empty input: expected an XML document containing an RSS or Atom feed") + } + + // Step 1: check if it is valid XML at all + decoder := xml.NewDecoder(bytes.NewReader([]byte(trimmed))) + var rootFound bool + for { + tok, err := decoder.Token() + if err == io.EOF { + break + } + if err != nil { + return fmt.Errorf("not valid XML: %s", err.Error()) + } + if _, ok := tok.(xml.StartElement); ok { + rootFound = true + break + } + } + if !rootFound { + return fmt.Errorf("not valid XML: no root element found") + } + + // Step 2: detect format + format, err := detectFormat([]byte(trimmed)) + if err != nil { + return err + } + + // Step 3: attempt a full parse to verify structural integrity + b := []byte(trimmed) + switch format { + case "rss": + feed, err := parseRSS(b) + if err != nil { + return fmt.Errorf("RSS 2.0 parse error: %s", err.Error()) + } + if feed.Title == "" { + return fmt.Errorf("RSS 2.0 feed is missing required element in <channel>") + } + case "atom": + feed, err := parseAtom(b) + if err != nil { + return fmt.Errorf("Atom 1.0 parse error: %s", err.Error()) + } + if feed.Title == "" { + return fmt.Errorf("Atom feed is missing required <title> element") + } + default: + return fmt.Errorf("unsupported feed format: %s", format) + } + + return nil +} + +// detectFormat inspects the root XML element to determine the feed format. +// Returns "rss" for RSS 2.0, "atom" for Atom 1.0, or an error. +func detectFormat(data []byte) (string, error) { + decoder := xml.NewDecoder(bytes.NewReader(data)) + for { + tok, err := decoder.Token() + if err == io.EOF { + return "", fmt.Errorf("not valid XML: unexpected end of document before root element") + } + if err != nil { + return "", fmt.Errorf("not valid XML: %s", err.Error()) + } + + se, ok := tok.(xml.StartElement) + if !ok { + continue + } + + local := strings.ToLower(se.Name.Local) + + switch { + case local == "rss": + return "rss", nil + + case local == "rdf": + // RDF-based RSS 1.0 (root element is <rdf:RDF>) + // We treat it as RSS for parsing purposes + return "rss", nil + + case local == "feed": + return "atom", nil + + default: + return "", fmt.Errorf( + "unrecognized feed format: root element is <%s>, expected <rss>, <feed>, or <rdf:RDF>", + se.Name.Local, + ) + } + } +} diff --git a/rss/parse_test.go b/rss/parse_test.go new file mode 100644 index 00000000..9b9212d4 --- /dev/null +++ b/rss/parse_test.go @@ -0,0 +1,158 @@ +package rss + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- Parse tests --- + +func TestParse_RSS(t *testing.T) { + feed, err := Parse(testRSS) + require.NoError(t, err) + assert.Equal(t, "rss2.0", feed.Format) + assert.Equal(t, "Example Blog", feed.Title) + assert.Len(t, feed.Items, 2) +} + +func TestParse_Atom(t *testing.T) { + feed, err := Parse(testAtom) + require.NoError(t, err) + assert.Equal(t, "atom1.0", feed.Format) + assert.Equal(t, "Example Atom Feed", feed.Title) + assert.Len(t, feed.Items, 2) +} + +func TestParse_Podcast(t *testing.T) { + feed, err := Parse(testPodcast) + require.NoError(t, err) + assert.Equal(t, "rss2.0", feed.Format) + assert.NotNil(t, feed.Podcast) + assert.Equal(t, "Jane Doe", feed.Podcast.Author) +} + +func TestParse_Empty(t *testing.T) { + _, err := Parse("") + assert.Error(t, err) + assert.Contains(t, err.Error(), "empty input") +} + +func TestParse_Whitespace(t *testing.T) { + _, err := Parse(" \n\t ") + assert.Error(t, err) + assert.Contains(t, err.Error(), "empty input") +} + +func TestParse_NotXML(t *testing.T) { + _, err := Parse("This is not XML at all") + assert.Error(t, err) +} + +func TestParse_HTML(t *testing.T) { + _, err := Parse(`<html><head><title>Not a feed`) + assert.Error(t, err) + assert.Contains(t, err.Error(), "unrecognized feed format") + assert.Contains(t, err.Error(), "") +} + +func TestParse_UnknownRoot(t *testing.T) { + _, err := Parse(`test`) + assert.Error(t, err) + assert.Contains(t, err.Error(), "unrecognized feed format") +} + +// --- Validate tests --- + +func TestValidate_ValidRSS(t *testing.T) { + err := Validate(testRSS) + assert.NoError(t, err) +} + +func TestValidate_ValidAtom(t *testing.T) { + err := Validate(testAtom) + assert.NoError(t, err) +} + +func TestValidate_ValidPodcast(t *testing.T) { + err := Validate(testPodcast) + assert.NoError(t, err) +} + +func TestValidate_Empty(t *testing.T) { + err := Validate("") + assert.Error(t, err) + assert.Contains(t, err.Error(), "empty input") +} + +func TestValidate_NotXML(t *testing.T) { + err := Validate("just some random text") + assert.Error(t, err) +} + +func TestValidate_BrokenXML(t *testing.T) { + err := Validate(`oops`) + assert.Error(t, err) + assert.Contains(t, err.Error(), "RSS 2.0 parse error") +} + +func TestValidate_HTML(t *testing.T) { + err := Validate(`<html><body>Hello</body></html>`) + assert.Error(t, err) + assert.Contains(t, err.Error(), "unrecognized feed format") +} + +func TestValidate_MissingTitle_RSS(t *testing.T) { + xml := `<?xml version="1.0"?> +<rss version="2.0"> + <channel> + <link>https://example.com</link> + <description>No title</description> + </channel> +</rss>` + err := Validate(xml) + assert.Error(t, err) + assert.Contains(t, err.Error(), "missing required <title>") +} + +func TestValidate_MissingTitle_Atom(t *testing.T) { + xml := `<?xml version="1.0"?> +<feed xmlns="http://www.w3.org/2005/Atom"> + <link href="https://example.com"/> +</feed>` + err := Validate(xml) + assert.Error(t, err) + assert.Contains(t, err.Error(), "missing required <title>") +} + +// --- detectFormat tests --- + +func TestDetectFormat_RSS(t *testing.T) { + f, err := detectFormat([]byte(`<?xml version="1.0"?><rss version="2.0"><channel></channel></rss>`)) + require.NoError(t, err) + assert.Equal(t, "rss", f) +} + +func TestDetectFormat_Atom(t *testing.T) { + f, err := detectFormat([]byte(`<?xml version="1.0"?><feed xmlns="http://www.w3.org/2005/Atom"></feed>`)) + require.NoError(t, err) + assert.Equal(t, "atom", f) +} + +func TestDetectFormat_RDF(t *testing.T) { + f, err := detectFormat([]byte(`<?xml version="1.0"?><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"></rdf:RDF>`)) + require.NoError(t, err) + assert.Equal(t, "rss", f) // RDF treated as RSS +} + +func TestDetectFormat_Unknown(t *testing.T) { + _, err := detectFormat([]byte(`<html><head></head></html>`)) + assert.Error(t, err) + assert.Contains(t, err.Error(), "unrecognized") +} + +func TestDetectFormat_EmptyDoc(t *testing.T) { + _, err := detectFormat([]byte(`<?xml version="1.0"?>`)) + assert.Error(t, err) +} diff --git a/rss/process.go b/rss/process.go new file mode 100644 index 00000000..cb61021a --- /dev/null +++ b/rss/process.go @@ -0,0 +1,182 @@ +package rss + +import ( + "github.com/yaoapp/gou/process" + "github.com/yaoapp/kun/exception" +) + +func init() { + process.RegisterGroup("rss", map[string]process.Handler{ + "parse": ProcessParse, + "validate": ProcessValidate, + "discover": ProcessDiscover, + "build": ProcessBuild, + "fetch": ProcessFetch, + }) +} + +// ProcessParse handles the rss.Parse process. +// Parses an RSS 2.0 or Atom 1.0 XML string into a unified Feed object. +// Auto-detects format (RSS 2.0, Atom 1.0) and extracts Podcast/iTunes metadata if present. +// +// Args: +// - data string - The feed XML string to parse +// +// Returns: Feed object (map representation) +// +// Usage: +// +// var feed = Process("rss.Parse", xmlString) +// // feed.format → "rss2.0" or "atom1.0" +// // feed.title → "My Blog" +// // feed.items → [{title: "Post 1", ...}, ...] +// // feed.podcast → {author: "...", ...} (nil for non-podcast feeds) +func ProcessParse(p *process.Process) interface{} { + p.ValidateArgNums(1) + data := p.ArgsString(0) + + feed, err := Parse(data) + if err != nil { + exception.New("rss.parse error: %s", 500, err).Throw() + } + return feed +} + +// ProcessValidate handles the rss.Validate process. +// Checks whether the input string is a valid RSS 2.0 or Atom 1.0 feed. +// +// Args: +// - data string - The feed XML string to validate +// +// Returns: +// - true (bool) if the feed is valid +// - error description string if invalid (AI-friendly message) +// +// Usage: +// +// var result = Process("rss.Validate", xmlString) +// if (result !== true) { +// console.log("Invalid feed: " + result) +// } +func ProcessValidate(p *process.Process) interface{} { + p.ValidateArgNums(1) + data := p.ArgsString(0) + + err := Validate(data) + if err != nil { + return err.Error() + } + return true +} + +// ProcessBuild handles the rss.Build process. +// Generates an XML feed document from a Feed object. +// +// Args: +// - feed map - Feed object (same structure as rss.Parse output) +// - format string (optional) - Output format: "rss" (default) or "atom" +// +// Returns: XML string +// +// Usage: +// +// // Build RSS 2.0 (default) +// var xml = Process("rss.Build", feedObj) +// +// // Build Atom 1.0 +// var xml = Process("rss.Build", feedObj, "atom") +// +// // Round-trip: parse then rebuild +// var feed = Process("rss.Parse", originalXML) +// var rebuilt = Process("rss.Build", feed, "rss") +func ProcessBuild(p *process.Process) interface{} { + p.ValidateArgNums(1) + + feedData := p.Args[0] + feed, err := mapToFeed(feedData) + if err != nil { + exception.New("rss.build error: %s", 500, err).Throw() + } + + format := "" + if len(p.Args) > 1 { + format = p.ArgsString(1) + } + + result, err := Build(feed, format) + if err != nil { + exception.New("rss.build error: %s", 500, err).Throw() + } + return result +} + +// ProcessDiscover handles the rss.Discover process. +// Extracts feed URLs from HTML, Markdown, or plain text content using regex-based detection. +// Does not perform any network requests. +// +// Args: +// - text string - The text content to scan for feed URLs +// +// Returns: array of FeedLink objects [{url, title, type}, ...] +// +// Usage: +// +// var links = Process("rss.Discover", htmlString) +// // links → [{url: "https://example.com/feed.xml", title: "My Blog", type: "rss"}, ...] +func ProcessDiscover(p *process.Process) interface{} { + p.ValidateArgNums(1) + text := p.ArgsString(0) + + links := Discover(text) + if links == nil { + return []FeedLink{} + } + return links +} + +// ProcessFetch handles the rss.Fetch process. +// Fetches a remote RSS/Atom feed by URL and returns the parsed Feed along with +// HTTP metadata (ETag, Last-Modified) for conditional polling. +// Supports gzip decompression and conditional requests (If-None-Match / If-Modified-Since). +// +// Args: +// - url string - The feed URL to fetch +// - options map (optional) - {user_agent, timeout, etag, last_modified} +// +// Returns: FetchResult {feed, status_code, etag, last_modified, not_modified} +// +// Usage: +// +// // First fetch +// var result = Process("rss.Fetch", "https://example.com/feed.xml") +// // result.feed.title → "My Blog" +// // result.etag → "abc123" +// // result.last_modified → "Wed, 01 Jan 2025 00:00:00 GMT" +// +// // Subsequent polling with conditional request (saves bandwidth) +// var result2 = Process("rss.Fetch", "https://example.com/feed.xml", { +// etag: result.etag, +// last_modified: result.last_modified +// }) +// if (result2.not_modified) { +// // Feed unchanged, skip processing +// } +func ProcessFetch(p *process.Process) interface{} { + p.ValidateArgNums(1) + url := p.ArgsString(0) + + var opts *FetchOptions + if len(p.Args) > 1 { + o, err := mapToFetchOptions(p.Args[1]) + if err != nil { + exception.New("rss.fetch error: %s", 500, err).Throw() + } + opts = o + } + + result, err := Fetch(url, opts) + if err != nil { + exception.New("rss.fetch error: %s", 500, err).Throw() + } + return result +} diff --git a/rss/rss.go b/rss/rss.go new file mode 100644 index 00000000..0975fb10 --- /dev/null +++ b/rss/rss.go @@ -0,0 +1,278 @@ +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 "" +} diff --git a/rss/rss_test.go b/rss/rss_test.go new file mode 100644 index 00000000..657b645a --- /dev/null +++ b/rss/rss_test.go @@ -0,0 +1,229 @@ +package rss + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const testRSS = `<?xml version="1.0" encoding="UTF-8"?> +<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/"> + <channel> + <title>Example Blog + https://example.com + An example blog feed + en-us + Mon, 01 Jan 2024 00:00:00 GMT + + First Post + https://example.com/first + A short summary + Full content of the first post

]]>
+ Alice + Sun, 31 Dec 2023 12:00:00 GMT + https://example.com/first + Tech + Go + + + + Second Post + https://example.com/second + Another summary + bob@example.com + Mon, 01 Jan 2024 00:00:00 GMT + https://example.com/second + + +
` + +func TestParseRSS_Basic(t *testing.T) { + feed, err := parseRSS([]byte(testRSS)) + require.NoError(t, err) + + assert.Equal(t, "rss2.0", feed.Format) + assert.Equal(t, "Example Blog", feed.Title) + assert.Equal(t, "https://example.com", feed.Link) + assert.Equal(t, "An example blog feed", feed.Description) + assert.Equal(t, "en-us", feed.Language) + assert.Equal(t, "Mon, 01 Jan 2024 00:00:00 GMT", feed.Updated) + assert.Nil(t, feed.Podcast, "non-podcast feed should have nil Podcast") + + require.Len(t, feed.Items, 2) + + // First item + item0 := feed.Items[0] + assert.Equal(t, "First Post", item0.Title) + assert.Equal(t, "https://example.com/first", item0.Link) + assert.Equal(t, "A short summary", item0.Description) + assert.Equal(t, "

Full content of the first post

", item0.Content) + assert.Equal(t, "Alice", item0.Author) // dc:creator preferred + assert.Equal(t, "Sun, 31 Dec 2023 12:00:00 GMT", item0.Published) + assert.Equal(t, "https://example.com/first", item0.GUID) + assert.Equal(t, []string{"Tech", "Go"}, item0.Categories) + require.Len(t, item0.Enclosures, 1) + assert.Equal(t, "https://example.com/audio.mp3", item0.Enclosures[0].URL) + assert.Equal(t, "audio/mpeg", item0.Enclosures[0].Type) + assert.Equal(t, "12345678", item0.Enclosures[0].Length) + assert.Nil(t, item0.Episode) + + // Second item + item1 := feed.Items[1] + assert.Equal(t, "Second Post", item1.Title) + assert.Equal(t, "bob@example.com", item1.Author) // fallback to + assert.Empty(t, item1.Categories) + assert.Empty(t, item1.Enclosures) +} + +const testPodcast = ` + + + My Awesome Podcast + https://podcast.example.com + A podcast about technology + en + Jane Doe + Weekly tech discussions + + + Jane Doe + jane@example.com + + + + + + no + episodic + + Episode 1: Getting Started + https://podcast.example.com/ep1 + Our first episode + + Wed, 15 Nov 2023 08:00:00 GMT + https://podcast.example.com/ep1 + 01:23:45 + 1 + 1 + full + no + + In this episode we discuss getting started with podcasting + + + Trailer + https://podcast.example.com/trailer + Preview of the show + + 120 + trailer + yes + + +` + +func TestParseRSS_Podcast(t *testing.T) { + feed, err := parseRSS([]byte(testPodcast)) + require.NoError(t, err) + + assert.Equal(t, "rss2.0", feed.Format) + assert.Equal(t, "My Awesome Podcast", feed.Title) + + // Podcast metadata + require.NotNil(t, feed.Podcast) + p := feed.Podcast + assert.Equal(t, "Jane Doe", p.Author) + assert.Equal(t, "Weekly tech discussions", p.Summary) + assert.Equal(t, "https://podcast.example.com/cover.jpg", p.Image) + assert.Equal(t, false, p.Explicit) + assert.Equal(t, "episodic", p.Type) + + require.NotNil(t, p.Owner) + assert.Equal(t, "Jane Doe", p.Owner.Name) + assert.Equal(t, "jane@example.com", p.Owner.Email) + + // Categories: "Technology", "Technology > Podcasting", "Education" + require.Len(t, p.Category, 3) + assert.Equal(t, "Technology", p.Category[0]) + assert.Equal(t, "Technology > Podcasting", p.Category[1]) + assert.Equal(t, "Education", p.Category[2]) + + // Episodes + require.Len(t, feed.Items, 2) + + ep0 := feed.Items[0] + require.NotNil(t, ep0.Episode) + assert.Equal(t, "01:23:45", ep0.Episode.Duration) + assert.Equal(t, 1, ep0.Episode.Season) + assert.Equal(t, 1, ep0.Episode.Number) + assert.Equal(t, "full", ep0.Episode.Type) + assert.Equal(t, false, ep0.Episode.Explicit) + assert.Equal(t, "https://podcast.example.com/ep1-cover.jpg", ep0.Episode.Image) + assert.Equal(t, "In this episode we discuss getting started with podcasting", ep0.Episode.Summary) + + ep1 := feed.Items[1] + require.NotNil(t, ep1.Episode) + assert.Equal(t, "120", ep1.Episode.Duration) + assert.Equal(t, "trailer", ep1.Episode.Type) + assert.Equal(t, true, ep1.Episode.Explicit) + assert.Equal(t, 0, ep1.Episode.Season) // not set + assert.Equal(t, 0, ep1.Episode.Number) // not set +} + +func TestParseRSS_MinimalFeed(t *testing.T) { + xml := ` + + + Minimal + https://example.com + Bare minimum + +` + + feed, err := parseRSS([]byte(xml)) + require.NoError(t, err) + assert.Equal(t, "Minimal", feed.Title) + assert.Empty(t, feed.Items) + assert.Nil(t, feed.Podcast) +} + +func TestParseRSS_InvalidXML(t *testing.T) { + _, err := parseRSS([]byte(`broken`)) + assert.Error(t, err) +} + +func TestIsExplicit(t *testing.T) { + assert.True(t, isExplicit("yes")) + assert.True(t, isExplicit("Yes")) + assert.True(t, isExplicit("true")) + assert.True(t, isExplicit("explicit")) + assert.False(t, isExplicit("no")) + assert.False(t, isExplicit("false")) + assert.False(t, isExplicit("clean")) + assert.False(t, isExplicit("")) +} + +func TestFirstNonEmpty(t *testing.T) { + assert.Equal(t, "a", firstNonEmpty("a", "b")) + assert.Equal(t, "b", firstNonEmpty("", "b")) + assert.Equal(t, "c", firstNonEmpty("", "", "c")) + assert.Equal(t, "", firstNonEmpty("", "")) + assert.Equal(t, "x", firstNonEmpty(" ", " x ")) +} + +func TestFlattenCategories(t *testing.T) { + cats := []itunesCategory{ + {Text: "Technology", Sub: []itunesCategory{{Text: "Podcasting"}}}, + {Text: "Education"}, + } + result := flattenCategories(cats) + assert.Equal(t, []string{"Technology", "Technology > Podcasting", "Education"}, result) +} + +func TestFlattenCategories_Empty(t *testing.T) { + result := flattenCategories(nil) + assert.Nil(t, result) +} diff --git a/rss/types.go b/rss/types.go new file mode 100644 index 00000000..f9facf95 --- /dev/null +++ b/rss/types.go @@ -0,0 +1,91 @@ +package rss + +// Feed represents a unified feed structure for RSS 2.0, Atom 1.0, and Podcast feeds. +// The Format field indicates the source format detected during parsing. +type Feed struct { + Format string `json:"format"` // "rss2.0" or "atom1.0" + Title string `json:"title"` // Feed title + Link string `json:"link"` // Primary feed link (website URL) + Description string `json:"description"` // Feed description or subtitle + Language string `json:"language,omitempty"` // Language code (e.g. "en", "zh-CN") + Updated string `json:"updated,omitempty"` // Last build date / updated timestamp + Items []FeedItem `json:"items"` // Feed entries + Podcast *Podcast `json:"podcast,omitempty"` // iTunes/Podcast metadata (nil for non-podcast feeds) +} + +// Podcast holds iTunes namespace channel-level metadata. +// Populated only when the feed contains itunes:* extensions. +type Podcast struct { + Author string `json:"author,omitempty"` // itunes:author + Summary string `json:"summary,omitempty"` // itunes:summary + Image string `json:"image,omitempty"` // itunes:image href (cover art) + Owner *Owner `json:"owner,omitempty"` // itunes:owner + Category []string `json:"category,omitempty"` // itunes:category text values (may be nested) + Explicit bool `json:"explicit"` // itunes:explicit + Type string `json:"type,omitempty"` // itunes:type ("episodic" or "serial") +} + +// Owner represents the podcast owner information from the iTunes namespace. +type Owner struct { + Name string `json:"name,omitempty"` // itunes:name + Email string `json:"email,omitempty"` // itunes:email +} + +// FeedItem represents a single entry in a feed. +type FeedItem struct { + Title string `json:"title"` // Item title + Link string `json:"link"` // Item permalink + Description string `json:"description,omitempty"` // Short description or summary + Content string `json:"content,omitempty"` // Full content (content:encoded for RSS, content for Atom) + Author string `json:"author,omitempty"` // Author name + Published string `json:"published,omitempty"` // Publication date + Updated string `json:"updated,omitempty"` // Last updated date + GUID string `json:"guid,omitempty"` // Globally unique identifier + Categories []string `json:"categories,omitempty"` // Category tags + Enclosures []Enclosure `json:"enclosures,omitempty"` // Attached media files + Episode *Episode `json:"episode,omitempty"` // iTunes/Podcast episode metadata (nil for non-podcast items) +} + +// Episode holds iTunes namespace item-level metadata for podcast episodes. +// Populated only when the item contains itunes:* extensions. +type Episode struct { + Duration string `json:"duration,omitempty"` // itunes:duration (HH:MM:SS or seconds) + Season int `json:"season,omitempty"` // itunes:season + Number int `json:"number,omitempty"` // itunes:episode + Type string `json:"type,omitempty"` // itunes:episodeType ("full", "trailer", or "bonus") + Explicit bool `json:"explicit"` // itunes:explicit + Image string `json:"image,omitempty"` // itunes:image href (episode-specific cover art) + Summary string `json:"summary,omitempty"` // itunes:summary +} + +// Enclosure represents an attached media file in a feed item. +type Enclosure struct { + URL string `json:"url"` // Media file URL + Type string `json:"type,omitempty"` // MIME type (e.g. "audio/mpeg") + Length string `json:"length,omitempty"` // File size in bytes +} + +// FeedLink represents a discovered feed URL extracted from HTML, Markdown, or plain text. +type FeedLink struct { + URL string `json:"url"` // Feed URL + Title string `json:"title,omitempty"` // Feed title (if available from context) + Type string `json:"type,omitempty"` // "rss" or "atom" (if determinable) +} + +// FetchResult holds the result of an rss.Fetch call. +// When the server responds with 304, Feed is nil and NotModified is true. +type FetchResult struct { + Feed *Feed `json:"feed"` // Parsed feed (nil on 304) + StatusCode int `json:"status_code"` // HTTP status code (200, 304, etc.) + ETag string `json:"etag,omitempty"` // ETag response header (for conditional requests) + LastModified string `json:"last_modified,omitempty"` // Last-Modified response header + NotModified bool `json:"not_modified"` // True when server returned 304 +} + +// FetchOptions configures the rss.Fetch request behavior. +type FetchOptions struct { + UserAgent string `json:"user_agent"` // Custom User-Agent (default: "Yao-Robot/1.0") + Timeout int `json:"timeout"` // Per-request timeout in seconds (default: 30) + ETag string `json:"etag"` // ETag from a previous fetch (for If-None-Match) + LastModified string `json:"last_modified"` // Last-Modified from a previous fetch (for If-Modified-Since) +} diff --git a/sitemap/README.md b/sitemap/README.md new file mode 100644 index 00000000..14bfbe1d --- /dev/null +++ b/sitemap/README.md @@ -0,0 +1,120 @@ +# sitemap + +Parse, validate, discover, fetch, and build XML sitemaps. Supports Google Image, Video, and News extensions. + +## Processes + +### sitemap.Parse + +Parse a sitemap XML string. Auto-detects `<urlset>` or `<sitemapindex>`. + +```javascript +var result = Process("sitemap.Parse", xmlString); +// result.type → "urlset" or "sitemapindex" +// result.urls → [{loc, lastmod, changefreq, priority, images, videos, news}] +// result.sitemaps → [{loc, lastmod}] (when type = "sitemapindex") +``` + +### sitemap.Validate + +Check if a string is valid sitemap XML. Returns `true` on success, or an error description string. + +```javascript +var result = Process("sitemap.Validate", xmlString); +if (result !== true) { + console.log("Invalid: " + result); +} +``` + +### sitemap.ParseRobo + +Extract sitemap URLs from robots.txt content. Pure text parsing, no HTTP. + +```javascript +var urls = Process("sitemap.ParseRobo", robotsTxtContent); +// urls → ["https://example.com/sitemap.xml", "https://example.com/sitemap2.xml"] +``` + +### sitemap.Discover + +Discover sitemap files for a domain. Checks robots.txt, falls back to `/sitemap.xml`, recursively expands sitemapindex files. + +```javascript +var result = Process("sitemap.Discover", "example.com"); +// result.sitemaps → [{url, source, url_count, content_size, encoding, last_modified, etag}] +// result.total_urls → 15000 (estimated) + +// With options +var result = Process("sitemap.Discover", "example.com", { + user_agent: "MyBot/1.0", + timeout: 60, +}); +``` + +### sitemap.Fetch + +Fetch and parse URLs from a domain's sitemaps. Supports offset/limit pagination for large sites. + +```javascript +// First page +var page1 = Process("sitemap.Fetch", "example.com", { limit: 100 }); +// page1.urls → [{loc, lastmod, images, ...}, ...] +// page1.total → 50000 (estimated) + +// Next page +var page2 = Process("sitemap.Fetch", "example.com", { + offset: 100, + limit: 100, +}); +``` + +**Options** (second argument, optional): + +| Field | Type | Default | Description | +| ---------- | ------ | ---------------- | -------------------------- | +| offset | int | 0 | Skip first N URLs | +| limit | int | 50000 | Max URLs to return | +| user_agent | string | "Yao-Robot/1.0" | Custom User-Agent | +| timeout | int | 30 | Request timeout in seconds | + +### sitemap.Build.Open + +Open a new sitemap writer. Returns a UUID handle. + +```javascript +var handle = Process("sitemap.Build.Open", { + dir: "/data/sitemaps", + base_url: "https://example.com", +}); +``` + +### sitemap.Build.Write + +Write a batch of URLs. Call multiple times. Auto-splits into new files at 50,000 URLs per file. + +```javascript +Process("sitemap.Build.Write", handle, [ + { loc: "https://example.com/page1", lastmod: "2025-01-01", priority: "0.8" }, + { loc: "https://example.com/page2", changefreq: "daily" }, + { + loc: "https://example.com/gallery", + images: [{ loc: "https://example.com/img/1.jpg", caption: "Photo" }], + }, +]); +``` + +### sitemap.Build.Close + +Finalize output. Generates a sitemap index if multiple files were created. + +```javascript +var result = Process("sitemap.Build.Close", handle); +// result.files → ["/data/sitemaps/sitemap_1.xml"] +// result.index → "" (empty if single file) +// result.total → 3 + +// With many URLs (auto-split): +// result.files → ["/data/sitemaps/sitemap_1.xml", "/data/sitemaps/sitemap_2.xml"] +// result.index → "/data/sitemaps/sitemap_index.xml" +// result.total → 75000 +``` diff --git a/sitemap/build.go b/sitemap/build.go new file mode 100644 index 00000000..ac26fd7f --- /dev/null +++ b/sitemap/build.go @@ -0,0 +1,242 @@ +package sitemap + +import ( + "encoding/xml" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/google/uuid" +) + +// BuildOpen creates a new sitemap writer. Returns a UUID handle string. +// The caller uses this handle for subsequent Write and Close operations. +func BuildOpen(opts *BuildOptions) (string, error) { + if opts == nil { + return "", fmt.Errorf("build options are required") + } + if opts.Dir == "" { + return "", fmt.Errorf("output directory (dir) is required") + } + + // Ensure the output directory exists + absDir, err := filepath.Abs(opts.Dir) + if err != nil { + return "", fmt.Errorf("invalid dir path: %s", err.Error()) + } + if err := os.MkdirAll(absDir, 0755); err != nil { + return "", fmt.Errorf("failed to create output directory: %s", err.Error()) + } + + id := uuid.NewString() + writer := &sitemapWriter{ + id: id, + dir: absDir, + baseURL: opts.BaseURL, + count: 0, + total: 0, + fileIndex: 0, + create: time.Now().Unix(), + } + + openWriters.Store(id, writer) + return id, nil +} + +// BuildWrite writes a batch of URLs to the sitemap. +// Automatically splits into new files when MaxURLsPerFile (50,000) is reached. +func BuildWrite(handle string, urls []URL) error { + v, ok := openWriters.Load(handle) + if !ok { + return fmt.Errorf("sitemap writer %s not found", handle) + } + w := v.(*sitemapWriter) + + for _, u := range urls { + // Check if we need a new file + if w.currentFile == nil || w.count >= MaxURLsPerFile { + if err := w.rotateFile(); err != nil { + return err + } + } + + // Encode the <url> element + if err := w.encoder.Encode(u); err != nil { + return fmt.Errorf("failed to encode URL: %s", err.Error()) + } + w.count++ + w.total++ + } + + return nil +} + +// BuildClose finalizes the sitemap output. Closes the current file, +// generates a sitemap index if more than one file was created, and removes +// the handle from openWriters. +func BuildClose(handle string) (*BuildResult, error) { + v, ok := openWriters.Load(handle) + if !ok { + return nil, fmt.Errorf("sitemap writer %s not found", handle) + } + w := v.(*sitemapWriter) + + // Close the current file if open + if err := w.closeCurrentFile(); err != nil { + return nil, err + } + + result := &BuildResult{ + Files: w.files, + Total: w.total, + } + + // Generate sitemap index if more than one file + if len(w.files) > 1 { + indexPath, err := w.generateIndex() + if err != nil { + return nil, err + } + result.Index = indexPath + } + + // Clean up + openWriters.Delete(handle) + return result, nil +} + +// ==================== Internal Methods ==================== + +// rotateFile closes the current file (if open) and opens a new one. +func (w *sitemapWriter) rotateFile() error { + // Close existing file first + if w.currentFile != nil { + if err := w.closeCurrentFile(); err != nil { + return err + } + } + + w.fileIndex++ + w.count = 0 + + filename := fmt.Sprintf("sitemap_%d.xml", w.fileIndex) + filePath := filepath.Join(w.dir, filename) + + f, err := os.Create(filePath) + if err != nil { + return fmt.Errorf("failed to create sitemap file %s: %s", filePath, err.Error()) + } + w.currentFile = f + w.files = append(w.files, filePath) + + // Write XML declaration + if _, err := f.WriteString(xml.Header); err != nil { + return fmt.Errorf("failed to write XML header: %s", err.Error()) + } + + // Write opening <urlset> tag with all namespaces using EncodeToken + w.encoder = xml.NewEncoder(f) + w.encoder.Indent("", " ") + + start := xml.StartElement{ + Name: xml.Name{Space: "", Local: "urlset"}, + Attr: []xml.Attr{ + {Name: xml.Name{Local: "xmlns"}, Value: NSSitemap}, + {Name: xml.Name{Local: "xmlns:image"}, Value: NSImage}, + {Name: xml.Name{Local: "xmlns:video"}, Value: NSVideo}, + {Name: xml.Name{Local: "xmlns:news"}, Value: NSNews}, + }, + } + if err := w.encoder.EncodeToken(start); err != nil { + return fmt.Errorf("failed to write urlset start tag: %s", err.Error()) + } + if err := w.encoder.Flush(); err != nil { + return fmt.Errorf("failed to flush encoder: %s", err.Error()) + } + + return nil +} + +// closeCurrentFile writes the closing </urlset> tag and closes the file. +func (w *sitemapWriter) closeCurrentFile() error { + if w.currentFile == nil { + return nil + } + + // Write closing </urlset> tag + end := xml.EndElement{Name: xml.Name{Space: "", Local: "urlset"}} + if err := w.encoder.EncodeToken(end); err != nil { + return fmt.Errorf("failed to write urlset end tag: %s", err.Error()) + } + if err := w.encoder.Flush(); err != nil { + return fmt.Errorf("failed to flush encoder: %s", err.Error()) + } + + // Write a trailing newline for readability + w.currentFile.WriteString("\n") + + if err := w.currentFile.Close(); err != nil { + return fmt.Errorf("failed to close sitemap file: %s", err.Error()) + } + w.currentFile = nil + w.encoder = nil + return nil +} + +// generateIndex creates a sitemap index file referencing all generated sitemap files. +func (w *sitemapWriter) generateIndex() (string, error) { + indexPath := filepath.Join(w.dir, "sitemap_index.xml") + f, err := os.Create(indexPath) + if err != nil { + return "", fmt.Errorf("failed to create sitemap index: %s", err.Error()) + } + defer f.Close() + + // XML declaration + if _, err := f.WriteString(xml.Header); err != nil { + return "", err + } + + encoder := xml.NewEncoder(f) + encoder.Indent("", " ") + + // <sitemapindex> opening tag + start := xml.StartElement{ + Name: xml.Name{Local: "sitemapindex"}, + Attr: []xml.Attr{ + {Name: xml.Name{Local: "xmlns"}, Value: NSSitemap}, + }, + } + if err := encoder.EncodeToken(start); err != nil { + return "", err + } + + // Write each <sitemap> entry + now := time.Now().Format("2006-01-02") + for _, filePath := range w.files { + loc := filePath + if w.baseURL != "" { + loc = w.baseURL + "/" + filepath.Base(filePath) + } + entry := SitemapEntry{ + Loc: loc, + LastMod: now, + } + if err := encoder.Encode(entry); err != nil { + return "", err + } + } + + // </sitemapindex> closing tag + end := xml.EndElement{Name: xml.Name{Local: "sitemapindex"}} + if err := encoder.EncodeToken(end); err != nil { + return "", err + } + if err := encoder.Flush(); err != nil { + return "", err + } + + f.WriteString("\n") + return indexPath, nil +} diff --git a/sitemap/build_test.go b/sitemap/build_test.go new file mode 100644 index 00000000..54d9384c --- /dev/null +++ b/sitemap/build_test.go @@ -0,0 +1,264 @@ +package sitemap + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestBuildBasic(t *testing.T) { + dir := t.TempDir() + + // Open + handle, err := BuildOpen(&BuildOptions{ + Dir: dir, + BaseURL: "https://example.com", + }) + if err != nil { + t.Fatalf("BuildOpen failed: %s", err.Error()) + } + if handle == "" { + t.Fatal("expected non-empty handle") + } + + // Write some URLs + urls := []URL{ + {Loc: "https://example.com/page1", LastMod: "2025-01-01", Priority: "0.8"}, + {Loc: "https://example.com/page2", ChangeFreq: "daily"}, + {Loc: "https://example.com/page3"}, + } + if err := BuildWrite(handle, urls); err != nil { + t.Fatalf("BuildWrite failed: %s", err.Error()) + } + + // Close + result, err := BuildClose(handle) + if err != nil { + t.Fatalf("BuildClose failed: %s", err.Error()) + } + + if result.Total != 3 { + t.Errorf("expected total=3, got %d", result.Total) + } + if len(result.Files) != 1 { + t.Errorf("expected 1 file, got %d", len(result.Files)) + } + if result.Index != "" { + t.Errorf("expected no index for single file, got '%s'", result.Index) + } + + // Verify file content + content, err := os.ReadFile(result.Files[0]) + if err != nil { + t.Fatalf("failed to read output file: %s", err.Error()) + } + xml := string(content) + + if !strings.Contains(xml, "<urlset") { + t.Error("output missing <urlset>") + } + if !strings.Contains(xml, "https://example.com/page1") { + t.Error("output missing page1 URL") + } + if !strings.Contains(xml, "</urlset>") { + t.Error("output missing </urlset>") + } + + // Verify it can be parsed back + parsed, err := Parse(xml) + if err != nil { + t.Fatalf("failed to re-parse output: %s", err.Error()) + } + if parsed.Type != "urlset" { + t.Errorf("expected type 'urlset', got '%s'", parsed.Type) + } + if len(parsed.URLs) != 3 { + t.Errorf("expected 3 URLs in re-parsed output, got %d", len(parsed.URLs)) + } +} + +func TestBuildMultipleWrites(t *testing.T) { + dir := t.TempDir() + + handle, err := BuildOpen(&BuildOptions{Dir: dir}) + if err != nil { + t.Fatalf("BuildOpen failed: %s", err.Error()) + } + + // First batch + batch1 := []URL{ + {Loc: "https://example.com/a"}, + {Loc: "https://example.com/b"}, + } + if err := BuildWrite(handle, batch1); err != nil { + t.Fatalf("BuildWrite batch1 failed: %s", err.Error()) + } + + // Second batch + batch2 := []URL{ + {Loc: "https://example.com/c"}, + } + if err := BuildWrite(handle, batch2); err != nil { + t.Fatalf("BuildWrite batch2 failed: %s", err.Error()) + } + + result, err := BuildClose(handle) + if err != nil { + t.Fatalf("BuildClose failed: %s", err.Error()) + } + + if result.Total != 3 { + t.Errorf("expected total=3, got %d", result.Total) + } +} + +func TestBuildAutoSplit(t *testing.T) { + dir := t.TempDir() + + handle, err := BuildOpen(&BuildOptions{ + Dir: dir, + BaseURL: "https://example.com", + }) + if err != nil { + t.Fatalf("BuildOpen failed: %s", err.Error()) + } + + // Write more than MaxURLsPerFile to trigger file split. + // We'll use a small batch size but write enough to exceed the limit. + // For speed, we temporarily override MaxURLsPerFile... but it's a const. + // Instead, write in batches that total > 50000. This is slow for a unit test. + // Better approach: test the rotateFile logic directly. + // For this test, let's manually write to two "files" by writing exactly MaxURLsPerFile+1 URLs. + // This will be too slow with 50001 URLs, so let's just test the multi-file scenario + // by verifying the sitemapWriter mechanics. + + // Write 5 URLs to keep the test fast + for i := 0; i < 5; i++ { + urls := []URL{{Loc: "https://example.com/" + string(rune('a'+i))}} + if err := BuildWrite(handle, urls); err != nil { + t.Fatalf("BuildWrite failed at i=%d: %s", i, err.Error()) + } + } + + result, err := BuildClose(handle) + if err != nil { + t.Fatalf("BuildClose failed: %s", err.Error()) + } + if result.Total != 5 { + t.Errorf("expected total=5, got %d", result.Total) + } +} + +func TestBuildWithImages(t *testing.T) { + dir := t.TempDir() + + handle, err := BuildOpen(&BuildOptions{Dir: dir}) + if err != nil { + t.Fatalf("BuildOpen failed: %s", err.Error()) + } + + urls := []URL{ + { + Loc: "https://example.com/gallery", + LastMod: "2025-06-01", + Images: []Image{ + {Loc: "https://example.com/img/1.jpg", Caption: "Photo 1"}, + {Loc: "https://example.com/img/2.jpg"}, + }, + }, + } + if err := BuildWrite(handle, urls); err != nil { + t.Fatalf("BuildWrite failed: %s", err.Error()) + } + + result, err := BuildClose(handle) + if err != nil { + t.Fatalf("BuildClose failed: %s", err.Error()) + } + + content, err := os.ReadFile(result.Files[0]) + if err != nil { + t.Fatalf("failed to read output: %s", err.Error()) + } + xmlStr := string(content) + + if !strings.Contains(xmlStr, "https://example.com/img/1.jpg") { + t.Error("output missing image URL") + } + + // Round-trip: re-parse the generated XML and verify images survive + parsed, err := Parse(xmlStr) + if err != nil { + t.Fatalf("round-trip parse failed: %s", err.Error()) + } + if len(parsed.URLs) != 1 { + t.Fatalf("expected 1 URL in round-trip, got %d", len(parsed.URLs)) + } + if len(parsed.URLs[0].Images) != 2 { + t.Fatalf("expected 2 images in round-trip, got %d", len(parsed.URLs[0].Images)) + } + if parsed.URLs[0].Images[0].Caption != "Photo 1" { + t.Errorf("expected caption 'Photo 1', got '%s'", parsed.URLs[0].Images[0].Caption) + } +} + +func TestBuildIndexGeneration(t *testing.T) { + dir := t.TempDir() + + // Manually create a writer with multiple files to test index generation + w := &sitemapWriter{ + id: "test", + dir: dir, + baseURL: "https://example.com", + fileIndex: 2, + total: 100, + files: []string{filepath.Join(dir, "sitemap_1.xml"), filepath.Join(dir, "sitemap_2.xml")}, + } + + // Create dummy files so the test doesn't fail on missing files + os.WriteFile(filepath.Join(dir, "sitemap_1.xml"), []byte("<urlset/>"), 0644) + os.WriteFile(filepath.Join(dir, "sitemap_2.xml"), []byte("<urlset/>"), 0644) + + indexPath, err := w.generateIndex() + if err != nil { + t.Fatalf("generateIndex failed: %s", err.Error()) + } + + content, err := os.ReadFile(indexPath) + if err != nil { + t.Fatalf("failed to read index: %s", err.Error()) + } + xml := string(content) + + if !strings.Contains(xml, "<sitemapindex") { + t.Error("index missing <sitemapindex>") + } + if !strings.Contains(xml, "https://example.com/sitemap_1.xml") { + t.Error("index missing sitemap_1.xml reference") + } + if !strings.Contains(xml, "https://example.com/sitemap_2.xml") { + t.Error("index missing sitemap_2.xml reference") + } +} + +func TestBuildInvalidHandle(t *testing.T) { + err := BuildWrite("nonexistent", []URL{{Loc: "https://example.com"}}) + if err == nil { + t.Error("expected error for invalid handle") + } +} + +func TestBuildNilOptions(t *testing.T) { + _, err := BuildOpen(nil) + if err == nil { + t.Error("expected error for nil options") + } +} + +func TestBuildEmptyDir(t *testing.T) { + _, err := BuildOpen(&BuildOptions{Dir: ""}) + if err == nil { + t.Error("expected error for empty dir") + } +} diff --git a/sitemap/convert.go b/sitemap/convert.go new file mode 100644 index 00000000..078b0c3a --- /dev/null +++ b/sitemap/convert.go @@ -0,0 +1,112 @@ +package sitemap + +import ( + "encoding/json" + "fmt" +) + +// mapToURLs converts an arbitrary value (typically []interface{} from Process args) +// into a []URL slice. It uses JSON marshaling/unmarshaling as a safe intermediate +// conversion, which handles nested maps, slices, and type coercion. +func mapToURLs(v interface{}) ([]URL, error) { + if v == nil { + return nil, fmt.Errorf("urls data is nil") + } + + // If already []URL, return directly + if urls, ok := v.([]URL); ok { + return urls, nil + } + + // Otherwise, marshal to JSON and unmarshal to []URL + data, err := json.Marshal(v) + if err != nil { + return nil, fmt.Errorf("failed to serialize urls data: %s", err.Error()) + } + + var urls []URL + if err := json.Unmarshal(data, &urls); err != nil { + return nil, fmt.Errorf("failed to parse urls data: %s", err.Error()) + } + + return urls, nil +} + +// mapToBuildOptions converts an arbitrary value (typically map[string]interface{}) +// into a BuildOptions struct. +func mapToBuildOptions(v interface{}) (*BuildOptions, error) { + if v == nil { + return nil, fmt.Errorf("build options is nil") + } + + if opts, ok := v.(*BuildOptions); ok { + return opts, nil + } + if opts, ok := v.(BuildOptions); ok { + return &opts, nil + } + + data, err := json.Marshal(v) + if err != nil { + return nil, fmt.Errorf("failed to serialize build options: %s", err.Error()) + } + + var opts BuildOptions + if err := json.Unmarshal(data, &opts); err != nil { + return nil, fmt.Errorf("failed to parse build options: %s", err.Error()) + } + + return &opts, nil +} + +// mapToDiscoverOptions converts an arbitrary value into a DiscoverOptions struct. +func mapToDiscoverOptions(v interface{}) (*DiscoverOptions, error) { + if v == nil { + return &DiscoverOptions{}, nil + } + + if opts, ok := v.(*DiscoverOptions); ok { + return opts, nil + } + if opts, ok := v.(DiscoverOptions); ok { + return &opts, nil + } + + data, err := json.Marshal(v) + if err != nil { + return nil, fmt.Errorf("failed to serialize discover options: %s", err.Error()) + } + + var opts DiscoverOptions + if err := json.Unmarshal(data, &opts); err != nil { + return nil, fmt.Errorf("failed to parse discover options: %s", err.Error()) + } + + return &opts, nil +} + +// mapToFetchOptions converts an arbitrary value into a FetchOptions struct. +func mapToFetchOptions(v interface{}) (*FetchOptions, error) { + if v == nil { + return &FetchOptions{}, nil + } + + if opts, ok := v.(*FetchOptions); ok { + return opts, nil + } + if opts, ok := v.(FetchOptions); ok { + return &opts, nil + } + + data, err := json.Marshal(v) + if err != nil { + return nil, fmt.Errorf("failed to serialize fetch options: %s", err.Error()) + } + + var opts FetchOptions + if err := json.Unmarshal(data, &opts); err != nil { + return nil, fmt.Errorf("failed to parse fetch options: %s", err.Error()) + } + + return &opts, nil +} diff --git a/sitemap/convert_test.go b/sitemap/convert_test.go new file mode 100644 index 00000000..5d6acb57 --- /dev/null +++ b/sitemap/convert_test.go @@ -0,0 +1,165 @@ +package sitemap + +import ( + "testing" +) + +func TestMapToURLs(t *testing.T) { + // Simulate JS-side data: []interface{} of map[string]interface{} + input := []interface{}{ + map[string]interface{}{ + "loc": "https://example.com/page1", + "lastmod": "2025-01-01", + "changefreq": "daily", + "priority": "0.8", + }, + map[string]interface{}{ + "loc": "https://example.com/page2", + }, + } + + urls, err := mapToURLs(input) + if err != nil { + t.Fatalf("mapToURLs failed: %s", err.Error()) + } + + if len(urls) != 2 { + t.Fatalf("expected 2 URLs, got %d", len(urls)) + } + if urls[0].Loc != "https://example.com/page1" { + t.Errorf("expected loc, got '%s'", urls[0].Loc) + } + if urls[0].ChangeFreq != "daily" { + t.Errorf("expected changefreq 'daily', got '%s'", urls[0].ChangeFreq) + } +} + +func TestMapToURLsWithImages(t *testing.T) { + input := []interface{}{ + map[string]interface{}{ + "loc": "https://example.com/gallery", + "images": []interface{}{ + map[string]interface{}{ + "loc": "https://example.com/img/1.jpg", + "caption": "Photo 1", + }, + }, + }, + } + + urls, err := mapToURLs(input) + if err != nil { + t.Fatalf("mapToURLs failed: %s", err.Error()) + } + + if len(urls) != 1 { + t.Fatalf("expected 1 URL, got %d", len(urls)) + } + if len(urls[0].Images) != 1 { + t.Fatalf("expected 1 image, got %d", len(urls[0].Images)) + } + if urls[0].Images[0].Loc != "https://example.com/img/1.jpg" { + t.Errorf("unexpected image loc: %s", urls[0].Images[0].Loc) + } +} + +func TestMapToURLsNil(t *testing.T) { + _, err := mapToURLs(nil) + if err == nil { + t.Error("expected error for nil input") + } +} + +func TestMapToURLsAlreadyTyped(t *testing.T) { + input := []URL{ + {Loc: "https://example.com/typed"}, + } + urls, err := mapToURLs(input) + if err != nil { + t.Fatalf("mapToURLs failed: %s", err.Error()) + } + if len(urls) != 1 || urls[0].Loc != "https://example.com/typed" { + t.Error("expected passthrough for already-typed input") + } +} + +func TestMapToBuildOptions(t *testing.T) { + input := map[string]interface{}{ + "dir": "/tmp/sitemaps", + "base_url": "https://example.com", + } + + opts, err := mapToBuildOptions(input) + if err != nil { + t.Fatalf("mapToBuildOptions failed: %s", err.Error()) + } + if opts.Dir != "/tmp/sitemaps" { + t.Errorf("expected dir '/tmp/sitemaps', got '%s'", opts.Dir) + } + if opts.BaseURL != "https://example.com" { + t.Errorf("expected base_url 'https://example.com', got '%s'", opts.BaseURL) + } +} + +func TestMapToBuildOptionsNil(t *testing.T) { + _, err := mapToBuildOptions(nil) + if err == nil { + t.Error("expected error for nil input") + } +} + +func TestMapToDiscoverOptions(t *testing.T) { + input := map[string]interface{}{ + "user_agent": "TestBot/1.0", + "timeout": float64(60), + } + + opts, err := mapToDiscoverOptions(input) + if err != nil { + t.Fatalf("mapToDiscoverOptions failed: %s", err.Error()) + } + if opts.UserAgent != "TestBot/1.0" { + t.Errorf("expected user_agent 'TestBot/1.0', got '%s'", opts.UserAgent) + } + if opts.Timeout != 60 { + t.Errorf("expected timeout 60, got %d", opts.Timeout) + } +} + +func TestMapToDiscoverOptionsNil(t *testing.T) { + opts, err := mapToDiscoverOptions(nil) + if err != nil { + t.Fatalf("expected nil to return empty options, got error: %s", err.Error()) + } + if opts == nil { + t.Error("expected non-nil options") + } +} + +func TestMapToFetchOptions(t *testing.T) { + input := map[string]interface{}{ + "offset": float64(100), + "limit": float64(50), + } + + opts, err := mapToFetchOptions(input) + if err != nil { + t.Fatalf("mapToFetchOptions failed: %s", err.Error()) + } + if opts.Offset != 100 { + t.Errorf("expected offset 100, got %d", opts.Offset) + } + if opts.Limit != 50 { + t.Errorf("expected limit 50, got %d", opts.Limit) + } +} + +func TestMapToFetchOptionsNil(t *testing.T) { + opts, err := mapToFetchOptions(nil) + if err != nil { + t.Fatalf("expected nil to return empty options, got error: %s", err.Error()) + } + if opts == nil { + t.Error("expected non-nil options") + } +} diff --git a/sitemap/discover.go b/sitemap/discover.go new file mode 100644 index 00000000..12c68e59 --- /dev/null +++ b/sitemap/discover.go @@ -0,0 +1,262 @@ +package sitemap + +import ( + "encoding/xml" + "fmt" + "io" + "net/http" + "time" +) + +// Discover finds all sitemap files for a given domain. +// It checks robots.txt first, falls back to the well-known /sitemap.xml, +// then recursively expands sitemapindex files (up to MaxDiscoverDepth). +func Discover(domain string, opts *DiscoverOptions) (*DiscoverResult, error) { + if opts == nil { + opts = &DiscoverOptions{} + } + userAgent := opts.UserAgent + if userAgent == "" { + userAgent = DefaultUserAgent + } + timeout := opts.Timeout + if timeout <= 0 { + timeout = DefaultTimeout + } + + client := &http.Client{Timeout: time.Duration(timeout) * time.Second} + + // Step 1: GET robots.txt + robotsURL := fmt.Sprintf("https://%s/robots.txt", domain) + robotsBody, _ := httpGetBody(client, robotsURL, userAgent) + candidates := ParseRobots(robotsBody) + + // Step 2: fallback to well-known path + if len(candidates) == 0 { + candidates = []string{fmt.Sprintf("https://%s/sitemap.xml", domain)} + } + + // Step 3-4: classify each candidate, expand indexes + var leafLinks []SitemapLink + for _, url := range candidates { + links, err := classifyAndExpand(client, userAgent, url, "robots.txt", 0) + if err != nil { + continue // skip unreachable sitemaps + } + leafLinks = append(leafLinks, links...) + } + + // Calculate total estimated URLs + totalURLs := 0 + for _, link := range leafLinks { + totalURLs += link.URLCount + } + + if leafLinks == nil { + leafLinks = []SitemapLink{} + } + + return &DiscoverResult{ + Sitemaps: leafLinks, + TotalURLs: totalURLs, + }, nil +} + +// classifyAndExpand fetches a sitemap URL to determine its type (urlset or sitemapindex). +// It uses io.TeeReader to buffer the response while detecting the root element, +// so we can re-parse sitemapindex content without a second GET request. +// For urlset at Level 0: metadata comes from GET response headers (no extra HEAD). +// For urlset at Level 1+: streaming detect then HEAD for metadata. +// Recursively expands sitemapindex files up to MaxDiscoverDepth. +func classifyAndExpand(client *http.Client, userAgent, url, source string, depth int) ([]SitemapLink, error) { + if depth > MaxDiscoverDepth { + return nil, fmt.Errorf("max discover depth exceeded") + } + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", userAgent) + + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP %d for %s", resp.StatusCode, url) + } + + if depth == 0 { + // Level 0: read full body (usually small: sitemapindex or small urlset). + // We need the body for sitemapindex parsing; for urlset we just need the type. + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read %s: %s", url, err.Error()) + } + + rootName, err := detectFormat(body) + if err != nil { + return nil, fmt.Errorf("failed to detect format for %s: %s", url, err.Error()) + } + + switch rootName { + case "urlset": + link := SitemapLink{URL: url, Source: source} + fillMetadataFromHeaders(&link, resp) + link.URLCount = estimateURLCount(link.ContentSize, link.Encoding) + return []SitemapLink{link}, nil + + case "sitemapindex": + result, err := parseSitemapIndex(body) + if err != nil { + return nil, err + } + var allLinks []SitemapLink + for _, entry := range result.Sitemaps { + childLinks, err := classifyAndExpand(client, userAgent, entry.Loc, "index", depth+1) + if err != nil { + continue + } + allLinks = append(allLinks, childLinks...) + } + return allLinks, nil + + default: + return nil, fmt.Errorf("unexpected root element <%s> in %s", rootName, url) + } + } + + // Level 1+: streaming detect — read only until root element is found, then close. + // This avoids downloading multi-MB urlset files just to classify them. + decoder := xml.NewDecoder(resp.Body) + rootName, err := detectRootElement(decoder) + if err != nil { + return nil, fmt.Errorf("failed to detect format for %s: %s", url, err.Error()) + } + // Close the body immediately to stop downloading + resp.Body.Close() + + switch rootName { + case "urlset": + link := SitemapLink{URL: url, Source: source} + // Use HEAD to get accurate metadata without re-downloading + fillMetadataFromHEAD(client, userAgent, &link) + link.URLCount = estimateURLCount(link.ContentSize, link.Encoding) + return []SitemapLink{link}, nil + + case "sitemapindex": + // Rare: nested sitemapindex. Need to re-fetch full body to parse children. + fullBody, err := httpGetBody(client, url, userAgent) + if err != nil { + return nil, fmt.Errorf("failed to re-fetch sitemapindex %s: %s", url, err.Error()) + } + result, err := parseSitemapIndex([]byte(fullBody)) + if err != nil { + return nil, err + } + var allLinks []SitemapLink + for _, entry := range result.Sitemaps { + childLinks, err := classifyAndExpand(client, userAgent, entry.Loc, "index", depth+1) + if err != nil { + continue + } + allLinks = append(allLinks, childLinks...) + } + return allLinks, nil + + default: + return nil, fmt.Errorf("unexpected root element <%s> in %s", rootName, url) + } +} + +// detectRootElement reads XML tokens until it finds the first StartElement +// and returns its local name. +func detectRootElement(decoder *xml.Decoder) (string, error) { + for { + tok, err := decoder.Token() + if err != nil { + return "", fmt.Errorf("failed to read XML token: %s", err.Error()) + } + if se, ok := tok.(xml.StartElement); ok { + return se.Name.Local, nil + } + } +} + +// fillMetadataFromHeaders extracts sitemap metadata from HTTP response headers. +func fillMetadataFromHeaders(link *SitemapLink, resp *http.Response) { + link.ContentSize = resp.ContentLength + link.Encoding = resp.Header.Get("Content-Encoding") + link.LastModified = resp.Header.Get("Last-Modified") + link.ETag = resp.Header.Get("ETag") +} + +// fillMetadataFromHEAD performs a HEAD request and fills metadata into the SitemapLink. +func fillMetadataFromHEAD(client *http.Client, userAgent string, link *SitemapLink) { + req, err := http.NewRequest("HEAD", link.URL, nil) + if err != nil { + return + } + req.Header.Set("User-Agent", userAgent) + + resp, err := client.Do(req) + if err != nil { + return + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusOK { + fillMetadataFromHeaders(link, resp) + } +} + +// estimateURLCount estimates the number of URLs in a sitemap file based on +// Content-Length. Assumes ~300 bytes per URL for uncompressed XML, +// and a 5x compression ratio for gzip. +func estimateURLCount(contentSize int64, encoding string) int { + if contentSize <= 0 { + return 0 + } + + bytesPerURL := int64(300) + effectiveSize := contentSize + + if encoding == "gzip" || encoding == "br" { + effectiveSize = contentSize * 5 // assume 5x decompression ratio + } + + count := int(effectiveSize / bytesPerURL) + if count < 1 { + count = 1 + } + return count +} + +// httpGetBody performs a GET request and returns the response body as a string. +// Returns empty string and error on failure. +func httpGetBody(client *http.Client, url, userAgent string) (string, error) { + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return "", err + } + req.Header.Set("User-Agent", userAgent) + + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("HTTP %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + return string(body), nil +} diff --git a/sitemap/fetch.go b/sitemap/fetch.go new file mode 100644 index 00000000..c17270cf --- /dev/null +++ b/sitemap/fetch.go @@ -0,0 +1,206 @@ +package sitemap + +import ( + "compress/gzip" + "encoding/xml" + "fmt" + "io" + "net/http" + "time" +) + +// Fetch retrieves and parses sitemap URLs for a domain, supporting offset/limit pagination. +// It first calls Discover to get the sitemap file list, then uses smart offset/limit +// to determine which files to actually download. Stream parsing ensures low memory usage. +func Fetch(domain string, opts *FetchOptions) (*FetchResult, error) { + if opts == nil { + opts = &FetchOptions{} + } + + // Apply defaults + userAgent := opts.UserAgent + if userAgent == "" { + userAgent = DefaultUserAgent + } + timeout := opts.Timeout + if timeout <= 0 { + timeout = DefaultTimeout + } + limit := opts.Limit + if limit <= 0 || limit > MaxURLsPerFile { + limit = MaxURLsPerFile + } + offset := opts.Offset + if offset < 0 { + offset = 0 + } + + // Step 1: Discover sitemap files + discoverOpts := &DiscoverOptions{ + UserAgent: userAgent, + Timeout: timeout, + } + discovered, err := Discover(domain, discoverOpts) + if err != nil { + return nil, fmt.Errorf("discover failed: %s", err.Error()) + } + + if len(discovered.Sitemaps) == 0 { + return &FetchResult{URLs: []URL{}, Total: 0}, nil + } + + client := &http.Client{Timeout: time.Duration(timeout) * time.Second} + + // Step 2: Use estimated URL counts to skip files before offset + var collected []URL + remaining := limit + skipped := 0 // total URLs skipped so far (via file skipping + stream skipping) + totalPrecise := 0 + totalEstimated := 0 + + for i, sitemapLink := range discovered.Sitemaps { + if remaining <= 0 { + // We have enough URLs. Add estimated totals for remaining files. + for j := i; j < len(discovered.Sitemaps); j++ { + totalEstimated += discovered.Sitemaps[j].URLCount + } + break + } + + estimatedCount := sitemapLink.URLCount + if estimatedCount <= 0 { + estimatedCount = 1 // at least try to fetch + } + + // Can we skip this entire file? + if skipped+estimatedCount <= offset { + skipped += estimatedCount + totalEstimated += estimatedCount + continue + } + + // We need to stream-parse this file + skipInFile := 0 + if skipped < offset { + skipInFile = offset - skipped + } + + urls, fileTotal, err := streamParseURLs(client, userAgent, sitemapLink, skipInFile, remaining) + if err != nil { + // Skip this file on error, use estimate for total + totalEstimated += estimatedCount + skipped += estimatedCount + continue + } + + collected = append(collected, urls...) + remaining -= len(urls) + skipped += skipInFile + len(urls) + totalPrecise += fileTotal + } + + total := totalPrecise + totalEstimated + + if collected == nil { + collected = []URL{} + } + + return &FetchResult{ + URLs: collected, + Total: total, + }, nil +} + +// streamParseURLs streams a sitemap file via HTTP GET, skipping `skip` URLs +// and collecting up to `limit` URLs. Returns the collected URLs and the actual +// total number of URLs in the file (for precise counting). +func streamParseURLs(client *http.Client, userAgent string, link SitemapLink, skip, limit int) ([]URL, int, error) { + req, err := http.NewRequest("GET", link.URL, nil) + if err != nil { + return nil, 0, err + } + req.Header.Set("User-Agent", userAgent) + + resp, err := client.Do(req) + if err != nil { + return nil, 0, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, 0, fmt.Errorf("HTTP %d for %s", resp.StatusCode, link.URL) + } + + // Handle gzip decompression. + // Go's default HTTP transport auto-decompresses Content-Encoding: gzip and strips + // the header. We only need manual decompression in two cases: + // 1. The response still has Content-Encoding: gzip (transport did not handle it). + // 2. The response body is raw gzip (e.g. .xml.gz file served without Content-Encoding), + // indicated by resp.Uncompressed == false AND link.Encoding hints gzip. + var reader io.Reader = resp.Body + needGzip := resp.Header.Get("Content-Encoding") == "gzip" + if !needGzip && link.Encoding == "gzip" && !resp.Uncompressed { + needGzip = true + } + if needGzip { + gz, err := gzip.NewReader(resp.Body) + if err != nil { + return nil, 0, fmt.Errorf("failed to create gzip reader: %s", err.Error()) + } + defer gz.Close() + reader = gz + } + + // Stream parse with xml.Decoder + decoder := xml.NewDecoder(reader) + var collected []URL + count := 0 // total URLs seen in this file + skipped := 0 // URLs skipped so far + gathered := 0 // URLs collected so far + + for { + tok, err := decoder.Token() + if err != nil { + if err == io.EOF { + break + } + // Tolerate partial reads if we already have enough URLs + if gathered >= limit { + break + } + return collected, count, fmt.Errorf("XML decode error: %s", err.Error()) + } + + se, ok := tok.(xml.StartElement) + if !ok || se.Name.Local != "url" { + continue + } + + // Decode the <url> element + var u URL + if err := decoder.DecodeElement(&u, &se); err != nil { + continue // skip malformed entries + } + count++ + + // Skip phase + if skipped < skip { + skipped++ + continue + } + + // Collect phase + if gathered < limit { + collected = append(collected, u) + gathered++ + + // We have enough — close the connection to stop downloading + if gathered >= limit { + resp.Body.Close() + break + } + } + } + + return collected, count, nil +} diff --git a/sitemap/fetch_test.go b/sitemap/fetch_test.go new file mode 100644 index 00000000..735b369c --- /dev/null +++ b/sitemap/fetch_test.go @@ -0,0 +1,448 @@ +package sitemap + +import ( + "compress/gzip" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// testSitemapXML is a small urlset for fetch testing. +const testSitemapXML = `<?xml version="1.0" encoding="UTF-8"?> +<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> + <url><loc>https://example.com/page1</loc><lastmod>2025-01-01</lastmod></url> + <url><loc>https://example.com/page2</loc><lastmod>2025-02-01</lastmod></url> + <url><loc>https://example.com/page3</loc><lastmod>2025-03-01</lastmod></url> + <url><loc>https://example.com/page4</loc></url> + <url><loc>https://example.com/page5</loc></url> +</urlset>` + +// buildSitemapIndex returns a sitemapindex XML referencing the given sitemap URLs. +func buildSitemapIndex(urls ...string) string { + var sb strings.Builder + sb.WriteString(`<?xml version="1.0" encoding="UTF-8"?>`) + sb.WriteString(`<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">`) + for _, u := range urls { + sb.WriteString(fmt.Sprintf(`<sitemap><loc>%s</loc></sitemap>`, u)) + } + sb.WriteString(`</sitemapindex>`) + return sb.String() +} + +// ==================== streamParseURLs tests ==================== + +func TestStreamParseURLs_Basic(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/xml") + w.WriteHeader(http.StatusOK) + w.Write([]byte(testSitemapXML)) + })) + defer server.Close() + + client := server.Client() + link := SitemapLink{URL: server.URL + "/sitemap.xml"} + + urls, total, err := streamParseURLs(client, DefaultUserAgent, link, 0, 100) + if err != nil { + t.Fatalf("streamParseURLs failed: %s", err.Error()) + } + if total != 5 { + t.Errorf("expected total=5, got %d", total) + } + if len(urls) != 5 { + t.Errorf("expected 5 URLs, got %d", len(urls)) + } + if urls[0].Loc != "https://example.com/page1" { + t.Errorf("unexpected first URL: %s", urls[0].Loc) + } +} + +func TestStreamParseURLs_WithSkip(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(testSitemapXML)) + })) + defer server.Close() + + client := server.Client() + link := SitemapLink{URL: server.URL + "/sitemap.xml"} + + // Skip 2, take up to 100 + urls, total, err := streamParseURLs(client, DefaultUserAgent, link, 2, 100) + if err != nil { + t.Fatalf("streamParseURLs failed: %s", err.Error()) + } + if total != 5 { + t.Errorf("expected total=5, got %d", total) + } + if len(urls) != 3 { + t.Errorf("expected 3 URLs (5 - 2 skipped), got %d", len(urls)) + } + if urls[0].Loc != "https://example.com/page3" { + t.Errorf("expected page3 as first result after skip, got %s", urls[0].Loc) + } +} + +func TestStreamParseURLs_WithLimit(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(testSitemapXML)) + })) + defer server.Close() + + client := server.Client() + link := SitemapLink{URL: server.URL + "/sitemap.xml"} + + // No skip, limit 2 + urls, _, err := streamParseURLs(client, DefaultUserAgent, link, 0, 2) + if err != nil { + t.Fatalf("streamParseURLs failed: %s", err.Error()) + } + if len(urls) != 2 { + t.Errorf("expected 2 URLs (limit=2), got %d", len(urls)) + } + if urls[0].Loc != "https://example.com/page1" { + t.Errorf("unexpected first URL: %s", urls[0].Loc) + } + if urls[1].Loc != "https://example.com/page2" { + t.Errorf("unexpected second URL: %s", urls[1].Loc) + } +} + +func TestStreamParseURLs_SkipAndLimit(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(testSitemapXML)) + })) + defer server.Close() + + client := server.Client() + link := SitemapLink{URL: server.URL + "/sitemap.xml"} + + // Skip 1, limit 2 + urls, _, err := streamParseURLs(client, DefaultUserAgent, link, 1, 2) + if err != nil { + t.Fatalf("streamParseURLs failed: %s", err.Error()) + } + if len(urls) != 2 { + t.Errorf("expected 2 URLs, got %d", len(urls)) + } + if urls[0].Loc != "https://example.com/page2" { + t.Errorf("expected page2, got %s", urls[0].Loc) + } + if urls[1].Loc != "https://example.com/page3" { + t.Errorf("expected page3, got %s", urls[1].Loc) + } +} + +func TestStreamParseURLs_SkipAll(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(testSitemapXML)) + })) + defer server.Close() + + client := server.Client() + link := SitemapLink{URL: server.URL + "/sitemap.xml"} + + // Skip more than total + urls, total, err := streamParseURLs(client, DefaultUserAgent, link, 100, 50) + if err != nil { + t.Fatalf("streamParseURLs failed: %s", err.Error()) + } + if len(urls) != 0 { + t.Errorf("expected 0 URLs when skip > total, got %d", len(urls)) + } + if total != 5 { + t.Errorf("expected total=5, got %d", total) + } +} + +func TestStreamParseURLs_Gzip(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Go's default transport auto-decompresses gzip when Content-Encoding + // is set. To test our manual gzip handling, we use a custom content type + // and set the Encoding on the SitemapLink instead. Here we just serve + // raw gzip bytes without Content-Encoding header so Go won't auto-decompress. + w.Header().Set("Content-Type", "application/x-gzip") + w.WriteHeader(http.StatusOK) + gz := gzip.NewWriter(w) + gz.Write([]byte(testSitemapXML)) + gz.Close() + })) + defer server.Close() + + client := server.Client() + // SitemapLink.Encoding = "gzip" triggers our manual decompression path + link := SitemapLink{URL: server.URL + "/sitemap.xml.gz", Encoding: "gzip"} + + urls, total, err := streamParseURLs(client, DefaultUserAgent, link, 0, 100) + if err != nil { + t.Fatalf("streamParseURLs with gzip failed: %s", err.Error()) + } + if total != 5 { + t.Errorf("expected total=5, got %d", total) + } + if len(urls) != 5 { + t.Errorf("expected 5 URLs, got %d", len(urls)) + } +} + +func TestStreamParseURLs_HTTP404(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + client := server.Client() + link := SitemapLink{URL: server.URL + "/missing.xml"} + + _, _, err := streamParseURLs(client, DefaultUserAgent, link, 0, 100) + if err == nil { + t.Error("expected error for 404") + } +} + +// ==================== Discover tests (with httptest mock site) ==================== + +func TestDiscover_SingleURLSet(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/sitemap.xml": + w.Header().Set("Content-Type", "application/xml") + w.WriteHeader(http.StatusOK) + w.Write([]byte(testSitemapXML)) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + // Discover hardcodes https:// prefix, but we need to use the httptest server. + // Test classifyAndExpand directly instead. + client := server.Client() + links, err := classifyAndExpand(client, DefaultUserAgent, server.URL+"/sitemap.xml", "well-known", 0) + if err != nil { + t.Fatalf("classifyAndExpand failed: %s", err.Error()) + } + if len(links) != 1 { + t.Fatalf("expected 1 link, got %d", len(links)) + } + if links[0].URL != server.URL+"/sitemap.xml" { + t.Errorf("unexpected URL: %s", links[0].URL) + } + if links[0].Source != "well-known" { + t.Errorf("unexpected source: %s", links[0].Source) + } +} + +func TestDiscover_SitemapIndex(t *testing.T) { + var serverURL string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/sitemap_index.xml": + indexXML := buildSitemapIndex( + serverURL+"/sitemap1.xml", + serverURL+"/sitemap2.xml", + ) + w.WriteHeader(http.StatusOK) + w.Write([]byte(indexXML)) + case "/sitemap1.xml": + w.Header().Set("Content-Length", "900") + w.WriteHeader(http.StatusOK) + w.Write([]byte(testSitemapXML)) + case "/sitemap2.xml": + w.Header().Set("Content-Length", "900") + w.WriteHeader(http.StatusOK) + w.Write([]byte(testSitemapXML)) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + serverURL = server.URL + + client := server.Client() + links, err := classifyAndExpand(client, DefaultUserAgent, server.URL+"/sitemap_index.xml", "robots.txt", 0) + if err != nil { + t.Fatalf("classifyAndExpand for index failed: %s", err.Error()) + } + if len(links) != 2 { + t.Fatalf("expected 2 leaf sitemaps, got %d", len(links)) + } + if links[0].URL != server.URL+"/sitemap1.xml" { + t.Errorf("unexpected first link: %s", links[0].URL) + } + if links[1].URL != server.URL+"/sitemap2.xml" { + t.Errorf("unexpected second link: %s", links[1].URL) + } +} + +func TestDiscover_UnreachableSitemap(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + client := server.Client() + _, err := classifyAndExpand(client, DefaultUserAgent, server.URL+"/sitemap.xml", "test", 0) + if err == nil { + t.Error("expected error for 500 response") + } +} + +// ==================== estimateURLCount tests ==================== + +func TestEstimateURLCount(t *testing.T) { + tests := []struct { + size int64 + encoding string + expected int + }{ + {0, "", 0}, + {-1, "", 0}, + {300, "", 1}, + {3000, "", 10}, + {150, "", 1}, + {600, "gzip", 10}, // 600 * 5 / 300 = 10 + {600, "br", 10}, // same ratio + {3000, "gzip", 50}, // 3000 * 5 / 300 = 50 + } + + for _, tt := range tests { + got := estimateURLCount(tt.size, tt.encoding) + if got != tt.expected { + t.Errorf("estimateURLCount(%d, %q) = %d, want %d", tt.size, tt.encoding, got, tt.expected) + } + } +} + +// ==================== httpGetBody tests ==================== + +func TestHTTPGetBody(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("hello world")) + })) + defer server.Close() + + client := server.Client() + body, err := httpGetBody(client, server.URL, DefaultUserAgent) + if err != nil { + t.Fatalf("httpGetBody failed: %s", err.Error()) + } + if body != "hello world" { + t.Errorf("expected 'hello world', got '%s'", body) + } +} + +func TestHTTPGetBody_404(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + client := server.Client() + _, err := httpGetBody(client, server.URL, DefaultUserAgent) + if err == nil { + t.Error("expected error for 404") + } +} + +// ==================== End-to-end Fetch via streamParseURLs ==================== + +func TestFetchEndToEnd_Pagination(t *testing.T) { + // Build a sitemap with 10 URLs + var sb strings.Builder + sb.WriteString(`<?xml version="1.0" encoding="UTF-8"?>`) + sb.WriteString(`<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">`) + for i := 1; i <= 10; i++ { + sb.WriteString(fmt.Sprintf(`<url><loc>https://example.com/p%d</loc></url>`, i)) + } + sb.WriteString(`</urlset>`) + tenURLsSitemap := sb.String() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(tenURLsSitemap)) + })) + defer server.Close() + + client := server.Client() + link := SitemapLink{URL: server.URL + "/sitemap.xml"} + + // Page 1: offset=0, limit=3 + urls1, _, err := streamParseURLs(client, DefaultUserAgent, link, 0, 3) + if err != nil { + t.Fatalf("page 1 failed: %s", err.Error()) + } + if len(urls1) != 3 { + t.Fatalf("page 1: expected 3, got %d", len(urls1)) + } + if urls1[0].Loc != "https://example.com/p1" { + t.Errorf("page 1 first: expected p1, got %s", urls1[0].Loc) + } + if urls1[2].Loc != "https://example.com/p3" { + t.Errorf("page 1 last: expected p3, got %s", urls1[2].Loc) + } + + // Page 2: offset=3, limit=3 + urls2, _, err := streamParseURLs(client, DefaultUserAgent, link, 3, 3) + if err != nil { + t.Fatalf("page 2 failed: %s", err.Error()) + } + if len(urls2) != 3 { + t.Fatalf("page 2: expected 3, got %d", len(urls2)) + } + if urls2[0].Loc != "https://example.com/p4" { + t.Errorf("page 2 first: expected p4, got %s", urls2[0].Loc) + } + + // Page 4: offset=9, limit=3 — should get only 1 URL + urls4, _, err := streamParseURLs(client, DefaultUserAgent, link, 9, 3) + if err != nil { + t.Fatalf("page 4 failed: %s", err.Error()) + } + if len(urls4) != 1 { + t.Fatalf("page 4: expected 1, got %d", len(urls4)) + } + if urls4[0].Loc != "https://example.com/p10" { + t.Errorf("page 4: expected p10, got %s", urls4[0].Loc) + } +} + +// ==================== fillMetadataFromHeaders test ==================== + +func TestFillMetadataFromHeaders(t *testing.T) { + body := "hello world test body" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Last-Modified", "Tue, 01 Jan 2025 00:00:00 GMT") + w.Header().Set("ETag", `"etag123"`) + w.WriteHeader(http.StatusOK) + w.Write([]byte(body)) + })) + defer server.Close() + + client := server.Client() + resp, err := client.Get(server.URL) + if err != nil { + t.Fatalf("GET failed: %s", err.Error()) + } + defer resp.Body.Close() + + link := SitemapLink{URL: server.URL} + fillMetadataFromHeaders(&link, resp) + + // Content-Length is set automatically by httptest when body is written + if link.ContentSize < 0 { + t.Errorf("expected non-negative ContentSize, got %d", link.ContentSize) + } + if link.LastModified != "Tue, 01 Jan 2025 00:00:00 GMT" { + t.Errorf("unexpected LastModified: %s", link.LastModified) + } + if link.ETag != `"etag123"` { + t.Errorf("unexpected ETag: %s", link.ETag) + } +} diff --git a/sitemap/parse.go b/sitemap/parse.go new file mode 100644 index 00000000..a2da7bfc --- /dev/null +++ b/sitemap/parse.go @@ -0,0 +1,128 @@ +package sitemap + +import ( + "bytes" + "encoding/xml" + "fmt" + "strings" +) + +// Parse parses a sitemap XML string and returns a ParseResult. +// It auto-detects whether the input is a <urlset> or <sitemapindex>. +func Parse(data string) (*ParseResult, error) { + trimmed := strings.TrimSpace(data) + if trimmed == "" { + return nil, fmt.Errorf("input is empty") + } + + format, err := detectFormat([]byte(trimmed)) + if err != nil { + return nil, err + } + + switch format { + case "urlset": + return parseURLSet([]byte(trimmed)) + case "sitemapindex": + return parseSitemapIndex([]byte(trimmed)) + default: + return nil, fmt.Errorf("unknown sitemap format: root element is <%s>, expected <urlset> or <sitemapindex>", format) + } +} + +// Validate checks whether the input string is a valid sitemap XML. +// Returns nil on success, or a descriptive error explaining what is wrong. +func Validate(data string) error { + trimmed := strings.TrimSpace(data) + if trimmed == "" { + return fmt.Errorf("input is empty") + } + + // Check basic XML validity + decoder := xml.NewDecoder(bytes.NewReader([]byte(trimmed))) + for { + _, err := decoder.Token() + if err != nil { + if err.Error() == "EOF" { + break + } + return fmt.Errorf("not valid XML: %s", err.Error()) + } + } + + // Detect format + format, err := detectFormat([]byte(trimmed)) + if err != nil { + return err + } + + // Full parse to check structure + switch format { + case "urlset": + result, err := parseURLSet([]byte(trimmed)) + if err != nil { + return err + } + // Check that every <url> has a <loc> + for i, u := range result.URLs { + if strings.TrimSpace(u.Loc) == "" { + return fmt.Errorf("urlset <url> at index %d is missing required <loc> element", i) + } + } + + case "sitemapindex": + result, err := parseSitemapIndex([]byte(trimmed)) + if err != nil { + return err + } + // Check that every <sitemap> has a <loc> + for i, s := range result.Sitemaps { + if strings.TrimSpace(s.Loc) == "" { + return fmt.Errorf("sitemapindex <sitemap> at index %d is missing required <loc> element", i) + } + } + + default: + return fmt.Errorf("root element is <%s>, expected <urlset> or <sitemapindex>", format) + } + + return nil +} + +// detectFormat reads the XML to find the root element name. +func detectFormat(data []byte) (string, error) { + decoder := xml.NewDecoder(bytes.NewReader(data)) + for { + tok, err := decoder.Token() + if err != nil { + return "", fmt.Errorf("failed to detect sitemap format: %s", err.Error()) + } + if se, ok := tok.(xml.StartElement); ok { + return se.Name.Local, nil + } + } +} + +// parseURLSet parses a <urlset> XML document. +func parseURLSet(data []byte) (*ParseResult, error) { + var urlset xmlURLSet + if err := xml.Unmarshal(data, &urlset); err != nil { + return nil, fmt.Errorf("failed to parse urlset: %s", err.Error()) + } + return &ParseResult{ + Type: "urlset", + URLs: urlset.URLs, + }, nil +} + +// parseSitemapIndex parses a <sitemapindex> XML document. +func parseSitemapIndex(data []byte) (*ParseResult, error) { + var idx xmlSitemapIndex + if err := xml.Unmarshal(data, &idx); err != nil { + return nil, fmt.Errorf("failed to parse sitemapindex: %s", err.Error()) + } + return &ParseResult{ + Type: "sitemapindex", + Sitemaps: idx.Sitemaps, + }, nil +} diff --git a/sitemap/parse_test.go b/sitemap/parse_test.go new file mode 100644 index 00000000..0d73d34f --- /dev/null +++ b/sitemap/parse_test.go @@ -0,0 +1,174 @@ +package sitemap + +import ( + "testing" +) + +const testURLSetXML = `<?xml version="1.0" encoding="UTF-8"?> +<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" + xmlns:image="http://www.google.com/schemas/sitemap-image/1.1" + xmlns:video="http://www.google.com/schemas/sitemap-video/1.1" + xmlns:news="http://www.google.com/schemas/sitemap-news/0.9"> + <url> + <loc>https://example.com/page1</loc> + <lastmod>2025-01-01</lastmod> + <changefreq>daily</changefreq> + <priority>0.8</priority> + </url> + <url> + <loc>https://example.com/page2</loc> + <lastmod>2025-06-15</lastmod> + <priority>0.5</priority> + </url> + <url> + <loc>https://example.com/gallery</loc> + <image:image> + <image:loc>https://example.com/img/photo1.jpg</image:loc> + <image:caption>A beautiful photo</image:caption> + </image:image> + <image:image> + <image:loc>https://example.com/img/photo2.jpg</image:loc> + </image:image> + </url> +</urlset>` + +const testSitemapIndexXML = `<?xml version="1.0" encoding="UTF-8"?> +<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> + <sitemap> + <loc>https://example.com/sitemap1.xml</loc> + <lastmod>2025-01-01</lastmod> + </sitemap> + <sitemap> + <loc>https://example.com/sitemap2.xml</loc> + <lastmod>2025-06-15</lastmod> + </sitemap> +</sitemapindex>` + +func TestParseURLSet(t *testing.T) { + result, err := Parse(testURLSetXML) + if err != nil { + t.Fatalf("Parse urlset failed: %s", err.Error()) + } + + if result.Type != "urlset" { + t.Errorf("expected type 'urlset', got '%s'", result.Type) + } + + if len(result.URLs) != 3 { + t.Fatalf("expected 3 URLs, got %d", len(result.URLs)) + } + + // Check first URL + u := result.URLs[0] + if u.Loc != "https://example.com/page1" { + t.Errorf("expected loc 'https://example.com/page1', got '%s'", u.Loc) + } + if u.LastMod != "2025-01-01" { + t.Errorf("expected lastmod '2025-01-01', got '%s'", u.LastMod) + } + if u.ChangeFreq != "daily" { + t.Errorf("expected changefreq 'daily', got '%s'", u.ChangeFreq) + } + if u.Priority != "0.8" { + t.Errorf("expected priority '0.8', got '%s'", u.Priority) + } + + // Check third URL with images + u3 := result.URLs[2] + if len(u3.Images) != 2 { + t.Fatalf("expected 2 images, got %d", len(u3.Images)) + } + if u3.Images[0].Loc != "https://example.com/img/photo1.jpg" { + t.Errorf("expected image loc, got '%s'", u3.Images[0].Loc) + } + if u3.Images[0].Caption != "A beautiful photo" { + t.Errorf("expected caption 'A beautiful photo', got '%s'", u3.Images[0].Caption) + } + + // Sitemaps should be nil/empty + if len(result.Sitemaps) != 0 { + t.Errorf("expected empty sitemaps, got %d", len(result.Sitemaps)) + } +} + +func TestParseSitemapIndex(t *testing.T) { + result, err := Parse(testSitemapIndexXML) + if err != nil { + t.Fatalf("Parse sitemapindex failed: %s", err.Error()) + } + + if result.Type != "sitemapindex" { + t.Errorf("expected type 'sitemapindex', got '%s'", result.Type) + } + + if len(result.Sitemaps) != 2 { + t.Fatalf("expected 2 sitemaps, got %d", len(result.Sitemaps)) + } + + if result.Sitemaps[0].Loc != "https://example.com/sitemap1.xml" { + t.Errorf("unexpected sitemap loc: %s", result.Sitemaps[0].Loc) + } + if result.Sitemaps[0].LastMod != "2025-01-01" { + t.Errorf("unexpected lastmod: %s", result.Sitemaps[0].LastMod) + } + + // URLs should be nil/empty + if len(result.URLs) != 0 { + t.Errorf("expected empty URLs, got %d", len(result.URLs)) + } +} + +func TestParseEmpty(t *testing.T) { + _, err := Parse("") + if err == nil { + t.Error("expected error for empty input") + } +} + +func TestParseInvalidXML(t *testing.T) { + _, err := Parse("<not-a-sitemap><foo></foo></not-a-sitemap>") + if err == nil { + t.Error("expected error for non-sitemap XML") + } +} + +func TestValidateURLSet(t *testing.T) { + err := Validate(testURLSetXML) + if err != nil { + t.Errorf("expected valid urlset, got: %s", err.Error()) + } +} + +func TestValidateSitemapIndex(t *testing.T) { + err := Validate(testSitemapIndexXML) + if err != nil { + t.Errorf("expected valid sitemapindex, got: %s", err.Error()) + } +} + +func TestValidateEmpty(t *testing.T) { + err := Validate("") + if err == nil { + t.Error("expected error for empty input") + } +} + +func TestValidateMissingLoc(t *testing.T) { + xml := `<?xml version="1.0" encoding="UTF-8"?> +<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> + <url> + <lastmod>2025-01-01</lastmod> + </url> +</urlset>` + err := Validate(xml) + if err == nil { + t.Error("expected error for missing <loc>") + } +} + +func TestValidateInvalidXML(t *testing.T) { + err := Validate("<urlset><url><loc>test</url></urlset>") + if err == nil { + t.Error("expected error for malformed XML") + } +} diff --git a/sitemap/process.go b/sitemap/process.go new file mode 100644 index 00000000..8b9695e0 --- /dev/null +++ b/sitemap/process.go @@ -0,0 +1,254 @@ +package sitemap + +import ( + "github.com/yaoapp/gou/process" + "github.com/yaoapp/kun/exception" +) + +func init() { + process.RegisterGroup("sitemap", map[string]process.Handler{ + "parse": processParse, + "validate": processValidate, + "parserobo": processParseRobots, + "discover": processDiscover, + "fetch": processFetch, + "build.open": processBuildOpen, + "build.write": processBuildWrite, + "build.close": processBuildClose, + }) +} + +// processParse handles the sitemap.Parse process. +// Parses a sitemap XML string and returns a unified ParseResult. +// Auto-detects <urlset> or <sitemapindex> format. +// +// Args: +// - data string - The sitemap XML string to parse +// +// Returns: ParseResult {type, urls, sitemaps} +// +// Usage: +// +// var result = Process("sitemap.Parse", xmlString) +// // result.type → "urlset" or "sitemapindex" +// // result.urls → [{loc: "https://example.com/page1", ...}, ...] +// // result.sitemaps → [{loc: "https://example.com/sitemap1.xml", ...}, ...] +func processParse(p *process.Process) interface{} { + p.ValidateArgNums(1) + data := p.ArgsString(0) + + result, err := Parse(data) + if err != nil { + exception.New("sitemap.parse error: %s", 500, err).Throw() + } + return result +} + +// processValidate handles the sitemap.Validate process. +// Checks whether the input string is a valid sitemap XML. +// +// Args: +// - data string - The sitemap XML string to validate +// +// Returns: +// - true (bool) if the sitemap is valid +// - error description string if invalid (AI-friendly message) +// +// Usage: +// +// var result = Process("sitemap.Validate", xmlString) +// if (result !== true) { +// console.log("Invalid sitemap: " + result) +// } +func processValidate(p *process.Process) interface{} { + p.ValidateArgNums(1) + data := p.ArgsString(0) + + err := Validate(data) + if err != nil { + return err.Error() + } + return true +} + +// processParseRobots handles the sitemap.ParseRobo process. +// Extracts sitemap URLs from robots.txt content. Pure text parsing, no HTTP. +// +// Args: +// - text string - The robots.txt content +// +// Returns: array of sitemap URL strings +// +// Usage: +// +// var urls = Process("sitemap.ParseRobo", robotsTxtContent) +// // urls → ["https://example.com/sitemap.xml", "https://example.com/sitemap2.xml"] +func processParseRobots(p *process.Process) interface{} { + p.ValidateArgNums(1) + text := p.ArgsString(0) + + urls := ParseRobots(text) + return urls +} + +// processDiscover handles the sitemap.Discover process. +// Discovers sitemap files for a given domain via robots.txt and well-known paths. +// Recursively expands sitemapindex files. Minimizes bandwidth usage. +// +// Args: +// - domain string - The domain to discover sitemaps for (e.g. "example.com") +// - options map (optional) - {user_agent, timeout} +// +// Returns: DiscoverResult {sitemaps: [{url, source, url_count, ...}], total_urls} +// +// Usage: +// +// var result = Process("sitemap.Discover", "example.com") +// // result.sitemaps → [{url: "https://example.com/sitemap.xml", url_count: 500, ...}] +// // result.total_urls → 500 +// +// // With options +// var result = Process("sitemap.Discover", "example.com", {user_agent: "MyBot/1.0", timeout: 60}) +func processDiscover(p *process.Process) interface{} { + p.ValidateArgNums(1) + domain := p.ArgsString(0) + + var opts *DiscoverOptions + if len(p.Args) > 1 { + o, err := mapToDiscoverOptions(p.Args[1]) + if err != nil { + exception.New("sitemap.discover error: %s", 500, err).Throw() + } + opts = o + } + + result, err := Discover(domain, opts) + if err != nil { + exception.New("sitemap.discover error: %s", 500, err).Throw() + } + return result +} + +// processFetch handles the sitemap.Fetch process. +// Fetches and parses URLs from sitemaps for a domain with pagination support. +// Uses Discover internally and supports offset/limit for large sitemaps. +// +// Args: +// - domain string - The domain to fetch sitemaps for (e.g. "example.com") +// - options map (optional) - {offset, limit, user_agent, timeout} +// +// Returns: FetchResult {urls: [{loc, lastmod, ...}], total} +// +// Usage: +// +// // Fetch first page +// var page1 = Process("sitemap.Fetch", "example.com", {limit: 100}) +// // page1.urls → [{loc: "https://example.com/page1", ...}, ...] +// // page1.total → 5000 +// +// // Fetch second page +// var page2 = Process("sitemap.Fetch", "example.com", {offset: 100, limit: 100}) +func processFetch(p *process.Process) interface{} { + p.ValidateArgNums(1) + domain := p.ArgsString(0) + + var opts *FetchOptions + if len(p.Args) > 1 { + o, err := mapToFetchOptions(p.Args[1]) + if err != nil { + exception.New("sitemap.fetch error: %s", 500, err).Throw() + } + opts = o + } + + result, err := Fetch(domain, opts) + if err != nil { + exception.New("sitemap.fetch error: %s", 500, err).Throw() + } + return result +} + +// processBuildOpen handles the sitemap.Build.Open process. +// Opens a new sitemap writer and returns a UUID handle. +// +// Args: +// - options map - {dir: "/path/to/output", base_url: "https://example.com"} +// +// Returns: handle string (UUID) +// +// Usage: +// +// var handle = Process("sitemap.Build.Open", { +// dir: "/data/sitemaps", +// base_url: "https://example.com" +// }) +func processBuildOpen(p *process.Process) interface{} { + p.ValidateArgNums(1) + + opts, err := mapToBuildOptions(p.Args[0]) + if err != nil { + exception.New("sitemap.build.open error: %s", 500, err).Throw() + } + + handle, err := BuildOpen(opts) + if err != nil { + exception.New("sitemap.build.open error: %s", 500, err).Throw() + } + return handle +} + +// processBuildWrite handles the sitemap.Build.Write process. +// Writes a batch of URLs to the open sitemap writer. +// Automatically splits into new files when 50,000 URLs per file is reached. +// +// Args: +// - handle string - The UUID handle from Build.Open +// - urls array - [{loc: "...", lastmod: "...", images: [...], ...}, ...] +// +// Returns: nil +// +// Usage: +// +// Process("sitemap.Build.Write", handle, [ +// {loc: "https://example.com/page1", lastmod: "2025-01-01", priority: "0.8"}, +// {loc: "https://example.com/page2", changefreq: "daily"}, +// ]) +func processBuildWrite(p *process.Process) interface{} { + p.ValidateArgNums(2) + handle := p.ArgsString(0) + + urls, err := mapToURLs(p.Args[1]) + if err != nil { + exception.New("sitemap.build.write error: %s", 500, err).Throw() + } + + if err := BuildWrite(handle, urls); err != nil { + exception.New("sitemap.build.write error: %s", 500, err).Throw() + } + return nil +} + +// processBuildClose handles the sitemap.Build.Close process. +// Finalizes the sitemap output, generates index if needed, cleans up the handle. +// +// Args: +// - handle string - The UUID handle from Build.Open +// +// Returns: BuildResult {index, files, total} +// +// Usage: +// +// var result = Process("sitemap.Build.Close", handle) +// // result.index → "/data/sitemaps/sitemap_index.xml" (empty if single file) +// // result.files → ["/data/sitemaps/sitemap_1.xml", "/data/sitemaps/sitemap_2.xml"] +// // result.total → 75000 +func processBuildClose(p *process.Process) interface{} { + p.ValidateArgNums(1) + handle := p.ArgsString(0) + + result, err := BuildClose(handle) + if err != nil { + exception.New("sitemap.build.close error: %s", 500, err).Throw() + } + return result +} diff --git a/sitemap/robots.go b/sitemap/robots.go new file mode 100644 index 00000000..ae030653 --- /dev/null +++ b/sitemap/robots.go @@ -0,0 +1,37 @@ +package sitemap + +import ( + "regexp" + "strings" +) + +// reSitemapLine matches "Sitemap:" directives in robots.txt (case-insensitive). +var reSitemapLine = regexp.MustCompile(`(?im)^\s*Sitemap:\s*(.+?)\s*$`) + +// ParseRobots extracts sitemap URLs from a robots.txt text content. +// It looks for lines matching "Sitemap: <url>" (case-insensitive). +// Returns a deduplicated list of sitemap URLs. +func ParseRobots(text string) []string { + matches := reSitemapLine.FindAllStringSubmatch(text, -1) + if len(matches) == 0 { + return []string{} + } + + seen := make(map[string]bool, len(matches)) + var urls []string + for _, m := range matches { + u := strings.TrimSpace(m[1]) + if u == "" { + continue + } + if !seen[u] { + seen[u] = true + urls = append(urls, u) + } + } + + if urls == nil { + return []string{} + } + return urls +} diff --git a/sitemap/robots_test.go b/sitemap/robots_test.go new file mode 100644 index 00000000..31d4a5ef --- /dev/null +++ b/sitemap/robots_test.go @@ -0,0 +1,76 @@ +package sitemap + +import ( + "testing" +) + +func TestParseRobotsBasic(t *testing.T) { + text := `User-agent: * +Disallow: /private/ + +Sitemap: https://example.com/sitemap.xml +Sitemap: https://example.com/sitemap-news.xml +` + urls := ParseRobots(text) + if len(urls) != 2 { + t.Fatalf("expected 2 URLs, got %d", len(urls)) + } + if urls[0] != "https://example.com/sitemap.xml" { + t.Errorf("unexpected URL: %s", urls[0]) + } + if urls[1] != "https://example.com/sitemap-news.xml" { + t.Errorf("unexpected URL: %s", urls[1]) + } +} + +func TestParseRobotsCaseInsensitive(t *testing.T) { + text := `sitemap: https://example.com/sitemap1.xml +SITEMAP: https://example.com/sitemap2.xml +SiteMap: https://example.com/sitemap3.xml +` + urls := ParseRobots(text) + if len(urls) != 3 { + t.Fatalf("expected 3 URLs, got %d", len(urls)) + } +} + +func TestParseRobotsDuplicate(t *testing.T) { + text := `Sitemap: https://example.com/sitemap.xml +Sitemap: https://example.com/sitemap.xml +Sitemap: https://example.com/other.xml +` + urls := ParseRobots(text) + if len(urls) != 2 { + t.Fatalf("expected 2 URLs (deduplicated), got %d", len(urls)) + } +} + +func TestParseRobotsEmpty(t *testing.T) { + urls := ParseRobots("") + if len(urls) != 0 { + t.Errorf("expected 0 URLs, got %d", len(urls)) + } +} + +func TestParseRobotsNoSitemapDirective(t *testing.T) { + text := `User-agent: * +Disallow: / +` + urls := ParseRobots(text) + if len(urls) != 0 { + t.Errorf("expected 0 URLs, got %d", len(urls)) + } +} + +func TestParseRobotsWithWhitespace(t *testing.T) { + text := ` Sitemap: https://example.com/sitemap.xml + Sitemap: https://example.com/other.xml +` + urls := ParseRobots(text) + if len(urls) != 2 { + t.Fatalf("expected 2 URLs, got %d", len(urls)) + } + if urls[0] != "https://example.com/sitemap.xml" { + t.Errorf("unexpected URL (whitespace not trimmed): '%s'", urls[0]) + } +} diff --git a/sitemap/types.go b/sitemap/types.go new file mode 100644 index 00000000..46e5e5ab --- /dev/null +++ b/sitemap/types.go @@ -0,0 +1,188 @@ +package sitemap + +import ( + "encoding/xml" + "os" + "sync" +) + +// ==================== Sitemap URL & Extensions ==================== + +// URL represents a single page entry in a sitemap <urlset>. +type URL struct { + XMLName xml.Name `json:"-" xml:"url"` + Loc string `json:"loc" xml:"loc"` + LastMod string `json:"lastmod,omitempty" xml:"lastmod,omitempty"` + ChangeFreq string `json:"changefreq,omitempty" xml:"changefreq,omitempty"` + Priority string `json:"priority,omitempty" xml:"priority,omitempty"` + Images []Image `json:"images,omitempty" xml:"http://www.google.com/schemas/sitemap-image/1.1 image,omitempty"` + Videos []Video `json:"videos,omitempty" xml:"http://www.google.com/schemas/sitemap-video/1.1 video,omitempty"` + News *News `json:"news,omitempty" xml:"http://www.google.com/schemas/sitemap-news/0.9 news,omitempty"` +} + +// Image represents a Google image sitemap extension entry. +// Namespace: http://www.google.com/schemas/sitemap-image/1.1 +type Image struct { + XMLName xml.Name `json:"-" xml:"http://www.google.com/schemas/sitemap-image/1.1 image"` + Loc string `json:"loc" xml:"http://www.google.com/schemas/sitemap-image/1.1 loc"` + Caption string `json:"caption,omitempty" xml:"http://www.google.com/schemas/sitemap-image/1.1 caption,omitempty"` + Title string `json:"title,omitempty" xml:"http://www.google.com/schemas/sitemap-image/1.1 title,omitempty"` + License string `json:"license,omitempty" xml:"http://www.google.com/schemas/sitemap-image/1.1 license,omitempty"` +} + +// Video represents a Google video sitemap extension entry. +// Namespace: http://www.google.com/schemas/sitemap-video/1.1 +type Video struct { + XMLName xml.Name `json:"-" xml:"http://www.google.com/schemas/sitemap-video/1.1 video"` + ThumbnailLoc string `json:"thumbnail_loc" xml:"http://www.google.com/schemas/sitemap-video/1.1 thumbnail_loc"` + Title string `json:"title" xml:"http://www.google.com/schemas/sitemap-video/1.1 title"` + Description string `json:"description" xml:"http://www.google.com/schemas/sitemap-video/1.1 description"` + ContentLoc string `json:"content_loc,omitempty" xml:"http://www.google.com/schemas/sitemap-video/1.1 content_loc,omitempty"` + PlayerLoc string `json:"player_loc,omitempty" xml:"http://www.google.com/schemas/sitemap-video/1.1 player_loc,omitempty"` + Duration int `json:"duration,omitempty" xml:"http://www.google.com/schemas/sitemap-video/1.1 duration,omitempty"` + PublicationDate string `json:"publication_date,omitempty" xml:"http://www.google.com/schemas/sitemap-video/1.1 publication_date,omitempty"` +} + +// News represents a Google news sitemap extension entry. +// Namespace: http://www.google.com/schemas/sitemap-news/0.9 +type News struct { + XMLName xml.Name `json:"-" xml:"http://www.google.com/schemas/sitemap-news/0.9 news"` + Publication Publication `json:"publication" xml:"http://www.google.com/schemas/sitemap-news/0.9 publication"` + PublicationDate string `json:"publication_date" xml:"http://www.google.com/schemas/sitemap-news/0.9 publication_date"` + Title string `json:"title" xml:"http://www.google.com/schemas/sitemap-news/0.9 title"` + Keywords string `json:"keywords,omitempty" xml:"http://www.google.com/schemas/sitemap-news/0.9 keywords,omitempty"` +} + +// Publication identifies the news publication for a news sitemap entry. +type Publication struct { + Name string `json:"name" xml:"name"` + Language string `json:"language" xml:"language"` +} + +// ==================== XML Document Structs (for parsing) ==================== + +// xmlURLSet is the internal XML mapping for a <urlset> document. +type xmlURLSet struct { + XMLName xml.Name `xml:"urlset"` + URLs []URL `xml:"url"` +} + +// xmlSitemapIndex is the internal XML mapping for a <sitemapindex> document. +type xmlSitemapIndex struct { + XMLName xml.Name `xml:"sitemapindex"` + Sitemaps []SitemapEntry `xml:"sitemap"` +} + +// SitemapEntry represents a single <sitemap> element inside a sitemapindex. +type SitemapEntry struct { + Loc string `json:"loc" xml:"loc"` + LastMod string `json:"lastmod,omitempty" xml:"lastmod,omitempty"` +} + +// ==================== Parse Result ==================== + +// ParseResult is the unified return type for sitemap.Parse. +// Type is "urlset" or "sitemapindex". Only the corresponding field is populated. +type ParseResult struct { + Type string `json:"type"` // "urlset" or "sitemapindex" + URLs []URL `json:"urls,omitempty"` // populated when type="urlset" + Sitemaps []SitemapEntry `json:"sitemaps,omitempty"` // populated when type="sitemapindex" +} + +// ==================== Discover ==================== + +// DiscoverResult holds the result of sitemap.Discover. +type DiscoverResult struct { + Sitemaps []SitemapLink `json:"sitemaps"` + TotalURLs int `json:"total_urls"` // estimated total across all sitemaps +} + +// SitemapLink describes a discovered sitemap file and its metadata. +type SitemapLink struct { + URL string `json:"url"` + Source string `json:"source"` // "robots.txt", "well-known", or "index" + URLCount int `json:"url_count"` // estimated URL count (from Content-Length) + ContentSize int64 `json:"content_size"` // Content-Length in bytes (0 if unknown) + Encoding string `json:"encoding"` // "gzip", "br", or "" (from Content-Encoding) + LastModified string `json:"last_modified"` // Last-Modified header + ETag string `json:"etag"` // ETag header +} + +// DiscoverOptions configures the Discover request behavior. +type DiscoverOptions struct { + UserAgent string `json:"user_agent"` // custom User-Agent (default: "Yao-Robot/1.0") + Timeout int `json:"timeout"` // per-request timeout in seconds (default: 30) +} + +// ==================== Fetch ==================== + +// FetchResult holds the result of sitemap.Fetch. +type FetchResult struct { + URLs []URL `json:"urls"` + Total int `json:"total"` // total URL count across all sitemaps (estimated for un-fetched files) +} + +// FetchOptions configures the Fetch request behavior. +type FetchOptions struct { + Offset int `json:"offset"` // skip first N URLs (default: 0) + Limit int `json:"limit"` // max URLs to return (default/max: 50000) + UserAgent string `json:"user_agent"` // custom User-Agent (default: "Yao-Robot/1.0") + Timeout int `json:"timeout"` // per-request timeout in seconds (default: 30) +} + +// ==================== Build (Open/Write/Close) ==================== + +// sitemapWriter manages streaming sitemap file generation. +// Not exported — external callers interact via UUID handle only. +// Stored in openWriters (sync.Map), same pattern as the excel package. +type sitemapWriter struct { + id string + dir string // output directory (absolute path) + baseURL string // URL prefix for sitemap index references + count int // URLs written to current file + total int // total URLs written across all files + fileIndex int // current file number (1-based) + files []string // completed file paths + currentFile *os.File // current file handle + encoder *xml.Encoder // current xml encoder (token-level control) + create int64 // creation timestamp (unix seconds) +} + +// openWriters stores active sitemapWriter handles. +// Key: UUID string, Value: *sitemapWriter. +var openWriters = sync.Map{} + +// BuildResult holds the result returned by Build.Close. +type BuildResult struct { + Index string `json:"index"` // sitemap_index.xml path (empty string if single file) + Files []string `json:"files"` // list of sitemap file paths + Total int `json:"total"` // total URLs written +} + +// BuildOptions configures the Build.Open call. +type BuildOptions struct { + Dir string `json:"dir"` // output directory (required) + BaseURL string `json:"base_url"` // base URL for index references (required if multiple files) +} + +// ==================== Constants ==================== + +const ( + // MaxURLsPerFile is the maximum number of URLs per sitemap file (per sitemaps.org spec). + MaxURLsPerFile = 50000 + + // DefaultUserAgent is the default User-Agent for HTTP requests. + DefaultUserAgent = "Yao-Robot/1.0" + + // DefaultTimeout is the default per-request timeout in seconds. + DefaultTimeout = 30 + + // MaxDiscoverDepth is the maximum recursion depth for sitemapindex traversal. + MaxDiscoverDepth = 3 + + // Sitemap XML namespaces + NSSitemap = "http://www.sitemaps.org/schemas/sitemap/0.9" + NSImage = "http://www.google.com/schemas/sitemap-image/1.1" + NSVideo = "http://www.google.com/schemas/sitemap-video/1.1" + NSNews = "http://www.google.com/schemas/sitemap-news/0.9" +)