Outsider's Dev Story

Stay Hungry. Stay Foolish. Don't Be Satisfied.

Headless Chrome으로 AWS Lambda에서 웹사이트 스크린샷 찍기 #2

이 글은 Headless Chrome으로 AWS Lambda에서 웹사이트 스크린샷 찍기 #1에서 이어진 글이다.



AWS Lambda 함수 작성

AWS Lambda에서는 시작 파일이 필요하므로 index.js를 만들어야 한다.

// index.js
exports.handler = (event, context, callback) => {
  context.succeed('Hello World');
};

Lambda에서 exports.handler를 실행하므로 이 함수가 시작점이 된다. 앞에서 작성한 모듈을 사용해서 스크린샷을 찍어서 S3에 저장하도록 코드를 수정했다.

// index.js
const { connectChrome } = require('./chrome');
const { uploadFile } = require('./s3');

exports.handler = (event, context, callback) => {
  connectChrome()
    .then(uploadFile)
    .then(context.succeed)
    .catch(context.fail);
};

이제 Lambda 함수를 다 작성했다. 로컬에서 실행할 때는 데스크톱에 설치된 크롬을 사용하지만, Lambda 환경에는 Chrome이 없으므로 headless_shell을 포함해야 한다. 크롬에서 빌드한 headless_shellGitHub에 올려두었으므로 이 파일을 다운받아서 저장한 뒤 같이 업로드하면 된다.

├── chrome.js
├── headless_shell
├── index.js
├── node_modules
└── s3.js

그래서 파일 구조를 보면 위와 같은 형태가 된다.

$ zip -r function.zip .

Lambda에 올리려면 함수를 zip으로 압축해야 하므로 현재 폴더를 function.zip으로 압축했다. headless_shell이 130MB인데 function.zip로 압축하면 44MB가 된다.

여기서 Lambda에 올릴 수 있는 압축파일의 최대 크기는 50MB이므로(실제로는 좀 더 올라가지만...) 44MB는 문제없이 headless_shell를 사용할 수 있다. 다만 여기서는 예제코드라서 간단하지만 npm 모듈을 더 추가하다 보면 금방 50MB를 넘어설 수 있다. 예제는 최대한 간단하게 작성하지만, 실제 내가 Lambda를 쓸 때는 Apex를 사용하므로 배포할 때 hook을 지정해서 webpack으로 필요한 코드만 배포되도록 사용하고 있다. 이렇게 하면 50MB가 넘어갈 일은 없어 보인다.

AWS Lambda 함수 배포

배포 준비도 끝났으니 AWS Lambda에 올려서 실행해 보자.

AWS Lambda 생성

AWS Lambda에서 새로운 함수를 만들어서 zip 파일로 앞에서 만든 function.zip을 업로드한다. 이때 Lambda 내에서 headless_shell을 사용할 수 있도록 앞에서 설명한 대로 CHROME_PATH 환경변수로 /var/task/headless_shell로 지정한다. 업로드한 코드는 모두 /var/task 밑으로 올라간다. 메모리 설정은 256MB로 지정하고 타임아웃은 60초로 지정했다. 예시에서는 스크린샷을 1장만 찍지만 여러 장을 찍는다면 사용 메모리는 점점 올라가지만 1장만 찍는다고 하더라도 128MB로는 부족하다.

업로드한 Lambda 함수 실행 결과

AWS Lambda에서 제공하는 테스트 기능으로 Lambda를 실행한 결과이다. 성공적으로 실행이 되었고 11초가 걸렸고 153MB의 메모리를 사용했다. 여기서는 Lambda를 실행하는 IAM Role과 S3 버킷에 대한 접근 권한에 대한 설명은 생략했다. S3 버킷에 파일을 제대로 올릴 수 있도록 권한을 주어야 한다.

Lambda에서 생성한 깃헙 스크린샷

위 파일은 Lambda로 찍은 GiHub.com의 스크린샷이다. 지정한 대로 정상적으로 찍은 것을 알 수 있고 위 예시를 수정하면 Lambda에서 원하는 웹사이트의 스크린샷을 자동으로 찍어서 저장하게 할 수 있다.

CJK 폰트 문제

여기까지 테스트를 했을 때 이제 다 작성한 줄 알았지만 실제로 사용하려고 하니까 그렇지 않았다.

이 블로그의 스크린샷에서 한글이 깨진 화면

위는 Lambda로 이 블로그의 스크린샷을 찍은 화면인데 보다시피 한글이 제대로 나오지 않는다. 브라우저는 OS에 설치된 폰트를 사용하는데 Lambda에는 한글 폰트가 설치되어 있지 않으므로 당연히 한글이 제대로 출력되지 않는다. 내 블로그는 한글이 문제가 된 것이지만 폰트 문제에서 보통 그렇듯이 중국어, 일본어에서도 당연히 같은 문제가 발생할 것이다.

결국, Lambda에서 실행하는 headless Chrome이 CJK 폰트를 사용할 수 있게 설정해 주어야 한다. 이런 문제는 다른 headless chrome 프로젝트에서도 이슈가 되고 있는데 일단 나는 Google Noto 폰트를 사용하기로 했다.

Noto CJK에서 CJK 폰트를 제공하므로 이 중에서 한글 폰트인 NotoSansKR-[weight].otf를 다운로드 받았다. 이 안에는 많은 weight가 포함되어 있는데 NotoSansKR-Regular.otf를 여기서 사용한다. 다른 Weight나 CJK 폰트가 다 필요하다면 다른 파일을 사용해야 한다.

구글의 폰트 관련 문서를 보면 리눅스에서는 HOME 디렉터리 아래 .fonts 디렉터리를 만들고 그 아래 폰트 파일을 두면 크롬이 자동으로 폰트를 로딩해서 사용한다. Lambda에서도 이 방법을 사용해야 하는데 업로드하는 파일에 넣으면 /var/task 아래 만들고 여기를 HOME으로 지정해야 하는데 다른 문서에도 이렇게 나와 있긴 하지만 권한 문제 때문인지(/var/task는 읽기 전용이다.) 제대로 되지 않았다. 그래서 /tmpHOME으로 지정하고 Lambda가 실행될 때 S3에 올려둔 폰트를 다운로드 받도록 했다. 한글 폰트 하나는 4.5MB 정도이지만 많은 weight가 필요하거나 CJK 폰트를 모두 쓰려면 NotoSansCJK-Regular.ttc가 18.7MB이므로 zip 파일의 용량이 50MB가 넘어서 어차피 올릴 수가 없다.

// s3.js
const AWS = require('aws-sdk');
const fs = require('fs');

const s3 = new AWS.S3({ apiVersion: '2006-03-01' });

module.exports = {
  uploadFile: (buffer) => {
    // 생략
  },
  downloadFont: () => {
    return new Promise((resolve, reject) => {
      return s3.getObject({
        Bucket: 'outsider-demo',
        Key: 'NotoSansKR-Regular.otf'
      }, (err, data) => {
        if (err) { return reject(err); }

        fs.mkdir('/tmp/.fonts', (err) => {
          if (err) { return reject(err); }
          fs.writeFile('/tmp/.fonts/NotoSansKR-Regular.otf', data.Body, (err) => {
            if (err) { return reject(err); }
            resolve();
          });
        });
      });
    });
  },
};

S3 버킷에서 NotoSansKR-Regular.otf 폰트를 다운받는 downloadFont() 함수를 추가했다. 폰트는 미리 S3에 올려놨고 이 파일을 다운로드 받아서 /tmp/.fonts/에 저장한다.

// index.js
const { connectChrome } = require('./chrome');
const { uploadFile, downloadFont } = require('./s3');

exports.handler = (event, context, callback) => {
  downloadFont()
    .then(connectChrome)
    .then(uploadFile)
    .then(context.succeed)
    .catch(context.fail);
};

index.js에서 크롬을 실행하기 전에 폰트를 먼저 다운로드 받도록 했다.

환경변수를 변경해서 새 Lambda 함수의 배포 화면

압축한 function.zip을 다시 업로드 하고 HOME/tmp로 지정했다. 새로 업로드한 Lambda 함수를 다시 실행하면 다음과 같이 한글이 제대로 나타나는 걸 볼 수 있다.

한글이 제대로 나온 이 블로그의 스크린샷



2017/08/26 23:09 2017/08/26 23:09

Headless Chrome으로 AWS Lambda에서 웹사이트 스크린샷 찍기 #1

