Skip to content
Все посты /

Herald v2: локальная новостная разведка для AI-агентов

Как я собрал 4-стадийный пайплайн новостей, который кластеризует статьи в истории по схожести заголовков - stdlib Python + SQLite.

#context-engineering #claude-code #agents #local-first #python

Я хотел, чтобы мой AI-агент знал, что происходит в технологиях - без облачных API, платных тарифов и утечки данных. Поэтому я сделал Herald: плагин для Claude Code, который собирает RSS и Hacker News, кластеризует связанные статьи в истории, скорит их и генерирует ранжированный Markdown-дайджест.

v2 - полная переписка с нуля. Вот как это работает.

Пайплайн

Четыре стадии, каждая - отдельный Python-модуль:

RSS/Atom feeds ─┐
                 ├─→ articles ─→ stories (clustered) ─→ scored brief
HN Algolia API ─┘

Collect забирает данные через адаптеры (RSS, HN Algolia, опционально Tavily). Каждый источник изолирован - падение одного не блокирует остальные.

Ingest дедуплицирует через каноникализацию URL (убирает трекинг-параметры, нормализует хосты, сортирует query params), делает UPSERT в SQLite, трекает кросс-источниковые упоминания и назначает топики по ключевым словам.

Cluster группирует статьи в истории по схожести заголовков. Самая интересная часть - подробнее ниже.

Project генерирует Markdown-дайджест с YAML frontmatter, истории сгруппированы по типу (release, research, tutorial, opinion, news), отсортированы по скору.

Кластеризация: основной алгоритм

Наивный подход - точное совпадение заголовков - пропускает очевидные группы. “Python 3.14 Released” и “Python 3.14 released with great new features” - одна история. Но “Python 3.13 Released” и “Python 3.14 Released” - нет.

Я использую difflib.SequenceMatcher для схожести заголовков с 4 защитными фильтрами. Заголовки сначала нормализуются - приводятся к нижнему регистру, убираются префиксы “Show HN:”, трейлинг [pdf]/(video), лишние пробелы:

def _can_merge(article_title, story, db, cfg):
    # Guard 1: схожесть заголовка выше порога (default 0.65)
    ratio = SequenceMatcher(None, norm_a, norm_s).ratio()
    if ratio < cfg.threshold:
        return False

    # Guard 2: статьи в пределах временного окна
    if abs(article_collected - story_last_updated) > max_gap:
        return False

    # Guard 3: оба заголовка должны быть достаточно длинными
    if len(words_a) < cfg.min_title_words:
        return False

    # Guard 4: конфликт версий/номеров
    nums_a = set(re.findall(r"\d+(?:\.\d+)*", norm_a))
    nums_s = set(re.findall(r"\d+(?:\.\d+)*", norm_s))
    if nums_a and nums_s and nums_a != nums_s:
        return False

    return True

Guard 4 - ключевой инсайт. Без него SequenceMatcher радостно мерджит “Python 3.13 Released” (ratio 0.88) с “Python 3.14 Released”. Конфликт номеров версий ловит это - разные версии значат разные истории.

Переизбрание канонической статьи

У каждой истории есть каноническая статья - та, чей заголовок показывается в дайджесте. Когда статья с более высоким скором присоединяется к истории, она заменяет каноническую - но только если обгоняет текущую на настраиваемую дельту (гистерезис). Это предотвращает постоянные переключения при близких скорах.

canonical_delta = cfg.canonical_delta  # default 0.1
if article_score > current_canonical_score + canonical_delta:
    new_canonical = article_id

Скоринг

Два уровня: статьи и истории.

Скор статьи комбинирует вес источника, очки HN (линейно масштабированные, cap 3.0), плотность ключевых слов и буст для релизов:

def article_score_base(source_weight, points, keyword_density, is_release):
    return (
        source_weight
        + min(points / 500, 3.0)
        + keyword_density * 0.2
        + (0.2 if is_release else 0.0)
    )

