SPA/MPA Detector

Detects if the current website is SPA or MPA. Floating panel with status, log, reset, close, and toggle. Hides on modals/captchas.

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         SPA/MPA Detector
// @namespace    http://tampermonkey.net/
// @version      2.1
// @description  Detects if the current website is SPA or MPA. Floating panel with status, log, reset, close, and toggle. Hides on modals/captchas.
// @author       Amr Fateem
// @license MIT
// @match        *://*/*
// @grant        GM_getValue
// @grant        GM_setValue
// @run-at       document-end
// ==/UserScript==

(function() {
    'use strict';

    // Hide if inside cross-origin iframe (e.g., Google reCAPTCHA, login modals)
    try {
        if (window.self !== window.top && window.location.hostname !== window.parent.location.hostname) {
            return; // Do not run/inject anything in cross-origin iframes
        }
    } catch (e) {
        return; // Same-origin policy error means cross-origin iframe → hide
    }

    let isSPA = false;
    let eventsLog = [];
    let lastHref = location.href;
    const domain = location.hostname;

    // Load persisted state
    const stored = GM_getValue(domain, null);
    if (stored) {
        isSPA = stored.isSPA || false;
        eventsLog = stored.eventsLog || [];
        eventsLog.push('Loaded previous detection state');
    }

    // Initial framework detection
    function detectFrameworks() {
        const frameworks = {
            React: !!window.React || !!window.ReactDOM || !!document.querySelector('[data-reactroot], [data-reactid]'),
            Vue: !!window.Vue,
            Angular: !!window.angular || !!window.ng,
            Svelte: !!document.querySelector('[svelte]'),
            WixThunderbolt: !!window.thunderboltVersion || !!window.wixBiSession
        };
        const detected = Object.keys(frameworks).filter(k => frameworks[k]);
        if (detected.length > 0) {
            isSPA = true;
            eventsLog.push(`Potential SPA frameworks detected: ${detected.join(', ')}`);
        }
    }
    detectFrameworks();

    // Monkey-patch History API
    if (typeof history.pushState === 'function') {
        const origPush = history.pushState;
        history.pushState = function(...args) {
            isSPA = true;
            eventsLog.push('History.pushState called');
            return origPush.apply(this, args);
        };
    }
    if (typeof history.replaceState === 'function') {
        const origReplace = history.replaceState;
        history.replaceState = function(...args) {
            isSPA = true;
            eventsLog.push('History.replaceState called');
            return origReplace.apply(this, args);
        };
    }

    // Events
    window.addEventListener('popstate', () => {
        isSPA = true;
        eventsLog.push('popstate event triggered');
    });
    window.addEventListener('hashchange', () => {
        isSPA = true;
        eventsLog.push('hashchange event triggered');
    });

    // URL polling
    setInterval(() => {
        if (location.href !== lastHref) {
            isSPA = true;
            eventsLog.push('URL changed without full reload');
            lastHref = location.href;
        }
    }, 300);

    // Save state periodically
    setInterval(() => {
        GM_setValue(domain, { isSPA, eventsLog });
    }, 2000);

    // Create main panel
    const panel = document.createElement('div');
    panel.id = 'spa-mpa-panel';
    panel.style.cssText = `
        position: fixed; bottom: 60px; right: 20px; width: 280px; max-height: 400px;
        background: white; border: 1px solid #ccc; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.2);
        padding: 10px; font-family: Arial, sans-serif; font-size: 13px; z-index: 999999;
        overflow: hidden; display: flex; flex-direction: column; transition: opacity 0.3s;
    `;

    // Close button
    const closeBtn = document.createElement('div');
    closeBtn.textContent = '×';
    closeBtn.style.cssText = `
        position: absolute; top: 5px; right: 8px; font-size: 20px; cursor: pointer;
        width: 20px; height: 20px; text-align: center; line-height: 20px;
        color: #999; border-radius: 50%;
    `;
    closeBtn.title = 'Close panel (click toggle button to reopen)';
    closeBtn.onclick = () => { panel.style.display = 'none'; toggleBtn.style.display = 'block'; };

    const header = document.createElement('div');
    header.innerHTML = '<strong>SPA/MPA Detector</strong>';
    header.style.marginBottom = '8px';

    const status = document.createElement('div');
    status.id = 'spa-mpa-status';
    status.style.fontWeight = 'bold';
    status.style.marginBottom = '10px';

    const logContainer = document.createElement('div');
    logContainer.id = 'spa-mpa-log';
    logContainer.style.flex = '1';
    logContainer.style.overflowY = 'auto';
    logContainer.style.border = '1px solid #eee';
    logContainer.style.padding = '5px';
    logContainer.style.fontSize = '12px';
    logContainer.style.background = '#f9f9f9';

    const resetBtn = document.createElement('button');
    resetBtn.textContent = 'Reset Detection';
    resetBtn.style.marginTop = '8px';
    resetBtn.onclick = () => {
        isSPA = false;
        eventsLog = ['Detection reset manually'];
        GM_setValue(domain, { isSPA: false, eventsLog });
        updateUI();
    };

    panel.appendChild(closeBtn);
    panel.appendChild(header);
    panel.appendChild(status);
    panel.appendChild(logContainer);
    panel.appendChild(resetBtn);

    // Toggle button (hidden initially)
    const toggleBtn = document.createElement('div');
    toggleBtn.textContent = 'SPA?';
    toggleBtn.style.cssText = `
        position: fixed; bottom: 20px; right: 20px; width: 50px; height: 50px;
        background: #007bff; color: white; border-radius: 50%; box-shadow: 0 4px 12px rgba(0,0,0,0.2);
        text-align: center; line-height: 50px; font-size: 14px; font-weight: bold; cursor: pointer;
        z-index: 999999; display: none;
    `;
    toggleBtn.onclick = () => {
        panel.style.display = 'flex';
        toggleBtn.style.display = 'none';
    };

    document.body.appendChild(panel);
    document.body.appendChild(toggleBtn);

    // Update UI function
    function updateUI() {
        let type = isSPA ? 'SPA (Single Page Application)' : 'MPA (Multi Page Application)';
        let confidence = eventsLog.length > 2 ? 'High confidence' : (eventsLog.length === 0 ? 'No navigation yet' : 'Medium confidence');

        if (eventsLog.some(l => l.includes('frameworks')) && !isSPA) {
            type = 'Hybrid (Frameworks detected but MPA navigation)';
        }

        status.textContent = `${type} — ${confidence}`;

        logContainer.innerHTML = eventsLog.map(l => `<div>${l}</div>`).join('') || '<div>No events logged yet.</div>';
        logContainer.scrollTop = logContainer.scrollHeight;
    }

    updateUI();
    setInterval(updateUI, 1000);

    // Optional: Toggle with ESC key
    document.addEventListener('keydown', e => {
        if (e.key === 'Escape') {
            if (panel.style.display !== 'none') {
                panel.style.display = 'none';
                toggleBtn.style.display = 'block';
            }
        }
    });

})();