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.
Files changed (49) hide show
  1. cleanbookmarks-4.0.0/LICENSE +21 -0
  2. cleanbookmarks-4.0.0/PKG-INFO +114 -0
  3. cleanbookmarks-4.0.0/README.md +82 -0
  4. cleanbookmarks-4.0.0/cleanbookmarks/__init__.py +3 -0
  5. cleanbookmarks-4.0.0/cleanbookmarks/cache.py +102 -0
  6. cleanbookmarks-4.0.0/cleanbookmarks/classifier.py +282 -0
  7. cleanbookmarks-4.0.0/cleanbookmarks/cli.py +219 -0
  8. cleanbookmarks-4.0.0/cleanbookmarks/config.py +98 -0
  9. cleanbookmarks-4.0.0/cleanbookmarks/deduplicator.py +374 -0
  10. cleanbookmarks-4.0.0/cleanbookmarks/exporter.py +246 -0
  11. cleanbookmarks-4.0.0/cleanbookmarks/health.py +79 -0
  12. cleanbookmarks-4.0.0/cleanbookmarks/llm.py +306 -0
  13. cleanbookmarks-4.0.0/cleanbookmarks/loader.py +99 -0
  14. cleanbookmarks-4.0.0/cleanbookmarks/models.py +67 -0
  15. cleanbookmarks-4.0.0/cleanbookmarks/organizer.py +120 -0
  16. cleanbookmarks-4.0.0/cleanbookmarks/processor.py +185 -0
  17. cleanbookmarks-4.0.0/cleanbookmarks/resources/__init__.py +0 -0
  18. cleanbookmarks-4.0.0/cleanbookmarks/resources/config.json +663 -0
  19. cleanbookmarks-4.0.0/cleanbookmarks/resources/taxonomy/__init__.py +0 -0
  20. cleanbookmarks-4.0.0/cleanbookmarks/resources/taxonomy/resource_types.yaml +21 -0
  21. cleanbookmarks-4.0.0/cleanbookmarks/resources/taxonomy/subjects.yaml +19 -0
  22. cleanbookmarks-4.0.0/cleanbookmarks/rules.py +290 -0
  23. cleanbookmarks-4.0.0/cleanbookmarks/taxonomy.py +118 -0
  24. cleanbookmarks-4.0.0/cleanbookmarks/text_utils.py +149 -0
  25. cleanbookmarks-4.0.0/cleanbookmarks/url_analyzer.py +227 -0
  26. cleanbookmarks-4.0.0/cleanbookmarks.egg-info/PKG-INFO +114 -0
  27. cleanbookmarks-4.0.0/cleanbookmarks.egg-info/SOURCES.txt +47 -0
  28. cleanbookmarks-4.0.0/cleanbookmarks.egg-info/dependency_links.txt +1 -0
  29. cleanbookmarks-4.0.0/cleanbookmarks.egg-info/entry_points.txt +2 -0
  30. cleanbookmarks-4.0.0/cleanbookmarks.egg-info/requires.txt +10 -0
  31. cleanbookmarks-4.0.0/cleanbookmarks.egg-info/top_level.txt +1 -0
  32. cleanbookmarks-4.0.0/pyproject.toml +66 -0
  33. cleanbookmarks-4.0.0/setup.cfg +4 -0
  34. cleanbookmarks-4.0.0/tests/test_cache.py +48 -0
  35. cleanbookmarks-4.0.0/tests/test_classifier.py +121 -0
  36. cleanbookmarks-4.0.0/tests/test_cli.py +99 -0
  37. cleanbookmarks-4.0.0/tests/test_config.py +66 -0
  38. cleanbookmarks-4.0.0/tests/test_deduplicator.py +159 -0
  39. cleanbookmarks-4.0.0/tests/test_exporter.py +82 -0
  40. cleanbookmarks-4.0.0/tests/test_llm.py +147 -0
  41. cleanbookmarks-4.0.0/tests/test_loader.py +23 -0
  42. cleanbookmarks-4.0.0/tests/test_models.py +37 -0
  43. cleanbookmarks-4.0.0/tests/test_organizer.py +128 -0
  44. cleanbookmarks-4.0.0/tests/test_processor.py +27 -0
  45. cleanbookmarks-4.0.0/tests/test_rules.py +83 -0
  46. cleanbookmarks-4.0.0/tests/test_runtime_paths.py +33 -0
  47. cleanbookmarks-4.0.0/tests/test_taxonomy.py +43 -0
  48. cleanbookmarks-4.0.0/tests/test_text_utils.py +83 -0
  49. 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
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue)](#)
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
+ ![运行示例](https://raw.githubusercontent.com/build-workbench/bookmarks-cleaner/main/docs/screenshot.png)
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
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue)](#)
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
+ ![运行示例](https://raw.githubusercontent.com/build-workbench/bookmarks-cleaner/main/docs/screenshot.png)
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,3 @@
1
+ """CleanBookmarks - 离线书签清理与分类 CLI"""
2
+
3
+ __version__ = "4.0.0"
@@ -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
+ }