はじめに
ちょっと前にYahoo天気の指数情報に洗濯や熱中症などに混じって、
ビールが追加されて話題になってました。
数ある天気予報の中でビールの指数を出しているところは見たことがなかったので、
Yahoo天気の予報をSlackに通知して毎朝確認しよう!と思ったのが今回の話。
APIを提供してくれていれば楽だったんだけど、
無さそうだったのでスクレイピングして無理やり取ってくることにする。
今回はtypescriptでサクッと作ったものをAWSに乗せて使うことにする。
TL;DR.
事前準備
コード側
今回使うライブラリなどをinstallしておく。
# 今回使うlibrart(httpリクエスト用、HTMLパーサ)
$ npm install --save request jsdom
# 型定義(好みで)
$ npm install --save-dev @types/request @types/jsdom
# その他
# TSとかeslintとかwebpackとかetc...
Slack側
ここからwebhook許可しとく
追加手順
プルダウンから通知を送りたいチャンネルを指定してAdd Incoming WebHooks integrationをクリック。
(もしかしたら日本語表記だと違うかも)
Setup Instructions
にあるWebhook URL
をコピーしておく。
これでSlack側の設定は完了
AWS側
IAMからユーザを作成する。
アクセス権限にでAdministratorAccess
を設定すれば他は何でもOK(のはず)。
アクセスキーIDとシークレットアクセスキーをコピっておく。
serverless framework側
公式のドキュメントがあるので、これを参考にしつつインストールとこから始める。
# 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
実装
実践ドメイン駆動設計をレイヤードアーキテクチャの辺りまで読んだので、
レイヤードアーキテクチャ的に実装していくことにする。
+typescriptでDIをやるためにinversify
を入れる。
必須ではないので必要であれば追加でインストール(npm install —-save inversify
)。
Interface
とりあえず叩かれる用。
受け取るパラメータも特にないので、ただapplication層を呼び出すだけ。
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へ通知の流れ。
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
を使ってパースして返す。
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
パースしたデータを使いやすいように整形するところ。
整形したり、必要なデータを取り出したりしている。
ゴリ押しに次ぐゴリ押しで実装してしまったので、
もうちょい綺麗にしたい。。。
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で送れる形にしてあげる。
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はだいぶ乱暴だけど、今回は用途が限られているので許容する。
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を参考にしながら設定を進める。
inversify.config.ts
interfaceと実装を紐づける設定。
DIコンテナ的な設定の認識。
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だそう。
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
は下記の通り。
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
これでデプロイできる用になったので実際にやってみる。
# デプロイしてみる
# slackのwebhookを使いたいので、値を渡してあげる
$ sls deploy --WEBHOOK=webhook_hoge
# デプロイできたらcurlを叩いてみる。エラーなく動けばOK
$ curl localhost:3000/debugtoday
まとめ
簡単に作って上げてみた。
おそらくコードはまだキレイにできるはずなので、
気が向いたらバージョンアップしていきたい。
cloud run or cloud functionsも使ってみたいところ。