코드는 Github Repo에 공개되어있습니다. 직접 받아서 실행하실 수 있습니다.


24년 3월 15일 업데이트

실행 결과

실행 결과

24년 3월 15일 버전
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
import certifi
import ssl
import asyncio
import websockets
import requests
from api import get_player_live

# 유니코드 및 기타 상수
F = "\x0c"
ESC = "\x1b\t"
SEPARATOR = "+" + "-" * 70 + "+"

# 아프리카TV에서 제공하는 API로 채팅 정보를 받습니다.
def get_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)

        return CHDOMAIN, CHATNO, FTK, TITLE, BJID, CHPT

    except requests.RequestException as e:
        print(f"  ERROR: API 요청 중 오류 발생: {e}")
        return None
    except KeyError as e:
        print(f"  ERROR: 응답에서 필요한 데이터를 찾을 수 없습니다: {e}")
        return None

# SSL 컨텍스트 생성
def create_ssl_context():
    ssl_context = ssl.create_default_context()
    ssl_context.load_verify_locations(certifi.where())
    ssl_context.check_hostname = False
    ssl_context.verify_mode = ssl.CERT_NONE
    return ssl_context

# 메시지 디코드 및 출력
def decode_message(bytes):
    parts = bytes.split(b'\x0c')
    messages = [part.decode('utf-8') for part in parts]
    if len(messages) > 5 and messages[1] not in ['-1', '1'] and '|' not in messages[1]:
        user_id, comment, user_nickname = messages[2], messages[1], messages[6]
        print(SEPARATOR)
        print(f"| {user_nickname}[{user_id}] - {comment}")
    else:
        # 채팅 뿐만 아니라 다른 메세지도 동시에 내려옵니다.
        pass

# 바이트 크기 계산
def calculate_byte_size(string):
    return len(string.encode('utf-8')) + 6

# 채팅에 연결
async def connect_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}")
    except Exception as e:
        print(f"  ERROR: API 호출 실패 - {e}")
        return

    try:
        async with websockets.connect(
            f"wss://{CHDOMAIN}:{CHPT}/Websocket/{BID}",
            subprotocols=['chat'],
            ssl=ssl_context,
            ping_interval=None
        ) as websocket:
            # 최초 연결시 전달하는 패킷
            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}'

            await websocket.send(CONNECT_PACKET)
            print(f"  연결 성공, 채팅방 정보 수신 대기중...")
            await asyncio.sleep(2)
            await websocket.send(JOIN_PACKET)

            async def ping():
                while True:
                    # 5분동안 핑이 보내지지 않으면 소켓은 끊어집니다.
                    await asyncio.sleep(60)  # 1분 = 60초
                    await websocket.send(PING_PACKET)

            async def receive_messages():
                while True:
                    data = await websocket.recv()
                    decode_message(data)

            await asyncio.gather(
                receive_messages(),
                ping(),
            )

    except Exception as e:
        print(f"  ERROR: 웹소켓 연결 오류 - {e}")

async def main():
    url = input("아프리카TV URL을 입력해주세요: ")
    ssl_context = create_ssl_context()
    await connect_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비트 맞추기

메세지를 받겠다는 send 역할을 하는 패킷을 보낼때는 패킷은 같습니다.

1
2
3
0002 > 메세지를 받겠다는 코드
000123 > 패킷(바디)의 데이터길이 123은 예시입니다.
00 > 12비트 맞추기

위와같은 규칙을 코드로 구현하면 다음과 같습니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# 유니코드
F = "\x0c"
ESC = "\x1b\t"

# 최초 연결시 전달하는 패킷
# 0001 - connect / 000006 - 바디 길이가 {F}{F}{F}16{F} 로 6 / 00 - 12비트 만들기 / {F*3}16{F} - 바디
CONNECT_PACKET = f'{ESC}000100000600{F*3}16{F}'

# 메세지를 내려받기 위해 보내는 패킷
# 0002 - JOIN / {calculate_byte_size(CHATNO):06} - CHATNO 길이 + {F} x 6 / 00 - 12비트 / {F}{CHATNO}{F*5} - 바디
# 즉 아프리카 API로 부터 받아온 채팅번호 + 6을 해준게 패킷 바디 사이즈가 됩니다.
JOIN_PACKET = f'{ESC}0002{calculate_byte_size(CHATNO):06}00{F}{CHATNO}{F*5}'

# 주기적으로 핑을 보내서 메세지를 계속 수신하는 패킷
# 0000 - PING / 000001 - 바디 길이는 {F} 로 1 / 00 - 12비트 만들기 / {F} - 바디
PING_PACKET = f'{ESC}000000000100{F}'

