不得不说“语雀”是一款优秀的文档管理工具,除了用户体验良好的“语雀自研编辑器”以外,开放的 API 与 WebHook 也给了我眼前一亮且心动的感觉。
把玩一段时间“语雀”后我决定使用语雀的 API 和 WebHook 来做一些有意思的事情。

功能期望

涉及对接形式 具体内容
WebHook 自建站点与语雀平台上的内容同步(关联/新增/更新/删除)
WebHook 用语雀编辑器来编写内容
API 在自建站点展示语雀平台的内容

这篇文章主要侧重于 通过“语雀”提供的 WebHook 接口与我的个人网站实现博文内容同步 的例子。
重在讲思路,略讲实现。

预知问题

经过一段与“语雀”相处的时间,我预知到了一些可能会出现的问题,如下表:

来源 类型 描述 解决思路
语雀平台 资源引用 语雀屏蔽了外链cdn连接
- img 图片元素 添加 referrerpolicy 属性
- html 页面 添加 meta 属性


- OSS 镜像存储
- 服务端转存储
- 直接跳转到语雀平台查看文章内容
| | 语雀平台 | 内容格式 | 语雀自建卡片格式无法以markdown形式渲染 |
- 屏蔽或删除自建卡片
- 使用HTML形式展示
| | 自建站点 | 目录匹配 | 当前个人网站只有单级分类 | 调整业务与模型,新增多级分类与语雀的“目录”概念相匹配 |

实现思路

关于具体的 实现思路 以及 任务拆解 我选择用思维导图的形式来展现: 通过语雀WebHook与自建站点内容同步 - 图1

功能实现

这里选择了一些重要步骤写出来,熟悉了上面描述的实现思路的同学不用看这部分。
查看预览可以移步至我的个人网站:曾小满的盒子

配置语雀文档库

这里我们选用文档库级别的 WebHook 设置,输入名字和链接(此时处理接口还没写)以及触发动作即可。
因为目前只对文档的变更进行同步,因此只选择了 发布、更新与删除文档 的触动动作。
勾选 仅主动推送更新触发 选项可以在发布或更新的时候自行选择是否触发WebHook。
image.png
接口地址后面可以拼接附加参数,按照一套逻辑做校验,不然地址泄漏不安全。

此处,我按照一套简单逻辑为语雀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

调整管理后台

虽然很多人不太喜欢语雀的文档库以目录形式展示的模式,但我却很欣赏这个涉及——我觉得这样思路层级更清晰。

以“互联网”文档库为例,我在语雀的目录层级设置如下:
image.png
之前我的个人网站只有平行的一级分类,现在想要参照语雀平台实现多级(无限极)分类。

实现多级分类管理

image.png

实现博文与语雀文档关联

image.png

调整分类目录接口

从“分类列表”接口返回分类数据的时候,新增了如下两个字段:

  • parent_category 字段以对象的形式返回 父级分类(没有返回null)
  • sub_categories 字段以对象数组的形式返回 下级分类(没有返回空数组)

    1. "data": [
    2. {
    3. "id": 29,
    4. "module": "blog",
    5. "name": "互联网",
    6. "description": null,
    7. "icon": null,
    8. "image": null,
    9. "url": "internet",
    10. "order": 5,
    11. "parent_id": 0,
    12. "is_display": 1,
    13. "created_at": "2020-06-18 23:36:09",
    14. "updated_at": "2020-06-20 00:33:56",
    15. "parent_category": null,
    16. "sub_categories": [
    17. {
    18. "id": 4,
    19. "module": "blog",
    20. "name": "开发",
    21. "description": null,
    22. "icon": null,
    23. "image": null,
    24. "url": "development",
    25. "order": 6,
    26. "is_display": 1,
    27. "parent_id": 29,
    28. "created_at": null,
    29. "updated_at": "2020-06-18 23:36:27",
    30. "sub_categories": [
    31. ]
    32. }
    33. ]
    34. },
    35. {
    36. "id": 4,
    37. "module": "blog",
    38. "name": "开发",
    39. "description": null,
    40. "icon": null,
    41. "image": null,
    42. "url": "development",
    43. "order": 6,
    44. "parent_id": 29,
    45. "is_display": 1,
    46. "created_at": "",
    47. "updated_at": "2020-06-18 23:36:27",
    48. "parent_category": {
    49. "id": 29,
    50. "module": "blog",
    51. "name": "互联网",
    52. "description": null,
    53. "icon": null,
    54. "image": null,
    55. "url": "internet",
    56. "order": 5,
    57. "is_display": 1,
    58. "parent_id": 0,
    59. "created_at": "2020-06-18 23:36:09",
    60. "updated_at": "2020-06-20 00:33:56",
    61. "parent_category": null
    62. },
    63. "sub_categories": [
    64. ]
    65. }
    66. ],

    调整后端服务

    测试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传递的参数存入日志进行查看。
