MTSLASH Exporter

Export fanfics to TXT/EPUB from mtslash thread pages.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         MTSLASH Exporter
// @namespace    https://www.mtslash.life/
// @version      1.0.3
// @description  Export fanfics to TXT/EPUB from mtslash thread pages.
// @author       qom
// @match        *://www.mtslash.life/forum.php?mod=viewthread*
// @match        *://www.mtslash.life/thread-*-*-*.html*
// @grant        none
// @run-at       document-idle
// @license      MIT
// ==/UserScript==

(function () {
  "use strict";

  const APP_ID = "mtslash-exporter";
  const STYLE_ID = `${APP_ID}-style`;
  const PANEL_ID = `${APP_ID}-panel`;
  const LAUNCHER_ID = `${APP_ID}-launcher`;
  const STORAGE_KEY = `${APP_ID}-settings`;
  const LOG_LIMIT = 120;
  const CRC_TABLE = createCrc32Table();
  const state = {
    running: false,
    cancelled: false,
    logs: [],
  };
  const defaults = {
    authorMode: "lz",
    format: "epub",
    chapterMode: "simple",
    customHeadingPattern: "",
    customHeadingFlags: "",
  };

  if (!isSupportedThreadPage(document, location.href)) {
    return;
  }

  ensureStyles();
  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", init, { once: true });
  } else {
    init();
  }

  function init() {
    if (document.getElementById(PANEL_ID)) {
      return;
    }

    const panel = buildPanel();
    const launcher = buildLauncher(panel);
    document.body.appendChild(panel);
    document.body.appendChild(launcher);
    setPanelVisibility(panel, launcher, true);
    log("面板已注入,等待导出。");
  }

  function buildPanel() {
    const settings = loadSettings();
    const panel = document.createElement("section");
    panel.id = PANEL_ID;
    panel.innerHTML = `
      <div class="${APP_ID}__header">
        <strong>MTSLASH Exporter</strong>
        <button class="${APP_ID}__close-button" type="button" data-role="closePanel" aria-label="关闭面板">
          ${closeSvg()}
        </button>
      </div>
      <div class="${APP_ID}__row">
        <label>范围</label>
        <select data-role="authorMode">
          <option value="all">全部楼层</option>
          <option value="lz">仅楼主</option>
          <option value="uid">指定 UID</option>
        </select>
      </div>
      <div class="${APP_ID}__row" data-role="uidRow" hidden>
        <label>UID</label>
        <input data-role="uid" type="text" placeholder="输入目标用户 UID" />
      </div>
      <div class="${APP_ID}__row">
        <label>格式</label>
        <select data-role="format">
          <option value="txt">TXT</option>
          <option value="epub">EPUB</option>
        </select>
      </div>
      <div class="${APP_ID}__row">
        <label>章节</label>
        <select data-role="chapterMode">
          <option value="simple">Chapter N</option>
          <option value="custom">自定义正则</option>
        </select>
      </div>
      <div class="${APP_ID}__settings" data-role="settingsPanel">
        <div class="${APP_ID}__field">
          <label for="${APP_ID}-custom-pattern">分节正则</label>
          <input id="${APP_ID}-custom-pattern" data-role="customHeadingPattern" type="text" placeholder="例如 ^(?:第[0-9一二三四五六七八九十]+章|番外)$" value="${escapeAttribute(settings.customHeadingPattern)}" />
        </div>
        <div class="${APP_ID}__field">
          <label for="${APP_ID}-custom-flags">Flags</label>
          <input id="${APP_ID}-custom-flags" data-role="customHeadingFlags" type="text" placeholder="例如 i" value="${escapeAttribute(settings.customHeadingFlags)}" />
        </div>
        <p class="${APP_ID}__hint">选择“自定义正则”时,按逐行匹配章节标题处理。</p>
      </div>
      <div class="${APP_ID}__row ${APP_ID}__actions">
        <button class="${APP_ID}__button ${APP_ID}__button--primary" data-role="export">导出</button>
        <button class="${APP_ID}__button ${APP_ID}__button--secondary" data-role="cancel" disabled>取消</button>
      </div>
      <div class="${APP_ID}__meta">
        <div class="${APP_ID}__log-label">运行日志</div>
        <div class="${APP_ID}__status" data-role="status">空闲</div>
      </div>
      <textarea class="${APP_ID}__log" data-role="log" readonly></textarea>
    `;

    const authorMode = panel.querySelector('[data-role="authorMode"]');
    const uidInput = panel.querySelector('[data-role="uid"]');
    const exportBtn = panel.querySelector('[data-role="export"]');
    const cancelBtn = panel.querySelector('[data-role="cancel"]');
    const closeBtn = panel.querySelector('[data-role="closePanel"]');
    const chapterMode = panel.querySelector('[data-role="chapterMode"]');
    const settingsPanel = panel.querySelector('[data-role="settingsPanel"]');
    const patternInput = panel.querySelector('[data-role="customHeadingPattern"]');
    const flagsInput = panel.querySelector('[data-role="customHeadingFlags"]');

    const uidRow = panel.querySelector('[data-role="uidRow"]');

    authorMode.value = defaults.authorMode;
    panel.querySelector('[data-role="format"]').value = defaults.format;
    chapterMode.value = settings.chapterMode || defaults.chapterMode;
    uidRow.hidden = authorMode.value !== "uid";

    authorMode.addEventListener("change", () => {
      uidRow.hidden = authorMode.value !== "uid";
      if (authorMode.value !== "uid") {
        uidInput.value = "";
      }
    });

    chapterMode.addEventListener("change", () => {
      syncSettingsPanelState();
      persistPanelSettings();
    });

    patternInput.addEventListener("change", persistPanelSettings);
    flagsInput.addEventListener("change", persistPanelSettings);

    function syncSettingsPanelState() {
      settingsPanel.hidden = chapterMode.value !== "custom";
    }

    function persistPanelSettings() {
      const normalizedFlags = normalizeRegexFlags(flagsInput.value);
      flagsInput.value = normalizedFlags;
      saveSettings({
        chapterMode: chapterMode.value,
        customHeadingPattern: patternInput.value.trim(),
        customHeadingFlags: normalizedFlags,
      });
    }

    syncSettingsPanelState();

    closeBtn.addEventListener("click", () => {
      const launcher = document.getElementById(LAUNCHER_ID);
      setPanelVisibility(panel, launcher, false);
    });

    exportBtn.addEventListener("click", async () => {
      if (state.running) {
        return;
      }
      try {
        await runExport(panel);
      } catch (error) {
        setStatus(panel, `失败:${error.message}`);
        log(`失败:${error.stack || error.message}`);
      } finally {
        setRunning(panel, false);
      }
    });

    cancelBtn.addEventListener("click", () => {
      state.cancelled = true;
      log("已请求取消,当前页结束后停止。");
      setStatus(panel, "正在取消...");
    });

    return panel;
  }

  function buildLauncher(panel) {
    const launcher = document.createElement("button");
    launcher.id = LAUNCHER_ID;
    launcher.type = "button";
    launcher.innerHTML = gearSvg();
    launcher.setAttribute("aria-label", "打开导出面板");

    enableLauncherDragging(launcher);

    launcher.addEventListener("click", () => {
      if (launcher.dataset.dragged === "true") {
        launcher.dataset.dragged = "false";
        return;
      }
      setPanelVisibility(panel, launcher, true);
    });

    return launcher;
  }

  function setPanelVisibility(panel, launcher, visible) {
    if (panel) {
      panel.hidden = !visible;
    }
    if (launcher) {
      launcher.hidden = visible;
    }
  }

  function enableLauncherDragging(launcher) {
    let dragState = null;

    launcher.addEventListener("pointerdown", (event) => {
      if (event.button !== 0) {
        return;
      }
      const rect = launcher.getBoundingClientRect();
      dragState = {
        pointerId: event.pointerId,
        startX: event.clientX,
        startY: event.clientY,
        offsetX: event.clientX - rect.left,
        offsetY: event.clientY - rect.top,
        moved: false,
      };
      launcher.dataset.dragged = "false";
      launcher.setPointerCapture(event.pointerId);
    });

    launcher.addEventListener("pointermove", (event) => {
      if (!dragState || event.pointerId !== dragState.pointerId) {
        return;
      }
      const deltaX = event.clientX - dragState.startX;
      const deltaY = event.clientY - dragState.startY;
      if (!dragState.moved && Math.hypot(deltaX, deltaY) < 15) {
        return;
      }
      dragState.moved = true;
      launcher.dataset.dragged = "true";
      const left = clamp(event.clientX - dragState.offsetX, 8, window.innerWidth - launcher.offsetWidth - 8);
      const top = clamp(event.clientY - dragState.offsetY, 8, window.innerHeight - launcher.offsetHeight - 8);
      launcher.style.left = `${left}px`;
      launcher.style.top = `${top}px`;
      launcher.style.right = "auto";
      launcher.style.bottom = "auto";
    });

    launcher.addEventListener("pointerup", (event) => {
      if (!dragState || event.pointerId !== dragState.pointerId) {
        return;
      }
      launcher.releasePointerCapture(event.pointerId);
      const wasDragged = dragState.moved;
      dragState = null;
      launcher.dataset.dragged = "false";

      if (!wasDragged) {
        setPanelVisibility(panel, launcher, true);
      }
    });

    launcher.addEventListener("pointercancel", (event) => {
      if (!dragState || event.pointerId !== dragState.pointerId) {
        return;
      }
      launcher.releasePointerCapture(event.pointerId);
      dragState = null;
      launcher.dataset.dragged = "false";
    });

    launcher.addEventListener("click", (event) => {
      event.preventDefault();
      // Handled in pointerup to prevent touch capture bugs
    });
  }

  async function runExport(panel) {
    const authorMode = panel.querySelector('[data-role="authorMode"]').value;
    const format = panel.querySelector('[data-role="format"]').value;
    const chapterMode = panel.querySelector('[data-role="chapterMode"]').value;
    const targetUidRaw = panel.querySelector('[data-role="uid"]').value.trim();
    const targetUid = targetUidRaw || undefined;
    const customHeadingPattern = panel.querySelector('[data-role="customHeadingPattern"]').value.trim();
    const customHeadingFlags = normalizeRegexFlags(panel.querySelector('[data-role="customHeadingFlags"]').value);
    const customHeadingRegex = buildCustomHeadingRegex(chapterMode, customHeadingPattern, customHeadingFlags);

    if (authorMode === "uid" && !/^\d+$/.test(targetUid || "")) {
      throw new Error("指定作者模式需要填写纯数字 UID");
    }

    state.logs = [];
    state.cancelled = false;
    setRunning(panel, true);
    setStatus(panel, "准备抓取...");
    log(`开始导出,格式=${format},范围=${authorMode}${targetUid ? `:${targetUid}` : ""},章节模式=${chapterMode}`);

    const context = extractThreadContext(document, location.href);
    const pageTargets = createPageTargets(context);

    log(`目标页数:${pageTargets.length},当前线程总页数:${context.pageCount}`);

    const collectedPosts = [];
    const failures = [];

    let firstPageDoc = context.currentPage === 1 ? document : null;

    for (let i = 0; i < pageTargets.length; i += 1) {
      if (state.cancelled) {
        break;
      }

      const target = pageTargets[i];
      setStatus(panel, `抓取第 ${target.page} 页 (${i + 1}/${pageTargets.length})`);

      try {
        const pageDoc = target.page === context.currentPage
          ? document
          : await fetchThreadPage(target.url);
        const pageContext = extractThreadContext(pageDoc, target.url);
        const posts = extractPosts(pageDoc, pageContext);
        if (pageContext.currentPage === 1 && !firstPageDoc) {
          firstPageDoc = pageDoc;
        }
        collectedPosts.push(...posts);
        log(`第 ${target.page} 页完成,解析 ${posts.length} 条帖子`);
      } catch (error) {
        failures.push({ page: target.page, message: error.message });
        log(`第 ${target.page} 页失败:${error.message}`);
      }

      if (i < pageTargets.length - 1 && !state.cancelled) {
        await delay(randomInt(1200, 2200));
      }
    }

    const filtered = filterPosts(collectedPosts, context, authorMode, targetUid);
    if (!filtered.length) {
      throw new Error("过滤后没有可导出的正文");
    }

    const frontMatter = extractFrontMatter(firstPageDoc || document);
    const exportPayload = {
      context,
      posts: filtered,
      authorMode,
      chapterMode,
      customHeadingRegex,
      targetUid,
      frontMatter,
      failures,
      partial: state.cancelled || failures.length > 0,
    };

    const normalizedFM = normalizeFrontMatter(frontMatter, context);
    const resolvedAuthor = normalizedFM["作者"] || context.lzName || filtered[0]?.authorName;
    const filename = buildFilename(context.title, authorMode, resolvedAuthor, format);
    if (format === "epub") {
      setStatus(panel, "正在生成 EPUB...");
      const epubBlob = buildEpub(exportPayload);
      downloadBlob(filename, epubBlob);
    } else {
      const txt = renderTxt(exportPayload);
      downloadTextFile(filename, txt);
    }

    const doneMessage = state.cancelled
      ? `已取消并导出已完成部分,共 ${filtered.length} 条`
      : failures.length
        ? `部分成功,已导出 ${filtered.length} 条,失败页 ${failures.map((item) => item.page).join(", ")}`
        : `导出完成,共 ${filtered.length} 条`;

    setStatus(panel, doneMessage);
    log(doneMessage);
  }

  function extractThreadContext(doc, url) {
    const normalizedUrl = new URL(url, location.origin);
    const title = extractThreadTitle(doc);
    if (!title) {
      throw new Error("未找到帖子标题,当前页面可能不是可访问的线程页");
    }

    const pageData = parsePageData(doc, normalizedUrl);
    const tid = extractTid(normalizedUrl, doc);
    const posts = collectPostNodes(doc);
    if (!posts.length) {
      throw new Error("未找到帖子楼层,页面可能是权限页或异常页");
    }

    // Extract LZ (original poster) info.
    // On non-first pages, posts[0] is NOT the OP. We use multiple strategies
    // ordered by reliability:
    //
    // 1. #tath header — desktop template, explicitly labeled "楼主: XXX".
    //    Contains <a href="space-uid-XXXXX.html">楼主名</a>.
    //
    // 2. Mobile "只看楼主" link — mobile template only, a single link whose
    //    TEXT is "只看楼主" with authorid=<OP_UID> in href.
    //    IMPORTANT: Desktop has "只看该作者" links per-post with different
    //    authorids — those are NOT the OP! Must match by link text.
    //
    // 3. posts[0] fallback — only correct on page 1.
    let lzUid = null;
    let lzName = null;

    // Strategy 1: #tath header (desktop template — most reliable)
    const tathHeader = doc.querySelector('#tath');
    if (tathHeader) {
      // #tath has two links: avatar (no text) and name link (has text).
      // We need the one with actual text content.
      const tathLinks = tathHeader.querySelectorAll('a[href*="space-uid-"], a[href*="mod=space"]');
      for (const lzLink of tathLinks) {
        const href = lzLink.getAttribute('href') || '';
        const uidMatch = href.match(/uid[-=](\d+)/);
        const name = textOf(lzLink) || lzLink.getAttribute('title') || '';
        if (uidMatch) {
          lzUid = uidMatch[1];
          if (name) {
            lzName = name;
          }
        }
      }
    }

    // Strategy 2: Mobile "只看楼主" link (must match by text, not just href)
    if (!lzUid) {
      const allAuthoridLinks = doc.querySelectorAll('a[href*="authorid"]');
      for (const link of allAuthoridLinks) {
        const linkText = (link.textContent || '').trim();
        if (linkText === '只看楼主') {
          const href = link.getAttribute('href') || '';
          const uidMatch = href.match(/authorid[=](\d+)/);
          if (uidMatch) {
            lzUid = uidMatch[1];
          }
          break;
        }
      }
    }

    // Strategy 3: First post fallback (works only on page 1)
    if (!lzUid) {
      lzUid = extractUidFromPost(posts[0]);
      lzName = extractAuthorName(posts[0]);
    }

    // If we got lzUid but not lzName, try to find the name from matching posts
    if (lzUid && !lzName) {
      for (const postNode of posts) {
        if (extractUidFromPost(postNode) === lzUid) {
          lzName = extractAuthorName(postNode);
          break;
        }
      }
    }

    // Sanitize lzName: filter out known Discuz UI labels that could
    // be accidentally captured as author names.
    const uiNoiseNames = ['只看楼主', '只看该作者', '收藏', '回复', '举报', '未知作者'];
    if (lzName && uiNoiseNames.includes(lzName)) {
      lzName = null;
    }

    return {
      title,
      tid,
      pageCount: pageData.pageCount,
      currentPage: pageData.currentPage,
      canonicalBase: pageData.canonicalBase,
      canonicalUrl: buildCanonicalThreadUrl(tid),
      currentUrl: normalizedUrl.toString(),
      lzUid,
      lzName,
    };
  }

  function parsePageData(doc, currentUrl) {
    const pager = findThreadPager(doc, currentUrl);
    const pagerValues = new Set();
    const currentPageFromUrl = parsePageValue(currentUrl) || 1;
    const mobilePageSelect = pager ? pager.querySelector('select#dumppage, select[name="page"]') : null;

    if (pager) {
      pager.querySelectorAll("a, strong, em").forEach((node) => {
        const value = parseInt((node.textContent || "").trim(), 10);
        if (!Number.isNaN(value)) {
          pagerValues.add(value);
        }
        if (!(node instanceof HTMLAnchorElement)) {
          return;
        }
        try {
          const target = new URL(node.getAttribute("href"), currentUrl);
          if (!isSameThreadPage(target, currentUrl)) {
            return;
          }
          const pageValue = parsePageValue(target);
          if (pageValue) {
            pagerValues.add(pageValue);
          }
        } catch (_error) {
          // Ignore malformed pagination hrefs.
        }
      });
    }

    if (mobilePageSelect) {
      Array.from(mobilePageSelect.options).forEach((option) => {
        const value = parseInt(option.value || option.textContent || "", 10);
        if (!Number.isNaN(value)) {
          pagerValues.add(value);
        }
      });
    }

    const pageInput = pager ? pager.querySelector('label input[type="text"]') : null;
    const mobileCurrentOption = mobilePageSelect
      ? mobilePageSelect.options[mobilePageSelect.selectedIndex]
      : null;
    const currentPage = pageInput
      ? parseInt(pageInput.value, 10) || currentPageFromUrl
      : mobileCurrentOption
        ? parseInt(mobileCurrentOption.value || mobileCurrentOption.textContent || "", 10) || currentPageFromUrl
        : currentPageFromUrl;

    let pageCount = Math.max(...Array.from(pagerValues), currentPage, 1);
    const pageLabel = pager ? pager.querySelector("label") : null;
    const match = pageLabel ? pageLabel.textContent.match(/\/\s*(\d+)\s*页/) : null;
    if (match) {
      pageCount = parseInt(match[1], 10);
    } else if (mobilePageSelect && mobilePageSelect.options.length) {
      pageCount = Math.max(
        ...Array.from(mobilePageSelect.options).map((option) => (
          parseInt(option.value || option.textContent || "", 10) || 0
        )),
        currentPage,
        1,
      );
    }

    return {
      currentPage,
      pageCount,
      canonicalBase: currentUrl.toString(),
    };
  }

  function createPageTargets(context) {
    const targets = [];
    for (let page = 1; page <= context.pageCount; page += 1) {
      const url = buildPageUrl(context, page);
      targets.push({ page, url });
    }
    return targets;
  }

  function buildPageUrl(context, page) {
    const currentUrl = new URL(context.currentUrl || location.href, location.origin);
    if (currentUrl.pathname.includes("thread-")) {
      currentUrl.pathname = `/thread-${context.tid}-${page}-1.html`;
      currentUrl.search = "";
      return currentUrl.toString();
    }
    currentUrl.searchParams.set("mod", "viewthread");
    currentUrl.searchParams.set("tid", context.tid);
    currentUrl.searchParams.set("page", String(page));
    return currentUrl.toString();
  }

  function buildCanonicalThreadUrl(tid) {
    return `${location.origin}/thread-${tid}-1-1.html`;
  }

  function findThreadPager(doc, currentUrl) {
    const pagers = Array.from(doc.querySelectorAll(".pg, .page"));
    if (!pagers.length) {
      return null;
    }

    let bestPager = null;
    let bestScore = -1;
    pagers.forEach((pager) => {
      let score = 0;
      pager.querySelectorAll("a, strong, em").forEach((node) => {
        const text = (node.textContent || "").trim();
        if (/^\d+$/.test(text)) {
          score += 1;
        }
        if (!(node instanceof HTMLAnchorElement)) {
          return;
        }
        try {
          const target = new URL(node.getAttribute("href"), currentUrl);
          if (isSameThreadPage(target, currentUrl)) {
            score += 3;
          }
        } catch (_error) {
          // Ignore malformed pagination hrefs.
        }
      });
      if (pager.querySelector('label input[type="text"]')) {
        score += 2;
      }
      const mobilePageSelect = pager.querySelector('select#dumppage, select[name="page"]');
      if (mobilePageSelect) {
        score += 4;
        score += mobilePageSelect.querySelectorAll("option").length;
      }
      if (score > bestScore) {
        bestScore = score;
        bestPager = pager;
      }
    });

    return bestScore > 0 ? bestPager : pagers[0];
  }

  async function fetchThreadPage(url) {
    let currentUrl = url;
    for (let attempt = 1; attempt <= 3; attempt += 1) {
      try {
        log(`请求第 ${attempt} 次:${currentUrl}`);
        const response = await fetch(currentUrl, {
          credentials: "include",
          method: "GET",
        });
        if (!response.ok) {
          throw new Error(`HTTP ${response.status}`);
        }

        const buffer = await response.arrayBuffer();
        const html = decodeHtml(buffer, response);

        // Detect Discuz _dsign JS challenge pages:
        // These are tiny pages (< 4KB) containing only a <script> that does
        // location.replace(url + '?_dsign=xxx'). fetch() can't execute JS,
        // so we parse out the redirect URL and retry.
        const challengeUrl = extractDsignChallengeUrl(html, currentUrl);
        if (challengeUrl) {
          log(`检测到 _dsign 安全验证,重定向至: ${challengeUrl}`);
          currentUrl = challengeUrl;
          await delay(randomInt(300, 800));
          continue;
        }

        if (looksLikeBlockedPage(html)) {
          throw new Error("命中登录/权限/重载提示页");
        }

        const parser = new DOMParser();
        const doc = parser.parseFromString(html, "text/html");
        if (!isSupportedThreadPage(doc, url)) {
          throw new Error("返回内容不是有效线程页");
        }
        return doc;
      } catch (error) {
        if (attempt >= 3) {
          throw error;
        }
        await delay(Math.min(1500 * Math.pow(2, attempt - 1), 8000) + randomInt(500, 1500));
      }
    }
    throw new Error("抓取失败");
  }

  /**
   * Detect and extract the redirect URL from Discuz's _dsign JS challenge page.
   *
   * When attackevasive is enabled, Discuz returns a small page containing only
   * obfuscated JS that calls location.replace(url + '?_dsign=xxx').
   * Since fetch() doesn't execute JS, we need to parse the URL ourselves.
   *
   * Returns the redirect URL string, or null if this is not a challenge page.
   */
  function extractDsignChallengeUrl(html, originalUrl) {
    // Quick check: challenge pages are very small and script-only
    if (html.length > 5000) {
      return null;
    }
    if (!html.includes('location') || !html.includes('replace')) {
      return null;
    }
    // Must not contain normal page indicators
    if (html.includes('postmessage_') || html.includes('thread_subject')) {
      return null;
    }

    // Try to extract the URL by executing the challenge JS in a sandboxed way.
    // The challenge script typically has TWO URL assignments:
    //   1. location.replace(fullURL_with_dsign) — the real redirect
    //   2. window.href = truncatedURL — a decoy/fallback
    // We track them separately and prefer the location.replace capture.
    try {
      const scriptMatch = html.match(/<script[^>]*>([\s\S]*?)<\/script>/i);
      if (!scriptMatch) {
        return null;
      }
      const scriptCode = scriptMatch[1];

      let locationUrl = null;
      let windowUrl = null;
      const locationProxy = new Proxy({}, {
        set(_target, prop, value) {
          if (typeof value === 'string' && value.includes('thread-')) {
            locationUrl = value;
          }
          return true;
        },
        get(_target, prop) {
          if (prop === 'replace' || prop === 'assign') {
            return function(url) { locationUrl = url; };
          }
          if (prop === 'href') {
            return '';
          }
          return undefined;
        },
      });

      const windowProxy = new Proxy({}, {
        set(_target, prop, value) {
          if (typeof value === 'string' && value.includes('thread-')) {
            windowUrl = value;
          }
          return true;
        },
        get(_target, prop) {
          if (prop === 'href') return '';
          return undefined;
        },
      });

      // Build and run the sandboxed function
      const fn = new Function(
        'location', 'window',
        scriptCode,
      );
      fn(locationProxy, windowProxy);

      // Prefer the URL captured from location.replace (it has _dsign),
      // fall back to window.href, and finally prefer whichever has _dsign
      const candidates = [locationUrl, windowUrl].filter(Boolean);
      const bestUrl = candidates.find((u) => u.includes('_dsign') || u.includes('dsign')) || candidates[0];
      if (bestUrl) {
        const resolved = new URL(bestUrl, originalUrl);
        return resolved.toString();
      }
    } catch (_error) {
      // Sandboxed eval failed — fall back to regex extraction
    }

    // Fallback: try to find _dsign value via regex in the raw script
    const dsignMatch = html.match(/_dsign['"]?\s*[=+]\s*['"]([a-f0-9]+)/i) ||
                       html.match(/dsign=([a-f0-9]{6,})/i);
    if (dsignMatch) {
      try {
        const base = new URL(originalUrl);
        base.searchParams.set('_dsign', dsignMatch[1]);
        return base.toString();
      } catch (_error) {
        // ignore
      }
    }

    return null;
  }

  function decodeHtml(buffer, response) {
    const charset = detectCharset(response);
    try {
      return new TextDecoder(charset).decode(buffer);
    } catch (_error) {
      return new TextDecoder("utf-8").decode(buffer);
    }
  }

  function detectCharset(response) {
    const contentType = response.headers.get("content-type") || "";
    const headerMatch = contentType.match(/charset=([^;]+)/i);
    if (headerMatch) {
      return normalizeCharset(headerMatch[1]);
    }
    if (location.hostname === "www.mtslash.life") {
      return "gbk";
    }
    return "utf-8";
  }

  function normalizeCharset(charset) {
    const value = String(charset || "").trim().toLowerCase();
    if (value === "gb2312" || value === "gbk" || value === "gb18030") {
      return "gbk";
    }
    return value || "utf-8";
  }

  function extractPosts(doc, context) {
    const postNodes = collectPostNodes(doc);
    return postNodes.map((postNode) => {
      const messageNode = findMessageNode(postNode);
      if (!messageNode) {
        return null;
      }

      const cleanFragment = messageNode.cloneNode(true);
      cleanPostFragment(cleanFragment);
      const rawHtml = messageNode.innerHTML;
      const cleanHtml = cleanFragment.innerHTML;
      const text = htmlToText(cleanFragment);

      return {
        postId: postNode.id.replace("post_", ""),
        page: context.currentPage,
        floor: extractFloor(postNode),
        authorName: extractAuthorName(postNode),
        authorUid: extractUidFromPost(postNode),
        authorProfileUrl: extractAuthorProfileUrl(postNode),
        publishedAt: extractPublishedAt(postNode),
        isLz: extractUidFromPost(postNode) === context.lzUid,
        fromMobile: /来自手机/.test(textOf(postNode.querySelector(".authi"))),
        rawHtml,
        cleanHtml,
        text,
      };
    }).filter(Boolean);
  }

  function cleanPostFragment(root) {
    root.querySelectorAll([
      ".pstatus",
      ".quote",
      "blockquote",
      ".aimg_tip",
      ".pct",
      ".sign",
      "script",
      "style",
    ].join(",")).forEach((node) => node.remove());

    root.querySelectorAll("img").forEach((img) => {
      const alt = (img.getAttribute("alt") || "").trim();
      const replacement = alt ? `[图片:${alt}]` : "[图片]";
      img.replaceWith(document.createTextNode(replacement));
    });
  }

  function htmlToText(root) {
    const working = root.cloneNode(true);

    working.querySelectorAll("br").forEach((br) => {
      br.replaceWith("\n");
    });

    const blockSelectors = [
      "p",
      "div",
      "section",
      "article",
      "li",
      "tr",
      "td",
      "h1",
      "h2",
      "h3",
      "h4",
      "h5",
      "h6",
    ];

    working.querySelectorAll(blockSelectors.join(",")).forEach((node) => {
      if (!node.textContent || !node.textContent.trim()) {
        return;
      }
      if (!node.textContent.endsWith("\n")) {
        node.appendChild(document.createTextNode("\n"));
      }
    });

    let text = working.textContent || "";
    text = text.replace(/\r/g, "");
    text = text.replace(/[ \t]+\n/g, "\n");
    text = text.replace(/\n{3,}/g, "\n\n");
    text = text.replace(/^\s+|\s+$/g, "");

    const noisePatterns = [
      /本帖最后由.+?编辑/g,
      /电梯直达/g,
      /显示全部楼层/g,
      /倒序浏览/g,
      /阅读模式/g,
      /点评\s*回复\s*举报/g,
      /只看楼主/g,
      /只看该作者/g,
    ];
    noisePatterns.forEach((pattern) => {
      text = text.replace(pattern, "");
    });

    return text.replace(/\n{3,}/g, "\n\n").trim();
  }

  function extractFrontMatter(doc) {
    const firstPost = collectPostNodes(doc)[0];
    if (!firstPost) {
      return {};
    }
    const knownFieldOrder = ["标题", "原作", "作者", "译者", "分级", "警告", "配对", "标签", "摘要", "注释", "原文地址"];
    const aliasMap = {
      cp: "配对",
      CP: "配对",
      配對: "配对",
      tag: "标签",
      tags: "标签",
      Tags: "标签",
      summary: "摘要",
      Summary: "摘要",
      简介: "摘要",
      notes: "注释",
      Notes: "注释",
      备注: "注释",
      note: "注释",
      原文链接: "原文地址",
      原文: "原文地址",
      链接: "原文地址",
      link: "原文地址",
      Link: "原文地址",
      分类: "分类",
      类型: "分类",
    };
    const result = {};

    const typeRows = Array.from(firstPost.querySelectorAll(".typeoption tr, .cgtl tr"));
    typeRows.forEach((row) => {
      const key = textOf(row.querySelector("th")).replace(/[::]\s*$/, "").trim();
      const value = textOf(row.querySelector("td"));
      if (!key || !value) {
        return;
      }
      const normalizedKey = aliasMap[key] || key;
      if (!(normalizedKey in result)) {
        result[normalizedKey] = value;
      }
    });

    const message = findMessageNode(firstPost);
    if (!message) {
      return orderFrontMatter(result, knownFieldOrder);
    }

    const cleanFragment = message.cloneNode(true);
    cleanPostFragment(cleanFragment);
    const lines = extractStructuredLines(cleanFragment).slice(0, 40);

    lines.forEach((line) => {
      const match = line.match(/^([^::]{1,8})\s*[::]\s*(.+)$/);
      if (!match) {
        return;
      }
      const rawKey = match[1].trim();
      const value = match[2].trim();
      if (!value) {
        return;
      }
      const normalizedKey = aliasMap[rawKey] || rawKey;
      if (!knownFieldOrder.includes(normalizedKey) && rawKey.length > 8) {
        return;
      }
      if (!(normalizedKey in result)) {
        result[normalizedKey] = value;
      }
    });

    return orderFrontMatter(result, knownFieldOrder);
  }

  function orderFrontMatter(result, knownFieldOrder) {
    const ordered = {};
    knownFieldOrder.forEach((key) => {
      if (key in result) {
        ordered[key] = result[key];
      }
    });

    Object.keys(result).forEach((key) => {
      if (!(key in ordered)) {
        ordered[key] = result[key];
      }
    });

    return ordered;
  }

  function extractStructuredLines(root) {
    const working = root.cloneNode(true);
    working.querySelectorAll("br").forEach((br) => br.replaceWith("\n"));
    let text = working.textContent || "";
    text = text.replace(/\r/g, "");
    text = text.replace(/\u00a0/g, " ");
    text = text.replace(/[ \t]+\n/g, "\n");
    text = text.replace(/\n{3,}/g, "\n\n");
    return text
      .split("\n")
      .map((line) => line.trim())
      .filter(Boolean);
  }

  function filterPosts(posts, context, authorMode, targetUid) {
    const deduped = dedupePosts(posts).sort((a, b) => {
      if (a.page !== b.page) {
        return a.page - b.page;
      }
      return (a.floor || 0) - (b.floor || 0);
    });

    if (authorMode === "all") {
      return deduped.filter((post) => post.text);
    }
    if (authorMode === "lz") {
      return deduped.filter((post) => post.authorUid && post.authorUid === context.lzUid && post.text);
    }
    return deduped.filter((post) => post.authorUid === targetUid && post.text);
  }

  function dedupePosts(posts) {
    const map = new Map();
    posts.forEach((post) => {
      if (!map.has(post.postId)) {
        map.set(post.postId, post);
      }
    });
    return Array.from(map.values());
  }

  function renderTxt({ context, posts, authorMode, targetUid, frontMatter, failures, partial }) {
    const lines = [];
    const normalizedFrontMatter = normalizeFrontMatter(frontMatter, context);
    const mainAuthor = authorMode === "lz"
      ? normalizedFrontMatter["作者"] || context.lzName
      : authorMode === "uid"
        ? `${posts[0]?.authorName || ""} (${targetUid})`
        : normalizedFrontMatter["作者"] || context.lzName || posts[0]?.authorName || "未知作者";

    lines.push(`标题: ${context.title}`);
    lines.push(`作者: ${mainAuthor}`);
    lines.push(`来源: ${context.canonicalUrl || context.currentUrl}`);
    lines.push("");

    const fmEntries = Object.entries(normalizedFrontMatter || {}).filter(([key, value]) => {
      if (!value) {
        return false;
      }
      if (key === "标题") {
        return false;
      }
      if (key === "作者" && value === mainAuthor) {
        return false;
      }
      return true;
    });
    if (fmEntries.length) {
      lines.push("作品信息:");
      fmEntries.forEach(([key, value]) => {
        lines.push(`${key}: ${value}`);
      });
      lines.push("");
    }

    if (partial) {
      lines.push("状态: 部分导出");
      if (failures.length) {
        lines.push(`失败页: ${failures.map((item) => item.page).join(", ")}`);
      }
      lines.push("");
    }

    lines.push("==================================================");
    lines.push("");

    const groupedSingleAuthor = authorMode !== "all";
    posts.forEach((post, index) => {
      if (!groupedSingleAuthor) {
        lines.push(`[第 ${post.floor || "?"} 楼] ${post.authorName}${post.publishedAt ? ` / ${post.publishedAt}` : ""}`);
        lines.push("");
      } else if (index > 0) {
        lines.push("");
        lines.push("");
      }
      lines.push(post.text);
    });

    return lines.join("\n").replace(/\n{3,}/g, "\n\n");
  }

  function normalizeFrontMatter(frontMatter, context) {
    const orderedKeys = ["标题", "原作", "作者", "译者", "分级", "警告", "配对", "标签", "摘要", "注释", "原文地址"];
    const normalized = {};
    const source = frontMatter || {};

    orderedKeys.forEach((key) => {
      if (source[key]) {
        normalized[key] = source[key];
      }
    });

    Object.keys(source).forEach((key) => {
      if (!(key in normalized) && source[key]) {
        normalized[key] = source[key];
      }
    });

    if (!normalized["标题"]) {
      normalized["标题"] = context.title;
    }
    if (!normalized["作者"] && context.lzName) {
      normalized["作者"] = context.lzName;
    }

    return normalized;
  }

  function buildFilename(title, authorMode, authorName, format) {
    const ext = format === "epub" ? "epub" : "txt";
    if (authorMode === "all" || !authorName) {
      return `${sanitizeFilename(title)}.${ext}`;
    }
    return `${sanitizeFilename(`${title} - ${authorName}`)}.${ext}`;
  }

  function downloadTextFile(filename, text) {
    const blob = new Blob([text], { type: "text/plain;charset=utf-8" });
    downloadBlob(filename, blob);
  }

  function downloadBlob(filename, blob) {
    const url = URL.createObjectURL(blob);
    const a = document.createElement("a");
    a.href = url;
    a.download = filename;
    document.body.appendChild(a);
    a.click();
    a.remove();
    setTimeout(() => URL.revokeObjectURL(url), 1500);
  }

  function buildEpub({ context, posts, authorMode, chapterMode, customHeadingRegex, targetUid, frontMatter, failures, partial }) {
    const normalizedFrontMatter = normalizeFrontMatter(frontMatter, context);
    const mainAuthor = authorMode === "lz"
      ? normalizedFrontMatter["作者"] || context.lzName
      : authorMode === "uid"
        ? `${posts[0]?.authorName || ""}${targetUid ? ` (${targetUid})` : ""}`
        : normalizedFrontMatter["作者"] || context.lzName || posts[0]?.authorName || "未知作者";
    const bookId = `urn:uuid:${makeUuidLike(context.tid, context.title, mainAuthor)}`;
    const lang = "zh-CN";
    const nowIso = new Date().toISOString();

    const chapters = buildEpubChapters(posts, chapterMode, customHeadingRegex);
    const infoXhtml = renderInfoPage(context, normalizedFrontMatter, failures, partial, mainAuthor);
    const navXhtml = renderNavPage(context.title, chapters);
    const opf = renderContentOpf({
      context,
      mainAuthor,
      normalizedFrontMatter,
      bookId,
      lang,
      nowIso,
      chapters,
    });

    const files = [
      { name: "mimetype", data: "application/epub+zip", compress: false },
      { name: "META-INF/container.xml", data: renderContainerXml(), compress: false },
      { name: "OEBPS/nav.xhtml", data: navXhtml, compress: false },
      { name: "OEBPS/text/info.xhtml", data: infoXhtml, compress: false },
      { name: "OEBPS/content.opf", data: opf, compress: false },
      ...chapters.map((chapter, index) => ({
        name: `OEBPS/text/chapter-${index + 1}.xhtml`,
        data: renderChapterXhtml(context.title, chapter),
        compress: false,
      })),
    ];

    const zipBytes = createZip(files);
    return new Blob([zipBytes], { type: "application/epub+zip" });
  }

  function buildEpubChapters(posts, chapterMode, customHeadingRegex) {
    if (chapterMode === "custom" && customHeadingRegex) {
      const customChapters = buildRegexEpubChapters(posts, customHeadingRegex);
      if (customChapters.length) {
        return customChapters.map((chapter, index) => ({
          id: `chapter-${index + 1}`,
          navTitle: chapter.headingTitle || `Chapter ${index + 1}`,
          headingTitle: chapter.headingTitle || "",
          body: chapter.body,
        }));
      }
    }

    return posts.map((post, index) => ({
      id: `chapter-${index + 1}`,
      navTitle: `Chapter ${index + 1}`,
      headingTitle: "",
      body: post.text,
    }));
  }

  function buildRegexEpubChapters(posts, headingRegex) {
    const chapters = [];
    let current = null;

    posts.forEach((post) => {
      const segments = splitPostIntoSegments(post.text, headingRegex);
      if (!segments.length) {
        return;
      }

      segments.forEach((segment) => {
        if (segment.headingTitle) {
          if (current && current.body.trim()) {
            chapters.push(current);
          }
          current = {
            headingTitle: segment.headingTitle,
            body: segment.body,
          };
          return;
        }

        if (!current) {
          current = {
            headingTitle: "",
            body: segment.body,
          };
          return;
        }

        current.body = `${current.body}\n\n${segment.body}`.trim();
      });
    });

    if (current && current.body.trim()) {
      chapters.push(current);
    }

    const usefulChapters = chapters.filter((chapter) => chapter.body && chapter.body.trim());
    if (usefulChapters.length <= 1) {
      return [];
    }
    return usefulChapters;
  }

  function splitPostIntoSegments(text, headingRegex) {
    const lines = String(text || "").split(/\n+/).map((line) => line.trim());
    const segments = [];
    let currentHeading = "";
    let currentLines = [];

    lines.forEach((line) => {
      if (!line) {
        if (currentLines.length) {
          currentLines.push("");
        }
        return;
      }

      if (isHeadingLine(line, headingRegex)) {
        if (currentLines.length) {
          segments.push({
            headingTitle: currentHeading,
            body: currentLines.join("\n").replace(/\n{3,}/g, "\n\n").trim(),
          });
        }
        currentHeading = line;
        currentLines = [];
        return;
      }

      currentLines.push(line);
    });

    if (currentHeading || currentLines.length) {
      segments.push({
        headingTitle: currentHeading,
        body: currentLines.join("\n").replace(/\n{3,}/g, "\n\n").trim(),
      });
    }

    return segments.filter((segment) => segment.headingTitle || segment.body);
  }

  function isHeadingLine(line, headingRegex) {
    const value = String(line || "").trim();
    if (!value || value.length > 80 || !headingRegex) {
      return false;
    }
    headingRegex.lastIndex = 0;
    return headingRegex.test(value);
  }

  function renderInfoPage(context, frontMatter, failures, partial, mainAuthor) {
    const infoRows = [];
    infoRows.push(["标题", context.title]);
    infoRows.push(["作者", mainAuthor]);
    infoRows.push(["来源", context.canonicalUrl || context.currentUrl]);
    Object.entries(frontMatter).forEach(([key, value]) => {
      if (!value || key === "标题") {
        return;
      }
      if (key === "作者" && value === mainAuthor) {
        return;
      }
      infoRows.push([key, value]);
    });
    if (partial) {
      infoRows.push(["状态", "部分导出"]);
      if (failures.length) {
        infoRows.push(["失败页", failures.map((item) => item.page).join(", ")]);
      }
    }

    const rowsHtml = infoRows.map(([key, value]) => (
      `<tr><th>${escapeXml(key)}</th><td>${escapeXml(value)}</td></tr>`
    )).join("");

    return wrapXhtmlDocument("作品信息", `
      <section class="meta-page">
        <h1>作品信息</h1>
        <table class="meta-table">
          <tbody>${rowsHtml}</tbody>
        </table>
      </section>
    `);
  }

  function renderNavPage(title, chapters) {
    const items = [
      `<li><a href="text/info.xhtml">作品信息</a></li>`,
      ...chapters.map((chapter, index) => `<li><a href="text/chapter-${index + 1}.xhtml">${escapeXml(chapter.navTitle)}</a></li>`),
    ].join("");

    return `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <title>${escapeXml(title)}</title>
  </head>
  <body>
    <nav epub:type="toc" id="toc">
      <h1>${escapeXml(title)}</h1>
      <ol>${items}</ol>
    </nav>
  </body>
</html>`;
  }

  function renderContentOpf({ context, mainAuthor, normalizedFrontMatter, bookId, lang, nowIso, chapters }) {
    const manifestItems = [
      `<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>`,
      `<item id="info" href="text/info.xhtml" media-type="application/xhtml+xml"/>`,
      ...chapters.map((_, index) => `<item id="chapter-${index + 1}" href="text/chapter-${index + 1}.xhtml" media-type="application/xhtml+xml"/>`),
    ].join("\n    ");

    const spineItems = [
      `<itemref idref="info"/>`,
      ...chapters.map((_, index) => `<itemref idref="chapter-${index + 1}"/>`),
    ].join("\n    ");

    const subjects = [];
    ["原作", "分级", "警告", "配对", "标签"].forEach((key) => {
      if (normalizedFrontMatter[key]) {
        subjects.push(`<dc:subject>${escapeXml(`${key}:${normalizedFrontMatter[key]}`)}</dc:subject>`);
      }
    });

    return `<?xml version="1.0" encoding="UTF-8"?>
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="bookid" xml:lang="${lang}">
  <metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
    <dc:identifier id="bookid">${escapeXml(bookId)}</dc:identifier>
    <dc:title>${escapeXml(context.title)}</dc:title>
    <dc:creator>${escapeXml(mainAuthor)}</dc:creator>
    <dc:language>${lang}</dc:language>
    <dc:source>${escapeXml(context.canonicalUrl || context.currentUrl)}</dc:source>
    ${subjects.join("\n    ")}
    <meta property="dcterms:modified">${escapeXml(nowIso.replace(/\.\d{3}Z$/, "Z"))}</meta>
  </metadata>
  <manifest>
    ${manifestItems}
  </manifest>
  <spine>
    ${spineItems}
  </spine>
</package>`;
  }

  function renderContainerXml() {
    return `<?xml version="1.0" encoding="UTF-8"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
  <rootfiles>
    <rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>
  </rootfiles>
</container>`;
  }

  function renderChapterXhtml(_bookTitle, chapter) {
    const bodyHtml = textToXhtml(chapter.body);
    return wrapXhtmlDocument(chapter.navTitle, `
      <article>
        ${chapter.headingTitle ? `<h1>${escapeXml(chapter.headingTitle)}</h1>` : ""}
        ${bodyHtml}
      </article>
    `);
  }

  function wrapXhtmlDocument(title, bodyInnerHtml) {
    const safeTitle = escapeXml(title);
    return `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <title>${safeTitle}</title>
  </head>
  <body>
    ${bodyInnerHtml}
  </body>
</html>`;
  }

  function textToXhtml(text) {
    const blocks = String(text || "").split(/\n{2,}/).map((block) => block.trim()).filter(Boolean);
    return blocks.map((block) => {
      const withBreaks = escapeXml(block).replace(/\n/g, "<br/>");
      return `<p>${withBreaks}</p>`;
    }).join("\n");
  }

  function makeUuidLike(...parts) {
    const seed = parts.join("|");
    let hash = 2166136261 >>> 0;
    for (let i = 0; i < seed.length; i += 1) {
      hash ^= seed.charCodeAt(i);
      hash = Math.imul(hash, 16777619);
    }
    const hex = (`00000000${(hash >>> 0).toString(16)}`).slice(-8);
    return `${hex}${hex.slice(0, 4)}-${hex.slice(4, 8)}-4000-8000-${hex}${hex}`;
  }

  function createZip(entries) {
    const encoder = new TextEncoder();
    const localParts = [];
    const centralParts = [];
    const records = [];
    let offset = 0;

    entries.forEach((entry) => {
      const nameBytes = encoder.encode(entry.name);
      const dataBytes = typeof entry.data === "string" ? encoder.encode(entry.data) : new Uint8Array(entry.data);
      const crc = crc32(dataBytes);
      const localHeader = new Uint8Array(30 + nameBytes.length);
      const localView = new DataView(localHeader.buffer);
      writeUint32(localView, 0, 0x04034b50);
      writeUint16(localView, 4, 20);
      writeUint16(localView, 6, 0);
      writeUint16(localView, 8, 0);
      writeUint16(localView, 10, 0);
      writeUint16(localView, 12, 0);
      writeUint32(localView, 14, crc);
      writeUint32(localView, 18, dataBytes.length);
      writeUint32(localView, 22, dataBytes.length);
      writeUint16(localView, 26, nameBytes.length);
      writeUint16(localView, 28, 0);
      localHeader.set(nameBytes, 30);
      localParts.push(localHeader, dataBytes);

      records.push({
        nameBytes,
        dataBytes,
        crc,
        offset,
      });

      offset += localHeader.length + dataBytes.length;
    });

    const centralOffset = offset;
    let centralSize = 0;
    records.forEach((record) => {
      const header = new Uint8Array(46 + record.nameBytes.length);
      const view = new DataView(header.buffer);
      writeUint32(view, 0, 0x02014b50);
      writeUint16(view, 4, 20);
      writeUint16(view, 6, 20);
      writeUint16(view, 8, 0);
      writeUint16(view, 10, 0);
      writeUint16(view, 12, 0);
      writeUint16(view, 14, 0);
      writeUint32(view, 16, record.crc);
      writeUint32(view, 20, record.dataBytes.length);
      writeUint32(view, 24, record.dataBytes.length);
      writeUint16(view, 28, record.nameBytes.length);
      writeUint16(view, 30, 0);
      writeUint16(view, 32, 0);
      writeUint16(view, 34, 0);
      writeUint16(view, 36, 0);
      writeUint32(view, 38, 0);
      writeUint32(view, 42, record.offset);
      header.set(record.nameBytes, 46);
      centralParts.push(header);
      centralSize += header.length;
    });

    const end = new Uint8Array(22);
    const endView = new DataView(end.buffer);
    writeUint32(endView, 0, 0x06054b50);
    writeUint16(endView, 4, 0);
    writeUint16(endView, 6, 0);
    writeUint16(endView, 8, records.length);
    writeUint16(endView, 10, records.length);
    writeUint32(endView, 12, centralSize);
    writeUint32(endView, 16, centralOffset);
    writeUint16(endView, 20, 0);

    return concatUint8Arrays([...localParts, ...centralParts, end]);
  }

  function createCrc32Table() {
    const table = new Uint32Array(256);
    for (let i = 0; i < 256; i += 1) {
      let c = i;
      for (let j = 0; j < 8; j += 1) {
        c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
      }
      table[i] = c >>> 0;
    }
    return table;
  }

  function crc32(bytes) {
    let crc = 0xffffffff;
    for (let i = 0; i < bytes.length; i += 1) {
      crc = CRC_TABLE[(crc ^ bytes[i]) & 0xff] ^ (crc >>> 8);
    }
    return (crc ^ 0xffffffff) >>> 0;
  }

  function concatUint8Arrays(parts) {
    const totalLength = parts.reduce((sum, part) => sum + part.length, 0);
    const result = new Uint8Array(totalLength);
    let offset = 0;
    parts.forEach((part) => {
      result.set(part, offset);
      offset += part.length;
    });
    return result;
  }

  function writeUint16(view, offset, value) {
    view.setUint16(offset, value, true);
  }

  function writeUint32(view, offset, value) {
    view.setUint32(offset, value >>> 0, true);
  }

  function escapeXml(value) {
    return String(value || "")
      .replace(/&/g, "&amp;")
      .replace(/</g, "&lt;")
      .replace(/>/g, "&gt;")
      .replace(/"/g, "&quot;")
      .replace(/'/g, "&apos;");
  }

  function escapeAttribute(value) {
    return escapeXml(value).replace(/`/g, "&#96;");
  }

  function loadSettings() {
    try {
      const raw = localStorage.getItem(STORAGE_KEY);
      if (!raw) {
        return { ...defaults };
      }
      const parsed = JSON.parse(raw);
      return {
        chapterMode: parsed.chapterMode || defaults.chapterMode,
        customHeadingPattern: parsed.customHeadingPattern || defaults.customHeadingPattern,
        customHeadingFlags: normalizeRegexFlags(parsed.customHeadingFlags || defaults.customHeadingFlags),
      };
    } catch (_error) {
      return { ...defaults };
    }
  }

  function saveSettings(settings) {
    try {
      localStorage.setItem(STORAGE_KEY, JSON.stringify({
        chapterMode: settings.chapterMode || defaults.chapterMode,
        customHeadingPattern: settings.customHeadingPattern || "",
        customHeadingFlags: normalizeRegexFlags(settings.customHeadingFlags || ""),
      }));
    } catch (_error) {
      // ignore storage failures
    }
  }

  function normalizeRegexFlags(value) {
    const unique = Array.from(new Set(String(value || "").replace(/[^dgimsuvy]/g, "").split("")));
    return unique.join("");
  }

  function buildCustomHeadingRegex(chapterMode, pattern, flags) {
    if (chapterMode !== "custom") {
      return null;
    }
    if (!pattern) {
      throw new Error("自定义正则模式需要填写分节正则");
    }
    try {
      return new RegExp(pattern, normalizeRegexFlags(flags));
    } catch (error) {
      throw new Error(`自定义分节正则无效:${error.message}`);
    }
  }

  function gearSvg() {
    return `
      <svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" focusable="false">
        <path fill="currentColor" d="M19.14 12.94c.04-.31.06-.63.06-.94s-.02-.63-.06-.94l2.03-1.58a.5.5 0 0 0 .12-.64l-1.92-3.32a.5.5 0 0 0-.6-.22l-2.39.96a7.1 7.1 0 0 0-1.63-.94l-.36-2.54a.5.5 0 0 0-.49-.42h-3.84a.5.5 0 0 0-.49.42l-.36 2.54c-.58.22-1.13.53-1.63.94l-2.39-.96a.5.5 0 0 0-.6.22L2.7 8.84a.5.5 0 0 0 .12.64l2.03 1.58c-.04.31-.06.63-.06.94s.02.63.06.94L2.82 14.52a.5.5 0 0 0-.12.64l1.92 3.32a.5.5 0 0 0 .6.22l2.39-.96c.5.4 1.05.72 1.63.94l.36 2.54a.5.5 0 0 0 .49.42h3.84a.5.5 0 0 0 .49-.42l.36-2.54c.58-.22 1.13-.53 1.63-.94l2.39.96a.5.5 0 0 0 .6-.22l1.92-3.32a.5.5 0 0 0-.12-.64l-2.03-1.58ZM12 15.5A3.5 3.5 0 1 1 12 8a3.5 3.5 0 0 1 0 7.5Z"/>
      </svg>
    `;
  }

  function closeSvg() {
    return `
      <svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" focusable="false">
        <path fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" d="M6 6 18 18M18 6 6 18"/>
      </svg>
    `;
  }

  function setRunning(panel, running) {
    state.running = running;
    panel.querySelector('[data-role="export"]').disabled = running;
    panel.querySelector('[data-role="cancel"]').disabled = !running;
    const statusNode = panel.querySelector('[data-role="status"]');
    if (statusNode) {
      if (running) {
        statusNode.setAttribute("data-running", "true");
      } else {
        statusNode.removeAttribute("data-running");
      }
    }
  }

  function setStatus(panel, text) {
    const node = panel.querySelector('[data-role="status"]');
    if (node) {
      node.textContent = text;
    }
  }

  function log(message) {
    const timestamp = new Date().toLocaleTimeString("zh-CN", { hour12: false });
    state.logs.push(`[${timestamp}] ${message}`);
    if (state.logs.length > LOG_LIMIT) {
      state.logs.shift();
    }
    const logNode = document.querySelector(`#${PANEL_ID} [data-role="log"]`);
    if (logNode) {
      logNode.value = state.logs.join("\n");
      logNode.scrollTop = logNode.scrollHeight;
    }
  }

  function isSupportedThreadPage(doc, url) {
    const parsed = new URL(url, location.origin);
    const urlLooksRight = parsed.pathname.includes("thread-") || parsed.searchParams.get("mod") === "viewthread";
    if (!urlLooksRight) {
      return false;
    }
    return true; // Trust URL to support Discuz mobile templates which lack #thread_subject
  }

  function extractThreadTitle(doc) {
    const candidates = [
      "#thread_subject",
      "h1.ts",
      ".thread_subject",
      ".thread-title",
      ".thread_tit",
      ".view_tit",
      ".tit",
      ".message h2 strong",
      "h1",
      "h2",
    ];
    for (const selector of candidates) {
      const el = doc.querySelector(selector);
      if (!el) {
        continue;
      }
      const title = cleanTitleElement(el);
      if (title && !/^(提示信息|用户登录)$/.test(title)) {
        return title;
      }
    }
    const pageTitle = textOf(doc.querySelector("title")).replace(/\s*-\s*随缘居.*$/, "").trim();
    return /^(提示信息|用户登录)$/.test(pageTitle) ? "" : pageTitle;
  }

  /**
   * Extract clean title text from a DOM element.
   * Removes inline UI elements (e.g., "只看楼主" links) that some
   * Discuz mobile templates embed inside the title heading.
   */
  function cleanTitleElement(el) {
    const clone = el.cloneNode(true);
    // Remove known UI child elements
    clone.querySelectorAll('a[href*="authorid"], a[href*="only"]').forEach(a => a.remove());
    let text = (clone.textContent || '').trim();
    // Strip common trailing/leading UI noise
    text = text.replace(/\s*(只看楼主|只看该作者|电梯直达|显示全部楼层|倒序浏览|阅读模式)\s*/g, '');
    text = text.replace(/\s{2,}/g, ' ').trim();
    return text;
  }

  function collectPostNodes(doc) {
    const explicitNodes = Array.from(doc.querySelectorAll('div[id^="post_"], li[id^="post_"], div[id^="pid"], li[id^="pid"]'));
    const fallbackNodes = Array.from(doc.querySelectorAll(".message, .postmessage")).map((node) => (
      node.closest('li[id], div[id], table[id], article[id]') || node
    ));
    const unique = new Map();
    [...explicitNodes, ...fallbackNodes].forEach((node) => {
      if (!node || unique.has(node)) {
        return;
      }
      if (findMessageNode(node)) {
        unique.set(node, true);
      }
    });
    return Array.from(unique.keys());
  }

  function findMessageNode(postNode) {
    return postNode.querySelector([
      '[id^="postmessage_"]',
      ".message",
      ".postmessage",
      ".pcb .t_f",
    ].join(","));
  }

  function looksLikeBlockedPage(html) {
    return [
      "尚未登录",
      "没有权限",
      "页面重载开启",
      "页面正在重新载入",
      "提示信息",
      "请输入验证码",
      "操作太频繁",
      "您的访问受限",
      "指定的主题不存在",
      "抱歉,您没有权限",
    ].some((needle) => html.includes(needle));
  }

  function extractTid(url, doc) {
    const searchTid = url.searchParams.get("tid");
    if (searchTid) {
      return searchTid;
    }
    const pathMatch = url.pathname.match(/thread-(\d+)-/);
    if (pathMatch) {
      return pathMatch[1];
    }
    const copyLink = doc.querySelector('a[href*="thread-"]');
    const href = copyLink ? copyLink.getAttribute("href") || "" : "";
    const hrefMatch = href.match(/thread-(\d+)-/);
    if (hrefMatch) {
      return hrefMatch[1];
    }
    throw new Error("无法解析 tid");
  }

  function extractThreadStylePage(pathname) {
    const match = pathname.match(/thread-\d+-(\d+)-/);
    return match ? match[1] : null;
  }

  function extractThreadIdFromUrl(urlLike) {
    if (!(urlLike instanceof URL)) {
      return null;
    }
    const searchTid = urlLike.searchParams.get("tid");
    if (searchTid) {
      return searchTid;
    }
    const pathMatch = urlLike.pathname.match(/thread-(\d+)-/);
    return pathMatch ? pathMatch[1] : null;
  }

  function isSameThreadPage(targetUrl, currentUrl) {
    if (!(targetUrl instanceof URL) || !(currentUrl instanceof URL)) {
      return false;
    }

    const currentTid = extractThreadIdFromUrl(currentUrl);
    const targetTid = extractThreadIdFromUrl(targetUrl);
    if (currentTid && targetTid) {
      return currentTid === targetTid;
    }

    return targetUrl.pathname === currentUrl.pathname;
  }

  function parsePageValue(urlLike) {
    const value = urlLike instanceof URL
      ? urlLike.searchParams.get("page") || extractThreadStylePage(urlLike.pathname) || "1"
      : "1";
    const parsed = parseInt(value, 10);
    return Number.isNaN(parsed) ? undefined : parsed;
  }

  function extractAuthorName(postNode) {
    const selectors = [
      ".pls .authi a.xw1",
      '.pls a[href*="space-uid-"]',
      ".user_info .name",
      ".userinfo .name",
      '.authi a[href*="space-uid-"]',
      '.authi a[href*="uid="]',
      'a[href*="space-uid-"]',
      'a[href*="mod=space&uid="]',
    ];
    for (const selector of selectors) {
      const name = textOf(postNode.querySelector(selector));
      if (name) {
        return name;
      }
    }
    return "未知作者";
  }

  function extractUidFromPost(postNode) {
    const href = extractAuthorProfileUrl(postNode);
    if (!href) {
      return undefined;
    }
    const match = href.match(/(?:uid=|space-uid-)(\d+)/);
    return match ? match[1] : undefined;
  }

  function extractAuthorProfileUrl(postNode) {
    const link = postNode.querySelector([
      ".pls .authi a.xw1",
      '.pls a[href*="space-uid-"]',
      '.authi a[href*="space-uid-"]',
      '.authi a[href*="uid="]',
      'a[href*="space-uid-"]',
      'a[href*="mod=space&uid="]',
    ].join(","));
    return link ? new URL(link.getAttribute("href"), location.origin).toString() : undefined;
  }

  function extractFloor(postNode) {
    const floorLink =
      postNode.querySelector(".plc .pi strong a") ||
      postNode.querySelector('.plc .pi a[href*="findpost"]') ||
      Array.from(postNode.querySelectorAll(".plc .pi a, .authi a, a")).find((node) => /#/.test(node.textContent || ""));
    const floorText = textOf(floorLink) || textOf(postNode.querySelector(".plc .pi")) || textOf(postNode.querySelector(".authi")) || textOf(postNode);
    const match = floorText.match(/(\d+)\s*#|第\s*(\d+)\s*楼/);
    return match ? parseInt(match[1] || match[2], 10) : undefined;
  }

  function extractPublishedAt(postNode) {
    const candidates = [
      textOf(postNode.querySelector(".plc .pi .authi")),
      textOf(postNode.querySelector(".authi")),
      textOf(postNode.querySelector(".user_info")),
      textOf(postNode.querySelector(".userinfo")),
      textOf(postNode),
    ];
    for (const candidate of candidates) {
      if (!candidate) {
        continue;
      }
      const postTimeMatch = candidate.match(/发表于\s*([^\n|]+)/);
      if (postTimeMatch) {
        return postTimeMatch[1].trim();
      }
      const genericMatch = candidate.match(/(\d{4}-\d{1,2}-\d{1,2}(?:\s+\d{1,2}:\d{2}(?::\d{2})?)?)/);
      if (genericMatch) {
        return genericMatch[1].trim();
      }
    }
    return undefined;
  }

  function textOf(node) {
    return node ? (node.textContent || "").replace(/\s+/g, " ").trim() : "";
  }

  function sanitizeFilename(input) {
    return input
      .replace(/[\\/:*?"<>|]+/g, "_")
      .replace(/\s+/g, " ")
      .trim()
      .slice(0, 120);
  }

  function delay(ms) {
    return new Promise((resolve) => setTimeout(resolve, ms));
  }

  function randomInt(min, max) {
    return Math.floor(Math.random() * (max - min + 1)) + min;
  }

  function clamp(value, min, max) {
    return Math.min(Math.max(value, min), max);
  }

  function ensureStyles() {
    if (document.getElementById(STYLE_ID)) {
      return;
    }
    const style = document.createElement("style");
    style.id = STYLE_ID;
    style.textContent = `
      #${PANEL_ID} {
        --mts-panel-bg: linear-gradient(180deg, rgba(255, 251, 245, 0.98) 0%, rgba(246, 249, 252, 0.98) 100%);
        --mts-border: rgba(145, 170, 193, 0.4);
        --mts-text: #24364a;
        --mts-subtle: #6a7d92;
        --mts-accent: #1f5f95;
        --mts-accent-strong: #174f7f;
        --mts-accent-soft: #e8f1fa;
        --mts-surface: rgba(255, 255, 255, 0.9);
        box-sizing: border-box;
        position: fixed;
        top: 18px;
        right: 18px;
        z-index: 2147483647;
        width: 380px;
        max-width: calc(100vw - 36px);
        max-height: calc(100vh - 36px);
        overflow: auto;
        padding: 18px;
        border: 1px solid var(--mts-border);
        border-radius: 18px;
        box-shadow: 0 24px 60px rgba(27, 50, 75, 0.18);
        background: var(--mts-panel-bg);
        backdrop-filter: blur(18px);
        color: var(--mts-text);
        font: 14px/1.45 -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif;
      }
      #${PANEL_ID}::before {
        content: "";
        position: absolute;
        inset: 0;
        border-radius: inherit;
        pointer-events: none;
        background:
          radial-gradient(circle at top right, rgba(255, 255, 255, 0.72), transparent 34%),
          linear-gradient(180deg, rgba(255, 255, 255, 0.28), transparent 26%);
      }
      #${PANEL_ID}[hidden],
      #${LAUNCHER_ID}[hidden] {
        display: none !important;
      }
      #${PANEL_ID} .${APP_ID}__header {
        position: relative;
        display: flex;
        align-items: center;
        justify-content: space-between;
        margin-bottom: 16px;
      }
      #${PANEL_ID} .${APP_ID}__header strong {
        font-size: 17px;
        font-weight: 700;
        letter-spacing: -0.02em;
      }
      #${PANEL_ID} .${APP_ID}__close-button {
        display: inline-flex;
        align-items: center;
        justify-content: center;
        width: 36px;
        height: 36px;
        padding: 0;
        border: 1px solid rgba(123, 149, 173, 0.24);
        background: rgba(255, 255, 255, 0.58);
        color: #46627d;
        cursor: pointer;
        border-radius: 999px;
        touch-action: manipulation;
      }
      #${PANEL_ID} .${APP_ID}__close-button:active {
        background: rgba(232, 241, 250, 0.9);
      }
      #${PANEL_ID} .${APP_ID}__close-button svg {
        display: block;
        width: 18px;
        height: 18px;
      }
      #${PANEL_ID} .${APP_ID}__row {
        position: relative;
        display: grid;
        grid-template-columns: 64px 1fr;
        gap: 12px;
        align-items: center;
        margin-bottom: 12px;
      }
      #${PANEL_ID} .${APP_ID}__row[hidden] {
        display: none;
      }
      #${PANEL_ID} .${APP_ID}__row > label {
        color: var(--mts-subtle);
        font-size: 12px;
        font-weight: 700;
        letter-spacing: 0.04em;
      }
      #${PANEL_ID} select,
      #${PANEL_ID} input,
      #${PANEL_ID} button,
      #${PANEL_ID} textarea {
        font: inherit;
        font-size: 14px;
        border-radius: 12px;
        box-sizing: border-box;
      }
      #${PANEL_ID} select,
      #${PANEL_ID} input {
        min-height: 44px;
        height: 44px;
        padding: 10px 14px;
        border: 1px solid rgba(160, 184, 205, 0.56);
        background: var(--mts-surface);
        box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.55);
        line-height: normal;
        appearance: none;
        -webkit-appearance: none;
        color: var(--mts-text);
      }
      #${PANEL_ID} select:focus,
      #${PANEL_ID} input:focus,
      #${PANEL_ID} textarea:focus {
        outline: none;
        border-color: rgba(31, 95, 149, 0.65);
        box-shadow: 0 0 0 3px rgba(31, 95, 149, 0.12);
      }
      #${PANEL_ID} input::placeholder {
        color: #8c9caf;
      }
      #${PANEL_ID} select {
        background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2337546f' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
        background-repeat: no-repeat;
        background-position: right 12px center;
        padding-right: 38px;
      }
      #${PANEL_ID} .${APP_ID}__actions {
        grid-template-columns: 1fr 1fr;
        gap: 12px;
        margin-top: 18px;
      }
      #${PANEL_ID} .${APP_ID}__settings {
        margin: 4px 0 12px;
        padding: 14px;
        border: 1px solid rgba(175, 196, 214, 0.48);
        background: rgba(255, 255, 255, 0.58);
        border-radius: 16px;
      }
      #${PANEL_ID} .${APP_ID}__field {
        display: flex;
        flex-direction: column;
        gap: 6px;
        margin-bottom: 10px;
      }
      #${PANEL_ID} .${APP_ID}__field > label {
        color: var(--mts-subtle);
        font-size: 12px;
        font-weight: 700;
      }
      #${PANEL_ID} .${APP_ID}__field:last-child {
        margin-bottom: 0;
      }
      #${PANEL_ID} .${APP_ID}__hint {
        margin: 0;
        color: var(--mts-subtle);
        font-size: 12px;
        line-height: 1.4;
      }
      #${PANEL_ID} .${APP_ID}__button {
        min-height: 46px;
        height: 46px;
        border: 1px solid transparent;
        cursor: pointer;
        font-weight: 700;
        letter-spacing: -0.01em;
        touch-action: manipulation;
        transition: transform 140ms ease, background-color 140ms ease, border-color 140ms ease, box-shadow 140ms ease;
      }
      #${PANEL_ID} .${APP_ID}__button--primary {
        background: var(--mts-accent);
        color: #fff;
        box-shadow: 0 4px 12px rgba(31, 95, 149, 0.15);
      }
      #${PANEL_ID} .${APP_ID}__button--secondary {
        border-color: rgba(160, 184, 205, 0.64);
        background: rgba(255, 255, 255, 0.7);
        color: var(--mts-subtle);
      }
      #${PANEL_ID} .${APP_ID}__button:active {
        transform: translateY(1px);
      }
      #${PANEL_ID} button:disabled,
      #${PANEL_ID} input:disabled {
        opacity: 0.55;
        cursor: not-allowed;
      }
      #${PANEL_ID} .${APP_ID}__meta {
        display: flex;
        align-items: center;
        justify-content: space-between;
        gap: 12px;
        margin: 14px 0 8px;
      }
      #${PANEL_ID} .${APP_ID}__status {
        display: inline-flex;
        align-items: center;
        gap: 6px;
        color: var(--mts-subtle);
        font-size: 12px;
        font-weight: 500;
        white-space: nowrap;
      }
      #${PANEL_ID} .${APP_ID}__status::before {
        content: "";
        display: block;
        width: 8px;
        height: 8px;
        border-radius: 50%;
        background-color: #8c9caf;
        transition: background-color 0.2s, box-shadow 0.2s;
      }
      #${PANEL_ID} .${APP_ID}__status[data-running="true"]::before {
        background-color: #10b981;
        box-shadow: 0 0 6px rgba(16, 185, 129, 0.4);
      }
      #${PANEL_ID} .${APP_ID}__log-label {
        color: var(--mts-subtle);
        font-size: 12px;
        font-weight: 700;
        letter-spacing: 0.04em;
      }
      #${PANEL_ID} .${APP_ID}__log {
        width: 100%;
        max-width: 100%;
        min-height: 132px;
        resize: vertical;
        padding: 12px 14px;
        border: 1px solid rgba(175, 196, 214, 0.52);
        background: rgba(250, 252, 255, 0.82);
        color: #334;
        box-sizing: border-box;
        overflow-wrap: anywhere;
        border-radius: 14px;
        font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace;
        font-size: 12px;
        line-height: 1.55;
      }
      #${LAUNCHER_ID} {
        position: fixed;
        bottom: 24px;
        right: 24px;
        z-index: 2147483647;
        width: 52px;
        height: 52px;
        padding: 0;
        border: 0;
        border-radius: 50%;
        background: linear-gradient(180deg, #ffffff 0%, #e8f1fa 100%);
        color: var(--mts-accent-strong);
        box-shadow: 0 12px 28px rgba(33, 52, 74, 0.18);
        display: inline-flex;
        align-items: center;
        justify-content: center;
        cursor: grab;
        user-select: none;
        touch-action: none;
      }
      #${LAUNCHER_ID} svg {
        display: block;
        width: 24px;
        height: 24px;
      }
      #${LAUNCHER_ID}:active {
        cursor: grabbing;
        background: #dceaf4;
      }

      /* Mobile Adaptations */
      @media screen and (max-device-width: 800px), screen and (max-width: 600px) {
        #${PANEL_ID} {
          top: auto !important;
          bottom: 0 !important;
          left: 0 !important;
          right: 0 !important;
          width: 100%;
          max-width: 100%;
          max-height: min(88vh, 760px);
          border: none;
          border-top: 1px solid rgba(145, 170, 193, 0.32);
          border-radius: 24px 24px 0 0;
          padding: 18px 18px calc(20px + env(safe-area-inset-bottom));
          box-shadow: 0 -16px 44px rgba(33, 52, 74, 0.14);
        }
        #${PANEL_ID} .${APP_ID}__header {
          margin-bottom: 16px;
        }
        #${PANEL_ID} .${APP_ID}__header strong {
          font-size: 15px;
        }
        #${PANEL_ID} select,
        #${PANEL_ID} input {
          font-size: 16px;
          min-height: 50px;
          height: 50px;
        }
        #${PANEL_ID} .${APP_ID}__button {
          font-size: 16px;
          min-height: 52px;
          height: 52px;
        }
        #${PANEL_ID} .${APP_ID}__close-button {
          width: 40px;
          height: 40px;
        }
        #${PANEL_ID} .${APP_ID}__row {
          grid-template-columns: 1fr;
          gap: 8px;
          margin-bottom: 14px;
          align-items: stretch;
        }
        #${PANEL_ID} .${APP_ID}__row > label {
          padding-left: 2px;
        }
        #${PANEL_ID} .${APP_ID}__actions {
          grid-template-columns: 1fr 1fr;
          margin-top: 18px;
        }
        #${PANEL_ID} .${APP_ID}__meta {
          margin: 14px 0 8px;
        }
        #${PANEL_ID} .${APP_ID}__log {
          min-height: 112px;
        }
        #${LAUNCHER_ID} {
          bottom: calc(24px + env(safe-area-inset-bottom));
          right: 16px;
          width: 56px;
          height: 56px;
        }
        #${LAUNCHER_ID} svg {
          width: 28px;
          height: 28px;
        }
      }
    `;
    document.head.appendChild(style);
  }
})();