어떠한 패킷으로 메세지를 주고받는지 정확하게 알게되어 전체적인 코드를 개선시킬 수 있었습니다.

전체적인 진행과정은 아래 글을 확인해주시면 됩니다. 궁금하신 내용이 있으시면 언제든지 댓글을 남겨주시기 바랍니다. 😀




들어가며

방송프로그램으로 OBS를 사용하고 있는데, 최근 M1맥 업데이트로 동시송출 플러그인 사용이 가능해졌습니다. 유트브에서 유튜브 + 아프리카TV로 넘어가는 계획중인데 추첨방송을 계속 이어서 진행하기 위해 유튜브, 아프리카tv 두개 채팅을 실시간으로 크롤링하려 합니다.

아프리카TV의 경우 참고할만한 레퍼런스가 매우 매우 매우 없는 (누가하겠냐고..) 맨땅에 해딩이었습니다 🥲 분석해보았지만 글에 설명되어 있듯이 예외케이스가 존재하니 참고 부탁드립니다.


아프리카TV 채팅방 분석

탭이동 하면 소켓연결이 끊어지고 다시 돌아오면 새로 연결된다

탭이동 하면 소켓연결이 끊어지고 다시 돌아오면 새로 연결된다

크롬의 개발자도구 네트워크탭을 분석해보면 아프리카TV의 채팅방의 경우 대략적으로 다음과 같이 웹소켓 서버와 연결됨을 알 수 있습니다.

  1. 사람이 동영상을 보고있는지 계속 확인하는 웹소켓 1개

    일정시간마다 핸드쉐이크를 꼐속 진행한다

    일정시간마다 핸드쉐이크를 꼐속 진행한다

  2. 사람이 동영상을 보고있다고 판단되면 채팅방 연결하는 웹소켓 1개

    맨 처음에만 핸드쉐이크

    맨 처음에만 핸드쉐이크

사람이 동영상을 안보고있다고 판단되면 모든 소켓 연결을 끊어버립니다. 소리는 들려도 채팅은 보여지지 않습니다. 다시 동영상에 입장하면 당연히 처음부터 소켓연결을 재시작하기 떄문에 이전 채팅 내용들이 사라지게 됩니다 입장, 퇴장 여부도 전부 소켓에 찍힙니다.

우선 가장 중요한 부분은 실제 채팅이 오고 가는 것이 찍히는 위 2번 웹소켓입니다. 해당 웹소켓에 연결하기 위해서는 ⭐️다음 값들을⭐️ 알아야합니다.

  1. 웹소켓 주소

    채팅방 입장시 받아오는 API 혹은 1번 웹소켓에서 불러와질 수 있습니다.

  2. 핸드쉐이크때 필요한 값

    위의 웹소켓 주소를 받아올때 같이 넘겨받는 값이 특정 bytes array 구조로 보내져야합니다.

우선은 1번 2번은 재껴두고 소켓에 연결해서 채팅을 넘겨받을 수 있는지 확인해보겠습니다.


어떻게든 채팅을 불러올 수 있을까? (소켓 분석)

총 2번의 핸드쉐이크 혹은 정보를 전달하는 것을 알 수 있습니다. 각각의 핸드쉐이크때 어떠한 정보를 보내야 연결이 성공되어 나머지 정보도 전달받는지 분석이 필요합니다.

우선은 채팅방을 어떻게든 연결하여 정보를 출력하기 위해 받은 키값 그대로 연결해보겠습니다.

첫번째 핸드쉐이크

첫번째 핸드쉐이크의 값중 유의미한 값은 A32.로 시작합니다. 네트워크탭을 분석해본 결과 아프리카TV API로 요청을 보낼때 쿠키로 함께 보내지는 PdboxTicket 값임을 확인했습니다.


두번째 핸드쉐이크

두번째 핸드쉐이크는 _ 로 나누어지거나 &으로 값이 나누어 져있었습니다. &으로 나누어진 것을 보면 뭔가 파라미터 값을 전송하는 것으로 예측 할 수 있습니다. 전달되는 값들은 API의 리스폰스나 JS파일을 분석하면 나옵니다. 우선은 파이썬으로 핸드쉐이크떄 보내지는 값을 그대로 전송했을때 연결이 되는지 확인해봅니다


Test Code

해당값을 UTF-8즉 string 형태로 그대로 복사할 경우 깨져서 보여지지 않기 떄문에 base64 값으로 복사한다음에 코드에서 복호화 하는 형태로 진행합니다.

핸드쉐이크 테스트
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import certifi
import ssl
import base64
import asyncio
import websockets

