GROWI の特定のページを定期的にメール送信する (AWS Serverless Framework)

※2019年5月19日追記: プログラムをリファクタリング&特定階層下の複数記事送信に対応したコードを追加

オープンソースの Wiki システム [GROWI](https://growi.org/) の API を利用して、特定のページを定期的にメール送信してみます。

## 環境
- Windows 10 Pro 1809 / Windows Subsystem for Linux (Bash on windows) Ubuntu 16.04.4 LTS
    - たぶんMacでも一緒
- [TypeScript](https://www.typescriptlang.org/): 3.4.5
- [Serverless Framework](https://serverless.com/): 1.33.2
- [GROWI](https://growi.org/): 3.4.6
    - 予め [growi\-docker\-compose](https://github.com/weseek/growi-docker-compose) を使って [ConoHa VPS](https://www.conoha.jp/vps/) 上で起動しておいた GROWI を使います

また、AWSの下記サービスを利用します

- [AWS Lambda](https://aws.amazon.com/jp/lambda/): API取得とメール送信プログラムを実行します
- [Amazon SES](https://aws.amazon.com/jp/ses/): メールを送信します
- [Amazon CloudWatch](https://aws.amazon.com/jp/cloudwatch/): 今回は、Lambdaを定期的に実行するために利用します

### GROWI の準備
- 「ユーザー設定」→「API設定」→「現在のAPI Token」の値をメモしておく
- 「管理」→「セキュリティ設定」→「ゲストユーザーのアクセス」を「閲覧のみ許可」にしておく

GROWI には現在APIのドキュメントは無いようですが、どのような物があるかは[ルーティングファイル](https://github.com/weseek/growi/blob/v3.3.8/src/server/routes/index.js)から推測できます。今回用いる記事単体のAPIは `/_api/pages.get?access_token=(アクセストークン)&path=(パス)` というURLになります。実際にアクセスしてみると、ページの情報がJSONで取得できることを確認しておきます。GETなのでブラウザで直接叩くだけで良いです。

### AWS SES の利用準備
[E メールアドレスの検証 \- Amazon Simple Email Service](https://docs.aws.amazon.com/ja_jp/ses/latest/DeveloperGuide/verify-email-addresses-procedure.html) に沿って、メールアドレスを認証しておきます。

### Serverless Framework の利用準備
Serverless Framework をインストールしておきます。

```bash
$ npm install --global serverless
```

デプロイするために、 [Serverless Framework \- AWS Lambda Guide \- Credentials](https://serverless.com/framework/docs/providers/aws/guide/credentials/) に沿って認証情報を設定しておきます。必要に応じて [awslabs/git\-secrets](https://github.com/awslabs/git-secrets) もインストールしておきましょう。

## Serverlessプロジェクトの作成
準備ができたら、 Serverless のコードを書いていきます。

```bash
$ serverless create --template aws-nodejs-typescript --path growi-to-mail
$ cd growi-to-mail
$ npm install --save-dev
$ npm install --save aws-sdk
```
Git 管理する場合は、お好みで下記も実行しておきます。

```bash
$ git init
$ curl https://raw.githubusercontent.com/github/gitignore/master/Node.gitignore > .gitignore # お好みで
$ echo 'package-lock.json binary' >> .gitattributes # お好みで
$ git remote  add origin https://github.com/USERNAME/REPOSITORY.git # GitHubに上げるなら
```


### デプロイの設定
`serverless.yml` にデプロイ用の設定を記述していきます。

```serverless.yml
service:
  name: growi-to-mail

plugins:
  - serverless-webpack

provider:
  name: aws
  runtime: nodejs8.10
  region: us-west-2
  iamRoleStatements:
    - Effect: 'Allow'
      Action:
        - ses:SendEmail
      Resource: '*'

functions:
  main:
    handler: handler.main
    events:
      - schedule: cron(0/3 * * * ? *)
```
`schedule` の設定にはcron記法が利用できます。詳細は [Serverless Framework のドキュメント](https://serverless.com/framework/docs/providers/aws/events/schedule/)や [Amazon CloudWatch のドキュメント](https://docs.aws.amazon.com/ja_jp/AmazonCloudWatch/latest/events/ScheduledEvents.html#RateExpressions)を参照してください。

## 特定の単一ページをメール送信するプログラム
下記のようなプログラムで、GROWIの特定ページをメール送信できます。

```handler.ts
import * as AWS from 'aws-sdk';
import * as https from 'https';

const aws_region = 'us-west-2';
const mail_to = ['受信用E-mailアドレス'];
const mail_from = '送信用E-mailアドレス';
const growi_url = 'GROWIのURL 例: https://demo.growi.org/';
const growi_access_token = 'メモしたアクセストークン';
const growi_pages_path = '送信したいパス';

export const main = async (event, context) => {
  const ses = new AWS.SES({region: aws_region});
  const page = await getGrowiPage(growi_pages_path);

  return await ses.sendEmail({
    Source: mailFrom,
    Destination: { ToAddresses: mailTo },
    Message: {
      Subject: { Data: page['page']['path'] },
      Body: { Text: { Data: page['page']['revision']['body'] } }
    }
  }).promise();
}

function getGrowiPage(growi_pages_path: string) {
  return new Promise(function(resolve, reject) {
    const url = `${growi_url}_api/pages.get?access_token=${growi_access_token}&path=${growi_pages_path}`;

    https.get(url, (resp) => {
      let data = '';
      resp.on('data', (chunk) => { data += chunk; });
      resp.on('end', () => {
        resolve(JSON.parse(data));
      });
    });
  });
}

```

### デプロイ

```bash
$ serverless deploy
```
これで、上記設定なら3分に1度メールが飛んでくると思います。

また、デプロイしたlambdaは下記コマンドで即時実行できます。

```bash
$ serverless invoke --function main
```

### 片付け
下記コマンドで、デプロイしたlambda等を削除できます。

```bash
$ serverless remove
```

## 特定階層以下にあるすべてのページをメール送信するプログラム

`/_api/pages.list` APIを使うことで、指定した階層以下にあるすべてのページのパスが取得できます。取得したパスを`/_api/pages.get`で叩いていくことで、特定階層以下にあるすべてのページのメール送信が実現できます。


```index.ts
import * as AWS from 'aws-sdk';
import * as https from 'https';

const aws_region = 'us-west-2';
const mail_to = ['受信用E-mailアドレス'];
const mail_from = '送信用E-mailアドレス';
const growi_url = 'GROWIのURL 例: https://demo.growi.org/';
const growi_access_token = 'メモしたアクセストークン';
const growi_pages_path = '送信したいパス';

export const main = async (event, context) => {
  const ses = new AWS.SES({region: aws_region});
  const pageList = await getGrowiList(growi_pages_path);

  let contents = [];
  for (const pagePath of pageList['pages']) {
    // TODO: 環境のキャパシティに応じて調整してください
    const page = await getGrowiPage(pagePath['path']);
    contents.push(page['page']['revision']);
  }

  const title = `${growi_pages_path}の一覧`;
  const body = contents.reduce((accumulator, revision) => {
    const time = new Date(revision['createdAt']);
    // TODO: 記事のフォーマットは適当に変更してください
    const article = `
--------------------------------
${revision['path']}
--------------------------------
${time.getFullYear()}年${time.getMonth()}月${time.getDate()}日 ${revision['author']['name']}

${revision['body']}

url: ${growi_url}${revision['path']}
`;
    return `${accumulator}${article}`;
  }, `${title}
================================================================
`);

  return await ses.sendEmail({
    Source: mailFrom,
    Destination: { ToAddresses: mailTo },
    Message: {
      Subject: { Data: title },
      Body: { Text: { Data: body } }
    }
  }).promise();
}

function getGrowiAPI(api: ('pages.get' | 'pages.list'), growi_pages_path) {
  return new Promise(function(resolve, reject) {
    const url = `${growi_url}_api/${api}?access_token=${growi_access_token}&path=${growi_pages_path}`;

    http.get(url, (resp) => {
      let data = '';
      resp.on('data', (chunk) => { data += chunk; });
      resp.on('end', () => {
        resolve(JSON.parse(data));
      });
    });
  });
}

function getGrowiPage(growi_pages_path: string) {
  return getGrowiAPI('pages.get', growi_pages_path);
}
function getGrowiList(growi_pages_path: string) {
  return getGrowiAPI('pages.list', growi_pages_path);
}
```
APIを多く叩くことになるので、環境に応じて負荷を下げるためにsleepを入れたり、逆にスペックに余裕がある環境なら並列実行したりして調整してください。

また記事の量が多い場合、[AWS Lambdaのタイムアウト(現在は15分)](https://aws.amazon.com/jp/blogs/news/aws-lambda-timeout-15min/)に引っかかる可能性があります。その場合は最終的に、APIで取得した結果をS3に配置していくLambdaと、完成したS3からメールを送信するLambdaに分ける、といった事が必要になるのではないかと思います。

## 参考
- [LambdaからSESを呼んでメール送信をサクッと実装してみた ｜ DevelopersIO](https://dev.classmethod.jp/cloud/aws/lambda-to-ses/)
- [\[小ネタ\]Serverless FrameworkでLambdaを定期実行させる ｜ DevelopersIO](https://dev.classmethod.jp/etc/serverless-framework-lambda-cron-execute/)
- [Crowi/Growi編集用のツールを作成する](https://kapibara-sos.net/archives/456)
- [【初心者向け】AWS\+TypeScriptで、AIを使って画像にラベル付けをするサーバレスアプリケーションを作ってみよう \- Qiita](https://qiita.com/suzuki_sh/items/8ec558349c66899dd5f3)