【Rails7+スクレイピング+LINE】Qiitaの週間トレンド記事一覧に新しくランクインした記事達をLINEで通知したい

# はじめに
Qiitaの週間トレンド記事に入った記事は大体読んでいるのですが、新しくランクインした記事をどこかにピックアップできたら便利だと思ったので、LINEに通知するプログラムを作ってみました。

自分で方法を考えて作ったことがほぼなかったので若干迷走して作った結果、毎週日曜と木曜の朝に、新入り記事達のURLを素っ気なく容赦なく通知してくるプログラムが出来上がりました。

開発過程でいろいろサービスを使いましたが、2022/05/12現在、すべて無料枠でどうにかなっています。

（備忘録がわりに自分が何を考えていたかなどもつらつら書いているので、方法論だけ読みたい方はいい感じに飛ばし読みをお願いします。）

# やりたいこと
- Qiitaの週間トレンド記事一覧に新しくランクインした記事のURLを取得する
- 取得したURLを自分のLINEアカウントに通知する

# 環境
- Rails 7.0.2.3
- line-bot-api 1.23.0
- heroku

# 全体像
作ったものの全体像を軽く書きます。
## Qiitaの週間トレンド記事一覧のページから、スクレイピングで新入り記事達のURLを取得する
Qiitaの週間トレンド記事に、新しくランクインした記事のURLを取りにいきます。 

下画像のNEWアイコンを目印にすることで、新入り記事のURLを取得しました。
...過去の自分はなかなかトリッキーなことをしていたようです。
![スクリーンショット 2022-05-10 10.38.49.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/605286/7e42b939-13e8-decf-f0c8-588658539ab5.png)
#### 他に検討した方法
- QiitaのAPIを使って取得する　→ 一番に検討しましたが、使えそうなAPIを見つけることができず断念しました。
- 他のスクレイピングの目印を見つける　→　見つかりませんでした。

## 取得した記事URLを自分のLINEアカウントに通知
やっていることはそのままで、取得したURLを LINE Messaging API を使って１件ずつ通知していきます。
こちらどうやら数年前に流行っていたようで、関連記事がこれでもかと見つかりました。
個人的な好みで１メッセージ１URLにしましたが、１件のメッセージに全てのURLを突っ込むこともできます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/605286/4c233bc6-f7bc-b92d-e46a-f07797ea3cc8.png)
#### 他に検討した方法
特にありません。

## 定期実行
/lib/tasksフォルダ直下にプログラムを書いてheroku（webアプリを簡単に公開できるサービス）にデプロイし、herokuのアドオンの一つであるscheduler（実行する日・時間を設定できる）を使って定期実行する方法を取りました。
#### 他に検討した方法
- デプロイせずにローカルでcronを使って実行する → PCの調子が悪くなったり壊れたりしてプログラムがデータの海に消えたら嫌だなーと思い、やめました。軽く前科があるので。

# 作成
順を追って書いていきます。

## 0.前準備
今回はherokuにデプロイしてプログラムを定期実行しますので、そのためにはherokuのアカウント登録が必要です。
同じようにデプロイしたい方はアカウントを作っておきます。

## 1. Railsアプリケーションを作ってherokuにデプロイ
下記記事を参考に、とりあえずデプロイできるところまで確認します（丸投げ）。
わたしは「Railsアプリケーションの作成」あたりから参考にしましたが、Rails環境構築がまだの方は記事の初めの方から読むことをおすすめします。
この時点ではプログラムは特に何も書いていませんでした。

https://qiita.com/avimigo/items/2a0187b30e4baca8b9c7

空のプログラムをデプロイするの？と思われるかもしれませんが、やっとこさ全てのプログラムを書き終わっていざデプロイ、というところでデプロイエラーが出る時のストレスはなかなかです。herokuを触るのが初めてなら、ここでデプロイしておいても損はないと思います、~~自分しか使わないし~~。
逆にherokuや他のサービスでのデプロイ経験者の方や、空の状態でデプロイなんてとんでもないという方、諸事情で完成後デプロイしか許されない方は、デプロイは後回しで、先に行きましょう。

#### つまったこと
特にありません。
上記記事の`$heroku create`で軽く不安になった覚えがあるのですが、メモが残っていないので大丈夫だったに違いありません。

## 2. LINE Messaging API のチャンネルを作る
LINE Developpersのアカウントを作り、その勢いで新入りQiita記事を通知するチャンネルも作ってしまいます。
公式ドキュメントがとてもわかりやすいので、それを参考に作ります（丸投げその２）

