Files
dispatch/backend/feedFetcher.js
2026-07-10 15:48:21 +02:00

122 lines
4.2 KiB
JavaScript

const Parser = require('rss-parser');
const sanitizeHtml = require('sanitize-html');
const db = require('./db');
const parser = new Parser({
timeout: 15000,
headers: { 'User-Agent': 'Dispatch RSS Reader/1.0' },
customFields: {
item: [
['media:content', 'mediaContent'],
['media:thumbnail', 'mediaThumbnail'],
['content:encoded', 'contentEncoded'],
],
},
});
function cleanHtml(html) {
if (!html) return null;
return sanitizeHtml(html, {
allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img', 'figure', 'figcaption', 'iframe', 'h1', 'h2']),
allowedAttributes: {
...sanitizeHtml.defaults.allowedAttributes,
img: ['src', 'alt', 'title', 'width', 'height', 'loading'],
iframe: ['src', 'allowfullscreen', 'frameborder'],
a: ['href', 'name', 'target', 'rel'],
},
allowedIframeHostnames: ['www.youtube.com', 'player.vimeo.com'],
transformTags: {
a: sanitizeHtml.simpleTransform('a', { rel: 'noopener noreferrer', target: '_blank' }),
},
});
}
function extractImage(item) {
if (item.mediaContent && item.mediaContent.$ && item.mediaContent.$.url) return item.mediaContent.$.url;
if (item.mediaThumbnail && item.mediaThumbnail.$ && item.mediaThumbnail.$.url) return item.mediaThumbnail.$.url;
if (item.enclosure && item.enclosure.url && /image/.test(item.enclosure.type || '')) return item.enclosure.url;
const html = item.contentEncoded || item.content || '';
const match = html.match(/<img[^>]+src=["']([^"']+)["']/i);
return match ? match[1] : null;
}
function stripToSummary(html, len = 220) {
if (!html) return '';
const text = html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
return text.length > len ? text.slice(0, len).trim() + '…' : text;
}
async function fetchFeedMeta(url) {
const feed = await parser.parseURL(url);
return {
title: feed.title || url,
siteUrl: feed.link || null,
description: feed.description || null,
};
}
async function fetchAndStoreFeed(feedRow) {
const insertArticle = db.prepare(`
INSERT INTO articles (feed_id, guid, title, link, author, content, summary, image_url, published_at)
VALUES (@feed_id, @guid, @title, @link, @author, @content, @summary, @image_url, @published_at)
ON CONFLICT(feed_id, guid) DO UPDATE SET
title=excluded.title,
link=excluded.link,
content=excluded.content,
summary=excluded.summary,
image_url=excluded.image_url
WHERE articles.title != excluded.title OR articles.content != excluded.content
`);
try {
const feed = await parser.parseURL(feedRow.url);
const tx = db.transaction((items) => {
for (const item of items) {
const guid = item.guid || item.id || item.link || item.title;
if (!guid) continue;
const rawContent = item.contentEncoded || item.content || item.summary || '';
const content = cleanHtml(rawContent);
insertArticle.run({
feed_id: feedRow.id,
guid,
title: item.title || '(untitled)',
link: item.link || null,
author: item.creator || item.author || null,
content,
summary: stripToSummary(rawContent),
image_url: extractImage(item),
published_at: item.isoDate || item.pubDate || new Date().toISOString(),
});
}
});
tx(feed.items || []);
db.prepare(`UPDATE feeds SET last_fetched_at = datetime('now'), last_error = NULL,
title = CASE WHEN title IS NULL OR title = '' THEN @title ELSE title END,
site_url = COALESCE(site_url, @siteUrl)
WHERE id = @id`).run({
id: feedRow.id,
title: feed.title || feedRow.title,
siteUrl: feed.link || null,
});
return { ok: true, count: (feed.items || []).length };
} catch (err) {
db.prepare(`UPDATE feeds SET last_fetched_at = datetime('now'), last_error = ? WHERE id = ?`)
.run(err.message || String(err), feedRow.id);
return { ok: false, error: err.message };
}
}
async function fetchAllFeeds() {
const feeds = db.prepare('SELECT * FROM feeds').all();
const results = [];
for (const feed of feeds) {
const res = await fetchAndStoreFeed(feed);
results.push({ feedId: feed.id, ...res });
}
return results;
}
module.exports = { fetchAndStoreFeed, fetchAllFeeds, fetchFeedMeta };