// ==UserScript==
// @name 连续下一页 - 优化版
// @namespace https://scriptcat.org/zh-CN/scripts/create
// @version 1.1.0
// @description 智能预加载下一页,滚动到底部自动拼接,无感加载,右下角显示加载进度。
// @author YZD
// @license MIT
// @match *://*/*
// @grant GM_addStyle
// @run-at document-end
// @icon https://images.icon-icons.com/2063/PNG/512/download_down_arrow_browser_webpage_icon_124661.png
// ==/UserScript==
(function () {
'use strict';
// ─── 状态面板 ───────────────────────────────────────────────
const panel = document.createElement('div');
panel.id = 'np-panel';
panel.innerHTML = `
`;
document.body.appendChild(panel);
GM_addStyle(`
#np-panel {
position: fixed;
bottom: 12px;
right: 12px;
width: 180px;
background: rgba(255,255,255,0.96);
border-radius: 10px;
box-shadow: 0 4px 18px rgba(0,0,0,0.18);
font-family: "Segoe UI", system-ui, sans-serif;
font-size: 12px;
color: #333;
z-index: 2147483647;
user-select: none;
}
.np-header {
display: flex;
align-items: center;
gap: 6px;
padding: 7px 10px;
background: linear-gradient(135deg,#667eea,#764ba2);
color: #fff;
border-radius: 10px 10px 0 0;
cursor: move;
}
.np-title { flex: 1; font-size: 12px; font-weight: 600; }
.np-toggle { cursor: pointer; font-size: 10px; padding: 2px 4px; }
.np-content { padding: 8px 10px 10px; }
.np-page {
font-size: 11px;
font-weight: bold;
color: #667eea;
margin-bottom: 4px;
text-align: center;
}
.np-status {
font-size: 11px;
text-align: center;
color: #555;
line-height: 1.5;
}
.np-status.loading { color: #667eea; }
.np-status.success { color: #2c9f67; }
.np-status.error { color: #e05f66; }
.np-divider {
border: none;
border-top: 2px dashed #667eea44;
margin: 16px 0 4px;
}
.np-page-label {
text-align: center;
font-size: 11px;
color: #764ba2;
font-weight: bold;
margin: 4px 0 8px;
}
`);
// ─── 折叠 ────────────────────────────────────────────────────
const toggleBtn = panel.querySelector('.np-toggle');
const content = panel.querySelector('.np-content');
let collapsed = false;
toggleBtn.addEventListener('click', () => {
collapsed = !collapsed;
content.style.display = collapsed ? 'none' : 'block';
toggleBtn.textContent = collapsed ? '▲' : '▼';
});
// ─── 拖拽 ────────────────────────────────────────────────────
const header = panel.querySelector('.np-header');
header.addEventListener('mousedown', (e) => {
const sx = e.clientX, sy = e.clientY;
const sr = parseInt(getComputedStyle(panel).right, 10) || 12;
const sb = parseInt(getComputedStyle(panel).bottom, 10) || 12;
panel.style.cursor = 'grabbing';
const move = (e) => {
panel.style.right = `${Math.max(0, sr + sx - e.clientX)}px`;
panel.style.bottom = `${Math.max(0, sb - sy + e.clientY)}px`;
};
const up = () => {
panel.style.cursor = 'default';
document.removeEventListener('mousemove', move);
};
document.addEventListener('mousemove', move);
document.addEventListener('mouseup', up, { once: true });
});
// ─── 工具函数 ─────────────────────────────────────────────────
const statusEl = panel.querySelector('.np-status');
const pageEl = panel.querySelector('.np-page');
let pageCount = 1;
function setStatus(text, cls = '') {
statusEl.textContent = text;
statusEl.className = 'np-status ' + cls;
}
function setPage(n) {
pageCount = n;
pageEl.textContent = `第 ${n} 页`;
}
// ─── 下一页链接检测(多策略)────────────────────────────────
const NEXT_SELECTORS = [
'a[rel="next"]',
'link[rel="next"]',
'.next a', '.next-page a', '.pagination-next a',
'a.next', 'a.next-page', 'a.nextpage',
'[class*="next"] a',
'[aria-label="Next page"]',
'[aria-label="下一页"]',
'[aria-label="next"]',
'a[href*="page="]:last-of-type',
];
const NEXT_TEXT = ['下一页','下一篇','下页','next','next page','»','›','>>'];
function findNextLink() {
// 1. 标准选择器
for (const sel of NEXT_SELECTORS) {
const el = document.querySelector(sel);
if (el && el.href) return el.href;
}
// 2. 按文字内容匹配所有
const anchors = Array.from(document.querySelectorAll('a[href]'));
for (const a of anchors) {
const txt = a.textContent.trim().toLowerCase();
if (NEXT_TEXT.includes(txt) && a.href) return a.href;
}
// 3. 按当前 URL 自动推断页码
return guessNextUrl();
}
function guessNextUrl() {
const url = new URL(location.href);
// ?page=N 或 ?p=N
for (const key of ['page', 'p', 'paged', 'pg', 'pagenum', 'pageno']) {
if (url.searchParams.has(key)) {
const cur = parseInt(url.searchParams.get(key), 10);
if (!isNaN(cur)) {
url.searchParams.set(key, cur + 1);
return url.toString();
}
}
}
// 路径中的 /page/N/ 或 /p/N/
const pathMatch = url.pathname.match(/\/(page|p)\/(\d+)(\/?)$/i);
if (pathMatch) {
const next = parseInt(pathMatch[2], 10) + 1;
url.pathname = url.pathname.replace(pathMatch[0], `/${pathMatch[1]}/${next}${pathMatch[3]}`);
return url.toString();
}
// 路径末尾 -N 或 _N
const numMatch = url.pathname.match(/[-_](\d+)(\.html?)?$/i);
if (numMatch) {
const next = parseInt(numMatch[1], 10) + 1;
url.pathname = url.pathname.replace(numMatch[0], `-${next}${numMatch[2] || ''}`);
return url.toString();
}
return null;
}
// ─── 内容容器检测 ─────────────────────────────────────────────
const CONTENT_SELECTORS = [
'article', 'main',
'[role="main"]',
'.post-content', '.article-content', '.entry-content',
'.content', '#content', '#main', '.main',
'.post', '.article',
];
function findContainer(doc) {
for (const sel of CONTENT_SELECTORS) {
const el = doc.querySelector(sel);
if (el) return el;
}
return doc.body;
}
// ─── 拼接逻辑 ─────────────────────────────────────────────────
let currentNextUrl = null;
let isFetching = false;
function fetchAndAppend(url) {
if (isFetching) return;
isFetching = true;
setStatus('正在加载...', 'loading');
fetch(url, { credentials: 'include' })
.then((r) => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.text();
})
.then((html) => {
const doc = new DOMParser().parseFromString(html, 'text/html');
const srcEl = findContainer(doc);
const dstEl = findContainer(document);
// 添加分隔线 + 页码标签
const hr = document.createElement('hr');
hr.className = 'np-divider';
const label = document.createElement('div');
label.className = 'np-page-label';
label.textContent = `—— 第 ${pageCount + 1} 页 ——`;
dstEl.appendChild(hr);
dstEl.appendChild(label);
// 拼接内容
Array.from(srcEl.childNodes).forEach((node) => {
dstEl.appendChild(node.cloneNode(true));
});
setPage(pageCount + 1);
setStatus('已拼接', 'success');
// 更新下一页 URL(从新文档中重新查找)
// 临时替换 document.querySelector 上下文
const oldBody = document.body;
const tmpDiv = document.createElement('div');
tmpDiv.innerHTML = doc.body.innerHTML;
currentNextUrl = (() => {
for (const sel of NEXT_SELECTORS) {
const el = tmpDiv.querySelector(sel);
if (el && el.href) return el.href;
}
const anchors = Array.from(tmpDiv.querySelectorAll('a[href]'));
for (const a of anchors) {
const txt = a.textContent.trim().toLowerCase();
if (NEXT_TEXT.includes(txt) && a.href) return a.href;
}
return null;
})();
if (!currentNextUrl) {
// 降级:继续用 URL 推断
currentNextUrl = (() => {
const u = new URL(url);
for (const key of ['page','p','paged','pg','pagenum','pageno']) {
if (u.searchParams.has(key)) {
const cur = parseInt(u.searchParams.get(key), 10);
if (!isNaN(cur)) { u.searchParams.set(key, cur + 1); return u.toString(); }
}
}
return null;
})();
}
if (!currentNextUrl) setStatus('已到最后一页', 'success');
})
.catch((err) => {
setStatus('加载失败: ' + err.message, 'error');
})
.finally(() => {
isFetching = false;
});
}
// ─── 滚动监听 ─────────────────────────────────────────────────
let scrollTimer = null;
window.addEventListener('scroll', () => {
clearTimeout(scrollTimer);
scrollTimer = setTimeout(() => {
const distToBottom = document.body.scrollHeight - window.innerHeight - window.scrollY;
if (distToBottom > 300) return;
const url = currentNextUrl || findNextLink();
if (!url) {
setStatus('未找到下一页', 'error');
return;
}
if (url === location.href) {
setStatus('已是最后一页', 'success');
return;
}
currentNextUrl = url;
fetchAndAppend(url);
}, 200);
});
})();