スーパーの5%OFF開催日をiCal形式で配信してGoogleカレンダーに表示してみた

# はじめに

うちの最寄りのスーパー（西友）は月に何日か5%OFFの日があるのですが、規則性がよく分からなくていつも忘れます。

[西友 - 5%OFF開催日カレンダー](https://www.seiyu.co.jp/service/5off/)
毎月5日、20日が開催日と言いつつ、13日も対象だったりしてよく分かりません。

公式でiCalendar等が配信されていれば嬉しいのですが、なさそうだったので自作APIを作成し、Googleカレンダーに表示されるようにしてみました。

# 使った技術など
- Webスクレイピング: スーパーのWebサイトからセール日を取得する
- iCalendar形式: Googleカレンダーに取り込むための仕様 →[wiki](https://ja.wikipedia.org/wiki/ICalendar)
- AWS
  - Lambda: 取得したセール日をiCal形式に加工する関数
  - API Gateway: iCalデータを配信するエンドポイント
- IDE: Visual Studio Community 2017
  - 言語: C#
  - フレームワーク: .NET Core 2.0
  - プラグイン: AWS Toolkit for Visual Studio

# 実装
## セール日の取得（Webスクレイピング）

- 使用しているNuGetパッケージ
  - AngleSharp　: スクレイピング処理の簡略化

```csharp:
using AngleSharp;
...
namespace SeiyuSale
{
    public class WebScraper
    {
        public async Task<IEnumerable<SaleDay>> GetSaleDaysAsync()
        {
            // 対象ページを読み込み
            const string _targetUrl = "https://www.seiyu.co.jp/service/5off/";
            var config = Configuration.Default.WithDefaultLoader();
            var context = BrowsingContext.New(config);
            var doc = await context.OpenAsync(_targetUrl);

            // セールの月日を取得
            var saleDays = doc.QuerySelectorAll("li.off_calendar_item").Select(_ =>
            {
                return new SaleDay
                {
                    Month = int.TryParse(_.QuerySelector("span.off_calendar_month").InnerHtml.Replace("/", ""), out var month) ? month : 0,
                    Day = int.TryParse(_.QuerySelector("span.off_calendar_day").InnerHtml, out var day) ? day : 0,
                };
            });

            // 年をまたぐかどうか
            var isExtendingYears = saleDays.Select(_ => _.Month).Distinct().All(_ => _ == 1 || _ == 12);

            // セールの年を設定して返す
            // （対象ページには年は表示されていないのでこちらで設定する）
            var currentYear = DateTime.Now.Year;
            return saleDays.Select(_ =>
            {
                _.Year = isExtendingYears && _.Month == 1 ? currentYear + 1 : currentYear;

                return _;
            }).ToList();
        }
    }

    public class SaleDay
    {
        public int Year { get; internal set; }
        public int Month { get; internal set; }
        public int Day { get; internal set; }

        public DateTime StartDateTime
        {
            get
            {
                if (System.DateTime.TryParse($"{Year}/{Month}/{Day}", out var result))
                {
                    return result;
                }
                else
                {
                    throw new Exception($"Unknown Date [{Year}/{Month}/{Day}].");
                }
            }
        }

        public DateTime EndDateTime { get { return StartDateTime.AddDays(1); } }

        // 以下iCalendar用の項目
        public string UniqueId { get { return StartDateTime.ToString("yyyyMMdd"); } }

        public string Summary { get; } = "西友5%OFF";

        public string Description { get; } = "セゾンカードご利用で5%OFF。毎月5,20日は5%OFF開催日。";
    }
}
```

## iCalendar形式への加工

下記サイトがとても参考になりました。
http://www.asahi-net.or.jp/~ci5m-nmr/iCal/ref.html

```csharp:
namespace SeiyuSale
{
    public class CalendarConverter
    {
        private const string ProdId = "SeiyuSale";

        public HttpResponseMessage Convert(IEnumerable<SaleDay> saleDays)
        {
            var timestamp = System.DateTime.Now.ToString("yyyyMMddTHHmmssZ");

            // http://www.asahi-net.or.jp/~ci5m-nmr/iCal/ref.html
            var body = new StringBuilder();
            {
                body.AppendLine("BEGIN:VCALENDAR");
                body.AppendLine($"PRODID:{ProdId}");
                body.AppendLine("VERSION:2.0");
                body.AppendLine("METHOD:PUBLISH");
                {
                    body.AppendLine("BEGIN:VTIMEZONE");
                    body.AppendLine("TZID:Asia/Tokyo");
                    {
                        body.AppendLine("BEGIN:STANDARD");
                        body.AppendLine("DTSTART:19390101T000000");
                        body.AppendLine("TZOFFSETFROM:+0900");
                        body.AppendLine("TZOFFSETTO:+0900");
                        body.AppendLine("TZNAME:JST");
                        body.AppendLine("END:STANDARD");
                    }
                    body.AppendLine("END:VTIMEZONE");
                }
                // セール日をそれぞれイベントとして生成する
                foreach (var _ in saleDays)
                {
                    body.AppendLine("BEGIN:VEVENT");
                    {
                        body.AppendLine("CLASS:PUBLIC");
                        body.AppendLine($"UID:{_.UniqueId}");
                        body.AppendLine($"DTSTAMP:{timestamp}");
                        body.AppendLine($"SUMMARY:{_.Summary}");
                        body.AppendLine($"DESCRIPTION:{_.Description}");
                        body.AppendLine($"DTSTART;VALUE=DATE:{_.StartDateTime:yyyyMMdd}");
                        body.AppendLine($"DTEND;VALUE=DATE:{_.EndDateTime:yyyyMMdd}");
                    }
                    body.AppendLine("END:VEVENT");
                }
                body.AppendLine("END:VCALENDAR");
            }

            var response = new HttpResponseMessage
            {
                StatusCode = HttpStatusCode.OK,
                Content = new StringContent(body.ToString()),
            };
            response.Content.Headers.ContentType =  new MediaTypeHeaderValue("text/calendar");

            return response;
        }
    }
}
```

## Lambdaのエントリポイント

- 使用しているNuGetパッケージ
  - Newtonsoft.Json : Jsonオブジェクトの定義のため

```csharp:
namespace SeiyuSale
{
    public class Function
    {
        // ★ハマったポイント: 戻り値はstringではなくクラス
        public ApiGatewayResponse FunctionHandler(ILambdaContext context)
        {
            // セール日を取得
            var webScraper = new WebScraper();
            var saleDays = webScraper.GetSaleDaysAsync();
            saleDays.Wait();

            // iCal形式に変換
            var converter = new CalendarConverter();
            var calendar = converter.Convert(saleDays.Result);

            // API Gateway用のレスポンス形式に変換
            // ★ハマったポイント: Json文字列ではなく、オブジェクトを返すこと！
            var response = ToApiGatewayResponseAsync(calendar);
            response.Wait();
            return response.Result;
        }

        private async Task<ApiGatewayResponse> ToApiGatewayResponseAsync(HttpResponseMessage response)
        {
            return new ApiGatewayResponse
            {
                StatusCode = (int)response.StatusCode,
                Headers = response.Content.Headers.ToDictionary(_ => _.Key, _ => string.Join(",", _.Value)),
                Body = await response.Content.ReadAsStringAsync(),
            };
        }

        // https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/api-gateway-integration-settings-integration-response.html
        [JsonObject()]
        public class ApiGatewayResponse
        {
            [JsonProperty("statusCode")]
            public int StatusCode { get; set; }

            [JsonProperty("headers")]
            public IDictionary<string, string> Headers { get; set; }

            [JsonProperty("body")]
            public string Body { get; set; }
        }
    }
}
```

# AWSの構成
## Lambda

### デプロイ

![](https://qiita-image-store.s3.amazonaws.com/0/104653/2d8fe42e-ff6c-9041-c3ac-228d0ec9cf9f.png)

### 動作確認

![](https://qiita-image-store.s3.amazonaws.com/0/104653/257b63ab-3757-9c33-6435-5b010ea6514e.png)

Lambda関数の戻り値を **Json文字列** ではなく **Jsonオブジェクト** で返すことに注意。
文字列で返していると、以下のようなエラーになります。

```
Endpoint response body before transformations: "{\n  \"statusCode\": 200,\n  \"headers\": {\n    \"Content-Type\": \"text/calendar\"\n  },\n  \"body\": ...
...
Execution failed due to configuration error: Malformed Lambda proxy response
```

## API Gateway

### 作成

- Amazon API Gateway から[APIの作成]
- [新しいAPI]を選択し、適当な名前をつけて作成
- 作成したAPIを選択して[アクション]から[メソッドの作成]
- [GET]を選択して作成
- 先程デプロイしたLambda関数を選択
  - [lambdaプロキシ統合の使用]にチェックをつけておく
![](https://qiita-image-store.s3.amazonaws.com/0/104653/5a8a3e1a-db92-0baf-e30b-87ae9411b350.png)
- 権限を設定して終わり
![](https://qiita-image-store.s3.amazonaws.com/0/104653/95e3e70a-c7ec-07a2-42bf-1d26a2a31a55.png)

### 動作確認

作成したGETメソッドをテストして、エラーが起きないことを確認します。

![](https://qiita-image-store.s3.amazonaws.com/0/104653/01c836e1-a8d4-770b-f7a9-b58cd1aaf056.png)

### デプロイ

[アクション]から[APIのデプロイ]を選択、適当な名前でデプロイします。

![](https://qiita-image-store.s3.amazonaws.com/0/104653/9396d2da-4b39-c94a-564d-a21eb301e55e.png)

エンドポイントが生成されます。

![](https://qiita-image-store.s3.amazonaws.com/0/104653/2f866a1a-7f69-d2cc-0319-964f90587cb3.png)

### 動作確認

```
$ curl --include https://XXXXX.execute-api.ap-northeast-1.amazonaws.com/prod/
HTTP/1.1 200 OK
Date: Thu, 19 Apr 2018 07:16:51 GMT
Content-Type: text/calendar ←★ちゃんと設定されてる
Content-Length: 1384
Connection: keep-alive
x-amzn-RequestId: a1ca37ca-43a1-11e8-8303-ade078d158f8
x-amz-apigw-id: Fk9PlF0dtjMFUBA=
X-Amzn-Trace-Id: sampled=0;root=1-5ad84263-1d524b1249b003f96a2ddf6e

BEGIN:VCALENDAR
PRODID:SeiyuSale
VERSION:2.0
METHOD:PUBLISH
BEGIN:VTIMEZONE
TZID:Asia/Tokyo
BEGIN:STANDARD
DTSTART:19390101T000000
TZOFFSETFROM:+0900
TZOFFSETTO:+0900
TZNAME:JST
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
...
```

エラーなく返ってくることを確認します。

# Googleカレンダーで取り込む

[カレンダーを追加]-[URLで追加]を選択し、先程生成したエンドポイントを指定します。

![](https://qiita-image-store.s3.amazonaws.com/0/104653/7fbcddec-d793-f791-3688-e559c91c501f.png)

カレンダーが追加されました。

![](https://qiita-image-store.s3.amazonaws.com/0/104653/99e6e2fe-9e02-5a40-721a-b17511c903d9.png)

以降、自動で更新されるはずです。

# おわりに

Lambda + API Gateway の連携に苦労しました。
（文中にも書きましたが、Json文字列を返すのではなくJsonオブジェクトを返す、等）

今回はシンプルな終日イベントのみだったので、iCalendarデータの生成にはそこまで苦戦しませんでした。複雑なイベントやタイムゾーンが絡んでくるとめんどくさそうです。

GoogleCalendarの外部カレンダー更新頻度を調べてみましたが見つからなかったので少し不安です（ ~~Lambdaの実行を計測すれば分かるとは思いますが~~ ）。
~~無料枠を超えないとは思ってますが、あまりに多いようならまた別途考えないといけないな、と思っています。~~

## 2018/04/25 追記
CloudWatchでAPIコール回数を計測しました。

<blockquote class="twitter-tweet" data-lang="ja"><p lang="ja" dir="ltr">自前のiCalendarをGoogleCalendarがどれぐらいの頻度で参照しに来るかを計測した結果。例外はあるけど、だいたい14-15時間ぐらいの間隔で参照しに来るっぽい。どういうトリガーなんだろう。 <a href="https://t.co/XneXRDL1H8">pic.twitter.com/XneXRDL1H8</a></p>&mdash; ushibutatory (@ushibutatory) <a href="https://twitter.com/ushibutatory/status/989084282172911617?ref_src=twsrc%5Etfw">2018年4月25日</a></blockquote>
<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>