Last active
April 15, 2025 03:44
-
-
Save oovz/3257e1acd16ef2fa2913b430d95dc283 to your computer and use it in GitHub Desktop.
Download chapter content from qidian
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ==UserScript== | |
// @name Qidian Chapter Downloader | |
// @name:zh-CN 起点章节下载器 | |
// @namespace http://tampermonkey.net/ | |
// @version 0.7 | |
// @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[@class="content-text"]'; // For p with span structure | |
const CHAPTER_WRAPPER_XPATH = '//div[contains(@class, "chapter-wrapper")]'; | |
const NEXT_CHAPTER_BUTTON_XPATH = '//div[@class="nav-btn-group"]/a[last()]'; | |
const AUTHOR_SAY_XPATH = '//section[@id="r-authorSay"]//p[@class="trans"]'; // XPath for author say | |
// 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', | |
includeAuthorSay: isZhCN ? '包含作家说' : 'Include Author Say', | |
excludeAuthorSay: isZhCN ? '排除作家说' : 'Exclude Author Say' | |
}; | |
// State variable for author say inclusion | |
let includeAuthorSay = true; | |
// 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; | |
`; | |
// Create toggle author say button | |
const toggleAuthorSayButton = document.createElement('button'); | |
toggleAuthorSayButton.textContent = includeAuthorSay ? i18n.excludeAuthorSay : i18n.includeAuthorSay; | |
toggleAuthorSayButton.style.cssText = ` | |
padding: 4px 12px; | |
cursor: pointer; | |
background-color: #fbbc05; /* Yellow */ | |
color: white; | |
border: none; | |
border-radius: 15px; | |
font-weight: bold; | |
font-size: 0.9em; | |
`; | |
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(toggleAuthorSayButton); | |
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 for title | |
if (xpath === TITLE_XPATH) { | |
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; | |
} | |
} | |
directTextContent = directTextContent.trim(); | |
if (directTextContent) { | |
results.push(directTextContent); | |
} | |
} else { | |
// For content and author say, get full text content including children, preserving whitespace | |
const textContent = node.textContent; // Keep original whitespace | |
// Only push if the content is not just whitespace | |
if (textContent && textContent.trim()) { | |
results.push(textContent); | |
} | |
} | |
} | |
} | |
return results; | |
} | |
// Initial extraction | |
function updateTitleOutput() { | |
const elements = getElementsByXpath(TITLE_XPATH); | |
return elements.join('\n'); | |
} | |
function updateContentOutput(includeAuthorSayFlag) { | |
// 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 | |
const textContent = pNode.textContent; // Keep original whitespace | |
// Only push if the content is not just whitespace | |
if (textContent && textContent.trim()) { | |
elements.push(textContent); | |
} | |
} | |
} | |
} | |
// Join elements, do not trim here to preserve first line indentation | |
let content = elements.join('\n'); | |
// Append author say if requested | |
if (includeAuthorSayFlag) { | |
const authorSayElements = getElementsByXpath(AUTHOR_SAY_XPATH); | |
if (authorSayElements.length > 0) { | |
// Join author say elements, do not trim here | |
const authorSayContent = authorSayElements.join('\n'); | |
// Add separation if both content and author say exist and are not just whitespace | |
if (content.trim() && authorSayContent.trim()) { | |
content += '\n\n---\n\n' + authorSayContent; // Add separator | |
} else if (authorSayContent.trim()) { // Only author say exists (and is not just whitespace) | |
content = authorSayContent; | |
} | |
} | |
} | |
return content; // Return potentially leading-whitespace content | |
} | |
// 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(includeAuthorSay); // Pass the state | |
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 toggle author say button | |
toggleAuthorSayButton.addEventListener('click', () => { | |
includeAuthorSay = !includeAuthorSay; // Toggle state | |
toggleAuthorSayButton.textContent = includeAuthorSay ? i18n.excludeAuthorSay : i18n.includeAuthorSay; | |
updateOutput(); // Update the content | |
}); | |
// 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( | |
NEXT_CHAPTER_BUTTON_XPATH, | |
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.'); | |
} | |
})(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment