🐙

ほぼClineが実装、自分は4行だけ手で書いてchrome拡張機能を公開した話

2025/02/27に公開

はじめに

Rehab for JAPANでレセプトの開発マネージャをしている @makikubo です!

今回はレセプトと関係ないところから。

最近の生成AI関連ニュースの流れが早すぎて、最新newsを追っています。

弊社ではGithub Copilotが希望者には付与されるのですが、巷にはCursorやCline、Replit AgentなどCopilot以外にも開発ツールは数多く存在し、生成AI自体のアップデートにも追随するように、日々できることが増えていったり、全く新しいツールがでてきたりするので、個人で別のツールを試してみています。

今回はVSCodeの拡張機能であるCline、言語モデルはClaude 3.5 sonnetを実際に使って、chrome拡張機能を作り、最終的にchrome web storeへ公開した話です。

ターゲット

  • Clineを使った開発の様子を知りたい人
  • AIコーディングに興味ある人

この記事で伝えたいこと

  • Clineを使ったコーディングの流れ
  • 実際のプロンプト例

開発したものときっかけ

perplexityも個人で調査に使うのですが、以下のポストをみてNotebookLMも使ってみるかと思ったのがきっかけ。

https://x.com/kazunori_279/status/1891583415993913517

ただ、perplexityでコピーしたときの引用元の元ネタURLをnotebookLMへ登録するときに1個ずつURLを登録するのがすごく面倒だったので、自動操作でURLを一括登録するchrome拡張機能を作りました。

https://chromewebstore.google.com/detail/notebooklm-all-shooter/gkopcgfjeonjpepkoekgablcnfcppjni?authuser=0&hl=ja

タイトルにもある通り、4行くらい自分で一部いじったくらいで、ほとんどClineが書いてます。
私は自然言語で指示出してるだけ。

以下ではどんな指示出していったのかを具体的に示します。

Clineとの実装

Clineではトータルでかかったコストなども表示されます。

alt text

主な点だけ抜粋。

LLMへ指示を出しながら、ある程度動くものができてきたら自分で動かして、
機能追加していく方式で進めています。

ディレクトリ構成の作成とやること伝達

notebooklm_all_shooterというディレクトリでchrome拡張用のディレクトリ構成をセットアップしてほしい。これからchrome拡張を作ります。

Clineの作業ディレクトリが大事。Clineの設定 - フォルダで正しい場所を指定してあげる必要あり。
変なところにファイルを作ってしまうことも。

拡張機能自体の機能開発の実装依頼

テキストエリアをpopupの中に配置して、将来的に特定サイトへ入力された文字列を、改行ごとに分割して登録する機能を実装してほしいです。
以下のURLのサイトにアクセスして、popupの実行ボタンを作成して押すと、以下の順でイベントが実行される機能を作ってください。
- ソースを追加ボタン:ID: add-source-button
(押下)
- ウェブサイトボタン:ID: website-source-button
(押下)
- URLを貼り付けボタンID: url-input
(テキストエリアに保持している改行単位で分割した文字列を登録)
- 挿入ボタン:ID: url-insert-button
(押下)

ここまでで基本的に動くものは生成してくれたのですが、このあとセレクターの検証は自分でやっています。
結果、4行のコードに対して手を加えています。ID指定だとだめでした。
notebookLM自体はgoogleの認証などがありそうだったので、直接アクセスを避けてます。
が、やっても実はできたのかも。(未検証)

表示ボタンの実装

ローカルストレージに登録した情報がなにかを分かるように、表示ボタンを押したら別ウインドウで表示できるようにしたい。

使っていると、すでに登録済みのURLが何かを知りたくなったので依頼。

入力文字のサニタイズ処理

Register Itemsボタンを押したときに、入力されている値のうち、httpsから改行までにサニタイズしたうえで登録したい。

以下のような先頭文字は勝手に削除してほしかったので、登録時に除外する処理を追加。

alt text

登録済みURLの削除機能実装、一括削除機能実装

Viewの機能で、特定のURLを削除できる機能を実装したい。
viewでの一括削除機能を実装したい。

URLを登録したあとで、間違ったので取り消したいときもあるよなと思ったので機能追加。

HTMLとCSSの調整

HTMLとCSSを調整して、より見やすいように修正できますか?

なにもないよりもマシくらいのスタイルが当たってたので、もう少しマシにしたかったので依頼。

YouTubeボタンへの対応 v1.1

URLに以下のURLが含まれる場合は、実行ボタン押下時にウェブサイトボタンではなく、YouTubeボタンを押すように変更したい。

先頭が以下のURLが対象
・https://youtu.be/
・https://www.youtube.com/

ウェブリンクだけを最初ターゲットにしていたのですが、
perplexityはyoutubeのリンクも混ぜて表示するため、youtubeは専用のボタンを押下するように依頼。

ソースコード

生成されたコードの一部はこちら。
これがAIに指示を出すだけで自動生成されます。私はほぼ動作確認担当。

popup.js
document.addEventListener('DOMContentLoaded', function() {
  const inputText = document.getElementById('input-text');
  const registerButton = document.getElementById('register-button');
  const viewButton = document.getElementById('view-button');
  const executeButton = document.getElementById('execute-button');
  const statusMessage = document.getElementById('status-message');

  // View button click handler
  viewButton.addEventListener('click', function() {
    // Open view.html in a new window
    chrome.windows.create({
      url: chrome.runtime.getURL('view.html'),
      type: 'popup',
      width: 800,
      height: 600
    });
  });

  registerButton.addEventListener('click', function() {
    const text = inputText.value.trim();
    if (!text) {
      showStatus('Please enter some text', 'error');
      return;
    }

    // Split text by newlines and process each line
    const items = text.split('\n')
      .map(line => {
        // Find https:// in the line
        const httpsIndex = line.indexOf('https://');
        if (httpsIndex === -1) return null;
        
        // Extract from https:// to the next newline or end of string
        const url = line.substring(httpsIndex);
        
        // Sanitize: remove whitespace, quotes, and any text after whitespace
        return url.split(/[\s'"]+/)[0].trim();
      })
      .filter(item => item && item.startsWith('https://'));

    if (items.length === 0) {
      showStatus('No valid items found', 'error');
      return;
    }

    // Get existing items and merge with new ones
    chrome.storage.local.get('registeredItems', function(result) {
      const existingItems = result.registeredItems || [];
      
      // Create a Set to remove duplicates
      const uniqueItems = new Set([...existingItems, ...items]);
      const mergedItems = Array.from(uniqueItems);

      // Store merged items in chrome.storage.local
      chrome.storage.local.set({ 'registeredItems': mergedItems }, function() {
        // 登録成功後にテキストエリアをクリア
        inputText.value = '';
        showStatus(`Successfully registered ${items.length} new items. Total: ${mergedItems.length}`, 'success');
        console.log('Items registered:', mergedItems);
      });
    });
  });

  executeButton.addEventListener('click', async function() {
    const items = await chrome.storage.local.get('registeredItems');
    if (!items.registeredItems || items.registeredItems.length === 0) {
      showStatus('No items registered. Please register items first.', 'error');
      return;
    }

    // Get current active tab
    const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
    if (!tab) {
      showStatus('No active tab found', 'error');
      return;
    }

    // Execute the content script
    try {
      await chrome.scripting.executeScript({
        target: { tabId: tab.id },
        func: executeSequence,
        args: [items.registeredItems]
      });
      
      // Clear the storage after successful execution
      await chrome.storage.local.remove('registeredItems');
      inputText.value = ''; // Clear the textarea
      showStatus('Execution completed and items cleared', 'success');
    } catch (error) {
      showStatus('Failed to execute: ' + error.message, 'error');
    }
  });

  function showStatus(message, type) {
    statusMessage.textContent = message;
    statusMessage.className = type;
    setTimeout(() => {
      statusMessage.className = '';
    }, 3000);
  }
});

// This function will be injected into the page
async function executeSequence(items) {
  function sleep(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
  }

  async function waitForElement(selector, timeout = 10000) {
    return new Promise((resolve, reject) => {
      const interval = 100;
      let elapsedTime = 0;

      const checkExist = setInterval(() => {
        const element = document.querySelector(selector);
        if (element) {
          clearInterval(checkExist);
          resolve(element);
        } else if (elapsedTime >= timeout) {
          clearInterval(checkExist);
          reject(new Error(`Element not found: ${selector}`));
        }
        elapsedTime += interval;
      }, interval);
    });
  }

  async function clickElement(selector) {
    const element = await waitForElement(selector);
    element.click();
    await sleep(1000); // Wait for animation/loading
  }

  async function clickElementByText(text) {
    const elements = Array.from(document.querySelectorAll('*'));
    const element = elements.find(el => el.textContent.trim() === text);
    if (!element) {
      throw new Error(`Element with text "${text}" not found`);
    }
    element.click();
    await sleep(1000); // Wait for animation/loading
  }


  try {
    console.log('Starting sequence execution');
    
    // Process each item
    for (const item of items) {
      // Wait for "Add source" button to be available
      await waitForElement('[aria-label="ソースを追加"]');
      // Click "Add source" button
      await clickElement('[aria-label="ソースを追加"]');
      
      // Check if URL is from YouTube
      const isYouTubeUrl = item.startsWith('https://youtu.be/') || 
                          item.startsWith('https://www.youtube.com/');
      
      // Click appropriate button based on URL type
      if (isYouTubeUrl) {
        await clickElementByText('YouTube');
      } else {
        await clickElementByText('ウェブサイト');
      }
      
      // Wait for URL input to be available
      const urlInput = await waitForElement('input[formcontrolname="newUrl"]');
      if (!urlInput) {
        throw new Error('URL input not found');
      }

      urlInput.value = item;
      // Dispatch an input event to ensure the value is registered
      urlInput.dispatchEvent(new Event('input', { bubbles: true }));
      await sleep(500);
      
      // Click "Insert" button
      await clickElementByText('挿入');
      await sleep(1000); // Wait between items
    }

    console.log('Sequence completed successfully');
  } catch (error) {
    console.error('Execution error:', error);
  }
}


popup.html
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>NotebookLM All Shooter</title>
  <link rel="stylesheet" href="styles/popup.css">
</head>
<body>
  <div class="container">
    <header>
      <h1>NotebookLM All Shooter</h1>
      <p class="subtitle">URLを一括登録・実行</p>
    </header>
    <main>
      <div class="input-section">
        <label for="input-text">URLを入力(1行に1つ)</label>
        <textarea id="input-text" placeholder="https://example.com&#10;https://example.org"></textarea>
      </div>
      <div class="button-group">
        <button id="register-button" class="primary-button">
          <span class="button-icon">📝</span>
          URLを登録
        </button>
        <button id="view-button" class="secondary-button">
          <span class="button-icon">📋</span>
          登録済みURL確認
        </button>
        <button id="execute-button" class="action-button">
          <span class="button-icon">▶️</span>
          NotebookLMで実行
        </button>
      </div>
      <div id="status-message"></div>
    </main>
  </div>
  <script src="popup.js"></script>
</body>
</html>

まとめ

生成AIの流れがすさまじく、どんどんモデルも賢くなっているので、このAIを使ったコーディングの流れは今後の主流にもなるのかなぁと思いながら、お茶でも飲みながらAIにコードを書いてもらっていました。

業務では、今までGithub copilot補助ありで自分でコーディングしていた単発のツール開発などは、Cline + Claudeですべて代替できそうなイメージが湧いたので、まずこの目的で使ってみようと思います。

またこの対応の中でも記載している通り、YouTubeボタンへも対応しているバージョンが近々公開されるので、perplexity -> notebookLMでURLをたくさん食わせたい人は是非使ってみてください。

Rehab Tech Blog

Discussion