StripChat Special Models Tracker

Track and manage special models on StripChat without AI filtering.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==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();

})();