Як ми змінили алгоритм Стрічки на Друкарні і стартап залучився новим інтересом читачів
Друкарня — популярна українська блог-платформа, що робить акцент на популяризації української мови та україномовних довгочитів у Мережі. На «Друкарні» немає російської мови в інтерфейсі, чи в статтях.
Стартап був створений українцями в першу чергу для українців як альтернатива іноземним блог-платформам штибу Medium, чи Habr.

Починаючи з листопада 2025 року «Друкарнею» володіє моя компанія WE.UA. То ж, я хочу розповісти про досвід змін та покращень, яких зазнала Drukarnia за останній час.
Технічна сторона: загальні моменти
Варто віддати належне попередньому розробнику Друкарні, оскільки ця блог-платформа втілює передовий досвід та використовує сучасні технології. Бек — NestJS з MongoDB, фронт — Nuxt 3. Використано найкращі практики з GitLab CI/CD та AWS. Хостинг зображень — Bunny.net. Власний TG чат-бот на NestJS та розсилки релевантних довгочитів всім підписниками в Telegram тричі на тиждень.
Загалом, блог-платформа «Друкарня» працює як і всі блоги: Автор — пише, Відвідувач — читає. Опубліковані статті доступні в профілях авторів (в їх особистих блогах) та через пошук.
Але Друкарня не була б такою популярною, як би не мала важливих складових, що функціонально наближають її радше до соціальної мережі: Стрічка рекомендацій, пошук за темами, коментарі та спеціальні вподобайки (оплески).
Тезисно про клієнтський функціонал на front-end:
- профіль користувача (блог) зі списком статей та тем, на які пише автор;
- швидка авторизація через Google;
- WYSIWYG редактор Tiptap;
- миттєві сповіщення через WebSockets;
- підписки на авторів та окремі теми;
- додавання статей в список «закладинок» для подальшого прочитання;
- до 5 тем в кожній статті та навігація за темами;
- коментарі до кожної статті;
- статистика та гейміфікація (хто набере більше прочитань та відвідувань);
- темна та світла теми інтерфейсу;
- адаптивний дизайн (зручно користуватись сайтом з мобільних);
- автоматичне збереження чернеток;
- багатий пошук (текст, автор, тема);
- SEO-дружній процес публікації статті (вказування
title+description+og:image+canonical+ інше) та швидка індексація в Google;
Стрічка рекомендацій на «Друкарні»

