惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

宝玉的分享
宝玉的分享
T
Threat Research - Cisco Blogs
H
Hacker News: Front Page
N
News and Events Feed by Topic
Know Your Adversary
Know Your Adversary
Cisco Talos Blog
Cisco Talos Blog
SecWiki News
SecWiki News
C
Cisco Blogs
D
Darknet – Hacking Tools, Hacker News & Cyber Security
T
Tor Project blog
K
Kaspersky official blog
Forbes - Security
Forbes - Security
Webroot Blog
Webroot Blog
Schneier on Security
Schneier on Security
P
Privacy & Cybersecurity Law Blog
H
Heimdal Security Blog
Y
Y Combinator Blog
The GitHub Blog
The GitHub Blog
S
SegmentFault 最新的问题
V
Vulnerabilities – Threatpost
T
Tenable Blog
T
Tailwind CSS Blog
P
Privacy International News Feed
WordPress大学
WordPress大学
大猫的无限游戏
大猫的无限游戏
小众软件
小众软件
博客园 - Franky
Hacker News: Ask HN
Hacker News: Ask HN
Jina AI
Jina AI
C
Cybersecurity and Infrastructure Security Agency CISA
CTFtime.org: upcoming CTF events
CTFtime.org: upcoming CTF events
雷峰网
雷峰网
Vercel News
Vercel News
A
About on SuperTechFans
爱范儿
爱范儿
Simon Willison's Weblog
Simon Willison's Weblog
AWS News Blog
AWS News Blog
The Last Watchdog
The Last Watchdog
Engineering at Meta
Engineering at Meta
Spread Privacy
Spread Privacy
Security Archives - TechRepublic
Security Archives - TechRepublic
博客园 - 司徒正美
量子位
博客园 - 三生石上(FineUI控件)
J
Java Code Geeks
Hacker News - Newest:
Hacker News - Newest: "LLM"
Recorded Future
Recorded Future
H
Hackread – Cybersecurity News, Data Breaches, AI and More
Martin Fowler
Martin Fowler
Project Zero
Project Zero

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
Как я собрал новостной агрегатор на Cloudflare Workers + Supabase и что пошло не так
smotiks · 2026-06-23 · via Все публикации подряд на Хабре

Привет, Хабр!

За последние пару месяцев я собрал новостной агрегатор — парсинг RSS, API, кэширование, фронтенд. Всё на бесплатных инструментах, без единого сервера. Звучит красиво, но по дороге я наступил на столько граблей, что хватит на отдельную статью. Собственно, вот она.


Зачем ещё один агрегатор

Мне хотелось собрать в одном месте новости из российских СМИ — без рекламы, без кликбейтных врезок, без автоплея видео. Просто лента: заголовок, текст, источник, время. Открыл — прочитал — закрыл.

Второй мотив — чисто инженерный. Хотелось проверить, можно ли построить полноценный бэкенд вообще без сервера. Не «почти без сервера», а буквально: ни одного процесса, который бы крутился 24/7 и ждал запросов.


Стек

В итоге архитектура выглядит так:

  • Cloudflare Workers — вся бизнес‑логика: парсинг RSS‑лент, REST API для фронтенда, инвалидация кэша

  • Supabase — PostgreSQL для хранения новостей, таблица источников, Supabase Auth для авторизации пользователей

  • Cloudflare Cache API — кэширование ответов API на edge

  • VPS — только для раздачи статических файлов фронтенда

  • Чистый HTML + Vanilla JS — никаких фреймворков, сборщиков и билд‑процессов

Поток данных простой:

Cron (каждые 15 мин) → Worker парсит 26 RSS-лент → очищает данные →
→ сохраняет в Supabase → инвалидирует кэш

Пользователь открывает сайт → fetch к Worker API →
→ Worker проверяет Cache API → если кэш свежий — отдаёт мгновенно →
→ если нет — идёт в Supabase → кэширует → отдаёт

