LoginSignup
1
0

More than 3 years have passed since last update.

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

Last updated at Posted at 2019-12-18

はじめに

ちょっと前に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も使ってみたいところ。

参考サイト

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0