forestec

勉強した内容をつらつらと備忘録として記していきます。

Atomic Design について考える 〜Buttonコンポーネント編〜

かなり久々の更新。

最近仕事ではc#をやりつつフロントもちょこちょこ触っています。
現在使用しているのはjQueryですが、
いい加減フロント改善したいという機運が社内でも高まってきていて 、
次はVueかReactかみたいな感じになっています。

なので自分も焦ってVue.jsとAtomic Design について勉強しているところです。
その過程で得た自分の理解というか整理を残しておきたくてこの記事を書きます。

開発環境はNuxt.jsなのでその前提で書いてはいますが本題はあくまでAtomic Designについてです。

Buttonコンポーネントについての記事なんて世の中にありふれてるじゃねーかという感じではありますが、
Button一つとっても考えることはたくさんあったので気にせず書いていきます。

ButtonはAtomsかMoleculesか

まずはAtomic DesignにおいてButtonをどこに分類するかです。

先に言ってしまうと自分はどちらでも間違っていないと思っています。
それは何も考えずにどっちに作ってもいいよって意味ではなく、
デザイン定義に沿って決めるべきだと考えているからです。

例えば以下のようなボタンを作成したいとなった場合

f:id:forest_yuzuremon:20190525155930p:plain     f:id:forest_yuzuremon:20190525160144p:plain

1つ目はベーシックなテキストだけ入ったButton
2つ目はIconが入ったButton

前者のButtonであれば迷わずAtomsかなと自分は思います。

ですが後者のButtonだとどうでしょう?
IconはAtomsとして定義しておくべきな気がします。
それが中に含まれているButtonをAtomsとするべきでしょうか?

ButtonをAtomsとした場合、
Buttonコンポーネント内でIconコンポーネントを呼び出すことになってしまいます。
Atomsには最小の単位まで分解したものを定義するはずなのでこれだと違和感を感じます。
ではこの場合はIconコンポーネントをAtoms、
ButtonコンポーネントをMoleculesとして定義すればどうでしょう。
そうすればButtonコンポーネントからIconコンポーネントを呼び出しても違和感は無くなりました。

これで解決したかと思いきや1つ問題が発生しています。
なぜなら後者のButtonをMoleculesにした場合、
前者のButtonはどうすればいいのかとなってしまいます。
前者のButtonをAtomsに定義しても良いですが、
AtomsにもMoleculesにもButtonが存在しているのが少し気持ち悪いです。
かといって前者のButtonもMoleculesに入れても違和感があります...

といった感じで自分も色々悩んだのですが次のような方法で解決しました。

色々言ったがButtonはやっぱりAtomsにする

サブタイの通りButtonはAtomsにしました。
IconもAtomsにしました。
そして1つのButtonコンポーネントでIconが付く場合も付かない場合も表現出来るようにします。

自分は以下のようなButtonコンポーネントを作成しました。

<template lang="pug">
button.btn
slot(name='leftIcon')
span.btn-label {{ label }}
slot(name='rightIcon')
</template>

vue.jsのslot機能を使っているので、
Buttonコンポーネントを使用する側の親コンポーネントが 、
slot部分に対してIconコンポーネントを挿入した場合はIcon付きのButton、
挿入しない場合はテキストのみのButtonを表示できるようになっています。
さらにrightIconも定義して右側にIconを挿入することも出来るようになっています。

これでIconコンポーネントはもちろんButton以外でも再利用可能ですし、
Buttonコンポーネントも1つのコンポーネントで様々なButtonを表現出来るようになりました。

 

もっとButtonコンポーネントについて考える

Icon問題については上の実装で解決できましたが、
Buttonコンポーネントについてはまだまだ考えないといけないことがあります。
なので順番に整理していきましょう。

 

Buttonの色を変えたい

以下のように先ほど定義したようなベーシックな見た目のButtonだけでなく、
赤色のButtonも定義したいとなったとしたらどうすればいいでしょうか?

f:id:forest_yuzuremon:20190525155930p:plain     f:id:forest_yuzuremon:20190525171944p:plain

新しくRedButtonコンポーネントを作成などしているとコンポーネントが大量に出来てしまいます。

なのでButton.vueを変更して動的に見た目を変更出来るようします。

 

