iPhone Reserve Bot 教學 5 - 最後的預訂

來到 iReserve 最後的教學。回顧一下完整的步驟:

  1. 在第一頁
  2. 網頁會下載 驗證碼 captcha
  3. 用戶在輸入 apple ID 、密碼和 驗證碼 captcha,按遞交
  4. 在第二頁會用 ajax 下載顯示 SMS 的碼
  5. 用戶用手機將 SMS 碼以 SMS 形式寄到 Apple 電話,等待回覆
  6. Apple 回覆 SMS code
  7. 用戶到第二頁輸入發送 SMS 的手機號碼和 SMS 回覆碼,遞交
  8. 在第三頁網頁會自動下載你的個人資訊
  9. 用戶選擇 Apple Store,網頁會下載 Apple Store 的 timeslot 資料
  10. 用戶選擇 iPhone Model 、大小和 Contract type 後,網頁會下載存貨資料
  11. 如有存貨,用戶可輸入姓名、電話、身份證明號碼,遞交
  12. 預訂成功/失敗

其實最後的幾步 (8 - 12),就是拿時間和存貨的資料,然後遞交預訂而已。

e1s2, e1s3, e1s4, ...

開始前,大家還記得此 URL 嗎?

https://reserve-hk.apple.com/HK/en_HK/reserve/iPhone?execution=e1s3&ajaxSource=true&_eventId=context

Apple 網頁多次使用此 url 來拿 json 資料,Part 4 說過 execution 會在每次遞交後 + 1 的,則由最初的 e1s1,每 post 一次便會變成 e1s2, e1s3, e1s4......我們可以自行 + 1,但當然最好是拿回 apple 網頁的值,這樣便一定沒有錯。

每次 post 後,response.request().url() 中的 execution 也會變的,所以我們在 ReserveWorker 中新增以下 method,抽取 url 中的 execution

private void updateFlowExecutionKey(String url) {

    Map<String, String> queryString = extractQueryString(url);
    String execution = queryString.get("execution");
    if (execution != null) {
        loginPageQueryString.put(FLOW_EXECUTION_KEY, execution);
    }
}

在每次 post 上 apple 網站後,行一行此 method 便可。

若果沒有此 method 而使用錯誤的 execution,便會遇上 503 錯誤,又要重新由 step 1 來過。

時間和存貨

上回去到預訂頁面,會拿到一個 json, 裏面有 login user 的資料。我們可以記下用來,用來做預訂時會用到。

{
  "firstName" : "Frodo",
  "lastName" : "Baggins",
  "phoneNumber" : "85299981234",
  "stores" : [ {
    "storeName" : "Causeway Bay",
    "storeNumber" : "R409",
    "enabled" : true
  }, {
    "storeName" : "Festival Walk",
    "storeNumber" : "R485",
    "enabled" : true
  }, {
    "storeName" : "ifc mall",
    "storeNumber" : "R428",
    "enabled" : true
  } ],
  "isToday" : true,
  "_flowExecutionKey" : "e1s3",
  "email" : "XXX@xxx.com",
  "p_ie" : "9450c39b-2994-4991-a7c2-5f32e9c6ab5c"
}

拿到此資料代表已成功登入,而裏面的 firstNamelastName 是原本 Apple ID 裏設定的姓名,可以用來做遞交預訂時用的,不過 Apple 其實容許你在遞交時輸入其他姓名,但我們會直接使用這裏的 firstNamelastName

然後就是拿 time slot 資料,這個需要每間店鋪分開拿,所以我們為 ReserveWorker 加以下 function:

public String getTimeSlots(String storeNumber) throws Exception {
    Map<String, String> params = new HashMap<String, String>();
    params.put("ajaxSource", "true");
    params.put("_eventId", "timeslots");
    params.put("storeNumber", storeNumber);
    params.put("p_ie", loginPageQueryString.get(P_IE));

    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://reserve-hk.apple.com/HK/en_HK/reserve/iPhone?execution=" + loginPageQueryString.get(FLOW_EXECUTION_KEY))
            .post(formBody)
            .build();
    Response response = okHttpClient.newCall(request).execute();

    String body = response.body().string();
    return body;
}

