← 全部文章

操作类型

一个一键动作,把任何页面复制为干净的 Markdown

你读到值得保留的东西,想把它放进笔记。从浏览器里复制粘贴会拖进导航菜单、三个广告、一个 cookie 栏和一团乱麻般的内联样式。你真正想要的是干净的 Markdown:标题、段落、链接、代码块 —— 别无他物。本文构建一个 JustZix 动作按钮,一键就做到这件事。

为什么是 Markdown,为什么是动作按钮

Markdown 是通用的笔记格式 —— 它能干净地粘进 Obsidian、Notion、GitHub issue、你的编辑器、一个纯文本文件。这里的目标是 JustZix 动作栏里的一个按钮,它把当前的文字选区或整篇文章转换成 Markdown 并放到你的剪贴板上。没有 DevTools、没有额外应用、没有手动清理。

一个 BUTTON 类型的动作对此完美:它在动作栏里显示一个带标签的按钮,点击它就运行你的 JavaScript。我们现在就来写那段 JavaScript。

转换策略

我们递归地遍历 DOM。对每个节点我们决定:它是文本(输出它),还是一个我们知道如何转换的元素(为它输出 Markdown),还是要跳过的东西(导航、脚本、广告)。任何我们不认识的,我们就递归进去 —— 这样未知的包装器不会丢掉它的内容。这种「默认递归」的行为就是回退到纯文本。

// Elements we never want in the output
const SKIP = new Set([
  'SCRIPT', 'STYLE', 'NAV', 'HEADER', 'FOOTER', 'ASIDE',
  'NOSCRIPT', 'IFRAME', 'FORM', 'BUTTON', 'SVG'
]);

function isHidden(el) {
  const s = getComputedStyle(el);
  return s.display === 'none' || s.visibility === 'hidden';
}

转换行内元素

行内转换处理文本流的格式:粗体、斜体、代码、链接。它返回一个字符串,递归进子节点,这样嵌套的行内标签也能工作。

function inline(node) {
  if (node.nodeType === Node.TEXT_NODE) {
    return node.textContent.replace(/\s+/g, ' ');
  }
  if (node.nodeType !== Node.ELEMENT_NODE) return '';
  if (SKIP.has(node.tagName) || isHidden(node)) return '';

  const inner = [...node.childNodes].map(inline).join('');
  switch (node.tagName) {
    case 'STRONG': case 'B':   return '**' + inner.trim() + '**';
    case 'EM': case 'I':       return '*' + inner.trim() + '*';
    case 'CODE':               return '`' + inner.trim() + '`';
    case 'BR':                 return '  \n';
    case 'A': {
      const href = node.getAttribute('href') || '';
      const abs = href ? new URL(href, location.href).href : '';
      return abs ? '[' + inner.trim() + '](' + abs + ')' : inner;
    }
    default:                   return inner;
  }
}

注意链接处理:我们用 URL 构造函数把相对 href 相对于 location.href 解析,这样复制的链接在你粘到别处时仍然有效。

转换块级元素

块级转换处理结构性的部件 —— 标题、段落、列表、代码块、引用块 —— 并用空行把它们连起来。

function block(node) {
  if (node.nodeType === Node.TEXT_NODE) {
    return node.textContent.trim();
  }
  if (node.nodeType !== Node.ELEMENT_NODE) return '';
  if (SKIP.has(node.tagName) || isHidden(node)) return '';

  const t = node.tagName;

  if (/^H[1-6]$/.test(t)) {
    return '#'.repeat(+t[1]) + ' ' + inline(node).trim();
  }
  if (t === 'P') return inline(node).trim();
  if (t === 'BLOCKQUOTE') {
    return inline(node).trim()
      .split('\n').map(l => '> ' + l).join('\n');
  }
  if (t === 'PRE') {
    return '```\n' + node.textContent.replace(/\n$/, '') + '\n```';
  }
  if (t === 'UL' || t === 'OL') {
    const ordered = t === 'OL';
    return [...node.children]
      .filter(li => li.tagName === 'LI')
      .map((li, i) => (ordered ? (i + 1) + '. ' : '- ')
        + inline(li).trim())
      .join('\n');
  }
  if (t === 'HR') return '---';
  if (t === 'IMG') {
    const alt = node.getAttribute('alt') || '';
    const src = node.src || '';
    return src ? '![' + alt + '](' + src + ')' : '';
  }

  // Unknown wrapper: recurse, keep the children
  return [...node.childNodes].map(block)
    .filter(Boolean).join('\n\n');
}

最后一行是回退:一个我们没有特殊规则的 <div><section><article>,只会递归进它的子节点。最坏情况下,一个奇异的元素降级成它的纯文本 —— 永远不会降级成什么都没有。

先选区,后文章

这个按钮应该聪明:如果你有选中的文字,只转换那部分;否则转换主文章。Selection API 给我们一个 range,我们可以把它克隆进一个 fragment。

function getRoot() {
  const sel = window.getSelection();
  if (sel && sel.rangeCount && !sel.isCollapsed) {
    const frag = sel.getRangeAt(0).cloneContents();
    const wrap = document.createElement('div');
    wrap.appendChild(frag);
    return wrap;
  }
  // No selection: best guess at the main content
  return document.querySelector(
    'article, main, [role="main"], .post, .content'
  ) || document.body;
}

一个克隆的选区 fragment 可能从元素中间开始,所以块级转换可能看到部分节点 —— 那没关系,我们的「默认递归」规则会优雅地处理它。

把它接到一个 BUTTON 动作并复制

现在把这些部件组装起来,构建最终字符串,并把它写到剪贴板。这就是你的 BUTTON 动作的主体。

const root = getRoot();
const md = [...root.childNodes]
  .map(block)
  .filter(Boolean)
  .join('\n\n')
  .replace(/\n{3,}/g, '\n\n')   // collapse extra blank lines
  .trim();

navigator.clipboard.writeText(md)
  .then(() => JZ.toast('Copied ' + md.length + ' chars of Markdown'))
  .catch(() => {
    // Fallback for older clipboard restrictions
    const ta = document.createElement('textarea');
    ta.value = md;
    document.body.appendChild(ta);
    ta.select();
    document.execCommand('copy');
    ta.remove();
    JZ.toast('Copied (fallback)');
  });

要把这个变成一个按钮:给规则加一个 BUTTON 类型的动作,把它标为「复制为 Markdown」,并把上面的代码粘成它的处理函数。JZ.toast() 是 JustZix 用于快速页内确认的助手 —— 很方便,因为剪贴板写入否则是无声的。

值得加上的改进

为什么用剪贴板,而不是下载

你本可以触发一个 .md 文件下载,但剪贴板在记笔记上胜出:你点按钮,切到笔记应用,粘贴。没有文件要找,没有重命名,没有清理。navigator.clipboard.writeText 这个调用需要一个用户手势 —— 而一次按钮点击就是,所以它直接就能用。

另见

一个按钮、干净的 Markdown、零清理。安装 JustZix,用上面的代码加一个 BUTTON 动作,开始按你的笔记应用真正想要的方式收集网络。

为这篇文章评分

暂无评分 — 成为第一个。

自己动手试试

安装 JustZix,粘贴本文中的任意代码片段。两分钟,从零到一条在你所有设备上生效的规则。

获取 JustZix

功能 · 工作原理 · 示例 · 应用场景