Royal Road Download Button

Adds "Download All Chapters" button to Royal Road fictions

目前為 2023-10-14 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name        Royal Road Download Button
// @license     MIT
// @namespace   rtonne
// @match       https://www.royalroad.com/fiction/*
// @exclude     https://www.royalroad.com/fiction/*/chapter/*
// @grant       none
// @version     3.5
// @author      Rtonne
// @description Adds "Download All Chapters" button to Royal Road fictions
// @require     https://cdn.jsdelivr.net/npm/[email protected]
// @require     https://cdn.jsdelivr.net/npm/[email protected]
// @run-at      document-end
// ==/UserScript==

const button = createDownloadAllButton();

// Add a clone of the download all button to both button lists for different screen widths
const defaultButtonRows = document.querySelectorAll("div.row.reduced-gutter");
defaultButtonRows.forEach((defaultButtonRow) => {
  const buttonClone = button.cloneNode(true);
  buttonClone.onclick = () => {
    downloadAll();
  };
  defaultButtonRow.insertAdjacentElement("afterend", buttonClone);
});

async function downloadAll() {
  // Disable the download button
  document
    .querySelectorAll("a.RRScraperDownloadAllButton")
    .forEach((element) => {
      element.style.pointerEvents = "none";
      element.style.background = "#060606";
      element.style.borderBottom = "2px inset rgba(256,256,256,.1)";
    });

  const parser = new DOMParser();
  const zip = new JSZip();
  const urlSplit = window.location.href.split("/");
  const fictionName = urlSplit[urlSplit.length - 1];

  // Get fiction page again to get full chapter list
  var newHtml = await fetch(window.location.href, { credentials: "omit" })
    .then((response) => response.text())
    .then((text) => parser.parseFromString(text, "text/html"));

  const chapterUrls = [...newHtml.querySelectorAll("tr.chapter-row")].map(
    (element) => {
      return element.getAttribute("data-url");
    }
  );

  const chapterCount = chapterUrls.length;

  const chapterCountLength = chapterCount.toString().length;

  // 0 required so all chapter numbers use the same amount of characters
  const fillZeros = "0".repeat(chapterCountLength);

  // timeoutLoop for the progress bar to work
  let index = 0;
  async function timeoutLoop() {
    let chapterUrl = chapterUrls[index];

    // Get chapter
    newHtml = await fetch("https://www.royalroad.com" + chapterUrl, {
      credentials: "omit",
    })
      .then((response) => response.text())
      .then((text) => parser.parseFromString(text, "text/html"));

    if (
      newHtml.body.firstChild.tagName === "PRE" &&
      newHtml.body.firstChild.innerText === "Slow down!"
    ) {
      // When pages are loaded too fast RoyalRoad may tell you to slow down
      // So we retry it if it does
      print("Slow down!");
      setTimeout(timeoutLoop, 0);
      return;
    }

    // Edit the header links so they work offline
    let chapterHeader = newHtml.querySelector(
      "div.fic-header > div > div.col-lg-6"
    );
    chapterHeader.querySelectorAll("a").forEach((element) => {
      element.setAttribute(
        "href",
        `https://www.royalroad.com${element.getAttribute("href")}`
      );
    });
    let chapter = getCustomHeader() + chapterHeader.outerHTML;

    chapter += '\n<div class="portlet">';

    [
      ...newHtml.querySelector("div.chapter-content").parentNode.children,
    ].forEach((element) => {
      if (
        element.classList.contains("chapter-content") ||
        element.classList.contains("author-note-portlet")
      ) {
        // Add chapter content and author notes
        chapter += "\n" + element.outerHTML;
      } else if (
        element.classList.contains("nav-buttons") ||
        element.classList.contains("margin-bottom-10")
      ) {
        // Add prev/next/index buttons and make them work offline
        element.querySelectorAll("a").forEach((element2) => {
          if (element2.innerText.includes("Index")) {
            element2.setAttribute("href", ".");
            return;
          }
          let adjFilledIndex = "";
          if (element2.innerText.includes("Previous")) {
            adjFilledIndex = (fillZeros + index).slice(chapterCountLength * -1);
          } else if (element2.innerText.includes("Next")) {
            adjFilledIndex = (fillZeros + (index + 2)).slice(
              chapterCountLength * -1
            );
          }
          let adjChapterUrlSplit = element2.getAttribute("href").split("/");
          let adjChapterName =
            adjChapterUrlSplit[adjChapterUrlSplit.length - 1];
          element2.setAttribute(
            "href",
            `${adjFilledIndex}_${adjChapterName}.html`
          );
        });
        chapter += "\n" + element.outerHTML;
      }
    });

    chapter += getCustomFooter();

    let chapterUrlSplit = chapterUrl.split("/");
    let chapterName = chapterUrlSplit[chapterUrlSplit.length - 1];

    let filledIndex = (fillZeros + (index + 1)).slice(chapterCountLength * -1);

    zip.file(`${fictionName}/${filledIndex}_${chapterName}.html`, chapter);

    // Change progress bar
    document.querySelectorAll("div.RRScraperProgressBar").forEach((element) => {
      element.style.width = `${((index + 1) / chapterCount) * 100}%`;
    });

    if (++index < chapterCount) {
      // If there are chapters left, fetch them
      setTimeout(timeoutLoop, 0);
    } else {
      // If all chapters have been fetched, zip them and download them
      zip
        .generateAsync({
          type: "blob",
          compression: "DEFLATE",
          compressionOptions: {
            level: 9,
          },
        })
        .then((blob) => {
          saveAs(blob, fictionName + ".zip");

          // Re-enable the download button
          document
            .querySelectorAll("a.RRScraperDownloadAllButton")
            .forEach((element) => {
              element.style.pointerEvents = null;
              element.style.background = null;
              element.style.borderBottom = null;
              element.querySelector("div.RRScraperProgressBar").style.width =
                "0";
            });
        });
    }
  }
  setTimeout(timeoutLoop, 0);
}

