Google App Script: googleドキュメントを一つに合体させたい

印刷とかじゃなく、AIに食わせるためドキュメントを一つにしたい

作成日:2025-10-17, 更新日:2025-10-17

概要

NoteBookLMってヤツにGoogleドキュメントを食わせて、自分用のナレッジにしたい。でも問題があった

  • 食わせる資料は任意のタイミングに更新。都度、NoteBookLMに食わせ直すのが面倒
  • Googleドキュメントなら手動になるけど、「同期」のボタンを押せば再読み込みしてくれる
  • しかし、Googleドキュメントがいっぱいあると追加が面倒。再読み込みも面倒
  • だからといって一つのGoogleドキュメントだと記載するときが面倒
  • 一つのGoogleドキュメントで、ドキュメントタブを設定するのは悪くないけど階層が深く出来ないので途中で行き詰まる

解決案

複数のGoogleドキュメントを一つにまとめて、それをNoteBookLMに食わせてやれば良い...と思う

対応: Google App ScriptでGoogleドキュメントを一つにまとめる

  1. スクリプトを用意
  2. 初回のみ「実行」(※権限付与のため)

スクリプトを用意

  • 条件
    • 対象フォルダにあるサブフォルダごとにまとめたい → サブフォルダ名でファイル出力
      • 対象フォルダをまとめたい → 対象フォルダ名でファイル出力
    • .bakは対象外
    • 対象ファイルに更新があるときだけ処理実行
  • 手順: combineAllSubfolders()の「folderSetBySubfolder」に値をセット(※フォルダIDは「https://drive.google.com/drive/u/0/folders/1-9T9nxxxx」の「1-9T9nxxxx」部分)
  • 実行対象: combineAllSubfolders()
function combineAllSubfolders() {

  // ====================
  // 対象内の各サブフォルダごとに配下の全ファイルをまとめて一つのファイルにする
  const folderSetBySubfolder = { // ソースになるフォルダIDと出力先になるフォルダIDのペア
    "ソースになるフォルダID": "出力先のフォルダID",
    "ソースになるフォルダID:2つめ": "出力先のフォルダID:2つめ",
  };
  for (const sourceRootID in folderSetBySubfolder) {
    const targetFolderID = folderSetBySubfolder[sourceRootID];
    combineAllSubfolders_(sourceRootID, targetFolderID);
  }

  // // ====================
  // // 特定のフォルダ配下のみ対象にするとき。自身のフォルダ名がファイル名になる
  // const folderSetBySelf = { // ソースになるフォルダIDと出力先になるフォルダIDのペア
  //   "xxx": "xxx",
  // };
  // for (const sourceFolderID in folderSetBySelf) {
  //   const targetFolderID = folderSetBySelf[sourceFolderID];
  //   const folderToCombine = DriveApp.getFolderById(sourceFolderID);
  //   combineGoogleDocs(folderToCombine, targetFolderID);
  // }
}

function combineAllSubfolders_(iptDirID, optDirID) {
  const root = DriveApp.getFolderById(iptDirID);
  const subs = root.getFolders();
  const targetFolder = DriveApp.getFolderById(optDirID);
  while (subs.hasNext()) {
    const sub = subs.next();

    const subFolderName = sub.getName();
    if (subFolderName.endsWith(".bak")) {
      Logger.log(`Skipping subfolder (ends with .bak): ${subFolderName}`);
      continue;
    }

    if (!shouldCombine(sub, subFolderName, targetFolder)) {
      Logger.log(`Skipping (no change): ${subFolderName}`);
      continue;
    }

    combineGoogleDocs(sub, optDirID);
  }
}

function combineGoogleDocs(subFolder, tragetFolderId) {
  const MAX_OPS_BEFORE_SAVE = 250;
  let opCount = 0;

  const targetFolder = DriveApp.getFolderById(tragetFolderId);
  const outName = subFolder.getName();
  const targetDocFile = getOrCreateTargetDocFile(targetFolder, outName);
  let TARGET_DOC_ID = targetDocFile.getId();

  let targetDoc  = DocumentApp.openById(TARGET_DOC_ID);
  let targetBody = targetDoc.getBody();
  safeClearBody(targetBody);

  const entries = listDocsRecursively(subFolder, ""); // [{id, name, path}]
  entries.sort((a, b) => {
    const pa = a.path + "/" + a.name;
    const pb = b.path + "/" + b.name;
    return pa.localeCompare(pb);
  });

  let docCount = 0;
  for (const { id, name, path } of entries) {
    const srcDoc  = DocumentApp.openById(id);

    if (name.endsWith(".bak")) {
      Logger.log(`Skipping document (ends with .bak): ${path ? path + "/" : ""}${name}`);
      continue;
    }

    const title = `--- ${path ? path + "/" : ""}${name} ---`;

    if (docCount === 0) {
      const p0 = targetBody.getChild(0).asParagraph();
      p0.setText(title);
      p0.setHeading(DocumentApp.ParagraphHeading.HEADING1);
    } else {
      targetBody.appendHorizontalRule(); opCount++;
      targetBody.appendPageBreak();      opCount++;
      const h1 = targetBody.appendParagraph(title); opCount++;
      h1.setHeading(DocumentApp.ParagraphHeading.HEADING1); opCount++;
      maybeSave();
    }

    targetBody = appendContainerChildrenWithBatch(targetBody, srcDoc.getBody());
    [targetDoc, targetBody] = hardSave(targetDoc, targetBody);
    docCount++;
  }

  [targetDoc, targetBody] = hardSave(targetDoc, targetBody);
  Logger.log(`✅ ${outName}: ${docCount} 個のドキュメントを結合しました。`);

  // ====== ヘルパ ======

  function getOrCreateTargetDocFile(folder, name) {
    const files = folder.getFiles();
    while (files.hasNext()) {
      const f = files.next();
      try {
        if (f.getName() === name && f.getMimeType() === MimeType.GOOGLE_DOCS) {
          return f;
        }
      } catch (e) {
        // アクセス等で例外が出てもスキップ
      }
    }
    const newDoc = DocumentApp.create(name);
    const newFile = DriveApp.getFileById(newDoc.getId());
    folder.addFile(newFile);
    try { DriveApp.getRootFolder().removeFile(newFile); } catch (_) {}
    return newFile;
  }

  function appendContainerChildrenWithBatch(body, container) {
    for (let i = 0; i < container.getNumChildren(); i++) {
      const el = container.getChild(i);
      const t  = el.getType();
      try {
        switch (t) {
          case DocumentApp.ElementType.PARAGRAPH:
            body.appendParagraph(el.asParagraph().copy()); opCount += 1; break;
          case DocumentApp.ElementType.LIST_ITEM:
            body.appendListItem(el.asListItem().copy());   opCount += 1; break;
          case DocumentApp.ElementType.TABLE:
            body.appendTable(el.asTable().copy());         opCount += 1; break;
          case DocumentApp.ElementType.HORIZONTAL_RULE:
            body.appendHorizontalRule();                   opCount += 1; break;
          case DocumentApp.ElementType.PAGE_BREAK:
            body.appendPageBreak();                        opCount += 1; break;
          default: {
            let ok = false;
            try { body.appendElement(el.copy()); ok = true; opCount += 1; } catch (_) {}
            if (!ok) insertAsCodeLikeParagraph(body, tryGetText(el), t);
          }
        }
      } catch (err) {
        Logger.log(`Copy failed for ${t}: ${err}`);
        insertAsCodeLikeParagraph(body, tryGetText(el), t);
      }
      const oldTargetBody = targetBody;
      maybeSave();
      if (oldTargetBody !== targetBody) {
          body = targetBody;
      }
    }
    return body;
  }

  function insertAsCodeLikeParagraph(body, text, typeLabel) {
    if (!text) { Logger.log(`Skipped: ${typeLabel}`); return; }
    const lines = text.split('\n').map(s => s.replace(/^( +)/, m => m.replace(/ /g, '\u00A0')));
    const p = body.appendParagraph(lines.join('\n')); opCount += 1;
    p.setAttributes({
      [DocumentApp.Attribute.FONT_FAMILY]: 'Courier New',
      [DocumentApp.Attribute.BACKGROUND_COLOR]: '#F1F3F4',
    }); opCount += 1;
  }

  function tryGetText(el) {
    try { return el.asText().getText(); } catch (_) {}
    try { if (el.getType() === DocumentApp.ElementType.PARAGRAPH) return el.asParagraph().getText(); } catch (_) {}
    try { if (el.getType() === DocumentApp.ElementType.LIST_ITEM)  return el.asListItem().getText(); } catch (_) {}
    return '';
  }

  function maybeSave() {
    if (opCount >= MAX_OPS_BEFORE_SAVE) {
      [targetDoc, targetBody] = hardSave(targetDoc, targetBody);
    }
  }

  function hardSave(doc, body) {
    doc.saveAndClose();
    const newDoc  = DocumentApp.openById(TARGET_DOC_ID);
    const newBody = newDoc.getBody();
    opCount = 0;
    return [newDoc, newBody];
  }

  function safeClearBody(body) {
    const dummy = body.appendParagraph("");

    while (body.getNumChildren() > 1) {
      body.removeChild(body.getChild(0));
    }

    const p = body.getChild(0).asParagraph();
    p.setText("\u200B");
    p.setAttributes({
      [DocumentApp.Attribute.BOLD]: false,
      [DocumentApp.Attribute.ITALIC]: false,
      [DocumentApp.Attribute.UNDERLINE]: false,
      [DocumentApp.Attribute.STRIKETHROUGH]: false,
      [DocumentApp.Attribute.FONT_SIZE]: 11,
      [DocumentApp.Attribute.FONT_FAMILY]: "Arial",
      [DocumentApp.Attribute.BACKGROUND_COLOR]: null
    });
  }
}

function listDocsRecursively(folder, basePath) {
  const result = [];

  const files = folder.getFilesByType(MimeType.GOOGLE_DOCS);
  while (files.hasNext()) {
    const f = files.next();
    result.push({ id: f.getId(), name: f.getName(), path: basePath });
  }

  const subs = folder.getFolders();
  while (subs.hasNext()) {
    const sub = subs.next();

    if (sub.getName().endsWith(".bak")) {
        Logger.log(`Skipping recursive subfolder (ends with .bak): ${basePath ? basePath + "/" : ""}${sub.getName()}`);
        continue;
    }

    const subPath = basePath ? basePath + "/" + sub.getName() : sub.getName();
    try {
      result.push.apply(result, listDocsRecursively(sub, subPath));
    } catch (e) {
      Logger.log("Skip subfolder due to error: " + sub.getName() + " / " + e);
    }
  }
  return result;
}

function shouldCombine(subFolder, outName, targetFolder) {
  const targetDocFile = findTargetDocFile(targetFolder, outName);
  let targetUpdateTime = null;
  if (targetDocFile) {
    targetUpdateTime = targetDocFile.getLastUpdated();
  } else {
    return true;
  }

  const entries = listDocsRecursively(subFolder, "");

  for (const { id, name, path } of entries) {
    if (name.endsWith(".bak")) continue;

    try {
      const file = DriveApp.getFileById(id);
      const sourceUpdateTime = file.getLastUpdated();

      if (sourceUpdateTime.getTime() > targetUpdateTime.getTime()) {
        Logger.log(`Update needed for ${outName}. Source: ${path}/${name} (${sourceUpdateTime}) is newer than Target (${targetUpdateTime}).`);
        return true;
      }
    } catch (e) {
      Logger.log(`Error checking file ${id}: ${e}`);
      return true;
    }
  }

  return false;
}

function findTargetDocFile(folder, name) {
  const files = folder.getFiles();
  while (files.hasNext()) {
    const f = files.next();
    try {
      if (f.getName() === name && f.getMimeType() === MimeType.GOOGLE_DOCS) {
        return f;
      }
    } catch (e) {
      // スキップ
    }
  }
  return null;
}