# WSS 연결 위한 SSL 설정
ssl_context = ssl.create_default_context()
ssl_context.load_verify_locations(certifi.where())
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE

# 아래 3개 변수값은 어딘가의 API로 부터 받은 값들의 조합으로 우선은 그대로 사용
Base64PdboxTicket = 'GwkwMDAxMDAwNTg3MDAMLkEzMi43YmJUNTZ2eUhNOWZLWmsuQ1V0N0dxNXdzSW95ZGdUejRvT0JkUGJEVTRPTEJjbDNEUkFUenJUYkhrQzlYRmtiZnl1YXMwUzE3SFlyZDFjbFU1VzV4U3hpTUJYS2lmV25fVXFDQThWWUYxczVidE04QzJuenhWN3NEVzlTcHFPM2V4VHNHQUMxU3ZmNTlCNUE1bDVrUFJsZHpleHB4OGxKbkJFYXE5UXNLbW1KQ21TTExQRm1JNkc3Q0FHZ1R0UDFWcGhzaTYzTDZXUHRDdkRPalZDNTg1LXVJQV9hRGRxcTlWX1pOWUlBQ0N3d3Yxb0NDckRLeE9icldKS0xpQlNrbGs3bW1TMkkwWVdtNnhiMDB2TG53OGJuQXVyZzRyNkpHeWVma0ZDWmZaM0V6c29LTEFwRU9xeFlkX0JXMVNxRWVNM1FuNkoyc0E5Y0d5WGFZLWhySm5wblJGNmN4RzFPTWJBSi1KVGVXc2JTLTNaNUNULS1fUW1vTWJCb2stSmJwUmt1Y1oxMURJSkFpY25NZmwxaWtzU1Y4aHh6YUVqWVExb19pamI1OXVCWUNYMlNsMFdLSEwydjk4WkhSLXZGNjNRRDY5VEtqSEpjaHBIaDh0RFNzUUxJekc3WUFZbmpKYjl3cDlvV2JfVi0wSFgyRFRYVnVUSEtKRTFPMWNtbHE1bC1qaXZ6bW1HdnJ0WnVmQVVfRG1NQzA1bUpPelNxZTl0bzNKVFZmMjBJWldfWXFpW...=='
Base64ChannelInfo = 'GwkwMDAyMDAwMjk3MDAMOTYxOAw0NTY1MzFjMDg0YjUxMGJkOTAyYWViZDcyMjFiMjUyNl92bGZ2bGY3ODlfMjQ1NTkwMzY1X2lvcwwwDAxsb2cRBiYGc2V0X2JwcwY9BjgwMDAGJgZ2aWV3X2JwcwY9BjEwMDAGJgZxdWFsaXR5Bj0Gbm9ybWFsBiYGdXVpZAY9BjFlNDNjZjZkMzc5MTNjMzZiMzVkNTgwZTBiNTY1NmVjBiYGZ2VvX2NjBj0GS1IGJgZnZW9fcmMGPQYxMQYmBmFjcHRfbGFuZwY9BmtvX0tSBiYGc3ZjX2xhbmcGPQZrb19LUgYmBmpvaW5fY2MGPQY0MTAScHdkERJhdXRoX2luZm8RTlVMTBJwdmVyETESYWNjZXNzX3N5c3R...='
WSSUrl = 'wss://chat-76dbfccf.afreecatv.com:8001/Websocket/vlfvlf789'

async def connect():
    # 웹 소켓에 접속을 합니다.
    async with websockets.connect(WSSUrl, subprotocols=['chat'], ssl=ssl_context, ping_interval=None) as websocket:

        # 핸드쉐이크
        await websocket.send(base64.b64decode(Base64PdboxTicket))
        await websocket.recv()
        await websocket.send(base64.b64decode(Base64ChannelInfo))

        # 이후부터 채팅내용 받아와짐
        while True:
            try:
                data = await websocket.recv()
                print(data)
            except Exception as e:
                print("ERROR:", e)

asyncio.get_event_loop().run_until_complete(connect())

코드 실행시 데이터를 성공적으로 잘 받아오는 것을 확인 할 수 있었습니다.

단, 바이트 코드로 되어있으면서 hex 값이랑 평문이랑 섞여있기 때문에 해당 데이터를 복호화 해줍니다. \x0c 값이 계속 보이는 것으로 보아 해당 값으로 split 해주고 utf-8로 변환하였습니다.

1
2
3
4
5
6
def decode(bytes):
    test = bytes.split(b'\x0c')
    res = []
    for i in test:
        res.append(str(i, 'utf-8'))
    print(res)

해당 코드로 복호화 하였을 경우에 유의미한 값이 보여지는 것을 확인했습니다.

