【GAS】Difyプロトタイプを本格業務アプリに!実践的な自動化

【GAS】Difyプロトタイプを本格業務アプリに!実践的な自動化

今回は、以前Difyで作成したプロトタイプ「Dify入門ガイド:X投稿を自動生成!10分で作るブログPR効率化ワークフロー」をGASとAzure OpenAI Serviceで業務アプリとして作成しました。Difyで作成したプロトタイプから、普段使いしやすいように業務アプリ化する具体的な流れについて解説していきます。

Dify版プロトタイプ

プロトタイプについては、「Dify入門ガイド:X投稿を自動生成!10分で作るブログPR効率化ワークフロー」で紹介しています。

こちらのプロトタイプでは、「URLと文字数を入力として、HTMLから情報を取得しXの投稿文を生成」します。プロトタイプでは、2ステップで処理を作成しています。

  • HTMLから記事情報を取得
  • 取得したテキストからPR文を生成する

業務アプリ検討

業務アプリケーションのモチベーションとしては、「ブログの投稿と連動してXの投稿を自動生成し、業務効率を改善」となります。GASを選択した背景を含めて解説していきます。

要件

業務アプリは、以下の要件を満たす必要があると考えました。

  • 投稿の管理:投稿ステータス管理(投稿済み・未投稿)
  • ブログの更新と連動して投稿を取得する(定期実行)
  • Xの自動投稿はせずに人の手で確認をする
  • 複数の担当者が一覧にアクセスすることができる(アクセス管理)

定期実行・アクセス管理・一覧表示などの要件から、GASとスプレッドシートで業務アプリの作成を決定しました。GASとDifyの連携で処理を行うことも考えましたが、Difyの環境をAzure上で構成するよりもREST APIでAOAIにリクエストを投げるほうがお手軽なので、生成AIにはAOAIを使用します。

GASの制限

GASを採用するにあたって業務アプリに関係のある制限事項についてまとめていきます。公式の情報としてはこちらになります。

制限閾値
スクリプトの実行時間6 min
URL Fetch数20,000 件/日
スクリプトあたりの同時実行数1,000
トリガー20 ユーザー/スクリプト

トリガー実行に関しては、日付ベースの場合では実行時間の範囲を指定することができます。この実行時間は、必ずその範囲で実行するわけではないので、スクリプト間で前処理が必要な場合の処理に関しては工夫が必要です。こちらの制限を回避する方法としては、スクリプトを独立させておくなどが挙げられます。

上記の条件に抵触しないように業務アプリとして作成する必要があります。

GAS:XのPR文生成 from RSS

それでは、GASコードの解説に入っていきます。構成図としては、以下のような構成になっています。データソースとしては、Google Spred Sheetを使用しています。

スクリプトとしては、3つになります。

  • トリガー実行:RSSから記事情報を取得しURL・タイトル・投稿日をシートに書き込む
  • トリガー実行:シートからURLを取得し情報を圧縮してシートに書き込む
  • 通常実行:圧縮したテキストをAOAIに投げて投稿本文を生成しシートに書き込む

トリガー実行をする部分に関しては、それぞれ単独で運用しても問題なく機能するように作成しています。

投稿管理を行うシートの構成としては、RSS というシート名で以下のヘッダーを持っています。

ヘッダー名説明
タイトルRSSから情報を取得した記事タイトル
リンクRSSから情報を取得した記事リンク
日付RSSから情報を取得した記事投稿日
圧縮生データHTMLタグなどを除外し圧縮したテキストデータ
PRAOAIが生成したXの投稿文

実際の出力画面としては、以下のようになります。手動でフラグ管理としてステータス(投稿済み・未投稿)の列を追加して管理しています。

もし使用される方がいれば、上記のヘッダーを持ったRSSというシートを用意してください。

共通スクリプト

シートへのアクセスはどのスクリプトでも実行するため、共通として書き出しています。

// SpreadSheetのアクセスSheet取得
const accessSheet = (sheetName = "RSS") => {
  const file = SpreadsheetApp.getActiveSpreadsheet()
  const sheet = file.getSheetByName(sheetName)
  return sheet
}

引数としてsheetNameを受け取れるようにしていますが、デフォルトとしてRSSを指定しています。

RSSから情報を取得しシートに記載する

このスクリプトでは「RSSから情報を取得(getRSSFeed)しシート内に情報が記載されていない場合、行の最後にタイトル・リンク・投稿日の情報を追記」を処理しています。工夫として、重複を避けるために記載済みの情報を削除する仕組みが搭載されています。

// スケジュール用:RSSから取得してスプレッドシートに書き込む
const createRowData = () => {
  const sheet = accessSheet()
  const lastRow = sheet.getLastRow()
  // 2-2(B2) からスタート
  const linkList = lastRow == 1 ? [] : sheet.getRange(2, 2, lastRow - 1, 1).getValues().map((row) => row[0])

  const dataFromRSS = getRSSFeed()
  const data = []
  dataFromRSS.forEach((value) => {
    const [, link] = value
    if (!linkList.includes(link)) data.push(value)
  })
  if (data.length == 0) return
  sheet.getRange(lastRow + 1, 1, data.length, data[0].length).setValues(data)
}

