Привет, Хабр!

Я Саша Шутай, backend-тимлид в компании AGIMA. Сейчас расскажу, что делать, если на проекте Bitrix сожительствует с Vue.js и поисковые боты не видят контента вашего сайта. Рассмотрим технологию серверного рендеринга страниц с помощью Puppeteer, как это всё настроить и быстро запустить для любого веб-приложения.

А для начала немного поговорим, зачем используются реактивные фреймворки и какие трудности приносит их использование.

Подводный камень реактивных веб-приложений

Для разработки современных интерактивных веб-приложений, как известно, используют реактивные JS-фреймворки. Они дают возможность бесшовно переходить между страницами и автоматически перерисовывать контент при его изменении. При этом рендеринг происходит уже на устройстве у пользователя, и это довольно тяжелая операция.То есть, если использовать классическую архитектуру, каждая новая страница сайта для пользователя будет отрисовываться как бы заново — это увеличивает время загрузки. А вот реактивные фреймворки позволяют зарендерить страницу один раз, а потом в нее уже просто подгружается нужный контент. 

Но представим следующую ситуацию. Вы интегрировали на сайт реактивный фреймворк, пользователям нравится скорость работы веб-приложения и его быстрые интерфейсы, но теперь ваш сайт не попадает в поисковую выдачу либо имеет низкие позиции. Что не так?Происходит это потому, что ваш сервер отдает пустой html с базовым <div id=”app”> без контента. А то, что видят ваши пользователи, создает JS-приложение в браузере. При этом далеко не все поисковые системы будут запускать ваш JS. Чаще всего боты заходят на страницу, видят отсутствие контента и помечают сайт как пустой.

Google bot умеет выполнять JS, но это имеет смысл, если полный рендеринг  занимает не больше 3 секунд — иначе страница будет в красной зоне и про высокие позиции в поисковой выдаче можно будет забыть. А еще в сети популярна мысль, что Google bot для просмотра страниц может использовать Chrome 41, а в этой версии ещё не реализована полная поддержка ES6 и стрелочных функций.

Короче, при разработка фронтовой части сайта как приложения на JS-фреймворке надо использовать технологии серверного рендеринга — для будущей SEO-доступности контента в поисковиках.

Одно «но»

На проекте, где я работаю, был проведён редизайн и впервые опробованы технологии Vue.js и его связь с Bitrix. Мы не хотели уходить от предлагаемого вендором способа интеграции верстки в компоненты, лишать контент-менеджера возможности править страницы в визуальном редакторе и при этом хотели оставить роутинг страниц на бекенде (urlrewrite.php).

После пробы пера реактивном фреймоврке нам нужно было в короткие сроки реализовать механизм отдачи контента для поисковых систем, потому что Google начал помечать страницы красным, а Яндекс выкидывал их из выдачи. Когда проект подразумевает полное разделение фронта и бэка и их взаимодействие происходит через API, можно использовать удобные решения: Nuxt.js или Vue SSR.

Но не было бы этой статьи, если бы не одно «но».

На бэкенде стоял Bitrix, роутинг приложения был разработан тоже в нем. В общем команда оставила стандартные Bitrix-компоненты. Только в их template появился вызов собранных Vue-компонентов и в их Store сразу начал складываться JSON-массив со всеми данными для контента.

Исходя из того, что я описал выше, интеграции Vue.js в Bitrix-компоненты и использование Nuxt.js стали недоступны. И перед нами встала серьезная задача: нужно было спроектировать систему отдачи поисковым системам уже заранее отрендеренного контента страниц. Сейчас расскажу, как нам это удалось.

Дисклеймер. Тот вариант генерации реактивных страниц на стороне сервера, о котором сейчас пойдет речь, это не универсальное средство от всех проблем. И наверняка есть другие, не менее или даже более удачные способы связать архитектуры Bitrix и Vue.js без использование Nuxt.js. Но зато точно могу сказать, что это работает. Это позволяет быстро вернуть индексацию сайту и вывести его на высокие позиции в рейтинге за счёт хороших оценок в Google PageSpeed Insights. И в целом наше решение нужно рассматривать исключительно как интересный опыт и необычный способ поисковой оптимизации. 

Архитектура рендеринга

Упрощённая схема
Упрощённая схема

