feat(cards): X/Twitter oEmbed support and image URL card detection; render oEmbed HTML in UI
This commit is contained in:
parent
9e95ccdca4
commit
15f7f3ac96
2 changed files with 37 additions and 1 deletions
|
|
@ -215,6 +215,7 @@ type linkCard struct {
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Image string `json:"image"`
|
Image string `json:"image"`
|
||||||
|
HTML string `json:"html"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleLinkCard fetches basic OpenGraph/Twitter card metadata (best-effort) and returns a small card.
|
// 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])
|
_ = json.NewEncoder(w).Encode(s.cardCache[raw])
|
||||||
return
|
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
|
// fetch minimal HTML and extract tags using a tolerant HTML parser
|
||||||
client := &http.Client{Timeout: 10 * time.Second}
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
req, _ := http.NewRequestWithContext(r.Context(), http.MethodGet, raw, nil)
|
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)
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
w.WriteHeader(http.StatusBadGateway)
|
w.WriteHeader(http.StatusBadGateway)
|
||||||
|
|
@ -261,6 +286,16 @@ func (s *Server) handleLinkCard(w http.ResponseWriter, r *http.Request) {
|
||||||
_, _ = w.Write([]byte("bad status"))
|
_, _ = w.Write([]byte("bad status"))
|
||||||
return
|
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
|
// limit to 256KB and parse tokens
|
||||||
limited := http.MaxBytesReader(w, resp.Body, 262144)
|
limited := http.MaxBytesReader(w, resp.Body, 262144)
|
||||||
doc, err := xhtml.Parse(limited)
|
doc, err := xhtml.Parse(limited)
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ function processLinks(scope){ const links = scope.querySelectorAll('a[href]:not(
|
||||||
// Fetch and render card
|
// Fetch and render card
|
||||||
fetch('/api/linkcard?url='+encodeURIComponent(a.href)).then(r=>r.json()).then(card=>{
|
fetch('/api/linkcard?url='+encodeURIComponent(a.href)).then(r=>r.json()).then(card=>{
|
||||||
if(!card) return;
|
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='';
|
const c=document.createElement('div'); c.className='card'; var html='';
|
||||||
if(card.image){ html += '<div><img src="'+card.image+'" alt="" style="max-width:160px;max-height:120px;object-fit:cover;border-radius:.25rem"></div>'; }
|
if(card.image){ html += '<div><img src="'+card.image+'" alt="" style="max-width:160px;max-height:120px;object-fit:cover;border-radius:.25rem"></div>'; }
|
||||||
html += '<div style="flex:1;margin-left:.5rem">';
|
html += '<div style="flex:1;margin-left:.5rem">';
|
||||||
|
|
@ -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;
|
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.appendChild(row);
|
||||||
c.querySelectorAll('img').forEach(img=> img.addEventListener('load', ()=> pinBottomMulti()));
|
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
|
// Summary control row
|
||||||
const ctrl = document.createElement('div'); ctrl.style.marginTop='.25rem';
|
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';
|
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';
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue