GPTとGASでBacklogプロジェクトの週次レポート作成

AIが簡単なスクリプトのコーディングまでやってくれる今日、別にGASを組んで彼是できるようにするのなんて珍しくもなんともないとは思いますが、とはいえちょうどよくまとまった該当処理もなかったので備忘として挙げておきます。

# できること
Backlogのレポート作成。プロジェクト管理者として、プロジェクト全体をいちいち検索するのがとても効率が悪かったので、レポート出せるようにしました。こんな感じ。

※実際のプロジェクト名や課題番号は架空のものとなっています。
```
レポート期間：**10月1日～10月7日**

---

## 全体サマリ
- 新規登録課題件数：8件
- コメント追記課題件数：12件
- 完了課題件数：5件

---

## 新規登録されたバックログチケット

- **PROJECT-150：新機能Xの要件定義**
- **PROJECT-151：モバイルアプリのデザイン刷新**
- **PROJECT-152：API連携の不具合修正**
- **PROJECT-153：ユーザー登録機能の改善**

---

## 完了したバックログチケット

- **PROJECT-140：ログイン画面のUI改善**
- **PROJECT-141：検索機能の高速化**
- **PROJECT-142：エラーメッセージの統一**

---

## コメントが追加されたバックログチケット

### PROJECT-150: 新機能Xの要件定義
- **問題**
  - 要件の範囲が広く、優先順位の設定が必要。
- **対応**
  - 関係部署と協議し、重要度に応じたタスク分割を実施。
- **状況**
  - 要件リストを整理し、次回ミーティングで確認予定。

### PROJECT-151: モバイルアプリのデザイン刷新
- **問題**
  - 一部デザインがユーザビリティの観点で問題あり。
- **対応**
  - ユーザーテストの結果を踏まえ、デザインを修正。
- **状況**
  - 修正版デザインを開発チームに共有済み。

### PROJECT-152: API連携の不具合修正
- **問題**
  - 外部サービスとの通信エラーが発生。
- **対応**
  - エラーログを解析し、タイムアウト設定の見直しを実施。
- **状況**
  - 修正パッチを適用し、再発防止策を検討中。

---

```

# 用意するもの

* Googleアカウント
* Backlogプロジェクト用のAPIキー
* OpenAIのAPI連携用キー

# スクリプト

## 全体

```javascript

const BACKLOG_API_KEY = 'バックログのAPIキー';
const SPACE_ID = 'バックログのスペースID';
const PROJECT_ID = 'バックログのプロジェクトID';
const CHATGPT_API_KEY = 'ChatGPTのAPIキー';
const GOOGLE_DRIVE_FOLDER_ID = 'GoogleドライブのフォルダID';

class BacklogApiClient {
  constructor(apiKey, spaceId, projectId) {
    // APIキー、スペースID、プロジェクトIDを初期化
    this.apiKey = apiKey;
    this.spaceId = spaceId;
    this.projectId = projectId;
  }

  // 1週間以内に更新された課題を取得する関数
  getIssuesUpdatedLastWeek(since) {
    const url = `https://${this.spaceId}.backlog.com/api/v2/issues?apiKey=${this.apiKey}&projectId[]=${this.projectId}&updatedSince=${since}`;
    const response = UrlFetchApp.fetch(url, { 'muteHttpExceptions': true }); // エラー応答を抑制し、エラー内容を解析できるように変更
    const responseCode = response.getResponseCode();
    if (responseCode !== 200) {
      Logger.log(`Error fetching issues: ${response.getContentText()}`);
      throw new Error(`Failed to fetch issues: ${responseCode}`);
    }
    return JSON.parse(response.getContentText());
  }

  // 指定した課題のコメントを取得する関数
  getIssueComments(issueIdOrKey, count = 20, order = 'desc') {
    const url = `https://${this.spaceId}.backlog.com/api/v2/issues/${issueIdOrKey}/comments?apiKey=${this.apiKey}&count=${count}&order=${order}`;
    const response = UrlFetchApp.fetch(url, { 'muteHttpExceptions': true }); // エラー応答を抑制し、エラー内容を解析できるように変更
    const responseCode = response.getResponseCode();
    if (responseCode !== 200) {
      Logger.log(`Error fetching comments for issue ${issueIdOrKey}: ${response.getContentText()}`);
      throw new Error(`Failed to fetch comments: ${responseCode}`);
    }
    return JSON.parse(response.getContentText());
  }

  // 1週間以内に更新されたすべての課題のコメントを取得する関数
  getCommentsUpdatedLastWeek(since) {
    const issues = this.getIssuesUpdatedLastWeek(since);
    let comments = [];
    
    issues.forEach(issue => {
      const url = `https://${this.spaceId}.backlog.com/api/v2/issues/${issue.id}/comments?apiKey=${this.apiKey}`;
      const response = UrlFetchApp.fetch(url, { 'muteHttpExceptions': true }); // エラー応答を抑制し、エラー内容を解析できるように変更
      const responseCode = response.getResponseCode();
      if (responseCode !== 200) {
        Logger.log(`Error fetching comments for issue ${issue.id}: ${response.getContentText()}`);
        return; // この課題のコメント取得に失敗した場合はスキップ
      }
      const issueComments = JSON.parse(response.getContentText());
      comments = comments.concat(issueComments);
    });
    
    return comments;
  }

  // 課題の詳細を取得する関数
  getIssueDetails(issueIdOrKey) {
    const url = `https://${this.spaceId}.backlog.com/api/v2/issues/${issueIdOrKey}?apiKey=${this.apiKey}`;
    const response = UrlFetchApp.fetch(url, { 'muteHttpExceptions': true }); // エラー応答を抑制し、エラー内容を解析できるように変更
    const responseCode = response.getResponseCode();
    if (responseCode !== 200) {
      Logger.log(`Error fetching issue details for ${issueIdOrKey}: ${response.getContentText()}`);
      throw new Error(`Failed to fetch issue details: ${responseCode}`);
    }
    return JSON.parse(response.getContentText());
  }

  // 1週間以内に「完了」になった課題を取得する関数
  getCompletedIssuesLastWeek(since) {
    const issues = this.getIssuesUpdatedLastWeek(since);
    return issues.filter(issue => issue.status.name === '完了');
  }
}

