记录一下,我如何从零开发一个vscode掘金消息助手插件。

1,346 阅读7分钟

背景

这段时间开始在掘金写文章了,但是写完文章后,想快速知道当前文章的数据,比如新增赞、收藏、评论、粉丝等,就需要一直在浏览器中刷新页面,然后看文章数据,这样在上班的时候很不方便。于是想写一个vscode插件,在写代码的时候也能实时看到自己最新文章的数据。

效果展示

vscode底部展示,定时请求接口刷新文章数据。 image.png 如果有消息通知,会主动推送消息,这里用报错提示是有原因的,下面会有讲解。点击查看会跳到对应的掘金官网消息页面。

image.png

使用介绍

在vscode插件市场搜索juejin-message-helper,作者是dbfu321。也可以从这里安装,marketplace.visualstudio.com/items?itemN…

从掘金官网把cookie复制出来,设置到juejin-cookie上。

在官网中找到这个接口

api.juejin.cn/interact_ap… image.png

复制下面的cookie

image.png

在vscode中设置刚复制cookie

image.png

下面还有一个参数可以设置刷新频率

image.png

需求分析

从掘金官网找到查文章数据的接口和消息接口

查询文章的数据接口

url:api.juejin.cn/content_api…

请求方式:post

请求参数(body):

{
    "audit_status": 2,   // 这个不知道有啥用,写死2就行了
    "page_size": 5,      // 一页多少条数据,这个最小是5,再小接口会报错,其实我只想查一条数据,被迫查五条,然后取第一条
    "page_no": 1         // 从第几页开始查,最小1
}

响应参数: 我从data[0].article_info这个对象中找到了我需要的所有参数

    display_count,  // 展现数
    view_count,     // 阅读数
    digg_count,     // 赞的数量
    comment_count,  // 评论数
    collect_count,  // 收藏数

查询消息的接口

image.png 就是这里面的数据

url:api.juejin.cn/interact_ap…

请求方式:get

请求参数:无

响应参数:

image.png

掘金优化做的真好,数据的key都优化了。我猜测这样做是为了减少传输量,绝对不是为了省事。我一个一个试,终于试出来了每个数字代表的含义。

  • 1表示赞和收藏
  • 2表示粉丝
  • 3表示评论
  • 7表示私信
  • 4表示系统通知
  • total是未读消息总数

代码中设置两个定时器

一个定时器定时去掉文章数据接口,查最新文章数据。另外一个定时器定时去调用消息接口,第一个好做,请求完数据把数据使用StatusBarItem显示就行了,第二个有点麻烦,因为掘金的消息接口,如果你没处理,接口会一直有数据,这里肯定不能一直弹出提示,所以就需要在代码里记录一下上一次请求后消息的数据,然后和当前对比,如果数量是一样的,那么就不提示。

开发过程

安装全局命令yo

npm install yo -g

生成插件项目

在一个空文件夹中执行命令,生成插件项目模板。

yo code

执行完命令正常会出现如下图页面

image.png 就选第一个就行了,如果不想用ts,可以选第二个。选后后:

image.png 上面几个字段按自己的需求填就行了,是否需要使用webpack,这个大家注意一下,使用webpack的好处是打包的时候会把代码压缩混淆,包的体积会小一点,别人安装你依赖的时候快一点,但是缺点是打包比较慢,这个大家根据自己项目去决定吧,因为我这个项目比较小,我就选择不使用webpack。

image.png 安装依赖使用npm或yarn根据自己习惯选吧

image.png 项目文件创建后,可能会卡在装依赖的地方,我的建议是终止掉,然后在项目里用tyarn或tnpm安装会快很多,这两个用的是国内镜像。

image.png 进入刚创建的项目

cd juejin-message-helper

image.png 使用vscode打开项目

code .

image.png 使用tyarn安装依赖,没有安装tyarn命令的,可以全局安装一下。

npm i tyarn -g

image.png 运行项目

image.png

image.png command+shift+p调出命令输入框,输入Hello,然后点击Hello World,下面会弹出一个消息,表示项目初始化完成,下面开始写代码。

核心功能实现

启动vscode就执行代码

因为vscode对插件有优化,没有执行命令前,activate事件是不会执行的,可以设置package.json文件中的activationEvents属性为onStartupFinished,这样src/extension.ts文件里的activate方法就会自动执行了。

监听配置改变

	// 监听配置时候改变,如果改变则重新new一个对象,重新监听
	const disposable = vscode.workspace.onDidChangeConfiguration(event => {
		if (
			[
				'juejin-cookie',
				'juejin-refresh-time-span',
			].some(str => event.affectsConfiguration(str))
		) {
			messageHelper.stopListen();
			messageHelper = new MessageHelper();
			messageHelper.startListen();
		}
	});

使用onDidChangeConfiguration监听配置变化,然后判断是否是cookie和juejin-refresh-time-span这两个和我相关的配置改变,如果有变化,就重新监听。

使用showErrorMessage来提示用户有新消息

上面提过,这里使用error类型的消息,因为正常info类型的消息,几秒钟之后会自动关闭,如果写代码很入神,很可能会错过消息,所以我不想让他关闭。查了很多资料都没办法让它不关掉,vscode issues也有人问,官方则说就是这样设计的,没办法只能用error类型的消息,这个如果用户不点,不会自动关闭,

核心代码

src/extension.ts

// The module 'vscode' contains the VS Code extensibility API
// Import the module and reference it with the alias vscode in your code below
import * as vscode from 'vscode';
import { MessageHelper } from './message-helper';

export async function activate(context: vscode.ExtensionContext) {
	let messageHelper = new MessageHelper();
	messageHelper.startListen();

	// 监听配置时候改变,如果改变则重新new一个对象,重新监听
	const disposable = vscode.workspace.onDidChangeConfiguration(event => {
		if (
			[
				'juejin-cookie',
				'juejin-refresh-time-span',
			].some(str => event.affectsConfiguration(str))
		) {
			messageHelper.stopListen();
			messageHelper = new MessageHelper();
			messageHelper.startListen();
		}
	});

	context.subscriptions.push(disposable);
}

// this method is called when your extension is deactivated
export function deactivate() {

}

src/message-helper.ts

import { StatusBarAlignment, Uri, env, window, workspace } from 'vscode';
import { Message, Service, juejinBaseUrl } from './service';

export class MessageHelper {
  private statusBarItem = window.createStatusBarItem(StatusBarAlignment.Left, 10);
  private timerStatusBarItem = window.createStatusBarItem(StatusBarAlignment.Left, 10);
  private timeSpan: number;
  private texts: string[] = [];
  private count: number = 0;
  private service: Service | null = null;
  private hasShow = false;

  private countTimer: NodeJS.Timeout | null = null;
  private messageTimer: NodeJS.Timeout | null = null;

  private messages: Message[] = [];

  constructor() {
    const cookie = workspace.getConfiguration().get('juejin-cookie') as string;
    this.timeSpan = (workspace.getConfiguration().get('juejin-refresh-time-span') as number) * 1000;

    if (!cookie) {
      window.showErrorMessage('请先配置cookie!');
      return;
    }

    this.service = new Service(cookie);
  }

  public startListen() {
    this.refreshCountText();

    this.countTimer = globalThis.setInterval(async () => {
      if (this.count === 0) {
        this.count = this.timeSpan;
        this.refreshCountText();
      } else {
        this.count -= 1000;
      }
      this.timerStatusBarItem.text = `下次刷新:${this.count / 1000}`;
    }, 1000);

    this.listenMessage();

    this.messageTimer = globalThis.setInterval(() => {
      this.listenMessage();
    }, 5000);

  }

  public stopListen() {
    if (this.countTimer) {
      globalThis.clearInterval(this.countTimer);
    }

    if (this.messageTimer) {
      globalThis.clearInterval(this.messageTimer);
    }

    this.statusBarItem.dispose();
    this.timerStatusBarItem.dispose();
  }

  private async listenMessage() {
    try {
      const messages = await this.service?.getMessages() || [];
      this.messages.push(...messages);
      if (!this.hasShow) {
        this.showMessage();
      }
    } catch {
      if (this.messageTimer) {
        globalThis.clearInterval(this.messageTimer);
      }
      window.showWarningMessage('请检查cookie是否配置错误');
    }
  }

  private async showMessage() {
    const curMessage = this.messages.pop();

    if (!curMessage) {
      this.hasShow = false;
      return;
    }

    const { message, url, count } = curMessage;

    this.hasShow = true;

    const result = await window.showErrorMessage(`掘金消息助手:您有${count}${message}`, ...['查看', '关闭']);
    if (result === '查看') {
      await env.openExternal(Uri.parse(`${juejinBaseUrl}${url}`));
    }

    this.hasShow = false;
    this.showMessage();
  }

  private async refreshCountText() {
    try {
      this.texts = await this.service?.getLatestArticleCount() || [];
      if (!this.texts?.length) {
        this.statusBarItem.text = '接口调用失败,请稍后刷新重试。';
      } else {
        this.statusBarItem.text = this.texts.join('\t');
      }

      this.timerStatusBarItem.text = `下次刷新:${this.count / 1000}`;
      this.statusBarItem.show();
      this.timerStatusBarItem.show();
    } catch {
      if (this.countTimer) {
        globalThis.clearInterval(this.countTimer);
      }
      window.showWarningMessage('请检查cookie是否配置错误');
    }
  }
}

