marky

Lightweight bookmarking + plaintext archive
Log | Files | Refs | README | LICENSE

archiver.go (8160B)


      1 package internal
      2 
      3 import (
      4 	"bytes"
      5 	"context"
      6 	"fmt"
      7 	"io"
      8 	"net/http"
      9 	neturl "net/url"
     10 	"os/exec"
     11 	"strings"
     12 	"time"
     13 
     14 	"golang.org/x/net/html"
     15 )
     16 
     17 // ArchiveToText fetches the given URL and returns a plaintext rendering.
     18 // It attempts to extract the main content via Readability and then converts
     19 // the resulting HTML to text; falls back to full document if extraction fails.
     20 func ArchiveToText(ctx context.Context, rawURL string) (string, error) {
     21 	_, txt, err := TitleAndText(ctx, rawURL)
     22 	return txt, err
     23 }
     24 
     25 // TitleAndText fetches the URL and returns the page <title> (if any) and a
     26 // plaintext rendering of the document.
     27 func TitleAndText(ctx context.Context, rawURL string) (string, string, error) {
     28 	if _, err := neturl.ParseRequestURI(rawURL); err != nil {
     29 		return "", "", fmt.Errorf("invalid url: %w", err)
     30 	}
     31 	body, err := fetchHTML(ctx, rawURL)
     32 	if err != nil {
     33 		return "", "", err
     34 	}
     35 	htmlStr := string(body)
     36 	title := extractTitle(htmlStr)
     37 	// Prefer external w3m if available for robust text rendering; fall back to built-in.
     38 	var txt string
     39 	if path, err := exec.LookPath("w3m"); err == nil && path != "" {
     40 		if out, err := w3mDump(ctx, rawURL); err == nil && strings.TrimSpace(out) != "" {
     41 			txt = strings.TrimSpace(out)
     42 		} else {
     43 			txt = strings.TrimSpace(htmlToText(htmlStr))
     44 		}
     45 	} else {
     46 		txt = strings.TrimSpace(htmlToText(htmlStr))
     47 	}
     48 	return title, txt, nil
     49 }
     50 
     51 // IsHTMLURL attempts a HEAD request and inspects Content-Type to determine if
     52 // the resource is HTML. If Content-Type is empty or HEAD fails, we assume HTML
     53 // to avoid false negatives on servers that mishandle HEAD.
     54 func IsHTMLURL(ctx context.Context, rawURL string) bool {
     55 	req, err := http.NewRequestWithContext(ctx, http.MethodHead, rawURL, nil)
     56 	if err != nil {
     57 		return true
     58 	}
     59 	req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
     60 	req.Header.Set("Accept-Language", "en-US,en;q=0.9")
     61 	client := &http.Client{Timeout: 15 * time.Second}
     62 	resp, err := client.Do(req)
     63 	if err != nil {
     64 		return true
     65 	}
     66 	defer resp.Body.Close()
     67 	// A denial or other error response describes the error document, not the
     68 	// resource. Treat it as inconclusive so the archive path can still try its
     69 	// renderer fallback (for example, w3m may be allowed by the origin).
     70 	if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
     71 		return true
     72 	}
     73 	ct := strings.ToLower(resp.Header.Get("Content-Type"))
     74 	if ct == "" {
     75 		return true
     76 	}
     77 	if strings.Contains(ct, "text/html") || strings.Contains(ct, "+html") || strings.Contains(ct, "application/xhtml+xml") {
     78 		return true
     79 	}
     80 	return false
     81 }
     82 
     83 // w3mDump runs `w3m -dump` to render the given URL to plaintext.
     84 func w3mDump(ctx context.Context, url string) (string, error) {
     85 	// Force UTF-8 output to avoid mojibake on systems with non-UTF locales.
     86 	cmd := exec.CommandContext(ctx, "w3m", "-dump", "-T", "text/html", "-O", "UTF-8", "-o", "display_link_number=1", url)
     87 	// Keep environment as-is; rely on user PATH. Capture stdout only.
     88 	b, err := cmd.Output()
     89 	if err != nil {
     90 		return "", fmt.Errorf("w3m dump: %w", err)
     91 	}
     92 	return string(b), nil
     93 }
     94 
     95 func fetchHTML(ctx context.Context, rawURL string) ([]byte, error) {
     96 	req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
     97 	if err != nil {
     98 		return nil, fmt.Errorf("build request: %w", err)
     99 	}
    100 	req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
    101 	req.Header.Set("Accept-Language", "en-US,en;q=0.9")
    102 
    103 	client := &http.Client{Timeout: 30 * time.Second}
    104 	resp, err := client.Do(req)
    105 	if err != nil {
    106 		return nil, fmt.Errorf("fetch: %w", err)
    107 	}
    108 	defer resp.Body.Close()
    109 	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
    110 		return nil, fmt.Errorf("unexpected status: %s", resp.Status)
    111 	}
    112 	body, err := io.ReadAll(resp.Body)
    113 	if err != nil {
    114 		return nil, fmt.Errorf("read body: %w", err)
    115 	}
    116 	return body, nil
    117 }
    118 
    119 // htmlToText converts HTML to a readable plaintext representation.
    120 // It removes script/style, collapses whitespace, and inserts newlines around block elements.
    121 type liState struct{ hasContent bool }
    122 
    123 func htmlToText(s string) string {
    124 	doc, err := html.Parse(strings.NewReader(s))
    125 	if err != nil {
    126 		return strings.TrimSpace(stripTagsFallback(s))
    127 	}
    128 	var b bytes.Buffer
    129 	var walk func(n *html.Node, linkHref string, li *liState)
    130 	walk = func(n *html.Node, linkHref string, li *liState) {
    131 		if n.Type == html.ElementNode {
    132 			tag := strings.ToLower(n.Data)
    133 			switch tag {
    134 			case "script", "style":
    135 				return
    136 			case "br":
    137 				ensureNL(&b, 1)
    138 			case "p", "div", "section", "article", "header", "footer", "h1", "h2", "h3", "h4", "h5", "h6", "pre", "blockquote":
    139 				// Ensure blank line before block paragraphs/headers
    140 				ensureNL(&b, 2)
    141 			case "ul", "ol":
    142 				ensureNL(&b, 1)
    143 			case "li":
    144 				ensureNL(&b, 1)
    145 			}
    146 			if tag == "a" {
    147 				// capture href
    148 				if href := getAttr(n, "href"); href != "" {
    149 					linkHref = href
    150 				}
    151 			}
    152 		}
    153 
    154 		if n.Type == html.TextNode {
    155 			// Only emit bullet when the first non-empty text appears within this <li>
    156 			trimmed := strings.TrimSpace(n.Data)
    157 			if li != nil && !li.hasContent && trimmed != "" {
    158 				ensureNL(&b, 1)
    159 				b.WriteString("- ")
    160 				li.hasContent = true
    161 			}
    162 			writeCollapsed(&b, n.Data)
    163 		}
    164 
    165 		// children
    166 		startLen := b.Len()
    167 		// If we are at an <li>, propagate a fresh liState to children
    168 		var childLI *liState
    169 		if n.Type == html.ElementNode && strings.EqualFold(n.Data, "li") {
    170 			childLI = &liState{}
    171 		} else {
    172 			childLI = li
    173 		}
    174 		for c := n.FirstChild; c != nil; c = c.NextSibling {
    175 			walk(c, linkHref, childLI)
    176 		}
    177 
    178 		if n.Type == html.ElementNode {
    179 			tag := strings.ToLower(n.Data)
    180 			// If this was an anchor with text and href, append inline reference
    181 			if tag == "a" && linkHref != "" && b.Len() > startLen {
    182 				b.WriteString(" [")
    183 				b.WriteString(linkHref)
    184 				b.WriteString("]")
    185 			}
    186 			switch tag {
    187 			case "p", "div", "section", "article", "header", "footer", "h1", "h2", "h3", "h4", "h5", "h6", "pre", "blockquote":
    188 				ensureNL(&b, 2) // blank line after paragraphs/headers
    189 			case "li":
    190 				ensureNL(&b, 1)
    191 			case "tr":
    192 				ensureNL(&b, 1)
    193 			}
    194 		}
    195 	}
    196 	walk(doc, "", nil)
    197 	return b.String()
    198 }
    199 
    200 // extractTitle parses the HTML and returns the document <title> text if found.
    201 func extractTitle(s string) string {
    202 	doc, err := html.Parse(strings.NewReader(s))
    203 	if err != nil {
    204 		return ""
    205 	}
    206 	var title string
    207 	var walk func(*html.Node)
    208 	walk = func(n *html.Node) {
    209 		if n.Type == html.ElementNode && strings.EqualFold(n.Data, "title") {
    210 			// Collect text nodes under <title>
    211 			var b strings.Builder
    212 			for c := n.FirstChild; c != nil; c = c.NextSibling {
    213 				if c.Type == html.TextNode {
    214 					b.WriteString(c.Data)
    215 				}
    216 			}
    217 			title = strings.TrimSpace(b.String())
    218 			return
    219 		}
    220 		for c := n.FirstChild; c != nil; c = c.NextSibling {
    221 			if title != "" {
    222 				break
    223 			}
    224 			walk(c)
    225 		}
    226 	}
    227 	walk(doc)
    228 	return title
    229 }
    230 
    231 func getAttr(n *html.Node, key string) string {
    232 	for _, a := range n.Attr {
    233 		if strings.EqualFold(a.Key, key) {
    234 			return a.Val
    235 		}
    236 	}
    237 	return ""
    238 }
    239 
    240 func ensureNL(b *bytes.Buffer, n int) {
    241 	// Ensure exactly at least n trailing newlines
    242 	count := 0
    243 	bs := b.Bytes()
    244 	for i := len(bs) - 1; i >= 0 && bs[i] == '\n'; i-- {
    245 		count++
    246 	}
    247 	for count < n {
    248 		b.WriteByte('\n')
    249 		count++
    250 	}
    251 }
    252 
    253 func writeCollapsed(b *bytes.Buffer, s string) {
    254 	// collapse all whitespace runs to single spaces; trim leading spaces at line start
    255 	space := false
    256 	for _, r := range s {
    257 		if r == '\n' || r == '\r' || r == '\t' || r == ' ' || r == '\f' {
    258 			space = true
    259 			continue
    260 		}
    261 		if space {
    262 			if last := lastByte(b); last != 0 && last != '\n' && last != ' ' {
    263 				b.WriteByte(' ')
    264 			}
    265 			space = false
    266 		}
    267 		b.WriteRune(r)
    268 	}
    269 }
    270 
    271 func lastByte(b *bytes.Buffer) byte {
    272 	if b.Len() == 0 {
    273 		return 0
    274 	}
    275 	return b.Bytes()[b.Len()-1]
    276 }
    277 
    278 func stripTagsFallback(s string) string {
    279 	// naive fallback: remove angle-bracketed tags
    280 	out := make([]rune, 0, len(s))
    281 	inTag := false
    282 	for _, r := range s {
    283 		switch r {
    284 		case '<':
    285 			inTag = true
    286 		case '>':
    287 			inTag = false
    288 		default:
    289 			if !inTag {
    290 				out = append(out, r)
    291 			}
    292 		}
    293 	}
    294 	return string(out)
    295 }