홈페이지 로그인 자동화

송선권·2024년 5월 30일

코인

목록 보기
1/3
post-thumbnail

배경

지금까지 코인에서는 공개 페이지로부터 학식 정보를 크롤링해왔다. 하지만 학교로부터 해당 페이지가 폐쇄될 예정이고, 이후 학식 정보는 학교 홈페이지에 로그인하여 확인 가능하다는 내용을 전달받았다. 우리는 식단을 학교 공식 홈페이지에 올려달라는 요청을 했고 긍정적 답변을 받았으나, 시간이 꽤 걸릴 것으로 추측되어 학교 홈페이지 크롤링을 위해 홈페이지 로그인 자동화를 진행하기로 했다.

환경 설정

로그인 자동화를 위해 파이썬을 사용했고, 사용한 의존성 및 라이브러리는 다음과 같다.

  • python3
  • selenium: 홈페이지 자동 로그인을 위해 사용한 파이썬 라이브러리
    • chrome: selenium을 구동하기 위해 필요한 의존 프로그램
  • imaplib: 인증 메일 수신을 위해 사용한 파이썬 라이브러리
  • beautifulsoup4: 인증 메일 내용 중 인증번호 파싱을 위해 사용한 파이썬 라이브러리

테스트 환경은 Mac OS, 실제 자동화 구동 환경은 ubuntu 20.04에서 진행했다. 여기서는 ubuntu 환경을 기준으로 설명하겠다.

Python 3 설치

요즘 ubuntu에서는 python3를 자체적으로 제공한다고 하지만, 혹시나 없다면 다음 명령어로 설치할 수 있다.

sudo apt update
sudo apt install python3

설치 여부 및 버전은 다음 명령어를 통해 확인할 수 있다.

python3 --version

파이썬 라이브러리 설치

필요한 파이썬 라이브러리는 다음 명령어로 설치 가능하다.

pip3 install selenium
pip3 install beautifulsoup4

pip3 에러가 발생한다면 다음 명령어를 선행한다.

sudo apt install python3-pip

Google-Chrome 설치

ubuntu에도 크롬은 존재한다. selenium을 사용하기 위해서는 크롬이 있어야 하기 때문에 다음 명령어를 통해 크롬을 설치한다.

ubuntu 크롬은 amd64 기반만 지원하기 때문에 만약 본인이 arm 환경이라면 chromium 설치와 같이 다른 방법을 찾아봐야 한다.

wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb
sudo apt install ./google-chrome-stable_current_amd64.deb

설치를 위해 다운받은 파일은 큰 용량을 차지하기 때문에 지워준다.

rm google-chrome-stable_current_amd64.deb

만약 wget 관련 에러가 발생한다면 다음 명령어를 선행한다.

sudo apt install wget

과거에는 selenium을 사용할 때 크롬과 함께 크롬 드라이버를 반드시 설치해줘야 했지만, selenium4부터는 크롬 드라이버를 별도로 설치하지 않아도 구동 가능하다.

로그인 자동화

selenium의 기본적인 동작 과정은 우리가 웹 페이지를 사용하는 과정과 동일하다. 특정 웹페이지에 접속하여 특정 입력란에 정보를 입력하고, 특정 버튼을 눌러 다른 페이지로 이동한다.

Windows나 Mac에서는 selenium 코드를 실행하면 크롬 창이 나오면서 작성한 코드가 순서대로 실행되는데, 그 과정을 눈으로 볼 수 있어서 편리하다.

코드 작성

먼저 초기 세팅 코드를 작성한다. 앞으로 나오는 코드에서는 driver 변수가 브라우저 창이라고 생각하면 이해하기 수월할 것이다.

from selenium import webdriver  
from selenium.webdriver.chrome.options import Options  
from selenium.webdriver.common.by import By  
from selenium.webdriver.support.ui import WebDriverWait  
from selenium.webdriver.support import expected_conditions as EC  

