marky

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

commit 97c0eb383058b979acbb5dcf7a17340fded79297
parent cc4249f287ba6c604f11e99f79bc03f4264125c3
Author: David Eisinger <[email protected]>
Date:   Mon,  6 Oct 2025 13:55:56 -0400

title fetching

Diffstat:
MREADME.md | 6+++---
Mcmd/marky.go | 16++++++++++++----
Minternal/archiver.go | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
3 files changed, 69 insertions(+), 17 deletions(-)

diff --git a/README.md b/README.md @@ -33,9 +33,9 @@ Create a new bookmark. - Request body: ``` { - "url": "https://example.com", - "title": "Example", - "description": "Optional description" // optional + "url": "https://example.com", // required + "title": "Example", // optional; fetched from page <title> if missing + "description": "Optional description" // optional } ``` - Responses: diff --git a/cmd/marky.go b/cmd/marky.go @@ -80,8 +80,8 @@ func main() { http.Error(w, "invalid JSON payload", http.StatusBadRequest) return } - if req.URL == "" || req.Title == "" { - http.Error(w, "url and title are required", http.StatusBadRequest) + if req.URL == "" { + http.Error(w, "url is required", http.StatusBadRequest) return } @@ -92,11 +92,19 @@ func main() { } ctx, cancel := context.WithTimeout(r.Context(), 45*time.Second) defer cancel() - txt, err := dbpkg.ArchiveToText(ctx, req.URL) + fetchedTitle, txt, err := dbpkg.TitleAndText(ctx, req.URL) if err != nil { http.Error(w, "failed to archive URL", http.StatusBadGateway) return } + title := req.Title + if strings.TrimSpace(title) == "" { + if strings.TrimSpace(fetchedTitle) != "" { + title = fetchedTitle + } else { + title = req.URL + } + } // If URL exists, re-archive and touch updated_at; else create if existing, err := db.GetBookmarkByURL(r.Context(), req.URL); err != nil { log.Printf("lookup by url error: %v", err) @@ -126,7 +134,7 @@ func main() { _ = json.NewEncoder(w).Encode(toResp(updated)) return } else { - created, err := db.CreateBookmarkWithArchive(r.Context(), req.URL, req.Title, req.Description, txt) + created, err := db.CreateBookmarkWithArchive(r.Context(), req.URL, title, req.Description, txt) if err != nil { log.Printf("create bookmark with archive error: %v", err) http.Error(w, "internal server error", http.StatusInternalServerError) diff --git a/internal/archiver.go b/internal/archiver.go @@ -17,34 +17,47 @@ import ( // It attempts to extract the main content via Readability and then converts // the resulting HTML to text; falls back to full document if extraction fails. func ArchiveToText(ctx context.Context, rawURL string) (string, error) { + _, txt, err := TitleAndText(ctx, rawURL) + return txt, err +} + +// TitleAndText fetches the URL and returns the page <title> (if any) and a +// plaintext rendering of the document. +func TitleAndText(ctx context.Context, rawURL string) (string, string, error) { if _, err := neturl.ParseRequestURI(rawURL); err != nil { - return "", fmt.Errorf("invalid url: %w", err) + return "", "", fmt.Errorf("invalid url: %w", err) } + body, err := fetchHTML(ctx, rawURL) + if err != nil { + return "", "", err + } + htmlStr := string(body) + title := extractTitle(htmlStr) + txt := strings.TrimSpace(htmlToText(htmlStr)) + return title, txt, nil +} +func fetchHTML(ctx context.Context, rawURL string) ([]byte, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) if err != nil { - return "", fmt.Errorf("build request: %w", err) + return nil, fmt.Errorf("build request: %w", err) } req.Header.Set("User-Agent", "marky-archiver/1.0 (+https://example.invalid)") client := &http.Client{Timeout: 30 * time.Second} resp, err := client.Do(req) if err != nil { - return "", fmt.Errorf("fetch: %w", err) + return nil, fmt.Errorf("fetch: %w", err) } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return "", fmt.Errorf("unexpected status: %s", resp.Status) + return nil, fmt.Errorf("unexpected status: %s", resp.Status) } - body, err := io.ReadAll(resp.Body) if err != nil { - return "", fmt.Errorf("read body: %w", err) + return nil, fmt.Errorf("read body: %w", err) } - - // Convert full HTML document to plaintext with a basic parser. - txt := htmlToText(string(body)) - return strings.TrimSpace(txt), nil + return body, nil } // htmlToText converts HTML to a readable plaintext representation. @@ -128,6 +141,37 @@ func htmlToText(s string) string { return b.String() } +// extractTitle parses the HTML and returns the document <title> text if found. +func extractTitle(s string) string { + doc, err := html.Parse(strings.NewReader(s)) + if err != nil { + return "" + } + var title string + var walk func(*html.Node) + walk = func(n *html.Node) { + if n.Type == html.ElementNode && strings.EqualFold(n.Data, "title") { + // Collect text nodes under <title> + var b strings.Builder + for c := n.FirstChild; c != nil; c = c.NextSibling { + if c.Type == html.TextNode { + b.WriteString(c.Data) + } + } + title = strings.TrimSpace(b.String()) + return + } + for c := n.FirstChild; c != nil; c = c.NextSibling { + if title != "" { + break + } + walk(c) + } + } + walk(doc) + return title +} + func getAttr(n *html.Node, key string) string { for _, a := range n.Attr { if strings.EqualFold(a.Key, key) {