分类
联系方式
  1. 新浪微博
  2. E-mail

Maeiee Weekly No.11:使用 WebScrapBook 制作互联网剪报

前言

什么是”剪报“

当我写下标题后突然意识到,会不会好多人已经不知道什么是”剪报“了……

什么是”剪报“?在我小的时候,那会儿大家还不知道什么是电脑,获取资讯主要靠看报纸。我爷爷会把好的文章用剪刀剪下来,贴到本子上。这就叫”剪报“,按现在的话来说,属于”资讯离线阅读手账“(嗯?)。

至于”剪报“长什么样,大家自行搜索这个关键词即可。

互联网时代的剪报

如今是互联网时代,已经没人看报了。连我爷爷都 iPad 玩的飞起。

互联网上的文章浩如烟海,看到好文章该怎么收藏呢?这里面存在一些现实问题:

  1. 文章会消失:比如托管平台倒闭了,平台下的文章都没了
  2. 大量文章如何归类:比如收藏了几万篇文章,怎么查阅都成为问题
  3. 如何阅读海量资讯:这么多资讯,看不过来,怎么提高效率?

针对这些问题,诞生了一些互联网剪报产品,比如各种 Read it later 工具,以及 Internet Archive 这种保护信息的非盈利机构。

今天要介绍的 WebScrapBook 也是一种互联网剪报,适合于个人使用。

WebScrapBook

WebScrapBook 是一个浏览器插件,通过它能够裁剪网页。用户将网页的正文部分剪下来,上传到 WebScrapBook 配套的后端服务器,实现管理、添加笔记、高亮标注等功能。

该软件长这样:

其中:

  • 左侧是网页的归类管理,有点像资源管理器
  • 网页正文部分
    • 底部有一个工具栏
      • 可以对内容添加高亮
      • 可以对内容添加笔记
      • 可以将网页转为编辑模式,自行添加内容
      • 可以进入 DOM 编辑模式,通过可视化小剪刀对 DOM 树进行裁剪,这是最神奇的功能

对于该软件的使用,参考项目官网(danny0838/webscrapbook)即可。本文主要分析 WebScrapBook 的实现原理。

ScrapBook 的神奇功能

这个插件属于 Web1.0 时代的产物,但是很多功能放在今天也是非常惊艳的。

剪切页面离线保存

在我看来这是 ScrapBook 最神奇的功能,在离线剪切时,你能用一种可视化方法剪切 DOM 树,将不需要的部分剪掉(比如广告、边栏),只留下需要的部分(正文)。

进入【编辑页面】功能后,底部多出一个工具栏,里面的工具分别包括【文本高亮】、【添加备注】、【DOM剪刀】、【HTML编辑】、【保存】等。

网页中左边有一个红框,这就是【DOM剪刀】的功能,开启后将鼠标在网页中移动,光标所在的 DOM 树会自动绘制红框范围,点击后该 DOM 树会被删除。

同时【DOM剪刀】也支持反选,按下 Ctrl + 点击,会将 DOM 树之外的部分都删除掉。

通过剪来剪去,最终只留下文章正文部分,点击【保存】。一份完美的、100% 保留格式的页面,就被我们永久保留下来了。

高亮

除了【DOM剪刀】之外,还可以手动对文字添加高亮,来划重点。

后端存储

剪下来的数据该如何存储呢?WebScrapBook 提供了多种存储方式,既可以存储在浏览器中,也可以搭建后端服务器。

WebScrapBook 提供了一套后端实现,danny0838/PyWebScrapBook,基于 Flask 框架使用 Python 开发而成。

通过这种方式,数据能够得到更好的存储与保管,同时也能够支持不同的设备维护同一套 WebScrapBook 数据。

服务器启动效果如下:

归档页面管理

剪藏下来的页面,通过【打开剪贴簿】可以进行分级浏览。

下图是剪贴簿远程访问后台服务器的数据效果。

里面的文件夹都是我自己创建的,可以创建任意层级深度的目录,帮助更好地管理页面。

丰富的设置选项

现在的 App 流行“傻瓜化”,过去的 App 流行专业化。ScrapBook 提供了大量的设置项目,让适应了“傻瓜化”的我目瞪口呆。

下图展示了部分配置项:

通过这些配置项,用户可以根据自身喜好配置出不同的使用效果。比如在意 100% 排版还原的用户可以尽可能保留现场,而在意归档文件空间占用的用户可以尽可能滤除多余元素。

甚至还能通过正则名单配置自动抓取项,当我们打开某一类网址时能够自动进行离线缓存。

还有一个名为【获取助手】的模块,基于 JSON 创建了一个 DSL,允许用户针对不同站点定制抓取逻辑,以实现最佳的抓取效果。

