Create a gist now

Instantly share code, notes, and snippets.

A scraper for www.kenketsu.jp
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import okhttp3.*
import org.jsoup.Jsoup
import java.io.IOException
import java.io.Serializable
import java.net.CookieManager
import java.nio.charset.Charset
import java.text.ParseException
import java.util.logging.*
class HttpResponseException(val statusCode:Int) : Exception(statusCode.toString())
class ErrorPageException(val errorCode:Int) : Exception(errorCode.toString())
/**
* 献血日時などの記録
*/
class RecordIndex() : Serializable {
data class RecordData(val id: String, val place: String, val type: String) : Serializable {
override fun toString(): String { return "id: $id, place: $place, type: $type" }
}
var data: MutableMap<String, RecordData> = mutableMapOf()
operator fun get(key: String) = if (data.containsKey(key)) data[key] else null
operator fun set(key: String, recordList: List<String>) {
if (recordList.count() != 3) throw IllegalArgumentException()
val recData = RecordData(recordList[0], recordList[1], recordList[2])
this.data[key] = recData
}
override fun toString(): String {
val textBuilder = StringBuilder()
for ((key, value) in data) textBuilder.append("[$key] $value\n")
return textBuilder.toString()
}
fun append(that: RecordIndex) = this.data.plusAssign(that.data)
fun contains(that: RecordIndex): Boolean = that.data.keys.any { data.containsKey(it) }
}
/**
* 血液検査結果データ
*/
class BloodData() : Serializable {
// スクレイピング結果は文字列で出るし整数と小数が混じってるので、数値は取りあえず文字列で受ける
var data: MutableMap<String, List<String>> = mutableMapOf()
var headers: MutableMap<String, List<String>> = mutableMapOf()
operator fun get(key: String) = if (data.containsKey(key)) data[key] else null
operator fun set(key: String, bloodDataList :List<String>) {
val fixedBloodDataList = bloodDataList.map {
// スペースのみのデータを空文字に置き換える
value -> if (value.isNullOrBlank()) "" else value
}
when (key) {
"header1", "header2" -> {
this.headers[key] = fixedBloodDataList
}
else -> this.data[key] = fixedBloodDataList
}
}
/*
* [H28.11.26] 献血種類: 400mL, 血圧(最高):130, ... みたいな形で文字列型を返す
*/
override fun toString(): String {
val textBuilder = StringBuilder()
for ((key, value) in data) {
val header:List<String>? = this.headers["header1"]
textBuilder.append("[$key] ")
for (i in 0..value.count()-1)
if (!value[i].isNullOrEmpty()) // データが空の項目は除外
textBuilder.append("${header?.get(i)}: $value[i], ")
textBuilder.append("\n")
}
return textBuilder.toString()
}
fun append(that: BloodData) {
for ((key, value) in that.data+that.headers) {
this[key] = value
}
}
}
/**
* (仮)
* いまんとこインスタンス生成後 next() でデータをダウンロードし
* recordIndex と bloodData に直接アクセスするのみ
* TODO: ネットワークアクセス関係を別クラスに切り出す
*
* @param userId: String ユーザID(数字10桁・ハイフンなし)
* @param loginPassword: String ログインパスワード(英数字)
* @param recordPassword: String 献血結果に入るためのパスワード(数字4桁)
*/
class Scraper(userId: String, loginPassword: String, recordPassword: String) : Serializable {
companion object {
val logger: Logger? = Logger.getLogger("Scraper")
}
private var currentPage: Int = 0
private var maxPage: Int = 1
private var client = HttpClient(userId, loginPassword, recordPassword)
var recordIndex = RecordIndex()
var bloodData = BloodData()
init {
//val cookieHandler = CookieManager()
//Scraper.client = OkHttpClient.Builder().cookieJar(JavaNetCookieJar(cookieHandler)).build()
}
/**
* recoredIndex と bloodData のインスタンスを json 化して返す
*
* @return : String json 化した Pair(this.recordIndex, this.bloodData)
*/
fun exportData(): String {
return Gson().toJson(Pair(this.recordIndex, this.bloodData))
}
/**
* exportData() で書き出した json 文字列を受け取って
* this.recordIndex, this.bloodData にセットする
*
* @param data: String exportData() で返された文字列
*/
fun importData(data: String) {
// ref -> http://stackoverflow.com/questions/33381384/how-to-use-typetoken-generics-with-gson-in-kotlin
val type = object : TypeToken<Pair<RecordIndex, BloodData>>() {}.type
val (first, second) = Gson().fromJson<Pair<RecordIndex, BloodData>>(data, type)
this.recordIndex = first
this.bloodData = second
}
/**
* データ取得にはまずこれを呼ぶ!!
* ログインしてなければログイン処理から、ログイン済みなら献血記録の次のページからデータをスクレイピング
* TODO: エラー処理書く!!!
*
* @return : Boolean データが取得できたら true (新しいデータが取得できたかは問わない←一考の余地あり
*/
fun next(): Boolean {
Scraper.logger?.log(Level.FINE, "next()")
if (this.currentPage >= this.maxPage) return false
try {
if (this.getRecordIndex() == false) return false
this.getBloodData()
return true
}
// ネットワークエラーとか
catch(e: IOException) {
e.printStackTrace()
}
// 200番台以外が返ってきた
catch(e: HttpResponseException) {
e.printStackTrace()
Scraper.logger?.log(Level.SEVERE, "StatusCode: ${e.statusCode}")
}
// エラーページチェックに引っかかった
catch(e: ErrorPageException) {
e.printStackTrace()
Scraper.logger?.log(Level.SEVERE, "ErrorPageNo: ${e.errorCode}")
}
// parse()で必要なHTML要素がなかった場合に投げるようにしたい
catch (e: ParseException) {
e.printStackTrace()
}
return false
}
/**
* parseRecordIndex() から献血記録ページ内のデータを取得し、this.recordIndex に代入
* 取得したデータが全て this.recordIndex に既にあったならば再帰呼び出しを行い、
* データが更新できるか maxPage に行き着くまで繰り返す
*
* @return : Boolean データが更新できたら true そうでなければ false
*/
private fun getRecordIndex(): Boolean {
if (this.currentPage >= this.maxPage) return false
// html を取得
val content: String = this.client.getRecordIndexHtml(++this.currentPage)
// html をパースし RecordIndex インスタンスと maxPage を得る
val (newRecordIndex, maxPage) = this.parseRecordIndex(content)
// 得た RecordIndex が新しくないなら再帰呼び出しで次のページを取得
// いずれにしても maxPage は先に更新する
if (this.maxPage < maxPage) this.maxPage = maxPage
if (this.recordIndex.contains(newRecordIndex)) {
Scraper.logger?.log(Level.INFO, "getRecordIndex: Recursive call")
getRecordIndex()
} else {
this.recordIndex.append(newRecordIndex)
return true
}
// 新しいデータが無いまま最終ページに達した場合のみここにくる
return false
}
/**
* 血液検査結果のHTMLを取得しパースしデータを更新する
* このメソッドは this.recordIndex のキーを全て走査して this.bloodData に格納済みか調べ
* なければ実際にネットワークアクセスを行う
* 無駄にネットワークアクセスを行うことはないが毎回フルにループ回すので非効率的……現実的に問題はないと思うけど
*/
private fun getBloodData() {
val index = this.recordIndex
/* 献血結果のページには5回分のデータが記載されているため、
このループは1回アクセス(5回分のデータ取得)→4回else側に回る を繰り返す */
for ((key, value) in index.data) {
if (this.bloodData[key] == null) {
Scraper.logger?.log(Level.FINE, "getBloodData: date=$key Download")
val id = value.id // index.keys()を回してるんだから!!して大丈夫
val content = this.client.getBloodDataHtml(id)
val newBloodData = this.parseBloodData(content)
this.bloodData.append(newBloodData)
} else {
Scraper.logger?.log(Level.FINE, "getBloodData: date=$key Pass")
}
}
}
/**
* 献血記録の日時一覧が載ってるページからデータを取得する
* 最大で10回分の献血データを取得
*
* @param html: String 解析対象のHTML文字列
* @return Pair<RecordIndex, Int> 取得したRecordIndexのインスタンスとmaxPage
*/
private fun parseRecordIndex(html: String): Pair<RecordIndex, Int> {
// パース失敗時にthrowすることを考える
val document = Jsoup.parse(html)
val partOfRecordIndex = RecordIndex()
// 献血記録のテーブル
val trElements = document.select("#recordIndex tr")
for (trElement in trElements) {
val tdElements = trElement.getElementsByTag("td")
if (tdElements.count() > 0) {
// <td>のある列
val linkId = Regex("""open_done\('(\d+)'\);""").find(trElement.html())?.groupValues?.get(1)
if (linkId != null) {
partOfRecordIndex[tdElements[1].text()] = listOf(linkId, tdElements[2].text(), tdElements[3].text())
} else {
throw Exception("linkIdが無い")
}
} else {
// thの列もしくはエラー thでなければthrowしたほうがいいかも
}
}
// テーブル下のページ移動用リンクから最終ページを取得
// ここ変な HTML が渡ってたりすると NPE 起こる
val lastPage = document.select("a[href^=\"./list.asp?cmdScroll=Page+\"]").last().text().toInt()
return Pair(partOfRecordIndex, lastPage)
}
/**
* 血液検査結果のページからデータを抽出しBloodDataインスタンスを返す
* 見出し2列(header1, header2)と最大献血5回分のデータを含む
*
* @param html: String 解析対象のHTML文字列
* @return : BloodData 取得したデータを格納したBloodDataインスタンス
*/
fun parseBloodData(html: String): BloodData {
val document = Jsoup.parse(html)
val partOfBloodData = BloodData()
/*
tr(行)は上から
献血年月日・献血種類・献血時(現在データなし)・血圧↑・血圧↓・脈拍・
生化学検査(見出し)・7~13が生化学検査データ・
血球計数検査(見出し)・15~22が血球計数検査データ
td(列)は左から
項目名・基準値(行7~13・25~22のみ)・2以降が実際のデータ
*/
val trElements = document.select("#recordDetail tr")
val trCount = trElements.count()
val tdCount = trElements[0].getElementsByTag("td").count()
val dataList: MutableList<MutableList<String>> = mutableListOf()
for (i in 0..tdCount - 1) dataList.add(mutableListOf())
for (trIndex in 0..trCount - 1) {
val tdElements = trElements[trIndex].getElementsByTag("td")
for (tdIndex in 0..tdCount - 1) {
val td = tdElements[tdIndex]
val tdContent = if (trIndex == 0 && tdIndex == 0) "header1"
else if (trIndex == 0 && tdIndex == 1) "header2"
else td.text()
dataList[tdIndex].add(tdContent)
}
}
// data[0]に日時が入っててkeyになる
// data[1-]には無駄なデータも入っているが、ゴミを分別するならBloodData.set()で
for (data in dataList) partOfBloodData[data[0]] = data.drop(1)
return partOfBloodData
}
}
/**
* インターネットへのアクセスを行い HTML を返したりする
* 取得したレスポンス(主に HTML)にタッチはしない
*/
class HttpClient(private val userId: String, private val loginPassword: String, private val recordPassword: String) : Serializable {
companion object {
val client = OkHttpClient.Builder().cookieJar(JavaNetCookieJar(CookieManager())).build()
}
var isLoggedIn = false
/**
* OkHttp3.ResponseBodyからエンコードした文字列型のcontentsを返すだけ
*/
private fun responseBodyToString(body: ResponseBody): String =
kotlin.text.String(body.bytes(), Charset.forName("Shift_JIS"))
/**
* 複数回献血クラブにログイン試行する
* ログインに成功すると献血記録の1ページ目に行き着くので、そのページの html 文字列を返す
*
* @return : String 献血記録1ページ目の html 文字列
*/
private fun login(): String {
Scraper.logger?.log(Level.FINE, "login()")
/* URL、multiformデータ*/
val urlList: List<String> = listOf(
"https://www.kenketsu.jp/nskc/user/login.asp",
"https://www.kenketsu.jp/nskc/user/done/passwdexe.asp"
)
val formDataList: List<Map<String, String>> = listOf(
mapOf("mode" to "login", "user_id" to this.userId, "password" to this.loginPassword),
mapOf("mode" to "login", "passwd" to this.recordPassword, "dummy" to "")
)
var content = ""
for (i in 0..1) {
content = access(urlList[i], formDataList[i])
val errorCode = this.isErrorPage(content)
if (errorCode != 0) throw Exception("login failed: errorCode=$errorCode")
}
this.isLoggedIn = true
return content
}
/**
* 献血記録ページの HTML を取得する
* 引数が与えられなければ this.currentPage をインクリメントしながら次のページを順次取得する
*
*/
fun getRecordIndexHtml(page: Int): String {
return if (isLoggedIn) {
val url = "https://www.kenketsu.jp/nskc/user/done/list.asp?cmdScroll=Page+$page"
access(url)
} else {
this.login()
}
}
fun getBloodDataHtml(id: String): String {
val url = "https://www.kenketsu.jp/nskc/user/done/done_view.asp?done_id=$id"
return this.access(url)
}
/**
* Scraper.client:OkHttpClientで通信を行う
* @param url: String POSTするURL
* @param formdata: Map<String, String> POSTするmulti-form fata
* @return : String HTML文字列を返す
*/
fun access(url: String, formdata: Map<String, String>? = null): String {
// TODO: ここ整理する
Scraper.logger?.log(Level.FINE, "access($url)")
val req = if (formdata != null) {
// post method
val reqBody = FormBody.Builder()
for ((key, value) in formdata)
reqBody.add(key, value)
val formBody = reqBody.build()
Request.Builder().url(url).post(formBody).build()
}
else { // get method
Request.Builder().url(url).build()
}
val response = HttpClient.client.newCall(req).execute()
// 200番台はOKにしたほうが
if (response.code() != 200) throw HttpResponseException(response.code())
val html: String = responseBodyToString(response.body())
//val errorCode: Int = isErrorPage(html)
//if (errorCode != 0) throw ErrorPageException(errorCode)
return html
}
/**
* htmlを解析しエラーページではないかを調べる 特定の要素や文字列があればエラーページと判断
* エラーページのHTML要素・文字列をローカル変数のerrorCheckListに格納しているので、
* このチェックに引っかかった場合はその添字+1を、そうでなければ0を返す
*
* @param html: String 解析対象のHTML文字列
* @return : Int 引っかかったerrorCheckListの添字+1 エラーでなければ0
*/
fun isErrorPage(html: String): Int {
val errorCheckList: List<List<String>> = listOf(
listOf("td", """^ログインエラー"""),
listOf("td", """^エラー案内""")
)
val document = Jsoup.parse(html)
for (i in 0..errorCheckList.count()-1) {
val (selector, innerContentRegex) = errorCheckList[i]
if (document.select(selector)
.firstOrNull { innerContentRegex.toRegex().containsMatchIn(it.text()) } !== null) {
return i+1
}
}
return 0
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment