* 再点它」—— 真实链接点击不会被弹窗拦截器挡掉,而且能带上 rel=noopener
* 不给目标页 opener 权限。合成的链接打标记让点击守卫放行,免得自己拦截自己。
*/
function openInNewTab(to) {
try {
const a = document.createElement("a");
a.href = to;
a.target = "_blank";
a.rel = "noopener";
a.setAttribute(ATTR + "-bypass", "1");
a.style.display = "none";
(document.body || document.documentElement).appendChild(a);
a.click();
if (a.parentNode) a.parentNode.removeChild(a);
return true;
} catch (e) {
return false;
}
}
function navigate(to, newTab) {
if (newTab) {
try {
const w = rawOpen ? rawOpen(to, "_blank") : win.open(to, "_blank");
if (w) return;
} catch (e) {
/* 落到合成链接 */
}
if (openInNewTab(to)) return;
}
try {
location.assign(to);
} catch (e) {
win.location.href = to;
}
}
/** 记一次「免中转」—— 只计数与留痕,不弹提示(结果看得见,提示是噪音) */
function noteSkip(to, why) {
bump("skip");
pushEvent("skip", location.href, to);
if (why) log("免中转跳转 →", to, why);
}
function hostOf(u) {
const x = safeUrl(u);
return x ? x.hostname : String(u).slice(0, 40);
}
function onClick(e) {
if (!opt.on) return;
if (typeof e.button === "number" && e.button !== 0) return;
const a = anchorFromEvent(e);
if (!a) return;
if (a.getAttribute(ATTR + "-bypass") === "1") return; // 我们自己合成的导航链接
// 注意:这里**不**因 e.defaultPrevented 提前返回。
// React / Vue 这类框架会在自己的根容器上先跑一遍处理器(可能已经
// preventDefault 掉),但那往往正是「站点在拦这次点击」的表现 ——
// 我们要接管它,而不是让出去。真正的「自己人」由 bypass 标记挡掉。
const hit = resolveAnchor(a);
let to = hit ? hit.to : "";
let why = hit ? hit.why : "";
if (!to && opt.guard && a.getAttribute(ATTR) === "1") {
// href 已经被我们处理过,但站点自己的 click 处理器可能再把它拉回中转页。
// 接管掉,让这一次点击只做「去目标站」这一件事。
const marked = a.getAttribute(ATTR + "-to");
to = isHttpUrl(marked) ? marked : a.href;
why = "takeover";
}
// 网盘分享页:把页面上(就链接旁边)的提取码拼上,让这一次点击直接到内容
if (opt.panAuto) {
if (!to && !netdiskCandidate(a)) return;
const nd = netdiskTarget(a, to || a.href);
if (nd) to = nd.to;
}
if (!to) return;
const newTab = opt.newTab || e.ctrlKey || e.metaKey || e.shiftKey || (a.target && a.target !== "_self");
e.preventDefault();
e.stopPropagation();
// 只要**终点**是带码的网盘链接,就按「已带提取码」记 —— 不管码是点击时现拼的
// 还是预解析先拼好的,用户感知到的都是「点一下就到、码已经在了」。
const u = safeUrl(to);
const pan = opt.panAuto && u && panOf(u.hostname);
const code = pan && readPwdFromUrl(u.href);
if (pan && code) {
bump("fill");
pushEvent("fill", pan.name, code);
} else {
noteSkip(to, why);
}
// 成功一律不提示:人已经到地方了,结果自己会说话
navigate(to, newTab);
}
/** 便宜的预筛:这个链接有没有可能是网盘分享页?(避免为一个普通链接去跑 ancestry 遍历) */
function netdiskCandidate(a) {
const href = a && a.href;
if (!href) return false;
const u = safeUrl(href);
if (!u || !/^https?:$/.test(u.protocol)) return false;
if (u.pathname === "/" || u.pathname === "") return false;
return !!panOf(u.hostname);
}
/** window.open:站点用脚本开中转页时同样直接换掉 */
function hookOpen() {
if (!opt.on || !opt.guard || !win || !isFn(win.open)) return;
if (win.__jhOpenHooked) return;
const original = win.open;
const wrapped = function (url, target, features) {
try {
if (opt.on && isString(url)) {
const to = resolveTarget(url, location.href);
if (to && to !== url && shouldJump(url, to, location.href)) {
log("window.open 免中转 →", to);
return original.call(this, to, target, features);
}
}
} catch (e) {
/* 出错就走原路 */
}
return original.call(this, url, target, features);
};
try {
Object.defineProperty(wrapped, "name", { value: "open" });
Object.defineProperty(wrapped, "toString", { value: () => original.toString() });
} catch (e) {
/* 部分环境不允许改,忽略 */
}
try {
win.open = wrapped;
win.__jhOpenHooked = true;
} catch (e) {
/* 改不动就算了,点击接管仍然生效 */
}
}
/* ---- 落地中转页自动跳 ---- */
function pageText() {
try {
const body = document.body;
if (!body) return "";
return String(body.innerText || body.textContent || "").slice(0, 1200);
} catch (e) {
return "";
}
}
function tryAutoJump() {
if (!opt.on || !opt.autoJump) return;
const here = location.href;
const rule = currentRule();
// ① 站点规则显式声明是中转页
if (rule && rule.jump) {
const j = rule.jump;
let matched = false;
if (j.path) matched = j.path instanceof RegExp ? j.path.test(location.pathname) : location.pathname.indexOf(j.path) >= 0;
else matched = true;
if (matched) {
let target = "";
if (isFn(j.custom)) {
const r = j.custom();
if (isString(r) && isHttpUrl(r)) target = r;
}
if (!target && j.attr) {
const el = j.from ? qs(j.from) : document.body;
const v = el && el.getAttribute ? el.getAttribute(j.attr) : "";
if (isHttpUrl(v)) target = normToUrl(v);
}
if (!target && j.sep) {
const part = here.split(j.sep)[1];
if (part) target = normToUrl(part);
}
if (!target && j.param) {
const u = safeUrl(here);
const hit = u && pickRedirectParam(u.search, [].concat(j.param));
if (hit) target = normToUrl(hit.value);
}
if (!target) target = resolveTarget(here, here);
if (target && isHttpUrl(target) && target !== here && !isSameHost(hostOf(target), HOST)) {
// 有明确按钮就先点按钮(贴合站点自己的跳转逻辑),否则直接 replace
if (j.click) {
const btn = qs(j.click);
if (btn) {
noteSkip(target, "规则点击");
try {
btn.click();
} catch (e) {
navigate(target, false);
}
return;
}
}
noteSkip(target, "规则自跳");
navigate(target, false);
return;
}
}
}
// ② 通用评分:路径像中转页 + 参数里带跨站地址 + 页面上写着「即将跳转」
const text = pageText();
const target = resolveTarget(here, here);
const u = safeUrl(here);
const score = scoreInterstitial({
ruleHit: false,
target: target && target !== here ? target : "",
targetHost: target && target !== here ? hostOf(target) : "",
pageHost: HOST,
jumpPath: !!u && isJumpPath(u.pathname),
text,
hasCountdown: /\b\d+\s*(?:秒|s)\s*(?:后|之后)?\s*(?:自动)?(?:跳转|离开)/i.test(text) || /setTimeout|countdown/i.test(text.slice(0, 200))
});
if (score >= INTERSTITIAL_THRESHOLD && target && target !== here && !isSameHost(hostOf(target), HOST) && !sameRegistry(hostOf(target), HOST)) {
noteSkip(target, "评分 " + score);
navigate(target, false);
}
}
/* ==========================================================================
* 7. 网盘:分享页自动填码
* ======================================================================== */
function setNativeValue(el, val) {
const prev = el.value;
try {
el.focus();
} catch (e) {
/* 忽略 */
}
el.value = val;
try {
const tracker = el._valueTracker;
if (tracker && isFn(tracker.setValue)) tracker.setValue(prev);
} catch (e) {
/* 忽略 */
}
try {
el.dispatchEvent(new Event("input", { bubbles: true }));
el.dispatchEvent(new Event("change", { bubbles: true }));
el.dispatchEvent(new KeyboardEvent("keyup", { bubbles: true, key: "Enter" }));
} catch (e) {
/* 忽略 */
}
}
const isInput = (el) => !!el && /^(INPUT|TEXTAREA)$/.test(el.tagName || "");
/** 逐个选择器试,避免一条写错的选择器把整个列表带崩 */
function firstMatching(selectors, accept) {
for (const sel of selectors) {
for (const el of qsa(sel)) {
if (accept(el)) return el;
}
}
return null;
}
function panInputOf(pan) {
const direct = firstMatching(pan.input, (el) => isInput(el) && isVisible(el));
if (direct) return direct;
// 兜底:页面上确实写着「提取码 / 访问码 / 密码」时才认通用输入框,
// 免得在网盘首页把搜索框 / 登录框当提取码框填了。
if (!pan.hint.test(pageText())) return null;
const generic = qsa('input[type="password"], input[type="text"]').filter((el) => isInput(el) && isVisible(el));
return generic.length === 1 ? generic[0] : null;
}
function panButtonOf(pan) {
return firstMatching(pan.button, (el) => isVisible(el)) || firstMatching(pan.button, () => true);
}
function autoFill() {
if (!opt.on || !opt.panAuto) return;
const pan = panOf(HOST);
if (!pan) return;
const pwd = readPwdFromUrl(location.href);
if (!pwd) return;
let tries = 0;
const timer = setInterval(() => {
tries++;
const input = panInputOf(pan);
if (input) {
clearInterval(timer);
if (input.value === pwd) return;
setNativeValue(input, pwd);
bump("fill");
pushEvent("fill", pan.name, pwd);
log(`已为「${pan.name}」自动填入提取码`);
// 填完就提交 —— 用户此刻可能在别的标签页,多一次手动确认就白自动化了
setTimeout(() => {
const btn = panButtonOf(pan);
if (btn && isVisible(btn)) {
try {
btn.click();
} catch (e) {
/* 忽略 */
}
}
}, 600);
return;
}
if (tries >= 25) clearInterval(timer);
}, 400);
}
/* ==========================================================================
* 8. UI 层(jh- 前缀,遵循仓库 UI 规范)
* ======================================================================== */
const CSS = `
html{
--jh-accent:#2da44e; --jh-accent-2:#1a7f37; --jh-accent-fg:#ffffff;
--jh-good:#2da44e; --jh-warn:#d29922; --jh-bad:#f85149;
}
.jh-scope{
--jh-bg:#0d1117; --jh-bg-2:#161b22; --jh-bg-3:#21262d;
--jh-bd:#30363d; --jh-bd-2:#21262d;
--jh-fg:#e6edf3; --jh-fg-2:#8b949e; --jh-fg-3:#6e7681;
--jh-shadow:0 16px 44px rgba(0,0,0,.5);
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;
color:var(--jh-fg); font-size:13px; line-height:1.5;
}
.jh-scope.jh-light{
--jh-bg:#ffffff; --jh-bg-2:#f6f8fa; --jh-bg-3:#eaeef2;
--jh-bd:#d0d7de; --jh-bd-2:#d8dee4;
--jh-fg:#1f2328; --jh-fg-2:#59636e; --jh-fg-3:#818b98;
--jh-shadow:0 16px 44px rgba(31,35,40,.16);
}
.jh-scope svg{width:1em;height:1em;fill:currentColor;flex:none;vertical-align:-.125em;}
#jh-overlay{position:fixed; inset:0; z-index:2147483001; background:rgba(0,0,0,.5); opacity:0; pointer-events:none; transition:opacity .2s;}
#jh-overlay.jh-open{opacity:1; pointer-events:auto;}
#jh-panel{position:fixed; left:50%; top:50%; z-index:2147483002; width:460px; max-width:calc(100vw - 32px); max-height:84vh;
background:var(--jh-bg); border:1px solid var(--jh-bd); border-radius:14px; box-shadow:var(--jh-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);}
#jh-panel.jh-open{opacity:1; transform:translate(-50%,-50%) scale(1); pointer-events:auto;}
.jh-head{display:flex; align-items:center; gap:10px; padding:14px 16px; border-bottom:1px solid var(--jh-bd-2); flex:none;}
.jh-head h2{margin:0; font-size:15px; font-weight:600; color:var(--jh-fg);}
.jh-head .jh-ver{font-size:11px; color:var(--jh-fg-2); border:1px solid var(--jh-bd); border-radius:999px; padding:1px 7px;}
.jh-spacer{flex:1;}
.jh-tabs{display:flex; border-bottom:1px solid var(--jh-bd-2); flex:none; background:var(--jh-bg-2);}
.jh-tab{flex:1; padding:10px 0; border:none; background:transparent; cursor:pointer; font-family:inherit; font-size:13px;
color:var(--jh-fg-2); border-bottom:2px solid transparent; transition:color .15s, background .15s;}
.jh-tab:hover{color:var(--jh-fg); background:var(--jh-bg-3);}
.jh-tab.jh-on{color:var(--jh-fg); font-weight:600; border-bottom-color:var(--jh-accent);}
.jh-body{flex:1; overflow-y:auto; min-height:200px;}
.jh-body::-webkit-scrollbar{width:8px;}
.jh-body::-webkit-scrollbar-thumb{background:var(--jh-bd); border-radius:4px;}
.jh-page{display:none; padding:14px 16px 16px;} .jh-page.jh-on{display:block;}
.jh-card{background:var(--jh-bg-2); border:1px solid var(--jh-bd-2); border-radius:10px; padding:12px; margin-bottom:12px;}
.jh-card h3{margin:0 0 10px; font-size:12px; font-weight:600; color:var(--jh-fg-2); letter-spacing:.02em;}
.jh-stats{display:flex; gap:10px;}
.jh-stat{flex:1; background:var(--jh-bg-3); border-radius:8px; padding:10px 12px; text-align:center;}
.jh-stat b{display:block; font-size:20px; font-weight:600; color:var(--jh-fg); line-height:1.2;}
.jh-stat span{font-size:11px; color:var(--jh-fg-2);}
.jh-btn{display:inline-flex; align-items:center; gap:5px; padding:5px 10px; border:1px solid var(--jh-bd); border-radius:6px;
background:var(--jh-bg-3); color:var(--jh-fg); font-family:inherit; font-size:12px; cursor:pointer; white-space:nowrap;
transition:background .15s, border-color .15s, opacity .15s;}
.jh-btn:hover{background:var(--jh-bd); border-color:var(--jh-fg-3);}
.jh-btn[disabled]{opacity:.5; pointer-events:none;}
.jh-btn.jh-primary{background:var(--jh-accent); border-color:var(--jh-accent); color:var(--jh-accent-fg);}
.jh-btn.jh-primary:hover{background:var(--jh-accent-2);}
.jh-btn.jh-danger:hover{background:var(--jh-bad); border-color:var(--jh-bad); color:#fff;}
.jh-icon-btn{width:28px; height:28px; display:flex; align-items:center; justify-content:center; border:none; border-radius:6px;
background:transparent; color:var(--jh-fg-2); cursor:pointer; font-size:16px; transition:background .15s, color .15s;}
.jh-icon-btn:hover{background:var(--jh-bg-3); color:var(--jh-fg);}
.jh-switch{position:relative; width:40px; height:22px; flex:none; cursor:pointer;}
.jh-switch input{position:absolute; inset:0; width:100%; height:100%; margin:0; opacity:0; cursor:pointer; z-index:1;}
.jh-switch i{display:block; width:40px; height:22px; border-radius:11px; background:var(--jh-bd); transition:background .22s; position:relative;}
.jh-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);}
.jh-switch input:checked + i{background:var(--jh-accent);}
.jh-switch input:checked + i::after{transform:translateX(18px);}
.jh-field{display:flex; align-items:center; gap:10px; padding:7px 0;}
.jh-field + .jh-field{border-top:1px solid var(--jh-bd-2);}
.jh-field .jh-name{font-size:13px; color:var(--jh-fg); flex:none;}
.jh-field .jh-desc{font-size:11px; color:var(--jh-fg-2); flex:1;}
.jh-tag{display:inline-block; font-size:11px; padding:2px 7px; margin:0 6px 6px 0; border-radius:999px;
border:1px solid var(--jh-bd); color:var(--jh-fg-2); cursor:pointer; transition:all .15s;}
.jh-tag:hover{border-color:var(--jh-fg-3); color:var(--jh-fg);}
.jh-tag.jh-off{opacity:.42; text-decoration:line-through;}
.jh-row{display:flex; align-items:center; gap:8px; padding:7px 0;}
.jh-row + .jh-row{border-top:1px solid var(--jh-bd-2);}
.jh-row .jh-k{font-size:11px; color:var(--jh-fg-2); flex:none;}
.jh-row .jh-v{font-size:12px; color:var(--jh-fg); flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;}
.jh-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; font-size:11.5px;}
.jh-empty{font-size:12px; color:var(--jh-fg-2); padding:6px 0;}
.jh-note{font-size:11px; color:var(--jh-fg-3); line-height:1.6;}
.jh-pill{font-size:11px; padding:1px 7px; border-radius:999px; border:1px solid var(--jh-bd); color:var(--jh-fg-2);}
.jh-pill.jh-ok{color:var(--jh-good); border-color:var(--jh-good);}
.jh-pill.jh-warn{color:var(--jh-warn); border-color:var(--jh-warn);}
.jh-btns{display:flex; gap:8px; flex-wrap:wrap;}
#jh-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;}
.jh-toast{display:flex; align-items:center; gap:8px; max-width:min(560px, calc(100vw - 32px)); padding:9px 14px;
border-radius:8px; font-family:inherit; 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;}
.jh-toast.jh-show{opacity:1; transform:none;}
.jh-toast.jh-info{background:#1f6feb;} .jh-toast.jh-ok{background:var(--jh-good);}
.jh-toast.jh-warn{background:#9e6a03;} .jh-toast.jh-err{background:#b62324;}
`;
const ICON = {
link: '',
close: ''
};
let uiRoot = null;
let uiLight = false;
/**
* 轻提示。
*
* 只在两个地方出现:**首次安装那一次**(否则用户没法确认脚本是否生效),
* 以及**面板里的操作反馈**(用户主动点的,理应有回应)。
* 脚本的自动行为(免中转 / 带提取码 / 填码)一律不调用它。
*/
function toast(msg, kind) {
try {
let box = document.getElementById("jh-toasts");
if (!box) {
box = document.createElement("div");
box.id = "jh-toasts";
(document.body || document.documentElement).appendChild(box);
}
const el = document.createElement("div");
el.className = "jh-toast jh-" + (kind || "info");
el.textContent = msg;
box.appendChild(el);
requestAnimationFrame(() => el.classList.add("jh-show"));
setTimeout(() => {
el.classList.remove("jh-show");
setTimeout(() => el.remove(), 260);
}, 2600);
} catch (e) {
/* 忽略 */
}
}
function addStyle(id, css) {
if (document.getElementById(id)) return;
const el = document.createElement("style");
el.id = id;
el.textContent = css;
(document.head || document.documentElement).appendChild(el);
}
/* ---- 面板 ---- */
function esc(s) {
return String(s == null ? "" : s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
}
function shortLink(u) {
const s = String(u || "");
return s.length > 52 ? s.slice(0, 30) + "…" + s.slice(-18) : s;
}
function fmtTime(t) {
const d = new Date(t);
const p = (n) => String(n).padStart(2, "0");
return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
}
const KIND_LABEL = { skip: "免中转", resolve: "预解析", fill: "带提取码" };
function overviewHTML() {
const rule = currentRule();
const pan = panOf(HOST);
const hostTip = rule
? `已适配`
: `未适配 · 走通用参数兜底`;
const jumpReady = rule && rule.jump;
const events = eventLog.length
? eventLog.slice(0, 8).map((e) => `
${fmtTime(e.t)}
${esc(KIND_LABEL[e.k] || e.k)}
${esc(shortLink(e.to))}
`).join("")
: `还没有记录。点一个站外链接试试。
`;
return `
当前页面
站点${esc(HOST || "-")}${hostTip}
规则${esc(rule ? rule.name : "无站点规则")}
${jumpReady ? "含中转页自跳" : "仅链接改写"}
网盘${esc(pan ? pan.name : "当前页面不是网盘分享页")}
统计
${stat.skip}已免中转
${stat.resolve}已预解析链接
${stat.fill}已带提取码
怎么用
不用做任何操作 —— 直接点链接就行:
· 站外链接:跳过「安全提示 / 即将离开 / 确认跳转」这类中转页
· 网盘链接:自动把写在链接旁边的提取码拼上,落地后自动填入并提交
· 统一在新标签页打开,原页面留着;中键 / 右键同样生效
· 过程里不会有任何弹窗或提示。这里只是用来看状态和改设置的地方。
最近事件
${events}
`;
}
/**
* 设置分组。
*
* 分组依据是「用户会在什么场景下想动它」,不是实现模块:
* 主开关 —— 临时不想用它
* 跳转行为 —— 想改变「跳到哪儿 / 怎么跳」
* 网盘 —— 提取码抓错时整体关掉
* 站点 —— 某个站不能动
*/
const OPT_GROUPS = [
{
title: "主开关",
note: "关掉后所有功能停用,管理器菜单仍然保留,方便再打开。",
fields: [["on", "总开关", "全部功能的总闸"]]
},
{
title: "跳转行为",
fields: [
["newTab", "在新标签页打开", "原页面留着,不被打断"],
["autoJump", "落地中转页自动跳走", "不用再点「继续访问」"],
["guard", "压掉站点拦截", "站点自己的跳转 / 统计 / 弹窗处理器抢不走这次点击"]
]
},
{
title: "网盘",
note: "带码、填码、提交是一件事的三个步骤,所以合成一个开关。抓错码就整体关掉,免中转部分不受影响。",
fields: [["panAuto", "自动带提取码", "点网盘链接时取链接旁的码拼上,落地后填入并提交"]]
}
];
function optHTML() {
const cards = OPT_GROUPS.map((g) => `
${esc(g.title)}
${(g.fields || [])
.map(
([k, name, desc]) => `
${esc(name)}
${esc(desc)}
`
)
.join("")}
${g.note ? `
${esc(g.note)}
` : ""}
`).join("");
const skipped = isSkippedSite();
const skipTags = opt.skipHosts.length
? opt.skipHosts.map((h) => `${esc(h)}`).join("")
: `还没有不处理的站点。
`;
const ruleTags = SITES.map((s) => {
const off = opt.mute.indexOf(s.name) >= 0;
return `${esc(s.name)}`;
}).join("");
return `
${cards}
站点
本站不处理
${esc(HOST || "-")} 命中后本页完全跳过
不处理名单(点一下移除):
${skipTags}
站点规则 · ${SITES.length} 条(点标签停用单条)
${ruleTags}
数据
存储键${KEY.opt} · ${KEY.stat} · ${KEY.log}
读取提取码从 URL 的 pwd 参数读取,不发任何网络请求
`;
}
function panelHTML() {
return `
${ICON.link}
链接直跳
v${VERSION}
${overviewHTML()}
${optHTML()}
`;
}
let currentTab = "overview";
function renderPages() {
if (!uiRoot) return;
const map = { overview: overviewHTML, opt: optHTML };
for (const key of Object.keys(map)) {
const host = uiRoot.querySelector(`.jh-page[data-page="${key}"]`);
if (host) host.innerHTML = map[key]();
}
}
function openPanel(tab) {
if (!opt.on) {
toast("脚本已被关闭,可先到设置里打开总开关", "warn");
}
if (!uiRoot) {
addStyle("jh-style", CSS);
const wrap = document.createElement("div");
wrap.innerHTML = panelHTML();
(document.body || document.documentElement).appendChild(wrap);
uiRoot = document;
bindPanel();
}
if (tab) switchTab(tab);
else renderPages();
const ov = document.getElementById("jh-overlay");
const pn = document.getElementById("jh-panel");
if (ov) ov.classList.add("jh-open");
if (pn) pn.classList.add("jh-open");
}
function closePanel() {
const ov = document.getElementById("jh-overlay");
const pn = document.getElementById("jh-panel");
if (ov) ov.classList.remove("jh-open");
if (pn) pn.classList.remove("jh-open");
}
function switchTab(tab) {
currentTab = tab;
if (!uiRoot) return;
qsa(".jh-tab").forEach((b) => b.classList.toggle("jh-on", b.getAttribute("data-tab") === tab));
qsa(".jh-page").forEach((p) => p.classList.toggle("jh-on", p.getAttribute("data-page") === tab));
renderPages();
}
function bindPanel() {
document.addEventListener("click", (e) => {
const t = e.target;
if (!t || !t.closest) return;
const tabBtn = t.closest(".jh-tab");
if (tabBtn && tabBtn.closest("#jh-panel")) {
switchTab(tabBtn.getAttribute("data-tab"));
return;
}
if (t.id === "jh-overlay") {
closePanel();
return;
}
const unskip = t.closest("[data-unskip]");
if (unskip && unskip.closest("#jh-panel")) {
removeSkipHost(unskip.getAttribute("data-unskip"));
return;
}
const tag = t.closest(".jh-tag");
if (tag && tag.closest("#jh-panel")) {
toggleMute(tag.getAttribute("data-mute"));
return;
}
const act = t.closest("[data-act]");
if (act && act.closest("#jh-panel")) {
handleAction(act.getAttribute("data-act"), act);
}
}, true);
document.addEventListener("change", (e) => {
const t = e.target;
if (!t || !t.getAttribute || !t.closest || !t.closest("#jh-panel")) return;
if (t.hasAttribute("data-skip-here")) {
toggleSkipHere(!!t.checked);
return;
}
const key = t.getAttribute("data-opt");
if (!key) return;
opt[key] = !!t.checked;
saveOpt();
}, true);
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") closePanel();
}, true);
}
/** 一键把当前站点加入 / 移出不处理名单 —— 比让人手打逗号分隔的域名友好得多 */
function toggleSkipHere(on) {
const h = normHost(HOST);
if (!h) return;
const i = opt.skipHosts.indexOf(h);
if (on && i < 0) opt.skipHosts.push(h);
if (!on && i >= 0) opt.skipHosts.splice(i, 1);
saveOpt();
renderPages();
toast(on ? `${h} 已加入不处理名单,刷新后生效` : `${h} 已移出不处理名单`, "ok");
}
function removeSkipHost(host) {
const i = opt.skipHosts.indexOf(host);
if (i < 0) return;
opt.skipHosts.splice(i, 1);
saveOpt();
renderPages();
}
function toggleMute(name) {
const i = opt.mute.indexOf(name);
if (i >= 0) opt.mute.splice(i, 1);
else opt.mute.push(name);
saveOpt();
renderPages();
}
function handleAction(act, el) {
switch (act) {
case "close":
closePanel();
break;
case "rescan":
scan(document);
deepScan();
invalidatePagePwd();
toast("已重新扫描本页链接", "ok");
break;
case "all-on":
opt.mute = [];
saveOpt();
renderPages();
toast("已启用全部站点规则", "ok");
break;
case "reset":
opt = Object.assign({}, DEFAULT_OPT);
saveOpt();
renderPages();
toast("已恢复默认设置", "ok");
break;
case "reset-stat":
stat.skip = stat.resolve = stat.fill = 0;
eventLog = [];
saveStat();
gm.set(KEY.log, []);
renderPages();
toast("统计与日志已重置", "ok");
break;
default:
break;
}
}
function registerMenu() {
if (!IS_TOP) return;
gm.menu("链接直跳 · 打开面板", () => openPanel());
gm.menu("链接直跳 · 重新扫描本页链接", () => {
scan(document);
deepScan();
invalidatePagePwd();
toast("已重新扫描本页链接", "ok");
});
gm.menu(`链接直跳 · 已免中转 ${stat.skip} 次(点击重置)`, () => {
stat.skip = stat.resolve = stat.fill = 0;
eventLog = [];
saveStat();
gm.set(KEY.log, []);
toast("统计已重置", "ok");
});
}
/* ==========================================================================
* 9. 编排
* ======================================================================== */
let observer = null;
/**
* 增量扫描队列。
*
* 只扫「新增子树」而不是整页重扫 —— 动态页面(Next.js / React 水合、
* 无限滚动、评论区懒加载)里链接是**逐步插进来**的,而首屏 deepScan
* 只跑一次,漏掉的再没人管。这就是「同一页里靠前的链接接管了、靠后的
* 没接管」的根因。
*
* 队列按帧合并;同一节点只处理一次。
*/
const pendingRoots = [];
let pendingSet = null;
function queueNode(node) {
if (!node || node.nodeType !== 1) return;
if (!pendingSet) pendingSet = new Set();
if (pendingSet.has(node)) return;
pendingSet.add(node);
pendingRoots.push(node);
}
/**
* 把一个新增子树里的链接按「通用规则」过一遍。
*
* 与 deepScan 共用 `resolveLink` —— 站点规则命中与否都要走,
* 未适配站点靠的就是这条路。
*/
function scanRoot(root) {
if (!opt.on || !root) return 0;
let n = 0;
const links = [];
try {
if (root.tagName === "A" && root.href) links.push(root);
const inner = root.querySelectorAll ? root.querySelectorAll("a[href]") : [];
for (const a of inner) links.push(a);
} catch (e) {
return 0;
}
for (const a of links) {
try {
if (resolveLink(a)) n++;
} catch (e) {
/* 单条失败不影响其它 */
}
}
if (n) bump("resolve");
return n;
}
function flushRoots() {
if (!pendingRoots.length) return;
const batch = pendingRoots.splice(0, pendingRoots.length);
pendingSet = null;
for (const node of batch) {
if (!node.isConnected) continue;
// 已经在别处处理过的子树不必重复走
if (node.closest && node.closest("[" + ATTR + "]") === node) continue;
scanRoot(node);
}
}
function startObserver() {
const root = document.documentElement;
if (!root || observer) return;
// 回调是异步的,跑起来时文档可能已经不是当初那个了(重导航 / 宿主换文档),
// 那时再按老上下文扫新 DOM 只会改错别人的链接
const doc = document;
let queued = false;
observer = new MutationObserver((records) => {
if (document !== doc) return; // 环境已失效
// 页面变了,整页提取码缓存作废 —— 帖子异步加载出「提取码:xxxx」时才知道有码
invalidatePagePwd();
for (const r of records) {
// 只看新增节点;属性变更(站点自己改 href)由点击层兜底
if (r.type !== "childList") continue;
for (const node of r.addedNodes || []) queueNode(node);
}
if (queued || !pendingRoots.length) return;
queued = true;
// 合并同一帧内的多次变更,避免动态页面里反复查询
const run = () => {
if (document !== doc) return; // 环境已失效
queued = false;
// ① 站点规则命中的部分仍走 scan(它按选择器精确改写)
const n = scan(document);
if (n) bump("resolve");
// ② 其余按通用规则处理新增子树
flushRoots();
};
if (typeof requestAnimationFrame === "function") requestAnimationFrame(run);
else setTimeout(run, 16);
});
observer.observe(root, { childList: true, subtree: true });
}
function init() {
loadStore();
if (isSkippedSite()) {
log("站点在不处理名单里,本页跳过");
return;
}
if (!opt.on) {
registerMenu();
return;
}
/* 尽早挂上 —— 必须在页面自己的脚本之前,否则点击已被站点接管 */
document.addEventListener("click", onClick, true);
hookOpen();
const boot = () => {
addStyle("jh-style", CSS);
startObserver();
scan(document);
// deepScan 扫完全页链接即收工;脚本不往页面注入任何东西
deepScan();
registerMenu();
setTimeout(() => {
autoFill();
tryAutoJump();
}, 120);
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot, { once: true });
} else {
boot();
}
if (!gm.get(KEY.flag, false)) {
gm.set(KEY.flag, true);
setTimeout(() => toast("链接直跳已启用:点外链不再经过中转页", "info"), 1200);
}
log(`v${VERSION} 已加载 · ${HOST} · 规则 ${SITES.length} 条 · 网盘 ${PANS.length} 家`);
}
/* ==========================================================================
* 10. 导出(Node / jsdom 直测用)
* ======================================================================== */
const API = {
VERSION,
ATTR,
KEY,
STRONG_PARAMS,
WEAK_PARAMS,
SITES,
PANS,
INTERSTITIAL_THRESHOLD,
// 纯函数
decodeLoose,
tryDecodeBase64,
safeUrl,
normToUrl,
isHttpUrl,
isSameHost,
sameRegistry,
registrable,
isJumpPath,
queryPairs,
pickRedirectParam,
stepOnce,
resolveTarget,
shouldJump,
scoreInterstitial,
panOf,
parsePwd,
parsePwdAll,
withPwd,
readPwdFromUrl,
// 引擎
eng: {
scan,
scanRoot,
deepScan,
resolveLink,
isPanShare,
onClick,
tryAutoJump,
autoFill,
resolveAnchor,
applyRewrite,
netdiskTarget,
netdiskCandidate,
findNearbyPwd,
hasSiblingPanLink,
pagePwds,
invalidatePagePwd,
navigate,
pageText,
},
ui: {
open: openPanel,
close: closePanel,
get tab() {
return currentTab;
},
showToast: toast,
/** 供 preview.html 之类的静态预览复用同一套样式 */
css: CSS,
render: { overviewHTML, optHTML }
},
state: {
get opt() {
return opt;
},
get stat() {
return stat;
},
get log() {
return eventLog;
},
loadStore,
saveOpt,
currentRule,
migrate: migrateOpt
}
};
if (typeof module !== "undefined" && module.exports) module.exports = API;
if (typeof window !== "undefined") {
try {
win.__jumpHub__ = API;
} catch (e) {
/* 忽略 */
}
}
// 只在真实浏览器环境里自启动,方便 Node 里 require 直测
if (typeof document !== "undefined" && typeof window !== "undefined" && typeof location !== "undefined") {
try {
init();
} catch (e) {
try {
log("初始化失败:", e && e.message);
} catch (e2) {
/* 忽略 */
}
}
}
})();