_develop(python) 11번가 매크로 정복기

2022. 4. 30. 17:17카테고리 없음

Selenium

셀레니움은 크롬 등의 브라우저를 자동화 프로그램이 조종할 수 있게 만드는 라이브러리이다.

웹페이지의 요소를 자동으로 분석해 원하는 요소를 빠르게 얻어서 실행 시킬 수 있다. 이런 특성은 선착순 구매 매크로에 매우 매력적이다.

인터넷에 떠돌아다니는 여러 영상에도 셀레니움을 적극 활용하는 것을 볼 수 있었다.

본 글에서는 파이썬에서 구동되는 임시 Chrome을 사용하는 것이 아니라 미리 설정된 Chrome에 Selenium을 붙이는 방식으로 매크로를 구현 할 것임을 미리 알린다.

chrome.exe --remote-debugging-port=9222 --user-data-dir="C:/trash"

위 명령어를 통해 C드라이브의 trash 폴더를 유저 데이터 저장소로 사용하며, 9222 포트로 Selenium과 통신하는 Chrome을 실행 할 수 있다.

11번가 웹페이지 분석

먼저 11번가 쇼핑몰은 url 주소 이동에 어떠한 제약이 걸려있지 않다. 그래서 requests 모듈을 사용해서 속도를 최대한으로 끌어올리려고 시도해보았다. 그러나 클릭을 할 때마다 weblog를 기록하고 있기 때문에 보안상 시도하기 힘들었다. 또한 SK pay는 돈과 관련되어 있는 만큼 난수가 많았고 이를 전부 예측하기란 불가능하다.

셀리니움을 사용하면 문제는 간단하다. 셀레니움을 통해 웹페이지의 요소를 불러오고, 해당 요소에 클릭 명령을 원격으로 보내면 된다. 우리가 실제 브라우저를 사용하는 것과 같이 작동하므로 난이도가 매우 쉬워진다. 다만 이 속도를 얼마나 빠르게 할 수 있느냐가 문제이다.

리더스시스템즈가 판매하는 그래픽 카드의 경우 모바일 화면에서만 결제할 수 있기 때문에, 모바일 11번가를 사용할 것이다.

모바일 에뮬레이터

모바일 브라우저를 띄우기 위해서는 User-Agent 값을 조작해주는 확장 프로그램이 필요하다. 여기서는 아래에 있는 확장 프로그램을 이용했다.

Mobile View Switcher

속도전 분석

https://www.youtube.com/watch?v=urhpkyIpcEs

이 영상을 보면서 많이 참고했다.

CSS 비활성화

CSS는 웹페이지를 더 세련되고 직관적으로 보이게 만드는 심미적 도구이다. 그러나 매크로를 만드는 관점에서 보면 불필요한 애니매이션을 유발해 속도를 더욱 느리게 하는 암적인 요소이다.

11번가 모바일 애니메이션

셀레니움은 html을 기반으로 요소를 검색하기 때문에 애니메이션으로 인해 보이지 않는 요소들도 찾을 수는 있다. 그러나 기본적으로 셀레니움은 click을 기반으로 동작하기 때문에 화면에 요소가 보여야만 요소와 상호작용할 수 있다.

그러므로 CSS를 렌더링에서 차단해 줄 확장 프로그램이 필요하다.

CSS-Block

위 확장 프로그램은 CSS의 다운로드를 차단하므로 결론적으로 인터넷 로딩 속도를 높이는 결과를 가져온다.

이미지 비활성화

이미지는 인터넷 로딩속도를 느리게 하는 또 하나의 주요 원인이다. 이미지를 표시하는데는 큰 시간이 걸리지 않으나, 인터넷으로부터 다운로드 받을 때 비교적 큰 트래픽 부하를 일으킨다.

Block image

위 확장 프로그램은 이미지의 다운로드를 차단한다. 즉, 다운로드 시간과 렌더링 시간 모두 절약할 수 있다.

Alert 비활성화

매크로를 위해서는 Alert가 없는 것이 유리하다. Alert를 없애는 확장 프로그램을 다운받아 설치하도록 하자.

Alert Control

매크로 코드 작성

