Jinjiang Chapter Downloader

Download chapter content from JinJiang (jjwxc.net)

// ==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(/&zwnj;/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 &zwnj; with [加密字符]
      modifiedContent = modifiedContent.replace(/.(?:&zwnj;)/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.");
  }
})();