用笨办法应对充满不确定性的未来

方便地在 Org-mode 中插入 Firefox 中的链接

2023.01.10

TL:DR;

在撰写博客和记笔记时,我希望能够轻松地插入我正在浏览的标签页的 URL 和标题。我曾考虑使用 AppleScript,这与我之前在 Alfred 中的实现方式相似。但是,当我尝试在 Firefox 中获取 URL 时,只能通过模拟按键点击的方式,这样做既不可靠,也会打断我的写作思路。

最终,我通过编写 Tampermonkey 脚本和本地 Python HTTP 服务的方式来解决这个问题。这种方法可以将 Firefox 中当前访问的标签页信息存储起来,并通过 lisp 从 HTTP 服务中获取该标签页的信息,最终解决了该问 题。

一、问题

在 Emacs 中获取并插入 Firefox 中的链接的体验并不是很好

最近在使用 Emacs 时,我发现了一个命令可以快速获取并插入其他应用程序中的链接,这个命令叫做 org-mac-link-get-link 。然而,我发现在使用 Firefox 中获取链接时,这个命令的使用体验并不是很好,经常会出现获取失败的情况。

我发现在 Chrome 和 Safari 中获取链接时,体验比较流畅。但是,在 Firefox 中获取链接时,出现问题的概率 比较高。经过仔细调查,我发现了从 Firefox 中获取链接 URL 的底层代码。

org-mac-link-firefox-insert-frontmost-url

(defun org-mac-link-firefox-insert-frontmost-url ()
  "Insert the link to the frontmost window of the Firefox.app."
  (interactive)
  (insert (org-mac-link-firefox-get-frontmost-url)))

我发现该命令内部使用了 AppleScript 来操作 Firefox 获取链接 URL:

(defun org-mac-link-applescript-firefox-get-frontmost-url ()
  "AppleScript to get the links to the frontmost window of the Firefox.app."
  (let ((result
	 (org-mac-link-do-applescript
	  (concat
	   "set oldClipboard to the clipboard\n"
	   "set frontmostApplication to path to frontmost application\n"
	   "tell application \"Firefox\"\n"
	   "	activate\n"
	   "	delay 0.15\n"
	   "	tell application \"System Events\"\n"
	   "		keystroke \"l\" using {command down}\n"
	   "		delay 0.2\n"
	   "		keystroke \"c\" using {command down}\n"
	   "	end tell\n"
	   "	delay 0.15\n"
	   "	set theUrl to the clipboard\n"
	   "	set the clipboard to oldClipboard\n"
	   "	set theResult to (get theUrl) & \"::split::\" & (get name of window 1)\n"
	   "end tell\n"
	   "activate application (frontmostApplication as text)\n"
	   "set links to {}\n"
	   "copy theResult to the end of links\n"
	   "return links as string\n"))))
    (car (split-string result "[\r\n]+" t))))

所以本质上是用 AppleScript 来操作浏览器、获取链接的。

使用 AppleScript 是一种操作浏览器并获取链接 URL 的常见方法。在这种方法中,AppleScript 会模拟用户的键盘鼠标操作,以此来实现对浏览器的控制,并从浏览器中获取需要的信息。虽然这种方法在某些情况下是有效的,但由于依赖于用户界面,因此可能不够可靠,并且可能会打断正在进行的任务。

现有的流行的 AppleScript 脚本

互联网上有很多关于如何实现这个脚本的帖子。我放上两个我认为比较优秀的。

display dialog "Name of the browser?" default answer "Safari"
set inp to text returned of result

tell application "System Events"
    if inp is "Google Chrome" then
        tell application "Google Chrome" to return URL of active tab of front window
    else if inp is "Safari" then
        tell application "Safari" to return URL of front document
    else if inp is "Firefox" then
        tell application "Firefox" to activate
        tell application "System Events"
            keystroke "l" using command down
            keystroke "c" using command down
        end tell
        delay 0.5
        return the clipboard
    else
        return
    end if
end tell

我们可以看到,从 Chrome 和 Safari 中获取 URL 都是比较直接的

