Greasy Fork is available in English.

AO3下载文章

AO3下载tag中的文章并打包成压缩包

  1. // ==UserScript==
  2. // @name AO3下载文章
  3. // @namespace https://greasyfork.org/users/1384897
  4. // @version 0.2
  5. // @description AO3下载tag中的文章并打包成压缩包
  6. // @author ✌
  7. // @match https://archiveofourown.org/tags/*/works*
  8. // @match https://archiveofourown.org/works?*
  9. // @grant GM_xmlhttpRequest
  10. // @grant GM_download
  11. // @connect archiveofourown.org
  12. // @require https://cdnjs.cloudflare.com/ajax/libs/jszip/3.7.1/jszip.min.js
  13. // @license MIT
  14. // ==/UserScript==
  15.  
  16. (function() {
  17. 'use strict';
  18.  
  19. const maxWorks = 1000; // 设置最大下载篇数
  20. const delay = 4000; // 设置页面跳转的延迟,单位:毫秒
  21. let worksProcessed = Number(localStorage.getItem('worksProcessed')) || 0;
  22. let zip = new JSZip();
  23. let isDownloading = false; // 标志变量,是否正在下载
  24. let downloadInterrupted = false; // 标志变量,用于控制是否中断下载
  25.  
  26. // 恢复未完成的 ZIP 进程
  27. if (localStorage.getItem('ao3ZipData')) {
  28. const zipData = JSON.parse(localStorage.getItem('ao3ZipData'));
  29. Object.keys(zipData).forEach(filename => zip.file(filename, zipData[filename]));
  30. }
  31.  
  32. // 创建下载按钮
  33. const button = document.createElement('button');
  34. button.innerText = `开始下载`;
  35. button.style.margin = "10px auto";
  36. button.style.display = "block";
  37. button.style.padding = "10px 20px";
  38. button.style.backgroundColor = "#3498db";
  39. button.style.color = "#000";
  40. button.style.border = "none";
  41. button.style.borderRadius = "5px";
  42. button.style.cursor = "pointer";
  43. button.style.fontSize = "16px";
  44. button.style.textAlign = "center";
  45. button.style.boxShadow = "0 2px 4px rgba(0, 0, 0, 0.2)";
  46.  
  47. // 将按钮插入到 header 中
  48. const header = document.querySelector('header#header');
  49. if (header) {
  50. header.insertAdjacentElement('afterend', button);
  51. }
  52.  
  53. button.addEventListener('click', () => {
  54. if (isDownloading) {
  55. // 如果正在下载,则停止下载
  56. finalizeDownloadPartial(true);
  57. downloadInterrupted = true;
  58. console.log('下载已暂停');
  59. button.innerText = '开始下载';
  60.  
  61. localStorage.clear();
  62. worksProcessed = 0;
  63. isDownloading = false;
  64. location.reload();
  65. } else {
  66. // 如果没有在下载,则开始下载
  67. downloadInterrupted = false;
  68. startDownload();
  69. }
  70. });
  71.  
  72. // 自动启动下载(用于翻页后的页面)
  73. if (localStorage.getItem('worksProcessed')) {
  74. startDownload();
  75. }
  76.  
  77. function startDownload() {
  78. console.log(`开始下载最多 ${maxWorks} 篇作品...`);
  79. isDownloading = true;
  80. button.innerText = `下载中 - 进度:${worksProcessed}/${maxWorks}`;
  81. updateButtonProgress();
  82. processPage(window.location.href);
  83. }
  84.  
  85. function processWorksWithDelay(links, index = 0) {
  86. if (downloadInterrupted) {
  87. isDownloading = false;
  88. console.log('下载已中断');
  89. return;
  90. }
  91.  
  92. if (index >= links.length || worksProcessed >= maxWorks) {
  93. checkForNextPage(document);
  94. return;
  95. }
  96.  
  97. const link = links[index];
  98. GM_xmlhttpRequest({
  99. method: 'GET',
  100. url: link,
  101. onload: response => {
  102. if (downloadInterrupted) {
  103. isDownloading = false;
  104. console.log('下载已中断');
  105. return;
  106. }
  107.  
  108. const parser = new DOMParser();
  109. const doc = parser.parseFromString(response.responseText, "text/html");
  110.  
  111. const title = doc.querySelector('h2.title').innerText.trim();
  112. let authorElement = doc.querySelector('a[rel="author"]');
  113. const author = authorElement ? authorElement.innerText.trim() : "匿名";
  114. const contentElement = doc.querySelector('#workskin');
  115. const content = contentElement ? contentElement.innerHTML : "<p>内容不可用</p>";
  116.  
  117. const htmlContent = `
  118. <!DOCTYPE html>
  119. <html lang="en">
  120. <head>
  121. <meta charset="UTF-8">
  122. <title>${title} by ${author}</title>
  123. </head>
  124. <body>
  125. <h1>${title}</h1>
  126. <h2>by ${author}</h2>
  127. ${content}
  128. </body>
  129. </html>
  130. `;
  131.  
  132. const filename = `${title} - ${author}.html`.replace(/[\/:*?"<>|]/g, '');
  133. zip.file(filename, htmlContent);
  134.  
  135. try {
  136. const zipData = JSON.parse(localStorage.getItem('ao3ZipData')) || {};
  137. zipData[filename] = htmlContent;
  138. localStorage.setItem('ao3ZipData', JSON.stringify(zipData));
  139. } catch (e) {
  140. if (e.name === 'QuotaExceededError') {
  141. console.warn('存储空间已满,立即导出并清空。');
  142. finalizeDownloadPartial(true); // 强制导出当前部分
  143. } else {
  144. console.error('存储时出错:', e);
  145. }
  146. }
  147.  
  148. worksProcessed++;
  149. localStorage.setItem('worksProcessed', worksProcessed);
  150. console.log(`已处理 ${worksProcessed}/${maxWorks}: ${title} by ${author}`);
  151. updateButtonProgress();
  152.  
  153. // 每100篇下载一个ZIP包
  154. if (worksProcessed % 100 === 0) {
  155. finalizeDownloadPartial();
  156. }
  157.  
  158. setTimeout(() => processWorksWithDelay(links, index + 1), delay);
  159. },
  160. onerror: () => {
  161. console.error(`加载内容失败: ${link}`);
  162. setTimeout(() => processWorksWithDelay(links, index + 1), delay);
  163. }
  164. });
  165. }
  166.  
  167. function processPage(url) {
  168. GM_xmlhttpRequest({
  169. method: 'GET',
  170. url: url,
  171. onload: response => {
  172. const parser = new DOMParser();
  173. const doc = parser.parseFromString(response.responseText, "text/html");
  174. const links = Array.from(doc.querySelectorAll('h4.heading a'))
  175. .filter(link => link.getAttribute("href").includes("/works/"))
  176. .map(link => `${new URL(link.getAttribute('href'), window.location.origin)}?view_adult=true&view_full_work=true`);
  177.  
  178. console.log(`正在处理页面,共有 ${links.length} 篇作品...`);
  179. processWorksWithDelay(links);
  180. },
  181. onerror: () => {
  182. console.error(`加载页面失败: ${url}`);
  183. }
  184. });
  185. }
  186.  
  187. function checkForNextPage(doc) {
  188. if (worksProcessed >= maxWorks || downloadInterrupted) {
  189. finalizeDownload();
  190. return;
  191. }
  192.  
  193. const nextLink = document.querySelector('a[rel="next"]');
  194.  
  195. if (nextLink) {
  196. const nextPageUrl = new URL(nextLink.getAttribute('href'), window.location.origin).toString();
  197. console.log("找到下一页链接:", nextPageUrl);
  198. window.location.href = nextPageUrl;
  199. } else {
  200. console.log("未找到下一页链接,结束下载");
  201. finalizeDownload();
  202. }
  203. }
  204.  
  205. function finalizeDownloadPartial(forceDownload = false) {
  206. console.log(`生成部分 ZIP 文件,包含 ${forceDownload ? worksProcessed % 100 : 100} 篇作品...`);
  207. zip.generateAsync({ type: "blob" }).then(blob => {
  208. const partNumber = Math.ceil(worksProcessed / 100);
  209. GM_download({
  210. url: URL.createObjectURL(blob),
  211. name: `AO3_Works_Part_${partNumber}.zip`,
  212. saveAs: true
  213. });
  214.  
  215. zip = new JSZip();
  216. localStorage.removeItem('ao3ZipData');
  217. }).catch(err => console.error("生成部分 ZIP 时出错:", err));
  218. }
  219.  
  220. function finalizeDownload() {
  221. if (worksProcessed % 100 !== 0) {
  222. finalizeDownloadPartial(true);
  223. }
  224. console.log("所有作品已处理,下载完成。");
  225.  
  226. localStorage.clear();
  227. worksProcessed = 0;
  228. isDownloading = false;
  229. location.reload();
  230. }
  231.  
  232. function updateButtonProgress() {
  233. button.innerText = `下载中 - 进度:${worksProcessed}/${maxWorks}`;
  234. }
  235. })();