1705 lines
78 KiB
JavaScript
1705 lines
78 KiB
JavaScript
// ============================================
|
||
// 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') || '';
|
||
|
||
// 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分析に失敗しました。しばらく後にお試しください。',
|
||
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 スキーマは schema 配列の CREATE TABLE IF NOT EXISTS で管理
|
||
];
|
||
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') || 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') || 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') || 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=COALESCE($3, pulse_log.mood),
|
||
energy=COALESCE($4, pulse_log.energy),
|
||
focus=COALESCE($5, pulse_log.focus),
|
||
note=COALESCE(NULLIF($6,''), pulse_log.note),
|
||
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') || 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') || 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) => {
|
||
if (!/^[a-zA-Z0-9_-]+$/.test(req.params.groupId)) return res.status(400).json({ error: 'invalid groupId' });
|
||
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 — リアクション toggle(like / 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 RSS(XMLを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(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||
.replace(/"/g, '"').replace(/'/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);
|
||
});
|