返回 last30days-skill
dedupe.py
根目录 / skills / last30days / scripts / lib / dedupe.py
1 """Within-source near-duplicate detection."""
2
3 from __future__ import annotations
4
5 import re
6
7 from . import cjk, schema
8
9 STOPWORDS = frozenset(
10 {
11 "the",
12 "a",
13 "an",
14 "to",
15 "for",
16 "how",
17 "is",
18 "in",
19 "of",
20 "on",
21 "and",
22 "with",
23 "from",
24 "by",
25 "at",
26 "this",
27 "that",
28 "it",
29 "what",
30 "are",
31 "do",
32 "can",
33 }
34 ) | cjk.CHINESE_STOPWORDS
35
36
37 def normalize_text(text: str) -> str:
38 text = re.sub(r"[^\w\s]", " ", text.lower())
39 return re.sub(r"\s+", " ", text).strip()
40
41
42 def _ngrams_of_normalized(norm: str, n: int = 3) -> set[str]:
43 if len(norm) < n:
44 return {norm} if norm else set()
45 return {norm[index:index + n] for index in range(len(norm) - n + 1)}
46
47
48 def get_ngrams(text: str, n: int = 3) -> set[str]:
49 return _ngrams_of_normalized(normalize_text(text), n)
50
51
52 def jaccard_similarity(left: set[str], right: set[str]) -> float:
53 if not left or not right:
54 return 0.0
55 union = left | right
56 if not union:
57 return 0.0
58 return len(left & right) / len(union)
59
60
61 def token_jaccard(text_a: str, text_b: str) -> float:
62 tokens_a = {
63 token
64 for token in cjk.segment(normalize_text(text_a))
65 if len(token) > 1 and token not in STOPWORDS
66 }
67 tokens_b = {
68 token
69 for token in cjk.segment(normalize_text(text_b))
70 if len(token) > 1 and token not in STOPWORDS
71 }
72 return jaccard_similarity(tokens_a, tokens_b)
73
74
75 def hybrid_similarity(text_a: str, text_b: str) -> float:
76 return max(
77 jaccard_similarity(get_ngrams(text_a), get_ngrams(text_b)),
78 token_jaccard(text_a, text_b),
79 )
80
81
82 def _tokenize(normalized: str) -> frozenset[str]:
83 return frozenset(
84 tok for tok in cjk.segment(normalized)
85 if len(tok) > 1 and tok not in STOPWORDS
86 )
87
88
89 class _PreparedText:
90 """Pre-computed text representations for fast repeated similarity checks."""
91
92 __slots__ = ("ngrams", "tokens")
93
94 def __init__(self, raw: str) -> None:
95 norm = normalize_text(raw)
96 self.ngrams = _ngrams_of_normalized(norm)
97 self.tokens = _tokenize(norm)
98
99
100 def prepared_similarity(a: _PreparedText, b: _PreparedText) -> float:
101 return max(
102 jaccard_similarity(a.ngrams, b.ngrams),
103 jaccard_similarity(a.tokens, b.tokens),
104 )
105
106
107 def item_text(item: schema.SourceItem) -> str:
108 parts = [item.title, item.body, item.author or "", item.container or ""]
109 return " ".join(part for part in parts if part).strip()
110
111
112 def dedupe_items(items: list[schema.SourceItem], threshold: float = 0.7) -> list[schema.SourceItem]:
113 """Remove near-duplicates while keeping earlier, better-scored items.
114
115 Jobs are deduped by exact URL only: distinct postings on the same careers
116 board share heavy boilerplate (company intro, "TL;DR", benefits) that trips
117 fuzzy text similarity and collapses unrelated roles (a 26-role board fell to
118 7). A unique posting URL is an unambiguous identity, so use it instead.
119 """
120 kept: list[schema.SourceItem] = []
121 kept_prepared: list[_PreparedText] = []
122 seen_job_urls: set[str] = set()
123 for item in items:
124 if item.source == "jobs":
125 url = (item.url or "").strip()
126 if url and url in seen_job_urls:
127 continue
128 if url:
129 seen_job_urls.add(url)
130 kept.append(item)
131 continue
132 text = item_text(item)
133 if not text:
134 kept.append(item)
135 continue
136 prep = _PreparedText(text)
137 is_duplicate = False
138 for existing_prep in kept_prepared:
139 if prepared_similarity(prep, existing_prep) >= threshold:
140 is_duplicate = True
141 break
142 if not is_duplicate:
143 kept.append(item)
144 kept_prepared.append(prep)
145 return kept
146
146 lines PYTHON