Парсинг: регулярки вместо DOM‑парсера

Cloudflare Workers — это V8-изолят без полноценного DOM API. Нет DOMParser, нет cheerio, нет xmldom без дополнительных зависимостей. Первая мысль была подтянуть npm‑пакет, но Workers имеют ограничение на размер скрипта (1 МБ на бесплатном плане), и тащить туда тяжёлые парсеры не хотелось.

Поэтому XML разбирается регулярными выражениями. Да, я знаю что «нельзя парсить XML регулярками». Но для конкретной задачи — вытащить <title>, <link>, <description> и <pubDate> из <item> — это работает:

function extractTag(text, tag) {
  // Сначала пробуем CDATA
  const cdata = text.match(
    new RegExp(`<${tag}[^>]*>\\s*<!\\[CDATA\\[([\\s\\S]*?)\\]\\]>`, 'i')
  );
  if (cdata) return cdata[1].trim();

  // Иначе обычный тег
  const plain = text.match(
    new RegExp(`<${tag}[^>]*>([\\s\\S]*?)</${tag}>`, 'i')
  );
  return plain ? plain[1].trim() : '';
}

Два прохода — сначала проверяем CDATA‑обёртку (которую используют примерно половина российских источников), потом обычный тег. Этого хватает для всех RSS 2.0 и большинства Atom‑лент.

Кодировка: Windows-1251

Неочевидная проблема с российскими RSS: некоторые до сих пор отдают фид в Windows-1251. Если просто декодировать как UTF-8 — получаем кашу из вопросительных знаков.

Решение — читаем первые 300 байт как UTF-8 (этого хватает чтобы добраться до <?xml encoding="..."?>), вытаскиваем кодировку и перечитываем буфер в нужной кодировке:

