LOFTER 一键导出合集/单篇。支持合集自动识别、关键词筛选、图片下载、归档页批量导出、文章页单篇导出。可配置合集/散篇的合并或单篇导出策略。
// ==UserScript== // @name LOFTER Helper // @namespace http://tampermonkey.net/ // @version 2.4.3 // @description LOFTER 一键导出合集/单篇。支持合集自动识别、关键词筛选、图片下载、归档页批量导出、文章页单篇导出。可配置合集/散篇的合并或单篇导出策略。 // @author Lumiarna // @match *://*.lofter.com/view* // @match *://*.lofter.com/post/* // @grant GM_setValue // @grant GM_getValue // @grant GM.xmlHttpRequest // @connect api.lofter.com // @connect lf127.net // @run-at document-idle // @license MIT // @require https://cdn.jsdelivr.net/npm/[email protected]/umd/index.js // ==/UserScript== (function () { 'use strict'; // 常量 const CONTENT_SELECTORS = [ '.post-text', '.post-content', '.article-desc', '.article-content', '.content .text', '.text', '.ct', '.m-post', ]; const API_PRODUCT = 'lofter-android-7.6.12'; const SCROLL_MAX_ATTEMPTS = 40; const SCROLL_INTERVAL = 1500; const FETCH_DELAY = 800; const IMG_DELAY = 300; const isArchive = !location.pathname.includes('/post/'); const GROUP_STRATEGY = { MERGE: 'merge', SINGLE: 'single', SKIP: 'skip', }; const EXPORT_MODE = { ARCHIVE: 'archive', ORIGIN: 'origin', }; const COLLECTION_SETTINGS_KEY = 'lofter_helper_collection_settings'; const LEGACY_COLLECTION_STRATEGY_KEY = 'lofter_helper_collection_strategy'; // 设置 function readCollectionStrategies() { const raw = GM_getValue(COLLECTION_SETTINGS_KEY, '{}'); if (raw && typeof raw === 'object') return raw; try { const parsed = JSON.parse(raw || '{}'); return parsed && typeof parsed === 'object' ? parsed : {}; } catch { return {}; } } const settings = { format: GM_getValue('lofter_helper_format', 'txt'), looseStrategy: GM_getValue('lofter_helper_loose_strategy', GROUP_STRATEGY.SINGLE), skipImages: GM_getValue('lofter_helper_skip_images', false), collectionStrategies: readCollectionStrategies(), }; // 工具函数 const delay = ms => new Promise(r => setTimeout(r, ms)); const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel)); let author = ''; let archiveCollections = []; let collectionsLoadError = ''; function safeFileName(name, maxLen = 200) { return name.replace(/[\\/:*?"<>|]/g, '_').replace(/\s+/g, ' ').trim().slice(0, maxLen); } function escapeHtml(value) { return String(value).replace(/[&<>"']/g, char => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', }[char])); } function normalizeStrategy(value, fallback = GROUP_STRATEGY.MERGE) { if (value === GROUP_STRATEGY.SINGLE || value === GROUP_STRATEGY.SKIP) return value; return value === GROUP_STRATEGY.MERGE ? GROUP_STRATEGY.MERGE : fallback; } function persistCollectionStrategies() { GM_setValue(COLLECTION_SETTINGS_KEY, JSON.stringify(settings.collectionStrategies)); } function getCollectionStrategy(collectionName) { return normalizeStrategy(settings.collectionStrategies[collectionName], GROUP_STRATEGY.MERGE); } function setCollectionStrategy(collectionName, strategy) { settings.collectionStrategies[collectionName] = normalizeStrategy(strategy, GROUP_STRATEGY.MERGE); persistCollectionStrategies(); } function getArchiveBlogdomain() { return location.hostname; } function normalizeCollectionMeta(collection) { const name = collection?.name?.trim(); if (!name) return null; return { id: String(collection.id ?? ''), name, postCount: Number(collection.postCount) || 0, }; } function extractCollectionList(response) { if (Array.isArray(response)) return response.map(normalizeCollectionMeta).filter(Boolean); if (!response || typeof response !== 'object') return []; const candidates = [ response.collections, response.postCollections, response.postCollectionList, response.data, response.list, response.result, ]; for (const candidate of candidates) { if (Array.isArray(candidate)) return candidate.map(normalizeCollectionMeta).filter(Boolean); } return []; } function syncCollectionStrategies(collections) { const next = {}; const legacyDefault = normalizeStrategy( GM_getValue(LEGACY_COLLECTION_STRATEGY_KEY, GROUP_STRATEGY.MERGE), GROUP_STRATEGY.MERGE, ); for (const collection of collections) { next[collection.name] = normalizeStrategy( settings.collectionStrategies[collection.name], legacyDefault, ); } settings.collectionStrategies = next; persistCollectionStrategies(); } function getPageAuthor() { if (isArchive) { const links = $$('.w-bttl2.w-bttl-hd > a'); return links[links.length - 1]?.textContent.trim(); } return document.querySelector('h1 > a, .m-nick > a')?.textContent.trim(); } function sequenceWidth(value) { return String(Math.max(1, value || 0)).length; } function imageExt(url) { const m = url.match(/\.(jpe?g|png|gif|webp|bmp)/i); return m ? m[1].toLowerCase() : 'jpg'; } function downloadBlob(blob, fileName) { const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = fileName; a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 10_000); } function formatDate(ts) { const d = new Date(ts); const pad = n => String(n).padStart(2, '0'); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; } function isMd() { return settings.format === 'markdown'; } function fileExt() { return isMd() ? 'md' : 'txt'; } function formatArticle(a, { showTitle = true } = {}) { if (isMd()) { const imgBlock = a.images?.length ? a.images.map(src => ``).join('\n') + '\n\n' : ''; const lines = []; if (showTitle) lines.push(`## ${a.title}`, ''); lines.push(`> 原文地址:${a.url}`, `> 发布时间:${a.publishTime}`, '', imgBlock + a.content.trim()); return lines.join('\n'); } const lines = []; if (showTitle) lines.push(`☆ ${a.title}`, ''); lines.push(`原文地址:${a.url}`, `发布时间:${a.publishTime}`, '', a.content.trim()); return lines.join('\n'); } const FileNames = { /** 单篇文本 */ singleTextFile(title) { const raw = `${title}_${author}`; return `${safeFileName(raw)}.${fileExt()}`; }, /** 单篇图片 */ singleImageFile(title, index, url, indexWidth) { const imageIndexPadded = String(index + 1).padStart(indexWidth, '0'); const raw = `${title}_${imageIndexPadded}_${author}`; return `${safeFileName(raw)}.${imageExt(url)}`; }, /** 打包文件夹 / ZIP 基础名 */ archiveFolder({ keyword = '' } = {}) { const raw = keyword ? `${keyword}_${author}` : author; return safeFileName(raw); }, /** 打包内合并文本文件 */ archiveMergedTextFile(folder, collectionName = '') { const raw = collectionName ? `${collectionName}_${author}` : `散章_${author}`; return `${folder}/${safeFileName(raw)}.${fileExt()}`; }, /** 打包内单篇文件和图片前缀(不含扩展名) */ archiveEntryPrefix(article, { itemWidth, seq }) { const posPadded = String(article.pos).padStart(itemWidth, '0'); const seqPadded = String(seq).padStart(itemWidth, '0'); return article.collectionName ? `${safeFileName(article.collectionName)}/${posPadded}_${safeFileName(article.title)}` : `${seqPadded}_${safeFileName(article.title)}`; }, /** 打包内单篇文本文件 */ archiveArticleTextFile(folder, article, ctx) { const prefix = this.archiveEntryPrefix(article, ctx); return `${folder}/${(prefix)}.${fileExt()}`; }, /** 打包内图片文件 */ archiveImageFile(folder, article, ctx, imgIndex, url, imageWidth) { const prefix = this.archiveEntryPrefix(article, ctx); const imageIndexPadded = String(imgIndex + 1).padStart(imageWidth, '0'); return `${folder}/${prefix}_${imageIndexPadded}.${imageExt(url)}`; }, }; // DOM → Markdown 转换 function htmlToMarkdown(node) { if (node.nodeType === Node.TEXT_NODE) return node.textContent; if (node.nodeType !== Node.ELEMENT_NODE) return ''; const tag = node.tagName.toLowerCase(); const children = () => Array.from(node.childNodes).map(htmlToMarkdown).join(''); switch (tag) { case 'br': return '\n'; case 'b': case 'strong': return `**${children().trim()}**`; case 'i': case 'em': return `*${children().trim()}*`; case 'a': { const href = node.getAttribute('href') || ''; const text = children().trim(); return href ? `[${text}](${href})` : text; } case 'img': { const src = node.getAttribute('src') || node.getAttribute('data-src') || ''; const alt = node.getAttribute('alt') || ''; return src ? `` : ''; } case 'h1': case 'h2': case 'h3': case 'h4': case 'h5': case 'h6': { const level = '#'.repeat(Number(tag[1])); return `\n${level} ${children().trim()}\n`; } case 'blockquote': { const inner = children().trim().split('\n').map(l => `> ${l}`).join('\n'); return `\n${inner}\n`; } case 'pre': { const code = node.querySelector('code'); const text = code ? code.textContent : node.textContent; return `\n\`\`\`\n${text.trim()}\n\`\`\`\n`; } case 'code': return `\`${node.textContent}\``; case 'ul': { return '\n' + Array.from(node.children).map(li => `- ${htmlToMarkdown(li).trim()}` ).join('\n') + '\n'; } case 'ol': { return '\n' + Array.from(node.children).map((li, i) => `${i + 1}. ${htmlToMarkdown(li).trim()}` ).join('\n') + '\n'; } case 'li': return children(); case 'p': case 'div': return `${children().trim()}\n\n`; default: return children(); } } // DOM 解析 function extractText(root) { for (const sel of CONTENT_SELECTORS) { const elems = root.querySelectorAll(sel); if (!elems.length) continue; const text = Array.from(elems).map(e => isMd() ? htmlToMarkdown(e).trim() : e.textContent.trim() ).join('\n\n'); if (text.length > 0) return text; } return ''; } function getCookie(name) { const m = document.cookie.match(new RegExp('(?:^|; )' + name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '=([^;]*)')); return m ? decodeURIComponent(m[1]) : ''; } function parseLofterUrl(url) { const m = url.match(/https?:\/\/([^/]+\.lofter\.com)\/post\/[0-9a-f]+_([0-9a-f]+)/); return m ? { blogdomain: m[1], postId: parseInt(m[2], 16) } : null; } // API 请求 async function fetchPostDetail(url) { const parsed = parseLofterUrl(url); if (!parsed) return null; const auth = getCookie('LOFTER-PHONE-LOGIN-AUTH'); if (!auth) return null; const params = new URLSearchParams({ product: API_PRODUCT }); const body = new URLSearchParams({ supportposttypes: '1,2,3,4,5,6', blogdomain: parsed.blogdomain, postid: String(parsed.postId), offset: '0', requestType: '0', postdigestnew: '1', checkpwd: '1', needgetpoststat: '1', }); try { const resp = await GM.xmlHttpRequest({ method: 'POST', url: `https://api.lofter.com/oldapi/post/detail.api?${params}`, headers: { 'User-Agent': 'LOFTER-Android 7.6.12 (V2272A; Android 13; null) WIFI', 'lofproduct': API_PRODUCT, 'lofter-phone-login-auth': auth, 'Content-Type': 'application/x-www-form-urlencoded', }, data: body.toString(), timeout: 10_000, }); const data = JSON.parse(resp.responseText); const post = data.response?.posts?.[0]?.post || null; return post; } catch { return null; } } async function fetchAuthorCollections() { const auth = getCookie('LOFTER-PHONE-LOGIN-AUTH'); if (!auth) throw new Error('未找到 LOFTER 登录 Cookie'); const params = new URLSearchParams({ method: 'getCollectionList', needViewCount: '1', blogdomain: getArchiveBlogdomain(), product: API_PRODUCT, }); const resp = await GM.xmlHttpRequest({ method: 'GET', url: `https://api.lofter.com/v1.1/postCollection.api?${params.toString()}`, headers: { 'Accept-Encoding': 'br,gzip', 'content-type': 'application/x-www-form-urlencoded; charset=utf-8', 'lofter-phone-login-auth': auth, }, timeout: 10_000, }); const data = JSON.parse(resp.responseText); return extractCollectionList(data.response); } function extractImages(post) { // 图片贴:解析 photoLinks try { const links = JSON.parse(post.photoLinks || '[]'); const urls = links.map(p => p.raw || p.orign).filter(Boolean); if (urls.length) return urls; } catch {} // 文字贴:从 content HTML 提取 <img src> if (post.content) { const doc = new DOMParser().parseFromString(post.content, 'text/html'); const urls = Array.from(doc.querySelectorAll('img')) .map(img => img.getAttribute('src')) .filter(Boolean); if (urls.length) return urls; } // 兜底:firstImageUrl(JSON 数组 [缩略图, 原图]) try { const first = JSON.parse(post.firstImageUrl || '[]'); const best = Array.isArray(first) && first.filter(Boolean).pop(); if (best) return [best]; } catch {} return []; } function parseApiArticle(post) { const doc = new DOMParser().parseFromString( `<div class="post-content">${post.content}</div>`, 'text/html', ); const content = extractText(doc.body); const images = extractImages(post); return { url: post.blogPageUrl || '', title: post.title || '未命名', content, images, publishTime: post.publishTime ? formatDate(post.publishTime) : '', collectionId: post.postCollection?.id ? String(post.postCollection.id) : '', collectionName: post.postCollection?.name || '', pos: post.pos || 0, }; } // 数据抓取 async function autoScroll() { let lastCount = 0, stable = 0; for (let i = 0; i < SCROLL_MAX_ATTEMPTS; i++) { window.scrollTo(0, document.body.scrollHeight); await delay(SCROLL_INTERVAL); const count = $$("a[href*='/post/']").length; if (count === lastCount) { if (++stable >= 3) break; } else { stable = 0; lastCount = count; } } return lastCount; } function extractArticleLinks() { const map = new Map(); for (const el of $$("a[href*='/post/']")) { const url = el.href; if (!url || map.has(url)) continue; const h3 = el.querySelector('h3'); const title = h3?.textContent.trim() || el.querySelector('p')?.textContent.replace(/\s+/g, ' ').trim() || ''; map.set(url, { url, title }); } return [...map.values()]; } async function fetchArticle(url) { const post = await fetchPostDetail(url); if (!post) throw new Error('详情 API 未返回文章数据'); const article = parseApiArticle(post); return article; } async function fetchAll(articles, onProgress) { const results = []; for (let i = 0; i < articles.length; i++) { onProgress?.(i + 1, articles.length); try { const fetched = await fetchArticle(articles[i].url); results.push(fetched); } catch (e) { throw e; } if (i < articles.length - 1) await delay(FETCH_DELAY); } return results; } // 导出 function exportSingle(article) { downloadBlob( new Blob([`${formatArticle(article)}\n`], { type: 'text/plain;charset=utf-8' }), FileNames.singleTextFile(article.title), ); } function mergeArticles(articles, { collectionName = '', skipSort = false } = {}) { const sorted = skipSort ? articles : [...articles].sort((a, b) => a.pos - b.pos); const body = sorted.map(a => `${formatArticle(a, { showTitle: true })}\n\n${'─'.repeat(36)}\n` ).join('\n'); return isMd() && collectionName ? `# ${collectionName}\n\n${body}` : body; } async function exportArchive(articles, keyword, onProgress) { const folder = FileNames.archiveFolder({ keyword }); const files = {}; // 分组:合集 vs 散篇 const collectionGroups = new Map(); const looseArticles = []; for (const a of articles) { if (a.collectionName) { if (!collectionGroups.has(a.collectionName)) collectionGroups.set(a.collectionName, []); collectionGroups.get(a.collectionName).push(a); } else { looseArticles.push(a); } } // 合集文章 for (const [name, group] of collectionGroups) { const strategy = getCollectionStrategy(name); if (strategy === GROUP_STRATEGY.SKIP) continue; if (strategy === GROUP_STRATEGY.MERGE) { files[FileNames.archiveMergedTextFile(folder, name)] = fflate.strToU8(mergeArticles(group, { collectionName: name })); } else { const sorted = [...group].sort((a, b) => a.pos - b.pos); const maxPos = Math.max(...sorted.map(a => a.pos || 0), 1); const itemWidth = sequenceWidth(maxPos); for (const article of sorted) { const ctx = { index: 0, total: sorted.length, itemWidth, seq: 0 }; files[FileNames.archiveArticleTextFile(folder, article, ctx)] = fflate.strToU8(formatArticle(article)); } } } // 散章 if (settings.looseStrategy === GROUP_STRATEGY.MERGE && looseArticles.length) { files[FileNames.archiveMergedTextFile(folder, keyword)] = fflate.strToU8(mergeArticles([...looseArticles].reverse(), { skipSort: true })); } else if (settings.looseStrategy === GROUP_STRATEGY.SINGLE) { const looseWidth = sequenceWidth(looseArticles.length); for (let i = 0; i < looseArticles.length; i++) { const article = looseArticles[i]; const ctx = { index: i, total: looseArticles.length, itemWidth: looseWidth, seq: i + 1 }; files[FileNames.archiveArticleTextFile(folder, article, ctx)] = fflate.strToU8(formatArticle(article)); } } // 图片(始终逐篇逐图) if (!settings.skipImages) { const exportedArticles = articles.filter(article => { if (article.collectionName) { return getCollectionStrategy(article.collectionName) !== GROUP_STRATEGY.SKIP; } return settings.looseStrategy !== GROUP_STRATEGY.SKIP; }); const allSorted = [...exportedArticles].sort((a, b) => { if (a.collectionName !== b.collectionName) return a.collectionName.localeCompare(b.collectionName); return a.pos - b.pos; }); for (let i = 0; i < allSorted.length; i++) { const article = allSorted[i]; if (!article.images?.length) continue; const imageWidth = sequenceWidth(article.images.length); // 为图片命名重建 ctx let itemWidth, seq; if (article.collectionName) { const group = collectionGroups.get(article.collectionName); itemWidth = sequenceWidth(Math.max(...group.map(a => a.pos || 0), 1)); seq = 0; } else { itemWidth = sequenceWidth(looseArticles.length); seq = looseArticles.indexOf(article) + 1; } const ctx = { index: i, total: allSorted.length, itemWidth, seq }; for (let j = 0; j < article.images.length; j++) { onProgress?.(`文章 ${i + 1}/${allSorted.length},图片 ${j + 1}/${article.images.length}`); const blob = await fetchImageAsBlob(article.images[j]); if (blob) { files[FileNames.archiveImageFile(folder, article, ctx, j, article.images[j], imageWidth)] = new Uint8Array(await blob.arrayBuffer()); } await delay(IMG_DELAY); } } } // end if !skipImages if (!Object.keys(files).length) { throw new Error('当前设置会跳过全部内容,没有可导出的文章'); } const zipped = fflate.zipSync(files); downloadBlob(new Blob([zipped], { type: 'application/zip' }), `${folder}.zip`); } async function downloadImages(article, onProgress) { if (!article.images?.length) return; const imageWidth = sequenceWidth(article.images.length); for (let i = 0; i < article.images.length; i++) { onProgress?.(`下载图片 ${i + 1}/${article.images.length}`); const blob = await fetchImageAsBlob(article.images[i]); if (blob) downloadBlob(blob, FileNames.singleImageFile(article.title, i, article.images[i], imageWidth)); await delay(IMG_DELAY); } } async function fetchImageAsBlob(imgUrl) { try { const resp = await GM.xmlHttpRequest({ method: 'GET', url: imgUrl, responseType: 'blob', timeout: 30_000, }); return resp.response; } catch (e) { void e; return null; } } // UI let shadow; function initShadowHost() { if (shadow) return shadow; const host = document.createElement('div'); host.id = 'lofter-helper-host'; document.body.append(host); shadow = host.attachShadow({ mode: 'closed' }); shadow.innerHTML = `<style> button { position: fixed; right: 10px; z-index: 999999; padding: 8px 14px; border: none; border-radius: 6px; color: #fff; font: 500 13px/1.4 system-ui, sans-serif; cursor: pointer; box-shadow: 0 2px 8px rgba(0,0,0,.15); transition: opacity .2s, transform .15s; white-space: nowrap; } button:hover { opacity: .9; transform: translateY(-1px); } button:active { transform: translateY(0); } button:disabled { opacity: .6; cursor: not-allowed; } .primary { top: 40px; background: #1e90ff; } .success { top: 40px; background: #28a745; } .settings-btn { position: fixed; right: 10px; z-index: 999999; padding: 6px 10px; border: none; border-radius: 6px; background: #555; color: #fff; font-size: 16px; line-height: 1; cursor: pointer; box-shadow: 0 2px 8px rgba(0,0,0,.15); transition: opacity .2s, transform .3s; } .settings-btn:hover { opacity: .85; transform: rotate(45deg); } .settings-panel { display: none; position: fixed; right: 56px; z-index: 999999; width: 180px; padding: 16px; border-radius: 10px; background: #fff; border: 1px solid #ddd; color: #333; font: 14px/1.6 system-ui, sans-serif; box-shadow: 0 4px 20px rgba(0,0,0,.2); overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; } .settings-panel.open { display: block; } .settings-panel h3 { margin: 0 0 12px; font-size: 15px; font-weight: 600; color: #222; text-align: center; } .settings-panel label { display: flex; align-items: center; gap: 6px; margin: 4px 0; cursor: pointer; font-size: 13px; } .settings-panel input[type="radio"] { margin: 0; accent-color: #1e90ff; } .settings-panel .group-title { font-size: 12px; color: #888; margin: 10px 0 4px; font-weight: 500; text-align: center; } .settings-panel .option-row { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); column-gap: 8px; width: 100%; margin: 0 0 8px; } .settings-panel .option-row-2 { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 16px; width: 100%; margin: 0 0 8px; } .settings-panel .option-row label { margin: 0; min-width: 0; justify-content: center; } .settings-panel .option-row-2 label { margin: 0; min-width: 0; justify-content: center; } .settings-panel .settings-note { margin-top: 8px; font-size: 12px; color: #b26a00; text-align: center; } .settings-panel .checkbox-row { display: flex; align-items: center; justify-content: center; gap: 6px; margin: 6px 0 8px; font-size: 13px; } .settings-panel .checkbox-row input[type="checkbox"] { margin: 0; accent-color: #1e90ff; } .settings-panel .collection-display { font-size: 13px; color: #555; text-align: center; margin: 2px 0 4px; word-break: break-all; } </style>`; return shadow; } function createBtn(label, modifier) { const root = initShadowHost(); const btn = document.createElement('button'); btn.className = modifier; btn.textContent = label; root.append(btn); return btn; } function createSettingsUI(topOffset) { const root = initShadowHost(); const collectionGroupsHtml = isArchive ? archiveCollections.map((collection, index) => { const groupName = `collection-strategy-${index}`; const strategy = getCollectionStrategy(collection.name); const countText = collection.postCount > 0 ? `(${collection.postCount})` : ''; return ` <div class="group-title">${escapeHtml(collection.name)}${countText}</div> <div class="option-row"> <label><input type="radio" name="${groupName}" value="${GROUP_STRATEGY.MERGE}" data-collection-name="${escapeHtml(collection.name)}"${strategy === GROUP_STRATEGY.MERGE ? ' checked' : ''}> 合并</label> <label><input type="radio" name="${groupName}" value="${GROUP_STRATEGY.SINGLE}" data-collection-name="${escapeHtml(collection.name)}"${strategy === GROUP_STRATEGY.SINGLE ? ' checked' : ''}> 单篇</label> <label><input type="radio" name="${groupName}" value="${GROUP_STRATEGY.SKIP}" data-collection-name="${escapeHtml(collection.name)}"${strategy === GROUP_STRATEGY.SKIP ? ' checked' : ''}> 跳过</label> </div>`; }).join('') : ''; const collectionStatusHtml = isArchive && collectionsLoadError ? `<div class="settings-note">${escapeHtml(collectionsLoadError)}</div>` : ''; // 齿轮按钮 const btn = document.createElement('div'); btn.className = 'settings-btn'; btn.style.top = `${topOffset}px`; btn.textContent = '⚙'; root.append(btn); // 面板 const panel = document.createElement('div'); panel.className = 'settings-panel'; panel.style.top = `${topOffset}px`; panel.style.maxHeight = `calc(100vh - ${topOffset + 24}px)`; panel.innerHTML = ` <h3>设置</h3> <div class="group-title">导出格式</div> <div class="option-row-2"> <label><input type="radio" name="fmt" value="markdown"${settings.format === 'markdown' ? ' checked' : ''}> Markdown</label> <label><input type="radio" name="fmt" value="txt"${settings.format === 'txt' ? ' checked' : ''}> TXT</label> </div> <div class="checkbox-row"> <label><input type="checkbox" name="skip-images"${settings.skipImages ? ' checked' : ''}> 跳过图片</label> </div> ${!isArchive ? ` <div class="group-title">所属合集</div> <div class="collection-display">检测中…</div> ` : ''} ${isArchive ? ` <div class="group-title">散章</div> <div class="option-row"> <label><input type="radio" name="loose-strategy" value="${GROUP_STRATEGY.MERGE}"${settings.looseStrategy === GROUP_STRATEGY.MERGE ? ' checked' : ''}> 合并</label> <label><input type="radio" name="loose-strategy" value="${GROUP_STRATEGY.SINGLE}"${settings.looseStrategy === GROUP_STRATEGY.SINGLE ? ' checked' : ''}> 单篇</label> <label><input type="radio" name="loose-strategy" value="${GROUP_STRATEGY.SKIP}"${settings.looseStrategy === GROUP_STRATEGY.SKIP ? ' checked' : ''}> 跳过</label> </div> ${collectionGroupsHtml} ${collectionStatusHtml} ` : ''} `; root.append(panel); btn.addEventListener('click', () => panel.classList.toggle('open')); panel.querySelectorAll('input[name="fmt"]').forEach(radio => { radio.addEventListener('change', () => { settings.format = radio.value; GM_setValue('lofter_helper_format', radio.value); }); }); panel.querySelectorAll('input[data-collection-name]').forEach(radio => { radio.addEventListener('change', () => { setCollectionStrategy(radio.dataset.collectionName, radio.value); }); }); panel.querySelectorAll('input[name="loose-strategy"]').forEach(radio => { radio.addEventListener('change', () => { settings.looseStrategy = radio.value; GM_setValue('lofter_helper_loose_strategy', radio.value); }); }); const skipImagesCheckbox = panel.querySelector('input[name="skip-images"]'); if (skipImagesCheckbox) { skipImagesCheckbox.addEventListener('change', () => { settings.skipImages = skipImagesCheckbox.checked; GM_setValue('lofter_helper_skip_images', skipImagesCheckbox.checked); }); } } function updateCollectionDisplay(collectionName) { if (!shadow) return; const el = shadow.querySelector('.collection-display'); if (!el) return; el.textContent = collectionName || '未加入合集'; } async function resolveExportContext(setStatus) { if (!isArchive) { return { keyword: '', links: [{ url: location.href, title: '' }], }; } const keyword = prompt('请输入关键词(多个关键词用 | 分隔,留空则导出全部):'); if (keyword === null) return null; setStatus('自动滚动加载中…'); const totalCount = await autoScroll(); setStatus(`已加载 ${totalCount} 篇,正在筛选…`); let links = extractArticleLinks(); const keywords = keyword ? keyword.split('|').map(k => k.trim()).filter(Boolean) : []; if (keywords.length) { links = links.filter(a => keywords.some(k => a.title.includes(k))); } if (!links.length) { alert('未找到匹配的文章'); return null; } return { keyword: keywords.join('-'), links }; } async function fetchArticlesForExport(links, setStatus) { setStatus(`正在抓取正文(共 ${links.length} 篇)…`); return fetchAll(links, (cur, tot) => { setStatus(`抓取正文 ${cur}/${tot}…`); }); } async function executeExport(mode, articles, keyword, setStatus) { if (mode === EXPORT_MODE.ARCHIVE) { setStatus(`正在打包 ${articles.length} 篇为 ZIP…`); await exportArchive(articles, keyword, setStatus); return; } exportSingle(articles[0]); if (!settings.skipImages && articles[0].images.length) { setStatus(`下载图片:${articles[0].title}`); await downloadImages(articles[0], setStatus); } } function buildCompletionMessage(mode, articles) { const totalImages = articles.reduce((sum, a) => sum + (a.images?.length || 0), 0); const skippedMsg = settings.skipImages && totalImages ? `,${totalImages} 张图片已跳过` : ''; if (mode === EXPORT_MODE.ORIGIN) { const article = articles[0]; const imgMsg = !settings.skipImages && article.images.length ? `,${article.images.length} 张图片` : ''; return `导出完成:${article.title}${imgMsg}${skippedMsg}`; } return `导出完成,共 ${articles.length} 篇文章${skippedMsg}`; } // 主流程 async function runExport({ mode, btn }) { const defaultLabel = btn.textContent; const setStatus = msg => { btn.textContent = msg; }; try { btn.disabled = true; if (mode === EXPORT_MODE.ARCHIVE) { const labels = { merge: '合并', single: '单篇', skip: '跳过' }; const lines = [`- LOFTER 导出策略`, `\t- 散章:${labels[settings.looseStrategy]}`]; for (const [name, s] of Object.entries(settings.collectionStrategies)) { lines.push(`\t- ${name}:${labels[s] || s}`); } console.log(lines.join('\n')); } const context = await resolveExportContext(setStatus); if (!context) return; const articles = await fetchArticlesForExport(context.links, setStatus); if (mode === EXPORT_MODE.ORIGIN) updateCollectionDisplay(articles[0]?.collectionName); await executeExport(mode, articles, context.keyword, setStatus); alert(buildCompletionMessage(mode, articles)); } catch (e) { alert(`导出出错:${e.message}`); } finally { btn.textContent = defaultLabel; btn.disabled = false; } } // 初始化 function initPageActions(actions, settingsTopOffset) { for (const action of actions) { const btn = createBtn(action.label, action.modifier); btn.addEventListener('click', () => runExport({ mode: action.mode, btn })); } createSettingsUI(settingsTopOffset); } async function initArchiveCollections() { archiveCollections = []; collectionsLoadError = ''; try { archiveCollections = await fetchAuthorCollections(); syncCollectionStrategies(archiveCollections); if (!archiveCollections.length) { collectionsLoadError = '当前作者无公开合集。'; } } catch (error) { collectionsLoadError = `合集配置加载失败,将按默认策略导出:${error.message}`; settings.collectionStrategies = {}; } } async function init() { author = getPageAuthor() || 'LOFTER'; if (isArchive) { await initArchiveCollections(); initPageActions([ { label: '导出全部', modifier: 'primary', mode: EXPORT_MODE.ARCHIVE }, ], 80); } else { initPageActions([ { label: '导出本篇', modifier: 'success', mode: EXPORT_MODE.ORIGIN }, ], 80); fetchPostDetail(location.href) .then(post => updateCollectionDisplay(post?.postCollection?.name || '')) .catch(() => updateCollectionDisplay('')); } } void init(); })();