Grain 是我大一自学 Android 时写的一个App,最终学校关闭服务器接口,卒。 最近发现了一个新的可用的网站,所以重构的时候到了,在框架的选择上我一直纠结是用 MVP 还是 MVVM,最后还是用了 MVVM 配合 Google 的 Jetpack,使用起来很奇妙,也有很多坑。

目前 App 确定的几个功能是:查成绩、校园卡、借阅查询。

这次我用的数据来源是学校的官网,可以在外网访问,貌似知道这个网站的同学很少啊。

直接可用的 API

可以通过POST和GET直接获取数据的API,不需要通过解析HTML(校园卡余额和借阅数据都需要解析HTML来获取)

  • 登录

    • POST
    • URL
    • 参数
      • authType “0”
      • username “your id”
      • password “your password”
      • lt "”
      • execution “execution”
      • _eventId “submit”
      • randomStr "”
  • 登出

  • 查询成绩(以下都需要Cookie)

    • URL
    • GET/POST
    • 注意返回数据的Content-Type为text/html,但实际上是json数据(
  • 学分

    • URL
    • GET/POST
    • 注意返回数据的Content-Type为text/html,但实际上是json数据(

登录和登出

大概最难搞定的就是登录了,通过 Chrome 抓包来看是使用 cookie + session 的方式来校验用户的。

分析

grain-login-post

登录是一个POST请求,表单的内容也很一般,唯一需要注意的是excution字段,一开始还以为是一个固定的字段,结果发现它是会“递增”的。当前是e1s1,下次再使用这个session id 登录会变成e2s1,以此类推。

execution是一个隐藏的字段,所以可以先GET 这个网页,拿到隐藏字段之后再发起POST请求登录。相等于模拟在浏览器上登录一样。(不过,如果使用某些 User-agent 会触发验证码的样子,而使用其他的就不会,所以当然选择不触发验证码的User-agent。。。)

当密码或者学号错误的时候,会返回登录界面,并且有错误提示,在id为status的 div 中,所以可以根据有无错误提示来判断是否登录成功。

grain-login-error

登出就比较简单,只需要访问

https://my.zjou.edu.cn/cas/logout?service=http://portal.zjou.edu.cn

就会清除cookie和session。

实现

使用Okhttp 发起网络请求,用 Jsoup来解析HTML,用Rxjava来处理异步调用。

获取 execution

    fun getExecution(): Observable<String> {
        return Observable.fromCallable {
            val response = httpClient.newCall(executionRequest).execute()
            if (!response.isSuccessful) {
                ""
            } else {
                val respString = response.body()!!.string()
                val doc = Jsoup.parse(respString)
                val selected = doc.select("input[type=hidden]")
                val executionNode = selected[1]
                val executionStr = executionNode.attr("value")
                Log.d(TAG, "onResponse: execution: $executionStr")
                executionStr
            }
        }
            .subscribeOn(Schedulers.io())
            .observeOn(AndroidSchedulers.mainThread())
    }

登录

    /**
     * @param user user to login with is and password
     * @return return observable of login status code
     * @see LOGIN_NET_ERROR -1 -> network error
     * @see LOGIN_ID_PASS_ERROR 0 -> password error
     * @see LOGIN_SUCCESS 1 -> success
     */
    fun login(user: User, execution: String): Observable<Int> {
        if (execution.isEmpty()) {
            Log.e(TAG, "getExecution failed");
            return Observable.fromCallable { LOGIN_NET_ERROR }
        }
        val formBody = FormBody.Builder()
            .add("authType", "0")
            .add("username", user.id.toString())
            .add("password", user.password)
            .add("lt", "")
            .add("execution", execution)
            .add("_eventId", "submit")
            .add("randomStr", "")
            .build()
        val loginRequest = Request.Builder()
            .url("https://my.zjou.edu.cn/cas/login?service=http%3A%2F%2Fportal.zjou.edu.cn%2Findex.portal")
            .header("User-Agent", USER_AGENT)
            .post(formBody)
            .build()

        return Observable.fromCallable {
            val response = httpClient.newCall(loginRequest).execute()
            Log.d(TAG, "login: respose code: ${response.code()}")
            val doc = Jsoup.parse(response.body()?.string())
            // password or ID error
            val statusDiv = doc.getElementById("status")
            if (statusDiv != null) {
                LOGIN_ID_PASS_ERROR
            } else { // login succeed, redirect to home page
                LOGIN_SUCCESS
            }
        }
            .subscribeOn(Schedulers.io())
            .observeOn(AndroidSchedulers.mainThread())
    }

login方法需要用户id、密码和execution。所以需要在登录之前先获取execution,获取成功之后才会登录。

登出

    fun logoutAsync(callback: Callback) {
        val logoutRequest = Request.Builder()
            .url("https://my.zjou.edu.cn/cas/logout?service=http://portal.zjou.edu.cn")
            .header("User-Agent", USER_AGENT)
            .get()
            .build()
        httpClient.newCall(logoutRequest).enqueue(callback)
    }

logout没有返回RxjavaObservable,而是传入okhttp3.CallbackCallback中的方法是异步的,不在主线程。其实效果和Rxjava差不多,返回的Observable可以subscribe一个Observer。在Observable中处理网络请求,在Observer中处理结果。

observable
	.subscribeOn(Schedulers.io()) // Observable 运行在 IO 线程
	.observeOn(AndroidSchedulers.mainThread()) // Observer 运行在 Main 线程

我个人比较喜欢Rxjava的链式调用。

成绩

解决了登录问题,那么接下来的问题都不是问题了(

返回数据的Content-Type为text/html,但实际上就是json数据,这也太垃圾了吧(搞的我要手动去解析。

    fun getTranscript(): Observable<MutableList<Transcript>?> {
        return Observable.fromCallable {
            val response = httpClient.newCall(transcriptRequest).execute()
            Log.d(TAG, "onResponse: code: ${response.code()}")
            val resp = response.body()?.string()
            if (resp?.startsWith("{") == true) {
                Log.d(TAG, "onResponse: $resp")
                val transcriptResponse = Gson().parseJson<TranscriptResponse>(resp)
                transcriptResponse?.transcriptList
            } else {
                ArrayList<Transcript>()
            }
        }
            .subscribeOn(Schedulers.io())
            .observeOn(AndroidSchedulers.mainThread())
    }

剩下的校园卡和借阅的数据需要用Jsoup解析HTML去获取,下回再写。

2019.1.30 更新

校园卡

ecard-request

    /**
     * @param pageIndex page start from 1
     */
    fun getECardList(pageIndex: Int, pageSize: Int): Observable<ArrayList<ECard>?> {
        val form = FormBody.Builder()
            .add("pageIndex", pageIndex.toString())
            .add("pageSize", pageSize.toString())
            .build()
        val request = Request.Builder()
            .url("http://portal.zjou.edu.cn/independent.portal?.cs=ZHxjb20uZWR1LmRrLnN0YXJnYXRlLnBvcnRhbC5jb250YWluZXIuY29yZS5pbXBsLlBvcnRsZXRFbnRpdHlXaW5kb3d8aW50ZS1qeXh4fHZpZXd8bm9ybWFsfHBtX2ludGUtanl4eF9hY3Rpb249cXVlcnlKeXh4fHJtX3xwcm1f")
            .header("User-Agent", USER_AGENT)
            .post(form)
            .build()

        return Observable.fromCallable {
            val response = httpClient.newCall(request).execute()
            if (!response.isSuccessful || response.body() == null) {
                ArrayList()
            } else {
                parseCardTable(response.body()!!.string())
            }
        }
            .subscribeOn(Schedulers.io())
            .observeOn(AndroidSchedulers.mainThread())
    }

校园卡的消费记录是一个表单 post 请求,response 是 html。。。大概是没有做专门的 RESTful API 吧。所以只能使用 Jsoup 解析表格。

下面是一段 HTML 表格代码的截取:第一个tr标签是表头,不需要。从第二个tr开始提取我们想要的td,组成每一项消费记录。

<table isscroll="false" class="portlet-table" id="queryGridPluto_inte_jyxx_">
<thead>
<tr>
<th>卡号</th>
<th>交易流水号</th>
<th>交易时间</th>
<th>商户代码</th>
<th>交易类型</th>
<th>交易金额</th>
<th>交易余额</th></tr></thead>
<tbody>
<tr class="odd">
<td style="width:10%;">57016</td>
<td style="width:10%;">219171082</td>
<td style="width:6%;">2019-01-12 11:41:05</td>
<td style="width:6%;">长峙学生宿舍P</td>
<td style="width:10%;">收费冲正</td>
<td style="width:8%;">4.69</td>
<td style="width:8%;">43.3</td></tr>
<tr class="even">
<td style="width:10%;">57016</td>
<td style="width:10%;">219169846</td>
<td style="width:6%;">2019-01-12 11:32:01</td>
<td style="width:6%;">长峙学生宿舍P</td>
<td style="width:10%;">商务收费</td>
<td style="width:8%;">-5</td>
<td style="width:8%;">38.61</td></tr></tbody>
</table>
val table = doc.getElementById("queryGridPluto_inte_jyxx_")
val rows = table.select("tr")
for (i in 1 until rows.size) {
  val cols = rows[i].select("td")
	//...
}

交易流水号、交易时间、商户代码、交易金额都是我们需要的数据,分别对其进行提取和提纯:

    private val regex1 = Regex("(消费机|同力|多媒体)[0-9]*")
    private val regexToRemoveNum = Regex("[0-9]+")
    private fun parseCardTable(responseString: String, pageSize: Int): ArrayList<ECard>? {
        if (responseString.isEmpty()) {
            return null
        }
        val itemList = ArrayList<ECard>(pageSize)
        val doc = Jsoup.parse(responseString)
        val table = doc.getElementById("queryGridPluto_inte_jyxx_")
        val rows = table.select("tr")

        Util.formatSecondThreadLocal.set(SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.CHINA))

        for (i in 1 until rows.size) {
            val cols = rows[i].select("td")
            var money = 0.0
            try {
                money = cols[5].text().toDouble()
            } catch (ignore: NumberFormatException) {
                continue
            }
            if (money != 0.0) {
                val name = prettyName(cols[3].text())
                var item: ECard? = null
                try {
                    val date = Util.getTimeDate(cols[2].text())
                    if (date == null) continue
                    item = ECard(cols[1].text().toLong(), name, date, money)
                } catch (e: ArrayIndexOutOfBoundsException) {
                    e.printStackTrace()
                    continue
                }
                itemList.add(item)
            }
        }
        return itemList
    }

    private fun prettyName(name: String): String {
        var tmp = name
        tmp = tmp.replace(regex1, "")
        tmp = tmp.replace(regexToRemoveNum, "")
        return tmp
    }

用户信息

网站的主页包含一些用户的信息:班级,学号,校园卡余额,图书借阅情况,同样可以用 Jsoup 对其进行提取,在 app 中展示。

book-request

唯一需要注意的是:一个标签的第0项是它和它所以子标签内容的合集。所以这里取第一项:val nameText = div[1].text()

    fun getUserInfo(): Observable<User> {
        val request = Request.Builder()
            .url("http://portal.zjou.edu.cn/index.portal")
            .header("User-Agent", USER_AGENT)
            .get()
            .build()
        return Observable.fromCallable {
            val response = httpClient.newCall(request).execute()
            val user = User(0, "")
            val resp = response.body()?.string()
            if (resp.isNullOrEmpty()) {
                user
            } else {
                val doc = Jsoup.parse(resp)
                var div: Elements? = null
                try {
                    div = doc.getElementsByClass("user_info")[0].allElements
                } catch (ignore: Exception) {
                    return@fromCallable user
                }

                // index 0 is all content text in the div
                Log.d(TAG, "nameText: ${div!![1].text()}")
                val nameText = div[1].text().let { str ->
                    val index = str.indexOfFirst { it == ',' }
                    str.substring(0, index)
                }
                Log.d(TAG, "name: $nameText")

                Log.d(TAG, "idText: ${div[2].text()}")
                val idText = div[2].text().let { str ->
                    val index = str.indexOfFirst { it == ':' }
                    str.substring(index + 1, str.length)
                }
                Log.d(TAG, "id: $idText")

                Log.d(TAG, "classText: ${div[3].text()}")
                val classText = div[3].text().let { str ->
                    val index = str.indexOfFirst { it == ':' }
                    str.substring(index + 1, str.length)
                }
                Log.d(TAG, "class: $classText")

                val cardLi = doc.getElementsByClass("ykt")
                val libraryLi = doc.getElementsByClass("tsg")
                val bookRentTexts = libraryLi[0].allElements[0].text().split(" ")

                var moneyRem = 0.0
                var bookRentNum = 0
                var bookRentOutDateNum = 0
                try {
                    moneyRem = cardLi[0].allElements[0].text().filter { it.isDigit() || it == '.' }.toDouble()
                    bookRentNum = bookRentTexts[1].toInt()
                    bookRentOutDateNum = bookRentTexts[2].filter { it.isDigit() }.toInt()
                } catch (ignore: NumberFormatException) {
                }

                User(idText.toInt(), "", nameText, classText, moneyRem, bookRentNum, bookRentOutDateNum)
            }
        }
            .subscribeOn(Schedulers.io())
            .observeOn(AndroidSchedulers.mainThread())
    }