0% прочитано

Laravel: чому Seen і Read - це різні стани для персоналізованої News-картки

Показати News на Home, відкрити її, прочитати та зарахувати analytics view - це різні події. Розбираю, чому персоналізований seen state має належати відомому користувачу, а для гостя не варто створювати постійний профіль через IP, cookie або fingerprint.

25 вересня 2026 р. 9 хв читанняЛаравель

Коли я додала News у ICanUp, з’явилося просте продуктове питання: яку новину показувати користувачу помітніше на Home?

Для авторизованого користувача логічно не показувати одну й ту саму News як "нову" нескінченно. Але тут дуже легко зробити неправильний висновок: якщо картку вже показали, це не означає, що новину прочитали.

Ще важливіше не переносити цю персоналізацію автоматично на гостей через IP, cookie або fingerprint. Відсутність ідентифікації облікового запису - це не проблема, яку обов’язково потрібно виправляти прихованою ідентифікацією.

У цій статті seen - це продуктовий стан персоналізації. Він не є поточною Analytics-метрикою ICanUp і не повинен виводитися з дедублікованих переглядів.

Shown, Seen, Opened, Read і View відповідають на різні питання

Проблема починається, коли всі ці слова використовуються як синоніми.

Назва стану новини

Що він означає

Чого він не доводить

shown

News потрапила у відповідь або була відрендерена як картка.

Що користувач реально звернув на неї увагу.

seen

Продукт вирішив вважати цю News уже показаною конкретному відомому користувачу.

Що користувач відкрив або прочитав матеріал.

opened

Користувач перейшов на сторінку News.

Що він дочитав її до кінця.

read

Сильніша продуктова семантика завершеного читання, якщо продукт її явно визначає.

Її не можна автоматично отримати лише з факту відкриття сторінки.

view

Analytics зафіксував прийнятний перегляд публічного контенту.

Що це унікальна людина або що матеріал був прочитаний.

Якщо два стани змінюють різну поведінку продукту, їм краще мати різні назви й різні правила.

View у ICanUp виникає після відкриття public News

У поточній реалізації ICanUp public News tracking знаходиться на сторінці самої новини.

ShowNewsAction спочатку отримує доступну News через публічний сервіс і лише для реально публічної новини передає subject у PublicContentViewTracker.

Тобто сама присутність News-картки на Головній не є переглядом сторінки новини.

