Webサイトの情報をWebフォームに自動で転記するChrome拡張機能を作ってみた

未分類

Webサイトの情報をWebフォームに自動で転記するChrome拡張機能の紹介です。
あるサイトから情報をコピペして、Webフォームに転記するという作業を毎日されている方がいたため、AIで自動化しました。
ちなみにほぼ以下の方法で、AIが実装しています。

CursorのRuleを使って簡単にSpec駆動開発
Cursorとは?Cursorとは、AIを活用した次世代のコードエディタです。Visual Studio Code(VS Code)をベースに開発されており、GitHub CopilotのようなAI補完機能に加えて、自然言語でコードを生成・...

仕様

機能

  • フォーム項目の自動検出: 現在開いているWebページの入力項目を自動で取得・理解
  • データスクレイピング: 指定されたURLからWebページの情報を取得
  • AI自動マッピング: Gemini Flash APIを使用してフォーム項目とスクレイピングデータを自動マッピング
  • 自動入力: マッピングされたデータをフォームに自動入力

アーキテクチャ

アーキテクチャ

主要コンポーネント

  • popup.js: ユーザーインターフェース処理
  • content.js: Webページのフォーム操作とスクレイピング
  • background.js: 拡張機能のバックグラウンド処理

API連携

  • Gemini Flash API: データマッピング用AI処理
  • Chrome Extension API: ブラウザ機能へのアクセス

ソースコード

早速作成された以下のソースコードを掲載します。

プログラムファイルのフォルダ構成

chrome_auto_form/
        manifest.json
        popup.html
        popup.js
        content.js
        background.js

実装したプログラム

manifest.json

{
  "manifest_version": 3,
  "name": "Auto Form Filler",
  "version": "1.0",
  "description": "Webサイトの情報をWebフォームに自動で転記するChrome拡張機能",
  "permissions": [
    "activeTab",
    "scripting",
    "storage"
  ],
  "host_permissions": [
    "https://*/*",
    "http://*/*"
  ],
  "action": {
    "default_popup": "popup.html",
    "default_title": "Auto Form Filler"
  },
  "content_scripts": [
    {
      "matches": ["<all_urls>"],
      "js": ["content.js"]
    }
  ],
  "background": {
    "service_worker": "background.js"
  },
  "web_accessible_resources": [
    {
      "resources": ["popup.html"],
      "matches": ["<all_urls>"]
    }
  ]
}

