GAS+GCF(Python)でGoogle Drive上のPDFファイルを結合する

GAS（Google App Script）で結合元PDFファイル（複数）および結合後PDFファイルの格納先のGoogleドライブのフォルダIDをGCF（Google Cloud Functions）に送り、GCF側で該当のPDFファイルを結合する方法を説明します。

![GAS+GCF.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1067155/4641039d-c150-c4dc-06a1-5f08dd4d937d.png)


## 背景

弊社では各部署の代表者が集まって行われる会議が週次で開催されているのですが、その週次会議の運営に際し、以下のような作業が毎週発生していました。

1. 事前資料として各部から共有フォルダに格納されたPDFファイルの結合（PDF編集用アプリケーションの使用）
1. 会議後の議事録作成
1. 議事録と1.のファイルをPDFファイルとして結合（PDF編集用アプリケーションの使用）してメールで展開

今までこれらの作業を会議運営者が手動で行っていましたが、毎週決まった作業が発生しており、これらの作業を自動化できないかと考えました。

## 実装方法に至るまでの試行錯誤

### GCF使用に至るまでの経緯

* 弊社ではグループウェアとしてGoogle Workspace（旧G Suite）を導入しており、社内で使用するファイルがGoogle Driveで同期管理されていました。  
そこで、当初は議事録をGoogleドキュメントで作成し（それまではWordで作成）、GoogleドキュメントからGASを呼び出すことで上記背景に記載の機能を実装できないかと考えていました。
* GASでPDFファイルを結合する方法としては[mat_aaaさんの投稿](https://qiita.com/mat_aaa/items/d77320769b5ac837a98b)を参考に実装しようとしましたが、当方式ではPDFのVer1.5以降には対応しておらず、弊社内では主にVer1.7以降のPDFファイルを使用していたことから、やりたいことが実現できませんでした。  
そこでJavaScriptのPDF編集用ライブラリである[pdf-lib](https://pdf-lib.js.org/)をGASで読み込んで実装しようとしましたが、pdf-libをそのままGASに貼り付けた状態では、PDFファイルの生成は成功したのでしが、PDF読み込みがどうしてもうまくいきませんでした。
* そのため、PDF結合部分はGASではなく別サーバーで実装させることとしました。
別サーバーで実装する方法としてはせっかくなので同じGoogleのGCP（Google Cloud Platform）を使用することとしました。  
GCPとしてはPDF結合部分の機能部分だけをできるだけシンプルに実装するために、GCFで実装することとしました。
* PDFファイルの連携方法としてはなるべくファイルのやり取りをなくすために
　* GASからは結合元のファイルが入ったGoogle DriveのフォルダIDと結合後のアップロード先のフォルダIDのみをGCF側に送る
　* GCF側ではフォルダIDを元にGoogle DriveからPDFファイルを取得、結合後のファイルをGoogle Drive上に直接アップロードする
方式としました。

以下で冒頭の処理を実現するためのGAS側、GCF側それぞれの実装方法を示します。


## GAS（Google Apps Script）側の実装

議事録の格納場所が、以下フォルダ構成前提のプログラムになります。
1_連絡会議
├─YYYYMMDD　←会議開催日の日付
│  ├─1_事前資料 ←ここに会議出席者が事前に会議用のファイルを入れておく
│  ├─連絡会議_YYYYMMDD.gdoc　←議事録のGoogleドキュメント、以下のGASもこのドキュメント内に作成する
│  └─9_old
├─YYYYMMDD
…
└─z_このフォルダを事前にコピー_YYYYMMDD
   ├─1_事前資料
   ├─連絡会議_YYYYMMDD.gdoc
   └─9_old

<details><summary>conference_automation.gs（長いので折り畳み）</summary><div>

```javascript
/* 議事録展開時のメール情報 */
const MAIL_TO = 'xxxxxx@ishida-tec.co.jp'; //会議出席者のグループメール
const FS_FOLDER = '\\\\192.168.XXX.XXX\\********'
const MAIL_BODY_HEADER = "各位\n\nお疲れ様です。\n本日の連絡会議の資料を共有致します。\n\n";
const MAIL_BODY_FOOTER = "\n\nご確認をお願い致します。\n\nまた次回会議の議事録は以下になります。\n議題を追加したい方は、会議当日8:15までに議事録に追記をお願いいたします。";
const GCF_URL = "https://asia-northeast1-xxxxxxxxxxxxxxxxxx"; //GCFエンドポイントのURLを記載する

/* ドキュメントの情報を取得　*/
var doc = DocumentApp.getActiveDocument();
var docId = doc.getId();

/* 議事録のメニューバーにGAS実行のためのメニューを表示 */
function onOpen() {
  var ui = DocumentApp.getUi();
  ui.createMenu('スクリプト')
      .addItem('①事前資料のPDF統合', 'integratePdfFiles')
      .addItem('②議事録展開＆次回会議用フォルダ作成', 'createNextConfFolderAndSendMinutes')
      .addToUi();
}

/* Google Cloud Functionsの関数を呼び出す */
function callPdfMergeFunction(src_pdf_folder_id, dst_pdf_folder_id, merged_file_name) {
  Logger.log("src folder id:" + src_pdf_folder_id);
  Logger.log("dst_pdf_folder_id:" + dst_pdf_folder_id);

  //送信パラメータを組み立てる
  var payload = {
      "download_folder_id": src_pdf_folder_id,
      "upload_folder_id" : dst_pdf_folder_id,
      "file_name" : merged_file_name,
  };
  
  //POSTで関数を実行する
  var response = UrlFetchApp.fetch(GCF_URL, {
    method: 'POST',
    contentType: "application/json",
    payload : JSON.stringify(payload),
    muteHttpExceptions: true
  });
  
  //サーバーレスポンスコードを取得する
  var resCode = response.getResponseCode();
 
  //リターンされて来たバイナリデータをドライブに作成する
  if (resCode === 200) {
    let createdFileId = response.getContentText();

    //終了メッセージ
    Logger.log("createdFileId:" + createdFileId);
    return createdFileId;

  }else{
    //エラーメッセージ
    Logger.log(resCode + "エラーが発生しました。");
    Logger.log(response.getContentText());
    return resCode;
  }
}

/* 事前フォルダに格納されたPDFファイルを統合して１つのファイルとし、会議フォルダに格納する */
function integratePdfFiles() {
  
  let conf_folder = getConfFolder();

  // 1_事前資料のフォルダーを取得
  let src_pdf_folder_id = conf_folder.getFoldersByName("1_事前資料").next().getId();
  let dst_pdf_folder_id = conf_folder.getId();

  callPdfMergeFunction(src_pdf_folder_id, 
    dst_pdf_folder_id, 
    '会議資料_' + conf_folder.getName() + '.pdf');
}

/* 議事録を含めて全ての資料を統合して１つのPDFファイルとする。そして次回の会議のフォルダを作成し、議事録をメールで展開する */
function createNextConfFolderAndSendMinutes() {
  
  let conf_folder = getConfFolder();

  // 次回開催日の取得
  let ui = DocumentApp.getUi();
  let next_conf_date_str = ui.prompt("次回の開催日を入力ください（YYYYMMDD）").getResponseText();

  // 議事録のPDFを作成
  let pdf_minutes_file = createMinutesPdf();
  
  // 議事録を含めた全体PDFを作成
  let integrated_pdf_file_id = callPdfMergeFunction(conf_folder.getId(), conf_folder.getId(), '連絡会議_' + conf_folder.getName() + '.pdf');
  let integrated_pdf_file = DriveApp.getFileById(integrated_pdf_file_id);

  // 次週フォルダの作成
  let next_conf_mintes = createNextConferenceFolder(next_conf_date_str);

  // 議事録をメール送付
  sendMinutes(integrated_pdf_file, next_conf_mintes);

  // 議事録をoldフォルダーに移動
  let old_folder = conf_folder.getFoldersByName('9_old').next();
  let doc_file = DriveApp.getFileById(docId);
  doc_file.moveTo(old_folder);
  pdf_minutes_file.moveTo(old_folder);

  // 議事資料をoldフォルダーに移動
  let integrated_attached_file = conf_folder.getFilesByName("会議資料_" + conf_folder.getName() + '.pdf').next();
  integrated_attached_file.moveTo(old_folder);
}

function getConfFolder() {
  return DriveApp.getFileById(docId).getParents().next();
}

function createMinutesPdf() {
  let token = ScriptApp.getOAuthToken();
  let url = "https://docs.google.com/document/d/" + doc.getId() + "/export?format=pdf&portrait=true&size=A4";
  
  let pdf_minutes = UrlFetchApp.fetch(url, {headers: {'Authorization': 'Bearer ' +  token}}).getBlob().setName("0_" + doc.getName() +".pdf");
  
  let conf_folder = getConfFolder();
  let pdf_minutes_file = conf_folder.createFile(pdf_minutes);

  return pdf_minutes_file;
}

//議事録のメール展開用の関数
function sendMinutes(integrated_pdf_file, next_conf_minutes) {
  let conf_folder = getConfFolder();
  let title = "【共有】" + conf_folder.getName() + "分_連絡会議時資料";
  let mail_body = 
      MAIL_BODY_HEADER
       + "【格納先】<\"" + FS_FOLDER + conf_folder.getName() + "\">\n"
       + "【ファイル名】" + integrated_pdf_file.getName() + "\n"
       + "【GoogleDrive格納先】" + integrated_pdf_file.getUrl()
       + MAIL_BODY_FOOTER
       + next_conf_minutes.getUrl();

  let html_mail_body = mail_body.replace(/\n/g, "<br>");

  GmailApp.sendEmail(MAIL_TO, title, mail_body, {htmlBody: html_mail_body});
}

function createNextConferenceFolder(next_conf_date_str) {

  let conf_folder = getConfFolder();
  let parent_folder = conf_folder.getParents().next();
  let template_folder = parent_folder.getFoldersByName('z_このフォルダを事前にコピー_YYYYMMDD').next();
  let next_conf_folder = parent_folder.createFolder(next_conf_date_str);

  copyFolderFiles(template_folder, next_conf_folder);

  let next_conf_files = next_conf_folder.getFiles();
  while(next_conf_files.hasNext()) {
    let file = next_conf_files.next();
    if(file.getName() == '連絡会議議事_YYYYMMDD') {
      file.setName('連絡会議議事_' + next_conf_date_str);
      return file;
    }
  }
}


/*
  フォルダー内のファイルを含めて全てコピーする。以下記事から拝借。
  https://qiita.com/matsuhandy/items/9b9c7bcfbee646a16222 
*/
function copyFolderFiles(srcFolder, newFolder){
  var srcFiles = srcFolder.getFiles();//フォルダ内ファイルをゲット
  while(srcFiles.hasNext()) {
    var srcFile = srcFiles.next();
    Logger.log(srcFile.getName());
    srcFile.makeCopy(srcFile.getName(), newFolder);
  }
  var srcFolders = srcFolder.getFolders();//フォルダ内フォルダをゲット
  while(srcFolders.hasNext()) {
    var nextSrcFolder = srcFolders.next();
    Logger.log(nextSrcFolder.getName());
    var nextNewFolder = newFolder.createFolder(nextSrcFolder.getName());
    copyFolderFiles(nextSrcFolder, nextNewFolder);//再帰
  }
}
```

</div></details>


## GCF（Google Cloud Functios）側の実装
ランタイム : Python 3.7 

<details><summary>main.py（長いので折り畳み）</summary><div>

```python
# -*- coding: utf-8 -*-
import pickle
import io
import fitz

# pip install --upgrade google-api-python-client google-auth-httplib2 google-auth-oauthlib
from googleapiclient.discovery import build
from googleapiclient.http import MediaIoBaseDownload
from googleapiclient.http import MediaIoBaseUpload
from oauth2client.client import GoogleCredentials

SCOPES = ['https://www.googleapis.com/auth/drive']

def pdf_merge_from_drive(request):
    request_json = request.get_json()
    DOWNLOAD_FOLDER_ID = request_json['download_folder_id']
    UPLOAD_FOLDER_ID = request_json['upload_folder_id']
    MERGED_FILE_NAME = request_json['file_name']

    # Cloud Functions環境から認証情報を取得する
    creds = GoogleCredentials.get_application_default()

    drive = build('drive', 'v3', credentials=creds, cache_discovery=False)
    if not drive: print('Drive auth failed.')

    # File list
    files = None
    query = '(' + '"' + DOWNLOAD_FOLDER_ID + '" in parents' + ')'
    query += ' and (name contains ".pdf")  and (trashed = false) and (mimeType != "application/vnd.google-apps.folder")'
    print("query:::" + query)

    # Call the Drive v3 API
    results = drive.files().list(
        pageSize=100, 
        fields='nextPageToken, files(id, name)',
        q=query,
        orderBy='name'
        ).execute()

    files = results.get('files', [])
    if not files: print('No files found.')

    # Download
    pdfBytesList = []
    if files:
        for file in files:
            print("downloaded file name:" + file['name'])
            request = drive.files().get_media(fileId=file['id'])
            fh = io.BytesIO()
            downloader = MediaIoBaseDownload(fh, request)
            pdfBytesList.append(fh)
            done = False
            while not done:
                _, done = downloader.next_chunk()
    
    # 結合先のPDFを新規作成
    doc = fitz.open()
    
    # 結合元PDFを開く
    for fileBytes in pdfBytesList:
        infile = fitz.open("pdf", fileBytes)
    
        # 結合先PDFと結合元PDFのページ番号を指定
        doc_lastPage = len(doc)
        infile_lastPage = len(infile)
        doc.insertPDF(infile, from_page=0, to_page=infile_lastPage, start_at=doc_lastPage, rotate=0)
    
        # 結合元PDFを閉じる
        infile.close()
    
    # 結合先PDFを保存する
    docBytes = doc.tobytes()

    file_metadata = {'name': MERGED_FILE_NAME,
                    'parents': [UPLOAD_FOLDER_ID]}
    media = MediaIoBaseUpload(io.BytesIO(docBytes), mimetype='application/pdf')
    file = drive.files().create(body=file_metadata,
                                        media_body=media,
                                        fields='id').execute()
    print('uploaed File ID: %s' % file.get('id'))

    return file.get('id')
```
</div></details>

<details><summary>requirements.txt</summary><div>

```
# Function dependencies, for example:
# package>=version
pymupdf
google-api-python-client
google-auth-httplib2
google-auth-oauthlib
oauth2client
```

</div></details>

## 課題
* GCF側のリクエストについて、同じGoogleドメイン内のリクエストのみ受け付けたかったのですが、方法がわからなかったため、URL宛てに正しくリクエストが来たら処理が実行されます。
* GoogleDrive⇔GCF間で処理が完結するため大きな問題はないと思いますが、できればリクエストの制限をかけたい…。


## デモ
1. 上記GASを実装したGoogleドキュメントを開くと、メニューバーに「スクリプト」というメニューが出てきます。
![demo1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1067155/bac17e62-d867-ee61-f84e-733f14c31108.png)
1. 「スクリプト」→「①事前資料のPDF結合」というメニューをクリックすると、当議事録が格納されたフォルダと同じフォルダ内にある、「1_事前資料」フォルダ内のPDFファイルが結合され、1つのPDFファイルとなり、議事録と同フォルダに格納されます。
1. 議事録作成後に「スクリプト」→「②議事録展開＆次回会議用フォルダ作成」というメニューをクリックすると、議事録のGoogleドキュメントがPDF化され、①実行時のPDFと結合された状態で会議参加者にメールが届きます。
また、それと同時に次週の会議用のフォルダも作成します。祝日などで開催日がずれる場合もあるため、次週の会議日は議事録展開者がフォームに手打ちするようにしています。




## 終わりに
* 1週間に2リクエストしかしないため、GCPの無料枠で使い続けられる想定です。
* GCFの認証系の処理が情報が少なく苦労しました。。
（AWS Lambdaであれば情報も多く、もっと楽にできたかもしれない）
* 似たような自動化を行おうとしている方の一助になれば幸いです。


## 参照
* https://qiita.com/mat_aaa/items/d77320769b5ac837a98b
* https://qiita.com/matsuhandy/items/9b9c7bcfbee646a16222