Merge pull request #1462 from trheyi/main

Add RSS and Sitemap support
This commit is contained in:
Max 2026-02-12 19:48:03 +08:00 committed by GitHub
commit a1a004deb3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 5966 additions and 354 deletions

View file

@ -215,7 +215,6 @@ func (ast *Assistant) Clone() *Assistant {
UpdatedAt: ast.UpdatedAt,
},
HookScript: ast.HookScript,
openai: ast.openai,
}
// Deep copy tags

View file

@ -15,7 +15,6 @@ import (
"github.com/yaoapp/yao/agent/i18n"
searchTypes "github.com/yaoapp/yao/agent/search/types"
store "github.com/yaoapp/yao/agent/store/types"
"github.com/yaoapp/yao/openai"
"gopkg.in/yaml.v3"
)
@ -796,12 +795,6 @@ func (ast *Assistant) initialize() error {
}
ast.Connector = conn
api, err := openai.New(conn)
if err != nil {
return err
}
ast.openai = api
// Register scripts as process handlers
if len(ast.Scripts) > 0 {
if err := ast.RegisterScripts(); err != nil {

View file

@ -8,7 +8,6 @@ import (
outputMessage "github.com/yaoapp/yao/agent/output/message"
store "github.com/yaoapp/yao/agent/store/types"
api "github.com/yaoapp/yao/openai"
)
const (
@ -34,8 +33,7 @@ type Assistant struct {
// Internal
// ===============================
openai *api.OpenAI // OpenAI API
vision bool // Whether this assistant supports vision
vision bool // Whether this assistant supports vision
}
// MCPTool represents a simplified MCP tool for building LLM requests

File diff suppressed because it is too large Load diff

View file

@ -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"

79
rss/README.md Normal file
View file

@ -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");
```

139
rss/atom.go Normal file
View file

@ -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
}

127
rss/atom_test.go Normal file
View file

@ -0,0 +1,127 @@
package rss
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const testAtom = `<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en">
<title>Example Atom Feed</title>
<subtitle>An example Atom feed for testing</subtitle>
<link href="https://example.com" rel="alternate"/>
<link href="https://example.com/feed.atom" rel="self"/>
<updated>2024-01-15T10:30:00Z</updated>
<entry>
<title>First Entry</title>
<link href="https://example.com/entry-1" rel="alternate"/>
<id>urn:uuid:entry-1</id>
<published>2024-01-14T08:00:00Z</published>
<updated>2024-01-14T10:00:00Z</updated>
<summary>Summary of the first entry</summary>
<content type="html">Full HTML content of entry 1</content>
<author>
<name>Alice</name>
<email>alice@example.com</email>
</author>
<category term="tech" label="Technology"/>
<category term="go"/>
</entry>
<entry>
<title>Second Entry</title>
<link href="https://example.com/entry-2"/>
<id>urn:uuid:entry-2</id>
<updated>2024-01-15T10:30:00Z</updated>
<author>
<name>Bob</name>
</author>
<author>
<name>Charlie</name>
</author>
</entry>
</feed>`
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 := `<?xml version="1.0"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Minimal Atom</title>
</feed>`
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(`<feed><title>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{}))
}

25
rss/build.go Normal file
View file

@ -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)
}
}

156
rss/build_atom.go Normal file
View file

@ -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"`
}

225
rss/build_rss.go Normal file
View file

@ -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"`
}

347
rss/build_test.go Normal file
View file

