520 lines
16 KiB
JavaScript
520 lines
16 KiB
JavaScript
const http = require('http');
|
|
const https = require('https');
|
|
const url = require('url');
|
|
|
|
const PORT = 3002;
|
|
|
|
// Default configuration
|
|
let config = {
|
|
sites: [
|
|
{
|
|
id: '1411',
|
|
name: 'Ambassaderna',
|
|
enabled: true
|
|
}
|
|
],
|
|
rssFeeds: [
|
|
{
|
|
name: "Travel Alerts",
|
|
url: "https://travel.state.gov/content/travel/en/rss/rss.xml",
|
|
enabled: true
|
|
}
|
|
]
|
|
};
|
|
|
|
// Function to load configuration from file
|
|
function loadSitesConfig() {
|
|
try {
|
|
const fs = require('fs');
|
|
if (fs.existsSync('sites-config.json')) {
|
|
const configData = fs.readFileSync('sites-config.json', 'utf8');
|
|
const loadedConfig = JSON.parse(configData);
|
|
|
|
// Handle old format (array of sites)
|
|
if (Array.isArray(loadedConfig)) {
|
|
config.sites = loadedConfig;
|
|
} else {
|
|
config = loadedConfig;
|
|
}
|
|
|
|
console.log('Loaded configuration:', config);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading configuration:', error);
|
|
}
|
|
}
|
|
|
|
// Load configuration on startup
|
|
loadSitesConfig();
|
|
|
|
// Feed templates for different formats
|
|
const feedTemplates = {
|
|
rss2: {
|
|
detect: (data) => data.includes('<rss version="2.0"'),
|
|
itemPath: 'item',
|
|
titlePath: 'title',
|
|
descPath: 'description',
|
|
datePath: 'pubDate'
|
|
},
|
|
atom: {
|
|
detect: (data) => data.includes('<feed xmlns="http://www.w3.org/2005/Atom"'),
|
|
itemPath: 'entry',
|
|
titlePath: 'title',
|
|
descPath: 'content',
|
|
datePath: 'updated'
|
|
},
|
|
rss1: {
|
|
detect: (data) => data.includes('<rdf:RDF'),
|
|
itemPath: 'item',
|
|
titlePath: 'title',
|
|
descPath: 'description',
|
|
datePath: 'dc:date'
|
|
}
|
|
};
|
|
|
|
// Helper function to detect feed type
|
|
function detectFeedType(data) {
|
|
for (const [type, template] of Object.entries(feedTemplates)) {
|
|
if (template.detect(data)) {
|
|
return type;
|
|
}
|
|
}
|
|
return 'rss2'; // Default to RSS 2.0
|
|
}
|
|
|
|
// Helper function to clean HTML content
|
|
function cleanHtmlContent(content) {
|
|
if (!content) return '';
|
|
|
|
// Remove CDATA if present
|
|
content = content.replace(/<!\[CDATA\[(.*?)\]\]>/gs, '$1');
|
|
|
|
// Remove HTML tags
|
|
content = content.replace(/<[^>]+>/g, ' ');
|
|
|
|
// Convert common HTML entities
|
|
content = content
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, "'")
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/&/g, '&')
|
|
.replace(/&#(\d+);/g, (match, dec) => String.fromCharCode(dec))
|
|
.replace(/&#x([A-Fa-f0-9]+);/g, (match, hex) => String.fromCharCode(parseInt(hex, 16)));
|
|
|
|
// Remove extra whitespace
|
|
content = content.replace(/\s+/g, ' ').trim();
|
|
|
|
// Get first sentence if content is too long
|
|
if (content.length > 200) {
|
|
const match = content.match(/^.*?[.!?](?:\s|$)/);
|
|
if (match) {
|
|
content = match[0].trim();
|
|
} else {
|
|
content = content.substring(0, 197) + '...';
|
|
}
|
|
}
|
|
|
|
return content;
|
|
}
|
|
|
|
// Function to extract content based on template
|
|
function extractContent(content, template) {
|
|
const itemRegex = new RegExp(`<${template.itemPath}>([\\\s\\\S]*?)<\/${template.itemPath}>`, 'g');
|
|
const titleRegex = new RegExp(`<${template.titlePath}>([\\\s\\\S]*?)<\/${template.titlePath}>`);
|
|
const descRegex = new RegExp(`<${template.descPath}>([\\\s\\\S]*?)<\/${template.descPath}>`);
|
|
const dateRegex = new RegExp(`<${template.datePath}>([\\\s\\\S]*?)<\/${template.datePath}>`);
|
|
const linkRegex = /<link[^>]*?>([\s\S]*?)<\/link>/;
|
|
|
|
const items = [];
|
|
let match;
|
|
|
|
while ((match = itemRegex.exec(content)) !== null) {
|
|
const itemContent = match[1];
|
|
|
|
const titleMatch = titleRegex.exec(itemContent);
|
|
const descMatch = descRegex.exec(itemContent);
|
|
const dateMatch = dateRegex.exec(itemContent);
|
|
const linkMatch = linkRegex.exec(itemContent);
|
|
|
|
const title = cleanHtmlContent(titleMatch ? titleMatch[1] : '');
|
|
const description = cleanHtmlContent(descMatch ? descMatch[1] : '');
|
|
|
|
// Create display text from title and optionally description
|
|
let displayText = title;
|
|
if (description && description !== title) {
|
|
displayText = `${title}: ${description}`;
|
|
}
|
|
|
|
items.push({
|
|
title,
|
|
displayText,
|
|
link: linkMatch ? linkMatch[1].trim() : '',
|
|
description,
|
|
pubDate: dateMatch ? dateMatch[1] : '',
|
|
feedUrl: null // Will be set by caller
|
|
});
|
|
}
|
|
|
|
return items;
|
|
}
|
|
|
|
// Function to fetch RSS feed data from a single URL
|
|
async function fetchSingleRssFeed(feedUrl) {
|
|
return new Promise((resolve, reject) => {
|
|
console.log(`Fetching RSS feed from: ${feedUrl}`);
|
|
|
|
const request = https.get(feedUrl, (res) => {
|
|
console.log(`RSS feed response status: ${res.statusCode}`);
|
|
console.log('RSS feed response headers:', res.headers);
|
|
|
|
let data = '';
|
|
|
|
res.on('data', (chunk) => {
|
|
data += chunk;
|
|
});
|
|
|
|
res.on('end', () => {
|
|
try {
|
|
// Log first part of response
|
|
console.log('RSS feed raw response (first 500 chars):', data.substring(0, 500));
|
|
|
|
// Detect feed type and get appropriate template
|
|
const feedType = detectFeedType(data);
|
|
const template = feedTemplates[feedType];
|
|
console.log(`Detected feed type: ${feedType}`);
|
|
|
|
// Extract and process items
|
|
const items = extractContent(data, template);
|
|
console.log(`Extracted ${items.length} items from feed`);
|
|
|
|
// Add feed URL to each item
|
|
items.forEach(item => {
|
|
item.feedUrl = feedUrl;
|
|
});
|
|
|
|
resolve(items);
|
|
} catch (error) {
|
|
console.error('Error processing RSS feed:', error);
|
|
console.error('Error stack:', error.stack);
|
|
resolve([
|
|
{
|
|
title: 'Error processing RSS feed',
|
|
link: feedUrl,
|
|
feedUrl: feedUrl,
|
|
error: error.message
|
|
}
|
|
]);
|
|
}
|
|
});
|
|
});
|
|
|
|
request.on('error', (error) => {
|
|
console.error('Error fetching RSS feed:', error);
|
|
console.error('Error stack:', error.stack);
|
|
resolve([
|
|
{
|
|
title: 'Error fetching RSS feed',
|
|
link: feedUrl,
|
|
feedUrl: feedUrl,
|
|
error: error.message
|
|
}
|
|
]);
|
|
});
|
|
|
|
// Set a timeout of 10 seconds
|
|
request.setTimeout(10000, () => {
|
|
console.error('RSS feed request timed out:', feedUrl);
|
|
request.destroy();
|
|
resolve([
|
|
{
|
|
title: 'RSS feed request timed out',
|
|
link: feedUrl,
|
|
feedUrl: feedUrl,
|
|
error: 'Request timed out after 10 seconds'
|
|
}
|
|
]);
|
|
});
|
|
});
|
|
}
|
|
|
|
// Function to fetch all enabled RSS feeds
|
|
async function fetchRssFeed() {
|
|
try {
|
|
const enabledFeeds = config.rssFeeds.filter(feed => feed.enabled);
|
|
const feedPromises = enabledFeeds.map(feed => fetchSingleRssFeed(feed.url));
|
|
|
|
const allItems = await Promise.all(feedPromises);
|
|
|
|
// Flatten array and sort by date (if available)
|
|
const items = allItems.flat().sort((a, b) => {
|
|
const dateA = new Date(a.pubDate || 0);
|
|
const dateB = new Date(b.pubDate || 0);
|
|
return dateB - dateA;
|
|
});
|
|
|
|
return { items };
|
|
} catch (error) {
|
|
console.error('Error fetching RSS feeds:', error);
|
|
return {
|
|
items: [{
|
|
title: 'Error fetching RSS feeds',
|
|
link: '#',
|
|
error: error.message
|
|
}]
|
|
};
|
|
}
|
|
}
|
|
|
|
// Function to fetch data from the SL Transport API for a specific site
|
|
function fetchDeparturesForSite(siteId) {
|
|
return new Promise((resolve, reject) => {
|
|
const apiUrl = `https://transport.integration.sl.se/v1/sites/${siteId}/departures`;
|
|
console.log(`Fetching data from: ${apiUrl}`);
|
|
|
|
https.get(apiUrl, (res) => {
|
|
let data = '';
|
|
|
|
res.on('data', (chunk) => {
|
|
data += chunk;
|
|
});
|
|
|
|
res.on('end', () => {
|
|
console.log('Raw API response:', data.substring(0, 200) + '...');
|
|
|
|
try {
|
|
try {
|
|
const parsedData = JSON.parse(data);
|
|
console.log('Successfully parsed as regular JSON');
|
|
resolve(parsedData);
|
|
return;
|
|
} catch (jsonError) {
|
|
console.log('Not valid JSON, trying to fix format...');
|
|
}
|
|
|
|
if (data.startsWith('departures":')) {
|
|
data = '{' + data;
|
|
} else if (data.includes('departures":')) {
|
|
const startIndex = data.indexOf('departures":');
|
|
if (startIndex > 0) {
|
|
data = '{' + data.substring(startIndex);
|
|
}
|
|
}
|
|
|
|
data = data.replace(/}{\s*"/g, '},{"');
|
|
data = data.replace(/"([^"]+)":\s*([^,{}\[\]]+)(?=")/g, '"$1": $2,');
|
|
data = data.replace(/,\s*}/g, '}').replace(/,\s*\]/g, ']');
|
|
|
|
try {
|
|
const parsedData = JSON.parse(data);
|
|
console.log('Successfully parsed fixed JSON');
|
|
|
|
if (parsedData && parsedData.departures) {
|
|
const line7Departures = parsedData.departures.filter(d => d.line && d.line.designation === '7');
|
|
if (line7Departures.length > 0) {
|
|
console.log('Line 7 details:', JSON.stringify(line7Departures[0], null, 2));
|
|
}
|
|
}
|
|
|
|
resolve(parsedData);
|
|
} catch (parseError) {
|
|
console.error('Error parsing fixed JSON:', parseError);
|
|
resolve({
|
|
departures: [],
|
|
error: 'Failed to parse API response: ' + parseError.message,
|
|
rawResponse: data.substring(0, 500) + '...'
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error('Error processing API response:', error);
|
|
resolve({
|
|
departures: [],
|
|
error: 'Error processing API response: ' + error.message,
|
|
rawResponse: data.substring(0, 500) + '...'
|
|
});
|
|
}
|
|
});
|
|
}).on('error', (error) => {
|
|
console.error('Error fetching data from API:', error);
|
|
resolve({
|
|
departures: [],
|
|
error: 'Error fetching data from API: ' + error.message
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
// Create HTTP server
|
|
const server = http.createServer(async (req, res) => {
|
|
const parsedUrl = url.parse(req.url, true);
|
|
|
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
|
|
if (req.method === 'OPTIONS') {
|
|
res.writeHead(204);
|
|
res.end();
|
|
return;
|
|
}
|
|
|
|
// Function to fetch data from all enabled sites
|
|
async function fetchAllDepartures() {
|
|
const enabledSites = config.sites.filter(site => site.enabled);
|
|
|
|
if (enabledSites.length === 0) {
|
|
return { sites: [], error: 'No enabled sites configured' };
|
|
}
|
|
|
|
try {
|
|
const sitesPromises = enabledSites.map(async (site) => {
|
|
try {
|
|
const departureData = await fetchDeparturesForSite(site.id);
|
|
return {
|
|
siteId: site.id,
|
|
siteName: site.name,
|
|
data: departureData
|
|
};
|
|
} catch (error) {
|
|
console.error(`Error fetching departures for site ${site.id}:`, error);
|
|
return {
|
|
siteId: site.id,
|
|
siteName: site.name,
|
|
error: error.message
|
|
};
|
|
}
|
|
});
|
|
|
|
const results = await Promise.all(sitesPromises);
|
|
return { sites: results };
|
|
} catch (error) {
|
|
console.error('Error fetching all departures:', error);
|
|
return { sites: [], error: error.message };
|
|
}
|
|
}
|
|
|
|
// Handle API endpoints
|
|
if (parsedUrl.pathname === '/api/departures') {
|
|
try {
|
|
const data = await fetchAllDepartures();
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify(data));
|
|
} catch (error) {
|
|
console.error('Error handling departures request:', error);
|
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: error.message }));
|
|
}
|
|
}
|
|
else if (parsedUrl.pathname === '/api/config') {
|
|
// Return the current configuration
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify(config));
|
|
}
|
|
else if (parsedUrl.pathname === '/api/config/update' && req.method === 'POST') {
|
|
// Update configuration
|
|
let body = '';
|
|
req.on('data', chunk => {
|
|
body += chunk.toString();
|
|
});
|
|
|
|
req.on('end', () => {
|
|
try {
|
|
const newConfig = JSON.parse(body);
|
|
if (newConfig.sites && newConfig.rssFeeds) {
|
|
config = newConfig;
|
|
|
|
// Save to file
|
|
const fs = require('fs');
|
|
fs.writeFileSync('sites-config.json', JSON.stringify(config, null, 2));
|
|
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ success: true, message: 'Configuration updated' }));
|
|
} else {
|
|
throw new Error('Invalid configuration format');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error updating configuration:', error);
|
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: error.message }));
|
|
}
|
|
});
|
|
}
|
|
else if (parsedUrl.pathname === '/api/rss') {
|
|
try {
|
|
// If URL is provided, fetch single feed, otherwise fetch all enabled feeds
|
|
const feedUrl = parsedUrl.query.url;
|
|
console.log('RSS request received for URL:', feedUrl);
|
|
|
|
let data;
|
|
if (feedUrl) {
|
|
console.log('Fetching single RSS feed:', feedUrl);
|
|
const items = await fetchSingleRssFeed(feedUrl);
|
|
data = { items };
|
|
console.log(`Fetched ${items.length} items from feed`);
|
|
} else {
|
|
console.log('Fetching all enabled RSS feeds');
|
|
data = await fetchRssFeed();
|
|
console.log(`Fetched ${data.items.length} total items from all feeds`);
|
|
}
|
|
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify(data));
|
|
console.log('RSS response sent successfully');
|
|
} catch (error) {
|
|
console.error('Error handling RSS request:', error);
|
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({
|
|
error: error.message,
|
|
stack: error.stack
|
|
}));
|
|
}
|
|
}
|
|
// Serve static files
|
|
else if (parsedUrl.pathname === '/' || parsedUrl.pathname === '/index.html') {
|
|
const fs = require('fs');
|
|
fs.readFile('index.html', (err, data) => {
|
|
if (err) {
|
|
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
res.end('Error loading index.html');
|
|
return;
|
|
}
|
|
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
res.end(data);
|
|
});
|
|
}
|
|
else if (/\.(js|css|png|jpg|jpeg|gif|ico)$/.test(parsedUrl.pathname)) {
|
|
const fs = require('fs');
|
|
const filePath = parsedUrl.pathname.substring(1);
|
|
fs.readFile(filePath, (err, data) => {
|
|
if (err) {
|
|
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
res.end('File not found');
|
|
return;
|
|
}
|
|
|
|
const ext = parsedUrl.pathname.split('.').pop();
|
|
const contentType = {
|
|
'js': 'text/javascript',
|
|
'css': 'text/css',
|
|
'png': 'image/png',
|
|
'jpg': 'image/jpeg',
|
|
'jpeg': 'image/jpeg',
|
|
'gif': 'image/gif',
|
|
'ico': 'image/x-icon'
|
|
}[ext] || 'text/plain';
|
|
|
|
res.writeHead(200, { 'Content-Type': contentType });
|
|
res.end(data);
|
|
});
|
|
}
|
|
else {
|
|
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
res.end('Not Found');
|
|
}
|
|
});
|
|
|
|
// Start the server
|
|
server.listen(PORT, () => {
|
|
console.log(`Server running at http://localhost:${PORT}/`);
|
|
});
|