我经常在浏览器中打开很多网页却没有时间看(实际是懒:-)),如果关掉又怕未来有用时找不到,而如果加入收藏夹,那就基本代表着遗忘了。在关掉可惜,开着又占标签栏空间的两难之间,我准备搞一个“待浏览“插件,将所有已经打开的网页一次性全放进去。然后,就可以把除当前页面外的所有网页标签关闭,这下世界清静了。

        chrome浏览器插件编写我只是知道大概,要写出一个完整能用的插件,按以往的经验,没有两天的时间编码和调试是搞不出来的。所以我决定尝试用trae编程助手来完成开发工作。而让我惊喜的是,当我用提示词分步提出要求后,trae完成了一个完整可用,且一次跑通的插件。而我除了readme.md文件之外,其他html、js、css、josn文件,全部由trae AI自动生成,代码人工“零修改”!

      一、使用trae开发这个叫unview插件的过程

        首先在用trae打一个空文件夹,然后在trae AI侧栏输入提示词。项目分几步完成,每一步的提示词如下:

        第一步提示词:

        我想编写一个Chrome插件,将浏览器当前打开的网面全部放入一个“待浏览”列表,并按日期分组,同时要去除重复的网址。

        第二步提提示词

        现在这个插件已经能够加入待浏览的网址,并按日期分组和去重。我需要在此基础上进一步完善:

  1. 待浏览的网址要能够保存在本地,并在打开插件时,自动加载到插件中。
  2. 加入按网址标题搜索定位的功能。

        第三步提示词

        数据存储在chrome.storage.local中可能会被清理,应该加入保存在本地系统文件中的功能。

        第四步提示词

        现在进一步完善:

  1. 日期分组应该可以折叠,以方便在多个日期间导航
  2. 增加一个关闭除当前而外其他所有页面的功能
  3. 优化功能按钮布局和大小,让功能按钮与列表区有明确的界限

        第五步提示词

        重新生成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

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