options = Options()  
options.add_argument("disable-blink-features=AutomationControlled")  
options.add_argument(  
    "user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.150 Safari/537.36")  
options.add_experimental_option("excludeSwitches", ["enable-automation"])  
options.add_experimental_option('useAutomationExtension', False)  
  
driver = webdriver.Chrome(options=options)  
driver.execute_script("Object.defineProperty(navigator, 'webdriver', {get: () => undefined})")

아래 코드는 selenium 실행 시 크롬 브라우저를 GUI로 띄우지 않겠다는 의미다. 리눅스 환경에서는 아래 문장을 추가하지 않으면 에러가 발생한다.

options.add_argument("--headless")  
options.add_argument("--no-sandbox")  
options.add_argument("--disable-dev-shm-usage")  

실행부는 다음과 같다.

driver.get(url='https://portal.koreatech.ac.kr/login.jsp')
"""
주요 로직 수행
"""
driver.quit()

사용 예시

간단한 로그인 자동화 예시 코드는 다음과 같다. selenium의 자세한 사용법은 별도 문서를 참조하자.

# 웹사이트로 이동
driver.get(url='사이트 주소')  
  
# 아이디 비밀번호 입력란 탐색  
wait = WebDriverWait(driver, 5) # 최대 5초 대기, 0.5초 간격 호출 설정 선언
id = wait.until(EC.presence_of_element_located((By.XPATH, '//*[@id="user_id"]'))) # id 입력란 엘리먼트가 로딩될 때까지 대기 및 불러옴
pw = driver.find_element(By.XPATH, '//*[@id="user_pwd"]') # pw 입력란 엘리먼트 불러옴
loginButton = driver.find_element(By.XPATH, '//*[@id="ssoLoginFrm"]/ul[2]/li[1]/a') # 로그인 버튼 엘리먼트 불러옴
  
# 로그인  
id.send_keys('아이디') # id 입력란에 문자열 입력
pw.send_keys('비밀번호') # pw 입력란에 문자열 입력
loginButton.click() # 로그인 버튼 클릭

selenium에서는 엘리먼트를 특정할 때 XPath를 사용할 수 있는데, 이게 굉장히 편리하다.
1. 웹 브라우저로 접속을 시도하는 페이지에 접속한다.
2. 접근하고자 하는 엘리먼트에 우클릭 > 검사 를 누른다.

3. 개발자 도구가 펼쳐지며 특정 html 엘리먼트가 선택될텐데, 우클릭 후 Copy > Copy XPath 를 누른다.

4. 복사한 XPath를 코드에 붙여넣으면 selenium이 해당 엘리먼트를 인식한다.
1. id = wait.until(EC.presence_of_element_located((By.XPATH, '붙여넣기')))

교외 인증

자동 로그인 자체는 의외로 정말 쉽게 끝났다. 하지만 우리 학교 홈페이지에는 교외 인증 시스템이 있다. 학교 외부에서 로그인을 시도하면 추가 인증 페이지로 넘어간다.

카카오와 이메일 중 이메일 인증이 훨씬 간단할 것이라고 판단하여 이메일 인증 방법을 선택했다. 이메일은 계정에 등록되어 있던 대표 메일로 자동 입력되고 수정이 불가하다. (1) 전송 버튼을 누르면 (2) 메일이 오고, (3) 그 인증번호를 입력한 후 (4) 인증 버튼을 누르면 된다. 이 로직은 그대로 selenium으로 쉽게 구현이 가능한데, 문제는 인증 메일을 파이썬에서 어떻게 수신받을 수 있는가이다.

메일 서버로의 연결이 필요한데, 코드를 작성하기 전에 먼저 해야할 일이 있다. 여기서는 지메일 기준으로 설명한다.

IMAP 설정

지메일 페이지에 접속하여 우측 상단 톱니바퀴 > 모든 설정 보기 > 전달 및 POP/IMAP > IMAP 액세스 > IMAP 사용 을 활성화한다.

