絶対に見逃せない投稿が、そこにはある

  • 950
    いいね
  • 13
    コメント
この記事は最終更新日から1年以上が経過しています。

Qiita の 「見逃せない投稿」 を独自に評価してランキングするサービス Qaleidospace を作りました。

qaleidospace.png

本投稿では、そのようなサービスを作ろうと思った理由、投稿を評価するアルゴリズム、システム構成について書きます。

余談ですが、今なら Yearly Ranking がほぼ 2015 年の投稿ランキングとなっており、眺めていて楽しいです。

TL;DR

  • Qiita の「見逃せない投稿」をランキングするサービス Qaleidospace を作った。
  • 適切な評価システムがあれば、書き手も読み手もみんな幸せになれるはず。
  • ストック数だけで評価すると、初心者向けの投稿やキャッチーなキーワードを散りばめただけの投稿が注目されやすい。誰がストックしたのかを重視して「見逃せない投稿」を評価する。
  • 風変わりなシステム構成: GitHub Pages でホスティング + Swift で書かれたバッチ

モチベーション

僕は Qiita をとても気に入って使っています。しかし前々から、 Qiita には優れた投稿をピックアップする仕組みが足りないと感じていました1

Qiita ではタグやユーザーをフォローすることができます。しかし、それらには問題があります。タグをフォローするとタイムラインにそのタグが付けられたすべての投稿が表示されるため、優れた投稿がそうでない投稿に埋もれてしまいます。ユーザーをフォローすれば人手でキュレートされているので優れた投稿が多くなりますが、フォローしたユーザーがカバーしていない投稿は見逃してしまいます。カバー範囲を広げようと多くのユーザーをフォローしすぎると、タイムラインに興味のない分野の投稿が増え、自分が求めていた投稿が埋もれてしまいます。

フォローの他に、公式には Qiitaニュース があります。Qiitaニュースでは週に 1 回、週間ストック数ランキングが提供されます。しかし、これにも次の問題があります。

  • ストック数は十分に信頼できる指標ではない
  • 最大で 1 週間半遅れ

これまで Qiita を書いてきた経験から、僕は初心者向けの投稿の方がストックを集めやすいと感じています。これは、人口分布を考えると当たり前です。初心者は多いので初心者向けの投稿はストックされやすくなります。そのため、技術的におもしろい優れた投稿であっても、内容が高度になるほどストックが集めにくくなります。また、キャッチーなキーワードを散りばめれば内容が正しくなくても真偽を判断できない初心者のストックを集めることができてしまいます。Qiitaニュースのように 投稿をストック数だけで評価していては、誤った内容の投稿が注目され、優れた投稿を見逃してしまうおそれがあります

また、Qiitaニュースのランキングは月曜から日曜で集計され、翌水曜に配信されるので、最大で 1 週間半遅れになります。情報鮮度が重要な情報だと、気付いた時にはもう話題に乗り遅れているということになりかねません。

もし、ストック数とは異なる指標で、リアルタイムに 「見逃せない投稿」 をランキングしてくれるサービスがあれば便利そうです。 Qiita API を使えば簡単にそのようなサービスが作れそうだったので、 Qiita の投稿の非公式ランキング Qaleidospace を作ってみることにしました。

「かくれた名作」をなくしたい

ただ便利そうだということに加えて、 Qaleidospace を作りたかったのには、昔から評価システムの重要性を考えていたことが大きいです。

世の中には「かくれた名作」と呼ばれる作品があります。「かくれた名作」とは、普通の文脈ではその作品をポジティブに形容する言葉です。しかし裏を返せば、優れた作品を正しく評価できなかったという意味で、評価システムの未熟さを指摘する言葉だとも言えます。

僕が中高生の頃はオリコンランキングが絶大な影響力を持っていました。 100 人が聴いて 99 人が良いと言った曲よりも、 100 万人が聴いて 1 万人が良いと言った曲が評価される わけです。そんな評価方式が蔓延した世界では、 質にこだわって良いものを作ろうとする人より、質はそこそこでも注目を集めた人の方が評価されます。すると、人々は質の良いものを作ることより注目を集めることを目指し始めます。これは社会にとって良いことではありません。当時からそのような評価システムには疑問を抱いていました。