const buffer   = await res.arrayBuffer();
const probe    = new TextDecoder('utf-8').decode(
  new Uint8Array(buffer).slice(0, 300)
);
const encMatch = probe.match(/encoding=["']([\w-]+)["']/i);
const enc      = encMatch ? encMatch[1].toLowerCase() : 'utf-8';

const text = new TextDecoder(
  (enc === 'windows-1251' || enc === 'cp1251') ? 'windows-1251' : 'utf-8'
).decode(buffer);

TextDecoder в Workers поддерживает windows-1251 из коробки — отдельный плюс платформе.

Очистка мусора

RSS‑ленты приходят грязные. HTML‑теги в описаниях, &laquo; вместо кавычек, рекламные вставки, эмодзи‑подписи из Telegram‑каналов, скрипты внутри CDATA. Всё это нужно вычищать перед сохранением:

function cleanText(text) {
  return text
    .replace(/<script[\s\S]*?<\/script>/gi, '')  // скрипты
    .replace(/<[^>]*>/g, ' ')                     // HTML-теги
    .replace(/&laquo;/g, '«')                     // HTML-entities
    .replace(/&raquo;/g, '»')
    .replace(/&mdash;/g, '—')
    .replace(/&nbsp;/g, ' ')
    .replace(/&#\d+;/g, '')                       // числовые entities
    .replace(/&\w+;/g, '')                        // остальные entities
    .replace(/\s+/g, ' ')
    .trim();
}

Функция cleanText разрасталась итеративно — каждый новый источник приносил свои сюрпризы. Отдельная боль — Telegram‑каналы через RSSHub: подписи каналов, реклама, цепочки эмодзи‑реакций в конце каждого поста. Для них пришлось добавить отдельный блок регулярок.


Кэширование: Cache API

Каждый запрос к Supabase — это ~300мс (сервер в Европе). Для первой страницы ленты, которую открывают чаще всего, это много. Cloudflare Cache API позволяет закэшировать ответ прямо на edge‑ноде:

const cache    = caches.default;
const cacheKey = new Request(
  `https://rss.hypenet.ru/news-cache?limit=${limit}&category=${category || 'all'}`
);

const cached = await cache.match(cacheKey);
if (cached) return cached;  // Отдаём за ~10мс

// Идём в Supabase
const data     = await fetchFromSupabase(limit, offset, category, env);
const response = new Response(JSON.stringify(data), {
  headers: { 'Cache-Control': 'public, max-age=900' }
});

await cache.put(cacheKey, response.clone());
return response;

Кэш живёт 15 минут. После каждого цикла парсинга воркер инвалидирует кэш по всем категориям:

const categories = ['all', 'tech', 'sport', 'politics', 'economy'];
for (const cat of categories) {
  await cache.delete(new Request(
    `https://rss.hypenet.ru/news-cache?limit=20&category=${cat}`
  ));
}

Казалось бы — пять cache.delete(), что тут сложного? А вот тут начинается самая интересная часть.


Лимит subrequests: история одной ошибки

Cloudflare Workers на бесплатном плане имеют жёсткое ограничение — максимум 50 subrequests за одно выполнение воркера. Subrequest — это любой исходящий fetch(). Неважно куда: к RSS‑ленте, к Supabase, cache.delete() — всё считается.

Посчитаем для 26 источников:

Операция

Запросов

fetch() к каждому RSS

26

Получить список источников из Supabase

1

DELETE старых новостей

1

POST батчи новостей (260 / batchSize)

зависит от batchSize

Инвалидация кэша

5–10

Итого

33 + батчи + кэш

Изначально batchSize был 50. 260 новостей / 50 = 6 POST‑запросов. Плюс 10 запросов на кэш (5 категорий × 2 варианта лимита). Итого: ~49. Впритык.

Первое время работало. А потом я добавил ещё один источник, и посыпалось:

{
  "error": "Too many subrequests by single Worker invocation"
}

Ошибка выглядит пугающе — воркер просто падает и не сохраняет ничего. Причём она недетерминированная: если один из источников делает HTTP‑редирект (301/302), каждый редирект тоже считается subrequest. Утром всё работает, вечером — падает, потому что конкретный источник начал редиректить.

Что помогло

Увеличение batchSize. Поднял с 50 до 200. Теперь 260 новостей сохраняются за 2 POST‑запроса вместо 6. Это сразу освободило 4 слота.

Уменьшение количества новостей на источник. Было 30, стало 15. Суммарный объём данных уменьшился, батчей стало меньше.

Отключение проблемных источников. Один конкретный источник (новостной сайт о финансах) делал цепочку из 3–4 редиректов при каждом запросе. Это +4 subrequest только на один фид. Отключил — стало стабильно.

Сокращение инвалидации кэша. Вместо 10 cache.delete() оставил 5 — убрал дублирование по разным лимитам пагинации.

В итоге текущий бюджет: 26 (RSS) + 1 (get sources) + 1 (delete old) + 2 (post batches) + 5 (cache) = 35 subrequests. Запас в 15 штук — можно добавить ещё 10–12 источников.

Upsert вместо ignore‑duplicates

Отдельная проблема возникла с сохранением. Изначально использовал Prefer: resolution=ignore-duplicates — Supabase просто игнорировал новости с существующим link. Но при большом батче (200 записей) PostgREST возвращал ошибку:

{"code":"23505","message":"duplicate key value violates unique constraint"}

Весь батч падал из‑за одного дубликата. Решение — переключиться на merge-duplicates с явным указанием on_conflict:

await fetch(`${SUPABASE_URL}/rest/v1/news?on_conflict=link`, {
  method: 'POST',
  headers: {
    'Prefer': 'resolution=merge-duplicates,return=minimal'
  },
  body: JSON.stringify(batch)
});

Теперь дубликаты обновляются, новые вставляются — и весь батч проходит за один запрос.


Фронтенд: ноль зависимостей

Сознательно отказался от React, Vue, Next.js и вообще любых фреймворков. Фронтенд — это статические HTML‑страницы и один главный JS‑файл. Данные подгружаются через fetch при открытии. Никакого билда, вебпака, транспиляции — файлы лежат на VPS и раздаются напрямую.

Бесконечная прокрутка реализована через IntersectionObserver — когда элемент «Загрузить ещё» попадает в viewport, автоматически подгружается следующая страница. Пагинация серверная — Worker принимает offset и отдаёт следующую порцию.

Для авторизации — Supabase Auth. Регистрация по email, вход через Google OAuth, слушатель onAuthStateChange для обновления UI. Весь auth‑flow работает на клиенте без промежуточного бэкенда.


Автокатегоризация

Каждая новость автоматически попадает в одну из категорий: политика, экономика, технологии, спорт. Категоризация работает на уровне парсинга — регулярное выражение проверяет заголовок и описание на ключевые слова:

function categorize(text) {
  const t = text.toLowerCase();
  if (/спорт|футбол|хоккей|чемпионат|матч|турнир/.test(t)) return 'sport';
  if (/политик|президент|правительств|закон|дума/.test(t))  return 'politics';
  if (/экономик|рубл|доллар|банк|биржа|нефт/.test(t))      return 'economy';
  if (/технолог|смартфон|нейросет|гаджет|процессор/.test(t)) return 'tech';
  return 'all';
}

Примитивно, но для новостного агрегатора работает. Процент ошибочной категоризации — порядка 5–10%, в основном на граничных темах вроде «экономика спорта».


Управление источниками без редеплоя

Отдельная удобная вещь — таблица rss_sources в Supabase. Воркер при каждом запуске загружает список активных источников из базы. Это значит что добавить новый источник, отключить сломавшийся или поменять категорию — можно через SQL‑запрос или Supabase Dashboard, без деплоя воркера.

На практике это оказалось критически важно: источники периодически меняют URL фидов, ломают формат XML, включают защиту от ботов. Возможность мгновенно отключить проблемный источник через UPDATE rss_sources SET enabled = false WHERE name = '...' — экономит много времени.


Про деньги

Монетизации нет и не планируется. Это пет‑проект, который задумывался как инженерный эксперимент: можно ли построить полноценный продукт с парсингом, API, кэшированием и авторизацией — полностью на бесплатных инструментах, без единого постоянно работающего процесса?

Ответ: можно, но с оговорками. Бесплатный план Cloudflare Workers даёт 100 000 запросов в день и лимит в 50 subrequests. Supabase — 500 МБ хранилища и 2 ГБ трафика. Для пет‑проекта этого хватает. Для чего‑то серьёзного — нет.

Проект живёт потому что мне интересно его развивать и потому что инфраструктурные расходы равны нулю. Не нужно думать «стоит ли держать сервер за $20/мес ради сайта с 50 посетителями». Cron тикает, новости парсятся, кэш обновляется — всё само.


Выводы

Cloudflare Workers — отличная платформа для задач, которые укладываются в модель «принял запрос — обработал — отдал». Для парсинга RSS — идеально: не нужен долгоживущий процесс, достаточно Cron Trigger.

Лимит subrequests — главное архитектурное ограничение. Нужно считать каждый fetch() и оптимизировать количество обращений к внешним сервисам. Это дисциплинирует, но и ограничивает.

Supabase — удобная замена «серверу с PostgreSQL» для пет‑проектов. REST API из коробки, Auth, Dashboard для ручного управления данными. Минус — отсутствие нормального ORM на клиенте, все запросы через HTTP.

Vanilla JS — спорное решение для фронтенда. С одной стороны — нет билда, нет зависимостей, мгновенная загрузка. С другой — по мере роста функционала код в app.js стал сложным для поддержки. Если бы начинал заново — возможно взял бы что‑то легковесное вроде Preact.


Собственно, сам агрегатор HypeNet. Буду рад конструктивному фидбеку. Особенно интересно — как вы решаете проблему лимита subrequests в Cloudflare Workers? Дробите на несколько воркеров, переходите на платный план, или нашли другой подход?