popup.html

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <style>
        body {width: 400px;padding: 16px;font-family: system-ui, -apple-system, sans-serif;background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);color: white;margin: 0;}
        .header {text-align: center;margin-bottom: 24px;}
        .title {font-size: 20px;font-weight: bold;margin: 0;text-shadow: 0 2px 4px rgba(0,0,0,0.3);}
        .form-group {margin-bottom: 16px;
        label {display: block;margin-bottom: 4px;font-weight: 600;font-size: 14px;}
        input[type="text"], input[type="url"] {width: 100%;padding: 10px;border: none;border-radius: 6px;background: rgba(255,255,255,0.9);color: #333;font-size: 14px;box-sizing: border-box;}
        input[type="text"]:focus, input[type="url"]:focus {outline: none;background: white;box-shadow: 0 0 0 2px rgba(255,255,255,0.5);}
        button {background: linear-gradient(45deg, #ff6b6b, #ee5a52);color: white;border: none;padding: 12px 24px;border-radius: 6px;font-size: 14px;font-weight: 600;cursor: pointer;transition: all 0.3s ease;width: 100%;text-transform: uppercase;letter-spacing: 0.5px;}
        button:hover {transform: translateY(-2px);box-shadow: 0 4px 12px rgba(238, 90, 82, 0.4);}
        button:active {transform: translateY(0);}
        button:disabled {opacity: 0.6;cursor: not-allowed;transform: none;}
        .status {margin-top: 16px;padding: 12px;border-radius: 6px;background: rgba(255,255,255,0.1);font-size: 14px;min-height: 20px;backdrop-filter: blur(10px);}
        .status.success {background: rgba(76, 175, 80, 0.3);}
        .status.error {background: rgba(244, 67, 54, 0.3);}
        .form-info {background: rgba(255,255,255,0.1);padding: 12px;border-radius: 6px;margin-bottom: 16px;backdrop-filter: blur(10px);}
        .form-info h3 {margin: 0 0 8px 0;font-size: 16px;}
        .field-list {font-size: 12px;opacity: 0.9;}
        .loading {display: inline-block;width: 16px;height: 16px;border: 2px solid rgba(255,255,255,0.3);border-radius: 50%;border-top-color: white;animation: spin 1s ease-in-out infinite;margin-right: 8px;}
        @keyframes spin {to { transform: rotate(360deg); }}
    </style>
</head>
<body>
    <div class="header">
        <h1 class="title">Auto Form Filler</h1>
    </div>
    
    <div class="form-info" id="formInfo">
        <h3>現在のフォーム情報</h3>
        <div class="field-list" id="fieldList">フォーム項目を読み込み中...</div>
    </div>
    
    <div class="form-group">
        <label for="apiKey">Gemini API キー:</label>
        <input type="text" id="apiKey" placeholder="AIZaSy..." />
    </div>
    
    <div class="form-group">
        <label for="sourceUrl">データ取得元URL:</label>
        <input type="url" id="sourceUrl" placeholder="https://example.com" />
    </div>
    
    <button id="fillButton">取込・自動入力</button>
    
    <div class="status" id="status">準備完了</div>
    
    <script src="popup.js"></script>
</body>
</html>

popup.js

document.addEventListener('DOMContentLoaded', async () => {
    const apiKeyInput = document.getElementById('apiKey');
    const sourceUrlInput = document.getElementById('sourceUrl');
    const fillButton = document.getElementById('fillButton');
    const statusDiv = document.getElementById('status');
    const fieldListDiv = document.getElementById('fieldList');
    
    // 保存されたAPIキーを読み込み
    const result = await chrome.storage.local.get(['geminiApiKey']);
    if (result.geminiApiKey) {
        apiKeyInput.value = result.geminiApiKey;
    }
    
    // 現在のページのフォーム情報を取得
    async function loadFormInfo() {
        try {
            const [tab] = await chrome.tabs.query({active: true, currentWindow: true});
            const response = await chrome.tabs.sendMessage(tab.id, {action: 'getFormInfo'});
            
            if (response.success) {
                const fields = response.fields;
                if (fields.length > 0) {
                    fieldListDiv.innerHTML = fields.map(field => 
                        `<div>• ${field.label || field.name || field.id || 'unnamed'} (${field.type})</div>`
                    ).join('');
                } else {
                    fieldListDiv.innerHTML = 'フォーム項目が見つかりません';
                }
            } else {
                fieldListDiv.innerHTML = 'フォーム情報の取得に失敗しました';
            }
        } catch (error) {
            console.error('Error loading form info:', error);
            fieldListDiv.innerHTML = 'フォーム情報の取得に失敗しました';
        }
    }
    
    // 初期読み込み
    loadFormInfo();
    
    // ステータス更新関数
    function updateStatus(message, type = '') {
        statusDiv.textContent = message;
        statusDiv.className = `status ${type}`;
    }
    
    // データ取得とフォーム入力
    fillButton.addEventListener('click', async () => {
        const apiKey = apiKeyInput.value.trim();
        const sourceUrl = sourceUrlInput.value.trim();
        
        if (!apiKey) {
            updateStatus('APIキーを入力してください', 'error');
            return;
        }
        
        if (!sourceUrl) {
            updateStatus('データ取得元URLを入力してください', 'error');
            return;
        }
        
        try {
            // APIキーを保存
            await chrome.storage.local.set({geminiApiKey: apiKey});
            
            fillButton.disabled = true;
            updateStatus('処理中...', '');
            statusDiv.innerHTML = '<span class="loading"></span>処理中...';
            
            // 現在のタブを取得
            const [tab] = await chrome.tabs.query({active: true, currentWindow: true});
            
            // フォーム情報を取得
            const formResponse = await chrome.tabs.sendMessage(tab.id, {action: 'getFormInfo'});
            
            if (!formResponse.success) {
                throw new Error('フォーム情報の取得に失敗しました');
            }
            
            // データをスクレイピング
            const scrapingResponse = await chrome.tabs.sendMessage(tab.id, {
                action: 'scrapeData',
                url: sourceUrl
            });
            
            if (!scrapingResponse.success) {
                throw new Error('データの取得に失敗しました: ' + scrapingResponse.error);
            }

            console.log(formResponse.fields);
            console.log(scrapingResponse.data);
            
            // AIで入力項目とデータをマッピング
            const mappingResponse = await chrome.tabs.sendMessage(tab.id, {
                action: 'mapDataToForm',
                apiKey: apiKey,
                formFields: formResponse.fields,
                scrapedData: scrapingResponse.data
            });
            
            if (!mappingResponse.success) {
                throw new Error('データマッピングに失敗しました: ' + mappingResponse.error);
            }

            console.log(mappingResponse.mappedData);
            
            // フォームに自動入力
            const fillResponse = await chrome.tabs.sendMessage(tab.id, {
                action: 'fillForm',
                mappedData: mappingResponse.mappedData
            });
            
            if (fillResponse.success) {
                updateStatus(`${fillResponse.filledCount}件の項目を入力しました`, 'success');
            } else {
                throw new Error('フォーム入力に失敗しました');
            }
            
        } catch (error) {
            console.error('Error:', error);
            updateStatus('エラー: ' + error.message, 'error');
        } finally {
            fillButton.disabled = false;
        }
    });
});

content.js

// コンテンツスクリプト - Webページに注入される
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
    if (request.action === 'getFormInfo') {
        getFormInfo(sendResponse);
        return true; // 非同期レスポンス
    }
    
    if (request.action === 'scrapeData') {
        scrapeData(request.url, sendResponse);
        return true;
    }
    
    if (request.action === 'mapDataToForm') {
        mapDataToForm(request.apiKey, request.formFields, request.scrapedData, sendResponse);
        return true;
    }
    
    if (request.action === 'fillForm') {
        fillForm(request.mappedData, sendResponse);
        return true;
    }
});

// フォーム情報を取得
function getFormInfo(sendResponse) {
    try {
        const formFields = [];
        
        // 入力可能な要素を取得
        const inputs = document.querySelectorAll('input, textarea, select');
        
        inputs.forEach(input => {
            // 隠しフィールドや無効なフィールドをスキップ
            if (input.type === 'hidden' || input.type === 'button' || input.type === 'submit' || input.type === 'file' || input.disabled || input.readOnly) {
                return;
            }
            
            const fieldInfo = {
                id: input.id,
                name: input.name,
                type: input.type || input.tagName.toLowerCase(),
                placeholder: input.placeholder,
                label: getFieldLabel(input),
                value: input.value,
                required: input.required,
                selector: getUniqueSelector(input)
            };
            
            formFields.push(fieldInfo);
        });
        
        sendResponse({success: true, fields: formFields});
    } catch (error) {
        console.error('Error getting form info:', error);
        sendResponse({success: false, error: error.message});
    }
}

// フィールドのラベルを取得
function getFieldLabel(input) {
    // label要素から取得
    if (input.id) {
        const label = document.querySelector(`label[for="${input.id}"]`);
        if (label) return label.textContent.trim();
    }
    
    // 親要素のlabelから取得
    const parentLabel = input.closest('label');
    if (parentLabel) {
        return parentLabel.textContent.replace(input.value || '', '').trim();
    }
    
    // 前の兄弟要素から取得
    let prevElement = input.previousElementSibling;
    while (prevElement) {
        if (prevElement.tagName === 'LABEL' || prevElement.textContent.trim()) {
            return prevElement.textContent.trim();
        }
        prevElement = prevElement.previousElementSibling;
    }

    // テーブル構造対応: td → tr → th をたどる
    const td = input.closest('td');
    if (td) {
        const tr = td.closest('tr');
        if (tr) {
            const th = tr.querySelector('th');
            if (th) {
                return th.textContent.replace(/\s+/g, ' ').trim();
            }
        }
    }
    
    return '';
}

// 要素の一意なセレクタを生成
function getUniqueSelector(element) {
    if (element.id) return `#${element.id}`;
    if (element.name) return `[name="${element.name}"]`;
    
    // より複雑なセレクタ生成
    const path = [];
    let current = element;
    
    while (current && current.nodeType === Node.ELEMENT_NODE) {
        let selector = current.tagName.toLowerCase();
        
        if (current.id) {
            selector += `#${current.id}`;
            path.unshift(selector);
            break;
        }
        
        if (current.className) {
            selector += `.${current.className.split(' ').join('.')}`;
        }
        
        const parent = current.parentNode;
        if (parent) {
            const siblings = Array.from(parent.children);
            const index = siblings.indexOf(current);
            if (index > 0) {
                selector += `:nth-child(${index + 1})`;
            }
        }
        
        path.unshift(selector);
        current = parent;
    }
    
    return path.join(' > ');
}

// データをスクレイピング
async function scrapeData(url, sendResponse) {
    try {
        const response = await fetch(url);
        const html = await response.text();
        
        // 簡単なHTMLパースでテキストコンテンツを抽出
        const parser = new DOMParser();
        const doc = parser.parseFromString(html, 'text/html');
        
        // スクリプト、スタイル、コメントを除去
        const elementsToRemove = doc.querySelectorAll('script, style, noscript, meta, link');
        elementsToRemove.forEach(el => el.remove());
        
        // 構造化データを抽出
        const extractedData = {
            title: doc.title || '',
            headings: Array.from(doc.querySelectorAll('h1, h2, h3, h4, h5, h6')).map(h => h.textContent.trim()),
            paragraphs: Array.from(doc.querySelectorAll('p')).map(p => p.textContent.trim()).filter(text => text.length > 0),
            lists: Array.from(doc.querySelectorAll('ul, ol')).map(list => 
                Array.from(list.querySelectorAll('li')).map(li => li.textContent.trim())
            ).flat(),
            links: Array.from(doc.querySelectorAll('a[href]')).map(a => ({
                text: a.textContent.trim(),
                href: a.href
            })),
            images: Array.from(doc.querySelectorAll('img[alt]')).map(img => ({
                alt: img.alt,
                src: img.src
            })),
            tables: Array.from(doc.querySelectorAll('table')).map(table => {
                const rows = Array.from(table.querySelectorAll('tr'));
                return rows.map(row => 
                    Array.from(row.querySelectorAll('td, th')).map(cell => cell.textContent.trim())
                );
            }),
            bodyText: doc.body.textContent.trim().replace(/[ \t]+/g, ' ')
        };
        
        sendResponse({success: true, data: extractedData});
    } catch (error) {
        console.error('Error scraping data:', error);
        sendResponse({success: false, error: error.message});
    }
}

// AIでデータをフォーム項目にマッピング(※10000文字まで)
async function mapDataToForm(apiKey, formFields, scrapedData, sendResponse) {
    try {
        const prompt = `
あなたは自動フォーム入力アシスタントです。以下のWebページから取得したデータを、フォーム項目に適切にマッピングしてください。

【フォーム項目】
${formFields.map(field => `
- ID: ${field.id || 'なし'}
- Name: ${field.name || 'なし'}
- Type: ${field.type}
- Label: ${field.label || 'なし'}
- Placeholder: ${field.placeholder || 'なし'}
- Selector: ${field.selector}
`).join('\n')}

【スクレイピングデータ】
タイトル: ${scrapedData.title}
見出し: ${scrapedData.headings.join(', ')}
本文: ${scrapedData.bodyText.substring(0, 10000)}...

【指示】
1. 各フォーム項目に適切な値を入力してください
2. データが見つからない場合は空文字を返してください
3. 以下のJSON形式で回答してください:

{
  "mappings": [
    {
      "selector": "フィールドのセレクタ",
      "value": "入力する値",
      "confidence": "信頼度(0-1)"
    }
  ]
}
`;
        // console.log(prompt);

        const response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${apiKey}`, {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
            },
            body: JSON.stringify({
                contents: [
                    {
                        parts: [
                            {
                                text: prompt
                            }
                        ]
                    }
                ]
            })
        });
        
        const result = await response.json();

        console.log(result);

        if (!response.ok) {
            throw new Error(`Gemini API error: ${result.error?.message || 'Unknown error'}`);
        }

        const aiResponse = result.candidates[0].content.parts[0].text;

        // JSONを抽出
        const jsonMatch = aiResponse.match(/\{[\s\S]*\}/);
        if (!jsonMatch) {
            throw new Error('AI response format error');
        }

        const mappedData = JSON.parse(jsonMatch[0]);

        sendResponse({success: true, mappedData: mappedData.mappings});
    } catch (error) {
        console.error('Error mapping data:', error);
        sendResponse({success: false, error: error.message});
    }
}

// フォームに自動入力
function fillForm(mappedData, sendResponse) {
    try {
        let filledCount = 0;
        
        mappedData.forEach(mapping => {
            if (mapping.value && mapping.confidence > 0.5) {
                const element = document.querySelector(mapping.selector);
                if (element) {
                    // 値を設定
                    element.value = mapping.value;
                    
                    // イベントを発火(React等のフレームワーク対応)
                    element.dispatchEvent(new Event('input', { bubbles: true }));
                    element.dispatchEvent(new Event('change', { bubbles: true }));
                    
                    filledCount++;
                }
            }
        });
        
        sendResponse({success: true, filledCount});
    } catch (error) {
        console.error('Error filling form:', error);
        sendResponse({success: false, error: error.message});
    }
}

background.js

// バックグラウンドスクリプト - 拡張機能のメイン処理
chrome.runtime.onInstalled.addListener(() => {
    console.log('Auto Form Filler extension installed');
});

// タブが更新されたときの処理
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
    if (changeInfo.status === 'complete' && tab.url) {
        // ページが完全に読み込まれた時の処理
        console.log('Page loaded:', tab.url);
    }
});

// 拡張機能アイコンクリック時の処理
chrome.action.onClicked.addListener((tab) => {
    // ポップアップが設定されているのでここは実行されない
    console.log('Extension icon clicked');
});

// メッセージハンドリング(必要に応じて)
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
    if (request.action === 'background_action') {
        // バックグラウンドでの処理が必要な場合
        console.log('Background action received:', request);
        sendResponse({success: true});
    }
});

// ストレージ変更の監視
chrome.storage.onChanged.addListener((changes, namespace) => {
    if (namespace === 'local') {
        console.log('Storage changed:', changes);
    }
});

// エラーハンドリング
chrome.runtime.onSuspend.addListener(() => {
    console.log('Extension suspended');
});

// 拡張機能の初期化処理
async function initializeExtension() {
    try {
        // 初期設定の確認
        const result = await chrome.storage.local.get(['geminiApiKey']);
        if (!result.geminiApiKey) {
            console.log('API key not found - user needs to set it up');
        }
        
        // 必要に応じて初期設定
        await chrome.storage.local.set({
            extensionVersion: '1.0',
            initialized: true
        });
        
        console.log('Extension initialized successfully');
    } catch (error) {
        console.error('Extension initialization failed:', error);
    }
}

// 初期化実行
initializeExtension();

使用方法

インストール方法

  1. Chrome拡張機能の開発者モードを有効にする
    • Chrome → 設定 → 拡張機能 → 開発者モードをON
  2. 拡張機能ファイルを準備
    • manifest.json
    • popup.html
    • popup.js
    • content.js
    • background.js
  3. 「パッケージ化されていない拡張機能を読み込む」でフォルダを選択

自動入力の実行

今回は、以下画像の右画面から情報を取得し、左画面のフォームに自動転記してみます。
Webフォーム

  1. フォームがあるWebページを開く
    • 入力したいフォームがあるページを表示
    • 登録した拡張機能のポップアップを開く
      Webフォーム
  2. データ取得元URLを指定
    • Google AI StudioでAPIキーを取得し、Gemini API キーを設定(初回のみ)
    • 「データ取得元URL」欄にスクレイピングしたいWebページのURLを入力
      Webフォーム
  3. 自動入力を実行
    • 「取込・自動入力」ボタンをクリック
    • AIが自動でデータをマッピングしてフォームに入力

まとめ

今回紹介したChrome拡張機能を使えば、毎日のコピペ作業を自動化し、業務効率を大幅に向上させることができます。
Web上の情報を正確かつ素早くフォームに転記できるため、入力ミスの防止にも役立ちます。
同様のルーチン作業にお悩みの方は、ぜひ本記事の手順を参考に導入してみてください。

コメント

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