개요
특정 영화 정보 플랫폼의 평점 데이터는 다층적인 보안 메커니즘으로 보호되어 있다. 본 문서에서는 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생성 로직 재분석 필요