0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

[悪用禁止] 外部サイトのDOMを自前SPAで操る(後編):Node.jsとTerserによる自動化と配備

0
Last updated at Posted at 2026-03-29

はじめに

前回の 「中編」 では、.toString()eval() を組み合わせ、ドメインの壁を超えて関数を遠隔実行する 「RPC」 の実装を解説しました。

シリーズ記事

記事 内容
1 [悪用禁止] 外部サイトのDOMを自前SPAで操る(前編):Cross-Domain通信の仕組み postMessageによるセキュアな通信基盤とHeartbeatの実装
2 [悪用禁止] 外部サイトのDOMを自前SPAで操る(中編):RPCによる関数の遠隔実行 関数シリアライズによるドメイン跨ぎのRPCメカニズム
3 [悪用禁止] 外部サイトのDOMを自前SPAで操る(後編):Node.jsとTerserによる自動化と配備 本編

開発環境で手作業でコンソールを叩く分にはこれで完成ですが、一般ユーザーに「非エンジニアでも簡単に使えるツール」として提供するには、配布の手間を最小限に抑える必要があります。

最終回となる「後編」では、Node.jsとTerserを用いたビルドプロセスの自動化と、ユーザーの導入体験を向上させる 「Bookmarklet化」 について詳しく見ていきます。


方法

配布とインストールの自動化(Bookmarklet化)

「コンソールへのコピペ」による実行は、エンジニア以外の人に使ってもらうには不親切です。
コードを自動でエスケープし、javascript: プロトコルを付与して「Bookmarklet」化することで、ユーザーへの提供コストを下げ、利便性を向上させます。

1. 実行用スクリプトのファイル化

まず、前編・中編を通じて完成させた Parent側(外部サイト側)の全量コード を、プロジェクト内に bridge-loader.js という名前で保存します。

bridge-loader.js
(function() {
    const parentDomain = window.location.origin;
    const spaUrl = `http://localhost:3000/#parentDomain=${encodeURIComponent(parentDomain)}`;

    // 1. SPAを子画面として開く
    const subWin = window.open(spaUrl, "Cross-Domain-RPC-Demo");
    if (subWin) subWin.focus();

    // 2. メッセージハンドラ(Heartbeat応答 + RPC実行)
    window.addEventListener("message", (event) => {
        // セキュリティチェック:許可したオリジン(localhost:3000)以外は無視
        if (event.origin !== "http://localhost:3000") return;

        const { type, functionStr, timestamp, args } = event.data;

        // Heartbeat (PING) への応答
        if (type === 'HEARTBEAT_PING') {
            event.source.postMessage({ type: 'HEARTBEAT_PONG' }, event.origin);
        } 
        // RPCリクエスト(関数実行命令)の処理
        else if (type === "RPC_REQUEST") {
            try {
                // 文字列化された関数を復元してParentのコンテキストで実行
                const executor = eval('(' + functionStr + ')');
                const result = executor(...args);

                // 実行結果をChild側へ返信
                event.source.postMessage({
                    type: "RPC_RESPONSE",
                    timestamp,
                    data: result
                }, event.origin);
            } catch (e) {
                console.error("RPC Error:", e);
            }
        }
    });
})();

これにより、開発時は読みやすい外部ファイルとして管理し、配布時は一行の「URL化したスクリプト」に変換するという、クリーンな開発環境が整います。

2. Terserによる自動コンパイル

次に、保存したファイルを読み込んで極限まで圧縮し、Bookmarklet形式に変換するスクリプト build-bookmarklet.js を作成します。

build-bookmarklet.js
const { minify } = require('terser');
const fs = require('fs');
const path = require('path');

// 1. コマンドライン引数から対象ファイルを取得
const inputPath = process.argv[2];
if (!inputPath) {
    console.error("Error: 入力ファイルパスを指定してください。");
    console.error("Usage: node build-bookmarklet.js <file-path>");
    process.exit(1);
}

async function build() {
    try {
        // 2. 指定されたファイルを読み込む
        const code = fs.readFileSync(inputPath, 'utf8');

        // 3. Terserで圧縮を実行
        const minified = await minify(code, {
            compress: { passes: 2 },
            mangle: true,
            format: { comments: false }
        });

        const bookmarklet = `javascript:${minified.code}`;

        // 4. 元のファイル名をベースに出力ファイル名を決定 (例: bridge-loader_bookmarklet.js)
        const ext = path.extname(inputPath);
        const base = path.basename(inputPath, ext);
        const outputPath = `./${base}_bookmarklet.js`;

        // 5. ファイルとして書き出し
        fs.writeFileSync(outputPath, bookmarklet);
        
        console.log(`--- Success: ${outputPath} ---`);
        console.log(bookmarklet);
    } catch (err) {
        console.error("Build Error:", err.message);
    }
}

build();

実行は node build-bookmarklet.js bridge-loader.js のように、対象のファイルを引数に渡すだけです。
これにより、複数のツールを開発する場合でも、単一のビルドスクリプトを使いまわすことが可能になります。

3. UIへの統合:ドラッグ&ドロップ体験

生成された文字列を、SPA内の設定画面で <a> タグの href にセットします。

<!-- Settings.html (Child) -->
<div class="install-section">
  <p>インストールの際は、下のボタンをブックマークバーにドラッグしてください:</p>
  <a href="javascript:..." draggable="true" class="bookmarklet-btn">
    Install Bridge Tool
  </a>
