···2020 {`^https?://(www\.)?(youtube\.com|youtu\.be)/.+`, "https://www.youtube.com/oembed", ""},
2121 {`^https?://(open\.)?spotify\.com/.+`, "https://open.spotify.com/oembed", ""},
2222 {`^https?://(www\.)?tiktok\.com/.+`, "https://www.tiktok.com/oembed", ""},
2323- {`^https?://(www\.)?reddit\.com/.+`, "https://www.reddit.com/oembed", ""},
2323+ // {`^https?://(www\.)?reddit\.com/.+`, "https://www.reddit.com/oembed", ""}, // Use scraping for better metadata
2424 {`^https?://(www\.)?flickr\.com/.+`, "https://www.flickr.com/services/oembed", ""},
2525 {`^https?://(www\.|mobile\.)?(twitter|x)\.com/.+`, "https://publish.twitter.com/oembed", ""},
2626 {`^https?://(www\.)?instagram\.com/.+`, "https://api.instagram.com/oembed", ""}, // Note: Often requires token
···5959 if urlParam == "" {
6060 json.NewEncoder(w).Encode(map[string]string{"error": "No URL provided"})
6161 return
6262+ }
6363+6464+ // 0. Special Hybrid Handlers
6565+ if strings.Contains(urlParam, "reddit.com") {
6666+ meta, err := h.GetRedditPreview(urlParam)
6767+ if err == nil && len(meta) > 0 {
6868+ json.NewEncoder(w).Encode(meta)
6969+ return
7070+ }
7171+ // If failed, fallthrough or return error?
7272+ // Fallthrough to generic scrape might not work if Scrape inside GetRedditPreview failed.
7373+ // But let's let fallthrough happen just in case.
6274 }
63756476 // 1. Try OEmbed
···168180}
169181170182func (h *Handler) fetchOGScrape(w http.ResponseWriter, urlParam string) {
171171- // Fetch data
172172- req, err := http.NewRequest("GET", urlParam, nil)
183183+ // Default UA
184184+ 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"
185185+186186+ meta, err := h.scrapeOpenGraph(urlParam, ua)
173187 if err != nil {
174174- json.NewEncoder(w).Encode(map[string]string{"error": "Invalid URL"})
188188+ w.Header().Set("Content-Type", "application/json")
189189+ // If it's a 4xx/5xx error from the helper, we might want to pass that through
190190+ // For now, generic error or basic mapping
191191+ if strings.Contains(err.Error(), "status") {
192192+ json.NewEncoder(w).Encode(map[string]interface{}{
193193+ "error": "HTTP Error",
194194+ // Extract status code if possible, or default to 400
195195+ "status": 400,
196196+ })
197197+ } else {
198198+ json.NewEncoder(w).Encode(map[string]string{"error": "Failed to fetch metadata"})
199199+ }
175200 return
176201 }
177177- // Use a standard browser UA to avoid 403s
178178- 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")
202202+203203+ json.NewEncoder(w).Encode(meta)
204204+}
205205+206206+func (h *Handler) scrapeOpenGraph(targetURL, userAgent string) (map[string]string, error) {
207207+ req, err := http.NewRequest("GET", targetURL, nil)
208208+ if err != nil {
209209+ return nil, err
210210+ }
211211+ req.Header.Set("User-Agent", userAgent)
179212180213 client := &http.Client{}
181214 resp, err := client.Do(req)
182215 if err != nil {
183183- json.NewEncoder(w).Encode(map[string]string{"error": "Failed to fetch URL"})
184184- return
216216+ return nil, err
185217 }
186218 defer resp.Body.Close()
187219188220 if resp.StatusCode >= 400 {
189189- w.Header().Set("Content-Type", "application/json")
190190- json.NewEncoder(w).Encode(map[string]interface{}{
191191- "error": "HTTP Error",
192192- "status": resp.StatusCode,
193193- })
194194- return
221221+ return nil, fmt.Errorf("status %d", resp.StatusCode)
195222 }
196223197224 // Parse HTML
198225 doc, err := html.Parse(resp.Body)
199226 if err != nil {
200200- json.NewEncoder(w).Encode(map[string]string{"error": "Failed to parse HTML"})
201201- return
227227+ return nil, err
202228 }
203229204230 metadata := make(map[string]string)
···238264 metadata["description"] = content
239265 } else if property == "og:image" {
240266 metadata["image"] = content
267267+ } else if property == "og:site_name" {
268268+ metadata["provider_name"] = content
241269 } else if name == "twitter:image" {
242270 metadata["twitter_image"] = content
243271 } else if name == "twitter:title" {
···269297 metadata["icon"] = "https:" + href
270298 } else if strings.HasPrefix(href, "/") {
271299 // Need base URL.
272272- // Simple hack: use the scheme/host from urlParam
273273- u, _ := url.Parse(urlParam)
300300+ // Simple hack: use the scheme/host from targetURL
301301+ u, _ := url.Parse(targetURL)
274302 if u != nil {
275303 metadata["icon"] = fmt.Sprintf("%s://%s%s", u.Scheme, u.Host, href)
276304 }
···309337 // Fallback icon if not found in Scrape
310338 if metadata["icon"] == "" {
311339 // Try using google favicon service on the scraped URL
312312- u, _ := url.Parse(urlParam)
340340+ u, _ := url.Parse(targetURL)
313341 if u != nil {
314342 metadata["icon"] = fmt.Sprintf("https://www.google.com/s2/favicons?domain=%s://%s&sz=32", u.Scheme, u.Host)
315343 }
316344 }
317345318346 // Check for YouTube "soft 404"
319319- if strings.Contains(urlParam, "youtube.com") || strings.Contains(urlParam, "youtu.be") {
347347+ if strings.Contains(targetURL, "youtube.com") || strings.Contains(targetURL, "youtu.be") {
320348 title, hasTitle := metadata["title"]
321349 if !hasTitle || title == " - YouTube" || title == "YouTube" {
322322- w.Header().Set("Content-Type", "application/json")
323323- json.NewEncoder(w).Encode(map[string]interface{}{
324324- "error": "Video Unavailable",
325325- "status": 404,
326326- })
327327- return
350350+ // Special handling for caller to know it's a 404
351351+ return nil, fmt.Errorf("status 404")
328352 }
329353 metadata["type"] = "video"
330354 }
331355332332- json.NewEncoder(w).Encode(metadata)
356356+ return metadata, nil
333357}
+86
internal/handler/preview_reddit.go
···11+package handler
22+33+import (
44+ "encoding/json"
55+ "fmt"
66+ "net/http"
77+ "net/url"
88+)
99+1010+// GetRedditPreview implements the hybrid Scrape + OEmbed approach
1111+func (h *Handler) GetRedditPreview(targetURL string) (map[string]string, error) {
1212+ // 1. Scrape with Slackbot UA for rich metadata (Title, Image, Description, Icon)
1313+ slackbotUA := "Slackbot-LinkExpanding 1.0 (+https://api.slack.com/robots)"
1414+ meta, err := h.scrapeOpenGraph(targetURL, slackbotUA)
1515+ if err != nil {
1616+ // If scrape fails, we might still try OEmbed, but usually if scrape fails, OEmbed might also fail/be lesser.
1717+ // Let's rely on fallback. But for now, let's proceed to OEmbed only if scrape worked partially?
1818+ // Or if scrape failed completely, just try OEmbed as last resort.
1919+ // For simplicity, let's initialize map if nil
2020+ if meta == nil {
2121+ meta = make(map[string]string)
2222+ }
2323+ }
2424+2525+ // 2. Fetch OEmbed for the Embed HTML (Video Player)
2626+ // We manually fetch from Reddit's OEmbed endpoint to bypass the provider check in global tryOEmbed.
2727+ oembedEndpoint := "https://www.reddit.com/oembed"
2828+ reqURL := fmt.Sprintf("%s?url=%s&format=json", oembedEndpoint, url.QueryEscape(targetURL))
2929+3030+ oembedMeta, err2 := h.manualFetchOEmbed(reqURL)
3131+ if err2 == nil {
3232+ // Merge OEmbed HTML into Scraped Meta
3333+ if html, ok := oembedMeta["html"]; ok {
3434+ meta["embed_html"] = html
3535+ }
3636+ // If Scrape failed to get type, use OEmbed type
3737+ if _, ok := meta["type"]; !ok {
3838+ if t, ok := oembedMeta["type"]; ok {
3939+ meta["type"] = t
4040+ }
4141+ }
4242+ // Force type to video/rich if we have html
4343+ if meta["embed_html"] != "" {
4444+ meta["type"] = "rich"
4545+ }
4646+ }
4747+4848+ return meta, nil
4949+}
5050+5151+// manualFetchOEmbed fetches OEmbed data from a fully constructed URL
5252+func (h *Handler) manualFetchOEmbed(reqURL string) (map[string]string, error) {
5353+ req, err := http.NewRequest("GET", reqURL, nil)
5454+ if err != nil {
5555+ return nil, err
5656+ }
5757+ // Use generic browser UA
5858+ 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")
5959+6060+ client := &http.Client{}
6161+ resp, err := client.Do(req)
6262+ if err != nil {
6363+ return nil, err
6464+ }
6565+ defer resp.Body.Close()
6666+6767+ if resp.StatusCode >= 400 {
6868+ return nil, fmt.Errorf("oembed status %d", resp.StatusCode)
6969+ }
7070+7171+ var data OEmbedResponse
7272+ if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
7373+ return nil, err
7474+ }
7575+7676+ // Map to generic map
7777+ m := make(map[string]string)
7878+ m["title"] = data.Title
7979+ m["author_name"] = data.AuthorName
8080+ m["provider_name"] = data.ProviderName
8181+ m["type"] = data.Type
8282+ m["html"] = data.HTML
8383+ m["thumbnail_url"] = data.ThumbnailURL
8484+8585+ return m, nil
8686+}
+11
internal/templates/views/index.html
···192192 previewHTML += "</div>"; // end og-card
193193194194 previewDiv.innerHTML = previewHTML;
195195+196196+ // Auto-expand Reddit embeds to provide "richer view out of the box"
197197+ // The custom card acts as a placeholder or fallback, but we trigger the embed swap immediately.
198198+ if (provider === "Reddit" && data.embed_html) {
199199+ var imgDiv = previewDiv.querySelector('.og-image.is-video');
200200+ if (imgDiv) {
201201+ // Small delay to ensure render? No, synchronous is fine for DOM,
202202+ // but we need to ensure the scripts run. replaceWithVideo handles that.
203203+ window.replaceWithVideo(imgDiv);
204204+ }
205205+ }
195206 }
196207 }
197208