EXIF Location Data Detector

UserScript that dynamically detects location data within the EXIF headers of images loaded on a webpage and provides an interactive UI to view it.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name        EXIF Location Data Detector
// @namespace   https://github.com/nyqui/exif-location-data-detector
// @version     1.0.0
// @author      nyqui
// @description UserScript that dynamically detects location data within the EXIF headers of images loaded on a webpage and provides an interactive UI to view it.
// @license     MIT

// @match       *://*/*
// @require     https://cdn.jsdelivr.net/npm/[email protected]/dist/full.umd.js
// @grant       GM_xmlhttpRequest
// @grant       GM_getValue
// @grant       GM_setValue
// @grant       GM_registerMenuCommand
// ==/UserScript==

(function() {
    'use strict';

    // Load user settings
    let minWidth = GM_getValue('EXIF_MIN_WIDTH', 300);
    let minHeight = GM_getValue('EXIF_MIN_HEIGHT', 300);
    let highlightImage = GM_getValue('EXIF_HIGHLIGHT_IMAGE', true);
    let flashHighlight = GM_getValue('EXIF_FLASH_HIGHLIGHT', true);
    let highlightColor = GM_getValue('EXIF_HIGHLIGHT_COLOR', '#ff0055');

    const FETCH_BYTES = 131072; // 128KB buffer
    const processedImages = new WeakSet();

    const style = document.createElement('style');
    style.textContent = `
        @keyframes exif-flash-pattern {
            0%, 76.9%   { outline-color: var(--exif-hl-color); }
            77%, 84.6%  { outline-color: transparent; }
            84.7%, 92.3% { outline-color: var(--exif-hl-color); }
            92.4%, 100%  { outline-color: transparent; }
        }
    `;
    document.head.appendChild(style);

    // --- SETTINGS UI ---

    const settingsDialog = document.createElement('dialog');
    settingsDialog.style.cssText = `
        padding: 20px;
        border: none;
        border-radius: 8px;
        box-shadow: 0 10px 25px rgba(0,0,0,0.5);
        font-family: system-ui, -apple-system, sans-serif;
        background: #1e1e1e;
        color: #fff;
        width: 280px;
    `;

    settingsDialog.addEventListener('click', (e) => {
        const rect = settingsDialog.getBoundingClientRect();
        const isInDialog = (rect.top <= e.clientY && e.clientY <= rect.top + rect.height && rect.left <= e.clientX && e.clientX <= rect.left + rect.width);
        if (!isInDialog) settingsDialog.close();
    });

    settingsDialog.innerHTML = `
        <h3 style="margin: 0 0 15px 0; font-size: 16px; border-bottom: 1px solid #444; padding-bottom: 10px;">
            EXIF Viewer Settings
        </h3>

        <div style="margin-bottom: 12px;">
            <label style="display: block; font-size: 13px; color: #aaa; margin-bottom: 5px;">Minimum Width (px)</label>
            <input type="number" id="exif-setting-width" value="${minWidth}" style="width: 100%; box-sizing: border-box; padding: 8px; background: #2a2a2a; border: 1px solid #444; color: #fff; border-radius: 4px;">
        </div>
        <div style="margin-bottom: 15px;">
            <label style="display: block; font-size: 13px; color: #aaa; margin-bottom: 5px;">Minimum Height (px)</label>
            <input type="number" id="exif-setting-height" value="${minHeight}" style="width: 100%; box-sizing: border-box; padding: 8px; background: #2a2a2a; border: 1px solid #444; color: #fff; border-radius: 4px;">
        </div>

        <div style="margin-bottom: 8px; display: flex; align-items: center; border-top: 1px solid #444; padding-top: 15px;">
            <input type="checkbox" id="exif-setting-highlight" ${highlightImage ? 'checked' : ''} style="margin-right: 8px; cursor: pointer;">
            <label for="exif-setting-highlight" style="font-size: 13px; color: #eee; cursor: pointer;">Highlight Image Border</label>
        </div>

        <div style="margin-bottom: 15px; display: flex; align-items: center; padding-left: 20px;">
            <input type="checkbox" id="exif-setting-flash" ${flashHighlight ? 'checked' : ''} style="margin-right: 8px; cursor: pointer;">
            <label for="exif-setting-flash" style="font-size: 13px; color: #ccc; cursor: pointer;">Flash Sequence (2s on, 0.2s pulse)</label>
        </div>

        <div style="margin-bottom: 25px; display: flex; align-items: center; justify-content: space-between;">
            <label style="font-size: 13px; color: #aaa;">Border Color</label>
            <input type="color" id="exif-setting-color" value="${highlightColor}" style="background: none; border: none; cursor: pointer; height: 30px; width: 50px; padding: 0;">
        </div>

        <div style="display: flex; justify-content: flex-end; gap: 10px;">
            <button id="exif-settings-cancel" style="background: transparent; color: #aaa; border: none; cursor: pointer; padding: 8px 12px; border-radius: 4px;">Cancel</button>
            <button id="exif-settings-save" style="background: #4a90e2; color: white; border: none; cursor: pointer; padding: 8px 15px; border-radius: 4px; font-weight: bold;">Save</button>
        </div>
    `;

    document.body.appendChild(settingsDialog);

    settingsDialog.querySelector('#exif-settings-cancel').onclick = () => settingsDialog.close();

    settingsDialog.querySelector('#exif-settings-save').onclick = () => {
        const newWidth = parseInt(settingsDialog.querySelector('#exif-setting-width').value, 10);
        const newHeight = parseInt(settingsDialog.querySelector('#exif-setting-height').value, 10);
        const newHighlight = settingsDialog.querySelector('#exif-setting-highlight').checked;
        const newFlash = settingsDialog.querySelector('#exif-setting-flash').checked;
        const newColor = settingsDialog.querySelector('#exif-setting-color').value;

        if (!isNaN(newWidth) && !isNaN(newHeight)) {
            GM_setValue('EXIF_MIN_WIDTH', newWidth);
            GM_setValue('EXIF_MIN_HEIGHT', newHeight);
            minWidth = newWidth;
            minHeight = newHeight;
        }

        GM_setValue('EXIF_HIGHLIGHT_IMAGE', newHighlight);
        GM_setValue('EXIF_FLASH_HIGHLIGHT', newFlash);
        GM_setValue('EXIF_HIGHLIGHT_COLOR', newColor);

        highlightImage = newHighlight;
        flashHighlight = newFlash;
        highlightColor = newColor;

        settingsDialog.close();
    };

    GM_registerMenuCommand('Settings...', () => {
        settingsDialog.querySelector('#exif-setting-width').value = minWidth;
        settingsDialog.querySelector('#exif-setting-height').value = minHeight;
        settingsDialog.querySelector('#exif-setting-highlight').checked = highlightImage;
        settingsDialog.querySelector('#exif-setting-flash').checked = flashHighlight;
        settingsDialog.querySelector('#exif-setting-color').value = highlightColor;
        settingsDialog.showModal();
    });

    // --- DATA UI ---

    const dialog = document.createElement('dialog');
    dialog.style.cssText = `
        padding: 20px;
        border: none;
        border-radius: 8px;
        box-shadow: 0 10px 25px rgba(0,0,0,0.5);
        font-family: system-ui, -apple-system, sans-serif;
        background: #1e1e1e;
        color: #fff;
        width: 300px;
        max-width: 90vw;
    `;

    document.body.appendChild(dialog);

    dialog.addEventListener('click', (e) => {
        const rect = dialog.getBoundingClientRect();
        const isInDialog = (rect.top <= e.clientY && e.clientY <= rect.top + rect.height && rect.left <= e.clientX && e.clientX <= rect.left + rect.width);
        if (!isInDialog) dialog.close();
    });

    function showDialog(exifData, e) {
        e.stopPropagation();

        const mapsLink = `https://www.google.com/maps?q=${exifData.latitude},${exifData.longitude}`;
        const alt = exifData.altitude ?? exifData.GPSAltitude;
        const hasAlt = alt !== undefined && alt !== null;

        let extraExifHtml = '<div style="margin-top: 15px; padding-top: 15px; border-top: 1px solid #444; font-size: 12px; color: #ccc; max-height: 200px; overflow-y: auto;">';
        const skipKeys = ['latitude', 'longitude', 'altitude', 'GPSAltitude', 'GPSAltitudeRef'];

        for (const [key, value] of Object.entries(exifData)) {
            if (!skipKeys.includes(key) && typeof value !== 'object' && value !== null) {
                extraExifHtml += `<div style="margin-bottom: 6px; line-height: 1.3;">
                    <strong style="color: #fff;">${key}:</strong> ${value}
                </div>`;
            }
        }
        extraExifHtml += '</div>';

        dialog.innerHTML = `
            <div style="display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #444; padding-bottom: 10px; margin-bottom: 15px;">
                <h3 style="margin: 0; font-size: 16px;">📍 Location Data</h3>
                <button id="exif-close-btn" style="background: none; border: none; color: #aaa; font-size: 16px; cursor: pointer; padding: 0; transition: color 0.2s;">✕</button>
            </div>

            <div style="font-size: 14px; margin-bottom: 8px;">
                <span style="color: #aaa;">Lat:</span> ${exifData.latitude.toFixed(6)}
            </div>
            <div style="font-size: 14px; margin-bottom: ${hasAlt ? '8px' : '15px'};">
                <span style="color: #aaa;">Lon:</span> ${exifData.longitude.toFixed(6)}
            </div>
            ${hasAlt ? `
            <div style="font-size: 14px; margin-bottom: 15px;">
                <span style="color: #aaa;">Alt:</span> ${Number(alt).toFixed(2)} m
            </div>
            ` : ''}

            <a href="${mapsLink}" target="_blank" style="
                display: block;
                text-align: center;
                background: #4a90e2;
                color: white;
                text-decoration: none;
                padding: 10px;
                border-radius: 4px;
                font-weight: bold;
                font-size: 14px;
                transition: background 0.2s;
            " onmouseover="this.style.background='#357abd'" onmouseout="this.style.background='#4a90e2'">Open in Google Maps</a>

            ${extraExifHtml}
        `;

        dialog.querySelector('#exif-close-btn').onclick = () => dialog.close();
        const closeBtn = dialog.querySelector('#exif-close-btn');
        closeBtn.onmouseenter = () => closeBtn.style.color = '#fff';
        closeBtn.onmouseleave = () => closeBtn.style.color = '#aaa';

        dialog.showModal();
    }

    function markImage(img, exifData) {
        if (highlightImage) {
            img.style.setProperty('--exif-hl-color', highlightColor);
            img.style.outline = `4px solid var(--exif-hl-color)`;
            img.style.outlineOffset = '-4px';


            if (flashHighlight) {
                img.style.animation = 'exif-flash-pattern 2.6s infinite';
            } else {
                img.style.animation = 'none';
            }
        }

        const marker = document.createElement('div');
        marker.innerHTML = '📍';
        marker.style.cssText = `
            position: absolute;
            z-index: 999999;
            cursor: pointer;
            background: rgba(0, 0, 0, 0.7);
            border: 2px solid #ff3333;
            border-radius: 50%;
            padding: 6px 7px;
            font-size: 16px;
            line-height: 1;
            box-shadow: 0 3px 8px rgba(0,0,0,0.4);
            transition: transform 0.2s;
            user-select: none;
        `;

        marker.onmouseenter = () => marker.style.transform = 'scale(1.15)';
        marker.onmouseleave = () => marker.style.transform = 'scale(1)';
        marker.onclick = (e) => showDialog(exifData, e);

        document.body.appendChild(marker);

        const updatePosition = () => {
            const rect = img.getBoundingClientRect();
            if (rect.width > 0) {
                marker.style.display = 'block';
                marker.style.top = `${rect.top + window.scrollY + 10}px`;
                marker.style.left = `${rect.left + window.scrollX + 10}px`;
            } else {
                marker.style.display = 'none';
            }
        };

        updatePosition();

        const ro = new ResizeObserver(updatePosition);
        ro.observe(img);
        window.addEventListener('resize', updatePosition);
    }

    // --- EXIF Parse ---

    function fetchAndParseExif(img) {
        GM_xmlhttpRequest({
            method: 'GET',
            url: img.src,
            headers: { 'Range': `bytes=0-${FETCH_BYTES}` },
            responseType: 'arraybuffer',
            onload: async function(response) {
                if (response.status >= 200 && response.status < 300 || response.status === 206) {
                    try {
                        const exifData = await exifr.parse(response.response, { xmp: false, icc: false });
                        if (exifData && exifData.latitude && exifData.longitude) {
                            markImage(img, exifData);
                        }
                    } catch (err) { /* silent fail */ }
                }
            }
        });
    }

    function evaluateImage(img) {
        if (img.naturalWidth >= minWidth && img.naturalHeight >= minHeight) {
            fetchAndParseExif(img);
        }
    }

    const imageObserver = new IntersectionObserver((entries, observer) => {
        entries.forEach(entry => {
            if (entry.isIntersecting) {
                const img = entry.target;
                observer.unobserve(img);
                if (img.complete) {
                    evaluateImage(img);
                } else {
                    img.addEventListener('load', () => evaluateImage(img), { once: true });
                }
            }
        });
    }, { rootMargin: '300px 0px' });

    function observeImage(img) {
        if (!img || img.tagName !== 'IMG' || !img.src || img.src.startsWith('data:') || processedImages.has(img)) return;
        processedImages.add(img);
        imageObserver.observe(img);
    }

    document.querySelectorAll('img').forEach(observeImage);

    const domObserver = new MutationObserver((mutations) => {
        mutations.forEach(mutation => {
            mutation.addedNodes.forEach(node => {
                if (node.nodeType === Node.ELEMENT_NODE) {
                    if (node.tagName === 'IMG') {
                        observeImage(node);
                    } else if (node.querySelectorAll) {
                        node.querySelectorAll('img').forEach(observeImage);
                    }
                }
            });
        });
    });

    domObserver.observe(document.body, { childList: true, subtree: true });

})();