fix: POST /save と quick-save を即時保存に変更 — fetchMeta/Jina/AI をバックグラウンドへ移動してラグ解消
This commit is contained in:
parent
e4bd0a1901
commit
1336b20c90
139
server.js
139
server.js
|
|
@ -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>`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue