Yahoo天気をスクレイピングしてSlackに通知させる

# はじめに
ちょっと前に[Yahoo天気](https://weather.yahoo.co.jp/weather/13/4410.html)の指数情報に洗濯や熱中症などに混じって、
ビールが追加されて話題になってました。
数ある天気予報の中でビールの指数を出しているところは見たことがなかったので、
Yahoo天気の予報をSlackに通知して毎朝確認しよう！と思ったのが今回の話。
APIを提供してくれていれば楽だったんだけど、
無さそうだったのでスクレイピングして無理やり取ってくることにする。

今回はtypescriptでサクッと作ったものをAWSに乗せて使うことにする。

# TL;DR.
[コード](https://github.com/Tetsuya-Minase/inform-weather-forecast)

# 事前準備
## コード側
今回使うライブラリなどをinstallしておく。

``` shell
# 今回使うlibrart(httpリクエスト用、HTMLパーサ)
$ npm install --save request jsdom
# 型定義(好みで)
$ npm install --save-dev @types/request @types/jsdom
# その他
# TSとかeslintとかwebpackとかetc...
```

## Slack側
[ここ](https://slack.com/services/new/incoming-webhook)からwebhook許可しとく

### 追加手順
プルダウンから通知を送りたいチャンネルを指定してAdd Incoming WebHooks integrationをクリック。
(もしかしたら日本語表記だと違うかも)
`Setup Instructions`にある`Webhook URL`をコピーしておく。

これでSlack側の設定は完了

## AWS側
[IAM](https://aws.amazon.com/jp/iam/)からユーザを作成する。
アクセス権限にで`AdministratorAccess`を設定すれば他は何でもOK(のはず)。
アクセスキーIDとシークレットアクセスキーをコピっておく。

## serverless framework側
[公式のドキュメント](https://serverless.com/framework/docs/getting-started/)があるので、これを参考にしつつインストールとこから始める。

``` sh
# cli インストール.
# node6以上じゃないとダメそう
$ npm install -g serverless
  
# インストールできているか確認。バージョンが出てくればOK。
$ serverless --version
  
# awsの設定をする
# デフォルトaliasが設定されているので、そっちを使う。
# $ACCESS_KEYと$SECRET_KEYは先程取得したやつに変える
$ sls config credentials --provider aws --key $ACCESS_KEY --secret $SECRET_KEY
  
# 後々使いたいpluginをインストールしておく
$ npm install --save-dev serverless-offline serverless-webpack
```

# 実装
[実践ドメイン駆動設計](https://www.amazon.co.jp/dp/B00UX9VJGW/ref=dp-kindle-redirect?_encoding=UTF8&btkr=1)をレイヤードアーキテクチャの辺りまで読んだので、
レイヤードアーキテクチャ的に実装していくことにする。
＋typescriptでDIをやるために`inversify`を入れる。
必須ではないので必要であれば追加でインストール(`npm install —-save inversify`)。

## Interface
とりあえず叩かれる用。
受け取るパラメータも特にないので、ただapplication層を呼び出すだけ。

``` typescript
import { WeatherNewsService } from '../application/weather-news-service';
import { inject, injectable } from 'inversify';
import { TYPES } from '../inversify.types';
import { DATE } from '../domain/model/weather-forecast-model';
  
@injectable()
export class WeatherNews {
  constructor(
    @inject(TYPES.WeatherNewsService)
    private readonly weatherNewsService: WeatherNewsService
  ) {}
  
  /**
   * 天気予報を取得する
    * @param date 取得する日
    */
  public async informWeatherNews(date: DATE) {
    switch (date) {
      case DATE.TODAY:
        await this.weatherNewsService.informTodayWeatherInfo();
        return;
      case DATE.TOMORROW:
        await this.weatherNewsService.informTomorrowWeatherInfo();
        return;
      default:
        throw new Error(`${date} is invalid date.`);
    }
  }
}
```

## Application
Domain使ってるところ。
Domデータ取ってくる→欲しいデータに整形する→Slackへ通知の流れ。

``` typescript
import { ScrapingService } from '../../domain/service/scraping-service';
import { ConverterService } from '../../domain/service/converter-service';
import { WeatherNewsService } from '../weather-news-service';
import { InformSlackService } from '../../domain/service/inform-slack-service';
import { injectable, inject } from 'inversify';
import { TYPES } from '../../inversify.types';
import { WEATHER_FORECAST_AT_TOKYO } from '../../config/constant';
import 'reflect-metadata';
import {
DATE,
INDEX,
TEMPERATURE,
WeatherDate
} from '../../domain/model/weather-forecast-model';
  
@injectable()
export class WeatherNewsServiceImpl implements WeatherNewsService {
  private weatherNewsUrl = WEATHER_FORECAST_AT_TOKYO;

  constructor(
    @inject(TYPES.ScrapingService)
    private readonly scrapingService: ScrapingService,
    @inject(TYPES.ConverterService)
    private readonly converterService: ConverterService,
    @inject(TYPES.InformSlackService)
    private readonly informSlackService: InformSlackService
  ) {}

  public async informTodayWeatherInfo(): Promise<void> {
    const domData = await this.scrapingService.fetchDomData(
      this.weatherNewsUrl
    );
    // 指標取得
    const indexList: NodeListOf<
      Element
    > = domData.window.document.querySelectorAll('.indexList_item');
    const indexMap: Map<
      INDEX,
      string
    > = this.converterService.indexDomDataFormatter(indexList, DATE.TODAY);
    // 天気取得
    const weatherList: NodeListOf<
      Element
    > = domData.window.document.querySelectorAll('.pict');
    // 日付取得
    const dateList: NodeListOf<
      Element
    > = domData.window.document.querySelectorAll('.tabView_item');
    const weatherDateMap: Map<
      DATE,
      WeatherDate
    > = this.converterService.weatherDomDataFormatter(weatherList, dateList);
    // 気温取得
    const temperatureList: NodeListOf<
      Element
    > = domData.window.document.querySelectorAll('.temp');
    const temperatureMap: Map<
      TEMPERATURE,
      string
    > = this.converterService.temperatureDomDataFormatter(
      temperatureList,
      DATE.TODAY
    );

    const detailData = this.converterService.toDetailInformation(
      indexMap,
      weatherDateMap,
      temperatureMap,
      DATE.TODAY
    );
    await this.informSlackService.informMessage(
      `<!channel>\n${detailData.toString()}`
    );
  }

  public async informTomorrowWeatherInfo(): Promise<void> {
    const domData = await this.scrapingService.fetchDomData(
      this.weatherNewsUrl
    );
    // 指標取得
    const indexList: NodeListOf<
      Element
    > = domData.window.document.querySelectorAll('.indexList_item');
    const indexMap: Map<
      INDEX,
      string
    > = this.converterService.indexDomDataFormatter(indexList, DATE.TOMORROW);
    // 天気取得
    const weatherList: NodeListOf<
      Element
    > = domData.window.document.querySelectorAll('.pict');
    // 日付取得
    const dateList: NodeListOf<
      Element
    > = domData.window.document.querySelectorAll('.tabView_item');
    const weatherDateMap: Map<
      DATE,
      WeatherDate
    > = this.converterService.weatherDomDataFormatter(weatherList, dateList);
    // 気温取得
    const temperatureList: NodeListOf<
      Element
    > = domData.window.document.querySelectorAll('.temp');
    const temperatureMap: Map<
      TEMPERATURE,
      string
    > = this.converterService.temperatureDomDataFormatter(
      temperatureList,
      DATE.TOMORROW
    );

    const detailData = this.converterService.toDetailInformation(
      indexMap,
      weatherDateMap,
      temperatureMap,
      DATE.TOMORROW
    );
    console.log('detail tomorrow: ', detailData.toString());
    await this.informSlackService.informMessage(
      `<!channel>\n${detailData.toString()}`
    );
  }
}
```

## Domain
### ScrapingService
サイトにGet飛ばしてhtml取ってくるところ。
取ってきたデータを`jsdom`を使ってパースして返す。

``` typescript
import { HttpRequest } from '../../../infrastructure/http-request';
import { JSDOM } from 'jsdom';
import { ResponseError } from '../../model/request-types';
import { ScrapingService } from '../scraping-service';
import { inject, injectable } from 'inversify';
import 'reflect-metadata';
import { TYPES } from '../../../inversify.types';
import axios from 'axios';

@injectable()
export class ScrapingServiceImpl implements ScrapingService {
  constructor(
    @inject(TYPES.HttpRequest) private readonly httpRequest: HttpRequest
  ) {}

  public async fetchDomData(url: string): Promise<JSDOM> {
    const response = await axios.get<string>(url);
    if (response.status !== 200) {
      console.error('scraping error: ', response.data);
      throw new ResponseError(
        response.status,
        response.data,
        response.statusText
      );
    }
    return new JSDOM(response.data);
  }
}
```

### ConverterService
パースしたデータを使いやすいように整形するところ。
整形したり、必要なデータを取り出したりしている。

ゴリ押しに次ぐゴリ押しで実装してしまったので、
もうちょい綺麗にしたい。。。

``` typescript
import { ConverterService } from '../converter-service';
import { injectable } from 'inversify';
import {
  DATE,
  DetailInformation,
  getIndexFromText,
  INDEX,
  TEMPERATURE,
  WeatherDate
} from '../../model/weather-forecast-model';

@injectable()
export class ConverterServiceImpl implements ConverterService {
  public indexDomDataFormatter(
    domList: NodeListOf<Element>,
    date: DATE
  ): Map<INDEX, string> {
    const resultMap = new Map<INDEX, string>();
    domList.forEach(dom => {
      if (dom.textContent === null) {
        return;
      }
      const data = this.textSplitter(dom.textContent);
      const indexKey = getIndexFromText(data[0]);
      // keyなし or スキップ
      if (indexKey === undefined || this.isSkip(date, indexKey, resultMap)) {
        return;
      }
      resultMap.set(indexKey, data[2]);
    });
    return resultMap;
  }

  public weatherDomDataFormatter(
    weatherDomList: NodeListOf<Element>,
    dateDomList: NodeListOf<Element>
  ): Map<DATE, WeatherDate> {
    const resultMap = new Map<DATE, WeatherDate>();
    const weatherList: string[] = [];
    const dateList: string[] = [];
    weatherDomList.forEach(dom => {
      if (dom.textContent === null) {
        return;
      }
      const data = this.textSplitter(dom.textContent);
      weatherList.push(...data);
    });
    dateDomList.forEach(dom => {
      if (dom.textContent === null) {
        return;
      }
      const data = this.textSplitter(dom.textContent);
      dateList.push(...data);
    });
    resultMap.set(DATE.TODAY, {
      weather: weatherList[0],
      date: dateList[0]
    });
    resultMap.set(DATE.TOMORROW, {
      weather: weatherList[1],
      date: dateList[1]
    });
    return resultMap;
  }

  public temperatureDomDataFormatter(
    domList: NodeListOf<Element>,
    date: DATE
  ): Map<TEMPERATURE, string> {
    const resultMap = new Map<TEMPERATURE, string>();
    const tempList: string[] = [];
    domList.forEach(dom => {
      if (dom.textContent === null) {
        return;
      }
      const data = this.textSplitter(dom.textContent);
      tempList.push(...data);
    });

    switch (date) {
      case DATE.TODAY:
        resultMap.set(TEMPERATURE.MAX, tempList[0]);
        resultMap.set(TEMPERATURE.MIN, tempList[1]);
        break;
      case DATE.TOMORROW:
        resultMap.set(TEMPERATURE.MAX, tempList[2]);
        resultMap.set(TEMPERATURE.MIN, tempList[3]);
        break;
      default:
      // 何もしない
    }
    return resultMap;
  }

  public toDetailInformation(
    indexMap: Map<INDEX, string>,
    weatherDateMap: Map<DATE, WeatherDate>,
    temperatureMap: Map<TEMPERATURE, string>,
    date: DATE
  ): DetailInformation {
    const weatherDate: WeatherDate | undefined = weatherDateMap.get(date);

    return new DetailInformation(
      (weatherDate && weatherDate.date) || undefined,
      (weatherDate && weatherDate.weather) || undefined,
      temperatureMap.get(TEMPERATURE.MAX),
      temperatureMap.get(TEMPERATURE.MIN),
      indexMap.get(INDEX.WASHING),
      indexMap.get(INDEX.UMBRELLA),
      indexMap.get(INDEX.UV),
      indexMap.get(INDEX.LAYERING),
      indexMap.get(INDEX.DRY),
      indexMap.get(INDEX.COLD),
      indexMap.get(INDEX.HEATSTROKE),
      indexMap.get(INDEX.BEER)
    );
  }

  private textSplitter(text: string): string[] {
    return text
      .replace(/ /g, '')
      .split('\n')
      .filter(r => r !== '');
  }

  /**
   * 重複したときスキップするかどうか
    * @param date 取得対象が強化
    * @param key マップkey
    * @param map マップ
    */
  private isSkip(date: DATE, key: INDEX, map: Map<INDEX, string>): boolean {
    // 取得対象が今日の時は重複スキップする
    return date === DATE.TODAY && map.has(key);
  }
}
```

### InformSlackService
Slcakへ通知するところ。
送るデータを受け取って、Slackのwebhookで送れる形にしてあげる。

``` typescript
import { HttpRequest } from '../../../infrastructure/http-request';
import { InformSlackService } from '../inform-slack-service';
import { inject, injectable } from 'inversify';
import 'reflect-metadata';
import { TYPES } from '../../../inversify.types';
import { RequestParams } from '../../model/request-types';

@injectable()
export class InformSlackServiceImpl implements InformSlackService {
private slackUrl = process.env.WEBHOOK;

constructor(
  @inject(TYPES.HttpRequest) private readonly httpRequest: HttpRequest
) {}

public async informMessage(message: string) {
  const param: RequestParams = {
    url: this.slackUrl || '',
    data: {
      channel: '#weather',
      username: 'webhookbot',
      text: message
    }
  };
  console.info('request param:', param);
  await this.httpRequest.post(param);
}
}
```
## Infrastructure
### HttpRequest
`axios`をラップしているだけ。
ここももう少し綺麗にしたいところ。
200以外がerrorはだいぶ乱暴だけど、今回は用途が限られているので許容する。

``` typescript
import {
  ResponseSuccess,
  RequestParams,
  ResponseError
} from '../../domain/model/request-types';
import { HttpRequest } from '../http-request';
import { injectable } from 'inversify';
import axios from 'axios';

@injectable()
export class HttpRequestImpl implements HttpRequest {
  private header = { 'Content-Type': 'application/json' };

  public async get(url: string): Promise<ResponseSuccess> {
    const response = await axios.get<string>(url);
    if (response.status !== 200) {
      console.error('get error: ', response.data);
      throw new ResponseError(
        response.status,
        response.data,
        response.statusText
      );
    }
    return {
      status: response.status,
      data: response.data
    };
  }

  public async post(param: RequestParams): Promise<ResponseSuccess> {
    if (param.url === '') {
      throw new ResponseError(400, param.url, 'BadRequest');
    }

    const response = await axios.post(param.url, param.data, {
      headers: this.header
    });
    if (response.status !== 200) {
      console.error('post error:', response.data);
      throw new ResponseError(
        response.status,
        response.data,
        response.statusText
      );
    }
    return {
      status: response.status,
      data: response.data
    };
  }
}
```

## inversify用設定
[inversifyのGithub](https://github.com/inversify/InversifyJS)を参考にしながら設定を進める。

### inversify.config.ts
interfaceと実装を紐づける設定。
DIコンテナ的な設定の認識。

``` typescript
import { Container } from 'inversify';
import { TYPES } from './inversify.types';
import { WeatherNewsService } from './application/weather-news-service';
import { WeatherNewsServiceImpl } from './application/impl/weather-news-service-impl';
import { ConverterService } from './domain/service/converter-service';
import { ConverterServiceImpl } from './domain/service/impl/converter-service-impl';
import { InformSlackService } from './domain/service/inform-slack-service';
import { InformSlackServiceImpl } from './domain/service/impl/inform-slack-service-impl';
import { ScrapingService } from './domain/service/scraping-service';
import { ScrapingServiceImpl } from './domain/service/impl/scraping-service-impl';
import { HttpRequest } from './infrastructure/http-request';
import { HttpRequestImpl } from './infrastructure/impl/http-request-impl';
import { WeatherNews } from './interface/weather-news';

const container = new Container();
container
.bind<WeatherNewsService>(TYPES.WeatherNewsService)
.to(WeatherNewsServiceImpl)
.inSingletonScope();
container
.bind<ConverterService>(TYPES.ConverterService)
.to(ConverterServiceImpl)
.inSingletonScope();
container
.bind<InformSlackService>(TYPES.InformSlackService)
.to(InformSlackServiceImpl)
.inSingletonScope();
container
.bind<ScrapingService>(TYPES.ScrapingService)
.to(ScrapingServiceImpl)
.inSingletonScope();
container
.bind<HttpRequest>(TYPES.HttpRequest)
.to(HttpRequestImpl)
.inSingletonScope();
container
.bind<WeatherNews>(TYPES.WeatherNews)
.to(WeatherNews)
.inSingletonScope();

export { container };
```

### inversify.type.ts
実行時に識別子が必要なので、宣言しておく。
公式にならって`Symbol`を使っているが、クラスでも文字列でもOKだそう。

``` typescript
const TYPES = {
  WeatherNewsService: Symbol.for('WeatherNewsService'),
  ConverterService: Symbol.for('ConverterService'),
  InformSlackService: Symbol.for('InformSlackService'),
  ScrapingService: Symbol.for('ScrapingService'),
  HttpRequest: Symbol.for('HttpRequest'),
  WeatherNews: Symbol.for('WeatherNews')
};

export { TYPES };
```

# デプロイする
`serverles.yml`に設定を書いた上で、`sls deploy`でデプロイできる。
今回作成した`serverless.yml`は下記の通り。

``` yaml
service:
  name: weather-forecast-aws
  
plugins:
  # webpack使うようのプラグイン
  - serverless-webpack
  # ローカルで実行するためのプラグイン
  - serverless-offline
  
provider:
  name: aws
  runtime: nodejs12.6
  # リージョンを東京に変更
  region: ap-northeast-1
  environment:
  # 環境変数設定(デプロイ時に引数で渡す)
  WEBHOOK: ${opt:webhook}
    custom:
      webpackIncludeModules: true
  functions:
    weather-forecast-today:
      # 叩くメソッド。
      handler: app.weatherForecastToday
      events:
        # cron設定。jstじゃないので9時間引いた時間を設定
        - schedule: cron(30 22 * * ? *)
    weather-debug-today:
      handler: app.weatherForecastToday
      events:
          # httpリクエスト受ける用設定。debug用に設定しておく
          - http:
              method: get
              path: debugtoday
```

これでデプロイできる用になったので実際にやってみる。

``` sh
# デプロイしてみる
# slackのwebhookを使いたいので、値を渡してあげる
$ sls deploy --WEBHOOK=webhook_hoge
  
# デプロイできたらcurlを叩いてみる。エラーなく動けばOK
$ curl localhost:3000/debugtoday
```

# まとめ
簡単に作って上げてみた。
おそらくコードはまだキレイにできるはずなので、
気が向いたらバージョンアップしていきたい。
cloud run or cloud functionsも使ってみたいところ。

# 参考サイト
- [Yahoo天気](https://weather.yahoo.co.jp/weather/)
- [inversify](https://github.com/inversify/InversifyJS)
- [serverless framework公式](https://serverless.com/)
  - [serverless framework](https://serverless.com/framework/docs/)ドキュメント