您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Saves letters as MD files. Scroll to the first letter. An export works from the opened letter to the most recent one.
当前为
// ==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); })();