feat: счётчики с consent, политика, фиксы дизайна

Дизайн-фиксы:
- Перенесены картинки из старого WP в public/wp-content/uploads/
  (header_bg.gif + 3 размера NY-baner.png 2014/12). migrate-wp.mjs
  обновлён: корректно обрабатывает <a><img></a>, переписывает абсолютные
  URL anotherreflections.ru/wp-content/... в относительные
- Description в frontmatter теперь чистый plain text — без markdown-разметки
- Слаги 11/95/152 → kto-my, s-23-fevralya-2011, s-nastupayushhim-novym-2012-godom
- Hero-метрика: «N лет онлайн» → «с 2006 года» (последний пост 2015,
  активной жизни «20 лет» нет)
- Drop cap (буквица) — только для постов длиннее 240 символов
  (короткие посты раньше выглядели обрезанными)

Аналитика и конфиденциальность:
- Яндекс.Метрика (counter 13938862, webvisor) и Google gtag (GT-PH39R3X)
  перенесены со старого WP
- Cookie consent banner — самописный, без зависимостей: счётчики грузятся
  только после явного «Принять». Выбор хранится в localStorage + cookie
  ar-consent на 12 месяцев. Уведомление в духе 152-ФЗ
- /privacy/ — полная политика конфиденциальности: что собираем (через
  Метрику и GA), для чего, как cookies устроены, права пользователя,
  контакт для запросов на удаление
- В футере добавлены ссылки «Политика конфиденциальности» и «Контакты»
- sitemap.txt + llms.txt включают /privacy/
This commit is contained in:
2026-05-21 02:07:08 +03:00
parent 864819a67c
commit 6f2cfdd63d
19 changed files with 310 additions and 13 deletions

View File

@@ -13,6 +13,7 @@
- [Миры — 8 игровых вселенных](https://anotherreflections.ru/miry/)
- [Наши друзья](https://anotherreflections.ru/nashi-druzya/)
- [Контакты](https://anotherreflections.ru/kontakty/)
- [Политика конфиденциальности](https://anotherreflections.ru/privacy/)
## Игровые форумы

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

View File

@@ -28,15 +28,22 @@ const decodeEntities = (s) => s
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&');
/** Заменить старые WP-uploads URL на относительные (мы скачали в public/wp-content/uploads/). */
const rewriteWpUploads = (url) =>
url.replace(/^https?:\/\/anotherreflections\.ru(\/wp-content\/uploads\/.+)$/i, '$1');
const htmlToMd = (html) => {
if (!html) return '';
let s = html;
// images
// Сначала "вложенные" <a href="..."><img src="..."></a> — превращаем в один markdown-image со ссылкой
s = s.replace(/<a\s+[^>]*?href=["']([^"']+)["'][^>]*>\s*<img[^>]*?src=["']([^"']+)["'][^>]*?(?:alt=["']([^"']*)["'])?[^>]*?\/?>\s*<\/a>/gi,
(_, href, src, alt) => `[![${alt || ''}](${rewriteWpUploads(src)})](${rewriteWpUploads(href)})`);
// одиночные images
s = s.replace(/<img[^>]*?src=["']([^"']+)["'][^>]*?(?:alt=["']([^"']*)["'])?[^>]*?\/?>/gi,
(_, src, alt) => `![${alt || ''}](${src})`);
(_, src, alt) => `![${alt || ''}](${rewriteWpUploads(src)})`);
// links
s = s.replace(/<a\s+[^>]*?href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi,
(_, href, text) => `[${text.replace(/<[^>]+>/g,'').trim()}](${href})`);
(_, href, text) => `[${text.replace(/<[^>]+>/g,'').trim()}](${rewriteWpUploads(href)})`);
// bold
s = s.replace(/<(strong|b)\b[^>]*>([\s\S]*?)<\/\1>/gi, '**$2**');
// italic
@@ -106,7 +113,12 @@ for (const p of data.posts) {
categories: p.categories.map(c => c.name),
categorySlugs: p.categories.map(c => c.slug),
tags: p.tags.map(t => t.name),
description: (htmlToMd(p.excerpt) || htmlToMd(p.content_html)).slice(0, 200).replace(/\s+/g,' ').trim(),
description: (htmlToMd(p.excerpt) || htmlToMd(p.content_html))
.replace(/!?\[([^\]]*)\]\([^)]+\)/g, '$1') // strip markdown images/links для excerpt
.replace(/[*_>#]+/g, '') // strip md formatting символов
.replace(/\s+/g, ' ')
.trim()
.slice(0, 200),
};
const body = htmlToMd(p.content_html);
const out = `${fmtFrontmatter(fm)}\n\n${body}\n`;

View File

@@ -0,0 +1,37 @@
---
import { ANALYTICS } from '../consts';
const { yandexMetrika, googleGtag } = ANALYTICS;
---
<!-- Аналитика грузится только после согласия пользователя (cookie consent). -->
<script
is:inline
type="text/plain"
data-cookieconsent="statistics"
set:html={`
(function(m,e,t,r,i,k,a){m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)};
m[i].l=1*new Date();
for (var j = 0; j < document.scripts.length; j++) {if (document.scripts[j].src === r) { return; }}
k=e.createElement(t),a=e.getElementsByTagName(t)[0],k.async=1,k.src=r,a.parentNode.insertBefore(k,a)})
(window, document, "script", "https://mc.yandex.ru/metrika/tag.js", "ym");
ym(${yandexMetrika}, "init", { defer:true, clickmap:true, trackLinks:true, accurateTrackBounce:true, webvisor:true });
`} />
<noscript><div><img src={`https://mc.yandex.ru/watch/${yandexMetrika}`} style="position:absolute; left:-9999px;" alt="" /></div></noscript>
<script
is:inline
type="text/plain"
data-cookieconsent="statistics"
async
src={`https://www.googletagmanager.com/gtag/js?id=${googleGtag}`}
></script>
<script
is:inline
type="text/plain"
data-cookieconsent="statistics"
set:html={`
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '${googleGtag}');
`} />

View File

@@ -0,0 +1,73 @@
---
// Минимальный self-hosted cookie consent под 152-ФЗ:
// — баннер показывается до явного выбора (cookie `ar-consent`)
// — «Принять» → активирует все <script type="text/plain" data-cookieconsent="statistics">
// — «Только необходимые» → ничего не активирует, баннер исчезает
// — выбор хранится в localStorage и cookie на 12 месяцев
---
<div id="cookie-consent" class="cookie-consent" hidden>
<div class="cookie-text">
<p>
Мы используем cookies и сервисы статистики (Яндекс.Метрика, Google Analytics),
чтобы понимать, как вы пользуетесь сайтом, и делать его лучше.
Подробнее — в <a href="/privacy/">политике конфиденциальности</a>.
</p>
</div>
<div class="cookie-actions">
<button type="button" class="cookie-btn cookie-btn-secondary" data-cc-deny>Только необходимые</button>
<button type="button" class="cookie-btn cookie-btn-primary" data-cc-accept>Принять</button>
</div>
</div>
<script is:inline>
(function () {
const KEY = 'ar-consent';
const banner = document.getElementById('cookie-consent');
if (!banner) return;
const activate = () => {
document.querySelectorAll('script[type="text/plain"][data-cookieconsent="statistics"]').forEach((tpl) => {
const s = document.createElement('script');
for (const a of tpl.attributes) {
if (a.name === 'type' || a.name === 'data-cookieconsent') continue;
s.setAttribute(a.name, a.value);
}
s.text = tpl.textContent || '';
tpl.parentNode.insertBefore(s, tpl);
tpl.parentNode.removeChild(tpl);
});
};
const setConsent = (value) => {
try { localStorage.setItem(KEY, value); } catch {}
const exp = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toUTCString();
document.cookie = `${KEY}=${value}; expires=${exp}; path=/; SameSite=Lax`;
};
const current = (() => {
try {
const ls = localStorage.getItem(KEY);
if (ls) return ls;
} catch {}
const m = document.cookie.match(/(?:^|; )ar-consent=([^;]+)/);
return m ? decodeURIComponent(m[1]) : null;
})();
if (current === 'accept') {
activate();
} else if (current === 'deny') {
// ничего не делаем — статистика не активируется
} else {
banner.hidden = false;
}
banner.querySelector('[data-cc-accept]')?.addEventListener('click', () => {
setConsent('accept');
banner.hidden = true;
activate();
});
banner.querySelector('[data-cc-deny]')?.addEventListener('click', () => {
setConsent('deny');
banner.hidden = true;
});
})();
</script>

View File

@@ -5,6 +5,11 @@ export const SOCIAL = {
export const CONTACT_EMAIL = 'info@anotherreflections.ru';
export const ANALYTICS = {
yandexMetrika: '13938862', // counter ID, webvisor включён
googleGtag: 'GT-PH39R3X', // GTM/GA4 measurement
};
/** Русская плюрализация: [1, 2-4, 5+]. Пример: plural(8, ['мир', 'мира', 'миров']) → 'миров' */
export function plural(n: number, forms: [string, string, string]): string {
const a = Math.abs(n) % 100;

View File

@@ -8,10 +8,10 @@ categories:
- "Общие новости"
categorySlugs:
- "obshhie-novosti"
description: "[![](http://anotherreflections.ru/wp-content/uploads/2014/12/NY-baner-300x51.png)](http://anotherreflections.ru/wp-content/uploads/2014/12/NY-baner.png) Подходит к своему завершению 2014 год."
description: "[![](/wp-content/uploads/2014/12/NY-baner-300x51.png)](/wp-content/uploads/2014/12/NY-baner.png) Подходит к своему завершению 2014 год. В жизни каждого из нас произошло много событий - ярких,"
---
[![](http://anotherreflections.ru/wp-content/uploads/2014/12/NY-baner-300x51.png)](http://anotherreflections.ru/wp-content/uploads/2014/12/NY-baner.png)
[![](/wp-content/uploads/2014/12/NY-baner-300x51.png)](/wp-content/uploads/2014/12/NY-baner.png)

View File

@@ -2,7 +2,7 @@
title: "Кто мы?"
pubDate: "2013-08-25T03:15:01+03:00"
updatedDate: "2013-10-06T02:05:52+03:00"
slug: "11"
slug: "kto-my"
legacyId: "365"
author: "admin"
categories:

View File

@@ -2,7 +2,7 @@
title: "С 23 февраля!"
pubDate: "2011-02-23T00:48:39+03:00"
updatedDate: "2013-03-29T14:34:37+03:00"
slug: "95"
slug: "s-23-fevralya-2011"
legacyId: "95"
author: "admin"
categories:

View File

@@ -2,7 +2,7 @@
title: "С наступающим Новым 2012 годом!"
pubDate: "2012-01-01T00:27:00+03:00"
updatedDate: "2013-03-29T14:35:17+03:00"
slug: "152"
slug: "s-nastupayushhim-novym-2012-godom"
legacyId: "152"
author: "admin"
categories:

View File

@@ -3,6 +3,8 @@ import '../styles/global.css';
import { SITE_TITLE, SITE_DESCRIPTION, SITE_URL, SITE_LANG, MAIN_NAV } from '../consts';
import BrandMark from '../components/BrandMark.astro';
import SocialLinks from '../components/SocialLinks.astro';
import Analytics from '../components/Analytics.astro';
import CookieConsent from '../components/CookieConsent.astro';
interface Props {
title?: string;
@@ -39,6 +41,8 @@ const year = new Date().getFullYear();
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Cormorant+Garamond:wght@500;600;700&display=swap" rel="stylesheet" />
<Analytics />
</head>
<body>
<header class="site-header">
@@ -67,7 +71,14 @@ const year = new Date().getFullYear();
<footer class="site-footer">
<span class="footer-ornament"></span>
<p>© 2006{year} · {SITE_TITLE}</p>
<p><a href="/feed.xml">RSS</a> · <a href="/sitemap-index.xml">Sitemap</a></p>
<p>
<a href="/feed.xml">RSS</a> ·
<a href="/sitemap-index.xml">Sitemap</a> ·
<a href="/privacy/">Политика конфиденциальности</a> ·
<a href="/kontakty/">Контакты</a>
</p>
</footer>
<CookieConsent />
</body>
</html>

View File

@@ -14,6 +14,9 @@ export async function getStaticPaths() {
const { entry, kind } = Astro.props;
const { Content } = await render(entry);
// Буквица — только для постов с телом длиннее короткого порога.
const bodyLen = entry.body?.length ?? 0;
const useDropCap = kind === 'post' && bodyLen > 240;
const fmtDate = (d: Date) =>
d.toLocaleDateString('ru-RU', { year: 'numeric', month: 'long', day: 'numeric' });
@@ -41,7 +44,7 @@ const fmtDate = (d: Date) =>
</div>
)}
</header>
<div class="post-body">
<div class={`post-body ${useDropCap ? 'has-dropcap' : ''}`}>
<Content />
</div>
</article>

View File

@@ -21,7 +21,7 @@ const fmtDate = (d: Date) =>
<div class="hero-meta">
<span><strong>{totalWorlds}</strong> {plural(totalWorlds, ['мир', 'мира', 'миров'])}</span>
<span><strong>{totalPosts}</strong> {plural(totalPosts, ['публикация', 'публикации', 'публикаций'])}</span>
<span><strong>{new Date().getFullYear() - SITE_FOUNDED}</strong> {plural(new Date().getFullYear() - SITE_FOUNDED, ['год', 'года', 'лет'])} онлайн</span>
<span><strong>с {SITE_FOUNDED}</strong> года</span>
</div>
</section>

96
src/pages/privacy.astro Normal file
View File

@@ -0,0 +1,96 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import { CONTACT_EMAIL, SITE_URL } from '../consts';
const lastUpdated = '20 мая 2026 г.';
---
<BaseLayout
title="Политика конфиденциальности"
description="Какие данные собирает сайт anotherreflections.ru, для чего и как с ними обращаются."
>
<article class="post">
<header>
<h1>Политика конфиденциальности</h1>
<div class="post-meta">
<time>Обновлено: {lastUpdated}</time>
</div>
</header>
<div class="post-body">
<p>
Настоящая Политика описывает, какие данные собирает сайт <a href={SITE_URL}>anotherreflections.ru</a>
(далее — «Сайт»), для чего они используются и как с ними обращаются. Политика составлена
в соответствии с Федеральным законом от 27.07.2006 № 152-ФЗ «О персональных данных».
</p>
<h2>1. Какие данные мы собираем</h2>
<p>Сайт не требует регистрации и не запрашивает у посетителя ни имени, ни телефона, ни почты.</p>
<p>
При посещении сайта автоматически собираются обезличенные технические данные через
сервисы статистики:
</p>
<ul>
<li><strong>Яндекс.Метрика</strong> — IP-адрес (обезличенный), тип устройства и браузера,
источник перехода, поведение на страницах, в том числе запись действий (Вебвизор).</li>
<li><strong>Google Analytics</strong> — аналогичный обезличенный набор технических данных
о посещении.</li>
</ul>
<p>
Эти данные собираются <em>только после</em> того, как вы согласились на использование
статистики через всплывающее уведомление о cookies. Если вы выбрали «Только необходимые»,
статистика не загружается.
</p>
<h2>2. Cookies</h2>
<p>Сайт использует следующие cookies:</p>
<ul>
<li><strong>Технические</strong> — для запоминания вашего выбора по уведомлению о cookies
(cookie <code>ar-consent</code>, срок 12 месяцев). Без согласия не устанавливаются никакие
другие.</li>
<li><strong>Аналитические</strong> (после согласия) — устанавливаются скриптами
Яндекс.Метрики и Google Analytics для подсчёта уникальных посетителей и анализа
поведения. Подробнее — на страницах
<a href="https://yandex.ru/legal/metrica_termsofuse/" target="_blank" rel="noopener">Условия использования Яндекс.Метрики</a>
и <a href="https://policies.google.com/privacy" target="_blank" rel="noopener">Политика конфиденциальности Google</a>.</li>
</ul>
<h2>3. Зачем мы это делаем</h2>
<p>Статистика помогает нам понимать:</p>
<ul>
<li>сколько людей и откуда приходит на сайт;</li>
<li>какие разделы и публикации читают чаще;</li>
<li>какие страницы работают плохо (медленно загружаются, неудобны на мобильном).</li>
</ul>
<p>На основе этого мы улучшаем сайт. Данные используются в обезличенном виде и не позволяют идентифицировать конкретного человека.</p>
<h2>4. Передача данных третьим лицам</h2>
<p>
Обезличенные технические данные передаются операторам сервисов статистики
(Яндекс, Google) — это необходимо для работы самих сервисов. Иным третьим лицам ничего не передаётся.
</p>
<h2>5. Ваши права</h2>
<p>Вы можете в любой момент:</p>
<ul>
<li>Отозвать согласие на статистику — очистите cookies сайта или удалите <code>ar-consent</code>
в DevTools. При следующем визите снова появится уведомление, выберите «Только необходимые».</li>
<li>Запросить удаление ваших данных из систем статистики — обратитесь напрямую к операторам
(<a href="https://yandex.ru/support/metrica/" target="_blank" rel="noopener">Яндекс</a>,
<a href="https://support.google.com/analytics/" target="_blank" rel="noopener">Google</a>).</li>
<li>Связаться с администрацией сайта по адресу <a href={`mailto:${CONTACT_EMAIL}`}>{CONTACT_EMAIL}</a>
по любым вопросам, связанным с данными.</li>
</ul>
<h2>6. Изменения</h2>
<p>
Мы можем обновлять эту Политику. Актуальная версия всегда доступна по адресу
<a href="/privacy/">/privacy/</a>. Дата последнего обновления указана в начале документа.
</p>
<h2>7. Контакты</h2>
<p>
По вопросам обработки персональных данных:
<a href={`mailto:${CONTACT_EMAIL}`}>{CONTACT_EMAIL}</a>.
</p>
</div>
</article>
</BaseLayout>

View File

@@ -10,6 +10,7 @@ export async function GET(_context: APIContext) {
'/',
'/miry/',
'/kontakty/',
'/privacy/',
];
// Категории — собираем из постов

View File

@@ -412,7 +412,7 @@ pre {
.post-body {
font-size: 1.05rem;
}
.post-body p:first-of-type::first-letter {
.post-body.has-dropcap p:first-of-type::first-letter {
font-family: var(--font-serif);
font-size: 3.2em;
float: left;
@@ -563,6 +563,64 @@ pre {
line-height: 1.5;
}
/* ============================== COOKIE CONSENT ============================== */
.cookie-consent {
position: fixed;
left: 1rem;
right: 1rem;
bottom: 1rem;
max-width: 720px;
margin: 0 auto;
padding: 1.1rem 1.4rem;
background: rgba(17, 20, 29, 0.96);
backdrop-filter: blur(10px);
border: 1px solid var(--border-strong);
border-radius: 10px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.6);
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 1rem;
z-index: 60;
font-size: .9rem;
}
.cookie-text { flex: 1 1 320px; }
.cookie-text p { margin: 0; color: var(--fg-muted); line-height: 1.5; }
.cookie-text a { color: var(--link); }
.cookie-actions {
display: flex;
gap: .55rem;
flex-wrap: wrap;
}
.cookie-btn {
padding: .55em 1.1em;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--bg-elev);
color: var(--fg);
font-family: inherit;
font-size: .88rem;
font-weight: 500;
cursor: pointer;
transition: border-color .2s, background .2s, color .2s;
}
.cookie-btn:hover { border-color: var(--accent); color: var(--accent); }
.cookie-btn-primary {
background: var(--accent-deep);
border-color: var(--accent-deep);
color: #fff;
}
.cookie-btn-primary:hover {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
@media (max-width: 640px) {
.cookie-consent { padding: .9rem 1rem; left: .5rem; right: .5rem; bottom: .5rem; }
.cookie-actions { width: 100%; justify-content: flex-end; }
}
/* ============================== SOCIAL RAIL ============================== */
.social-rail {
position: fixed;