Greasy Fork is available in English.

cmoa.jp Downloader

Downloads comic pages from cmoa.jp

  1. // ==UserScript==
  2. // @name cmoa.jp Downloader
  3. // @version 1.1.5
  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;
  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 totalPages = getPageCount();
  160.  
  161. const pagesField = document.querySelector('#pages-field');
  162. const fieldValue = pagesField.value;
  163. const pagesList = fieldValue.split(',');
  164.  
  165. const isValidPage = num => !isNaN(num) && (parseInt(num) > 0) && (parseInt(num) <= totalPages);
  166. const isValidSingle = range => (range.length === 1) && isValidPage(range[0]);
  167. const isValidRange = range => (range.length === 2) && range.every(isValidPage) && (parseInt(range[0]) < parseInt(range[1]));
  168.  
  169. for (const x of pagesList) {
  170. let pages = x.split('-');
  171. if (!isValidSingle(pages) && !isValidRange(pages)) {
  172. pagesField.setCustomValidity('Invalid page range, use eg. 1-5, 8, 11-13 or leave blank');
  173. return;
  174. }
  175. }
  176. pagesField.setCustomValidity('');
  177. }
  178.  
  179. function preventDefaultValidation() {
  180. 'use strict'
  181.  
  182. // Fetch all the forms we want to apply custom Bootstrap validation styles to
  183. var forms = document.querySelectorAll('.needs-validation');
  184.  
  185. // Loop over them and prevent submission
  186. Array.prototype.slice.call(forms)
  187. .forEach(function (form) {
  188. form.addEventListener('submit', function (event) {
  189. if (!form.checkValidity()) {
  190. event.preventDefault();
  191. event.stopPropagation();
  192. } else {
  193. submitForm(event);
  194. }
  195. form.classList.add('was-validated');
  196. }, false)
  197. });
  198. }
  199.  
  200. function submitForm(e) {
  201. e.preventDefault();
  202. const downloadNameField = document.querySelector('#download-name-field');
  203. if (!downloadNameField.value) {
  204. downloadNameField.value = downloadNameField.placeholder;
  205. }
  206. const form = document.querySelector('#download-sidebar form');
  207. const elements = form.elements;
  208. for (let i = 0; i < elements.length; i++) {
  209. elements[i].readOnly = true;
  210. }
  211. const downloadButton = document.querySelector('#download-button');
  212. downloadButton.disabled = true;
  213. downloadComic(getPageIntervals());
  214. }
  215.  
  216. function setUpDownloadForm() {
  217. const pagesField = document.querySelector('#pages-field');
  218. pagesField.addEventListener('change', validatePagesField);
  219.  
  220. const downloadNameField = document.querySelector('#download-name-field');
  221. downloadNameField.addEventListener('change', validateDownloadNameField);
  222.  
  223. preventDefaultValidation();
  224. }
  225.  
  226. function addSidebarEventListeners() {
  227. const stopProp = function(e) { e.stopPropagation(); };
  228. const sidebar = document.querySelector('#download-sidebar');
  229. sidebar.addEventListener('shown.bs.offcanvas', function() {
  230. document.addEventListener('keydown', stopProp, true);
  231. document.addEventListener('wheel', stopProp, true);
  232. });
  233. sidebar.addEventListener('hidden.bs.offcanvas', function() {
  234. document.removeEventListener('keydown', stopProp, true);
  235. document.removeEventListener('wheel', stopProp, true);
  236. });
  237. }
  238.  
  239. function getImgCoordinates(img, pageWidth, pageHeight) {
  240. const insetTop = parseFloat(img.parentElement.style.top);
  241. const insetRight = parseFloat(img.parentElement.style.right);
  242. const insetBottom = parseFloat(img.parentElement.style.bottom);
  243. const insetLeft = parseFloat(img.parentElement.style.left);
  244.  
  245. return {
  246. x: (pageHeight * insetLeft) / 100,
  247. y: (pageHeight * insetTop) / 100,
  248. width: pageWidth * ((100 - insetRight - insetLeft) / 100),
  249. height: pageHeight * ((100 - insetTop - insetBottom) / 100),
  250. };
  251. }
  252.  
  253. function getPageBlob(pageNumber, scaled) {
  254. return new Promise(function(resolve, reject) {
  255. const speedbinb = SpeedBinb.getInstance('content');
  256. const pageInfo = speedbinb.Ii.Hn.page;
  257. const orgPageHeight = pageInfo[pageNumber - 1].image.orgheight;
  258. const orgPageWidth = pageInfo[pageNumber - 1].image.orgwidth;
  259.  
  260. const imgs = document.getElementById(`content-p${pageNumber}`).getElementsByTagName('img');
  261.  
  262. const imgsArray = Array.from(imgs);
  263. const pageWidth = scaled ? orgPageWidth : imgsArray[0].naturalWidth;
  264.  
  265. const pageHeight = scaled ? orgPageHeight : Math.floor(orgPageHeight * pageWidth / orgPageWidth);
  266.  
  267. const canvas = document.createElement('canvas');
  268. const ctx = canvas.getContext('2d');
  269. canvas.height = pageHeight;
  270. canvas.width = pageWidth;
  271.  
  272. const topImgCoordinates = getImgCoordinates(imgsArray[0], pageWidth, pageHeight);
  273. const middleImgCoordinates = getImgCoordinates(imgsArray[1], pageWidth, pageHeight);
  274. const bottomImgCoordinates = getImgCoordinates(imgsArray[2], pageWidth, pageHeight);
  275.  
  276. ctx.drawImage(imgs[0], topImgCoordinates.x, topImgCoordinates.y, topImgCoordinates.width, topImgCoordinates.height);
  277. ctx.drawImage(imgs[1], middleImgCoordinates.x, middleImgCoordinates.y, middleImgCoordinates.width, middleImgCoordinates.height);
  278. ctx.drawImage(imgs[2], bottomImgCoordinates.x, bottomImgCoordinates.y, bottomImgCoordinates.width, bottomImgCoordinates.height);
  279.  
  280. canvas.toBlob(blob => { resolve(blob); }, 'image/jpeg', 1.0);
  281. });
  282. }
  283.  
  284. async function sleep(ms) {
  285. return new Promise(resolve => setTimeout(resolve, ms));
  286. }
  287.  
  288. async function waitUntilPageLoaded(pageNumber) {
  289. const speedbinb = SpeedBinb.getInstance('content');
  290. speedbinb.moveTo(pageNumber - 1);
  291. while (!document.getElementById(`content-p${pageNumber}`)) {
  292. await sleep(200);
  293. }
  294. while (!document.getElementById(`content-p${pageNumber}`).getElementsByTagName('img')) {
  295. await sleep(200);
  296. }
  297. while (document.getElementById(`content-p${pageNumber}`).getElementsByTagName('img').length !== 3) {
  298. await sleep(200);
  299. }
  300. const imgs = document.getElementById(`content-p${pageNumber}`).getElementsByTagName('img');
  301. for (let i = 0; i < imgs.length; i++) {
  302. while (!imgs[i].complete) {
  303. await sleep(200);
  304. }
  305. }
  306. return new Promise(function(resolve, reject) {
  307. resolve();
  308. });
  309. }
  310.  
  311. function toggleProgressBar() {
  312. const progress = document.querySelector('#download-sidebar .progress');
  313. const progressBar = document.querySelector('#download-sidebar .progress-bar');
  314.  
  315. if (progress.classList.contains('invisible')) {
  316. progress.classList.remove('invisible');
  317. progress.classList.add('visible');
  318. progressBar.style.width = '0%';
  319. } else if (progress.classList.contains('visible')) {
  320. progress.classList.remove('visible');
  321. progress.classList.add('invisible');
  322. progressBar.style.width = '0%';
  323. }
  324. }
  325.  
  326. function updateProgressBar(percentage) {
  327. const progressBar = document.querySelector('#download-sidebar .progress-bar');
  328. progressBar.style.width = `${percentage}%`;
  329. }
  330.  
  331. async function downloadComic(pageIntervals) {
  332. const stopProp = function(e) { e.preventDefault(); e.stopPropagation(); };
  333. const sidebar = document.querySelector('#download-sidebar');
  334. sidebar.addEventListener('hide.bs.offcanvas', stopProp, true);
  335.  
  336. const zip = new JSZip();
  337. const downloadName = document.querySelector('#download-name-field').value;
  338. const shouldScalePages = document.querySelector('#scale-checkbox').checked;
  339.  
  340. toggleProgressBar();
  341.  
  342. let totalPages = 0;
  343. for (let i = 0; i < pageIntervals.length; i++) {
  344. totalPages += pageIntervals[i][1] - pageIntervals[i][0];
  345. }
  346.  
  347. let downloadedPages = 0;
  348. const speedbinb = SpeedBinb.getInstance('content');
  349.  
  350. for (let i = 0; i < pageIntervals.length; i++) {
  351. const interval = pageIntervals[i], start = 0, end = 1;
  352. for (let nextPage = interval[start]; nextPage <= interval[end]; nextPage++) {
  353. await waitUntilPageLoaded(nextPage);
  354. const pageBlob = await getPageBlob(nextPage, shouldScalePages);
  355. zip.file(`${nextPage}.jpeg`, pageBlob);
  356. downloadedPages++;
  357. updateProgressBar(Math.round((downloadedPages / totalPages) * 100));
  358. }
  359. }
  360.  
  361. zip.generateAsync({ type: 'blob' }, function updateCallback(metadata) {
  362. updateProgressBar(Math.round(metadata.percent));
  363. }).then(function(content) {
  364. const details = {
  365. 'url': URL.createObjectURL(content),
  366. 'name': `${downloadName}.zip`
  367. };
  368. GM_download(details);
  369.  
  370. toggleProgressBar();
  371.  
  372. const form = document.querySelector('#download-sidebar form');
  373. const elements = form.elements;
  374. for (let i = 0; i < elements.length; i++) {
  375. elements[i].readOnly = false;
  376. }
  377.  
  378. const downloadButton = document.querySelector('#download-button');
  379. downloadButton.disabled = false;
  380.  
  381. sidebar.removeEventListener('hide.bs.offcanvas', stopProp, true);
  382. });
  383. }
  384.  
  385. function addDownloadTab() {
  386. const tabAnchor = document.createElement('a');
  387. tabAnchor.id = 'download-tab-anchor';
  388. tabAnchor.setAttribute('data-bs-toggle', 'offcanvas')
  389. tabAnchor.setAttribute('href', '#download-sidebar');
  390. tabAnchor.setAttribute('role', 'button');
  391. tabAnchor.setAttribute('aria-label', 'Open Download Options');
  392.  
  393. const tab = document.createElement('div');
  394. tab.id = 'download-tab';
  395. tab.classList.add('rounded-start');
  396.  
  397. const icon = document.createElement('i');
  398. icon.id = 'download-icon';
  399. icon.classList.add('fas');
  400. icon.classList.add('fa-file-download');
  401.  
  402. tabAnchor.appendChild(tab);
  403. tab.appendChild(icon);
  404. document.body.append(tabAnchor);
  405.  
  406. const tabCss =
  407. `#download-tab {
  408. background-color: var(--bs-orange);
  409. color: white;
  410. position: absolute;
  411. top: 3em;
  412. right: 0;
  413. z-index: 20;
  414. padding: 0.75em;
  415. }
  416. #download-tab:hover {
  417. background-color: #ca6510;
  418. }`;
  419. GM_addStyle(tabCss);
  420. }
  421.  
  422. function addDownloadSidebar() {
  423. const sidebar = document.createElement('div');
  424. sidebar.id = 'download-sidebar';
  425. sidebar.classList.add('offcanvas');
  426. sidebar.classList.add('offcanvas-end');
  427. sidebar.classList.add('rounded-start');
  428. sidebar.setAttribute('tabindex', '-1');
  429. sidebar.setAttribute('aria-labelledby', '#download-sidebar-title');
  430.  
  431. sidebar.innerHTML = `
  432. <div class="offcanvas-header">
  433. <h5 id="download-sidebar-title">Download Options</h5>
  434. <button type="button" class="btn-close text-reset" data-bs-dismiss="offcanvas" aria-label="Close"></button>
  435. </div>
  436. <div class="offcanvas-body">
  437. <div class="alert alert-warning d-flex align-items-center" role="alert">
  438. <i class="fas fa-exclamation-triangle bi flex-shrink-0 me-2" width="24" height="24" role="img" aria-label="Warning"></i>
  439. <div id="warning" style="padding-left: 0.5em">Do not interact with the reader while download is in progress.</div>
  440. </div>
  441. <ul class="list-group mb-3">
  442. <li class="list-group-item" id="comic-title">
  443. <div class="fw-bold">Title</div>
  444. </li>
  445. <li class="list-group-item" id="comic-author">
  446. <div class="fw-bold">Author</div>
  447. </li>
  448. <li class="list-group-item" id="comic-volume">
  449. <div class="fw-bold">Volume</div>
  450. </li>
  451. <li class="list-group-item" id="comic-page-count">
  452. <div class="fw-bold">Page Count</div>
  453. </li>
  454. </ul>
  455. <form id="download-options-form" class="needs-validation" novalidate>
  456. <div class="mb-3">
  457. <label for="download-name-field" class="form-label">Download name</label>
  458. <textarea type="text" id="download-name-field" name="download-name" class="form-control" placeholder="Leave blank for comic name"></textarea>
  459. <div class="invalid-feedback">Special characters /\?%*:|"&lt;&gt;] are not allowed</div>
  460. </div>
  461. <div class="mb-3">
  462. <label for="pages-field" class="form-label">Pages</label>
  463. <input type="text" id="pages-field" name="pages" class="form-control" placeholder="eg. 1-5, 8, 11-13">
  464. <div class="invalid-feedback">Invalid page range, use eg. 1-5, 8, 11-13</div>
  465. </div>
  466. <div class="form-check d-flex align-items-center">
  467. <input class="form-check-input me-2" type="checkbox" value="" id="scale-checkbox">
  468. <label class="form-check-label me-2" for="scale-checkbox">Scale pages that are different sizes</label>
  469. <a class="btn p-0" data-bs-toggle="collapse" href="#scale-checkbox-info" role="button" aria-expanded="false" aria-controls="scaleCheckboxInfo">
  470. <i class="fas fa-info-circle" width="24" height="24" aria-label="Info"></i>
  471. </a>
  472. </div>
  473. <div class="collapse" id="scale-checkbox-info">
  474. <div class="card card-body mt-2">
  475. 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.
  476. </div>
  477. </div>
  478. </form>
  479. </div>
  480. <div id="sidebar-footer" class="footer d-flex align-content-center position-absolute bottom-0 start-0 p-3">
  481. <button type="submit" form="download-options-form" id="download-button" class="btn btn-primary">Download</button>
  482. <div class="progress ms-3 invisible" style="flex-grow: 1">
  483. <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>
  484. </div>
  485. </div>`;
  486. document.body.append(sidebar);
  487. setUpDownloadForm();
  488. addSidebarEventListeners();
  489.  
  490. const sidebarCss =
  491. `#download-sidebar {
  492. user-select: text;
  493. -moz-user-select: text;
  494. -webkit-user-select: text;
  495. -ms-user-select: text;
  496. }
  497. #download-sidebar .offcanvas-header {
  498. border-bottom: 1px solid var(--bs-gray-300);
  499. }
  500. #download-sidebar h5 {
  501. margin-bottom: 0;
  502. }
  503. #sidebar-footer {
  504. border-top: 1px solid var(--bs-gray-300);
  505. width: 100%;
  506. }
  507. .offcanvas-body {
  508. margin-bottom: 71px;
  509. }`;
  510. GM_addStyle(sidebarCss);
  511. }
  512.  
  513. window.addEventListener('load', () => {
  514. GM_addStyle(GM_getResourceText("bt"));
  515. addDownloadSidebar();
  516. addDownloadTab();
  517. const speedbinb = SpeedBinb.getInstance('content');
  518. speedbinb.addEventListener('onPageRendered', initializeSidebar);
  519. });