Fanfiction.net: Filter and Sorter

Add filters and additional sorters and "Load all pages" button to Fanfiction.net.

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Fanfiction.net: Filter and Sorter
// @namespace    https://greasyfork.org/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://greasyfork.org/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://greasyfork.org/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://greasyfork.org/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://greasyfork.org/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://greasyfork.org/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://greasyfork.org/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://greasyfork.org/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();
    }
})();