您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Add filters and additional sorters and "Load all pages" button to Fanfiction.net.
// ==UserScript== // @name Fanfiction.net: Filter and Sorter // @namespace https://gf.qytechs.cn/en/users/163551-vannius // @version 1.89 // @license MIT // @description Add filters and additional sorters and "Load all pages" button to Fanfiction.net. // @author Vannius // @match https://www.fanfiction.net/* // @exclude /^https://www\.fanfiction\.net/s// // @exclude /^https://www\.fanfiction\.net/r// // @grant GM_addStyle // @grant GM_getResourceText // @resource JSON https://raw.githubusercontent.com/Nellius/FanFiction-FandomData/master/json/exceptional-fandom.json // ==/UserScript== (function () { 'use strict'; // Author Biography Setting const HIDE_BIO_AUTOMATICALLY = true; // Filter Setting // Options for 'gt', 'ge', 'le', 'dateRange' mode. // Options for chapters filters. // Format: [\d+(K)?] in ascending order const chapterOptions = ['1', '5', '10', '20', '30', '50']; // Options for word_count_gt and word_count_le filters. // Format: [\d+(K)?] in ascending order const wordCountOptions = ['1K', '5K', '10K', '20K', '40K', '60K', '80K', '100K', '200K', '300K']; // Options for reviews, favs and follows filters. // Format: [\d+(K)?] in ascending order const kudoCountOptions = ['10', '50', '100', '200', '400', '600', '800', '1K', '2K', '3K']; // Options for updated and published filters. // Format: [\d+ (hour|day|week|month|year)(s)?] in ascending order const dateRangeOptions = ['24 hours', '1 week', '1 month', '6 months', '1 year', '3 years', '5 years']; // dataId: property key of storyData defined in makeStoryData() // text: text for filter select dom // title: title for filter select dom // mode: used to determine how to compare selectValue and storyValue in throughFilter() // options: required when mode is 'gt', 'ge', 'le', 'dateRange' // reverse: reverse result of throughFilter() // condition: display filter only if filter[filterKey] has defined value const filterDic = { fandom_a: { dataId: 'fandom', text: 'Fandom A', title: "Fandom filter a", mode: 'contain' }, crossover: { dataId: 'crossover', text: '?', title: "Crossover filter", mode: 'equal' }, // Display only if there are crossover fanfictions fandom_b: { dataId: 'fandom', text: 'Fandom B', title: "Fandom filter b", mode: 'contain', condition: { filterKey: 'crossover', value: 'X' } }, rating: { dataId: 'rating', text: 'Rating', title: "Rating filter", mode: 'equal' }, language: { dataId: 'language', text: 'Language', title: "Language filter", mode: 'equal' }, genre: { dataId: 'genre', text: 'Genre', title: "Genre filter", mode: 'contain' }, not_genre: { dataId: 'genre', text: 'Not Genre', title: "Genre reverse filter", mode: 'contain', reverse: true }, chapters_gt: { dataId: 'chapters', text: '< Chapters', title: "Chapter number greater than filter", mode: 'gt', options: chapterOptions }, chapters_le: { dataId: 'chapters', text: 'Chapters ≤', title: "Chapter number less or equal filter", mode: 'le', options: chapterOptions }, word_count_gt: { dataId: 'word_count', text: '< Words', title: "Word count greater than filter", mode: 'gt', options: wordCountOptions }, word_count_le: { dataId: 'word_count', text: 'Words ≤', title: "Word count less or equal filter", mode: 'le', options: wordCountOptions }, reviews: { dataId: 'reviews', text: 'Reviews', title: "Review count greater than or equal filter", mode: 'ge', options: kudoCountOptions }, favs: { dataId: 'favs', text: 'Favs', title: "Fav count greater than or equal filter", mode: 'ge', options: kudoCountOptions }, follows: { dataId: 'follows', text: 'Follows', title: "Follow count greater than or equal filter", mode: 'ge', options: kudoCountOptions }, updated: { dataId: 'updated', text: 'Updated', title: "Updated date range filter", mode: 'dateRange', options: dateRangeOptions }, published: { dataId: 'published', text: 'Published', title: "Published date range filter", mode: 'dateRange', options: dateRangeOptions }, character_a: { dataId: 'character', text: 'Character A', title: "Character filter a", mode: 'contain' }, character_b: { dataId: 'character', text: 'Character B', title: "Character filter b", mode: 'contain' }, not_character: { dataId: 'character', text: 'Not Character', title: "Character reverse filter", mode: 'contain', reverse: true }, relationship: { dataId: 'relationship', text: 'Relationship', title: "Relationship filter", mode: 'contain' }, status: { dataId: 'status', text: 'Status', title: "Status filer", mode: 'equal' } }; // Whether or not to sort characters of relationship in ascending order. // true: [foo, bar] => [bar, foo] // false: [foo, bar] => [foo, bar] const SORT_CHARACTERS_OF_RELATIONSHIP = true; // Sorter Setting // dataId: property key of storyData defined in makeStoryData() // text: displayed sorter name // order: 'asc' or 'dsc' const sorterDicList = [ { dataId: 'fandom', text: 'Category', order: 'asc' }, { dataId: 'updated', text: 'Updated', order: 'dsc' }, { dataId: 'published', text: 'Published', order: 'dsc' }, { dataId: 'title', text: 'Title', order: 'asc' }, { dataId: 'word_count', text: 'Words', order: 'dsc' }, { dataId: 'chapters', text: 'Chapters', order: 'dsc' }, { dataId: 'reviews', text: 'Reviews', order: 'dsc' }, { dataId: 'favs', text: 'Favs', order: 'dsc' }, { dataId: 'follows', text: 'Follows', order: 'dsc' }, { dataId: 'status', text: 'Status', order: 'asc' } ]; // Specify symbols to represent 'asc' and 'dsc'. const orderSymbol = { asc: '▲', dsc: '▼' }; // Css Setting // ColorScheme definitions // [[backgroundColor, color]] const red = [ // ['#ff1111', '#f96540', '#f4a26d', '#efcc99', 'white'].map(color => [color, getReadableColor(color, '#555')]) => ['#ff1111', "#000033"], ["#f96540", "#000099"], ["#f4a26d", "#000000"], ["#efcc99", "#000000"], ["white", "#000000"] ]; // const blue = makeGradualColorScheme('#11f', '#fff', 'rgb', 5, '#555'); // const purple = makeGradualColorScheme('#cd47fd', '#e8eaf6', 'hsl', 5, '#555'); // const gold = makeGradualColorScheme('gold', 'darkgrey', 'rgb', 5); // Select colorScheme const colorScheme = red; // Generate list of className for colorScheme automatically. const menuItemGroupClasses = ((length) => { let indexes = [...Array(length).keys()].map(x => x.toString()); if (length.toString().length > 1) { indexes = indexes.map(x => x.padStart(length.toString().length, '0')); } return indexes.map(index => 'fas-filter-menu-item_group-' + index); })(colorScheme.length); // Generate str of colorScheme css automatically. const menuItemGroupCss = menuItemGroupClasses.map((groupClass, i) => { return '.' + groupClass + " { background-color: " + colorScheme[i][0] + "; color: " + colorScheme[i][1] + "; }"; }); // eslint-disable-next-line no-undef GM_addStyle([ ".fas-badge { color: #555; padding-top: 8px; padding-bottom: 8px; }", ".fas-badge-number { color: #fff; background-color: #999; padding-right: 9px; padding-left: 9px; border-radius: 9px }", ".fas-badge-number:hover { background-color: #555;}", ".fas-progress { width: 1%; height: 10px; background-color: #4caf50; }", ".fas-progress-bar { width: 100%; background-color: #ccc;}", ".fas-loaded-page { text-decoration: line-through !important; }", ".fas-sorter-div { color: gray; font-size: .9em; }", ".fas-sorter { color: gray; }", ".fas-sorter:after { content: attr(data-order); }", ".fas-filter-menus { color: gray; font-size: .9em; }", ".fas-filter-menu { font-size: 1em; padding: 1px 1px; height: 23px; margin: .1em auto; }", ".fas-filter-exclude-menu { border-color: #777; }", ".fas-filter-menu_locked { background-color: #ccc; }", ".fas-filter-menu:disabled { border-color: #999; background-color: #999; }", ".fas-filter-menu-item { color: #555; }", ".fas-filter-menu-item_locked { font-style: oblique; }", ...menuItemGroupCss, ".fas-filter-menu-item_story-zero { background-color: #999; }" ].join('')); // Css functions // Color convert Functions function strColorToHex (strColor) { const ctx = document.createElement('canvas').getContext('2d'); ctx.fillStyle = strColor; return ctx.fillStyle; }; function hexColorToRgb (hexColor) { const hexColor6Digit = hexColor.length - 1 === 3 ? hexColor[1] + hexColor[1] + hexColor[2] + hexColor[2] + hexColor[3] + hexColor[3] : hexColor.slice(1); return [0, 2, 4] .map(x => hexColor6Digit.slice(x, x + 2)) .map(x => parseInt(x, 16)); }; function standardizeToRgb (color) { if (/^#[0-9a-fA-F]{3,6}$/.test(color)) { return hexColorToRgb(color); } else { const hexColor = strColorToHex(color); if (!/^black$/i.test(color) && hexColor === '#000000') { throw new Error(`args of standardizeToRgb, ${color} is invalid.`); } return hexColorToRgb(hexColor); } }; function rgbToHexColor (rgb) { return rgb .map(x => x.toString(16).padStart(2, '0')) .reduce((p, x) => p + x, '#'); }; // Make graduation of background color from startColor to endColor // with gradationsLength steps by using colorSpace('rgb', 'hsv' or 'hsl'). // Determine readable foregroundColor from web safe color automatically. // eslint-disable-next-line no-unused-vars function makeGradualColorScheme ( startColor, endColor, colorSpace = 'rgb', gradationsLength = 5, defaultForegroundColor = null ) { const rgbToHsv = (rgb) => { const [r, g, b] = rgb.map(x => x / 255); const max = Math.max(r, g, b); const min = Math.min(r, g, b); const diff = max - min; const h = (() => { if (max !== min) { if (max === r) { return (60 * ((g - b) / diff) + 360) % 360; } else if (max === g) { return (60 * ((b - r) / diff) + 120) % 360; } else if (max === b) { return (60 * ((r - g) / diff) + 240) % 360; } } return 0; })(); const s = max === 0 ? 0 : diff / max * 100; const v = max * 100; return [h, s, v]; }; const hsvToRgb = (hsv) => { const [h, s, v] = [hsv[0], hsv[1] / 100, hsv[2] / 100]; const f = (n, k = (n + h / 60) % 6) => { return v - v * s * Math.max(Math.min(k, 4 - k, 1), 0); }; return [f(5), f(3), f(1)].map(x => Math.round(x * 255)); }; function hsvToHsl (hsv) { const [h, sHsv, v] = [hsv[0], hsv[1] / 100, hsv[2] / 100]; const l = v - v * sHsv / 2; const m = Math.min(l, 1 - l); const sHsl = m ? (v - l) / m : 0; return [h, sHsl * 100, l * 100]; }; function hslToHsv (hsl) { const [h, sHsl, l] = [hsl[0], hsl[1] / 100, hsl[2] / 100]; const v = l + sHsl * Math.min(l, 1 - l); const sHsv = v === 0 ? 0 : 2 - 2 * l / v; return [h, sHsv * 100, v * 100]; }; function rgbToHsl (rgb) { return hsvToHsl(rgbToHsv(rgb)); } function hslToRgb (hsl) { return hsvToRgb(hslToHsv(hsl)); } // Check colorSpace if (!['rgb', 'hsv', 'hsl'].includes(colorSpace)) { throw new Error(`args of makeGradualColorScheme, ${colorSpace} is invalid.`); } // Convert hex color str into int rgb array. const startRgb = standardizeToRgb(startColor); const endRgb = standardizeToRgb(endColor); // Make rgb arrays of gradations made in rgb or hsv color space. const rgbGradations = (() => { if (colorSpace === 'rgb') { // Make rgb gradations const rgbGradation = [0, 1, 2].map(x => (endRgb[x] - startRgb[x]) / (gradationsLength - 1)); const rgbMiddleGradationsByRgb = [...Array(gradationsLength - 1).keys()] .slice(1) .map(gradationStep => { return startRgb .map((x, i) => x + rgbGradation[i] * gradationStep) .map(x => Math.round(x)); }); return [startRgb, ...rgbMiddleGradationsByRgb, endRgb]; } else if (colorSpace === 'hsv' || colorSpace === 'hsl') { // Convert rgb into hsv const startHsv = rgbToHsv(startRgb); const endHsv = rgbToHsv(endRgb); // Make hsv gradations const hsvGradation = (() => { const hd = endHsv[0] - startHsv[0]; const minHd = Math.abs(hd) < Math.abs(hd - 360) ? hd : hd - 360; const sd = endHsv[1] - startHsv[1]; const vd = endHsv[2] - startHsv[2]; return [minHd, sd, vd].map(x => x / (gradationsLength - 1)); })(); const rgbMiddleGradationsByHsv = [...Array(gradationsLength - 1).keys()] .slice(1) .map(gradationStep => { const h = (startHsv[0] + hsvGradation[0] * gradationStep + 360) % 360; const s = startHsv[1] + hsvGradation[1] * gradationStep; const v = startHsv[2] + hsvGradation[2] * gradationStep; return [h, s, v].map(x => Math.round(x)); }).map(x => hsvToRgb(x)); return [startRgb, ...rgbMiddleGradationsByHsv, endRgb]; } else if (colorSpace === 'hsl') { // Convert rgb into hsl const startHsl = rgbToHsl(startRgb); const endHsl = rgbToHsl(endRgb); // Make hsl gradations const hslGradation = (() => { const hd = endHsl[0] - startHsl[0]; const minHd = Math.abs(hd) < Math.abs(hd - 360) ? hd : hd - 360; const sd = endHsl[1] - startHsl[1]; const ld = endHsl[2] - startHsl[2]; return [minHd, sd, ld].map(x => x / (gradationsLength - 1)); })(); const rgbMiddleGradationsByHsl = [...Array(gradationsLength - 1).keys()] .slice(1) .map(gradationStep => { const h = (startHsl[0] + hslGradation[0] * gradationStep + 360) % 360; const s = startHsl[1] + hslGradation[1] * gradationStep; const l = startHsl[2] + hslGradation[2] * gradationStep; return [h, s, l].map(x => Math.round(x)); }).map(x => hslToRgb(x)); return [startRgb, ...rgbMiddleGradationsByHsl, endRgb]; } })(); const hexGradations = rgbGradations.map(rgb => rgbToHexColor(rgb)); // Make readable pairs of backgroundColor and foregroundColor. const hexGradualColorSchemes = hexGradations.map(backgroundHex => { return [ backgroundHex, getReadableColor(backgroundHex, defaultForegroundColor) ]; }); return hexGradualColorSchemes; }; // Get readable color by comparing backgroundColor and possible foregroundColor // according to contrast ratio and hue difference of backgroundColor and foregroundColor. // Return defaultForegroundColor if it is contrastRatio > 4.5 (WCAG 2 AA Compliant). // Otherwise return WCAG 2 AA Compliant color with highest hueDiff. function getReadableColor (backgroundColor, defaultForegroundColor = null) { const backgroundRgb = standardizeToRgb(backgroundColor); // Get contrast ratio and hue difference of two colors const getColorContrast = (rgb1, rgb2) => { const table = [rgb1, rgb2]; // https://www.w3.org/TR/WCAG20/#contrast-ratiodef const lWeight = [0.2126, 0.7152, 0.0722]; const relativeLuminances = table .map(rgb => rgb.map(x => x / 255)) .map(rgb => rgb.map(x => { if (x <= 0.03928) { return x / 12.92; } else { return ((x + 0.055) / 1.055) ** 2.4; } })).map(rgb => rgb.map((x, i) => x * lWeight[i]).reduce((p, x) => p + x)) .sort((a, b) => b - a); const contrastRatio = (relativeLuminances[0] + 0.05) / (relativeLuminances[1] + 0.05); // https://www.w3.org/TR/AERT/#color-contrast const hueDiff = [0, 1, 2].map(i => Math.abs(rgb1[i] - rgb2[i])).reduce((p, x) => p + x); const yFilter = [0.299, 0.587, 0.114]; const brightnessDiff = Math.abs( table.map(rgb => rgb.map((x, i) => x * yFilter[i]).reduce((p, x) => p + x)) .reduce((p, x) => p - x) ); const contrastRatioThresholdAA = 4.5; const contrastRatioThresholdAAA = 7; const hueThreshold = 500; const brightnessThreshold = 125; return { 'contrastRatio': contrastRatio, 'contrastComplianceAA': contrastRatio >= contrastRatioThresholdAA, 'contrastComplianceAAA': contrastRatio >= contrastRatioThresholdAAA, 'hueDiff': hueDiff, 'hueDiffCompliance': hueDiff >= hueThreshold, 'brightnessDiff': brightnessDiff, 'brightnessDiffCompliance': brightnessDiff >= brightnessThreshold }; }; // Return defaultForegroundColor if it is readable if (defaultForegroundColor) { const defaultForegroundRgb = standardizeToRgb(defaultForegroundColor); const defaultColorContrast = getColorContrast(defaultForegroundRgb, backgroundRgb); if (defaultColorContrast.readable) { return defaultForegroundColor; } } // Generate web safe color const rgbValues = [...Array(6).keys()].map(x => x * 255 / 5); const foregroundRgbs = rgbValues .map(r => rgbValues.map(g => rgbValues.map(b => [r, g, b]))) .reduce((p, x) => p.concat(x), []) .reduce((p, x) => p.concat(x), []); // Calculate each colorContrast of foregroundRgb and backgroundRgb const colorContrasts = foregroundRgbs .map(foregroundRgb => getColorContrast(foregroundRgb, backgroundRgb)); // Find index of WCAG 2 AA Compliant color with highest hueDiff. colorContrasts.forEach((x, i) => { x.index = i; }); let sortedColorContrasts = colorContrasts .filter(x => x.contrastComplianceAA) .sort((a, b) => b.hueDiff - a.hueDiff); if (sortedColorContrasts.length === 0) { sortedColorContrasts = colorContrasts.sort((a, b) => b.contrastRatio - a.contrastRatio); } // Return readable foreground hexColor return rgbToHexColor(foregroundRgbs[sortedColorContrasts[0].index]); }; // Regex functions // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#Escaping function escapeRegExp (string) { return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string } // Main // Check standard of filterDic const defaultFilterDataKeys = ['dataId', 'text', 'title', 'mode', 'options', 'reverse', 'condition']; const modesRequireOptions = ['gt', 'ge', 'le', 'dateRange']; const filterDicUpToStandard = Object.keys(filterDic) .map(filterKey => { const filterData = filterDic[filterKey]; const everyKeyUpToStandard = Object.keys(filterData) .map(filterDataKey => { const keyUpToStandard = defaultFilterDataKeys.includes(filterDataKey); if (!keyUpToStandard) { console.log(`${filterKey} filter: '${filterDataKey}' is an irregular key.`); } return keyUpToStandard; }).every(x => x); const modeRequirementUpToStandard = modesRequireOptions.includes(filterData.mode) ? 'options' in filterData : true; if (!modeRequirementUpToStandard) { console.log(`${filterKey} filter: '${filterData.mode}' mode filter requires to specify options.`); } return everyKeyUpToStandard && modeRequirementUpToStandard; }).every(x => x); if (!filterDicUpToStandard) { console.log("filterDic isn't up to standard."); return; } const setDatasetToZListTag = (x) => { // .filter_placeholder don't have children. // https://gf.qytechs.cn/ja/scripts/13486-fanfiction-net-unwanted-result-filter if (x.firstElementChild) { const zPadtop2Tag = x.getElementsByClassName('z-padtop2')[0]; const rawText = zPadtop2Tag.textContent; const dataText = rawText.replace(/ - Complete$/, ''); const matches = dataText.match(/^(Crossover - )?(.+ - )?Rated: ([^ ]+) - ([^ ]+)( - [^ ]+)? - Chapters: (\d+) - Words: ([\d,]+)( - Reviews: [\d,]+)?( - Favs: [\d,]+)?( - Follows: [\d,]+)? ?(- Updated: [^-]+)?(- Published: [^-]+)?(- .*)?$/); // These dataset are defined in author page. if (!x.dataset.story_id) { // FicLab add .ficlab-save tag at the place of first child of .z-list tag. // https://www.ficlab.com/ const titleTag = x.getElementsByClassName('stitle')[0]; const url = new URL(titleTag.href); x.dataset.storyid = url.pathname.split('/')[2]; x.dataset.title = titleTag.textContent; x.dataset.category = matches[2] ? matches[2].replace(/ - $/g, '') : ''; x.dataset.chapters = matches[6].replace(/[^\d]/g, ''); x.dataset.wordcount = matches[7].replace(/[^\d]/g, ''); x.dataset.ratingtimes = matches[8] ? matches[8].replace(/[^\d]/g, '') : 0; const xutimes = zPadtop2Tag.getElementsByTagName('span'); x.dataset.datesubmit = xutimes[xutimes.length - 1].dataset.xutime; x.dataset.dateupdate = xutimes.length === 2 ? xutimes[0].dataset.xutime : x.dataset.datesubmit; x.dataset.statusid = / - Complete$/.test(rawText) ? 2 : 1; } // Set following dataset for makeStoryData. x.dataset.crossover = matches[2] ? (matches[1] ? 1 : 0) : ''; x.dataset.rating = matches[3]; x.dataset.language = matches[4]; x.dataset.favtimes = matches[9] ? matches[9].replace(/[^\d]/g, '') : 0; x.dataset.followtimes = matches[10] ? matches[10].replace(/[^\d]/g, '') : 0; const genreList = [ 'Adventure', 'Angst', 'Crime', 'Drama', 'Family', 'Fantasy', 'Friendship', 'General', 'Horror', 'Humor', 'Hurt/Comfort', 'Mystery', 'Parody', 'Poetry', 'Romance', 'Sci-Fi', 'Spiritual', 'Supernatural', 'Suspense', 'Tragedy', 'Western' ]; x.dataset.genre = matches[5] ? genreList.filter(genre => matches[5].includes(genre)) : ''; x.dataset.character = ''; x.dataset.relationship = ''; if (matches[13]) { const bracketMatches = matches[13].match(/\[[^\]]+\]/g); if (bracketMatches) { const relationship = []; for (let bracketMatch of bracketMatches) { // [foo, bar] => [bar, foo] if (SORT_CHARACTERS_OF_RELATIONSHIP) { const sortedCharacters = bracketMatch .split(/\[|\]|, /) .map(x => x.trim()) .filter(x => x) .sort() .join(', '); relationship.push('[' + sortedCharacters + ']'); // [foo, bar] => [foo, bar] } else { relationship.push(bracketMatch); } } if (relationship.length) { x.dataset.relationship = relationship; } } x.dataset.character = matches[13].slice(2).split(/\[|\]|, /).map(x => x.trim()).filter(x => x); } } }; const getFandomData = () => { const aTags = [...document.getElementById('content_wrapper_inner').children] .filter(element => element.tagName === 'A'); if (aTags.length === 1) { const fandom = aTags[0].nextElementSibling.nextSibling.textContent.trim(); return { category: fandom, crossover: 0 }; } else { const crossoverFandom = aTags .filter(aTag => /\/crossovers\/[^/]+\/\d+\//.test(aTag.href)) .map(aTag => aTag.textContent) .join(' & '); return { category: crossoverFandom, crossover: 1 }; } }; async function loadAllPages () { const badge = document.getElementById('l_' + this.tabId); const btn = badge.getElementsByClassName('fas-load-button')[0]; btn.disabled = true; // get zListTags from urls const getZListTags = async (url) => { // eslint-disable-next-line no-undef const res = await fetch(url); const text = await res.text(); // eslint-disable-next-line no-undef const parsedDoc = new DOMParser().parseFromString(text, "text/html"); return parsedDoc.getElementsByClassName('z-list'); }; // Add progress bar const progressBar = document.createElement('div'); progressBar.classList.add('fas-progress-bar'); const progress = document.createElement('div'); progress.classList.add('fas-progress'); progress.style.width = 1 / (this.urls.length + 1) * 100 + '%'; progressBar.appendChild(progress); badge.parentElement.insertBefore(progressBar, badge.nextElementSibling); // Set Dataset to zListTag const loadedZListTags = []; const fandomData = getFandomData(); for (let i = 0; i < this.urls.length; i++) { if (i !== 0) { await new Promise(resolve => setTimeout(resolve, 1000)); } const zListTags = await getZListTags(this.urls[i]); [...zListTags].forEach(x => { setDatasetToZListTag(x); if (!x.dataset.category && !x.dataset.crossover) { x.dataset.category = fandomData.category; x.dataset.crossover = fandomData.crossover; } loadedZListTags.push(x); }); progress.style.width = (i + 2) / (this.urls.length + 1) * 100 + '%'; } // Set storyid to .filter_placeholder tags. // https://gf.qytechs.cn/ja/scripts/13486-fanfiction-net-unwanted-result-filter for (let i = 0; i < loadedZListTags.length - 1; i++) { if (!loadedZListTags[i].dataset.storyid && loadedZListTags[i + 1].dataset.storyid) { loadedZListTags[i].dataset.storyid = loadedZListTags[i + 1].dataset.storyid; i++; } } // Add loaded zListTags to #id + '_inside' const inside = document.getElementById(this.tabId + '_inside'); loadedZListTags.forEach(x => { inside.appendChild(x); }); // Render page links in the strikethrough style. const aTags = document.querySelectorAll('#l_cs > a, #content_wrapper_inner > center > a'); [...aTags].forEach(aTag => { aTag.classList.add('fas-loaded-page'); }); // Reset filter const clearTag = document.getElementsByClassName('fas-filter-menus')[0].lastElementChild; clearTag.click(); }; // Restructure elements for community, search and browse pages // and add "Load all pages" button if (/www\.fanfiction\.net\/community\//.test(window.location.href)) { // Restructure elements of community page. const zListTags = document.getElementsByClassName('z-list'); if (zListTags.length <= 1) { return; } const newTabInside = document.createElement('div'); newTabInside.id = 'cs_inside'; [...zListTags].forEach(x => { newTabInside.appendChild(x); }); const newTab = document.createElement('div'); newTab.id = 'cs'; newTab.appendChild(document.createElement('br')); newTab.appendChild(newTabInside); const scriptTag = document.querySelector('#content_wrapper_inner script'); scriptTag.parentElement.insertBefore(newTab, scriptTag); // Make cs badge which contain number of community stories, // page information and "Load all pages" button const badge = document.createElement('div'); badge.id = 'l_' + newTab.id; badge.align = 'center'; badge.classList.add('fas-badge'); const badgeSpan = document.createElement('span'); badgeSpan.classList.add('fas-badge-number'); badgeSpan.textContent = [...zListTags] .filter(zListTag => !zListTag.classList.contains('filter_placeholder')) .length; badge.appendChild(document.createTextNode('Community Stories: ')); badge.appendChild(badgeSpan); const pager = document.querySelector('#content_wrapper_inner center'); if (pager) { badge.appendChild(document.createTextNode(' / ')); pager.childNodes.forEach(x => { badge.appendChild(x.cloneNode(true)); }); } // When community page has plural pages, add "Load all pages" button const aTags = pager ? pager.getElementsByTagName('a') : []; if (aTags.length) { const loadBtn = document.createElement('button'); loadBtn.appendChild(document.createTextNode("Load all pages")); loadBtn.disabled = false; loadBtn.classList.add('fas-load-button'); const currentUrlSplits = window.location.href.split('/'); const startCurrentUrl = currentUrlSplits.slice(0, 8).join('/'); const current = parseInt(currentUrlSplits[8]); const endCurrentUrl = currentUrlSplits.slice(9).join('/'); const last = [...aTags] .map(x => parseInt(x.href.split('/')[8])) .reduce((p, x) => p > x ? p : x, current); const urls = [...Array(last).keys()] .map(x => x + 1) .filter(x => x !== current) .map(x => [startCurrentUrl, x, endCurrentUrl].join('/')); // Add click event loadBtn.addEventListener('click', { urls: urls, tabId: 'cs', handleEvent: loadAllPages }); badge.appendChild(document.createTextNode(' ')); badge.appendChild(loadBtn); } scriptTag.parentElement.insertBefore(badge, newTab); } else if ( /www\.fanfiction\.net\/search\//.test(window.location.href) && /&type=story/.test(window.location.search) ) { // Restructure elements of search page. const divTags = document.querySelectorAll('#content_wrapper_inner > div'); const zListTags = document.getElementsByClassName('z-list'); if (divTags.length < 2 || zListTags.length <= 1) { return; } const newTabInside = document.createElement('div'); newTabInside.id = 'ss_inside'; newTabInside.appendChild(divTags[0]); newTabInside.appendChild(divTags[1]); const newTab = document.createElement('div'); newTab.id = 'ss'; newTab.appendChild(document.createElement('br')); newTab.appendChild(newTabInside); divTags[2].parentElement.insertBefore(newTab, divTags[2]); // Reshape center tag to ss badge which contain number of searched stories, // page information and "Load all pages" button const badge = document.getElementsByTagName('center')[0]; badge.id = 'l_' + newTab.id; badge.classList.add('fas-badge'); const badgeSpan = document.createElement('span'); badgeSpan.classList.add('fas-badge-number'); badgeSpan.textContent = [...zListTags] .filter(zListTag => !zListTag.classList.contains('filter_placeholder')) .length; const fragment = document.createDocumentFragment(); fragment.appendChild(document.createTextNode('Searched Stories: ')); fragment.appendChild(badgeSpan); fragment.appendChild(document.createTextNode(' / ')); badge.insertBefore(fragment, badge.firstChild); // When search page has plural pages, add "Load all pages" button const aTags = badge.getElementsByTagName('a'); if (aTags.length) { const loadBtn = document.createElement('button'); loadBtn.appendChild(document.createTextNode("Load all pages")); loadBtn.disabled = false; loadBtn.classList.add('fas-load-button'); const currentPageMatch = window.location.search.match(/&ppage=(\d+)/); const current = currentPageMatch ? parseInt(currentPageMatch[1]) : 1; const last = [...aTags] .map(aTag => aTag.href.match(/&ppage=(\d+)/)) .map(matches => parseInt(matches[1])) .reduce((p, x) => p > x ? p : x, current); const urls = [...Array(last).keys()] .map(x => x + 1) .filter(x => x !== current) .map(x => aTags[0].href.replace(/&ppage=\d+/, "&ppage=" + x)); // Add click event loadBtn.addEventListener('click', { urls: urls, tabId: 'ss', handleEvent: loadAllPages }); const fragment = document.createDocumentFragment(); fragment.appendChild(document.createTextNode(' ')); fragment.appendChild(loadBtn); badge.appendChild(fragment); } } else if (document.getElementById('filters')) { // Restructure elements of browse page. const zListTags = document.getElementsByClassName('z-list'); if (zListTags.length <= 1) { return; } const newTabInside = document.createElement('div'); newTabInside.id = 'bs_inside'; [...zListTags].forEach(x => { newTabInside.appendChild(x); }); const newTab = document.createElement('div'); newTab.id = 'bs'; newTab.appendChild(document.createElement('br')); newTab.appendChild(newTabInside); const centerTags = [...document.getElementsByTagName('center')] .filter(centerTag => centerTag.getElementsByTagName('a').length); if (centerTags.length) { centerTags[0].parentElement.insertBefore(newTab, centerTags[1]); } else { const scriptTag = document.querySelector('#content_wrapper_inner script'); scriptTag.parentElement.insertBefore(newTab, scriptTag); } // Reshape center tag to bs badge which contain number of browse stories, // page information and "Load all pages" button const badge = centerTags.length ? centerTags[0] : document.createElement('center'); badge.id = 'l_' + newTab.id; badge.classList.add('fas-badge'); const badgeSpan = document.createElement('span'); badgeSpan.classList.add('fas-badge-number'); badgeSpan.textContent = [...zListTags] .filter(zListTag => !zListTag.classList.contains('filter_placeholder')) .length; const fragment = document.createDocumentFragment(); fragment.appendChild(document.createTextNode('Browse Stories: ')); fragment.appendChild(badgeSpan); if (!centerTags.length) { badge.insertBefore(fragment, badge.firstChild); newTab.parentElement.insertBefore(badge, newTab); } else { fragment.appendChild(document.createTextNode(' / ')); badge.insertBefore(fragment, badge.firstChild); } // When search page has plural pages, add "Load all pages" button const aTags = badge.getElementsByTagName('a'); if (aTags.length) { const loadBtn = document.createElement('button'); loadBtn.appendChild(document.createTextNode("Load all pages")); loadBtn.disabled = false; loadBtn.classList.add('fas-load-button'); const currentPageMatch = window.location.search.match(/&p=(\d+)/); const current = currentPageMatch ? parseInt(currentPageMatch[1]) : 1; const last = [...aTags] .map(aTag => aTag.href.match(/&p=(\d+)/)) .map(matches => parseInt(matches[1])) .reduce((p, x) => p > x ? p : x, current); const urls = [...Array(last).keys()] .map(x => x + 1) .filter(x => x !== current) .map(x => aTags[0].href.replace(/&p=\d+/, "&p=" + x)); // Add click event loadBtn.addEventListener('click', { urls: urls, tabId: 'bs', handleEvent: loadAllPages }); const fragment = document.createDocumentFragment(); fragment.appendChild(document.createTextNode(' ')); fragment.appendChild(loadBtn); badge.appendChild(fragment); } } else if (/www\.fanfiction\.net\/u\//.test(window.location.href)) { // Hide author biography automatically if (HIDE_BIO_AUTOMATICALLY) { const bioTag = document.getElementById('bio_text'); if (bioTag && bioTag.textContent === "hide bio") { bioTag.click(); } } } // Add filters and sorters for (let tabId of ['st', 'fs', 'cs', 'ss', 'bs']) { // Initiation const tab = document.getElementById(tabId); const tabInside = document.getElementById(tabId + '_inside'); // Is there a need to add sorters and filters? const moreThanOneStories = tabInside && tabInside.getElementsByClassName('z-list').length >= 2; if (!moreThanOneStories) { continue; } // Data-set initiation const zListTags = tabInside.getElementsByClassName('z-list'); [...zListTags].forEach(x => { setDatasetToZListTag(x); }); const datasetIncludeCategory = [...zListTags].some(x => x.dataset.category); if (!datasetIncludeCategory) { const fandomData = getFandomData(); [...zListTags].forEach(x => { x.dataset.category = fandomData.category; x.dataset.crossover = fandomData.crossover; }); } // Set storyid to .filter_placeholder tags. // https://gf.qytechs.cn/ja/scripts/13486-fanfiction-net-unwanted-result-filter for (let i = 0; i < zListTags.length - 1; i++) { if (!zListTags[i].dataset.storyid && zListTags[i + 1].dataset.storyid) { zListTags[i].dataset.storyid = zListTags[i + 1].dataset.storyid; i++; } } // Sorter functions const makeSorterFunctionBy = (dataId, order = 'asc') => { const sorterFunctionBy = (a, b) => { const aData = makeStoryData(a); const bData = makeStoryData(b); if (aData[dataId] < bData[dataId]) { return order === 'asc' ? -1 : 1; } else if (aData[dataId] > bData[dataId]) { return order === 'asc' ? 1 : -1; } else { if (dataId !== 'title') { const sortByTitle = makeSorterFunctionBy('title'); return sortByTitle(a, b); } else { return 0; } } }; return sorterFunctionBy; }; const makeSorterTag = (sorterDic) => { const sorterId = sorterDic.dataId; const sorterText = sorterDic.text; const firstOrder = sorterDic.order; const sorterSpan = document.createElement('span'); sorterSpan.textContent = sorterText; sorterSpan.classList.add('fas-sorter'); sorterSpan.dataset.order = ''; sorterSpan.addEventListener('click', (e) => { const sortedWithFirstOrder = e.target.dataset.order === orderSymbol[firstOrder]; const sorterTags = document.getElementsByClassName('fas-sorter'); [...sorterTags].forEach(sorterTag => { sorterTag.dataset.order = ''; }); const [secondOrder] = ['asc', 'dsc'].filter(x => x !== firstOrder); const nextOrder = sortedWithFirstOrder ? secondOrder : firstOrder; e.target.dataset.order = orderSymbol[nextOrder]; const sortBySorterId = makeSorterFunctionBy(sorterId, nextOrder); // .filter_placeholder is added by // https://gf.qytechs.cn/ja/scripts/13486-fanfiction-net-unwanted-result-filter const zListTags = tabInside.querySelectorAll('div.z-list:not(.filter_placeholder)'); const placeHolderTags = tabInside.getElementsByClassName('filter_placeholder'); const fragment = document.createDocumentFragment(); [...zListTags] .sort(sortBySorterId) .forEach(x => { if (placeHolderTags.length) { [...placeHolderTags] .filter(p => x.dataset.storyid === p.dataset.storyid) .forEach(p => fragment.appendChild(p)); } fragment.appendChild(x); }); tabInside.appendChild(fragment); }); return sorterSpan; }; // Make sorters // Remove original sorter span in author page. if (['st', 'fs'].includes(tabId)) { while (tab.firstElementChild.firstChild) { tab.firstElementChild.removeChild(tab.firstElementChild.firstChild); } } // Append sorters const fragment = document.createDocumentFragment(); fragment.appendChild(document.createTextNode('Sort: ')); sorterDicList.forEach(sorterDic => { const sorterSpan = makeSorterTag(sorterDic); fragment.appendChild(sorterSpan); fragment.appendChild(document.createTextNode(' . ')); }); if (['st', 'fs'].includes(tabId)) { tab.firstElementChild.appendChild(fragment); } else if (['cs', 'ss', 'bs'].includes(tabId)) { const sorterTag = document.createElement('div'); sorterTag.classList.add('fas-sorter-div'); sorterTag.appendChild(fragment); tab.insertBefore(sorterTag, tab.firstElementChild); } // Filter functions // List of exceptional fandoms contain ' & ' // eslint-disable-next-line no-undef const resourceText = GM_getResourceText('JSON'); const exceptionalFandomList = resourceText ? JSON.parse(resourceText).fandoms : []; // Make story data from .zList tag. const makeStoryData = (zList) => { const storyData = {}; storyData.story_id = parseInt(zList.dataset.storyid); // .zList.filter_placeholder tag have only dataset.storyid. // https://gf.qytechs.cn/ja/scripts/13486-fanfiction-net-unwanted-result-filter if (zList.dataset.title) { storyData.title = zList.dataset.title; storyData.crossover = parseInt(zList.dataset.crossover) ? 'X' : '='; const rawFandom = zList.dataset.category; if (storyData.crossover === 'X') { const splitFandoms = rawFandom.split(' & '); if (splitFandoms.length === 2) { storyData.fandom = splitFandoms.sort(); } else { storyData.fandom = []; for (let fandom of exceptionalFandomList) { const escapedFandom = escapeRegExp(fandom); const fandomRegex = new RegExp('^' + escapedFandom + " & (.+)$|^(.+) & " + escapedFandom + '$', ''); const matches = rawFandom.match(fandomRegex); if (matches) { const fandom2 = matches[1] || matches[2]; storyData.fandom = [fandom, fandom2].sort(); break; } } if (!storyData.fandom.length) { storyData.fandom = [rawFandom]; } } } else { storyData.fandom = [rawFandom]; } storyData.rating = zList.dataset.rating; storyData.language = zList.dataset.language; storyData.genre = zList.dataset.genre ? zList.dataset.genre.split(',') : []; storyData.chapters = parseInt(zList.dataset.chapters); storyData.word_count = parseInt(zList.dataset.wordcount); storyData.reviews = parseInt(zList.dataset.ratingtimes); storyData.favs = parseInt(zList.dataset.favtimes); storyData.follows = parseInt(zList.dataset.followtimes); storyData.published = parseInt(zList.dataset.datesubmit); storyData.updated = parseInt(zList.dataset.dateupdate); storyData.character = zList.dataset.character ? zList.dataset.character.split(',') : []; storyData.relationship = zList.dataset.relationship ? zList.dataset.relationship.match(/\[[^\]]+\]/g) : []; storyData.status = parseInt(zList.dataset.statusid) === 1 ? 'In-Progress' : 'Complete'; } return storyData; }; const timeStrToInt = (timeStr) => { const hour = 3600; const day = hour * 24; const week = hour * 24 * 7; const month = week * 4; const year = month * 12; const matches = timeStr .replace(/hour(s)?/, hour.toString()) .replace(/day(s)?/, day.toString()) .replace(/week(s)?/, week.toString()) .replace(/month(s)?/, month.toString()) .replace(/year(s)?/, year.toString()) .match(/\d+/g); return matches ? parseInt(matches[0]) * parseInt(matches[1]) : null; }; // Judge if a story with storyValue passes through filter with selectValue. const throughFilter = (storyValue, selectValue, filterKey) => { if (selectValue === 'default') { return true; } else { const filterMode = filterDic[filterKey].mode; const resultByFilterMode = (() => { if (filterMode === 'equal') { return storyValue === selectValue; } else if (filterMode === 'contain') { return storyValue.includes(selectValue); } else if (filterMode === 'dateRange') { const now = Math.floor(Date.now() / 1000); const intRange = timeStrToInt(selectValue); return intRange === null || now - storyValue <= intRange; } else if (['gt', 'ge', 'le'].includes) { const execResult = /\d+/.exec(selectValue.replace(/K/, '000')); const intSelectValue = execResult ? parseInt(execResult[0]) : null; if (filterMode === 'gt') { return storyValue > intSelectValue; } else if (filterMode === 'ge') { return storyValue >= intSelectValue; } else if (filterMode === 'le') { return intSelectValue === null || storyValue <= intSelectValue; } } })(); return filterDic[filterKey].reverse ? !resultByFilterMode : resultByFilterMode; } }; const makeStoryDic = () => { const selectFilterDic = {}; Object.keys(filterDic).forEach(filterKey => { const selectId = tabId + '_' + filterKey + '_select'; const selectTag = document.getElementById(selectId); selectFilterDic[filterKey] = selectTag ? selectTag.value : null; }); const storyDic = {}; const zListTags = tabInside.getElementsByClassName('z-list'); [...zListTags].forEach(x => { const storyData = makeStoryData(x); const id = storyData.story_id; storyDic[id] = storyDic[id] || {}; // .filter_placeholder is added by // https://gf.qytechs.cn/ja/scripts/13486-fanfiction-net-unwanted-result-filter if (x.classList.contains('filter_placeholder')) { storyDic[id].placeHolder = x; } else { storyDic[id].dom = x; Object.keys(filterDic).forEach(filterKey => { const dataId = filterDic[filterKey].dataId; storyDic[id][filterKey] = storyData[dataId]; }); storyDic[id].filterStatus = {}; Object.keys(selectFilterDic).forEach(filterKey => { if (selectFilterDic[filterKey] === null) { storyDic[id].filterStatus[filterKey] = true; // Initialization } else { const filterFlag = throughFilter(storyDic[id][filterKey], selectFilterDic[filterKey], filterKey); storyDic[id].filterStatus[filterKey] = filterFlag; } }); } }); return storyDic; }; const changeStoryDisplay = (story) => { // If a story passes through every filter story.displayFlag = Object.keys(story.filterStatus).every(x => story.filterStatus[x]); // .filter_placeholder is added by // https://gf.qytechs.cn/ja/scripts/13486-fanfiction-net-unwanted-result-filter if (story.placeHolder) { story.placeHolder.style.display = story.displayFlag ? '' : 'none'; } else { story.dom.style.display = story.displayFlag ? '' : 'none'; } }; const makeAlternatelyFilteredStoryIds = (storyDic, alternateOptionValue, filterKey) => { return Object.keys(storyDic) .filter(x => { const filterStatus = { ...storyDic[x].filterStatus }; filterStatus[filterKey] = throughFilter(storyDic[x][filterKey], alternateOptionValue, filterKey); return Object.keys(filterStatus).every(x => filterStatus[x]); }).sort(); }; // Collect all filter doms at once by making selectDic const makeSelectDic = () => { const selectDic = {}; Object.keys(filterDic).forEach(filterKey => { const selectTag = document.getElementById(tabId + '_' + filterKey + '_select'); selectDic[filterKey] = {}; selectDic[filterKey].dom = selectTag; selectDic[filterKey].value = selectDic[filterKey].dom.value; selectDic[filterKey].displayed = selectDic[filterKey].dom.style.display === ''; selectDic[filterKey].disabled = selectDic[filterKey].dom.hasAttribute('disabled'); selectDic[filterKey].accessible = selectDic[filterKey].displayed && !selectDic[filterKey].disabled; selectDic[filterKey].optionDic = {}; if (selectDic[filterKey].accessible) { const optionTags = selectTag.getElementsByTagName('option'); [...optionTags].forEach(optionTag => { selectDic[filterKey].optionDic[optionTag.value] = { dom: optionTag }; }); } }); return selectDic; }; // generateCombinations([1, 2, 3], 2) => [[1, 2], [1, 3], [2, 3]] const generateCombinations = (xs, count, previous = []) => { if (count === 0) { return [previous]; } else { return xs.reduce((acc, c, i) => { const nxs = xs.filter((_, j) => j > i); return [...acc, ...generateCombinations(nxs, count - 1, [...previous, c])]; }, []); } }; // Apply selectKey filter with selectValue to all stories. const filterStories = (selectKey, selectValue) => { const storyDic = makeStoryDic(); // Change display of each story. Object.keys(storyDic).forEach(x => { storyDic[x].filterStatus[selectKey] = throughFilter(storyDic[x][selectKey], selectValue, selectKey); changeStoryDisplay(storyDic[x]); }); // Hide useless options. const selectDic = makeSelectDic(); Object.keys(selectDic) .filter(filterKey => selectDic[filterKey].accessible) .forEach(filterKey => { const optionDic = selectDic[filterKey].optionDic; // By changing to one of usableOptionValues, display of stories would change. // Excluded options can't change display of stories. const usableOptionValues = (() => { // Make usableStoryValues from alternately filtered stories // by neutralizing each filter. const usableStoryValues = Object.keys(storyDic) .filter(x => { const filterStatus = { ...storyDic[x].filterStatus }; filterStatus[filterKey] = true; return Object.keys(filterStatus).every(x => filterStatus[x]); }).map(x => storyDic[x][filterKey]) .reduce((p, x) => p.concat(x), []) .filter((x, i, self) => self.indexOf(x) === i) .sort((a, b) => a - b); // Remove redundant options when filter mode is 'gt', 'ge', 'le', or 'dateRange' const filterMode = filterDic[filterKey].mode; if (['gt', 'ge', 'le', 'dateRange'].includes(filterMode)) { const reverse = (filterDic[filterKey].reverse); const sufficientOptionValues = usableStoryValues.map(storyValue => { const optionValues = Object.keys(optionDic).filter(x => x !== 'default'); const throughOptionValues = optionValues .filter(optionValue => { const result = throughFilter(storyValue, optionValue, filterKey); return reverse ? !result : result; }); if (filterMode === 'gt' || filterMode === 'ge') { return throughOptionValues[throughOptionValues.length - 1]; } else if (filterMode === 'le' || filterMode === 'dateRange') { return throughOptionValues[0]; } }).filter((x, i, self) => self.indexOf(x) === i); return sufficientOptionValues; } else { return usableStoryValues; } })(); // Add/remove hidden attribute to options. Object.keys(optionDic).forEach(optionValue => { // usableOptionValues don't include 'default'. const usable = optionValue === 'default' ? true : usableOptionValues.includes(optionValue); optionDic[optionValue].usable = usable; if (!usable) { optionDic[optionValue].dom.setAttribute('hidden', ''); } else { optionDic[optionValue].dom.removeAttribute('hidden'); } }); }); // Hide same value when filterKey uses same dataId. Object.keys(filterDic) .filter(filterKey => selectDic[filterKey].accessible) .filter(filterKey => !filterDic[filterKey].options) .forEach(filterKey => { const filterKeysBySameDataId = Object.keys(filterDic) .filter(x => selectDic[x].accessible) .filter(x => x !== filterKey) .filter(x => filterDic[x].dataId === filterDic[filterKey].dataId); if (filterKeysBySameDataId.length) { filterKeysBySameDataId .filter(x => !filterDic[x].reverse) .filter(x => selectDic[x].value !== 'default') .forEach(x => { const sameValue = selectDic[x].value; selectDic[filterKey].optionDic[sameValue].dom.setAttribute('hidden', ''); selectDic[filterKey].optionDic[sameValue].usable = false; }); } }); const filteredStoryIds = Object.keys(storyDic) .filter(x => storyDic[x].displayFlag) .sort(); // Add/remove // .fas-filter-menu_locked, .fas-filter-menu-item_locked and menuItemGroupClasses. Object.keys(selectDic) .filter(filterKey => selectDic[filterKey].accessible) .forEach(filterKey => { const optionDic = selectDic[filterKey].optionDic; // Remove // .fas-filter-menu_locked and .fas-filter-menu-item_locked and menuItemGroupClasses. selectDic[filterKey].dom.classList.remove('fas-filter-menu_locked'); Object.keys(optionDic).forEach(x => { optionDic[x].dom.classList.remove( 'fas-filter-menu-item_locked', ...menuItemGroupClasses, 'fas-filter-menu-item_story-zero' ); }); // Add .fas-filter-menu-item_locked to each option tag // when alternatelyFilteredStoryIds are equal to filteredStoryIds. const optionsLocked = Object.keys(optionDic) .filter(optionValue => optionDic[optionValue].usable) .map(optionValue => { const alternatelyFilteredStoryIds = makeAlternatelyFilteredStoryIds(storyDic, optionValue, filterKey); optionDic[optionValue].storyNumber = alternatelyFilteredStoryIds.length; if (filterDic[filterKey].reverse && alternatelyFilteredStoryIds.length === 0) { optionDic[optionValue].dom.classList.add('fas-filter-menu-item_story-zero'); } const idsEqualFlag = JSON.stringify(filteredStoryIds) === JSON.stringify(alternatelyFilteredStoryIds); if (idsEqualFlag) { optionDic[optionValue].dom.classList.add('fas-filter-menu-item_locked'); } return idsEqualFlag; }).every(x => x); if (optionsLocked) { // Add .fas-filter-menu_locked to select tag // when every alternatelyFilteredStoryIds are equal to filteredStoryIds. selectDic[filterKey].dom.classList.add('fas-filter-menu_locked'); } else if (menuItemGroupClasses.length) { // Highlight options by filter result by adding menuItemGroupClasses // Remove menuItemGroupClasses Object.keys(optionDic).forEach(optionValue => { optionDic[optionValue].dom.classList.remove(...menuItemGroupClasses); }); // Unique storyNumber in dsc order const filterResults = Object.keys(optionDic) .filter(optionValue => optionDic[optionValue].usable) .map(optionValue => optionDic[optionValue].storyNumber) .filter((x, i, self) => self.indexOf(x) === i) .sort((a, b) => b - a); // Generate combinations of filterResults // which is divided into menuItemGroupClasses.length groups. const dividedResultsCombinations = (() => { if (filterResults.length <= menuItemGroupClasses.length) { // There is no need to divide filterResults. return [filterResults.map(x => [x])]; } else { // Generate combinations of divideIndexes. // Divide filterResults by using divideIndexesCombination. const middleIndexes = [...Array(filterResults.length).keys()].slice(1); return generateCombinations(middleIndexes, menuItemGroupClasses.length - 1) .map(middleIndexesCombination => { const divideIndexes = [0, ...middleIndexesCombination, filterResults.length]; const dividedResultsCombination = []; divideIndexes.reduce((p, x) => { dividedResultsCombination.push(filterResults.slice(p, x)); return x; }); return dividedResultsCombination; }); } })(); // Jenks Natural Breaks. // For each dividedResultsCombination, // calculate sum of squared deviations for class means(SDCM). // dividedResultsCombination with minimum SDCM score is the best match. const minIndex = (() => { if (dividedResultsCombinations.length === 1) { return 0; } else { return dividedResultsCombinations.map(dividedResultsCombination => { return dividedResultsCombination.map(dividedResults => { const classMean = dividedResults.reduce((p, x) => p + x) / dividedResults.length; return dividedResults.map(x => (x - classMean) ** 2).reduce((p, x) => p + x); }).reduce((p, x) => p + x); }).reduce((iMin, x, i, self) => x < self[iMin] ? i : iMin, 0); } })(); // Add menuItemGroupClasses according to dividedResultsCombinations[minIndex] Object.keys(optionDic) .filter(optionValue => optionDic[optionValue].usable) .forEach(optionValue => { const dividedResultsIndex = dividedResultsCombinations[minIndex] .findIndex(dividedResults => dividedResults.includes(optionDic[optionValue].storyNumber) ); optionDic[optionValue].dom.classList.add(menuItemGroupClasses[dividedResultsIndex]); }); } }); // Change badge's story number. const badge = document.getElementById('l_' + tabId).firstElementChild; const displayedStoryNumber = [...Object.keys(storyDic).filter(x => storyDic[x].displayFlag)].length; badge.textContent = displayedStoryNumber; }; // Append filter Div const appendFilterDiv = () => { // Make filterDiv const filterDiv = document.createElement('div'); filterDiv.classList.add('fas-filter-menus'); filterDiv.appendChild(document.createTextNode('Filter: ')); // Make initialStoryDic from initial state of stories. const initialStoryDic = makeStoryDic(); const initialStoryIds = Object.keys(initialStoryDic).sort(); // Log initial attributes and classList for clear feature. const initialSelectDic = {}; const makeSelectTag = (filterKey, defaultText) => { const selectTag = document.createElement('select'); selectTag.id = tabId + '_' + filterKey + '_select'; selectTag.title = filterDic[filterKey].title; selectTag.classList.add('fas-filter-menu'); if (filterDic[filterKey].reverse) { selectTag.classList.add('fas-filter-exclude-menu'); } // Make optionValues from filterKey values of // each story, wordCountOptions, kudoCountOptions or dateRangeOptions. const optionValues = (() => { const storyValues = Object.keys(initialStoryDic) .map(x => initialStoryDic[x][filterKey]) .reduce((p, x) => p.concat(x), []) .filter((x, i, self) => self.indexOf(x) === i) .sort(); const filterMode = filterDic[filterKey].mode; if (filterKey === 'rating') { const orderedOptions = ['K', 'K+', 'T', 'M']; return orderedOptions.filter(x => storyValues.includes(x)); } else if (['gt', 'ge', 'le', 'dateRange'].includes(filterMode)) { const allOptionValues = (() => { if (filterMode === 'gt') { return ['0'].concat(filterDic[filterKey].options) .map(x => x + ' <'); } else if (filterMode === 'ge') { return ['0'].concat(filterDic[filterKey].options) .map(x => x + ' ≤'); } else if (filterMode === 'le') { return filterDic[filterKey].options.concat(['∞']) .map(x => '≤ ' + x); } else if (filterMode === 'dateRange') { return filterDic[filterKey].options.concat(['∞']) .map(x => 'With in ' + x); } })(); // Remove redundant options // when filter mode is 'gt', 'ge', 'le', or 'dateRange' const reverse = (filterDic[filterKey].reverse); const sufficientOptionValues = storyValues.map(storyValue => { const throughOptionValues = allOptionValues .filter(optionValue => { const result = throughFilter(storyValue, optionValue, filterKey); return reverse ? !result : result; }); if (filterMode === 'gt' || filterMode === 'ge') { return throughOptionValues[throughOptionValues.length - 1]; } else if (filterMode === 'le' || filterMode === 'dateRange') { return throughOptionValues[0]; } }).filter((x, i, self) => self.indexOf(x) === i); // "return sufficientOptionValues;" would disturb order of options. return allOptionValues.filter(x => sufficientOptionValues.includes(x)); } else { return storyValues; } })(); initialSelectDic[filterKey] = {}; initialSelectDic[filterKey].initialOptionDic = {}; const initialOptionDic = initialSelectDic[filterKey].initialOptionDic; // Add .fas-filter-menu-item_locked to each option tag // when alternatelyFilteredStoryIds are equal to initialStoryIds. const initialOptionLocked = ['default', ...optionValues].map(optionValue => { initialOptionDic[optionValue] = {}; const option = document.createElement('option'); option.textContent = optionValue === 'default' ? defaultText : optionValue; option.value = optionValue; option.classList.add('fas-filter-menu-item'); const alternatelyFilteredStoryIds = makeAlternatelyFilteredStoryIds(initialStoryDic, optionValue, filterKey); initialOptionDic[optionValue].storyNumber = alternatelyFilteredStoryIds.length; if (filterDic[filterKey].reverse && alternatelyFilteredStoryIds.length === 0) { option.classList.add('fas-filter-menu-item_story-zero'); } const idsEqualFlag = JSON.stringify(initialStoryIds) === JSON.stringify(alternatelyFilteredStoryIds); if (idsEqualFlag) { option.classList.add('fas-filter-menu-item_locked'); } selectTag.appendChild(option); return idsEqualFlag; }).every(x => x); const optionTags = selectTag.getElementsByTagName('option'); if (initialOptionLocked) { // When every alternatelyFilteredStoryIds are equal to initialStoryIds, if (optionTags.length === 1) { // if every story have no filter value, don't display filter. selectTag.style.display = 'none'; } else if (optionTags.length === 2) { // if every stories has same value, disable filter. selectTag.value = optionTags[1].value; selectTag.setAttribute('disabled', ''); } else { // else, add .fas-filter-menu_locked. selectTag.classList.add('fas-filter-menu_locked'); } } else if (menuItemGroupClasses.length) { // Highlight options by filter result by adding menuItemGroupClasses // Unique storyNumber in dsc order const filterResults = Object.keys(initialOptionDic) .map(optionValue => initialOptionDic[optionValue].storyNumber) .filter((x, i, self) => self.indexOf(x) === i) .sort((a, b) => b - a); // Generate combinations of filterResults // which is divided into menuItemGroupClasses.length groups. const dividedResultsCombinations = (() => { if (filterResults.length <= menuItemGroupClasses.length) { // There is no need to divide filterResults. return [filterResults.map(x => [x])]; } else { // Generate combinations of divideIndexes. // Divide filterResults by using divideIndexesCombination. const middleIndexes = [...Array(filterResults.length).keys()].slice(1); return generateCombinations(middleIndexes, menuItemGroupClasses.length - 1) .map(middleIndexesCombination => { const divideIndexes = [0, ...middleIndexesCombination, filterResults.length]; const dividedResultsCombination = []; divideIndexes.reduce((p, x) => { dividedResultsCombination.push(filterResults.slice(p, x)); return x; }); return dividedResultsCombination; }); } })(); // Jenks Natural Breaks. // For each dividedResultsCombination, // calculate sum of squared deviations for class means(SDCM). // dividedResultsCombination with minimum SDCM score is the best match. const minIndex = (() => { if (dividedResultsCombinations.length === 1) { return 0; } else { return dividedResultsCombinations.map(dividedResultsCombination => { return dividedResultsCombination.map(dividedResults => { const classMean = dividedResults.reduce((p, x) => p + x) / dividedResults.length; return dividedResults .map(x => (x - classMean) ** 2) .reduce((p, x) => p + x); }).reduce((p, x) => p + x); }).reduce((iMin, x, i, self) => x < self[iMin] ? i : iMin, 0); } })(); // Add menuItemGroupClasses according to dividedResultsCombinations[minIndex] Object.keys(initialOptionDic) .forEach(optionValue => { const dividedResultsIndex = dividedResultsCombinations[minIndex] .findIndex(dividedResults => { return dividedResults.includes( initialOptionDic[optionValue].storyNumber ); }); [...optionTags] .filter(x => x.value === optionValue) .forEach(x => { x.classList.add(menuItemGroupClasses[dividedResultsIndex]); }); }); } // Log initial classList initialSelectDic[filterKey].initialMenuClassName = selectTag.className; [...optionTags].forEach(optionTag => { initialOptionDic[optionTag.value].initialItemClassName = optionTag.className; }); // Change display of stories by selected filter value. selectTag.addEventListener('change', (e) => { filterStories(filterKey, selectTag.value); }); return selectTag; }; // Make and append filters Object.keys(filterDic).forEach(filterKey => { const filterTag = makeSelectTag(filterKey, filterDic[filterKey].text); filterDiv.appendChild(filterTag); filterDiv.appendChild(document.createTextNode(' ')); }); // Don't display filter which doesn't meet a filterDic[filterKey].condition Object.keys(filterDic) .filter(filterKey => filterDic[filterKey].condition) .forEach(filterKey => { const condition = filterDic[filterKey].condition; const conditionInitialOptions = Object.keys(initialSelectDic[condition.filterKey].initialOptionDic); if (!conditionInitialOptions.includes(condition.value)) { const selectTag = [...filterDiv.children] .find(selectTag => selectTag.id === tabId + '_' + filterKey + '_select'); selectTag.style.display = 'none'; } }); // Add Clear button: // Clear filter settings and revert attributes and class according to initialSelectDic. // Make new filterDiv when "Load all pages" button is clicked. const clear = document.createElement('span'); clear.textContent = 'Clear'; clear.title = "Reset filter values to default"; clear.className = 'gray'; clear.addEventListener('click', (e) => { const selectDic = makeSelectDic(); const changed = Object.keys(selectDic) .filter(filterKey => selectDic[filterKey].accessible) .map(filterKey => selectDic[filterKey].value !== 'default') .some(x => x); const zListTags = [...tabInside.getElementsByClassName('z-list')] .filter(zListTag => !zListTag.classList.contains('filtered')); const allPageLoaded = zListTags.length !== initialStoryIds.length; // Is there a need to run clear feature? if (changed) { Object.keys(selectDic) .filter(filterKey => selectDic[filterKey].accessible) .forEach(filterKey => { // Clear each filter if (selectDic[filterKey].value !== 'default') { selectDic[filterKey].dom.value = 'default'; } // Revert attributes and class of select tag according to initialSelectDic. const initialMenuClassName = initialSelectDic[filterKey].initialMenuClassName; if (selectDic[filterKey].dom.className !== initialMenuClassName) { selectDic[filterKey].dom.className = initialMenuClassName; } // Revert attributes and class of option tag according to optionDic. const optionDic = selectDic[filterKey].optionDic; const initialOptionDic = initialSelectDic[filterKey].initialOptionDic; Object.keys(optionDic).forEach(optionValue => { const initialItemClassName = initialOptionDic[optionValue].initialItemClassName; if (optionDic[optionValue].dom.hasAttribute('hidden')) { optionDic[optionValue].dom.removeAttribute('hidden'); } if (optionDic[optionValue].dom.className !== initialItemClassName) { optionDic[optionValue].dom.className = initialItemClassName; } }); }); } if (changed || allPageLoaded) { // Change display of stories to initial state. zListTags .filter(zListTag => zListTag.style.display === 'none') .forEach(x => { x.style.display = ''; }); // Change story number to initial state. const badge = document.getElementById('l_' + tabId).firstElementChild; badge.textContent = zListTags.length; } // When "Load all pages" button is clicked, // remove old filterDiv and add new filterDiv. if (allPageLoaded) { tab.removeChild(tab.firstElementChild); appendFilterDiv(); } }); filterDiv.appendChild(clear); // Append filterDiv tab.insertBefore(filterDiv, tab.firstChild); }; // Append filters appendFilterDiv(); } })();
QingJ © 2025
镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址