Time slot 的 json 大約是這個樣子:

{
  "formattedDate" : "09 月 21 日, 星期日",
  "timeslots" : [ {
    "timeSlotId" : "05246bd8-7e7b-4dff-b42a-fd2b97bc8b52",
    "formattedTime" : "3:00 PM-4:00 PM"
  }, {
    "timeSlotId" : "f18810df-33bb-4998-8ef7-eb4950fd7992",
    "formattedTime" : "4:00 PM-5:00 PM"
  }, {
    "timeSlotId" : "f3f0c167-71df-4a66-a933-812874bf673e",
    "formattedTime" : "5:00 PM-6:00 PM"
  } ],
  "_flowExecutionKey" : "e1s3",
  "p_ie" : "9450c39b-2994-4991-a7c2-5f32e9c6ab5c"
}

拿到後當然要 parse 令它成為好用的 data format。就在 MainActivity 做吧:

private List<String[]> getTimeSlot(String storeNumber){
    List<String[]> timeSlotList = null;
    try{
        String jsonStr = reserveWorker.getTimeSlots(storeNumber);

        timeSlotList = new ArrayList<String[]>();
        try {
            JSONObject json = new JSONObject(jsonStr);
            JSONArray timeSlotsJsonArray= json.getJSONArray("timeslots");
            for(int i=0; i < timeSlotsJsonArray.length(); i++){
                JSONObject timeSlotJson = timeSlotsJsonArray.getJSONObject(i);
                String timeSlotId = timeSlotJson.getString("timeSlotId");
                String timeSlotTime = timeSlotJson.getString("formattedTime");
                timeSlotList.add(new String[]{timeSlotId, timeSlotTime});

                Log.d(TAG, "time is " + timeSlotTime + ", " + timeSlotId);
            }
        } catch (JSONException jsonException) {
            jsonException.printStackTrace();
        } catch (NullPointerException e) {
            addLog("Null pointer.  Please start again");
        }

    }
    catch(Exception e){
        e.printStackTrace();
    }

    return timeSlotList;
}

這樣便會拿到一個 list<String[]>,記著所有 time slot。弄妥 time slot 後當然要拿存貨資料。在 ReserveWorker 新增 getAvailability()

public String getAvailability(String storeNumber, String partNumber) throws Exception{
    Map<String, String> params = new HashMap<String, String>();
    params.put("ajaxSource", "true");
    params.put("_eventId", "availability");
    params.put("storeNumber", storeNumber);
    params.put("partNumbers", partNumber);
    params.put("selectedContractType", "UNLOCKED");
    params.put("p_ie", loginPageQueryString.get("p_ie"));

    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://reserve-hk.apple.com/HK/en_HK/reserve/iPhone?execution=" + loginPageQueryString.get(FLOW_EXECUTION_KEY))
            .post(formBody)
            .build();
    Response response = okHttpClient.newCall(request).execute();

    String body = response.body().string();
    return body;
}

存貨需要每間分店和每種型號逐個拿取,若你有空也可研究各種「大法」,看看可否一次過拿所有 stocks。成功的話請告訴我,因為我實在試不到。(預訂首頁的 availability.json 更新太慢,很多時存貨已沒有了也會顯示有貨,所以是沒有用的。)

拿回來的存貨 json 則是:

{
  "inventories" : [ {
    "partNumber" : "MGAA2ZP/A",
    "available" : true
  }, {
    "partNumber" : "MGAK2ZP/A",
    "available" : false
  }, {
    "partNumber" : "MGAF2ZP/A",
    "available" : false
  } ],
  "_flowExecutionKey" : "e1s3",
  "p_ie" : "9450c39b-2994-4991-a7c2-5f32e9c6ab5c"
}

又在 MainActivity 將 json 轉做 Map 吧:

