触发浏览器下载事件。
* 关键:Gopeed 浏览器扩展监听的就是这条原生事件——
* 因此脚本只此一条路径,不中转、不接字节,确保下载工具能正常接管。
*/
function clickAnchor(href, filename) {
const a = document.createElement('a');
a.href = href;
if (filename) a.download = filename;
a.rel = 'noopener';
a.style.display = 'none';
document.body.appendChild(a);
a.click();
setTimeout(() => a.remove(), 1000);
}
/** HEAD 预检:确认镜像真的能给文件,再把直链交给浏览器 */
function precheck(url, timeoutMs) {
return gmRequest({ url, method: 'HEAD', timeout: timeoutMs || HEAD_TIMEOUT })
.then((res) => {
const m = /content-length:\s*(\d+)/i.exec(res.responseHeaders || '');
return { ok: true, size: m ? parseInt(m[1], 10) : -1 };
})
.catch((e) => ({ ok: false, error: (e && e.message) || '预检失败' }));
}
const Downloader = {
/** 直链交付:原生 a[download] 点击,发射后即交还浏览器,脚本不再介入 */
deliver(githubUrl, filename) {
clickAnchor(githubUrl, filename);
},
/**
* 自动模式:候选镜像逐个 HEAD 预检,失败换下一个;全部失败时对最快节点
* 直接放行一次(仍是 clickAnchor 原生通道,下载工具照样能接管)。
* @returns Promise<{ok, nodeUrl?, blind?, size?, error?, trace:string[]}>
*/
async runAuto(githubUrl, filename, hooks) {
hooks = hooks || {};
if (!NodeStore.nodes.length) await loadNodes('自动下载');
const trace = [];
// fast-path:fresh 候选直接 fire,跳过预检
const fresh = NodeStore.freshCandidates().slice(0, NODE_RETRY_MAX);
if (fresh.length) {
const best = fresh[0];
if (hooks.onNode) hooks.onNode(best, 1, 1, false);
this.deliver(mirrorUrl(githubUrl, best.url), filename);
return { ok: true, nodeUrl: best.url, blind: false, trace };
}
const list = NodeStore.candidates().slice(0, NODE_RETRY_MAX);
if (!list.length) return { ok: false, error: '没有可用镜像节点', trace: [] };
for (let i = 0; i < list.length; i++) {
const node = list[i];
if (hooks.onNode) hooks.onNode(node, i + 1, list.length, false);
const target = mirrorUrl(githubUrl, node.url);
const head = await precheck(target, HEAD_TIMEOUT_FAST);
if (head.ok) {
NodeStore.markOk(node.url);
this.deliver(target, filename);
return { ok: true, nodeUrl: node.url, size: head.size, blind: false, trace };
}
NodeStore.markFail(node.url);
trace.push(Utils.shortDomain(node.url) + ' ✗ ' + head.error);
Log.warn('镜像预检失败 →', Utils.shortDomain(node.url), head.error);
}
// 轮转耗尽:预检可能误判(部分镜像不支持 HEAD),对最快节点直接放行
const best = list[0];
if (hooks.onNode) hooks.onNode(best, list.length, list.length, true);
this.deliver(mirrorUrl(githubUrl, best.url), filename);
return { ok: true, nodeUrl: best.url, blind: true, trace };
}
};
/* ======================================================================
* L5-b · CAPABILITY —— Injector:规则表驱动,无硬编码 if/else 分支
* ==================================================================== */
const Injector = {
timer: null,
activeScenarios() {
const cfg = Settings.get().inject;
const host = location.hostname;
return SCENARIOS.filter((s) => {
if (!cfg[s.key]) return false;
if (s.hosts && !s.hosts.includes(host)) return false;
return true;
});
},
build(githubUrl, filename) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'ghb-dl-btn';
btn.title = '镜像加速下载';
btn.dataset.ghbUrl = githubUrl;
btn.setAttribute('aria-label', '镜像加速下载');
btn.innerHTML = Icons.download + '镜像下载';
btn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
View.download(githubUrl, filename || Utils.filenameFromUrl(githubUrl));
});
return btn;
},
attach(container, link, scenario) {
if (!container || !link || !link.href) return;
if (container.querySelector(':scope > .ghb-dl-btn')) return;
// selector 已限定 github 域名(含 codeload.github.com),此处不再二次过滤
const href = link.href;
const name = scenario.name(link) || Utils.filenameFromUrl(href);
container.appendChild(this.build(href, name));
},
run() {
this.activeScenarios().forEach((s) => {
let links;
try {
links = document.querySelectorAll(s.selector);
} catch {
return;
}
links.forEach((link) => {
this.attach(s.container(link), link, s);
});
});
},
schedule(delay) {
clearTimeout(this.timer);
this.timer = setTimeout(() => this.run(), delay || 0);
},
start() {
this.schedule(400);
// GitHub 是 SPA:监听路由变化与相关 DOM 增量
let lastHref = location.href;
const mo = new MutationObserver((records) => {
if (location.href !== lastHref) {
lastHref = location.href;
this.schedule(700);
return;
}
const relevant = records.some((m) => {
for (const node of m.addedNodes) {
if (node.nodeType !== 1) continue;
const html = (node.outerHTML || '').toLowerCase();
if (html.includes('download') || html.includes('release') ||
html.includes('archive') || html.includes('raw') ||
html.includes('codeload')) return true;
}
return false;
});
if (relevant) this.schedule(INJECT_DEBOUNCE);
});
mo.observe(document.body, { childList: true, subtree: true });
setInterval(() => this.schedule(), INJECT_INTERVAL);
}
};
/* ======================================================================
* L6 · VIEW —— 样式表 / 启动器 / 面板 / 下载弹窗 / Toast
* ==================================================================== */
const CSS = `
/* 主题色挂在 html 上:注入到页面里的按钮与 toast 不在 .ghb-scope 内,
同样需要读到这些变量;明暗切换直接跟随 GitHub 的 data-color-mode */
html{
--ghb-accent:#2da44e; --ghb-accent-2:#1a7f37; --ghb-accent-fg:#ffffff;
--ghb-good:#2da44e; --ghb-warn:#d29922; --ghb-bad:#f85149;
}
html[data-color-mode="light"]{
--ghb-accent:#1a7f37; --ghb-accent-2:#116329;
}
.ghb-scope{
--ghb-bg:#0d1117; --ghb-bg-2:#161b22; --ghb-bg-3:#21262d;
--ghb-bd:#30363d; --ghb-bd-2:#21262d;
--ghb-fg:#e6edf3; --ghb-fg-2:#8b949e; --ghb-fg-3:#6e7681;
--ghb-shadow:0 16px 44px rgba(0,0,0,.5);
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;
color:var(--ghb-fg); font-size:13px; line-height:1.5;
}
.ghb-scope.ghb-light{
--ghb-bg:#ffffff; --ghb-bg-2:#f6f8fa; --ghb-bg-3:#eaeef2;
--ghb-bd:#d0d7de; --ghb-bd-2:#d8dee4;
--ghb-fg:#1f2328; --ghb-fg-2:#59636e; --ghb-fg-3:#818b98;
--ghb-shadow:0 16px 44px rgba(31,35,40,.16);
}
.ghb-scope svg{width:1em;height:1em;fill:currentColor;flex:none;vertical-align:-.125em;}
/* ---------- 启动器:右侧中部圆形按钮,可拖拽,默认贴右 ---------- */
#ghb-launcher{
position:fixed; right:0; top:50%; transform:translateY(-50%);
margin-right:10px; z-index:2147483000;
width:44px; height:44px; padding:0;
display:flex; align-items:center; justify-content:center;
border:none; border-radius:50%;
background:var(--ghb-accent); color:#fff; cursor:pointer;
box-shadow:0 6px 20px rgba(0,0,0,.32);
transition:background .18s, box-shadow .18s, transform .18s, filter .18s;
font-family:inherit; line-height:1;
}
#ghb-launcher:hover{background:var(--ghb-accent-2); box-shadow:0 8px 26px rgba(0,0,0,.4); transform:translateY(-50%) scale(1.06);}
#ghb-launcher.ghb-dragging{cursor:grabbing; filter:brightness(1.08); user-select:none; transform:translateY(-50%) scale(.96);}
#ghb-launcher .ghb-lau-mark{width:26px;height:26px;display:block;}
#ghb-launcher .ghb-lau-mark svg{width:26px;height:26px;}
/* ---------- 遮罩 ---------- */
#ghb-overlay{
position:fixed; inset:0; z-index:2147483001;
background:rgba(0,0,0,.5); opacity:0; pointer-events:none; transition:opacity .2s;
}
#ghb-overlay.ghb-open{opacity:1; pointer-events:auto;}
/* ---------- 面板:居中,三 Tab ---------- */
#ghb-panel{
position:fixed; left:50%; top:50%; z-index:2147483002;
width:460px; max-width:calc(100vw - 32px); max-height:84vh;
background:var(--ghb-bg); border:1px solid var(--ghb-bd);
border-radius:14px; box-shadow:var(--ghb-shadow);
display:flex; flex-direction:column; overflow:hidden;
opacity:0; transform:translate(-50%,-46%) scale(.97); pointer-events:none;
transition:opacity .22s, transform .22s cubic-bezier(.4,0,.2,1);
}
#ghb-panel.ghb-open{opacity:1; transform:translate(-50%,-50%) scale(1); pointer-events:auto;}
.ghb-head{display:flex; align-items:center; gap:10px; padding:14px 16px; border-bottom:1px solid var(--ghb-bd-2); flex:none;}
.ghb-head .ghb-mark{width:24px;height:24px;}
.ghb-head .ghb-mark svg{width:24px;height:24px;}
.ghb-head h2{margin:0; font-size:15px; font-weight:600; color:var(--ghb-fg);}
.ghb-head .ghb-ver{font-size:11px; color:var(--ghb-fg-2); border:1px solid var(--ghb-bd); border-radius:999px; padding:1px 7px;}
.ghb-head .ghb-spacer{flex:1;}
.ghb-icon-btn{
width:28px; height:28px; display:flex; align-items:center; justify-content:center;
border:none; border-radius:6px; background:transparent; color:var(--ghb-fg-2);
cursor:pointer; font-size:16px; transition:background .15s, color .15s;
}
.ghb-icon-btn:hover{background:var(--ghb-bg-3); color:var(--ghb-fg);}
.ghb-tabs{display:flex; border-bottom:1px solid var(--ghb-bd-2); flex:none; background:var(--ghb-bg-2);}
.ghb-tab{
flex:1; padding:10px 0; border:none; background:transparent; cursor:pointer;
font-family:inherit; font-size:13px; color:var(--ghb-fg-2);
border-bottom:2px solid transparent; transition:color .15s, background .15s;
}
.ghb-tab:hover{color:var(--ghb-fg); background:var(--ghb-bg-3);}
.ghb-tab.ghb-on{color:var(--ghb-fg); font-weight:600; border-bottom-color:var(--ghb-accent);}
.ghb-body{flex:1; overflow-y:auto; min-height:180px;}
.ghb-body::-webkit-scrollbar{width:8px;}
.ghb-body::-webkit-scrollbar-thumb{background:var(--ghb-bd); border-radius:4px;}
.ghb-page{display:none;}
.ghb-page.ghb-on{display:block;}
.ghb-status{
display:flex; align-items:center; gap:8px; padding:10px 16px;
font-size:12px; color:var(--ghb-fg-2); border-bottom:1px solid var(--ghb-bd-2);
background:var(--ghb-bg-2);
}
.ghb-dot{width:8px;height:8px;border-radius:50%;background:var(--ghb-fg-3);flex:none;}
.ghb-dot.ghb-online{background:var(--ghb-good);}
.ghb-dot.ghb-offline{background:var(--ghb-bad);}
.ghb-status .ghb-tail{margin-left:auto; color:var(--ghb-fg-3);}
.ghb-toolbar{display:flex; flex-wrap:wrap; gap:6px; padding:10px 16px; border-bottom:1px solid var(--ghb-bd-2);}
.ghb-btn{
display:inline-flex; align-items:center; gap:5px; padding:5px 10px;
border:1px solid var(--ghb-bd); border-radius:6px; background:var(--ghb-bg-3);
color:var(--ghb-fg); font-family:inherit; font-size:12px; cursor:pointer;
white-space:nowrap; transition:background .15s, border-color .15s, opacity .15s;
}
.ghb-btn:hover{background:var(--ghb-bd); border-color:var(--ghb-fg-3);}
.ghb-btn[disabled]{opacity:.5; pointer-events:none;}
.ghb-btn.ghb-primary{background:var(--ghb-accent); border-color:var(--ghb-accent); color:var(--ghb-accent-fg);}
.ghb-btn.ghb-primary:hover{background:var(--ghb-accent-2);}
.ghb-btn.ghb-danger:hover{background:var(--ghb-bad); border-color:var(--ghb-bad); color:#fff;}
.ghb-btn svg{font-size:14px;}
.ghb-spin{animation:ghb-spin 1s linear infinite;}
@keyframes ghb-spin{to{transform:rotate(360deg);}}
.ghb-field{display:flex; align-items:center; gap:8px; padding:6px 16px 10px;}
.ghb-input{
flex:1; min-width:0; padding:6px 9px; border:1px solid var(--ghb-bd);
border-radius:6px; background:var(--ghb-bg-2); color:var(--ghb-fg);
font-family:inherit; font-size:13px;
}
.ghb-input:focus{outline:none; border-color:var(--ghb-accent);}
.ghb-list{padding:4px 0;}
.ghb-row{display:flex; align-items:center; gap:10px; padding:8px 16px; transition:background .12s;}
.ghb-row:hover{background:var(--ghb-bg-2);}
.ghb-cb{position:relative; width:16px; height:16px; flex:none; cursor:pointer;}
.ghb-cb input{position:absolute; inset:0; width:100%; height:100%; margin:0; opacity:0; cursor:pointer; z-index:1;}
.ghb-cb span{
display:block; width:16px; height:16px; border:1.5px solid var(--ghb-bd);
border-radius:4px; background:var(--ghb-bg); transition:background .15s, border-color .15s;
}
.ghb-cb input:checked + span{background:var(--ghb-accent); border-color:var(--ghb-accent);}
.ghb-cb input:checked + span::after{
content:''; display:block; width:4px; height:8px; margin:1px 0 0 4.5px;
border:solid #fff; border-width:0 2px 2px 0; transform:rotate(45deg);
}
.ghb-main{flex:1; min-width:0;}
.ghb-name{font-size:13px; color:var(--ghb-fg); overflow:hidden; text-overflow:ellipsis; white-space:nowrap;}
.ghb-meta{display:flex; align-items:center; gap:6px; margin-top:3px; font-size:11px; color:var(--ghb-fg-2);}
.ghb-bar{height:3px; width:64px; border-radius:2px; background:var(--ghb-bg-3); overflow:hidden; flex:none;}
.ghb-bar i{display:block; height:100%; border-radius:2px;}
.ghb-tag{font-size:11px; padding:1px 6px; border-radius:999px; border:1px solid var(--ghb-bd); color:var(--ghb-fg-2);}
/* 文字色与进度条填充色分开,避免 .ghb-tag 被染成同色背景 */
.ghb-t-fast{color:var(--ghb-good);}
.ghb-t-mid{color:var(--ghb-warn);}
.ghb-t-slow{color:var(--ghb-bad);}
.ghb-f-fast{background:var(--ghb-good);}
.ghb-f-mid{background:var(--ghb-warn);}
.ghb-f-slow{background:var(--ghb-bad);}
.ghb-empty{padding:32px 16px; text-align:center; color:var(--ghb-fg-2); font-size:13px;}
.ghb-empty small{display:block; margin-top:6px; color:var(--ghb-fg-3);}
.ghb-hint{padding:10px 16px; font-size:12px; color:var(--ghb-fg-2); border-bottom:1px solid var(--ghb-bd-2); background:var(--ghb-bg-2);}
.ghb-setting{display:flex; align-items:center; gap:12px; padding:12px 16px; border-bottom:1px solid var(--ghb-bd-2);}
.ghb-setting:last-child{border-bottom:none;}
.ghb-setting .ghb-label{flex:1; min-width:0;}
.ghb-setting .ghb-lt{display:block; font-size:13px; font-weight:500; color:var(--ghb-fg);}
.ghb-setting .ghb-ld{display:block; font-size:11px; color:var(--ghb-fg-2); margin-top:2px;}
.ghb-switch{position:relative; width:40px; height:22px; flex:none; cursor:pointer;}
.ghb-switch input{position:absolute; inset:0; width:100%; height:100%; margin:0; opacity:0; cursor:pointer; z-index:1;}
.ghb-switch i{
display:block; width:40px; height:22px; border-radius:11px;
background:var(--ghb-bd); transition:background .22s; position:relative;
}
.ghb-switch i::after{
content:''; position:absolute; top:3px; left:3px; width:16px; height:16px;
border-radius:50%; background:#fff; transition:transform .22s cubic-bezier(.4,0,.2,1);
}
.ghb-switch input:checked + i{background:var(--ghb-accent);}
.ghb-switch input:checked + i::after{transform:translateX(18px);}
.ghb-select, .ghb-num{
padding:5px 8px; border:1px solid var(--ghb-bd); border-radius:6px;
background:var(--ghb-bg-2); color:var(--ghb-fg); font-family:inherit; font-size:12px;
}
.ghb-num{width:72px; text-align:right;}
.ghb-select:focus, .ghb-num:focus{outline:none; border-color:var(--ghb-accent);}
.ghb-inline{display:flex; align-items:center; gap:6px; flex:none;}
.ghb-about{padding:14px 16px; font-size:12px; color:var(--ghb-fg-2); border-top:1px solid var(--ghb-bd-2);}
.ghb-about b{color:var(--ghb-fg); font-weight:600;}
.ghb-foot{
display:flex; align-items:center; justify-content:space-between; gap:8px;
padding:8px 16px; border-top:1px solid var(--ghb-bd-2);
background:var(--ghb-bg-2); font-size:11px; color:var(--ghb-fg-3); flex:none;
}
/* ---------- 下载弹窗 ---------- */
#ghb-dl{
position:fixed; inset:0; z-index:2147483003; display:flex;
align-items:center; justify-content:center;
opacity:0; pointer-events:none; transition:opacity .2s;
}
#ghb-dl.ghb-open{opacity:1; pointer-events:auto;}
#ghb-dl .ghb-dl-bg{position:absolute; inset:0; background:rgba(0,0,0,.6);}
#ghb-dl .ghb-card{
position:relative; width:440px; max-width:calc(100vw - 32px); max-height:80vh;
background:var(--ghb-bg); border:1px solid var(--ghb-bd); border-radius:14px;
box-shadow:var(--ghb-shadow); display:flex; flex-direction:column; overflow:hidden;
transform:translateY(10px) scale(.98); transition:transform .22s cubic-bezier(.4,0,.2,1);
}
#ghb-dl.ghb-open .ghb-card{transform:none;}
.ghb-file{padding:10px 16px; background:var(--ghb-bg-2); border-bottom:1px solid var(--ghb-bd-2); flex:none;}
.ghb-file .ghb-f1{font-size:11px; color:var(--ghb-fg-2);}
.ghb-file .ghb-f2{font-size:13px; font-weight:600; color:var(--ghb-fg); word-break:break-all; margin-top:2px;}
.ghb-file .ghb-f3{display:flex; align-items:center; gap:8px; margin-top:8px; flex-wrap:wrap;}
.ghb-dl-sub{display:flex; justify-content:flex-end; padding:8px 16px 0; font-size:12px; color:var(--ghb-fg-2); flex:none;}
.ghb-nodes{flex:1; overflow-y:auto; padding:4px 0; min-height:120px;}
.ghb-nodes::-webkit-scrollbar{width:8px;}
.ghb-nodes::-webkit-scrollbar-thumb{background:var(--ghb-bd); border-radius:4px;}
.ghb-nrow{display:flex; align-items:center; gap:8px; padding:8px 16px;}
.ghb-nrow:hover{background:var(--ghb-bg-2);}
.ghb-nrow .ghb-nd{flex:1; min-width:0; font-size:13px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;}
/* ---------- 页面注入的下载按钮 ---------- */
.ghb-dl-btn{
display:inline-flex; align-items:center; gap:4px; margin-left:6px;
padding:2px 8px; border:1px solid var(--ghb-accent); border-radius:5px;
background:transparent; color:var(--ghb-accent); font-family:inherit;
font-size:12px; font-weight:500; line-height:1.5; cursor:pointer;
vertical-align:middle; white-space:nowrap; flex:none; transition:background .15s, color .15s;
}
.ghb-dl-btn:hover{background:var(--ghb-accent); color:var(--ghb-accent-fg);}
.ghb-dl-btn svg{width:12px; height:12px; fill:currentColor;}
/* ---------- Toast ---------- */
#ghb-toasts{position:fixed; bottom:24px; left:50%; transform:translateX(-50%); z-index:2147483004; display:flex; flex-direction:column; gap:8px; align-items:center; pointer-events:none;}
.ghb-toast{
display:flex; align-items:center; gap:8px; max-width:min(560px, calc(100vw - 32px));
padding:9px 14px; border-radius:8px; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;
font-size:13px; color:#fff; box-shadow:0 6px 20px rgba(0,0,0,.35);
opacity:0; transform:translateY(12px); transition:opacity .22s, transform .22s;
}
.ghb-toast.ghb-show{opacity:1; transform:none;}
.ghb-toast.ghb-info{background:#1f6feb;}
.ghb-toast.ghb-ok{background:var(--ghb-good);}
.ghb-toast.ghb-warn{background:#9e6a03;}
.ghb-toast.ghb-err{background:#b62324;}
`;
const View = {
el: {},
/* ---------- 主题跟随 GitHub 明暗模式 ---------- */
theme() {
const mode = document.documentElement.getAttribute('data-color-mode');
if (mode === 'light' || mode === 'dark') return mode;
return window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
},
applyTheme() {
const light = this.theme() === 'light';
[this.el.launcher, this.el.panel, this.el.dl].forEach((n) => {
if (n) n.classList.toggle('ghb-light', light);
});
},
mount() {
GM_addStyle(CSS);
const launcher = document.createElement('button');
launcher.id = 'ghb-launcher';
launcher.className = 'ghb-scope';
launcher.type = 'button';
launcher.title = 'GitHub 加速助手(点击打开面板,按住可拖拽)';
launcher.innerHTML =
'' + Icons.mark + '';
document.body.appendChild(launcher);
const overlay = document.createElement('div');
overlay.id = 'ghb-overlay';
document.body.appendChild(overlay);
const panel = document.createElement('div');
panel.id = 'ghb-panel';
panel.className = 'ghb-scope';
document.body.appendChild(panel);
const dl = document.createElement('div');
dl.id = 'ghb-dl';
dl.className = 'ghb-scope';
dl.innerHTML =
'' +
'' +
'
' +
' ' + Icons.mark + '' +
'
镜像加速下载
' +
' ' +
' ' +
' ' +
' ' +
'
' +
'
下载文件
' +
'
-
' +
'
' +
' ' +
' ' +
'
' +
'
' +
'
' +
'
' +
' ' +
'
';
document.body.appendChild(dl);
Object.assign(this.el, { launcher, overlay, panel, dl });
this.applyTheme();
const mo = new MutationObserver(() => this.applyTheme());
mo.observe(document.documentElement, { attributes: true, attributeFilter: ['data-color-mode', 'data-dark-theme', 'data-light-theme'] });
this.Panel.mount(panel);
this.DlModal.mount(dl);
this.bindLauncher(launcher, overlay);
},
bindLauncher(launcher, overlay) {
let dragged = false;
launcher.addEventListener('mousedown', (e) => {
if (e.button !== 0) return;
const r = launcher.getBoundingClientRect();
const sx = e.clientX, sy = e.clientY, ox = r.left, oy = r.top;
let moved = false;
launcher.classList.add('ghb-dragging');
const onMove = (ev) => {
const dx = ev.clientX - sx, dy = ev.clientY - sy;
if (!moved && Math.abs(dx) < 4 && Math.abs(dy) < 4) return;
moved = true;
launcher.style.right = 'auto';
launcher.style.transform = 'none';
launcher.style.left = Math.max(0, Math.min(window.innerWidth - r.width, ox + dx)) + 'px';
launcher.style.top = Math.max(0, Math.min(window.innerHeight - r.height, oy + dy)) + 'px';
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
launcher.classList.remove('ghb-dragging');
if (moved) {
dragged = true;
Settings.set({ launcherPos: { left: launcher.style.left, top: launcher.style.top } });
setTimeout(() => { dragged = false; }, 0);
}
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
e.preventDefault();
});
launcher.addEventListener('click', () => {
if (dragged) return;
this.Panel.toggle();
});
overlay.addEventListener('click', () => this.Panel.toggle());
},
restoreLauncherPos() {
const pos = Settings.get().launcherPos;
const l = this.el.launcher;
if (pos && pos.left && pos.top) {
// 必须把 right 置 auto,否则 CSS 的 right:0 会与 inline left 冲突导致贴右
l.style.transform = 'none';
l.style.right = 'auto';
l.style.left = pos.left;
l.style.top = pos.top;
}
this.applyLauncherVisible();
},
/** 启动器显示状态的唯一写入口:设置项、DOM、面板勾选框三者同步 */
setLauncherVisible(on) {
Settings.set({ showLauncher: !!on });
this.applyLauncherVisible();
},
applyLauncherVisible() {
const on = !!Settings.get().showLauncher;
if (this.el.launcher) this.el.launcher.style.display = on ? 'flex' : 'none';
const cb = document.getElementById('ghb-s-launcher');
if (cb) cb.checked = on;
},
/** 统一下载入口:弹窗手选 / 全自动最快节点,由设置 askNode 决定(脚本不接管下载) */
download(githubUrl, filename) {
if (!githubUrl) { this.Toast.err('链接无效'); return; }
const name = filename || Utils.filenameFromUrl(githubUrl);
if (Settings.get().askNode) this.DlModal.open(githubUrl, name);
else this.autoDownload(githubUrl, name);
},
/** 全自动:轮换镜像直到预检通过,常驻 Toast 反馈,不打扰页面 */
async autoDownload(githubUrl, filename) {
if (this._autoBusy) { this.Toast.warn('已有下载任务在进行中'); return; }
this._autoBusy = true;
const sticky = this.Toast.sticky('正在选择最快镜像…');
const r = await Downloader.runAuto(githubUrl, filename, {
onNode: (node, i, n, last) =>
sticky.update('(' + i + '/' + n + ') 预检 ' + Utils.shortDomain(node.url) + (last ? '(兜底直连)' : '') + '…')
});
this._autoBusy = false;
if (r.ok && r.blind) {
sticky.warn('镜像预检均失败,已对最快节点直接放行 · ' + filename);
} else if (r.ok) {
sticky.ok('已交给浏览器下载 · ' + filename + '(' + Utils.shortDomain(r.nodeUrl) + ')');
} else {
sticky.err(r.error + '|可点「复制链接」手动下载');
}
},
/* ---------- Toast ---------- */
Toast: {
host() {
let h = document.getElementById('ghb-toasts');
if (!h) {
h = document.createElement('div');
h.id = 'ghb-toasts';
document.body.appendChild(h);
}
return h;
},
show(msg, kind, ms) {
// 懒创建:初始化完成前的提示不会被静默丢弃
const host = this.host();
const t = document.createElement('div');
t.className = 'ghb-toast ghb-' + (kind || 'info');
t.textContent = msg;
host.appendChild(t);
requestAnimationFrame(() => t.classList.add('ghb-show'));
setTimeout(() => {
t.classList.remove('ghb-show');
setTimeout(() => t.remove(), 260);
}, ms || (kind === 'err' ? 4200 : 2400));
},
/** 常驻状态提示:自动下载过程的反馈载体,结束时收敛为普通 toast */
sticky(text) {
const t = document.createElement('div');
t.className = 'ghb-toast ghb-info';
t.textContent = text;
this.host().appendChild(t);
requestAnimationFrame(() => t.classList.add('ghb-show'));
let gone = false;
const close = () => {
if (gone) return;
gone = true;
t.classList.remove('ghb-show');
setTimeout(() => t.remove(), 260);
};
return {
update(msg) { if (!gone) t.textContent = msg || text; },
ok(msg) { close(); View.Toast.ok(msg); },
warn(msg) { close(); View.Toast.warn(msg); },
err(msg) { close(); View.Toast.err(msg); }
};
},
ok: (m) => View.Toast.show(m, 'ok'),
warn: (m) => View.Toast.show(m, 'warn'),
err: (m) => View.Toast.show(m, 'err'),
info: (m) => View.Toast.show(m, 'info')
},
/* ---------- 管理面板 ---------- */
Panel: {
open: false,
tab: 'nodes',
root: null,
mount(root) {
this.root = root;
root.innerHTML =
'' +
' ' + Icons.mark + '' +
'
GitHub 加速助手
' +
' ' + VERSION + '' +
' ' +
' ' +
'' +
'' +
' ' +
' ' +
' ' +
'
' +
'' +
'';
root.querySelector('#ghb-panel-close').addEventListener('click', () => this.toggle());
root.querySelectorAll('.ghb-tab').forEach((btn) => {
btn.addEventListener('click', () => this.switch(btn.dataset.tab));
});
this.renderNodesPage();
this.renderInjectPage();
this.renderSettingsPage();
this.switch(Settings.get().lastTab || 'nodes');
},
toggle() {
this.open = !this.open;
View.el.panel.classList.toggle('ghb-open', this.open);
View.el.overlay.classList.toggle('ghb-open', this.open);
if (this.open) this.refresh();
},
switch(tab) {
this.tab = tab;
Settings.set({ lastTab: tab });
this.root.querySelectorAll('.ghb-tab').forEach((b) => b.classList.toggle('ghb-on', b.dataset.tab === tab));
this.root.querySelectorAll('.ghb-page').forEach((p) => p.classList.toggle('ghb-on', p.id === 'ghb-page-' + tab));
},
refresh() {
this.renderNodesPage();
this.renderInjectPage();
this.renderSettingsPage();
},
renderNodesPage() {
const page = this.root.querySelector('#ghb-page-nodes');
const nodes = NodeStore.nodes;
const online = nodes.length > 0;
const lats = nodes.map((n) => n.latency || 0);
const summary = online
? nodes.length + ' 个节点 · 最快 ' + Math.min.apply(null, lats) +
'ms · 平均 ' + Math.round(lats.reduce((a, b) => a + b, 0) / lats.length) + 'ms'
: '暂无可用节点';
page.innerHTML =
'' +
' ' +
' ' + Utils.esc(summary) + '' +
' 更新 ' + Utils.clock(NodeStore.updatedAt) + '' +
'
' +
'' +
' ' +
' ' +
' ' +
' ' +
' ' +
'
' +
'' +
'';
page.querySelector('#ghb-n-refresh').addEventListener('click', (e) => this.onRefresh(e.currentTarget));
page.querySelector('#ghb-n-probe').addEventListener('click', (e) => this.onProbe(e.currentTarget));
page.querySelector('#ghb-n-all').addEventListener('click', () => {
NodeStore.setVisible(NodeStore.nodes.map((n) => n.url));
View.Toast.info('已全选 ' + NodeStore.nodes.length + ' 个节点');
});
page.querySelector('#ghb-n-none').addEventListener('click', () => {
NodeStore.setVisible([]);
View.Toast.info('已取消全部勾选');
});
page.querySelector('#ghb-n-top').addEventListener('click', () => {
NodeStore.setVisible(NodeStore.nodes.slice(0, 10).map((n) => n.url));
View.Toast.info('已恢复延迟最低的 10 个节点');
});
const filter = page.querySelector('#ghb-n-filter');
filter.addEventListener('input', () => { this.filter = filter.value; this.renderList(); });
this.renderList();
},
renderList() {
const list = this.root.querySelector('#ghb-n-list');
if (!list) return;
const kw = (this.filter || '').trim().toLowerCase();
const nodes = NodeStore.nodes.filter((n) => !kw || n.url.toLowerCase().includes(kw));
if (!nodes.length) {
list.innerHTML = '没有匹配的节点' +
(NodeStore.nodes.length ? '' : '
正在后台自动获取…') +
'也可点击「刷新节点」手动重试
';
return;
}
list.innerHTML = nodes.map((n) => {
const ms = n.latency || 0;
const lv = Utils.level(ms);
const on = NodeStore.visible.includes(n.url);
return '' +
'
' +
'
' +
'
' + Utils.esc(Utils.shortDomain(n.url)) + '
' +
'
' + ms + 'ms
' +
'
' +
'
' +
'
';
}).join('');
list.querySelectorAll('.ghb-n-cb').forEach((cb) => {
cb.addEventListener('change', () => {
const next = new Set(NodeStore.visible);
cb.checked ? next.add(cb.dataset.url) : next.delete(cb.dataset.url);
NodeStore.setVisible(Array.from(next));
});
});
list.querySelectorAll('.ghb-n-test').forEach((b) => {
b.addEventListener('click', () => this.onTest(b));
});
const count = this.root.querySelector('#ghb-panel-count');
if (count) count.textContent = '已启用 ' + NodeStore.visible.length + ' / ' + NodeStore.nodes.length;
},
async onRefresh(btn) {
btn.disabled = true;
btn.querySelector('svg').classList.add('ghb-spin');
const ok = await loadNodes('手动');
btn.disabled = false;
btn.querySelector('svg').classList.remove('ghb-spin');
ok ? View.Toast.ok('已刷新,共 ' + NodeStore.nodes.length + ' 个节点')
: View.Toast.err('刷新失败,请检查网络或稍后重试');
},
async onProbe(btn) {
if (!NodeStore.nodes.length) { View.Toast.warn('暂无节点可测速'); return; }
btn.disabled = true;
btn.querySelector('svg').classList.add('ghb-spin');
const list = await probeMany(NodeStore.nodes.map((n) => n.url));
btn.disabled = false;
btn.querySelector('svg').classList.remove('ghb-spin');
if (!list.length) { View.Toast.err('全部节点均不可达'); return; }
list.forEach((n) => NodeStore.markOk(n.url));
NodeStore.setNodes(list);
View.Toast.ok('测速完成,' + list.length + ' 个节点已就绪');
},
onTest(btn) {
const url = btn.dataset.url;
const old = btn.textContent;
btn.disabled = true;
btn.textContent = '…';
probeOne(url).then((r) => {
btn.textContent = r.ok ? r.ms + 'ms' : '不可达';
btn.style.color = r.ok ? 'var(--ghb-good)' : 'var(--ghb-bad)';
const node = NodeStore.nodes.find((n) => n.url === url);
if (node && r.ok) {
node.latency = r.ms;
NodeStore.setNodes(NodeStore.nodes.slice().sort((a, b) => a.latency - b.latency));
}
setTimeout(() => {
btn.textContent = old;
btn.style.color = '';
btn.disabled = false;
}, 2600);
});
},
renderInjectPage() {
const page = this.root.querySelector('#ghb-page-inject');
const cfg = Settings.get().inject;
page.innerHTML =
'控制各位置「镜像下载」按钮的显示。改动立即生效,已渲染的按钮需刷新页面才移除。
' +
SCENARIOS.map((s) =>
'' +
' ' +
' ' +
' ' +
' ' +
'
').join('');
page.querySelectorAll('.ghb-switch input').forEach((cb) => {
cb.addEventListener('change', () => {
Settings.setInject(cb.dataset.key, cb.checked);
const s = SCENARIOS.find((x) => x.key === cb.dataset.key);
View.Toast.info((cb.checked ? '已开启「' : '已关闭「') + (s ? s.label : cb.dataset.key) + '」');
Injector.schedule(150);
});
});
},
renderSettingsPage() {
const page = this.root.querySelector('#ghb-page-settings');
const s = Settings.get();
page.innerHTML =
'' +
' ' +
' ' +
'
' +
'' +
' ' +
' ' +
'
' +
'' +
' ' +
' ' +
'
' +
'' +
' ' +
' ' +
'
' +
'' +
' 恢复默认设置' +
' 清空节点缓存与全部偏好' +
' ' +
'
' +
'' +
' ' + VERSION + ' · 作者 ' + AUTHOR + '
' +
' 本脚本不接管下载:只负责挑选可用镜像并生成直链,下载一律走浏览器原生通道,Gopeed / IDM 等工具可正常接管。
' +
' 若浏览器无反应,点「复制链接」粘贴进下载工具即可。' +
'
';
page.querySelector('#ghb-s-autorefresh').addEventListener('change', (e) => {
Settings.set({ refreshOnStart: e.target.checked });
});
page.querySelector('#ghb-s-asknode').addEventListener('change', (e) => {
Settings.set({ askNode: e.target.checked });
View.Toast.info(e.target.checked
? '下载时将弹出节点选择弹窗'
: '已切换为全自动:自动选最快镜像,失败自动换下一个');
});
page.querySelector('#ghb-s-pagebtn').addEventListener('change', (e) => {
Settings.set({ showPageButtons: e.target.checked });
View.Toast.info('页面内镜像按钮已' + (e.target.checked ? '开启' : '关闭') + ',刷新页面后生效');
});
page.querySelector('#ghb-s-launcher').addEventListener('change', (e) => {
View.setLauncherVisible(e.target.checked);
View.Toast.info('侧边启动器已' + (e.target.checked ? '显示' : '隐藏'));
});
page.querySelector('#ghb-s-reset').addEventListener('click', () => {
resetAll();
View.restoreLauncherPos();
this.refresh();
View.Toast.ok('已恢复默认设置');
});
}
},
/* ---------- 下载弹窗 ---------- */
DlModal: {
url: '',
name: '',
root: null,
mount(root) {
this.root = root;
root.querySelector('.ghb-dl-bg').addEventListener('click', () => this.close());
root.querySelector('#ghb-dl-close').addEventListener('click', () => this.close());
root.querySelector('#ghb-dl-refresh').addEventListener('click', (e) => this.onRefresh(e.currentTarget));
root.querySelector('#ghb-dl-copy').addEventListener('click', () => this.onCopy());
root.querySelector('#ghb-dl-fast').addEventListener('click', () => this.onFastest());
root.querySelector('#ghb-dl-nodes').addEventListener('click', (e) => {
const b = e.target.closest('.ghb-dl-go');
if (b) this.onDownload(b.dataset.node, b);
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && root.classList.contains('ghb-open')) this.close();
});
},
open(githubUrl, filename) {
this.url = githubUrl || '';
this.name = filename || Utils.filenameFromUrl(githubUrl);
this.root.querySelector('#ghb-dl-name').textContent = this.name;
this.renderNodes();
this.root.classList.add('ghb-open');
if (!NodeStore.nodes.length) loadNodes('弹窗').then(() => this.renderNodes());
},
close() {
this.root.classList.remove('ghb-open');
},
nodes() { return NodeStore.candidates(); },
renderNodes() {
const host = this.root.querySelector('#ghb-dl-nodes');
const list = this.nodes();
const count = this.root.querySelector('#ghb-dl-count');
if (count) count.textContent = list.length + ' 个节点';
if (!list.length) {
host.innerHTML = '暂无可用节点
正在后台自动获取…' +
'可点击右上角刷新按钮重试
';
return;
}
host.innerHTML = list.map((n) => {
const ms = n.latency || 0;
return '' +
'' + Utils.esc(Utils.shortDomain(n.url)) + '' +
'' + ms + 'ms' +
'' +
'
';
}).join('');
},
async onRefresh(btn) {
btn.querySelector('svg').classList.add('ghb-spin');
btn.disabled = true;
await loadNodes('弹窗');
btn.disabled = false;
btn.querySelector('svg').classList.remove('ghb-spin');
this.renderNodes();
View.Toast.info('节点已刷新');
},
async onCopy() {
const list = this.nodes();
if (!list.length) { View.Toast.warn('暂无节点,无法生成镜像链接'); return; }
const url = mirrorUrl(this.url, list[0].url);
const ok = await Utils.copy(url);
View.Toast.show(ok ? '已复制最快节点链接,可粘贴进下载工具' : '复制失败,请手动复制', ok ? 'ok' : 'err');
},
onFastest() {
const list = this.nodes();
if (!list.length) { View.Toast.warn('暂无可用节点'); return; }
this.onDownload(list[0].url);
},
async onDownload(nodeUrl, btn) {
const target = mirrorUrl(this.url, nodeUrl);
if (!target) { View.Toast.err('链接拼装失败'); return; }
// fast-path:fresh 候选直接 fire
if (NodeStore.isFresh(nodeUrl)) {
this.close();
Downloader.deliver(target, this.name);
View.Toast.ok('已交给浏览器下载 · ' + this.name + '|Gopeed 等工具会自动接管');
return;
}
if (btn) { btn.disabled = true; btn.textContent = '预检中…'; }
const head = await precheck(target, HEAD_TIMEOUT_FAST);
if (!head.ok) {
NodeStore.markFail(nodeUrl);
if (btn) { btn.disabled = false; btn.innerHTML = Icons.download + '下载'; }
View.Toast.err('该节点预检失败(' + head.error + '),已记入健康度,试试其他节点');
return;
}
NodeStore.markOk(nodeUrl);
this.close();
Downloader.deliver(target, this.name);
View.Toast.ok('已交给浏览器下载 · ' + this.name + '|Gopeed 等工具会自动接管');
}
}
};
/* ======================================================================
* L7 · BOOTSTRAP
* ==================================================================== */
function registerMenu() {
const items = [
['打开加速面板', () => { if (!View.Panel.open) View.Panel.toggle(); }],
['刷新镜像节点', () => loadNodes('菜单').then((ok) =>
ok ? View.Toast.ok('已刷新 ' + NodeStore.nodes.length + ' 个节点') : View.Toast.err('刷新失败'))],
['显示 / 隐藏侧边启动器', () => {
const on = !Settings.get().showLauncher;
View.setLauncherVisible(on);
View.Toast.info('侧边启动器已' + (on ? '显示' : '隐藏'));
}],
['重置全部设置', () => {
resetAll();
View.restoreLauncherPos();
View.Toast.ok('已重置,刷新页面后生效');
}]
];
if (typeof GM_registerMenuCommand === 'function') {
items.forEach(([label, fn]) => GM_registerMenuCommand(label, fn));
}
}
/** 清空全部持久化数据并恢复默认(油猴菜单与设置页共用,避免两处逻辑漂移) */
function resetAll() {
Settings.reset();
Store.remove(K.nodes); Store.remove(K.visible); Store.remove(K.updatedAt); Store.remove(K.fails); Store.remove(K.lastOk);
NodeStore.nodes = []; NodeStore.visible = [];
NodeStore.updatedAt = 0; NodeStore.fails = {}; NodeStore.lastOk = {};
NodeStore.emit();
}
function bootstrap() {
Settings.load();
NodeStore.hydrate();
View.mount();
View.restoreLauncherPos();
// 状态变更 → 面板/弹窗自动重绘,无需手工调用刷新
NodeStore.subscribe(() => {
View.Panel.renderList();
View.DlModal.renderNodes();
});
registerMenu();
if (Settings.get().showPageButtons) Injector.start();
if (NodeStore.isStale() && Settings.get().refreshOnStart) {
loadNodes('启动');
} else if (NodeStore.nodes.length) {
loadNodes('后台'); // 缓存可用,静默更新
}
setInterval(() => loadNodes('定时'), NODE_TTL);
}
try {
bootstrap();
} catch (err) {
Log.error('初始化失败', err);
}
})();