posimai-root/docs/synology/synology-brain-api-save-end...

393 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 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)
//
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━