package main import ( "bytes" "fmt" "image" _ "image/gif" "image/jpeg" _ "image/png" "io" "net/http" "net/url" "strings" "time" "go.deanishe.net/favicon" "golang.org/x/image/draw" _ "golang.org/x/image/webp" ) // ImageUploadResult contains the uploaded blob and image dimensions type ImageUploadResult struct { Blob *BlobRef Width int Height int } // uploadImages fetches and uploads up to 4 images, returning BskyImage structs func (p *Publisher) uploadImages(session *PDSSession, imageURLs []string, altText string) []BskyImage { var images []BskyImage maxImages := 4 if len(imageURLs) < maxImages { maxImages = len(imageURLs) } for i := 0; i < maxImages; i++ { result := p.fetchAndUploadImageWithDimensions(session, imageURLs[i]) if result != nil && result.Blob != nil { img := BskyImage{ Alt: altText, Image: result.Blob, } if result.Width > 0 && result.Height > 0 { img.AspectRatio = &BskyAspectRatio{ Width: result.Width, Height: result.Height, } } images = append(images, img) } } return images } // FetchFavicon tries to get a favicon URL for a site // Uses go.deanishe.net/favicon library which parses HTML, manifests, and checks common paths // Returns the favicon URL or empty string if not found func (p *Publisher) FetchFavicon(siteURL string) string { if siteURL == "" { return "" } // Ensure URL has scheme if !strings.Contains(siteURL, "://") { siteURL = "https://" + siteURL } u, err := url.Parse(siteURL) if err != nil { return "" } // Create finder with custom HTTP client // Note: Don't use IgnoreNoSize as it filters out valid favicon.ico files that don't have size metadata finder := favicon.New( favicon.WithClient(p.httpClient), ) // Find icons - library checks HTML tags, manifests, OG images, common paths icons, err := finder.Find(siteURL) if err == nil && len(icons) > 0 { // Filter and score icons for avatar use // Prefer: square icons, reasonable size, PNG format, actual favicons over OG images var bestIcon string var bestScore int for _, icon := range icons { // Skip tiny icons (likely tracking pixels) if icon.Width > 0 && icon.Width < 32 { continue } // Skip Open Graph images (meant for link previews, usually wide banners) lowerURL := strings.ToLower(icon.URL) if strings.Contains(lowerURL, "og-image") || strings.Contains(lowerURL, "og_image") || strings.Contains(lowerURL, "opengraph") || strings.Contains(lowerURL, "twitter") { continue } // Skip wide images (aspect ratio > 1.5 means it's a banner, not an icon) if icon.Width > 0 && icon.Height > 0 { ratio := float64(icon.Width) / float64(icon.Height) if ratio > 1.5 || ratio < 0.67 { continue } } // Score the icon score := 0 // Prefer actual favicon paths if strings.Contains(lowerURL, "favicon") || strings.Contains(lowerURL, "icon") || strings.Contains(lowerURL, "apple-touch") { score += 100 } // Prefer PNG over other formats if icon.MimeType == "image/png" { score += 50 } else if icon.MimeType == "image/x-icon" || strings.HasSuffix(lowerURL, ".ico") { score += 40 } else if icon.MimeType == "image/jpeg" { score += 10 // JPEG less preferred for icons } // Prefer larger icons (but not too large) if icon.Width >= 64 && icon.Width <= 512 { score += 30 } else if icon.Width > 0 { score += 10 } if score > bestScore { bestScore = score bestIcon = icon.URL } } if bestIcon != "" { return bestIcon } // Fall back to first non-OG icon for _, icon := range icons { lowerURL := strings.ToLower(icon.URL) if !strings.Contains(lowerURL, "og-image") && !strings.Contains(lowerURL, "og_image") { return icon.URL } } } // Fallback to Google's favicon service (reliable, returns PNG) return fmt.Sprintf("https://www.google.com/s2/favicons?domain=%s&sz=128", u.Host) } func (p *Publisher) fetchAndUploadImage(session *PDSSession, imageURL string) *BlobRef { result := p.fetchAndUploadImageWithDimensions(session, imageURL) if result == nil { return nil } return result.Blob } // upgradeImageURL attempts to get a larger version of known CDN image URLs func upgradeImageURL(imageURL string) string { // BBC images: /standard/240/ -> /standard/800/ if strings.Contains(imageURL, "ichef.bbci.co.uk") { imageURL = strings.Replace(imageURL, "/standard/240/", "/standard/800/", 1) imageURL = strings.Replace(imageURL, "/standard/480/", "/standard/800/", 1) } return imageURL } func (p *Publisher) fetchAndUploadImageWithDimensions(session *PDSSession, imageURL string) *ImageUploadResult { // Upgrade image URL to larger size if possible imageURL = upgradeImageURL(imageURL) // Fetch the image resp, err := p.httpClient.Get(imageURL) if err != nil { return nil } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil } // Check content type contentType := resp.Header.Get("Content-Type") if contentType == "" { // Try to guess from URL if strings.HasSuffix(strings.ToLower(imageURL), ".png") { contentType = "image/png" } else if strings.HasSuffix(strings.ToLower(imageURL), ".gif") { contentType = "image/gif" } else if strings.HasSuffix(strings.ToLower(imageURL), ".webp") { contentType = "image/webp" } else { contentType = "image/jpeg" // Default } } // Only accept image types if !strings.HasPrefix(contentType, "image/") { return nil } // Read image data (limit to 2MB to allow for resize headroom) data, err := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024)) if err != nil || len(data) == 0 { return nil } // Decode image to get dimensions imgConfig, _, err := image.DecodeConfig(bytes.NewReader(data)) width, height := 1, 1 // Default if decode fails if err == nil { width, height = imgConfig.Width, imgConfig.Height } // Bluesky blob limit is ~976KB, use 900KB as safe threshold const maxBlobSize = 900 * 1024 // If image is too large, resize it if len(data) > maxBlobSize { // Decode the full image for resizing img, _, err := image.Decode(bytes.NewReader(data)) if err != nil { return nil // Can't decode, can't resize } // Scale down iteratively until under limit scaleFactor := 0.9 // Start with 90% and iterate if needed for attempt := 0; attempt < 5; attempt++ { newWidth := int(float64(width) * scaleFactor) newHeight := int(float64(height) * scaleFactor) // Minimum dimensions if newWidth < 100 { newWidth = 100 } if newHeight < 100 { newHeight = 100 } // Create resized image resized := image.NewRGBA(image.Rect(0, 0, newWidth, newHeight)) draw.CatmullRom.Scale(resized, resized.Bounds(), img, img.Bounds(), draw.Over, nil) // Encode as JPEG var buf bytes.Buffer if err := jpeg.Encode(&buf, resized, &jpeg.Options{Quality: 85}); err != nil { return nil } if buf.Len() <= maxBlobSize { data = buf.Bytes() width = newWidth height = newHeight contentType = "image/jpeg" break } // Still too large, reduce scale further scaleFactor *= 0.8 } // If still too large after 5 attempts, give up if len(data) > maxBlobSize { return nil } } // Upload to PDS blob, err := p.UploadBlob(session, data, contentType) if err != nil { return nil } return &ImageUploadResult{ Blob: blob, Width: width, Height: height, } } // FetchFavicon downloads a favicon/icon from a URL // Uses go.deanishe.net/favicon library to find the best icon // Returns the favicon URL or empty string if not found func FetchFaviconBytes(siteURL string) ([]byte, string, error) { if !strings.HasPrefix(siteURL, "http") { siteURL = "https://" + siteURL } u, err := url.Parse(siteURL) if err != nil { return nil, "", err } client := &http.Client{Timeout: 10 * time.Second} // Use favicon library to find icons finder := favicon.New( favicon.WithClient(client), favicon.IgnoreNoSize, ) icons, err := finder.Find(siteURL) if err != nil || len(icons) == 0 { // Fallback to Google's favicon service googleURL := fmt.Sprintf("https://www.google.com/s2/favicons?domain=%s&sz=128", u.Host) return fetchIconBytes(client, googleURL) } // Try icons in order (sorted by size, largest first) // Prefer PNG/JPEG over ICO var iconURLs []string for _, icon := range icons { if icon.Width > 0 && icon.Width < 32 { continue // Skip tiny icons } if icon.MimeType == "image/png" || icon.MimeType == "image/jpeg" { iconURLs = append([]string{icon.URL}, iconURLs...) // Prepend PNG/JPEG } else { iconURLs = append(iconURLs, icon.URL) } } // If no good icons, use all of them if len(iconURLs) == 0 { for _, icon := range icons { iconURLs = append(iconURLs, icon.URL) } } // Try to download each icon for _, iconURL := range iconURLs { data, mimeType, err := fetchIconBytes(client, iconURL) if err == nil && len(data) > 0 { return data, mimeType, nil } } // Final fallback to Google googleURL := fmt.Sprintf("https://www.google.com/s2/favicons?domain=%s&sz=128", u.Host) return fetchIconBytes(client, googleURL) } // fetchIconBytes downloads an icon and returns its bytes and mime type func fetchIconBytes(client *http.Client, iconURL string) ([]byte, string, error) { resp, err := client.Get(iconURL) if err != nil { return nil, "", err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, "", fmt.Errorf("HTTP %d", resp.StatusCode) } data, err := io.ReadAll(resp.Body) if err != nil { return nil, "", err } // Determine mime type contentType := resp.Header.Get("Content-Type") if contentType == "" { if strings.HasSuffix(iconURL, ".png") { contentType = "image/png" } else if strings.HasSuffix(iconURL, ".ico") { contentType = "image/x-icon" } else { contentType = "image/png" } } return data, contentType, nil }