Qiita Organization の新着記事をチャットに通知して組織の学習効率を高める！

社内で Qiita 記事のアウトプット活動を促進しています。G-awa です。アウトプットによる学習方法は学習効率が良く、大変おすすめです。ただその一方で、ひとりでは続けづらいという面もあるのでチームや組織で互いに支え合うことが重要です。ひとりだと心が折れがちですよね。

<img src="https://4.bp.blogspot.com/-b_wxsoyflcs/WZP38JpI0LI/AAAAAAABGDA/troMJOw1KGUpq7yueRTgIdPc85o_kexXQCLcBGAs/s400/tamashii_nukeru_man.png" width="30%"> →→→→ <img src="https://2.bp.blogspot.com/-aIs9YKfGlEk/Vy2v2rXYgjI/AAAAAAAA6b0/PuaALahZfJsIluEeFOKgfgOy43qrc8bBQCLcB/s450/enjin_circle_wakamono.png" width="40%">

昨日の記事読んだよ、とても参考になった :thumbsup: 
などのように、互いに励まし合える環境があると、エンジニアが成長する環境として素晴らしいです。
当社ではそんな素敵な環境を目指して Qiita に投稿する人が参加するチャンネルを作成して運用しています（実際はパブリックチャンネルになっており、社員全員が閲覧、投稿できます）。

Qiita Organization を作って運用していると、以下のような課題に直面します。

- 日々の皆のアウトプットを見逃したくない
- モチベーションを維持したい
- 活発なコミュニケーションを促進したい

そこで、チャットへ記事の更新情報を通知するツールを作成してみました。

# ツールについて

Qiita Organization に投稿された新着記事を取得して、Rocket Chat に通知するといったツールです。Qiita API では Organization の情報が取得できなかったため、クローリングによる実装です。AWS の Lambda で cron 実行されます。
※ Qiita 記事をクローリングすることに対して過度なリクエストは送らない想定ですが、運営側からご指摘があれば本記事およびソースコードは取り下げます。

| 技術スタック          | 説明                           |
| --------------------- | ------------------------------ |
| AWS Lambda            | 実行環境                       |
| Serverless Framework  | デプロイ                       |
| Python beautiful soup | クローリング                   |
| rocket chat api       | Rocket Chat にメッセージを投稿 |
| CircleCI              | テスト                         |

ソースコードはこちら
https://github.com/qiita-scraper/qiita-scraper-rocket-chat

# クローリング