ScrapBook 的发展渊源

ScrapBook 是该软件的最早实现,随后经历了一系列的 fork,WebScrapBook 是目前还在活跃的 fork。

维基百科对 ScrapBook 的介绍中树立了该项目的渊源。

ScrapBook

最早由 Murota Laboratory 研发,该实验室是‎‎东京工业大学‎‎决策科学研究生院人类系统科学系人力资源开发主席的成员。它目前由 Gomita 维护。也就是说,这个项目是由日本开发者研发的。

ScrapBook 赢得了火狐 2006 年的”最有用的扩展功能“奖项。原来 ScrapBook 也火过,只不过是在快 20 年前。

Gomita

Gomita 的全名是 Gomibuchi, Taiga,博客最新一篇文章更新到 18 年,专注于浏览器和浏览器插件开发。

翻看作者的博客,以下回到了 20 年前的 Web 1.0 时代。里面用到的很多技术都已经尘封为历史往事,可在当年都是最新潮的 Web 开发技术。

其中《designMode》一篇介绍道:设计模式是一个简单的 HTML 编辑功能,安装在许多 Web 浏览器中,主要用作博客文章和 Web 邮件创建的富文本编辑器。

ScrapBook X

ScrapBook 从 2012 年开始研发速度变慢了,很多问题没有及时修复。这段时间出现了大量 Fork,比如:ScrapBook Plus、ScrapBook Plus 2、ScrapBook Lite。但是这也也都没有维护下去。

Danny Lin 创建了 ScrapBook X,GitHub 首页。ScrapBook X 对 ScrapBook 做了很多修复和新功能添加,还扩展了一些新的插件:

  • ScrapBook X MAF Creator:将 ScrapBook 数据转换为 MAFF 格式(一种单文件网页归档格式)
  • ScrapBook X CopyPageInfo:复制 ScrapBook 的引用信息,比如 BibTeX。
  • ScrapBook X AutoSave:在浏览过程中自动归档网页
  • ScrapBook X File Converter:其他格式的文件与 ScrapBook 相互导入导出

本文要介绍的 WebScrapBook 也是 Danny Lin 创建的。

Danny Lin

Danny Lin 是一个台湾的开发者,GitHub。创建了以下软件:

WebScrapBook

WebScrapBook 也是由 Danny Lin,作为 ScrapBook X 的后继,基于 WebExtension 规范。目前仍然在活跃维护当中。两者由什么不同呢?

ScrapBook X 只能在老版的 Firefox 下运行,WebScrapBook 在最新的 Chrome、Firefox 下都能运行。同时 WebScrapBook 提供 Server,支持远程访问。并且可自定义的项目更多。

该项目的 Wiki 价值非常高,比如 FAQ

ScrapBee

插件首页,也是 ScrapBook 的一种 Fork,GitHub。他的后端是用 Golang 写的。

ScrapYard

插件首页,另一个 ScrapBook Fork。支持通过云服务同步页面。该项目是对 ScrapBee 的 Fork,GitHub。目前处于活跃开发中。

作者是 GChristensen,个人主页。这个作者的文章都很有意思,值得认真学习。比如这一篇《Creating a personal knowledge base

Weekly 周目标

我接触 ScrapBook 有段时间了,断断续续用过几次。ScrapBook 的剪藏功能是非常强大的,但对资讯的管理功能相对较弱,难以至少满足我的需要。我也一直在寻找适合自己的终极互联网资讯缓存方案,思来想去,开源的 ScrapBook 可能是一个合适的入手点,这也是我本周选择 ScrapBook 的原因。

我的第一个目标个搞清 ScrapBook 代码的结构。知道不同功能的代码各自在什么位置。

大致浏览了一遍代码,发现 ScrapBook 插件代码是基于 Web1.0 的方式编写的,即基于传统 HTML、JavaScript、CSS 的写法,没有 Webpack 也没有框架,完全基于 DOM JavaScript API。这给我的理解带来了一些困难。

通过静态阅读代码搞懂是很难的,我的第二个目标是将插件代码加载进浏览器,并一边调试一边研究。争取通过长期研究,搞明白代码的含义,以及各个选项的含义

结合我的终极目标,加上 Server 端是使用 Flask 编写的,第三个目标是,我打算从后端入手,维护一份自己的 PyWebScrapBook Fork。按照我自己对资讯的理解来管理数据。

以上是中短期会做的事情。再往长期规划,一是基于现代前端技术重写 ScrapBook,二是基于核心技术打造一个新的应用,适合于个人管理海量离线数据

ScrapBook 前端插件代码结构

