Playwrightでスクショ前に個人情報の写り込みを止める|見えないリンク先に本番IDが残っていた

開発ログ

自作のGASアプリの画面を記事や動画に載せたいと思っていました。ただ、本番の画面をそのまま撮ると、実データや固有名が写ります。わたしは顔も名前も出さずに発信しているので、画面に1文字でも身元の手がかりが残れば、それで終わりです。

そこで、アプリに撮影用のダミーモードを付けました。やっていることは3つです。

  • 本番に触れない: アプリのHTMLを読み込み、サーバー呼び出しを架空データを返す模擬に差し替えて、手元のブラウザで描画する
  • 撮る前に画面を走査する: 描画し終わった画面の文字・入力値・リンク先・iframeの中まで調べ、NG語や実在のメールアドレス、IDの形をした文字列を探す
  • 合格した画面だけ撮る: 1件でも見つかったら、その画面は撮らずに止める

いまは4つのアプリ・13画面がすべて走査に合格しています。ただ、最初の走査では画像には写っていない漏れが見つかりました。この記事の本題はそこです。

なぜ「撮ってから確認」ではだめなのか

これまでは、撮ったスクリーンショットを目で見て確認していました。原稿のテキストは、公開前に正規表現で走査するスクリプトを通しています(パターンは公開前チェックで実際に使っている正規表現パターン集にまとめています)。

ただ、この走査は画像の中の文字を読めません。画像を文字に起こす(OCR)方法もありますが、小さい文字や色の薄い文字は読み落とします。

そこで順番を逆にしました。画像になる前の画面、つまりブラウザが描画し終わった状態のページを調べれば、文字はすべてテキストとして取り出せます。OCRは要りません。

1. 本番に触れずに描画する

対象は自作GASアプリ群のうち、公開名で「共通ログイン」「ノート」「連絡帳」「ダッシュボード」の4つです。GASのWebアプリは、画面側のHTMLから google.script.run.関数名() でサーバーの関数を呼びます。ここを架空データを返す模擬に差し替えれば、GASにもスプレッドシートにもGoogleアカウントにも接続せずに画面が動きます。

google.script.run は withSuccessHandler(f).withFailureHandler(g).関数名(引数) とつなげて呼ぶ形です。どの関数名が来ても受けられるように、Proxy で作りました。

// 画面のHTMLより前に差し込む模擬(例)
const handlers = {
  listItems: () => [{ id: 1, title: 'サンプルの項目' }],
  getUser:   () => ({ name: '管理者' }),
};

function makeRunner() {
  let ok = () => {}, ng = () => {};
  const runner = new Proxy({}, {
    get(_, name) {
      if (name === 'withSuccessHandler') return f => (ok = f, runner);
      if (name === 'withFailureHandler') return f => (ng = f, runner);
      // それ以外は「サーバー関数の呼び出し」として扱う
      return (...args) => setTimeout(() => {
        try { ok(handlers[name] ? handlers[name](...args) : { ok: true }); }
        catch (e) { ng(e); }
      }, 50); // 本物と同じく非同期で返す
    },
  });
  return runner;
}

window.google = { script: {} };
// 呼び出しのたびに新しいrunnerを返す(ハンドラーが混ざらないように)
Object.defineProperty(window.google.script, 'run', { get: makeRunner });

ポイントは2つです。

  • run をゲッターにして、呼び出しのたびに新しい模擬を返す。1つを使い回すと、前の呼び出しの成功時の処理が次の呼び出しに残る
  • 戻り値の形は、サーバー側のコードを読んで合わせる。形が違うと、画面が空のまま描画されて気づきにくい

架空データは全部作り直しました。メールアドレスは例示用に予約されているドメイン(example.com)だけにしています。連絡帳は架空の8件、ダッシュボードの予定と期限は表示した日の今月に並ぶように組み立てています。いつ撮っても、カレンダーが空にならないようにするためです。

撮影中は、ブラウザから外への通信を遮断しています。許可しているのは、公開ライブラリの配信元(CDN)とフォントだけです。もし模擬の漏れで本物のサーバーを呼ぼうとしたら、遮断されて警告に出ます。

2. 固有名を公開名に置き換える

元のHTMLには、アプリの本来の名前が入っています。これを公開用の一般名(共通ログイン・ノート・連絡帳・ダッシュボード)に置き換える表を持たせました。置き換えは9組です。あわせて、本番のWebアプリのURLは架空のURLに、組織のドメインは example.com に置き換えます。

ほかに、アプリごとに次の手当てをしました。

  • ダッシュボードの元ソースの先頭にあったコメント(作者名入り)は、画面には出ないが配信するHTMLからも消した
  • ファビコンのURLにファイルIDが入っていたので、タグごと外した
  • 連絡帳とダッシュボードのロゴは、元アプリが分かる図案だったので一般的なアイコンに差し替えた

共通ログインの管理画面。連携アプリ名は公開名、アカウントはexample.comのみ

3. 撮る前に画面を走査する

ここが本体です。Playwright(ブラウザを自動で操作するライブラリ)でページを開き、描画が終わったところで、画面に写りうる文字を全部集めます。

  • 表示されているテキスト(script や style の中は除く)
  • ページタイトル
  • 入力欄とテキストエリアの値
  • title alt placeholder aria-label href src などの属性
  • Shadow DOMの中、iframeの中

集めた文字に対して、NG語と正規表現を当てます。NG語は表記ゆれを含めて29個、正規表現は4種類(example.* 以外のメールアドレス、Apps ScriptのデプロイID、URLに含まれるGoogleのファイルID、スクリプトIDの形)です。NG語の中身は、本名、所属先やアプリの本来の名前、検証用に使っている実在のアカウント、検証データに入っていた実在の地名などです。

骨格だけ書くと、こうなります。

// Playwright の page を渡して、画面に写りうる文字を走査する(例)
const NG_WORDS = ['本名', '所属先の名前']; // 実際は自分用のリストを置く
const NG_REGEX = [
  /[\w.%+-]+\x40(?!example\.(com|org|net)\b)[\w.-]+\.[a-z]{2,}/gi, // 実在しうるメール(\x40 はアットマーク)
  /AKfyc[\w-]{20,}/g,                                             // GASのデプロイID
];

async function scanPage(page) {
  const texts = [];
  for (const frame of page.frames()) {           // iframe の中も見る
    texts.push(...await frame.evaluate(() => {
      const out = [document.title];
      const tw = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
      for (let n; (n = tw.nextNode());) {
        if (!n.parentElement.closest('script,style')) out.push(n.nodeValue);
      }
      document.querySelectorAll('*').forEach(el => {
        for (const a of ['title', 'alt', 'placeholder', 'aria-label', 'href', 'src']) {
          const v = el.getAttribute(a); if (v) out.push(v);
        }
        if ('value' in el && el.value) out.push(el.value);
      });
      return out;
    }));
  }
  return texts.flatMap(t => [
    ...NG_WORDS.filter(w => t.includes(w)),
    ...NG_REGEX.flatMap(re => t.match(re) || []),
  ]);
}

// 使い方: 1件でも見つかったら撮らない
const hits = await scanPage(page);
if (hits.length) { console.log('撮影しない', hits); process.exitCode = 1; }
else await page.screenshot({ path: 'shot.png' });

見つかったものがあれば、その画面は撮影せず、どの文字がどこ(本文・属性・iframe)にあったかを出して終了コード1で止めます。直してから、もう一度流します。

初回の走査で見つかったもの

最初に全部の画面を流したとき、止まった画面がありました。

ノートのダミー画面のリンク先に、本番のデプロイIDが残っていました。

このダミー画面は、前日に別の作業で作ったものです。架空データで描画して、見た目は確認済みでした。画面上に表示されている文字には、何も問題がありません。ところが、画面内のリンクの href、つまり画面には表示されないリンク先に、本番のWebアプリのURLがそのまま入っていました。GASのWebアプリのURLには、デプロイIDが含まれます。

スクリーンショットには写りません。前日の画像も確認しましたが、写っていませんでした。なので、公開済みの画像を差し替える必要はありませんでした。

ただ、これは目で見るチェックでは見つからない種類の漏れです。画像ではなくHTMLや動画の画面操作でリンクにマウスを乗せれば、ブラウザの左下にURLが出ます。記事にHTMLのまま埋め込んでいたら、そのまま公開されていました。わたしの簡易チェックには「リンク先」という項目がありますが、これは記事に貼るURLのことで、スクリーンショットの中のリンクまでは想定していませんでした。

同じ走査で、テーマ(配色)の名前の1つに所属先に由来する名前が入っていたのも見つかりました。これも画面に出る名前だったので、テーマ名を「ミント/ダーク/ライト」の一般名にしています。

