OnlyFans Chat Exporter (CSV)

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

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

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

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==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);
    }

})();