class ChatGptClient {
  constructor(chatGptApiKey) {
    // ChatGPTのAPIキーを初期化
    this.chatGptApiKey = chatGptApiKey;
  }

  // ChatGPTを使ってサマリを取得する関数
  getSummary(prompt) {
    const url = 'https://api.openai.com/v1/chat/completions';
    const payload = {
      'model': 'gpt-4o',
      'messages': [{ 'role': 'user', 'content': prompt }], // 修正: 'messages'パラメータを追加
      'max_tokens': 1000,
      'temperature': 0.7
    };
    const options = {
      'method': 'post',
      'contentType': 'application/json',
      'headers': {
        'Authorization': `Bearer ${this.chatGptApiKey}`
      },
      'payload': JSON.stringify(payload),
      'muteHttpExceptions': true // エラー応答を抑制し、エラー内容を解析できるように変更
    };
    
    const response = UrlFetchApp.fetch(url, options);
    const responseCode = response.getResponseCode();
    if (responseCode !== 200) {
      Logger.log(`Error fetching summary from ChatGPT: ${response.getContentText()}`);
      throw new Error(`Failed to fetch summary: ${responseCode}`);
    }
    const result = JSON.parse(response.getContentText());
    Logger.log(`Rate Limit Remaining: ${response.getHeaders()['x-ratelimit-limit-tokens']}`); // レートリミットの残りをログに出力
    return result.choices[0].message.content.trim();
  }

  // ChatGPT APIへの接続をテストする関数
  testConnection() {
    const testPrompt = "これは接続テストです。適当な応答を返してください。";
    try {
      const response = this.getSummary(testPrompt);
      Logger.log(`ChatGPT Test Response: ${response}`);
      return true;
    } catch (error) {
      Logger.log(`ChatGPT Test Failed: ${error.message}`);
      return false;
    }
  }
}

class GoogleDriveUploader {
  constructor(driveFolderId) {
    // GoogleドライブフォルダIDを初期化
    this.driveFolderId = driveFolderId;
  }

  // レポートをGoogleドライブにアップロードする関数
  uploadReport(content) {
    const fileName = `Weekly_Backlog_Report_${this.formatDate(new Date())}.txt`;
    const file = DriveApp.getFolderById(this.driveFolderId).createFile(fileName, content);
    const fileUrl = file.getUrl();
    Logger.log(`Report uploaded to Google Drive: ${fileUrl}`);
    return fileUrl;
  }

