回帰

自分の人生をひとつのドラマとして俯瞰したとき、かなり高い確率で、どこかで音楽に回帰するんじゃないかと思っています。

ただ、そのタイミングはわかりません。どんな形かもわかりません。なんとなく、子供たちに関連する文脈で戻ってくる気がしますが、それも確信じゃなく、ただの予感です。


人生、どの道を選ぶかに正解はないです。

そう考えると、究極的には全部どうでもいいという話になります。どの仕事を選ぶか、どこに住むか、何を作るか。どれが正解などないです。

であれば、「幼少時代〜青春期の原体験に帰結しておく」というのが、動機づけとして最も根っこがしっかりしているんじゃないかと。

もうこれでいいや、という感覚があります。


そのためには、10代までに出会ったキーパーソンに、もう一度会いに行くのがいいんじゃないか、と。

保育園、幼稚園、小学校、中学校、高校、大学。それぞれの時期に、自分の何かを形作った人たちがいるはずです。先生、友人、恋人、憧れの先輩、などなど。
当時はそれが「特別な出来事」だとは思っていなかったとしても、人の価値観の大部分は、人との関わりによって形成されています。

40半ばに立ち止まって、そういう人たちに会いに行くのに、意味がある気がしています。

それは単にノスタルジーを感じて「エモいね」「懐かしいね」それで終わり、という話ではなく、自分の原点を再確認する作業だと思っています。

iOS/macOS ボイスメモを「録音日・タイトル・録音時間」を保持したままファイル保存

チャッピーさんに書いてもらいました。最近Androidに変えたし、iCloudの容量追加サブスクを解約しようかな…と思ってきました。


はじめに

iOSやmacOS の「ボイスメモ」アプリ、便利ですが、録音が増えてくると iCloudやiPhoneストレージ容量を圧迫 してしまいます。

そこで、古い録音をMacOS内のFinderからGoogle Driveや外付けHDDにコピーしようとしても、ボイスメモアプリのファイルは、タイトルが保持されないという仕様。

20241120 123456.m4a
20241119 091234.m4a

これでは、どの録音が何の内容なのか、録音時間はどれくらいなのか、全く分かりません。

この記事では、この問題を解決する 録音日・タイトル・録音時間をファイル名へ自動付与するスクリプトを紹介します。

これにより、iCloud から解放しつつ、大量のボイスメモを効率的に整理・管理できるようになります。


こんなファイル名に変換できます

2025-11-20_会議メモ_15m30s.m4a
2025-11-19_アイデア_03m12s.m4a
2025-11-18_インタビュー_45m08s.m4a

できること:

  • 録音日が一目で分かる
  • タイトルで内容を識別できる
  • 録音時間(分秒)で長さが分かる
  • Finder での並び順が録音順に統一される
  • 元ファイルは保持され、安全に処理できる
  • Google Drive や外付けHDDに移行して iCloudやiPhoneストレージ容量を削減できる

動作環境:

  • macOS 15 (Sequoia) 以降で動作確認済み
  • それ以前のバージョンでも動作する可能性がありますが未検証

実践編:手順に従って実行

この章では、技術的な説明は省略し、手順だけを記載しています。

理解していなくても、このとおりに進めれば完了します。


ステップ1:ボイスメモデータをコピー

  1. Finder で「移動」メニューから `Option (⌥)` キーを押しながら「ライブラリ」を選択
  2. `Group Containers` → `group.com.apple.VoiceMemos.shared` → `Recordings` と進む
  3. `Recordings` フォルダをコピー
  4. デスクトップに `VoiceMemos_Work` フォルダを作成してペースト

重要: 元のデータは削除されません。


ステップ2:ターミナルを開く

ターミナルとは、Mac でコマンドを入力して操作するアプリです。

手順:

  1. Spotlight 検索を開く(`Command ⌘ + Space`)
  2. 「ターミナル」と入力
  3. 「ターミナル.app」をクリック

黒い(または白い)ウィンドウが開けば成功です。


ステップ3:スクリプトファイルを作成

手順:

  1. テキストエディットを開く(Spotlight で「テキストエディット」と検索)
  2. 新規ファイルを作成
  3. メニューから「フォーマット」→「標準テキストにする」を選択(重要!)
  4. 以下のスクリプトをすべてコピーしてテキストエディットに貼り付け
#!/bin/zsh

DB="$HOME/Desktop/VoiceMemos_Work/Recordings/CloudRecordings.db"
SRC_DIR="$HOME/Desktop/VoiceMemos_Work/Recordings"
OUT_DIR="$HOME/Desktop/VoiceMemos_Work/output"
mkdir -p "$OUT_DIR"

sqlite3 -separator "|" "$DB" '
SELECT
  ZPATH,
  ZCUSTOMLABEL,
  ZENCRYPTEDTITLE,
  CAST(ZDATE AS INTEGER) AS ZDATE
FROM ZCLOUDRECORDING;
' | while IFS="|" read -r ZPATH ZCUSTOMLABEL ZENCRYPTEDTITLE ZDATE; do

  [ -z "$ZPATH" ] && continue
  SRC_FILE="$SRC_DIR/$ZPATH"
  [ ! -f "$SRC_FILE" ] && continue

  EXT="${ZPATH##*.}"

  # タイトル決定(ISO形式は除外)
  RAW_TITLE="$ZENCRYPTEDTITLE"
  [[ -z "$RAW_TITLE" ]] && RAW_TITLE="$ZCUSTOMLABEL"
  if echo "$RAW_TITLE" | grep -Eq '^[0-9]{4}-[0-9]{2}-[0-9]{2}T'; then
    SAFE_TITLE="Untitled"
  else
    SAFE_TITLE=$(printf "%s" "$RAW_TITLE" | tr '/:' '_' | tr -d '\n\r')
    [ -z "$SAFE_TITLE" ] && SAFE_TITLE="Untitled"
  fi

  # 録音日
  EPOCH=$((ZDATE + 978307200))
  DATE=$(date -r "$EPOCH" +"%Y-%m-%d")

  # 録音時間(duration)
  DURATION_RAW=$(afinfo "$SRC_FILE" | awk '/estimated duration/ {print $3; exit}')
  if [ -z "$DURATION_RAW" ]; then
    DURATION_STR="00m00s"
  else
    SEC=$(printf "%.0f" "$DURATION_RAW")
    DURATION_STR=$(printf "%02dm%02ds" $((SEC/60)) $((SEC%60)))
  fi

  NEW_NAME="${DATE}_${SAFE_TITLE}_${DURATION_STR}.${EXT}"
  cp "$SRC_FILE" "$OUT_DIR/$NEW_NAME"

  # 作成日/更新日を録音日時に書き換え
  TOUCH_DATE=$(date -r "$EPOCH" +"%Y%m%d%H%M.%S")
  touch -t "$TOUCH_DATE" "$OUT_DIR/$NEW_NAME"

done
  1. ファイルを保存:
    • 保存場所:デスクトップの `VoiceMemos_Work` フォルダ
    • ファイル名:`rename.sh`
    • 「.txt を追加しない」にチェック(表示される場合)

ステップ4:スクリプトを実行

ターミナルに以下をコピー&ペーストして `Enter` を押してください:

cd ~/Desktop/VoiceMemos_Work && chmod +x rename.sh && ./rename.sh

copy

処理が始まります。ファイル数によっては数分かかる場合があります。


ステップ5:結果を確認

処理が完了すると、デスクトップの `VoiceMemos_Work/output` フォルダに、整理されたファイルが保存されています。

確認方法:

  1. デスクトップの `VoiceMemos_Work` フォルダを開く
  2. `output` フォルダを開く
  3. ファイル名が以下のように変わっているはずです:
2025-11-15_会議メモ_15m30s.m4a
2025-11-16_アイデア_03m12s.m4a
2025-11-18_インタビュー_45m08s.m4a

これで完了です!

整理されたファイルを Google Drive や外付けHDDに移動できます。



解説編:技術的な背景を理解する

ここからは、スクリプトの仕組みを理解したい方向けの解説です。

実行だけが目的の方は読む必要はありません。


処理の仕組み

データの取得元

このスクリプトは、ボイスメモアプリが内部で使用している SQLite データベースから情報を取得します。

CloudRecordings.db の主要カラム:

  • `ZENCRYPTEDTITLE`:録音タイトル(ユーザーが設定したタイトル)
  • `ZCUSTOMLABEL`:カスタムラベル(タイトルが未設定の場合の代替)
  • `ZDATE`:録音日時(macOS 独自の epoch 形式)
  • `ZPATH`:実際のファイルパス

処理の流れ

  1. データベースクエリ:SQLite から録音情報を取得
  2. タイトル処理:
    • ISO 形式の日付(`2025-11-20T12:34:56`)は `Untitled` に置換
    • ファイル名に使えない文字(`/`, `:`)をアンダースコアに変換
  3. 日時変換:macOS epoch(2001年1月1日基準)を Unix epoch に変換
  4. 録音時間取得:`afinfo` コマンドで M4A ファイルの実際の長さを取得
  5. ファイル生成:新しいファイル名でコピーを作成
  6. メタデータ更新:ファイルの作成日・更新日を録音日時に書き換え

技術的なポイント

macOS Epoch の変換

EPOCH=$((ZDATE + 978307200))

macOS の Core Data は 2001年1月1日 00:00:00 UTC を基準とする独自の epoch を使用しています。

Unix epoch(1970年1月1日)との差分は `978307200` 秒です。

この値を加算することで Unix timestamp に変換しています。

afinfo コマンド

afinfo "$SRC_FILE" | awk '/estimated duration/ {print $3; exit}'

`afinfo` は macOS 標準のオーディオファイル情報取得コマンドです。

`estimated duration` 行から録音時間(秒数)を抽出し、この値を分秒形式(`15m30s`)に変換しています。

ファイル名の安全化

SAFE_TITLE=$(printf "%s" "$RAW_TITLE" | tr '/:' '_' | tr -d '\n\r')

`/` と `:` はファイル名に使用できないため `_` に置換し、改行文字(`\n`, `\r`)を削除しています。


トラブルシューティング

データベースが見つからない

症状: スクリプトが動作しない、エラーが出る

解決策:

  • ステップ1でボイスメモデータが正しくコピーされているか確認
  • `VoiceMemos_Work/Recordings` フォルダ内に `CloudRecordings.db` ファイルがあるか確認

afinfo コマンドが見つからない

