Slowly Export

Saves letters as MD files. Scroll to the first letter. An export works from the opened letter to the most recent one.

当前为 2025-07-27 提交的版本,查看 最新版本

// ==UserScript==
// @name         Slowly Export
// @namespace    Violentmonkey Scripts
// @version      1.0
// @license      GPLv3
// @author       HUM4N_F1L3.json
// @description  Saves letters as MD files. Scroll to the first letter. An export works from the opened letter to the most recent one.
// @match        https://web.slowly.app/friend/*
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/jszip.min.js
// @icon         https://www.google.com/s2/favicons?sz=64&domain=slowly.app
// @grant        none
// ==/UserScript==

(function() {
    'use strict';

    const WAIT_AFTER_CLICK = 1200;
    const WAIT_AFTER_LOAD = 800;

    let isExporting = false;
    let lastLetterId = '';
    let letters = [];
    let user1 = null;
    let user2 = null;

    // Check if we are on a specific letter page (not the conversation list)
    function isLetterPage() {
        const pathParts = window.location.pathname.split('/').filter(Boolean);
        return pathParts.length === 3 && pathParts[0] === 'friend';
    }

    // Extract all relevant data from the current letter, including attachments, stamp, avatar
    function getLetterData() {
        const footer = document.querySelector('.modal-footer .media-body');
        let sender = '', date = '', geo = '', avatarUrl = '', avatarAlt = '', avatarFilename = '';
        if (footer) {
            sender = footer.querySelector('h5')?.innerText.trim() || '';
            const p = footer.querySelector('p');
            if (p) {
                const parts = p.innerHTML.split('<br>');
                if (parts.length > 0) date = parts[0].replace(/<[^>]+>/g, '').trim();
                if (parts.length > 1) geo = parts[1].replace(/<[^>]+>/g, '').trim();
            }
            // Avatar
            const avatarImg = footer.parentElement.querySelector('img.avatar-border, img.rounded-circle');
            if (avatarImg) {
                avatarUrl = avatarImg.src;
                avatarAlt = avatarImg.alt || '';
                avatarFilename = avatarUrl.split('/').pop().split('?')[0];
                if (!avatarFilename) avatarFilename = 'avatar_' + Math.random().toString(36).slice(2) + '.png';
            }
        }
        let letterText = '';
        const textNode = document.querySelector('.modal-body .pre-wrap.mb-3');
        if (textNode) letterText = textNode.innerText.trim();
        let stampUrl = '', stampAlt = '', stampFilename = '';
        const stampImg = document.querySelector('img.stamp');
        if (stampImg) {
            stampUrl = stampImg.src;
            stampAlt = stampImg.alt || '';
            stampFilename = stampUrl.split('/').pop().split('?')[0];
            if (!stampFilename) stampFilename = 'stamp_' + Math.random().toString(36).slice(2) + '.png';
        }
        // Collect all image attachments
        const attachments = [];
        document.querySelectorAll('.slider img').forEach(img => {
            const url = img.src;
            let filename = url.split('/').pop().split('?')[0];
            if (!filename) filename = 'attachment_' + Math.random().toString(36).slice(2) + '.jpg';
            attachments.push({
                url,
                alt: img.alt || '',
                filename
            });
        });
        return { sender, date, geo, letterText, stampUrl, stampAlt, stampFilename, attachments, avatarUrl, avatarAlt, avatarFilename };
    }

    // Generate a unique ID for the current letter (used to detect page changes)
    function getCurrentLetterId() {
        const data = getLetterData();
        return (data.date || '') + '|' + (data.letterText || '');
    }

    // Generate the Markdown content for a letter, with avatar left, stamp right, attachments at the bottom
    function makeMarkdown({ sender, date, geo, letterText, stampFilename, stampAlt, attachments, avatarFilename, avatarAlt }) {
        // Avatar left (only local)
        let avatarBlock = '';
        if (avatarFilename) {
            avatarBlock = `<img src="images/avatars/${avatarFilename}" alt="${avatarAlt || ''}" width="60" height="60" style="margin-right:20px;flex-shrink:0;border-radius:50%;margin-top: 25px;">`;
        }
        // Stamp right (only local)
        let stampBlock = '';
        if (stampFilename) {
            stampBlock = `<img src="images/stamps/${stampFilename}" alt="${stampAlt || ''}" width="100" height="100" style="margin-left:20px;flex-shrink:0;">`;
        }
        // Header flex block
        let headerBlock;
        if (avatarBlock || stampBlock) {
            headerBlock = `<div style="display:flex;align-items:flex-start;justify-content:space-between;">
  <div style="display:flex;align-items:flex-start;">
    ${avatarBlock}
    <div>
      <h1 style="margin-bottom:0;">Letter from ${sender || 'Unknown'}</h1>
      <div><strong>Date:</strong> ${date || 'Unknown'}</div>
      <div><strong>Location:</strong> ${geo || 'Unknown'}</div>
    </div>
  </div>
  ${stampBlock}
</div>`;
        } else {
            headerBlock = `# Letter from ${sender || 'Unknown'}
**Date:** ${date || 'Unknown'}
**Location:** ${geo || 'Unknown'}`;
        }

        // Attachments block at the bottom
        let attachmentsBlock = '';
        if (attachments && attachments.length) {
            attachmentsBlock = '\n\n---\n\n**Attachments:**\n\n' +
                attachments.map(att =>
                    `<img src="images/attachments/${att.filename}" alt="${att.alt}" style="max-width:400px;max-height:400px;">`
                ).join('\n') + '\n';
        }

        return (
`${headerBlock}


${letterText || ''}${attachmentsBlock}`
        );
    }

    // Click the "next letter" button (chevron-left icon)
    function clickNextLetterButton() {
        const nextBtn = Array.from(document.querySelectorAll('a.no-underline.link'))
            .find(a => a.querySelector('i.icon-chevron-left'));
        if (nextBtn) {
            nextBtn.click();
            return true;
        }
        return false;
    }

    // Wait for the next letter to load (by checking for a change in letter ID)
    function waitForNextLetter() {
        return new Promise(resolve => {
            const prevId = lastLetterId;
            const check = () => {
                const newId = getCurrentLetterId();
                if (newId && newId !== prevId) {
                    resolve();
                } else {
                    setTimeout(check, 300);
                }
            };
            setTimeout(check, WAIT_AFTER_CLICK);
        });
    }

    // Main export loop: save current letter, go to next, repeat, then save ZIP
    async function exportAndNext() {
        if (isExporting) return;
        isExporting = true;

        const data = getLetterData();
        if (!data.letterText) {
            alert('Could not find letter text! Stopped.');
            isExporting = false;
            return;
        }

        // Determine user1 and user2
        if (!user1) user1 = data.sender;
        if (!user2 && data.sender !== user1) user2 = data.sender;

        // Save both content and all images for this letter
        letters.push({
            content: makeMarkdown(data),
            attachments: data.attachments,
            stampUrl: data.stampUrl,
            stampFilename: data.stampFilename,
            avatarUrl: data.avatarUrl,
            avatarFilename: data.avatarFilename
        });

        lastLetterId = getCurrentLetterId();

        if (clickNextLetterButton()) {
            await waitForNextLetter();
            setTimeout(() => {
                isExporting = false;
                exportAndNext();
            }, WAIT_AFTER_LOAD);
        } else {
            // All letters exported, create ZIP
            saveZip();
            isExporting = false;
        }
    }

    // Create and download the ZIP archive with all letters, attachments, stamps, avatars
    async function saveZip() {
        const zip = new JSZip();
        // Add all images to images/attachments, images/stamps, images/avatars
        const imagesFolder = zip.folder('images');
        const attachmentsFolder = imagesFolder.folder('attachments');
        const stampsFolder = imagesFolder.folder('stamps');
        const avatarsFolder = imagesFolder.folder('avatars');
        // Collect all unique images
        const allAttachments = [];
        const allStamps = [];
        const allAvatars = [];
        letters.forEach(letter => {
            if (letter.attachments) {
                letter.attachments.forEach(att => {
                    if (!allAttachments.find(a => a.url === att.url)) {
                        allAttachments.push(att);
                    }
                });
            }
            if (letter.stampUrl && letter.stampFilename) {
                if (!allStamps.find(s => s.url === letter.stampUrl)) {
                    allStamps.push({ url: letter.stampUrl, filename: letter.stampFilename });
                }
            }
            if (letter.avatarUrl && letter.avatarFilename) {
                if (!allAvatars.find(a => a.url === letter.avatarUrl)) {
                    allAvatars.push({ url: letter.avatarUrl, filename: letter.avatarFilename });
                }
            }
        });

        // Download and add each attachment
        for (const att of allAttachments) {
            try {
                const response = await fetch(att.url);
                const blob = await response.blob();
                await attachmentsFolder.file(att.filename, blob);
            } catch (e) {
                console.error('Failed to fetch attachment:', att.url, e);
            }
        }

        // Download and add each stamp
        for (const s of allStamps) {
            try {
                const response = await fetch(s.url);
                const blob = await response.blob();
                await stampsFolder.file(s.filename, blob);
            } catch (e) {
                console.error('Failed to fetch stamp:', s.url, e);
            }
        }

        // Download and add each avatar
        for (const a of allAvatars) {
            try {
                const response = await fetch(a.url);
                const blob = await response.blob();
                await avatarsFolder.file(a.filename, blob);
            } catch (e) {
                console.error('Failed to fetch avatar:', a.url, e);
            }
        }

        // Add letters
        letters.forEach((letter, i) => {
            zip.file(`${i + 1}.md`, letter.content);
        });

        // Archive name
        let name1 = user1 || 'User1';
        let name2 = user2 || 'User2';
        name1 = name1.replace(/[\\/:*?"<>|]/g, '_');
        name2 = name2.replace(/[\\/:*?"<>|]/g, '_');
        const zipName = `${name1}_and_${name2}'s_Correspondence.zip`;

        zip.generateAsync({ type: "blob" }).then(function(content) {
            const a = document.createElement('a');
            a.href = URL.createObjectURL(content);
            a.download = zipName;
            document.body.appendChild(a);
            a.click();
            setTimeout(() => {
                document.body.removeChild(a);
                URL.revokeObjectURL(a.href);
            }, 100);
        });
    }

    // Add or remove the export button depending on the current page
    function addOrRemoveButton() {
        const btnId = 'slowly-md-export-all-btn';
        const oldBtn = document.getElementById(btnId);
        if (isLetterPage()) {
            if (!oldBtn) {
                const btn = document.createElement('button');
                btn.id = btnId;
                const icon = document.createElement('i');
                icon.className = 'icon-download';
                icon.style.marginRight = '10px';
                btn.appendChild(icon);
                btn.appendChild(document.createTextNode('Export'));
                btn.style.position = 'fixed';
                btn.style.top = '150px';
                btn.style.right = '30px';
                btn.style.zIndex = 1019;
                btn.style.borderRadius = '999px';
                btn.style.paddingLeft = '30px';
                btn.style.paddingRight = '30px';
                btn.style.fontSize = '18px';
                btn.style.boxShadow = '0 1rem 3rem rgba(0, 0, 0, .175)';
                btn.style.padding = '.5rem 1rem';
                btn.style.backgroundColor = '#ffc300';
                btn.style.borderColor = '#ffc300';
                btn.style.color = '#212529';
                btn.style.border = '1px solid #ffc300';
                btn.style.transition = 'color .15s ease-in-out, background-color .15s ease-in-out, border-color .15s ease-in-out, box-shadow .15s ease-in-out';
                btn.style.cursor = 'pointer';
                btn.onclick = () => {
                    isExporting = false;
                    lastLetterId = '';
                    letters = [];
                    user1 = null;
                    user2 = null;
                    exportAndNext();
                };
                document.body.appendChild(btn);
            }
        } else {
            if (oldBtn) oldBtn.remove();
        }
    }

    // Initial check on page load
    setTimeout(addOrRemoveButton, 1000);

    // Watch for DOM changes (SPA navigation)
    const observer = new MutationObserver(() => {
        addOrRemoveButton();
    });
    observer.observe(document.body, { childList: true, subtree: true });

    // Also check for URL changes (as a fallback)
    let lastUrl = location.href;
    setInterval(() => {
        if (location.href !== lastUrl) {
            lastUrl = location.href;
            addOrRemoveButton();
        }
    }, 500);
})();