https://developers.line.biz/ja/docs/messaging-api/getting-started/#using-console

#### つまったこと
強いて書くなら、チャンネルのプロバイダー名（日本語にすると提供元？開発元？）とチャンネル名自体を何にするか悩みました。

## 3. LINEアカウントのUserIdを取得する
今回やりたかったことは、先ほど作ったチャンネルを追加した人宛に、直接通知を送ることですが、そのためには、宛先になるLINEアカウントのUserIdを用意する必要があります。

ちなみに、ここでいうLINEのUserIdとは、LINEユーザーが任意に設定できるあのIDではなく、それぞれのアカウントに割り振られている数十桁のUserIdのことです。これを取得するには、先ほど作ったチャンネルのWebhookURLを叩かないといけないみたいです。


いくつか方法はあるみたいですが、GASを使うお手軽な方法が下記記事にあったため、それを参考に取得しました（丸投げその３)。
今回は自分以外の人に使ってもらう予定はなかったので、使い終わったGASアプリケーションは風の速さで削除しました。

https://blog.taka73.net/2020/12/23/line-messaging-api-line-bot/

#### つまったこと
作ったGASプログラムをデプロイする際に、公開範囲を「全員」とか「誰でも」みたいな、最も広い範囲に公開するように設定します。
筆者は最初、公開範囲を「自分のみ」にしたせいでLINE MessagigAPI側のWebhookURLの認証がうまくいかず、15分ほど持っていかれました。
GASはGoogleのサービスなのに対し、LINEはGoogleから見ると外部サービスなので、「自分」の範囲には入らないんだと思います。

#### 注意すること
筆者が今回この方法を取ったのは、作ったLINEチャンネルを他の人に共有するつもりが微塵もなかったからです。
例えば、作ったLINEチャンネルを公開し、誰かが追加するたびにその人のUserIdを取得したいのなら、GASでその用途のコードを追記した上でアプリケーションを公開し続けるか、後述するタスクを書く際にそのコードを追加する必要があります。

## 4. heroku configに諸々の情報を追加
LINEのUserIdやチャンネルのトークンなどをそのまま公開するプログラムに書いてしまうのはまずいです。
ということで、herokuのconfigに情報を入れていきます。

- LINE_CHANNEL_SECRETは、チャンネルの基本設定ページの下の方、
- LINE_CHANNEL_TOKENはチャンネルのMessaging API設定の１番下、
- LINE_USER_IDは先ほどGASで取得したUserId

これらをそれぞれ登録していきます。
```
$ heroku config:set LINE_CHANNEL_SECRET={your LINE_CHANNEL_SECRET}
$ heroku config:set LINE_CHANNEL_TOKEN={your LINE_CHANNEL_TOKEN}
$ heroku config:set LINE_USER_ID={your LINE_USER_ID}
```

UserIdに関しては上でも書いた通り、やりたいことによってはここで登録する必要がなくなるケースもあります。

## 5.プログラムを書く
最初にインストールするGemを紹介し、その後プログラムを２つに分けて紹介して、最後に完成形をどんと載せます。おまけで、ローカルでプログラムを試行する方法も書いておきます。
### Gemインストール
まずは、LINEのMessagingAPIを使うために、gemをインストールします。
Gemfileに`gem 'line-bot-api'`と書いた後に、`bundle install`をして終わりです。

### スクレイピング部分
スクレイピング部分は、今回はnokogiriを使いました。
なかなか一回で書き上げるのが難しいですが、下記記事を参考に、ログを出して確認しつつ数撃ちゃ当たる作戦で目標のURLを取りにいきます。

https://zenn.dev/arao99/articles/a68d8039f85fa8

```ruby
 require 'open-uri'

 # qiitaの週間トレンド記事ページからDOMを取得
 url = 'https://qiita.com/Qiita/items/b5c1550c969776b65b9b'
 res = URI.open(url)

 res_body = res.read
 charset = res.charset
 html = Nokogiri::HTML.parse(res_body, nil, charset)

 # 目的のidを持つ要素とその子要素を取得
 target_id = "#personal-public-article-body"
 body = html.css(target_id)

 # h3タグとその子要素を配列で取得
 title_array = body.xpath("//h3")

 # h3の兄弟要素のpタグを回す
 body.css('h3 ~ p').each_with_index do |node1, i|

   # 子要素で、alt属性の値がnewであるimgタグを持つ時
   if node1.children.css('img')[0].attribute('alt').value == ":new:"
     # 記事url
     article_url = title_array[i].children.css('a')[1].attribute('href').text

     # 本文作成
     # プッシュ通知を送信
   end
 end
```


