<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Web Push API: Полное руководство по реализации push-уведомлений]]></title><description><![CDATA[<p dir="auto">Присаживайте по удобнее, заварите чайку тема будет объемная и большая…</p>
<p dir="auto">А делиться я буду своим опытом реализации push-уведомлений на веб-приложениях с помощью <strong>Web Push API</strong>. Это настоящий киллер-фича, который позволяет держать пользователей в курсе даже тогда, когда они не находятся на вашем сайте. Давайте разберемся с этой технологией во всех деталях.</p>
<hr />
<p dir="auto">Но давайте с самого начала - <strong>Что такое Web Push API и зачем это нужно</strong>?</p>
<p dir="auto"><strong>Web Push API</strong> - это стандартный API браузера, который позволяет отправлять пользователям нативные push-уведомления, не полагаясь на сторонние сервисы вроде Firebase или Pusher. Звучит просто? На самом деле это мощная технология, которая работает прямо из коробки во всех современных браузерах.</p>
<p dir="auto"><strong>Главные преимущества</strong>:</p>
<ul>
<li>Пользователи получают уведомления даже когда браузер закрыт (на большинстве платформ)</li>
<li>Не нужно платить за сторонние сервисы - все работает через браузер</li>
<li>Полный контроль над данными и шифрованием</li>
<li>Стандартизированный подход, поддерживаемый всеми современными браузерами</li>
</ul>
<blockquote>
<p dir="auto">И да на нашем форум  тоже есть пуш-уведомления, но мы их пока дорабатываем <img src="https://forum.exlends.ru/assets/plugins/nodebb-plugin-emoji/emoji/android/1f642.png?v=a1e94250dac" class="not-responsive emoji emoji-android emoji--slightly_smiling_face" style="height:23px;width:auto;vertical-align:middle" title=":)" alt="🙂" /></p>
</blockquote>
<h2>Архитектура Web Push API: из чего всё состоит?</h2>
<p dir="auto">Помимо самой практики не менее важно понимать что вы делает и как это работает. Да-да, вайб-кодеры читайте внимательнее и вникайте! Я что просто так собирал этот материал? <img src="https://forum.exlends.ru/assets/plugins/nodebb-plugin-emoji/emoji/android/1f600.png?v=a1e94250dac" class="not-responsive emoji emoji-android emoji--grinning" style="height:23px;width:auto;vertical-align:middle" title=":grinning:" alt="😀" /></p>
<p dir="auto">Вся система состоит из четырех ключевых компонентов:</p>
<p dir="auto"><strong>1. Service Worker - Фоновый боец приложения</strong><br />
Service Worker - это JavaScript-скрипт, который работает в фоне, отдельно от основной страницы. Именно благодаря ему уведомления доходят до пользователя даже при закрытом браузере.</p>
<p dir="auto">Что делает Service Worker:</p>
<ul>
<li>Получает push-события от браузера</li>
<li>Обрабатывает входящие уведомления</li>
<li>Отображает уведомления пользователю</li>
<li>Обрабатывает клики по уведомлениям</li>
</ul>
<p dir="auto"><strong>2. VAPID ключи - Паспорт вашего сервера</strong><br />
VAPID (Voluntary Application Server Identification) - это криптографическая пара ключей, которая идентифицирует ваш сервер при отправке уведомлений. Думайте о ней как о паспорте: без него push-сервис браузера просто откажется принимать ваши уведомления.​</p>
<p dir="auto">Как это работает:</p>
<ul>
<li>Публичный ключ передаётся браузеру</li>
<li>Приватный ключ хранится на вашем сервере и никогда не передаётся</li>
<li>Сервер подписывает все отправляемые уведомления приватным ключом</li>
<li>Push-сервис браузера проверяет подпись с помощью публичного ключа</li>
</ul>
<p dir="auto"><strong>3. Subscription Object - Билет на доставку</strong><br />
После подписки пользователя, вы получаете объект, который содержит:​</p>
<ul>
<li>Endpoint - уникальный URL для отправки сообщений</li>
<li>Ключи шифрования (p256dh и auth) - для безопасности данных</li>
<li>Это всё хранится в вашей базе данных и используется для отправки уведомлений</li>
</ul>
<p dir="auto"><strong>4. Push Service - Почтальон браузера</strong><br />
Это сервис, управляемый браузером (Google FCM для Chrome, Mozilla для Firefox и т.д.), который:</p>
<ul>
<li>Получает шифрованные сообщения от вашего сервера</li>
<li>Хранит их, если пользователь оффлайн</li>
<li>Доставляет их браузеру пользователя</li>
<li>Гарантирует доставку даже при нестабильном соединении</li>
</ul>
<hr />
<h2>Пошаговая реализация</h2>
<p dir="auto">Хватит теории, давайте перейдем к практике, подготовьте свои редакторы, проверьте работает ли нода и начинаем ковыряться.</p>
<h3>Шаг 1: Генерируем VAPID ключи</h3>
<p dir="auto">Первое, что нужно сделать - это сгенерировать пару VAPID ключей. Это одноразовая операция для вашего приложения.</p>
<p dir="auto">Установим web-push:</p>
<pre><code>npm install web-push
</code></pre>
<p dir="auto">Генерируем ключи:</p>
<pre><code>npx web-push generate-vapid-keys
</code></pre>
<p dir="auto">Вывод будет примерно таким:</p>
<pre><code>Public Key: BKV3l...qA
Private Key: 1Ik0...xA
</code></pre>
<p dir="auto">Сохраните эти ключи в переменных окружения! Например, в <code>.env</code>:</p>
<pre><code>VAPID_PUBLIC_KEY=BKV3l...qA
VAPID_PRIVATE_KEY=1Ik0...xA
</code></pre>
<hr />
<h3>Шаг 2: Регистрируем Service Worker</h3>
<p dir="auto">Теперь создаем файл Service Worker и регистрируем его на клиенте.</p>
<p dir="auto">Файл <code>public/sw.js</code> (Service Worker):</p>
<pre><code class="language-js">// Прослушиваем входящие push-события
self.addEventListener('push', event =&gt; {
  // Если сообщение содержит данные, парсим их
  const data = event.data?.json() || {};
  
  // Подготавливаем параметры уведомления
  const options = {
    body: data.body || 'Новое уведомление',
    icon: data.icon || '/icon-192.png',
    badge: '/badge.png',
    vibrate: [200, 100, 200], // Вибрация на мобильных
    tag: data.tag || 'notification', // Теги для группировки
    data: { 
      url: data.url || '/' // URL для открытия по клику
    }
  };
  
  // Отображаем уведомление
  event.waitUntil(
    self.registration.showNotification(
      data.title || 'Уведомление',
      options
    )
  );
});

// Обрабатываем клик по уведомлению
self.addEventListener('notificationclick', event =&gt; {
  // Закрываем уведомление
  event.notification.close();
  
  // Открываем URL в окне браузера
  event.waitUntil(
    clients.matchAll({ type: 'window' })
      .then(clientList =&gt; {
        // Проверяем, не открыт ли уже этот URL
        for (const client of clientList) {
          if (client.url === event.notification.data?.url &amp;&amp; 'focus' in client) {
            return client.focus();
          }
        }
        // Если не открыт, открываем новое окно
        if (clients.openWindow) {
          return clients.openWindow(event.notification.data?.url || '/');
        }
      })
  );
});

// Обработчик закрытия уведомления
self.addEventListener('notificationclose', event =&gt; {
  console.log('Уведомление было закрыто', event.notification.tag);
});
</code></pre>
<p dir="auto">На клиенте (например, в <code>index.html</code> или вашем <strong>React/Vue</strong> компоненте):</p>
<pre><code class="language-js">// Проверяем поддержку API
if ('serviceWorker' in navigator &amp;&amp; 'PushManager' in window) {
  // Регистрируем Service Worker при загрузке страницы
  navigator.serviceWorker.register('/sw.js')
    .then(registration =&gt; {
      console.log('✅ Service Worker зарегистрирован:', registration);
    })
    .catch(error =&gt; {
      console.error('❌ Ошибка регистрации Service Worker:', error);
    });
} else {
  console.warn('⚠️ Web Push API не поддерживается в этом браузере');
}
</code></pre>
<h3>Шаг 3: Запрашиваем разрешение и подписываем пользователя</h3>
<p dir="auto">Теперь нужно запросить у пользователя разрешение на отправку уведомлений и подписать его.</p>
<pre><code class="language-js">// Публичный VAPID ключ (не секретный, можно хранить в коде)
const VAPID_PUBLIC_KEY = process.env.REACT_APP_VAPID_PUBLIC_KEY;

// Вспомогательная функция для конвертации Base64 в Uint8Array
function urlBase64ToUint8Array(base64String) {
  const padding = '='.repeat((4 - base64String.length % 4) % 4);
  const base64 = (base64String + padding)
    .replace(/\-/g, '+')
    .replace(/_/g, '/');
  
  const rawData = window.atob(base64);
  const outputArray = new Uint8Array(rawData.length);
  
  for (let i = 0; i &lt; rawData.length; ++i) {
    outputArray[i] = rawData.charCodeAt(i);
  }
  
  return outputArray;
}

// Функция подписки на уведомления
async function subscribeToPush() {
  try {
    // Ждём, пока Service Worker будет готов
    const registration = await navigator.serviceWorker.ready;
    
    // Запрашиваем разрешение у пользователя
    // ВАЖНО: это должно быть в ответ на действие пользователя (клик)
    const permission = await Notification.requestPermission();
    
    if (permission !== 'granted') {
      console.log('❌ Пользователь отклонил разрешение');
      return;
    }
    
    // Подписываем пользователя на push
    const subscription = await registration.pushManager.subscribe({
      userVisibleOnly: true, // Уведомления должны быть видны
      applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY)
    });
    
    console.log('✅ Пользователь подписан на уведомления');
    console.log('Subscription:', subscription);
    
    // Отправляем данные подписки на сервер
    await fetch('/api/subscribe', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(subscription)
    });
    
    console.log('✅ Данные подписки отправлены на сервер');
    
  } catch (error) {
    console.error('❌ Ошибка при подписке:', error);
  }
}

