BlockPup

Blocks unwanted popups with whitelist/blocklist control and interactive dialogs.

目前為 2025-08-23 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         BlockPup
// @namespace    Violentmonkey Scripts
// @version      0.3
// @description  Blocks unwanted popups with whitelist/blocklist control and interactive dialogs.
// @author       0xArCHDeViL
// @match        *://*/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_registerMenuCommand
// @run-at       document-start
// @license MIT
// ==/UserScript==

(function() {
    'use strict';

    const createPatternManager = (storageKey, defaultPatterns = []) => ({
        STORAGE_KEY: storageKey,
        CACHE_DURATION: 5 * 60 * 1000,
        _cache: null,

        _patternToRegex(pattern) {
            const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
            return new RegExp(`^https?:\/\/${escaped}`, 'i');
        },

        async _load() {
            if (this._cache && this._cache.expires > Date.now()) {
                return this._cache.patterns;
            }
            const patterns = await GM_getValue(this.STORAGE_KEY, defaultPatterns);
            this._cache = {
                patterns: new Set(patterns),
                expires: Date.now() + this.CACHE_DURATION,
            };
            return this._cache.patterns;
        },

        async isMatch(url) {
            const patterns = await this._load();
            for (const pattern of patterns) {
                if (this._patternToRegex(pattern).test(url)) return true;
            }
            return false;
        },

        async add(pattern) {
            const patterns = await this._load();
            patterns.add(pattern);
            this._cache.patterns = patterns;
            await GM_setValue(this.STORAGE_KEY, Array.from(patterns));
        },

        async remove(pattern) {
            const patterns = await this._load();
            patterns.delete(pattern);
            this._cache.patterns = patterns;
            await GM_setValue(this.STORAGE_KEY, Array.from(patterns));
        },

        async getAll() {
            return Array.from(await this._load());
        }
    });

    const whitelistManager = createPatternManager('popup_whitelist_patterns', ['localhost', '127.0.0.1']);
    const blocklistManager = createPatternManager('popup_blocklist_patterns');

    const createManagementDialog = async (manager, title) => {
        document.querySelector('.popup-manager-container')?.remove();

        const container = document.createElement('div');
        container.className = 'popup-manager-container';
        const shadow = container.attachShadow({ mode: 'open' });
        const isDarkMode = window.matchMedia('(prefers-color-scheme: dark)').matches;
        const patterns = await manager.getAll();

        const style = document.createElement('style');
        style.textContent = `
            :host {
                --background: ${isDarkMode ? 'hsl(240 10% 3.9%)' : 'hsl(0 0% 100%)'};
                --foreground: ${isDarkMode ? 'hsl(0 0% 98%)' : 'hsl(240 10% 3.9%)'};
                --muted-foreground: ${isDarkMode ? 'hsl(240 3.7% 62.9%)' : 'hsl(240 3.7% 45.9%)'};
                --card: ${isDarkMode ? 'hsl(240 4.8% 12%)' : 'hsl(0 0% 100%)'};
                --border: ${isDarkMode ? 'hsl(240 3.7% 15.9%)' : 'hsl(240 5.9% 90%)'};
                --input: ${isDarkMode ? 'hsl(240 3.7% 15.9%)' : 'hsl(240 5.9% 90%)'};
                --primary: ${isDarkMode ? 'hsl(0 0% 98%)' : 'hsl(240 5.9% 10%)'};
                --primary-foreground: ${isDarkMode ? 'hsl(240 5.9% 10%)' : 'hsl(0 0% 98%)'};
                --destructive: ${isDarkMode ? 'hsl(0 62.8% 30.6%)' : 'hsl(0 84.2% 60.2%)'};
                --destructive-foreground: ${isDarkMode ? 'hsl(0 0% 98%)' : 'hsl(0 0% 98%)'};
                --overlay-bg: ${isDarkMode ? 'hsl(240 10% 3.9% / 0.5)' : 'hsl(0 0% 100% / 0.5)'};
            }
            .overlay {
                position: fixed; inset: 0; z-index: 2147483646;
                background-color: var(--overlay-bg);
                backdrop-filter: blur(8px);
                -webkit-backdrop-filter: blur(8px);
                display: flex; justify-content: center; align-items: center;
                font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
            }
            .dialog {
                background-color: var(--card); color: var(--foreground); border: 1px solid var(--border);
                border-radius: 0.75rem; width: 600px; max-width: 90vw; max-height: 80vh;
                display: flex; flex-direction: column; box-shadow: 0 10px 30px rgba(0,0,0,0.2);
                animation: fadeIn 0.2s ease-out;
            }
            @keyframes fadeIn { from { opacity: 0; transform: scale(0.95); } to { opacity: 1; transform: scale(1); } }
            .header { padding: 1.5rem; font-size: 1.125rem; font-weight: 600; border-bottom: 1px solid var(--border); }
            .content { padding: 1.5rem; flex-grow: 1; overflow-y: auto; max-height: 50vh; }
            .pattern-item { display: flex; justify-content: space-between; align-items: center; padding: 0.75rem; border-radius: 0.5rem; gap: 1rem; }
            .pattern-item:nth-child(odd) { background-color: ${isDarkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.02)'}; }
            .pattern-text { font-family: monospace; font-size: 0.875rem; flex-grow: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
            .footer { padding: 1.5rem; border-top: 1px solid var(--border); display: flex; gap: 0.75rem; }
            .input { flex-grow: 1; padding: 0.5rem 0.75rem; border: 1px solid var(--input); border-radius: 0.375rem; background-color: var(--background); color: var(--foreground); }
            .btn { padding: 0.5rem 1rem; border-radius: 0.375rem; border: none; cursor: pointer; font-weight: 500; }
            .btn-primary { background-color: var(--primary); color: var(--primary-foreground); }
            .btn-icon { background: none; border: none; cursor: pointer; padding: 0.5rem; border-radius: 50%; display: flex; align-items: center; justify-content: center; transition: background-color 0.2s; }
            .btn-icon:hover { background-color: ${isDarkMode ? 'rgba(255, 82, 82, 0.2)' : 'rgba(220, 53, 69, 0.1)'}; }
            .btn-icon svg { stroke: var(--muted-foreground); transition: stroke 0.2s; }
            .btn-icon:hover svg { stroke: var(--destructive); }
        `;

        const overlay = document.createElement('div');
        overlay.className = 'overlay';
        overlay.innerHTML = `
            <div class="dialog">
                <div class="header">${title}</div>
                <div class="content"></div>
                <div class="footer">
                    <input type="text" class="input" placeholder="e.g., *.google.com/*">
                    <button class="btn btn-primary">Add</button>
                </div>
            </div>
        `;

        const dialog = overlay.querySelector('.dialog');
        const content = dialog.querySelector('.content');
        const addInput = dialog.querySelector('.input');

        const renderList = () => {
            content.innerHTML = '';
            patterns.sort();
            patterns.forEach(pattern => {
                const item = document.createElement('div');
                item.className = 'pattern-item';
                item.innerHTML = `
                    <span class="pattern-text" title="${pattern}">${pattern}</span>
                    <button class="btn-icon" title="Remove">
                        <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
                            <path d="M3 6h18"></path>
                            <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
                            <line x1="10" y1="11" x2="10" y2="17"></line>
                            <line x1="14" y1="11" x2="14" y2="17"></line>
                        </svg>
                    </button>
                `;
                item.querySelector('.btn-icon').onclick = async () => {
                    const index = patterns.indexOf(pattern);
                    if (index > -1) patterns.splice(index, 1);
                    await manager.remove(pattern);
                    renderList();
                };
                content.appendChild(item);
            });
        };

        dialog.querySelector('.btn-primary').onclick = async () => {
            const newPattern = addInput.value.trim();
            if (newPattern && !patterns.includes(newPattern)) {
                patterns.push(newPattern);
                await manager.add(newPattern);
                addInput.value = '';
                renderList();
            }
        };

        overlay.onclick = (e) => { if (e.target === overlay) container.remove(); };
        dialog.onclick = (e) => e.stopPropagation();

        renderList();
        shadow.appendChild(style);
        shadow.appendChild(overlay);
        document.body.appendChild(container);
    };

    const initializePopupInterceptor = () => {
        const originalWindowOpen = window.open;

        const hijackedWindowOpen = async (url, name, features) => {
            const fullUrl = new URL(url, window.location.origin).href;

            if (await blocklistManager.isMatch(fullUrl)) {
                return null;
            }

            if (await whitelistManager.isMatch(fullUrl)) {
                originalWindowOpen(url, name, features);
            } else {
                createPopupDialog({ url: fullUrl, onAllow: () => originalWindowOpen(url, name, features) });
            }
            return null;
        };

        unsafeWindow.open = (url, name, features) => {
            hijackedWindowOpen(url, name, features);
            return null;
        };

        const createPopupDialog = ({ url, onAllow }) => {
            document.querySelector('.popup-blocker-container')?.remove();
            const container = document.createElement('div');
            container.className = 'popup-blocker-container';
            const shadow = container.attachShadow({ mode: 'open' });
            const isDarkMode = window.matchMedia('(prefers-color-scheme: dark)').matches;
            const targetDomain = new URL(url).hostname;
            const targetPath = new URL(url).pathname;

            const style = document.createElement('style');
            style.textContent = `
                :host {
                    --background: ${isDarkMode ? 'hsl(240 10% 3.9%)' : 'hsl(0 0% 100%)'};
                    --foreground: ${isDarkMode ? 'hsl(0 0% 98%)' : 'hsl(240 10% 3.9%)'};
                    --muted-foreground: ${isDarkMode ? 'hsl(240 3.7% 62.9%)' : 'hsl(240 3.7% 45.9%)'};
                    --card: ${isDarkMode ? 'hsl(240 4.8% 12%)' : 'hsl(0 0% 100%)'};
                    --border: ${isDarkMode ? 'hsl(240 3.7% 15.9%)' : 'hsl(240 5.9% 90%)'};
                    --input: ${isDarkMode ? 'hsl(240 3.7% 15.9%)' : 'hsl(240 5.9% 90%)'};
                    --primary: ${isDarkMode ? 'hsl(0 0% 98%)' : 'hsl(240 5.9% 10%)'};
                    --primary-foreground: ${isDarkMode ? 'hsl(240 5.9% 10%)' : 'hsl(0 0% 98%)'};
                    --secondary: ${isDarkMode ? 'hsl(240 3.7% 15.9%)' : 'hsl(240 4.9% 95.9%)'};
                    --secondary-foreground: ${isDarkMode ? 'hsl(0 0% 98%)' : 'hsl(240 5.9% 10%)'};
                    --destructive: ${isDarkMode ? 'hsl(0 72% 51%)' : 'hsl(0 84.2% 60.2%)'};
                    --destructive-foreground: ${isDarkMode ? 'hsl(0 0% 98%)' : 'hsl(0 0% 98%)'};
                    --constructive: ${isDarkMode ? 'hsl(142.1 70.6% 45.1%)' : 'hsl(142.1 76.2% 41.2%)'};
                    --constructive-foreground: ${isDarkMode ? 'hsl(144.9 80.4% 10%)' : 'hsl(0 0% 98%)'};
                    --overlay-bg: ${isDarkMode ? 'hsl(240 10% 3.9% / 0.5)' : 'hsl(0 0% 100% / 0.5)'};
                }
                .overlay {
                    position: fixed; inset: 0; z-index: 2147483647;
                    background-color: var(--overlay-bg);
                    backdrop-filter: blur(8px);
                    -webkit-backdrop-filter: blur(8px);
                    display: flex; justify-content: center; align-items: center;
                    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
                }
                .dialog {
                    background-color: var(--card); color: var(--foreground); border: 1px solid var(--border);
                    border-radius: 0.75rem; width: 400px; max-width: 90vw;
                    box-shadow: 0 10px 30px rgba(0,0,0,0.2); animation: fadeIn 0.2s ease-out;
                }
                @keyframes fadeIn { from { opacity: 0; transform: scale(0.95); } to { opacity: 1; transform: scale(1); } }
                .header { padding: 1rem 1.5rem; text-align: center; }
                .title { font-size: 1.125rem; font-weight: 600; }
                .description { font-size: 0.875rem; color: var(--muted-foreground); margin-top: 0.25rem; }
                .content { padding: 0 1.5rem 1.5rem; }
                .url-display { font-size: 0.8rem; background-color: ${isDarkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.02)'}; padding: 0.5rem 0.75rem; border-radius: 0.375rem; word-break: break-all; max-height: 90px; overflow-y: auto; text-align: left; border: 1px solid var(--border); }
                .footer { padding: 1.5rem; border-top: 1px solid var(--border); display: flex; flex-direction: column; gap: 0.75rem; }
                .btn { padding: 0.6rem; border-radius: 0.375rem; border: 1px solid transparent; cursor: pointer; font-weight: 500; width: 100%; }
                .btn-primary { background-color: var(--primary); color: var(--primary-foreground); border-color: var(--border); }
                .btn-secondary { background-color: var(--secondary); color: var(--secondary-foreground); border-color: var(--border); }
                .btn-destructive { background-color: var(--destructive); color: var(--destructive-foreground); border-color: transparent; }
                .btn-constructive { background-color: var(--constructive); color: var(--constructive-foreground); border-color: transparent; }
                .initial-buttons, .input-container { display: flex; flex-direction: column; gap: 0.75rem; }
                .input-container { display: none; margin-top: 0.5rem; animation: slideDown 0.3s ease-out; }
                @keyframes slideDown { from { opacity: 0; transform: translateY(-10px); } to { opacity: 1; translateY(0); } }
                .input-header { display: flex; justify-content: flex-end; margin-bottom: 0.5rem; }
                .btn-back { background: none; border: none; color: var(--muted-foreground); cursor: pointer; font-size: 0.875rem; padding: 0.25rem; }
                .input { width: 100%; padding: 0.5rem 0.75rem; box-sizing: border-box; border: 1px solid var(--input); border-radius: 0.375rem; background-color: var(--background); color: var(--foreground); margin-bottom: 0.5rem; }
            `;

            const overlay = document.createElement('div');
            overlay.className = 'overlay';
            overlay.innerHTML = `
                <div class="dialog">
                    <div class="header">
                        <div class="title">Popup Request</div>
                        <div class="description">A script is trying to open a new tab.</div>
                    </div>
                    <div class="content">
                        <div class="url-display">${url}</div>
                    </div>
                    <div class="footer">
                        <button class="btn btn-primary btn-allow-once">Allow Once</button>
                        <div class="initial-buttons">
                            <button class="btn btn-secondary btn-show-whitelist">Always Allow...</button>
                            <button class="btn btn-secondary btn-show-blocklist">Always Block...</button>
                        </div>
                        <div class="input-container whitelist-input-container">
                             <div class="input-header"><button class="btn-back">← Back</button></div>
                            <input type="text" class="input" value="${targetDomain}${targetPath === '/' ? '/*' : ''}">
                            <button class="btn btn-constructive btn-confirm-whitelist">Allow</button>
                        </div>
                         <div class="input-container blocklist-input-container">
                            <div class="input-header"><button class="btn-back">← Back</button></div>
                            <input type="text" class="input" value="${targetDomain}">
                            <button class="btn btn-destructive btn-confirm-blocklist">Block</button>
                        </div>
                    </div>
                </div>
            `;

            const dialog = overlay.querySelector('.dialog');
            const removeDialog = () => container.remove();

            const initialButtons = dialog.querySelector('.initial-buttons');
            const whitelistContainer = dialog.querySelector('.whitelist-input-container');
            const blocklistContainer = dialog.querySelector('.blocklist-input-container');
            const whitelistInput = whitelistContainer.querySelector('.input');
            const blocklistInput = blocklistContainer.querySelector('.input');

            const showInitialButtons = () => {
                initialButtons.style.display = 'flex';
                whitelistContainer.style.display = 'none';
                blocklistContainer.style.display = 'none';
            };

            dialog.querySelector('.btn-allow-once').onclick = () => { onAllow(); removeDialog(); };

            initialButtons.querySelector('.btn-show-whitelist').onclick = () => {
                initialButtons.style.display = 'none';
                whitelistContainer.style.display = 'block';
                whitelistInput.focus();
                whitelistInput.select();
            };

            initialButtons.querySelector('.btn-show-blocklist').onclick = () => {
                initialButtons.style.display = 'none';
                blocklistContainer.style.display = 'block';
                blocklistInput.focus();
                blocklistInput.select();
            };

            whitelistContainer.querySelector('.btn-back').onclick = showInitialButtons;
            blocklistContainer.querySelector('.btn-back').onclick = showInitialButtons;

            dialog.querySelector('.btn-confirm-whitelist').onclick = () => {
                const pattern = whitelistInput.value.trim();
                if (pattern) {
                    whitelistManager.add(pattern);
                    onAllow();
                    removeDialog();
                }
            };

            dialog.querySelector('.btn-confirm-blocklist').onclick = () => {
                const pattern = blocklistInput.value.trim();
                if (pattern) {
                    blocklistManager.add(pattern);
                    removeDialog();
                }
            };

            overlay.onclick = (e) => { if (e.target === overlay) removeDialog(); };
            dialog.onclick = (e) => e.stopPropagation();

            shadow.appendChild(style);
            shadow.appendChild(overlay);
            document.body.appendChild(container);
        };

        document.addEventListener('click', e => {
            const link = e.target.closest('a[target="_blank"]');
            if (link && link.href) {
                e.preventDefault();
                e.stopImmediatePropagation();
                hijackedWindowOpen(link.href);
            }
        }, true);

        document.addEventListener('submit', e => {
            const form = e.target.closest('form[target="_blank"]');
            if (form && form.action) {
                e.preventDefault();
                e.stopImmediatePropagation();
                hijackedWindowOpen(form.action);
            }
        }, true);
    };

    initializePopupInterceptor();
    GM_registerMenuCommand('Manage Whitelist', () => createManagementDialog(whitelistManager, 'Manage Whitelist Patterns'));
    GM_registerMenuCommand('Manage Blocklist', () => createManagementDialog(blocklistManager, 'Manage Blocklist Patterns'));

})();