この記事は、SLP KBIT AdventCalendar 2023 7日目の記事となるはずのものです。
はじめに
こんにちは、na_11です。
高専から編入してきて、単位だとか課題だとかに追われたと思ったらもう11月のようです。時の流れは早いですね。
今回は、昔から抱えていた「定時に何か処理をしたい、だけど持っているPCは起動しているか分からないのでそれに頼りたくない」な状況を解決するかもしれないGASを使ってみる記事です。
概要
Google Apps Scriptを使用して、20:00とかの定時にDiscordへメッセージを投げるBotを作成します。なんとサーバ不要です。
凡庸なメッセージを投稿してもつまらないので、「ボ」「ー」「・」をランダムな順番で並べたものを投稿します。
Google Apps Script?
略してGAS。Googleのサーバーを使って任意のタイミングでJavaScriptなプログラムを走らせることのできるサービスです。
毎週日曜日の0~1時の間に1+1の演算を無意味に行っても良いですし、毎日20~21時の間に「ボーボボボボー・ボボ」とDiscordに投稿してもOK、Googleカレンダーが更新されたら通知を...も可能です。
常にプログラムを走らせるといったことはできません。何なら動作時間等で上限が設けられているため、うっかり長ったらしい処理を書かないようにしましょう。さもなければ(一見)何もしていないのに動かなくなります。
作る
GASしていきます。
まずはシンプルなものを
まずは何かシンプルなものを作ります。GAS公式サイトを開き、左上の「新しいプロジェクト」を押してコーディングを初めます。
ひとまず、左上の「無題のプロジェクト」は分かりやすい名前に変えておきましょう(画像ではtestとしました)。
myfunctionは別に必須ではありませんが、どこかにmain関数的な存在は必要です。Pythonのようにいきなり処理を書き始めることはできません。
GASはJavaScriptの感覚で色々な処理を書くことができます(と言うよりも確かJavaScript)。ということで、次のように書いてみます。
function main() {
console.log(1+1)
}
Ctrl+Sで保存し、(ここではfunction main
としたため)main関数を選んで実行します。
実行ログが開かれて...
良さそうです。
GASでは好きな関数をエントリーポイントとして実行させることができます。
逆にコード1行目から実行させることはできません。だからmain関数的関数が必要なんですね。
保存はこまめに行いましょう。
と言うより、保存しないと変更内容が実行結果などに反映されない気がします。「直ってない!? なんで!?」「保存してなかった」がないように心がけたいものです。
Discordに投稿する
Webhookを使って投稿します。そのためにはWebhook URLが必要です。
Discordを開き、投稿したいチャンネルの設定画面を開き、連携中サービス→ウェブフックと進みます。後は道なりに進んでWebhook URLを手に入れます。
次に、投稿するための関数を作り、これを使って投稿してみましょう。
function post_discord(name, content, url){
const payload = {
username: name,
content: content,
};
UrlFetchApp.fetch(url,{
method: "post",
contentType: "application/json",
payload: JSON.stringify(payload),
})
}
nameには表示させたいユーザー名を、contentには本文を渡します。urlはWebhook URLです。
さっきのmain関数で使うだけなので答えは書かなくてもいいと思いました。
実行すると権限を渡すか脅されますが、証明書の切れたhttpsサイトを開く時のような手順で承認します。
できました。
ちなみに、Discordではいつの間にかMarkdown記法が使えるようになったみたいです。
本題
では本題、ボボボーボ・ボーボボボットを作成していきます。
ボボボーボ・ボーボボボットの仕様の確認をしましょう。ボボボーボ・ボーボボボットは「ボ」と「ー」と「・」をランダムな順番で並べ、20:00に投稿します。
配列内の要素をランダムに並び替えて文字列にして返すreturn_all_element_random
関数を定義しておきました。
const WEBHOOK_URL = "[WebhookのURLをここに入れる]"
function main() {
result = return_all_element_random([..."ボボボーボ・ボーボボ"])
console.log(result)
post_discord(
"ボボボーボ・ボーボボボット",
result,
WEBHOOK_URL,
)
}
function return_all_element_random(array){
result = ""
do{
result += array.splice(Math.floor(Math.random()*array.length),1)[0]
}while(array.length!=0)
return result
}
function post_discord(name, content, url){
const payload = {
username: name,
content: content,
};
UrlFetchApp.fetch(url,{
method: "post",
contentType: "application/json",
payload: JSON.stringify(payload),
})
}
ボボボーボ・ボーボボボットは正常に作動し、ボボボーボ・ボーボボからボーー・ボボボボボボが生成されたことが分かります。
実行トリガーを設定する
「毎日20:00実行」とかができない
後はボボボーボ・ボーボボボットを自動で20:00に動かすのみです。
自動でコードを動かすには、トリガーを設定します。
左のメニューバー(?)にカーソルをホバーし、目覚まし時計アイコンの「トリガー」をクリックします。
トリガーを追加、時間主導型、日付ベースのタイマー、時刻を選択...
あれっ?
かなりファジーです。これでは20:00にボボボーボ・ボーボボボットを動かしてボボボーボ・ボーボボできません。これでは日によって20:32にボボボーボ・ボーボボされてしまったり、20:59にボボボーボ・ボーボボされても文句は言えない状況です。
特定の日時ベースであれば分単位まで指定できますが、代わりに日付まで手動入力しなくてはならなくなります。
コードで日時ベーストリガーを自動追加する
しかし、抜け穴があります。
コードでトリガーが追加・削除できるのです。
古いトリガーを削除&20:00に指定した新しいトリガーをセットする関数を作り、これを先程のファジーな日付ベーストリガーを使って毎日18:00くらいに実行させます。
すると、18:00~19:00にトリガー追加関数が起動し、ボボボーボ・ボーボボボット実行のトリガーをセット、20:00にボボボーボ・ボーボボボットが起動してボボボーボ・ボーボボが行われるようになります。
function add_trigger(){
delete_trigger();
console.log("Trigger Creating");
let date=new Date();
date.setDate(date.getDate());
date.setHours(20);
date.setMinutes(0);
date.setSeconds(0);
let trigger = ScriptApp.newTrigger("[ボボボーボ・ボーボボボットメイン関数名]")
.timeBased()
.at(date)
.create();
console.log(date);
Logger.log("Trigger Process Ended");
}
function delete_trigger() {
Logger.log("Trigger Deleting");
let allTriggers = ScriptApp.getProjectTriggers();
console.log(" found: " + allTriggers.length + " trigger(s)")
for (var i = 0; i < allTriggers.length; i++) {
if (allTriggers[i].getHandlerFunction() == "[ボボボーボ・ボーボボボットメイン関数名]") {
ScriptApp.deleteTrigger(allTriggers[i]);
console.log("Deleted: "+allTriggers[i]);
}
}
}
(約3, 4年前にどこかのサイトから持ってきたものを増改築した記憶があるのですが、どこのサイトか出せませんでした...申し訳ない...)
では、このadd_trigger
を18:00~19:00に実行するようにトリガーを設定して...(また権限を聞かれます)
20:00ピッタリです!
指定どうり、ボボボーボ・ボーボボボットを20:00に起動するトリガー追加が18:28に行われ、20:00にボボボーボ・ボーボボボットが起動、ボボーボボボボーボ・が出力されました。
次の日の18:00~19:00に、発動済みのボボボーボ・ボーボボボットを20:00に起動するトリガーは削除され、新しいトリガーが登録されるはずです。多分。
とにかく、これで目的を達成できました。
おわりに
GASを使用して、定時にDiscordにメッセージを投げるスクリプトを作成しました。
GASはGoogle製というのもあり、GoogleカレンダーやGoogleスプレッドシートと連携が簡単にできます。
頑張ればDiscord等で「今週の予定一覧」を投稿したり、スプレッドシートに溜め込んだ過去の実行結果データを元に処理を行うこともできるでしょう。
過去の実行データを使う例
過去のボボボーボ・ボーボボボットの結果を記録し、重複が起こった場合はその回数や最終重複日時を表示します。
スプレッドシートにはボボボーボ・ボーボボボットの結果や実行日時が記録されています。
ぜひ何か試してみてはいかがでしょうか?
ちなみに、ボーボボアニメは確か第13話が一番おすすめです。あまりにカオスで寒気がして風邪をひきかけました。
ふろく
なんとなく上記の「過去の実行データを使う例」のコードを配布します。
設計変更が重なった結果スパゲティになっていますが、GASコードをコピペし、Webhook URLとGoogleスプレッドシートのIDを定義すれば動くはずです。
スプレッドシートIDはGoogleドライブで適当にシートを作成して開き、URLを見ることで入手できます(https://docs.google.com/spreadsheets/d/XXXXXXXXXX/edit#gid=0
のXXXXXXXXXXがID)
煮るなり焼くなり
20:00と0:00に投稿。
生成後にObserverが生成分とボボボーボ・ボーボボ間の編集距離と、重複有無を調べ表示。
スプレッドシートは[生成した文字列, DBスキームver, 生成日時, 新種なら1さもなくば0]
のスキーム。
const WEBHOOK_URL = "[Webhook URL]"
const SHEET_ID = "[GoogleスプレッドシートID]"
const DEBUG = false // trueにすると実行結果をスプレッドシートへ保存しない
const CONBINATION_COUNT = 168
function main() {
bobo = return_all_element_random([..."ボボボーボ・ボーボボ"])
console.log(bobo)
SendDiscord(bobo)
let name = bobo.slice(bobo.length-4, bobo.length)
let simil = similarity(bobo, "ボボボーボ・ボーボボ")
let checkResult = dup_check_add_sheat(bobo)
console.log(checkResult)
string = build_advanced_message(
"それでは、今回の"+ name +"の結果を見ていきましょう",
simil,
build_dupMes(checkResult, name),
checkResult[4],
checkResult[1],
build_footer(simil)
)
console.log(string)
SendDiscordAdvanced(string)
}
function return_all_element_random(array){
result = ""
do{
result += array.splice(Math.floor(Math.random()*array.length),1)[0]
}while(array.length!=0)
return result
}
function return_element_random(array){
return array[Math.floor(Math.random()*array.length)]
}
function str_accuracy(str, correct){
let min = minValue(str.length, correct.length)
let max = maxValue(str.length, correct.length)
let count = 0
for (let i=0; i<min; i++){
if (str[i] == correct[i]){
count++
}
}
console.log("Hit:" + count + " max: " + max)
return count*1.0 / max
}
function str_accuracy_compare_figure(str,correct){
if (str.length != correct.length) {
return ""
}
result = ""
for (let i=0; i<str.length; i++){
if (str[i] == correct[i]){
result += ":white_check_mark:"
} else {
result += ":x:"
}
}
return result
}
function build_advanced_message(
desc,
simil,
duplMess,
newCount,
allDatasNum,
footer
){
return `{
"username": "Bobobo-boBo-boboObserver",
"avatar_url": "https://dic.nicovideo.jp/oekaki/152101.png",
"embeds": [{
"description": "` + desc +`",
"fields": [
{
"name": "類似度",
"value": "` + simil * 100 +`%"
},
{
"name": "",
"value": ""
},
{
"name": "重複",
"value": "`+duplMess[0]+`",
"inline": true
},
{
"name": "最終重複",
"value": "`+duplMess[1]+`",
"inline": "true"
},
{
"name": "",
"value": ""
},
{
"name": "コンプ率",
"value": "`+newCount+`/`+CONBINATION_COUNT+` (`+(newCount/CONBINATION_COUNT*100).toFixed(3)+`%)",
"inline": true
},
{
"name": "全データ数",
"value": "`+allDatasNum+`",
"inline": true
}
],
"color": 16776456,
"footer": {
"text": "` + footer + `",
"icon_url": "https://dic.nicovideo.jp/oekaki/152101.png"
}
}
]}
`
}
function build_footer(acc){
if (acc == 1.0){
return "みんなありがとう"
}
return return_element_random([
"フン",
"神に感謝",
"クッ ボーボボに負けた...",
"順当な結果ですね"
])
}
function build_dupMes(checkData,str){
if (checkData[0]==0){
return ["なんと新種"+str+"のようです!","-"]
}
return [""+checkData[0]+"回目",""+ date_get_y_m_d_string(checkData[3]) +" ("+checkData[2]+"日前)"]
}
function date_get_y_m_d_string(date){
return ""+date.getFullYear()+"/"+(date.getMonth()+1)+"/"+date.getDate()
}
function utc_to_jst(date){
time.setHours(time.getHours() + 9)
return time
}
function dup_check_add_sheat(str){
//strには生成したボーボボ文字列が来ることが想定される
let sheet = SpreadsheetApp.openById(SHEET_ID).getActiveSheet()
let table = sheet.getDataRange().getValues()
let col = table.length
console.log("data length: " + col)
let hitCount = 0
let lastHit = new Date()
let newCount = 0
for (let i=0; i<col; i++){
if (str == table[i][0]){
hitCount++
lastHit = new Date(table[i][2])
}
//scheme ver >= 2, new == 1
if (table[i][1]>=2 &&table[i][3] == 1){
newCount++
}
}
if (!DEBUG){
sheet.appendRow([
str,
2, //scheme ver
new Date().toISOString(),
hitCount==0? 1 : 0
])
col++
if (hitCount==0){newCount++}
} else {console.log("Debug is on. result not saved")}
let lastHitDif = Math.floor((new Date() - lastHit)/86400000)
//ヒット数, 全データ数, 最終ヒットからの日数, 最終ヒットDate型, 確認新種数
return [hitCount, col, lastHitDif, lastHit, newCount]
}
function levenshtein(s, t){
if (s.length == 0){
return t.length
}
if (t.length == 0){
return s.length
}
if (s[0] == t[0]){
return levenshtein(s.slice(1,s.length),t.slice(1,t.length))
}
let l1 = levenshtein(s, t.slice(1, t.length))
let l2 = levenshtein(s.slice(1,s.length), t)
let l3 = levenshtein(s.slice(1,s.length), t.slice(1, t.length))
if (l1 > l2) { l1 = l2 }
if (l1 > l3) { l1 = l3 }
return l1 + 1
}
function normalizedLevenshtein(s, t){
let maxLen = s.length
if (t.length > maxLen){ maxLen = t.length}
return levenshtein(s, t) / maxLen
}
function similarity(s, t){
return -normalizedLevenshtein(s, t) + 1
}
function SendDiscord(string) {
const payload = {
username: "ボボボーボ・ボーボボボット 🕶",
content: string,
};
UrlFetchApp.fetch(WEBHOOK_URL, {
method: "post",
contentType: "application/json",
payload: JSON.stringify(payload),
});
}
function SendDiscordAdvanced(payload){
UrlFetchApp.fetch(WEBHOOK_URL, {
method: "post",
contentType: "application/json",
payload: payload,
});
}
function minValue(a, b){
if (a < b){
return a
}
return b
}
function maxValue(a,b){
if (a > b){
return a
}
return b
}
function add_trigger(){
delete_trigger();
console.log("Trigger Creating");
let date=new Date();
date.setDate(date.getDate());
date.setHours(20);
date.setMinutes(0);
date.setSeconds(0);
let trigger = ScriptApp.newTrigger("main")
.timeBased()
.at(date)
.create();
console.log(date);
date.setDate(date.getDate()+1);
date.setHours(0);
date.setMinutes(0);
date.setSeconds(0);
trigger = ScriptApp.newTrigger("main")
.timeBased()
.at(date)
.create();
console.log(date);
Logger.log("Trigger Process Ended");
}
function delete_trigger() {
Logger.log("Trigger Deleting");
let allTriggers = ScriptApp.getProjectTriggers();
console.log(" found: " + allTriggers.length + " trigger(s)")
for (var i = 0; i < allTriggers.length; i++) {
if (allTriggers[i].getHandlerFunction() == "main") {
ScriptApp.deleteTrigger(allTriggers[i]);
console.log("Deleted: "+allTriggers[i]);
}
}
}