selenium 기반이었던 프로그램을 cv2, pywin32 기반으로 변경했다.
아직은 초기 버전이라 창도 어설프지만, 앞으로 꾸준히 업데이트 해볼 예정이다.
현재 버전은 2.0.0, 암호는 earthscience.kr 이다.
최근에 지인에게서 연수를 자동으로 넘겨주는 닥터클릭이라는 프로그램에 대해 듣게 되었다.
일단 터미널이 열리는 것부터 뭔가 파이썬 냄새가 났다.
그래서 나도 한번 만들어보기로 했다.
이름은 OPEN AI의 이름을 딴 오픈 클릭으로 작명해 보았다.
나 또한 이 프로그램을 만드는 이유는 닥터클릭 제작자와 다르지 않다.
손목이 아파 마우스나 키보드를 사용하기 힘든 이들을 위해 제작한다.
연수를 자동으로 넘겨주는 동안, 사용자는 앞에서 자리를 지키며 시청만 하면 된다.
물론 사용은 자유지만, 사용에 대한 책임은 본인에게 있음을 인지하길 바란다.
자동화 프로그램을 쓰지 말라는 공지를 보고난 뒤, 닥터클릭을 다시 한번 들여다 보았다.
폴더 안에 이미지 폴더가 따로 있고, 다음 이미지를 업데이트 한다라...
그리고 연수에 관련된 각종 이미지들이 들어있어?
![]() | ![]() |
---|
처음엔 그냥 터미널만 보고 셀레니움으로 구현했는데, 내부 구조를 보니 pyautogui와 pywin32 같다.
이미지로 클릭할 위치를 찾고, 해당 포인트에서 클릭을 반환해주면 된다.
이렇게 하면 직접 사용자의 시스템을 모니터링 하지 않는 이상은 걸릴 일이 없을듯 하다.
프로그램 실행 방법은 간단하다.
뭐 더 설명이 필요하다고 하면 나중에 적겠다.
참고로 화면의 이미지를 기준으로 탐색하기 떄문에 연수화면이 가려지면 안된다.
처음에는 selenium을 이용해 연수를 넘겨주는 방법을 구현했다.
공공기관 연수 사이트라서 selenium에 대한 방어가 전무할줄 알았는데 이런 공지가 떴다.
이거 혹시... 내 이야기인가?
그래서 selenium을 버리고 마우스 자동화 쪽으로 방향을 변경했다.
selenium으로 구현했기에 그리 어렵지 않았는데, 이번에는 gui를 이용하는 것이기 때문에 거의 사람이 인식하는 과정과 동일하게 구현했다.
이제 연수 화면을 보면서 여러가지 가정들을 한번 해보자.
일단 연수 강의실에서의 모습은 아래와 같다.
여기서 연수를 넘기기 위해서 필요한건 최소 2개.
다음 버튼과 영상 재생 버튼이다.
영상 재생 버튼은 한번만 누르면 다음 영상 부터는 자동 재생이기 때문에 상관할 필요가 없다.
현재 영상이 학습 완료이고 다음 버튼이 있다면 다음 버튼을 누르면 된다.
때로는 문제를 모두 푼 뒤 아래와 같은 버튼을 누르도록 요구하기도 한다.
그럼 이제 이것들을 찾아서 눌러주면 된다.
간단한 조건들을 설정하자면 아래처럼 할 수 있다.
어떤 잡다한 기능들 다 빼고 원하는 것을 눌러주는 것부터 시작해보자.
처음에는 아래처럼 간간단하게 pyautogui로 이미지를 찾고 클릭하려고 했다.
import pyautogui
import pygetwindow as gw
import pathlib
from functools import partial
imagePath = pathlib.Path.cwd() / "test.png"
windows = gw.getAllWindows()
x, y, w, h = 0, 0, 0, 0
for window in windows:
if "강의실" in window.title:
x, y, w, h = window.left, window.top, window.width, window.height
try :
locationg = pyautogui.locateOnScreen(str(imagePath), region=(x, y, w, h))
print(locationg)
except pyautogui.ImageNotFoundException:
print("이미지 없음")
except Exception as e:
print('오류 발생 : ', e)
그런데 왜 이미지조차 찾질 못하니...?
알고보니 듀얼모니터일 경우, 화면 1에 있는 창들은 인식하지만 2에 있는 창들은 인식하지 못한다는 것.
그래서 imagegrap으로 전체화면 스크린샷을 찍고, opencv-python으로 이미지를 매칭하는 식으로 구현했다.
from PIL import ImageGrab
import cv2, numpy as np
import pathlib
import win32gui, win32con
from functools import partial
imagePath = pathlib.Path.cwd() / "test.png"
# 모든 화면 캡처가 가능하도록 설정
ImageGrab.grab = partial(ImageGrab.grab, all_screens=True)
# win32gui와 콜백함수로 모든 창들을 검색
def getAllWindows():
result = []
def handleWindows(window, _):
if win32gui.IsWindowVisible(window):
title = win32gui.GetWindowText(window)
if title.strip():
x1, y1, x2, y2 = win32gui.GetWindowRect(window)
result.append({
"title" : title,
"x": x1,
"y": y1,
"w": x2-x1,
"h": y2-y1,
"active" : win32gui.GetForegroundWindow() == window,
})
win32gui.EnumWindows(handleWindows, None)
if len(result) != 0 :
return result
return None
# 창들을 돌면서 내가 원하는 제목이 포함된 창 찾기
def locateOnWindow(image, title, threshold=0.8):
x, y, w, h = 0, 0, 0, 0
windows = getAllWindows()
for window in windows:
if title in window["title"]:
x, y, w, h = window["x"], window["y"], window["w"], window["h"]
if x == 0 and y == 0 and w == 0 and h == 0:
return None
img = np.array(ImageGrab.grab(bbox=(x, y, x+w, y+h)))
tmpl = cv2.imread(image)
res = cv2.matchTemplate(img, tmpl, cv2.TM_CCOEFF_NORMED)
_, val, _, loc = cv2.minMaxLoc(res)
if val >= threshold:
return x + loc[0], y + loc[1]
return None
print(locateOnWindow(imagePath, "강의실" ))
이렇게 하니 창이 어디에 있던지 찾기가 가능했다.
이제 그럼 원하는 이미들을 추출하고 클릭하는 일만 남았다.
먼저 pywin32를 이용해 클릭을 구현해보자.
창에 클릭 이벤트를 윈도우 api로 넘겨주기 위해 locateOnWindow
함수에 몇가지를 더 추가하였다.
def click(position):
hwnd = win32gui.FindWindow(None, position['title'])
lParam = win32gui.MAKELONG(position[0], position[1]) # 클릭 좌표 (버튼 내부 상대)
win32gui.SendMessage(position[2], win32con.WM_LBUTTONDOWN, win32con.MK_LBUTTON, lParam)
time.sleep(0.05)
win32gui.SendMessage(position[2], win32con.WM_LBUTTONUP, None, lParam)
이렇게 하면 함수에서 리턴한 값에서 창 제목 등을 추출해 클릭이 가능하다.
대략적인 것들은 이제 틀이 잡혔으니 자잘한 부분들을 수정해보자.
이제 이걸 클래스로 만들어서 유지 보수가 쉽도록 만들어주자.
루프를 스레드로 돌려서 다른 프로그램도 실행 가능하도록 해보았다.
이제 창만 구현하면 된다.
창 구현부터는 귀찮아서 업로드 하지 않을듯 하다.
import pyautogui
import time
import traceback
from requests import post
import os
import platform
import subprocess
url = "https://earthscience.kr/post/30"
rootUrl = 'https://openclick.earthscience.kr'
serverVersionUrl = f'{rootUrl}/version'
version = '2.0.0'
def open_chrome(url):
system = platform.system()
if system == "Darwin": # macOS
chrome_path = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
subprocess.Popen([chrome_path, url])
elif system == "Windows":
os.system(f'start chrome "{url}"')
else:
raise OSError("지원하지 않는 운영체제입니다.")
def checkVersion():
try :
response = post(serverVersionUrl)
serverVersion = response.json()["version"]
print(f'현재 버전: {version}')
if serverVersion == version:
print('현재 버전이 최신 버전입니다.')
pass
elif serverVersion is None:
print('서버 버전을 확인할 수 없습니다. 사이트에 접속하시겠습니까?')
if input('y/n : ') == 'y':
open_chrome(url)
exit()
else:
print(f'현재 버전은 {version}이고, 서버 버전은 {serverVersion}입니다.')
print('업데이트가 필요합니다. 사이트에 접속하시겠습니까?')
if input('y/n : ') == 'y':
open_chrome(url)
exit()
except Exception as e:
print(f'버전 확인 실패: {e}')
return None
if __name__ == '__main__':
print("-"*15)
print('오류 발생시 오류 내역을 https://earthscience.kr로 전송합니다.')
print('아이디, 비밀번호 등 개인정보는 일체 전송하지 않습니다.')
print("-"*15)
checkVersion()
try:
print("hello world")
except Exception as e:
print('에러가 발생했습니다. 에러 내용을 전송합니다.')
errorTrackback = traceback.format_exc()
errordata = {'error' : str(e), 'errorTrackback' : str(errorTrackback)}
res = post('https://openclick.earthscience.kr/error', json=errordata)
print(res.json()['message'])
time.sleep(10)
exit()
최근에 했던 코딩 중에 가장 손코딩을 많이 하지 않았나 싶다.
이런 재미난 프로젝트 하나씩만 있어도 정말 보람찬 한달을 보낼 수 있을 것 같다.
해당 코드가 부디 당신의 손목을 지켜주길 바랄 뿐이다.