  // 日付をフォーマットするヘルパー関数
  formatDate(date) {
    return Utilities.formatDate(date, Session.getScriptTimeZone(), 'yyyy-MM-dd');
  }
}

class BacklogWeeklyReportService {
  constructor(backlogApiClient, chatGptClient, googleDriveUploader) {
    // 各サービスのクライアントを初期化
    this.backlogApiClient = backlogApiClient;
    this.chatGptClient = chatGptClient;
    this.googleDriveUploader = googleDriveUploader;
  }

  // 週次レポートを生成する関数
  generateWeeklyReport(startDate, endDate) {
    let weekAgo;
    let since;

    if (startDate && endDate) {
      weekAgo = new Date(startDate);
      since = new Date(endDate);
    } else {
      weekAgo = new Date();
      weekAgo.setDate(weekAgo.getDate() - 7);
      since = new Date();
    }

    const formattedWeekAgo = weekAgo.toISOString().split('T')[0]; // Convert to yyyy-MM-dd format
    const formattedSince = since.toISOString().split('T')[0]; // Convert to yyyy-MM-dd format

    let fileUrl = '';
    let success = false;
    const timestamp = Utilities.formatDate(new Date(), Session.getScriptTimeZone(), 'yyyy-MM-dd HH:mm:ss');

    try {
      // 指定期間に更新された課題、コメント、および完了した課題を取得
      const issues = this.backlogApiClient.getIssuesUpdatedLastWeek(formattedWeekAgo);
      const completedIssues = this.backlogApiClient.getCompletedIssuesLastWeek(formattedWeekAgo);
      const comments = this.backlogApiClient.getCommentsUpdatedLastWeek(formattedWeekAgo);

      // 課題一覧、完了した課題リスト、コメントのサマリを作成
      const issuesList = this.createIssuesList(issues);
      const completedIssuesList = this.createCompletedIssuesList(completedIssues);
      const commentsSummary = this.createCommentsSummary(comments);

      // レポートフォーマットを作成
      const reportContent = this.formatWeeklyReport(issuesList, completedIssuesList, commentsSummary, weekAgo, since);

      // レポートをGoogleドライブにアップロードし、URLを取得
      fileUrl = this.googleDriveUploader.uploadReport(reportContent);

      // アップロード完了のログを出力
      Logger.log(`Weekly Backlog Report has been uploaded: ${fileUrl}`);
      success = true;
    } catch (error) {
      Logger.log(`Error generating weekly report: ${error.message}`);
      success = false;
    } finally {
      this.logReportGeneration(timestamp, fileUrl, success);
    }
  }

  // 課題一覧を作成する関数
  createIssuesList(issues) {
    return issues.map(issue => `- **${issue.issueKey}：${issue.summary}**`).join('\n');
  }

  // 完了した課題一覧を作成する関数
  createCompletedIssuesList(completedIssues) {
    return completedIssues.map(issue => `- **${issue.issueKey}：${issue.summary}**`).join('\n');
  }

  // コメントのサマリを作成する関数
  createCommentsSummary(comments) {
    let summaries = [];
    comments.forEach(comment => {
      const issueDetails = this.backlogApiClient.getIssueDetails(comment.issueId);
      const summary = `課題ID: ${issueDetails.issueKey}, 課題名: ${issueDetails.summary}, 登録日: ${issueDetails.created}\nコメント: ${comment.content}`;
      summaries.push(summary);
    });
    const prompt = `以下のコメントについての「問題」「対応」「状況」の項目を設けてサマリを作成してください:\n${summaries.join('\n')}`;
    return this.chatGptClient.getSummary(prompt);
  }

  // 週次レポートのフォーマットを整える関数
  formatWeeklyReport(issuesList, completedIssuesList, commentsSummary, weekAgo, since) {
    const formattedStartDate = Utilities.formatDate(weekAgo, Session.getScriptTimeZone(), 'MM月dd日');
    const formattedEndDate = Utilities.formatDate(since, Session.getScriptTimeZone(), 'MM月dd日');

    return `レポート期間：**${formattedStartDate}～${formattedEndDate}**

---

## 全体サマリ
- 新規登録課題件数：${issuesList.split('\n').length}件
- コメント追記課題件数：${commentsSummary ? commentsSummary.split('\n').length : 0}件
- 完了課題件数：${completedIssuesList.split('\n').length}件

---

## 新規登録されたバックログチケット

${issuesList}

---

## 完了したバックログチケット

${completedIssuesList}

---

## コメントが追加されたバックログチケット

${commentsSummary}

---
`;
  }

