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