Googleコンタクトをチームの共有電話帳にする(GAS)|People APIで読み書きし、宛名ラベル印刷と地図表示まで

Google Workspace・GAS

Googleコンタクトは便利ですが、基本的に1つのアカウントの持ち物です。チームや家族で「同じ電話帳を見たい、誰が直しても全員に反映されてほしい」と思うと、無料の範囲ではちょうどよい方法が見つかりませんでした。

わたしは仕事の道具をGoogle Workspaceのサービスだけで完結させると決めているので、外部の共有電話帳サービスは使いたくありません。連絡先はGoogleコンタクトに置いたまま、足りない「共有」の部分だけを自分で補うことにしました。

そこで、わたしはGoogle Apps Script(GAS)で小さなWebアプリ(以下「連絡帳」)を作りました。共有用のGoogleアカウント1つの連絡先を、許可した人だけがブラウザから検索・編集できるアプリです。ついでに、宛名ラベルや封筒の印刷と、住所を地図に並べる機能も付けました。

先に結論を書きます。

  • 連絡先の実体はGoogleコンタクトのまま。スプレッドシートにコピーしない。読み書きはPeople APIで行う
  • 「共有用アカウントの連絡先を読む権限」と「誰が使ってよいか」を分けた。前者はOAuthで一度だけ認可して保存、後者は自作の共通ログインで判定する
  • 印刷はGASでPDFを作らず、ブラウザの印刷機能に任せた。mm単位のCSSで用紙に合わせる
  • 地図はGASの Maps サービスで住所を緯度経度に変え、静的地図の画像にピンを立てる

連絡帳アプリの連絡先一覧画面(架空のデータ)

※画面はすべて撮影用の架空データです。

全体の構成

最初に決めたのは「誰の連絡先を、誰の権限で読み、誰に見せるか」です。

役割 中身
連絡先の持ち主 共有用のGoogleアカウント1つに固定。ここの連絡先が「共有電話帳」になる
画面(フロント) GASのWebアプリ。executeAs: USER_ACCESSING。開いた人にGoogleログインを求め、本人のメールアドレスを取る。利用者自身の連絡先の権限は要らない
接続用のWebアプリ(バックエンド) 別のGASのWebアプリ。executeAs: USER_DEPLOYING。共有用アカウントの認可(refresh token)をスクリプトプロパティに持ち、People APIを呼ぶ
誰が使えるか 共通ログインの登録(アカウント単位・Googleグループ単位)で判定

画面とバックエンドを分けたのは、連絡先の持ち主と、スクリプトを置いたアカウントが別だったからです。同じアカウントなら、GASの拡張サービス「People API」を USER_DEPLOYING のWebアプリから呼ぶだけで足りるはずです(わたしはこの形は試していません)。

「誰が使えるか」の管理は、自作のGASアプリ群で共通にしている仕組みを使いました。詳しくは複数のGASアプリのログインを1つにまとめたに書いています。Webアプリで本人のメールアドレスが取れる条件はGAS Webアプリの認証|getActiveUserが空になる時の判断表にまとめています。

決めごとは次の3つです。

  • 共有用アカウントが未接続のときは止める。開いた人の連絡先に切り替えて表示することはしない
  • 連絡先のコピー(スプレッドシート)は作らない。二重管理になるため
  • 差出人の情報(印刷に使う自分の住所など)は利用者ごとに UserProperties に置く

共有用アカウントの連絡先をOAuthで読めるようにする

バックエンドは、共有用アカウントのrefresh tokenを持っておき、必要なときにアクセストークンに換えてPeople APIを呼びます。準備は次の順でした。

1. Google Cloudでプロジェクトを作り、People APIを有効化する
2. OAuthクライアント(ウェブアプリケーション)を作り、承認済みリダイレクトURIにバックエンドの /exec のURLを登録する
3. バックエンドの doGet を、認可コードを受け取るコールバックにする
4. 共有用アカウントでログインして一度だけ認可する。スコープは https://www.googleapis.com/auth/contacts(読み書き)

