📋

「リンクコピー拡張」の実装で踏んだ、Clipboard API・CSP・Service Workerの罠

に公開

アイキャッチ

https://chromewebstore.google.com/detail/copy-current-page-as/cpcebplpaimkniohaoapaiinckcgdhjn

はじめに

Slackでページを共有するとき、「タイトルをコピー → URLをコピー → [タイトル](URL) に整形 → 貼り付け」を毎日何度も繰り返していました。しかもSlackにはリッチリンクで貼りたいので、HTMLアンカーを手で書くか、プレビューが出るまで待つかの二択です。

「リンクをコピー」系の拡張は既にありますが、選択テキストに依存するもの、Markdownしか対応していないもの、Manifest V2で将来性が不安なものばかりでした。

そこで作ったのが Copy Current Page As です。Manifest V3 + Clipboard APIを使い、Markdown / Slack / Plain形式をショートカットキー一発でコピーできます。

作ったもの

Copy Current Page As は、今見ているページのタイトルとURLを3つの形式でワンクリックコピーできるChrome拡張機能です。

形式 出力例
Markdown [Zenn](https://zenn.dev)
Slack リッチテキストリンク(HTMLアンカー)
Plain Zenn - https://zenn.dev

コピーする情報は常に現在のページのタイトルとURLです。テキスト選択やリンクの右クリックには依存しません。

コピーの起動方法は3つあります。

  • 右クリックメニュー — ページ上で右クリック → 「Copy Current Page As」 → 形式を選択
  • キーボードショートカットCmd+Shift+E(Mac) / Ctrl+Shift+E(Windows/Linux)でデフォルト形式を即コピー。Slack形式は Cmd+Shift+F / Ctrl+Shift+F
  • ツールバーアイコン — 拡張機能アイコンのクリックでデフォルト形式をコピー

コピー完了時にはページ右上にトースト通知が表示されます。

実装で詰まった3つのポイント

「ページのリンクをコピーするだけ」の拡張ですが、作ってみると意外なところで詰まりました。

1. Service Workerからクリップボードに書き込めない

Manifest V3ではバックグラウンドページがService Workerに置き換わりました。ところが、Service Workerには navigator.clipboard へのアクセス権がありません。

解決策として chrome.scripting.executeScript でコンテンツスクリプトの文脈からClipboard APIを呼び出しています。

async function writeClipboard(tabId, text, { richText = false } = {}) {
  await chrome.scripting.executeScript({
    target: { tabId },
    args: [text, richText],
    func: async (value, isRichText) => {
      try {
        if (isRichText) {
          await navigator.clipboard.write([
            new ClipboardItem({
              'text/html': new Blob([value], { type: 'text/html' }),
              'text/plain': new Blob([value.replace(/<[^>]*>/g, '')], {
                type: 'text/plain',
              }),
            }),
          ]);
        } else {
          await navigator.clipboard.writeText(value);
        }
      } catch (_) {
        // CSPが厳しいサイト向けフォールバック
        const textarea = document.createElement('textarea');
        textarea.value = isRichText ? value.replace(/<[^>]*>/g, '') : value;
        textarea.setAttribute('readonly', '');
        textarea.style.position = 'fixed';
        textarea.style.opacity = '0';
        document.body.appendChild(textarea);
        textarea.focus();
        textarea.select();
        document.execCommand('copy');
        textarea.remove();
      }
    },
  });
}

Service Worker → executeScript → Clipboard APIという経路をたどり、ページのDOM文脈でクリップボードを操作しています。CSPの厳しいサイトでは document.execCommand('copy') にフォールバックする二段構えです。

2. Service Workerの不確定な生存期間

Manifest V2のバックグラウンドページは永続的でした。一方、V3のService Workerはブラウザの判断で停止・再起動します。右クリックメニューをchrome.runtime.onInstalledだけで登録した場合、Service Worker再起動後にメニューの消失を招きます。

function createMenus() {
  chrome.contextMenus.removeAll(() => {
    chrome.contextMenus.create({ id: 'parent', title: 'Copy Current Page As', contexts: ['page'] });
    chrome.contextMenus.create({ id: 'markdown', parentId: 'parent', title: 'Markdown', contexts: ['page'] });
    chrome.contextMenus.create({ id: 'slack', parentId: 'parent', title: 'Slack', contexts: ['page'] });
    chrome.contextMenus.create({ id: 'plain', parentId: 'parent', title: 'Plain', contexts: ['page'] });
  });
}

chrome.runtime.onInstalled.addListener(createMenus);
chrome.runtime.onStartup.addListener(createMenus);

onInstalled(インストール・更新時)とonStartup(ブラウザ起動時)の両方でメニューを登録することで、Service Workerの寿命に左右されない動作を保証しています。

また、ショートカットキーとツールバーボタンの起動パスではアクティブタブの取得方法が異なる点にも注意が必要です。

// ショートカットキー: chrome.tabs.queryで能動的に取得
chrome.commands.onCommand.addListener(async (command) => {
  const [tab] = await chrome.tabs.query({ active: true, lastFocusedWindow: true });
  // ...
});

// ツールバーボタン: リスナーの引数として受け取る
chrome.action.onClicked.addListener(async (tab) => {
  // ...
});

3. Slack向けリッチテキストとXSSの両立

Slack形式では<a href="...">タイトル</a>というHTMLをクリップボードに書き込みます。Slackはクリップボードのtext/htmlを検出すると自動的にリッチテキストリンクとして表示してくれます。

ただしページタイトルにHTMLの特殊文字が含まれるケースもあります。<script>がタイトルに入ったページを想像してみてください。

function escapeHtml(str) {
  return str
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;');
}

export function formatLink({ mode, text, url }) {
  const label = (text || '').trim() || url;
  switch (mode) {
    case 'slack':
      return `<a href="${escapeHtml(url)}">${escapeHtml(label)}</a>`;
    case 'plain':
      return `${label} - ${url}`;
    case 'markdown':
    default:
      return `[${label}](${url})`;
  }
}

URLとラベルの両方をescapeHtml()でサニタイズすることで、XSSリスクを排除しつつリッチテキスト出力を実現しています。

ランタイム依存ゼロ

拡張機能のコードにはフレームワークやライブラリを一切使っていません。素のJavaScript(ES modules)だけで構成しているため、バンドルやビルドのステップが不要です。ソースコードをそのままChromeに読み込めます。

テストもnode:testnode:assertだけで書いています。依存が少ないほど保守は楽です。

Chrome Web Storeへの自動公開

release-pleaseとGitHub Actionsを組み合わせて、Release PRをマージするだけでChrome Web Storeへの公開まで自動で完了する仕組みにしています。手動でzipを作ってダッシュボードにアップロードする作業はゼロです。

CI/CDは4つのワークフローで構成しています。

  • ci.yml — push/PRごとにubuntu・macOS・Windowsの3環境でテストを実行し、extension.zipを作成
  • chrome-webstore.yml — GitHubリリースの作成をトリガーにChrome Web Storeへ自動アップロード
  • release-please.yml — Conventional Commitsからバージョン管理とリリースを自動化
  • auto-merge.yml — Dependabotのsemver-minor/patch更新を自動マージ

おわりに

「ページのリンクをコピーする」だけの拡張ですが、Clipboard API・CSP・Service Workerの制約が絡み合うと意外に奥の深い実装になりました。同じようなChrome拡張を作る方の参考になればうれしいです。

Chrome Web Storeで公開しています。使ってみてフィードバックがあればGitHubのIssueで受け付けています。

https://chromewebstore.google.com/detail/copy-current-page-as/cpcebplpaimkniohaoapaiinckcgdhjn

https://github.com/atani/copy-current-page-as

GitHub Sponsors

Discussion

ログインするとコメントできます