Web 자동화 툴 Playwright 소개

웹 페이지 자동화 시 Selenium을 대체할 수 있는 Playwright 소개

웹 페이지 자동화 시에 보통 Selenium을 많이 사용하는데, 오랜만에 웹 페이지 자동화를 하려고 알아보다가 Playwright를 발견하였다.
Playwright는 마이크로소프트가 2020년 경에 공개한 오픈 소스로 점점 많이 사용되는 것 같다. 나는 이번에 연습 삼아 간단히 사용해 보았는데, Selenium 보다 좋은 것 같아서 소개해 본다.

Playwright 소개

Playwright 설치

아래에서는 Linux에서 Python인 경우의 예로, 다른 플랫폼에서 다른 언어를 사용하는 경우는 적절히 알맞게 맞추자.
Playwright 패키지는 아래와 같이 설치할 수 있다.

$ pip install playwright

이후 아래와 같이 실행하면 추가로 필요한 패키지(예: Chromium, FFMPEG, Firefox, Webkit) 들이 설치된다.

$ playwright install

Generator 실행

아래와 같이 Generator를 실행시킬 수 있다.

$ playwright codegen <url>

결과로 브라우저에서 행하는 동작을 바탕으로 소스 코드를 생성해 주므로, 필요한 부분을 복사하여 사용할 수 있다.

Playwright 장점

간단히 Playwright를 사용해 보았는데 일단 Selenium 보다 다음 사항들이 더 좋았다.

  • Playwright는 자체적으로 웹브라우저를 설치하여 사용하므로 시스템에 설치된 웹브라우저 종류/버전과 관계없이 정상 동작하였다. (반면에 Selenium은 시스템에 설치된 웹브라우저를 사용하고 이것과 버전까지 일치하는 Selenium 드라이버를 다운로드해서 사용해야 하는 불편함이 있음)

    ℹ️ 참고로 Selenium에서 이 불편함을 해소하기 위해서 webdriver_manager 패키지를 이용하면 드라이버 다운로드를 자동화 할 수 있다.

  • 웹페이지가 로딩될 때까지 기다리는 것이 간단하게 구현되었다.
  • 소스 코드 Generator 툴인 codegen을 이용하여 간단히 기본 코드를 구성할 수 있었다.

사용 예제 1

회사 정책으로 매일 Redmine에서 나에게 할당된 프로젝트의 이슈에서 작업 시간을 기록해야 하는데, 이 단순 작업을 하기가 귀찮아서 이것을 자동화해 보았다.
사용자마다 Redmine ID/PW가 다르고, 할당된 프로젝트 모델이 다르므로, 이를 소스 코드에서 하드 코딩하지 않기 위하여 아래와 같이 JSON 파일을 구성하였다. (파일 이름은 user_issue.json으로 함)

{
    "user": {
        "USER_ID": "",
        "USER_PW": ""
    },
    "issue": {
        "TARGET_MODEL": ""
    }
}

이후 아래 예와 같이 Python 코드를 작성하였다. (아래는 블로그 참조용으로 내 최종 코드와는 일부 다름)

import json
import re
import sys
import time
from playwright.sync_api import Playwright, Page, sync_playwright, expect

USER_JSON_FILE = "user_issue.json"
HR_ATTENDANCE = "인사시스템_근태_URL"
REDMINE_SERVER = "Redmine_서버_주소"
REDMINE_URL = REDMINE_SERVER + "/redmine"

def get_user_issue_info():
    global USER_ID
    global USER_PW
    global TARGET_MODEL

    # JSON 파일을 연다.
    try:
        json_file = open(USER_JSON_FILE)
    except FileNotFoundError:
        print('"' + USER_JSON_FILE + '" 파일을 읽을 수 없습니다.')
        sys.exit(1)

    # JSON 파일로부터 user 정보, issue 정보를 읽는다.
    json_data = json.load(json_file)
    try:
        USER_ID = json_data['user']['USER_ID']
        USER_PW = json_data['user']['USER_PW']
        TARGET_MODEL = json_data['issue']['TARGET_MODEL']
    except KeyError:
        print('"' + USER_JSON_FILE + '" 파일이 올바르게 구성되어 있지 않습니다.')
        sys.exit(1)

    # JSON 파일을 닫는다.
    json_file.close()

