自社のIRをGoogle Apps ScriptでChatWorkに通知する

Google Apps Script（以下GAS）を用いて、[自社（株式会社ネクスト）のIR情報](http://www.next-group.jp/news/)をチャットワークに通知するように設定しました。

新卒1年目の頃、「ふつうに仕事してるだけじゃ、他部署の人が何やってんのかわかんねえな」って思ったことがコードを書いた動機です。

# スクレイピングを行う

以下の記事を参考に、正規表現を用いてHTMLをパースする方法を選びました。

* [Google Apps Scriptでスクレイピングする方法](http://qiita.com/ina6ra/items/23248b7290d8140f53fc)

HTMLのパース用のライブラリ（[PythonであればBeautiful Soup4](https://www.crummy.com/software/BeautifulSoup/bs4/doc/)）があればそちらを利用するつもりだったのですが、私が探した限りスクレイピング用のライブラリは見つかりませんでした。

また、[XmlService](https://developers.google.com/apps-script/reference/xml-service/)を利用する手もあったのですが、スクレイピングでは多少間違った形式のHTMLも存在するため、読み込みに失敗する場合があります。

また、「正規表現の最短マッチ」と「改行も含めた正規表現」で多少詰まりました。主に以下のサイトを参考にしました。

* [正規表現の最短マッチ](http://qiita.com/ha_g1/items/d41febac011df4601544)
* [JavaScriptでの正規表現のハマりポイント](http://qiita.com/xtetsuji/items/1da6e34eb907088ed256)


# チャットワークに通知する

ChatWorkの方が開発されたアドオンを使いました。

* [チャットワークAPI を Google Apps Script で使ってみた](http://c-note.chatwork.com/post/69590585422/chatworkapi-gas-library)

# 最新のURLだけを通知する

ただ、上記のChatWorkのアドオンには「room_idからメッセージを取得する」というメソッドは未実装だったようなので、（本当は自分の投稿から、既に投稿したURLを判断したかったのですが）スプレッドシートに別に保存するようにしました。

「Googleスプレッドシートに最新のURLを残しておき、それ以前のURLは投稿しない」というように設定しています。

# コード例

私はGoogle Apps Scriptや、JavaScriptやnode.jsを書き慣れていないので書き方が変かもしれません…。

```js
var ROOM_ID = '12345678'; // 投稿する部屋のID
var TOKEN = 'xxxxxxx'; // チャットワークのアクセストークン
var PICT_ID_DICT = {
  'next': '11111111' // あらかじめアップロードしているホームズくんのアイコン画像のid
};
var TITLE_DICT = {
  'next': 'ネクストニュースが更新しました'
};

/**
 * 実行する関数
 */
function myFunction() {
  try {
    postNextNews();
  } catch(e) {
    Logger.log(e);
    postChatWork(String(e));
    throw e;
  }
}

/**
 * ネクストのニュースを投稿する
 */
function postNextNews() {
  var target_news = selectLatestNews(fetchNextIr(), 'B2');
  if (target_news.length < 1) return;
  postNews(target_news, 'next');
}

/**
 * 投稿したことないニュースだけを返し、スプレッドシートに最新のURLを残す
 */
function selectLatestNews(news_list, cell_address) {
  var sheet = SpreadsheetApp.getActive().getSheetByName('シート1');
  var cell = sheet.getRange(cell_address);
  var latest_url = cell.getValues()[0][0];
  var target_news = takeUntilLastNews(news_list, latest_url);
  if (target_news.length < 1) return [];
  cell.setValues([[target_news[0]['url']]]); // save latest url
  return target_news;
}

/**
 * 既に投稿された最新のニュースが出るまでNewsを取得する
 */
function takeUntilLastNews(news_list, latest_url) {
  result = [];
  for (var i in news_list) {
    var news = news_list[i];
    if (news['url'] == latest_url) break;
    result.push(news);
  };
  return result;
}

/**
 * 株式会社ネクストのニュースを取得する
 */
function fetchNextIr() {
  return parseXmlToDict(
    fetchUrl('http://www.next-group.jp/news/'),
    /<ul class="news_list">([\s\S]*?)<\/ul>/,
    /<li class="clf">([\s\S]*?)<\/li>/g,
    /<span class="date">(\d{4}年\d{2}月\d{2}日)<\/span>/,
    /<span class="[\s\S]*?"><a href="[\s\S]*?".*?>([\s\S]*?)<\/a>[\s\S]*?<\/span>/,
    /<span class="[\s\S]*?"><a href="([\s\S]*?)".*?>[\s\S]*?<\/a>[\s\S]*?<\/span>/
  );
}

/**
 * 正規表現で与えたXML要素を取り出す
 */
function parseXmlToDict(html, table_regexp, row_regexp, index_regexp, title_regexp, url_regexp) {
  var table = parseMatchedElement(html, table_regexp);
  var rows = parseAllTags(table, row_regexp);
  return rows.map(function(row) {
    return {
      "index": parseMatchedElement(row, index_regexp),
      "title": parseToText(parseMatchedElement(row, title_regexp)),
      "url": parseMatchedElement(row, url_regexp)
    }
  });
}

/**
 * 正規表現にマッチする要素全てを取得する
 */
function parseAllTags(html, regexp) {
  return html.match(regexp);
}

/**
 * 正規表現にマッチした要素を取得する
 */
function parseMatchedElement(html, regexp) {
  var match = regexp.exec(html);
  if (!match) throw String(regexp) + 'にマッチする要素が見つかりませんでした';
  return match[1].replace(/^\s*(.*?)\s*$/, "$1"); // strip
}

/**
 * htmlタグを取り除き、テキストのみを返す
 */
function parseToText(html) {
  return html.replace(/<("[^"]*"|'[^']*'|[^'">])*>/g,'')
}

/**
 * URLからデータを取得する
 */
function fetchUrl(url) {
  var response = UrlFetchApp.fetch(url);
  return response.getContentText();
}

/**
 * チャットワークにメッセージを送信する
 */
function postChatWork(message) {
  var client = ChatWorkClient.factory({token: TOKEN});
  client.sendMessage({room_id: ROOM_ID, body: message});
}

/**
 * ニュースを通知する
 */
function postNews(news_list, media) {
  var title = '[title]' + TITLE_DICT[media] + '[/title]';
  var body = news_list.map(createRowMessage).join("\n\n");
  var pic = '[preview id=' + PICT_ID_DICT[media] + ' ht=120]';
  var message = pic + '[info]' + title +  body + '[/info]';
  postChatWork(message);
}

/**
 * 一行のニュースを成形する
 */
function createRowMessage(news) {
  return news['title'] + '\n' + news['url'];
}
```

Google Apps Scriptはcronのようにコードを定期実行する機能があるので、↑のコードを設定すると、次のように自動でチャットに通知してくれます。

<img width="453" alt="capture.JPG" src="https://qiita-image-store.s3.amazonaws.com/0/127762/faf2d7f4-bc0f-0b60-a55f-72bf54833585.jpeg">

また、正規表現で汎用的に取れるようにしたので、例えば競合他社のIR更新通知などの横展開も簡単にできました。（こちらはIR情報がATOM方式で公開されていたため && 万が一不要な負荷をかけて迷惑をかけないようATOMから取得しています）

アイコン画像も表示しているのは、「どの会社のIRなのかひと目で分かるようにするため」という意味もあります。

```js
/**
 * リクルート住まいカンパニーのIRを投稿する
 */
function postSuumoNews() {
  var press_news = selectLatestNews(fetchAtom('http://www.recruit-sumai.co.jp/press/atom.xml'), 'B3');
  var data_news = selectLatestNews(fetchAtom('http://www.recruit-sumai.co.jp/data/atom.xml'), 'B4');
  var target_news = press_news.concat(data_news);
  if (target_news.length < 1) return;
  postNews(target_news, 'suumo');
}

/**
 * Atom形式のニュースを取得する
 */
function fetchAtom(url) {
  return parseXmlToDict(
    fetchUrl(url),
    /<feed.*?>([\s\S]*?)<\/feed>/,
    /<entry>([\s\S]*?)<\/entry>/g,
    /<published>([\s\S]*?)<\/published>/,
    /<title>([\s\S]*?)<\/title>/,
    /<link.*?href="([\s\S]*?)".*?\/>/
  );
}
```