src/service.ts

import axios, { AxiosInstance } from 'axios';

export const juejinBaseUrl = 'https://juejin.cn';
export interface Message {
  message: string;
  count: number;
  url: string;
}

export class Service {
  private request: AxiosInstance;
  private countFailCount = 0;
  private messageFailCount = 0;

  lastCollentAndDiggCount: number = 0;
  lastFanCount: number = 0;
  lastCommentCount: number = 0;
  lastNewsCount: number = 0;
  lastNoticeCount: number = 0;

  constructor(cookie: string) {
    this.request = axios.create({
      baseURL: juejinBaseUrl,
      headers: {
        cookie,
      },
    });
  }

  public async getLatestArticleCount(): Promise<string[]> {
    const url = '/content_api/v1/article/list_by_user?aid=2608&uuid=7056220463659533837&spider=0';
    try {
      // eslint-disable-next-line @typescript-eslint/naming-convention
      const { data: { data, err_no } } = await this.request.post(
        url,
        {
          page_no: 1,
          page_size: 5,
        }
      );

      if (err_no !== 0) {
        // 失败三次就不再刷新了
        if (this.countFailCount === 3) {
          throw new Error();
        }
        this.countFailCount += 1;
        return await this.getLatestArticleCount();
      }

      // 取最新的文章数据
      const latestArticle = data[0];

      if (!latestArticle) {
        return [];
      }

      const { article_info: detail } = latestArticle;

      const {
        display_count,
        view_count,
        digg_count,
        comment_count,
        collect_count,
      } = detail;

      const texts = [
        `展现:${display_count}`,
        `观看:${view_count}`,
        `赞:${digg_count}`,
        `评论:${comment_count}`,
        `收藏:${collect_count}`,
      ];

      this.countFailCount = 0;
      return texts;
    } catch {
      // 失败三次就不再刷新了
      if (this.countFailCount === 3) {
        throw new Error();
      }
      this.countFailCount += 1;
      return await this.getLatestArticleCount();
    }
  }

  public async getMessages(): Promise<Message[] | undefined> {
    const url = '/interact_api/v1/message/count?aid=2608&uuid=7056220463659533837&spider=0';

    try {
      const { data: { data, err_no } } = await this.request.get(url);

      if (err_no !== 0) {
        // 失败三次就不再刷新了
        if (this.messageFailCount === 3) {
          throw new Error();
        }
        this.messageFailCount += 1;
        return await this.getMessages();
      }

      const { count } = data;

      const messages = [];
      let collentAndDiggCount = count["1"];
      let fanCount = count["2"];
      let commentCount = count["3"];
      let noticeCount = count["4"];
      let newsCount = count["7"];

      if (collentAndDiggCount !== this.lastCollentAndDiggCount && collentAndDiggCount > 0) {
        messages.push({
          message: `个新的赞和收藏`,
          url: '/notification/digg',
          count: collentAndDiggCount,
        });
      }

      if (fanCount !== this.lastFanCount && fanCount > 0) {
        messages.push({
          message: `个新的粉丝`,
          url: '/notification/follow',
          count: fanCount,
        });
      }

      if (commentCount !== this.lastCommentCount && commentCount > 0) {
        messages.push({
          message: `条新的评论`,
          url: '/notification',
          count: commentCount,
        });
      }

      if (newsCount !== this.lastFanCount && newsCount > 0) {
        messages.push({
          message: `条新的私信`,
          url: '/notification/im',
          count: newsCount,
        });
      }

      if (noticeCount !== this.lastNewsCount && noticeCount > 0) {
        messages.push({
          message: `条新的系统通知`,
          url: '/notification/system',
          count: noticeCount,
        });
      }

      this.lastCollentAndDiggCount = collentAndDiggCount;
      this.lastFanCount = fanCount;
      this.lastCommentCount = commentCount;
      this.lastNewsCount = newsCount;
      this.lastNoticeCount = noticeCount;

      this.messageFailCount = 0;

      return messages;
    } catch {
      // 失败三次就不再刷新了
      if (this.messageFailCount === 3) {
        throw new Error();
      }
    }
  }
}

代码仓库地址:github.com/dbfu/juejin…

总结

后面可能还会加自己选择一个文章去监听,而不是写死的最新一条。

这里提示大家一下,我调用掘金的接口频率很低,对掘金服务器不会造成影响,大家在使用掘金api的时候一定要控制频率,别把服务器搞崩了。