yao/sitemap/fetch.go

206 lines
5.1 KiB
Go

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
}