Google Apps Scriptのウェブアプリから、Screen Capture APIを使って画面をキャプチャできました。
Apps Scriptのウェブアプリはiframeの中で動作するため、navigator.mediaDevices.getDisplayMedia() を直接呼び出すとPermissions Policyによってブロックされます。
しかし、window.open() で別ウィンドウを開き、そこで getDisplayMedia() を呼び出すと動作しました。
さらに、キャプチャした画像を postMessage() で元のウェブアプリに送信し、2秒間隔で継続的に取得することもできました。
この記事では、実際に動作したコードと検証結果を紹介します。最後に、この方法を見つけた経緯も紹介します。
仕組み
Apps ScriptのHTML Serviceは、Googleが用意したiframeの中でHTMLを実行します。このiframeでは display-capture が許可されていないため、Screen Capture APIを使用できません。
そこで、別ウィンドウを開き、画面キャプチャを担当させます。
ポイントは、window.open() で空のウィンドウを開き、document.write() でHTMLを書き込むことです。
const popup = window.open(
'',
'_blank',
'width=520,height=520'
);
popup.document.write(html);
popup.document.close();
この方法で開いたポップアップは、元のページと同じオリジンを持ちながら、トップレベルのウィンドウとして動作します。
GoogleのHTML Serviceの制限事項によると、iframeのsandbox属性には allow-popups-to-escape-sandbox が設定されています。
これは、iframeから開いたポップアップに、元のiframeのsandbox制限を引き継がせないための設定です。
今回の環境では、ポップアップ側のPermissions Policyも display-capture: true となり、画面キャプチャに成功しました。
試してみる
1. Apps Scriptのプロジェクトを作成する
Google Apps Scriptで新しいプロジェクトを作成します。
必要なファイルは2つだけです。
-
Code.gs:ウェブアプリのHTMLを返す -
Index.html:画面キャプチャ、別ウィンドウとの通信、画像表示
2. Code.gs
function doGet() {
return HtmlService.createHtmlOutputFromFile('Index')
.setTitle('Screen Capture Test');
}
サーバー側のコードはこれだけです。
3. Index.html
HTMLファイルを追加して、次のコードを貼り付けてください。
Index.html の完全なコードを表示
<!DOCTYPE html>
<html>
<head>
<base target="_top">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body {
font-family: system-ui, sans-serif;
margin: 24px auto;
padding: 0 16px;
max-width: 800px;
}
button {
padding: 10px 16px;
margin: 4px;
cursor: pointer;
}
button:disabled {
opacity: 0.4;
cursor: default;
}
img {
width: 100%;
margin-top: 12px;
}
pre {
background: #f1f1f1;
padding: 12px;
white-space: pre-wrap;
overflow-wrap: anywhere;
font-size: 12px;
}
#status {
font-weight: bold;
}
</style>
</head>
<body>
<h2>Screen Capture Test</h2>
<button id="open">画面共有ウィンドウを開く</button>
<button id="shot" disabled>静止画を取得</button>
<button id="auto" disabled>自動取得を開始</button>
<button id="stop" disabled>共有を停止</button>
<p id="status">未接続</p>
<p id="info"></p>
<img id="image" alt="キャプチャ画像">
<h3>ログ</h3>
<pre id="log"></pre>
<script>
const $ = id => document.getElementById(id);
const ORIGIN = location.origin;
const INTERVAL_MS = 2000;
let popup = null;
let token = null;
let connected = false;
let capturing = false;
let autoMode = false;
let shotCount = 0;
function log(message) {
$('log').textContent +=
new Date().toLocaleTimeString() + ' ' + message + '\n';
}
function updateButtons() {
$('open').disabled = !!popup && !popup.closed;
$('shot').disabled = !connected || !capturing;
$('auto').disabled = !connected || !capturing;
$('auto').textContent = autoMode
? '自動取得を停止'
: '自動取得を開始';
$('stop').disabled = !popup || popup.closed;
}
function send(type) {
if (!popup || popup.closed || !token) return;
popup.postMessage({ type, token }, ORIGIN);
}
// ポップアップからのメッセージを受信
window.addEventListener('message', event => {
if (event.origin !== ORIGIN) return;
if (event.source !== popup) return;
const data = event.data;
if (!data || data.token !== token) return;
switch (data.type) {
case 'ready':
connected = true;
$('status').textContent = 'ポップアップ接続済み';
log('Popup ready');
break;
case 'started':
capturing = true;
$('status').textContent = '画面共有中';
log('Screen capture started');
break;
case 'image':
if (typeof data.image !== 'string' ||
!data.image.startsWith('data:image/jpeg;base64,')) {
log('Invalid image data');
break;
}
$('image').src = data.image;
shotCount++;
$('info').textContent =
data.width + ' × ' + data.height +
' / ' + shotCount + ' 枚取得';
log('Image received (' +
Math.round(data.image.length * 0.75 / 1024) +
' KB approx.)');
break;
case 'auto':
autoMode = data.enabled === true;
log(autoMode ? 'Auto capture ON' : 'Auto capture OFF');
break;
case 'stopped':
capturing = false;
autoMode = false;
$('status').textContent = '共有停止';
log('Screen capture stopped');
break;
case 'error':
log('ERROR: ' + data.message);
break;
}
updateButtons();
});
// 画面共有ウィンドウを開く
$('open').addEventListener('click', () => {
if (popup && !popup.closed) {
popup.focus();
return;
}
token = crypto.randomUUID();
connected = false;
capturing = false;
autoMode = false;
popup = window.open(
'',
'_blank',
'width=520,height=520'
);
if (!popup) {
log('Popup blocked');
return;
}
// ポップアップ内で実行するHTML
const html = `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Screen Capture Controller</title>
<style>
body {
font-family: system-ui, sans-serif;
padding: 16px;
}
button {
padding: 10px;
margin: 4px;
}
video {
width: 100%;
margin-top: 12px;
}
pre {
white-space: pre-wrap;
overflow-wrap: anywhere;
}
</style>
</head>
<body>
<h3>画面キャプチャ</h3>
<button id="start">画面共有を開始</button>
<button id="stop" disabled>停止</button>
<p id="status">待機中</p>
<video id="video" autoplay muted playsinline></video>
<pre id="debug"></pre>
<script>
const TOKEN = ${JSON.stringify(token)};
const ORIGIN = ${JSON.stringify(ORIGIN)};
const INTERVAL = ${INTERVAL_MS};
let stream = null;
let timer = null;
const video = document.getElementById('video');
const start = document.getElementById('start');
const stop = document.getElementById('stop');
const status = document.getElementById('status');
const debug = document.getElementById('debug');
// 親画面へメッセージを送信
function report(type, extra) {
if (!window.opener) return;
window.opener.postMessage(
Object.assign({ type: type, token: TOKEN }, extra || {}),
ORIGIN
);
}
function diagnostic() {
const policy =
document.permissionsPolicy || document.featurePolicy;
debug.textContent =
'Origin: ' + location.origin + '\\n' +
'Inside iframe: ' + (top !== self) + '\\n' +
'Secure context: ' + isSecureContext + '\\n' +
'display-capture: ' +
(policy?.allowsFeature('display-capture') ?? 'unknown');
}
// 映像からJPEG画像を取得する
function captureFrame() {
if (!stream || video.readyState < 2) return;
try {
const sourceWidth = video.videoWidth;
const sourceHeight = video.videoHeight;
if (!sourceWidth || !sourceHeight) return;
const scale = Math.min(1, 1280 / sourceWidth);
const canvas = document.createElement('canvas');
canvas.width = Math.round(sourceWidth * scale);
canvas.height = Math.round(sourceHeight * scale);
const ctx = canvas.getContext('2d');
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
report('image', {
image: canvas.toDataURL('image/jpeg', 0.75),
width: canvas.width,
height: canvas.height
});
} catch (e) {
report('error', {
message: e.name + ': ' + e.message
});
}
}
function stopAuto() {
if (timer !== null) {
clearInterval(timer);
timer = null;
report('auto', { enabled: false });
}
}
function stopCapture() {
stopAuto();
if (stream) {
const oldStream = stream;
stream = null;
oldStream.getTracks().forEach(track => track.stop());
}
video.srcObject = null;
start.disabled = false;
stop.disabled = true;
status.textContent = '共有停止';
report('stopped');
}
// ユーザー操作から画面共有を開始
start.addEventListener('click', async () => {
try {
stream = await navigator.mediaDevices.getDisplayMedia({
video: true,
audio: false
});
video.srcObject = stream;
start.disabled = true;
stop.disabled = false;
status.textContent = '画面共有中';
stream.getVideoTracks()[0].addEventListener(
'ended', stopCapture
);
report('started');
} catch (e) {
status.textContent = '取得失敗';
report('error', {
message: e.name + ': ' + e.message
});
}
});
stop.addEventListener('click', stopCapture);
// 親画面からの指示を受信
window.addEventListener('message', event => {
if (event.origin !== ORIGIN) return;
if (event.source !== window.opener) return;
const data = event.data;
if (!data || data.token !== TOKEN) return;
if (data.type === 'shot') {
captureFrame();
}
if (data.type === 'auto-start' && stream) {
if (timer === null) {
captureFrame();
timer = setInterval(captureFrame, INTERVAL);
report('auto', { enabled: true });
}
}
if (data.type === 'auto-stop') {
stopAuto();
}
if (data.type === 'stop') {
stopCapture();
}
});
window.addEventListener('pagehide', () => {
if (stream) stopCapture();
});
diagnostic();
report('ready');
<\/script>
</body>
</html>`;
popup.document.open();
popup.document.write(html);
popup.document.close();
$('status').textContent = 'ポップアップを起動しました';
log('Popup opened');
updateButtons();
});
// メイン画面のボタン操作
$('shot').addEventListener('click', () => send('shot'));
$('auto').addEventListener('click', () => {
send(autoMode ? 'auto-stop' : 'auto-start');
});
$('stop').addEventListener('click', () => send('stop'));
log('Main origin: ' + ORIGIN);
log('Inside iframe: ' + (top !== self));
updateButtons();
</script>
</body>
</html>
HTMLの中に、ポップアップ用のHTMLをテンプレートリテラルで埋め込んでいます。
少し長くなりましたが、外部のライブラリやサーバーは不要です。
なお、ポップアップ内のスクリプトを閉じる部分は </script> ではなく <\/script> としています。外側のHTMLを読み込むときに、途中で script 要素が閉じられないようにするためです。
4. ウェブアプリとしてデプロイする
Apps Scriptのエディタから「デプロイ」→「新しいデプロイ」を選び、種類に「ウェブアプリ」を指定します。
最初はアクセスできるユーザーを「自分のみ」にして試せば十分です。
デプロイしたURLをChromeで開いて、次の順に操作してください。
- 「画面共有ウィンドウを開く」をクリック
- 開いたウィンドウで「画面共有を開始」をクリック
- 共有する画面やウィンドウを選択
- 元の画面で「静止画を取得」をクリック
- 「自動取得を開始」をクリックすると、約2秒間隔で画像が更新される
ポップアップ側にも、実行環境を確認するための情報が表示されます。
検証結果
iframeの中では失敗
まず、Apps Scriptのウェブアプリ内で直接 getDisplayMedia() を呼び出した結果です。
Secure context: true
Inside iframe: true
getDisplayMedia: function
display-capture allowed: false
FAILED: NotAllowedError
Failed to execute 'getDisplayMedia' on 'MediaDevices':
Access to the feature "display-capture"
is disallowed by permissions policy.
APIそのものは存在しますが、Permissions Policyによって禁止されています。
別ウィンドウでは成功
同じコードを別ウィンドウから実行すると、次の結果になりました。
Inside iframe: false
Secure context: true
display-capture: true
SUCCESS
display-capture が false から true に変わり、実際に画面共有のダイアログも表示されました。
連続キャプチャも成功
Canvasで静止画に変換し、postMessage() で元のウェブアプリへ転送したときのログです。
10:55:51 PM Popup opened
10:55:51 PM Popup ready
10:55:58 PM Screen capture started
10:56:04 PM Image received (85 KB approx.)
10:56:11 PM Image received (94 KB approx.)
10:56:11 PM Auto capture ON
10:56:13 PM Image received (100 KB approx.)
10:56:15 PM Image received (101 KB approx.)
...
10:56:31 PM Auto capture OFF
10:56:31 PM Screen capture stopped
手動取得、自動取得、停止まで正常に動作しました。
2秒間隔で取得した画像は、この検証ではおおむね60〜104KBでした。
| 検証項目 | 結果 |
|---|---|
| iframe内での画面キャプチャ | 失敗 |
| 別ウィンドウでの画面キャプチャ | 成功 |
| CanvasでのJPEG変換 | 成功 |
postMessage() で画像を転送 |
成功 |
| 2秒間隔の自動取得 | 成功 |
| 自動取得と画面共有の停止 | 成功 |
検証したのはWindowsのデスクトップ版Chromeです。ほかのブラウザーやOSでの動作は未確認です。
制限事項と注意点
今回の方法で画面をキャプチャできましたが、インストールして利用するデスクトップアプリと同じことができるわけではありません。
PC全体のクリックやキー入力は取得できない
Screen Capture APIで取得できるのは、画面に表示されている映像です。
通常のウェブアプリから、PC全体のマウスやキーボードの操作イベントを直接取得することはできません。
例えば、次のような情報は取得できません。
- どの座標をクリックしたかという正確な操作記録
- ほかのアプリで押したキー
- ウィンドウの切り替えやアプリの起動といったOSのイベント
- ほかのアプリで行われたコピーやペーストの操作
マウスカーソルがキャプチャ画像に映ることはありますが、クリックしたことまで分かるわけではありません。
画面の変化から、どのような操作があったのかをAIに推測させることはできるかもしれません。ただし、正確な操作ログの代わりにはなりません。
画面共有には毎回ユーザーの許可が必要
getDisplayMedia() は、ユーザーがボタンをクリックするなどの操作を行ったときに呼び出す必要があります。
共有する画面やウィンドウはユーザーが選択します。アプリ側で任意の画面を強制的に選択することはできません。
また、共有許可を保存して、次回から自動的に画面共有を開始することもできません。
今回の実装でも、ポップアップを開いたあと、ユーザーが「画面共有を開始」をクリックするようにしています。
共有対象によって取得できる内容が異なる
画面全体を共有する場合と、特定のウィンドウやブラウザーのタブを共有する場合では、取得できる内容が異なります。
また、共有したウィンドウの最小化や画面のロック、保護された映像コンテンツなどによって、画面が取得できなくなる場合があります。
ブラウザーやOSによって動作が異なる可能性もあるので、利用環境での確認が必要です。
正確な時間間隔で取得できるとは限らない
今回は setInterval() を使って、2秒間隔で画像を取得しました。
ただし、ブラウザーはバックグラウンドのタブやウィンドウで、タイマーの実行頻度を制限することがあります。
今回の検証では自動取得が動作しましたが、長時間の取得やポップアップを最小化した状態での動作までは確認していません。
また、2秒ごとの静止画では、その間に行われた短い操作は見逃してしまいます。
画像の取り扱いに注意する
画面全体をキャプチャすると、メール、チャット、通知、個人情報などが映り込む可能性があります。
今回のサンプルでは、画像をブラウザー内で受け渡しして表示するだけで、外部のサーバーには送信していません。
今後、取得した画像を生成AIに分析させる場合には、送信対象の確認や機密情報のマスキングなども必要になるでしょう。
まとめ
Google Apps Scriptのウェブアプリでは、iframeのPermissions PolicyによってScreen Capture APIを直接利用できません。
しかし、window.open() で別ウィンドウを開き、そこで getDisplayMedia() を呼び出すことで画面キャプチャに成功しました。
さらに、Canvasで取得した静止画を postMessage() で元のウェブアプリへ戻し、継続的に取得することもできました。
今回の方法は、ブラウザーやApps Scriptの実装に依存するため、将来も同じように動作するとは限りません。また、画面を取得できても、PC全体の操作イベントを取得できるわけではありません。
それでも、Apps Scriptだけで画面キャプチャを使ったウェブアプリを作れることが分かりました。
次は、取得した画像をGeminiに渡して、PCの作業内容をどこまで分析できるか試してみたいと思います。