[Java]jsoupでブラウザから時刻合わせ

# 要件

* とあるアプリケーションで、時刻がすぐに狂ってしまう。
* NTPへの接続はできない。社内NTPも不可。
* アプリケーションはブラウザからIPアドレスでアクセスが出来る。
* ブラウザ上から手動で時刻設定は可能。
* 該当アプリケーションが動くサーバは、全国30台近く点在している。
* サーバは今後増減可能性あり。
* ログインパスワードはバッチ実行時に入力する（引数もしくはプロンプト）
* Javaは社内標準で導入されている。それ以外の言語の導入は手続きが必要。

#実行環境

* Windows8.1, 10 (メインは8だが、将来的に10になる可能性大)
* Java1.8以上

#開発環境

* Windows7
* Eclipse
* Java1.8
 * jsoup 1.10.2
* gradle 
 * applicationプラグインでbatファイルからjarを実行可能に設定

#ソースコード

## 時刻合わせ起動バッチ
* src\dist\以下に配置

```TimeAdjusterMain.bat
@echo off

rem ------------------------------------------------
rem --- 時刻合わせバッチ
rem ------------------------------------------------

rem -- カレントディレクトリを基準とする
cd /d %~dp0

echo **** 時刻合わせ開始: %date% %time%

rem -- 引数をチェックする(xxを付ける事で空にならないようにする)
if xx%1==xx goto confirmPw
goto setPw



:confirmPw

rem -- 引数が指定されていない場合、管理者パスワードの入力
set /P PASSWD=管理者パスワードを入力してください: 

goto exec



:setPw

rem -- 引数が指定されている場合、管理者パスワードとして設定
set PASSWD=%1

goto exec



rem -- 処理バッチの実行
:exec

rem -- 処理バッチの実行開始
call bin\TimeAdjuster.bat %PASSWD%

echo **** 時刻合わせ終了: %date% %time%

rem -- 完了させずとどまる
pause
```

## 処理対象IPアドレスリストtsv
* src\dist\以下に配置

```list.tsv
10.20.30.40	abc123	ABC123	A支店	Bフロア	Ａ－Ｂ＃１
10.20.50.60	def456	DEF456	C支店	Dフロア	Ｃ－Ｄ＃２
```

## Javaソースコード

### Main

```TimeAdjusterMain.java
package jp.co.sample.timeadjuster;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import jp.co.sample.timeadjuster.bean.SettingBean;
import jp.co.sample.timeadjuster.launcher.TimeAdjusterLauncher;

public class TimeAdjusterMain {

	private static Logger logger = LoggerFactory.getLogger(TimeAdjusterMain.class);

	public static void main(String[] args) {
		logger.info("TimeAdjusterMain 開始");

		// 設定情報
	    SettingBean setting;
	    try {
			// args[0]: 管理者パスワード
			// args[1]: src/distフォルダ絶対パス
			setting = new SettingBean(args[0], args[1], "list.tsv");
		} catch (Exception e) {
			logger.error("設定情報エラー", e);
			return;
		}

	    // 処理実行
	    TimeAdjusterLauncher launcher = new TimeAdjusterLauncher(setting);
	    try {
	    	launcher.execute();
		} catch (Exception e) {
			logger.error("時刻合わせ処理エラー", e);
			return;
		}
	}
}

```

### 設定情報