<template lang="pug">
button.btn(:class="theme")
slot(name='leftIcon')
span.btn-label {{ label }}
slot(name='rightIcon')
</template>

<script>
export default {
props: {
label: {
type: String,
required: false,
default: ''
},
theme: {
type: String,
required: true
}
}
}
</script>

<style lang="scss" scoped>
.btn {
display: flex;
align-items: center;
justify-content: center;
border-radius: 5px;

&.white {
background-color: #FFFFFF;
border: 1px solid #E0E0E0;
color: #262322;
}

&.red {
background-color: #CB4248;
color: #FFFFFF;
}
}
</style>

 

Buttonコンポーネントを使用する際にpropstheme変数に何が指定されているかでclassを動的に変更出来るようになっています。

themewhiteを指定

f:id:forest_yuzuremon:20190525155930p:plain

themeredを指定

f:id:forest_yuzuremon:20190525171944p:plain

これでButtonコンポーネントがさらに汎用的に使えるようになってきました。

ですがこれではまだ問題があります。
もしRed Buttonを使っていたけど色を変更して、
全てBlue Buttonに変更したいとなった場合はどうすればいいでしょうか?

Buttonコンポーネント内にBlue Buttonの定義を作成して、
プロジェクト内のthemeredを指定している箇所を全てリファクタする!
気が遠くなりそうです...

これは色に対して依存をしているのが問題だと思うので、
自分はボタンの持つ役割に対して依存したclass名を使うようにしています。

デザインを定義する際にButtonのスタイルだけでなく、
それぞれのスタイルのButtonが持つ役割も定義されているのではないかと思います。
その役割に対してclass名を依存させておけばcssの変更だけで済むようになるのではないでしょうか。

ということでclass名を変更します。

 

<template lang="pug">
button.btn(:class="theme")
slot(name='leftIcon')
span.btn-label {{ label }}
slot(name='rightIcon')
</template>

<script>
export default {
props: {
label: {
type: String,
required: false,
default: ''
},
theme: {
type: String,
required: true
}
}
}
</script>

<style lang="scss" scoped>
.btn {
display: flex;
align-items: center;
justify-content: center;
border-radius: 5px;

&.cancel {
background-color: #FFFFFF;
border: 1px solid #E0E0E0;
color: #262322;
}

&.submit {
background-color: #CB4248;
color: #FFFFFF;
}
}
</style>

今回はsubmitcancelという2つを設定してみました。


Buttonを無効化させたい

次はButtonを無効化させたい場合はどうすればいいかについて考えてみます。
これは基本的にはさっきと同じでv-bindを使えば解決すると思います。

<template lang="pug">
button.btn(:class="theme")(:disabled="disabled")
slot(name='leftIcon')
span.btn-label {{ label }}
slot(name='rightIcon')
</template>

<script>
export default {
props: {
label: {
type: String,
required: false,
default: ''
},
theme: {
type: String,
required: true
},
disabled: {
type: Boolean,
required: false,
default: false
}
}
}
</script>

<style lang="scss" scoped>
.btn {
display: flex;
align-items: center;
justify-content: center;
border-radius: 5px;

&.cancel {
background-color: #FFFFFF;
border: 1px solid #E0E0E0;
color: #262322;
}

&.submit {
background-color: #CB4248;
color: #FFFFFF;
}

&:disabled {
background-color: #E5E5E5;
border: 1px solid #CBCFCC;
color: #CBCFCC;
}
}
</style>

propsdisabled変数をBooleanで定義して値が入ってくればButtonをdisabledにしています。
無効化したくない時はそもそも変数を指定しなくても問題なくしてあります。


Buttonのサイズを変更したい

最後はButtonのサイズについて考えてみたいと思います。

<template lang="pug">
button.btn(:class="theme")(:disabled="disabled")(:style="{width: width, height: height}")
slot(name='leftIcon')
span.btn-label {{ label }}
slot(name='rightIcon')
</template>

横幅と高さを変数で受け取ることでサイズを動的に変更出来るようにしました。

ですがこれもあまりいい方法だとは思えません。
Atomic Designの利点としてデザインの統一化を行えることが挙げられると思いますが、
上記の方法だと統一化は行えるでしょうか?
指定の自由度が高すぎてサイズがバラバラのButtonがたくさん出来てしまいかねません。
なので自分はButtonの色を変更する際と同じ方法を用いています。
Buttonのサイズについてもあらかじめデザインが定義されているものではないかと思うので。

