[心得分享] iPhone Reserve Bot 教學 2 - 登入 Apple Store

本帖最後由 goofyz 於 2014-10-21 15:19 編輯

原 blog link:  http://bit.ly/1pwapy4 ,圖片請到原 post 看。
==================================

前言

現在開始我們寫 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

    http://square.github.io/okhttp/

下載了 okhttp 和相關的 library 後, 將它們放進 libs/ 資料夾下,再 import 進你的 IDE 裏。

要做 http get 的話只要
  1. String httpGet(String url) throws IOException {
  2.   OkHttpClient client = new OkHttpClient();
  3.   Request request = new Request.Builder()
  4.       .url(url)
  5.       .build();

  6.   Response response = client.newCall(request).execute();
  7.   return response.body().string();
  8. }
複製代碼
做 post 的話
  1. public String httpPost() throws IOException {
  2.     Map<String, String> params = new HashMap<String, String>();

  3.     params.put("param1", "param1_value");
  4.     params.put("param2", "param2_value");

  5.     FormEncodingBuilder builder = new FormEncodingBuilder();
  6.     for (String key : params.keySet()) {
  7.         builder.add(key, params.get(key));
  8.     }
  9.     RequestBody formBody = builder.build();
  10.     Request request = new Request.Builder()
  11.             .url("https://web_page_to_be_post.com")
  12.             .post(formBody)
  13.             .build();
  14.     Response response = execute(request);

  15.     return response.body().string();
  16. }
複製代碼
你只需懂得 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 用的
  1. public class AppleReserveWorker {
  2.   private OkHttpClient okHttpClient;

  3.   public AppleReserveWorker() {
  4.       okHttpClient = new OkHttpClient();
  5.   }
  6. }
複製代碼
因為 Apple Reserve Page 需用到 cookie 和 session,所以我們需要 cookies 的支援
  1. public AppleReserveWorker() {
  2.     okHttpClient = new OkHttpClient();
  3.     okHttpClient.setFollowSslRedirects(true);

  4.     CookieManager cookieManager = new CookieManager();
  5.     CookieHandler.setDefault(cookieManager);
  6.     cookieManager.setCookiePolicy(CookiePolicy.ACCEPT_ALL);
  7.     okHttpClient.setCookieHandler(cookieManager);
  8. }
複製代碼
這樣 okHttpClient 便會自動記錄 cookies。

再新增一個 method 去做瀏覽第一頁:
  1. public String visitFirstPage() throws Exception {
  2.     Request request = new Request.Builder()
  3.             .url("https://reserve-hk.apple.com/HK/en_HK/reserve/iPhone")
  4.             .build();
  5.     Response response = okHttpClient.newCall(request).execute();
  6. }
複製代碼
但如何知道 okhttpClient 有否執行 redirect 呢? 要知道 redirect 後的 url , 可以這樣
  1. String resultUrl = response.request().url().toString();
複製代碼
我們只要將 resultUrl 對比 apple login page 的 URL 便知道有否 redirect 了。整個 method 會是這樣
  1. public String visitFirstPage() throws Exception {
  2.     Request request = new Request.Builder()
  3.             .url("https://reserve-hk.apple.com/HK/en_HK/reserve/iPhone")
  4.             .build();
  5.     Response response = okHttpClient.newCall(request).execute();

  6.     String resultUrl = response.request().url().toString();

  7.     return resultUrl;
  8. }
複製代碼
現在回到 MainActivity 中執行它
  1. public class MainActivity extends Activity {
  2.     private static final String TAG = "MyActivity";
  3.     ReserveWorker reserveWorker;

  4.     TextView tvMsg;

  5.     @Override
  6.     public void onCreate(Bundle savedInstanceState) {
  7.         super.onCreate(savedInstanceState);
  8.         setContentView(R.layout.acitivty_main);

  9.         tvMsg = (TextView)findViewById(R.id.tv_msg);

  10.         reserveWorker = new ReserveWorker();
  11.         goFrontPage();
  12.     }

  13.     private void goFrontPage(){
  14.         try {
  15.             String resultUrl = appleReserveWorker.visitFirstPage();
  16.             Log.d(TAG, "Result url is " + resultUrl);
  17.         }
  18.         catch(Exception e){
  19.             e.printStackTrace();
  20.         }
  21.     }
  22. }