認可のコールバックでは、次を確かめてから保存するようにしました。

  • 使い捨ての state(10分で失効)と PKCE
  • 認可したアカウントが決めておいた共有用アカウントと一致するか(別のアカウントで認可されると、その人の連絡先が共有されてしまう)
  • contacts スコープが付いているか、refresh token が返ってきたか

refresh token などの秘密情報はスクリプトプロパティだけに置き、コード・ログ・ブラウザには出しません。

People APIで連絡先を読む

一覧の取得は people/me/connections です。汎用の例を書きます。

// バックエンド側(例): 共有用アカウントの連絡先を読む
const FIELDS = 'names,organizations,emailAddresses,phoneNumbers,addresses,userDefined,metadata';

function listContacts(accessToken, syncToken) {
  const q = { personFields: FIELDS, pageSize: 1000 };
  if (syncToken) {
    q.syncToken = syncToken;            // 前回からの差分だけ
  } else {
    q.requestSyncToken = true;          // 次回の差分用トークンをもらう
    q.sortOrder = 'FIRST_NAME_ASCENDING';
  }
  const qs = Object.keys(q).map(k => k + '=' + encodeURIComponent(q[k])).join('&');
  const res = UrlFetchApp.fetch('https://people.googleapis.com/v1/people/me/connections?' + qs, {
    headers: { Authorization: 'Bearer ' + accessToken },
    muteHttpExceptions: true,
  });
  if (res.getResponseCode() === 410) return { expired: true }; // 同期トークン切れ → 全件取り直す
  if (res.getResponseCode() !== 200) throw new Error('People API: HTTP ' + res.getResponseCode());
  return JSON.parse(res.getContentText()); // connections / nextPageToken / nextSyncToken
}

ポイントは3つです。

  • pageSize は最大1000。件数が多ければ nextPageToken で続きを取る
  • 最初に requestSyncToken=true を付けると nextSyncToken が返る。次からそれを渡すと、追加・変更・削除の差分だけが返る。削除された連絡先は metadata.deleted が付いて返るので、画面側で消す
  • 同期トークンが古くなると HTTP 410 が返る。そのときは全件取得に戻す

アプリ独自の項目(タグ、敬称、緯度経度)は、連絡先の userDefined(カスタム項目)に入れました。データの置き場を増やさずに済みます。

書き込みで気をつけたこと

更新は people/{id}:updateContact、新規は people:createContact、削除は people/{id}:deleteContact です。複数人で編集するので、次の3つを入れました。

  • ETagを確認してから書く。画面が持っているETagと最新のETagが違えば、「他の人が更新しました。再読み込みしてください」で止める
  • 書き込みは LockService.getScriptLock() で1件ずつ順番に処理する
  • updatePersonFields には変わった項目だけを渡す。電話番号を1つ直しただけで住所まで書き換えないようにする。2つ目以降の電話番号や、アプリが知らないカスタム項目も消さない

住所を直したのに、古い住所の緯度経度が残るという不具合を途中で作りかけました。いまは「住所が変わって、緯度経度が変わっていなければ緯度経度を消す」ようにしています。

宛名ラベル・封筒・FAX送信状を印刷する

印刷スタジオ画面。宛名ラベルの列数・行数・寸法を入力する(架空のデータ)

印刷は、選んだ連絡先から印刷用のHTMLを組み立てて新しいタブに書き出し、ブラウザで印刷する方式にしました。PDFで残したいときは、印刷画面で「PDFに保存」を選びます。対応した形式は5つです。

形式 用紙サイズ
FAX送信状 A4(210×297mm)
はがき 100×148mm
封筒 長形3号 120×235mm
封筒 角形2号 240×332mm
宛名ラベル A4、寸法は入力式

FAX送信状は「送信状を印刷するだけ」で、FAXそのものは送りません。

宛名ラベルは、ラベル用紙のメーカーごとに寸法が違うので、列数・行数・1枚の幅と高さ・上と左の余白・間隔を入力できるようにしました。初期値は2列×6行、86.4×42.3mm、上余白21.2mm、左余白18.6mmです。汎用の例です。