<template lang="pug">
button.btn(:class="[theme, size]")(:disabled="disabled")
slot(name='leftIcon')
span.btn-label {{ label }}
slot(name='rightIcon')
</template>

<script>
export default {
props: {
label: {
type: String,
required: false,
default: ''
},
theme: {
type: String,
required: true
},
disabled: {
type: Boolean,
required: false,
default: false
},
size: {
type: String,
required: false,
default: 'large'
}
}
}
</script>

<style lang="scss" scoped>
.btn {
display: flex;
align-items: center;
justify-content: center;
border-radius: 5px;

&.cancel {
background-color: #FFFFFF;
border: 1px solid #E0E0E0;
color: #262322;
}

&.submit {
background-color: #CB4248;
color: #FFFFFF;
}

&:disabled {
background-color: #E5E5E5;
border: 1px solid #CBCFCC;
color: #CBCFCC;
}

&.large {
width: 210px;
height: 42px;
}

&.medium {
width: 120px;
height: 35px;
}

&.small {
width: 75px;
height: 30px;
}
}
</style>

large, medium, smallの3種類のサイズを定義してみました。
これでButtonのサイズも統一化出来ると思います。

 

設定されたButton一覧

作成したButtonコンポーネント、Iconコンポーネントで作成されたButtonの一覧はこんな感じになりました。

f:id:forest_yuzuremon:20190526000547p:plain

ButtonコンポーネントとIconコンポーネントだけで様々なButtonを作ることが出来ました。

 

最後に

思った以上に長くなってしまいましたが、
今回この記事に書いたことはあくまで自分なりの理解なので、
ここは間違ってるとかこうした方がいいとかもしくは自分はこんな整理でやってるとかコメントいただけると嬉しいです。

Button以外のコンポーネントについても考えがまとまったら記事を書いてみようと思います。

RailsのActive Adminで簡単な管理画面を作ってみた話

最近Railsを一度触ってみたかったので簡単なブログサービスを作ってました。
その中で管理画面の作成にActive Adminを使ったので備忘録として残しておこうと思います。

環境

Active Adminとは

ブログとかの編集画面(記事の作成とか編集とか)みたいなものをほぼ自動で作成してくれるruby gemのことです。
すごく便利で導入もかなり簡単。

ブログとかなら記事を管理するModelを作成⇨Active Admin導入ってすると、
記事の新規作成、更新、削除の管理画面がたちまち完成するといった感じです。

今回の備忘録

導入の細かい話はググればいくらでも出てくるので特にしません。

今回ブログを作る上で、
このはてなブログやQiitaのようにMarkDownでの記事の記述と、
それを編集画面でリアルタイムプレビュー出来る機能を実装したかったのでデフォルトの管理画面のカスタマイズを行いました。
その際の備忘録です。

自分は記事を管理するarticleというModelを作成してそれを管理画面で編集するようにしているのでその前提で話を進めます。

formをカスタマイズ

記事の新規作成画面と編集画面では共通のformが使用されているのでそれをカスタマイズします。
まずはapp/adminarticle.rbファイルを修正します。
Active Adminを導入した際に自動生成されているはず。
以下の一行を追加

form partial: "form"

"form"と指定しているのがファイル名なので、
app/views/admin/article配下に_form.erbファイルを作成します。
以下はformのテンプレートです。

<%= semantic_form_for [:admin, @article], builder: ActiveAdmin::FormBuilder do |f| %>
  <fieldset class="inputs">
    <ol>
      <%= f.input :name %>
    </ol>
  </fieldset>

  <fieldset class="actions">
    <ol>
      <li><%= f.submit "保存" %></li>
      <li class="cancel"><%= link_to("取り消す", admin_users_path)%></li>
    </ol>
  </fieldset>
<% end %>

inputsクラス内に必要なinput項目を追加していくだけです。
<input>タグで記述することも可能です。

一覧画面をカスタマイズする場合

一覧画面のカスタマイズ方法も記載しておきます。
先程と同様にapp/adminarticle.rbファイルを修正します。
formのカスタマイズと同じくerbファイルを作成して読み込ませることもできますが、
一覧画面に表示する項目の変更だけしたい場合などは以下の方法でも可能です。

  index do
    column :id
    column :published
    column :created_at
  end

