posimai-root/docs/synology/synology-events-api-endpoin...

227 lines
9.3 KiB
JavaScript
Raw Normal View History

/**
* 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=<n8nからのPOSTに使うトークン>
* 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;