Letterboxd ListSearch Plus

Search and filter Letterboxd lists with advanced options

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Letterboxd ListSearch Plus
// @namespace    https://greasyfork.org/users/1484969
// @license      MIT
// @version      1.0.2
// @description  Search and filter Letterboxd lists with advanced options
// @match        https://letterboxd.com/*/list/*
// @exclude      https://letterboxd.com/*/list/*/page*
// @exclude      https://letterboxd.com/*/list/*/edit*
// @exclude      https://letterboxd.com/*/list/*/stats*
// @exclude      https://letterboxd.com/*/list/*/detail*
// @grant        none
// ==/UserScript==

(function() {
    'use strict';

    const style = document.createElement('style');
    style.textContent = `
.user-search-wrapper {
    margin: 20px 0;
    display: flex;
    justify-content: center;
    align-items: center;
    position: relative;
}

.load-status {
    margin-right: 8px;
    display: flex;
    align-items: center;
    font: 13px Graphik-Regular-Web, sans-serif;
    color: #ccc;
}

.spinner {
    display: inline-block;
    width: 16px;
    height: 16px;
    min-width: 16px;
    min-height: 16px;
    border: 2px solid rgba(255,255,255,0.4);
    border-top-color: #00ac1c;
    border-radius: 50%;
    animation: spin 0.8s linear infinite;
    margin-right: 6px;
    box-sizing: border-box;
    flex-shrink: 0;
}

@keyframes spin {
    from { transform: rotate(0deg); }
    to   { transform: rotate(360deg); }
}

.user-search {
    width: min(600px, 100%);
    padding: 0 48px 0 16px;
    height: 40px;
    font: 14px/1.2 Graphik-Regular-Web, sans-serif;
    color: #222;
    background: #fff;
    border: 1px solid #d3d3d3;
    border-radius: 20px;
    box-shadow: inset 0 1px 3px rgba(0,0,0,0.1);
    transition: border-color .2s, box-shadow .2s;
}

.user-search:focus {
    border-color: #3a7ca5;
    box-shadow: 0 0 0 2px rgba(58,124,165,0.3);
    outline: none;
}

.user-search-button {
    position: absolute;
    right: 16px;
    top: 50%;
    width: 30px;
    height: 30px;
    transform: translateY(-50%);
    border: none;
    background: url('https://s.ltrbxd.com/static/img/sprite-Cmcg-tqK.svg') no-repeat;
    background-size: 800px 1020px;
    background-position: -100px -170px;
    cursor: pointer;
    background-color: transparent;
    text-indent: -9999px;
}

.user-advanced-toggle {
    position: absolute;
    right: 60px;
    top: 50%;
    transform: translateY(-50%);
    border-radius: 20px;
    padding: 0 8px;
    font: 14px/1.2 Graphik-Regular-Web, sans-serif;
    cursor: pointer;
    background-color: #f0f0f0;
    color: #555;
    border: 1px solid #d3d3d3;
}

.user-search-wrapper.advanced-mode .user-advanced-toggle {
    background-color: #00ac1c;
    color: #fff;
    border-color: #00ac1c;
}
`;
    document.head.appendChild(style);

    const wrapper = document.createElement('div');
    wrapper.className = 'user-search-wrapper';

    const status = document.createElement('div');
    status.className = 'load-status';
    const spinner = document.createElement('div');
    spinner.className = 'spinner';
    const statusText = document.createElement('span');
    statusText.textContent = 'Loading… (0 movies)';
    status.append(spinner, statusText);

    const input = document.createElement('input');
    input.className = 'user-search';
    input.placeholder = 'Search list…';

    const advBtn = document.createElement('button');
    advBtn.className = 'user-advanced-toggle';
    advBtn.type = 'button';
    advBtn.textContent = 'Advanced';
    advBtn.setAttribute('aria-label', 'Toggle advanced search');

    const btn = document.createElement('button');
    btn.className = 'user-search-button';
    btn.type = 'button';
    btn.setAttribute('aria-label', 'Search');

    wrapper.append(status, input, advBtn, btn);

    const listEl = document.querySelector('.js-list-entries');
    if (listEl && listEl.parentNode) {
        listEl.parentNode.insertBefore(wrapper, listEl);
    } else {
        const fallback = document.querySelector('.section > .tags') || document.querySelector('.sidebar');
        fallback && fallback.insertBefore(wrapper, fallback.firstChild);
    }

    input.disabled = btn.disabled = advBtn.disabled = true;

    let advancedMode = false;
    advBtn.addEventListener('click', () => {
        advancedMode = !advancedMode;
        wrapper.classList.toggle('advanced-mode', advancedMode);
        // console.log('Advanced search mode:', advancedMode);
    });

    const parser = new DOMParser();
    async function getDom(i) {
        const res = await fetch(`${window.location.href}page/${i}/`);
        const doc = parser.parseFromString(await res.text(), 'text/html');
        const movies = doc.querySelectorAll('.js-list-entries > li');
        // console.log(i, movies);
        return movies.length ? Array.from(movies) : undefined;
    }

    const container = document.querySelector('.js-list-entries');
    const items = Array.from(container.querySelectorAll('li'));
    const nodelists = [];

    let loadedCount = items.length;
    statusText.textContent = `Loading… (${loadedCount} movies)`;

    (async () => {
        for (let i = 2; i <= 100; i++) {
            const page = await getDom(i);
            if (!page) break;
            nodelists.push(page);
            loadedCount += page.length;
            statusText.textContent = `Loading… (${loadedCount} movies)`;
        }
        spinner.remove();
        statusText.textContent = `Loaded ${loadedCount} movies`;
        input.disabled = btn.disabled = advBtn.disabled = false;
    })();

    function parseQuery(input) {
        const tokens = input.match(/-?"[^"]*"|[()|]|-?[^()\s|]+/g) || [];
        let pos = 0;
        function peek() { return tokens[pos]; }
        function consume(tok) {
            if (!tok || peek() === tok) pos++;
            else throw new Error(`Expected ${tok} but got ${peek()}`);
        }
        function parseExpression() { return parseOr(); }
        function parseOr() {
            let node = parseAnd();
            while (peek() === '|') {
                consume('|');
                const right = parseAnd();
                node = { type: 'OR', children: [node, right] };
            }
            return node;
        }
        function parseAnd() {
            let node = parseNot();
            while (peek() && peek() !== ')' && peek() !== '|') {
                const right = parseNot();
                node = { type: 'AND', children: [node, right] };
            }
            return node;
        }
        function parseNot() {
            const tok = peek();
            if (tok && tok.startsWith('-') && tok.length > 1) {
                consume();
                const sub = tok.slice(1);
                if (sub.startsWith('"') && sub.endsWith('"')) {
                    const phrase = sub.slice(1, -1);
                    return { type: 'NOT', child: { type: 'TERM', value: phrase.toLowerCase() } };
                }
                return { type: 'NOT', child: { type: 'TERM', value: sub.toLowerCase() } };
            }
            return parseTerm();
        }
        function parseTerm() {
            const tok = peek();
            if (tok === '(') {
                consume('(');
                const node = parseExpression();
                if (peek() === ')') consume(')');
                return node;
            }
            if (!tok) throw new Error('Unexpected end of input');
            consume();
            if (tok.startsWith('"') && tok.endsWith('"')) {
                const phrase = tok.slice(1, -1);
                return { type: 'TERM', value: phrase.toLowerCase() };
            }
            return { type: 'TERM', value: tok.toLowerCase() };
        }
        const ast = parseExpression();
        if (pos < tokens.length) throw new Error('Unexpected token: ' + peek());
        return ast;
    }
    function evaluateAST(node, text) {
        switch (node.type) {
            case 'TERM': return text.includes(node.value);
            case 'NOT':  return !evaluateAST(node.child, text);
            case 'AND':  return node.children.every(c => evaluateAST(c, text));
            case 'OR':   return node.children.some(c => evaluateAST(c, text));
        }
    }

    function normalizeText(str) {
        return str
            .normalize('NFD')
            .replace(/[\u0300-\u036f]/g, '')
            .toLowerCase();
    }

    function getSearchText(li) {
        const ds  = li.childNodes[1]?.dataset.filmName || '';
        const alt = li.querySelector('div > img')?.alt || '';
        return normalizeText(ds + '|' + alt);
    }

    function triggerLazyLoad() {
        requestAnimationFrame(() => {
            window.dispatchEvent(new Event('scroll'));
        });
    }

    function doSearch() {
        const raw = input.value.trim();
        const normTerm = normalizeText(raw);
        container.innerHTML = '';

        if (!raw) {
            items.forEach(i => container.appendChild(i));
            statusText.textContent = `Showing ${loadedCount} of ${loadedCount} movies`;
            triggerLazyLoad();
            return;
        }

        if (advancedMode) {
            let ast;
            try {
                ast = parseQuery(normTerm);
            } catch {
                items.forEach(li => {
                    const name = getSearchText(li);
                    if (name.includes(normTerm)) container.appendChild(li);
                });
                nodelists.forEach(list => list.forEach(li => {
                    const name = getSearchText(li);
                    if (name.includes(normTerm)) container.appendChild(li);
                }));
                statusText.textContent = `Showing ${container.children.length} of ${loadedCount} movies`;
                triggerLazyLoad();
                return;
            }
            items.forEach(li => {
                const name = getSearchText(li);
                if (evaluateAST(ast, name)) container.appendChild(li);
            });
            nodelists.forEach(list => list.forEach(li => {
                const name = getSearchText(li);
                if (evaluateAST(ast, name)) container.appendChild(li);
            }));
            statusText.textContent = `Showing ${container.children.length} of ${loadedCount} movies`;
            triggerLazyLoad();
            return;
        }

        items.forEach(li => {
            const name = getSearchText(li);
            if (name.includes(normTerm)) container.appendChild(li);
        });
        nodelists.forEach(list => list.forEach(li => {
            const name = getSearchText(li);
            if (name.includes(normTerm)) container.appendChild(li);
        }));
        statusText.textContent = `Showing ${container.children.length} of ${loadedCount} movies`;
        triggerLazyLoad();
    }

    input.addEventListener('keypress', e => { if (e.key === 'Enter') doSearch(); });
    btn.addEventListener('click', doSearch);

})();