見出し画像

社内マニュアル作成システムのDriveAPI+GeminiAPIでAI自動取込を強化(セキュリティ対策も)

4月24日の記事で、GASとGeminiを使って「AI解析マニュアル作成・共有システム」を正味6時間で開発したお話をしました。
PDFからAIがマニュアルを自動生成し、タグも自動抽出してくれるこのシステム、社内でも「これなら使えそう!」とレビュー頂いた各部署の方々にはまずまずの反響でした。

ここからは本格運用に向けて更なる機能改善とセキュリティ対策です。

さらに使いやすく!マニュアル作成機能の強化

1.Drive API × Gemini API によるPDF解析機能
 (T◯◯◯ Bizからの移行をスムーズに)

前回のバージョンでは、Gemini APIを用いてPDFのテキスト情報を抽出していましたが、「PDF内に含まれる画像」をそのまま抽出して各ステップに割り当てることはGemini API単体では困難でした。

そこで今回は、Drive APIのドキュメント変換機能とGemini APIを組み合わせる(`analyzePDFWithDocs`)アプローチに変更しました。
これにより、既存のSaaSからエクスポートしたPDFマニュアルを読み込ませるだけで、テキストだけでなくPDF内の画像も自動で抽出し、各ステップに適切に配置することが可能になりました。抽出した画像はDrive APIを利用してGoogleドライブ内の専用フォルダに保存され、URLとしてマニュアルに紐づけられます。

※以下①から⑤の流れのあとにGASコードあり。
※AIが異なるステップに画像を入れてしまった場合でもドラッグ&ドロップで画像の入れ替えが出来るようにしています。

①フロントエンド(ブラウザ)側の処理

・ユーザーが画面上でPDFファイルを選択します。
・JavaScriptの FileReader がPDFファイルを読み込み、
 Base64形式の文字列(テキスト化されたデータ)に変換します。
・そのBase64データを、セッショントークンと共にバックエンド
 (GAS)の analyzePDFWithDocs 関数へ送信します。

②バックエンド側の処理 A:画像抽出(Drive API)

テキスト解析モデル(Geminiなど)は画像の「抽出」はできないため、Googleドライブの機能を利用して画像を取り出します。

  • ドキュメント変換(OCR): 送られてきたBase64データをPDFファイルとして認識させ、Google Drive API(Drive.Files.create または insert)を使って、一時的な「Googleドキュメント」として変換・保存します。 この際、Googleの強力なOCR(光学文字認識)機能により、PDF内のレイアウトがドキュメント上の要素(段落やインライン画像)に分解されます。

  • 画像の取得とフィルタリング: 変換された一時ドキュメントをDocumentApp.openById で開き、ドキュメント内のすべての画像(getImages())を上から順に取得します。 この時、幅や高さが100px未満の小さな画像(会社ロゴやアイコンなど)は除外するフィルタリングを行います。

  • Googleドライブへの保存: 抽出した画像をPNG形式(Blob)として指定の画像保存フォルダ(TMB_App_Images)に保存し、誰でも閲覧可能な公開リンク(URL)を生成して配列(extractedImages)に一時保管します。

  • 一時ファイルの削除: 画像抽出が終わった一時ドキュメントは、ゴミ箱へ移動して削除します。

③バックエンド側の処理 B:テキスト構造化(Gemini API)

次に、PDFのテキスト内容をAIに解析させて、「タイトル」「概要」「各ステップ」というマニュアルの構造に変換します。

  • Gemini 2.5 Proへのリクエスト: PDFのBase64データそのものと、以下のプロンプト(指示)をセットにして、Gemini APIへ送信します。

  • 「マニュアルPDFを解析し、マニュアルの段階(3段階や5段階など)を正確に把握した上で、JSON形式で返してください。 { "title": "", "description": "", "stepCount": 3, "steps": [{"description": ""}] }」

  • JSONデータの取得: GeminiがPDFのテキストを読み解き、指定したJSONフォーマットで整理された文字列を返してきます。正規表現を使ってJSON部分だけを抜き出し、JavaScriptのオブジェクト(manualData)に変換します。

