油猴脚本哈
// ==UserScript==
// @name LINUX DO Reading Assistant
// @namespace https://linux.do/
// @version 0.3.0
// @description Slow reading helper for unread posts on LINUX DO. Can keep scrolling while the browser is in background.
// @match https://linux.do/t/topic/*
// @match http://linux.do/t/topic/*
// @grant none
// ==/UserScript==
(function () {
"use strict";
const storageKey = "linuxdo-reading-assistant-settings";
const defaultSettings = {
settingsVersion: 3,
enabled: false,
pauseOnHidden: false,
keepAlive: true,
targetOffsetRatio: 0.35,
dwellMs: 2800,
nudgePx: 180,
};
let settings = loadSettings();
let timer = 0;
let scheduleId = 0;
let dueAt = 0;
let ticking = false;
let workerTimer = createWorkerTimer();
let heartbeatTimer = 0;
let audioContext = null;
let audioOscillator = null;
let audioGain = null;
let wakeLock = null;
let wakeLockRequesting = false;
let target = null;
let targetReadyAt = 0;
const consumedTargets = new WeakSet();
const consumedTargetKeys = new Set();
const panel = document.createElement("div");
panel.id = "linuxdo-reading-assistant";
panel.innerHTML = `
<style>
#linuxdo-reading-assistant {
position: fixed;
right: 18px;
bottom: 18px;
z-index: 99999;
width: 228px;
box-sizing: border-box;
padding: 12px;
border: 1px solid rgba(0, 0, 0, 0.12);
border-radius: 8px;
background: rgba(255, 255, 255, 0.94);
color: #222;
box-shadow: 0 8px 26px rgba(0, 0, 0, 0.14);
font: 13px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
#linuxdo-reading-assistant * {
box-sizing: border-box;
}
#linuxdo-reading-assistant .ra-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
#linuxdo-reading-assistant .ra-title {
font-weight: 700;
}
#linuxdo-reading-assistant button {
min-width: 64px;
border: 0;
border-radius: 6px;
padding: 6px 10px;
color: #fff;
cursor: pointer;
background: #2f80ed;
font-weight: 700;
}
#linuxdo-reading-assistant button.is-off {
background: #777;
}
#linuxdo-reading-assistant label {
display: grid;
gap: 4px;
margin-top: 10px;
}
#linuxdo-reading-assistant input[type="range"] {
width: 100%;
}
#linuxdo-reading-assistant .ra-checkbox {
display: flex;
grid-template-columns: none;
align-items: center;
gap: 6px;
}
#linuxdo-reading-assistant .ra-checkbox input {
margin: 0;
}
#linuxdo-reading-assistant .ra-status {
margin-top: 8px;
min-height: 18px;
color: #666;
word-break: break-word;
}
</style>
<div class="ra-row">
<div class="ra-title">阅读辅助</div>
<button type="button" class="ra-toggle"></button>
</div>
<label>
<span>停留时间 <b class="ra-dwell-value"></b>s</span>
<input class="ra-dwell" type="range" min="2" max="12" step="1">
</label>
<label>
<span>滑动步长 <b class="ra-step-value"></b>px</span>
<input class="ra-step" type="range" min="80" max="420" step="20">
</label>
<label class="ra-checkbox">
<input class="ra-background" type="checkbox">
<span>切到其它页面也继续</span>
</label>
<label class="ra-checkbox">
<input class="ra-keepalive" type="checkbox">
<span>后台保活(切应用更稳)</span>
</label>
<div class="ra-status"></div>
`;
document.documentElement.appendChild(panel);
const toggleButton = panel.querySelector(".ra-toggle");
const dwellInput = panel.querySelector(".ra-dwell");
const dwellValue = panel.querySelector(".ra-dwell-value");
const stepInput = panel.querySelector(".ra-step");
const stepValue = panel.querySelector(".ra-step-value");
const backgroundInput = panel.querySelector(".ra-background");
const keepAliveInput = panel.querySelector(".ra-keepalive");
const statusNode = panel.querySelector(".ra-status");
dwellInput.value = String(Math.round(settings.dwellMs / 1000));
stepInput.value = String(settings.nudgePx);
backgroundInput.checked = !settings.pauseOnHidden;
keepAliveInput.checked = settings.keepAlive;
toggleButton.addEventListener("click", () => {
settings.enabled = !settings.enabled;
if (!settings.enabled) {
resetTarget();
}
saveSettings();
syncKeepAlive();
render();
schedule(150);
});
dwellInput.addEventListener("input", () => {
settings.dwellMs = Number(dwellInput.value) * 1000;
saveSettings();
render();
});
stepInput.addEventListener("input", () => {
settings.nudgePx = Number(stepInput.value);
saveSettings();
render();
});
backgroundInput.addEventListener("change", () => {
settings.pauseOnHidden = !backgroundInput.checked;
saveSettings();
syncKeepAlive();
render();
schedule(150);
});
keepAliveInput.addEventListener("change", () => {
settings.keepAlive = keepAliveInput.checked;
saveSettings();
syncKeepAlive();
render();
schedule(150);
});
document.addEventListener("visibilitychange", () => {
syncKeepAlive();
schedule(250);
render();
});
window.addEventListener("focus", () => {
syncKeepAlive();
schedule(100);
});
window.addEventListener("pageshow", () => {
syncKeepAlive();
schedule(100);
});
window.addEventListener("beforeunload", () => {
stopHeartbeat();
stopKeepAlive();
if (workerTimer) {
workerTimer.terminate();
workerTimer = null;
}
});
render();
syncKeepAlive();
schedule(500);
function loadSettings() {
try {
const raw = JSON.parse(localStorage.getItem(storageKey) || "{}");
const migrated = {
...defaultSettings,
...raw,
};
// 0.1.0 没有“后台继续”开关,并且默认写入 pauseOnHidden=true。
// 升级后默认改成后台继续,避免旧 localStorage 继续把脚本卡在后台暂停。
if (!raw.settingsVersion) {
migrated.pauseOnHidden = false;
migrated.keepAlive = true;
migrated.settingsVersion = defaultSettings.settingsVersion;
localStorage.setItem(storageKey, JSON.stringify(migrated));
} else if (raw.settingsVersion < defaultSettings.settingsVersion) {
migrated.settingsVersion = defaultSettings.settingsVersion;
if (typeof raw.keepAlive !== "boolean") {
migrated.keepAlive = true;
}
localStorage.setItem(storageKey, JSON.stringify(migrated));
}
return migrated;
} catch {
return {
...defaultSettings,
};
}
}
function saveSettings() {
localStorage.setItem(storageKey, JSON.stringify(settings));
}
function render(message) {
toggleButton.textContent = settings.enabled ? "开启" : "关闭";
toggleButton.classList.toggle("is-off", !settings.enabled);
dwellValue.textContent = String(Math.round(settings.dwellMs / 1000));
stepValue.textContent = String(settings.nudgePx);
backgroundInput.checked = !settings.pauseOnHidden;
keepAliveInput.checked = settings.keepAlive;
if (message) {
statusNode.textContent = message;
return;
}
if (!settings.enabled) {
statusNode.textContent = "已暂停";
return;
}
if (settings.pauseOnHidden && document.hidden) {
statusNode.textContent = "后台暂停中";
return;
}
if (document.hidden) {
statusNode.textContent = settings.keepAlive ? "后台保活继续中" : "后台继续中";
return;
}
statusNode.textContent = "运行中";
}
function schedule(delay) {
const id = ++scheduleId;
dueAt = Date.now() + Math.max(0, Number(delay) || 0);
window.clearTimeout(timer);
timer = window.setTimeout(() => runScheduledTick(id), delay);
startHeartbeat();
if (workerTimer) {
workerTimer.postMessage({ type: "schedule", id, delay, dueAt });
}
}
function runScheduledTick(id) {
if (id !== scheduleId) {
return;
}
if (ticking) {
return;
}
window.clearTimeout(timer);
timer = 0;
dueAt = 0;
ticking = true;
try {
tick();
} finally {
ticking = false;
}
}
function runDueTick(id) {
if (id !== scheduleId || !dueAt || Date.now() < dueAt) {
return;
}
runScheduledTick(id);
}
function tick() {
if (!settings.enabled) {
render("已暂停");
return;
}
if (settings.pauseOnHidden && document.hidden) {
render("后台暂停中");
schedule(1200);
return;
}
if (
!target ||
!target.isConnected ||
isRead(target) ||
isConsumed(target)
) {
target = findNextUnread();
targetReadyAt = 0;
if (!target) {
render("没有找到未读标记");
schedule(1800);
return;
}
}
const rect = target.getBoundingClientRect();
const desiredTop = window.innerHeight * settings.targetOffsetRatio;
if (rect.top < 90 || rect.top > window.innerHeight * 0.72) {
window.scrollTo({
top: Math.max(0, window.scrollY + rect.top - desiredTop),
behavior: scrollBehavior(),
});
targetReadyAt = 0;
render(document.hidden ? "后台定位未读帖" : "定位未读帖");
schedule(document.hidden ? 450 : 900);
return;
}
if (!targetReadyAt) {
targetReadyAt = Date.now();
}
const elapsed = Date.now() - targetReadyAt;
if (!isRead(target) && elapsed < settings.dwellMs) {
const remaining = settings.dwellMs - elapsed;
render(
document.hidden
? `后台等待 ${Math.ceil(remaining / 1000)}s`
: "等待当前帖变为已读"
);
schedule(Math.max(250, Math.min(remaining, 1200)));
return;
}
if (!isRead(target)) {
rememberConsumed(target);
}
window.scrollBy({
top: settings.nudgePx,
left: 0,
behavior: scrollBehavior(),
});
render(document.hidden ? "后台滑到下一帖" : "滑到下一帖");
resetTarget();
schedule(document.hidden ? 500 : 900);
}
function findNextUnread() {
const candidates = Array.from(
document.querySelectorAll('.read-state[title*="未读"], .read-state:not(.read)')
).filter((node) => !isRead(node) && !isConsumed(node));
return candidates.find((node) => node.getBoundingClientRect().bottom > 80) || null;
}
function isRead(node) {
return node.classList.contains("read");
}
function resetTarget() {
target = null;
targetReadyAt = 0;
}
function scrollBehavior() {
// 后台页的 smooth 动画经常被浏览器节流或暂停;后台直接改 scrollY 更稳。
return document.hidden ? "auto" : "smooth";
}
function startHeartbeat() {
if (heartbeatTimer) {
return;
}
heartbeatTimer = window.setInterval(() => runDueTick(scheduleId), 1000);
}
function stopHeartbeat() {
if (!heartbeatTimer) {
return;
}
window.clearInterval(heartbeatTimer);
heartbeatTimer = 0;
}
function rememberConsumed(node) {
consumedTargets.add(node);
const key = getTargetKey(node);
if (key) {
consumedTargetKeys.add(key);
if (consumedTargetKeys.size > 1000) {
consumedTargetKeys.clear();
}
}
}
function isConsumed(node) {
if (consumedTargets.has(node)) {
return true;
}
const key = getTargetKey(node);
return Boolean(key && consumedTargetKeys.has(key));
}
function getTargetKey(node) {
const post = node.closest(
"[data-post-id], [data-post-number], article[id], .topic-post[id], [id^='post_']"
);
if (!post) {
return "";
}
return (
post.getAttribute("data-post-id") ||
post.getAttribute("data-post-number") ||
post.id ||
""
);
}
function createWorkerTimer() {
try {
const blob = new Blob(
[
`
let timer = 0;
let heartbeat = 0;
let activeId = 0;
let dueAt = 0;
function emitDue() {
if (activeId && dueAt && Date.now() >= dueAt) {
self.postMessage({ id: activeId, due: true });
}
}
self.onmessage = (event) => {
const data = event.data || {};
if (data.type === "stop") {
activeId = 0;
dueAt = 0;
clearTimeout(timer);
clearInterval(heartbeat);
heartbeat = 0;
return;
}
activeId = data.id;
dueAt = Number(data.dueAt) || Date.now() + Math.max(0, Number(data.delay) || 0);
clearTimeout(timer);
timer = setTimeout(() => {
self.postMessage({ id: data.id });
}, Math.max(0, Number(data.delay) || 0));
if (!heartbeat) {
heartbeat = setInterval(emitDue, 1000);
}
};
`,
],
{ type: "text/javascript" }
);
const url = URL.createObjectURL(blob);
const worker = new Worker(url);
URL.revokeObjectURL(url);
worker.onmessage = (event) => runScheduledTick(event.data && event.data.id);
worker.onerror = () => {
worker.terminate();
workerTimer = null;
};
return worker;
} catch {
return null;
}
}
function syncKeepAlive() {
if (settings.enabled && !settings.pauseOnHidden && settings.keepAlive) {
startKeepAlive();
requestWakeLock();
} else {
stopKeepAlive();
releaseWakeLock();
}
}
function startKeepAlive() {
startHeartbeat();
const AudioCtor = window.AudioContext || window.webkitAudioContext;
if (!AudioCtor) {
return false;
}
try {
if (!audioContext) {
audioContext = new AudioCtor({ latencyHint: "playback" });
}
if (!audioOscillator) {
audioGain = audioContext.createGain();
// 极低音量的连续音频可以减少 Chrome/Edge 在切到其它应用后对计时器和 App Nap 的节流。
// 不是 0,是因为完全静音通常不会被浏览器当成“正在播放音频”。
audioGain.gain.value = 0.0008;
audioOscillator = audioContext.createOscillator();
audioOscillator.type = "sine";
audioOscillator.frequency.value = 220;
audioOscillator.connect(audioGain);
audioGain.connect(audioContext.destination);
audioOscillator.start();
}
if (audioContext.state === "suspended") {
audioContext.resume().catch(() => {});
}
if (navigator.mediaSession) {
navigator.mediaSession.playbackState = "playing";
}
return true;
} catch {
return false;
}
}
function stopKeepAlive() {
if (navigator.mediaSession) {
navigator.mediaSession.playbackState = "none";
}
if (audioOscillator) {
try {
audioOscillator.stop();
} catch {}
try {
audioOscillator.disconnect();
} catch {}
audioOscillator = null;
}
if (audioGain) {
try {
audioGain.disconnect();
} catch {}
audioGain = null;
}
if (audioContext && audioContext.state !== "closed") {
audioContext.suspend().catch(() => {});
}
}
async function requestWakeLock() {
if (
wakeLock ||
wakeLockRequesting ||
!("wakeLock" in navigator) ||
document.visibilityState !== "visible"
) {
return;
}
wakeLockRequesting = true;
try {
wakeLock = await navigator.wakeLock.request("screen");
wakeLock.addEventListener("release", () => {
wakeLock = null;
});
} catch {
wakeLock = null;
} finally {
wakeLockRequesting = false;
}
}
async function releaseWakeLock() {
if (!wakeLock) {
return;
}
const lock = wakeLock;
wakeLock = null;
try {
await lock.release();
} catch {}
}
})();