Замість тисячі слів — drukarnia.com.ua/feed. Стрічка складається з коротких тизерів опублікованих статей з заголовком, описом та зображенням. Відвідувачам без авторизації показуються 20 найновіших дописів у зворотній хронології (найновіше — вгорі). З авторизованими користувачами — все складніше, адже Стрічка враховує теми та авторів, на яких користувач підписаний та теми, на які користувач позитивно реагував вподобайками, хоча ще не підписався на них.
Головний задум полягав в тому, щоб кожен з читачів легко знаходив щось своє, щось цікаве та релевантне до його вподобань.
Технічна сторона: як працює Стрічка
Стрічка являє собою окрему колекцію в MongoDB , куди періодично збираються та копіюються тизери опублікованих статей. Ми говоримо про «тизери», адже в такій колекції було б дуже проблематично зберігати всі тексти повністю. До того ж, під час публікації Автор обовʼязково вказує короткий опис матеріалу, що було б доцільно використовувати не лише в META-тезі description, а й в Стрічці також.
Мабуть, найцікавіша частина, заради якої Ви швидко догортали сюди — це приклад того ЯК здійснюється вибірка та ранжування матеріалів для Стрічки:
db.collection('articles').aggregate([
{
$match: {
readTime: { // readTime - це орієнтовний час в секундах, який необхідний для прочитання статті
$gt: 30 // Зменшили поріг входження для коротких статей та поезії
},
$or: [
{pr: true}, // Замовна стаття в розділі "Піар"
{createdAt: { $gte: twoYearsAgo }} // Або опублікована менше 2 років тому
],
$and: [
{title: {$not: {$regex: badWords, $options: 'i'}}}, // Не містить поганих слів в заголовку
{description: {$not: {$regex: badWords, $options: 'i'}}}, // Не містить поганих слів в описі
{title: {$regex: '[а-яіїєґ]{4,}', $options: 'i'}}, // Містить слова українською в заголовку
{description: {$regex: '[а-яіїєґ]{4,}', $options: 'i'}}, // Містить слова українською в описі
{noindex: {$ne: true}} // Не виключена через спец.поле `noindex`
],
},
},
// Далі йде приєднання даних з колекції користувачів
{
$lookup: {
from: "users",
let: {
owner: "$owner",
},
pipeline: [
{
$match: {
$expr: {
$eq: ["$$owner", "$_id"],
},
},
},
{
$project: {
name: 1,
avatar: 1,
username: 1,
},
},
],
as: "owner",
},
},
// Перелік тегів з їх назвами
{
$lookup: {
from: "tags",
localField: "tags",
foreignField: "_id",
as: "tags",
},
},
// Далі перераховуються поля, які потраплять в колекцію `feed`
{
$project: {
title: 1, // Заголовок
description: 1, // Опис
owner: { // Обʼєкт, що стосується автора
$first: "$owner",
},
tags: 1, // Обʼєкт з тегами
sensitive: 1, // Ознака "18+"
slug: 1, // Унікальний Url-аліас статті
createdAt: 1, // Дата створення
mainTagId: 1, // Ідентифікатор головного (першого) тегу статті
mainTag: 1, // Головний (перший) тег статті
mainTagSlug: 1, // Аліас для побудови посилання на пошук за головним тегом статті
thumbPicture: 1,// Головне зображення статті
likeNum: 1, // Кількість вподобайок
readNum: 1, // Кількість відвідувань статті
fullReadNum: 1, // Кількість повних прочитань статті
commentNum: 1, // Кількість коментарів
readTime: 1, // Орієнтовний час, необхідний для прочитання статті
impressionNum: 1,
pr: 1, // Ознака приналежності до розділу "Піар"
canonical: 1, // Канонічне посилання на інший сайт (рекомендовано для передруків)
seconds: {$toInt: {$round: [{$divide: [{ $toLong: "$createdAt" }, 1000]}, 0]}},
// Найцікавіше - бали "популярності"
popularityPoints: {
$sum: [ // Рахується сума величин
// За основу та в цілому береться к-ть секунд від 1970-01-01 за датою створення статті
{$toInt: {$round: [{$divide: [{ $toLong: "$createdAt" }, 1000]}, 0]}},
{$multiply: ["$likeNum", 300]}, // К-ть вподобайок множимо на 300 (+5 хв за кожну вподобайку)
{$multiply: ["$commentNum", 300]}, // К-ть коментарів множимо на 300 (+5 хв за кожен коментар)
{$multiply: ["$readNum", 120]}, // К-ть відвідувань множимо на 120 (+2 хв за кожен перегляд)
{$multiply: ["$fullReadNum", 300]}, // К-ть повних прочитань множимо на 300 (+5 хв/прочитання)
{$multiply: ["$bookmarkedNum", 300]}, // К-ть додавань в закладинки * 300 (+5 хв за кожну закладинку)
{$multiply: [{$toInt:"$pr"}, 259200]} // Тримаємо вгорі Стрічки замовні матеріали протягом 3 днів
],
},
},
}
]);
В прикладі коду упущено значення змінних badWords та кількох змінних з різними часовими мітками: twoYearsAgo, weekAgo. Їх значення — очевидні та не потребують додаткового обговорення в рамках даної статті.
Періодичне збирання тизерів статей в окрему колекцію в базі даних — це лише верхівка айсбергу. Але це — дуже важливий крок, який дозволив ранжувати статті по новому і показувати новим відвідувачам та користувачам без підписок на теми найновіші матеріали вгорі Стрічки.
Також, це дозволило тримати вгорі певний час матеріали з розділу «Піар», що дуже корисно для рекламодавців. Іноді, ми також розміщуємо власні матеріали в «Піар», якщо потрібно донести певні новини платформи до користувачів та відвідувачів.
Довелося ввести в статтях параметр noindex , щоб в Стрічку перестали потрапляти певні матеріали від спамерів, примітивний crowd-маркетинг, чи публікації недобросовісних авторів. Ознака `noindex` не означає, що стаття та автор будуть видалені з платформи, а лиш те, що вони не потраплять в загальну Стрічку. Спамери та недобросовісні автори і надалі можуть писати в своїх блогах, але ми не гарантуємо, що про них дізнається широка аудиторія.
Ми вітаємо на нашій платформі різноманітний само-піар
Добре, коли талановиті українці розповідають про власні досягнення в різних галузях: в бізнесі, в розробці, в написанні чи прочитанні художніх творів. РОЗКАЗУЙТЕ ПРО СЕБЕ НА ДРУКАРНІ ЯКОМОГА БІЛЬШЕ!
Але бувають і не добросовісні автори, які не розуміють що зовнішні посилання на сторонні сайти закриті тегом rel="noopener noreferrer nofollow" і не принесуть жодної користі від так званого «сарафанного радіо», але наполегливо публікують відверто замовні матеріали про чужі компанії, інтернет-магазини чи чужу приватну справу. Такі замовні матеріали створюють виключно лише інформаційний шум і можуть бути (обовʼязково будуть) виключені від індексації пошуковими системами та від потрапляння в загальну Стрічку рекомендацій.
Як працює Стрічка для авторизованих користувачів
Найкладніша підводна частина айсбергу — це складний запит для отримання релевантних матеріалів в Стрічці рекомендацій на основі кількох факторів:
- відписку (бан) від певних користувачів;
- відписку (бан) певних тем;
- підписки користувача на певні теми;
- підписки користувача на певних авторів;
- вподобання статей на певні теми, що не входять до п.1;
popularityPointsз колекції Стрічки;
Розглянемо найцікавіші фрагменти на TypeScript в побудові запиту:
pipeline
.addFields({
// Вводиться додаткове поле `articles.owner`
'articles.owner': {
$let: {
vars: {
// Оголошується додаткова змінна `relAuthor` - на основі owner._id кожної статті
relAuthor: {
$first: {
$filter: {
input: '$authors', // Обʼєкт `authors` оголошується раніше, містить авторів на яких підписались, або яких додали в бан
as: 'item',
cond: {
$eq: ['$$item.secondUser', '$articles.owner._id'],
},
limit: 1,
},
},
},
},
in: {
_id: '$articles.owner._id',
name: '$articles.owner.name',
avatar: '$articles.owner.avatar',
username: '$articles.owner.username',
isSubscribed: '$$relAuthor.isSubscribed', // береться значення isSubscribed від $authors
points: {
$cond: {
// Якщо автор - заблокований, дається статті -316224000 умовних "балів", щоб стаття не потрапила у Стрічку
if: {
$eq: ['$$relAuthor.isBlocked', true],
},
then: -316224000, // -10 years in seconds
else: {
$cond: {
// Інакше, якщо підписані на автора, його стаття буде піднята в Стрічці на 7 днів, щоб не пропустити цікаве
if: '$$relAuthor.isSubscribed',
then: 604800, // +7 days visibility for articles of subscribed authors
else: 0,
},
},
},
},
},
},
},
// Вводиться додаткове поле `articles.tags`
'articles.tags': {
$sortArray: {
input: {
$map: {
input: '$articles.tags',
in: {
$let: {
vars: {
relTag: {
$first: {
$filter: {
input: '$tags',
as: 'item',
cond: {
$eq: ['$$item._id', '$$this._id'],
},
limit: 1,
},
},
},
},
in: {
_id: '$$this._id',
name: '$$this.name',
slug: '$$this.slug',
isSubscribed: '$$relTag.isSubscribed',
points: {
$cond: {
// Якщо від теми відписались ("не показувати тему") - статті дається -316224000 умовних "балів"
if: '$$relTag.isBlocked',
then: -316224000, // -10 years in seconds
else: {
$cond: {
// Якщо підписані на одну з тем статті - додається 359200 умовних "балів"
if: '$$relTag.isSubscribed',
then: 259200, // +3 days visibility for articles with subscribed tag
else: {
$cond: {
if: {
$lt: ['$$relTag.strength', 0]
},
// Multiply `strength` to 1 year in seconds for don't see excluded tags in the feed
then: {$multiply: ['$$relTag.strength', 31622400]},
else: 0,
}
}
},
},
},
},
},
},
},
},
},
// Сортування, щоб напочатку були підписані теми і статті, які набрали найбільше "балів"
sortBy: {
isSubscribed: -1,
points: -1,
},
},
},
});
Далі додаються ще кілька полів:
pipeline
.addFields({
// Бали за підписку - сума всіх балів за теми, на які підписані, або відписані від них
'articles.preferencePoints': {
$sum: [
'$articles.popularityPoints',
'$articles.owner.points',
{
$reduce: {
input: '$articles.tags',
initialValue: 0,
in: {
$add: [
'$$value',
{
$cond: {
if: {
$eq: ['$$this.points', null],
},
then: 0, // +1 day per each of subscribed tag
else: { $toInt: '$$this.points' }, // -1 year per each of unsubscribed tags
},
},
],
},
},
},
],
},
// Причина підняття статті в Стрічці: підписка на тему, або підписка на автора
'articles.preferenceReason': {
$cond: {
if: {
$eq: [
{
$getField: {
field: 'isSubscribed',
input: { $arrayElemAt: ['$articles.tags', 0] },
},
},
true,
],
},
then: {
name: 'tag:subscribed',
tagId: {
$getField: {
field: '_id',
input: {
$first: '$articles.tags',
},
},
},
},
else: {
$cond: {
if: {
$eq: ['$articles.owner.isSubscribed', true],
},
then: {
name: 'user:subscribed',
},
else: {
name: alternate ? 'tag:similar' : 'tag:like',
tagId: {
$getField: {
field: '_id',
input: {
$first: '$articles.tags',
},
},
},
},
},
},
},
},
});
Далі накладається умова, щоб були показані статті від 1 Січня 2022 року
pipeline
.match({
'articles.preferencePoints': {
$gt: 1640995200,
},
})
Встановлення $articles як головного набору даних та сортування за обрахованими `preferencePoints` (за спаданням) та збереженими раніше в базі `popularityPoints` (теж за спаданням)
pipeline
.replaceRoot('$articles')
.sort({ preferencePoints: -1, popularityPoints: -1 })
Наведені фрагменти не є вичерпними та не містять структури додаткових колекцій та обʼєкту $authors, але дозволяють скласти загальне уявлення про складну будову запиту для формування Стрічки рекомендацій на «Друкарні».
Результати змін та висновки
Внесені зміни дозволили отримати більш прогнозовані результати в Стрічці рекомендацій. За основу береться час створення статті, тому типово найновіші дописи будуть на початку Стрічки.
Тепер всі взаємодії зі статею додають їй бали до ранжування і вгорі Стрічки можуть затриматись більш популярні дописи, які жваво обговорюються в коментарях, або до яких залишили багато вподобайок.
Покращене збереження колекції Стрічки в БД дозволило боротись зі спамом та виключати надокучливий контент в місцях загального користування платформою.
Раніше, до внесених змін, вгорі Стрічки міг показуватись найбільш релевантний контент, якому було понад
Такі зміни дали свій результат: Стрічка стала більш зручною та зрозумілою відвідувачам. Найновіше — вгорі, релевантність — за вподобаннями та підписками. Відвідуваність блог-платформи збільшується, автори створюють більше цікавих матеріалів та з легкістю знаходять однодумців.
Далі буде ...
Команда WE.UA продовжує роботи над Друкарнею і в найближчій перспективі плануються наступні цікаві зміни:
- збірки довгочитів з можливістю ексопрту в PDF та друку в партнерських видавництвах;
- імпорт RSS в свої блоги з інших платформ;
- платні підписки користувачів та монетизація за прочитання авторам;
- більше можливостей для персоніфікації власних блогів;
- створення додаткових покращень інтерфейсу та платформи.
2 коментарі
Додати коментар Підписатись на коментаріВідписатись від коментарівDrukarnia.com.ua — чудове місце, щоб розповісти про свій стартап, чи свою творчість.
Якщо Ви досі не маєте власного блогу на Друкарні, час його створити: drukarnia.com.ua