🦖

怠惰デザイナーワイ、コンペ情報通知botを作成する

2023/07/08に公開

はじめに

この記事は現在フリーランスデザイナーの私が、大学時代にかじったプログラミング知識の残りカスで作成したもの&初めての記事です。
もっと合理的な方法や上手い書き方もあると思いますが、お手柔らかにお願いします。

コードの全容はこちら

この記事の対象者

  • Discord生活してる人
  • GASでスクレイピングしてみたい人
  • 同様のbotを作成してみたい人
  • 自分のサーバーにbot導入してイキりたい方 ← 必見!!!

作成経緯

Discord生活

当方、生粋のゲーマーかつ自宅PCでの作業が中心なため常時Discordに浮上していました。(在宅時にスマホを開かないためLINEよりDiscordで人と話すことの方が圧倒的に多い)
これまでゲームの通話以外にも、いっそのことDiscordで全部管理しちゃおう! と思い、ゲーマーもしくは僕の専門分野に興味がある友人たちが集うサーバーを作成しました。
Discordサーバー
超公私混同Discordチャンネル

こんな感じでありとあらゆる情報をまとめていて、ゲーム系のチャンネル以外にも

  • ジャンルごとに分化した雑談スレ
  • プログラミング関係
  • デザイン関係
  • 動画編集関係

など、自分が仕事していて参考になったサイト記事や、今後また訪れるページなどがどんど溜まっていき、膨大なデータベースみたいになっていってます。(スレッドが細分化し過ぎてて自分にしかたどり着けない)

他にはプライベートチャンネルで

  • パスワード保存
  • 自分のGoogleカレンダーの予定を通知するbot (今後記事にするかも)
    Googleカレンダー通知bot
  • 自宅の緯度経度の天気予報を通知するbot(停止中)
  • クリップボード代わりに張り付けるだけチャンネル

など完全にプライベートなことも一括Discordで行ってます。


現職デザイナーの方やデザインに興味のある学生さん達の中には、各企業自治体が行うデザインコンペに参加される方がいるかと思います。
当方も今後積極的にコンペに色々応募していこうと思い立った所存ですが、エントリー資格が学生限定だったり、内容がデザインと無関係だったりで毎回サイトを開いて確認するのが億劫に感じます。

作ったbot

そこで今回はコンペ情報が更新されたらDiscordに通知するbotを作成し、チャンネル内で興味があるタイトルが見えたら詳細を見に行けるようにしました。

要はただのスクレイピングbotってことです。

使用した技術

  • Google Spreadsheet
  • Google Apps Script (以降GAS)
  • Webhooks(Discord)

たったこれだけです。

GASとはなんぞやって方はこちら
https://workspace.google.co.jp/intl/ja/products/apps-script/

Google Apps Scriptとは、Googleが提供しているプログラミング言語です。それぞれの頭文字をとってGAS(ガス)とも呼ばれています。Google Apps Scriptの基本的な文法は、JavaScriptの文法を踏襲しており、JavaScriptを学習済みなら、ほとんどそのまま利用できます。

  1. GASでスクレイピングサイトのHTML要素を取得
  2. スプレッドシートに張り付け
  3. 更新があったら通知 | 更新が無ければ通知しない

ほんとこれだけ。

多分GASでのプログラミング経験がほとんどない方でも他の言語をちょっと触った方なら問題なく理解できる内容だと思います。

いざbot作るで

チュートリアル的な感じで参考になれば嬉しいです。

STEP1 Google Spreadsheetを作成

Googleドライブからスプシを作成します。ファイル名は何でも大丈夫です。

作成できたら上のツールバーから「拡張機能 > Apps Script」と進みます。

「Apps Script」をクリックするとこんな感じの画面に進むと思います

これからここに色々コードを書いていきます。

STEP2 ライブラリのインストール

GASでスクレイピングをするためにはライブラリを読み込みする必要があります。

「ライブラリ」をクリックします

「ライブラリの追加」画面で検索バーがでてくるので、

↓をコピペして検索してください

1Mc8BthYthXx6CoIz90-JiSzSafVnT6U3t0z_W3hLTAX5ek4w0G_EIrNw

バージョン・IDはデフォルトのままで大丈夫なので、「追加」を選択して進みます。

STEP3 コードの説明

まずは変数の設定です

const WEBHOOK_URL = "https://discord.com/api/webhooks/~~~";
const URL = "https://docs.google.com/spreadsheets/~~~";
const USER_NAME = 'コンペ情報お知らせbot';

