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