cmoa.jp Downloader

Downloads comic pages from cmoa.jp

当前为 2024-04-07 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name cmoa.jp Downloader
  3. // @version 1.1.3
  4. // @description Downloads comic pages from cmoa.jp
  5. // @author tnt_kitty
  6. // @match *://*.cmoa.jp/bib/speedreader/*
  7. // @icon https://www.cmoa.jp/favicon.ico
  8. // @grant GM_addStyle
  9. // @grant GM_getResourceText
  10. // @grant GM_download
  11. // @resource bt https://cdn.jsdelivr.net/npm/bootstrap@5.1.0/dist/css/bootstrap.min.css
  12. // @require https://cdn.jsdelivr.net/npm/bootstrap@5.1.0/dist/js/bootstrap.bundle.min.js
  13. // @require https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/js/all.min.js
  14. // @require https://cdnjs.cloudflare.com/ajax/libs/jszip/3.7.1/jszip.min.js
  15. // @license GPL-3.0-only
  16. // @namespace https://greasyfork.org/users/914763
  17. // ==/UserScript==
  18.  
  19. function convertToValidFileName(string) {
  20. return string.replace(/[/\\?%*:|"<>]/g, '-');
  21. }
  22.  
  23. function isValidFileName(string) {
  24. const regex = new RegExp('[/\\?%*:|"<>]', 'g');
  25. return !regex.test(string);
  26. }
  27.  
  28. function getTitle() {
  29. try {
  30. return __sreaderFunc__.contentInfo.items[0].Title;
  31. } catch (error) {
  32. return null;
  33. }
  34. }
  35.  
  36. function getAuthors() {
  37. try {
  38. return __sreaderFunc__.contentInfo.items[0].Authors[0].Name.split('/'); // Returns array of authors, ex. ['Author1', 'Author2']
  39. } catch (error) {
  40. return null;
  41. }
  42. }
  43.  
  44. function getVolume() {
  45. try {
  46. return parseInt(__sreaderFunc__.contentInfo.items[0].ShopURL.split('/').at(-2));
  47. } catch (error) {
  48. return null;
  49. }
  50. }
  51.  
  52. function getPageCount() {
  53. try {
  54. return SpeedBinb.getInstance('content').total - 1;
  55. } catch (error) {
  56. return null;
  57. }
  58. }
  59.  
  60. function getPageIntervals() {
  61. const isEmpty = string => !string.trim().length;
  62.  
  63. const pagesField = document.querySelector('#pages-field');
  64. let fieldValue = pagesField.value;
  65.  
  66. if (isEmpty(fieldValue)) {
  67. const speedbinb = SpeedBinb.getInstance('content');
  68. const totalPages = getPageCount();
  69. return [[1, totalPages]];
  70. }
  71.  
  72. const pagesList = fieldValue.split(',');
  73. let pageIntervals = [];
  74.  
  75. for (const x of pagesList) {
  76. let pages = x.split('-');
  77. if (pages.length === 1) {
  78. pageIntervals.push([parseInt(pages[0]), parseInt(pages[0])]);
  79. } else if (pages.length === 2) {
  80. pageIntervals.push([parseInt(pages[0]), parseInt(pages[1])]);
  81. }
  82. }
  83.  
  84. if (pageIntervals.length <= 1) {
  85. return pageIntervals;
  86. }
  87.  
  88. pageIntervals.sort((a, b) => b[0] - a[0]);
  89.  
  90. const start = 0, end = 1;
  91. let mergedIntervals = [];
  92. let newInterval = pageIntervals[0];
  93. for (let i = 1; i < pageIntervals.length; i++) {
  94. let currentInterval = pageIntervals[i];
  95. if (currentInterval[start] <= newInterval[end]) {
  96. newInterval[end] = Math.max(newInterval[end], currentInterval[end]);
  97. } else {
  98. mergedIntervals.push(newInterval);
  99. newInterval = currentInterval;
  100. }
  101. }
  102. mergedIntervals.push(newInterval);
  103. return mergedIntervals;
  104. }
  105.  
  106. function initializeComicInfo() {
  107. const titleListItem = document.querySelector('#comic-title');
  108. const authorListItem = document.querySelector('#comic-author');
  109. const volumeListItem = document.querySelector('#comic-volume');
  110. const pageCountListItem = document.querySelector('#comic-page-count');
  111.  
  112. const titleDiv = document.createElement('div');
  113. titleDiv.innerText = getTitle();
  114. titleListItem.appendChild(titleDiv);
  115.  
  116. const authors = getAuthors();
  117. if (authors.length > 1) {
  118. const authorLabel = authorListItem.querySelector('.fw-bold');
  119. authorLabel.innerText = 'Authors';
  120. }
  121. for (let i = 0; i < authors.length; i++) {
  122. const authorDiv = document.createElement('div');
  123. authorDiv.innerText = authors[i];
  124. authorListItem.appendChild(authorDiv);
  125. }
  126.  
  127. const volumeDiv = document.createElement('div');
  128. volumeDiv.innerText = getVolume();
  129. volumeListItem.appendChild(volumeDiv);
  130.  
  131. const pageCountDiv = document.createElement('div');
  132. pageCountDiv.innerText = getPageCount();
  133. pageCountListItem.appendChild(pageCountDiv);
  134. }
  135.  
  136. function initializeDownloadName() {
  137. const downloadNameField = document.querySelector('#download-name-field');
  138. downloadNameField.placeholder = convertToValidFileName(getTitle().concat(' ', getVolume()));
  139. }
  140.  
  141. function initializeSidebar() {
  142. initializeComicInfo();
  143. initializeDownloadName();
  144.  
  145. const speedbinb = SpeedBinb.getInstance('content');
  146. speedbinb.removeEventListener('onPageRendered', initializeSidebar); // Remove event listener to prevent info from being added again
  147. }
  148.  
  149. function validateDownloadNameField() {
  150. const downloadNameField = document.querySelector('#download-name-field');
  151. if (isValidFileName(downloadNameField.value)) {
  152. downloadNameField.setCustomValidity('');
  153. } else {
  154. downloadNameField.setCustomValidity('Special characters /\?%*:|"<>] are not allowed');
  155. }
  156. }
  157.  
  158. function validatePagesField() {
  159. const speedbinb = SpeedBinb.getInstance('content');
  160. const totalPages = speedbinb.total - 1;
  161.  
  162. const pagesField = document.querySelector('#pages-field');
  163. const fieldValue = pagesField.value;
  164. const pagesList = fieldValue.split(',');
  165.  
  166. const isValidPage = num => !isNaN(num) && (parseInt(num) > 0) && (parseInt(num) <= totalPages);
  167. const isValidSingle = range => (range.length === 1) && isValidPage(range[0]);
  168. const isValidRange = range => (range.length === 2) && range.every(isValidPage) && (parseInt(range[0]) < parseInt(range[1]));
  169.  
  170. for (const x of pagesList) {
  171. let pages = x.split('-');
  172. if (!isValidSingle(pages) && !isValidRange(pages)) {
  173. pagesField.setCustomValidity('Invalid page range, use eg. 1-5, 8, 11-13 or leave blank');
  174. return;
  175. }
  176. }
  177. pagesField.setCustomValidity('');
  178. }
  179.  
  180. function preventDefaultValidation() {
  181. 'use strict'
  182.  
  183. // Fetch all the forms we want to apply custom Bootstrap validation styles to
  184. var forms = document.querySelectorAll('.needs-validation');
  185.  
  186. // Loop over them and prevent submission
  187. Array.prototype.slice.call(forms)
  188. .forEach(function (form) {
  189. form.addEventListener('submit', function (event) {
  190. if (!form.checkValidity()) {
  191. event.preventDefault();
  192. event.stopPropagation();
  193. } else {
  194. submitForm(event);
  195. }
  196. form.classList.add('was-validated');
  197. }, false)
  198. });
  199. }
  200.  
  201. function submitForm(e) {
  202. e.preventDefault();
  203. const downloadNameField = document.querySelector('#download-name-field');
  204. if (!downloadNameField.value) {
  205. downloadNameField.value = downloadNameField.placeholder;
  206. }
  207. const form = document.querySelector('#download-sidebar form');
  208. const elements = form.elements;
  209. for (let i = 0; i < elements.length; i++) {
  210. elements[i].readOnly = true;
  211. }
  212. const downloadButton = document.querySelector('#download-button');
  213. downloadButton.disabled = true;
  214. downloadComic(getPageIntervals());
  215. }
  216.  
  217. function setUpDownloadForm() {
  218. const pagesField = document.querySelector('#pages-field');
  219. pagesField.addEventListener('change', validatePagesField);
  220.  
  221. const downloadNameField = document.querySelector('#download-name-field');
  222. downloadNameField.addEventListener('change', validateDownloadNameField);
  223.  
  224. preventDefaultValidation();
  225. }
  226.  
  227. function addSidebarEventListeners() {
  228. const stopProp = function(e) { e.stopPropagation(); };
  229. const sidebar = document.querySelector('#download-sidebar');
  230. sidebar.addEventListener('shown.bs.offcanvas', function() {
  231. document.addEventListener('keydown', stopProp, true);
  232. document.addEventListener('wheel', stopProp, true);
  233. });
  234. sidebar.addEventListener('hidden.bs.offcanvas', function() {
  235. document.removeEventListener('keydown', stopProp, true);
  236. document.removeEventListener('wheel', stopProp, true);
  237. });
  238. }
  239.  
  240. function getImgCoordinates(img, pageWidth, pageHeight) {
  241. const insetTop = parseFloat(img.parentElement.style.top);
  242. const insetRight = parseFloat(img.parentElement.style.right);
  243. const insetBottom = parseFloat(img.parentElement.style.bottom);
  244. const insetLeft = parseFloat(img.parentElement.style.left);
  245.  
  246. return {
  247. x: (pageHeight * insetLeft) / 100,
  248. y: (pageHeight * insetTop) / 100,
  249. width: pageWidth * ((100 - insetRight - insetLeft) / 100),
  250. height: pageHeight * ((100 - insetTop - insetBottom) / 100),
  251. };
  252. }
  253.  
  254. function getPageBlob(pageNumber, scaled) {
  255. return new Promise(function(resolve, reject) {
  256. const speedbinb = SpeedBinb.getInstance('content');
  257. const pageInfo = speedbinb.Ii.Fn.page;
  258. const orgPageHeight = pageInfo[pageNumber - 1].image.orgheight;
  259. const orgPageWidth = pageInfo[pageNumber - 1].image.orgwidth;
  260.  
  261. const imgs = document.getElementById(`content-p${pageNumber}`).getElementsByTagName('img');
  262.  
  263. const imgsArray = Array.from(imgs);
  264. const pageWidth = scaled ? orgPageWidth : imgsArray[0].naturalWidth;
  265.  
  266. const pageHeight = scaled ? orgPageHeight : Math.floor(orgPageHeight * pageWidth / orgPageWidth);
  267.  
  268. const canvas = document.createElement('canvas');
  269. const ctx = canvas.getContext('2d');
  270. canvas.height = pageHeight;
  271. canvas.width = pageWidth;
  272.  
  273. const topImgCoordinates = getImgCoordinates(imgsArray[0], pageWidth, pageHeight);
  274. const middleImgCoordinates = getImgCoordinates(imgsArray[1], pageWidth, pageHeight);
  275. const bottomImgCoordinates = getImgCoordinates(imgsArray[2], pageWidth, pageHeight);
  276.  
  277. ctx.drawImage(imgs[0], topImgCoordinates.x, topImgCoordinates.y, topImgCoordinates.width, topImgCoordinates.height);
  278. ctx.drawImage(imgs[1], middleImgCoordinates.x, middleImgCoordinates.y, middleImgCoordinates.width, middleImgCoordinates.height);
  279. ctx.drawImage(imgs[2], bottomImgCoordinates.x, bottomImgCoordinates.y, bottomImgCoordinates.width, bottomImgCoordinates.height);
  280.  
  281. canvas.toBlob(blob => { resolve(blob); }, 'image/jpeg', 1.0);
  282. });
  283. }
  284.  
  285. async function sleep(ms) {
  286. return new Promise(resolve => setTimeout(resolve, ms));
  287. }
  288.  
  289. async function waitUntilPageLoaded(pageNumber) {
  290. const speedbinb = SpeedBinb.getInstance('content');
  291. speedbinb.moveTo(pageNumber - 1);
  292. while (!document.getElementById(`content-p${pageNumber}`)) {
  293. await sleep(200);
  294. }
  295. while (!document.getElementById(`content-p${pageNumber}`).getElementsByTagName('img')) {
  296. await sleep(200);
  297. }
  298. while (document.getElementById(`content-p${pageNumber}`).getElementsByTagName('img').length !== 3) {
  299. await sleep(200);
  300. }
  301. const imgs = document.getElementById(`content-p${pageNumber}`).getElementsByTagName('img');
  302. for (let i = 0; i < imgs.length; i++) {
  303. while (!imgs[i].complete) {
  304. await sleep(200);
  305. }
  306. }
  307. return new Promise(function(resolve, reject) {
  308. resolve();
  309. });
  310. }
  311.  
  312. function toggleProgressBar() {
  313. const progress = document.querySelector('#download-sidebar .progress');
  314. const progressBar = document.querySelector('#download-sidebar .progress-bar');
  315.  
  316. if (progress.classList.contains('invisible')) {
  317. progress.classList.remove('invisible');
  318. progress.classList.add('visible');
  319. progressBar.style.width = '0%';
  320. } else if (progress.classList.contains('visible')) {
  321. progress.classList.remove('visible');
  322. progress.classList.add('invisible');
  323. progressBar.style.width = '0%';
  324. }
  325. }
  326.  
  327. function updateProgressBar(percentage) {
  328. const progressBar = document.querySelector('#download-sidebar .progress-bar');
  329. progressBar.style.width = `${percentage}%`;
  330. }
  331.  
  332. async function downloadComic(pageIntervals) {
  333. const stopProp = function(e) { e.preventDefault(); e.stopPropagation(); };
  334. const sidebar = document.querySelector('#download-sidebar');
  335. sidebar.addEventListener('hide.bs.offcanvas', stopProp, true);
  336.  
  337. const zip = new JSZip();
  338. const downloadName = document.querySelector('#download-name-field').value;
  339. const shouldScalePages = document.querySelector('#scale-checkbox').checked;
  340.  
  341. toggleProgressBar();
  342.  
  343. let totalPages = 0;
  344. for (let i = 0; i < pageIntervals.length; i++) {
  345. totalPages += pageIntervals[i][1] - pageIntervals[i][0];
  346. }
  347.  
  348. let downloadedPages = 0;
  349. const speedbinb = SpeedBinb.getInstance('content');
  350.  
  351. for (let i = 0; i < pageIntervals.length; i++) {
  352. const interval = pageIntervals[i], start = 0, end = 1;
  353. for (let nextPage = interval[start]; nextPage <= interval[end]; nextPage++) {
  354. await waitUntilPageLoaded(nextPage);
  355. const pageBlob = await getPageBlob(nextPage, shouldScalePages);
  356. zip.file(`${nextPage}.jpeg`, pageBlob);
  357. downloadedPages++;
  358. updateProgressBar(Math.round((downloadedPages / totalPages) * 100));
  359. }
  360. }
  361.  
  362. zip.generateAsync({ type: 'blob' }, function updateCallback(metadata) {
  363. updateProgressBar(Math.round(metadata.percent));
  364. }).then(function(content) {
  365. const details = {
  366. 'url': URL.createObjectURL(content),
  367. 'name': `${downloadName}.zip`
  368. };
  369. GM_download(details);
  370.  
  371. toggleProgressBar();
  372.  
  373. const form = document.querySelector('#download-sidebar form');
  374. const elements = form.elements;
  375. for (let i = 0; i < elements.length; i++) {
  376. elements[i].readOnly = false;
  377. }
  378.  
  379. const downloadButton = document.querySelector('#download-button');
  380. downloadButton.disabled = false;
  381.  
  382. sidebar.removeEventListener('hide.bs.offcanvas', stopProp, true);
  383. });
  384. }
  385.  
  386. function addDownloadTab() {
  387. const tabAnchor = document.createElement('a');
  388. tabAnchor.id = 'download-tab-anchor';
  389. tabAnchor.setAttribute('data-bs-toggle', 'offcanvas')
  390. tabAnchor.setAttribute('href', '#download-sidebar');
  391. tabAnchor.setAttribute('role', 'button');
  392. tabAnchor.setAttribute('aria-label', 'Open Download Options');
  393.  
  394. const tab = document.createElement('div');
  395. tab.id = 'download-tab';
  396. tab.classList.add('rounded-start');
  397.  
  398. const icon = document.createElement('i');
  399. icon.id = 'download-icon';
  400. icon.classList.add('fas');
  401. icon.classList.add('fa-file-download');
  402.  
  403. tabAnchor.appendChild(tab);
  404. tab.appendChild(icon);
  405. document.body.append(tabAnchor);
  406.  
  407. const tabCss =
  408. `#download-tab {
  409. background-color: var(--bs-orange);
  410. color: white;
  411. position: absolute;
  412. top: 3em;
  413. right: 0;
  414. z-index: 20;
  415. padding: 0.75em;
  416. }
  417. #download-tab:hover {
  418. background-color: #ca6510;
  419. }`;
  420. GM_addStyle(tabCss);
  421. }
  422.  
  423. function addDownloadSidebar() {
  424. const sidebar = document.createElement('div');
  425. sidebar.id = 'download-sidebar';
  426. sidebar.classList.add('offcanvas');
  427. sidebar.classList.add('offcanvas-end');
  428. sidebar.classList.add('rounded-start');
  429. sidebar.setAttribute('tabindex', '-1');
  430. sidebar.setAttribute('aria-labelledby', '#download-sidebar-title');
  431.  
  432. sidebar.innerHTML = `
  433. <div class="offcanvas-header">
  434. <h5 id="download-sidebar-title">Download Options</h5>
  435. <button type="button" class="btn-close text-reset" data-bs-dismiss="offcanvas" aria-label="Close"></button>
  436. </div>
  437. <div class="offcanvas-body">
  438. <div class="alert alert-warning d-flex align-items-center" role="alert">
  439. <i class="fas fa-exclamation-triangle bi flex-shrink-0 me-2" width="24" height="24" role="img" aria-label="Warning"></i>
  440. <div id="warning" style="padding-left: 0.5em">Do not interact with the reader while download is in progress.</div>
  441. </div>
  442. <ul class="list-group mb-3">
  443. <li class="list-group-item" id="comic-title">
  444. <div class="fw-bold">Title</div>
  445. </li>
  446. <li class="list-group-item" id="comic-author">
  447. <div class="fw-bold">Author</div>
  448. </li>
  449. <li class="list-group-item" id="comic-volume">
  450. <div class="fw-bold">Volume</div>
  451. </li>
  452. <li class="list-group-item" id="comic-page-count">
  453. <div class="fw-bold">Page Count</div>
  454. </li>
  455. </ul>
  456. <form id="download-options-form" class="needs-validation" novalidate>
  457. <div class="mb-3">
  458. <label for="download-name-field" class="form-label">Download name</label>
  459. <textarea type="text" id="download-name-field" name="download-name" class="form-control" placeholder="Leave blank for comic name"></textarea>
  460. <div class="invalid-feedback">Special characters /\?%*:|"&lt;&gt;] are not allowed</div>
  461. </div>
  462. <div class="mb-3">
  463. <label for="pages-field" class="form-label">Pages</label>
  464. <input type="text" id="pages-field" name="pages" class="form-control" placeholder="eg. 1-5, 8, 11-13">
  465. <div class="invalid-feedback">Invalid page range, use eg. 1-5, 8, 11-13</div>
  466. </div>
  467. <div class="form-check d-flex align-items-center">
  468. <input class="form-check-input me-2" type="checkbox" value="" id="scale-checkbox">
  469. <label class="form-check-label me-2" for="scale-checkbox">Scale pages that are different sizes</label>
  470. <a class="btn p-0" data-bs-toggle="collapse" href="#scale-checkbox-info" role="button" aria-expanded="false" aria-controls="scaleCheckboxInfo">
  471. <i class="fas fa-info-circle" width="24" height="24" aria-label="Info"></i>
  472. </a>
  473. </div>
  474. <div class="collapse" id="scale-checkbox-info">
  475. <div class="card card-body mt-2">
  476. cmoa may send pages that are a different size than the rest. If you select this option, those pages will be automatically resized. This may affect the image quality.
  477. </div>
  478. </div>
  479. </form>
  480. </div>
  481. <div id="sidebar-footer" class="footer d-flex align-content-center position-absolute bottom-0 start-0 p-3">
  482. <button type="submit" form="download-options-form" id="download-button" class="btn btn-primary">Download</button>
  483. <div class="progress ms-3 invisible" style="flex-grow: 1">
  484. <div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: 0%;" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100"></div>
  485. </div>
  486. </div>`;
  487. document.body.append(sidebar);
  488. setUpDownloadForm();
  489. addSidebarEventListeners();
  490.  
  491. const sidebarCss =
  492. `#download-sidebar {
  493. user-select: text;
  494. -moz-user-select: text;
  495. -webkit-user-select: text;
  496. -ms-user-select: text;
  497. }
  498. #download-sidebar .offcanvas-header {
  499. border-bottom: 1px solid var(--bs-gray-300);
  500. }
  501. #download-sidebar h5 {
  502. margin-bottom: 0;
  503. }
  504. #sidebar-footer {
  505. border-top: 1px solid var(--bs-gray-300);
  506. width: 100%;
  507. }
  508. .offcanvas-body {
  509. margin-bottom: 71px;
  510. }`;
  511. GM_addStyle(sidebarCss);
  512. }
  513.  
  514. window.addEventListener('load', () => {
  515. GM_addStyle(GM_getResourceText("bt"));
  516. addDownloadSidebar();
  517. addDownloadTab();
  518. const speedbinb = SpeedBinb.getInstance('content');
  519. speedbinb.addEventListener('onPageRendered', initializeSidebar);
  520. });