매크로를 돌리기 위한 최적의 크롬 환경을 갖추어 놓았으니 이제 파이썬 프로그램을 짜보자.

미리 설정된 Chrome에 Selenium 붙이기

from selenium import webdriver
from webdriver_manager.chrome import ChromeDriverManager
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.remote.remote_connection import LOGGER
LOGGER.setLevel(logging.WARNING)

options = Options()
options.add_experimental_option('debuggerAddress','127.0.0.1:9222')
driver = webdriver.Chrome(service=Service(ChromeDriverManager().install()), options=options)

이렇게 하면 9222 포트에 Chrome이 연결된다.

로그인 하기

url1 = "<https://login.11st.co.kr/auth/login.tmall?returnURL=http%253A%252F%252Fm.11st.co.kr%252FMW%252FMyPage%252FmypageHome.tmall>"
ID = ''
PW = ''
try :
    driver.get(url1)
    WebDriverWait(driver, 1).until(EC.presence_of_element_located((By.NAME, 'memId'))).send_keys(ID)
    driver.find_element(By.NAME, 'memPwd').send_keys(PW)
    login = driver.find_element(By.CLASS_NAME, "bbtn")
    driver.execute_script('arguments[0].click();', login)
    time.sleep(1)
except :
    pass

11번가에 로그인 되어 있는 경우에는 login.11st.co.kr로 접속해도 다시 로그인 할 수 있는게 아니라 홈으로 강제 이동 된다. 로그인을 위한 객체를 찾지 못할 수도 있으므로 try 문을 사용했다.

driver.get(url1)을 해서 브라우저가 url1에 접속하도록 한다.

WebDriverWait.until 구문은 다양하게 사용할 수 있다. EC 객체 내에 정의된 다양한 상태를 이용하여 html 객체의 상태가 정의된 상태가 될 때까지 기다릴때 사용한다.

memId라는 이름을 가진 html 객체가 존재하게 될 때까지 기다리며 timeout=1초 라는 뜻이다. 기다린 후에는 html 객체를 반환하는데, 이 코드에서는 바로 send_keys를 호출하여 ID를 입력하고 있다.

memId가 존재한다면 memPwd도 존재할 것이기에 명시적 대기를 사용하지 않고 바로 객체를 찾아 PW를 입력한다.

class 이름이 bbtn인 요소가 로그인을 담당하므로 찾아서 login 변수에 저장한다.

다음 줄에 보이는 driver.execute_script함수는 브라우저에서 마치 javascript가 실행된 것처럼 행동하게 한다. 즉, bbtn이 javascript에 의해서 click된 상태로 만들어지는 것이다.

이런 매커니즘으로 로그인이 된다.

옵션 선택하기

영겁의 시행착오를 통해 여러가지 사실을 발견했다.

  1. CSS block 적용 후 객관식 옵션은 애니메이션 없이 1초 이내에 선택 가능하다.
  2. 최종 구매확인 창으로 넘어가는 “바로구매” 버튼은 “구매하기” 버튼을 누르면 생성된다.
  3. 주관식 옵션은 “바로구매” 버튼을 눌러 한 번의 Alert를 받은 후에 입력 가능하다.
url2 = '<http://m.11st.co.kr/products/m/3167879989>'
wait = WebDriverWait(driver, 200)
driver.get(url2)
while True:
    nowSales = wait.until(
        lambda driver: driver.find_elements(by=By.XPATH, value='//div[@class="buy"]/button')
         or driver.find_elements(By.CLASS_NAME, "no_sale"))[0]
    if nowSales.text == '현재 판매중인 상품이 아닙니다.':
        driver.refresh()
        now = time.localtime()
        print("판매중지, 새로고침: " + "%04d/%02d/%02d %02d:%02d:%02d" % (now.tm_year, now.tm_mon, now.tm_mday, now.tm_hour, now.tm_min, now.tm_sec))
    else :
        startTime = time.time()
        wait.until(EC.element_to_be_clickable(nowSales))
        driver.execute_script('arguments[0].click();', nowSales)
        try :
            i = 0
            while (True) :
                selectOpt = driver.find_element(By.XPATH, '//*[@id="optlst_{}"]/li[1]/a'.format(i))
                driver.execute_script('arguments[0].click();', selectOpt)
                time.sleep(0.2)
                i += 1
        except Exception as e:
            print(e)
        try :
            optConfirm = driver.find_element(By.XPATH, '//*[@id="inputOpt0"]/div[3]/button[1]')
            driver.execute_script('arguments[0].click();', optConfirm)
        except :
            pass
        while (True) :
            try :
                confirmBuy = driver.find_element(By.XPATH, '//*[@id="optionContainer"]//div[@class="buy"]/button')
                break
            except :
                driver.execute_script('arguments[0].click();', nowSales)
        driver.execute_script('arguments[0].click();', confirmBuy)
        try :
            writeFirstOpt = driver.find_element(By.ID, 'optionText_0_0').send_keys('아니요')
            writeSecondOpt = driver.find_element(By.ID, 'optionText_0_1').send_keys('기타')
            driver.execute_script('arguments[0].click();', confirmBuy)
        except Exception as e:
            print(e)
        break