④バックエンド側の処理 C:画像とテキストの合体(マージ処理)

抽出した「画像の配列(Drive API)」と、「構造化されたテキスト(Gemini API)」をくっつけます。
T〇〇〇 Biz形式のPDFは「表紙の大きな画像が1枚あり、その後に各ステップの画像が続く」という構造になることが多いです。そのため、以下のような「後ろ合わせ」のロジックで割り当てを行います。

  • Geminiが抽出したステップ数(stepCount)と、DriveAPIが抽出した画像数(imageCount)を比較します。

  • ズレの検知: もし画像数とステップ数が合わない(かつ表紙+ステップ数でもない)場合は、Geminiが作った「概要(description)」の先頭に「⚠️画像割り当ての確認」という警告メッセージを自動で追記します。

  • 画像の割り当て:

    • 画像がステップ数+1枚の場合: 最初の1枚を「表紙画像(coverImage)」とし、残りを各ステップ(steps[i].imageUrl)に順番に割り当てます。

    • 画像がステップ数より多い場合: PDFの先頭付近にある不要な画像(除外しきれなかったロゴなど)を無視するため、後ろからステップ数分だけを取得してステップに割り当てます。さらにその直前の1枚を表紙画像とします。

    • 画像が少ない場合: 最初から順番にステップに割り当て、足りないステップは「画像なし」とします。

⑤フロントエンドへの返却

完成したマニュアルデータ(タイトル、概要、表紙画像URL、各ステップの説明と画像URL)をフロントエンドに返却します。 フロントエンドは受け取ったデータを元に、エディタ画面の入力欄や画像プレビュー枠に自動で値をセットします。

※以下はバックエンド側のコード

