cleanbookmarks 4.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cleanbookmarks-4.0.0/LICENSE +21 -0
- cleanbookmarks-4.0.0/PKG-INFO +114 -0
- cleanbookmarks-4.0.0/README.md +82 -0
- cleanbookmarks-4.0.0/cleanbookmarks/__init__.py +3 -0
- cleanbookmarks-4.0.0/cleanbookmarks/cache.py +102 -0
- cleanbookmarks-4.0.0/cleanbookmarks/classifier.py +282 -0
- cleanbookmarks-4.0.0/cleanbookmarks/cli.py +219 -0
- cleanbookmarks-4.0.0/cleanbookmarks/config.py +98 -0
- cleanbookmarks-4.0.0/cleanbookmarks/deduplicator.py +374 -0
- cleanbookmarks-4.0.0/cleanbookmarks/exporter.py +246 -0
- cleanbookmarks-4.0.0/cleanbookmarks/health.py +79 -0
- cleanbookmarks-4.0.0/cleanbookmarks/llm.py +306 -0
- cleanbookmarks-4.0.0/cleanbookmarks/loader.py +99 -0
- cleanbookmarks-4.0.0/cleanbookmarks/models.py +67 -0
- cleanbookmarks-4.0.0/cleanbookmarks/organizer.py +120 -0
- cleanbookmarks-4.0.0/cleanbookmarks/processor.py +185 -0
- cleanbookmarks-4.0.0/cleanbookmarks/resources/__init__.py +0 -0
- cleanbookmarks-4.0.0/cleanbookmarks/resources/config.json +663 -0
- cleanbookmarks-4.0.0/cleanbookmarks/resources/taxonomy/__init__.py +0 -0
- cleanbookmarks-4.0.0/cleanbookmarks/resources/taxonomy/resource_types.yaml +21 -0
- cleanbookmarks-4.0.0/cleanbookmarks/resources/taxonomy/subjects.yaml +19 -0
- cleanbookmarks-4.0.0/cleanbookmarks/rules.py +290 -0
- cleanbookmarks-4.0.0/cleanbookmarks/taxonomy.py +118 -0
- cleanbookmarks-4.0.0/cleanbookmarks/text_utils.py +149 -0
- cleanbookmarks-4.0.0/cleanbookmarks/url_analyzer.py +227 -0
- cleanbookmarks-4.0.0/cleanbookmarks.egg-info/PKG-INFO +114 -0
- cleanbookmarks-4.0.0/cleanbookmarks.egg-info/SOURCES.txt +47 -0
- cleanbookmarks-4.0.0/cleanbookmarks.egg-info/dependency_links.txt +1 -0
- cleanbookmarks-4.0.0/cleanbookmarks.egg-info/entry_points.txt +2 -0
- cleanbookmarks-4.0.0/cleanbookmarks.egg-info/requires.txt +10 -0
- cleanbookmarks-4.0.0/cleanbookmarks.egg-info/top_level.txt +1 -0
- cleanbookmarks-4.0.0/pyproject.toml +66 -0
- cleanbookmarks-4.0.0/setup.cfg +4 -0
- cleanbookmarks-4.0.0/tests/test_cache.py +48 -0
- cleanbookmarks-4.0.0/tests/test_classifier.py +121 -0
- cleanbookmarks-4.0.0/tests/test_cli.py +99 -0
- cleanbookmarks-4.0.0/tests/test_config.py +66 -0
- cleanbookmarks-4.0.0/tests/test_deduplicator.py +159 -0
- cleanbookmarks-4.0.0/tests/test_exporter.py +82 -0
- cleanbookmarks-4.0.0/tests/test_llm.py +147 -0
- cleanbookmarks-4.0.0/tests/test_loader.py +23 -0
- cleanbookmarks-4.0.0/tests/test_models.py +37 -0
- cleanbookmarks-4.0.0/tests/test_organizer.py +128 -0
- cleanbookmarks-4.0.0/tests/test_processor.py +27 -0
- cleanbookmarks-4.0.0/tests/test_rules.py +83 -0
- cleanbookmarks-4.0.0/tests/test_runtime_paths.py +33 -0
- cleanbookmarks-4.0.0/tests/test_taxonomy.py +43 -0
- cleanbookmarks-4.0.0/tests/test_text_utils.py +83 -0
- cleanbookmarks-4.0.0/tests/test_url_analyzer.py +43 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024-2026 holtwood
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cleanbookmarks
|
|
3
|
+
Version: 4.0.0
|
|
4
|
+
Summary: 离线书签清理与分类:规则优先 · LLM 可选
|
|
5
|
+
Author: holtwood
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Repository, https://github.com/build-workbench/bookmarks-cleaner
|
|
8
|
+
Project-URL: Issues, https://github.com/build-workbench/bookmarks-cleaner/issues
|
|
9
|
+
Keywords: bookmarks,classification,clean,cli
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Browsers
|
|
18
|
+
Classifier: Topic :: Utilities
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Requires-Dist: beautifulsoup4~=4.12.3
|
|
24
|
+
Requires-Dist: lxml~=5.2.2
|
|
25
|
+
Requires-Dist: pyyaml~=6.0.2
|
|
26
|
+
Requires-Dist: chardet~=5.2.0
|
|
27
|
+
Provides-Extra: llm
|
|
28
|
+
Requires-Dist: requests~=2.32.3; extra == "llm"
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: pytest~=8.2.2; extra == "dev"
|
|
31
|
+
Dynamic: license-file
|
|
32
|
+
|
|
33
|
+
# CleanBookmarks
|
|
34
|
+
|
|
35
|
+
[](#)
|
|
36
|
+
|
|
37
|
+
**中文** | [English](https://github.com/build-workbench/bookmarks-cleaner/blob/main/README.en.md)
|
|
38
|
+
|
|
39
|
+
书签太多太乱?一条命令帮你**去重、自动分类、整理导出**,全程本地运行。
|
|
40
|
+
|
|
41
|
+
## 安装
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pipx install cleanbookmarks
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## 快速上手
|
|
48
|
+
|
|
49
|
+
1. **在浏览器导出书签 HTML**
|
|
50
|
+
- Chrome / Edge:`书签管理器 → ⋮ → 导出书签`
|
|
51
|
+
- Firefox:`书签 → 管理书签 → 导入和备份 → 导出书签到 HTML`
|
|
52
|
+
|
|
53
|
+
2. **运行分类**
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
cleanbookmarks -i bookmarks.html -o output/
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
3. **导入回浏览器**:把 `output/` 下生成的 `*.html` 用浏览器的「导入书签」导回即可。同目录还有 `*.json`(结构化数据)和 `*.markdown`(分类报告)。
|
|
60
|
+
|
|
61
|
+

|
|
62
|
+
|
|
63
|
+
没有书签文件?下载仓库自带的示例试跑:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
# 源码运行:直接使用仓库内示例
|
|
67
|
+
cleanbookmarks -i examples/sample_bookmarks.html -o output/
|
|
68
|
+
# pipx 安装:先下载示例(或任意浏览器导出的书签 HTML)
|
|
69
|
+
curl -O https://raw.githubusercontent.com/build-workbench/bookmarks-cleaner/main/examples/sample_bookmarks.html
|
|
70
|
+
cleanbookmarks -i sample_bookmarks.html -o output/
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## 常用选项
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
cleanbookmarks -i a.html b.html -o output/ --workers 8 # 多个文件 + 并行
|
|
77
|
+
cleanbookmarks -i "bookmarks/*.html" -o output/ # 支持 glob
|
|
78
|
+
cleanbookmarks -i bookmarks.html -c config.local.json # 自定义配置
|
|
79
|
+
cleanbookmarks -i bookmarks.html --limit 20 # 先小批量试跑
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
默认配置开箱即用。想调整分类规则、置信度阈值、标题清理等,把默认配置复制为本地文件再修改,用 `-c` 指定:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
# 源码运行:默认配置在 cleanbookmarks/resources/config.json
|
|
86
|
+
cp cleanbookmarks/resources/config.json config.local.json
|
|
87
|
+
# pipx 安装:先找到安装包内配置(pipx runpip cleanbookmarks show cleanbookmarks 可查路径)
|
|
88
|
+
cleanbookmarks -i bookmarks.html -c config.local.json
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
完整参数见 `cleanbookmarks --help`。
|
|
92
|
+
|
|
93
|
+
## LLM 分类(可选)
|
|
94
|
+
|
|
95
|
+
默认全离线。若想让规则未命中的书签由 AI 兜底分类:
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
pip install "cleanbookmarks[llm]"
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
然后在 `config.local.json` 中开启:
|
|
102
|
+
|
|
103
|
+
```json
|
|
104
|
+
{ "llm": { "enable": true, "base_url": "https://api.openai.com", "model": "gpt-4o-mini", "api_key_env": "OPENAI_API_KEY" } }
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
设置 `OPENAI_API_KEY` 环境变量后重新运行即可。
|
|
108
|
+
|
|
109
|
+
## 常见问题
|
|
110
|
+
|
|
111
|
+
- **会误删吗?** 只在相同域名内判重,4 种策略(精确 URL、规范化 URL、标题+URL 相似度、标题相似度)任一命中才算重复,阈值保守。
|
|
112
|
+
- **隐私?** 默认不发起任何网络请求;仅开启 LLM 后,书签标题/URL 才会发送给你配置的 API。
|
|
113
|
+
- **支持中文书签吗?** 支持,分类词表含中英变体。
|
|
114
|
+
- **导出文件怎么用?** Chrome / Edge / Firefox 均支持导入书签 HTML。
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# CleanBookmarks
|
|
2
|
+
|
|
3
|
+
[](#)
|
|
4
|
+
|
|
5
|
+
**中文** | [English](https://github.com/build-workbench/bookmarks-cleaner/blob/main/README.en.md)
|
|
6
|
+
|
|
7
|
+
书签太多太乱?一条命令帮你**去重、自动分类、整理导出**,全程本地运行。
|
|
8
|
+
|
|
9
|
+
## 安装
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pipx install cleanbookmarks
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## 快速上手
|
|
16
|
+
|
|
17
|
+
1. **在浏览器导出书签 HTML**
|
|
18
|
+
- Chrome / Edge:`书签管理器 → ⋮ → 导出书签`
|
|
19
|
+
- Firefox:`书签 → 管理书签 → 导入和备份 → 导出书签到 HTML`
|
|
20
|
+
|
|
21
|
+
2. **运行分类**
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
cleanbookmarks -i bookmarks.html -o output/
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
3. **导入回浏览器**:把 `output/` 下生成的 `*.html` 用浏览器的「导入书签」导回即可。同目录还有 `*.json`(结构化数据)和 `*.markdown`(分类报告)。
|
|
28
|
+
|
|
29
|
+

|
|
30
|
+
|
|
31
|
+
没有书签文件?下载仓库自带的示例试跑:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
# 源码运行:直接使用仓库内示例
|
|
35
|
+
cleanbookmarks -i examples/sample_bookmarks.html -o output/
|
|
36
|
+
# pipx 安装:先下载示例(或任意浏览器导出的书签 HTML)
|
|
37
|
+
curl -O https://raw.githubusercontent.com/build-workbench/bookmarks-cleaner/main/examples/sample_bookmarks.html
|
|
38
|
+
cleanbookmarks -i sample_bookmarks.html -o output/
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## 常用选项
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
cleanbookmarks -i a.html b.html -o output/ --workers 8 # 多个文件 + 并行
|
|
45
|
+
cleanbookmarks -i "bookmarks/*.html" -o output/ # 支持 glob
|
|
46
|
+
cleanbookmarks -i bookmarks.html -c config.local.json # 自定义配置
|
|
47
|
+
cleanbookmarks -i bookmarks.html --limit 20 # 先小批量试跑
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
默认配置开箱即用。想调整分类规则、置信度阈值、标题清理等,把默认配置复制为本地文件再修改,用 `-c` 指定:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
# 源码运行:默认配置在 cleanbookmarks/resources/config.json
|
|
54
|
+
cp cleanbookmarks/resources/config.json config.local.json
|
|
55
|
+
# pipx 安装:先找到安装包内配置(pipx runpip cleanbookmarks show cleanbookmarks 可查路径)
|
|
56
|
+
cleanbookmarks -i bookmarks.html -c config.local.json
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
完整参数见 `cleanbookmarks --help`。
|
|
60
|
+
|
|
61
|
+
## LLM 分类(可选)
|
|
62
|
+
|
|
63
|
+
默认全离线。若想让规则未命中的书签由 AI 兜底分类:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
pip install "cleanbookmarks[llm]"
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
然后在 `config.local.json` 中开启:
|
|
70
|
+
|
|
71
|
+
```json
|
|
72
|
+
{ "llm": { "enable": true, "base_url": "https://api.openai.com", "model": "gpt-4o-mini", "api_key_env": "OPENAI_API_KEY" } }
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
设置 `OPENAI_API_KEY` 环境变量后重新运行即可。
|
|
76
|
+
|
|
77
|
+
## 常见问题
|
|
78
|
+
|
|
79
|
+
- **会误删吗?** 只在相同域名内判重,4 种策略(精确 URL、规范化 URL、标题+URL 相似度、标题相似度)任一命中才算重复,阈值保守。
|
|
80
|
+
- **隐私?** 默认不发起任何网络请求;仅开启 LLM 后,书签标题/URL 才会发送给你配置的 API。
|
|
81
|
+
- **支持中文书签吗?** 支持,分类词表含中英变体。
|
|
82
|
+
- **导出文件怎么用?** Chrome / Edge / Firefox 均支持导入书签 HTML。
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""线程安全的 LRU 缓存"""
|
|
2
|
+
|
|
3
|
+
from collections import OrderedDict
|
|
4
|
+
from typing import Callable, Generic, Hashable, Optional, TypeVar
|
|
5
|
+
import threading
|
|
6
|
+
|
|
7
|
+
T = TypeVar("T")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CacheManager(Generic[T]):
|
|
11
|
+
"""LRU 缓存管理器"""
|
|
12
|
+
|
|
13
|
+
def __init__(self, max_size: int = 1000, strategy: str = "lru", thread_safe: bool = True):
|
|
14
|
+
if strategy != "lru":
|
|
15
|
+
raise ValueError(f"不支持的淘汰策略: {strategy},目前只支持 'lru'")
|
|
16
|
+
self.max_size = max_size
|
|
17
|
+
self.strategy = strategy
|
|
18
|
+
self._cache: OrderedDict[Hashable, T] = OrderedDict()
|
|
19
|
+
self._lock = threading.Lock() if thread_safe else None
|
|
20
|
+
self._stats = {"hits": 0, "misses": 0, "evictions": 0, "put_count": 0, "get_count": 0}
|
|
21
|
+
|
|
22
|
+
def get(self, key: Hashable) -> Optional[T]:
|
|
23
|
+
if self._lock:
|
|
24
|
+
with self._lock:
|
|
25
|
+
return self._get_unsafe(key)
|
|
26
|
+
return self._get_unsafe(key)
|
|
27
|
+
|
|
28
|
+
def _get_unsafe(self, key: Hashable) -> Optional[T]:
|
|
29
|
+
self._stats["get_count"] += 1
|
|
30
|
+
if key in self._cache:
|
|
31
|
+
self._cache.move_to_end(key)
|
|
32
|
+
self._stats["hits"] += 1
|
|
33
|
+
return self._cache[key]
|
|
34
|
+
self._stats["misses"] += 1
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
def get_or_compute(self, key: Hashable, factory: Callable[[], T]) -> T:
|
|
38
|
+
if self._lock:
|
|
39
|
+
with self._lock:
|
|
40
|
+
value = self._get_unsafe(key)
|
|
41
|
+
if value is not None:
|
|
42
|
+
return value
|
|
43
|
+
value = factory()
|
|
44
|
+
self._put_unsafe(key, value)
|
|
45
|
+
return value
|
|
46
|
+
value = self._get_unsafe(key)
|
|
47
|
+
if value is not None:
|
|
48
|
+
return value
|
|
49
|
+
value = factory()
|
|
50
|
+
self._put_unsafe(key, value)
|
|
51
|
+
return value
|
|
52
|
+
|
|
53
|
+
def put(self, key: Hashable, value: T) -> None:
|
|
54
|
+
if self._lock:
|
|
55
|
+
with self._lock:
|
|
56
|
+
self._put_unsafe(key, value)
|
|
57
|
+
else:
|
|
58
|
+
self._put_unsafe(key, value)
|
|
59
|
+
|
|
60
|
+
def _put_unsafe(self, key: Hashable, value: T) -> None:
|
|
61
|
+
self._stats["put_count"] += 1
|
|
62
|
+
if key in self._cache:
|
|
63
|
+
self._cache.move_to_end(key)
|
|
64
|
+
self._cache[key] = value
|
|
65
|
+
else:
|
|
66
|
+
self._cache[key] = value
|
|
67
|
+
if len(self._cache) > self.max_size:
|
|
68
|
+
self._cache.popitem(last=False)
|
|
69
|
+
self._stats["evictions"] += 1
|
|
70
|
+
|
|
71
|
+
def invalidate(self, key: Hashable) -> bool:
|
|
72
|
+
if self._lock:
|
|
73
|
+
with self._lock:
|
|
74
|
+
if key in self._cache:
|
|
75
|
+
del self._cache[key]
|
|
76
|
+
return True
|
|
77
|
+
return False
|
|
78
|
+
if key in self._cache:
|
|
79
|
+
del self._cache[key]
|
|
80
|
+
return True
|
|
81
|
+
return False
|
|
82
|
+
|
|
83
|
+
def clear(self) -> None:
|
|
84
|
+
if self._lock:
|
|
85
|
+
with self._lock:
|
|
86
|
+
self._cache.clear()
|
|
87
|
+
else:
|
|
88
|
+
self._cache.clear()
|
|
89
|
+
|
|
90
|
+
def get_stats(self) -> dict:
|
|
91
|
+
total = self._stats["hits"] + self._stats["misses"]
|
|
92
|
+
hit_rate = self._stats["hits"] / total if total > 0 else 0.0
|
|
93
|
+
return {**self._stats, "size": len(self._cache), "max_size": self.max_size, "hit_rate": hit_rate}
|
|
94
|
+
|
|
95
|
+
def __len__(self) -> int:
|
|
96
|
+
return len(self._cache)
|
|
97
|
+
|
|
98
|
+
def __contains__(self, key: Hashable) -> bool:
|
|
99
|
+
if self._lock:
|
|
100
|
+
with self._lock:
|
|
101
|
+
return key in self._cache
|
|
102
|
+
return key in self._cache
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
"""书签分类器 - 规则优先 + LLM(可选) 两级级联"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import logging
|
|
7
|
+
import threading
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from typing import Dict, Optional
|
|
10
|
+
|
|
11
|
+
from cleanbookmarks.cache import CacheManager
|
|
12
|
+
from cleanbookmarks.config import load_json_config, resolve_config_path
|
|
13
|
+
from cleanbookmarks.models import BookmarkFeatures, ClassificationResult
|
|
14
|
+
from cleanbookmarks.rules import RuleEngine
|
|
15
|
+
from cleanbookmarks.text_utils import (
|
|
16
|
+
detect_language,
|
|
17
|
+
is_video_url,
|
|
18
|
+
normalize_category_config,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
from cleanbookmarks.llm import LLMClassifier
|
|
23
|
+
except ImportError:
|
|
24
|
+
LLMClassifier = None # type: ignore[assignment,misc]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class BookmarkClassifier:
|
|
28
|
+
"""书签分类器
|
|
29
|
+
|
|
30
|
+
两级级联:规则引擎给出确定性主分类,LLM(可选)在规则未命中时兜底、命中时补充子分类。
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
config_path: Optional[str] = None,
|
|
36
|
+
config: Optional[Dict] = None,
|
|
37
|
+
):
|
|
38
|
+
resolved_path, _ = resolve_config_path(config_path)
|
|
39
|
+
self.config_path = str(resolved_path)
|
|
40
|
+
self.logger = logging.getLogger(__name__)
|
|
41
|
+
|
|
42
|
+
if isinstance(config, dict):
|
|
43
|
+
normalized = normalize_category_config(config)
|
|
44
|
+
if not isinstance(normalized.get("category_rules"), dict) or not normalized.get("category_rules"):
|
|
45
|
+
raise ValueError("传入的 config 缺少有效的 category_rules")
|
|
46
|
+
self._config = normalized
|
|
47
|
+
else:
|
|
48
|
+
self._config = None
|
|
49
|
+
self._rule_engine: Optional[RuleEngine] = None
|
|
50
|
+
self._llm_classifier = None
|
|
51
|
+
|
|
52
|
+
# 缓存大小来自配置(默认 10000),分类结果缓存减半以省内存
|
|
53
|
+
cache_size = 10000
|
|
54
|
+
if isinstance(config, dict):
|
|
55
|
+
try:
|
|
56
|
+
cache_size = int((config.get("ai_settings") or {}).get("cache_size", 10000))
|
|
57
|
+
except (TypeError, ValueError):
|
|
58
|
+
cache_size = 10000
|
|
59
|
+
self.feature_cache: CacheManager[BookmarkFeatures] = CacheManager(max_size=cache_size, strategy="lru")
|
|
60
|
+
self.classification_cache: CacheManager[ClassificationResult] = CacheManager(max_size=max(cache_size // 2, 100), strategy="lru")
|
|
61
|
+
|
|
62
|
+
# stats 由多线程(_classify_batch)并发更新,需要锁保护
|
|
63
|
+
self._stats_lock = threading.Lock()
|
|
64
|
+
|
|
65
|
+
self.stats = {
|
|
66
|
+
"total_classified": 0,
|
|
67
|
+
"rule_engine": 0,
|
|
68
|
+
"fallback": 0,
|
|
69
|
+
"cache_hits": 0,
|
|
70
|
+
"average_confidence": 0.0,
|
|
71
|
+
"llm": 0,
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def config(self) -> Dict:
|
|
76
|
+
if self._config is None:
|
|
77
|
+
self._config = self._load_config()
|
|
78
|
+
return self._config
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def rule_engine(self) -> RuleEngine:
|
|
82
|
+
if self._rule_engine is None:
|
|
83
|
+
self._rule_engine = RuleEngine(self.config)
|
|
84
|
+
return self._rule_engine
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def llm_classifier(self):
|
|
88
|
+
if self._llm_classifier is None and LLMClassifier is not None:
|
|
89
|
+
try:
|
|
90
|
+
self._llm_classifier = LLMClassifier(self.config_path)
|
|
91
|
+
except Exception as e:
|
|
92
|
+
self.logger.warning(f"LLM 分类器初始化失败: {e}")
|
|
93
|
+
return self._llm_classifier
|
|
94
|
+
|
|
95
|
+
def _load_config(self) -> Dict:
|
|
96
|
+
config, _, _ = load_json_config(self.config_path)
|
|
97
|
+
normalized = normalize_category_config(config)
|
|
98
|
+
if not isinstance(normalized.get("category_rules"), dict) or not normalized.get("category_rules"):
|
|
99
|
+
raise ValueError(f"配置缺少有效的 category_rules: {self.config_path}")
|
|
100
|
+
return normalized
|
|
101
|
+
|
|
102
|
+
def extract_features(self, url: str, title: str) -> BookmarkFeatures:
|
|
103
|
+
cache_key = f"{url}::{title}"
|
|
104
|
+
|
|
105
|
+
def _extract():
|
|
106
|
+
content_type = self._detect_content_type(url, title)
|
|
107
|
+
language = detect_language(title)
|
|
108
|
+
return BookmarkFeatures.from_url_title(url, title, content_type, language)
|
|
109
|
+
|
|
110
|
+
return self.feature_cache.get_or_compute(cache_key, _extract)
|
|
111
|
+
|
|
112
|
+
def classify(self, url: str, title: str) -> ClassificationResult:
|
|
113
|
+
start_time = datetime.now()
|
|
114
|
+
cache_key = hashlib.md5(f"{url}::{title}".encode()).hexdigest()
|
|
115
|
+
cached = self.classification_cache.get(cache_key)
|
|
116
|
+
if cached is not None:
|
|
117
|
+
with self._stats_lock:
|
|
118
|
+
self.stats["cache_hits"] += 1
|
|
119
|
+
cached.processing_time = (datetime.now() - start_time).total_seconds()
|
|
120
|
+
return cached
|
|
121
|
+
|
|
122
|
+
features = self.extract_features(url, title)
|
|
123
|
+
|
|
124
|
+
# 1) 规则引擎 - 确定性优先
|
|
125
|
+
rule_result = self.rule_engine.classify(features)
|
|
126
|
+
|
|
127
|
+
# 2) LLM(可选)- 规则未命中时兜底,命中时补充子分类
|
|
128
|
+
llm_result = None
|
|
129
|
+
if self.llm_classifier and self.llm_classifier.enabled():
|
|
130
|
+
try:
|
|
131
|
+
llm_result = self.llm_classifier.classify(
|
|
132
|
+
url, title,
|
|
133
|
+
context={"domain": features.domain, "content_type": features.content_type, "language": features.language},
|
|
134
|
+
)
|
|
135
|
+
except Exception as e:
|
|
136
|
+
self.logger.warning(f"LLM 分类调用失败: {e}")
|
|
137
|
+
|
|
138
|
+
confidence_threshold = self.config.get("ai_settings", {}).get("confidence_threshold", 0.7)
|
|
139
|
+
final_result = self._cascade_fuse(
|
|
140
|
+
rule_result=rule_result,
|
|
141
|
+
llm_result=llm_result,
|
|
142
|
+
features=features,
|
|
143
|
+
confidence_threshold=float(confidence_threshold),
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
with self._stats_lock:
|
|
147
|
+
if "rule_engine" in final_result.method:
|
|
148
|
+
self.stats["rule_engine"] += 1
|
|
149
|
+
if "llm" in final_result.method:
|
|
150
|
+
self.stats["llm"] += 1
|
|
151
|
+
if final_result.method == "fallback":
|
|
152
|
+
self.stats["fallback"] += 1
|
|
153
|
+
|
|
154
|
+
final_result.processing_time = (datetime.now() - start_time).total_seconds()
|
|
155
|
+
self._update_stats(final_result)
|
|
156
|
+
self.classification_cache.put(cache_key, final_result)
|
|
157
|
+
return final_result
|
|
158
|
+
|
|
159
|
+
def _cascade_fuse(
|
|
160
|
+
self,
|
|
161
|
+
rule_result,
|
|
162
|
+
llm_result,
|
|
163
|
+
features: BookmarkFeatures,
|
|
164
|
+
confidence_threshold: float,
|
|
165
|
+
) -> ClassificationResult:
|
|
166
|
+
"""级联决策:规则命中即采用规则主分类,LLM 补子分类/facets;规则未命中才走 LLM"""
|
|
167
|
+
if rule_result is not None:
|
|
168
|
+
result = self._to_classification_result(rule_result)
|
|
169
|
+
# 1) 配置的 category_hierarchy 标题匹配
|
|
170
|
+
if result.subcategory is None:
|
|
171
|
+
result.subcategory = self._determine_subcategory(result.category, features)
|
|
172
|
+
# 2) LLM 补充子分类/facets/理由
|
|
173
|
+
if llm_result is not None:
|
|
174
|
+
llm = self._to_classification_result(llm_result)
|
|
175
|
+
if result.subcategory is None and llm.subcategory:
|
|
176
|
+
result.subcategory = llm.subcategory
|
|
177
|
+
# LLM 输出不可控,facets 可能是非 dict(如列表/字符串),防御性处理
|
|
178
|
+
llm_facets = llm.facets if isinstance(llm.facets, dict) else {}
|
|
179
|
+
for k, v in llm_facets.items():
|
|
180
|
+
if v and k not in (result.facets or {}):
|
|
181
|
+
result.facets[k] = v
|
|
182
|
+
result.reasoning.extend(llm.reasoning or [])
|
|
183
|
+
elif llm_result is not None:
|
|
184
|
+
result = self._to_classification_result(llm_result)
|
|
185
|
+
else:
|
|
186
|
+
return ClassificationResult(
|
|
187
|
+
category="未分类", confidence=0.0,
|
|
188
|
+
reasoning=["没有匹配到任何分类规则"], method="fallback",
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
if result.category != "未分类" and result.confidence < confidence_threshold:
|
|
192
|
+
result.reasoning.append(
|
|
193
|
+
f"最终置信度 {result.confidence:.2f} 低于阈值 {confidence_threshold:.2f},标记为未分类"
|
|
194
|
+
)
|
|
195
|
+
return ClassificationResult(
|
|
196
|
+
category="未分类", subcategory=None,
|
|
197
|
+
confidence=result.confidence,
|
|
198
|
+
reasoning=result.reasoning,
|
|
199
|
+
alternatives=result.alternatives[:3],
|
|
200
|
+
method=result.method, facets=result.facets,
|
|
201
|
+
)
|
|
202
|
+
return result
|
|
203
|
+
|
|
204
|
+
@staticmethod
|
|
205
|
+
def _to_classification_result(raw) -> ClassificationResult:
|
|
206
|
+
if isinstance(raw, ClassificationResult):
|
|
207
|
+
return raw
|
|
208
|
+
if isinstance(raw, dict):
|
|
209
|
+
# LLM 输出不可控,facets 可能是非 dict(如列表/字符串),统一防御
|
|
210
|
+
facets = raw.get("facets", {})
|
|
211
|
+
if not isinstance(facets, dict):
|
|
212
|
+
facets = {}
|
|
213
|
+
return ClassificationResult(
|
|
214
|
+
category=raw.get("category", "未分类"),
|
|
215
|
+
confidence=float(raw.get("confidence", 0.0)),
|
|
216
|
+
subcategory=raw.get("subcategory"),
|
|
217
|
+
reasoning=raw.get("reasoning", []),
|
|
218
|
+
alternatives=raw.get("alternatives", []),
|
|
219
|
+
processing_time=float(raw.get("processing_time", 0.0)),
|
|
220
|
+
method=raw.get("method", "unknown"),
|
|
221
|
+
facets=facets,
|
|
222
|
+
)
|
|
223
|
+
raise TypeError(f"Unexpected classification result type: {type(raw)}")
|
|
224
|
+
|
|
225
|
+
def _determine_subcategory(self, category: str, features: BookmarkFeatures) -> Optional[str]:
|
|
226
|
+
hierarchy = self.config.get("category_hierarchy", {})
|
|
227
|
+
if not isinstance(hierarchy, dict):
|
|
228
|
+
return None
|
|
229
|
+
# 规则引擎的 category 可能是 '主类/子类' 格式,按主类查 hierarchy
|
|
230
|
+
main = category.split("/", 1)[0].strip()
|
|
231
|
+
subs = hierarchy.get(category) or hierarchy.get(main)
|
|
232
|
+
if not isinstance(subs, list):
|
|
233
|
+
return None
|
|
234
|
+
title_lower = features.title.lower()
|
|
235
|
+
for sub in subs:
|
|
236
|
+
if str(sub).lower() in title_lower:
|
|
237
|
+
return sub
|
|
238
|
+
return None
|
|
239
|
+
|
|
240
|
+
def _detect_content_type(self, url: str, title: str) -> str:
|
|
241
|
+
url_lower = url.lower()
|
|
242
|
+
title_lower = title.lower()
|
|
243
|
+
if is_video_url(url):
|
|
244
|
+
return "video"
|
|
245
|
+
if any(d in url_lower for d in ["github.com", "gitlab.com"]):
|
|
246
|
+
return "code_repository"
|
|
247
|
+
if any(p in url_lower for p in ["docs.", "documentation", "wiki"]):
|
|
248
|
+
return "documentation"
|
|
249
|
+
if any(d in url_lower for d in ["arxiv.org", "acm.org", "ieee.org"]):
|
|
250
|
+
return "academic_paper"
|
|
251
|
+
if any(k in title_lower for k in ["news", "新闻", "breaking"]):
|
|
252
|
+
return "news"
|
|
253
|
+
if any(k in title_lower for k in ["tool", "工具", "online", "generator"]):
|
|
254
|
+
return "online_tool"
|
|
255
|
+
return "webpage"
|
|
256
|
+
|
|
257
|
+
def _update_stats(self, result: ClassificationResult):
|
|
258
|
+
with self._stats_lock:
|
|
259
|
+
self.stats["total_classified"] += 1
|
|
260
|
+
total = self.stats["total_classified"]
|
|
261
|
+
old_avg = self.stats["average_confidence"]
|
|
262
|
+
self.stats["average_confidence"] = (old_avg * (total - 1) + result.confidence) / total
|
|
263
|
+
|
|
264
|
+
def get_statistics(self) -> Dict:
|
|
265
|
+
total_predictions = (
|
|
266
|
+
self.stats["rule_engine"] + self.stats["llm"] + self.stats["fallback"]
|
|
267
|
+
)
|
|
268
|
+
# total_classified 只在缓存未命中时 +1,分母 = 命中 + 未命中 = 总尝试数
|
|
269
|
+
total_attempts = self.stats["cache_hits"] + self.stats["total_classified"]
|
|
270
|
+
return {
|
|
271
|
+
"total_classified": self.stats["total_classified"],
|
|
272
|
+
"cache_hits": self.stats["cache_hits"],
|
|
273
|
+
"cache_hit_rate": self.stats["cache_hits"] / max(total_attempts, 1),
|
|
274
|
+
"average_confidence": self.stats["average_confidence"],
|
|
275
|
+
"classification_methods": {
|
|
276
|
+
"rule_engine": self.stats["rule_engine"],
|
|
277
|
+
"llm": self.stats["llm"],
|
|
278
|
+
"unclassified (fallback)": self.stats["fallback"],
|
|
279
|
+
"total": total_predictions,
|
|
280
|
+
},
|
|
281
|
+
"llm_enabled": self.llm_classifier is not None and self.llm_classifier.enabled(),
|
|
282
|
+
}
|