function createDownloadAllButton() {
  const button = document.createElement("a");
  button.className = "button-icon-large RRScraperDownloadAllButton";

  const buttonStyle = getComputedStyle(
    document.querySelector("a.button-icon-large")
  );
  const progressBar = document.createElement("div");
  progressBar.style.position = "absolute";
  progressBar.style.top = `calc(${buttonStyle.height} - ${buttonStyle.borderBottomWidth})`;
  progressBar.style.left = "0";
  progressBar.style.height = buttonStyle.borderBottomWidth;
  progressBar.style.background = getComputedStyle(
    document.querySelector("a.btn-primary")
  ).backgroundColor;
  progressBar.style.width = "0";
  progressBar.className = "RRScraperProgressBar";
  button.appendChild(progressBar);

  const i = document.createElement("i");
  i.className = "fa fa-download";
  button.appendChild(i);

  const span = document.createElement("span");
  span.innerText = "Download All Chapters";
  span.className = "center";
  button.appendChild(span);

  return button;
}

function getCustomHeader() {
  return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<style>
.portlet-body p,
p {
  margin-top: 0;
}
body {
  background: #181818;
  font-family: Open Sans, open-sans, Helvetica Neue, Helvetica, Roboto,
    Arial, sans-serif;
  line-height: 1.42857143;
  font-size: 16px;
  margin: 0;
}
.font-white {
  color: #fff !important;
}
.portlet {
  background: #131313;
  border: 1px solid hsla(0, 0%, 100%, 0.1);
  color: hsla(0, 0%, 100%, 0.8);
  padding: 1em 20px 0;
  margin: 10px 0;
  display: flex;
  flex-direction: column;
}
.author-note-portlet {
  background: #393939;
  color: hsla(0, 0%, 100%, 0.8);
  border: 0;
  padding: 0 10px 10px;
  margin: 0 0 1em;
}
.portlet-title {
  border-bottom: 0;
  margin-bottom: 10px;
  min-height: 41px;
  padding: 0;
  margin-left: 15px;
}
.caption {
  padding: 16px 0 2px;
  display: inline-block;
  float: left;
  font-size: 18px;
  line-height: 18px;
}
.uppercase {
  text-transform: uppercase !important;
}
.bold {
  font-weight: 700 !important;
}
a {
  color: #337ab7;
  text-shadow: none;
  text-decoration: none;
}
.portlet-body {
  padding: 10px 15px;
}
p {
  margin-bottom: 1em;
}
.col-md-5 {
  min-height: 1px;
  background: #2a3642;
  margin-left: -15px;
  margin-right: -15px;
  padding: 10px;
}
.text-center {
  text-align: center;
}
.container {
  margin-left: auto;
  margin-right: auto;
  padding-left: 15px;
  padding-right: 15px;
  width: "100%";
}
.col-md-5 > *,
.col-md-5 > * > * {
  font-weight: 300;
  margin: 10px 0;
}
table {
  background: #004b7a;
  border: none;
  border-collapse: separate;
  border-spacing: 2px;
  box-shadow: 1px 1px 1px rgba(0, 0, 0, 0.75);
  margin: 10px auto;
  width: 90%;
}
table td {
  background: rgba(0, 0, 0, 0.1);
  border: 1px solid hsla(0, 0%, 100%, 0.25) !important;
  color: #ccc;
  margin: 3px;
  padding: 5px;
}
.spoiler,
.spoiler-new {
  max-height: 20px;
  padding-top: 100px;
  overflow-y: scroll;
  border: 1px solid hsla(0, 0%, 100%, 0.5);
}
.spoiler-new:before,
.spoiler:before {
  content: "Spoiler ahead:";
}
.btn-primary {
  box-shadow: none;
  outline: none;
  line-height: 1.44;
  background-color: #337ab7;
  color: #fff;
  padding: 6px 0;
  text-align: center;
  display: inline-block;
  font-size: 14px;
  font-weight: 400;
  border: 1px solid #2e6da4;
}
.btn-primary[disabled] {
  cursor: not-allowed;
  opacity: 0.65;
}
.col-xs-12 {
  width: 100%;
}
.col-xs-6 {
  width: 50%;
  position: relative;
  float: left;
}
.visible-xs,
.visible-xs-block {
  display: none;
}
.col-xs-4 {
  width: 33.33333333%;
  margin: 0;
}
.row {
  display: flex;
  margin-bottom: 1em;
}
@media (min-width: 992px) {
  .container {
    width: 970px;
  }
  .col-md-4 {
    width: 33.33333333%;
  }
  .col-md-offset-4 {
    margin-left: 33.33333333%;
  }
}
@media (min-width: 1200px) {
  .container {
    width: 1170px;
  }
  .col-lg-3 {
    width: 25%;
  }
  .col-lg-offset-6 {
    margin-left: 50%;
  }
}
</style>
</head>
<body>
<div class="container">`;
}

function getCustomFooter() {
  return "</div></div></body></html>";
}