Export Website to Markdown

Export all rendered text from any webpage to a Markdown file. Preserves headings, lists, links, bold/italic, code, tables, and blockquotes.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, Greasemonkey alebo Violentmonkey.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie, ako napríklad Tampermonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, % alebo Violentmonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey alebo Userscripts.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie, ako napríklad Tampermonkey.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie správcu používateľských skriptov.

(Už mám správcu používateľských skriptov, nechajte ma ho nainštalovať!)

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

(Už mám správcu používateľských štýlov, nechajte ma ho nainštalovať!)

// ==UserScript==
// @name         Export Website to Markdown
// @namespace    https://github.com/theelderemo/export-website-as-markdown
// @version      1.0.0
// @description  Export all rendered text from any webpage to a Markdown file. Preserves headings, lists, links, bold/italic, code, tables, and blockquotes.
// @author       theelderemo
// @license      MIT
// @match        *://*/*
// @grant        none
// @run-at       document-idle
// ==/UserScript==

(function () {
  "use strict";

  const SKIP_TAGS = new Set([
    "SCRIPT", "STYLE", "NOSCRIPT", "TEMPLATE", "SVG", "CANVAS",
    "AUDIO", "VIDEO", "IFRAME", "OBJECT", "EMBED", "HEAD", "META",
    "LINK", "INPUT", "TEXTAREA", "SELECT", "BUTTON",
  ]);

  const BLOCK_TAGS = new Set([
    "P", "DIV", "SECTION", "ARTICLE", "MAIN", "ASIDE", "FOOTER",
    "HEADER", "NAV", "FIGURE", "FIGCAPTION", "DETAILS", "SUMMARY",
    "DIALOG", "ADDRESS", "FIELDSET", "LEGEND", "FORM",
  ]);

  function isVisible(el) {
    if (el.nodeType !== Node.ELEMENT_NODE) return true;
    const style = window.getComputedStyle(el);
    return (
      style.display !== "none" &&
      style.visibility !== "hidden" &&
      style.visibility !== "collapse" &&
      parseFloat(style.opacity) > 0 &&
      el.getAttribute("aria-hidden") !== "true"
    );
  }

  function nodeToMarkdown(node, ctx) {
    if (node.nodeType === Node.TEXT_NODE) {
      const text = node.textContent;
      const clean = text.replace(/[\r\n]+/g, " ").replace(/\t/g, " ");
      if (!clean.trim()) return "";
      return applyInlineStyle(clean, ctx);
    }

    if (node.nodeType !== Node.ELEMENT_NODE) return "";
    if (SKIP_TAGS.has(node.tagName)) return "";
    if (!isVisible(node)) return "";

    const tag = node.tagName;

    if (/^H[1-6]$/.test(tag)) {
      const level = parseInt(tag[1]);
      const prefix = "#".repeat(level);
      const inner = childrenToMarkdown(node, ctx).trim();
      return inner ? `\n\n${prefix} ${inner}\n\n` : "";
    }

    if (tag === "HR") return "\n\n---\n\n";

    if (tag === "BLOCKQUOTE") {
      const inner = childrenToMarkdown(node, ctx).trim();
      if (!inner) return "";
      return (
        "\n\n" +
        inner
          .split("\n")
          .map((l) => `> ${l}`)
          .join("\n") +
        "\n\n"
      );
    }

    if (tag === "PRE") {
      const codeEl = node.querySelector("code");
      const lang = codeEl
        ? (codeEl.className.match(/language-(\S+)/) || [])[1] || ""
        : "";
      const text = (codeEl || node).innerText || node.textContent;
      return `\n\n\`\`\`${lang}\n${text.trim()}\n\`\`\`\n\n`;
    }

    if (tag === "CODE" && node.closest("pre")) return "";

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

    if (["STRONG", "B"].includes(tag)) {
      const inner = childrenToMarkdown(node, { ...ctx, bold: true }).trim();
      return inner ? `**${inner}**` : "";
    }

    if (["EM", "I"].includes(tag)) {
      const inner = childrenToMarkdown(node, { ...ctx, italic: true }).trim();
      return inner ? `_${inner}_` : "";
    }

    if (tag === "MARK") {
      const inner = childrenToMarkdown(node, ctx).trim();
      return inner ? `==${inner}==` : "";
    }

    if (["S", "STRIKE", "DEL"].includes(tag)) {
      const inner = childrenToMarkdown(node, ctx).trim();
      return inner ? `~~${inner}~~` : "";
    }

    if (tag === "SUP") {
      const inner = childrenToMarkdown(node, ctx).trim();
      return inner ? `^${inner}^` : "";
    }

    if (tag === "SUB") {
      const inner = childrenToMarkdown(node, ctx).trim();
      return inner ? `~${inner}~` : "";
    }

    if (tag === "A") {
      const href = node.getAttribute("href") || "";
      const inner = childrenToMarkdown(node, ctx).trim();
      if (!inner) return "";
      const absHref = href
        ? new URL(href, window.location.href).href
        : "";
      return absHref && absHref !== inner ? `[${inner}](${absHref})` : inner;
    }

    if (tag === "IMG") {
      const alt = node.getAttribute("alt") || "";
      const src = node.getAttribute("src") || "";
      const absSrc = src ? new URL(src, window.location.href).href : "";
      return alt ? `![${alt}](${absSrc})` : "";
    }

    if (tag === "BR") return "  \n";

    if (tag === "UL") {
      const items = listItems(node, ctx, false);
      return items ? `\n\n${items}\n\n` : "";
    }

    if (tag === "OL") {
      const items = listItems(node, ctx, true);
      return items ? `\n\n${items}\n\n` : "";
    }

    if (tag === "LI") {
      return childrenToMarkdown(node, ctx).trim();
    }

    if (tag === "DL") {
      return "\n\n" + childrenToMarkdown(node, ctx).trim() + "\n\n";
    }
    if (tag === "DT") {
      return `\n**${childrenToMarkdown(node, ctx).trim()}**\n`;
    }
    if (tag === "DD") {
      return `: ${childrenToMarkdown(node, ctx).trim()}\n`;
    }

    if (tag === "TABLE") {
      return tableToMarkdown(node, ctx);
    }
    if (["THEAD", "TBODY", "TFOOT", "TR", "TH", "TD"].includes(tag)) {
      return childrenToMarkdown(node, ctx);
    }

    if (tag === "P" || BLOCK_TAGS.has(tag)) {
      const inner = childrenToMarkdown(node, ctx).trim();
      return inner ? `\n\n${inner}\n\n` : "";
    }

    return childrenToMarkdown(node, ctx);
  }

  function childrenToMarkdown(node, ctx) {
    let out = "";
    for (const child of node.childNodes) {
      out += nodeToMarkdown(child, ctx);
    }
    return out;
  }

  function applyInlineStyle(text, ctx) {
    return text;
  }

  function listItems(ulEl, ctx, ordered) {
    const items = [];
    let idx = 1;
    for (const child of ulEl.children) {
      if (child.tagName !== "LI") continue;
      if (!isVisible(child)) continue;
      const bullet = ordered ? `${idx}.` : "-";
      idx++;
      const inner = childrenToMarkdown(child, ctx)
        .trim()
        .replace(/\n{2,}/g, "\n")
        .replace(/\n/g, "\n    ");
      items.push(`${bullet} ${inner}`);
    }
    return items.join("\n");
  }

  function tableToMarkdown(tableEl, ctx) {
    const rows = [];
    for (const section of ["THEAD", "TBODY", "TFOOT"]) {
      const sectionEl = tableEl.querySelector(section);
      if (sectionEl) {
        for (const row of sectionEl.querySelectorAll("tr")) {
          if (isVisible(row)) rows.push(row);
        }
      }
    }
    if (rows.length === 0) {
      for (const row of tableEl.querySelectorAll("tr")) {
        if (isVisible(row)) rows.push(row);
      }
    }
    if (rows.length === 0) return "";

    const matrix = rows.map((row) =>
      [...row.querySelectorAll("th, td")]
        .filter(isVisible)
        .map((cell) => childrenToMarkdown(cell, ctx).trim().replace(/\|/g, "\\|"))
    );

    const colCount = Math.max(...matrix.map((r) => r.length));
    const pad = (arr) => {
      while (arr.length < colCount) arr.push("");
      return arr;
    };

    const lines = [];
    matrix.forEach((cells, i) => {
      lines.push(`| ${pad(cells).join(" | ")} |`);
      if (i === 0) {
        lines.push(`|${" --- |".repeat(colCount)}`);
      }
    });

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

  function postProcess(md) {
    return md
      .replace(/\n{4,}/g, "\n\n\n")
      .replace(/[ \t]+\n/g, "\n")
      .replace(/^\n+/, "")
      .replace(/\n+$/, "\n");
  }

  function exportToMarkdown() {
    const title = document.title || window.location.hostname;
    const url = window.location.href;
    const date = new Date().toISOString().replace("T", " ").slice(0, 19) + " UTC";

    let md = `# ${title}\n\n`;
    md += `> **Source:** [${url}](${url})  \n`;
    md += `> **Exported:** ${date}\n\n`;
    md += "---\n\n";

    const body = document.body;
    if (!body) {
      alert("No document body found.");
      return;
    }

    const rawMd = childrenToMarkdown(body, {});
    md += postProcess(rawMd);

    const slug = title
      .toLowerCase()
      .replace(/[^a-z0-9]+/g, "-")
      .replace(/^-+|-+$/g, "")
      .slice(0, 60);
    const filename = `${slug || "export"}.md`;

    const blob = new Blob([md], { type: "text/markdown;charset=utf-8" });
    const a = document.createElement("a");
    a.href = URL.createObjectURL(blob);
    a.download = filename;
    document.body.appendChild(a);
    a.click();
    setTimeout(() => {
      URL.revokeObjectURL(a.href);
      a.remove();
    }, 1000);

    showToast(`✓ Exported as ${filename}`);
  }

  function showToast(message) {
    const toast = document.createElement("div");
    Object.assign(toast.style, {
      position: "fixed",
      bottom: "80px",
      right: "20px",
      background: "#1a1a2e",
      color: "#e2f0ff",
      padding: "10px 18px",
      borderRadius: "8px",
      fontFamily: "system-ui, sans-serif",
      fontSize: "13px",
      fontWeight: "500",
      zIndex: "2147483647",
      boxShadow: "0 4px 20px rgba(0,0,0,0.35)",
      transition: "opacity 0.4s ease",
      opacity: "1",
      pointerEvents: "none",
    });
    toast.textContent = message;
    document.body.appendChild(toast);
    setTimeout(() => (toast.style.opacity = "0"), 2400);
    setTimeout(() => toast.remove(), 2800);
  }

  function createButton() {
    const btn = document.createElement("button");
    btn.textContent = "⬇ MD";
    btn.title = "Export page to Markdown";

    const baseStyle = {
      position: "fixed",
      bottom: "20px",
      right: "20px",
      zIndex: "2147483647",
      background: "#1a1a2e",
      color: "#7dd3fc",
      border: "1.5px solid #334155",
      borderRadius: "10px",
      padding: "8px 14px",
      fontFamily: "system-ui, monospace",
      fontSize: "12px",
      fontWeight: "700",
      letterSpacing: "0.05em",
      cursor: "pointer",
      boxShadow: "0 4px 18px rgba(0,0,0,0.4)",
      transition: "transform 0.15s ease, background 0.15s ease, box-shadow 0.15s ease",
      userSelect: "none",
    };

    Object.assign(btn.style, baseStyle);

    btn.addEventListener("mouseenter", () => {
      btn.style.background = "#0f172a";
      btn.style.boxShadow = "0 6px 24px rgba(0,0,0,0.5)";
      btn.style.transform = "translateY(-2px)";
    });
    btn.addEventListener("mouseleave", () => {
      btn.style.background = "#1a1a2e";
      btn.style.boxShadow = "0 4px 18px rgba(0,0,0,0.4)";
      btn.style.transform = "translateY(0)";
    });
    btn.addEventListener("mousedown", () => {
      btn.style.transform = "translateY(0) scale(0.96)";
    });
    btn.addEventListener("mouseup", () => {
      btn.style.transform = "translateY(-2px)";
    });

    let isDragging = false, startX, startY, startRight, startBottom;

    btn.addEventListener("pointerdown", (e) => {
      if (e.button !== 0) return;
      isDragging = false;
      startX = e.clientX;
      startY = e.clientY;
      startRight = parseInt(btn.style.right) || 20;
      startBottom = parseInt(btn.style.bottom) || 20;

      const onMove = (ev) => {
        const dx = ev.clientX - startX;
        const dy = ev.clientY - startY;
        if (Math.abs(dx) > 4 || Math.abs(dy) > 4) isDragging = true;
        if (isDragging) {
          btn.style.right = `${startRight - dx}px`;
          btn.style.bottom = `${startBottom - dy}px`;
        }
      };
      const onUp = () => {
        document.removeEventListener("pointermove", onMove);
        document.removeEventListener("pointerup", onUp);
      };

      document.addEventListener("pointermove", onMove);
      document.addEventListener("pointerup", onUp);
    });

    btn.addEventListener("click", (e) => {
      if (isDragging) {
        e.stopImmediatePropagation();
        isDragging = false;
        return;
      }
      exportToMarkdown();
    });

    document.body.appendChild(btn);
  }

  document.addEventListener("keydown", (e) => {
    if (e.altKey && e.shiftKey && e.key === "M") {
      e.preventDefault();
      exportToMarkdown();
    }
  });

  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", createButton);
  } else {
    createButton();
  }
})();