<?php
declare(strict_types=1);
namespace WebBundle\Controller;
use DOMDocument;
use DOMElement;
use FlexApp\Service\AdsFilters\FilterAdsFilesDataService;
use RuntimeException;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use WebBundle\Entity\Vacancy;
use WebBundle\Helper\App;
use WebBundle\Helper\LocaleHelper;
use WebBundle\Helper\StrHelper;
use WebBundle\Helper\VacancyHelper;
use WebBundle\Repository\ListCountryRepository;
use WebBundle\Service\SeoService;
use WebBundle\Service\SitemapGenerator;
/**
* Контроллер для SEO-задач (sitemap, robots, html-sitemap и т.д.).
*/
class SeoController extends ExtendedController
{
/** @required */
public SeoService $seoService;
/** @required */
public SitemapGenerator $sitemapGenerator;
private FilterAdsFilesDataService $filterAdsFilesDataService;
public function __construct(FilterAdsFilesDataService $filterAdsFilesDataService)
{
parent::__construct();
$this->filterAdsFilesDataService = $filterAdsFilesDataService;
}
/**
* Главная страница для всех sitemap-файлов (sitemapindex).
* Генерирует ссылку на каждый sitemap для доступных локалей (один маршрут на локаль).
*
* @return Response XML-ответ с индексом sitemap-файлов.
*/
public function indexAction(): Response
{
$siteMap = new DOMDocument('1.0', 'UTF-8');
$siteMap->formatOutput = true;
$sitemapIndex = $siteMap->createElementNS(
"http://www.sitemaps.org/schemas/sitemap/0.9",
'sitemapindex'
);
$sitemapIndex->setAttribute('xmlns', 'http://www.sitemaps.org/schemas/sitemap/0.9');
// Получаем список локалей/стран из сервиса
$localeCountryList = $this->sitemapGenerator->generateLocaleCountryList();
foreach ($localeCountryList as $locale) {
$link = $this->link('seo_sitemap', ['_locale' => $locale]);
$loc = $siteMap->createElement('loc', $link);
$lastmod = $siteMap->createElement('lastmod', date('c'));
$sitemap = $siteMap->createElement('sitemap');
$sitemap->appendChild($loc);
$sitemap->appendChild($lastmod);
$sitemapIndex->appendChild($sitemap);
}
$siteMap->appendChild($sitemapIndex);
$response = new Response($siteMap->saveXML());
$response->headers->set('Content-Type', 'application/xml');
return $response;
}
/**
* Возвращает XML-содержимое sitemap для указанной локали.
*
* @param string $_locale Локаль, например, 'da-dk'.
*
* @return Response XML-ответ с содержимым sitemap.
*/
public function siteMapXmlTEAction(string $_locale): Response
{
try {
$filePath = $this->sitemapGenerator->generateSitemapSinglePath($_locale);
$content = $this->filterAdsFilesDataService->getFileContents($filePath);
} catch (RuntimeException $e) {
throw new NotFoundHttpException(sprintf(
'Sitemap файл не найден для локали "%s". Детали: %s',
$_locale,
$e->getMessage()
));
}
return new Response($content, Response::HTTP_OK, [
'Content-Type' => 'application/xml; charset=UTF-8',
]);
}
/**
* Обрабатывает запросы к частичным Sitemap-файлам.
*
* Этот метод возвращает содержимое определённого файла Sitemap в зависимости от локали и номера части.
*
* @param string $_locale Локаль, например, 'da-dk'
* @param int $part Номер части Sitemap, например, 1
*
* @return Response Ответ с содержимым Sitemap-файла
*/
public function sitemapPartAction(string $_locale, int $part): Response
{
if ($part < 1) {
throw new NotFoundHttpException('Номер части Sitemap должен быть положительным целым числом.');
}
try {
$filePath = $this->sitemapGenerator->generateSitemapPath($_locale, $part);
$content = $this->filterAdsFilesDataService->getFileContents($filePath);
} catch (RuntimeException $e) {
throw new NotFoundHttpException(sprintf(
'Файл Sitemap не найден: sitemap.%s_part%d.xml. Детали: %s',
$_locale,
$part,
$e->getMessage()
));
}
return new Response($content, Response::HTTP_OK, [
'Content-Type' => 'application/xml; charset=UTF-8',
]);
}
/**
* Генерирует файл robots.txt.
*
* @param Request $request HTTP-запрос.
*
* @return Response Ответ с содержимым robots.txt.
*/
public function robotsTxtTEAction(Request $request): Response
{
if (App::getContainer()->getParameter('site') != 'tile.expert' && $request->get('debug') == null) {
$response = $this->render(
'@Web/Seo/robots.txt.twig',
[
'disallow' => false,
]
);
} else {
$list = [
'disallow' => $this->getDisallowUrlPatterns(),
];
foreach (LocaleHelper::getListCode() as $lc) {
foreach (App::getCountryList() as $code => $country) {
if ($code != 'en' && !in_array($lc, $country['localesArr'])) {
$list['full'][] = '/' . $lc . '-' . $code . '$';
$list['full'][] = '/' . $lc . '-' . $code . '/';
}
}
$list['full'][] = '/' . $lc . '-en';
$list['full'][] = '/' . $lc . '_es-cn';
$list['full'][] = '/' . $lc . '-es-cn';
}
$response = $this->render(
'@Web/Seo/robots.txt.twig',
[
'host' => 'tile.expert',
'list' => $list,
'locales' => LocaleHelper::getListCode(),
'countries' => array_keys(App::getCountryList()),
'allows' => $this->getAllowsUrlPatterns(),
]
);
}
$response->headers->set('Content-Type', 'text/plain');
return $response;
}
/**
* Генерирует HTML sitemap-индекс.
*
* @return Response Ответ с содержимым HTML sitemap.
*/
public function sitemapHtmlIndex(): Response
{
$dom = new DOMDocument();
$head = $dom->createElement('head');
$body = $dom->createElement('body');
$dom->appendChild($head);
$dom->appendChild($body);
foreach (LocaleHelper::getListCodeAvailable() as $lc) {
foreach (App::getCountryList() as $code => $country) {
if (
empty($code)
|| ($code === 'en' && $lc === 'en')
|| ($code !== 'en' && !in_array($lc, $country['localesArr']))
|| (!in_array($code, SitemapGenerator::COUNTRIES_EN) && $lc === 'en')
) {
continue;
}
$code = strtolower($code);
if (in_array($code, SitemapGenerator::ONLY_NATIVE_LANG_AVAILABLE)) {
$lang = $code;
} else {
$lang = $lc;
}
$l = LocaleHelper::buildLocaleCountry($lc, $lang, $code);
if ($l === 'en-fi') {
continue;
}
$loc = $dom->createElement('a', "sitemap.$l.html");
$loc->setAttribute('href', $this->link('seo_sitemap_html', ['_locale' => $l]));
$div = $dom->createElement('div');
$div->appendChild($loc);
$body->appendChild($div);
}
}
$response = new Response($dom->saveHTML());
$response->headers->set('Content-Type', 'text/html');
return $response;
}
/**
* Генерирует HTML sitemap для указанной локали.
*
* @param string $_locale Локаль, например, 'da-dk'.
*
* @return Response Ответ с содержимым HTML sitemap.
*/
public function sitemapHtmlTEAction(string $_locale): Response
{
$oTranslator = App::getTranslator();
$locale = explode('-', $_locale);
if (!empty($locale[1]) && in_array($locale[1], SitemapGenerator::ONLY_NATIVE_LANG_AVAILABLE)) {
if ($locale[0] !== $locale[1]) {
$l = $locale[1] . '-' . $locale[1];
return $this->redirect($this->link('seo_sitemap_html', ['_locale' => $l]));
}
}
$dom = new DOMDocument();
$head = $dom->createElement('head');
$body = $dom->createElement('body');
$dom->appendChild($head);
$dom->appendChild($body);
if (
!in_array($locale[0], App::getCountryList()[App::getCurCountry()]['localesArr']) ||
(!empty($locale[1]) && $locale[0] == 'en' && !in_array($locale[1], SitemapGenerator::COUNTRIES_EN))
) {
throw $this->createNotFoundException('Not found sitemap');
}
// главная
$body = $this->siteMapHtmlElem(
$dom,
$body,
$this->link('app_home', ['_locale' => $_locale]),
$oTranslator->trans('home_page')
);
// публикации
$blogs = $this->seoService->getBlogs($locale[0], $locale[1] ?? null);
foreach ($blogs as $blog) {
$body = $this->siteMapHtmlElem(
$dom,
$body,
$this->link('app_publication_single', ['id' => $blog['url'], '_locale' => $_locale]),
$blog['title']
);
}
// коллекции
$collections = $this->seoService->getCollections();
foreach ($collections as $collection) {
if ($collection['f_url'] == 'testing-factory') {
continue;
}
$body = $this->siteMapHtmlElem(
$dom,
$body,
$this->link(
'app_collection',
[
'collectionUrl' => $collection['c_url'],
'factoryUrl' => $collection['f_id'] == 12
? StrHelper::toLower($collection['f_url'])
: $collection['f_url'],
'_locale' => $_locale,
]
),
$collection['c_name']
);
}
// фильтры
$filters = $this->seoService->getFilters($locale[0]);
foreach ($filters as $filter) {
if (in_array($filter['url'], [
'testing-factory',
'cataloge',
'kakelkatalog',
'katalog',
'catalogo',
])) {
continue;
}
$body = $this->siteMapHtmlElem(
$dom,
$body,
$this->link('app_catalog', ['key' => $filter['url'], '_locale' => $_locale]),
$oTranslator->trans($filter['leftMenu'])
);
}
// статика
$statics = $this->seoService->getStatics($locale[0]);
foreach ($statics as $static) {
if ($static['url'] === '_test') {
continue;
}
$body = $this->siteMapHtmlElem(
$dom,
$body,
$this->link('app_page', ['url' => $static['url'], '_locale' => $_locale]),
$static['title'][$locale[0]]
);
}
$response = new Response($dom->saveHTML());
$response->headers->set('Content-Type', 'text/html');
return $response;
}
/**
* Вспомогательный метод для HTML sitemap.
*/
protected function siteMapHtmlElem(
DOMDocument $siteMap,
DOMElement $urlSet,
string $link,
string $name = null
): DOMElement
{
$div = $siteMap->createElement('div');
$a = $siteMap->createElement('a', $name ? htmlspecialchars($name) : $link);
$a->setAttribute('href', $link);
$div->appendChild($a);
$urlSet->appendChild($div);
return $urlSet;
}
/**
* Генерирует индекс sitemap для вакансий.
*
* @return Response XML-ответ с индексом sitemap-файлов вакансий.
*/
public function siteMapXmlJobIndexAction(): Response
{
$siteMap = new DOMDocument('1.0', 'UTF-8');
$siteMap->formatOutput = true;
$sitemapIndex = $siteMap->createElementNS(
"http://www.sitemaps.org/schemas/sitemap/0.9",
'sitemapindex'
);
$sitemapIndex->setAttribute('xmlns', 'http://www.sitemaps.org/schemas/sitemap/0.9');
$locales = VacancyHelper::getAviableLocales();
foreach ($locales as $lc) {
foreach (App::getCountryList() as $code => $country) {
if (
empty($code) ||
($code == 'en' && $lc == 'en') ||
($code != 'en' && !in_array($lc, $country['localesArr'])) ||
(!in_array($code, SitemapGenerator::COUNTRIES_EN) && $lc == 'en')
) {
continue;
}
$code = strtolower($code);
if (in_array($code, SitemapGenerator::ONLY_NATIVE_LANG_AVAILABLE)) {
$lang = $code;
} else {
$lang = $lc;
}
$l = LocaleHelper::buildLocaleCountry($lc, $lang, $code);
$loc = $siteMap->createElement('loc', $this->link('app_jobs_seo_sitemap', ['_locale' => $l]));
$lastmod = $siteMap->createElement('lastmod', date('c'));
$sitemap = $siteMap->createElement('sitemap');
$sitemap->appendChild($loc);
$sitemap->appendChild($lastmod);
$sitemapIndex->appendChild($sitemap);
}
}
$siteMap->appendChild($sitemapIndex);
$response = new Response($siteMap->saveXML());
$response->headers->set('Content-Type', 'application/xml');
return $response;
}
/**
* Генерирует XML sitemap для вакансии указанной локали.
*
* @param string $_locale Локаль, например, 'da-dk'.
*
* @return Response XML-ответ с содержимым sitemap вакансий.
*/
public function siteMapXmlJobAction(string $_locale): Response
{
$locale = explode('-', $_locale);
if (!empty($locale[1]) && in_array($locale[1], SitemapGenerator::ONLY_NATIVE_LANG_AVAILABLE)) {
if ($locale[0] != $locale[1]) {
$l = $locale[1] . '-' . $locale[1];
return $this->redirect(
$this->generateUrl('seo_sitemap', ['_locale' => $l]),
UrlGeneratorInterface::ABSOLUTE_URL
);
}
}
$siteMap = new DOMDocument('1.0', 'UTF-8');
$siteMap->formatOutput = true;
$urlSet = $siteMap->createElementNS("http://www.sitemaps.org/schemas/sitemap/0.9", 'urlset');
$urlSet->setAttribute('xmlns:xsi', 'http://www.w3.org/2001/XMLSchema-instance');
$urlSet->setAttribute(
'xsi:schemaLocation',
'http://www.sitemaps.org/schemas/sitemap/0.9 '
. 'http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd'
);
$code = StrHelper::toUpper($locale[1] ?? $locale[0]);
if (
!in_array($locale[0], App::getCountryList()[$locale[1] ?? App::getCurCountry()]['localesArr'] ?? [])
|| (!empty($locale[1]) && $locale[0] === 'en' && !in_array($locale[1], SitemapGenerator::COUNTRIES_EN))
) {
throw $this->createNotFoundException('Not found site map');
}
/** @var ListCountryRepository $countryRepo */
$countryRepo = $this->getDoctrine()->getRepository('WebBundle:ListCountry');
$country = $countryRepo->getCountry($code);
$priority = ($country && explode('|', $country->getLocale())[0] === $locale[0])
? '1.0'
: '0.5';
$localeUp = StrHelper::ucFirst($locale[0]);
$hide = 'hide' . $localeUp;
// Показать только активные вакансии
$items = $this->getDoctrine()
->getRepository(Vacancy::class)
->findBy(
[
'status' => true,
$hide => false,
],
[
'subject' => 'asc',
]
);
/** @var Vacancy $item */
foreach ($items as $item) {
$urlSet = $this->siteMapXmlElem(
$siteMap,
$urlSet,
$this->link('app_job_show', ['_locale' => $_locale, 'slug' => $item->getSubjectTranslit()]),
$priority
);
}
// Неактивные вакансии с меньшим приоритетом
$priority = '0.5';
$items = $this->getDoctrine()
->getRepository(Vacancy::class)
->findBy(
[
'status' => false,
$hide => false,
],
[
'subject' => 'asc',
]
);
foreach ($items as $item) {
$urlSet = $this->siteMapXmlElem(
$siteMap,
$urlSet,
$this->link('app_job_show', ['_locale' => $_locale, 'slug' => $item->getSubjectTranslit()]),
$priority
);
}
$siteMap->appendChild($urlSet);
$response = new Response($siteMap->saveXML());
$response->headers->set('Content-Type', 'application/xml');
return $response;
}
/**
* Генерирует файл robots.txt для вакансий.
*
* @param Request $request HTTP-запрос.
*
* @return Response Ответ с содержимым robots.txt для вакансий.
*/
public function robotsTxtJobsAction(Request $request): Response
{
if ((bool)$_SERVER['APP_DEBUG']) {
$response = $this->render(
'@Web/Seo/robots.txt.twig',
[
'host' => App::getContainer()->getParameter('jobs_subdomain') . '.' . App::getContainer()->getParameter('site'),
'list' => null,
'disallow' => true,
]
);
} else {
$list = [
'full' => [
'/json/',
'/ru/full/questionnaire/',
'/ru/cv/modal',
'/en-us/full/questionnaire/',
'/en-us/cv/modal',
'/interview/',
'/tmp/_files/cv/',
'/userdirs/vacancy/',
'/ru/comments/create',
'/ru/comment-form/',
'/ru/comment-block/',
'/en-us/comments/create',
'/en-us/comment-form/',
'/en-us/comment-block/',
],
];
$response = $this->render(
'@Web/Seo/robots.txt.twig',
[
'host' => App::getContainer()->getParameter('jobs_subdomain') . '.' . App::getContainer()->getParameter('site'),
'list' => $list,
'locales' => LocaleHelper::getListCode(),
'countries' => array_keys(App::getCountryList()),
'allows' => [],
]
);
}
$response->headers->set('Content-Type', 'text/plain');
return $response;
}
/**
* Вспомогательная логика для робота (Disallow и т.д.).
*
* @return array Список шаблонов URL для Disallow в robots.txt.
*/
private function getDisallowUrlPatterns(): array
{
return [
'*/z_*',
'*/rasprodazha*',
'*/sale*',
'*/svendita*',
'*/docs/improving-site*',
'*/docs/payment-usca*',
'*/docs/payment-eact*',
'*/docs/payment-eu*',
'*/docs/payment-new*',
'*/catalogue/coll/*',
'*/catalogue/release-now*',
'*/catalogue/release-before-2017*',
'*/catalogue/release-2017*',
'*/catalogue/release-2018*',
'*/catalogue/release-2019*',
'*/catalogue/cevisama-2017*',
'*/catalogue/cevisama-2018*',
'*/catalogue/cevisama-2019*',
'*/catalogue/cevisama-2020*',
'*/catalogue/cersaie-2017*',
'*/catalogue/cersaie-2018*',
'*/catalogue/cersaie-2019*',
'*/catalogue/cersaie-2020*',
'*/catalogue/cersaie-2021*',
'*/catalogue/cersaie-2022*',
'*/catalogue/no-exhibitions-2020*',
'*/catalogue/no-exhibitions-2021*',
'*/catalogue/coverings-2018*',
'*/catalogue/coverings-2019*',
'*/catalogue/cataloge*',
'/blog/20-',
'*/rebajas*',
'*/destockage*',
'*/ausverkauf*',
'*/wyprzedaz*',
'*/opruiming-tegels*',
'*/alennusmyynti*',
'*/best-price*',
'*/miglior-prezzo*',
'*/mejor-precio*',
'*/meilleur-prix*',
'*/besten-preis*',
'*/najlepsza-cena*',
'*/beste-prijs*',
'*/paras-hinta*',
'*/basta-pris*',
'*/likvidaciya*',
'*/clearance*',
'*/liquidazione*',
'*/liquidacion*',
'*/liquidation*',
'*/liquidierung*',
'*/likwidacja*',
'*/opruiming*',
'*/poistomyynti*',
'*/top-20-mesyaca*',
'*/top-20-month*',
'*/kuukauden-top-20-lista*',
'*/manadens-topp-20*',
'*/manedens-20-mest-populære*',
'*/top-20-do-mes*',
];
}
/**
* Вспомогательная логика для робота (Allow и т.д.).
*
* @return array Список шаблонов URL для Allow в robots.txt.
*/
private function getAllowsUrlPatterns(): array
{
return [];
}
/**
* Генерирует абсолютную ссылку, заменяя & на & для корректного отображения в XML.
*
* @param string $route Имя маршрута.
* @param array $params Параметры маршрута.
*
* @return string Абсолютная ссылка с заменёнными амперсандами.
*/
public function link(string $route, $params = []): string
{
return str_replace(
'&',
'&',
$this->generateUrl($route, $params, UrlGeneratorInterface::ABSOLUTE_URL)
);
}
/**
* Вспомогательный метод для формирования <url> в sitemap Jobs.
*
* @param DOMDocument $siteMap DOM-документ sitemap.
* @param DOMElement $urlSet Элемент <urlset> в документе.
* @param string $link Абсолютная ссылка.
* @param string $priority Приоритет URL (по умолчанию '0.5').
* @param string $changefreq Частота изменений URL (по умолчанию 'daily').
* @param string $date Дата последнего изменения (по умолчанию текущая дата).
*
* @return DOMElement Обновлённый элемент <urlset>.
*/
protected function siteMapXmlElem(
DOMDocument $siteMap,
DOMElement $urlSet,
string $link,
string $priority = '0.5',
string $changefreq = 'daily',
string $date = ''
): DOMElement
{
$url = $siteMap->createElement('url');
$loc = $siteMap->createElement('loc', $link);
$url->appendChild($loc);
if (empty($date)) {
$date = date('c');
}
$lastmod = $siteMap->createElement('lastmod', $date);
$freq = $siteMap->createElement('changefreq', $changefreq);
$prio = $siteMap->createElement('priority', $priority);
$url->appendChild($lastmod);
$url->appendChild($freq);
$url->appendChild($prio);
$urlSet->appendChild($url);
return $urlSet;
}
}