CGN Airport Busiest Flight Window Analyzer

Analyzes flight data from Cologne Bonn Airport (CGN) to find the busiest time windows for plane spotting, based on user-specified date and time range and maximum window duration.

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         CGN Airport Busiest Flight Window Analyzer
// @namespace    shiftgeist
// @match        https://www.koeln-bonn-airport.de/fluggaeste/fluege/abflug-ankunft.html
// @version      20250430
// @author       shiftgeist
// @description  Analyzes flight data from Cologne Bonn Airport (CGN) to find the busiest time windows for plane spotting, based on user-specified date and time range and maximum window duration.
// @license      GNU GPLv3
// @icon         https://www.google.com/s2/favicons?sz=64&domain=www.koeln-bonn-airport.de
// ==/UserScript==

const debug = window.localStorage.getItem('debug-log') === 'true';
const doc = document;
const elAttach = '#main-content-container';

let statsDisplay = null;
let timeoutCounter = 0;

const jsonEndpointUrl = 'https://www.koeln-bonn-airport.de/fluggaeste/fluege/abflug-ankunft/fsjson';

function log(...params) {
    if (debug) {
        console.debug('[Traffic]', ...params);
    }
}

function timeToMinutes(timeStr) {
    const [hours, minutes] = timeStr.split(':').map(Number);
    return hours * 60 + minutes;
}

function minutesToTime(totalMinutes) {
    const hours = Math.floor(totalMinutes / 60) % 24;
    const minutes = totalMinutes % 60;
    const hourStr = String(hours).padStart(2, '0');
    const minuteStr = String(minutes).padStart(2, '0');
    return `${hourStr}:${minuteStr}`;
}

function findBusiestWindowInRange(flightTimes, maxDurationMinutes) {
    if (!flightTimes || flightTimes.length < 2) {
        return null;
    }

    const flightMinutes = flightTimes.map(timeToMinutes).sort((a, b) => a - b);

    let bestWindow = {
        startTime: -1,
        endTime: -1,
        count: 0,
        duration: Infinity
    };

    for (let i = 0; i < flightMinutes.length; i++) {
        const currentStartTime = flightMinutes[i];

        for (let j = i + 1; j < flightMinutes.length; j++) {
            const currentEndTime = flightMinutes[j];
            const currentDuration = currentEndTime - currentStartTime;

            if (currentDuration <= maxDurationMinutes) {
                const currentCount = j - i + 1;

                if (currentCount > bestWindow.count) {
                    bestWindow.count = currentCount;
                    bestWindow.duration = currentDuration;
                    bestWindow.startTime = currentStartTime;
                    bestWindow.endTime = currentEndTime;
                } else if (currentCount === bestWindow.count) {
                    if (currentDuration < bestWindow.duration) {
                        bestWindow.duration = currentDuration;
                        bestWindow.startTime = currentStartTime;
                        bestWindow.endTime = currentEndTime;
                    }
                }
            }
             if (currentDuration > maxDurationMinutes) {
                 break;
             }
        }
    }

    if (bestWindow.count > 0) {
        return {
            window: `${minutesToTime(bestWindow.startTime)} - ${minutesToTime(bestWindow.endTime)}`,
            count: bestWindow.count,
            durationMinutes: bestWindow.duration
        };
    } else {
        return null;
    }
}

function findFlightWindows(flightTimes, maxDurationMinutes) {
    return findBusiestWindowInRange(flightTimes, maxDurationMinutes);
}

function getCurrentDateFormatted() {
    const now = new Date();
    const day = String(now.getDate()).padStart(2, '0');
    const month = String(now.getMonth() + 1).padStart(2, '0');
    const year = now.getFullYear();
    return `${day}.${month}.${year}`;
}

function getCurrentDateInputFormatted() {
    const now = new Date();
    const year = now.getFullYear();
    const month = String(now.getMonth() + 1).padStart(2, '0');
    const day = String(now.getDate()).padStart(2, '0');
    return `${year}-${month}-${day}`;
}


function getCurrentTimeFormatted() {
     const now = new Date();
     const hours = String(now.getHours()).padStart(2, '0');
     const minutes = String(now.getMinutes()).padStart(2, '0');
     return `${hours}:${minutes}`;
}

