Qiitaの記事でフォロイーのいいねを表示するユーザースクリプト

  • 1
    Like
  • 0
    Comment

(ただし、速度に不満あり)

前書き

フォローしてますか。
信頼できるフォロイーはフィードを豊かにしてくれます。
彼らのいいねは記事を価値あるものと信ずるに足ると勝手に思っています。
しかし、彼らのいいねが流れるのはフィードの一瞬。
最近は人気の記事一覧にも反映され目にする機会は増えましたが、それでもリアルタイムなキャッチアップには利用できても、本来の調べ物をしているときの判断材料には使いづらかったです。(適度に枯れているものを使いますし)
Qiitaの記事の指標にいいね数は使いますが、それでもさらにフォロイーもいいねしていることが可視化されると、より判断しやすくなるんじゃないかと思いました。
…特にポエム系はいいね数だけで信じるべきか悩ましいので。

前々から作りたかったものが、技術の巡りあわせがよかったので低レベルながら出来た。

(構想おそらく2年ぐらい)

  • (ストック時代にストックで)作りたい
  • わからない(通信・表示・キャッシュ)
  • 出来たとして、APIリクエスト数制限→トークンとか認証わからない→1時間60回は現実的で無い(モチベ低下)
  • いいねの導入(ストックの非可視化)
  • いいね取得APIの(一般に)非導入→ストックの意味霧散→表示してもな…(モチベ低下)
  • 別のユーザースクリプトでJSスキルすこし上がる(Storage,Fetch,async)
  • いいね取得API(一般に)追加発見→モチベ回復
  • ページングと認証頑張れば今までの組み合わせで出来そう
  • LINKでページング回避できそう→パースは嫌だ…→助けてGitHub!
  • できた

スクリーンショット

image.png

image.png

デザインはこだわってないので分かりづらかったりレイアウト崩れの可能性があります。

…Qiitaのhovercardかっこいいので本当に使いたい。

検証環境

  • Firefox 54
  • greasemonkey 3.11

コード

Qiita_Show_Followees_iine.user.js

Qiita_Show_Followees_iine.user.js
// ==UserScript==
// @name        Qiita show followees iine
// @namespace   khsk
// @description 記事に自分(ユーザー)がフォローandいいねしているユーザーを表示
// @include     http://qiita.com/*/items/*
// @include     https://qiita.com/*/items/*
// @include     http://qiita.com/*/private/*
// @include     https://qiita.com/*/private/*
// @version     1
// @grant       none
// ==/UserScript==

console.time('Qiita show followees iine')

const userId = ''; // 主に自分
const token = ''; // 各自で取得してください
const displayFolloweesNum = 5; // 表示数 -1 で無制限

(async () => {

  // 表示場所作成
  const iine = document.getElementsByClassName('list-inline ArticleMainHeader__users')[0]
  const ciine = iine.cloneNode()
  iine.parentElement.insertBefore(ciine, null)
  // TODO いいね取得が長いから進捗を適当に書いているんだけれど、事前にheightを確保していないので、フォロイいねが無かったときなどにガタッとレイアウトが変わるかも。オプションめ。
  ciine.innerText = 'フォロイー取得中'


  let followees = sessionStorage.getItem('followees')
  if (!followees) {
    followees = await fetchAll('https://qiita.com/api/v2/users/' + userId + '/followees?per_page=100',user => {
        // 無いとは思うが、sessionStorage容量上限対策として削れるものは削る
        delete user.description
        delete user.facebook_id
        delete user.followees_count
        delete user.followers_count
        delete user.github_login_name
        delete user.items_count
        delete user.linkedin_id
        delete user.location
        delete user.prganization
        delete user.permanent_id
        delete user.twitter_screen_name
        delete user.website_url
        delete user.organization
        return user
      })
    // 保存
    followees = JSON.stringify(followees)
    sessionStorage.setItem('followees', followees)
  }

  followees = JSON.parse(followees)

  ciine.innerText = 'いいね取得中'

  // 記事のいいね取得
  const itemId = location.pathname.split('/').pop()
  // 1ページ取得→即アイコン表示の方が止まっている疑惑が出にくいが…
  let likes = await fetchAll('https://qiita.com/api/v2/items/' + itemId + '/likes?per_page=100', o => { return o.user.id })


  ciine.innerText = ''

  // 要素作成ループ
  // ここ100*1000程度はありそうでヤバめ
  // いいねしたフォロイーだけ抽出するとまたループが必要なのでDOMまで作るか…

  // breakしたいしsomeは嫌だからforにするか…
  // フォロイーといいねどちらを削除していけば性能良いかわからん…
  for (let i = 0, likesLength = likes.length, displayedNum = 0; i < likesLength; i++ ) {
    if (!followees.length || (displayFolloweesNum > 0 && displayFolloweesNum == displayedNum) ) {
      break
    }
    for (let j = 0, followeesLength = followees.length; j < followeesLength; j++) {
      if (likes[i] == followees[j].id) {
        let followee = followees[j]
        let id = followee.id
        let userTemplate = iine.firstChild.cloneNode(true)
        userTemplate.dataHovercardTargetName = id
        userTemplate.querySelector('a').href = '/' + id
        let img = userTemplate.querySelector('img') 
        img.alt = followee.name || id
        img.src = followee.profile_image_url

        // ツールチップ追加 フォロイーはアイコンでわかるだろうからオマケ程度
        setPopover(userTemplate, followee)

        ciine.insertBefore(userTemplate, null)
        displayedNum++

        // 表示し終わったフォロイーは配列から削除しようと思っていたが、影響が出るような数百単位のいいねの場合はいいね取得がボトルネックに見えるので保留にする
      }
    }
  }

})()