症状: `command not found: afinfo`

解決策:

`afinfo` は macOS 標準コマンドです。Xcode Command Line Tools がインストールされているか確認してください:

xcode-select --install

ファイルが出力されない

解決策:

  • ボイスメモアプリでタイトルを設定しているか確認
  • `Recordings` フォルダ内に `.m4a` ファイルがあるか確認

まとめ

このスクリプトを使うことで:

  • 録音日・タイトル・録音時間で自動リネーム
  • duration 付きで一覧性が大幅に向上
  • Finder の並び順が録音順に統一
  • 元ファイルを壊さず安全に処理
  • ファイルのメタデータも録音日時に統一

大量のボイスメモを効率的に整理・アーカイブできます。

応用例

iCloud ストレージ管理
整理したファイルを Google Drive に移行し、ボイスメモアプリから古い録音を削除して iCloud 容量を削減

バックアップ
定期的に実行して整理済みアーカイブを作成

クラウド同期
整理したファイルを Dropbox や Google Drive に保存

検索性向上
タイトルと日付で簡単に目的の録音を見つけられる


ぜひお試しください!

Google Apps ScriptでPodcastエピソードのメタ情報を自動取得

Podcastの編集を業務委託する OR される方が増えてきてます。
そんな中、ギャラの計算方法も「1月あたりいくら」「1本あたりいくら」「1分あたりいくら」など多様なパターンがあるんじゃないかと。

ちなみに僕は、受注時も発注時も分数計算でやることが多いんですが、この計算がめちゃくちゃ面倒だったので、自動計算してくれるアプリを作りました。

こんな感じになります。

画像

もしよかったら、記事の一番下から投げ銭できるんで、投げ銭ください。

では、ChatGPTくんによる説明をご覧ください。


こんにちは!
今回は、Google Apps Scriptを使ってPodcastのエピソード情報をRSSフィードから自動取得し、Googleスプレッドシートに自動的に記載する方法をご紹介します。

1. このスクリプトの特徴

・RSSフィードからデータ取得

RSSフィードに記載されたPodcastエピソードの情報(GUID、タイトル、公開日時、再生時間など)を取得します。

・期間指定によるフィルタリング

スクリプトプロパティに設定した開始日時(START_DATE)と終了日時(END_DATE)をもとに、対象期間内のエピソードだけをシートに反映します。

  • 両方未設定の場合: 全エピソード取得
  • 開始日時のみ設定: 指定日時以降のエピソード取得
  • 終了日時のみ設定: 指定日時以前のエピソード取得

・柔軟な秒数丸め設定

エピソードの再生時間(RSSで取得した形式)を分単位に計算する際、丸め方をスクリプトプロパティの ROUNDING_RULE で指定可能です。
設定できる丸めルールは以下の3種類:

  1. ルール1:30秒未満は切り捨て、30秒以上は切り上げ
    例:1分29秒 → 1分、1分36秒 → 2分
  2. ルール2:秒は常に切り捨て
    例:1分50秒 → 1分
  3. ルール3:秒が0でなければ切り上げ
    例:1分00秒 → 1分、1分01秒 → 2分

スクリプトプロパティに設定がなければ、デフォルトはルール1が適用されます。

・重複防止&視覚的な補助

既に取得済みのエピソードはGUIDで判定してスキップ。
また、偶数月のエピソード行には背景色を付け、視認性を向上させています。

2. スクリプトプロパティの設定

スクリプトエディタの「ファイル」→「プロパティ」→「スクリプトのプロパティ」で、以下のキーと値を設定してください。

  • RSS_URL:RSSフィードのURL
  • START_DATE(任意):例 “20250101”(開始日時)
  • END_DATE(任意):例 “20250131”(終了日時)
  • ROUNDING_RULE(任意):1, 2, または 3(設定しなければデフォルトは 1)

3. スプレッドシートの準備

シート名は 「EP LIST」 にしてください。
また、以下のような列構成がおすすめです。

  • A1セル:実行時の「更新日時」が記録される
  • 行2(ヘッダー行)
    • A列:GUID
    • B列:タイトル
    • C列:配信日(yyyy/MM/dd 形式)
    • D列:再生時間(分)(丸めルールに基づいて計算)
    • E列:元の再生時間(例:00:30:00)
  • 行3以降:各エピソードのデータが追加されます

4. 完成したコード全文

function findLastRow(sheet) {
  const range = sheet.getRange('A2:E');
  const values = range.getValues();
  
  for (let row = 0; row < values.length; row++) {
    if (values[row].every(cell => cell === '')) {
      return row + 1; // 空行が見つかったら、その一つ前の行を最終行として返す
    }
  }
  
  return values.length + 1; // 全て埋まっている場合は最終行を返す
}

function calculateDuration(parts, roundingMode) {
  let baseMinutes, seconds;
  if (parts.length === 3) {
    baseMinutes = parts[0] * 60 + parts[1];
    seconds = parts[2];
  } else if (parts.length === 2) {
    baseMinutes = parts[0];
    seconds = parts[1];
  } else {
    return 0;
  }
  
  switch (roundingMode) {
    case 1: // 30秒未満は切り捨て、30秒以上は切り上げ
      return seconds >= 30 ? baseMinutes + 1 : baseMinutes;
    case 2: // 秒は常に切り捨て
      return baseMinutes;
    case 3: // 秒が0でなければ切り上げ
      return seconds > 0 ? baseMinutes + 1 : baseMinutes;
    default:
      return seconds >= 30 ? baseMinutes + 1 : baseMinutes;
  }
}

function fetchAndUpdatePodcastData() {
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("EP LIST");
  if (!sheet) {
    Logger.log('シート「EP LIST」が見つかりません');
    return;
  }
  
  // スクリプトプロパティから各プロパティを取得
  const scriptProperties = PropertiesService.getScriptProperties();
  const url = scriptProperties.getProperty("RSS_URL");
  if (!url) {
    Logger.log("RSS_URLがスクリプトプロパティに設定されていません。");
    return;
  }
  
  // 開始日時と終了日時(例:"20250101"の形式)を取得
  const startDateStr = scriptProperties.getProperty("START_DATE");
  const endDateStr = scriptProperties.getProperty("END_DATE");
  let startDate = null;
  let endDate = null;
  
  if (startDateStr) {
    startDate = new Date(startDateStr.substr(0,4) + '-' + startDateStr.substr(4,2) + '-' + startDateStr.substr(6,2));
  }
  
  if (endDateStr) {
    endDate = new Date(endDateStr.substr(0,4) + '-' + endDateStr.substr(4,2) + '-' + endDateStr.substr(6,2));
  }
  
  // ROUNDING_RULE:1, 2, または 3(設定がなければデフォルトは1)
  const roundingRuleStr = scriptProperties.getProperty("ROUNDING_RULE");
  let roundingRule = 1;
  if (roundingRuleStr) {
    roundingRule = parseInt(roundingRuleStr, 10);
    if (isNaN(roundingRule) || roundingRule < 1 || roundingRule > 3) {
      roundingRule = 1;
    }
  }
  
  // A〜E列の最終行を見つける
  const lastRow = findLastRow(sheet);
  
  // スプレッドシートの既存のGUIDを取得(重複登録防止)
  let existingGUIDs = [];
  if (lastRow > 2) {
    existingGUIDs = sheet.getRange(3, 1, lastRow - 2).getValues().flat();
  }
  
  const response = UrlFetchApp.fetch(url);
  const xml = response.getContentText();
  const document = XmlService.parse(xml);
  const root = document.getRootElement();
  const ns = XmlService.getNamespace("itunes", "http://www.itunes.com/dtds/podcast-1.0.dtd");
  const items = root.getChild("channel").getChildren("item");
  
  let newData = [];
  
  items.forEach(item => {
    const guid = item.getChildText("guid");
    
    // 既存のGUIDがある場合はスキップ
    if (existingGUIDs.includes(guid)) return;
    
    const title = item.getChildText("title");
    const pubDateText = item.getChildText("pubDate");
    const pubDate = new Date(pubDateText);
    
    // 開始日時、終了日時によるフィルタリング
    if (startDate && pubDate < startDate) return;
    if (endDate && pubDate > endDate) return;
    
    const formattedDate = Utilities.formatDate(pubDate, Session.getScriptTimeZone(), "yyyy/MM/dd");
    
    const durationElement = item.getChild("duration", ns);
    let durationInMinutes = 0;
    let originalDuration = '';
    
    if (durationElement) {
      originalDuration = durationElement.getText();
      const parts = originalDuration.split(':').map(Number);
      durationInMinutes = calculateDuration(parts, roundingRule);
    }
    
    // 新しいデータを配列に追加
    newData.push([guid, title, formattedDate, durationInMinutes, originalDuration, pubDate]);
  });
  
  // 配信日時(pubDate)で昇順に並び替え
  newData.sort((a, b) => a[5] - b[5]);
  
  // 新しいデータが存在する場合のみシートに書き込む
  if (newData.length > 0) {
    const startRow = lastRow + 1;
    const range = sheet.getRange(startRow, 1, newData.length, 5);
    range.setValues(newData.map(row => [row[0], row[1], row[2], row[3], row[4]]));
    
    // 偶数月の行に背景色を付ける
    newData.forEach((row, index) => {
      const pubDate = new Date(row[5]);
      const month = pubDate.getMonth() + 1;
      if (month % 2 === 0) {
        const lastColumn = sheet.getLastColumn();
        sheet.getRange(startRow + index, 1, 1, lastColumn).setBackground('#A7C6ED');
      }
    });
  }
  
  // 最終更新日時をA1セルに記入
  const now = new Date();
  const formattedNow = Utilities.formatDate(now, Session.getScriptTimeZone(), "yyyy/MM/dd HH:mm:ss");
  sheet.getRange('A1').setValue('更新日時: ' + formattedNow);
}

5. おわりに

このスクリプトを利用することで、RSSフィードからPodcastのエピソードデータを自動で取得し、指定した期間・丸めルールに基づいてGoogleスプレッドシートに反映できます。

ぜひお試しください!ご不明な点や改善のアイディアがあれば、コメントでシェアしていただけると嬉しいです。

Podcast概要欄作成支援ツール「Spotify for Creators HTML Editor」【動画あり】

Podcastの概要欄が、なんか変な感じになって、デザイン的に美しくないまま放置しちゃってるって経験はあるあるだと思います。
ってことで、概要欄作成支援ツールを作ってみました。

