MusicBrainz: Editor Subscription Manager

Manages subscriptions, tracks name changes and detects deleted users.

Aby zainstalować ten skrypt, wymagana jest instalacje jednego z następujących rozszerzeń: Tampermonkey, Greasemonkey lub Violentmonkey.

You will need to install an extension such as Tampermonkey to install this script.

Aby zainstalować ten skrypt, wymagana jest instalacje jednego z następujących rozszerzeń: Tampermonkey, Violentmonkey.

Aby zainstalować ten skrypt, wymagana będzie instalacja rozszerzenia Tampermonkey lub Userscripts.

You will need to install an extension such as Tampermonkey to install this script.

Aby zainstalować ten skrypt, musisz zainstalować rozszerzenie menedżera skryptów użytkownika.

(Mam już menedżera skryptów użytkownika, pozwól mi to zainstalować!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

Będziesz musiał zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

Będziesz musiał zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

Musisz zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

(Mam już menedżera stylów użytkownika, pozwól mi to zainstalować!)

// ==UserScript==
// @name         MusicBrainz: Editor Subscription Manager
// @namespace    https://musicbrainz.org/user/chaban
// @version      0.2.2
// @tag          ai-created
// @description  Manages subscriptions, tracks name changes and detects deleted users.
// @author       chaban
// @license      MIT
// @match        *://*.musicbrainz.org/user/*
// @match        *://*.musicbrainz.eu/user/*
// @connect      self
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// ==/UserScript==

(function () {
    'use strict';

    // #region Configuration & Constants
    const SCRIPT_NAME = GM.info.script.name;
    const SCRIPT_KEY = 'UserJS.MusicBrainz.EditorSubscriptionManager';
    const CACHE_KEY = `${SCRIPT_KEY}.Cache`;
    const SETTINGS_KEY = `${SCRIPT_KEY}.Settings`;

    const CACHE_TTL_DAYS = 7;
    const CONCURRENCY_LIMIT = 5;
    const EDITORS_PER_PAGE = 100;
    const SPAMMER_TEXT = 'This user was blocked and their profile is hidden';

    const COLUMNS = [
        {
            id: 'select',
            header: '<input type="checkbox" id="esm-select-all">',
            className: 'col-cb',
            render: (e) => `<input type="checkbox" class="esm-select-row" data-id="${e.id}">`,
            sortable: false
        },
        {
            id: 'name',
            header: 'Name',
            getValue: (e) => e.name.toLowerCase(),
            render: (e) => {
                let html = `<a href="${e.profileUrl}" target="_blank"><b>${e.name}</b></a>`;
                if (e.previousNames?.length) {
                    html += `<br><span class="esm-aka" title="Known aliases">aka: ${e.previousNames.join(', ')}</span>`;
                }
                return html;
            }
        },
        {
            id: 'userType',
            header: 'Type',
            getValue: (e) => e.userType || '',
            render: (e) => e.userType || ''
        },
        {
            id: 'accepted',
            header: 'Acc.',
            className: 'esm-num',
            getValue: (e) => e.acceptedEdits || 0,
            render: (e) => (e.acceptedEdits || 0).toLocaleString()
        },
        {
            id: 'rejected',
            header: 'Rej.',
            className: 'esm-num',
            getValue: (e) => e.rejectedEdits || 0,
            render: (e) => {
                const val = e.rejectedEdits || 0;
                return val > 0 ? `<span class="esm-warn">${val.toLocaleString()}</span>` : val;
            }
        },
        {
            id: 'rate',
            header: '% Rej.',
            className: 'esm-num',
            getValue: (e) => e.rejectionRate || 0,
            render: (e) => {
                const val = e.rejectionRate || 0;
                const cls = val > 10 ? 'esm-bad-stat' : '';
                return `<span class="${cls}">${val.toFixed(1)}%</span>`;
            }
        },
        {
            id: 'lastEdit',
            header: 'Last Edit',
            getValue: (e) => e.lastEditDate || '',
            render: (e) => e.lastEditDate ? new Date(e.lastEditDate).toLocaleDateString() : 'N/A'
        },
        {
            id: 'since',
            header: 'Registered',
            getValue: (e) => e.memberSince || '',
            render: (e) => e.memberSince ? new Date(e.memberSince).toLocaleDateString() : 'N/A'
        },
        {
            id: 'cache',
            header: 'Cache',
            getValue: (e) => e.lastUpdated || 0,
            render: (e) => {
                if (!e.lastUpdated) return '';
                const age = (Date.now() - e.lastUpdated) / 86400000;
                return `<span class="${age > CACHE_TTL_DAYS ? 'esm-stale' : 'esm-fresh'}">${age < 0.1 ? 'Fresh' : age.toFixed(1) + 'd'}</span>`;
            }
        },
        {
            id: 'status',
            header: 'Status',
            getValue: (e) => {
                if (e.isDeleted) return 1;
                if (e.isLost) return 2;
                if (e.isSpammer) return 3;
                if (e.error) return 4;
                if (!e.isSubscribed) return 5;
                return 6;
            },
            render: (e) => {
                if (e.isDeleted) return '<b style="color:darkred">DELETED</b>';
                if (e.isLost) return '<b style="color:orange">LOST SUB</b>';
                if (!e.isSubscribed) return '<span style="color:#666">Visited Only</span>';
                if (e.isSpammer) return '<b style="color:red">SPAMMER</b>';
                if (e.error) return `ERR: ${e.error}`;
                return '<span style="color:green">Active</span>';
            }
        }
    ];

    let allEditorData = [];
    const sortState = { key: 'status', asc: true };
    // #endregion

    // #region Logging
    function log(msg, ...args) {
        console.log(`[${SCRIPT_NAME}] ${msg}`, ...args);
    }
    function error(msg, ...args) {
        console.error(`[${SCRIPT_NAME}] ${msg}`, ...args);
    }
    // #endregion

    // #region Network & Parsing Utilities
    async function request(method, url) {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method, url,
                onload: (res) => {
                    if (res.status === 404) resolve({ status: 404, doc: null, finalUrl: res.finalUrl });
                    else if (res.status >= 200 && res.status < 400) {
                        const doc = method === 'GET' && res.responseText ? new DOMParser().parseFromString(res.responseText, 'text/html') : null;
                        resolve({ status: res.status, doc, finalUrl: res.finalUrl });
                    } else reject(new Error(`HTTP ${res.status}`));
                },
                onerror: () => reject(new Error('Network error'))
            });
        });
    }

    function requestGet(url) {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: 'GET', url,
                onload: (res) => (res.status >= 200 && res.status < 400) ? resolve() : reject(new Error(`HTTP ${res.status}`)),
                onerror: () => reject(new Error('Network error'))
            });
        });
    }

    function parseStatNumber(text) {
        return text ? parseInt(text.replace(/,/g, '').match(/^(-?\d+)/)?.[1] || 0, 10) : 0;
    }

    function scrapeStats(doc) {
        const stats = { edits: {}, votes: {}, added: {}, secondary: {} };
        doc.querySelectorAll('table.statistics').forEach(table => {
            const header = table.querySelector('thead th')?.textContent || '';
            const type = header.includes('Edits') ? 'edits' : header.includes('Added') ? 'added' : header.includes('Tags') ? 'secondary' : null;

            if (type) {
                table.querySelectorAll('tbody tr').forEach(row => {
                    const k = row.querySelector('th')?.textContent.trim();
                    const v = row.querySelector('td')?.textContent;
                    if(k && v) stats[type][k] = parseStatNumber(v);
                });
            } else if (header.includes('Votes')) {
                table.querySelectorAll('tbody tr').forEach(row => {
                    const cells = row.querySelectorAll('td, th');
                    if(cells.length >= 3) stats.votes[cells[0].textContent.trim()] = parseStatNumber(cells[2].textContent);
                });
            }
        });
        return stats;
    }

    function parseProfile(doc, url, forceId = null) {
        const id = forceId || getEditorId(doc);
        if (!id) return { id: null, error: 'No ID found' };

        const hasUnsubLink = !!doc.querySelector('a[href*="/subscriptions/editor/remove"]');
        const pageText = doc.getElementById('page')?.textContent || '';
        const name = doc.querySelector('h1 a bdi')?.textContent.trim() || 'Unknown';

        const isDeleted = pageText.includes('user has been deleted') || name.startsWith('Deleted Editor #');

        const editor = {
            id, name,
            profileUrl: url,
            isSpammer: pageText.includes(SPAMMER_TEXT),
            isDeleted: isDeleted,
            isSubscribed: hasUnsubLink,
            isLost: hasUnsubLink ? false : undefined,
            stats: scrapeStats(doc),
            acceptedEdits: 0, rejectedEdits: 0, rejectionRate: 0,
            lastUpdated: Date.now()
        };

        if (editor.isSpammer || editor.isDeleted) return editor;

        const getMeta = (label) => [...doc.querySelectorAll('.profileinfo th')].find(th => th.textContent.trim() === label)?.nextElementSibling;

        editor.restrictions = getMeta('Restrictions:')?.textContent.trim();
        const dateStr = getMeta('Member since:')?.textContent.trim();
        if(dateStr) editor.memberSince = new Date(dateStr).toISOString();

        const typeNode = getMeta('User type:');
        if (typeNode) {
            const clone = typeNode.cloneNode(true);
            clone.querySelector('a[href*="nominate"]')?.remove();
            editor.userType = clone.textContent.replace(/\s+/g, ' ').replace(/\(\s*\)/g, '').trim();
            if (editor.userType.includes('Deleted user')) editor.isDeleted = true;
        }

        if (editor.isDeleted) return editor;

        editor.acceptedEdits = editor.stats.edits?.['Accepted'] || 0;
        editor.rejectedEdits = editor.stats.edits?.['Voted down'] || 0;
        const total = editor.acceptedEdits + editor.rejectedEdits;
        if (total > 0) editor.rejectionRate = (editor.rejectedEdits / total) * 100;

        return editor;
    }

    function getEditorId(doc) {
        const subLinks = [
            doc.querySelector('a[href*="/subscriptions/editor/remove"]')?.href, // Subscribed
            doc.querySelector('a[href*="/subscriptions/editor/add"]')?.href     // Not subscribed
        ];

        for (const h of subLinks) {
            const m = h?.match(/[?&]id=(\d+)/);
            if (m) return m[1];
        }
        return null;
    }

    async function fetchEditorFull(basicInfo) {
        try {
            const { status, doc, finalUrl } = await request('GET', basicInfo.profileUrl);

            if (status === 404) {
                const recovered = await tryResolveEditorById(basicInfo.id);
                if (recovered) return fetchEditorFull(recovered);
                return { ...basicInfo, isDeleted: true, error: 'Page 404', lastUpdated: Date.now() };
            }

            const effectiveUrl = finalUrl || basicInfo.profileUrl;
            const data = parseProfile(doc, effectiveUrl, basicInfo.id);

            if (data.error || data.isSpammer || data.isDeleted) return data;

            if ((data.stats.edits?.['Total'] || 0) > 0) {
                const { doc: editsDoc } = await request('GET', `${effectiveUrl}/edits`);
                if (editsDoc) {
                    const dateStr = editsDoc.querySelector('div.edit-header:not(.open) td.edit-expiration')?.lastChild?.textContent.trim();
                    if (dateStr) data.lastEditDate = new Date(dateStr).toISOString();
                    else if (editsDoc.querySelector('div.edit-header.open')) data.lastEditDate = new Date().toISOString();
                }
            }
            return data;
        } catch (e) { return { ...basicInfo, error: e.message }; }
    }

    async function tryResolveEditorById(id) {
        try {
            const url = `/search/edits?conditions.0.field=editor&conditions.0.operator=%3D&conditions.0.args.0=${id}`;
            const { doc } = await request('GET', url);
            if (!doc) return null;
            const userLink = doc.querySelector('.edit-list .subheader a[href^="/user/"]');
            if (userLink) {
                return { id, name: userLink.textContent.trim(), profileUrl: userLink.href };
            }
        } catch (e) { error(`Resolve ID failed:`, e); }
        return null;
    }

    function getTotalPages(doc) {
        const listItems = doc.querySelectorAll('#page > p + ul > li');
        const editorLi = [...listItems].find(li => li.textContent.includes(' editors'));
        const total = editorLi ? parseInt(editorLi.textContent.replace(/,/g,'')) : 0;
        return Math.ceil(total / EDITORS_PER_PAGE) || 1;
    }
    // #endregion

    // #region Cache & Storage
    const storage = {
        getSettings: () => { try { return { showVisited: false, ...JSON.parse(localStorage.getItem(SETTINGS_KEY)) }; } catch { return { showVisited: false }; } },
        saveSettings: (s) => localStorage.setItem(SETTINGS_KEY, JSON.stringify(s)),
        getCache: () => { try { return JSON.parse(localStorage.getItem(CACHE_KEY)) || {}; } catch { return {}; } },
        saveEditor: (e) => {
            const cache = storage.getCache();
            const prev = cache[e.id];
            if (prev?.name && prev.name !== e.name) {
                e.previousNames = [...(prev.previousNames || []), prev.name];
            } else if (prev?.previousNames) {
                e.previousNames = prev.previousNames;
            }
            // Preserve subscribed status if not explicitly set in the new object
            if (e.isSubscribed === undefined && prev) e.isSubscribed = prev.isSubscribed;

            cache[e.id] = { ...prev, ...e, lastUpdated: Date.now() };
            localStorage.setItem(CACHE_KEY, JSON.stringify(cache));
        },
        remove: (ids) => {
            const cache = storage.getCache();
            ids.forEach(id => delete cache[id]);
            localStorage.setItem(CACHE_KEY, JSON.stringify(cache));
        }
    };
    // #endregion

    // #region UI Rendering
    function updateReportStats() {
        let years = parseInt(document.getElementById('esm-inactive-years')?.value, 10);
        if (isNaN(years) || years < 1) {
            years = 1;
            const input = document.getElementById('esm-inactive-years');
            if(input) input.value = 1;
        }

        const cutoff = new Date();
        cutoff.setFullYear(cutoff.getFullYear() - years);

        const stats = {
            total: allEditorData.length,
            visited: allEditorData.filter(e => !e.isSubscribed).length,
            spammers: allEditorData.filter(e => e.isSpammer).length,
            highRejection: allEditorData.filter(e => (e.rejectionRate || 0) > 10).length,
            inactive: allEditorData.filter(e => e.isSubscribed && !e.isLost && !e.isSpammer && !e.error && (!e.lastEditDate || new Date(e.lastEditDate) < cutoff)).length,
            lost: allEditorData.filter(e => e.isLost).length,
            deleted: allEditorData.filter(e => e.isDeleted).length
        };

        const setTxt = (id, txt) => { const el = document.getElementById(id); if (el) el.textContent = txt; };
        setTxt('esm-stats-total', ` ${stats.total}`);
        setTxt('esm-stats-visited', ` ${stats.visited}`);
        setTxt('esm-stats-spammers', ` ${stats.spammers}`);
        setTxt('esm-inactive-count', ` ${stats.inactive}`);
        setTxt('esm-stats-high-rejection', ` ${stats.highRejection}`);
        setTxt('esm-stats-lost', ` ${stats.lost} (${stats.deleted} deleted)`);
        setTxt('esm-inactive-years-text', years);

        const spamBtn = document.getElementById('esm-unsub-spammers');
        if (spamBtn) {
            spamBtn.textContent = `Unsubscribe Spammers (${stats.spammers})`;
            spamBtn.disabled = stats.spammers === 0;
        }
    }

    function renderReportTable() {
        const tbody = document.querySelector('#esm-report-table tbody');
        if (!tbody) return;

        const settings = storage.getSettings();
        let displayData = settings.showVisited ? allEditorData : allEditorData.filter(e => e.isSubscribed);

        const { key, asc } = sortState;

        document.querySelectorAll('#esm-report-table th.esm-sortable').forEach(th => {
            th.classList.remove('sorted-asc', 'sorted-desc');
            if (th.dataset.id === key) {
                th.classList.add(asc ? 'sorted-asc' : 'sorted-desc');
            }
        });

        if (displayData.length === 0) {
            const msg = allEditorData.length > 0 ? 'No subscriptions to display (Visited profiles are hidden).' : 'No data found. Try scanning.';
            tbody.innerHTML = `<tr><td colspan="${COLUMNS.length}" style="text-align:center; padding:20px;">${msg}</td></tr>`;
            return;
        }

        const colDef = COLUMNS.find(c => c.id === key) || COLUMNS.find(c => c.id === 'status');

        displayData.sort((a, b) => {
            let valA = colDef.getValue(a);
            let valB = colDef.getValue(b);

            if (valA == null) valA = asc ? Infinity : -Infinity;
            if (valB == null) valB = asc ? Infinity : -Infinity;

            if (typeof valA === 'string') { valA = valA.toLowerCase(); valB = valB.toLowerCase(); }

            if (valA < valB) return asc ? -1 : 1;
            if (valA > valB) return asc ? 1 : -1;
            return 0;
        });

        tbody.innerHTML = displayData.map(e => {
            const rowClass = e.isDeleted ? 'esm-row-deleted' : e.isLost ? 'esm-row-lost' : e.isSpammer ? 'esm-row-spam' : !e.isSubscribed ? 'esm-row-visited' : '';
            const cells = COLUMNS.map(col => `<td class="${col.className || ''}">${col.render(e)}</td>`).join('');
            return `<tr class="${rowClass}">${cells}</tr>`;
        }).join('');
    }

    function buildReportUI() {
        const existing = document.getElementById('esm-report-ui');
        if (existing) existing.remove();
        const settings = storage.getSettings();
        const years = parseInt(document.getElementById('esm-inactive-years')?.value || '5', 10);

        const div = document.createElement('div');
        div.id = 'esm-report-ui';
        div.innerHTML = `
            <h1>Editor Subscription Manager</h1>
            <div class="esm-controls">
                <div class="esm-stats">
                    <ul>
                        <li><strong>Total Cached:</strong> <span id="esm-stats-total">...</span></li>
                        <li><strong>Visited Only:</strong> <span id="esm-stats-visited">...</span></li>
                        <li><strong>Spammers:</strong> <span id="esm-stats-spammers">...</span></li>
                        <li><strong>Inactive (> <span id="esm-inactive-years-text">${years}</span>y):</strong> <span id="esm-inactive-count">...</span></li>
                        <li><strong>High Rejection (>10%):</strong> <span id="esm-stats-high-rejection">...</span></li>
                        <li><strong>Lost/Deleted Subs:</strong> <span id="esm-stats-lost">...</span></li>
                    </ul>
                </div>
                <div class="esm-actions">
                    <div class="esm-action-row">
                        <button id="esm-refresh-new" title="Updates only new or expired entries">↻ Scan (Smart)</button>
                        <button id="esm-refresh-all" title="Force updates all cache entries">↻ Refresh (Full)</button>
                        <button id="esm-close-report">Close</button>
                    </div>
                    <div class="esm-action-row">
                        <label>Unsubscribe Inactive > </label>
                        <input type="number" id="esm-inactive-years" value="${years}" min="1" style="width:40px"> y
                        <button id="esm-unsub-inactive">Go</button>
                    </div>
                    <div class="esm-action-row">
                        <label style="cursor:pointer; display:flex; align-items:center;">
                            <input type="checkbox" id="esm-show-visited" style="margin-right:5px;"> Show Visited Profiles
                        </label>
                    </div>
                    <div class="esm-action-row">
                        <button id="esm-unsub-spammers" disabled>Unsubscribe Spammers</button>
                        <button id="esm-unsub-selected" disabled>Unsubscribe / Dismiss Selected</button>
                    </div>
                </div>
            </div>
            <table class="tbl" id="esm-report-table">
                <thead>
                    <tr>${COLUMNS.map(c => `<th class="${c.className || ''} ${c.sortable!==false?'esm-sortable':''}" data-id="${c.id}">${c.header}</th>`).join('')}</tr>
                </thead>
                <tbody></tbody>
            </table>
        `;

        document.getElementById('page').childNodes.forEach(c => { if(c.id !== 'esm-report-ui' && c.style) c.style.display = 'none'; });
        document.getElementById('page').appendChild(div);

        const getEl = (id) => document.getElementById(id);

        getEl('esm-show-visited').checked = settings.showVisited;

        getEl('esm-close-report').onclick = () => location.reload();
        getEl('esm-refresh-new').onclick = () => runManager('scan');
        getEl('esm-refresh-all').onclick = () => runManager('refresh');
        getEl('esm-show-visited').onchange = (e) => {
            const currentSettings = storage.getSettings();
            storage.saveSettings({ ...currentSettings, showVisited: e.target.checked });
            renderReportTable();
        };
        getEl('esm-inactive-years').oninput = updateReportStats;

        getEl('esm-report-table').querySelector('thead').onclick = (e) => {
            const th = e.target.closest('th.esm-sortable');
            if(th) {
                sortState.asc = sortState.key === th.dataset.id ? !sortState.asc : true;
                sortState.key = th.dataset.id;
                renderReportTable();
            }
        };

        const updateSel = () => {
            const n = document.querySelectorAll('.esm-select-row:checked').length;
            const btn = getEl('esm-unsub-selected');
            btn.textContent = `Unsubscribe / Dismiss Selected (${n})`;
            btn.disabled = n === 0;
        };
        getEl('esm-select-all').onchange = (e) => {
            document.querySelectorAll('.esm-select-row').forEach(c => c.checked = e.target.checked);
            updateSel();
        };
        getEl('esm-report-table').querySelector('tbody').onchange = (e) => { if(e.target.matches('.esm-select-row')) updateSel(); };

        const doUnsub = async (ids) => {
            if(!ids.length) return;
            const toUnsub = ids.filter(id => { const e = allEditorData.find(x=>x.id===id); return e && !e.isLost && e.isSubscribed; });

            if(toUnsub.length) {
                updateProgress(`Unsubscribing ${toUnsub.length}...`);
                await Promise.all(Array(CONCURRENCY_LIMIT).fill(0).map(async () => {
                    while(toUnsub.length) {
                        const id = toUnsub.shift();
                        try {
                            await requestGet(`${location.origin}/account/subscriptions/editor/remove?id=${id}`);
                        } catch(e){error(e);}
                    }
                }));
            }
            storage.remove(ids);
            allEditorData = allEditorData.filter(e => !ids.includes(e.id));
            renderReportTable();
            showProgress(false);
        };

        getEl('esm-unsub-selected').onclick = () => {
            const ids = [...document.querySelectorAll('.esm-select-row:checked')].map(c => c.dataset.id);
            if(confirm(`Process ${ids.length} selected items?`)) doUnsub(ids);
        };
        const spammers = allEditorData.filter(e => e.isSpammer);
        getEl('esm-unsub-spammers').textContent = `Unsubscribe Spam (${spammers.length})`;
        getEl('esm-unsub-spammers').disabled = !spammers.length;
        getEl('esm-unsub-spammers').onclick = () => { if(confirm('Unsubscribe spammers?')) doUnsub(spammers.map(e=>e.id)); };

        getEl('esm-unsub-inactive').onclick = () => {
            const y = parseInt(getEl('esm-inactive-years').value);
            const d = new Date(); d.setFullYear(d.getFullYear() - y);
            const ids = allEditorData.filter(e => e.isSubscribed && !e.isLost && !e.isSpammer && !e.error && (!e.lastEditDate || new Date(e.lastEditDate) < d)).map(e => e.id);
            if(confirm(`Unsubscribe ${ids.length} inactive?`)) doUnsub(ids);
        };

        updateReportStats();
        renderReportTable();
    }
    // #endregion

    // #region Logic Flow
    /**
     * @param {'scan'|'refresh'} mode
     */
    async function runManager(mode) {
        document.querySelectorAll('button').forEach(b => b.disabled = true);
        showProgress(true, 'Reading subscription list...');

        try {
            // 1. Sync Subscription List
            const { doc: page1 } = await request('GET', `${location.pathname}?page=1`);
            let totalPages = 1;
            if (page1) totalPages = getTotalPages(page1);

            const editorStubs = [];
            const scrapeList = (doc) => [...doc.querySelectorAll('form tbody tr')].map(tr => ({
                id: tr.querySelector('input')?.value,
                name: tr.querySelector('a')?.textContent.trim(),
                profileUrl: tr.querySelector('a')?.href,
                isSubscribed: true
            })).filter(x=>x.id);

            if(page1) editorStubs.push(...scrapeList(page1));

            for(let i=2; i<=totalPages; i++) {
                updateProgress(`Reading page ${i}/${totalPages}...`);
                const { doc } = await request('GET', `${location.pathname}?page=${i}`);
                if(doc) editorStubs.push(...scrapeList(doc));
            }

            const uniqueStubs = editorStubs.filter((e,i,a) => a.findIndex(x => x.id === e.id) === i);
            const currentSubIds = new Set(uniqueStubs.map(e => e.id));

            // 2. Prepare Cache & Queue
            let cache = storage.getCache();
            const queue = [];

            // Add subscribed stubs to processing queue or update status
            uniqueStubs.forEach(stub => {
                const cached = cache[stub.id];
                if (!cached) {
                    queue.push({ ...stub, reason: 'new' });
                } else {
                    cached.isSubscribed = true;
                    // Reset 'lost' status if it was previously lost
                    if (cached.isLost) cached.isLost = false;

                    const age = (Date.now() - (cached.lastUpdated || 0)) / 86400000;
                    if (mode === 'refresh' || age > CACHE_TTL_DAYS || cached.error) {
                        queue.push({ ...cached, reason: 'update' });
                    }
                }
            });

            // Mark missing subscriptions as lost in cache
            Object.values(cache).forEach(e => {
                if (e.isSubscribed && !currentSubIds.has(e.id)) {
                    e.isLost = true; // Potentially deleted or unsubscribed elsewhere
                    queue.push({ ...e, reason: 'lost_check' });
                } else if (mode === 'refresh' && !e.isSubscribed) {
                    // Refresh visited profiles too if full refresh
                    queue.push({ ...e, reason: 'refresh_visited' });
                }
            });

            // 3. Process Queue
            if(queue.length > 0) {
                updateProgress(`Updating ${queue.length} profiles...`);
                let processed = 0;

                const worker = async () => {
                    while(queue.length) {
                        const task = queue.shift();
                        let result;

                        // Just a quick check for lost items first to see if they are deleted or just unsubbed
                        if (task.reason === 'lost_check') {
                            result = await fetchEditorFull(task);
                            if (!result.error && !result.isDeleted) {
                                // Profile exists, so we just aren't subscribed anymore
                                result.isSubscribed = false;
                                result.isLost = false;
                            } else {
                                // Deleted or error
                                result.isSubscribed = true; // Keep as subbed so it shows as "LOST" or "DELETED" in list
                                result.isLost = !result.isDeleted;
                            }
                        } else {
                            result = await fetchEditorFull(task);
                            if (task.isSubscribed) result.isSubscribed = true;
                        }

                        storage.saveEditor(result);
                        processed++;
                        updateProgress(`Processed ${processed} profiles... (${result.name})`);
                    }
                };
                await Promise.all(Array(CONCURRENCY_LIMIT).fill(0).map(worker));
            }

            // Reload Cache to get everything
            cache = storage.getCache();
            allEditorData = Object.values(cache);

            showProgress(false);
            buildReportUI();

        } catch (e) { error(e); alert(e.message); showProgress(false); location.reload(); }
    }

    async function runPassiveScraper() {
        const id = getEditorId(document);

        log(`Passive Scraper: ID=${id}, URL=${location.href}`);

        if(!id) return;

        const notif = document.createElement('div');
        notif.id = 'esm-notification';
        notif.textContent = `ESM: Updating...`;
        Object.assign(notif.style, { position:'fixed', top:'10px', right:'10px', background:'#eee', padding:'5px 10px', border:'1px solid #999', zIndex:9999, fontSize:'12px', opacity: 0.9, transition: 'opacity 0.5s' });
        document.body.appendChild(notif);

        // Attach listeners to subscribe/unsubscribe buttons for immediate feedback
        const attachListeners = () => {
            const subBtn = document.querySelector('a[href*="/subscriptions/editor/add"]');
            const unsubBtn = document.querySelector('a[href*="/subscriptions/editor/remove"]');

            const handleManualChange = (isSub) => {
                const cache = storage.getCache();
                if(cache[id]) {
                    cache[id].isSubscribed = isSub;
                    cache[id].isLost = false;
                    storage.saveEditor(cache[id]);
                    const el = document.getElementById('esm-notification');
                    if(el) {
                        el.textContent = isSub ? 'ESM: Subscribed!' : 'ESM: Unsubscribed!';
                        el.style.background = '#e6f7ff';
                        setTimeout(() => el.style.opacity = '0', 2000);
                    }
                }
            };

            if(subBtn) subBtn.addEventListener('click', () => handleManualChange(true));
            if(unsubBtn) unsubBtn.addEventListener('click', () => handleManualChange(false));
        };
        attachListeners();

        try {
            // Use current page as source
            const data = parseProfile(document, location.href, id);

            // Check cache for previous name
            const cache = storage.getCache();
            const prev = cache[id];
            if (prev) {
                if(prev.name !== data.name) data.previousNames = [...(prev.previousNames||[]), prev.name];
                else data.previousNames = prev.previousNames;
                // Preserve existing subscription status if undetermined from UI (though parseProfile tries)
                if (data.isSubscribed === undefined) data.isSubscribed = prev.isSubscribed;
            }

            // Fetch extra edits data for "Last Edit" date
            const { doc } = await request('GET', `${location.origin}/user/${encodeURIComponent(data.name)}/edits`);
            if(doc) {
               const dateStr = doc.querySelector('div.edit-header:not(.open) td.edit-expiration')?.lastChild?.textContent.trim();
               if(dateStr) data.lastEditDate = new Date(dateStr).toISOString();
            }

            storage.saveEditor(data);

            notif.textContent = 'ESM: Profile Cached.';
            notif.style.background = '#ebfccb';
            notif.style.borderColor = 'green';
            setTimeout(()=>notif.remove(), 2000);
        } catch(e) {
            error(e);
            notif.style.background='#fccbcb';
            notif.style.borderColor = 'red';
            notif.textContent='ESM: Error';
            setTimeout(()=>notif.remove(), 3000);
        }
    }
    // #endregion

    // #region Helpers
    function showProgress(show, txt) {
        let el = document.getElementById('esm-progress');
        if(!el) {
            el = document.createElement('div');
            Object.assign(el.style, { position:'fixed', top:0, left:0, width:'100%', background:'#333', color:'#fff', textAlign:'center', padding:'10px', zIndex:10000 });
            el.id = 'esm-progress'; document.body.appendChild(el);
        }
        el.style.display = show ? 'block' : 'none';
        if(txt) el.textContent = txt;
    }
    function updateProgress(txt) { showProgress(true, txt); }

    function addStyles() {
        GM_addStyle(`
            #esm-report-ui { background:#fff; padding:20px; border:1px solid #ccc; margin-top:20px; }
            .esm-controls { display:flex; gap:20px; margin-bottom:20px; flex-wrap:wrap; border-bottom:1px solid #eee; padding-bottom:20px; }
            .esm-stats ul { list-style:none; padding:0; margin:0; }
            .esm-stats li { margin-bottom:5px; }
            .esm-action-row { margin-bottom:10px; display:flex; align-items:center; gap:10px; }
            #esm-report-table { width:100%; border-collapse:collapse; }
            #esm-report-table th, #esm-report-table td { border:1px solid #ddd; padding:6px; text-align:left; font-size:0.9em; }
            #esm-report-table th { background:#f9f9f9; position:relative; padding-right:20px; }
            #esm-report-table th:first-child { padding-right: 6px; }
            #esm-report-table th:first-child, #esm-report-table td:first-child { width: 25px; text-align: center; }
            #esm-report-table th.esm-sortable { cursor:pointer; }
            #esm-report-table th.esm-sortable:not(.sorted-asc):not(.sorted-desc):hover::after { content: '↕'; position: absolute; right: 5px; opacity: 0.6; font-size: 0.8em; }
            #esm-report-table th.esm-sortable.sorted-asc::after { content: '▲'; position: absolute; right: 5px; opacity: 1; font-size: 0.8em; }
            #esm-report-table th.esm-sortable.sorted-desc::after { content: '▼'; position: absolute; right: 5px; opacity: 1; font-size: 0.8em; }
            .esm-num { text-align:right !important; font-family:monospace; }
            .esm-bad-stat { color:red; font-weight:bold; }
            .esm-warn-stat { color:#d35400; }
            .esm-stale { color:#999; font-style:italic; }
            .esm-fresh { color:green; }
            .esm-aka { font-size: 0.85em; color: #666; font-style: italic; display:block; margin-top:2px; }
            .esm-row-deleted { background-color: #ffe6e6; opacity: 0.7; }
            .esm-row-lost { background-color: #fff9e6; }
            .esm-row-visited { background-color: #f7f7f7; color: #666; }
        `);
    }
    // #endregion

    function init() {
        const path = location.pathname;

        // 1. Subscription Management Page
        if (path.match(/\/user\/[^/]+\/subscriptions\/editor/)) {
            const h2 = document.querySelector('#page > h2');
            if (h2 && h2.textContent.includes('Editor subscriptions')) {
                addStyles();
                const container = document.createElement('div');
                container.style.display = 'inline-block';
                container.style.marginLeft = '20px';
                container.innerHTML = `<button id="esm-btn-open" style="font-weight:bold">Manage Subscriptions</button>`;
                h2.appendChild(container);
                document.getElementById('esm-btn-open').onclick = () => runManager();
            }
            return;
        }

        // 2. User Profile Page (Passive Scan)
        // Check if path matches /user/<name> but NOT sub-pages like /edits, /votes etc.
        const userMatch = path.match(/^\/user\/([^/]+)$/);
        if (userMatch) {
            runPassiveScraper();
        }
    }

    init();
})();