Extracts chat history to CSV. Uses robust message-count tracking, strict time parsing, auto-sorting, and two-stage loader checks.
// ==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);
}
})();