Python 기반 웹 스크래핑: JS 역분석과 WOFF 폰트 파싱으로 영화 평점 데이터 수집하기

개요

특정 영화 정보 플랫폼의 평점 데이터는 다층적인 보안 메커니즘으로 보호되어 있다. 본 문서에서는 JavaScript 역분석을 통한 요청 파라미터 복원과 WOFF(Web Open Font Format) 폰트 치환 기법을 활용해 실제 수치를 추출하는 방법을 다룬다.

전체 흐름

데이터 수집은 다음 단계로 진행된다:

  • 동적 페이지 내 삽입된 API 엔드포인트 식별
  • JS 난독화 로직 분석 및 signKey 생성 규칙 역산
  • 폰트 파일 획득 및 글리프 매핑 테이블 구축
  • 유니코드 이스케이프 시퀀스를 실제 숫자로 치환

1단계: API 요청 파라미터 복원

브라우저 개발자 도구의 Call Stack을 추적하면 난독화된 JS 모듈 내에서 x 객체가 구성되는 지점을 확인할 수 있다. 해당 객체의 필드는 서버 측 검증을 위한 다양한 요소를 포함한다.

핵심은 getMD5Sign 함수로, 다음과 같은 입력을 받아 서명을 생성한다:

function generateSignature(cfg) {
    var httpMethod = cfg['method'];
    var chId = cfg['channelId'] !== undefined ? cfg['channelId'] : 40011;
    var apiVer = cfg['sVersion'];
    var reqType = cfg['type'] !== undefined ? cfg['type'] : "qs";
    
    var salt = Math['ceil'](10 * Math.random());
    var now = (new Date)['getTime']();
    var ua = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36';
    
    var payload = 'method=' + httpMethod + '&timeStamp=' + now + 
                  '&User-Agent=' + ua + '&index=' + salt + 
                  '&channelId=' + chId + '&sVersion=' + apiVer;
    var secret = '&key=A013F70DB97834C0A5492378BD76C53A';
    
    return {
        randomNum: salt,
        timeStamp: now,
        md5sign: hashEngine(payload + secret),
        channelId: chId,
        sVersion: apiVer,
        params: {webdriver: false}
    };
}

위 함수가 반환하는 md5sign 값이 최종 URL의 signKey 파라미터로 활용된다. 내부 해시 엔진은 표준 MD5와 동일한 라운드 함수를 사용하나, 변수명과 함수 배치가 변형되어 있다.

2단계: MD5 핵심 연산 구현

아래는 재구성된 해시 처리 코어다. 원본과 동일한 비트 연산 패턴을 유지하면서도 구조를 단순화했다.

var hashEngine = function (plainText) {
    if (plainText === undefined || plainText === null) {
        throw new Error('Input cannot be empty');
    }
    var byteArray = textToBytes(plainText);
    var state = bytesToState(byteArray);
    return stateToHex(state);
};

// MD5 보조 함수들
var roundF = function (a, b, c, d, x, s, t) {
    var n = a + (b & c | ~b & d) + (x >>> 0) + t;
    return (n << s | n >>> (32 - s)) + b;
};

var roundG = function (a, b, c, d, x, s, t) {
    var n = a + (b & d | c & ~d) + (x >>> 0) + t;
    return (n << s | n >>> (32 - s)) + b;
};

var roundH = function (a, b, c, d, x, s, t) {
    var n = a + (b ^ c ^ d) + (x >>> 0) + t;
    return (n << s | n >>> (32 - s)) + b;
};

var roundI = function (a, b, c, d, x, s, t) {
    var n = a + (c ^ (b | ~d)) + (x >>> 0) + t;
    return (n << s | n >>> (32 - s)) + b;
};

var textToBytes = function (str) {
    var out = [];
    for (var i = 0; i < str.length; i++) {
        out.push(str.charCodeAt(i) & 0xFF);
    }
    return out;
};