Spotify for Creators HTML Editor

https://higuchi.world/s4c-html-editor

解説動画はこちら

https://youtube.com/watch?v=3hgdVcdg-YE%3Frel%3D0

今回の記事も、いつも通りChatGPT先生に全部書いてもらってます。
どうぞ!


  • 背景と課題
    • Spotifyの概要欄作成時に、意図しない改行やレイアウトの乱れが頻発
    • エピソード登録時に毎回余計な手間や混乱が発生
  • 開発の目的
    • 日々の概要欄作成作業をシンプルかつ効率的にするため
    • 誰でも直感的に使えるツールで作業負担を軽減する
  • 主な特徴
    • 3画面構成
      • ソースコードエリア:裏側のHTMLコードを確認・修正可能
      • リッチテキストエディター:見た目通りに文章を直感的に編集
      • プレビュー画面:Spotify上での表示をリアルタイムで確認
    • 双方向同期
      • どの画面で編集しても、全体に即時反映
    • フォーマット切替機能
      • 「with breaks」:見やすいコードレイアウト(改行あり)
      • 「no breaks」:不要な改行を除去したシンプルなHTML(コピー用)
    • ワンクリックコピー
      • 整形済みHTMLをすぐにコピー可能
    • テーマ切替機能
      • ライト/ダークモード対応で快適な作業環境
  • 使い方
    1. 既存の概要欄HTMLをエディターにコピー&ペースト
    2. ソースコード、リッチテキスト、プレビュー各エリアで編集しながら確認
    3. モード切替で、作業用とコピー用の最適なフォーマットを選択
    4. 編集完了後、ワンクリックでHTMLコードをコピーし、Spotifyに貼り付け
  • メリット
    • 作業効率が向上:直感的な編集で手間が大幅に削減
    • 美しいレイアウト:不要な改行やタグを自動除去して整った見た目
    • 時間節約:毎回の概要欄作成がシンプルに完了

「Spotify for Creators HTMLエディター」は、ポッドキャスターが概要欄作成をもっと楽に、そして美しく仕上げるためのツールです。
技術的な知識がなくても安心して使えるので、ぜひ一度試してみてください!

GASでWebhookを複数のエンドポイントに中継する方法

以前、こんな記事を書きました。

https://note.com/embed/notes/n7a855bbcc73c

なかなか便利に使っていたのですが、新たに「L Message」というLINE公式アカウントの拡張機能を導入したところ、LINE公式アカウントのWebhook URLが一つしか設定できないことが判明し、しばらくの間、Discord通知を諦めていました。

しかし、GASを使ったWebhook分岐用アプリを経由することで、両方の通知を同時に実現できたので、その方法をシェアします。
今回の記事も、おなじみのChatGPT先生に執筆してもらいました。


LINE公式アカウントを運用する際に、Webhookを利用して自動通知を行うのは便利ですが、WebhookのURLは一つしか設定できないため、複数のシステムに同時通知するのは一工夫必要です。

この記事では、Google Apps Script(GAS)を使って、LINEからのWebhookデータをDiscord通知用のGASアプリとL Messageのウェブアプリに転送する方法を解説します。


準備するもの

  • Googleアカウント
  • LINE公式アカウント(LINE DevelopersでWebhookを設定)
  • GASアプリ(Discord通知用)
  • L Messageアプリ

手順概要

  1. 中継用Google Apps Script(GAS)プロジェクトの作成
  2. GASスクリプトの記述とデプロイ
  3. LINE DevelopersでWebhook URLの設定
  4. テストと確認

1. 中継用Google Apps Scriptプロジェクトの作成

  1. Googleドライブにアクセスし、「新規」→「その他」→「Google Apps Script」を選択します。
  2. 以下のコードをコピーし、エディタに貼り付けます。

GASスクリプトのコード

var discordAppUrl = 'DISCORD_APP_URL';  // Discord通知用のGASアプリURL
var lMessageAppUrl = 'L_MESSAGE_APP_URL';  // L Message用のGASアプリURL

// LINEからのPOSTリクエストを処理する関数
function doPost(e) {
  try {
    var lineData = JSON.parse(e.postData.contents);  // LINEからのデータを取得

    // Discord通知用GASアプリとL Message用GASアプリに同じデータを送信
    sendToApp(discordAppUrl, lineData);
    sendToApp(lMessageAppUrl, lineData);

    return ContentService.createTextOutput(JSON.stringify({ "status": "success" }))
      .setMimeType(ContentService.MimeType.JSON);
  } catch (error) {
    return ContentService.createTextOutput(JSON.stringify({ 
      "status": "error", 
      "message": error.message 
    })).setMimeType(ContentService.MimeType.JSON);
  }
}

// GASアプリにデータを送信する関数
function sendToApp(url, data) {
  var options = {
    'method': 'post',
    'contentType': 'application/json',
    'payload': JSON.stringify(data)
  };

  try {
    var response = UrlFetchApp.fetch(url, options);
    Logger.log("Response from " + url + ": " + response.getContentText());
  } catch (error) {
    Logger.log("Error sending to " + url + ": " + error.message);
  }
}

2. スクリプトのデプロイ

  1. GASエディタで、右上の「デプロイ」ボタンをクリックし、「新しいデプロイ」を選択します。
  2. 「ウェブアプリ」を選択し、「次のユーザーとして実行」を「自分」に設定します。
  3. 「アクセスできるユーザー」を「全員(匿名ユーザーを含む)」に設定し、「デプロイ」をクリックします。
  4. 表示されたウェブアプリのURLをコピーします。

3. LINE DevelopersでWebhook URLを設定

  1. LINE Developersコンソールにアクセスし、該当するLINE公式アカウントのチャネルを選択します。
  2. 「Messaging API」タブを開きます。
  3. 「Webhook URL」に、上記でコピーした中継用GASアプリのURLを設定し、「Webhook利用」を有効にします。

4. テストと確認

  1. Discord通知用GASアプリとL MessageアプリのURLが正しく設定されていることを確認してください。
  2. LINE公式アカウントにメッセージを送信し、両方のGASアプリが正常に動作することを確認します。
  3. Google Apps Scriptの「ログ」を使い、エラーやレスポンスを確認して、問題がないかチェックします。

まとめ

この仕組みを使えば、LINEのWebhookを一度のリクエストで複数のGASアプリに転送することが可能になります。これにより、例えばDiscord通知アプリとL Messageのシステムに同時にデータを送信できるため、情報共有や管理が効率化されます。


注意点と改善案

  • データ加工の必要がある場合:各アプリに適したデータフォーマットで送信するため、sendToApp関数内で条件分岐を追加できます。
  • エラーハンドリング:各転送先への送信に失敗した場合は、GASのログを活用して原因を追究してください。

これで、LINEのWebhookを使った複数のGASアプリへのデータ転送ができるようになります。GASを活用すれば、コストをかけずに効率的な自動化が可能です。ぜひ試してみてください。

Podcastが更新されたらSpotifyのプレイリストに追加してTwitter(X)に告知ポストをする方法

最新のPodcastエピソードがSpotifyで公開されたら、Spotifyのプレイリストに自動的に追加し、Twitter(X)に告知ツイートするシステムをGoogle Apps Script(GAS)を使って作成します。

この記事は以下の記事を参考にさせていただきました。
Youパパさん、感謝!

https://note.com/embed/notes/n195564d8e562

そして、記事だけだとわかりにくいので、動画にしました。
わかりにくければ、こちらをご覧ください。

https://youtu.be/IC9ln4mLsjM

手順 1: 必要なアカウントとAPIキーの準備

まずは、以下のアカウントを準備します。

  1. Googleアカウント
  2. Spotify Developerアカウント
  3. Twitter (X) Developerアカウント

手順 2: Google Apps Scriptのセットアップ

  1. Googleスプレッドシートを作成
  2. Google Apps Scriptを開く
    • スプレッドシートを開いた状態で、上部メニューの「拡張機能」→「Apps Script」をクリックします。
  3. スクリプトエディタにコードを貼り付ける
    • 新しいプロジェクトを作成し、次に示すコードを貼り付けます。

手順 3: ライブラリの追加

このシステムを作成するためには、Google Apps Scriptに外部ライブラリを追加する必要があります。以下のライブラリを追加してください。

1. TwitterWebService ライブラリの追加

  1. スクリプトエディタの「ライブラリ」タブをクリックします。
  2. 「ライブラリを追加」をクリックし、以下のライブラリを検索して追加します。
    • TwitterWebService
      • スクリプトID: 1rgo8rXsxi1DxI_5Xgo_t3irTw1Y5cxl2mGSkbozKsSXf2E_KBBPC3xTF
    • OAuth1
      • スクリプトID: 1CXDCY5sqT9ph64fFwSzVtXnbjpSfWdRymafDrtIZ7Z_hwysTY7IIhi7s
    • OAuth2
      • スクリプトID: 1B7FSrk5Zi6L1rSxxTDgDEUsPzlukDsi4KGuTMorsTQHhGBzBkMun4iDF

手順 4: Spotify APIとTwitter APIの設定

SpotifyとTwitterのAPIキーとシークレットを取得します。

Spotify APIの登録手順

  1. Spotify Developerにアクセスし、アカウントを作成します。
  2. 新しいアプリを作成し、「クライアントID」と「クライアントシークレット」を取得します。
  3. アプリの「Redirect URIs」に次のURLを追加します。
https://script.google.com/macros/s/{{デプロイID}}/usercallback

Twitter (X) APIの登録手順

  1. Twitter Developerにアクセスし、アプリケーションを作成します。
  2. 「APIキー」と「APIシークレットキー」を取得します。
  3. TwitterアプリのUser authentication settingsの「Callback URI / Redirect URL」には次のURLを追加します。
https://script.google.com/macros/d/{{スクリプトID}}/usercallback

手順 5. スクリプトプロパティの設定