res[1] 값에 따라서 배열의 크기가 변경되는 것을 보아 해당 값을 기준으로 어떤 데이터인지 유추해볼 수 있습니다. 여러번 테스트해본 결과 다음과 같습니다.

  • res[1] = 1 일때 res[2]=id res[3]=닉네임가 채팅방에 입장 하였음
  • res[1] = -1 일때 res[2]=id res[3]=닉네임가 채팅방에 퇴장 하였음
  • res[1] = '문자열' 일때 res[1]=닉네임 res[2]=id res[6]=채팅내용 보여지는 채팅입니다.

다른 경우에는 매니저 이거나 일때 아이디가 색상으로 표시되는 경우 등이 있었지만 위 3가지만 으로도 충분히 목표를 달성하여 더이상 분석을 진행하진 않았습니다.

최종 복호화 코드로 채팅 내용만 불러오게 하였습니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
def decode(bytes):
    test = bytes.split(b'\x0c')
    res = []
    for i in test:
        res.append(str(i, 'utf-8'))
    if(res[1] != '-1' and res[1] != '1' and '|' not in res[1]):
        if(len(res) > 5):
            print(res[1], res[2], res[6])
        else:
            # print(res)
            pass

결과

채팅 받아오기 성공!

채팅 받아오기 성공!

🚨 5분이 지나면 ERROR: no close frame received or sent 메세지와 함께 소켓 연결이 끊어집니다.


[심화] URL만으로 채팅을 자동으로 불러올 수 있을까?

아프리카TV 채팅창 내용을 불러오는 것 까지는 성공했습니다 ! 다만 네트워크탭을 켜서 필요한 값들을 복사할 수는 없기에 해당 값들까지 분석하여 URL만 제공해도 모든것이 자동으로 돌아가져야 합니다. 위에서 연결하고 추출까지 성공했기 때문에 이제부터 필요한 값은 딱 3개 입니다.

  • 👉 채팅방 url
  • 👉 첫번째 핸드쉐이크
  • 👉 두번째 핸드쉐이크

채팅방 URL 찾기

채팅에 연결되면서 두번 핸드쉐이크할때 변화되는 값들을 확인해보고 해당 값들이 어떤 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
def player_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연결할 채팅 Url
    CHDOMAIN = 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"]
    return CHDOMAIN, CHATNO, FTK, TITLE, BJID, TK, CHPT

네트워크 탭을 보면 해당 API에 2번 POST요청을 보냅니다. 헤더값에 따라 받아오는 채팅 연결 도메인값이 변경되므로 헤더 설정을 꼭 잘해주어야합니다.

헤더 참고

헤더 참고

헤더는 네트워크탭에서 확인할 수 있으며 그대로 진행하였습니다.. 단, 중간에 PdboxSaveTicket의 경우 로그인 혹은 일정 시간 이후 변경되는 것으로 확인하였습니다. 따라서 테스트 하다가 채팅 내용을 불러 올 수 없을 경우에 헤더를 일단 변경해주면 채팅방 URL을 확인할 수 있습니다 (헤더 없어도 채팅방 url 받을 수 있습니다)


첫번째 핸드쉐이크 키값 찾기

시력이 약 0.1 정도 감소된듯 하다

시력이 약 0.1 정도 감소된듯 하다

핸드쉐이크 값을 비교해보니 변경되는부분, 고정인부분, API로 불러오는부분이 있었습니다.

1
2
secret_1 = f'	000100058200.{tk}16'
secret_2 = f'	000200030000{chat_no}{ftk}0log&set_bps=8000&view_bps=1000&quality=normal&uuid=1e43cf6d37913c36b35d580e0b5656ec&geo_cc=KR&geo_rc=11&acpt_lang=ko_KR&svc_lang=ko_KR&join_cc=410pwdauth_infoNULLpver1access_systemhtml5'
  • secret_1 의 경우에 000100058200 (5820 부분)

  • secret_2 의 경우에 000200030000 (3000 부분)

일단 방송중인 방을 셀레늄으로 받아와서 아까 찾은 API에 날려서 어떠한 공통점들이 있는지 확인해보려합니다.

테스트 코드

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.chrome.service import Service
from webdriver_manager.chrome import ChromeDriverManager
import requests

# DRIVER = webdriver.Chrome(service=Service(ChromeDriverManager().install()))
DRIVER = webdriver.Chrome('./chromedriver')
url = 'https://www.afreecatv.com/'
DRIVER.get(url)
DRIVER.implicitly_wait(3)

href = []
cBox_info = DRIVER.find_elements(By.CLASS_NAME, 'cBox-info')
for c in cBox_info:
    url = c.find_element(By.CLASS_NAME, 'title').get_attribute('href')
    print(url)
    href.append(url)

