넥사크로 Playwright 크롤링 2탄: 그리드·PDF
넥사크로 협력사 포털 자동화 2탄입니다. 캔버스 그리드 데이터를 객체 모델과 엑셀로 꺼내고, 10건만 나오던 서버 페이징, OZ Report PDF 저장, SMS 2차 인증과 헤드리스 감지 문제를 해결한 과정과 코드를 담았습니다.

낮에는 회사에서 일하고, 밤에는 코드를 만지고, 주말에는 부엌에 있습니다. 겪은 것을 정리해 두는 곳입니다.
목차
대형 유통사의 협력사 포털에서 로그인 뒤에 만난 벽은 세 가지였습니다. 캔버스로 그려져 글자를 읽을 수 없는 그리드, 별도 창에 뜨는 OZ Report 뷰어, 그리고 SMS 2차 인증입니다. 그리드는 넥사크로 객체 모델과 엑셀 다운로드로, 리포트는 렌더 완료를 폴링한 뒤 저장 버튼으로 풀었습니다. 인증문자가 늦게 오던 문제는 정식 구글 크롬을 가상 디스플레이에서 띄우고 나서야 정리됐습니다.
1탄에서는 fill()과 click()이 먹지 않는 입력칸, 화면 밖 로그인 바, 겹쳐 뜨는 공지 팝업처럼 로그인 전에 막힌 문제를 다뤘습니다. 이번 글의 코드도 그 글에서 만든 좌표 클릭 함수 click_text()와 입력 함수 _type_nexacro()를 그대로 씁니다. 이렇게 받아 온 발주를 사내 수발주 프로그램에 등록하는 것이 이 작업의 최종 목표였습니다. 지금은 하루 10건의 발주 문서를 이 흐름으로 처리합니다.
한 줄 요약 화면에서 못 읽는 데이터는 넥사크로 객체 모델에 묻고, 자동화 티가 나서 막히면 위장보다 진짜 크롬을 띄웁니다.
넥사크로 캔버스 그리드 데이터는 어떻게 가져오나
넥사크로 그리드가 canvas로 그려져 있다면 화면을 긁지 말고 뒤에 있는 데이터셋을 직접 다뤄야 합니다. 이 포털의 주문 목록 그리드는 글자가 DOM에 없고 캔버스에 그림으로만 찍혀 있었습니다. 그래서 날짜 입력과 전체 선택 같은 조작은 넥사크로 객체 모델로, 실제 주문 데이터는 엑셀 다운로드로 가져왔습니다.
개발자 도구로 그리드를 찍어 보면 canvas 태그 하나만 잡힙니다. 1탄에서 버튼을 찾을 때 쓰던 innerText 탐색도 여기서는 빈손으로 돌아옵니다. 셀 하나하나가 눈에 뻔히 보이는데 코드로는 한 글자도 안 잡히니 허무했습니다.
nexacro.getApplication()으로 데이터셋을 직접 다룬다
넥사크로가 브라우저 전역에 노출하는 객체 모델을 page.evaluate()로 부르면 그리드 뒤의 데이터셋에 바로 닿습니다. 경로는 nexacro.getApplication()에서 시작해 폼 경로를 따라 div_work, div_main을 거쳐 grd_main까지 내려갑니다. 그리드의 getBindDataset()이 화면에 묶인 데이터셋을 돌려줍니다.
제가 쓴 호출은 네 가지입니다. getRowCount()로 행 수를 세고, setColumn(i, 'CHK', 1)로 모든 행의 체크 칸을 켜서 전체 선택합니다. 조회 날짜 칸 calFrom에는 set_value('20260906')처럼 날짜를 넣고, 전체 출력 체크박스 chk_allPrint는 set_value(1)로 켭니다.
# <폼 경로>는 실제 화면의 경로로 바꿔 넣는다WORK_JS = "nexacro.getApplication().<폼 경로>.div_work.form"MAIN_JS = WORK_JS + ".div_main.form"JS_SET_DATE = "(ymd) => MAIN.calFrom.set_value(ymd)".replace("MAIN", MAIN_JS)JS_SELECT_ALL = """() => { const ds = MAIN.grd_main.getBindDataset(); for (let i = 0; i < ds.getRowCount(); i++) ds.setColumn(i, 'CHK', 1); MAIN.chk_allPrint.set_value(1); return ds.getRowCount();}""".replace("MAIN", MAIN_JS)page.evaluate(JS_SET_DATE, "20260906") # 조회 날짜click_text(page, "조회") # 1탄의 좌표 클릭 함수rows = page.evaluate(JS_SELECT_ALL) # 전체 선택 + 전체 출력폼 경로는 이 포털 화면 기준이라 다른 사이트에 그대로 옮기면 동작하지 않습니다. 날짜 칸과 전체 출력 체크박스가 폼 트리 어디에 붙어 있는지도 화면마다 다를 수 있습니다
엑셀저장 파일을 받아 openpyxl로 읽는다
주문 내용은 "엑셀저장" 버튼으로 파일을 받아 openpyxl로 읽는 쪽이 가장 확실했습니다. 버튼은 1탄처럼 좌표로 누르고, page.expect_download()로 내려오는 파일을 잡습니다. 파일로 받으면 캔버스에 무엇이 그려져 있든 행과 열이 그대로 남습니다.
import openpyxlfrom playwright.sync_api import TimeoutError as PWTimeoutJS_ROW_COUNT = "() => MAIN.grd_main.getBindDataset().getRowCount()".replace("MAIN", MAIN_JS)def download_orders(page, path): try: with page.expect_download() as dl: click_text(page, "엑셀저장") dl.value.save_as(path) except PWTimeout: if page.evaluate(JS_ROW_COUNT) == 0: return None # 발주 없음 raise # 진짜 오류 return openpyxl.load_workbook(path).active알아두기 발주가 0건이면 엑셀 다운로드 자체가 일어나지 않습니다.
타임아웃만 보고 오류로 처리하면 발주가 없는 조용한 날마다 실패로 기록됩니다. 그래서 타임아웃이 나면 그리드 행 수를 한 번 더 확인합니다. 0행이면 "발주 없음"으로 끝내고, 행이 있는데 파일이 안 왔을 때만 진짜 오류로 올립니다.
문서가 32건인데 PDF에는 10건만 나올 때
넥사크로 그리드가 서버 페이징이라면 페이지 크기 콤보를 200으로 늘린 뒤 출력해야 전체가 나옵니다. 이 포털 그리드는 기본으로 10행만 불러왔습니다. 전체 선택과 전체 출력을 켜도 이미 불러온 10건만 PDF로 나갔습니다.
처음 알아챈 건 숫자가 안 맞았을 때입니다. 엑셀에는 문서가 32건인데 PDF에는 10건뿐이었습니다. 앞에서 setColumn()으로 체크한 범위도 결국 불러온 10행까지였던 겁니다.
해결은 페이지 크기 콤보 cboCnt 값을 200으로 바꾸는 것입니다. 문제는 화면마다 콤보 경로가 달라 고정 경로가 자주 실패했다는 점입니다. 그래서 고정 경로가 안 되면 컴포넌트 트리의 components 배열을 7단계까지 재귀로 훑습니다. 이름이 cboCnt, cboCount, cboPageSize 중 하나인 콤보를 찾으면 거기에 값을 넣습니다.
JS_PAGE_SIZE_200 = """() => { const names = ['cboCnt', 'cboCount', 'cboPageSize']; const walk = (form, depth) => { const cs = (form && depth <= 7 && form.components) || []; for (let i = 0; i < cs.length; i++) { if (names.includes(cs[i].name)) return cs[i]; const hit = walk(cs[i].form, depth + 1); if (hit) return hit; } }; const cbo = walk(WORK, 1); if (cbo) cbo.set_value('200'); return !!cbo;}""".replace("WORK", WORK_JS)콤보 값을 바꾼 뒤에는 조회를 다시 눌러 200행을 불러오게 했습니다. 그래도 엑셀의 문서 수와 그리드 행 수가 맞지 않는 날이 있었습니다. 그럴 때는 무리하지 않고 한 발 물러서도록 순서를 정했습니다.
엑셀에서 센 문서 수와 그리드 행 수를 비교한다.
두 숫자가 같으면 원래대로 그룹별로 나눠 출력한다.
다르면 그룹별 출력을 포기하고 전체 출력으로 PDF 한 개를 만든다.
일부 지점 발주서가 통째로 빠지는 것보다, 한 파일로 뭉친 PDF가 낫다고 판단했습니다. 나눠진 파일은 사람이 조금 불편할 뿐이지만, 빠진 발주서는 아무도 모르고 지나갈 수 있기 때문입니다.
OZ Report 뷰어에서 PDF를 자동으로 저장하는 방법
OZ Report 뷰어는 렌더가 끝났는지 알려 주지 않으므로, 본문 글자를 1초마다 확인하다가 문서번호가 보이면 저장하면 됩니다. 포털에서 출력 버튼을 누르면 별도 창에 뷰어가 뜹니다. 렌더 완료를 알려 주는 신호를 찾지 못해서 화면에 찍힌 글자로 판단했습니다.
판단 기준은 두 가지입니다. body 텍스트에서 processing이나 loading 같은 글자가 사라지고, 10자리 문서번호가 나타나면 다 그려진 것으로 봅니다.
import redef open_report(page, limit_sec): with page.context.expect_page() as new_page: click_text(page, "출력") viewer = new_page.value for _ in range(limit_sec): text = viewer.inner_text("body") busy = re.search(r"processing|loading", text, re.I) if not busy and re.search(r"\d{10}", text): return viewer # 문서번호가 보이면 렌더 완료 viewer.wait_for_timeout(1000) return None저장 버튼 글자가 OK에서 확인으로 바뀌었다
렌더가 끝나면 뷰어 iframe 안의 .btnSAVEAS를 눌러 저장 대화상자를 열고, 확인 버튼으로 다운로드를 시작합니다. 확인 버튼은 정규식 ^(확인|OK)$로 잡아 두 가지 글자를 모두 받습니다. 처음부터 이렇게 짠 건 아니었습니다.
어느 날 잘 되던 PDF 다운로드가 갑자기 멈췄습니다. 제 코드는 그대로였고, 뷰어가 업데이트되면서 버튼 글자가 "OK"에서 "확인"으로 바뀐 것이 원인이었습니다 . 글자 두 개 바뀐 것에 자동화가 통째로 멈추는 걸 보고 좀 허탈했습니다.
import osREPORT_TIMEOUT_SEC = int(os.environ["REPORT_TIMEOUT_SEC"]) # 이름은 예시OK_BUTTON = re.compile(r"^(확인|OK)$")def save_pdf(viewer, pdf_path): frame = viewer.frame_locator("iframe").first with viewer.expect_download(timeout=REPORT_TIMEOUT_SEC * 1000) as dl: frame.locator(".btnSAVEAS").click() frame.get_by_text(OK_BUTTON).click() dl.value.save_as(pdf_path)주의 외부 뷰어의 버튼 글자는 예고 없이 바뀔 수 있습니다. 글자로 버튼을 찾는다면 후보를 둘 이상 열어 두는 편이 안전합니다.
리포트가 안 나오면 빨리 실패시킨다
다운로드 대기 상한은 코드에 박지 않고 환경변수로 뺐습니다. 리포트가 안 나오는 날 한 번에 3분씩 매달리면 뒤에 이어지는 등록 작업까지 줄줄이 밀렸기 때문입니다. 지금은 상한을 2분으로 줄여 빨리 실패하고, 등록을 먼저 끝내도록 순서를 바꿨습니다.
SMS 2차 인증 자동화, 인증번호가 버려질 때
SMS 2차 인증은 인증번호 발송 버튼을 누른 직후, 다른 어떤 대기보다 먼저 "코드 대기" 상태로 바꿔 두면 번호를 놓치지 않습니다. 이 순서가 늦었을 때는 문자가 로그인 직후 바로 도착해서, 대기 상태가 되기 전에 들어온 번호가 버려졌습니다. 전형적인 레이스 컨디션이었습니다.
인증번호는 휴대폰 자동화 앱이 받은 문자를 텔레그램으로 넘겨 주고, 프로그램이 그 메시지를 읽어 입력합니다. 거래처 발주를 확인해 텔레그램으로 알림을 보내던 구조를 반대 방향으로 쓴 셈입니다.
click_text(page, "인증번호 발송")set_state("WAITING_CODE") # 클릭 바로 다음 줄. 다른 대기보다 먼저code = read_code_from_telegram()_type_nexacro(page, SEL_AUTH_INPUT, code) # 1탄의 입력 함수로그인 뒤 화면은 세 가지 중 하나로 판별한다
로그인 버튼을 누른 뒤에는 인증칸 등장, 포털 진입, 비밀번호 변경 페이지 중 어느 상태인지부터 가립니다. 셋 중 무엇이 나올지 매번 같지 않아서, 한 가지만 기다리면 나머지 경우에 타임아웃이 납니다. 1초마다 세 가지를 번갈아 확인하고 먼저 나타난 쪽으로 분기합니다.
def detect_after_login(page, limit_sec): for _ in range(limit_sec): if page.query_selector(SEL_AUTH_INPUT): return "AUTH" # 인증칸 등장 if page.query_selector(SEL_PORTAL_HOME): return "PORTAL" # 포털 진입 if page.query_selector(SEL_PW_EXPIRED): return "PW_EXPIRED" # 비밀번호 변경 페이지 page.wait_for_timeout(1000) return "UNKNOWN"비밀번호 만료 페이지는 "30일 후 변경"으로 넘긴다
비밀번호 만료 페이지가 끼어들면 그 페이지 안에서 인증을 마치고 "30일 후 변경"을 누르면 됩니다. 이렇게 넘기면 별도의 2차 인증 없이 포털로 바로 들어갔습니다.
"인증번호 받기"를 누르고 곧바로 코드 대기 상태로 바꾼다.
텔레그램으로 넘어온 코드를 입력한다.
"확인"을 누른다.
"30일 후 변경" 버튼을 누른다.
3번의 확인은 화면 본문에 있는 버튼입니다. 1탄에서 공지 팝업을 닫을 때 알림창 확인 버튼만 누르도록 셀렉터를 좁혀 둔 이유가 바로 이 버튼이었습니다. 다만 30일 뒤에는 같은 페이지가 다시 뜰 것 같아, 실제 비밀번호 변경은 따로 챙겨야 합니다
헤드리스 크롬에서 인증문자가 안 오거나 7분씩 늦을 때
인증문자가 아예 안 오거나 7분씩 늦게 왔다면 헤드리스 크로뮴의 자동화 흔적을 의심해 볼 만합니다. 제 경우 지문 위장 스크립트로는 부족했고, 정식 구글 크롬(channel="chrome")을 서버의 가상 디스플레이 Xvfb에서 화면 있는 모드로 띄워 정리했습니다. 이 포털 말고 다른 사이트는 지금도 헤드리스로 돌립니다.
헤드리스 크로뮴을 그대로 쓰면 자동화라는 티가 여러 군데서 납니다. 제가 확인한 흔적은 다섯 가지였습니다.
UA 문자열에 HeadlessChrome이 그대로 들어 있다.
navigator.webdriver 값이 true다.
WebGL 렌더러가 SwiftShader로 나온다.
브라우저 플러그인이 0개다.
플랫폼은 Linux인데 UA는 Windows라서 둘이 서로 맞지 않는다.
1탄에서 공지 팝업을 자동화 감지로 오해하고 지문을 고쳤던 경험이 여기서 다시 등장했습니다. 이번에는 정말로 지문이 걸리는 것으로 보였지만, 스크립트로 값을 하나씩 덮어쓰는 방식은 끝내 부족했습니다. 위장을 과하게 할수록 오히려 더 티가 난다는 것도 이때 알았습니다.
def launch_portal_browser(p): # 서버에서는 Xvfb 가상 디스플레이를 띄워 둔 상태에서 실행한다 return p.chromium.launch( channel="chrome", # 번들 크로뮴이 아닌 정식 구글 크롬 headless=False, # 가상 디스플레이 위에서 화면 있는 모드 args=["--window-size=1920,1080"], )def launch_default_browser(p): return p.chromium.launch(headless=True) # page.pdf()를 쓰는 사이트용번들 크로뮴 대신 정식 크롬을 쓴 이유
번들 크로뮴을 화면 있는 모드로 띄우면 제 서버에서는 크래시 핸들러가 죽으면서 브라우저가 뜨지 않았습니다. 그래서 정식 구글 크롬을 설치하고 channel="chrome"으로 지정했습니다. Playwright에서는 playwright install chrome 명령으로 정식 크롬을 설치할 수 있습니다.
헤드리스를 완전히 버리지 않은 건 page.pdf() 때문입니다. Playwright 공식 문서 기준으로 page.pdf()는 크로미움 헤드리스 모드에서만 동작해서, 이 기능에 기대는 다른 사이트는 헤드리스로 남겨 뒀습니다.
진짜 브라우저를 쓰고 난 뒤로는 지문 조작을 최소한으로 줄였습니다. 전환한 뒤 인증문자는 30초정도면 됐습니다.
만드는 것보다 계속 돌아가게 하는 게 더 어려웠다
이 포털 하나를 3일 동안 붙들고 있었는데, 코드가 처음 돌아간 날보다 그 뒤에 멈춘 날들이 더 기억에 남습니다. 뷰어 버튼 글자가 바뀐 날처럼, 제 코드가 아니라 상대 사이트가 바뀌어서 멈추는 일은 앞으로도 생길 겁니다. 그래서 다음에 다른 포털을 붙일 때는 기능보다 "발주 없음"과 "진짜 오류"가 먼저 구분되는 구조부터 잡으려고 합니다. 혹시 비슷한 넥사크로 포털을 자동화하려 하신다면, 헤드리스 위장에 시간을 쓰기 전에 처음부터 정식 크롬을 띄워 보시길 권하고 싶습니다.