Files
archy/packages/app/vite-rss.ts
T
Dorian 2d056f9498 feat(chat): enhance chat functionality with web search and article integration
- Updated ChatMessage and ChatWindow components to support inline web search results and articles.
- Integrated new web search and RSS plugins into the chat system for real-time information retrieval.
- Enhanced useContentPanel to manage web search results alongside existing media types.
- Added ArticleOverlay component for displaying selected articles from search results.
- Improved UI elements and styles for better user interaction with web search features.

Made-with: Cursor
2026-03-02 21:29:50 +00:00

142 lines
4.9 KiB
TypeScript

import type { Plugin } from 'vite'
import type { Connect } from 'vite'
import Parser from 'rss-parser'
export interface RssArticle {
title: string
url: string
content?: string
imgSrc?: string
}
const RSS_PATHS = ['/feed', '/rss', '/atom.xml', '/feed.xml', '/?feed=rss', '/rss.xml', '/.rss', '/feeds/posts/default']
function extractImgFromItem(item: Record<string, unknown>): string | undefined {
const enc = item.enclosure as { url?: string; type?: string } | undefined
if (enc?.url && /^https?:\/\//i.test(enc.url)) {
const t = (enc.type ?? '').toLowerCase()
if (t.startsWith('image/') || t === '') return enc.url
}
const thumb = item['media:thumbnail'] as { $?: { url?: string }; url?: string } | undefined
if (thumb?.$?.url) return thumb.$.url
if (typeof thumb?.url === 'string') return thumb.url
const arr = thumb as unknown[]
if (Array.isArray(arr) && arr[0]?.$?.url) return (arr[0] as { $: { url: string } }).$.url
const media = item['media:content'] as { $?: { url?: string }; url?: string } | undefined
if (media?.$?.url) return media.$.url
if (typeof media?.url === 'string') return media.url
const group = item['media:group'] as { 'media:content'?: Array<{ $?: { url?: string } }> } | undefined
if (group?.['media:content']?.[0]?.$?.url) return group['media:content'][0].$.url
const itunes = item['itunes:image'] as { href?: string } | undefined
if (itunes?.href) return itunes.href
const content = item.content ?? item.summary
const html = typeof content === 'string' ? content : ''
const imgMatch = html.match(/<img[^>]+src=["']([^"']+)["']/i)
if (imgMatch?.[1]) return imgMatch[1]
return undefined
}
async function tryParseFeed(parser: Parser, feedUrl: string): Promise<RssArticle[] | null> {
try {
const feed = await parser.parseURL(feedUrl)
if (!feed?.items?.length) return null
return feed.items.slice(0, 10).map((item) => {
const rawContent = item.contentSnippet ?? item.content ?? item.summary
const contentStr = typeof rawContent === 'string' ? rawContent : ''
const imgSrc = extractImgFromItem(item as Record<string, unknown>)
return {
title: (item.title ?? '').trim() || 'Untitled',
url: (item.link ?? item.guid ?? '').trim() || feedUrl,
content: contentStr.trim().slice(0, 15000),
imgSrc: imgSrc && /^https?:\/\//i.test(imgSrc) ? imgSrc : undefined,
}
})
} catch {
return null
}
}
function discoverFeedUrl(siteUrl: string): string[] {
try {
const u = new URL(siteUrl)
const base = `${u.protocol}//${u.host}`
return RSS_PATHS.map((path) => base + path)
} catch {
return []
}
}
async function fetchRssFromUrls(urls: string[]): Promise<RssArticle[]> {
const parser = new Parser({
timeout: 5000,
headers: { 'User-Agent': 'AIUI/1.0 (RSS reader)' },
})
const seen = new Set<string>()
const articles: RssArticle[] = []
for (const url of urls.slice(0, 5)) {
const candidates = discoverFeedUrl(url)
for (const feedUrl of candidates) {
const items = await tryParseFeed(parser, feedUrl)
if (!items?.length) continue
for (const a of items) {
const k = a.url.toLowerCase()
if (seen.has(k)) continue
seen.add(k)
articles.push(a)
}
break // found a feed for this site, move to next URL
}
}
return articles.slice(0, 15)
}
function createRssMiddleware() {
return async (req: Connect.IncomingMessage, res: any, next: () => void) => {
if (req.method !== 'GET') return next()
const requestUrl = new URL(req.url ?? '', `http://${req.headers?.host ?? 'localhost'}`)
if (!requestUrl.pathname.startsWith('/api/rss-articles')) return next()
const urls = requestUrl.searchParams.getAll('url').map((u) => u.trim()).filter(Boolean)
if (urls.length === 0) {
res.writeHead(400, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'Missing urls (array or comma-separated)' }))
return
}
const safe = urls.filter((u) => /^https?:\/\//i.test(u.trim())).slice(0, 8)
if (safe.length === 0) {
res.writeHead(400, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'No valid https URLs' }))
return
}
try {
const articles = await fetchRssFromUrls(safe)
res.setHeader('Content-Type', 'application/json')
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('Cache-Control', 'public, max-age=300')
res.end(JSON.stringify({ articles }))
} catch (err) {
console.error('[rss]', err)
res.writeHead(502, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: String(err) }))
}
}
}
export function rssPlugin(): Plugin {
return {
name: 'aiui-rss',
configureServer(server) {
server.middlewares.use(createRssMiddleware())
},
configurePreviewServer(server) {
server.middlewares.use(createRssMiddleware())
},
}
}