index do 〜 endの間に表示したいカラムを指定して表示項目を変更できます。

最後に

Active Admin初めて触りましたが導入までがすごく簡単で早いです。
Railsは優秀なgemがたくさんあって驚きました。
ただカスタマイズの仕方については少し苦戦したので書き残しました。。。
他にもactionなども自前のControllerでカスタマイズしたり出来るようです。

docker-composeを使ってみる

docker-composeを使っての環境構築を行なってみたのでその備忘録。

環境

構築する環境は以下。

  • Python3.7.1
  • Redis5.0.2

Dockerfileの作成

Python用のDockerfileを作成します。

FROM python:3.7

RUN pip install redis
RUN pip install hiredis

installしてるのはredishiredisのみです。
必要なものがあれば適宜追記で。

docker-compose.ymlの作成

docker-compose.ymlを以下のように作成します。

version: '2'
services:
  app:
    build: .
    ports:
      - "5000:5000"
    volumes:
      - .:/app
    links:
      - redis
    depends_on:
      - redis
    tty: true
  redis:
    restart: always
    image: redis:5.0.2
    volumes:
      - ./data/redis:/data
    command: redis-server --appendonly yes

serviceとしてappredisを記載しています。

app

  • build:
    上記で作成したDockerfileのパスを指定します。

  • ports:
    右側にコンテナ内で使用するポート、左側にそれとマッピングさせるホストのポートを指定します。
    ホストのポートを指定しない場合はランダムで割り振られるようです。

  • volumes:
    コンテナ内のディレクトリをマウントする際に指定します。
    今回は/appをマウントしています。
    左側にはホストのディレクトリを指定。

  • links:
    PythonからRedisを使用出来るようにredisを記載しています。

  • depends_on:
    serviceの依存関係を指定します。
    appより先にredisが立ち上がるようにしています。

  • tty:
    ポート待受とかしていないコンテナの場合、docker-compose upで起動してもすぐに終了してしまいます。
    ttyをtrueに設定しておくことでコンテナが起動し続けます。

redis

  • restart:
    OSの起動時にコンテナを自動起動させるためにalwaysを指定しています。

  • image:
    Dockerイメージを記載。
    redis:5.0.2を指定しています。

  • volumes:
    /dataをマウントすることでデータを永続化させることが出来るようです。

  • command:
    command:を指定することでデフォルトのコマンドの書き換えを行っています。
    --appendonly yesを指定しないとデータが作られないようです。

ビルド

作成したdocker-compose.ymlをビルド

$ docker-compose build

最後にSuccessfullyと表示されていればOK。

起動

$ docker-compose up

これでPython、Redisの各コンテナが立ち上がれば構築完了

動作確認

まずはRedisコンテナに入って確認します。

$ docker exec -it [コンテナ名、またはコンテナID] /bin/bash

コンテナに入れたらredis-cliを使用してみます。

$ redis-cli
127.0.0.1:6379> SET test test
OK
127.0.0.1:6379> GET test
"test"
127.0.0.1:6379>

次はPythonからRedisに接続してみます。 Redisコンテナで以下のコマンドを実行してIPを取得します。

$ hostname -i
172.20.0.2

以下のapp.pyファイルを作成

import redis

# Redisに接続
pool = redis.ConnectionPool(host='172.20.0.2', port=6379, db=0)
r = redis.StrictRedis(connection_pool=pool)

r.set('key', 'value')
print(r.get('key'))

hostに上記で調べたIPを設定。
app.pyを実行。

$ python app.py
b'value'

PythonからもRedisが実行出来ることが確認できました。
これで完了です。

Pythonで登録したRSSの新着記事をSlackで受け取るツールを作成してみた

定期購読したいサイトのRSS登録を行い、
新着記事が存在した場合はそれをSlackへ通知するツールをPythonで作成してみました。

機能

  • RSS登録機能
  • 登録RSS一覧表示機能
  • RSS削除機能
  • 新着記事通知機能

ソースはgithubにも上げているので詳しくはそちらを参照下さい。 github.com

RSS登録機能

Slack上からスラッシュコマンドで新規RSSを登録する機能
RSS情報はRedisのハッシュを使って以下のように管理

