posimai-tech-events/server-endpoint.js

164 lines
6.8 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-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;
}