  // レポートの生成結果を「レポートログ」シートに記録する関数
  logReportGeneration(timestamp, fileUrl, success) {
    const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('レポートログ');
    if (!sheet) {
      Logger.log('「レポートログ」シートが見つかりません。');
      return;
    }
    sheet.appendRow([timestamp, fileUrl, success ? '成功' : '失敗']);
  }
}

// 実行する関数
function runWeeklyReportGeneration() {
  const ui = SpreadsheetApp.getUi();
  const response = ui.prompt('レポート生成', '開始日を入力してください（yyyy-MM-dd形式）。未入力の場合は1週間前の日付が使用されます:', ui.ButtonSet.OK_CANCEL);
  if (response.getSelectedButton() == ui.Button.OK) {
    const startDate = response.getResponseText() || null;
    const endDate = new Date();
    endDate.setDate(endDate.getDate());

    // クライアントのインスタンスを作成し、週次レポートを生成
    const backlogApiClient = new BacklogApiClient(BACKLOG_API_KEY, SPACE_ID, PROJECT_ID);
    const chatGptClient = new ChatGptClient(CHATGPT_API_KEY);
    const googleDriveUploader = new GoogleDriveUploader(GOOGLE_DRIVE_FOLDER_ID);

    const backlogReportService = new BacklogWeeklyReportService(backlogApiClient, chatGptClient, googleDriveUploader);

    // ChatGPT接続テスト
    chatGptClient.testConnection();

    // 週次レポートの生成
    backlogReportService.generateWeeklyReport(startDate, endDate);
  }
}

// GPTテストをする関数
function testGptConnection () {
  const chatGptClient = new ChatGptClient(CHATGPT_API_KEY);
  // ChatGPT接続テスト
  chatGptClient.testConnection();
}

