Royal Road Download Button

Adds "Download All Chapters" button to Royal Road fictions

当前为 2023-10-14 提交的版本,查看 最新版本

// ==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.4
// @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() {
  // Start progress bar
  document.querySelectorAll("div.RRScraperProgressBar").forEach((element) => {
    element.style.width = "100%";
  });

  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 = `${
        ((chapterCount - 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");
        });
    }
  }
  setTimeout(timeoutLoop, 0);
}

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

  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.right = "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>";
}