複製代碼
試試 compile 放到 Android 上行一次,看看 logcat 有什麼?
  1. 10-17 17:38:39.569    6020-6020/com.thirtysparks.apple.bot W/System.err﹕ android.os.NetworkOnMainThreadException
  2. 10-17 17:38:39.569    6020-6020/com.thirtysparks.apple.bot W/System.err﹕ at android.os.StrictMode$AndroidBlockGuardPolicy.onNetwork(StrictMode.java:1145)
  3. 10-17 17:38:39.569    6020-6020/com.thirtysparks.apple.bot W/System.err﹕ at libcore.io.BlockGuardOs.connect(BlockGuardOs.java:84)
  4. 10-17 17:38:39.569    6020-6020/com.thirtysparks.apple.bot W/System.err﹕ at libcore.io.IoBridge.connectErrno(IoBridge.java:127)
  5. 10-17 17:38:39.569    6020-6020/com.thirtysparks.apple.bot W/System.err﹕ at libcore.io.IoBridge.connect(IoBridge.java:112)
  6. 10-17 17:38:39.569    6020-6020/com.thirtysparks.apple.bot W/System.err﹕ at java.net.PlainSocketImpl.connect(PlainSocketImpl.java:192)
  7. 10-17 17:38:39.569    6020-6020/com.thirtysparks.apple.bot W/System.err﹕ at java.net.PlainSocketImpl.connect(PlainSocketImpl.java:460)
  8. 10-17 17:38:39.569    6020-6020/com.thirtysparks.apple.bot W/System.err﹕ at java.net.Socket.connect(Socket.java:833)
  9. 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. 10-17 17:38:39.569    6020-6020/com.thirtysparks.apple.bot W/System.err﹕ at com.squareup.okhttp.Connection.connect(Connection.java:148)
  11. 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)
  12. 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)
  13. 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)
  14. 10-17 17:38:39.569    6020-6020/com.thirtysparks.apple.bot W/System.err﹕ at com.squareup.okhttp.Call.getResponse(Call.java:198)
  15. 10-17 17:38:39.569    6020-6020/com.thirtysparks.apple.bot W/System.err﹕ at com.squareup.okhttp.Call.execute(Call.java:80)
  16. 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)
  17. 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)
  18. 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:
  1. new AsyncTask<Void, Void, String>() {
  2.     @Override
  3.     protected String doInBackground(Void... voids) {
  4.         //do something in another thread
  5.     }

  6.     @Override
  7.     protected void onPostExecute(String resultString) {
  8.         //update the UI
  9.     }
  10. }.execute();
複製代碼
用 AsyncTask 很簡單,doInBackground 是用來做耗時的工作,完成後交給 onPostExecute 來做 ui 更新。所以之前的 goFrontPage() 會更新為:
  1. private void goFrontPage(){
  2.     new AsyncTask<Void, Void, String>() {
  3.         @Override
  4.         protected String doInBackground(Void... voids) {
  5.             String resultUrl = null;
  6.             try {
  7.                 resultUrl = reserveWorker.visitFirstPage();
  8.             }
  9.             catch(Exception e){
  10.                 e.printStackTrace();
  11.             }

  12.             return resultUrl;
  13.         }

  14.         @Override
  15.         protected void onPostExecute(String resultString) {
  16.             //update the UI

  17.             Log.d(TAG, "Result url is " + resultString);
  18.             boolean isLogin = (resultString.startsWith("https://signin.apple.com/IDMSWebAuth/"));
  19.             if(isLogin){
  20.                 tvMsg.setText("Redirected to login page");
  21.             }
  22.             else{
  23.                 tvMsg.setText("Failed");
  24.             }
  25.         }
  26.     }.execute();
  27. }
複製代碼
注意的是,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

    https://github.com/bumptech/glide

Glide 需要 Android Support Library v4 才能運作,請自行下載吧。

Glide 功能強大,有需要的請自行研究。為簡單起見我們只用最基本的功能:
  1. Glide.load(myUrl).into(captchaImageView);
複製代碼
執行後圖片便會自動載入到 captchaImageView,省時省力 (這正是我們應該追求的最高境界)。

那麼現在到 layout_main.xml 中加進 ImageView 和 EditText,用來顯示 captcha。
  1. <?xml version="1.0" encoding="utf-8"?>
  2. <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
  3.               android:orientation="vertical"
  4.               android:layout_width="fill_parent"
  5.               android:layout_height="fill_parent"
  6.         >
  7.     <TextView
  8.             android:id="@+id/tv_msg"
  9.             android:layout_width="match_parent"
  10.             android:layout_height="wrap_content"
  11.             android:text="Hello World, MyActivity"
  12.             />
  13.     <ImageView
  14.             android:id="@+id/iv_captcha"
  15.             android:layout_width="match_parent"
  16.             android:layout_height="80dp"/>
  17.     <EditText
  18.             android:id="@+id/et_captcha"
  19.             android:layout_width="match_parent"
  20.             android:layout_height="wrap_content"/>
  21. </LinearLayout>
