用TRAE编程助手编写一个浏览器插件
本文介绍了一个利用Trae编程助手快速开发的Chrome插件"待浏览",用于管理浏览器标签页。该插件可将当前打开的网页保存到待浏览列表,具备按日期分组、去重、搜索、导入导出等功能。开发者通过五步提示词交互,Trae自动生成了完整可用的插件代码,包括HTML、CSS、JavaScript和manifest文件。插件实现了保存当前页面/所有标签页、关闭其他标签页、数据本地存储等核心
我经常在浏览器中打开很多网页却没有时间看(实际是懒:-)),如果关掉又怕未来有用时找不到,而如果加入收藏夹,那就基本代表着遗忘了。在关掉可惜,开着又占标签栏空间的两难之间,我准备搞一个“待浏览“插件,将所有已经打开的网页一次性全放进去。然后,就可以把除当前页面外的所有网页标签关闭,这下世界清静了。
chrome浏览器插件编写我只是知道大概,要写出一个完整能用的插件,按以往的经验,没有两天的时间编码和调试是搞不出来的。所以我决定尝试用trae编程助手来完成开发工作。而让我惊喜的是,当我用提示词分步提出要求后,trae完成了一个完整可用,且一次跑通的插件。而我除了readme.md文件之外,其他html、js、css、josn文件,全部由trae AI自动生成,代码人工“零修改”!
一、使用trae开发这个叫unview插件的过程
首先在用trae打一个空文件夹,然后在trae AI侧栏输入提示词。项目分几步完成,每一步的提示词如下:
第一步提示词:
我想编写一个Chrome插件,将浏览器当前打开的网面全部放入一个“待浏览”列表,并按日期分组,同时要去除重复的网址。
第二步提提示词
现在这个插件已经能够加入待浏览的网址,并按日期分组和去重。我需要在此基础上进一步完善:
- 待浏览的网址要能够保存在本地,并在打开插件时,自动加载到插件中。
- 加入按网址标题搜索定位的功能。
第三步提示词
数据存储在chrome.storage.local中可能会被清理,应该加入保存在本地系统文件中的功能。
第四步提示词
现在进一步完善:
- 日期分组应该可以折叠,以方便在多个日期间导航
- 增加一个关闭除当前而外其他所有页面的功能
- 优化功能按钮布局和大小,让功能按钮与列表区有明确的界限
第五步提示词
重新生成readme.md
通过上述五步与trae AI助手的交互,trae完整生成了插件框架和相应代码,甚至readme.md都帮助写好了。
二、项目结构
d:\MyProg\browerExt\
├── README.md
└── un_viwe\
├── README.md
├── images\
│ ├── icon128.png
│ ├── icon16.png
│ └── icon48.png
├── manifest.json
├── popup.html
├── popup.js
├── styles.css
└── unview.png
三、相关代码
1、popup.html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>待浏览列表</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div class="container">
<h1>待浏览列表</h1>
<div class="search-container">
<input type="text" id="searchInput" placeholder="搜索网址标题...">
</div>
<div class="button-group">
<div class="buttons">
<button id="addCurrentPage">添加当前页面</button>
<button id="addAllTabs">添加所有标签页</button>
<button id="removeDuplicates">去重</button>
</div>
<div class="buttons">
<button id="closeOtherTabs">关闭其他标签页</button>
<button id="exportData">导出数据</button>
<button id="importData">导入数据</button>
</div>
<input type="file" id="fileInput" accept=".json" style="display: none;">
</div>
<div id="listContainer" class="list-container">
<!-- 待浏览列表将在这里显示 -->
</div>
</div>
<script src="popup.js"></script>
</body>
</html>
2.popup.js
// 获取DOM元素
const addCurrentPageBtn = document.getElementById('addCurrentPage');
const addAllTabsBtn = document.getElementById('addAllTabs');
const removeDuplicatesBtn = document.getElementById('removeDuplicates');
const listContainer = document.getElementById('listContainer');
const searchInput = document.getElementById('searchInput');
const exportDataBtn = document.getElementById('exportData');
const importDataBtn = document.getElementById('importData');
const fileInput = document.getElementById('fileInput');
const closeOtherTabsBtn = document.getElementById('closeOtherTabs');
// 添加搜索功能事件监听
searchInput.addEventListener('input', () => {
loadUrls(searchInput.value.toLowerCase());
});
// 导出数据到本地文件
exportDataBtn.addEventListener('click', async () => {
try {
const data = await chrome.storage.local.get('urlsByDate');
const urlsByDate = data.urlsByDate || {};
// 将数据转换为JSON字符串
const jsonData = JSON.stringify(urlsByDate, null, 2);
// 创建Blob对象
const blob = new Blob([jsonData], { type: 'application/json' });
const url = URL.createObjectURL(blob);
// 创建下载链接
const a = document.createElement('a');
a.href = url;
// 以当前日期作为文件名
const today = new Date().toISOString().split('T')[0];
a.download = `待浏览列表备份_${today}.json`;
document.body.appendChild(a);
a.click();
// 清理
setTimeout(() => {
document.body.removeChild(a);
URL.revokeObjectURL(url);
}, 100);
showNotification('数据已成功导出到本地文件');
} catch (error) {
console.error('导出数据失败:', error);
showNotification('导出数据失败,请重试');
}
});
// 关闭除当前页面外的其他所有标签页
closeOtherTabsBtn.addEventListener('click', async () => {
try {
// 获取当前窗口的所有标签页
const tabs = await chrome.tabs.query({ currentWindow: true });
// 获取当前活动标签页
const activeTab = await chrome.tabs.query({ active: true, currentWindow: true });
if (tabs.length <= 1) {
showNotification('没有其他标签页可关闭');
return;
}
// 显示确认对话框
if (confirm(`确定要关闭除当前页外的 ${tabs.length - 1} 个标签页吗?`)) {
// 收集所有非活动标签页的ID
const tabsToClose = tabs.filter(tab => tab.id !== activeTab[0].id).map(tab => tab.id);
// 关闭这些标签页
await chrome.tabs.remove(tabsToClose);
showNotification(`已关闭 ${tabsToClose.length} 个标签页`);
}
} catch (error) {
console.error('关闭其他标签页失败:', error);
showNotification('关闭其他标签页失败,请重试');
}
});
// 导入数据从本地文件
importDataBtn.addEventListener('click', () => {
fileInput.click();
});
fileInput.addEventListener('change', async (e) => {
try {
const file = e.target.files[0];
if (!file) return;
// 检查文件类型
if (!file.name.endsWith('.json')) {
showNotification('请选择JSON格式的文件');
fileInput.value = ''; // 重置文件输入
return;
}
// 读取文件内容
const reader = new FileReader();
reader.onload = async (event) => {
try {
const jsonData = event.target.result;
const urlsByDate = JSON.parse(jsonData);
// 显示确认对话框,警告用户导入会覆盖现有数据
if (confirm('导入数据将会覆盖现有列表,确定要继续吗?')) {
// 保存导入的数据
await chrome.storage.local.set({ urlsByDate });
showNotification('数据已成功导入');
loadUrls(); // 重新加载列表
}
} catch (parseError) {
console.error('解析文件失败:', parseError);
showNotification('文件格式错误,无法导入数据');
}
};
reader.onerror = () => {
console.error('读取文件失败');
showNotification('读取文件失败,请重试');
};
reader.readAsText(file);
} catch (error) {
console.error('导入数据失败:', error);
showNotification('导入数据失败,请重试');
} finally {
fileInput.value = ''; // 重置文件输入
}
});
// 初始化页面时加载待浏览列表
loadUrls();
// 添加当前页面到待浏览列表
addCurrentPageBtn.addEventListener('click', async () => {
try {
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
if (tabs && tabs.length > 0) {
const currentTab = tabs[0];
await addUrlToList(currentTab.url, currentTab.title);
showNotification('当前页面已添加到待浏览列表');
}
} catch (error) {
console.error('添加当前页面失败:', error);
}
});
// 添加所有标签页到待浏览列表
addAllTabsBtn.addEventListener('click', async () => {
try {
const tabs = await chrome.tabs.query({ currentWindow: true });
if (tabs && tabs.length > 0) {
const urlsAdded = [];
for (const tab of tabs) {
if (tab.url && !tab.url.startsWith('chrome://')) {
await addUrlToList(tab.url, tab.title);
urlsAdded.push(tab.url);
}
}
showNotification(`已添加 ${urlsAdded.length} 个标签页到待浏览列表`);
}
} catch (error) {
console.error('添加所有标签页失败:', error);
}
});
// 去重功能
removeDuplicatesBtn.addEventListener('click', async () => {
try {
const data = await chrome.storage.local.get('urlsByDate');
const urlsByDate = data.urlsByDate || {};
// 收集所有URL并检测重复
const allUrls = new Map(); // key: url, value: {date, title}
const duplicateUrls = new Set();
for (const [date, urls] of Object.entries(urlsByDate)) {
for (const urlInfo of urls) {
if (allUrls.has(urlInfo.url)) {
duplicateUrls.add(urlInfo.url);
} else {
allUrls.set(urlInfo.url, { date, title: urlInfo.title });
}
}
}
if (duplicateUrls.size === 0) {
showNotification('没有找到重复的URL');
return;
}
// 显示确认对话框
const confirmMessage = `找到 ${duplicateUrls.size} 个重复的URL,确定要删除重复项吗?`;
if (confirm(confirmMessage)) {
// 创建新的去重后的URL数据结构
const newUrlsByDate = {};
// 遍历去重后的URL集合
for (const [url, info] of allUrls.entries()) {
if (!newUrlsByDate[info.date]) {
newUrlsByDate[info.date] = [];
}
newUrlsByDate[info.date].push({ url, title: info.title });
}
// 保存去重后的数据
await chrome.storage.local.set({ urlsByDate: newUrlsByDate });
showNotification(`已删除 ${duplicateUrls.size} 个重复的URL`);
loadUrls(); // 重新加载列表
}
} catch (error) {
console.error('去重失败:', error);
}
});
// 添加URL到列表
async function addUrlToList(url, title) {
try {
const data = await chrome.storage.local.get('urlsByDate');
const urlsByDate = data.urlsByDate || {};
// 获取当前日期(YYYY-MM-DD格式)
const today = new Date().toISOString().split('T')[0];
// 初始化今天的数组(如果不存在)
if (!urlsByDate[today]) {
urlsByDate[today] = [];
}
// 检查URL是否已存在(在当前日期下)
const urlExists = urlsByDate[today].some(urlInfo => urlInfo.url === url);
if (!urlExists) {
urlsByDate[today].push({ url, title: title || url });
await chrome.storage.local.set({ urlsByDate });
loadUrls(); // 重新加载列表
}
} catch (error) {
console.error('添加URL失败:', error);
}
}
// 加载并显示待浏览列表
async function loadUrls(searchTerm = '') {
try {
const data = await chrome.storage.local.get('urlsByDate');
const urlsByDate = data.urlsByDate || {};
// 清空列表容器
listContainer.innerHTML = '';
// 如果没有URL,显示空消息
if (Object.keys(urlsByDate).length === 0) {
const emptyMessage = document.createElement('div');
emptyMessage.className = 'empty-message';
emptyMessage.textContent = '待浏览列表为空,点击上方按钮添加URL';
listContainer.appendChild(emptyMessage);
return;
}
// 获取所有日期并按降序排序
const dates = Object.keys(urlsByDate).sort((a, b) => new Date(b) - new Date(a));
let hasVisibleUrls = false;
// 为每个日期创建分组
for (const date of dates) {
const urls = urlsByDate[date];
// 创建日期分组容器
const dateGroup = document.createElement('div');
dateGroup.className = 'date-group';
// 创建日期标题容器
const dateHeader = document.createElement('div');
dateHeader.className = 'date-header';
// 创建折叠/展开按钮
const toggleBtn = document.createElement('span');
toggleBtn.className = 'toggle-btn';
toggleBtn.textContent = '▼'; // 默认展开
dateHeader.appendChild(toggleBtn);
// 创建日期文本
const dateText = document.createElement('span');
dateText.className = 'date-text';
dateText.textContent = formatDate(date);
dateHeader.appendChild(dateText);
dateGroup.appendChild(dateHeader);
// 创建URL列表容器
const urlsContainer = document.createElement('div');
urlsContainer.className = 'urls-container';
dateGroup.appendChild(urlsContainer);
// 添加折叠/展开功能
dateHeader.addEventListener('click', () => {
const isExpanded = toggleBtn.textContent === '▼';
toggleBtn.textContent = isExpanded ? '▶' : '▼';
urlsContainer.style.display = isExpanded ? 'none' : 'block';
});
let hasVisibleUrlsInDate = false;
// 为每个URL创建条目
urls.forEach((urlInfo, index) => {
// 根据搜索词过滤URL
if (searchTerm && !urlInfo.title.toLowerCase().includes(searchTerm) && !urlInfo.url.toLowerCase().includes(searchTerm)) {
return;
}
hasVisibleUrls = true;
hasVisibleUrlsInDate = true;
const urlItem = document.createElement('div');
urlItem.className = 'url-item';
// 创建URL链接
const urlLink = document.createElement('a');
urlLink.href = urlInfo.url;
urlLink.target = '_blank';
urlLink.textContent = urlInfo.title || urlInfo.url;
urlItem.appendChild(urlLink);
// 创建删除按钮
const deleteBtn = document.createElement('button');
deleteBtn.textContent = '删除';
deleteBtn.addEventListener('click', (e) => {
e.preventDefault(); // 阻止链接跳转
deleteUrl(date, index);
});
urlItem.appendChild(deleteBtn);
urlsContainer.appendChild(urlItem); // 将URL项添加到URL容器中
});
// 只有当该日期有可见的URL时,才添加到列表容器
if (hasVisibleUrlsInDate) {
listContainer.appendChild(dateGroup);
}
}
// 如果搜索后没有匹配的URL,显示提示信息
if (!hasVisibleUrls) {
const noResultsMessage = document.createElement('div');
noResultsMessage.className = 'empty-message';
noResultsMessage.textContent = '没有找到匹配的URL';
listContainer.appendChild(noResultsMessage);
}
} catch (error) {
console.error('加载URL列表失败:', error);
}
}
// 删除指定的URL
async function deleteUrl(date, index) {
try {
const data = await chrome.storage.local.get('urlsByDate');
const urlsByDate = data.urlsByDate || {};
if (urlsByDate[date] && urlsByDate[date][index]) {
// 从数组中删除指定索引的URL
urlsByDate[date].splice(index, 1);
// 如果该日期的数组为空,则删除该日期
if (urlsByDate[date].length === 0) {
delete urlsByDate[date];
}
// 保存更新后的数据
await chrome.storage.local.set({ urlsByDate });
loadUrls(); // 重新加载列表
}
} catch (error) {
console.error('删除URL失败:', error);
}
}
// 格式化日期显示
function formatDate(dateString) {
const date = new Date(dateString);
const year = date.getFullYear();
const month = (date.getMonth() + 1).toString().padStart(2, '0');
const day = date.getDate().toString().padStart(2, '0');
// 获取今天、昨天和明天的日期进行比较
const today = new Date().toISOString().split('T')[0];
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
const yesterdayString = yesterday.toISOString().split('T')[0];
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const tomorrowString = tomorrow.toISOString().split('T')[0];
if (dateString === today) {
return `今天 ${year}-${month}-${day}`;
} else if (dateString === yesterdayString) {
return `昨天 ${year}-${month}-${day}`;
} else if (dateString === tomorrowString) {
return `明天 ${year}-${month}-${day}`;
} else {
return `${year}-${month}-${day}`;
}
}
// 显示通知
function showNotification(message) {
// 创建一个简单的通知元素
const notification = document.createElement('div');
notification.style.position = 'fixed';
notification.style.bottom = '10px';
notification.style.right = '10px';
notification.style.padding = '10px 15px';
notification.style.backgroundColor = '#4285f4';
notification.style.color = 'white';
notification.style.borderRadius = '4px';
notification.style.zIndex = '1000';
notification.style.boxShadow = '0 2px 5px rgba(0,0,0,0.2)';
notification.textContent = message;
document.body.appendChild(notification);
// 3秒后移除通知
setTimeout(() => {
notification.style.opacity = '0';
notification.style.transition = 'opacity 0.5s';
setTimeout(() => {
document.body.removeChild(notification);
}, 500);
}, 3000);
}
3.styles.css
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
width: 400px;
max-height: 600px;
background-color: #f5f5f5;
}
.container {
padding: 16px;
}
h1 {
font-size: 18px;
margin-bottom: 16px;
color: #333;
text-align: center;
}
.search-container {
margin-bottom: 16px;
padding-bottom: 16px;
border-bottom: 2px solid #e0e0e0;
}
#searchInput {
width: 100%;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
outline: none;
transition: border-color 0.2s;
margin-bottom: 12px;
}
#searchInput:focus {
border-color: #4285f4;
}
.toggle-btn {
margin-right: 8px;
cursor: pointer;
font-size: 12px;
width: 16px;
display: inline-block;
text-align: center;
}
date-text {
flex: 1;
}
.urls-container {
display: block;
}
.buttons {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
button {
flex: 1;
padding: 8px 12px;
border: none;
border-radius: 4px;
background-color: #4285f4;
color: white;
font-size: 14px;
cursor: pointer;
transition: background-color 0.2s;
}
button:hover {
background-color: #3367d6;
}
button:active {
transform: scale(0.98);
}
.list-container {
max-height: 450px;
overflow-y: auto;
}
.date-group {
margin-bottom: 16px;
background-color: white;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
overflow: hidden;
}
.date-header {
background-color: #4285f4;
color: white;
padding: 8px 12px;
font-weight: bold;
font-size: 14px;
display: flex;
align-items: center;
cursor: pointer;
}
.date-text {
flex: 1;
}
.url-item {
display: flex;
align-items: center;
padding: 8px 12px;
border-bottom: 1px solid #eee;
transition: background-color 0.2s;
}
.url-item:last-child {
border-bottom: none;
}
.url-item:hover {
background-color: #f9f9f9;
}
.url-item a {
flex: 1;
color: #333;
text-decoration: none;
word-break: break-all;
font-size: 13px;
}
.url-item a:hover {
text-decoration: underline;
}
.url-item button {
margin-left: 8px;
padding: 4px 8px;
background-color: #ea4335;
font-size: 12px;
}
.url-item button:hover {
background-color: #d3392c;
}
.empty-message {
text-align: center;
color: #999;
padding: 20px;
font-size: 14px;
}
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 3px;
}
::-webkit-scrollbar-thumb {
background: #ccc;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #aaa;
}
4.manifest.json
{
"manifest_version": 3,
"name": "待浏览列表",
"description": "将当前打开的网页地址归纳到待浏览列表,并按日期分组和去重功能",
"version": "1.0",
"action": {
"default_popup": "popup.html",
"default_icon": {
"16": "images/icon16.png",
"48": "images/icon48.png",
"128": "images/icon128.png"
}
},
"permissions": [
"tabs",
"storage"
],
"icons": {
"16": "images/icon16.png",
"48": "images/icon48.png",
"128": "images/icon128.png"
}
}
将上述代码放在一个单独的文件夹如d:\unview,并在其中创建一个images子目录,放上自己的3个icon文件,然后在chrome浏览器中打开扩展管理,打开”开发者柜式“,点击”加载已解压的扩展程序“(我用的360极速浏览器),在插件管理器上点选”常驻“,此插件就可以使用了。
四、TRAE自动生成firefox、edge同款插件
提示词如下:
“现在un_viwe在chrome浏览器中已经正常工作了,但是我想将这引插件也用在foxfire浏览器上,请在fox_unview文件夹中创建一个同样功能,在foxfire浏览器上工作的插件。”
提示词中甚至有错字,但trae用时8分钟,完成了代码平台转换,将Chrome API调用(chrome. )替换为Firefox兼容的browser. API调用。利用Firefox临时加载功能加载插件后,导入标签页等功能正常,但关闭其他标签和导入文件功能无法正常工作。
相同的提示词,替换成edge浏览器后,edge插件一次性生成,一次性通过。原因trae也给出了提示:“Edge浏览器支持Chrome的API“
五、关于firefox插件的问题
这是文章发布之后,追加上的内容,实在是解决firefox插件的问题消耗了大量时间。这里将我在项目说明里的内容也放上来,让和我一样对firefox扩展开发不太熟悉的开发者少入几个坑。
由于firefox的popup窗口在失去焦点后会自动隐藏,导致文件选择后的 "change" 事件无法触发,因此导入文件功能做了以下特殊处理:
- 在 `background.js` 中创建一个扩展页面:`file_open.html`和`file_open.js`,用于文件选择。
- 在 `file_open.js` 中监听 `file_open.html` 的 change 事件,读取导入的文件内容。
- 将读取的内容存储到 browser.storage.local 中
- popup 窗口每次打开时,从 storage 中加载内容
- 也尝试过在background.html中放置一个隐藏的 input 元素,并监听其 change 事件,但是除了browser.browserAction事件能够调用input.click()方法外,其他事件都无法调用input.click()方法,因此放弃了这种方法。究其原因,依然是firefox的扩展安全机制限制input.click()方法的调用。
- 在`manifest.json`的`persistent`属性中增加了`tabs`和`history`权限,`tabs`权限如果不加,则无法获取其他标签的链接,`history`权限如果不加,则无法清除browser.windows.create()方法创建新标签页的历史记录。而在chrome的插件中,在没有显示的加入`tabs`权限的情况下, popup 窗口可以正常工作。
- firefox的安全机制使chrome插件的迁移变得复杂,需要考虑更多安全和兼容性问题。同时也能感觉到现有AI助手对firefox的特殊性了解不足,而且firefox的扩展开发docs让人非常难以发现其与chrome不兼容的地方,找解决问题的方法花了相当长的时间。firefox和chrome扩展开发的差别增加了开发者的学习成本,也增加了插件的维护成本。
- 这个firefox插件本身是为了测试AI助手的能力,由于对firefox的扩展开发不熟悉,可能解决兼容性问题的方式并不专业,还请高手给予指正。
最后,AI助手是一个有益的帮手,但是目前在解决新问题时,还需要开发人员发挥自己的能力找到解决办法。有了解决办法,让AI助手去做常规的编码工作,会大幅度提升效率。
项目的github地址:https://github.com/woxili880409/unview
更多推荐
所有评论(0)