위 코드는 상품이 입고 되었는지 확인하고, 옵션을 선택하는 일련의 과정을 수행한다.

lambda문법을 활용해 둘 중 하나의 객체를 찾게 할 수 있다.

결제 선택 창

while (True) :
    try : 
        paymentStart = driver.find_element(By.CLASS_NAME, "btn_pay")
        break
    except :
        pass

결제 수단을 선택하는 창에서 결제하기 버튼을 찾는 과정이다. 여기서 명시적 대기를 사용해도 되지만, 좀 더 공격적인 방법인 무한 루프를 사용해서 더 빠르게 객체를 찾을 수 있도록 했다.

SK Pay 진입

newframe = 0
while (True) :
    try:
        newframe = driver.find_element(By.ID,'skpay-ui-frame')
        driver.switch_to.frame(newframe)
        break
    except:
        driver.execute_script("arguments[0].click();", paymentStart)

SK Pay 결제창은 iframe 태그로 한번 더 감싸져 있다. iframe 내의 요소를 탐색하기 위해서는 switch_to.frame을 이용해 검색 프레임을 변경해야 한다.

try 문을 이용해 skpay-ui-frame을 찾으려고 하고 있다. 실패하면 paymentStart를 광클한다. 이전 명령이 먹히지 않았을 수도 있기 때문에 여러번 클릭 요청을 하는 것이 의미가 있다.

SK Pay 비밀번호 입력

from PIL import Image
import time
import base64
import io

takeImage = 0
while (True) :
    try:
        takeImage = driver.find_element(By.XPATH,'//*[@id="keypad11pay-keypad-0"]/span')
        if takeImage == 0:
            continue
        break
    except:
        driver.switch_to.default_content()
        driver.switch_to.frame(newframe)        

encoded = takeImage.get_attribute('style')
decodedByte = base64.b64decode(encoded[57:-28])
shuffle_numbers = Image.open(io.BytesIO(decodedByte))
cropped_number = []
shuffle_md5 = []
for i in range(0, 11) :
    cropped_number.append(shuffle_numbers.crop((i * 26, 0, (i + 1) * 26, 64)))
    ioStream = io.BytesIO()
    cropped_number[i].save(ioStream, format='PNG')
    shuffle_md5.append(ioStream.getvalue())
    enc = hashlib.md5()
    enc.update(shuffle_md5[i])
    shuffle_md5[i] = enc.hexdigest()
    
md5_hash = ["f9ac4e252fbe47c4ae55d7321943854c",     # 0
            "528b09765e7eb3a676a782e213e956b6",     # 1
            "441db6adde4fcffaf3b0b3faf4c96176",     # 2
            "0336c5499940e4051fa2f606e0f44817",     # 3
            "3860457746c2bc8f0622c5ba764f7606",     # 4
            "a9c66ce6216d15c4827cd1c730167863",     # 5
            "cd7026729c25e773f6ef53a4268f401c",     # 6
            "43c217f443b7d78ca781afc33c3db944",     # 7
            "81e3e30b0b9784f7600b797ea5f1495a",     # 8
            "ff66a4a46b731989bf1904ff228662cc",     # 9
            "26b37a54e4ae5d45a4191e08d1884d34"]     # _

