Google Classroom APIを利用してGASで課題を投稿するための3ステップとTips


#背景

職場で利用しているGoogle Classroomの作業には少々面倒臭いところがあります……。

その作業内容とは**下記の各設定を指定して複数の課題をまとめて投稿すること**なのですが、
UIの操作性やクラスごとにページを切り替えたりする手間が馬鹿にできません。

+ *クラス*
+ *トピック*
+ *タイトル*
+ *投稿日時*
+ *期限日時*
+ *添付ファイル*
+ *etc.*

RESTAPIであるClassroomAPIを使えば簡単に自動化できるかもしれないと夢見て、GASを書くことにしました。


#目的

**スプレッドシートを活用して、**
**Classroomを開くことなく、**
**上記の設定を指定した課題を投稿する**


#この記事を読んで得られる知見

+ ClassroomAPIを利用するための基本的な情報
+ Googleで使われる時間フォーマットRFC3339 timestampの変換方法
+ ClassroomAPIの注意点（下記で登場する※のところが要注意）

#環境・使用ツール

+ GASスクリプトエディタ（新しくなってそこそこ使いやすいです）
+ GitHub（[GAS GitHubアシスタント](https://chrome.google.com/webstore/detail/google-apps-script-github/lfjcgcmkmjjlieihflfhjopckgpelofo?hl=ja)を使うことで簡単にリポジトリを管理できます）
+ ClassroomAPI（[リファレンス](https://developers.google.com/classroom/reference/rest)、GASにはClassroomドキュメントをGoogle拡張サービスから追加しておきましょう）
+ 日付操作用のライブラリDay.js（[追加方法](https://gas.excelspeedup.com/dayjs/)）

#課題を投稿するまでの3ステップ

1. クラスIDを取得
1. トピックIDを取得
1. 各設定を指定して、課題投稿

APIを利用するタイミングは3回ありましたので、各ステップごとに解説していきます。

#1. クラスIDを取得
残念ながら投稿に必要なクラスIDはUIから確認することができないようです。
[リファレンス](https://developers.google.com/classroom/reference/rest)からAPI叩けるので、そちらから試すか、下記の方法で取得することができます。
まず、ログインしているGoogleアカウントで参加しているクラスの一覧を呼び出し、クラスIDを取得します。

```
// オプションの設定
let optionalArgs = {
  pageSize: 20
};
//  リストを取得
const response = Classroom.Courses.list(optionalArgs);
const courses = response.courses;
```

`Courses.list`というメソッドにコールします。
その際に、`courseStates, pageSize, pageToken, studentId, teacherId`などのオプションをパラメータとして追加可能です。
ここでは例として取得数を指定してみました。


**※UIではクラスと表記されていますが、リファレンス上では全てcourseと表記されています**
~~ややこしいので日本語ではクラス、英語ではcourseと書き分けています~~


```
{
  "id": string,
  "name": string,
  "section": string,
  "descriptionHeading": string,
  "description": string,
  "room": string,
  "ownerId": string,
  "creationTime": string,
  "updateTime": string,
  "enrollmentCode": string,
  "courseState": enum (CourseState),
  "alternateLink": string,
  "teacherGroupEmail": string,
  "courseGroupEmail": string,
  "teacherFolder": {
    object (DriveFolder)
  },
  "courseMaterialSets": [
    {
      object (CourseMaterialSet)
    }
  ],
  "guardiansEnabled": boolean,
  "calendarId": string
}
```
レスポンスで受け取る`courses`のフィールドはこのようになっています。
今回必要なのは名前とクラスIDだけなので、こちらをスプレッドシートに表示していきます。

```
if (courses && courses.length > 0) {
    // 取得したデータを名前とIDの形に整形
    let data = Object.keys(courses).map((k)=>[courses[k].name, courses[k].id]);
    // 出力欄を初期化して、シートに書き込み
    const url = ""; // 操作元のURL
    const sheetName = "";　// 操作元のシート名
    const sheet = SpreadsheetApp.openByUrl(url).getSheetByName(sheetName);
    sheet.getRange(*,*,*,*).clearContent();
    sheet.getRange(*,*,*,*).setValues(data);
}
```

Object型のメソッドを利用して２次元配列に変換し、スプレッドシートに貼り付けています。

#2. トピックIDを取得

投稿に必須ではありませんが、トピックの指定が必要な場合はトピックIDも取得する必要があります。
こちらも[リファレンス](https://developers.google.com/classroom/reference/rest)からAPI叩けるので、そちらから試すか、下記の方法で取得することができます。
先ほど呼び出したクラスIDを使用して、トピックIDを取得します。

```
const courseId = sheet.getRange(*,*).getValue();
let optionalArgs = {
  pageSize: 10
};
const topics = Classroom.Courses.Topics.list(courseId,　optionalArgs).topic;
```

`Courses.Topics.list`というメソッドにコールします。
その際に、`courseId`として先ほど呼び出してスプレッドシートに記録したクラスIDと、`pageSize, pageToken`などのオプションをパラメータとして追加可能です。
ここでは例として取得数を指定してみました。


```
{
  "courseId": string,
  "topicId": string,
  "name": string,
  "updateTime": string
}
```
レスポンスで受け取る`Courses.topics`のフィールドはこのようになっています。
今回必要なのは名前とトピックIDだけなので、こちらをスプレッドシートに表示していきます。

```
if (topics && topics.length > 0) {
  // 取得したデータを名前とIDの形に整形
  let data = Object.keys(topics).map((k)=>[topics[k].name, topics[k].topicId]);

  // 出力欄を初期化して、シートに書き込み
  sheet.getRange(*,*,*,*).clearContent();
  sheet.getRange(*,*,*,*).setValues(data);
}
```

Object型のメソッドを利用して２次元配列に変換し、スプレッドシートに貼り付けています。

#3. 各パラメータを設定して、課題投稿

ようやく課題の投稿に入ります。
ここまでで取得したIDを含めて、リクエスト用のJSONフィールドを設定していきます。
時間の指定が少々ややこしいので、下記のように指定することにして、詳しいフォーマットを確認していきます。

* 投稿日　：2021年10月15日
* 投稿時刻：12時34分
* 期限時刻：23時45分

```
// IDの取得
const courseId = sheet.getRange(*,*).getValue();
const topicId = sheet.getRange(*,*).getValue();

// タイトルの設定
const title = "";

// 投稿日の設定
let date = new Date(Date.UTC(2021, 9, 15));
let scheduledDate = date;
// 投稿時刻の設定
scheduledDate.setHours(12);
scheduledDate.setMinutes(34);
let scheduledTime = dayjs.dayjs(scheduledDate).toISOString();

// 期限時刻の設定
let dueTime = date;
dueTime.setHours(23-9);
dueTime.setMinutes(45);

// 添付ファイルの設定
let attachedFile = ""; // URLの形
let attachedFileTitle = "";

// パラメータの設定
const courseWork = {
  title: title,
  dueDate: { year: dueTime.getFullYear(), month: dueTime.getMonth()+1, day: dueTime.getDate() },
  dueTime: { hours: dueTime.getHours(), minutes: dueTime.getMinutes(), seconds: 0, nanos: 0 },
  state: "DRAFT",
  workType: "ASSIGNMENT",
  topicId: topicId,
  scheduledTime: scheduledTime
}
if (attachedFile){
  courseWork["materials"] = [{ 
    "link": {
      "url":attachedFile,
      "title":attachedFileTitle
    }        
  }];
}
try {
  // 課題の予約投稿
  let response = Classroom.Courses.CourseWork.create(courseWork, courseId);
  let resultUrl = response.alternateLink;
} catch(e) {
  // 失敗した時の処理
}
```

呼び出し済みのIDとタイトルについては大丈夫でしょう。

投稿日と締切日は今回同日としているので、
`let date = new Date(Date.UTC(2021, 9, 15));`
という形で設定します。
**※月だけ0-indexなので、指定する月から1を引いた数を設定しましょう**

問題は時刻指定の部分です。

```
// 投稿時刻の設定
let scheduledDate = date;
scheduledDate.setHours(12);
scheduledDate.setMinutes(34);
let scheduledTime = dayjs.dayjs(scheduledDate).toISOString();
```

`setHours`と`setMinutes`で時刻を設定します。
ただ、投稿日時のフォーマットはなんとタイムスタンプで指定されています。


>**string (Timestamp format)**
>Optional timestamp when this course work is scheduled to be published.
>A timestamp in RFC3339 UTC "Zulu" format, with nanosecond resolution and up to nine fractional digits. Examples: "2014-10-02T15:01:23Z" and "2014-10-02T15:01:23.045123456Z".
[引用：公式リファレンス](https://developers.google.com/classroom/reference/rest/v1/courses.courseWork)

こちらで指定されている`RFC3339 UTC "Zulu" format`というフォーマットに変換するにするのに一番手間取りました。
結論として、Day.jsというMoment.jsの後継ライブラリを利用して`ISO8601`に変換しています。

一方で、期限時刻は指定されたフォーマットが異なっており、`setHours`と`setMinutes`で時刻を設定したものをそのまま活用できます。
ただし、時差9時間分を計算しておきましょう。

添付ファイルはURLとタイトルを設定しておきます。

```
{
  "courseId": string,
  "id": string,
  "title": string,
  "description": string,
  "materials": [
    {
      object (Material)
    }
  ],
  "state": enum (CourseWorkState),
  "alternateLink": string,
  "creationTime": string,
  "updateTime": string,
  "dueDate": {
    object (Date)
  },
  "dueTime": {
    object (TimeOfDay)
  },
  "scheduledTime": string,
  "maxPoints": number,
  "workType": enum (CourseWorkType),
  "associatedWithDeveloper": boolean,
  "assigneeMode": enum (AssigneeMode),
  "individualStudentsOptions": {
    object (IndividualStudentsOptions)
  },
  "submissionModificationMode": enum (SubmissionModificationMode),
  "creatorUserId": string,
  "topicId": string,

  // Union field details can be only one of the following:
  "assignment": {
    object (Assignment)
  },
  "multipleChoiceQuestion": {
    object (MultipleChoiceQuestion)
  }
  // End of list of possible types for union field details.
}

```

これまで設定した値をフィールドに付与していきます。
上記のように、課題（courseWork）のフィールドはたくさんありますが、`"state"`と`"workTipe"`、`"dueDate, dueTime"`には特有のフォーマットを指定しなければいけません。
特に、`"state"`は予約投稿の場合は`"DRAFT"`を指定します。
**※即時投稿の場合は`"scheduledTime"`を設定せず、`"state"`を`"PUBLISHED"`にします**

これらが終われば、後はリクエストを投げるだけです。

```
// 課題の予約投稿
let response = Classroom.Courses.CourseWork.create(courseWork, courseId);
let resultUrl = response.alternateLink;
```

レスポンスで受け取るのは課題（courseWork）のインスタンスなので、上記のフィールドがほぼそのまま帰ってきます。

**※即時投稿では`"alternateLink"`（投稿された課題のURL）が生成されますが、予約投稿では`"alternateLink"`は生成されません**


以上の流れで、`response`が無事帰ってきたらミッションコンプリートです。
おめでとうございます。

#最後に
Classroom APIにはリファレンスには色々とハマりやすい罠がありました。

+ 予約投稿するやり方が明記されておらず、日時の指定フォーマットがややこしい
+ 投稿した時点でリンクは生成されているはずなのに、予約登校の時はそのURLを取得できない
+ クラスや課題を削除するメソッドとして`delete`と書かれているのに、実際は`remove`


今後APIを使おうと思い立った誰かにとって、少しでもこの記事が役立つことを祈ります。

#参考資料

+ 公式リファレンス：https://developers.google.com/classroom/reference/rest
+ 課題を作成する：https://web-breeze.net/classroom-coursework-create/
+ Day.jsライブラリの追加方法：https://gas.excelspeedup.com/dayjs/
+ 課題リストの取得：https://stackoverflow.com/questions/52881931/how-to-use-draft-enum-in-google-classroom-coursework-list-call
+ 指定された時間フォーマットへの変換：https://stackoverflow.com/questions/7244246/generate-an-rfc-3339-timestamp-similar-to-google-tasks-api
+ Day.jsのリファレンス（今回使用した変換部分）：https://day.js.org/docs/en/display/as-iso-string
+ クラスを削除する方法：https://qiita.com/coticoticotty/items/cacfe00f79c533595d67