スクリプトプロパティは、APIキーやIDを保管するためのものです。

  1. スクリプトエディタの「ファイル」→「プロジェクトのプロパティ」→「スクリプトプロパティ」を開きます。
  2. 以下の項目を設定します。
    • DEPLOY_ID:デプロイID
    • NOTIFICATION_EMAIL:通知するメールアドレス
    • SHEET_NAME:スプレッドシート内のシート名
    • SPOTIFY_CLIENT_ID:Spotify APIページで表示されるCLIENT ID
    • SPOTIFY_CLIENT_SECRET:Spotify APIページで表示されるCLIENT SECRET
    • SPOTIFY_PODCAST_GUID:最新エピソードID「0」を入力
    • SPOTIFY_PODCAST_ID:Spotifyの番組ID
    • SPREADSHEET_ID:Google SheetのID
    • TWITTER_ACCOUNT:Xのアカウント名
    • TWITTER_API_KEY:X APIで取得したAPI_KEY
    • TWITTER_API_SECRET_KEY:X APIで取得したAPI_SECRET_KEY
  3. スクリプトで一発で指定する(※2024.09.27 追記)
    • スクリプトプロパティーを関数で作成する方法がありました。以下のコードを貼り付けて、setLibraryProperties()を一度だけ実行してください。
      スクリプトプロパティーが設定されたのが確認できたら、関数ごと削除して大丈夫です。
function setScriptProperties() {
  const scriptProperties = PropertiesService.getScriptProperties();
  
  // スクリプトプロパティの設定
  const properties = {
    "SPREADSHEET_ID": "あなたのスプレッドシートID",
    "SHEET_NAME": "シート名",
    "DEPLOY_ID": "デプロイID",
    "NOTIFICATION_EMAIL": "あなたのメールアドレス",
    "SPOTIFY_CLIENT_ID": "あなたのSpotifyクライアントID",
    "SPOTIFY_CLIENT_SECRET": "あなたのSpotifyクライアントシークレット",
    "SPOTIFY_PODCAST_ID": "あなたのSpotifyポッドキャストID",
    "SPOTIFY_PLAYLIST_ID": "あなたのSpotifyプレイリストID",
    "TWITTER_ACCOUNT": "あなたのTwitterアカウント",
    "TWITTER_API_KEY": "あなたのTwitter APIキー",
    "TWITTER_API_SECRET_KEY": "あなたのTwitter APIシークレットキー"
  };

  // プロパティを一括で設定
  for (const [key, value] of Object.entries(properties)) {
    scriptProperties.setProperty(key, value);
  }

  Logger.log("スクリプトプロパティが設定されました。");
}

手順 6: Spotify APIとTwitter APIの認証

1. Spotify APIの認証

Spotifyの認証を行うためには、authorizeSpotify() 関数を実行します。

2. Twitter APIの認証

Twitterの認証を行うためには、authorizeTwitter() 関数を実行します。

おまけ: リセット機能の使い方

SpotifyまたはTwitterの認証やPodcast情報をリセットすることもできます。

1. Spotifyの認証をリセット

Spotifyの認証をリセットするには、resetSpotifyAuthorization() 関数を実行します。

2. Twitterの認証をリセット

Twitterの認証をリセットするには、resetTwitter() 関数を実行します。

3. SPOTIFY_PODCAST_GUID をリセットする

Spotifyで最新のPodcastエピソードを確認するために使用している SPOTIFY_PODCAST_GUID をリセットするには、resetSpotifyPodcastGuid() 関数を実行します。

手順 7: トリガーの設定

スクリプトを自動実行するためにトリガーを設定します。

  1. スクリプトエディタの「時計」アイコンをクリックして、新しいトリガーを作成します。
  2. checkPodcastRSS 関数を定期的に実行するための時間ベースのトリガーを設定します。

完成したコード

以下が最終的なコードです。これをGoogle Apps Scriptに貼り付けてください。

// ツイートのテンプレート
const tweetTemplate = "|NEW|番組タイトル\n\n" +
                      "≪{{PLAYLIST_TITLE}}≫\n" +
                      "{{EPISODE_TITLE}}\n\n" +
                      "聴く\n→{{EPISODE_URL}}\n\n" +
                      "プレイリスト\n→{{PLAYLIST_URL}}\n\n" +
                      "#ハッシュタグ";

// スクリプトプロパティの取得
const scriptProperties = PropertiesService.getScriptProperties();

const SPREADSHEET_ID = scriptProperties.getProperty("SPREADSHEET_ID");
const SHEET_NAME = scriptProperties.getProperty("SHEET_NAME");
const DEPLOY_ID = scriptProperties.getProperty("DEPLOY_ID");
const NOTIFICATION_EMAIL = scriptProperties.getProperty("NOTIFICATION_EMAIL");

const SPOTIFY_CLIENT_ID = scriptProperties.getProperty("SPOTIFY_CLIENT_ID");
const SPOTIFY_CLIENT_SECRET = scriptProperties.getProperty("SPOTIFY_CLIENT_SECRET");
const SPOTIFY_PODCAST_ID = scriptProperties.getProperty("SPOTIFY_PODCAST_ID");
const SPOTIFY_PODCAST_GUID = scriptProperties.getProperty("SPOTIFY_PODCAST_GUID");

const TWITTER_ACCOUNT = scriptProperties.getProperty("TWITTER_ACCOUNT");
const TWITTER_API_KEY = scriptProperties.getProperty("TWITTER_API_KEY");
const TWITTER_API_SECRET_KEY = scriptProperties.getProperty("TWITTER_API_SECRET_KEY");

const LASTEST_EPISODE_GUID = scriptProperties.getProperty("LASTEST_EPISODE_GUID");

// 認証用インスタンス
var twitterInstance = TwitterWebService.getInstance(TWITTER_API_KEY, TWITTER_API_SECRET_KEY);
twitterInstance.service_name = TWITTER_ACCOUNT;
twitterInstance.getService = function () {
  return OAuth1.createService(this.service_name)
    .setAccessTokenUrl("https://api.twitter.com/oauth/access_token")
    .setRequestTokenUrl("https://api.twitter.com/oauth/request_token")
    .setAuthorizationUrl("https://api.twitter.com/oauth/authorize")
    .setConsumerKey(this.consumer_key)
    .setConsumerSecret(this.consumer_secret)
    .setCallbackFunction("TwitterAuthCallback")
    .setPropertyStore(PropertiesService.getUserProperties());
};

// シート名でシートを取得する関数
function getSheetByName(spreadsheet, sheetName) {
  return spreadsheet.getSheetByName(sheetName); // シート名でシートを取得
}

// 公開日からプレイリストのタイトルを探す(本日の日付を基に)
function findPlaylistTitleByDate(currentDate) {
  var spreadsheet = SpreadsheetApp.openById(SPREADSHEET_ID);
  var sheet = getSheetByName(spreadsheet, SHEET_NAME); // シート名を使用
  if (!sheet) {
    Logger.log("指定されたシートが見つかりません。");
    return null;
  }
  
  var lastRow = sheet.getLastRow();
  var closestDate = null;
  var closestTitle = null;

  // 公開日(B列)を2行目から順に見ていき、一番近い日付を探す
  for (var i = 2; i <= lastRow; i++) {
    var publishDate = sheet.getRange(i, 2).getValue(); // B列が公開日
    
    // publishDateが日付オブジェクトでない場合、変換を試みる
    if (!(publishDate instanceof Date)) {
      publishDate = new Date(publishDate);
    }

    // 日付の比較を行う前に、両方の日付から時間部分を削除
    var currentDateWithoutTime = new Date(currentDate.getFullYear(), currentDate.getMonth(), currentDate.getDate());
    var publishDateWithoutTime = new Date(publishDate.getFullYear(), publishDate.getMonth(), publishDate.getDate());

    // 本日の日付と一致または通過した場合、その日付を記録
    if (publishDateWithoutTime <= currentDateWithoutTime) {
      closestDate = publishDate;
      closestTitle = sheet.getRange(i, 1).getValue(); // A列にプレイリストタイトル
    }
  }
  
  // 最も近い日付を返す
  if (closestDate && closestTitle) {
    Logger.log('公開日が一致または近い行を発見: ' + closestDate + ' タイトル: ' + closestTitle);
    return { playlistTitle: closestTitle, publishDate: closestDate };
  } else {
    Logger.log('公開日が一致する行が見つかりませんでした。');
    return null;
  }
}

// Spotify API 認証用
function getSpotifyService() {
  var redirectUri = "https://script.google.com/macros/s/" + DEPLOY_ID + "/usercallback";
  return OAuth2.createService("spotify")
    .setAuthorizationBaseUrl("https://accounts.spotify.com/authorize")
    .setTokenUrl("https://accounts.spotify.com/api/token")
    .setClientId(SPOTIFY_CLIENT_ID)
    .setClientSecret(SPOTIFY_CLIENT_SECRET)
    .setRedirectUri(redirectUri)
    .setPropertyStore(PropertiesService.getUserProperties())
    .setCallbackFunction("spotifyAuthCallback")
    .setScope("playlist-modify-public playlist-modify-private");
}

// Spotify認証のトリガー
function authorizeSpotify() {
  var service = getSpotifyService();
  if (service.hasAccess()) {
    Logger.log("既に認証されています。");
  } else {
    var authorizationUrl = service.getAuthorizationUrl();
    Logger.log("Spotifyの認証URL: " + authorizationUrl);
  }
}

// Spotify認証リセット(解除)
function resetSpotifyAuthorization() {
  var service = getSpotifyService();
  service.reset();
  Logger.log("Spotifyの認証がリセットされました。");
}

// Spotify認証後のコールバック処理
function spotifyAuthCallback(request) {
  var service = getSpotifyService();
  var authorized = service.handleCallback(request);
  if (authorized) {
    return HtmlService.createHtmlOutput("認証に成功しました。");
  } else {
    return HtmlService.createHtmlOutput("認証に失敗しました。");
  }
}

