Export Linux.do topics to Markdown with automatic flat, nest, and main-post-only modes.
Från och med
// ==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.2
// @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));
} else {
lines.push(renderFlatPosts(visiblePosts));
}
if (skipped.length) {
lines.push("", "---", "", `跳过空白/不可见楼层: ${skipped.join(", ")}`, "");
}
return normalizeMarkdown(lines.join("\n"));
}
function renderFlatPosts(items) {
return items.map(({ post, body }) => {
return [
renderPostLabel(post),
"",
renderPostMeta(post),
"",
body,
"",
].join("\n");
}).join("\n");
}
function renderNestedPosts(items) {
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, 2, lines);
}
if (outOfRangeParents.length) {
lines.push("## 范围外父级回复", "");
for (const node of outOfRangeParents) {
renderNestedNode(node, 3, lines);
}
}
return lines.join("\n");
}
function renderNestedNode(node, depth, lines) {
lines.push(renderPostLabel(node.post));
lines.push("");
lines.push(renderPostMeta(node.post));
lines.push("");
lines.push(node.body);
lines.push("");
for (const child of node.children) {
renderNestedNode(child, depth + 1, lines);
}
}
function renderPostMeta(post) {
const parts = [
`时间: ${formatDate(post.created_at)}`,
];
if (post.reply_to_post_number) {
parts.push(`回复: #${post.reply_to_post_number}`);
}
return parts.map((part) => `- ${part}`).join("\n");
}
function renderPostLabel(post) {
return `**#${post.post_number} ${formatAuthor(post)}**`;
}
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 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 linkedImage = findLinkedImageAttachment(node);
if (href && linkedImage) {
const emojiText = imageToEmojiText(linkedImage);
if (emojiText) return emojiText;
const hrefIsImage = isImageUrl(href);
const imageMarkdown = renderImageMarkdown(linkedImage, hrefIsImage ? href : null);
if (!imageMarkdown) return "";
const src = linkedImage.getAttribute("src") || "";
if (urlsPointToSameResource(href, src) || hrefIsImage) return imageMarkdown;
return `[${imageMarkdown}](${absoluteUrl(href)})`;
}
const label = plainTextContent(node) || href || "";
if (!href) return label;
return `[${escapeMarkdownLinkText(label)}](${absoluteUrl(href)})`;
}
case "img": {
return renderImageMarkdown(node);
}
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 renderImageMarkdown(node, preferredSrc) {
const emojiText = imageToEmojiText(node);
if (emojiText) return emojiText;
const src = preferredSrc || node.getAttribute("src");
if (!src) return "";
const alt = node.getAttribute("alt") || node.getAttribute("title") || "image";
return `})`;
}
function findLinkedImageAttachment(node) {
const meaningfulChildren = [...node.childNodes].filter((child) => {
if (child.nodeType === Node.TEXT_NODE) return child.nodeValue.trim();
if (child.nodeType === Node.ELEMENT_NODE) return true;
return false;
});
const imageChildren = meaningfulChildren.filter((child) => {
return child.nodeType === Node.ELEMENT_NODE && child.tagName.toLowerCase() === "img";
});
if (imageChildren.length !== 1) return null;
const onlyImage = imageChildren[0];
const nonImageText = meaningfulChildren
.filter((child) => child !== onlyImage)
.map((child) => plainTextContent(child))
.join(" ")
.trim();
if (!nonImageText || looksLikeImageAttachmentInfo(nonImageText)) {
return onlyImage;
}
return null;
}
function looksLikeImageAttachmentInfo(value) {
const normalized = String(value || "")
.replace(/\s+/g, " ")
.trim();
if (!normalized) return true;
return /^(?:image|图片)?\s*\d+\s*[x×]\s*\d+(?:\s+\d+(?:\.\d+)?\s*(?:KB|MB|B))?$/i.test(normalized);
}
function plainTextContent(node) {
return String(node?.textContent || "").replace(/\s+/g, " ").trim();
}
function isImageUrl(value) {
try {
const url = new URL(value, location.origin);
return /\.(?:apng|avif|gif|jpe?g|png|svg|webp)(?:$|\?)/i.test(url.pathname);
} catch {
return /\.(?:apng|avif|gif|jpe?g|png|svg|webp)(?:$|\?)/i.test(String(value || ""));
}
}
function urlsPointToSameResource(first, second) {
if (!first || !second) return false;
try {
return new URL(first, location.origin).href === new URL(second, location.origin).href;
} catch {
return String(first) === String(second);
}
}
function imageToEmojiText(node) {
const src = node.getAttribute("src") || "";
const alt = node.getAttribute("alt") || "";
const title = node.getAttribute("title") || "";
const ariaLabel = node.getAttribute("aria-label") || "";
const className = node.getAttribute("class") || "";
const haystack = `${className} ${src} ${alt} ${title} ${ariaLabel}`.toLowerCase();
const looksLikeEmoji =
/\b(custom-)?emoji\b/.test(className.toLowerCase()) ||
/\bemoticon\b/.test(className.toLowerCase()) ||
/^:[^:\s][^:]*:$/.test(alt.trim()) ||
/^:[^:\s][^:]*:$/.test(title.trim()) ||
/^:[^:\s][^:]*:$/.test(ariaLabel.trim()) ||
haystack.includes("/emoji/") ||
haystack.includes("emoji") ||
haystack.includes("emoticon");
if (!looksLikeEmoji) return "";
const rawName = alt || title || ariaLabel || filenameFromUrl(src);
const name = cleanEmojiName(rawName);
return name ? `:${name}:` : "";
}
function filenameFromUrl(value) {
try {
const url = new URL(value, location.origin);
const filename = url.pathname.split("/").filter(Boolean).pop() || "";
return filename.replace(/\.[a-z0-9]+$/i, "");
} catch {
const filename = String(value || "").split(/[/?#]/).filter(Boolean).pop() || "";
return filename.replace(/\.[a-z0-9]+$/i, "");
}
}
function cleanEmojiName(value) {
return String(value || "")
.trim()
.replace(/^:+|:+$/g, "")
.replace(/\.[a-z0-9]+$/i, "")
.replace(/\s+/g, "_")
.replace(/[^\w+\-.]/g, "")
.replace(/^_+|_+$/g, "");
}
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);
})();