"""Скор записи: вес провайдера + вовлечённость + свежесть + ценник + объём текста + сигнал абуза.""" import re import time from .dates import parse_published WEIGHT = {"reddit": 1.15, "x": 1.10, "forums": 1.0, "tavily": 1.0, "exa": 1.0, "firecrawl": 0.9, "you": 0.9, "ddg": 0.75, "cards": 0.35, "treg": 0.8} ABUSE_TERMS = re.compile( r"(cve-\d{4}-\d+|0day|0-day|zero[- ]day|exploit|rce|sqli|xss|ssrf|rce|" r"leak|leaked|dump|breach|combo[- ]?list|stealer|ransomware|botnet|credentials|" r"password[s]?|api[- ]?key[s]?|token[s]?|аккаунт\w*|база|слив\w*|утечк\w*|комбо|" r"взлом|эксплойт|уязвим\w*|парол\w*|ключи)", re.I) def _abuse(it): """Реальные абузы должны всплывать выше новостей: считаем сигнальные термины.""" blob = f"{it.title} {it.text}"[:2000] hits = len({m.group(0).lower() for m in ABUSE_TERMS.finditer(blob)}) return min(4, hits) KIND_BONUS = {"tweet": 0.15, "thread": 0.25, "tg_post": 0.2, "card": 0.05, "result": 0.1, "post": 0.1} # Категорийный вес: то, ради чего лента существует (абузы/панели/ключи/фермы), # поднимаем выше новостного шума. Значения проверены на текущем индексе. CAT_BONUS = {"abuse": 0.9, "panels": 0.8, "keys": 0.7, "treg": 0.35, "ai": 0.25, "web3": 0.2, "tools": 0.2, "news": -0.35, "misc": -0.2} # Мусор, который не должен всплывать никогда: служебные заголовки, оглавления, # changelog/release notes, голые URL, склейки-«лапши» и пустые обрывки. NOISE_RE = re.compile( r"(chapter\s*list|release\s*notes|changelog|table\s*of\s*contents|" r"^\s*(index|readme|docs?|documentation|wiki)\s*$|" r"^\s*https?://\S+\s*$|\|\|)", re.I) SHOUT_RE = re.compile(r"^[^a-zа-я]{12,}$") def _noise(it): """0 = чисто, иначе штраф. Считается по заголовку и началу текста.""" title = (it.title or "").strip() if not title: return 2.0 penalty = 0.0 if len(title) < 8: penalty += 0.6 if len(title) > 220: penalty += 0.8 if NOISE_RE.search(title): penalty += 1.6 # Голый CVE-дамп (тысячи одинаковых описаний из фида) — полезен точечно, # но не должен занимать верх ленты: топ должен принадлежать находкам. if re.match(r"^CVE-\d{4}-\d+", title): penalty += 1.4 # Обрывок переписки/цитата вместо заголовка: «> be ...», «хотелось бы отметить». if title.startswith(">"): penalty += 1.5 elif title[:1].islower(): penalty += 1.0 if SHOUT_RE.match(title): penalty += 0.35 if sum(c.isalnum() for c in title) < len(title) * 0.4: penalty += 0.4 txt = (it.text or "") if len(txt) > 4000 and len(title) < 25: penalty += 0.7 if not txt and len(title) < 40: penalty += 0.3 return min(3.0, penalty) def _fresh(published): """Unix seconds/milliseconds, ISO or RFC 2822. Return freshness in 0..1.""" dt = parse_published(published) if dt is None: return 0.3 days = max(0.0, (time.time() - dt.timestamp()) / 86400) return max(0.0, 1.0 - days / 30.0) def score(it): base = it.score if it.score else 0.0 w = WEIGHT.get(it.provider, 0.7) fresh = _fresh(it.published) priced = 0.35 if it.price else 0.0 body = min(1.0, len(it.text) / 1200.0) eng = min(3.0, base ** 0.5) if base > 1 else base cat = CAT_BONUS.get((getattr(it, "category", "") or "").lower(), 0.0) total = (w * (1.0 + eng) + fresh * 1.2 + priced + body + KIND_BONUS.get(it.kind, 0.0) + 0.45 * _abuse(it) + cat - _noise(it)) it.score = round(total, 4) return it def rank(items): return sorted((score(i) for i in items), key=lambda x: -x.score)