Created
August 5, 2026 05:32
-
-
Save ochilab/e270f91fe422877528fbf78994c5513f to your computer and use it in GitHub Desktop.
Classroomの課題URLから提出状況一覧をとってくる
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| function exportClassroomSubmissionStatus() { | |
| const sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet(); | |
| const classroomUrl = String(sheet.getRange('B1').getValue()).trim(); | |
| if (!classroomUrl) { | |
| throw new Error('セルB1にGoogle Classroomの課題URLを入力してください。'); | |
| } | |
| // URLからAPI上の正式なcourseIdとcourseWorkIdを特定 | |
| const target = findCourseWorkByUrl(classroomUrl); | |
| const courseId = target.course.id; | |
| const courseWorkId = target.courseWork.id; | |
| const course = target.course; | |
| const courseWork = target.courseWork; | |
| const students = getAllStudents(courseId); | |
| const submissions = getAllSubmissions(courseId, courseWorkId); | |
| const submissionMap = new Map(); | |
| submissions.forEach(function (submission) { | |
| submissionMap.set(String(submission.userId), submission); | |
| }); | |
| const headers = [ | |
| 'ユーザーID', | |
| '氏名', | |
| 'メールアドレス', | |
| '提出状況', | |
| 'Classroom状態', | |
| '提出日時', | |
| '更新日時' | |
| ]; | |
| const rows = students.map(function (student) { | |
| const userId = String(student.userId); | |
| const profile = student.profile || {}; | |
| const name = profile.name || {}; | |
| const submission = submissionMap.get(userId); | |
| const state = submission | |
| ? submission.state | |
| : 'NO_SUBMISSION_DATA'; | |
| return [ | |
| userId, | |
| name.fullName || '', | |
| profile.emailAddress || '', | |
| convertSubmissionStatus(state), | |
| state, | |
| getTurnedInTime(submission), | |
| submission && submission.updateTime | |
| ? new Date(submission.updateTime) | |
| : '' | |
| ]; | |
| }); | |
| sheet.clearContents(); | |
| sheet.getRange('A1').setValue('コース名'); | |
| sheet.getRange('B1').setValue(course.name || ''); | |
| sheet.getRange('A2').setValue('課題名'); | |
| sheet.getRange('B2').setValue(courseWork.title || ''); | |
| sheet.getRange('A3').setValue('課題URL'); | |
| sheet.getRange('B3').setValue(classroomUrl); | |
| sheet.getRange('A4').setValue('courseId'); | |
| sheet.getRange('B4').setValue(courseId); | |
| sheet.getRange('A5').setValue('courseWorkId'); | |
| sheet.getRange('B5').setValue(courseWorkId); | |
| sheet.getRange('A7:G7').setValues([headers]); | |
| if (rows.length > 0) { | |
| sheet.getRange(8, 1, rows.length, headers.length) | |
| .setValues(rows); | |
| sheet.getRange(8, 6, rows.length, 2) | |
| .setNumberFormat('yyyy/MM/dd HH:mm:ss'); | |
| } | |
| sheet.getRange('A7:G7') | |
| .setFontWeight('bold'); | |
| sheet.setFrozenRows(7); | |
| sheet.autoResizeColumns(1, headers.length); | |
| Logger.log('courseId: ' + courseId); | |
| Logger.log('courseWorkId: ' + courseWorkId); | |
| Logger.log('学生数: ' + students.length); | |
| Logger.log('提出情報数: ' + submissions.length); | |
| } | |
| /** | |
| * Classroomの課題URLから、API上の正式なコースと課題を探す。 | |
| */ | |
| function findCourseWorkByUrl(classroomUrl) { | |
| const normalizedTargetUrl = normalizeClassroomUrl(classroomUrl); | |
| const urlInfo = parseClassroomWebUrl(classroomUrl); | |
| const courses = getAllCourses(); | |
| Logger.log('取得できたコース数: ' + courses.length); | |
| Logger.log('URL内のコース識別子: ' + urlInfo.courseUrlId); | |
| Logger.log('URL内の課題識別子: ' + urlInfo.courseWorkUrlId); | |
| for (const course of courses) { | |
| if (!isTargetCourse(course, urlInfo, normalizedTargetUrl)) { | |
| continue; | |
| } | |
| Logger.log( | |
| '候補コース: ' + course.name + | |
| ' / API ID: ' + course.id + | |
| ' / URL: ' + course.alternateLink | |
| ); | |
| const courseWorks = getAllCourseWork(course.id); | |
| for (const courseWork of courseWorks) { | |
| if (isTargetCourseWork( | |
| courseWork, | |
| urlInfo, | |
| normalizedTargetUrl | |
| )) { | |
| return { | |
| course: course, | |
| courseWork: courseWork | |
| }; | |
| } | |
| } | |
| } | |
| throw new Error( | |
| '指定されたURLに対応する課題をClassroom APIから特定できませんでした。\n' + | |
| '次の点を確認してください。\n' + | |
| '・スクリプトを実行しているアカウントが、そのクラスの教師である\n' + | |
| '・B1が課題の詳細画面URLである\n' + | |
| '・別のGoogleアカウント用の /u/0/ などがURLに含まれていても問題ありません\n' + | |
| '・課題が削除済み、またはアクセス権のない課題ではない' | |
| ); | |
| } | |
| /** | |
| * 実行ユーザーがアクセスできる全コースを取得する。 | |
| */ | |
| function getAllCourses() { | |
| const courses = []; | |
| let pageToken = null; | |
| do { | |
| const options = { | |
| pageSize: 100, | |
| courseStates: [ | |
| 'ACTIVE', | |
| 'ARCHIVED', | |
| 'PROVISIONED', | |
| 'DECLINED', | |
| 'SUSPENDED' | |
| ] | |
| }; | |
| if (pageToken) { | |
| options.pageToken = pageToken; | |
| } | |
| const response = Classroom.Courses.list(options); | |
| if (response.courses) { | |
| courses.push.apply(courses, response.courses); | |
| } | |
| pageToken = response.nextPageToken || null; | |
| } while (pageToken); | |
| return courses; | |
| } | |
| /** | |
| * 指定コースの全課題を取得する。 | |
| */ | |
| function getAllCourseWork(courseId) { | |
| const courseWorks = []; | |
| let pageToken = null; | |
| do { | |
| const options = { | |
| pageSize: 100, | |
| courseWorkStates: [ | |
| 'PUBLISHED', | |
| 'DRAFT', | |
| 'DELETED' | |
| ] | |
| }; | |
| if (pageToken) { | |
| options.pageToken = pageToken; | |
| } | |
| const response = Classroom.Courses.CourseWork.list( | |
| courseId, | |
| options | |
| ); | |
| if (response.courseWork) { | |
| courseWorks.push.apply( | |
| courseWorks, | |
| response.courseWork | |
| ); | |
| } | |
| pageToken = response.nextPageToken || null; | |
| } while (pageToken); | |
| return courseWorks; | |
| } | |
| /** | |
| * URLに含まれるClassroom画面上の識別子を抽出する。 | |
| */ | |
| function parseClassroomWebUrl(url) { | |
| const decodedUrl = decodeURIComponent(url); | |
| const courseMatch = decodedUrl.match(/\/c\/([^/?#]+)/); | |
| const courseWorkMatch = decodedUrl.match(/\/a\/([^/?#]+)/); | |
| if (!courseMatch) { | |
| throw new Error( | |
| 'URLからコース部分を取得できませんでした。' + | |
| '課題詳細ページのURLを指定してください。' | |
| ); | |
| } | |
| if (!courseWorkMatch) { | |
| throw new Error( | |
| 'URLから課題部分を取得できませんでした。' + | |
| '課題詳細ページのURLを指定してください。' | |
| ); | |
| } | |
| return { | |
| courseUrlId: courseMatch[1], | |
| courseWorkUrlId: courseWorkMatch[1] | |
| }; | |
| } | |
| /** | |
| * 対象コースか判定する。 | |
| */ | |
| function isTargetCourse(course, urlInfo, targetUrl) { | |
| if (!course) { | |
| return false; | |
| } | |
| if (String(course.id) === String(urlInfo.courseUrlId)) { | |
| return true; | |
| } | |
| if (!course.alternateLink) { | |
| return false; | |
| } | |
| const alternateLink = normalizeClassroomUrl( | |
| course.alternateLink | |
| ); | |
| if (alternateLink === targetUrl) { | |
| return true; | |
| } | |
| return alternateLink.indexOf( | |
| '/c/' + urlInfo.courseUrlId | |
| ) !== -1; | |
| } | |
| /** | |
| * 対象課題か判定する。 | |
| */ | |
| function isTargetCourseWork(courseWork, urlInfo, targetUrl) { | |
| if (!courseWork) { | |
| return false; | |
| } | |
| if ( | |
| String(courseWork.id) === | |
| String(urlInfo.courseWorkUrlId) | |
| ) { | |
| return true; | |
| } | |
| if (!courseWork.alternateLink) { | |
| return false; | |
| } | |
| const alternateLink = normalizeClassroomUrl( | |
| courseWork.alternateLink | |
| ); | |
| if (alternateLink === targetUrl) { | |
| return true; | |
| } | |
| return alternateLink.indexOf( | |
| '/a/' + urlInfo.courseWorkUrlId | |
| ) !== -1; | |
| } | |
| /** | |
| * アカウント番号やクエリ文字列を除いてURLを比較しやすくする。 | |
| */ | |
| function normalizeClassroomUrl(url) { | |
| return String(url) | |
| .trim() | |
| .replace(/\/u\/\d+\//, '/') | |
| .replace(/[?#].*$/, '') | |
| .replace(/\/+$/, ''); | |
| } | |
| function getAllStudents(courseId) { | |
| const students = []; | |
| let pageToken = null; | |
| do { | |
| const options = { | |
| pageSize: 100 | |
| }; | |
| if (pageToken) { | |
| options.pageToken = pageToken; | |
| } | |
| const response = Classroom.Courses.Students.list( | |
| courseId, | |
| options | |
| ); | |
| if (response.students) { | |
| students.push.apply(students, response.students); | |
| } | |
| pageToken = response.nextPageToken || null; | |
| } while (pageToken); | |
| return students; | |
| } | |
| function getAllSubmissions(courseId, courseWorkId) { | |
| const submissions = []; | |
| let pageToken = null; | |
| do { | |
| const options = { | |
| pageSize: 100 | |
| }; | |
| if (pageToken) { | |
| options.pageToken = pageToken; | |
| } | |
| const response = | |
| Classroom.Courses.CourseWork.StudentSubmissions.list( | |
| courseId, | |
| courseWorkId, | |
| options | |
| ); | |
| if (response.studentSubmissions) { | |
| submissions.push.apply( | |
| submissions, | |
| response.studentSubmissions | |
| ); | |
| } | |
| pageToken = response.nextPageToken || null; | |
| } while (pageToken); | |
| return submissions; | |
| } | |
| function convertSubmissionStatus(state) { | |
| switch (state) { | |
| case 'TURNED_IN': | |
| return '提出済み'; | |
| case 'RETURNED': | |
| return '提出済み・返却済み'; | |
| case 'CREATED': | |
| case 'NEW': | |
| return '未提出'; | |
| case 'RECLAIMED_BY_STUDENT': | |
| return '提出取り消し・未提出'; | |
| case 'NO_SUBMISSION_DATA': | |
| return '提出情報なし'; | |
| default: | |
| return '要確認'; | |
| } | |
| } | |
| function getTurnedInTime(submission) { | |
| if ( | |
| !submission || | |
| !submission.submissionHistory | |
| ) { | |
| return ''; | |
| } | |
| const turnedInTimes = submission.submissionHistory | |
| .filter(function (history) { | |
| return history.stateHistory && | |
| history.stateHistory.state === 'TURNED_IN' && | |
| history.stateHistory.stateTimestamp; | |
| }) | |
| .map(function (history) { | |
| return new Date( | |
| history.stateHistory.stateTimestamp | |
| ); | |
| }); | |
| if (turnedInTimes.length === 0) { | |
| return ''; | |
| } | |
| turnedInTimes.sort(function (a, b) { | |
| return b.getTime() - a.getTime(); | |
| }); | |
| return turnedInTimes[0]; | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment