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 }