10分钟制作Alfred文档 (语雀) 搜索插件

1,975 阅读3分钟

[作者]:楚北

一、背景

当我们收藏或者浏览过的语雀文章非常多时,我们可能无法从这些自己曾经关注过的文章里面「迅速搜索」到自己想要的文章。下面将开始介绍下针对语雀收藏、浏览过的文档进行聚焦搜索定制一个Alfred插件的详细教程。

二、环境要求

  1. Mac OS(Alfred 并不支持 Windows系统)
  2. Alfred 4 + Powerpack(呼吁大家支持正版,付费购买软件的Powerpack使用许可,RMB约¥260)
  3. Node.js >= 8
  4. Visual Studio Code(建议安装)

三、自定义插件步骤

  1. 打开 Alfred Preferences -> Workflows -> 点击 "+" -> 选择 "Blank Workflow"

image.png

image.png

  1. 参考下图填写好新增插件(下文以“yuque插件”简称)的基本信息

image2.png

  1. 此时会在Alfred的插件目录下生成一个类似 user.workflow.53C71033-D345-4BF4-8F61-4C328FC5DE22 的目录,后缀是随机生成的(下文以该路径举例)。

该目录的完整路径将会是:

/Users/${YOUR_USER_NAME}/Library/Application Support/Alfred/Alfred.alfredpreferences/workflows/user.workflow.53C71033-D345-4BF4-8F61-4C328FC5DE22

image.png

  1. 全局安装yeoman
sudo npm install -g yo
  1. 全局安装generator-alfred
sudo npm install -g generator-alfred
  1. 进到该“yuque插件”工程根目录,建议采用右键点击“yuque插件” -> Open in Terminal 方便快速

image.png

  1. 运行如下命令初始化符合Alfred Node.js规范的插件工程
yo alfred

按步骤填写一下必要的参数,注意确保如下项目填写你期望唤起该yuque插件的快捷指令(比如填“yq”)

20220526134136.jpg

  1. 继续在该目录下运行如下命令打开Visual Studio Code实现插件编码逻辑
code .
  1. 安装node-fetchfuse.js依赖
npm install node-fetch fuse.js --save
  1. 编辑index.js
"use strict";
import alfy from "alfy";
import fetch from "node-fetch";
import Fuse from "fuse.js";