// Вешаем обработчик на кнопку
document.getElementById('subscribe-btn').addEventListener('click', subscribeToPush);
</code></pre>
<p dir="auto">Лучшие практики для запроса разрешения:</p>
<ul>
<li>Запрашивайте разрешение только после действия пользователя (клик на кнопку)</li>
<li>Объясните, зачем вам нужны уведомления</li>
<li>Не спрашивайте сразу же при загрузке страницы — это раздражает</li>
<li>Предложите возможность отписки позже</li>
</ul>
<h3>Шаг 4: Серверная реализация (Node.js)</h3>
<p dir="auto">На сервере используем библиотеку <code>web-push</code> для отправки уведомлений.</p>
<p dir="auto">Установка зависимостей:</p>
<pre><code class="language-js">npm install web-push express dotenv
</code></pre>
<p dir="auto">Файл <code>server.js</code>:</p>
<pre><code class="language-js">const express = require('express');
const webpush = require('web-push');
require('dotenv').config();

const app = express();
app.use(express.json());

// Инициализируем web-push с VAPID ключами
webpush.setVapidDetails(
  'mailto:your-email@example.com', // Ваша почта (может быть любой)
  process.env.VAPID_PUBLIC_KEY,
  process.env.VAPID_PRIVATE_KEY
);