// =========================================
// Gemini API + Drive API を組み合わせたハイブリッドPDF解析
// =========================================
function analyzePDFWithDocs(token, base64PdfData) {
  try {
    // 【セキュリティ】認証チェック
    if (!verifyToken(token)) return { success: false, error: '認証エラー:不正なアクセスです。' };

    const base64Data = base64PdfData.split(';base64,')[1];
    const blob = Utilities.newBlob(Utilities.base64Decode(base64Data), 'application/pdf', 'temp.pdf');
    
    // =========================================
    // 1. Drive APIでドキュメント化して画像だけを抽出
    // =========================================
    const props = PropertiesService.getScriptProperties();
    let folderId = props.getProperty('TMB_App_Images');
    if (!folderId) {
      const folders = DriveApp.getFoldersByName('TMB_App_Images');
      let folder = folders.hasNext() ? folders.next() : DriveApp.createFolder('TMB_App_Images');
      folderId = folder.getId();
      props.setProperty('TMB_App_Images', folderId);
    }
    const outputFolder = DriveApp.getFolderById(folderId);

    let extractedImages = [];
    try {
      if (typeof Drive === 'undefined') {
         throw new Error("GASエディタ左側の「サービス」から「Drive API」を追加してください。");
      }
      
      const resource = {
        name: 'temp_pdf_to_doc',
        title: 'temp_pdf_to_doc',
        mimeType: MimeType.GOOGLE_DOCS
      };
      
      // PDFをGoogleドキュメントとして一時的に変換・保存(OCR処理)
      let tempDoc;
      if (Drive.Files.create) {
         tempDoc = Drive.Files.create(resource, blob);
      } else {
         tempDoc = Drive.Files.insert(resource, blob, {ocr: true});
      }
      const tempDocId = tempDoc.id;
      
      // 変換されたドキュメントを開き、中の画像を取得
      const doc = DocumentApp.openById(tempDocId);
      const images = doc.getBody().getImages();
      
      for (let i = 0; i < images.length; i++) {
        const inlineImg = images[i];
        // 100px未満の小さな画像(会社ロゴやアイコンなど)は除外
        if (inlineImg.getWidth() < 100 || inlineImg.getHeight() < 100) {
           continue; 
        }

        const imgBlob = inlineImg.getBlob();
        const imgName = `extracted_img_${Date.now()}_${i}.png`;
        imgBlob.setName(imgName);
        
        // 抽出した画像をGoogleドライブに保存してURLを取得
        const savedFile = outputFolder.createFile(imgBlob);
        savedFile.setSharing(DriveApp.Access.ANYONE_WITH_LINK, DriveApp.Permission.VIEW);
        extractedImages.push(savedFile.getDownloadUrl().replace('&export=download', ''));
      }
      
      // 用済みのテンポラリドキュメントをゴミ箱へ
      DriveApp.getFileById(tempDocId).setTrashed(true);
    } catch (e) {
      console.log("画像抽出でエラーが発生しましたが、テキスト解析を続行します: " + e.message);
    }

    // =========================================
    // 2. Gemini APIでテキストを解析・構成抽出
    // =========================================
    const apiKey = props.getProperty('GEMINI_API_KEY');
    if (!apiKey) return { success: false, error: "APIキー未設定" };
    
    // PDFのデータと、マニュアルの段階をJSONで返すように指示するプロンプトを送信
    const payload = {
      "contents": [{ "parts": [
        { "text": "マニュアルPDFを解析し、マニュアルの段階(3段階や5段階など)を正確に把握した上で、JSON形式で返してください。\n\n{ \"title\": \"\", \"description\": \"\", \"stepCount\": 3, \"steps\": [{\"description\": \"\"}] }" },
        { "inline_data": { "mime_type": "application/pdf", "data": base64Data } }
      ]}]
    };
    
    const apiUrl = `https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-pro:generateContent?key=${apiKey}`;
    const response = UrlFetchApp.fetch(apiUrl, {
      "method": "post", "contentType": "application/json", "payload": JSON.stringify(payload), "muteHttpExceptions": true
    });
    
    const result = JSON.parse(response.getContentText());
    if (result.error) return { success: false, error: "Gemini APIエラー: " + result.error.message };

    // 結果からJSON部分だけを抜き出してオブジェクトに変換
    const responseText = result.candidates[0].content.parts[0].text;
    const jsonMatch = responseText.match(/\{[\s\S]*\}/);
    
    if(!jsonMatch) return { success: false, error: "JSONデータの抽出に失敗しました。" };
    
    let manualData = JSON.parse(jsonMatch[0]);

    // =========================================
    // 3. 画像数と段階数のチェック&割り当て(マージ処理)
    // =========================================
    if (manualData.steps && Array.isArray(manualData.steps)) {
       const stepCount = manualData.steps.length;
       const imageCount = extractedImages.length;
       
       // 解析したステップ数と抽出できた画像数が一致しない場合、警告文を追加
       if (stepCount > 0 && imageCount !== stepCount && imageCount !== (stepCount + 1)) {
          const warningMsg = `\n\n⚠️【システム通知: 画像割り当ての確認】\nマニュアルの段階数(${stepCount}段階)に対し、PDFから抽出できた有効な画像が ${imageCount} 枚でした。画像がズレている可能性があるため、各ステップの画像を必ずご確認ください。\n`;
          manualData.description = warningMsg + (manualData.description || "");
       }

       let stepImageStartIndex = 0;
       
       if (imageCount === stepCount + 1) {
          // 画像が1枚多い場合は、最初の1枚を表紙に設定
          manualData.coverImage = extractedImages[0];
          stepImageStartIndex = 1;
       } else if (imageCount >= stepCount) {
          // 画像が多すぎる場合は「後ろから」ステップ数分を割り当てる(先頭の不要なロゴ等を弾くため)
          stepImageStartIndex = imageCount - stepCount;
          if (stepImageStartIndex > 0) {
             manualData.coverImage = extractedImages[stepImageStartIndex - 1]; // その直前を表紙候補にする
          }
       } else {
          // 画像が少ない場合は最初から順番に割り当て
          stepImageStartIndex = 0;
       }
       
       // JSONの各ステップデータに画像URLを挿入
       for(let i = 0; i < stepCount; i++){
          const imgIndex = stepImageStartIndex + i;
          if (imgIndex < imageCount) {
             manualData.steps[i].imageUrl = extractedImages[imgIndex] || "";
          } else {
             manualData.steps[i].imageUrl = "";
          }
       }
    }
    
    // 完成したデータをフロントエンドへ返す
    return { success: true, data: manualData };
  } catch (e) { 
    return { success: false, error: "解析エラー: " + e.toString() }; 
  }
}

