// ==UserScript==
// @name Royal Road Download Button
// @license MIT
// @namespace rtonne
// @match https://www.royalroad.com/fiction/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=royalroad.com
// @grant none
// @version 5
// @author Rtonne
// @description Adds buttons to download to Royal Road chapters
// @require https://cdn.jsdelivr.net/npm/[email protected]
// @require https://cdn.jsdelivr.net/npm/[email protected]
// @run-at document-end
// ==/UserScript==
const FICTION_REGEX = new RegExp(
/^https:\/\/www.royalroad.com\/fiction\/\d+?\/[^\/]+$/
);
const CHAPTER_REGEX = new RegExp(
/^https:\/\/www.royalroad.com\/fiction\/\d+?\/[^\/]+\/chapter\/\d+?\/[^\/]+$/
);
const PARSER = new DOMParser();
if (FICTION_REGEX.test(window.location.href)) {
// If current page is a fiction page
setupFictionPageDownload();
}
if (CHAPTER_REGEX.test(window.location.href)) {
// If current page is a chapter page
setupChapterPageDownload();
}
async function setupFictionPageDownload() {
const chapter_metadata_list = await fetchChapterMetadataList();
if (!chapter_metadata_list.length) {
return;
}
const royalroad_button_computed_style = getComputedStyle(
document.querySelector("a.button-icon-large")
);
// The page has multiple sets of buttons for different widths
const royalroad_3_button_rows = document.querySelectorAll(
"div.row.reduced-gutter"
);
for (const button_row of royalroad_3_button_rows) {
const container = document.createElement("div");
container.style.marginBottom = "10px";
button_row.after(container);
const button = document.createElement("a");
button.className =
"button-icon-large rtonne-royalroad-download-button-fiction-button";
button.style.marginBottom = "0";
container.append(button);
const progress_bar = document.createElement("div");
progress_bar.style.position = "absolute";
progress_bar.style.top = `calc(${royalroad_button_computed_style.height} - ${royalroad_button_computed_style.borderBottomWidth})`;
progress_bar.style.left = "0";
progress_bar.style.height =
royalroad_button_computed_style.borderBottomWidth;
progress_bar.style.background = getComputedStyle(
document.querySelector("a.btn-primary")
).backgroundColor;
progress_bar.style.width = "0";
progress_bar.className = "rtonne-royalroad-download-button-progress-bar";
button.append(progress_bar);
const button_icon = document.createElement("i");
button_icon.className = "fa fa-download";
button.append(button_icon);
const button_text = document.createElement("span");
button_text.innerText = "Download Chapters";
button_text.className = "center";
button.append(button_text);
const form = document.createElement("div");
form.className = "icon-container";
form.style.padding = "5px";
form.style.marginLeft = "5px";
form.style.marginRight = "5px";
container.append(form);
const start_select_label = document.createElement("span");
start_select_label.innerText = "From:";
start_select_label.className = "tip";
start_select_label.style.position = "unset";
form.append(start_select_label);
const start_select = document.createElement("select");
start_select.className =
"form-control rtonne-royalroad-download-button-start-select";
start_select.style.marginBottom = "5px";
form.append(start_select);
const end_select_label = document.createElement("span");
end_select_label.innerText = "To:";
end_select_label.className = "tip";
end_select_label.style.position = "unset";
form.append(end_select_label);
const end_select = document.createElement("select");
end_select.className =
"form-control rtonne-royalroad-download-button-end-select";
form.append(end_select);
for (const [index, chapter_metadata] of chapter_metadata_list.entries()) {
const option = document.createElement("option");
option.value = index;
option.innerText = chapter_metadata.title;
start_select.append(option);
end_select.append(option.cloneNode(true));
}
start_select.firstChild.setAttribute("selected", "selected");
end_select.lastChild.setAttribute("selected", "selected");
button.addEventListener("click", () => {
const start_index = Number(start_select.value);
const end_index = Number(end_select.value);
const chosen_chapters_metadata_list = chapter_metadata_list.slice(
start_index,
end_index + 1
);
downloadChapters(chosen_chapters_metadata_list);
});
start_select.addEventListener("change", () => {
const all_start_selects = document.querySelectorAll(
"select.rtonne-royalroad-download-button-start-select"
);
for (const select of all_start_selects) {
select.value = start_select.value;
}
});
end_select.addEventListener("change", () => {
const all_end_selects = document.querySelectorAll(
"select.rtonne-royalroad-download-button-end-select"
);
for (const select of all_end_selects) {
select.value = end_select.value;
}
});
}
}
async function setupChapterPageDownload() {
const button = document.createElement("a");
button.className =
"btn btn-primary rtonne-royalroad-download-button-chapter-button";
const button_icon = document.createElement("i");
button_icon.className = "fa fa-download";
button.appendChild(button_icon);
const button_text = document.createElement("span");
button_text.innerText = " Download Chapter";
button_text.className = "center";
button.appendChild(button_text);
const royalroad_fiction_page_button = document.querySelector(
"a.btn.btn-block.btn-primary"
);
const royalroad_rss_button = document.querySelector("a.btn-sm.yellow-gold");
const button_clone = button.cloneNode(true);
button_clone.classList.add("btn-block");
button_clone.classList.add("margin-bottom-5");
royalroad_fiction_page_button.after(button_clone);
button.classList.add("btn-sm");
button.setAttribute(
"style",
"border-radius: 4px !important; margin-right: 5px;"
);
royalroad_rss_button.before(button);
const time_element = document.querySelector("i[title='Published'] ~ time");
const date = shortenDate(time_element.getAttribute("datetime"));
const chapter_metadata = {
url: window.location.href,
date: date,
};
button_clone.addEventListener("click", () =>
downloadChapters([chapter_metadata])
);
button.addEventListener("click", () => downloadChapters([chapter_metadata]));
}
/**
* Get chapters' contents, pack them into a ZIP file,
* and send a download request to the user.
* @param {[{url: string, date: string}]} chapter_metadata_list
*/
async function downloadChapters(chapter_metadata_list) {
const fiction_buttons = document.querySelectorAll(
"a.rtonne-royalroad-download-button-fiction-button"
);
const chapter_buttons = document.querySelectorAll(
"a.rtonne-royalroad-download-button-chapter-button"
);
const progress_bars = document.querySelectorAll(
"div.rtonne-royalroad-download-button-progress-bar"
);
// Disable all the download buttons
for (const button of fiction_buttons) {
button.style.pointerEvents = "none";
button.style.background = "#060606";
button.style.borderBottom = "2px inset rgba(256,256,256,.1)";
}
for (const button of chapter_buttons) {
button.style.pointerEvents = "none";
button.style.opacity = ".65";
}
const zip = new JSZip();
const fiction_name = window.location.href.split("/")[5];
for (let index = 0; index < chapter_metadata_list.length; index++) {
const chapter_metadata = chapter_metadata_list[index];
const html = await fetchChapterHtml(chapter_metadata.url);
let prev_date;
let next_date;
if (index - 1 >= 0) {
prev_date = chapter_metadata_list[index - 1].date;
}
if (index + 1 < chapter_metadata_list.length - 1) {
next_date = chapter_metadata_list[index + 1].date;
}
const processed_html = await processChapterHtml(
chapter_metadata.url,
html,
prev_date,
next_date
);
const chapter_url_split = chapter_metadata.url.split("/");
const chapter_name = chapter_url_split[chapter_url_split.length - 1];
zip.file(
`${fiction_name}/${chapter_metadata.date}_${chapter_name}.html`,
processed_html
);
// Change the progress bars
for (const progress_bar of progress_bars) {
progress_bar.style.width = `${
((index + 1) / chapter_metadata_list.length) * 100
}%`;
}
}
await zip
.generateAsync({
type: "blob",
compression: "DEFLATE",
compressionOptions: {
level: 9,
},
})
.then((blob) => {
saveAs(blob, fiction_name + ".zip");
});
// Re-enable all the download buttons, and reset the progress bars
for (const button of fiction_buttons) {
button.style.pointerEvents = null;
button.style.background = null;
button.style.borderBottom = null;
}
for (const button of chapter_buttons) {
button.style.pointerEvents = null;
button.style.opacity = null;
}
for (const progress_bar of progress_bars) {
progress_bar.style.width = "0";
}
}
/**
* Turn RoyalRoad's chapter html into one better for offline reading.
* @param {string} chapter_url
* @param {HTMLHtmlElement} html
* @param {string} [prev_date] The publish date of the previous chapter.
* @param {string} [next_date] The publish date of the next chapter.
*/
async function processChapterHtml(chapter_url, html, prev_date, next_date) {
// Edit spoilers so they function the same offline
const spoilers = html.querySelectorAll(".spoiler-new, .spoiler");
for (const spoiler of spoilers) {
if (spoiler.classList.contains("spoiler")) {
spoiler.innerHTML = spoiler.querySelector(".spoiler-inner").innerHTML;
}
spoiler.className = "spoiler";
const spoiler_content = document.createElement("div");
for (const child of spoiler.children) {
spoiler_content.append(child);
}
const spoiler_label = document.createElement("label");
spoiler_label.innerText = "Spoiler";
const spoiler_checkbox = document.createElement("input");
spoiler_checkbox.type = "checkbox";
spoiler_label.append(spoiler_checkbox);
spoiler.append(spoiler_label);
spoiler.append(spoiler_content);
}
// Edit the header links so they work offline
const chapter_header = html.querySelector(
"div.fic-header > div > div.col-lg-6"
);
const chapter_header_links = chapter_header.querySelectorAll("a");
for (const link of chapter_header_links) {
link.setAttribute(
"href",
`https://www.royalroad.com${link.getAttribute("href")}`
);
}
chapter_header.querySelector(
"h1"
).innerHTML = `<a href="${chapter_url}" class="font-white">${
chapter_header.querySelector("h1").innerHTML
}</a>`;
let chapter =
getCustomChapterHeader() +
chapter_header.outerHTML +
'<div class="portlet">';
const chapterElements = html.querySelector("div.chapter-content").parentNode
.children;
for (const element of chapterElements) {
if (
element.classList.contains("chapter-content") ||
element.classList.contains("author-note-portlet")
) {
// Add chapter content and author notes
chapter += 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
const buttons = element.querySelectorAll("a");
for (const button of buttons) {
if (button.innerText.includes("Index")) {
button.setAttribute("href", ".");
break;
}
// "unknown" should never end up being used
let filename_prefix = "unknown";
// Get the dates here if the button exists but the date is null
// (can occur if a chapter comes out after we get the chapter metadata list)
// Will also be used for the chapter button
// TODO figure out what should happen if the prev/next chapter no longer exists
if (button.innerText.includes("Previous")) {
if (!prev_date) {
const prev_html = await fetchChapterHtml(button.href);
const prev_time_element = prev_html.querySelector(
"i[title='Published'] ~ time"
);
prev_date = shortenDate(prev_time_element.getAttribute("datetime"));
}
filename_prefix = shortenDate(prev_date);
} else if (button.innerText.includes("Next")) {
if (!next_date) {
const next_html = await fetchChapterHtml(button.href);
const next_time_element = next_html.querySelector(
"i[title='Published'] ~ time"
);
next_date = shortenDate(next_time_element.getAttribute("datetime"));
}
filename_prefix = shortenDate(next_date);
}
let adjacent_chapter_url_split = button.getAttribute("href").split("/");
let adjacent_chapter_name =
adjacent_chapter_url_split[adjacent_chapter_url_split.length - 1];
button.setAttribute(
"href",
`${filename_prefix}_${adjacent_chapter_name}.html`
);
}
chapter += element.outerHTML;
}
}
chapter += getCustomChapterFooter();
/* Regex explanation:
Deletes whitespace at the start of each line.
Deletes whitespace at the end of each line, including the newline.
*/
chapter = chapter.replace(/^\s+|(\s*\n)/gm, "");
return chapter;
}
/**
* @param {string} chapter_url
* @returns {Promise<HTMLHtmlElement>}
*/
async function fetchChapterHtml(chapter_url) {
// TODO: watch out for errors like 429 (Too many requests), or if the chapter no longer exists
const html = await fetch(chapter_url, {
credentials: "omit",
})
.then((response) => response.text())
.then((text) => PARSER.parseFromString(text, "text/html"));
return html;
}
/**
* Gets all the chapters from a fiction page.
* If url is null, the current page is used.
* @param {string} [url]
* @returns {Promise<[{title: string, url: string, date: string}]>}
*/
async function fetchChapterMetadataList(url = null) {
// If its not a fiction page, return an empty chapter list
if (
(url === null && !FICTION_REGEX.test(window.location.href)) ||
(url !== null && !FICTION_REGEX.test(url))
) {
return [];
}
let html;
if (url === null) {
// Use current page
html = document.querySelector("html").cloneNode(true);
url = window.location.href;
} else {
// Fetch new page
html = await fetch(url, {
credentials: "omit",
})
.then((response) => response.text())
.then((text) => PARSER.parseFromString(text, "text/html"));
}
const chapter_metadata_list = [
...html.querySelectorAll("tr.chapter-row"),
].map((element) => {
const left_link = element.querySelector("td:not(.text-right) a");
const time_element = element.querySelector("td.text-right a time");
return {
title: left_link.innerText.trim(),
url: left_link.getAttribute("href"),
date: shortenDate(time_element.getAttribute("datetime")),
};
});
// Because javascript hides chapters from the list
// we check and retry if chapters are hidden
if (
chapter_metadata_list.length === 20 &&
html.querySelectorAll(".pagination-small").length > 0
) {
return fetchChapterMetadataList(url);
}
return chapter_metadata_list;
}
/**
* Turns a date with a format like "2023-06-21T22:02:45.0000000Z"
* into something like "20230621220245".
* @param {string} date
* @returns {string}
*/
function shortenDate(date) {
if (!date) {
return "";
}
return date
.split(".")[0]
.replaceAll("-", "")
.replaceAll(":", "")
.replaceAll("T", "");
}
function getCustomChapterHeader() {
const header = /*html*/ `<!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;
}
.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%;
}
}
.spoiler > label > input {
position: absolute;
opacity: 0;
z-index: -1;
}
.spoiler > label {
font-weight: bold;
cursor: pointer;
}
.spoiler > label::after {
content: "Show";
background: #2c2c2c;
border: 1px solid rgba(61, 61, 61, 0.31);
color: hsla(0, 0%, 100%, 0.8) !important;
font-size: 12px;
padding: 1px 5px;
font-weight: 400;
margin-left: 5px;
}
.spoiler > label:has(> input:checked)::after {
content: "Hide";
}
.spoiler > label:hover::after {
background: #3e3e3e;
}
.spoiler > div {
display: none;
margin-top: 20px;
}
.spoiler > label:has(> input:checked) ~ div {
display: block;
}
img {
height: auto !important;
max-width: 100%;
}
</style>
</head>
<body>
<div class="container">`;
/* Regex explanation:
Deletes whitespace at the start of each line.
Deletes whitespace at the end of each line, including the newline.
Deletes whitespace before "{", "(", "}", ")", "/", ":", ",", "<", ">".
("?=" means that the character ahead is found but not selected for deletion)
Deletes whitespace after "{", "(", "}", ")", "/", ":", ",", "<", ">".
("?<=" means that the character behind is found but not selected for deletion)
*/
const no_newlines_header = header.replace(
/^\s+|(\s*\n)|(\s+(?=[\{\(\}\)\/:,<>]))|((?<=[\{\(\}\)\/:,<>])\s+)/gm,
""
);
return no_newlines_header;
}
function getCustomChapterFooter() {
return "</div></div></body></html>";
}