diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index c31419a..6fd2ae1 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -215,6 +215,7 @@ type linkCard struct { Title string `json:"title"` Description string `json:"description"` Image string `json:"image"` + HTML string `json:"html"` } // handleLinkCard fetches basic OpenGraph/Twitter card metadata (best-effort) and returns a small card. @@ -246,9 +247,33 @@ func (s *Server) handleLinkCard(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(s.cardCache[raw]) return } + + // Special handling for X/Twitter posts via oEmbed + host := strings.ToLower(u.Host) + if (host == "x.com" || host == "twitter.com" || strings.HasSuffix(host, ".twitter.com")) && strings.Contains(strings.ToLower(u.Path), "/status/") { + oembed := "https://publish.twitter.com/oembed?omit_script=1&hide_thread=1&dnt=1&align=center&url=" + url.QueryEscape(raw) + client := &http.Client{Timeout: 8 * time.Second} + reqTw, _ := http.NewRequestWithContext(r.Context(), http.MethodGet, oembed, nil) + respTw, errTw := client.Do(reqTw) + if errTw == nil && respTw.StatusCode >= 200 && respTw.StatusCode < 300 { + defer respTw.Body.Close() + var o struct{ HTML string `json:"html"` } + if err := json.NewDecoder(respTw.Body).Decode(&o); err == nil && o.HTML != "" { + card := linkCard{URL: raw, HTML: o.HTML} + s.cardCache[raw] = card + s.cardCacheExp[raw] = time.Now().Add(24 * time.Hour) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(card) + return + } + } + // fallthrough to generic fetch if oEmbed fails + } + // fetch minimal HTML and extract tags using a tolerant HTML parser client := &http.Client{Timeout: 10 * time.Second} req, _ := http.NewRequestWithContext(r.Context(), http.MethodGet, raw, nil) + req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; sojuboy/1.0)") resp, err := client.Do(req) if err != nil { w.WriteHeader(http.StatusBadGateway) @@ -261,6 +286,16 @@ func (s *Server) handleLinkCard(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("bad status")) return } + // If image content-type, return as image card + if ct := strings.ToLower(resp.Header.Get("Content-Type")); strings.HasPrefix(ct, "image/") { + card := linkCard{URL: raw, Image: raw} + s.cardCache[raw] = card + s.cardCacheExp[raw] = time.Now().Add(24 * time.Hour) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(card) + return + } + // limit to 256KB and parse tokens limited := http.MaxBytesReader(w, resp.Body, 262144) doc, err := xhtml.Parse(limited) diff --git a/internal/httpapi/static/app.js b/internal/httpapi/static/app.js index c530594..8acbf0f 100644 --- a/internal/httpapi/static/app.js +++ b/internal/httpapi/static/app.js @@ -33,7 +33,7 @@ function processLinks(scope){ const links = scope.querySelectorAll('a[href]:not( // Fetch and render card fetch('/api/linkcard?url='+encodeURIComponent(a.href)).then(r=>r.json()).then(card=>{ if(!card) return; - if(card.title||card.description||card.image){ + if(card.title||card.description||card.image||card.html){ const c=document.createElement('div'); c.className='card'; var html=''; if(card.image){ html += '
'; } html += '
'; @@ -43,6 +43,7 @@ function processLinks(scope){ const links = scope.querySelectorAll('a[href]:not( const row = document.createElement('div'); row.style.display='flex'; row.style.alignItems='flex-start'; row.style.gap='.5rem'; row.innerHTML = html; c.appendChild(row); c.querySelectorAll('img').forEach(img=> img.addEventListener('load', ()=> pinBottomMulti())); + if(card.html){ const wrap=document.createElement('div'); wrap.innerHTML=card.html; c.appendChild(wrap); } // Summary control row const ctrl = document.createElement('div'); ctrl.style.marginTop='.25rem'; const btn = document.createElement('button'); btn.type='button'; btn.title='Summarize this link'; btn.textContent='\u25B6'; btn.style.padding='0 .4rem'; btn.style.fontSize='.9rem';