Googleドキュメント内にあるリンクをGASを使ってすべて取得する方法

とある業務でGoogleドキュメント内にあるリンクを取得したかったのですが、これが思いのほか大変でした。GASのドキュメントを読むと[RichLink](https://developers.google.com/apps-script/reference/document/rich-link?hl=en)というElementがあったので、これを[findElement](https://developers.google.com/apps-script/reference/document/body?hl=en#findelementelementtype,-from)で探してやれば一発かなと安易に考えていたのですが、次の画像のように
![スクリーンショット 2022-07-17 16.46.17.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/147086/6d954613-c304-2f93-62cd-8413a8c7b889.png)
2つ目のいわゆる一般的なリンクは、RichLinkではなく、別のやり方が必要でした。そして、このやり方がなかなか良い方法がなくて苦労したので、ここに記録として残しておきたいと思います。


## やりたいこと
上記の画像の例で言えば、次のようなデータを取得することがゴールです。RichLinkはGASのAPIを普通に使えば取得できるのでここでは省略します。

```
[{
  anchorText: "普通のリンク",
  url: "https://xxx.com/"
}]
```


## コードの全容
先にコードの全容を紹介して、そこからポイントごとに説明をしていきます。[clasp](https://github.com/google/clasp)を使ってTypeScriptでGASの開発をしていますが、GAS特有のTypeの型ガードを良い感じにコントロールする方法がよくわからず、安易にts-ignoreしてます...。ご容赦ください。

```sample.ts
interface referToDocInfoProp {
  anchorText: string;
  url: string;
  startOffset: number;
}

const getAllLinks = (element: Docs.Text | Docs.Body): referToDocInfoProp[] => {
  let links: referToDocInfoProp[] = [];

  if (element.getType() === DocumentApp.ElementType.TEXT) {
    const textObj = element.editAsText();
    const text = element.getText();
    let inUrl = false;

    for (let ch = 0; ch < text.length; ch++) {
      let curUrl: referToDocInfoProp = {
        anchorText: '',
        url: '',
        startOffset: null,
      };
      const url = textObj.getLinkUrl(ch);
      // リングがある
      if (url !== null) {
        if (!inUrl) {
          inUrl = true;
          curUrl.url = String(url);
          curUrl.anchorText = text.slice(ch); // textはelement単位なので、まずはアンカーテキストの冒頭から抜粋
          curUrl.startOffset = ch; // 後でアンカーテキストの後ろ部分を調整する為に使う
        }
      } else {
        // リンクがない、= アンカーテキストの最後の次の文字
        if (inUrl) {
          // アンカーテキストの後ろの部分を調整する
          links[links.length - 1].anchorText = links[links.length - 1].anchorText.substring(
            0,
            ch - links[links.length - 1].startOffset
          );
          inUrl = false;
          curUrl = { anchorText: '', url: '', startOffset: null };
        }
      }
      if (inUrl) {
        // linksへpush
        if (curUrl.url !== '') {
          links.push(curUrl);
        }
      }
    }
  } else {
    // @ts-ignore
    const numChildren = element.getNumChildren();
    for (let i = 0; i < numChildren; i++) {
      // @ts-ignore
      const childElement = element.getChild(i);
      const childElementType = childElement.getType();
      // childを持てないElementはエラーになるのでここで除外する
      if (
        childElementType !== DocumentApp.ElementType.RichText &&
        childElementType !== DocumentApp.ElementType.PAGE_BREAK &&
        childElementType !== DocumentApp.ElementType.UNSUPPORTED
      ) {
        links = links.concat(getAllLinks(childElement));
      }
    }
  }
  return links;
};
```

## コードのポイントごとの説明
### リンクをテキスト一文字、一文字ずつ探していく
```sample.ts
 if (element.getType() === DocumentApp.ElementType.TEXT) {
    const textObj = element.editAsText();
    const text = element.getText();
    let inUrl = false;

    for (let ch = 0; ch < text.length; ch++) {
      let curUrl: referToDocInfoProp = {
        anchorText: '',
        url: '',
        startOffset: null,
      };
      const url = textObj.getLinkUrl(ch);
```
Googleドキュメント内のテキストを1文字ずつに対して[getLinkUrl](https://developers.google.com/apps-script/reference/document/text#getlinkurloffset)を実行してリンクを探していきます。本当はパラグラフ単位で探すことができれば良いのですが、ParagraphのClassが持つgetLinkUrlメソッドは、複数のリンクを持つ場合にnullを返す仕様になっており、あまり実用的ではありませんでした。

### アンカーテキストの文字を取得する
```sample.ts
      // リングがある
      if (url !== null) {
        if (!inUrl) {
          inUrl = true;
          curUrl.url = String(url);
          curUrl.anchorText = text.slice(ch); // textはelement単位なので、まずはアンカーテキストの冒頭から抜粋
          curUrl.startOffset = ch; // 後でアンカーテキストの後ろ部分を調整する為に使う
        }
      } else {
        // リンクがない、= アンカーテキストの最後の次の文字
        if (inUrl) {
          // アンカーテキストの後ろの部分を調整する
          links[links.length - 1].anchorText = links[links.length - 1].anchorText.substring(
            0,
            ch - links[links.length - 1].startOffset
          );
          inUrl = false;
          curUrl = { anchorText: '', url: '', startOffset: null };
        }
      }
```
getLinkUrlの返り値はそのURLだけです。また、例えば"普通のリンク"というアンカーテキストがあった場合に「普」と「通」のいずれも文字で実行しても結果は変わりません。アンカーテキストを取得することは要件の一つのため、上手く文字列を操作して取得していく必要があります。

コード内のコメントでも説明していますが、Element単位のテキストに対して、先頭から順番にgetLinkUrlをしていき、一番初めにURLが取得できた地点でそれまでのテキストをsliceしておきます。その後もgetLinkUrlを続けていき、取得ができなくなった地点をアンカーテキストの終了地点として保存しておいたアンカーテキストの後ろの文字をカットします。


### ドキュメントの全てのElementを探索する

```sample.ts
    const numChildren = element.getNumChildren();
    for (let i = 0; i < numChildren; i++) {
      // @ts-ignore
      const childElement = element.getChild(i);
      const childElementType = childElement.getType();
      // childを持てないElementはエラーになるのでここで除外する
      if (
        childElementType !== DocumentApp.ElementType.RichText &&
        childElementType !== DocumentApp.ElementType.PAGE_BREAK &&
        childElementType !== DocumentApp.ElementType.UNSUPPORTED
      ) {
        links = links.concat(getAllLinks(childElement));
      }
    }
```
この関数を呼び出すときは最初にBodyを渡すのが前提となっています。BodyはParagraph等の複数のElementを持っており、その配下にTEXTのElementがあるという構造です。よって、Bodyのchildを引数として再帰的にこの関数を呼び出していきます。

ここの注意点としては、Bodyの中にgetChildメソッドを持たないElementがある場合にエラーになってしまうことです。完全に排除するには該当する全てのElementを条件に加えることですが、実際に使用されているElementが排除できていれば十分だと思います。

## 終わり
以上、Googleドキュメント内にあるリンクをGASを使ってすべて取得する方法でした。これを特定のフォルダの配下のドキュメントすべてに実行すれば、そこにあるリンクが簡単に取得できて良い感じになります。何かの業務のお役に立てれば幸いです。

- 参考文献
    - [Get All Links in a Document - Stack Overflow](https://stackoverflow.com/questions/18727341/get-all-links-in-a-document/18731628#18731628)
    ※ コードの多くはこちらを参考にさせて頂きました。ありがとうございました。