Flickr: Commenters Summary

Displays a panel showing commenters sorted by the number of comments made

// ==UserScript==
// @name         Flickr: Commenters Summary
// @namespace    http://tampermonkey.net/
// @version      0.5
// @author       Isidro Vila Verde
// @description  Displays a panel showing commenters sorted by the number of comments made
// @match        https://www.flickr.com/*
// @match        https://flickr.com/*
// @grant        none
// ==/UserScript==

(function () {
    'use strict';

    // Configurações globais
    const STORAGE = {
        apiKey: 'flickr_api_key',
        darkMode: 'flickr_dark_mode',
        panelPos: 'flickr_panel_pos',
        sortMode: 'flickr_sort_mode'
    };

    // Elementos globais
    let btn = null;
    let panel = null;
    let isRunning = false;
    const validPathRegex = /^\/photos\/[^/]+(?:\/(?:with\/.+)?)?$/;

    // Verificador de URL
    function isValidPage() {
        return validPathRegex.test(window.location.pathname);
    }

    // Limpeza dos elementos
    function cleanUp() {
        if (btn) {
            btn.remove();
            btn = null;
        }
        if (panel) {
            panel.remove();
            panel = null;
        }
        isRunning = false;
    }

    // Cria o botão inicial
    function createStartButton() {
        if (btn) return;

        btn = document.createElement('button');
        btn.textContent = '📊 Comentadores';
        btn.style.position = 'fixed';
        btn.style.top = '5px';
        btn.style.left = '50%';
        btn.style.transform = 'translateX(-50%)';
        btn.style.zIndex = '9999';
        btn.style.padding = '2px';
        btn.style.background = '#0063dc';
        btn.style.color = '#fff';
        btn.style.border = 'none';
        btn.style.borderRadius = '5px';
        btn.style.cursor = 'pointer';
        btn.style.maxWidth = '10vw';
        btn.style.whiteSpace = 'nowrap';
        btn.style.overflow = 'hidden';
        btn.style.textOverflow = 'ellipsis';

        btn.addEventListener('click', run);
        document.body.appendChild(btn);
    }

    // Observador de mudanças de URL
    function setupUrlObserver() {
        let lastUrl = location.href;

        // Observa mudanças a cada 500ms
        setInterval(() => {
            if (location.href !== lastUrl) {
                lastUrl = location.href;
                handleUrlChange();
            }
        }, 500);

        // Captura navegações via History API
        const originalPushState = history.pushState;
        const originalReplaceState = history.replaceState;

        history.pushState = function() {
            originalPushState.apply(this, arguments);
            handleUrlChange();
        };

        history.replaceState = function() {
            originalReplaceState.apply(this, arguments);
            handleUrlChange();
        };

        // Captura eventos de popstate (back/forward)
        window.addEventListener('popstate', handleUrlChange);
    }

    // Manipulador de mudança de URL
    function handleUrlChange() {
        if (isValidPage()) {
            console.log('isValidPage');
            if (!btn) {
                console.log('createButton');
                createStartButton();
            }
        } else {
            console.log('isNotValidPage=>CleanButton');
            cleanUp();
        }
    }

    // Funções auxiliares
    const log = (...args) => console.log('[FlickrResumo]', ...args);

    function getStored(key, fallback = null) {
        return JSON.parse(localStorage.getItem(key)) ?? fallback;
    }

    function setStored(key, value) {
        localStorage.setItem(key, JSON.stringify(value));
    }

    function getApiKey() {
        let key = getStored(STORAGE.apiKey);
        if (!key) {
            key = prompt("🔑 Introduz a tua API key do Flickr:");
            if (key) setStored(STORAGE.apiKey, key.trim());
            else return null;
        }
        return key;
    }

    async function resolveUserId(apiKey) {
        const path = window.location.pathname;
        const match = path.match(/^\/photos\/([^/]+)(?:\/(?:with\/.+)?)?$/);
        if (!match) return null;

        const identifier = match[1];
        if (/^\d+@N\d+$/.test(identifier)) {
            return identifier;
        }

        const fullUrl = `https://www.flickr.com/photos/${identifier}/`;
        const url = `https://www.flickr.com/services/rest/?method=flickr.urls.lookupUser&api_key=${apiKey}&url=${encodeURIComponent(fullUrl)}&format=json&nojsoncallback=1`;

        try {
            const data = await fetchJSON(url);
            return data.user?.id || null;
        } catch (e) {
            console.error("Erro ao resolver user_id via lookupUser:", e);
            return null;
        }
    }

    async function fetchJSON(url) {
        const res = await fetch(url);
        return res.json();
    }

    async function getPhotos(userId, apiKey, perPage = 100, maxPages = 2) {
        let photos = [];
        for (let page = 1; page <= maxPages; page++) {
            const url = `https://www.flickr.com/services/rest/?method=flickr.people.getPublicPhotos&api_key=${apiKey}&user_id=${userId}&format=json&nojsoncallback=1&per_page=${perPage}&page=${page}`;
            log(`📷 A obter fotos da página ${page}...`);
            const data = await fetchJSON(url);
            if (!data.photos?.photo?.length) break;
            photos = photos.concat(data.photos.photo);
            if (page >= data.photos.pages) break;
        }
        log(`✅ Total de fotos obtidas: ${photos.length}`);
        return photos;
    }

    async function getComments(photoId, apiKey) {
        const url = `https://www.flickr.com/services/rest/?method=flickr.photos.comments.getList&api_key=${apiKey}&photo_id=${photoId}&format=json&nojsoncallback=1`;
        const data = await fetchJSON(url);
        return (data.comments?.comment || []).map(c => ({
            user: c.authorname,
            username: c.realname || c.authorname,
            nsid: c.author,
            date: new Date(parseInt(c.datecreate, 10) * 1000)
        }));
    }

    function formatDate(date) {
        return date.toISOString().split("T")[0];
    }

    function createPanel(dataMap, totalPhotos) {
        let sortBy = getStored(STORAGE.sortMode, 'count');

        const sorted = () => {
            return Object.entries(dataMap).sort((a, b) => {
                if (sortBy === 'count') return b[1].count - a[1].count;
                return b[1].last - a[1].last;
            });
        };

        panel = document.createElement("div");
        panel.style.position = "fixed";
        panel.style.width = "600px";
        panel.style.height = "400px";
        panel.style.overflow = "auto hidden";
        panel.style.resize = "both";
        panel.style.zIndex = "10000";
        panel.style.border = "2px solid #0063dc";
        panel.style.borderRadius = "8px";
        panel.style.boxShadow = "0 0 10px rgba(0,0,0,0.3)";
        panel.style.fontFamily = "sans-serif";

        const savedPos = getStored(STORAGE.panelPos, { top: 100, left: 100 });
        panel.style.top = savedPos.top + 'px';
        panel.style.left = savedPos.left + 'px';

        let dark = getStored(STORAGE.darkMode, false);

        // Cabeçalho
        const header = document.createElement("div");
        header.style.background = "#0063dc";
        header.style.color = "#fff";
        header.style.padding = "6px 10px";
        header.style.cursor = "move";
        header.style.display = "flex";
        header.style.flexDirection = "column";
        header.style.gap = "4px";

        const titleRow = document.createElement("div");
        titleRow.style.display = "flex";
        titleRow.style.justifyContent = "space-between";
        titleRow.style.alignItems = "center";

        const titleSpan = document.createElement("span");
        titleSpan.textContent = "Resumo de Comentadores";
        titleRow.appendChild(titleSpan);

        const controls = document.createElement("div");

        const makeBtn = (text, title, onclick) => {
            const btn = document.createElement("button");
            btn.textContent = text;
            btn.title = title;
            btn.style.marginLeft = "6px";
            btn.style.cursor = "pointer";
            btn.onclick = onclick;
            return btn;
        };

        const closeBtn = makeBtn("✖", "Fechar", () => {
            cleanUp();
            if (btn) btn.disabled = false;
        });
        const darkBtn = makeBtn("🌙", "Alternar tema", () => {
            dark = !dark;
            setStored(STORAGE.darkMode, dark);
            applyTheme();
        });
        const sortBtn = makeBtn("↕️", "Alternar ordenação", () => {
            sortBy = sortBy === 'count' ? 'date' : 'count';
            setStored(STORAGE.sortMode, sortBy);
            updateContent();
        });

        [sortBtn, darkBtn, closeBtn].forEach(btn => controls.appendChild(btn));
        titleRow.appendChild(controls);
        header.appendChild(titleRow);

        // Progresso no header
        const progressContainer = document.createElement("div");
        progressContainer.style.display = "flex";
        progressContainer.style.alignItems = "center";
        progressContainer.style.gap = "8px";
        progressContainer.style.fontSize = "0.85em";
        progressContainer.style.opacity = "0.9";

        const smallSpinner = document.createElement("div");
        smallSpinner.style.width = "14px";
        smallSpinner.style.height = "14px";
        smallSpinner.style.border = "2px solid rgba(255,255,255,0.3)";
        smallSpinner.style.borderRadius = "50%";
        smallSpinner.style.borderTop = "2px solid #fff";
        smallSpinner.style.animation = "spin 1s linear infinite";
        smallSpinner.style.display = "none";

        const progressText = document.createElement("span");
        progressContainer.appendChild(smallSpinner);
        progressContainer.appendChild(progressText);
        header.appendChild(progressContainer);

        panel.appendChild(header);

        // Container principal
        const mainContainer = document.createElement("div");
        mainContainer.style.position = "relative";
        mainContainer.style.height = "calc(100% - 60px)";
        mainContainer.style.overflow = "auto";

        // Spinner grande central
        const bigSpinner = document.createElement("div");
        bigSpinner.style.position = "absolute";
        bigSpinner.style.top = "50%";
        bigSpinner.style.left = "50%";
        bigSpinner.style.transform = "translate(-50%, -50%)";
        bigSpinner.style.width = "60px";
        bigSpinner.style.height = "60px";
        bigSpinner.style.border = "6px solid rgba(0,99,220,0.2)";
        bigSpinner.style.borderRadius = "50%";
        bigSpinner.style.borderTop = "6px solid #0063dc";
        bigSpinner.style.animation = "spin 1s linear infinite";
        bigSpinner.style.display = "none";

        // Conteúdo
        const content = document.createElement("div");
        content.style.padding = "10px";
        content.style.display = "grid";
        content.style.gridTemplateColumns = "1fr auto auto";
        content.style.gap = "8px";
        content.style.alignItems = "center";
        content.style.fontSize = "14px";
        content.style.minHeight = "100%";

        // Adicionar animação
        const style = document.createElement("style");
        style.textContent = `
            @keyframes spin {
                0% { transform: translate(-50%, -50%) rotate(0deg); }
                100% { transform: translate(-50%, -50%) rotate(360deg); }
            }
        `;
        document.head.appendChild(style);

        mainContainer.appendChild(bigSpinner);
        mainContainer.appendChild(content);
        panel.appendChild(mainContainer);
        document.body.appendChild(panel);

        function applyTheme() {
            panel.style.background = dark ? "#1e1e1e" : "#fff";
            panel.style.color = dark ? "#ccc" : "#000";
            bigSpinner.style.border = dark ? "6px solid rgba(170,170,221,0.2)" : "6px solid rgba(0,99,220,0.2)";
            bigSpinner.style.borderTop = dark ? "6px solid #aad" : "6px solid #0063dc";
            smallSpinner.style.border = dark ? "2px solid rgba(170,170,221,0.3)" : "2px solid rgba(255,255,255,0.3)";
            smallSpinner.style.borderTop = dark ? "2px solid #aad" : "2px solid #fff";
        }

        function updateContent(processed = 0, total = totalPhotos) {
            if (processed === 0 && Object.keys(dataMap).length === 0) {
                bigSpinner.style.display = "block";
                content.style.display = "none";
            } else {
                bigSpinner.style.display = "none";
                content.style.display = "grid";
            }

            if (processed > 0 && processed < total) {
                smallSpinner.style.display = "block";
                progressText.textContent = `A processar: ${processed} / ${total} fotos`;
            } else if (processed > 0) {
                smallSpinner.style.display = "none";
                progressContainer.style.display = "none";
            } else {
                smallSpinner.style.display = "none";
                progressText.textContent = "";
            }

            content.innerHTML = "";

            ['Utilizador', 'Comentários', 'Último comentário'].forEach(h => {
                const el = document.createElement("div");
                el.textContent = h;
                el.style.fontWeight = "bold";
                el.style.position = "sticky";
                el.style.top = "0";
                el.style.background = dark ? "#1e1e1e" : "#fff";
                el.style.zIndex = "1";
                content.appendChild(el);
            });

            sorted().forEach(([user, info]) => {
                content.appendChild(userLink(info.username, info.nsid));
                content.appendChild(el(info.count));
                content.appendChild(el(formatDate(info.last)));
            });

            function el(text) {
                const d = document.createElement("div");
                d.textContent = text;
                return d;
            }

            function userLink(name, nsid) {
                const d = document.createElement("div");
                const a = document.createElement("a");
                a.href = `https://www.flickr.com/photos/${nsid}/`;
                a.textContent = name;
                a.target = "_blank";
                a.style.color = dark ? "#aad" : "#06c";
                a.style.textDecoration = "none";
                d.appendChild(a);
                return d;
            }
        }

        applyTheme();
        updateContent();

        // Função de arrastar
        let dragging = false, offsetX = 0, offsetY = 0;

        titleRow.onmousedown = e => {
            if (e.target.tagName === 'BUTTON') return;

            dragging = true;
            offsetX = e.clientX - panel.offsetLeft;
            offsetY = e.clientY - panel.offsetTop;
            e.preventDefault();
        };

        document.onmousemove = e => {
            if (dragging) {
                panel.style.left = (e.clientX - offsetX) + 'px';
                panel.style.top = (e.clientY - offsetY) + 'px';
                setStored(STORAGE.panelPos, {
                    top: parseInt(panel.style.top),
                    left: parseInt(panel.style.left)
                });
            }
        };

        document.onmouseup = () => dragging = false;

        return { updateContent };
    }

    async function run() {
        if (!isValidPage()) return;
        if (isRunning) return;

        isRunning = true;
        if (btn) btn.disabled = true;

        try {
            const apiKey = getApiKey();
            if (!apiKey) {
                cleanUp();
                return;
            }

            const nsid = await resolveUserId(apiKey);
            if (!nsid) {
                alert("❌ Não foi possível obter o ID do utilizador.");
                cleanUp();
                return;
            }

            const photos = await getPhotos(nsid, apiKey, 100, 2);
            if (!photos.length) {
                alert("⚠️ Sem fotos públicas.");
                cleanUp();
                return;
            }

            const commenters = {};
            const { updateContent } = createPanel(commenters, photos.length);
            let updateCounter = 0;

            for (let i = 0; i < photos.length; i++) {
                if (!isValidPage()) {
                    cleanUp();
                    return;
                }

                const photo = photos[i];
                log(`💬 Comentários da foto ${i + 1}/${photos.length} (ID ${photo.id})...`);
                const comments = await getComments(photo.id, apiKey);

                for (const { user, username, nsid, date } of comments) {
                    if (!commenters[user]) {
                        commenters[user] = { count: 1, last: date, nsid, username };
                    } else {
                        commenters[user].count++;
                        if (date > commenters[user].last) {
                            commenters[user].last = date;
                        }
                    }
                }

                updateCounter++;
                if (updateCounter >= 10 || i === photos.length - 1) {
                    updateContent(i + 1, photos.length);
                    updateCounter = 0;
                }

                await new Promise(r => setTimeout(r, 500));
            }

            log("📊 Resultado final:", commenters);
        } catch (error) {
            console.error("Erro durante execução:", error);
            cleanUp();
        }
    }

    // Inicialização
    setupUrlObserver();
    if (isValidPage()) {
        createStartButton();
    }
})();