Hiratake Web ロゴ

GASを使って小説家になろうの小説が更新されたらDiscordに通知する

投稿した日
更新した日
書いたひと
icon
ひらたけ

小説投稿サイトの「小説家になろう」に投稿された小説をよく読みます。長編の小説では、大変ありがたいことに定期的に新しいお話を執筆して更新くださっている作品が多くあり、それを楽しみに日々生きているわけなのですが 毎回ページを開いて更新されているかを確認するのも大変 なので、Google Apps Script を使って普段からよく利用する Discord に通知するようにしました。

小説家になろうには「なろう小説 API」という API が公開されており、こちらを使うことで小説の情報を取得できます。

2024 年 4 月現在では特に利用制限などはないようですが、 無料で利用させていただいているサービスのサーバに負荷をかけまくるのはやってはいけない ので、そのあたりは配慮しましょう(無料じゃなくても負荷かけまくるのはよくないですが)。

利用するもの

実装で利用するものは以下のとおりです。

  • なろう小説 API
  • Google Apps Script
  • Google スプレッドシート
  • Discord

流れとしては 「Google スプレッドシートに通知する対象の小説を入力」「Google Apps Script を使ってなろう小説 API から情報を取得」「ウェブフックを使って Discord へ通知」 となります。

スプレッドシートをつくる

Google スプレッドシートを作成し、更新通知を送信したい小説家になろうの小説のリストを入力します。今回の実装では「タイトル」「コード」「エピソード数」の列を作成し、それぞれ入力します。「コード」は、小説家になろうの小説の URL の https://ncode.syosetu.com/nXXXXXX/ の最後にある nXXXXXX の部分です。

更新通知を送信したい小説家になろうの小説のリストを入力したら、任意の名前で保存しておきます。また、入力したシートの名前を分かりやすいものに変更しておきます。今回は「リスト」という名前にしました。

小説家になろうの小説のリストを入力したスプレッドシートの画像

スプレッドシートの情報を取得する

Google Apps Script を記述して、スプレッドシートに入力した小説の情報をなろう小説 API から取得します。スプレッドシートの画面の上部にある「拡張機能」から「Apps Script」を選択して Google Apps Script の画面を開きます。

小説家になろうの小説のリストを入力したスプレッドシートの画像

左側のメニューから「コード」を選択するとプログラムを記述するエディタが表示されるので、ここに処理を書いていきます。

これはちょっと好みの話になってしまいますが、個人的にアロー関数式のほうがすきなので、今回は元々入力されている内容は全て消して、以下のように main 関数を新たに記述します。

const main = () => {
  /** スプレッドシート */
  const spreadsheet = SpreadsheetApp.getActive()
  /** リストシート */
  const sheet = spreadsheet.getSheetByName('リスト')
  /** 取得するデータの範囲(2行目以降) */
  const range = sheet.getRange(2, 1, sheet.getLastRow() - 1, 3)
  /** リストシートのデータ */
  const data = range.getValues().map((item) => ({
    /** タイトル */
    title: item[0] || '',
    /** Nコード */
    ncode: (item[1] || '').toLowerCase(),
    /** エピソード数 */
    latest: Number(item[2] || 0),
  }))
}

ざっくりとした説明ですが、スプレッドシートから「リスト」という名前のシートを取得し、そこから 2 行目以降(1 行目は列の見出しを入れているため)の入力内容を取得しています。

その後、取得したデータを後の処理で扱いやすくするために、行ごとに titlencodelatest のキーを持つオブジェクトに変換しています。

小説の情報を取得する

スプレッドシートから情報を取得することができたら、続いて「なろう小説 API」から情報を取得する関数をつくります。

先ほど作成した main 関数の外側に、以下のような getNarouInfo 関数をつくります。

/**
 * なろう小説APIから情報を取得する
 * @param ncodes Nコードの配列
 * @returns 小説情報の配列
 */
const getNarouInfo = (ncodes = []) => {
  /** APIのベースURL */
  const base = 'https://api.syosetu.com/novelapi/api/'
  /** 出力形式 */
  const output = 'json'
  /** 取得する項目 */
  const key = [
    'n',  // Nコード
    's',  // 作品のあらすじ
    'ga', // エピソード数
  ].join('-')
  /** 取得する件数 */
  const limit = ncodes.length
  /** Nコード */
  const ncode = ncodes.join('-')

  try {
    const response = UrlFetchApp
      .fetch(`${base}?out=${output}&of=${key}&lim=${limit}&ncode=${ncode}`)
    const data = JSON.parse(response.getContentText())
      .filter((item) => item.ncode)
      .map((item) => ({
        ncode: item.ncode.toLowerCase(),
        description: item.story.substring(0, 100),
        latest: Number(item.general_all_no),
      }))
    return data
  } catch (err) {
    Logger.log(err.toString())
  }
}

この関数は、['nxxxxxx', 'nxxxxxx', 'nxxxxxx'] のような小説のコード(N コード)の配列を渡すと、その配列に含まれたコードの小説の情報をなろう小説 API から取得するものになります。

