顧客フロントSEのIT勉強ブログ

2022 Japan AWS Top Engineer / 2022-23 Japan AWS Certifications Engineer。AWS認定12冠、情報処理試験全冠。顧客フロントSEがなるべく手を動かしながらIT技術を学んでいくブログです。

Lambda(Java)でスクレイピング処理を実装してみた

パソコンからアクセスされている方は、画面右のサイドバーに読書メーターの棒グラフが表示されていると思いますが、これは読書メーターというサイトをLambdaでスクレイピングした結果をグラフ化しています。

今回はこのブログパーツに関する記事第2弾です。Lambdaによるスクレイピング処理の実装方法をまとめます。

処理方式概要

ブログパーツ全体の処理方式は次のとおりです。

大きくは「処理①:Lambdaによるスクレイピング」「処理②:Google Chartsによるグラフ化」の2ステップを踏んでいます。詳細はブログパーツに関する記事第1弾に記載しています。

今回は処理①のスクレイピング処理について、処理内容を深堀りしていきます。

事前準備

IDEとしてEclipseを採用したので、AWS Toolkit for Eclipseというプラグインを使います。
プラグインのインストール方法は下記サイトの通りです。Eclipse[Instrall New Software]から対象のコンポーネントを選択するとインストールできます。

インストール後にAWS認証情報(IAMユーザのアクセスキー)を設定すればEclipseから直接AWSへアクセスすることもできますが設定しなくても良いです。基本的にアクセスキーは不必要に発行しないことが推奨されていますので、個人的には登録しなくてもよいかなと思います。

インストールが完了したらLambda用Javaプロジェクト作成の準備を進めます。EclipseAWSアイコンが増えているはずなので、そこから[新規AWS Lambda Javaプロジェクト]を選択します。

プロジェクト名は「scraping-lambda」にしました。
最終的にこのLambdaはEventBridgeから起動するつもりなので入力タイプは「Custom」です。

次にJavaスクレイピングを行うためにjsoupというライブラリを使います。
jsoupを導入するとJavaでHTMLを編集・解析するために便利なAPI(多数のクラス)を利用できるようになります。

上記で作成したLambda JavaプロジェクトはMavenがプロジェクト管理ツールとして採用されています。Mavenとは何ぞや、の説明は個々では割愛しますが、プロジェクトのルートディレクトリ配下にあるpom.xmlファイルに下記を入力することでjsoupを外部リポジトリからダウンロードしてプロジェクトに取り込みます。
versionタグの部分は、原則その時点の最新バージョンを指定すればよいと思います。

    <dependency>
        <groupId>org.jsoup</groupId>
        <artifactId>jsoup</artifactId>
        <version>1.15.1</version>
    </dependency>

これで事前準備が整いました。

Javaアプリを実装する

まず今回実装するJavaアプリのクラス図は次のとおりです。

シンプルなアプリなので別にクラスを分ける必要はないのですが、自分のJava勉強もかねて2つのクラスを作っています。
LambdaFunctionHandlerクラスがLambdaから呼び出されるメインクラスでhandleRequestというメソッドだけを持っています。そのメソッド内から呼び出されるのがScrapingInfoクラスで、scrapingInfoメソッドに読書メーターのマイページURLを引数として引き渡すと必要情報をスクレイピングして文字列の配列として返却してくれます。

STEP1)読書メーターのマイページを開き、必要情報をスクレイピングする

ScrapingInfoクラスの実装は次のとおりです。

package com.amazonaws.lambda.demo;

import java.io.IOException;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.jsoup.select.Elements;

public class ScrapingInfo{
public String[] scrapingInfo(String url) throws IOExeption{
// スクレイピング処理を格納する変数
String[] info = new String[2];

// 読書メーターのマイページにGETリクエストを送る
Document doc = Jsoup.connect(url).get();
// マイページのプロフィールを取得する
Elements elements = doc.select("dd.bm-details-side__item");
// 必要情報をスクレイピングする
for (Element element; elements){
// 読んだ本
if (element.text().contains("冊")){
info[0] = element.text().substring(0, element.text().indexOf("冊"));
}
// 読んだページ
if (element.text().contains("ページ")){
info[1] = element.text().substring(0, element.text().indexOf("ページ"));
}
}
return info;
}
}