이전에 Headless Chrome의 사용방법에 관한 글을 올리고 Chrome의 headless_shell을 직접 컴파일해서 사용하는 방법에 대해서도 올렸다.(이 글이 6월이었다니...)

이전 글에서도 얘기했듯이 headless_shell을 테스트해보는 이유는 AWS Lambda에서 사용해보기 위함이다. AWS Lambda에서 사용할 수 있으면 다른 환경에서는 훨씬 쉽게 사용할 수 있고 Headless Chrome 같은 경우 자동화에 활용할 가능성이 크므로 Lambda 같은데 올려서 쓸 수 있다면 제격이라고 생각했다. Chrome 버전이 계속 올라가긴 하겠지만 현재 Headless Chrome으로 어디까지 할 수 있고 어떻게 활용할 수 있는지 파악해 놓고자 함이었다.

웹페이지 스크린샷

Internet Archive Wayback Machine처럼 내가 원하는 사이트를 정기적으로 계속 스크린샷을 찍으려고 한다. 요즘은 잘 안 바꾸지만 내 블로그의 디자인을 변경하는 때도 있으므로 예전부터 로컬에서 스크린샷을 찍어서 보관하고 있었다. 로컬에서 돌리는 건 아무래도 환경마다 달라서 귀찮으므로 이를 Labmda에 올려서 알아서 돌아가게 하면 좋겠다는 생각을 했다. 뭐 이전에도 이런 작업은 충분히 가능했지만 귀찮아서 놔두다가 Headless Chrome을 사용해 보기 좋아서 선택했다.

물론 이는 사이트에 접속해서 스크린샷만 찍는 것이므로 브라우저의 동작까지 제어해야 하는 자동화보다는 훨씬 간단한 작업이지만 Lambda에서 Headless Chrome을 돌려보기에는 적당한 주제라고 생각했다.(기대보다 훨씬 험난했지만...)

이렇게 작성한 Lambda 함수는 GitHub에 올려두었지만 이 글 이후에도 소스를 계속 수정할 것 같으므로 여기서는 만들면서 겪은 이슈 위주로 정리해 보려고 한다. Lambda를 쓸 때 apex를 사용하고 있지만, 이 글에서는 설명이 복잡하므로 따로 만들어서 직접 Lambda에 업로드 하려고 한다.

스크린샷 Lambda

AWS Lambda에 올릴 Node.js 코드를 예시로 작성할 예정이고 환경은 Node.js 6.10을 사용할 것이다.

Chrome Launcher

크롬을 사용할 것이므로 일단 크롬을 실행해야 하는데 나는 chrome-launcher를 선택했다. 쉘 명령어로 직접 headless_shell을 실행해도 되지만 이경우 관리가 좀 귀찮을 것으로 생각했고 구글 크롬에서 관리하는 Lighthouse 아래 있는 chrome-launcher가 사용하기 적당해 보였다.

관련해서 몇 가지 참고할 부분이 있는데

  • 현재 0.6.0 인데 0.4.0 이전까지는 Lighthouse와 같이 배포되었으므로 require('lighthouse/lighthouse-cli/chrome-launcher') 처럼 가져와야 했지만, 이제는 별도의 npm 모듈로 배포되므로 바로 require('chrome-launcher')로 가져오면 된다. 0.4.0 이전 버전을 사용하는 글을 참고한다면 참고해야 한다.
  • 구글 크롬팀에서 chrome-launcher 외에 Headless Chrome을 Node.js로 제어할 수 있는 Puppeteer을 얼마 전에 공개했다. 이번에 만든 Lambda도 Puppeteer으로 변경할 예정이긴 하지만 아직 사용해보지 않았다. 새로 시작한다면 Puppeteer으로 바로 시작하는 게 더 좋을 수도 있다.

현재 chrome-launcher 버전은 0.6.0이다. 이제 Headless Chrome을 사용할 코드를 작성하기 위해서 chrome.js을 다음과 같이 만든다. 경험상 Lambda를 쓸 때 위의 index.js는 아주 간단하게만 둔 채로 다른 로직은 별도의 파일로 작성하는 게 테스트도 작성하기 쉽고 훨씬 낫다. 먼저 chrome-launcher를 설치하고(npm install chrome-launcher) chrome.js에 다음과 같은 코드를 작성한다. 현재 버전은 0.6.0이다.

