레진코믹스 분석 및 스크래퍼 제작 후기

ilotoki·2023년 7월 29일
0

WebtoonScraper 바로가기
LezhinComicsScraper(레진코믹스 스크래퍼) 소스 코드 바로 가기

API 분석

네이버 웹툰, 베스트 도전만화, 웹툰 오리지널, 웹툰 캔버스, 만화경, 버프툰, 네이버 포스트, 네이버 게임 등 다양한 사이트의 웹툰 스크래퍼를 만들어 왔지만 레진코믹스는 지금까지의 웹툰 플랫폼 중에 단연코 가장 어려웠다고 말할 수 있다.

지금까지 WebtoonScraper를 만드는 동안 때 자바스크립트를 해석할 일은 없었다. 대부분은 웹사이트에 정적으로 포함된 데이터를 BeautifulSoup를 통해 해석하거나 API 데이터를 해석하면 그만이였다.

하지만 레진코믹스의 경우에는 달랐다. HTML파일 안에 <script>태그가 있고 해당 태그 안에 __LZ_PRODUCT__라는 변수가 있고 그 안에 웹툰에 대한 정보가 있었다. 이는 자바스크립트였기 때문에 이를 파싱할 수 있는 것이 필요했다. 구글링 결과 pyjsparser라는 라이브러리를 찾을 수 있었고 이를 사용해서 내용을 분석했다.

pyjsperser는 분석하기가 조금 까다로웠다. 예를 들어 다음은 에피소드 정보를 불러오기 위해 작성해야 했던 코드이다:

parsed["body"][1]["expression"]["right"]["properties"][1]["value"]["properties"][-1]["value"]["elements"]

보다시피 길기도 하고 ["value"]["properties"]를 반복하여 사용해야 해서 불편했다. 분석은 평소처럼 진행했다.

레진은 또 특이한 부분이 있는데, 바로 titleidepisode_id모두 '문자열' 버전과 '숫자'버전이 따로 있다는 점이다. 예를 들어 어떤 웹툰은 'dr_hearthstone'이라는 titleid를 가진다. 하지만 내부에서는 5978137257574400이라는 숫자 titleid도 가지고 있다. 에피소드의 경우도 마찬가지로, 어떤 에피소드는 대외적으론 '39'라는 episode_id를 가지고 있지만, 내부적으론 4857415232651264라는 상당히 긴 episode_id 또한 가지고 있다. 상당히 골때리는 점이 아닐 수 없다. 나는 이 문제를 문자열 ID를 공식으로 정하고 숫자 ID는 episode_id_intstitleid_int같이 타입을 붙여 해결했다.

또 재미있었던 점은 레진코믹스는 사용자를 인식하는 데에 Bearer라는 것을 이용했다는 점이다. 그런데 토큰이 유효기간이 상당히 긴 모양인지 비슷하게 로그인이 필요했던 버프툰의 경우보다 훨씬 긴 기간동안 (적어도 제작 기간이였던 5일 동안) 토큰이 유효했다.

보안에도 여러모로 신경을 쓰는 느낌이었다. 이미지를 다운로드 받으려면 서버에 요청에 policy, signature, key-pair-id 등을 받아 이미지 요청에 포함하여 보내야 했고, 이가 일치하지 않으면 403 에러를 내었다.

언셔플링

하지만 그 무엇보다도 레진코믹스의 스크래핑을 특별하게 만든 것을 셔플링이었다. 다른 웹툰 플랫폼은 모두 원본 이미지 파일을 받는 방식으로 진행했지만 레진코믹스의 경우에는 섞은 이미지를 보낸 뒤 클라이언트에서 섞은 것을 풀도록 하고 있었다. 이 방식은 확실히 스크래핑을 방지하고 보안을 유지하는 데 효과적인 방식일 것이다. 필자의 경우에도 스크래퍼를 만드는 기간의 대부분은 언셔플링을 분석하는 데에 보냈을 정도로 상당히 까다로운 부분이었다.

