- 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é)
97 lines
3.1 KiB
JavaScript
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 }
|
|
}
|