前言
起因是想在博客上加一个类似 github contribution heatmap 的小组件,用来展示博客更新频率,但社区没有这种高度定制(集成 github api)的组件,大体符合需求的开源项目,只有githubchart-api,但它只能生成 heatmap 的图片 url,然后只能以 img 元素的形式插入到博客 DOM 中,缺乏交互能力,而且也不够灵活。
githubchart-api 获取 contributions 依赖于githubstats——一个 ruby 实现的爬取 github contribution 数据的库,核心就是访问https://github.com/users/{user_name}/contributions
总之多方调研发现并没有可以直接满足需求的类库,所以尝试自己写一个开源类库,核心功能就是爬取 github contributions,至于 heatmap 已经有很多成熟实现了,可以直接拿来用。
思路
构思了以下几套方案:
- cheerio.js:访问上述的 URL,拿到 html,通过 cheerio 解析并获取相关的数据;
- nodejs + ruby:通过 node.js child_process 模块执行 ruby 指令,直接复用 gem module
githubstats
; - octokit.js:通过 github 开源的 octokit.js 直接调用 Github RESTful Api;
获取数据后可以:
- 组件运行时调用,直接获取动态数据;
- 在后台生成静态 json 文件,组件获取静态数据;
可行性分析
对于稳定性来说,方案 1、2,都存在一个问题,那就是以爬虫的形式去获取数据,一旦源文档改变结构,爬虫程序就要跟着改,不够稳定,而方案 3 直接从 github server 获取数据显然是最稳定的。
对于开发成本来说,方案 1 需要实现一整套代码,方案 2 存在 ruby 的学习成本,方案 3 成本最少。
对于入参来说,方案 1、2 都只需要 username,方案 3 却需要 github personal token,显然是不够方便的。
调研过程中发现的其他相关细节:
- github.com 设置了严格的 CSP,只要是现代浏览器,从一个跨源的站点对 github.com 的资源发起请求,这个请求就会被浏览器拦截,这就意味着爬虫方案只能实现在 Nodejs 或服务器等非现代浏览器环境中。
- 国内使用,还需要实现一个 proxy,否则不能保证每次都能连接到 github.com
- Github Api 没有提供获取 contributions 的接口,contributions 派生自 commits、opened issues、created repository 等,同理 octokit 就得实现一套同样的 contributions 计算逻辑。
- Github Api 有速率限制,则 octokit 在浏览器端的实时获取数据就有问题了,而且实时获取的话,token 也存在安全问题。
综合来看,octokit 是最优的,但由于必须传入 token,那就不能写进组件里在运行时获取数据了,这是因为别人打开 devtools 直接就能从 authentication 首部拿到我的 token,虽然可以设置该 token 的权限为 readonly,但由于 Github Api 速率限制,拿到我的 token 就意味着可以调用 Github Api,进而直接耗光调用次数。
至此可以确定,这三套方案都只能实现在 Node.js 或服务器上,获取数据后只能先持久化为静态数据。
考虑到使用上,octokit 需要 token,其他方案不需要,显然对于用户来说更容易接受其他方案,既然要开源,就要优先考虑用户习惯,所以直接排除方案 3.
ruby 方案看似简单,实则实现过程有很多坑,首先就是githubstats
库没有提供设置 proxy 的接口,本地调试不易,其次 ruby 与我学过的语言语法差别很大,学习成本并不像其宣称的那么低,排除方案 2.
最终敲定方案 1:访问上述的 URL,拿到 html,通过 cheerio 解析并获取相关的数据,持久化为静态数据;
当前场景特化
数据的更新比较简单,contributions 时效性要求不高,可以起一个任务每天跑一次就行,至于数据怎么传递到组件是有必要考虑的:
- 高成本:数据持久化到数据库中,起一个服务器,定时 job 更新数据,实现一个获取数据的 api;
- 低成本:数据持久化为 json 文件,利用 Github Action 定时执行脚本以更新数据,json 可以推送到任意地方;
由于不想买服务器,我只好考虑低成本方案 QAQ,当前场景下,我已经实现博客 push 后 github action 自动 build 并推送到 github page,只需要在 build 时把获取数据集成进来就行,然后生成的 json 文件同样推送到 github page,组件本身也在 github page,这样也不存在跨域问题。
实施
开发环境脚手架基本配置:
- webpack
- ts
需要使用的开源类库:
- cheerio(解析 html)
- arg(解析命令行参数,主要是为了构建一个简单的 cli 程序,便于集成到 hexo 里)
- node-fetch(替换 nodejs 原生 fetch,搭配 http-proxy-agent 实现请求代理,便于本地调试)
Crawler 核心逻辑
contributions 的 html 结构如下,暂称之为数据项(省略祖先节点 table tbody tr):
1 | <td |
还存在干扰项:
1 | <td class="ContributionCalendar-label" style="position: relative"> |
cheerio 的 load
方法加载并解析 html,然后选择器定为table tbody tr td
,其实 cheerio 选择器就是通过 css selector 实现,这里直接看作 css 选择器就可以了。
由于这里会选中干扰项——标明周几的单元格,所以需要根据二者差异过滤掉干扰项,这里选用数据属性data-ix
,通过 cheerio element 的data
方法获取数据属性,注意省略data-
前缀,这点与 Element 原生属性dataset
行为一致。
接下来就是从 span 元素中提取第一个数字作为 contributions 值,提取第一个数字只需要一个简单的正则表达式/^\d*/
,再从 td 的data-date
提取日期。
最后注意由于文档流中表格 cell 的顺序并非按时间顺序排列,还需要做一次排序。
1 | function extractContributions(html: string): ContributionItem[] { |
fetch 替换及注入 Proxy
由于以下两点
- 发布 npm module 时,不要把无关依赖打到一个包里
- 不是所有人都需要请求代理才能正常获取 github.com 数据
fetch 不能直接使用 node-fetch,这里我的实现不算优雅,加一个 Monkey patch,调用fetchHtml
之前通过injectFetch
用node-fetch
替换原生fetch
.
1 | let fetchFunc: FetchFunc = fetch; |
node-fetch
中设置 proxy,这里是一种代理模式的实现。
1 | import { HttpsProxyAgent } from "https-proxy-agent"; |
如此一来,可以把node-fetch, https-proxy-agent
排除到 dependencies 之外,只在本地调试时执行(即 devDependencies),打包时也可以完全排除掉(按 webpack 的概念讲,连 externals 都不算)。
CLI 实现
目标是写一个简单的 CLI 应用,调用 crawler 并把数据持久化为 json 文件。
首行注释#!/usr/bin/env node
声明执行环境为 nodejs.
然后,参数指令解析比较麻烦,没必要造轮子,小脚本也没必要使用commander
,通过轻量级的arg
实现即可。定义了三个指令--username --years --path
,分别是 github 用户名、时间范围及 json 文件路径。
为了简化--years
指令的参数格式,约定以逗号,
分割每一年,这样可以把-y 2021 -y 2022 -y 2023
简化为-y 2021,2022,2023
.
1 |
|
在package.json
中定义 js 脚本文件与 CLI 指令的映射。
1 | "bin": { |
OK,全局安装的情况下就可以通过crawl -u "user-name"
来执行该脚本了,本地安装则需要配置 npm script,这是 nodejs 查找模块、命令的规则限制的。
1 | "scripts": { |
具体来说,本地安装的模块,package.json 的 bin 字段会指示 npm 在当前项目的 node_modules/.bin 中添加一条命令,但直接调用crawl
命令却是从全局的 node_modules 中查找的,显然是找不到的,而编写进 npm script 后,执行npm run crawl
,此时查找范围就是当前项目的 node_modules,所以可以找到。
Webpack config
将项目发布为 npm module 之前需要做一些额外工作:
- 依赖分析;
- 排除外部依赖;
- 确定打包后资源的模块类型;
- 入口分离,chunk 分割;
- 打包成生产版本;
为了实现目标 1,引入了webpack-bundle-analyzer
插件;
为了实现目标 2,做以下配置,把 dependencies 统统排除,并且指定这些外部依赖的模块类型externalsType
为commonjs
,这个会影响打包后资源中导入这些模块的方式,由于运行在 nodejs,直接定义为commonjs
即可;externalsPresets.node
用于标明 nodejs built-in module 为外部依赖,运行时导入即可,无需打包。
1 | externals: ["arg", "cheerio", "signale"], |
为了实现目标 3,做以下配置,注意 index 是一切核心逻辑的入口,scripts 是 CLI 所需逻辑的入口,是在核心逻辑的基础上构建的,因此指定 dependOn 为 index,避免两个入口打包后的资源中各包含一份重复的核心逻辑;另外对于以发布 lib 为目标的项目,必须指定 library 的 type,否则默认为"var"
,也就是导出模块会被视为变量,显然在 nodejs 中就没办法通过 require 导入了,因为按照 commonjs 规范定义,导出模块的标准语句是exports
。
顺带说一下初始的commonjs
规范只定义了 exports,而 nodejs 及很多其他 commonjs 的实现都引入了 module.exports,而只把 exports 实现为 module.exports 的引用,这就是为什么 webpack 加了一种commonjs2
的构建类型,因为这二者在 build 后的代码是截然不同的,具体可以看这个例子
1 | entry: { |
剩下的没什么可说的,都是基础用法,最后看一下github-contribution
的项目依赖图,相对来说是比较小巧的。