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 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 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, 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 }