Last active
May 29, 2025 08:23
-
-
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.7 | |
// @description Download chapter content from JinJiang (jjwxc.net) | |
// @description:zh-CN 从晋江下载章节文本 | |
// @author oovz | |
// @match *://www.jjwxc.net/onebook.php?novelid=*&chapterid=* | |
// @match *://my.jjwxc.net/onebook_vip.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 CONTENT_CONTAINER_SELECTOR_VIP = "div[id^=content_]"; // Selector for the main content div | |
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 | |
const AD_1 = "@无限好文,尽在晋江文学城"; | |
// Additional advertisement texts that might appear in chapters | |
const ADVERTISEMENT_TEXTS = [AD_1]; | |
// --- 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 errorMessage = 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; | |
} | |
.font-error-message { | |
background-color: #ffeaa7; border: 1px solid #fdcb6e; border-radius: 4px; | |
padding: 8px 12px; margin-bottom: 8px; font-size: 0.9em; color: #2d3436; | |
display: none; line-height: 1.4; | |
} | |
`; | |
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); | |
errorMessage.className = "font-error-message"; | |
errorMessage.innerHTML = isZhCN | |
? "⚠️ VIP章节字体解密表未找到,内容可能无法正确解密。" | |
: "⚠️ VIP chapter font table not found, content may not be properly decrypted."; | |
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(errorMessage); | |
gui.appendChild(output); | |
gui.appendChild(buttonContainer); | |
gui.appendChild(spinnerOverlay); | |
document.body.appendChild(gui); | |
} | |
// --- Advertisement Text Removal --- | |
/** | |
* Removes advertisement text from content | |
* @param {string} content - The content to clean | |
* @param {string[]} adTexts - Array of advertisement texts to remove | |
* @returns {string} Cleaned content | |
*/ function removeAdvertisementText(content, adTexts = ADVERTISEMENT_TEXTS) { | |
if (!content || !adTexts || adTexts.length === 0) { | |
return content; | |
} | |
let cleanedContent = content; | |
let removedCount = 0; | |
for (const adText of adTexts) { | |
if (!adText) continue; | |
// Count occurrences before removal | |
const beforeLength = cleanedContent.length; | |
// Remove exact matches of the advertisement text | |
cleanedContent = cleanedContent.replaceAll(adText, ""); | |
// Also remove the advertisement text with common surrounding punctuation/whitespace | |
const adPatterns = [ | |
new RegExp(`\\s*${escapeRegExp(adText)}\\s*`, "g"), | |
new RegExp(`^\\s*${escapeRegExp(adText)}\\s*`, "gm"), // At start of line | |
new RegExp(`\\s*${escapeRegExp(adText)}\\s*$`, "gm"), // At end of line | |
]; | |
for (const pattern of adPatterns) { | |
cleanedContent = cleanedContent.replace(pattern, ""); | |
} | |
// Check if any removal occurred | |
const afterLength = cleanedContent.length; | |
if (afterLength < beforeLength) { | |
removedCount++; | |
console.log(`[Advertisement Removal] Removed "${adText}" from content`); | |
} | |
} | |
// Clean up any excessive whitespace that might remain after ad removal | |
cleanedContent = cleanedContent.replace(/\n{3,}/g, "\n\n"); // Collapse 3+ newlines into 2 | |
cleanedContent = cleanedContent.replace(/^[ \t\r\n]+/, ""); // Remove leading whitespace | |
cleanedContent = cleanedContent.replace(/[\s\r\n]+$/, ""); // Remove trailing whitespace | |
if (removedCount > 0) { | |
console.log( | |
`[Advertisement Removal] Successfully removed ${removedCount} advertisement patterns from content` | |
); | |
} | |
return cleanedContent; | |
} | |
/** | |
* Escapes special regex characters for use in RegExp constructor | |
* @param {string} string - String to escape | |
* @returns {string} Escaped string | |
*/ | |
function escapeRegExp(string) { | |
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); | |
} | |
// --- VIP Font Decryption Functions --- | |
/** Detect font name and URL from VIP chapter CSS styles */ | |
function detectVipFont() { | |
// Method 1: Check CSS rules in style sheets | |
const styles = document.querySelectorAll("body > style"); | |
for (const style of styles) { | |
if (style.sheet && style.sheet.cssRules) { | |
try { | |
const rules = style.sheet.cssRules; | |
for (let i = 0; i < rules.length; i++) { | |
const rule = rules[i]; | |
if (rule.cssText) { | |
const fontNameMatch = rule.cssText.match(/jjwxcfont_[\d\w]+/); | |
const cssContentMatch = rule.cssText.match(/{(.*)}/); | |
if (fontNameMatch && cssContentMatch) { | |
const fontName = fontNameMatch[0]; | |
const cssContent = cssContentMatch[1]; | |
// Look for font URL in CSS content | |
for (const part of cssContent.split(",")) { | |
if (part.includes('format("woff2")')) { | |
const urlMatch = part.match(/url\("(.*)"\)\s/); | |
if (urlMatch) { | |
const fontUrl = document.location.protocol + urlMatch[1]; | |
return { fontName, fontUrl }; | |
} | |
} | |
} | |
} | |
} | |
} | |
} catch (e) { | |
console.debug("Error accessing stylesheet:", e); | |
} | |
} | |
} | |
// Method 2: Check div.noveltext classes for font name | |
const noveltextDiv = document.querySelector("div.noveltext"); | |
if (noveltextDiv && noveltextDiv.classList) { | |
const fontClass = Array.from(noveltextDiv.classList).find((className) => | |
className.startsWith("jjwxcfont_") | |
); | |
if (fontClass) { | |
const fontUrl = `${document.location.protocol}//static.jjwxc.net/tmp/fonts/${fontClass}.woff2?h=my.jjwxc.net`; | |
return { fontName: fontClass, fontUrl }; | |
} | |
} | |
return null; | |
} | |
/** Fetch font mapping table from remote repository */ | |
async function fetchFontTable(fontName) { | |
const url = `https://fastly.jsdelivr.net/gh/404-novel-project/jinjiang_font_tables@master/${fontName}.woff2.json`; | |
const fontLink = `https://static.jjwxc.net/tmp/fonts/${fontName}.woff2?h=my.jjwxc.net`; | |
console.log(`[VIP Font] Fetching font table for ${fontName}`); | |
let retryCount = 3; | |
while (retryCount > 0) { | |
try { | |
const response = await fetch(url); | |
if (response.ok) { | |
const fontTable = await response.json(); | |
console.log( | |
`[VIP Font] Successfully loaded font table for ${fontName}` | |
); | |
return fontTable; | |
} else if (response.status === 404) { | |
console.warn( | |
`[VIP Font] Font table not found for ${fontName}. Please submit font link to https://github.com/404-novel-project/jinjiang_font_tables: ${fontLink}` | |
); | |
return null; | |
} | |
} catch (error) { | |
console.error(`[VIP Font] Error fetching font table:`, error); | |
retryCount--; | |
if (retryCount > 0) { | |
await new Promise((resolve) => setTimeout(resolve, 2000)); | |
} | |
} | |
} | |
console.error( | |
`[VIP Font] Failed to fetch font table for ${fontName} after retries` | |
); | |
return null; | |
} | |
/** Replace encrypted characters using font mapping table */ | |
function replaceEncryptedCharacters(text, fontTable) { | |
if (!fontTable) return text; | |
let output = text; | |
// Replace each encrypted character with its normal equivalent | |
for (const encryptedChar in fontTable) { | |
if (fontTable.hasOwnProperty(encryptedChar)) { | |
const normalChar = fontTable[encryptedChar]; | |
output = output.replaceAll(encryptedChar, normalChar); | |
} | |
} | |
// Remove zero-width non-joiner characters (ZWNJ) | |
output = output.replace(/\u200c/g, ""); | |
output = output.replace(/‌/g, ""); | |
return output; | |
} | |
/** Main function to decrypt VIP chapter content */ | |
async function decryptVipContent(rawContent) { | |
const fontInfo = detectVipFont(); | |
if (!fontInfo) { | |
console.log( | |
"[VIP Font] No font encryption detected, returning original content" | |
); | |
return { content: rawContent, fontTableMissing: false }; | |
} | |
console.log(`[VIP Font] Detected encrypted font: ${fontInfo.fontName}`); | |
const fontTable = await fetchFontTable(fontInfo.fontName); | |
if (!fontTable) { | |
console.warn( | |
"[VIP Font] Could not load font table. Replacing encrypted characters (char + ZWNJ) with placeholder." | |
); | |
let modifiedContent = rawContent; | |
// Replace a character followed by ‌ with [加密字符] | |
modifiedContent = modifiedContent.replace(/.(?:‌)/g, "[加密字符]"); | |
// Replace a character followed by \u200c with [加密字符] | |
modifiedContent = modifiedContent.replace(/.(?:\u200c)/g, "[加密字符]"); | |
return { | |
content: modifiedContent, | |
fontTableMissing: true, | |
fontName: fontInfo.fontName, | |
}; | |
} | |
const decryptedContent = replaceEncryptedCharacters(rawContent, fontTable); | |
console.log(`[VIP Font] Successfully decrypted content using font table`); | |
return { content: decryptedContent, fontTableMissing: false }; | |
} | |
// --- Data Extraction --- | |
/** Gets the chapter title */ | |
function updateTitleOutput() { | |
const elements = getElementsByXpath(TITLE_XPATH); | |
return elements.join("\n"); | |
} | |
/** Extracts the main chapter content */ | |
async function updateContentOutput() { | |
let container = document.querySelector(CONTENT_CONTAINER_SELECTOR); | |
let isVipChapter = false; | |
// If regular container not found, assume it's a VIP chapter | |
if (!container) { | |
container = document.querySelector(CONTENT_CONTAINER_SELECTOR_VIP); | |
isVipChapter = true; | |
} | |
if (!container) { | |
console.error( | |
"Could not find the main content container (neither regular nor VIP)." | |
); | |
return "[Error: Cannot find content container]"; | |
} | |
const contentParts = []; | |
let processingContent = false; | |
let foundTitleDiv = false; | |
let foundTitleClearDiv = false; // For VIP chapters, use simpler extraction logic | |
if (isVipChapter) { | |
// For VIP chapters, extract all text content directly | |
const walker = document.createTreeWalker( | |
container, | |
NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT, | |
{ | |
acceptNode: function (node) { | |
if (node.nodeType === Node.TEXT_NODE) { | |
return NodeFilter.FILTER_ACCEPT; | |
} else if (node.nodeName === "BR") { | |
return NodeFilter.FILTER_ACCEPT; | |
} | |
return NodeFilter.FILTER_SKIP; | |
}, | |
} | |
); | |
let node; | |
while ((node = walker.nextNode())) { | |
if (node.nodeType === Node.TEXT_NODE) { | |
const text = node.textContent.trim(); | |
if (text) { | |
contentParts.push(text); | |
} | |
} else if (node.nodeName === "BR") { | |
contentParts.push("\n"); | |
} | |
} | |
} else { | |
// Original logic for regular chapters | |
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 | |
// Apply font decryption for VIP chapters | |
if (isVipChapter) { | |
const decryptResult = await decryptVipContent(result); | |
// Apply advertisement removal to decrypted content | |
if (decryptResult.content) { | |
decryptResult.content = removeAdvertisementText(decryptResult.content); | |
} | |
return decryptResult; | |
} | |
// Apply advertisement removal to regular chapter content | |
result = removeAdvertisementText(result); | |
return { content: result, fontTableMissing: false }; | |
} | |
/** 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); | |
} | |
let processedText = processedHtml.replace(/<br\s*\/?>/g, "\n").trim(); | |
// Apply advertisement removal to author say content | |
processedText = removeAdvertisementText(processedText); | |
return processedText; | |
} | |
/** Main function to update the output textarea */ | |
async function updateOutput() { | |
spinnerOverlay.style.display = "flex"; | |
setTimeout(async () => { | |
let finalOutput = ""; | |
let rawAuthorSayHtml = null; | |
let showFontError = false; | |
try { | |
const title = updateTitleOutput(); | |
const contentResult = await updateContentOutput(); | |
const content = contentResult.content || contentResult; // Handle both new and old format | |
showFontError = contentResult.fontTableMissing || false; | |
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 { | |
// Show/hide font error message | |
errorMessage.style.display = showFontError ? "block" : "none"; | |
// 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