アドベントカレンダーの各記事の投稿状況、いいね数などを簡単に確認したい


はじめに

Advent Calendarは初の参加となります。株式会社ピー・アール・オーと申します。
弊社には60名超の技術者が在籍していますが、各人の興味の方向性が割とバラバラでして、
Advent Calendarを実施するにあたりテーマ決めを試みたのですが全くまとまらず、
結果として各人が書きたいこと・やりたいことやってみるという場にすることにしました。

一応、社是である「あったらいいな」を副題として掲げていますが、
誰のための「あったらいいな」なのかは各人に丸投げしているので、
本当にあったらうれしい記事になるのか?については私もわかりませんのでご容赦ください。

Qiita Advent Calendarを楽しみたい

こっからが本記事の本題です。
今回会社としては初めてAdvent Calendarに参加するため、いかにこのイベントを楽しもうか?というのを考えたいと思いました。
それこそ本物のAdvent Calendarみたいに、イエスの降誕を待ち望む気分で25日間を過ごしたいものです(経験はないので想像ですが)。
いや、参加するだけでももちろん十分楽しいんですけど、なにかこう、記事を書いてくれる仲間と自分のモチベーションをあげられるようなことを、「スマート」な感じで一発目でやりたいと思ってました。

とりあえず俯瞰かな

やはり仲間内でやるので、各人の記事がどれくらい読まれ、「いいね」されたか?という情報は結構励みになるものです。Qiita組織のページを見れば各記事の「いいね」数などは閲覧できますが、すべての記事が対象となるし、ページングもあるので少し視認性が悪いです。
それに、あくまでもアドベントカレンダーという枠で見たいなと思いました。

やりたいこと

というわけで、弊社のAdvent Calendarから情報を引いて、何らかの形で投稿の有無やview数、いいね数などを把握することを本記事の目標としてみます。

※この時点で割と過去に繰り返されたネタであろうことは察しがついてますが、私自身が「あったらいいな」と思ってるのでそのまま突き進みます。

Qiita APIを見てみる

やりたいことはまだ虚ろですが、なんとなーくの方向性はふわっとですが定まりました。
ですが実現するものの姿はまだ見えていません。
こういうときは公開されているAPIを眺めてみるのがいいと思いました。
Advent Calendarについてapi叩いて得られる情報を知れば、そこから何かこうしてこう・・・というアイデアが浮かぶのではないかと・・・
qiita api v2 ドキュメント

はい。ここで軽く絶望しましたね。
qiita諸先輩方はここらでもうお分かりと思いますが、なんとqiitaにはアドベントカレンダーに関するAPIが用意されてないのです。
qiitaといえばAdvent calendar的に思ってた私は裏切られた気持ちを少し感じましたが、それは私の詰めが甘かったと割り切ることにしました。
よくよく過去のAdvent Calendarを見てみても同様のネタは上がっており、たいてい皆さんスクレイピングなどで凌いでいるようでした。

仕方ないのでスクレイピングしてみる

まずは、Advent Calendarの情報を得ないことには始まりません。諸先輩方に倣い、スクレイピングによって情報を得るようにしてみます。
取得した情報をストックしておいて後で如何様にも使えるようにしておきたいので、今回も開発の場はSpread Sheet + GASに決定です(最近これしか使ってない気がする)。

Advent calendarページの構成

なるべく楽できるように、Advent Calendarのhtmlを見てみます。

カレンダー自体はtableで作られていて、各日にちはtdになっているようです。
日にち、コメント、投稿者などはそれぞれ独自のClassが当たっているので楽できそうです。

ただ、これは弊社の2019カレンダーですので、まだ記事が投稿されていません(執筆は11月)。
他の2018年カレンダーを一応見てみましょう。

※以下は食べログ様の2018年Advent Calendarを参照させていただきました。
食べログ Advent Calendar 2018

commentの中にaタグですね。問題ないです。そしてaタグには記事IDが含まれるので、ここで記事を取得したらQiita apiの/api/v2/items/:item_idを叩いて個別に記事情報を得れば具合がよさそうです。

次に、出力用のシートを準備しておきます。
取得項目は適当に、知りたいと思うことを入れておきます。実現性はあとで検討しましょう。

そして、Qiita APIを使用することにしたので、Qiita APIを利用する準備をします。
GASでQiita APIを使ってView・いいね・ストック数の一覧を取得するを大いに参考にさせていただきました。

試行錯誤しましたが、最終的にはこんな感じになりました。

Advent Calendarページのスクレイピング処理

