Greasy Fork is available in English.
Enhances webpages by identifying airport and multi-airport codes, styling them, and adding interactive tooltips/hyperlinks. Dismiss reverts instances.
当前为
// ==UserScript==
// @name Nitan Airport Flair
// @namespace http://tampermonkey.net/
// @version 0.5.2
// @description Enhances webpages by identifying airport and multi-airport codes, styling them, and adding interactive tooltips/hyperlinks. Dismiss reverts instances.
// @author s5kf
// @license CC BY-NC-ND 4.0; https://creativecommons.org/licenses/by-nc-nd/4.0/
// @match *://www.uscardforum.com/*
// @resource airportJsonData https://raw.githubusercontent.com/s5kf/airport_flair/main/airports_filtered.json
// @resource multiAirportJsonData https://raw.githubusercontent.com/s5kf/airport_flair/main/multi_airport_data.json
// @grant GM_addStyle
// @grant GM_getResourceText
// @run-at document-idle
// @supportURL https://github.com/s5kf/airport_flair/issues
// ==/UserScript==
(function() {
'use strict';
let airportData = {}; // To store single airport data keyed by IATA code
let multiAirportData = {}; // To store multi-airport area data
let observer = null; // To store the MutationObserver instance
// --- Constants (moved up for early initialization) ---
const iataRegex = /\b([A-Za-z]{3})\b/g;
const processedMark = 'airport-flair-processed';
const flairTag = 'airport-flair-tag'; // Class for the flair span itself
const potentialFlairClass = 'potential-airport-code'; // New class for potential flairs
const multiAirportFlairClass = 'multi-airport-flair'; // New class for multi-airport flairs
const COMMON_TLA_BLOCKLIST = new Set([
"CEO", "CFO", "COO", "CTO", "CIO", "CMO", "HRD", // Common C-suite and department heads
"GDP", "GNP", "ROI", "KPI", "ETA", "FAQ", "DIY", "AKA", // Common business & general acronyms
"USB", "CPU", "GPU", "RAM", "SSD", "HDD", "OSX", "IOS", // Tech acronyms
"LOL", "OMG", "BTW", "FYI", "IMO", "BRB", // Internet slang
"USA", "UKG", "CAN", // Note: UKG for United Kingdom, CAN for Canada if they conflict. Add specific airport codes if they are also common TLAs.
"ETA", "ETC", "INC", "LTD", "LLC", "DIY", "FAQ", "PDF", "XML", "DOC",
"API", "URL", "WWW", "CSS",
"ESG", "VIX", "AOC"
// Add more as needed, ensure they are uppercase
]);
// --- Load Data from @resource ---
function initializeData() {
let mainDataLoaded = false;
let multiDataLoaded = false;
// Load Main Airport Data
try {
console.log("[Airport Flair] Attempting to load main airport data from @resource...");
const airportJsonDataString = GM_getResourceText("airportJsonData");
if (!airportJsonDataString) {
console.error("[Airport Flair] Failed to get main airport data. GM_getResourceText returned empty.");
} else {
const sanitizedJsonDataString = airportJsonDataString
.replace(/: Infinity,/g, ": null,")
.replace(/: Infinity}/g, ": null}")
.replace(/: NaN,/g, ": null,")
.replace(/: NaN}/g, ": null}");
const data = JSON.parse(sanitizedJsonDataString);
data.forEach(airport => {
if (airport.iata_code) {
airportData[airport.iata_code.toUpperCase()] = airport;
}
});
console.log("[Airport Flair] Main airport data loaded and processed:", Object.keys(airportData).length, "entries");
mainDataLoaded = true;
}
} catch (e) {
console.error("[Airport Flair] Error loading or parsing main airport data:", e);
// Optional: log airportJsonDataString snippet if needed
}
// Load Multi-Airport Data
try {
console.log("[Airport Flair] Attempting to load multi-airport data from @resource...");
const multiAirportJsonDataString = GM_getResourceText("multiAirportJsonData");
if (!multiAirportJsonDataString) {
console.error("[Airport Flair] Failed to get multi-airport data. GM_getResourceText returned empty.");
} else {
multiAirportData = JSON.parse(multiAirportJsonDataString); // Assuming this JSON is clean
console.log("[Airport Flair] Multi-airport data loaded and processed:", Object.keys(multiAirportData).length, "entries");
multiDataLoaded = true;
}
} catch (e) {
console.error("[Airport Flair] Error loading or parsing multi-airport data:", e);
// Optional: log multiAirportJsonDataString snippet if needed
}
return mainDataLoaded; // Script functionality primarily depends on main airport data for flags etc.
// Multi-airport data is supplementary.
}
// Initialize data and then process the page
if (initializeData()) {
processPage();
} else {
console.error("[Airport Flair] Script will not run effectively due to critical data loading issues (main airport data).");
}
// --- CSS Styles ---
function getDynamicStyles() {
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
const htmlElement = document.documentElement;
const bodyElement = document.body;
let isLikelyDarkMode = prefersDark ||
htmlElement.classList.contains('dark') ||
bodyElement.classList.contains('dark') ||
htmlElement.classList.contains('dark-mode') || // Added common alternative
bodyElement.classList.contains('dark-mode'); // Added common alternative
// Get computed background colors
const htmlBgColor = getComputedStyle(htmlElement).backgroundColor;
const bodyBgColor = getComputedStyle(bodyElement).backgroundColor;
// Function to check if a CSS color string represents a dark color
function isColorDark(colorString) {
if (!colorString || colorString === 'transparent' || colorString === 'rgba(0, 0, 0, 0)') return false;
if (colorString === 'rgb(0, 0, 0)' || colorString === '#000000') return true; // Pure black
try {
const rgbMatch = colorString.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
if (rgbMatch) {
const r = parseInt(rgbMatch[1], 10);
const g = parseInt(rgbMatch[2], 10);
const b = parseInt(rgbMatch[3], 10);
// Heuristic: if average intensity is low, or all components are relatively low
if ((r + g + b) / 3 < 75 || (r < 100 && g < 100 && b < 100)) {
return true;
}
}
} catch (e) {
// Ignore parsing errors, default to not dark for this check
}
return false;
}
if (!isLikelyDarkMode) {
// If no class or media query indicated dark mode, check computed background colors
if (isColorDark(bodyBgColor) || isColorDark(htmlBgColor)) {
isLikelyDarkMode = true;
}
}
let flairBgColor = '#eff3f4'; // Default light mode
if (isLikelyDarkMode) {
// Now, differentiate between dim and lights out if we've determined it's dark
// Prioritize body background for "lights out" check, then html
if (bodyBgColor === 'rgb(0, 0, 0)' || bodyBgColor === '#000000' ||
htmlBgColor === 'rgb(0, 0, 0)' || htmlBgColor === '#000000') {
flairBgColor = '#202327'; // Lights out
} else {
flairBgColor = '#273440'; // Dim mode (default dark)
}
}
return `
.${flairTag} {
display: inline-flex;
align-items: center;
vertical-align: baseline;
font-family: 'Fira Code', 'Roboto Mono', monospace;
padding: 1px 4px; /* Reduced padding */
color: #e6c07b; /* Light gold text */
background-color: ${flairBgColor};
border-radius: 3px;
text-decoration: none; /* For the anchor tag */
margin: 0 1px; /* Small margin to prevent touching adjacent text */
position: relative; /* For absolute positioning of the dismiss button */
}
.${flairTag} img.country-flag {
width: 16px;
height: 12px;
margin-left: 4px; /* Switched from margin-right and slightly increased for balance */
vertical-align: middle;
}
.${flairTag} .dismiss-flair {
position: absolute;
top: -6px;
right: -6px;
width: 16px;
height: 16px;
line-height: 16px;
text-align: center;
font-size: 14px;
font-weight: bold;
background-color: rgba(74, 74, 74, 0.6); /* Translucent background */
color: #ffffff;
border-radius: 50%;
cursor: pointer;
opacity: 0;
/* Only transition opacity and background-color for smoothness */
transition: opacity 0.15s ease-in-out, background-color 0.15s ease-in-out;
pointer-events: none; /* Prevent interaction when hidden */
z-index: 10;
}
.${flairTag}:hover .dismiss-flair {
opacity: 0.85; /* Default visible opacity */
pointer-events: auto; /* Allow interaction when visible */
}
.${flairTag} .dismiss-flair:hover {
opacity: 1; /* Full opacity on button hover */
background-color: rgba(51, 51, 51, 0.85); /* Darker, slightly more opaque on hover */
}
.${potentialFlairClass} {
text-decoration: underline dotted rgba(128, 128, 128, 0.7);
cursor: help;
/* Ensure it doesn't pick up parent link styles if it's inside an <a> not yet processed */
color: inherit;
}
.${potentialFlairClass}:hover {
text-decoration-color: rgba(100, 100, 100, 1); /* Darker underline on hover */
background-color: rgba(200, 200, 200, 0.1); /* Very subtle hover background */
}
/* No specific hover title CSS needed if using default browser title attribute */
/* Styles for Multi-Airport Flairs */
.${multiAirportFlairClass} {
display: inline-flex;
align-items: center;
vertical-align: baseline;
font-family: 'Fira Code', 'Roboto Mono', monospace;
padding: 1px 4px;
color: #d19a66; /* Slightly different text color, e.g., a bit more orange */
background-color: #3a3d41; /* Different background for dark mode by default */
border-radius: 3px;
text-decoration: none;
margin: 0 1px;
position: relative;
}
/* Dynamic background for multi-airport flairs based on theme */
/* Light mode for multi-airport */
html:not(.dark) body:not(.dark) .${multiAirportFlairClass},
body:not([style*="background-color: rgb(0, 0, 0)"]):not([style*="background-color: #000"]) .${multiAirportFlairClass} {
background-color: #e0e0e0; /* Lighter grey for light mode */
color: #8c5a32;
}
html.dark .${multiAirportFlairClass},
body.dark .${multiAirportFlairClass},
body[style*="background-color: rgb(0, 0, 0)"] .${multiAirportFlairClass},
body[style*="background-color: #000"] .${multiAirportFlairClass} {
background-color: #273440; /* Dim mode dark */
color: #e6c07b; /* Revert to standard flair text color in dark mode */
}
/* Consider a specific lights-out color if needed, for now dim is fine */
.${multiAirportFlairClass} img.country-flag {
width: 16px;
height: 12px;
margin-left: 4px;
vertical-align: middle;
}
/* No specific hover title CSS needed */
/* The dismiss button for multi-airport should also be covered by a combined selector or specific one */
/* Combining dismiss button hover selectors for robustness */
.${flairTag}:hover .dismiss-flair,
.${multiAirportFlairClass}:hover .dismiss-flair {
opacity: 0.85;
pointer-events: auto;
}
.${flairTag} .dismiss-flair:hover,
.${multiAirportFlairClass} .dismiss-flair:hover {
opacity: 1;
background-color: rgba(51, 51, 51, 0.85);
}
`;
}
function injectStyles() {
// Remove existing styles if they exist to update dynamic ones (like dark mode)
const existingStyleId = 'airport-flair-styles';
let existingStyleElement = document.getElementById(existingStyleId);
if (existingStyleElement) {
existingStyleElement.remove();
}
GM_addStyle(getDynamicStyles());
// Add an ID to the style element for easy removal/update if needed
const styleElements = document.head.getElementsByTagName('style');
if(styleElements.length > 0) {
// Assume the last style element added by GM_addStyle is ours if we don't have a direct way to ID it from GM_addStyle
styleElements[styleElements.length - 1].id = existingStyleId;
}
}
// --- DOM Interaction & Manipulation ---
function createFlairElement(code, airportInfo, originalCasing = null) {
const anchor = document.createElement('a');
anchor.href = `https://www.google.com/search?q=airport+${encodeURIComponent(code)}`;
anchor.target = '_blank';
anchor.rel = 'noopener noreferrer';
anchor.classList.add(flairTag); // Standard flair tag
anchor.classList.add(processedMark);
let titleText = `${airportInfo.name} (${code})`;
if (airportInfo.municipality) titleText += `, ${airportInfo.municipality}`;
if (airportInfo.iso_country) titleText += `, ${airportInfo.iso_country}`;
anchor.title = titleText;
const codeTextNode = document.createTextNode(code);
anchor.appendChild(codeTextNode); // Text first
if (airportInfo.iso_country) {
const flagImg = document.createElement('img');
flagImg.src = `https://cdnjs.cloudflare.com/ajax/libs/flag-icon-css/4.1.5/flags/4x3/${airportInfo.iso_country.toLowerCase()}.svg`;
flagImg.alt = `${airportInfo.iso_country} flag`;
flagImg.classList.add('country-flag');
anchor.appendChild(flagImg); // Flag after text
}
// Dismiss Button for single airport flairs
const dismissBtn = document.createElement('span');
dismissBtn.classList.add('dismiss-flair');
dismissBtn.innerHTML = '×';
dismissBtn.dataset.code = code;
dismissBtn.dataset.type = 'single-airport'; // Explicitly mark type
if (originalCasing && originalCasing !== code) {
dismissBtn.dataset.originalCasing = originalCasing;
dismissBtn.title = `Revert to '${originalCasing}' (potential code)`;
} else {
dismissBtn.title = `Revert to '${code}' (potential code)`;
}
dismissBtn.addEventListener('click', function(event) {
console.log("[Airport Flair] Single Airport Dismiss button clicked:", event.target.dataset.code);
event.preventDefault();
event.stopPropagation();
const currentCode = event.target.dataset.code;
const originalMixedCasing = event.target.dataset.originalCasing;
const currentFlairElement = event.target.closest('.' + flairTag);
const codeForPotential = originalMixedCasing || currentCode;
const airportInfoForReversion = airportData[codeForPotential.toUpperCase()];
if (currentFlairElement && currentFlairElement.parentNode) {
if (airportInfoForReversion) {
const potentialElement = createPotentialFlairElement(codeForPotential, airportInfoForReversion, false);
currentFlairElement.parentNode.replaceChild(potentialElement, currentFlairElement);
} else {
const revertedTextSpan = document.createElement('span');
revertedTextSpan.textContent = codeForPotential;
revertedTextSpan.classList.add(processedMark);
currentFlairElement.parentNode.replaceChild(revertedTextSpan, currentFlairElement);
}
}
});
anchor.appendChild(dismissBtn);
return anchor;
}
// Modified createPotentialFlairElement to handle both single and multi-airport types
function createPotentialFlairElement(originalCode, info, isMultiAirport = false) {
const span = document.createElement('span');
span.classList.add(potentialFlairClass);
span.classList.add(processedMark);
span.textContent = originalCode;
let titleText = `Recognize ${originalCode.toUpperCase()} as an `;
if (isMultiAirport && info && info.name) {
titleText += `area? (${info.name})`;
} else if (!isMultiAirport && info && info.name) {
titleText += `airport? (${info.name})`;
} else {
titleText += `code?`; // Fallback
}
span.title = titleText;
span.dataset.originalCode = originalCode;
span.dataset.isMulti = isMultiAirport ? "true" : "false";
const clickListener = function(event) {
event.preventDefault();
event.stopPropagation();
const codeToConvert = event.target.dataset.originalCode;
const wasMulti = event.target.dataset.isMulti === "true";
const uppercaseCode = codeToConvert.toUpperCase();
let fullFlairElement;
if (wasMulti) {
const maInfo = multiAirportData[uppercaseCode];
if (maInfo) {
fullFlairElement = createMultiAirportFlairElement(uppercaseCode, maInfo, codeToConvert);
}
} else {
const airportInfo = airportData[uppercaseCode];
if (airportInfo) {
fullFlairElement = createFlairElement(uppercaseCode, airportInfo, codeToConvert);
}
}
if (fullFlairElement && event.target.parentNode) {
event.target.parentNode.replaceChild(fullFlairElement, event.target);
}
};
span.addEventListener('click', clickListener, { once: true });
return span;
}
function replaceTextWithFlair(textNode) {
if (!textNode.parentNode || textNode.parentNode.classList.contains(processedMark) ||
textNode.parentNode.closest('a, script, style, input, textarea, [contenteditable="true"]')) {
return;
}
const text = textNode.nodeValue;
let match;
let lastIndex = 0;
const fragment = document.createDocumentFragment();
let foundMatch = false;
while ((match = iataRegex.exec(text)) !== null) {
const originalCode = match[1];
const uppercaseCode = originalCode.toUpperCase();
const airportInfo = airportData[uppercaseCode];
const maInfo = multiAirportData[uppercaseCode];
if (COMMON_TLA_BLOCKLIST.has(uppercaseCode)) {
if (airportInfo || maInfo) { // It's a common TLA but also a valid airport/MA code
foundMatch = true;
if (match.index > lastIndex) {
fragment.appendChild(document.createTextNode(text.substring(lastIndex, match.index)));
}
// Always create potential for blocklisted items that are also airports
fragment.appendChild(createPotentialFlairElement(originalCode, airportInfo || maInfo, !!maInfo));
lastIndex = iataRegex.lastIndex;
}
// If it's in the blocklist and NOT an airport, we do nothing, effectively skipping it.
// The loop will continue, and this part of the text remains unchanged.
} else if (airportInfo || maInfo) { // Not in blocklist, but is an airport/MA code
foundMatch = true;
if (match.index > lastIndex) {
fragment.appendChild(document.createTextNode(text.substring(lastIndex, match.index)));
}
if (originalCode === uppercaseCode) { // Already all caps
if (airportInfo) {
fragment.appendChild(createFlairElement(uppercaseCode, airportInfo));
} else { // maInfo must be true
fragment.appendChild(createMultiAirportFlairElement(uppercaseCode, maInfo));
}
} else { // Mixed or lowercase - create potential
if (airportInfo) {
fragment.appendChild(createPotentialFlairElement(originalCode, airportInfo, false));
} else { // maInfo must be true
fragment.appendChild(createPotentialFlairElement(originalCode, maInfo, true));
}
}
lastIndex = iataRegex.lastIndex;
}
// If no conditions met (not blocklisted, not airport), loop continues, text remains.
}
if (foundMatch) {
if (lastIndex < text.length) {
fragment.appendChild(document.createTextNode(text.substring(lastIndex)));
}
textNode.parentNode.replaceChild(fragment, textNode);
}
}
function processNode(node) {
if (node.nodeType === Node.TEXT_NODE) {
replaceTextWithFlair(node);
}
else if (node.nodeType === Node.ELEMENT_NODE &&
!node.classList.contains(processedMark) &&
!node.closest('a, script, style, input, textarea, [contenteditable="true"], .option-text, .' + flairTag + ', .' + potentialFlairClass + ', .' + multiAirportFlairClass)) {
Array.from(node.childNodes).forEach(child => processNode(child));
}
}
function processAddedNodes(mutationList) {
for (const mutation of mutationList) {
if (mutation.type === 'childList') {
mutation.addedNodes.forEach(node => {
// Check if the node itself has already been processed (e.g. if it's part of a flair we just added)
if (node.nodeType === Node.ELEMENT_NODE && node.classList.contains(processedMark)) {
return;
}
processNode(node);
});
}
}
// After processing mutations, it's good to re-evaluate dynamic styles like dark mode
injectStyles();
}
function observeDOMChanges() {
if (observer) observer.disconnect(); // Disconnect previous observer if any
observer = new MutationObserver(processAddedNodes);
observer.observe(document.body, {
childList: true,
subtree: true
});
console.log("MutationObserver started.");
}
function processPage() {
console.log("Processing page for airport codes...");
injectStyles();
// Initial scan of the entire body
processNode(document.body);
observeDOMChanges();
}
// --- Cleanup ---
window.addEventListener('unload', () => {
if (observer) {
observer.disconnect();
console.log("Disconnected MutationObserver.");
}
});
// --- Create Flair Elements ---
// Function to create the actual styled flair element for MULTI-AIRPORT codes
function createMultiAirportFlairElement(code, maInfo, originalCasing = null) {
const anchor = document.createElement('a');
anchor.href = `https://www.google.com/search?q=${encodeURIComponent(maInfo.name)}`; // Search for the area name
anchor.target = '_blank';
anchor.rel = 'noopener noreferrer';
anchor.classList.add(multiAirportFlairClass); // Use special class
anchor.classList.add(processedMark);
anchor.title = `${maInfo.name} (${code})`;
// Flag logic: use primaryAirportForFlag from maInfo to look up in airportData
if (maInfo.primaryAirportForFlag && airportData[maInfo.primaryAirportForFlag]) {
const primaryAirportDetails = airportData[maInfo.primaryAirportForFlag];
if (primaryAirportDetails.iso_country) {
const flagImg = document.createElement('img');
flagImg.src = `https://cdnjs.cloudflare.com/ajax/libs/flag-icon-css/4.1.5/flags/4x3/${primaryAirportDetails.iso_country.toLowerCase()}.svg`;
flagImg.alt = `${primaryAirportDetails.iso_country} flag`;
flagImg.classList.add('country-flag');
anchor.appendChild(flagImg); // Flag first for multi-airport to differentiate?
}
}
const codeTextNode = document.createTextNode(code);
if (anchor.firstChild && anchor.firstChild.tagName === 'IMG') { // If flag was added, append text after
anchor.appendChild(codeTextNode);
} else { // Otherwise, text is first
anchor.insertBefore(codeTextNode, anchor.firstChild);
}
// Add Dismiss Button
const dismissBtn = document.createElement('span');
dismissBtn.classList.add('dismiss-flair');
dismissBtn.innerHTML = '×';
dismissBtn.dataset.code = code; // Uppercase code of the flair
dismissBtn.dataset.type = 'multi-airport'; // Mark type for potential differentiated dismiss logic
// Determine title and if originalCasing needs to be stored for dismiss
if (originalCasing && originalCasing !== code) {
dismissBtn.dataset.originalCasing = originalCasing;
dismissBtn.title = `Revert to '${originalCasing}' (potential code)`;
} else {
dismissBtn.title = `Revert to '${code}' (potential code)`;
}
dismissBtn.addEventListener('click', function(event) {
console.log("[Airport Flair] Multi-Airport Dismiss button clicked:", event.target.dataset.code);
event.preventDefault();
event.stopPropagation();
const currentCode = event.target.dataset.code;
const originalMixedCasing = event.target.dataset.originalCasing;
const currentFlairElement = event.target.closest('.' + multiAirportFlairClass);
const codeForPotential = originalMixedCasing || currentCode;
const maInfoForReversion = multiAirportData[codeForPotential.toUpperCase()];
// Also check regular airport data in case it's an ambiguous code that got here
const airportInfoForReversion = airportData[codeForPotential.toUpperCase()];
if (currentFlairElement && currentFlairElement.parentNode) {
const infoForPotential = maInfoForReversion || airportInfoForReversion; // Prefer MA info if available
if (infoForPotential) {
const potentialElement = createPotentialFlairElement(codeForPotential, infoForPotential, !!maInfoForReversion);
currentFlairElement.parentNode.replaceChild(potentialElement, currentFlairElement);
} else {
// Fallback to plain text if no info found (should be rare)
const revertedTextSpan = document.createElement('span');
revertedTextSpan.textContent = codeForPotential;
revertedTextSpan.classList.add(processedMark);
currentFlairElement.parentNode.replaceChild(revertedTextSpan, currentFlairElement);
}
}
});
anchor.appendChild(dismissBtn);
return anchor;
}
})();