もちろん、完全な粗悪品はユーザーから評価を得ることが出来ないのでいくら注目を集めても勝ち残ることはできません。しかし、そこそこの品質を持ったものなら、注目を集めることで本当に優れたものを打ち負かすことができてしまいます。そのため、現実には品質の向上のためにかけられるべき労力の一部が、注目を集めるために費やされています。これは、評価システムが未熟なために社会が負担しているコストです。 適切な評価システムが存在すれば、社会は品質を向上することに注力することができ、注目を集めるための無駄なコストを支払わなくて済むはずです

理想的な評価システムが存在する世界では「かくれた名作」は生まれません。そんな理想の評価システムを作ることは困難ですが、少しでもそれに近いものを作ることはできるはずです。

Qaleidospace が解決できることはとても限定的ですが、それを作ろうと思った根底にはそういう考えがありました。

稚拙さや間違いを許容する

もう一つ僕が気にかかっていたのが、稚拙な投稿、間違った投稿を攻撃する風潮です(僕も皆無とは言えないので気持ちはわかりますし、自分でも気を付けないといけないと思っています)。

特に、初心者が書く内容は稚拙になりがちですが、誰だって最初は初心者です。 どんなにくだらない内容であっても、情報発信者を萎縮させ、発信する場を奪う方向に導くのは間違っていると思います

間違っていたら指摘をしてあげればいいし、その時間がなければ無視をすればいいはずです。問題は、そのような投稿が注目されて誤った知識が拡散してしまうことです。しかし、それは発信者の問題ではなく、評価システムの問題だと思います。評価システムが適切であれば、稚拙な投稿が注目され「検索結果が汚れる」こともありません。僕は、攻撃によってコミュニティを委縮させてしまうより、 稚拙な投稿や間違いを含む投稿が注目されにくい評価システムを考える方が建設的 だと思います2

評価の仕組み

「見逃せない投稿」を評価するにはどうすれば良いでしょうか。次の仮定をおきましょう。

  • 優れたエンジニアは優れた投稿を見分けることができる。
  • 優れたエンジニアは情報収集力が高い。

これが真だとすると、優れた投稿は優れたエンジニアによってストックされている可能性が高いということになります。であれば、優れたエンジニアのストックに大きな重みを与えてストック数を集計すれば優れた投稿を見分けられそうです。

では、どのようにしてユーザーを評価すれば良いでしょうか? Qaleidospace はユーザー同士のフォロー関係に注目してスコア付けします。

優れたエンジニアはたくさんフォローされているはずだし、優れたエンジニアからもフォローされているはずです。気を付けないといけないのは、フォロー返しという文化があるので、たくさんフォローしているユーザーはそれだけフォローされやすくなるということです。そのユーザーが誰からフォローされているかだけでなく、誰をフォローしているかも考慮しなくてはいけません。

ユーザーのフォロー関係は、数学的には有向グラフを作ります。フォローをリンクと読み替えれば、検索エンジンがバックリンクを利用してスコア付けしているのと同じような計算も可能です。

現状では簡易的なアルゴリズムを用いてそのようなことを表しています。それ自体はヒューリスティックなもので特に学ぶべきところがないので説明は省略しますが、上記のような考え方を簡易的な計算で近似できるようなアルゴリズムになっています。今後、精度を高めるために継続的にアルゴリズムを改良予定です。

とはいえ、スコアの高いユーザーの上位陣を見る限り現状のアルゴリズムもそれなりに機能しているように思えます。参考までに、上位 20 人を掲載しておきます。

1.  dankogai
2.  hirokidaichi
3.  kensuu
4.  masuidrive
5.  mizchi
6.  shu223
7.  jnchito
8.  naoya@github
9.  Qiita
10. tanakh
11. repeatedly
12. yuroyoro
13. vvakame
14. yoshiori
15. koizuka
16. kansai_takako
17. yusuke
18. yugui
19. kazunori279
20. joker1007

Qaleidospaceにできないこと

Qaleidospaceはストック数を直接スコアとして使うわけではないですが、ストックに重みを与えて集計しているので、誰にもストックされていない投稿は評価することができません。