var bytesToState = function (msg) {
    // MD5 상태머신: 4개의 32비트 레지스터 순환
    var len = msg.length * 8;
    var padded = msg.slice();
    padded.push(0x80);
    while ((padded.length * 8) % 512 !== 448) {
        padded.push(0);
    }
    // 길이 추가 (64비트 little-endian)
    for (var i = 0; i < 8; i++) {
        padded.push((len >>> (i * 8)) & 0xFF);
    }
    
    var A = 0x67452301, B = 0xEFCDAB89, C = 0x98BADCFE, D = 0x10325476;
    var W = [];
    for (var j = 0; j < padded.length; j += 64) {
        for (var k = 0; k < 16; k++) {
            W[k] = padded[j + k * 4] | (padded[j + k * 4 + 1] << 8) |
                   (padded[j + k * 4 + 2] << 16) | (padded[j + k * 4 + 3] << 24);
        }
        var a = A, b = B, c = C, d = D;
        
        // 64 라운드 연산 (간략화)
        for (var step = 0; step < 64; step++) {
            var f, g;
            if (step < 16) {
                f = roundF(a, b, c, d, W[step % 16], [7,12,17,22][step%4], 
                          [-680876936,-389564586,606105819,-1044525330][step%4]);
            } else if (step < 32) {
                f = roundG(a, b, c, d, W[(5*step+1)%16], [5,9,14,20][step%4], 0);
            } else if (step < 48) {
                f = roundH(a, b, c, d, W[(3*step+5)%16], [4,11,16,23][step%4], 0);
            } else {
                f = roundI(a, b, c, d, W[(7*step)%16], [6,10,15,21][step%4], 0);
            }
            a = d; d = c; c = b; b = f;
        }
        A = (A + a) >>> 0; B = (B + b) >>> 0;
        C = (C + c) >>> 0; D = (D + d) >>> 0;
    }
    return [A, B, C, D];
};

var stateToHex = function (state) {
    var hex = '';
    for (var i = 0; i < 4; i++) {
        for (var j = 0; j < 4; j++) {
            var byteVal = (state[i] >>> (j * 8)) & 0xFF;
            hex += (byteVal < 16 ? '0' : '') + byteVal.toString(16);
        }
    }
    return hex;
};

3단계: 폰트 파일 분석 및 글리프 매핑

서버 응답에서 WOFF 파일 URL을 추출한 후, 해당 파일을 온라인 폰트 뷰어에 업로드하면 글리드 비주얼 맵을 확인할 수 있다. 각 글리프는 특정 유니코드 포인트와 대응하는 실제 숫자를 가진다.

OCR 기반 자동 매핑 파이프라인:

from PIL import Image
import pytesseract
import cv2
import time

def build_glyph_table(screenshot_path, glyph_width=125):
    """
    폰트 뷰어 스크린샷에서 개별 글리프를 분리하여
    {실제숫자: 유니코드이스케이프} 매핑 생성
    """
    raw_img = cv2.imread(screenshot_path)
    h, w = raw_img.shape[:2]
    
    glyph_count = w // glyph_width
    mapping = {}
    
    for idx in range(glyph_count):
        x_start = idx * glyph_width
        x_end = x_start + glyph_width
        
        # 상부: 숫자 이미지
        number_region = raw_img[6:100, x_start+15:x_start+120]
        # 하부: 유니코드 식별자
        code_region = raw_img[105:148, x_start+15:x_start+111]
        
        cv2.imwrite('tmp_num.png', number_region)
        cv2.imwrite('tmp_code.png', code_region)
        time.sleep(0.5)
        
        digit = pytesseract.image_to_string(
            Image.open('tmp_num.png'),
            config='--psm 10 --oem 3 -c tessedit_char_whitelist=0123456789'
        ).strip()
        
        raw_unicode = pytesseract.image_to_string(
            Image.open('tmp_code.png'),
            config='--psm 6'
        ).strip()
        
        # "uniE83A" → "\ue83a" 변환
        if digit and raw_unicode.startswith('uni'):
            uni_esc = '\\u' + raw_unicode[3:].lower()
            mapping[digit] = uni_esc
            
    return mapping

4단계: 통합 수집 스크립트

import requests
import re
import execjs
import json
from lxml import etree
from PIL import Image, ImageGrab
import pytesseract
import cv2
import time

TARGET_DOMAIN = 'https://www.maoyan.com'
GLYPH_WIDTH = 125

