Linux.do Export Markdown

Export Linux.do topics to Markdown with automatic flat, nest, and main-post-only modes.

As of 03.06.2026. See ბოლო ვერსია.

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         Linux.do Export Markdown
// @name:zh-CN   Linux.do 帖子 Markdown 导出
// @name:en      Linux.do Export Markdown
// @namespace    https://github.com/kai-wei-kfuse/Linuxdo-Export-Markdown
// @version      1.0.0
// @description  Export Linux.do topics to Markdown with automatic flat, nest, and main-post-only modes.
// @description:zh-CN 将 Linux.do 论坛帖子导出为 Markdown,自动识别 flat/nest 模式,并支持只导出主帖或指定楼层。
// @description:en Export Linux.do topics to Markdown with automatic flat/nest detection, main-post-only export, and post range selection.
// @author       kai-wei-kfuse
// @license      MIT
// @homepageURL  https://github.com/kai-wei-kfuse/Linuxdo-Export-Markdown
// @supportURL   https://github.com/kai-wei-kfuse/Linuxdo-Export-Markdown/issues
// @match        https://linux.do/t/topic/*
// @match        https://linux.do/n/topic/*
// @match        https://www.linux.do/t/topic/*
// @match        https://www.linux.do/n/topic/*
// @grant        GM_download
// @run-at       document-idle
// ==/UserScript==