PHP - News view starts in ShowNewsAction
public function __invoke(    Request $request,    News $news,): Response {    $news = $this->newsService->getNews(        $news,        $request->user()?->can(            PermissionEnum::NEWS_PREVIEW->value        ) ?? false,    );     if ($news->isPubliclyVisible()) {        $this->publicContentViewTracker->track(            $request,            AnalyticsSubject::entity(                ContentSubjectTypeEnum::NEWS->definition(),

Seen не повинен бути псевдонімом Analytics view

Analytics і персоналізація мають різні цілі.

Analytics відповідає на агреговані питання: скільки було переглядів, яка динаміка, які типи контенту читають, які канали приводять трафік.

Персоналізація відповідає на інше питання: що показати конкретному користувачу зараз?

Якщо використовувати історію аналітики як продуктовий стан, ці два контракти починають протікати один в одного. Зміна дедуплікрваного вікна може несподівано змінити поведінку Головної, а очистка історії аналітики - повернути вже показані новини.

Deduplicated view теж не означає Read

У ICanUp кожен прийнятий перегляд спочатку записується як звичайне вимірювання. Далі окрема deduplication стратегія вирішує, чи потрібно додати супутній показник deduplicated_views.

Це корисно для статистики, але семантика залишається аналітичною. Дедублікований перегляд не перетворюється на доказ того, що конкретна людина прочитала новину.

PHP - Views and deduplicated views are separate measurements
public function track(    TrackAnalyticsInput $input,): ?AnalyticsDeduplicationDecisionEnum {    $this->history->record(        $input->measurement()    );     $context =        $input->deduplication();     if ($context === null) {        return null;    }     $decision =        $this->deduplication->claim(

Для авторизованого користувача stable identity вже існує

Для авторизованого користувача ситуація інша. Система вже знає, кому належить персоналізований state.

Якщо продукту потрібно не повторювати одну й ту саму новину на Головній, можна мати окрему історію контактів для user + news. Вона відповідає лише за персоналізацію.

  • Новину показали у виділеній позиції - можна змінити seen.

  • Користувач відкрив новину - це окремий факт.

  • Analytics записав перегляд - це окрема система вимірювання.

  • Якщо колись з’явиться реальний read contract, він теж не повинен виникати автоматично з seen.

Так продукт може вибрати наступну непереглянуту новину без змішування personalization state з Analytics.

Для гостя я не хочу створювати News history через fingerprint

Для гостя немає account identity. Можна було б спробувати створити її заміну: cookie, IP-based key, browser fingerprint або інший довгоживучий ідентифікатор.

Але для задачі "покажи мені іншу новину" це надто велика ціна.

Я віддаю перевагу слабшій персоналізації: гість може отримати актуальну публічну News без постійної історії того, що саме йому колись показували. Після авторизації вже з’являється нормальний owner для персонального state.

Authenticated and guest personalization boundary
Authenticated user    |    +--> stable account identity exists    |    +--> product may remember News exposure    |    +--> next Home request can prefer unseen News  Guest    |    +--> no account identity    |    +--> do not create persistent News-seen profile    |    +--> choose eligible News without durable guest history
Це не означає, що ICanUp взагалі не використовує privacy-safe fingerprint для Analytics. Він існує для обмеженої дедублікації. Важлива межа в іншому: не перетворювати analytics deduplication key на довгоживучу guest identity для персоналізації.

Analytics fingerprint має вузьку відповідальність

PublicContentViewTracker передає privacy-safe visitor value у deduplication context разом із subject і locale.

Цей механізм потрібен, щоб не рахувати кожен повторний запит як новий deduplicated view у межах deduplication window.

Він не повинен відповідати на питання "яку Новину цьому гостю показували минулого тижня?". Це вже інший продуктовий контракт.

PHP - Analytics fingerprint is used for bounded deduplication
return $this->tracker->track(    TrackAnalyticsInput::withDeduplication(        measurement: $measurement,        deduplicatedMetric:            ContentMetricEnum::DEDUPLICATED_VIEWS->metric(),        visitor:            $this->fingerprints->forRequest($request),        scope:            AnalyticsDeduplicationScope::subjectAndLocale(                $locale            ),    ));

Чому не варто ставити seen у момент SQL select

Є ще одна важлива межа між вибором News і зміною state.

Query, який шукає "найновішу непереглянуту новину", не повинен автоматично означати, що користувач її вже побачив.

Причин кілька:

  • SSR може сформувати відповідь, яку користувач фактично не побачить.

  • Попередня вибірка не означає реальний показ.

  • Помилка frontend після відповіді не означає успішну експозицію.

  • Повторний server render не повинен безконтрольно спалювати unseen queue.

Тому вибір і контакт краще вважати двома різними діями, навіть якщо в конкретному UI вони відбуваються дуже близько одна до одної.

Read потребує власного визначення

Слово read виглядає очевидним, але технічно воно найбільш неоднозначне.

Чи достатньо відкрити сторінку? Провести на ній 10 секунд? Доскролити до 80%? Побачити останній ContentBlock? Натиснути "позначити прочитаним"?

Поки продукт не має чіткої відповіді, я б не створювала автоматичний News read state. Краще мати точний opened або analytics view, ніж назвати "прочитаним" те, чого система насправді не знає.

Не кожен стан, який технічно можна записати, варто називати тим, чого система насправді не знає.

Яку межу я залишаю між продуктом і Analytics

Потреба

Де їй місце

Не повторювати одну й ту саму News авторизованому користувачу

Окремий user-owned personalization state

Показати public News гостю

Public selection без durable guest profile

Порахувати відкриття News

Public Content Analytics

Прибрати повтори з аналітичної метрики

Bounded analytics deduplication

Знати, що News справді прочитана

Окремий read contract, тільки якщо продукт його чітко визначить

Висновок: privacy теж є частиною продуктової архітектури

У цьому кейсі мені подобається, що маленька вимога до News-картки змушує чітко розділити кілька відповідальностей.

seen потрібен продукту для персоналізації відомого користувача. view потрібен Analytics для вимірювання. read має з’являтися тільки тоді, коли продукт справді може визначити, що це означає.

А для гостя не обов’язково створювати нову приховану identity лише тому, що персоналізований алгоритм міг би використати ще один сигнал.

Іноді правильна архітектура персоналізації полягає не в тому, щоб зібрати більше state, а в тому, щоб свідомо не створювати його там, де він не потрібен.