Notion APIで、特定の雛形のページをもとに新しいページを作成する（Googleフォーム回答をトリガーにしたケース実践）※インデントにも対応

# やりたいこと
- Googleフォームの送信等をトリガーにして、Notionのデータベースにカード（ページ）を新規追加したい
- コード内で雛形を書くのではなく、特定のNotionページを雛形として（チェックリスト等）、雛形は必要に応じて更新できるようにしたい
    - ページ本文部分でインデントされた要素もコピーしたい
- 丸ごとコピーではなく、ページのプロパティはフォーム回答内容等をもとに調整できるようにしたい

# 課題
NotionのBlock object（ページの本文部分）が、オブジェクトと配列が入れ子になった複雑な構造なため、コードで書くのがかなり面倒です。
雛形は非エンジニア側の都合で更新したいことも多いため、その度にコードを更新するのも大変。
そのため、テンプレートとなるページをNotionで作成し、それをコピーして新しいページを作成する仕組みを考えることにしました。

# 今回のケース実践の条件確認
## お題
Googleフォームで「新規契約」の申し込みを受けたら、Notionの「新規契約処理」データベースにカード（ページ）を新規追加する。追加するカードの本文部分はテンプレートをコピーし、プロパティはGoogleフォームの回答内容をもとに設定する。

## Googleフォーム
こんな感じで「メールアドレス」「お名前」「携帯電話番号」「利用開始日」の4項目を取得。
これらをNotionのページタイトルおよびプロパティに入れます。
![GoogleFormSample.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2956515/4da64745-3ab1-bbca-e23c-f34a5dcfaa72.png)

## Notionデータベース
シンプルなボードビューで作成。
![NotionDBSample.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2956515/f109ed22-c9b6-df3d-8f2b-6fe0fa137530.png)

## Notionカード（ページ）
こんな感じで、インデントやチェックボックス、箇条書き、コードブロック等を含めた雛形を準備しました。上記のデータベースのカードとして作成します。
プロパティには適当な値を入れておきます。
![NotionPageSample.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2956515/a59ca7ca-e8c9-6669-b6ed-e34d5dead3a2.png)


# 処理の流れ
1. GAS（Google Apps Script）で、フォーム回答時に回答内容を取得する。
1. フォーム回答内容をもとに、Notionで新規作成するページのタイトル、プロパティを設定したオブジェクトを生成する。
1. テンプレートページのBlock object（ページの本文部分）を取得し、上記のオブジェクトと結合する※この時点ではインデントされた行は含まない。
1. 結合したオブジェクトをNotionのデータベースに送る。
1. 新たに作成したページに、インデントされた行を追加する。

# GAS（Google Apps Script）で、フォーム回答時に回答内容を取得する
Googleフォームに以下のスクリプトを追加します。

```JavaScript
function onFormSubmit(){
  let response = {};
  for(let i = 1; i <= 10; i++){
    try{
      response = getNewestFormResponse();
      break;
    } catch(error) {
      console.log(error.message);
      if(i === 10){
        console.log("フォームの回答取得に失敗したため、スクリプトを終了します");
        return;
      }
      Utilities.sleep(10000);
    }
  }
  
  const parsedFormResponse = parseResponses(response);
}

function getNewestFormResponse(){
  const form = FormApp.getActiveForm();
  let yesterday = new Date();
  yesterday.setDate(yesterday.getDate() - 1);
  const responses = form.getResponses(yesterday);
  const newestResponse = responses.pop();
  return newestResponse;
}

function parseResponse(e){
  //すべての質問と回答を取得する
  const itemResponses = e.getItemResponses();

  //質問と回答から各要素を取得してオブジェクトを再生成。
  //選択式の質問で無回答だとインデックスがスキップされるため、インデックスは使用しない。
  let parsedResponses = {};

  //メールアドレスのみ取得方法が特殊なので注意（フォーム設定で「メールアドレスを収集する」にチェックを入れた場合）
  parsedResponses.mail = e.getRespondentEmail();

  //フォームの質問と回答を1件ずつチェックして、オブジェクトに収納
  for(let i = 0; i < itemResponses.length; i++){
    const title = itemResponses[i].getItem().getTitle();
    const response = itemResponses[i].getResponse();
    
    switch (title) {
      case "お名前":
        parsedResponses.name = response;
        break;
      case "携帯電話番号":
        parsedResponses.tel = response;
        break;
      case "利用開始日":
        parsedResponses.start_date = response;
        break;
    }
  }

  return parsedResponses;
}
```

`onFormSubmit`関数はGoogleフォームの回答をトリガーとして設定し、引数としてその回答結果を受け取ることができるのですが、Google側のエラー？により、回答結果を受け取ることができないことが稀にあり、その解決方法が見つかっていません。
そのため、直接回答結果を引数として受け取らず、24時間以内にあった最新のフォーム回答を見にいく形で実装しています。失敗した場合は10秒ほど時間を空けて、最大10回まで回答取得を試みています。

フォームが高頻度で送信される場合は、他の回答が参照されてしまうリスクもありますが、そのリスクは許容可能なものとして、一旦無視しています。この実装の副次的なメリットとして、テスト時にいちいちフォームを回答する必要がないこともあります。

また`onFormSubmit`関数をトリガーとして設定しておきます。イベントの種類はフォーム送信時です。

![TriggerSample.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2956515/cc9c1def-b21f-ec7a-b174-5f19e80ad585.png)


# フォーム回答内容をもとに、Notionで新規作成するページのタイトル、プロパティを設定したオブジェクトを生成する

## Notion APIを使用するための準備
### インテグレーションを作成する
以下のページから画面の説明に沿ってインテグレーション（Internal integration）を作成します。

https://www.notion.so/my-integrations

### Notionのデータベースにインテグレーションを追加する
先ほど作成したデータベースをフルページで開き、右上の…から`Add connections`を選択した上で、上記で作成したインテグレーションを追加します。
![IntegrationSample.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2956515/aed59557-6f87-3de5-ea15-39425c2f2ee7.png)

## テンプレートページのプロパティを取得する
先ほど作成したGASに、以下のコードを追加します。
```JavaScript
//指定したページのプロパティを取得
function fetchNotionPageProperties(pageId){
  //インテグレーションのページで確認できるInternal Integration Token
  const token = "secret_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";
  const requestUrl = "https://api.notion.com/v1/pages/" + pageId;

  const headers = {
    "Authorization" : "Bearer " + token,
    "Notion-Version" : "2022-06-28" //最新バージョンは https://developers.notion.com/reference/versioning から確認
  };

  const requestOptions = {
    "headers" : headers
  }

  const response = JSON.parse(UrlFetchApp.fetch(requestUrl, requestOptions));
  return response;
}

//テンプレートページのプロパティを取得
function fetchSampleNotionPageProperties(){
  //pageIdは、テンプレートページ（フルページ）のURLの最後の32文字。ハイフンがある場合はハイフンより後ろの部分
  const pageId = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";
  const response = fetchNotionPageProperties(pageId);
  console.log(JSON.stringify(response, null, 4));
}
```

この`fetchSampleNotionPageProperties`を実行すると、色々入ったオブジェクトが取得できますが、重要なのは以下の部分です（かなり要素はしょってます）。
```JavaScript
{
	"parent": {
		"database_id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
	},
	"properties": {
		"電話番号": {
			"phone_number": "09012345678"
		},
		"メールアドレス": {
			"email": "abc@samplepage.com"
		},
		"利用開始日": {
			"date": {
				"start": "2023-02-22",
				"end": null,
				"time_zone": null
			}
		},
		"Name": {
			"title": [{
				"text": {
					"content": "テンプレート"
				}
			}]
		}
	}
}
```
基本的には`parent`と`properties`だけ確認すればOKですが、プロパティの種類によって内容は異なるので、実際のユースケースに合わせて色々設定しながら確認してみてください。

また、`database_id`は、データベースをフルページで開いた際のURLの、`?v=`の前の32文字です。テンプレートページから取得する際にはハイフンで区切られていますが、データを送る際にはハイフンはなくても大丈夫です。

各プロパティの構造が分かったので、先ほど取得したGoogleフォームの回答をプロパティにあてがって、Notionで新しいページを作っていきます。