tell application "Google Chrome" to return URL of active tab of front window
tell application "Safari" to return URL of front document

但是在 Firefox 中则需要模拟键盘按键操作

tell application "Firefox" to activate
tell application "System Events"
    keystroke "l" using command down
    keystroke "c" using command down
end tell

这一段先将 Firefox 激活到前台,然后模拟按键 command + L 将光标定位到浏览器地址栏,并选中 URL 地址。 接着再次模拟按键 command +c 来复制 URL。

大家可以按顺序手动操作一下,一般这样就可以复制到 URL 了。

Firefox 不支持 AppleScript

为什么 Firefox 无法像 Chrome 和 Safari 那样直接使用 AppleScript 来获取 URL 呢?

主要原因在于,Firefox 目前尚未完全实现 AppleScript 支持,仅处于开发阶段,而该进程已经停滞不前。

有人曾提出希望添加 AppleScript 支持的建议,但由于优先级和人力资源等问题,这一功能被搁置了。

对 AppleScript 的设计文档

Mac:AppleScript - MozillaWiki

追踪进度用的 BUG:

608049 - Add Applescript Support to Firefox

正是由于缺乏对 AppleScript 的支持,所以只能通过模拟按键的方式来在 AppleScript 中获取 URL。

小结

由于 Firefox 不支持 AppleScript,因此想要从 Firefox 中获取 URL 和 TITLE,只能通过模拟点击的方式实现。

然而,这种点击方式很容易造成前后台切换卡顿、复制错误内容、粘贴时卡住等问题。最重要的是,整个流程不够流畅,容易分散人们的注意力,影响写作流程。

二、是真正的问题吗?

所以这确实是一个真正的问题。由于在 Firefox 中使用 AppleScript 获取 URL 和标题的不连贯不流畅,容易打 断思路,而我们希望能够顺畅地在 Org-mode 中插入来自 Firefox 的链接。

三、探索解决办法

方案一:使用 HTTP 服务来保存当前的 TAB 页面信息

Python 脚本

用于存储用户最后访问的浏览器 Tab,并通过 HTTP api 的方式提供给外部调用。

import hug


@hug.local()
@hug.post("/echo", versions=1)
def echo(url, title):
    globals()["currentTab"] = {"url": url, "title": title}
    return {"status": "ok", "message": "I memoryed"}


@hug.local()
@hug.get("/show", versions=1)
def show():
    tab = globals()["currentTab"]
    return tab

脚本很简单,引入了 hug 库,用于快速的将函数映射到 api 上。

代码中暴露了两个 api:

  • POST /v1/echo 用于存储当前 Tab 的参数
  • GET /v1/show 用于获取当前的 Tab

Tampermonkey 脚本:

// ==UserScript==
// @name         记录当前的Tab
// @namespace    http://tampermonkey.net/
// @version      0.1
// @description  try to take over the world!
// @author       You
// @connect      localhost
// @match        https://*/*
// @grant          GM_xmlhttpRequest
// @noframes
// ==/UserScript==

(function() {
    'use strict';
    var oldHref = location.href;
    var title = document.title;
    function track() {
        var requestDetails = {
            method: "POST",
            url: "http://localhost:8000/v1/echo",
            data: JSON.stringify({
                url:oldHref,
                title: title
            }),
            headers: {
                "Content-Type": "application/json"
            },
            onload: function(response) {

            },
            onerror: function(err){
                console.log('报错啦',err)
            }
        }
        GM_xmlhttpRequest(requestDetails);
    };
    track();
    if(document.addEventListener) {
        document.addEventListener("visibilitychange", function(){
            if (document.hidden) {
                return;
            }
            track();
        })
    }
})();

