feat: RSS-based events (Doorkeeper + connpass), client-side tab filter, bump SW v4

This commit is contained in:
posimai 2026-03-16 18:08:55 +09:00
parent 8d1c254442
commit cb0263522a
3 changed files with 196 additions and 23 deletions

View File

@ -1150,38 +1150,48 @@
renderTimeline(); 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') { async function loadEvents(tabFilter = 'all') {
currentFilter = tabFilter;
document.getElementById('eventList').innerHTML = document.getElementById('eventList').innerHTML =
'<div class="loading-state"><div class="spinner"></div></div>'; '<div class="loading-state"><div class="spinner"></div></div>';
// 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 { try {
const fetchKeyword = queryKw || 'IT,エンジニア,デザイン,Web,AI,アプリ'; // RSSプロキシ経由Doorkeeper + connpass
// Call our Synology NAS proxy endpoint to bypass Connpass CloudFront WAF const response = await fetch(API_BASE + '/events/rss', {
const response = await fetch('https://posimai-lab.tail72e846.ts.net/brain/api/connpass?q=' + encodeURIComponent(fetchKeyword)); signal: AbortSignal.timeout(10000),
});
if (!response.ok) { if (!response.ok) throw new Error('RSS fetch failed: ' + response.status);
throw new Error('API request failed');
}
const data = await response.json(); 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(); renderTimeline();
} catch (e) { } catch (e) {
console.error("[Events] Load error:", e); console.error('[Events] Load error:', e);
// エラー時フォールバック(キャッシュ) // フォールバック: キャッシュから全件取得してクライアントフィルタ
const cached = localStorage.getItem(`events-cache-${tabFilter}`); const cached = localStorage.getItem('events-cache');
allEvents = cached ? JSON.parse(cached) : []; const all = cached ? JSON.parse(cached) : [];
allEvents = all.filter(ev => matchesTab(ev, tabFilter));
renderTimeline(); renderTimeline();
} }
} }

163
server-endpoint.js Normal file
View File

@ -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 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;
}

4
sw.js
View File

@ -1,5 +1,5 @@
const CACHE_NAME = 'posimai-events-v3'; const CACHE_NAME = 'posimai-tech-events-v4';
const STATIC_ASSETS = ['/?v=3', '/index.html?v=3', '/manifest.json', '/logo.png']; const STATIC_ASSETS = ['/', '/index.html', '/manifest.json', '/logo.png'];
self.addEventListener('install', event => { self.addEventListener('install', event => {
event.waitUntil( event.waitUntil(