importcertifiimportsslimportasyncioimportwebsocketsimportrequestsfromapiimportget_player_live# 유니코드 및 기타 상수F="\x0c"ESC="\x1b\t"SEPARATOR="+"+"-"*70+"+"# 아프리카TV에서 제공하는 API로 채팅 정보를 받습니다.defget_player_live(bno,bid):url='https://live.afreecatv.com/afreeca/player_live_api.php'data={'bid':bid,'bno':bno,'type':'live','confirm_adult':'false','player_type':'html5','mode':'landing','from_api':'0','pwd':'','stream_type':'common','quality':'HD'}try:response=requests.post(f'{url}?bjid={bid}',data=data)response.raise_for_status()# HTTP 요청 에러를 확인하고, 에러가 있을 경우 예외를 발생시킵니다.res=response.json()CHDOMAIN=res["CHANNEL"]["CHDOMAIN"].lower()CHATNO=res["CHANNEL"]["CHATNO"]FTK=res["CHANNEL"]["FTK"]TITLE=res["CHANNEL"]["TITLE"]BJID=res["CHANNEL"]["BJID"]CHPT=str(int(res["CHANNEL"]["CHPT"])+1)returnCHDOMAIN,CHATNO,FTK,TITLE,BJID,CHPTexceptrequests.RequestExceptionase:print(f" ERROR: API 요청 중 오류 발생: {e}")returnNoneexceptKeyErrorase:print(f" ERROR: 응답에서 필요한 데이터를 찾을 수 없습니다: {e}")returnNone# SSL 컨텍스트 생성defcreate_ssl_context():ssl_context=ssl.create_default_context()ssl_context.load_verify_locations(certifi.where())ssl_context.check_hostname=Falsessl_context.verify_mode=ssl.CERT_NONEreturnssl_context# 메시지 디코드 및 출력defdecode_message(bytes):parts=bytes.split(b'\x0c')messages=[part.decode('utf-8')forpartinparts]iflen(messages)>5andmessages[1]notin['-1','1']and'|'notinmessages[1]:user_id,comment,user_nickname=messages[2],messages[1],messages[6]print(SEPARATOR)print(f"| {user_nickname}[{user_id}] - {comment}")else:# 채팅 뿐만 아니라 다른 메세지도 동시에 내려옵니다.pass# 바이트 크기 계산defcalculate_byte_size(string):returnlen(string.encode('utf-8'))+6# 채팅에 연결asyncdefconnect_to_chat(url,ssl_context):try:BNO,BID=url.split('/')[-1],url.split('/')[-2]CHDOMAIN,CHATNO,FTK,TITLE,BJID,CHPT=get_player_live(BNO,BID)print(f"{SEPARATOR}\n"f" CHDOMAIN: {CHDOMAIN}\n CHATNO: {CHATNO}\n FTK: {FTK}\n"f" TITLE: {TITLE}\n BJID: {BJID}\n CHPT: {CHPT}\n"f"{SEPARATOR}")exceptExceptionase:print(f" ERROR: API 호출 실패 - {e}")returntry:asyncwithwebsockets.connect(f"wss://{CHDOMAIN}:{CHPT}/Websocket/{BID}",subprotocols=['chat'],ssl=ssl_context,ping_interval=None)aswebsocket:# 최초 연결시 전달하는 패킷CONNECT_PACKET=f'{ESC}000100000600{F*3}16{F}'# 메세지를 내려받기 위해 보내는 패킷JOIN_PACKET=f'{ESC}0002{calculate_byte_size(CHATNO):06}00{F}{CHATNO}{F*5}'# 주기적으로 핑을 보내서 메세지를 계속 수신하는 패킷PING_PACKET=f'{ESC}000000000100{F}'awaitwebsocket.send(CONNECT_PACKET)print(f" 연결 성공, 채팅방 정보 수신 대기중...")awaitasyncio.sleep(2)awaitwebsocket.send(JOIN_PACKET)asyncdefping():whileTrue:# 5분동안 핑이 보내지지 않으면 소켓은 끊어집니다.awaitasyncio.sleep(60)# 1분 = 60초awaitwebsocket.send(PING_PACKET)asyncdefreceive_messages():whileTrue:data=awaitwebsocket.recv()decode_message(data)awaitasyncio.gather(receive_messages(),ping(),)exceptExceptionase:print(f" ERROR: 웹소켓 연결 오류 - {e}")asyncdefmain():url=input("아프리카TV URL을 입력해주세요: ")ssl_context=create_ssl_context()awaitconnect_to_chat(url,ssl_context)if__name__=="__main__":asyncio.run(main())
중요한 업데이트된 내용은 2가지가 있습니다. 첫번째로는 5분마다 끊어지는 것을 개선하고, 두번째로는 이전에 찾지못했던 bjid 길이에 따른 legth를 찾는 것이었습니다. DOCHIS(헛삯)님께서 댓글로 남겨주신 내용을 통해서 수정되었습니다. 감사합니다. 😀
우선 아프리카TV 채팅 웹소켓에 규칙이 있었습니다. 앞의 12자리는 어떤 종류를 수행하고 패킷의 바디 길이는 몇인지 등을 정하는 데이터를 담고 있습니다.
1
2
3
0000 > 4자리 : 어떤것을 실행할지
000000 > 6자리 : 패킷(바디)의 데이터 길이 (바이트)
00 > 2자리 : 어떤 역할인진 모르지만 12비트를 맞추기 위해 사용되는 걸로 추측됩니다.
위 규칙을 토대로 예를 들어보면 채팅 서버에 연결이 끊어지지 않게 확인하는 패킷은 다음과 같습니다.
1
2
3
0000 > 핑을 보내는 코드
000001 > 패킷(바디)의 데이터길이는 1
00 > 12비트 맞추기
두번째 핸드쉐이크는 _ 로 나누어지거나 &으로 값이 나누어 져있었습니다. &으로 나누어진 것을 보면 뭔가 파라미터 값을 전송하는 것으로 예측 할 수 있습니다. 전달되는 값들은 API의 리스폰스나 JS파일을 분석하면 나옵니다. 우선은 파이썬으로 핸드쉐이크떄 보내지는 값을 그대로 전송했을때 연결이 되는지 확인해봅니다
importcertifiimportsslimportbase64importasyncioimportwebsockets# WSS 연결 위한 SSL 설정ssl_context=ssl.create_default_context()ssl_context.load_verify_locations(certifi.where())ssl_context.check_hostname=Falsessl_context.verify_mode=ssl.CERT_NONE# 아래 3개 변수값은 어딘가의 API로 부터 받은 값들의 조합으로 우선은 그대로 사용Base64PdboxTicket='GwkwMDAxMDAwNTg3MDAMLkEzMi43YmJUNTZ2eUhNOWZLWmsuQ1V0N0dxNXdzSW95ZGdUejRvT0JkUGJEVTRPTEJjbDNEUkFUenJUYkhrQzlYRmtiZnl1YXMwUzE3SFlyZDFjbFU1VzV4U3hpTUJYS2lmV25fVXFDQThWWUYxczVidE04QzJuenhWN3NEVzlTcHFPM2V4VHNHQUMxU3ZmNTlCNUE1bDVrUFJsZHpleHB4OGxKbkJFYXE5UXNLbW1KQ21TTExQRm1JNkc3Q0FHZ1R0UDFWcGhzaTYzTDZXUHRDdkRPalZDNTg1LXVJQV9hRGRxcTlWX1pOWUlBQ0N3d3Yxb0NDckRLeE9icldKS0xpQlNrbGs3bW1TMkkwWVdtNnhiMDB2TG53OGJuQXVyZzRyNkpHeWVma0ZDWmZaM0V6c29LTEFwRU9xeFlkX0JXMVNxRWVNM1FuNkoyc0E5Y0d5WGFZLWhySm5wblJGNmN4RzFPTWJBSi1KVGVXc2JTLTNaNUNULS1fUW1vTWJCb2stSmJwUmt1Y1oxMURJSkFpY25NZmwxaWtzU1Y4aHh6YUVqWVExb19pamI1OXVCWUNYMlNsMFdLSEwydjk4WkhSLXZGNjNRRDY5VEtqSEpjaHBIaDh0RFNzUUxJekc3WUFZbmpKYjl3cDlvV2JfVi0wSFgyRFRYVnVUSEtKRTFPMWNtbHE1bC1qaXZ6bW1HdnJ0WnVmQVVfRG1NQzA1bUpPelNxZTl0bzNKVFZmMjBJWldfWXFpW...=='Base64ChannelInfo='GwkwMDAyMDAwMjk3MDAMOTYxOAw0NTY1MzFjMDg0YjUxMGJkOTAyYWViZDcyMjFiMjUyNl92bGZ2bGY3ODlfMjQ1NTkwMzY1X2lvcwwwDAxsb2cRBiYGc2V0X2JwcwY9BjgwMDAGJgZ2aWV3X2JwcwY9BjEwMDAGJgZxdWFsaXR5Bj0Gbm9ybWFsBiYGdXVpZAY9BjFlNDNjZjZkMzc5MTNjMzZiMzVkNTgwZTBiNTY1NmVjBiYGZ2VvX2NjBj0GS1IGJgZnZW9fcmMGPQYxMQYmBmFjcHRfbGFuZwY9BmtvX0tSBiYGc3ZjX2xhbmcGPQZrb19LUgYmBmpvaW5fY2MGPQY0MTAScHdkERJhdXRoX2luZm8RTlVMTBJwdmVyETESYWNjZXNzX3N5c3R...='WSSUrl='wss://chat-76dbfccf.afreecatv.com:8001/Websocket/vlfvlf789'asyncdefconnect():# 웹 소켓에 접속을 합니다.asyncwithwebsockets.connect(WSSUrl,subprotocols=['chat'],ssl=ssl_context,ping_interval=None)aswebsocket:# 핸드쉐이크awaitwebsocket.send(base64.b64decode(Base64PdboxTicket))awaitwebsocket.recv()awaitwebsocket.send(base64.b64decode(Base64ChannelInfo))# 이후부터 채팅내용 받아와짐whileTrue:try:data=awaitwebsocket.recv()print(data)exceptExceptionase:print("ERROR:",e)asyncio.get_event_loop().run_until_complete(connect())
코드 실행시 데이터를 성공적으로 잘 받아오는 것을 확인 할 수 있었습니다.
단, 바이트 코드로 되어있으면서 hex 값이랑 평문이랑 섞여있기 때문에 해당 데이터를 복호화 해줍니다. \x0c 값이 계속 보이는 것으로 보아 해당 값으로 split 해주고 utf-8로 변환하였습니다.
아프리카TV 채팅창 내용을 불러오는 것 까지는 성공했습니다 ! 다만 네트워크탭을 켜서 필요한 값들을 복사할 수는 없기에 해당 값들까지 분석하여 URL만 제공해도 모든것이 자동으로 돌아가져야 합니다. 위에서 연결하고 추출까지 성공했기 때문에 이제부터 필요한 값은 딱 3개 입니다.
채팅에 연결되면서 두번 핸드쉐이크할때 변화되는 값들을 확인해보고 해당 값들이 어떤 api에서 왔는지 분석해보았습니다.
API 주소 : https://live.afreecatv.com/afreeca/player_live_api.php?bjid={bid}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
defplayer_live_api(bno,bid):# type=aid 일 경우 aid(.A32~~~)불러옴data={'bid':bid,'bno':bno,'type':'live','confirm_adult':'false','player_type':'html5','mode':'landing','from_api':'0','pwd':'','stream_type':'common','quality':'HD'}res=requests.post(f'https://live.afreecatv.com/afreeca/player_live_api.php?bjid={bid}',data=data,headers=headers).json()# wss연결할 채팅 UrlCHDOMAIN=res["CHANNEL"]["CHDOMAIN"].lower()# 채팅방 번호CHATNO=res["CHANNEL"]["CHATNO"]# 채팅방 포트 번호 인데 1을 더해주어야함CHPT=str(int(res["CHANNEL"]["CHPT"])+1)# 첫번재 핸드쉐이크때 사용할 티켓TK=res["CHANNEL"]["BJID"]# 두번째 핸드쉐이크때 사용할 티켓FTK=res["CHANNEL"]["FTK"]# bj 명BJID=res["CHANNEL"]["BJID"]# 제목TITLE=res["CHANNEL"]["TITLE"]returnCHDOMAIN,CHATNO,FTK,TITLE,BJID,TK,CHPT
네트워크 탭을 보면 해당 API에 2번 POST요청을 보냅니다. 헤더값에 따라 받아오는 채팅 연결 도메인값이 변경되므로 헤더 설정을 꼭 잘해주어야합니다.
헤더는 네트워크탭에서 확인할 수 있으며 그대로 진행하였습니다.. 단, 중간에 PdboxSaveTicket의 경우 로그인 혹은 일정 시간 이후 변경되는 것으로 확인하였습니다. 따라서 테스트 하다가 채팅 내용을 불러 올 수 없을 경우에 헤더를 일단 변경해주면 채팅방 URL을 확인할 수 있습니다 (헤더 없어도 채팅방 url 받을 수 있습니다)
채팅 URL의 경우 두가지로 어떻게 분류하는진 확인할 수 없으나 url에서 채팅번호를 기준으로 방입장이 되는 것을 확인할 수 있었습니다. 테스트 크롤링으로 핸드쉐이크에 대한 유의미한 결과를 얻진 못했습니다.
💡 그러던중 PdTicket 값이 API에서 불러와질때 쿠키가 없으면 불러와지질 않는다는 것을 보고 비회원 즉 쿠키에 티켓값이 없는 상태로 진행하기로 하였습니다. 비회원일 경우에 첫번째 핸드쉐이크때 해당 티켓값 (로그인 정보)를 받지 않기 떄문에 티켓값이 갱신되거나 헤더가 변경되는 경우를 제외해도 된다는 이점이 있습니다.
importcertifiimportjsonimportsslimportbase64importasyncioimportrequestsimportwebsockets# WSS 연결 위한 SSL 설정ssl_context=ssl.create_default_context()ssl_context.load_verify_locations(certifi.where())ssl_context.check_hostname=Falsessl_context.verify_mode=ssl.CERT_NONEdefdecode(bytes):test=bytes.split(b'\x0c')res=[]foriintest:res.append(str(i,'utf-8'))if(res[1]!='-1'andres[1]!='1'and'|'notinres[1]):if(len(res)>5):print(res[1],'\t| ',res[2],'|',res[6])else:print(res)passdefplayer_live_api(bno,bid):# type=aid 일 경우 aid(.A32~~~)불러옴data={'bid':bid,'bno':bno,'type':'live','confirm_adult':'false','player_type':'html5','mode':'landing','from_api':'0','pwd':'','stream_type':'common','quality':'HD'}res=requests.post(f'https://live.afreecatv.com/afreeca/player_live_api.php?bjid={bid}',data=data).json()CHDOMAIN=res["CHANNEL"]["CHDOMAIN"].lower()CHATNO=res["CHANNEL"]["CHATNO"]FTK=res["CHANNEL"]["FTK"]TITLE=res["CHANNEL"]["TITLE"]BJID=res["CHANNEL"]["BJID"]CHPT=str(int(res["CHANNEL"]["CHPT"])+1)returnCHDOMAIN,CHATNO,FTK,TITLE,BJID,CHPTasyncdefconnect(url):BNO=str(url.split('/')[-1])BID=str(url.split('/')[-2])CHDOMAIN,CHATNO,FTK,TITLE,BJID,CHPT=player_live_api(BNO,BID)KEY=''if(len(BJID)==5):KEY='80'elif(len(BJID)==6):KEY='81'elif(len(BJID)==7):KEY='82'elif(len(BJID)==8):KEY='83'elif(len(BJID)==9):KEY='84'elif(len(BJID)==10):KEY='85'elif(len(BJID)==11):KEY='86'elif(len(BJID)==12):KEY='87'handshake=f' 00020002{KEY}00{CHATNO}{FTK}0log&set_bps=8000&view_bps=1000&quality=normal&uuid=1e43cf6d37913c36b35d580e0b5656ec&geo_cc=KR&geo_rc=11&acpt_lang=ko_KR&svc_lang=ko_KRpwdauth_infoNULLpver1access_systemhtml5'asyncwithwebsockets.connect(f"wss://{CHDOMAIN}:{CHPT}/Websocket/{BID}",subprotocols=['chat'],ssl=ssl_context,ping_interval=None)aswebsocket:# 핸드쉐이크# await websocket.send(secret_1)awaitwebsocket.send(' 00010000060016')data=awaitwebsocket.recv()awaitwebsocket.send(handshake)# 이후부터 채팅내용 받아와짐whileTrue:try:data=awaitwebsocket.recv()decode(data)exceptExceptionase:print("ERROR:",e)# breakurl=input("아프리카TV URL을 입력해주세요 : ")asyncio.get_event_loop().run_until_complete(connect(url))
아프리카 도우미를 분석해보면 소켓으로 통신하고 있는데 아프리카 도우미에 연결된 소켓 주소로 메세지 정보를 바로 받아올 수 있겠다고 생각되어 분석을 해보았으나 후원 관련된 내용들만 받아와졌습니다. 아프리카 도우미가 자체적으로 계속 멈추기 때문에 성능적으로 크게 효과를 보지 못할 것으로 생각됩니다.
# line 16DRIVER=webdriver.Chrome(service=Service(ChromeDriverManager().install()))
해당 프로그램은 pyinstaller로 exe파일로 만들어야 하기 떄문에 웹 드라이버를 자동으로 최신 버전으로 받아옵니다. 정적인 드라이버를 사용할 경우에 파일을 실행하는 컴퓨터의 드라이버와 맞지 않는 것을 방지할 수 있습니다.
1
2
3
4
5
6
7
8
# line 24defstop_catpure():globalCAPTURINGinput()CAPTURING=False...# line 37th.Thread(target=stop_catpure,args=(),name='stop_catpure',daemon=True).start()
라이브 방송이 끝나기 전에 추출을 종료하기 위해서 해당 함수를 쓰레드로 실행시킵니다. 키보드 인풋이 있는경우 추출을 자동으로 종료합니다.