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

12.3 KB · 511 lines · Go Raw History
  1package main
  2
  3import (
  4	"bytes"
  5	"encoding/json"
  6	"fmt"
  7	"net/http"
  8	neturl "net/url"
  9	"strings"
 10	"time"
 11
 12	htmltomarkdown "github.com/JohannesKaufmann/html-to-markdown/v2"
 13	"golang.org/x/net/html"
 14	"golang.org/x/net/html/atom"
 15)
 16
 17// Page is a fetched and cleaned document, ready to chunk into passages.
 18type Page struct {
 19	URL       string
 20	Title     string
 21	Site      string
 22	Published string // ISO date, empty when the page carries none
 23	Markdown  string
 24	Links     []Link
 25}
 26
 27// Link is an outbound link found in a page's article body, with the words that
 28// were linked.
 29//
 30// These are harvested rather than searched for. A page that answers "the most
 31// popular Go project" almost always links to the project it names, so the
 32// canonical URL is already in hand and costs no extra search.
 33type Link struct {
 34	URL  string
 35	Text string
 36}
 37
 38// chrome are the elements that never carry article text. Dropping them and then
 39// taking the semantic content element beats a full-page conversion by enough to
 40// matter, and it measures level with a readability port on real article pages.
 41var chrome = map[atom.Atom]bool{
 42	atom.Script: true, atom.Style: true, atom.Nav: true, atom.Header: true,
 43	atom.Footer: true, atom.Aside: true, atom.Form: true, atom.Iframe: true,
 44	atom.Noscript: true, atom.Svg: true, atom.Button: true, atom.Select: true,
 45	atom.Textarea: true, atom.Dialog: true, atom.Template: true,
 46}
 47
 48// Fetch pulls a URL and reduces it to markdown plus whatever metadata the page
 49// was willing to state about itself.
 50func Fetch(client *http.Client, target string) (*Page, error) {
 51	req, err := http.NewRequest("GET", target, nil)
 52	if err != nil {
 53		return nil, err
 54	}
 55	browserHeaders(req, "")
 56
 57	resp, err := client.Do(req)
 58	if err != nil {
 59		return nil, err
 60	}
 61	defer resp.Body.Close()
 62	if resp.StatusCode != http.StatusOK {
 63		return nil, fmt.Errorf("%s: %s", target, resp.Status)
 64	}
 65	if ct := resp.Header.Get("Content-Type"); ct != "" && !strings.Contains(ct, "html") {
 66		return nil, fmt.Errorf("%s: not html (%s)", target, ct)
 67	}
 68
 69	body, err := readBody(resp, 8<<20)
 70	if err != nil {
 71		return nil, err
 72	}
 73	doc, err := html.Parse(bytes.NewReader(body))
 74	if err != nil {
 75		return nil, err
 76	}
 77
 78	p := &Page{URL: target}
 79	p.Title = metaTitle(doc)
 80	p.Site = metaContent(doc, "og:site_name")
 81	if t, ok := pubDate(doc); ok {
 82		p.Published = t.Format("2006-01-02")
 83	}
 84
 85	// Before stripAndPick, which removes script tags in place and takes the
 86	// structured data with them.
 87	recipe, hasRecipe := recipeFromJSONLD(doc)
 88
 89	content := stripAndPick(doc)
 90	p.Links = harvestLinks(content, target)
 91	md, err := htmltomarkdown.ConvertString(renderNode(content))
 92	if err != nil {
 93		return nil, err
 94	}
 95	p.Markdown = tidyMarkdown(md)
 96	if len(strings.Fields(p.Markdown)) < 40 {
 97		return nil, fmt.Errorf("%s: too little text", target)
 98	}
 99	// A recipe page publishes its ingredients with quantities as data and then
100	// describes them without quantities in the prose, so the structured copy
101	// goes first where the passage ranking will find it.
102	if hasRecipe {
103		p.Markdown = recipe.Markdown() + "\n\n" + p.Markdown
104	}
105	return p, nil
106}
107
108// stripAndPick removes page chrome, then returns the semantic content element
109// if there is one. Without a readability port this is what separates an article
110// from the navigation around it, and on real article pages it measures level
111// with one.
112func stripAndPick(root *html.Node) *html.Node {
113	var prune func(*html.Node)
114	prune = func(n *html.Node) {
115		var next *html.Node
116		for c := n.FirstChild; c != nil; c = next {
117			next = c.NextSibling
118			if c.Type == html.ElementNode && chrome[c.DataAtom] {
119				n.RemoveChild(c)
120				continue
121			}
122			if c.Type == html.CommentNode {
123				n.RemoveChild(c)
124				continue
125			}
126			prune(c)
127		}
128	}
129	prune(root)
130
131	var best *html.Node
132	bestLen := 0
133	var walk func(*html.Node)
134	walk = func(n *html.Node) {
135		if n.Type == html.ElementNode && (n.DataAtom == atom.Article || n.DataAtom == atom.Main) {
136			if l := textLen(n); l > bestLen {
137				best, bestLen = n, l
138			}
139		}
140		for c := n.FirstChild; c != nil; c = c.NextSibling {
141			walk(c)
142		}
143	}
144	walk(root)
145	if best != nil && bestLen > 400 {
146		return best
147	}
148	return root
149}
150
151// harvestLinks pulls the outbound links out of an article body. Same-host links
152// are dropped because they are navigation, and so is anything whose anchor text
153// is too short or too long to name a thing.
154func harvestLinks(root *html.Node, pageURL string) []Link {
155	base, err := neturl.Parse(pageURL)
156	if err != nil {
157		return nil
158	}
159	seen := map[string]bool{}
160	var out []Link
161
162	var walk func(*html.Node)
163	walk = func(n *html.Node) {
164		if n.Type == html.ElementNode && n.DataAtom == atom.A {
165			href := ""
166			for _, a := range n.Attr {
167				if a.Key == "href" {
168					href = a.Val
169				}
170			}
171			if u, err := neturl.Parse(href); err == nil && href != "" {
172				abs := base.ResolveReference(u)
173				text := strings.Join(strings.Fields(textContent(n)), " ")
174				if keepLink(abs, base, text) && !seen[abs.String()] {
175					seen[abs.String()] = true
176					out = append(out, Link{URL: abs.String(), Text: text})
177				}
178			}
179		}
180		for c := n.FirstChild; c != nil; c = c.NextSibling {
181			walk(c)
182		}
183	}
184	walk(root)
185	return out
186}
187
188func keepLink(u, base *neturl.URL, text string) bool {
189	if u.Scheme != "http" && u.Scheme != "https" {
190		return false
191	}
192	if u.Host == "" || sameSite(u.Host, base.Host) {
193		return false
194	}
195	if len(text) < 2 || len(text) > 80 {
196		return false
197	}
198	// Sharing widgets and the sites every article links to regardless of what
199	// it is about.
200	for _, junk := range []string{
201		"facebook.com", "twitter.com", "x.com", "instagram.com", "pinterest.com",
202		"linkedin.com", "reddit.com/submit", "t.me/share", "whatsapp.com",
203		"doubleclick", "googletagmanager", "amazon-adsystem",
204	} {
205		if strings.Contains(u.Host+u.Path, junk) {
206			return false
207		}
208	}
209	return true
210}
211
212func sameSite(a, b string) bool {
213	return registrable(a) == registrable(b)
214}
215
216// registrable is a rough eTLD+1. It only has to tell one publisher from
217// another, so the two label rule is enough and a public suffix list would be a
218// dependency for nothing.
219func registrable(host string) string {
220	host = strings.TrimPrefix(strings.ToLower(host), "www.")
221	parts := strings.Split(host, ".")
222	if len(parts) <= 2 {
223		return host
224	}
225	return strings.Join(parts[len(parts)-2:], ".")
226}
227
228func textContent(n *html.Node) string {
229	if n.Type == html.TextNode {
230		return n.Data
231	}
232	var b strings.Builder
233	for c := n.FirstChild; c != nil; c = c.NextSibling {
234		b.WriteString(textContent(c))
235	}
236	return b.String()
237}
238
239func textLen(n *html.Node) int {
240	if n.Type == html.TextNode {
241		return len(strings.TrimSpace(n.Data))
242	}
243	total := 0
244	for c := n.FirstChild; c != nil; c = c.NextSibling {
245		total += textLen(c)
246	}
247	return total
248}
249
250func renderNode(n *html.Node) string {
251	var b bytes.Buffer
252	html.Render(&b, n)
253	return b.String()
254}
255
256// tidyMarkdown drops the wreckage a conversion leaves behind: image lines,
257// tracking parameters, and the runs of blank lines that come from stripped
258// elements.
259func tidyMarkdown(md string) string {
260	lines := strings.Split(md, "\n")
261	out := make([]string, 0, len(lines))
262	blanks := 0
263	for _, l := range lines {
264		t := strings.TrimSpace(l)
265		if t == "" {
266			blanks++
267			if blanks > 1 {
268				continue
269			}
270			out = append(out, "")
271			continue
272		}
273		blanks = 0
274		// A line that is nothing but images carries no text for the model.
275		if strings.HasPrefix(t, "![") && !strings.Contains(t, "](#") && len(stripImages(t)) < 3 {
276			continue
277		}
278		out = append(out, stripTracking(l))
279	}
280	return strings.TrimSpace(strings.Join(out, "\n"))
281}
282
283func stripImages(s string) string {
284	for {
285		i := strings.Index(s, "![")
286		if i < 0 {
287			return strings.TrimSpace(s)
288		}
289		j := strings.Index(s[i:], ")")
290		if j < 0 {
291			return strings.TrimSpace(s[:i])
292		}
293		s = s[:i] + s[i+j+1:]
294	}
295}
296
297var trackingParams = []string{"utm_source", "utm_campaign", "utm_medium", "utm_content", "utm_term"}
298
299func stripTracking(s string) string {
300	for _, p := range trackingParams {
301		for {
302			i := strings.Index(s, "?"+p+"=")
303			if i < 0 {
304				i = strings.Index(s, "&"+p+"=")
305			}
306			if i < 0 {
307				break
308			}
309			end := i + 1
310			for end < len(s) && s[end] != '&' && s[end] != ')' && s[end] != ' ' && s[end] != '"' {
311				end++
312			}
313			s = s[:i] + s[end:]
314		}
315	}
316	return s
317}
318
319// ---- metadata ----
320
321func metaTitle(root *html.Node) string {
322	if v := metaContent(root, "og:title"); v != "" {
323		return v
324	}
325	var out string
326	var walk func(*html.Node)
327	walk = func(n *html.Node) {
328		if out != "" {
329			return
330		}
331		if n.Type == html.ElementNode && n.DataAtom == atom.Title && n.FirstChild != nil {
332			out = strings.Join(strings.Fields(n.FirstChild.Data), " ")
333			return
334		}
335		for c := n.FirstChild; c != nil; c = c.NextSibling {
336			walk(c)
337		}
338	}
339	walk(root)
340	return out
341}
342
343func metaContent(root *html.Node, key string) string {
344	var out string
345	var walk func(*html.Node)
346	walk = func(n *html.Node) {
347		if out != "" {
348			return
349		}
350		if n.Type == html.ElementNode && n.DataAtom == atom.Meta {
351			var k, v string
352			for _, a := range n.Attr {
353				switch a.Key {
354				case "property", "name", "itemprop":
355					k = strings.ToLower(a.Val)
356				case "content":
357					v = a.Val
358				}
359			}
360			if k == strings.ToLower(key) && v != "" {
361				out = v
362				return
363			}
364		}
365		for c := n.FirstChild; c != nil; c = c.NextSibling {
366			walk(c)
367		}
368	}
369	walk(root)
370	return out
371}
372
373// pubDate reads a publication date out of structured metadata. It finds one on
374// roughly half of real pages; the rest have it only as prose in the byline,
375// which is what the model's extraction pass is asked to pick up.
376func pubDate(root *html.Node) (time.Time, bool) {
377	if t, ok := fromJSONLD(root); ok {
378		return t, true
379	}
380	metas := map[string]string{}
381	var times []string
382	var walk func(*html.Node)
383	walk = func(n *html.Node) {
384		if n.Type == html.ElementNode {
385			switch n.DataAtom {
386			case atom.Meta:
387				var k, v string
388				for _, a := range n.Attr {
389					switch a.Key {
390					case "property", "name", "itemprop":
391						k = strings.ToLower(a.Val)
392					case "content":
393						v = a.Val
394					}
395				}
396				if k != "" && v != "" {
397					metas[k] = v
398				}
399			case atom.Time:
400				for _, a := range n.Attr {
401					if a.Key == "datetime" {
402						times = append(times, a.Val)
403					}
404				}
405				if n.FirstChild != nil && n.FirstChild.Type == html.TextNode {
406					times = append(times, n.FirstChild.Data)
407				}
408			}
409		}
410		for c := n.FirstChild; c != nil; c = c.NextSibling {
411			walk(c)
412		}
413	}
414	walk(root)
415
416	for _, k := range []string{
417		"article:published_time", "og:article:published_time", "datepublished",
418		"publishdate", "date", "dc.date", "dc.date.issued", "sailthru.date",
419		"parsely-pub-date", "article:modified_time",
420	} {
421		if t, ok := parseDate(metas[k]); ok {
422			return t, true
423		}
424	}
425	for _, v := range times {
426		if t, ok := parseDate(v); ok {
427			return t, true
428		}
429	}
430	return time.Time{}, false
431}
432
433func fromJSONLD(root *html.Node) (time.Time, bool) {
434	var out time.Time
435	found := false
436	var walk func(*html.Node)
437	walk = func(n *html.Node) {
438		if found {
439			return
440		}
441		if n.Type == html.ElementNode && n.DataAtom == atom.Script {
442			for _, a := range n.Attr {
443				if a.Key == "type" && strings.Contains(a.Val, "ld+json") && n.FirstChild != nil {
444					var v any
445					if json.Unmarshal([]byte(n.FirstChild.Data), &v) == nil {
446						if t, ok := digDate(v); ok {
447							out, found = t, true
448							return
449						}
450					}
451				}
452			}
453		}
454		for c := n.FirstChild; c != nil; c = c.NextSibling {
455			walk(c)
456		}
457	}
458	walk(root)
459	return out, found
460}
461
462func digDate(v any) (time.Time, bool) {
463	switch x := v.(type) {
464	case map[string]any:
465		for _, k := range []string{"datePublished", "dateCreated", "uploadDate"} {
466			if s, ok := x[k].(string); ok {
467				if t, ok := parseDate(s); ok {
468					return t, true
469				}
470			}
471		}
472		for _, sub := range x {
473			if t, ok := digDate(sub); ok {
474				return t, true
475			}
476		}
477	case []any:
478		for _, sub := range x {
479			if t, ok := digDate(sub); ok {
480				return t, true
481			}
482		}
483	}
484	return time.Time{}, false
485}
486
487func parseDate(s string) (time.Time, bool) {
488	s = strings.TrimSpace(s)
489	if s == "" {
490		return time.Time{}, false
491	}
492	for _, f := range []string{
493		time.RFC3339, "2006-01-02T15:04:05Z0700", "2006-01-02T15:04:05",
494		"2006-01-02 15:04:05", "2006-01-02", "01/02/2006",
495		"January 2, 2006", "2 January 2006", "Jan 2, 2006", "2 Jan 2006",
496		"02 January 2006", time.RFC1123, time.RFC1123Z,
497	} {
498		if t, err := time.Parse(f, s); err == nil && t.Year() > 1990 && t.Year() < 2100 {
499			return t, true
500		}
501	}
502	return time.Time{}, false
503}
504
505func abs(v float64) float64 {
506	if v < 0 {
507		return -v
508	}
509	return v
510}