Last active
April 15, 2025 05:54
-
-
Save oovz/5eaabb8adecadac515d13d261fbb93b5 to your computer and use it in GitHub Desktop.
Download chapter content from JinJiang (jjwxc.net)
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 Jinjiang Chapter Downloader | |
// @name:zh-CN 晋江章节下载器 | |
// @namespace http://tampermonkey.net/ | |
// @version 0.6 | |
// @description Download chapter content from JinJiang (jjwxc.net) | |
// @description:zh-CN 从晋江下载章节文本 | |
// @author oovz | |
// @match *://www.jjwxc.net/onebook.php?novelid=*&chapterid=* | |
// @grant none | |
// @source https://gist.github.com/oovz/5eaabb8adecadac515d13d261fbb93b5 | |
// @license MIT | |
// ==/UserScript== | |
(function() { | |
'use strict'; | |
// --- Configuration --- | |
const TITLE_XPATH = '//div[@class="novelbody"]//h2'; | |
const CONTENT_CONTAINER_SELECTOR = '.novelbody > div[style*="font-size: 16px"]'; // Selector for the main content div | |
const CONTENT_START_TITLE_DIV_SELECTOR = 'div[align="center"]'; // Title div within the container | |
const CONTENT_START_CLEAR_DIV_SELECTOR = 'div[style*="clear:both"]'; // Div marking start of content after title div | |
const CONTENT_END_DIV_TAG = 'DIV'; // First DIV tag encountered after content starts marks the end | |
const CONTENT_END_FALLBACK_SELECTOR_1 = '#favoriteshow_3'; // Fallback end marker | |
const CONTENT_END_FALLBACK_SELECTOR_2 = '#note_danmu_wrapper'; // Fallback end marker (author say wrapper) | |
const AUTHOR_SAY_HIDDEN_XPATH = '//div[@id="note_str"]'; // Hidden div containing raw author say HTML | |
const AUTHOR_SAY_CUTOFF_TEXT = '谢谢各位大人的霸王票'; // Text to truncate author say at | |
const NEXT_CHAPTER_XPATH = '//div[@id="oneboolt"]/div[@class="noveltitle"]/span/a[span][last()]'; // Next chapter link | |
const CHAPTER_WRAPPER_XPATH = '//div[@class="novelbody"]'; // Wrapper for MutationObserver | |
// --- 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', | |
authorSaySeparator: isZhCN ? '--- 作者有话说 ---' : '--- Author Say ---' | |
}; | |
// --- State --- | |
let includeAuthorSay = true; // Default to including author say | |
// --- Utilities --- | |
/** | |
* Extracts text content from elements matching an XPath. | |
* Special handling for title to trim whitespace. | |
*/ | |
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) { | |
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; | |
} | |
} | |
if (xpath === TITLE_XPATH) { | |
directTextContent = directTextContent.trim(); | |
} | |
if (directTextContent) { | |
results.push(directTextContent); | |
} | |
} | |
} | |
return results; | |
} | |
// --- GUI Creation --- | |
const gui = document.createElement('div'); | |
const style = document.createElement('style'); | |
const resizeHandle = document.createElement('div'); | |
const output = document.createElement('textarea'); | |
const buttonContainer = document.createElement('div'); | |
const copyButton = document.createElement('button'); | |
const authorSayButton = document.createElement('button'); | |
const nextChapterButton = document.createElement('button'); | |
const spinnerOverlay = document.createElement('div'); | |
const spinner = document.createElement('div'); | |
function setupGUI() { | |
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; | |
`; | |
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); | |
resizeHandle.className = 'resize-handle'; | |
output.style.cssText = ` | |
width: 100%; flex: 1; margin-bottom: 8px; resize: none; overflow: auto; | |
box-sizing: border-box; min-height: 180px; | |
`; | |
output.readOnly = true; | |
buttonContainer.style.cssText = `display: flex; justify-content: center; gap: 10px; margin-bottom: 2px;`; | |
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;`; | |
authorSayButton.textContent = includeAuthorSay ? i18n.excludeAuthorSay : i18n.includeAuthorSay; | |
authorSayButton.style.cssText = `padding: 4px 12px; cursor: pointer; background-color: #fbbc05; color: white; border: none; border-radius: 15px; font-weight: bold; font-size: 0.9em; margin-right: 5px;`; | |
authorSayButton.disabled = true; | |
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;`; | |
buttonContainer.appendChild(authorSayButton); | |
buttonContainer.appendChild(copyButton); | |
buttonContainer.appendChild(nextChapterButton); | |
spinnerOverlay.className = 'spinner-overlay'; | |
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); | |
gui.appendChild(resizeHandle); | |
gui.appendChild(output); | |
gui.appendChild(buttonContainer); | |
gui.appendChild(spinnerOverlay); | |
document.body.appendChild(gui); | |
} | |
// --- Data Extraction --- | |
/** Gets the chapter title */ | |
function updateTitleOutput() { | |
const elements = getElementsByXpath(TITLE_XPATH); | |
return elements.join('\n'); | |
} | |
/** Extracts the main chapter content */ | |
function updateContentOutput() { | |
const container = document.querySelector(CONTENT_CONTAINER_SELECTOR); | |
if (!container) { | |
console.error("Could not find the main content container."); | |
return "[Error: Cannot find content container]"; | |
} | |
const contentParts = []; | |
let processingContent = false; | |
let foundTitleDiv = false; | |
let foundTitleClearDiv = false; | |
const endMarkerFallback1 = container.querySelector(CONTENT_END_FALLBACK_SELECTOR_1); | |
const endMarkerFallback2 = container.querySelector(CONTENT_END_FALLBACK_SELECTOR_2); | |
for (const childNode of container.childNodes) { | |
// --- Fallback End Marker Check --- | |
if ((endMarkerFallback1 && childNode === endMarkerFallback1) || (endMarkerFallback2 && childNode === endMarkerFallback2)) { | |
processingContent = false; | |
break; | |
} | |
// --- State Management for Start --- | |
if (!foundTitleDiv && childNode.nodeType === Node.ELEMENT_NODE && childNode.matches(CONTENT_START_TITLE_DIV_SELECTOR)) { | |
foundTitleDiv = true; | |
continue; | |
} | |
if (foundTitleDiv && !foundTitleClearDiv && childNode.nodeType === Node.ELEMENT_NODE && childNode.matches(CONTENT_START_CLEAR_DIV_SELECTOR)) { | |
foundTitleClearDiv = true; | |
continue; | |
} | |
// Start processing *after* the clear:both div is found, unless the next node is already the end div | |
if (foundTitleClearDiv && !processingContent) { | |
if (childNode.nodeType === Node.ELEMENT_NODE && childNode.tagName === CONTENT_END_DIV_TAG) { | |
break; // No content between clear:both and the first div | |
} | |
processingContent = true; | |
} | |
// --- Content Extraction & Primary End Check --- | |
if (processingContent) { | |
if (childNode.nodeType === Node.TEXT_NODE) { | |
contentParts.push(childNode.textContent); | |
} else if (childNode.nodeName === 'BR') { | |
// Handle BR tags, allowing max two consecutive newlines | |
if (contentParts.length === 0 || !contentParts[contentParts.length - 1].endsWith('\n')) { | |
contentParts.push('\n'); | |
} else if (contentParts.length > 0 && contentParts[contentParts.length - 1].endsWith('\n')) { | |
const lastPart = contentParts[contentParts.length - 1]; | |
if (!lastPart.endsWith('\n\n')) { | |
contentParts.push('\n'); | |
} | |
} | |
} else if (childNode.nodeType === Node.ELEMENT_NODE && childNode.tagName === CONTENT_END_DIV_TAG) { | |
// Stop processing when the first DIV element is encountered after content starts | |
processingContent = false; | |
break; | |
} | |
// Ignore other element types within the content | |
} | |
} | |
// Join and clean up | |
let result = contentParts.join(''); | |
result = result.replace(/^[ \t\r\n]+/, ''); // Remove leading standard whitespace only | |
result = result.replace(/\n{3,}/g, '\n\n'); // Collapse 3+ newlines into 2 | |
result = result.replace(/[\s\r\n]+$/, ''); // Remove trailing standard whitespace | |
return result; | |
} | |
/** Gets the raw author say HTML from the hidden div */ | |
function getRawAuthorSayHtml() { | |
const authorSayQuery = document.evaluate( | |
AUTHOR_SAY_HIDDEN_XPATH, | |
document, | |
null, | |
XPathResult.FIRST_ORDERED_NODE_TYPE, | |
null | |
); | |
const authorSayNode = authorSayQuery.singleNodeValue; | |
return authorSayNode ? authorSayNode.innerHTML.trim() : null; | |
} | |
/** Processes the raw author say HTML (removes cutoff text, converts <br>) */ | |
function processAuthorSayHtml(html) { | |
if (!html) return ''; | |
let processedHtml = html; | |
const cutoffIndex = processedHtml.indexOf(AUTHOR_SAY_CUTOFF_TEXT); | |
if (cutoffIndex !== -1) { | |
processedHtml = processedHtml.substring(0, cutoffIndex); | |
} | |
return processedHtml | |
.replace(/<br\s*\/?>/g, '\n') | |
.trim(); | |
} | |
/** Main function to update the output textarea */ | |
function updateOutput() { | |
spinnerOverlay.style.display = 'flex'; | |
setTimeout(() => { | |
let finalOutput = ''; | |
let rawAuthorSayHtml = null; | |
try { | |
const title = updateTitleOutput(); | |
const content = updateContentOutput(); | |
rawAuthorSayHtml = getRawAuthorSayHtml(); // Get from hidden div | |
const processedAuthorSay = processAuthorSayHtml(rawAuthorSayHtml); | |
finalOutput = title ? title + '\n\n' + content : content; | |
if (includeAuthorSay && processedAuthorSay && processedAuthorSay.length > 0) { | |
finalOutput += '\n\n' + i18n.authorSaySeparator + '\n\n' + processedAuthorSay; | |
} | |
output.value = finalOutput; | |
} catch (error) { | |
console.error('Error updating output:', error); | |
output.value = 'Error extracting content: ' + error.message; | |
} finally { | |
// Update Author Say button state | |
const authorSayExists = rawAuthorSayHtml && rawAuthorSayHtml.length > 0; | |
authorSayButton.disabled = !authorSayExists; | |
authorSayButton.style.backgroundColor = authorSayExists ? '#fbbc05' : '#ccc'; | |
authorSayButton.style.cursor = authorSayExists ? 'pointer' : 'not-allowed'; | |
authorSayButton.textContent = includeAuthorSay ? i18n.excludeAuthorSay : i18n.includeAuthorSay; | |
spinnerOverlay.style.display = 'none'; | |
} | |
}, 0); | |
} | |
// --- Event Handlers --- | |
// Custom resize functionality | |
let isResizing = false; | |
let originalWidth, originalHeight, originalX, originalY; | |
function handleResizeMouseDown(e) { | |
e.preventDefault(); | |
isResizing = true; | |
originalWidth = parseFloat(getComputedStyle(gui).width); | |
originalHeight = parseFloat(getComputedStyle(gui).height); | |
originalX = e.clientX; | |
originalY = e.clientY; | |
document.addEventListener('mousemove', handleResizeMouseMove); | |
document.addEventListener('mouseup', handleResizeMouseUp); | |
} | |
function handleResizeMouseMove(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'; | |
gui.style.right = getComputedStyle(gui).right; // Keep right fixed | |
} | |
if (height > 250 && height < window.innerHeight * 0.8) { | |
gui.style.height = height + 'px'; | |
gui.style.bottom = getComputedStyle(gui).bottom; // Keep bottom fixed | |
} | |
} | |
function handleResizeMouseUp() { | |
isResizing = false; | |
document.removeEventListener('mousemove', handleResizeMouseMove); | |
document.removeEventListener('mouseup', handleResizeMouseUp); | |
} | |
function handleCopyClick() { | |
output.select(); | |
document.execCommand('copy'); | |
copyButton.textContent = i18n.copiedText; | |
setTimeout(() => { | |
copyButton.textContent = i18n.copyText; | |
}, 1000); | |
} | |
function handleAuthorSayToggle() { | |
if (authorSayButton.disabled) return; | |
includeAuthorSay = !includeAuthorSay; | |
authorSayButton.textContent = includeAuthorSay ? i18n.excludeAuthorSay : i18n.includeAuthorSay; | |
updateOutput(); // Re-render | |
} | |
function handleNextChapterClick() { | |
const nextChapterQuery = document.evaluate(NEXT_CHAPTER_XPATH, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null); | |
const nextChapterLink = nextChapterQuery.singleNodeValue; | |
if (nextChapterLink && nextChapterLink.href) { | |
window.location.href = nextChapterLink.href; | |
} else { | |
nextChapterButton.textContent = i18n.noNextChapter; | |
nextChapterButton.style.backgroundColor = '#ea4335'; | |
setTimeout(() => { | |
nextChapterButton.textContent = i18n.nextChapter; | |
nextChapterButton.style.backgroundColor = '#34a853'; | |
}, 2000); | |
} | |
} | |
// --- Initialization --- | |
setupGUI(); // Create and append GUI elements | |
// Add event listeners | |
resizeHandle.addEventListener('mousedown', handleResizeMouseDown); | |
copyButton.addEventListener('click', handleCopyClick); | |
authorSayButton.addEventListener('click', handleAuthorSayToggle); | |
nextChapterButton.addEventListener('click', handleNextChapterClick); | |
// Initial content extraction | |
updateOutput(); | |
// Set up MutationObserver to re-run extraction if chapter content changes dynamically | |
const chapterWrapperQuery = document.evaluate(CHAPTER_WRAPPER_XPATH, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null); | |
const chapterWrapper = chapterWrapperQuery.singleNodeValue; | |
if (chapterWrapper) { | |
const observer = new MutationObserver(() => { | |
console.log("Chapter wrapper mutation detected, updating output."); | |
updateOutput(); | |
}); | |
observer.observe(chapterWrapper, { childList: true, subtree: true, characterData: true }); | |
} else { | |
console.error('Chapter wrapper element not found for MutationObserver.'); | |
} | |
})(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment