183 lines
3.9 KiB
Go
183 lines
3.9 KiB
Go
package webpage
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-shiori/go-readability"
|
|
)
|
|
|
|
const (
|
|
DefaultMaxPages = 6
|
|
DefaultMaxMarkdown = 6000
|
|
DefaultPerPageChars = 2500
|
|
DefaultFetchTimeout = 18 * time.Second
|
|
DefaultMaxBodyBytes = 2 << 20
|
|
)
|
|
|
|
type FetchOptions struct {
|
|
MaxPages int
|
|
MaxMarkdown int
|
|
PerPageChars int
|
|
Timeout time.Duration
|
|
UserAgent string
|
|
}
|
|
|
|
func (o FetchOptions) normalized() FetchOptions {
|
|
out := o
|
|
if out.MaxPages <= 0 {
|
|
out.MaxPages = DefaultMaxPages
|
|
}
|
|
if out.MaxMarkdown <= 0 {
|
|
out.MaxMarkdown = DefaultMaxMarkdown
|
|
}
|
|
if out.PerPageChars <= 0 {
|
|
out.PerPageChars = DefaultPerPageChars
|
|
}
|
|
if out.Timeout <= 0 {
|
|
out.Timeout = DefaultFetchTimeout
|
|
}
|
|
if strings.TrimSpace(out.UserAgent) == "" {
|
|
out.UserAgent = "ThreadMasterKnowledgeBot/1.0 (+https://thread-master.local)"
|
|
}
|
|
return out
|
|
}
|
|
|
|
type Digest struct {
|
|
URL string
|
|
Title string
|
|
Markdown string
|
|
Error string
|
|
}
|
|
|
|
// FetchDigests fetches readable page content and formats each page as lightweight markdown.
|
|
func FetchDigests(ctx context.Context, urls []string, opts FetchOptions) []Digest {
|
|
opts = opts.normalized()
|
|
seen := map[string]struct{}{}
|
|
out := make([]Digest, 0, min(len(urls), opts.MaxPages))
|
|
for _, raw := range urls {
|
|
if len(out) >= opts.MaxPages {
|
|
break
|
|
}
|
|
u := strings.TrimSpace(raw)
|
|
if u == "" {
|
|
continue
|
|
}
|
|
key := strings.ToLower(u)
|
|
if _, ok := seen[key]; ok {
|
|
continue
|
|
}
|
|
seen[key] = struct{}{}
|
|
if !FetchableURL(u) {
|
|
continue
|
|
}
|
|
out = append(out, fetchOne(ctx, u, opts))
|
|
}
|
|
return out
|
|
}
|
|
|
|
func fetchOne(ctx context.Context, pageURL string, opts FetchOptions) Digest {
|
|
d := Digest{URL: pageURL}
|
|
reqCtx, cancel := context.WithTimeout(ctx, opts.Timeout)
|
|
defer cancel()
|
|
|
|
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, pageURL, nil)
|
|
if err != nil {
|
|
d.Error = err.Error()
|
|
return d
|
|
}
|
|
req.Header.Set("User-Agent", opts.UserAgent)
|
|
req.Header.Set("Accept", "text/html,application/xhtml+xml")
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
d.Error = err.Error()
|
|
return d
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
d.Error = fmt.Sprintf("http %d", resp.StatusCode)
|
|
return d
|
|
}
|
|
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, DefaultMaxBodyBytes))
|
|
if err != nil {
|
|
d.Error = err.Error()
|
|
return d
|
|
}
|
|
parsed, err := url.Parse(pageURL)
|
|
if err != nil {
|
|
d.Error = err.Error()
|
|
return d
|
|
}
|
|
article, err := readability.FromReader(strings.NewReader(string(body)), parsed)
|
|
if err != nil {
|
|
d.Error = err.Error()
|
|
return d
|
|
}
|
|
title := strings.TrimSpace(article.Title)
|
|
text := strings.TrimSpace(article.TextContent)
|
|
if title == "" && text == "" {
|
|
d.Error = "empty article"
|
|
return d
|
|
}
|
|
d.Title = title
|
|
d.Markdown = toMarkdown(title, text, opts.PerPageChars)
|
|
return d
|
|
}
|
|
|
|
func toMarkdown(title, text string, maxChars int) string {
|
|
var b strings.Builder
|
|
if title != "" {
|
|
b.WriteString("# ")
|
|
b.WriteString(title)
|
|
b.WriteString("\n\n")
|
|
}
|
|
if text != "" {
|
|
b.WriteString(text)
|
|
}
|
|
out := strings.TrimSpace(b.String())
|
|
if maxChars > 0 && len([]rune(out)) > maxChars {
|
|
runes := []rune(out)
|
|
out = strings.TrimSpace(string(runes[:maxChars])) + "…"
|
|
}
|
|
return out
|
|
}
|
|
|
|
// FetchableURL returns false for unsupported or crawler-owned hosts.
|
|
func FetchableURL(raw string) bool {
|
|
u, err := url.Parse(strings.TrimSpace(raw))
|
|
if err != nil || u.Scheme == "" || u.Host == "" {
|
|
return false
|
|
}
|
|
switch strings.ToLower(u.Scheme) {
|
|
case "http", "https":
|
|
default:
|
|
return false
|
|
}
|
|
host := strings.ToLower(u.Hostname())
|
|
if host == "localhost" || strings.HasSuffix(host, ".local") {
|
|
return false
|
|
}
|
|
if strings.Contains(host, "threads.net") || strings.Contains(host, "threads.com") {
|
|
return false
|
|
}
|
|
path := strings.ToLower(u.Path)
|
|
if strings.HasSuffix(path, ".pdf") || strings.HasSuffix(path, ".zip") {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func min(a, b int) int {
|
|
if a < b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|