2026-03-17 08:19:20 +00:00
// ============================================
// 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 ;
2026-03-17 15:07:40 +00:00
// Together 専用インスタンス(メインキーを共用)
const genAITogether = genAI ;
2026-03-17 08:19:20 +00:00
// ── 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 {
2026-03-20 14:01:18 +00:00
summary : 'AI分析に失敗しました。しばらく後にお試しください。' ,
2026-03-17 08:19:20 +00:00
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) ` ,
2026-03-20 14:01:18 +00:00
// together スキーマは schema 配列の CREATE TABLE IF NOT EXISTS で管理
2026-03-17 08:19:20 +00:00
] ;
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 ) ;
2026-03-17 09:10:55 +00:00
// 重要: contentが空の場合、Jina Reader APIで本文を自動取得
2026-03-17 08:19:20 +00:00
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 > 保存完了 < / t i t l e > < / h e a d >
< body style = "font-family:sans-serif;padding:40px;text-align:center;background:#0a0a0a;color:#e2e2e2" >
< h1 style = "color:#818CF8" > ✓ 保存しました < / h 1 >
< p > $ { meta . title } < / p >
< p style = "color:#888" > AI分析をバックグラウンドで開始しました < / p >
< script > setTimeout ( ( ) => window . close ( ) , 1500 ) < / s c r i p t >
< / b o d y > < / h t m l >
` );
} 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 ) => {
2026-03-20 14:01:18 +00:00
const limit = Math . min ( parseInt ( req . query . limit || '50' ) || 50 , 100 ) ;
2026-03-17 08:19:20 +00:00
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 {
2026-03-20 14:01:18 +00:00
const limit = Math . min ( parseInt ( req . query . limit || '50' ) || 50 , 100 ) ;
2026-03-17 08:19:20 +00:00
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 ) => {
2026-03-20 14:01:18 +00:00
const days = Math . min ( parseInt ( req . query . days || '90' ) || 90 , 365 ) ;
2026-03-17 08:19:20 +00:00
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
2026-03-20 14:01:18 +00:00
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 ( )
2026-03-17 08:19:20 +00:00
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 ) => {
2026-03-20 14:01:18 +00:00
const days = Math . min ( parseInt ( req . query . days || '30' ) || 30 , 365 ) ;
2026-03-17 08:19:20 +00:00
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 ) => {
2026-03-20 14:01:18 +00:00
const limit = Math . min ( parseInt ( req . query . limit || '20' ) || 20 , 100 ) ;
2026-03-17 08:19:20 +00:00
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' } ,
2026-03-17 15:07:40 +00:00
signal : AbortSignal . timeout ( 30000 ) ,
2026-03-17 08:19:20 +00:00
} ) ;
if ( ! jinaRes . ok ) throw new Error ( ` Jina ${ jinaRes . status } ` ) ;
const fullContent = await jinaRes . text ( ) ;
2026-03-17 15:07:40 +00:00
// Jina Reader のレスポンス先頭から "Title: ..." を抽出
const titleMatch = fullContent . match ( /^Title:\s*(.+)/m ) ;
const jinaTitle = titleMatch ? titleMatch [ 1 ] . trim ( ) . slice ( 0 , 300 ) : null ;
2026-03-17 08:19:20 +00:00
await pool . query (
2026-03-17 15:07:40 +00:00
` UPDATE together_shares SET full_content= $ 1, title=COALESCE(title, $ 2) WHERE id= $ 3 ` ,
[ fullContent , jinaTitle , shareId ]
2026-03-17 08:19:20 +00:00
) ;
let summary = null ;
2026-03-17 15:07:40 +00:00
let tags = [ ] ;
2026-03-17 08:19:20 +00:00
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' } ) ;
2026-03-17 15:07:40 +00:00
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 ) ) ;
2026-03-17 08:19:20 +00:00
const result = await Promise . race ( [ model . generateContent ( prompt ) , timeoutP ] ) ;
2026-03-17 15:07:40 +00:00
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 ) ;
}
2026-03-17 08:19:20 +00:00
}
await pool . query (
2026-03-17 15:07:40 +00:00
` UPDATE together_shares SET summary= $ 1, tags= $ 2, archive_status='done' WHERE id= $ 3 ` ,
[ summary , tags , shareId ]
2026-03-17 08:19:20 +00:00
) ;
} 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 ) => {
2026-03-20 14:01:18 +00:00
if ( ! /^[a-zA-Z0-9_-]+$/ . test ( req . params . groupId ) ) return res . status ( 400 ) . json ( { error : 'invalid groupId' } ) ;
2026-03-17 08:19:20 +00:00
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 は必須です' } ) ;
2026-03-17 08:26:59 +00:00
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 のみ有効です' } ) ; }
}
2026-03-17 08:19:20 +00:00
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 } ) ;
}
} ) ;
2026-03-17 15:07:40 +00:00
// 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 } ) ;
}
} ) ;
2026-03-17 08:19:20 +00:00
// 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 は必須です' } ) ;
2026-03-17 08:26:59 +00:00
if ( ! [ 'like' , 'star' , 'fire' ] . includes ( type ) ) return res . status ( 400 ) . json ( { error : 'type は like/star/fire のみ有効です' } ) ;
2026-03-17 08:19:20 +00:00
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' , ( ) => {
2026-03-17 09:10:55 +00:00
console . log ( ` \n Posimai Brain API ` ) ;
2026-03-17 08:19:20 +00:00
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 ) ;
} ) ;