ほぼClineが実装、自分は4行だけ手で書いてchrome拡張機能を公開した話
はじめに
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も使ってみるかと思ったのがきっかけ。
ただ、perplexityでコピーしたときの引用元の元ネタURLをnotebookLMへ登録するときに1個ずつURLを登録するのがすごく面倒だったので、自動操作でURLを一括登録するchrome拡張機能を作りました。
タイトルにもある通り、4行くらい自分で一部いじったくらいで、ほとんどClineが書いてます。
私は自然言語で指示出してるだけ。
以下ではどんな指示出していったのかを具体的に示します。
Clineとの実装
Clineではトータルでかかったコストなども表示されます。
主な点だけ抜粋。
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から改行までにサニタイズしたうえで登録したい。
以下のような先頭文字は勝手に削除してほしかったので、登録時に除外する処理を追加。
登録済み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に指示を出すだけで自動生成されます。私はほぼ動作確認担当。
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);
}
}
<!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 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をたくさん食わせたい人は是非使ってみてください。
Discussion