
3
Счетчик просмотров в Bolt CMS
Про Bolt CMS я, возможно, еще напишу отдельно, пока займемся улучшениями.
Изначально задача была простой: добавить счетчик просмотров для статей. Но хотелось также решить несколько важных вопросов:
- Избежать накрутки просмотров
- Отфильтровать ботов
- Обойтись малой кровью - без внешних сервисов и дополнительных систем вроде redis/memcached/etc.
Варианты
1. Просто счетчик
Добавляем поле к сущности записи и инкрементируем при каждом запросе детальной страницы записи.
Минимум усилий, но каждое обновление будет приводить к увеличению счетчика. Зажав F5 можно накрутить какое угодно значение.
2. Защита на клиентской стороне
Фиксируем просмотры статей на стороне клиента - cookies или localStorage. Запрос на учет счетчика можно отправлять через js со страницы статьи - таким образом отсечем просмотры статей ботами/краулерами, которые не рендерят страницу полностью и не исполняют js.
Усилий чуть больше, обычное зажатие F5 уже просмотры не увеличивает, но хитрые пользователи (зачем им это может понадобиться отдельный вопрос) могут отключить куки или очищать localStorage и опять же нагенерировать просмотров.
Помимо клиентов это могут делать и боты, поэтому идем дальше.
3. Защита по IP
Фиксируем уникальные для IP адреса просмотры статей. Никакой более подходящей системы, чем база данных, у нас нет - поэтому ведем отдельную таблицу, в которую пишем IP адрес, id записи и дату просмотра. Периодически можно удалять эти записи, чтобы открытие статьи через час / день / неделю добавляло новый просмотр.
Есть еще и другие варианты - использование невидимой капчи на страницах для фиксации просмотра, получение количества просмотров из внешней аналитики по API, но они как-то слишком избыточны, так что рассматривать их здесь мы не будем. Также как и не будем рассматривать вопрос оптимизации нагрузки - личные блоги обычно не слишком нагружены и кешировать запросы к БД нет особой необходимости.
Дополнительно можем исключить из учета посещения ботов - они обычно указывают свои отдельные User-Agent заголовки, по информации в которых мы можем исключить такие запросы.
В общем и целом цель сбора просмотров - получить наиболее адекватные числа без накруток.
Я остановился на этом варианте, период для себя установил в сутки - т.е. просмотр статьи с одного IP адреса фиксируется только 1 раз.
Шаг 1. Добавление поля для просмотров
Bolt CMS написан на symfony, дальнейшие этапы будут в целом тривиальны для symfony разработчиков. С symfony я достаточно тесно знаком по работе, но здесь будет супер тривиальное решение, по сложности сопоставимое с моим расширением для комментариев в bolt.
Первым шагом добавляем поле views
в конфигурацию ContentType. Это обычный числовой счетчик записей.
В Bolt это делается без миграций, просто добавлением поля в конфигурацию:
# config/bolt/contenttypes.yaml
posts:
name: Posts
singular_name: Post
fields:
# ... другие поля ...
views:
type: number
mode: integer
default: 0
group: Meta
readonly: true
Шаг 2: Создание сущности для хранения просмотров
Для отслеживания уникальных просмотров создаем сущность ArticleView:
<?php
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
/**
* @ORM\Entity
* @ORM\Table(name="article_views")
*/
class ArticleView
{
/**
* @var int
* @ORM\Id
* @ORM\GeneratedValue(strategy="AUTO")
* @ORM\Column(type="integer")
*/
protected $id;
/**
* @var string
* @ORM\Column(type="string", length=45)
*/
protected string $ip;
/**
* @var int
* @ORM\Column(name="content_id", type="integer")
*/
protected int $contentId;
/**
* @var \DateTime
* @ORM\Column(type="datetime")
*/
protected \DateTime $viewedAt;
public function __construct(string $ip, int $contentId)
{
$this->ip = $ip;
$this->contentId = $contentId;
$this->viewedAt = new \DateTime();
}
// ... геттеры ...
}
Шаг 3: Контроллер для подсчета просмотров
Создаем контроллер, который обрабатывает AJAX-запросы для увеличения счетчика:
<?php
namespace App\Controller;
use App\Entity\ArticleView;
use Bolt\Entity\Content;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
class ViewCounterController extends AbstractController
{
protected EntityManagerInterface $em;
/**
* Common bot user agent keywords
*/
protected array $botKeywords = [
'bot', 'crawler', 'spider', 'slurp',
// ... другие ключевые слова ...
];
public function __construct(EntityManagerInterface $em)
{
$this->em = $em;
}
protected function isBot(string $userAgent): bool
{
$userAgent = strtolower($userAgent);
foreach ($this->botKeywords as $keyword) {
if (str_contains($userAgent, $keyword)) {
return true;
}
}
return false;
}
#[Route('/async/increment-views/{id}', name: 'increment_views', methods: ['POST'])]
public function incrementViews(Request $request, Content $content): JsonResponse
{
// Проверка на бота
if ($this->isBot($request->headers->get('User-Agent', ''))) {
return new JsonResponse([
'views' => $content->getFieldValue('views')
]);
}
$ip = $request->getClientIp();
// Проверка на просмотр с этого IP за последние 24 часа
$viewRepository = $this->em->getRepository(ArticleView::class);
$existingView = $viewRepository->createQueryBuilder('v')
->where('v.ip = :ip')
->andWhere('v.contentId = :contentId')
->andWhere('v.viewedAt > :dayAgo')
->setParameters([
'ip' => $ip,
'contentId' => $content->getId(),
'dayAgo' => new \DateTime('-24 hours')
])
->getQuery()
->getOneOrNullResult();
if (!$existingView) {
// Записываем новый просмотр
$view = new ArticleView($ip, $content->getId());
$this->em->persist($view);
// Увеличиваем счетчик
$content->setFieldValue('views', (int)$content->getFieldValue('views') + 1);
$this->em->flush();
}
return new JsonResponse([
'views' => $content->getFieldValue('views')
]);
}
}
Шаг 4: JavaScript для отправки запроса
Подключаем js-библиотеку и стили в основной шаблон:
<!-- GLightbox CSS -->
<link href="https://cdn.jsdelivr.net/npm/glightbox/dist/css/glightbox.min.css" rel="stylesheet">
<!-- GLightbox JavaScript -->
<script src="https://cdn.jsdelivr.net/gh/mcstudios/glightbox/dist/js/glightbox.min.js"></script>
В twig-шаблон страницы статьи добавляем JavaScript для отправки асинхронного запроса, помимо непосредственно увеличения счетчика он также обновляет и значение на странице, если пользовательский просмотр привел к увеличению числа просмотров:
<script>
document.addEventListener('DOMContentLoaded', function() {
fetch('/async/increment-views/{{ record.id }}', {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest'
},
credentials: 'same-origin'
})
.then(response => response.json())
.then(data => {
if (data.views) {
document.querySelector('.view-counter').textContent = data.views;
}
});
});
</script>
Шаг 5: Отображение счетчика
В шаблон добавим отображение количества просмотров:
{# В детальном просмотре #}
<div style="color: darkgray; float: right; margin-left: 10px;">
{{ record.publishedAt|localdate(format='d.m.Y H:i') }}
<br>
<i class="fas fa-eye"></i> <span class="view-counter">{{ record.views|default(0) }}</span>
</div>
Шаг 6: Очистка старых записей
Для очистки старых записей добавляем консольную команду:
<?php
namespace App\Command;
use App\Entity\ArticleView;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
class CleanupViewsCommand extends Command
{
protected static $defaultName = 'app:cleanup:views';
protected static $defaultDescription = 'Remove old article views';
protected EntityManagerInterface $em;
public function __construct(EntityManagerInterface $em)
{
parent::__construct();
$this->em = $em;
}
protected function configure(): void
{
$this
->addOption(
'older-than',
null,
InputOption::VALUE_REQUIRED,
'Remove views older than this date (strtotime format)',
'-1 day'
)
->addOption(
'dry-run',
null,
InputOption::VALUE_NONE,
'Only show what would be deleted'
);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$olderThan = new \DateTime($input->getOption('older-than'));
$isDryRun = $input->getOption('dry-run');
$qb = $this->em->createQueryBuilder();
$qb->select('v')
->from(ArticleView::class, 'v')
->where('v.viewedAt < :olderThan')
->setParameter('olderThan', $olderThan);
$views = $qb->getQuery()->getResult();
$count = count($views);
if ($count === 0) {
$io->success('No old views found to delete.');
return Command::SUCCESS;
}
if ($isDryRun) {
$io->note(sprintf('Would delete %d views older than %s', $count, $olderThan->format('Y-m-d H:i:s')));
return Command::SUCCESS;
}
$qb = $this->em->createQueryBuilder();
$qb->delete(ArticleView::class, 'v')
->where('v.viewedAt < :olderThan')
->setParameter('olderThan', $olderThan);
$deleted = $qb->getQuery()->execute();
$io->success(sprintf('Deleted %d views older than %s', $deleted, $olderThan->format('Y-m-d H:i:s')));
return Command::SUCCESS;
}
}
Команду можно запускать вручную:
# Удалить записи старше 1 дня (по умолчанию)
php bin/console app:cleanup:views
# Удалить записи старше недели
php bin/console app:cleanup:views --older-than="-7 days"
# Посмотреть, сколько записей будет удалено
php bin/console app:cleanup:views --dry-run
Или добавить в cron для автоматической очистки:
0 0 * * * php bin/console app:cleanup:views
Итоговое решение
В результате мы получили надежную систему учета просмотров со следующими преимуществами:
-
Защита от накрутки:
- Один просмотр с IP-адреса в сутки
- Фильтрация ботов по User-Agent
- Хранение истории просмотров за определенный период
-
Производительность:
- Асинхронный подсчет просмотров
- Регулярная очистка старых записей
-
Удобство использования:
- Мгновенное обновление счетчика на странице
- Простая интеграция в существующие шаблоны
- Легкое обслуживание через консольные команды
Возможные улучшения
В будущем систему можно улучшить:
- Добавить кеширование счетчиков для снижения нагрузки на БД
- Реализовать более сложную защиту от ботов (fingerprinting, поведенческий анализ)
- Добавить аналитику или задержку учета просмотра по времени просмотра страниц
Счетчики были добавлены неделю назад, 26.01.2025.
Комментариев пока нет
-
Подарочный бокс с ИИ
Пришло то время, когда AI может не только отвечать в чатиках и применяться в di… -
Делаем картинки на сайте кликабельными
Я часто вставляю картинки в хорошем качестве, но до сих пор их нельзя было удоб… -
Включаем функции сбора статистики в ESP32-arduino
Речь пойдет о функциях FreeRTOS vTaskGetRunTimeStats / vTaskList, но таким же… -
Счетчик просмотров в Bolt CMS
Делаем счетчик просмотров для статей в Bolt CMS. Возможные варианты и базовая… -
Скрытый держатель дисков
Ищете куда деть свою коллекцию дисков, а складирование на полках считаете скучн… -
Student48
Для выпускников АСУ ЛГТУ 2014-2021 годов это название говорит о многом, давайте…