はじめに
うちの最寄りのスーパー(西友)は月に何日か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関数を選択
- 権限を設定して終わり
動作確認
作成した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コール回数を計測しました。
自前のiCalendarをGoogleCalendarがどれぐらいの頻度で参照しに来るかを計測した結果。例外はあるけど、だいたい14-15時間ぐらいの間隔で参照しに来るっぽい。どういうトリガーなんだろう。 pic.twitter.com/XneXRDL1H8
— ushibutatory (@ushibutatory) 2018年4月25日