AWS Lambda + Typescript + PuppeteerでWebスクレイピング

# AWS Lambda + Typescript + PuppeteerでWebスクレイピング

## 前提条件

- 開発環境
    - Windows10
    - Node.js 12.14.0-x64
    - AWS CLI
    - VS Code
- AWSアカウント
- IAMユーザー※

※IAMユーザーはServerlessFrameworkのリソース作成に必要な権限が付与されていること（今回はAdministratorAccessを使用）、AWS CLIに`--profile serverless`で設定していることを前提とする

## Serverless Framework のプロジェクト作成

AWSリソースの管理に[Serverless Framework](https://serverless.com/)を使います。AWS Lambdaを使う上で必要な面倒くさいことを大体やってくれます。すごい。

### Serverless Frameworkのインストール

サービス作成コマンドを使うためにグローバルインストールします。
どうしてもグローバルに入れたくない人は、後述するファイル群を自作しても良いです。

```
> npm install -g serverless

> serverless --version
Framework Core: 1.60.4
Plugin: 3.2.6
SDK: 2.2.1
Components Core: 1.1.2
Components CLI: 1.4.0
```

### サービスの作成
`aws-nodejs-typescript`でサービスを作成します。

```
> serverless create --template aws-nodejs-typescript --name scraping-service --path scraping-service
```

以下のファイルが作成されます。

```
scraping-service
|-.vscode ※vscode(ドット無し)で作成されるので、先頭にドットを追加する
| `-launch.json
|-.gitignore
|-handler.ts
|-package.json
|-serverless.yml
|-tsconfig.json
`-webpack.config.js
```

### グローバルモジュールの削除

```
> npm remove -g serverless
```

## 各種設定の調整

### .gitignore

そのまま使います。

```text:.gitignore
# package directories
node_modules
jspm_packages

# Serverless directories
.serverless

# Webpack directories
.webpack
```

### Serverless Framework

- NPMモジュールをLambda Layerに入れるための設定
    - `includeModules: false`
    - `plugins:`に`serverless-layers`を追加
    - `deploymentBucket:`にS3バケット名を指定 ※S3バケットは事前に作成します
- テンプレートでAPIGatewayのエンドポイントが設定されているので削除
- `stage:`を引数から受け取れるように設定
- `profile:`にAWS CLIのprofile名を設定
- `region:`にデプロイ先のリージョンを設定
- `functions:`に関数定義`execute:`を追加して、メモリサイズとタイムアウト時間を設定

```yaml:serverless.yml
service:
  name: scraping-service
# app and org for use with dashboard.serverless.com
#app: your-app-name
#org: your-org-name

custom:
  webpack:
    webpackConfig: ./webpack.config.js
    includeModules: false

# Add the serverless-webpack plugin
plugins:
  - serverless-layers
  - serverless-webpack

provider:
  name: aws
  stage: ${opt:stage, 'dev'}
  profile: serverless
  region: ap-northeast-1
  runtime: nodejs12.x
  #apiGateway:
  #  minimumCompressionSize: 1024 # Enable gzip compression for responses > 1 KB
  environment:
    AWS_NODEJS_CONNECTION_REUSE_ENABLED: 1
  deploymentBucket:
      name: scraping-service-deploy # 事前にS3バケットを作成してバケット名を指定

functions:
  #hello:
  #  handler: handler.hello
  #  events:
  #    - http:
  #        method: get
  #        path: hello
  execute:
    handler: handler.execute
    memorySize: 1024
    timeout: 30
```

### NPMモジュール

- 必要なパッケージを追加。Chromeは[chrome-aws-lambda](https://github.com/alixaxel/chrome-aws-lambda)を使用します。
    - `npm install chrome-aws-lambda`
    - `npm install puppeteer-core`
    - `npm install -D @types/puppeteer-core`
    - `npm install -D serverless`
    - `npm install -D serverless-layers`
- scriptsにローカル実行用のコマンドを追加します。

※chrome-aws-lambdaでインストールされるバイナリがWindowsでうまく動かないので、ローカル実行は普通にインストールしたChromeで代替しています。WindowsとLambda(AmazonLinux?)でうまく両用できるパッケージがあれば切り替えたいところ。

```json:package.json
{
  "name": "scraping-service",
  "version": "1.0.0",
  "description": "Serverless webpack example using Typescript",
  "main": "handler.js",
  "scripts": {
    "local": "node ./node_modules/serverless/bin/serverless invoke local -f execute",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "dependencies": {
    "chrome-aws-lambda": "^2.0.1",
    "puppeteer-core": "^2.0.0",
    "source-map-support": "^0.5.10"
  },
  "devDependencies": {
    "@types/aws-lambda": "^8.10.17",
    "@types/node": "^10.12.18",
    "@types/puppeteer-core": "^2.0.0",
    "fork-ts-checker-webpack-plugin": "^3.0.1",
    "serverless": "^1.60.4",
    "serverless-layers": "^1.4.3",
    "serverless-webpack": "^5.2.0",
    "ts-loader": "^5.3.3",
    "typescript": "^3.2.4",
    "webpack": "^4.29.0",
    "webpack-node-externals": "^1.7.2"
  },
  "author": "The serverless webpack authors (https://github.com/elastic-coders/serverless-webpack)",
  "license": "MIT"
}
```

### Typescript

そのまま使います。

```json:tsconfig.json
{
  "compilerOptions": {
    "lib": ["es2017"],
    "removeComments": true,
    "moduleResolution": "node",
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "sourceMap": true,
    "target": "es2017",
    "outDir": "lib"
  },
  "include": ["./**/*.ts"],
  "exclude": [
    "node_modules/**/*",
    ".serverless/**/*",
    ".webpack/**/*",
    "_warmup/**/*",
    ".vscode/**/*"
  ]
}
```

### Webpack

- source-mapの設定を調整

```javascript:webpack.config.js
const path = require('path');
const slsw = require('serverless-webpack');
const nodeExternals = require('webpack-node-externals');
const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin');

module.exports = {
  context: __dirname,
  mode: slsw.lib.webpack.isLocal ? 'development' : 'production',
  entry: slsw.lib.entries,
  devtool: slsw.lib.webpack.isLocal ? 'inline-source-map' : 'source-map',
  resolve: {
    extensions: ['.mjs', '.json', '.ts'],
    symlinks: false,
    cacheWithContext: false,
  },
  output: {
    libraryTarget: 'commonjs',
    path: path.join(__dirname, '.webpack'),
    filename: '[name].js',
  },
  target: 'node',
  externals: [nodeExternals()],
  module: {
    rules: [
      // all files with a `.ts` or `.tsx` extension will be handled by `ts-loader`
      {
        test: /\.(tsx?)$/,
        loader: 'ts-loader',
        exclude: [
          [
            path.resolve(__dirname, 'node_modules'),
            path.resolve(__dirname, '.serverless'),
            path.resolve(__dirname, '.webpack'),
          ],
        ],
        options: {
          transpileOnly: true,
          experimentalWatchApi: true,
        },
      },
    ],
  },
  plugins: [
    // new ForkTsCheckerWebpackPlugin({
    //   eslint: true,
    //   eslintOptions: {
    //     cache: true
    //   }
    // })
  ],
};
```

## NPMモジュールのインストール

```
> npm install
```

※この時点で新しいバージョンが出ているモジュールが存在するので、必要に応じで`npm outdated`で確認してバージョンアップしましょう。

## Lambda関数

### ハンドラ

```typescript:handler.ts
import 'source-map-support/register';
import { Handler } from 'aws-lambda';
import * as chromium from 'chrome-aws-lambda';

export const execute: Handler = async (_event, _context) => {
  let result;
  let browser;

  try {
    // chrome-aws-lambda でインストールされるバイナリがWindowsで動かないため
    // ローカルで実行する際は通常インストールしたChromeを代替とする
    browser = await chromium.puppeteer.launch({
      args: chromium.args,
      ignoreDefaultArgs: process.env.IS_LOCAL ? ['--single-process'] : [],
      defaultViewport: chromium.defaultViewport,
      executablePath: process.env.IS_LOCAL ?
        'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe' : await chromium.executablePath,
      headless: chromium.headless, // ここをfalseにするとChromeのウインドウが表示されるので、開発時にうまくいかない場合は変更すると良いです
    });

    const page = await browser.newPage();

    await page.goto('https://example.com');

    result = await page.title();
  } finally {
    if (browser) {
      await browser.close();
    }
  }

  return {
    statusCode: 200,
    body: JSON.stringify({
      message: `Title: ${result}`,
    }, null, 2),
  };
}
```

### 実行

```
> npm run local
...
{
    "statusCode": 200,
    "body": "{\n  \"message\": \"Title: Example Domain\"\n}"
}
```

## デバッグ

VS Codeでデバッグ > 構成を開く

```json:launch.json
{
  "configurations": [
    {
      "name": "Lambda",
      "type": "node",
      "request": "launch",
      "runtimeArgs": ["--inspect"],
      "program": "${workspaceFolder}/node_modules/serverless/bin/serverless",
      "args": ["invoke", "local", "-f", "execute", "-d", "{}"],
      "outFiles": ["${workspaceFolder}/.webpack/service/*"]
    }
  ]
}
```

ブレークポイントを設定してF5でデバッグ実行

## デプロイ

デフォルトでは`dev`ステージにデプロイされます。
本番と分けたい場合は`-s`オプションでステージを指定できます。

```
> npx sls deploy -v
```

CloudFormationを利用して必要なリソースが作成されます。らくちん。

## AWS上で実行

こちらも同じく`-s`オプションでステージを指定できます。

```
> npx sls invoke -f execute
{
    "statusCode": 200,
    "body": "{\n  \"message\": \"Title: Example Domain\"\n}"
}
```

CloudWatchで実行ログを確認できます。

## おわり
最後に不要な課金が発生しないようにAWSリソースを削除しましょう。

```
> npx sls remove
```

実際のスクレイピング処理が完成したら、CloudWatch Eventsのスケジュールなり、SNS Topicなりをトリガーに設定しましょう。
一部は`serverless.yml`でも設定できます。
更新はデプロイコマンドを実行するだけ。