function buildJsonPostData(startDate, startTime, endDate, endTime) {
    const startDateTime = new Date(`${startDate.split('.').reverse().join('-')}T${startTime}:00`);
    const endDateTime = new Date(`${endDate.split('.').reverse().join('-')}T${endTime}:00`);

    const startTimestamp = Math.floor(startDateTime.getTime() / 1000);
    const endTimestamp = Math.floor(endDateTime.getTime() / 1000);

    return `mode=A&mode=D&more=&flightsperpage=500&tolerance=&page=0&dtpSTARTDATE=${startDate}+${startTime}&START=${startTimestamp}&END=${endTimestamp}&destination=&datehelper=${startDate}+${startTime}&date=${startDate}+${startTime}`;
}

async function fetchFlightData(postData, callback) {
    log('Fetching flight data from JSON endpoint with data:', postData);

    const statsOutputDiv = doc.getElementById('stats-output');
    if (statsOutputDiv) {
        statsOutputDiv.innerHTML = 'Fetching flight data...';
        statsDisplay.style.display = 'flex';
    }

    try {
        const response = await fetch(jsonEndpointUrl, {
            method: 'POST',
            headers: {
                'Content-Type': 'application/x-www-form-urlencoded'
            },
            body: postData
        });

        if (!response.ok) {
            log(`HTTP error! status: ${response.status}`);
            throw new Error(`HTTP error! status: ${response.status}`);
        }

        const jsonResponse = await response.json();
        log('JSON response received:', jsonResponse);

        const flightTimes = [];
        if (jsonResponse && Array.isArray(jsonResponse.flights)) {
            jsonResponse.flights.forEach(flight => {
                const time = (flight.expected && flight.expected !== flight.time) ? flight.expected : flight.time;
                if (time) {
                     flightTimes.push(time.trim());
                }
            });
        }

        log('Extracted flight times:', flightTimes);
        callback(flightTimes);

    } catch (e) {
        log('Error fetching or processing JSON data:', e);
        const statsOutputDiv = doc.getElementById('stats-output');
        if (statsOutputDiv) {
             statsOutputDiv.innerHTML = `Error: ${e.message || 'Could not fetch flight data.'}`;
             statsDisplay.style.display = 'flex';
        }
    }
}

