QiitaのいいねランキングをSlackに自動投稿するbotを作った

# はじめに

Qiitaのトレンドをチェックする時によく以下ページを参照させていただいています。
[【毎日自動更新】Qiitaのデイリーストックランキング！ウィークリーもあるよ](https://qiita.com/takeharu/items/bb154a4bc198fb102ff3)

よりチェックしやすくしたいと思い、Slackに毎日自動投稿するようにしてみました。

# 構成

- プログラム（.NET Core 2.0）
 - 上記Webページをスクレイピングし、ランキング情報を取得する
 - Slackチャネルへ投稿する
- 定期実行
 - 上記プログラムをAWS Lambdaの関数としてデプロイする
 - AWS CloudWatch Eventsで実行スケジュールを設定する

# 事前準備

## WebhookURLの取得

- SlackのApp管理ページから[カスタムインテグレーション]を選択する
- [着信Webフック]を選択する
- [設定を追加]を選択する
- 投稿するSlackチャネルを選択して[インテグレーションを追加]をクリックする
- **表示された`Webhook URL`をコピーするなどして手元に置いておく**
 - 名前やアイコンをお好みで変更する
- 保存して終了

![スクリーンショット.png](https://qiita-image-store.s3.amazonaws.com/0/104653/3df86611-1575-5348-1ba2-cae227914795.png)

## AWS Toolkit for Visual Studioのセットアップ

以下を参考にしました。
https://docs.aws.amazon.com/ja_jp/toolkit-for-visual-studio/latest/user-guide/getting-set-up.html

# プログラム

- 言語: C#
- フレームワーク: .NET Core 2.0
- NuGetパッケージ:
 - [AngleSharp](https://anglesharp.github.io/) ... HTML解析を簡略化するため
 - [Json.NET](https://www.newtonsoft.com/json) ... json処理を簡略化するため
- IDE: Visual Studio Community 2017
 - 拡張機能: [AWS Toolkit for Visual Studio](https://aws.amazon.com/jp/visualstudio/)

## プロジェクトの作成

[AWS Lambda Project(.NET Core)]を選択します。
ブループリントは[Empty Function]を選択します。

![スクリーンショット.png](https://qiita-image-store.s3.amazonaws.com/0/104653/29cb78db-faae-d019-a787-6d8a894ab316.png)


## スクレイピング処理

※記事として見やすくするため1メソッドにまとめています。
（実際はもう少し細かくメソッド分け、クラス分けをしています。）

```csharp:WebScraper.cs
using AngleSharp;
...

namespace QiitaRankingBot
{
    internal class WebScraper
    {
        public async Task<string> GenerateText()
        {
            // 対象ページを読み込み
            const string targetUrl = "https://qiita.com/takeharu/items/bb154a4bc198fb102ff3";
            var config = Configuration.Default.WithDefaultLoader();
            var context = BrowsingContext.New(config);
            var doc = await context.OpenAsync(targetUrl);

            // 更新日を取得
            var date = System.DateTime.Parse(doc.QuerySelector("time[itemprop='dateModified']").GetAttribute("datetime"));

            // ランキングを取得
            const int rankingCount_Daily = 10;
            var elements = doc.QuerySelectorAll("h4").Take(rankingCount_Daily);
            var items = elements.Select((_, i) =>
            {
                var link = _.QuerySelector("a[href^='https://qiita.com/']");
                return new Item
                {
                    Ranking = i + 1, // 構成上取得が難しかったのでちょっとダサいけど手動カウント
                    Title = link.InnerHtml,
                    Url = link.GetAttribute("href"),
                };
            });

            // 整形
            var separator = Environment.NewLine + Environment.NewLine;
            var text = new StringBuilder();
            text.Append($"更新日: {date:yyyy/MM/dd}");
            text.Append(separator);
            text.Append(items.Select(_ => $"<{_.Url}|{_.Ranking}. {_.Title}>").Aggregate((x, y) => $"{x}{separator}{y}"));
            return text.ToString();
        }

        private class Item
        {
            public int Ranking { get; set; }
            public string Title { get; set; }
            public string Url { get; set; }
        }
    }
}
```

以下のような文字列を生成します。
体裁は個人の好みです。
（空行を挟んでいるのは、スマホで見た時にタップしやすくするため。）

```text
更新日: 2018/03/30

<https://qiita.com/...|1. ○○ >

<https://qiita.com/...|2. ○○ >

<https://qiita.com/...|3. ○○ >

...
```

※参考）https://api.slack.com/docs/message-formatting

## Slackへ投稿

UTF8でエンコードした以下のようなJSON文字列をPOSTします。
POST先のURLは、先程取得したWebhookURLです。

```
{
    "text":"更新日: 2018/03/30\r\n\r\n<https://qiita.com/...|1. ○○>\r\n\r\n<https://qiita.com/...|2. ○○>\r\n\r\n<https://qiita.com/...|3. ○○>\r\n\r\n..."
}
```

※参考）https://api.slack.com/docs/messages

```csharp:SlackClient.cs
using Newtonsoft.Json;
...

namespace QiitaRankingBot
{
    internal class SlackClient
    {
        public async Task PostAsync(string text)
        {
            const string webHookUrl = "https://hooks.slack.com/services/XXX...";

            using (var client = new WebClient())
            {
                client.Headers.Add(HttpRequestHeader.ContentType, "application/json;charset=UTF-8");
                client.Encoding = Encoding.UTF8;

                await client.UploadDataTaskAsync(new Uri(webHookUrl)
                    , Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(new PostData
                    {
                        Text = text,
                    })));
            }
        }

        // https://api.slack.com/docs/messages
        [JsonObject]
        private class PostData
        {
            [JsonProperty("text")]
            public string Text { get; set; }
        }
    }
}
```

あとはエントリポイントからそれぞれ順番に実行します。

```csharp:Function.cs
using Amazon.Lambda.Core;
...

namespace QiitaRankingBot
{
    public class Function
    {
        public void FunctionHandler(ILambdaContext context)
        {
            var scraper = new WebScraper();
            var generateText = scraper.GenerateText();
            generateText.Wait();

            var slack = new SlackClient();
            slack.PostAsync(generateText.Result).Wait();
        }
    }
}
```

# 定期実行

## AWS Lambda

こちらのページがとても参考になりました。
[AWS Lambda で C# が使えるようになったので早速試してみた](https://qiita.com/Hanocha/items/7a3b6861f957f1a35a68)

バージョンは違いますが、基本的な流れは同じでした。

## AWS CloudWatch Events

デプロイしたLambda関数を確認すると、以下のようにトリガーが未設定です。

![スクリーンショット.png](https://qiita-image-store.s3.amazonaws.com/0/104653/1550700e-636f-a5b8-1248-8bd8e625c1df.png)

ここにAWS CloudWatch Eventsで定期実行するトリガーを設定します。

- [トリガーの追加]から[AWS CloudWatch Events]を選択
- [トリガーの設定]内で[新規ルールの作成]を選択
 - スケジュール式にcronで希望の実行間隔を指定
 - 例）`cron(0 0 ? * * *)` ... 毎日9:00（JST）に実行

# 動作確認

![スクリーンショット.png](https://qiita-image-store.s3.amazonaws.com/0/104653/5d9611b1-e02e-43d1-09c1-02abb0ceb296.png)

各リンクをクリックして該当ページへ遷移することを確認します。

# おわりに

Qiitaのランキング等のデータはAPIで提供されていないようだったので、Webスクレイピングという手法を使いました。
面白いですね、スクレイピング。「最後の手段」って感じなので、仕事ではできるだけ使いたくはないですが。
今回のbotは家族用に作った1日1アクセスの処理なので問題ないと考えましたが、スクレイピングって対象媒体（今回ならQiita）の規約や著作権法等に違反していないかどうか、少しドキドキします。

AWSで定期実行バッチを作りましたが、EC2でサーバ立てるより安くて良い感じでした。
ただ、Lambda関数のタイムアウトは最大でも5分らしいので、重いバッチ処理を実装するには向いてないですし、そもそもLambdaってこういったバッチ処理を載せる入れ物ではない気がします。

AWSもっと勉強しなきゃな、と思いました。