私が作ったポートフォリオ用のモックアップを作成するアプリ「Mockup Generator」。
一番欲しかったのはURLからモックアップを作り、PNG画像で出力する機能だったのですが、CORS(Cross-Origin Resource Sharing)というセキュリティの仕組みにぶつかりました。
(諦めてた時の記事👇)

URLからプレビューで画像を生成するところまではできていたので、その枠をスクショを撮って使ってもらうようにしていました。
この度、「URLを入れると、そのサイトのスクリーンショットを取ってきて表示する」機能を実装することができました!!!
調べて解決できたので、仕組みを自分用のメモとして残しておきます。
「canvasの汚染(taint)」というブラウザの仕組みと、それを「プロキシ」で回避する話です。
何が起きていたか
スクリーンショット画像を <canvas> に描いて、そこからPNGを書き出す作りにしていました。
ところが保存しようとすると失敗する。
原因は、ブラウザのセキュリティルールでした。
ブラウザには
「別のサイト(別ドメイン)から来た画像を、勝手に加工して中身を取り出させない」
というルールがあります。
スクリーンショットは外部サービスのドメインから直接もらっていたため、ブラウザが
「これは他人の画像だから、canvasに描くのは許すけど保存(書き出し)はさせない」
とロックをかけていたのです。
これを「canvasが汚染された(tainted)」状態と呼びます。
解決の考え方:自分のサーバーを間にはさむ
ポイントは、「他人のドメインから直接もらう」のをやめて、いったん自分のサーバーを経由させること。
画像の中身は同じでも、ブラウザから見ると「自分のサイト(同じドメイン)から来た画像」になるので、ロックが外れて保存できるようになります。
【前】 ブラウザ ──直接──▶ 外部のスクショサービス(他人) ← 保存ロック 【今】 ブラウザ ─▶ 自分の /api/shot ─▶ 外部のスクショサービス ← 自分の画像扱い=保存OK
この「間に立って画像を取り次ぐ係」をプロキシ(proxy)と呼びます。
今回はこのプロキシを、サーバーレス関数(サーバーを常時立てなくても、リクエストが来たときだけ動く小さなプログラム)として1つ用意しました。
プロキシのコードと意味
やっていることは「リクエストが来たら、スクショ画像を取ってきて、そのまま返す」だけです。上から意味を追っていきます(言語はNode.js)。
module.exports = async (req, res) => {
req はブラウザからのリクエスト(注文)、res はこちらから返すレスポンス(返事)。「このアドレスに注文が来たら、この処理を動かす」という入り口です。
const { url, w, h } = req.query || {};
注文に付いてきたパラメータを取り出します。/api/shot?url=〇〇&w=1440&h=900 の url・w(幅)・h(高さ)の部分です。
if (!url || !/^https?:\/\//i.test(url)) {
res.statusCode = 400;
res.end('invalid url');
return;
}
入力チェック(安全弁)。http:// か https:// で始まる正しいURLでなければ、エラー(400)を返してそこで終了。変な入力で誤動作しないようにします。
const width = Math.min(Math.max(parseInt(w, 10) || 1440, 100), 2400);
const height = Math.min(Math.max(parseInt(h, 10) || 900, 100), 2400);
幅・高さを100〜2400pxの範囲に収める制限。巨大な数字を送られてサーバーに負担がかからないようにします。数字が無ければ初期値を使います。
const src = `https://(スクショサービスのURL)/${encodeURIComponent(url)}?w=${width}&h=${height}`;
実際にスクショをもらう先のURLを組み立てます。encodeURIComponent は、URLの中に記号が入っても壊れないように変換してくれる関数です。
const upstream = await fetch(src, { redirect: 'follow' });
サーバーが自分でスクショ画像を取りにいく核心部分。await は「取れるまで待つ」という意味です。
if (!upstream.ok) { res.statusCode = 502; res.end('upstream error'); return; }
取得先でうまくいかなかったら、エラー(502)を返して終了します。
const buf = Buffer.from(await upstream.arrayBuffer());
もらった画像データを、そのまま送り返せる形(バイト列)に変換します。
res.statusCode = 200;
res.setHeader('Content-Type', upstream.headers.get('content-type') || 'image/jpeg');
res.setHeader('Cache-Control', 'no-store');
res.end(buf);
ブラウザに画像を返す部分です。
200=成功のしるし。Content-Type=「これは画像だよ」という種類の表示。Cache-Control: no-store=キャッシュ(一時保存)しないでという指示。スクショサービスは生成中に「まだ準備中の仮画像」を返すことがあり、キャッシュされると仮画像のまま固定されてしまうため、毎回取りにいかせます。res.end(buf)=画像を送って返事を完了。
} catch (e) {
res.statusCode = 500;
res.end('proxy error');
}
途中で予期せぬ失敗が起きても、アプリが落ちずにエラー(500)を返すための保険です。
フロント側の変更は1か所だけ
画像の取得先を、外部サービスから自分の /api/shot に変えるだけです。これが「自分のドメイン経由にする」の実体です。
// 変更前:外部サービスから直接
const src = `https://(外部スクショサービス)/...`;
// 変更後:自分のサーバー関数を経由
const src = `/api/shot?url=${encodeURIComponent(url)}&w=${w}&h=${h}&n=${i}`;
末尾の &n=${i} は、何度も取りにいく(ポーリングする)たびにURLを少しずつ変えて、ブラウザが古い仮画像をキャッシュから返すのを防ぐための工夫です。
プロキシ以外の方法(3つの選択肢)
今回採ったのは一番手軽な「プロキシ」でしたが、サーバー側でスクリーンショットを扱う方法は大きく3つあります。目的に応じて選べます。
| 方法 | 内容 | 難易度 | 品質・コスト |
|---|---|---|---|
| A. 画像プロキシ(今回) | 既存のスクショ画像を、自分のサーバー関数を経由して「自分のドメインの画像」として配信する | やさしい | 品質は元サービス次第。無料 |
| B. スクリーンショットAPIを使う | 専用のスクショサービス(後述)を、自分のサーバー関数から呼び出す | ふつう | 品質がきれい。無料枠あり→超えると有料 |
| C. 自前でヘッドレスChrome | サーバー関数の中でブラウザ(Chrome)を起動して、自分でスクショを撮る | やや大変 | 品質・自由度が高い。無料だが設定が面倒 |
A. 画像プロキシ(今回やった方法)
すでに使えているスクショ画像があるなら、これが最小労力。
やることは「自分のドメイン経由にする」だけで、十数行のサーバー関数1つで済みます。
品質は元のスクショサービスに依存します。
B. スクリーンショットAPIを使う
「スクショを撮ること」に特化した外部サービスを使う方法です。
代表的なものに次のようなサービスがあります(いずれも無料枠つきの有料サービス)。
- ScreenshotOne
- Urlbox
- ApiFlash
- Microlink
- thum.io
これらは高品質なスクショ(フルページ、遅延読み込み対応、広告ブロックなど)を返してくれます。
使うときのコツは2つ。
- APIキーは必ずサーバー側に隠す。 フロント(ブラウザのJS)に直書きすると、誰でも見られて悪用されます。自分のサーバー関数の中から呼び出し、キーは環境変数に入れます。
- 結果は自分のドメインから返す。 こうすることでAと同じく「自分の画像」扱いになり、canvas汚染を回避できます。
つまりBは「Aのプロキシの取得先を、無料のスクショサービスから高品質な有料APIに差し替えたもの」とも言えます。
C. 自前でヘッドレスChrome
外部サービスに頼らず、サーバー関数の中で画面のないChrome(ヘッドレスブラウザ)を起動して、自分でスクショを撮る方法です。
Puppeteer や Playwright といったライブラリを使います。
自由度は最高(好きな解像度・待機処理・スクロールなど何でもできる)ですが、注意点も多めです。
- サーバーレス環境ではChromeが大きすぎて入らないことがあり、
@sparticuz/chromiumのような軽量版Chromeと組み合わせるのが定番。 - 初回起動(コールドスタート)に数秒かかる。
- 関数のサイズ制限・実行時間制限にぶつかりやすい。
「品質・自由度を最大化したい」「外部サービスの費用や制限に縛られたくない」ときの選択肢です。
共通して知っておくべきこと
- どの方法もサーバー関数(バックエンド)が1つ必要。 「HTML1枚だけの完全な静的サイト」ではなくなります。とはいえ多くのホスティング(Vercelなど)は関数を標準サポートしているので、追加自体は難しくありません。
- ローカルでHTMLファイルを直接開いても、サーバー関数は動きません。 デプロイ後、または開発用サーバー(例:
vercel dev)で確認します。 - プライバシー表記は正確に。 「完全にブラウザ内で処理」と書いていた場合、URLモードは自分のサーバーを経由するようになるので、「アップロード画像は送信しません」のように実態に合わせて直します。
- キャッシュに注意。 生成に時間がかかるスクショは「仮画像→本画像」と変化するため、キャッシュを切る(
no-storeやURLを変える)工夫が要ります。
まとめ
「外部の画像だと保存できない」というブラウザのセキュリティは、画像を自分のサーバーで一度受け取ってから渡す(プロキシ)だけで、正しい方法で解けました。
たった十数行のサーバー関数で実現できたのが気持ちよかったです。
品質をもっと上げたいなら専用API(B)、自由度を極めたいなら自前ヘッドレスChrome(C)——と段階的にステップアップできるのも面白いところです。