Royal Road Download Button

Adds "Download All Chapters" button to Royal Road fictions

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

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

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

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 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>";
}