起点(qidian.com)章节下载器

从起点下载章节文本

  1. // ==UserScript==
  2. // @name Qidian Chapter Downloader
  3. // @name:zh-CN 起点(qidian.com)章节下载器
  4. // @namespace http://tampermonkey.net/
  5. // @version 0.8
  6. // @description Download chapter content from Qidian (qidian.com)
  7. // @description:zh-CN 从起点下载章节文本
  8. // @author oovz
  9. // @match https://www.qidian.com/chapter/*
  10. // @grant none
  11. // @source https://gist.github.com/oovz/3257e1acd16ef2fa2913b430d95dc283
  12. // @source https://gf.qytechs.cn/en/scripts/531290-qidian-chapter-downloader
  13. // @license MIT
  14. // ==/UserScript==
  15.  
  16. (function() {
  17. 'use strict';
  18.  
  19. // Configure your XPath here
  20. const TITLE_XPATH = '//div[contains(@class, "chapter-wrapper")]//div[contains(@class, "print")]//h1'; // Fill this with your XPath
  21. const CONTENT_XPATH = '//div[contains(@class, "chapter-wrapper")]//div[contains(@class, "print")]//main/p'; // Base path to p elements
  22. const CONTENT_SPAN_XPATH = '//div[contains(@class, "chapter-wrapper")]//div[contains(@class, "print")]//main/p/span[@class="content-text"]'; // For p with span structure
  23. const CHAPTER_WRAPPER_XPATH = '//div[contains(@class, "chapter-wrapper")]';
  24. const NEXT_CHAPTER_BUTTON_XPATH = '//div[@class="nav-btn-group"]/a[last()]';
  25. const AUTHOR_SAY_XPATH = '//section[@id="r-authorSay"]//p[@class="trans"]'; // XPath for author say
  26.  
  27. // Internationalization
  28. const isZhCN = navigator.language.toLowerCase() === 'zh-cn' ||
  29. document.documentElement.lang.toLowerCase() === 'zh-cn';
  30. const i18n = {
  31. copyText: isZhCN ? '复制文本' : 'Copy Content',
  32. copiedText: isZhCN ? '已复制!' : 'Copied!',
  33. nextChapter: isZhCN ? '下一章' : 'Next Chapter',
  34. noNextChapter: isZhCN ? '没有下一章' : 'No Next Chapter',
  35. includeAuthorSay: isZhCN ? '包含作家说' : 'Include Author Say',
  36. excludeAuthorSay: isZhCN ? '排除作家说' : 'Exclude Author Say'
  37. };
  38.  
  39. // State variable for author say inclusion
  40. let includeAuthorSay = true;
  41.  
  42. // Create GUI elements
  43. const gui = document.createElement('div');
  44. gui.style.cssText = `
  45. position: fixed;
  46. bottom: 20px;
  47. right: 20px;
  48. background: white;
  49. padding: 15px;
  50. border: 1px solid #ccc;
  51. border-radius: 5px;
  52. box-shadow: 0 0 10px rgba(0,0,0,0.1);
  53. z-index: 9999;
  54. resize: both;
  55. overflow: visible;
  56. min-width: 350px;
  57. min-height: 250px;
  58. max-width: 100vw;
  59. max-height: 80vh;
  60. resize-origin: top-left;
  61. display: flex;
  62. flex-direction: column;
  63. `;
  64.  
  65. // Add CSS for custom resize handle at top-left
  66. const style = document.createElement('style');
  67. style.textContent = `
  68. @keyframes spin {
  69. to { transform: rotate(360deg); }
  70. }
  71.  
  72. .resize-handle {
  73. position: absolute;
  74. width: 14px;
  75. height: 14px;
  76. top: 0;
  77. left: 0;
  78. cursor: nwse-resize;
  79. z-index: 10000;
  80. background-color: #888;
  81. border-top-left-radius: 5px;
  82. border-right: 1px solid #ccc;
  83. border-bottom: 1px solid #ccc;
  84. }
  85.  
  86. .spinner-overlay {
  87. position: absolute;
  88. top: 0;
  89. left: 0;
  90. width: 100%;
  91. height: 100%;
  92. background-color: rgba(240, 240, 240, 0.8);
  93. display: none;
  94. justify-content: center;
  95. align-items: center;
  96. z-index: 10001;
  97. }
  98. `;
  99. document.head.appendChild(style);
  100.  
  101. // Create resize handle
  102. const resizeHandle = document.createElement('div');
  103. resizeHandle.className = 'resize-handle';
  104. const output = document.createElement('textarea');
  105. output.style.cssText = `
  106. width: 100%;
  107. flex: 1;
  108. margin-bottom: 8px;
  109. resize: none;
  110. overflow: auto;
  111. box-sizing: border-box;
  112. min-height: 180px;
  113. `;
  114. output.readOnly = true;
  115.  
  116. // Create button container for horizontal layout
  117. const buttonContainer = document.createElement('div');
  118. buttonContainer.style.cssText = `
  119. display: flex;
  120. justify-content: center;
  121. gap: 10px;
  122. margin-bottom: 2px;
  123. `;
  124.  
  125. // Create toggle author say button
  126. const toggleAuthorSayButton = document.createElement('button');
  127. toggleAuthorSayButton.textContent = includeAuthorSay ? i18n.excludeAuthorSay : i18n.includeAuthorSay;
  128. toggleAuthorSayButton.style.cssText = `
  129. padding: 4px 12px;
  130. cursor: pointer;
  131. background-color: #fbbc05; /* Yellow */
  132. color: white;
  133. border: none;
  134. border-radius: 15px;
  135. font-weight: bold;
  136. font-size: 0.9em;
  137. `;
  138.  
  139. const copyButton = document.createElement('button');
  140. copyButton.textContent = i18n.copyText;
  141. copyButton.style.cssText = `
  142. padding: 4px 12px;
  143. cursor: pointer;
  144. background-color: #4285f4;
  145. color: white;
  146. border: none;
  147. border-radius: 15px;
  148. font-weight: bold;
  149. font-size: 0.9em;
  150. `;
  151.  
  152. // Create next chapter button
  153. const nextChapterButton = document.createElement('button');
  154. nextChapterButton.textContent = i18n.nextChapter;
  155. nextChapterButton.style.cssText = `
  156. padding: 4px 12px;
  157. cursor: pointer;
  158. background-color: #34a853;
  159. color: white;
  160. border: none;
  161. border-radius: 15px;
  162. font-weight: bold;
  163. font-size: 0.9em;
  164. `;
  165.  
  166. // Add buttons to container
  167. buttonContainer.appendChild(toggleAuthorSayButton);
  168. buttonContainer.appendChild(copyButton);
  169. buttonContainer.appendChild(nextChapterButton);
  170.  
  171. // Create spinner overlay for better positioning
  172. const spinnerOverlay = document.createElement('div');
  173. spinnerOverlay.className = 'spinner-overlay';
  174. // Create spinner
  175. const spinner = document.createElement('div');
  176. spinner.style.cssText = `
  177. width: 30px;
  178. height: 30px;
  179. border: 4px solid rgba(0,0,0,0.1);
  180. border-radius: 50%;
  181. border-top-color: #333;
  182. animation: spin 1s ease-in-out infinite;
  183. `;
  184. spinnerOverlay.appendChild(spinner);
  185.  
  186. // Add elements to GUI
  187. gui.appendChild(resizeHandle);
  188. gui.appendChild(output);
  189. gui.appendChild(buttonContainer);
  190. gui.appendChild(spinnerOverlay);
  191. document.body.appendChild(gui);
  192.  
  193. // Custom resize functionality
  194. let isResizing = false;
  195. let originalWidth, originalHeight, originalX, originalY;
  196.  
  197. resizeHandle.addEventListener('mousedown', (e) => {
  198. e.preventDefault();
  199. isResizing = true;
  200. originalWidth = parseFloat(getComputedStyle(gui).width);
  201. originalHeight = parseFloat(getComputedStyle(gui).height);
  202. originalX = e.clientX;
  203. originalY = e.clientY;
  204. document.addEventListener('mousemove', resize);
  205. document.addEventListener('mouseup', stopResize);
  206. });
  207.  
  208. function resize(e) {
  209. if (!isResizing) return;
  210. const width = originalWidth - (e.clientX - originalX);
  211. const height = originalHeight - (e.clientY - originalY);
  212. if (width > 300 && width < window.innerWidth * 0.8) {
  213. gui.style.width = width + 'px';
  214. // Keep right position fixed and adjust left position
  215. gui.style.right = getComputedStyle(gui).right;
  216. }
  217. if (height > 250 && height < window.innerHeight * 0.8) {
  218. gui.style.height = height + 'px';
  219. // Keep bottom position fixed and adjust top position
  220. gui.style.bottom = getComputedStyle(gui).bottom;
  221. }
  222. }
  223.  
  224. function stopResize() {
  225. isResizing = false;
  226. document.removeEventListener('mousemove', resize);
  227. document.removeEventListener('mouseup', stopResize);
  228. }
  229.  
  230. // Extract text function
  231. function getElementsByXpath(xpath) {
  232. const results = [];
  233. const query = document.evaluate(
  234. xpath,
  235. document,
  236. null,
  237. XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,
  238. null
  239. );
  240. for (let i = 0; i < query.snapshotLength; i++) {
  241. const node = query.snapshotItem(i);
  242. if (node) {
  243. // Get only direct text content and exclude child elements for title
  244. if (xpath === TITLE_XPATH) {
  245. let directTextContent = '';
  246. for (let j = 0; j < node.childNodes.length; j++) {
  247. const childNode = node.childNodes[j];
  248. if (childNode.nodeType === Node.TEXT_NODE) {
  249. directTextContent += childNode.textContent;
  250. }
  251. }
  252. directTextContent = directTextContent.trim();
  253. if (directTextContent) {
  254. results.push(directTextContent);
  255. }
  256. } else {
  257. // For content and author say, get full text content including children, preserving whitespace
  258. const textContent = node.textContent; // Keep original whitespace
  259. // Only push if the content is not just whitespace
  260. if (textContent && textContent.trim()) {
  261. results.push(textContent);
  262. }
  263. }
  264. }
  265. }
  266. return results;
  267. }
  268.  
  269. // Initial extraction
  270. function updateTitleOutput() {
  271. const elements = getElementsByXpath(TITLE_XPATH);
  272. return elements.join('\n');
  273. }
  274.  
  275. function updateContentOutput(includeAuthorSayFlag) {
  276. // Try to get content from spans first
  277. let elements = getElementsByXpath(CONTENT_SPAN_XPATH);
  278. // If no spans found, try direct p tags but filter out those with spans to avoid duplications
  279. if (elements.length === 0) {
  280. // First, get all p elements
  281. const pElements = document.evaluate(
  282. CONTENT_XPATH,
  283. document,
  284. null,
  285. XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,
  286. null
  287. );
  288. for (let i = 0; i < pElements.snapshotLength; i++) {
  289. const pNode = pElements.snapshotItem(i);
  290. // Check if this p element has span children
  291. const hasSpans = pNode.querySelectorAll('span').length > 0;
  292. if (!hasSpans) {
  293. // Only get text from p elements that don't have spans
  294. const textContent = pNode.textContent; // Keep original whitespace
  295. // Only push if the content is not just whitespace
  296. if (textContent && textContent.trim()) {
  297. elements.push(textContent);
  298. }
  299. }
  300. }
  301. }
  302. // Join elements, do not trim here to preserve first line indentation
  303. let content = elements.join('\n');
  304.  
  305. // Append author say if requested
  306. if (includeAuthorSayFlag) {
  307. const authorSayElements = getElementsByXpath(AUTHOR_SAY_XPATH);
  308. if (authorSayElements.length > 0) {
  309. // Join author say elements, do not trim here
  310. const authorSayContent = authorSayElements.join('\n');
  311. // Add separation if both content and author say exist and are not just whitespace
  312. if (content.trim() && authorSayContent.trim()) {
  313. content += '\n\n---\n\n' + authorSayContent; // Add separator
  314. } else if (authorSayContent.trim()) { // Only author say exists (and is not just whitespace)
  315. content = authorSayContent;
  316. }
  317. }
  318. }
  319.  
  320. return content; // Return potentially leading-whitespace content
  321. }
  322.  
  323. // Async update function
  324. async function updateOutput() {
  325. // Show spinner overlay
  326. spinnerOverlay.style.display = 'flex';
  327. // Use setTimeout to make it async and not block the UI
  328. setTimeout(() => {
  329. try {
  330. const title = updateTitleOutput();
  331. const content = updateContentOutput(includeAuthorSay); // Pass the state
  332. output.value = title ? title + '\n\n' + content : content;
  333. } catch (error) {
  334. console.error('Error updating output:', error);
  335. } finally {
  336. // Hide spinner when done
  337. spinnerOverlay.style.display = 'none';
  338. }
  339. }, 0);
  340. }
  341.  
  342. // Run initial extraction
  343. updateOutput();
  344.  
  345. // Add event listener for toggle author say button
  346. toggleAuthorSayButton.addEventListener('click', () => {
  347. includeAuthorSay = !includeAuthorSay; // Toggle state
  348. toggleAuthorSayButton.textContent = includeAuthorSay ? i18n.excludeAuthorSay : i18n.includeAuthorSay;
  349. updateOutput(); // Update the content
  350. });
  351.  
  352. // Add event listener for copy button
  353. copyButton.addEventListener('click', () => {
  354. output.select();
  355. document.execCommand('copy');
  356. copyButton.textContent = i18n.copiedText;
  357. setTimeout(() => {
  358. copyButton.textContent = i18n.copyText;
  359. }, 1000);
  360. });
  361.  
  362. // Add event listener for next chapter button
  363. nextChapterButton.addEventListener('click', () => {
  364. // Find the next chapter link using the provided XPath
  365. const nextChapterQuery = document.evaluate(
  366. NEXT_CHAPTER_BUTTON_XPATH,
  367. document,
  368. null,
  369. XPathResult.FIRST_ORDERED_NODE_TYPE,
  370. null
  371. );
  372. const nextChapterLink = nextChapterQuery.singleNodeValue;
  373. if (nextChapterLink) {
  374. // Navigate to the next chapter
  375. window.location.href = nextChapterLink.href;
  376. } else {
  377. // Show a message if there's no next chapter
  378. nextChapterButton.textContent = i18n.noNextChapter;
  379. nextChapterButton.style.backgroundColor = '#ea4335';
  380. setTimeout(() => {
  381. nextChapterButton.textContent = i18n.nextChapter;
  382. nextChapterButton.style.backgroundColor = '#34a853';
  383. }, 2000);
  384. }
  385. });
  386.  
  387. // Find the chapter wrapper element to observe
  388. const chapterWrapperQuery = document.evaluate(
  389. CHAPTER_WRAPPER_XPATH,
  390. document,
  391. null,
  392. XPathResult.FIRST_ORDERED_NODE_TYPE,
  393. null
  394. );
  395. const chapterWrapper = chapterWrapperQuery.singleNodeValue;
  396. // Update when the chapter wrapper changes
  397. if (chapterWrapper) {
  398. const observer = new MutationObserver(() => {
  399. updateOutput();
  400. });
  401. observer.observe(chapterWrapper, {
  402. childList: true,
  403. subtree: true,
  404. characterData: true
  405. });
  406. } else {
  407. console.error('Chapter wrapper element not found.');
  408. }
  409. })();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址