164 lines
6.8 KiB
JavaScript
164 lines
6.8 KiB
JavaScript
|
|
/**
|
|||
|
|
* 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;
|
|||
|
|
}
|