307 lines
8.3 KiB
Go
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
|
|
}
|