/** * 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 RSS(XMLを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(/([\s\S]*?)<\/entry>/g)]; return entries.map((match, i) => { const c = match[1]; const title = extractXml(c, 'title'); const url = /]+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(//g, '') .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') .replace(/"/g, '"').replace(/'/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; }