hako/internal/archival/archiver/thumbnail.go
2026-01-12 19:35:18 +01:00

307 lines
8.3 KiB
Go

package archiver
import (
"bytes"
"context"
"encoding/base64"
"fmt"
"image"
"image/jpeg"
"image/png"
"io"
"net/http"
"net/url"
"strings"
"github.com/PuerkitoBio/goquery"
"golang.org/x/image/draw"
)
const (
thumbnailWidth = 128
thumbnailHeight = 96
)
// ThumbnailResult contains the result of thumbnail extraction
type ThumbnailResult struct {
ImageBytes []byte
MimeType string
Filename string
}
// extractThumbnailFromHTML extracts a thumbnail image from HTML content
func extractThumbnailFromHTML(ctx context.Context, htmlBytes []byte, pageURL string, resize bool) (*ThumbnailResult, error) {
// Parse HTML
doc, err := goquery.NewDocumentFromReader(bytes.NewReader(htmlBytes))
if err != nil {
return nil, fmt.Errorf("failed to parse HTML: %w", err)
}
// Priority 1: Open Graph image
if ogImage := doc.Find("meta[property='og:image']").First(); ogImage.Length() > 0 {
if content, exists := ogImage.Attr("content"); exists && content != "" {
return downloadAndProcessImage(ctx, content, pageURL, resize)
}
}
// Priority 2: Twitter Card image
if twitterImage := doc.Find("meta[name='twitter:image']").First(); twitterImage.Length() > 0 {
if content, exists := twitterImage.Attr("content"); exists && content != "" {
return downloadAndProcessImage(ctx, content, pageURL, resize)
}
}
// Priority 3: First <img> tag with sufficient size
var foundImageURL string
doc.Find("img").Each(func(i int, s *goquery.Selection) {
if foundImageURL != "" {
return // Already found one
}
// Check width/height attributes
width, widthOk := s.Attr("width")
height, heightOk := s.Attr("height")
if widthOk && heightOk {
// Parse dimensions (basic parsing, handles "100", "100px", etc.)
var w, h int
_, _ = fmt.Sscanf(width, "%d", &w) //nolint:errcheck // Basic parsing, errors are acceptable
_, _ = fmt.Sscanf(height, "%d", &h) //nolint:errcheck // Basic parsing, errors are acceptable
// If image is too small, skip it
if w < 100 && h < 100 {
return
}
}
// Get src attribute
src, exists := s.Attr("src")
if exists && src != "" {
foundImageURL = src
}
})
if foundImageURL != "" {
return downloadAndProcessImage(ctx, foundImageURL, pageURL, resize)
}
// Priority 4: First <img> tag regardless of size
if img := doc.Find("img").First(); img.Length() > 0 {
if src, exists := img.Attr("src"); exists && src != "" {
return downloadAndProcessImage(ctx, src, pageURL, resize)
}
}
// No image found
return nil, nil
}
// downloadAndProcessImage downloads an image (if needed) and processes it
func downloadAndProcessImage(ctx context.Context, imageURL string, baseURL string, resize bool) (*ThumbnailResult, error) {
var imageBytes []byte
var mimeType string
var err error
// Check if it's a data URI
if strings.HasPrefix(imageURL, "data:image/") {
imageBytes, mimeType, err = decodeDataURI(imageURL)
if err != nil {
return nil, fmt.Errorf("failed to decode data URI: %w", err)
}
} else {
// Resolve relative URLs
absoluteURL, err := resolveURL(imageURL, baseURL)
if err != nil {
return nil, fmt.Errorf("failed to resolve URL: %w", err)
}
// Download image
imageBytes, mimeType, err = downloadImage(ctx, absoluteURL)
if err != nil {
return nil, fmt.Errorf("failed to download image: %w", err)
}
}
var finalBytes []byte
var finalMimeType string
// Conditionally resize image
if resize {
resizedBytes, resizedMimeType, err := resizeImage(imageBytes, mimeType)
if err != nil {
return nil, fmt.Errorf("failed to resize image: %w", err)
}
finalBytes = resizedBytes
finalMimeType = resizedMimeType
} else {
// Use original image without resizing
finalBytes = imageBytes
finalMimeType = mimeType
}
// Determine filename extension
ext := "jpg"
if finalMimeType == "image/png" {
ext = "png"
} else if after, ok := strings.CutPrefix(finalMimeType, "image/"); ok {
ext = after
// Handle common cases
if ext == "jpeg" {
ext = "jpg"
}
}
return &ThumbnailResult{
ImageBytes: finalBytes,
MimeType: finalMimeType,
Filename: fmt.Sprintf("thumbnail.%s", ext),
}, nil
}
// decodeDataURI decodes a data URI image
func decodeDataURI(dataURI string) ([]byte, string, error) {
// Format: data:image/png;base64,<data>
parts := strings.SplitN(dataURI, ",", 2)
if len(parts) != 2 {
return nil, "", fmt.Errorf("invalid data URI format")
}
// Extract MIME type
header := parts[0]
mimeType := "image/png" // default
if strings.HasPrefix(header, "data:image/") {
mimePart := strings.TrimPrefix(header, "data:image/")
if idx := strings.Index(mimePart, ";"); idx != -1 {
mimeType = "image/" + mimePart[:idx]
} else {
mimeType = "image/" + mimePart
}
}
// Decode base64
decoded, err := base64.StdEncoding.DecodeString(parts[1])
if err != nil {
return nil, "", fmt.Errorf("failed to decode base64: %w", err)
}
return decoded, mimeType, nil
}
// resolveURL resolves a relative URL against a base URL
func resolveURL(relativeURL, baseURL string) (string, error) {
base, err := url.Parse(baseURL)
if err != nil {
return "", fmt.Errorf("failed to parse base URL: %w", err)
}
rel, err := url.Parse(relativeURL)
if err != nil {
return "", fmt.Errorf("failed to parse relative URL: %w", err)
}
absolute := base.ResolveReference(rel)
return absolute.String(), nil
}
// downloadImage downloads an image from a URL
func downloadImage(ctx context.Context, imageURL string) ([]byte, string, error) {
req, err := http.NewRequestWithContext(ctx, "GET", imageURL, nil)
if err != nil {
return nil, "", fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("User-Agent", "Hako/1.0")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return nil, "", fmt.Errorf("failed to download image: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return nil, "", fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
// Read image data
imageBytes, err := io.ReadAll(resp.Body)
if err != nil {
return nil, "", fmt.Errorf("failed to read image data: %w", err)
}
// Get content type
mimeType := resp.Header.Get("Content-Type")
if mimeType == "" {
mimeType = "image/jpeg" // default
}
return imageBytes, mimeType, nil
}
// resizeImage resizes an image to 128x96 pixels
func resizeImage(imageBytes []byte, mimeType string) ([]byte, string, error) {
// Decode image
img, format, err := image.Decode(bytes.NewReader(imageBytes))
if err != nil {
return nil, "", fmt.Errorf("failed to decode image: %w", err)
}
// Create destination image with exact dimensions
dst := image.NewRGBA(image.Rect(0, 0, thumbnailWidth, thumbnailHeight))
// Calculate scaling to fit 128x96 while maintaining aspect ratio
srcBounds := img.Bounds()
srcWidth := srcBounds.Dx()
srcHeight := srcBounds.Dy()
// Calculate scale factors
scaleX := float64(thumbnailWidth) / float64(srcWidth)
scaleY := float64(thumbnailHeight) / float64(srcHeight)
scale := scaleX
if scaleY < scaleX {
scale = scaleY
}
// Calculate scaled dimensions
scaledWidth := int(float64(srcWidth) * scale)
scaledHeight := int(float64(srcHeight) * scale)
// Calculate offset to center the image
offsetX := (thumbnailWidth - scaledWidth) / 2
offsetY := (thumbnailHeight - scaledHeight) / 2
// Create temporary image for scaling
scaled := image.NewRGBA(image.Rect(0, 0, scaledWidth, scaledHeight))
// Scale image using high-quality scaler
scaler := draw.ApproxBiLinear
scaler.Scale(scaled, scaled.Bounds(), img, srcBounds, draw.Src, nil)
// Copy scaled image to center of destination
draw.Draw(dst, image.Rect(offsetX, offsetY, offsetX+scaledWidth, offsetY+scaledHeight), scaled, scaled.Bounds().Min, draw.Src)
// Encode to JPEG (smaller file size) unless original was PNG with transparency
outputFormat := "jpeg"
outputMimeType := "image/jpeg"
// Check if original was PNG (might have transparency)
if format == "png" {
// For PNG, we could check for alpha channel, but for simplicity, use JPEG
// JPEG is smaller and thumbnails don't need transparency
outputFormat = "jpeg"
outputMimeType = "image/jpeg"
}
// Encode image
var buf bytes.Buffer
if outputFormat == "jpeg" {
err = jpeg.Encode(&buf, dst, &jpeg.Options{Quality: 85})
} else {
err = png.Encode(&buf, dst)
}
if err != nil {
return nil, "", fmt.Errorf("failed to encode image: %w", err)
}
return buf.Bytes(), outputMimeType, nil
}