ストックの多い投稿は当然色んな人の目に触れやすくなるので多くのユーザーから評価を受ける機会が多くあります。しかし、ストックの少ない投稿は評価を受ける機会自体がほとんどありません。そのため、ストックが少ない投稿のスコアはどうしても低めになってしまいます。極論すれば、ストック数 0 の投稿でも内容さえ優れていれば高評価すべきですが、今の Qaleidospace にはそれはできません。言い換えれば、現状ではストック数が著しく少ない「かくれた名作」まで漏れなく拾い上げることはできないということです。

ストック数が少ない投稿でも、もし注目されればどの程度のスコアになったはずかを推測できれば、ポテンシャルに応じて加点するようなことも可能です。しかし、難しいのは、あるユーザーがその投稿を「ストックしたこと」は知ることができても、「ストックしなかったこと」を知ることはできないことです。その投稿を読んだ上でストックする価値がないと判断したのか、それともその投稿を知らないからストックしていないだけなのかを判別することができません。

この点は今後の課題だと考えています。

システム構成

Qaleidospace はちょっとおもしろいシステム構成をしています。実は、 Qaleidospace は動的なサイトのように見えて GitHub Pages でホスティングされた静的サイトです。

+------------------+        +------------------------------+
|    Mac (Batch)   |  push  |    GitHub Pages (Hosting)    |
|       Swift      | -----> |           Jekyll             |
| Updates rankings |        |  Generates HTMLs from JSONs  |
+------------------+        +------------------------------+
         | ^                              ^ |
 request | | response             request | | response
         v |                              | v
+-----------------+         +------------------------------+
|   Qiita (API)   |         |       Client (Browser)       |
|   Qiita API v2  |         | Searches items by JavaScript |
+-----------------+         +------------------------------+

僕が Qaleidospace を作ろうと決めたのは 12 月の初めでした。

この投稿 を書いたときに Swift で Qiita API を叩いてみたところ、タイプセーフな非同期処理が簡単にできていい感じでした。それで、前々から頭の片隅に構想があった Qaleidospace をサクッと作れるんじゃないかと思いました。年明けからは try! Swift の発表準備を始めようと思っていたので3、 12 月のプライベートの時間で完成させられるかが問題でした。数日ほどシステム構成を考えたり軽く検証したりして、いけそうだという感触があったので作ることに決めました。

Swift 以外の言語も検討しましたが、僕のスキルセットから考えて最短時間で作れそうなのは Swift でした。評価は簡易的なアルゴリズムで行うので、機械学習や数値計算の高度なライブラリは必要ありません。 API を叩くための通信や非同期処理、 JSON のデコードはライブラリがそろっています。

しかし、 Swift でサーバーサイドプログラミングは現実的ではありません。 Web サイト自体は何かしら別の方法で提供する必要がありました4

そもそも、リクエストの度に個々の投稿のスコアを動的に計算してランキングを作成するのは計算量的に現実的でありません。定期的にランキングを生成して更新するようなバッチが必要になります。それであれば、サイト自体は静的にしておいて定期的にバッチで書き換えるような構成も可能です。静的サイトであれば GitHub Pages で簡単にホスティングできますし、更新は commit を push するだけです。

とはいえ、ランキング更新の度に HTML をまるごと書き換えるのはちょっと不細工です。 Jekyll を使えば HTML からデータを分離できるので、ランキングのデータだけをアップデートすることができます。 Qaleidospace のランキングデータはこのように JSON になっています。 GitHub Pages は Jekyll に対応しているので、 JSON ファイルだけを更新して commit, push すれば、 GitHub Pages が HTML を JSON から自動生成してくれます。

Qaleidospace には投稿を検索(絞り込み)する機能もあります。しかし、それくらいならクライアントサイドの JavaScript で十分対応できます。サーバーサイドでプログラムを走らせる必要はありません。

というわけで、サイト自体は GitHub Pages でホスティングし、バッチを Swift で実装するという風変わりな構成が出来上がりました。

Swiftによるバッチ

バッチに求められる処理は次のようなものです。

  • Qiita API を叩いてデータを更新
  • 投稿のスコアを計算してランキングの JSON を更新
  • 更新内容を commit
  • commit を GitHub Pages に push

OS X Server なんて持ってないので、今のところバッチは手元の Mac で実行しています。せっかく Swift がオープンソース化されたので、将来的には Linux 用にビルドして EC2 上で動かしたいと思っていますが、現時点では(特にライブラリのビルドが)地雷臭しかしなかったので挑戦していません。

