OnlyFans Chat Exporter (CSV)

Extracts chat history to CSV. Uses robust message-count tracking, strict time parsing, auto-sorting, and two-stage loader checks.

Bu betiği kurabilmeniz için Tampermonkey, Greasemonkey ya da Violentmonkey gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği yüklemek için Tampermonkey gibi bir uzantı yüklemeniz gerekir.

Bu betiği kurabilmeniz için Tampermonkey ya da Violentmonkey gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği kurabilmeniz için Tampermonkey ya da Userscripts gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği indirebilmeniz için ayrıca Tampermonkey gibi bir eklenti kurmanız gerekmektedir.

Bu komut dosyasını yüklemek için bir kullanıcı komut dosyası yöneticisi uzantısı yüklemeniz gerekecek.

(Zaten bir kullanıcı komut dosyası yöneticim var, kurmama izin verin!)

Bu stili yüklemek için Stylus gibi bir uzantı yüklemeniz gerekir.

Bu stili yüklemek için Stylus gibi bir uzantı kurmanız gerekir.

Bu stili yükleyebilmek için Stylus gibi bir uzantı yüklemeniz gerekir.

Bu stili yüklemek için bir kullanıcı stili yöneticisi uzantısı yüklemeniz gerekir.

Bu stili yüklemek için bir kullanıcı stili yöneticisi uzantısı kurmanız gerekir.

Bu stili yükleyebilmek için bir kullanıcı stili yöneticisi uzantısı yüklemeniz gerekir.

(Zateb bir user-style yöneticim var, yükleyeyim!)

// ==UserScript==
// @name         OnlyFans Chat Exporter (CSV)
// @namespace    https://greasyfork.org/en/users/318296-thomased
// @version      1.3.3
// @description  Extracts chat history to CSV. Uses robust message-count tracking, strict time parsing, auto-sorting, and two-stage loader checks.
// @author       Gemini, Claude Sonnet 4.6 Thinking
// @license      MIT
// @icon         https://static2.onlyfans.com/static/prod/f/202512181451-75a62e2193/icons/favicon-32x32.png
// @match        https://onlyfans.com/*
// @grant        GM_registerMenuCommand
// @run-at       document-idle
// ==/UserScript==

