diff --git a/server.js b/server.js index ea1eed90..e0abcbff 100644 --- a/server.js +++ b/server.js @@ -1363,7 +1363,7 @@ function buildRouter() { } }); - // ========== 記事保存(Jina Reader自動取得対応)========== + // ========== 記事保存(即時保存 + バックグラウンドメタ取得)========== r.post('/save', authMiddleware, async (req, res) => { const { url, title: clientTitle, content, source: clientSource } = req.body || {}; if (!url) return res.status(400).json({ error: 'url is required' }); @@ -1373,56 +1373,59 @@ function buildRouter() { if (!['http:', 'https:'].includes(parsedUrl.protocol)) return res.status(400).json({ error: 'Only http/https' }); + const source = clientSource || extractSource(url); + const domain = parsedUrl.hostname; + try { - const meta = await fetchMeta(url); - let fullText = content || null; - const source = clientSource || extractSource(url); - - // 重要: contentが空の場合、Jina Reader APIで本文を自動取得 - if (!fullText || fullText.trim().length === 0) { - console.log(`[Brain API] No content provided for ${url}, attempting Jina Reader fetch...`); - - 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) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) - ON CONFLICT (user_id, url) DO UPDATE - SET title=EXCLUDED.title, full_text=EXCLUDED.full_text, source=EXCLUDED.source, summary='⏳ 再分析中...' - RETURNING * - `, [req.userId, url, clientTitle || meta.title, fullText, '⏳ AI分析中...', ['その他'], source, 3, meta.favicon, meta.ogImage]); - - let article = articleQuery.rows[0]; + // 1. URLだけ即座にDBへ保存してフロントに返す(メタ取得・AIはバックグラウンド) + const articleQuery = await pool.query(` + 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) + ON CONFLICT (user_id, url) DO UPDATE + SET source=EXCLUDED.source, summary='⏳ 再分析中...' + RETURNING * + `, [req.userId, url, clientTitle || domain, content || null, '⏳ AI分析中...', ['その他'], source, 3, + `https://www.google.com/s2/favicons?domain=${domain}&sz=32`, '']); + const article = articleQuery.rows[0]; res.json({ ok: true, article, aiStatus: 'pending' }); - // バックグラウンドでAI処理(ユーザーごとに 50記事/時間 まで) - if (checkRateLimit('gemini_analyze', req.userId, 50, 60 * 60 * 1000)) { - analyzeWithGemini(clientTitle || meta.title, fullText || meta.desc, url).then(async (ai) => { + // 2. バックグラウンドでメタ情報取得 → DB更新 → AI分析 + const savedUserId = req.userId; + 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 summary=$1, topics=$2, reading_time=$3 - WHERE user_id=$4 AND url=$5 - `, [ai.summary, ai.topics, ai.readingTime, req.userId, url]); - console.log(`[Brain API] ✓ AI analysis completed for ${url}`); - }).catch(e => console.error('[Background AI Error]:', e)); - } + 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(` + 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]); + console.log(`[Brain API] ✓ AI analysis completed for ${url}`); + }).catch(e => console.error('[Background AI Error]:', e)); + } + } catch (e) { + console.error('[Background Meta Error]:', e.message); + } + }); } catch (e) { 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' }); } }); - // クイック保存 (Bookmarklet等からのGET) — Jina Reader対応 + // クイック保存 (Bookmarklet等からのGET) — 即時保存 + バックグラウンドメタ取得 r.get('/quick-save', authMiddleware, async (req, res) => { const url = req.query.url; if (!url) return res.status(400).send('

URL not provided

'); + let parsedUrl; + try { parsedUrl = new URL(url); } catch { return res.status(400).send('

Invalid URL

'); } + + const domain = parsedUrl.hostname; + const source = extractSource(url); + try { - const meta = await fetchMeta(url); - const source = extractSource(url); - - // Jina Readerで本文取得を試みる - let fullText = await fetchFullTextViaJina(url); - if (!fullText || fullText.length === 0) { - fullText = meta.desc || ''; - } - + // 1. URLだけ即座に保存 await pool.query(` 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) ON CONFLICT (user_id, url) DO UPDATE - SET title=EXCLUDED.title, full_text=EXCLUDED.full_text, source=EXCLUDED.source, summary='⏳ 再分析中...' - `, [req.userId, url, meta.title, fullText, '⏳ AI分析中...', ['その他'], source, 3, meta.favicon, meta.ogImage]); + SET source=EXCLUDED.source, summary='⏳ 再分析中...' + `, [req.userId, url, domain, null, '⏳ AI分析中...', ['その他'], source, 3, + `https://www.google.com/s2/favicons?domain=${domain}&sz=32`, '']); - // バックグラウンドAI(ユーザーごとに 50記事/時間 まで) - 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レスポンス(自動で閉じる) + // 2. HTMLレスポンスを即座に返す res.send(` 保存完了 -

✓ 保存しました

-

${escapeHtml(meta.title)}

-

AI分析をバックグラウンドで開始しました

- +

✓ 保存しました

+

${escapeHtml(domain)}

+

タイトル・AI分析をバックグラウンドで取得中...

+ `); + + // 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) { + console.error('[Background Meta Error]:', e.message); + } + }); + } catch (e) { - res.status(500).send(`

保存失敗: ${escapeHtml(e.message)}

`); + if (!res.headersSent) res.status(500).send(`

保存失敗: ${escapeHtml(e.message)}

`); } });