feedbender/app.ts
2025-05-15 13:08:06 +02:00

127 lines
3.9 KiB
TypeScript

import { Hono } from 'hono';
import { serve } from 'bun';
import Parser from 'rss-parser';
import { Feed, type Item } from 'feed';
import fs from 'fs';
import path from 'path';
const app = new Hono();
const parser = new Parser();
const DATA_DIR = path.resolve('.', 'data');
// Ensure data directory exists
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
// Derive a safe filename from the feed URL
function getStorePath(feedUrl: string): string {
const safeName = feedUrl.replace(/[^a-z0-9]/gi, '_').toLowerCase();
return path.join(DATA_DIR, `${safeName}.json`);
}
// Async feed: fetch remote, load+merge local, persist only on change, return merged data
async function syncFeed(feedUrl: string): Promise<{ title: string; feedId: string; feedLink: string; items: Parser.Item[] }> {
// Fetch remote feed
const { title, feedUrl: feedId, link: feedLink, items: remoteItems } = await parser.parseURL(feedUrl);
// Determine storage path
const storePath = getStorePath(feedUrl);
let localItems: Parser.Item[] = [];
let localItemsJson = '';
// Load and parse local file once, reset if corrupted
if (fs.existsSync(storePath)) {
try {
localItemsJson = fs.readFileSync(storePath, 'utf-8');
localItems = JSON.parse(localItemsJson) as Parser.Item[];
} catch {
localItems = [];
localItemsJson = '';
}
}
const sevenDaysAgo = new Date();
sevenDaysAgo.setHours(0, 0, 0, 0);
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
// Merge fresh items (dedupe by link)
const localLinks = new Set(localItems.map(i => i.link));
const mergedItems = [...localItems.filter(p => new Date(p.pubDate as string) >= sevenDaysAgo)];
for (const remoteItem of remoteItems) {
if (remoteItem.link && !localLinks.has(remoteItem.link)) mergedItems.push(remoteItem);
}
// Serialize merged
const mergedItemsJson = JSON.stringify(mergedItems, null, 2);
// Persist only if changed
if (mergedItemsJson !== localItemsJson) {
fs.writeFileSync(storePath, mergedItemsJson, 'utf-8');
}
return { title: title as string, feedId: feedId as string, feedLink: feedLink as string, items: mergedItems };
}
app.get('/group-by-day', async (c) => {
const feedUrl = c.req.query('feedUrl');
if (!feedUrl) {
return c.text('Missing feedUrl query parameter', 400);
}
try {
const { title, feedId, feedLink, items } = await syncFeed(feedUrl);
const grouped = items.reduce((acc: Record<string, ({link: string, content: string})[]>, { pubDate, content, contentSnippet, summary, link }) => {
const day = new Date(pubDate as string).toISOString().slice(0, 10);
acc[day] = acc[day] || [];
acc[day].push({
link: link as string,
content: content || contentSnippet || summary || ''
});
return acc;
}, {});
const days = Object.keys(grouped).sort().reverse();
const today = new Date();
today.setHours(0, 0, 0, 0);
const feedItems: Item[] = days.map(day => {
const dateStr = new Date(day).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
return {
title: `${title} - ${dateStr}`,
id: day,
date: new Date(day),
link: grouped[day]?.at(0)?.link as string,
content: grouped[day]?.map((p: any) => p.content).join('\n\n---------------------\n\n') as string
};
}).filter(p => p.date < today);
const outFeed = new Feed({
title: title as string,
id: feedId as string,
link: feedLink,
copyright: ''
});
for (const item of feedItems) {
outFeed.addItem(item)
}
const xml = outFeed.rss2();
return c.text(xml, 200, {
'Content-Type': 'text/xml',
});
} catch (err: any) {
return c.text(`Error fetching or processing feed: ${err.message}`, 500);
}
});
// Start Bun server on port 3000
serve({
fetch: app.fetch,
port: 3000
});