diff --git a/index.html b/index.html index 5a9352c..8d408fc 100644 --- a/index.html +++ b/index.html @@ -1150,38 +1150,48 @@ renderTimeline(); }; - // ---- データ読み込み (API経由) ---- + // ---- タブフィルター キーワード ---- + const TAB_KEYWORDS = { + frontend: ['フロントエンド', 'React', 'Vue', 'TypeScript', 'Next.js', 'Svelte'], + backend: ['バックエンド', 'Go', 'Rust', 'Ruby', 'Python', 'PHP', 'API'], + design: ['デザイン', 'UX', 'UI', 'Figma'], + ai: ['AI', '機械学習', 'LLM', 'GPT', 'Claude', 'Gemini'], + }; + + function matchesTab(ev, tab) { + if (tab === 'all') return true; + const kws = TAB_KEYWORDS[tab] || []; + const haystack = (ev.title + ' ' + ev.description + ' ' + (ev.interestTags || []).join(' ')).toLowerCase(); + return kws.some(kw => haystack.includes(kw.toLowerCase())); + } + + // ---- データ読み込み (RSS経由) ---- + const API_BASE = 'https://posimai-lab.tail72e846.ts.net/brain/api'; + async function loadEvents(tabFilter = 'all') { + currentFilter = tabFilter; document.getElementById('eventList').innerHTML = '
'; - // Roleベースでのキーワード判定 - let queryKw = ''; - if (tabFilter === 'frontend') queryKw = 'フロントエンド,React,Vue,TypeScript'; - else if (tabFilter === 'backend') queryKw = 'バックエンド,Go,Rust,Ruby,Python,PHP'; - else if (tabFilter === 'design') queryKw = 'UI/UX,デザイン,Figma'; - else if (tabFilter === 'ai') queryKw = 'AI,機械学習,LLM,Python'; - try { - const fetchKeyword = queryKw || 'IT,エンジニア,デザイン,Web,AI,アプリ'; - // Call our Synology NAS proxy endpoint to bypass Connpass CloudFront WAF - const response = await fetch('https://posimai-lab.tail72e846.ts.net/brain/api/connpass?q=' + encodeURIComponent(fetchKeyword)); - - if (!response.ok) { - throw new Error('API request failed'); - } + // RSSプロキシ経由(Doorkeeper + connpass) + const response = await fetch(API_BASE + '/events/rss', { + signal: AbortSignal.timeout(10000), + }); + if (!response.ok) throw new Error('RSS fetch failed: ' + response.status); const data = await response.json(); - allEvents = data.events || []; + allEvents = (data.events || []).filter(ev => matchesTab(ev, tabFilter)); - localStorage.setItem(`events-cache-${tabFilter}`, JSON.stringify(allEvents)); + localStorage.setItem('events-cache', JSON.stringify(data.events || [])); renderTimeline(); } catch (e) { - console.error("[Events] Load error:", e); - // エラー時フォールバック(キャッシュ) - const cached = localStorage.getItem(`events-cache-${tabFilter}`); - allEvents = cached ? JSON.parse(cached) : []; + console.error('[Events] Load error:', e); + // フォールバック: キャッシュから全件取得してクライアントフィルタ + const cached = localStorage.getItem('events-cache'); + const all = cached ? JSON.parse(cached) : []; + allEvents = all.filter(ev => matchesTab(ev, tabFilter)); renderTimeline(); } } diff --git a/server-endpoint.js b/server-endpoint.js new file mode 100644 index 0000000..23dd6ce --- /dev/null +++ b/server-endpoint.js @@ -0,0 +1,163 @@ +/** + * 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; +} diff --git a/sw.js b/sw.js index 7fcce37..8638c66 100644 --- a/sw.js +++ b/sw.js @@ -1,5 +1,5 @@ -const CACHE_NAME = 'posimai-events-v3'; -const STATIC_ASSETS = ['/?v=3', '/index.html?v=3', '/manifest.json', '/logo.png']; +const CACHE_NAME = 'posimai-tech-events-v4'; +const STATIC_ASSETS = ['/', '/index.html', '/manifest.json', '/logo.png']; self.addEventListener('install', event => { event.waitUntil(