StripChat Special Models Tracker

Track and manage special models on StripChat without AI filtering.

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

You will need to install an extension such as Tampermonkey to install this script.

Necesitarás instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Necesitará instalar una extensión como Tampermonkey para instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

// ==UserScript==
// @name         StripChat Special Models Tracker
// @namespace    http://tampermonkey.net/
// @version      1.1
// @description  Track and manage special models on StripChat without AI filtering.
// @description  For a browser extension advanced version of this script with
// @description  thumbnail based AI filtering on models showing tits, armpits, butt, vagina, asshole (anus),
// @description  and more features, contact through email: [email protected]
// @author       You
// @match        https://stripchat.com/*
// @match        https://www.stripchat.com/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_xmlhttpRequest
// @license      MIT
// ==/UserScript==

(function () {
    'use strict';

    console.log('StripChat Special Models Tracker v1.0');

    // Intercept History API to detect SPA navigation
    (function (history) {
        var pushState = history.pushState;
        history.pushState = function () {
            pushState.apply(history, arguments);
            window.dispatchEvent(new Event('pushstate'));
        };
        var replaceState = history.replaceState;
        history.replaceState = function () {
            replaceState.apply(history, arguments);
            window.dispatchEvent(new Event('replacestate'));
        };
    })(window.history);

    // Listen for custom events and popstate to update button state
    window.addEventListener('pushstate', updateSpecialButtonState);
    window.addEventListener('replacestate', updateSpecialButtonState);
    window.addEventListener('popstate', updateSpecialButtonState);

    // Global variables
    let specialModels = new Map();
    let isFetching = false;
    let showSpecialModels = false;
    let filteredModelsByCategory = { 'SPECIAL': [], 'COUPLE': [], 'OTHER': [] };
    let collapsedCategories = new Set();

    // Logging
    function log(msg, type = 'info') {
        const colors = { info: '#4CAF50', warning: '#ff9800', error: '#f44336', debug: '#2196F3' };
        console.log(`%c[SpecialTracker] [${type.toUpperCase()}] ${msg}`, `color: ${colors[type] || '#ccc'}`);
    }

    function loadSpecialModels() {
        const defaultSpecialModels = [
            ["/mikakinamo", { "link": "/mikakinamo", "banned": false }],
            ["/ellieerose", { "link": "/ellieerose", "banned": false }],
            ["/vanessa_walters", { "link": "/vanessa_walters", "banned": false }],
            ["/amethystfoxx", { "link": "/amethystfoxx", "banned": true }],
            ["/whitefoxxie113", { "link": "/whitefoxxie113", "banned": false }]
        ];

        try {
            const saved = localStorage.getItem('specialModels');
            if (saved) {
                const parsedData = JSON.parse(saved);
                if (Array.isArray(parsedData)) {
                    specialModels = new Map(parsedData.map(([key, value]) => [key, { link: value.link || key, banned: value.banned || false }]));
                } else {
                    specialModels = new Map(defaultSpecialModels);
                }
            } else {
                specialModels = new Map(defaultSpecialModels);
                saveSpecialModels();
            }
        } catch (err) {
            log(`Error loading special models: ${err.message}`, 'error');
            specialModels = new Map(defaultSpecialModels);
        }
    }

    function saveSpecialModels() {
        try {
            const dataToSave = Array.from(specialModels.entries());
            localStorage.setItem('specialModels', JSON.stringify(dataToSave));
        } catch (err) {
            log(`Failed to save special models: ${err.message}`, 'error');
        }
    }

    function toggleSpecialModel() {
        const modelLink = window.location.pathname.replace(/\/+$/, '').toLowerCase();
        if (specialModels.has(modelLink)) {
            specialModels.delete(modelLink);
            log(`Removed ${modelLink} from special list`, 'info');
        } else {
            specialModels.set(modelLink, { link: modelLink, banned: false });
            log(`Added ${modelLink} to special list`, 'info');
        }
        saveSpecialModels();
        updateSpecialButtonState();
    }

    function updateSpecialButtonState() {
        const btn = document.getElementById('toggle-special-btn');
        if (!btn) return;
        const modelLink = window.location.pathname.replace(/\/+$/, '').toLowerCase();
        const model = specialModels.get(modelLink);

        if (model) {
            btn.textContent = `Remove Special (${model.banned ? 'Banned' : 'OK'})`;
            btn.style.background = model.banned ? '#f44336' : '#ffd700';
            btn.style.color = model.banned ? '#fff' : '#000';
        } else {
            btn.textContent = 'Add to Special';
            btn.style.background = '#9c27b0';
            btn.style.color = '#fff';
        }
    }

    function createUI() {
        const targetDiv = document.querySelector('[data-testid="viewcam-model-sections"]');
        if (!targetDiv || document.getElementById('special-models-section')) return;

        const section = document.createElement('div');
        section.id = 'special-models-section';
        section.style.cssText = `margin: 20px 0; padding: 15px; background: rgb(80, 30, 30); border-radius: 12px; box-shadow: 0 4px 15px rgba(0,0,0,0.2);`;

        const viewCamPageMain = document.querySelector('.view-cam-page-main');
        if (viewCamPageMain) {
            viewCamPageMain.parentNode.insertBefore(section, viewCamPageMain.nextSibling);
        } else {
            targetDiv.parentNode.insertBefore(section, targetDiv);
        }

        section.innerHTML = `
            <button id="special-models-toggle" style="background: rgba(255,255,255,0.0); border: 2px solid rgba(255,255,255,0.3); color: white; padding: 10px 24px; border-radius: 8px; font-size: 16px; font-weight: bold; cursor: pointer; transition: all 0.3s ease; backdrop-filter: blur(10px); display: flex; align-items: center; gap: 10px; width: 100%; justify-content: center;">
                <span style="font-size: 18px;">⭐</span>
                <span id="toggle-text">Show Favorites Library</span>
                <span id="special-count-badge" style="background: rgba(255,255,255,0.3); padding: 4px 12px; border-radius: 20px; font-size: 14px; margin-left: auto;">0</span>
            </button>
            <div id="special-models-container" style="display: none; margin-top: 0px; padding-top: 15px;">
                <div style="display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 15px;">
                    <button id="refresh-specials-btn" style="background: #2196F3; border: none; color: white; padding: 8px 16px; border-radius: 6px; font-size: 12px; cursor: pointer;">Refresh Now</button>
                    <button id="edit-specials-btn" style="background: #ff9800; border: none; color: white; padding: 8px 16px; border-radius: 6px; font-size: 12px; cursor: pointer;">Edit Specials</button>
                    <button id="toggle-special-btn" style="background: #9c27b0; border: none; color: white; padding: 8px 16px; border-radius: 6px; font-size: 12px; cursor: pointer;">Add to Special</button>
                </div>
                <div id="special-models-grid" style="display: flex; flex-direction: column; gap: 20px; max-height: 600px; overflow-y: auto; padding-right: 10px;"></div>
            </div>
        `;

        document.getElementById('special-models-toggle').addEventListener('click', () => {
            showSpecialModels = !showSpecialModels;
            document.getElementById('special-models-container').style.display = showSpecialModels ? 'block' : 'none';
            if (showSpecialModels) fetchSpecials();
        });

        document.getElementById('refresh-specials-btn').addEventListener('click', fetchSpecials);
        document.getElementById('edit-specials-btn').addEventListener('click', showEditSpecialsUI);
        document.getElementById('toggle-special-btn').addEventListener('click', toggleSpecialModel);

        ensureStyles();
        updateSpecialButtonState();
    }

    function ensureStyles() {
        if (document.getElementById('special-tracker-styles')) return;
        const style = document.createElement('style');
        style.id = 'special-tracker-styles';
        style.textContent = `
            .category-section {
                display: flex; flex-direction: column; gap: 10px;
            }
            .category-header {
                color: #fff; font-size: 14px; font-weight: bold; padding: 10px 15px;
                background: rgba(255,255,255,0.06); border-radius: 8px; border-left: 4px solid #ffd700;
                cursor: pointer; display: flex; align-items: center; gap: 10px;
                position: sticky; top: 0; z-index: 10; backdrop-filter: blur(10px);
                user-select: none; transition: background 0.2s ease;
            }
            .category-header:hover { background: rgba(255,255,255,0.1); }
            .models-subgrid {
                display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 10px;
            }
            .special-model-card {
                position: relative; overflow: hidden; text-decoration: none; color: inherit;
                transition: transform 0.2s ease, box-shadow 0.2s ease;
            }
            .special-model-card:hover { transform: translateY(-4px); box-shadow: 0 8px 25px rgba(0,0,0,0.3); }
        `;
        document.head.appendChild(style);
    }

    async function checkBroadcastsStatus(username) {
        try {
            const url = `https://stripchat.com/api/front/v1/broadcasts/${username}`;
            const resp = await fetch(url, { headers: { 'Accept': 'application/json', 'Referer': 'https://stripchat.com/' } });
            if (resp.status === 200) {
                const data = await resp.json();
                if (data.item && data.item.isLive) return data.item;
            }
        } catch (e) { }
        return null;
    }

    async function fetchSpecials() {
        if (isFetching) return;
        isFetching = true;
        log('Fetching favorites status...', 'info');
        filteredModelsByCategory = { 'SPECIAL': [], 'COUPLE': [], 'OTHER': [] };

        try {
            const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
            const toff = new Date().getTimezoneOffset();
            const common = { 'Accept': '*/*', 'Content-Type': 'application/json', 'Referer': 'https://stripchat.com/favorites' };

            const dynUrl = `https://stripchat.com/api/front/v3/config/dynamic?uniq=${Math.random().toString(36).slice(2)}`;
            const dyn = await fetch(dynUrl, { credentials: 'include', headers: common });
            const dynData = await dyn.json();
            const token = dynData?.dynamic?.jwtToken || '';

            const favUrl = `https://stripchat.com/api/front/models/favorites?offset=0&sortBy=bestMatch&limit=100&uniq=${Math.random().toString(36).slice(2)}`;
            const headers = { ...common, Authorization: token };
            const fav = await fetch(favUrl, { credentials: 'include', headers });
            const favData = await fav.json();

            (favData.models || []).forEach(m => {
                const link = `/${m.username}`.toLowerCase();
                const special = specialModels.get(link);
                let thumb = m.previewUrlThumbSmall || m.previewUrl;
                if (m.snapshotTimestamp && m.id) thumb = `https://img.doppiocdn.com/thumbs/${m.snapshotTimestamp}/${m.id}`;

                const modelData = {
                    thumbnail: thumb,
                    modelName: m.username,
                    modelLink: link,
                    isOnline: (m.isLive || m.isOnline),
                    status: m.status || 'public',
                    isBanned: special ? special.banned : false
                };

                if (special) {
                    filteredModelsByCategory['SPECIAL'].push(modelData);
                } else if (m.gender === 'maleFemale') {
                    filteredModelsByCategory['COUPLE'].push(modelData);
                } else {
                    filteredModelsByCategory['OTHER'].push(modelData);
                }
            });

            // 2. Check banned specials not in favorites
            const bannedPromises = [];
            specialModels.forEach((model, link) => {
                if (model.banned && !filteredModelsByCategory['SPECIAL'].some(m => m.modelLink === link)) {
                    bannedPromises.push((async () => {
                        const live = await checkBroadcastsStatus(link.replace(/^\//, ''));
                        if (live) {
                            filteredModelsByCategory['SPECIAL'].push({
                                thumbnail: `https://img.doppiocdn.com/thumbs/${live.snapshotTimestamp}/${live.modelId}`,
                                modelName: live.username,
                                modelLink: link,
                                isOnline: true,
                                status: live.status || 'public',
                                isBanned: true
                            });
                        }
                    })());
                }
            });
            await Promise.all(bannedPromises);

            renderGrid();
        } catch (e) {
            log(`Error fetching favorites: ${e.message}`, 'error');
        } finally {
            isFetching = false;
        }
    }

    function renderGrid() {
        const grid = document.getElementById('special-models-grid');
        const badge = document.getElementById('special-count-badge');
        if (!grid) return;

        const total = Object.values(filteredModelsByCategory).reduce((acc, cat) => acc + cat.length, 0);
        badge.textContent = total;

        if (total === 0) {
            grid.innerHTML = '<div style="color:white;text-align:center;padding:20px;">No live favorites found.</div>';
            return;
        }

        const sections = [
            { id: 'SPECIAL', name: 'Special Models', color: '#ffd700' },
            { id: 'COUPLE', name: 'Couple Content', color: '#ff69b4' },
            { id: 'OTHER', name: 'Other Live Favorites', color: '#4CAF50' }
        ];

        grid.innerHTML = '';
        sections.forEach(sec => {
            const models = filteredModelsByCategory[sec.id];
            if (models.length === 0) return;

            const isCollapsed = collapsedCategories.has(sec.id);
            const container = document.createElement('div');
            container.className = 'category-section';

            const header = document.createElement('div');
            header.className = 'category-header';
            header.style.borderLeftColor = sec.color;
            header.innerHTML = `
                <span style="transition: transform 0.3s ease; transform: rotate(${isCollapsed ? '-90' : '0'}deg); color: ${sec.color}">▼</span>
                <span style="flex: 1;">${sec.name} (${models.length})</span>
                <span style="font-size: 11px; opacity: 0.6;">${isCollapsed ? 'click to expand' : 'click to collapse'}</span>
            `;

            header.onclick = () => {
                if (collapsedCategories.has(sec.id)) collapsedCategories.delete(sec.id);
                else collapsedCategories.add(sec.id);
                renderGrid();
            };

            container.appendChild(header);

            if (!isCollapsed) {
                const subgrid = document.createElement('div');
                subgrid.className = 'models-subgrid';
                subgrid.innerHTML = models.map(m => `
                    <a href="${m.modelLink}" class="special-model-card" style="${m.isBanned ? 'background: rgba(244, 67, 54, 0.15);' : ''}">
                        <div style="position: relative;">
                            <img src="${m.thumbnail}" style="width: 100%; height: 120px; object-fit: cover;">
                            ${m.status !== 'public' ? `<div style="position:absolute;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.6);display:flex;align-items:center;justify-content:center;color:white;font-size:12px;font-weight:bold;">${m.status.toUpperCase()}</div>` : ''}
                            <div style="position:absolute;bottom:0;width:100%;background:rgba(0,0,0,0.5);color:white;text-align:center;font-size:11px;padding:2px 0;">${m.modelName}</div>
                            ${m.isOnline ? `<div style="position:absolute;top:6px;right:6px;background:#4CAF50;color:white;padding:2px 6px;border-radius:10px;font-size:10px;font-weight:bold;">LIVE</div>` : ''}
                            ${m.isBanned ? `<div style="position:absolute;top:0;left:0;width:100%;height:100%;display:flex;align-items:center;justify-content:center;color:white;font-size:20px;font-weight:bold;">BANNED</div>` : ''}
                        </div>
                    </a>
                `).join('');

                subgrid.querySelectorAll('a').forEach(a => {
                    a.onclick = (e) => {
                        if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
                        e.preventDefault();
                        const link = a.getAttribute('href');
                        if (window.location.pathname !== link) {
                            history.pushState({}, '', link);
                            window.dispatchEvent(new Event('popstate'));
                        }
                    };
                });
                container.appendChild(subgrid);
            }
            grid.appendChild(container);
        });
    }

    function showEditSpecialsUI() {
        const existing = document.getElementById('specials-modal'); if (existing) existing.remove();
        const modal = document.createElement('div'); modal.id = 'specials-modal';
        modal.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.8);z-index:10000;display:flex;align-items:center;justify-content:center;';

        const listHTML = Array.from(specialModels.entries()).map(([link, m]) => `
            <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:8px; padding:10px; background:#f0f0f0; border-radius:4px; color: black;">
                <span style="flex:1;">${link}</span>
                <label style="font-size:12px; margin-right:15px;"><input type="checkbox" class="ban-toggle" data-link="${link}" ${m.banned ? 'checked' : ''}> Banned</label>
                <button class="del-special" data-link="${link}" style="background:#f44336; color:white; border:none; padding:5px 10px; border-radius:4px; cursor:pointer;">Delete</button>
            </div>
        `).join('');

        modal.innerHTML = `
            <div style="background:white; padding:20px; border-radius:8px; width:80%; max-width:600px; max-height:80%; overflow:auto; color:black;">
                <h3 style="margin-top:0;">Edit Special Models</h3>
                <div id="specials-list-container">${listHTML}</div>
                <div style="margin-top:20px; display:flex; gap:10px;">
                    <input id="new-special-link" placeholder="/username" style="flex:1; padding:8px;">
                    <button id="add-special-btn" style="background:#4CAF50; color:white; border:none; padding:8px 16px; border-radius:4px;">Add</button>
                    <button id="close-specials" style="background:#2196F3; color:white; border:none; padding:8px 16px; border-radius:4px;">Close</button>
                </div>
            </div>`;

        document.body.appendChild(modal);

        modal.querySelectorAll('.ban-toggle').forEach(cb => cb.onchange = () => {
            const m = specialModels.get(cb.dataset.link);
            if (m) { m.banned = cb.checked; saveSpecialModels(); updateSpecialButtonState(); }
        });
        modal.querySelectorAll('.del-special').forEach(btn => btn.onclick = () => {
            specialModels.delete(btn.dataset.link);
            saveSpecialModels(); updateSpecialButtonState(); showEditSpecialsUI();
        });
        modal.querySelector('#add-special-btn').onclick = () => {
            let link = modal.querySelector('#new-special-link').value.trim();
            if (!link) return;
            if (!link.startsWith('/')) link = '/' + link;
            specialModels.set(link.toLowerCase(), { link: link.toLowerCase(), banned: false });
            saveSpecialModels(); updateSpecialButtonState(); showEditSpecialsUI();
        };
        modal.querySelector('#close-specials').onclick = () => modal.remove();
        modal.onclick = (e) => { if (e.target === modal) modal.remove(); };
    }

    function init() {
        loadSpecialModels();

        const observer = new MutationObserver(() => {
            if (!document.getElementById('special-models-section')) {
                if (document.querySelector('[data-testid="viewcam-model-sections"]') || document.querySelector('.view-cam-page-main')) {
                    createUI();
                }
            }
        });
        observer.observe(document.body, { childList: true, subtree: true });

        // Initial try
        createUI();
    }

    init();

})();