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 tags with RSS/Atom type // Matches // Handles attributes in any order, single or double quotes, and self-closing tags. reLinkTag = regexp.MustCompile( `(?i)]*\btype\s*=\s*["']application/(rss|atom)\+xml["'][^>]*>`, ) reLinkHref = regexp.MustCompile(`(?i)\bhref\s*=\s*["']([^"']+)["']`) reLinkTitle = regexp.MustCompile(`(?i)\btitle\s*=\s*["']([^"']+)["']`) reLinkType = regexp.MustCompile(`(?i)\btype\s*=\s*["']application/(rss|atom)\+xml["']`) // Pattern 2: Markdown links [text](url) reMarkdownLink = regexp.MustCompile(`\[([^\]]*)\]\((https?://[^)\s]+)\)`) // Pattern 3: Bare URLs in text reURL = regexp.MustCompile(`https?://[^\s<>"'\)\]]+`) ) // Discover extracts feed URLs from the given text content. // The input can be HTML (complete or partial), Markdown, or plain text. // It uses regex-based detection (not HTML parsing) to handle all input types robustly. // // Detection is performed in priority order: // 1. HTML tags with RSS/Atom type attributes // 2. Markdown links [text](url) matching feed URL patterns // 3. Bare URLs matching common feed path/query patterns // // Results are deduplicated by URL and ordered by detection priority. func Discover(text string) []FeedLink { if strings.TrimSpace(text) == "" { return nil } seen := make(map[string]bool) var results []FeedLink // Priority 1: HTML tags linkMatches := reLinkTag.FindAllString(text, -1) for _, tag := range linkMatches { href := extractAttr(reLinkHref, tag) if href == "" { continue } if seen[href] { continue } seen[href] = true fl := FeedLink{URL: href} fl.Title = extractAttr(reLinkTitle, tag) typeMatch := reLinkType.FindStringSubmatch(tag) if len(typeMatch) > 1 { fl.Type = strings.ToLower(typeMatch[1]) // "rss" or "atom" } results = append(results, fl) } // Priority 2: Markdown links with feed-like URLs mdMatches := reMarkdownLink.FindAllStringSubmatch(text, -1) for _, m := range mdMatches { if len(m) < 3 { continue } title, url := m[1], m[2] if seen[url] { continue } if !looksLikeFeedURL(url) { continue } seen[url] = true results = append(results, FeedLink{ URL: url, Title: strings.TrimSpace(title), Type: guessTypeFromURL(url), }) } // Priority 3: Bare URLs matching feed patterns urlMatches := reURL.FindAllString(text, -1) for _, url := range urlMatches { // Clean trailing punctuation that may be part of surrounding text url = strings.TrimRight(url, ".,;:!?") if seen[url] { continue } if !looksLikeFeedURL(url) { continue } seen[url] = true results = append(results, FeedLink{ URL: url, Type: guessTypeFromURL(url), }) } return results } // looksLikeFeedURL checks whether a URL matches common feed path or query patterns. func looksLikeFeedURL(url string) bool { lower := strings.ToLower(url) for _, p := range feedPathPatterns { if strings.Contains(lower, p) { return true } } for _, p := range feedQueryPatterns { if strings.Contains(lower, p) { return true } } return false } // guessTypeFromURL attempts to determine the feed type from URL patterns. // Returns "rss", "atom", or empty string if undetermined. func guessTypeFromURL(url string) string { lower := strings.ToLower(url) if strings.Contains(lower, "atom") { return "atom" } if strings.Contains(lower, "rss") { return "rss" } // Generic feed paths — cannot determine type return "" } // extractAttr extracts the first capture group from a regex match on the input string. func extractAttr(re *regexp.Regexp, input string) string { m := re.FindStringSubmatch(input) if len(m) > 1 { return strings.TrimSpace(m[1]) } return "" }