// SpotifyからPodcastエピソードを取得し、最新のものをプレイリストに追加
function checkPodcastRSS() {
  var service = getSpotifyService();
  if (!SPOTIFY_PODCAST_ID || !NOTIFICATION_EMAIL) {
    Logger.log("Podcast ID、または通知先メールアドレスが設定されていません。");
    return;
  }

  // エピソードの総数を取得するためのURL
  var totalEpisodesUrl = 'https://api.spotify.com/v1/shows/' + SPOTIFY_PODCAST_ID + '/episodes?market=JP';
  var options = {
    method: "GET",
    headers: {
      Authorization: "Bearer " + service.getAccessToken(),
      "Content-Type": "application/json",
    },
    muteHttpExceptions: true,
  };

  var totalEpisodesResponse = UrlFetchApp.fetch(totalEpisodesUrl, options);
  if (totalEpisodesResponse.getResponseCode() !== 200) {
    Logger.log("エピソード総数取得エラー: " + totalEpisodesResponse.getContentText());
    return;
  }

  var totalEpisodesJson = JSON.parse(totalEpisodesResponse.getContentText());
  var totalEpisodes = totalEpisodesJson.total; // エピソードの総数を取得

  // Spotifyエピソード取得順序を判定
  var order = getEpisodeOrder();
  var offset = (order === "ascending") ? totalEpisodes - 1 : 0; // 逆の順序に変更

  // 最新のエピソードを取得するためのURL(オフセットを使用)
  var latestEpisodeUrl = 'https://api.spotify.com/v1/shows/' + SPOTIFY_PODCAST_ID + '/episodes?market=JP&limit=1&offset=' + offset;

  var latestEpisodeResponse = UrlFetchApp.fetch(latestEpisodeUrl, options);
  if (latestEpisodeResponse.getResponseCode() !== 200) {
    Logger.log("エピソード取得エラー: " + latestEpisodeResponse.getContentText());
    return;
  }

  var latestEpisodeJson = JSON.parse(latestEpisodeResponse.getContentText());
  if (latestEpisodeJson && latestEpisodeJson.items && latestEpisodeJson.items.length > 0) {
    var latestEpisode = latestEpisodeJson.items[0];
    var latestEpisodeGuid = latestEpisode.id;

    // GUIDが以前のエピソードと同じ場合は処理を停止
    if (SPOTIFY_PODCAST_GUID === latestEpisodeGuid) {
      Logger.log('最新エピソードはすでに処理済みです。');
      return;
    }

    // プレイリスト名を取得
    var currentDate = new Date();
    var playlistData = findPlaylistTitleByDate(currentDate);
    if (!playlistData) {
      Logger.log("プレイリストデータが見つかりませんでした。");
      return;
    }

    var playlistTitle = playlistData.playlistTitle;
    var publishDate = playlistData.publishDate;

    // プレイリストにエピソードを追加
    var playlistInfo = findOrCreatePlaylist(service, playlistTitle);
    if (!playlistInfo) {
      Logger.log("プレイリストの作成に失敗しました。");
      return;
    }
    addPodcastToPlaylist(service, playlistInfo.id, latestEpisode.uri, latestEpisode.name, playlistTitle, publishDate);

    // エピソードが追加されたことを通知
    sendEmailNotification(NOTIFICATION_EMAIL, latestEpisode.name, playlistTitle, publishDate);

    // GUIDを更新
    scriptProperties.setProperty("SPOTIFY_PODCAST_GUID", latestEpisodeGuid);

    // ツイートの生成と投稿
    var tweetText = tweetTemplate
      .replace("{{PLAYLIST_TITLE}}", playlistTitle)
      .replace("{{EPISODE_TITLE}}", latestEpisode.name)
      .replace("{{EPISODE_URL}}", latestEpisode.external_urls.spotify)
      .replace("{{PLAYLIST_URL}}", playlistInfo.url);

    postTweet(tweetText, latestEpisode.images[0].url);
  } else {
    Logger.log("最新エピソードが見つかりませんでした。");
  }
}



// プレイリストが存在するか確認し、存在しなければ作成
function findOrCreatePlaylist(service, playlistTitle) {
  var options = {
    method: "GET",
    headers: {
      Authorization: "Bearer " + service.getAccessToken(),
      "Content-Type": "application/json",
    },
    muteHttpExceptions: true,
  };

  var url = "https://api.spotify.com/v1/me/playlists";
  var response = UrlFetchApp.fetch(url, options);
  var playlists = JSON.parse(response.getContentText()).items;

  Logger.log("取得したプレイリスト: " + JSON.stringify(playlists)); // 取得したプレイリストをログに出力

  for (var i = 0; i < playlists.length; i++) {
    Logger.log("チェック中のプレイリスト: " + playlists[i].name); // 各プレイリスト名をログに出力
    if (playlists[i].name === playlistTitle) {
      Logger.log("既存のプレイリストを発見: " + playlistTitle);
      return { id: playlists[i].id, url: playlists[i].external_urls.spotify }; // プレイリストIDとURLを返す
    }
  }

  var userResponse = UrlFetchApp.fetch("https://api.spotify.com/v1/me", options);
  var user = JSON.parse(userResponse.getContentText());
  var userId = user.id;

  Logger.log("プレイリストが存在しないため、新しく作成します: " + playlistTitle);
  var createOptions = {
    method: "POST",
    headers: {
      Authorization: 'Bearer ' + service.getAccessToken(),
      'Content-Type': 'application/json'
    },
    payload: JSON.stringify({
      name: playlistTitle,
      description: "自動作成プレイリスト",
      public: true, // ここをtrueに変更
    }),
  };

  var createResponse = UrlFetchApp.fetch("https://api.spotify.com/v1/users/" + userId + "/playlists", createOptions);
  Logger.log("プレイリスト作成レスポンス: " + createResponse.getContentText()); // レスポンスをログに出力
  var createdPlaylist = JSON.parse(createResponse.getContentText());

  // プレイリストのURLを取得
  if (createdPlaylist && createdPlaylist.id) {
    var playlistUrl = createdPlaylist.external_urls.spotify; // 作成したプレイリストのURLを取得
    Logger.log("作成したプレイリストのURL: " + playlistUrl);
    return { id: createdPlaylist.id, url: playlistUrl }; // プレイリストIDとURLを返す
  } else {
    Logger.log("プレイリストの作成に失敗しました。");
    return null;
  }
}

// プレイリストにエピソードを追加
function addPodcastToPlaylist(service, playlistId, episodeUri, episodeTitle, playlistTitle, publishDate) {
  var url = "https://api.spotify.com/v1/playlists/" + playlistId + "/tracks";
  var payload = {
    uris: [episodeUri],
  };
  var options = {
    method: "POST",
    headers: {
      Authorization: "Bearer " + service.getAccessToken(),
    },
    contentType: "application/json",
    payload: JSON.stringify(payload),
  };
  
  var response = UrlFetchApp.fetch(url, options);
  var responseJson = JSON.parse(response.getContentText());

  Logger.log("エピソードをプレイリストに追加しました。Response: " + JSON.stringify(responseJson));
  Logger.log("追加されたエピソード: " + episodeTitle + " プレイリスト: " + playlistTitle + " 公開日: " + publishDate);
}

// メール通知を送信する関数
function sendEmailNotification(emailAddress, episodeTitle, playlistTitle, publishDate) {
  var subject = "Spotifyプレイリストが更新されました";
  var message = "最新のエピソードがプレイリストに追加されました:\n" + "エピソードタイトル: " + episodeTitle + "\n" + "プレイリストタイトル: " + playlistTitle + "\n" + "公開日: " + publishDate;

  MailApp.sendEmail(emailAddress, subject, message);
  Logger.log("通知メールを送信しました: " + emailAddress);
}

// ツイートを投稿する関数
function postTweet(tweetText, imageUrl) {
  var service = twitterInstance.getService();

  if (service.hasAccess()) {
    var mediaId = null;
    if (imageUrl) {
      mediaId = uploadImage(service, imageUrl);
      if (!mediaId) {
        Logger.log("画像のアップロードに失敗しました。テキストのみでツイートします。");
      }
    }

    var url = "https://api.twitter.com/2/tweets";
    var payload = {
      text: tweetText,
    };

    if (mediaId) {
      payload.media = { media_ids: [mediaId] };
    }

    try {
      var response = service.fetch(url, {
        method: "post",
        contentType: "application/json",
        payload: JSON.stringify(payload),
        muteHttpExceptions: true,
      });

      var responseCode = response.getResponseCode();
      var responseText = response.getContentText();

      if (responseCode === 200 || responseCode === 201) {
        var result = JSON.parse(responseText);
        Logger.log("ツイートが投稿されました: %s", result.data.text);
      } else {
        Logger.log("ツイート投稿エラー: " + responseCode + " " + responseText);
      }
    } catch (e) {
      Logger.log("ツイート投稿中にエラーが発生しました: " + e.toString());
    }
  } else {
    Logger.log("%sの認証が必要です", TWITTER_ACCOUNT);
  }
}

// 画像をアップロードする関数

function uploadImage(service, imageUrl) {
  var response = UrlFetchApp.fetch(imageUrl);
  var contentType = response.getHeaders()['Content-Type'];
  var imageData = response.getBlob().setContentType(contentType);
  
  var uploadUrl = 'https://upload.twitter.com/1.1/media/upload.json';
  var boundary = Utilities.getUuid();

  var payload = '--' + boundary + '\r\n' +
                'Content-Disposition: form-data; name="media"; filename="image.jpg"\r\n' +
                'Content-Type: ' + contentType + '\r\n\r\n';

  var closeDelimiter = '\r\n--' + boundary + '--';

  var payloadBlob = Utilities.newBlob(payload).getBytes();
  var imageBlob = imageData.getBytes();
  var closeDelimiterBlob = Utilities.newBlob(closeDelimiter).getBytes();

  var multipartRequestBody = Utilities.newBlob(payloadBlob.concat(imageBlob).concat(closeDelimiterBlob));

  var uploadResponse = service.fetch(uploadUrl, {
    method: 'post',
    contentType: 'multipart/form-data; boundary=' + boundary,
    payload: multipartRequestBody,
    muteHttpExceptions: true
  });
  
  var responseCode = uploadResponse.getResponseCode();
  var responseText = uploadResponse.getContentText();
  
  if (responseCode !== 200) {
    Logger.log('画像アップロードエラー: ' + responseCode + ' ' + responseText);
    return null;
  }
  
  var uploadResult = JSON.parse(responseText);
  return uploadResult.media_id_string;
}



// Twitter認証
function authorizeTwitter() {
  var service = twitterInstance.getService();

  if (service.hasAccess()) {
    Logger.log("%sは認証済です", TWITTER_ACCOUNT);
  } else {
    var authorizationUrl = service.authorize();
    Logger.log("↓" + TWITTER_ACCOUNT + "の認証URL: " + authorizationUrl);
  }
}

// Twitter認証解除
function resetTwitter() {
  twitterInstance.reset();
  Logger.log("%sのアカウントの認証を解除しました", TWITTER_ACCOUNT);
}

// 認証後のコールバック
function TwitterAuthCallback(request) {
  return twitterInstance.authCallback(request);
}

// SPOTIFY_PODCAST_GUIDをリセットする関数
function resetSpotifyPodcastGuid() {
  const scriptProperties = PropertiesService.getScriptProperties();
  scriptProperties.setProperty("SPOTIFY_PODCAST_GUID", "0");
  Logger.log("SPOTIFY_PODCAST_GUIDがリセットされました。");
}


