Vectrum
Sign In
123 lines · 4.4 KB · Go
package websearch

import (
	"io"
	"strings"

	"golang.org/x/net/html"
	"golang.org/x/net/html/atom"
)

// noise is markup that is never content: code the browser was meant to run,
// and controls pretending to be text. Dropping it is safe on any page.
var noise = map[atom.Atom]bool{
	atom.Script: true, atom.Style: true, atom.Noscript: true,
	atom.Template: true, atom.Svg: true, atom.Iframe: true,
	atom.Form: true, atom.Button: true, atom.Select: true, atom.Textarea: true,
}

// furniture is the site around the page: menus, banners, the small print. It
// is usually most of the bytes and never the reason anyone fetched the URL —
// but "usually" is why it can be put back, see HTMLToText.
var furniture = map[atom.Atom]bool{
	atom.Nav: true, atom.Header: true, atom.Footer: true, atom.Aside: true,
}

// breaking are elements that end a line. The list is short on purpose: the
// point is to keep a document's shape, not to reimplement a renderer.
var breaking = map[atom.Atom]bool{
	atom.P: true, atom.Br: true, atom.Div: true, atom.Section: true,
	atom.Article: true, atom.Li: true, atom.Tr: true, atom.Blockquote: true,
	atom.Pre: true, atom.H1: true, atom.H2: true, atom.H3: true, atom.H4: true,
	atom.H5: true, atom.H6: true, atom.Table: true, atom.Ul: true, atom.Ol: true,
	atom.Dd: true, atom.Dt: true, atom.Hr: true, atom.Figcaption: true,
}

// minUseful is how little text is so little that the reduction is more likely
// to have gone wrong than the page to have been empty.
const minUseful = 200

// HTMLToText reduces a page to the text a reader would see.
//
// It parses rather than scans, and the difference is not academic. A scanner
// has to track which elements are open by counting tags, and real pages do not
// balance: go.dev's release notes open five <nav> elements and close two. A
// skip-by-counting therefore swallows the whole document from the first
// unclosed tag onwards — which is exactly what the first version of this
// function did, reducing 69 kB of release notes to their title. html.Parse
// implements HTML5's own error recovery, the algorithm browsers use, so a
// subtree has real boundaries that an unclosed tag cannot leak past.
//
// Furniture is dropped, then put back if dropping it left almost nothing. Most
// pages keep their content out of <header> and <nav>; the few that do not
// would otherwise come back blank, and blank is the one answer a fetch tool
// must never give when there was something there.
func HTMLToText(r io.Reader) (string, error) {
	doc, err := html.Parse(r)
	if err != nil {
		return "", err
	}
	strict := tidy(render(doc, true))
	if len([]rune(strict)) >= minUseful {
		return strict, nil
	}
	if lenient := tidy(render(doc, false)); len(lenient) > len(strict) {
		return lenient, nil
	}
	return strict, nil
}

// render walks the tree, appending text and breaking the line where a block
// element begins or ends. dropFurniture is false on the second pass.
func render(root *html.Node, dropFurniture bool) string {
	var b strings.Builder
	var walk func(*html.Node)
	walk = func(n *html.Node) {
		switch n.Type {
		case html.TextNode:
			b.WriteString(n.Data)
			return
		case html.CommentNode, html.DoctypeNode:
			return
		case html.ElementNode:
			if noise[n.DataAtom] || (dropFurniture && furniture[n.DataAtom]) {
				return
			}
			// <title> lives in <head> and is not part of the page's text, but
			// it is the best one-line summary a document has, so it is kept
			// and given a line of its own.
			if breaking[n.DataAtom] || n.DataAtom == atom.Title {
				b.WriteByte('\n')
			}
		}
		for c := n.FirstChild; c != nil; c = c.NextSibling {
			walk(c)
		}
		if n.Type == html.ElementNode && (breaking[n.DataAtom] || n.DataAtom == atom.Title) {
			b.WriteByte('\n')
		}
	}
	walk(root)
	return b.String()
}

// tidy collapses the whitespace HTML is written with: runs of spaces inside a
// line, and every blank line between them. Without it a reduced page is mostly
// indentation.
//
// Blank lines go entirely rather than being kept one deep as a paragraph
// break. Each block element already ends its own line, so the break is not
// lost — and a list of twenty items would otherwise cost forty lines of
// someone's context window to say the same thing.
func tidy(s string) string {
	lines := strings.Split(s, "\n")
	out := make([]string, 0, len(lines))
	for _, line := range lines {
		line = strings.Join(strings.Fields(line), " ")
		if line == "" {
			continue
		}
		out = append(out, line)
	}
	return strings.TrimSpace(strings.Join(out, "\n"))
}