('RSSから取得したブログタイトル', 'url', 'スラッシュコマンドから受け取ったRSSURL')
('RSSから取得したブログタイトル', 'previous_time', '最後に取得した最新記事の作成日時')

ここに登録したprevious_timeより作成日時が新しい記事のみ取得してSlackへ送信しています。

登録コマンドで送られてくるRSSURLのチェック

# URL形式チェック
if re.match(r'^https?:\/\/', url):
    rss = feedparser.parse(url)
    …
else:
    msg = 'URLは http://、https:// から記述してください。'

http://https://で始まる文字列でない場合はエラーメッセージを返却

f:id:forest_yuzuremon:20181204013121p:plain

RSSを読み込んだ結果の判定も実施

rss = feedparser.parse(url)
# パースが成功している場合のみ処理を行う
if rss.bozo == 0:
    title = rss.feed.title
    self.redis.add(title, url, self.time_format(rss.entries[1].published))
    msg = 'RSS登録完了: {}'.format(title)
else:
    # パースに失敗した場合はエラーを出力して処理続行
    print('url: {}, error: {}'.format(url, rss.bozo_exception))
    msg = 'URLが存在しないか、またはRSSとして読み取り出来ませんでした。'

feedparserから返却されるbozoで判定
bozoが0ならパース成功、
bozoが1ならパース失敗

f:id:forest_yuzuremon:20181204013010p:plain

RSS一覧表示機能

Slack上からスラッシュコマンドで登録されているRSSの一覧表示を行う機能

# RSSの一覧を表示
msg = 'RSSList : \n - ' + '\n - '.join([key.decode() for key in self.redis.get_key_all()])

Redisからkeyの一覧を取得してmsgに詰めているだけです。
見た目が悪かったので1タイトルずつ改行して表示させています。

f:id:forest_yuzuremon:20181204012859p:plain

RSS削除機能

Slack上からスラッシュコマンドで登録されているRSSを削除する機能
一覧表示機能で登録されているRSSを確認して削除するイメージです。

# RSS削除を実施
title = params[b'text'][0].decode()
self.redis.delete(title)
msg = 'RSS削除完了: {}'.format(title)

スラッシュコマンドで受け取ったタイトルをkeyにしてRedisから削除しているだけです。

f:id:forest_yuzuremon:20181204012740p:plain

新着記事通知機能

登録されているRSSを順番に読み込んでいき、
新着記事が存在する場合のみSlackへ通知を行っています。

登録されているkey分繰り返し処理

# 登録されたRSS数分処理を行う
for key in self.redis.get_key_all():
    # RSSのURL、最後に取得した記事の作成日時を取得
    rss_config_dict = self.redis.get(key)
    url = rss_config_dict[b'url'].decode()
    # RSS読み取り
    rss = feedparser.parse(url)
    …

Redisから取り出したkeyに紐付くRSSURLを読み込んでパースしています。

Slackへ送信するattachmentsを作成

def create_attachments(self, rss, last_items, key):
    """取得した記事からattachmentsを生成"""
    attachments = []
    for i in range(last_items, -1, -1):
        # 取得した記事の作成日時を保存
        published = rss.entries[i].published
        self.redis.update_previous_time(key, self.time_format(published))

        summary = rss.entries[i].summary
        # 投稿した記事をattachmentに追加
        attachments.append(
            {
                'title' : '<{}|{}>'.format(rss.entries[i].link, rss.entries[i].title),  # タイトルと記事のリンク
                'text' : re.compile(r'<[^>]*?>').sub('', summary) if summary is not None else '',  # 記事のサマリー
                'footer' : self.time_format(published)  # 記事の投稿時間
            }
        )
    return attachments

last_itemsには取得する新着記事が1件の場合は0、2件の場合は1が入ってくるイメージです。
Redisのprevious_timeを記事の作成日時で更新します。
これで次回実行時に同じ記事を取得しないようにしています。
Slackへ送信しているのはtitlelinksummarypublishedのみです。

それをスレッド化して送信すると以下のようになります。

f:id:forest_yuzuremon:20181204014432p:plain

スレッドを開くと以下のようになっています。

f:id:forest_yuzuremon:20181204014542p:plain

