# freshgo: порт FreshRSS на Go, который перестал быть портом


RSS мне нравится тем, что между мной и автором никого нет. Я сам выбираю сайты, и их обновления приходят ко мне все и по порядку. Лента не решает за меня, что показать первым, а что придержать.

Читалки при этом я менял. Долгое время пользовался [miniflux](https://miniflux.app/) и даже переносил его на отдельный хостинг[^1]. Потом перешёл на [FreshRSS](https://freshrss.org) и поставил его на свой сервер[^2]. Он меня в целом устраивал: ленты обновлялись, клиенты на телефоне работали. Но постоянно что-то не нравилось, по мелочи и в разных местах, и ни одна из этих мелочей не тянула на причину переезжать.

В итоге я сделал свою читалку: с помощью Claude написал [freshgo](https://github.com/juev/freshgo).

![Экран чтения freshgo в оформлении Reader classic](https://static.juev.org/2026/10/freshgo-classic.png)

## Сначала порт

Задумывался freshgo как полный порт FreshRSS на Go: один исполняемый файл, который забирает данные работающей установки и встаёт на её место. Первый коммит сделан утром 6 октября. К вечеру того же дня в репозитории были хранение в SQLite и PostgreSQL, импорт из FreshRSS, разбор лент всех видов, которые умеет оригинал, его язык поиска и фильтры, Google Reader API и каркас веб-интерфейса[^3].

Образцом служил запущенный FreshRSS 1.30.1. В репозитории лежит то, что он выдал на фиксированном наборе лент и что ответил на список запросов API, и тесты сверяют поведение freshgo с этими ответами[^4].

Переезд занимает одну команду: `freshgo import` читает каталог данных FreshRSS и переносит пользователей с паролями, ленты с настройками, статьи с отметками о прочтении, метки и значки. Мои 84 ленты и шесть тысяч статей импорт перенёс за 1,1 секунды. Обратной дороги нет, базу freshgo в FreshRSS не вернуть, поэтому каталог оригинала лучше сохранить, пока не убедитесь, что всё на месте.

Клиентам о переезде знать не нужно. FreshRSS отдаёт Google Reader API по адресу `/api/greader.php`, и freshgo отвечает по тому же пути, с прежними паролями API, уже выданными токенами и идентификаторами статей. Тот же API доступен и по обычным путям Google Reader, `/accounts/` и `/reader/`, так что клиенту можно дать просто адрес сервера. Fever API не реализован.

## Потом своё

Чистым портом freshgo пробыл недолго. Версия 0.1.0 вышла 8 октября, и уже в ней было то, чего в оригинале нет; остальное добавилось за следующие два дня.

**Интерфейс Google Reader.** Веб-интерфейс у freshgo свой, и собран он по образцу Google Reader: слева дерево категорий и лент, справа статьи по одной строке, статья раскрывается на месте. Оформлений два, «Reader classic» и «Reader 2011», по двум версиям оригинала. У каждого есть светлые и тёмные цвета; тёмные пришлось придумывать, у Google Reader их не было. Страницы собирает сервер, любое действие остаётся ссылкой или формой и работает без JavaScript, а скрипт добавляет клавиши и действия без перезагрузки страницы.

![Оформление Reader 2011 с тёмными цветами](https://static.juev.org/2026/10/freshgo-2011-dark.png)

![Раскрытая статья: действия стоят между заголовком и текстом](https://static.juev.org/2026/10/freshgo-entry.png)

**Телефон и PWA.** Под узкий экран интерфейс доработан отдельно. Меню и управление списком убираются за кнопки. Касание строки раскрывает статью и закрывает предыдущую. Внизу закреплена панель: следующая и предыдущая статья, отметка о прочтении, звёздочка. freshgo можно установить как приложение, с телефона через «На экран Домой», с компьютера через «Установить». Для этого нужен HTTPS, и читать без сети нельзя: это тот же сайт в отдельном окне.

<div class="shareding-gallery">
  <a href="https://static.juev.org/2026/10/freshgo-phone-list.png"><img src="https://static.juev.org/2026/10/freshgo-phone-list.png" alt="Список статей freshgo на телефоне" width="390" height="844" loading="lazy"><span>Список</span></a>
  <a href="https://static.juev.org/2026/10/freshgo-phone-entry.png"><img src="https://static.juev.org/2026/10/freshgo-phone-entry.png" alt="Раскрытая статья freshgo на телефоне" width="390" height="844" loading="lazy"><span>Статья</span></a>
  <a href="https://static.juev.org/2026/10/freshgo-phone-tree.png"><img src="https://static.juev.org/2026/10/freshgo-phone-tree.png" alt="Дерево подписок freshgo на телефоне" width="390" height="844" loading="lazy"><span>Подписки</span></a>
</div>

**Картинки через сервер.** Обычно картинки статьи браузер грузит с тех сайтов, где они лежат, и эти сайты видят адрес читателя и время, когда он открыл статью. freshgo может отдавать картинки со своего адреса. Схему я взял у miniflux[^5]: адреса подменяются в момент показа, каждый подписан, и сервер загружает только то, что сам вставил в статью. Режимов три: все картинки, только те, что отдаются по `http` (их страница на `https` иначе не покажет вовсе), и никакие. Картинки нигде не сохраняются, сервер передаёт их насквозь. Работает это и в веб-интерфейсе, и для клиентов Google Reader API.

**Полный текст.** Многие ленты отдают только анонс. FreshRSS умеет забирать текст со страницы статьи по CSS-селектору, и селектор для каждой ленты надо написать самому. В freshgo к этому добавились три вещи:

- статья находится на странице без селектора, так же, как её находит режим чтения в браузере;
- текст можно запросить для одной статьи клавишей `f`, что бы ни было настроено у ленты;
- страницы ленты может читать настоящий браузер.

Третий пункт появился из-за сайтов за проверкой Cloudflare. Лента у `openai.com` читается, а на запрос страницы статьи сервер получает 403 и «Just a moment…», напрямую и через прокси, с User-Agent и curl, и Firefox[^6]. Теперь рядом с freshgo можно запустить браузер с протоколом DevTools и отметить ленты, страницы которых читает он. Из того, что я пробовал, самым лёгким оказался [Lightpanda](https://lightpanda.io): около 30 МиБ в простое и несколько сотен, пока он читает страницу. Проверку, которая требует щёлкнуть или собрать картинку, так не пройти; статья в этом случае остаётся с текстом из ленты.

**Перевод.** У раскрытой статьи есть действие «Перевести», клавиша `T`. Переводит языковая модель: подходит любой сервис с API, совместимым с OpenAI, адрес, ключ и модель задаются тремя настройками сервера. Текст уходит в модель по абзацам и появляется на экране по мере готовности. Разметку модель не видит совсем: ссылки, картинки и код заменяются номерными метками и возвращаются на место без изменений. Готовый перевод хранится вместе со статьёй, второй раз за него платить не придётся. Помнить стоит одно: текст статьи при каждом запросе перевода уходит с вашего сервера в тот сервис, который вы назвали.

**WebSub.** Если лента объявляет хаб, freshgo подписывается на него, и новые статьи приходят в момент публикации; сама лента после этого опрашивается раз в сутки. В первой версии подписка зависела от опроса: пока документ ленты не менялся, сервер к хабу не обращался, и редко обновляемая лента могла неделями оставаться без подписки. Теперь адрес хаба хранится вместе с лентой, читается и из заголовков `Link`, а подписки продлеваются по сроку[^7].

Остальное перечислю коротко: вход через OpenID Connect, один прокси на все ленты с возможностью задать лентам свой, управление с клавиатуры с палитрой команд по `Ctrl+K`.

## Что показали замеры

Писать на Go ради того, чтобы было на Go, смысла мало, поэтому я попросил сравнение. Оба сервера работали на одной машине с одними и теми же подписками: один пользователь, 84 ленты, около 6000 статей, база в SQLite[^8].

| | FreshRSS 1.30.1 | freshgo |
| --- | ---: | ---: |
| Процессорное время за сутки | 109 с | 95 с |
| Память без дискового кэша, средняя / наибольшая | 116 / 178 МиБ | 42 / 64 МиБ |
| Образ контейнера | 384 МБ | 38,3 МБ |
| Данные на диске | 189 МиБ | 49 МиБ |
| Записано на диск за сутки | 1,2 ГиБ | 0,54 ГиБ |

Процессора оба тратят одинаково мало: время обновления лент уходит на ожидание чужих сайтов, а не на вычисления. Памяти freshgo нужно втрое меньше. На диске разница вчетверо, и почти вся она в кэше: FreshRSS хранит копию каждого загруженного документа ленты, freshgo запоминает только `ETag` и `Last-Modified`. Сами статьи занимают в обеих базах одинаково.

Со скоростью ответов получилось интереснее. В первом замере freshgo версии 0.2.3 проиграл оригиналу именно там, где клиент проводит время при синхронизации:

| Запрос | FreshRSS | freshgo 0.2.3 | freshgo 0.2.7 |
| --- | ---: | ---: | ---: |
| `stream/contents`, 50 статей | 4,6 мс | 7,3 мс | 3,3–3,7 мс |
| `stream/contents`, 200 статей | 13,8–18,4 мс | 25,7 мс | 11,3–11,7 мс |
| Список непрочитанных | 1,5 мс | 9,7 мс | 0,7 мс |
| Главная страница, 20 непрочитанных | 8,8 мс | 10,2 мс | 5,3–5,8 мс |
| `stream/contents`, 8 соединений | 694 запроса/с | 426 запросов/с | 1118–1125 запросов/с |

Причины нашлись обычные. Список непрочитанных открывал каждую строку пользователя ради одного флага. Текст статьи чистился заново при каждом показе. Адреса картинок подменялись через полный разбор HTML. После индексов, чистки текста при записи и подмены адресов без разбора документа freshgo отвечает быстрее FreshRSS на девяти запросах из десяти и наравне на одном.

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

## Чего ждать

- Проекту меньше недели. За это время вышло двенадцать версий, считая три предварительные; последняя 0.2.7.
- Проверен он одной установкой, моей: один пользователь, SQLite. PostgreSQL поддерживается и покрыт тестами, но в сравнении не участвовал.
- Импорт читает FreshRSS на SQLite и PostgreSQL. MySQL и MariaDB он не читает.
- PHP-расширения FreshRSS не работают. Точки расширения есть, но обработчики для них собираются вместе с сервером.
- Часть поведения отличается от оригинала намеренно, список есть в README[^9].
- Это независимый проект. Вопросы по нему авторам FreshRSS задавать не нужно.

Теперь на моём сервере работает freshgo, и я могу рекомендовать его вам. Код и инструкции лежат в репозитории [juev/freshgo](https://github.com/juev/freshgo), образ опубликован как `ghcr.io/juev/freshgo`. Если что-то работает не так или чего-то не хватает, заведите issue.

[^1]: [Miniflux on Heroku](/2020/12/20/miniflux-on-heroku/).
[^2]: [Настройка Docker для работы с локальным прокси-сервером](/2025/01/20/docker-localhost/): заметка времён переноса FreshRSS на свой сервер.
[^3]: [История коммитов juev/freshgo](https://github.com/juev/freshgo/commits/main).
[^4]: [testdata/reference](https://github.com/juev/freshgo/tree/main/testdata/reference) и спецификации в [docs/specs](https://github.com/juev/freshgo/tree/main/docs/specs).
[^5]: [Issue #4](https://github.com/juev/freshgo/issues/4): разбор того, как отдаёт картинки miniflux, и что из этого взято.
[^6]: [Issue #55](https://github.com/juev/freshgo/issues/55): что было испробовано против проверки Cloudflare и сколько памяти занимает каждый вариант.
[^7]: [Issue #18](https://github.com/juev/freshgo/issues/18).
[^8]: [freshgo and FreshRSS measured side by side](https://github.com/juev/freshgo/pull/77) — условия, полные таблицы и способ повторить. Процессор, память и диск измерены у версии 0.2.3, время ответов у версии 0.2.7.
[^9]: Раздел [What behaves differently](https://github.com/juev/freshgo#what-behaves-differently).