### LINE Messaging API部分
こちらは公式ドキュメントのほぼ丸写しです。
```ruby
 # クライアント作成
 client = Line::Bot::Client.new { |config|
   config.channel_secret = ENV["LINE_CHANNEL_SECRET"]
   config.channel_token = ENV["LINE_CHANNEL_TOKEN"]
 }

 # userId
 user_id = ENV["LINE_USER_ID"]

 # 本文作成
 message = {
   type: 'text',
   text: # 記事URL
 }

 # プッシュ通知を送信
 client.push_message(user_id, message)
    
```

### 完成形
最後に完成形です。
注意点として、特にherokuにデプロイすることを考えている方は、必ず/lib直下にtasksフォルダを切って、そこにプログラムを書くようにします。
筆者はbatchフォルダを切ってしまい、後述するherokuでうまく動いてくれず、気づくまで大変でした。
```ruby:/lib/tasks/line_bot_task.rb
require 'open-uri'

class Tasks::LineBotTask

  def self.run
    #################################
    # qiita関連
    #################################
    # qiitaの週間トレンド記事ページからDOMを取得
    url = 'https://qiita.com/Qiita/items/b5c1550c969776b65b9b'
    res = URI.open(url)
    res_body = res.read

    charset = res.charset
    html = Nokogiri::HTML.parse(res_body, nil, charset)

    # 目的のidを持つ要素とその子要素を取得
    target_id = "#personal-public-article-body"
    body = html.css(target_id)

    # h3タグ以下を取得
    title_array = body.xpath("//h3")

    #################################
    # line-bot関連
    #################################
    # クライアント作成
    client = Line::Bot::Client.new { |config|
      config.channel_secret = ENV["LINE_CHANNEL_SECRET"]
      config.channel_token = ENV["LINE_CHANNEL_TOKEN"]
    }

    # userId
    user_id = ENV["LINE_USER_ID"]

    #################################
    # メッセージを作ってpush通知で送信
    #################################
    body.css('h3 ~ p').each_with_index do |node, i|

      if node.children.css('img')[0].attribute('alt').value == ":new:"
        # 記事url
        article_url = title_array[i].children.css('a')[1].attribute('href').text
        # 本文作成
        message = {
          type: 'text',
          text: article_url
        }

        # プッシュ通知を送信
        client.push_message(user_id, message)
      end
      "OK"
    end

  end
end
```

### おまけ：ローカルでタスクを実行したいとき
開発段階でうまく開発できているか試すために、ローカルでタスクを実行したい時があるかと思います。
その時は、まず、beforeのようにherokuのconfigの情報を取得している箇所は、3ヶ所すべて下記afterのように書き直しましょう。
```ruby
 # before
 config.channel_secret = ENV["LINE_CHANNEL_SECRET"]
 # after
 config.channel_secret = 'xxxxxxxxxxx' #生のCHENNEL_SECRET
```
その後、ターミナルで下記コマンドを流します。
```command
$ rails runner Tasks::LineBotTask.run
```
ローカルで実行する際の注意として、ローカルで実行した後にherokuにデプロイするときは、`ENV["LINE_CHANNEL_SECRET"]`のような、__herokuのconfigを使う書き方に必ず戻してください。__

#### つまったこと
__1. 記事を取ってこれない__
何かの記事を参考に誤の書き方をしたところ`'initialize': No such file or directory @ rb_sysopen`のエラーが出ました。正しくは下の書き方をしないといけないようです。
```ruby
 # 誤
 res = open(url)

 # 正
 res = URI.open(url)
```

__2. 標的をなかなか捕まえられない__
今回スクレイピングする、Qiita週間トレンド記事一覧ページのhtmlタグ構造の概略がこんな感じですが、欲しいURLを取るためにあれこれ試行錯誤しました。
```html
...略
<div id="personal-public-article-body">
    <h3>
        ...略
        <a href="取りたい記事url">記事タイトル</a>
    </h3>
    <p>
        ...略
        <img alt=":new:">
        <img alt=":lgtm:">
    </p>
    ...以下繰り返し
</div>
...略
```