Скор истории - максимальный скор статьи плюс покрытие (логарифм количества источников) и бонус momentum за свежую активность:

def story_score(max_article_score, source_count, has_recent):
    coverage = math.log(max(source_count, 1)) * 0.3
    momentum = 0.2 if has_recent else 0.0
    return max_article_score + coverage + momentum

Истории, покрытые несколькими источниками, ранжируются выше. История из HN + RSS + блог более значима, чем из одного фида.

Модель данных

SQLite с 8 таблицами. Ключевые связи:

sources → articles → mentions (cross-source tracking)
                  → article_topics
                  → story_articles → stories → story_topics

Каноникализация URL выполняется перед INSERT - убирает utm_*, fbclid, ref, нормализует www., сортирует query params, апгрейдит HTTP на HTTPS. Две статьи из разных источников на один URL дедуплицируются при ingest, второй источник записывается как упоминание.

Как выглядит результат

---
generated_at: 2026-03-04T12:48:51Z
story_count: 25
period_hours: 24
---
## News

### MacBook Pro with M5 Pro and M5 Max
2.11 · 1 source
- [MacBook Pro with M5 Pro and M5 Max](https://apple.com/newsroom/...)

### TikTok will not introduce end-to-end encryption
1.06 · 1 source
- [TikTok will not introduce...](https://bbc.com/news/articles/...)

Истории сгруппированы по типу, отсортированы по скору. Каждая история показывает свои статьи со ссылками на оригинальные источники.

Интерфейс плагина

Herald - плагин для Claude Code. Семь slash-команд, один хук:

/news-init      → создаёт ~/.herald/ с конфигом + SQLite DB
/news-add <url> → автоматически находит RSS, добавляет в конфиг
/news-run       → запускает полный пайплайн
/news-digest    → выводит последний дайджест с гайдом по анализу
/news-status    → количество статей/историй + время последнего запуска

Хук SessionStart проверяет наличие свежего дайджеста (< 24ч) и подсказывает агенту прочитать его. Агент видит “Fresh news digest available” при старте сессии - никаких ручных триггеров.

Дизайн-решения

stdlib вместо зависимостей. Алгоритм кластеризации использует difflib.SequenceMatcher - без numpy, sklearn, embedding-моделей. Для новостных заголовков (короткие строки, один язык) посимвольная схожесть работает достаточно хорошо и за микросекунды.

SQLite вместо файлов. v1 использовал JSONL и flat-file дедупликацию. v2 - SQLite с foreign keys, FTS5 для полнотекстового поиска и WAL mode. Один файл, ACID-транзакции, ON CONFLICT для идемпотентных UPSERT.

Монотонные таймстампы. Когда поздно пришедшая старая статья присоединяется к истории, last_updated не должен регрессировать. Фикс: max(current_last_updated, article_collected_at). Нашёл этот баг при мультимодельном код-ревью, где Claude, Codex и Gemini независимо аудитили один diff.

Изоляция пайплайна. Каждый адаптер collect работает в своём try/except. Таймаут одного RSS-фида не блокирует HN. Статус пайплайна записывается в таблицу pipeline_runs (включая ошибки) - всегда знаешь, что произошло.

Цифры

  • 174 теста, 0.7с на M-series Mac
  • ~1200 строк Python в 10 модулях
  • 0 внешних AI API вызовов
  • Реальный E2E тест: 2 источника (HN + RSS) → 45 статей → 27 историй → скоринг + дайджест за 3 секунды

Попробуй

claude plugin marketplace add heurema/emporium
claude plugin install herald@emporium
/news-init
/news-run

Исходники: github.com/heurema/herald

Нашёл баг? Все плагины heurema поставляются с Reporter - файлишь issue не выходя из Claude Code:

claude plugin install reporter@emporium
/report bug

Reporter автоматически определяет продукт, собирает контекст окружения и отправляет через gh CLI.