После всех попыток найти простое решение мы поняли, что в нашей ситуации таким решением будет построить серверный рендеринг по-своему. Были варианты обратиться к платным сервисам, которые могли бы частично избавить нас от необходимости что-то изобретать, но мы хотели во что бы то ни стало найти выход из ситуации самостоятельно и бесплатно (об этом ниже). 

Скоро мы его нашли. Таким выходом стало как бы отдельное приложение (Server Side Rendering — SSR), которое занималось пререндерингом. Чтобы понять, как оно работает, посмотрим на схему. В обычной ситуации, когда пользователь отправляет запрос на получение страницы, запрос сразу направляется в App. Но мы добавили еще одно звено — Balancer. Он по ряду условий определяет, отдать поисковому боту уже отрендеренную страницу из SSR или направить трафик на App. При этом SSR взаимодействует с сервером приложения (App) и генерирует готовые страницы постоянно.

При такой реализации нам нужно было решить ряд задач

Задача №1 (главная)

Пререндеринг страниц, чтобы в момент обращения внешнего запроса, отдавался уже готовый html+css и клиент не ожидал фонового рендеринга.

Решение

Мы рассуждали так: если у нас не будет изображения, заранее заготовленного в кэше, то при каждом обращении пользователя машина будет бегать на Bitrix, запрашивать информацию, рендерить, а только потом отдавать. Это занимает не менее 5 секунд. Долго.Поэтому мы поняли, что хорошо бы иметь уже готовую страницу — собранную, html-ую. И придумали вот такой фоновый процесс: страницы в кэше актуализируются сами по себе раз в промежуток времени. Чтобы запустить пререндеринг, мы используем cron-задачу. Модуль принимает автогенерируемый xml-файл со списком актуальных адресов.

Задача №2

Оперативная актуализация готовых страниц при реальном их изменении.

Решение

Нашему SSR нужно было оперативно понимать, что на страницах Bitrix что-то поменялось. При этом у Bitrix есть технология «Умный кэш», которая позволяет оперативно актуализировать данные в кэше.

Мы подписались на эту технологию и немного расширили ее, чтобы Bitrix не только сбрасывал кэш, но еще и отправлял в наше приложение информацию о том, что нужно пересобрать страницу.

Задача №3

Облегчение страницы.

Решение

Страницы генерировались на html и JS. Причем у JS  в рендеринге была главная роль. Но грузить страницу с JS было тяжело, и для поискового бота он был не нужен. Поэтому нам надо было из итоговой странице JS удалить.

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

Как мы сделали приложение для рендеринга

Как я и говорил, когда мы только придумали верхнюю схему, начали искать технологию, которая бы так работала. Одной из них оказалась PhantomJS. Затем мы нашли сервис Prerender. Он тоже делал примерно то же самое, но был платным. Так что для нас изобрести свой вариант такого приложения было делом чести.Мы какое-то время поломали голову, как это сделать, но потом вышли на Node-библиотеку Google Puppeteer. Она актуальная и делается самим Google. Мы предположили, что это значит, что Google оптимизировал ее под потребности поисковых ботов. Сразу собрали быстрый образ, увидели, что всё работает, и остановились на ней. Библиотека предоставляет API для работы с Headless Chrome по протоколу DevTools. Она быстро разворачивается на Express web server и сразу готова для рендеринга страниц сайта.

Инструкция по применению 

Если вы столкнетесь с такой же задачей, как мы, и захотите решить ее так же, то вот ссылка на github, а ниже небольшая инструкция, как это сделать.

Во-первых, установите следующие библиотеки:

  • express;

  • puppeteer;

  • node-cron (для запуска фоновых событий: актуализация кэша, актуализация страниц и сброс кэша);

  • dotenv (для работы с переменными).

Еще для разработки понадобится:

  • @babel/cli;

  • @babel/core;

  • @babel/node;

  • @babel/plugin-transform-runtime;

  • @babel/preset-env;

  • eslint;

  • eslint-plugin-promise;

  • nodemon.

Дальше создаем файл server.js.

import express from 'express';

const app = express();
app.listen(3000, () => console.log(`http://localhost:3000`))

Добавляем роут для обработки команды рендеринга, принимающий на вход адрес страницы.