```SettingBean.java
package jp.co.sample.timeadjuster.bean;

import java.io.BufferedReader;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.ArrayList;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import org.apache.commons.csv.CSVFormat;
import org.apache.commons.csv.CSVRecord;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class SettingBean {

	public static Logger logger = LoggerFactory.getLogger(SettingBean.class);

	/**
	 * 管理者パスワード
	 */
	String passwd;

	/**
	 * 実行パスの基準ディレクトリ
	 */
	String basePath;

	/**
	 * tsvファイル名
	 */
	String tsvName;

	/**
	 * サーバ情報リスト
	 */
	ArrayList<ServerBean> serverList = new ArrayList<>();


	public SettingBean(String passwd, String basePath, String tsvName) throws IOException {
		this.passwd = passwd;

		this.basePath = basePath;

		logger.debug("this.basePath: "+ this.basePath);

		this.tsvName = tsvName;

		logger.debug("this.tsvName: "+ this.tsvName);

		File targetFile = new File(this.basePath, this.tsvName);
		if (!targetFile.isFile()) {
			logger.error("実在するファイルパスを指定して下さい。: "+ path);
			return;
		}

		// ファイルを読み込む
		try (BufferedReader reader = new BufferedReader(new InputStreamReader(new FileInputStream(targetFile),"UTF-8"));) {
			// TAB区切りのファイルで、ヘッダあり。空白は除外する
			Iterable<CSVRecord> records = CSVFormat.TDF.withIgnoreEmptyLines().withIgnoreSurroundingSpaces().parse(reader);
				for (CSVRecord record : records) {

					logger.debug(record.get(0));

					// サーバのIPアドレスはIPアドレス型のチェックを入れる
					// メモ帳でtsvファイルを作成すると、先頭に?が入ってしまうため
					Pattern p = Pattern.compile("\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}");
					Matcher m = p.matcher(record.get(0));

					if (!m.find()) {
						throw new RuntimeException("IPアドレスの指定方法が間違っています。 IPアドレス:"+ record.get(0));
					}

					// サーバ情報を取得し、リストに追加する
					ServerBean server = new ServerBean(m.group(), record.get(1), record.get(2), record.get(3), record.get(4), record.get(5));
					serverList.add(server);

					logger.debug("server: "+ server.getIpaddress() + " / "+ server.getSerial_no() + " / "+ server.getDevice_name());
				}
		} catch (FileNotFoundException e) {
			logger.error("TSVファイル読み込みエラー", e);
			throw e;
		} catch (IOException e) {
			logger.error("TSVファイル読み込みエラー", e);
			throw e;
		}

	}


	public String getPasswd() {
		return passwd;
	}


	public void setPasswd(String passwd) {
		this.passwd = passwd;
	}


	public String getBasePath() {
		return basePath;
	}


	public void setBasePath(String basePath) {
		this.basePath = basePath;
	}


	public String getTsvName() {
		return tsvName;
	}


	public void setTsvName(String tsvName) {
		this.tsvName = tsvName;
	}


	public ArrayList<ServerBean> getServerList() {
		return serverList;
	}


	public void setServerList(ArrayList<ServerBean> serverList) {
		this.serverList = serverList;
	}

}
```

### サーバ情報(tsvから読み込んだ情報)

```ServerBean.java
package jp.co.sample.timeadjuster.bean;

public class ServerBean {
	/**
	 * IPアドレス
	 */
	String ipaddress;

	/**
	 * ホスト名
	 */
	String host_name;

	/**
	 * シリアル番号
	 */
	String serial_no;

	/**
	 * 拠点名
	 */
	String base_name;


	/**
	 * 設置場所
	 */
	String installation_location;

	/**
	 * 機器名称
	 */
	String device_name;

	public ServerBean(String ipaddress, String host_name, String serial_no, String base_name,
			String installation_location, String device_name) {
		super();
		this.ipaddress = ipaddress;
		this.host_name = host_name;
		this.serial_no = serial_no;
		this.base_name = base_name;
		this.installation_location = installation_location;
		this.device_name = device_name;
	}

	public String getIpaddress() {
		return ipaddress;
	}

	public void setIpaddress(String ipaddress) {
		this.ipaddress = ipaddress;
	}

	public String getHost_name() {
		return host_name;
	}

	public void setHost_name(String host_name) {
		this.host_name = host_name;
	}

	public String getSerial_no() {
		return serial_no;
	}

	public void setSerial_no(String serial_no) {
		this.serial_no = serial_no;
	}

	public String getBase_name() {
		return base_name;
	}

	public void setBase_name(String base_name) {
		this.base_name = base_name;
	}

	public String getInstallation_location() {
		return installation_location;
	}

	public void setInstallation_location(String installation_location) {
		this.installation_location = installation_location;
	}

	public String getDevice_name() {
		return device_name;
	}

	public void setDevice_name(String device_name) {
		this.device_name = device_name;
	}

}
```

### 時刻合わせ処理

