
PyQt5와 Playwright로 인터넷등기소 자동화: 핵심 코드 분석
웹 스크래핑이나 자동화 작업을 하다 보면, 복잡한 사용자 인터페이스와 비동기적으로 로딩되는 데이터 때문에 골머리를 앓는 경우가 많습니다. 오늘은 PyQt5로 만든 GUI에 강력한 웹 자동화 도구인 Playwright를 결합하여 '인터넷등기소' 사이트의 매매목록을 조회하는 프로그램의 핵심 코드를 살펴보겠습니다.
프로그램의 핵심 구조: PyQt5 + QThread + Playwright
이 프로그램의 가장 큰 특징은 세 가지 기술을 유기적으로 결합했다는 점입니다.
- ✍️ PyQt5: 사용자 인터페이스(GUI)를 만듭니다. 사용자가 주소를 입력하고, 진행 상황과 결과를 확인할 수 있는 창을 제공합니다.
- ⚙️ QThread: Playwright로 실행되는 무거운 웹 자동화 작업이 GUI를 멈추게(freezing) 하지 않도록, 별도의 스레드에서 작업을 처리합니다.
- 🌐 Playwright: 실제 웹 브라우저를 제어하여 사이트에 접속하고, 데이터를 입력하며, 결과를 가져오는 핵심 자동화 엔진 역할을 합니다. 특히 비동기(async) 방식으로 동작하여 효율적입니다.
GUI의 '조회 시작' 버튼을 누르면, PlaywrightWorker라는 이름의 QThread가 생성되어 Playwright 작업을 시작합니다. 이 작업자는 log_signal과 finished_signal을 이용해 진행 상황과 최종 결과를 GUI에 안전하게 전달합니다.
1단계: 모든 호실 정보 수집 (페이지네이션 처리)
아파트나 오피스텔처럼 한 건물에 여러 호실이 있는 경우, 먼저 모든 호실의 정확한 주소를 수집해야 합니다. 이 프로그램은 총 2단계로 자동화를 진행하는데, 첫 번째 단계가 바로 이것입니다.
여기서 가장 까다로운 부분은 '다음 페이지' 버튼을 눌렀을 때, 페이지 내용이 실제로 바뀌었는지 확인하고 데이터를 수집하는 것입니다. 웹사이트가 느리거나 네트워크 상태가 좋지 않으면, 버튼만 클릭되고 데이터는 이전 페이지에 머물러 있을 수 있기 때문입니다.
아래 코드는 이 문제를 해결하는 핵심 로직입니다.
# 이전 페이지의 첫 번째 행 텍스트를 저장해 둡니다.
previous_first_row_text = None
# ... 페이지 루프 ...
# 2페이지부터는 페이지가 실제로 변경될 때까지 대기합니다.
page_changed = False
for wait_attempt in range(20): # 최대 20초(20 * 1000ms) 대기
await page.wait_for_timeout(1000)
try:
# 현재 페이지의 첫 번째 행 텍스트를 가져옵니다.
current_first_row_text = await all_rows_on_page.nth(0).locator('td').nth(5).inner_text()
# 이전 페이지의 텍스트와 다른지 비교합니다.
if current_first_row_text != previous_first_row_text:
self.log_signal.emit("페이지 새로운 데이터 로딩 완료")
page_changed = True
break # 변경이 확인되면 대기 루프를 탈출합니다.
except Exception:
# 아직 데이터가 로딩 중일 수 있으므로 계속 대기합니다.
continue
# ... (이후 데이터 수집 로직) ...
# 다음 페이지 비교를 위해 현재 첫 번째 행 텍스트를 저장합니다.
previous_first_row_text = await all_rows_on_page.nth(0).locator('td').nth(5).inner_text()
이처럼 이전 상태와 현재 상태를 비교하는 방식은 동적으로 콘텐츠가 변하는 웹페이지를 안정적으로 스크래핑하는 매우 효과적인 방법입니다.
2단계: 각 호실별 매매목록 확인 및 데이터 추출
1단계에서 수집한 모든 호실 주소 목록(units_to_process)을 가지고, 이제 하나씩 개별 조회를 시작합니다. 각 주소에 대해 메인 페이지에서부터 다시 검색하여 최종 매매목록 페이지까지 이동합니다.
최종 페이지에 도달하면, '매매 목록이 없다'는 메시지(no_data_div)가 나타나는지 여부로 매매목록의 존재를 판단합니다.
- 🟡 메시지가 있으면 → "매매목록: 존재하지 않음"
- 🟢 메시지가 없으면 → "매매목록: 존재함"
만약 매매목록이 존재한다면, 테이블에서 부동산고유번호, 갑구순위번호, 목록번호 같은 상세 데이터를 추출합니다.
sales_status = "존재함"
trade_data_details = ""
try:
# '데이터 없음' 메시지가 5초 안에 나타나는지 확인
await no_data_div.wait_for(state="visible", timeout=5000)
sales_status = "존재하지 않음"
except Exception:
# 예외가 발생하면 '데이터 없음' 메시지가 없는 것이므로, 목록이 존재한다고 판단
self.log_signal.emit("매매 목록이 존재하는 것으로 판단합니다.")
# ★★★ 매매목록 테이블 데이터 추출 시작 ★★★
try:
trade_table = page.locator("#mf_wfm_potal_main_wfm_content_grd_trade_list_body_table tbody")
await trade_table.wait_for(state="visible", timeout=10000)
visible_trade_rows = trade_table.locator('tr.grid_body_row:not([style*="display: none"])')
row_count = await visible_trade_rows.count()
if row_count > 0:
trade_records = []
for i in range(row_count):
row = visible_trade_rows.nth(i)
real_pin = await row.locator('td').nth(1).inner_text()
rank_no = await row.locator('td').nth(2).inner_text()
list_no = await row.locator('td').nth(3).inner_text()
trade_records.append(f"부동산번호:{real_pin}, 갑구순위:{rank_no}, 목록번호:{list_no}")
# 추출한 데이터를 결과 문자열에 추가
trade_data_details = f" ({len(trade_records)}건: {'; '.join(trade_records)})"
except Exception as e:
self.log_signal.emit(f"매매목록 데이터 추출 중 오류: {e}")
trade_data_details = " (데이터 추출 실패)"
# 최종 결과 조합
result_log = f"{dong_info} {cheung_info} {ho_info} - 매매목록: {sales_status}{trade_data_details}"
final_results.append(result_log)
이처럼 try-except 구문을 활용하여 특정 요소의 존재 여부를 파악하는 것은 웹 스크래핑에서 상태를 판별하는 고전적이면서도 확실한 방법입니다. 또한, 실제 데이터를 추출할 때도 각 행(row)과 열(cell)을 순회하며 필요한 정보를 정확히 가져오는 것을 볼 수 있습니다.
마치며
이 코드는 단순히 웹페이지를 클릭하고 데이터를 가져오는 것을 넘어, GUI의 반응성을 유지하기 위한 스레드 처리, 동적 웹페이지의 비동기 로딩에 대응하는 안정성 로직 등 실용적인 웹 자동화 프로그램이 갖춰야 할 중요한 요소들을 잘 보여줍니다. PyQt5와 Playwright의 조합은 복잡한 자동화 작업을 위한 훌륭한 선택지가 될 수 있습니다.
'콩's AI' 카테고리의 다른 글
| Cursor AI 12가지 꿀팁! (3) | 2025.08.27 |
|---|---|
| 제미나이(Gemini) 신상 이미지 모델 출시 미쳤다! (feat. 나노바나나) (0) | 2025.08.27 |
| 화제의 AI, 나노 바나나(Nano Banana) 사용법부터 성능까지 총정리 (2) | 2025.08.20 |
| Cursor AI에서 파이썬 가상환경(venv) 설정 방법 (2) | 2025.08.12 |
| AI로 나만의 동화책 만들기 (feat. Gemini Storybook) (2) | 2025.08.12 |