function scrapeCalendar() {
  // 集計シート
  var book = SpreadsheetApp.getActiveSpreadsheet();
  var sheet = book.getSheetByName("2019AdventCalendar");

  // シート内カラム・開始行定義
  const COL_DATE      = 1;
  const COL_AUTHOR    = 2;
  const COL_ICON      = 3;
  const COL_TITLE     = 4;
  const COL_VIEWS     = 5;
  const COL_LIKES     = 6;
  const COL_COMMENTS  = 7;
  const COL_REACTIONS = 8;
  const COL_UNPOSTED  = 9;
  const ROW_START     = 2;

  // Qiita Advent Calendarページのクラス定義
  const DAY_TAG    = '<td class="adventCalendarCalendar_day">'
  const DATE_TAG   = '<p class="adventCalendarCalendar_date">';
  const AUTHOR_TAG = '<div class="adventCalendarCalendar_author">';
  const ITEM_TAG   = '<div class="adventCalendarCalendar_comment">';

  // 取得先カレンダーURL
  var url = 'https://qiita.com/advent-calendar/2019/pro-japan1';

  // カレンダーページを取得
  var response = UrlFetchApp.fetch(url);
  var html = response.getContentText('UTF-8');
  var index = 0;
  var row = ROW_START;

  while(1) {
    // 日付
    var date = getTagContent(html, DATE_TAG, '</p>', DAY_TAG);
    // 日付がとれなかったら抜ける
    if(date === 0) break;
    // 日付をSpreadSheetに書き込み
    sheet.getRange(row, COL_DATE).setValue(date);

    // 投稿者
    var author = getTagContent(html, AUTHOR_TAG, '</div>', DAY_TAG);
    if(author !== 0){
      // 投稿者名とアイコンに分ける
      var author_name = author.replace('<a href="/', '').replace(/\".*/, '');
      var author_icon = author.replace(/.*src="/, '').replace(/\".*/, '');
      sheet.getRange(row, COL_AUTHOR).setValue(author_name);
      // 画像は保留
      //sheet.insertImage(author_icon, COL_ICON, row);
    }

    // 記事
    var item = getTagContent(html, ITEM_TAG, '</div>', DAY_TAG);

    if(item !== 0) {
      if(item.indexOf('<a') !== -1) {
        // 記事投稿済みの場合は個別にQiita apiをcallしてview数などを取得する
        // ※advent calendarの仕様上qiita以外の記事もセット可能・・・念のためそこ考慮しておく
        if(item.indexOf('href="https://qiita.com/') !== -1) {
          var title = item.replace(/<.*">/, '').replace(/<\/a>/, '');
          var item_id = item.replace(/<a.*href\=\"https\:\/\/qiita\.com\/.*\/items\//, '').replace(/\".*/, '');
          var item_info = getItemInfo(item_id);
          // ここで知りたいことを知る
          var views = item_info.page_views_count;
          var likes = item_info.likes_count;
          var comments = item_info.comments_count;
          var reactions = item_info.reactions_count;
          // Spreadsheetに入れておく
          sheet.getRange(row, COL_TITLE).setValue(title);
          sheet.getRange(row, COL_VIEWS).setValue(views);
          sheet.getRange(row, COL_LIKES).setValue(likes);
          sheet.getRange(row, COL_COMMENTS).setValue(comments);
          sheet.getRange(row, COL_REACTIONS).setValue(reactions);
        } else {
          // 外部記事の場合
          var title = item.replace(/<[^\/]*">/, '').replace(/<\/.*>/, '');
          sheet.getRange(row, COL_TITLE).setValue(title);
        }
      } else {
          sheet.getRange(row, COL_TITLE).setValue(item);
          sheet.getRange(row, COL_UNPOSTED).setValue('未投稿');
      }
    }    

    // htmlを読み込んだところまで切り詰める
    html = html.substring(html.indexOf(DAY_TAG));
    index = html.indexOf('</td>');
    html = html.substring(index);

    row++;
  }
}

/**
* スクレイピング結果を指定タグで取得・切り詰める
* @param {string} html スクレイピング結果
* @param {string} startTag 開始タグ
* @param {string} endTag 終了タグ
* @param {string} parentTag 親タグ
* @return タグ内コンテンツ
*/
function getTagContent(html, startTag, endTag, parentTag){
  // 指定されたタグのありかを探す
  var index = html.indexOf(startTag);

  // 親タグの位置
  var p_index = html.indexOf(parentTag);
  // 次の親タグの位置
  var np_index = html.indexOf(parentTag, p_index + 1);
  // 見つからないor次の親タグより先にある時は抜ける
  if(index == -1 || np_index < index) 
    return 0;

  // 開始タグ以降のhtmlを取得して、そこからの終了タグの位置を知る
  html = html.substring(index + startTag.length);
  index = html.indexOf(endTag);

  // タグ内コンテンツを返す
  return html.substring(0, index);
}

楽できそうといいながら、割と力業になってしまいました。
実はこちらで紹介されていたParserを当初使ってみたのですが、なぜか作成時にメモリが足らんとかで動かないことが何回か発生※し、泣く泣く断念した経緯がございます。
※ただ、このときはほかの動作も緩慢だったので、Parser自体の問題というよりは環境面の問題だった気はしてます。

スクレイピング機能ができたので、あとはこいつをトリガー起動に設定しておきます。
まああまり頻繁に動かすべきではないので、1時間に一回とかにしておきます。

Qiita APIのコール部

/**
* Qiita Apiから記事情報を得る
* @param {string} item_id
* @return 取得した記事情報
*/
function getItemInfo(item_id) {
  // endpoint、トークン
  const API_V2   = 'https://qiita.com/api/v2';
  const API_ITEM = '/items/';
  var prop =  PropertiesService.getScriptProperties().getProperties();
  var token = prop.qiitaToken; // tokenはスクリプトプロパティで管理

  // APIヘッダ
  var headers = {'Authorization' : 'Bearer ' + token};
  var params = {'headers' : headers};

  // 取得
  var res = UrlFetchApp.fetch(API_V2 + API_ITEM + item_id, params);
  Logger.log(res);
  return JSON.parse(res.getContentText());
}

これでAdvent Calendarの情報をSpread sheetに収めることができそうです。
ちなみに実行した結果は以下のようになりました。

ここでも、まだ一件も記事書いていないので閲覧数もいいね数も表示されません。
動作確認のため、2018の過去のカレンダーを標的に実行してみましょう。
先にご紹介した、食べログ様のカレンダーを見てみました。

(別にぼかし入れる必要はないでしょうけど。)
ここで私は重要な点に気づきました。閲覧数が取れてないですね。
あらためて調べてみたところ、QiitaはApiに限らずView数は投稿者本人しか知ることができないようです。なんてこったい・・・

まあ、できないことは仕方ないので、この辺りは運用でカバー(投稿者が自己報告するスタイル)するとして、この結果をどう使うか?を次に考えていきます。

結果をどのように知りたいか?

今回は「俯瞰」がテーマですので、あくまでもライトな感覚で、さくっとこの結果を知ることができるように考えてみましょう。
メンバー間で、情報共有というとやはりSlackですかね。
そしてデータの俯瞰という点ではグラフがよろしかろうと思います。

というわけで、グラフの作成とSlackの設定をかましていきます。

グラフ

手抜き効率化のため、グラフはSpreadSheetで作成しまいましょう。
こんな感じの横棒グラフにしてみました。(データはグラフの表示確認用にダミーにしてます)

グラフが二つあるのは、GASからグラフを画像化した際に、どうしてもy軸が省略されてしまい、表示されない日にち・投稿者が出てしまうのを正攻法で解決できなかったためです。
もうこの時点で相当スマートさを失っています。

Slack

SlackのOutgoing Webhooksを受け、画像化したグラフを返すという方向性にしました。
Outgoing webhookについては、先日弊社の新人佐藤くんが上げた、消灯当番を決めてくれるSlackのBotを作る① ~とりあえず返事だけしてくれ編~を参考にしつつも、
Slack BotをGASでいい感じで書くためのライブラリを作った において紹介されていたSlackAppが大変楽そうだったので大いに利用させていただきました。
また、SlackにSpread sheetのグラフを投げつけるやり方については、GoogleスプレッドシートのグラフをSlackにアップロードするの方法を丸パクリです。
今日もQiitaの記事のおかげでコードが書けてます。

結果として、Slackとの連携部分のコードは以下のようになりました。

var book = SpreadsheetApp.getActiveSpreadsheet();

var slackApp;

/**
 * グラフをslackに投げつける。
 * @param {string} channelId 投稿するチャンネルID
 */
function report(channelId) {
  initialize();
  var charts = book.getSheetByName('Graph1').getCharts();
  var result = true;
  charts.forEach(function(item, index) {
    var name = 'advent calendar ' + index + '.png'
    var response = upload(channelId, item.getBlob().setName(name), name)

    if (response['ok'] != true) {
      result = false
    }
  })
  return result
}

/**
 * グラフ画像のuploadメソッド
 * @param {string} channel
 * @param {blob} chart
 * @param {string} name
 */
function upload(channel, chart, name) {
  return slackApp.filesUpload(chart, {
    "filename": name,
    "channels": channel
  })
}

/**
* webhookを受け取る
* @param e {object} postパラメータ
*/
function doPost(e) {

  var prop =  PropertiesService.getScriptProperties().getProperties();
  if (e) {
    if (prop.verifyToken != e.parameter.token) {
      throw new Error("invalid token.");
    }

    // slackApp使う
    var slackApp = SlackApp.create(prop.slackToken); 

    // 受領メッセージ送っておく
    slackApp.chatPostMessage(e.parameter.channel_id, "Hi " + e.parameter.user_name, {
      username : "2019 Advent Calendar Bot",
      icon_emoji : ":+1:" 
    });    

    // グラフを送る
    report(e.parameter.channel_id);
  }
  return null;
}

/**
* SlackAppを初期化
*/
function initialize() {
  // スクリプトプロパティ
  var prop = PropertiesService.getScriptProperties().getProperties();

  //slackApp インスタンスの取得
  slackApp = SlackApp.create(prop.token); //tokenはスクリプトプロパティで管理
}

Slackのwebhookの設定はこんな感じにしました。

実行してみる

一通りパスがつながりました。実行してみましょう。
(重ね重ね、数字は動作確認用ダミーです)

無事動きました!
見てくれの面では課題はありますが・・・。

最後に

これで2019のAdvent Calendarを楽しむ準備が最低限できました。
途中の構想段階では、未投稿の枠を知らせたり、投稿予定日を過ぎた投稿者にプレッシャーをかけていく機能などいろいろ考えていたんですが、そうした機能はおいおい追加していこうと思います。
追加した場合は、本記事にて追記いたします。