homepage/src/composables/useRSS.js
Nathan Leclercq f795cc48b5 Homepage dashboard : horloge animée, todo, notes, RSS, pomodoro, recherche
- Frontend Vue 3 (Composition API) + Vite + Vue Router
- Backend Go (stdlib) : API REST todo/notes + proxy RSS + auth token
- Docker Compose : SPA nginx + backend + Miniflux + Postgres
- Widgets : horloge canvas météo, todo 3 colonnes, notes persistées,
  agrégateur RSS multi-feeds, pomodoro, recherche DuckDuckGo (Ctrl+K)
- Auth : dashboard public, todo/notes protégés par token
- Widgets expandables (mode agrandi centré)
2026-04-09 12:49:55 +02:00

97 lines
3.1 KiB
JavaScript

// useRSS — fetch RSS/Atom feeds via the backend proxy and parse them
// in the browser using DOMParser. Handles RSS 2.0, RSS 1.0 (RDF), Atom.
import { ref } from 'vue'
const BACKEND = import.meta.env.VITE_BACKEND_URL || 'http://localhost:8082'
function pickText(el, ...selectors) {
for (const sel of selectors) {
const node = el.querySelector(sel)
if (node?.textContent) return node.textContent.trim()
}
return ''
}
function pickLink(el) {
// Atom : <link href="..."/>
const atomLink = [...el.querySelectorAll('link')].find((l) => l.getAttribute('href'))
if (atomLink) return atomLink.getAttribute('href')
// RSS 2.0 / 1.0 : <link>text</link>
const rssLink = el.querySelector('link')
return rssLink?.textContent?.trim() || ''
}
export function parseFeed(xmlText, label) {
// Some servers return HTML error pages instead of XML
let trimmed = xmlText.trimStart()
if (trimmed.startsWith('<!DOCTYPE') || trimmed.startsWith('<html')) {
throw new Error(`${label || 'feed'}: got HTML instead of XML`)
}
// Some feeds are malformed with duplicate closing tags — truncate at first valid end
for (const end of ['</rss>', '</feed>', '</rdf:RDF>']) {
const idx = trimmed.indexOf(end)
if (idx !== -1) {
trimmed = trimmed.slice(0, idx + end.length)
break
}
}
const doc = new DOMParser().parseFromString(trimmed, 'application/xml')
const err = doc.querySelector('parsererror')
if (err) {
throw new Error(`${label || 'feed'}: XML parse error — ${err.textContent.slice(0, 80)}`)
}
// Items can be <item> (RSS) or <entry> (Atom)
const nodes = [...doc.querySelectorAll('item, entry')]
return nodes.map((n) => ({
title: pickText(n, 'title'),
link: pickLink(n),
date: pickText(n, 'pubDate', 'published', 'updated', 'date'),
}))
}
async function fetchOne(feed) {
const r = await fetch(`${BACKEND}/api/rss?url=${encodeURIComponent(feed.url)}`)
if (!r.ok) throw new Error(`${feed.label || feed.url}: HTTP ${r.status}`)
const text = await r.text()
return parseFeed(text, feed.label || feed.url).map((it) => ({ ...it, source: feed.label || '' }))
}
export function useRSS() {
const items = ref([])
const loading = ref(false)
const error = ref(null)
async function load(url) {
return loadMultiple([{ url, label: '' }])
}
async function loadMultiple(feeds) {
loading.value = true
error.value = null
try {
const results = await Promise.allSettled(feeds.map(fetchOne))
const merged = []
const errors = []
for (let i = 0; i < results.length; i++) {
if (results[i].status === 'fulfilled') {
merged.push(...results[i].value)
} else {
errors.push(results[i].reason.message)
}
}
merged.sort((a, b) => {
const da = new Date(a.date).getTime() || 0
const db = new Date(b.date).getTime() || 0
return db - da
})
items.value = merged
if (errors.length) error.value = errors.join(' · ')
} finally {
loading.value = false
}
}
return { items, loading, error, load, loadMultiple }
}