実際に Swift でバッチを書いてみるととても良かったのですが、言葉でその良さを伝えるのはなかなか難しいので、 どのようにコードを書いていったかを追体験できるような形で伝えたいと思います。例として、 Qiita API を使って最新の投稿 1 万件を取得し、ランキングを生成するコードを考えてみましょう。

動くコードはこちらのリポジトリにあります。以下では説明を省略していますが、この書き心地を実現するために extension を色々書いているので、それらについてはリポジトリのコードを御覧下さい。

投稿を取得するAPI

Qiita の最新の投稿は https://qiita.com/api/v2/items から JSON 形式で取得することができます。

デフォルトでは最新 20 個の投稿が返されます。 per_page パラメータを指定することで最大 100 件まで取得することができます。

また、 page パラメータを指定するとより古い投稿を取得することもできます。 page1 から始まり最大は 100 です。

そのため、 per_page=100&page=100 で得られる投稿が、この API で得られる最も古い投稿です。「最新の投稿 1 万件を取得」とは、この API で取得できるすべての投稿を取得するという意味です。

JSONをデコードする

データを JSON のまま扱うのは辛いので、まずは JSON をデコードすることを考えましょう。すべてのプロパティが必要なわけではないので、要らないものは捨てて、次のような struct にデコードすることを考えましょう。

struct Item {
    let id: String
    let title: String
    let url: String
    let userPermanentId: Int
    let tags: [String]
    let createdAt: NSDate
}

しかし、元の JSON はこれ少し違った構造をしています。 "user""tags" に注目して下さい。

{
  "id": "c6f446bad54442a28bf4",
  "title": "SwiftのOptional型を極める",
  "url": "http://qiita.com/koher/items/c6f446bad54442a28bf4",
  "user": {
    "permanent_id": 47085,
    "id": "koher",
    ...
  },
  "tags": [
    {
      "name": "Swift",
      ...
    },
    {
      "name": "iOS",
      ...
    }
  ],
  "created_at": "2015-02-16T09:42:48+09:00",
  ...
}

Qaleidospace ではユーザーの情報を別途 User 型として保持するので、 "permanent_id"5 さえあればユーザーを引くことができます。そのため、 struct ではその他の情報を捨て、 userPermanentId のみを保持しています。また、タグについても "name" だけあれば良いので、 tags: [String] として "name" のみを保持します。

struct と JSON で構造が異なるため、デコード時には構造の変換が必要になります。 Argo を使えばそれも簡単にできます。

// Argo で JSON をデコードして Item インスタンスを生成
extension Item: Decodable {
    static func decode(json: JSON) -> Decoded<Item> {
        let tagJsons: Decoded<[JSON]> = json <|| "tags"
        let tags: Decoded<[String]> = tagJsons
            .flatMap { tagJsons in sequence(tagJsons.map { $0 <| "name" }) }

        return curry(Item.init)
            <^> json <| "id"
            <*> json <| "title"
            <*> json <| "url"
            <*> json <| ["user", "permanent_id"]
            <*> tags
            <*> json <| "created_at"
    }
}

アプリカティブについて知らないとコードの中身は意味不明かもしれませんが、構造変換を含むやや複雑なデコードが簡潔に書けていることはわかると思います。 Argo の使い方やアプリカティブについてはこちらを御覧下さい。

最新の投稿100件を取得する

それでは、実際に API を叩いて JSON を Item にデコードしてみましょう。まずは、一度のリクエストで可能な 100 件だけでやってみましょう。

通信には Swift のネットワーキングライブラリの定番 Alamofire を使います。また、非同期処理を Promise で扱いたいので PromiseK も導入します。

PromiseK は僕が作ったライブラリで、他の Promise ライブラリと違うのはエラー処理を担当しないことです。 Swift でエラーを表すには Optional をはじめ EitherResult など様々な選択肢があります。 PromiseK ではエラーをエラー専用の型に任せて、 Promise<Optional<Item>> のような形で Promise と組み合わせて使います。一見面倒そうですが、 Promise の役割を非同期処理に限定してシンプル化し、エラーを表す型( OptionalEtiherResult か)を自由に選択できる利点があります。また、 Promise の値を利用するときにエラー処理が強制されるので、 catch 忘れのようなことも起こりません。

今回は、 Argo によるデコードに依存するので、エラーを表すのに Argo の Decoded を使います。 Alamofire でネットワークエラーが起きた場合には DecodeError.Custom として扱います。本当は独自のエラー型を作った方がタイプセーフですが、今回は特にエラーの原因で分岐してエラー処理をしたいわけではなくエラーメッセージを表示できれば十分なので、 Decoded で代用します。

そのため、 API を叩いて得られる [Item]PromiseDecoded に包まれた Promise<Decoded<[Item]>> として得られます。

Alamofire + PromiseK + Argo で Promise<Decoded<[Item]>> を得るコードは↓です。必要最小限のことしか書かれていない簡潔なコードになっているのがわかると思います。

let request: Request = Alamofire.request(.GET, "https://qiita.com/api/v2/items",
    parameters: ["page": 1, "per_page": 100])
let items: Promise<Decoded<Item>> = request.promisedResponseJSON().map { response in
    response.result.flatMap { decode($0) }
}

レスポンスとして得られた Promise<Response<...>>map して Promise<Decoded<[Item]>> に変換しています。 response.result はネットワークエラーかもしれないので、 decode のエラーと二重にエラーになってしまうところを flatMap で潰して一重の Decoded にしています。

最新の投稿1万件を取得する

1万件を取得するには↑をページ 1 から 100 まで順番にアクセスして結果をつなげる必要があります。リクエストを並列に投げてしまうと一度に大量のリクエストが飛んでしまうので、ちゃんと Promise をつなげて一つずつ順番にアクセスするようにしましょう。

ちなみに、僕は開発段階で間違えて並列でリクエストを投げてしまいました・・・。

さっき書いた 1 ページ分を読み込む処理何度も実行することになるので、まずはそれを関数にしてしまいましょう。

func downloadItems(page page: Int, perPage: Int) -> Promise<Decoded<[Item]>> {
    let request: Request = Alamofire.request(.GET, "https://qiita.com/api/v2/items",
        parameters: ["page": page, "per_page": perPage])
    return request.promisedResponseJSON().map { response in
        response.result.flatMap { decode($0) }
    }
}

これを使えば 1 ページから 100 ページまで順次アクセスするコードは次のように書けます。このコードもとても簡潔です。

let items: Promise<Decoded<[Item]>> = (1...100).reduce(pure(pure([]))) { items, page in
    items >>-? { items in
        downloadItems(page: page, perPage: 100) >-? { pageItems in
            items + pageItems
        }
    }
}

Promise をつなぎ合わせるには flatMap を使います。 >>-flatMap を表す演算子で Runes で宣言されています。しかし、今は PromiseDecoded が入れ子になってしまっているので、中身を取り出すためにはただの flatMap ではいけません。 flatMapPromise を剥がした上で、 Decoded を呼ぶために更に map を呼ばなければなりません。

さすがにそれは面倒なので、 PromiseDecoded をまとめて剥がしてくれる flatMap 演算子として PromiseK が >>-? を提供しています。 >-? はその map 版です。 ? という記号を使うのは Optional Chaining のアナロジーです。

APIを一定間隔で叩く

API を並列ではなく直列に叩いているとは言えインターバルを指定していないので、今のままでは連続して過剰なリクエストを投げてしまう可能性があります。リクエストを投げる間隔を指定できる方が望ましいです。

また、 Qiita API には 1 時間当たり 1000 回のリクエストまでしか受け付けないという制限があります6。この制限に引っかからずに 1000 回以上のリクエストをつないで処理するためには、リクエストの間隔を 3.6 秒以上にする必要があります7

これを実現するには、 3.6 秒間待つ Promise を作って Promise を合流させます。二つの Promise を合流させて、両方が完了するのを待つ一つの Promise を作るのも flatMap でできます。先程のコードを少し変更して次のようにします。