複製代碼
運行一次看看,應該看到 captcha 了。

Step 3 - 登入

最後是登入的步驟,需要以下資料:

Required parameters

相信大部份資料都一看即明,fdcBrowserData 即是 browser 資料,重用即可。其他的都是不變資料,唯一有需要處理的是 path,似乎每次會不同,究竟是何時製造出來的?

細看 firebug 記錄由首頁到 login 的資料,可看到 login page 的 URL 是
  1. 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。
  1. public static Map<String, String> extractQueryString(String url) {
  2.     String param = url.substring(url.indexOf("?") + 1);
  3.     if (param.indexOf("#") > -1) {
  4.         param = param.substring(0, param.indexOf("#"));
  5.     }
  6.     String paramsStr[] = param.split("&");
  7.     Map<String, String> params = new HashMap<String, String>();
  8.     for (String str : paramsStr) {
  9.         String keyVal[] = str.split("=");
  10.         if (keyVal.length == 2 && keyVal[0].length() > 0 && keyVal[1].length() > 0) {
  11.             params.put(keyVal[0], keyVal[1]);
  12.         }
  13.     }

  14.     return params;
  15. }
複製代碼
再更新之前 visitFrontPage() 為
  1. Map<String, String> loginPageQueryString = new HashMap<String, String>();
  2. public String visitFirstPage() throws Exception {
  3.     Request request = new Request.Builder()
  4.             .url("https://reserve-hk.apple.com/HK/en_HK/reserve/iPhone")
  5.             .build();
  6.     Response response = okHttpClient.newCall(request).execute();

  7.     String resultUrl = response.request().url().toString();


  8.     loginPageQueryString = extractQueryString(resultUrl);
  9.     Log.d(TAG, "Path is " + loginPageQueryString.get("path"));

  10.     return resultUrl;
  11. }
複製代碼
便可將所有 query string 放進 loginPageQueryString 裏。

而在 ReserverWorker 新增 loginWithCaptcha function:
  1. public String loginWithCaptcha(String captchaInput, String appleId, String password) throws Exception {
  2.     Map<String, String> params = new HashMap<String, String>();
  3.     params.put("openiForgotInNewWindow", "true");
  4.     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;;;;;;;"}");

  5.     params.put("appleId", appleId);
  6.     params.put("accountPassword", password);
  7.     params.put("captchaInput", captchaInput);
  8.     params.put("captchaAudioInput", "");
  9.     params.put("appIdKey", "db0114b11bdc2a139e5adff448a1d7325febef288258f0dc131d6ee9afe63df3");
  10.     params.put("language", "HK-EN");
  11.     params.put("path", URLDecoder.decode(loginPageQueryString.get("path")));
  12.     params.put("rv", "3");
  13.     params.put("sslEnabled", "true");
  14.     params.put("Env", "PROD");
  15.     params.put("captchaType", "image");
  16.     params.put("captchaToken", "");

  17.     FormEncodingBuilder builder = new FormEncodingBuilder();
  18.     for (String key : params.keySet()) {
  19.         builder.add(key, params.get(key));
  20.     }
  21.     RequestBody formBody = builder.build();
  22.     Request request = new Request.Builder()
  23.             .url("https://signin.apple.com/IDMSWebAuth/authenticate")
  24.             .post(formBody)
  25.             .build();
  26.     Response response =  okHttpClient.newCall(request).execute();


  27.     String resultUrl = response.request().url().toString();
  28.     return resultUrl;
  29. }
複製代碼
因為成功登入的話,URL 會變成 https://reserve-hk.apple.com/HK/ ... hone?execution=e1s2,所以這次也以 resultUrl 為成功登入與否的指標。

然後回到 layout_main.xml 裏加一登入按鈕
  1. <Button
  2.         android:id="@+id/btn_captcha"
  3.         android:layout_width="match_parent"
  4.         android:layout_height="wrap_content"
  5.         android:text="Submit Captcha"
  6.         />