for h in href:
    bid = h.split('/')[-2]
    bno = h.split('/')[-1]
    res = requests.post(f'https://live.afreecatv.com/afreeca/player_live_api.php?bjid={bid}', data={'bid': bid, 'bno':bno, 'type':'live', 'confirm_adult':'false', 'player_type':'html5', 'mode':'landing', 'from_api':'0','pwd':'','stream_type':'common','quality':'HD'}, headers=headers).json()
    CHDOMAIN = res["CHANNEL"]["CHDOMAIN"].lower()
    CHATNO = res["CHANNEL"]["CHATNO"]
    FTK = res["CHANNEL"]["FTK"]
    TITLE = res["CHANNEL"]["TITLE"]
    BJID = res["CHANNEL"]["BJID"]
    TK = res["CHANNEL"]["BJID"]
    print(CHDOMAIN, CHATNO, FTK, TITLE, BJID)

테스트 결과

셀레늄… 다시 보니 선녀 같다

셀레늄… 다시 보니 선녀 같다

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
chat-dee93642.afreecatv.com 1832 b730cac99de4c43e485636cd95a95171_ahrasmall_245682917_html5 신입여캠 예쁜음방 찾는사람♥신청곡 브금 라이브 뽑기 스쿼트 ahrasmall
chat-6e0a4c42.afreecatv.com 401 ee461619bb3d078951729641e140f043_wkdtnvndeod2_245680218_html5 쉬고온 폼 어떨란감(맞방 맞즐 맞팬 미션 환영 시참 가능) wkdtnvndeod2
chat-dee93642.afreecatv.com 8068 db550a2aa702133b90053db49559fc32_0100151_245682923_html5 [신입]롤 3일차 칼바람 시참방송 ෆ.̮ෆ  0100151
chat-dee93642.afreecatv.com 8041 f771fc0888b0b5a27b6f40700c3b6d3a_truelight89_245682906_html5 매일 9am 아줌마 영이랑 벚꽃 구경 갈 사람? truelight89
chat-dee93642.afreecatv.com 8007 fabe810c6f46dcea461c4510eadf8dca_wkdrn0405_245677828_html5 실버 4의 랭겜에는 어떤 사람들이 있을까? wkdrn0405
chat-dee93642.afreecatv.com 1617 82b9432c8b887e2e53b60771e3fcf919_pmw1131_245683267_html5 신입남캠 58일차💋오늘은 레몬치즈의 날🧀 pmw1131
chat-6e0a4c42.afreecatv.com 5137 c4f178bf7263e2bd265bd76efd824556_louloumomo_245682407_html5 미국여자의 일본여행 비행기놓침 louloumomo
chat-dee93642.afreecatv.com 6986 2ecd8e2f6a77e283736425eac8eec0f9_namnice1_245682825_html5  업사부 투자수업!! 환상적 수익행진~!#주식#ELW#선물옵션#해외선물!!#주린이#업사부#황금알#김성남#불개미#코인! namnice1
chat-dee93642.afreecatv.com 1832 4026375bc10725e389daf8da51889779_ahrasmall_245682917_html5 신입여캠 예쁜음방 찾는사람♥신청곡 브금 라이브 뽑기 스쿼트 ahrasmall
chat-6e0a4c42.afreecatv.com 401 57d27877f877ef1e80f62e31fb814884_wkdtnvndeod2_245680218_html5 쉬고온 폼 어떨란감(맞방 맞즐 맞팬 미션 환영 시참 가능) wkdtnvndeod2
chat-dee93642.afreecatv.com 8068 c629e4348ac69be8f452569494d7a869_0100151_245682923_html5 [신입]롤 3일차 칼바람 시참방송 ෆ.̮ෆ  0100151
chat-dee93642.afreecatv.com 8041 904c6dc021fa1ae3fabff201d15101d2_truelight89_245682906_html5 매일 9am 아줌마 영이랑 벚꽃 구경 갈 사람? truelight89
chat-dee93642.afreecatv.com 8007 5b21a6ddb7a1729111762b5b39dcb56c_wkdrn0405_245677828_html5 실버 4의 랭겜에는 어떤 사람들이 있을까? wkdrn0405
chat-dee93642.afreecatv.com 1617 1931b0cc33b8ecf29a8569007f6f815b_pmw1131_245683267_html5 신입남캠 58일차💋오늘은 레몬치즈의 날🧀 pmw1131
chat-6e0a4c42.afreecatv.com 5137 4a71361598af0d481297fc7439aa7a76_louloumomo_245682407_html5 미국여자의 일본여행 비행기놓침 louloumomo
chat-dee93642.afreecatv.com 6986 f8967d0187c2169670e9da82005928d0_namnice1_245682825_html5  업사부 투자수업!! 환상적 수익행진~!#주식#ELW#선물옵션#해외선물!!#주린이#업사부#황금알#김성남#불개미#코인! namnice1
chat-dee93642.afreecatv.com 703 74a4437c1f784a3c32e1ce14c3fb80a0_lovely5959_245674550_html5 수피 수힛 스맵 민교 vs 나닝 사장 밧드 교용    킬내기 사비빵 lovely5959
chat-6e0a4c42.afreecatv.com 9996 6aef02482a9b6446d4736b8ab4416d5e_townboy_245672930_html5 스맵임니다 배그의신 ^^ townboy
chat-6e0a4c42.afreecatv.com 3761 a5be977fac8f11d27de3f7c62b8261ff_drumkyn_245683107_html5 주식왕용느★정신차리고 왔습니다  drumkyn
chat-dee93642.afreecatv.com 4320 bd63b62701fdc62dd804baae84987e4c_wnstn0905_245674053_html5 박사장 킬내기 ^^ wnstn0905
chat-6e0a4c42.afreecatv.com 59 070c9c5fdcc4fa5c73ba74ed03534fa5_since821_245144841_html5 [생]음악방송 멜론 인기가요 슬픈발라드 24시간 듣기 좋은 노래 명곡 100 히트곡 차트음방최신가요팝송힙합댄스곡뮤직라디오퀵뷰신입BJ since821
chat-dee93642.afreecatv.com 7213 7eca7d68afba49201955e48b2114c1e6_na2un_245679460_html5 사비빵 ^ㅛ^ na2un
chat-6e0a4c42.afreecatv.com 1979 30c8f01aed8e85431af29005ebdba03e_giltae1124_245682339_html5 마스터 승급전 1승1패 giltae1124
chat-6e0a4c42.afreecatv.com 5065 d70b5ea61501657e88a5000944275e2b_wwe1_245682996_html5 [생방송] WWE RAW LIVE 1557회 wwe1
chat-dee93642.afreecatv.com 1595 b9fdf6637769710dcc39155c194583b0_dldmssk_245683163_html5 늦었습니다 dldmssk
chat-6e0a4c42.afreecatv.com 6667 55f36f872db59d3c3db4f2e992987db0_tsoul7_245664669_html5 대한민국 어죽1등 대흥식당 tsoul7
chat-dee93642.afreecatv.com 8486 a7a7b4c0417d781f8c0a9771479b7b29_suhee0051_245677530_html5 수힛 수피 스맵 민교 vs  나닝 사장 밧드 교용   킬내기 야미 suhee0051
chat-dee93642.afreecatv.com 4542 16a1bc62a4d357a422e9aa2f63f4fac0_wnddnjs3124_245682295_html5 오늘 태국 출국합니다 저는 비행기값 게이지 안차면 안갑니다 ......부천 정중만 설영욱 와이퍼 이수혁 나루토 품바 박공 하르 wnddnjs3124
chat-6e0a4c42.afreecatv.com 1779 3dd7b7d32ebe399045c639d71874d558_sunwo2534_245683271_html5 JUP 뉴스 분량전쟁 특별게스트 함께 X야옹민지 sunwo2534
chat-6e0a4c42.afreecatv.com 2010 58c69912e19990fa9cc3fcf7bd3d5dd3_gjgj3274_245683016_html5 스타 소룡이 래더 강의방송 욕하러오지마세요. gjgj3274
chat-6e0a4c42.afreecatv.com 9853 acf465cffcb8a267607f0fcf47507432_todakman1151_245682511_html5 드릴말씀이 있습니다 (어그로임) 범프리카 todakman1151
chat-6e0a4c42.afreecatv.com 7662 ccd30ea7baa9e681d74244b058b72810_dlrnf_245683294_html5 긴급회의 시작 합니다_세쌍컴퍼니 x 세찬 dlrnf
chat-dee93642.afreecatv.com 7871 872ef2c55774dfcac9278408bdb2881d_o31511_245682721_html5  [무파] 다이아 77개 캐기 [로나월드:서수길형님] o31511
...