let items: Promise<Decoded<[Item]>> = (1...100).reduce(pure(pure([]))) { items, page in
    items >>-? { items in
        // 3.6 秒待つ Promise
        let wait: Promise<()> = Promise { resolve in
            dispatch_after(dispatch_time(DISPATCH_TIME_NOW, Int64(3.6 * Double(NSEC_PER_SEC))), dispatch_get_main_queue()) {
                resolve(Promise())
            }
        }

        // 1 ページ分の投稿を取得し、これまでの結果と結合する Promise
        let pageItems: Promise<Decoded<[Item]>> = downloadItems(page: page, perPage: 100) >-? { pageItems in
            items + pageItems
        }

        // 両方が完了するのを待って結果を返す Promise
        return wait.flatMap { _ in pageItems }
    }
}

3.6 秒待つ処理と API を叩く処理は並列に走っていることに注意して下さい。そのため、同じ flatMap を使っていますが、 waitpageItems の二つの Promise は先ほどの違って入れ子になっていません。

DailyランキングのTOP 100を表示する

最後に、取得した items をランキングにして表示してみましょう。

まず、 Item に投稿のスコアを計算して返すプロパティ score を追加しましょう。

extension Item {
    var score: Double {
        // 何らかの処理でスコアを計算
        return ...
    }
}

あとは、取得した items: Promise<Decoded<[Item]>> の投稿の score を計算し、ソートして表示するだけです。

items.map { items in
    switch items {
    case let .Success(items):
        items.filter { $0.createdAt > NSDate(timeIntervalSinceNow: -86400) } // 24時間以内の投稿に絞る
            .map { ($0, $0.score) } // スコアの計算は重いので、一度計算した値を使いまわせるようにタプルに格納
            .sort { $0.1 > $1.1 }[0..<100] // ソートして上位100個の要素に絞る
            .enumerate() // 順位を表示するためにインデックスとペアにする
            .forEach { print("\($0 + 1): \($1.0.title) (\($1.1) pt)") } // 順位、タイトル、スコアを表示
    case let .Failure(error):
        print(error) // Qiita APIおよびデコードでエラーだった場合はエラーを表示
    }
}

filter, map, sort, enumerate, forEach と関数型と相性の良い道具がそろっているので、組み合わせてチェーンするだけで複雑な処理も簡単に実現できます。

どうでしょうか?ここまで見てきたように、 Swift を使えばタイプセーフな非同期処理やデータの変換・加工を簡潔に書くことができます。これだけの複雑な処理でも、(最後の forEach をカウントしなければ)ループの一つも必要ありません。道具がそろっているので、それらの組み合わせで思いのままに処理を組み上げることができます。

僕が Swift でバッチを書きたくなった気持ちが少しでも伝わればうれしいです。



  1. Qiita が公式にそのようなランキングを提供しづらいのには、客観的でみんなが満足できる指標を作るのが難しいということもあるかもしれません。ストック数であれば客観的です。 Qaleidospace は、非公式だからこそやりやすいサービスだとも言えます。 

  2. 意図的に間違った情報を流したり、他人の指摘に耳を貸さないのはどうかと思いますが、それはまた別の問題です。 

  3. 当初想定していたテーマが、 Swift がオープンソース化された影響で色々と調査が必要になり、結構準備が重めになってしまったので。 

  4. 一応今なら Swift & Linux & EC2 で でサーバーサイドプログラミングをすることもできますが、オープンソース化直後の当時にはそんな選択肢はありませんでした。たとえ今の状況でも、トラップを踏み抜く予感しかしないので選択しませんが。 

  5. Qiita のユーザーには idpermanent_id がありますが、 id"koher" などの見慣れた ID のことです。こちらは変更される可能性があるので恒久的な ID として利用することができません。 permanent_id はユーザーに割り振られた一意な整数で、名前の通り恒久的に利用できるものです。 

  6. 認証時です。認証していない状態では 60 回です。本投稿のコードでは認証はしませんが、 Alamofire.request のパラメータとして headers: ["Authorization": "Bearer 0123456789abcdef01234567890abcdef0123456"] のようにアクセストークンを渡せば認証している状態になります(アクセストークンの部分はダミーなので置き換えて下さい。)。 

  7. 最新の投稿 1 万件を取得するだけなら 100 回のリクエストで済むのでこの制限には引っかからないですが、 Qaleidospace では投稿をストックしたユーザーを取得する処理などで制限に引っかかることがありました。そのため、 3.6 秒以上のインターバルが必要になりました。