Sleazy Fork is available in English.

f95zone watched filter

f95zone filter only the watched threads.

// ==UserScript==
// @name        f95zone watched filter
// @namespace   f95zone watched filter
// @description f95zone filter only the watched threads.
// @author       C. Gauthier
// @license     MIT
// @icon        https://www.google.com/s2/favicons?domain=f95zone.to
// @match       https://f95zone.to/sam/latest_alpha/
// @version     1.2.0
// @require     https://ajax.googleapis.com/ajax/libs/jquery/3.7.1/jquery.min.js
// @grant       GM_addStyle
// @grant       GM_getValue
// @grant       GM_setValue
// ==/UserScript==

// ==/TODO== ¤¤
//add an animated loading icon while scraper working
//make it work with other categories than "game"
//allow removing watched threads (look at how the marker script works, or maybe a new option to remove them directly from the latest update by clicking on the thread boxes)

(function () {
    'use strict';
    $(function(b) {

        const prefixes = {"23":{"id":23,"name":"SiteRip","class":"label--lightGreen"},"19":{"id":19,"name":"Collection","class":"label--gray"},"13":{"id":13,"name":"VN","class":"label--red"},"14":{"id":14,"name":"Others","class":"label--lightGreen"},"5":{"id":5,"name":"RAGS","class":"label--orange"},"2":{"id":2,"name":"RPGM","class":"label--blue"},"47":{"id":47,"name":"WebGL","class":"pre-webgl"},"3":{"id":3,"name":"Unity","class":"pre-unity"},"4":{"id":4,"name":"HTML","class":"label--olive"},"1":{"id":1,"name":"QSP","class":"label--red"},"6":{"id":6,"name":"Java","class":"pre-java"},"7":{"id":7,"name":"Ren\u0026#039;Py","class":"pre-renpy"},"31":{"id":31,"name":"UnrealEngine","class":"label--royalBlue"},"30":{"id":30,"name":"WolfRPG","class":"label--green"},"8":{"id":8,"name":"Flash","class":"label--gray"},"12":{"id":12,"name":"ADRIFT","class":"label--blue"},"17":{"id":17,"name":"Tads","class":"label--blue"},"18":{"id":18,"name":"Completed","class":"label--blue"},"20":{"id":20,"name":"Onhold","class":"label--skyBlue"},"22":{"id":22,"name":"Abandoned","class":"label--orange"}};

        //const css = "a#watched-filter.selected i {color: #c15858;} a#watched-filter:hover i {color: #c15858;} a#data-scraper:hover i {color: #c15858;}
        const css = "a#watched-filter.selected i {color: #c15858;} a#watched-filter:hover i {color: #c15858;} a#data-scraper:hover i {color: #c15858;} #prompt-container {     position: fixed;     top: 0;     left: 0;     width: 100%;     height: 100%;     display: flex;     justify-content: center;     align-items: center;     background-color: rgba(0, 0, 0, 0.7);     z-index: 1000; } .prompt-box {     background-color: #363636;     border-radius: 8px;     padding: 20px;     text-align: center;     width: 300px;     box-shadow: 0px 0px 15px rgba(0, 0, 0, 0.5); } .prompt-message {  margin-bottom: 15px;  color: #fff;  font-size: 16px; text-align: left; } .prompt-slider { -webkit-appearance: none; appearance: none; width: 100%; height: 20px; border-radius: 5px; background: #ccc; outline: none; opacity: 0.9; transition: opacity .15s ease-in-out; margin-bottom: 20px; }  .prompt-slider:hover { opacity: 1; } .prompt-slider::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 20px; height: 20px; border-radius: 15%; background: #e54c4c; cursor: pointer; }  .prompt-slider::-moz-range-thumb { width: 20px; height: 20px; border-radius: 15%; background: #e54c4c; cursor: pointer; } .prompt-buttons {     display: flex;     justify-content: space-between; } .prompt-button {     background-color: #e54c4c;     border: none;     color: white;     padding: 10px 20px;     text-align: center;     border-radius: 4px;     font-size: 16px;     cursor: pointer;     transition: background-color 0.3s ease; } .prompt-button:hover {     background-color: #ff6a6a; } .hidden {     display: none !important; } "/*{animation: icon-rotate 4s linear infinite;transform-origin: 50% 45%;}@keyframes icon-rotate {from { transform: rotate(0deg); }to { transform: rotate(-1turn); }}"*/;

        const style = document.createElement('style'); style.textContent = css; document.head.appendChild(style);


        var thread_data = GM_getValue('thread_data', []);
        var latestDownloadDate = GM_getValue('latestDownloadDate', null);


        console.log(GM_getValue("thread_data", []));
        //GM_setValue("thread_data", []); GM_setValue('latestDownloadDate', null);


        // ==/ajaxSetup==
        b.ajaxSetup({
            method: "GET",
            cache: !1,
            dataType: "json",
            timeout: 1E4
        });

        // Save the original send method
        const originalSend = XMLHttpRequest.prototype.send;

        // Override the send method
        XMLHttpRequest.prototype.send = function(body) {
            // Store the reference to the current XHR object
            const xhr = this;

            // Attach an event listener to capture the response
            xhr.addEventListener('readystatechange', function() {
                if (xhr.readyState === XMLHttpRequest.DONE && xhr.responseURL.includes('latest_data.php?cmd=list')) {

                    $('#watched-filter').attr('href',transformUrl(xhr.responseURL));

                    if(GM_getValue('watchedFilter', false)){

                        // Here you can modify the response before it's processed by the application
                        const filtered = filterThreadData(xhr.responseURL);
                        const threads = filtered[0];
                        const searchParams = new URLSearchParams(xhr.responseURL); const page = Number(searchParams.get("page"));
                        const pTotal = filtered[1];
                        const count = filtered[2];
                        let customData = JSON.stringify({ "status": "ok", "msg": { "data": threads, "pagination": { "page": page, "total": pTotal }, "count": count }}); // Your custom JSON data

                        // Override the responseText property to return your custom data
                        Object.defineProperty(xhr, 'responseText', { value: customData });
                    }
                }
            });

            // Call the original send method to continue with the request
            originalSend.apply(this, arguments);
        };

        // Sorts an array of objects by a specified key in either ascending or descending order.
        function sortArrayByKey(arr, key, ascending = false) {
            return arr.sort((a, b) => {
                if (typeof a[key] === 'string') {
                    return ascending ? a[key].localeCompare(b[key]) : b[key].localeCompare(a[key]);
                } else {
                    return ascending ? a[key] - b[key] : b[key] - a[key];
                }
            });
        }

        // Calculates the weighted rating for each thread using the Bayesian formula based on views and ratings
        function calculateWeightedRating(threads, m = 3.5, C = 100000) {
            // C is the minimum number of views required to be considered
            // m is the average rating across all threads

            m = threads.reduce((accumulator, currentValue) => accumulator + currentValue.rating, 0,)/threads.length; //~3.5

            return threads.map(thread => {
                const { rating, views } = thread;

                // Calculate the weighted rating using the Bayesian formula
                const weightedRating = (views*rating + m*C) / (views+C)

                return {
                    ...thread,
                    weightedRating: weightedRating.toFixed(2) // round to 2 decimal places
                };
            });
        }

        // Adds a numeric timestamp property to each thread object based on its date string for easier sorting.
        function giveDatesValue(threads) {

            return threads.map(thread => {
                const { date } = thread;

                // parse the date in string format to get the time stamp
                const dateV = Date.parse(date);

                return {
                    ...thread,
                    dateV: dateV
                };
            });
        }

        // Filters thread data based on various search parameters from a URL
        function filterThreadData(url){
            var tmp_thread_data = GM_getValue('thread_data', []);
            const searchParams = new URLSearchParams(url);

            //sort
            if(searchParams.has("sort")){
                let key = searchParams.get("sort"); let ascending = key == "title";
                switch(searchParams.get("sort")) {
                    case "date":
                        tmp_thread_data = giveDatesValue(tmp_thread_data);
                        sortArrayByKey(tmp_thread_data, "dateV", ascending);
                        break;
                    case "rating":
                        tmp_thread_data = calculateWeightedRating(tmp_thread_data);
                        sortArrayByKey(tmp_thread_data, "weightedRating", ascending);
                        break;
                    default:
                        sortArrayByKey(tmp_thread_data, key, ascending);
                }
            }

            //date
            if(searchParams.has("date")){
                let key = Number(searchParams.get("date"));
                tmp_thread_data = tmp_thread_data.filter(thread => {
                    const timeDifference = new Date() - new Date(thread.date);
                    const daysDifference = timeDifference / (1000 * 60 * 60 * 24);
                    return daysDifference < key
                });
            }

            //tags (and/or)
            if(searchParams.has("tags[]")){
                let key = searchParams.getAll("tags[]").map(Number);
                tmp_thread_data = tmp_thread_data.filter(thread => {
                    if(searchParams.has("tagtype")){
                        // Return true if at least one of the tags in key is found in the thread's tags array
                        return key.some(tag => thread.tags.includes(tag));
                    }else{
                        // Check if every tag in keys is present in item.tags
                        return key.every(tag => thread.tags.includes(tag));
                    }
                });
            }

            //notags
            if(searchParams.has("notags[]")){
                let key = searchParams.getAll("notags[]").map(Number);
                tmp_thread_data = tmp_thread_data.filter(thread => {
                    // Return true if none of thetags in key are found in the thread's tags array
                    return !key.some(tag => thread.tags.includes(tag));
                });
            }

            //prefixes
            if(searchParams.has("prefixes[]")){
                let key = searchParams.getAll("prefixes[]").map(Number);
                tmp_thread_data = tmp_thread_data.filter(thread => {
                    // Return true if all of the prefixes in key are found in the thread's prefixes array
                    return key.every(tag => thread.prefixes.includes(tag));
                });
            }

            //noprefixes
            if(searchParams.has("noprefixes[]")){
                let key = searchParams.getAll("noprefixes[]").map(Number);
                tmp_thread_data = tmp_thread_data.filter(thread => {
                    // Return true if none of the prefixes in key are found in the thread's prefixes array
                    return !key.some(tag => thread.prefixes.includes(tag));
                });
            }

            //search
            if(searchParams.has("search")){
                let key = searchParams.get("search").toLowerCase();
                tmp_thread_data = tmp_thread_data.filter(thread => thread.title.toLowerCase().includes(key));
            }

            //creator
            if(searchParams.has("creator")){
                let key = searchParams.get("creator").toLowerCase();
                tmp_thread_data = tmp_thread_data.filter(thread => thread.creator.toLowerCase().includes(key));
            }

            //page and rows
            let page = [];
            let rows = 30; if(searchParams.has("rows")){rows = Number(searchParams.get("rows"));}
            if(searchParams.has("page")){
                let key = Number(searchParams.get("page"));
                for(let id = rows*(key-1); id < Math.min(rows*key, tmp_thread_data.length); id++){
                    page.push(tmp_thread_data[id]);
                }
            }

            //Date() date to String ( Date() => "x mins / hrs / days / weeks / months / years" )
            page.forEach(thread => {
                let d = dateToString(Date.parse(thread.date));
                thread.date = String(d[0]) + " "+ d[1];
            });


            return [page, Math.ceil(tmp_thread_data.length/rows), tmp_thread_data.length];
        }

        //parses a string back into a date object
        function stringToDate(input) {

            const newDate = new Date();

            const [amount, unit] = (() => {
                const parts = input.split(' ');
                if (parts.length === 2) {
                    return [parseInt(parts[0]), parts[1]];
                } else if (input.toLowerCase() === "yesterday") {
                    return [1, "days"];
                } else {
                    return [null, null];
                }
            })();

            if (amount === null || unit === null) {
                return newDate;
            }

            switch (unit) {
                case 'min':
                case 'mins':
                    newDate.setMinutes(newDate.getMinutes() - amount);
                    break;
                case 'hr':
                case 'hrs':
                    newDate.setHours(newDate.getHours() - amount);
                    break;
                case 'days':
                    newDate.setDate(newDate.getDate() - amount);
                    break;
                case 'week':
                case 'weeks':
                    newDate.setDate(newDate.getDate() - (amount * 7));
                    break;
                case 'month':
                case 'months':
                    newDate.setMonth(newDate.getMonth() - amount);
                    break;
                case 'year':
                case 'years':
                    newDate.setFullYear(newDate.getFullYear() - amount);
                    break;
                default:
                    throw new Error('Invalid time unit');
            }

            return newDate;
        }

        // Converts a date to it's coresponding string
        function dateToString(date) {
            const now = new Date();
            const diffInMs = now - date;

            const minutes = Math.floor(diffInMs / (1000 * 60));
            const hours = Math.floor(diffInMs / (1000 * 60 * 60));
            const days = Math.floor(diffInMs / (1000 * 60 * 60 * 24));
            const weeks = Math.floor(days / 7);
            const months = Math.floor(days / 30);
            const years = Math.floor(days / 365);

            if (minutes < 1) {
                return [0, "min"];
            } else if (minutes === 1) {
                return [1, "min"];
            } else if (minutes < 60) {
                return [minutes, "mins"];
            } else if (hours === 1) {
                return [1, "hr"];
            } else if (hours < 24) {
                return [hours, "hrs"];
            } else if (days === 1) {
                return ["", "yesterday"];
            } else if (days < 7) {
                return [days, "days"];
            } else if (weeks === 1) {
                return [1, "week"];
            } else if (weeks < 4) {
                return [weeks, "weeks"];
            } else if (months === 1) {
                return [1, "month"];
            } else if (months < 12) {
                return [months, "months"];
            } else if (years === 1) {
                return [1, "year"];
            } else {
                return [years, "years"];
            }
        }

        // Splits a string into parts
        function splitString(input) {
            const parts = input.split(' ');

            if (parts.length === 2) {
                if(parts[1] == "hours"){return [parts[0], "hrs"];}
                return [parts[0], parts[1]];
            } else {
                return ["", parts[0]];
            }
        }

        // Filters an array to keep only the first occurrence of each unique object based on a specified key
        function uniqByKeepFirst(data, key) {
            const seen = new Set();
            return data.filter(obj => {
                const keyValue = obj[key];
                if (seen.has(keyValue)) {
                    return false;
                } else {
                    seen.add(keyValue);
                    return true;
                }
            });
        }

        function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); }

        // Retrieves data from the 1st page and returns a promise with the result, the pagination and the elapsed time
        function getPageData(pageNumber){
            return new Promise(function(resolve, reject) {
                var start_time = new Date().getTime();
                b.ajax({
                    url: "latest_data.php?cmd=list&cat=games&page="+pageNumber+"&sort=date&rows=90&_=1710376207895",
                    async: false,
                    success: function(result) {
                        resolve({data: result.msg, time: (new Date().getTime() - start_time)}); // Resolve the promise with the result
                    },
                    error: function(error) {
                        reject(error); // Reject the promise if there's an error
                    }
                });
            });
        }

        // Builds the database by fetching data across the pages while showing a loading overlay
        async function buildDB(firstPage, lastSearchedPage, date=false){

            //var lastSearchedPage = GM_getValue('pages_to_filter', 1);
            if(!date){console.log("data is loading, pages searched : " + lastSearchedPage-firstPage+1);}
            else{console.log("data is loading, pages will be searched until the last time this function was executed");}

            // Create the overlay div
            var overlay = document.createElement('div');

            // Apply styles to the overlay div
            overlay.style.position = 'fixed'; overlay.style.top = '0';overlay.style.left = '0'; overlay.style.width = '100%'; overlay.style.height = '100%'; overlay.style.backgroundColor = 'rgba(0, 0, 0, 0.3)'; //style
            overlay.style.zIndex = '9999'; // ensure it's on top of all other elements

            // Create the message element
            var message = document.createElement('div');
            message.innerText = 'Please wait, loading...';
            message.style.color = 'white';message.style.fontSize = '24px';message.style.fontFamily = 'Arial, sans-serif';message.style.textAlign = 'center';message.style.padding = '20px';message.style.backgroundColor = 'rgba(0, 0, 0, 0.5)';message.style.borderRadius = '10px';

            // Append the message to the overlay
            overlay.appendChild(message);

            // Add the overlay to the document body for a search of more than one page
            document.body.appendChild(overlay);


            var test = [];
            var firstDate;
            var j = (lastSearchedPage-firstPage+1)*90;

            for (let i = firstPage; i <= lastSearchedPage; i++) {
                var stopLoop = false;


                await b.ajax({
                    url: "latest_data.php?cmd=list&cat=games&page="+i+"&sort=date&rows=90&_=1710376207895",
                    success: async function(f) {
                        const threads = f.msg.data;
                        if(i == firstPage){firstDate = stringToDate(threads[0].date);}
                        stopLoop = (date != false && new Date(stringToDate(threads[threads.length-1].date)) <= new Date(date));
                        for (const t of threads) {
                            if(!date){
                                message.innerText = 'Please wait, loading... \n' + j + ' threads left'; j--;

                                // Use requestAnimationFrame to allow the DOM to update before continuing
                                await new Promise(requestAnimationFrame);
                            }
                            //else{message.innerText = 'Please wait, loading... \n' + j + ' threads searched'; j++;} //¤¤change with an animated icon

                            if(t.watched){

                                var newDate = stringToDate(t.date); // change the t.date string as a Date object aproximating the creation date instead of a string of the time since creation
                                t.date = String(newDate);

                                test.push(t);
                            }
                        }
                    }
                });
                if(stopLoop){break;}
            }

            if (overlay && overlay.parentNode) {
                overlay.parentNode.removeChild(overlay);
            }
            var concat = test.concat(thread_data);
            thread_data = uniqByKeepFirst(concat, "thread_id"); //in case some threads where treated 2 times because of updates of the data base of the website while the list was being built
            GM_setValue("thread_data", thread_data);
            GM_setValue('latestDownloadDate', String(firstDate));
        }

        // Transforms an URL for better usage by the DOM
        function transformUrl(url) {
            // Step 1: Extract the base URL (before the query parameters) and the query string
            let [baseUrl, queryString] = url.split('?');

            if (!queryString) return url; // Return the original URL if no query parameters are present

            // Step 2: Parse the query string into key-value pairs
            let params = new URLSearchParams(queryString);

            // Step 3: Remove 'rows', 'page' and '_'
            params.delete('rows');
            params.delete('page');
            params.delete('_');

            // Step 4: Build the transformed URL
            let newUrl = '#';
            for (let [key, value] of params.entries()) {
                newUrl += `/${key}=${value}`;
            }

            return newUrl;
        }

        // Displays a custom prompt with message and callback for OK or Cancel
        function showPrompt(message, callback) {
            const promptContainer = document.getElementById('prompt-container');
            const promptMessage = document.querySelector('.prompt-message');
            const promptSlider = document.getElementById('prompt-slider');
            const okButton = document.getElementById('prompt-ok');
            const cancelButton = document.getElementById('prompt-cancel');

            // Set the message
            promptMessage.textContent = message;

            // Show the prompt container
            promptContainer.classList.remove('hidden');

            // Focus on input
            promptSlider.focus();

            // OK button click event
            okButton.onclick = function () {
                promptContainer.classList.add('hidden');
                callback(promptSlider.value);
            };

            // Cancel button click event
            cancelButton.onclick = function () {
                promptContainer.classList.add('hidden');
                callback(null);
            };
        }

        // Creates buttons for data scraping and filtering, adds event listeners, and builds a custom prompt for user input
        function createButtons(){
            // Select the controls-block where we want to add new elements
            var controlsBlock = document.querySelector(".controls-block");

            // Create the data scraper button
            var scraper = document.createElement('a');
            scraper.id = "data-scraper";
            scraper.setAttribute('data-tooltip', 'get filter data');

            var scraperIcon = document.createElement('i');
            scraper.appendChild(scraperIcon);
            scraperIcon.className = 'fas fa-download';

            // Create the watched thread filter button
            var filter = document.createElement('a');
            filter.id = "watched-filter";
            filter.setAttribute('href', '#/cat=games/page=1');
            filter.setAttribute('rel', 'ajax');
            filter.setAttribute('data-tooltip', 'filter watched');
            if(GM_getValue('watchedFilter')){$(filter).addClass("selected");}

            var filterIcon = document.createElement('i');
            filter.appendChild(filterIcon);
            filterIcon.className = 'fa fa-eye';


            const rangeToString = (v) => {switch(true) {
                case (v == 1):
                    return "yesterday";
                case v <= 6:
                    return v + " days";
                case v == 7:
                    return "1 week";
                case v <= 10:
                    return (v-6) + " weeks";
                case v == 11:
                    return "1 month";
                case v <= 21:
                    return (v-10) + " months";
                case v == 22:
                    return "1 year";
                case v >= 23:
                    return (v - 21) + " years";
            } }

            // Add listeners to the buttons
            const onClickScraper = event => {
                if(GM_getValue('watchedFilter', false)){
                    window.alert("can't get the data while the watched filter is checked"); //need to resolve that? ¤¤
                }else{
                    var nbThreads;
                    const f = (m) => showPrompt(m, function(d) {
                        const date = stringToDate(rangeToString(d));
                        if(!(d == null)){
                            buildDB(1, GM_getValue('pagination', { page: 1, total: 1, threads: 0, loadTime: 100}).total, new Date(date));
                        }
                    });
                    f("Select until when you want to search the threads for your watched ones");

                }
            };
            scraper.addEventListener("click", onClickScraper);

            const onClickWatched = event => {
                var e = $(event.currentTarget);
                e.hasClass("selected") ? (e.removeClass("selected")) : (e.addClass("selected"))
                GM_setValue('watchedFilter', e.hasClass("selected"));
            };
            filter.addEventListener("click", onClickWatched);

            // Insert the buttons elements into the controls block
            var autoRefreshControl = document.getElementById("controls_auto-refresh");
            controlsBlock.insertBefore(scraper, autoRefreshControl);
            controlsBlock.insertBefore(filter, autoRefreshControl);



            // Create container div
            const promptContainer = document.createElement('div');
            promptContainer.id = 'prompt-container';
            promptContainer.className = 'hidden';

            // Create the box div inside the container
            const promptBox = document.createElement('div');
            promptBox.className = 'prompt-box';

            // Create the input field
            const promptSlider = document.createElement('input');
            promptSlider.type = 'range';
            promptSlider.min = 1;
            promptSlider.max = (new Date().getYear() - new Date('2016').getYear()+21);
            promptSlider.value = "1";
            promptSlider.id = 'prompt-slider';
            promptSlider.className = 'prompt-slider';
            promptSlider.oninput = function() {
                promptMessage.textContent = "search threads : < "+rangeToString(this.value);
            }

            // Create the message paragraph
            const promptMessage = document.createElement('p');
            promptMessage.className = 'prompt-message';
            promptMessage.textContent = "search threads : < "+rangeToString(promptSlider.value);

            // Create the buttons div
            const promptButtons = document.createElement('div');
            promptButtons.className = 'prompt-buttons';

            // Create the OK button
            const okButton = document.createElement('button');
            okButton.id = 'prompt-ok';
            okButton.className = 'prompt-button';
            okButton.textContent = 'OK';

            // Create the Cancel button
            const cancelButton = document.createElement('button');
            cancelButton.id = 'prompt-cancel';
            cancelButton.className = 'prompt-button';
            cancelButton.textContent = 'Cancel';

            // Append the buttons to the button container
            promptButtons.appendChild(okButton);
            promptButtons.appendChild(cancelButton);

            // Append message, input, and buttons to the prompt box
            promptBox.appendChild(promptMessage);
            promptBox.appendChild(promptSlider);
            promptBox.appendChild(promptButtons);

            // Append the prompt box to the container
            promptContainer.appendChild(promptBox);

            // Append the prompt container to the body of the document
            document.body.appendChild(promptContainer);
        }

        // Automatically updates the database based on the last time it was updated
        function autoUpdateDB(date=null) {
            const pTotal = Number(GM_getValue('pagination', {page: 1, total: 1, threads: 0, loadTime: 100}).total);
            if(date === null){
                buildDB(1, pTotal+1);
            }else{
                buildDB(1, pTotal, new Date(date)); //10 or pagination.total ?
            }
        }

        var pagination = GM_getValue('pagination', { page: 1, total: 1, threads: 0, loadTime: 100});

        function init() {
            GM_setValue('watchedFilter', false);
            getPageData(1).then(function(result) {
                pagination = {page: result.data.pagination.page, total: result.data.pagination.total, threads: result.data.count, loadTime: result.time};
                GM_setValue('pagination', pagination);
                autoUpdateDB(latestDownloadDate);
            }).catch(function(error) {
                console.error(error);
            });
            createButtons();
        }

        //GM_setValue('latestDownloadDate', "Mon Jan 22 2024 21:27:49 GMT+0200");
        //GM_setValue('latestDownloadDate', null);
        init();
    });

})();