読書メーターのHTMLソースを見ると、bm-details-side__itemというクラス名をつけられたddタグ内に読んだ本の冊数やページ数が格納されているようだったので、その部分だけを取得し、冊数・ページ数の数値部分だけを抽出(substring)しています。

STEP2)S3からファイルを取得して、記載されているデータを配列に格納する

次にメインであるLambdaFunctionHandlerクラスの実装です。
handleRequestメソッドに全処理が実装されているのですが、少し長いのでまずは前半部分を記します。

package com.amazonaws.lambda.demo;

import java.io.*;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Calender;
import java.util.Date;
import com.amazonaws.regions.Regions;
import com.amazonaws.services.lambda.runtime.Context;
import com.amazonaws.services.lambda.runtime.RequestHandler;
import com.amazonaws.services.s3.AmazonS3;
import com.amazonaws.services.s3.AmazonS3ClientBuilder;
import com.amazonaws.services.s3.model.GetObjectRequest;
import com.amazonaws.services.s3.model.S3Object;


public class LambdaFunctionHander implements RequestHander<Object, String>{
private final String url = "読書メーターのマイページURL";
private final String bucketName = "S3バケット名";
private final String key = "読書記録のファイル名";

@Override
public String handleRequest(Object input, Context context){
String message = null; //エラーメッセージ格納用変数の初期化
String[] bookInfo = null; //スクレイピング結果格納用変数の初期化

// ScrapingInfoクラスをインスタンス化してスクレイピング
try {
ScrapingInfo scraping = new ScrapingInfo();
bookInfo = scraping.scrapingInfo(url);
} catch(IOException e) {
message = e.getMessage();
}
(下に続く)

JavaのLambda関数向けにRequestHanderというインターフェースが用意されています。このインターフェースにはhandleRequestという抽象メソッドが定義されているので、@Overrideアノテーションをつけて実装します。
まずこのメソッドの最初では、先ほど定義したScrapingInfoをインスタンス化してスクレイピング結果を取得しています。

(先ほどの続き)
// S3ファイル取得のためのクライアント生成
AmazonS3 s3client = AmazonS3ClientBuilder
.standard()
.withRegion(Regions.AP_NORTHEAST_1)
.build();

// S3から対象のファイルを取得する
S3Object getObject = s3Client.getObject(new GetObjectRequest(bucketName, key));
try(BufferedReader br = new BufferedReader(new InputStreamReader(getObject.getObjectContent()))){
String line; //取得したファイルの行データ格納用変数
String[] newline = new String[21]; //作成するファイルの行データ格納用変数
String lastday = null; //取得したファイルに記載された最終日

int num = 0; //取得したファイルの行インデックス
int newnum = 0; //作成するファイルの行インデックス

// 21日前の日付を取得
SimpleDateFormat dateformat = new SimpleDateFormat("yyyymmdd");
Calender cal = Calender.getInstance();
Date today = cal.getTime();
cal.setTime(today);
cal.add(Calender.Date, -21);

// ファイルを1行ずつ読み込み、21日以前のデータは読み捨て
while((line = br.readLine()) != null){
String[] item = line.split(","){
lastday = item[0];
if (dateformat.parse(item[0].before(cal.getTime())){
num++;
continue;
} else {
num++;
newline[newnum++] = line;
}
}
(下に続く)

次にAmazonS3ClientBuilderクラスを使ってS3へ接続するクライアントを作成しています。standardメソッドは生成するクライアントに対してデフォルト設定を適用するメソッドで、staticメソッドなのでインスタンス化せず呼び出せています。
そのクライアントに対してgetObjectメソッドを使うことでファイルを取得します。
今回S3に格納しているファイルは「日付(yyyymmdd形式), 日付(mm/dd形式), ページ数, 冊数」のフォーマットでデータ保持しているのですが、3週間(=21日)の読書記録をグラフ化したいので、読み込んだファイルから21日以前の日付のデータは読み捨てて、それ以降のデータはそのまま変数newlineに格納しています。

STEP3)古いデータを削除し、スクレイピングで取得した情報に差し替えてS3にファイルをアップロードする

LambdaFunctionHandlerクラスの続きです。

(先ほどの続き)
// 取得したファイルの最終日
Date dt = dateformat.parse(lastday);
cal.setTime(dt);

// 取得したファイルの最終日から当日までスクレイピング結果を埋める
for(int i = 0; i < num - newnum + 1; i++){
String day1 = dateformat.format(cal.getTime());
String day2 = day1.substring(4,6) + "/" + day1.substring(6,8);
newline[newnum + i -1] = day1 + "," + day2 + "," + bookInfo[1], "," + bookInfo[0];
cal.add(Calender.Date, 1);
}

// Lambdaのtmp領域にファイル作成
File file = new File("/tmp/" + key);
PrintWriter pw = new PrintWriter(new BufferedWriter(new FileWriter(file)));
for (int i = 0; i < num; i++){
pw.println(newline[i]);
}
pw.close();

// S3へファイルアップロード
s3Clinet.putObject(bucketName, key, file);

} catch (IOException | ParseException e) {
System.out.println(e);
return e.getMessage();
} finally {
s3Client.shutdown();
}
return message;
}
}

後述のEventBridgeで毎日1回Lambdaを起動するように仕込むものの、万が一数日稼働しなかった場合に備えて、取得したファイルに記載されていたデータの最終日から当日までの日付をスクレイピングで取得したページ数・冊数で埋めています。
これにより、もしLambdaが数日未稼働でも日付が歯抜けのグラフが作成されることを避けています。

Lambdaは/tmp以下でローカルの一時ファイルシステムが使用できるので、そこに新しいファイルを作成し、それを最終的にS3へアップロードしています。

これで実装したJavaアプリは全てです。

最後にこのアプリをビルドします。Eclipseで対象プロジェクトを右クリックして「実行」→「Maven install」をクリックします。

すると、Targetフォルダ配下にdemo-1.0.0.jar(プロジェクト作成時に指定したアーキファクトID-バージョン.jar)が出来上がっているはずです。

Lambda関数を定義する

1.AWS Lambdaコンソールから「関数の作成」ボタンをクリックします。

2.関数名に「ScrapingBookMeter」、ランタイムとして「Java 11 (Corretto)」を指定して「関数の作成」ボタンをクリックします。

3.出来上がったLambda関数において、「コード」→「コードソース」→「アップロード元」から「.zipまたは.jarファイル」を選び、先ほどビルドしたdemo-1.0.0.jarをアップロードします。

4.ハンドラ(=Lambdaが起動するメソッド)の指定を更新します。これまでの実装に即していれば「com.amazonaws.lambda.demo.LambdaFunctionHandler::handleRequest」になるはずです。

5.このままだとLambda関数がS3にアクセスする権限がないので、「設定」→「アクセス権限」から関数にアタッチされているロールをクリックします。

6.許可ポリシーで「許可を追加」→「ポリシーをアタッチ」をクリックします。今回は少し雑に「AmazonS3FullAccess」をアタッチします。

7.これでLambda関数が動くはずです。「テスト」ボタンをクリックしてLambda関数を動かしてみます。実行結果:成功となれば正常稼働しています。
※今回のJavaは対象のS3に既存で読書記録のファイルがあることを前提にしているので、初回は手動で対象ファイルを作成しておいてください。

EventBridgeから起動する

最後にこのLambda関数が毎日自動起動されるようEventBridgeを仕掛けます。
1.先ほどのLambda関数で「トリガーを追加」をクリックします。

2.トリガーとして「EventBridge」を設定します。今回は1日1回朝に動かしたいので、ルール名「one-day」、スケジュール式に「cron(0 01 * * ? *)」を指定します。cron式の形式は
cron(Minutes Hours Day-of-month Month Day-of-week Year)
なので、これで毎日1:00(UTC)に起動という定義になります。

これで全ての実装が完了しました。翌日10時(=UTC 1時)にS3ファイルを見ると、読書メーターのマイページに表示されているページ数・冊数が記録されているはずです。

まとめ

前回の記事に引き続き、ブログパーツの実装を解説する流れでLambda(Java)によるスクレイピング処理を作ってみました。
ちなみに、Lambda関数ってデフォルト設定でインターネットにアクセスできるんですね。
「NAT Gatewayを用意したVPCにLambda関数を接続して~」とか考えていたんですが不要でした。