これにより、「SaaSに蓄積された既存マニュアルの移行作業」が、文字通りアップロードするだけで完了するレベルにまでスムーズになりました。

2.外部イラストサイトからのスムーズな画像引用

マニュアルをわかりやすくするには画像が不可欠ですが、わざわざ探して保存、更にアップロードするのは面倒ですよね。そこで、エディタ画面のサイドパネルに別サイトを表示可能にし、検索できる機能を実装しました。

見つけた画像や外部サイトの画像を右クリック→アドレスをコピー→画像選択時にアドレス貼り付けでマニュアルの表紙や各ステップに配置できる機能とともに、画像をも搭載。ステップ間の画像の入れ替えもドラッグ操作で直感的に行えるようになり、作成スピードが飛躍的に向上しました。
※フリー素材かつ商用利用可のものを使用しましょう。

3.URL自動リンク機能の搭載

前述のXSS対策と合わせて実装した機能ですが、マニュアル内に記述されたURLが、閲覧画面で自動的にクリッカブルリンクになるようにしました。これにより、外部サイトや社内ポータルの参照がスムーズに行えます。

まず、セキュリティについては手を付けていなかったこともあって、
「パスワードそのまま保存してるけど、セキュリティ的に大丈夫?」
「他の人のマニュアル、勝手に消せちゃわない?」
「悪意のある人がスプレッドシート壊せるんじゃない?」

Geminiにチェックしてもらったところ「とりあえず動くけどセキュリティはガバガバ」な状態なので、Geminiと壁打ちしながらセキュリティ対策を施し、さらにSaaSで不便だなと感じていたマニュアル作成機能の部分も強化した記録を綴ります。

システムを守る7つのセキュリティ対策

そして今回のアップデートで、以下のセキュリティ対策を実装しました。

1. パスワードのソルト付きハッシュ化(Pass-the-Hash対策)
2. バックエンドのアクセス制御(API認証)
3. ログアウト時のセッション無効化
4. ブルートフォース攻撃対策
5. 他人のマニュアルの編集・削除防止(IDOR対策)
6. 蓄積型クロスサイトスクリプティング (Stored XSS) 対策
7. スプレッドシート・インジェクション対策

これらの対策について、実際のコード(GAS)を交えて解説します。

5. パスワードのソルト付きハッシュ化

初期バージョンではパスワードを平文で保存していましたが、万が一スプレッドシートが見られた場合のリスクがありました。そこで、フロントエンドからは平文で送信し、バックエンド側でスクリプトプロパティに保存した「ソルト」を付与してSHA-256でハッシュ化するセキュアな設計に変更しました。

```javascript
// =========================================
// セキュリティ: SHA-256 ソルト付きハッシュ化
// =========================================
function getSalt() { 
 const props = PropertiesService.getScriptProperties(); 
 let salt = props.getProperty('AUTH_SALT'); 
 if (!salt) { 
   salt = Utilities.getUuid(); 
   props.setProperty('AUTH_SALT', salt); 
 } 
 return salt; 
}

function computeSha256Hash(text) { 
 const salt = getSalt(); 
 const rawHash = Utilities.computeDigest(Utilities.DigestAlgorithm.SHA_256, text + salt, Utilities.Charset.UTF_8); 
 let txtHash = ''; 
 for (let i = 0; i < rawHash.length; i++) { 
   let hashVal = rawHash[i]; 
   if (hashVal < 0) hashVal += 256; 
   if (hashVal.toString(16).length == 1) txtHash += '0'; 
   txtHash += hashVal.toString(16); 
 } 
 return txtHash; 
}
```

