feat: RSS-based events (Doorkeeper + connpass), client-side tab filter, bump SW v4
This commit is contained in:
parent
8d1c254442
commit
cb0263522a
52
index.html
52
index.html
|
|
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(/<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(/&/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;
|
||||||
|
}
|
||||||
4
sw.js
4
sw.js
|
|
@ -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(
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue