编者注:本文涉及到 Bookmarklet 相关知识,如果你对该工具不熟悉,可以通过以下文章有所了解:


对于许多人来说,微信公众号是获取信息的重要来源。但是在微信封闭的环境里,没有这两个功能:文章关键词搜索和重要文段高亮,而且还有一个致命的问题:一旦文章被删除,以后想再找到就比较费劲了。

为了摆脱微信的束缚,更加方便地搜索文章,我们需要另寻出路来收藏微信公众号文章。我的主力笔记软件是 Bear,在文章被保存到 Bear 之前会先发送给 Drafts,对需要阅读的文章进行文段预处理和添加笔记标签。Drafts 有完善的 URL Scheme 支持,所以拿它来收集微信文章就再合适不过了1

但是微信文章怎么能让它支持 URL Scheme 呢? Bookmarklet 就在中间起到了桥梁的作用,沟通了这两个原本看似互不相干的事物。

注:同样的思路也可以用在其他支持 URL Scheme 的笔记软件中。例如同样对 URL Scheme 支持较全的印象笔记,只要在 Drafts 中选择将内容保存到印象笔记即可。

编写 Bookmarklet 代码

观看完上面的演示视频,或许你可以猜到这个 Bookmarklet 的作用就是将文章内容粘贴到 Drafts 中,分为三个步骤实现:

  1. 先把微信公众号文章在 Safari 中打开
  2. 在 Safari 中调用 Bookmarklet 拷贝文章内容到 Drafts 中
  3. 最后通过 x-success 自动跳回微信

本文的重点就在于第二步 Bookmarklet 代码的编写。

首先,构思一下我们需要 Bookmarklet 实现哪些功能:

  • 获取文章内容:通过 Bookmarklet 里面的 JavaScript 代码获取到文章内容
  • 拷贝文章内容:将获取到的文章内容拷贝到系统剪切板
  • 在 Drafts 打开并粘贴内容:通过 URL Scheme 打开 Drafts 并从剪切板新建草稿

本文会涉及到较多 HTML 知识点,我会尽可能通俗易懂地向大家解释清楚每一步的作用。

在此之前需要前往 Safari 设置 → 高级 → 勾选「在菜单栏中显示“开发”菜单」,只有勾选之后才能使用快捷键显示出网页检查器。

获取文章内容

首先,我们来认识一下 HTML 网页的基本结构,如下所示:

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>网页标题</title>
</head>
<body>网页内容</body>
</html>

<!DOCTYPE html> 用于区分文档类型,<html> 声明这是一个 HTML 网页,<head> 标签用于给网页添加基本的配置信息,<meta> 用于存储相关的元数据,那么顾名思义 <title><body> 就是分别对应着文章的网页标题和网页元素了。

首先要做的事情就是获取 <body> 标签的内容,在 HTML 的世界里,每一个网页都可以被视为是一份文档(document)。JavaScript 提供了 document.body 这样一个元素接口用于返回当前文档中 <body> 元素内容。同理可得,也有 document.title 可以获得网页标题 。

现在通过实践验证一下,在 macOS 的 Safari 上任意打开一篇微信公众号的微信,按下快捷键 option+command+i 打开网页检查器,在控制台输入 doucument.title 可以看到控制台返回了文章的网页标题。

输入 document.body 后遗憾地发现,它并没有输出我们期待的正文内容,这是因为 document.body 返回的内容是 HTML 代码。

为了获取 HTML 代码中的文本内容,HTML 也提供了相应的 innerText 属性,输入 document.body.innerText 就可以看到控制台成功输出了正文内容。

截屏2021-04-05 下午10.24.58

细心的读者看到这里可能就会有疑问了:这样做可以获取到文章内容没错,但是配图不是都丢了吗?为什么会考虑使用纯文本保存文章?

这样做是出于以下两个原因: 

  1. 图片对阅读理解的影响较小:我阅读的 95% 以上的文章是不需要配图就可以正常阅读的,配图在文章中的作用更多是辅助理解,只有不到 5% 的文章需要我手动去添加图片。
  2. 图片过于占用储存空间:曾经看到一个群友使用印象笔记收集了 2000 篇文章占用了 10GB 存储空间,大量不必要的图片收藏不仅占用空间,而且如果有多台设备要进行初次同步,这个过程也是非常漫长的。目前我在 Bear 中保存了 1616 篇笔记,占用存储空间仅仅只有不到 600MB。

拷贝文章内容

第一步获取文章内容完成,第二步就是要把获取到的文章内容复制到剪切板为后续的操作做铺垫。使用 JavaScript 拷贝文本在 GitHub 上已经有非常稳定成熟的代码可供参考了,所以可以不必再重新造一遍轮子。以 bradleybossard 在 2016 年编写的 titleUrlMarkdownClip.js 代码为例进行说明,本文制作的 Bookmarklet 拷贝文章内容模块代码正是从此处借鉴来的,代码如下:

function copyToClipboard(text) {
   if (document.queryCommandSupported && document.queryCommandSupported("copy")) {
        var textarea = document.createElement("textarea");
        textarea.textContent = text;
        textarea.style.position = "fixed";
        document.body.appendChild(textarea);
        textarea.select();
        try {
            return document.execCommand("copy");
        } catch(ex) {
            console.warn("Copy to clipboar failed.", ex);
            return false;
        } finally {
            document.body.removeChild(textarea);
        }
    }
}

可以看出,他将拷贝文本这个功能封装成了 copyToClipboard 函数,方便在其他地方调用,只要将需要拷贝到剪切板的内容作为参数传入这个函数,即可将其复制到剪切板。

看到第二行的 document.queryCommandSupported 方法,很容易可以猜出这个方法的用途是检测浏览器是否支持指定的编辑指令。此处的判断条件就是如果浏览器支持运行 copy 指令,则进入接下来的文本复制语句块。但是由于这个 Bookmarklet 是在 Safari 上使用的,而 Safari 是支持 copy 指令的,所以不必担心兼容性问题。

从第三行到第七行执行的操作依次为:

  • 创建临时元素 textarea
  • 将传入参数 text 赋值给临时元素 textarea,此处的参数 text 就是正文内容(document.body.innerText);
  • 设置 textarea 元素定位方法类型;
  • textarea 作为子节点添加到文章内容的末尾处,为接下来的选择文本做准备;
  • 使用 select() 方法选中 textarea 元素中的文本内容,这个步骤相当于使用鼠标选中文本,两者是一样的。

第八行调用 document.execCommand() 方法运行 copy 指令拷贝 select() 方法选中的内容,也就是文章的全部内容,如果错误就向控制台输出错误信息,否则复制成功之后删除 textarea 这个临时元素。

在 Drafts 打开并粘贴内容

在成功地实现了代码的前两个功能之后,接下来就是要打通 Safari 和 Drafts 之间的通道,这就需要 URL Scheme 和剪切板共同发挥作用了。

文章内容已经被拷贝到了剪切板,第三步只要制作一个动作让 Drafts 根据剪切板内容新建文稿即可,而 Bookmarklet 要做的事情就是打开 Drafts 并运行这个动作。通过查阅 Drafts URL Scheme 文档 可以知道 Drafts 运行动作 URL Scheme 为:

drafts5://x-callback-url/runAction?action=paste

在这里建议将动作名命名为 paste,最后一步只需要调用 window.open() 方法打开这个 URL Scheme 就完成了,这个步骤的代码如下:

url = 'drafts5://x-callback-url/runAction?action=paste';
window.open(url,'_self');

这里需要解释一下为什么不在 Bookmarklet 的代码中直接将 document.body.innerText 作为 URL Scheme 的 text 参数传递给 Drafts,而是要通过剪切板来传递文章内容。

如果直接将文章内容作为 URL Scheme 的 text 参数传递给 Drafts,所有的换行符都会被吃掉。具体原因没有深究,后来想到使用剪切板传递文章内容是个解决的好办法,于是就沿用下来了。

Bookmarklet 完整代码

javascript: (function() {
    function copyToClipboard(text) {
       if (document.queryCommandSupported && document.queryCommandSupported("copy")) {
            var textarea = document.createElement("textarea");
            textarea.textContent = text;
            textarea.style.position = "fixed";
            document.body.appendChild(textarea);
            textarea.select();
            try {
                return document.execCommand("copy");
            } catch(ex) {
                console.warn("Copy to clipboar failed.", ex);
                return false;
            } finally {
                document.body.removeChild(textarea);
            }
        }
    }
    copyToClipboard(document.body.innerText);
    url = 'drafts5://x-callback-url/runAction?action=paste';
    window.open(url,'_self');
})();

在 Drafts 中创建 paste 动作

通过以上几步,我们已经将文章内容拷贝到了剪切板,接下来就是要在 Drafts 里创建一个名为 paste 的 Action(点击安装),让它能够从剪切板读取内容,并用这个内容新建草稿,以下是制作思路:

通过查询 Drafts URL Scheme 文档 可以知道 Drafts 新建草稿的 URL Scheme 为:

drafts5://create?text=

打开 Drafts 新建 paste 动作,让它能够从剪切板读取内容并新建草稿

在 IDENTIFICATION 处填写动作名称,例如图中的「paste」,接着点击「0 steps」滑到动作库底部选择 「URL」:

编辑 URL,看到下图红框框起来的参数了吗?通过 [[clipboard]] 参数可以非常方便地获取到剪切板的内容,将它和刚才上面查询到的新建草稿的 URL Scheme 拼接之后就可以得到这样一个 URL Scheme:

drafts5://x-callback-url/create?text=[[clipboard]]&x-success=weixin://

增强功能

做完了上面这些功能,基本的功能都已经实现了,已经可以通过 Bookmarklet 复制粘贴到 Drafts 然后返回微信了。但是在实际使用过程中发现了以下三个新的功能需求:

  • 标题带上作者和公众号
  • 文章尾部带上原始链接
  • 删除微信底部文章推荐

标题带上作者和公众号这一步是最为麻烦的,需要再拆分为三小步完成。所以接下来沉住气,一起来探究为什么需要这些功能以及它们是如何实现的。

 

获取文章作者名字

为什么要在标题带上作者和公众号?在《从文本格式化到分类收藏,我的文章阅读自动化流程》中提到我的阅读流程是:微信文章粘贴到 Drafts → Drafts 打上标签保存到 Bear → 在 Bear 中进行阅读 → 阅读完成后添加内部链接到「复盘清单」笔记,如下图所示:



但是不同公众号作者可能是不同的,这与公众号的文章来源有关。比如刚才在上面测试用的文章:《和 Apple 一起工作是什么体验?说说这些趣事》的作者是「四处趴趴走」,发布的公众号是「少数派」。这篇文章的标题是比较好记住的。但是如果碰上不太好记的标题,那么就可以在「复盘清单」中搜索作者或者公众号来查找这篇文章。而且在记得文章作者的情况下,根据作者来查找一篇文章速度也是比标题或者公众号更快一些。

打开网页检查器,来到元素页面按下 command+F 搜索网页源代码中的关键词,搜索测试文章中的「四处趴趴走」作者关键词可以看到有四个匹配结果。那么要选择哪个取它的值呢?基本原则:谁易得就取谁。

看到图中蓝色高亮的 HTML 代码,它的开头是 <meta 是不是有一种似曾相识的感觉?它就是刚才介绍 HTML 基本结构时提到的网页元数据标签。

既然 <meta> 是属于网页的基本标签,大概可以猜测它也和 doucument.body 一样有相应的方法可以直接获取到标签值,查阅 Web API 接口参考文档| MDN 可知可以通过以下方法获取标签值:

document.getElementsByTagName('META')

在控制台输入这个方法可以看到控制台输出了一个 HTMLCollection,其中包含了 22 个 <meta> 标签。

需要的作者名字在第 8 个,如果你有一些的编程经验,很容易就可以猜想到只要在方法后面添加一个索引号即可获得指定 <meta> 标签的 HTML 代码,所以在控制台接着输入:document.getElementsByTagName('META')[8],可以看到这次返回的结果就只有单独的一个作者元数据标签了。

现在还差最后⼀步,也是最简单的⼀步。因为这⾥需要的不是元数据标签,⽽是作者名字,所以只需要在后面加上 .content 即可获得到相对应的作者名字。举一反三也可以很容易地猜到如果在后面加上 .name 会获得 author 文本。

截屏2021-04-06 上午7.13.48

获取文章发布公众号名字

获取到了文章作者名字之后就轮到文章发布公众号名字了,按照上面的思路你可能也会想到在 <meta> 标签中找到公众号名字并使用 .content 获取文本内容,但是实际去找了一遍却发现 <meta> 标签中是没有文章发布公众号名字的。那么就重新开始搜索网页中「少数派」这个关键词,可以看到依旧有四个搜索结果。

把四个搜索结果浏览一遍过去,最后一个搜索结果引起了我的兴趣,看到 var nickname = "少数派"; 而且它又是在 <script> 标签内部,可以大胆猜测它是一个全局变量,只要调用了这个变量就能得到文章发布公众号名字。在控制台输入 nickname,非常顺利地获取到了文章发布的公众号名字:「少数派」三个字,至此两个关键信息的来源解决完毕。

添加作者和公众号到标题

现在我们已经知道可以 document.body.innerText 很方便地获取到文章内容了,那么如何将刚刚获得到的这两个关键信息插入文章标题呢?

document.body.innerText 获得到的是整篇文章的全部内容,如果要单独修改首行标题就需要将首行「拆」出来进行处理。在 JavaScript 中提供了两个用于拆分和合并的方法:split()join(),而文章每行之间是使用换行(\n)进行分隔的,所以就可以很容易地构造出如下代码:

content = document.body.innerText;
content = content.split('\n');
content[0] += ' ' + document.getElementsByTagName('META')[8].content + ' ' + nickname;
content = content.join('\n');

首先将 document.body.innerText 赋值给一个名字较短的 content 变量,方便后续操作,然后以换行符(\n)为标记进行拆分文本。

content[0] += ' ' + document.getElementsByTagName('META')[8].content + ' ' + nickname;
等价于
content[0] = content[0] + ' ' + document.getElementsByTagName('META')[8].content + ' ' + nickname;

标题毫无疑问是在首行的,所以也就是对应着 content[0],将 content[0] 重新赋值在后面依次加上作者名字和公众号名字以及三者之间用于分隔的空格。最后将拆分修改好了的文本使用 join() 按照换行符(\n)进行合并还原,这个功能就实现了。
 

 

文章尾部带上原始链接

文章尾部带上原始链接这个操作是比较好理解的,因为 Drafts 仅支持纯文本是无法将图片一并粘贴进来的,所以在不可缺少配图的少数文章中,可以通过底部这个原始链接打开原始网页并将图片手动复制粘贴到 Bear 中。但是好在这样需要配图进行说明的文章非常非常地少,不会占用多少时间,一般是读了十来篇才会有一篇文章必须要添加配图,而每天最多也就读二十多篇文章。

既然要添加原始链接,那么使用 Markdown 格式的链接就再合适不过了。思路就是在调用 copyToClipboard() 函数的时候在后面追加一个 Markdown 格式的链接一并复制即可。

Markdown 链接需要两个参数:网页标题和网页链接。还记得刚开始介绍的 <title> 标签吗?和 <body> 标签一样可以通过 document.title 获取到标题的文本值。

截屏2021-04-06 上午8.49.45

 

JavaScript 提供了一个名为 window 的类可以获取到网页窗口中的参数,接下来就是需要获取网页链接了,如下图所示通过 window.location 即可获取网页地址的所有参数。

截屏2021-04-06 上午8.51.52

但是需要的只有 href 参数所对应的 URL 地址,所以需要在后面加上 .href 即可。

现在两个参数来源同样也解决完成了,接下来就是构造 markdown 链接格式,代码如下

copyToClipboard(content + "\n" + '[' + document.title.trim() + '](' + window.location.href + ')');

content 就是刚刚重新拼接起来的文章内容,加一个换行符之后在新的一行构造出了一个 markdown 链接共同拷贝到剪切板。

删除微信底部文章推荐

在微信公众号底部会有一个推荐阅读,微信会根据你当前阅读的文章类型推荐相似的文章给你,这些文本内容同样会被复制粘贴到 Drafts 中,但是对于实际使用需求来说这是不需要的,所以需要将它删除掉。

由于底部的推荐文章同样属于文章文字内容的一部分,所以需要从 content 入手,可以看到下图红色框框出的「喜欢此内容的人还喜欢」是固定的文本也是推荐文章的开头文本,只需要将从「喜欢此内容的人还喜欢」到底部的文字全部删除即可,更新 content 变量赋值代码如下:

content = document.body.innerText.replace(/(推荐阅读|喜欢此内容的人还喜欢)(.*\n)+/g, '').replace(/.*阅读原文.*/g,'').replace(/[\r\n]{3,}/g, '\n\n');

可以看出这是在 document.body.innerText 获取到文章内容之后添加了三个 replace 方法,它们的功能依次是:删除从「推荐阅读」或「喜欢此内容的人还喜欢」到文章最后的全部内容,删除阅读原文的行,将三个以上的换行替换为两个换行。

域名条件判断

上面编写的代码是仅适用于微信公众号文章的,对于其他网站的文章可能就会失效,所以需要对代码的运行条件做一下判断。还记得刚刚 window.location 返回了的几个参数吗?其中包含了一个名为 host 的参数,可以获取到当前网页的主机名,如下图所示是调用 window.location.host 方法之后返回的结果。


将前面的拆分、添加作者和公众号的代码合并放到判断语句的代码块里,得到下面的代码:

if (window.location.host == 'mp.weixin.qq.com'){
  content = document.body.innerText.replace(/(推荐阅读|喜欢此内容的人还喜欢)(.*\n)+/g, '').replace(/.*阅读原文.*/g,'').replace(/[\r\n]{3,}/g, '\n\n');
  content = content.split('\n');
  content[0] += ' ' + document.getElementsByTagName('META')[8].content + ' ' + nickname;
  content = content.join('\n');
}

升级版 Bookmarklet 完整代码

javascript: (function() {
    function copyToClipboard(text) {
       if (document.queryCommandSupported && document.queryCommandSupported("copy")) {
            var textarea = document.createElement("textarea");
            textarea.textContent = text;
            textarea.style.position = "fixed";
            document.body.appendChild(textarea);
            textarea.select();
            try {
                return document.execCommand("copy");
            } catch(ex) {
                console.warn("Copy to clipboar failed.", ex);
                return false;
            } finally {
                document.body.removeChild(textarea);
            }
        }
    }
    if (window.location.host == 'mp.weixin.qq.com'){
        content = document.body.innerText.replace(/(推荐阅读|喜欢此内容的人还喜欢)(.*\n)+/g, '').replace(/.*阅读原文.*/g,'').replace(/[\r\n]{3,}/g, '\n\n');
        content = content.split('\n');
        content[0] += ' ' + document.getElementsByTagName('META')[8].content + ' ' + nickname;
        content = content.join('\n');
    }
    copyToClipboard(content + "\n" + '[' + document.title.trim() + '](' + window.location.href + ')');
    url = 'drafts5://x-callback-url/runAction?action=paste';
    window.open(url,'_self');
})();

总结与扩展创新

到此小书签摘抄微信文章的 Bookmarklet 功能已经全部实现了,文中使用 Bookmarklet 在网页和软件之间,通过 URL Scheme 和 Clipboard 搭建起了桥梁,让两者得以复制粘贴文本。但是 Bookmarklet、URL Scheme 和 Clipboard 这三者的组合玩法,远远不止这篇文章实现的这个功能,故借本文抛砖引玉,希望少数派上的其他作者可以借鉴本文的思路构建出更多实用有趣的 Bookmarklet。

本文中的所有代码都可以在 Apple-Automation/JavaScript Bookmarklet 中找到,如果方便希望能帮我 star 一下,如果有任何需求或者 bug 也可以通过 issue 报给我。

参考文章

HTML 文档详解
HTML 元素参考
How To Copy to Clipboard
JS Copy Clipboard demo 
How do I copy to the clipboard in JavaScript
Bookmarklet to copy current page title and url in Markdown format to clipboard, like title