Greasy Fork is available in English.

下载你赞助的fanbox

快速下载你赞助的fanbox用户的所有投稿

Stan na 03-02-2025. Zobacz najnowsza wersja.

  1. // ==UserScript==
  2. // @name 下载你赞助的fanbox
  3. // @namespace Schwi
  4. // @version 2.1
  5. // @description 快速下载你赞助的fanbox用户的所有投稿
  6. // @author Schwi
  7. // @match https://*.fanbox.cc/*
  8. // @icon https://s.pximg.net/common/images/fanbox/favicon.ico
  9. // @grant GM_registerMenuCommand
  10. // @grant GM_getValue
  11. // @grant GM_setValue
  12. // @grant GM_xmlhttpRequest
  13. // @connect api.fanbox.cc
  14. // @connect downloads.fanbox.cc
  15. // @require https://cdn.jsdelivr.net/npm/@zip.js/zip.js@2.7.57/dist/zip.min.js
  16. // @require https://cdn.jsdelivr.net/gh/avoidwork/filesize.js@b480b2992a3ac2acb18a030c7b3ce11fe91fb6e0/dist/filesize.min.js
  17. // @require https://cdn.jsdelivr.net/npm/streamsaver@2.0.6/StreamSaver.min.js
  18. // @supportURL https://github.com/cyb233/script
  19. // @license GPL-3.0
  20. // ==/UserScript==
  21.  
  22. (function () {
  23. 'use strict';
  24. if (window.top !== window.self) return
  25.  
  26. const api = {
  27. creator: (creatorId) => `https://api.fanbox.cc/creator.get?creatorId=${creatorId}`,
  28. plan: (creatorId) => `https://api.fanbox.cc/plan.listCreator?creatorId=${creatorId}`,
  29. creatorPost: (creatorId, limit = 1) => `https://api.fanbox.cc/post.listCreator?creatorId=${creatorId}&limit=${limit}`,
  30. post: (postId) => `https://api.fanbox.cc/post.info?postId=${postId}`
  31. }
  32.  
  33. filesize = filesize.filesize
  34.  
  35. let allPost = []
  36. let totalPost = 0
  37.  
  38. const defaultFormat = `{publishedDatetime}_{title}/{filename}`
  39.  
  40. const postType = {
  41. text: { type: 'text', name: '文本' },
  42. image: { type: 'image', name: '图片' },
  43. file: { type: 'file', name: '文件' },
  44. video: {
  45. type: 'video', name: '视频', getFullUrl: (blockEmbed) => {
  46. const serviceProvideMap = {
  47. soundcloud: `https://soundcloud.com/${blockEmbed.videoId}`,
  48. vimeo: `https://vimeo.com/${blockEmbed.videoId}`,
  49. youtube: `https://www.youtube.com/watch?v=${blockEmbed.videoId}`
  50. }
  51. return serviceProvideMap[blockEmbed.serviceProvider]
  52. }
  53. },
  54. article: { type: 'article', name: '文章' }
  55. }
  56. const baseinfo = (() => {
  57. let cachedInfo = null;
  58. return async () => {
  59. if (cachedInfo) return cachedInfo;
  60.  
  61. let creatorId = top.window.location.host.split('.')[0];
  62. let baseUrl = `https://${creatorId}.fanbox.cc`;
  63. if (creatorId === 'www') {
  64. const pathname = top.window.location.pathname;
  65. if (!pathname.startsWith('/@')) {
  66. alert('请访问用户页再执行脚本');
  67. throw new Error('请访问用户页再执行脚本');
  68. }
  69. creatorId = pathname.split('/@')[1].split('/')[0];
  70. baseUrl = `https://www.fanbox.cc/@${creatorId}`;
  71. }
  72.  
  73. const creator = await fetch(api.creator(creatorId), { credentials: 'include' }).then(response => response.json()).catch(e => console.error(e));
  74. const nickname = creator.body.user.name;
  75.  
  76. cachedInfo = { creatorId, baseUrl, nickname };
  77. return cachedInfo;
  78. };
  79. })();
  80.  
  81. async function getAllPost(progressBar) {
  82. const planData = await fetch(api.plan((await baseinfo()).creatorId), { credentials: 'include' }).then(response => response.json()).catch(e => console.error(e));
  83. const yourPlan = planData.body.filter(plan => plan.paymentMethod)
  84. const yourFee = yourPlan.length === 0 ? 0 : yourPlan[0].fee
  85. const data = await fetch(api.creatorPost((await baseinfo()).creatorId), { credentials: 'include' }).then(response => response.json()).catch(e => console.error(e));
  86. let nextId = data.body[0]?.id
  87. const postArray = []
  88. let i = 0
  89. while (nextId) {
  90. console.log(`请求第${++i}个`)
  91. const resp = await fetch(api.post(nextId), { credentials: 'include' }).then(response => response.json()).catch(e => console.error(e));
  92. const feeRequired = resp.body.feeRequired || 0
  93. if (feeRequired <= yourFee) {
  94. // 处理post类型
  95. resp.body.body.images = resp.body.body.images || []
  96. resp.body.body.files = resp.body.body.files || []
  97. resp.body.body.video = resp.body.body.video || {}
  98. if (resp.body.coverImageUrl) {
  99. // 封面图片,extension从url中获取
  100. resp.body.body.images.push({ id: 'cover', extension: resp.body.coverImageUrl.split('.').pop(), originalUrl: resp.body.coverImageUrl })
  101. }
  102. if (resp.body.type === postType.text.type) {
  103. } else if (resp.body.type === postType.image.type) {
  104. } else if (resp.body.type === postType.file.type) {
  105. } else if (resp.body.type === postType.video.type) {
  106. const url = postType.video.getFullUrl(resp.body.body.video)
  107. let html =
  108. `
  109. <!DOCTYPE html>
  110. <html lang="zh-CN">
  111. <head>
  112. <meta charset="UTF-8">
  113. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  114. <title>${resp.body.title}</title>
  115. <style>
  116. .iframely-responsive>* {
  117. top: 0;
  118. left: 0;
  119. width: 100%;
  120. height: 100%;
  121. position: absolute;
  122. border: 0;
  123. box-sizing: border-box;
  124. }
  125. </style>
  126. </head>
  127. <body>`
  128. html += `<h1>${resp.body.title}</h1>`
  129. html += `<p><a href="${url}" target="_blank">${url}</a></p>`
  130. html += `<p>${resp.body.body.text}</p>`
  131. html += `</body></html>`
  132. resp.body.body.html = html
  133. resp.body.body.text = ''
  134. } else if (resp.body.type === postType.article.type) {
  135. const blocks = resp.body.body.blocks;
  136. const image = resp.body.body.imageMap;
  137. const file = resp.body.body.fileMap;
  138. const embed = resp.body.body.embedMap;
  139. const urlEmbed = resp.body.body.urlEmbedMap;
  140. let html =
  141. `
  142. <!DOCTYPE html>
  143. <html lang="zh-CN">
  144. <head>
  145. <meta charset="UTF-8">
  146. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  147. <title>${resp.body.title}</title>
  148. <style>
  149. .iframely-responsive>* {
  150. top: 0;
  151. left: 0;
  152. width: 100%;
  153. height: 100%;
  154. position: absolute;
  155. border: 0;
  156. box-sizing: border-box;
  157. }
  158. </style>
  159. </head>
  160. <body>`
  161. if (resp.body.coverImageUrl) {
  162. html += `<p><img src="${resp.body.coverImageUrl}" alt=""/></p>`
  163. }
  164. html += `<h1>${resp.body.title}</h1>`
  165.  
  166. for (const block of blocks) {
  167. if (block.type === 'p') {
  168. html += `<p>${block.text}</p>`
  169. } else if (block.type === 'header') {
  170. html += `<h2>${block.text}</h2>`
  171. } else if (block.type === 'image') {
  172. const blockImg = image[block.imageId]
  173. html += `<p><img src="${blockImg.originalUrl}" alt="${blockImg.id}"></p>`
  174. } else if (block.type === 'file') {
  175. const blockFile = file[block.fileId]
  176. html += `<p><a href="${blockFile.url}" download="${blockFile.name}.${blockFile.extension}">${blockFile.name}.${blockFile.extension}</a></p>`
  177. } else if (block.type === 'embed') {
  178. const blockEmbed = embed[block.embedId]
  179. const url = postType.video.getFullUrl(blockEmbed)
  180. if (url) {
  181. html += `<p><a href="${url}" target="_blank">${url}</a></p>`
  182. } else {
  183. html += `<p>${JSON.stringify(block)}</p>`
  184. }
  185. } else if (block.type === 'url_embed') {
  186. const blockUrlEmbed = urlEmbed[block.urlEmbedId]
  187. if (blockUrlEmbed.type.startsWith('html')) {
  188. html += `<p class="iframely-responsive">${blockUrlEmbed.html}</p>`
  189. } else if (blockUrlEmbed.type === 'default') {
  190. html += `<p><a src="${blockUrlEmbed.url}">${blockUrlEmbed.host}</a></p>`
  191. } else {
  192. html += `<p>${JSON.stringify(block)}</p>`
  193. }
  194. } else {
  195. html += `<p>${JSON.stringify(block)}</p>`
  196. }
  197. }
  198. html += `</body></html>`
  199. for (const key in image) {
  200. resp.body.body.images.push(image[key])
  201. }
  202. for (const key in file) {
  203. resp.body.body.files.push(file[key])
  204. }
  205. resp.body.body.html = html;
  206. } else {
  207. console.log(`${nextId}:${resp.body.title} 未知类型 ${resp.body.type}`)
  208. }
  209. postArray.push(resp.body)
  210. } else {
  211. console.log(`${nextId}:${resp.body.title} 赞助等级不足,需要 ${feeRequired} 日元档,您的档位是 ${yourFee} 日元`)
  212. }
  213. progressBar.update(postArray.length, i)
  214. const prevPost = resp.body.prevPost
  215. nextId = prevPost?.id
  216. if (!nextId) {
  217. break
  218. }
  219. }
  220. console.log(`共${postArray.length}个作品`, postArray)
  221. progressBar.close()
  222. return { postArray, total: i }
  223. }
  224.  
  225. /**
  226. * 格式化路径,替换模板中的占位符,并过滤非法路径字符
  227. * @param {string} pathFormat - 路径格式模板
  228. * @param {object} post - 投稿对象
  229. * @param {object} item - 文件或图片对象
  230. * @returns {string} - 格式化后的路径
  231. */
  232. async function formatPath(pathFormat, post, item) {
  233. const illegalChars = /[\\/:*?"<>|]/g;
  234. const formattedPath = pathFormat
  235. .replace('{title}', post.title.replace(illegalChars, '_'))
  236. .replace('{filename}', `${item.name}.${item.extension}`.replace(illegalChars, '_'))
  237. .replace('{creatorId}', (await baseinfo()).creatorId.replace(illegalChars, '_'))
  238. .replace('{nickname}', (await baseinfo()).nickname.replace(illegalChars, '_'))
  239. .replace('{publishedDatetime}', post.publishedDatetime.replace(illegalChars, '_'))
  240. return formattedPath;
  241. }
  242.  
  243. async function downloadPost(selectedPost, pathFormat = defaultFormat) {
  244. const downloadFiles = []
  245. const downloadTexts = []
  246. const fileNames = new Set(); // 用于记录已存在的文件名
  247. let totalDownloadedSize = 0;
  248. let isCancelled = false; // 用于标记是否取消下载
  249. const startTime = new Date(); // 记录下载开始时间
  250.  
  251. function onBeforeUnload(event) {
  252. event.preventDefault();
  253. event.returnValue = '文件可能还没下载完成,确认要离开吗?';
  254. }
  255.  
  256. unsafeWindow.addEventListener('beforeunload', onBeforeUnload);
  257.  
  258. for (const post of selectedPost) {
  259. let imgs = post.body.images || []
  260. let files = post.body.files || []
  261. let text = post.body.text || ''
  262. let html = post.body.html || ''
  263.  
  264. for (const img of imgs) {
  265. // 根据pathFormat记录路径,用于之后打包为zip
  266. const formattedPath = await formatPath(pathFormat, post, { name: img.id, extension: img.extension })
  267. downloadFiles.push({
  268. title: post.title,
  269. filename: formattedPath,
  270. url: img.originalUrl,
  271. publishedDatetime: post.publishedDatetime
  272. })
  273. }
  274. for (const file of files) {
  275. // 根据pathFormat记录路径,用于之后打包为zip
  276. const formattedPath = await formatPath(pathFormat, post, { name: file.name, extension: file.extension })
  277. downloadFiles.push({
  278. title: post.title,
  279. filename: formattedPath,
  280. url: file.url,
  281. publishedDatetime: post.publishedDatetime
  282. })
  283. }
  284. if (text) {
  285. // 根据pathFormat记录路径,用于之后打包为zip
  286. const formattedPath = await formatPath(pathFormat, post, { name: post.title, extension: 'txt' })
  287. downloadTexts.push({
  288. title: post.title,
  289. filename: formattedPath,
  290. text,
  291. publishedDatetime: post.publishedDatetime
  292. })
  293. }
  294. if (html) {
  295. // 根据pathFormat记录路径,用于之后打包为zip
  296. const formattedPath = await formatPath(pathFormat, post, { name: post.title, extension: 'html' })
  297. downloadTexts.push({
  298. title: post.title,
  299. filename: formattedPath,
  300. text: html,
  301. publishedDatetime: post.publishedDatetime
  302. })
  303. }
  304. }
  305. console.log(`开始下载 ${downloadFiles.length + downloadTexts.length} 个文件`)
  306.  
  307. // 创建下载进度提示dialog
  308. const downloadProgressDialog = createDownloadProgressDialog(downloadFiles.length + downloadTexts.length, startTime, () => {
  309. isCancelled = true;
  310. });
  311.  
  312. const writer = new zip.ZipWriter(new zip.BlobWriter("application/zip"));
  313. const failedFiles = []; // 用于记录下载失败的文件名和原因
  314. for (const file of downloadFiles) {
  315. if (isCancelled) break; // 如果取消下载,则跳出循环
  316. let attempts = 0;
  317. while (attempts < 3) {
  318. try {
  319. const resp = await GM.xmlHttpRequest({
  320. url: file.url, responseType: 'blob', onprogress: (event) => {
  321. if (isCancelled) throw new Error('下载已取消');
  322. if (event.lengthComputable) {
  323. downloadProgressDialog.updateFileProgress(event.loaded, event.total);
  324. }
  325. },
  326. onerror: (e) => {
  327. console.error(e);
  328. throw e;
  329. }
  330. });
  331. if (!resp.response?.size) {
  332. throw new Error('文件大小为0');
  333. }
  334. totalDownloadedSize += resp.response.size;
  335. downloadProgressDialog.updateTotalSize(totalDownloadedSize);
  336. let filename = file.filename;
  337. let counter = 1;
  338. while (fileNames.has(filename)) {
  339. const extIndex = file.filename.lastIndexOf('.');
  340. const baseName = file.filename.substring(0, extIndex);
  341. const extension = file.filename.substring(extIndex);
  342. filename = `${baseName}(${counter})${extension}`;
  343. counter++;
  344. }
  345. fileNames.add(filename);
  346. console.log(`${file.title}:${filename} 下载成功,文件大小 ${filesize(resp.response.size)}`);
  347. await writer.add(filename, new zip.BlobReader(resp.response));
  348. downloadProgressDialog.updateTotalProgress();
  349. break; // 下载成功,跳出重试循环
  350. } catch (e) {
  351. attempts++;
  352. console.error(`${file.title}:${file.filename} 下载失败,重试第 ${attempts} 次`, e);
  353. if (attempts >= 3) {
  354. failedFiles.push({ filename: file.filename, error: e.message });
  355. downloadProgressDialog.updateFailedFiles(failedFiles); // 实时更新失败文件列表
  356. }
  357. }
  358. }
  359. }
  360. for (const text of downloadTexts) {
  361. if (isCancelled) break; // 如果取消下载,则跳出循环
  362. try {
  363. console.log(`${text.title}:${text.filename} 下载成功,文件大小 ${filesize(text.text.length)}`);
  364. let filename = text.filename;
  365. let counter = 1;
  366. while (fileNames.has(filename)) {
  367. const extIndex = text.filename.lastIndexOf('.');
  368. const baseName = text.filename.substring(0, extIndex);
  369. const extension = text.filename.substring(extIndex);
  370. filename = `${baseName}(${counter})${extension}`;
  371. counter++;
  372. }
  373. fileNames.add(filename);
  374. totalDownloadedSize += text.text.length;
  375. downloadProgressDialog.updateTotalSize(totalDownloadedSize);
  376. await writer.add(filename, new zip.TextReader(text.text));
  377. downloadProgressDialog.updateTotalProgress();
  378. } catch (e) {
  379. console.error(`${text.title}:${text.filename} 下载失败`, e);
  380. failedFiles.push({ filename: text.filename, error: e.message });
  381. downloadProgressDialog.updateFailedFiles(failedFiles); // 实时更新失败文件列表
  382. }
  383. }
  384. if (isCancelled) {
  385. console.log('下载已取消');
  386. downloadProgressDialog.close();
  387. unsafeWindow.removeEventListener('beforeunload', onBeforeUnload);
  388. return;
  389. }
  390. console.log(`${downloadFiles.length + downloadTexts.length} 个文件下载完成`)
  391. console.log('开始生成压缩包', writer)
  392. const zipFileBlob = await writer.close().catch(e => console.error(e));
  393. console.log(`压缩包生成完成,开始下载,压缩包大小:${filesize(zipFileBlob.size)}`)
  394. downloadProgressDialog.updateTitle('下载完成');
  395. downloadProgressDialog.updateTotalSize(totalDownloadedSize, filesize(zipFileBlob.size));
  396. downloadProgressDialog.stopElapsedTime(); // 停止已运行时间更新
  397. downloadProgressDialog.updateFailedFiles(failedFiles); // 更新失败文件列表
  398. downloadProgressDialog.updateConfirmButton(() => {
  399. downloadProgressDialog.close();
  400. unsafeWindow.removeEventListener('beforeunload', onBeforeUnload);
  401. });
  402. downloadProgressDialog.addSaveButton(async () => {
  403. saveBlob(zipFileBlob, `${(await baseinfo()).nickname}.zip`);
  404. });
  405. saveBlob(zipFileBlob, `${(await baseinfo()).nickname}.zip`);
  406. }
  407.  
  408. function saveBlob(blob, filename) {
  409. // 使用StreamSaver.js下载
  410. const fileStream = streamSaver.createWriteStream(filename, {
  411. size: blob.size
  412. })
  413. const readableStream = blob.stream()
  414. // more optimized pipe version
  415. // (Safari may have pipeTo but it's useless without the WritableStream)
  416. if (window.WritableStream && readableStream.pipeTo) {
  417. return readableStream.pipeTo(fileStream)
  418. .then(() => alert('下载结束,请查看下载目录'))
  419. }
  420.  
  421. // Write (pipe) manually
  422. window.writer = fileStream.getWriter()
  423.  
  424. const reader = readableStream.getReader()
  425. const pump = () => reader.read()
  426. .then(res => res.done
  427. ? writer.close()
  428. : writer.write(res.value).then(pump))
  429.  
  430. pump()
  431. }
  432.  
  433. // 创建下载进度提示dialog
  434. function createDownloadProgressDialog(totalFiles, startTime, onCancel) {
  435. const dialog = document.createElement('div');
  436. dialog.style.position = 'fixed';
  437. dialog.style.top = '50%';
  438. dialog.style.left = '50%';
  439. dialog.style.transform = 'translate(-50%, -50%)';
  440. dialog.style.backgroundColor = 'white';
  441. dialog.style.padding = '20px';
  442. dialog.style.boxShadow = '0 0 10px rgba(0, 0, 0, 0.5)';
  443. dialog.style.zIndex = '1000';
  444. dialog.style.fontFamily = 'Arial, sans-serif';
  445. dialog.style.borderRadius = '10px'; // 添加圆角
  446. dialog.style.width = '50%'; // 调整宽度到50%
  447. dialog.style.height = '50%'; // 调整高度到50%
  448. dialog.style.textAlign = 'center'; // 居中文本
  449. dialog.style.overflowY = 'auto'; // 超出时可滚动
  450.  
  451. const title = document.createElement('h2');
  452. title.innerText = `下载进度`;
  453. title.style.marginBottom = '20px'; // 调整内边距
  454. dialog.appendChild(title);
  455.  
  456. const totalProgress = document.createElement('p');
  457. totalProgress.innerText = `总进度: 0/${totalFiles}`;
  458. totalProgress.style.marginBottom = '10px'; // 调整内边距
  459. dialog.appendChild(totalProgress);
  460.  
  461. const fileProgress = document.createElement('p');
  462. fileProgress.innerText = `当前文件进度: 0B/0B`;
  463. fileProgress.style.marginBottom = '10px'; // 调整内边距
  464. dialog.appendChild(fileProgress);
  465.  
  466. const totalSize = document.createElement('p');
  467. totalSize.innerText = `总大小: 0B`;
  468. totalSize.style.marginBottom = '10px'; // 调整内边距
  469. dialog.appendChild(totalSize);
  470.  
  471. const startTimeElement = document.createElement('p');
  472. startTimeElement.innerText = `开始时间: ${startTime.toLocaleTimeString()}`;
  473. startTimeElement.style.marginBottom = '10px'; // 调整内边距
  474. dialog.appendChild(startTimeElement);
  475.  
  476. const elapsedTimeElement = document.createElement('p');
  477. elapsedTimeElement.innerText = `已运行时间: 0秒`;
  478. elapsedTimeElement.style.marginBottom = '10px'; // 调整内边距
  479. dialog.appendChild(elapsedTimeElement);
  480.  
  481. const confirmButton = document.createElement('button');
  482. confirmButton.innerText = '取消';
  483. confirmButton.style.backgroundColor = '#ff4d4f'; // 修改背景颜色为红色
  484. confirmButton.style.color = '#fff'; // 修改文字颜色为白色
  485. confirmButton.style.border = 'none';
  486. confirmButton.style.borderRadius = '5px';
  487. confirmButton.style.cursor = 'pointer';
  488. confirmButton.style.padding = '5px 10px';
  489. confirmButton.style.transition = 'background-color 0.3s'; // 添加过渡效果
  490. confirmButton.onmouseover = () => { confirmButton.style.backgroundColor = '#d93637'; } // 添加悬停效果
  491. confirmButton.onmouseout = () => { confirmButton.style.backgroundColor = '#ff4d4f'; } // 恢复背景颜色
  492. confirmButton.onclick = () => {
  493. if (onCancel) onCancel();
  494. };
  495. dialog.appendChild(confirmButton);
  496.  
  497. const failedFilesTitle = document.createElement('h3');
  498. failedFilesTitle.innerText = '下载失败的文件';
  499. failedFilesTitle.style.marginTop = '20px'; // 调整内边距
  500. dialog.appendChild(failedFilesTitle);
  501.  
  502. const failedFilesTable = document.createElement('table');
  503. failedFilesTable.style.width = '100%';
  504. failedFilesTable.style.borderCollapse = 'collapse';
  505. failedFilesTable.style.marginBottom = '10px'; // 调整内边距
  506.  
  507. const failedFilesHeader = document.createElement('tr');
  508. const indexHeader = document.createElement('th');
  509. indexHeader.innerText = '序号';
  510. indexHeader.style.border = '1px solid #ccc';
  511. indexHeader.style.padding = '5px';
  512. indexHeader.style.width = '10%'; // 设置序号列宽度
  513. const filenameHeader = document.createElement('th');
  514. filenameHeader.innerText = '文件名';
  515. filenameHeader.style.border = '1px solid #ccc';
  516. filenameHeader.style.padding = '5px';
  517. filenameHeader.style.width = '45%'; // 设置文件名列宽度
  518. const errorHeader = document.createElement('th');
  519. errorHeader.innerText = '原因';
  520. errorHeader.style.border = '1px solid #ccc';
  521. errorHeader.style.padding = '5px';
  522. errorHeader.style.width = '45%'; // 设置原因列宽度
  523. failedFilesHeader.appendChild(indexHeader);
  524. failedFilesHeader.appendChild(filenameHeader);
  525. failedFilesHeader.appendChild(errorHeader);
  526. failedFilesTable.appendChild(failedFilesHeader);
  527.  
  528. const failedFilesBody = document.createElement('tbody');
  529. const initialRow = document.createElement('tr');
  530. const initialCell = document.createElement('td');
  531. initialCell.colSpan = 3;
  532. initialCell.innerText = '无';
  533. initialCell.style.border = '1px solid #ccc';
  534. initialCell.style.padding = '5px';
  535. initialRow.appendChild(initialCell);
  536. failedFilesBody.appendChild(initialRow);
  537. failedFilesTable.appendChild(failedFilesBody);
  538.  
  539. dialog.appendChild(failedFilesTable);
  540.  
  541. document.body.appendChild(dialog);
  542.  
  543. const intervalId = setInterval(() => {
  544. const elapsedTime = Math.floor((new Date() - startTime) / 1000);
  545. const hours = Math.floor(elapsedTime / 3600);
  546. const minutes = Math.floor((elapsedTime % 3600) / 60);
  547. const seconds = elapsedTime % 60;
  548. let elapsedTimeStr = '';
  549. if (hours > 0) {
  550. elapsedTimeStr += `${hours}小时`;
  551. }
  552. if (minutes > 0 || hours > 0) {
  553. elapsedTimeStr += `${minutes}分钟`;
  554. }
  555. elapsedTimeStr += `${seconds}秒`;
  556. elapsedTimeElement.innerText = `已运行时间: ${elapsedTimeStr}`;
  557. }, 1000);
  558.  
  559. return {
  560. updateTitle: (newTitle) => {
  561. title.innerText = newTitle;
  562. },
  563. updateTotalProgress: () => {
  564. const currentCount = parseInt(totalProgress.innerText.split('/')[0].split(': ')[1]) + 1;
  565. totalProgress.innerText = `总进度: ${currentCount}/${totalFiles}`;
  566. },
  567. updateFileProgress: (loaded, total) => {
  568. fileProgress.innerText = `当前文件进度: ${filesize(loaded)}/${filesize(total)}`;
  569. },
  570. updateTotalSize: (size, zipSize) => {
  571. totalSize.innerText = zipSize ? `总大小: ${filesize(size)} (压缩包大小: ${zipSize})` : `总大小: ${filesize(size)}`;
  572. },
  573. updateFailedFiles: (failedFiles) => {
  574. failedFilesBody.innerHTML = ''; // 清空表格内容
  575. if (failedFiles.length > 0) {
  576. failedFiles.forEach((file, index) => {
  577. const row = document.createElement('tr');
  578. const indexCell = document.createElement('td');
  579. indexCell.innerText = index + 1;
  580. indexCell.style.border = '1px solid #ccc';
  581. indexCell.style.padding = '5px';
  582. const filenameCell = document.createElement('td');
  583. filenameCell.innerText = file.filename;
  584. filenameCell.style.border = '1px solid #ccc';
  585. filenameCell.style.padding = '5px';
  586. const errorCell = document.createElement('td');
  587. errorCell.innerText = file.error;
  588. errorCell.style.border = '1px solid #ccc';
  589. errorCell.style.padding = '5px';
  590. row.appendChild(indexCell);
  591. row.appendChild(filenameCell);
  592. row.appendChild(errorCell);
  593. failedFilesBody.appendChild(row);
  594. });
  595. } else {
  596. const row = document.createElement('tr');
  597. const cell = document.createElement('td');
  598. cell.colSpan = 3;
  599. cell.innerText = '无';
  600. cell.style.border = '1px solid #ccc';
  601. cell.style.padding = '5px';
  602. row.appendChild(cell);
  603. failedFilesBody.appendChild(row);
  604. }
  605. },
  606. updateConfirmButton: (onConfirm) => {
  607. confirmButton.innerText = '确认';
  608. confirmButton.style.backgroundColor = '#007BFF'; // 修改背景颜色为蓝色
  609. confirmButton.onmouseover = () => { confirmButton.style.backgroundColor = '#0056b3'; } // 添加悬停效果
  610. confirmButton.onmouseout = () => { confirmButton.style.backgroundColor = '#007BFF'; } // 恢复背景颜色
  611. confirmButton.onclick = onConfirm;
  612. },
  613. addSaveButton: (onSave) => {
  614. // 添加保存按钮
  615. const saveButton = document.createElement('button');
  616. saveButton.innerText = '重新保存';
  617. saveButton.style.backgroundColor = '#28a745'; // 修改背景颜色为绿色
  618. saveButton.style.color = '#fff'; // 修改文字颜色为白色
  619. saveButton.style.border = 'none';
  620. saveButton.style.borderRadius = '5px';
  621. saveButton.style.cursor = 'pointer';
  622. saveButton.style.padding = '5px 10px';
  623. saveButton.style.transition = 'background-color 0.3s'; // 添加过渡效果
  624. saveButton.onmouseover = () => { saveButton.style.backgroundColor = '#218838'; } // 添加悬停效果
  625. saveButton.onmouseout = () => { saveButton.style.backgroundColor = '#28a745'; } // 恢复背景颜色
  626. saveButton.onclick = onSave;
  627. // 将保存按钮添加到确认按钮的右侧
  628. confirmButton.parentNode.insertBefore(saveButton, confirmButton.nextSibling);
  629. },
  630. stopElapsedTime: () => {
  631. clearInterval(intervalId);
  632. },
  633. close: () => {
  634. clearInterval(intervalId);
  635. document.body.removeChild(dialog);
  636. }
  637. };
  638. }
  639.  
  640. // 创建获取投稿进度条,长宽90%,实时显示postArray的长度
  641. function createProgressBar() {
  642. const progressBar = document.createElement('div')
  643. progressBar.style.position = 'fixed'
  644. progressBar.style.bottom = '10px'
  645. progressBar.style.left = '10px'
  646. progressBar.style.backgroundColor = 'rgba(0, 0, 0, 0.7)'
  647. progressBar.style.color = 'white'
  648. progressBar.style.padding = '5px 10px'
  649. progressBar.style.borderRadius = '5px'
  650. document.body.appendChild(progressBar)
  651. return {
  652. update: (num, total) => {
  653. progressBar.innerText = `已获取 ${num}/${total} 个投稿`
  654. },
  655. close: () => {
  656. document.body.removeChild(progressBar)
  657. }
  658. }
  659. }
  660.  
  661. /**
  662. * 创建结果弹窗,长宽90%, 顶部标题栏显示`投稿查询结果${选中数量}/${总数量}`,右上角有关闭按钮
  663. * 弹窗顶部有一个全选按钮,点击后全选所有投稿,有一个下载按钮,点击后下载所有勾选的投稿
  664. * 点击下载按钮后,会下载所有选中的投稿,下载路径格式为输入框的值,传入downloadPost函数
  665. * 弹窗顶部有一个输入框,用于输入下载路径格式,通过GM_setValue和GM_getValue保存到本地,可用参数`{creatorId}`,`{nickname}`,`{title}`,`{filename}`,`{publishedDatetime}`,用于替换为投稿的用户名、标题、文件名、发布时间
  666. * 投稿结果使用grid布局,长宽200px,每个格子顶部正中为标题,第二行为文件和图片数量,剩余空间为正文,正文总是存在并撑满剩余空间,且Y轴可滚动
  667. * 点击格子可以选中或取消选中,选中的格子会被下载按钮下载
  668. * 底部有查看详情按钮,链接格式为`/posts/${post.body.id}`
  669. */
  670. function createResultDialog(allPost, total) {
  671. const dialog = document.createElement('div')
  672. dialog.style.position = 'fixed'
  673. dialog.style.top = '5%'
  674. dialog.style.left = '5%'
  675. dialog.style.width = '90%'
  676. dialog.style.height = '90%'
  677. dialog.style.backgroundColor = 'white'
  678. dialog.style.zIndex = '1000'
  679. dialog.style.padding = '20px'
  680. dialog.style.boxShadow = '0 0 10px rgba(0, 0, 0, 0.5)'
  681. dialog.style.display = 'flex'
  682. dialog.style.flexDirection = 'column'
  683. dialog.style.fontFamily = 'Arial, sans-serif'
  684.  
  685. const header = document.createElement('div')
  686. header.style.display = 'flex';
  687. header.style.justifyContent = 'space-between';
  688. header.style.alignItems = 'center';
  689. header.style.paddingBottom = '20px'; // 调整内边距
  690. header.style.fontSize = '18px'; // 增加字体大小
  691.  
  692. const title = document.createElement('h2')
  693. title.innerText = `投稿查询结果 0/${allPost.length}/${total}`
  694. title.style.margin = '0'; // 移除默认的标题外边距
  695.  
  696. header.appendChild(title)
  697.  
  698. const closeButton = document.createElement('button')
  699. closeButton.innerText = '关闭'
  700. closeButton.style.backgroundColor = '#ff4d4f'; // 修改背景颜色为红色
  701. closeButton.style.color = '#fff'; // 修改文字颜色为白色
  702. closeButton.style.border = 'none';
  703. closeButton.style.borderRadius = '5px';
  704. closeButton.style.cursor = 'pointer';
  705. closeButton.style.padding = '5px 10px';
  706. closeButton.style.transition = 'background-color 0.3s'; // 添加过渡效果
  707. closeButton.onmouseover = () => { closeButton.style.backgroundColor = '#d93637'; } // 添加悬停效果
  708. closeButton.onmouseout = () => { closeButton.style.backgroundColor = '#ff4d4f'; } // 恢复背景颜色
  709. closeButton.onclick = () => {
  710. document.body.removeChild(dialog)
  711. }
  712. header.appendChild(closeButton)
  713.  
  714. dialog.appendChild(header)
  715.  
  716. const controls = document.createElement('div')
  717. controls.style.display = 'flex'
  718. controls.style.justifyContent = 'space-between'
  719. controls.style.alignItems = 'center'
  720. controls.style.marginBottom = '20px'
  721.  
  722. const leftControls = document.createElement('div')
  723. leftControls.style.display = 'flex'
  724. leftControls.style.alignItems = 'center'
  725.  
  726. const selectAllButton = document.createElement('button')
  727. selectAllButton.innerText = '全选'
  728. selectAllButton.style.backgroundColor = '#007BFF'; // 背景颜色
  729. selectAllButton.style.color = 'white'; // 文字颜色
  730. selectAllButton.style.border = 'none'; // 去掉边框
  731. selectAllButton.style.borderRadius = '5px'; // 圆角
  732. selectAllButton.style.cursor = 'pointer';
  733. selectAllButton.style.padding = '5px 10px';
  734. selectAllButton.style.transition = 'background-color 0.3s'; // 过渡效果
  735. selectAllButton.style.marginRight = '10px'; // 添加右侧外边距
  736. selectAllButton.onmouseover = () => {
  737. selectAllButton.style.backgroundColor = '#0056b3'; // 鼠标悬停时的颜色
  738. }
  739. selectAllButton.onmouseout = () => {
  740. selectAllButton.style.backgroundColor = '#007BFF'; // 鼠标移开时的颜色
  741. }
  742. selectAllButton.onclick = () => {
  743. const checkboxes = dialog.querySelectorAll('input[type="checkbox"]')
  744. const postElements = dialog.querySelectorAll('.post-element')
  745. checkboxes.forEach((checkbox, index) => {
  746. checkbox.checked = !selectAllButton.classList.contains('deselect')
  747. postElements[index].style.backgroundColor = checkbox.checked ? 'lightblue' : 'white'
  748. })
  749. selectAllButton.classList.toggle('deselect')
  750. updateTitle()
  751. }
  752. leftControls.appendChild(selectAllButton)
  753.  
  754. const downloadButton = document.createElement('button')
  755. downloadButton.innerText = '下载'
  756. downloadButton.style.backgroundColor = '#007BFF'; // 背景颜色
  757. downloadButton.style.color = 'white'; // 文字颜色
  758. downloadButton.style.border = 'none'; // 去掉边框
  759. downloadButton.style.borderRadius = '5px'; // 圆角
  760. downloadButton.style.cursor = 'pointer';
  761. downloadButton.style.padding = '5px 10px';
  762. downloadButton.style.transition = 'background-color 0.3s'; // 过渡效果
  763. downloadButton.onmouseover = () => {
  764. downloadButton.style.backgroundColor = '#0056b3'; // 鼠标悬停时的颜色
  765. }
  766. downloadButton.onmouseout = () => {
  767. downloadButton.style.backgroundColor = '#007BFF'; // 鼠标移开时的颜色
  768. }
  769. downloadButton.onclick = async () => {
  770. const selectedPost = []
  771. dialog.querySelectorAll('input[type="checkbox"]:checked').forEach(checkbox => {
  772. selectedPost.push(allPost[checkbox.dataset.index])
  773. })
  774. if (selectedPost.length === 0) {
  775. alert('请先选择要下载的投稿项');
  776. return;
  777. }
  778. const pathFormatInput = dialog.querySelector('input[type="text"]')
  779. const pathFormat = pathFormatInput.value || defaultFormat
  780. await downloadPost(selectedPost, pathFormat).catch(e => console.error(e))
  781. }
  782. leftControls.appendChild(downloadButton)
  783.  
  784. controls.appendChild(leftControls)
  785.  
  786. const rightControls = document.createElement('div')
  787. rightControls.style.display = 'flex'
  788. rightControls.style.alignItems = 'center'
  789.  
  790. const pathFormatLabel = document.createElement('label')
  791. pathFormatLabel.innerText = '下载路径格式 (可用参数: {creatorId}, {nickname}, {title}, {filename}, {publishedDatetime}):'
  792. pathFormatLabel.style.display = 'block'
  793. pathFormatLabel.style.marginBottom = '5px'
  794.  
  795. const pathFormatInput = document.createElement('input')
  796. pathFormatInput.type = 'text'
  797. pathFormatInput.placeholder = '下载路径格式'
  798. pathFormatInput.value = GM_getValue('pathFormat', defaultFormat)
  799. pathFormatInput.style.width = '200px'
  800. pathFormatInput.style.padding = '5px'
  801. pathFormatInput.style.fontSize = '14px'
  802. pathFormatInput.onchange = () => {
  803. GM_setValue('pathFormat', pathFormatInput.value)
  804. }
  805.  
  806. rightControls.appendChild(pathFormatLabel)
  807. rightControls.appendChild(pathFormatInput)
  808.  
  809. controls.appendChild(rightControls)
  810.  
  811. dialog.appendChild(controls)
  812.  
  813. const content = document.createElement('div')
  814. content.style.display = 'grid'
  815. content.style.gridTemplateColumns = 'repeat(auto-fill, minmax(250px, 1fr))'
  816. content.style.gap = '20px' // 控制postElement之间的距离
  817. content.style.padding = '20px'
  818. content.style.flexGrow = '1'
  819. content.style.overflowY = 'auto'
  820.  
  821. allPost.forEach((post, index) => {
  822. const postElement = document.createElement('div')
  823. postElement.className = 'post-element'
  824. postElement.style.border = '1px solid #ccc'
  825. postElement.style.padding = '10px'
  826. postElement.style.borderRadius = '10px' // 增加圆角
  827. postElement.style.display = 'flex'
  828. postElement.style.flexDirection = 'column'
  829. postElement.style.alignItems = 'center'
  830. postElement.style.justifyContent = 'space-between'
  831. postElement.style.height = '250px' // 调整高度
  832. postElement.style.boxShadow = '0 2px 5px rgba(0, 0, 0, 0.1)' // 添加阴影
  833. postElement.style.transition = 'transform 0.2s' // 添加过渡效果
  834. postElement.onmouseover = () => {
  835. postElement.style.transform = 'scale(1.05)' // 鼠标悬停时放大
  836. }
  837. postElement.onmouseout = () => {
  838. postElement.style.transform = 'scale(1)' // 鼠标移开时恢复
  839. }
  840.  
  841. const checkbox = document.createElement('input')
  842. checkbox.type = 'checkbox'
  843. checkbox.dataset.index = index
  844. checkbox.style.marginBottom = '10px'
  845. postElement.appendChild(checkbox)
  846.  
  847. const title = document.createElement('h3')
  848. title.innerText = post.title
  849. title.style.margin = '0'
  850. title.style.fontSize = '16px'
  851. title.style.textAlign = 'center'
  852. title.style.whiteSpace = 'nowrap' // 单行显示
  853. title.style.overflow = 'hidden' // 隐藏超出部分
  854. title.style.textOverflow = 'ellipsis' // 显示省略号
  855. title.style.width = '100%' // 确保宽度不超过父元素
  856. title.style.minHeight = '20px' // 设置最小高度
  857. title.style.flexShrink = '0' // 防止收缩
  858. postElement.appendChild(title)
  859.  
  860. const typeElement = document.createElement('p')
  861. typeElement.innerText = `${postType[post.type]?.name || post.type}`
  862. typeElement.style.margin = '0'
  863. typeElement.style.fontSize = '14px'
  864. typeElement.style.color = '#555'
  865. typeElement.style.whiteSpace = 'nowrap' // 单行显示
  866. typeElement.style.overflow = 'hidden' // 隐藏超出部分
  867. typeElement.style.textOverflow = 'ellipsis' // 显示省略号
  868. typeElement.style.width = '100%' // 确保宽度不超过父元素
  869. typeElement.style.minHeight = '15px' // 设置最小高度
  870. typeElement.style.flexShrink = '0' // 防止收缩
  871. typeElement.style.textAlign = 'center' // 居中
  872. postElement.appendChild(typeElement)
  873.  
  874. const images = post.body.images || []
  875. const files = post.body.files || []
  876. let text = post.body.text || ''
  877. if (!text && post.body.html) {
  878. const html = post.body.html
  879. const parser = new DOMParser()
  880. const doc = parser.parseFromString(html, 'text/html')
  881. text = doc.body.innerText
  882. }
  883.  
  884. const mediaCount = document.createElement('p')
  885. mediaCount.innerText = `${images.length} 张图片 | ${files.length} 个文件`
  886. mediaCount.style.margin = '0'
  887. mediaCount.style.fontSize = '14px'
  888. mediaCount.style.color = '#555'
  889. mediaCount.style.whiteSpace = 'nowrap' // 单行显示
  890. mediaCount.style.overflow = 'hidden' // 隐藏超出部分
  891. mediaCount.style.textOverflow = 'ellipsis' // 显示省略号
  892. mediaCount.style.width = '100%' // 确保宽度不超过父元素
  893. mediaCount.style.minHeight = '15px' // 设置最小高度
  894. mediaCount.style.flexShrink = '0' // 防止收缩
  895. mediaCount.style.textAlign = 'center' // 居中
  896. postElement.appendChild(mediaCount)
  897.  
  898. const textElement = document.createElement('div')
  899. textElement.innerText = text
  900. textElement.style.marginTop = '10px'
  901. textElement.style.fontSize = '14px'
  902. textElement.style.color = '#333'
  903. textElement.style.overflowY = 'auto'
  904. textElement.style.overflowX = 'hidden'
  905. textElement.style.wordBreak = 'break-all' // 长单词换行
  906. textElement.style.flexGrow = '1' // 撑满剩余空间
  907. textElement.style.width = '100%' // 确保宽度不超过父元素
  908. postElement.appendChild(textElement)
  909.  
  910. // 增加查看详情按钮
  911. const viewButton = document.createElement('button')
  912. viewButton.innerText = '查看详情'
  913. viewButton.style.marginTop = '10px'
  914. viewButton.style.padding = '5px 10px'
  915. viewButton.style.fontSize = '14px'
  916. viewButton.style.cursor = 'pointer'
  917. viewButton.style.backgroundColor = '#007BFF' // 背景颜色
  918. viewButton.style.color = 'white' // 文字颜色
  919. viewButton.style.border = 'none' // 去掉边框
  920. viewButton.style.borderRadius = '5px' // 圆角
  921. viewButton.style.transition = 'background-color 0.3s' // 过渡效果
  922. viewButton.onmouseover = () => {
  923. viewButton.style.backgroundColor = '#0056b3' // 鼠标悬停时的颜色
  924. }
  925. viewButton.onmouseout = () => {
  926. viewButton.style.backgroundColor = '#007BFF' // 鼠标移开时的颜色
  927. }
  928. viewButton.onclick = async (event) => {
  929. event.stopPropagation(); // 阻止事件冒泡
  930. window.open(`${(await baseinfo()).baseUrl}/posts/${post.id}`, '_blank')
  931. }
  932. postElement.appendChild(viewButton)
  933.  
  934. postElement.onclick = () => {
  935. checkbox.checked = !checkbox.checked
  936. postElement.style.backgroundColor = checkbox.checked ? 'lightblue' : 'white'
  937. updateTitle()
  938. }
  939.  
  940. content.appendChild(postElement)
  941. })
  942.  
  943. dialog.appendChild(content)
  944.  
  945. document.body.appendChild(dialog)
  946.  
  947. function updateTitle() {
  948. const selectedCount = dialog.querySelectorAll('input[type="checkbox"]:checked').length
  949. title.innerText = `投稿查询结果 ${selectedCount}/${allPost.length}/${total}`
  950. }
  951. }
  952.  
  953. async function fmain() {
  954. if (allPost.length === 0) {
  955. // 创建进度条
  956. const progressBar = createProgressBar()
  957. // 获取所有投稿
  958. const { postArray, total } = await getAllPost(progressBar).catch(e => console.error(e))
  959. allPost = postArray
  960. totalPost = total
  961. }
  962. // 创建结果弹窗
  963. const resultDialog = createResultDialog(allPost, totalPost)
  964. }
  965.  
  966. GM_registerMenuCommand('查询投稿', fmain)
  967.  
  968. })();