授業情報ページを作成するscriptその2
実行内容
授業日程を全てタスクにする
日時は、今期の授業日程から自動的に計算する
到達度テストやその他変則的な日程は手動で作ること
installation
clipboardにコピーするver
json形式でdownloadするverをbuildする
使い方
シラバス情報のmodal windowを開いた状態で実行する
https://gyazo.com/6ee2be4678aadeacb893c250bb0ea6db
2023-04-08
15:32:47 授業開始時刻を修正
2023-04-07
10:13:17 とりあえず完成
copyするやつが便利
top level awaitでbuildしたら、なんとたまたま$がglobalに定義されていたJQueryを上書きしてしまった global汚染やめろtakker.icon
application examples
clipboardにコピーする
code:copy.ts
import { scrapeCLASS } from "./scrape.ts";
import { convert } from "./convert.ts";
import { stringify } from "./stringify.ts";
(async () => {
try {
const title = window.prompt("授業情報ページの頭文字");
if (title) {
const page = stringify(title, convert(scrapeCLASS(), new Date(2024, 3, 11))); await navigator.clipboard.writeText(page.lines.map(({ text }) => text).join("\n"));
}
} catch(e) {
if (!(e instanceof Error)) {
alert(JSON.stringify(e));
throw e;
}
alert(${e.name} ${e.message}\n\n${e.stack});
}
})();
jsonにしてdownloadする
code:download.ts
import { scrapeCLASS } from "./scrape.ts";
import { convert } from "./convert.ts";
import { stringify } from "./stringify.ts";
try {
const title = window.prompt("授業情報ページの頭文字");
if (title) {
const pages = stringify(title, convert(scrapeCLASS(), new Date(2024, 3, 11)));
window.open(URL.createObjectURL(blob));
}
} catch(e) {
if (!(e instanceof Error)) {
alert(JSON.stringify(e));
throw e;
}
alert(${e.name} ${e.message}\n\n${e.stack});
}
型チェック用
code:mod.ts
export * from "./stringify.ts";
export * from "./convert.ts";
export * from "./scrape.ts";
reviewは残す
2022後期を過ごして、この2つを分ける必要性が皆無に感じたので ページは作成せず、中身のないリンクとして配置する
授業計画の書式が教員によってまちまちで、正規表現できれいに分割できない
項目も、回ごとに分けている教員もいれば、複数回を一つの項目として説明している教員もいる
(以前の考察)タスクリンクは、親ページにリストで載せたほうがよかったかも
前後のタスクと比較しにくい
特に、日付をずらしたいとき面倒
code:stringify.ts
import { lightFormat } from "../date-fns/mod.ts";
import type { ImportedPage } from "../scrapbox-jp%2Ftypes/rest.ts";
import type { Course } from "./convert.ts";
/** 授業情報ページや各回の授業ページを作る */
export const stringify = (courseName: string, course: Course): ImportedPage[] => {
const title = `${courseName}-${course.semester.year}${
course.semester.quater.includes("前") ? "F" : "S"
}`;
const now = new Date();
const created = Math.round(now.getTime() / 1000);
const timeTag = lightFormat(now, "#yyyy-MM-dd HH:mm:ss");
const tasks = course.schedule.flatMap(
(dates, i) =>
dates.map(
(date) => ⬜@${lightFormat(date, "yyyy-MM-dd")}T${lightFormat(date, "HH:mm")}D90 ${title}-${i + 1}
)
);
const mainPage = {
title,
lines: [
title,
"table:basic information",
Title\t${course.title},
Title (en)\t${course.titleEn},
` Instructor\t${
course.instructors.map((instructor) => [${instructor}]).join(", ")
}`,
Schedule\t${course.semesterRaw} ${course.hoursRaw},
Course credits\t${course.credits},
Course code\t${course.code},
"LETUS",
[Syllabus https://class.admin.tus.ac.jp/slResult/${course.semester.year}/japanese/syllabusHtml/SyllabusHtml.${course.semester.year}.${course.code}.html],
"",
"Descriptions",
...course.description.split("\n").map((line) => ${line}),
"",
"Objectives",
...course.objectives.split("\n").map((line) => ${line}),
"",
"Outcomes",
...course.outcomes.split("\n").map((line) => ${line}),
"",
"Course notes prerequisites",
...course.prerequisites.split("\n").map((line) => ${line}),
"",
"Preparation and review",
...course.preparationAndReview.split("\n").map((line) => ${line}),
"",
"Evaluation",
...course.evaluation.split("\n").map((line) => ${line}),
"",
...(course.reference ? [
"Materials",
...course.reference.split("\n").map((line) => ${line}),
""
] : []),
"Plan",
...course.plan.split("\n").map((line) => ${line}),
...tasks.map((title) => [${title}]),
"",
...(course.experience ? [
"Experience",
...course.experience.split("\n").map((line) => ${line}),
""
] : []),
#${lightFormat(new Date(), "yyyy-MM-dd HH:mm:ss")},
].map((text) => ({ text, created })),
};
};
RawData[]を整形して、必要なデータを取り出す
変更点
授業日程
該当期の開始日と時間割から、15回分の日程を割り出す
休講日は考慮しない
手動で直す
❌回ごとに授業計画をばらす
回ごとの授業計画を/^\d+[.\s]/の行から次の/^\d+[.\s]/の前まで区切れるとみなす
これ以外の書式の授業計画には対応できない
止めた
教科書は有無だけ調べる
具体的な教科書名は、MyKiTSで検索しないとわからなくなってしまった 販売期間以外はPDFから調べる
これでも現在の学期以外の授業の教科書は調べられない
code:convert.ts
import { addDays, subDays, nextDay } from "../date-fns/mod.ts";
import type { RawData } from "./scrape.ts";
export interface Course {
title: string;
titleEn: string;
instructors: string[];
credits: number;
hoursRaw: string;
hours: ClassHours[];
semesterRaw: string;
semester: {
year: number;
quater: string;
}
description: string;
objectives: string;
outcomes: string;
prerequisites: string;
preparationAndReview: string;
evaluation: string;
textbook: boolean;
reference: string;
plan: string;
schedule: Date[][];
experience: string;
num: string;
code: string;
}
interface ClassHours {
hour: 1 | 2 | 3 | 4 | 5 | 6 | 7;
day: 0 | 1 | 2 | 3 | 4 | 5 | 6;
}
export const convert = (table: RawData[], startOfSemester: Date): Course => {
const title = table.find(({ header }) => header?.includes?.("名称"))?.content ?? "";
const titleEn = table.find(({ header }) => header?.includes?.("名称(英文)"))?.content ?? "";
const instructors =
table.find(({ header }) => header?.includes?.("教員名"))?.content?.split?.(/,、,/) ?.map?.((instructor) => instructor.replace(/ /g, "").trim()) ?? [];
const semesterRaw = table.find(({ header }) => header?.includes?.("開講年度学期"))?.content ?? "";
const semester = {
year: parseInt(semesterRaw),
quater: semesterRaw.match(/\d+年(.*)$/)?.1 ?? "", };
const hoursRaw = table.find(({ header }) => header?.includes?.("曜日時限"))?.content ?? "";
const credits = parseInt(table.find(({ header }) => header?.includes?.("単位"))?.content ?? "");
const description =
tidy(table.find(({ header }) => header?.includes?.("概要"))?.content ?? "");
const objectives =
tidy(table.find(({ header }) => header?.includes?.("目的"))?.content ?? "");
const outcomes =
tidy(table.find(({ header }) => header?.includes?.("到達目標"))?.content ?? "");
const prerequisites =
tidy(table.find(({ header }) => header?.includes?.("履修上の注意"))?.content ?? "");
const preparationAndReview =
tidy(table.find(({ header }) => header?.includes?.("準備学習・復習"))?.content ?? "");
const evaluation =
tidy(table.find(({ header }) => header?.includes?.("成績評価方法"))?.content ?? "");
const textbook =
table.find(({ header }) => header?.includes?.("教科書の使用有無"))?.content?.includes?.("Y") ?? false;
const refIndex = table.findIndex(
({ header }) => header?.includes?.("その他資料の使用有無")
);
const reference = tidy(refIndex === -1 ? "" : tablerefIndex + 1?.content ?? ""); const plan = tidy(
table.find(({ header }) => header?.includes?.("授業計画"))?.content
?.replace?.(/\s*\n\n\s*/g, "\n") // 空行を消す ?? ""
);
/** 授業のある日時
*
* 決め打ちで15回分作成する
*/
hours.map(({ day, hour }) => {
const first = nextDay(subDays(startOfSemester, 1), day);
switch (hour) {
case 1:
first.setHours(8);
first.setMinutes(50);
break;
case 2:
first.setHours(10);
first.setMinutes(30);
break;
case 3:
first.setHours(13);
first.setMinutes(0);
break;
case 4:
first.setHours(14);
first.setMinutes(40);
break;
case 5:
first.setHours(16);
first.setMinutes(20);
break;
case 6:
first.setHours(18);
first.setMinutes(0);
break;
case 7:
first.setHours(19);
first.setMinutes(40);
break;
}
return addDays(first, i * 7);
})
);
let experience =
tidy(table.find(({ header }) => header?.includes?.("実務経験"))?.content ?? "");
if (experience === "-") experience = "";
const num = table.find(({ header }) => header?.includes?.("科目番号"))?.content ?? "";
const code = table.find(({ header }) => header?.includes?.("授業コード"))?.content ?? "";
return {
title,
titleEn,
instructors,
semesterRaw,
semester,
hoursRaw,
hours,
credits,
description,
objectives,
outcomes,
prerequisites,
preparationAndReview,
evaluation,
textbook,
reference,
plan,
schedule,
experience,
num,
code,
};
};
/** テキストを整形する */
const tidy = (text: string): string => text
.replace(/^\s*/mg, "")
.replace(/\s$/mg, "")
.replaceAll("\n", "")
// 全角英数を半角に直す
.replace(
(s) => String.fromCharCode(s.charCodeAt(0) - 0xFEE0),
)
// リンク記法をescapeする
.replace(/\s?\[/g, "[")
.replace(/\s?\[/g, "[")
.replaceAll(".", ".\n")
// 番号付き箇条書きにする
.replace(/(\d+).\s*/g, "$1. ")
.replaceAll("・", " ")
// 句読点を変換する
.replaceAll(".", "。")
.replaceAll("。", "。\n")
.replaceAll(",", "、")
/** "X曜日N限"から曜日と時限を取り出す */
function* parseClassHours(text: string): Generator<ClassHours, void, unknown>{
for (const youbi, hourStr of text.matchAll(/(日|月|火|水|木|金|土)曜(1-7)限/g)) { yield {
day: youbi2day(youbi as "日" | "月" | "火" | "水" | "木" | "金" | "土"),
hour: parseInt(hourStr) as 1 | 2 | 3 | 4 | 5 | 6 | 7,
};
}
}
/** 漢字の曜日を曜日番号に変換する */
const youbi2day = (youbi: "日" | "月" | "火" | "水" | "木" | "金" | "土"):
0 | 1 | 2 | 3 | 4 | 5 | 6 => {
switch(youbi) {
case "日":
return 0;
case "月":
return 1;
case "火":
return 2;
case "水":
return 3;
case "木":
return 4;
case "金":
return 5;
case "土":
return 6;
}
};
シラバス情報のmodal windowからRawData[]を取り出す
DOM構造
全データは#pkx02301:ch:tableに入っている
divを用いた疑似テーブル
この中のdiv.rowStyleが一つのレコードに相当する
中身
div.ui-widget-header:見出し
div.ui-widget-content:内容
一つのレコードに↑は複数入っている場合がある
また片方が存在しない場合もある
code:scrape.ts
/// <reference no-default-lib="true" />
/// <reference lib="esnext" />
/// <reference lib="dom" />
export interface RawData {
header?: string;
content?: string;
};
/** CLASSのシラバス照会のページから、シラバス情報を抜き出す */
export const scrapeCLASS = (): RawData[] => {
const host = "class.admin.tus.ac.jp" as const;
if (location.host !== host) {
throw Error(This script can be only executed in "${host}");
}
const table = document.getElementById("pkx02301:ch:table");
if (!table) {
throw Error("シラバス照会window (#pkx02301:ch:table) が開かれていません");
}
const rows = Array.from(table.getElementsByClassName("rowStyle"));
return rows.flatMap((row) => {
const headers = Array.from(row.getElementsByClassName("ui-widget-header"));
const contents = Array.from(row.getElementsByClassName("ui-widget-content"));
const items: RawData[] = [];
for (let i = 0; i < Math.max(headers.length, contents.length); i++) {
let item: RawData = {};
if (headersi) item.header = (headersi as HTMLElement).innerText; if (contentsi) item.content = (contentsi as HTMLElement).innerText; items.push(item);
}
return items;
});
};