image.png

根据我的测试日志,当前语雀传递的数据如下:
传递的数据很详细完备,同时 body_html 字段是文档的HTML转换格式。
我惊喜地发现语雀对文档的 HTML 格式转换很体贴,甚至将“思维导图”卡片转换为了svg图像,赞一下语雀团队!

  1. {
  2. "id": 8458589,
  3. "slug": "bicoet",
  4. "title": "测试语雀WebHook",
  5. "book_id": 1167972,
  6. "book": {
  7. "id": 1167972,
  8. "type": "Book",
  9. "slug": "blog",
  10. "name": "Blog",
  11. "user_id": 185810,
  12. "description": null,
  13. "creator_id": 185810,
  14. "public": 1,
  15. "items_count": 33,
  16. "likes_count": 0,
  17. "watches_count": 2,
  18. "content_updated_at": "2020-06-20T13:52:10.308Z",
  19. "updated_at": "2020-06-20T13:52:10.000Z",
  20. "created_at": "2020-06-12T09:47:52.000Z",
  21. "user": null,
  22. "_serializer": "v2.book"
  23. },
  24. "user_id": 185810,
  25. "user": {
  26. "id": 185810,
  27. "type": "User",
  28. "login": "shareman",
  29. "name": "ShareMan",
  30. "description": "https://share-man.com/",
  31. "avatar_url": "https://cdn.nlark.com/yuque/0/2019/png/185810/1562297327603-avatar/0ecef664-c371-4439-9a06-a8e92252f46f.png",
  32. "books_count": 12,
  33. "public_books_count": 6,
  34. "followers_count": 1,
  35. "following_count": 6,
  36. "created_at": "2018-10-08T02:01:06.000Z",
  37. "updated_at": "2020-06-19T16:29:52.000Z",
  38. "_serializer": "v2.user"
  39. },
  40. "format": "lake",
  41. "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)",
  42. "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)",
  43. "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>",
  44. "public": 1,
  45. "status": 1,
  46. "view_status": 0,
  47. "read_status": 1,
  48. "likes_count": 0,
  49. "comments_count": 0,
  50. "content_updated_at": "2020-06-20T13:52:10.000Z",
  51. "deleted_at": null,
  52. "created_at": "2020-06-20T11:14:58.000Z",
  53. "updated_at": "2020-06-20T13:52:10.000Z",
  54. "published_at": "2020-06-20T13:52:10.000Z",
  55. "first_published_at": "2020-06-20T11:21:06.000Z",
  56. "word_count": 8,
  57. "_serializer": "webhook.doc_detail",
  58. "path": "shareman/blog/bicoet",
  59. "publish": false,
  60. "action_type": "update",
  61. "webhook_subject_type": "update",
  62. "actor_id": 185810
  63. }

编写WebHook处理接口

