├── folder_structure ├── manifest.json ├── 申请教程.md ├── README.md ├── popup.html ├── styles.css ├── popup.js ├── LICENSE └── background.js /folder_structure: -------------------------------------------------------------------------------- 1 | bookmark-ai-organizer/ 2 | ├── manifest.json 3 | ├── popup.html 4 | ├── popup.js 5 | ├── background.js 6 | └── styles.css -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "AI 书签分类助手", 4 | "version": "1.0", 5 | "description": "使用 AI 智能分类您的书签", 6 | "permissions": [ 7 | "bookmarks", 8 | "storage", 9 | "webRequest" 10 | ], 11 | "host_permissions": [ 12 | "https://generativelanguage.googleapis.com/*", 13 | "" 14 | ], 15 | "action": { 16 | "default_popup": "popup.html" 17 | }, 18 | "background": { 19 | "service_worker": "background.js" 20 | } 21 | } -------------------------------------------------------------------------------- /申请教程.md: -------------------------------------------------------------------------------- 1 | # Google Gemini API 密钥申请教程 2 | 3 | ## 步骤一:准备工作 4 | 1. 确保你有一个 Google 账号 5 | 2. 准备一张国际信用卡(首次使用有免费额度,一般够用) 6 | 7 | ## 步骤二:访问 Google AI Studio 8 | 1. 打开浏览器,访问:https://makersuite.google.com/app/apikey 9 | 2. 使用你的 Google 账号登录 10 | 3. 如果是首次使用,需要同意服务条款 11 | 12 | ## 步骤三:创建 API 密钥 13 | 1. 在页面上找到 "Get API key" 或 "创建 API 密钥" 按钮 14 | 2. 点击 "Create API key" 按钮 15 | 3. 系统会自动生成一个 API 密钥 16 | 4. 立即复制并保存这个密钥(它只会显示一次!) 17 | 18 | ## 步骤四:在插件中使用 19 | 1. 打开 Chrome 浏览器 20 | 2. 点击书签分类插件图标 21 | 3. 将复制的 API 密钥粘贴到输入框 22 | 4. 点击"保存密钥"按钮 23 | 24 | ## 注意事项 25 | 1. API 密钥很重要,请勿分享给他人 26 | 2. 免费额度:每分钟 60 次请求 27 | 3. 如果密钥泄露,可以随时在 Google AI Studio 中重新生成 28 | 4. 建议定期更换密钥以确保安全 29 | 30 | ## 常见问题 31 | 1. Q: 提示 "API key not valid" 32 | A: 请检查密钥是否正确复制,或尝试重新生成一个 33 | 34 | 2. Q: 超出使用限制 35 | A: 等待一分钟后重试,或创建新的 API 密钥 36 | 37 | 3. Q: 密钥不小心泄露 38 | A: 立即在 Google AI Studio 删除旧密钥并创建新密钥 39 | 40 | ## 免费额度说明 41 | - 每个 API 密钥每分钟可以处理 60 次请求 42 | - 对于个人使用完全够用 43 | - 无需绑定信用卡也可以使用 -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AI 书签分类助手 2 | 3 | 一个 Chrome 扩展,使用 Google Gemini AI 智能分析并自动分类您的浏览器书签,让书签管理更轻松高效。 4 | 5 | ## 功能特点 6 | 7 | - 🤖 AI 智能分类:使用 Google Gemini AI 自动分析书签内容并进行分类 8 | - 📂 二级分类:支持主分类和子分类的两级分类结构 9 | - 🔍 无效链接检测:自动检测并归类无法访问的书签 10 | - 🔄 重复书签清理:智能识别并整理重复的书签 11 | - 📱 文件夹管理:支持一键打散所有文件夹 12 | - 🔑 安全可靠:API 密钥本地存储,确保安全性 13 | 14 | ## 安装使用 15 | 16 | 1. 下载扩展 17 | - 克隆仓库到本地 18 | ```bash 19 | git clone https://github.com/your-username/bookmark-ai-organizer.git 20 | ``` 21 | - 或直接下载 ZIP 文件并解压 22 | 23 | 2. 安装扩展 24 | - 打开 Chrome 浏览器,访问 `chrome://extensions/` 25 | - 开启右上角的"开发者模式" 26 | - 点击"加载已解压的扩展程序" 27 | - 选择项目文件夹 28 | 29 | 3. 配置 API 30 | - 点击工具栏中的扩展图标 31 | - 按照教程申请 Google Gemini API 密钥 32 | - 将密钥填入并保存 33 | 34 | 4. 开始使用 35 | - 点击"开始智能分类"进行书签分类 36 | - 使用"打散所有文件夹"重置分类 37 | - 使用"清理重复书签"整理重复内容 38 | 39 | ## 环境要求 40 | 41 | - Chrome 浏览器 88 或更高版本 42 | - Google Gemini API 密钥(可免费申请) 43 | 44 | ## 技术栈 45 | 46 | - Chrome Extension API 47 | - Google Gemini AI API 48 | - JavaScript (ES6+) 49 | - HTML5 & CSS3 50 | 51 | ## 注意事项 52 | 53 | - 首次使用需要配置 Google Gemini API 密钥 54 | - API 有每分钟 60 次的免费使用限制 55 | - 建议在分类前备份重要书签 56 | - 分类过程中请勿关闭浏览器 57 | 58 | ## 许可证 59 | 60 | 本项目采用 Apache License 2.0 许可证 - 查看 [LICENSE](LICENSE) 文件了解详情 -------------------------------------------------------------------------------- /popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 |