md5_hbsh = ["0e034aba96b61d35212b85b29e50642b",     # 0
            "45aaa2a81cd234d139d4396f164c2c81",     # 1
            "e8cb599728a70e3c4c8697b46b69c616",     # 2
            "75ca13311fad1050ea8fa4025634fde1",     # 3
            "3860457746c2bc8f0622c5ba764f7606",     # 4
            "e0d1501b121c6a0ed33061bfd2e3e414",     # 5
            "0f5de0c6a7b2edf52b6ccae55746340b",     # 6
            "1938da87e0020fb8a077b99ac3598da5",     # 7
            "a7012c21b410b05d136289a7c39f1959",     # 8
            "83f72d05a20c3bcfac853ac6edc7fa09",     # 9
            "260ff68d7b9a67fa53c911f502d7c82c"]     # _

keyPad = [0,0,0,0,0,0,0,0,0,0,0]

for i in range(len(shuffle_md5)) :
    if shuffle_md5[i] in md5_hash :
        idx = md5_hash.index(shuffle_md5[i])
        keyPad[idx] = i
    elif shuffle_md5[i] in md5_hbsh :
        idx = md5_hbsh.index(shuffle_md5[i])
        keyPad[idx] = i
    else :
        print("NOT FOUND!!: {}".format(shuffle_md5[i]))
        cropped_number[i].save("{}.png".format(i))
        print(cropped_number[i].size)
print(keyPad)
print("sk pay 복호화: ",time.time() - startTime)
buttons = list()
for i in keyPad:
    xpath = '//*[@id="keypad11pay-keypad-'
    xpath += str(i)
    xpath += '"]/span'
    buttons.append(driver.find_element(By.XPATH,xpath))

driver.execute_script('arguments[0].click();', buttons[0])
driver.execute_script('arguments[0].click();', buttons[0])
driver.execute_script('arguments[0].click();', buttons[1])
driver.execute_script('arguments[0].click();', buttons[1])
driver.execute_script('arguments[0].click();', buttons[0])
driver.execute_script('arguments[0].click();', buttons[0])

SK Pay의 가상키보드는 다음과 같은 방식으로 작동한다.

  1. 서버로부터 bas64로 인코딩된 PNG 파일을 받는다.
  2. 11개로 잘라서 키패드에 하나하나 표시된다.
  3. 내부 javascript 정보에 의해서 순서정보는 암호화 되어있다.

검정색 배경을 입혀 원본 이미지와 다릅니다.

이렇다 보니 SK Pay 가상 키패드를 우회하기 쉽지 않다. 하지만 방법은 있었다. 서버로부터 오는 PNG는 매번 정확히 286 x 64 픽셀이다. 이는 하나의 숫자를 26 x 64 픽셀로 잡고 11개로 나누면 정확히 나누어 떨어진다.

그래서 나는 각 파일이 존재하고 이들이 랜덤으로 순서만 바뀌어 합쳐져서 우리에게 전송될 것이라고 생각했다. 즉, 각각 나눠보면 고유한 hash 값을 가질 것이라 생각했다. 이 가설을 토대로 여러차례 검증해본 결과 각 숫자는 최대 2개의 hash 값을 가졌고, 이는 사전 검색 방법을 사용하기에 충분히 작았다.

서버로부터 다양한 이미지를 받아와 hash 값을 계속 업데이트 했고 위의 코드에 적힌 값으로 정리할 수 있었다.

 

💡 SK Pay 비밀번호 입력의 핵심은 이미지를 받아와 같은 크기로 잘라서 hash해 기존 hash 값과 비교하여 숫자를 판별한다는 점이다.

🚫 같은 이미지를 사용하므로 인공신경망은 의미가 없다. hash값을 구하는게 빠르고 간편하다.

나머지 (ARS 인증 등)

ars = wait.until(EC.element_to_be_clickable((By.XPATH, '//*[@id="popup"]/div[3]/div/button')))
ars.click()
ars_request = wait.until(EC.element_to_be_clickable((By.XPATH, '//*[@id="btn-req-auth"]')))
ars_request.click()

input()
print("주문이 완료되었습니다.")

마지막으로 ars 인증 버튼까지 자동으로 눌러주면 주문 완료다.