这一步骤要根据自己的业务逻辑来进行函数和接口的编写工作。
我的个人网站后端采用的PHP作为开发语言、Laravel作为框架,以下是示例代码(不断完善中):

  1. <?php
  2. namespace App\Http\Controllers\WebHook;
  3. use App\Blog;
  4. use App\Category;
  5. use App\LarkDoc;
  6. use Carbon\Carbon;
  7. use Illuminate\Routing\Controller;
  8. use Illuminate\Http\Request;
  9. use Illuminate\Support\Facades\Log;
  10. class LarkController extends Controller
  11. {
  12. static $TOKEN = 'YourTokenHere';
  13. public function syncBlog(Request $request)
  14. {
  15. $token = $request->input('token');
  16. if (!$token || $token !== self::$TOKEN) {
  17. return api_failed('token错误!');
  18. }
  19. $originData = (object)$request->input('data');
  20. if ($originData->_serializer !== 'webhook.doc_detail') {
  21. return api_failed('webhook内容格式不匹配!');
  22. }
  23. if ($originData->format === 'lake') {
  24. $larkDocItem = [];
  25. try {
  26. $larkDocItem = [
  27. 'slug' => $originData->slug,
  28. 'book_slug' => $originData->book->slug ?? '',
  29. 'book_name' => $originData->book->name ?? '',
  30. 'name' => $originData->title,
  31. 'path' => $originData->path,
  32. 'is_display' => $originData->public === 1 ? 1 : 0,
  33. 'is_deleted' => $originData->deleted_at ? 1 : 0,
  34. 'content' => $originData->body,
  35. 'content_draft' => $originData->body_draft,
  36. 'content_html' => $originData->body_html,
  37. 'published_at' => $originData->published_at ? Carbon::parse($originData->published_at)->toDateTimeString() : null,
  38. 'deleted_at' => $originData->deleted_at ? Carbon::parse($originData->deleted_at)->toDateTimeString() : null,
  39. 'created_at' => $originData->created_at ? Carbon::parse($originData->created_at)->toDateTimeString() : null,
  40. 'updated_at' => $originData->content_updated_at ? Carbon::parse($originData->content_updated_at)->toDateTimeString() : null,
  41. ];
  42. } catch (\Exception $e) {
  43. Log::channel('webhook')->error("[WebHook->larkSyncBlog] larkDocItem construct failed.", $request->input('data'));
  44. }
  45. Log::channel('webhook')->info("[WebHook->larkSyncBlog] [ $originData->action_type ] 《 $originData->title ( $originData->slug )》 content synced.", $request->input('data'));
  46. if ($originData->action_type === 'delete') {
  47. $this->deleteLarkDoc($larkDocItem);
  48. }
  49. if ($originData->action_type === 'publish' || $originData->action_type === 'update') {
  50. $this->updateOrInsetLarkDoc($larkDocItem);
  51. $this->updateOrInsetBlog($larkDocItem);
  52. }
  53. }
  54. return api_success($request->all());
  55. }
  56. public function updateOrInsetLarkDoc($larkDocItem)
  57. {
  58. LarkDoc::query()->updateOrCreate(['slug' => $larkDocItem['slug']], $larkDocItem);
  59. }
  60. public function deleteLarkDoc($larkDocItem)
  61. {
  62. LarkDoc::query()->where('slug', $larkDocItem['slug'])->delete();
  63. Blog::query()->where('lark_slug', $larkDocItem['slug'])->delete();
  64. }
  65. public function updateOrInsetBlog($larkDocItem)
  66. {
  67. $matchedCategory = Category::query()->where('url', $larkDocItem['book_slug'])->orWhere('name', $larkDocItem['book_name'])->first();
  68. Blog::query()->updateOrCreate(
  69. [
  70. 'lark_slug' => $larkDocItem['slug']
  71. ],
  72. [
  73. 'name' => $larkDocItem['name'],
  74. 'lark_slug' => $larkDocItem['slug'],
  75. 'is_display' => $larkDocItem['is_display'],
  76. 'status' => '同步自语雀文档库',
  77. 'datetime' => $larkDocItem['updated_at'],
  78. 'category_id' => $matchedCategory ? $matchedCategory->id : null,
  79. ]
  80. );
  81. }
  82. }

接下来进行流程测试:
通过查看个人网站的日志,目前在语雀更新文档时已经成功地触发了WebHook并存储数据到表中。
image.png
image.png
同时,也能够实现在语雀文档更新的时候更新关联博文的数据。

截至2020-06-21夜晚,语雀文档删除或被删除操作未触发WebHook。 已向语雀团队提交反馈。[链接]

调整博文列表页面

展现逻辑

列表页(博文模块首页)由以前的单一 列表展示模式 加上了 新的 目录展示模式。
由于语雀目前WebHook接口中并没有传递关于目录层级分类的数据,我就在自己网站上维护了一套目录逻辑也实现了解耦。
image.png

新增组件

这里新增了一个目录组件,我取名叫做 CatalogTree ,如果需要参考我的逻辑的话可以到下面的Github地址查看:
https://github.com/ShareManT/ShareManBox-Nuxt/blob/master/components/CatalogTree.vue

调整博文详情页面

内容展现逻辑

如业务逻辑中所构思,博文如果与语雀文档相关联的情况下,默认展示语雀文档的内容。
语雀文档内容直接取Html内容即可避免语雀专用md格式(HTML与MarkDown混用)无法渲染的问题。
image.png

语雀图片外链问题

同时,对于语雀图片无法渲染的问题可以在页面 head 中添加

  1. <meta name="referrer" content="no-referrer" />

也可以通过给 img 标签添加 referrerpolicy 属性来解决。

  1. function finalHtml () {
  2. return this.content.replace(/<img(?:.|\s)*?/gi, '<img referrerpolicy="no-referrer"')
  3. }

我选择了后者,因为前者对于其他服务也会有一定的影响。