靈感來源
之前在B站看到一個有意思的視訊:
【B站】【亦】終極雲遊戲!五千人同開一輛車,復現經典群體智慧實驗
大家可以看看,很有意思。
up主通過程式碼實現了實時讀取直播間裡的彈幕內容,進而控制自己的電腦,把彈幕翻譯成指令操控《賽博朋克2077》遊戲。
觀眾也越來越多,最後甚至還把直接間搞崩了(當然,其實是因為那天B站全站崩了)。
我十分好奇到底是怎麼做到的。
外行看熱鬧,內行看門道,作為半個內行,我們就模仿UP主的想法,自己做一個。
所以今天我的目標就是復刻一個 通過彈幕控制直播間 的程式碼,並且最終在自己的直播間開播。
先給大家看看最終我的成品小視訊:
看起來是不是很像樣了。
初版設計思路
首先在腦海裡規劃一個大致的思路,如下圖:
這個思路看起來很簡單,不過還是得解釋一下,首先我們要搞清楚,彈幕的內容是怎麼抓到的。
大部分我們常見的直播平臺,在瀏覽器端,彈幕都是通過WebSocket來推送給觀眾的。在手機平板等客戶端(非Web端),可能會有一些更加複雜的TCP進行彈幕的推送。
關於TCP的訊息投遞,有個很好的文章,就是美團的這個:美團終端訊息投遞服務Pike的演進之路
歸根結底,這些彈幕都是通過在客戶端和服務端建立長連結來實現的。
所以,我們需要做的就是用程式碼作為客戶端,與直播平臺進行長連結。這樣就能拿到彈幕。
我們只是需要實現整個彈幕控制的流程,所以彈幕的抓取也不是本文的重點,我們來淘一個現成的輪子!在Github上一頓找,找到了一個非常不錯的開源庫,裡面能夠獲取很多直播平臺的彈幕:
https://github.com/wbt5/real-url
獲取鬥魚&虎牙&嗶哩嗶哩&抖音&快手等 58 個直播平臺的真實流媒體地址(直播源)和彈幕,直播源可在 PotPlayer、flv.js 等播放器中播放。
我們把程式碼clone下來,執行main函式,隨便輸入一個Bilibili直播間地址,就能拿到直播間實時的彈幕流:
程式碼裡把獲取到的一條條彈幕(包括使用者名稱)直接列印在了控制檯。
他是如何做到的呢?核心的Python程式碼如下(不熟悉Python?不要緊,就當做虛擬碼,很容易看懂):
wss_url = 'wss://broadcastlv.chat.bilibili.com/sub'
heartbeat = b'\x00\x00\x00\x1f\x00\x10\x00\x01\x00\x00\x00\x02\x00\x00\x00\x01\x5b\x6f\x62\x6a\x65\x63\x74\x20' \
b'\x4f\x62\x6a\x65\x63\x74\x5d '
heartbeatInterval = 60
@staticmethod
async def get_ws_info(url):
url = 'https://api.live.bilibili.com/room/v1/Room/room_init?id=' + url.split('/')[-1]
reg_datas = []
async with aiohttp.ClientSession() as session:
async with session.get(url) as resp:
room_json = json.loads(await resp.text())
room_id = room_json['data']['room_id']
data = json.dumps({
'roomid': room_id,
'uid': int(1e14 + 2e14 * random.random()),
'protover': 1
}, separators=(',', ':')).encode('ascii')
data = (pack('>i', len(data) + 16) + b'\x00\x10\x00\x01' +
pack('>i', 7) + pack('>i', 1) + data)
reg_datas.append(data)
return Bilibili.wss_url, reg_datas
它連上了Bilibili的直播彈幕WSS地址,也就是WebSocket地址,然後偽裝成客戶端,接受彈幕推送。
OK,做完了第一步,下一步就是用訊息佇列將彈幕傳送出來。開啟單獨的消費者接收彈幕。
為了實現上儘量簡單,就不上那些專業的訊息佇列了,這裡用了redis的list作為佇列,將彈幕內容放進去。
傳送者核心程式碼如下:
# 連結Redis
def init_redis():
r = redis.Redis(host='localhost', port=6379, decode_responses=True)
return r
# 訊息傳送者
async def printer(q, redis):
while True:
m = await q.get()
if m['msg_type'] == 'danmaku':
print(f'{m["name"]}:{m["content"]}')
list_str = list(m["content"])
print("彈幕拆分:", list_str)
for char in list_str:
if char.lower() in key_list:
print('推送佇列:', char.lower())
redis.rpush(list_name, char.lower())
完成了彈幕內容的傳送後,需要寫一個消費者,消費這些彈幕,把裡面的指令都提取出來。
並且,在消費者收到彈幕後,如何消費呢?我們需要一個能夠用程式碼指令控制電腦的辦法。
我們繼續本著不造輪子的原則,找到了一個Python的自動化控制庫PyAutoGUI
PyAutoGUI is a cross-platform GUI automation Python module for human beings. Used to programmatically control the mouse & keyboard.
安裝上這個庫,在程式碼中引入,便可以通過他的API控制電腦滑鼠和鍵盤執行對應的操作。簡直是完美啊!
消費者(控制電腦)核心Python程式碼如下:
# 連結Redis
def init_redis():
r = redis.Redis(host='localhost', port=6379, decode_responses=True)
return r
# 消費者
def control(key_name):
print("key_name =", key_name)
if key_name == None:
print("本次無指令發出")
return
key_name = key_name.lower()
# 控制電腦指令
if key_name in key_list:
print("發出指令", key_name)
pyautogui.keyDown(key_name)
time.sleep(press_sec)
pyautogui.keyUp(key_name)
print("結束指令", key_name)
if __name__ == '__main__':
r = init_redis()
print("開始監聽彈幕訊息, loop_sec =", loop_sec)
while True:
key_name = r.lpop(list_name)
control(key_name)
time.sleep(loop_sec)
ok,大功告成,我們開啟彈幕傳送佇列和消費者,這個不斷迴圈消費的佇列就開始執行了。一旦彈幕中有wsad這種控制遊戲常用的按鍵,電腦就會自己給自己發出指令。
初版執行中的問題
我興沖沖的開啟自己的B站直播間,開始除錯,結果發現我還是太天真了。這個初版程式碼暴露了非常多的問題。我們一個個來說下是什麼問題,我是如何解決的。
指令不人性化
水友們其實很喜歡傳送類似www dddd這類重複單詞(疊詞),但初版的實現只支援單個字幕,水友們發現不得勁,沒有作用後,就從直播間走了。
這點很容易解決,把彈幕內容拆分成每個單詞,然後再推送給佇列。
解決方法:拆解彈幕,把DDD,拆成D,D,D,傳送個消費者。
危險指令
首先是玩家的指令超出了應該有的範圍。
在我把賽博朋克遊戲開啟,讓彈幕觀眾控制遊戲裡的開車時,有個神祕觀眾進了直播間,默默發了個“F”,然後。。。
然後遊戲裡的V(主角名)就從車裡下來了,淦,我是讓你們開車的,不是讓你們下來和警察鬥毆的。。。
解決方法:新增彈幕過濾器。
# 將彈幕進行拆分,只傳送指定的指令給消費者
key_list = ('w', 's', 'a', 'd', 'j', 'k', 'u', 'i', 'z', 'x', 'f', 'enter', 'shift', 'backspace')
list_str = list(m["content"])
print("彈幕拆分:", list_str)
for char in list_str:
if char.lower() in key_list:
print('推送佇列:', char.lower())
redis.rpush(list_name, char.lower())
上面兩個問題解決後,傳送者就像下面這樣執行了:
彈幕指令堆積
這是個很大的問題,如果處理所有水友傳送的全部彈幕指令,一定會存在消費不過來的問題。
解決方法:需要固定時間處理彈幕,其他拋棄。
if __name__ == '__main__':
r = init_redis()
print("開始監聽彈幕訊息, loop_sec =", loop_sec)
while True:
key_name = r.lpop(list_name)
# 每次只取出一個指令,然後把list清空,也就是這個時間視窗內其他彈幕都扔掉!
r.delete(list_name)
control(key_name)
time.sleep(loop_sec)
彈幕從發出到觀眾看到結果有延遲
在最開始的視訊裡,你們也能感受到了,從觀眾的指令發出,到最終被觀眾看到,大概要經歷5秒的延遲。其中,起碼有3秒,都是網路直播流的延遲,這一點,很難去優化。
回爐重造後的版本
經過一系列調優和涉及,我們的版本也算是從V0.1到了V0.2了。猛虎落淚。
下面是重構後的結構圖:
後記
在寫完這個專案後,我在直播間試了很多次,體驗已經無限接近UP主當時的視訊了。我開播掛在那邊好久,但是,人氣最高的時候,也只有20幾個人,寥寥十幾條彈幕,還有很多是我發的。我還期望著觀眾能夠拉更多人進來一起玩呢,事與願違啊。
由此可得出結論,我,先得有粉絲,才能玩得起來啊,嗚嗚嗚嗚。大家要是不介意,可以關注下我的B站賬號,也叫:蠻三刀醬。我會偶爾抽風發點有趣的技術視訊的。
本文實現的全部程式碼已經開源在了Github上,大家可以在自己的直播間裡試試呀:
https://github.com/qqxx6661/l...
我是在阿里搬磚的工程師 @蠻三刀醬
持續的更新優質文章,離不開你的點贊,轉發和分享!
全網唯一技術公眾號:後端技術漫談