Last active
May 16, 2025 05:48
-
-
Save oovz/8c1c38607ed01cb594ebbd4913ff2c60 to your computer and use it in GitHub Desktop.
A tampermonkey/violentmonkey script to download chapter content from Changpei(gongzicp.com)
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 Changpei Chapter Downloader | |
// @name:zh-CN 长佩章节下载器 | |
// @namespace http://tampermonkey.net/ | |
// @version 0.2 | |
// @description Download chapter content from Changpei(gongzicp.com) | |
// @description:zh-CN 从长佩(gongzicp.com)下载章节文本 | |
// @author oovz | |
// @match *://*gongzicp.com/read-*.html | |
// @grant none | |
// @source https://gist.github.com/oovz/8c1c38607ed01cb594ebbd4913ff2c60 | |
// @source https://greasyfork.org/en/scripts/536172-changpei-chapter-downloader | |
// @license MIT | |
// ==/UserScript== | |
(function() { | |
'use strict'; | |
// Configure your selectors here | |
const APP_WRAPPER_SELECTOR = '#app'; // for MutationObserver | |
const TITLE_SELECTOR = 'div.title > div.name'; // for title | |
const CONTENT_SELECTOR = 'div.h-reader > div.content'; // for content | |
const NEXT_CHAPTER_BASE_SELECTOR = 'div#readPage div.item > a'; // for next chaper link | |
const NEXT_ICON_IDENTIFIER = 'ic_next'; // for next chapter icon | |
const AUTHOR_SAY_SELECTOR = 'div.h-reader div.postscript > div.value'; // 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); | |
} | |
// Helper function to find the next chapter link | |
function findNextChapterLink() { | |
// Find all navigation links | |
const navLinks = document.querySelectorAll(NEXT_CHAPTER_BASE_SELECTOR); | |
console.log(`Found ${navLinks.length} navigation link candidates`); | |
// Look for the link with the next chapter icon | |
for (const link of navLinks) { | |
const iconImg = link.querySelector('img.iconfont'); | |
if (iconImg) { | |
console.log(`Found icon with src: ${iconImg.src}`); | |
if (iconImg.src && iconImg.src.includes(NEXT_ICON_IDENTIFIER)) { | |
console.log(`Found next chapter link: ${link.href}`); | |
return link; | |
} | |
} | |
} | |
console.log('No next chapter link found'); | |
return null; // No next chapter link found | |
} | |
// Legacy XPath extraction function (kept for fallback compatibility) | |
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 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() { | |
// Use querySelector with the new selector for title | |
const titleElement = document.querySelector(TITLE_SELECTOR); | |
if (titleElement) { | |
// Extract direct text content, similar to XPath approach | |
let directTextContent = ''; | |
for (let i = 0; i < titleElement.childNodes.length; i++) { | |
const childNode = titleElement.childNodes[i]; | |
if (childNode.nodeType === Node.TEXT_NODE) { | |
directTextContent += childNode.textContent; | |
} | |
} | |
return directTextContent.trim(); | |
} | |
return ''; | |
} | |
function updateContentOutput(includeAuthorSayFlag) { | |
// Use querySelector to get the content container | |
const contentContainer = document.querySelector(CONTENT_SELECTOR); | |
let elements = []; | |
if (contentContainer) { | |
// Get all p elements within the content container | |
const paragraphs = contentContainer.querySelectorAll('p'); | |
// Process each paragraph, excluding those with the "watermark" class | |
paragraphs.forEach(p => { | |
// Skip paragraphs with the "watermark" class | |
if (!p.classList.contains('watermark')) { | |
const textContent = p.textContent; | |
// Only add paragraphs that have non-whitespace content | |
if (textContent && textContent.trim()) { | |
elements.push(textContent); | |
} | |
} | |
}); | |
} | |
if (elements.length === 0) { | |
console.error('no elements found for content, maybe using canvas'); | |
} | |
// Join elements, do not trim here to preserve first line indentation | |
let content = elements.join('\n'); | |
// Append author say if requested | |
if (includeAuthorSayFlag) { | |
// Use querySelector for author say with the new selector | |
const authorSayElement = document.querySelector(AUTHOR_SAY_SELECTOR); | |
let authorSayContent = ''; | |
if (authorSayElement) { | |
authorSayContent = authorSayElement.textContent.trim(); | |
} | |
// Add author say content if it exists | |
if (authorSayContent) { | |
// Add separation if both content and author say exist | |
if (content.trim()) { | |
content += '\n\n---\n\n' + authorSayContent; // Add separator | |
} else { | |
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 our helper function | |
const nextChapterLink = findNextChapterLink(); | |
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 content container element to observe (using the content selector) | |
const contentElement = document.querySelector(APP_WRAPPER_SELECTOR); | |
// Setup MutationObserver to watch for changes | |
if (contentElement) { | |
const observer = new MutationObserver(() => { | |
updateOutput(); | |
}); | |
observer.observe(contentElement, { | |
childList: true, | |
subtree: true, | |
characterData: true | |
}); | |
// Also observe the document body for any structural changes that might affect the content | |
observer.observe(document.body, { | |
childList: true, | |
subtree: false // Only direct children of body | |
}); | |
} else { | |
console.error('Content element not found. Cannot setup observer.'); | |
} | |
})(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment