Add cross-source duplicate detection, extend retention to 14 days

Exact URL/text matching missed the same story from a different outlet
(different URL, different wording). Add fuzzy title matching via
difflib, checked both within a single poll batch (the common case —
two feeds returning the same story in the same 5-minute cycle) and
against a rolling window of recently-sent titles in the seen-DB (the
cross-cycle case, e.g. follow-up coverage a few hours later).

seen_articles.db/seen_victims.db retention bumped from 7 to 14 days
(bot_config.SEEN_RETENTION_DAYS) — the window the new title matching
actually needs, and no reason to keep dedup history longer than that.

validation/run_validation.py: switch to the shared bot_config mapping,
and prune results.jsonl to the same 14-day window each poll instead of
growing forever — it's a testing aid, not an archive.

Also fixes a bug where a future-dated (bad clock/feed) article stayed
"recent" indefinitely instead of aging out after 48 hours, and drops
the legacy MD5 url-hash field that SHA-256 fingerprints replaced.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
bot
2026-08-25 12:19:54 +03:00
parent 1b9a5de9e3
commit ce3b813e8b
2 changed files with 103 additions and 37 deletions
+68 -23
View File
@@ -10,6 +10,7 @@ import logging
import asyncio import asyncio
import aiohttp import aiohttp
import sqlite3 import sqlite3
import difflib
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
import hashlib import hashlib
from typing import Dict, List, Optional, Tuple from typing import Dict, List, Optional, Tuple
@@ -18,6 +19,8 @@ from html import escape
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
from pathlib import Path from pathlib import Path
from bot_config import CATEGORY_EMOJIS, SEEN_RETENTION_DAYS, TITLE_DEDUP_WINDOW_HOURS, TITLE_SIMILARITY_THRESHOLD
# Configure logging # Configure logging
logging.basicConfig( logging.basicConfig(
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
@@ -32,6 +35,7 @@ class RSSFeedManager:
self.feed_type = feed_type # 'daily', 'blogs', or 'general' self.feed_type = feed_type # 'daily', 'blogs', or 'general'
self.feeds = {} self.feeds = {}
self.seen_articles = {} # Changed to dict to store timestamps self.seen_articles = {} # Changed to dict to store timestamps
self.recent_titles: List[str] = [] # normalized titles sent recently, for cross-source dedup
self.session = None self.session = None
self.first_run = True self.first_run = True
self.load_feeds() self.load_feeds()
@@ -71,6 +75,7 @@ class RSSFeedManager:
self._ensure_seen_db() self._ensure_seen_db()
self._migrate_legacy_seen_json() self._migrate_legacy_seen_json()
self._load_seen_from_sqlite() self._load_seen_from_sqlite()
self._load_recent_titles()
def _ensure_seen_db(self): def _ensure_seen_db(self):
"""Create seen-article database and indexes if missing.""" """Create seen-article database and indexes if missing."""
@@ -87,10 +92,34 @@ class RSSFeedManager:
conn.execute( conn.execute(
"CREATE INDEX IF NOT EXISTS idx_seen_articles_seen_at ON seen_articles (seen_at)" "CREATE INDEX IF NOT EXISTS idx_seen_articles_seen_at ON seen_articles (seen_at)"
) )
conn.execute(
"""
CREATE TABLE IF NOT EXISTS sent_titles (
title_norm TEXT NOT NULL,
seen_at TEXT NOT NULL
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_sent_titles_seen_at ON sent_titles (seen_at)"
)
conn.commit() conn.commit()
except Exception as e: except Exception as e:
logger.error(f"Error initializing seen-article database: {e}") logger.error(f"Error initializing seen-article database: {e}")
def _load_recent_titles(self):
"""Load normalized titles sent within the cross-source dedup window."""
try:
cutoff_iso = (datetime.now(timezone.utc) - timedelta(hours=TITLE_DEDUP_WINDOW_HOURS)).isoformat()
with sqlite3.connect(self.seen_file) as conn:
cursor = conn.execute(
"SELECT title_norm FROM sent_titles WHERE seen_at >= ?", (cutoff_iso,)
)
self.recent_titles = [row[0] for row in cursor.fetchall()]
except Exception as e:
logger.error(f"Error loading recent titles: {e}")
self.recent_titles = []
def _load_seen_from_sqlite(self): def _load_seen_from_sqlite(self):
"""Load seen keys from SQLite into memory.""" """Load seen keys from SQLite into memory."""
try: try:
@@ -149,7 +178,7 @@ class RSSFeedManager:
def save_seen_articles(self): def save_seen_articles(self):
"""Save seen article keys with retention in SQLite.""" """Save seen article keys with retention in SQLite."""
try: try:
cutoff_time = datetime.now(timezone.utc) - timedelta(days=7) cutoff_time = datetime.now(timezone.utc) - timedelta(days=SEEN_RETENTION_DAYS)
cutoff_iso = cutoff_time.isoformat() cutoff_iso = cutoff_time.isoformat()
cleaned_articles = {} cleaned_articles = {}
with sqlite3.connect(self.seen_file) as conn: with sqlite3.connect(self.seen_file) as conn:
@@ -167,12 +196,22 @@ class RSSFeedManager:
except Exception as e: except Exception as e:
logger.error(f"Error saving seen articles: {e}") logger.error(f"Error saving seen articles: {e}")
def generate_article_hash(self, article: Dict) -> str: def _save_sent_title(self, title: str, seen_at: str):
"""Generate unique hash for article based on URL only for better duplicate detection""" """Record a sent article's normalized title for cross-source dedup, pruning old rows."""
# Use only the URL for hash to catch duplicates across different RSS sources title_norm = self.normalize_text(title)
# Different sources may have same article with different titles/dates if not title_norm:
url = article.get('link', '') return
return hashlib.md5(url.encode()).hexdigest() try:
cutoff_iso = (datetime.now(timezone.utc) - timedelta(days=SEEN_RETENTION_DAYS)).isoformat()
with sqlite3.connect(self.seen_file) as conn:
conn.execute("DELETE FROM sent_titles WHERE seen_at < ?", (cutoff_iso,))
conn.execute(
"INSERT INTO sent_titles (title_norm, seen_at) VALUES (?, ?)",
(title_norm, seen_at),
)
conn.commit()
except Exception as e:
logger.error(f"Error saving sent title: {e}")
@staticmethod @staticmethod
def canonicalize_url(url: str) -> str: def canonicalize_url(url: str) -> str:
@@ -220,22 +259,36 @@ class RSSFeedManager:
} }
def is_article_recent(self, published_dt: datetime) -> bool: def is_article_recent(self, published_dt: datetime) -> bool:
"""Allow articles published within the last 48 hours.""" """Allow articles published within the last 48 hours (with brief clock-skew tolerance)."""
if published_dt.tzinfo is None: if published_dt.tzinfo is None:
published_dt = published_dt.replace(tzinfo=timezone.utc) published_dt = published_dt.replace(tzinfo=timezone.utc)
age = datetime.now(timezone.utc) - published_dt.astimezone(timezone.utc) age_seconds = (datetime.now(timezone.utc) - published_dt.astimezone(timezone.utc)).total_seconds()
return age.total_seconds() <= 48 * 3600 return -300 <= age_seconds <= 48 * 3600
@staticmethod
def titles_are_near_duplicate(title_norm_a: str, title_norm_b: str) -> bool:
"""Fuzzy title match, tolerant of different outlets' wording for the same story."""
return difflib.SequenceMatcher(None, title_norm_a, title_norm_b).ratio() >= TITLE_SIMILARITY_THRESHOLD
def is_near_duplicate_title(self, title: str) -> bool:
"""Fuzzy-match against recently sent titles to catch the same story from a different source."""
title_norm = self.normalize_text(title)
if not title_norm:
return False
return any(self.titles_are_near_duplicate(title_norm, other) for other in self.recent_titles)
def is_duplicate_article(self, article: Dict) -> bool: def is_duplicate_article(self, article: Dict) -> bool:
"""Check URL/content fingerprint duplicates against sent-history.""" """Check URL/content fingerprint duplicates, plus fuzzy title matches against recent cross-source sends."""
fingerprints = self.get_article_fingerprints(article) fingerprints = self.get_article_fingerprints(article)
url_key = fingerprints['url_key'] url_key = fingerprints['url_key']
content_key = fingerprints['content_key'] content_key = fingerprints['content_key']
return (url_key and url_key in self.seen_articles) or (content_key in self.seen_articles) if (url_key and url_key in self.seen_articles) or (content_key in self.seen_articles):
return True
return self.is_near_duplicate_title(article.get('title', ''))
@classmethod @classmethod
def mark_article_as_sent(cls, article: Dict, seen_file: str = "seen_articles.db"): def mark_article_as_sent(cls, article: Dict, seen_file: str = "seen_articles.db"):
"""Persist fingerprints only after successful delivery.""" """Persist fingerprints and title only after successful delivery."""
manager = cls(feeds_file="", seen_file=seen_file, feed_type=article.get('feed_type', 'general')) manager = cls(feeds_file="", seen_file=seen_file, feed_type=article.get('feed_type', 'general'))
fingerprints = cls.get_article_fingerprints(article) fingerprints = cls.get_article_fingerprints(article)
now_iso = datetime.now(timezone.utc).isoformat() now_iso = datetime.now(timezone.utc).isoformat()
@@ -244,6 +297,7 @@ class RSSFeedManager:
manager.seen_articles[fingerprints['url_key']] = now_iso manager.seen_articles[fingerprints['url_key']] = now_iso
manager.seen_articles[fingerprints['content_key']] = now_iso manager.seen_articles[fingerprints['content_key']] = now_iso
manager.save_seen_articles() manager.save_seen_articles()
manager._save_sent_title(article.get('title', ''), now_iso)
def clean_html(self, text: str) -> str: def clean_html(self, text: str) -> str:
"""Clean HTML tags and decode entities""" """Clean HTML tags and decode entities"""
@@ -322,7 +376,6 @@ class RSSFeedManager:
content = entry.description content = entry.description
article = { article = {
'hash': self.generate_article_hash(entry),
'title': self.clean_html(getattr(entry, 'title', 'No Title')), 'title': self.clean_html(getattr(entry, 'title', 'No Title')),
'description': self.clean_html(content), 'description': self.clean_html(content),
'url': self.canonicalize_url(getattr(entry, 'link', '')), 'url': self.canonicalize_url(getattr(entry, 'link', '')),
@@ -449,15 +502,7 @@ class RSSFeedManager:
@staticmethod @staticmethod
def format_telegram_message(article: Dict) -> Tuple[str, Optional[str]]: def format_telegram_message(article: Dict) -> Tuple[str, Optional[str]]:
"""Format article for Telegram message""" """Format article for Telegram message"""
category_emojis = { emoji = CATEGORY_EMOJIS.get(article.get('category', ''), '📰')
'news': '📰',
'malware': '🦠',
'threat_intel': '🛰️',
'osint': '🕵️',
'research': '🔬'
}
emoji = category_emojis.get(article.get('category', ''), '📰')
title = escape(article.get('title', 'No Title')) title = escape(article.get('title', 'No Title'))
description = escape(article.get('description', '')) description = escape(article.get('description', ''))
source = escape(article.get('source', 'Unknown Source')) source = escape(article.get('source', 'Unknown Source'))
+34 -13
View File
@@ -14,7 +14,7 @@ import aiohttp
import json import json
import os import os
import sys import sys
from datetime import datetime, timezone from datetime import datetime, timezone, timedelta
from pathlib import Path from pathlib import Path
ROOT = Path(__file__).parent.parent ROOT = Path(__file__).parent.parent
@@ -33,19 +33,11 @@ if env_file.exists():
from rss_manager import RSSFeedManager from rss_manager import RSSFeedManager
from content_classifier import ContentClassifier from content_classifier import ContentClassifier
from ransomware_fetcher import RansomwareFetcher from ransomware_fetcher import RansomwareFetcher
from bot_config import CATEGORY_FEEDS as CATEGORY_CONFIG, POLL_INTERVAL_SECONDS as POLL_INTERVAL, SEEN_RETENTION_DAYS
CATEGORY_CONFIG = {
"news": "feeds/news_feeds.json",
"malware": "feeds/malware_feeds.json",
"threat_intel": "feeds/threat_intel_feeds.json",
"osint": "feeds/osint_feeds.json",
"research": "feeds/research_feeds.json",
}
OUTPUT_FILE = Path(__file__).parent / "results.jsonl" OUTPUT_FILE = Path(__file__).parent / "results.jsonl"
SEEN_FILE = str(Path(__file__).parent / "seen_validation.db") SEEN_FILE = str(Path(__file__).parent / "seen_validation.db")
SEEN_VICTIMS_FILE = str(Path(__file__).parent / "seen_validation_victims.db") SEEN_VICTIMS_FILE = str(Path(__file__).parent / "seen_validation_victims.db")
POLL_INTERVAL = 300
def save_record(f, record: dict): def save_record(f, record: dict):
@@ -123,7 +115,7 @@ async def poll_victims(fetcher: RansomwareFetcher) -> int:
"source": "ransomware.live", "source": "ransomware.live",
"url": article.get("url"), "url": article.get("url"),
"published": article.get("published_human"), "published": article.get("published_human"),
"category": "malware", "category": article.get("category"),
"cves": [], "cves": [],
"threat_actors": [], "threat_actors": [],
"malware_families": article.get("malware_families", []), "malware_families": article.get("malware_families", []),
@@ -135,6 +127,33 @@ async def poll_victims(fetcher: RansomwareFetcher) -> int:
return len(new_victims) return len(new_victims)
def prune_results_file():
"""Keep results.jsonl bounded to the same window dedup actually needs — this is a
testing aid, not an archive, so nothing here needs to outlive SEEN_RETENTION_DAYS."""
if not OUTPUT_FILE.exists():
return
cutoff = (datetime.now(timezone.utc) - timedelta(days=SEEN_RETENTION_DAYS)).isoformat()
kept = []
dropped = 0
with open(OUTPUT_FILE) as f:
for line in f:
line = line.strip()
if not line:
continue
try:
record = json.loads(line)
except json.JSONDecodeError:
continue
if record.get("run_at", "") >= cutoff:
kept.append(line)
else:
dropped += 1
if dropped:
with open(OUTPUT_FILE, "w") as f:
f.write("\n".join(kept) + ("\n" if kept else ""))
print(f" Pruned {dropped} record(s) older than {SEEN_RETENTION_DAYS} days")
async def main(): async def main():
classifier = ContentClassifier() classifier = ContentClassifier()
@@ -166,9 +185,11 @@ async def main():
victim_count = await poll_victims(fetcher) if fetcher else 0 victim_count = await poll_victims(fetcher) if fetcher else 0
total = rss_count + victim_count total = rss_count + victim_count
if total: if total:
print(f" Saved {rss_count} articles + {victim_count} victims\n") print(f" Saved {rss_count} articles + {victim_count} victims")
else: else:
print(f" Nothing new\n") print(f" Nothing new")
prune_results_file()
print()
except Exception as e: except Exception as e:
print(f" Error: {e}\n") print(f" Error: {e}\n")