こんにちは。2回目の投稿です。
今年の担当していた新人研修が一段落ついてホッとしているところです。
講師として、開発演習で新人がプログラミングをしているのを見ていたら、
自分も書きたい!っとなったので、コードを書くことにしました。
今回は前々から興味をもっていたGASに挑戦します。
GASって開発環境構築が不要なので、
短期のプログラミング体験にも向いているのではないか、と思っています。
そのあたりの調査も兼ねて、いざGASゆかんです。
やりたいこと
今回は社内Qiita啓蒙活動も兼ねて、社内の誰かが記事を投稿したら、
社内チャットに通知が送る仕組みを作ります。
作った背景として、
私が他の社内の人が書いた記事をもっと読みたいな、というのと、
記事から何かコミュニケーションが生まれたら良いな、ということで、
とりあえず作ってみようと思いました。
今回は、実装したいことは大きく分けて、下記の2つ。
- オーガナイゼーションに登録されているユーザーの投稿を検知する(本記事)
- 検知した投稿を社内チャットツールに通知する
こちらに沿って実装をしていきます。
まずは、
1.オーガナイゼーションに登録されているユーザーの投稿を検知する(本記事)
こちらから着手します。
下調べ
まずは、どうやって投稿を取得するかを考えます。
ぱっと思いついたのは
- QiitaAPIの使用
です。しかし、ドキュメントを読んでみると、オーガナイゼーションでの検索はできなさそうです。
(Teamを使うとできそう?しかし弊社はTeamじゃない・・・・)
やるとすれば、所属しているユーザーの一覧を使用して絞り込む形ならできそうです。
続いて思いついたのが、
- スクレイピング
Organizationページ部、赤枠を取得できれば良さそう
(今回、新規投稿の検知なので、上位数件だけ取得できれば十分)
スクレイピングできるかなと、該当ページのソースを見たところ、該当箇所がない。。。。
どうやら新着投稿は、GraphQLで後から生成しているようです。
これではスクレイピングするのは難しそうですね・・・・・。
ということで、今回はおとなしくAPIを使用する方法にしました。
仕様
ここで、どういったものを作るのか、仕様を整理します。
保存先
まず、一度検知した投稿は、再度通知したくないので、
通知履歴をどこかに保存する必要があります。
今回は、GAPで作るということで、
GASと相性の良さそうなスプレッドシートに保存します。
保存する内容は
- タイトル
- URL
- 投稿者
- 投稿日
あたりでしょうか。
※今回はURLで重複チェックをしていますが、
Qiitaの仕様上、ユーザー名が変更となるとURLも変更となります。
そのため、記事IDで比較するほうが望ましいでしょう。
(私も、本記事投稿後、そのように修正しています)
スクレイピング間隔
それほど更新頻度は高くないだろう、ということで、1日1,2回行うようにします。
QiitaのAPIリファレンスをみると、
認証している状態ではユーザごとに1時間に1000回まで、認証していない状態ではIPアドレスごとに1時間に60回までリクエストを受け付けます。
とあります。
幸いにも(?)対象のユーザー数は多くないので、
ここに抵触することはなさそうです。
実装
スプレッドシートの用意
まずは記録先のスプレッドシートを用意します。
作成したシートは下記。実際に入るデータを仮入力しています
なお、通知済みは 1:通知済み 0:未通知 という想定です。
続いて、Organizationに登録されているユーザーの一覧を作成します。
こちらはユーザーIDだけわかれば良いので、シンプルに保存します。
gsファイルの作成
まずはスプレッドシートからAppScriptを起動します。
上部メニューの 拡張機能 > AppScript をクリックし、エディタを開きます。
スプレッドシートにデータを追加する処理
苦手なスクレイピングは置いといて、
まずは気軽にできそうなシートに追加する処理をかきます。
ここでは、スクレイピング後、整形した結果を返す関数(getNewArticles())を作成し、
仮の値を返すことで、その先の処理を記載します。
/**
* スプレッドシートに新規に追加された
* 記事を登録する
* @void
*/
function addArticleList() {
// プロパティを取得
const prop = PropertiesService.getScriptProperties();
const spreadSheet = SpreadsheetApp.openById(prop.getProperty("SHEET_ID"));
const articleSheet = spreadSheet.getSheetByName(prop.getProperty("SHEET_NAME_ARTICLE"));
/**
* 対象ページから記事一覧を取得する
* @return [title, url, author, created ]
*/
const fetchNewArticles = function(){
// 仮データ
const result = [
{title:"タイトル", url:"https://example.com", author:"@test", created:"2022年05月30日" },
{title:"タイトル2", url:"https://example2.com", author:"@test2", created:"2022年06月30日" }
]
return result;
}
/**
* シートに新規記事リストを追加する
* @param newArticle
* @void
*/
const addArticle = function(newArticle){
// 追加する行番号を取得する
const rowNum = articleSheet.getLastRow()+1;
// No
articleSheet.getRange("A" + rowNum).setValue(rowNum-2);
articleSheet.getRange("B" + rowNum).setValue(newArticle.title);
articleSheet.getRange("C" + rowNum).setValue(newArticle.url);
articleSheet.getRange("D" + rowNum).setValue(newArticle.author);
articleSheet.getRange("E" + rowNum).setValue(newArticle.created);
// 通知済みか(デフォルト0)
articleSheet.getRange("F" + rowNum).setValue(0);
}
/**
* 記事一覧をシートにマージする。
* すでに登録されているURLの場合は追加しない。
* @param newArticles 新規追加記事
* @void
*/
const mergeArticles = function(newArticles){
// シートに記載されている記事のURL一覧を取得する(ヘッダー行はあっても支障がないため無視する)
const urls = articleSheet.getDataRange().getValues().map(row => row[2]);
// 新記事リストのURLが存在しない場合にはデータを追加する
newArticles.filter(newArticle => !urls.includes(newArticle.url)).forEach(newArticle => addArticle(newArticle));
}
// 記事一覧を取得する
const newArticles = fetchNewArticles();
// シートに追加する
mergeArticles(newArticles);
}
※シートIDなど固有の値については、「スクリプトプロパティ」に保存しています。
動かしてみる。
ちゃんと動いた!
マジックナンバー的なのが多いですが、
スプレッドシートを相手にしているので、ここは目をつぶります
ちゃんと、データの追加が出来たので、あとはいよいよ本命のスクレイピングです。
スクレイピング処理を記述する
fetchNewArticles()が仮データで動いているので、
このあたりを真面目に実装していきます。
下記の記事を参考にしつつ、実装します。
const URL = 'https://qiita.com/api/v2/items?page=1&per_page=30';
/**
* スプレッドシートから検索対象のユーザーを取得する
* @return array
*/
const fetchOrganizationUsers = function(){
return spreadSheet.getSheetByName(prop.getProperty("SHEET_NAME_USER")).getDataRange().getValues().map(row => row[0]);
}
/**
* ユーザーが書いた記事を検索するためのクエリストリングを生成する
*/
const createUserQueryString = function(users){
let queryString="";
users.forEach((user, index) => {
if(index != 0 ){queryString += "+OR+"}
queryString += `user:${user}`})
return queryString;
}
/**
* 対象ページから記事一覧を取得する
* @return [title, url, author, created ]
*/
const fetchNewArticles = function(){
// リクエストオプションの指定
const options = {
"method" : "get"
};
// 検索対象のユーザーを取得
const users = fetchOrganizationUsers()
// 検索文字列の作成
const queryString = '&query=' + createUserQueryString(users);
// リクエスト実行
const response = UrlFetchApp.fetch(URL + queryString,options).getContentText('UTF-8');
// JSONにParse
const articles = JSON.parse(response);
// フォーマットして返却
return articles.map(article => ({title:article.title, url:article.url, author:article.user.id, created:article.created_at}));
}
APIリファレンスによると、 OR を指定することでユーザーを複数指定できそうなので、これを活用します。
また、今回は最新投稿が欲しいので、(+社内の投稿頻度を考え)、上位30件を取得します。
(APIの仕様として、最新順に取得されます)
手順としては、
- スプレッドシートからユーザー一覧を取得
- クエリストリングの生成
- リクエスト発行
- JSONに変換し、欲しいフォーマットに変換
となります。
実行するとこんな感じに。
しっかりと情報を取得できていることがわかります。
(ユーザーで紐付けて取得しているため、オーガナイゼーションに紐付いていないデータも取れているが・・・・)
※追記:APIのレスポンス内 user > organization の値を確認することで、オーガナイゼーションに紐づく投稿のみピックアップすることも出来そうです(未検証)。
あとは、このリストをもとに、社内チャットに通知すればOKです。
ここまで長くなったので、続きは別記事で。
この記事のまとめ
初めてGASを触りましたが、JavaScriptベースということで、
GAS自体の書き方でほとんどハマることはありませんでした。
デバッグ機能も備わっているので、学ぶにはもってこいですね。
難点としては、ライトな層向けの情報が多く、
言語としての仕様であったり、深めの技術情報を中々見つけづらいことでしょうか。
それでは引き続き、社内チャットへの連携を作っていきたいと思います。
↓つづき