メインコンテンツまでスキップ

· 約9分
森田 有貴

森田です。

現在開発しているプロダクトで BlockNote というリッチテキストエディタを使う機会があったのですが、これがめちゃめちゃ便利だったので今回はその紹介をしたいと思います!

BlockNote とは?

BlockNote は React で Notion のようなブロックベースのテキストエディタを実装できるライブラリです。デフォルトの状態で Notion に引けを取らない UX が設定されており、とても簡単に高クオリティなテキストエディタを実装できます。

https://www.blocknotejs.org/

実装

長々と説明するより実際に見てもらうほうが早いと思うので、早速で実装してみましょう! 今回もNextjsで行きます

サクッとプロジェクトを作成して...

$ npx create-next-app blocknote-app
✔ Would you like to use TypeScript? … No / Yes
✔ Would you like to use ESLint? … No / Yes
✔ Would you like to use Tailwind CSS? … No / Yes
✔ Would you like to use `src/` directory? … No / Yes
✔ Would you like to use App Router? (recommended) … No / Yes
✔ Would you like to customize the default import alias (@/*)? … No / Yes

必要なライブラリをインポート

npm install @blocknote/core @blocknote/react @blocknote/mantine

エディタ用のコンポーネントを作成して...

/src/app/page.tsx
"use client";

import "@blocknote/core/fonts/inter.css";
import { useCreateBlockNote } from "@blocknote/react";
import { BlockNoteView } from "@blocknote/mantine";
import "@blocknote/mantine/style.css";

export const Editor = () => {
const editor = useCreateBlockNote();
return (
<div>
<BlockNoteView editor={editor} />
</div>
);
};

ページに表示するだけ!

/src/app/page.tsx
import { Editor } from "./components/Editor";

const Page = () => {
return (
<>
<Editor />
</>
);
};

export default Page;

これだけで実装完了!

video

とっても簡単にNotionのようなテキストエディタを実装できてしまいました🎉🎉🎉

できること

blocknoteでは文字を入力できるのはもちろん、文字のサイズや色の変更、画像や動画などさまざまなコンテンツを入力表示させることができます。

image1

image2

入力欄の左の+をクリックすると入力できるものリストが表示されます。

基本的にはMarkdown記法と同様に#でHeadingにしたり、 - でリスト表示にしたりという感じで入力できます。また、+の横の点々の部分をクリックすると文字の色や背景色なども選ぶことができます。

画像や動画などはurlを入力することによって表示させることができます。

さらにBlockNoteという名前の通り、入力したコンテンツをブロックとして移動させることも簡単にできてしまいます。

video2

ものすごい機能が充実していますね😲

スタイル変更

スタイルもかなり自由に変更できるようになっています。 いくつか抜粋してご紹介します。

文字サイズ変更

初期設定では文字のサイズが大きすぎるので変更してみましょう。

.editor-custom-style .bn-editor h1 {
font-size: 18px;
}

.editor-custom-style .bn-editor h2 {
font-size: 16px;
}

.editor-custom-style .bn-editor h3 {
font-size: 14px;
}

.editor-custom-style .bn-editor {
font-size: 14px;
}

cssインポートしてクラスを指定すると...

import "./editor.css";

export const Editor = () => {
const editor = useCreateBlockNote();

return (
<div>
<BlockNoteView editor={editor} className="editor-custom-style" />
</div>
);
};

image3

いい感じのサイズに変更できました!

文字サイズ以外にも色々カスタマイズができるようになっています。

テーマ

続いてテーマを編集してましょう! 背景色を赤色にしてみます。

import { BlockNoteView, darkDefaultTheme } from "@blocknote/mantine";
import { Theme } from "@blocknote/mantine";

export const Editor = () => {
const editor = useCreateBlockNote();

const lightRedTheme = {
colors: {
editor: {
background: "red",
},
},
} satisfies Theme;

const redTheme = {
light: lightRedTheme,
dark: darkDefaultTheme,
};

return (
<div>
<BlockNoteView editor={editor} className="editor-custom-style" theme={redTheme} />
</div>
);
};

image4

むちゃくちゃ目に悪そうですね。

みてわかる通りライトテーマとダークテーマそれぞれ設定できるようになっています。 今回はライトテーマのみにカスタムテーマを当てています。

こちらもかなり自由度高くカスタマイズできるようになっています。

詳しくはこちら↓

https://www.blocknotejs.org/docs/styling-theming/themes

言語変更

デフォルトではプレイスホルダーやメニューの部分の言語が英語になっています。 日本語の設定が用意されているので変更してみましょう。

import { locales } from "@blocknote/core";

export const Editor = () => {
const editor = useCreateBlockNote({
dictionary: locales.ja,
});

return (
<div>
<BlockNoteView editor={editor} className="editor-custom-style" />
</div>
);
};

localesをインポートしてlocales.jaを設定するだけ。

image5

特に変な日本語になっている部分もないですね。

おまけ

入力した内容を取得する

textareaタグのonChange={(e) => console.log(e.target.value)}的なことをしてみましょう!

Reactのテキストエディタですからね。内容が取得できてなんぼですからね。

入力された内容はeditor.documentで取得ができます。また、inputやtextareaと同様にonChangeで入力内容の変化を感知できます。

export const Editor = () => {
const editor = useCreateBlockNote();

const onChange = () => {
console.log(editor.document);
};

return (
<div>
<BlockNoteView editor={editor} onChange={onChange} />
</div>
);
};

video3

というわけで入力が取得できました。

ただ、みてわかる通りこの実装方法ではBlockNote特有のBlock型で出力されてしまいます。

なのでマークダウンに変換して出力してみましょう!

blocksToMarkdownLossyを使ってonChangeを以下のように変更します

const onChange = async () => {
const markdown = await editor.blocksToMarkdownLossy(editor.document);
console.log(markdown);
};

video4

マークダウンで出力されるようになりました!

プログラム側から入力する

続いてプログラム側から内容を入力する方法をご紹介します。

こちらはinputやtextareaとは全く異なります。

export const Editor = () => {
const editor = useCreateBlockNote();

const markdown = `# Hello, world!`;

const loadMarkdown = async () => {
const blocks = await editor.tryParseMarkdownToBlocks(markdown);
editor.replaceBlocks(editor.document, blocks);
};

return (
<div>
<BlockNoteView editor={editor} />
<button onClick={() => loadMarkdown()}>読み込む</button>
</div>
);
};

loadMarkdown関数で入力するマークダウンの文字列をBlock型に変換し、editor.replaceBlocksで置き換えているという感じです。 

実際に動かしてみるとこんな感じ。

video5

replace以外にinsertなどさまざまな操作が可能になっています。

詳しくはこちら↓

https://www.blocknotejs.org/docs/editor-api/manipulating-blocks

おわり

今回はBlockNoteをご紹介しました。今回紹介できたのはほんの一部で他にも便利な機能が沢山用意されているので、ぜひ実際に使っていただければと思います!

公式のDiscordサーバーも活発に動いており、日々質問や不具合報告など飛び交っています。わからないことがあればこちらで聞くといいかもしれないですね。

機会があれば実際にBlockNoteを用いた機能開発も紹介したいと思っています!

ではまた。

· 約5分
森田 有貴

森田です。

先日OpenAIのWhisperを用いた文字起こしの実装を紹介しました。ですがこの文字起こしはブラウザ上で音声を録音し、それをapiに投げて文字起こししてもらうという実装でした。

しかしこれには2つの欠点があります。まず全て録音が終了するまでちゃんと文字起こしされるかわからないという点です。もう一つは録音ファイルの容量が大きくなると文字起こしに時間がかかるという点です。

今回はこの2点を解決する方法を試験的に実装できたので、そちらを紹介したいと思います。

やったこと

今回試したのは録音を特定の秒数で区切り、その都度文字起こしをかけるという方法です。 とりあえず実装してみましょう。

実装

warn

前回の記事で作成したwhipserHook.tsをそのまま使用します。

特定の秒数で区切るだけなので、whisperHook.tsにその処理を追加します。

/src/app/utils/whisperHook.ts
import { useEffect, useRef, useState } from "react";

type Hooks = {
startRecording: () => void;
stopRecording: () => void;
isAudio: boolean;
recording: boolean;
audioFile: File | null;
isLoading: boolean;
transcript: string;
};

export const useWhisperHook = (): Hooks => {
const mediaRecorder = useRef<MediaRecorder | null>(null);
const [audioFile, setAudioFile] = useState<File | null>(null);
const [isAudio, setIsAudio] = useState<boolean>(false);
const [recording, setRecording] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [transcript, setTranscript] = useState("")
const intervalRef = useRef<number | null>(null);


const handleDataAvailable = (event: BlobEvent) => {
// 音声ファイル生成
const file = new File([event.data], "audio.mp3", {
type: event.data.type,
lastModified: Date.now(),
});
setAudioFile(file);
};

const startRecording = async () => {
setAudioFile(null)
setRecording(true);
// 録音開始
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
mediaRecorder.current = new MediaRecorder(stream);
mediaRecorder.current.start();
mediaRecorder.current.addEventListener("dataavailable", handleDataAvailable);
setIsAudio(true);

intervalRef.current = window.setInterval(() => {
mediaRecorder.current?.stop();
mediaRecorder.current?.start();
}, 5000); // 5秒ごとに録音を停止して新しい録音を開始
};

const stopRecording = () => {
setRecording(false);
// 録音停止
mediaRecorder.current?.stop();
setIsAudio(false);
};

useEffect(() => {
const uploadAudio = async () => {
if (!audioFile) return;
const endPoint = "https://api.openai.com/v1/audio/transcriptions";

const formData = new FormData();
// fileを指定
formData.append("file", audioFile, "audio.mp3");
// modelを指定
formData.append("model", "whisper-1");
// languageを指定
formData.append("language", "ja");
setIsLoading(true);
const response = await fetch(endPoint, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.NEXT_PUBLIC_OPENAI_API_KEY}`,
},
body: formData,
});
const responseData = await response.json();
if (responseData.text) {
// 文字起こしされたテキスト
setTranscript(prev => prev + "\n" + responseData.text);
}
setIsLoading(false);
};
uploadAudio();
}, [audioFile]);

return {
startRecording,
stopRecording,
isAudio,
recording,
audioFile,
isLoading,
transcript
};
};

42〜45行目の部分で指定した秒数で録音を止めて開始するという処理を挟んでいます。今回は5秒で区切るようにしています。

intervalRef.current = window.setInterval(() => {
mediaRecorder.current?.stop();
mediaRecorder.current?.start();
}, 5000);

これで録音を開始してみましょう。すると...

video

定期的に区切られてリアルタイムっぽく文字起こしされてる!🎉🎉🎉

ちなみにこちらはSREチームリーダーの山田哲さんとの今日の夜ご飯何食べようねという会話を文字起こししました。

というわけで実装できました。

欠点

前回の記事でも述べたとおり、文字起こし可能な音声が録音されていない場合に変な文字列が返ってくるという不具合がかなり影響しまして、区切るタイミングを細かくするほど顕著に現れてしまいます。 また、話の途中で区切られるとうまく文章がつながらないということもあるので、精度的にも少し懸念があります。

終わり

というわけでWhisperの文字起こしをリアルタイムっぽく実装してみました。 改善の余地はたくさんありますが、ひとまず形になったので個人的には満足しています。 whisperのsdkとか出ないかなぁ。

ではまた。

· 約8分
森田 有貴

森田です。 以前 Azure Speech SDK を用いたリアルタイム文字起こしをご紹介しましたが、今回は OpenAi の Whisper を用いた文字起こしをご紹介して実際に実装までしてみたいと思います。 今回も Next js 用います。

実装

Azure の SDK を用いた文字起こしはリアルタイムでの実行が可能でしたが、Whisper では音声ファイルをアップロードし文字起こしされた文字列が返ってくるという形です。 なのでまずは録音機能から実装していきましょう。

録音機能

いつも通りサクッとプロジェクトを作成して...

$ npx create-next-app
✔ What is your project named? … whisper-sample-app
✔ Would you like to use TypeScript? … No / Yes
✔ Would you like to use ESLint? … No / Yes
✔ Would you like to use Tailwind CSS? … No / Yes
✔ Would you like to use `src/` directory? … No / Yes
✔ Would you like to use App Router? (recommended) … No / Yes
✔ Would you like to customize the default import alias (@/*)? … No / Yes

whisperHook を作成しましょう。

/src/app/utils/whisperHook.ts
import { useRef, useState } from "react";

type Hooks = {
startRecording: () => void;
stopRecording: () => void;
isAudio: boolean;
recording: boolean;
audioFile: File | null;
};

export const useWhisperHook = (): Hooks => {
const mediaRecorder = useRef<MediaRecorder | null>(null);
const [audioFile, setAudioFile] = useState<File | null>(null);
const [isAudio, setIsAudio] = useState<boolean>(false);
const [recording, setRecording] = useState(false);

const handleDataAvailable = (event: BlobEvent) => {
// 音声ファイル生成
const file = new File([event.data], "audio.mp3", {
type: event.data.type,
lastModified: Date.now(),
});
setAudioFile(file);
};

const startRecording = async () => {
setAudioFile(null)
setRecording(true);
// 録音開始
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
mediaRecorder.current = new MediaRecorder(stream);
mediaRecorder.current.start();
mediaRecorder.current.addEventListener("dataavailable", handleDataAvailable);
setIsAudio(true);
};

const stopRecording = () => {
setRecording(false);
// 録音停止
mediaRecorder.current?.stop();
setIsAudio(false);
};

return {
startRecording,
stopRecording,
isAudio,
recording,
audioFile
};
};

これでひとまず録音機能は完成です! 実際に使えるか試してみましょう。

適当なページに実装して...

/src/app/page.tsx
"use client";
import { useWhisperHook } from "./utils/whisperHook";

const Page = () => {
const { startRecording, stopRecording, recording, audioFile } = useWhisperHook();
return (
<div>
<button onClick={startRecording}>start</button>
<button onClick={stopRecording}>stop</button>
{audioFile && <audio src={URL.createObjectURL(audioFile)} controls />}
{recording && <div>recording...</div>}
</div>
);
};

export default Page;

スタートボタンとストップボタンだけの簡素な画面になればOK!

image1

スタートボタンを押すと「recording...」と表示されて録音が開始されます。

※マイク権限の許可を求められた場合は許可してあげてください。

image2

ストップボタンを押すと録音が停止されて録音した音声を聴くことができます。

image3

というわけで録音機能の実装は完了です!

文字起こし

では続いて先ほど作成したHookに文字起こしの機能を追加していきいましょう。

/src/app/utils/whisperHook.ts
import { useEffect, useRef, useState } from "react";

type Hooks = {
startRecording: () => void;
stopRecording: () => void;
isAudio: boolean;
recording: boolean;
audioFile: File | null;
isLoading: boolean;
transcript: string;
};

export const useWhisperHook = (): Hooks => {
const mediaRecorder = useRef<MediaRecorder | null>(null);
const [audioFile, setAudioFile] = useState<File | null>(null);
const [isAudio, setIsAudio] = useState<boolean>(false);
const [recording, setRecording] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [transcript, setTranscript] = useState("")

const handleDataAvailable = (event: BlobEvent) => {
// 音声ファイル生成
const file = new File([event.data], "audio.mp3", {
type: event.data.type,
lastModified: Date.now(),
});
setAudioFile(file);
};

const startRecording = async () => {
setAudioFile(null)
setRecording(true);
// 録音開始
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
mediaRecorder.current = new MediaRecorder(stream);
mediaRecorder.current.start();
mediaRecorder.current.addEventListener("dataavailable", handleDataAvailable);
setIsAudio(true);
};

const stopRecording = () => {
setRecording(false);
// 録音停止
mediaRecorder.current?.stop();
setIsAudio(false);
};

useEffect(() => {
const uploadAudio = async () => {
if (!audioFile) return;
const endPoint = "https://api.openai.com/v1/audio/transcriptions";

const formData = new FormData();
// fileを指定
formData.append("file", audioFile, "audio.mp3");
// modelを指定
formData.append("model", "whisper-1");
// languageを指定
formData.append("language", "ja");
setIsLoading(true);
const response = await fetch(endPoint, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.NEXT_PUBLIC_OPENAI_API_KEY}`,
},
body: formData,
});
const responseData = await response.json();
if (responseData.text) {
// 文字起こしされたテキスト
setTranscript(responseData.text);
}
setIsLoading(false);
};
uploadAudio();
}, [audioFile]);

return {
startRecording,
stopRecording,
isAudio,
recording,
audioFile,
isLoading,
transcript
};
};

useEffectの部分がWhisperを用いた文字起こしの機能になります。

忘れずにOpenAIのAPIキーも環境変数に設定しておきましょう。

/.env.local
NEXT_PUBLIC_OPENAI_API_KEY=

page.tsxの方も編集。

/src/app/page.tsx
"use client";
import { useWhisperHook } from "./utils/whisperHook";

const Page = () => {
const { startRecording, stopRecording, recording, audioFile, isLoading, transcript } = useWhisperHook();

return (
<div>
<button onClick={startRecording}>start</button>
<button onClick={stopRecording}>stop</button>
{audioFile && <audio src={URL.createObjectURL(audioFile)} controls />}
{recording && <div>recording...</div>}
{isLoading && <div>loading...</div>}
{transcript && <div>{transcript}</div>}
</div>
);
};

export default Page;

というわけで完成!

image4

ページ自体は先ほどと変わりませんが、録音してみると...

image5

「loading...」と表示され...

image6

文字起こしされた文章が表示されました!🎉🎉🎉🎉🎉🎉

というわけでWhisperを用いた文字起こし機能完成です!

欠点

ここまで紹介しておいて何なのですが、実はwhipserには重大な欠点があります。 それは会話が記録されていない音声ファイルを投げると変な文字列が返ってくるという点です。

実際に試していただくけるとわかると思うのですが、5秒ほどの特に会話など文字起こしする内容のない音声を録音して文字起こしをかけると「本日はご覧いただきありがとうございます。」や「ご視聴ありがとうございました。」などよくわからない文字列が返ってきてしまいます。

apiに投げる前に無音区間を削除する処理などをかませると改善するのかなぁなどと対策を考えている今日この頃です。

Whisperで文字起こしをする際には頭の片隅に覚えておいてください。

終わり

今回はNextjsでWhisperを用いた文字起こしの実装を紹介しました。

ここまで書いて思ったのですがこれNextjsでなくてReactで良かったですね。

Whisperの文字起こしの精度についてですが、他のサービスと比べてもかなり良い方だと思います。ただ複数人で話している状況ではもう一歩かなという感じもします。 先ほどあげた欠点のこともあるので、今後に期待!というところですね。

ではまた!

· 約4分
山田 哲也

こんにちは!最近 Terraform にハマっている山田です。

Terraform を使っていて、レシピ集的なものを作ったら結構需要がありそうだなと思ったので、早速記事にしてみることにしました。

今回は、AWS Systems Manager のセッションマネージャーを使ってログイン可能な EC2 インスタンスを作成する Terraform のレシピを紹介したいと思います!

前提知識

セッションマネージャーとは

セッションマネージャーは AWS Systems Manager の機能の一つで、EC2 インスタンスに対して SSH などのポートを開けずにブラウザや AWS CLI からインスタンスにログインできる機能です。

SSH の場合ポート以外にもキーペアの管理やセキュリティグループの設定が必要ですが、セッションマネージャーを使うことでこれらの設定を省略することができて非常に便利です。また、よりセキュアに EC2 を利用することができるようになります。

AWS CLI では以下のようなコマンドを実行することで EC2 にログインすることができます。

aws ssm start-session --target <インスタンスID>

Terraform コード

プロバイダーの設定

terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 4.16"
}
}
}

provider "aws" {
region = "ap-northeast-1"
}

IAM ロールとインスタンスプロファイルの定義

ここでは IAM ロールとインスタンスプロファイルを定義しています。

ポイントは AmazonSSMManagedInstanceCore ポリシーをアタッチすることで、これによってセッションマネージャーを利用することができるようになります。

resource "aws_iam_role" "ec2_role" {
name = "sample-role"
assume_role_policy = jsonencode({
Version = "2012-10-17",
Statement = [
{
Effect = "Allow",
Principal = {
Service = "ec2.amazonaws.com"
},
Action = "sts:AssumeRole"
}
]
})
}

resource "aws_iam_role_policy_attachment" "ec2_role_policy_attach" {
role = aws_iam_role.ec2_role.name
policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}

resource "aws_iam_instance_profile" "ec2_instance_profile" {
name = "sample-profile"
role = aws_iam_role.ec2_role.name
}

EC2 インスタンスの定義

iam_instance_profile に先ほどのインスタンスプロファイルを割り当てることで、セッションマネージャーを使ってログイン可能なインスタンスを作成することができます。

resource "aws_instance" "example" {
ami = "ami-0f36779931e4e31ce"
instance_type = "t4g.medium"
iam_instance_profile = aws_iam_instance_profile.ec2_instance_profile.name

tags = {
Name = "ec2-ssm-sample"
}
}

アウトプットの設定

アウトプットでインスタンス ID を出力しておくと、terraform apply 後にインスタンス ID がコンソールに表示されるので AWS CLI でログインする際に便利です。

output "instance_id" {
value = aws_instance.example.id
}

これでコードの作成は完了です!

terraform の実行とログイン

最後に terraform apply を実行して EC2 インスタンスを作成したら、以下のコマンドでログインできるか確認してみてください。

aws ssm start-session --target <インスタンスID>

今回は内容は以上となります。次回もお楽しみに!

· 約12分
森田 有貴

今年の4月にリーディングマークに入社しもうすぐ3ヶ月が経とうとしています。初めての社会人は刺激的で、学びが多い日々を過ごしています。 今回はこの3ヶ月を振り返り、感じたことや学んだことを共有し、すこしお話的な記事をお届けします。

携わったプロジェクト

組織心理研究所サイト

組織心理研究所

4月初旬ごろまで我が社が誇る組織心理研究所のサイトの実装をさせていただきました。 実は内定者インターンの時期から携わっていたプロジェクトで、3月ごろから開発自体には携わっていました。

記事を閲覧できるページなどもあるのですが、基盤的な部分はすでに櫻田さんが実装されていて、私が担当したのはfigmaに作成されたデザインのフロントへの実装でした。

実際に開発するなかで、細かいアニメーション、パララックス、レスポンシブ対応、などなどかなり多くの課題に直面しました。 というのも今までは便利な機能を持ったウェブアプリの開発することに重点を置いていたことが多く、デザイン的によく見えるものを作ることにあまり重きを置いてきませんでした。

デザイナーさんとできるだけコミュニケーションを取り、組織心理研究所の方々の思いやデザイナーさん方のこだわりがふんだんに詰まったサイトをリリースまでやり切ることができたことは、エンジニアとして何よりもの喜びですし、今後の開発にも活きるのではないかなと思っています。

ちなみにこのプロジェクトの一員として5月に月次アワードをいただくことができました🎉

新規事業

組織心理研究所サイトの実装が終了した後、新規事業のリードエンジニアを担当させていただくことになりました。 元々、事業を立ち上げる0 -> 1の段階の開発を経験したいと言う要望を出しており、その要望が通った結果だったので、ものすごくやる気に満ち溢れていました。 新規事業とはいっても、私が参画した際はまだ決まっていることが少なく、基本的には機能検証のためのMVPの開発をしつつ本開発に向けた検証をしていくことになりました。

MVPなので製品としてリリースする際に使う使わないに関係なく、とにかく使えそうなものを片っ端から実装していくという感じで、ビジネスサイドで提案されたものをなるべく早く使える形にするというスピード感を持った開発をしていました。 決まっていないことが少ないことが逆に自由度の高い開発に繋がったので、そこは個人的にはいい環境でした。

約1ヶ月半の開発期間を経て、無事にMVPの第1弾をリリースすることができました。

ちなみにこのMVPの開発で6月にまたしても月次アワードをいただくことができました🎉

苦難と学び

今となって振り返ってみるとMVPの開発は想像を絶する大変さでした。自由度が高い環境だったことは良かったのですが、技術的や精神的に困難に直面することが数え切れないほど多くありました。何度心折れたことか。

人を頼るということ

一番苦しかったことは何よりも開発メンバーが私1人であったことです。何かわからないことがあっても他チームに気軽聞きにいけない、自分が開発しなければ新規事業自体が頓挫してしまう。そんなことを考えながら開発をしていました。

ある日このことを上長さんに相談したところ「自分が他のチームの人から相談を受けたらどう思う?」と問いかけられその瞬間に考えが全く変わりました。自分が頼られたら嬉しいなと思ったからです。

この日を境に他のチームの方にも良く相談を持ちかけるようになりました。実際相談に乗っていただくことによって解決できた問題もあったので、私が勝手にプレッシャーを感じて背負い込んでいただけでした。

無駄を減らすということ

もう一つ技術選定についても学びがありました。何か一つの機能を実装する際にどのライブラリやSSOを使うべきか、どれがパフォーマンス的に最適なのか、コスト的にはどうなのかなどもっとよく考えるべきだったなということがいくつかありました。 全てを実装して実際に使ってみるというのも一つの手ではあるのですが、時間は有限です。結局かなり手戻りが発生してしまったのでこの辺りは今後に活かしていきたいですね。

反省反省。

実装すること自体の技術的困難も数多くあったのですが、それはまた今度お話ししましょう。

これからエンジニアになる方へ

所詮3ヶ月しかエンジニアしてない私がそんなに体逸れたことも言えないのですが、今後新卒でエンジニアになった方が苦難に直面した際に役に立って欲しいアドバイスを少しだけ書きます。

信頼できる人を作る

仕事の同期や仕事とは関係ない友達など誰でもいいので信頼できる人を確保しておくことは重要だなと思います。というのも仕事の愚痴や悩みなどを心理的安全に話せるからです。 普段のイライラやモヤモヤは言葉にして吐き出すとものすごいスッキリします。本音で話すことができれば自分では思いつかなかった解決策が出てくるかもしれないですしね。

人の半分はバナナと同じ

人のDNAの半分はバナナと同じらしいですよ。人に何を言われようと強い言葉を使われようとも結局半分バナナに言われていることと思えば少し気が楽になる気がします。 自分がミスをした時も「どうせ自分半分バナナだし」と考えられれば前向きになれるような気もしますよね。 気軽に物事を捉えることで躓いた時にその場に止まらずに前進し続けられるのではないでしょうか。 自分が些細なことでも結構落ち込んでしまう性格なので最近はそう思うことでなんとか頑張っています。

大体なんとかなる

これは社会人として出なくても何にでも言えることなのですが、どんなに大変な困難に直面したとしても大体なんとかなります。なんとかなると言うよりは大変な時期はずっと続くものではないです。 想像を絶するほどの困難に直面し、時間が永遠に感じられるようなことが多々あるとはあ思うのですが、結局いつか終わります。しかも案外すぐ終わることが多いような気がします。 なので何かあってもそんなに重く考えず気楽にいきましょう。

おわり

入社してからの3ヶ月をサクッと振り返ってみました。書き出してみると書きたいことが山ほどあり、かなりいろいろあった3ヶ月だったんだなと改めて思いました。 今後また楽しいこともあれば嫌なことも経験すると思うので、楽しみながら成長していければなと思います。

では。

· 約5分
森田 有貴

唐突ですが皆様Slackはお使いでしょうか?Slack便利ですよね。私は業務のコミュニケーションは基本Slackで行っています。

このSlackで定期的にリマインドを送ってくれる機能があったら便利だと思いませんか?例えば朝の業務前に昨日やったことを送ってくれたりだとか、今日の会議の一覧を送ってくれたりなどなど...

というわけでNextjsのApi RoutesとVercelのCron Jobsを用いて簡単に実装できたので、ご紹介いたします。

warn

今回はすでにNextjsをVercelにデプロイしていることを前提とします。


Slack Api Appの準備

まずSlackのApiを利用してリマインドを送ってくれるアプリを作成します。

こちらにアクセス -> Your Apps - Slack API

Create New Appをクリック image2

From scratchをクリック image3

ここでリマインドを送ってくれるbotの名前とそのbotを使うワークスペースを設定できます。 botの名前は後からでも変更です。 image7

続いてOAuth & Permissionsからbotの権限を設定しましょう。 今回の場合はchat:writeim:writeを設定しておけば十分です。 image5

App HomeからDefault usernameを設定しておきます。 こちらを設定しないとワークスペースにインストールできないみたいです。 image8

もろもろの設定が完了したらInstallAppからワークスペースにアプリをインストールしましょう。 image6

許可するをクリック image9

これでSlackの方の設定は完了です!

OAuth & Permissionsからトークンを取得しておきましょう。

image12


Nextjs Api RoutesからSlack Apiを叩いてメッセージを送る

まず自分のslackのuser idを取得します。

左下のアイコンからプロフィールを表示 image15

三点リーダーボタンから「メンバーIDをコピー」をクリック image16 これで取得完了です。

環境変数を設定しておきましょう。

/sample-app
touch .env.local

先ほどSlack Apiで取得したトークンとuser idを貼り付けます。

/sample-app/.env.local
SLACK_BOT_TOKEN=
SLACK_CHANNEL_ID=

続いて定期実行するapiにSlackのApiを叩いてメッセージを送る機能を作成します。

/src/app/api/cron/route.ts
import { NextResponse } from "next/server";

export async function GET(request: Request) {
const slackToken = process.env.SLACK_BOT_TOKEN;

const response = await fetch("https://slack.com/api/chat.postMessage", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${slackToken}`,
},
body: JSON.stringify({
channel: process.env.SLACK_CHANNEL_ID,
text: "hoge",
}),
});

if (response.ok) {
return NextResponse.json({ message: "Success" });
} else {
return NextResponse.json({ message: "Failed" });
}
}

これで/api/cronを叩くとメッセージが飛んでくるようになります image13

結構簡単ですね


VercelのCron Jobsの設定

最後に定期実行の設定をします。 まずvercel.jsonを作成します。

/sample-app
touch vercel.json

作成したvercel.jsonに定期実行するapiのサブディレクトリと実行タイミングを設定します。 今回は毎日UTSで0時(JST9時)に実行されるように設定しました。

/sample-app/verce.json
{
"crons": [
{
"path": "/api/cron",
"schedule": "0 0 * * *"
}
]
}

こちらを設定してデプロイするとVercelのSettingsのCron Jobsに設定が反映されると思います。 image17

あとは次の日の朝9時まで待つだけです。


おわり

というわけでNextjsをVercelでSlackから定期的にリマインドを送ってくれる機能を作成しました。

ちなみに私は「次のMTGの3日前になったら前回のMTGの内容をリマインドしてくれる」という機能を作成する際に使いました。

使い方次第でもっと便利な機能が作れそうな気がしますね。

おしまい。

· 約6分
森田 有貴

最近、業務でリアルタイム文字起こし実装する機会がありまして、Nextjs と Azure の Speech SDK で実装したのでその知見を書いていこうと思います。

https://learn.microsoft.com/ja-jp/azure/ai-services/speech-service/speech-sdk

Deepgram や Open AI の Whisper、文字おこしを実装するための API や SDK は様々あります。 今回 Azure Speech SDK を選んだ理由は大きく分けて2つあり、「精度の高さ」と「話者認識」です。

Azure Speech SDK はかなり感動するレベルで高精度かつ高感度な文字起こしをしてくれます。また、公式ドキュメントには話者分離の機能についても書いてあり、これも業務上使いたい機能でした。

ちなみにDeepgramや、Whisperなどの選択肢もあったのですが、精度や話者分離の機能などの観点から見送ることになりました。

実装

APIキーの準備

まず Azure の API キーを準備しましょう。

Azure のサービスページからリソースの作成 image1

リソースの検索欄から Speech と入力 image2

こちらを選択 image3

プランを選択して作成 image4

適当に入力して image5

完了。リソースに移動して image6

キーの管理から image7

「キー1」と「場所/地域」を後で使うので保存しておきましょう。 image8

Nextjs の実装

フロントを作っていきましょう。

新しい Nextjs プロジェクトの作成

terminal
$ npx create-next-app
✔ What is your project named? … real-time-transcription
✔ Would you like to use TypeScript? … Yes
✔ Would you like to use ESLint? … Yes
✔ Would you like to use Tailwind CSS? … Yes
✔ Would you like to use `src/` directory? … Yes
✔ Would you like to use App Router? (recommended) … Yes
✔ Would you like to customize the default import alias (@/*)? … Yes
✔ What import alias would you like configured? … @/*
/real-time-transcription
$ npm i && npm run dev

image9

必要なライブラリのインストール

/real-time-transcription
$ npm i microsoft-cognitiveservices-speech-sdk

環境変数の設定

/real-time-transcription
$ vim .env.local

ここで先ほど取得した API キーを入力します。

/real-time-transcription/.env.local
NEXT_PUBLIC_AZURE_SPEECH_KEY="api key"
NEXT_PUBLIC_AZURE_SPEECH_REGION="region"

これで準備完了です。 では実際に作っていきましょう。


まず不要なものを削除していきます。

/src/app/layout.tsx
import { Inter } from "next/font/google";

const inter = Inter({ subsets: ["latin"] });

export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className={inter.className}>{children}</body>
</html>
);
}
/src/app/page.tsx
export default function Page() {
return <div>page</div>;
}

以下のようになれば OK image10


文字起こしコンポーネントの作成

新たに AzureSpeech コンポーネントを作成します。

/src/components/AzureSpeech.tsx
"use client";

import React, { useState, useEffect, useRef } from "react";
import * as sdk from "microsoft-cognitiveservices-speech-sdk";

const SPEECH_KEY = process.env.NEXT_PUBLIC_AZURE_SPEECH_KEY ?? "";
const SPEECH_REGION = process.env.NEXT_PUBLIC_AZURE_SPEECH_REGION ?? "";

export const AzureSpeech = () => {
const speechConfig = useRef<sdk.SpeechConfig | null>(null);
const audioConfig = useRef<sdk.AudioConfig | null>(null);
const recognizer = useRef<sdk.SpeechRecognizer | null>(null);

const [myTranscript, setMyTranscript] = useState("");
const [recognizingTranscript, setRecTranscript] = useState("");

useEffect(() => {
if (!SPEECH_KEY || !SPEECH_REGION) {
console.error("Speech key and region must be provided.");
return;
}

speechConfig.current = sdk.SpeechConfig.fromSubscription(
SPEECH_KEY,
SPEECH_REGION
);
speechConfig.current.speechRecognitionLanguage = "ja-JP";

audioConfig.current = sdk.AudioConfig.fromDefaultMicrophoneInput();
recognizer.current = new sdk.SpeechRecognizer(
speechConfig.current,
audioConfig.current
);

const processRecognizedTranscript = (
event: sdk.SpeechRecognitionEventArgs
) => {
const result = event.result;

if (result.reason === sdk.ResultReason.RecognizedSpeech) {
const transcript = result.text;
setMyTranscript(transcript);
}
};

const processRecognizingTranscript = (
event: sdk.SpeechRecognitionEventArgs
) => {
const result = event.result;
if (result.reason === sdk.ResultReason.RecognizingSpeech) {
const transcript = result.text;
setRecTranscript(transcript);
}
};

if (recognizer.current) {
recognizer.current.recognized = (
s: sdk.Recognizer,
e: sdk.SpeechRecognitionEventArgs
) => processRecognizedTranscript(e);
recognizer.current.recognizing = (
s: sdk.Recognizer,
e: sdk.SpeechRecognitionEventArgs
) => processRecognizingTranscript(e);
}
}, []);

return (
<div className="mt-8">
<div>
<div>Recognizing Transcript: {recognizingTranscript}</div>
<div>Recognized Transcript: {myTranscript}</div>
</div>
</div>
);
};

page.tsx をに AzureSpeech コンポーネントをインポートして表示します。

/src/app/page.tsx
src/app/page.tsx

import { AzureSpeech } from "./components/AzureSpeech";

export default function Page() {
return (
<div>
<AzureSpeech />
</div>
);
}

これで完成! ブラウザの設定からマイクとスピーカーを許可すると文字起こしが開始されます。 image11

Recognizing Transcript は文字起こし中の文字列が出力され、Recognized Transcript は文字起こしの内容が一区切りされたところで文を整形して出力してくれます。

ということで Azure Speech を用いた Nextjs での文字起こしの実装でした。


おまけ

冒頭で Azure Speech を選んだ理由として話者認識ができるという点を挙げました。 しかし公式ドキュメントをよく読んでみるとこんな記載が... image12

話者認識は申請をしないとできないみたいです。

というわけでワクワクドキドキで申請をしたのですが、見事に却下されました()

結局話者認識は出来ずじまいでした。

公式ドキュメントはちゃんと読みましょうね😢


おしまい。

· 約7分
矢代 祥一

こんにちは。株式会社リーディングマークエンジニアの矢代と申します。

RailsのFactoryBotっていいですよね!私は1年半ほどしかRailsの実務経験はありませんが、DjangoのFactory使ってみたり、Djangoの開発をしていると、ああ、Railsの引いてくれていたレールに脳死で乗ってしまっていた部分もあったんだなあと日々、開発者として未熟な自分を痛感する日々であります。

Leadingmarkでの勤務が1年ほどに達してDjangoでもしっかりとテストを書くようになってきまして、今回Fakerの使い方で面白い点があったので記事を書きました。

動作環境

  • MacBook Pro 13-inch, M1, 2020
  • Python 3.12
  • Django 4.2
  • factory-boy 3.3.0
  • Faker 25.2.0

今回例で使う3つのモデルとその関係

ER図

まずは書いてみた

DjangoにもRailsみたいなFactoryある!となって最初に書いたFactoryはこちら

from factory.django import DjangoModelFactory
from faker import Faker
fake = Faker('ja-JP')

class ClientFactory(DjangoModelFactory):
class Meta:
model = 'myapp.Client'

code = str(fake.random_int(min=0, max=9999)).zfill(4)
name = fake.romanized_name()
address = fake.address()

この方法のFakerの落とし穴

実際に呼び出してみるとわかる。特に着目していただきたいのが、ユニークにしたい部分があるのですが、2回作成しようとするとエラーになります

root@29ac6390e9d2:/code# python project/manage.py shell
Python 3.12.3 (main, Apr 10 2024, 11:26:46) [GCC 12.2.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
(InteractiveConsole)
>>> from myapp.factory import ClientFactory
>>> client_1 = ClientFactory()
>>> client_2 = ClientFactory()
Traceback (most recent call last):

~~~

django.db.utils.IntegrityError: (1062, "Duplicate entry '7878' for key 'myapp_client.PRIMARY'")

なぜ主キーの重複が起きるのか

これはPython特有?なのかわかりませんが、クラス直下にカラム名を記述して値を代入すると、みた感じ作成はできる(client_1 = ClientFactory()は通っているため)のですが、読み込み時に値が決定する方式らしく、読み込み時に「ABC」になったら「ABC」のまま2回目も作成しようとするのです。

一意にしたい顧客コードなのにFactoryの時点でこれはダメですね…

解決策

技術ブログなどを漁ってみたところ、LazyAttribute使えよBro!と書いてあったと思うので使ってみます。

class ClientFactory(DjangoModelFactory):
class Meta:
model = 'myapp.Client'

code = factory.LazyAttribute(lambda o: f'{fake.random_int(min=0, max=9999):04}')
name = factory.LazyAttribute(lambda o: f'{fake.name()}')
address = factory.LazyAttribute(lambda o: f'{fake.address()}')

ちゃんと作成できた

root@29ac6390e9d2:/code# python project/manage.py shell
Python 3.12.3 (main, Apr 10 2024, 11:26:46) [GCC 12.2.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
(InteractiveConsole)
>>> from myapp.factory import ClientFactory
>>> client_1 = ClientFactory()
>>> client_2 = ClientFactory()
>>> client_1.number
'6994'
>>> client_2.number
'1672'
info

後学のために公式ドキュメントを探し当てたので置いておきます https://factoryboy.readthedocs.io/en/stable/reference.html#lazyattribute

そういえば、0埋めのzfillってこういう書き方(:04)もできたなあと思い出した

この方法の所感

  • 正直、何個も関数に囲まれるのはちょっと…
  • 超細かいですがLazyAttribute始まる地点が違くてちょっと読みづらい…?
  • え、lambda使わないといけないんすか…

代替案として、Pythonのdict(辞書)にあるsetdefaultが便利でした

ここでRailsとは違った自由度の高いソリューションが見つかった(ソリューションって言ってみたかっただけ) 表題にあるとおりの関数でFactoryを作ってみると、なんと、Fakerの呼び出しにLazyAttributeがなくても、都度呼び出して値をセットしてくれるのです

from factory.django import DjangoModelFactory
import factory
from faker import Faker
fake = Faker('ja-JP')

class ClientFactory(DjangoModelFactory):
class Meta:
model = 'myapp.Client'

@classmethod
def _create(cls, model_class, *args, **kwargs):
kwargs.setdefault('code', f'{fake.random_int(min=0, max=9999):04}')
kwargs.setdefault('name', f'{fake.name()}')
kwargs.setdefault('address', f'{fake.address()}')

return super()._create(model_class, *args, **kwargs)

実行結果(ほぼ同じにはなりますが)

root@29ac6390e9d2:/code# python project/manage.py shell
Python 3.12.3 (main, Apr 10 2024, 11:26:46) [GCC 12.2.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
(InteractiveConsole)
>>> from myapp.factory import ClientFactory
>>> client_1 = ClientFactory.create()
>>> client_2 = ClientFactory.create()
>>> client_1.number
'9091'
>>> client_2.number
'3501'

setdefaultは、辞書の中にそのキーがなければ、第2引数のものを使うというメソッドで、都度呼び出しをしてくれます。 この方法なら、クラス直下宣言していたカラムたちを持ってきても特に違和感がない。というかぶっちゃけこの書き方は割と好きなだけかもしれません。 これでこのFactoryクラスは、RailsのFactoryBotに大きく近づいたのです

最後に

まだまだDjangoは修行中。書くのはノイズかな〜と思ったりした事項もあったので、その点は勉強会などでLTにしようと思います。 しばらくはDjangoのテスト関連の記事を書くと思うので、よろしければ、いいねやシェアで応援していただけると嬉しいです。 最後まで読んでいただきありがとうございました。

· 約3分
山田 哲也

こんにちは!山田です。

年度末も終わりますがみなさんはこの一年いかがでしたか?

自分自身を振り返ってみると、今年度は SRE チームを立ち上げや MVP 開発、初めてのメンバーマネジメントなど新しいことに挑戦することが多かった年になりました。

新しいことにチャレンジすることが好きなタイプの人間なので、とても充実した一年だったなと感じています。

ただ、反省点も多く残った一年でもありました。

例えば MVP 開発では成果物となるアウトプットは出せたものの、自社の MVP 開発のプロセスを標準化することまではできませんでしたし、メンバーのマネジメントに関しても、信頼関係の構築や適切なフィードバックなど先輩たちと比べて自分はまだまだできていないことがあるなと感じました。

他にもたくさんありますが、この一年で浮き彫りになった反省点を改善しながら来年度も突っ走って行けたらと思っています。

来年度も忙しい一年になりそうですが、人々が自己実現できる社会を作り上げるために、自分ができることを精一杯やっていきたいと思います。

本年度もお世話になりました。来年度もよろしくお願いいたします!

· 約13分
櫻田 亮太

はじめに

はじめまして。 新規事業開発チームのエンジニア櫻田です。
私たちのチームではGitHub ActionsとGitHub apiを活用して色んな作業を自動化しているので少しずつ紹介していきたいと思います。
今回はプルリクエスト開設時にレビュワーを自動設定する方法を紹介します。
初回なので構文の解説も行なっていきます!

実現すること

  • プルリクエスト開設時にレビュワーを自動設定する
  • チームメンバーが変わってもコードを変更せずに対応する

急いでいる人向け 完成ファイル

name: PR Reviewer Auto Assignment

on:
pull_request:
types: [opened, reopened, ready_for_review]

jobs:
reviewer-assign:
if: |
# お好きな条件分を設定ください
github.event.pull_request.base.ref != 'main' && startsWith(github.head_ref, 'feature')
runs-on: ubuntu-latest
env:
API_BASE_URL: "https://api.github.com"
ACCEPT_HEADER: "Accept: application/vnd.github+json"
VERSION_HEADER: "X-GitHub-Api-Version: 2022-11-28"
AUTH_HEADER: "Authorization: Bearer ${{ secrets.PULL_REQUEST_API_TOKEN }}"
ORG: "organization" # githubの組織名を設定してください
TEAM: "team" # レビュワーに設定したいチーム名を設定してください
steps:
- name: Get Members List
id: get-members-list
run: |
response=$(curl -X GET \
-H "$ACCEPT_HEADER" \
-H "$VERSION_HEADER" \
-H "$AUTH_HEADER" \
$API_BASE_URL/orgs/$ORG/teams/$TEAM/members)
members=$(echo $response | jq '[.[].login | select(. != "${{ github.actor }}")]')
echo ::set-output name=members::$members

- name: Set Reviewers
id: set-reviewers
run: |
reviewers=$(curl -X GET \
-H "$ACCEPT_HEADER" \
-H "$VERSION_HEADER" \
-H "$AUTH_HEADER" \
$API_BASE_URL/repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/requested_reviewers)
reviewr_count=$(echo $reviewers | jq '.users | length')

if [ $reviewr_count -eq 0 ]; then
curl -X POST \
-H "$ACCEPT_HEADER" \
-H "$VERSION_HEADER" \
-H "$AUTH_HEADER" \
$API_BASE_URL/repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/requested_reviewers \
-d '{ "reviewers": ${{ steps.get-members-list.outputs.members }} }'
fi

ワークフローの設定

.github/workflows 配下にymlファイルを作成します。
まずはnameとonまで記載します。

name: PR Reviewer Auto Assignment

on:
pull_request:
types: [opened, reopened, ready_for_review]

公式ドキュメント

name

ワークフローの名前
リポジトリの[アクション]タブに表示される。
省略するとファイルのパスになる

on

ワークフロー実行のトリガーとなるイベントを定義する。
設定できるイベント一覧
今回はプルリクエストを開設した時に実行したいのでpull_requestを設定しています。
types のデフォルトは[ opened(PRオープン), synchronize(PRオープン後push), reopened(PR再オープン) ] ですが今回はdraftで開設後readyにした場合も実行したい&PRオープン後pushでは実行したくない為明示的に指定しています。




続いてjobsを設定していきます。

jobs:
reviewer-assign:
if: |
github.event.pull_request.base.ref != 'main' && startsWith(github.head_ref, 'feature')
runs-on: ubuntu-latest
env:
API_BASE_URL: "https://api.github.com"
ACCEPT_HEADER: "Accept: application/vnd.github+json"
VERSION_HEADER: "X-GitHub-Api-Version: 2022-11-28"
AUTH_HEADER: "Authorization: Bearer ${{ secrets.PULL_REQUEST_API_TOKEN }}"
ORG: "organization" # githubの組織名を設定してください
TEAM: "team" # レビュワーに設定したいチーム名を設定してください

jobs

ワークフローで実行される一連の処理
必ず一つは設定する必要があり複数作成することもできる。

jobs.id

ジョブの一意の識別子
今回はreviewer-assign とします。

jobs.id.name

ジョブに名前を分かりやすいつけることができる。
省略すると実行時にはidが表示されます。今回は省略しています。

jobs.id.if

条件文を使って、条件が満たされなければジョブを実行しないようにできる。
設定できる条件
今回はbaseブランチがmain以外かつheadブランチがfeatureから始まるブランチのみに限定する条件式を書いています。
startsWithなどを使用すれば部分一致なども実現することができます。
使用できる演算子やリテラル
また、ワークフローの中では様々な情報を取得することができます。(今回のbaseブランチ名やheadブランチ名など)頻繁に使用するので知っておくと便利です。
取得できるコンテキスト

jobs.id.runs-on

ジョブを実行するマシンの種類を定義できる。
今回はubuntu を指定しています。

jobs.id.env

ジョブ中のすべてのステップで使うことができる変数を設定できる。
今回は以下を設定しています。

  • API_BASE_URL
    • github apiのurl
  • ACCEPT_HEADER
  • VERSION_HEADER
  • AUTH_HEADER
    • 共通のヘッダー。${{ secrets.PULL_REQUEST_API_TOKEN }}の解説は後で行います
  • ORG
    • 所属している組織名
  • TEAM
    • レビュワーに設定したいチーム名。チームの設定の解説は後で行います。



続いてstepsを設定していきます。

    steps: 
- name: Get Members List
id: get-members-list
run: |
response=$(curl -X GET \
-H "$ACCEPT_HEADER" \
-H "$VERSION_HEADER" \
-H "$AUTH_HEADER" \
$API_BASE_URL/orgs/$ORG/teams/$TEAM/members)
members=$(echo $response | jq '[.[].login | select(. != "${{ github.actor }}")]')
echo ::set-output name=members::$members

- name: Set Reviewers
id: set-reviewers
run: |
reviewers=$(curl -X GET \
-H "$ACCEPT_HEADER" \
-H "$VERSION_HEADER" \
-H "$AUTH_HEADER" \
$API_BASE_URL/repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/requested_reviewers)
reviewr_count=$(echo $reviewers | jq '.users | length')

if [ $reviewr_count -eq 0 ]; then
curl -X POST \
-H "$ACCEPT_HEADER" \
-H "$VERSION_HEADER" \
-H "$AUTH_HEADER" \
$API_BASE_URL/repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/requested_reviewers \
-d '{ "reviewers": ${{ steps.get-members-list.outputs.members }} }'
fi

jobs.id.steps

ジョブで実行される一連のタスクを定義できる。
複数設定することもできる。
様々な設定を行えます。公式ドキュメント

Get Members List

stepsの中のGet Members Listについて解説していきます。
run内の以下の部分でgithub apiを使用してチームメンバーを取得しています。
api ドキュメント

response=$(curl -X GET \
-H "$ACCEPT_HEADER" \
-H "$VERSION_HEADER" \
-H "$AUTH_HEADER" \
$API_BASE_URL/orgs/$ORG/teams/$TEAM/members)

そして以下の部分でjqを使用しresponseからメンバーのusernameを整形し取り出しています。その中でpr開設者は省いています。

members=$(echo $response | jq '[.[].login | select(. != "${{ github.actor }}")]')

最後に以下の部分でメンバーのusername配列をoutputしています。outputすることで次のステップで値を参照できるようになります。

echo ::set-output name=members::$members

Set Reviewers

最後になりますがstepsの中のSet Reviewersについて解説していきます。
以下の部分で現在のレビュワーに設定されている人数を取得しています。たまに手動で設定したい場合があるので既に設定されている場合は自動設定をスキップする為に取得しています。
api ドキュメント

reviewers=$(curl -X GET \
-H "$ACCEPT_HEADER" \
-H "$VERSION_HEADER" \
-H "$AUTH_HEADER" \
$API_BASE_URL/repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/requested_reviewers)
reviewr_count=$(echo $reviewers | jq '.users | length')

以下の部分でレビュワーを設定しています。上に書いたように一人以上レビュワーが設定済みならスキップするようにしています。

if [ $reviewr_count  -eq 0 ]; then
curl -X POST \
-H "$ACCEPT_HEADER" \
-H "$VERSION_HEADER" \
-H "$AUTH_HEADER" \
$API_BASE_URL/repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/requested_reviewers \
-d '{ "reviewers": ${{ steps.get-members-list.outputs.members }} }'
fi

やっと完成しました!

name: PR Reviewer Auto Assignment

on:
pull_request:
types: [opened, reopened, ready_for_review]

jobs:
reviewer-assign:
if: |
# お好きな条件分を設定ください
github.event.pull_request.base.ref != 'main' && startsWith(github.head_ref, 'feature')
runs-on: ubuntu-latest
env:
API_BASE_URL: "https://api.github.com"
ACCEPT_HEADER: "Accept: application/vnd.github+json"
VERSION_HEADER: "X-GitHub-Api-Version: 2022-11-28"
AUTH_HEADER: "Authorization: Bearer ${{ secrets.PULL_REQUEST_API_TOKEN }}"
ORG: "organization" # githubの組織名を設定してください
TEAM: "team" # レビュワーに設定したいチーム名を設定してください
steps:
- name: Get Members List
id: get-members-list
run: |
response=$(curl -X GET \
-H "$ACCEPT_HEADER" \
-H "$VERSION_HEADER" \
-H "$AUTH_HEADER" \
$API_BASE_URL/orgs/$ORG/teams/$TEAM/members)
members=$(echo $response | jq '[.[].login | select(. != "${{ github.actor }}")]')
echo ::set-output name=members::$members

- name: Set Reviewers
id: set-reviewers
run: |
reviewers=$(curl -X GET \
-H "$ACCEPT_HEADER" \
-H "$VERSION_HEADER" \
-H "$AUTH_HEADER" \
$API_BASE_URL/repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/requested_reviewers)
reviewr_count=$(echo $reviewers | jq '.users | length')

if [ $reviewr_count -eq 0 ]; then
curl -X POST \
-H "$ACCEPT_HEADER" \
-H "$VERSION_HEADER" \
-H "$AUTH_HEADER" \
$API_BASE_URL/repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/requested_reviewers \
-d '{ "reviewers": ${{ steps.get-members-list.outputs.members }} }'
fi

Tokenの設定

github apiを使用する為にtokenを設定します。

Fine-grained personal access tokensを生成

Fine-grained personal access tokensとは詳細に権限を設定できるbeta版の機能にです。ですが今後はこちらがメインになる可能性が高い?ようです。
参考

手順1

以下へアクセスします。
https://github.com/settings/tokens?type=beta

手順2

このような画面が表示されるのでお好きなToken nameとExpirationとDescriptionを設定してください。
github

手順3

Github Actionsを実行するリポジトリを選択してください。 github

手順4

Permissionsを設定していきます。
Repository permissionsの中からPull requestsRead and writeに設定します。
Organization permissionsの中からMembersRead and writeに設定します。 Overviewが以下のような表示になっていればokです。 github Generate tokenを押下してください。
Tokenが生成されるので忘れないようにコピーしてください。

Actions secretsの設定

Github Actions内で使用できるように設定をしていきます。
以下へアクセスします。orgsは各自置き換えてください
https://github.com/{orgs}/mikiwame/settings/secrets/actions
New repository secretを押下すると以下画面になるのでお好きなnameを設定し先ほど生成したtokenをペーストしてください。 github これでactions内で${{ secrets.PULL_REQUEST_API_TOKEN }}のように使用できるようになりました!tokenの設定は以上です。

チームの設定

最後にチームを設定していきます。
公式ドキュメント
以下へアクセスします。orgsは各自置き換えてください
https://github.com/orgs/{orgs}/teams
New teamを押下すると以下のような画面になるのでお好きなnameとdesctiptionを設定しCreate teamを押下します。 github
最後にレビュワーに設定したいチームメンバーを追加すれば完了です!

最後に

長くなりましたがGitHub ActionsとGitHub apiでレビュワーを自動設定する方法は以上になります。
Github Actionsを活用すると様々なことを自動化できて便利なので是非活用していきましょう! (※使いすぎると無料枠超えてしまうので注意)
また機会があれば別の活用方法も紹介していきますのでお待ちください。