脚本中值得注意的有以下几个地方:

  • 原理:监听页面可见性

    本脚本的原理上是通过使用 page-visibility,来监听页面的可见性。在页面显示的时候将页面信息发送给本地的 HTTP 服务。

     document.addEventListener("visibilitychange", function(){
                if (document.hidden) {
                    return;
                }
                track();
            })
  • 脚本相关配置

    // @connect      localhost
    // @match        https://*/*
    // @grant          GM_xmlhttpRequest
    // @noframes
    
    • @connect 属性由于需要从 Tampermonkey 中发起请求,需要指定 host 白名单
    • @match 指定在哪些页面中激活该脚本。
    • @grant 声明脚本可以使用哪些特权 API,不声明的话没法调用指定的 api
    • @noframes 声明不在 iframe 里执行该脚本。重要,因为很多网页会用到 iframe 技术,如果不声明这一行,会 导致脚本重复执行,从而污染数据

Emacs 配置

在 Emacs 中发起 HTTP 请求,将获取到的 title 和 url 组装成 Org-mode 中的链接格式,并插入到光标所在的位置。

(defun my/insert-json-link ()
   "Get Recently Visited Tab's Url&Title into current point"
  (interactive)
  (let* ((url "http://localhost:8000/v1/show")
         (response (url-retrieve-synchronously url))
         (json-object-type 'hash-table)
         (json-array-type 'list)
         (json-key-type 'string)
         (json (with-current-buffer response
                 (goto-char url-http-end-of-headers)
                 (json-read))))
    (insert (format "[[%s][%s]]" (gethash "url" json) (gethash "title" json)))
    (kill-buffer response)))

保存到任意配置文件中

整个流程的时序图如下:

整体流程时序图

整体流程时序图

使用

  • 安装 Tampermonkey,并将上面的 javascript 脚本导入进去。
  • 保存 Python 脚本,并使用 hug -f main.py 启动服务。默认的端口是 8000

最后刷新 Firefox 浏览器中的所有标签页,然后在 Tab 页中切换,可以看到 Python 服务中有以下请求

127.0.0.1 - - [10/Jan/2023 21:29:21] "POST /v1/echo HTTP/1.1" 200 41
127.0.0.1 - - [10/Jan/2023 21:29:27] "POST /v1/echo HTTP/1.1" 200 41

在 Emacs 中执行,可以看到有对应的记录

127.0.0.1 - - [10/Jan/2023 21:57:14] "GET /v1/show HTTP/1.1" 200 152

同时 Org-mode 中光标位置顺利的插入了我们最后访问的 Tab 中所见的链接

ox-hugo/ox-hugo-manual.org at main · kaushalmodi/ox-hugo

截图如下:

最终完成的效果,在 Emacs 中插入 Firefox 中的 Url

最终完成的效果,在 Emacs 中插入 Firefox 中的 Url

进阶方案:

虽然基本上可以满足我们的需求,但遗憾的是这段脚本还无法方便的分享给其他人使用,而且代码结构不便于功能拓展

为了解决这个问题,我们需要一种更加通用的解决方案,即一种能够使脚本更加标准化的方法。我想到了 deno-bridge 这个工具,它可以将 Deno 和 Elisp 连接起来,使得 Elisp 脚本能够更好地与 Deno 应用程序进行通信和交互。通过这种方法,我们可以将 Elisp 脚本封装成更通用、更易于分发的模块,从而更好地满足我们的需求。

deno 是什么?

A free/libre framework that build bridge between Emacs and Deno runtime. Allows execution of JavaScript and Typescript within Emacs.

使用 deno 的优点是依赖比较简洁,而 deno-bridge 则比直接从 Emacs 发起 HTTP 请求更为优雅。

四、总结

在我们的写作流程中,我们发现在 Emacs 中使用 org-mac-link-firefox-insert-frontmost-url 方法的体验并不好。我们分析了产生问题的直接原因是使用 AppleScript 来模拟按键点击的方式来操作 Firefox,导致不稳定等问题,而根本原因是 Firefox 不支持 AppleScript。

为了解决这个问题,我们首先联合使用了 Tampermonkey、Python、Emacs Lisp,串联起了整个流程,实现了第一版的原型。

最后,我们讨论了如何让这个服务更具通用性,可以使用 deno-bridge 工具来实现 Deno 服务与 Emacs 之间的交互,以降低依赖安装的复杂度,提高扩展能力,并使该服务更容易共享给其他人使用。