// ==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.1.1
// @description Export Linux.do topics to HTML or Markdown with automatic flat, nest, and main-post-only modes.
// @description:zh-CN 将 Linux.do 论坛帖子导出为 HTML 或 Markdown,自动识别 flat/nest 模式,并支持只导出主帖或指定楼层。
// @description:en Export Linux.do topics to HTML or 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 = "导出";
button.title = "导出当前 Linux.do 帖子";
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 exportOptions = await showRangeDialog();
if (exportOptions === null) return;
const range = parseRange(exportOptions.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 buildOptions = {
topic,
topicInfo,
posts: selectedPosts,
exportMode,
range,
};
const isHtml = exportOptions.outputFormat === "html";
const content = isHtml ? buildHtml(buildOptions) : buildMarkdown(buildOptions);
const mimeType = isHtml ? "text/html;charset=utf-8" : "text/markdown;charset=utf-8";
const filename = makeFilename(topicInfo.id, exportMode, topic.title, exportOptions.outputFormat);
downloadText(filename, content, mimeType);
} finally {
setBusy(button, false);
}
}
function setBusy(button, busy) {
button.disabled = busy;
button.textContent = busy ? "导出中..." : "导出";
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 fieldLabelStyle = [
"display:block",
"margin:0 0 6px",
"font-size:13px",
"font-weight:600",
"color:#334155",
].join(";");
const inputStyle = [
"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 formatLabel = document.createElement("label");
formatLabel.textContent = "导出格式";
formatLabel.style.cssText = fieldLabelStyle;
const formatSelect = document.createElement("select");
formatSelect.style.cssText = inputStyle;
const formatOptions = [
["html", "HTML"],
["markdown", "Markdown(导出评论不推荐)"],
];
for (const [value, text] of formatOptions) {
const option = document.createElement("option");
option.value = value;
option.textContent = text;
formatSelect.appendChild(option);
}
const markdownOption = [...formatSelect.options].find((option) => option.value === "markdown");
const label = document.createElement("label");
label.textContent = "范围";
label.style.cssText = fieldLabelStyle + ";margin-top:12px";
const select = document.createElement("select");
select.style.cssText = inputStyle;
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 = fieldLabelStyle;
const customInput = document.createElement("input");
customInput.type = "text";
customInput.placeholder = "例如:1-50 或 1,3,8-12";
customInput.style.cssText = inputStyle;
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 = "";
syncFormatRecommendation();
if (show) {
setTimeout(() => customInput.focus(), 0);
}
}
function syncFormatRecommendation() {
if (!markdownOption) return;
markdownOption.textContent = select.value === "post" ? "Markdown" : "Markdown(导出评论不推荐)";
}
function confirm() {
const value = selectedRangeInput();
try {
parseRange(value);
close({ rangeInput: value, outputFormat: formatSelect.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(formatLabel);
dialog.appendChild(formatSelect);
dialog.appendChild(label);
dialog.appendChild(select);
dialog.appendChild(customWrap);
dialog.appendChild(error);
dialog.appendChild(actions);
overlay.appendChild(dialog);
document.body.appendChild(overlay);
syncFormatRecommendation();
formatSelect.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));
} else {
lines.push(renderFlatPosts(visiblePosts));
}
if (skipped.length) {
lines.push("", "---", "", `跳过空白/不可见楼层: ${skipped.join(", ")}`, "");
}
return normalizeMarkdown(lines.join("\n"));
}
function buildHtml({ topic, topicInfo, posts, exportMode, range }) {
const skipped = [];
const visiblePosts = [];
for (const post of posts) {
const body = String(post.cooked || "").trim();
if (!body) {
skipped.push(post.post_number);
} else {
visiblePosts.push({ post, body });
}
}
const content = exportMode === "nest"
? renderNestedPostsHtml(visiblePosts)
: renderFlatPostsHtml(visiblePosts);
const skippedHtml = skipped.length
? `