fix: POST /save と quick-save を即時保存に変更 — fetchMeta/Jina/AI をバックグラウンドへ移動してラグ解消

This commit is contained in:
posimai 2026-04-09 20:48:17 +09:00
parent e4bd0a1901
commit 1336b20c90
1 changed files with 90 additions and 71 deletions

139
server.js
View File

@ -1363,7 +1363,7 @@ function buildRouter() {
} }
}); });
// ========== 記事保存(Jina Reader自動取得対応========== // ========== 記事保存(即時保存 + バックグラウンドメタ取得==========
r.post('/save', authMiddleware, async (req, res) => { r.post('/save', authMiddleware, async (req, res) => {
const { url, title: clientTitle, content, source: clientSource } = req.body || {}; const { url, title: clientTitle, content, source: clientSource } = req.body || {};
if (!url) return res.status(400).json({ error: 'url is required' }); if (!url) return res.status(400).json({ error: 'url is required' });
@ -1373,56 +1373,59 @@ function buildRouter() {
if (!['http:', 'https:'].includes(parsedUrl.protocol)) if (!['http:', 'https:'].includes(parsedUrl.protocol))
return res.status(400).json({ error: 'Only http/https' }); return res.status(400).json({ error: 'Only http/https' });
try {
const meta = await fetchMeta(url);
let fullText = content || null;
const source = clientSource || extractSource(url); const source = clientSource || extractSource(url);
const domain = parsedUrl.hostname;
// 重要: contentが空の場合、Jina Reader APIで本文を自動取得 try {
if (!fullText || fullText.trim().length === 0) { // 1. URLだけ即座にDBへ保存してフロントに返すメタ取得・AIはバックグラウンド
console.log(`[Brain API] No content provided for ${url}, attempting Jina Reader fetch...`); const articleQuery = await pool.query(`
const jinaText = await fetchFullTextViaJina(url);
if (jinaText && jinaText.length > 0) {
fullText = jinaText;
console.log(`[Brain API] ✓ Using Jina Reader full text (${fullText.length} chars)`);
} else {
// Jina Reader失敗時はOGP descriptionをフォールバック
console.log(`[Brain API] ⚠ Jina Reader failed, falling back to OGP description`);
fullText = meta.desc || '';
}
} else {
console.log(`[Brain API] Using provided content (${fullText.length} chars)`);
}
// 即座に保存してフロントに返すAIはバックグラウンド
let articleQuery = await pool.query(`
INSERT INTO articles (user_id, url, title, full_text, summary, topics, source, reading_time, favicon, og_image) INSERT INTO articles (user_id, url, title, full_text, summary, topics, source, reading_time, favicon, og_image)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
ON CONFLICT (user_id, url) DO UPDATE ON CONFLICT (user_id, url) DO UPDATE
SET title=EXCLUDED.title, full_text=EXCLUDED.full_text, source=EXCLUDED.source, summary='⏳ 再分析中...' SET source=EXCLUDED.source, summary='⏳ 再分析中...'
RETURNING * RETURNING *
`, [req.userId, url, clientTitle || meta.title, fullText, '⏳ AI分析中...', ['その他'], source, 3, meta.favicon, meta.ogImage]); `, [req.userId, url, clientTitle || domain, content || null, '⏳ AI分析中...', ['その他'], source, 3,
`https://www.google.com/s2/favicons?domain=${domain}&sz=32`, '']);
let article = articleQuery.rows[0];
const article = articleQuery.rows[0];
res.json({ ok: true, article, aiStatus: 'pending' }); res.json({ ok: true, article, aiStatus: 'pending' });
// バックグラウンドでAI処理ユーザーごとに 50記事/時間 まで) // 2. バックグラウンドでメタ情報取得 → DB更新 → AI分析
if (checkRateLimit('gemini_analyze', req.userId, 50, 60 * 60 * 1000)) { const savedUserId = req.userId;
analyzeWithGemini(clientTitle || meta.title, fullText || meta.desc, url).then(async (ai) => { setImmediate(async () => {
try {
const meta = await fetchMeta(url);
let fullText = content || null;
if (!fullText || fullText.trim().length === 0) {
const jinaText = await fetchFullTextViaJina(url);
fullText = jinaText || meta.desc || '';
}
const finalTitle = clientTitle || meta.title;
await pool.query(`
UPDATE articles SET title=$1, full_text=$2, favicon=$3, og_image=$4
WHERE user_id=$5 AND url=$6
`, [finalTitle, fullText, meta.favicon, meta.ogImage, savedUserId, url]);
if (checkRateLimit('gemini_analyze', savedUserId, 50, 60 * 60 * 1000)) {
analyzeWithGemini(finalTitle, fullText || meta.desc, url).then(async (ai) => {
await pool.query(` await pool.query(`
UPDATE articles SET summary=$1, topics=$2, reading_time=$3 UPDATE articles SET summary=$1, topics=$2, reading_time=$3
WHERE user_id=$4 AND url=$5 WHERE user_id=$4 AND url=$5
`, [ai.summary, ai.topics, ai.readingTime, req.userId, url]); `, [ai.summary, ai.topics, ai.readingTime, savedUserId, url]);
console.log(`[Brain API] ✓ AI analysis completed for ${url}`); console.log(`[Brain API] ✓ AI analysis completed for ${url}`);
}).catch(e => console.error('[Background AI Error]:', e)); }).catch(e => console.error('[Background AI Error]:', e));
} }
} catch (e) {
console.error('[Background Meta Error]:', e.message);
}
});
} catch (e) { } catch (e) {
if (e.code === '23505') return res.status(409).json({ error: 'すでに保存済みです' }); if (e.code === '23505') return res.status(409).json({ error: 'すでに保存済みです' });
console.error(e); res.status(500).json({ error: 'DB error' }); console.error(e);
if (!res.headersSent) res.status(500).json({ error: 'DB error' });
} }
}); });
@ -1459,51 +1462,67 @@ function buildRouter() {
} catch (e) { res.status(500).json({ error: 'DB error' }); } } catch (e) { res.status(500).json({ error: 'DB error' }); }
}); });
// クイック保存 (Bookmarklet等からのGET) — Jina Reader対応 // クイック保存 (Bookmarklet等からのGET) — 即時保存 + バックグラウンドメタ取得
r.get('/quick-save', authMiddleware, async (req, res) => { r.get('/quick-save', authMiddleware, async (req, res) => {
const url = req.query.url; const url = req.query.url;
if (!url) return res.status(400).send('<h1>URL not provided</h1>'); if (!url) return res.status(400).send('<h1>URL not provided</h1>');
try { let parsedUrl;
const meta = await fetchMeta(url); try { parsedUrl = new URL(url); } catch { return res.status(400).send('<h1>Invalid URL</h1>'); }
const domain = parsedUrl.hostname;
const source = extractSource(url); const source = extractSource(url);
// Jina Readerで本文取得を試みる try {
let fullText = await fetchFullTextViaJina(url); // 1. URLだけ即座に保存
if (!fullText || fullText.length === 0) {
fullText = meta.desc || '';
}
await pool.query(` await pool.query(`
INSERT INTO articles (user_id, url, title, full_text, summary, topics, source, reading_time, favicon, og_image) INSERT INTO articles (user_id, url, title, full_text, summary, topics, source, reading_time, favicon, og_image)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
ON CONFLICT (user_id, url) DO UPDATE ON CONFLICT (user_id, url) DO UPDATE
SET title=EXCLUDED.title, full_text=EXCLUDED.full_text, source=EXCLUDED.source, summary='⏳ 再分析中...' SET source=EXCLUDED.source, summary='⏳ 再分析中...'
`, [req.userId, url, meta.title, fullText, '⏳ AI分析中...', ['その他'], source, 3, meta.favicon, meta.ogImage]); `, [req.userId, url, domain, null, '⏳ AI分析中...', ['その他'], source, 3,
`https://www.google.com/s2/favicons?domain=${domain}&sz=32`, '']);
// バックグラウンドAIユーザーごとに 50記事/時間 まで) // 2. HTMLレスポンスを即座に返す
if (checkRateLimit('gemini_analyze', req.userId, 50, 60 * 60 * 1000)) {
analyzeWithGemini(meta.title, fullText, url).then(async (ai) => {
await pool.query(`
UPDATE articles SET summary=$1, topics=$2, reading_time=$3
WHERE user_id=$4 AND url=$5
`, [ai.summary, ai.topics, ai.readingTime, req.userId, url]);
}).catch(e => console.error('[Background AI Error]:', e));
}
// HTMLレスポンス自動で閉じる
res.send(` res.send(`
<!DOCTYPE html> <!DOCTYPE html>
<html><head><meta charset="utf-8"><title>保存完了</title></head> <html><head><meta charset="utf-8"><title>保存完了</title></head>
<body style="font-family:sans-serif;padding:40px;text-align:center;background:#0a0a0a;color:#e2e2e2"> <body style="font-family:sans-serif;padding:40px;text-align:center;background:#0a0a0a;color:#e2e2e2">
<h1 style="color:#818CF8"> 保存しました</h1> <h1 style="color:#6EE7B7"> 保存しました</h1>
<p>${escapeHtml(meta.title)}</p> <p style="color:#888">${escapeHtml(domain)}</p>
<p style="color:#888">AI分析をバックグラウンドで開始しました</p> <p style="color:#888">タイトルAI分析をバックグラウンドで取得中...</p>
<script>setTimeout(() => window.close(), 1500)</script> <script>setTimeout(() => window.close(), 1200)</script>
</body></html> </body></html>
`); `);
// 3. バックグラウンドでメタ情報取得 → DB更新 → AI分析
const savedUserId = req.userId;
setImmediate(async () => {
try {
const meta = await fetchMeta(url);
const jinaText = await fetchFullTextViaJina(url);
const fullText = jinaText || meta.desc || '';
await pool.query(`
UPDATE articles SET title=$1, full_text=$2, favicon=$3, og_image=$4
WHERE user_id=$5 AND url=$6
`, [meta.title, fullText, meta.favicon, meta.ogImage, savedUserId, url]);
if (checkRateLimit('gemini_analyze', savedUserId, 50, 60 * 60 * 1000)) {
analyzeWithGemini(meta.title, fullText, url).then(async (ai) => {
await pool.query(`
UPDATE articles SET summary=$1, topics=$2, reading_time=$3
WHERE user_id=$4 AND url=$5
`, [ai.summary, ai.topics, ai.readingTime, savedUserId, url]);
}).catch(e => console.error('[Background AI Error]:', e));
}
} catch (e) { } catch (e) {
res.status(500).send(`<h1>保存失敗: ${escapeHtml(e.message)}</h1>`); console.error('[Background Meta Error]:', e.message);
}
});
} catch (e) {
if (!res.headersSent) res.status(500).send(`<h1>保存失敗: ${escapeHtml(e.message)}</h1>`);
} }
}); });