// chrome.js
const chromeLauncher = require('chrome-launcher');

const launchChrome = () => {
  return chromeLauncher.launch({
    chromeFlags: [
      '--headless',
      '--disable-gpu',
      '--no-sandbox',
      '--vmodule',
      '--single-process',
    ],
    logLevel: 'verbose',
  })
  .then((chrome) => {
    console.log(`Headless Chrome launched with debugging port ${chrome.port}`);
    return chrome;
  });
};

launchChrome();

간단히 chromeLauncher.launch()로 원하는 플래그와 옵션을 지정해서 크롬을 실행하는 코드이다.

  • chromeFlags은 크롬에 직접 전달하는 플래그이다. chromeLauncher에서 지원하지 않는 옵션은 이렇게 지정해야 한다.

    • --headless은 Headless 모드로 띄우기 위해 실행했다.
    • --disable-gpu는 GPU의 사용을 끈 것이다. 문서에 따르면 Mesa 라이브러리가 없는 경우 오류를 피하려면 필요하다는데 내 테스트에서는 없어도 큰 문제는 없었다. 일단 문서에 그렇게 나와 있으니 지정했다.
    • --no-sandbox는 macOS에서는 괜찮았지만, Linux 환경(특히 Lambda)에 가면 이 옵션이 없으면 No usable sandbox!라는 오류가 발생하고 문서를 참고하라고 나온다.
    • --vmodule--single-process는 좀 묘한 옵션인데 macOS와 로컬에서 Linux 환경에서도 다 괜찮았는데 Lambda에만 올리면 Headless Chrome을 정상적으로 띄웠음에도 chrome devtools protocol에 접속하지 못하는 문제가 생긴다. 좀 더 정확히는 크롬을 띄우고 chrome devtools protocol로 리스트를 요청하면 chrome devtools protocol에 접속 가능한 URL과 웹소켓 주소 등이 나와야 하는데 아무것도 주지 않는다. 크롬 버그인지 어떤지까지는 추적을 못 했지만 이 두 옵션을 지정하면 Lambda에서도 정상 동작한다.
  • logLevel은 런처의 로그 메시지 설정이다. 문제가 생길 때마다 디버깅을 해야 해서 verbose로 아예 켜두었다.

위 코드를 실행해보자.

$ node chrome.js
  ChromeLauncher:verbose created /var/folders/k8/j1j0fjkn5_vdzxybfqq0r7lc0000gn/T/lighthouse.XXXXXXX.8tqw7dbE +0ms
  ChromeLauncher:verbose Launching with command:
  ChromeLauncher:verbose "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary" --disable-translate --disable-extensions --disable-background-networking --safebrowsing-disable-auto-update --disable-sync --metrics-recording-only --disable-default-apps --mute-audio --no-first-run --remote-debugging-port=50069 --user-data-dir=/var/folders/k8/j1j0fjkn5_vdzxybfqq0r7lc0000gn/T/lighthouse.XXXXXXX.8tqw7dbE --headless --disable-gpu --no-sandbox --homedir=/tmp --data-path=/tmp/data-path --disk-cache-dir=/tmp/cache-dir --vmodule --single-process about:blank +588ms
  ChromeLauncher:verbose Chrome running with pid 7834 on port 50069. +4ms
  ChromeLauncher Waiting for browser. +1ms
  ChromeLauncher Waiting for browser... +0ms
  ChromeLauncher Waiting for browser..... +511ms
  ChromeLauncher Waiting for browser.....✓ +3ms
Headless Chrome launched with debugging port 50069

로그를 켜두었으므로 실행과정을 볼 수 있는데 정상적으로 Headless Chrome이 뜬 것을 볼 수 있다. --remote-debugging-port의 기본 포트는 9222인데 여기서는 지정하지 않았으므로 동적으로 할당해서 사용한다. 크롬이 실행되면서 반환한 객체는 { pid: 5891, port: 62993, kill: [Function: kill] }처럼 생겼다. 프로세스의 pid와 디버깅 포트 정보가 있고 실행한 크롬을 죽일 수 있는 kill() 함수가 있다.

headless_shell

