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

227 lines
9.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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;