(function() {
    'use strict';

    const UI_ID = 'of-csv-exporter-ui';
    const UI_CONFIG = {
        bgColor: '#e0f5fd',
        textColor: '#333333',
        borderColor: '#b0d5e8'
    };

    let isExporting = false;
    let allMessages = [];
    let seenHashes = new Set();
    
    const IDLE_LIMIT = 5; 

    GM_registerMenuCommand("Start Chat Export", startAutoScrollAndExport);

    setInterval(() => {
        const isChatPage = window.location.href.includes('/my/chats/chat/');
        const uiElement = document.getElementById(UI_ID);

        if (isChatPage) {
            if (!uiElement) {
                initUI();
            } else {
                uiElement.style.display = 'flex';
            }
        } else {
            if (uiElement) {
                uiElement.style.display = 'none';
            }
        }
    }, 1000);

    function initUI() {
        if (document.getElementById(UI_ID)) return;

        const container = document.createElement('div');
        container.id = UI_ID;
        container.style.position = 'fixed';
        container.style.right = '0px';
        container.style.top = '15%';
        container.style.zIndex = '9999';
        container.style.display = 'flex';
        container.style.flexDirection = 'row';
        container.style.alignItems = 'flex-start';
        container.style.fontFamily = 'Arial, sans-serif';

        const toggleBtn = document.createElement('div');
        toggleBtn.innerText = 'OF CSV';
        toggleBtn.style.backgroundColor = UI_CONFIG.bgColor;
        toggleBtn.style.color = UI_CONFIG.textColor;
        toggleBtn.style.padding = '10px 8px';
        toggleBtn.style.cursor = 'pointer';
        toggleBtn.style.border = `1px solid ${UI_CONFIG.borderColor}`;
        toggleBtn.style.borderRight = 'none';
        toggleBtn.style.borderTopLeftRadius = '8px';
        toggleBtn.style.borderBottomLeftRadius = '8px';
        toggleBtn.style.fontWeight = 'bold';
        toggleBtn.style.fontSize = '12px';
        toggleBtn.style.writingMode = 'vertical-rl';
        toggleBtn.style.textOrientation = 'mixed';
        toggleBtn.title = 'Open/Close Export Panel';

        const panel = document.createElement('div');
        panel.style.backgroundColor = UI_CONFIG.bgColor;
        panel.style.border = `1px solid ${UI_CONFIG.borderColor}`;
        panel.style.borderRight = 'none';
        panel.style.padding = '15px';
        panel.style.display = 'none';
        panel.style.flexDirection = 'column';
        panel.style.gap = '10px';
        panel.style.borderBottomLeftRadius = '8px';
        panel.style.minWidth = '140px';

        const title = document.createElement('div');
        title.innerText = 'Chat Exporter';
        title.style.fontWeight = 'bold';
        title.style.marginBottom = '5px';
        title.style.fontSize = '14px';
        panel.appendChild(title);

        const exportBtn = document.createElement('button');
        exportBtn.id = 'of-csv-action-btn';
        exportBtn.innerText = 'Load All & Export';
        exportBtn.style.padding = '8px 12px';
        exportBtn.style.backgroundColor = '#00aff0';
        exportBtn.style.color = '#ffffff';
        exportBtn.style.border = 'none';
        exportBtn.style.borderRadius = '4px';
        exportBtn.style.cursor = 'pointer';
        exportBtn.style.fontWeight = 'bold';
        
        exportBtn.addEventListener('click', startAutoScrollAndExport);
        panel.appendChild(exportBtn);

        const statusText = document.createElement('div');
        statusText.id = 'of-csv-status';
        statusText.innerText = 'Ready';
        statusText.style.fontSize = '11px';
        statusText.style.color = '#555';
        panel.appendChild(statusText);

        container.appendChild(toggleBtn);
        container.appendChild(panel);
        document.body.appendChild(container);

        let isOpen = false;
        toggleBtn.addEventListener('click', () => {
            isOpen = !isOpen;
            panel.style.display = isOpen ? 'flex' : 'none';
            toggleBtn.style.borderBottomLeftRadius = isOpen ? '0' : '8px';
        });
    }

    function getPartnerName() {
        const nameEl = document.querySelector('.b-chat__header__title .g-user-name') || 
                       document.querySelector('h1.g-page-title .g-user-name') ||
                       document.querySelector('.b-chat__header .g-user-name');
        return nameEl ? nameEl.innerText.trim() : 'Partner'; 
    }

    function getMessageCount(container) {
        return container.querySelectorAll('.b-chat__message').length;
    }

    let globalLastDate = 'Today';
    let globalLastTime = '';

    function extractVisibleMessages(partnerName) {
        const wrapper = document.querySelector('.b-chat__messages-wrapper');
        if (!wrapper) return;

        for (let child of wrapper.children) {
            
            const timelineNode = child.querySelector('.m-timeline');
            if (timelineNode) {
                const timeSpan = timelineNode.querySelector('.b-chat__messages__time span');
                const dateText = timeSpan ? timeSpan.innerText : timelineNode.innerText;
                if (dateText.trim()) {
                    globalLastDate = dateText.trim();
                }
            }

            let messageNodes = [];
            if (child.classList.contains('b-chat__message')) {
                messageNodes = [child];
            } else {
                messageNodes = Array.from(child.querySelectorAll('.b-chat__message'));
            }

            if (messageNodes.length === 0) continue;

            let groupTime = '';
            const allTimeEls = child.querySelectorAll('.b-chat__message__time span, .b-chat__message__time');
            for (let i = allTimeEls.length - 1; i >= 0; i--) {
                if (!allTimeEls[i].closest('.b-chat__replied-message')) {
                    let txt = allTimeEls[i].innerText.trim();
                    if (/\d+:\d+/.test(txt)) { 
                        groupTime = txt;
                        break;
                    }
                }
            }

            for (const msgNode of messageNodes) {
                const isMe = msgNode.classList.contains('m-from-me');
                const sender = isMe ? 'Me' : partnerName;

                let text = '';
                const textWrappers = msgNode.querySelectorAll('.b-chat__message__text');
                for (let tw of textWrappers) {
                    if (!tw.closest('.b-chat__replied-message')) {
                        text = tw.innerText.trim();
                        break; 
                    }
                }
                if (text) text = text.replace(/(\r\n|\n|\r)/gm, ' <br> ');

                let liked = msgNode.querySelector('svg[data-icon-name="icon-liked"]') ? 'Yes' : '';

                let msgTime = '';
                const msgTimeEls = msgNode.querySelectorAll('.b-chat__message__time span, .b-chat__message__time');
                for (let el of msgTimeEls) {
                    if (!el.closest('.b-chat__replied-message')) {
                        let txt = el.innerText.trim();
                        if (/\d+:\d+/.test(txt)) {
                            msgTime = txt;
                            break;
                        }
                    }
                }
                
                if (msgTime) globalLastTime = msgTime;
                const effectiveTime = msgTime || groupTime || globalLastTime;

                let mediaUrl = '';
                const imgEl = msgNode.querySelector('.b-chat__message__media img');
                const videoEl = msgNode.querySelector('.b-chat__message__media video source') || msgNode.querySelector('.b-chat__message__media video');
                if (imgEl) mediaUrl = imgEl.src;
                else if (videoEl) mediaUrl = videoEl.src;

                if (!text && !mediaUrl) continue;
                if (!text && mediaUrl) text = '[Media Attachment]';

                const isoDateTime = formatToISO(globalLastDate, effectiveTime);
                const row = [isoDateTime, sender, text, liked, mediaUrl];
                
                const hash = `${isoDateTime}|||${sender}|||${text.substring(0, 50)}|||${liked}|||${mediaUrl}`;

                if (!seenHashes.has(hash)) {
                    seenHashes.add(hash);
                    allMessages.push(row);
                }
            }
        }
    }

    async function startAutoScrollAndExport() {
        if (isExporting) return;
        isExporting = true;

        const btn = document.getElementById('of-csv-action-btn');
        const status = document.getElementById('of-csv-status');
        const scrollContainer = document.querySelector('.b-chat__messages'); 

        if (!scrollContainer) {
            alert('Chat container not found. Open a chat first.');
            isExporting = false;
            return;
        }

        if (btn) {
            btn.disabled = true;
            btn.style.opacity = '0.6';
        }

        let previousMsgCount = getMessageCount(scrollContainer);
        let idleCounter = 0;

        if (status) status.innerText = 'Loading full history...';
        
        while (true) {
            scrollContainer.scrollTop = 0;
            
            // Odotetaan loaderin ilmestymistä (max 1000ms)
            await waitForLoaderToAppear(1000);
            // Odotetaan loaderin katoamista
            await waitForLoaderToDisappear();
            
            // Pieni tauko DOM-päivitykselle
            await new Promise(r => setTimeout(r, 600));

            let currentMsgCount = getMessageCount(scrollContainer);

            if (currentMsgCount > previousMsgCount) {
                previousMsgCount = currentMsgCount;
                idleCounter = 0; 
                if (status) status.innerText = `Loading... (${currentMsgCount} msgs)`;
            } else {
                idleCounter++;
                if (status) status.innerText = `Checking top... (${idleCounter}/${IDLE_LIMIT})`;
            }

            if (idleCounter >= IDLE_LIMIT) {
                break;
            }
        }

        allMessages = [];
        seenHashes.clear();
        globalLastDate = 'Today';
        globalLastTime = '';
        const partnerName = getPartnerName();

        if (status) status.innerText = 'Extracting msgs (Down)...';
        scrollContainer.scrollTop = 0;
        await new Promise(r => setTimeout(r, 1000));

        let lastScrollTop = -1;
        while (scrollContainer.scrollTop + scrollContainer.clientHeight < scrollContainer.scrollHeight) {
            if (scrollContainer.scrollTop === lastScrollTop) break; 
            lastScrollTop = scrollContainer.scrollTop;

            extractVisibleMessages(partnerName);
            
            scrollContainer.scrollTop += 600;
            await new Promise(r => setTimeout(r, 250)); 
        }

        scrollContainer.scrollTop = scrollContainer.scrollHeight;
        if (status) status.innerText = 'Syncing final msgs...';
        await new Promise(r => setTimeout(r, 1000));
        
        scrollContainer.scrollTop = scrollContainer.scrollHeight - 1000;
        await new Promise(r => setTimeout(r, 500));
        scrollContainer.scrollTop = scrollContainer.scrollHeight;
        await new Promise(r => setTimeout(r, 1000));

        extractVisibleMessages(partnerName);

        if (status) status.innerText = 'Sorting & Processing CSV...';
        
        if (allMessages.length === 0) {
            alert('No messages captured.');
            if (btn) {
                btn.disabled = false;
                btn.style.opacity = '1';
                btn.innerText = 'Load All & Export';
            }
            if (status) status.innerText = 'Ready';
            isExporting = false;
            return;
        }

        allMessages.sort((a, b) => a[0] > b[0] ? 1 : -1);

        allMessages.unshift(['ISO Timestamp', 'Sender', 'Message', 'Liked', 'Media URL']);
        downloadCSV(allMessages, partnerName);
        
        if (btn) {
            btn.disabled = false;
            btn.style.opacity = '1';
            btn.innerText = 'Load All & Export';
        }
        if (status) status.innerText = `Done. (${allMessages.length - 1} msgs)`;
        isExporting = false;
    }

    function isLoaderVisible() {
        const loader = document.querySelector('.b-posts_preloader') || 
                       document.querySelector('.infinite-loading-container .infinite-status-prompt[style*="display: block"]');
        return loader && loader.offsetParent !== null;
    }

    function waitForLoaderToAppear(timeoutMs) {
        return new Promise(resolve => {
            let elapsed = 0;
            const interval = 100;
            const checkInterval = setInterval(() => {
                if (isLoaderVisible()) {
                    clearInterval(checkInterval);
                    resolve(true);
                }
                elapsed += interval;
                if (elapsed >= timeoutMs) {
                    clearInterval(checkInterval);
                    resolve(false);
                }
            }, interval);
        });
    }

    function waitForLoaderToDisappear() {
        return new Promise(resolve => {
            const checkInterval = setInterval(() => {
                if (!isLoaderVisible()) {
                    clearInterval(checkInterval);
                    resolve();
                }
            }, 300);
        });
    }

    function formatToISO(dateStr, timeStr) {
        if (!dateStr) return '';
        
        let dateObj = new Date();
        const lowerDate = dateStr.toLowerCase().trim();

        if (lowerDate === 'today' || lowerDate === 'tänään') {
        } else if (lowerDate === 'yesterday' || lowerDate === 'eilen') {
            dateObj.setDate(dateObj.getDate() - 1);
        } else {
            const yearMatch = dateStr.match(/,\s*'?(\d{2,4})$/);
            
            if (yearMatch) {
                let year = yearMatch[1];
                if (year.length === 2) {
                    year = '20' + year;
                }
                const cleanDateStr = dateStr.replace(/,\s*'?\d{2,4}$/, `, ${year}`);
                let parsedDate = new Date(cleanDateStr);
                if (!isNaN(parsedDate.getTime())) {
                    dateObj = parsedDate;
                }
            } else {
                let parsedDate = new Date(dateStr);
                if (isNaN(parsedDate.getTime())) {
                    parsedDate = new Date(`${dateStr}, ${new Date().getFullYear()}`);
                }
                
                if (!isNaN(parsedDate.getTime())) {
                    const now = new Date();
                    if (parsedDate > now) {
                        parsedDate.setFullYear(now.getFullYear() - 1);
                    }
                    dateObj = parsedDate;
                }
            }
        }

        if (timeStr) {
            const timeParts = timeStr.match(/(\d+):(\d+)\s*(am|pm)?/i);
            if (timeParts) {
                let hours = parseInt(timeParts[1], 10);
                let minutes = parseInt(timeParts[2], 10);
                const meridian = timeParts[3] ? timeParts[3].toLowerCase() : null;

                if (meridian === 'pm' && hours < 12) hours += 12;
                if (meridian === 'am' && hours === 12) hours = 0;

                dateObj.setHours(hours, minutes, 0, 0);
            }
        }

        const y = dateObj.getFullYear();
        const m = String(dateObj.getMonth() + 1).padStart(2, '0');
        const d = String(dateObj.getDate()).padStart(2, '0');
        const hh = String(dateObj.getHours()).padStart(2, '0');
        const min = String(dateObj.getMinutes()).padStart(2, '0');
        
        // Return without seconds
        return `${y}-${m}-${d} ${hh}:${min}`;
    }

    function escapeCSV(field) {
        if (field === null || field === undefined) return '';
        const stringField = String(field);
        if (stringField.includes('"') || stringField.includes(',') || stringField.includes('\n')) {
            return `"${stringField.replace(/"/g, '""')}"`;
        }
        return stringField;
    }

    function downloadCSV(rows, partnerName) {
        const csvContent = rows.map(e => e.map(escapeCSV).join(',')).join('\n');
        const bom = '\uFEFF'; 
        const blob = new Blob([bom + csvContent], { type: 'text/csv;charset=utf-8;' });
        
        const link = document.createElement('a');
        const url = URL.createObjectURL(blob);
        
        // Date format for filename: YYYY-MM-DD_HH-mm
        const now = new Date();
        const y = now.getFullYear();
        const m = String(now.getMonth() + 1).padStart(2, '0');
        const d = String(now.getDate()).padStart(2, '0');
        const hh = String(now.getHours()).padStart(2, '0');
        const min = String(now.getMinutes()).padStart(2, '0');
        const timestamp = `${y}-${m}-${d}_${hh}-${min}`;
        
        const safePartnerName = partnerName.replace(/[^a-z0-9]/gi, '_').toLowerCase();
        link.setAttribute('href', url);
        link.setAttribute('download', `onlyfans_chat_${safePartnerName}_${timestamp}.csv`);
        link.style.visibility = 'hidden';
        
        document.body.appendChild(link);
        link.click();
        document.body.removeChild(link);
    }

})();