(async () => {
  const DOMAIN = process.env.DOMAIN || "www.yuque.com";
  const CSRF_TOKEN = process.env.CSRF_TOKEN;
  const COOKIE = process.env.COOKIE;

  const options = {
    headers: {
      accept: "application/json",
      "accept-language": "zh-CN,zh;q=0.9,en;q=0.8",
      "content-type": "application/json",
      "sec-ch-ua": '" Not;A Brand";v="99", "Google Chrome";v="91", "Chromium";v="91"',
      "sec-ch-ua-mobile": "?0",
      "sec-fetch-dest": "empty",
      "sec-fetch-mode": "cors",
      "sec-fetch-site": "same-origin",
      "x-requested-with": "XMLHttpRequest",
      "x-csrf-token": CSRF_TOKEN,
      cookie: COOKIE,
    },
    referrer: `https://${DOMAIN}/dashboard/collections`,
    referrerPolicy: "strict-origin-when-cross-origin",
    body: null,
    method: "GET",
    mode: "cors",
  };

  const fetchJSON = async (...args) => {
    let resp = await fetch(...args);
    return await resp.json();
  };

  const uniqBy = (list, prop) => {
    if (!list || !list.length) return [];
    return list.filter((obj, pos, arr) => {
      return arr.map((mapObj) => mapObj[prop]).indexOf(obj[prop]) === pos;
    });
  };

  const [dataFavorite, dataHistory] = await Promise.all([
    // 收藏接口
    fetchJSON(`https://${DOMAIN}/api/mine/marks?limit=1000&offset=0&type=all&q=${encodeURIComponent(alfy.input)}`, options),
    // 历史记录接口
    fetchJSON(`https://${DOMAIN}/api/mine/docs?limit=1000&offset=0&type=recent_read`, options),
  ]);

  const getFavoriteUrl = (element) => {
    let url = element?._url || "";
    let title = element?.title || "";
    if (element?._url) {
      let suffixUrl = element._url;
      suffixUrl = suffixUrl.indexOf("/") === 0 ? suffixUrl : `/${suffixUrl}`;
      return `https://${DOMAIN}${suffixUrl}`;
    }
    if (title) {
      return `https://${DOMAIN}/search?scope=&p=1&q=${encodeURIComponent(title)}&type=content&advanced=&related=`;
    }
    return `https://${DOMAIN}/`;
  };

  const getHistoryUrl = (element) => {
    let firstSlug = element?.book?.user?.login;
    let secondSlug = element?.book?.slug;
    let thirdSlug = element?.slug;
    if (element?.share?.type === "share" && element?.share?.token) {
      firstSlug = "docs";
      secondSlug = "share";
      thirdSlug = element.share.token;
    }
    let suffixUrl = [firstSlug, secondSlug, thirdSlug].filter(Boolean).join("/");
    return `https://${DOMAIN}/${suffixUrl}`;
  };

  const getFuzzyMatchResults = (list, input) => {
    const fuse = new Fuse(list || [], {
      includeScore: true,
      useExtendedSearch: true,
      keys: [
        {
          name: "title",
          weight: 1,
        },
        // {
        //   name: "subtitle",
        //   weight: 0.9,
        // },
        {
          name: "description",
          weight: 0.6,
        },
        {
          name: "username",
          weight: 1,
        },
        {
          name: "userlogin",
          weight: 1,
        },
        {
          name: "workid",
          weight: 1,
        },
      ],
    });
    return fuse.search(input).map((res) => {
      const title = res?.item?.title || "暂无标题";
      const percentage = `${((1 - res.score) * 100).toFixed(5)}%`;
      const isShare = res?.item?.element?.action_option === "share_Doc_doc" || res?.item?.element?.share?.type === "share";
      const defaultSubtitle = isShare ? "分享" : "文档";
      const subtitle = `${res?.item?.subtitle || defaultSubtitle} | ${percentage}`;
      const arg = res?.item?.isFavorite ? getFavoriteUrl(res?.item?.element) : getHistoryUrl(res?.item?.element);
      return {
        title,
        subtitle,
        arg,
      };
    });
  };

  const itemsFavorite = (dataFavorite?.data?.actions || []).map((element) => {
    const title = element?.title;
    const description = element?.target?.description;
    const username = element?.target?.user?.name;
    const userlogin = element?.target?.user?.login;
    const workid = element?.target?.user?.work_id;
    const subtitle = username || userlogin || workid;
    const id = element?.target_id;
    return {
      title,
      subtitle,
      description,
      username,
      userlogin,
      workid,
      element,
      isFavorite: true,
      _id: id + "#" + title,
    };
  });

  const itemsHistory = (dataHistory?.data || []).map((element) => {
    const title = element?.title;
    const username = element?.book?.user?.name;
    const userlogin = element?.book?.user?.login;
    const subtitle = username || userlogin;
    const id = element?.id;
    return {
      title,
      subtitle,
      description: "",
      username,
      userlogin,
      workid: "",
      element,
      isFavorite: false,
      _id: id + "#" + title,
    };
  });
  const itemsUniq = uniqBy([...itemsFavorite, ...itemsHistory], "_id");
  const items = getFuzzyMatchResults(itemsUniq, alfy.input);
  alfy.output(items);
})();

  1. 编辑完需要“yuque插件” -> 双击 Script Filter

image.png

image.png Script处使用如下代码完整粘贴覆盖:

DOMAIN="$domain" CSRF_TOKEN="$csrfToken" COOKIE="$cookie" ./node_modules/.bin/run-node index.js "$1"
  1. 最后可以通过右键点击“yuque插件” -> Edit Details 来完善图标和其他信息

image.png

截屏2022-05-26 上午12.11.25.png

  1. 快捷键 “Option + 空格” 可以快速运行检索,选择结果后回车即可跳转到对应的文档地址

屏幕录制2022-05-26 上午12.10.02.mov.gif

注意:

  • yq” 和 “关键词” 中间是有空格的
  • 关键词目前支持收藏过、浏览过的文档标题、作者、内容进行模糊匹配
  • 由于使用了fuse.js扩展搜索功能,可以使用下述规则进行高级功能检索

image.png

四、福利环节

Alfred Workflows包含非常多好用的插件,这里推荐几个好用的开发者插件,谁用谁知道😂