function createStats() {
    const container = doc.querySelector(elAttach);

    if (!container) {
        log('Attachment container not found:', elAttach);
        return;
    }

    if (!statsDisplay) {
        statsDisplay = doc.createElement('div');
        statsDisplay.id = 'plane-spotting-stats';
        statsDisplay.style.cssText = `
            position: fixed;
            top: 10px;
            right: 10px;
            background-color: rgba(0, 0, 0, 0.7);
            color: white;
            padding: 10px;
            border-radius: 5px;
            z-index: 10000;
            font-family: sans-serif;
            font-size: 14px;
            min-width: 300px;
            display: flex;
            flex-direction: column;
            gap: 5px;
        `;
        doc.body.appendChild(statsDisplay);

        const currentDateFormatted = getCurrentDateFormatted();
        const currentDateInputFormatted = getCurrentDateInputFormatted();
        const currentTime = getCurrentTimeFormatted();
        const endOfDayTime = '23:59';
        const defaultMaxDuration = 180; // Default max duration in minutes (3 hours)

        statsDisplay.innerHTML = `
            <div>
                <strong>Time Range:</strong>
            </div>
            <div style="display: flex; gap: 5px;">
                <label for="earliest-date">From:</label>
                <input type="date" id="earliest-date" value="${currentDateInputFormatted}">
                <input type="time" id="earliest-time" value="${currentTime}">
            </div>
            <div style="display: flex; gap: 5px;">
                 <label for="latest-date">To:</label>
                 <input type="date" id="latest-date" value="${currentDateInputFormatted}">
                 <input type="time" id="latest-time" value="${endOfDayTime}">
            </div>
             <div>
                <label for="max-duration">Max Window Duration (mins):</label>
                <input type="number" id="max-duration" value="${defaultMaxDuration}" min="1">
            </div>
            <button id="fetch-flights-button">Analyze Flights</button>
            <div id="stats-output"></div> `;

        doc.getElementById('fetch-flights-button').addEventListener('click', () => {
            const earliestDate = doc.getElementById('earliest-date').value;
            const earliestTime = doc.getElementById('earliest-time').value;
            const latestDate = doc.getElementById('latest-date').value;
            const latestTime = doc.getElementById('latest-time').value;
            const maxDuration = parseInt(doc.getElementById('max-duration').value, 10);


            if (earliestDate && earliestTime && latestDate && latestTime && !isNaN(maxDuration) && maxDuration > 0) {
                const startDateFormatted = earliestDate.split('-').reverse().join('.');
                const endDateFormatted = latestDate.split('-').reverse().join('.');

                const postData = buildJsonPostData(startDateFormatted, earliestTime, endDateFormatted, latestTime);
                fetchFlightData(postData, (flightTimes) => {
                    const busiestWindow = findFlightWindows(flightTimes, maxDuration);
                    log('Busiest window data:', busiestWindow);
                    updateStatsDisplay(busiestWindow);
                });
            } else {
                updateStatsDisplay(null);
            }
        });

         const initialPostData = buildJsonPostData(currentDateFormatted, currentTime, currentDateFormatted, endOfDayTime);
         fetchFlightData(initialPostData, (flightTimes) => {
            const busiestWindow = findFlightWindows(flightTimes, defaultMaxDuration);
            log('Initial busiest window data:', busiestWindow);
            updateStatsDisplay(busiestWindow);
         });

    } else {
         log('Stats display already exists, triggering data fetch based on current inputs.');
         const earliestDate = doc.getElementById('earliest-date').value;
         const earliestTime = doc.getElementById('earliest-time').value;
         const latestDate = doc.getElementById('latest-date').value;
         const latestTime = doc.getElementById('latest-time').value;
         const maxDuration = parseInt(doc.getElementById('max-duration').value, 10);


         if (earliestDate && earliestTime && latestDate && latestTime && !isNaN(maxDuration) && maxDuration > 0) {
             const startDateFormatted = earliestDate.split('-').reverse().join('.');
             const endDateFormatted = latestDate.split('-').reverse().join('.');

             const postData = buildJsonPostData(startDateFormatted, earliestTime, endDateFormatted, latestTime);
             fetchFlightData(postData, (flightTimes) => {
                 const busiestWindow = findFlightWindows(flightTimes, maxDuration);
                 log('Busiest window data (update):', busiestWindow);
                 updateStatsDisplay(busiestWindow);
             });
         } else {
             updateStatsDisplay(null);
         }
    }
}

function updateStatsDisplay(busiestWindow) {
    const statsOutputDiv = doc.getElementById('stats-output');
     if (statsOutputDiv) {
         if (busiestWindow) {
            statsOutputDiv.innerHTML = `
                <strong>Busiest Window:</strong> ${busiestWindow.window}<br>
                <strong>Flights:</strong> ${busiestWindow.count}<br>
                <strong>Duration:</strong> ${busiestWindow.durationMinutes} mins
            `;
         } else {
            statsOutputDiv.innerHTML = 'No suitable busy window found in the specified range.';
         }
         statsDisplay.style.display = 'flex';
     }
}

function waitForAttachElement(callback) {
    log('Waiting for attachment element...');
    const container = doc.querySelector(elAttach);

    if (container) {
        log('Attachment element found.');
        callback();
    } else {
        timeoutCounter += 1;
        const delay = 20 * (timeoutCounter / 2 + 1);
        log(`Attachment element not found, retrying in ${delay}ms. Attempt ${timeoutCounter}`);
        if (timeoutCounter < 50) {
             setTimeout(() => waitForAttachElement(callback), delay);
        } else {
             log('Max waitForAttachElement attempts reached. Could not find necessary elements.');
             if (!statsDisplay) {
                 statsDisplay = doc.createElement('div');
                 statsDisplay.style.cssText = `
                    position: fixed;
                    top: 10px;
                    right: 10px;
                    background-color: rgba(255, 99, 71, 0.8);
                    color: white;
                    padding: 10px;
                    border-radius: 5px;
                    z-index: 10000;
                    font-family: sans-serif;
                    font-size: 14px;
                 `;
                 statsDisplay.innerHTML = 'Plane Spotting script could not find the attachment element.';
                 doc.body.appendChild(statsDisplay);
             }
        }
    }
}

waitForAttachElement(createStats);