@ -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</title>`)
assert.Contains(t, xml, `<link>https://example.com</link>`)
assert.Contains(t, xml, `<description>A test blog</description>`)
assert.Contains(t, xml, `<language>en</language>`)
assert.Contains(t, xml, `<title>First Post</title>`)
assert.Contains(t, xml, `<title>Second Post</title>`)
assert.Contains(t, xml, `<author>Alice</author>`)
assert.Contains(t, xml, `<category>Tech</category>`)
assert.Contains(t, xml, `<category>Go</category>`)
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, `<rss version="2.0"`)
}
func TestBuild_RSS_Podcast(t *testing.T) {
feed := newTestPodcastFeed()
xml, err := Build(feed, "rss")
require.NoError(t, err)
assert.Contains(t, xml, `xmlns:itunes=`)
assert.Contains(t, xml, `<itunes:author>Jane Doe</itunes:author>`)
assert.Contains(t, xml, `<itunes:summary>Weekly tech discussions</itunes:summary>`)
assert.Contains(t, xml, `href="https://podcast.example.com/cover.jpg"`)
assert.Contains(t, xml, `<itunes:name>Jane Doe</itunes:name>`)
assert.Contains(t, xml, `<itunes:email>jane@example.com</itunes:email>`)
assert.Contains(t, xml, `<itunes:explicit>no</itunes:explicit>`)
assert.Contains(t, xml, `<itunes:type>episodic</itunes:type>`)
// 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, `<itunes:duration>01:23:45</itunes:duration>`)
assert.Contains(t, xml, `<itunes:season>1</itunes:season>`)
assert.Contains(t, xml, `<itunes:episode>1</itunes:episode>`)
assert.Contains(t, xml, `<itunes:episodeType>full</itunes:episodeType>`)
}
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, `<content:encoded>`)
assert.Contains(t, xml, `<p>Full content</p>`)
}
// --- 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, `<?xml version="1.0" encoding="UTF-8"?>`)
assert.Contains(t, xml, `<feed xmlns="http://www.w3.org/2005/Atom"`)
assert.Contains(t, xml, `<title>Test Blog</title>`)
assert.Contains(t, xml, `<subtitle>A test blog</subtitle>`)
assert.Contains(t, xml, `href="https://example.com"`)
assert.Contains(t, xml, `<title>First Post</title>`)
assert.Contains(t, xml, `<name>Alice</name>`)
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, `<title>My Podcast</title>`)
}
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, `<name>Alice</name>`)
assert.Contains(t, xml, `<name>Bob</name>`)
}
// --- 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, `<title>Empty</title>`)
assert.NotContains(t, xml, "<item>")
}
// --- 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, "<?xml"))
// Should be valid — re-parseable
err = Validate(xmlStr)
assert.NoError(t, err)
}
func TestBuild_Atom_WellFormedXML(t *testing.T) {
feed := newTestFeed()
xmlStr, err := Build(feed, "atom")
require.NoError(t, err)
assert.True(t, strings.HasPrefix(xmlStr, "<?xml"))
// Should be valid Atom — re-parseable
err = Validate(xmlStr)
assert.NoError(t, err)
}

65
rss/convert.go Normal file
View file

@ -0,0 +1,65 @@
package rss
import (
"encoding/json"
"fmt"
)
// mapToFeed converts an arbitrary value (typically a map from Process args)
// into a Feed struct. It uses JSON marshaling/unmarshaling as a safe
// intermediate conversion, which handles nested maps, slices, and type coercion.
func mapToFeed(v interface{}) (*Feed, error) {
if v == nil {
return nil, fmt.Errorf("feed data is nil")
}
// If already a *Feed, return directly
if feed, ok := v.(*Feed); ok {
return feed, nil
}
// If it's a Feed value (not pointer), take its address
if feed, ok := v.(Feed); ok {
return &feed, nil
}
// Otherwise, marshal to JSON and unmarshal to Feed
data, err := json.Marshal(v)
if err != nil {
return nil, fmt.Errorf("failed to serialize feed data: %s", err.Error())
}
var feed Feed
if err := json.Unmarshal(data, &feed); err != nil {
return nil, fmt.Errorf("failed to parse feed data: %s", err.Error())
}
return &feed, nil
}
// mapToFetchOptions converts an arbitrary value (typically a map from Process args)
// into a FetchOptions struct. Returns default options for nil input.
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
}

166
rss/discover.go Normal file
View file

