手动节点切换 + Ping测速 + 可用性测试 + 默认节点智能跟随(兼容 iwara.tv / iwara.ai)
// ==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);
})();