/** * Posimai Events - Synology API エンドポイント * * Brain の server.js に追記する形で統合する。 * または /events パス以下を別サービスとして立ち上げる。 * * エンドポイント: * GET /events/api/events イベント一覧取得(フィルター対応) * POST /events/api/events n8n からのイベントデータ受信・保存 * POST /events/api/events/batch n8n から複数イベントを一括登録 * DELETE /events/api/events/:id イベント削除(管理用) * * 環境変数(Brain の .env と同じファイルに追記): * EVENTS_N8N_TOKEN= * GEMINI_API_KEY=<既存のBrain用を流用> * DATABASE_URL=<既存のBrain用を流用> */ const { Pool } = require('pg'); const { GoogleGenerativeAI } = require('@google/generative-ai'); const pool = new Pool({ connectionString: process.env.DATABASE_URL }); const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY); // n8n 認証ミドルウェア function n8nAuth(req, res, next) { const token = process.env.EVENTS_N8N_TOKEN; if (!token) return next(); // トークン未設定時はスキップ(開発環境) const auth = req.headers['authorization']; if (auth !== `Bearer ${token}`) { return res.status(401).json({ error: 'Unauthorized' }); } next(); } // ---- GET /events/api/events ---- // クエリパラメータ: // from=YYYY-MM-DD 開始日以降(デフォルト: 今日から7日前) // to=YYYY-MM-DD 終了日まで // interests=sake,food カンマ区切り // audience=couple,family // free=true // limit=50 (max 100) async function getEvents(req, res) { try { const today = new Date().toISOString().slice(0,10); const from = req.query.from || today; const to = req.query.to; const interests = req.query.interests ? req.query.interests.split(',').map(s=>s.trim()) : []; const audience = req.query.audience ? req.query.audience.split(',').map(s=>s.trim()) : []; const isFree = req.query.free === 'true'; const limit = Math.min(parseInt(req.query.limit) || 50, 100); let params = [from, limit]; let where = ['e.end_date >= $1']; let idx = 3; if (to) { where.push(`e.start_date <= $${idx}`); params.splice(idx-1,0,to); idx++; } if (interests.length) { where.push(`e.interest_tags && $${idx}::text[]`); params.splice(idx-1,0,interests); idx++; } if (audience.length) { where.push(`e.audience_tags && $${idx}::text[]`); params.splice(idx-1,0,audience); idx++; } if (isFree) { where.push('e.is_free = TRUE'); } const sql = ` SELECT e.event_id AS id, e.title, e.start_date AS "startDate", e.start_time AS "startTime", e.end_date AS "endDate", e.end_time AS "endTime", e.location, e.address, e.description, e.category, e.url, e.source, e.interest_tags AS "interestTags", e.audience_tags AS "audienceTags", e.is_free AS "isFree", e.no_rsvp AS "noRsvp", e.is_outdoor AS "isOutdoor", e.scraped_at AS "scrapedAt" FROM events e WHERE ${where.join(' AND ')} ORDER BY e.start_date ASC, e.start_time ASC LIMIT $2 `; const result = await pool.query(sql, params); const events = result.rows.map(row => ({ ...row, startDate: row.startDate?.toISOString?.()?.slice(0,10) || row.startDate, endDate: row.endDate?.toISOString?.()?.slice(0,10) || row.endDate, startTime: row.startTime?.slice?.(0,5) || row.startTime, endTime: row.endTime?.slice?.(0,5) || row.endTime, })); res.json({ events, updatedAt: new Date().toISOString(), count: events.length }); } catch (err) { console.error('GET /events error:', err); res.status(500).json({ error: 'Internal server error' }); } } // ---- POST /events/api/events ---- // n8n から1件ずつ受け取る場合 // BodyにはJinaReader + Geminiが生成したイベントJSONを渡す async function createEvent(req, res) { const body = req.body; if (!body?.title || !body?.startDate) { return res.status(400).json({ error: 'title and startDate are required' }); } try { // Gemini でタグ自動付与(まだ付いていない場合) let interestTags = body.interestTags || []; let audienceTags = body.audienceTags || []; if (!interestTags.length && body.description) { const tagged = await autoTagEvent(body); interestTags = tagged.interestTags; audienceTags = tagged.audienceTags; } const eventId = body.id || `${body.source?.replace(/\s+/g,'-')}-${body.startDate}-${Date.now()}`; await pool.query(` INSERT INTO events (event_id, title, start_date, start_time, end_date, end_time, location, address, description, category, url, source, interest_tags, audience_tags, is_free, no_rsvp, is_outdoor, scraped_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,NOW()) ON CONFLICT (event_id) DO UPDATE SET title = EXCLUDED.title, description = EXCLUDED.description, interest_tags = EXCLUDED.interest_tags, audience_tags = EXCLUDED.audience_tags, scraped_at = NOW(), updated_at = NOW() `, [ eventId, body.title, body.startDate, body.startTime || null, body.endDate || body.startDate, body.endTime || null, body.location || '', body.address || '', body.description || '', body.category || '', body.url || '', body.source || '', interestTags, audienceTags, body.isFree || false, body.noRsvp || false, body.isOutdoor || false ]); res.json({ ok: true, eventId }); } catch (err) { console.error('POST /events error:', err); res.status(500).json({ error: 'Internal server error' }); } } // ---- POST /events/api/events/batch ---- // n8n から複数件を一括登録する場合 async function createEventsBatch(req, res) { const { events } = req.body; if (!Array.isArray(events) || !events.length) { return res.status(400).json({ error: 'events array is required' }); } let saved = 0, errors = 0; for (const ev of events) { try { const fakeReq = { body: ev }; const fakeRes = { json: () => { saved++; }, status: (c) => ({ json: () => { errors++; } }) }; await createEvent(fakeReq, fakeRes); } catch { errors++; } } res.json({ ok: true, saved, errors }); } // ---- Gemini タグ自動付与 ---- const INTEREST_IDS = ['sake','beer','wine','food','market','music','art','craft','outdoor','sports','culture','film','learn','volunteer','kids']; const AUDIENCE_IDS = ['couple','family','solo','senior','pet','foreign']; async function autoTagEvent(ev) { try { const model = genAI.getGenerativeModel({ model: 'gemini-1.5-flash' }); const prompt = ` 以下のイベント情報に、最も適切なタグを付与してください。 イベント名: ${ev.title} 説明: ${ev.description} カテゴリ: ${ev.category || '不明'} 【興味タグ候補】(複数選択可): sake=日本酒, beer=クラフトビール, wine=ワイン/お酒, food=グルメ/食, market=マルシェ/市場, music=音楽/ライブ, art=アート/展示, craft=クラフト/手工芸, outdoor=アウトドア/自然, sports=スポーツ/体験, culture=伝統/文化, film=映画/演劇, learn=学び/セミナー, volunteer=ボランティア, kids=子ども向け 【対象者タグ候補】(複数選択可): couple=カップル向け, family=ファミリー歓迎, solo=ソロ歓迎, senior=シニア向け, pet=ペット同伴OK, foreign=英語対応 JSONのみ返答: {"interestTags":["tag1","tag2"],"audienceTags":["tag1"]}`; const result = await model.generateContent(prompt); const text = result.response.text().trim(); const json = JSON.parse(text.match(/\{[\s\S]*\}/)[0]); return { interestTags: (json.interestTags || []).filter(t => INTEREST_IDS.includes(t)), audienceTags: (json.audienceTags || []).filter(t => AUDIENCE_IDS.includes(t)), }; } catch { return { interestTags: [], audienceTags: [] }; } } // ---- ルーター統合(server.js に追記する形)---- // 既存の Brain server.js の末尾に以下を追加: // // const eventsRouter = require('./events-api'); // app.use('/events/api', eventsRouter); // // または Router として export する場合: const express = require('express'); const router = express.Router(); router.get('/events', getEvents); router.post('/events', n8nAuth, createEvent); router.post('/events/batch', n8nAuth, createEventsBatch); router.delete('/events/:id', n8nAuth, async (req, res) => { await pool.query('DELETE FROM events WHERE event_id = $1', [req.params.id]); res.json({ ok: true }); }); module.exports = router;