app.get('/ssr', async (req, res, next) => {

const {url} = req.query;

const {html, status} = await ssr(url);

res.status(status).send(html);

})

Дальше переходим к самой функции SSR, предварительно подключив её в server.js.

import {ssr} from './functions/ssr.js'

ssr.js

import puppeteer from 'puppeteer';

export async function ssr(url) {

    const browser = await puppeteer.launch({
        args: [
            '--no-sandbox',
            '--disable-setuid-sandbox',
            '--disable-dev-shm-usage',
            '--disable-accelerated-2d-canvas',
            '--disable-gpu',
            '--window-size=1920x1080',
            '--single-process',
            '--no-zygote'
        ],
    });

    const page = await browser.newPage();
    page.setViewport({ width: 1280, height: 926 });
    
    try {
        const response = await page.goto(url, {
            timeout: 25000,
            waitUntil: 'networkidle2'
        });
        if (response.status() < 400) {

            let status = response.status();

            let html = await page.content();

            await page.close();
            browser.close();

            return {html, status: status}
        }

        let html = null;

        await page.close();
        browser.close();

        return {html, status: response.status()}

    } catch (e) {
        let html = e.toString();
        console.warn({message: `URL: ${url} Failed with message: ${html}`})

        await page.close();
        browser.close();

        return {html, status: 500}
    }

}

При каждом новом обращении запускается новый экземпляр const browser = await puppeteer.launchи завершается после попытки рендеринга страницы.

Часто встречаются рекомендации, что экземпляр лучше запустить один раз при первом обращении и в дальнейшем все последующие генерации запускать в нём. Мы убедились в этом на собственном опыте: на новый запуск виртуального браузера отводится существенное время всего процесса рендеринга. Однако были проблемы: количество процессов росло экспоненциально, быстро заканчивалась оперативная память, экземпляр Headless Chrome зависал и переставал принимать новые команды. 

После добавления аргументов:

'--single-process',
'--no-zygote'

...и запуска / завершения процесса Headless Chrome при каждом обращении, проблема с высокой загрузкой серверного железа ушла. Если вы знаете, как победить неконтролируемый расход памяти при использовании одного экземпляра, поделитесь в комментариях.

Теперь давайте разберем содержимое ssr.js

Открываем страницу const page = await browser.newPage();

Регулируем событие, когда страница считается готовой и можно её сохранять. Помимо таймаута и счётчика количества открытых соединений, можно использовать более гибкие признаки, как, например, появление селектора page.waitForSelector('#posts');

const response = await page.goto(url, {
   timeout: 25000,
   waitUntil: 'networkidle2'
});

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

let status = response.status();

let html = await page.content();

await page.close();
browser.close();

return {html, status: status}

Добавим в команды сборки в package.json.

"scripts": {
    "dev": "nodemon --exec babel-node ./src/server.js",
    "build": "NODE_ENV=production babel ./src --out-dir dist",
    "server": "node ./dist/index.js"
  },

Теперь можно запустить полученное приложение npm run dev , обратиться по адресу http://localhost:3000/ssr?url=https://example.com и получить готовую страницу!

Как мы оптимизировали рендеринг

Кэширование.

Как я и говорил, генерировать страницу при каждом обращении долго, поэтому мы решили, что нужно хранить уже готовые страницы в кэше.

Чтобы этого добиться, нужно добавить в проект cache.js с методами установки и получения страниц из хранилища:

export class Cache {
	constructor() {
		this.cache = {};
	}

	getPageByURL(url) {
  if (this.cache.hasOwnProperty(url)) {
     return this.cache[url];
  }

  return null;
}


	setPage(url, html, status) {
  this.cache[url] = {
     html,
        status,
     'date_create': Date.now(),
     'live_time': Date.now() + this.time
  }
}

	clear() {
  Object.keys(this.cache).forEach(key => {
     delete this.cache[key];
  })
}


}

Теперь добавляем в приложение библиотеку Node-cron и реализуем метод очистки устаревших страниц из хранилища.

cache.js

check() {
  for (const url in this.cache) {
     if (Date.now() > this.cache[url]['live_time']) {
        delete this.cache[url];
     }
  }
}

cron.js

export class Cron {
	constructor(CACHE) {
		this.cron = require('node-cron');
		this.cache = CACHE;
	}