// Spotifyからエピソードを取得して昇順か降順か判定
function getEpisodeOrder() {
  var service = getSpotifyService();
  var url = 'https://api.spotify.com/v1/shows/' + SPOTIFY_PODCAST_ID + '/episodes?market=JP&limit=2';
  var options = {
    method: "GET",
    headers: {
      Authorization: "Bearer " + service.getAccessToken(),
      "Content-Type": "application/json",
    },
    muteHttpExceptions: true,
  };

  var response = UrlFetchApp.fetch(url, options);
  if (response.getResponseCode() !== 200) {
    Logger.log("エピソード取得エラー: " + response.getContentText());
    return;
  }

  var episodesJson = JSON.parse(response.getContentText());
  if (episodesJson.items && episodesJson.items.length >= 2) {
    var episode1 = episodesJson.items[0];
    var episode2 = episodesJson.items[1];

    var publishDate1 = new Date(episode1.release_date);
    var publishDate2 = new Date(episode2.release_date);

    if (publishDate1 < publishDate2) {
      Logger.log("エピソードは昇順で取得されています。");
      return "ascending";
    } else {
      Logger.log("エピソードは降順で取得されています。");
      return "descending";
    }
  } else {
    Logger.log("エピソードが十分に取得できませんでした。");
  }
}

// 取得順序に応じて適切にエピソードを取得
function fetchEpisodesBasedOnOrder() {
  var order = getEpisodeOrder();
  var offset = (order === "ascending") ? 0 : totalEpisodes - 1;

  var url = 'https://api.spotify.com/v1/shows/' + SPOTIFY_PODCAST_ID + '/episodes?market=JP&limit=1&offset=' + offset;
  var response = UrlFetchApp.fetch(url, options);
  
  if (response.getResponseCode() === 200) {
    // 処理続行
  } else {
    Logger.log("再取得エラー: " + response.getContentText());
  }
}

これで、Spotifyの最新エピソードが自動でTwitterにツイートされ、認証やデータのリセットが行えるシステムが完成しました。

コピペ用資料

Twitter API(Free)の申請文

Volunteer manages the website related to. Using the WordPress plugin with the Twitter API, you can use Twitter to send the latest information on the website to many people and plan not only to attract customers, but also to tweet tweets that many people like and retweet This is to analyze and transmit more useful information.

以上です。
一応、無料で全文公開しますが、もし役立ったという方がいれば、任意で投げ銭ください。

Spotify Podcastプレイリストを自動更新する方法

公開のたびに、毎回Spotifyのプレイリストを手動で更新しないといけないのがしんどかったので、GASで自動化してみました。
ChatGPT先生ありがとう。記事もほぼ書いてもらいました


※2024.09.27追記
Xへの自動ポスト機能を追加した高機能拡張版を作りました。
こちらへどうぞ
https://note.com/higuchiki/n/na543c707d5ea


この記事では、Google Apps Script (GAS) と Spotify API を使って、Podcastエピソードの自動更新とメール通知を行う方法を、初心者向けに分かりやすく説明します。コードをコピーしてそのまま使うことができるようにし、各ステップを丁寧に説明していきます。

手順1: Google Apps Script(GAS)の準備

  1. Googleスプレッドシートを作成します。
    • A列にプレイリスト名を入力します。
    • C列に公開日(`YYYY-MM-DD`形式)を入力します。
画像
こんなかんじ
  1. スプレッドシート内で、「拡張機能」→「Apps Script」を選択し、スクリプトエディタを開きます。

手順2: スクリプトプロパティの設定

次に、Spotify APIを利用するためのクライアントIDやシークレットを設定します。

1. クライアントIDとクライアントシークレットの取得

Spotify for Developers でアカウントを作成し、アプリを登録してクライアントIDとクライアントシークレットを取得します。

2. スクリプトプロパティの設定

スクリプトエディタで、「ファイル」→「プロジェクトのプロパティ」→「スクリプトプロパティ」に次の項目を追加します。

  • `SPOTIFY_CLIENT_ID`: SpotifyのクライアントID
  • `SPOTIFY_CLIENT_SECRET`: Spotifyのクライアントシークレット
  • `DEPLOY_ID`: Google Apps ScriptのデプロイID
  • `PODCAST_SPOTIFY_ID`: 対象PodcastのSpotify ID
  • `SPOTIFY_PLAYLIST_ID`: プレイリストのSpotify ID
  • `NOTIFICATION_EMAIL`: 通知を受け取りたいメールアドレス

手順3: OAuthライブラリの追加

Spotify APIを利用するために、Google Apps ScriptにOAuth2ライブラリを追加する必要があります。

1. OAuth2ライブラリを追加

  1. スクリプトエディタで「ライブラリ」を選択します。
  2. 「ライブラリID」に以下のIDを入力して、OAuth2ライブラリを追加します。
1B7FSrk5Zi6L1rSxxTDgDEUsPzlukDsi4KGuTMorsTQHhGBzBkMun4iDF

これにより、Spotify APIに認証するためのOAuth2ライブラリがスクリプトに組み込まれます。

手順4: リダイレクトURIの設定

Spotify APIで正しく認証するには、Spotify for Developersのアプリ設定でリダイレクトURIを追加する必要があります。

  1. Spotify Dashboardでアプリを選択します。
  2. 「Redirect URIs」に以下のURLを追加します。
https://script.google.com/macros/s/{DEPLOY_ID}/usercallback

※ `{DEPLOY_ID}` はデプロイIDに置き換えてください。

手順5: 認証を実行

次に、Spotify APIにアクセスするための認証を行います。

  1. スクリプトエディタ内で `authorize` 関数を実行します。
  2. ログに表示されたURLを開き、Spotifyでアプリにアクセス権を与えてください。

手順6: スクリプトの自動実行設定

Podcastエピソードの更新を定期的にチェックするために、トリガーを設定します。

  1. スクリプトエディタで「時計のアイコン」から「トリガー」を開きます。
  2. `checkPodcastRSS` 関数を日単位または時間単位で実行するように設定します。

手順7: メール通知の仕組み

このスクリプトでは、最新エピソードがプレイリストに追加されると、指定したメールアドレスに通知が送信されます。通知にはエピソードタイトル、プレイリストタイトル、公開日が含まれます。


まとめ

この手順に従えば、Spotify APIとGoogle Apps Scriptを使ってPodcastエピソードを自動でプレイリストに追加し、メールで通知を受け取る仕組みが作成できます。

以下にスクリプト全文を掲載しますので、そのままコピーして、冒頭の、

const SPREADSHEET_ID = '{スプレッドシートID}'; // スプレッドシートID
const SHEET_ID = '{シートID}'; // シートID

だけ書き換えて使ってください。


スクリプト全文:

// 定数定義: スプレッドシートのIDとシートID
const SPREADSHEET_ID = '{スプレッドシートID}'; // スプレッドシートID
const SHEET_ID = '{シートID}'; // シートID

// シートIDでシートを取得する関数
function getSheetById(spreadsheet, sheetId) {
  var sheets = spreadsheet.getSheets(); // すべてのシートを取得
  for (var i = 0; i < sheets.length; i++) {
    if (sheets[i].getSheetId() === sheetId) { // シートIDが一致するか確認
      return sheets[i];
    }
  }
  throw new Error('指定されたシートIDのシートが見つかりませんでした。'); // シートが見つからない場合のエラーハンドリング
}
// 公開日からプレイリストのタイトルを探す(本日の日付を基に)
function findPlaylistTitleByDate(currentDate) {
  var spreadsheet = SpreadsheetApp.openById(SPREADSHEET_ID);
  var sheet = getSheetById(spreadsheet, SHEET_ID); // シートIDでシートを取得
  
  var lastRow = sheet.getLastRow();
  var closestDate = null;
  var closestTitle = null;

  // 公開日(C列)を2行目から順に見ていき、一番近い日付を探す
  for (var i = 2; i <= lastRow; i++) {
    var publishDate = sheet.getRange(i, 3).getValue(); // C列が公開日

    // 本日の日付と一致または通過した場合、その日付を記録
    if (publishDate <= currentDate) {
      closestDate = publishDate;
      closestTitle = sheet.getRange(i, 1).getValue(); // A列にプレイリストタイトル
    }
  }
  
  // 最も近い日付を返す
  if (closestDate && closestTitle) {
    Logger.log('公開日が一致または近い行を発見: ' + closestDate + ' タイトル: ' + closestTitle);
    return { playlistTitle: closestTitle, publishDate: closestDate };
  } else {
    Logger.log('公開日が一致する行が見つかりませんでした。');
    return null;
  }
}


// Spotify API 認証用
function getSpotifyService() {
  var scriptProperties = PropertiesService.getScriptProperties();
  var clientId = scriptProperties.getProperty('SPOTIFY_CLIENT_ID'); 
  var clientSecret = scriptProperties.getProperty('SPOTIFY_CLIENT_SECRET'); 
  var deployId = scriptProperties.getProperty('DEPLOY_ID'); 
  
  var redirectUri = 'https://script.google.com/macros/s/' + deployId + '/usercallback'; 
  
  return OAuth2.createService('spotify')
    .setAuthorizationBaseUrl('https://accounts.spotify.com/authorize')
    .setTokenUrl('https://accounts.spotify.com/api/token')
    .setClientId(clientId)
    .setClientSecret(clientSecret)
    .setRedirectUri(redirectUri)
    .setPropertyStore(PropertiesService.getUserProperties())
    .setCallbackFunction('authCallback')
    .setScope('playlist-modify-public playlist-modify-private');
}

// 認証のトリガー
function authorize() {
  var service = getSpotifyService();
  if (service.hasAccess()) {
    Logger.log('Already authorized');
  } else {
    var authorizationUrl = service.getAuthorizationUrl();
    Logger.log('Open the following URL and authorize the app: %s', authorizationUrl);
  }
}

// 認証後のコールバック処理
function authCallback(request) {
  var service = getSpotifyService();
  var authorized = service.handleCallback(request); 
  if (authorized) {
    return HtmlService.createHtmlOutput('認証に成功しました。');
  } else {
    return HtmlService.createHtmlOutput('認証に失敗しました。');
  }
}


// SpotifyからPodcastエピソードを取得し、最新のものをプレイリストに追加
function checkPodcastRSS() {
  var service = getSpotifyService();
  var scriptProperties = PropertiesService.getScriptProperties();
  var podcastId = scriptProperties.getProperty('PODCAST_SPOTIFY_ID'); 
  var playlistId = scriptProperties.getProperty('SPOTIFY_PLAYLIST_ID'); 
  var lastPodcastGuid = scriptProperties.getProperty('PODCAST_GUID'); // 以前のエピソードのGUID
  var notificationEmail = scriptProperties.getProperty('NOTIFICATION_EMAIL'); // 通知先のメールアドレス
  
  if (!podcastId || !playlistId || !notificationEmail) {
    Logger.log('Podcast ID、プレイリストID、または通知先メールアドレスが設定されていません。');
    return;
  }

  if (service.hasAccess()) {
    // エピソード数を取得する
    var url = 'https://api.spotify.com/v1/shows/' + podcastId + '/episodes?market=JP&limit=1';
    var options = {
      method: 'GET',
      headers: {
        Authorization: 'Bearer ' + service.getAccessToken(),
        'Content-Type': 'application/json'
      }
    };
    var response = UrlFetchApp.fetch(url, options);
    var json = JSON.parse(response.getContentText());
    
    if (json && json.total > 0) {
      var totalEpisodes = json.total; // 全エピソード数を取得
      Logger.log('全エピソード数: ' + totalEpisodes);
      
      // 最新のエピソードを取得するためのoffsetを設定
      var latestEpisodeUrl = 'https://api.spotify.com/v1/shows/' + podcastId + '/episodes?market=JP&limit=1&offset=' + (totalEpisodes - 1);
      var latestEpisodeResponse = UrlFetchApp.fetch(latestEpisodeUrl, options);
      var latestEpisodeJson = JSON.parse(latestEpisodeResponse.getContentText());
      
      if (latestEpisodeJson && latestEpisodeJson.items && latestEpisodeJson.items.length > 0) {
        var latestEpisodeUri = latestEpisodeJson.items[0].uri; // 最新エピソードのURIを取得
        var latestEpisodeGuid = latestEpisodeJson.items[0].id; // SpotifyのエピソードID
        var latestEpisodeDateUTC = new Date(); // 本日の日時を取得

        // 本日の日付を取得
        Logger.log('本日の日時 (JST): ' + latestEpisodeDateUTC.toLocaleString('ja-JP'));

        var latestEpisodeTitle = latestEpisodeJson.items[0].name; // 最新エピソードのタイトルを取得
        
        // GUIDが以前のエピソードと同じ場合は処理を停止
        if (lastPodcastGuid === latestEpisodeGuid) {
          Logger.log('最新エピソードはすでに処理済みです。');
          return;
        }
        
        // 新しいエピソードを認識した場合、処理を進める
        Logger.log('新しいエピソード URI: ' + latestEpisodeUri + ' タイトル: ' + latestEpisodeTitle + ' 本日 (JST): ' + latestEpisodeDateUTC.toLocaleString('ja-JP'));
        
        // 新しいGUIDを保存
        scriptProperties.setProperty('PODCAST_GUID', latestEpisodeGuid);
        
        // 公開日の行をスプレッドシートで探す(本日の日付と比較)
        var playlistData = findPlaylistTitleByDate(latestEpisodeDateUTC);
        
        if (playlistData) {
          var playlistTitle = playlistData.playlistTitle;
          var publishDate = playlistData.publishDate;
          
          // プレイリストにエピソードを追加
          var playlistId = findOrCreatePlaylist(service, playlistTitle);
          addPodcastToPlaylist(service, playlistId, latestEpisodeUri, latestEpisodeTitle, playlistTitle, publishDate);

          // エピソードが追加されたことを通知
          sendEmailNotification(notificationEmail, latestEpisodeTitle, playlistTitle, publishDate);
        }
      } else {
        Logger.log('最新エピソードが見つかりませんでした。');
      }
    } else {
      Logger.log('エピソードが見つかりませんでした。');
    }
  } else {
    Logger.log('アクセストークンがありません。authorize()を実行して認証を行ってください。');
  }
}

// プレイリストが存在するか確認し、存在しなければ作成
function findOrCreatePlaylist(service, playlistTitle) {
  var options = {
    method: 'GET',
    headers: {
      Authorization: 'Bearer ' + service.getAccessToken(),
      'Content-Type': 'application/json'
    }
  };
  
  // プレイリストを探す(仮にユーザーのプレイリスト一覧から探すと仮定)
  var url = 'https://api.spotify.com/v1/me/playlists';
  var response = UrlFetchApp.fetch(url, options);
  var playlists = JSON.parse(response.getContentText()).items;
  
  for (var i = 0; i < playlists.length; i++) {
    if (playlists[i].name === playlistTitle) {
      Logger.log('既存のプレイリストを発見: ' + playlistTitle);
      return playlists[i].id;
    }
  }
  
  // 認証されたユーザーの情報を取得する
  var userResponse = UrlFetchApp.fetch('https://api.spotify.com/v1/me', options);
  var user = JSON.parse(userResponse.getContentText());
  var userId = user.id; // 認証ユーザーのIDを取得

  // 見つからなかった場合、新しいプレイリストを作成
  Logger.log('プレイリストが存在しないため、新しく作成します: ' + playlistTitle);
  var createOptions = {
    method: 'POST',
    headers: {
      Authorization: 'Bearer ' + service.getAccessToken(),
      'Content-Type': 'application/json'
    },
    payload: JSON.stringify({
      name: playlistTitle,
      description: '自動作成プレイリスト',
      public: false
    })
  };
  
  // 認証ユーザーのためにプレイリストを作成
  var createResponse = UrlFetchApp.fetch('https://api.spotify.com/v1/users/' + userId + '/playlists', createOptions);
  var createdPlaylist = JSON.parse(createResponse.getContentText());
  return createdPlaylist.id;
}

// プレイリストにエピソードを追加
function addPodcastToPlaylist(service, playlistId, episodeUri, episodeTitle, playlistTitle, publishDate) {
  var url = 'https://api.spotify.com/v1/playlists/' + playlistId + '/tracks';
  var payload = {
    uris: [episodeUri] 
  };
  var options = {
    method: 'POST',
    headers: {
      Authorization: 'Bearer ' + service.getAccessToken()
    },
    contentType: 'application/json',
    payload: JSON.stringify(payload)
  };
  var response = UrlFetchApp.fetch(url, options);
  var responseJson = JSON.parse(response.getContentText());

  // ログに追加したエピソードのタイトル、プレイリスト名、公開日を表示
  Logger.log('エピソードをプレイリストに追加しました。Response: ' + JSON.stringify(responseJson));
  Logger.log('追加されたエピソード: ' + episodeTitle + ' プレイリスト: ' + playlistTitle + ' 公開日: ' + publishDate);
}

// スクリプトプロパティ "PODCAST_GUID" を "0" に設定する関数
function setPodcastGuidToZero() {
  // スクリプトプロパティにアクセス
  var scriptProperties = PropertiesService.getScriptProperties();
  
  // プロパティ "PODCAST_GUID" の値を "0" に設定
  scriptProperties.setProperty('PODCAST_GUID', '0');
  
  // 確認のためのログ出力
  Logger.log('PODCAST_GUID has been set to 0');
}

// メール通知を送信する関数
function sendEmailNotification(emailAddress, episodeTitle, playlistTitle, publishDate) {
  var subject = 'Spotifyプレイリストが更新されました';
  var message = '最新のエピソードがプレイリストに追加されました:\n' +
                'エピソードタイトル: ' + episodeTitle + '\n' +
                'プレイリストタイトル: ' + playlistTitle + '\n' +
                '公開日: ' + publishDate;

  // メール送信
  MailApp.sendEmail(emailAddress, subject, message);
  Logger.log('通知メールを送信しました: ' + emailAddress);
}

この方法を活用して、Podcastエピソードの更新を自動化し、作業を効率化しましょう!

iOSショートカットとGoogleスプレッドシートを使った体重記録アプリの作成

またChatGPT先輩にアプリ作ってもらったので公開します。
記事もほぼ書いてもらいました。

概要

今回は、iPhoneのショートカット機能を使って、日時と体重を入力すると自動的にGoogleスプレッドシートに記録されるアプリを作成する方法をご紹介します。このアプリを使えば、手軽に体重管理ができるようになります。

必要なもの

  • iPhone
  • Googleアカウント
  • Googleスプレッドシート
  • Google Apps Script
  • ショートカットアプリ(iOS)

手順

以下の手順に従ってアプリを作成します。

1. Googleスプレッドシートの準備

  1. Googleスプレッドシートを作成し、適当な名前を付けます(例:「体重管理」)。
  2. シート名は「シート1」とします(デフォルトのままでOKですし、変えてもOKです)。
  3. シートのA列には「日付」、B列には「体重」を記録します。

2. Google Apps Script(GAS)の設定

  1. スプレッドシートの「拡張機能」から「Apps Script」を選択します。
  2. 以下のコードを貼り付けます
function doPost(e) {
  var scriptProperties = PropertiesService.getScriptProperties();
  var spreadsheetId = scriptProperties.getProperty("SPREADSHEET_ID");
  var sheetName = scriptProperties.getProperty("SHEET_NAME");
  var sheet = SpreadsheetApp.openById(spreadsheetId).getSheetByName(sheetName);

  try {
    var data = JSON.parse(e.postData.contents);
    var datetime = new Date(data.datetime); // 送信された日時をDateオブジェクトに変換
    var weight = data.weight;

    // mm/dd形式に変換
    var month = ("0" + (datetime.getMonth() + 1)).slice(-2); // 月は0-11の範囲なので+1
    var day = ("0" + datetime.getDate()).slice(-2);
    var formattedDate = month + "/" + day;

    // 日付のフォーマットを確認し、無効な場合は「不明」を設定
    if (isNaN(datetime.getTime())) {
      formattedDate = "不明";
    }

    // A列とB列の最終行を特定し、その次の行を計算
    var lastRowA = sheet.getRange("A:A").getValues().filter(String).length;
    var lastRowB = sheet.getRange("B:B").getValues().filter(String).length;
    var lastRow = Math.max(lastRowA, lastRowB);

    // 次の空行を特定
    while(sheet.getRange(lastRow + 1, 1).getValue() || sheet.getRange(lastRow + 1, 2).getValue()) {
      lastRow++;
    }
    lastRow++;

    sheet.getRange(lastRow, 1).setValue(formattedDate);
    sheet.getRange(lastRow, 2).setValue(weight);

    return ContentService.createTextOutput(JSON.stringify({"result": "success"})).setMimeType(ContentService.MimeType.JSON);
  } catch (error) {
    // エラー処理
    return ContentService.createTextOutput(JSON.stringify({"result": "error", "message": error.message})).setMimeType(ContentService.MimeType.JSON);
  }
}
  1. コードを保存し、「デプロイ」→「新しいデプロイ」を選択します。
  1. 「ウェブアプリ」としてデプロイし、誰でもアクセスできるように設定します。
  2. デプロイ後に表示される「ウェブアプリのURL」をメモしておきます。
  3. スクリプトプロパティにスプレッドシートIDとシート名を設定します:
    • スクリプトエディタで「ファイル」→「プロジェクトのプロパティ」→「スクリプトのプロパティ」を選択します。
    • 「スクリプトプロパティ」に以下のキーと値を追加します:
      • SPREADSHEET_ID: {スプレッドシートのURLに記載されているID}
      • SHEET_NAME: {シート名(デフォルトは「シート1」)}