6. セッショントークンによるAPI認証と無効化

無認証での不正なAPIアクセスを防ぐため、ログイン時にトークンを発行し、`CacheService`を利用して管理するようにしました。また、ログアウト時には確実にトークンを破棄します。

```javascript
// ========================================= 
// セキュリティ: APIトークン認証 & 無効化 
// ========================================= 
function verifyToken(token) { 
 if (!token) return null; 
 const userName = CacheService.getScriptCache().get('TOKEN_' + token); 
 return userName || null; 
}

// バックエンドでのトークン無効化処理
function logoutUser(token) { 
 if (token) { 
   CacheService.getScriptCache().remove('TOKEN_' + token); 
 } 
 return { success: true }; 
}
```

データ操作関数(マニュアルの保存や削除など)の冒頭で必ず `verifyToken` を呼び出すことで、不正なリクエストをブロックします。さらに、ログイン処理には「5回連続でログインに失敗したユーザーを5分間ロックアウトする」ブルートフォース攻撃対策も実装しました。

7. 他人のマニュアルの編集・削除防止(IDOR対策)

マニュアルの所有者、またはシステム管理者でなければマニュアルを削除できないように、バックエンド側で厳密に判定する認可ロジックを追加しました。

```javascript
function deleteManual(token, manualId) { 
 const userName = verifyToken(token); 
 if (!userName) return { success: false, error: '認証エラー:権限がありません。' };

 const ss = SpreadsheetApp.getActiveSpreadsheet(); 
 const mSheet = ss.getSheetByName('Manuals'); 
 const mData = mSheet.getDataRange().getValues();

 let isOwner = false; 
 for (let i = mData.length - 1; i >= 1; i--) { 
   if (mData[i] === manualId) { 
     // 所有権の確認(認可)- adminは無条件許可 
     if (String(mData[i][10]) === userName || userName === '管理者') { 
       isOwner = true; 
       mSheet.deleteRow(i + 1); 
     } 
     break; 
   } 
 }
 if (!isOwner) { 
   return { success: false, error: '権限エラー:他人のマニュアルは削除できません。' }; 
 }
}
```

8. スプレッドシート・インジェクション&XSS対策

ユーザー入力に `=` や `+` などの記号が含まれていると、スプレッドシート上で関数として実行されてしまう脆弱性(スプレッドシート・インジェクション)の対策も行いました。

```javascript
// ========================================= 
// セキュリティ: スプレッドシート・インジェクション対策 
// ========================================= 
function sanitizeForSpreadsheet(val) { 
 if (typeof val === 'string' && /^[=+-@]/.test(val)) { 
   return "'" + val; 
 } 
 return val; 
}
```

さらにフロントエンドでは、悪意のあるスクリプト実行を防ぐため、HTMLの表示時に `escapeHtml` 関数を用いてテキストとしてエスケープします。その上で、URLのみを安全に `<a>` タグに変換する機能を実装し、安全性と利便性を両立させました。

まとめ

前回のシステム完成から、実運用に向けたセキュリティ強化と機能追加。
Geminiに問いかけることで、実運用に耐えうるシステムを構築することができました。
さらに、Drive APIとGemini APIを掛け合わせることで、既存SaaSからの移行という最大の障壁となろう部分もクリアできました。

「とりあえず動く」から「安全に使える」、そして「現場が喜んで使う」へ。
また色々と試してみたいと思いますが、そのためのインプットと神ツールを学ぶため、5月8日のまじんさんハンズオンセミナーで勉強したいと思います!


いいなと思ったら応援しよう!