対策として、本番のWebアプリのURLは、置き換えの段階で架空のURLにしています。リンク先に残っていても、走査が必ず止めます。

別のAIの審査で指摘されたこと

走査に全部合格したあと、公開前の審査を別のAIに通しました(わたしは公開前のチェックを、書いた本人とは別のAIに任せています。構成はClaude Codeをサブエージェントで役割分担するに書いています)。ここで2つ指摘を受けました。

1つ目: 自分の発信名が、撮影用のダミー画面に写っていた。

ダミーモードで描画した共通ログインの管理画面には、発信名の入った機能の案内と説明文があり、配色の名前の1つも発信名でした。わたしは「身元を守る=画面から本名や所属先を消す」と考えていたので、発信名は出ても問題ないと思っていました。

指摘はこうでした。アプリの画面に発信名が写ると、逆方向に結び付く。つまり、このアプリの画面を見たことがある人が記事を読んだとき、「このアプリを作った人が、この発信者だ」と分かってしまう。本名から発信名にたどり着く道だけでなく、アプリから発信名にたどり着く道もふさぐ必要がありました。

発信名そのものをNG語に加えて、該当する案内とリンクをダミーモードの画面から外しました。

2つ目: マインドマップの架空データが、自分の実際の運用に似すぎていた。

ノートのマインドマップ画面には、架空データとして「AIに任せる仕組みづくり」のような題材のマップを入れていました。単語ひとつひとつはNG語ではないので、走査は通ります。ですが、並んだ項目の組み合わせが、わたしが実際に発信している内容とほとんど同じでした。これも、アプリと発信者を結び付ける手がかりになります。

題材を、まったく関係のない引っ越しの準備に差し替えました。29個の項目を1つずつ置き換えています。

マインドマップの架空データ。題材は引っ越しの準備

この2つは、どちらも走査では拾えない種類でした。文字列としては無害で、意味や組み合わせが問題だったからです。

走査で拾えないもの

走査が見ているのは文字だけです。次のものは、いまも目で見て確認しています。

  • ロゴ・アイコンの図案: 連絡帳の元のロゴは、アプリ名の頭文字を組み合わせた図案でした。文字として取り出せないので走査を通ります。一般的な図案に差し替えました
  • 配色・レイアウト: 元のアプリを見たことがある人なら、配色や画面の並びで分かります。これは消せないので、どの画面を載せるかを選ぶ段階で判断しています
  • 時計や日付: 画面に現在時刻が出るアプリは、撮った時刻が写ります。生活の時間帯の手がかりになるので、載せる画像から外すか、切り抜きます
  • 組み合わせの意味: 上のマインドマップのように、1語ずつは無害でも、並ぶと手がかりになるもの

機械の走査は、撮った13枚すべてで検出0でした。記事に載せた3枚(共通ログインの管理画面、マインドマップ、連絡帳の一覧)は、目で見て確認しています。

連絡帳の一覧画面。架空の8件だけで描画し、ロゴは一般的な図案に差し替えた

結果

項目 結果
対象 4アプリ・13画面
走査の結果 13画面すべて合格
NG語 / 正規表現 29個 / 4種類
公開名への置き換え 9組
初回の走査で止めたもの 見えないリンク先のデプロイID、所属先に由来するテーマ名
審査で追加した対策 発信名をNG語に追加、マインドマップの題材を差し替え
1回の走査と撮影にかかる時間 未計測

わかったこと

  • スクリーンショットの確認は、画像になる前の画面でやる方が確実だった。画像を目で見る確認では、見えない属性やリンク先の漏れは見つからない
  • 身元の手がかりは「本名から発信名へ」の向きだけではない。アプリや道具から発信名への向きもある。記事に載せるダミー画面に自分の発信名が写るのも漏れだった
  • 架空データは、単語がNGでなくても、自分の実際の関心に似ていたら手がかりになる。無関係な題材を選ぶ
  • 機械で止められるのは文字まで。図案・配色・組み合わせの意味は、最後に人の目(か、別のAI)で見る

このダミーモードで撮った画面を使って、連絡帳とダッシュボードの開発記を書く予定です。共通ログインの仕組みそのものは複数のGASアプリのログインを1つにまとめた話に書いています。

関連記事:

自作GASアプリ群の開発記(noteマガジン・無料)
共通ログイン・ノート・連絡帳・ダッシュボードを作ってきた記録を、1つのマガジンにまとめています。
マガジンを読む

コメント

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