社内で Qiita 記事のアウトプット活動を促進しています。G-awa です。アウトプットによる学習方法は学習効率が良く、大変おすすめです。ただその一方で、ひとりでは続けづらいという面もあるのでチームや組織で互いに支え合うことが重要です。ひとりだと心が折れがちですよね。
昨日の記事読んだよ、とても参考になった
などのように、互いに励まし合える環境があると、エンジニアが成長する環境として素晴らしいです。
当社ではそんな素敵な環境を目指して 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 を使用してクローリングを行います。投稿日も抽出したいので少し面倒なことをしています。いやー、クローリングはしんどい。
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 で十分という記事が参考になりました。本当に urllib だけで十分です。
ログインして authToken と userId を取得します。http-header に記述することで認証され、他の API にアクセスします。
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 を名前から検索します。
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 からだと差し替えて送信できます。ちょっと裏技のようでいいですね。
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:
こんな感じ。
テスト
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
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 を使用してテストを実行します。簡単ですね。
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
を指定しましょう。※説明の簡略化のためにキャッシュの設定はのぞいています。
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 を使用して継続的にテストする方法など、自分が勉強になってしまいました。これで組織の学習効率がすこしでも高くなれば幸いです。