repos
/ orchard main

orchard

mirror

Every site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.

blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics

6.8 KB · 251 lines · Go Raw History
  1package main
  2
  3import (
  4	"context"
  5	"net/http"
  6	"sort"
  7	"strings"
  8	"sync"
  9
 10	"golang.org/x/net/html"
 11	"golang.org/x/net/html/atom"
 12)
 13
 14// The sources answer where the research came from, which is not the same as
 15// where the thing itself lives. Asked for the most popular Go project and told
 16// "Ollama", a reader wants a link to Ollama, not only to the listicle that said
 17// so.
 18//
 19// Candidates come from the links already harvested off the fetched pages, so
 20// finding one costs no extra search: a page that names a project nearly always
 21// links to it. Each candidate is then fetched and checked to actually be the
 22// thing before it is shown, because a wrong link is worse than none.
 23
 24// EntityLink is a verified link to something an answer named.
 25type EntityLink struct {
 26	Name  string
 27	URL   string
 28	Title string
 29	Host  string
 30}
 31
 32const (
 33	maxEntities = 4
 34	// Each check is one HTTP request, so the fan-out is bounded.
 35	maxCandidatesPerEntity = 3
 36)
 37
 38// linkEntities finds what the answer named and resolves each to a checked URL.
 39func (e *Engine) linkEntities(ctx context.Context, question, answer string, links []Link) []EntityLink {
 40	if len(links) == 0 {
 41		return nil
 42	}
 43	names := e.namedEntities(ctx, question, answer)
 44	if len(names) == 0 {
 45		return nil
 46	}
 47
 48	var (
 49		mu  sync.Mutex
 50		out []EntityLink
 51		wg  sync.WaitGroup
 52	)
 53	for _, name := range names {
 54		cands := rankCandidates(name, links)
 55		if len(cands) == 0 {
 56			continue
 57		}
 58		wg.Add(1)
 59		go func(name string, cands []Link) {
 60			defer wg.Done()
 61			for _, c := range cands {
 62				if title, ok := verifyLink(ctx, e.client, c.URL, name); ok {
 63					mu.Lock()
 64					out = append(out, EntityLink{
 65						Name: name, URL: c.URL, Title: title, Host: hostname(c.URL),
 66					})
 67					mu.Unlock()
 68					return
 69				}
 70			}
 71		}(name, cands)
 72	}
 73	wg.Wait()
 74
 75	sort.Slice(out, func(a, b int) bool { return out[a].Name < out[b].Name })
 76	return out
 77}
 78
 79// namedEntities asks which specific things the answer named. Schema
 80// constrained, and told to return nothing when the answer names nothing
 81// linkable, which is the common case for a question like the deepest river.
 82func (e *Engine) namedEntities(ctx context.Context, question, answer string) []string {
 83	schema := map[string]any{
 84		"type": "object",
 85		"properties": map[string]any{
 86			"entities": map[string]any{
 87				"type":     "array",
 88				"items":    map[string]any{"type": "string"},
 89				"maxItems": maxEntities,
 90			},
 91		},
 92		"required":             []string{"entities"},
 93		"additionalProperties": false,
 94	}
 95	system := strings.Join([]string{
 96		"You list the specific named things in an answer that a reader would want a link to.",
 97		"Include software projects, products, companies, tools, books, films and organisations, by their exact name.",
 98		"Exclude generic nouns, common concepts, units, places, and anything that is not a specific named thing someone could visit a page for.",
 99		"Return an empty list when the answer names nothing like that. An empty list is a normal and common answer.",
100	}, " ")
101	var out struct {
102		Entities []string `json:"entities"`
103	}
104	if err := e.llm.Structured(ctx, system,
105		"Question: "+question+"\n\nAnswer:\n"+truncate(plainText(answer), 1600),
106		200, schema, &out); err != nil {
107		return nil
108	}
109
110	var names []string
111	seen := map[string]bool{}
112	for _, n := range out.Entities {
113		n = strings.TrimSpace(n)
114		k := strings.ToLower(n)
115		if len(n) < 2 || len(n) > 60 || seen[k] {
116			continue
117		}
118		seen[k] = true
119		names = append(names, n)
120	}
121	return names
122}
123
124// rankCandidates orders the harvested links by how well each looks like the
125// home of the named thing. Anchor text matching first, then the host and path.
126func rankCandidates(name string, links []Link) []Link {
127	type scored struct {
128		link  Link
129		score int
130	}
131	slug := slugify(name)
132	if slug == "" {
133		return nil
134	}
135	lower := strings.ToLower(name)
136
137	var all []scored
138	for _, l := range links {
139		s := 0
140		text := strings.ToLower(l.Text)
141		host := strings.ToLower(hostname(l.URL))
142		path := strings.ToLower(l.URL)
143
144		switch {
145		case text == lower:
146			s += 6
147		case strings.Contains(text, lower):
148			s += 3
149		}
150		// The strongest signal there is: the thing's name is the domain.
151		if strings.HasPrefix(registrable(host), slug+".") || registrable(host) == slug+".com" {
152			s += 6
153		} else if strings.Contains(strings.ReplaceAll(host, "-", ""), slug) {
154			s += 3
155		}
156		if strings.Contains(strings.ReplaceAll(path, "-", ""), slug) {
157			s++
158		}
159		// A repository or a docs page is usually the canonical home for the
160		// kind of thing that gets named in these answers.
161		if strings.Contains(host, "github.com") && strings.Contains(path, slug) {
162			s += 3
163		}
164		if s > 0 {
165			all = append(all, scored{l, s})
166		}
167	}
168	sort.SliceStable(all, func(a, b int) bool { return all[a].score > all[b].score })
169
170	var out []Link
171	for i, c := range all {
172		if i >= maxCandidatesPerEntity || c.score < 3 {
173			break
174		}
175		out = append(out, c.link)
176	}
177	return out
178}
179
180func slugify(s string) string {
181	return strings.Map(func(r rune) rune {
182		switch {
183		case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
184			return r
185		case r >= 'A' && r <= 'Z':
186			return r + 32
187		}
188		return -1
189	}, s)
190}
191
192// verifyLink fetches a candidate and confirms the page is about the thing. This
193// is the difference between a link and a guess: a 404, a parked domain, or a
194// page about something else all fail here rather than being handed over.
195func verifyLink(ctx context.Context, client *http.Client, target, name string) (string, bool) {
196	req, err := http.NewRequestWithContext(ctx, "GET", target, nil)
197	if err != nil {
198		return "", false
199	}
200	browserHeaders(req, "")
201	resp, err := client.Do(req)
202	if err != nil {
203		return "", false
204	}
205	defer resp.Body.Close()
206	if resp.StatusCode != http.StatusOK {
207		return "", false
208	}
209	if ct := resp.Header.Get("Content-Type"); ct != "" && !strings.Contains(ct, "html") {
210		return "", false
211	}
212	body, err := readBody(resp, 512<<10)
213	if err != nil {
214		return "", false
215	}
216	doc, err := html.Parse(strings.NewReader(string(body)))
217	if err != nil {
218		return "", false
219	}
220
221	title := metaTitle(doc)
222	slug := slugify(name)
223	// The name has to appear in the title, the description, or the first
224	// heading. A page that never says what it is called is not evidence.
225	haystack := slugify(title) + " " + slugify(metaContent(doc, "og:description")) +
226		" " + slugify(metaContent(doc, "description")) + " " + slugify(firstHeading(doc))
227	if !strings.Contains(strings.ReplaceAll(haystack, " ", ""), slug) {
228		return "", false
229	}
230	return title, true
231}
232
233func firstHeading(root *html.Node) string {
234	var out string
235	var walk func(*html.Node)
236	walk = func(n *html.Node) {
237		if out != "" {
238			return
239		}
240		if n.Type == html.ElementNode && (n.DataAtom == atom.H1 || n.DataAtom == atom.H2) {
241			out = strings.Join(strings.Fields(textContent(n)), " ")
242			return
243		}
244		for c := n.FirstChild; c != nil; c = c.NextSibling {
245			walk(c)
246		}
247	}
248	walk(root)
249	return out
250}