Facebook 登入牆移除器

【桌面版網頁|未登入專用】移除登入提示與橫幅、解決頁面跳轉,並自動在新分頁開啟媒體以從根源上防止頁面死鎖。偵測到登入狀態時將自動停用。

// ==UserScript==
// @name         Facebook Login Wall Remover
// @name:en      Facebook Login Wall Remover
// @name:zh-TW   Facebook 登入牆移除器
// @name:ja      Facebook ログインウォールリムーバー
// @namespace    https://greasyfork.org/en/users/1467948-stonedkhajiit
// @version      0.1.1
// @description  [Desktop Site | Guest Mode Only] Removes login popups/banners, fixes page jumps, and opens media in a new tab to prevent deadlocks. Disables itself when logged in.
// @description:en [Desktop Site | Guest Mode Only] Removes login popups and banners, prevents page jumps, and automatically opens media in a new tab to prevent page deadlocks. Automatically disables itself when a logged-in state is detected.
// @description:zh-TW 【桌面版網頁|未登入專用】移除登入提示與橫幅、解決頁面跳轉,並自動在新分頁開啟媒體以從根源上防止頁面死鎖。偵測到登入狀態時將自動停用。
// @description:ja 【デスクトップサイト|未ログイン専用】ログインポップアップとバナーを削除し、ページのジャンプを修正します。デッドロックを防ぐためにメディアを新しいタブで自動的に開き、ログイン状態では自動的に無効になります。
// @author       StonedKhajiit
// @match        *://*.facebook.com/*
// @icon         https://www.facebook.com/favicon.ico
// @grant        GM_notification
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// @grant        GM_unregisterMenuCommand
// @run-at       document-start
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    // --- Configuration ---
    const CONFIG = {
        LOG_PREFIX: `[FB Login Wall Remover]`,
        THROTTLE_DELAY: 250,
        PROCESSED_MARKER: 'gm-processed',
        SCROLL_RESTORER: {
            CORRECTION_DURATION: 250, // ms: How long to force the scroll position.
            CORRECTION_FREQUENCY: 16, // ms: How often to force the scroll position (16ms ~ 60fps).
            WATCHER_FREQUENCY: 150, // ms: How often to check for modal changes.
            MODAL_GRACE_PERIOD: 300, // ms: Time to wait before considering a modal "closed" to ignore transitional states.
        },
        SELECTORS: {
            // Main component selectors
            POST_CONTAINER: 'div[role="article"]',
            MODAL_CONTAINER: 'div.__fb-light-mode',
            DIALOG: '[role="dialog"]',
            LOGIN_FORM: 'form#login_form, form[id="login_popup_cta_form"]',

            // Media links that trigger modals
            MEDIA_LINK: `
                a[href*="/photo"], a[href*="fbid="],
                a[href*="/videos/"], a[href*="/watch/"],
                a[href*="/reel/"]
            `.trim().replace(/\s+/g, ' '),

            // A robust, multi-language selector for close buttons
            CLOSE_BUTTON: `
                [aria-label="Close"][role="button"],
                [aria-label="關閉"][role="button"],
                [aria-label="閉じる"][role="button"],
                [aria-label="Cerrar"][role="button"],
                [aria-label="Fermer"][role="button"],
                [aria-label="Schließen"][role="button"],
                [aria-label="Fechar"][role="button"],
                [aria-label="Chiudi"][role="button"],
                [aria-label="Sluiten"][role="button"],
                [aria-label="Закрыть"][role="button"],
                [aria-label="Kapat"][role="button"],
                [aria-label="Zamknij"][role="button"],
                [aria-label="Tutup"][role="button"],
                [aria-label="Đóng"][role="button"],
                [aria-label="ปิด"][role="button"],
                [aria-label="Zatvori"][role="button"],
                [aria-label="Zavrieť"][role="button"],
                [aria-label="Zavřít"][role="button"],
                [aria-label="Bezárás"][role="button"],
                div[role="button"]:has(i[data-visualcompletion="css-img"])
            `.trim().replace(/\s+/g, ' '),
        },
    };

    // --- Internationalization ---
    const STRINGS = {
        en: {
            notificationTitle: 'Notice',
            notificationDeadlock: 'A non-closable login modal was hidden. The page is now locked and won\'t load new content.\n\nPro-Tip: To view media without locking your feed, open it in a new tab (middle-click). Please reload this page.',
            autoOpenMediaInNewTab: 'Auto-open media in new tab (prevents deadlock)',
            showDeadlockNotification: 'Show deadlock notification',
            hideUselessElements: 'Hide useless UI elements (for guest)',
        },
        'zh-TW': {
            notificationTitle: '注意',
            notificationDeadlock: '一個無關閉按鈕的登入提示已被隱藏,頁面現已鎖定且無法載入新內容。\n\n提示:為避免動態牆被鎖定,建議以新分頁(滑鼠中鍵)開啟圖片或影片。請重新整理此頁面以繼續。',
            autoOpenMediaInNewTab: '在新分頁開啟媒體 (防死鎖)',
            showDeadlockNotification: '顯示頁面死鎖通知',
            hideUselessElements: '隱藏訪客模式下的多餘介面',
        },
        ja: {
            notificationTitle: '通知',
            notificationDeadlock: '閉じるボタンのないログインモーダルを非表示にしました。ページがロックされ、新しいコンテンツは読み込まれません。\n\nヒント:フィードをロックせずにメディアを表示するには、新しいタブで開く(マウスの中央ボタンでクリック)ことをお勧めします。このページをリロードしてください。',
            autoOpenMediaInNewTab: 'メディアを新しいタブで開く (デッドロック防止)',
            showDeadlockNotification: 'デッドロック通知を表示',
            hideUselessElements: '不要なUI要素を非表示にする(ゲスト用)',
        },
    };

    /**
     * Determines the best language for UI strings based on browser settings.
     * @returns {object} The string object for the detected language.
     */
    function getStrings() {
        const lang = navigator.language.toLowerCase();
        if (lang.startsWith('ja')) return STRINGS.ja;
        if (lang.startsWith('zh')) return STRINGS['zh-TW'];
        return STRINGS.en;
    }

    /**
     * Checks if the user is logged into Facebook.
     * @returns {boolean} True if logged in, false otherwise.
     */
    function isLoggedIn() {
        // Presence of user-specific links indicates a logged-in state.
        if (document.querySelector('a[href="/friends/"]') || document.querySelector('a[href*="/watch/"]')) return true;
        // Presence of login forms indicates a logged-out state.
        if (document.querySelector('form#login_form') || document.querySelector('a[href*="/login/"]')) return false;
        // Default to not logged in if unsure.
        return false;
    }

    // --- Settings Manager ---
    const settings = {};
    const SettingsManager = {
        registeredCommands: [],
        definitions: [
            { key: 'autoOpenMediaInNewTab', label: 'Auto-open media in new tab (prevents deadlock)', type: 'toggle', defaultValue: true },
            { key: 'hideUselessElements', label: 'Hide useless UI elements (for guest)', type: 'toggle', defaultValue: true },
            { key: 'showDeadlockNotification', label: 'Show deadlock notification', type: 'toggle', defaultValue: true },
        ],
        init(T) {
            this.definitions.forEach(def => {
                settings[def.key] = GM_getValue(def.key, def.defaultValue);
            });
            this.renderMenu(T);
        },
        renderMenu(T) {
            this.registeredCommands.forEach(id => GM_unregisterMenuCommand(id));
            this.registeredCommands = [];
            this.definitions.forEach(def => {
                const status = settings[def.key] ? '✅' : '❌';
                const label = `${status} ${T[def.key] || def.label}`;
                this.registeredCommands.push(GM_registerMenuCommand(label, () => this.updateSetting(def.key, !settings[def.key], T)));
            });
        },
        updateSetting(key, value, T) {
            GM_setValue(key, value);
            settings[key] = value;
            this.renderMenu(T);
            // Inform user that a reload is needed for style changes to take effect.
            if (key === 'hideUselessElements') {
                alert('Please reload the page for this setting to take full effect.');
            }
        },
    };

    /**
     * Intercepts and blocks scroll event listeners to prevent the page from locking.
     * This aggressive strategy is applied at the earliest possible stage.
     */
    function setupInterceptors() {
        const originalAddEventListener = EventTarget.prototype.addEventListener;
        Object.defineProperty(EventTarget.prototype, 'addEventListener', {
            configurable: true,
            enumerable: true,
            get: () =>
                function(type, listener, options) {
                    if (type === 'scroll') return;
                    return originalAddEventListener.call(this, type, listener, options);
                },
        });
    }

    /**
     * Injects CSS rules to hide static UI elements for better performance.
     * @param {object} currentSettings - The current user settings object.
     */
    function injectHidingStyles(currentSettings) {
        const rules = [];

        // General login/register banner at the bottom.
        rules.push(`div[data-nosnippet]:has(a[href*="/login/"]):has(a[href*="/reg/"])`);

        // Top login banner, based on user settings.
        if (currentSettings.hideUselessElements) {
            rules.push(`div[role="banner"]:has(${CONFIG.SELECTORS.LOGIN_FORM})`);
        }

        if (rules.length === 0) return;

        const css = `${rules.join(',\n')} { display: none !important; }`;
        const styleElement = document.createElement('style');
        styleElement.textContent = css.trim();
        document.head.appendChild(styleElement);
        console.log(`${CONFIG.LOG_PREFIX} [StyleInjector] Injected ${rules.length} hiding rule(s).`);
    }

    /**
     * Counteracts page position jumps caused by opening media modals.
     * Facebook forcibly scrolls the background page to the top when a modal is opened.
     * This module records the scroll position just before the modal opens and restores
     * it after the modal is closed.
     * @returns {{init: function}} The scroll restorer instance.
     */
    function createScrollRestorer() {
        let restoreY = null;
        let watcherInterval = null;
        let correctionInterval = null;

        const stopWatcher = () => {
            if (!watcherInterval) return;
            clearInterval(watcherInterval);
            watcherInterval = null;
        };

        const forceScrollCorrection = () => {
            if (restoreY === null) return;
            if (correctionInterval) clearInterval(correctionInterval);

            const { CORRECTION_DURATION, CORRECTION_FREQUENCY } = CONFIG.SCROLL_RESTORER;
            const startTime = Date.now();
            const initialRestoreY = restoreY;

            correctionInterval = setInterval(() => {
                window.scrollTo({ top: initialRestoreY, behavior: 'instant' });
                if (Date.now() - startTime > CORRECTION_DURATION) {
                    clearInterval(correctionInterval);
                    correctionInterval = null;
                    restoreY = null;
                }
            }, CORRECTION_FREQUENCY);
        };

        const startWatcher = () => {
            stopWatcher();
            let isContentModalDetected = false;
            let modalFirstSeenTime = null;

            watcherInterval = setInterval(() => {
                const modal = document.querySelector(CONFIG.SELECTORS.DIALOG);

                if (!isContentModalDetected && modal && !modal.querySelector(CONFIG.SELECTORS.LOGIN_FORM)) {
                    isContentModalDetected = true;
                    modalFirstSeenTime = Date.now();
                } else if (isContentModalDetected && !modal) {
                    if (Date.now() - modalFirstSeenTime > CONFIG.SCROLL_RESTORER.MODAL_GRACE_PERIOD) {
                        stopWatcher();
                        forceScrollCorrection();
                    }
                }
            }, CONFIG.SCROLL_RESTORER.WATCHER_FREQUENCY);
        };

        const handleCloseClick = e => {
            const closeButton = e.target.closest(CONFIG.SELECTORS.CLOSE_BUTTON);
            if (closeButton && restoreY !== null && watcherInterval) {
                stopWatcher();
                forceScrollCorrection();
            }
        };

        const recordClick = e => {
            if (watcherInterval || correctionInterval) return;

            const postContainer = e.target.closest(CONFIG.SELECTORS.POST_CONTAINER);
            const externalLink = e.target.closest('a[target="_blank"]');

            if (postContainer && !externalLink) {
                restoreY = window.scrollY;
                // Delay watcher start to avoid race conditions with Facebook's event handling.
                // This ensures it runs after Facebook's own 'click' handlers have finished.
                setTimeout(startWatcher, 0);
            }
        };

        return {
            init: () => {
                document.body.addEventListener('click', recordClick, true);
                document.body.addEventListener('click', handleCloseClick, true);
                console.log(`${CONFIG.LOG_PREFIX} [ScrollRestorer] Activated.`);
            },
        };
    }

    /**
     * Proactively opens media links in a new tab to prevent page deadlocks
     * caused by non-closable login modals.
     */
    function createLinkInterceptor() {
        const handleClick = event => {
            if (!settings.autoOpenMediaInNewTab || event.button !== 0) return;

            const linkElement = event.target.closest(CONFIG.SELECTORS.MEDIA_LINK);
            if (linkElement && linkElement.closest(CONFIG.SELECTORS.POST_CONTAINER)) {
                console.log(`${CONFIG.LOG_PREFIX} [LinkInterceptor] High-risk media link clicked. Opening in new tab.`);
                event.preventDefault();
                event.stopPropagation();
                window.open(linkElement.href, '_blank');
            }
        };

        return {
            init: () => {
                document.body.addEventListener('click', handleClick, true);
                console.log(`${CONFIG.LOG_PREFIX} [LinkInterceptor] Activated.`);
            },
        };
    }

    /**
     * Finds and removes/modifies dynamic UI elements using a MutationObserver.
     * @param {object} T - The internationalized strings object.
     * @returns {{init: function}} The DOM cleaner instance.
     */
    function createDOMCleaner(T) {
        // Multi-language keywords for identifying action buttons.
        const PRIMARY_KEYWORDS = ["Like", "讚", "いいね!", "Me gusta", "J'aime", "Gefällt mir", "Curtir", "Mi piace", "Vind ik leuk", "Нравится", "Beğen", "Lubię to!", "Suka", "Thích", "ถูกใจ", "Sviđa mi se", "Páči sa mi to", "To se mi líbí", "Tetszik"];
        const SECONDARY_KEYWORDS = ["Comment", "留言", "コメントする", "Comentar", "Commenter", "Kommentieren", "Comentar", "Commento", "Reageren", "Комментировать", "Yorum Yap", "Skomentuj", "Komentari", "Bình luận", "แสดงความคิดเห็น", "Komentiraj", "Komentovať", "Okmentovat", "Hozzászólás"];

        const FINGERPRINTS = [
            // Handles the cookie consent dialog.
            {
                selector: `${CONFIG.SELECTORS.DIALOG}:has([href*="/policies/cookies/"]) [role="button"][tabindex="0"]`,
                action: 'click',
            },
            // Handles all login modals, closable or not.
            {
                selector: `${CONFIG.SELECTORS.DIALOG}:has(${CONFIG.SELECTORS.LOGIN_FORM})`,
                action: 'handle_login_modal',
            },
            // Fixes the sticky header that can sometimes appear.
            {
                selector: `div[style*="top:"][style*="z-index"]:has(div[role="tablist"])`,
                action: 'make_static',
                setting: 'hideUselessElements',
            },
        ];

        /**
         * Hides the main interaction toolbar ("Like", "Comment", etc.).
         * It locates the toolbar by finding buttons via their innerText, finds their
         * smallest common ancestor, and hides that ancestor's parent to avoid leaving
         * an empty container. This method is resilient to CSS class changes.
         */
        const hideInteractionToolbars = () => {
            const processedToolbars = new Set();
            // Scan only within post containers that haven't been fully processed for performance.
            const unprocessedPosts = document.querySelectorAll(`${CONFIG.SELECTORS.POST_CONTAINER}:not([data-gm-toolbar-processed])`);

            unprocessedPosts.forEach(post => {
                const allSpans = post.querySelectorAll('span');
                let foundToolbarForThisPost = false;

                for (const span of allSpans) {
                    const text = span.innerText.trim();

                    if (PRIMARY_KEYWORDS.includes(text)) {
                        let currentParent = span.parentElement;
                        let depth = 0;

                        while (currentParent && depth < 10) {
                            // Check if the parent also contains a secondary keyword.
                            const hasSecondary = Array.from(currentParent.querySelectorAll('span')).some(otherSpan => SECONDARY_KEYWORDS.includes(otherSpan.innerText.trim()));

                            // Ensure it's a multi-button container and not the entire post.
                            if (hasSecondary && currentParent.querySelectorAll('div[role="button"]').length > 1 && currentParent.getAttribute('role') !== 'article') {
                                const toolbar = currentParent;
                                // The actual container to hide is the parent of the toolbar.
                                const targetContainer = toolbar.parentElement;

                                if (targetContainer && !processedToolbars.has(targetContainer)) {
                                    processedToolbars.add(targetContainer);
                                    targetContainer.style.display = 'none';
                                    post.dataset.gmToolbarProcessed = 'true';
                                    foundToolbarForThisPost = true;
                                    break; // Exit the while loop for this span.
                                }
                            }

                            currentParent = currentParent.parentElement;
                            depth++;
                        }
                    }
                    if (foundToolbarForThisPost) break; // Exit the for...of loop for this post.
                }
            });
        };

        const showNotification = (title, text) => {
            if (!settings.showDeadlockNotification) return;
            GM_notification({ title, text, timeout: 8000, silent: true });
        };

        const runEngine = () => {
            for (const fingerprint of FINGERPRINTS) {
                // Skip rules disabled by user settings.
                if (fingerprint.setting && !settings[fingerprint.setting]) {
                    continue;
                }
                document.querySelectorAll(fingerprint.selector).forEach(element => {
                    if (element.dataset.gmProcessed) return;
                    element.dataset.gmProcessed = 'true';

                    switch (fingerprint.action) {
                        case 'click':
                            element.click();
                            break;
                        case 'make_static':
                            element.style.position = 'static';
                            break;
                        case 'handle_login_modal':
                            {
                                const closeButton = element.querySelector(CONFIG.SELECTORS.CLOSE_BUTTON);
                                if (closeButton) {
                                    closeButton.click();
                                } else {
                                    // Handle non-closable modals.
                                    const allContainers = document.querySelectorAll(CONFIG.SELECTORS.MODAL_CONTAINER);
                                    const parentContainer = Array.from(allContainers).find(c => c.contains(element));
                                    if (parentContainer) {
                                        parentContainer.style.display = 'none';
                                        console.warn(`${CONFIG.LOG_PREFIX} Non-closable modal hidden. Page deadlocked.`);
                                        showNotification(T.notificationTitle, T.notificationDeadlock);
                                    }
                                }
                                break;
                            }
                    }
                });
            }

            if (settings.hideUselessElements) {
                hideInteractionToolbars();
            }
        };

        const throttle = (func, delay) => {
            let timeoutId = null;
            return (...args) => {
                if (timeoutId === null) {
                    timeoutId = setTimeout(() => {
                        func.apply(this, args);
                        timeoutId = null;
                    }, delay);
                }
            };
        };

        return {
            init: () => {
                const throttledEngine = throttle(runEngine, CONFIG.THROTTLE_DELAY);
                throttledEngine();
                const observer = new MutationObserver(throttledEngine);
                observer.observe(document.documentElement, { childList: true, subtree: true });
            },
        };
    }

    // --- Script Execution ---

    if (isLoggedIn()) {
        console.log(`${CONFIG.LOG_PREFIX} Logged-in state detected. Script terminated.`);
        return;
    }
    console.log(`${CONFIG.LOG_PREFIX} Logged-out state detected. Script active.`);

    setupInterceptors();

    window.addEventListener('DOMContentLoaded', () => {
        const T = getStrings();

        SettingsManager.init(T);

        // Inject CSS hiding rules based on initial settings.
        injectHidingStyles(settings);

        const scrollRestorer = createScrollRestorer();
        const domCleaner = createDOMCleaner(T);
        const linkInterceptor = createLinkInterceptor();

        scrollRestorer.init();
        domCleaner.init();
        linkInterceptor.init();
    });
})();