01.02.2025 16:27
3

Счетчик просмотров в Bolt CMS

Про Bolt CMS я, возможно, еще напишу отдельно, пока займемся улучшениями.

Изначально задача была простой: добавить счетчик просмотров для статей. Но хотелось также решить несколько важных вопросов:

  1. Избежать накрутки просмотров
  2. Отфильтровать ботов
  3. Обойтись малой кровью - без внешних сервисов и дополнительных систем вроде redis/memcached/etc.

Варианты

1. Просто счетчик

1

Добавляем поле к сущности записи и инкрементируем при каждом запросе детальной страницы записи.

Минимум усилий, но каждое обновление будет приводить к увеличению счетчика. Зажав F5 можно накрутить какое угодно значение.

2. Защита на клиентской стороне

2

Фиксируем просмотры статей на стороне клиента - cookies или localStorage. Запрос на учет счетчика можно отправлять через js со страницы статьи - таким образом отсечем просмотры статей ботами/краулерами, которые не рендерят страницу полностью и не исполняют js.

Усилий чуть больше, обычное зажатие F5 уже просмотры не увеличивает, но хитрые пользователи (зачем им это может понадобиться отдельный вопрос) могут отключить куки или очищать localStorage и опять же нагенерировать просмотров.

Помимо клиентов это могут делать и боты, поэтому идем дальше.

3. Защита по IP

3

Фиксируем уникальные для 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

Итоговое решение

В результате мы получили надежную систему учета просмотров со следующими преимуществами:

  1. Защита от накрутки:

    • Один просмотр с IP-адреса в сутки
    • Фильтрация ботов по User-Agent
    • Хранение истории просмотров за определенный период
  2. Производительность:

    • Асинхронный подсчет просмотров
    • Регулярная очистка старых записей
  3. Удобство использования:

    • Мгновенное обновление счетчика на странице
    • Простая интеграция в существующие шаблоны
    • Легкое обслуживание через консольные команды

Возможные улучшения

В будущем систему можно улучшить:

  1. Добавить кеширование счетчиков для снижения нагрузки на БД
  2. Реализовать более сложную защиту от ботов (fingerprinting, поведенческий анализ)
  3. Добавить аналитику или задержку учета просмотра по времени просмотра страниц

Счетчики были добавлены неделю назад, 26.01.2025.

Tags: Php Bolt Symfony

Комментариев пока нет

Последние статьи