はじめに
皆さん、なろう小説は読んでますか?
私のお気に入りはありふれたシリーズです。ユエ様最高、異論は認めない。
皆さん、カクヨムは読んでますか?
最近電撃文庫系の作者が公式で投稿するようになって俺得やん、みたいな状況です。
お気に入りはネトゲ嫁です。シュヴァイン様最高、異論は認める。
さて、前振りはこれくらいにして、これらの小説、当然ながら不定期で更新されます。作者によっては毎週何曜日に、みたいな人もいますが、まぁ全員毎回同じ時間に更新してくれることはないでしょう。
とはいえ、みたい小説が新しく来てないかなぁーと毎回チェックするのはナンセンス。
ここはエンジニアらしくCoolに解決したいところですね。
と、いうことでなろう小説とカクヨム、両方に対応したSlack通知BOTを作成しました。
Slack側の準備
Slacsh Commandsを追加しよう
Slack側にはSlash Commandsアプリを追加する。追加の仕方は公式に書いてあるのでそれを参考に。
ワークスペースにアプリを追加する
このアプリはその名の通り、自分で好きなスラッシュコマンドをSlackに追加できる。
追加できたら、早速スラッシュコマンドを追加する。
設定項目は以下のとおり。
- Command → /narou
- URL → 後で埋めるので一旦空欄
- Method → POSTを選択
- Token → 生成されているはず。されてなければGenerate的なやつを押す。後で使う
- Customize Name,Icon → BOTの名前とアイコン。任意で
- Auto Complete help textのDescription→コマンドの概要。任意で
- Auto Complete help textのUsage Hint→コマンド補完。
add <最新ページのURL> | del <URL> | list | link
をコピペしとくといいかも
API Tokenを取得しよう
Slack APIのTokenの取得・場所この辺を参考にTokenを取得する。後で使うので適当にコピペしとくなり。
GAS側の設定
ここからが本番。サーバーなんて大層なものは用意せず、Google Apps Scriptで動かしているので、設定はちょっと手間。
DB代わりのスプレッドシートを作成しよう
GoogleDriveでスプレッドシートを作成。名前はなんでもいい。
その後、シート名がnarouとkakuyomuになってるシートを作成する。作成したら保存する。
GASプロジェクトを作成しよう
まずはGoogleDriveの画面から、Google Apps Scriptプロジェクトを新規作成する。

次に、GASプロジェクトに、以下のGithubからgsスクリプトをコピペして追加していく。
https://github.com/kiuchikeisuke/narouBOT
ライブラリを追加しよう
スクリプトをコピペしただけでは残念ながら動かない。ライブラリを追加する必要がある。
方法は、上記のタブから「リソース」→「ライブラリ」と開いていき
ダイアログに下部にある、「ライブラリを追加」のテキストボックスにそれぞれ以下のキーを入力し、追加ボタンを押す
- SlackApp
M3W5Ut3Q39AaIwLquryEPMwV62A3znfOO
- Paser
M1lugvAXKKtUxn_vdAG9JZleS6DrsjUUV
するとそれぞれ、SlackApp、Paserが追加される

追加し終えたら保存
設定ファイルを更新しよう
追加し終えたら、次はconst.gsの設定を行う。
Const = {
token: "", //Slack Regacy Token
channelId: "", //Slack ChannelId
spreadSheetId: "", //SpkeadSheetId
idColumnIndex: 1,
pageColumnIndex: 2,
titleColumnIndex: 3,
subTitleColumnIndex: 4,
userIdColumnIndex: 5,
checkUpdateFlagColumnIndex: 6
}
- token → 上記の「API Tokenを取得しよう」で取得したトークン
- channelId → この辺のSlackAPIを使って調べる。
- spreadSheetId → スプレッドシートを開き、URLを確認する。具体的にはURLが
https://docs.google.com/spreadsheets/d/<SpreadSheetId>/edit#gid=0
というようになっているので、Idの部分をコピペする。
全部入力し終えたら保存する
定期実行の設定をしよう
GASには指定した関数を定期実行する仕組みがある。
画面上部のアイコンの中の、時計?っぽいアイコンをクリックする

クリックするとダイアログが表示され、「新しいトリガー」を選択する。
すると要素が追加されるので、それぞれ以下のように設定する。

