diff --git a/server.js b/server.js index 83b5cc5..71d2105 100644 --- a/server.js +++ b/server.js @@ -790,53 +790,39 @@ app.get('/api/qrz/lookup/:callsign', async (req, res) => { // ============================================ app.get('/api/contests', async (req, res) => { - console.log('[Contests] Fetching contest calendar...'); - - // Try WA7BNM Contest Calendar API + // Try WA7BNM Contest Calendar RSS feed try { - const response = await fetch('https://www.contestcalendar.com/contestcal.json', { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 10000); + + const response = await fetch('https://www.contestcalendar.com/calendar.rss', { headers: { 'User-Agent': 'OpenHamClock/3.3', - 'Accept': 'application/json' - } + 'Accept': 'application/rss+xml, application/xml, text/xml' + }, + signal: controller.signal }); + clearTimeout(timeout); if (response.ok) { - const data = await response.json(); - console.log('[Contests] WA7BNM returned', data.length, 'contests'); - - const now = new Date(); - const contests = data - .filter(c => new Date(c.end) > now) // Only future/active - .slice(0, 20) - .map(c => { - const startDate = new Date(c.start); - const endDate = new Date(c.end); - let status = 'upcoming'; - if (now >= startDate && now <= endDate) { - status = 'active'; - } - - return { - name: c.name || c.contest, - start: startDate.toISOString(), - end: endDate.toISOString(), - mode: c.mode || 'Mixed', - status: status, - url: c.url || null - }; - }); + const text = await response.text(); + const contests = parseContestRSS(text); - return res.json(contests); + if (contests.length > 0) { + console.log('[Contests] WA7BNM RSS:', contests.length, 'contests'); + return res.json(contests); + } } } catch (error) { - console.error('[Contests] WA7BNM error:', error.message); + if (error.name !== 'AbortError') { + console.error('[Contests] RSS error:', error.message); + } } - // Fallback: Calculate known recurring contests + // Fallback: Use calculated contests try { const contests = calculateUpcomingContests(); - console.log('[Contests] Using calculated contests:', contests.length); + console.log('[Contests] Using calculated:', contests.length, 'contests'); return res.json(contests); } catch (error) { console.error('[Contests] Calculation error:', error.message); @@ -845,6 +831,129 @@ app.get('/api/contests', async (req, res) => { res.json([]); }); +// Parse WA7BNM RSS feed +function parseContestRSS(xml) { + const contests = []; + const now = new Date(); + const currentYear = now.getFullYear(); + + // Simple regex-based XML parsing (no external dependencies) + const itemRegex = /([\s\S]*?)<\/item>/g; + const titleRegex = /([^<]+)<\/title>/; + const linkRegex = /<link>([^<]+)<\/link>/; + const descRegex = /<description>([^<]+)<\/description>/; + + let match; + while ((match = itemRegex.exec(xml)) !== null) { + const item = match[1]; + + const titleMatch = item.match(titleRegex); + const linkMatch = item.match(linkRegex); + const descMatch = item.match(descRegex); + + if (titleMatch && descMatch) { + const name = titleMatch[1].trim(); + const desc = descMatch[1].trim(); + const url = linkMatch ? linkMatch[1].trim() : null; + + // Parse description like "1300Z, Jan 31 to 1300Z, Feb 1" or "0000Z-2359Z, Jan 31" + const parsed = parseContestDateTime(desc, currentYear); + + if (parsed) { + const status = (now >= parsed.start && now <= parsed.end) ? 'active' : 'upcoming'; + + // Try to detect mode from contest name + let mode = 'Mixed'; + const nameLower = name.toLowerCase(); + if (nameLower.includes('cw') || nameLower.includes('morse')) mode = 'CW'; + else if (nameLower.includes('ssb') || nameLower.includes('phone') || nameLower.includes('sideband')) mode = 'SSB'; + else if (nameLower.includes('rtty')) mode = 'RTTY'; + else if (nameLower.includes('ft4') || nameLower.includes('ft8') || nameLower.includes('digi')) mode = 'Digital'; + else if (nameLower.includes('vhf') || nameLower.includes('uhf')) mode = 'VHF'; + + contests.push({ + name, + start: parsed.start.toISOString(), + end: parsed.end.toISOString(), + mode, + status, + url + }); + } + } + } + + // Sort by start date and limit + contests.sort((a, b) => new Date(a.start) - new Date(b.start)); + return contests.slice(0, 20); +} + +// Parse contest date/time strings +function parseContestDateTime(desc, year) { + try { + const months = { 'jan': 0, 'feb': 1, 'mar': 2, 'apr': 3, 'may': 4, 'jun': 5, + 'jul': 6, 'aug': 7, 'sep': 8, 'oct': 9, 'nov': 10, 'dec': 11 }; + + // Pattern 1: "1300Z, Jan 31 to 1300Z, Feb 1" + const rangeMatch = desc.match(/(\d{4})Z,\s*(\w+)\s+(\d+)\s+to\s+(\d{4})Z,\s*(\w+)\s+(\d+)/i); + if (rangeMatch) { + const [, startTime, startMon, startDay, endTime, endMon, endDay] = rangeMatch; + const startMonth = months[startMon.toLowerCase()]; + const endMonth = months[endMon.toLowerCase()]; + + let startYear = year; + let endYear = year; + // Handle year rollover + if (startMonth > 10 && endMonth < 2) endYear = year + 1; + + const start = new Date(Date.UTC(startYear, startMonth, parseInt(startDay), + parseInt(startTime.substring(0, 2)), parseInt(startTime.substring(2, 4)))); + const end = new Date(Date.UTC(endYear, endMonth, parseInt(endDay), + parseInt(endTime.substring(0, 2)), parseInt(endTime.substring(2, 4)))); + + return { start, end }; + } + + // Pattern 2: "0000Z-2359Z, Jan 31" (same day) + const sameDayMatch = desc.match(/(\d{4})Z-(\d{4})Z,\s*(\w+)\s+(\d+)/i); + if (sameDayMatch) { + const [, startTime, endTime, mon, day] = sameDayMatch; + const month = months[mon.toLowerCase()]; + + const start = new Date(Date.UTC(year, month, parseInt(day), + parseInt(startTime.substring(0, 2)), parseInt(startTime.substring(2, 4)))); + const end = new Date(Date.UTC(year, month, parseInt(day), + parseInt(endTime.substring(0, 2)), parseInt(endTime.substring(2, 4)))); + + // Handle overnight contests (end time < start time means next day) + if (end <= start) end.setUTCDate(end.getUTCDate() + 1); + + return { start, end }; + } + + // Pattern 3: "0000Z-0100Z, Feb 5 and 0200Z-0300Z, Feb 6" (multiple sessions - use first) + const multiMatch = desc.match(/(\d{4})Z-(\d{4})Z,\s*(\w+)\s+(\d+)/i); + if (multiMatch) { + const [, startTime, endTime, mon, day] = multiMatch; + const month = months[mon.toLowerCase()]; + + const start = new Date(Date.UTC(year, month, parseInt(day), + parseInt(startTime.substring(0, 2)), parseInt(startTime.substring(2, 4)))); + const end = new Date(Date.UTC(year, month, parseInt(day), + parseInt(endTime.substring(0, 2)), parseInt(endTime.substring(2, 4)))); + + if (end <= start) end.setUTCDate(end.getUTCDate() + 1); + + return { start, end }; + } + + } catch (e) { + // Parse error, skip this contest + } + + return null; +} + // Helper function to calculate upcoming contests function calculateUpcomingContests() { const now = new Date(); @@ -866,20 +975,38 @@ function calculateUpcomingContests() { { name: 'ARRL RTTY Roundup', month: 0, weekend: 1, duration: 24, mode: 'RTTY' }, // 1st full weekend Jan { name: 'NA QSO Party CW', month: 0, weekend: 2, duration: 12, mode: 'CW' }, { name: 'NA QSO Party SSB', month: 0, weekend: 3, duration: 12, mode: 'SSB' }, - { name: 'CQ 160m CW', month: 0, weekend: -1, duration: 42, mode: 'CW' }, + { name: 'CQ 160m CW', month: 0, weekend: -1, duration: 42, mode: 'CW' }, // Last full weekend Jan + { name: 'CQ 160m SSB', month: 1, weekend: -1, duration: 42, mode: 'SSB' }, // Last full weekend Feb { name: 'CQ WW RTTY', month: 8, weekend: -1, duration: 48, mode: 'RTTY' }, { name: 'JIDX CW', month: 3, weekend: 2, duration: 48, mode: 'CW' }, { name: 'JIDX SSB', month: 10, weekend: 2, duration: 48, mode: 'SSB' }, + { name: 'ARRL VHF Contest', month: 0, weekend: 3, duration: 33, mode: 'Mixed' }, // 3rd weekend Jan + { name: 'ARRL June VHF', month: 5, weekend: 2, duration: 33, mode: 'Mixed' }, // 2nd weekend Jun + { name: 'ARRL Sept VHF', month: 8, weekend: 2, duration: 33, mode: 'Mixed' }, // 2nd weekend Sep + { name: 'Winter Field Day', month: 0, weekend: -1, duration: 24, mode: 'Mixed' }, // Last weekend Jan + { name: 'CQWW WPX RTTY', month: 1, weekend: 2, duration: 48, mode: 'RTTY' }, // 2nd weekend Feb + { name: 'Stew Perry Topband', month: 11, weekend: 4, duration: 14, mode: 'CW' }, // 4th weekend Dec + { name: 'RAC Canada Day', month: 6, weekend: 1, duration: 24, mode: 'Mixed' }, // 1st weekend Jul + { name: 'RAC Winter Contest', month: 11, weekend: -1, duration: 24, mode: 'Mixed' }, // Last weekend Dec + { name: 'NAQP RTTY', month: 1, weekend: 4, duration: 12, mode: 'RTTY' }, // 4th weekend Feb + { name: 'NAQP RTTY', month: 6, weekend: 3, duration: 12, mode: 'RTTY' }, // 3rd weekend Jul ]; - // Weekly mini-contests (CWT, SST, etc.) + // Weekly mini-contests (CWT, SST, etc.) - dayOfWeek: 0=Sun, 1=Mon, ... 6=Sat const weeklyContests = [ - { name: 'CWT 1300z', dayOfWeek: 3, hour: 13, duration: 1, mode: 'CW' }, - { name: 'CWT 1900z', dayOfWeek: 3, hour: 19, duration: 1, mode: 'CW' }, - { name: 'CWT 0300z', dayOfWeek: 4, hour: 3, duration: 1, mode: 'CW' }, - { name: 'NCCC Sprint', dayOfWeek: 5, hour: 3, minute: 30, duration: 0.5, mode: 'CW' }, - { name: 'K1USN SST', dayOfWeek: 0, hour: 0, duration: 1, mode: 'CW' }, - { name: 'ICWC MST', dayOfWeek: 1, hour: 13, duration: 1, mode: 'CW' }, + { name: 'CWT 1300z', dayOfWeek: 3, hour: 13, duration: 1, mode: 'CW' }, // Wednesday + { name: 'CWT 1900z', dayOfWeek: 3, hour: 19, duration: 1, mode: 'CW' }, // Wednesday + { name: 'CWT 0300z', dayOfWeek: 4, hour: 3, duration: 1, mode: 'CW' }, // Thursday + { name: 'CWT 0700z', dayOfWeek: 4, hour: 7, duration: 1, mode: 'CW' }, // Thursday + { name: 'NCCC Sprint', dayOfWeek: 5, hour: 3, minute: 30, duration: 0.5, mode: 'CW' }, // Friday + { name: 'K1USN SST', dayOfWeek: 0, hour: 0, duration: 1, mode: 'CW' }, // Sunday 0000z (Sat evening US) + { name: 'K1USN SST', dayOfWeek: 1, hour: 20, duration: 1, mode: 'CW' }, // Monday 2000z + { name: 'ICWC MST', dayOfWeek: 1, hour: 13, duration: 1, mode: 'CW' }, // Monday 1300z + { name: 'ICWC MST', dayOfWeek: 1, hour: 19, duration: 1, mode: 'CW' }, // Monday 1900z + { name: 'ICWC MST', dayOfWeek: 2, hour: 3, duration: 1, mode: 'CW' }, // Tuesday 0300z + { name: 'SKCC Sprint', dayOfWeek: 3, hour: 0, duration: 2, mode: 'CW' }, // Wednesday 0000z + { name: 'QRP Fox Hunt', dayOfWeek: 3, hour: 2, duration: 1.5, mode: 'CW' }, // Wednesday 0200z + { name: 'RTTY Weekday Sprint', dayOfWeek: 2, hour: 23, duration: 1, mode: 'RTTY' }, // Tuesday 2300z ]; // Calculate next occurrences of weekly contests