private Map<String, Boolean> getStock(String storeNumber, String groupPartNumber){
    Map<String, Boolean> list = null;
    try{
        String jsonStr = reserveWorker.getAvailability(storeNumber, groupPartNumber);
        list = new HashMap<String, Boolean>();
        try {
            JSONObject json = new JSONObject(jsonStr);
            JSONArray timeSlotsJsonArray= json.getJSONArray("inventories");
            for(int i=0; i < timeSlotsJsonArray.length(); i++){
                JSONObject timeSlotJson = timeSlotsJsonArray.getJSONObject(i);
                String partNumber = timeSlotJson.getString("partNumber");
                boolean available = timeSlotJson.getBoolean("available");
                list.put(partNumber, available);
                
                Log.d(TAG, "Stocks: " + partNumber + ": " + available);
            }
        } catch (JSONException jsonException) {
            jsonException.printStackTrace();
        } catch (NullPointerException e) {
            addLog("Null pointer.  Please start again");
        }
    }
    catch(Exception e){
        e.printStackTrace();
    }
    return list;
}

這樣所有的資料也拿齊,終於來到最後一步:預訂。

預訂

ReserveWorker 加入 submitOrder():

public String submitOrder(String color, String appleId, String firstName, String lastName, String govId, String govIdType, String productName, String partNumber, String storeNumber, String timeSlotId) throws Exception {
    Map<String, String> params = new HashMap<String, String>();
    params.put("_eventId", "next");
    params.put("_flowExecutionKey", loginPageQueryString.get(FLOW_EXECUTION_KEY));
    params.put("color", color);
    params.put("email", appleId);
    params.put("firstName", firstName);
    params.put("govtId", govId);
    params.put("lastName", lastName);
    params.put("p_ie", loginPageQueryString.get(P_IE));
    params.put("product", productName);
    params.put("selectedContractType", "UNLOCKED");
    params.put("selectedGovtIdType", govIdType);
    params.put("selectedPartNumber", partNumber);
    params.put("selectedQuantity", "2");
    params.put("selectedStoreNumber", storeNumber);
    params.put("selectedTimeSlotId", timeSlotId);

    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://reserve-hk.apple.com/HK/en_HK/reserve/iPhone?execution=" + loginPageQueryString.get(FLOW_EXECUTION_KEY))
            .post(formBody)
            .tag(TAG)
            .build();
    Response response = okHttpClient.newCall(request).execute();

    return null;
}

然後在 MainActivity 中執行:

private String order(String storeNumber, String partNum, String timeSlotId){
    String jsonStr = null;
    try {
        Log.d(TAG, "Ordering " + storeNumber + ": " + timeSlotId);
        jsonStr = reserveWorker.submitOrder(COLOR_GOLD, APPLE_ID, firstName, lastName, GOV_ID, GOV_ID_TYPE, MODEL_IPHONE6_PLUS_NAME, partNum, storeNumber, timeSlotId);
    }
    catch (Exception e){
        e.printStackTrace();
    }
    return jsonStr;
}

但如何知道成功與否呢?因為我們可以輕易知道失敗的結果,所以我們先處理失敗吧。

失敗的話會被彈回去同一頁,而且又用回同一個 request

https://reserve-hk.apple.com/HK/en_HK/reserve/iPhone?execution=e1s3&ajaxSource=true&_eventId=context

即是我們可以用回 ReserveWorker.getCommonAjax(),然後再 parse 拿回來的 json 檔。所以在 ReserveWorker.submitOrder() 最後我們加上

public String submitOrder(String color, String appleId, String firstName, String lastName, String govId, String govIdType, String productName, String partNumber, String storeNumber, String timeSlotId) throws Exception {
	
    ......
    
    String info = getCommonAjax();
    return info;
}

檢查結果就在 MainActivity

try{
  JSONObject jsonObject = new JSONObject(jsonStr);
  if(jsonStr.indexOf("errors") > 0) {
    JSONArray errors = jsonObject.getJSONArray("errors");
    if (errors.length() > 0) {
      msg = errors.join(", ");
    }
  }
}
catch (JSONException jsonException) {
	jsonException.printStackTrace();
} catch (NullPointerException e) {
}

errors 可以是 timeslotError, createReservationError 之類,詳細意思可以自己查看 apple reserve page 的 javascript,這裏不詳述了。

而成功的 response 呢?因為我已成功預訂過,所以可以告訴大家,其實成功的頁面也是用上面的 request 拿結果的,不同的只是 json 的內容而已。所以若果 json 裏沒有 error 的話,基本上就是成功的 response,將 json 直接 print 出來吧:

