Iwara Video Node Manual

手动节点切换 + Ping测速 + 可用性测试 + 默认节点智能跟随(兼容 iwara.tv / iwara.ai)

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

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

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

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

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

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

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

Advertisement:

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

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

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

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

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

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

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

Advertisement:

// ==UserScript==
// @name         Iwara Video Node Manual
// @icon         https://www.google.com/s2/favicons?sz=64&domain=iwara.tv
// @namespace    https://github.com/dawn-lc/
// @version      0.3.2
// @description  手动节点切换 + Ping测速 + 可用性测试 + 默认节点智能跟随(兼容 iwara.tv / iwara.ai)
// @author       dawn-lc + Grok
// @include      *://*.iwara.tv/*
// @include      *://*.iwara.ai/*
// @grant        unsafeWindow
// @run-at       document-start
// @license      MIT
// ==/UserScript==

(function () {
    'use strict';

    if (unsafeWindow.IwaraVideoNodeReplacer) return;
    unsafeWindow.IwaraVideoNodeReplacer = true;

    // 重要:无论页面是在 iwara.tv 还是 iwara.ai 上打开的,
    // 视频文件 / CDN 节点实际上始终只存在于 iwara.tv 域名下
    // (iwara.ai 只是另一个入口域名,播放走的还是 .tv 的节点)。
    // 因此这里固定使用 iwara.tv,不要跟随当前页面的 hostname。
    const SITE_DOMAIN = 'iwara.tv';

    // 匹配 "//xxx.iwara.tv" 或 "//xxx.iwara.ai" 的通用正则
    const NODE_HOST_REGEX_G = /\/\/[^.\/]+\.iwara\.(?:tv|ai)/g;
    const NODE_HOST_REGEX = /\/\/[^.\/]+\.iwara\.(?:tv|ai)/;
    // 提取节点名 + 域名后缀,例如 hime.iwara.tv -> ['hime', 'tv']
    const NODE_HOST_MATCH = /\/\/([^.\/]+)\.iwara\.(tv|ai)/;

    console.log(`[Iwara Node Replacer] 脚本已启动 v0.3.2(页面: ${location.hostname},节点统一走: ${SITE_DOMAIN})`);

    const locale = unsafeWindow.localStorage.getItem('locale') || 'zh';
    const I18N = {
        en: { title: 'Video Node', retest: 'Retest', availability: 'Test Avail', enable: 'On', current: 'Current', custom: 'Custom', default: 'Default' },
        zh: { title: '视频节点', retest: '重新测速', availability: '测试可用', enable: '开启', current: '当前', custom: '自定义', default: '默认' },
        ja: { title: '動画ノード', retest: '再テスト', availability: '可用性テスト', enable: '有効', current: '現在', custom: 'カスタム', default: 'デフォルト' }
    };
    const t = I18N[locale] || I18N.zh;

    const NODE_KEY = 'UseVideoNode';
    const ENABLE_KEY = 'UseVideoNodeEnabled';

    let currentNode = unsafeWindow.localStorage.getItem(NODE_KEY) || 'hime';
    let defaultNode = null;
    let isEnabled = unsafeWindow.localStorage.getItem(ENABLE_KEY) !== '0';

    let nodeList = ['hime', 'mikoto'];
    let pingMap = {};
    let availabilityMap = {};     // 新增:可用性结果 {node: true/false}
    let currentFileInfo = null;   // 当前视频的文件参数

    let panel = null;
    let selectElement = null;

    async function testNodeSpeed(node) {
        if (!node) return;
        const start = Date.now();
        try {
            const controller = new AbortController();
            setTimeout(() => controller.abort(), 7000);
            await fetch(`https://${node}.${SITE_DOMAIN}/favicon.ico`, {
                method: 'HEAD', mode: 'no-cors', signal: controller.signal
            });
            pingMap[node] = Math.min(Date.now() - start, 9999);
        } catch {
            pingMap[node] = 99999;
        }
    }

    async function testNodeAvailability(node) {
        // 说明:iwara.ai 页面访问 xxx.iwara.tv 是跨域请求。
        // 用 fetch 读取 res.status 在跨域下行不通——
        // 跨域响应会变成 "opaque response",status 恒为 0,
        // 永远无法等于 206,这是浏览器的安全限制,不是 bug,无法用 fetch 绕过。
        // <video> 标签加载媒体资源不受 CORS 同源限制(与实际播放路径一致),
        // 所以改用一个隐藏的 <video> 元素去真实尝试加载,以此判断节点是否可用。
        try {
            const video = document.querySelector('video');
            if (!video?.src) {
                return false;
            }

            const testUrl = video.src.replace(
                NODE_HOST_REGEX,
                `//${node}.${SITE_DOMAIN}`
            );

            return await new Promise(resolve => {
                const testVideo = document.createElement('video');
                testVideo.preload = 'metadata';
                testVideo.muted = true;
                testVideo.style.display = 'none';

                let settled = false;
                const finish = (ok) => {
                    if (settled) return;
                    settled = true;
                    clearTimeout(timer);
                    testVideo.removeAttribute('src');
                    testVideo.load();
                    testVideo.remove();
                    resolve(ok);
                };

                const timer = setTimeout(() => finish(false), 6000);

                testVideo.addEventListener('loadedmetadata', () => finish(true), { once: true });
                testVideo.addEventListener('error', () => finish(false), { once: true });

                testVideo.src = testUrl;
                document.body.appendChild(testVideo);
            });

        } catch {
            return false;
        }
    }

    async function updatePing() {
        const nodesToTest = [...new Set([...nodeList, currentNode, defaultNode].filter(Boolean))];
        await Promise.all(nodesToTest.map(testNodeSpeed));
    }

    async function testAllAvailability() {
        const nodesToTest = [...new Set([...nodeList, currentNode, defaultNode].filter(Boolean))];
        for (const node of nodesToTest) {
            await testNodeAvailability(node);
        }
    }

    async function applyNode(silent = false) {
        unsafeWindow.localStorage.setItem(NODE_KEY, currentNode);
        if (!silent) updatePanelDisplay();

        const video = document.querySelector('video');
        if (video?.src) {
            const ct = video.currentTime || 0;
            const playing = !video.paused;
            video.src = video.src.replace(NODE_HOST_REGEX_G, `//${currentNode}.${SITE_DOMAIN}`);
            video.load();
            if (playing) setTimeout(() => video.play().catch(() => {}), 300);
            if (ct > 0) video.currentTime = ct;
            return;
        }
        if (!silent) location.reload();
    }

    function refreshSelect() {
        if (!selectElement) return;

        selectElement.innerHTML = '';
        const nodes = [...new Set([...nodeList, currentNode, defaultNode].filter(Boolean))];

        nodes.forEach(node => {
            let label = node;
           if (pingMap[node] !== undefined) {
               label += ` (${pingMap[node]}ms)`;
           }
            if (availabilityMap[node] !== undefined) {
                label += availabilityMap[node] ? ' ✅' : ' ❌';
            }
            const opt = new Option(label, node);
            if (node === currentNode) opt.selected = true;
            selectElement.appendChild(opt);
        });

        if (defaultNode) {
            const defOpt = new Option(`★ ${t.default} (${defaultNode})`, defaultNode);
            if (defaultNode === currentNode) defOpt.selected = true;
            selectElement.appendChild(defOpt);
        }

        const customOpt = new Option(`→ ${t.custom}`, '__custom__');
        selectElement.appendChild(customOpt);
    }

    function createPanel() {
        if (panel) return;

        panel = document.createElement('div');
        panel.style.cssText = `
            position: fixed;
            left: 16px;
            bottom: 16px;
            z-index: 2147483647;
            background: rgba(0,0,0,0.9);
            color: #fff;
            padding: 8px;
            border-radius: 8px;
            font-size: 13px;
            font-family: system-ui;
            min-width: 170px;
            box-shadow: 0 6px 18px rgba(0,0,0,0.75);
            border: 1px solid #555;
        `;

        panel.innerHTML = `
            <div style="font-weight:bold;margin-bottom:5px;font-size:13.5px;">${t.title} <span style="opacity:.6;font-weight:normal;">(${location.hostname})</span></div>

            <div style="margin-bottom:6px;font-size:13px;color:#0f0;">
                ${t.current}: <strong id="currentNodeDisplay">${currentNode}</strong>
            </div>

            <label style="display:flex;align-items:center;gap:5px;margin-bottom:6px;cursor:pointer;">
                <input type="checkbox" id="enable" ${isEnabled ? 'checked' : ''}>
                <span style="font-size:13px;">${t.enable}</span>
            </label>

            <div style="margin:6px 0 7px 0;">
                <select id="nodeSelect" style="width:100%;padding:4px 6px;border-radius:4px;background:#2a2a2a;color:#fff;border:1px solid #444;font-size:13px;"></select>
            </div>

            <div id="customContainer" style="display:none;margin:6px 0;">
                <input type="text" id="manual" placeholder="自定义节点"
                       style="width:100%;padding:4px 6px;border-radius:4px;background:#2a2a2a;color:#fff;border:1px solid #444;font-size:13px;">
            </div>

            <div style="display:flex;gap:5px;margin-top:8px;">
                <button id="testBtn" style="flex:1;padding:5px 8px;background:#0066ff;color:white;border:none;border-radius:4px;cursor:pointer;font-size:13px;">
                    ${t.retest}
                </button>
                <button id="availBtn" style="flex:1;padding:5px 8px;background:#ff8800;color:white;border:none;border-radius:4px;cursor:pointer;font-size:13px;">
                    ${t.availability}
                </button>
            </div>
        `;

        document.documentElement.appendChild(panel);

        selectElement = panel.querySelector('#nodeSelect');
        const customContainer = panel.querySelector('#customContainer');
        const manualInput = panel.querySelector('#manual');

        refreshSelect();

        selectElement.addEventListener('change', () => {
            const val = selectElement.value;
            if (val === '__custom__') {
                customContainer.style.display = 'block';
                manualInput.focus();
            } else {
                customContainer.style.display = 'none';
                if (currentNode !== val) {
                    currentNode = val;
                    applyNode();
                }
            }
        });

        manualInput.addEventListener('blur', () => {
            const v = manualInput.value.trim().toLowerCase();
            if (/^[a-z0-9.-]+$/.test(v) && currentNode !== v) {
                currentNode = v;
                applyNode();
                refreshSelect();
            }
        });

        panel.querySelector('#enable').addEventListener('change', e => {
            unsafeWindow.localStorage.setItem(ENABLE_KEY, e.target.checked ? '1' : '0');
            location.reload();
        });

        panel.querySelector('#testBtn').addEventListener('click', async () => {
            const btn = panel.querySelector('#testBtn');
            btn.textContent = '测速中...';
            btn.disabled = true;
            await updatePing();
            refreshSelect();
            btn.textContent = '✅ 完成';
            setTimeout(() => { btn.textContent = t.retest; btn.disabled = false; }, 1200);
        });

        // 新增可用性测试按钮
        panel.querySelector('#availBtn').addEventListener('click', async () => {
            const btn = panel.querySelector('#availBtn');
            btn.textContent = '测试中...';
            btn.disabled = true;
            const nodes = [...new Set([
                ...nodeList,
                currentNode,
                defaultNode
            ].filter(Boolean))];

            let finished = 0;

            await Promise.all(
                nodes.map(async node => {

                    const ok = await testNodeAvailability(node);

                    availabilityMap[node] = ok;

                    finished++;

                    btn.textContent =
                        `${finished}/${nodes.length}`;

                    refreshSelect();
                })
            );

            btn.textContent = '✅ 完成';
            setTimeout(() => { btn.textContent = t.availability; btn.disabled = false; }, 1500);
        });
    }

    function updatePanelDisplay() {
        if (panel) {
            const el = panel.querySelector('#currentNodeDisplay');
            if (el) el.textContent = currentNode;
        }
    }

    // ==================== Fetch Hook ====================
    const originalFetch = unsafeWindow.fetch;
    unsafeWindow.fetch = async function (input, init) {
        if (!isEnabled) return originalFetch(input, init);

        const response = await originalFetch(input, init);
        let urlStr = typeof input === 'string' ? input : (input?.url || '');

        if ((urlStr.includes('.iwara.tv') || urlStr.includes('.iwara.ai')) && urlStr.includes('/file')) {
            const clone = response.clone();
            try {
                let body = await clone.json();

                if (Array.isArray(body) && body.length > 0) {
                    const firstSrc = body[0]?.src?.view || body[0]?.src?.download;
                    if (firstSrc) {
                        const match = firstSrc.match(NODE_HOST_MATCH);
                        if (match) {
                            const newDefault = match[1];
                            if (defaultNode !== newDefault) {
                                const oldDefault = defaultNode;
                                defaultNode = newDefault;

                                if (oldDefault && currentNode === oldDefault) {
                                    currentNode = newDefault;
                                    applyNode(true);
                                }
                                refreshSelect();
                                updatePanelDisplay();
                            }
                        }
                    }

                    // 保存当前视频的文件信息用于可用性测试
                    if (body[0]?.src?.view) {
                        try {
                            const url = new URL(body[0].src.view);
                            currentFileInfo = {
                                hash: url.searchParams.get('hash'),
                                filename: url.searchParams.get('filename'),
                                path: url.searchParams.get('path'),
                                expires: url.searchParams.get('expires')
                            };
                        } catch (e) {}
                    }
                }

                if (Array.isArray(body)) {
                    body.forEach(item => {
                        if (item.src) {
                            if (item.src.download) item.src.download = item.src.download.replace(NODE_HOST_REGEX_G, `//${currentNode}.${SITE_DOMAIN}`);
                            if (item.src.view) item.src.view = item.src.view.replace(NODE_HOST_REGEX_G, `//${currentNode}.${SITE_DOMAIN}`);
                        }
                    });
                }

                return new Response(JSON.stringify(body), { status: clone.status, headers: clone.headers });
            } catch (e) {}
        }
        return response;
    };

    // 初始化
    setTimeout(createPanel, 600);
    setTimeout(createPanel, 1500);
    document.addEventListener('DOMContentLoaded', createPanel);
})();