## フォームの回答をもとにプロパティを設定したオブジェクトを生成する
新しいページを作成する`createNotionPageObj`関数を定義し、フォーム回答時に実行される`onFormSubmit`関数から呼び出します。
```JavaScript
function onFormSubmit(){...//省略
  const parsedFormResponse = parseResponses(response);
  const notionPageObj = createNotionPageObj(parsedFormResponse);
}

//新しいNotionページとして利用するオブジェクトの作成
function createNotionPageObj(properties){
  const databaseId = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";
  let obj = {
    "parent": {
      "database_id": databaseId
    },
    "properties": {
      "電話番号": {
        "phone_number": properties.tel
      },
      "メールアドレス": {
        "email": properties.mail
      },
      "利用開始日": {
        "date": {
          "start": properties.start_date,
          "end": null,
          "time_zone": null
        }
      },
      "Name": {
        "title": [{
          "text": {
            "content": properties.name
          }
        }]
      }
    }
  }

  return obj;
}
```

# テンプレートページのBlock object（ページの本文部分）を取得し、上記のオブジェクトと結合する※この時点ではインデントされた行は含まない
## テンプレートページのBlock object（本文部分）を取得する
```JavaScript
//指定したページのBlock object（ページの本文部分）を取得
function fetchNotionPageBlocks(blockId){
  const token = "secret_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";
  const requestUrl = "https://api.notion.com/v1/blocks/" + blockId + "/children?page_size=100";

  const headers = {
    "Authorization" : "Bearer " + token,
    "Notion-Version" : "2022-06-28" //最新バージョンは https://developers.notion.com/reference/versioning から確認
  };

  const requestOptions = {
    "headers" : headers
  }

  const response = JSON.parse(UrlFetchApp.fetch(requestUrl, requestOptions));
  const results = response.results;

  return results;
}

//テンプレートページのBlock object（ページの本文部分）を取得
function fetchSampleNotionPageBlocks(){
  //pageIdは、テンプレートページ（フルページ）のURLの最後の32文字。ハイフンがある場合はハイフンより後ろの部分
  const pageId = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";
  const response = fetchNotionPageBlocks(pageId);
  return response;
}
```

## 【ポイント】インデントされた行は子要素とみなされ、一度に取得することはできない
これがNotion APIを使用する上で最大のネックであり、難しいポイントです。
上記のようにBlock objectを取得すると、本来子要素（その行の下にあるインデントされた行）がある場合、`has_children`というプロパティが`true`となるのですが、肝心の子要素`children`は取得することができません。
`children`を取得するためには、`has_children`が`true`の要素に対して、`block_id`を取得し、そこに対して改めてAPIリクエストする必要があります。
また、以下の公式API Referenceにも記載があるのですが、全ての子孫要素を取得するには再帰的（recursively）にAPIを用いることが必要です。

https://developers.notion.com/reference/get-block-children

また、APIでページを作成/要素を追加する際に、孫要素（`children`の配列内の要素が持つ`children`）を含むオブジェクトを投げるとエラーとなるため、これも注意が必要です。

## フォーム回答をもとにプロパティを設定したオブジェクトに、Block object（本文部分）を結合する



## オブジェクトの不要なプロパティを削除する
`annotations`等のプロパティを含めたオブジェクトを投げると、Notion上でのチェックボックス等の挙動がおかしくなるというバグ？があります。
また、テンプレートページから取得した情報にはそれ以外にも不要なプロパティがたくさんあるので、Notion側に投げる前に削除します。
```JavaScript
//新しいNotionページとして利用するオブジェクトの作成
function createNotionPageObj(properties){...//省略
  let blocks = fetchSampleNotionPageBlocks();
  cleanNotionPageObj(blocks);
  obj.children = blocks;
  
  return obj;
}

//オブジェクト（子孫含む）から、指定したプロパティの一覧をすべて削除
function deletePropertiesFromObj(obj, properties){
  for(const property of properties){
    delete obj[property];
  }
  for(const key of Object.keys(obj)){
    if(isObject(obj[key])){
      deletePropertiesFromObj(obj[key], properties);
    }
  }
  return obj;
}

//ページの作成に不要なプロパティを削除
function cleanNotionPageObj(notionPageObj){
  const UNNECESSARY_PROPERTIES = [
    "object",
    "id",
    "created_time",
    "last_edited_time",
    "created_by",
    "last_edited_by",
    "annotations",
    "archived",
    "plain_text",
    "href",
    "type"
  ];

  deletePropertiesFromObj(notionPageObj, UNNECESSARY_PROPERTIES);
  return notionPageObj;
}

//引数がオブジェクトまたは配列であればtrueを返す
function isObject(value) {
  return value !== null && typeof value === 'object';
}
```

# 結合したオブジェクトをNotionのデータベースに送る
```JavaScript
function onFormSubmit(){...//省略
  const parsedFormResponse = parseResponses(response);
  const notionPageObj = createNotionPageObj(parsedFormResponse);
  const newlyCreatedPageProperties = createNewNotionPage(notionPageObj);
}

//オブジェクトをもとに、新しいNotionページを作成
function createNewNotionPage(notionPageObj) {
  const token = "secret_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";
  const requestUrl = "https://api.notion.com/v1/pages";

  const headers = {
    "Authorization" : "Bearer " + token,
    "Content-Type" : "application/json",
    "Notion-Version" : "2022-06-28" //最新バージョンは https://developers.notion.com/reference/versioning から確認
  };

  const payload = notionPageObj;

  const requestOptions = {
    "method" : "post",
    "headers" : headers,
    "payload" : JSON.stringify(payload),
    "muteHttpExceptions" : true
  }

  try {
    const res = JSON.parse(UrlFetchApp.fetch(requestUrl, requestOptions));
    return res;
  } catch(e) {
    console.log(e.message);
  }
}
```
APIでページを新規作成したときの返り値`newlyCreatedPageProperties`には、Block objectは含まれず、プロパティのみが含まれます。
新規作成ページのBlock objectを取得するには、このプロパティを用いて再度APIリクエストする必要があります。

# 新たに作成したページに、インデントされた行を追加する
## 実装方針
前述のとおり、一度のAPIリクエストでは、ある要素の子要素までしか取得できず、孫以降の要素は取得できません。
そのため、テンプレートページにおける各要素の`has_children`プロパティを確認し、これが`true`であれば、その要素の`block_id`を用いて、再帰的に要素を取得します。
しかし、新規作成ページの要素をそのまま見ても`has_children`は`false`なので、新規作成ページとテンプレートページを照応させながら処理を行います。

## テンプレートページの子孫要素をすべて取得する
照応に使用するため、まずは子孫要素を全て含むテンプレートページを用意します。
こちらは`has_children`が`true`であれば、`block_id`をもとに再帰的にAPIリクエストをするだけでOKです。
先ほど作成した`fetchNotionPageBlocks`関数と`fetchSampleNotionPageBlocks`関数に任意の引数`callRecursively`を追加し、これが`true`であれば子孫要素もすべて取得できるように書き換えます。
```JavaScript
//指定したページのBlock object（ページの本文部分）を取得
//callRecursivelyをtrueにすると、children含めて再帰的にすべて取得
function fetchNotionPageBlocks(blockId, callRecursively=false){
  const token = "secret_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";
  const requestUrl = "https://api.notion.com/v1/blocks/" + blockId + "/children?page_size=100";

  const headers = {
    "Authorization" : "Bearer " + token,
    "Notion-Version" : "2022-06-28" //最新バージョンは https://developers.notion.com/reference/versioning から確認
  };

  const requestOptions = {
    "headers" : headers
  }

  const response = JSON.parse(UrlFetchApp.fetch(requestUrl, requestOptions));
  let results = response.results;

  if(callRecursively){
    for(const key of Object.keys(results)){
      if(results[key].has_children){
        results[key].children = fetchNotionPageBlocks(results[key].id, true);
      }
    }
  }

  return results;
}

//テンプレートページのBlock object（ページの本文部分）を取得
function fetchSampleNotionPageBlocks(callRecursively=false){
  //pageIdは、テンプレートページ（フルページ）のURLの最後の32文字。ハイフンがある場合はハイフンより後ろの部分
  const pageId = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";
  const response = fetchNotionPageBlocks(pageId, callRecursively);
  return response;
}
```

