|
|
|
|
@ -15,7 +15,6 @@ const express = require('express');
|
|
|
|
|
const cors = require('cors');
|
|
|
|
|
const path = require('path');
|
|
|
|
|
const fetch = require('node-fetch');
|
|
|
|
|
const net = require('net');
|
|
|
|
|
|
|
|
|
|
const app = express();
|
|
|
|
|
const PORT = process.env.PORT || 3000;
|
|
|
|
|
@ -117,11 +116,10 @@ app.get('/api/hamqsl/conditions', async (req, res) => {
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// DX Cluster proxy - fetches from selectable sources
|
|
|
|
|
// Query param: ?source=hamqth|dxheat|dxsummit|dxspider|auto (default: auto)
|
|
|
|
|
// Query param: ?source=hamqth|dxheat|dxsummit|jo30|auto (default: auto)
|
|
|
|
|
|
|
|
|
|
// Cache for DX Spider telnet spots (to avoid too many connections)
|
|
|
|
|
let dxSpiderCache = { spots: [], timestamp: 0 };
|
|
|
|
|
const DXSPIDER_CACHE_TTL = 60000; // 60 seconds cache
|
|
|
|
|
// Note: DX Spider (telnet) removed - doesn't work on hosted platforms
|
|
|
|
|
// Using HTTP-based APIs only for online compatibility
|
|
|
|
|
|
|
|
|
|
app.get('/api/dxcluster/spots', async (req, res) => {
|
|
|
|
|
const source = (req.query.source || 'auto').toLowerCase();
|
|
|
|
|
@ -274,117 +272,111 @@ app.get('/api/dxcluster/spots', async (req, res) => {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Helper function for DX Spider (G6NHU-2) via telnet
|
|
|
|
|
async function fetchDXSpider() {
|
|
|
|
|
// Check cache first
|
|
|
|
|
if (Date.now() - dxSpiderCache.timestamp < DXSPIDER_CACHE_TTL && dxSpiderCache.spots.length > 0) {
|
|
|
|
|
console.log('[DX Cluster] DX Spider: returning', dxSpiderCache.spots.length, 'cached spots');
|
|
|
|
|
return dxSpiderCache.spots;
|
|
|
|
|
}
|
|
|
|
|
// Helper function for IU1BOW Spiderweb (HTTP-based DX Spider web interface)
|
|
|
|
|
async function fetchIU1BOW() {
|
|
|
|
|
const controller = new AbortController();
|
|
|
|
|
const timeout = setTimeout(() => controller.abort(), 8000);
|
|
|
|
|
|
|
|
|
|
return new Promise((resolve) => {
|
|
|
|
|
const spots = [];
|
|
|
|
|
let buffer = '';
|
|
|
|
|
let gotSpots = false;
|
|
|
|
|
let loginSent = false;
|
|
|
|
|
let commandSent = false;
|
|
|
|
|
|
|
|
|
|
const client = new net.Socket();
|
|
|
|
|
client.setTimeout(12000);
|
|
|
|
|
|
|
|
|
|
client.connect(7300, 'dxspider.co.uk', () => {
|
|
|
|
|
console.log('[DX Cluster] DX Spider: connected');
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch('https://www.iu1bow.it/spotlist', {
|
|
|
|
|
headers: {
|
|
|
|
|
'User-Agent': 'OpenHamClock/3.4',
|
|
|
|
|
'Accept': 'application/json'
|
|
|
|
|
},
|
|
|
|
|
signal: controller.signal
|
|
|
|
|
});
|
|
|
|
|
clearTimeout(timeout);
|
|
|
|
|
|
|
|
|
|
client.on('data', (data) => {
|
|
|
|
|
buffer += data.toString();
|
|
|
|
|
|
|
|
|
|
// Wait for login prompt
|
|
|
|
|
if (!loginSent && (buffer.includes('login:') || buffer.includes('Please enter your call'))) {
|
|
|
|
|
loginSent = true;
|
|
|
|
|
// Send a guest login (GUEST or anonymous callsign)
|
|
|
|
|
client.write('GUEST\r\n');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Wait for prompt after login, then send command
|
|
|
|
|
if (loginSent && !commandSent && (buffer.includes('Hello') || buffer.includes('de ') || buffer.includes('>'))) {
|
|
|
|
|
commandSent = true;
|
|
|
|
|
// Request last 25 spots
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
client.write('sh/dx 25\r\n');
|
|
|
|
|
}, 500);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (response.ok) {
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
|
|
|
|
|
// Parse DX spots from the output
|
|
|
|
|
// Format: DX de W3LPL: 14195.0 TI5/AA8HH FT8 -09 dB 1234Z
|
|
|
|
|
const lines = buffer.split('\n');
|
|
|
|
|
for (const line of lines) {
|
|
|
|
|
if (line.includes('DX de ')) {
|
|
|
|
|
const match = line.match(/DX de ([A-Z0-9\/\-]+):\s+(\d+\.?\d*)\s+([A-Z0-9\/\-]+)\s+(.+?)\s+(\d{4})Z/i);
|
|
|
|
|
if (match) {
|
|
|
|
|
const spotter = match[1].replace(':', '');
|
|
|
|
|
const freqKhz = parseFloat(match[2]);
|
|
|
|
|
const dxCall = match[3];
|
|
|
|
|
const comment = match[4].trim();
|
|
|
|
|
const timeStr = match[5];
|
|
|
|
|
|
|
|
|
|
if (!isNaN(freqKhz) && freqKhz > 0 && dxCall) {
|
|
|
|
|
const freqMhz = (freqKhz / 1000).toFixed(3);
|
|
|
|
|
const time = timeStr.substring(0, 2) + ':' + timeStr.substring(2, 4) + 'z';
|
|
|
|
|
|
|
|
|
|
// Avoid duplicates
|
|
|
|
|
if (!spots.find(s => s.call === dxCall && s.freq === freqMhz)) {
|
|
|
|
|
spots.push({
|
|
|
|
|
freq: freqMhz,
|
|
|
|
|
call: dxCall,
|
|
|
|
|
comment: comment,
|
|
|
|
|
time: time,
|
|
|
|
|
spotter: spotter,
|
|
|
|
|
source: 'DX Spider'
|
|
|
|
|
});
|
|
|
|
|
gotSpots = true;
|
|
|
|
|
}
|
|
|
|
|
if (Array.isArray(data) && data.length > 0) {
|
|
|
|
|
const spots = data.slice(0, 25).map(spot => {
|
|
|
|
|
// IU1BOW format varies, common fields: freq, spotcall/dx_call, spotter, time, comment
|
|
|
|
|
const freqVal = spot.freq || spot.frequency || 0;
|
|
|
|
|
const freqMhz = freqVal > 1000 ? (freqVal / 1000).toFixed(3) : String(freqVal).includes('.') ? String(freqVal) : (freqVal / 1000).toFixed(3);
|
|
|
|
|
let time = '';
|
|
|
|
|
if (spot.time) {
|
|
|
|
|
// Time might be Unix timestamp or string
|
|
|
|
|
if (typeof spot.time === 'number') {
|
|
|
|
|
const d = new Date(spot.time * 1000);
|
|
|
|
|
time = d.toISOString().substring(11, 16) + 'z';
|
|
|
|
|
} else {
|
|
|
|
|
time = String(spot.time).substring(0, 5) + 'z';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If we have enough spots or see end marker, close connection
|
|
|
|
|
if (spots.length >= 20 || buffer.includes('G6NHU-2 de GUEST')) {
|
|
|
|
|
client.write('bye\r\n');
|
|
|
|
|
setTimeout(() => client.destroy(), 500);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
client.on('timeout', () => {
|
|
|
|
|
console.log('[DX Cluster] DX Spider: timeout');
|
|
|
|
|
client.destroy();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
client.on('error', (err) => {
|
|
|
|
|
console.error('[DX Cluster] DX Spider error:', err.message);
|
|
|
|
|
client.destroy();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
client.on('close', () => {
|
|
|
|
|
if (spots.length > 0) {
|
|
|
|
|
console.log('[DX Cluster] DX Spider:', spots.length, 'spots');
|
|
|
|
|
dxSpiderCache = { spots: spots, timestamp: Date.now() };
|
|
|
|
|
resolve(spots);
|
|
|
|
|
} else {
|
|
|
|
|
resolve(null);
|
|
|
|
|
return {
|
|
|
|
|
freq: freqMhz,
|
|
|
|
|
call: spot.spotcall || spot.dx_call || spot.dx || 'UNKNOWN',
|
|
|
|
|
comment: spot.comment || spot.info || '',
|
|
|
|
|
time: time,
|
|
|
|
|
spotter: spot.spotter || spot.de || '',
|
|
|
|
|
source: 'IU1BOW DX Spider'
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
console.log('[DX Cluster] IU1BOW:', spots.length, 'spots');
|
|
|
|
|
return spots;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
clearTimeout(timeout);
|
|
|
|
|
if (error.name !== 'AbortError') {
|
|
|
|
|
console.error('[DX Cluster] IU1BOW error:', error.message);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Helper function for Spothole (aggregated DX cluster + xOTA spots)
|
|
|
|
|
async function fetchSpothole() {
|
|
|
|
|
const controller = new AbortController();
|
|
|
|
|
const timeout = setTimeout(() => controller.abort(), 8000);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// Spothole API endpoint - filter for DX cluster spots only
|
|
|
|
|
const response = await fetch('https://spothole.app/api/spots?sources=dxcluster&limit=25', {
|
|
|
|
|
headers: {
|
|
|
|
|
'User-Agent': 'OpenHamClock/3.4',
|
|
|
|
|
'Accept': 'application/json'
|
|
|
|
|
},
|
|
|
|
|
signal: controller.signal
|
|
|
|
|
});
|
|
|
|
|
clearTimeout(timeout);
|
|
|
|
|
|
|
|
|
|
// Fallback timeout
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
if (!gotSpots) {
|
|
|
|
|
client.destroy();
|
|
|
|
|
if (response.ok) {
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
const spotsList = data.spots || data;
|
|
|
|
|
|
|
|
|
|
if (Array.isArray(spotsList) && spotsList.length > 0) {
|
|
|
|
|
const spots = spotsList.slice(0, 25).map(spot => {
|
|
|
|
|
// Spothole format: dx, frequency, mode, comment, de, time, etc.
|
|
|
|
|
const freqVal = spot.frequency || spot.freq || 0;
|
|
|
|
|
const freqMhz = freqVal > 1000 ? (freqVal / 1000).toFixed(3) : String(freqVal);
|
|
|
|
|
let time = '';
|
|
|
|
|
if (spot.time || spot.timestamp) {
|
|
|
|
|
const d = new Date(spot.time || spot.timestamp);
|
|
|
|
|
time = d.toISOString().substring(11, 16) + 'z';
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
freq: freqMhz,
|
|
|
|
|
call: spot.dx || spot.call || spot.spotted || 'UNKNOWN',
|
|
|
|
|
comment: spot.comment || spot.info || spot.mode || '',
|
|
|
|
|
time: time,
|
|
|
|
|
spotter: spot.de || spot.spotter || '',
|
|
|
|
|
source: 'Spothole'
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
console.log('[DX Cluster] Spothole:', spots.length, 'spots');
|
|
|
|
|
return spots;
|
|
|
|
|
}
|
|
|
|
|
}, 15000);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
clearTimeout(timeout);
|
|
|
|
|
if (error.name !== 'AbortError') {
|
|
|
|
|
console.error('[DX Cluster] Spothole error:', error.message);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Fetch based on selected source
|
|
|
|
|
@ -396,11 +388,15 @@ app.get('/api/dxcluster/spots', async (req, res) => {
|
|
|
|
|
spots = await fetchDXHeat();
|
|
|
|
|
} else if (source === 'dxsummit') {
|
|
|
|
|
spots = await fetchDXSummit();
|
|
|
|
|
} else if (source === 'dxspider') {
|
|
|
|
|
spots = await fetchDXSpider();
|
|
|
|
|
} else if (source === 'iu1bow') {
|
|
|
|
|
spots = await fetchIU1BOW();
|
|
|
|
|
} else if (source === 'spothole') {
|
|
|
|
|
spots = await fetchSpothole();
|
|
|
|
|
} else {
|
|
|
|
|
// Auto mode - try sources in order
|
|
|
|
|
// Auto mode - try sources in order (most reliable first)
|
|
|
|
|
spots = await fetchHamQTH();
|
|
|
|
|
if (!spots) spots = await fetchIU1BOW();
|
|
|
|
|
if (!spots) spots = await fetchSpothole();
|
|
|
|
|
if (!spots) spots = await fetchDXHeat();
|
|
|
|
|
if (!spots) spots = await fetchDXSummit();
|
|
|
|
|
}
|
|
|
|
|
@ -412,10 +408,11 @@ app.get('/api/dxcluster/spots', async (req, res) => {
|
|
|
|
|
app.get('/api/dxcluster/sources', (req, res) => {
|
|
|
|
|
res.json([
|
|
|
|
|
{ id: 'auto', name: 'Auto (Best Available)', description: 'Automatically selects the best available source' },
|
|
|
|
|
{ id: 'hamqth', name: 'HamQTH', description: 'HamQTH.com DX Cluster feed' },
|
|
|
|
|
{ id: 'hamqth', name: 'HamQTH', description: 'HamQTH.com DX Cluster CSV feed' },
|
|
|
|
|
{ id: 'iu1bow', name: 'IU1BOW DX Spider', description: 'IU1BOW.it Spiderweb cluster (HTTP API)' },
|
|
|
|
|
{ id: 'spothole', name: 'Spothole', description: 'Spothole.app aggregated DX cluster' },
|
|
|
|
|
{ id: 'dxheat', name: 'DXHeat', description: 'DXHeat.com real-time cluster' },
|
|
|
|
|
{ id: 'dxsummit', name: 'DX Summit', description: 'DXSummit.fi cluster (may be slow)' },
|
|
|
|
|
{ id: 'dxspider', name: 'DX Spider (G6NHU)', description: 'G6NHU-2 DX Spider node via telnet (dxspider.co.uk)' }
|
|
|
|
|
{ id: 'dxsummit', name: 'DX Summit', description: 'DXSummit.fi cluster (may be slow)' }
|
|
|
|
|
]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|