@ -0,0 +1,166 @@
package rss
import (
"regexp"
"strings"
)
// Common feed URL path patterns used for heuristic URL detection.
var feedPathPatterns = []string{
"/feed", "/rss", "/atom",
"/feed.xml", "/rss.xml", "/atom.xml", "/index.xml",
"/feed/", "/rss/",
"/feed.json", // JSON Feed (for completeness)
".rss", ".atom",
}
// Feed URL query parameter patterns.
var feedQueryPatterns = []string{
"feed=rss", "feed=atom", "format=rss", "format=atom", "format=feed",
}
// Compiled regex patterns (initialized once).
var (
// Pattern 1: HTML <link> tags with RSS/Atom type
// Matches <link rel="alternate" type="application/rss+xml" href="..." title="...">
// Handles attributes in any order, single or double quotes, and self-closing tags.
reLinkTag = regexp.MustCompile(
`(?i)<link\b[^>]*\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 <link> 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 <link> 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 ""
}

156
rss/discover_test.go Normal file
View file

@ -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 := `<html>
<head>
<title>My Site</title>
<link rel="alternate" type="application/rss+xml" href="https://example.com/feed.xml" title="RSS Feed"/>
<link rel="alternate" type="application/atom+xml" href="https://example.com/atom.xml" title="Atom Feed"/>
<link rel="stylesheet" href="/style.css"/>
</head>
<body>Hello</body>
</html>`
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 := `<link href='https://blog.example.com/rss' title='Blog' type='application/rss+xml' rel='alternate'>`
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 := `<link rel="alternate" type="application/rss+xml" href="https://example.com/feed.xml" title="Feed"/>
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 := `<div>Some content</div>
<link rel="alternate" type="application/rss+xml" href="https://example.com/feed" title="My Feed">
<p>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 := `<link rel="alternate" type="application/atom+xml" href="https://a.com/atom.xml" title="A"/>
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"))
}

108
rss/fetch.go Normal file
View file

@ -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
}

248
rss/fetch_test.go Normal file
View file