AI 书签分类

10 | 11 |
12 |
13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 |
21 | 22 | 63 | 64 |
65 | 66 | 67 | 68 | 80 |
81 | 85 |
86 | 87 | 88 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | .container { 2 | width: 400px; 3 | padding: 16px; 4 | } 5 | 6 | .api-settings { 7 | margin-bottom: 20px; 8 | padding: 10px; 9 | background-color: #f5f5f5; 10 | border-radius: 4px; 11 | } 12 | 13 | .api-settings input { 14 | width: 70%; 15 | padding: 8px; 16 | margin-right: 8px; 17 | border: 1px solid #ddd; 18 | border-radius: 4px; 19 | } 20 | 21 | .status { 22 | display: block; 23 | margin-top: 8px; 24 | font-size: 12px; 25 | } 26 | 27 | .status.success { 28 | color: #4CAF50; 29 | } 30 | 31 | .status.error { 32 | color: #f44336; 33 | } 34 | 35 | .hidden { 36 | display: none; 37 | } 38 | 39 | .controls { 40 | margin: 16px 0; 41 | } 42 | 43 | .progress-info { 44 | display: flex; 45 | justify-content: space-between; 46 | margin-bottom: 8px; 47 | font-size: 14px; 48 | } 49 | 50 | .progress-bar { 51 | width: 100%; 52 | height: 6px; 53 | background-color: #e0e0e0; 54 | border-radius: 3px; 55 | overflow: hidden; 56 | margin: 8px 0; 57 | } 58 | 59 | .progress-fill { 60 | width: 0%; 61 | height: 100%; 62 | background-color: #4CAF50; 63 | transition: width 0.3s ease; 64 | } 65 | 66 | .progress-detail { 67 | text-align: center; 68 | font-size: 12px; 69 | color: #666; 70 | margin-top: 4px; 71 | } 72 | 73 | #categoryList { 74 | max-height: 400px; 75 | overflow-y: auto; 76 | } 77 | 78 | button { 79 | padding: 8px 16px; 80 | background-color: #4CAF50; 81 | color: white; 82 | border: none; 83 | border-radius: 4px; 84 | cursor: pointer; 85 | } 86 | 87 | button:hover { 88 | background-color: #45a049; 89 | } 90 | 91 | .tutorial { 92 | background-color: #f8f9fa; 93 | border-radius: 8px; 94 | padding: 16px; 95 | margin: 16px 0; 96 | box-shadow: 0 2px 4px rgba(0,0,0,0.1); 97 | } 98 | 99 | .tutorial-section { 100 | margin-bottom: 16px; 101 | } 102 | 103 | .tutorial-section h4 { 104 | color: #2196F3; 105 | margin-bottom: 8px; 106 | } 107 | 108 | .tutorial-section ul { 109 | padding-left: 20px; 110 | margin: 8px 0; 111 | } 112 | 113 | .tutorial-section li { 114 | margin: 4px 0; 115 | line-height: 1.4; 116 | } 117 | 118 | .tutorial a { 119 | color: #2196F3; 120 | text-decoration: none; 121 | } 122 | 123 | .tutorial a:hover { 124 | text-decoration: underline; 125 | } 126 | 127 | .tutorial-btn { 128 | background-color: #2196F3; 129 | margin-left: 8px; 130 | } 131 | 132 | .tutorial-btn:hover { 133 | background-color: #1976D2; 134 | } 135 | 136 | button.secondary { 137 | background-color: #757575; 138 | } 139 | 140 | button.secondary:hover { 141 | background-color: #616161; 142 | } 143 | 144 | /* 添加 API 输入组样式 */ 145 | .api-input-group { 146 | display: flex; 147 | align-items: center; 148 | margin-bottom: 8px; 149 | gap: 4px; 150 | } 151 | 152 | .api-input-group input { 153 | flex: 1; 154 | } 155 | 156 | .icon-btn { 157 | padding: 8px; 158 | background: none; 159 | border: 1px solid #ddd; 160 | border-radius: 4px; 161 | cursor: pointer; 162 | font-size: 16px; 163 | display: flex; 164 | align-items: center; 165 | justify-content: center; 166 | min-width: 36px; 167 | transition: background-color 0.2s; 168 | } 169 | 170 | .icon-btn:hover { 171 | background-color: #f0f0f0; 172 | } 173 | 174 | /* 复制成功提示 */ 175 | .copy-tooltip { 176 | position: fixed; 177 | background: rgba(0, 0, 0, 0.8); 178 | color: white; 179 | padding: 4px 8px; 180 | border-radius: 4px; 181 | font-size: 12px; 182 | pointer-events: none; 183 | animation: fadeOut 1.5s forwards; 184 | } 185 | 186 | @keyframes fadeOut { 187 | 0% { opacity: 1; } 188 | 70% { opacity: 1; } 189 | 100% { opacity: 0; } 190 | } 191 | 192 | /* 修改原有的 API 设置样式 */ 193 | .api-settings input { 194 | padding: 8px; 195 | border: 1px solid #ddd; 196 | border-radius: 4px; 197 | font-size: 14px; 198 | } -------------------------------------------------------------------------------- /popup.js: -------------------------------------------------------------------------------- 1 | // API 密钥相关功能 2 | document.getElementById('saveApiKey').addEventListener('click', async () => { 3 | const apiKey = document.getElementById('apiKey').value.trim(); 4 | const apiStatus = document.getElementById('apiStatus'); 5 | 6 | if (!apiKey) { 7 | apiStatus.textContent = '请输入API密钥'; 8 | apiStatus.className = 'status error'; 9 | return; 10 | } 11 | 12 | try { 13 | const response = await chrome.runtime.sendMessage({ 14 | action: 'setApiKey', 15 | apiKey: apiKey 16 | }); 17 | 18 | if (response.success) { 19 | apiStatus.textContent = '密钥保存成功'; 20 | apiStatus.className = 'status success'; 21 | document.getElementById('apiKey').value = ''; 22 | } else { 23 | throw new Error(response.error || '保存失败'); 24 | } 25 | } catch (error) { 26 | apiStatus.textContent = error.message; 27 | apiStatus.className = 'status error'; 28 | } 29 | }); 30 | 31 | // 添加密钥显示/隐藏功能 32 | document.getElementById('toggleApiKey').addEventListener('click', async () => { 33 | const apiKeyInput = document.getElementById('apiKey'); 34 | const toggleBtn = document.getElementById('toggleApiKey'); 35 | 36 | if (apiKeyInput.type === 'password') { 37 | apiKeyInput.type = 'text'; 38 | toggleBtn.textContent = '🔒'; 39 | toggleBtn.title = '隐藏密钥'; 40 | } else { 41 | apiKeyInput.type = 'password'; 42 | toggleBtn.textContent = '👁️'; 43 | toggleBtn.title = '显示密钥'; 44 | } 45 | }); 46 | 47 | // 添加复制功能 48 | document.getElementById('copyApiKey').addEventListener('click', async () => { 49 | const apiKeyInput = document.getElementById('apiKey'); 50 | const currentKey = apiKeyInput.value; 51 | 52 | if (!currentKey) { 53 | // 如果输入框为空,尝试从存储中获取密钥 54 | const { apiKey } = await chrome.storage.sync.get('apiKey'); 55 | if (apiKey) { 56 | await copyToClipboard(apiKey); 57 | showCopyTooltip('密钥已复制'); 58 | } else { 59 | showCopyTooltip('没有保存的密钥'); 60 | } 61 | } else { 62 | await copyToClipboard(currentKey); 63 | showCopyTooltip('密钥已复制'); 64 | } 65 | }); 66 | 67 | // 复制到剪贴板 68 | async function copyToClipboard(text) { 69 | try { 70 | await navigator.clipboard.writeText(text); 71 | } catch (err) { 72 | // 如果clipboard API不可用,使用传统方法 73 | const textarea = document.createElement('textarea'); 74 | textarea.value = text; 75 | document.body.appendChild(textarea); 76 | textarea.select(); 77 | document.execCommand('copy'); 78 | document.body.removeChild(textarea); 79 | } 80 | } 81 | 82 | // 显示复制成功提示 83 | function showCopyTooltip(message) { 84 | const tooltip = document.createElement('div'); 85 | tooltip.className = 'copy-tooltip'; 86 | tooltip.textContent = message; 87 | 88 | // 定位在复制按钮下方 89 | const copyBtn = document.getElementById('copyApiKey'); 90 | const rect = copyBtn.getBoundingClientRect(); 91 | tooltip.style.left = `${rect.left}px`; 92 | tooltip.style.top = `${rect.bottom + 5}px`; 93 | 94 | document.body.appendChild(tooltip); 95 | 96 | // 1.5秒后移除提示 97 | setTimeout(() => { 98 | document.body.removeChild(tooltip); 99 | }, 1500); 100 | } 101 | 102 | // 检查是否已设置API密钥 103 | async function checkApiKey() { 104 | const { apiKey } = await chrome.storage.sync.get('apiKey'); 105 | const apiStatus = document.getElementById('apiStatus'); 106 | const apiKeyInput = document.getElementById('apiKey'); 107 | 108 | if (apiKey) { 109 | apiStatus.textContent = '已设置API密钥'; 110 | apiStatus.className = 'status success'; 111 | apiKeyInput.value = apiKey; 112 | } 113 | } 114 | 115 | // 添加状态检查和更新 116 | async function checkClassificationState() { 117 | const result = await chrome.runtime.sendMessage({ 118 | action: 'getClassificationState' 119 | }); 120 | 121 | if (result.isRunning) { 122 | // 如果分类正在进行,显示进度 123 | const progress = document.getElementById('progress'); 124 | const progressFill = document.querySelector('.progress-fill'); 125 | const percentage = document.getElementById('percentage'); 126 | const status = document.getElementById('status'); 127 | const processedCount = document.getElementById('processedCount'); 128 | const totalCount = document.getElementById('totalCount'); 129 | 130 | progress.classList.remove('hidden'); 131 | progressFill.style.width = `${result.progress}%`; 132 | percentage.textContent = `${Math.round(result.progress)}%`; 133 | status.textContent = result.status; 134 | processedCount.textContent = result.processed; 135 | totalCount.textContent = result.total; 136 | } 137 | } 138 | 139 | // 修改页面加载时的检查 140 | document.addEventListener('DOMContentLoaded', async () => { 141 | await checkApiKey(); 142 | await checkClassificationState(); 143 | }); 144 | 145 | // 修改开始分类按钮处理 146 | document.getElementById('startBtn').addEventListener('click', async () => { 147 | const progress = document.getElementById('progress'); 148 | const status = document.getElementById('status'); 149 | const progressFill = document.querySelector('.progress-fill'); 150 | 151 | try { 152 | // 重置进度显示 153 | progress.classList.remove('hidden'); 154 | progressFill.style.width = '0%'; 155 | document.getElementById('percentage').textContent = '0%'; 156 | status.textContent = '准备中...'; 157 | 158 | // 获取所有书签 159 | const bookmarks = await chrome.bookmarks.getTree(); 160 | const flatBookmarks = flattenBookmarks(bookmarks); 161 | 162 | // 发送到后台进行 AI 分类 163 | const result = await chrome.runtime.sendMessage({ 164 | action: 'classifyBookmarks', 165 | bookmarks: flatBookmarks 166 | }); 167 | 168 | if (result.error) { 169 | throw new Error(result.error); 170 | } 171 | 172 | // 显示结果 173 | if (!result.isRunning) { 174 | displayResults(result); 175 | } 176 | } catch (error) { 177 | status.textContent = '分类过程出错: ' + error.message; 178 | } 179 | }); 180 | 181 | function flattenBookmarks(nodes) { 182 | let bookmarks = []; 183 | for (const node of nodes) { 184 | if (node.children) { 185 | bookmarks = bookmarks.concat(flattenBookmarks(node.children)); 186 | } else if (node.url) { 187 | bookmarks.push({ 188 | id: node.id, 189 | title: node.title, 190 | url: node.url 191 | }); 192 | } 193 | } 194 | return bookmarks; 195 | } 196 | 197 | function displayResults(categories) { 198 | const results = document.getElementById('results'); 199 | const categoryList = document.getElementById('categoryList'); 200 | results.classList.remove('hidden'); 201 | categoryList.innerHTML = ''; 202 | 203 | for (const [mainCategory, data] of Object.entries(categories)) { 204 | const li = document.createElement('li'); 205 | const subCategories = data.subCategories; 206 | 207 | let subCategoryHtml = ''; 208 | for (const [subName, subFolder] of Object.entries(subCategories)) { 209 | subCategoryHtml += ` 210 |
  • 211 |
    ${subName}
    212 |
  • 213 | `; 214 | } 215 | 216 | li.innerHTML = ` 217 |

    ${mainCategory}

    218 | 221 | `; 222 | categoryList.appendChild(li); 223 | } 224 | } 225 | 226 | // 添加打散文件夹按钮的处理 227 | document.getElementById('flattenBtn').addEventListener('click', async () => { 228 | const status = document.getElementById('status'); 229 | const progress = document.getElementById('progress'); 230 | progress.classList.remove('hidden'); 231 | status.textContent = '正在打散文件夹...'; 232 | 233 | try { 234 | const result = await chrome.runtime.sendMessage({ 235 | action: 'flattenFolders' 236 | }); 237 | 238 | if (result.success) { 239 | status.textContent = result.message; 240 | } else { 241 | throw new Error(result.error); 242 | } 243 | } catch (error) { 244 | status.textContent = '操作失败: ' + error.message; 245 | } 246 | }); 247 | 248 | // 修改清理重复书签按钮的处理 249 | document.getElementById('cleanDuplicatesBtn').addEventListener('click', async () => { 250 | const progress = document.getElementById('progress'); 251 | const status = document.getElementById('status'); 252 | const progressFill = document.querySelector('.progress-fill'); 253 | 254 | try { 255 | // 重置进度显示 256 | progress.classList.remove('hidden'); 257 | progressFill.style.width = '0%'; 258 | document.getElementById('percentage').textContent = '0%'; 259 | status.textContent = '正在查找重复书签...'; 260 | 261 | const result = await chrome.runtime.sendMessage({ 262 | action: 'cleanDuplicates' 263 | }); 264 | 265 | if (result && result.success) { 266 | status.textContent = result.message; 267 | progressFill.style.width = '100%'; 268 | document.getElementById('percentage').textContent = '100%'; 269 | } else { 270 | throw new Error(result?.error || '清理失败'); 271 | } 272 | } catch (error) { 273 | console.error('清理重复书签时出错:', error); 274 | status.textContent = '清理失败: ' + (error.message || '未知错误'); 275 | progressFill.style.width = '0%'; 276 | document.getElementById('percentage').textContent = '0%'; 277 | } 278 | }); 279 | 280 | // 页面加载时检查API密钥 281 | document.addEventListener('DOMContentLoaded', checkApiKey); 282 | 283 | // 添加教程控制 284 | document.getElementById('showTutorial').addEventListener('click', () => { 285 | document.getElementById('tutorial').classList.remove('hidden'); 286 | }); 287 | 288 | document.getElementById('closeTutorial').addEventListener('click', () => { 289 | document.getElementById('tutorial').classList.add('hidden'); 290 | }); 291 | 292 | // 在新标签页打开链接 293 | document.addEventListener('click', (e) => { 294 | if (e.target.tagName === 'A' && e.target.href) { 295 | e.preventDefault(); 296 | chrome.tabs.create({ url: e.target.href }); 297 | } 298 | }); -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /background.js: -------------------------------------------------------------------------------- 1 | // 添加全局状态管理 2 | let classificationState = { 3 | isRunning: false, 4 | progress: 0, 5 | processed: 0, 6 | total: 0, 7 | status: '' 8 | }; 9 | 10 | // 监听来自popup的消息 11 | chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { 12 | if (request.action === 'classifyBookmarks') { 13 | // 如果已经在运行,返回当前状态 14 | if (classificationState.isRunning) { 15 | sendResponse({ 16 | isRunning: true, 17 | progress: classificationState.progress, 18 | status: classificationState.status, 19 | processed: classificationState.processed, 20 | total: classificationState.total 21 | }); 22 | return true; 23 | } 24 | 25 | // 开始新的分类任务 26 | classificationState.isRunning = true; 27 | classifyBookmarks(request.bookmarks) 28 | .then(result => { 29 | classificationState.isRunning = false; 30 | sendResponse(result); 31 | }) 32 | .catch(error => { 33 | classificationState.isRunning = false; 34 | sendResponse({error: error.message}); 35 | }); 36 | return true; 37 | } else if (request.action === 'flattenFolders') { 38 | flattenAllFolders() 39 | .then(sendResponse) 40 | .catch(error => sendResponse({error: error.message})); 41 | return true; 42 | } else if (request.action === 'cleanDuplicates') { 43 | cleanDuplicateBookmarks() 44 | .then(result => sendResponse(result)) 45 | .catch(error => sendResponse({ 46 | success: false, 47 | error: error.message 48 | })); 49 | return true; 50 | } else if (request.action === 'setApiKey') { 51 | chrome.storage.sync.set({ apiKey: request.apiKey }) 52 | .then(() => sendResponse({ success: true })) 53 | .catch(error => sendResponse({ 54 | success: false, 55 | error: error.message 56 | })); 57 | return true; 58 | } else if (request.action === 'getClassificationState') { 59 | // 返回当前状态 60 | sendResponse(classificationState); 61 | return true; 62 | } 63 | }); 64 | 65 | // 打散文件夹的功能 66 | async function flattenAllFolders() { 67 | try { 68 | const bookmarks = await chrome.bookmarks.getTree(); 69 | await flattenFolderRecursive(bookmarks[0]); 70 | return { success: true, message: '文件夹打散完成' }; 71 | } catch (error) { 72 | throw new Error('打散文件夹失败: ' + error.message); 73 | } 74 | } 75 | 76 | async function flattenFolderRecursive(node) { 77 | if (node.children) { 78 | // 复制一份子节点数组,因为我们会修改原数组 79 | const children = [...node.children]; 80 | for (const child of children) { 81 | if (child.children) { 82 | // 是文件夹 83 | await flattenFolderRecursive(child); 84 | // 将书签移动到根目录 85 | for (const bookmark of child.children || []) { 86 | if (bookmark.url) { 87 | await chrome.bookmarks.move(bookmark.id, { 88 | parentId: '1' // '1' 是书签栏的ID 89 | }); 90 | } 91 | } 92 | // 删除空文件夹 93 | if (child.id !== '1' && child.id !== '2') { // 不删除书签栏和其他书签 94 | await chrome.bookmarks.remove(child.id); 95 | } 96 | } 97 | } 98 | } 99 | } 100 | 101 | async function classifyBookmarks(bookmarks) { 102 | const { apiKey } = await chrome.storage.sync.get('apiKey'); 103 | if (!apiKey) { 104 | throw new Error('请先设置 Google Gemini API 密钥'); 105 | } 106 | 107 | const categories = {}; 108 | let processed = 0; 109 | const total = bookmarks.length; 110 | 111 | // 创建无法访问的书签文件夹 112 | const invalidFolder = await chrome.bookmarks.create({ 113 | parentId: '1', 114 | title: '⚠️ 无法访问的书签' 115 | }); 116 | 117 | for (const bookmark of bookmarks) { 118 | try { 119 | updateProgress( 120 | (processed / total) * 100, 121 | `正在检查: ${bookmark.title}`, 122 | processed, 123 | total 124 | ); 125 | 126 | // 检查页面是否可访问 127 | const isAccessible = await checkPageAccessibility(bookmark.url); 128 | 129 | if (!isAccessible) { 130 | // 如果页面无法访问,移动到无法访问文件夹 131 | await chrome.bookmarks.create({ 132 | parentId: invalidFolder.id, 133 | title: bookmark.title, 134 | url: bookmark.url 135 | }); 136 | await chrome.bookmarks.remove(bookmark.id); 137 | processed++; 138 | continue; 139 | } 140 | 141 | updateProgress( 142 | (processed / total) * 100, 143 | `正在分类: ${bookmark.title}`, 144 | processed, 145 | total 146 | ); 147 | 148 | const [mainCategory, subCategory] = await getDetailedCategory(bookmark, apiKey); 149 | 150 | // 处理主分类 151 | if (!categories[mainCategory]) { 152 | categories[mainCategory] = { 153 | folder: await chrome.bookmarks.create({ 154 | parentId: '1', // 直接在书签栏创建 155 | title: mainCategory 156 | }), 157 | subCategories: {} 158 | }; 159 | } 160 | 161 | // 处理子分类 162 | if (subCategory) { 163 | if (!categories[mainCategory].subCategories[subCategory]) { 164 | categories[mainCategory].subCategories[subCategory] = await chrome.bookmarks.create({ 165 | parentId: categories[mainCategory].folder.id, 166 | title: subCategory 167 | }); 168 | } 169 | // 创建新书签 170 | await chrome.bookmarks.create({ 171 | parentId: categories[mainCategory].subCategories[subCategory].id, 172 | title: bookmark.title, 173 | url: bookmark.url 174 | }); 175 | } else { 176 | // 如果没有子分类,直接创建在主分类文件夹下 177 | await chrome.bookmarks.create({ 178 | parentId: categories[mainCategory].folder.id, 179 | title: bookmark.title, 180 | url: bookmark.url 181 | }); 182 | } 183 | 184 | // 删除原始书签 185 | await chrome.bookmarks.remove(bookmark.id); 186 | 187 | processed++; 188 | } catch (error) { 189 | console.error('处理错误:', error); 190 | continue; 191 | } 192 | } 193 | 194 | // 如果无法访问文件夹为空,则删除它 195 | const invalidFolderContent = await chrome.bookmarks.getChildren(invalidFolder.id); 196 | if (invalidFolderContent.length === 0) { 197 | await chrome.bookmarks.remove(invalidFolder.id); 198 | } 199 | 200 | // 清理空文件夹 201 | await cleanEmptyFolders(); 202 | 203 | return categories; 204 | } 205 | 206 | // 添加清理空文件夹的功能 207 | async function cleanEmptyFolders() { 208 | const bookmarks = await chrome.bookmarks.getTree(); 209 | await cleanEmptyFoldersRecursive(bookmarks[0]); 210 | } 211 | 212 | async function cleanEmptyFoldersRecursive(node) { 213 | if (node.children) { 214 | // 先处理子文件夹 215 | for (const child of [...node.children]) { 216 | if (child.children) { 217 | await cleanEmptyFoldersRecursive(child); 218 | } 219 | } 220 | 221 | // 如果当前文件夹为空且不是根文件夹,则删除 222 | const currentNode = await chrome.bookmarks.get(node.id); 223 | if (currentNode[0].children?.length === 0 && node.id !== '0' && node.id !== '1' && node.id !== '2') { 224 | await chrome.bookmarks.remove(node.id); 225 | } 226 | } 227 | } 228 | 229 | async function getDetailedCategory(bookmark, apiKey) { 230 | const prompt = `分析以下网页的标题和URL,返回两级分类(用|分隔,例如:技术|编程 或 购物|电子产品),分类名称要简短精确: 231 | 标题: ${bookmark.title} 232 | URL: ${bookmark.url} 233 | 要求: 234 | 1. 第一级分类要笼统(如:技术、生活、教育、购物等) 235 | 2. 第二级分类要具体(如:编程、美食、课程、数码等) 236 | 3. 分类名称必须是中文 237 | 4. 只返回分类名称,不要其他解释 238 | 示例返回格式:技术|编程`; 239 | 240 | try { 241 | const response = await fetch('https://generativelanguage.googleapis.com/v1/models/gemini-pro:generateContent?key=' + apiKey, { 242 | method: 'POST', 243 | headers: { 244 | 'Content-Type': 'application/json' 245 | }, 246 | body: JSON.stringify({ 247 | contents: [{ 248 | parts: [{ 249 | text: prompt 250 | }] 251 | }], 252 | generationConfig: { 253 | temperature: 0.3, 254 | maxOutputTokens: 20, 255 | } 256 | }) 257 | }); 258 | 259 | if (!response.ok) { 260 | const error = await response.json(); 261 | throw new Error(error.error?.message || '请求失败'); 262 | } 263 | 264 | const data = await response.json(); 265 | const categories = data.candidates[0].content.parts[0].text.trim().split('|'); 266 | return [ 267 | categories[0].trim(), 268 | categories[1]?.trim() || null 269 | ]; 270 | } catch (error) { 271 | console.error('Gemini API请求错误:', error); 272 | throw new Error('AI分类请求失败'); 273 | } 274 | } 275 | 276 | // 添加用于设置API密钥的方法 277 | chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { 278 | if (request.action === 'setApiKey') { 279 | chrome.storage.sync.set({ apiKey: request.apiKey }) 280 | .then(() => sendResponse({ success: true })) 281 | .catch(error => sendResponse({ error: error.message })); 282 | return true; 283 | } 284 | }); 285 | 286 | // 添加检查页面可访问性的函数 287 | async function checkPageAccessibility(url) { 288 | try { 289 | const response = await fetch(url, { 290 | method: 'HEAD', 291 | mode: 'no-cors', 292 | cache: 'no-cache', 293 | timeout: 5000 // 5秒超时 294 | }); 295 | 296 | // 由于 no-cors 模式的限制,我们只能通过是否抛出异常来判断 297 | return true; 298 | } catch (error) { 299 | // 如果发生错误(超时、网络错误等),认为页面不可访问 300 | return false; 301 | } 302 | } 303 | 304 | // 添加清理重复书签的功能 305 | async function cleanDuplicateBookmarks() { 306 | try { 307 | const bookmarks = await chrome.bookmarks.getTree(); 308 | const allBookmarks = await getAllBookmarks(bookmarks); 309 | 310 | // 用于存储已见过的URL 311 | const urlMap = new Map(); 312 | // 用于存储重复的书签 313 | const duplicates = []; 314 | // 用于存储唯一的书签 315 | const unique = []; 316 | 317 | // 首次遍历,找出所有重复项 318 | for (const bookmark of allBookmarks) { 319 | // 标准化 URL(移除尾部斜杠等) 320 | const normalizedUrl = normalizeUrl(bookmark.url); 321 | 322 | if (!urlMap.has(normalizedUrl)) { 323 | urlMap.set(normalizedUrl, { 324 | original: bookmark, 325 | duplicates: [] 326 | }); 327 | unique.push(bookmark); 328 | } else { 329 | urlMap.get(normalizedUrl).duplicates.push(bookmark); 330 | duplicates.push(bookmark); 331 | } 332 | } 333 | 334 | // 创建重复书签文件夹 335 | const duplicateFolder = await chrome.bookmarks.create({ 336 | parentId: '1', 337 | title: '🔄 重复的书签' 338 | }); 339 | 340 | // 移动重复的书签到重复文件夹 341 | let processed = 0; 342 | const total = duplicates.length; 343 | 344 | for (const duplicate of duplicates) { 345 | try { 346 | // 更新进度 347 | chrome.runtime.sendMessage({ 348 | action: 'updateProgress', 349 | progress: (processed / total) * 100, 350 | status: `正在处理重复书签: ${duplicate.title}`, 351 | processed: processed, 352 | total: total 353 | }); 354 | 355 | // 移动到重复文件夹 356 | await chrome.bookmarks.move(duplicate.id, { 357 | parentId: duplicateFolder.id 358 | }); 359 | 360 | processed++; 361 | } catch (error) { 362 | console.error('移动书签失败:', error); 363 | } 364 | } 365 | 366 | return { 367 | success: true, 368 | message: `已找到 ${duplicates.length} 个重复书签,已移动到"重复的书签"文件夹` 369 | }; 370 | } catch (error) { 371 | throw new Error('清理重复书签失败: ' + error.message); 372 | } 373 | } 374 | 375 | // 获取所有书签 376 | async function getAllBookmarks(nodes) { 377 | let bookmarks = []; 378 | for (const node of nodes) { 379 | if (node.children) { 380 | bookmarks = bookmarks.concat(await getAllBookmarks(node.children)); 381 | } else if (node.url) { 382 | bookmarks.push(node); 383 | } 384 | } 385 | return bookmarks; 386 | } 387 | 388 | // 标准化 URL 389 | function normalizeUrl(url) { 390 | try { 391 | // 创建 URL 对象以标准化 URL 392 | const urlObj = new URL(url); 393 | // 移除末尾的斜杠 394 | let normalized = urlObj.origin + urlObj.pathname.replace(/\/$/, ''); 395 | // 添加查询参数(如果有) 396 | if (urlObj.search) { 397 | normalized += urlObj.search; 398 | } 399 | // 添加哈希(如果有) 400 | if (urlObj.hash) { 401 | normalized += urlObj.hash; 402 | } 403 | return normalized.toLowerCase(); 404 | } catch (e) { 405 | // 如果 URL 无效,返回原始 URL 406 | return url.toLowerCase(); 407 | } 408 | } 409 | 410 | // 修改 manifest.json 中的权限 411 | const manifestUpdates = { 412 | "permissions": [ 413 | "bookmarks", 414 | "storage", 415 | "webRequest" 416 | ], 417 | "host_permissions": [ 418 | "https://generativelanguage.googleapis.com/*", 419 | "" // 需要添加此权限���检查页面可访问性 420 | ] 421 | }; 422 | 423 | // 修改更新进度的方法 424 | function updateProgress(progress, status, processed, total) { 425 | classificationState = { 426 | isRunning: true, 427 | progress, 428 | status, 429 | processed, 430 | total 431 | }; 432 | 433 | // 广播进度更新给所有打开的 popup 434 | chrome.runtime.sendMessage({ 435 | action: 'updateProgress', 436 | progress, 437 | status, 438 | processed, 439 | total 440 | }).catch(() => { 441 | // 忽略错误,这可能是因为没有活动的 popup 442 | }); 443 | } 444 | --------------------------------------------------------------------------------