thread-master/backend/internal/library/webpage/digest.go

183 lines
3.9 KiB
Go
Raw Permalink Normal View History

2026-06-28 08:28:42 +00:00
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
2026-06-28 08:28:42 +00:00
)
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
}