OnlyFans Chat Exporter (CSV)

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

Dovrai installare un'estensione come Tampermonkey, Greasemonkey o Violentmonkey per installare questo script.

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

Dovrai installare un'estensione come Tampermonkey o Violentmonkey per installare questo script.

Dovrai installare un'estensione come Tampermonkey o Userscripts per installare questo script.

Dovrai installare un'estensione come ad esempio Tampermonkey per installare questo script.

Dovrai installare un gestore di script utente per installare questo script.

(Ho già un gestore di script utente, lasciamelo installare!)

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

(Ho già un gestore di stile utente, lasciamelo installare!)

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

})();