앱 비밀번호 발급

구글 로그인은 2단계 인증이 필요하다. 하지만 파이썬으로 메일 서버에 접속하는 경우에는 2단계 인증이 (정상적인 접근으로는)힘든데, 이 점 때문에 일반 비밀번호로는 메일 서버 접속이 불가능하다.
앱 비밀번호를 발급받기 위해서는 2단계 인증이 활성화되어 있어야 한다. 2단계 인증이 활성화되어 있다면 이 곳으로 들어가 새로운 앱 비밀번호를 발급받을 수 있다. (관련 공식 문서)

앱 비밀번호를 사용해야 하는 구체적인 이유
구글이 "안전하지 않은 앱"의 액세스를 차단하고 있기 때문에 일반 비밀번호로는 접근이 불가능하다. 구글 로그인을 위해서는 앱 비밀번호를 사용하거나 보안 수준이 낮은 앱의 접근을 허용하도록 계정 설정을 변경해야 한다. 하지만 후자는 추천되지 않을 뿐더러 24.09.30부터 지원되지 않는다고 한다. 따라서 앱 비밀번호 발급 및 사용을 추천한다. 단, 앱 비밀번호만 있으면 2단계 인증 없이 구글 로그인이 가능하기 때문에 키를 별도로 보관하지 않는 것을 추천한다.
참고) https://support.google.com/accounts/answer/6010255?hl=ko&sjid=12360809417258640493-AP

기능 명세

구현할 기능 명세를 정의해보면 다음과 같다.

  • 인증 이메일은 3분의 유효시간을 가진다.
  • 가장 최근에 학교로부터 온 인증 메일이 존재하지 않을 시 5초 간격으로 재조회한다.
    - 가장 최근 메일이 아닌 가장 최근 학교로부터 온 메일을 인식한다.
    - 이미 존재할 경우 요청으로부터 3분이 지난 이메일은 유효한 이메일로 판단하지 않는다.

Gmail 코드 작성

아래와 같이 작성하여 gmail 서버에 로그인할 수 있다.

mail = imaplib.IMAP4_SSL("imap.gmail.com")  
mail.login(gmail_id, gmail_pw)

받은 메일함을 선택한다.

mail.select("inbox")

특정 발신자의 메일을 검색한다.

status, messages = mail.search(None, f'(FROM "{from_email}")')

검색 결과로 나온 메일의 id 리스트를 얻는다. 만약 id 리스트가 비어있다면 해당 발신자에게서 수신받은 메일이 없다는 의미이다.

mail_ids = messages[0].split()  
if not mail_ids:  
    return None, None

가장 최근(마지막) 메일 id를 선택한다.

latest_email_id = mail_ids[-1]

선택한 메일 내용 원본(RFC822)을 서버에서 가져와 이메일 객체와 id를 반환한다.

status, msg_data = mail.fetch(latest_email_id, "(RFC822)")
for response_part in msg_data:
    if isinstance(response_part, tuple):
        msg = email.message_from_bytes(response_part[1])
        return msg, latest_email_id

모든 작업이 완료되면 메일을 종료한다.

mail.logout()

인증번호 추출

위에서 반환한 이메일 객체를 가져와 인증 번호를 추출하는 코드이다.

import imaplib  
import email  
from email.header import decode_header  
from email.utils import parsedate_to_datetime, parseaddr  
from datetime import datetime, timedelta  
from bs4 import BeautifulSoup

