您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Counts and displays how many chapters each branch has inside on the map screen and when choosing your next chapter
当前为
// ==UserScript== // @name Chapter Counter // @version 2.0.0 // @description Counts and displays how many chapters each branch has inside on the map screen and when choosing your next chapter // @author sllypper // @namespace https://greasyfork.org/en/users/55535-sllypper // @match *://chyoa.com/story/* // @match *://chyoa.com/chapter/* // @icon https://chyoa.com/favicon.png // @grant GM_getValue // @grant GM_setValue // @grant GM_deleteValue // @grant GM_listValues // @grant GM_registerMenuCommand // ==/UserScript== /* How to use: * Load the entire map by moving to the bottom, then click the Regenerate button. * There's more commands on the right-click menu or by clicking on the extension you used to load the script * The script only sees the chapters that are currently loaded in the map page. You might have to regenerate after loading more * There's also a toggler for the map to close/open all chapters of a certain depth * If you got the map data, the counter will also appear when choosing your next chapter */ // runs automatically when opening a new map let runAutomatically = true // shows the floating buttons on the bottom-right of the page let showFloatingButton = true let showTogglerButton = true // Style for the counter on the chapter pages. Use one of the below between "" // alternative | simple | crazy | default const cssStyle = "alternative" /************************/ GM_registerMenuCommand("Regenerate map data", generateDraw, "r"); GM_registerMenuCommand("Redraw map counters", drawChildrenCounter, "d"); GM_registerMenuCommand("Delete this from cache", deleteThisFromCache, "c"); GM_registerMenuCommand("Delete entire cache", deleteEntireCache, "a"); /************************/ let storyMap = []; let page = getCurrentPage() console.log('The current page is a ' + page) if (page == "map") { if (!attemptLoadFromCache()) { // no map data on cache if (runAutomatically) { generateDraw() } } else { // got map data from cache drawChildrenCounter() } if (showFloatingButton) { document.body.appendChild(btnLoad()) } addStyleHead(".title-wrapper > .children { padding-right: 8px; }") if (showTogglerButton) { collapsibator() } return } if (page == "chapter") { showChapterCountOnNextChapters() return } /************************/ function generateDraw() { generateMap() drawChildrenCounter() } function createChildrenElement(chapterCount) { var child = document.createElement('span') child.setAttribute('class', 'control-item children') let icon = document.createElement('i') icon.setAttribute('class', 'btb bt-folder') child.appendChild(icon); let count = document.createTextNode(" "+chapterCount) child.appendChild(count) return child } function addStyleHead(css) { var style = document.createElement('style'); document.head.appendChild(style); style.textContent = css; }; function attemptLoadFromCache() { const cachedMap = GM_getValue(window.location.pathname) if (cachedMap == undefined) { console.log("!! map not found in cache"); return false } storyMap = JSON.parse(cachedMap); console.log("!! got map from cache"); unsafeWindow.storyMap = storyMap console.log("Map available as a javascript array on `storyMap`") return true } function deleteThisFromCache() { if (page == "map") { GM_deleteValue(window.location.pathname) } let storyMapPath = getStoryPathName() + '/map' GM_deleteValue(storyMapPath) } function deleteEntireCache() { GM_listValues().forEach((value) => { GM_deleteValue(value) }) } function readOrGenerateMap() { if (! attemptLoadFromCache()) generateMap() } function generateMap() { console.log("!! generating map"); storyMap = createStoryMap() console.log("Chapter Count = ", storyMap.length); unsafeWindow.storyMap = storyMap console.log("Map available as a javascript array on `storyMap`") console.log("!! assembling hierarchy tree"); countMapChapters(); console.log("!! done counting the children"); // cache it GM_setValue(window.location.pathname, JSON.stringify(storyMap)) } /** * counts and assigns the children count of all elements */ function countMapChapters() { for (const chapter of storyMap) { // find the leaves of the tree if (chapter.children.length !== 0) continue; chapter.chapterCount = 0; let next = chapter.parent != null ? chapter.parent : -1; // rise through the branch until it can't while (next != -1) { //console.log("from", chapter.id, "to", next); next = countChildChaptersOf(next); } // then continue to the next chapter childless chapter } // done } /** * Counts and assigns the chapterCount of the node at that index * Aborts returning -1 if it already has been counted * or can't because one of its children hasn't been counted yet * * @param {number} currIndex - The index of the node * @returns {number} The index of the next node. -1 to abort */ function countChildChaptersOf(currIndex) { let nextIndex = -1; // if (currIndex > storyMap.length) console.log('currIndex > storyMap.length', currIndex, storyMap.length) const currentNode = storyMap[currIndex]; if (currentNode.chapterCount != undefined) { // this node was already been processed // abort return nextIndex; } // sum the counts of its children const chapterCountSum = sumChildrenChaptersCount(currentNode); if (chapterCountSum === -1) { // one or more children haven't been processed yet // abort return nextIndex; } // on successful sum currentNode.chapterCount = chapterCountSum + currentNode.children.length; nextIndex = currentNode.parent !== null ? currentNode.parent : -1; return nextIndex; } /** * Sums the chapterCount of all children of the attribute node */ function sumChildrenChaptersCount(chapterObj) { return chapterObj.children.reduce((acc, curr) => { const currentNode = storyMap[curr]; if (currentNode.chapterCount == undefined) { return -1; } return acc !== -1 ? acc + currentNode.chapterCount : acc; }, 0); } function drawChildrenCounter() { let list = document.querySelectorAll(".title-wrapper"); list.forEach((elem, i) => { let existingChildren = elem.querySelector('.children') if (existingChildren) { // redraw existingChildren.remove() } let child = createChildrenElement(storyMap[i].chapterCount) let page = elem.querySelector('.page') elem.insertBefore(child, page) }) } // receives html dom element // returns parsed element as an object function createChapterObj(el) { const pel = {}; let tempEl = el.querySelector(".title"); pel.title = tempEl.textContent; pel.url = tempEl.href; // sometimes the author is empty and there's no <a> inside it tempEl = el.querySelector(".username > a"); pel.author = tempEl ? tempEl.textContent : ""; // page is completely unreliable for chapters loaded afterwards. It resets on loading // pel.page = el.querySelector(".page").textContent; // Sometimes the date is empty, but there's no issue here // console.log(el) pel.date = el.querySelector(".date").textContent; pel.parent = null; pel.children = []; // pel.linksTo = null; pel.margin = parseInt(el.style["margin-left"]); // link chapters don't have views, likes, or comments // so find out if the chapter is a link chapter or not const viewsEl = el.querySelector(".views"); if (viewsEl == null) { pel.views = null; pel.likes = null; pel.comments = null; pel.isLinkChapter = 1; return pel; } pel.views = parseInt(viewsEl.textContent.split(",").join("")) || 0; pel.likes = parseInt(el.querySelector(".likes").textContent) || 0; pel.comments = parseInt(el.querySelector(".comments").textContent) || 0; pel.isLinkChapter = 0; return pel; } // final list like [ chapterObj, (...) ] // where every element has its parent and children noted function createStoryMap() { // temporary list, to get the DOM element from the page // let list = document.getElementsByClassName("story-map-content"); // if (list == null || !list) return; // list = Array.from(list[0].children); let chapterElementArray = Array.from(document.querySelectorAll(".story-map-chapter")) let prevParentI = -1; const finalList = []; chapterElementArray.forEach((el, i) => { // for (const i in chapterElementArray) { // console.log("- Processing Chapter", i); // const el = chapterElementArray[i]; // parse el and add it to the final list const chapterObj = createChapterObj(el); finalList[i] = chapterObj; // console.log(chapterObj) // now we find the parent of el // before checking margin // check if it's the first element of the list if (i == 0) { prevParentI = 0; // continue; // when using a for loop return; } // check margins const currElMargin = chapterObj.margin const prevElMargin = finalList[i-1].margin // check if el is child of prev el if (prevElMargin < currElMargin) { // prev el is parent chapterObj.parent = parseInt(i - 1); // add this el as child of prev element finalList[i - 1].children.push(parseInt(i)); // set prev parent to prev element prevParentI = i - 1; // continue; // when using a for loop return; } // check if el is sibling of prev el if (prevElMargin == currElMargin) { // they share the same parent // prevParent is parent chapterObj.parent = prevParentI; // add this el as child of prevParent finalList[prevParentI].children.push(i); // continue; // when using a for loop return; } // then el must be the "uncle" of prev el // prevElMargin > currElMargin // use a loop go back through the parents from the previous node // to find the first element with margin smaller than self const selfMargin = chapterObj.margin; for (let j = i - 1; j >= 0; j = finalList[j].parent) { if (finalList[j].margin < selfMargin) { // found the parent: j const actualParentI = j; chapterObj.parent = actualParentI; // add this el as child of actual parent // finalList[actualParentI].children.push(chapterObj.id); finalList[actualParentI].children.push(i); // set prev parent to actual parent prevParentI = actualParentI; break; } } // } // when using a for loop }) return finalList; } // button stuff function createButton(text, action, styleStr) { let button = document.createElement('button'); button.textContent = text; button.onclick = action; button.setAttribute('style', styleStr || ''); return button; }; function toStyleStr(obj, selector) { let stack = [], key; for (key in obj) { if (obj.hasOwnProperty(key)) { stack.push(key + ':' + obj[key]); } } if (selector) { return selector + '{' + stack.join(';') + '}'; } return stack.join(';'); }; function btnLoadCss() { return toStyleStr({ 'position': 'fixed', 'bottom': 0, 'right': 0, 'padding': '2px', 'margin': '0 10px 10px 0', 'color': '#333', 'background-color': 'rgb(246, 245, 244)', 'z-index': '9999999999' }) } function btnLoad() { return createButton('Regenerate', function() { generateDraw() // this.remove(); }, btnLoadCss()); } /* Depth Toggler */ function collapsibator() { const input = document.createElement('input') input.defaultValue = 1 input.setAttribute('id', 'toggler-input') input.setAttribute('style', toStyleStr({ // 'padding': '2px', 'color': '#333', 'width': '30px' // 'display': 'inline-block' })) const button = document.createElement('button') button.setAttribute('id', 'toggler-btn') button.textContent = 'Toggle' button.setAttribute('style', toStyleStr({ 'padding': '2px', 'color': '#333', 'background-color': 'rgb(246, 245, 244)', 'display': 'inline-block', 'font-size': '12px', 'margin-left': '4px', })) button.onclick = () => { const level = document.getElementById('toggler-input').value toggleCollapsibleLevel(level) } const cont = document.createElement('div') cont.setAttribute('id', 'toggler-container') cont.setAttribute('style', toStyleStr({ 'position': 'fixed', 'bottom': '50px', 'right': '10px', 'padding': '2px', 'z-index': '9999999999' })) cont.appendChild(input) cont.appendChild(button) document.body.appendChild(cont) } // toggle all collapsibles from depht level "level" function toggleCollapsibleLevel(level) { const chapters = Array.from(document.getElementsByClassName("story-map-chapter")); if (chapters == null) return; if (!storyMap.length) { console.error('storyMap is undefined'); return; } const firstMargin = parseInt(chapters[0].style['margin-left']) const marginGap = parseInt(chapters[1].style['margin-left']) - firstMargin; for (let i = 0; i < storyMap.length; i++) { // if (st.margin == (level*marginGap + firstMargin)) { if (storyMap[i].margin == (level*marginGap + firstMargin)) { // toggle it const btn = chapters[i].querySelector('.btn.btn-link.collapsable.js-collapsable') if (btn) btn.click() // maybe will use this in the future for better performance??? // wasn't able to figure it out // expand // let clazz = Array.from(chapters[i].getAttribute('class')).split(' ').filter(c=>c!="hidden") // chapters[i].setAttribute('class', clazz) // clazz = Array.from(chapters[i].querySelector(".js-collapsable > i").getAttribute('class')).split(' ').filter(c=>c!="bt-minus"&&c!="bt-plus") // clazz.push('bt-minus') // chapters[i].querySelector(".js-collapsable > i").setAttribute('class', clazz) // collapse // if (storyMap[i].margin == (level*marginGap + firstMargin)) { // let el = chapters[i].querySelector(".js-collapsable > i") // let clazz = el.getAttribute('class').split(' ').filter(c=>c!="bt-minus").join(' ') + " bt-plus" // el.setAttribute('class', clazz) // if (storyMap[i].margin > (level*marginGap + firstMargin)) { // let el = chapters[i] // let clazz = el.getAttribute('class') + " hidden" // el.setAttribute('class', clazz) } } // }) } function getCurrentPage() { let url = window.location.pathname // // console.log(url) if (url.search(/\/story\/.*\/map/) >= 0) { return "map" } if (url.search(/\/chapter\//) >= 0) { return "chapter" } if (url.search(/\/story\/.*\.[0-9]+$\/?/) >= 0) { // first chapter of story return "chapter" } } // get story url // find it on cache // get chapter url // compare with chapters listed function showChapterCountOnNextChapters() { let storyPathName = getStoryPathName() + '/map' if (GM_listValues().indexOf(storyPathName) < 0) { console.log('story not found in cache', GM_listValues(), storyPathName); return } // story is on cache let map = JSON.parse(GM_getValue(storyPathName)) // getting the chapter url if we're in /story/ let chapterUrl = window.location.href if (chapterUrl.search(/\/story\//) > -1) { let els = document.querySelectorAll('.controls-left a') let chapterNum = els[els.length-1].href.match(/\d+\/?$/)[0] chapterUrl = "https://chyoa.com/chapter/Introduction."+chapterNum; } // console.log(map, map[1].url, chapterUrl) let currentChapter = map.find(c=>c.url==chapterUrl) let date = document.createElement('div') date.textContent = currentChapter.date document.querySelector('.meta').append(date) let nextChapterIndexList = currentChapter.children let chapterElements = Array.from(document.querySelectorAll(".question-content a")) // Remove "Add a new chapter" link from the list (if the story is not private) // its link ends with "/new" if (chapterElements[chapterElements.length-1].href.search(/\/new$/) > -1) { chapterElements.pop() } if (cssStyle == "crazy") { applyCrazyCSS() } else if (cssStyle == "alternative") { applyAlternativeCSS() } chapterElements.forEach((el, i) => { let mapIndex = nextChapterIndexList[i] if (mapIndex == undefined) { return console.log('Chapter "'+el.textContent+'" not found. Please regenerate the story map.'); } drawLinkChapterCount(el, map[mapIndex], cssStyle) }) } // crazy css function applyCrazyCSS() { let style = toStyleStr({ 'display': 'flex', 'width': '55px', 'justify-content': 'space-between', 'align-items': 'center', 'margin': '0 8px 0 -55px !important', 'position': 'inherit !important', 'float': 'left', 'border-right': '1px solid', 'padding': '0 8px 0 0', 'font-family': 'monospace', 'font-size': '14px', 'line-height': '27px', }, '.question-content .chapterCount') addStyleHead(style) } function applyAlternativeCSS() { let style = toStyleStr({ 'position': 'absolute', 'left': '-45px', 'text-align': 'right', 'width': '40px', 'padding': '11px 0', 'top': '0', }, '.question-content .chapterCount') + toStyleStr({ 'position': 'relative', }, '.question-content a') addStyleHead(style) } function drawLinkChapterCount(el, chapterObj, cssStyle = 'default') { const chapterCount = chapterObj.chapterCount const isLinkChapter = chapterObj.isLinkChapter let span = document.createElement('SPAN') if (isLinkChapter) { let icon = document.createElement('i') icon.setAttribute('class', 'btb bt-external-link') el.insertAdjacentElement('afterbegin',icon) return } span.setAttribute('class', 'chapterCount') if (cssStyle == 'simple') { span.textContent = " ("+chapterCount+")" el.append(span) return } if (cssStyle == 'alternative') { let icon = document.createElement('i') icon.setAttribute('class', 'btb bt-folder') span.textContent = Number(chapterCount).toString() el.append(span) el.insertAdjacentElement('afterbegin', icon) return } // default & carzy markup let icon = document.createElement('i') icon.setAttribute('class', 'btb bt-folder') span.appendChild(icon); let count = document.createTextNode(" "+chapterCount) span.append(count) el.insertAdjacentElement('afterbegin',span) // skip styling if cssStyle is set to 'crazy' if (cssStyle != 'crazy') { let color = window.getComputedStyle(el).color span.setAttribute('style', 'position: absolute; margin: 0 0 0 -60px; color: '+color+' !important;') icon.setAttribute('style', 'margin: 0 2px 0 0;') } } function getStoryPathName() { let href = document.querySelectorAll('.controls-left a') href = href[href.length-2].href return href.slice(href.search(/\/story/)) }