LeetCode Copy Helper

Copy LeetCode problem as Markdown

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         LeetCode Copy Helper
// @namespace    https://your-local-project.dev
// @version      1.0.0
// @description  Copy LeetCode problem as Markdown
// @author       galaxywk223
// @match        https://leetcode.cn/problems/*
// @match        https://leetcode.com/problems/*
// @grant        GM_setClipboard
// @run-at       document-idle
// @license      MIT
// @supportURL   https://github.com/galaxywk223/leetcode-copy-script/issues
// ==/UserScript==

(function () {
  "use strict";

  const BUTTON_ID = "lc-copy-helper-button";
  const TOAST_ID = "lc-copy-helper-toast";
  const APP_FLAG = "__LC_COPY_HELPER_INSTALLED__";

  if (window[APP_FLAG]) return;
  window[APP_FLAG] = true;

  let mutationObserver = null;
  let ensureButtonTimer = null;
  let lastUrl = location.href;

  function isProblemPage() {
    return /^\/problems\/[^/]+(?:\/.*)?$/.test(location.pathname);
  }

  function getProblemSlug() {
    const parts = location.pathname.split("/").filter(Boolean);
    const idx = parts.indexOf("problems");
    return idx >= 0 ? parts[idx + 1] || "" : "";
  }

  function getCanonicalProblemUrl() {
    const slug = getProblemSlug();
    if (!slug) return location.href.split("?")[0].split("#")[0];
    return `${location.origin}/problems/${slug}/`;
  }

  function isVisible(el) {
    if (!el) return false;
    const style = window.getComputedStyle(el);
    if (style.display === "none" || style.visibility === "hidden") return false;
    return el.getClientRects().length > 0;
  }

  function unique(arr) {
    return [...new Set(arr)];
  }

  function showToast(message, isError = false) {
    const oldToast = document.getElementById(TOAST_ID);
    if (oldToast) oldToast.remove();

    const toast = document.createElement("div");
    toast.id = TOAST_ID;
    toast.textContent = message;
    toast.style.position = "fixed";
    toast.style.top = "60px";
    toast.style.right = "16px";
    toast.style.zIndex = "999999";
    toast.style.padding = "8px 12px";
    toast.style.background = isError ? "#cf1322" : "#222";
    toast.style.color = "#fff";
    toast.style.fontSize = "14px";
    toast.style.borderRadius = "8px";
    toast.style.boxShadow = "0 2px 8px rgba(0, 0, 0, 0.2)";
    toast.style.maxWidth = "360px";
    toast.style.wordBreak = "break-word";

    document.body.appendChild(toast);

    setTimeout(() => {
      toast.remove();
    }, 2200);
  }

  function fallbackCopyText(text) {
    const textarea = document.createElement("textarea");
    textarea.value = text;
    textarea.setAttribute("readonly", "readonly");
    textarea.style.position = "fixed";
    textarea.style.top = "-9999px";
    textarea.style.left = "-9999px";
    document.body.appendChild(textarea);
    textarea.select();
    document.execCommand("copy");
    textarea.remove();
  }

  async function copyText(text) {
    if (typeof GM_setClipboard === "function") {
      GM_setClipboard(text, "text");
      return;
    }

    if (navigator.clipboard && navigator.clipboard.writeText) {
      await navigator.clipboard.writeText(text);
      return;
    }

    fallbackCopyText(text);
  }

  function normalizeWhitespace(text) {
    return String(text || "")
      .replace(/\u00a0/g, " ")
      .replace(/\r/g, "")
      .replace(/[ \t]+\n/g, "\n")
      .replace(/\n[ \t]+/g, "\n")
      .replace(/[ \t]{2,}/g, " ");
  }

  function collapseInlineWhitespace(text) {
    return String(text || "")
      .replace(/\u00a0/g, " ")
      .replace(/\s+/g, " ");
  }

  function escapeInlineCode(text) {
    return String(text || "").replace(/`/g, "\\`");
  }

  function renderCodeInline(node) {
    let out = "";

    for (const child of node.childNodes) {
      if (child.nodeType === Node.TEXT_NODE) {
        out += collapseInlineWhitespace(child.textContent || "");
        continue;
      }

      if (child.nodeType !== Node.ELEMENT_NODE) continue;

      const tag = child.tagName.toLowerCase();

      if (tag === "sup") {
        out += "^" + renderCodeInline(child).trim();
        continue;
      }

      if (tag === "sub") {
        out += "_" + renderCodeInline(child).trim();
        continue;
      }

      out += renderCodeInline(child);
    }

    return out;
  }

  function findTitleElement() {
    const selectors = [
      '[data-cy="question-title"]',
      "div.text-title-large",
      "h1",
    ];

    for (const selector of selectors) {
      const el = document.querySelector(selector);
      if (el && isVisible(el) && el.textContent.trim()) {
        return el;
      }
    }

    return null;
  }

  function getProblemTitle() {
    const titleEl = findTitleElement();
    if (titleEl) {
      const text = titleEl.textContent.trim();
      if (text) return text;
    }

    const meta = document.querySelector('meta[property="og:title"]');
    if (meta) {
      const content = (meta.getAttribute("content") || "").trim();
      if (content) {
        return content.replace(/\s*-\s*LeetCode.*$/i, "").trim();
      }
    }

    const title = document.title.replace(/\s*-\s*LeetCode.*$/i, "").trim();
    if (title) return title;

    return "未识别到题目标题";
  }

  function getDifficulty() {
    const difficultyWords = ["简单", "中等", "困难", "Easy", "Medium", "Hard"];
    const titleEl = findTitleElement();

    const searchRoots = [];
    if (titleEl) {
      let p = titleEl.parentElement;
      let depth = 0;
      while (p && depth < 5) {
        searchRoots.push(p);
        p = p.parentElement;
        depth += 1;
      }
    }
    searchRoots.push(document.body);

    for (const root of searchRoots) {
      const nodes = root.querySelectorAll("span, div, a, button");
      for (const el of nodes) {
        if (!isVisible(el)) continue;
        const text = (el.textContent || "").trim();
        if (difficultyWords.includes(text)) return text;
      }
    }

    return "";
  }

  function looksLikeTopicTag(text) {
    const t = (text || "").trim();
    if (!t) return false;

    if (t.length > 12) return false;
    if (/^\d+\.\s/.test(t)) return false;

    // 纯数字、比例、统计
    if (/^[\d,.]+([kKmMbBwW万亿])?$/.test(t)) return false;
    if (/^[\/%.\d\s]+$/.test(t)) return false;
    if (/^\d+(\.\d+)?%$/.test(t)) return false;

    // 公司次数 / 题频
    if (/[A-Za-z\u4e00-\u9fa5]+\d+$/.test(t)) return false;

    // 明显不是标签
    const blocked = new Set([
      "相关标签",
      "Related Topics",
      "标签",
      "Topics",
      "相关企业",
      "Companies",
      "简单",
      "中等",
      "困难",
      "Easy",
      "Medium",
      "Hard",
      "收藏",
      "分享",
    ]);
    if (blocked.has(t)) return false;

    // 题目名常见特征:有编号、连字符、罗马数字尾巴
    if (/\bI{1,3}\b$/.test(t)) return false;
    if (/[::]/.test(t)) return false;
    if (/\s-\s/.test(t)) return false;

    return true;
  }

  function getTags() {
    const headingWords = ["相关标签", "Related Topics", "标签", "Topics"];
    const headings = [
      ...document.querySelectorAll("h2, h3, h4, h5, div, span"),
    ];

    for (const heading of headings) {
      const headingText = (heading.textContent || "").trim();
      if (!headingWords.includes(headingText)) continue;

      // 只在“标题的最近区域”里找,不再扫太大的父容器
      const scope =
        heading.parentElement || heading.closest("section, div") || heading;

      if (!scope) continue;

      const candidates = [...scope.querySelectorAll("a, button, span")];
      const tags = [];

      for (const el of candidates) {
        if (!isVisible(el)) continue;

        const text = (el.textContent || "").trim();
        if (!looksLikeTopicTag(text)) continue;

        const href = el.getAttribute("href") || "";

        // 明确排除题目链接、公司链接、其他跳转
        if (href && /\/problems\//.test(href)) continue;
        if (href && /company|interview|study-plan/i.test(href)) continue;

        // 优先收真正标签链接;button/span 作为兜底
        if (el.tagName.toLowerCase() === "a") {
          if (!/tag|topic/i.test(href) && href !== "") continue;
        }

        tags.push(text);
      }

      const cleaned = unique(tags).slice(0, 6);
      if (cleaned.length) return cleaned;
    }

    return [];
  }

  function scoreDescriptionNode(el) {
    if (!el || !isVisible(el)) return -Infinity;

    const text = normalizeWhitespace(el.innerText || "").trim();
    if (text.length < 80) return -Infinity;

    let score = text.length;

    const keywords = [
      "示例 1",
      "示例1",
      "Example 1",
      "Example1",
      "约束",
      "Constraints",
      "提示",
      "Hints",
      "输入",
      "Input",
      "输出",
      "Output",
    ];

    for (const keyword of keywords) {
      if (text.includes(keyword)) score += 800;
    }

    if (text.includes("提交记录")) score -= 1500;
    if (text.includes("Submissions")) score -= 1500;
    if (text.includes("题解")) score -= 1500;
    if (text.includes("Solutions")) score -= 1500;

    const blockCount = el.querySelectorAll(
      "p, pre, code, ul, ol, li, table, h1, h2, h3, h4",
    ).length;
    score += blockCount * 20;

    return score;
  }

  function getProblemContentNode() {
    const directSelectors = [
      '[data-track-load="description_content"]',
      '[data-cy="question-content"]',
      'div[data-key="description-content"]',
      "article",
    ];

    for (const selector of directSelectors) {
      const nodes = document.querySelectorAll(selector);
      for (const node of nodes) {
        if (scoreDescriptionNode(node) > 500) {
          return node;
        }
      }
    }

    const titleEl = findTitleElement();
    if (titleEl) {
      let current = titleEl.parentElement;
      let depth = 0;
      let bestNode = null;
      let bestScore = -Infinity;

      while (current && depth < 6) {
        const candidates = current.querySelectorAll("div, section, article");
        for (const node of candidates) {
          const score = scoreDescriptionNode(node);
          if (score > bestScore) {
            bestScore = score;
            bestNode = node;
          }
        }
        current = current.parentElement;
        depth += 1;
      }

      if (bestNode) return bestNode;
    }

    const main = document.querySelector("main");
    if (main) {
      let bestNode = null;
      let bestScore = -Infinity;
      const candidates = main.querySelectorAll("div, section, article");
      for (const node of candidates) {
        const score = scoreDescriptionNode(node);
        if (score > bestScore) {
          bestScore = score;
          bestNode = node;
        }
      }
      if (bestNode) return bestNode;
    }

    return null;
  }

  function cleanupDescriptionNode(node) {
    const clone = node.cloneNode(true);

    const removeSelectors = [
      "button",
      "svg",
      "style",
      "script",
      "noscript",
      "form",
      "textarea",
      "input",
      "video",
      "canvas",
    ];

    clone
      .querySelectorAll(removeSelectors.join(","))
      .forEach((el) => el.remove());

    clone.querySelectorAll('[aria-hidden="true"]').forEach((el) => el.remove());

    return clone;
  }

  function renderInline(node) {
    if (!node) return "";

    if (node.nodeType === Node.TEXT_NODE) {
      return collapseInlineWhitespace(node.textContent || "");
    }

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

    const tag = node.tagName.toLowerCase();

    if (tag === "br") return "\n";
    if (tag === "code") {
      return "`" + escapeInlineCode(renderCodeInline(node).trim()) + "`";
    }
    if (tag === "sup") {
      const text = renderChildrenInline(node).trim();
      return text ? `^${text}` : "";
    }

    if (tag === "sub") {
      const text = renderChildrenInline(node).trim();
      return text ? `~${text}~` : "";
    }
    if (tag === "strong" || tag === "b") {
      const text = renderChildrenInline(node).trim();
      if (!text) return "";
      if (text.includes("`") || text.includes("**") || text.includes("*")) {
        return text;
      }
      return `**${text}**`;
    }

    if (tag === "em" || tag === "i") {
      const text = renderChildrenInline(node).trim();
      if (!text) return "";
      if (text.includes("`") || text.includes("**") || text.includes("*")) {
        return text;
      }
      return `*${text}*`;
    }
    if (tag === "a") {
      const text =
        renderChildrenInline(node).trim() || (node.textContent || "").trim();
      const href = node.getAttribute("href") || "";
      if (!href) return text;
      const fullHref = href.startsWith("http")
        ? href
        : new URL(href, location.origin).href;
      return `[${text}](${fullHref})`;
    }
    if (tag === "img") {
      const alt = (node.getAttribute("alt") || "").trim();
      const src = node.getAttribute("src") || "";
      if (!src) return alt || "";
      const fullSrc = src.startsWith("http")
        ? src
        : new URL(src, location.origin).href;
      return `![${alt}](${fullSrc})`;
    }

    return renderChildrenInline(node);
  }

  function renderChildrenInline(node) {
    let out = "";
    for (const child of node.childNodes) {
      out += renderInline(child);
    }
    return out.replace(/ *\n */g, "\n");
  }

  function renderList(listEl, depth = 0) {
    const ordered = listEl.tagName.toLowerCase() === "ol";
    const items = [...listEl.children].filter(
      (el) => el.tagName && el.tagName.toLowerCase() === "li",
    );

    let out = "";
    items.forEach((li, index) => {
      let main = "";
      let nested = "";

      for (const child of li.childNodes) {
        if (
          child.nodeType === Node.ELEMENT_NODE &&
          ["ul", "ol"].includes(child.tagName.toLowerCase())
        ) {
          nested += renderList(child, depth + 1);
        } else {
          main += renderNode(child, depth + 1, true);
        }
      }

      const prefix = ordered ? `${index + 1}. ` : "- ";
      const indent = "  ".repeat(depth);
      out += `${indent}${prefix}${main.trim()}\n`;
      if (nested.trim()) out += nested;
    });

    return out + "\n";
  }

  function tableToMarkdown(table) {
    const rows = [...table.querySelectorAll("tr")]
      .map((tr) =>
        [...tr.children]
          .filter((cell) => /^(td|th)$/i.test(cell.tagName))
          .map((cell) =>
            renderChildrenInline(cell).replace(/\|/g, "\\|").trim(),
          ),
      )
      .filter((row) => row.length);

    if (!rows.length) return "";

    const colCount = Math.max(...rows.map((row) => row.length));
    const normalized = rows.map((row) =>
      Array.from({ length: colCount }, (_, i) => row[i] || ""),
    );

    const header = normalized[0];
    const separator = Array.from({ length: colCount }, () => "---");

    const lines = [`| ${header.join(" | ")} |`, `| ${separator.join(" | ")} |`];

    normalized.slice(1).forEach((row) => {
      lines.push(`| ${row.join(" | ")} |`);
    });

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

  function renderBlockquote(node, depth = 0) {
    const inner = renderChildren(node, depth).trim();
    if (!inner) return "";

    return (
      inner
        .split("\n")
        .map((line) => `> ${line}`)
        .join("\n") + "\n\n"
    );
  }

  function renderNode(node, depth = 0, inlineOnly = false) {
    if (!node) return "";

    if (node.nodeType === Node.TEXT_NODE) {
      return inlineOnly
        ? collapseInlineWhitespace(node.textContent || "")
        : collapseInlineWhitespace(node.textContent || "");
    }

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

    const tag = node.tagName.toLowerCase();

    if (inlineOnly) {
      return renderInline(node);
    }

    if (tag === "pre") {
      const code = (node.innerText || node.textContent || "").replace(
        /\n+$/,
        "",
      );
      return code ? `\n\`\`\`\n${code}\n\`\`\`\n\n` : "";
    }

    if (tag === "code") {
      return "`" + escapeInlineCode((node.textContent || "").trim()) + "`";
    }

    if (/^h[1-6]$/.test(tag)) {
      const level = Number(tag[1]);
      const text = renderChildrenInline(node).trim();
      return text ? `\n${"#".repeat(level)} ${text}\n\n` : "";
    }

    if (tag === "p") {
      const text = renderChildrenInline(node).trim();
      return text ? `\n${text}\n\n` : "";
    }

    if (tag === "ul" || tag === "ol") {
      return "\n" + renderList(node, depth);
    }

    if (tag === "blockquote") {
      return "\n" + renderBlockquote(node, depth);
    }

    if (tag === "table") {
      const text = tableToMarkdown(node);
      return text ? `\n${text}\n\n` : "";
    }

    if (tag === "hr") {
      return "\n---\n\n";
    }

    if (tag === "br") {
      return "\n";
    }

    return renderChildren(node, depth);
  }

  function renderChildren(node, depth = 0) {
    let out = "";
    for (const child of node.childNodes) {
      out += renderNode(child, depth);
    }
    return out;
  }

  function cleanupMarkdown(markdown) {
    let text = normalizeWhitespace(markdown);

    text = text
      .replace(/[ \t]+\n/g, "\n")
      .replace(/\n{3,}/g, "\n\n")
      .replace(/`(\d{2,})(\d)`/g, (m, a, b) => {
        // 104 -> 10^4, 109 -> 10^9
        if (/^10$/.test(a)) return "`10^" + b + "`";
        return m;
      })
      .replace(/`O\(([^`()]*)n(\d)\)`/g, "`O($1n^$2)`")
      .replace(/\*\*\*`([^`]+)`\*/g, "`$1`")
      .replace(/\*`([^`]+)`\*/g, "`$1`")
      .replace(/\*\*`([^`]+)`\*\*/g, "`$1`")
      .replace(/^\n+/, "")
      .replace(/\n+$/, "");

    return text.trim();
  }

  function getProblemDescriptionMarkdown() {
    const contentNode = getProblemContentNode();
    if (!contentNode) return "";

    const cleaned = cleanupDescriptionNode(contentNode);
    const markdown = cleanupMarkdown(renderChildren(cleaned));

    const title = getProblemTitle();
    const titleRegex = new RegExp(
      "^#{1,6}\\s*" + title.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "\\s*\\n?",
      "i",
    );

    return markdown.replace(titleRegex, "").trim();
  }

  function buildProblemMarkdown() {
    const title = getProblemTitle();
    const url = getCanonicalProblemUrl();
    const difficulty = getDifficulty();
    const tags = getTags();
    const description = getProblemDescriptionMarkdown();

    const lines = [`# ${title}`, "", `链接:${url}`];

    if (difficulty) {
      lines.push(`难度:${difficulty}`);
    }

    if (tags.length) {
      lines.push(`标签:${tags.join(" / ")}`);
    }

    lines.push("", "## 题目内容", "");

    if (description) {
      lines.push(description);
    } else {
      lines.push("(未提取到题面正文,可以调整选择器后再试)");
    }

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

  async function handleCopy() {
    if (!isProblemPage()) {
      showToast("当前不是题目页面", true);
      return;
    }

    const button = document.getElementById(BUTTON_ID);
    if (button) {
      button.disabled = true;
      button.textContent = "复制中...";
      button.style.opacity = "0.75";
    }

    try {
      const markdown = buildProblemMarkdown();
      await copyText(markdown);
      console.log("[LeetCode Copy Helper] copied markdown:\n", markdown);
      showToast("题目已复制为 Markdown");
    } catch (error) {
      console.error("[LeetCode Copy Helper] copy failed:", error);
      showToast("复制失败,请打开 Console 查看错误", true);
    } finally {
      if (button) {
        button.disabled = false;
        button.textContent = "复制题目";
        button.style.opacity = "1";
      }
    }
  }

  function findToolbarContainer() {
    const titleEl = findTitleElement();
    if (!titleEl) return null;

    // 先在标题附近找你截图里的那排按钮
    let current = titleEl.parentElement;
    for (
      let depth = 0;
      current && depth < 6;
      depth += 1, current = current.parentElement
    ) {
      const candidates = current.querySelectorAll("div.flex.gap-1");

      for (const el of candidates) {
        if (!isVisible(el)) continue;

        const text = (el.textContent || "").trim();

        // 这排里一般会带这些文字
        if (
          text.includes("相关标签") ||
          text.includes("Related Topics") ||
          text.includes("提示") ||
          text.includes("Hints")
        ) {
          return el;
        }
      }
    }

    return null;
  }

  function createButton() {
    const button = document.createElement("button");
    button.id = BUTTON_ID;
    button.type = "button";
    button.textContent = "复制题目";

    // 不再 fixed
    button.style.display = "inline-flex";
    button.style.alignItems = "center";
    button.style.justifyContent = "center";
    button.style.gap = "6px";
    button.style.padding = "4px 10px";
    button.style.border = "none";
    button.style.borderRadius = "9999px";
    button.style.background = "var(--fill-secondary, #f3f4f6)";
    button.style.color = "var(--text-secondary-foreground, #262626)";
    button.style.fontSize = "12px";
    button.style.lineHeight = "20px";
    button.style.cursor = "pointer";
    button.style.whiteSpace = "nowrap";

    button.addEventListener("mouseenter", () => {
      button.style.filter = "brightness(0.96)";
    });

    button.addEventListener("mouseleave", () => {
      button.style.filter = "none";
    });

    button.addEventListener("click", handleCopy);

    return button;
  }

  function ensureButton() {
    const oldButton = document.getElementById(BUTTON_ID);

    if (!isProblemPage()) {
      if (oldButton) oldButton.remove();
      return;
    }

    const toolbar = findToolbarContainer();
    if (!toolbar) return;

    if (oldButton && toolbar.contains(oldButton)) return;
    if (oldButton) oldButton.remove();

    const wrapper = document.createElement("div");
    wrapper.style.display = "inline-flex";
    wrapper.style.alignItems = "center";

    const button = createButton();
    wrapper.appendChild(button);

    const children = [...toolbar.children];
    const hintItem = children.find((el) => {
      const text = (el.textContent || "").trim();
      return text.includes("提示") || text.includes("Hints");
    });

    if (hintItem) {
      if (hintItem.nextSibling) {
        toolbar.insertBefore(wrapper, hintItem.nextSibling);
      } else {
        toolbar.appendChild(wrapper);
      }
    } else {
      toolbar.appendChild(wrapper);
    }
  }

  function scheduleEnsureButton() {
    clearTimeout(ensureButtonTimer);
    ensureButtonTimer = setTimeout(() => {
      ensureButton();
    }, 120);
  }

  function patchHistory() {
    if (window.__LC_COPY_HELPER_HISTORY_PATCHED__) return;
    window.__LC_COPY_HELPER_HISTORY_PATCHED__ = true;

    const rawPushState = history.pushState;
    const rawReplaceState = history.replaceState;

    history.pushState = function (...args) {
      const result = rawPushState.apply(this, args);
      window.dispatchEvent(new Event("lc-copy-helper:urlchange"));
      return result;
    };

    history.replaceState = function (...args) {
      const result = rawReplaceState.apply(this, args);
      window.dispatchEvent(new Event("lc-copy-helper:urlchange"));
      return result;
    };
  }

  function watchRouteChange() {
    patchHistory();

    window.addEventListener("popstate", () => {
      scheduleEnsureButton();
    });

    window.addEventListener("lc-copy-helper:urlchange", () => {
      if (location.href !== lastUrl) {
        lastUrl = location.href;
        scheduleEnsureButton();
      }
    });

    setInterval(() => {
      if (location.href !== lastUrl) {
        lastUrl = location.href;
        scheduleEnsureButton();
      }
    }, 800);
  }

  function watchDom() {
    if (mutationObserver) return;
    if (!document.body) return;

    mutationObserver = new MutationObserver(() => {
      scheduleEnsureButton();
    });

    mutationObserver.observe(document.body, {
      childList: true,
      subtree: true,
    });
  }

  function init() {
    ensureButton();
    watchRouteChange();
    watchDom();
  }

  window.addEventListener("load", init);
  setTimeout(init, 1000);
})();