def get_start_work_time(page: Page) -> int:
    start_work_hour = 0
    start_work_hm = 0

    # 인사 시스템의 근태정보 페이지에 접속해서 오늘 날짜의 출근 시각을 얻는다.
    page.goto(HR_ATTENDANCE)
    page.wait_for_url(HR_ATTENDANCE)
    now = time.localtime()
    month_str = "%02d" % now.tm_mon
    day_str = "%02d" % now.tm_mday
    today_str = str(now.tm_year) + "-" + month_str + "-" + day_str
    try:
        ten_days_attendance = page.locator("xpath=/html/body/div[2]/div[2]/div/div[2]/div[3]/table/tbody").element_handle(timeout=500).inner_text()
    except:
        print("인사 시스템으로부터 출근 정보를 얻지 못했습니다.")
        return 0
    daily_attendance = ten_days_attendance.splitlines()
    for attendance in daily_attendance:
        if attendance.find(today_str) == -1:
            continue
        today_attendance = attendance.split()
        if len(today_attendance) < 10:
            continue
        start_work_str = today_attendance[9].split(sep=':')
        start_work_hour = int(start_work_str[0])
        start_work_min = int(start_work_str[1])
        start_work_hm = start_work_hour * 60 + start_work_min
    if start_work_hm == 0:
        print("인사 시스템에서 오늘 날짜의 출근 정보를 발견하지 못했습니다.")
        return 0

    # 현재 시각과 출근 시각으로 작업 시간을 얻는다. (단, 점심 시간, 저녁 시간은 작업 시간에서 제외)
    now_hm = now.tm_hour * 60 + now.tm_min
    work_hm = now_hm - start_work_hm
    if start_work_hour < 12:
        if now.tm_hour == 12:
            work_hm -= now.tm_min
        elif now.tm_hour >= 13:
            work_hm -= 60
    if start_work_hour < 18:
        if now.tm_hour == 18:
            work_hm -= now.tm_min
        elif now.tm_hour >= 19:
            work_hm -= 60

    # 작업 시간을 시간 단위로 리턴한다.
    work_hour = int(work_hm / 60)
    return work_hour

def set_redmine_work_time():
    playwright = sync_playwright().start()

    # 브라우저를 생성한다.
    browser = playwright.chromium.launch(headless=False)
    context = browser.new_context()
    page = context.new_page()

    # 오늘의 작업 시간을 얻는다.
    work_hour = get_start_work_time(page)

    # Redmine 사이트에 접속한 후 로그인한다.
    page.goto(REDMINE_URL + "/login")
    page.get_by_label("로그인:").fill(USER_ID)
    page.get_by_label("비밀번호:").fill(USER_PW)
    page.get_by_role("button", name=re.compile("로그인")).click()
    try:
        page.wait_for_url(REDMINE_URL + "/my/page", timeout=1000)
    except:
        print("Redmine에 로그인하지 못하였습니다. ID/PW가 맞는지 확인해 주세요.")
        sys.exit(1)

    # "프로젝트" 링크를 클릭한다.
    page.get_by_role("link", name="프로젝트").click()
    page.wait_for_url(REDMINE_URL + "/projects")

    # 타겟 모델로 이동한다. (타겟 모델이 없으면 종료)
    try:
        target_model = page.get_by_role("link", name=TARGET_MODEL).element_handle(timeout=500).get_attribute("href")
    except:
        print('타겟 모델 "' + TARGET_MODEL + '"를 찾을 수 없습니다.')
        sys.exit(1)
    target_model_link = REDMINE_SERVER + str(target_model)
    page.get_by_role("link", name=TARGET_MODEL).click()
    page.wait_for_url(target_model_link)

    # "일감"을 클릭한 후, 검색조건을 담당자로 선택해서 적용한다.
    page.get_by_role("link", name="일감").click()
    page.wait_for_url(target_model_link + "/issues")
    page.get_by_role("combobox", name="검색조건 추가").select_option("assigned_to_id")
    page.get_by_role("link", name="적용").click()
    page.wait_for_load_state("load")

    # 이슈 리스트에서 나에게로 할당된 첫번째 이슈를 타겟 이슈로 간주하고, 이 이슈의 첫번째 칼럼에서 이슈 번호를 얻는다.
    try:
        issue_text = page.locator("xpath=/html/body/div/div[2]/div[1]/div[3]/div[2]/form[2]/div/table/tbody").element_handle(timeout=500).inner_text().split()
    except:
        print("타겟 이슈를 찾을 수 없습니다.")
        sys.exit(1)
    issue_num = issue_text[0]

     # 이슈 번호를 클릭한다.
    page.get_by_role("link", name=issue_num).click()
    page.wait_for_url(REDMINE_URL + "/issues/" + issue_num)

    # "작업시간 기록" 링크를 클릭한다.
    page.get_by_role("link", name="작업시간 기록").nth(1).click()
    page.wait_for_url(REDMINE_URL + "/issues/" + issue_num + "/time_entries/new")

    # "시간"을 입력한다.
    page.get_by_role("textbox", name="시간 *").fill(str(work_hour))

    # "작업종류"를 선택한다.
    page.get_by_role("combobox", name="작업종류 *").select_option("9")

    # 사용자가 "만들기" 버튼을 클릭할 때까지 무한 대기한다. (이 버튼을 클릭하면 "생성 성공" 메시지가 출력됨)
    page.get_by_role("button", name="만들기").focus()
    print('내용 확인 후에 브라우저에서 "만들기" 버튼을 클릭해 주세요.')
    try:
        expect(page.get_by_text("생성 성공")).to_be_visible(timeout=0)
    except KeyboardInterrupt:
        playwright.stop()
        sys.exit(1)
    except:
        context.close()
        browser.close()
        playwright.stop()
        sys.exit(1)

    # 5초 후 종료한다.
    time.sleep(5)
    context.close()
    browser.close()
    playwright.stop()