こんな感じでSlackからブログの購読が可能になりました。
自分はこれをcronで毎朝7時に実行されるようにして通知を受け取っています。

Pythonで作成したWebAPIをherokuにデプロイ

Pythonで作成したWebAPIをherokuにデプロイしてみたのでその備忘録

準備

まずはherokuにデプロイするために必要なファイルの準備を行います。

$ pip install gunicorn
$ pip freeze > requirements.txt
$ echo python-3.7.1 > runtime.txt
$ echo web: gunicorn [実行ファイル]:app --log-file=- > Procfile

アプリケーションサーバとしてgunicornをインストール
freezeを使用してインストール済みパッケージをrequirements.txtに記載
runtime.txtに使用しているPythonのバージョンを記載
Procfileにはどのプロセスを使用するかを定義、[実行ファイル]には実行する.pyファイルを記載

git管理

git⇨herokuへデプロイするのでgitで管理できるようにします。

$ git init
$ git add .
$ git commit -m "initial commit"

herokuへデプロイ

git⇨herokuへデプロイを行います。

$ heroku login
$ heroku apps:create
$ git push heroku master

herokuアカウントを作成していない場合は作成。
$ heroku apps:createで作成した場合はランダムでアプリケーション名が設定されます。
必要な場合はアプリケーション名を指定する。

エラーなくpushできれば完了です。

PythonでQiitaの新着記事を取得してSlackへ連携してみる

QiitaAPIで自分がフォローしているタグが付いた記事を最新10件取得して、
それをさらにSlackへ連携するツールを作成してみました。

前提

Pythonは3.7.1を使用しています。
Python環境構築については特に触れません。
・Slack APIの作成部分についても触れません。
・ソースはgithubにも上がっています。 github.com

フォローしているタグ一覧を取得

まずはQiitaAPIから自身がフォローしているタグの一覧を取得します。
以下のURLで取得出来ます。

http://qiita.com/api/v2/users/[Qiitaアカウント]/following_tags?page=1

コードは以下です。

import requests

# Qiitaアカウント
qiita_user = ''

# 自分がfollowしているタグの一覧を取得
url = 'http://qiita.com/api/v2/users/' + qiita_user + '/following_tags'
params = {'page': '1'}
following_tags = requests.get(url, params=params).json()

following_tagsに取得結果が入っています。
中身はこんな感じです。

[
 {
  'followers_count': 2106,
  'icon_url': ,
  'id': 'React',
  'items_count': 1999
 }
]

4つフィールドが取得出来ましたが、今回使用するのは「id」のみです。
これがタグ名になっています。

Qiita新着記事を取得

次はQiitaAPIを使用して新着記事を取得する部分です。
以下のURLで取得出来ます。

http://qiita.com/api/v2/items?page=1&per_page=10

これで新着記事が10件取得出来ます。
「per_page」の件数を変更すると取得件数が変わります。

import requests

# QiitaAPIを使用して新着記事を10件取得
url = 'http://qiita.com/api/v2/items'
params = {'page': '1', 'per_page': '10'}
new_articles = requests.get(url, params=params).json()

new_articlesに取得結果が入っています。
中身はこんな感じです。

[
 {
  'rendered_body': ,
  'coediting': ,
  'comments_count': ,
  'created_at': ,
  'group': ,
  'id': ,
  'likes_count': ,
  'private': ,
  'reactions_count': ,
  'tags': [
   {
    'name': 'Python',
    'versions': []
   }
  ],
  'title': ,
  'updated_at' : ,
  'url': ,
  'user': {
   'description': ,
   'facebook_id': ,
   'followees_count': ,
   'followers_count': ,
   'github_login_name': ,
   'id': ,
   'items_count': ,
   'linkedin_id': ,
   'location': ,
   'name': ,
   'organization': ,
   'permanent_id': ,
   'profile_image_url': ,
   'twitter_screen_name': ,
   'website_url': 
  },
  'page_views_count': 
 }
]

例によってほとんどの項目は使用しません。。。

ひとまずこれでフォローしているタグの一覧と新着記事の取得が出来ました。

記事の絞り込み

では次にフォローしているタグが含まれる記事のみに絞り込みます。
絞り込みは記事の取得時のURLでクエリパラメーターを追加してあげることで可能です。

http://qiita.com/api/v2/items?page=1&per_page=10&query=[ここに条件を指定]

先程取得してきたタグの一覧を条件に含める部分のソースは以下になります。

# 自分がfollowしているタグの一覧を取得
url = 'http://qiita.com/api/v2/users/' + qiita_user + '/following_tags'
params = {'page': '1'}
following_tags = requests.get(url, params=params).json()

# フォローしているタグを記事取得時の検索で使用出来るようにする
# 記事取得時のqueryにtag:[タグ名]でそのタグが含まれる記事を取得出来るので、
# フォローしているタグのいずれかを含むものを取得出来るようにORでつなげていく
query = ''
for i in range(len(following_tags)):
    query = query + 'tag:' + following_tags[i]['id'] + " OR "

# QiitaAPIを使用してフォローしているタグを含む新着記事を10件取得
url = 'http://qiita.com/api/v2/items'
params = {'page': '1', 'per_page': '10', 'query': query.rstrip(' OR ')}
new_articles = requests.get(url, params=params).json()

タグが含まれているかの条件はtag:[タグ名]で可能です。
検索時の条件をORで繋げて指定することが出来ます。

ここまでで自分がフォローしているタグを含む新着記事10件を取得することが可能になりました。

Slackへ送信

次はSlackへ取得した記事を送信する部分です。
10件分の記事を送信するので、
1回分の送信をスレッド化してメッセージが長くならないようにしてみます。

まずは最初にスレッドタイトルの送信部分です。

# slack送信用のHeader
headers = {
    'Content-Type' : 'application/json; charset=utf-8',
    'Authorization' : 'Bearer ' + slack_token
}

# slackへ送信(スレッドタイトル)
web_hook_url = "https://slack.com/api/chat.postMessage"
res = requests.post(web_hook_url, data = json.dumps({
    'channel': slack_channel,  # チャンネル
    'attachments': [{ 'title' : 'Qiita新着投稿' }],  # 通知内容
}), headers = headers)

「Qiita新着投稿」というattachmentsだけを送信するシンプルなものになっています。
headerに「Content-Type」と「Authorization」を指定しています。
「slack_token」にはSlackAPIのトークンを発行したものを指定しています。
「slack_channel」には投稿するチャンネルを指定しています。

次にスレッド化して記事を送信します。
先程送信したスレッドタイトルのレスポンスから「ts」を取得します。
それを記事送信時に「thread_ts」にセットすることで紐付けることが可能。
コードは以下です。

# slackへ送信(スレッドタイトルに紐付けて記事をスレッド化)
# スレッドタイトルを送信した際のレスポンスの'ts'を'thread_ts'にセットすることで紐付けしている
requests.post(web_hook_url, data = json.dumps({
    'channel': slack_channel,  # チャンネル
    'attachments': attachments,  # 通知内容
    'thread_ts' : res.json()['ts'],  # スレッドタイトルの'ts'
}), headers = headers)

「attachements」には取得した記事を入れています。
生成のロジックは以下です。

# 取得した記事を1つずつattachmentに詰めていく
attachments = []
for i in range(len(new_articles)):
    # 記事のタグを取得
    tags = 'タグ:'
    for tag in new_articles[i]['tags']:
        tags = tags + '[' + tag['name'] + ']'

    # 投稿した記事をattachmentに追加
    attachments.append(
        {
            'title' : '<' + new_articles[i]['url'] + '|' + new_articles[i]['title'] + '>',  # タイトルと記事のリンク
            'text' : tags,  # 記事のタグ
            'author_name' : new_articles[i]['user']['id'],  # 投稿者名
            'author_link' : 'https://qiita.com/' + new_articles[i]['user']['id'],  # 投稿者のQiitaページへのリンク
            'author_icon' : new_articles[i]['user']['profile_image_url'],  # 投稿者のQiitaアイコン
            'footer' :  new_articles[i]['updated_at']  # 記事の投稿時間
        }
    )

記事自体はセットせず、記事タイトルとリンクなどを送信しています。
これで全て完成です。
実際にslackへ送信すると以下のようになります。
スクリーンショット 2018-11-25 0.01.48.png
ちゃんとスレッド化されています。
スレッドを開くと以下のような感じです。
スクリーンショット 2018-11-25 0.03.41.png
見やすく表示させることが出来ました。

RustでSlackにカスタム絵文字を登録するツールを作成してみた

Slackのカスタム絵文字

以下みたいなアクションで使う絵文字
f:id:forest_yuzuremon:20181128193054p:plain

このカスタム絵文字の登録をSlackから実施出来るようにRustでAPIを実装してみました。

このソースはgithubにもあげています。 github.com

概要

以下のようなAPIになっています。
・スラッシュコマンドで画像のURLと登録絵文字名をリクエストとして受け取る
・画像のURLからアップロードする画像を取得する
・Slackの絵文字アップロード画面をPOSTで叩く

Slackではカスタム絵文字登録を行うAPIは公開されていませんでした。
なので、Slackの画像アップロード画面を直接APIから叩くような仕様になっています。

api_tokenの取得方法について

Slackの画像アップロード画面を叩く際にapi_tokenが認証に必要になります。
api_tokenは以下から取得できます。
https://[自身のワークスペース].slack.com/customize/emoji
ページを開いたらF12を押してデベロッパーツールを開きます。
その状態でページを再読み込みし、上部のSourcesを選択します。
そこで表示されるHTML内のapi_tokenを使用しています。

実装

リクエストの分解

// 空白で文字列を分割する 絵文字画像URL 登録絵文字名
let v: Vec<&str> = params.get("text").unwrap().split(' ').collect();
let emoji_url: &str = v.get(0).unwrap();
let emoji_name: &str = v.get(1).unwrap();

スラッシュコマンドで送られてきたリクエストからparamsを取得、
そのparamsを空白で分割し、1つ目を画像URL、2つ目を絵文字登録名として取り出します。

画像の取得

// URLから画像を取得してローカルに保存する
fn download_image(url: &str) {
    // 画像データの取得
    let image = reqwest::get(url).unwrap();
    let file = File::create(FILE_NAME).unwrap();
    let mut br = BufWriter::new(&file);
    for byte in image.bytes() {
        br.write(&[byte.unwrap()]).unwrap();
    }
    br.flush().unwrap();

    // 保存した画像に読み取り権限を付与
    let mut perms = file.metadata()
        .expect("メタデータの取得に失敗しました。")
        .permissions();
    perms.set_readonly(true);
    file.set_permissions(perms)
        .expect("権限付与に失敗しました。");
}

リクエストから取得した画像URLで画像を取得し、
それを一時的にローカルに保存しています。 後でSlackにアップロードするために読み取り権限を付与しています。

絵文字アップロード

// 絵文字のアップロードを行う
fn upload_emoji(url: &str, form: Form, emoji_name: &str, api_token: &str)
-> impl Future<Item = String, Error = Error> {
    // カスタム絵文字アップロードリクエスト
    let mut res = Client::new()
        .post(url)
        .query(&[
            ("mode", "data"),
            ("name", emoji_name),
            ("token", api_token)])
        .multipart(form)
        .send()
        .unwrap();

    match res.text() {
        Err(e) => fut_ok(String::from(format!("アップロード失敗:{:?}", e))),
        Ok(result) => {
            let v: Value = serde_json::from_str(result.as_str()).unwrap();

            // エラー判定
            match v["ok"].as_bool() {
                Some(true) => fut_ok(String::from(format!("アップロード完了::{}:", emoji_name))),
                Some(false) => fut_ok(String::from(format!("アップロード失敗:{}", v["error"]))),
                None => fut_ok(String::from(format!("アップロード失敗:{}", v["error"]))),
            }
        }
    }
}

Slackへの絵文字のアップロードを行なっています。
crateはrequwestを使用しています。
ここで設定しているemoji_nameはスラッシュコマンドから、
api_tokenは上で記載した取得方法で取得したものを使用しています。
Slackからのレスポンスも判定しており、
正常に登録できた場合は応答として「アップロード完了:[登録した絵文字]」が、
登録に失敗した場合は「アップロード失敗:[エラーメッセージ]」が返却されるようになっています。

使用イメージ

addemojiというスラッシュコマンドを使用しています。 cat_iconという名前で登録してみます。 f:id:forest_yuzuremon:20181128232620p:plain
登録が完了すると以下のように表示されます。
f:id:forest_yuzuremon:20181128232850p:plain

これでスマホのSlackアプリからも登録出来るようになりました。