chrome-launcher는 설치된 크롬을 자동으로 찾는다. Chrome Canary가 설치되어 있다면 이를 먼저 사용하고 아니면 Chrome을 찾는다.

사용 중인 데스크톱에는 보통 크롬이 설치되어 있겠지만 서버의 경우는 다르다. 나 같은 경우도 서버, 여기서는 AWS Lambda를 사용하기 위해서 headless_shell만 별도로 컴파일했다. 이렇게 별도로 있는 크롬 바이너리를 실행하려면 CHROME_PATH로 환경 변수로 크롬의 위치를 지정해 주어야 한다.

AWS Lambda라면 코드를 업로드 했을 때 /var/task에 올려지므로 업로드하는 코드에 headless_shell을 포함한다면 CHROME_PATH=/var/task/headless_shell로 환경변수를 지정해야 Lambda 내에서도 chrome-launcher가 정상적으로 크롬을 찾아낸다. 물론 환경변수보다 OS 설치된 크롬을 먼저 찾는다.

참고삼아 얘기하지만, chrome-launcher 0.4.0 이전 버전에서는 LIGHTHOUSE_CHROMIUM_PATH 환경변수를 사용했는데 지금은 CHROME_PATH로 바뀌었다.

Chrome Devtools Protocol

크롬에는 Chrome DevTools Protocol이 있다. 이는 크롬에서 개발자도구로 웹사이트를 디버깅, 프로파일링, 검사하듯이 원격에서 이 프로토콜을 사용하면 크롬으로 똑같은 일을 할 수 있다. 많은 도구가 이 프로토콜을 이용하고 있다.

이 프로토콜의 동작을 확인해보자.

macOS에서 bash를 쓴다면 설치된 크롬 카나리를 터미널에서 실행할 수 있도록 다음과 같이 별칭을 준다.

$ alias chrome-canary="/Applications/Google\ Chrome\ Canary.app/Contents/MacOS/Google\ Chrome\ Canary"
$ chrome-canary \
  --headless --disable-gpu \
  --remote-debugging-port=9222

[0824/223949.914930:WARNING:dns_config_service_posix.cc(156)] dns_config has unhandled options!

DevTools listening on ws://127.0.0.1:9222/devtools/browser/a2c2fe73-a8bc-49c8-84aa-61a251a7def9

헤드리스 모드로 크롬 카나리를 실행하고 원격 디버깅 포트를 9222로 실행하자 개발자 도구가 9222 포트를 리스닝하고 있는 것을 볼 수 있다. 이렇게 실행해 놓은 채로 터미널을 하나 더 열어서 이 디버깅 포트에 요청을 보내보자.

$ curl http://localhost:9222/json/version
{
   "Browser": "HeadlessChrome/62.0.3194.0",
   "Protocol-Version": "1.2",
   "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_5) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/62.0.3194.0 Safari/537.36",
   "V8-Version": "6.2.333",
   "WebKit-Version": "537.36 (@c14400a94cb4f43e0e9b02e59e93cf5a082c6408)",
   "webSocketDebuggerUrl": "ws://localhost:9222/devtools/browser/a2c2fe73-a8bc-49c8-84aa-61a251a7def9"
}

$ curl http://localhost:9222/json/list
[ {
   "description": "",
   "devtoolsFrontendUrl": "/devtools/inspector.html?ws=localhost:9222/devtools/page/e873e5cb-f1db-4892-87cf-5296fbc7485c",
   "id": "e873e5cb-f1db-4892-87cf-5296fbc7485c",
   "title": "about:blank",
   "type": "page",
   "url": "about:blank",
   "webSocketDebuggerUrl": "ws://localhost:9222/devtools/page/e873e5cb-f1db-4892-87cf-5296fbc7485c"
} ]

/json/version으로 요청을 보내면 현재 크롬의 정보를 볼 수 있고 /json/list를 이용하면 접속할 수 있는 Chrome Devtools Protocol 목록을 반환한다. 목록이 배열인 이유는 크롬에서 탭을 여러 개 열 수도 있기 때문이다. 각 클라이언트는 이 정보 등을 이용해서 Chrome Devtools Protocol을 구현하고 프로토콜에서 제공하는 객체로 크롬을 제어할 수 있다. DOM, Page, Network 등 세세하게 다룰 수 있게 모두 제공하는데 직접 클라이언트를 개발하는 게 아니라면 세부 내용을 다 알 필요는 없어 보인다. 필요할 때 위 문서를 참고해보는 정도로 충분하다.

