Як ми змінили алгоритм Стрічки на Друкарні і стартап залучився новим інтересом читачів

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

Стартап був створений українцями в першу чергу для українців як альтернатива іноземним блог-платформам штибу 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, але дозволяють скласти загальне уявлення про складну будову запиту для формування Стрічки рекомендацій на «Друкарні».

Результати змін та висновки

Внесені зміни дозволили отримати більш прогнозовані результати в Стрічці рекомендацій. За основу береться час створення статті, тому типово найновіші дописи будуть на початку Стрічки.

Тепер всі взаємодії зі статею додають їй бали до ранжування і вгорі Стрічки можуть затриматись більш популярні дописи, які жваво обговорюються в коментарях, або до яких залишили багато вподобайок.

Покращене збереження колекції Стрічки в БД дозволило боротись зі спамом та виключати надокучливий контент в місцях загального користування платформою.

Раніше, до внесених змін, вгорі Стрічки міг показуватись найбільш релевантний контент, якому було понад 2-3 роки. Наразі ж, ми надали пріоритет новим статтям, щоб кожен читач міг спочатку ознайомитись з релевантним і новішим контентом на відповідну тему. Спробуйте підписатись на кілька цікавих тем, залиште вподобайки цікавим авторам і Ваша стрічка миттєво зміниться і запропонує Вам ще більше релевантного контенту.

Такі зміни дали свій результат: Стрічка стала більш зручною та зрозумілою відвідувачам. Найновіше — вгорі, релевантність — за вподобаннями та підписками. Відвідуваність блог-платформи збільшується, автори створюють більше цікавих матеріалів та з легкістю знаходять однодумців.

Далі буде ...

Команда WE.UA продовжує роботи над Друкарнею і в найближчій перспективі плануються наступні цікаві зміни:

  • збірки довгочитів з можливістю ексопрту в PDF та друку в партнерських видавництвах;
  • імпорт RSS в свої блоги з інших платформ;
  • платні підписки користувачів та монетизація за прочитання авторам;
  • більше можливостей для персоніфікації власних блогів;
  • створення додаткових покращень інтерфейсу та платформи.
👍ПодобаєтьсяСподобалось1
До обраногоВ обраному1
LinkedIn
Ctrl + Enter
Ctrl + Enter

Drukarnia.com.ua — чудове місце, щоб розповісти про свій стартап, чи свою творчість.

Якщо Ви досі не маєте власного блогу на Друкарні, час його створити: drukarnia.com.ua

Підписатись на коментарі