	initTasks() {
		this.cron.schedule('0 0 */1 * * *', () => {
			this.cache.check();
		});
	}
  }

server.js

import {Cache} from "./functions/cache";
import {Cron} from "./cron";

const CACHE = new Cache();
const CRON = new Cron(CACHE);
CRON.initTasks();

...

APP.get('/ssr', async (req, res, next) => {

	const {url} = req.query;

	/**
	 * Отдача страницы из кеша (при наличии)
	 */
	let page = CACHE.getPageByURL(url);
	if (page) {
		return res.status(page.status).send(page.html);
	}

	const {html, status} = await ssr(url);

	if (status === 200) {
		CACHE.setPage(url, html, status)
	}

	res.status(status).send(html);
})

Облегчение страниц

Почти последний пункт. Если заранее отрендеренные страницы отдаются только поисковым ботам для индексации контента, то имеет смысл удалить с них подключения JS, чтобы страница была легче. Поэтому добавим в ssr.js

await page.evaluate(() => {
const elements = document.querySelectorAll('script, link[rel="import"]');
					elements.forEach(e => e.remove());
				});

Сохранение статусов редиректов

Когда 301 и 302 редиректы производятся на уровне приложения, то статус, полученный Puppeteer будет 200. Например, при LocalRedirect('index.php', true, 301); в Bitrix, важно передать поисковому боту правильный статус произведенного редиректа.

const chain = response.request().redirectChain();
			for ( let num in chain ) {
				if(chain[num].response().headers().p3p)
				{
					let chainStatus = chain[num].response().headers().status;
					if ((chainStatus === '301') || (chainStatus === '302')) {
						status = chainStatus;
						redirect = chain[num].response().headers().location;
					}
				}
			}

server.js

...
APP.get('/ssr', async (req, res, next) => {
...
   /**
	 * Обработка редиректов
	 */
	if (redirect) {
		return res.set('Redirect', redirect).status(status).send();
	}

res.status(status).send(html);

})

Фоновая генерация кэша

Удобно заранее иметь страницы в хранилище, чтобы при реальном запросе не тратить время на вызов Puppeteer. Поэтому запустите фоновую задачу рендеринга страниц для адресов из sitemap.xml вашего сайта, повесив актуализацию на cron. Мы использовали библиотеку sitemapper, корректно парсящующую карту сайта и учитывающую вложенные ссылки. И результат её работы скормили методу ssr.

И как это запустить на любом сервере?

Обернем приложение в Docker, чтобы запускать образ одной командой на любой машине. Начнем строить Dockerfile.

Первой инструкцией указываем, что будем строить образ на основе node 14.16.0

FROM node:14.16.0

Обновляем и устанавливаем пакеты, в том числе и Chrome

RUN  apt-get update \
     && apt-get install -y wget gnupg ca-certificates procps libxss1 \
     && wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - \
     && sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list' \
     && apt-get update \
     && apt-get install -y google-chrome-stable \
     && rm -rf /var/lib/apt/lists/* \
     && wget --quiet https://raw.githubusercontent.com/vishnubob/wait-for-it/master/wait-for-it.sh -O /usr/sbin/wait-for-it.sh \
     && chmod +x /usr/sbin/wait-for-it.sh \
     && echo 'kernel.unprivileged_userns_clone=1' > /etc/sysctl.d/userns.conf

Если запускаете Docker-образ из-под Root, то создайте раздел и настройте права на пользователя Node

RUN mkdir -p /home/node/app/node_modules && chown -R node:node /home/node/app

Дальше указываем директорию для приложения

WORKDIR /home/node/app

Копируем package.json и package-lock.json, присваивая права пользователю Node

COPY --chown=node:node package*.json ./

Переключаемся на пользователя Node

USER node

Устанавливаем NPM-зависимости

RUN npm install

Копируем код приложения в Docker-образ

COPY --chown=node:node . .

Запускаем сборку приложения

RUN npm run build

Поскольку мы настроили наше приложение на порт 3000, то указываем это

EXPOSE 3000

И финальной командой запускаем приложение

CMD [ "npm", "run", "server" ]

Разделение трафика поисковых ботов и пользователей

После настройки модуля рендеринга мы получаем 3 вида каждой страницы сайта: 

  • отрендеренная страница с JS;

  • отрендеренная страница без JS;

  • неотрендеренная страница.

