晋江章节下载器

从晋江下载章节文本

  1. // ==UserScript==
  2. // @name Jinjiang Chapter Downloader
  3. // @name:zh-CN 晋江章节下载器
  4. // @namespace http://tampermonkey.net/
  5. // @version 0.6
  6. // @description Download chapter content from JinJiang (jjwxc.net)
  7. // @description:zh-CN 从晋江下载章节文本
  8. // @author oovz
  9. // @match *://www.jjwxc.net/onebook.php?novelid=*&chapterid=*
  10. // @grant none
  11. // @source https://gist.github.com/oovz/5eaabb8adecadac515d13d261fbb93b5
  12. // @source https://greasyfork.org/en/scripts/532897-jinjiang-chapter-downloader
  13. // @license MIT
  14. // ==/UserScript==
  15.  
  16. (function() {
  17. 'use strict';
  18.  
  19. // --- Configuration ---
  20. const TITLE_XPATH = '//div[@class="novelbody"]//h2';
  21. const CONTENT_CONTAINER_SELECTOR = '.novelbody > div[style*="font-size: 16px"]'; // Selector for the main content div
  22. const CONTENT_START_TITLE_DIV_SELECTOR = 'div[align="center"]'; // Title div within the container
  23. const CONTENT_START_CLEAR_DIV_SELECTOR = 'div[style*="clear:both"]'; // Div marking start of content after title div
  24. const CONTENT_END_DIV_TAG = 'DIV'; // First DIV tag encountered after content starts marks the end
  25. const CONTENT_END_FALLBACK_SELECTOR_1 = '#favoriteshow_3'; // Fallback end marker
  26. const CONTENT_END_FALLBACK_SELECTOR_2 = '#note_danmu_wrapper'; // Fallback end marker (author say wrapper)
  27. const AUTHOR_SAY_HIDDEN_XPATH = '//div[@id="note_str"]'; // Hidden div containing raw author say HTML
  28. const AUTHOR_SAY_CUTOFF_TEXT = '谢谢各位大人的霸王票'; // Text to truncate author say at
  29. const NEXT_CHAPTER_XPATH = '//div[@id="oneboolt"]/div[@class="noveltitle"]/span/a[span][last()]'; // Next chapter link
  30. const CHAPTER_WRAPPER_XPATH = '//div[@class="novelbody"]'; // Wrapper for MutationObserver
  31.  
  32. // --- Internationalization ---
  33. const isZhCN = navigator.language.toLowerCase() === 'zh-cn' ||
  34. document.documentElement.lang.toLowerCase() === 'zh-cn';
  35.  
  36. const i18n = {
  37. copyText: isZhCN ? '复制文本' : 'Copy Content',
  38. copiedText: isZhCN ? '已复制!' : 'Copied!',
  39. nextChapter: isZhCN ? '下一章' : 'Next Chapter',
  40. noNextChapter: isZhCN ? '没有下一章' : 'No Next Chapter',
  41. includeAuthorSay: isZhCN ? '包含作话' : 'Include Author Say',
  42. excludeAuthorSay: isZhCN ? '排除作话' : 'Exclude Author Say',
  43. authorSaySeparator: isZhCN ? '--- 作者有话说 ---' : '--- Author Say ---'
  44. };
  45.  
  46. // --- State ---
  47. let includeAuthorSay = true; // Default to including author say
  48.  
  49. // --- Utilities ---
  50.  
  51. /**
  52. * Extracts text content from elements matching an XPath.
  53. * Special handling for title to trim whitespace.
  54. */
  55. function getElementsByXpath(xpath) {
  56. const results = [];
  57. const query = document.evaluate(
  58. xpath,
  59. document,
  60. null,
  61. XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,
  62. null
  63. );
  64.  
  65. for (let i = 0; i < query.snapshotLength; i++) {
  66. const node = query.snapshotItem(i);
  67. if (node) {
  68. let directTextContent = '';
  69. for (let j = 0; j < node.childNodes.length; j++) {
  70. const childNode = node.childNodes[j];
  71. if (childNode.nodeType === Node.TEXT_NODE) {
  72. directTextContent += childNode.textContent;
  73. }
  74. }
  75.  
  76. if (xpath === TITLE_XPATH) {
  77. directTextContent = directTextContent.trim();
  78. }
  79.  
  80. if (directTextContent) {
  81. results.push(directTextContent);
  82. }
  83. }
  84. }
  85. return results;
  86. }
  87.  
  88. // --- GUI Creation ---
  89. const gui = document.createElement('div');
  90. const style = document.createElement('style');
  91. const resizeHandle = document.createElement('div');
  92. const output = document.createElement('textarea');
  93. const buttonContainer = document.createElement('div');
  94. const copyButton = document.createElement('button');
  95. const authorSayButton = document.createElement('button');
  96. const nextChapterButton = document.createElement('button');
  97. const spinnerOverlay = document.createElement('div');
  98. const spinner = document.createElement('div');
  99.  
  100. function setupGUI() {
  101. gui.style.cssText = `
  102. position: fixed; bottom: 20px; right: 20px; background: white; padding: 15px;
  103. border: 1px solid #ccc; border-radius: 5px; box-shadow: 0 0 10px rgba(0,0,0,0.1);
  104. z-index: 9999; resize: both; overflow: visible; min-width: 350px; min-height: 250px;
  105. max-width: 100vw; max-height: 80vh; resize-origin: top-left; display: flex; flex-direction: column;
  106. `;
  107.  
  108. style.textContent = `
  109. @keyframes spin { to { transform: rotate(360deg); } }
  110. .resize-handle {
  111. position: absolute; width: 14px; height: 14px; top: 0; left: 0; cursor: nwse-resize;
  112. z-index: 10000; background-color: #888; border-top-left-radius: 5px;
  113. border-right: 1px solid #ccc; border-bottom: 1px solid #ccc;
  114. }
  115. .spinner-overlay {
  116. position: absolute; top: 0; left: 0; width: 100%; height: 100%;
  117. background-color: rgba(240, 240, 240, 0.8); display: none; justify-content: center;
  118. align-items: center; z-index: 10001;
  119. }
  120. `;
  121. document.head.appendChild(style);
  122.  
  123. resizeHandle.className = 'resize-handle';
  124.  
  125. output.style.cssText = `
  126. width: 100%; flex: 1; margin-bottom: 8px; resize: none; overflow: auto;
  127. box-sizing: border-box; min-height: 180px;
  128. `;
  129. output.readOnly = true;
  130.  
  131. buttonContainer.style.cssText = `display: flex; justify-content: center; gap: 10px; margin-bottom: 2px;`;
  132.  
  133. copyButton.textContent = i18n.copyText;
  134. 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;`;
  135.  
  136. authorSayButton.textContent = includeAuthorSay ? i18n.excludeAuthorSay : i18n.includeAuthorSay;
  137. 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;`;
  138. authorSayButton.disabled = true;
  139.  
  140. nextChapterButton.textContent = i18n.nextChapter;
  141. 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;`;
  142.  
  143. buttonContainer.appendChild(authorSayButton);
  144. buttonContainer.appendChild(copyButton);
  145. buttonContainer.appendChild(nextChapterButton);
  146.  
  147. spinnerOverlay.className = 'spinner-overlay';
  148. 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;`;
  149. spinnerOverlay.appendChild(spinner);
  150.  
  151. gui.appendChild(resizeHandle);
  152. gui.appendChild(output);
  153. gui.appendChild(buttonContainer);
  154. gui.appendChild(spinnerOverlay);
  155. document.body.appendChild(gui);
  156. }
  157.  
  158. // --- Data Extraction ---
  159.  
  160. /** Gets the chapter title */
  161. function updateTitleOutput() {
  162. const elements = getElementsByXpath(TITLE_XPATH);
  163. return elements.join('\n');
  164. }
  165.  
  166. /** Extracts the main chapter content */
  167. function updateContentOutput() {
  168. const container = document.querySelector(CONTENT_CONTAINER_SELECTOR);
  169. if (!container) {
  170. console.error("Could not find the main content container.");
  171. return "[Error: Cannot find content container]";
  172. }
  173.  
  174. const contentParts = [];
  175. let processingContent = false;
  176. let foundTitleDiv = false;
  177. let foundTitleClearDiv = false;
  178.  
  179. const endMarkerFallback1 = container.querySelector(CONTENT_END_FALLBACK_SELECTOR_1);
  180. const endMarkerFallback2 = container.querySelector(CONTENT_END_FALLBACK_SELECTOR_2);
  181.  
  182. for (const childNode of container.childNodes) {
  183. // --- Fallback End Marker Check ---
  184. if ((endMarkerFallback1 && childNode === endMarkerFallback1) || (endMarkerFallback2 && childNode === endMarkerFallback2)) {
  185. processingContent = false;
  186. break;
  187. }
  188.  
  189. // --- State Management for Start ---
  190. if (!foundTitleDiv && childNode.nodeType === Node.ELEMENT_NODE && childNode.matches(CONTENT_START_TITLE_DIV_SELECTOR)) {
  191. foundTitleDiv = true;
  192. continue;
  193. }
  194. if (foundTitleDiv && !foundTitleClearDiv && childNode.nodeType === Node.ELEMENT_NODE && childNode.matches(CONTENT_START_CLEAR_DIV_SELECTOR)) {
  195. foundTitleClearDiv = true;
  196. continue;
  197. }
  198. // Start processing *after* the clear:both div is found, unless the next node is already the end div
  199. if (foundTitleClearDiv && !processingContent) {
  200. if (childNode.nodeType === Node.ELEMENT_NODE && childNode.tagName === CONTENT_END_DIV_TAG) {
  201. break; // No content between clear:both and the first div
  202. }
  203. processingContent = true;
  204. }
  205.  
  206. // --- Content Extraction & Primary End Check ---
  207. if (processingContent) {
  208. if (childNode.nodeType === Node.TEXT_NODE) {
  209. contentParts.push(childNode.textContent);
  210. } else if (childNode.nodeName === 'BR') {
  211. // Handle BR tags, allowing max two consecutive newlines
  212. if (contentParts.length === 0 || !contentParts[contentParts.length - 1].endsWith('\n')) {
  213. contentParts.push('\n');
  214. } else if (contentParts.length > 0 && contentParts[contentParts.length - 1].endsWith('\n')) {
  215. const lastPart = contentParts[contentParts.length - 1];
  216. if (!lastPart.endsWith('\n\n')) {
  217. contentParts.push('\n');
  218. }
  219. }
  220. } else if (childNode.nodeType === Node.ELEMENT_NODE && childNode.tagName === CONTENT_END_DIV_TAG) {
  221. // Stop processing when the first DIV element is encountered after content starts
  222. processingContent = false;
  223. break;
  224. }
  225. // Ignore other element types within the content
  226. }
  227. }
  228.  
  229. // Join and clean up
  230. let result = contentParts.join('');
  231. result = result.replace(/^[ \t\r\n]+/, ''); // Remove leading standard whitespace only
  232. result = result.replace(/\n{3,}/g, '\n\n'); // Collapse 3+ newlines into 2
  233. result = result.replace(/[\s\r\n]+$/, ''); // Remove trailing standard whitespace
  234.  
  235. return result;
  236. }
  237.  
  238. /** Gets the raw author say HTML from the hidden div */
  239. function getRawAuthorSayHtml() {
  240. const authorSayQuery = document.evaluate(
  241. AUTHOR_SAY_HIDDEN_XPATH,
  242. document,
  243. null,
  244. XPathResult.FIRST_ORDERED_NODE_TYPE,
  245. null
  246. );
  247. const authorSayNode = authorSayQuery.singleNodeValue;
  248. return authorSayNode ? authorSayNode.innerHTML.trim() : null;
  249. }
  250.  
  251. /** Processes the raw author say HTML (removes cutoff text, converts <br>) */
  252. function processAuthorSayHtml(html) {
  253. if (!html) return '';
  254.  
  255. let processedHtml = html;
  256. const cutoffIndex = processedHtml.indexOf(AUTHOR_SAY_CUTOFF_TEXT);
  257. if (cutoffIndex !== -1) {
  258. processedHtml = processedHtml.substring(0, cutoffIndex);
  259. }
  260.  
  261. return processedHtml
  262. .replace(/<br\s*\/?>/g, '\n')
  263. .trim();
  264. }
  265.  
  266. /** Main function to update the output textarea */
  267. function updateOutput() {
  268. spinnerOverlay.style.display = 'flex';
  269.  
  270. setTimeout(() => {
  271. let finalOutput = '';
  272. let rawAuthorSayHtml = null;
  273. try {
  274. const title = updateTitleOutput();
  275. const content = updateContentOutput();
  276. rawAuthorSayHtml = getRawAuthorSayHtml(); // Get from hidden div
  277. const processedAuthorSay = processAuthorSayHtml(rawAuthorSayHtml);
  278.  
  279. finalOutput = title ? title + '\n\n' + content : content;
  280.  
  281. if (includeAuthorSay && processedAuthorSay && processedAuthorSay.length > 0) {
  282. finalOutput += '\n\n' + i18n.authorSaySeparator + '\n\n' + processedAuthorSay;
  283. }
  284.  
  285. output.value = finalOutput;
  286.  
  287. } catch (error) {
  288. console.error('Error updating output:', error);
  289. output.value = 'Error extracting content: ' + error.message;
  290. } finally {
  291. // Update Author Say button state
  292. const authorSayExists = rawAuthorSayHtml && rawAuthorSayHtml.length > 0;
  293. authorSayButton.disabled = !authorSayExists;
  294. authorSayButton.style.backgroundColor = authorSayExists ? '#fbbc05' : '#ccc';
  295. authorSayButton.style.cursor = authorSayExists ? 'pointer' : 'not-allowed';
  296. authorSayButton.textContent = includeAuthorSay ? i18n.excludeAuthorSay : i18n.includeAuthorSay;
  297.  
  298. spinnerOverlay.style.display = 'none';
  299. }
  300. }, 0);
  301. }
  302.  
  303. // --- Event Handlers ---
  304.  
  305. // Custom resize functionality
  306. let isResizing = false;
  307. let originalWidth, originalHeight, originalX, originalY;
  308.  
  309. function handleResizeMouseDown(e) {
  310. e.preventDefault();
  311. isResizing = true;
  312. originalWidth = parseFloat(getComputedStyle(gui).width);
  313. originalHeight = parseFloat(getComputedStyle(gui).height);
  314. originalX = e.clientX;
  315. originalY = e.clientY;
  316. document.addEventListener('mousemove', handleResizeMouseMove);
  317. document.addEventListener('mouseup', handleResizeMouseUp);
  318. }
  319.  
  320. function handleResizeMouseMove(e) {
  321. if (!isResizing) return;
  322. const width = originalWidth - (e.clientX - originalX);
  323. const height = originalHeight - (e.clientY - originalY);
  324. if (width > 300 && width < window.innerWidth * 0.8) {
  325. gui.style.width = width + 'px';
  326. gui.style.right = getComputedStyle(gui).right; // Keep right fixed
  327. }
  328. if (height > 250 && height < window.innerHeight * 0.8) {
  329. gui.style.height = height + 'px';
  330. gui.style.bottom = getComputedStyle(gui).bottom; // Keep bottom fixed
  331. }
  332. }
  333.  
  334. function handleResizeMouseUp() {
  335. isResizing = false;
  336. document.removeEventListener('mousemove', handleResizeMouseMove);
  337. document.removeEventListener('mouseup', handleResizeMouseUp);
  338. }
  339.  
  340. function handleCopyClick() {
  341. output.select();
  342. document.execCommand('copy');
  343. copyButton.textContent = i18n.copiedText;
  344. setTimeout(() => {
  345. copyButton.textContent = i18n.copyText;
  346. }, 1000);
  347. }
  348.  
  349. function handleAuthorSayToggle() {
  350. if (authorSayButton.disabled) return;
  351. includeAuthorSay = !includeAuthorSay;
  352. authorSayButton.textContent = includeAuthorSay ? i18n.excludeAuthorSay : i18n.includeAuthorSay;
  353. updateOutput(); // Re-render
  354. }
  355.  
  356. function handleNextChapterClick() {
  357. const nextChapterQuery = document.evaluate(NEXT_CHAPTER_XPATH, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
  358. const nextChapterLink = nextChapterQuery.singleNodeValue;
  359. if (nextChapterLink && nextChapterLink.href) {
  360. window.location.href = nextChapterLink.href;
  361. } else {
  362. nextChapterButton.textContent = i18n.noNextChapter;
  363. nextChapterButton.style.backgroundColor = '#ea4335';
  364. setTimeout(() => {
  365. nextChapterButton.textContent = i18n.nextChapter;
  366. nextChapterButton.style.backgroundColor = '#34a853';
  367. }, 2000);
  368. }
  369. }
  370.  
  371. // --- Initialization ---
  372.  
  373. setupGUI(); // Create and append GUI elements
  374.  
  375. // Add event listeners
  376. resizeHandle.addEventListener('mousedown', handleResizeMouseDown);
  377. copyButton.addEventListener('click', handleCopyClick);
  378. authorSayButton.addEventListener('click', handleAuthorSayToggle);
  379. nextChapterButton.addEventListener('click', handleNextChapterClick);
  380.  
  381. // Initial content extraction
  382. updateOutput();
  383.  
  384. // Set up MutationObserver to re-run extraction if chapter content changes dynamically
  385. const chapterWrapperQuery = document.evaluate(CHAPTER_WRAPPER_XPATH, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
  386. const chapterWrapper = chapterWrapperQuery.singleNodeValue;
  387. if (chapterWrapper) {
  388. const observer = new MutationObserver(() => {
  389. console.log("Chapter wrapper mutation detected, updating output.");
  390. updateOutput();
  391. });
  392. observer.observe(chapterWrapper, { childList: true, subtree: true, characterData: true });
  393. } else {
  394. console.error('Chapter wrapper element not found for MutationObserver.');
  395. }
  396.  
  397. })();