chrome-remote-interface

Chrome DevTools Protocol을 직접 사용하는 것은 어려우므로 이미 존재하는 많은 라이브러리가 존재하는데 공식 문서에서 권장하는 chrome-remote-interface를 사용했다.

chrome-remote-interface를 설치하고(npm install chrome-remote-interface) 앞에서 작성한 launchChrome()를 이용해서 Chrome Devtools Protocol에 연결해보자. 현재 버전은 0.24.4다.

// chrome.js
const CDP = require('chrome-remote-interface');

// launchChrome 부분 생략

let launchedChrome = null;

const connectChrome = () => {
  return launchChrome()
    .then((chrome) => {
      launchedChrome = chrome;
      return CDP.Version({ port: launchedChrome.port });
    })
    .then(info => console.log('version: ', info))
    .then(() => CDP({ port: launchedChrome.port }))
    .then((client) => {
      console.log('client connected');
    });
};

connectChrome();

이를 실행하면 다음과 같이 실행된 크롬에 접속해서 정보를 가져오고(CDP.Version) 연결을 하는 것을 볼 수 잇다. 여기서 CDP({ port: launchedChrome.port }))에서 포트만 지정했는데 host를 지정하지 않으면 localhost를 사용한다.

node chrome.js
  ChromeLauncher:verbose created /var/folders/k8/j1j0fjkn5_vdzxybfqq0r7lc0000gn/T/lighthouse.XXXXXXX.rNE9ehBV +0ms
  ChromeLauncher:verbose Launching with command:
  ChromeLauncher:verbose "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary" --disable-translate --disable-extensions --disable-background-networking --safebrowsing-disable-auto-update --disable-sync --metrics-recording-only --disable-default-apps --mute-audio --no-first-run --remote-debugging-port=64165 --user-data-dir=/var/folders/k8/j1j0fjkn5_vdzxybfqq0r7lc0000gn/T/lighthouse.XXXXXXX.rNE9ehBV --headless --disable-gpu --no-sandbox --homedir=/tmp --data-path=/tmp/data-path --disk-cache-dir=/tmp/cache-dir --vmodule --single-process about:blank +528ms
  ChromeLauncher:verbose Chrome running with pid 29094 on port 64165. +16ms
  ChromeLauncher Waiting for browser. +0ms
  ChromeLauncher Waiting for browser... +0ms
  ChromeLauncher Waiting for browser..... +512ms
  ChromeLauncher Waiting for browser.....✓ +2ms
Headless Chrome launched with debugging port 64165
version:  { Browser: 'HeadlessChrome/62.0.3196.0',
  'Protocol-Version': '1.2',
  'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/62.0.3196.0 Safari/537.36',
  'V8-Version': '6.2.363',
  'WebKit-Version': '537.36 (@1bdf82f83c51a1f42672bc8e59494d238269fc6f)',
  webSocketDebuggerUrl: 'ws://localhost:64165/devtools/browser/87f5f38e-cae0-43c2-b595-d199d2c73d86' }
client connected

여기서 CDP({ port: launchedChrome.port }))가 반환한 client 객체는 Chrome DevTools Protocol에 있는 Page, Memory, Inspector, CSS, CacheStorage같은 객체가 있으므로 이를 이용해서 크롬을 조작하면 된다.

웹사이트 스크린샷 찍기

이제 준비가 다 되었으니 chrome-remote-interface를 이용해서 스크린샷을 찍을 차례다.

// chrome.js
const chromeLauncher = require('chrome-launcher');
const CDP = require('chrome-remote-interface');

// launchChrome 부분 생략