Нам нужно сегментировать трафик так, чтобы готовые облегченные страницы отдать поисковым ботам, готовые полноценные страницы отдать неавторизованным пользователям, а на неотрендеренные страницы, функциональные и закрытые, направлять авторизованных пользователей.

Внимание! Следите за актуализацией отрендеренных страниц в кэше и за их наполнением. Контент страниц, отдаваемый ботам, должен соответствовать контенту, возвращаемому пользователю. Иначе есть риск попасть в бан за подмену страниц.

Рекомендую перенаправлять трафик поисковых ботов до загрузки основного приложения. Например, такие варианты предлагает сервис prerender.io: https://docs.prerender.io/article/12-middlewares. Либо, если вы хотите управлять трафиком на уровне приложения Bitrix, подпишитесь на событие OnPageStart

Всё работает. Что дальше? 

Такой базовой настройки достаточно, чтобы опробовать Puppeteer для рендеринга динамических страниц на любых JS-фреймворках, если в вашей архитектуре невозможно использовать Nuxt и его аналоги. Расширяя функциональность, можно добавлять роуты для управления кэшем, сбора статистики и т. д.

Как я и говорил в дисклеймере, этот инструмент позволяет в короткие сроки для любого сайта без СМС и регистрации подключить технологию серверного рендеринга страниц и кормить поискового бота контентом. Стоит ли на этом останавливаться?

Если вам хочется за короткие сроки попробовать фишки реактивных фреймворков, не интегрируя Nuxt.js, не разрабатывая API для связи бэкенда и фронтенда и не теряя SEO, Puppeteer — идеальный инструмент. После тестирования вашего MVP с реактивными технологиями, я всё-таки рекомендую переходить на классические способы сайтостроения. В нашем случае мы движемся в сторону связки: Bitrix + GraphQL + Nuxt.js. Историю этой крайне интересной интеграции я расскажу в следующей статье. До новых встреч!

P.S. Если задачу, которая стояла перед нами, вы на своих проектах решали иначе, то интересно узнать, как именно. Напишите, пожалуйста, в комментариях!

Материалы и полезные ссылки:

Headless Chrome: an answer to server-side rendering JS sites.

Server side rendering with Puppeteer and Headless Chrome — a practical guide.

Tips and Tricks for Web Scraping with Puppeteer.

Опыт 2 миллионов headless-сессий.

