// ==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
// @source https://greasyfork.org/en/scripts/532897-jinjiang-chapter-downloader
// @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.');
}
})();