[beautiful soup](https://www.crummy.com/software/BeautifulSoup/bs4/doc/) を使用してクローリングを行います。投稿日も抽出したいので少し面倒なことをしています。いやー、クローリングはしんどい。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/163591/ec13deea-ab3b-e450-23af-835cbe164093.png)

```python
def fetch_recent_user_articles(self, user):
    """
    指定したユーザの投稿した Qiita 記事のうち、最新の記事を複数取得する
    :param user:
    :return:
    """

    qiita_url = 'https://qiita.com/' + user
    response = request.urlopen(qiita_url)
    soup = BeautifulSoup(response, 'html.parser')
    response.close()

    created_ats = []
    created_dates = soup.find_all('div', class_='ItemLink__info')
    for created_date in created_dates:
        div = re.sub('<a.*?>|</a>', '', str(created_date))
        text = re.sub('<div.*?>|</div>', '', div).split()
        month = str(time.strptime(text[3], '%b').tm_mon)
        day = text[4][:-1]
        year = text[5]
        created_at = year + '/' + month + '/' + day
        created_ats.append(created_at)

    articles = []
    a_tags = soup.find_all('a', class_='u-link-no-underline')
    for index, a in enumerate(a_tags):
        href = a.get('href')
        url = 'https://qiita.com' + href
        title = a.string
        articles.append({'title': title, 'url': url, 'created_at': created_ats[index]})

    return articles
```

# Rocket Chat へ投稿する

RocketChat が API 仕様を公開しています。
https://rocket.chat/docs/developer-guides/rest-api/

シンプルに Python の標準ライブラリである urllib を使用します。[Python の HTTP クライアントは urllib.request で十分](https://qiita.com/hoto17296/items/8fcf55cc6cd823a18217)という記事が参考になりました。本当に urllib だけで十分です。

ログインして authToken と userId を取得します。http-header に記述することで認証され、他の API にアクセスします。

```python
def __login_rocket_chat(self, user, password):
    """
    Rocket Chatにログインして auth_token と user_id を取得します。
    :param url:
    :return:
    """
    obj = {
        "user": user,
        "password": password
    }
    json_data = json.dumps(obj).encode("utf-8")
    headers = {"Content-Type": "application/json"}
    req_object = request.Request(self.url + '/api/v1/login', data=json_data, headers=headers, method='POST')
    with request.urlopen(req_object) as response:
        response_body = response.read().decode("utf-8")
        result_objs = json.loads(response_body.split('\n')[0])
        user_id = result_objs["data"]["userId"]
        auth_token = result_objs["data"]["authToken"]
        print(user_id, auth_token)
    return auth_token, user_id
```

チャットルームの id を名前から検索します。

```python
def fetch_room_id(self, room_name):
    """
    Rocket Chat の room_id を取得します。
    :param room_name:
    :return:
    """

    headers = {
        "Content-Type": "application/json",
        "X-Auth-Token": self.auth_token,
        "X-User-Id": self.user_id
    }
    params = {'roomName': room_name}
    url = '{}?{}'.format(self.url + '/api/v1/channels.info', parse.urlencode(params))
    req_object = request.Request(url, headers=headers, method="GET")
    with request.urlopen(req_object) as response:
        response_body = response.read().decode("utf-8")
        print(response_body)
        result_objs = json.loads(response_body.split('\n')[0])
        channel = result_objs.get('channel')
        return channel.get('_id')
```

メッセージを投稿します。RocketChat の Web 画面からだとユーザ名とアイコン画像を差し替えて送信できませんが、API からだと差し替えて送信できます。ちょっと裏技のようでいいですね。

```python
def send_message_to_rocket_chat(self, msg, room_name):
    """
    Rocket Chatにメッセージを送信する
    :param msg:
    :param room_name
    :return:
    """
    headers = {
        "Content-Type": "application/json",
        "X-Auth-Token": self.auth_token,
        "X-User-Id": self.user_id
    }
    print(headers)
    body = {
        "message": {
            "rid": self.fetch_room_id(room_name),
            "msg": msg,
            "alias": 'Qiita Bot',
            "avatar": 'https://haskell.jp/antenna/image/logo/qiita.png'
        }
    }
    print(body)
    req_object = request.Request(self.url + '/api/v1/chat.sendMessage', data=json.dumps(body).encode("utf-8"), headers=headers, method="POST")
    with request.urlopen(req_object) as response:
```

こんな感じ。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/163591/e24ebb5c-fd11-b96a-c84d-8ced30586874.png)

# テスト

docker で RocketChat と mongoDB を起動して、実際の RocketChat アプリケーションに対してリクエストを送ることでテストを実行します。Qiita はごめんなさい、本物にアクセスしてテストします。

RocketChat を docker-compose で立ち上げます。
https://rocket.chat/docs/installation/docker-containers/docker-compose/

OVERWRITE_SETTING_Show_Setup_Wizard=completed を環境変数に指定することで RocketChat 起動時のうざいウィザード画面をスキップできるようです。参考：https://github.com/RocketChat/Rocket.Chat/issues/2233

```docker-compose.yml
version: "2"

services:
  rocketchat:
    image: rocketchat/rocket.chat:latest
    command: >
      bash -c
        "for i in `seq 1 30`; do
          node main.js &&
          s=$$? && break || s=$$?;
          echo \"Tried $$i times. Waiting 5 secs...\";
          sleep 5;
        done; (exit $$s)"
    restart: unless-stopped
    volumes:
      - ./uploads:/app/uploads
    environment:
      - PORT=3000
      - ROOT_URL=http://localhost:3000
      - MONGO_URL=mongodb://mongo:27017/rocketchat
      - MONGO_OPLOG_URL=mongodb://mongo:27017/local
      - MAIL_URL=smtp://smtp.email
      - ADMIN_USERNAME=admin
      - ADMIN_PASS=supersecret
      - ADMIN_EMAIL=admin@example.com
      # https://github.com/RocketChat/Rocket.Chat/issues/2233
      - OVERWRITE_SETTING_Show_Setup_Wizard=completed
    depends_on:
      - mongo
    ports:
      - 3000:3000
    labels:
      - "traefik.backend=rocketchat"
      - "traefik.frontend.rule=Host: your.domain.tld"

  mongo:
    image: mongo:4.0
    restart: unless-stopped
    volumes:
      - ./data/db:/data/db
    command: mongod --smallfiles --oplogSize 128 --replSet rs0 --storageEngine=mmapv1
    labels:
      - "traefik.enable=false"

  # this container's job is just run the command to initialize the replica set.
  # it will run the command and remove himself (it will not stay running)
  mongo-init-replica:
    image: mongo:4.0
    command: >
      bash -c
        "for i in `seq 1 30`; do
          mongo mongo/rocketchat --eval \"
            rs.initiate({
              _id: 'rs0',
              members: [ { _id: 0, host: 'localhost:27017' } ]})\" &&
          s=$$? && break || s=$$?;
          echo \"Tried $$i times. Waiting 5 secs...\";
          sleep 5;
        done; (exit $$s)"
    depends_on:
      - mongo

```

あとは Python の unittest で localhost:3000 に起動した RocketChat を使用してテストを実行します。簡単ですね。

```python
import unittest
from rocket_chat.rocket_chat import RocketChat
import urllib
from qiita.qiita import Qiita
import freezegun
import json

class TestQiitaScraper(unittest.TestCase):
    def setUp(self):
        # rocket chat admin user set in docker-compoose.yml rocketchat service environment value.
        self.aurhorized_user = 'admin'
        self.aurhorized_password = 'supersecret'
        self.rocket_chat_url = 'http://localhost:3000'

    def test_login_success(self):
        rocket_chat = RocketChat(self.rocket_chat_url, self.aurhorized_user, self.aurhorized_password)
        self.assertNotEqual(len(rocket_chat.auth_token), 0)
        self.assertNotEqual(len(rocket_chat.user_id), 0)

    def test_login_failed(self):
        with self.assertRaises(urllib.error.HTTPError):
            unauthorized_user = 'mbvdr678ijhvbjiutrdvbhjutrdfyuijhgf'
            unauthorized_pass = 'gfr67865tghjgfr567uijhfrt67ujhgthhh'
            RocketChat(self.rocket_chat_url, unauthorized_user, unauthorized_pass)
```

# CircleCI でテストを実行する

circleci の実行環境で docker-compose を使用するために `executer` には `imagae` ではなくて `machine` を指定しましょう。※説明の簡略化のためにキャッシュの設定はのぞいています。

```.circleci/config.yml
version: 2
jobs:
  build:
    machine:
      image: circleci/classic:201808-01
    steps:
      - checkout
      - run:
          name: "Switch to Python v3.7"
          command: |
            pyenv versions
            pyenv global 3.7.0
      - run:
          name: docker-compose up
          command: sh dcup.sh
      - run:
          name: install dependencies and test
          command: |
            python3 -m venv venv
            . venv/bin/activate
            pip install -r requirements.txt
            pip install -r requirements-dev.txt
            python -m unittest test.py
```

# まとめ

本質的なところ（社内組織のアウトプット活動を促進する）から話がそれてしまいましたが、クローリングや docker-compose を使用して継続的にテストする方法など、自分が勉強になってしまいました。これで組織の学習効率がすこしでも高くなれば幸いです。