채팅 URL의 경우 두가지로 어떻게 분류하는진 확인할 수 없으나 url에서 채팅번호를 기준으로 방입장이 되는 것을 확인할 수 있었습니다. 테스트 크롤링으로 핸드쉐이크에 대한 유의미한 결과를 얻진 못했습니다.

💡 그러던중 PdTicket 값이 API에서 불러와질때 쿠키가 없으면 불러와지질 않는다는 것을 보고 비회원 즉 쿠키에 티켓값이 없는 상태로 진행하기로 하였습니다. 비회원일 경우에 첫번째 핸드쉐이크때 해당 티켓값 (로그인 정보)를 받지 않기 떄문에 티켓값이 갱신되거나 헤더가 변경되는 경우를 제외해도 된다는 이점이 있습니다.

  • 비회원 테스트 결과

  • 첫번째 키 값 secret_1

    전: 0001000XX200.A32.XXX..

    후: 000100000600.16 (고정)

  • 두번째 키 값 secret_2

    전: 00020003XX0 (로그인 쿠키값이 있을 경우에)

    후: 00020002XX0 (비회원)

첫번째 핸드쉐이크는 고정이므로 필요한 3개 값 중 2개는 해결 되었습니다.


두번째 핸드쉐이크 키값 찾기

이제 남은건 두번째 핸드쉐이크 인데 위 처럼 2자리 숫자가 감이 오질 않아서 무작정 정리해보기 시작했습니다. 네트워크탭 두번째 핸드쉐이크 키값, bj닉네임, 닉네임 길이를 무작정 적어보기 시작했습니다.

