// ==UserScript== // @name 海康威视NAS Docker手动下载镜像(单弹窗+改写pull/progress/cancel,保留原参数格式) // @namespace tm-docker-native-rewrite-stable // @license MIT // @version 2.2.6 // @description 拦截镜像仓库“下载”;弹窗输入repo/tag;改写 image_pull / image_pull_progress / image_pull_cancel;保留如tid中的原始冒号格式 // @match *://*/web/easyconnect/* // @run-at document-start // @grant none // ==/UserScript== (function () { "use strict"; if (window.__tm_native_rewrite_stable__) return; window.__tm_native_rewrite_stable__ = true; let pendingOverride = null; // { repo, tag, expireAt } let lastPullInfo = null; // { repo, tag, ts } let bypassClickOnce = false; let modalOpen = false; let lock = false; function decodeDeep(v, maxRounds = 5) { let s = v == null ? "" : String(v); for (let i = 0; i < maxRounds; i++) { try { const d = decodeURIComponent(s); if (d === s) break; s = d; } catch (_) { break; } } return s; } function normRepoTag(repo, tag) { const r = decodeDeep(repo).trim(); const t = decodeDeep(tag || "latest").trim() || "latest"; return { repo: r, tag: t }; } function encodeLoose(v) { return encodeURIComponent(v) .replace(/%2F/gi, "/") .replace(/%3A/gi, ":") .replace(/%40/gi, "@"); } function saveLastPullInfo(repo, tag) { const n = normRepoTag(repo, tag); lastPullInfo = { repo: n.repo, tag: n.tag, ts: Date.now() }; try { localStorage.setItem("__tm_last_pull_info__", JSON.stringify(lastPullInfo)); } catch (_) {} } try { const s = localStorage.getItem("__tm_last_pull_info__"); if (s) { const v = JSON.parse(s); if (v && v.repo) { const n = normRepoTag(v.repo, v.tag); lastPullInfo = { repo: n.repo, tag: n.tag, ts: Number(v.ts || Date.now()) }; } } } catch (_) {} function toast(msg, err) { const el = document.createElement("div"); el.textContent = msg; el.style.cssText = [ "position:fixed", "right:20px", "bottom:20px", "z-index:1000001", "background:" + (err ? "#ff4d4f" : "#1677ff"), "color:#fff", "padding:8px 12px", "border-radius:8px", "font-size:12px", "box-shadow:0 6px 16px rgba(0,0,0,.2)" ].join(";"); (document.body || document.documentElement).appendChild(el); setTimeout(() => el.remove(), 1800); } function isDockerRepoPage() { const path = (location.pathname || "") + (location.hash || ""); if (/(docker|image|registry|mirror)/i.test(path)) return true; const txt = document.body ? document.body.innerText || "" : ""; return /Docker/i.test(txt) && /镜像仓库|本地镜像/.test(txt); } function isTargetDownloadBtn(btn) { if (!btn) return false; if (btn.closest("[data-tm-dialog='1']")) return false; const t = (btn.textContent || "").trim(); if (t !== "下载") return false; const row = btn.closest(".el-table__row,.list-item,.item,[class*='image'],[class*='docker'],tr,li") || btn.parentElement; const ctx = ((row && row.innerText) ? row.innerText : "") + "\n" + (document.body ? (document.body.innerText || "").slice(0, 4000) : ""); return /镜像仓库|repo|tag|镜像名|Docker/i.test(ctx); } function guessRepoTag(btn) { const row = btn.closest(".el-table__row,.list-item,.item,[class*='image'],[class*='docker'],tr,li") || btn.parentElement; const txt = ((row && row.innerText) ? row.innerText : "").trim(); let m = txt.match(/([a-zA-Z0-9._/-]+):([a-zA-Z0-9._-]+)/); if (m) return normRepoTag(m[1], m[2]); m = txt.match(/\b([a-zA-Z0-9._/-]{3,})\b/); return normRepoTag(m ? m[1] : "", "latest"); } function openPrompt(defaultRepo, defaultTag) { return new Promise((resolve) => { modalOpen = true; const mask = document.createElement("div"); mask.setAttribute("data-tm-dialog", "1"); mask.style.cssText = [ "position:fixed", "inset:0", "background:rgba(0,0,0,.42)", "z-index:1000000", "display:flex", "align-items:center", "justify-content:center" ].join(";"); const box = document.createElement("div"); box.setAttribute("data-tm-dialog", "1"); box.style.cssText = [ "width:420px", "max-width:calc(100vw - 40px)", "background:#fff", "border-radius:12px", "padding:14px", "box-sizing:border-box" ].join(";"); box.innerHTML = `
确认下载参数
repo
tag
`; mask.appendChild(box); (document.documentElement || document.body).appendChild(mask); const repoEl = box.querySelector("#tm_repo"); const tagEl = box.querySelector("#tm_tag"); repoEl.value = decodeDeep(defaultRepo || ""); tagEl.value = decodeDeep(defaultTag || "latest"); repoEl.focus(); const close = (ret) => { mask.remove(); modalOpen = false; resolve(ret); }; box.querySelector("#tm_cancel").onclick = () => close(null); box.querySelector("#tm_ok").onclick = () => { const n = normRepoTag(repoEl.value, tagEl.value); if (!n.repo) return toast("repo 不能为空", true); close(n); }; mask.onclick = (e) => { if (e.target === mask) close(null); }; }); } function getAction(urlLike) { try { const u = new URL(urlLike, location.origin); if (!/\/rest\/1\.1\/docker$/i.test(u.pathname)) return ""; return (u.searchParams.get("action") || "").trim(); } catch (_) { return ""; } } function setRepoTagRaw(urlLike, repo, tag) { const u = new URL(urlLike, location.origin); const n = normRepoTag(repo, tag); let raw = u.search ? u.search.slice(1) : ""; if (raw) { raw = raw .split("&") .filter(Boolean) .filter((kv) => { const eq = kv.indexOf("="); const k = eq >= 0 ? kv.slice(0, eq) : kv; try { const kd = decodeURIComponent(k).trim(); return kd !== "repo" && kd !== "tag"; } catch (_) { return k !== "repo" && k !== "tag"; } }) .join("&"); } const append = `repo=${encodeLoose(n.repo)}&tag=${encodeLoose(n.tag)}`; const q = raw ? `${raw}&${append}` : append; return `${u.origin}${u.pathname}?${q}`; } function rewriteUrl(urlLike) { let u; try { u = new URL(urlLike, location.origin); } catch (_) { return urlLike; } const action = (u.searchParams.get("action") || "").trim(); const isTarget = action === "image_pull" || action === "image_pull_progress" || action === "image_pull_cancel"; if (!isTarget) return urlLike; if (action === "image_pull") { if (pendingOverride && Date.now() <= pendingOverride.expireAt) { const rewritten = setRepoTagRaw(urlLike, pendingOverride.repo, pendingOverride.tag); pendingOverride = null; try { const uu = new URL(rewritten, location.origin); const final = normRepoTag( uu.searchParams.get("repo") || "", uu.searchParams.get("tag") || "latest" ); if (final.repo) saveLastPullInfo(final.repo, final.tag); } catch (_) {} return rewritten; } const final = normRepoTag( u.searchParams.get("repo") || "", u.searchParams.get("tag") || "latest" ); if (final.repo) saveLastPullInfo(final.repo, final.tag); return urlLike; } if ( (action === "image_pull_progress" || action === "image_pull_cancel") && lastPullInfo && lastPullInfo.repo ) { return setRepoTagRaw(urlLike, lastPullInfo.repo, lastPullInfo.tag || "latest"); } return urlLike; } const rawFetch = window.fetch; window.fetch = function (input, init) { try { const oldUrl = typeof input === "string" ? input : input && input.url ? input.url : ""; if (oldUrl) { const action = getAction(oldUrl); if ( action === "image_pull" || action === "image_pull_progress" || action === "image_pull_cancel" ) { const newUrl = rewriteUrl(oldUrl); if (typeof input === "string") input = newUrl; else input = new Request(newUrl, input); } } } catch (e) { console.warn("[TM] fetch rewrite error:", e); } return rawFetch.call(this, input, init); }; const rawOpen = XMLHttpRequest.prototype.open; XMLHttpRequest.prototype.open = function (method, url, async, user, password) { try { if (url) { const action = getAction(url); if ( action === "image_pull" || action === "image_pull_progress" || action === "image_pull_cancel" ) { url = rewriteUrl(url); } } } catch (e) { console.warn("[TM] xhr rewrite error:", e); } return rawOpen.call(this, method, url, async, user, password); }; document.addEventListener( "click", async (ev) => { if (lock || modalOpen) return; if (bypassClickOnce) return; if (!isDockerRepoPage()) return; if (ev.target && ev.target.closest && ev.target.closest("[data-tm-dialog='1']")) return; const btn = ev.target.closest("button,.el-button,[role='button']"); if (!btn || !isTargetDownloadBtn(btn)) return; ev.preventDefault(); ev.stopPropagation(); ev.stopImmediatePropagation(); lock = true; try { const preset = guessRepoTag(btn); const input = await openPrompt(preset.repo, preset.tag); if (!input) return; const n = normRepoTag(input.repo, input.tag); pendingOverride = { repo: n.repo, tag: n.tag, expireAt: Date.now() + 12000 }; bypassClickOnce = true; btn.click(); toast(`已按参数继续:${n.repo}:${n.tag}`); } finally { setTimeout(() => { bypassClickOnce = false; lock = false; }, 0); } }, true ); })();