// Хранилище подписок (в продакшене используйте БД)
const subscriptions = new Set();

// Эндпоинт для сохранения подписки пользователя
app.post('/api/subscribe', (req, res) =&gt; {
  const subscription = req.body;
  
  try {
    // Сохраняем подписку (в реальном приложении — в БД)
    subscriptions.add(JSON.stringify(subscription));
    
    res.status(201).json({ 
      success: true, 
      message: 'Подписка сохранена' 
    });
  } catch (error) {
    console.error('Ошибка при сохранении подписки:', error);
    res.status(500).json({ 
      success: false, 
      message: 'Ошибка сохранения подписки' 
    });
  }
});

// Функция отправки уведомления конкретному пользователю
async function sendNotificationToUser(subscription, payload) {
  try {
    await webpush.sendNotification(subscription, JSON.stringify(payload));
    console.log('✅ Уведомление отправлено');
  } catch (error) {
    if (error.statusCode === 410) {
      // Подписка устарела, удаляем её
      subscriptions.delete(JSON.stringify(subscription));
      console.log('⚠️ Подписка удалена (410 Gone)');
    } else {
      console.error('❌ Ошибка отправки:', error);
    }
  }
}

// Функция отправки уведомления всем пользователям
async function sendNotificationToAll(payload) {
  console.log(`📤 Отправляем уведомление ${subscriptions.size} пользователям`);
  
  for (const subStr of subscriptions) {
    const subscription = JSON.parse(subStr);
    await sendNotificationToUser(subscription, payload);
  }
}

