NextDNS Manager

Manage Allow/Deny lists and view info in a nice looking GUI for NextDNS

您需要先安裝使用者腳本管理器擴展,如 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 and view info in a nice looking GUI for NextDNS 
// @namespace    https://nextdns.io/
// @license      MIT
// @version      2.8
// @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()))];

    let stopImport = false;

    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) => {
        let attempts = 0;
        while (attempts < 5) {
            try {
                const res = await fetch(`https://api.nextdns.io/profiles/${profileId}/${listType}`, {
                    method: "POST",
                    headers: {
                        "Content-Type": "application/json"
                    },
                    credentials: "include",
                    body: JSON.stringify({
                        id: domain,
                        active: true
                    })
                });
                if (res.status === 429 || !res.ok) {
                    attempts++;
                    await new Promise(r => setTimeout(r, 2000));
                } else {
                    return true;
                }
            } catch (e) {
                attempts++;
                await new Promise(r => setTimeout(r, 2000));
            }
        }
        console.warn(`Failed to add ${domain} after 5 retries`);
        return false;
    };

    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: 450px;
            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:6px; display:flex; align-items:center; gap:4px;">
        <button id="ndns-import" style="width:80px;background:#444;color:#fff;border:none;padding:6px;border-radius:4px;font-weight:bold;cursor:pointer;">Import</button>
        <div id="ndns-selected-file" style="flex:1; font-size:12px; color:#ccc; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">No file selected</div>
        <button id="ndns-stop" style="width:60px;background:#aa3333;color:#fff;border:none;padding:6px;border-radius:4px;font-weight:bold;cursor:pointer;">Stop</button>
    </div>
    <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;margin-bottom:6px;"></textarea>
    <button id="ndns-add-bulk" style="width:100%;margin-bottom:6px;background:#333;color:#fff;border:none;padding:6px;border-radius:4px;font-weight:bold;cursor:pointer;transition:0.2s;">Add Domains</button>
    <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 importBtn = contentDiv.querySelector("#ndns-import");
                const stopBtn = contentDiv.querySelector("#ndns-stop");
                const selectedFileLabel = contentDiv.querySelector("#ndns-selected-file");
                const status = contentDiv.querySelector("#ndns-status");

                addBulkBtn.onmouseenter = () => {
                    addBulkBtn.style.background = "#444";
                }
                addBulkBtn.onmouseleave = () => {
                    addBulkBtn.style.background = "#333";
                }
                importBtn.onmouseenter = () => {
                    importBtn.style.background = "#555";
                }
                importBtn.onmouseleave = () => {
                    importBtn.style.background = "#444";
                }
                stopBtn.onmouseenter = () => {
                    stopBtn.style.background = "#cc5555";
                }
                stopBtn.onmouseleave = () => {
                    stopBtn.style.background = "#aa3333";
                }

                stopBtn.onclick = () => {
                    stopImport = true;
                    status.innerText = "Import stopped.";
                };

                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");
                    stopImport = false;
                    status.innerText = `Adding ${domains.length} domains...`;
                    for (let i = 0; i < domains.length; i++) {
                        if (stopImport) break;
                        await addDomain(currentProfileId, currentListType, domains[i]);
                        status.innerText = `Added ${i+1}/${domains.length}: ${domains[i]}`;
                        await new Promise(r => setTimeout(r, 500));
                    }
                    bulkInput.value = "";
                    if (!stopImport) status.innerText = `Done! Added ${domains.length} domains.`;
                    await refreshList();
                };

                importBtn.onclick = async () => {
                    const input = document.createElement("input");
                    input.type = "file";
                    input.accept = ".txt";
                    input.onchange = async () => {
                        if (!input.files.length) return;
                        const file = input.files[0];
                        selectedFileLabel.innerText = `Selected file: ${file.name}`;
                        const text = await file.text();
                        const domains = parseDomains(text).slice(0, 200);
                        if (domains.length === 0) return alert("No valid domains in file.");
                        stopImport = false;
                        status.innerText = `Importing ${domains.length} domains...`;
                        for (let i = 0; i < domains.length; i++) {
                            if (stopImport) break;
                            await addDomain(currentProfileId, currentListType, domains[i]);
                            status.innerText = `Imported ${i+1}/${domains.length}: ${domains[i]}`;
                            await new Promise(r => setTimeout(r, 500));
                        }
                        if (!stopImport) status.innerText = `Done! Imported ${domains.length} domains.`;
                        await refreshList();
                    };
                    input.click();
                };

                await refreshList();

                // Live sync
                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 uiItems = Array.from(listDiv.querySelectorAll("div"));

                        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);

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

                        pageDomains.forEach(d => {
                            let uiItem = uiItems.find(i => i.querySelector("span").innerText === d.domain);
                            if (!uiItem) {
                                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);
})();