不得不说“语雀”是一款优秀的文档管理工具,除了用户体验良好的“语雀自研编辑器”以外,开放的 API 与 WebHook 也给了我眼前一亮且心动的感觉。
把玩一段时间“语雀”后我决定使用语雀的 API 和 WebHook 来做一些有意思的事情。
功能期望
| 涉及对接形式 | 具体内容 |
|---|---|
| WebHook | 自建站点与语雀平台上的内容同步(关联/新增/更新/删除) |
| WebHook | 用语雀编辑器来编写内容 |
| API | 在自建站点展示语雀平台的内容 |
这篇文章主要侧重于 通过“语雀”提供的 WebHook 接口与我的个人网站实现博文内容同步 的例子。
重在讲思路,略讲实现。
预知问题
经过一段与“语雀”相处的时间,我预知到了一些可能会出现的问题,如下表:
| 来源 | 类型 | 描述 | 解决思路 |
|---|---|---|---|
| 语雀平台 | 资源引用 | 语雀屏蔽了外链cdn连接 | - img 图片元素 添加 referrerpolicy 属性 - html 页面 添加 meta 属性 |
- OSS 镜像存储
- 服务端转存储
- 直接跳转到语雀平台查看文章内容
|
| 语雀平台 | 内容格式 | 语雀自建卡片格式无法以markdown形式渲染 |
- 屏蔽或删除自建卡片
- 使用HTML形式展示
|
| 自建站点 | 目录匹配 | 当前个人网站只有单级分类 | 调整业务与模型,新增多级分类与语雀的“目录”概念相匹配 |
实现思路
关于具体的 实现思路 以及 任务拆解 我选择用思维导图的形式来展现:
功能实现
这里选择了一些重要步骤写出来,熟悉了上面描述的实现思路的同学不用看这部分。
查看预览可以移步至我的个人网站:曾小满的盒子
配置语雀文档库
这里我们选用文档库级别的 WebHook 设置,输入名字和链接(此时处理接口还没写)以及触发动作即可。
因为目前只对文档的变更进行同步,因此只选择了 发布、更新与删除文档 的触动动作。
勾选 仅主动推送更新触发 选项可以在发布或更新的时候自行选择是否触发WebHook。
接口地址后面可以拼接附加参数,按照一套逻辑做校验,不然地址泄漏不安全。
此处,我按照一套简单逻辑为语雀Webhook签发了一个Token
调整数据库
为了能够将语雀文档和个人网站上的博文关联起来,执行了以下数据库操作:
| 数据表 | 操作 | 字段名 | 作用 |
|---|---|---|---|
| i_blogs | 新增字段 | lark_slug | 匹配语雀中对应的文档 |
| i_lark_docs | 新建数据表 | n/a | n/a |
| i_lark_docs | 新增字段 | id |
|
| i_lark_docs | 新增字段 | slug | |
| i_lark_docs | 新增字段 | name | |
| i_lark_docs | 新增字段 | path | 存储语雀文档路径 |
| i_lark_docs | 新增字段 | content | 存储发布状态的文档 Markdown格式 内容 |
| i_lark_docs | 新增字段 | content_draft | 存储待发布状态的文档 Markdown格式 内容 |
| i_lark_docs | 新增字段 | content_html | 存储发布状态的文档 HTML格式 内容 |
| i_lark_docs | 新增字段 | is_deleted | |
| i_lark_docs | 新增字段 | is_display | |
| i_lark_docs | 新增字段 | created_at | |
| i_lark_docs | 新增字段 | deleted_at | |
| i_lark_docs | 新增字段 | published_at | |
| i_lark_docs | 新增字段 | updated_at |
调整管理后台
虽然很多人不太喜欢语雀的文档库以目录形式展示的模式,但我却很欣赏这个涉及——我觉得这样思路层级更清晰。
以“互联网”文档库为例,我在语雀的目录层级设置如下:
之前我的个人网站只有平行的一级分类,现在想要参照语雀平台实现多级(无限极)分类。
实现多级分类管理
实现博文与语雀文档关联
调整分类目录接口
从“分类列表”接口返回分类数据的时候,新增了如下两个字段:
- 用
parent_category字段以对象的形式返回 父级分类(没有返回null) 用
sub_categories字段以对象数组的形式返回 下级分类(没有返回空数组)"data": [{"id": 29,"module": "blog","name": "互联网","description": null,"icon": null,"image": null,"url": "internet","order": 5,"parent_id": 0,"is_display": 1,"created_at": "2020-06-18 23:36:09","updated_at": "2020-06-20 00:33:56","parent_category": null,"sub_categories": [{"id": 4,"module": "blog","name": "开发","description": null,"icon": null,"image": null,"url": "development","order": 6,"is_display": 1,"parent_id": 29,"created_at": null,"updated_at": "2020-06-18 23:36:27","sub_categories": []}]},{"id": 4,"module": "blog","name": "开发","description": null,"icon": null,"image": null,"url": "development","order": 6,"parent_id": 29,"is_display": 1,"created_at": "","updated_at": "2020-06-18 23:36:27","parent_category": {"id": 29,"module": "blog","name": "互联网","description": null,"icon": null,"image": null,"url": "internet","order": 5,"is_display": 1,"parent_id": 0,"created_at": "2020-06-18 23:36:09","updated_at": "2020-06-20 00:33:56","parent_category": null},"sub_categories": []}],
调整后端服务
测试WebHook请求接口
接下来要做的事情就是根据 语雀官方WebHook处理相关文档 ,了解每一次推送包含哪些数据,分析哪些数据可以为我们所用。
按照语雀官方文档给出的数据序列,有如下可用字段:id - 文档编号
- slug - 文档路径
- title - 标题
- book_id - 仓库编号,就是 repo_id
- book - 仓库信息 [BookSerializer](https://www.yuque.com/yuque/developer/BookSerializer),就是 repo 信息
- user_id - 用户/团队编号
- user - 用户/团队信息 [UserSerializer](https://www.yuque.com/yuque/developer/UserSerializer)
- format - 描述了正文的格式
[lake , markdown] - body - 正文 Markdown 源代码
- body_draft - 草稿 Markdown 源代码
- body_html - 转换过后的正文 HTML
- body_lake - 语雀 lake 格式的文档内容
- creator_id - 文档创建人 User Id
- public - 公开级别 [0 - 私密, 1 - 公开]
- status - 状态 [0 - 草稿, 1 - 发布]
- likes_count - 赞数量
- comments_count - 评论数量
- content_updated_at - 文档内容更新时间
- deleted_at - 删除时间,未删除为
null - created_at - 创建时间
- updated_at - 更新时间
我建了一篇如下图简洁的新文档,在接口中将语雀WebHook传递的参数存入日志进行查看。
根据我的测试日志,当前语雀传递的数据如下:
传递的数据很详细完备,同时 body_html 字段是文档的HTML转换格式。
我惊喜地发现语雀对文档的 HTML 格式转换很体贴,甚至将“思维导图”卡片转换为了svg图像,赞一下语雀团队!
{"id": 8458589,"slug": "bicoet","title": "测试语雀WebHook","book_id": 1167972,"book": {"id": 1167972,"type": "Book","slug": "blog","name": "Blog","user_id": 185810,"description": null,"creator_id": 185810,"public": 1,"items_count": 33,"likes_count": 0,"watches_count": 2,"content_updated_at": "2020-06-20T13:52:10.308Z","updated_at": "2020-06-20T13:52:10.000Z","created_at": "2020-06-12T09:47:52.000Z","user": null,"_serializer": "v2.book"},"user_id": 185810,"user": {"id": 185810,"type": "User","login": "shareman","name": "ShareMan","description": "https://share-man.com/","avatar_url": "https://cdn.nlark.com/yuque/0/2019/png/185810/1562297327603-avatar/0ecef664-c371-4439-9a06-a8e92252f46f.png","books_count": 12,"public_books_count": 6,"followers_count": 1,"following_count": 6,"created_at": "2018-10-08T02:01:06.000Z","updated_at": "2020-06-19T16:29:52.000Z","_serializer": "v2.user"},"format": "lake","body": "<a name=\"EK4EH\"></a>\n# 标题一\n标题一正文<br />\n\n","body_draft": "<a name=\"EK4EH\"></a>\n# 标题一\n标题一正文<br />\n\n","body_html": "<!doctype html><div class=\"lake-content-editor-core lake-engine lake-typography-classic\" data-lake-element=\"root\"><h1 data-lake-id=\"640786fff209a497d07e23482943b0de\" id=\"EK4EH\" style=\"padding: 7px 0px; margin: 0px; font-weight: 700; font-size: 28px; line-height: 36px;\">标题一</h1><p data-lake-id=\"a736993a6729d15357354a28dd3b2b52\" style=\"font-size: 14px; color: rgb(38, 38, 38); line-height: 1.74; letter-spacing: 0.05em; outline-style: none; overflow-wrap: break-word; margin: 0px;\">标题一正文</p><p data-lake-id=\"addbd2b1e740cca774a63ee90037961a\" style=\"font-size: 14px; color: rgb(38, 38, 38); line-height: 1.74; letter-spacing: 0.05em; outline-style: none; overflow-wrap: break-word; margin: 0px;\"><br></p><div data-card-type=\"block\" data-lake-card=\"mindmap\" id=\"tuTUQ\" class=\"lake-card-margin\" data-cell_count=\"4\"><img src=\"https://cdn.nlark.com/yuque/0/2020/svg/185810/1592661129487-c84cecc3-e7f1-4017-8f85-20d5f0d5af97.svg\"></div><p data-lake-id=\"327e808c0143bb24fbe6d20185c839a6\" style=\"font-size: 14px; color: rgb(38, 38, 38); line-height: 1.74; letter-spacing: 0.05em; outline-style: none; overflow-wrap: break-word; margin: 0px;\"><br></p></div>","public": 1,"status": 1,"view_status": 0,"read_status": 1,"likes_count": 0,"comments_count": 0,"content_updated_at": "2020-06-20T13:52:10.000Z","deleted_at": null,"created_at": "2020-06-20T11:14:58.000Z","updated_at": "2020-06-20T13:52:10.000Z","published_at": "2020-06-20T13:52:10.000Z","first_published_at": "2020-06-20T11:21:06.000Z","word_count": 8,"_serializer": "webhook.doc_detail","path": "shareman/blog/bicoet","publish": false,"action_type": "update","webhook_subject_type": "update","actor_id": 185810}
编写WebHook处理接口
这一步骤要根据自己的业务逻辑来进行函数和接口的编写工作。
我的个人网站后端采用的PHP作为开发语言、Laravel作为框架,以下是示例代码(不断完善中):
<?phpnamespace App\Http\Controllers\WebHook;use App\Blog;use App\Category;use App\LarkDoc;use Carbon\Carbon;use Illuminate\Routing\Controller;use Illuminate\Http\Request;use Illuminate\Support\Facades\Log;class LarkController extends Controller{static $TOKEN = 'YourTokenHere';public function syncBlog(Request $request){$token = $request->input('token');if (!$token || $token !== self::$TOKEN) {return api_failed('token错误!');}$originData = (object)$request->input('data');if ($originData->_serializer !== 'webhook.doc_detail') {return api_failed('webhook内容格式不匹配!');}if ($originData->format === 'lake') {$larkDocItem = [];try {$larkDocItem = ['slug' => $originData->slug,'book_slug' => $originData->book->slug ?? '','book_name' => $originData->book->name ?? '','name' => $originData->title,'path' => $originData->path,'is_display' => $originData->public === 1 ? 1 : 0,'is_deleted' => $originData->deleted_at ? 1 : 0,'content' => $originData->body,'content_draft' => $originData->body_draft,'content_html' => $originData->body_html,'published_at' => $originData->published_at ? Carbon::parse($originData->published_at)->toDateTimeString() : null,'deleted_at' => $originData->deleted_at ? Carbon::parse($originData->deleted_at)->toDateTimeString() : null,'created_at' => $originData->created_at ? Carbon::parse($originData->created_at)->toDateTimeString() : null,'updated_at' => $originData->content_updated_at ? Carbon::parse($originData->content_updated_at)->toDateTimeString() : null,];} catch (\Exception $e) {Log::channel('webhook')->error("[WebHook->larkSyncBlog] larkDocItem construct failed.", $request->input('data'));}Log::channel('webhook')->info("[WebHook->larkSyncBlog] [ $originData->action_type ] 《 $originData->title ( $originData->slug )》 content synced.", $request->input('data'));if ($originData->action_type === 'delete') {$this->deleteLarkDoc($larkDocItem);}if ($originData->action_type === 'publish' || $originData->action_type === 'update') {$this->updateOrInsetLarkDoc($larkDocItem);$this->updateOrInsetBlog($larkDocItem);}}return api_success($request->all());}public function updateOrInsetLarkDoc($larkDocItem){LarkDoc::query()->updateOrCreate(['slug' => $larkDocItem['slug']], $larkDocItem);}public function deleteLarkDoc($larkDocItem){LarkDoc::query()->where('slug', $larkDocItem['slug'])->delete();Blog::query()->where('lark_slug', $larkDocItem['slug'])->delete();}public function updateOrInsetBlog($larkDocItem){$matchedCategory = Category::query()->where('url', $larkDocItem['book_slug'])->orWhere('name', $larkDocItem['book_name'])->first();Blog::query()->updateOrCreate(['lark_slug' => $larkDocItem['slug']],['name' => $larkDocItem['name'],'lark_slug' => $larkDocItem['slug'],'is_display' => $larkDocItem['is_display'],'status' => '同步自语雀文档库','datetime' => $larkDocItem['updated_at'],'category_id' => $matchedCategory ? $matchedCategory->id : null,]);}}
接下来进行流程测试:
通过查看个人网站的日志,目前在语雀更新文档时已经成功地触发了WebHook并存储数据到表中。

同时,也能够实现在语雀文档更新的时候更新关联博文的数据。
截至2020-06-21夜晚,语雀文档删除或被删除操作未触发WebHook。 已向语雀团队提交反馈。[链接]
调整博文列表页面
展现逻辑
列表页(博文模块首页)由以前的单一 列表展示模式 加上了 新的 目录展示模式。
由于语雀目前WebHook接口中并没有传递关于目录层级分类的数据,我就在自己网站上维护了一套目录逻辑也实现了解耦。
新增组件
这里新增了一个目录组件,我取名叫做 CatalogTree ,如果需要参考我的逻辑的话可以到下面的Github地址查看:
https://github.com/ShareManT/ShareManBox-Nuxt/blob/master/components/CatalogTree.vue
调整博文详情页面
内容展现逻辑
如业务逻辑中所构思,博文如果与语雀文档相关联的情况下,默认展示语雀文档的内容。
语雀文档内容直接取Html内容即可避免语雀专用md格式(HTML与MarkDown混用)无法渲染的问题。
语雀图片外链问题
同时,对于语雀图片无法渲染的问题可以在页面 head 中添加
<meta name="referrer" content="no-referrer" />
也可以通过给 img 标签添加 referrerpolicy 属性来解决。
function finalHtml () {return this.content.replace(/<img(?:.|\s)*?/gi, '<img referrerpolicy="no-referrer"')}
我选择了后者,因为前者对于其他服务也会有一定的影响。