- 実行 → timerを選択
- イベント → 時間主導型、分タイマー、15分ごと
イベントのところは時間主導型であれば、時間幅は任意でOK。ただ、あまりにも間隔が短いとサーバー側に攻撃と見なされかねないので節度ある時間幅で。
設定し終えたら保存
GASプロジェクトを公開しよう
GASプロジェクトをWebサービスとして公開することで、Slackとのやりとりを可能にする。
画面上の、「公開」→「Webアプリケーションとして導入」を選択

設定は以下のとおり
- 次のユーザーとしてアプリケーションを実行 → 自分のアカウントを選択
- アプリケーションにアクセスできるユーザー → 全員(匿名を含む)
設定し終えたら、公開ボタンをぽちー。
すると承認してね、的なことが言われるのでぽちぽちする。んだが、ここに罠がある。
承認しよう
そのまま承認しようとボタンをぽちぽちすると、「レビューを通してないのでこのままじゃ公開させられねーぜ!」と言われる(意訳)。
それを無視して公開する(危険性を理解して公開する)方法があるのでそのルートを辿る。
ただ、操作が特殊なので、以下の記事の「原因と対処法」の部分を参考に公開をする。
GAS で「一部のスコープへのアクセス権限がありません」と怒られたときの対処法
承認が終わると以下のようなウェブアプリ用のURLが発行される。
https://script.google.com/macros/s/XXXXXXXXXXX/exec
Slackと連携させよう
上記で発行されたURLを、SlackのSlash CommandsのURLの記載の部分に貼り付ける
最終的にSlack側は以下のような感じになる