@ -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 = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>Test Feed</title>
<link>https://example.com</link>
<description>A test feed</description>
<item>
<title>Post 1</title>
<link>https://example.com/post1</link>
</item>
</channel>
</rss>`
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 := `<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Atom Test</title>
<link href="https://example.com"/>
<updated>2025-01-01T00:00:00Z</updated>
<entry>
<title>Atom Entry</title>
<link href="https://example.com/entry1"/>
<id>urn:uuid:1</id>
<updated>2025-01-01T00:00:00Z</updated>
</entry>
</feed>`
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)
}

138
rss/parse.go Normal file
View file

@ -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 <title> 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,
)
}
}
}

158
rss/parse_test.go Normal file
View file

@ -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</title></head></html>`)
assert.Error(t, err)
assert.Contains(t, err.Error(), "unrecognized feed format")
assert.Contains(t, err.Error(), "<html>")
}
func TestParse_UnknownRoot(t *testing.T) {
_, err := Parse(`<?xml version="1.0"?><document><data>test</data></document>`)
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(`<?xml version="1.0"?><rss><channel><title>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)
}

182
rss/process.go Normal file
View file

@ -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
}

278
rss/rss.go Normal file
View file

@ -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 ""
}

229
rss/rss_test.go Normal file
View file

@ -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</title>
<link>https://example.com</link>
<description>An example blog feed</description>
<language>en-us</language>
<lastBuildDate>Mon, 01 Jan 2024 00:00:00 GMT</lastBuildDate>
<item>
<title>First Post</title>
<link>https://example.com/first</link>
<description>A short summary</description>
<content:encoded><![CDATA[<p>Full content of the first post</p>]]></content:encoded>
<dc:creator>Alice</dc:creator>
<pubDate>Sun, 31 Dec 2023 12:00:00 GMT</pubDate>
<guid isPermaLink="true">https://example.com/first</guid>
<category>Tech</category>
<category>Go</category>
<enclosure url="https://example.com/audio.mp3" type="audio/mpeg" length="12345678"/>
</item>
<item>
<title>Second Post</title>
<link>https://example.com/second</link>
<description>Another summary</description>
<author>bob@example.com</author>
<pubDate>Mon, 01 Jan 2024 00:00:00 GMT</pubDate>
<guid>https://example.com/second</guid>
</item>
</channel>
</rss>`
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, "<p>Full content of the first post</p>", 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 <author>
assert.Empty(t, item1.Categories)
assert.Empty(t, item1.Enclosures)
}
const testPodcast = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"
xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd"
xmlns:content="http://purl.org/rss/1.0/modules/content/">
<channel>
<title>My Awesome Podcast</title>
<link>https://podcast.example.com</link>
<description>A podcast about technology</description>
<language>en</language>
<itunes:author>Jane Doe</itunes:author>
<itunes:summary>Weekly tech discussions</itunes:summary>
<itunes:image href="https://podcast.example.com/cover.jpg"/>
<itunes:owner>
<itunes:name>Jane Doe</itunes:name>
<itunes:email>jane@example.com</itunes:email>
</itunes:owner>
<itunes:category text="Technology">
<itunes:category text="Podcasting"/>
</itunes:category>
<itunes:category text="Education"/>
<itunes:explicit>no</itunes:explicit>
<itunes:type>episodic</itunes:type>
<item>
<title>Episode 1: Getting Started</title>
<link>https://podcast.example.com/ep1</link>
<description>Our first episode</description>
<enclosure url="https://podcast.example.com/ep1.mp3" type="audio/mpeg" length="50000000"/>
<pubDate>Wed, 15 Nov 2023 08:00:00 GMT</pubDate>
<guid>https://podcast.example.com/ep1</guid>
<itunes:duration>01:23:45</itunes:duration>
<itunes:season>1</itunes:season>
<itunes:episode>1</itunes:episode>
<itunes:episodeType>full</itunes:episodeType>
<itunes:explicit>no</itunes:explicit>
<itunes:image href="https://podcast.example.com/ep1-cover.jpg"/>
<itunes:summary>In this episode we discuss getting started with podcasting</itunes:summary>
</item>
<item>
<title>Trailer</title>
<link>https://podcast.example.com/trailer</link>
<description>Preview of the show</description>
<enclosure url="https://podcast.example.com/trailer.mp3" type="audio/mpeg" length="5000000"/>
<itunes:duration>120</itunes:duration>
<itunes:episodeType>trailer</itunes:episodeType>
<itunes:explicit>yes</itunes:explicit>
</item>
</channel>
</rss>`
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 := `<?xml version="1.0"?>
<rss version="2.0">
<channel>
<title>Minimal</title>
<link>https://example.com</link>
<description>Bare minimum</description>
</channel>
</rss>`
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(`<rss><channel><title>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)
}

91
rss/types.go Normal file
View file

@ -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)
}

120
sitemap/README.md Normal file
View file

