Как я собрал новостной агрегатор HypeNet на Cloudflare Workers + Supabase и что пошло не так
- вторник, 23 июня 2026 г. в 00:00:07
Привет, Хабр!
За последние пару месяцев я собрал новостной агрегатор — парсинг 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 → кэширует → отдаёт
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‑лент.
Неочевидная проблема с российскими 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‑теги в описаниях, « вместо кавычек, рекламные вставки, эмодзи‑подписи из Telegram‑каналов, скрипты внутри CDATA. Всё это нужно вычищать перед сохранением:
function cleanText(text) { return text .replace(/<script[\s\S]*?<\/script>/gi, '') // скрипты .replace(/<[^>]*>/g, ' ') // HTML-теги .replace(/«/g, '«') // HTML-entities .replace(/»/g, '»') .replace(/—/g, '—') .replace(/ /g, ' ') .replace(/&#\d+;/g, '') // числовые entities .replace(/&\w+;/g, '') // остальные entities .replace(/\s+/g, ' ') .trim(); }
Функция cleanText разрасталась итеративно — каждый новый источник приносил свои сюрпризы. Отдельная боль — Telegram‑каналы через RSSHub: подписи каналов, реклама, цепочки эмодзи‑реакций в конце каждого поста. Для них пришлось добавить отдельный блок регулярок.
Каждый запрос к 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(), что тут сложного? А вот тут начинается самая интересная часть.
Cloudflare Workers на бесплатном плане имеют жёсткое ограничение — максимум 50 subrequests за одно выполнение воркера. Subrequest — это любой исходящий fetch(). Неважно куда: к RSS‑ленте, к Supabase, cache.delete() — всё считается.
Посчитаем для 26 источников:
Операция | Запросов |
|---|---|
| 26 |
Получить список источников из Supabase | 1 |
| 1 |
| зависит от 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 источников.
Отдельная проблема возникла с сохранением. Изначально использовал 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? Дробите на несколько воркеров, переходите на платный план, или нашли другой подход?