특이했던 부분은 셔플링이라는 훌륭한 보안 기술을 가졌음에도 모든 웹툰이 아닌 일부 웹툰만 셔플링을 활용했다는 점이다. 셔플링은 에피소드별로 설정할 수 있는 게 아닌 웹툰 단위로만 설정할 수 있는 것으로 보이기에 유료 회차를 보호하기 위한 조치는 아닌 것 같아 보였고, 최근에 연재를 시작한 웹툰도 셔플링이 되지 않은 것이 있었기에 최근에 시작한 기술이라고 추측하는 것도 무리였다. 아마 사용자 경험 상의 이유가 아닐까 하는 생각도 든다. 어쨌건 다른 웹툰 플랫폼에서 볼 수 없었던 재미있는 기능이었다.

레진코믹스의 언셔플링은 다음과 같이 작동한다.

  1. 서버에서 이미지를 가져온다.

  2. 해당 웹툰이 셔플링을 사용하는지 아닌지 확인한다. 만약 아니라면 이미지를 그대로 사용하고 아니라면 언셔플링을 진행한다.

  3. episode_id로 랜덤하게 0부터 24까지의 숫자가 들어 있는 배열을 만든다.
    이 배열을 만드는 코드는 다음과 같다.

    def get_random_numbers_of_certain_seed(seed):
        """
        Mutating Lezhin's random number generator.
        `random_numbers` are always same if given seed is same.
        """
        results = []
        state = seed
        for _ in range(25):
            state ^= state >> 12
            state ^= (state << 25) & 18446744073709551615
            state ^= state >> 27
            result = (state >> 32) % 25
            results.append(result)
        return results
    
    def get_image_order_from_random_number(random_numbers):
        image_order = list(range(25))
        for i in range(25):
            shuffle_index = random_numbers[i]
            image_order[i], image_order[shuffle_index] = image_order[shuffle_index], image_order[i]
        return image_order
    
    random_numbers = get_random_numbers_of_certain_seed(episode_id_int)
    image_order = get_image_order_from_random_number(random_numbers)

    소스 보기(추후 코드 변경으로 정확한 위치가 변경될 수 있음)

  4. 서버에서 가져온 이미지를 세로 픽셀 수가 5의 배수가 되도록 버림한 뒤 25등분한다.
    이때 이미지의 번호는 왼쪽 위부터 차례로 매긴다. 즉 다음과 같다.

    01234
    56789
    1011121314
    1516171819
    2021222324
  5. 서버에서 가져온 이미지와 동일한 사이즈*의 캔버스를 만든다
    * 이때 서버에서 가져온 이미지의 세로 픽셀 수가 5의 배수가 아니라면 5의 배수가 되도록 버림한다. (가로 픽셀 수는 항상 5의 배수이기에 상관이 없다.)

  6. 3. 과정에서 만들었던 배열(image_order)의 숫자대로 캔버스에 그린다. 만약 3. 과정에서 만들었던 배열이 [5, 6, 23, 15, 18, 3, 21 ... ]이라면 0번 위치에 5번 이미지를 그리고, 1번 위치에 6번 그림을 그리는 식이다. 즉 다음과 같다.

    56231518
    321.........
    ...............
    ...............
    ...............
  7. 한 이미지의 언셔플링을 완료했다! 한 회차는 여러 이미지로 구성되어 있다. 같은 에피소드는 모두 같은 image_order를 사용하기 때문에 image_order로 에피소드의 이미지를 정렬하면 된다.

이게 레진코믹스 언셔플링의 작동 방식이다. 언셔플링은 까다로웠지만 분석하는 맛이 있었다.

여담으로 언셔플링을 분석하면서 확인할 수 있었던 것은 레진코믹스에서 어떤 규칙에 따라 이미지에 '흔적(trace)'를 남긴다는 것을 확인할 수 있었다. 즉 레진코믹스의 웹툰을 무단으로 캡처한다면 일정한 확률로 레진에서 알아차릴 수 있다는 의미가 된다. 물론 본 스크래퍼와는 무관한 이야기이다.

여기까지가 내 분석의 결과이다. 분석에 사용한 기술과 나의 생각에 대해서는 2부에서 계속하겠다.

2부 바로가기

0개의 댓글