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(