LOFTER Helper

LOFTER 一键导出合集/单篇。支持合集自动识别、关键词筛选、图片下载、归档页批量导出、文章页单篇导出。可配置合集/散篇的合并或单篇导出策略。

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==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 => ({
      '&': '&amp;',
      '<': '&lt;',
      '>': '&gt;',
      '"': '&quot;',
      "'": '&#39;',
    }[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 => `![](${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 ? `![${alt}](${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();
})();