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

はじめに

Qiitaのトレンドをチェックする時によく以下ページを参照させていただいています。
【毎日自動更新】Qiitaのデイリーストックランキング!ウィークリーもあるよ

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

構成

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

事前準備

WebhookURLの取得

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

スクリーンショット.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 ... HTML解析を簡略化するため
    • Json.NET ... json処理を簡略化するため
  • IDE: Visual Studio Community 2017

プロジェクトの作成

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

スクリーンショット.png

スクレイピング処理

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

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; }
        }
    }
}

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

更新日: 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

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; }
        }
    }
}

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

Function.cs
using Amazon.Lambda.Core;
...

namespace QiitaRankingBot
{
    public class Function
    {
        // 最初は以下のシグネチャで生成されるが、エラーで動作しないので以下のように修正。
        // 当時の分報を見返したけど、どういうエラーでなぜこう修正したのかの記録が残っていない……。
        // public string FunctionHandler(string input, ILambdaContext context)
        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# が使えるようになったので早速試してみた

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

AWS CloudWatch Events

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

スクリーンショット.png

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

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

動作確認

スクリーンショット.png

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

おわりに

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

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

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

Sign up for free and join this conversation.
Sign Up
If you already have a Qiita account log in.