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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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())
}

登录

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
/**
* @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,获取成功之后才会登录。

登出

1
2
3
4
5
6
7
8
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中处理结果。

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

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

成绩

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/**
* @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,组成每一项消费记录。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<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>
1
2
3
4
5
6
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")
//...
}

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
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()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
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())
}