3. iPhoneのショートカットアプリの設定

  1. ショートカットアプリを開き、右上の「+」アイコンをタップして新しいショートカットを作成します。
  2. 「アクションを追加」ボタンをタップし、「入力を要求」を追加します。
    • 「プロンプト」に「体重」と入力、「テキスト」をタップし、メニューから「数字」を選択、右矢印をタップして詳細設定を開き、「小数点以下の数字を許可」をオンに、「負の数字の許可」をオフにします。
    • 「プロンプト」に「日付」と入力、「テキスト」をタップし、メニューから「日付」を選択します。
  3. 「URLの内容を取得」アクションを追加し、以下の設定をします。
    • 「URL」フィールドにGASウェブアプリのURLを入力します。
    • 「方法」を「POST」に設定します。
    • 「本文を要求」を有効にし、JSONを選択して、新規フィールドを以下のように追加します。
      • 型:数字、キー:weight、値:タップして「変数を選択」より、「体重」項目の下の「指定入力」をタップ。
      • 型:テキスト、キー:datetime、値:タップして「変数を選択」より、「日時」項目の下の「指定入力」をタップ。
  4. ショートカットに名前を付けて保存します。

実行

作成したショートカットを実行すると、日付と体重を入力するプロンプトが表示され、それを入力すると指定のGoogleスプレッドシートにデータが追加されます。

終わりに

この方法を使えば、簡単に体重を記録することができます。さらに、自動化を進めることで、他の健康管理やデータ収集も手軽に行えるようになるでしょう。ぜひ試してみてください。

LINE公式アカウントのメッセージをDiscordに通知する方法

LINE公式アカウントのメッセージをDiscordに通知するアプリをChatGPTさんに作ってもらいました。GASなので、無料です。

以下の記事も、ChatGPTさんに「noteで公開したいから記事書いて」と言ったらまとめてくれました。
では、どうぞ。


この記事では、Google Apps Scriptを使ってLINE公式アカウントから受信したメッセージをDiscordに通知する方法を紹介します。スクリプトプロパティを使用して設定を管理し、簡単にカスタマイズできるようにしています。

必要なもの

  1. Googleアカウント
  2. LINE公式アカウント
  3. Discordアカウント

手順概要

  1. Google Apps Scriptプロジェクトを作成
  2. スクリプトプロパティの設定
  3. LINE DevelopersコンソールでWebhookを設定
  4. DiscordでWebhookを設定
  5. Google Apps Scriptのデプロイ
  6. テストと確認

1. Google Apps Scriptプロジェクトを作成

スクリプトの作成

  1. Googleドライブにアクセスし、「新規」→「その他」→「Google Apps Script」を選択します。
  2. 以下のスクリプトをコピーして貼り付けます。
// スクリプトプロパティを取得
var scriptProperties = PropertiesService.getScriptProperties();
var discordWebhookUrl = scriptProperties.getProperty('discordWebhookUrl');
var lineChannelAccessToken = scriptProperties.getProperty('lineChannelAccessToken');
var logFolderId = scriptProperties.getProperty('logFolderId');

// ログメッセージをGoogleドライブの指定フォルダに保存する関数
function logToFile(logMessage) {
  var folder = DriveApp.getFolderById(logFolderId);
  var logFile = folder.getFilesByName("line_discord_log.txt");
  var file;
  
  if (logFile.hasNext()) {
    file = logFile.next();
  } else {
    file = folder.createFile("line_discord_log.txt", "");
  }
  
  var content = file.getBlob().getDataAsString();
  content += new Date().toLocaleString() + " - " + logMessage + "\n";
  file.setContent(content);
}

// 日付と時刻をフォーマットする関数
function formatDate(date) {
  var year = date.getFullYear();
  var month = ("0" + (date.getMonth() + 1)).slice(-2);
  var day = ("0" + date.getDate()).slice(-2);
  var hours = ("0" + date.getHours()).slice(-2);
  var minutes = ("0" + date.getMinutes()).slice(-2);
  
  return month + "/" + day + " " + hours + ":" + minutes;
}

// ブラウザでアクセスした際の応答
function doGet(e) {
  logToFile("Webhook received via GET request");
  return ContentService.createTextOutput("Webhook received").setMimeType(ContentService.MimeType.TEXT);
}

// LINEからのPOSTリクエストを処理する関数
function doPost(e) {
  try {
    logToFile("Webhook request received via POST");
    logToFile("Request content: " + e.postData.contents); // リクエストの内容をログに記録

    var lineData = JSON.parse(e.postData.contents);
    logToFile("Parsed lineData: " + JSON.stringify(lineData));

    var userId = lineData.events[0].source.userId;
    logToFile("User ID: " + userId); // User IDをログに記録

    var userMessage = lineData.events[0].message.type === "text" ? lineData.events[0].message.text : "[スタンプ]";
    logToFile("User Message: " + userMessage); // User Messageをログに記録

    var timestamp = formatDate(new Date(lineData.events[0].timestamp));
    logToFile("Timestamp: " + timestamp); // Timestampをログに記録

    // ユーザー情報を取得する
    var userName = getUserName(userId) || "ユーザー名不明";
    logToFile("User Name: " + userName); // User Nameをログに記録

    var formattedMessage = 
      ':speech_balloon: **' + userName + '** | __' + timestamp + '__' + '\n' +
      userMessage + '\n'+ '--------------------------------';

    logToFile("Formatted Message: " + formattedMessage); // Formatted Messageをログに記録

    sendToDiscord(formattedMessage);

    return ContentService.createTextOutput(JSON.stringify({ "status": "success" })).setMimeType(ContentService.MimeType.JSON);
  } catch (error) {
    logToFile("Error: " + error.message);
    return ContentService.createTextOutput(JSON.stringify({ "status": "error", "message": error.message })).setMimeType(ContentService.MimeType.JSON);
  }
}

// ユーザーIDから名前を取得する関数
function getUserName(userId) {
  try {
    var url = 'https://api.line.me/v2/bot/profile/' + userId;
    logToFile("Profile URL: " + url); // Profile URLをログに記録

    var options = {
      'headers': {
        'Authorization': 'Bearer ' + lineChannelAccessToken
      },
      'method': 'get',
      'contentType': 'application/json'
    };

    var response = UrlFetchApp.fetch(url, options);
    logToFile("Profile Response: " + response.getContentText()); // Profile Responseをログに記録

    var userData = JSON.parse(response.getContentText());
    logToFile("User Data: " + JSON.stringify(userData)); // User Dataをログに記録

    return userData.displayName;
  } catch (error) {
    logToFile("Error fetching user profile: " + error.message);
    return null; // デフォルトの名前
  }
}

// Discordにメッセージを送信する関数
function sendToDiscord(message) {
  try {
    var payload = {
      'content': message
    };

    var options = {
      'method': 'post',
      'contentType': 'application/json',
      'payload': JSON.stringify(payload)
    };

    var response = UrlFetchApp.fetch(discordWebhookUrl, options);
    logToFile("Discord Response: " + response.getContentText()); // Discord Responseをログに記録
  } catch (error) {
    logToFile("Error sending to Discord: " + error.message);
  }
}

2. スクリプトプロパティの設定

スクリプトプロパティの設定方法

  1. Google Apps Scriptエディタで、トップメニューの「ファイル」から「プロジェクトのプロパティ」を選択します。
  2. 「スクリプトのプロパティ」タブを選択し、以下のプロパティを追加します:
    • discordWebhookUrl: DiscordのWebhook URL
    • lineChannelAccessToken: LINEチャネルアクセストークン
    • logFolderId: ログファイルを保存するフォルダのID

3. LINE DevelopersコンソールでWebhookを設定

  1. LINE Developersにログインします。
  2. 該当するプロバイダーを選択し、目的のLINE公式アカウントのチャネルを選択します。
  3. 「Messaging API」タブを開きます。
  4. 「Webhook URL」にGoogle Apps ScriptのウェブアプリURLを設定し、「更新」をクリックします。
    1. https://script.google.com/macros/s/あなたのデプロイURL/exec
  5. 「Webhook利用」を「利用する」に設定します。

4. DiscordでWebhookを設定

  1. Discordで通知を送りたいチャンネルを選び、「チャンネルを編集」→「連携サービス」→「ウェブフック」からウェブフックを作成します。
  2. ウェブフックURLをコピーし、スクリプトプロパティのdiscordWebhookUrlに設定します。

5. Google Apps Scriptのデプロイ

デプロイ手順

  1. スクリプトエディタの右上の「デプロイ」ボタンをクリックし、「新しいデプロイ」を選択します。
  2. 「ウェブアプリ」を選択し、「次のユーザーとして実行」を「自分」に設定します。
  3. 「アクセスできるユーザー」を「全員(匿名ユーザーを含む)」に設定します。
  4. デプロイをクリックし、表示されたウェブアプリのURLを取得します。

6. テストと確認

ブラウザでの確認

  1. ウェブアプリのURLにブラウザでアクセスし、「Webhook received」のメッセージが表示されるか確認します。

LINE公式アカウントからのテストメッセージ

  1. LINE公式アカウントにテキストメッセージとスタンプを送信します。
  2. Googleドライブで、指定したフォルダIDの場所にline_discord_log.txtというファイルが作成されている