cleanbookmarks 4.0.0__py3-none-any.whl

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.
@@ -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
+ }
cleanbookmarks/cli.py ADDED
@@ -0,0 +1,219 @@
1
+ """CleanBookmarks CLI 入口"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import glob
7
+ import logging
8
+ import os
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ from cleanbookmarks import __version__
13
+ from cleanbookmarks.config import ResourceResolutionError, resolve_config_path
14
+ from cleanbookmarks.processor import BookmarkProcessor
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ def setup_logging(log_level: str = "INFO", use_file: bool = False):
20
+ handlers: list[logging.Handler] = [logging.StreamHandler()]
21
+ if use_file:
22
+ os.makedirs("logs", exist_ok=True)
23
+ handlers.insert(0, logging.FileHandler("logs/cleanbookmarks.log", encoding="utf-8"))
24
+ logging.basicConfig(
25
+ level=getattr(logging, log_level.upper()),
26
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
27
+ handlers=handlers,
28
+ force=True,
29
+ )
30
+
31
+
32
+ def main():
33
+ parser = argparse.ArgumentParser(
34
+ description=f"CleanBookmarks v{__version__} - 书签清理与分类",
35
+ formatter_class=argparse.RawDescriptionHelpFormatter,
36
+ epilog="""
37
+ 示例:
38
+ %(prog)s -i bookmarks.html -o output/
39
+ %(prog)s -i examples/demo_bookmarks.html
40
+ %(prog)s --health-check
41
+ """,
42
+ )
43
+ parser.add_argument("-V", "--version", action="version", version=f"%(prog)s {__version__}")
44
+ parser.add_argument("-i", "--input", nargs="+", help="输入的HTML书签文件")
45
+ parser.add_argument("-o", "--output", default="output", help="输出目录")
46
+ parser.add_argument("-c", "--config", default=None, help="配置文件路径")
47
+ parser.add_argument("--health-check", action="store_true", help="运行健康检查")
48
+ parser.add_argument("--workers", type=int, default=4, help="并行处理线程数")
49
+ parser.add_argument("--threshold", type=float, default=None, help="分类置信度阈值(默认使用配置文件中的值)")
50
+ parser.add_argument("--log-level", default="INFO", choices=["DEBUG", "INFO", "WARNING", "ERROR"])
51
+ parser.add_argument("--limit", type=int, default=0, help="限制处理的书签数量(调试用)")
52
+ parser.add_argument("--eval", metavar="FILE", help="评估分类效果,传入标注数据 JSON 文件")
53
+
54
+ args = parser.parse_args()
55
+
56
+ try:
57
+ config_path, _ = resolve_config_path(args.config)
58
+ use_file_logging = bool(args.input)
59
+ setup_logging(args.log_level, use_file=use_file_logging)
60
+
61
+ if args.health_check:
62
+ from cleanbookmarks.health import run_health_check
63
+ ok = run_health_check(str(config_path))
64
+ sys.exit(0 if ok else 1)
65
+
66
+ if args.input:
67
+ input_files = []
68
+ for pattern in args.input:
69
+ if "*" in pattern or "?" in pattern:
70
+ expanded = glob.glob(pattern)
71
+ if expanded:
72
+ input_files.extend(expanded)
73
+ else:
74
+ logger.warning(f"没有找到匹配模式的文件: {pattern}")
75
+ else:
76
+ if Path(pattern).is_file():
77
+ input_files.append(pattern)
78
+ else:
79
+ logger.warning(f"文件不存在: {pattern}")
80
+
81
+ if not input_files:
82
+ logger.error("没有找到有效的输入文件")
83
+ sys.exit(1)
84
+
85
+ logger.info(f"将处理 {len(input_files)} 个文件: {input_files}")
86
+
87
+ processor = BookmarkProcessor(
88
+ config_path=str(config_path),
89
+ max_workers=args.workers,
90
+ confidence_threshold=args.threshold,
91
+ )
92
+
93
+ results = processor.process_files(
94
+ input_files=input_files,
95
+ output_dir=args.output,
96
+ limit=args.limit if args.limit and args.limit > 0 else 0,
97
+ )
98
+
99
+ logger.info(f"处理完成: {results['processed_bookmarks']} 个书签已分类")
100
+ return
101
+
102
+ if args.eval:
103
+ return run_eval(args)
104
+
105
+ parser.print_help()
106
+
107
+ except KeyboardInterrupt:
108
+ logger.info("程序被用户中断")
109
+ sys.exit(1)
110
+ except (FileNotFoundError, ValueError, ResourceResolutionError) as e:
111
+ logger.error(f"配置或资源错误: {e}")
112
+ if args.log_level == "DEBUG":
113
+ raise
114
+ sys.exit(2)
115
+ except ImportError as e:
116
+ logger.error(f"依赖缺失: {e}")
117
+ if args.log_level == "DEBUG":
118
+ raise
119
+ sys.exit(3)
120
+ except Exception as e:
121
+ logger.error(f"程序执行失败: {e}")
122
+ if args.log_level == "DEBUG":
123
+ raise
124
+ sys.exit(1)
125
+
126
+
127
+ def run_eval(args):
128
+ """评估分类效果:加载标注数据,逐条分类,输出准确率"""
129
+ import json
130
+ from cleanbookmarks.text_utils import normalize_category_string
131
+
132
+ eval_path = Path(args.eval)
133
+ if not eval_path.is_file():
134
+ logger.error(f"标注文件不存在: {eval_path}")
135
+ sys.exit(1)
136
+
137
+ try:
138
+ with open(eval_path, "r", encoding="utf-8") as f:
139
+ labeled = json.load(f)
140
+ except json.JSONDecodeError as e:
141
+ logger.error(f"标注文件不是合法 JSON: {e}")
142
+ sys.exit(1)
143
+ if not isinstance(labeled, list):
144
+ logger.error("标注文件顶层必须是 JSON 数组")
145
+ sys.exit(1)
146
+
147
+ processor = BookmarkProcessor(
148
+ config_path=str(resolve_config_path(args.config)[0]),
149
+ max_workers=args.workers,
150
+ confidence_threshold=args.threshold,
151
+ )
152
+
153
+ correct = 0
154
+ evaluated = 0
155
+ skipped = 0
156
+ mismatches = []
157
+ category_stats = {}
158
+
159
+ for item in labeled:
160
+ if not isinstance(item, dict):
161
+ logger.warning(f"标注数据格式非法,跳过: {item}")
162
+ skipped += 1
163
+ continue
164
+ url = item.get("url", "")
165
+ title = item.get("title", "")
166
+ expected = normalize_category_string(item.get("expected", ""))
167
+ if not url or not title or not expected:
168
+ logger.warning(f"标注数据缺字段,跳过: {item}")
169
+ skipped += 1
170
+ continue
171
+ result = processor.classifier.classify(url, title)
172
+ predicted = normalize_category_string(result.category)
173
+ evaluated += 1
174
+
175
+ top_expected = expected.split("/")[0]
176
+ top_predicted = predicted.split("/")[0]
177
+
178
+ category_stats.setdefault(top_expected, {"correct": 0, "total": 0})
179
+ category_stats[top_expected]["total"] += 1
180
+ if top_predicted == top_expected:
181
+ correct += 1
182
+ category_stats[top_expected]["correct"] += 1
183
+ else:
184
+ mismatches.append({
185
+ "url": url,
186
+ "title": title,
187
+ "expected": top_expected,
188
+ "predicted": top_predicted,
189
+ "confidence": round(result.confidence, 3),
190
+ "method": result.method,
191
+ })
192
+
193
+ total = evaluated
194
+ accuracy = correct / total if total > 0 else 0
195
+ print(f"\n{'='*50}")
196
+ print(f"评估结果: {correct}/{total} 正确 ({accuracy:.1%})")
197
+ if skipped:
198
+ print(f"(跳过 {skipped} 条缺字段的标注)")
199
+ print("分类方法: 规则引擎(LLM 可选)")
200
+ print(f"{'='*50}")
201
+
202
+ print(f"\n各分类准确率:")
203
+ for cat in sorted(category_stats.keys()):
204
+ s = category_stats[cat]
205
+ rate = s["correct"] / s["total"] if s["total"] > 0 else 0
206
+ print(f" {cat}: {s['correct']}/{s['total']} ({rate:.0%})")
207
+
208
+ if mismatches:
209
+ print(f"\n分类错误 ({len(mismatches)} 条):")
210
+ for m in mismatches:
211
+ print(f" [{m['expected']} -> {m['predicted']}] {m['title'][:50]}")
212
+ print(f" url: {m['url'][:80]}")
213
+ print(f" method={m['method']}, confidence={m['confidence']}")
214
+
215
+ return
216
+
217
+
218
+ if __name__ == "__main__":
219
+ main()