WEBHOOK_URLは、DiscordのWebhook URLを入れます。このURLでDiscordにメッセージを送信します。通行手形みたいなもんだと思ってください(?)

URLは、GoogleスプレッドシートのURLを入れます。スプシにアクセスするために使用されます。

USER_NAMEは、DiscordでBOTのユーザー名として表示されるテキストです。お好みの名前に変えてあげましょう。

Discord Webhook URLの発行方法

botを導入したいチャンネルの設定から 連携サービス > 新しいウェブフック と進みます

作成ができると初期アイコン & 自動で名前が作成されるので、webhookを複数作成した時に混乱しないようにお好みのbotアイコンと分かりやすいウェブフック名にしておきましょう。

作成したウェブフックのタブを開き、「ウェブフックURLをコピー」でwebhook URLがコピーできます。

メインの関数を作成します

最終的にGAS側でトリガーとなる関数を設定するんですが、その際に実行させるメインの関数を作成します。
基本的にDiscordが公式で出しているリファレンスをコピペ参考にしただけです。

流れはこんな感じ↓

  1. この後作成するinputValue()という関数を呼び出す
  2. コンペのタイトルを取得※
  3. コンペ情報をオブジェクト化しJSON形式に変換してPOSTリクエスト

※コンペのタイトルが更新されていない場合はnullを返すので何も起きません(スプシの操作も行わない)

function sendToDiscord() {
  const webhookUrl = WEBHOOK_URL;
  var msg = inputValue();

  if (msg) {
    const message = {
      content: msg
    };
    const options = {
      method: "post",
      contentType: "application/json",
      payload: JSON.stringify(message)
    };
    UrlFetchApp.fetch(webhookUrl, options);
  }
}
もっとカスタマイズしたい人向け - Discord webhook referrence

サイトからデータを取得し、スプシの操作を行う関数を作成します

本当は可読性を上げるためにもっとパーツ分けた方がいいんですが、元々1関数ゴリ押しで作ろうとしてたので謎に複数機能ある関数になってしまいました。(反省)
綺麗なコード書きたい方はこの関数を切り分けてみてください。

.............そのためにわざと悪い例として作りました!!!

function inputValue() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const sheet = ss.getSheetByName('sheet1');

  var newest = getHTMLValue();
  var lasts = getValues_from_Spreadsheet()[0];

  var outputs = [];
  var msg = "";

  if (newest.toString() == lasts.toString()) {
    console.log("Nothing was changed. Keep past 10 titles.");
    return null;
  } else {
    outputs = [newest];
    console.log(outputs);
    sheet.getRange(1, 1, 1, 10).setValues(outputs);
    console.log("New title seems like added");

    var newTitles = getUpdatedData([lasts], [newest]);
    var howMany = newTitles.length;
    msg = "@here\n=======================\n登竜門に新たなコンペが" + Math.floor(howMany) + "つ掲載されたようです。\n\n";

    for (var i = 0; i < howMany; i++) {
      msg += "■" + newTitles[i] + "\n";
    }

    msg += "-----------------------\nチェックはこちらから↓\n  https://compe.japandesign.ne.jp/\n=======================";
    Logger.log(msg);
    return msg;
  }
}

ごちゃごちゃしてるので少し丁寧に説明します。適宜読み飛ばしてください。

  • SpreadsheetApp.getActiveSpreadsheet()でスプシを取得
  • getHTMLValue()を呼び出して最新のデータ(newest)を取得 ← ここでスクレイピングしている
  • getValues_from_Spreadsheet()を呼び出して過去のデータ(lasts)を取得 ← スプシに保存している直近10件のコンペ情報を引っ張ってくる
  • outputsmsgという空の配列を宣言
    • newestlastsを比較し、変更がない場合はnullを返して終了
    • 更新があった場合は、outputsnewestを格納し、スプシに新しいデータを書き込む
  • getUpdatedData()を呼び出して新たに追加されたコンペの配列を取得し、その数をhowManyとして格納
    • DiscordにPOSTする用のメッセージを作成し、msgに代入

スクレイピングでコンペ情報を取得する関数

function getHTMLValue() {
  let response = UrlFetchApp.fetch("https://compe.japandesign.ne.jp/");
  let content = response.getContentText("utf-8");
  var all_text = Parser.data(content).from('<h3>').to('</h3>').iterate();
  latest_10 = all_text.slice(0, 10);
  return latest_10;
}
getHTMLValue() 解説