정리해보니 닉네임 길이에 따른 규칙을 찾을 수 있었습니다.

여기서 이상한건 lovely5959 만 뭔가 -1 오차가 있었습니다. 찜찜하기는 해도 랜덤테스트해도 거의 다 맞긴 해도 100% 정확하지 않습니다

코드

아프리카 TV 실시간 채팅 긁어오기.최종.최종,최종.최종의최종...
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
import certifi
import json
import ssl
import base64
import asyncio
import requests
import websockets

# WSS 연결 위한 SSL 설정
ssl_context = ssl.create_default_context()
ssl_context.load_verify_locations(certifi.where())
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE

def decode(bytes):
    test = bytes.split(b'\x0c')
    res = []
    for i in test:
        res.append(str(i, 'utf-8'))
    if(res[1] != '-1' and res[1] != '1' and '|' not in res[1]):
        if(len(res) > 5):
            print(res[1], '\t| ', res[2], '|', res[6])
        else:
            print(res)
            pass

def player_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)
    return CHDOMAIN, CHATNO, FTK, TITLE, BJID, CHPT

async def connect(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}0log&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'

    async with websockets.connect(f"wss://{CHDOMAIN}:{CHPT}/Websocket/{BID}", subprotocols=['chat'],ssl=ssl_context, ping_interval=None) as websocket:
        # 핸드쉐이크
        # await websocket.send(secret_1)
        await websocket.send('	00010000060016')
        data = await websocket.recv()
        await websocket.send(handshake)
        # 이후부터 채팅내용 받아와짐
        while True:
            try:
                data = await websocket.recv()
                decode(data)
            except Exception as e:
                print("ERROR:", e)
                # break

url = input("아프리카TV URL을 입력해주세요 : ")
asyncio.get_event_loop().run_until_complete(connect(url))

결과


마무리

채팅방과 소켓으로 연결되기 때문에 성능적으로 매우 편~~안 했습니다.

소켓 연결은 5분마다 끊어지기 때문에 while문 돌면서 지속해서 새로 handshake 부터 다시 시작하면 됩니다.

게시글 이동되어 archive합니다

게시글 이동되어 archive합니다



번외판) 아프리카도우미 크롤링하기

들어가며

아프리카도우미 서비스를 이용하면 유튜브 댓글과 아프리카 댓글을 함께 긁을 수 있습니다.

아프리카 도우미는 채팅이 중간 중간 멈추는 현상이 있습니다.

셀레늄 VS 소켓

아프리카 도우미를 분석해보면 소켓으로 통신하고 있는데 아프리카 도우미에 연결된 소켓 주소로 메세지 정보를 바로 받아올 수 있겠다고 생각되어 분석을 해보았으나 후원 관련된 내용들만 받아와졌습니다. 아프리카 도우미가 자체적으로 계속 멈추기 때문에 성능적으로 크게 효과를 보지 못할 것으로 생각됩니다.

nomomo 님께서 도움 주셨습니다. Twip-Toonation-Afreehp-Parser-Example issues #4 감사합니다 🙏

코드

main.py
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
import time
import re
import threading as th
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.chrome.service import Service
from webdriver_manager.chrome import ChromeDriverManager

# 상수, 아프리카도우미 채팅 URL, 컴퓨터 성능에 따라 STRESS 조절
URL = input('아프리카 도우미 채팅 url')
STRESS = 3
DEBUG = False
CAPTURING = True

# SELENIUM 초기화
DRIVER = webdriver.Chrome(service=Service(ChromeDriverManager().install()))