```TimeAdjusterLauncher.java
package jp.co.sample.timeadjuster.launcher;

import java.io.IOException;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;

import org.apache.commons.lang3.StringUtils;
import org.jsoup.Connection.Method;
import org.jsoup.Connection.Response;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import jp.co.sample.timeadjuster.bean.ServerBean;
import jp.co.sample.timeadjuster.bean.SettingBean;

public class TimeAdjusterLauncher {

	public static Logger logger = LoggerFactory.getLogger(TimeAdjusterLauncher.class);

	/**
	 * 設定情報
	 */
	SettingBean setting;

	/**
	 * ログイン画面表示結果
	 * Cookie等を保持
	 */
	Response loginRes;

	/**
	 * コンストラクタ
	 *
	 * @param setting 設定情報
	 */
	public TimeAdjusterLauncher(SettingBean setting) {
		super();

		// 設定を保持する
		this.setting = setting;
	}

	/**
	 * 時刻合わせ処理を実行します
	 * @throws RuntimeException
	 * @throws IOException
	 */
	public void execute() throws RuntimeException, IOException {

		// サーバ一台ごとに処理をループする
		for (ServerBean server : setting.getServerList()) {
			logger.info("時刻合わせ開始 ****************** ");
			logger.info("対象サーバ: "+ server.getIpaddress() + " / "+ server.getDevice_name());

			// ログイン
			login(server);

			// 現在設定時刻確認
			String zone = getTimer(server);

			// 新規時刻設定
			adjustTimer(server, zone);
		}

	}

	/**
	 * ログイン処理
	 * @throws RuntimeException
	 * @throws IOException
	 */
	private void login(ServerBean server) throws RuntimeException, IOException {
		// ログイン処理実行
		String loginUrl = "http://"+ server.getIpaddress() + "/logon.cgi";
		try {
			loginRes = Jsoup.connect(loginUrl)
				    .data("Password", setting.getPasswd())
				    .data("Mode", "Admin")
				    .data("Lang", "Japanese")
				    .method(Method.POST)
				    .execute();
		} catch (IOException e) {
			logger.error("初期画面ログインエラー", e);
			throw e;
		}

//		logger.debug(loginRes.parse().outerHtml());

		// HTTPステータス照合
		checkStatusCode(loginRes, loginUrl);


		if (StringUtils.contains(loginRes.parse().outerHtml(), "/pages/_top2.htm") ) {
			// ログインエラー画面にリダイレクトがかかっている場合、
			// 管理者ログインに失敗したという事なので、エラー終了
			throw new RuntimeException("管理者ログインに失敗しました。処理を中断します。");
		}


		// ログイン成功後、リダイレクトを行う
		// 実際には特に必要ない処理。確認のために行っただけ。
		String redirectUrl = "http://"+ server.getIpaddress() + "/pages/_devadm.htm";
		Response redirectRes;
		try {
			redirectRes = Jsoup.connect(redirectUrl)
				// ログイン時に取得したcookieを設定する
			    .cookies(loginRes.cookies())
			    .method(Method.GET)
			    .execute();
		} catch (IOException e) {
			logger.error("初期画面ログインリダイレクトエラー", e);
			throw e;
		}

//		logger.debug(redirectRes.parse().outerHtml());

		// HTTPステータス照合
		checkStatusCode(redirectRes, redirectUrl);

	}

	/**
	 * 現在サーバに設定されている時刻を取得します
	 *
	 * @param server
	 * @throws RuntimeException
	 * @return ゾーン
	 * @throws IOException
	 */
	private String getTimer(ServerBean server) throws RuntimeException, IOException {

		// サーバ時刻設定画面を取得します
		String timerUrl = "http://"+ server.getIpaddress() + "/pages/ed_time.htm";
		Response timerRes;
		try {
			timerRes = Jsoup.connect(timerUrl)
					// ログイン時に取得したcookieを設定する
				    .cookies(loginRes.cookies())
				    .method(Method.GET)
				    .execute();
		} catch (IOException e) {
			logger.error("時刻設定画面取得エラー", e);
			throw e;
		}

		// HTTPステータス照合
		checkStatusCode(timerRes, timerUrl);

//		logger.debug(timerRes.parse().outerHtml());

		Document doc = timerRes.parse();

		// 現在サーバに設定されている時刻を取得します。
		String year = doc.getElementsByAttributeValue("name", "DateYYYY").get(0).val();
		String month = doc.getElementsByAttributeValue("name", "DateMM").get(0).val();
		String day = doc.getElementsByAttributeValue("name", "DateDD").get(0).val();
		String hour = doc.getElementsByAttributeValue("name", "TimeHH").get(0).val();
		String minutes = doc.getElementsByAttributeValue("name", "TimeMM").get(0).val();
		String zone = doc.select("select option[selected]").get(0).val();

		logger.info("現在サーバ時刻: "+ year + "/"+ month + "/"+ day + " "+ hour +":"+ minutes + " ("+ zone + ")");

		// zoneだけ、既存値を使用するので、返します。
		return zone;
	}

	/**
	 * 時刻合わせを行います。
	 *
	 * @param server
	 * @param zone
	 * @throws RuntimeException
	 * @throws IOException
	 */
	private void adjustTimer(ServerBean server, String zone) throws RuntimeException, IOException {
		// 実行環境の現在時刻を取得します。
		LocalDateTime now = LocalDateTime.now();

		logger.info("現在時刻: "+ now.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME));

		// 1分後の0秒に合わせるため、次の時刻合わせのタイミングを取得します。
		LocalDateTime nextLdt =
		        LocalDateTime.now()
		        .plusMinutes(1)
		        .truncatedTo(ChronoUnit.MINUTES);

		logger.debug("合わせる時刻: "+ nextLdt.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME));

		// 待機ミリ秒
		long localDiffMsec = ChronoUnit.MILLIS.between(now, nextLdt);

		logger.debug("合わせる時刻まで、ミリ秒待機: "+ localDiffMsec);

		try {
			// 待機ミリ秒分、待つ
			Thread.sleep(localDiffMsec);
		} catch (InterruptedException e) {
			logger.error("サーバ時刻合わせミリ秒待機失敗");
		}

		logger.info("時刻合わせ実行");

		// サーバ時刻合わせ処理実行(POST)
		// なぜかcookieがなくても動いたので、そのままPOST投げてます
		String timerUrl = "http://"+ server.getIpaddress() + "/settime.cgi";
		Response timerRes;
		try {
			timerRes = Jsoup.connect(timerUrl)
				    .data("DateYYYY", ""+ nextLdt.getYear())
				    .data("DateMM", ""+ nextLdt.getMonthValue())
				    .data("DateDD", ""+ nextLdt.getDayOfMonth())
				    .data("TimeHH", ""+ nextLdt.getHour())
				    .data("TimeMM", ""+ nextLdt.getMinute())
				    .data("TimeZone", ""+ zone)
				    .method(Method.POST)
				    .execute();
		} catch (IOException e) {
			logger.error("サーバ時刻合わせ投稿エラー", e);
			throw e;
		}

//		logger.debug(timerRes.parse().outerHtml());

		// HTTPステータス照合
		checkStatusCode(timerRes, timerUrl);

		logger.info("時刻合わせ完了: "+ server.getIpaddress() + " / "+ server.getDevice_name() + " / " + nextLdt.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME));

	}

	/**
	 * HTTPステータスのチェック
	 *
	 * @param res
	 * @param url
	 * @throws RuntimeException
	 */
	private void checkStatusCode(Response res, String url) throws RuntimeException {

		if (res.statusCode() != 200) {
			// HTTPステータスが200(正常)ではない場合、エラーとする
			throw new RuntimeException("URLアクセス失敗: url="+ url + " status="+ res.statusCode());
		}
	}

}
```

# 所感
* jsoupというとスクレイピングがよくに出てきますが、こういう使い方もありますよ、という事で。
* ログイン->cookie保持->ログイン後処理 というjsoupコードが日本語であんまりなかったので、せっかくの機会ですし纏めてみました。
* jsoupで一番苦労するのは、実際に処理が実行されるURLがどういうものなのか、を見つける事ですね。form で指定されていたり、リダイレクトされたり…
* メモ帳でtsvを触ると、なぜか先頭に?が入ってしまう、という謎の現象が起きたので、IPアドレスの取得方法には正規表現を使用しています。エディタの指定とかできないんで…
* つい昔の習慣で、beanを作って詰めてしまいますが、最近はこうじゃないんですかね…新しい方法も勉強しないと ^^;


今年もコツコツ頑張っていきたいと思います。どうぞよろしくお願いいたします。