この関数の概要
自分で書くのがめんどくさかったのでChatGPTさんに聞いてみました

この関数は、ウェブページから最新のタイトルを取得するために使用されます。取得したタイトルは後続の処理で使用され、変更の有無を判断したり、新たに追加されたタイトルを抽出したりするために活用されます。
なお、Parserオブジェクトや関数はこのプログラム内で直接定義されていないため、外部のカスタムライブラリや関数が使用されている可能性があります。その詳細については、提供されたコードの範囲内では判断できません。関連するカスタムライブラリや関数についてのドキュメントや追加の情報を参照する必要があります。

らしいです。今言おうとしてました。

  • UrlFetchApp.fetch()で指定されたURLにGETリクエストを送信し、レスポンスを取得。これによりWebページのHTML要素がresponseオブジェクトに格納される
  • response.getContentText("utf-8")で、レスポンスからテキストコンテンツを取得します。"utf-8"でテキストのencode形式を指定してますがutf-8がデフォルトなので()内は何も書かなくても大丈夫です
  • Parser.data(content).from('<h3>').to('</h3>').iterate()で、content内のHTMLコンテンツから<h3>タグで囲まれたテキストを抽出します
    • Parserは、HTMLの解析およびデータの抽出を行うためのライブラリまたは関数のようなものです。詳しくはこちら
  • all_text.slice(0, 10)で、<h3>要素を配列で取得。slice(0, 10)により最新10件のタイトル(もしくは指定されたHTMLタグで囲まれたテキスト)がlatest_10に格納される
  • latest_10を返して、呼び出し元に最新10件のタイトルを渡します

スプシから前回実行時の10件を取ってくる関数

function getValues_from_Spreadsheet() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const sheet = ss.getSheetByName('sheet1');

  var range = sheet.getRange('A1:J1');
  var values = range.getValues();

  return values;
}
getValues_from_Spreadsheet() 解説

この関数の概要
自分で書くのがめんどくさかったのでChatGPTさんに聞いてみました

この関数は、スプレッドシートの特定の範囲の値を取得するために使用されます。取得した値は、後続の処理で使用される可能性があります。
なお、関数内で指定されている範囲やシート名は固定されており、必要に応じて変更する必要があります。また、SpreadsheetAppやその他の関連するクラスやメソッドは、Google Apps Scriptのスプレッドシートサービスから提供されるものであり、詳細については公式ドキュメントを参照することをおすすめします。

らしいです。今言おうとしてました。

  • SpreadsheetApp.getActiveSpreadsheet()でスプシを取得。これでssオブジェクトにアクセスできる
  • ss.getSheetByName('sheet1')でスプシで"sheet1"という名前のシートを取得します。取得したシートオブジェクトはsheetに格納される
  • sheet.getRange('A1:J1')でA1からJ1までのセル範囲のデータを取得するための範囲オブジェクトを取得します。範囲オブジェクトはrangeに格納
  • range.getValues()で範囲内のセルの値を取得します。値は2次元配列で返され、valuesに格納されます
  • valuesを返して、呼び出し元にスプシの指定した範囲の値を渡します

スプシ操作時に必要な2次元配列についてはこの記事が参考になるかと思います。

更新されたものだけを選別する関数

function getUpdatedData(arrayA, arrayB) {
  var updatedData = [];
  
  for (var i = 0; i < arrayB[0].length; i++) {
    var title = arrayB[0][i];
    if (arrayA[0].indexOf(title) === -1) {
      updatedData.push(title);
    }
  }
  return updatedData;
}
getUpdatedData() 解説

この関数の概要
自分で書くのがめんどくさかったのでChatGPTさんにk

この関数は、新たに追加されたデータ(タイトルなど)を検出するために使用されます。具体的には、arrayA には以前のデータが格納されており、arrayB には最新のデータが格納されています。関数は arrayB に存在し、arrayA に存在しない要素を updatedData 配列に追加し、それを呼び出し元に返します。

前回の10件と今回の10件を比較した時に更新してる場合は通知したいんだけど、Discordに送るPOSTには更新されたものだけにしたい
そのために更新されたものだけを別で取り出す必要があるってことやね

  • updatedDataという空の配列を作成。この配列は更新されたデータ(arrayBに含まれていてarrayAに含まれていない要素)を格納する
  • arrayBの要素を1つずつループ
    • arrayB[0][i]arrayBのデータを取得
  • arrayA[0].indexOf(title)arrayA内のタイトルがarrayBに存在するかチェックします。indexOf()は指定した要素が配列内で最初に出現するindexを返します。もしtitlearrayA[0]内に存在しない場合、indexOf()-1を返します
  • もしarrayA[0].indexOf(title)の結果が-1arrayA内にtitleが存在しない)であれば、そのタイトルをupdatedDataに追加します。
  • ループ終了時、配列updatedDataを返します。