// メニューを作成してスプレッドシートに追加する関数
function onOpen() {
  const ui = SpreadsheetApp.getUi();
  ui.createMenu('バックログレポート')
    .addItem('レポートを生成', 'runWeeklyReportGeneration')
    .addToUi();
}
```

## 解説

### **定数の宣言**

```javascript
const BACKLOG_API_KEY = 'バックログのAPIキー';
const SPACE_ID = 'バックログのスペースID';
const PROJECT_ID = 'バックログのプロジェクトID';
const CHATGPT_API_KEY = 'ChatGPTのAPIキー';
const GOOGLE_DRIVE_FOLDER_ID = 'GoogleドライブのフォルダID';
```

- **目的**: 各種APIやサービスにアクセスするためのキーやIDを定義。
  - `BACKLOG_API_KEY`: BacklogのAPIキーで、APIリクエストの認証に使用。
  - `SPACE_ID`: BacklogのスペースIDで、APIエンドポイントのURL構築に使用。
  - `PROJECT_ID`: 特定のプロジェクトを指定するためのID。
  - `CHATGPT_API_KEY`: OpenAIのChatGPT APIキーで、サマリ生成時に使用。
  - `GOOGLE_DRIVE_FOLDER_ID`: レポートを保存するGoogleドライブフォルダのID。

---

### **クラス: `BacklogApiClient`**

BacklogのAPIと通信するためのクライアントクラス。

#### **コンストラクタ**

```javascript
constructor(apiKey, spaceId, projectId) {
  this.apiKey = apiKey;
  this.spaceId = spaceId;
  this.projectId = projectId;
}
```

- **目的**: クラスのインスタンスを初期化し、必要な情報を保持。

#### **メソッド: `getIssuesUpdatedLastWeek(since)`**

```javascript
getIssuesUpdatedLastWeek(since) {
  const url = `https://${this.spaceId}.backlog.com/api/v2/issues?apiKey=${this.apiKey}&projectId[]=${this.projectId}&updatedSince=${since}`;
  // 以下、省略
}
```

- **目的**: 指定した日付以降に更新された課題を取得。
- **パラメータ**:
  - `since`: 取得開始日（フォーマットは`yyyy-MM-dd`）。
- **処理**:
  - APIリクエストのURLを構築。
  - `UrlFetchApp.fetch`を使用してAPIにリクエストを送信。
  - レスポンスをチェックし、問題があればエラーを投げる。
  - 成功した場合、JSONを解析して課題のリストを返す。

#### **メソッド: `getIssueComments(issueIdOrKey, count = 20, order = 'desc')`**

- **目的**: 指定した課題のコメントを取得。
- **パラメータ**:
  - `issueIdOrKey`: 課題のIDまたはキー。
  - `count`: 取得するコメントの数（デフォルトは20）。
  - `order`: コメントの取得順序（デフォルトは降順）。
- **処理**:
  - コメント取得用のURLを構築。
  - APIリクエストを送信し、レスポンスを解析してコメントを返す。

#### **メソッド: `getCommentsUpdatedLastWeek(since)`**

- **目的**: 指定した期間内に更新されたすべての課題のコメントを取得。
- **処理**:
  - `getIssuesUpdatedLastWeek`を使用して課題を取得。
  - 各課題に対してコメントを取得し、全コメントをリストにまとめて返す。

#### **メソッド: `getIssueDetails(issueIdOrKey)`**

- **目的**: 指定した課題の詳細情報を取得。
- **処理**:
  - 課題詳細取得用のURLを構築。
  - APIリクエストを送信し、レスポンスを解析して課題の詳細を返す。

#### **メソッド: `getCompletedIssuesLastWeek(since)`**

- **目的**: 指定した期間内に「完了」ステータスになった課題を取得。
- **処理**:
  - `getIssuesUpdatedLastWeek`を使用して課題を取得。
  - ステータスが「完了」の課題のみをフィルタリングして返す。

---

### **クラス: `ChatGptClient`**

ChatGPT APIと通信するためのクライアントクラス。

#### **コンストラクタ**

```javascript
constructor(chatGptApiKey) {
  this.chatGptApiKey = chatGptApiKey;
}
```

- **目的**: クラスのインスタンスを初期化し、APIキーを保持。

#### **メソッド: `getSummary(prompt)`**

```javascript
getSummary(prompt) {
  const url = 'https://api.openai.com/v1/chat/completions';
  // 以下、省略
}
```

- **目的**: 提供されたプロンプトを使用してChatGPTからサマリを取得。
- **パラメータ**:
  - `prompt`: ChatGPTに送信するテキスト。
- **処理**:
  - リクエストのペイロードを構築（モデル、メッセージ、トークン数、温度など）。
  - `UrlFetchApp.fetch`を使用してAPIリクエストを送信。
  - レスポンスをチェックし、問題があればエラーを投げる。
  - 成功した場合、レスポンスからサマリを抽出して返す。

#### **メソッド: `testConnection()`**

- **目的**: ChatGPT APIへの接続をテスト。
- **処理**:
  - テスト用のプロンプトを使用して`getSummary`を呼び出す。
  - 成功または失敗の結果をログに記録。

---

### **クラス: `GoogleDriveUploader`**

Googleドライブにファイルをアップロードするためのクラス。

#### **コンストラクタ**

```javascript
constructor(driveFolderId) {
  this.driveFolderId = driveFolderId;
}
```

- **目的**: クラスのインスタンスを初期化し、フォルダIDを保持。

#### **メソッド: `uploadReport(content)`**

- **目的**: レポート内容をテキストファイルとしてGoogleドライブにアップロード。
- **パラメータ**:
  - `content`: アップロードするレポートの内容。
- **処理**:
  - 現在の日付を使用してファイル名を生成。
  - 指定されたフォルダにファイルを作成。
  - ファイルのURLを取得し、ログに記録。

#### **メソッド: `formatDate(date)`**

- **目的**: 日付を指定のフォーマット（`yyyy-MM-dd`）に変換。
- **パラメータ**:
  - `date`: フォーマットする日付オブジェクト。
- **処理**:
  - `Utilities.formatDate`を使用して日付をフォーマット。

---

### **クラス: `BacklogWeeklyReportService`**

週次レポートを生成するためのサービスクラス。

#### **コンストラクタ**

```javascript
constructor(backlogApiClient, chatGptClient, googleDriveUploader) {
  this.backlogApiClient = backlogApiClient;
  this.chatGptClient = chatGptClient;
  this.googleDriveUploader = googleDriveUploader;
}
```

- **目的**: 必要なクライアントインスタンスを受け取り、サービスを初期化。

#### **メソッド: `generateWeeklyReport(startDate, endDate)`**

- **目的**: 週次レポートを生成。
- **パラメータ**:
  - `startDate`: レポートの開始日。
  - `endDate`: レポートの終了日。
- **処理**:
  - 日付範囲を設定。日付が指定されていない場合、過去1週間の範囲を使用。
  - 日付を`yyyy-MM-dd`形式にフォーマット。
  - 課題、完了した課題、コメントを取得。
  - 課題一覧、完了した課題一覧、コメントのサマリを作成。
  - レポート内容をフォーマット。
  - レポートをGoogleドライブにアップロードし、ファイルのURLを取得。
  - 成功または失敗の結果をログに記録。
  - 「レポートログ」シートに結果を記録。

#### **メソッド: `createIssuesList(issues)`**

- **目的**: 課題のリストをMarkdown形式の文字列に変換。
- **処理**:
  - 各課題を`- **[課題キー]：[概要]**`の形式で文字列に変換。
  - 文字列を改行で結合。

#### **メソッド: `createCompletedIssuesList(completedIssues)`**

- **目的**: 完了した課題のリストを作成（`createIssuesList`と同様）。

#### **メソッド: `createCommentsSummary(comments)`**

- **目的**: コメントのサマリをChatGPTを使用して生成。
- **処理**:
  - 各コメントに対して、関連する課題の詳細を取得。
  - 課題情報とコメント内容をまとめた文字列を作成。
  - すべてのコメント情報を結合し、ChatGPTに送信するプロンプトを作成。
  - ChatGPTからサマリを取得し、返す。

#### **メソッド: `formatWeeklyReport(...)`**

- **目的**: レポート全体をMarkdown形式でフォーマット。
- **処理**:
  - 日付範囲や各セクション（全体サマリ、新規課題、完了課題、コメントサマリ）を組み合わせてレポートを構成。

#### **メソッド: `logReportGeneration(timestamp, fileUrl, success)`**

- **目的**: レポートの生成結果を「レポートログ」シートに記録。
- **処理**:
  - スプレッドシートの「レポートログ」シートを取得。
  - タイムスタンプ、ファイルURL、成功・失敗のステータスを新しい行として追加。

---

### **関数: `runWeeklyReportGeneration()`**

- **目的**: ユーザーインターフェースからレポート生成を開始する関数。
- **処理**:
  - ユーザーに開始日を入力するようにプロンプトを表示。
  - 入力された日付、またはデフォルトの日付範囲を使用。
  - 必要なクライアントのインスタンスを作成。
  - ChatGPTへの接続テストを行う。
  - `generateWeeklyReport`を呼び出してレポートを生成。

---

### **関数: `testGptConnection()`**

- **目的**: ChatGPT APIへの接続をテストするための関数。
- **処理**:
  - `ChatGptClient`のインスタンスを作成し、`testConnection`メソッドを呼び出す。

---

### **関数: `onOpen()`**

- **目的**: スプレッドシートが開かれたときにメニューを追加する関数。
- **処理**:
  - スプレッドシートのUIに「バックログレポート」というメニューを作成。
  - 「レポートを生成」というアイテムを追加し、クリック時に`runWeeklyReportGeneration`を呼び出す。

---

### **補足情報**

- **エラーハンドリング**: 各API呼び出しや処理の中で、エラーが発生した場合は適切にログを記録し、必要に応じてエラーを投げる。
- **ログ出力**: `Logger.log`を使用して、各種情報やエラーをログに記録。
- **日付の扱い**: 日付のフォーマットや計算には、`Utilities.formatDate`や`Date`オブジェクトを使用。
- **国際化対応**: スクリプト内の文字列は主に日本語で書かれており、日本語環境での利用を想定。


**注意事項**:

- スクリプト内のAPIキーやIDは、実際の値に置き換えて使用すること。
- APIキーや認証情報は機密情報であり、公開しないように注意。
- ChatGPTのAPI利用には料金が発生する場合があるため、利用状況を監視する。

# おわりに
このスクリプトGPTに全部書いてもらったんですけどね。30分くらいかな。
いやあありがたい時代になったもんだ。