関数の中で、baseoutputkeylimitncode 変数を定義しています。

  • base - API の URL。
  • output - API の out パラメータに渡す値。Google Apps Script で扱いやすいよう JSON 形式を指定。
  • key - API の of パラメータに渡す値。転送量を減らすため、不要なデータは取得しないようにここで指定します。複数の項目を取得する場合は、ハイフン(-)で区切ります。
    • n - N コード
    • s - 作品のあらすじ
    • ga - エピソード数
  • limit - API の lim パラメータに渡す、取得する小説の件数。ここでは引数として渡された配列の要素数を指定しています。
  • ncode - API の ncode パラメータに渡す、取得する小説の N コード。複数の小説を指定する場合はハイフン(-)で区切ります。

取得する小説ごとに API で情報を取得する方法もあるのですが、なるべくリクエスト数を減らしたほうが良いと思うので、1 度のリクエストで取得できるようにしています。他の情報も取得したいなど、詳細な API の利用方法については公式のドキュメントをご確認ください。

取得した情報は、後の処理で扱いやすくするために少し加工をしています。

getNarouInfo 関数ができたら、再び main 関数へと戻ります。シートのデータからコードのみの配列を生成して、先程作成した getNarouInfo 関数に渡します。

const main = () => {
  /** スプレッドシート */
  const spreadsheet = SpreadsheetApp.getActive()
  /** リストシート */
  const sheet = spreadsheet.getSheetByName('リスト')
  /** 取得するデータの範囲(2行目以降) */
  const range = sheet.getRange(2, 1, sheet.getLastRow() - 1, 3)
  /** リストシートのデータ */
  const data = range.getValues().map((item) => ({
    /** タイトル */
    title: item[0] || '',
    /** Nコード */
    ncode: (item[1] || '').toLowerCase(),
    /** エピソード数 */
    latest: Number(item[2] || 0),
  }))

  /** なろう小説APIから取得した情報 */
  const response = getNarouInfo(
    data.filter((item) => item.ncode).map((item) => item.ncode)
  )
}

これで、なろう小説 API からスプレッドシートに入力した小説の情報を取得することができました。

通知を送信する

続いて、Discord へ通知を送信する関数をつくります。ざっくりとは説明しますが、詳細な仕様については Discord のドキュメントをご確認ください。

main 関数の外側に、以下のような postDiscord 関数をつくります。

/**
 * Discordへ通知を送信する
 * @params novels 通知する小説の情報
 */
const postDiscord = (novels = []) => {
  /** 送信先URL */
  const webhookUrl = 'https://discord.com/api/webhooks/xxxxxxxxxxxxxxxxxxxx'
  const items = novels.reduce((prev, current) => {
    if (!prev.length || prev[prev.length - 1].length === 10) {
      return [...prev, [current]]
    } else {
      const [last, ...rest] = prev.reverse()
      return [...rest.reverse(), [...last, current]]
    }
  }, [])

  try {
    items.forEach((item) => {
      UrlFetchApp.fetch(webhookUrl, {
        'method': 'post',
        'contentType': 'application/json',
        'payload': JSON.stringify({
          content: '小説家になろうの作品に更新がありました。',
          embeds: item,
        })
      })
    })
  } catch (err) {
    Logger.log(err.toString())
  }
}

この関数は、Discord に送信するデータ(配列)を受け取って、ウェブフックを使って通知します。送信先の URL は通知先のものを webhookUrl に指定してください。

ウェブフックでは embeds に複数の埋め込み要素を指定できるのですが、これが最大 10 件となっているため、通知する小説が 10 件を超える場合には 10 件ごとに通知を送信するようにしています。

最後に、再び main 関数へ戻ります。API から取得した「エピソード数」が、スプレッドシートに入力されているものより多かった場合に「更新があった」と判断し通知します。

const main = () => {
  /** スプレッドシート */
  const spreadsheet = SpreadsheetApp.getActive()
  /** リストシート */
  const sheet = spreadsheet.getSheetByName('リスト')
  /** 取得するデータの範囲(2行目以降) */
  const range = sheet.getRange(2, 1, sheet.getLastRow() - 1, 3)
  /** リストシートのデータ */
  const data = range.getValues().map((item) => ({
    /** タイトル */
    title: item[0] || '',
    /** Nコード */
    ncode: (item[1] || '').toLowerCase(),
    /** エピソード数 */
    latest: Number(item[2] || 0),
  }))

  /** なろう小説APIから取得した情報 */
  const response = getNarouInfo(
    data.filter((item) => item.ncode).map((item) => item.ncode)
  )

  /** 通知する小説の情報 */
  const postItems = []

  data.forEach((item, index) => {
    try {
      const current = response.find((novel) => novel.ncode === item.ncode)
      // 見つからない場合はエラー
      if (!current) {
        throw new Error('小説が見つかりません')
      }
      if (Number(item.latest) < current.latest) {
        // スプレッドシートのエピソード数を更新
        sheet.getRange(`C${index + 2}`).setValue(current.latest)
        // 通知する項目として配列に追加
        postItems.push({
          title: item.title,
          description: current.description,
          url: `https://ncode.syosetu.com/${item.ncode}/`,
        })
      }
    } catch (err) {
      Logger.log(err.toString())
    }
  })

  // Discordへ通知を送信する
  postDiscord(postItems)
}

