227 lines
9.3 KiB
JavaScript
227 lines
9.3 KiB
JavaScript
/**
|
||
* 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;
|