(function () {
  "use strict";

  const BUTTON_ID = "linuxdo-md-export-button";
  const DIALOG_ID = "linuxdo-md-export-dialog";
  const POST_ONLY_ALIASES = new Set(["post", "main", "主帖"]);

  function parseTopicLocation(urlText = location.href) {
    const url = new URL(urlText, location.origin);
    const parts = url.pathname.split("/").filter(Boolean);
    const viewToken = parts[0];
    let idToken = null;

    if (parts[1] === "topic" && /^\d+$/.test(parts[2] || "")) {
      idToken = parts[2];
    } else {
      idToken = parts.slice(1).find((part) => /^\d+$/.test(part));
    }

    if (!idToken || !["t", "n"].includes(viewToken)) {
      return null;
    }

    return {
      id: Number(idToken),
      viewToken,
      detectedMode: viewToken === "n" ? "nest" : "flat",
      canonicalJsonUrl: `${url.origin}/t/topic/${idToken}.json`,
      originalUrl: url.href.replace(/#.*$/, ""),
      origin: url.origin,
    };
  }

  function injectButton() {
    const topic = parseTopicLocation();
    if (!topic) return;

    const existing = document.getElementById(BUTTON_ID);
    if (existing) return;

    const button = document.createElement("button");
    button.id = BUTTON_ID;
    button.type = "button";
    button.textContent = "导出 MD";
    button.title = "导出当前 Linux.do 帖子为 Markdown";
    button.style.cssText = [
      "position:fixed",
      "right:18px",
      "bottom:82px",
      "z-index:99999",
      "border:1px solid #0f766e",
      "border-radius:8px",
      "background:#0d9488",
      "color:#fff",
      "font-size:14px",
      "font-weight:600",
      "line-height:1",
      "padding:10px 12px",
      "box-shadow:0 6px 18px rgba(15,23,42,.18)",
      "cursor:pointer",
    ].join(";");

    button.addEventListener("click", () => {
      exportCurrentTopic(button).catch((error) => {
        console.error("[linuxdo-md-export]", error);
        alert(`导出失败:${error.message || error}`);
      });
    });

    document.body.appendChild(button);
  }

  async function exportCurrentTopic(button) {
    const topicInfo = parseTopicLocation();
    if (!topicInfo) {
      throw new Error("当前页面不是可识别的 linux.do 帖子链接。");
    }

    const rangeInput = await showRangeDialog();

    if (rangeInput === null) return;

    const range = parseRange(rangeInput);
    const exportMode = range.kind === "post" ? "post" : topicInfo.detectedMode;

    setBusy(button, true);
    try {
      const topic = await fetchCompleteTopic(topicInfo);
      const selectedPosts = selectPosts(topic.posts, range);

      if (!selectedPosts.length) {
        throw new Error("所选范围内没有可导出的楼层。");
      }

      const markdown = buildMarkdown({
        topic,
        topicInfo,
        posts: selectedPosts,
        exportMode,
        range,
      });

      const filename = makeFilename(topicInfo.id, exportMode, topic.title);
      downloadText(filename, markdown);
    } finally {
      setBusy(button, false);
    }
  }

  function setBusy(button, busy) {
    button.disabled = busy;
    button.textContent = busy ? "导出中..." : "导出 MD";
    button.style.opacity = busy ? "0.72" : "1";
    button.style.cursor = busy ? "wait" : "pointer";
  }

  function showRangeDialog() {
    return new Promise((resolve) => {
      const existing = document.getElementById(DIALOG_ID);
      if (existing) existing.remove();

      const overlay = document.createElement("div");
      overlay.id = DIALOG_ID;
      overlay.style.cssText = [
        "position:fixed",
        "inset:0",
        "z-index:100000",
        "display:flex",
        "align-items:center",
        "justify-content:center",
        "background:rgba(15,23,42,.38)",
        "font-family:system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif",
      ].join(";");

      const dialog = document.createElement("div");
      dialog.setAttribute("role", "dialog");
      dialog.setAttribute("aria-modal", "true");
      dialog.setAttribute("aria-labelledby", "linuxdo-md-export-title");
      dialog.style.cssText = [
        "width:min(420px,calc(100vw - 32px))",
        "box-sizing:border-box",
        "border:1px solid rgba(15,23,42,.12)",
        "border-radius:8px",
        "background:#fff",
        "color:#0f172a",
        "box-shadow:0 18px 52px rgba(15,23,42,.28)",
        "padding:18px",
      ].join(";");

      const title = document.createElement("h2");
      title.id = "linuxdo-md-export-title";
      title.textContent = "选择导出范围";
      title.style.cssText = [
        "margin:0 0 14px",
        "font-size:18px",
        "font-weight:700",
        "line-height:1.3",
        "letter-spacing:0",
      ].join(";");

      const label = document.createElement("label");
      label.textContent = "范围";
      label.style.cssText = [
        "display:block",
        "margin:0 0 6px",
        "font-size:13px",
        "font-weight:600",
        "color:#334155",
      ].join(";");

      const select = document.createElement("select");
      select.style.cssText = [
        "width:100%",
        "box-sizing:border-box",
        "border:1px solid #cbd5e1",
        "border-radius:6px",
        "background:#fff",
        "color:#0f172a",
        "font-size:14px",
        "line-height:1.4",
        "padding:9px 10px",
        "outline:none",
      ].join(";");

      const options = [
        ["all", "全部回复"],
        ["post", "只导出主帖"],
        ["custom", "自定义楼层"],
      ];

      for (const [value, text] of options) {
        const option = document.createElement("option");
        option.value = value;
        option.textContent = text;
        select.appendChild(option);
      }

      const customWrap = document.createElement("div");
      customWrap.style.cssText = "display:none;margin-top:12px;";

      const customLabel = document.createElement("label");
      customLabel.textContent = "自定义楼层";
      customLabel.style.cssText = [
        "display:block",
        "margin:0 0 6px",
        "font-size:13px",
        "font-weight:600",
        "color:#334155",
      ].join(";");

      const customInput = document.createElement("input");
      customInput.type = "text";
      customInput.placeholder = "例如:1-50 或 1,3,8-12";
      customInput.style.cssText = [
        "width:100%",
        "box-sizing:border-box",
        "border:1px solid #cbd5e1",
        "border-radius:6px",
        "background:#fff",
        "color:#0f172a",
        "font-size:14px",
        "line-height:1.4",
        "padding:9px 10px",
        "outline:none",
      ].join(";");

      const error = document.createElement("div");
      error.setAttribute("aria-live", "polite");
      error.style.cssText = [
        "min-height:18px",
        "margin-top:10px",
        "font-size:13px",
        "line-height:1.4",
        "color:#b91c1c",
      ].join(";");

      const actions = document.createElement("div");
      actions.style.cssText = [
        "display:flex",
        "justify-content:flex-end",
        "gap:8px",
        "margin-top:16px",
      ].join(";");

      const cancelButton = document.createElement("button");
      cancelButton.type = "button";
      cancelButton.textContent = "取消";
      cancelButton.style.cssText = dialogButtonStyle("#fff", "#334155", "#cbd5e1");

      const confirmButton = document.createElement("button");
      confirmButton.type = "button";
      confirmButton.textContent = "导出";
      confirmButton.style.cssText = dialogButtonStyle("#0d9488", "#fff", "#0f766e");

      function close(value) {
        overlay.remove();
        document.removeEventListener("keydown", onKeydown);
        resolve(value);
      }

      function selectedRangeInput() {
        if (select.value === "custom") return customInput.value.trim();
        return select.value;
      }

      function syncCustomVisibility() {
        const show = select.value === "custom";
        customWrap.style.display = show ? "block" : "none";
        error.textContent = "";
        if (show) {
          setTimeout(() => customInput.focus(), 0);
        }
      }

      function confirm() {
        const value = selectedRangeInput();

        try {
          parseRange(value);
          close(value);
        } catch (parseError) {
          error.textContent = parseError.message || "范围格式无效。";
          customInput.focus();
        }
      }

      function onKeydown(event) {
        if (event.key === "Escape") {
          close(null);
        } else if (event.key === "Enter") {
          event.preventDefault();
          confirm();
        }
      }

      select.addEventListener("change", syncCustomVisibility);
      customInput.addEventListener("input", () => {
        error.textContent = "";
      });
      cancelButton.addEventListener("click", () => close(null));
      confirmButton.addEventListener("click", confirm);
      document.addEventListener("keydown", onKeydown);

      customWrap.appendChild(customLabel);
      customWrap.appendChild(customInput);
      actions.appendChild(cancelButton);
      actions.appendChild(confirmButton);
      dialog.appendChild(title);
      dialog.appendChild(label);
      dialog.appendChild(select);
      dialog.appendChild(customWrap);
      dialog.appendChild(error);
      dialog.appendChild(actions);
      overlay.appendChild(dialog);
      document.body.appendChild(overlay);

      select.focus();
    });
  }

  function dialogButtonStyle(background, color, borderColor) {
    return [
      `background:${background}`,
      `color:${color}`,
      `border:1px solid ${borderColor}`,
      "border-radius:6px",
      "font-size:14px",
      "font-weight:600",
      "line-height:1",
      "padding:9px 12px",
      "cursor:pointer",
    ].join(";");
  }

  function parseRange(input) {
    const raw = String(input || "").trim();
    const normalized = raw.toLowerCase();

    if (!raw || normalized === "all" || raw === "全部") {
      return { kind: "all", label: raw || "all", postNumbers: null };
    }

    if (POST_ONLY_ALIASES.has(normalized) || POST_ONLY_ALIASES.has(raw)) {
      return { kind: "post", label: raw, postNumbers: new Set([1]) };
    }

    const postNumbers = new Set();
    const segments = raw.split(",").map((part) => part.trim()).filter(Boolean);
    if (!segments.length) {
      throw new Error("范围格式为空。");
    }

    for (const segment of segments) {
      const rangeMatch = segment.match(/^(\d+)\s*-\s*(\d+)$/);
      const numberMatch = segment.match(/^\d+$/);

      if (rangeMatch) {
        const start = Number(rangeMatch[1]);
        const end = Number(rangeMatch[2]);
        if (start < 1 || end < 1 || start > end) {
          throw new Error(`范围无效:${segment}`);
        }
        for (let value = start; value <= end; value += 1) {
          postNumbers.add(value);
        }
      } else if (numberMatch) {
        const value = Number(segment);
        if (value < 1) throw new Error(`楼层无效:${segment}`);
        postNumbers.add(value);
      } else {
        throw new Error(`无法识别范围:${segment}`);
      }
    }

    return { kind: "range", label: raw, postNumbers };
  }

  async function fetchCompleteTopic(topicInfo) {
    const first = await fetchJson(topicInfo.canonicalJsonUrl);
    const postsById = new Map();
    const postsByNumber = new Map();

    for (const post of first?.post_stream?.posts || []) {
      if (post && post.id) postsById.set(post.id, post);
      if (post && post.post_number) postsByNumber.set(post.post_number, post);
    }

    const streamIds = Array.isArray(first?.post_stream?.stream) ? first.post_stream.stream : [];
    const missingIds = streamIds.filter((id) => !postsById.has(id));

    for (const chunk of chunkArray(missingIds, 50)) {
      const extraPosts = await fetchPostsByIds(topicInfo.id, chunk);
      for (const post of extraPosts) {
        if (post && post.id) postsById.set(post.id, post);
        if (post && post.post_number) postsByNumber.set(post.post_number, post);
      }
    }

    return {
      id: topicInfo.id,
      title: first?.title || document.title.replace(/\s*-\s*LINUX DO\s*$/i, "").trim() || `topic-${topicInfo.id}`,
      category: first?.category_id,
      tags: Array.isArray(first?.tags) ? first.tags : [],
      posts: [...postsByNumber.values()].sort((a, b) => a.post_number - b.post_number),
      raw: first,
    };
  }

  async function fetchPostsByIds(topicId, ids) {
    if (!ids.length) return [];

    const params = new URLSearchParams();
    for (const id of ids) params.append("post_ids[]", String(id));

    const url = `${location.origin}/t/${topicId}/posts.json?${params.toString()}`;
    const data = await fetchJson(url);
    return data?.post_stream?.posts || data?.posts || [];
  }

  async function fetchJson(url) {
    const response = await fetch(url, {
      credentials: "same-origin",
      headers: {
        Accept: "application/json",
      },
    });

    if (!response.ok) {
      throw new Error(`请求失败 ${response.status}:${url}`);
    }

    return response.json();
  }

  function selectPosts(posts, range) {
    const selected = posts.filter((post) => {
      if (!post || !post.post_number) return false;
      if (!range.postNumbers) return true;
      return range.postNumbers.has(post.post_number);
    });

    return selected.sort((a, b) => a.post_number - b.post_number);
  }

  function buildMarkdown({ topic, topicInfo, posts, exportMode, range }) {
    const skipped = [];
    const visiblePosts = [];

    for (const post of posts) {
      const body = htmlToMarkdown(post.cooked || "").trim();
      if (!body) {
        skipped.push(post.post_number);
      } else {
        visiblePosts.push({ post, body });
      }
    }

    const lines = [
      `# ${escapeMarkdownLine(topic.title)}`,
      "",
      `- 原始链接: ${topicInfo.originalUrl}`,
      `- 导出模式: ${exportMode}`,
      `- 导出时间: ${new Date().toLocaleString()}`,
      `- 楼层范围: ${range.label || "all"}`,
      "",
      "---",
      "",
    ];

    if (exportMode === "nest") {
      lines.push(renderNestedPosts(visiblePosts, topicInfo));
    } else {
      lines.push(renderFlatPosts(visiblePosts, topicInfo));
    }

    if (skipped.length) {
      lines.push("", "---", "", `跳过空白/不可见楼层: ${skipped.join(", ")}`, "");
    }

    return normalizeMarkdown(lines.join("\n"));
  }

  function renderFlatPosts(items, topicInfo) {
    return items.map(({ post, body }) => {
      return [
        `## #${post.post_number} ${formatAuthor(post)}`,
        "",
        renderPostMeta(post, topicInfo),
        "",
        body,
        "",
      ].join("\n");
    }).join("\n");
  }

  function renderNestedPosts(items, topicInfo) {
    const byNumber = new Map(items.map((item) => [item.post.post_number, { ...item, children: [] }]));
    const roots = [];
    const outOfRangeParents = [];

    const mainPostNode = byNumber.get(1);

    for (const node of byNumber.values()) {
      const parentNumber = Number(node.post.reply_to_post_number || 0);
      if (parentNumber && byNumber.has(parentNumber)) {
        byNumber.get(parentNumber).children.push(node);
      } else if (parentNumber && !byNumber.has(parentNumber)) {
        outOfRangeParents.push(node);
      } else if (mainPostNode && node.post.post_number !== 1) {
        mainPostNode.children.push(node);
      } else {
        roots.push(node);
      }
    }

    for (const node of byNumber.values()) {
      node.children.sort((a, b) => a.post.post_number - b.post.post_number);
    }

    roots.sort((a, b) => a.post.post_number - b.post.post_number);
    outOfRangeParents.sort((a, b) => a.post.post_number - b.post.post_number);

    const lines = [];
    for (const root of roots) {
      renderNestedNode(root, topicInfo, 2, lines);
    }

    if (outOfRangeParents.length) {
      lines.push("## 范围外父级回复", "");
      for (const node of outOfRangeParents) {
        renderNestedNode(node, topicInfo, 3, lines);
      }
    }

    return lines.join("\n");
  }

  function renderNestedNode(node, topicInfo, depth, lines) {
    const level = Math.min(depth, 6);
    lines.push(`${"#".repeat(level)} #${node.post.post_number} ${formatAuthor(node.post)}`);
    lines.push("");
    lines.push(renderPostMeta(node.post, topicInfo));
    lines.push("");
    lines.push(node.body);
    lines.push("");

    for (const child of node.children) {
      renderNestedNode(child, topicInfo, depth + 1, lines);
    }
  }

  function renderPostMeta(post, topicInfo) {
    const parts = [
      `作者: ${formatAuthor(post)}`,
      `时间: ${formatDate(post.created_at)}`,
      `链接: ${makePostUrl(topicInfo, post.post_number)}`,
    ];

    if (post.reply_to_post_number) {
      parts.push(`回复: #${post.reply_to_post_number}`);
    }

    return parts.map((part) => `- ${part}`).join("\n");
  }

  function formatAuthor(post) {
    const display = post.display_username || post.name || post.username || "unknown";
    return post.username && post.username !== display ? `${display} (@${post.username})` : display;
  }

  function formatDate(value) {
    if (!value) return "";
    const date = new Date(value);
    return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString();
  }

  function makePostUrl(topicInfo, postNumber) {
    return `${topicInfo.origin}/${topicInfo.viewToken}/topic/${topicInfo.id}/${postNumber}`;
  }

  function htmlToMarkdown(html) {
    const doc = new DOMParser().parseFromString(`<div>${html || ""}</div>`, "text/html");
    const root = doc.body.firstElementChild;
    return normalizeMarkdown(renderChildren(root, { listDepth: 0 }).trim());
  }

  function renderChildren(node, context) {
    return [...node.childNodes].map((child) => renderNode(child, context)).join("");
  }

  function renderNode(node, context) {
    if (node.nodeType === Node.TEXT_NODE) {
      return node.nodeValue.replace(/\s+/g, " ");
    }

    if (node.nodeType !== Node.ELEMENT_NODE) {
      return "";
    }

    const tag = node.tagName.toLowerCase();
    const text = () => renderChildren(node, context).trim();
    const block = (content) => `\n\n${content.trim()}\n\n`;

    switch (tag) {
      case "br":
        return "\n";
      case "p":
        return block(text());
      case "strong":
      case "b":
        return `**${text()}**`;
      case "em":
      case "i":
        return `*${text()}*`;
      case "code":
        if (node.closest("pre")) return node.textContent || "";
        return `\`${(node.textContent || "").replace(/`/g, "\\`")}\``;
      case "pre": {
        const code = node.textContent.replace(/\n+$/, "");
        return `\n\n\`\`\`\n${code}\n\`\`\`\n\n`;
      }
      case "blockquote": {
        const content = normalizeMarkdown(text());
        return block(content.split("\n").map((line) => `> ${line}`.trimEnd()).join("\n"));
      }
      case "a": {
        const href = node.getAttribute("href");
        const label = text() || href || "";
        if (!href) return label;
        return `[${escapeMarkdownLinkText(label)}](${absoluteUrl(href)})`;
      }
      case "img": {
        const src = node.getAttribute("src");
        if (!src) return "";
        const alt = node.getAttribute("alt") || node.getAttribute("title") || "image";
        return `![${escapeMarkdownLinkText(alt)}](${absoluteUrl(src)})`;
      }
      case "ul":
      case "ol":
        return renderList(node, context, tag === "ol");
      case "li":
        return text();
      case "h1":
      case "h2":
      case "h3":
      case "h4":
      case "h5":
      case "h6": {
        const level = Number(tag.slice(1)) + 1;
        return block(`${"#".repeat(Math.min(level, 6))} ${text()}`);
      }
      case "div":
      case "section":
      case "article":
      case "aside":
        return block(text());
      case "span":
        return text();
      default:
        return text();
    }
  }

  function renderList(node, context, ordered) {
    const depth = context.listDepth || 0;
    const lines = [];
    let index = 1;

    for (const child of [...node.children].filter((element) => element.tagName.toLowerCase() === "li")) {
      const marker = ordered ? `${index}.` : "-";
      const prefix = "  ".repeat(depth) + marker + " ";
      const rendered = renderChildren(child, { ...context, listDepth: depth + 1 }).trim();
      const [firstLine, ...restLines] = rendered.split("\n");
      lines.push(prefix + firstLine);
      for (const line of restLines) {
        lines.push("  ".repeat(depth + 1) + line);
      }
      index += 1;
    }

    return `\n${lines.join("\n")}\n`;
  }

  function absoluteUrl(value) {
    try {
      return new URL(value, location.origin).href;
    } catch {
      return value;
    }
  }

  function escapeMarkdownLine(value) {
    return String(value || "").replace(/\s+/g, " ").trim();
  }

  function escapeMarkdownLinkText(value) {
    return String(value || "").replace(/]/g, "\\]").replace(/\s+/g, " ").trim();
  }

  function normalizeMarkdown(markdown) {
    return markdown
      .replace(/[ \t]+\n/g, "\n")
      .replace(/\n{4,}/g, "\n\n\n")
      .trim()
      + "\n";
  }

  function makeFilename(topicId, mode, title) {
    const safeTitle = String(title || "topic")
      .replace(/[\\/:*?"<>|]/g, " ")
      .replace(/\s+/g, "-")
      .replace(/^-+|-+$/g, "")
      .slice(0, 80) || "topic";

    return `linuxdo-${topicId}-${mode}-${safeTitle}.md`;
  }

  function downloadText(filename, text) {
    const blob = new Blob([text], { type: "text/markdown;charset=utf-8" });
    const url = URL.createObjectURL(blob);

    if (typeof GM_download === "function") {
      GM_download({
        url,
        name: filename,
        saveAs: true,
        ontimeout: () => fallbackDownload(url, filename),
        onerror: () => fallbackDownload(url, filename),
        onload: () => setTimeout(() => URL.revokeObjectURL(url), 3000),
      });
      return;
    }

    fallbackDownload(url, filename);
  }

  function fallbackDownload(url, filename) {
    const link = document.createElement("a");
    link.href = url;
    link.download = filename;
    document.body.appendChild(link);
    link.click();
    link.remove();
    setTimeout(() => URL.revokeObjectURL(url), 3000);
  }

  function chunkArray(values, size) {
    const chunks = [];
    for (let index = 0; index < values.length; index += size) {
      chunks.push(values.slice(index, index + size));
    }
    return chunks;
  }

  injectButton();

  let previousUrl = location.href;
  setInterval(() => {
    if (location.href !== previousUrl) {
      previousUrl = location.href;
      const existing = document.getElementById(BUTTON_ID);
      if (existing) existing.remove();
      injectButton();
    }
  }, 1000);
})();