RSSの取得部分は処理として分割しています。こちらでは、RSSフィードからXML情報を取得しています。XMLをパースし、配列化して、最後に投稿日順にソートをして返答しています。

// RSSから情報を取得する
const getRSSFeed = () => {
  // RSSフィードのURLを指定
  const rssUrl = 'https://ch-lab.sios.jp/feed'; // ここにRSSフィードのURLを入力してください
  // RSSフィードを取得
  const response = UrlFetchApp.fetch(rssUrl);
  const xml = response.getContentText();
  const document = XmlService.parse(xml);
  const root = document.getRootElement();

  // RSSフィードのエントリを解析
  const items = root.getChild('channel').getChildren('item');
  const data = [];

  items.forEach(function (item) {
    const title = item.getChild('title').getText();
    const link = item.getChild('link').getText();
    const pubDate = new Date(item.getChild('pubDate').getText());
    data.push([title, link, pubDate]);
  });
  // 日付sort
  data.sort((a, b) => a[1] > b[1] ? 1 : -1)
  return data
}

URLから圧縮済みテキストを生成しシートに記載

このスクリプトでは「シートから圧縮済みテキストを生成していない場合、URLから情報を取得し圧縮済みテキストを生成(compressHTML)しシートに追記」を処理しています。工夫として、重複を防ぐために圧縮済みテキストが””の場合のみURLを取得しています。また、HTML情報を取得する部分に関してはfetchAllでリクエストを同時実行しています。

// スケジュール用:URLから圧縮のテキストデータ未作成の場合は作成
function getCompressTextFromURL() {

  const sheet = accessSheet()
  const lastRow = sheet.getLastRow()
  // 情報がなければスルー
  if (lastRow == 1) return
  const itemList = sheet.getRange(2, 1, lastRow - 1, 5).getValues()
  const linkList = itemList.map((row) => row[1])
  const nonComporessTextlinkList = []
  itemList.forEach((row) => {
    const [, , , comporessText] = row
    if (comporessText == "") nonComporessTextlinkList.push(row[1])
  })

  const response = UrlFetchApp.fetchAll(nonComporessTextlinkList)
  response.forEach((value, index) => {
    const html = value.getContentText()
    const compressText = compressHTML(html)
    const url = nonComporessTextlinkList[index]
    const listIndex = linkList.indexOf(url)
    if (listIndex == -1) return
    itemList[listIndex][3] = compressText
  })

  sheet.getRange(2, 1, lastRow - 1, 5).setValues(itemList)
}

URLから情報を取得して、情報を圧縮するスクリプトは以下になります。こちらは「GASコード付き:URLからHTML取得&圧縮する方法|AI入力の最適化に」で詳細に解説しています。

// HTMLから圧縮したTextを作成
const compressHTML = (html) => {
  // HTMLを解析
  const document = HtmlService.createHtmlOutput(html).getContent();

  // 必要な部分を抽出(この記事の内容が <section class="entry-content"> タグ内にあると仮定します
  var content = '';
  const regex = /<section class="entry-content"[^>]*>([\s\S]*?)<\/section>/;
  const match = regex.exec(document);
  if (match && match[1]) {
    const temp = match[1];

    // プロンプト圧縮用
    content = temp.replace(/<img([^>]*?)alt="([^"]+)"([^>]*)>/g, '<img$1$3>$2')
      .replace(/<("[^"]*"|'[^']*'|[^'">])*>/g, '')       //タグ削除
      .replace(/[\r\n]+/g, "")                           //改行削除
      .replace(/[\s\t\n]/g, "")                          //空白削除
      .trim();                                           //ダメ押しのTrim(文字列の前後の空白削除)

  }
  return content
}

圧縮の処理は以下になります。

  • imgタグのalt情報の分離
  • HTMLタグの削除
  • 空白の削除

生成AIから投稿文を生成しシートに記載

このスクリプトでは「圧縮済みテキストからPR文を生成していない場合、AOAIのリクエストを生成(createRequestToAOAI)し、Xの投稿文を同時に5件まで生成しシートに追記」を処理しています。工夫として、重複を防ぐために圧縮済みテキストが””の場合のみ圧縮済みテキストを取得しています。また、APIの負荷を考慮してリクエストを最大5件まで制限し、fetchAllでリクエストを同時実行しています