@ -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
```

242
sitemap/build.go Normal file
View file

@ -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
}

264
sitemap/build_test.go Normal file
View file

@ -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")
}
}

112
sitemap/convert.go Normal file
View file

@ -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
}

165
sitemap/convert_test.go Normal file
View file

@ -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")
}
}

262
sitemap/discover.go Normal file
View file

@ -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
}

206
sitemap/fetch.go Normal file
View file

@ -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
}

448
sitemap/fetch_test.go Normal file
View file

@ -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)
}
}

128
sitemap/parse.go Normal file
View file

@ -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
}

174
sitemap/parse_test.go Normal file
View file

@ -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")
}
}

254
sitemap/process.go Normal file
View file

@ -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
}

37
sitemap/robots.go Normal file
View file

@ -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
}

76
sitemap/robots_test.go Normal file
View file

@ -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])
}
}

188
sitemap/types.go Normal file
View file

@ -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"
)

View file

@ -15,7 +15,7 @@ var slotRe = regexp.MustCompile(`\[\{([^\}]+)\}\]`)
var cssRe = regexp.MustCompile(`([\.a-z0-9A-Z-:# ]+)\{`)
var transStmtReSingle = regexp.MustCompile(`'::([^:']+)'`)
var transStmtReDouble = regexp.MustCompile(`"::([^:"]+)"`)
var transFuncRe = regexp.MustCompile(`__m\s*\(\s*["'](.*?)["']\s*\)`)
var transFuncRe = regexp.MustCompile(`(?:__m|(?:^|[^a-zA-Z0-9_.$])T)\s*\(\s*["'](.*?)["']\s*\)`)
// Build build the page
func (page *Page) Build(ctx *BuildContext, option *BuildOption) (*goquery.Document, []string, error) {

View file

@ -85,6 +85,8 @@ const i118nScriptTmpl = `
}
return __sui_locale[message] || message;
}
var T = __m;
`
const pageEventScriptTmpl = `

View file

@ -140,12 +140,23 @@ func (parser *TemplateParser) Render(html string) (string, error) {
head := doc.Find("head")
if head.Length() > 0 {
scriptMessages := map[string]string{}
if parser.locale != nil && parser.locale.ScriptMessages != nil {
scriptMessages = parser.locale.ScriptMessages
// Merge Messages and ScriptMessages so that __sui_locale (and thus
// __m / T) can resolve all translation keys at runtime — not just those
// extracted from __m("literal") calls during build. Messages written in
// the YAML locale files are now also available to JS code.
allMessages := map[string]string{}
if parser.locale != nil {
// Messages first (from YAML locale files, used for server-side rendering)
for k, v := range parser.locale.Messages {
allMessages[k] = v
}
// ScriptMessages override (extracted from __m() calls during build)
for k, v := range parser.locale.ScriptMessages {
allMessages[k] = v
}
}
data, err := jsoniter.MarshalToString(scriptMessages)
data, err := jsoniter.MarshalToString(allMessages)
if err != nil {
data = "{}"
}

View file

@ -317,7 +317,7 @@ func (page *Page) Build(globalCtx *core.GlobalBuildContext, option *core.BuildOp
}
// Write locale files from page's __locales directory
err = page.writeLocaleFiles(option.Data)
err = page.writeLocaleFiles(ctx, option.Data)
if err != nil {
log.Warn("[Agent] Write locale files error: %s", err.Error())
// Don't fail the build for locale errors
@ -474,8 +474,9 @@ func (page *Page) AssistantID() string {
return page.assistantID
}
// writeLocaleFiles writes locale files from page's __locales directory to public
func (page *Page) writeLocaleFiles(data map[string]interface{}) error {
// writeLocaleFiles writes locale files from page's __locales directory to public,
// merging script translations extracted from __m() calls during build.
func (page *Page) writeLocaleFiles(ctx *core.BuildContext, data map[string]interface{}) error {
fs := page.tmpl.agent.fs
// Check if page has __locales directory
@ -484,6 +485,13 @@ func (page *Page) writeLocaleFiles(data map[string]interface{}) error {
return nil
}
// Get translations from build context (includes __m() calls marked as type "script")
var translations []core.Translation
if ctx != nil {
translations = ctx.GetTranslations()
}
prefix := core.TranslationKeyPrefix(page.Route)
// Get the public root
root, err := page.tmpl.agent.DSL.PublicRoot(data)
if err != nil {
@ -544,7 +552,7 @@ func (page *Page) writeLocaleFiles(data map[string]interface{}) error {
}
}
// Extract script_messages
// Extract script_messages (if manually specified in source locale)
if scriptMessages, ok := localeData["script_messages"].(map[string]interface{}); ok {
for k, v := range scriptMessages {
if strVal, ok := v.(string); ok {
@ -553,6 +561,12 @@ func (page *Page) writeLocaleFiles(data map[string]interface{}) error {
}
}
// Merge translations from build context — this populates ScriptMessages
// from __m() calls found in TS/JS scripts during compilation
if len(translations) > 0 {
locale.MergeTranslations(translations, prefix)
}
// Extract timezone and direction
if tz, ok := localeData["timezone"].(string); ok {
locale.Timezone = tz