package bot
// Feed discovery — the builder's answer to "regular people don't know RSS".
// A member names a publication ("the Irish Times"); the builder translates
// well-known outlets from its own knowledge, and for the long tail calls
// discover_feeds: fetch the site the MEMBER just named and read the feed
// URLs its HTML head advertises ().
// The drafting table's dry run then proves the chosen feeds actually work
// before anything is created — a wrong guess fails visibly, not silently.
//
// Bounds (this fetch is NOT allowlist-gated — the member named the site, in
// a DM, to the builder; that ask is the consent): https only, public
// addresses only (the same dialer guard as task fetches), HTML head sized
// (256 KiB), one host, no cross-host redirects.
import (
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strings"
)
const maxDiscoverBytes = 256 * 1024
// discoverFeeds fetches a site's homepage and returns the feed URLs it
// advertises, absolute, deduped, capped at 8.
func discoverFeeds(raw string) ([]string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, fmt.Errorf("no site given")
}
if !strings.Contains(raw, "://") {
raw = "https://" + raw
}
u, err := url.Parse(raw)
if err != nil || u.Host == "" {
return nil, fmt.Errorf("that doesn't look like a site address")
}
if u.Scheme != "https" && !allowPrivateFetch() {
return nil, fmt.Errorf("only https sites can be read")
}
client := fetchClient([]string{}) // the SSRF-guarded dialer; no allowlist here
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
if len(via) >= 3 {
return fmt.Errorf("too many redirects")
}
// The www dance is the same site: members type "bbc.com", the apex
// redirects to www (or vice versa). Anything further off-host is a
// real departure and stays blocked.
if !sameSiteHost(req.URL.Host, u.Host) && !allowPrivateFetch() {
return fmt.Errorf("site redirected off-host")
}
return nil
}
req, err := http.NewRequest(http.MethodGet, u.String(), nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "lchat-bot/1.0 (+https://elevenmessenger.com)")
req.Header.Set("Accept", "text/html")
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("couldn't reach %s: %w", u.Host, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("%s answered with status %d", u.Host, resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, maxDiscoverBytes))
if err != nil {
return nil, err
}
feeds := feedLinksFromHTML(string(body), resp.Request.URL)
if len(feeds) == 0 {
return nil, fmt.Errorf("%s doesn't advertise a feed on its homepage", u.Host)
}
return feeds, nil
}
// sameSiteHost treats a host and its www. variant as one site (either
// direction), for the redirect guard above. Pure; pinned by unit test.
func sameSiteHost(a, b string) bool {
return a == b || a == "www."+b || b == "www."+a
}
// linkTagRE matches tags; attribute order varies wildly in the
// field, so attributes are re-scanned per tag rather than positionally.
var linkTagRE = regexp.MustCompile(`(?is)]*>`)
var attrRE = regexp.MustCompile(`(?is)([a-z-]+)\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>]+))`)
// feedLinksFromHTML pulls rel="alternate" rss/atom link hrefs out of an HTML
// document, resolved against base. Pure — pinned by unit tests.
func feedLinksFromHTML(html string, base *url.URL) []string {
var out []string
seen := map[string]bool{}
for _, tag := range linkTagRE.FindAllString(html, 200) {
attrs := map[string]string{}
for _, m := range attrRE.FindAllStringSubmatch(tag, -1) {
val := m[3]
if val == "" {
val = m[4]
}
if val == "" {
val = m[5]
}
attrs[strings.ToLower(m[1])] = val
}
rel := strings.ToLower(attrs["rel"])
typ := strings.ToLower(attrs["type"])
href := strings.TrimSpace(attrs["href"])
if href == "" || !strings.Contains(rel, "alternate") {
continue
}
if !strings.Contains(typ, "rss") && !strings.Contains(typ, "atom") {
continue
}
ref, err := url.Parse(href)
if err != nil {
continue
}
abs := base.ResolveReference(ref).String()
if seen[abs] {
continue
}
seen[abs] = true
out = append(out, abs)
if len(out) == 8 {
break
}
}
return out
}
// discoverFeedsTool is the builder's information tool (a RESULT tool — the
// brain round-trips its output, unlike chip tools; see modelReply's loop).
func discoverFeedsTool() anthTool {
return anthTool{
Name: "discover_feeds",
Description: "Find the RSS/Atom feed URLs a website advertises. Give it the site a member " +
"named (a domain or homepage URL, e.g. irishtimes.com) and it returns the feed URLs from " +
"the site's own HTML. Use this whenever you aren't CERTAIN of an outlet's feed URL — " +
"never ask a member for a feed URL, and never guess one you can't vouch for.",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{"site": map[string]any{"type": "string", "description": "Domain or homepage URL"}},
"required": []any{"site"},
},
}
}
// runDiscoverFeeds executes the tool, formatting for a tool_result.
func runDiscoverFeeds(input map[string]any) string {
site, _ := input["site"].(string)
feeds, err := discoverFeeds(site)
if err != nil {
return "discovery failed: " + err.Error()
}
return "feeds advertised by the site:\n" + strings.Join(feeds, "\n")
}