これにてプログラム自体は完成です。
保存 > 実行 で実際にDiscordに通知が来るか試してみましょう。

プログラムを自動で動かす

さて手動で実行した際にきちんとDiscordに通知が来た方は自動で動かしてみましょう。
左のメニューバーから「トリガー」を選択し、

画面右下の「トリガーを追加」を選択して、

↓を設定します。

  • 実行する関数はsendToDiscord
  • イベントのソースは時間主導型
  • トリガータイプは日付ベース
  • 時刻は好きな時間(選択した時間のどこかで発火しますが、たまにフライングしたり遅刻したりします)

を選択して保存します。

これでbotの完成です。

コードにしか興味無い方向け(コード全容)

「前置きはいいからさっさとコード見せろや!」という有識者向け
コピペで動かす際はURL系の変数の中身いじってよしなにやってください。

コード全容

躓いたらこれ見ましょう()
https://developers.google.com/apps-script/quickstart/custom-functions?hl=en

const WEBHOOK_URL = "discordのwebhook URLをいれます";
const URL = "スプシのURLをいれます";
const USER_NAME = 'discordで表示したいbot名にします(長すぎると切れるよ)'

function sendToDiscord() {
  // Discord Webhook URL
  const webhookUrl = WEBHOOK_URL;
  var msg = inputValue();
  // メッセージの作成
  if (msg) {
    const message = {
      content: msg
    };
    // POST request setting
    const options = {
      method: "post",
      contentType: "application/json",
      payload: JSON.stringify(message)
    };
    // send POST request
    UrlFetchApp.fetch(webhookUrl, options);
  }
}


function inputValue() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const sheet = ss.getSheetByName('');

  //get newst 10 titles from website
  var newest = getHTMLValue();

  //get last 10 data
  var lasts = getValues_from_Spreadsheet()[0];

  //set output array
  var outputs = [];
  var msg = "";

  if (newest.toString() == lasts.toString()) {
    console.log("Nothing was changed. Keep past 10 titles.");
    return null;
  } else {
    outputs = [newest];
    sheet.getRange(1, 1, 1, 10).setValues(outputs);
    console.log("New title seems like added");
    //compare how many titles updated here
    var newTitles = getUpdatedData([lasts], [newest]);
    var howMany = newTitles.length;
    msg = "@here\n=======================\n登竜門に新たなコンペが" + Math.floor(howMany) + "つ掲載されたようです。\n\n";
    for (var i = 0; i < howMany; i++) {
      msg += "■" + newTitles[i] + "\n";
    }
    msg += "-----------------------\nチェックはこちらから↓\n  https://compe.japandesign.ne.jp/\n=======================";
    Logger.log(msg);
    return msg;
  }
}


function getUpdatedData(arrayA, arrayB) {
  var updatedData = [];
  
  for (var i = 0; i < arrayB[0].length; i++) {
    var title = arrayB[0][i];
    if (arrayA[0].indexOf(title) === -1) {
      updatedData.push(title);
    }
  }
  return updatedData;
}

function getHTMLValue() {
  let response = UrlFetchApp.fetch("https://compe.japandesign.ne.jp/");
  let content = response.getContentText("utf-8");
  var all_text = Parser.data(content).from('<h3>').to('</h3>').iterate();
  latest_10 = all_text.slice(0, 10);
  return latest_10;
}

function getValues_from_Spreadsheet() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const sheet = ss.getSheetByName('sheet1');

  //select A1~J1 cells
  var range = sheet.getRange('A1:J1');
  var values = range.getValues();

  return values;
}

最後に

スクレイピングってPythonでやることが多いと思うんですけど、GASでできると知ったときは驚きました。
自分の手が関与してない部分で自動で何かしてくれるって面白いですよね。

個人的には初学者でも簡単にbotが作れて嬉しかったですし、Zennで記事書くのも振り返りになって面白かったです。
今回は稚拙な解説になってしまいましたが、コードを適当に書いてることの弊害や自分なりの改善点・反省点が分かって成長に繋がりそうです。
今後も何か作ったらまた記事書くかもしれません。

あとchatGPTすごすぎる....


一部注意点と参考文献

スクレイピングの危険性と規約について

度々トラブルになるスクレイピングやクローラーですが、許可されているサイトかつ負担をかけない回数であれば大丈夫です。

今回の記事でスクレイピングしている登竜門というサイトはスクレイピングが許可されているため問題ありません。
試したいサイトで許可されているか確認するには「indexページ/robots.txt」で見てみましょう。
詳しくはググってください

参考文献
  1. Udemyメディア -【Google Apps Script入門】GASでできることや連携できるツール、活用事例を解説!リンク
  2. MDN Web Docs 用語集: ウェブ関連用語の定義 - Parse (解析)リンク
  3. Discord - Developerリファレンスリンク
  4. GAS - リファレンスリンク

追記(2023/8/4)

バグだらけだったので修正しました

const WEBHOOK_URL = "https://discord.com/api/webhooks/xxxxxxxxxxxx";
const URL = "https://docs.google.com/spreadsheets/xxxxxxxxxxxxx";
const USER_NAME = 'コンペ情報お知らせbot'

function sendToDiscord() {
  // Discord Webhook URL
  const webhookUrl = WEBHOOK_URL;
  var msg = inputValue();
  // メッセージの作成
  if (msg) {
    const message = {
      content: msg
    };
    // POST リクエストのオプション設定
    const options = {
      method: "post",
      contentType: "application/json",
      payload: JSON.stringify(message)
    };
    // POST リクエストの送信
    UrlFetchApp.fetch(webhookUrl, options);
  }
}


function inputValue() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const sheet = ss.getSheetByName('sheet1');

  //get newst 10 titles from website
  var newest = getHTMLValue();

  //get last 10 data
  var lasts = getValues_from_Spreadsheet()[0];

  //set output array
  var outputs = [];
  var msg = "";

  if (newest.toString() == lasts.toString()) {
    Logger.log("Nothing was changed. Keep past 10 titles.");
    return null;
  } else {
    var cnt = 0;
    for (let i = 0; i < newest.length; i++) {
      if ( !lasts.includes(newest[i]) ) {
        cnt = cnt + 1;
      }
    }
    Logger.log(cnt)
    if (cnt == 10) {
      msg = "@here\n=======================\n登竜門に新たなコンペが" + Math.floor(newest.length) + "つ掲載されたようです。\n\n";
      for (var i = 0; i < newest.length; i++) {
        msg += "■" + newest[i] + "\n";
      }
      msg += "-----------------------\nチェックはこちらから↓\n  https://compe.japandesign.ne.jp/\n=======================";
      Logger.log(msg);
      return msg;
    } else {
        outputs = [newest];
        Logger.log(outputs);
        sheet.getRange(1, 1, 1, newest.length).setValues(outputs);
        Logger.log("New title seems like added");
        //compare how many titles updated here
        var newTitles = getUpdatedData([lasts], [newest]);
        var howMany = newTitles.length;
        if (howMany == 0) {
          Logger.log("Nothing added");
          return null;
        } else {
          msg = "@here\n=======================\n登竜門に新たなコンペが" + Math.floor(howMany) + "つ掲載されたようです。\n\n";
          for (var i = 0; i < howMany; i++) {
            msg += "■" + newTitles[i] + "\n";
          }
          msg += "-----------------------\nチェックはこちらから↓\n  https://compe.japandesign.ne.jp/\n=======================";
          Logger.log(msg);
          return msg;
        }
    }
  }
}

function getUpdatedData(arrayA, arrayB) {
  var updatedData = [];
  
  for (var i = 0; i < arrayB[0].length; i++) {
    var title = arrayB[0][i];
    if (arrayA[0].indexOf(title) === -1) {
      updatedData.push(title);
    }
  }
  return updatedData;
}

function getHTMLValue() {
  let response = UrlFetchApp.fetch("https://compe.japandesign.ne.jp/");
  let content = response.getContentText("utf-8");
  var all_text = Parser.data(content).from('<h3>').to('</h3>').iterate();
  latest_10 = all_text.slice(0, 10);
  return latest_10;
}

function getValues_from_Spreadsheet() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const sheet = ss.getSheetByName('sheet1');

  //select A1~J1 cells
  var range = sheet.getRange('A1:J1');
  var values = range.getValues();

  // Logger.log(values);
  return values;
}

Discussion