## 子孫を追加する関数を用意する
```JavaScript
function appendChildren(blockId, childrenArray){
  const token = "secret_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";
  const requestUrl = "https://api.notion.com/v1/blocks/" + blockId + "/children";

  const headers = {
    "Authorization" : "Bearer " + token,
    "Content-Type" : "application/json",
    "Notion-Version" : "2022-06-28" //最新バージョンは https://developers.notion.com/reference/versioning から確認
  };

  const payload = {
    "children" : childrenArray
  };

  const requestOptions = {
    "method" : "patch",
    "headers" : headers,
    "payload" : JSON.stringify(payload),
    "muteHttpExceptions" : true
  }

  try {
    const res = JSON.parse(UrlFetchApp.fetch(requestUrl, requestOptions));
    return res;
  } catch(e) {
    console.log(e.message);
  }
}
```

## テンプレートページに沿って、新たに作成したページに子孫要素を追加する
新規作成ページとテンプレートページを照応させながら、再帰的に処理を進めていきます。
`pageBlocks[key]`は新規作成ページの要素（行）、`templetePageBlocks[key]`はテンプレートページにおけるその要素（行）に対応しています。

```JavaScript
function cloneChildrenFromTemplete(pageBlocks, templetePageBlocks){
  for(const key of Object.keys(pageBlocks)){
    const blockId = pageBlocks[key].id;
    if(templetePageBlocks[key].has_children){
      //オブジェクトはデフォルトだと参照渡しされるため、元データを変えてしまわないように複製したものを用意する
      let childrenTemplete = duplicateObject(templetePageBlocks[key].children);
      
      //不要なプロパティの削除
      childrenTemplete = cleanNotionPageObj(childrenTemplete);
      
      //孫となるchildrenを含むオブジェクトを投げるとエラーになるため、削除
      childrenTemplete = deleteChildrenFromNotionPageObj(childrenTemplete);

      const response = appendChildren(blockId, childrenTemplete);

      //再帰的に関数を実行し、孫要素がある場合には同様の処理を行う
      cloneChildrenFromTemplete(response.results, templetePageBlocks[key].children);
    }
  }
}

function duplicateObject(obj){
  const retObj = JSON.parse(JSON.stringify(obj));
  return retObj;
}

//childrenプロパティを削除
function deleteChildrenFromNotionPageObj(notionPageObj){
  deletePropertiesFromObj(notionPageObj, ["children"]);
  return notionPageObj;
}
```

# 実行テスト
フォームから回答を送信したところ、問題なく新規カード（ページ）が追加されていることが確認できました。
![NotionAddedSample.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2956515/dfbd94f5-62e5-1153-3b0e-e417648c3205.png)


# 今回作成したコードの全貌
結構ボリュームが多くなってしまったため、以下の5つのファイルに分けました。
```JavaScript:form_controll.gs
function onFormSubmit(){
  let response = {};
  for(let i = 1; i <= 10; i++){
    try{
      response = getNewestFormResponse();
      break;
    } catch(error) {
      console.log(error.message);
      if(i === 10){
        console.log("フォームの回答取得に失敗したため、スクリプトを終了します");
        return;
      }
      Utilities.sleep(10000);
    }
  }

  const parsedFormResponse = parseResponse(response);
  const notionPageObj = createNotionPageObj(parsedFormResponse);
  const newlyCreatedPageProperties = createNewNotionPage(notionPageObj);
  
  //APIで新たに作成したNotionページのブロック要素（本文部分）を取得
  //この段階ではインデントされた行（子孫要素）は取得できない
  const page = fetchNotionPageBlocks(newlyCreatedPageProperties.id);

  //テンプレートとなるページを、子孫要素を含めて再帰的に取得
  const templetePage = fetchSampleNotionPageBlocks(true);

  //テンプレートページに合わせて、新たに作成したページに子孫要素を追加
  cloneChildrenFromTemplete(page, templetePage);
}

function getNewestFormResponse(){
  const form = FormApp.getActiveForm();
  let yesterday = new Date();
  yesterday.setDate(yesterday.getDate() - 1);
  const responses = form.getResponses(yesterday);
  const newestResponse = responses.pop();
  return newestResponse;
}

function parseResponse(e){
  //すべての質問と回答を取得する
  const itemResponses = e.getItemResponses();

  //質問と回答から各要素を取得してオブジェクトを再生成。
  //選択式の質問で無回答だとインデックスがスキップされるため、インデックスは使用しない。
  let parsedResponses = {};

  //メールアドレスのみ取得方法が特殊なので注意（フォーム設定で「メールアドレスを収集する」にチェックを入れた場合）
  parsedResponses.mail = e.getRespondentEmail();

  //フォームの質問と回答を1件ずつチェックして、オブジェクトに収納
  for(let i = 0; i < itemResponses.length; i++){
    const title = itemResponses[i].getItem().getTitle();
    const response = itemResponses[i].getResponse();
    
    switch (title) {
      case "お名前":
        parsedResponses.name = response;
        break;
      case "携帯電話番号":
        parsedResponses.tel = response;
        break;
      case "利用開始日":
        parsedResponses.start_date = response;
        break;
    }
  }

  return parsedResponses;
}
```