</div>

ユーザーはこのリンクを下記の図のように、ブックマークバーにドラッグするだけで導入が完了します。

install-bookmarklet.png

配布の手間という高い障壁を、最もシンプルなUXで解決しています。


ブラウザ拡張機能を作るまでもないが、特定のサイト上で高度なデータ処理やDOM操作を行いたい場合、このアーキテクチャは非常に有効なアプローチになります。

ぜひ、皆さんの開発支援ツール開発に役立ててください。


余談:サーバーレス・ブリッジの実装(Blob URLの活用)

本連載ではSPA(Child)を http://localhost:3000 でホストする構成を前提としましたが、外部サーバーを一切立てずにブックマークレットのみで完結させる手法も存在します。

具体的には、Child側のHTML/JSソースコードを文字列としてブックマークレットに含め、Blob オブジェクト を介して実行時に動的なURLを生成する方法です。

// 1. Child(子画面)となるHTML/JSの定義
const htmlContent = `
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>Serverless Bridge SPA</title>
    <style>
        body { font-family: sans-serif; padding: 20px; background: #f0f4f8; }
        button { padding: 10px 20px; font-size: 14px; cursor: pointer; }
    </style>
</head>
<body>
    <h3>Serverless Bridge SPA</h3>
    <p>Status: <span id="status">Disconnected</span></p>
    <button onclick="requestTitle()">Parent側のタイトルを取得 (RPC)</button>

    <script>
        let isConnected = false;
        let lastPongTime = Date.now();
        const rpcCallbacks = {};

        // RPC実行用Promiseラッパー
        const execInParent = (executorFn, ...args) => {
            return new Promise((resolve, reject) => {
                if (!isConnected) return reject("Not connected");
                const timestamp = Date.now();
                rpcCallbacks[timestamp] = { resolve, reject };

                // Blob URLは同一オリジン扱いのため、targetOriginに "*" を指定可能
                window.opener.postMessage({
                    type: 'RPC_REQUEST',
                    functionStr: executorFn.toString(),
                    args,
                    timestamp
                }, "*"); // 同一オリジン間のためワイルドカードを許容
            });
        };

        // メッセージハンドラ
        window.addEventListener("message", (event) => {
            const { type, timestamp, data } = event.data;
            if (type === 'HEARTBEAT_PONG') {
                isConnected = true; 
                lastPongTime = Date.now();
                document.getElementById("status").textContent = "Connected";
            } else if (type === 'RPC_RESPONSE' && rpcCallbacks[timestamp]) {
                rpcCallbacks[timestamp].resolve(data);
                delete rpcCallbacks[timestamp];
            }
        });

        // 生存確認(Heartbeat)の定時実行
        setInterval(() => {
            if (Date.now() - lastPongTime > 5000) {
                isConnected = false;
                document.getElementById("status").textContent = "Disconnected";
            }
            if (window.opener) {
                window.opener.postMessage({ type: 'HEARTBEAT_PING' }, "*");
            }
        }, 1000);

        // RPC実行テスト
        async function requestTitle() {
            try {
                const result = await execInParent(() => document.title);
                alert("Parent Title: " + result);
            } catch (err) {
                alert("Error: " + err);
            }
        }
    </script>
</body>
</html>`;

// 2. Blob URLの生成と起動
const blob = new Blob([htmlContent], { type: 'text/html' });
const blobUrl = URL.createObjectURL(blob);
const subWin = window.open(blobUrl, "Serverless-Bridge");
if (subWin) subWin.focus();

// 3. Parent側の受信ロジック(そのままブラウザコンソールで実行することを想定)
window.addEventListener("message", (event) => {
    const { type, functionStr, timestamp, args } = event.data;

    // Heartbeat応答
    if (type === 'HEARTBEAT_PING') {
        event.source.postMessage({ type: 'HEARTBEAT_PONG' }, "*");
    } 
    // RPCリクエストの実行
    else if (type === "RPC_REQUEST") {
        try {
            const executor = eval('(' + functionStr + ')');
            const result = executor(...args);
            event.source.postMessage({ 
                type: "RPC_RESPONSE", 
                timestamp, 
                data: result 
            }, "*");
        } catch (e) {
            console.error("RPC Execution Error:", e);
        }
    }
});

特徴とトレードオフ

この手法は、通常のオリジン間通信(Cross-Domain)とは異なるメリット・デメリットを持ちます。

項目 特徴
Originの継承 blob: URLは生成元のドメイン(Origin)を継承します。操作対象のサイト上で生成すれば、Child Windowも同一オリジン扱いとなります。
通信の簡略化 同一オリジン(Same-Origin) となるため、postMessage を使わずとも window.opener 経由で直接的なDOM操作や関数実行が可能です(Origin通信のロジックを省略可能)。
ポータビリティ 外部サーバーを立てる必要がなく、ブックマークレット単体でツールが完結します。
データ量の制限 ブックマークレットの文字数制限により、埋め込めるコード量(ライブラリ等)に限界があります。
更新の手間 コードを修正するたびに、ユーザーにブックマークレットの再登録を依頼する必要があります。

ツールが小規模であり、かつ「特定のサイトに最適化した補助ツール」として配布する場合には、このサーバーレスなアプローチも非常に有効です。


参考

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?