この記事は、静大情報 LT 大会(IT) Advent Calendar 2018の 8 日目の記事です。
TL;DR
Puppeteer はいいぞ
簡単な自己紹介
本ブログを大学内のコミュニティに公開するのは初めてなので簡単な自己紹介でもしたいと思います。
一番最初にもあるように静岡大学情報学部で 3 年間文系の学生をしています。3 年後期になってからは受講する講義も減り、10 月から始めたアルバイト先で主に Web に関する開発を行っています。このブログはそのアルバイトで行った開発についての方法を備忘録的に書き綴っているサイトです。
本題
ここから本題ですが、私が在学している大学では 1 年を 2 期に分けて、合間である夏季休暇・冬季休暇の間に学期ごとの成績を公開しています。成績の公開日に目安はあるのですが、目安以前にも公開されることが多く、GPA を気にしていた私は成績を見るのが休暇中の楽しみになっていました。
しかし、成績が公開されても通知などはなく、月曜日から金曜日までの間毎日のように大学のシステムにログインする必要がありました。そこで私の代わりにプログラムが大学のシステムにログインし、成績を Twitter で通知してくれると毎日繰り返すログインの日々にサヨナラできると思い、成績も殆ど出きった 9 月の下旬に作り上げ、1 回だけですが成績を通知することができました。ここではどのような方法を使ったのかをお話します。
学務情報システムの仕組み
学生が自身の学籍情報を参照するには大学が提供している学務情報システムにログインする必要があります。
学務情報システムでは大学側から発行された ID と、仮発行されたパスワードか自身で変更したパスワードを入力するのですが、私にはこのログインの機構がどのように行われているのか理解することができませんでした。
単純に ID とパスワードを POST して Web スクレイピングをしようとしたのですが、何回目のアクセスなのかを Cookie に保存して、その Cookie からセッションを生成しているようです。認証で用いる Cookie がどのように生成されているのか分からない上に、何回かの認証を伴うリダイレクトを行っているようで、リダイレクト中の Header を記録して眺めてもさっぱり分かりません。
そのためログインするには別のアプローチを取る必要がありました。調べている間に見つかったのが Puppeteer です。
Puppeteer
Puppeteerとは Google が開発しているフレームワークで、Headless Chrome を利用し、自動化テストなどを行うものです。
Puppeteer 登場以前は Selenium + PhantomJS の組み合わせがクローリングにおける鉄板だったそうですが、PhantomJS は更新が終了し、また Chrome が Headless モード(GUI が表示されない実行環境)に対応、Puppeteer を使うことでより簡単にクローリングができるようになりました。
Puppeteer の動かし方
Puppeteer は Node.js のライブラリなので、まず Node.js の実行環境が必要になります。
Node.js のインストール
Linux や Mac については各自で調べてもらうこととして、仮に OS が Windows であるならば Windows 用のパッケージマネージャであるchocolateyを使って Node.js をインストールすることをオススメします。
コマンドプロンプトを立ち上げ、以下を入力し実行するだけで chocolatey が使えるようになります。
@"%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -InputFormat None -ExecutionPolicy Bypass -Command "iex ((New-Object System.Net.WebClient).DownloadString('https://chocolatey.org/install.ps1'))" && SET "PATH=%PATH%;%ALLUSERSPROFILE%\chocolatey\bin"
インストール後、同様にコマンドプロンプトから以下を実行することで最新版の Node.js 環境が構築できます。
cinst nodejs -y
Poppeteer でスクリーンショットを撮る
試しに Puppeteer を使ってサイト全体のスクリーンショットを撮って保存するスクリプトを用意します。
適当なところに新しいディレクトリを用意して、ターミナルからnpm init
コマンドで初期化します。いくつか尋ねられますが全て Enter で問題ありません。
次にnpm install puppeteer
で Puppeteer のダウンロードが行われるので、実際に動かすための index.js を作成しましょう。
const puppeteer = require("puppeteer");
(async () => {
const browser = await puppeteer.launch({
headless: true
});
const page = await browser.newPage();
await page.goto("https://hi97.hamazo.tv");
await page.screenshot({
path: "example.png",
fullPage: true
});
await browser.close();
})();
node index.js
と実行するとサイトのサイズに合わせた画像が同一ディレクトリ内に保存されます。
単純な Web スクレイピングでは JavaScript などで動的に生成されたものを取得できませんが、Puppeteer では動的サイトのスクレイピングも可能です。
学務情報システムから成績取得する
では実際に学務情報システムから成績を取得するスクリプトはどのようであったかを公開します。なお本来学務情報システムにログインできる人しか得られない URL は念のため伏せています。
const { CronJob } = require("cron");
const puppeteer = require("puppeteer");
const config = require("dotenv").config();
const fs = require("fs");
const Twit = require("twit");
const T = new Twit({
consumer_key: process.env.TWITTER_CONSUMER_KEY,
consumer_secret: process.env.TWITTER_CONSUMER_SECRET,
access_token: process.env.TWITTER_ACCESS_TOKEN,
access_token_secret: process.env.TWITTER_ACCESS_TOKEN_SECRET
});
let currentNum = 0;
try {
const seiseki = JSON.parse(fs.readFileSync("seiseki.json"));
currentNum = Object.keys(seiseki).length;
} catch {}
const accessPage = async () => {
const browser = await puppeteer.launch({
headless: true
});
const page = await browser.newPage();
await page.goto("https://gakujo.shizuoka.ac.jp/portal/");
await page.click(".btn_login");
await page.waitForNavigation();
await page.type("#username", process.env.USER_ID);
await page.type("#password", process.env.PASSWORD);
await page.click("button");
await page.waitForRequest(""); // 学務情報システムのトップページのURL
await page.goto(""); // 教務システムのトップページのURL
await page.click('a[onclick*="seisekiSearch"]');
await page.waitForNavigation();
await page.click('a[onclick*="shuutokuDate"]');
await page.waitForNavigation();
const getScrapingData = await page.evaluate(() => {
const data = new Object();
const thList = document
.querySelector("table.txt12>tbody>tr")
.querySelectorAll("td");
const trList = document.querySelectorAll("table.txt12>tbody>tr");
trList.forEach((tr, i) => {
data[i] = tr;
const tdList = tr.querySelectorAll("td");
tdList.forEach((td, i) => {
tr[thList[i].textContent] = td.textContent.replace(/\s/g, "");
});
});
delete data[0];
return data;
});
browser.close();
const scrapedNum = Object.keys(getScrapingData).length;
const timestamp = Date.now();
console.log(currentNum, scrapedNum, new Date(timestamp).toLocaleString());
if (currentNum == 0 || currentNum != scrapedNum) {
fs.writeFile(
"seiseki.json",
JSON.stringify(getScrapingData, null, "\t"),
error => {
if (error) {
throw error;
}
}
);
} else {
for (let i = currentNum; i < scrapedNum; i++) {
const subjectName = getScrapingData[i]["科目名"];
T.post("statuses/update", {
status: `【成績通知bot】\n${subjectName}の成績が公開されました`
});
}
}
currentNum = scrapedNum;
};
new CronJob(
"*/15 9-21 * * 1-5",
() => {
accessPage();
},
null,
true
);
学務情報システムに使う ID やパスワード、Twitter の API トークンなどは見られてしまうとマズいため、.env を使って環境変数としています。
このプログラムでは平日午前 9 時から 12 時間、15 分の頻度でメソッドを呼び出し、json ファイルに内容を書き出して、15 分前の json ファイルと比べて変更があれば Twitter でその科目名を呟くようになっています。
動かす際には forever を使ってエラーが発生しても終了しないように永続化する必要があるので、適宜インストールします。
まとめ
文系大学生でも Puppeteer を使うことで、他の学生も面倒に感じている人が多かった成績の取得を自動化することができました。
本当は他の人にも使って欲しかったのですが、他人の ID とパスワードを管理するだけのセキュリティや責任を持てなかったので自分だけで使う結果となりました。成績の他にも Puppeteer と Google カレンダーの API を使えば、自分が登録した科目をカレンダーに登録することもできるかもしれません。
是非 Puppeteer を使って、今まで面倒だった単純作業を自動化してみてください。