이번 가이드는 가이드 3편(Selenium으로 무적 크롤러 만들기)의 확장편입니다. 아직 selenium을 이용해보지 않은 분이라면 먼저 저 가이드를 보고 오시는걸 추천합니다.
HeadLess Chrome? 머리없는 크롬?
HeadLess란?
Headless라는 용어는 ‘창이 없는’과 같다고 이해하시면 됩니다. 여러분이 브라우저(크롬 등)을 이용해 인터넷을 브라우징 할 때 기본적으로 창이 뜨고 HTML파일을 불러오고, CSS파일을 불러와 어떤 내용을 화면에 그러야 할지 계산을 하는 작업을 브라우저가 자동으로 진행해줍니다.
하지만 이와같은 방식을 사용할 경우 사용하는 운영체제에 따라 크롬이 실행이 될 수도, 실행이 되지 않을 수도 있습니다. 예를들어 우분투 서버와 같은 OS에서는 ‘화면’ 자체가 존재하지 않기 때문에 일반적인 방식으로는 크롬을 사용할 수 없습니다. 이를 해결해 주는 방식이 바로 Headless 모드입니다. 브라우저 창을 실제로 운영체제의 ‘창’으로 띄우지 않고 대신 화면을 그려주는 작업(렌더링)을 가상으로 진행해주는 방법으로 실제 브라우저와 동일하게 동작하지만 창은 뜨지 않는 방식으로 동작할 수 있습니다.
그러면 왜 크롬?
일전 가이드에서 PhantomJS(팬텀)라는 브라우저를 이용하는 방법에 대해 다룬적이 있습니다. 팬텀은 브라우저와 유사하게 동작하고 Javascript를 동작시켜주지만 성능상의 문제점과 크롬과 완전히 동일하게 동작하지는 않는다는 문제점이 있습니다. 우리가 크롤러를 만드는 상황이 대부분 크롬에서 진행하고, 크롬의 결과물 그대로 가져오기 위해서는 브라우저도 크롬을 사용하는 것이 좋습니다.
하지만 여전히 팬텀이 가지는 장점이 있습니다. WebDriver Binary만으로 추가적인 설치 없이 환경을 만들 수 있다는 장점이 있습니다.
윈도우 기준 크롬 59, 맥/리눅스 기준 크롬 60버전부터 크롬에 Headless Mode가 정식으로 추가되어서 만약 여러분의 브라우저가 최신이라면 크롬의 Headless모드를 쉽게 이용할 수 있습니다.
위 코드를 보시면 ChromeOptions()를 만들어 add_argument를 통해 Headless모드인 것과, 크롬 창의 크기, 그리고 gpu(그래픽카드 가속)를 사용하지 않는 옵션을 넣어준 것을 볼 수 있습니다.
제일 중요한 부분은 바로 options.add_argument('headless')라는 부분입니다. 크롬이 Headless모드로 동작하도록 만들어주는 키워드에요. 그리고 크롬 창의 크기를 직접 지정해 준 이유는, 여러분이 일반적으로 노트북이나 데스크탑에서 사용하는 모니터의 해상도가 1920x1080이기 때문입니다. 즉, 여러분이 일상적으로 보는 것 그대로 크롬이 동작할거라는 기대를 해볼수 있습니다!
마지막으로는 disable-gpu인데요, 만약 위 코드를 실행했을때 GPU에러~가 난다면 --disable-gpu로 앞에 dash(-)를 두개 더 붙여보세요. 이 버그는 크롬 자체에 있는 문제점입니다. 브라우저들은 CPU의 부담을 줄이고 좀더 빠른 화면 렌더링을 위해 GPU를 통해 그래픽 가속을 사용하는데, 이 부분이 크롬에서 버그를 일으키는 현상을 보이고 있습니다. (윈도우 크롬 61버전까지는 아직 업데이트 되지 않았습니다. 맥 61버전에는 해결된 이슈입니다.)
그리고 driver 변수를 만들 때 단순하게 chromedriver의 위치만 적어주는 것이 아니라 chrome_options라는 이름의 인자를 함께 넘겨줘야 합니다.
이 chrome_options는 Chrome을 이용할때만 사용하는 인자인데요, 이 인자값을 통해 headless등의 추가적인 인자를 넘겨준답니다.
자, 이제 그러면 한번 실행해 보세요. 크롬 창이 뜨지 않았는데도 naver_main_headless.png파일이 생겼다면 여러분 컴퓨터에서 크롬이 Headless모드로 성공적으로 실행된 것이랍니다!
Headless브라우저임을 숨기기
Headless모드는 CLI기반의 서버 OS에서도 Selenium을 통한 크롤링/테스트를 가능하게 만드는 멋진 모드지만, 어떤 서버들에서는 이런 Headless모드를 감지하는 여러가지 방법을 쓸 수 있습니다.
아래 글에서는 Headless모드를 탐지하는 방법과 탐지를 ‘막는’방법을 다룹니다.(창과 방패, 또 새로운 창!)
# UserAgent값을 바꿔줍시다! options.add_argument("user-agent=Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/61.0.3163.100 Safari/537.36")
// overwrite the `languages` property to use a custom getter Object.defineProperty(navigator, 'languages', { get: function() { return ['ko-KR', 'ko']; }, });
// overwrite the `plugins` property to use a custom getter Object.defineProperty(navigator, 'plugins', { get: function() { return [1, 2, 3, 4, 5]; } });
const getParameter = WebGLRenderingContext.getParameter; WebGLRenderingContext.prototype.getParameter = function(parameter) { // UNMASKED_VENDOR_WEBGL if (parameter === 37445) { return'Intel Open Source Technology Center'; } // UNMASKED_RENDERER_WEBGL if (parameter === 37446) { return'Mesa DRI Intel(R) Ivybridge Mobile '; }
return getParameter(parameter); };
['height', 'width'].forEach(property => { // store the existing descriptor const imageDescriptor = Object.getOwnPropertyDescriptor(HTMLImageElement.prototype, property);
// redefine the property with a patched descriptor Object.defineProperty(HTMLImageElement.prototype, property, { ...imageDescriptor, get: function() { // return an arbitrary non-zero dimension if the image failed to load if (this.complete && this.naturalHeight == 0) { return20; } // otherwise, return the actual dimension return imageDescriptor.get.apply(this); }, }); });
// store the existing descriptor const elementDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetHeight');
// redefine the property with a patched descriptor Object.defineProperty(HTMLDivElement.prototype, 'offsetHeight', { ...elementDescriptor, get: function() { if (this.id === 'modernizr') { return1; } return elementDescriptor.get.apply(this); }, });
# inject.py from bs4 import BeautifulSoup from mitmproxy import ctx
# load in the javascript to inject with open('content.js', 'r') as f: content_js = f.read()
defresponse(flow): # only process 200 responses of html content if flow.response.headers['Content-Type'] != 'text/html': return ifnot flow.response.status_code == 200: return
# inject the script tag html = BeautifulSoup(flow.response.text, 'lxml') container = html.head or html.body if container: script = html.new_tag('script', type='text/javascript') script.string = content_js container.insert(0, script) flow.response.text = str(html)
ctx.log.info('Successfully injected the content.js script.')
하지만 사실 이 코드는 정상적으로 동작하지 않을거에요. 헤드리스모드를 끄면 잘 돌아가지만 헤드리스모드를 켜면 정상적으로 동작하지 않아요. 바로 SSL오류 때문입니다.
크롬에서 SSL을 무시하도록 만들수 있고, 로컬의 HTTP를 신뢰 가능하도록 만들 수도 있지만 아직 크롬 Headless모드에서는 지원하지 않습니다.
정확히는 아직 webdriver에서 지원하지 않습니다.
결론
아직까지는 크롬 Headless모드에서 HTTPS 사이트를 ‘완전히 사람처럼’보이게 한뒤 크롤링 하는 것은 어렵습니다. 하지만 곧 업데이트 될 크롬에서는 익스텐션 사용 기능이 추가될 예정이기 때문에 이 기능이 추가되면 복잡한 과정 없이 JS를 바로 추가해 진짜 일반적인 크롬처럼 동작하도록 만들 수 있으리라 생각합니다.
사실 서버 입장에서 위와 같은 요청을 보내는 경우 처리를 할 수 있는 방법은 JS로 헤드리스 유무를 확인하는 방법이 전부입니다. 즉, 서버 입장에서도 ‘식별’은 가능하지만 이로 인해 유의미한 차단은 하기 어렵습니다. 현재로서는 UserAgent 값만 변경해주어도 대부분의 사이트에서는 자연스럽게 크롤링을 진행할 수 있으리라 생각합니다.