🦕

DenoとGraphQLでGitHubの草をターミナルに表示する

11 min read 2

GitHubの草が何となく好きです。どれくらい活動しているかが可視化され、自信やモチベーションになる気がします。
しかし普段ターミナルに生息しているので、わざわざWebページを開くのは不便です。
せっかくなのでターミナルに表示させたいと思いました。

作業記録(Zenn Scrap)はこちら

GitHubのトークンを取得する

GraphQLでユーザー情報を取得するため、read:user権限のトークンが必要になります。

https://github.com/settings/tokens/new からトークンを作成します。

ページを辿っていく場合

メニューのSettingsからサイドバーのDeveloper settingsへ入ります

さらにサイドバーのPersonal access tokensに入り、Generate new tokenへ行ってください。

名前とread:user権限を設定してGenerate tokenしましょう。

次の画面でトークンが表示されます。
例によってこのタイミングでしか見られないので保存しておきます。

これを使います。もちろんコード内にベタで書いても良いのですが、間違ってpushされてしまうと怖いので、.gitignoreされているファイルに書くのが良いと思います。ここでは.envに設定します。

.env.example
GITHUB_READ_USER_TOKEN=github_read_user_token
.env
GITHUB_READ_USER_TOKEN=xxxxxxxxx

これで事前準備は完了です。

DenoからGraphQLを使う

こちらの記事をかなり参考にさせていただきました。

疎通を確認する

まずユーザー名を取得するだけのクエリを作ってみます。先程生成したトークンをBearer tokenとしてheadersに入れましょう。
ここではリクエストにfetchではなくkyを使っています。使い方は以前書いた記事をご覧ください。

https://zenn.dev/kawarimidoll/articles/13c3f75f6f22d6
main.ts
import { ky } from "./deps.ts";
import { GITHUB_READ_USER_TOKEN } from "./env.ts";

const query = `
query {
  viewer {
    login
  }
}
`;

const url = "https://api.github.com/graphql";
const json = { query };

const result = await ky.post(url, {
  headers: { Authorization: `Bearer ${GITHUB_READ_USER_TOKEN}` },
  json,
}).json();

console.log(result);

実行します。
なお、実行にはVelociraptorを使っています。
設定は以前書いた記事をご覧ください。

https://zenn.dev/kawarimidoll/articles/1c48c097020cbc
❯ vr start
{ data: { viewer: { login: "kawarimidoll" } } }

疎通が確認できました。

しかしkyは導入してから八面六臂の活躍です。べんり。

Contributionsを取得する

本題です。コントリビューションを取得します。
queryを書き換え、variablesも追加します。

main.ts
  import { ky } from "./deps.ts";
  import { GITHUB_READ_USER_TOKEN } from "./env.ts";

+ const userName = "kawarimidoll";

  const query = `
- query {
-   viewer {
-     login
+ query($userName:String!) {
+   user(login: $userName){
+     contributionsCollection {
+       contributionCalendar {
+         totalContributions
+         weeks {
+           contributionDays {
+             color
+             contributionCount
+             contributionLevel
+             date
+           }
+         }
+       }
+     }
    }
  }
  `;
+ const variables = `
+ {
+   "userName": "${userName}"
+ }
+ `;

  const url = "https://api.github.com/graphql";

- const json = { query };
+ const json = { query, variables };

  const result = await ky.post(url, {
    headers: { Authorization: `Bearer ${GITHUB_READ_USER_TOKEN}` },
    json,
  }).json();

  console.log(result);

(ほとんど参考元のコピペになってしまった)
実行するとこんな感じです。

❯ vr start  
{ data: { user: { contributionsCollection: { contributionCalendar: [Object] } } } }

ちょっと階層が深いので中身を取り出して表示してみます。

