7
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

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

Last updated at Posted at 2018-04-19

はじめに

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

西友 - 5%OFF開催日カレンダー
毎月5日、20日が開催日と言いつつ、13日も対象だったりしてよく分かりません。

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

使った技術など

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

実装

セール日の取得(Webスクレイピング)

  • 使用しているNuGetパッケージ
    • AngleSharp : スクレイピング処理の簡略化
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

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オブジェクトの定義のため
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

デプロイ

動作確認

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プロキシ統合の使用]にチェックをつけておく
  • 権限を設定して終わり

動作確認

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

デプロイ

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

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

動作確認

$ 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で追加]を選択し、先程生成したエンドポイントを指定します。

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

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

おわりに

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

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

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

2018/04/25 追記

CloudWatchでAPIコール回数を計測しました。

7
6
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
7
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?