feat: add Feed background RSS fetch job and /feed/articles endpoint

This commit is contained in:
posimai 2026-04-05 12:29:48 +09:00
parent ac8cc6db81
commit c7b6d0b2d3
1 changed files with 175 additions and 3 deletions

178
server.js
View File

@ -22,6 +22,8 @@ const crypto = require('crypto');
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const os = require('os'); const os = require('os');
const { execSync } = require('child_process'); const { execSync } = require('child_process');
let RssParser = null;
try { RssParser = require('rss-parser'); } catch (_) { console.warn('[Feed] rss-parser not found, background fetch disabled'); }
// ── Auth: WebAuthn (ESM dynamic import) ───────────────────────────── // ── Auth: WebAuthn (ESM dynamic import) ─────────────────────────────
let webauthn = null; let webauthn = null;
@ -1888,8 +1890,13 @@ ${excerpt}
r.get('/feed/media', authMiddleware, async (req, res) => { r.get('/feed/media', authMiddleware, async (req, res) => {
try { try {
// default_user のメディア(共通)+ ユーザー自身のメディアを統合して返す
const result = await pool.query( const result = await pool.query(
'SELECT id, name, feed_url, site_url, category, is_active, created_at FROM feed_media WHERE user_id=$1 ORDER BY created_at ASC', `SELECT id, name, feed_url, site_url, category, is_active, created_at,
(user_id = 'default_user') AS is_default
FROM feed_media
WHERE user_id = 'default_user' OR user_id = $1
ORDER BY is_default DESC, created_at ASC`,
[req.userId] [req.userId]
); );
res.json(result.rows); res.json(result.rows);
@ -1897,15 +1904,27 @@ ${excerpt}
}); });
r.post('/feed/media', authMiddleware, async (req, res) => { r.post('/feed/media', authMiddleware, async (req, res) => {
const { name, feed_url, site_url = '', category = 'tech', is_active = true } = req.body || {}; const { name, feed_url, site_url = '', is_active = true } = req.body || {};
if (!name || !feed_url) return res.status(400).json({ error: 'name and feed_url required' }); if (!name || !feed_url) return res.status(400).json({ error: 'name and feed_url required' });
// カテゴリ自動判定(指定があればそのまま使用)
let category = req.body.category || '';
if (!category) {
const urlLower = feed_url.toLowerCase();
if (/news|nhk|yahoo|nikkei|asahi|mainichi|yomiuri/.test(urlLower)) category = 'news';
else if (/business|bizjapan|diamond|toyo|kaizen/.test(urlLower)) category = 'business';
else if (/lifestyle|life|food|cooking|fashion|beauty|travel/.test(urlLower)) category = 'lifestyle';
else category = 'tech';
}
try { try {
const result = await pool.query( const result = await pool.query(
'INSERT INTO feed_media (user_id, name, feed_url, site_url, category, is_active) VALUES ($1,$2,$3,$4,$5,$6) RETURNING id, name, feed_url, site_url, category, is_active, created_at', 'INSERT INTO feed_media (user_id, name, feed_url, site_url, category, is_active) VALUES ($1,$2,$3,$4,$5,$6) RETURNING id, name, feed_url, site_url, category, is_active, created_at',
[req.userId, name, feed_url, site_url, category, is_active] [req.userId, name, feed_url, site_url, category, is_active]
); );
res.status(201).json(result.rows[0]); res.status(201).json(result.rows[0]);
} catch (e) { console.error(e); res.status(500).json({ error: 'DB error' }); } } catch (e) {
if (e.code === '23505') return res.status(409).json({ error: 'このメディアはすでに追加済みです' });
console.error(e); res.status(500).json({ error: 'DB error' });
}
}); });
r.patch('/feed/media/:id', authMiddleware, async (req, res) => { r.patch('/feed/media/:id', authMiddleware, async (req, res) => {
@ -1940,6 +1959,81 @@ ${excerpt}
} catch (e) { console.error(e); res.status(500).json({ error: 'DB error' }); } } catch (e) { console.error(e); res.status(500).json({ error: 'DB error' }); }
}); });
// ── Feed ArticlesDBキャッシュから高速配信──────────────────────
// VPS背景取得ジョブがfeed_articlesテーブルに書き込み、ここで読む
r.get('/feed/articles', authMiddleware, async (req, res) => {
try {
const limit = Math.min(parseInt(req.query.limit || '100'), 200);
const offset = parseInt(req.query.offset || '0');
const category = req.query.category || null;
const mediaId = req.query.media_id || null;
let where = `WHERE fa.user_id IN ('default_user', $1)`;
const vals = [req.userId];
let idx = 2;
if (category) { where += ` AND fm.category = $${idx++}`; vals.push(category); }
if (mediaId) { where += ` AND fa.media_id = $${idx++}`; vals.push(mediaId); }
const [articlesResult, mediasResult] = await Promise.all([
pool.query(
`SELECT fa.id, fa.url, fa.title, fa.summary, fa.author,
fa.published_at, fa.is_read,
fm.id AS media_id, fm.name AS source, fm.category,
fm.feed_url, fm.site_url, fm.favicon
FROM feed_articles fa
JOIN feed_media fm ON fa.media_id = fm.id
${where}
ORDER BY fa.published_at DESC NULLS LAST
LIMIT $${idx} OFFSET $${idx + 1}`,
[...vals, limit, offset]
),
pool.query(
`SELECT id, name, feed_url, site_url, category, favicon,
(user_id = 'default_user') AS is_default
FROM feed_media
WHERE user_id IN ('default_user', $1) AND is_active = true
ORDER BY is_default DESC, created_at ASC`,
[req.userId]
)
]);
res.json({
success: true,
articles: articlesResult.rows,
medias: mediasResult.rows,
categories: [
{ id: 'all', name: '全て', icon: 'layout-grid' },
{ id: 'news', name: 'ニュース', icon: 'newspaper' },
{ id: 'tech', name: 'テクノロジー', icon: 'rocket' },
{ id: 'lifestyle', name: 'ライフスタイル', icon: 'coffee' },
{ id: 'business', name: 'ビジネス', icon: 'briefcase' },
],
total: articlesResult.rows.length,
updatedAt: new Date().toISOString()
});
} catch (e) { console.error(e); res.status(500).json({ error: 'DB error' }); }
});
// Feed既読マーク
r.patch('/feed/articles/:id/read', authMiddleware, async (req, res) => {
try {
await pool.query(
`UPDATE feed_articles SET is_read = true WHERE id = $1 AND user_id IN ('default_user', $2)`,
[req.params.id, req.userId]
);
res.json({ ok: true });
} catch (e) { console.error(e); res.status(500).json({ error: 'DB error' }); }
});
// 手動リフレッシュ(フロントの更新ボタンから呼ぶ)
r.post('/feed/refresh', authMiddleware, async (req, res) => {
try {
const count = await runFeedFetch();
res.json({ ok: true, fetched: count });
} catch (e) { console.error(e); res.status(500).json({ error: e.message }); }
});
// ── TTS (VOICEVOX) ───────────────────────────────────────────── // ── TTS (VOICEVOX) ─────────────────────────────────────────────
// VOICEVOX_URL: docker-compose.yml で設定(同ネットワーク内なら http://voicevox:50021 // VOICEVOX_URL: docker-compose.yml で設定(同ネットワーク内なら http://voicevox:50021
// 未設定時は localhost フォールバックNAS上で同じコンテナホストの場合 // 未設定時は localhost フォールバックNAS上で同じコンテナホストの場合
@ -2802,6 +2896,76 @@ app.use('/api', router); // ローカル直接アクセス
// ── 起動 ────────────────────────────────── // ── 起動 ──────────────────────────────────
const PORT = parseInt(process.env.PORT || '8090'); const PORT = parseInt(process.env.PORT || '8090');
// ── Feed 背景取得ジョブ ────────────────────────────────────────────
// feed_media テーブルの全URLを15分ごとに取得し feed_articles へ upsert
let feedFetchRunning = false;
async function runFeedFetch() {
if (!RssParser) return 0;
if (feedFetchRunning) { console.log('[Feed] fetch already running, skip'); return 0; }
feedFetchRunning = true;
let totalNew = 0;
try {
const rssParser = new RssParser({
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; Posimai/1.0)',
'Accept': 'application/rss+xml, application/atom+xml, application/xml, text/xml, */*'
},
timeout: 10000
});
const mediasResult = await pool.query(
`SELECT id, user_id, name, feed_url, category FROM feed_media WHERE is_active = true`
);
const medias = mediasResult.rows;
console.log(`[Feed] fetching ${medias.length} feeds...`);
await Promise.allSettled(medias.map(async (media) => {
try {
const feed = await rssParser.parseURL(media.feed_url);
const items = (feed.items || []).slice(0, 20);
for (const item of items) {
if (!item.link) continue;
const publishedAt = item.pubDate || item.isoDate || null;
try {
const result = await pool.query(
`INSERT INTO feed_articles (media_id, user_id, url, title, summary, author, published_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (user_id, url) DO NOTHING`,
[
media.id, media.user_id,
item.link,
(item.title || '').slice(0, 500),
(item.contentSnippet || item.content || item.description || '').slice(0, 1000),
(item.creator || item.author || '').slice(0, 200),
publishedAt ? new Date(publishedAt) : null
]
);
totalNew += result.rowCount;
} catch (_) { /* duplicate or DB error, skip */ }
}
// last_fetched_at 更新
await pool.query(
`UPDATE feed_media SET last_fetched_at = NOW() WHERE id = $1`,
[media.id]
);
} catch (e) {
console.warn(`[Feed] failed to fetch ${media.feed_url}: ${e.message}`);
}
}));
// 古い記事を削除30日以上前 + 既読)
await pool.query(
`DELETE FROM feed_articles WHERE published_at < NOW() - INTERVAL '30 days' AND is_read = true`
);
console.log(`[Feed] fetch done. new articles: ${totalNew}`);
} finally {
feedFetchRunning = false;
}
return totalNew;
}
loadWebauthn() loadWebauthn()
.then(() => initDB()) .then(() => initDB())
.then(() => { .then(() => {
@ -2814,6 +2978,14 @@ loadWebauthn()
console.log(` Local: http://localhost:${PORT}/api/health`); console.log(` Local: http://localhost:${PORT}/api/health`);
console.log(` Public: https://api.soar-enrich.com/brain/api/health`); console.log(` Public: https://api.soar-enrich.com/brain/api/health`);
}); });
// 起動直後に1回取得し、以降15分ごとに繰り返す
if (RssParser) {
setTimeout(() => runFeedFetch().catch(e => console.error('[Feed] initial fetch error:', e.message)), 5000);
setInterval(() => runFeedFetch().catch(e => console.error('[Feed] interval fetch error:', e.message)), 15 * 60 * 1000);
console.log(' Feed: background fetch enabled (15min interval)');
} else {
console.log(' Feed: background fetch disabled (rss-parser not installed)');
}
}) })
.catch(err => { .catch(err => {
console.error('[FATAL] Startup failed:', err.message); console.error('[FATAL] Startup failed:', err.message);