# 이메일에서 인증번호 꺼내기
def extract_verification_code(msg):
    # 이메일이 멀티파트인지 확인
    if msg.is_multipart():
        # 이메일의 각 파트를 순회
        for part in msg.walk():
            # 파트의 콘텐츠 타입과 디스포지션을 가져오기
            content_type = part.get_content_type()
            content_disposition = str(part.get("Content-Disposition"))

            # HTML 텍스트이며 첨부파일이 아닌 경우
            if content_type == "text/html" and "attachment" not in content_disposition:
                # 파트의 내용을 디코딩하여 본문으로 변환
                body = part.get_payload(decode=True).decode()
                # 본문에서 인증번호를 파싱하여 반환
                return parse_verification_code_from_body(body)
    else:
        # 이메일이 멀티파트가 아닌 경우
        if msg.get_content_type() == "text/html":
            # 본문을 디코딩하여 변환
            body = msg.get_payload(decode=True).decode()
            # 본문에서 인증번호를 파싱하여 반환
            return parse_verification_code_from_body(body)
    # 인증번호를 찾지 못한 경우 None 반환
    return None

# 인증번호 숫자 꺼내기
def parse_verification_code_from_body(body):
    # HTML 파싱을 위해 BeautifulSoup 사용
    soup = BeautifulSoup(body, "html.parser")
    # <b> 태그 안의 텍스트를 찾기
    code = soup.find('b').text
    return code

인증 유효시간 판단 및 재조회

import imaplib  
import email  
import time  
from email.header import decode_header  
from email.utils import parsedate_to_datetime, parseaddr  
from datetime import datetime, timedelta  
from bs4 import BeautifulSoup

# 유효한 이메일 판단 기준 시간 (현재로부터 X min까지)  
time_threshold = timedelta(minutes=3) 
# 메일 재조회 간격
check_interval = 5
# 메일 재조회 동작 시간
timeout = 3 * 60 

mail = imaplib.IMAP4_SSL("imap.gmail.com")  
mail.login(id, pw)
start_time = time.time()  
try:  
    while True:
	    # 특정 발신자의 가장 최근 메일을 가져온다.(사용자 정의 함수)
        msg, latest_email_id = get_latest_email(mail)  
        if msg:  
            # 메일 날짜 파싱
            mail_date = parsedate_to_datetime(msg["Date"])
            current_time = datetime.now(mail_date.tzinfo)
            
	        # 유효 시간이 지나지 않은 경우
	        if current_time - mail_date <= time_threshold:
		        """
		        인증 번호 추출 및 반환 로직
		        """
				print(f"인증 메일을 찾을 수 없어 {check_interval}초 후 재조회합니다.")
				time.sleep(check_interval)  
	  
	        # 경과 시간 확인  
	        elapsed_time = time.time() - start_time  
	        if elapsed_time > timeout:  
	            print("최대 대기 시간을 초과했습니다. 인증 메일을 찾지 못했습니다.")  
	            break  
finally:  
    mail.logout()

회고

처음에는 학교에서 식단 정보를 공식 홈페이지에 게시한 후 기존 페이지를 내릴 것이라고 믿고 있었다. 하지만 스스로 해결하는 게 좋은 자세지 않겠냐는 동아리 선배의 조언을 듣고 학교에 의존적인 서비스는 확실히 문제가 있다고 생각했다. 그래서 막연히 어렵게 느껴졌던 홈페이지 로그인 자동화를 해봐야겠다고 느꼈고, 막상 해보니 그리 어렵지 않게 해결할 수 있었다. 앞으로는 변화에 유연한 자세를 가질 수 있도록 노력해야겠다.

+) 파이썬 짱.. 없는 게 없다. 최고의 장난감...

++) 실제로 학교에서 기존 페이지를 아무런 예고도 없이 내려버렸다. 아직 공식 홈페이지에 식단이 올라오기도 전에! 혹시나 해서 만들어뒀던 로그인 자동화가 실제로 쓰이게 된 지금 생각해보면 미리 만들어두길 정말 잘했다는 생각이 든다.

참고자료

https://www.lesstif.com/lpt/linux-chrome-106857342.html
https://selenium-python.readthedocs.io/
https://support.google.com/accounts/answer/185833
https://haeunyah.tistory.com/48

3개의 댓글

comment-user-thumbnail
2024년 5월 30일

멋있다 이상적인 태도....

1개의 답글
comment-user-thumbnail
2024년 5월 30일

우와

답글 달기