/////////// functions
// 今回は無名関数でなくfuntionを使ってみる。ライブラリコピペを下部に持ってくるので、自作関数も下にまとめたい→変数なら上に書かなければ行けないので関数で

// レスポンスがないとnextがわからないのでオーバーヘッドがだいぶある。
// いいねやフォロイー総数から事前にページ数を計算すれば非同期並列で取得して最後にPromise.allで合算、で高速化できそうなんだけれども、手間がかかる。
// 計算せずとも最初のlinkにラストページが表示されるのでその数までループ、なら総数取得は無しに出来るかも。初回1本+以降並列。
// TODO 進捗表示のためにいちループごとに何か出来るコールバック作るか
async function fetchAll(url, callback = (v) =>{return v}) {
  let next = url
  let result = []
  while (next) {
    let response = await fetch(next, {
      headers: new Headers({ Authorization: 'Bearer ' + token,}),
    })

    if (response.ok == false || response.status != 200) {
      return result
    }

    next = li().parse(response.headers.get('Link')).next

    let responseData = await response.json()
    // ここデフォルト引数付けずに無いならmapしない方が性能いいけど
    Array.prototype.push.apply(result, responseData.map(callback)) 
  }
  return result
}

// フィードのポップアップスクリプトの関数を持ってきて調整
function setPopover(elm, data) {
    // Qiitaのhovercardはhovercard.jsではないのかもで諦め
    // 最後に$(elm).tooltip() or popover()を実行するとタイトルがタグになる。謎。なので最初に設定
    // datasetでは効かないものがある
    $(elm).popover({
        trigger   : 'hover',
        placement : 'bottom', // いいねは下付き
        container : 'body',
        html      : true,
        // 画像サイズ固定化や左配置のために、本家で使っている.hovervardクラスをデフォルトテンプレートに追加
        template  : '<div class="popover" role="tooltip"><div class="popover-arrow"></div><h3 class="popover-title"></h3><div class="popover-content hovercard"></div></div>'
    })
    elm.dataset.toggle = 'popover'
    elm.dataset.originalTitle = 'Followee Info'
    // hovercardっぽさのためにアイコン画像は今回は載せる
    elm.dataset.content = '<img src="' + data.profile_image_url + '">' + '<br>\n' + (data.name || data.id)
    // 改行対策にデフォルト値 + nowrap
    elm.dataset.viewport = "{ selector: 'body', padding: 0 , white-space: nowrap,}"
}


/////////// 外部ライブラリ(コピペ)
// MITライセンス表示はこれで大丈夫かしら
/**
* Author  : José F. Romaniello <jfromaniello@gmail.com> (http://joseoncode.com)
* License : MIT 2014 - JOSE F. ROMANIELLO https://opensource.org/licenses/mit-license.php
* URL     : https://github.com/jfromaniello/li
**/
function li () {
  // compile regular expressions ahead of time for efficiency
  var relsRegExp = /^;\s*([^"=]+)=(?:"([^"]+)"|([^";,]+)(?:[;,]|$))/;
  var keysRegExp = /([^\s]+)/g;
  var sourceRegExp = /^<([^>]*)>/;
  var delimiterRegExp = /^\s*,\s*/;

  return {
    parse: function (linksHeader, options) {
      var match;
      var source;
      var rels;
      var extended = options && options.extended || false;
      var links = [];

      while (linksHeader) {
        linksHeader = linksHeader.trim();

        // Parse `<link>`
        source = sourceRegExp.exec(linksHeader);
        if (!source) break;

        var current = {
          link: source[1]
        };

        // Move cursor
        linksHeader = linksHeader.slice(source[0].length);

        // Parse `; attr=relation` and `; attr="relation"`

        var nextDelimiter = linksHeader.match(delimiterRegExp);
        while(linksHeader && (!nextDelimiter || nextDelimiter.index > 0)) {
          match = relsRegExp.exec(linksHeader);
          if (!match) break;

          // Move cursor
          linksHeader = linksHeader.slice(match[0].length);
          nextDelimiter = linksHeader.match(delimiterRegExp);


          if (match[1] === 'rel' || match[1] === 'rev') {
            // Add either quoted rel or unquoted rel
            rels = (match[2] || match[3]).split(/\s+/);
            current[match[1]] = rels;
          } else {
            current[match[1]] = match[2] || match[3];
          }
        }

        links.push(current);
        // Move cursor
        linksHeader = linksHeader.replace(delimiterRegExp, '');
      }

      if (!extended) {
        return links.reduce(function(result, currentLink) {
          if (currentLink.rel) {
            currentLink.rel.forEach(function(rel) {
              result[rel] = currentLink.link;
            });
          }
          return result;
        }, {});
      }

      return links;
    },
    stringify: function (headerObject, callback) {
      var result = "";
      for (var x in headerObject) {
        result += '<' + headerObject[x] + '>; rel="' + x + '", ';
      }
      result = result.substring(0, result.length - 2);

      return result;
    }
  };
}

///////////////
console.timeEnd('Qiita show followees iine')

トークン

https://qiita.com/settings/applications
からread_qiitaにチェックを入れたトークンを発行してください。

速度

image.png

私の環境ではいいね100件取得するのに2~3秒かかっている。
直列なので、1000いいね記事などの取得はすこぶる長い。

学び

  • RFC 5988
  • Array.prototype.push.apply
  • async function

参考




他の人のQiitaAPI使った実装を見るに、ページネーションはnextを辿らずエラーが出るまで機械的にインクリメントが王道そうでしたけど、link気に入ったので。