javdb Re-sort

Fetch all pages, filter out movies rated below 4.3 or with fewer than 500 reviews, sort remainder by review count descending

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         javdb Re-sort
// @namespace    http://tampermonkey.net/
// @version      1.3
// @description  Fetch all pages, filter out movies rated below 4.3 or with fewer than 500 reviews, sort remainder by review count descending
// @author       You
// @match        https://javdb.com/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=javdb.com
// @grant        none
// @license      MIT
// ==/UserScript==

(function () {
    'use strict';

    // ── Regex patterns ──
    const peoplePattern = /由(\d+)人/;   // Extract review count from Chinese text "由123人"
    const scorePattern  = /(\d+\.\d+)/;  // Extract numeric rating e.g. "4.56"
    const pagePattern   = /[?&]page=(\d+)/;

    // ── Filter thresholds (edit these to adjust) ──
    const MIN_SCORE  = 4.3;   // Minimum rating
    const MIN_PEOPLE = 500;   // Minimum number of reviews

    // ── Skip favourites / collection pages ──
    if (/\/(users\/[^/]+\/)?(collected|favourites|watchlist|want_watches|watched|reviews|history)/i.test(window.location.pathname)) return;

    const movieList = $('.movie-list')[0];
    if (!movieList) return; // Not a list page, exit early

    // ── Get total number of pages from pagination ──
    function getTotalPages() {
        // Primary: read last pagination link
        const lastPageLink = $('.pagination-list .pagination-link:last-child, .pagination a:last-of-type');
        if (lastPageLink && lastPageLink.length) {
            const href = lastPageLink.last().attr('href') || '';
            const m = pagePattern.exec(href);
            if (m) return parseInt(m[1], 10);
        }
        // Fallback: scan all pagination links for the highest page number
        const links = $('.pagination-link');
        let max = 1;
        links.each(function () {
            const m = pagePattern.exec($(this).attr('href') || '');
            if (m) max = Math.max(max, parseInt(m[1], 10));
        });
        return max;
    }

    // ── Read the current page number from the URL ──
    function getCurPage() {
        const m = pagePattern.exec(window.location.href);
        return m ? parseInt(m[1], 10) : 1;
    }

    // ── Build a URL for the given page number ──
    function getPageUrl(page) {
        const url = window.location.href;
        if (pagePattern.test(url)) {
            return url.replace(pagePattern, (match) => match.replace(/\d+$/, page));
        }
        return url + (url.includes('?') ? `&page=${page}` : `?page=${page}`);
    }

    // ── Parse review count and rating from a .item element ──
    function parseItem(item) {
        const scoreEl = $('.score', item)[0];
        if (!scoreEl) return null;

        const text        = scoreEl.textContent;
        const peopleMatch = peoplePattern.exec(text);
        const scoreMatch  = scorePattern.exec(text);

        if (!peopleMatch || !scoreMatch) return null;

        return {
            peopleCnt: parseInt(peopleMatch[1], 10),
            rating:    parseFloat(scoreMatch[1]),
        };
    }

    // ── Fetch a page via jQuery $.get, return a Promise of .item elements ──
    function fetchPage(page) {
        return new Promise((resolve) => {
            $.get(getPageUrl(page), function (data) {
                const container = document.createElement('div');
                container.innerHTML = data;
                resolve(Array.from($('.movie-list .item', container)));
            }).fail(() => resolve([]));
        });
    }

    // ── Highlight rating (green) and review count (red) in the score element ──
    function highlightScore(item) {
        const scoreEl = $('.score', item)[0];
        if (!scoreEl) return;
        scoreEl.innerHTML = scoreEl.innerHTML
            .replace(peoplePattern, `由<span style="color:#e74c3c;font-weight:bold">$1</span>人`)
            .replace(scorePattern,  `<span style="color:#27ae60;font-weight:bold">$1</span>`);
    }

    // ── Render the filtered list into the movie list container ──
    function renderList(items) {
        movieList.innerHTML = '';
        items.forEach(item => movieList.append(item));
    }

    // ── Fixed status bar shown at the top of the page ──
    function createStatusBar() {
        const bar = document.createElement('div');
        bar.id = 'reorder-status';
        bar.style.cssText = `
            position: fixed; top: 0; left: 0; right: 0; z-index: 99999;
            background: rgba(0,0,0,0.82); color: #fff;
            font-size: 14px; padding: 8px 16px;
            display: flex; align-items: center; gap: 12px;
            backdrop-filter: blur(4px);
        `;
        bar.innerHTML = `
            <span id="rs-spinner" style="display:inline-block;width:14px;height:14px;border:2px solid #fff;
                border-top-color:transparent;border-radius:50%;animation:rs-spin 0.7s linear infinite;"></span>
            <span id="rs-text">Initializing...</span>
            <style>@keyframes rs-spin{to{transform:rotate(360deg)}}</style>
        `;
        document.body.prepend(bar);
        return {
            update(msg) { document.getElementById('rs-text').textContent = msg; },
            done(msg) {
                document.getElementById('rs-spinner').style.display = 'none';
                document.getElementById('rs-text').textContent = msg;
                setTimeout(() => bar.remove(), 4000);
            }
        };
    }

    // ── Hide the original pagination to avoid confusion ──
    function hidePagination() {
        $('.pagination, nav.pagination').hide();
    }

    // ══════════════════════════════
    //  Main flow
    // ══════════════════════════════
    async function main() {
        const status = createStatusBar();
        hidePagination();

        const curPage    = getCurPage();
        const totalPages = getTotalPages();

        status.update(`Detected ${totalPages} page(s). Starting fetch...`);

        // Seed with items already on the current page
        let allItems = Array.from($('.item', movieList));

        // Build list of pages to fetch (skip current page, already loaded)
        const pagesToFetch = [];
        for (let p = 1; p <= totalPages; p++) {
            if (p !== curPage) pagesToFetch.push(p);
        }

        // Fetch in batches of 5 to avoid rate limiting
        let loaded = 1;
        const BATCH = 5;
        for (let i = 0; i < pagesToFetch.length; i += BATCH) {
            const batch   = pagesToFetch.slice(i, i + BATCH);
            const results = await Promise.all(batch.map(p => fetchPage(p)));
            results.forEach(items => allItems = allItems.concat(items));
            loaded += batch.length;
            status.update(`Loaded ${Math.min(loaded, totalPages)} / ${totalPages} pages — ${allItems.length} items so far...`);
        }

        // Parse metadata for each item.
        // Items whose score/count can't be read (e.g. favorites page uses a
        // different layout) are kept as-is and sorted to the bottom, rather
        // than being silently discarded.
        allItems.forEach(item => {
            const data = parseItem(item);
            if (data) {
                item._peopleCnt = data.peopleCnt;
                item._rating    = data.rating;
                item._parsed    = true;
            } else {
                item._peopleCnt = 0;
                item._rating    = Infinity; // bypass rating filter for unreadable items
                item._parsed    = false;
            }
        });

        // Apply filters only to items whose metadata could be read
        const filtered = allItems.filter(item =>
            !item._parsed || (item._rating >= MIN_SCORE && item._peopleCnt >= MIN_PEOPLE)
        );

        // Sort: parsed items by review count descending; unparsed items fall to the bottom
        filtered.sort((a, b) => {
            if (a._parsed && b._parsed) return b._peopleCnt - a._peopleCnt;
            if (a._parsed) return -1;
            if (b._parsed) return 1;
            return 0;
        });

        // Highlight scores and render
        filtered.forEach(item => { if (item._parsed) highlightScore(item); });
        renderList(filtered);

        const removedCount   = allItems.length - filtered.length;
        const unparsedCount  = filtered.filter(i => !i._parsed).length;
        const unparsedNote   = unparsedCount ? ` (${unparsedCount} item(s) kept without score data)` : '';
        status.done(
            `✅ All ${totalPages} pages loaded — ${allItems.length} total → removed ${removedCount} (rating < ${MIN_SCORE} or reviews < ${MIN_PEOPLE}) → ${filtered.length} remaining, sorted by review count${unparsedNote}`
        );
    }

    main();

})();