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

393 lines
17 KiB
JavaScript
Raw Normal View History

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