IP Location Finder

Finds the geographical location of IP addresses on a page.

// ==UserScript==
// @name         IP Location Finder
// @namespace    https://github.com/Yanel85/IP-Location-Finder/
// @version      1.4
// @description  Finds the geographical location of IP addresses on a page.
// @author       Perry Yen
// @match        *://*/*
// @grant        GM_xmlhttpRequest
// @grant        GM_getValue
// @grant        GM_setValue
// @connect      ipapi.co
// @connect      geo.ipify.org
// @connect      ip-api.com
// @icon         https://raw.githubusercontent.com/Yanel85/IP-Location-Finder/refs/heads/main/extension/icon.svg
// @license      GPL3
// ==/UserScript==

(function () {
    'use strict';

    let currentSelectedText;
    let tooltip;
    let locationSpanElementMap = new Map();

    const ipIconUrl = "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICA8cGF0aCBkPSJNMTEuOTk5NyAxLjk5OTk2QzguMTMzOSAxLjk5OTk2IDQuOTk5NzQgNS4xMzQwMiA0Ljk5OTc0IDguOTk5OTZDMjkuMTkzNSA4Ljk5OTk2IDExLjk5OTcgMTYuMDAwMyAxMS45OTk3IDE2LjAwMDNDMTEuOTk5NyAxNi4wMDAzIDE0Ljg2NTQgOC45OTk5NiAyMC45OTk3IDguOTk5OTZDMTkuMTkzNSA4Ljk5OTk2IDE1Ljk5OTcgNS4xMzQwMiAxMS45OTk3IDEuOTk5OTZaIiBmaWxsPSIjMTA5NkRiIi8+CiAgPHBhdGggZD0iTTEyIDExLjk5OTlDMTMuNjU2OSAxMS45OTk5IDE1IDEwLjY1NjggMTUgOC45OTk5M0MxNSA3LjM0MzA5IDEzLjY1NjkgNi4wMDAwNyAxMiA2LjAwMDA3QzEwLjM0MzEgNi4wMDAwNyA5IDcuMzQzMDkgOSA4Ljk5OTkzQzkgMTAuNjU2OCA4LjM0MzE0IDExLjk5OTkgMTIgMTEuOTk5OVoiIGZpbGw9IiMxMDk2RGIiLz4KICA8cGF0aCBkPSJNMTEuOTk5NyAxNy45OTk2QzkuMzI3NjcgMTcuOTk5NiAzLjMzOTYyIDE5LjMzMTcgMy4zMzk2MiAyMi45OTk2VjIzLjOTk2SDIxLjY1OTVWMjIuOTk5NkMzMS42NTk1IDIwLjMzMTcgMTYuNjkxNCAxNy45OTk2IDExLjk5OTcgMTcuOTk5NloiIGZpbGw9IiMxMDk2RGIiLz4KPC9zdmc+";

    // API URLs
    const apiUrls = {
        "ipapi.co": "https://ipapi.co/{ip}/json",
        "geoIpify": "https://geo.ipify.org/api/v2/country,city?apiKey=at_9kY03l6G3CExGRBVfAqHQLIvOSj2m&ipAddress={ip}", // 需要替换API Key
        "ip-api.com": "http://ip-api.com/json/{ip}",
        "custom": "custom" // 添加自定义选项
    };

    let currentApiUrl = GM_getValue("apiUrl", "http://ip-api.com/json/{ip}");

    const cache = {};

    // Event listener for mouseup
    document.addEventListener('mouseup', handleMouseUp);

    // Handle mouseup event
    function handleMouseUp() {
        removeTooltip();
        //removeIcon(currentSelectedText);
        const selectedText = window.getSelection().toString().trim();

        if (selectedText && isValidIP(selectedText)) {
            currentSelectedText = selectedText;
            //showIcon();
            queryIpLocation(currentSelectedText);
        }
    }


    // Send IP location query
    async function queryIpLocation(ip) {
        // 检查缓存
        if (cache[ip]) {
            const { countryCode, city } = cache[ip];
            insertLocation(countryCode, city);
            return;
        }

        try {
            let apiUrl = currentApiUrl;
            if (currentApiUrl !== apiUrls["custom"]) {
                apiUrl = currentApiUrl.replace("{ip}", ip);
            }

            const response = await GM_xmlhttpRequestPromise(apiUrl);
            if (response.status !== 200) {
                throw new Error(`HTTP error! Status: ${response.status}`);
            }

            let data = JSON.parse(response.responseText);
            if (currentApiUrl === apiUrls["geoIpify"]) {
                cache[ip] = { countryCode: data.location.country, city: data.location.city }; // 缓存结果
                insertLocation(data.location.country, data.location.city);
            } else if (currentApiUrl === apiUrls["ip-api.com"]) {
                cache[ip] = { countryCode: data.countryCode, city: data.city }; // 缓存结果
                insertLocation(data.countryCode, data.city);
            } else if (currentApiUrl === apiUrls["bigDataCloud"]) {
                cache[ip] = { countryCode: data.countryCode, city: data.city }; // 缓存结果
                insertLocation(data.countryCode, data.city);
            } else {
                cache[ip] = { countryCode: data.country, city: data.city }; // 缓存结果
                insertLocation(data.country, data.city);
            }
        } catch (error) {
            showTooltip(`error: ${error.message}`, true);
        }
    }

    // Display tooltip message
    function showTooltip(text, isError = false) {
        removeTooltip();
        const selection = window.getSelection();
        if (!selection.rangeCount) return;

        const range = selection.getRangeAt(0);
        const rect = range.getBoundingClientRect();


        tooltip = document.createElement("div");
        tooltip.style.position = "absolute";
        tooltip.style.background = isError ? "red" : "lightgreen";
        tooltip.style.color = "black";
        tooltip.style.padding = "3px";
        tooltip.style.border = "1px solid #ccc";
        tooltip.style.borderRadius = "4px";
        tooltip.style.fontSize = "0.6em";
        tooltip.style.zIndex = "9999"; // Ensure tooltip is above all elements
        tooltip.textContent = text;
        tooltip.style.top = rect.bottom + window.scrollY + "px";
        tooltip.style.left = rect.left + window.scrollX + "px";


        document.body.appendChild(tooltip);

        setTimeout(() => {
            removeTooltip();
        }, 3000);
    }

    // Remove tooltip
    function removeTooltip() {
        if (tooltip) {
            tooltip.remove();
            tooltip = null;
        }
    }

    // Insert IP location into the page
    function insertLocation(countryCode, city) {
        const selection = window.getSelection();
        if (!selection.rangeCount) return;

        const range = selection.getRangeAt(0);
        const selectedTextNode = range.startContainer;

        if (selectedTextNode.nodeType !== Node.TEXT_NODE) return;
        const selectedText = selectedTextNode.textContent;
        const ipIndex = selectedText.indexOf(currentSelectedText);

        if (ipIndex === -1) return;
        removeLocationSpan(currentSelectedText);

        locationSpanElementMap.set(currentSelectedText, document.createElement('span'));
        let locationSpan = locationSpanElementMap.get(currentSelectedText);
        locationSpan.style.color = 'red';
        locationSpan.style.fontWeight = 'bold';
        locationSpan.style.fontSize = '0.6em';

        let locationText = "";
        if (countryCode) {
            const flagIconUrl = `https://flagcdn.com/24x18/${countryCode.toLowerCase()}.png`;
            const flagImage = `<img src="${flagIconUrl}" style="display:inline-block;vertical-align:middle;margin-right:2px; width:18px; height:13px;">`;
            locationText = `(${flagImage}${countryCode}`;
            if (city && city.trim()) {
                locationText = `${locationText},${city})`;
            } else {
                locationText = `${locationText})`;
            }
        }
        locationSpan.innerHTML = locationText;

        const beforeIpTextNode = document.createTextNode(selectedText.substring(0, ipIndex + currentSelectedText.length));
        const afterIpTextNode = document.createTextNode(selectedText.substring(ipIndex + currentSelectedText.length));

        selectedTextNode.textContent = '';

        selectedTextNode.parentNode.insertBefore(beforeIpTextNode, selectedTextNode);
        selectedTextNode.parentNode.insertBefore(locationSpan, selectedTextNode);
        selectedTextNode.parentNode.insertBefore(afterIpTextNode, selectedTextNode);

        window.getSelection().empty();
    }

    function removeLocationSpan(ipText) {
        if (locationSpanElementMap.has(ipText)) {
            let locationSpan = locationSpanElementMap.get(ipText)
            locationSpan.remove();
            locationSpanElementMap.delete(ipText);
        }
    }

    // IP address validation
    function isValidIP(ip) {
        const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/;
        const ipv6Regex = /^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:))$/;
        const ipv4CidrRegex = /^(\d{1,3}\.){3}\d{1,3}\/([0-9]|[1-2][0-9]|3[0-2])$/;

        if (ipv4CidrRegex.test(ip)) {
            ip = ip.split('/')[0]; // 去除CIDR部分
        }
        return ipv4Regex.test(ip) || ipv6Regex.test(ip);
    }


    // Helper function for GM_xmlhttpRequest
    function GM_xmlhttpRequestPromise(url) {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                url: url,
                method: "GET",
                onload: (response) => {
                    resolve(response);
                },
                onerror: (error) => {
                    reject(error);
                }
            });
        });
    }

    function createSettingsUI() {
        const settingsDiv = document.createElement('div')
        settingsDiv.style.position = "fixed"
        settingsDiv.style.bottom = "10px"
        settingsDiv.style.right = "80px"
        settingsDiv.style.padding = "10px"
        settingsDiv.style.background = "white"
        settingsDiv.style.border = "1px solid black"
        settingsDiv.style.zIndex = "9999999"
        settingsDiv.style.display = "flex"
        settingsDiv.style.flexDirection = "column"
        settingsDiv.style.width = "200px"
        settingsDiv.style.fontSize = "12px"

        const apiUrlLabel = document.createElement('label')
        apiUrlLabel.textContent = "IP Location Finder API option:"
        const apiUrlSelect = document.createElement('select');
        Object.keys(apiUrls).forEach(key => {
            const option = document.createElement("option");
            option.value = apiUrls[key]
            option.text = key
            apiUrlSelect.add(option)
        })
        apiUrlSelect.value = currentApiUrl;
        settingsDiv.append(apiUrlLabel, apiUrlSelect)

        const customApiInput = document.createElement('input');
        customApiInput.type = "text"
        customApiInput.placeholder = "Replace the API's IP with {ip}."
        customApiInput.style.display = (currentApiUrl === "custom" ? "block" : "none");

        settingsDiv.append(customApiInput);

        const saveButton = document.createElement("button")
        saveButton.textContent = "Save"
        settingsDiv.append(saveButton)

        saveButton.addEventListener("click", () => {
            const selectedValue = apiUrlSelect.value
            if (selectedValue === 'custom') {
                currentApiUrl = customApiInput.value;
            } else {
                currentApiUrl = selectedValue;
            }
            GM_setValue("apiUrl", currentApiUrl);
            customApiInput.style.display = (currentApiUrl === "custom" ? "block" : "none");
            statusDiv.textContent = "Settings Saved"
            setTimeout(() => {
                statusDiv.textContent = "";
            }, 1000);
        })

        const statusDiv = document.createElement("div")
        settingsDiv.append(statusDiv)


        apiUrlSelect.addEventListener("change", (event) => {
            const selectedValue = event.target.value;
            customApiInput.style.display = (selectedValue === "custom" ? "block" : "none");
        })

        document.body.appendChild(settingsDiv)

    }

    createSettingsUI();
})();