WebScrapBook 前端代码的目录结构:

  • src
    • _locales_
      • 多语言文案
      • 当想知道一个 UI 对应的代码在哪里时,通过文案寻找,是一种效率比较高的方式
    • capturer:离线页面捕获
      • capturer.js:离线页面捕获核心功能
      • background.js:捕获相关的后台逻辑
      • details.js:捕获详情
    • core
      • background.js:后台逻辑,包括初始化
      • common.js:默认设置、缓存,通用工具逻辑
      • extension.js:插件暴露 API 工具类
      • options.js:设置页逻辑
    • editor
      • 内容编辑器
      • 工具栏的 HTML 是通过编辑 DOM 动态注入页面中的
    • scrapbook
      • 相关页面及实现
    • viewer

原样获取数据的实现

尽管通过【DOM剪刀】功能用户能够以手动方式把网页正文“剪”出来。可对于懒人和不在意排版的人来说,能够一键将网页 100% 保持排版地离线保存下来才是最棒的。

这个功能对应于,网页右键【网页剪贴簿】,【获取页面(原样)】即可。这个过程很快,1s 时间网页就被你永久缓存下来了。

在本节中,研究这个功能的实现原理。缓存过程的日志如下:

Capturing (source) https://www.site.com/articles/YvUZziU ...
下载文件错误 (https://static2.site.com/images/zoomout.cur): 404 
Saving data...
Saved to "20220827130834024"
Updating server index for item "20220827130834024"...
Done.

右键菜单

代码中该文案【获取页面(原样)】位于 src/_locales/zh_CN/messages.json,key 名称是 CapturePageSource。

沿着 key 值继续寻找,找到右键菜单创建代码,位于 src/core/background.js,对应代码如下:

{\n return scrapbook.invokeCapture([{\n tabId: tab.id,\n mode: \"source\",\n }]);\n },\n});\n"}}" data-parsoid="{"dsr":[6670,6982,2,2]}" dir="ltr" typeof="mw:Extension/syntaxhighlight">
browser.contextMenus.create({
  title: scrapbook.lang("CapturePageSource"),
  contexts: ["page"],
  documentUrlPatterns: urlMatch,
  onclick: (info, tab) => {
    return scrapbook.invokeCapture([{
      tabId: tab.id,
      mode: "source",
    }]);
  },
});

可以看到,点击回调对应的是 scrapbook.invokeCapture 方法,该方法位于 src/core/extension.js,提供了一个工具列 scrapbook,通过它作为插件功能的入口。invokeCapture 方法最终会调用 scrapbook.invokeCaptureEx。

scrapbook.invokeCaptureEx

Advanced API to invoke a capture.(发起一次抓取的高级 API)。

该方法的大致逻辑是:

  1. 开一个新窗口(Tab)打开页面
  2. 等待页面加载完成
  3. 页面完成后通过 scrapbook.invokeExtensionScript,在页面 Context 下执行脚本 capturer.getMissionResult

capturePromise

来到 capture.js 的 capturePromise。

添加 document 的 DOMContentLoaded 事件回调,待页面加载完成后,继续执行逻辑。

在 capture.html 中专门有一个 iframe,是用来执行网络请求的。这个页面是弹出来的用于显示抓取日志的那个对话框的页面。

执行 capturer.runTasks(taskInfo) 进行实际抓取工作。

captureGeneral + capturer.captureRemote

再往后的逻辑,如调用栈所示:

capturer.js:1091 console.trace
capturer.captureRemote @ capturer.js:1091
await in capturer.captureRemote(异步)
capturer.captureGeneral @ capturer.js:889
await in capturer.captureGeneral(异步)
capturer.runTasks @ capturer.js:817
(匿名) @ capturer.js:4191

后续执行捕获的方法进入 capturer.captureUrl。在该方法中将进行页面和资源的 fetach 拉取操作。

获取页面功能的实现

按照同样的方式【获取页面】功能,从使用上跟【获取页面(原样)】没有区别,都是一键就能完成离线操作。对应的日志如下:

Capturing1 (document) [1014441721] https://www.tuicool.com/articles/MrI3uqZ ...
Saving data...
Saved to "20220827141824287"
Updating server index for item "20220827141824287"...
Done.

通过对比发现,Capture 的类型不同,前者为 source,这里是 document。从日志还能看出过程是不同的。

注:这里的 Capturing1 使我人工修改的,为了方面代码搜索定位。

还是通过文案下手,【获取页面】对应的 key 是 CapturePage,对应右键菜单代码:

{\n return scrapbook.invokeCapture([{\n tabId: tab.id,\n fullPage: true,\n }]);\n },\n});\n"}}" data-parsoid="{"dsr":[8436,8742,2,2]}" dir="ltr" typeof="mw:Extension/syntaxhighlight">
browser.contextMenus.create({
  title: scrapbook.lang("CapturePage"),
  contexts: ["page"],
  documentUrlPatterns: urlMatch,
  onclick: (info, tab) => {
    return scrapbook.invokeCapture([{
      tabId: tab.id,
      fullPage: true,
    }]);
  },
});

区别在于 CapturePage 的传参不同。 原样获取传的是:

{
  tabId: tab.id,
  mode: "source",
}

这里穿的是 fullPage: true。

capturer.captureTab

【获取页面】最终进入的是 capturer.captureTab,这与【获取页面(原样)】是不同的。

captureGeneral:

  • 【获取页面】模式下是:
    • tabId = 1014441721
    • url = undefined
  • 【获取页面(原样)】模式下是:
    • tabId = 1014441721
    • url = undefined

原来两个模式下都走了 capturer.captureTab,但在 capturer.captureTab 内部逻辑出现了不同。

在 capturer.captureTab 中,对于 "source mode",直接走 capturer.captureRemote。而【获取页面】则走了 capturer.invoke("captureDocumentOrFile" 逻辑。

capturer.captureDocumentOrFile

TBD。

后端研究思路

前文中对前端代码的研究,还是比较浅显的。更多的还是在梳理过程。还没法达到一个自顶向下的更高级、更透彻的分析。我意识到这还需要花费一定时间才能够达成。于是我转而考虑从后端入手。

具体做法是先 fork 一份 PyWebScrapBook,在电脑的 WSL2 中搭建 python3 环境,把 PyWebScrapBook 跑起来。之后通过 Log 来调试接口。因为后端是我改造的重点,我可以先把前端当作一个黑盒,先把后端改造成我希望的样子,之后也从后端为出发点,继续挖掘前端的代码实现。

源码运行 Server

代码 Clone 下来之后,安装以下依赖:

pip install Werkzeug
pip install Flask
pip install commonmark
pip install lxml

切换到项目根目录。 首先创建一个配置文件,通过配置文件,能够对 PyWebScrapBook 进行更加全面细致的设置。生成配置文件的指令是:

python3 -m webscrapbook --root=/home/maxiee/Code/PyWebScrapBookData config -ba

其中,root 是我指定的用于存放数据的目录。在生成的 config.ini 中,我进行了以下配置:

  • host = 0.0.0.0:默认是 localhost,导致无法从 Windows 访问 WSL2 的端口。设置成 0.0.0.0 便能够访问。

加入以下指令启动服务器:

python3 -m webscrapbook --root=/home/maxiee/Code/PyWebScrapBookData serve

数据目录

在 root 目录下有两个子目录用于存放数据:

  1. data:
    1. 存放页面数据
    2. 每个页面在 data 下的子目录,名称是以时间戳标识的唯一 id
      1. 页面目录内是该网页相关资源,比如图片和 HTML、CSS
  2. tree
    1. 存放的是剪贴簿的索引文件,内含 3 个 JavaScript 文件
    2. fulltext.js:包含了页面全文,看起来是用于搜索的
    3. meta.js:页面元信息,以 id 作为 key,value 包含
      1. index:首页路径
      2. title:标题
      3. type:类型
      4. create:创建时间戳
      5. modify:修改时间戳
      6. source:原文路径
      7. icon:图标

tree 中的几个文件都是 JavaScript 文件,应该是用于 PyWebScrapBook 生成的 Web 站点。

PyWebScrapBook 会生成一个 Web 站点,供用户通过 Web 方式进行访问。

WebServer 入口

服务器后端的入口是 webscrapbook/server.py。

真正的 flask 入口是在 webscrapbook/app.py。这是后续改造的重点位置。

app.py 的 Flask App 使用到了 Flask 的 BluePrint 特性,在文件内搜索 .bp 字样,基本能够了解到 API 概貌。

handle_action_token 注解

一次页面保存对应的后端操作

发起一次【获取页面】会导致那些后端访问呢?

"GET /data/20220827171320276?f=json HTTP/1.1" 200 -
"GET /data/20220827171320276/favicon.ico?f=json HTTP/1.1" 200 -
"POST /?a=token&f=json HTTP/1.1" 200 -
maxiee action_save
maxiee action_save

//……

maxiee action_lock
"POST /?a=lock&f=json HTTP/1.1" 200 -
"GET /tree/?a=list&f=json HTTP/1.1" 200 -
"POST /?a=token&f=json HTTP/1.1" 200 -
maxiee action_backup
"POST /tree/meta.js?a=backup&ts=20220827171321285¬e=transaction&f=json HTTP/1.1" 200 -
"POST /?a=token&f=json HTTP/1.1" 200 -
maxiee action_backup
"POST /tree/toc.js?a=backup&ts=20220827171321285¬e=transaction&f=json HTTP/1.1" 200 -
"GET /tree/meta.js HTTP/1.1" 304 -
"GET /tree/toc.js HTTP/1.1" 304 -
"POST /?a=token&f=json HTTP/1.1" 200 -
maxiee action_save
"POST /tree/meta.js?a=save&f=json HTTP/1.1" 200 -
"POST /?a=token&f=json HTTP/1.1" 200 -
maxiee action_save
"POST /tree/toc.js?a=save&f=json HTTP/1.1" 200 -
"POST /?a=token&f=json HTTP/1.1" 200 -
maxiee action_cache
"GET /?f=sse&a=cache&book=&item=20220827171320276&fulltext=1&inclusive_frames=true&no_lock=1&no_backup=1&token=wnRkehUcMvmVa4vhcziT_NnEMK7MNTp0aBkzdptUYrE HTTP/1.1" 200 -
"GET /tree/?a=list&f=json HTTP/1.1" 200 -
"POST /?a=token&f=json HTTP/1.1" 200 -
maxiee action_unbackup
"POST /tree/?a=unbackup&ts=20220827171321285¬e=transaction&f=json HTTP/1.1" 200 -
"POST /?a=token&f=json HTTP/1.1" 200 -
maxiee action_unlock
"POST /?a=unlock&f=json HTTP/1.1" 200 -
"GET /?a=config&f=json&ts=1661620401442 HTTP/1.1" 200 -
"GET /tree/?a=list&f=json HTTP/1.1" 200 -
"GET /tree/meta.js HTTP/1.1" 200 -
"GET /tree/toc.js HTTP/1.1" 200 -

上面的日志经过了我的精简。并夹杂了一些我打的 Log 日志。

还可以看到,WebScrapbook 在编辑 tree 目录的时候,采用了一种锁机制,即在同意时间内,只有一台机器能够更新 tree 目录,来防止出现数据不一致的问题。

数据保存类型

在 WebScrapbook 中有一栏是设置缓存页面保存的类型。WebScrapbook 支持多种数据保存类型,具体设置页的设置项如下:

分为 4 种:

  1. 文件夹
  2. HTZ 封存文件
  3. MAFF 封存文件
  4. 单一 HTML 文件

在 WebScrapbook 的 FAQ 中各种格式的优劣进行了详细对比,我将其概要翻译如下:

每种格式既有有点也有缺点,这些是为什么它们会在业界并存。快速对比表格:

Feature 文件夹 HTZ MAFF 单一 HTML
抓取的灵活性[1]
抓取性能[2] 最高
文件大小[3] 最小
加载速度
方便观看[4] 最低
跨平台兼容性[5] 最高
编辑灵活性[6]
格式转化灵活性[7] 最高
可作为静态站点分发[8] 可以 不行 不行 可以
版本控制系统兼容性[9]

[1]:文件夹模仿了原始网站的结构,是最可靠的,也支持大多数的用法。而其他格式是单文件的,如果总大小太大,就不能使用,也不支持合并捕捉。单一HTML由于使用了数据URL技术,对每个嵌入的文件都有大小限制(甚至更严格地要求老式浏览器可以加载);它也不能保留源页面中某些复杂的循环引用数据结构,不支持深入捕捉。

[2]:由于浏览器需要处理大量的文件条目,"文件夹 "格式的捕捉速度很慢。在基于Chromium的浏览器中,当把捕获的数据保存到剪贴簿文件夹时,勾选 "下载前询问每个文件的保存位置 "会在保存每个文件时引起大量提示;而且有几种文件格式(如.js或.exe)被浏览器屏蔽了,用户必须为每个文件手动选择 "保留 "才能下载它们。不过,这个问题可以通过保存到后端服务器而不是剪贴簿文件夹来绕过。

[3]:由于HTZ和MAFF都使用了压缩技术,所以文件的大小有所减少。由于单个HTML使用的日期URI技术,每个嵌入文件的大小增加到4/3倍。此外,如果源页面重复引用同一大型资源,单个HTML文件的大小会膨胀,因为没有良好的重复数据删除技术。

[4]:单一的HTML文件可以用支持的浏览器轻松查看。文件夹需要用户事先在文件夹中寻找索引页,不太方便。HTZ和MAFF需要一个辅助工具,而这个工具并不适用所有平台,因此更糟糕;MAFF更糟糕,因为它的模式比HTZ更复杂,支持它的辅助工具更难写。但是,如果使用侧边栏和后端服务器,所有的区域同样容易被查看。

[5]:基于ZIP的档案无论如何在最坏的情况下都可以被解压缩并作为普通网页来浏览。单一的HTML文件需要浏览器支持某些技术,如数据URL、iframe的srcdoc属性和CSS变量,在跨浏览器兼容性方面很差,尤其是对于老式浏览器。

[6]:由于需要解压和重新压缩,后端服务器在保存对基于ZIP的档案文件的修改时表现不佳,而保存对单个HTML文件的修改则由于其体积较大而表现不佳。另一方面,对于手动编辑源代码来说,单个HTML文件由于嵌入的数据URL字符串较长,通常更难编辑,文本编辑器在处理它们时可能表现不佳甚至崩溃。

[7]:Folder和HTZ基本上是可以互换的,但后者需要对ZIP操作的额外支持。MAFF有一个更复杂的内部结构,对工具来说更难处理。单一的HTML文件要复杂得多,不能保留源页中的某些信息,而且一般来说,应用程序很难支持格式转换。

[8]:对于HTZ或MAFF,服务器必须运行PyWebScrapBook或其他专门设计的应用程序,或者用户必须在浏览器中安装WebScrapBook或安装其他辅助工具来直接查看捕获的页面。

[9]:大多数版本控制系统(VCS),如Git和Mercurial是基于文本的。对于一个基于ZIP的档案,需要一个扩展来支持差异,而且一般不支持合并。对于单一的HTML文件,VCS应该能够处理HTML代码文本,但在处理大的嵌入式数据URL字符串时可能会有问题。

HTZ 格式

从设置中将保存类型修改为 HTZ,后续再离线缓存页面时,会自动在 data 目录下以 htz 格式保存。

而之前缓存过的页面的保存格式不会变。比如我之前都是以文件夹方式保存的,还是维持现状。

也就是说,PyWebScrapBook 的 data 目录是支持多格式并存的。

将 htz 格式的后缀修改为 zip,可以变成正常压缩包解压,解压后看起来跟【文件夹】保存方式是一致的。

因此,HTZ 格式可以简单理解为,对文件夹格式加了一层压缩过程。

PyWebScrapBook 改造思路

之前我曾长期使用过 ScrapBook 一段时间,但是最后放弃了。主要的原因是 ScrapBook 的资讯检索效率,当离线保存大量的文章之后,难以有效查找。

具体来说,从我个人感受,有几点不足:

一、基于层级目录的资讯管理方式。剪贴簿采用树状嵌套文件夹的方式来管理资讯,需要用户手工维护目录的层级结构。但这个层级结构是没有意义的。因为假设你有一万个离线页面,你该如何划分目录呢?一种方法是每个站点作为一个目录,那也将会有成百上千个文件夹。也许熟悉的站点你一眼便知,但对于更多的不熟悉的小站点,如果你不展开它你根本不知道里面有什么!

二、缺乏 Tag 标签系统。当有海量资讯之后,标签系统是发挥作用的关键了。尤其是关联性的标签,比如,用户先选择一个 Tag,得到该 Tag 下的标签云,用户再选择一个 Tag,通过两个 Tag 缩小了资讯范围,如此往复,知道 Tag 缩小到最小粒度,或者用户心满意足。总结来说,对于用户,维护 Tag 比维护目录层级简单地多,但是在海量资讯检索场景下有帮助得多。

因此,在我的改造思路中,包括以下内容。

添加标签系统

目前 Scrapbook 没有支持标签系统,我准备手动加入这个系统。

具体来说,用户可以手动给资讯添加标签。并且支持对标签进行不断缩小范围式的检索。

注:未来可以扩充,支持根据规则自动给资讯添加标签。

基于域名的自动目录创建

我打算将目录层级采用固定规则:每个域名一个目录。

采用 SQLite 数据

由于支持 Tag 系统,我打算引入 SQLite 数据库,与已有的 Flask 相打通。

开发新的管理前端

目前 Scrapbook 的管理端做在了浏览器插件中,开发技术比较繁琐。

我打算利用现代化的前端技术重新开发一个管理端。

目前打算使用 Flutter for Windows 来开发。有几个原因:

  1. Web 前端开发我已经忘得差不多了……否则的话我会考虑 Electron,因为毕竟都是 Web 技术栈,操纵起 DOM 来更加方便
  2. Flutter 是我比较擅长的,最近两年一直在搞 Flutter
  3. Flutter for Windows 下有 WebView,Flutter 是没有官方 Desktop 下的 WebView 的,目前只有 Windows 和 macOS 有非官方的实现。其中 Windows 的版本,目前看起来足够使用了。
  4. 移动化迁移。这套系统我希望能像移动端迁移,把整个 Scrapbook Server 服务跑在手机上。如果能先把管理端迁过去,Flask 的部分可以通过 Android 嵌 Python 的技术快速落地(已有开源框架),形成 MVP 产品,之后再慢慢将 Flask 的部分使用 Flutter 或移动端原生语言重写。

下期主题

下期 Weekly 的主题是我的静态博客生成器。我自己写了一个 MediaWiki 的静态博客生成器,还有一些待完善的地方。

下周我准备梳理这个生成器的架构实现,进行一些完善,并将该程序的架构设计作为 Weekly 文章发布。

浏览器插件开发基础

如何调试插件

参见《Debugging extensions》。进入插件管理页,选择检查视图,进入一个 Devtools 调试窗口,能够看到 background 脚本的日志。

在弹出的 Devtools 中,进入【源代码】Tab 可以下断点,调试效率大幅提升。

插件基础知识

WebScrapBook 是一个浏览器插件,首先需要补充浏览器插件相关的基础知识。

这一节主要参阅了以下文章:

  1. 《【干货】Chrome插件(扩展)开发全攻略》
  2. 《10分钟入门chrome(谷歌)浏览器插件开发》
  3. 《从零深入Chrome插件开发》

核心概念:

  1. Manifest:清单文件
  2. Background Script:后台脚本
  3. UI 元素
  4. Content Script:内容脚本
  5. Options Page:设置页面

manifest.json

是一个 JSON 配置清单。内容元素:

  • name:插件名称
  • description:插件描述
  • version:插件版本
  • manifest_version:第几代插件格式,2是主流,最新为3
  • browser_action:右上角插件按钮
    • default_popup:弹出页面的 HTML
    • default_icon:图标
  • commands:定义插件命令,可指定快捷键

manifest 相当于 App 的入口,透过它能够快速了解全貌。WebScrapBook 的 manifest:

name 和 description:

"name": "__MSG_ExtensionName__",
"description": "__MSG_ExtensionDescription__",

为什么是 __MSG_ 的常量定义?查阅到《使用 jQuery 进行前端多语言化的方法》,这是一种基于 jQuery 的多语言展示方案。 permissions

"permissions": [
  "contextMenus",           // 右键菜单
  "downloads",              // 下载
  "storage",                // 数据存储
  "tabs",                   // 标签访问能力
  "unlimitedStorage",       // 默认只有 5MB 存储配额,该权限扩展到无上限
  "webNavigation",          // 导航状态
  "webRequest",             // 发请求
  "webRequestBlocking",     // 屏蔽请求
  "http://*/*",             // 插件匹配所有 http
  "https://*/*",            // 插件匹配所有 https
  "file://*"                // 插件匹配所有本地文件
],

参阅《“https:// * / *”和“< all_urls>”在Chrome扩展程序的权限区分》《Chrome 应用和扩展程序权限》《谷歌浏览器插件permissions权限列表大全以及权限字段描述》

background

background 指常驻页面,生命周期与浏览器一样长,用于处理耗时操作。从中可见,关键逻辑都在这里面。

"background": {
  "persistent": true,
  "scripts": [
    "lib/browser-polyfill.js",
    "core/common.js",
    "core/optionsAuto.js",
    "core/extension.js",
    "core/background.js",
    "scrapbook/server.js",
    "capturer/background.js",
    "editor/background.js",
    "viewer/background.js"
  ]
},

browser_action 点击浏览器图标弹出的菜单:

"browser_action": {
  "default_icon": {
    "32": "core/scrapbook_32.png",
    "128": "core/scrapbook_128.png"
  },
  "default_title": "__MSG_ExtensionName__",
  "default_popup": "core/browserAction.html"
},

web_accessible_resources 插件携带的资源文件:

"web_accessible_resources": [
  "resources/*",
  "scrapbook/sitemap.html",
  "viewer/load.html"
],

options_ui 设置页面:

"options_ui": {
  "chrome_style": true,
  "open_in_tab": true,
  "page": "core/options.html"
},

commands

声明插件的命令:

命令 说明
openScrapBook 打开 ScrapBook 管理器
openOptions 打开设置
openViewer 打开浏览器
openSearch 打开搜索
searchCaptures 搜索归档页面
captureTab 归档当前 Tab
captureTabSource
captureTabBookmark 归档当前 Tab 的书签
captureTabAs
batchCaptureLinks
editTab

使用方式,只要出发 Command,浏览器就会向 background 发送一个 command。

对应于 src/core/background.js 的 background:

({\n tabId: tab.id,\n }))\n );\n },\n\n async captureTabSource() {\n return await scrapbook.invokeCapture(\n (await scrapbook.getHighlightedTabs()).map(tab => ({\n tabId: tab.id,\n mode: \"source\",\n }))\n );\n },\n\n async captureTabBookmark() {\n return await scrapbook.invokeCapture(\n (await scrapbook.getHighlightedTabs()).map(tab => ({\n tabId: tab.id,\n mode: \"bookmark\",\n }))\n );\n },\n\n // ……\n },\n};\n\n"}}" data-parsoid="{"dsr":[19896,20635,2,2]}" dir="ltr" typeof="mw:Extension/syntaxhighlight">
const background = {
  commands: {
    // ……

    async captureTab() {
      return await scrapbook.invokeCapture(
        (await scrapbook.getHighlightedTabs()).map(tab => ({
          tabId: tab.id,
        }))
      );
    },

    async captureTabSource() {
      return await scrapbook.invokeCapture(
        (await scrapbook.getHighlightedTabs()).map(tab => ({
          tabId: tab.id,
          mode: "source",
        }))
      );
    },

    async captureTabBookmark() {
      return await scrapbook.invokeCapture(
        (await scrapbook.getHighlightedTabs()).map(tab => ({
          tabId: tab.id,
          mode: "bookmark",
        }))
      );
    },

    // ……
  },
};

可以看到,具体操作都是转到了 scrapbook.invokeCapture 中进行。并且传入的参数有两个,一个是 tabId,一个是类型。

Capturer

让我们直奔主题,Scrapbook 的核心功能是页面离线缓存,对应的实现是 capturer 模块,位于 src/capturer/capturer.js。

capturer.captureGeneral

页面捕捉方法。

根据 capture.saveTo 有多种缓存模式:server 服务器、storage 本地存储、memory 内存缓存。

支持两种页面捕捉方式:

  1. Tab 捕捉:通过 capturer.captureTab
  2. URL 捕捉:通过 capturer.captureRemote

captureTab

捕获一个 Tab 内的网页信息。

const response = await capturer.invoke(
    "captureDocumentOrFile", 
    message, 
    {tabId, frameId});

来到 src/capturer/common.js:capturer.captureDocumentOrFile,调用 capturer.captureDocument。

capturer.captureDocument

该方法是页面离线化的最核心方法,该方法代码长达 2k 多行。

整体流程如下。首先,cloneDocument 开始搞事情:

// create a new document to replicate nodes via import
const newDoc = scrapbook.cloneDocument(doc, {origNodeMap, clonedNodeMap});

有两种页面捕捉方式:全页面(fullPage)和 Selection 局部选择。

let selection = settings.fullPage ? null : doc.getSelection();

简单起见,这里只看 fullPage 的处理分支。

将老文档的所有子节点拷贝到新文档中,并指定跟节点(rootNode):

// not capture selection: clone all nodes
for (const node of doc.childNodes) {
  newDoc.appendChild(cloneNodeMapping(node, true));
}
rootNode = newDoc.documentElement;

注:DOM JavaScript API:Element.removeAttribute():从指定的元素中删除一个属性。

通过以下代码删除页面中的 ScrapBook Toolbar:

// remove webscrapbook toolbar related
rootNode.removeAttribute('data-scrapbook-toolbar-active');
for (const elem of rootNode.querySelectorAll(`[data-scrapbook-elem|="toolbar"]`)) {
  elem.remove();
}

capturer.captureDocument.rewriteNode

capturer.captureDocument 中会对所有节点进行遍历,并对每个节点进行改造。其中,用于对节点进行改造的方法就是 rewriteNode。

capturer.CaptureHelperHandler

capturer.captureDocument 中会调用该方法。会对 options 做一些修改。

invokeExtensionScript

该方法位于 src/core/common.js,作用是向扩展发送消息。

scrapbook.invokeExtensionScript = async function ({id, cmd, args}) {
  isDebug && console.debug(cmd, "send to extension page", args);
  const response = await browser.runtime.sendMessage({id, cmd, args});
  isDebug && console.debug(cmd, "response from extension page", response);
  return response;
};

消息包含三个参数,参阅《runtime.sendMessage()》,分别指的是:ExtensionID、消息、参数。

都有哪些类型的消息呢?

消息 作用 参数 发出方 接收方
background.onCaptureEnd src/capturer/capturer.js:

capturer.captureGeneral

src/core/background.js:

background.onCaptureEnd

background.onServerTreeChange
background.invokeFrameScript
capturer.getMissionResult
background.findBookIdFromUrl
background.locateItem
background.invokeEditorCommand
background.captureCurrentTab
background.createSubPage
background.registerActiveEditorTab
……

从中可以看出,Scrapbook 是基于消息机制通信的。

与 invokeExtensionScript 方法类似的还有一个 invokeBackgroundScript。