複製代碼
並在 MainActivity 新增以下 method :
  1. private void goLoginCaptcha() {
  2.     tvMsg.setText("Submitting captcha");

  3.     final String captchaInput = ((EditText) findViewById(R.id.et_captcha)).getText().toString();
  4.     new AsyncTask<Void, Void, String>() {
  5.         @Override
  6.         protected String doInBackground(Void... params)  {
  7.             String string = null;
  8.             try {
  9.                 string = reserveWorker.loginWithCaptcha(captchaInput, APPLE_ID, PASSWORD);
  10.             }
  11.             catch(Exception e){
  12.                 e.printStackTrace();
  13.             }

  14.             return string;
  15.         }

  16.         @Override
  17.         protected void onPostExecute(String s) {
  18.             if ("https://reserve-hk.apple.com/HK/en_HK/reserve/iPhone?execution=e1s2".equals(s)) {
  19.                 tvMsg.setText("Apple ID Login successfully");
  20.             } else {
  21.                 tvMsg.setText("Error: Apple ID Login failed");
  22.             }
  23.         }
  24.     }.execute();
  25. }
複製代碼
在 onCreate() 設定點擊 Button 便登入吧。
  1.     findViewById(R.id.btn_captcha).setOnClickListener(new View.OnClickListener() {
  2.         @Override
  3.         public void onClick(View v) {
  4.             goLoginCaptcha();
  5.         }
  6.     });
複製代碼
運行試一試,輸入 captcha 按 Submit 應該已成功登入。


待續

今次解釋了如何進行 http get 和 post 的動作,以上的 code 可在以下網址找到:

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

如果你已懂得發送接收 SMS,又已記錄所有 parameter 的話,你已經可以自行繼續寫整個 bot。如果你不懂的話,請期待 Part 3 。

==================================
原 blog link:  http://bit.ly/1pwapy4 ,圖片請到原 post 看。

Ching 正! 支持

TOP

今晚要開夜車開明白!

TOP

研究 研究

Thanks!

TOP

本帖最後由 joetcw 於 2014-10-21 17:09 編輯

真係要回post讚下樓主, 外國呢類分享好常見, 但香港真正做到真係少之有少

有好多所謂的技術分享post, 其實只係一班小圈子係度講暗語, 唔知係真心曬命定係小圈子分享, 講一堆technical jargon, 可能佢地本身真係勁, 但如果唔係真係公開討論的話, 咁不如唔好開post好了, 免得一堆讀者讀咗十幾版都係得個桔

我上年試過俾人呃添, 有條戊利又話交換心得, 我pm咗code俾佢, 但佢最後當然冇reply啦, 但好彩, 我都唔笨, 啲code係少咗幾句保障自己

TOP

支持樓主分享

TOP

本帖最後由 qtimeqtime 於 2014-10-21 19:54 編輯

身苦晒師兄打咁多字
期待PART3

TOP

真係要回post讚下樓主, 外國呢類分享好常見, 但香港真正做到真係少之有少

有好多所謂的技術分享po ...
joetcw 發表於 2014-10-21 17:06


香港人手停口停....老實講除非你開工時間無野做....唔係都應該寫唔出教學.....
相信樓主是無業人士/ 上工沒事做之人士......

TOP

香港人手停口停....老實講除非你開工時間無野做....唔係都應該寫唔出教學.....
相信樓主是無業人士/ 上工 ...
神秘二代 發表於 2014-10-21 17:27


haha, 咁你又唔好咁講, 樓主有寫開blog的, 冇人話要一次過打晒, 分開打兩三晚都得啦
唔好講到打咁多字就手停口停

TOP

本帖最後由 hihihi123hk 於 2014-10-21 18:02 編輯
真係要回post讚下樓主, 外國呢類分享好常見, 但香港真正做到真係少之有少

有好多所謂的技術分享po ...
joetcw 發表於 2014-10-21 17:06




我佩服樓主 花時間寫呢個 blog , 可以放係 CV 入面

於我而言,都曾經想搞 IT/ programming  related 既 blog 賺下 Google Adsense
可惜香港人太少(學programming的更少) ,要賺廣告費實在太難
根本無動力

真係要搞 一定係北望神洲 或者 台灣,但係就要寫 書面語
再唔係 當操英文向國際出發。

反觀外國啲 blogger ,每個BLOG 既潛在觀看人數 同香港比差好遠

雖然Programming 係我興趣,但如果純為興趣唔為錢而寫BLOG ,我又覺得無呢個必要,

個人覺得
寫BLOG寫興趣 related + 賺 Google Adsense  根本上係雙贏,

作者及讀者都開心

TOP