LOFTER Helper

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

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==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();
})();