不得不说“语雀”是一款优秀的文档管理工具,除了用户体验良好的“语雀自研编辑器”以外,开放的 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![](https://cdn.nlark.com/yuque/0/2020/svg/185810/1592661129487-c84cecc3-e7f1-4017-8f85-20d5f0d5af97.svg)",
"body_draft": "<a name=\"EK4EH\"></a>\n# 标题一\n标题一正文<br />\n\n![](https://cdn.nlark.com/yuque/0/2020/svg/185810/1592661129487-c84cecc3-e7f1-4017-8f85-20d5f0d5af97.svg)",
"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作为框架,以下是示例代码(不断完善中):
<?php
namespace 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"')
}
我选择了后者,因为前者对于其他服务也会有一定的影响。