- // ==UserScript==
- // @name cmoa.jp Downloader
- // @version 1.1.5
- // @description Downloads comic pages from cmoa.jp
- // @author tnt_kitty
- // @match *://*.cmoa.jp/bib/speedreader/*
- // @icon https://www.cmoa.jp/favicon.ico
- // @grant GM_addStyle
- // @grant GM_getResourceText
- // @grant GM_download
- // @resource bt https://cdn.jsdelivr.net/npm/bootstrap@5.1.0/dist/css/bootstrap.min.css
- // @require https://cdn.jsdelivr.net/npm/bootstrap@5.1.0/dist/js/bootstrap.bundle.min.js
- // @require https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/js/all.min.js
- // @require https://cdnjs.cloudflare.com/ajax/libs/jszip/3.7.1/jszip.min.js
- // @license GPL-3.0-only
- // @namespace https://greasyfork.org/users/914763
- // ==/UserScript==
-
- function convertToValidFileName(string) {
- return string.replace(/[/\\?%*:|"<>]/g, '-');
- }
-
- function isValidFileName(string) {
- const regex = new RegExp('[/\\?%*:|"<>]', 'g');
- return !regex.test(string);
- }
-
- function getTitle() {
- try {
- return __sreaderFunc__.contentInfo.items[0].Title;
- } catch (error) {
- return null;
- }
- }
-
- function getAuthors() {
- try {
- return __sreaderFunc__.contentInfo.items[0].Authors[0].Name.split('/'); // Returns array of authors, ex. ['Author1', 'Author2']
- } catch (error) {
- return null;
- }
- }
-
- function getVolume() {
- try {
- return parseInt(__sreaderFunc__.contentInfo.items[0].ShopURL.split('/').at(-2));
- } catch (error) {
- return null;
- }
- }
-
- function getPageCount() {
- try {
- return SpeedBinb.getInstance('content').total;
- } catch (error) {
- return null;
- }
- }
-
- function getPageIntervals() {
- const isEmpty = string => !string.trim().length;
-
- const pagesField = document.querySelector('#pages-field');
- let fieldValue = pagesField.value;
-
- if (isEmpty(fieldValue)) {
- const speedbinb = SpeedBinb.getInstance('content');
- const totalPages = getPageCount();
- return [[1, totalPages]];
- }
-
- const pagesList = fieldValue.split(',');
- let pageIntervals = [];
-
- for (const x of pagesList) {
- let pages = x.split('-');
- if (pages.length === 1) {
- pageIntervals.push([parseInt(pages[0]), parseInt(pages[0])]);
- } else if (pages.length === 2) {
- pageIntervals.push([parseInt(pages[0]), parseInt(pages[1])]);
- }
- }
-
- if (pageIntervals.length <= 1) {
- return pageIntervals;
- }
-
- pageIntervals.sort((a, b) => b[0] - a[0]);
-
- const start = 0, end = 1;
- let mergedIntervals = [];
- let newInterval = pageIntervals[0];
- for (let i = 1; i < pageIntervals.length; i++) {
- let currentInterval = pageIntervals[i];
- if (currentInterval[start] <= newInterval[end]) {
- newInterval[end] = Math.max(newInterval[end], currentInterval[end]);
- } else {
- mergedIntervals.push(newInterval);
- newInterval = currentInterval;
- }
- }
- mergedIntervals.push(newInterval);
- return mergedIntervals;
- }
-
- function initializeComicInfo() {
- const titleListItem = document.querySelector('#comic-title');
- const authorListItem = document.querySelector('#comic-author');
- const volumeListItem = document.querySelector('#comic-volume');
- const pageCountListItem = document.querySelector('#comic-page-count');
-
- const titleDiv = document.createElement('div');
- titleDiv.innerText = getTitle();
- titleListItem.appendChild(titleDiv);
-
- const authors = getAuthors();
- if (authors.length > 1) {
- const authorLabel = authorListItem.querySelector('.fw-bold');
- authorLabel.innerText = 'Authors';
- }
- for (let i = 0; i < authors.length; i++) {
- const authorDiv = document.createElement('div');
- authorDiv.innerText = authors[i];
- authorListItem.appendChild(authorDiv);
- }
-
- const volumeDiv = document.createElement('div');
- volumeDiv.innerText = getVolume();
- volumeListItem.appendChild(volumeDiv);
-
- const pageCountDiv = document.createElement('div');
- pageCountDiv.innerText = getPageCount();
- pageCountListItem.appendChild(pageCountDiv);
- }
-
- function initializeDownloadName() {
- const downloadNameField = document.querySelector('#download-name-field');
- downloadNameField.placeholder = convertToValidFileName(getTitle().concat(' ', getVolume()));
- }
-
- function initializeSidebar() {
- initializeComicInfo();
- initializeDownloadName();
-
- const speedbinb = SpeedBinb.getInstance('content');
- speedbinb.removeEventListener('onPageRendered', initializeSidebar); // Remove event listener to prevent info from being added again
- }
-
- function validateDownloadNameField() {
- const downloadNameField = document.querySelector('#download-name-field');
- if (isValidFileName(downloadNameField.value)) {
- downloadNameField.setCustomValidity('');
- } else {
- downloadNameField.setCustomValidity('Special characters /\?%*:|"<>] are not allowed');
- }
- }
-
- function validatePagesField() {
- const totalPages = getPageCount();
-
- const pagesField = document.querySelector('#pages-field');
- const fieldValue = pagesField.value;
- const pagesList = fieldValue.split(',');
-
- const isValidPage = num => !isNaN(num) && (parseInt(num) > 0) && (parseInt(num) <= totalPages);
- const isValidSingle = range => (range.length === 1) && isValidPage(range[0]);
- const isValidRange = range => (range.length === 2) && range.every(isValidPage) && (parseInt(range[0]) < parseInt(range[1]));
-
- for (const x of pagesList) {
- let pages = x.split('-');
- if (!isValidSingle(pages) && !isValidRange(pages)) {
- pagesField.setCustomValidity('Invalid page range, use eg. 1-5, 8, 11-13 or leave blank');
- return;
- }
- }
- pagesField.setCustomValidity('');
- }
-
- function preventDefaultValidation() {
- 'use strict'
-
- // Fetch all the forms we want to apply custom Bootstrap validation styles to
- var forms = document.querySelectorAll('.needs-validation');
-
- // Loop over them and prevent submission
- Array.prototype.slice.call(forms)
- .forEach(function (form) {
- form.addEventListener('submit', function (event) {
- if (!form.checkValidity()) {
- event.preventDefault();
- event.stopPropagation();
- } else {
- submitForm(event);
- }
- form.classList.add('was-validated');
- }, false)
- });
- }
-
- function submitForm(e) {
- e.preventDefault();
- const downloadNameField = document.querySelector('#download-name-field');
- if (!downloadNameField.value) {
- downloadNameField.value = downloadNameField.placeholder;
- }
- const form = document.querySelector('#download-sidebar form');
- const elements = form.elements;
- for (let i = 0; i < elements.length; i++) {
- elements[i].readOnly = true;
- }
- const downloadButton = document.querySelector('#download-button');
- downloadButton.disabled = true;
- downloadComic(getPageIntervals());
- }
-
- function setUpDownloadForm() {
- const pagesField = document.querySelector('#pages-field');
- pagesField.addEventListener('change', validatePagesField);
-
- const downloadNameField = document.querySelector('#download-name-field');
- downloadNameField.addEventListener('change', validateDownloadNameField);
-
- preventDefaultValidation();
- }
-
- function addSidebarEventListeners() {
- const stopProp = function(e) { e.stopPropagation(); };
- const sidebar = document.querySelector('#download-sidebar');
- sidebar.addEventListener('shown.bs.offcanvas', function() {
- document.addEventListener('keydown', stopProp, true);
- document.addEventListener('wheel', stopProp, true);
- });
- sidebar.addEventListener('hidden.bs.offcanvas', function() {
- document.removeEventListener('keydown', stopProp, true);
- document.removeEventListener('wheel', stopProp, true);
- });
- }
-
- function getImgCoordinates(img, pageWidth, pageHeight) {
- const insetTop = parseFloat(img.parentElement.style.top);
- const insetRight = parseFloat(img.parentElement.style.right);
- const insetBottom = parseFloat(img.parentElement.style.bottom);
- const insetLeft = parseFloat(img.parentElement.style.left);
-
- return {
- x: (pageHeight * insetLeft) / 100,
- y: (pageHeight * insetTop) / 100,
- width: pageWidth * ((100 - insetRight - insetLeft) / 100),
- height: pageHeight * ((100 - insetTop - insetBottom) / 100),
- };
- }
-
- function getPageBlob(pageNumber, scaled) {
- return new Promise(function(resolve, reject) {
- const speedbinb = SpeedBinb.getInstance('content');
- const pageInfo = speedbinb.Ii.Hn.page;
- const orgPageHeight = pageInfo[pageNumber - 1].image.orgheight;
- const orgPageWidth = pageInfo[pageNumber - 1].image.orgwidth;
-
- const imgs = document.getElementById(`content-p${pageNumber}`).getElementsByTagName('img');
-
- const imgsArray = Array.from(imgs);
- const pageWidth = scaled ? orgPageWidth : imgsArray[0].naturalWidth;
-
- const pageHeight = scaled ? orgPageHeight : Math.floor(orgPageHeight * pageWidth / orgPageWidth);
-
- const canvas = document.createElement('canvas');
- const ctx = canvas.getContext('2d');
- canvas.height = pageHeight;
- canvas.width = pageWidth;
-
- const topImgCoordinates = getImgCoordinates(imgsArray[0], pageWidth, pageHeight);
- const middleImgCoordinates = getImgCoordinates(imgsArray[1], pageWidth, pageHeight);
- const bottomImgCoordinates = getImgCoordinates(imgsArray[2], pageWidth, pageHeight);
-
- ctx.drawImage(imgs[0], topImgCoordinates.x, topImgCoordinates.y, topImgCoordinates.width, topImgCoordinates.height);
- ctx.drawImage(imgs[1], middleImgCoordinates.x, middleImgCoordinates.y, middleImgCoordinates.width, middleImgCoordinates.height);
- ctx.drawImage(imgs[2], bottomImgCoordinates.x, bottomImgCoordinates.y, bottomImgCoordinates.width, bottomImgCoordinates.height);
-
- canvas.toBlob(blob => { resolve(blob); }, 'image/jpeg', 1.0);
- });
- }
-
- async function sleep(ms) {
- return new Promise(resolve => setTimeout(resolve, ms));
- }
-
- async function waitUntilPageLoaded(pageNumber) {
- const speedbinb = SpeedBinb.getInstance('content');
- speedbinb.moveTo(pageNumber - 1);
- while (!document.getElementById(`content-p${pageNumber}`)) {
- await sleep(200);
- }
- while (!document.getElementById(`content-p${pageNumber}`).getElementsByTagName('img')) {
- await sleep(200);
- }
- while (document.getElementById(`content-p${pageNumber}`).getElementsByTagName('img').length !== 3) {
- await sleep(200);
- }
- const imgs = document.getElementById(`content-p${pageNumber}`).getElementsByTagName('img');
- for (let i = 0; i < imgs.length; i++) {
- while (!imgs[i].complete) {
- await sleep(200);
- }
- }
- return new Promise(function(resolve, reject) {
- resolve();
- });
- }
-
- function toggleProgressBar() {
- const progress = document.querySelector('#download-sidebar .progress');
- const progressBar = document.querySelector('#download-sidebar .progress-bar');
-
- if (progress.classList.contains('invisible')) {
- progress.classList.remove('invisible');
- progress.classList.add('visible');
- progressBar.style.width = '0%';
- } else if (progress.classList.contains('visible')) {
- progress.classList.remove('visible');
- progress.classList.add('invisible');
- progressBar.style.width = '0%';
- }
- }
-
- function updateProgressBar(percentage) {
- const progressBar = document.querySelector('#download-sidebar .progress-bar');
- progressBar.style.width = `${percentage}%`;
- }
-
- async function downloadComic(pageIntervals) {
- const stopProp = function(e) { e.preventDefault(); e.stopPropagation(); };
- const sidebar = document.querySelector('#download-sidebar');
- sidebar.addEventListener('hide.bs.offcanvas', stopProp, true);
-
- const zip = new JSZip();
- const downloadName = document.querySelector('#download-name-field').value;
- const shouldScalePages = document.querySelector('#scale-checkbox').checked;
-
- toggleProgressBar();
-
- let totalPages = 0;
- for (let i = 0; i < pageIntervals.length; i++) {
- totalPages += pageIntervals[i][1] - pageIntervals[i][0];
- }
-
- let downloadedPages = 0;
- const speedbinb = SpeedBinb.getInstance('content');
-
- for (let i = 0; i < pageIntervals.length; i++) {
- const interval = pageIntervals[i], start = 0, end = 1;
- for (let nextPage = interval[start]; nextPage <= interval[end]; nextPage++) {
- await waitUntilPageLoaded(nextPage);
- const pageBlob = await getPageBlob(nextPage, shouldScalePages);
- zip.file(`${nextPage}.jpeg`, pageBlob);
- downloadedPages++;
- updateProgressBar(Math.round((downloadedPages / totalPages) * 100));
- }
- }
-
- zip.generateAsync({ type: 'blob' }, function updateCallback(metadata) {
- updateProgressBar(Math.round(metadata.percent));
- }).then(function(content) {
- const details = {
- 'url': URL.createObjectURL(content),
- 'name': `${downloadName}.zip`
- };
- GM_download(details);
-
- toggleProgressBar();
-
- const form = document.querySelector('#download-sidebar form');
- const elements = form.elements;
- for (let i = 0; i < elements.length; i++) {
- elements[i].readOnly = false;
- }
-
- const downloadButton = document.querySelector('#download-button');
- downloadButton.disabled = false;
-
- sidebar.removeEventListener('hide.bs.offcanvas', stopProp, true);
- });
- }
-
- function addDownloadTab() {
- const tabAnchor = document.createElement('a');
- tabAnchor.id = 'download-tab-anchor';
- tabAnchor.setAttribute('data-bs-toggle', 'offcanvas')
- tabAnchor.setAttribute('href', '#download-sidebar');
- tabAnchor.setAttribute('role', 'button');
- tabAnchor.setAttribute('aria-label', 'Open Download Options');
-
- const tab = document.createElement('div');
- tab.id = 'download-tab';
- tab.classList.add('rounded-start');
-
- const icon = document.createElement('i');
- icon.id = 'download-icon';
- icon.classList.add('fas');
- icon.classList.add('fa-file-download');
-
- tabAnchor.appendChild(tab);
- tab.appendChild(icon);
- document.body.append(tabAnchor);
-
- const tabCss =
- `#download-tab {
- background-color: var(--bs-orange);
- color: white;
- position: absolute;
- top: 3em;
- right: 0;
- z-index: 20;
- padding: 0.75em;
- }
- #download-tab:hover {
- background-color: #ca6510;
- }`;
- GM_addStyle(tabCss);
- }
-
- function addDownloadSidebar() {
- const sidebar = document.createElement('div');
- sidebar.id = 'download-sidebar';
- sidebar.classList.add('offcanvas');
- sidebar.classList.add('offcanvas-end');
- sidebar.classList.add('rounded-start');
- sidebar.setAttribute('tabindex', '-1');
- sidebar.setAttribute('aria-labelledby', '#download-sidebar-title');
-
- sidebar.innerHTML = `
- <div class="offcanvas-header">
- <h5 id="download-sidebar-title">Download Options</h5>
- <button type="button" class="btn-close text-reset" data-bs-dismiss="offcanvas" aria-label="Close"></button>
- </div>
- <div class="offcanvas-body">
- <div class="alert alert-warning d-flex align-items-center" role="alert">
- <i class="fas fa-exclamation-triangle bi flex-shrink-0 me-2" width="24" height="24" role="img" aria-label="Warning"></i>
- <div id="warning" style="padding-left: 0.5em">Do not interact with the reader while download is in progress.</div>
- </div>
- <ul class="list-group mb-3">
- <li class="list-group-item" id="comic-title">
- <div class="fw-bold">Title</div>
- </li>
- <li class="list-group-item" id="comic-author">
- <div class="fw-bold">Author</div>
- </li>
- <li class="list-group-item" id="comic-volume">
- <div class="fw-bold">Volume</div>
- </li>
- <li class="list-group-item" id="comic-page-count">
- <div class="fw-bold">Page Count</div>
- </li>
- </ul>
- <form id="download-options-form" class="needs-validation" novalidate>
- <div class="mb-3">
- <label for="download-name-field" class="form-label">Download name</label>
- <textarea type="text" id="download-name-field" name="download-name" class="form-control" placeholder="Leave blank for comic name"></textarea>
- <div class="invalid-feedback">Special characters /\?%*:|"<>] are not allowed</div>
- </div>
- <div class="mb-3">
- <label for="pages-field" class="form-label">Pages</label>
- <input type="text" id="pages-field" name="pages" class="form-control" placeholder="eg. 1-5, 8, 11-13">
- <div class="invalid-feedback">Invalid page range, use eg. 1-5, 8, 11-13</div>
- </div>
- <div class="form-check d-flex align-items-center">
- <input class="form-check-input me-2" type="checkbox" value="" id="scale-checkbox">
- <label class="form-check-label me-2" for="scale-checkbox">Scale pages that are different sizes</label>
- <a class="btn p-0" data-bs-toggle="collapse" href="#scale-checkbox-info" role="button" aria-expanded="false" aria-controls="scaleCheckboxInfo">
- <i class="fas fa-info-circle" width="24" height="24" aria-label="Info"></i>
- </a>
- </div>
- <div class="collapse" id="scale-checkbox-info">
- <div class="card card-body mt-2">
- 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.
- </div>
- </div>
- </form>
- </div>
- <div id="sidebar-footer" class="footer d-flex align-content-center position-absolute bottom-0 start-0 p-3">
- <button type="submit" form="download-options-form" id="download-button" class="btn btn-primary">Download</button>
- <div class="progress ms-3 invisible" style="flex-grow: 1">
- <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>
- </div>
- </div>`;
- document.body.append(sidebar);
- setUpDownloadForm();
- addSidebarEventListeners();
-
- const sidebarCss =
- `#download-sidebar {
- user-select: text;
- -moz-user-select: text;
- -webkit-user-select: text;
- -ms-user-select: text;
- }
- #download-sidebar .offcanvas-header {
- border-bottom: 1px solid var(--bs-gray-300);
- }
- #download-sidebar h5 {
- margin-bottom: 0;
- }
- #sidebar-footer {
- border-top: 1px solid var(--bs-gray-300);
- width: 100%;
- }
- .offcanvas-body {
- margin-bottom: 71px;
- }`;
- GM_addStyle(sidebarCss);
- }
-
- window.addEventListener('load', () => {
- GM_addStyle(GM_getResourceText("bt"));
- addDownloadSidebar();
- addDownloadTab();
- const speedbinb = SpeedBinb.getInstance('content');
- speedbinb.addEventListener('onPageRendered', initializeSidebar);
- });