393 lines
17 KiB
JavaScript
393 lines
17 KiB
JavaScript
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||
// Posimai Brain API - /save endpoint (UPDATED)
|
||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||
// Purpose: Save articles from Reader with full-text content
|
||
// Location: Copy this to Synology NAS at /app/server.js or /brain/api/save.js
|
||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||
|
||
/**
|
||
* POST /brain/api/save
|
||
*
|
||
* Saves an article to Brain with full-text content and AI analysis
|
||
*
|
||
* Request body:
|
||
* {
|
||
* url: string // Article URL (required)
|
||
* title: string // Article title (required)
|
||
* content: string // Full article body from Reader (NEW!)
|
||
* source: string // 'reader', 'feed', or 'bookmarklet'
|
||
* }
|
||
*
|
||
* Response:
|
||
* {
|
||
* success: boolean
|
||
* articleId: number
|
||
* fullTextSaved: boolean // Debug info
|
||
* textLength: number // Debug info
|
||
* }
|
||
*/
|
||
router.post('/save', authMiddleware, async (req, res) => {
|
||
const { url, title, content, source } = req.body || {};
|
||
|
||
// ──────────────────────────────────────────────────────────────────
|
||
// 1. Validation
|
||
// ──────────────────────────────────────────────────────────────────
|
||
if (!url || !title) {
|
||
return res.status(400).json({
|
||
error: 'Missing required fields: url and title are required'
|
||
});
|
||
}
|
||
|
||
try {
|
||
// ──────────────────────────────────────────────────────────────
|
||
// 2. Get full text content
|
||
// ──────────────────────────────────────────────────────────────
|
||
let fullText = content || null; // Content from Reader
|
||
let meta = {};
|
||
|
||
// If no content provided (e.g., from Feed, Web Share, Command Palette)
|
||
// Fetch full text via Jina Reader API
|
||
if (!fullText || fullText.trim().length === 0) {
|
||
console.log(`[Brain API] No content provided, fetching via Jina Reader for ${url}`);
|
||
|
||
try {
|
||
// Jina Reader API call
|
||
const jinaResponse = await fetch(`https://r.jina.ai/${url}`, {
|
||
headers: {
|
||
'User-Agent': 'Mozilla/5.0 Posimai Brain Bot'
|
||
},
|
||
timeout: 15000 // 15 second timeout
|
||
});
|
||
|
||
if (jinaResponse.ok) {
|
||
let markdown = await jinaResponse.text();
|
||
|
||
// Extract Markdown content (same logic as Reader)
|
||
const contentMarker = 'Markdown Content:';
|
||
const contentIndex = markdown.indexOf(contentMarker);
|
||
if (contentIndex !== -1) {
|
||
fullText = markdown.substring(contentIndex + contentMarker.length).trim();
|
||
} else {
|
||
fullText = markdown;
|
||
}
|
||
|
||
// Remove image references (same logic as Reader)
|
||
fullText = fullText.replace(/!\[Image\s+\d+[^\]]*\]\([^)]*\)/gmi, '');
|
||
fullText = fullText.replace(/!\[Image\s+\d+[^\]]*\]/gmi, '');
|
||
fullText = fullText.replace(/^\s*\*?\s*!\[?Image\s+\d+[^\n]*/gmi, '');
|
||
fullText = fullText.replace(/\[\]\([^)]*\)/gm, '');
|
||
|
||
console.log(`[Brain API] Fetched full text via Jina Reader (${fullText.length} chars)`);
|
||
} else {
|
||
console.warn(`[Brain API] Jina Reader returned status ${jinaResponse.status}`);
|
||
}
|
||
} catch (error) {
|
||
console.error('[Brain API] Jina Reader fetch failed:', error);
|
||
}
|
||
|
||
// Fallback: If Jina Reader failed, use OGP description
|
||
if (!fullText || fullText.trim().length === 0) {
|
||
console.log(`[Brain API] Jina Reader failed, falling back to OGP for ${url}`);
|
||
meta = await fetchOGP(url);
|
||
fullText = meta.desc || '';
|
||
}
|
||
} else {
|
||
console.log(`[Brain API] Received full text content (${fullText.length} chars) for ${url}`);
|
||
}
|
||
|
||
// Fetch OGP metadata for favicon/og_image (regardless of full text source)
|
||
if (!meta.favicon && !meta.ogImage) {
|
||
meta = await fetchOGP(url);
|
||
}
|
||
|
||
// ──────────────────────────────────────────────────────────────
|
||
// 3. AI Analysis with full text
|
||
// ──────────────────────────────────────────────────────────────
|
||
const ai = await analyzeWithGemini(title, fullText, url);
|
||
|
||
// ──────────────────────────────────────────────────────────────
|
||
// 4. Save to database with full_text
|
||
// ──────────────────────────────────────────────────────────────
|
||
const result = await pool.query(`
|
||
INSERT INTO articles (
|
||
user_id,
|
||
url,
|
||
title,
|
||
full_text,
|
||
summary,
|
||
topics,
|
||
source,
|
||
favicon,
|
||
og_image,
|
||
status,
|
||
created_at,
|
||
updated_at
|
||
) VALUES (
|
||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), NOW()
|
||
)
|
||
ON CONFLICT (user_id, url) DO UPDATE SET
|
||
title = EXCLUDED.title,
|
||
full_text = EXCLUDED.full_text,
|
||
summary = EXCLUDED.summary,
|
||
topics = EXCLUDED.topics,
|
||
favicon = EXCLUDED.favicon,
|
||
og_image = EXCLUDED.og_image,
|
||
updated_at = NOW()
|
||
RETURNING id
|
||
`, [
|
||
req.userId, // $1: user_id from authMiddleware
|
||
url, // $2: url
|
||
title, // $3: title
|
||
fullText, // $4: full_text
|
||
ai.summary, // $5: AI-generated summary
|
||
ai.topics, // $6: AI-generated topics
|
||
source || 'reader', // $7: source
|
||
meta.favicon || null, // $8: favicon
|
||
meta.ogImage || null, // $9: og_image
|
||
'inbox' // $10: default status
|
||
]);
|
||
|
||
const articleId = result.rows[0].id;
|
||
|
||
// ──────────────────────────────────────────────────────────────
|
||
// 5. Success response
|
||
// ──────────────────────────────────────────────────────────────
|
||
console.log(`[Brain API] Article saved successfully: ID=${articleId}, URL=${url}, FullText=${!!fullText}, Length=${fullText?.length || 0}`);
|
||
|
||
return res.json({
|
||
success: true,
|
||
articleId: articleId,
|
||
fullTextSaved: !!fullText, // Debug: Was full text saved?
|
||
textLength: fullText?.length || 0 // Debug: How long is the text?
|
||
});
|
||
|
||
} catch (error) {
|
||
console.error('[Brain API] Save failed:', error);
|
||
|
||
return res.status(500).json({
|
||
error: 'Failed to save article',
|
||
message: error.message
|
||
});
|
||
}
|
||
});
|
||
|
||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||
// Helper Functions
|
||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||
|
||
/**
|
||
* Fetch OGP metadata from URL
|
||
*
|
||
* @param {string} url - Article URL
|
||
* @returns {Promise<{desc: string, favicon: string, ogImage: string}>}
|
||
*/
|
||
async function fetchOGP(url) {
|
||
try {
|
||
// Your existing OGP fetching logic
|
||
// Example implementation:
|
||
const response = await fetch(url, {
|
||
headers: { 'User-Agent': 'Mozilla/5.0 Posimai Brain Bot' }
|
||
});
|
||
|
||
const html = await response.text();
|
||
|
||
// Parse OGP tags (simplified example)
|
||
const descMatch = html.match(/<meta property="og:description" content="([^"]+)"/);
|
||
const imageMatch = html.match(/<meta property="og:image" content="([^"]+)"/);
|
||
const faviconMatch = html.match(/<link rel="icon" href="([^"]+)"/);
|
||
|
||
return {
|
||
desc: descMatch ? descMatch[1] : '',
|
||
ogImage: imageMatch ? imageMatch[1] : null,
|
||
favicon: faviconMatch ? faviconMatch[1] : null
|
||
};
|
||
|
||
} catch (error) {
|
||
console.error('[fetchOGP] Failed to fetch OGP metadata:', error);
|
||
return { desc: '', ogImage: null, favicon: null };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Analyze article with Gemini AI
|
||
*
|
||
* @param {string} title - Article title
|
||
* @param {string} fullText - Full article body
|
||
* @param {string} url - Article URL
|
||
* @returns {Promise<{summary: string, topics: string[], readingTime: number}>}
|
||
*/
|
||
async function analyzeWithGemini(title, fullText, url) {
|
||
try {
|
||
// Limit text length to prevent token overflow
|
||
// Gemini Flash: ~30k tokens input, ~1 token = 3-4 chars
|
||
// Safe limit: 5000 chars = ~1250 tokens
|
||
const maxLength = 5000;
|
||
const textForAnalysis = fullText?.substring(0, maxLength) || '';
|
||
|
||
const prompt = `
|
||
以下の記事を分析してください:
|
||
|
||
タイトル: ${title}
|
||
本文:
|
||
${textForAnalysis}
|
||
|
||
以下のJSON形式で返してください:
|
||
{
|
||
"summary": "3文の要約(本文の核心を捉えた要約)",
|
||
"topics": ["トピック1", "トピック2"]
|
||
}
|
||
|
||
**重要**:
|
||
- summaryは本文全体の内容を踏まえた正確な要約にしてください
|
||
- topicsは記事の主要なテーマを2つ選んでください(技術、ビジネス、健康、エンタメなど)
|
||
`;
|
||
|
||
// Gemini API call
|
||
const model = genAI.getGenerativeModel({ model: 'gemini-2.0-flash-exp' });
|
||
const response = await model.generateContent(prompt);
|
||
const resultText = response.response.text();
|
||
|
||
// Parse JSON response
|
||
const result = JSON.parse(resultText);
|
||
|
||
return {
|
||
summary: result.summary || 'AI分析中...',
|
||
topics: result.topics || []
|
||
};
|
||
|
||
} catch (error) {
|
||
console.error('[Gemini AI] Analysis failed:', error);
|
||
|
||
// Fallback: Return basic info if AI fails
|
||
return {
|
||
summary: 'AI分析中...',
|
||
topics: []
|
||
};
|
||
}
|
||
}
|
||
|
||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||
// Updated GET /articles endpoint (include full_text in response)
|
||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||
|
||
/**
|
||
* GET /brain/api/articles
|
||
*
|
||
* Fetch all articles for authenticated user
|
||
* NOW INCLUDES full_text for Brain UI display
|
||
*/
|
||
router.get('/articles', authMiddleware, async (req, res) => {
|
||
try {
|
||
const { rows } = await pool.query(`
|
||
SELECT
|
||
id,
|
||
url,
|
||
title,
|
||
full_text,
|
||
summary,
|
||
topics,
|
||
source,
|
||
favicon,
|
||
og_image,
|
||
status,
|
||
created_at,
|
||
updated_at
|
||
FROM articles
|
||
WHERE user_id = $1
|
||
ORDER BY created_at DESC
|
||
`, [req.userId]);
|
||
|
||
return res.json({
|
||
articles: rows.map(row => ({
|
||
id: row.id,
|
||
url: row.url,
|
||
title: row.title,
|
||
fullText: row.full_text,
|
||
summary: row.summary,
|
||
topics: row.topics,
|
||
source: row.source,
|
||
favicon: row.favicon,
|
||
ogImage: row.og_image,
|
||
status: row.status,
|
||
createdAt: row.created_at,
|
||
updatedAt: row.updated_at
|
||
}))
|
||
});
|
||
|
||
} catch (error) {
|
||
console.error('[Brain API] Failed to fetch articles:', error);
|
||
|
||
return res.status(500).json({
|
||
error: 'Failed to fetch articles',
|
||
message: error.message
|
||
});
|
||
}
|
||
});
|
||
|
||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||
// Testing & Debugging
|
||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||
|
||
/**
|
||
* GET /brain/api/test-save
|
||
*
|
||
* Debug endpoint to test if full_text is being saved correctly
|
||
*
|
||
* Usage: https://posimai-lab.tail72e846.ts.net/brain/api/test-save
|
||
*/
|
||
router.get('/test-save', authMiddleware, async (req, res) => {
|
||
try {
|
||
const { rows } = await pool.query(`
|
||
SELECT
|
||
id,
|
||
title,
|
||
LENGTH(full_text) as text_length,
|
||
LEFT(full_text, 100) as text_preview,
|
||
source,
|
||
created_at
|
||
FROM articles
|
||
WHERE user_id = $1
|
||
ORDER BY created_at DESC
|
||
LIMIT 10
|
||
`, [req.userId]);
|
||
|
||
const stats = {
|
||
total: rows.length,
|
||
withFullText: rows.filter(r => r.text_length > 0).length,
|
||
withoutFullText: rows.filter(r => r.text_length === 0 || r.text_length === null).length,
|
||
articles: rows
|
||
};
|
||
|
||
return res.json(stats);
|
||
|
||
} catch (error) {
|
||
console.error('[Brain API] Test failed:', error);
|
||
|
||
return res.status(500).json({
|
||
error: 'Test failed',
|
||
message: error.message
|
||
});
|
||
}
|
||
});
|
||
|
||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||
// Export
|
||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||
|
||
module.exports = router;
|
||
|
||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||
// DEPLOYMENT CHECKLIST
|
||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||
//
|
||
// 1. [ ] Run database migration: synology-brain-migration.sql
|
||
// 2. [ ] Update server.js with this code
|
||
// 3. [ ] Restart Brain API server: docker restart posimai-brain-api
|
||
// 4. [ ] Test save from Reader
|
||
// 5. [ ] Check logs: docker logs -f posimai-brain-api
|
||
// 6. [ ] Verify database: SELECT * FROM articles ORDER BY created_at DESC LIMIT 5;
|
||
// 7. [ ] Test GET /articles includes fullText
|
||
// 8. [ ] Update Brain UI to display full text (next task)
|
||
//
|
||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|