// 画面側(例): A4のラベル用紙に宛名を並べるHTML
function labelSheetHtml(addresses, l) { // l = {cols, rows, width, height, top, left, gapX, gapY, start}
  const cells = Array(l.start - 1).fill(null).concat(addresses); // 途中から使う用紙に対応
  const per = l.cols * l.rows;
  let body = '';
  for (let i = 0; i < cells.length; i += per) {
    body += '<section class="sheet"><div class="grid">' +
      cells.slice(i, i + per).map(a => '<div class="label">' + (a ? a.html : '') + '</div>').join('') +
      '</div></section>';
  }
  const css =
    '@page{size:210mm 297mm;margin:0}' +
    '.sheet{width:210mm;height:297mm;overflow:hidden;break-after:page}' +
    '.grid{display:grid;grid-template-columns:repeat(' + l.cols + ',' + l.width + 'mm);' +
    'grid-auto-rows:' + l.height + 'mm;column-gap:' + l.gapX + 'mm;row-gap:' + l.gapY + 'mm;' +
    'margin:' + l.top + 'mm 0 0 ' + l.left + 'mm}';
  return '<!doctype html><meta charset="utf-8"><style>' + css + '</style>' + body;
}
// const w = window.open('', '_blank'); w.document.write(labelSheetHtml(list, opts)); w.document.close();

宛名の文字列は、HTMLに入れる前に必ずエスケープします(例では省略しています)。

宛名ラベルの印刷プレビュー。A4に2列で宛名が並ぶ(架空のデータ)

実際に使ってみて入れたものです。

  • 開始位置: 半端に残ったラベル用紙の続きから印刷できる(例の start)
  • 位置調整: プリンターごとのずれを、上下左右それぞれ−20〜20mmで補正する
  • はみ出し検知: プレビューを開いたときに、文字が枠からはみ出したラベルや用紙があれば印刷ボタンを押せなくする
  • 印刷時の設定は「倍率100%・余白なし・ヘッダーとフッターなし」をプレビューの上に表示しておく。ここがずれると、どれだけCSSを合わせても位置が合わない
  • 1回に印刷できる宛先は200件まで。FAX送信状はFAX番号、それ以外は住所がない宛先があれば、名前を出して止める

最初は普通紙に印刷し、ラベル用紙と重ねて位置を確かめると無駄にしにくいです。

住所から地図にピンを立てる

「コンタクトマップ」では、一覧で選んだ連絡先(最大20件)を1枚の地図に並べます。GASには Maps サービスが最初からあるので、APIキーを用意せずに使えました。