__3. プッシュ通知のbotと応答botの違いをわかっていない__
今回使うLineMessagingAPIは、今回使うプッシュ通知の他にもう一つ、自動で応答してくれるbotにも対応できます。
どうやら応答botの方が人気だったようで、最初書き方を調べる際に「`rails line-bot-api`」とかで検索をしていたところ、出てきたのは応答botの書き方の記事が主でした。
本来調べるべきはプッシュ通知の記事だったのですが、応答botの記事に惑わされてしばらく迷走し、作成期間の３分の1ほどをここに費やしたことも、今となっては良い思い出です。

## 6.プログラムをデプロイしてherokuにタスク追加する
これがラストです。
書き上げたプログラムをherokuにデプロイし、その後定期実行のスケジュールを設定しにいきます。
スケジュール設定に関しては下記記事がとてもわかりやすかったです。

https://qiita.com/gensan64311466/items/2c6f4ce82dc5d3a1affe

ちなみに今回のプログラムは、日曜と木曜の早朝6時に動くようにします。理由は週間トレンド記事一覧が日曜と木曜の朝5時に更新されるためです。曜日実行に関しては下記記事を参考にさせていただき、スケジュールを２つ設定しました。
開始時間は時差を考え`PM09:00`にセットします。
コマンドの部分に書いたコードは、それぞれ下の通りです。

```console
// 日曜日（標準時だと土曜日）実行分
$ [ $(date +%w) = 6 ] && rails runner Tasks::LineBotTask.run

// 木曜日（標準時だと水曜日）実行分
$ [ $(date +%w) = 3 ] && rails runner Tasks::LineBotTask.run
```

https://qiita.com/shunichi_com/items/bcd5e84a5797c9c50410

#### つまったこと
__１. タスクが動いてくれない__
当初、コマンド部分にこんなふうに書いていました。
```console
$ [ $(date +%w) = 3 || $(date +%w) = 6 ] && rails runner Tasks::LineBotTask.run
```
これならタスク１つで済むじゃん！！と喜び勇んで書いた結果、動きませんでした。
原因は調べていないのでよくわかっていません。

__２. 木曜日分が動いてくれない__
しょうがないのでタスクを２つに分けてそれぞれ設定した結果、なぜか木曜日分が動いてくれず。
その時に書いていたコマンドはこちらです。
```console
// 日曜日（標準時だと土曜日）実行分
$ [ $(date +%w) = 6 ] && rails runner Tasks::LineBotTask.run

// 木曜日（標準時だと水曜日）実行分
$ [ 　$(date +%w) = 3 ] && rails runner Tasks::LineBotTask.run
```
よーく見てみると木曜日の方には謎のスペースが。
変な空白は動かないコマンドの元です。気をつけようと思いました。

# もっと開発を進めるとしたら
これ以上補強する予定はないですが、さらに開発するとしたらこんなことをやりたいです。
- ローカルでもタスクをそのまま実行できるようにする
- 追加した人のUserIdを自動で取得できるようにする
- 自動テスト・ログを追加する

# 終わりに
記事を書いてみて改めて思ったことが、自分で作ってみたとは言っても、先人たちの知恵を借りることでできるもんなんだなあということでした。わからないことは調べれば大体解決できるのって本当にありがたいです。

Qiita記事のタグ構造が変わってしまうと動かなくなる可能性が大きいことが懸念ではありますが、現時点では元気に動いているし、個人的には便利で勉強にもなったので作って良かったです。

# 参考にさせていただいた記事
たくさんの方々の記事を参考にさせていただきましたので、感謝を込めて再掲します。ありがとうございました。

[初心者がRubyで自作したLINE botを公開するまで](https://qiita.com/avimigo/items/2a0187b30e4baca8b9c7)
[Messaging APIを始めよう](https://developers.line.biz/ja/docs/messaging-api/getting-started/#using-console)
[LINE BOTを作ってMessaging API でグループにメッセージを送る](https://blog.taka73.net/2020/12/23/line-messaging-api-line-bot/)
[Herokuで月一回のバッチ処理を実装する方法](https://qiita.com/gensan64311466/items/2c6f4ce82dc5d3a1affe)
[heroku schedulerで任意の曜日のみ実行する方法](https://qiita.com/shunichi_com/items/bcd5e84a5797c9c50410)
[RubyでWebスクレイピング #3 Nokogiriを使いこなす](https://zenn.dev/arao99/articles/a68d8039f85fa8)