iPhone Reserve Bot 教學 2 - 登入 Apple Store
前言
現在開始我們寫 Android App,請確保你有以下智識,否則應該跟不上。
- 懂 Java (if-then-else, for loop, HashMap)
- 懂 Hello World 程度的 android app
- 懂設定 eclipse / IntelliJ IDEA 去 import library 和 compile & run android app
請留意本文會介紹一些 Android 相關概念, 但 code 以簡單化為目標,未必是 Android Design Best Practice。
前期準備
新增一個 Hello World Application
開啟 AndroidManifest.xml
新增以下 permission
:
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.RECEIVE_SMS"/>
<uses-permission android:name="android.permission.READ_SMS"/>
<uses-permission android:name="android.permission.SEND_SMS"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
第一個是容許 app 連接網絡,之後的是收發 SMS,最後的 Write External Storage
是遲些用來 debug 用的。
Game Started
現在我們要用到 Part 1 記錄的資訊 (沒有的話快去做一次吧,不過記著 Apple Reserve 只在上午八時至下午八時開放)。
回想一下 Workflow:
- 用戶到 https://reserve-hk.apple.com/HK/en_HK/reserve/iPhone
- 網頁 redirect 用戶到 https://signin.apple.com/IDMSWebAuth/login?.....
- 網頁會下載驗證碼 captcha
- 用戶在輸入 apple ID 、密碼和 驗證碼 captcha,按遞交
- Browser 將資料 post 到 authenticate 頁面
- 網站 redirect 用戶到第二頁 SMS 版面
說穿了 bot 其實只是代替 browser 自動進行 http submit 的動作而已。不過由於這次 Apple Reserve 加進了 captcha,所以中間需要人手輸入。
HTTP Client - okhttp
要 submit http request ,當然要相對應的 client 。 Android SDK 已有 DefaultHttpClient
,可以做相關的操作,但因為太 low level,需要很多自訂的 code。想更簡單的話推薦用其他 library,Okhttp 是選擇之一,這次就用它來玩一玩吧。
下載了 okhttp
和相關的 library 後, 將它們放進 libs/
資料夾下,再 import 進你的 IDE 裏。
要做 http get 的話只要
String httpGet(String url) throws IOException {
OkHttpClient client = new OkHttpClient();
Request request = new Request.Builder()
.url(url)
.build();
Response response = client.newCall(request).execute();
return response.body().string();
}
做 post 的話
public String httpPost() throws IOException {
Map<String, String> params = new HashMap<String, String>();
params.put("param1", "param1_value");
params.put("param2", "param2_value");
FormEncodingBuilder builder = new FormEncodingBuilder();
for (String key : params.keySet()) {
builder.add(key, params.get(key));
}
RequestBody formBody = builder.build();
Request request = new Request.Builder()
.url("https://web_page_to_be_post.com")
.post(formBody)
.build();
Response response = execute(request);
return response.body().string();
}
你只需懂得 get 和 post 便可做大部份的工作了。
Step 1 - 瀏覽首頁
我們只集中看看瀏覽第一頁的動作:
okHttpClient
要到第一頁 https://reserve-hk.apple.com/HK/en_HK/reserve/iPhone- 網站將 browser 重新導向至 apple ID login page https://signin.apple.com/IDMSWebAuth/login?.....
新增一個 class 叫 ReserveWorker
, 它會負責所有關 network 的動作。 okhttp
自然是在這個 class 用的
public class AppleReserveWorker {
private OkHttpClient okHttpClient;
public AppleReserveWorker() {
okHttpClient = new OkHttpClient();
}
}
因為 Apple Reserve Page 需用到 cookie 和 session,所以我們需要 cookies 的支援
public AppleReserveWorker() {
okHttpClient = new OkHttpClient();
okHttpClient.setFollowSslRedirects(true);
CookieManager cookieManager = new CookieManager();
CookieHandler.setDefault(cookieManager);
cookieManager.setCookiePolicy(CookiePolicy.ACCEPT_ALL);
okHttpClient.setCookieHandler(cookieManager);
}
這樣 okHttpClient
便會自動記錄 cookies。
再新增一個 method 去做瀏覽第一頁:
public String visitFirstPage() throws Exception {
Request request = new Request.Builder()
.url("https://reserve-hk.apple.com/HK/en_HK/reserve/iPhone")
.build();
Response response = okHttpClient.newCall(request).execute();
}
但如何知道 okhttpClient
有否執行 redirect 呢? 要知道 redirect 後的 url , 可以這樣
String resultUrl = response.request().url().toString();
我們只要將 resultUrl
對比 apple login page 的 URL 便知道有否 redirect 了。整個 method 會是這樣
public String visitFirstPage() throws Exception {
Request request = new Request.Builder()
.url("https://reserve-hk.apple.com/HK/en_HK/reserve/iPhone")
.build();
Response response = okHttpClient.newCall(request).execute();
String resultUrl = response.request().url().toString();
return resultUrl;
}
現在回到 MainActivity
中執行它
public class MainActivity extends Activity {
private static final String TAG = "MyActivity";
ReserveWorker reserveWorker;
TextView tvMsg;
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.acitivty_main);
tvMsg = (TextView)findViewById(R.id.tv_msg);
reserveWorker = new ReserveWorker();
goFrontPage();
}
private void goFrontPage(){
try {
String resultUrl = appleReserveWorker.visitFirstPage();
Log.d(TAG, "Result url is " + resultUrl);
}
catch(Exception e){
e.printStackTrace();
}
}
}
試試 compile 放到 Android 上行一次,看看 logcat 有什麼?
10-17 17:38:39.569 6020-6020/com.thirtysparks.apple.bot W/System.err﹕ android.os.NetworkOnMainThreadException
10-17 17:38:39.569 6020-6020/com.thirtysparks.apple.bot W/System.err﹕ at android.os.StrictMode$AndroidBlockGuardPolicy.onNetwork(StrictMode.java:1145)
10-17 17:38:39.569 6020-6020/com.thirtysparks.apple.bot W/System.err﹕ at libcore.io.BlockGuardOs.connect(BlockGuardOs.java:84)
10-17 17:38:39.569 6020-6020/com.thirtysparks.apple.bot W/System.err﹕ at libcore.io.IoBridge.connectErrno(IoBridge.java:127)
10-17 17:38:39.569 6020-6020/com.thirtysparks.apple.bot W/System.err﹕ at libcore.io.IoBridge.connect(IoBridge.java:112)
10-17 17:38:39.569 6020-6020/com.thirtysparks.apple.bot W/System.err﹕ at java.net.PlainSocketImpl.connect(PlainSocketImpl.java:192)
10-17 17:38:39.569 6020-6020/com.thirtysparks.apple.bot W/System.err﹕ at java.net.PlainSocketImpl.connect(PlainSocketImpl.java:460)
10-17 17:38:39.569 6020-6020/com.thirtysparks.apple.bot W/System.err﹕ at java.net.Socket.connect(Socket.java:833)
10-17 17:38:39.569 6020-6020/com.thirtysparks.apple.bot W/System.err﹕ at com.squareup.okhttp.internal.Platform$Android.connectSocket(Platform.java:220)
10-17 17:38:39.569 6020-6020/com.thirtysparks.apple.bot W/System.err﹕ at com.squareup.okhttp.Connection.connect(Connection.java:148)
10-17 17:38:39.569 6020-6020/com.thirtysparks.apple.bot W/System.err﹕ at com.squareup.okhttp.OkHttpClient$1.connect(OkHttpClient.java:84)
10-17 17:38:39.569 6020-6020/com.thirtysparks.apple.bot W/System.err﹕ at com.squareup.okhttp.internal.http.HttpEngine.connect(HttpEngine.java:321)
10-17 17:38:39.569 6020-6020/com.thirtysparks.apple.bot W/System.err﹕ at com.squareup.okhttp.internal.http.HttpEngine.sendRequest(HttpEngine.java:241)
10-17 17:38:39.569 6020-6020/com.thirtysparks.apple.bot W/System.err﹕ at com.squareup.okhttp.Call.getResponse(Call.java:198)
10-17 17:38:39.569 6020-6020/com.thirtysparks.apple.bot W/System.err﹕ at com.squareup.okhttp.Call.execute(Call.java:80)
10-17 17:38:39.569 6020-6020/com.thirtysparks.apple.bot W/System.err﹕ at com.thirtysparks.apple.bot.AppleReserveWorker.visitFirstPage(AppleReserveWorker.java:31)
10-17 17:38:39.569 6020-6020/com.thirtysparks.apple.bot W/System.err﹕ at com.thirtysparks.apple.bot.MyActivity.goFrontPage(MyActivity.java:22)
10-17 17:38:39.569 6020-6020/com.thirtysparks.apple.bot W/System.err﹕ at com.thirtysparks.apple.bot.MyActivity.onCreate(MyActivity.java:17)
一大堆 error code,太恐佈了!
為什麼有此問題呢?因為我們在 main thread 上執行 network 相關操作。Main thread 又名 UI thread, app 是用一個 process 來運行的,更新畫面全靠它來做,如果用它來做 network / disk io 這些相對較耗時的工作,app 便不能更新 UI、回應 user 的輸入等等,所以 android預設是不容許用 UI thread 來做這些功能。要做的話,便要開新 thread 來做。
最簡單的解決方法是用 AsyncTask
:
new AsyncTask<Void, Void, String>() {
@Override
protected String doInBackground(Void... voids) {
//do something in another thread
}
@Override
protected void onPostExecute(String resultString) {
//update the UI
}
}.execute();
用 AsyncTask
很簡單,doInBackground
是用來做耗時的工作,完成後交給 onPostExecute
來做 ui 更新。所以之前的 goFrontPage()
會更新為:
private void goFrontPage(){
new AsyncTask<Void, Void, String>() {
@Override
protected String doInBackground(Void... voids) {
String resultUrl = null;
try {
resultUrl = reserveWorker.visitFirstPage();
}
catch(Exception e){
e.printStackTrace();
}
return resultUrl;
}
@Override
protected void onPostExecute(String resultString) {
//update the UI
Log.d(TAG, "Result url is " + resultString);
boolean isLogin = (resultString.startsWith("https://signin.apple.com/IDMSWebAuth/"));
if(isLogin){
tvMsg.setText("Redirected to login page");
}
else{
tvMsg.setText("Failed");
}
}
}.execute();
}
注意的是,doInBackground
不能執行任何 UI 的更新,不然會死得很慘的。
現在再運行一次,這次沒問題了。在 logcat 上也可看到 redirect 後的網址。
Step 2 - 顯示 Captcha
去完第一頁,下一步當然是 login 了,我們需以下資料
- Apple ID
- Password
- Captcha
Apple ID 和 Password 也很容易解決,問題是 captcha。它是一幅圖片,由於不能 skip 和 hardcode,所以我們必須要顯示在畫面上顯示 captcha 並讓用家自行輸入。
但如何顯示 captcha 呢?
從之前的經驗知道,下載任何東西也需要用新 thread
來做,那麼要用 AsyncTask
嗎 ? 在最原始的世界,我們可以用 AsyncTask
下載圖片的 byte 然後 decode 做 Bitmap
再顯示在 ImageView
上,所幸科技發展一日千里,Load image 問題已經有很多人遇到並解決了,我們不用再 reinvent the wheel。
來,讓我們使用 Glide 吧。
Glide 需要 Android Support Library v4 才能運作,請自行下載吧。
Glide 功能強大,有需要的請自行研究。為簡單起見我們只用最基本的功能:
Glide.load(myUrl).into(captchaImageView);
執行後圖片便會自動載入到 captchaImageView
,省時省力 (這正是我們應該追求的最高境界)。
那麼現在到 layout_main.xml
中加進 ImageView
和 EditText
,用來顯示 captcha。
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:layout_width="fill_parent"
android:layout_height="fill_parent"
>
<TextView
android:id="@+id/tv_msg"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Hello World, MyActivity"
/>
<ImageView
android:id="@+id/iv_captcha"
android:layout_width="match_parent"
android:layout_height="80dp"/>
<EditText
android:id="@+id/et_captcha"
android:layout_width="match_parent"
android:layout_height="wrap_content"/>
</LinearLayout>
運行一次看看,應該看到 captcha 了。
顯示不到 Captcha
什麼? 你還是顯示不到 Captcha? 這正正是 Android 最麻煩的地方,同一段 code 有些機無問題但另一些卻有問題。
其實,拿 captcha 應該要用同一 http client 去拿取,這樣才能用同一 session,才能拿到正確的圖片的。幸好當初選用 Glide 的其中一個原因是它支援 okHttp !
先到 Glide Release page 下載 Glide-okhttp-Integration library,import 進 project 後,在 ReserveWorker
中加進以下 method:
public OkHttpClient getOkHttpClient() {
return okHttpClient;
}
然後在 Glide.load(myUrl)
前設定好使用 okHttpClient
:
Glide.get(MainActivity.this).register(GlideUrl.class, InputStream.class, new OkHttpUrlLoader.Factory(reserveWorker.getOkHttpClient()));
這樣 Glide 便會使用同一 client 拿 image,cookies 呀 session 呀什麼的也會共用了。
為了解 Glide 在載入 captcha 時有沒有遇到 exception
,可以將原來 load captcha 的一句 code 變成:
Glide.with(MainActivity.this).load(imageUrl).skipMemoryCache(true).diskCacheStrategy(DiskCacheStrategy.NONE).listener(new RequestListener<String, GlideDrawable>() {
@Override
public boolean onException(Exception e, String s, Target<GlideDrawable> glideDrawableTarget, boolean b) {
tvMsg.setText("Error in loading captcha");
Log.d(TAG, "Error in loading captcha");
if(e != null){
Log.d(TAG, "Exception is " + e.getClass().getSimpleName() + ": " + e.getMessage());
}
else{
Log.d(TAG, "Exception is null");
}
return false;
}
@Override
public boolean onResourceReady(GlideDrawable glideDrawable, String s, Target<GlideDrawable> glideDrawableTarget, boolean b, boolean b2) {
tvMsg.setText("Got Captcha, please enter the string. ");
return false;
}
}).into(captchaImageView);
這樣有問題時便可在 logcat 看到。
若還是載入不到 captcha 的話便 reboot 看看,reboot 後還是不行的話再問問。
Step 3 - 登入
最後是登入的步驟,需要以下資料:
相信大部份資料都一看即明,fdcBrowserData
即是 browser 資料,重用即可。其他的都是不變資料,唯一有需要處理的是 path
,似乎每次會不同,究竟是何時製造出來的?
細看 firebug 記錄由首頁到 login 的資料,可看到 login page 的 URL 是
https://signin.apple.com/IDMSWebAuth/login?path=%2FHK%2Fen_HK%2Freserve%2FiPhone%3Fexecution%3De1s1%26p_left%3DAAAAAARx6gk%252BcoKdb1dcWaBp2a1SG9Z5fcrf958H1xT0ydAVyg%253D%253D%26_eventId%3Dnext&p_time=1413859369&rv=3&language=HK-EN&p_left=AAAAAARx6gk%2BcoKdb1dcWaBp2a1SG9Z5fcrf958H1xT0ydAVyg%3D%3D&appIdKey=db0114b11bdc2a139e5adff448a1d7325febef288258f0dc131d6ee9afe63df3
看到 path
嗎?即是我們可從首頁 redirect 到 login page 的 URL 上找到 path
!
新增以下 function 到 ReserveWorker
去抽出所有 query string value。
public static Map<String, String> extractQueryString(String url) {
String param = url.substring(url.indexOf("?") + 1);
if (param.indexOf("#") > -1) {
param = param.substring(0, param.indexOf("#"));
}
String paramsStr[] = param.split("&");
Map<String, String> params = new HashMap<String, String>();
for (String str : paramsStr) {
String keyVal[] = str.split("=");
if (keyVal.length == 2 && keyVal[0].length() > 0 && keyVal[1].length() > 0) {
params.put(keyVal[0], keyVal[1]);
}
}
return params;
}
再更新之前 visitFrontPage()
為
Map<String, String> loginPageQueryString = new HashMap<String, String>();
public String visitFirstPage() throws Exception {
Request request = new Request.Builder()
.url("https://reserve-hk.apple.com/HK/en_HK/reserve/iPhone")
.build();
Response response = okHttpClient.newCall(request).execute();
String resultUrl = response.request().url().toString();
loginPageQueryString = extractQueryString(resultUrl);
Log.d(TAG, "Path is " + loginPageQueryString.get("path"));
return resultUrl;
}
便可將所有 query string 放進 loginPageQueryString
裏。
而在 ReserverWorker
新增 loginWithCaptcha
function:
public synchronized String loginWithCaptcha(String captchaInput, String appleId, String password) throws Exception {
Map<String, String> params = new HashMap<String, String>();
params.put("openiForgotInNewWindow", "true");
params.put("fdcBrowserData", "{\"U\":\"Mozilla/5.0 (Windows NT 6.3; WOW64; rv:32.0) Gecko/20100101 Firefox/32.0\",\"L\":\"en-US\",\"Z\":\"GMT+08:00\",\"V\":\"1.1\",\"F\":\"TF1;016;;;;;;;;;;;;;;;;;;;;;;Mozilla;Netscape;5.0%20%28Windows%29;20100101;undefined;true;Windows%20NT%206.3%3B%20WOW64;true;Win32;undefined;Mozilla/5.0%20%28Windows%20NT%206.3%3B%20WOW64%3B%20rv%3A32.0%29%20Gecko/20100101%20Firefox/32.0;en-US;undefined;signin.apple.com;undefined;undefined;undefined;undefined;false;false;" + GregorianCalendar.getInstance().getTime().getTime() + ";8;6/7/2005%2C%209%3A33%3A44%20PM;1920;1080;;12.0;;;;2013;12;-480;-480;9/22/2014%2C%209%3A13%3A52%20AM;24;1920;1040;0;0;Adobe%20Acrobat%7CAdobe%20PDF%20Plug-In%20For%20Firefox%20and%20Netscape%2011.0.06;;;;;Shockwave%20Flash%7CShockwave%20Flash%2012.0%20r0;;;;;;;;;;;;;18;;;;;;;\"}");
params.put("appleId", appleId);
params.put("accountPassword", password);
params.put("captchaInput", captchaInput);
params.put("captchaAudioInput", "");
params.put("appIdKey", "db0114b11bdc2a139e5adff448a1d7325febef288258f0dc131d6ee9afe63df3");
params.put("language", "HK-EN");
params.put("path", URLDecoder.decode(loginPageQueryString.get("path")));
params.put("rv", "3");
params.put("sslEnabled", "true");
params.put("Env", "PROD");
params.put("captchaType", "image");
params.put("captchaToken", "");
FormEncodingBuilder builder = new FormEncodingBuilder();
for (String key : params.keySet()) {
builder.add(key, params.get(key));
}
RequestBody formBody = builder.build();
Request request = new Request.Builder()
.url("https://signin.apple.com/IDMSWebAuth/authenticate")
.post(formBody)
.build();
Response response = okHttpClient.newCall(request).execute();
String resultUrl = response.request().url().toString();
return resultUrl;
}
因為成功登入的話,URL 會變成 https://reserve-hk.apple.com/HK/en_HK/reserve/iPhone?execution=e1s2
,所以這次也以 resultUrl
為成功登入與否的指標。
然後回到 layout_main.xml
裏加一登入按鈕
<Button
android:id="@+id/btn_captcha"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Submit Captcha"
/>
並在 MainActivity
新增以下 method :
private void goLoginCaptcha() {
tvMsg.setText("Submitting captcha");
final String captchaInput = ((EditText) findViewById(R.id.et_captcha)).getText().toString();
new AsyncTask<Void, Void, String>() {
@Override
protected String doInBackground(Void... params) {
String string = null;
try {
string = reserveWorker.loginWithCaptcha(captchaInput, APPLE_ID, PASSWORD);
}
catch(Exception e){
e.printStackTrace();
}
return string;
}
@Override
protected void onPostExecute(String s) {
if ("https://reserve-hk.apple.com/HK/en_HK/reserve/iPhone?execution=e1s2".equals(s)) {
tvMsg.setText("Apple ID Login successfully");
} else {
tvMsg.setText("Error: Apple ID Login failed");
}
}
}.execute();
}
在 onCreate()
設定點擊 Button
便登入吧。
findViewById(R.id.btn_captcha).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
goLoginCaptcha();
}
});
運行試一試,輸入 captcha 按 Submit 應該已成功登入。
待續
今次解釋了如何進行 http get 和 post 的動作,以上的 code 可在以下網址找到:
https://github.com/goofyz/iphone6-reserve-bot/tree/part2.1
如果你已懂得發送接收 SMS,又已記錄所有 parameter 的話,你已經可以自行繼續寫整個 bot。Part 3 將教如何發送 SMS。
Apple iPhone Reserve Bot 教學 - 首頁