PagePilot

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.

// ==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);
        }
      });
    }
  );

})();