您需要先安装一个扩展,例如 篡改猴、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 for Map and Next Chapter choice // @version 3.0.1 // @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== /* Load the entire map by moving to the bottom, then click the Regenerate button. */ // 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" /************************/ let chapterDataArray; let chapterData; let storyStorageId; let pageType = getPageType() console.log('The current page is a ' + pageType); /************************/ (async() => { if (pageType == "map") { GM.registerMenuCommand("Regenerate map data", generateDraw, "r"); GM.registerMenuCommand("Redraw map counters", drawChildrenCounter, "d"); GM.registerMenuCommand("Fold read chapters", foldTopmostReadChapters, "f"); GM.registerMenuCommand("Fold unread chapters", foldTopmostUnreadChapters, "u"); await loadMapData() if (showFloatingButton) { document.body.appendChild(btnLoad()) } addStyleHead(".title-wrapper > .children { padding-right: 8px; }") if (showTogglerButton) { collapsibator() } } // /chapter/* and /story/* pages if (pageType == "chapter") { await showChapterCountOnChapterChoices() showChapterDate() // previous chapters map tree on clicking the link await prevMapInit() } GM.registerMenuCommand("Delete this from cache", deleteThisFromCache, "c"); GM.registerMenuCommand("Delete entire cache", deleteEntireCache, "a"); })(); return /************************/ async function loadMapData() { if (! await attemptLoadChapterArrayFromCache() && runAutomatically) { // no map data on cache generateMap() drawChildrenCounter() } else { // got map data from cache drawChildrenCounter() } } 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; }; async function attemptLoadChapterArrayFromCache() { if (!storyStorageId) loadStoryPathName() if (! await storyIsInTheCache(storyStorageId)) { console.log('story not found in cache', GM.listValues(), storyStorageId); return false } let chapterDataJson = await GM.getValue(storyStorageId) chapterDataArray = JSON.parse(chapterDataJson); console.log("!! got map from cache"); unsafeWindow.chapterDataArray = chapterDataArray console.log("Map available as a javascript array on `chapterDataArray`"); return true } function deleteThisFromCache() { if (pageType == "map") { GM.deleteValue(getStoryStorageId()) } let storyStorageId = getStoryStorageId() GM.deleteValue(storyStorageId) } function deleteEntireCache() { (GM.listValues()).forEach((value) => { GM.deleteValue(value) }) } async function readOrGenerateMap() { if (! await attemptLoadChapterArrayFromCache()) generateMap() } function generateMap() { console.log("!! generating map"); chapterDataArray = createStoryMap() console.log("Chapter Count = ", chapterDataArray.length); unsafeWindow.chapterDataArray = chapterDataArray console.log("Map available as a javascript array on `chapterDataArray`"); console.log("!! assembling hierarchy tree"); countMapChapters(); console.log("!! done counting the children"); // cache it GM.setValue(getStoryStorageId(), JSON.stringify(chapterDataArray)) } /** * counts and assigns the children count of all elements */ function countMapChapters() { for (const chapter of chapterDataArray) { // 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 > chapterDataArray.length) console.log('currIndex > chapterDataArray.length', currIndex, chapterDataArray.length); const currentNode = chapterDataArray[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 = chapterDataArray[curr]; if (currentNode.chapterCount == undefined) { return -1; } return acc !== -1 ? acc + currentNode.chapterCount : acc; }, 0); } function drawChildrenCounter() { let list = document.querySelectorAll(".title-wrapper"); if (list.length !== chapterDataArray.length) { console.log('Outdated data. Please regenerate the map'); if (list.length >= chapterDataArray.length) { return } } list.forEach((elem, i) => { let existingChildren = elem.querySelector('.children') if (existingChildren) { // redraw existingChildren.remove() } let child = createChildrenElement(chapterDataArray[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 (!chapterDataArray.length) { console.error('chapterDataArray is undefined'); return; } if (!chapterDataArray) { console.error('chapterDataArray 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 < chapterDataArray.length; i++) { // if (st.margin == (level*marginGap + firstMargin)) { if (chapterDataArray[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 (chapterDataArray[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 (chapterDataArray[i].margin > (level*marginGap + firstMargin)) { // let el = chapters[i] // let clazz = el.getAttribute('class') + " hidden" // el.setAttribute('class', clazz) } } // }) } function getPageType() { 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" } } async function storyIsInTheCache(storyStorageId) { return ((await GM.listValues()).indexOf(storyStorageId) >= 0) } // get story url // find it on cache // get chapter url // compare with chapters listed async function showChapterCountOnChapterChoices() { let storyStorageId = getStoryStorageId() // check if chapterData is undefined // if it is, try fetching it if (!chapterData) { //fetch story data array first if (!chapterDataArray) if (! await attemptLoadChapterArrayFromCache()) return false; // fetch story data chapterData = await getChapterData(storyStorageId) if (!chapterData) return console.error('Story found but Chapter not found. Please regenerate the map.'); } let nextChapterIndexList = chapterData.children // get chapter choices let chapterChoices = 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 (chapterChoices[chapterChoices.length-1].href.search(/\/new$/) > -1) { chapterChoices.pop() } // prepare Count Number css style if (cssStyle == "crazy") { applyCrazyCSS() } else if (cssStyle == "alternative") { applyAlternativeCSS() } chapterChoices.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, chapterDataArray[mapIndex], cssStyle) }) } async function getChapterData() { if (chapterData) return chapterData; if (!chapterDataArray) chapterDataArray = await attemptLoadChapterArrayFromCache() if (!chapterDataArray) return false; // story is on cache as chapterDataArray // let chapterDataArray = JSON.parse(await GM.getValue(storyStorageId)) // 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(chapterDataArray, chapterDataArray[1].url, chapterUrl); return chapterDataArray.find(c=>c.url==chapterUrl) } // Show chapter date under its Title // assuming pageType == "chapter" function showChapterDate() { if (!chapterData) return false; let date = document.createElement('div') date.textContent = chapterData.date document.querySelector('.meta').append(date) } // 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 loadStoryPathName() { storyStorageId = getStoryStorageId() } function getStoryStorageId() { if (storyStorageId) return storyStorageId; if (pageType == "map") return window.location.pathname; if (pageType != "chapter") return console.error("Unrecognizable page type"); // pageType is chapter let href = document.querySelectorAll('.controls-left a') href = href[href.length-2].href return href.slice(href.search(/\/story/)) + '/map'; } function foldTopmostReadChapters() { let ignoreList = [] const els = Array.from(document.querySelectorAll(".story-map-chapter")) if (chapterDataArray.length != els.length) return false; // iterate all chapters // fold all read chapters not in the ignore list // put its children in the ignore list // put every ignored chapter children in the ignored list // console.log(chapterDataArray); for (const i in chapterDataArray) { if (i == 0) continue // console.log('in for'); // check if chapter read && i different from 0 if (els[i].classList.length == 1) { // console.log('unread chapter found'); // if (binarySearch(i, ignoreList) == -1 && i != 0) { if (binarySearch(i, ignoreList) == -1) { console.log('success at i = ', i); // console.log('first unread not in ignore list, fold'); els[i].querySelector('.btn').click() } // console.log('add children to ignored list'); ignoreList = ignoreList.concat(chapterDataArray[i].children) } } } function foldTopmostUnreadChapters() { let ignoreList = [] const els = Array.from(document.querySelectorAll(".story-map-chapter")) if (chapterDataArray.length != els.length) return false; for (const i in chapterDataArray) { if (i == 0) continue if (els[i].classList.length != 1) { if (binarySearch(i, ignoreList) == -1) { console.log('success at i = ', i); els[i].querySelector('.btn').click() } ignoreList = ignoreList.concat(chapterDataArray[i].children) } } console.log('ignoreList leng',ignoreList.length); } function binarySearch(value, list) { return list.find(a=>a==value) ? 1 : -1; let first = 0; //left endpoint let last = list.length - 1; //right endpoint let position = -1; let found = false; let middle; if (value < 100) console.log('search', value, list); while (found === false && first <= last) { middle = Math.floor((first + last)/2); if (list[middle] == value) { found = true; position = middle; } else if (list[middle] > value) { //if in lower half last = middle - 1; } else { //in in upper half first = middle + 1; } } return position; } // TODO // index to remain in cache forever containing a story index with the number of chapters and other story stats // update index if the generated data has more chapters than what's on the index // detect branches (chapters where child chapters have many chapters) and note them down // on chapter pages, check if it belongs to any recognizable branch and print its name on the page // fold all topmost read chapters // prev chapters map // let prevMapContainer; function prevMapShow() { let container = document.querySelector('.prevMapContainer') // if (container === null) { // container = prevMapRender() // container = document.querySelector('.prevMapContainer') // } container.classList.toggle('show'); // prevMapContainer.classList.toggle('show'); } async function prevMapRender() { let storyStorageId = getStoryStorageId() // check if chapterData is undefined // if it is, try fetching it if (!chapterData) { //fetch story data array first // if (!chapterDataArray) if (!attemptLoadChapterArrayFromCache()) { if (!chapterDataArray) { if (! await attemptLoadChapterArrayFromCache()) { document.querySelector('.question').insertAdjacentText('Map data not found. Please regenerate the map.') return false; } } // fetch story data chapterData = getChapterData(storyStorageId) if (!chapterData) { document.querySelector('.question').insertAdjacentText('Unable to find Chapter in the story data. Please regenerate the mapp.') // return console.error('Unable to find Chapter in the story data. Please regenerate the map.'); return false; } } if (document.querySelector('.prevMapContainer') === null) { // if (prevMapContainer === null) { document.querySelector('.question').insertAdjacentHTML('beforeend', '<div class="prevMapContainer"></div>') // prevMapContainer = document.querySelector('.prevMapContainer') } // drawThisChapter() drawParentTree() } function drawThisChapter() { let container = document.querySelector('.prevMapContainer') container.insertAdjacentHTML('beforend', '<div class="prevMap_chapter prevMap_currentChapter"><a href="'+chapterData.url+'">'+chapterData.title+'</a></div>') } function drawParentTree() { let container = document.querySelector('.prevMapContainer') let chapter = chapterData // let chapter = chapterDataArray[chapterData.parent] while (chapter.parent != null) { container.insertAdjacentHTML('afterbegin', '<div class="prevMap_chapter"><a href="'+chapter.url+'">'+chapter.title+'</a></div>') chapter = chapterDataArray[chapter.parent] } } async function prevMapInit() { document.querySelector('.controls-left').insertAdjacentHTML('beforeend','<br class="visible-xs-inline"> <a href="#" class="prevMap_button"><i class="btb bt-sitemap"></i>Prev Chapters</a>') let prevMapButton = document.querySelector('.prevMap_button') prevMapButton.addEventListener('click', function(e) { e.preventDefault(); prevMapShow() }); await prevMapRender() let style = toStyleStr({ 'display': 'none', }, '.prevMapContainer') + toStyleStr({ 'display': 'block', }, '.prevMapContainer.show') addStyleHead(style) }