除夕練習:自動掛bilibili直播

剛開了299一年年費姥爺,看直播2.5倍經驗不能浪費了,但是如果一直開著網頁掛直播的話又會因為視頻的解碼消耗CPU資源,很是不爽。

古語有云:世界上沒有靠寫程序做不到的事,除了找女朋友。

那麼, 何不寫一個小程序在後台自動掛直播,而無須實際打開直播頁面呢?

除夕在家閑的無聊,就拿這個給大家(如果真的有的話)拜個年吧。

0x01 開發者頁面了解一下

把大象塞進冰箱里只需要三步:

第一步是找一個一直在直播的直播間,我們這裡選擇iPanda直播。

第二步是分析出b站用過什麼原理來得知我開著直播間。

第三步是寫程序來模擬這一過程,達到後台掛直播的效果。

打開chrome的開發者頁面,network一欄是這樣的:

去掉不是重複出現的項目,一堆web開頭的紅色的部分是因為跨域問題被禁止訪問了,不過既然沒有它也會累積經驗,那麼這肯定不是最重要的東西。剩下三個userOnlineHeart, player和heartBeat(heartBeat有兩種),看起來都是各種心跳包,那麼到底哪個才是我們想要的呢?

通過看Response Header裡面的Date可以得出(heartBeat-A和heartBeat-B雖然url是一樣的但是參數和間隔都不一樣,加一個後綴用以區分):

  • player(http://live-trace.bilibili.com/heartbeat_ext/v1/Heartbeat/player)的心跳間隔是15s。
  • heartBeat-A(api.live.bilibili.com/f)的心跳間隔是100s。
  • heartBeat-B(api.live.bilibili.com/f)的心跳間隔是300s。
  • userOnlineHeart(api.live.bilibili.com/U)的心跳間隔是300s。

再看直播頁面上對於經驗的描述:

5分鐘提升3000經驗,5分鐘=300秒,這個數字是不是剛剛見過吶?

因此我們可以就此猜想:決定經驗積累的心跳包主要是heartBeat-B和userOnlineHeart這兩個,更巧的是這倆一般都是一前一後的出現,先是userOnlineHeart然後是heartBeat-B。

那麼事實確實如此嗎?

0x02 ジャバスクリプト?エヴァーガーデン

(讀作javascript evergarden)

先來看userOnlineHeart:

嗯好大一個馬賽克。

總之這是一個沒有表單數據的POST請求,通過cookie來確定身份等信息。自然這麼多cookie中不一定都是必要的,這一點我們可以通過模擬請求來檢測。

正常的請求返回的內容是這樣的:

{"code":0,"msg":"OK","message":"OK","data":{"giftlist":[]}}

而當我們去掉所有cookie之後返回的內容是這樣的:

{"code":3,"msg":"user no login","message":"user no login","data":{"giftlist":[]}}

我們可以從正常的請求中一一去除cookie,看去掉哪個之後就會由有效的請求變成無效的。

實驗過程沒有什麼技術含量,在此不表,最後得到結果:必要的Cookie項只有DedeUserID, DedeUserID__ckMd5和SESSDATA這三個。它們的expire時間都是每月的月末,不過可惜的是其內容暫時無法破解,只能通過從自己瀏覽器裡面複製。

再來看heartBeat-B:

與userOnlineHeart相同,執行這個操作所必須的cookie也是DedeUserID, DedeUserID__ckMd5和SESSDATA。那麼url中「_」後的參數是什麼意思呢?

答案是:當前時間的時間戳。

雖然似乎吧這個參數設成0照樣能得到與正常請求相同的回復,不過保險起見我們還是用時間戳好了。

0x03 ダーリン?イン?ザ?ナマホウソウ

(讀作darling in the 生放送)

高中生物書第一冊告訴我們,提出假設之後要設計實驗,設計實驗之後再執行實驗,最後得出結論。我們已經給出了一個假設:通過userOnlineHeart和heartBeat-B來積累經驗,那麼解下來就要設計實驗了。

先記錄下我現在的經驗值,關掉直播頁面,在程序運行一段時間後再重新看經驗值有沒有增加,如果增加了的話就說明試驗成功。

不過我們首先得把這個程序寫出來。當然,在上一節已經充分分析了這兩個請求的原理之後,實現起來就要方便得多了。

作為自封的著名java語言文字工作者,我選擇用Apache HttpClient 4.x來進行網路訪問,org.json:json來解析JSON,編譯環境Java8。程序很簡單,不多解釋了,直接上代碼:

package nichijou.bililive;import org.apache.http.HttpEntity;import org.apache.http.HttpResponse;import org.apache.http.client.methods.HttpGet;import org.apache.http.client.methods.HttpPost;import org.apache.http.client.methods.HttpUriRequest;import org.apache.http.client.protocol.HttpClientContext;import org.apache.http.cookie.ClientCookie;import org.apache.http.impl.client.BasicCookieStore;import org.apache.http.impl.client.CloseableHttpClient;import org.apache.http.impl.client.HttpClients;import org.apache.http.impl.cookie.BasicClientCookie;import org.apache.http.protocol.BasicHttpContext;import org.apache.http.protocol.HttpContext;import org.apache.http.util.EntityUtils;import org.json.JSONObject;import javax.swing.*;import java.awt.*;import java.io.IOException;import java.util.Calendar;import java.util.HashMap;import java.util.Map;import java.util.concurrent.Executors;import java.util.concurrent.ScheduledExecutorService;import java.util.concurrent.TimeUnit;import java.util.concurrent.atomic.AtomicBoolean;public class BiliLive implements Runnable { private static final int INTERVAL = 300000; // 300000ms = 300s = 5min private static final String USER_ONLINE_HEART = "http://api.live.bilibili.com/User/userOnlineHeart"; private static final String HEART_BEAT_B = "http://api.live.bilibili.com/feed/v1/feed/heartBeat?_=%d"; private static final Map<String, String> COOKIES = new HashMap<>(); private static final String COOKIE_PATH = "/"; private static final String COOKIE_DOMAIN = ".bilibili.com"; static { COOKIES.put("DedeUserID", ""); COOKIES.put("DedeUserID__ckMd5", ""); COOKIES.put("SESSDATA", ""); } private ScheduledExecutorService executorService; private AtomicBoolean started; private AtomicBoolean terminated; public BiliLive() { this.executorService = Executors.newSingleThreadScheduledExecutor(); this.started = new AtomicBoolean(false); this.terminated = new AtomicBoolean(false); } /** * Start scheduled heartbeats. Cannot be called again after termination. Thread-safe. */ public synchronized void start() { if (this.started.get() || this.terminated.get()) return; log("Starting..."); this.executorService.scheduleAtFixedRate( this, 0, INTERVAL, TimeUnit.MILLISECONDS); this.started.set(true); log("Started."); } /** * Request termination. Will not block until terminated. Thread-safe. */ public synchronized void terminate() { if (!this.started.get() || this.terminated.get()) return; log("Terminating..."); this.executorService.shutdown(); this.started.set(false); this.terminated.set(true); log("Terminated."); } /** * Fetch data from given request as {@link String} and throws {@link IOException} * when something gets wrong. */ private String fetch(HttpUriRequest request) throws Exception { try (CloseableHttpClient client = HttpClients.createDefault()) { BasicCookieStore cookieStore = new BasicCookieStore(); for (Map.Entry<String, String> entry : COOKIES.entrySet()) { BasicClientCookie cookie = new BasicClientCookie( entry.getKey(), entry.getValue()); cookie.setPath(COOKIE_PATH); cookie.setDomain(COOKIE_DOMAIN); // without this cookie will not be sent in HttpClient 4.5 cookie.setAttribute(ClientCookie.DOMAIN_ATTR, "true"); cookieStore.addCookie(cookie); } HttpContext context = new BasicHttpContext(); context.setAttribute(HttpClientContext.COOKIE_STORE, cookieStore); HttpResponse response = client.execute(request, context); if (response.getStatusLine().getStatusCode() != 200) throw new IllegalStateException("Not responding 200 OK!"); return EntityUtils.toString(response.getEntity()); } } private String get(String uri) throws Exception { return fetch(new HttpGet(uri)); } private String post(String url, HttpEntity entity) throws Exception { HttpPost request = new HttpPost(url); request.setEntity(entity); return fetch(request); } /** * Check whether the given JSONObject (represented with a {@link String}) is a * valid response, that is, {@code code = 0}. */ private void validate(String text) throws Exception { JSONObject response = new JSONObject(text); int code = response.getInt("code"); if (code != 0) throw new IllegalStateException(String.format( "%s is not a valid response: code=%d, msg=%s", text, code, response.getString("msg"))); } /** * Send the heartbeat requests once.<br /> * Overrides {@link Runnable#run()}, used by {@link #executorService} in * {@link ScheduledExecutorService#scheduleAtFixedRate(Runnable, long, long, TimeUnit)}. */ @Override public void run() { try { log("Sending heartbeat..."); validate(post(USER_ONLINE_HEART, null)); validate(get(String.format(HEART_BEAT_B, System.currentTimeMillis()))); log("Heartbeat success."); } catch (Exception e) { e.printStackTrace(); throw new RuntimeException( "An error occurred while sending heartbeat requests!", e); } } private void log(String msg) { Calendar calendar = Calendar.getInstance(); System.out.printf("[%02d:%02d:%02d] %s
"
, calendar.get(Calendar.HOUR_OF_DAY), calendar.get(Calendar.MINUTE), calendar.get(Calendar.SECOND), msg); } public static void main(String[] args) throws Exception { // init swing UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName()); // init frame final int width = 300, height = 200; JFrame frame = new JFrame("BiliLive"); frame.setResizable(false); frame.setSize(width, height); frame.setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE); // init components Container container = frame.getContentPane(); container.setLayout(new BorderLayout()); final JButton button = new JButton("Start"); container.add(button, BorderLayout.CENTER); // init logic final BiliLive live = new BiliLive(); button.addActionListener(e -> { String text = button.getText(); if ("Start".equals(text)) { live.start(); button.setText("Terminate"); } else { live.terminate(); button.setEnabled(false); } }); // show frame Dimension screen = Toolkit.getDefaultToolkit().getScreenSize(); frame.setLocation((screen.width - width) / 2, (screen.height - height) / 2); frame.setVisible(true); }}

三個cookie的地方留了空,自己用的話應該把自己的cookie填進去。

Swing做了一個簡單的小UI,就一個按鈕,點擊開始發送心跳包,不過swing用了系統LAF的啟動速度偏慢,剛運行時沒有跳出來的話稍等一下即可。

程序寫罷,そして、試験が始まります!

實驗開始之前我的經驗值是:

年費老爺2.5倍經驗,也就是5分鐘7500經驗,我們將程序運行30分鐘,大概能獲得37500~45000經驗。30分鐘後:

大成功!

0x04 總結

仔細觀察可以發現,我們實現的整個過程中只用到了userOnlineHeart和heartBeat-B兩個心跳包,而其中包含的數據也只有當前時間和用戶的登錄信息,並沒有直播信息,因此實際上不需要看直播就可以達成自動領經驗的功能。

在後續的實驗中,我將發送心跳包的時間間隔調至了2.5分鐘,但是得到的經驗並沒有提升。這說明心跳包只是起到一個維持在線狀態的功能,實際經驗的計算是在後台進行的。這也解釋了為什麼它能做到「同時開著多個直播間不會得到多倍經驗」——因為心跳包中不包含直播間信息,同時開著多個直播間等價於降低心跳包時間間隔,而這並不會導致經驗的增多。

古語有云:精益求精。

手動從cookie中提取需要的項未免有些複雜,對於沒有編程背景的人來說也不夠友善,而(利用XSS漏洞等)自動提取cookie又難度頗大。想到我寫這些最初的用意只是想在賺直播經驗的同時不開著直播間而消耗系統資源,因此可以有這樣一個折衷的辦法:

發送心跳包所需的DedeUserID,DedeUserID__ckMd5和SESSDATA的域(domain)都是.bilibili.com,也就是在bilibili全站下的任何一個頁面都可以獲取其值,而我們可以在這些頁面上發送心跳包,效果也是完全相同的。

當然在過多的頁面上發送心跳包並沒有意義,也消耗系統資源,因此我們可以指定只在特定頁面發送,以後不掛直播間而改掛這個頁面即可。將這個特定頁面可以取作個人中心 - bilibili link,而為了在這個頁面上自動發送心跳包,我寫了一個簡單的油猴插件,下載地址如下:

BiliLive Exp++greasyfork.org

關於油猴插件的安裝和運行,可以參考Tampermonkey ? Download,基本大部分常用的桌面瀏覽器都能兼容,當然如果你用的是某些特殊的國產瀏覽器那當我沒說。(其實那些很多也是chrome內核,百度一下也能找到裝chrome插件的方法)


最後,離雞年的結束還差6個小時左右,估計各位(如果有的話)看到這篇文章時也已經是狗年了吧。無論如何,在這件雞年最後的工作的最後,還是要祝大家:

狗年大吉吧!

【假裝有圖】


推薦閱讀:

ngrok - 免費的內網穿透
探究 tcp 協議中的三次握手與四次揮手
在美聯航(United)飛機上使用免費Wi-Fi的探索
計算機網路學習隨想三

TAG:嗶哩嗶哩直播 | 前端開發 | 計算機網路 |