実際に使ってみる
サポートしているコマンドはSlackのUsage hint textにも書いてあるが、以下のとおり
-
/narou add <最新話のURL>
→ URLで指定した小説をBOTの監視対象に加える -
/narou del <最新話のURL>
→ URLで指定した小説をBOTの監視対象から外す -
/narou list
→ 監視対象の一覧を表示する(半分デバッグ用) -
/narou link
→ 監視対象のリンク一覧を表示する - サイトに更新があったら → BOTが教えてくれる
実際に使ってみるとこんな感じ
/narou list
スプレッドシートのパラメータがずらずらと並ぶ
技術的なお話
これ以降はほぼ蛇足なので、使うだけなら特に読む必要はない。どうやってこのBOT作ってるの?というお話をざっくり。
Slack → GAS → Slackのやりとり
GAS側でプロジェクトをWebhookとして使っているため、SlackCommandsで入力したパラメータがJSON形式でGASにやってくる。あとはそれをPOSTで受けてやる形。
詳しい話はQiitaでもいくつか記事が上がっているはずなのでググってもらえればすぐに見つかると思う。
実装としては以下の部分が該当する
function doPost(e) {
var request = parseRequest(e);
var msg;
switch(true) {
case /^add/.test(request.text):
msg = doAdd(request);
break;
case /^del/.test(request.text):
msg = doDel(request);
break;
case /^list/.test(request.text):
msg = doList(request);
break;
case /^link/.test(request.text):
msg = doLink(request);
break;
// case /^flg/.test(request.text):
// msg = doFlag(request);
break;
default:
msg = notifyError2Slack("パラメータおかしくね?\n text=" + request.text);
}
return encode2Json("ephemeral", msg);
}
ちなみにflagってパラメータを設定できるようにして、監視対象から一時的に外す、みたいなこともやろうと思ったが、利用シーンも多くないし、スプレッドシート直接いじればいいやー、となって実装途中で止まってる。
見ての通り、リクエストのテキストの始まりが何であるか、を判断して条件分岐。
その後諸々処理したらSlackに返す用のメッセージを作成してSlack側へ送信、というのが基本的な流れ。
小説の更新を検知する
大層なことを書いているが、やってることはWebページのスクレイピングして、今シートに保存している最新話のページ情報と一致しているかを判断しているだけ。
ただ、ページの構成がなろう小説とカクヨムで大きく異なるため、具体的な方法は異なる。
なろう小説の場合
なろう小説のURLは以下のような感じ
https://ncode.syosetu.com/n8577dn/397/
ドメインの次に小説のId、次にページ番号がそのまま数字で、といった具合。
なので難しいことを考えず上記の場合なら、https://ncode.syosetu.com/n8577dn/398/
が存在していないかをチェックすればOKとなる
カクヨムの場合
カクヨムのURLは以下のような感じ
https://kakuyomu.jp/works/1177354054885551452/episodes/1177354054885623990
ドメインの次にworks/<小説のId>/episodes/<ページのId>
と続いている。ここで曲者になるのがページのIdで、連番になっていない。そのためなろう小説と同じ手法は使えない。
なので、小説のトップページをスクレイピングし、そこからエピソード一覧を探し出し、最新話のIdと、保存されているIdを比較する、といった手法をとっている。
具体的には以下のHtmlを解析している
<div class="widget-toc-main">
<ol class="widget-toc-items test-toc-items">
<li class="widget-toc-chapter widget-toc-level1">
<span>ホワイトデー大連続クエスト</span>
</li>
<li class="widget-toc-episode">
<a href="/works/1177354054885551452/episodes/1177354054885590538">
<span class="widget-toc-episode-titleLabel js-vertical-composition-item">Lv.1「このままだと、取り返しのつかない失敗しそうだもん」</span>
<time class="widget-toc-episode-datePublished" datetime="2018-04-09T15:02:00Z">2018年4月10日</time>
</a>
</li>
<li class="widget-toc-episode">
<a href="/works/1177354054885551452/episodes/1177354054885623990">
<span class="widget-toc-episode-titleLabel js-vertical-composition-item">Lv.2「あたしの自信が崩壊待ったなしじゃないの」</span>
<time class="widget-toc-episode-datePublished" datetime="2018-04-14T15:00:40Z">2018年4月15日</time>
</a>
</li>
</ol>
</div>
これを以下の部分で解析している。
function checkKakuyomuUpdateAll() {
var sheet = getKakuyomuSheet();
var data = sheet.getDataRange().getValues();
for(var i = 0; i < data.length; i++) {
var id = data[i][0];
var parentUrl = Kakuyomu.baseUrl + id;
var html = UrlFetchApp.fetch(parentUrl).getContentText();
var episodesHtml = Parser.data(html).from('<li class="widget-toc-episode">').to('</li>').iterate();
var latestEpisodeHtml = episodesHtml[episodesHtml.length - 1];
var params = latestEpisodeHtml.match(/<a href=".+">/);
var param = params[0];
var items = param.split(" ");
var hrefItem = null;
for each(var item in items) {
if(item.match(/href=".+"/)) {
hrefItem = item;
}
}
if (hrefItem == null) {
continue;
}
var page = hrefItem.substring('href=\"'.length, hrefItem.length - '\"'.length).split("/")[4];
if(data[i][5] == true && data[i][1] != page) {
var user = data[i][4];
var title = data[i][2];
params = latestEpisodeHtml.match(/<span class="widget-toc-episode-titleLabel js-vertical-composition-item">.+<\/span>/);
param = params[0];
var subTitle = param.substring('<span class="widget-toc-episode-titleLabel js-vertical-composition-item">'.length,param.length - '</span>'.length);
var url = Kakuyomu.baseUrl + id + "/episodes/" + page;
saveKakuyomuPage(id, page, subTitle, user);
notifyUpdate2Slack(title, user, url);
}
}
}
ただこの手法、おそらく将来的にうまく動作しない可能性をがある。
というのも(試してはいないが)カクヨムの小説のトップページを見る限りでは、以下のような構成が可能のようなデザインになっている。
大タイトル(ネトゲ嫁)
+ 中タイトル1(ホワイトデー大連続クエスト)
| + エピソード1(Lv.1「...」)
| + エピソード2(Lv.2「...」)
| + エピソード3 ←これを検知できない
+ 中タイトル2(???)
+ エピソード1
+ エピソード2
+ ...
現在の実装ではHtmlの先頭からエピソード部分の<li>
タグを頼りにマッチしたリストを取得し、リストの一番最後にあるエピソード=最新のエピソード、という前提で判定している。
しかし、中タイトル2が存在した状態で、中タイトル1のエピソード3が追加された場合、その前提が崩れてしまうことを意味する。
ひょっとしたらエピソード部分のIdの数字の先頭何文字かが中タイトルのIdとなっててその辺を考慮すればなんとかなるかもしれないが、推測になってしまう。
そして、うまく動かなくなったらその時なおせばいいか〜ぐらいのモチベーションなので、その辺は気長に待ってほしい。あるいは修正してGithubの方にPR送ってくれると超嬉しい、といった感じ。
最後に
情報の受け取りの窓口としてSlackはかなり便利だし、
サーバーレスで手軽にサーバーっぽいことができるGASもかなり便利なので、これを機会に自分だけの通知BOTを作ってみてはいかがだろうか。