if __name__ == '__main__':
    get_user_issue_info()
    set_redmine_work_time()

사용 예제 2

회사 인증 모델들에서 각 모델별로 인증서가 EOL(End Of Life) 되는 경우를 사전에 체크하여 담당팀으로 알람 메일을 보내는 기능을 구현하였다.
먼저 아래와 같이 config.json 이름으로 설정 파일을 작성하였다. (아래에서는 실제 정보들은 의도적으로 비웠음)

{
    "advance_day": 90,
    "user": {
        "user_id": "",
        "user_pw": ""
    },
    "mail": {
        "to": ""
    },
    "models": [
        ""
    ]
}

아래와 같이 코드를 작성하였다. (마찬가지로 실제 정보들은 비웠음)

#!/usr/bin/python3
import json
import smtplib
import sys

from datetime import datetime, timedelta
from playwright.sync_api import sync_playwright
from email.mime.text import MIMEText
from typing import List

global NOTIFY_ADVANCE_DAY, no_product_models
global playwright, browser, context, page

# 설정 파일
MODELS_JSON_FILE = "config.json"

def get_configuration() -> None:
    '''JSON 파일로부터 각종 설정 정보를 얻는다.'''
    global NOTIFY_ADVANCE_DAY, USER_ID, USER_PW, mail_to, no_product_models

    # 설정 JSON 파일을 연다.
    try:
        json_file = open(MODELS_JSON_FILE, encoding="UTF-8")
    except FileNotFoundError:
        print(f"{MODELS_JSON_FILE} file is not found")
        sys.exit(1)

    # JSON 파일로부터 사전일, 유저 정보, 양산 중단된 모델 정보를 얻는다.
    json_data = json.load(json_file)
    try:
        NOTIFY_ADVANCE_DAY = json_data['advance_day']
        USER_ID = json_data['user']['user_id']
        USER_PW = json_data['user']['user_pw']
        mail_to = json_data['mail']['to']
        no_product_models = json_data['models']
    except KeyError:
        print(f"{MODELS_JSON_FILE} file is wrong")
        sys.exit(1)

    # JSON 파일을 닫는다.
    json_file.close()

def get_total_models_info() -> List[str]:
    '''전체 인증 모델 정보를 얻어서 리턴한다.'''
    global USER_ID, USER_PW
    global no_product_models
    global playwright, browser, context, page

    # 인증 페이지에 로그인한다.
    page.goto("")
    page.get_by_label("Username").fill(USER_ID)
    page.get_by_label("Password").fill(USER_PW)
    page.get_by_role("button", name="Logon").click()

    # 인증 모델별 인증 만료일 정보 페이지로 이동한다.
    page.goto("")

    # 테이블 정보를 얻어서 각 줄로 나누어서 문자열 리스트에 저장한다.
    table_rows = page.locator("body > div > table:nth-child(4)").inner_text().splitlines()

    return table_rows

def get_alarm_models(models_info: list) -> str:
    '''입력 모델들에서 인증서 만료일을 확인하여 만료되었거나 곧 만료될 모델들의 정보를 얻어서 리턴한다.'''
    global NOTIFY_ADVANCE_DAY, no_product_models
    expire_models = ""

    # 오늘 날짜를 얻는다.
    today = datetime.now()

    # 테이블 모델 시작 줄부터 각 줄을 파싱하면서, 만료일을 확인하여 만료되었거나 곧 만료될 모델들의 정보를 expire_models에 저장한다.
    for model in models_info[1:]:
        # 해당 줄 내용을 tab 문자로 나눈 후, 모델 이름, OP 이름, 인증서 만료일 정보를 얻는다. (인증서 만료일이 없는 모델은 무시한다)
        model_item = model.split("\t")
        model_name = model_item[2]
        op_name = model_item[3]
        expire_date_str = model_item[4]
        try:
            expire_date = datetime.strptime(expire_date_str, "%d %b %Y")
        except ValueError:
            continue

        # 사전 만료일(사전 알람 메일 시작일)을 구한다.
        advance_expire_date = expire_date - timedelta(days=NOTIFY_ADVANCE_DAY)

        # 오늘이 아직 사전 만료일을 지나지 않았으면 알람 대상 모델이 아니다.
        if today <= advance_expire_date:
            continue

        # 양산 중단된 모델이면 알람 대상에서 제외시킨다.
        if model_name in no_product_models:
            continue

        # 알람 대상 모델이면 관련 정보를 저장한다.
        expire_models += " - Model: " + model_name + ", OP: " + op_name + ", Expire date: " + expire_date.strftime("%Y-%m-%d")
        if today > expire_date:
            expire_models += " (EOL 되었음)<br>"
        else:
            expire_models += " (%d 일 후에 EOL 됨)<br>" % ((expire_date - today).days + 1)

    return expire_models

def open_web_browser() -> None:
    '''Playwright로 웹 브라우저를 생성한다.'''
    global playwright, browser, context, page

    playwright = sync_playwright().start()
    browser = playwright.chromium.launch(headless=True)
    context = browser.new_context()
    page = context.new_page()

def close_web_browser() -> None:
    '''Playwright로 생성된 웹 브라우저를 종료시킨다.'''
    global playwright, browser, context

    context.close()
    browser.close()
    playwright.stop()

def send_mail(mail_body: str) -> None:
    '''입력 메일 본문 내용을 바탕으로 인증서 만료 알람 메일을 발송한다.'''
    global NOTIFY_ADVANCE_DAY, mail_to

    # SMTP 세션을 생성하고 (TLS 사용) 시작시킨다.
    smtp = smtplib.SMTP('smtp.gmail.com', 587)
    smtp.starttls()

    # SMTP 로그인을 한다.
    smtp.login('', '')

    # 메일 HTML 시작/종료 내용을 세팅한다. (폰트 종류, 크기, 색깔 설정)
    mailBodyHtmlHead = ""
    mailBodyHtmlTail = ""

    # 메일 시작 및 종료 내용을 세팅한다.
    sayHello = ""
    sayGoodbye = ""

    # HTML을 포함하는 메일 본문 내용을 세팅한다.
    mailBodyMsg = mailBodyHtmlHead + sayHello + mail_body + sayGoodbye + mailBodyHtmlTail

    # 보낼 메시지의 본문과 제목을 설정한다.
    msg = MIMEText(mailBodyMsg, 'html')
    msg['Subject'] = ''

    # 메일을 발송한다.
    smtp.sendmail("", mail_to, msg.as_string())

    # SMTP 세션을 종료한다.
    smtp.quit()

if __name__ == '__main__':
    get_configuration()

    open_web_browser()
    models_info = get_total_models_info()
    close_web_browser()

    alarm_models_info = get_alarm_models(models_info)
    if alarm_models_info == "":
        print("No models to notify")
        sys.exit(0)

    send_mail(alarm_models_info)

Linux 및 Windows에서 정상적으로 동작하는 것을 확인하였다. 매주 한 번 정해진 시각에 실행되도록 하려면 Windows의 경우에는 작업 스케줄러에 추가하면 되는데, 나는 편의상 Linux 서버에서 crontab을 이용하여 추가하였다.

맺음말

처음 Playwright를 사용해 봤음에도 codegen generator를 이용하여 쉽게 기본 코드를 구성할 수 있었고, Python 용 문서 페이지를 참조하여 필요에 맞게 수정하는 데 그리 오래 걸리지 않았다.
다만, 구글링 해보면 아직 한글로 된 자료는 별로 없었지만, 온라인 문서 페이지를 참조하니 큰 어려움 없이 구현할 수 있었다.
Playwright를 이용하면 웹 페이지를 아주 쉽게 자동화할 수 있으므로 필요한 경우 이용해 보기를 추천한다.

카테고리:

업데이트: