Qiita:Team + Hubot + textlintで文章校正を自動で実行する

f:id:vasilyjp:20170427164516j:plain

こんにちは、Androidエンジニアの堀江です。最近はiOSのプロジェクトに参加してSwiftを書いています。新しいことを始めるのは楽しいですね。

ところで今ご覧になられている弊社の技術ブログ「VASILY DEVELOPERS BLOG」は、VASILYのエンジニアが交代で更新しています。記事に何を書くかは各エンジニアの裁量に任されていますが、公開前に社内でレビューをするようにしています。

レビューをする際には、以下のような点に注意しています。

  • 誤字脱字・文法上の間違いが無いか
  • 間違った情報が無いか
  • 文章中にわかりにくい表現や解説が無いか

このうち、誤字脱字・文法上の間違いは、文章校正ツールを使うことで機械的にチェックすることが可能です。それによって、文章そのもののより本質的なレビューに時間を割くことができます。記事はレビュー前に文章校正済みであるのが理想ですが、実際には忘れる事も多いです。そのため、ある程度自動的に文章校正を実行してくれると捗ります。

本記事では、botを利用することで文章校正の実行を自動化する試みについてご紹介します。

記事公開までの流れについて

記事の公開までの流れは、概ね以下の通りになっています。

  1. 記事を執筆
  2. 記事のレビュー
  3. 指摘点の修正
  4. 公開

VASILYでは社内の情報共有にQiita:Teamを使用しており、ブログ記事についてもある程度書いた段階でQiita:Teamに投稿してしまいます。普段どのようにQiita:Teamを使っているかは、以下の記事にまとめて頂いています。

https://codeiq.jp/magazine/2017/04/50255/codeiq.jp

そして、記事の執筆が完了するとSlackのエンジニアチャンネルにレビュー依頼を投げ、指摘を受けた点について修正後、公開します。また、何時誰が記事を書くかはスケジュールを予め決めています。(歴史的経緯により社内ではDEVELOPERS BLOGの事をテックブログと呼んでいます)

f:id:vasilyjp:20170429190620p:plain

どう自動化するか?

記事がQiita:Teamに投稿・更新された際に文章校正を自動で実行します。Qiita:Teamで発生する様々なイベントはWebhookで受取ることができるので、記事が投稿・更新されたタイミングで任意の処理を実行可能です。Webhookの受け取りには、Heroku上にホストしたHubotを利用します。そして、文章校正にはtextlintを使用します。

全体の流れ

全体の流れを図にすると以下のようになります。

f:id:vasilyjp:20170429190706p:plain

① 記事の投稿・更新
② HubotがWebhookを受け取り記事本文を取得
③ 記事本文に対してtextlintで文章校正を実行
④ 文章校正の結果を取得
⑤ 対象の記事に文章校正の結果をコメント
⑥ 文章校正の結果を確認

②での記事本文の取得、⑤でのコメント投稿には、Qiita API v2(以下Qiita API)を使用します。

Webhookの概要と設定方法

Qiita:DeveloperのWebhooksにWebhookの仕様がまとまっています。ドキュメントより、Qiita:Teamから受け取れるイベントの一覧は以下のようになっています。

イベント名 概要
ItemCreated 記事が作成されたときに送信
ItemDestroyed 記事が削除されたときに送信
ItemUpdated 記事が更新されたときに送信
CommentCreated コメントが作成されたときに送信
CommentDestroyed コメントが削除されたときに送信
CommentUpdated コメントが更新されたときに送信
MemberAdded チームメンバーが追加されたときに送信
MemberRemoved チームメンバーが離脱したときに送信
PingRequested Webhookの設定画面からテストを行ったときに送信
ProjectCreated プロジェクトが作成されたときに送信
ProjectDestroyed プロジェクトが削除されたときに送信
ProjectUpdated プロジェクトが更新されたときに送信

リクエスト

イベントが発生すると指定したURLにPOSTリクエストが送信されます。リクエストボディにJSON形式でエンコードされた文字列が含まれ、これをPayloadと呼びます。

また、共通リクエストヘッダとしてX-Qiita-Event-ModelPayloadmodelプロパティと同様の値が、X-Qiita-TokenにWebhookに割り当てられたトークンが含まれます。

Payload

Payloadには、必ずactionプロパティとmodelプロパティが含まれ、actionプロパティは「どのイベントが発生したか」を表す文字列で、modelプロパティは「何に対してイベントが発生したか」を表す文字列です。この他に各イベントに関連するデータが含まれます。

例えば、記事作成を表すItemCreatedイベントの場合、Payloadは以下の様になります。その他のイベントについては公式ドキュメントを参照してください。

プロパティ名 説明
action String "created"
model String "item"
item Item 作成された記事
user User 作成したユーザ

ItemUser型がどのようなプロパティを含むかは、公式ドキュメントのTypesから確認できます。

設定方法

Webhookのリクエスト先や、通知したいイベントの設定は、設定メニューから設定をします。通知したいイベントにチェックを付け、URLにはイベントを送信したいURLを指定します。Webhookの設定はチームの管理者のみ設定メニューに表示されます。

f:id:vasilyjp:20170429193403p:plain

textlintの設定と実行

textlintはテキスト向けのLintツールで、予め定義したルールに沿って文章校正を行ってくれます。textlintの利用方法としてCLIから利用することが多いですが、今回は、Hubotのコードからモジュールとして実行します。

ここでは、textlintの設定とモジュールとしての実行方法について紹介します。ローカルで動作を確認できるようサンプルプロジェクトを用意しました。

github.com

サンプルプロジェクトのセットアップ

サンプルプロジェクトをcloneしnpm installを実行します。Node.jsのバージョンはv6.10.2で動作確認をしています。Node.jsをインストール済みで無い場合、nvmnodebrew等を使用してインストールするとNode.jsのバージョンを簡単に切り替えることができます。

git clone [email protected]:horie1024/textlint-sample.git 
cd textlint-sample
npm install

.textlintrcの設定

サンプルでは、textlintのルールとしてtextlint-ja/textlint-rule-preset-ja-technical-writingを使用しています。.textlintrcは以下のようになります。

{
  "rules": {
    "preset-ja-technical-writing": true
  }
}

モジュールとしてtextlintを実行

モジュールとしてtextlintを実行するコードは以下のようになります。TextLintEngineをインスタンス化する際にconfigFile.textlintrcのパスを指定します。そして、TextLintEngineCore#executeOnTextを使用すると引数に取ったテキストに対してtextlintを実行することができます。引数にファイルを指定したい場合TextLintEngineCore#executeOnFilesを使用します。

const TextLintEngine = require("textlint").TextLintEngine;

const engine = new TextLintEngine({
  configFile: "config/.textlintrc"
});

engine.executeOnText("# test!!").then(results => {

  console.log(results[0].messages);

  if (engine.isErrorResults(results)) {
    const output = engine.formatResults(results);
    console.log(output);
  }
});

実行結果は以下のように出力されます。

f:id:vasilyjp:20170429190802g:plain

実装

ここまでで紹介した、Qiita:TeamのWebhookとtextlintのモジュールとしての実行を組み合わせて以下の流れを実現します。

f:id:vasilyjp:20170429190706p:plain

サンプルプロジェクト

作成済みのHubotプロジェクトをサンプルプロジェクトとして用意しました。こちらをclone後rootディレクトリでnpm installを実行することでローカルでHubotを動かせるようになります。

github.com

Node.jsのバージョンはv6.10.2で動作確認をしています。

この記事では、Heroku上でHubotをホストするための設定方法は紹介しませんが、Web上に良い記事が多くあります。参考にしてみてください。

文章校正を実行する条件

Webhookでは、全ての記事の投稿・更新を受け取れるので、記事に対して文章校正を実行するかを判断する必要があります。Qiita APIを使用すると、タグだけでなく投稿者やコメント等を取得できますので、これらの情報を判別に用いる事も可能です。

今回は、以下の条件に当てはまる場合に文章校正を実行します。

  • 記事にタグtechblogが付いている
  • WebhookのPayload中のmodel"item"action"created"もしくは"updated"である

Webhookでの各種イベントの受け取り

今回はHeroku上にホストしたHubotでWebhookを受け取ります。Qiita:TeamからのWebhookはPOSTリクエストですので、HubotのHTTP Listenerを利用してイベントを受け取ります。

実際のコードは以下のようになります。このコードをHubotプロジェクトのscriptsディレクトリ以下に配置します。Webhookを受け取るためのパスは/qiita/webhooksとします。Webhookの設定で入力するURLは、https://Herokuアプリ名.herokuapp.com/qiita/webhooksとなるはずです。

module.exports = robot => {
  robot.router.post('/qiita/webhooks', (req, res) => {
    console.log(req.body);
    res.end();
  });
}

URLの設定後、例として以下のような投稿をQiita:Teamで作成してみます。

f:id:vasilyjp:20170429193317p:plain

その結果、Qiita:TeamからWebhookのPOSTリクエストが送信され、以下のようなリクエストボディを受け取ることができます。

{ action: 'created',
  item: { 
    body: '<p>test</p>\n',
    coediting: false,
    comment_count: 0,
    created_at_as_seconds: 1492484003,
    created_at_in_words: '1分未満',
    created_at: '2017-04-18 11:53:23 +0900',
    id: 12345,
    lgtm_count: 0,
    raw_body: 'test\n',
    stock_count: 0,
    stock_users: [],
    tags: [],
    title: 'test',
    updated_at: '2017-04-18 11:53:23 +0900',
    updated_at_in_words: '1分未満',
    url: 'https://vasily.qiita.com/Horie1024/items/1234567890abcdefghijk',
    user:
    { id: 12345,
      profile_image_url: 'https://qiita-image-store.s3.amazonaws.com/profile-images/xxxxxx',
      url_name: 'Horie1024' 
    },
    uuid: '1234567890abcdefghijk'
  },
  model: 'item',
  user:{ 
    id: 12345,
    profile_image_url: 'https://qiita-image-store.s3.amazonaws.com/profile-images/xxxxxx',
    url_name: 'Horie1024' 
  }
}

リクエストボディのactionプロパティがcreatedmodelitemであることから記事の新規作成であることがわかります。

また、itemプロパティはItem型で表され、ドキュメントのTypes#ItemでItem型の各プロパティがどのような意味を持つのか確認できます。例えば、raw_bodyプロパティは記事本文、bodyプロパティは記事本文のHTML表現を表します。

.textlintrcの設定

検証ルールは、書籍執筆用のルールをベースにVASILY DEVELOPERS BLOGの体裁に合わせてカスタマイズしています。簡単な表記ゆれの対策には、textlint-rule-prhを利用しています。

{
  "rules": {
    "prh": {
      rulePaths: ["prh.yml"]
    },
    "max-ten":{
      max: 3
    },
    "spellcheck-tech-word": true,
    "no-mix-dearu-desumasu": true,
    "no-exclamation-question-mark": false,
    "preset-ja-technical-writing": {
      "no-exclamation-question-mark": {
        "allowHalfWidthExclamation": false,
        "allowHalfWidthQuestion": false,
        "allowFullWidthExclamation": true,
        "allowFullWidthQuestion": true
      }
    },
    "preset-jtf-style": true
  }
}

以下はprhの定義ファイルです。

version: 1
rules:
  - expected: IQON
    pattern:
      - iqon
      - iQON
  - expected: VASILY
    pattern:
      - vasily
      - Vasily

Webhookからの記事本文の取得とtextlintの実行

Webhookからの記事の投稿・更新を受け、textlintでの文章校正を実行します。TextLintEngineCore#executeOnTextを使用すると引数に取ったテキストに対してtextlintを実行することができます。第二引数にはMarkdown形式のファイルである事を示すために.mdを指定します。そして、校正結果についてTextLintEngineCore#isErrorResultsで確認し、エラーが有ればエラー内容をQiitaのコメントで確認しやすいようフォーマットします。

robot.router.post("/qiita/webhooks", (req, res) => {

  let item = req.body.item;
  // Webhookで受け取った記事本文をexecuteOnTextに渡す
  // 第二引数には".md"を指定
  engine.executeOnText(item.raw_body, ".md").then(results => {
    if (engine.isErrorResults(results)) {
      // エラー有り
      // 結果を確認しやすいようフォーマット
    } else {
      // エラー無し
    }
  });
});

文章校正の結果をフィードバック

文章校正の結果は、記事へのコメントとしてフィードバックします。コメントの投稿・編集にはQiita APIを通じて行うため、簡単なAPIクライアントを作成しています。

以下のコードでは、校正結果をコメントとして投稿しています。

const Qiita = require('../libs/Qiita');
const qiita = new Qiita({team: "vasily", token: process.env.YOUR_QIITA_TOKEN});

robot.router.post("/qiita/webhooks", (req, res) => {

  let item = req.body.item;
  // Webhookで受け取った記事本文をexecuteOnTextに渡す
  // 第二引数には".md"を指定
  engine.executeOnText(item.raw_body, ".md").then(results => {
    if (engine.isErrorResults(results)) {

      // エラー有り
      let output = ... // 結果を確認しやすいようフォーマット
      qiita.Comments.post(item.uuid, output);

    } else {
      // エラー無し
      qiita.Comments.post(item.uuid, "エラーはありません");
    }
  });
});

また、既に文章校正の結果が投稿されている場合、コメントを上書きするようにしています。

ここまでのコードの詳細はこちらを御覧ください。

https://github.com/horie1024/hubot-textlint-sample/blob/master/scripts/hubot-textlint.js

実行結果

Qiita:Teamにtechblogタグを付けて投稿・編集すると以下のように校正結果がコメントとして投稿されます。自分で自分にフィードバックしている形になっていますが、本来ならbot用にサービスアカウントを用意したいところです。Qiita:Teamでサービスアカウントが利用できないため、暫定で自分で発行したアクセストークンを使用しています。

f:id:vasilyjp:20170430195158p:plain

まとめ

Qiita:Teamへの投稿をトリガーに文章校正を実行することができました。textlintは非常に素晴らしいツールで、既存の他のツールと連携させることも簡単に行うことができます。校正ルールはまだ見直しの余地がありますが、今回の事を切っ掛けにみんながより文章を書きやすい環境を作っていけたらと思います。

最後に

VASILYでは、現在新規サービスの開発を行っています。少しでもご興味のある方のご応募をお待ちしています。

カテゴリー