GASで定期タスクを設定した際のメモ

こんにちは。QA（Quality assurance：品質管理）チームの佐藤です。

:::note warn
この記事は、[Supershipグループ Advent Calendar 2022](https://qiita.com/advent-calendar/2022/supership)の7日目の記事になります。
:::
:::note
今年はGASネタで記事を書く人が何人かいるので、一部内容が重複するかもしれません。その際は適当に読み飛ばしてください。
:::

## TL;DR
この記事は、Googleスプレッドシートで、Google　Apps　Script（以下、GAS）を使う際の覚書のようなもので、以下の内容を含みます。
- GASのはじめかた
- コンソールへの出力
- ネット上のデータの取得
- 取得したデータのパース
- シートからデータを取得
- シートにデータを書き込む
- トリガーの設定
- サイドエリアにHTMLを表示する
- 独自メニューを追加する
- シートの保護

## はじめに
QAチームでは、定期的なタスクを日々実行しているのですが、いくつかは手動で実施し、Slack等で結果を共有するといった段取りで実施しています。今回は、各アドネットワークのSDKのアップデート情報を管理しているスプレッドシートの更新を、GASに定期的に自動で行わせるように設定した際に使用した機能を、メモとして書き留めておきます。

### 定期タスクの実行に、なぜGASなのか？
これまで、QAチームでは、定期的なタスク処理を効率化するために、だいたい下記のような感じで対応していました。
- サーバーを建ててPHP等で処理
- Seleniumなどでメンバーで当番制で処理
- その際、cron または 手動 による実施

すると、以下のような問題が発生しがちです。
- サーバー等のメンテナンスが発生する（放置されがち）
- メンバーのリソースの消費（時間および繰り返し単純作業による疲弊）
- 実行する条件に制限がある（環境の作成や作業の一時中断）

そこで、データを管理しているスプレッドシートをGASで拡張する形で運用できないか…という発想から、現在の利用しているスプレッドシートのGASを使って定期タスクの実行をさせてみようということになりました。

利点としては、下記の項目が期待されます。
- クラウド上にデータを保存できる
- クラウド上でタスクを実行できる
- セキュリティ面の環境構築が不要
（現在会社で運用しているGoogleの機能・設定をそのまま利用できる）

今回GASにやってもらうことをざっくりとまとめる下記になります。
1. SDKの更新情報のページ等から更新の有無を確認
1. 更新情報を管理しているシートを更新し、必要な通知を出す
1. 開発者でない人にも使いやすいように工夫する

最終的には、以下のようなフローを実現させます。
![処理の流れ.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/20844/b3c6819b-34c2-2bcb-fb2b-06d7597daa8f.png)
## GASを利用する
GASは、スプレッドシートのメニューの「拡張機能>Apps Script」で開く新しいタブから利用できます。
<img width="600" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/20844/9b86bd80-12fb-fec3-2fdc-f78978ef2647.png" />
コードは「エディタ」で編集して使用します。デバックはコンソールや、ブラウザのデベロッパツールを利用します。初期状態でスクリプトのファイルがすでに作成されています。ファイルは自由に作成できるので、機能ごとに小分けにしておくと管理が楽になります。
<img width="600" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/20844/c9b63694-4aa0-5a56-d8c7-5b45c34b90ea.png" />

### コンソールに出力する
スクリプトを書いていく上で状態を確認するために、コンソールにいろいろ表示して確認したくなることでしょう。コンソールに文字等を表示するには、```Logger.log```を使用します。
```js:コード例
function myFunction() {
  let txt = "テスト";
  Logger.log(txt);
}
```
<img width="600" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/20844/c23904b4-0a7b-a7d9-937f-201b14e49a36.png" />

### ネット上のデータを取得する
ネット上のデータを取得し、コンソールに表示するには、```UrlFetchApp```を使用します。
```js:コード例
function myFunction() {
  let url = "https://supership.jp/";
  source = UrlFetchApp.fetch(url);
  Logger.log(source);
}
```
<img width="600" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/20844/ac6e7740-9b23-3eaa-42e2-e946bf361b29.png" />

:::note warn
取得したデータが上限（8192文字）を超える場合、```Logging output too large. Truncating output.```となり、一部のみ表示されます。
:::

### 取得したデータをパースする
上述したように、HTML等のデータを取得した場合、コンソールでは表示できない大きさのデータを取得しがちです。取得したデータから、最終的に必要な部分のみ取り出す必要があります。

そのためには、取得したデータをパース（データを解析し、扱いやすく変換すること）する必要があります。GASでHTMLをパースするためには、Parserライブラリを利用するのがお手軽です（JSONやXMLはデフォルトで可能）。

#### ライブラリの追加
ライブラリの追加は、GASのエディタ画面の「ライブラリ +」から行います。今回はネットで探したHTMLパーサーのうち、Google Developer Expertsである[Ivan Kutilさん](https://www.kutil.org/)のParserを利用させてもらいます。

https://script.google.com/home/projects/1Mc8BthYthXx6CoIz90-JiSzSafVnT6U3t0z_W3hLTAX5ek4w0G_EIrNw

ライブラリの追加にはスクリプトIDが必要です。スクリプトIDは上記URLの
```
1Mc8BthYthXx6CoIz90-JiSzSafVnT6U3t0z_W3hLTAX5ek4w0G_EIrNw
```
の部分です。GASのエディタ画面の「ライブラリ +」をクリックして、ダイアログに上記IDを入力して検索ボタンをクリックします。
検索結果を確認したら、追加ボタンで追加します。

<img width="350" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/20844/0421f86e-21f5-dc38-5c88-21084b7cd010.png" />

Parserライブラリが追加されました。
<img width="250" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/20844/20672d56-1038-6b16-bf78-b2a80b40b6eb.png" />

#### HTMLのパース
HTMLのパースは、下記のフォーマットで取得したデータから抽出します。
```js:フォーマット
Parser.data([取得したデータ]).from([開始タグ]).to([終了タグ]).iterate();
```
タグの特定は、ブラウザアプリに付属しているデベロッパーツールなどを使って、事前に済ませておきます。
```js:コード例
function myFunction() {
  let url = "https://supership.jp/magazine/"; // URLを指定
  source = UrlFetchApp.fetch(url).getContentText("UTF-8"); // 取得したデータをUTF-8に変換
  // 指定のタグ要素を取り出す
  links = Parser.data(source).from('<h4 class="c-ttl__3a">').to('</h4>').iterate();　
  Logger.log(links);
}
```
上記を実行すると、以下のような感じで返ってきます（注：改行・空白の調整をして見やすくしています。実際は改行も空白もない状態で返ってきます。）
```bash:レスポンスの中身
[
　　　　【モンストに聞いた】広告戦略のイマとミライ、課金型ゲームにおけるハイブリッド型広告マネタイズとは #TGS2022セッションレポート, 
　　　　ポストCookie時代に改めて問われるアドベリフィケーションの重要性〜JICDAQ認証記念スペシャル対談〜, 
　　　　デジタル広告投資における企業のリスクマネジメントとは, 
　　　　【Yahoo! ショッピング＆楽天市場】 モールの現在から学ぶ、EC店舗売上拡大の成功のカギ, 
　　　　効果的なオンラインプロモーションのために、SNSやインターネット広告運用において必要なリスクマネジメントとは？, 
　　　　ECモール出店の課題は、高度なデータ分析力で解決。 売上拡大と効率的な広告運用を「Supership式コンサルティングプログラム」で実現！, 
　　　　動画リワード「エラー検知機能」でGCPのコストを75%削減したお話〜エンジニアの備忘録〜, 
　　　　アプリ上での広告配信におけるブランドセーフティを実現「App Unsafe List」徹底解説！, 
　　　　フェイクニュース問題に対して インターネット広告業界は何ができるか？ 〜0から学ぶフェイクニュース勉強会セミナー〜, 
　　　　アプリ内課金と広告収益のハイブリッドマネタイズ！『束縛彼氏』のアドジェネ動画リワード導入事例, 
　　　　【ワコール×Momentum共催セミナー】ワコールのYouTube広告キャンペーン事例大公開！ブランド認知率、好意度、サイト回遊率…ブランドセーフティ施策がもたらす様々な影響とは？, 
　　　　「Supership式コンサルティングプログラム」で、ECストア運営の課題を解決 ー氷結熟成肉専門店「丹波蔵」の導入事例ー, 
　　　　ポストCookie時代に改めて問われるアドベリフィケーションの重要性〜JICDAQ認証記念スペシャル対談〜, 
　　　　KDDIの位置情報ビッグデータとDATUM STUDIOの高度なデータ分析力で新たなインサイトを導き出す『Location Trends』とは,
　　　　Momentumで実現できるアドベリフィケーション対策とは, 中の人がAd Generation動画リワード広告のオススメ実装方法を…
]
```
このように、必要な要素を取り出して、配列やオブジェクトにして加工したり、保持したりして利用します。

### シートからデータを取得する
数件のURLをループ処理してデータを取得するなど、スクリプト上で配列を指定して扱うのが手っ取り早いのですが、URLの追加や変更はユーザーがシート上から行えるようにする方が使い勝手がよいでしょう。

スプレッドシートのファイルを開いたタイミングで、特定のシートからデータを取得するようにしてみます。シート関連の処理をするには、```SpreadsheetApp```を使用します。

:::note warn
- よく使用する値を「プロジェクトの設定 > スクリプト　プロパティ」でセットしています。<img width="500" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/20844/d8c0f9b1-16ea-fcde-f8fe-c2923014024b.png" />
- あらかじめ作成済みのシート「config」からデータを取得するとします。
- ファイルを開いた際に処理が実行されるようにするため、トップレベル（コードの上層、関数等に含まれない層）で処理を実施しています。
- 頻繁に参照することが予想されるため、定数的に結果を格納しています。
:::

```js:コード例
// スクリプト　プロパティ　SHEET_ID　を読み込む
const SHEET = PropertiesService.getScriptProperties().getProperties().SHEET_ID;
// 定数「CONFIG」を生成
const CONFIG = (function(){
  // -- スプレッドシートから指定シートのデータ読み込み
  const sheet = SpreadsheetApp.openById(SHEET).getSheetByName("config");
  // 全体データを配列に読込
  const master_data = sheet.getRange(
    1, // 開始行番号
    1, // 開始列番号
    sheet.getLastRow(), // 行数
    sheet.getLastColumn()) // 列数
  .getValues(); // 範囲から値を取得
  return master_data;
})();

function myFunction() {
  ...以下省略...
```
という感じでやると、```CONFIG```に
```bash:レスポンスの中身（注：改行・空白の調整をして見やすくしています）
[
　　　　[サイト名, url], 
　　　　[Supership株式会社, https://supership.jp/], 
　　　　[SuperMagazine, https://supership.jp/magazine/], 
　　　　[採用情報, https://supership.jp/recruit/], 
　　　　[Supershipホールディングス株式会社, https://supership-hd.jp/], 
　　　　[Momentum株式会社, https://www.m0mentum.co.jp/], 
　　　　[DATUM STUDIO株式会社, https://datumstudio.jp/], 
　　　　[ちゅらデータ株式会社, https://churadata.okinawa/]
]
```
という感じで返ってきますので、適宜加工処理して利用します。

:::note warn
この場合、 ```CONFIG``` は、複数ファイルを跨いでどのスクリプトからも利用できるグローバルな配列になります。
:::

:::note info
ちなみに読み込んだシート"config"はこんな感じです
<img width="500" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/20844/40b25105-a428-ace9-10a0-1ff5aff67323.png" />
:::

### シートにデータを書き込む
データを取得して、必要データの塊の準備ができたら、シートに保存します。シートにデータを書き込むには、```SpreadsheetApp```の```setValues```を使用します。

:::note warn
- ここでは、シート全体を一度に更新する想定で例を提示しています。
- シートの一部を更新するには、セルの特定などの処理が別途必要になります。
- 更新前にGoogle Deiveなどにバックアップするなど必要応じて処理を追加してください。
:::

```js:例
function myFunction() {
  // スクリプト　プロパティ　SHEET_ID　を読み込む（すでに関数外で定義している際は要コメントアウト）
  const SHEET = PropertiesService.getScriptProperties().getProperties().SHEET_ID;
  // --- シートの指定
  const sheet = SpreadsheetApp.openById(SHEET).getSheetByName('Test');
  // --- 書込データの取得
  let url = "https://supership.jp/magazine/";
  source = UrlFetchApp.fetch(url).getContentText("UTF-8"); // パース用に変換しながら取得
  // 取得したデータから特定のタグパターンを指定して抽出
  datas = Parser.data(source).from('<a class="p-magazine__card_link" href="').to('</a>').iterate();
  // シートへ書込用データの整形
  let post_list = [['title', 'url', 'date']]; // タイトル行用のデータを追加
  // 配列から1レコードづつ取り出して処理
  for(let i in datas){　// この場合、iにはインデックスの数値が入ります
    let work_tmp = datas[i].replace(/[\r\n]+/g,""); // 改行の削除して作業用変数へ
    let post_rec = [];　// 一時作業用配列
    post_rec.push(work_tmp.replace(/^.*strong class="p-magazine__card_text">/,"").replace(/<\/strong>.*/,"")); //　タイトルを抽出
    post_rec.push(work_tmp.replace(/\".*/,"")); // URLを抽出
    post_rec.push(work_tmp.replace(/^.*datetime=\"/,"").replace(/\">.*/,"")); // 記事公開日時の抽出
    post_list.push(post_rec); // 書込用配列へ格納
  }
  // --- データの書込（setValuesの要素の数とgetRangeの範囲のセル数は一致しないとエラー）
  sheet.getRange(1,1,post_list.length,post_list[0].length).setValues(post_list);
}
```
という感じでやると、あらかじめ作成してあるTestというシートのデータが更新されます。
<img width="600" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/20844/f8aef8f8-297f-cabb-8610-c835f49b1aec.png" />

:::note warn
目的のタグが未設定だった場合の処理を書いていないので、最後のレコードは変になっています:sweat:。
:::

:::note info
更新する際に流し込んでいるデータは、title, url, date が一単位の二次元配列になっています。
```bash:データの中身（注：改行・空白の調整をして見やすくしています）
[
　　　　[
　　　　　　　　　title, 
　　　　　　　　　url, 
　　　　　　　　　date
　　　　　], 
　　　　[
　　　　　　　　　【モンストに聞いた】広告戦略のイマとミライ、課金型ゲームにおけるハイブリッド型広告マネタイズとは #TGS2022セッションレポート, 
　　　　　　　　　https://supership.jp/magazine/seminar-report/7882/, 
　　　　　　　　　2022-10-20
　　　　　],
　　　　[
　　　　　　　　　ポストCookie時代に改めて問われるアドベリフィケーションの重要性〜JICDAQ認証記念スペシャル対談〜, 
　　　　　　　　　https://supership.jp/magazine/column/7306/, 
　　　　　　　　　2022-10-19
　　　　　],
...略...
]
```
:::

## 運用しやすくする
必要な機能のスクリプトを書き、動作の確認ができました。スクリプトを不定期に低頻度で利用するだけであれば、拡張機能メニューから指定して実行すれば済みます。だた、それでは以下のような問題も出てきます。
- 普通の人が使えない
- 定期的に自動で実行できない
- GASの編集画面を一般ユーザーの目に触れさせたくない

これらの問題に役に立ちそうな機能として、以下が利用できます。
- トリガー
- サイドエリア
- 独自メニュー
- シートの保護

### トリガー
GASには、定期タスクを実行を指定するための「トリガー」という機能があります。トリガーは、機能拡張のページの時計マークのアイコンから設定画面に入れます。右下の「トリガーを追加」ボタンでトリガーを設定しましょう。ポイントとしては以下の4点です。

- 実行する関数を指定する
- 時間はだいたいでしか指定できない
- 時間以外にファイルを開くなどのイベント等もトリガーにできる
- スクリプトからも設定できる

:::note warn
関数を指定する際に、引数などを指定することはできないので、その場合は、トリガーで指定する用に、引数を指定して関数を起動する関数を作ったりして対応しています。
:::

<img width="300" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/20844/3fa318a8-c3cb-a6d6-67d3-c8ff3597ef39.png" />

#### スクリプトからトリガーを指定する
トリガーをスクリプトから指定すると詳細な時間の指定が可能なります。スクリプトの失敗を検知した場合に起動し、1時間後に再実行するスクリプトなどで利用できます。また、詳細な時間設定が必要なトリガーを設定したい場合など、エディタ画面から一度だけ実行するスクリプトなどを書いて実行するのもよいでしょう。

```js:例
/** 現在時刻の一時間後にトリガーを設定
 * 前提: 再実行日時の文字列を返す（受け取った文字列はSlack通知で利用）
 * 前提: trDo_checkUpdate などの起動用関数を用意し、目的の関数に引数を渡す
 * 使用例: setTrigger('trDo_checkUpdate');
 */

function setTrigger(functionName) {
  const date = new Date();
  const nextH = date.getHours() + 1;
  date.setHours(nextH);
  ScriptApp.newTrigger(functionName).timeBased().at(date).create();
  return Utilities.formatDate(date, "Asia/Tokyo", "yyyy/MM/dd HH:mm:ss" + 'に再実行します');
}
```
:::note warn
```trDo_```などの自分で決めた文字を頭に付けることで、トリガー設定画面で指定しやすくしています（関数の数が増えると探すのが大変になるので）。
:::

### サイドエリア
GASでは、エディタにスクリプトの他に、HTMLファイルを追加できます。
<img width="300" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/20844/8b021a03-1702-a371-2908-3546696694f4.png" />
追加したHTMLは、ダイアログで表示したり、サイドエリアで表示したりできます。使用方法の提示をしたり、スクリプトと連携して入力フォームや結果表示などに利用するとよいと思います。

```HTML:readme.html
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset='utf-8'>
  <title>お読みください</title>
</head>
<body>
	<header>
		<h1>お読みください</h1>
	</header>
	<article>QAメニュー</article>
  <article>定期実行</article>
  <article>Slack</article>
	<footer>
		<p>&copy; 2022 Supership Inc. All Rights Reserved</p>
  </footer>
</body>
</html>
```
サイドエリアに表示させるスクリプトはこちらです。
```js:例
function showReadMe(){
  let htmloutput = HtmlService.createHtmlOutputFromFile('readme').setTitle('Read Me');
  SpreadsheetApp.getUi().showSidebar(htmloutput);
}
```
スクリプトを実行すると、サイドエリアにHTMLが表示されます。
<img width="400" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/20844/e1c84e4a-374e-26b3-57ec-a97d25b49d84.png" />

:::note
HTMLには、BootstrapなどのフレームワークやJavaScriptなどを埋め込むことも可能なようです。
:::

### 独自メニュー
スプレッドシートの標準で表示されるメニューに追加して、独自のメニューを追加することができます。これは、開いているスプレッドシートのみで表示され、他のスプレッドシートへは影響しません。よく使う機能を開発者以外のユーザーへ提供するのに便利です。
ここでは、先程のサイドエリアの表示／非表示を切り替えるメニューを作ってみたいと思います。

```js:例
/** 独自メニュー
 * ・トリガーで起動時に initMenu を実行するように設定
 * ・起動時、メニューの表示までが結構遅いので、辛抱して待つ
 */
function initMenu(){ 
  SpreadsheetApp.getUi()
      .createMenu('オレオレメニュー')
      .addItem('READMEを表示', 'showReadMe')
      .addToUi();
}
```
スクリプトを実行すると、メニューが追加され、指定した関数をメニューから呼び出すことが可能になります。

<img width="400" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/20844/1eb175ca-c70c-5de3-4fa5-8ba812a685e1.png" />

トリガーで起動時に initMenu を実行するように設定すると、手動で毎回呼び出す手間がなく便利です。

<img width="400" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/20844/e883a1d1-6b12-b493-db2c-429ba53cea0e.png" />

### シートの保護
設定のためのシートなど、常時表示していると、なにも知らないユーザーによって誤って編集されて、スクリプトが実行できないなどの不具合が起きる可能性があります。また、普段使用しないシートは表示しない方が、画面がスッキリします。
ここでは、シート保護し、編集可能なユーザーを指定して、独自メニューに表示／非表示の項目を追加します。

シートの保護設定は、,メニューの「データ>シートと範囲の保護」を実施すると、サイドエリアで設定できるようになります。
<img width="300" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/20844/4f51c20f-6966-70fb-ade1-31c72e708ff7.png" /><br />

今回はシートが保護対象なので、シートタブを選択し、設定を行います。
<img width="300" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/20844/42045f90-00fb-4e87-d601-0ed3026db10b.png" />

<img width="300" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/20844/ad7a5173-8c5b-2a78-46b1-91470506a59e.png" />

「権限を設定」ボタンをクリックすると、権限を編集できます。「自分のみ」のドロップダウンリストから、カスタムを選択すると、指定したユーザーが編集できるよう権限設定できます。
<img width="300" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/20844/1ac86b17-d8f2-964d-e495-02dcd0172a58.png" />

シートの保護が有効になると、タブに鍵のアイコンが表示されるようになります。

<img width="300" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/20844/da36b753-b6d1-8107-9ba0-22abe5946827.png" />

シートの保護ができたら、シートの表示/非表示を切り替える関数を作成します。
```js:例
/** 「設定を表示/非表示」メニュー */
function configShowHide(){
  // スクリプト　プロパティ　SHEET_ID　を読み込む（すでに関数外で定義している際は要コメントアウト）
  const SHEET = PropertiesService.getScriptProperties().getProperties().SHEET_ID;
  // "config"シートのオブジェクト化
  const sheet = SpreadsheetApp.openById(SHEET).getSheetByName("config");
  // シートが非表示かどうかを確認し、表示/非表示の処理を実施
  if(sheet.isSheetHidden()){
    sheet.showSheet(); // シートを表示する
  } else {
    sheet.hideSheet(); // シートを非表示にする
  } 
}
```
関数が想定どおりに動作するのを確認したら、先程作成した独自メニューに追加します。
```diff_javascript: function initMenu()に行追加
      .createMenu('オレオレメニュー')
+      .addItem('設定を表示/非表示', 'configShowHide')
      .addItem('READMEを表示', 'showReadMe')
```
メニューが追加された様子
<img width="300" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/20844/33e0c3b1-21a7-c78b-6292-e19f2f7deae8.png" >

## おわりに
以上、GASに定期タスクを設定した際のメモを書き留めてみました。そもそもGASをよく知らなかったという方や、知らなかった機能があったという方の参考になれば幸いです。

この記事では、メモということで内容を軽くさらった程度ですが、実際にはWEBページからの情報だけでなく、SDKのリポジトリのデータをチェックして、各方面の情報を比較して更新の判断したりしています。また記事では触れていませんが、シートの更新と同時にSlackへの通知を行ったりもしています。

調べて使用しなかった機能のうち、トリガーのカレンダーベースでの起動や、ユーザーのシートの編集をトリガーにする機能などは利用できるケースが多そうです。Googleフォームなどとの組み合わせ次第では簡易なCRMのようなものもできそうです。

:::note
**最後に宣伝です。**

Supershipではプロダクト開発やサービス開発に関わる人を絶賛募集しております。
ご興味がある方は以下リンクよりご確認ください。
[Supership 採用サイト](https://supership.jp/recruit/)
是非ともよろしくお願いします。
:::