NextDNS Manager

Manage Allow/Deny lists in NextDNS

目前為 2025-12-28 提交的版本,檢視 最新版本

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         NextDNS Manager
// @description  Manage Allow/Deny lists in NextDNS
// @namespace    https://nextdns.io/
// @license      MIT
// @version      2.7
// @match        https://my.nextdns.io/*
// @grant        none
// ==/UserScript==

(async function() {
    'use strict';

    const parseDomains = text => [...new Set((text.match(/\b(?:[a-z0-9-]+\.)+[a-z]{2,}\b/gi)||[]).map(d=>d.toLowerCase()))];

    const getProfiles = async () => {
        try{
            const res = await fetch("https://api.nextdns.io/accounts/@me?withProfiles=true", {credentials:"include"});
            if(!res.ok) return null;
            const data = await res.json();
            return data.profiles || [];
        }catch(e){return null;}
    };

    const getProfileInfo = async profileId => {
        const profiles = await getProfiles();
        if(!profiles) return null;
        return profiles.find(p => p.id === profileId);
    };

    const getProfileIdFromURL = () => {
        const match = window.location.pathname.match(/^\/([a-z0-9]+)\//i);
        return match ? match[1] : null;
    };

    const getListTypeFromURL = () => {
        if (window.location.pathname.includes("/allowlist")) return "allowlist";
        if (window.location.pathname.includes("/denylist")) return "denylist";
        return null;
    };

    const fetchList = async (profileId, listType) => {
        try{
            const res = await fetch(`https://api.nextdns.io/profiles/${profileId}/${listType}`, {credentials:"include"});
            const data = await res.json();
            return data.data || [];
        }catch(e){return [];}
    };

    const addDomain = async (profileId, listType, domain) => fetch(`https://api.nextdns.io/profiles/${profileId}/${listType}`, {
        method:"POST",
        headers:{"Content-Type":"application/json"},
        credentials:"include",
        body:JSON.stringify({id:domain, active:true})
    });

    const removeDomain = async (profileId, listType, domain) => fetch(`https://api.nextdns.io/profiles/${profileId}/${listType}/hex:${Array.from(domain).map(c=>c.charCodeAt(0).toString(16)).join('')}`, {
        method:"DELETE",
        credentials:"include"
    });

    const toggleDomainActive = async (profileId, listType, domain, active) => fetch(`https://api.nextdns.io/profiles/${profileId}/${listType}/hex:${Array.from(domain).map(c=>c.charCodeAt(0).toString(16)).join('')}`, {
        method:"PATCH",
        headers:{"Content-Type":"application/json"},
        credentials:"include",
        body: JSON.stringify({active})
    });

    let guiBox = null;

    const createGUI = async () => {
        if(guiBox) guiBox.remove();

        guiBox = document.createElement("div");
        guiBox.style.cssText = `
            position: fixed;
            top: 100px;
            right: 20px;
            z-index: 2147483647;
            width: 400px;
            background: #111;
            color: #fff;
            padding: 12px;
            border-radius: 8px;
            font-family: monospace;
            box-shadow: 0 0 15px rgba(0,0,0,0.8);
            display: flex;
            flex-direction: column;
            max-height: 650px;
        `;

        guiBox.innerHTML = `
            <div id="ndns-header" style="cursor:move; font-weight:bold; display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;">
                <div>
                    <div id="ndns-profile-name" style="font-size:16px;"></div>
                    <div id="ndns-profile-info" style="font-size:11px;opacity:0.7;"></div>
                    <div id="ndns-list-type" style="font-size:13px;font-weight:bold;margin-top:2px;"></div>
                </div>
                <button id="ndns-toggle" style="background:#222;color:#fff;border:none;padding:2px 6px;border-radius:3px;cursor:pointer;">Hide</button>
            </div>
            <div id="ndns-content" style="flex:1; display:flex; flex-direction:column; overflow:hidden;"></div>
        `;
        document.documentElement.appendChild(guiBox);

        const toggleBtn = guiBox.querySelector("#ndns-toggle");
        const contentDiv = guiBox.querySelector("#ndns-content");
        const profileNameEl = guiBox.querySelector("#ndns-profile-name");
        const profileInfoEl = guiBox.querySelector("#ndns-profile-info");
        const listTypeEl = guiBox.querySelector("#ndns-list-type");

        let hidden=false;
        toggleBtn.onclick = () => {
            hidden=!hidden;
            contentDiv.style.display=hidden?"none":"flex";
            toggleBtn.innerText=hidden?"Show":"Hide";
        };

        // Dragging
        const header = guiBox.querySelector("#ndns-header");
        let offsetX=0, offsetY=0, dragging=false;
        header.onmousedown = e => {dragging=true;offsetX=e.clientX - guiBox.getBoundingClientRect().left;offsetY=e.clientY - guiBox.getBoundingClientRect().top;};
        document.onmouseup = ()=>dragging=false;
        document.onmousemove = e => {if(dragging){guiBox.style.left=e.clientX-offsetX+"px"; guiBox.style.top=e.clientY-offsetY+"px";}};

        const refreshGUI = async () => {
            const currentProfileId = getProfileIdFromURL();
            const currentListType = getListTypeFromURL();
            const onSetupPage = window.location.pathname.includes("/setup");

            // Check login
            const profiles = await getProfiles();
            if(!profiles){
                profileNameEl.innerText = "Not logged in";
                profileInfoEl.innerText = "";
                listTypeEl.innerText = "";
                contentDiv.innerHTML = "<div style='color:red;'>Please log in to view your profiles and lists.</div>";
                document.title = "NextDNS - Not logged in";
                return;
            }

            // Update profile info
            const profile = await getProfileInfo(currentProfileId);
            if(profile){
                profileNameEl.innerText = profile.name;
                profileInfoEl.innerText = `ID: ${profile.id} | Role: ${profile.role}`;
                document.title = `NextDNS - ${profile.name} (${currentListType ? currentListType.toUpperCase() : ''})`;
            } else {
                profileNameEl.innerText = "Profile not found";
                profileInfoEl.innerText = "";
            }

            // Setup page
            if(onSetupPage && profile){
                const res = await fetch(`https://api.nextdns.io/profiles/${currentProfileId}/setup`, {credentials:"include"});
                const data = await res.json();
                listTypeEl.innerText = "SETUP";
                listTypeEl.style.color = "#66ccff";
                contentDiv.innerHTML = `
                    <div style="margin-bottom:6px;font-weight:bold;">Setup Info</div>
                    <div><span style="color:#ffcc00">IPv4:</span> ${data.data.ipv4.join(", ") || "None"}</div>
                    <div><span style="color:#ffcc00">IPv6:</span> ${data.data.ipv6.join(", ") || "None"}</div>
                    <div><span style="color:#66ff66">Linked IP:</span> ${data.data.linkedIp?.ip || "None"}</div>
                    <div><span style="color:#66ff66">Servers:</span> ${data.data.linkedIp?.servers.join(", ") || "None"}</div>
                    <div><span style="color:#66ccff">DNSCrypt:</span> ${data.data.dnscrypt || "None"}</div>
                `;
            }
            // Allow/Deny list page
            else if(currentListType && profile){
                listTypeEl.innerText = currentListType.toUpperCase();
                listTypeEl.style.color = currentListType==="denylist"?"#ff4136":"#2ecc40";

                contentDiv.innerHTML = `
                    <div style="margin-bottom:8px;">
                        <textarea id="ndns-bulk" placeholder="Paste multiple domains..." style="width:100%;height:80px;padding:6px;resize:none;background:#1c1c1c;color:#fff;border:none;border-radius:4px;"></textarea>
                        <button id="ndns-add-bulk" style="width:100%;margin-top:4px;background:#333;color:#fff;border:none;padding:6px;border-radius:4px;font-weight:bold;cursor:pointer;transition:0.2s;">Add Domains</button>
                    </div>
                    <div id="ndns-list" style="flex:1;overflow-y:auto;max-height:400px;"></div>
                    <div id="ndns-status" style="margin-top:6px;font-size:12px;opacity:0.8;"></div>
                `;

                const listDiv = contentDiv.querySelector("#ndns-list");
                const bulkInput = contentDiv.querySelector("#ndns-bulk");
                const addBulkBtn = contentDiv.querySelector("#ndns-add-bulk");
                const status = contentDiv.querySelector("#ndns-status");

                addBulkBtn.onmouseenter = ()=>{addBulkBtn.style.background="#444";}
                addBulkBtn.onmouseleave = ()=>{addBulkBtn.style.background="#333";}

                const refreshList = async () => {
                    const list = await fetchList(currentProfileId,currentListType);
                    listDiv.innerHTML="";

                    const borderColor = currentListType==="denylist"?"rgb(255,65,54)":"rgb(46,204,64)";
                    list.forEach(d=>{
                        const item = document.createElement("div");
                        item.style.cssText=`
                            display:flex;
                            align-items:center;
                            justify-content:space-between;
                            padding:6px;
                            border-left:4px solid ${borderColor};
                            margin-bottom:4px;
                            background:#1a1a1a;
                            border-radius:4px;
                        `;
                        const domainContainer = document.createElement("div");
                        domainContainer.style.cssText="display:flex;align-items:center;gap:6px;";
                        const img = document.createElement("img");
                        img.src=`https://favicons.nextdns.io/hex:${Array.from(d.id).map(c=>c.charCodeAt(0).toString(16)).join('')}@2x.png`;
                        img.style.cssText="width:16px;height:16px;";
                        const span = document.createElement("span");
                        span.style.cssText="word-break: break-all;";
                        span.innerText=d.id;
                        domainContainer.appendChild(img);
                        domainContainer.appendChild(span);

                        const controls = document.createElement("div");
                        controls.style.cssText="display:flex;align-items:center;gap:6px;";
                        const checkbox = document.createElement("input");
                        checkbox.type="checkbox";
                        checkbox.checked=d.active;
                        checkbox.onchange = async () => { await toggleDomainActive(currentProfileId,currentListType,d.id,checkbox.checked); };
                        const delBtn = document.createElement("button");
                        delBtn.innerText="🗑️";
                        delBtn.style.cssText="background:none;border:none;color:#fff;cursor:pointer;";
                        delBtn.onclick = async () => { await removeDomain(currentProfileId,currentListType,d.id); item.remove(); };
                        controls.appendChild(checkbox);
                        controls.appendChild(delBtn);

                        item.appendChild(domainContainer);
                        item.appendChild(controls);
                        listDiv.appendChild(item);
                    });
                };

                addBulkBtn.onclick = async () => {
                    const domains = parseDomains(bulkInput.value);
                    if(!domains.length) return alert("No valid domains found");
                    status.innerText=`Adding ${domains.length} domains...`;
                    for(let i=0;i<domains.length;i++){
                        await addDomain(currentProfileId,currentListType,domains[i]);
                        status.innerText=`Added ${i+1}/${domains.length}: ${domains[i]}`;
                        await new Promise(r=>setTimeout(r,150));
                    }
                    bulkInput.value="";
                    status.innerText=`Done! Added ${domains.length} domains.`;
                    refreshList();
                };

                await refreshList();

                // --- LIVE SYNC OBSERVER ---
                const pageList = document.querySelector(".list-group.list-group-flush");
                if(pageList){
                    const observer = new MutationObserver(() => {
                        const pageItems = Array.from(pageList.querySelectorAll(".list-group-item"));
                        const pageDomains = pageItems.map(item=>{
                            const span = item.querySelector("span.notranslate");
                            if(!span) return null;
                            const domain = span.innerText.replace(/^\*\./,"").trim();
                            const active = parseFloat(item.querySelector(".flex-grow-1")?.style.opacity||"1") > 0.5;
                            return {domain, active};
                        }).filter(d=>d);

                        // Current GUI items
                        const uiItems = Array.from(listDiv.querySelectorAll("div"));

                        // Remove deleted
                        uiItems.forEach(uiItem=>{
                            const domain = uiItem.querySelector("span")?.innerText;
                            if(domain && !pageDomains.some(d=>d.domain===domain)) uiItem.remove();
                        });

                        // Add new or update active state
                        pageDomains.forEach(d=>{
                            let uiItem = Array.from(listDiv.querySelectorAll("div")).find(i=>i.querySelector("span")?.innerText===d.domain);
                            if(!uiItem){
                                // Add new
                                const item = document.createElement("div");
                                item.style.cssText=`
                                    display:flex;
                                    align-items:center;
                                    justify-content:space-between;
                                    padding:6px;
                                    border-left:4px solid ${currentListType==="denylist"?"rgb(255,65,54)":"rgb(46,204,64)"};
                                    margin-bottom:4px;
                                    background:#1a1a1a;
                                    border-radius:4px;
                                `;
                                const domainContainer = document.createElement("div");
                                domainContainer.style.cssText="display:flex;align-items:center;gap:6px;";
                                const img = document.createElement("img");
                                img.src=`https://favicons.nextdns.io/hex:${Array.from(d.domain).map(c=>c.charCodeAt(0).toString(16)).join('')}@2x.png`;
                                img.style.cssText="width:16px;height:16px;";
                                const span = document.createElement("span");
                                span.style.cssText="word-break: break-all;";
                                span.innerText=d.domain;
                                domainContainer.appendChild(img);
                                domainContainer.appendChild(span);

                                const controls = document.createElement("div");
                                controls.style.cssText="display:flex;align-items:center;gap:6px;";
                                const checkbox = document.createElement("input");
                                checkbox.type="checkbox";
                                checkbox.checked=d.active;
                                checkbox.onchange = async () => { await toggleDomainActive(currentProfileId,currentListType,d.domain,checkbox.checked); };
                                const delBtn = document.createElement("button");
                                delBtn.innerText="🗑️";
                                delBtn.style.cssText="background:none;border:none;color:#fff;cursor:pointer;";
                                delBtn.onclick = async () => { await removeDomain(currentProfileId,currentListType,d.domain); item.remove(); };
                                controls.appendChild(checkbox);
                                controls.appendChild(delBtn);

                                item.appendChild(domainContainer);
                                item.appendChild(controls);
                                listDiv.appendChild(item);
                            } else {
                                const checkbox = uiItem.querySelector("input[type=checkbox]");
                                if(checkbox) checkbox.checked = d.active;
                            }
                        });
                    });

                    observer.observe(pageList, {childList:true, subtree:true});
                }
            }
        };

        await refreshGUI();

        let lastURL = location.href;
        setInterval(()=>{
            if(location.href!==lastURL){lastURL=location.href; refreshGUI(); lastURL=location.href;}
        },1000);
    };

    setTimeout(createGUI,1500);

})();