// ==UserScript==
// @name Qidian Chapter Downloader
// @name:zh-CN 起点章节下载器
// @namespace http://tampermonkey.net/
// @version 0.5
// @description Download chapter content from qidian
// @description:zh-CN 从起点下载章节文本
// @author oovz
// @match https://www.qidian.com/chapter/*
// @grant none
// @source https://gist.github.com/oovz/3257e1acd16ef2fa2913b430d95dc283
// @license MIT
// ==/UserScript==
(function() {
'use strict';
// Configure your XPath here
const TITLE_XPATH = '//div[contains(@class, "chapter-wrapper")]//div[contains(@class, "print")]//h1'; // Fill this with your XPath
const CONTENT_XPATH = '//div[contains(@class, "chapter-wrapper")]//div[contains(@class, "print")]//main/p'; // Base path to p elements
const CONTENT_SPAN_XPATH = '//div[contains(@class, "chapter-wrapper")]//div[contains(@class, "print")]//main/p/span'; // For p with span structure
const CHAPTER_WRAPPER_XPATH = '//div[contains(@class, "chapter-wrapper")]';
// Internationalization
const isZhCN = navigator.language.toLowerCase() === 'zh-cn' ||
document.documentElement.lang.toLowerCase() === 'zh-cn';
const i18n = {
copyText: isZhCN ? '复制文本' : 'Copy Content',
copiedText: isZhCN ? '已复制!' : 'Copied!',
nextChapter: isZhCN ? '下一章' : 'Next Chapter',
noNextChapter: isZhCN ? '没有下一章' : 'No Next Chapter'
};
// Create GUI elements
const gui = document.createElement('div');
gui.style.cssText = `
position: fixed;
bottom: 20px;
right: 20px;
background: white;
padding: 15px;
border: 1px solid #ccc;
border-radius: 5px;
box-shadow: 0 0 10px rgba(0,0,0,0.1);
z-index: 9999;
resize: both;
overflow: visible;
min-width: 350px;
min-height: 250px;
max-width: 100vw;
max-height: 80vh;
resize-origin: top-left;
display: flex;
flex-direction: column;
`;
// Add CSS for custom resize handle at top-left
const style = document.createElement('style');
style.textContent = `
@keyframes spin {
to { transform: rotate(360deg); }
}
.resize-handle {
position: absolute;
width: 14px;
height: 14px;
top: 0;
left: 0;
cursor: nwse-resize;
z-index: 10000;
background-color: #888;
border-top-left-radius: 5px;
border-right: 1px solid #ccc;
border-bottom: 1px solid #ccc;
}
.spinner-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(240, 240, 240, 0.8);
display: none;
justify-content: center;
align-items: center;
z-index: 10001;
}
`;
document.head.appendChild(style);
// Create resize handle
const resizeHandle = document.createElement('div');
resizeHandle.className = 'resize-handle';
const output = document.createElement('textarea');
output.style.cssText = `
width: 100%;
flex: 1;
margin-bottom: 8px;
resize: none;
overflow: auto;
box-sizing: border-box;
min-height: 180px;
`;
output.readOnly = true;
// Create button container for horizontal layout
const buttonContainer = document.createElement('div');
buttonContainer.style.cssText = `
display: flex;
justify-content: center;
gap: 10px;
margin-bottom: 2px;
`;
const copyButton = document.createElement('button');
copyButton.textContent = i18n.copyText;
copyButton.style.cssText = `
padding: 4px 12px;
cursor: pointer;
background-color: #4285f4;
color: white;
border: none;
border-radius: 15px;
font-weight: bold;
font-size: 0.9em;
`;
// Create next chapter button
const nextChapterButton = document.createElement('button');
nextChapterButton.textContent = i18n.nextChapter;
nextChapterButton.style.cssText = `
padding: 4px 12px;
cursor: pointer;
background-color: #34a853;
color: white;
border: none;
border-radius: 15px;
font-weight: bold;
font-size: 0.9em;
`;
// Add buttons to container
buttonContainer.appendChild(copyButton);
buttonContainer.appendChild(nextChapterButton);
// Create spinner overlay for better positioning
const spinnerOverlay = document.createElement('div');
spinnerOverlay.className = 'spinner-overlay';
// Create spinner
const spinner = document.createElement('div');
spinner.style.cssText = `
width: 30px;
height: 30px;
border: 4px solid rgba(0,0,0,0.1);
border-radius: 50%;
border-top-color: #333;
animation: spin 1s ease-in-out infinite;
`;
spinnerOverlay.appendChild(spinner);
// Add elements to GUI
gui.appendChild(resizeHandle);
gui.appendChild(output);
gui.appendChild(buttonContainer);
gui.appendChild(spinnerOverlay);
document.body.appendChild(gui);
// Custom resize functionality
let isResizing = false;
let originalWidth, originalHeight, originalX, originalY;
resizeHandle.addEventListener('mousedown', (e) => {
e.preventDefault();
isResizing = true;
originalWidth = parseFloat(getComputedStyle(gui).width);
originalHeight = parseFloat(getComputedStyle(gui).height);
originalX = e.clientX;
originalY = e.clientY;
document.addEventListener('mousemove', resize);
document.addEventListener('mouseup', stopResize);
});
function resize(e) {
if (!isResizing) return;
const width = originalWidth - (e.clientX - originalX);
const height = originalHeight - (e.clientY - originalY);
if (width > 300 && width < window.innerWidth * 0.8) {
gui.style.width = width + 'px';
// Keep right position fixed and adjust left position
gui.style.right = getComputedStyle(gui).right;
}
if (height > 250 && height < window.innerHeight * 0.8) {
gui.style.height = height + 'px';
// Keep bottom position fixed and adjust top position
gui.style.bottom = getComputedStyle(gui).bottom;
}
}
function stopResize() {
isResizing = false;
document.removeEventListener('mousemove', resize);
document.removeEventListener('mouseup', stopResize);
}
// Extract text function
function getElementsByXpath(xpath) {
const results = [];
const query = document.evaluate(
xpath,
document,
null,
XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,
null
);
for (let i = 0; i < query.snapshotLength; i++) {
const node = query.snapshotItem(i);
if (node) {
// Get only direct text content and exclude child elements
let directTextContent = '';
for (let j = 0; j < node.childNodes.length; j++) {
const childNode = node.childNodes[j];
if (childNode.nodeType === Node.TEXT_NODE) {
directTextContent += childNode.textContent;
}
}
// Only trim if it's the title (preserve indentation for content)
if (xpath === TITLE_XPATH) {
directTextContent = directTextContent.trim();
if (directTextContent) {
results.push(directTextContent);
}
} else {
// For content, preserve indentation
if (directTextContent) {
results.push(directTextContent);
}
}
}
}
return results;
}
// Initial extraction
function updateTitleOutput() {
const elements = getElementsByXpath(TITLE_XPATH);
return elements.join('\n');
}
function updateContentOutput() {
// Try to get content from spans first
let elements = getElementsByXpath(CONTENT_SPAN_XPATH);
// If no spans found, try direct p tags but filter out those with spans to avoid duplications
if (elements.length === 0) {
// First, get all p elements
const pElements = document.evaluate(
CONTENT_XPATH,
document,
null,
XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,
null
);
for (let i = 0; i < pElements.snapshotLength; i++) {
const pNode = pElements.snapshotItem(i);
// Check if this p element has span children
const hasSpans = pNode.querySelectorAll('span').length > 0;
if (!hasSpans) {
// Only get text from p elements that don't have spans
elements.push(pNode.textContent);
}
}
}
return elements.join('\n');
}
// Async update function
async function updateOutput() {
// Show spinner overlay
spinnerOverlay.style.display = 'flex';
// Use setTimeout to make it async and not block the UI
setTimeout(() => {
try {
const title = updateTitleOutput();
const content = updateContentOutput();
output.value = title ? title + '\n\n' + content : content;
} catch (error) {
console.error('Error updating output:', error);
} finally {
// Hide spinner when done
spinnerOverlay.style.display = 'none';
}
}, 0);
}
// Run initial extraction
updateOutput();
// Add event listener for copy button
copyButton.addEventListener('click', () => {
output.select();
document.execCommand('copy');
copyButton.textContent = i18n.copiedText;
setTimeout(() => {
copyButton.textContent = i18n.copyText;
}, 1000);
});
// Add event listener for next chapter button
nextChapterButton.addEventListener('click', () => {
// Find the next chapter link using the provided XPath
const nextChapterQuery = document.evaluate(
'//div[@class="nav-btn-group"]/a[last()]',
document,
null,
XPathResult.FIRST_ORDERED_NODE_TYPE,
null
);
const nextChapterLink = nextChapterQuery.singleNodeValue;
if (nextChapterLink) {
// Navigate to the next chapter
window.location.href = nextChapterLink.href;
} else {
// Show a message if there's no next chapter
nextChapterButton.textContent = i18n.noNextChapter;
nextChapterButton.style.backgroundColor = '#ea4335';
setTimeout(() => {
nextChapterButton.textContent = i18n.nextChapter;
nextChapterButton.style.backgroundColor = '#34a853';
}, 2000);
}
});
// Find the chapter wrapper element to observe
const chapterWrapperQuery = document.evaluate(
CHAPTER_WRAPPER_XPATH,
document,
null,
XPathResult.FIRST_ORDERED_NODE_TYPE,
null
);
const chapterWrapper = chapterWrapperQuery.singleNodeValue;
// Update when the chapter wrapper changes
if (chapterWrapper) {
const observer = new MutationObserver(() => {
updateOutput();
});
observer.observe(chapterWrapper, {
childList: true,
subtree: true,
characterData: true
});
} else {
console.error('Chapter wrapper element not found.');
}
})();