// Тестовый эндпоинт для отправки уведомления
app.post('/api/send-notification', async (req, res) =&gt; {
  const { title, body, icon, url } = req.body;
  
  const payload = {
    title: title || 'Новое уведомление',
    body: body || 'Нажмите, чтобы узнать больше',
    icon: icon || '/icon-192.png',
    url: url || '/'
  };
  
  try {
    await sendNotificationToAll(payload);
    res.json({ 
      success: true, 
      message: `Уведомление отправлено ${subscriptions.size} пользователям` 
    });
  } catch (error) {
    console.error('Ошибка:', error);
    res.status(500).json({ 
      success: false, 
      message: 'Ошибка при отправке уведомления' 
    });
  }
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () =&gt; {
  console.log(`🚀 Сервер запущен на http://localhost:${PORT}`);
});
</code></pre>
<hr />
<h2>Важные особенности для каждого браузера</h2>
<p dir="auto">Ну и конечно же разные браузеры имеют разные требования и ограничения. Об этом нужно помнить!</p>
<table class="table table-bordered table-striped">
<thead>
<tr>
<th>Браузер</th>
<th>Требования</th>
<th>Особенности</th>
</tr>
</thead>
<tbody>
<tr>
<td>Chrome/Edge</td>
<td>Видимые уведомления для каждого push</td>
<td>Поддержка до 4KB полезной нагрузки</td>
</tr>
<tr>
<td>Firefox</td>
<td>Видимые уведомления требуются</td>
<td>4KB полезная нагрузка</td>
</tr>
<tr>
<td>Safari</td>
<td>Видимые уведомления обязательны</td>
<td>Только 2KB полезная нагрузка, требует веб-приложения</td>
</tr>
<tr>
<td>iOS (Safari)</td>
<td>Не поддерживает background push</td>
<td>Уведомления работают только при открытом приложении</td>
</tr>
</tbody>
</table>
<p dir="auto">Всегда отображайте уведомления немедленно при их получении в Service Worker. Некоторые браузеры требуют это для сохранения разрешений.</p>
<hr />
<h2>Ограничения на размер сообщений</h2>
<p dir="auto">Push-уведомления не могут содержать большое количество данных. Если вам нужно отправить много информации:</p>
<pre><code class="language-js">// ❌ ПЛОХО: отправляем большой JSON
const badPayload = {
  title: 'Новая статья',
  body: 'Очень длинное описание статьи...',
  fullArticle: '...' // 100KB текста
};

// ✅ ХОРОШО: отправляем минимальные данные
const goodPayload = {
  title: 'Новая статья',
  body: 'Кликните для прочтения',
  articleId: '12345' // ID статьи
};

// В Service Worker загружаем полные данные:
self.addEventListener('push', async event =&gt; {
  const data = event.data?.json() || {};
  
  // Загружаем полные данные с сервера
  const response = await fetch(`/api/article/${data.articleId}`);
  const article = await response.json();
  
  // Теперь отображаем с полной информацией
  event.waitUntil(
    self.registration.showNotification(article.title, {
      body: article.description,
      image: article.coverImage
    })
  );
});
</code></pre>
<hr />
<h2>Управление жизненным циклом подписки</h2>
<p dir="auto">Время от времени браузер может автоматически пересоздать подписку (например, после обновления Service Worker). Нужно об этом позаботиться:</p>
<pre><code class="language-js">// В Service Worker
self.addEventListener('pushsubscriptionchange', event =&gt; {
  console.log('⚠️ Подписка изменилась, переподписываемся');
  
  event.waitUntil(
    self.registration.pushManager.subscribe({
      userVisibleOnly: true,
      applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY)
    })
    .then(newSubscription =&gt; {
      // Отправляем новую подписку на сервер
      return fetch('/api/update-subscription', {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(newSubscription)
      });
    })
    .catch(error =&gt; {
      console.error('❌ Ошибка при переподписке:', error);
    })
  );
});
</code></pre>
<hr />
<h2>Безопасность: неотъемлемая часть</h2>
<p dir="auto">Без безопасности никуда, тем более после последних угроз связанных с Next - лучше стараться уделять этому вопросу больше внимания.</p>
<p dir="auto">А теперь о правилах, золотые правила:</p>
<ul>
<li>
<p dir="auto">Приватный ключ VAPID никогда не передавайте клиенту. Это секрет!</p>
</li>
<li>
<p dir="auto">Шифруйте полезную нагрузку - Web Push API делает это автоматически, но всегда проверяйте</p>
</li>
<li>
<p dir="auto">Валидируйте данные на сервере при сохранении подписок</p>
</li>
<li>
<p dir="auto">Используйте HTTPS - это не опционально</p>
</li>
<li>
<p dir="auto">Реализуйте Rate Limiting на эндпоинте отправки, чтобы предотвратить спам</p>
</li>
<li>
<p dir="auto">Удаляйте старые подписки, если браузер возвращает ошибку 410 (Gone)</p>
</li>
</ul>
<p dir="auto">Для примера:</p>
<pre><code class="language-js">// Пример простой защиты на сервере
const RATE_LIMIT = 5; // Максимум 5 уведомлений в минуту
let lastNotificationTime = 0;
let notificationCount = 0;