// 通常実行:シートからPR文未作成のものを抽出して生成、最大同時生成5個
function getCreateContent() {
  const sheet = accessSheet()
  const lastRow = sheet.getLastRow()
  // 情報がなければスルー
  if (lastRow == 1) return
  const itemList = sheet.getRange(2, 1, lastRow - 1, 5).getValues()
  const comporessTextList = itemList.map((row) => row[3])
  let nonContentRequestList = []
  let nonConentComporessText = []
  itemList.forEach((row) => {
    const [, , , comporessText, prText] = row
    if (prText == "") {
      nonContentRequestList.push(createRequestToAOAI(comporessText))
      nonConentComporessText.push(comporessText)
    }
  })
  nonContentRequestList = nonContentRequestList.slice(-5)
  nonConentComporessText = nonConentComporessText.slice(-5)

  const responses = UrlFetchApp.fetchAll(nonContentRequestList)
  responses.forEach((response, index) => {
    try {
      const responsJson = JSON.parse(response.getContentText());
      const res = responsJson.choices[0].message.content.trim();

      Logger.log(responsJson)
      const listIndex = comporessTextList.indexOf(nonConentComporessText[index])
      if (listIndex == -1) return
      itemList[listIndex][4] = res
    } catch (e) {
      Logger.log(e)
    }
  })
  sheet.getRange(2, 1, lastRow - 1, 5).setValues(itemList)

}

リクエスト作成の処理に関しては以下になります。GASからのREST APIへの書き込みに関しては公式の情報を参考に構築しています。

// AOAIへのリクエスト作成
function createRequestToAOAI(text) {
  const apiEndpoint = PropertiesService.getScriptProperties().getProperty("AOAI_API_URL");
  const modelName = PropertiesService.getScriptProperties().getProperty("AOAI_API_MODEL");
  const apiVersion = PropertiesService.getScriptProperties().getProperty("AOAI_API_VERSION");
  const apiKey = PropertiesService.getScriptProperties().getProperty("AOAI_API_KEY");
  const apiUrl = `${apiEndpoint}/openai/deployments/${modelName}/chat/completions?api-version=${apiVersion}`;

  const payload = {
    messages: [
      {
        role: "system",
        content: `## 出力要件: 1投稿140文字以内で作成する 記事の要点を簡潔にまとめる 魅力的なフレーズや問いかけを活用する ## 出力例: - AIを活用した最新のマーケティング戦略とは?成功事例を交えて解説.詳しくはこちら - 生成AIが変えるデザインの未来。クリエイター必見のトレンドをチェック ## 注意事項:企業アカウント向けの場合、ブランドトーンを意識する ユーザーの関心を引く表現を心がける クリックを促すアクション(例: 詳しくはこちら)を含める 上記の条件に従い、適切なX向けの投稿文を生成してください。`
      }, {
        role: "user",
        content: text
      }
    ]
  }
  const request = {
    url: apiUrl,
    method: 'post',
    headers: {
      "Content-Type": "application/json",
      "api-key": apiKey
    },
    payload: JSON.stringify(payload),
    muteHttpExceptions: true
  }
  return request
}

使用しているモデルとAPIバージョンは以下になります。

項目バージョン
モデルGPT-4o-mini
AOAI APIバージョン2024-10-21

システムプロンプトとしては、以下のプロンプトを与えています。

## 出力要件: 
1投稿140文字以内で作成する 記事の要点を簡潔にまとめる 魅力的なフレーズや問いかけを活用する 

## 出力例: 
- AIを活用した最新のマーケティング戦略とは?成功事例を交えて解説.詳しくはこちら 
- 生成AIが変えるデザインの未来。クリエイター必見のトレンドをチェック 

## 注意事項:
企業アカウント向けの場合、ブランドトーンを意識する ユーザーの関心を引く表現を心がける クリックを促すアクション(例: 詳しくはこちら)を含める 上記の条件に従い、適切なX向けの投稿文を生成してください。
コラム

こちらのスクリプトは通常実行しています。理由としては、主に二つあります。

一つ目は金銭的な側面です。現状、リクエスト単位でお金が発生しています。自動実行で再リクエストなどを実装した場合、コストが予期せず発生する可能性があります(だいぶ恐怖です)。

二つ目は生成した結果を人間の目でチェックしたいからです。SNS投稿の効率化がこちらのアプリのモチベーションとなっています。SNS投稿は社外の目があるとのことで、生成した結果を自動で投稿するのは、ハルシネーションなどのリスクを含めて断念しました。再生成なども行うことを視野に入れて実装しました。

上記の二点から、手動での実行にしています。

おわり

今回は、Difyで作成したプロトタイプをGASとAzure OpenAI Serviceを利用して業務アプリ化する方法について解説しました。投稿内容の自動生成と管理を実現しつつ、リスク管理の観点から手動での確認プロセスを組み込むことで、効率的かつ安全なワークフローを構築することができました。今後も改善を重ねながら、より使いやすいシステムを目指していきたいと思います。

ご覧いただきありがとうございます! この投稿はお役に立ちましたか?

役に立った 役に立たなかった

0人がこの投稿は役に立ったと言っています。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です