DenoでGitHubの草を取得する
GraphQL使いたい
参考
https://github.com/settings/tokens/new からトークン作成
ページを辿っていく場合は以下
read:userの権限があればOK
Generate tokenするとトークンが表示される、例によってこのタイミングでしか見られないので保存しておく
そしたら.env
に追記
GITHUB_READ_USER_TOKEN=github_read_user_token
GITHUB_READ_USER_TOKEN=xxxxxxxxx
とりあえず参考記事で載せられていたユーザー名を取得するだけのクエリを書いてみる
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);
実行してみる
❯ vr start
{ data: { viewer: { login: "kawarimidoll" } } }
良さげ
しかしkyは導入してから八面六臂の活躍だな…
本題
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 {
+ contributionCount
+ 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] } } } }
❯ 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]
]
}
}
}
参考元にもあったけど、ページ上のcontributions数とはちょっとずれるみたい?
なんだろう、差が1しかないのでprivate repositoryの数とは違うと思うけど…
weeks
は古いものから並んでいるみたい
const json = { query, variables }; // ここまで先程のコードと同じ
const { data } = await ky.post(url, {
headers: { Authorization: `Bearer ${GITHUB_READ_USER_TOKEN}` },
json,
}).json();
const { weeks, totalContributions } = 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: [
{ contributionCount: 2, date: "2021-06-27" },
{ contributionCount: 6, date: "2021-06-28" }
]
}
オブジェクトの配列になっていると扱いづらいので単純な二次元配列に変換する
map()
の引数に型が必要なのでinterface
を定義する
extractedContributions
も後で使用するときに型を聞かれるのでつけておく
console.log(totalContributions + " contributions in the last year"); // ここまで先程のコードと同じ
interface ContributionDay {
contributionCount: number;
date: string;
}
// 二次元配列に変換するついでに最大値も調べる
let maxContributionsCount = 0;
const extractedContributions: number[][] = weeks.map((
week: { contributionDays: ContributionDay[] },
) =>
week.contributionDays.map(
(day) => {
if (day.contributionCount > maxContributionsCount) {
maxContributionsCount = day.contributionCount;
}
return day.contributionCount;
},
)
);
console.log(maxContributionsCount);
console.log(extractedContributions);
❯ vr start
2135 contributions in the last year
32
[
[
2, 0, 0, 0,
0, 0, 0
],
[
1, 1, 0, 5,
1, 5, 9
],
[
(略)
やりたいことが見えてきたぞ
行列を転置して横に表示させる
最新週のrow[i]
がundefined
なので表示可否の判定を挟む
ただし、この場合${row[i] || ""}.padStart(3)
とかrow[i] ? ${row[i]}.padStart(3) : ""
だと0
がfalsy
なためうまくいかない(この行、mdのシンタックスとjsのシンタックスでバッククォートが重複していてうまく書けない)
ちゃんとrow[i] != null
でチェックする必要あり
…と思っていたけど??
のおかげでシンプルに書けた
// extractedContributionsを作るところまで同じ
extractedContributions[0].forEach((_, i) => {
// 判定部は row[i] != null ? `${row[i]}`.padStart(3) : "" でもOK
console.log(
extractedContributions.map((row) => `${row[i] ?? ""}`.padStart(3)).join(""),
);
});
実行すると以下のように横に並ぶ
50週 x 3字ずつ並んでいるので150字以上表示できる端末が必要
❯ 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
さあ色を付けていこう
https://deno.land/x/chalk_deno@v4.1.1-deno →使えなかった
そもそもstd/fmt/colorsというのがあったよ
難儀しました
import { rgb24 } from "https://deno.land/std@0.99.0/fmt/colors.ts"; // import追加
// 途中は略
// extractedContributionsを作るところまで同じ
// const colors = [
// "#ebedf0",
// "#9be9a8",
// "#40c463",
// "#30a14e",
// "#216e39",
// ];
const fillPixel = (count: number) => {
if (count === 0) {
return { r: 253, g: 237, b: 240 };
} else if (count < maxContributionsCount / 4) {
return { r: 155, g: 233, b: 168 };
} else if (count < maxContributionsCount / 2) {
return { r: 64, g: 196, b: 99 };
} else if (count < maxContributionsCount * 3 / 4) {
return { r: 48, g: 161, b: 78 };
}
return { r: 33, g: 110, b: 57 };
};
const grass = (count?: number) =>
(count == null) ? "" : rgb24("■", fillPixel(count));
extractedContributions[0].forEach((_, i) => {
console.log(
extractedContributions.map((row) => grass(row[i])).join(""),
);
});
キモは true colorに対応した端末を使うこと ですね
macデフォルトのterminal.appを使っていたので悲しいことになっていた
なんというかterminal.appで十分だと思っていたけど乗り換えどきだなこれは…
あーそうか、GraphQLだから問い合わせの段階で項目を選べるじゃない
const query = `
query($userName:String!) {
user(login: $userName){
contributionsCollection {
contributionCalendar {
totalContributions
weeks {
contributionDays {
color
+ contributionCount
date
}
}
}
}
}
}
`;
(略)
console.log(weeks[weeks.length - 1]);
❯ vr start
Check file:///Users/kawarimidoll/ghq/github.com/kawarimidoll/deno-dev-playground/main.ts
2135 contributions in the last year
{
contributionDays: [
{
color: "#9be9a8",
contributionCount: 2,
date: "2021-06-27"
},
{
color: "#9be9a8",
contributionCount: 6,
date: "2021-06-28"
},
{
color: "#ebedf0",
contributionCount: 0,
date: "2021-06-29"
}
]
}
つまりここから色情報を取り出せばプログラム内で保持する必要はない
これでOKだった
// https://lab.syncer.jp/Web/JavaScript/Snippet/61/
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(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
return { r, g, b };
};
const grass = (color?: string) => color ? rgb24("■", hexToRgb(color)) : "";
extractedContributions[0].forEach((_, i) => {
console.log(
extractedContributions.map((row) => grass(row[i]?.color)).join(""),
);
});
自分で定義してないから良い感じに色を変えるみたいなことは難しいかも
カラースキーム追加できる
const github = ["#9be9a8", "#40c463", "#30a14e", "#216e39"];
const halloween = ["#fdf156", "#ffc722", "#ff9711", "#04001b"];
const amber = ["#ffecb3", "#ffd54f", "#ffb300", "#ff6f00"];
//(もっと追加できる)
const colorSchemes = {
github,
halloween,
amber,
};
const colorSchemeName = "halloween";
const baseColor = "#eeeeee";
const fillPixel = (day?: ContributionDay) => {
const colors = colorSchemes[colorSchemeName];
switch (day?.contributionLevel) {
case "FIRST_QUARTILE":
return hexToRgb(colors[0]);
case "SECOND_QUARTILE":
return hexToRgb(colors[1]);
case "THIRD_QUARTILE":
return hexToRgb(colors[2]);
case "FOURTH_QUARTILE":
return hexToRgb(colors[3]);
}
// case "NONE" or undefined
return hexToRgb(baseColor);
};
published
deno deployでserverless化できるかなあ?
ローカル実行から試そうかね
addEventListener("fetch", (event) => {
const response = new Response("Hello World!", {
headers: { "content-type": "text/plain" },
});
event.respondWith(response);
});
❯ deno install --allow-read --allow-write --allow-env --allow-net --allow-run --no-check -f https://deno.land/x/deploy/deployctl.ts
❯ deployctl run --watch ./main.ts
とりあえずcontributionsの方をモジュール化しよう
import { ky, rgb24 } from "./deps.ts";
import { ContributionDay } from "./types.ts";
import { getColorScheme } from "./color_scheme.ts";
const contributions = async (userName: string, token: string) => {
if (!userName || !token) {
throw new Error("Missing required arguments");
}
const query = `(略) `;
const variables = `(略) `;
const json = { query, variables };
const url = "https://api.github.com/graphql";
const { data } = await ky.post(url, {
headers: { Authorization: `Bearer ${token}` },
json,
}).json();
const { weeks, totalContributions }: {
weeks: { contributionDays: ContributionDay[] }[];
totalContributions: number;
} = data?.user?.contributionsCollection
?.contributionCalendar;
if (!weeks || !totalContributions) {
throw new Error("Could not get contributions data");
}
const total = totalContributions + " contributions in the last year\n";
const colorScheme = getColorScheme();
const grass = (day?: ContributionDay) =>
day?.color ? rgb24("■", colorScheme(day?.contributionLevel)) : "";
return total +
weeks[0].contributionDays.map((_, i) =>
weeks.map((row) => grass(row.contributionDays[i])).join("")
).join("\n");
};
export { contributions };
けっこうリファクタリングできてきた(一部抜粋)
const colorScheme = getColorScheme(options.scheme);
const total = (options.total ?? true)
? totalContributions + " contributions in the last year\n"
: "";
const legend = (options.legend ?? true)
? "\n Less " + colorScheme.colors.map((color) =>
rgb24("■", color)
).join("") + " More"
: "";
const grass = (day?: ContributionDay) =>
day?.contributionLevel
? rgb24("■", colorScheme.getByLevel(day?.contributionLevel))
: "";
return total +
weeks[0].contributionDays.map((_, i) =>
weeks.map((row) => grass(row.contributionDays[i])).join("")
).join("\n") + legend;
const randomColorScheme = () => {
const values = Object.values(colorSchemes);
return values[Math.floor(Math.random() * values.length)];
};
const getColorScheme = (name?: ColorSchemeName | "random") => {
const colors = [
baseColor,
...(name === "random"
? randomColorScheme()
: colorSchemes[name ?? "github"]),
].map((color) =>
parseInt(
"0x" + color.replace(
/^#?(.*)$/,
(_, hex) => (hex.length == 3) ? hex.replace(/./g, "$&$&") : hex,
),
)
);
const getByLevel = (levelName?: ContributionLevelName) => {
switch (levelName) {
case "FIRST_QUARTILE":
return colors[1];
case "SECOND_QUARTILE":
return colors[2];
case "THIRD_QUARTILE":
return colors[3];
case "FOURTH_QUARTILE":
return colors[4];
}
// case "NONE" or undefined
return colors[0];
};
return { colors, getByLevel };
};
export { getColorScheme };
特にgetColorScheme
の実装は自分でもかなり気持ち良いワンライナー
その後も色々修正したけど省略
deployに着手
❯ deno install --allow-read --allow-write --allow-env --allow-net --allow-run --no-check -f https://deno.land/x/deploy/deployctl.ts
Download https://deno.land/x/deploy/deployctl.ts
Warning Implicitly using latest version (0.3.0) for https://deno.land/x/deploy/deployctl.ts
(各種Downloadは省略)
✅ Successfully installed deployctl
/Users/kawarimidoll/.deno/bin/deployctl
server.ts
作成 なんかrespondWith
が無いっていうエラーが出たけど気にしない
addEventListener("fetch", (event) => {
const response = new Response("Hello World!", {
headers: { "content-type": "text/plain" },
});
event.respondWith(response);
});
実行
❯ deployctl run --watch ./server.ts
Download https://deno.land/x/deploy@0.3.0/src/runtime.bundle.js
(Check省略)
Listening on http://0.0.0.0:8080
表示される
❯ curl http://0.0.0.0:8080
Hello World
--watch
しているのでserver.ts
を修正するとrestartする(ブラウザのhot reloadまではされない)
addEventListener("fetch", (event) => {
- const response = new Response("Hello World!", {
+ const response = new Response("Hello Deno Deploy!", {
headers: { "content-type": "text/plain" },
});
event.respondWith(response);
});
❯ curl http://0.0.0.0:8080
Hello Deno Deploy!
velociraptor.yml
にサーバー起動スクリプトを追加
(その他は略)
+ scripts:
+ dev:
+ desc: Starts local server
+ cmd: deployctl run --watch ./server.ts
めっちゃ基本的なハンドリングを設定
0.0.0.0:8080
にアクセスするとHTMLが、0.0.0.0:8080/username
にアクセスするとテキストメッセージが表示される
type=json
パラメータを付けるとJSONが返される
ローレベルだが今回はこれができればOK
function handleRequest(request: Request) {
const { pathname, searchParams } = new URL(request.url);
console.log({ pathname, searchParams });
if (pathname === "/") {
const html = `<html>
<p>Welcome to deno-github-contributions-api!</p>
<p>Access to /[username] to get your contributions graph</p>
</html>`;
return new Response(html, {
headers: { "content-type": "text/html; charset=utf-8" },
});
}
const message = `username: ${pathname.slice(1)}`;
if (searchParams.get("type") === "json") {
return new Response(JSON.stringify({ message }), {
headers: { "content-type": "application/json; charset=utf-8" },
});
}
return new Response(message, {
headers: { "content-type": "text/plain; charset=utf-8" },
});
}
addEventListener("fetch", (event) => {
event.respondWith(handleRequest(event.request));
});
意気揚々とcontributions()
を呼び出そうとしたらエラー
❯ vr dev
Check file:///Users/kawarimidoll/ghq/github.com/kawarimidoll/deno-github-contributions-api/$deno$eval.ts
error: TS2339 [ERROR]: Property 'readFileSync' does not exist on type 'typeof Deno'.
return parse(new TextDecoder("utf-8").decode(Deno.readFileSync(filepath)));
~~~~~~~~~~~~
at https://deno.land/x/dotenv@v2.0.0/mod.ts:76:55
TS2339 [ERROR]: Property 'errors' does not exist on type 'typeof Deno'.
if (e instanceof Deno.errors.NotFound) return {};
~~~~~~
at https://deno.land/x/dotenv@v2.0.0/mod.ts:78:27
Found 2 errors.
dotenv
でエラー…?なぜ
issue上がってた
deployctlにはfsが無いので--env=.env
オプションを指定して環境変数にアクセスする
allow:
- read=.env
- env
- net
+ envFile:
+ - .env
scripts:
dev:
desc: Starts local server
- cmd: deployctl run --watch ./server.ts
+ cmd: deployctl run --watch --env=.env ./server.ts
通常のスクリプトと共存するためにはdeno-dotenvは使えないので自分で実装する
const GITHUB_READ_USER_TOKEN = Deno.env.get("GITHUB_READ_USER_TOKEN") ?? "";
if (!GITHUB_READ_USER_TOKEN) {
throw new Error("No token!");
}
export { GITHUB_READ_USER_TOKEN };
トークン名を4回も書かないといけないのでDRYにしたいと思い、変えてみたけど…
const env: { [key: string]: string } = {};
const envNames = [
"GITHUB_READ_USER_TOKEN",
];
envNames.forEach((envName) => {
const envValue = Deno.env.get(envName);
if (!envValue) {
throw new Error(`No token: ${envName}`);
}
env[envName] = envValue;
});
export default env;
exportされているメンバーをTSコンパイラが認識できないので読み込みのときにいったん変数を介さないといけないのが直感的じゃないかな まあこういう運用にしていけば良いか
- import { GITHUB_READ_USER_TOKEN } from "./env.ts";
+ import env from "./env.ts";
+ const { GITHUB_READ_USER_TOKEN } = env;
もしくは使用箇所で逐一Deno.env.get()
を使ってエラーハンドリングもする