// ==UserScript==
// @name PagePilot
// @name:zh-CN 键页通
// @namespace https://greasyfork.org/scripts/541642
// @version 0.2.1
// @author oajsdfk
// @description PagePilot allows custom keybindings and CSS selectors for page-turning buttons to be uniquely configured per URL. Automatically detects webpage addresses and applies your preset rules, enabling precise one-click navigation across all sites.
// @description:zh-CN 键页通支持为不同网页(URL)独立配置按键策略与翻页按钮定位规则。自动识别当前访问网址,精准匹配预设的自定义按键及翻页按钮CSS选择器,实现一键翻页的个性化操控体验。
// @license MIT
// @match *://*/*
// @grant GM_addElement
// @grant GM_getValue
// @grant GM_registerMenuCommand
// @grant GM_setValue
// ==/UserScript==
(function () {
'use strict';
function pilot(url, event, options2) {
const find = (keys) => {
if (!keys) return false;
if (event.type === "keyup") {
if (keys.key?.find((i) => i === event.key)) return true;
if (keys.code?.find((i) => i === event.code)) return true;
} else if (event.type === "mouseup") {
if (keys.button === event.button) return true;
}
return false;
};
const g = [];
for (const [k, v] of Object.entries(options2.globalKeys)) {
if (find(v)) g.push(k);
}
function matchGlobal(k, p) {
if (find(p.keys)) return true;
if (typeof p.globalKeys === "boolean") {
if (!p.globalKeys) return false;
return g.indexOf(k) != -1;
} else if (typeof p.globalKeys === "string") {
return g.indexOf(p.globalKeys) != -1;
}
return g.indexOf(k) != -1;
}
function matchKey(p) {
if (p.cur) {
if (!p.next && !p.prev && !p.other) {
for (const i of g) {
if (i === "next") return { child: p.cur, sibling: 1 };
if (i === "prev") return { child: p.cur, sibling: -1 };
}
return;
}
for (const k of ["prev", "next"]) {
if (!p[k]) continue;
let r = typeof p[k] === "string" ? {
child: p.cur,
sibling: k === "next" ? 1 : -1,
deep: { child: p[k] }
} : p[k];
if (matchGlobal(k, r)) return r;
}
for (const [k, v] of Object.entries(p.other ?? [])) {
let r = typeof v === "string" ? {
child: p.cur,
sibling: k === "next" ? 1 : -1,
deep: { child: v }
} : v;
if (matchGlobal(k, r)) return r;
}
} else {
for (const k of ["prev", "next"]) {
if (!p[k]) continue;
let r = typeof p[k] === "string" ? { child: p[k] } : p[k];
if (matchGlobal(k, r)) return r;
}
for (const [k, v] of Object.entries(p.other ?? [])) {
let r = typeof v === "string" ? { child: v } : v;
if (matchGlobal(k, r)) return r;
}
}
return;
}
for (const [r, p] of Object.entries(options2.pilots)) {
if (!url.match(new RegExp(r))) continue;
const q = matchKey(p);
if (!q) continue;
const { child, sibling, deep } = q;
if (!child) continue;
const cur = document.querySelector(child);
const ele = queryDeep({ sibling, deep }, cur);
if (!ele) continue;
ele.click();
return ele;
}
return null;
}
function queryDeep({ sibling, child, deep }, e) {
if (child) e = e?.querySelector(child);
if (sibling) {
let c = Math.abs(sibling);
while (c > 0 && e) {
c--;
e = sibling > 0 ? e.nextElementSibling : e.previousElementSibling;
}
if (c !== 0) return null;
}
return deep ? queryDeep(deep, e) : e;
}
function renderPilotConfig(root, options2) {
root.innerHTML = `
<style>
#pilot-config-form button {
transition: background 0.2s, color 0.2s, box-shadow 0.2s;
}
#pilot-config-form button:not([type="submit"]):hover,
#pilot-config-form button:not([type="submit"]):focus {
background: #ffd369;
color: #232931;
box-shadow: 0 2px 8px rgba(255, 211, 105, 0.15);
}
#pilot-config-form button:not([type="submit"]):active {
background: #e1c15a;
color: #232931;
}
#pilot-config-form button[type="submit"]:hover,
#pilot-config-form button[type="submit"]:focus {
background: #ff6b7c;
color: #fff;
box-shadow: 0 2px 8px rgba(255, 75, 92, 0.15);
}
#pilot-config-form button[type="submit"]:active {
background: #d93a4a;
color: #fff;
}
#pilot-config-form button[type="reset"]:hover,
#pilot-config-form button[type="reset"]:focus {
background: #ffd369;
color: #232931;
box-shadow: 0 2px 8px rgba(255, 211, 105, 0.15);
}
#pilot-config-form button[type="reset"]:active {
background: #e1c15a;
color: #232931;
}
#pilot-config-form button {
border: none;
border-radius: 6px;
padding: 8px 18px;
font-weight: 500;
cursor: pointer;
background: #393e46;
color: #ffd369;
}
</style>
<form style="
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
padding: 24px 32px;
box-sizing: border-box;
z-index: 50;
position: fixed;
left: 0; top: 0; right: 0; bottom: 0;
background: linear-gradient(135deg, #232526 0%, #414345 100%);
color: #f5f6fa;
font-family: 'Segoe UI', 'Roboto', sans-serif;
font-size: 16px;
overflow-y: auto;
"
id="pilot-config-form">
<h1 style="
font-weight: bold;
text-align: center;
height: 48px;
margin-bottom: 20px;
letter-spacing: 1px;
color: #ffd369;
text-shadow: 0 2px 8px rgba(0,0,0,0.2);
">Page Pilot Setting</h1>
<textarea
id="pagepilot_detail"
style="
flex-grow: 1;
resize: none;
border-radius: 8px;
border: 1px solid #393e46;
background: #232931;
color: #f5f6fa;
padding: 12px;
margin-bottom: 18px;
font-size: 15px;
outline: none;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
">
</textarea>
<div style="
display: flex;
flex-direction: row;
height: 48px;
gap: 14px;
justify-content: center;
align-items: center;
padding: 0;
margin-bottom: 8px;
">
<button type="button" id="pilot-sample-btn">Sample</button>
<button type="button" id="pilot-clear-btn">Clear</button>
<button type="button" id="pilot-reset-btn">Reset</button>
<button type="button" id="pilot-import-btn">Import</button>
<button type="submit" style=" background: #ff4b5c; color: #fff; ">Save</button>
<button type="reset" style=" background: #393e46; color: #fff; ">Close</button>
</div>
</form>
`;
const form = root.querySelector("#pilot-config-form");
const textareaEl = root.querySelector("#pagepilot_detail");
form.onsubmit = (e) => {
e.preventDefault();
options2.save?.();
};
form.onreset = (e) => {
e.preventDefault();
options2.close?.();
};
root.querySelector("#pilot-sample-btn").onclick = () => {
if (!options2.sample) return;
textareaEl.value = options2.sample();
};
root.querySelector("#pilot-clear-btn").onclick = () => {
if (!options2.clear) return;
options2.clear();
textareaEl.value = "";
};
root.querySelector("#pilot-reset-btn").onclick = () => {
if (!options2.export) return;
textareaEl.value = options2.export();
};
root.querySelector("#pilot-import-btn").onclick = () => {
if (!options2.import) return;
textareaEl.value = options2.import(textareaEl.value);
};
}
function stringifyWithDepth(obj, maxDepth, space) {
function helper(value, depth) {
if (depth > maxDepth || value === null || typeof value !== "object") {
return JSON.stringify(value);
}
if (Array.isArray(value)) {
const items = value.map((item) => helper(item, depth + 1));
if (depth < maxDepth) {
return "[\n" + items.map((i) => " ".repeat((depth + 1) * space) + i).join(",\n") + "\n" + " ".repeat(depth * space) + "]";
} else {
return "[" + items.join(", ") + "]";
}
} else {
const entries = Object.entries(value).map(
([k, v]) => depth < maxDepth ? " ".repeat((depth + 1) * space) + JSON.stringify(k) + ": " + helper(v, depth + 1) : JSON.stringify(k) + ": " + helper(v, depth + 1)
);
if (depth < maxDepth) {
return "{\n" + entries.join(",\n") + "\n" + " ".repeat(depth * space) + "}";
} else {
return "{" + entries.join(", ") + "}";
}
}
}
return helper(obj, 0);
}
const lastOptionsVer = GM_getValue("pagepilot_options_ver");
let options = GM_getValue("pagepilot_options");
const optionsVer = 1;
if (!options || lastOptionsVer !== optionsVer) {
options = {
globalKeys: {
// 绑定名称的通用按键,所有网站的元素根据名称自动绑定到这些按键上
// Common keys bound by name, elements on all sites will be automatically bound to these keys
prev: { code: ["ArrowUp", "PageUp", "KeyP", "KeyD"], button: 5 },
next: { code: ["ArrowDown", "PageDown", "KeyN", "KeyF"], button: 4 },
top: { code: ["Home", "KeyT"] },
bottom: { code: ["End", "KeyB"] }
},
pilots: {
// 站点正则 => 元素选择器或按键
// site regex => element selector or key
"http://example1.com(/.*)?": {
// 当点击元素对应的按键时候,触发元素的点击事件: PageUp => querySelector('.pg .prev_btn').click()
// When the corresponding key is pressed, trigger the element's click event: PageUp => querySelector('.pg .prev_btn').click()
prev: ".pg .prev_btn",
next: ".pg .next_btn"
},
// 通过当前页元素的兄弟来定位翻页按钮
// locate the page-turning buttons by the siblings of the current page element
"https://(.*.)?example2.org(/.*)?": { cur: "#cur_page" },
// 通过当前页元素的兄弟的子孙来定位翻页按钮
// locate the page-turning buttons by the descendants of the siblings of the current page element
"https?://(.*.)?example3.org(/.*)?": {
cur: "#cur_page",
prev: "a",
// querySelector('#cur_page').prevElment.querySelector(a).click()
next: "a"
// querySelector('#cur_page').nextElment.querySelector(a).click()
},
"https?://(.*.)?example4.org(/.*)?": {
// 多层级子孙、兄弟的翻页按钮的定位规则: querySelector('.nav .pg').prevE.prevE.prevE.querySelector(a).click()
// multi-level descendants and siblings page button location rules (): querySelector('.nav .pg').prevE.prevE.prevE.querySelector(a).click()
prev: {
child: ".nav",
deep: {
child: ".pg",
sibling: -3,
deep: {
child: "a"
}
}
},
next: {
child: ".nav",
deep: {
child: ".pg",
sibling: 5,
// querySelector('.nav .pg') .nextE x5 .querySelector(a).click()
deep: {
child: "a"
}
}
},
// 定义翻页以外的任意按钮
// define any buttons other than page turning
other: {
// 显示指定绑定或者禁用全局按键
// Explicitly bind or disable global keys
top: "#top",
bottom: {
// 显示指定绑定或者禁用全局按键
// Explicitly bind or disable global keys
globalKeys: false,
// disable
child: "#bottomBtn",
// 指定只对该网站生效的按键
// Specify keys that only take effect on this site
keys: { code: ["KeyN"] }
},
close: {
child: "#closeBtn",
keys: { code: ["KeyC"] }
}
}
}
}
};
GM_setValue("pagepilot_options", options);
GM_setValue("pagepilot_options_ver", optionsVer);
}
console.log({ pagepilot_options_ver: optionsVer, pagepilot_options: options });
window.addEventListener("keyup", function(e) {
const ae = document.activeElement;
if (ae && ["input", "select", "button", "textarea"].indexOf(
ae.tagName.toLowerCase()
) !== -1) return;
console.log({ key: e.key, code: e.code, event: e });
if (pilot(window.location.href, e, options)) {
e.preventDefault();
}
});
window.addEventListener("mouseup", function(e) {
if (e.button < 2) return;
console.log({ button: e.button, event: e });
if (pilot(window.location.href, e, options)) {
e.preventDefault();
}
});
GM_registerMenuCommand(
"PagePilotSetting",
(_event) => {
let root = document.getElementById("pagepilot_options");
if (root) {
root.setAttribute("hidden", "false");
return;
}
root = GM_addElement(document.body, "div", { id: "pagepilot_options" });
renderPilotConfig(root, {
export() {
return stringifyWithDepth(options, 4, 2);
},
save() {
GM_setValue("pagepilot_options", options);
},
close() {
root.setAttribute("hidden", "true");
},
import(json) {
try {
const j = JSON.parse(json);
for (const [k, v] of Object.entries(j.globalKeys ?? {})) {
options.globalKeys[k] = v;
}
for (const [k, v] of Object.entries(j.pilots ?? {})) {
options.pilots[k] = v;
}
GM_setValue("pagepilot_options", options);
return stringifyWithDepth(options, 4, 2);
} catch (e) {
console.error(e);
return stringifyWithDepth(options, 4, 2);
}
},
clear() {
options = { globalKeys: {}, pilots: {} };
},
sample() {
const s = {
globalKeys: {
prev: { code: ["ArrowUp", "PageUp", "KeyP", "KeyD"] },
next: { code: ["ArrowDown", "PageDown", "KeyN", "KeyF"] },
top: { code: ["Home", "KeyT"] },
bottom: { code: ["End", "KeyB"] }
},
pilots: {
"http://example1.com(/.*)?": {
prev: ".pg .prev_btn",
next: ".pg .next_btn"
},
"https://(.*.)?example2.org(/.*)?": { cur: "#cur_page" },
"https?://(.*.)?example3.org(/.*)?": {
cur: "#cur_page",
prev: "a",
next: "a"
},
"https?://(.*.)?example4.org(/.*)?": {
prev: {
child: ".nav",
deep: {
child: ".pg",
sibling: -3,
deep: {
child: "a"
}
}
},
next: {
child: ".nav",
deep: {
child: ".pg",
sibling: 5,
deep: {
child: "a"
}
}
},
other: {
top: "#top",
bottom: {
globalKeys: false,
child: "#bottomBtn",
keys: { code: ["KeyN"] }
},
close: {
child: "#closeBtn",
keys: { code: ["KeyC"] }
}
}
}
}
};
return JSON.stringify(s, null, 2);
}
});
}
);
})();