posimai-root/server.js

1744 lines
80 KiB
JavaScript
Raw Normal View History

// ============================================
// Posimai Brain API — Synology Docker Server
// ============================================
// Port: 8090
// Route prefix: /brain/api (Tailscale Funnel) and /api (local dev)
//
// ENV VARS (set in docker-compose.yml):
// DB_HOST, DB_PORT, DB_USER, DB_PASSWORD
// GEMINI_API_KEY
// API_KEYS = "pk_maita_xxx:maita,pk_partner_xxx:partner,pk_musume_xxx:musume"
// ALLOWED_ORIGINS = 追加許可したいオリジン(カンマ区切り)※ posimai-*.vercel.app は自動許可
// ============================================
const express = require('express');
const cors = require('cors');
const { Pool } = require('pg');
const { GoogleGenerativeAI } = require('@google/generative-ai');
const { parse } = require('node-html-parser');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const app = express();
app.use(express.json({ limit: '10mb' }));
// ── CORS ──────────────────────────────────
// ALLOWED_ORIGINS 環境変数(カンマ区切り)+ 開発用ローカル
// posimai-*.vercel.app は新アプリ追加のたびに変更不要(ワイルドカード許可)
const extraOrigins = (process.env.ALLOWED_ORIGINS || '')
.split(',').map(o => o.trim()).filter(Boolean);
function isAllowedOrigin(origin) {
if (!origin) return true; // 同一オリジン
if (process.env.NODE_ENV !== 'production' && /^http:\/\/localhost(:\d+)?$/.test(origin)) return true; // localhost 開発のみ
if (/^https:\/\/posimai-[^.]+\.vercel\.app$/.test(origin)) return true; // 全 Posimai アプリ
if (extraOrigins.includes(origin)) return true; // 追加許可
return false;
}
// Chrome の Private Network Access ポリシー対応cors より前に置く必要がある)
// cors() が OPTIONS preflight を先に完結させるため、後に置いても実行されない
app.use((req, res, next) => {
if (req.headers['access-control-request-private-network']) {
res.setHeader('Access-Control-Allow-Private-Network', 'true');
}
next();
});
app.use(cors({
origin: (origin, cb) => {
if (isAllowedOrigin(origin)) cb(null, true);
else cb(new Error('CORS not allowed'));
},
methods: ['GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization']
}));
// ── PostgreSQL ────────────────────────────
const pool = new Pool({
host: process.env.DB_HOST || 'db',
port: parseInt(process.env.DB_PORT || '5432'),
user: process.env.DB_USER || 'gitea',
password: process.env.DB_PASSWORD || '',
database: 'posimai_brain',
max: 5
});
// ── Gemini ────────────────────────────────
const genAI = process.env.GEMINI_API_KEY
? new GoogleGenerativeAI(process.env.GEMINI_API_KEY) : null;
// Together 専用インスタンス(メインキーを共用)
const genAITogether = genAI;
// ── API Key 認証 ──────────────────────────
// API_KEYS="pk_maita_abc:maita,pk_partner_def:partner"
const KEY_MAP = {};
(process.env.API_KEYS || '').split(',').forEach(pair => {
const [key, userId] = pair.trim().split(':');
if (key && userId) KEY_MAP[key.trim()] = userId.trim();
});
function authMiddleware(req, res, next) {
let key = '';
// 1. ヘッダーからの取得
const auth = req.headers.authorization || '';
if (auth.toLowerCase().startsWith('bearer ')) {
key = auth.substring(7).trim();
}
// 2. クエリパラメータからの取得 (Bookmarklet等)
else if (req.query.key) {
key = req.query.key.trim();
}
const userId = KEY_MAP[key];
if (!userId) return res.status(401).json({ error: '認証エラー: APIキーが無効です' });
req.userId = userId;
next();
}
// ── ソース抽出 ────────────────────────────
const SOURCE_MAP = {
'zenn.dev': 'Zenn', 'qiita.com': 'Qiita',
'x.com': 'X', 'twitter.com': 'X',
'note.com': 'note', 'dev.to': 'DEV',
'nikkei.com': '日経', 'nikkei.co.jp': '日経',
'gigazine.net': 'GIGAZINE', 'gizmodo.jp': 'GIZMODE',
'developers.io': 'DevelopersIO', 'classmethod.jp': 'DevelopersIO',
'github.com': 'GitHub', 'medium.com': 'Medium',
'techcrunch.com': 'TechCrunch', 'vercel.com': 'Vercel',
};
function extractSource(url) {
try {
const host = new URL(url).hostname.replace(/^www\./, '');
for (const [domain, label] of Object.entries(SOURCE_MAP)) {
if (host === domain || host.endsWith('.' + domain)) return label;
}
return host;
} catch { return 'unknown'; }
}
// ── OGP フェッチ ───────────────────────────
async function fetchMeta(url) {
try {
const res = await fetch(url, {
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; PosimaiBot/1.0)' },
signal: AbortSignal.timeout(6000)
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const buffer = await res.arrayBuffer();
// 文字コード判定先頭2000バイトからcharsetを探す
const headSnippet = new TextDecoder('utf-8', { fatal: false }).decode(buffer.slice(0, 2000));
let encoding = 'utf-8';
const charsetMatch = headSnippet.match(/charset=["']?(shift_jis|euc-jp|utf-8)["']?/i);
if (charsetMatch && charsetMatch[1]) {
encoding = charsetMatch[1].toLowerCase();
}
const html = new TextDecoder(encoding).decode(buffer);
const doc = parse(html);
const og = (p) => doc.querySelector(`meta[property="${p}"]`)?.getAttribute('content') || '';
const meta = (n) => doc.querySelector(`meta[name="${n}"]`)?.getAttribute('content') || '';
const title = og('og:title') || doc.querySelector('title')?.text || url;
const desc = og('og:description') || meta('description') || '';
const img = og('og:image') || '';
let host; // Declare host here for broader scope
// Google Favicon API優先→ favicon.icoフォールバック
const faviconUrl = `https://www.google.com/s2/favicons?domain=${new URL(url).hostname}&sz=32`;
return {
title: title.trim().slice(0, 300), desc: desc.trim().slice(0, 500),
ogImage: img, favicon: faviconUrl
};
} catch {
let host = '';
try {
try { host = new URL(url).hostname; } catch { }
return {
title: url.slice(0, 300), desc: '', ogImage: '',
favicon: host ? `https://www.google.com/s2/favicons?domain=${host}&sz=32` : ''
};
} catch { return { title: url.slice(0, 300), desc: '', ogImage: '', favicon: '' }; }
}
}
// ── Jina Reader API フェッチ(新規追加)───
async function fetchFullTextViaJina(url) {
try {
console.log(`[Brain API] Fetching full text via Jina Reader for: ${url}`);
const jinaResponse = await fetch(`https://r.jina.ai/${url}`, {
headers: {
'User-Agent': 'Mozilla/5.0 Posimai Brain Bot'
},
signal: AbortSignal.timeout(15000)
});
if (!jinaResponse.ok) {
console.warn(`[Brain API] Jina Reader returned status ${jinaResponse.status}`);
return null;
}
let markdown = await jinaResponse.text();
// Markdown Content: マーカーの後ろを抽出
const contentMarker = 'Markdown Content:';
const contentIndex = markdown.indexOf(contentMarker);
if (contentIndex !== -1) {
markdown = markdown.substring(contentIndex + contentMarker.length).trim();
}
// 画像参照を除去Readerと同じロジック
markdown = markdown.replace(/!\[Image\s+\d+[^\]]*\]\([^)]*\)/gmi, '');
markdown = markdown.replace(/!\[Image\s+\d+[^\]]*\]/gmi, '');
markdown = markdown.replace(/^\s*\*?\s*!\[?Image\s+\d+[^\n]*/gmi, '');
markdown = markdown.replace(/\[\]\([^)]*\)/gm, '');
console.log(`[Brain API] ✓ Fetched ${markdown.length} chars via Jina Reader`);
return markdown;
} catch (error) {
console.error('[Brain API] Jina Reader fetch failed:', error.message);
return null;
}
}
// ── Gemini 分析 ───────────────────────────
const TOPIC_CANDIDATES = [
'AI', 'React', 'Next.js', 'TypeScript', 'Node.js',
'Flutter', 'Docker', 'PostgreSQL',
'日本酒', 'ガジェット', 'キャリア', 'その他'
];
function smartExtract(text, maxLen) {
if (!text || text.length <= maxLen) return text || '';
const halfLen = Math.floor(maxLen / 2);
const front = text.substring(0, halfLen);
const back = text.substring(text.length - halfLen);
return front + '\n\n[...中略...]\n\n' + back;
}
async function analyzeWithGemini(title, fullText, url) {
if (!genAI) return { summary: (fullText || '').slice(0, 120) || '(要約なし)', topics: ['その他'], readingTime: 3 };
try {
const model = genAI.getGenerativeModel({
model: 'gemini-2.0-flash-lite',
generationConfig: { responseMimeType: 'application/json' }
});
const prompt = `記事分析してJSONで返答:
タイトル: ${title.slice(0, 100)}
本文:
${smartExtract(fullText || '', 5000)}
{"summary":"3文要約","topics":["最大2個"],"readingTime":3}
候補: ${TOPIC_CANDIDATES.slice(0, 15).join(',')}`;
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Gemini timeout')), 15000)
);
let result;
result = await Promise.race([
model.generateContent(prompt),
timeoutPromise
]);
let raw = result.response.text().trim();
// ```json や ``` で囲まれている場合の除去を堅牢化
raw = raw.replace(/^```(json)?/i, '').replace(/```$/i, '').trim();
const parsed = JSON.parse(raw);
const validTopics = (parsed.topics || [])
.filter(t => TOPIC_CANDIDATES.includes(t)).slice(0, 2);
if (validTopics.length === 0) validTopics.push('その他');
return {
summary: String(parsed.summary || '').slice(0, 300),
topics: validTopics,
readingTime: Math.max(1, parseInt(parsed.readingTime) || 3)
};
} catch (e) {
console.error('[Gemini] FULL ERROR:', e);
if (typeof result !== 'undefined' && result?.response) {
console.error('[Gemini] Raw response:', result.response.text());
}
return {
summary: `⚠️ AI分析失敗: ${String(e)}`,
topics: ['その他'],
readingTime: 3
};
}
}
// ── DB 初期化 (起動時に自動実行) ─────────
async function initDB() {
// 1. posimai_brain DB を作成(なければ)
const admin = new Pool({
host: process.env.DB_HOST || 'db', port: parseInt(process.env.DB_PORT || '5432'),
user: process.env.DB_USER || 'gitea', password: process.env.DB_PASSWORD || '',
database: 'gitea'
});
try {
await admin.query('CREATE DATABASE posimai_brain');
console.log('[DB] Created database posimai_brain');
} catch (e) {
if (e.code !== '42P04') throw e;
console.log('[DB] Database already exists, skipping');
} finally { await admin.end(); }
// 2. テーブル定義 — 1文ずつ実行マルチステートメントのエラーを防止
const schema = [
`CREATE TABLE IF NOT EXISTS users (
user_id VARCHAR(50) PRIMARY KEY,
name VARCHAR(100) NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
)`,
`CREATE TABLE IF NOT EXISTS articles (
id SERIAL PRIMARY KEY,
user_id VARCHAR(50) NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
url TEXT NOT NULL,
title TEXT,
summary TEXT,
topics TEXT[] DEFAULT '{}',
source VARCHAR(100),
status VARCHAR(20) DEFAULT 'inbox',
previous_status VARCHAR(20) DEFAULT 'inbox',
reading_time INT DEFAULT 3,
favicon TEXT,
og_image TEXT,
saved_at TIMESTAMPTZ DEFAULT NOW(),
read_at TIMESTAMPTZ,
full_text TEXT,
UNIQUE(user_id, url)
)`,
`CREATE INDEX IF NOT EXISTS idx_articles_user_id ON articles(user_id)`,
`CREATE INDEX IF NOT EXISTS idx_articles_saved_at ON articles(saved_at DESC)`,
`CREATE INDEX IF NOT EXISTS idx_articles_status ON articles(user_id, status)`,
`CREATE INDEX IF NOT EXISTS idx_articles_topics ON articles USING GIN(topics)`,
`CREATE TABLE IF NOT EXISTS reading_history (
id SERIAL PRIMARY KEY,
user_id VARCHAR(50) NOT NULL,
url TEXT NOT NULL,
title TEXT,
domain VARCHAR(200),
read_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, url)
)`,
`CREATE TABLE IF NOT EXISTS journal_posts (
id SERIAL PRIMARY KEY,
user_id VARCHAR(50) NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
title TEXT NOT NULL DEFAULT '',
body TEXT NOT NULL DEFAULT '',
tags TEXT[] DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
)`,
`CREATE INDEX IF NOT EXISTS idx_journal_user_id ON journal_posts(user_id)`,
`CREATE INDEX IF NOT EXISTS idx_journal_updated ON journal_posts(updated_at DESC)`,
// site_config: CMS サイト設定テーブル
`CREATE TABLE IF NOT EXISTS site_config (
key VARCHAR(50) PRIMARY KEY,
value JSONB NOT NULL DEFAULT '{}',
updated_at TIMESTAMPTZ DEFAULT NOW()
)`,
// ── Habit ──────────────────────────────
`CREATE TABLE IF NOT EXISTS habit_habits (
id SERIAL PRIMARY KEY,
user_id VARCHAR(50) NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
icon VARCHAR(50) NOT NULL DEFAULT 'check',
position INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW()
)`,
`CREATE TABLE IF NOT EXISTS habit_log (
user_id VARCHAR(50) NOT NULL,
habit_id INTEGER NOT NULL REFERENCES habit_habits(id) ON DELETE CASCADE,
log_date DATE NOT NULL,
PRIMARY KEY (user_id, habit_id, log_date)
)`,
// ── Pulse ──────────────────────────────
`CREATE TABLE IF NOT EXISTS pulse_log (
user_id VARCHAR(50) NOT NULL,
log_date DATE NOT NULL,
mood SMALLINT CHECK (mood BETWEEN 1 AND 5),
energy SMALLINT CHECK (energy BETWEEN 1 AND 5),
focus SMALLINT CHECK (focus BETWEEN 1 AND 5),
note TEXT NOT NULL DEFAULT '',
updated_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (user_id, log_date)
)`,
// ── Lens ───────────────────────────────
`CREATE TABLE IF NOT EXISTS lens_history (
id SERIAL PRIMARY KEY,
user_id VARCHAR(50) NOT NULL,
filename VARCHAR(255),
exif_data JSONB NOT NULL DEFAULT '{}',
thumbnail TEXT,
scanned_at TIMESTAMPTZ DEFAULT NOW()
)`,
// ── Together ────────────────────────────
`CREATE TABLE IF NOT EXISTS together_groups (
id SERIAL PRIMARY KEY,
invite_code VARCHAR(8) UNIQUE NOT NULL,
name VARCHAR(100) NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
)`,
`CREATE TABLE IF NOT EXISTS together_members (
group_id INTEGER REFERENCES together_groups(id) ON DELETE CASCADE,
username VARCHAR(50) NOT NULL,
joined_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (group_id, username)
)`,
`CREATE TABLE IF NOT EXISTS together_shares (
id SERIAL PRIMARY KEY,
group_id INTEGER REFERENCES together_groups(id) ON DELETE CASCADE,
shared_by VARCHAR(50) NOT NULL,
url TEXT,
title TEXT,
message TEXT NOT NULL DEFAULT '',
og_image TEXT,
tags TEXT[] DEFAULT '{}',
full_content TEXT,
summary TEXT,
archive_status VARCHAR(10) NOT NULL DEFAULT 'pending',
shared_at TIMESTAMPTZ DEFAULT NOW()
)`,
`CREATE INDEX IF NOT EXISTS idx_together_shares_group ON together_shares(group_id, shared_at DESC)`,
`CREATE TABLE IF NOT EXISTS together_reactions (
share_id INTEGER REFERENCES together_shares(id) ON DELETE CASCADE,
username VARCHAR(50) NOT NULL,
type VARCHAR(20) NOT NULL DEFAULT 'like',
created_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (share_id, username, type)
)`,
`CREATE TABLE IF NOT EXISTS together_comments (
id SERIAL PRIMARY KEY,
share_id INTEGER REFERENCES together_shares(id) ON DELETE CASCADE,
username VARCHAR(50) NOT NULL,
body TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
)`,
`CREATE INDEX IF NOT EXISTS idx_together_comments_share ON together_comments(share_id, created_at)`,
];
for (const sql of schema) {
await pool.query(sql).catch(e => console.warn('[DB] Schema warning:', e.message));
}
// 3. マイグレーション — 既存テーブルへのカラム追加(全て安全に実行)
const migrations = [
`ALTER TABLE articles ADD COLUMN IF NOT EXISTS previous_status VARCHAR(20) DEFAULT 'inbox'`,
`ALTER TABLE articles ADD COLUMN IF NOT EXISTS full_text TEXT`,
`ALTER TABLE journal_posts ADD COLUMN IF NOT EXISTS published BOOLEAN NOT NULL DEFAULT FALSE`,
`CREATE INDEX IF NOT EXISTS idx_journal_published ON journal_posts(published)`,
// site_config をユーザー別に分離(既存データは maita に帰属)
`ALTER TABLE site_config ADD COLUMN IF NOT EXISTS user_id VARCHAR(50) NOT NULL DEFAULT 'maita'`,
`ALTER TABLE site_config DROP CONSTRAINT IF EXISTS site_config_pkey`,
`ALTER TABLE site_config ADD PRIMARY KEY (user_id, key)`,
// together テーブルをクリーンリセット(古いスキーマを完全に作り直す)
`DROP TABLE IF EXISTS together_comments, together_reactions, together_shares, together_members, together_groups CASCADE`,
`CREATE TABLE together_groups (
id SERIAL PRIMARY KEY,
invite_code VARCHAR(8) UNIQUE NOT NULL,
name VARCHAR(100) NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
)`,
`CREATE TABLE together_members (
group_id INTEGER REFERENCES together_groups(id) ON DELETE CASCADE,
username VARCHAR(50) NOT NULL,
joined_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (group_id, username)
)`,
`CREATE TABLE together_shares (
id SERIAL PRIMARY KEY,
group_id INTEGER REFERENCES together_groups(id) ON DELETE CASCADE,
shared_by VARCHAR(50) NOT NULL,
url TEXT,
title TEXT,
message TEXT NOT NULL DEFAULT '',
og_image TEXT,
tags TEXT[] DEFAULT '{}',
full_content TEXT,
summary TEXT,
archive_status VARCHAR(10) NOT NULL DEFAULT 'pending',
shared_at TIMESTAMPTZ DEFAULT NOW()
)`,
`CREATE INDEX IF NOT EXISTS idx_together_shares_group ON together_shares(group_id, shared_at DESC)`,
`CREATE TABLE together_reactions (
share_id INTEGER REFERENCES together_shares(id) ON DELETE CASCADE,
username VARCHAR(50) NOT NULL,
type VARCHAR(20) NOT NULL DEFAULT 'like',
created_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (share_id, username, type)
)`,
`CREATE TABLE together_comments (
id SERIAL PRIMARY KEY,
share_id INTEGER REFERENCES together_shares(id) ON DELETE CASCADE,
username VARCHAR(50) NOT NULL,
body TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
)`,
`CREATE INDEX IF NOT EXISTS idx_together_comments_share ON together_comments(share_id, created_at)`,
];
for (const sql of migrations) {
await pool.query(sql).catch(e => console.warn('[DB] Migration warning:', e.message));
}
// 4. 初期ユーザー (API_KEYSから)
for (const [, userId] of Object.entries(KEY_MAP)) {
await pool.query(
`INSERT INTO users (user_id, name) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
[userId, userId]
);
}
console.log('[DB] Schema ready. Users:', Object.values(KEY_MAP).join(', '));
}
// ── ルーター ──────────────────────────────
function buildRouter() {
const r = express.Router();
// ヘルスチェック
r.get('/health', (req, res) => {
res.json({ status: 'ok', gemini: !!genAI, users: Object.values(KEY_MAP).length });
});
// 認証テスト (UI用)
r.get('/auth-test', authMiddleware, (req, res) => {
res.json({ ok: true, userId: req.userId });
});
// 記事一覧取得
r.get('/articles', authMiddleware, async (req, res) => {
const { status, topic, source, q } = req.query;
let sql = `SELECT id, url, title, summary, topics, source, status, previous_status, reading_time, favicon, saved_at, (full_text IS NOT NULL) AS has_full_text FROM articles WHERE user_id = $1`;
const params = [req.userId];
let i = 2;
if (status) { sql += ` AND status = $${i++}`; params.push(status); }
if (topic) { sql += ` AND $${i++} = ANY(topics)`; params.push(topic); }
if (source) { sql += ` AND source ILIKE $${i++}`; params.push(source); }
if (q) {
sql += ` AND (title ILIKE $${i} OR summary ILIKE $${i})`;
params.push(`%${q}%`); i++;
}
sql += ' ORDER BY saved_at DESC LIMIT 300';
try {
const { rows } = await pool.query(sql, params);
// カウント計算
const counts = {
all: rows.length,
unread: rows.filter(a => a.status === 'inbox').length,
favorite: rows.filter(a => a.status === 'favorite').length,
shared: rows.filter(a => a.status === 'shared').length
};
res.json({ articles: rows, counts });
} catch (e) { console.error(e); res.status(500).json({ error: 'DB error' }); }
});
// 記事詳細取得(full_text を含む完全版)
r.get('/articles/:id', authMiddleware, async (req, res) => {
try {
const { rows } = await pool.query(
'SELECT * FROM articles WHERE id=$1 AND user_id=$2',
[req.params.id, req.userId]
);
if (rows.length === 0) return res.status(404).json({ error: 'Article not found' });
return res.json({ article: rows[0] });
} catch (e) {
console.error('[Brain API] GET /articles/:id failed:', e.stack || e);
return res.status(500).json({ error: 'DB error' });
}
});
// ========== 記事保存Jina Reader自動取得対応==========
r.post('/save', authMiddleware, async (req, res) => {
const { url, title: clientTitle, content, source: clientSource } = req.body || {};
if (!url) return res.status(400).json({ error: 'url is required' });
let parsedUrl;
try { parsedUrl = new URL(url); } catch { return res.status(400).json({ error: 'Invalid URL' }); }
if (!['http:', 'https:'].includes(parsedUrl.protocol))
return res.status(400).json({ error: 'Only http/https' });
try {
const meta = await fetchMeta(url);
let fullText = content || null;
const source = clientSource || extractSource(url);
// 重要: contentが空の場合、Jina Reader APIで本文を自動取得
if (!fullText || fullText.trim().length === 0) {
console.log(`[Brain API] No content provided for ${url}, attempting Jina Reader fetch...`);
const jinaText = await fetchFullTextViaJina(url);
if (jinaText && jinaText.length > 0) {
fullText = jinaText;
console.log(`[Brain API] ✓ Using Jina Reader full text (${fullText.length} chars)`);
} else {
// Jina Reader失敗時はOGP descriptionをフォールバック
console.log(`[Brain API] ⚠ Jina Reader failed, falling back to OGP description`);
fullText = meta.desc || '';
}
} else {
console.log(`[Brain API] Using provided content (${fullText.length} chars)`);
}
// 即座に保存してフロントに返すAIはバックグラウンド
let articleQuery = await pool.query(`
INSERT INTO articles (user_id, url, title, full_text, summary, topics, source, reading_time, favicon, og_image)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
ON CONFLICT (user_id, url) DO UPDATE
SET title=EXCLUDED.title, full_text=EXCLUDED.full_text, source=EXCLUDED.source, summary='⏳ 再分析中...'
RETURNING *
`, [req.userId, url, clientTitle || meta.title, fullText, '⏳ AI分析中...', ['その他'], source, 3, meta.favicon, meta.ogImage]);
let article = articleQuery.rows[0];
res.json({ ok: true, article, aiStatus: 'pending' });
// バックグラウンドでAI処理
analyzeWithGemini(clientTitle || meta.title, fullText || meta.desc, url).then(async (ai) => {
await pool.query(`
UPDATE articles SET summary=$1, topics=$2, reading_time=$3
WHERE user_id=$4 AND url=$5
`, [ai.summary, ai.topics, ai.readingTime, req.userId, url]);
console.log(`[Brain API] ✓ AI analysis completed for ${url}`);
}).catch(e => console.error('[Background AI Error]:', e));
} catch (e) {
if (e.code === '23505') return res.status(409).json({ error: 'すでに保存済みです' });
console.error(e); res.status(500).json({ error: 'DB error' });
}
});
// ステータス更新
r.patch('/articles/:id/status', authMiddleware, async (req, res) => {
const { status } = req.body || {};
const valid = ['inbox', 'favorite', 'shared'];
if (!valid.includes(status)) return res.status(400).json({ error: 'Invalid status' });
try {
const readAt = status === 'shared' || status === 'reading' ? 'NOW()' : 'NULL';
if (status === 'favorite') {
await pool.query(
`UPDATE articles SET previous_status=status, status=$1, read_at=${readAt === 'NULL' ? 'NULL' : 'NOW()'}
WHERE id=$2 AND user_id=$3`,
[status, req.params.id, req.userId]
);
} else {
await pool.query(
`UPDATE articles SET status=$1, read_at=${readAt === 'NULL' ? 'NULL' : 'NOW()'}
WHERE id=$2 AND user_id=$3`,
[status, req.params.id, req.userId]
);
}
res.json({ ok: true });
} catch (e) { res.status(500).json({ error: 'DB error' }); }
});
// 削除
r.delete('/articles/:id', authMiddleware, async (req, res) => {
try {
await pool.query('DELETE FROM articles WHERE id=$1 AND user_id=$2', [req.params.id, req.userId]);
res.json({ ok: true });
} catch (e) { res.status(500).json({ error: 'DB error' }); }
});
// クイック保存 (Bookmarklet等からのGET) — Jina Reader対応
r.get('/quick-save', authMiddleware, async (req, res) => {
const url = req.query.url;
if (!url) return res.status(400).send('<h1>URL not provided</h1>');
try {
const meta = await fetchMeta(url);
const source = extractSource(url);
// Jina Readerで本文取得を試みる
let fullText = await fetchFullTextViaJina(url);
if (!fullText || fullText.length === 0) {
fullText = meta.desc || '';
}
await pool.query(`
INSERT INTO articles (user_id, url, title, full_text, summary, topics, source, reading_time, favicon, og_image)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
ON CONFLICT (user_id, url) DO UPDATE
SET title=EXCLUDED.title, full_text=EXCLUDED.full_text, source=EXCLUDED.source, summary='⏳ 再分析中...'
`, [req.userId, url, meta.title, fullText, '⏳ AI分析中...', ['その他'], source, 3, meta.favicon, meta.ogImage]);
// バックグラウンドAI
analyzeWithGemini(meta.title, fullText, url).then(async (ai) => {
await pool.query(`
UPDATE articles SET summary=$1, topics=$2, reading_time=$3
WHERE user_id=$4 AND url=$5
`, [ai.summary, ai.topics, ai.readingTime, req.userId, url]);
}).catch(e => console.error('[Background AI Error]:', e));
// HTMLレスポンス自動で閉じる
res.send(`
<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>保存完了</title></head>
<body style="font-family:sans-serif;padding:40px;text-align:center;background:#0a0a0a;color:#e2e2e2">
<h1 style="color:#818CF8"> 保存しました</h1>
<p>${meta.title}</p>
<p style="color:#888">AI分析をバックグラウンドで開始しました</p>
<script>setTimeout(() => window.close(), 1500)</script>
</body></html>
`);
} catch (e) {
res.status(500).send(`<h1>保存失敗: ${e.message}</h1>`);
}
});
// ========== 履歴機能 ==========
// POST /api/history/save - 軽量履歴保存AI分析なし
r.post('/history/save', authMiddleware, async (req, res) => {
const { url, title } = req.body || {};
if (!url) return res.status(400).json({ error: 'url is required' });
try {
const domain = new URL(url).hostname.replace(/^www\./, '');
await pool.query(`
INSERT INTO reading_history (user_id, url, title, domain, read_at)
VALUES ($1, $2, $3, $4, NOW())
ON CONFLICT (user_id, url)
DO UPDATE SET
title = EXCLUDED.title,
read_at = NOW()
`, [req.userId, url, title || '', domain]);
res.json({ ok: true, message: '履歴に保存しました' });
} catch (e) {
console.error('[History Save Error]:', e);
res.status(500).json({ error: 'Failed to save history' });
}
});
// GET /api/history - 履歴取得
r.get('/history', authMiddleware, async (req, res) => {
const limit = Math.min(parseInt(req.query.limit || '50'), 100);
try {
const result = await pool.query(`
SELECT url, title, domain, read_at
FROM reading_history
WHERE user_id = $1
ORDER BY read_at DESC
LIMIT $2
`, [req.userId, limit]);
res.json({
ok: true,
history: result.rows,
count: result.rows.length
});
} catch (e) {
console.error('[History Fetch Error]:', e);
res.status(500).json({ error: 'Failed to fetch history' });
}
});
// ── Journal API ──────────────────────────
// GET /journal/posts/public — 認証不要・published=true のみposimai-site 用)
// ?user=maita でユーザー指定可能(将来の独立サイト対応)
r.get('/journal/posts/public', async (req, res) => {
try {
const limit = Math.min(parseInt(req.query.limit || '50'), 100);
const userId = req.query.user || null;
const { rows } = userId
? await pool.query(
`SELECT id, title, body, tags, created_at, updated_at
FROM journal_posts WHERE published=TRUE AND user_id=$1
ORDER BY updated_at DESC LIMIT $2`,
[userId, limit]
)
: await pool.query(
`SELECT id, title, body, tags, created_at, updated_at
FROM journal_posts WHERE published=TRUE
ORDER BY updated_at DESC LIMIT $1`,
[limit]
);
res.json({ posts: rows });
} catch (e) { console.error(e); res.status(500).json({ error: 'DB error' }); }
});
// GET /journal/posts — 記事一覧(認証あり・全記事)
r.get('/journal/posts', authMiddleware, async (req, res) => {
try {
const { rows } = await pool.query(
'SELECT id, title, body, tags, published, created_at, updated_at FROM journal_posts WHERE user_id=$1 ORDER BY updated_at DESC',
[req.userId]
);
res.json({ posts: rows });
} catch (e) { console.error(e); res.status(500).json({ error: 'DB error' }); }
});
// POST /journal/posts — 新規作成 or 更新id があれば更新)
r.post('/journal/posts', authMiddleware, async (req, res) => {
const { id, title, body, tags, published } = req.body || {};
const tagArr = Array.isArray(tags) ? tags.map(String) : [];
const pub = published === true || published === 'true';
try {
if (id) {
const { rows } = await pool.query(
`UPDATE journal_posts SET title=$1, body=$2, tags=$3, published=$4, updated_at=NOW()
WHERE id=$5 AND user_id=$6
RETURNING id, title, body, tags, published, created_at, updated_at`,
[title || '', body || '', tagArr, pub, id, req.userId]
);
if (rows.length === 0) return res.status(404).json({ error: 'Not found' });
res.json({ post: rows[0] });
} else {
const { rows } = await pool.query(
`INSERT INTO journal_posts (user_id, title, body, tags, published)
VALUES ($1,$2,$3,$4,$5)
RETURNING id, title, body, tags, published, created_at, updated_at`,
[req.userId, title || '', body || '', tagArr, pub]
);
res.json({ post: rows[0] });
}
} catch (e) { console.error(e); res.status(500).json({ error: 'DB error' }); }
});
// DELETE /journal/posts/:id — 削除
r.delete('/journal/posts/:id', authMiddleware, async (req, res) => {
try {
await pool.query(
'DELETE FROM journal_posts WHERE id=$1 AND user_id=$2',
[req.params.id, req.userId]
);
res.json({ ok: true });
} catch (e) { console.error(e); res.status(500).json({ error: 'DB error' }); }
});
// ── Site Config API ───────────────────────
// GET /site/config/public — 認証不要・posimai-site 用
// ?user=maita でユーザー指定可能(将来の独立サイト対応)
r.get('/site/config/public', async (req, res) => {
try {
const userId = req.query.user || 'maita';
const { rows } = await pool.query(
'SELECT key, value FROM site_config WHERE user_id=$1',
[userId]
);
const config = {};
rows.forEach(row => { config[row.key] = row.value; });
res.json({ config });
} catch (e) { console.error(e); res.status(500).json({ error: 'DB error' }); }
});
// POST /site/config/:key — 認証あり・設定を保存(ユーザー別)
r.post('/site/config/:key', authMiddleware, async (req, res) => {
const { key } = req.params;
const { value } = req.body;
if (!key || value === undefined) return res.status(400).json({ error: 'value required' });
try {
await pool.query(
`INSERT INTO site_config (user_id, key, value, updated_at) VALUES ($1, $2, $3::jsonb, NOW())
ON CONFLICT (user_id, key) DO UPDATE SET value=$3::jsonb, updated_at=NOW()`,
[req.userId, key, JSON.stringify(value)]
);
res.json({ ok: true, key });
} catch (e) { console.error(e); res.status(500).json({ error: 'DB error' }); }
});
// POST /journal/upload — 画像アップロードbase64、認証あり
// POST /journal/suggest-tags — Gemini でタグ候補を提案
r.post('/journal/suggest-tags', authMiddleware, async (req, res) => {
if (!genAI) return res.status(503).json({ error: 'Gemini not configured' });
const { title = '', body = '' } = req.body || {};
if (!title && !body) return res.status(400).json({ error: 'title or body required' });
try {
const model = genAI.getGenerativeModel({
model: 'gemini-2.0-flash-lite',
generationConfig: { responseMimeType: 'application/json' }
});
const excerpt = smartExtract(body, 2000);
const prompt = `以下の記事にふさわしい日本語タグを3〜5個提案してください。
タグは短い単語または短いフレーズ: "Next.js", "インフラ", "開発メモ", "Tailscale"
既存のタグと重複してもOK結果はJSONで返してください
タイトル: ${title.slice(0, 80)}
本文抜粋:
${excerpt}
{"tags":["タグ1","タグ2","タグ3"]}`;
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('timeout')), 12000)
);
const result = await Promise.race([model.generateContent(prompt), timeoutPromise]);
let raw = result.response.text().trim()
.replace(/^```(json)?/i, '').replace(/```$/i, '').trim();
const parsed = JSON.parse(raw);
const tags = (parsed.tags || []).filter(t => typeof t === 'string' && t.length <= 30).slice(0, 5);
res.json({ tags });
} catch (e) {
console.error('[suggest-tags]', e.message);
res.status(500).json({ error: 'AI suggestion failed' });
}
});
r.post('/journal/upload', authMiddleware, (req, res) => {
try {
const { base64 } = req.body || {};
if (!base64) return res.status(400).json({ error: 'base64 required' });
const match = base64.match(/^data:(image\/(jpeg|png|gif|webp));base64,(.+)$/);
if (!match) return res.status(400).json({ error: 'Invalid image format' });
const ext = match[2] === 'jpeg' ? 'jpg' : match[2];
const buffer = Buffer.from(match[3], 'base64');
if (buffer.length > 5 * 1024 * 1024) {
return res.status(400).json({ error: 'File too large (max 5MB)' });
}
const name = crypto.randomBytes(10).toString('hex') + '.' + ext;
fs.writeFileSync(path.join(UPLOADS_DIR, name), buffer);
const url = `https://posimai-lab.tail72e846.ts.net/brain/api/uploads/${name}`;
res.json({ ok: true, url });
} catch (e) {
console.error('[Upload Error]:', e);
res.status(500).json({ error: 'Upload failed' });
}
});
// ── Habit API ─────────────────────────────
// GET /habit/habits — habit 一覧取得
r.get('/habit/habits', authMiddleware, async (req, res) => {
try {
const { rows } = await pool.query(
'SELECT id, name, icon, position FROM habit_habits WHERE user_id=$1 ORDER BY position, id',
[req.userId]
);
res.json({ habits: rows });
} catch (e) { console.error(e); res.status(500).json({ error: 'DB error' }); }
});
// POST /habit/habits — habit 作成
r.post('/habit/habits', authMiddleware, async (req, res) => {
const { name, icon = 'check', position = 0 } = req.body || {};
if (!name) return res.status(400).json({ error: 'name required' });
try {
const { rows } = await pool.query(
'INSERT INTO habit_habits (user_id, name, icon, position) VALUES ($1,$2,$3,$4) RETURNING id, name, icon, position',
[req.userId, name, icon, position]
);
res.json({ habit: rows[0] });
} catch (e) { console.error(e); res.status(500).json({ error: 'DB error' }); }
});
// PATCH /habit/habits/:id — habit 更新name / icon / position
r.patch('/habit/habits/:id', authMiddleware, async (req, res) => {
const { name, icon, position } = req.body || {};
try {
const sets = [];
const params = [];
let i = 1;
if (name !== undefined) { sets.push(`name=$${i++}`); params.push(name); }
if (icon !== undefined) { sets.push(`icon=$${i++}`); params.push(icon); }
if (position !== undefined) { sets.push(`position=$${i++}`); params.push(position); }
if (sets.length === 0) return res.status(400).json({ error: 'nothing to update' });
params.push(req.params.id, req.userId);
const { rows } = await pool.query(
`UPDATE habit_habits SET ${sets.join(',')} WHERE id=$${i++} AND user_id=$${i} RETURNING id, name, icon, position`,
params
);
if (rows.length === 0) return res.status(404).json({ error: 'Not found' });
res.json({ habit: rows[0] });
} catch (e) { console.error(e); res.status(500).json({ error: 'DB error' }); }
});
// DELETE /habit/habits/:id — habit 削除
r.delete('/habit/habits/:id', authMiddleware, async (req, res) => {
try {
await pool.query(
'DELETE FROM habit_habits WHERE id=$1 AND user_id=$2',
[req.params.id, req.userId]
);
res.json({ ok: true });
} catch (e) { console.error(e); res.status(500).json({ error: 'DB error' }); }
});
// GET /habit/log/:date — その日のチェック済み habit_id 一覧
r.get('/habit/log/:date', authMiddleware, async (req, res) => {
try {
const { rows } = await pool.query(
'SELECT habit_id FROM habit_log WHERE user_id=$1 AND log_date=$2',
[req.userId, req.params.date]
);
res.json({ checked: rows.map(r => r.habit_id) });
} catch (e) { console.error(e); res.status(500).json({ error: 'DB error' }); }
});
// POST /habit/log/:date — habit をトグルchecked=true で追加、false で削除)
r.post('/habit/log/:date', authMiddleware, async (req, res) => {
const { habitId, checked } = req.body || {};
if (!habitId) return res.status(400).json({ error: 'habitId required' });
try {
if (checked) {
await pool.query(
'INSERT INTO habit_log (user_id, habit_id, log_date) VALUES ($1,$2,$3) ON CONFLICT DO NOTHING',
[req.userId, habitId, req.params.date]
);
} else {
await pool.query(
'DELETE FROM habit_log WHERE user_id=$1 AND habit_id=$2 AND log_date=$3',
[req.userId, habitId, req.params.date]
);
}
res.json({ ok: true });
} catch (e) { console.error(e); res.status(500).json({ error: 'DB error' }); }
});
// GET /habit/heatmap — 過去 N 日分のチェック数(ヒートマップ用)
r.get('/habit/heatmap', authMiddleware, async (req, res) => {
const days = Math.min(parseInt(req.query.days || '90'), 365);
try {
const { rows } = await pool.query(`
SELECT log_date::text AS date, COUNT(*) AS count
FROM habit_log
WHERE user_id=$1 AND log_date >= CURRENT_DATE - ($2 || ' days')::INTERVAL
GROUP BY log_date ORDER BY log_date
`, [req.userId, days]);
res.json({ heatmap: rows });
} catch (e) { console.error(e); res.status(500).json({ error: 'DB error' }); }
});
// ── Pulse API ─────────────────────────────
// GET /pulse/log/:date — 特定日のデータ取得
r.get('/pulse/log/:date', authMiddleware, async (req, res) => {
try {
const { rows } = await pool.query(
'SELECT mood, energy, focus, note FROM pulse_log WHERE user_id=$1 AND log_date=$2',
[req.userId, req.params.date]
);
res.json({ entry: rows[0] || null });
} catch (e) { console.error(e); res.status(500).json({ error: 'DB error' }); }
});
// POST /pulse/log/:date — 記録UPSERT
r.post('/pulse/log/:date', authMiddleware, async (req, res) => {
const { mood, energy, focus, note = '' } = req.body || {};
if (!mood && !energy && !focus) return res.status(400).json({ error: 'at least one metric required' });
try {
const { rows } = await pool.query(`
INSERT INTO pulse_log (user_id, log_date, mood, energy, focus, note, updated_at)
VALUES ($1,$2,$3,$4,$5,$6,NOW())
ON CONFLICT (user_id, log_date) DO UPDATE
SET mood=$3, energy=$4, focus=$5, note=$6, updated_at=NOW()
RETURNING mood, energy, focus, note
`, [req.userId, req.params.date, mood || null, energy || null, focus || null, note]);
res.json({ entry: rows[0] });
} catch (e) { console.error(e); res.status(500).json({ error: 'DB error' }); }
});
// GET /pulse/log — 範囲取得デフォルト直近30日
r.get('/pulse/log', authMiddleware, async (req, res) => {
const days = Math.min(parseInt(req.query.days || '30'), 365);
try {
const { rows } = await pool.query(`
SELECT log_date::text AS date, mood, energy, focus, note
FROM pulse_log
WHERE user_id=$1 AND log_date >= CURRENT_DATE - ($2 || ' days')::INTERVAL
ORDER BY log_date
`, [req.userId, days]);
res.json({ entries: rows });
} catch (e) { console.error(e); res.status(500).json({ error: 'DB error' }); }
});
// ── Lens API ──────────────────────────────
// GET /lens/history — スキャン履歴取得(直近 limit 件)
r.get('/lens/history', authMiddleware, async (req, res) => {
const limit = Math.min(parseInt(req.query.limit || '20'), 100);
try {
const { rows } = await pool.query(
'SELECT id, filename, exif_data, thumbnail, scanned_at FROM lens_history WHERE user_id=$1 ORDER BY scanned_at DESC LIMIT $2',
[req.userId, limit]
);
res.json({ history: rows });
} catch (e) { console.error(e); res.status(500).json({ error: 'DB error' }); }
});
// POST /lens/history — スキャン結果を保存
r.post('/lens/history', authMiddleware, async (req, res) => {
const { filename, exif_data, thumbnail } = req.body || {};
if (!exif_data) return res.status(400).json({ error: 'exif_data required' });
try {
const { rows } = await pool.query(
'INSERT INTO lens_history (user_id, filename, exif_data, thumbnail) VALUES ($1,$2,$3,$4) RETURNING id, scanned_at',
[req.userId, filename || null, JSON.stringify(exif_data), thumbnail || null]
);
res.json({ ok: true, id: rows[0].id, scanned_at: rows[0].scanned_at });
} catch (e) { console.error(e); res.status(500).json({ error: 'DB error' }); }
});
// DELETE /lens/history/:id — 履歴削除
r.delete('/lens/history/:id', authMiddleware, async (req, res) => {
try {
await pool.query(
'DELETE FROM lens_history WHERE id=$1 AND user_id=$2',
[req.params.id, req.userId]
);
res.json({ ok: true });
} catch (e) { console.error(e); res.status(500).json({ error: 'DB error' }); }
});
// ── TTS (VOICEVOX) ─────────────────────────────────────────────
// VOICEVOX_URL: docker-compose.yml で設定(同ネットワーク内なら http://voicevox:50021
// 未設定時は localhost フォールバックNAS上で同じコンテナホストの場合
const VOICEVOX_URL = process.env.VOICEVOX_URL || 'http://voicevox:50021';
const ttsCache = new Map(); // key: "speaker:text" → Buffer
const TTS_CACHE_MAX = 60; // 約60文を最大キャッシュメモリ節約
let ttsBusy = false; // VOICEVOX は同時1リクエストのみ対応排他ロック
// 合成ヘルパー(/tts と /tts/warmup から共用)
async function ttsSynthesize(text, speaker) {
const queryRes = await fetch(
`${VOICEVOX_URL}/audio_query?text=${encodeURIComponent(text)}&speaker=${speaker}`,
{ method: 'POST' }
);
if (!queryRes.ok) throw new Error(`VOICEVOX audio_query failed: ${queryRes.status}`);
const query = await queryRes.json();
query.speedScale = 1.08;
query.intonationScale = 1.15;
query.prePhonemeLength = 0.05;
query.postPhonemeLength = 0.1;
const synthRes = await fetch(`${VOICEVOX_URL}/synthesis?speaker=${speaker}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(query)
});
if (!synthRes.ok) throw new Error(`VOICEVOX synthesis failed: ${synthRes.status}`);
return Buffer.from(await synthRes.arrayBuffer());
}
// POST /tts — テキストを音声WAVに変換して返す
r.post('/tts', authMiddleware, async (req, res) => {
const { text, speaker = 1 } = req.body || {};
if (!text || typeof text !== 'string') return res.status(400).json({ error: 'text required' });
if (text.length > 600) return res.status(400).json({ error: 'text too long (max 600 chars)' });
const cacheKey = `${speaker}:${text}`;
if (ttsCache.has(cacheKey)) {
const cached = ttsCache.get(cacheKey);
res.setHeader('Content-Type', 'audio/wav');
res.setHeader('Content-Length', cached.length);
res.setHeader('X-TTS-Cache', 'HIT');
return res.send(cached);
}
if (ttsBusy) return res.status(503).json({ error: 'TTS_BUSY' });
try {
ttsBusy = true;
const audioBuffer = await ttsSynthesize(text, speaker);
if (ttsCache.size >= TTS_CACHE_MAX) {
ttsCache.delete(ttsCache.keys().next().value);
}
ttsCache.set(cacheKey, audioBuffer);
res.setHeader('Content-Type', 'audio/wav');
res.setHeader('Content-Length', audioBuffer.length);
res.setHeader('X-TTS-Cache', 'MISS');
res.send(audioBuffer);
} catch (e) {
console.error('[TTS]', e.message);
res.status(503).json({ error: 'TTS_UNAVAILABLE', detail: e.message });
} finally {
ttsBusy = false;
}
});
// POST /tts/ready — 指定テキストがキャッシュ済みか確認(ポーリング用)
r.post('/tts/ready', authMiddleware, (req, res) => {
const { texts, speaker = 1 } = req.body || {};
if (!texts || !Array.isArray(texts)) return res.json({ cached: 0, total: 0, ready: false });
const total = texts.length;
const cached = texts.filter(t => ttsCache.has(`${speaker}:${t}`)).length;
res.json({ cached, total, ready: cached === total });
});
// POST /tts/warmup — バックグラウンドで事前合成してキャッシュを温める
// ブラウザが Feed 読み込み直後に呼び出す。即座に 202 を返し、VOICEVOX をバックグラウンドで実行。
r.post('/tts/warmup', authMiddleware, async (req, res) => {
const { texts, speaker = 1 } = req.body || {};
if (!texts || !Array.isArray(texts) || texts.length === 0) {
return res.status(400).json({ error: 'texts[] required' });
}
const valid = texts.filter(t => typeof t === 'string' && t.length > 0 && t.length <= 600);
res.status(202).json({ queued: valid.length }); // 即座に返す
// バックグラウンドでシリアル合成busy なら待機)
(async () => {
for (const text of valid) {
const cacheKey = `${speaker}:${text}`;
if (ttsCache.has(cacheKey)) continue;
while (ttsBusy) await new Promise(r => setTimeout(r, 500));
ttsBusy = true;
try {
const buf = await ttsSynthesize(text, speaker);
if (ttsCache.size >= TTS_CACHE_MAX) ttsCache.delete(ttsCache.keys().next().value);
ttsCache.set(cacheKey, buf);
console.log(`[TTS warmup] cached: ${text.substring(0, 30)}`);
} catch (e) {
console.error(`[TTS warmup] failed: ${e.message}`);
} finally {
ttsBusy = false;
}
}
console.log('[TTS warmup] all done');
})().catch(() => {});
});
// GET /tts/speakers — 利用可能な話者一覧(デバッグ用)
r.get('/tts/speakers', authMiddleware, async (req, res) => {
try {
const r2 = await fetch(`${VOICEVOX_URL}/speakers`);
const speakers = await r2.json();
res.json(speakers);
} catch (e) {
res.status(503).json({ error: 'TTS_UNAVAILABLE', detail: e.message });
}
});
// ── サーバー側自動プリウォーム ──────────────────────────────────
// 起動時 + 30分ごとに最新フィード記事の音声を VOICEVOX で合成してキャッシュ
// ユーザーが開いたときは既にキャッシュ済み → 即再生
async function preWarmFeedAudio() {
try {
console.log('[TTS pre-warm] fetching feed...');
const feedRes = await fetch('https://posimai-feed.vercel.app/api/feed', {
signal: AbortSignal.timeout(15000)
});
if (!feedRes.ok) throw new Error(`feed ${feedRes.status}`);
const data = await feedRes.json();
const articles = (data.articles || []).slice(0, 5);
if (articles.length === 0) return;
// ブラウザと同じロジックでテキスト生成
const texts = [];
articles.forEach((a, i) => {
const prefix = i === 0 ? '最初のニュースです。' : '続いて。';
const body = (a.title || '').substring(0, 60);
texts.push(`${prefix}${a.source || '不明なソース'}より。${body}`);
});
texts.push('本日のブリーフィングは以上です。');
const speaker = 1; // デフォルト: ずんだもん(明るい)
for (const text of texts) {
const cacheKey = `${speaker}:${text}`;
if (ttsCache.has(cacheKey)) {
console.log(`[TTS pre-warm] skip (cached): ${text.substring(0, 25)}`);
continue;
}
while (ttsBusy) await new Promise(r => setTimeout(r, 500));
ttsBusy = true;
try {
const buf = await ttsSynthesize(text, speaker);
if (ttsCache.size >= TTS_CACHE_MAX) ttsCache.delete(ttsCache.keys().next().value);
ttsCache.set(cacheKey, buf);
console.log(`[TTS pre-warm] OK: ${text.substring(0, 25)}`);
} catch (e) {
console.error(`[TTS pre-warm] synth failed: ${e.message}`);
} finally {
ttsBusy = false;
}
}
console.log('[TTS pre-warm] done');
} catch (e) {
console.error('[TTS pre-warm] error:', e.message);
}
}
// 起動直後に実行 + 30分ごとに更新記事が変わっても自動対応
preWarmFeedAudio();
setInterval(preWarmFeedAudio, 30 * 60 * 1000);
// ── IT イベント情報Doorkeeper + connpass RSS ──────────────────────────
r.get('/events/rss', async (req, res) => {
res.setHeader('Access-Control-Allow-Origin', '*');
try {
const [doorkeeper, connpassEvents] = await Promise.allSettled([
fetchDoorkeeper(),
fetchConnpassRss(),
]);
const events = [
...(doorkeeper.status === 'fulfilled' ? doorkeeper.value : []),
...(connpassEvents.status === 'fulfilled' ? connpassEvents.value : []),
];
// URLで重複排除
const seen = new Set();
const unique = events.filter(ev => {
if (seen.has(ev.url)) return false;
seen.add(ev.url);
return true;
});
// 開始日時順にソート
unique.sort((a, b) => (a.startDate + a.startTime).localeCompare(b.startDate + b.startTime));
res.json({ events: unique, fetched_at: new Date().toISOString() });
} catch (err) {
console.error('[events/rss]', err);
res.status(500).json({ events: [], error: err.message });
}
});
// ── Together ──────────────────────────────────────────────────────────────
// invite_code 生成8文字大文字16進
function genInviteCode() {
return crypto.randomBytes(4).toString('hex').toUpperCase();
}
// fire-and-forget アーカイブ: Jina Reader → Gemini 要約(直列)
async function archiveShare(shareId, url) {
if (!url) {
await pool.query(`UPDATE together_shares SET archive_status='failed' WHERE id=$1`, [shareId]);
return;
}
try {
const jinaRes = await fetch(`https://r.jina.ai/${url}`, {
headers: { 'Accept': 'text/plain', 'User-Agent': 'Posimai/1.0' },
signal: AbortSignal.timeout(30000),
});
if (!jinaRes.ok) throw new Error(`Jina ${jinaRes.status}`);
const fullContent = await jinaRes.text();
// Jina Reader のレスポンス先頭から "Title: ..." を抽出
const titleMatch = fullContent.match(/^Title:\s*(.+)/m);
const jinaTitle = titleMatch ? titleMatch[1].trim().slice(0, 300) : null;
await pool.query(
`UPDATE together_shares SET full_content=$1, title=COALESCE(title, $2) WHERE id=$3`,
[fullContent, jinaTitle, shareId]
);
let summary = null;
let tags = [];
if (genAI && fullContent) {
// 最初の ## 見出し以降を本文とみなし 4000 字を Gemini に渡す
const bodyStart = fullContent.search(/^#{1,2}\s/m);
const excerpt = (bodyStart >= 0 ? fullContent.slice(bodyStart) : fullContent).slice(0, 4000);
const model = genAITogether.getGenerativeModel({ model: 'gemini-2.5-flash' });
const prompt = `以下の記事を分析して、JSONのみを返してくださいコードブロック不要\n\n{"summary":"1〜2文の日本語要約","tags":["タグ1","タグ2","タグ3"]}\n\n- summary: 読者が読む価値があるかを判断できる1〜2文\n- tags: 内容を表す日本語タグを2〜4個例: AI, テクノロジー, ビジネス, 健康, 旅行, 料理, スポーツ, 政治, 経済, エンタメ, ゲーム, 科学, デザイン)\n\n記事:\n${excerpt}`;
const timeoutP = new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 30000));
const result = await Promise.race([model.generateContent(prompt), timeoutP]);
const raw = result.response.text().trim();
try {
const parsed = JSON.parse(raw);
summary = (parsed.summary || '').slice(0, 300);
tags = Array.isArray(parsed.tags) ? parsed.tags.slice(0, 4).map(t => String(t).slice(0, 20)) : [];
} catch {
// JSON パース失敗時は全文を要約として扱う
summary = raw.slice(0, 300);
}
}
await pool.query(
`UPDATE together_shares SET summary=$1, tags=$2, archive_status='done' WHERE id=$3`,
[summary, tags, shareId]
);
} catch (e) {
console.error('[together archive]', shareId, e.message);
await pool.query(`UPDATE together_shares SET archive_status='failed' WHERE id=$1`, [shareId]);
}
}
// POST /together/groups — グループ作成
r.post('/together/groups', async (req, res) => {
const { name, username } = req.body || {};
if (!name || !username) return res.status(400).json({ error: 'name と username は必須です' });
try {
let invite_code, attempt = 0;
while (attempt < 5) {
invite_code = genInviteCode();
const exists = await pool.query('SELECT id FROM together_groups WHERE invite_code=$1', [invite_code]);
if (exists.rows.length === 0) break;
attempt++;
}
const group = await pool.query(
`INSERT INTO together_groups (name, invite_code) VALUES ($1, $2) RETURNING *`,
[name, invite_code]
);
await pool.query(
`INSERT INTO together_members (group_id, username) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
[group.rows[0].id, username]
);
res.json({ group: group.rows[0] });
} catch (e) {
console.error('[together/groups POST]', e.message);
res.status(500).json({ error: 'グループ作成に失敗しました' });
}
});
// POST /together/join — 招待コードでグループ参加
r.post('/together/join', async (req, res) => {
const { invite_code, username } = req.body || {};
if (!invite_code || !username) return res.status(400).json({ error: 'invite_code と username は必須です' });
try {
const group = await pool.query(
'SELECT * FROM together_groups WHERE invite_code=$1',
[invite_code.toUpperCase()]
);
if (group.rows.length === 0) return res.status(404).json({ error: '招待コードが見つかりません' });
await pool.query(
`INSERT INTO together_members (group_id, username) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
[group.rows[0].id, username]
);
res.json({ group: group.rows[0] });
} catch (e) {
console.error('[together/join]', e.message);
res.status(500).json({ error: 'グループ参加に失敗しました' });
}
});
// GET /together/groups/:groupId — グループ情報
r.get('/together/groups/:groupId', async (req, res) => {
try {
const result = await pool.query('SELECT * FROM together_groups WHERE id=$1', [req.params.groupId]);
if (result.rows.length === 0) return res.status(404).json({ error: 'グループが見つかりません' });
res.json(result.rows[0]);
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// GET /together/members/:groupId — メンバー一覧
r.get('/together/members/:groupId', async (req, res) => {
try {
const result = await pool.query(
'SELECT username, joined_at FROM together_members WHERE group_id=$1 ORDER BY joined_at',
[req.params.groupId]
);
res.json(result.rows);
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// POST /together/share — 記事・テキストをシェア(即返却 + 非同期アーカイブ)
r.post('/together/share', async (req, res) => {
const { group_id, shared_by, url = null, title = null, message = '', tags = [] } = req.body || {};
if (!group_id || !shared_by) return res.status(400).json({ error: 'group_id と shared_by は必須です' });
if (url) {
try { const p = new URL(url); if (!['http:', 'https:'].includes(p.protocol)) throw new Error(); }
catch { return res.status(400).json({ error: 'url は http/https のみ有効です' }); }
}
try {
const grpCheck = await pool.query('SELECT id FROM together_groups WHERE id=$1', [group_id]);
if (grpCheck.rows.length === 0) return res.status(404).json({ error: 'グループが見つかりません' });
const result = await pool.query(
`INSERT INTO together_shares (group_id, shared_by, url, title, message, tags)
VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`,
[group_id, shared_by, url, title, message, tags]
);
const share = result.rows[0];
res.json({ ok: true, share });
// URL がある場合のみ非同期アーカイブ(ユーザーを待たせない)
if (url) archiveShare(share.id, url);
} catch (e) {
console.error('[together/share]', e.message);
res.status(500).json({ error: e.message });
}
});
// DELETE /together/share/:id — 自分の投稿を削除
r.delete('/together/share/:id', async (req, res) => {
const { username } = req.body || {};
if (!username) return res.status(400).json({ error: 'username は必須です' });
try {
const result = await pool.query(
'DELETE FROM together_shares WHERE id=$1 AND shared_by=$2 RETURNING id',
[req.params.id, username]
);
if (result.rows.length === 0) return res.status(403).json({ error: '削除できません' });
res.json({ ok: true });
} catch (e) {
console.error('[together/share DELETE]', e.message);
res.status(500).json({ error: e.message });
}
});
// GET /together/feed/:groupId — フィード(リアクション・コメント数付き)
r.get('/together/feed/:groupId', async (req, res) => {
try {
const result = await pool.query(`
SELECT
s.*,
COALESCE(
json_agg(DISTINCT jsonb_build_object('username', r.username, 'type', r.type))
FILTER (WHERE r.username IS NOT NULL), '[]'
) AS reactions,
COUNT(DISTINCT c.id)::int AS comment_count
FROM together_shares s
LEFT JOIN together_reactions r ON r.share_id = s.id
LEFT JOIN together_comments c ON c.share_id = s.id
WHERE s.group_id = $1
GROUP BY s.id
ORDER BY s.shared_at DESC
LIMIT 50
`, [req.params.groupId]);
res.json(result.rows);
} catch (e) {
console.error('[together/feed]', e.message);
res.status(500).json({ error: e.message });
}
});
// GET /together/article/:shareId — アーカイブ本文取得
r.get('/together/article/:shareId', async (req, res) => {
try {
const result = await pool.query(
'SELECT id, title, url, full_content, summary, archive_status, shared_at FROM together_shares WHERE id=$1',
[req.params.shareId]
);
if (result.rows.length === 0) return res.status(404).json({ error: '見つかりません' });
res.json(result.rows[0]);
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// POST /together/react — リアクション togglelike / star / fire
r.post('/together/react', async (req, res) => {
const { share_id, username, type = 'like' } = req.body || {};
if (!share_id || !username) return res.status(400).json({ error: 'share_id と username は必須です' });
if (!['like', 'star', 'fire'].includes(type)) return res.status(400).json({ error: 'type は like/star/fire のみ有効です' });
try {
const existing = await pool.query(
'SELECT 1 FROM together_reactions WHERE share_id=$1 AND username=$2 AND type=$3',
[share_id, username, type]
);
if (existing.rows.length > 0) {
await pool.query(
'DELETE FROM together_reactions WHERE share_id=$1 AND username=$2 AND type=$3',
[share_id, username, type]
);
res.json({ ok: true, action: 'removed' });
} else {
await pool.query(
'INSERT INTO together_reactions (share_id, username, type) VALUES ($1, $2, $3)',
[share_id, username, type]
);
res.json({ ok: true, action: 'added' });
}
} catch (e) {
console.error('[together/react]', e.message);
res.status(500).json({ error: e.message });
}
});
// GET /together/comments/:shareId — コメント一覧
r.get('/together/comments/:shareId', async (req, res) => {
try {
const result = await pool.query(
'SELECT * FROM together_comments WHERE share_id=$1 ORDER BY created_at',
[req.params.shareId]
);
res.json(result.rows);
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// POST /together/comments — コメント投稿
r.post('/together/comments', async (req, res) => {
const { share_id, username, body } = req.body || {};
if (!share_id || !username || !body?.trim()) {
return res.status(400).json({ error: 'share_id, username, body は必須です' });
}
try {
const result = await pool.query(
'INSERT INTO together_comments (share_id, username, body) VALUES ($1, $2, $3) RETURNING *',
[share_id, username, body.trim()]
);
res.json(result.rows[0]);
} catch (e) {
console.error('[together/comments POST]', e.message);
res.status(500).json({ error: e.message });
}
});
// GET /together/search/:groupId — キーワード / タグ検索
r.get('/together/search/:groupId', async (req, res) => {
const { q = '', tag = '' } = req.query;
if (!q && !tag) return res.status(400).json({ error: 'q または tag が必要です' });
try {
const keyword = q ? `%${q}%` : '';
const result = await pool.query(`
SELECT id, shared_by, url, title, message, tags, summary, archive_status, shared_at
FROM together_shares
WHERE group_id = $1
AND (
($2 != '' AND (title ILIKE $2 OR message ILIKE $2 OR full_content ILIKE $2))
OR ($3 != '' AND $3 = ANY(tags))
)
ORDER BY shared_at DESC
LIMIT 30
`, [req.params.groupId, keyword, tag]);
res.json(result.rows);
} catch (e) {
console.error('[together/search]', e.message);
res.status(500).json({ error: e.message });
}
});
return r;
}
// ─── Doorkeeper JSON API認証不要・CORS 問題なし) ─────────────────────────
async function fetchDoorkeeper() {
const url = 'https://api.doorkeeper.jp/events?locale=ja&per_page=50&sort=starts_at';
const res = await fetch(url, {
headers: { 'Accept': 'application/json', 'User-Agent': 'Posimai/1.0' },
signal: AbortSignal.timeout(8000),
});
if (!res.ok) throw new Error(`Doorkeeper ${res.status}`);
const data = await res.json();
return data.map((item) => {
const ev = item.event;
const start = new Date(ev.starts_at);
const end = new Date(ev.ends_at || ev.starts_at);
return {
id: `doorkeeper-${ev.id}`,
title: ev.title || '',
url: ev.url || '',
location: ev.venue_name || (ev.address ? ev.address.split(',')[0] : 'オンライン'),
address: ev.address || '',
startDate: evToDateStr(start),
endDate: evToDateStr(end),
startTime: evToTimeStr(start),
endTime: evToTimeStr(end),
category: 'IT イベント',
description: evStripHtml(ev.description || '').slice(0, 300),
source: ev.group || 'Doorkeeper',
isFree: false,
interestTags: evGuessInterestTags(ev.title + ' ' + (ev.description || '')),
audienceTags: evGuessAudienceTags(ev.title + ' ' + (ev.description || '')),
};
});
}
// ─── connpass Atom RSSXMLをregexでパース ──────────────────────────────────
async function fetchConnpassRss() {
const url = 'https://connpass.com/explore/ja.atom';
const res = await fetch(url, {
headers: { 'Accept': 'application/atom+xml', 'User-Agent': 'Posimai/1.0' },
signal: AbortSignal.timeout(8000),
});
if (!res.ok) throw new Error(`connpass RSS ${res.status}`);
const xml = await res.text();
const entries = [...xml.matchAll(/<entry>([\s\S]*?)<\/entry>/g)];
return entries.map((match, i) => {
const c = match[1];
const title = evExtractXml(c, 'title');
const url = /<link[^>]+href="([^"]+)"/.exec(c)?.[1] || '';
const updated = evExtractXml(c, 'updated');
const summary = evExtractXml(c, 'summary');
const author = evExtractXml(c, 'name');
const dt = updated ? new Date(updated) : new Date();
return {
id: `connpass-${i}-${evToDateStr(dt)}`,
title,
url,
location: 'connpass',
address: '',
startDate: evToDateStr(dt),
endDate: evToDateStr(dt),
startTime: evToTimeStr(dt),
endTime: evToTimeStr(dt),
category: 'IT イベント',
description: summary.slice(0, 300),
source: author || 'connpass',
isFree: false,
interestTags: evGuessInterestTags(title + ' ' + summary),
audienceTags: evGuessAudienceTags(title + ' ' + summary),
};
});
}
function evExtractXml(xml, tag) {
const m = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)<\\/${tag}>`).exec(xml);
if (!m) return '';
return m[1].replace(/<!\[CDATA\[|\]\]>/g, '')
.replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>')
.replace(/&quot;/g, '"').replace(/&#39;/g, "'").trim();
}
function evStripHtml(html) { return html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(); }
function evToDateStr(dt) { return dt.toISOString().slice(0, 10); }
function evToTimeStr(dt) {
const jst = new Date(dt.getTime() + 9 * 3600 * 1000);
return jst.toISOString().slice(11, 16);
}
function evGuessInterestTags(text) {
const tags = [];
if (/React|Vue|TypeScript|フロントエンド|Next\.js|Svelte/i.test(text)) tags.push('frontend');
if (/Go|Rust|Ruby|Python|PHP|バックエンド|API/i.test(text)) tags.push('backend');
if (/デザイン|UX|Figma|UI/i.test(text)) tags.push('design');
if (/AI|機械学習|LLM|GPT|Claude|Gemini/i.test(text)) tags.push('ai');
if (/インフラ|AWS|GCP|Azure|クラウド|Docker|Kubernetes/i.test(text)) tags.push('infra');
if (/iOS|Android|Flutter|React Native|モバイル/i.test(text)) tags.push('mobile');
if (/データ|分析|ML|データサイエンス/i.test(text)) tags.push('data');
if (/PM|プロダクト|プロダクトマネジメント/i.test(text)) tags.push('pm');
if (/初心者|入門|ビギナー/i.test(text)) tags.push('beginner');
return tags;
}
function evGuessAudienceTags(text) {
const tags = [];
if (/交流|ミートアップ|meetup/i.test(text)) tags.push('meetup');
if (/もくもく/i.test(text)) tags.push('mokumoku');
if (/セミナー|勉強会|study/i.test(text)) tags.push('seminar');
if (/ハンズオン|hands.?on/i.test(text)) tags.push('handson');
return tags;
}
// ── Uploads ───────────────────────────────
const UPLOADS_DIR = path.join(__dirname, 'uploads');
if (!fs.existsSync(UPLOADS_DIR)) fs.mkdirSync(UPLOADS_DIR, { recursive: true });
app.use('/brain/api/uploads', express.static(UPLOADS_DIR));
app.use('/api/uploads', express.static(UPLOADS_DIR));
// ── マウントTailscale経由と直接アクセスの両方対応
const router = buildRouter();
app.use('/brain/api', router); // Tailscale Funnel 経由
app.use('/api', router); // ローカル直接アクセス
// ── 起動 ──────────────────────────────────
const PORT = parseInt(process.env.PORT || '8090');
initDB()
.then(() => {
app.listen(PORT, '0.0.0.0', () => {
console.log(`\nPosimai Brain API`);
console.log(` Port: ${PORT}`);
console.log(` Gemini: ${genAI ? 'enabled' : 'disabled (no key)'}`);
console.log(` Users: ${Object.values(KEY_MAP).join(', ') || '(none - set API_KEYS)'}`);
console.log(` Local: http://localhost:${PORT}/api/health`);
console.log(` Public: https://posimai-lab.tail72e846.ts.net/brain/api/health`);
});
})
.catch(err => {
console.error('[FATAL] DB init failed:', err.message);
process.exit(1);
});