const connectChrome = () => {
  return launchChrome()
    .then((chrome) => {
      launchedChrome = chrome;
      return CDP.Version({ port: launchedChrome.port });
    })
    .then(info => console.log('version: ', info))
    .then(() => CDP({ port: launchedChrome.port }))
    .then((client) => {
      console.log('client connected');

      const { DOM, Emulation, Network, Page } = client;

      return Page.enable()
        .then(DOM.enable)
        .then(Network.enable)
        .then(() => {
          const deviceMetrics = {
            width: 1280,
            height: 1000,
            deviceScaleFactor: 0,
            mobile: false,
            fitWindow: false,
          };
          return Emulation.setDeviceMetricsOverride(deviceMetrics);
        })
        .then(() => Page.navigate({ url: 'https://github.com' }))
        .then(Page.loadEventFired)
        .then(() => Emulation.setVisibleSize({ width: 1280, height: 1000 }))
        .then(Page.captureScreenshot)
        .then(screenshot => new Buffer(screenshot.data, 'base64'))
        .catch((err) => {
          client.close();
          throw err;
        });
    })
    .catch((err) => {
      console.log(err);
      launchedChrome.kill();
      throw err;
    });
};

module.exports = {
  connectChrome,
};

위 코드가 CDP로 웹페이지를 띄워서 페이지 로딩이 끝나면 스크린샷을 찍어서 Buffer를 반환하는 코드이다. 간단히 설명하자면...

  • const { DOM, Emulation, Network, Page } = client;는 클라이언트에서 사용할 객체만 가져왔다.
  • Page.enable()에서 페이지를 활성화한다.
  • DOM.enable에서 해당 페이지에 DOM을 활성화한다.
  • Network.enable에서 해당 페이지의 네트워크 활동을 추적할 수 있게 한다.
  • Emulation.setDeviceMetricsOverride에서 디바이스의 스크린 설정을 지정한다. 여기서는 width, height를 지정하고(뒤에서 재지정한다.) 모바일 여부 등을 설정할 수 있는데 여기서는 특별한 설정은 하지 않았다.
  • Page.navigate에서 지정한 URL로 페이지를 이동한다.
  • Page.loadEventFired는 웹페이지에서 load 이벤트가 발생하면 실행된다.
  • Emulation.setVisibleSize에서는 스크린샷을 찍기 위해서 페이지의 viewport 크기를 지정한다. 문서를 보면 deprecated로 표시되고 있는데 chrome-remote-interface에서 아직 이 방법으로 안내하고 있어서 그대로 사용했다. 아직 다른 방법은 찾지 못했다.
  • Page.captureScreenshot에서는 현재 페이지의 스크린샷을 찍는다.
  • screenshot => new Buffer(screenshot.data, 'base64')에서는 Page.captureScreenshot가 반환한 base64 데이터를 Buffer로 생성해서 반환한다.

참고로 스크린샷을 찍는 작업을 하면서 여러 웹사이트를 한 번에 찍으려고 할 때 매번 크롬을 띄우고 DevTools Protocol에 붙을 필요가 없으므로 한번 연결해 놓고 URL만 변경하면서 계속 스크린샷을 찍으려고 할 때 Emulation.setVisibleSizePage.loadEventFired 이후에 하지 않으니까 처음 스크린샷만 지정한 사이즈로 찍히고 이후부터는 기본값인 800x600으로 찍히는 문제가 있었다. 이 문제가 Chrome의 버그인지 chrome-remote-interface의 버그인지는 아직 잘 모르겠다.

파일을 S3에 업로드하기

이 코드를 Lambda에서 실행할 것이므로 파일을 로컬에 저장할 수 없으므로 다른 스토리지에 저장해야 한다. 여기서는 S3에 저장하려고 아래와 같은 s3.js를 만들었다.

// s3.js
const AWS = require('aws-sdk');

const s3 = new AWS.S3({ apiVersion: '2006-03-01' });

module.exports = {
  uploadFile: (buffer) => {
    return new Promise((resolve, reject) => {
      const params = {
        Bucket: 'outsider-demo',
        Key: 'screenshot.png',
        Body: buffer,
      };
      return s3.putObject(params, (err) => {
        if (err) { return reject(err); }
        return resolve();
      });
    });
  },
};

aws-sdk를 이용해서 전달받은 Buffer를 S3 버킷(여기서는 outsider-demo)에 저장하는 Promise 코드이다. 예시를 간단하게 하려고 파일명 등은 하드 코딩했다.



이 글은 Headless Chrome으로 AWS Lambda에서 웹사이트 스크린샷 찍기 #2로 이어진다.

2017/08/26 23:02 2017/08/26 23:02