OnlyFans Chat Exporter (CSV)

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

Du musst eine Erweiterung wie Tampermonkey, Greasemonkey oder Violentmonkey installieren, um dieses Skript zu installieren.

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

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

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

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

Sie müssten eine Skript Manager Erweiterung installieren damit sie dieses Skript installieren können

(Ich habe schon ein Skript Manager, Lass mich es installieren!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

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

})();