if (errors.length() > 0) {
	msg = errors.join(", ");
}
else{
	//should be order successfully, print the whole json
    msg = jsonStr
}

好,最後我們可以新增 MainActivity.doFinalStep()一次過執行 getTimeSlot(), getStock() 和預訂的這幾步驟:

private void doFinalStep(){
    addLog("Do final step");
    new AsyncTask<Void, Void, String>() {
        @Override
        protected String doInBackground(Void... params)  {
            String msg = null;
            String storeNum = STORE_IFC;
            List<String[]> timeSlotList = getTimeSlot(storeNum);
            Map<String, Boolean> stockList = getStock(storeNum, MODEL_IPHONE6_PLUS_GROUP);

            if(timeSlotList != null && stockList != null){
                //do ordering
                for(String[] timeSlot:timeSlotList){
                    for(String partNum:stockList.keySet()){
                        if(stockList.get(partNum)){
                            String jsonStr = order(storeNum, timeSlot[0]);
                            try{
                                JSONObject jsonObject = new JSONObject(jsonStr);
                                if(jsonStr.indexOf("errors") > 0) {
                                    JSONArray errors = jsonObject.getJSONArray("errors");
                                    if (errors.length() > 0) {
                                        msg = errors.join(", ");
                                    }
                                }
                                else{
                                    //should be order success
                                    msg = jsonStr;
                                }
                                return msg;
                            }
                            catch (JSONException jsonException) {
                                jsonException.printStackTrace();
                            } catch (NullPointerException e) {
                                addLog("Null pointer.  Please start again");
                            }

                        }
                    }
                }
            }
            return msg;
        }

        @Override
        protected void onPostExecute(String msg) {
            if(msg != null) {
                addLog(msg);
            }
            else{
                addLog("order failed. Msg is null.");
            }
        }
    }.execute();
}

心水清的朋友可能會留意到 getTimeSlot()getStock() 沒有分別用獨立的 AsyncTask 去拿取,而是在最後這個 doFinalStep() 一拼去做。這是因為之前用 AsyncTask 是為了在 onPostExecute() 中執行 UI 的更新和等候 User interaction,但在 getTimeSlot()getStock()中我們都不需要 interact,所以可以一次過在 doFinalStep() 執行。

好,最後我們可以在 MainActivity 一到達 page 3 便立刻執行以上的動作,始終 iPhone 機械人大戰是分秒必爭嘛:

private void getSubmitResult(){
    addLog("Getting submission result");
    new AsyncTask<Void, Void, String>() {
    	...
        
        @Override
        protected void onPostExecute(String jsonStr) {
        	...
            doFinalStep();
        }
    }
}

來到這裏我們的 iPhone Reserve Bot 已經完成。當然它的功能還非常簡單,但最重要的功能已經寫好。

完整的 source code 在此:

https://github.com/goofyz/iphone6-reserve-bot/

改善空間

其實要寫得較好,便不應該用 AsyncTask 而用 Service,或弄個 SettingActivity 出來讓用戶設定 apple id 等資料而不是 hardcode,或者預訂失敗後要試預訂下一款 iPhone 等。但因為本教學目的是教大家寫 bot 的原理和基本 coding,所以就沒加這些額外的東西去令大家分心。

始終,寫一個程式,其中八成的功能(e.g. Reserve Bot 的預訂功能)其實只佔一體兩成的時間,但寫剩下的兩成功能(UI, validation 之類)卻花了整體八成時間。若連周邊的東西也教的話,教學可能寫到 Part 10 也未寫完。

iReserver 宣傳

看完此教學大家應該可以自己寫 bot 了。但若嫌麻煩的話,可以直接下載我們寫的 bot: iReserver。基本上本篇教學也是以 iReserver 為原型而寫的,不同的只是我加多一些 error handling 和 OCR 而已。如果你嫌麻煩,或者想支持下本 blog ,請到 Google Play 下載,或者可以試試「香港天晴」或「HKEPC Reader」),多謝。

iReserver - Goolge Play Store 連結

iReserver image


Apple iPhone Reserve Bot 教學 - 首頁