posimai-tech-events/server-endpoint.js

164 lines
6.8 KiB
JavaScript
Raw Normal View History

/**
* posimai-tech-events server.js に追加するエンドポイント
*
* 使い方:
* このファイルの内容を server.js の既存ルートの末尾あたりにコピーする
* Doorkeeper (JSON API) + connpass Atom RSS を取得して正規化して返す
* 追加 npm パッケージは不要Node.js 18+ 組み込みの fetch を使用
*
* デプロイ:
* docker cp server.js posimai_api:/app/server.js && docker restart posimai_api
*/
// ─── /events/rss GET ──────────────────────────────────────────────────────────
app.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 });
}
});
// ─── 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, i) => {
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: toDateStr(start),
endDate: toDateStr(end),
startTime: toTimeStr(start),
endTime: toTimeStr(end),
category: 'IT イベント',
description: stripHtml(ev.description || '').slice(0, 300),
source: ev.group || 'Doorkeeper',
isFree: false,
interestTags: guessInterestTags(ev.title + ' ' + (ev.description || '')),
audienceTags: guessAudienceTags(ev.title + ' ' + (ev.description || '')),
};
});
}
// ─── connpass Atom RSSXMLを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 = extractXml(c, 'title');
const url = /<link[^>]+href="([^"]+)"/.exec(c)?.[1] || '';
const updated = extractXml(c, 'updated');
const summary = extractXml(c, 'summary');
const author = extractXml(c, 'name');
const dt = updated ? new Date(updated) : new Date();
return {
id: `connpass-${i}-${toDateStr(dt)}`,
title,
url,
location: 'connpass',
address: '',
startDate: toDateStr(dt),
endDate: toDateStr(dt),
startTime: toTimeStr(dt),
endTime: toTimeStr(dt),
category: 'IT イベント',
description: summary.slice(0, 300),
source: author || 'connpass',
isFree: false,
interestTags: guessInterestTags(title + ' ' + summary),
audienceTags: guessAudienceTags(title + ' ' + summary),
};
});
}
// ─── ユーティリティ ────────────────────────────────────────────────────────────
function extractXml(xml, tag) {
const m = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)<\\/${tag}>`).exec(xml);
if (!m) return '';
return m[1].replace(/<!\[CDATA\[|\]\]>/g, '')
.replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>')
.replace(/&quot;/g, '"').replace(/&#39;/g, "'").trim();
}
function stripHtml(html) {
return html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
}
function toDateStr(dt) {
return dt.toISOString().slice(0, 10);
}
function toTimeStr(dt) {
// JST (+9h)
const jst = new Date(dt.getTime() + 9 * 3600 * 1000);
return jst.toISOString().slice(11, 16);
}
function guessInterestTags(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 guessAudienceTags(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;
}