// バックエンド側(例): 住所 → 緯度経度 → ピン付きの静的地図
function plot(contacts) {
  const map = Maps.newStaticMap().setSize(640, 480).setLanguage('ja');
  const points = [];
  contacts.slice(0, 20).forEach(c => {
    let loc = c.lat && c.lng ? { lat: +c.lat, lng: +c.lng } : null; // 保存済みの座標を優先
    if (!loc) {
      const r = Maps.newGeocoder().setLanguage('ja').setRegion('jp').geocode(c.address);
      if (r.status !== 'OK' || !r.results.length) return; // 特定できない住所は飛ばして一覧で知らせる
      loc = r.results[0].geometry.location;
    }
    const label = String.fromCharCode(65 + points.length); // A, B, C...
    map.setMarkerStyle(Maps.StaticMap.MarkerSize.MID, Maps.StaticMap.Color.BLUE, label)
       .addMarker(loc.lat, loc.lng);
    points.push({ name: c.name, label: label, lat: loc.lat, lng: loc.lng });
  });
  return { points: points, image: 'data:image/png;base64,' + Utilities.base64Encode(map.getBlob().getBytes()) };
}
  • 地図は画像(PNG)で返し、画面では <img> に入れるだけ。ピンのA・B・Cと、横の一覧を対応させる
  • ジオコーディングの結果が ROOFTOP(番地まで一致)でないときや partial_match のときは「概算」と表示する。番地の書き方によっては近くの別の場所に立つので、正確にしたい連絡先は緯度経度を保存できるようにした
  • 各地点には、Googleマップの検索URL(https://www.google.com/maps/search/?api=1&query=緯度,経度)と経路案内のURL(.../maps/dir/?api=1&destination=...)のリンクを付けた。スマホで開けばそのままナビになる

つまずいたこと

1. 画面からバックエンドを呼ぶとHTTP 401

画面側のGASから、UrlFetchApp でバックエンドのWebアプリを呼ぶとHTTP 401が返りました。

原因を切り分けたログは残していません。疑っているのは、GASのWebアプリの公開範囲を「ログインが必要」にしていると、サーバーから UrlFetchApp で呼んだリクエストがログイン済みとして扱われない、という挙動です(未確認)。わたしは次の形に変えたところ、401は出なくなりました。

  • バックエンドの公開範囲は「全員(匿名を含む)」にして、HTTPの入口は開ける
  • その代わり、画面側は毎回利用者本人のアクセストークン(ScriptApp.getOAuthToken())を本文に入れて送る
  • バックエンドは受け取ったトークンでGoogleの userinfo を呼び、verified_email が真のメールアドレスだけを本人として扱う。リクエストの中に「わたしは○○です」と書かれたメールアドレスは信用しない
  • そのメールアドレスが共通ログインに登録されているかを確認してから、連絡先を読む

入口を匿名に開けるのは不安でしたが、本人確認をHTTPの手前(公開範囲)から、中身(トークンの照会)に移したと考えると筋が通ります。照会結果は2分だけキャッシュしています。

補足として、前日の記事「GASのUSER_DEPLOYINGとUSER_ACCESSINGを分けた|管理画面に管理者が入れない」の管理画面でも、利用者のトークンを付けてGASのAPIを呼んだときにGoogle側の入口でHTTP 401になり、画面側のスコープにDriveの読み取り権限を含めて対応しました。同じ環境の別の画面でも、入口で401になった例があります(同じ原因かは確かめていません)。

2. ときどき404や「認証画面が返った」

しばらく使うと、たまにHTTP 404や、JSONの代わりにGoogleのHTML画面が返ることがありました。データや登録が壊れたわけではなく、GASからGASへの通信が一時的に遅れたときに、エラー用の応答が返っていたようです。

  • 起動時に3回呼んでいた問い合わせ(使えるか・配色・共有用アカウントの状態)を1回にまとめた
  • 読み取りだけは、404・タイムアウト・HTML応答・5xxのとき最大3回まで待ってやり直す
  • 登録・更新・削除は自動でやり直さない。二重に登録されるのを防ぐため

3. 7日ごとに「再接続してください」

OAuthの同意画面を「テスト」のままにしていると、refresh tokenは原則7日で失効します。いまは失効したら画面に再接続の案内を出し、共有用アカウントでもう一度認可する運用です。ずっと使うなら、OAuthアプリを本番公開して必要な確認を通すのが本筋です(未対応)。

4. グループで登録した人だけ入れない

共通ログインの「Googleグループ単位の登録」を全アプリ共通に変えたあと、このアプリだけ、グループをアプリ名で絞り込む古い判定が残っていました。グループ経由の人が全員拒否されていたので、絞り込みを外してテストを足しました。

その後に足したもの(9月28日)

使いながら、次の3つを追加しました。

  • 詳細画面: 一覧で名前を押すと、電話・メール・住所・リンク・タグ・メモ全文を1画面で見られる
  • SNS・チャットのリンク欄: Googleコンタクト標準のウェブサイト欄(urls)に、サービス名をラベルにして保存する。URLを貼るとサービスを自動で判別し、javascript: などの危険な形式は保存しない
  • 起動時の配色のちらつき対策: 前回の配色を利用者ごとに保存し、最初の描画の前に当てる

わかったこと

  • 共有したいなら、コピーを作るより「持ち主を1つに決めてAPIで読み書きする」ほうが、Googleコンタクト本体やスマホとずれない
  • 「連絡先を読む権限」と「アプリを使ってよい人」は別物。混ぜると、開いた人の連絡先を誤って表示する作りになりやすい
  • 印刷はブラウザに任せると、はがき・封筒・ラベルを同じ仕組みで扱える。代わりに印刷設定の案内が要る
  • 自動テストは36項目(本人確認、未登録の拒否、ETagの競合、印刷寸法、地図など)。表示速度の差は未計測

次は、この連絡帳を含む自作アプリを、共通ログインの画面の中にまとめて開けるようにした話を書く予定です。

関連記事:

コメント

タイトルとURLをコピーしました