app.post('/api/send-notification', (req, res) =&gt; {
  const now = Date.now();
  
  // Сбрасываем счётчик каждую минуту
  if (now - lastNotificationTime &gt; 60000) {
    lastNotificationTime = now;
    notificationCount = 0;
  }
  
  // Проверяем лимит
  if (notificationCount &gt;= RATE_LIMIT) {
    return res.status(429).json({ 
      success: false, 
      message: 'Слишком много запросов' 
    });
  }
  
  notificationCount++;
  
  // ... остальной код отправки
});
</code></pre>
<p dir="auto">Также стоит упомянуть о типичных ошибках и как их решать:</p>
<table class="table table-bordered table-striped">
<thead>
<tr>
<th>Проблема</th>
<th>Причина</th>
<th>Решение</th>
</tr>
</thead>
<tbody>
<tr>
<td>Service Worker не регистрируется</td>
<td>Отсутствует HTTPS (кроме localhost)</td>
<td>Используйте HTTPS или localhost для тестирования</td>
</tr>
<tr>
<td>Браузер не запрашивает разрешение</td>
<td>API вызван без действия пользователя</td>
<td>Вешайте обработчик на клик кнопки</td>
</tr>
<tr>
<td>Уведомления не приходят</td>
<td>VAPID ключи неправильные</td>
<td>Проверьте, что используете одну пару ключей везде</td>
</tr>
<tr>
<td>Ошибка 410 (Gone)</td>
<td>Подписка устарела</td>
<td>Удалите подписку из БД, переподпишите пользователя</td>
</tr>
<tr>
<td>Сообщение слишком большое</td>
<td>Превышен лимит (4KB или 2KB)</td>
<td>Уменьшите размер payload, отправляйте ID вместо данных</td>
</tr>
</tbody>
</table>
<hr />
<h2>Продвинутые фишки</h2>
<p dir="auto">Возможно кому-то это покажется излишним, но в некоторых случаях такие решения могут облегчить жизнь.</p>
<p dir="auto"><strong>Группировка уведомлений</strong><br />
Используйте параметр <code>tag</code> для группировки уведомлений одного типа:</p>
<pre><code class="language-js">const options = {
  body: 'Новое сообщение в чате',
  tag: 'chat-messages', // Все уведомления с этим тегом объединяются
  renotify: true // Заново уведомить пользователя
};
</code></pre>
<p dir="auto"><strong>Кнопки действий</strong><br />
Некоторые браузеры поддерживают кнопки в уведомлениях:</p>
<pre><code class="language-js">const options = {
  body: 'Вам пришел новый заказ',
  actions: [
    {
      action: 'approve',
      title: 'Принять'
    },
    {
      action: 'decline',
      title: 'Отклонить'
    }
  ]
};

