Files
llm-browser/backend/internal/browse/markdown.go
T
2026-10-19 15:39:46 +00:00

578 lines
14 KiB
Go

package browse
import (
"net/url"
"regexp"
"strconv"
"strings"
"golang.org/x/net/html"
"golang.org/x/net/html/atom"
)
// pageMeta is what agents read from <head> plus document shape statistics
// collected while walking the body.
type pageMeta struct {
Title string
Description string
Canonical string
Robots string
Lang string
H1 string
LinkCount int
DeadLinks int
ScriptCount int
ImageCount int
WordCount int
TextLength int
}
// maxMarkdownChars caps the converted markdown before it reaches the wire.
const maxMarkdownChars = 200_000
var (
hiddenStyleRe = regexp.MustCompile(`(?i)display\s*:\s*none|visibility\s*:\s*hidden`)
wsRe = regexp.MustCompile(`\s+`)
manyNlRe = regexp.MustCompile(`\n{3,}`)
)
// htmlToMarkdown parses the document once and returns the body rendered as
// markdown plus the head metadata. baseURL resolves relative links.
func htmlToMarkdown(body []byte, baseURL string) (string, pageMeta, error) {
var meta pageMeta
doc, err := html.Parse(strings.NewReader(string(body)))
if err != nil {
return "", meta, err
}
var htmlNode, bodyNode *html.Node
var walk func(n *html.Node)
walk = func(n *html.Node) {
for c := n.FirstChild; c != nil; c = c.NextSibling {
if c.Type != html.ElementNode {
continue
}
switch c.DataAtom {
case atom.Html:
htmlNode = c
walk(c)
case atom.Head:
collectHead(c, &meta)
case atom.Body:
bodyNode = c
}
}
}
walk(doc)
if htmlNode != nil {
meta.Lang = attr(htmlNode, "lang")
}
if bodyNode != nil {
inspectBody(bodyNode, &meta)
}
r := &mdRenderer{base: parseBase(baseURL)}
return r.renderBody(bodyNode), meta, nil
}
// collectHead reads title, description, robots and canonical from <head>.
func collectHead(head *html.Node, meta *pageMeta) {
for c := head.FirstChild; c != nil; c = c.NextSibling {
if c.Type != html.ElementNode {
continue
}
switch c.DataAtom {
case atom.Title:
if meta.Title == "" {
meta.Title = collapse(textContent(c))
}
case atom.Meta:
content := collapse(attr(c, "content"))
switch strings.ToLower(attr(c, "name")) {
case "description":
if meta.Description == "" {
meta.Description = content
}
case "robots":
if meta.Robots == "" {
meta.Robots = content
}
}
case atom.Link:
if strings.EqualFold(attr(c, "rel"), "canonical") && meta.Canonical == "" {
meta.Canonical = attr(c, "href")
}
}
}
}
// inspectBody counts the elements checks care about and measures the
// visible text (script/style/svg content stays out of the count).
func inspectBody(body *html.Node, meta *pageMeta) {
var walk func(n *html.Node)
walk = func(n *html.Node) {
if n.Type == html.ElementNode {
switch n.DataAtom {
case atom.A:
href := strings.TrimSpace(attr(n, "href"))
if href == "" || strings.HasPrefix(href, "#") || strings.HasPrefix(strings.ToLower(href), "javascript:") {
meta.DeadLinks++
} else {
meta.LinkCount++
}
case atom.Script:
meta.ScriptCount++
return
case atom.Style, atom.Svg, atom.Noscript, atom.Template, atom.Iframe:
return
case atom.Img:
meta.ImageCount++
return
case atom.H1:
if meta.H1 == "" {
meta.H1 = collapse(textContent(n))
}
}
}
if n.Type == html.TextNode {
meta.TextLength += len(n.Data)
meta.WordCount += len(strings.Fields(n.Data))
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
walk(c)
}
}
walk(body)
}
// mdRenderer renders a parsed document as markdown.
type mdRenderer struct {
base *url.URL
}
func (r *mdRenderer) renderBody(body *html.Node) string {
if body == nil {
return ""
}
var b strings.Builder
r.blocks(&b, body)
out := strings.TrimSpace(b.String())
return manyNlRe.ReplaceAllString(out, "\n\n")
}
// skipped reports whether an element contributes nothing an agent could
// act on: invisible content, script payloads, and embedded documents.
func skipped(n *html.Node) bool {
if n.Type != html.ElementNode {
return false
}
switch n.DataAtom {
case atom.Script, atom.Style, atom.Noscript, atom.Template, atom.Svg,
atom.Head, atom.Link, atom.Meta, atom.Iframe, atom.Object, atom.Embed:
return true
}
if hasAttr(n, "hidden") || strings.EqualFold(attr(n, "aria-hidden"), "true") {
return true
}
return hiddenStyleRe.MatchString(attr(n, "style"))
}
// blocks renders the children of n as a sequence of markdown blocks.
func (r *mdRenderer) blocks(b *strings.Builder, n *html.Node) {
for c := n.FirstChild; c != nil; c = c.NextSibling {
if skipped(c) {
continue
}
switch {
case c.Type == html.TextNode:
if s := strings.TrimSpace(collapse(c.Data)); s != "" {
b.WriteString(s + "\n\n")
}
case c.Type == html.ElementNode:
r.element(b, c)
}
}
}
// element renders one block-level element.
func (r *mdRenderer) element(b *strings.Builder, n *html.Node) {
switch n.DataAtom {
case atom.H1, atom.H2, atom.H3, atom.H4, atom.H5, atom.H6:
level := int(n.DataAtom) - int(atom.H1) + 1
b.WriteString(strings.Repeat("#", level) + " " + strings.TrimSpace(r.inline(n)) + "\n\n")
case atom.P:
if s := strings.TrimSpace(r.inline(n)); s != "" {
b.WriteString(s + "\n\n")
}
case atom.Hr:
b.WriteString("---\n\n")
case atom.Blockquote:
var inner strings.Builder
r.blocks(&inner, n)
b.WriteString(prefixLines(inner.String(), "> "))
case atom.Pre:
b.WriteString("```\n" + strings.Trim(textContent(n), "\n") + "\n```\n\n")
case atom.Ul, atom.Ol:
var lb strings.Builder
r.list(&lb, n, "")
b.WriteString(strings.TrimRight(lb.String(), "\n") + "\n\n")
case atom.Table:
r.table(b, n)
case atom.Dl:
r.defList(b, n)
case atom.Button, atom.A, atom.Span, atom.Strong, atom.Em, atom.Code, atom.Small, atom.Label:
// phrasing content at block position (nav links, card wrappers):
// render with full inline semantics so anchors keep their href
var ib strings.Builder
r.inlineNode(&ib, n)
if s := strings.TrimSpace(ib.String()); s != "" {
b.WriteString(s + "\n\n")
}
default:
r.blocks(b, n)
}
}
// inlineNode renders one child node with inline semantics — the single
// dispatch point for list items and block-position phrasing elements, so
// anchors keep their href wherever they appear.
func (r *mdRenderer) inlineNode(b *strings.Builder, n *html.Node) {
if n.Type == html.TextNode {
b.WriteString(collapse(n.Data))
return
}
if n.Type == html.ElementNode {
r.inlineElement(b, n)
}
}
// list renders ul/ol items; nested lists indent under their item.
func (r *mdRenderer) list(b *strings.Builder, n *html.Node, indent string) {
ordered := n.DataAtom == atom.Ol
index := 1
if v := attr(n, "start"); v != "" {
if i, err := strconv.Atoi(v); err == nil && i > 0 {
index = i
}
}
for li := n.FirstChild; li != nil; li = li.NextSibling {
if li.Type != html.ElementNode || li.DataAtom != atom.Li {
continue
}
marker := "- "
if ordered {
marker = strconv.Itoa(index) + ". "
index++
}
var text strings.Builder
var nested []*html.Node
for c := li.FirstChild; c != nil; c = c.NextSibling {
if skipped(c) {
continue
}
if c.Type == html.ElementNode && (c.DataAtom == atom.Ul || c.DataAtom == atom.Ol) {
nested = append(nested, c)
continue
}
r.inlineNode(&text, c)
}
if item := strings.TrimSpace(text.String()); item != "" {
b.WriteString(indent + marker + item + "\n")
}
for _, sub := range nested {
var nb strings.Builder
r.list(&nb, sub, indent+" ")
b.WriteString(strings.TrimRight(nb.String(), "\n") + "\n")
}
}
}
// table renders a GFM pipe table; the first header row (th, else the first
// tr) becomes the table head.
func (r *mdRenderer) table(b *strings.Builder, n *html.Node) {
var header []string
var rows [][]string
collectRows := func(scope *html.Node) {
for tr := scope.FirstChild; tr != nil; tr = tr.NextSibling {
if tr.Type != html.ElementNode || tr.DataAtom != atom.Tr {
continue
}
var cells []string
isHeader := false
for cell := tr.FirstChild; cell != nil; cell = cell.NextSibling {
if cell.Type != html.ElementNode {
continue
}
switch cell.DataAtom {
case atom.Th:
isHeader = true
cells = append(cells, r.cell(cell))
case atom.Td:
cells = append(cells, r.cell(cell))
}
}
if len(cells) == 0 {
continue
}
if isHeader && header == nil {
header = cells
} else {
rows = append(rows, cells)
}
}
}
hasSections := false
for c := n.FirstChild; c != nil; c = c.NextSibling {
if c.Type != html.ElementNode {
continue
}
switch c.DataAtom {
case atom.Thead, atom.Tbody, atom.Tfoot:
hasSections = true
collectRows(c)
}
}
if !hasSections {
collectRows(n) // table with bare rows
}
if header == nil && len(rows) > 0 {
header, rows = rows[0], rows[1:]
}
cols := len(header)
for _, row := range rows {
if len(row) > cols {
cols = len(row)
}
}
if cols == 0 {
return
}
pad := func(row []string) []string {
out := make([]string, cols)
copy(out, row)
return out
}
b.WriteString("| " + strings.Join(pad(header), " | ") + " |\n")
b.WriteString("|" + strings.Repeat(" --- |", cols) + "\n")
for _, row := range rows {
b.WriteString("| " + strings.Join(pad(row), " | ") + " |\n")
}
b.WriteString("\n")
}
// defList renders dt as a bold term and dd as its paragraph.
func (r *mdRenderer) defList(b *strings.Builder, n *html.Node) {
for c := n.FirstChild; c != nil; c = c.NextSibling {
if c.Type != html.ElementNode {
continue
}
switch c.DataAtom {
case atom.Dt:
if s := strings.TrimSpace(r.inline(c)); s != "" {
b.WriteString("**" + s + "**\n\n")
}
case atom.Dd:
if s := strings.TrimSpace(r.inline(c)); s != "" {
b.WriteString(s + "\n\n")
}
}
}
}
// cell flattens one table cell to a single line.
func (r *mdRenderer) cell(n *html.Node) string {
s := strings.TrimSpace(r.inline(n))
return strings.ReplaceAll(s, "|", "\\|")
}
// inline renders phrasing content to a markdown string.
func (r *mdRenderer) inline(n *html.Node) string {
var b strings.Builder
r.inlineInto(&b, n)
return b.String()
}
func (r *mdRenderer) inlineInto(b *strings.Builder, n *html.Node) {
for c := n.FirstChild; c != nil; c = c.NextSibling {
if skipped(c) {
continue
}
switch {
case c.Type == html.TextNode:
b.WriteString(collapse(c.Data))
case c.Type == html.ElementNode:
r.inlineElement(b, c)
}
}
}
func (r *mdRenderer) inlineElement(b *strings.Builder, n *html.Node) {
switch n.DataAtom {
case atom.A:
label := strings.TrimSpace(r.inline(n))
if label == "" {
label = attr(n, "href")
}
href := strings.TrimSpace(attr(n, "href"))
lower := strings.ToLower(href)
if href == "" || strings.HasPrefix(href, "#") || strings.HasPrefix(lower, "javascript:") {
// an agent cannot follow these; keep the text only
b.WriteString(label)
return
}
b.WriteString("[" + label + "](" + r.resolve(href) + ")")
case atom.Img:
src := strings.TrimSpace(attr(n, "src"))
if src == "" {
return
}
alt := collapse(attr(n, "alt"))
if alt == "" {
alt = baseName(src)
}
b.WriteString("![" + alt + "](" + r.resolve(src) + ")")
case atom.Br:
b.WriteString("\n")
case atom.Hr:
b.WriteString("\n---\n")
case atom.Strong, atom.B:
b.WriteString("**" + strings.TrimSpace(r.inline(n)) + "**")
case atom.Em, atom.I:
b.WriteString("*" + strings.TrimSpace(r.inline(n)) + "*")
case atom.Del, atom.S:
b.WriteString("~~" + strings.TrimSpace(r.inline(n)) + "~~")
case atom.Code:
b.WriteString("`" + strings.TrimSpace(r.inline(n)) + "`")
case atom.Summary:
b.WriteString("**" + strings.TrimSpace(r.inline(n)) + "** ")
case atom.Input, atom.Select, atom.Textarea, atom.Form:
// form controls have no markdown meaning an agent can act on
default:
if blockish[n.DataAtom] {
// block-level content inside inline context (nav menus, divs in
// paragraphs): keep the words from running together
padInline(b)
r.inlineInto(b, n)
padInline(b)
return
}
r.inlineInto(b, n)
}
}
// blockish marks elements a browser starts on a fresh line; adjacent ones
// need separating whitespace once flattened to inline text.
var blockish = map[atom.Atom]bool{
atom.Div: true, atom.Ul: true, atom.Ol: true, atom.Li: true, atom.Nav: true,
atom.Section: true, atom.Article: true, atom.Header: true, atom.Footer: true,
atom.Aside: true, atom.Main: true, atom.Form: true, atom.Fieldset: true,
atom.Figure: true, atom.P: true, atom.Table: true, atom.Tr: true,
}
func padInline(b *strings.Builder) {
s := b.String()
if s == "" {
return
}
if last := s[len(s)-1]; last != ' ' && last != '\n' {
b.WriteByte(' ')
}
}
// resolve turns href into an absolute URL against the inspected page.
func (r *mdRenderer) resolve(href string) string {
if r.base == nil {
return href
}
u, err := url.Parse(href)
if err != nil {
return href
}
return r.base.ResolveReference(u).String()
}
func parseBase(raw string) *url.URL {
u, err := url.Parse(raw)
if err != nil {
return nil
}
return u
}
// collapse flattens whitespace runs to single spaces.
func collapse(s string) string { return wsRe.ReplaceAllString(s, " ") }
// textContent returns the raw concatenated text under n.
func textContent(n *html.Node) string {
var b strings.Builder
var walk func(n *html.Node)
walk = func(n *html.Node) {
if n.Type == html.TextNode {
b.WriteString(n.Data)
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
walk(c)
}
}
walk(n)
return b.String()
}
func prefixLines(s, prefix string) string {
lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
for i, line := range lines {
if line == "" {
lines[i] = strings.TrimRight(prefix, " ")
continue
}
lines[i] = prefix + line
}
return strings.Join(lines, "\n") + "\n\n"
}
func baseName(src string) string {
s := src
if i := strings.IndexAny(s, "?#"); i >= 0 {
s = s[:i]
}
if i := strings.LastIndex(s, "/"); i >= 0 {
s = s[i+1:]
}
if s == "" {
return "image"
}
return s
}
func attr(n *html.Node, key string) string {
for _, a := range n.Attr {
if a.Key == key {
return a.Val
}
}
return ""
}
func hasAttr(n *html.Node, key string) bool {
for _, a := range n.Attr {
if a.Key == key {
return true
}
}
return false
}
// truncateMarkdown caps markdown at max chars without splitting a rune.
func truncateMarkdown(md string, max int) (string, bool) {
if len(md) <= max {
return md, false
}
// step back to a rune boundary so the cut stays valid UTF-8
for i := max; i > max-4 && i > 0; i-- {
if (md[i] & 0xC0) != 0x80 {
return md[:i], true
}
}
return md[:max], true
}