Iwara Video Node Manual

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

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

Advertisement:

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

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