更新があった場合にスプレッドシートの値を書き換え、postItems に追加。最後に、postDiscord 関数に postItems を与えて実行します。

最終的なコード

最終的なコードは以下のとおりです。

const main = () => {
  /** スプレッドシート */
  const spreadsheet = SpreadsheetApp.getActive()
  /** リストシート */
  const sheet = spreadsheet.getSheetByName('リスト')
  /** 取得するデータの範囲(2行目以降) */
  const range = sheet.getRange(2, 1, sheet.getLastRow() - 1, 3)
  /** リストシートのデータ */
  const data = range.getValues().map((item) => ({
    /** タイトル */
    title: item[0] || '',
    /** Nコード */
    ncode: (item[1] || '').toLowerCase(),
    /** エピソード数 */
    latest: Number(item[2] || 0),
  }))

  /** なろう小説APIから取得した情報 */
  const response = getNarouInfo(
    data.filter((item) => item.ncode).map((item) => item.ncode)
  )

  /** 通知する小説の情報 */
  const postItems = []

  data.forEach((item, index) => {
    try {
      const current = response.find((novel) => novel.ncode === item.ncode)
      // 見つからない場合はエラー
      if (!current) {
        throw new Error('小説が見つかりません')
      }
      if (Number(item.latest) < current.latest) {
        // スプレッドシートのエピソード数を更新
        sheet.getRange(`C${index + 2}`).setValue(current.latest)
        // 通知する項目として配列に追加
        postItems.push({
          title: item.title,
          description: current.description,
          url: `https://ncode.syosetu.com/${item.ncode}/`,
        })
      }
    } catch (err) {
      Logger.log(err.toString())
    }
  })

  // Discordへ通知を送信する
  postDiscord(postItems)
}

/**
 * なろう小説APIから情報を取得する
 * @param ncodes Nコードの配列
 * @returns 小説情報の配列
 */
const getNarouInfo = (ncodes = []) => {
  /** APIのベースURL */
  const base = 'https://api.syosetu.com/novelapi/api/'
  /** 出力形式 */
  const output = 'json'
  /** 取得する項目 */
  const key = [
    'n',  // Nコード
    's',  // 作品のあらすじ
    'ga', // エピソード数
  ].join('-')
  /** 取得する件数 */
  const limit = ncodes.length
  /** Nコード */
  const ncode = ncodes.join('-')

  try {
    const response = UrlFetchApp
      .fetch(`${base}?out=${output}&of=${key}&lim=${limit}&ncode=${ncode}`)
    const data = JSON.parse(response.getContentText())
      .filter((item) => item.ncode)
      .map((item) => ({
        ncode: item.ncode.toLowerCase(),
        description: item.story.substring(0, 100),
        latest: Number(item.general_all_no),
      }))
    return data
  } catch (err) {
    Logger.log(err.toString())
  }
}

/**
 * Discordへ通知を送信する
 * @params novels 通知する小説の情報
 */
const postDiscord = (novels = []) => {
  /** 送信先URL */
  const webhookUrl = 'https://discord.com/api/webhooks/xxxxxxxxxxxxxxxxxxxx'
  const items = novels.reduce((prev, current) => {
    if (!prev.length || prev[prev.length - 1].length === 10) {
      return [...prev, [current]]
    } else {
      const [last, ...rest] = prev.reverse()
      return [...rest.reverse(), [...last, current]]
    }
  }, [])

  try {
    items.forEach((item) => {
      UrlFetchApp.fetch(webhookUrl, {
        'method': 'post',
        'contentType': 'application/json',
        'payload': JSON.stringify({
          content: '小説家になろうの作品に更新がありました。',
          embeds: item,
        })
      })
    })
  } catch (err) {
    Logger.log(err.toString())
  }
}

定期的に実行する

記述した Google Apps Script を毎回手動で実行するのは大変なので、自動で実行されるように設定します。

左側のメニューから「トリガー」を選択し、画面右下にある「トリガーを追加」から新しいトリガーを作成します。実行する関数には「main」、イベントのソースは「時間主導型」、あとは関数を実行する頻度を選択します。

最初の方にも書きましたが、サーバに負荷をかけまくるのはよくないので、1 時間に 1 回とか、1 日に 1 回とか、ほどほどな頻度にしておきましょう。


めっちゃ余談なんですけど、自分が読んでるもの大公開するのもちょっとアレだったので、ブログ書く用に仮で累計ランキングの 1 位から順にデータ入れてやっていたのですが、数年ぶりくらいに累計ランキング見ると結構変動してて驚きましたね…。

以上です。通知の内容などは適宜カスタマイズなどしていただければ。