class MovieRatingExtractor:
    def __init__(self, js_path='./common.js'):
        with open(js_path, 'r', encoding='utf-8') as f:
            self.js_ctx = execjs.compile(f.read())
        self.session = requests.Session()
        self.session.headers.update({
            'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
        })
        self.glyph_map = {}
    
    def _make_signature(self, method='GET', channel=40011, version=1):
        config = {
            'method': method,
            'channelId': channel,
            'sVersion': version,
            'type': 'object'
        }
        return self.js_ctx.call('getMD5Sign', config)
    
    def _fetch_page(self, movie_id):
        endpoint = f'{TARGET_DOMAIN}/ajax/films/{movie_id}'
        sig = self._make_signature()
        
        query = {
            'index': sig['randomNum'],
            'timeStamp': sig['timeStamp'],
            'signKey': sig['md5sign'],
            'channelId': sig['channelId'],
            'sVersion': sig['sVersion'],
            'webdriver': 'false'
        }
        
        resp = self.session.get(endpoint, params=query, timeout=15)
        return resp.text if resp.status_code == 200 else None
    
    def _extract_woff_url(self, html):
        pattern = r"url\('(//[^']+\.woff)'\)\s+format\('woff'\)"
        match = re.search(pattern, html)
        return 'https:' + match.group(1) if match else None
    
    def _build_mapping_from_font(self, woff_url):
        # Selenium으로 폰트 뷰어 자동화
        from selenium import webdriver
        from selenium.webdriver.common.by import By
        from selenium.webdriver.support.ui import WebDriverWait
        from selenium.webdriver.support import expected_conditions as EC
        
        driver = webdriver.Chrome()
        try:
            driver.get('https://font.qqe2.com/')
            wait = WebDriverWait(driver, 15)
            
            # URL 입력 방식으로 폰트 로드
            dropdown = wait.until(EC.element_to_be_clickable(
                (By.CSS_SELECTOR, '.dropdown-toggle')))
            dropdown.click()
            
            driver.find_element(By.CSS_SELECTOR, 
                "[data-action='add-url']").click()
            
            inputs = driver.find_elements(By.CSS_SELECTOR, '.form-control')
            inputs[1].send_keys(woff_url)
            
            driver.find_element(By.CSS_SELECTOR, '.btn-confirm').click()
            time.sleep(5)
            
            # 뷰포트 캡처
            screenshot = ImageGrab.grab(bbox=(236, 278, 1738, 442))
            screenshot.save('font_atlas.png')
            
        finally:
            driver.quit()
        
        # OCR 처리
        self.glyph_map = self._ocr_atlas('font_atlas.png')
        return self.glyph_map
    
    def _ocr_atlas(self, image_path):
        img = cv2.imread(image_path)
        total_width = img.shape[1]
        count = total_width // GLYPH_WIDTH
        
        result = {}
        for pos in range(count):
            x1, x2 = pos * GLYPH_WIDTH, (pos + 1) * GLYPH_WIDTH
            
            num_slice = img[6:100, x1+15:x2-5]
            code_slice = img[105:148, x1+15:x2-14]
            
            cv2.imwrite('_num.png', num_slice)
            cv2.imwrite('_code.png', code_slice)
            
            num_val = pytesseract.image_to_string(
                Image.open('_num.png'),
                config='--psm 10 -c tessedit_char_whitelist=0123456789'
            ).strip()
            
            code_raw = pytesseract.image_to_string(
                Image.open('_code.png'),
                config='--psm 6'
            ).strip()
            
            if num_val and code_raw.startswith('uni'):
                uni = '\\u' + code_raw[3:].lower().replace('.', '')
                result[num_val] = uni
                
        return result
    
    def _decode_ratings(self, encoded_texts):
        decoded = []
        for text in encoded_texts:
            # \uXXXX 패턴 추출
            chars = text.encode('unicode_escape').decode('utf-8')
            buffer = []
            i = 0
            while i < len(chars):
                if chars[i:i+2] == '\\u':
                    esc = chars[i:i+6]
                    # 매핑 역조회
                    for digit, pattern in self.glyph_map.items():
                        if pattern == esc:
                            buffer.append(digit)
                            break
                    i += 6
                else:
                    buffer.append(chars[i])
                    i += 1
            decoded.append(''.join(buffer))
        return decoded
    
    def extract(self, movie_url):
        movie_id = re.search(r'/films/(\d+)', movie_url).group(1)
        
        html = self._fetch_page(movie_id)
        if not html:
            return None
        
        # WOFF 파일 획득 및 매핑 구축
        woff = self._extract_woff_url(html)
        if woff:
            self._build_mapping_from_font(woff)
        
        # 평점 노드 추출
        tree = etree.HTML(html)
        rating_nodes = tree.xpath(
            "//div[contains(@class,'movie-stats-container')]//"
            "span[@class='stonefont']/text()"
        )
        
        # 치환 및 반환
        return self._decode_ratings(rating_nodes)


# 실행
if __name__ == '__main__':
    extractor = MovieRatingExtractor()
    ratings = extractor.extract('https://www.maoyan.com/films/1200486')
    print(ratings)

주의사항 및 한계

상기 OCR 방식은 폰트 렌더링 품질에 민감하다. 실무에서는 다음 대안을 고려할 수 있다:

  • 직접 WOFF 파싱: fontTools 라이브러리로 글리輪輪 테이블을 직접 읽어 OCR 없이 매핑
  • 템플릿 매칭: 미리 구축된 글리프 이미셋과 픽셀 단위 비교
  • API 엔드포인트 변경 대응: JS 번들 업데이트 시 signKey 생성 로직 재분석 필요

태그: python JavaScript Reverse Engineering WOFF Font Parsing Web Scraping OCR

7월 14일 04:03에 게시됨