main.ts
(略)
- const result = await ky.post(url, {
+ const { data } = await ky.post(url, {
    headers: { Authorization: `Bearer ${GITHUB_READ_USER_TOKEN}` },
    json,
  }).json();

- console.log(result);
+ console.log(data?.user);
❯ vr start
{
  contributionsCollection: {
    contributionCalendar: {
      totalContributions: 2135,
      weeks: [
        [Object], [Object], [Object], [Object],
        [Object], [Object], [Object], [Object],
        [Object], [Object], [Object], [Object],
        [Object], [Object], [Object], [Object],
        [Object], [Object], [Object], [Object],
        [Object], [Object], [Object], [Object],
        [Object], [Object], [Object], [Object],
        [Object], [Object], [Object], [Object],
        [Object], [Object], [Object], [Object],
        [Object], [Object], [Object], [Object],
        [Object], [Object], [Object], [Object],
        [Object], [Object], [Object], [Object],
        [Object], [Object], [Object], [Object],
        [Object]
      ]
    }
  }
}

参考元でも触れられていますが、Webページ上のcontributions数とはちょっとずれがあるようです。上記実行結果ではtotalContributionsが2135でしたが、ページでは2136と表示されていました。

private repositoryの問題かとも思いましたが、それならもっと差が出ると思うんですよね、よくわかりません。

weeksの中を表示させてみます。古いものから並んでいるので、最後の週を表示させると最新のものが見られます。

main.ts
// const { data } の代入まで同じ

interface ContributionDay {
  contributionCount: number;
  contributionLevel: string;
  date: string;
  color: string;
}
const { weeks, totalContributions }: {
  weeks: { contributionDays: ContributionDay[] }[];
  totalContributions: number;
} = data?.user?.contributionsCollection
  ?.contributionCalendar;

if (!weeks || !totalContributions) {
  throw new Error("Could not get contributions data");
}

console.log(totalContributions + " contributions in the last year");
console.log(weeks[weeks.length - 1]);
❯ vr start
2135 contributions in the last year
{
  contributionDays: [
    {
      color: "#9be9a8",
      contributionCount: 2,
      contributionLevel: "FIRST_QUARTILE",
      date: "2021-06-27"
    },
    {
      color: "#9be9a8",
      contributionCount: 6,
      contributionLevel: "FIRST_QUARTILE",
      date: "2021-06-28"
    }
  ]
}

queryで設定した項目が取得できていますね。

ターミナルで表示させる

Contribution数を表示させる

では、まず数値を表示させてみます。

受け取った値は最初の週から日付順に並んでいますが、これを適切に表示させるためには行列を転置して曜日を横断して表示させる必要があります。

main.ts
// console.log(totalContributions + " contributions in the last year");
// ↑これまで同じ

weeks[0].contributionDays.forEach((_, i) => {
  console.log(
    weeks.map((row) =>
      `${row.contributionDays[i]?.contributionCount ?? ""}`.padStart(3)
    ).join(""),
  );
});

表示処理で??を使って場合分けしています。||を使うと、0falsyのため、contributionCount0のときにうまくいきません。
また、padStart(3)で桁を調整しています。1日で3桁以上のコントリビューションがない場合はこれで十分でしょう。

実行すると以下のように横に並べることができます。
なお、53週 x 3字ずつ並んでいるので159字以上表示できる幅が必要です。

❯ vr start
2135 contributions in the last year
  2  1  5  1 13  5  2  4  5  9  1  2  3  3  3  8  3 10  1  4  1  1  6  2  4  2 10 10 10  9  8  6 13  4  7  7  6  6  3  1 11  1  5  2  6  6  6 21 11 17 13 16  2
  0  1  2  1  4  0  1  5  7  1  1  3  9  1  5  8  3  1  4  5  3  1  1  1  1  6  9 12  5  7  5  5 19  2 20 11  8 10 32  4  6  2  5  4  3  3  7 20  5 17  6  5  6
  0  0  2  4  0  5  0  7  8  3  3  3  2  2  5  1  1  4  1  3  1  1  2  1  1  7  8  9  4 10  2 14 12 18 11 22  6  7  7 14  1  3  2  5  1 18  5 20  7  4  6  5
  0  5  3  4  8  5  1  4  2  5  2  4  1  5  2  3  1  4  1  1  1  1  1  2  2  4 11  5  6  4  2  6 18 16  6  6  2  2  7  9  1  4  1  4  4  9  4 11  5 20  6 14
  0  1  0 13  6  1  0  0  5  1  4  3  1  6  3  2  1  1  2 10  1  1  1  2  2 11  4 18  4 15  4 12 24  4 23 15  4 17  8  5  4  2  2 14  2 12  6  9  1  9  4  2
  0  5  9 10  2  3  3  2  3  1  3  1  9  5  6  1  1  1  3  1  1  1  1  2  1  9  7 23  6 13  6  1 14  6  3 12  3  4  6  2  1  2  6 14  6 12  6 15  2 10  4 18
  0  9  0  5  7  3  3 10  4  5  4  3  2  4  1  6  4  1  4  2  2  3  1  2  1 10  7 28  6 12  8 17  7  6  4 14 13  3  5 15  2  5  4 11  6 15 24 20  8 19  9  9

色をつける

返却値に含まれているcolorを使って着色します。
Denoでは、出力結果の着色にstd/fmt/colorsが使えます。

https://deno.land/std@0.99.0/fmt

GitHubから返却されるcolorはHEX値ですが、std/fmt/colorsではHEXに対応したものは提供されていないようでした。
ということで変換処理を作ります。

main.ts
// コード内の適当な場所に追加
const hexToRgb = (hex: string) => {
  if (hex.slice(0, 1) == "#") hex = hex.slice(1);
  if (hex.length == 3) {
    hex = hex.slice(0, 1) + hex.slice(0, 1) + hex.slice(1, 2) +
      hex.slice(1, 2) + hex.slice(2, 3) + hex.slice(2, 3);
  }

  const r = parseInt("0x" + hex.slice(0, 2));
  const g = parseInt("0x" + hex.slice(2, 4));
  const b = parseInt("0x" + hex.slice(4, 6));

  return { r, g, b };
};

今回は返却値は"#123456"形式なのでここまでの場合分けは要らないのですが、後々も使えるかと思い、頭に#のない場合や省略形式("#123")にも対応しています。

これを使ってを着色します。

main.ts
import { rgb24 } from "https://deno.land/std@0.99.0/fmt/colors.ts"; // import追加

// (略)
const grass = (day?: ContributionDay) =>
  day?.color ? rgb24("■", hexToRgb(day.color)) : "";

weeks[0].contributionDays.forEach((_, i) => {
  console.log(
    weeks.map((row) => grass(row.contributionDays[i])).join(""),
  );
});

実行すると草が表示されると思います。

また、colorを直接表示させるのではなくcontributionLevelを使って場合分け、オリジナルのカラースキームを使うこともできます。

main.ts
// (略)
+ const colors = [ "#eeeeee", "#f8bbd0", "#f06292", "#e91e63", "#880e4f"];
+ const fillPixel = (day?: ContributionDay) => {
+   switch (day?.contributionLevel) {
+     case "NONE":
+       return hexToRgb(colors[0]);
+     case "FIRST_QUARTILE":
+       return hexToRgb(colors[1]);
+     case "SECOND_QUARTILE":
+       return hexToRgb(colors[2]);
+     case "THIRD_QUARTILE":
+       return hexToRgb(colors[3]);
+     case "FOURTH_QUARTILE":
+       return hexToRgb(colors[4]);
+   }
+   return hexToRgb("#eeeeee");
+ };

  const grass = (day?: ContributionDay) =>
-   day?.color ? rgb24("■", hexToRgb(day.color)) : "";
+   day ? rgb24("■", fillPixel(day)) : "";

  weeks[0].contributionDays.forEach((_, i) => {
    console.log(
      weeks.map((row) => grass(row.contributionDays[i])).join(""),
    );
  });


配色はこちらを参考にしました。

https://github.com/williambelle/github-contribution-color-graph
補足

以下のように定義するとswitch文がなくなってきれいになるとは思いますが、複数カラースキームをきりかえて使いたいときなどに面倒な気がしたので今回はswitchを採用しました。

colors = {
  "NONE": "#eeeeee",
  "FIRST_QUARTILE": "#f8bbd0",
  "SECOND_QUARTILE": "#f06292",
  "THIRD_QUARTILE":"#e91e63",
  "FOURTH_QUARTILE":"#880e4f"
};

おわりに

今回のキモは true colorに対応した端末を使うこと ですね。

ずっとmacデフォルトのterminal.appを使っていたので悲しいことになっていました。

これまではterminal.appで十分だと思っていましたが、乗り換えどきかもしれません。

Denoでいろいろ遊んでいるリポジトリはこちら。

https://github.com/kawarimidoll/deno-dev-playground

参考

https://zenn.dev/yuichkun/articles/b207651f5654b0
https://zenn.dev/gentamura/scraps/3718406d2c9316
https://qiita.com/shoheihagiwara/items/9cd7b54e5cd69a289ceb

https://lab.syncer.jp/Web/JavaScript/Snippet/61/

Discussion

とても面白い発想で、勉強になりました。
ありがとうございます!

ログインするとコメントできます