this repo has no description
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: rich preview for reddit

+148 -27
+51 -27
internal/handler/preview.go
··· 20 20 {`^https?://(www\.)?(youtube\.com|youtu\.be)/.+`, "https://www.youtube.com/oembed", ""}, 21 21 {`^https?://(open\.)?spotify\.com/.+`, "https://open.spotify.com/oembed", ""}, 22 22 {`^https?://(www\.)?tiktok\.com/.+`, "https://www.tiktok.com/oembed", ""}, 23 - {`^https?://(www\.)?reddit\.com/.+`, "https://www.reddit.com/oembed", ""}, 23 + // {`^https?://(www\.)?reddit\.com/.+`, "https://www.reddit.com/oembed", ""}, // Use scraping for better metadata 24 24 {`^https?://(www\.)?flickr\.com/.+`, "https://www.flickr.com/services/oembed", ""}, 25 25 {`^https?://(www\.|mobile\.)?(twitter|x)\.com/.+`, "https://publish.twitter.com/oembed", ""}, 26 26 {`^https?://(www\.)?instagram\.com/.+`, "https://api.instagram.com/oembed", ""}, // Note: Often requires token ··· 59 59 if urlParam == "" { 60 60 json.NewEncoder(w).Encode(map[string]string{"error": "No URL provided"}) 61 61 return 62 + } 63 + 64 + // 0. Special Hybrid Handlers 65 + if strings.Contains(urlParam, "reddit.com") { 66 + meta, err := h.GetRedditPreview(urlParam) 67 + if err == nil && len(meta) > 0 { 68 + json.NewEncoder(w).Encode(meta) 69 + return 70 + } 71 + // If failed, fallthrough or return error? 72 + // Fallthrough to generic scrape might not work if Scrape inside GetRedditPreview failed. 73 + // But let's let fallthrough happen just in case. 62 74 } 63 75 64 76 // 1. Try OEmbed ··· 168 180 } 169 181 170 182 func (h *Handler) fetchOGScrape(w http.ResponseWriter, urlParam string) { 171 - // Fetch data 172 - req, err := http.NewRequest("GET", urlParam, nil) 183 + // Default UA 184 + ua := "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" 185 + 186 + meta, err := h.scrapeOpenGraph(urlParam, ua) 173 187 if err != nil { 174 - json.NewEncoder(w).Encode(map[string]string{"error": "Invalid URL"}) 188 + w.Header().Set("Content-Type", "application/json") 189 + // If it's a 4xx/5xx error from the helper, we might want to pass that through 190 + // For now, generic error or basic mapping 191 + if strings.Contains(err.Error(), "status") { 192 + json.NewEncoder(w).Encode(map[string]interface{}{ 193 + "error": "HTTP Error", 194 + // Extract status code if possible, or default to 400 195 + "status": 400, 196 + }) 197 + } else { 198 + json.NewEncoder(w).Encode(map[string]string{"error": "Failed to fetch metadata"}) 199 + } 175 200 return 176 201 } 177 - // Use a standard browser UA to avoid 403s 178 - req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") 202 + 203 + json.NewEncoder(w).Encode(meta) 204 + } 205 + 206 + func (h *Handler) scrapeOpenGraph(targetURL, userAgent string) (map[string]string, error) { 207 + req, err := http.NewRequest("GET", targetURL, nil) 208 + if err != nil { 209 + return nil, err 210 + } 211 + req.Header.Set("User-Agent", userAgent) 179 212 180 213 client := &http.Client{} 181 214 resp, err := client.Do(req) 182 215 if err != nil { 183 - json.NewEncoder(w).Encode(map[string]string{"error": "Failed to fetch URL"}) 184 - return 216 + return nil, err 185 217 } 186 218 defer resp.Body.Close() 187 219 188 220 if resp.StatusCode >= 400 { 189 - w.Header().Set("Content-Type", "application/json") 190 - json.NewEncoder(w).Encode(map[string]interface{}{ 191 - "error": "HTTP Error", 192 - "status": resp.StatusCode, 193 - }) 194 - return 221 + return nil, fmt.Errorf("status %d", resp.StatusCode) 195 222 } 196 223 197 224 // Parse HTML 198 225 doc, err := html.Parse(resp.Body) 199 226 if err != nil { 200 - json.NewEncoder(w).Encode(map[string]string{"error": "Failed to parse HTML"}) 201 - return 227 + return nil, err 202 228 } 203 229 204 230 metadata := make(map[string]string) ··· 238 264 metadata["description"] = content 239 265 } else if property == "og:image" { 240 266 metadata["image"] = content 267 + } else if property == "og:site_name" { 268 + metadata["provider_name"] = content 241 269 } else if name == "twitter:image" { 242 270 metadata["twitter_image"] = content 243 271 } else if name == "twitter:title" { ··· 269 297 metadata["icon"] = "https:" + href 270 298 } else if strings.HasPrefix(href, "/") { 271 299 // Need base URL. 272 - // Simple hack: use the scheme/host from urlParam 273 - u, _ := url.Parse(urlParam) 300 + // Simple hack: use the scheme/host from targetURL 301 + u, _ := url.Parse(targetURL) 274 302 if u != nil { 275 303 metadata["icon"] = fmt.Sprintf("%s://%s%s", u.Scheme, u.Host, href) 276 304 } ··· 309 337 // Fallback icon if not found in Scrape 310 338 if metadata["icon"] == "" { 311 339 // Try using google favicon service on the scraped URL 312 - u, _ := url.Parse(urlParam) 340 + u, _ := url.Parse(targetURL) 313 341 if u != nil { 314 342 metadata["icon"] = fmt.Sprintf("https://www.google.com/s2/favicons?domain=%s://%s&sz=32", u.Scheme, u.Host) 315 343 } 316 344 } 317 345 318 346 // Check for YouTube "soft 404" 319 - if strings.Contains(urlParam, "youtube.com") || strings.Contains(urlParam, "youtu.be") { 347 + if strings.Contains(targetURL, "youtube.com") || strings.Contains(targetURL, "youtu.be") { 320 348 title, hasTitle := metadata["title"] 321 349 if !hasTitle || title == " - YouTube" || title == "YouTube" { 322 - w.Header().Set("Content-Type", "application/json") 323 - json.NewEncoder(w).Encode(map[string]interface{}{ 324 - "error": "Video Unavailable", 325 - "status": 404, 326 - }) 327 - return 350 + // Special handling for caller to know it's a 404 351 + return nil, fmt.Errorf("status 404") 328 352 } 329 353 metadata["type"] = "video" 330 354 } 331 355 332 - json.NewEncoder(w).Encode(metadata) 356 + return metadata, nil 333 357 }
+86
internal/handler/preview_reddit.go
··· 1 + package handler 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + "net/url" 8 + ) 9 + 10 + // GetRedditPreview implements the hybrid Scrape + OEmbed approach 11 + func (h *Handler) GetRedditPreview(targetURL string) (map[string]string, error) { 12 + // 1. Scrape with Slackbot UA for rich metadata (Title, Image, Description, Icon) 13 + slackbotUA := "Slackbot-LinkExpanding 1.0 (+https://api.slack.com/robots)" 14 + meta, err := h.scrapeOpenGraph(targetURL, slackbotUA) 15 + if err != nil { 16 + // If scrape fails, we might still try OEmbed, but usually if scrape fails, OEmbed might also fail/be lesser. 17 + // Let's rely on fallback. But for now, let's proceed to OEmbed only if scrape worked partially? 18 + // Or if scrape failed completely, just try OEmbed as last resort. 19 + // For simplicity, let's initialize map if nil 20 + if meta == nil { 21 + meta = make(map[string]string) 22 + } 23 + } 24 + 25 + // 2. Fetch OEmbed for the Embed HTML (Video Player) 26 + // We manually fetch from Reddit's OEmbed endpoint to bypass the provider check in global tryOEmbed. 27 + oembedEndpoint := "https://www.reddit.com/oembed" 28 + reqURL := fmt.Sprintf("%s?url=%s&format=json", oembedEndpoint, url.QueryEscape(targetURL)) 29 + 30 + oembedMeta, err2 := h.manualFetchOEmbed(reqURL) 31 + if err2 == nil { 32 + // Merge OEmbed HTML into Scraped Meta 33 + if html, ok := oembedMeta["html"]; ok { 34 + meta["embed_html"] = html 35 + } 36 + // If Scrape failed to get type, use OEmbed type 37 + if _, ok := meta["type"]; !ok { 38 + if t, ok := oembedMeta["type"]; ok { 39 + meta["type"] = t 40 + } 41 + } 42 + // Force type to video/rich if we have html 43 + if meta["embed_html"] != "" { 44 + meta["type"] = "rich" 45 + } 46 + } 47 + 48 + return meta, nil 49 + } 50 + 51 + // manualFetchOEmbed fetches OEmbed data from a fully constructed URL 52 + func (h *Handler) manualFetchOEmbed(reqURL string) (map[string]string, error) { 53 + req, err := http.NewRequest("GET", reqURL, nil) 54 + if err != nil { 55 + return nil, err 56 + } 57 + // Use generic browser UA 58 + req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") 59 + 60 + client := &http.Client{} 61 + resp, err := client.Do(req) 62 + if err != nil { 63 + return nil, err 64 + } 65 + defer resp.Body.Close() 66 + 67 + if resp.StatusCode >= 400 { 68 + return nil, fmt.Errorf("oembed status %d", resp.StatusCode) 69 + } 70 + 71 + var data OEmbedResponse 72 + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { 73 + return nil, err 74 + } 75 + 76 + // Map to generic map 77 + m := make(map[string]string) 78 + m["title"] = data.Title 79 + m["author_name"] = data.AuthorName 80 + m["provider_name"] = data.ProviderName 81 + m["type"] = data.Type 82 + m["html"] = data.HTML 83 + m["thumbnail_url"] = data.ThumbnailURL 84 + 85 + return m, nil 86 + }
+11
internal/templates/views/index.html
··· 192 192 previewHTML += "</div>"; // end og-card 193 193 194 194 previewDiv.innerHTML = previewHTML; 195 + 196 + // Auto-expand Reddit embeds to provide "richer view out of the box" 197 + // The custom card acts as a placeholder or fallback, but we trigger the embed swap immediately. 198 + if (provider === "Reddit" && data.embed_html) { 199 + var imgDiv = previewDiv.querySelector('.og-image.is-video'); 200 + if (imgDiv) { 201 + // Small delay to ensure render? No, synchronous is fine for DOM, 202 + // but we need to ensure the scripts run. replaceWithVideo handles that. 203 + window.replaceWithVideo(imgDiv); 204 + } 205 + } 195 206 } 196 207 } 197 208