```JavaScript:optimize_obj.gs
//引数がオブジェクトまたは配列であればtrueを返す
function isObject(value) {
  return value !== null && typeof value === 'object';
}

//オブジェクト（子孫含む）から、指定したプロパティの一覧をすべて削除
function deletePropertiesFromObj(obj, properties){
  for(const property of properties){
    delete obj[property];
  }
  for(const key of Object.keys(obj)){
    if(isObject(obj[key])){
      deletePropertiesFromObj(obj[key], properties);
    }
  }
  return obj;
}

//ページの作成に不要なプロパティを削除
function cleanNotionPageObj(notionPageObj){
  const UNNECESSARY_PROPERTIES = [
    "object",
    "id",
    "created_time",
    "last_edited_time",
    "created_by",
    "last_edited_by",
    "annotations",
    "archived",
    "plain_text",
    "href",
    "type"
  ];

  deletePropertiesFromObj(notionPageObj, UNNECESSARY_PROPERTIES);
  return notionPageObj;
}

//childrenプロパティを削除
function deleteChildrenFromNotionPageObj(notionPageObj){
  deletePropertiesFromObj(notionPageObj, ["children"]);
  return notionPageObj;
}
```

```JavaScript:fetch_page.gs
//指定したページのプロパティを取得
function fetchNotionPageProperties(pageId){
  //インテグレーションのページで確認できるInternal Integration Token
  const token = "secret_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";
  const requestUrl = "https://api.notion.com/v1/pages/" + pageId;

  const headers = {
    "Authorization" : "Bearer " + token,
    "Notion-Version" : "2022-06-28" //最新バージョンは https://developers.notion.com/reference/versioning から確認
  };

  const requestOptions = {
    "headers" : headers
  }

  const response = JSON.parse(UrlFetchApp.fetch(requestUrl, requestOptions));
  return response;
}

//テンプレートページのプロパティを取得
function fetchSampleNotionPageProperties(){
  //pageIdは、テンプレートページ（フルページ）のURLの最後の32文字。ハイフンがある場合はハイフンより後ろの部分
  const pageId = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";
  const response = fetchNotionPageProperties(pageId);
  console.log(JSON.stringify(response, null, 4));
}

//指定したページのBlock object（ページの本文部分）を取得
//callRecursivelyをtrueにすると、children含めて再帰的にすべて取得
function fetchNotionPageBlocks(blockId, callRecursively=false){
  const token = "secret_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";
  const requestUrl = "https://api.notion.com/v1/blocks/" + blockId + "/children?page_size=100";

  const headers = {
    "Authorization" : "Bearer " + token,
    "Notion-Version" : "2022-06-28" //最新バージョンは https://developers.notion.com/reference/versioning から確認
  };

  const requestOptions = {
    "headers" : headers
  }

  const response = JSON.parse(UrlFetchApp.fetch(requestUrl, requestOptions));
  let results = response.results;

  if(callRecursively){
    for(const key of Object.keys(results)){
      if(results[key].has_children){
        results[key].children = fetchNotionPageBlocks(results[key].id, true);
      }
    }
  }

  return results;
}

//テンプレートページのBlock object（ページの本文部分）を取得
function fetchSampleNotionPageBlocks(callRecursively=false){
  //pageIdは、テンプレートページ（フルページ）のURLの最後の32文字。ハイフンがある場合はハイフンより後ろの部分
  const pageId = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";
  const response = fetchNotionPageBlocks(pageId, callRecursively);
  return response;
}
```

```JavaScript:create_page.gs
//新しいNotionページとして利用するオブジェクトの作成
function createNotionPageObj(properties){
  const databaseId = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";
  let obj = {
    "parent": {
      "database_id": databaseId
    },
    "properties": {
      "電話番号": {
        "phone_number": properties.tel
      },
      "メールアドレス": {
        "email": properties.mail
      },
      "利用開始日": {
        "date": {
          "start": properties.start_date,
          "end": null,
          "time_zone": null
        }
      },
      "Name": {
        "title": [{
          "text": {
            "content": properties.name
          }
        }]
      }
    }
  }

  let blocks = fetchSampleNotionPageBlocks();
  cleanNotionPageObj(blocks);
  obj.children = blocks;
  return obj;
}

//オブジェクトをもとに、新しいNotionページを作成
function createNewNotionPage(notionPageObj) {
  const token = "secret_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";
  const requestUrl = "https://api.notion.com/v1/pages";

  const headers = {
    "Authorization" : "Bearer " + token,
    "Content-Type" : "application/json",
    "Notion-Version" : "2022-06-28" //最新バージョンは https://developers.notion.com/reference/versioning から確認
  };

  const payload = notionPageObj;

  const requestOptions = {
    "method" : "post",
    "headers" : headers,
    "payload" : JSON.stringify(payload),
    "muteHttpExceptions" : true
  }

  try {
    const res = JSON.parse(UrlFetchApp.fetch(requestUrl, requestOptions));
    return res;
  } catch(e) {
    console.log(e.message);
  }
}
```

```JavaScript:append_blocks.gs
function appendChildren(blockId, childrenArray){
  const token = "secret_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";
  const requestUrl = "https://api.notion.com/v1/blocks/" + blockId + "/children";

  const headers = {
    "Authorization" : "Bearer " + token,
    "Content-Type" : "application/json",
    "Notion-Version" : "2022-06-28" //最新バージョンは https://developers.notion.com/reference/versioning から確認
  };

  const payload = {
    "children" : childrenArray
  };

  const requestOptions = {
    "method" : "patch",
    "headers" : headers,
    "payload" : JSON.stringify(payload),
    "muteHttpExceptions" : true
  }

  try {
    const res = JSON.parse(UrlFetchApp.fetch(requestUrl, requestOptions));
    return res;
  } catch(e) {
    console.log(e.message);
  }
}

function cloneChildrenFromTemplete(pageBlocks, templetePageBlocks){
  for(const key of Object.keys(pageBlocks)){
    const blockId = pageBlocks[key].id;
    if(templetePageBlocks[key].has_children){
      //オブジェクトはデフォルトだと参照渡しされるため、元データを変えてしまわないように複製したものを用意する
      let childrenTemplete = duplicateObject(templetePageBlocks[key].children);
      
      //不要なプロパティの削除
      childrenTemplete = cleanNotionPageObj(childrenTemplete);
      
      //孫となるchildrenを含むオブジェクトを投げるとエラーになるため、削除
      childrenTemplete = deleteChildrenFromNotionPageObj(childrenTemplete);

      const response = appendChildren(blockId, childrenTemplete);

      //再帰的に関数を実行し、孫要素がある場合には同様の処理を行う
      cloneChildrenFromTemplete(response.results, templetePageBlocks[key].children);
    }
  }
}

function duplicateObject(obj){
  const retObj = JSON.parse(JSON.stringify(obj));
  return retObj;
}
```

# 総括
インデントされた行（`children`）の扱いがかなり難解で、理解するのに時間がかかりました。NotionのAPIがアップデートされて、この辺りもっと簡単に取り扱えるようになってくれれば嬉しいです。

しかしそれまでは、GASに限らず、プログラムからNotionのページを新規作成したいときにはこちらを参考にしていただけると幸いです。Googleフォームだけでなく、Googleスプレッドシートをトリガーにする等、さまざまな使い方がありそうです。

もっとこうした方が良い！等のアドバイスがあれば、コメントお待ちしています！

# 追記
共通化できる部分は共通化し、コピペで使えるコードにまとめて記事にしました！

https://qiita.com/CurryPrince/items/2fa8db372d41fa3bd905