# global 변수
USERS = []      # 결과값
CONTENTS = []   # DEBUG일때 채팅 내용 터미널에 표시
CNT = 0


def stop_catpure():
    global CAPTURING
    input()
    CAPTURING = False

def start_capture():
    global USERS
    global CONTENTS
    global CNT

    DRIVER.get(URL)
    time.sleep(STRESS)

    th.Thread(target=stop_catpure, args=(), name='stop_catpure', daemon=True).start()
    while CAPTURING:
        try:
            DRIVER.implicitly_wait(STRESS)
            chat_list = DRIVER.find_element(By.CLASS_NAME, 'chat_list')
            chats = chat_list.find_elements(By.TAG_NAME, 'li')
            # print("chat len > ", len(chats))
            for chat in chats:
                name = chat.get_attribute('data-name')
                id = chat.get_attribute('data-id')
                classname = chat.get_attribute('class')
                platform = 'afreeca' if 'afreeca' in classname else 'youtube'
                user =  f'{name} | {platform}' if platform == 'afreeca' else f'{name} | @{id} | {platform}'
                secrets = str(re.sub(r'[^0-9]', '', classname)) + user
                if (user not in USERS) and (len(name) * len(id) != 0):
                    USERS.append(user)
                    content = chat.find_element(By.CLASS_NAME, 'text').text
                if secrets not in CONTENTS:
                    CONTENTS.append(secrets)
                    if DEBUG:
                        print(f'{platform} - {content} - {name} - {secrets}')
                    else:
                        print(f'------- \t 아이디 : {user}\n\t\t 내용 : {content}')
                    CNT += 1
            time.sleep(STRESS / 3)
        except KeyboardInterrupt:
            break
        except Exception as e:
            print("!", e)

def quit_capture():
    DRIVER.close()

def print_results():
    global USERS
    global CONTENTS
    users = (list(set(USERS)))
    print(users)


start_capture()
quit_capture()
print_results()

코드설명

셀레늄을 이용하여 웹 드라이버가 해당 소스를 렌더링 한 이후에 긁어옵니다.

1
2
# line 16
DRIVER = webdriver.Chrome(service=Service(ChromeDriverManager().install()))

해당 프로그램은 pyinstallerexe파일로 만들어야 하기 떄문에 웹 드라이버를 자동으로 최신 버전으로 받아옵니다. 정적인 드라이버를 사용할 경우에 파일을 실행하는 컴퓨터의 드라이버와 맞지 않는 것을 방지할 수 있습니다.

1
2
3
4
5
6
7
8
# line 24
def stop_catpure():
    global CAPTURING
    input()
    CAPTURING = False
...
# line 37
th.Thread(target=stop_catpure, args=(), name='stop_catpure', daemon=True).start()

라이브 방송이 끝나기 전에 추출을 종료하기 위해서 해당 함수를 쓰레드로 실행시킵니다. 키보드 인풋이 있는경우 추출을 자동으로 종료합니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# line 38
while CAPTURING:
    try:
        DRIVER.implicitly_wait(STRESS)
        chat_list = DRIVER.find_element(By.CLASS_NAME, 'chat_list')
        chats = chat_list.find_elements(By.TAG_NAME, 'li')
        # print("chat len > ", len(chats))
        for chat in chats:
            name = chat.get_attribute('data-name')
            id = chat.get_attribute('data-id')
            classname = chat.get_attribute('class')
            platform = 'afreeca' if 'afreeca' in classname else 'youtube'
            user =  f'{name} | {platform}' if platform == 'afreeca' else f'{name} | @{id} | {platform}'
            secrets = str(re.sub(r'[^0-9]', '', classname)) + user
            if (user not in USERS) and (len(name) * len(id) != 0):
                USERS.append(user)
                content = chat.find_element(By.CLASS_NAME, 'text').text
            if secrets not in CONTENTS:
                CONTENTS.append(secrets)
                if DEBUG:
                    print(f'{platform} - {content} - {name} - {secrets}')
                else:
                    print(f'------- \t 아이디 : {user}\n\t\t 내용 : {content}')
                CNT += 1
        time.sleep(STRESS / 3)
    except KeyboardInterrupt:
        break
    except Exception as e:
        print("!", e)

셀레늄을 이용해서 아프리카도우미 페이지를 긁어옵니다.

결과


마치며

레퍼런스가 없어서 진행하기 어려운 부분이 있었는데 해결되어서 성취감을 많이 느꼈습니다. 궁금하신 점이 있으면 댓글로 남겨주시기 바라며 해당 글이 도움이 되셨길 바랍니다 :)