// ==UserScript== // @name Fanfiction Tools // @author Ewino // @version 1.4 // @description Enhances fanfiction.net. // @namespace http://userscripts.org/scripts/show/102342 // @include http://*.fanfiction.net/* // @updateURL http://userscripts.org/scripts/source/102342.meta.js // @history 1.4 Introduced language filtering, shortened the new favs/follows line, and started using GM's built-in updater. // @history 1.39 A new fix for the site changes. Thanks afoongwl! // @history 1.38 No major update yet. Adapting to the site's new layout. // @history 1.37 Added the average update interval. // @history 1.36 Fixed a bug in Firefox 3.6 where the menu would not open // @history 1.35 Fixed a bug (following a site update) where marking information in several lists (like a user's favorite stories tab) didn't work. // @history 1.3 Added options to auto-close menus only on click, and not hide the chapter navigator (also fixed a small bug with list auto-loading) // @history 1.2 Added an options window and fixed a small bug. // @history 1.1 Fixed a bug with updating the url-hashes. // @history 1.05 FF.net introduced "Share" links in the beginning of each chapter, which broke the auto-loading feature. This fixes it. // @history 1.0 First version. Rewritten from "Power Fanfiction.net" by Ultimer (http://userscripts.org/scripts/show/61979) // ==/UserScript== /*jshint smarttabs:true */ /*global utils:true, features:true, GM_setValue: true, GM_getValue: true, GM_addStyle: true, unsafeWindow: true*/ /* * IMPORTANT: * ==================== * For all those who are looking to change the script's settings, there's now a menu called * "Fanfiction Tools Options" at the top right of every ff.net window (right next to the site's "help" menu) * Click on it to access your settings. * ==================== */ var settings = {}; /* DO NOT CHANGE THIS! IT WILL DO NOTHING */ var defaultSettings = { colorDate: true, /** Color the dates. [true/false] */ colorComplete: true, /** Add a unique color to completed stories. [true/false] */ dateFormat: 1, /** Format of the date. [0/1] 0:US date format, 1:UK date format */ dateOrder: 0, /** Order of the date. [0/1] 0: Published-Updated, 1: Updated-Published */ sep: '/', /** Separator of the dates. (i.e. '-' would result in 1-2-2000). */ fullStoryLoad: false, /** Load the entire story when opening a story. [true/false] */ loadAsYouGo: true, /** Load the next chapter as you read [true/false]. Ignored when fullStoryLoad: true. */ loadListsAsYouGo: true, /** Load the next page in a list as you pass through it. [true/false] */ markWords: 'rape|death|MPREG', /** Words that are marked in the story summary. [an array of words] */ combineReview: true, /** Combine the review link with the review count. [true/false] */ shouldMenusAutoClose: 1, /** use auto-closing menu. [0/1/2] 0: Never auto-close, 1: on mouse leaving, 2: on clicking elsewhere */ shouldRelativeDate: true, /** use relative dates. [true/false] */ showPostingFrequency: true, /** show the average posting frequency. [true/false] */ hideChaptersNavigator: true, /** Hide the chapters drop-down menu. [true/false] */ shortenFavsFollows: true, /** Whether to shorten favs/follows info to small symbols. [true/false] */ viewLanguages: '' /** The preferred languages. Stories not of these languages won't be shown (disabled if empty) [an array of language names */ }; var env = { /** The chapter we currently view. */ currentChapter: 1, /** The amount of chapters in this story. */ totalChapters: 1, /** The current chapter, or the one last loaded through AJAX. */ lastLoadedChapter: -1, /** The URL to load the next page in a paged list. */ nextListPageUrl: '', /** The ~real~ window element. The unsafe one if we use GM */ w: (unsafeWindow || window), /** A shortcut function to use for logging */ log: null }; env.log = env.w.console.log; /** This will be called after the environment finishes initialization. it's the start-point. */ function load() { // for some reason we also run for the sharing iframe. this prevents that. if (!unsafeWindow || unsafeWindow.location.host.indexOf('fanfiction.net') === -1) { return; } features.settings.load(); loadJQuery(); if (settings.shouldMenusAutoClose) { features.setAutoClosingMenus(settings.shouldMenusAutoClose === 2); } unsafeWindow.storylist_render = features.favStoriesRender; features.optionsMenu.setup(); // all selectors var chapterNavigator = utils.getChapterNavigator(), storyTextEl = $('#storytext'), zlists = $('.z-list'); if (storyTextEl.length > 0) { // we're in a story page if (chapterNavigator.length > 0) { // the story has multiple chapters! if ((settings.loadAsYouGo || settings.fullStoryLoad) && settings.hideChaptersNavigator) { chapterNavigator.hide(); } env.totalChapters = utils.chapters.getCount(); var currentChapter = env.currentChapter = utils.chapters.getCurrent(); // setup dom helper elements storyTextEl.prepend('
').append(''); $('#story-start').after(features._getChapterSeparator(currentChapter, null)); var currentSep = $('header.greasemonkey-chapter-separator:first').addClass('current-chapter'); features._addEndOfStorySeperatorIfNeeded(currentChapter); $('.a2a_kit').prependTo(currentSep); var hash = utils.getLocationHash(); if (hash !== '' && isFinite(hash) && hash > currentChapter) { features.redirectToChapter(hash); return; } if (settings.fullStoryLoad) { features.autoLoad.loadFullStory(); } else if (settings.loadAsYouGo) { features.setLoadAsYouGo(); } } features.formatting.doStoryPageInfo($('table#gui_table1i tr.alt2 > td div:contains(Rated:)').last()); } else if (zlists.length > 0) { // we're in a page containing lists zlists.each(function() { features.formatting.doListEntry(this); }); if (settings.loadListsAsYouGo) { features.autoLoad.autoLoadLists(); } } } /***************** Features *****************/ features = { /** * Makes the top menus close when the mouse pointer leaves them. */ setAutoClosingMenus: function(onlyOnClick) { $('.zui').append('Dummylink'); env.w.xmenu_render(); // Force a re-render var eventType = onlyOnClick ? 'click' : 'mouseover'; $('#content_parent')[0].addEventListener(eventType, function() { $('#menu-Dummylink').mouseover(); }, false); }, /** * This is a copy of the original storylist_render function, but with some (highlighted) changes. * It is used to write certain lists like a user's Favorite Stories list. * Note: some unhighlighted changes are that some functions have to be called now through the unsafeWindow. */ favStoriesRender: function (story_array, startrow, show_cat, show_author) { var buffer = []; var story; for(var i = story_array.length, c = startrow; i > 0; i--, c++) { story = story_array[i-1]; /*************************** CHANGE ***************************/ /* Here we store the old buffer in a temp var and use our own buffer for this single entry html. */ var mainBuffer = buffer; buffer = []; /************************ END OF CHANGE ***********************/ buffer[buffer.length] = c + ". "; buffer[buffer.length] = story.stitle + " "; if(story.chapters > 1) { buffer[buffer.length] = " » "; } if(show_author === 1) { buffer[buffer.length] = "by " + story.penname + " "; } if(story.ratingtimes > 0) { buffer[buffer.length] = "reviews"; } buffer[buffer.length] = "" + story.summary + ""; /*************************** CHANGE ***************************/ /* Now that we have the entry's html, we can reformat it like any normal list entry. * Also, return the buffer to be what it was. */ var entryEl = $('').html(buffer.join(''))[0]; features.formatting.doListEntry(entryEl); buffer = mainBuffer; buffer.push(entryEl.innerHTML); /************************ END OF CHANGE ***********************/ } return buffer.join(""); }, shouldHideListEntry: function(listEntry) { if (settings.viewLanguages == null || settings.viewLanguages.length === 0) { return false; } var html = listEntry.html(); var matcher = html.match(/Rated: .*? - (.*?) - /); if (!matcher) { return false; } if (matcher[1]) { var storyLang = matcher[1].toLowerCase(); for (var i = 0; i < settings.viewLanguages.length; i++) { var vl = settings.viewLanguages[i].toLowerCase(); if (vl === storyLang) { return false; } } return true; } }, formatting: { doStoryPageInfo: function(elementToFormat) { elementToFormat.attr('id', 'details-line'); this.formatInfo(elementToFormat); }, doListEntry: function(listEntry) { listEntry = $(listEntry); if (features.shouldHideListEntry(listEntry)) { listEntry.remove(); return false; } var reviewLink = listEntry.children('a[href^="/r/"]'); if (settings.combineReview) { reviewLink.hide(); } var reviewsUrl = reviewLink.attr('href') || ''; if (reviewsUrl === '') { var storyLink = listEntry.children('a[href^="/s/"]'); var storyUrl = storyLink.attr('href') || ''; var urlMatch = storyUrl.match(/\/s\/(\d+)/); if (urlMatch && urlMatch[1]) { var storyId = urlMatch[1]; reviewsUrl = '/r/' + storyId + '/'; } } listEntry[0].innerHTML = utils.markWords(listEntry[0].innerHTML, settings.markWords); this.formatInfo(listEntry.find('.z-padtop2,.gray'), reviewsUrl); return true; }, formatInfo: function(detailsLine, reviewsUrl) { // arguments verification. if ((detailsLine = $(detailsLine)).length === 0) { return; } if (!reviewsUrl) { reviewsUrl = detailsLine.children('a[href^="/r/"]').attr('href') || ''; } var html = detailsLine[0].innerHTML, bCompleted = false, matcher; // for general purposes :) if (settings.viewLanguages != null && settings.viewLanguages.length === 1 && settings.viewLanguages[0].indexOf('-') === -1) { html = html.replace(' - ' + settings.viewLanguages[0] + ' - ', ' - '); } matcher = html.match(/Rated: (]+>)?(Fiction\s+)?([\w+]+)(<\/a>)?/); if (matcher) { html = html.replace(matcher[0], features.formatting._getRatingTagString(matcher[3])); } // if a Complete text exists, remove it and keep note of it. matcher = html.match(/[\s\-]*(Status: )?Complete([\s\-]*)/); if (matcher) { bCompleted = true; html = html.replace(matcher[0], matcher[2]); } // set the review link matcher = html.match(/Reviews: (]+>)?([\d,]+)(<\/a>)?/); if (matcher) { var reviewLink = 'Reviews: ' + utils.getReadableNumber(matcher[2]); if (reviewsUrl) { reviewLink = '' + reviewLink + ''; } html = html.replace(matcher[0], reviewLink); } var totalChapters = env.totalChapters; if (env.totalChapters === 1) { totalChapters = 0; } // set the words/chapter matcher = html.match(/Chapters: ([\d,]+) - Words: ([\d,]+)/); if (matcher) { totalChapters = utils.parseNum(matcher[1]); var wordsPerChapter = utils.getReadableNumber(Math.round( utils.parseNum(matcher[2]) / totalChapters )); html = html.replace(matcher[0], 'Size: ' + matcher[1] + '/' + utils.getReadableNumber(matcher[2]) + ''); } // format favorites and followers text matcher = html.match(/( - Favs: [\d,]+| - Follows: [\d,]+){1,2}/); // either favs, follows, or both in either order if (matcher) { var orig = matcher[0]; var favs = (matcher = orig.match(/Favs: ([\d,]+)/)) ? matcher[1] : 0, follows = (matcher = orig.match(/Follows: ([\d,]+)/)) ? matcher[1] : 0; html = html.replace(orig, features.formatting._getFavsFollowsString(utils.parseNum(favs), utils.parseNum(follows))); } matcher = html.match(/ - (Updated: (\d+-\d+-\d+) - )?Published: (\d+-\d+-\d+)/); if (matcher) { var publishDate = matcher[3], updateDate = matcher[2]; html = html.replace(matcher[0], features.formatting._getUpdatedPublishedString(publishDate, updateDate, bCompleted, totalChapters)); } detailsLine[0].innerHTML = html; }, /** * Gets the " - Updated: x/y/z - Published: a/b/c" string, formatted per the user's settings. * @param publishDate The raw FF.net formatted date of this story's publishing. * @param updateDate The raw FF.net formatted date of this story's last update (or null, for single chapter stories). * @param completed whether this story is complete. * @param totalChapters The number of total chapters in the story. */ _getUpdatedPublishedString: function(publishDate, updateDate, completed, totalChapters) { publishDate = utils.dates.parseFFDate(publishDate); updateDate = updateDate ? utils.dates.parseFFDate(updateDate) : null; // in case there was no update date specified (i.e. for a oneshot or a story that was never updated) if (!updateDate) { return ' - ' + utils.dates.formatDateExtended(publishDate, completed, 'Published: '); } var avgPostingFrequency = 0; if (totalChapters > 0) { var storyLifespan = (updateDate - publishDate) / 1000 / 24 / 3600; avgPostingFrequency = storyLifespan / totalChapters; } // declare the final strings. var publishedPart = ' - Published: ' + utils.dates.formatDate(publishDate), updatedPart = ' - ' + utils.dates.formatDateExtended(updateDate, completed, 'Updated: ', avgPostingFrequency); return (settings.dateOrder === 1) ? updatedPart + publishedPart : publishedPart + updatedPart; }, _getFavsFollowsString: function(favsCount, followsCount) { return ' - ♡' + utils.getReadableNumber(favsCount) + ' ' + '☆' + utils.getReadableNumber(followsCount) + ''; }, _getRatingTagString: function(rating) { var description = 'Unknown rating'; switch(rating.toLowerCase()) { case 'k': description = 'Intended for general audience 5 years and older. Content should be free of any coarse language, violence, and adult themes.'; break; case 'k+': description = 'Suitable for more mature childen, 9 years and older, with minor action violence without serious injury. May contain mild coarse language. Should not contain any adult themes.'; break; case 't': description = 'Suitable for teens, 13 years and older, with some violence, minor coarse language, and minor suggestive adult themes.'; break; case 'm': description = 'Not suitable for children or teens below the age of 16 with non-explicit suggestive adult themes, references to some violence, or coarse language.\n\n' + 'Fiction M can contain adult language, themes and suggestions. Detailed descriptions of physical interaction of sexual or violent nature is considered Fiction MA.'; break; case 'ma': description = 'Content is only suitable for mature adults. May contain explicit language and adult themes.'; } return 'Rated: ' + rating + ''; } }, /** * Redirects the browser to the specified chapter's page. * @param chapterNum The chapter to navigate to. */ redirectToChapter: function(chapterNum) { utils.infoBar.setText('-- Jumping to chapter ' + chapterNum + ': ' + utils.chapters.getTitle(chapterNum) + ' --'); document.location = utils.chapters.getLink(chapterNum); }, autoLoad: { /** Starts the process of loading all chapters of a story (using a recursive function). */ loadFullStory: function() { this._fullStoryLoadStep(1); }, /** * Loads a chapter with the intention of loading the next one after it (until all chapters are loaded). * @param chapterNum The chapter to load. */ _fullStoryLoadStep: function(chapterNum) { var chapNum = chapterNum; // if we're not at the end, be prepared to load the next chapter after loading this one. var callback = (chapNum < env.totalChapters) ? function() { features.autoLoad._fullStoryLoadStep(chapNum + 1); } : null; if ($('#GEASEMONKEYSEPERATOR' + chapNum).length === 0) { // the chapter hasn't been loaded yet features._loadChapterContent(chapNum, /* addBefore: */ chapNum < env.currentChapter, callback); } else { if (callback) { callback(); } } }, autoLoadLists: function( ) { env.nextListPageUrl = this._getNextLinkPageUrl($(document.body)); if (!env.nextListPageUrl) { return; } // if we don't have a "next" link, there's no place to load from. var interval = -1; interval = setInterval(function() { // stop this if we don't have a next page url, or we'll try to load from an empty string (bad but funny - it then loads this page which leads to circular loading) if (!env.nextListPageUrl) { clearInterval(interval); return; } if (utils.pos.getScreenfullsLeft() < 0.5 && !utils.infoBar.isShown()) { features.autoLoad._loadListPage(env.nextListPageUrl); } }, 100); }, _loadListPage: function(pageUrl) { utils.infoBar.setText('-- Loading next page --'); var nextLinkFunc = this._getNextLinkPageUrl; utils.httpRequest({ url: pageUrl, onload: function(responseDetails) { var responseBody = responseDetails.responseText.match(//gi); if (!responseBody || !responseBody[0]) { // the body was not found :( utils.infoBar.setText('-- Error loading page --'); return; } responseBody = responseBody[0]; responseBody = responseBody.replace(/^$/, '/div>'); responseBody = $(responseBody).first(); env.nextListPageUrl = nextLinkFunc(responseBody); responseBody.find('.z-list') .filter(function() { return features.formatting.doListEntry(this); }) // format the entry's text, and include only the non-hidden entries .hover( function() { $(this).addClass('z-high'); }, function() { $(this).removeClass('z-high'); }) // add the z-high class at hover .insertAfter('.z-list:last'); responseBody.empty().remove(); // clean up the loaded DOM. utils.infoBar.hide(); } }); }, /** * Finds the target of the "next" link of the list. * @param parent The element that should contain the list (dom or textual) */ _getNextLinkPageUrl : function(parent) { // finds links with their text ~exactly~ "next" or "Next \u00bb" (») or "Next »" var anchors = $(parent).find('a:contains("next"),a:contains("Next \u00bb"),a:contains("Next »")') .filter(function() {return $(this).text() === 'Next \u00bb' || $(this).text() === 'next'; } ); return anchors.last().attr('href'); } }, setLoadAsYouGo: function() { if (!settings.loadAsYouGo) { return; } env.lastLoadedChapter = env.currentChapter; var chapterLoadInterval = 0, refreshHashInterval = 0; chapterLoadInterval = setInterval(loadChapterIfNeeded, 100); refreshHashInterval = setInterval(refreshHash, 50); function loadChapterIfNeeded() { if (env.lastLoadedChapter >= env.totalChapters) { // stop when you've loaded all chapters. clearInterval(chapterLoadInterval); chapterLoadInterval = -1; } if (env.lastLoadedChapter < env.totalChapters && !utils.infoBar.isShown() && utils.pos.getScreenfullsLeft() < 0.5) { features._loadChapterContent(env.lastLoadedChapter + 1, false /* do not add before */, function() { env.lastLoadedChapter++; }); } } function refreshHash() { var currentHash = utils.parseNum(utils.getLocationHash()) || 0; if (currentHash >= (utils.parseNum(env.totalChapters) || 0)) { /* If there won't be anymore chapters loaded, we can stop checking for hashes. * Otherwise, a new chapter might raise the totalChapters count and more hash refreshing will be needed. */ if (chapterLoadInterval === -1) { clearInterval(refreshHashInterval); refreshHashInterval = -1; } return; } $('header.greasemonkey-chapter-separator:not(.current-chapter)').each(function() { var chapId = utils.parseNum($(this).attr('data-chapterid')) || 0; if (chapId <= currentHash) { return; } // wev'e already passed this separator. if (utils.pos.getRelativeHeight(this) < 100) { // the separator is either in the top 100 pixels of the screen, or we've passed it. document.location.hash = chapId; } }); } }, /** * Retrieves a chapter from the server asynchronously, parses it's contents, * and inserts the chapter text before or after the current chapter's one. * Note: This function also updates the environment's totalChapters with fresh info from the server. * @param chapterNum The number of the chapter to load. * @param addBefore Whether to add this chapter before the current one, or append it to the end. * @param callback An optional function to call after the loading is complete. */ _loadChapterContent: function(chapterNum, addBefore, callback) { var chapterTitle = utils.chapters.getTitle(chapterNum); utils.infoBar.setText('-- Loading chapter ' + chapterNum + '/' + env.totalChapters + ': ' + chapterTitle + ' --'); utils.httpRequest({ url: utils.chapters.getLink(chapterNum), onload: function(responseDetails) { var regmatch = responseDetails.responseText.match(/
"; if(show_cat === 1) { if(story.crossover > 0) { buffer[buffer.length] = "Crossover - " + story.category + " - "; } else { buffer[buffer.length] = story.category + " - "; } } buffer[buffer.length] = unsafeWindow.array_censors[story.censorid] + " - "; buffer[buffer.length] = unsafeWindow.array_languages[story.languageid] + " - "; if(story.genreid > 0) { buffer[buffer.length] = unsafeWindow.array_genres[story.genreid]; if(story.subgenreid > 0) { buffer[buffer.length] = "/" + unsafeWindow.array_genres[story.subgenreid]; } } else if(story.subgenreid > 0) { buffer[buffer.length] = unsafeWindow.array_genres[story.subgenreid] + " - "; } buffer[buffer.length] = " - Chapters: " + story.chapters + " - Words: " + unsafeWindow.addCommas(story.wordcount) + " - Reviews: " + story.ratingtimes + " - Updated: " + story.dateupdatetext + " - Published: " + story.datesubmittext; if(story.chars.length) { buffer[buffer.length] = " - " + story.chars; } if(story.statusid === 2) { buffer[buffer.length] = " - Complete"; } buffer[buffer.length] = "