Комментарии (8)


  1. warhamster
    16.09.2021 16:43
    +1

    Контент страниц, отдаваемый ботам, должен соответствовать контенту, возвращаемому пользователю. Иначе есть риск попасть в бан за подмену страниц.

    Я че-то не понял, вы же как раз разный контент и отдаете: пользователь видит, условно говоря, "скелет" без контента, гугль - уже отрендеренный HTML со всем контентом. Или как?


    1. ashutay Автор
      16.09.2021 21:13
      +2

      Неавторизованный пользователь видит страницу с контентом. Для него отдаётся такая же отренедеренная версия, что и для поисковика, но со всем JS. И запускается механизм регидратации, чтобы актуализировать DOM, события и тд.

      Первая проблема: после отдачи уже отрендеренной страницы пользователю, отрабатывают события загрузки страницы и JS начинает заново пересоздавать блоки, начинается мигание, происходит двойная загрузка страницы...

      Здесь нужно научит клиентский код понимать, что текущая страница уже готова, на ней уже есть необходимые DOM объекты и загружать ничего не нужно.

      Вторая проблема это актуализация страницы. Контент-менеджер обновил описание элемента в админке. Кеш в битриксе сбросится автоматически, а вот отрендеренная страница об этом ничего не знает и поисковик/неавторизованный пользователь видит старый контент, а авторизованный пользователь новый.

      И тут нужно подписываться на обновления кеша либо апдейты контентных таблиц и прокидывать из битры триггер на перегенерацию страницы.

      Только авторизованный пользователь - видит скелет. И это сознательное допущение, чтобы не перерисовывать на лету контент, который видоизменяется для авторизованного. Не могу сказать, как это реализуется при использовании других технологий пререндеринга, возможно имеются какие-то изящные решения для авторизованных.


      1. dikko
        16.09.2021 22:04

        То есть за отдачу разного контента поисковикам и пользователям улететь в бан не боитесь?)


        1. ashutay Автор
          16.09.2021 22:20
          +1

          Я ответил в комментарии ниже за что и когда выдаются баны. Мы не вводим пользователя в заблуждение и не отдаём разный контент меняющий смысл страницы. Контент и внешний вид страницы для поисковика и контент страницы для неавторизованного пользователя отличается только наличием тегов <script>.


  1. Goodwinnew
    16.09.2021 17:18
    +1

    >Нам нужно сегментировать трафик так, чтобы готовые облегченные страницы отдать поисковым ботам, готовые полноценные страницы отдать неавторизованным пользователям, а на неотрендеренные страницы, функциональные и закрытые, направлять авторизованных пользователей.

    Кстати, тот же вопрос.

    Боты поисковых систем видят только облегченный вариант (с высокой скоростью загрузки)? Так тот же гугль умеет прикидываться полноценным пользователем (просмотр из браузера со всеми заголовками и событиями).

    И как тут не будет бана за подмену страниц?


    1. ashutay Автор
      16.09.2021 20:50
      +2

      Боты поисковых систем видят только облегченный вариант (с высокой скоростью загрузки)?

      Да, при прямом заходе бота (с заголовками, UA бота), PageSpeed Insights выдаёт 90+. И вы правы, когда гугл "притворяется" пользователем, то он увидит уже отрендеренную версию страницы, но уже плюс JS, аналитика и тд. и оценка будет 80+.

      А на закрытые для определённых групп пользователей страницы, страницы в авторизованном режиме, в общем на не отрендеренные страницы - гугл уже не попадёт.

      И как тут не будет бана за подмену страниц?

      Да, у нас тоже были переживания по этому поводу. Помимо описанного вами случая, поисковики ещё собирают информацию из реального сеанса пользователя, в том числе когда он авторизован.

      Консультировались по поводу случаев когда происходит бан страниц. Поисковые системы, когда обнаруживают существенные разницы в страницах, помечают их как подозрительные и отдают на верификацию человеку. Производится сверка страницы и бан выдаётся "если подменённый контент вводит пользователя в заблуждение".

      Т.е. если для гугла сайт отдаёт страницу с ромашками и в поисковом индексе ромашки. Пользователь переходя из поисковой выдачи думает, что попадает на сайт про ромашки, а в реальности подменяется контент и пользователю отдаётся страница с покупкой Чебурашек - будет бан. Такой разницы у нас конечно нет.

      Также добавлю, что проект категории "не для всех". При заходе на сайт пользователь попадает на страницу верификации. Это отдельная страница выдающаяся по любому адресу сайта. Т.е. в исходном состоянии поисковая система видит только страницу верификации и не индексирует контент.

      У нас уже несколько лет, ещё до внедрения Vue.js, используется механизм определения поискового бота, я немного рассказал про него в статье. Когда на сайт заходит поисковик, то он не уходит на страницу верификации, а сразу видит весь контент и может его индексировать. И даже при таком условии у нас не возникало проблем с индексацией и банами.

      Скажу честно, внушает доверие, что Puppeteer предлагают сами разработчики Lighthouse, как решение для веб-приложений, архитектура которых не предусматривает заложенный изначально SSR.

      Есть риск, что гугл в будущем изменит свою политику? Только одному гуглу известно :) Риски есть всегда. Мы сделали пробу пера с реактивным фреймворком, почти не изменяя текущий код бекенда, результат нас устроил. А дальше уже будет по классике: Bitrix + GraphQL + Nuxt.js ))


      1. Goodwinnew
        16.09.2021 23:20
        +1

        Спасибо за развернутый ответ.

        >Поисковые системы, когда обнаруживают существенные разницы в страницах, помечают их как подозрительные и отдают на верификацию человеку.

        Вот это смело :(

        Из личного опыта - тупо робот по закрытым алгоритмам делает бан и потом нужны танцы с бубном, что бы достучаться до реального живого человека, который будет решать проблему.

        "Малоинформативная страница..." - и по этому кругу можно долго ходить.


  1. AndreyMyagkov
    27.09.2021 12:36

    А не рассматривали вариант, когда контент для поисковика отдается в виде микроразметки (хлебные крошки, контакты компании, товарные предложения, отзывы и тп) плюс мета теги?