// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ // 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(/} */ 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) // // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━