// В Service Worker обрабатываем действия
self.addEventListener('notificationclick', event =&gt; {
  if (event.action === 'approve') {
    // Логика для принятия
  } else if (event.action === 'decline') {
    // Логика для отклонения
  }
});
</code></pre>
<p dir="auto"><strong>Изображения в уведомлениях</strong><br />
Многие браузеры поддерживают изображения:</p>
<pre><code class="language-js">const options = {
  body: 'Новая фотография',
  image: 'https://example.com/photo.jpg', // Большое изображение
  icon: '/app-icon.png', // Маленькая иконка
  badge: '/badge-icon.png' // Иконка для Android
};
</code></pre>
<hr />
<h2></h2>
<p dir="auto">Web Push API - это мощный инструмент, который позволяет создавать действительно стоящие веб-приложения. Да, есть нюансы с разными браузерами и ограничения на размер данных, но это все решаемо.​</p>
<p dir="auto">И помните золотые правила:</p>
<p dir="auto"><img src="https://forum.exlends.ru/assets/plugins/nodebb-plugin-emoji/emoji/android/2705.png?v=a1e94250dac" class="not-responsive emoji emoji-android emoji--white_check_mark" style="height:23px;width:auto;vertical-align:middle" title="✅" alt="✅" /> Используйте HTTPS (или localhost для тестирования)<br />
<img src="https://forum.exlends.ru/assets/plugins/nodebb-plugin-emoji/emoji/android/2705.png?v=a1e94250dac" class="not-responsive emoji emoji-android emoji--white_check_mark" style="height:23px;width:auto;vertical-align:middle" title="✅" alt="✅" /> Всегда запрашивайте разрешение в ответ на действие пользователя<br />
<img src="https://forum.exlends.ru/assets/plugins/nodebb-plugin-emoji/emoji/android/2705.png?v=a1e94250dac" class="not-responsive emoji emoji-android emoji--white_check_mark" style="height:23px;width:auto;vertical-align:middle" title="✅" alt="✅" /> Генерируйте и сохраняйте VAPID ключи в безопасности<br />
<img src="https://forum.exlends.ru/assets/plugins/nodebb-plugin-emoji/emoji/android/2705.png?v=a1e94250dac" class="not-responsive emoji emoji-android emoji--white_check_mark" style="height:23px;width:auto;vertical-align:middle" title="✅" alt="✅" /> Регулярно удаляйте устаревшие подписки (410 ошибки)<br />
<img src="https://forum.exlends.ru/assets/plugins/nodebb-plugin-emoji/emoji/android/2705.png?v=a1e94250dac" class="not-responsive emoji emoji-android emoji--white_check_mark" style="height:23px;width:auto;vertical-align:middle" title="✅" alt="✅" /> Следите за размером payload — не более 4KB<br />
<img src="https://forum.exlends.ru/assets/plugins/nodebb-plugin-emoji/emoji/android/2705.png?v=a1e94250dac" class="not-responsive emoji emoji-android emoji--white_check_mark" style="height:23px;width:auto;vertical-align:middle" title="✅" alt="✅" /> Тестируйте в DevTools для быстрой отладки</p>
<p dir="auto">Надеюсь, это руководство было исчерпывающим и поможет вам разобраться с Web Push API. Если у вас возникнут вопросы - добро пожаловать на наш форум.<br />
Не стесняйтесь и задавайте вопросы.</p>
<p dir="auto">И конечно удачи в разработке <img src="https://forum.exlends.ru/assets/plugins/nodebb-plugin-emoji/emoji/android/1f60a.png?v=a1e94250dac" class="not-responsive emoji emoji-android emoji--blush" style="height:23px;width:auto;vertical-align:middle" title=":blush:" alt="😊" /> !</p>
]]></description><link>https://forum.exlends.ru/topic/344/web-push-api-polnoe-rukovodstvo-po-realizacii-push-uvedomlenij</link><generator>RSS for Node</generator><lastBuildDate>Sat, 17 Oct 2026 03:25:36 GMT</lastBuildDate><atom:link href="https://forum.exlends.ru/topic/344.rss" rel="self" type="application/rss+xml"/><pubDate>Thu, 11 Dec 2025 10:54:05 GMT</pubDate><ttl>60</ttl></channel></rss>