[Qiita]なつかしの "人気の投稿" 欄を UserScript で復活させる(β版)

## イントロダクションのようなもの
むかしむかし、 Qiitaの記事の右カラムにも "人気の投稿" 欄があったのをご存知でしょうか。(言うほど昔でもないですが)

下記の画像をご覧ください。画像は Qiita 先生の古い記事からお借りました。問題があるようでしたら削除します。
<sub>**※自分の記事で画像作成できればよかったんですがこれがあった当時はまだ何も記事書いていませんでした＼(^o^)／**</sub>

![QiitaArchive__2.png](https://qiita-image-store.s3.amazonaws.com/0/70638/183584b6-8060-f79e-e0c0-b7ab154e44cd.png)



言い換えれば投稿者ごとのトレンド一覧というところです。上位4,５件くらい表示されていたみたいです。
記事から記事へ1アクションでジャンプできたのでネットサーフィンがけっこう捗って便利だったんですがいつのまにかなくなっていました。これをどうにかして復活させてみよう、というのが今回の話です。

## 失敗談

[条件を満たすまで $.ajax を繰り返し続けるサンプル - Qiita](https://qiita.com/tommy_aka_jps/items/20980d4df3e04dbbc609)

先日投稿しました上記エントリの際に実はAPIで取得したデータを使って復活を試みてみたんですが、APIのリクエスト回数上限にすぐひっかかってしまうため断念しました。

が、しかし新たな作戦を思いついたので今回実践してみた次第です。
よろしければお付き合いください。

## ということで今回の作戦
**APIじゃなくて、ユーザーのトップページから取ってくればよくね？** というのが今回の作戦です。
どういうことかといいますと、早い話しが↓ここに表示させているもの使えばいいんじゃないかということです。

https://qiita.com/tommy_aka_jps より抜粋

![貼り付けた画像_2018_11_19_14_10.png](https://qiita-image-store.s3.amazonaws.com/0/70638/e3fa7bea-3cca-bf8d-ad6b-c2aab268d855.png)

記事ページからはなくなりましたがトップページには残ってたようです。ということは、ajax のリクエスト先をここにして、レスポンスのHTMLソースからうまいこと必要なデータを拝借すればAPIを使うまでもないかと考えました。~~あったまいいー~~

## 仕様を考える
作戦を踏まえて大まかに仕様を決めました。

- UserScript を使う(拡張機能作るほどでもないので)
- 人気の投稿用データはユーザートップページの左カラムに表示されているものを使う
- 毎回トップページを丸ごと取得するのは重いし、Qiita への負荷にもなるので、そのあたりを考慮してデータはキャッシュするようにする
- キャッシュ先はローカルストレージを使う
- キャッシュの有効期限は当日内。日付が変わったらキャッシュは作り直す

まあ仕様というほど大げさなものでもないですｗこれを踏まえて実装を進めてみます。

## UserScript を動かすための拡張機能を導入
当方の使用ブラウザは Chrome がメインなので Chrome で UserScript を動かすための拡張機能を入れます。今回は Tampermonkey という拡張機能を使います。(Chromeの拡張機能でもいいんですがそこまでするほどのスクリプトでもないので今回は UserScript を使っています。)

- [Tampermonkey - Chrome ウェブストア](https://chrome.google.com/webstore/detail/tampermonkey/dhdgffkkebhmkfjojejmpbldmpobfkfo)

この拡張機能を Chrome に追加すると、あとはこの Tampermonkey に自作の JavaScript を追加すればスクリプトを動かせるようになります。

そもそも UserScript ってなんぞ？という方は下記の記事をどうぞ。わかりやすかったです。
要は UserScript とはWEBサイト上で自作 JavaScript を動かす仕組みのひとつです。ブックマークレットとブラウザ拡張機能の中間のようなものですね。

- [UserScript で業務改善](https://kenchan0130.github.io/business-improvement/2018/05/21/1.html)



## スクリプトの作成
Tampermonkey に追加する ~~ふっかつのじゅもん~~ スクリプトを作っていきます。PC版のChromeのみを想定しているため、IE,FireFoxは未確認です。たぶんIEはむりです。
あまりきれいなコードではありませんがご容赦ください。

```js
// ==UserScript==
// @name         Popular Posts Reviver for Qiita
// @namespace    PPR
// @version      0.2
// @description  Qiita の投稿の右カラムに「人気の投稿」を表示させるスクリプト。Chromeのみ使用可能。
// @author       tommy_aka_jps
// @match        https://qiita.com/*/items/*
// ==/UserScript==

(() => {

  /**
   * 人気の投稿データを非同期で取得する
   * @return {Promise}
   **/
  const fetchPopularPosts = (userId) => {
    const cache = getCache(userId);
    if(cache) {
      return Promise.resolve(cache);
    }

    return fetch("//qiita.com/" + userId, {
      method: "get"
    }).then((response) => {
      if (response.ok) {
        return response.text();
      } else {
        console.log(response.statusText);
      }
    }).catch((response) => {
      console.log(response);
    }).then((text) => {
      const data = createObjects(text);
      setCache(userId, data);
      return data;
    });
  };

  /**
   * キャッシュの名前に使う文字列を返す
   * @param {String} userId
   * @return {String}
   **/
  const cacheKeyName = (userId) => {
    return "c_" + userId;
  };

  /**
   * ローカルストレージから人気の投稿データのキャッシュを取得する
   * @param {String} userId
   * @return {Object[]}
   **/
  const getCache = (userId) => {
    const ckey = cacheKeyName(userId);
    const cache = JSON.parse(window.localStorage.getItem(ckey));

    if(!cache) {
      return;
    }

    return cache.posts;
  };

  /**
   * ローカルストレージに人気の投稿データのキャッシュをしまう
   * @param {String} userId
   * @param {Object[]} posts
   **/
  const setCache = (userId, posts) => {
    window.localStorage.setItem(
      cacheKeyName(userId),
      JSON.stringify({ posts: posts })
    );
  };

  /**
   * yyyyMMdd の文字列を生成する
   * @return {String}
   **/
  const generateCreateDate = () => {
    if(!generateCreateDate.cache) {
      const date = new Date();
      const year = date.getFullYear();
      const month = ("0" + (date.getMonth() + 1)).slice(-2);
      const day = ("0" + date.getDate()).slice(-2);
      generateCreateDate.cache = year + month + day;
    }
    return generateCreateDate.cache;
  };

  /**
   * html 文字列から人気の投稿用データのオブジェクトを生成する
   * @param {String} html
   * @return {Object[]}
   **/
  const createObjects = (html) => {
    const parser = new DOMParser();
    const dom = parser.parseFromString(html, "text/html");
    const titleElements = dom.getElementsByClassName("userPopularItems_title");

    return Array.prototype.map.call(titleElements, (titleElement) => {
      return {
        title: titleElement.text,
        url: titleElement.href
      }
    });
  };

  /**
   * ページに挿入する人気の投稿のHTMLを生成する
   * @param {Object[]} posts
   * @return {Element}
   **/
  const createHtml = (posts) => {
    const ul = document.createElement("ul");
    ul.style.listStyleType = "disc";
    ul.style.marginLeft = "20px";

    posts.forEach((post) => {
      const a = document.createElement("a");
      a.href = post.url;
      a.text = post.title;

      const li = document.createElement("li");
      li.style.marginBottom = "6px";
      li.style.fontSize = "12px";
      li.appendChild(a);
      ul.appendChild(li);
    });

    const h5 = document.createElement("h5");
    h5.textContent = "人気の投稿";
    h5.style.fontWeight = "700";
    h5.style.marginBottom = "10px";

    const div = document.createElement("div");
    div.style.marginBottom = "20px";
    div.appendChild(h5);
    div.appendChild(ul);

    return div;
  };

  /**
   * 必要に応じてローカルストレージの掃除を行う
   * 基本当日以外のキャッシュが存在した場合に限り clear が呼ばれる
   * それ以外は何もしない
   **/
  const clearStorageIfNeeds = () => {
    const keyName = "cache_cdate";
    const cacheCreateDate = window.localStorage.getItem(keyName);

    if(!cacheCreateDate) {
      window.localStorage.setItem(keyName, generateCreateDate());
      return;
    }

    if(cacheCreateDate != generateCreateDate()) {
      window.localStorage.clear();
      window.localStorage.setItem(keyName, generateCreateDate());
      return;
    }
  };

  // ----
  // Main
  // ----
  clearStorageIfNeeds();
  const userId = location.pathname.split('/')[1];
  fetchPopularPosts(userId).then((posts) => {
    if(!posts) return;

    const html = createHtml(posts);
    const targetDiv = document.getElementsByClassName("p-items_toc")[0];
    targetDiv.insertBefore(html, targetDiv.firstChild);
  });
})();
```

GitHub にも置いておきました。
https://github.com/JPSERN/UserScripts/blob/master/qiita-popular-posts-reviver.js

※11/28追記：
スクリプトを jQuery を使わないように変更しました。jQuery を使っていた際のコードを見たい方は下記をご覧ください。
https://github.com/JPSERN/UserScripts/blob/8dc4f17354849614cb6b10b9ce95f1678fc2820b/qiita-popular-posts-reviver.js

## Tampermonkey にスクリプトを追加
Tampermonkey を開いたら「新規スクリプトを追加」を選択し、前項のスクリプトをまるっとコピペしてください。

![貼り付けた画像_2018_11_19_15_50.png](https://qiita-image-store.s3.amazonaws.com/0/70638/9fd12764-5985-5216-067a-684f4afcf072.png)



## 動かしてみましょう
スクリプト追加後、どこでもいいので Qiita の記事ページを開けば動作します。うまく表示しないときはページをリロードしてみてください。なお、はじめて訪問するユーザーのページはキャッシュが存在しないので初回だけ少しラグがあるかとおもいます。

![貼り付けた画像_2018_11_19_13_49＿3.png](https://qiita-image-store.s3.amazonaws.com/0/70638/faff003a-1d7a-da61-8b99-d88c1154ce0c.png)


**右カラムに人気の投稿が追加されました**:grin:<sub>やったぜ。</sub>
CSSはとりあえず直書きで適当に付けて最低限の見た目だけ整えてました。スタイル定義はまだ調整の余地ありそうですが、機能的には要件を満たせたのでひとまずOKとします。

## 今後のアップデート予定(~~時期未定~~ => 完了)
- ~~ローカルストレージに掃除動作を付ける。各キャッシュ自体は記事のタイトルとURLの文字列だけなので大した容量は食っていないはずですが、今のままだと著者の数だけキャッシュが増えてしまうのでよろしくないかなと。塵も積もればボトルネックとなりますし。閾値とトリガーをどうするかが悩みどころです。~~ **<font color="red">[対応済]</font>**
- ~~jQuery のライブラリを Tampermonkey で読み込むと非同期とはいえちょっと重たいので、プレーンな JavaScript でひととおり組み直そうかとおもいます。(ajax や DOM 操作が楽なのでつい jQuery 使ってしまう。)~~ **<font color="red">[対応済]</font>**

## 後記

面白い記事を見つけたら同著者の他記事を読んでみると、もしかするとタイミングが合わなくて日の目を見ることのできなかった、あるいは埋もれてしまった良記事に出会えたり、調べたかった話題ではなくても新しい発見があるかもしれません。Qiita のHTMLの構造が変わらないうちしか使えませんがよかったら使ってみてください。

ちなみに私の人気の投稿(=どんぐりの背比べ)はどれも大したものはないのであまり見なくてもいいと思います(オチ)

はばないすでい:thumbsup: