NextDNS Manager

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

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

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

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==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);
})();