feat: ga consent mode v2

This commit is contained in:
2026-10-12 17:32:16 +05:00
parent 1981afbc64
commit dad71f0bda
4 changed files with 188 additions and 151 deletions
+7 -1
View File
@@ -55,7 +55,9 @@
закрытия. закрытия.
- **Один шаг — одна проблема**, примерно до 500 строк; не смешивать lint, i18n, - **Один шаг — одна проблема**, примерно до 500 строк; не смешивать lint, i18n,
UI и рефакторинг. UI и рефакторинг.
- **Не коммитить.** Коммит и пуш делает разработчик после ревью. - **Не коммитить.** Коммит и пуш делает разработчик после ревью. **И не
напоминать про них:** пользователь сам решает, когда коммитить и деплоить, — в
отчёте о шаге этого не предлагать.
- **Не запускать тяжёлое без спроса.** Playwright — только по явной команде - **Не запускать тяжёлое без спроса.** Playwright — только по явной команде
пользователя, один прогон, без циклов повторов и фонового запуска. пользователя, один прогон, без циклов повторов и фонового запуска.
- **Не писать изменяемые числа** (количество инструментов, тестов, локалей, - **Не писать изменяемые числа** (количество инструментов, тестов, локалей,
@@ -102,6 +104,10 @@
`completed`, `archived`). `completed`, `archived`).
- Не дублировать длинные объяснения в нескольких файлах: короткая формулировка и - Не дублировать длинные объяснения в нескольких файлах: короткая формулировка и
ссылка на канонический документ. ссылка на канонический документ.
- **Отмеченная `[x]` задача — короткая запись:** что решили и почему, без дат,
пересказа сделанного и списка файлов — это видно из коммита. Длинный текст
оправдан только там, где решение не восстановить из кода (отклонение от
прошлого плана, причина нестандартного решения).
- **Backlog живёт открытыми задачами:** выполненные удаляются, а критичные для - **Backlog живёт открытыми задачами:** выполненные удаляются, а критичные для
проекта факты переносятся в тематический документ. Завершённые планы и проекта факты переносятся в тематический документ. Завершённые планы и
исследования — в `docs/archive/`, удаляются через 30 дней; процедура в исследования — в `docs/archive/`, удаляются через 30 дней; процедура в
+104 -149
View File
@@ -4,12 +4,10 @@
> и подобные), каталог — `/list-tools`; реестр двухслойный: страница `slug` > и подобные), каталог — `/list-tools`; реестр двухслойный: страница `slug`
> ссылается на инструмент по `id` (`docs/architecture.md`, раздел 3). > ссылается на инструмент по `id` (`docs/architecture.md`, раздел 3).
> >
> **`noindex, nofollow` выставлен на каждой странице инструмента** > **`noindex, nofollow` стоит на странице инструмента без текста**
> (`web/src/routes/tools/[slug]/+page.svelte`) — осознанно, до появления > (`web/src/routes/tools/[slug]/+page.svelte`) и снимается автоматически, как
> уникального контента (S2). Пока он стоит, сайт не индексируется вообще, и > только у страницы появляется EN-`seo`-блок: индексность следует за контентом
> **S1b–S1e (sitemap, `robots.txt` с `Sitemap:`, canonical, OG) не дают > (S1h, §4).
> эффекта**: их имеет смысл делать вместе со снятием `noindex`, а не вместо
> него. Условие снятия и порядок — §4, S1.
> >
> **Цель:** страница каждого инструмента — индексируемый лендинг под конкретный > **Цель:** страница каждого инструмента — индексируемый лендинг под конкретный
> интент («crop png», «make png transparent», «jpg to png»…) плюс попадание в > интент («crop png», «make png transparent», «jpg to png»…) плюс попадание в
@@ -82,18 +80,16 @@
позиционированию, в отличие от счётчика посещений. Формулировку «без трекинга» позиционированию, в отличие от счётчика посещений. Формулировку «без трекинга»
на публичных страницах не использовать: она теперь неточна, см. §1 и S4b. на публичных страницах не использовать: она теперь неточна, см. §1 и S4b.
- **Три разных артефакта на странице, три разных источника.** `<title>` для - **Три разных артефакта на странице, три разных источника.** `<title>` для
выдачи (`seo.metaTitle`, ~55–60 символов **без префикса бренда** — «Crop PNG выдачи (`seo.metaTitle`, ~55–60 символов **без префикса бренда** — бренд не
Online - Free Image Cropper, No Upload»: бренд не приносит собственных приносит собственных поисковых запросов и съедает треть выдачи),
поисковых запросов и съедает треть выдачи), `meta description` `meta description` (`seo.metaDescription`, ~150–160 символов) и H1
(`seo.metaDescription`, ~150–160 символов) и H1 (`pages.<slug>.title`, чистый (`pages.<slug>.title`). **«free / no upload / нет лимитов» не идут в H1:**
интент: «Crop PNG online»). **Решение по «free / no upload / нет лимитов»:** Google не вознаграждает ключи в заголовке, переписывает title примерно в 60%
прямо в H1 они не идут — Google не вознаграждает ключи в заголовке, случаев, а список выгод в H1 читается как сборка ключей — эти обещания
переписывает title примерно в 60% случаев, а список выгод в H1 читается как работают в description (влияет на кликабельность) и в первом абзаце. **Форматы
сборка ключей. Эти обещания работают в `meta description` (влияет на в H1 не перечисляем:** широкие интенты («crop jpg») закрывает текст,
кликабельность) и в первом абзаце. **Форматы в H1 не перечисляем:** широкие description и FAQ — это же запрещено §5 как doorway-подход. Реестр не трогаем:
интенты («crop jpg») закрывает текст первого абзаца, description и FAQ, а не `title` в нём остаётся для каталога и цепочек, SEO-заголовки живут в словаре.
заголовок; это же запрещено §5 как doorway-подход. Реестр не трогаем: `title`
в нём остаётся для каталога и цепочек, SEO-заголовки живут в словаре.
- **Честные методы.** Никакого клонированного текста, дорвеев, PBN. Контент — по - **Честные методы.** Никакого клонированного текста, дорвеев, PBN. Контент — по
каждому инструменту, с ручной вычиткой. каждому инструменту, с ручной вычиткой.
@@ -114,19 +110,21 @@
инструментов против конкурента: `docs/tools-map.md`. инструментов против конкурента: `docs/tools-map.md`.
- EN-тексты в реестре + i18n-словари ru/en. - EN-тексты в реестре + i18n-словари ru/en.
Чего нет (пробелы закрываются этапами ниже): Чего нет на момент аудита (закрыто этапами §4 — смотри чекбоксы там, здесь
состояние не дублируется):
| Пробел | Где сейчас | Этап | | Пробел | Где тогда | Этап |
| -------------------------------------------------- | -------------------------------- | ---- | | -------------------------------------------------- | ----------------------------------- | ---- |
| `noindex` на страницах инструментов | стоит (`+page.svelte`) | S1 | | `noindex` на страницах инструментов | стоит (`+page.svelte`) | S1 |
| `sitemap.xml` | генерируется при сборке (S1b) | — | | `sitemap.xml` | нет | S1 |
| `robots.txt` без `Sitemap:` | allow-all + `Sitemap:` (S1c) | — | | `robots.txt` без `Sitemap:` | allow-all (`web/static/robots.txt`) | S1 |
| `rel=canonical` | есть, `Seo.svelte` (S1d) | — | | `rel=canonical` | нет | S1 |
| OG/Twitter-теги | есть; per-tool картинки — S1e | S1 | | OG/Twitter-теги, OG-картинки | нет | S1 |
| Контент страницы (how-to, FAQ, related) | title, robots и отрендеренный H1 | S2 | | Канонический домен в абсолютных URL | нет, деплой на GitHub Pages с базой | S1 |
| JSON-LD (SoftwareApplication, FAQPage, Breadcrumb) | нет | S3 | | Контент страницы (how-to, FAQ, related) | title, robots и отрендеренный H1 | S2 |
| `llms.txt`, хабы категорий с текстом | нет | S3 | | JSON-LD (SoftwareApplication, FAQPage, Breadcrumb) | нет | S3 |
| GSC + доводка GA до cookieless-конфигурации | GA4 стоит, GSC не подключён | S4 | | `llms.txt`, хабы категорий с текстом | нет | S3 |
| GSC + доводка GA до cookieless-конфигурации | GA4 стоит, GSC не подключён | S4 |
Строка `noindex` — первая не потому, что её сложнее всего, а потому, что она Строка `noindex` — первая не потому, что её сложнее всего, а потому, что она
блокирует всё остальное: пока тег стоит, остальные строки таблицы не дают блокирует всё остальное: пока тег стоит, остальные строки таблицы не дают
@@ -180,24 +178,18 @@
Разбито на две группы: **domain-free** (делаем параллельно с покупкой домена) и Разбито на две группы: **domain-free** (делаем параллельно с покупкой домена) и
**domain-required** (после покупки и DNS-настройки). **domain-required** (после покупки и DNS-настройки).
- [x] **[S1h]** Robots-тег на страницах инструментов — **сделано 2026-10-12, но - [x] **[S1h]** Robots-тег сделан **условным, а не снятым**: `index, follow`
не снят, а сделан условным** (`web/src/routes/tools/[slug]/+page.svelte`): ставится тогда и только тогда, когда у страницы есть EN-`seo`-блок, иначе
`index, follow` ставится **тогда и только тогда, когда у страницы есть `noindex, nofollow`. Единого места правки нет — индексность следует за
EN-`seo`-блок** (`isPageIndexable()` в контентом, поэтому страницу нельзя забыть при написании текста и нельзя
`web/src/lib/i18n/schema-tool-strings.ts`), иначе `noindex, nofollow`. проиндексировать с заготовкой. Тот же признак выкидывает noindex-страницы
Единого места правки нет: индексность следует за контентом, поэтому из sitemap, так что список в sitemap и набор индексируемых страниц не
страницу нельзя забыть при написании текста и нельзя проиндексировать с разойдутся. **Страховка от «сломать руками»:** контентный гейт в
заготовкой. Читается базовый словарь, а не объединённый, — переключение `web/src/lib/i18n/schema-tool-strings.test.ts` — страница с текстом
посетителя на RU не переворачивает тег. Тот же признак выкидывает
noindex-страницы из sitemap (`gen-sitemap.mjs`), так что список в sitemap
и набор индексируемых страниц не могут разойтись. **Страховка от «сломать
руками»:** контентный гейт вынесен в
`web/src/lib/i18n/schema-tool-strings.test.ts` — страница с `seo`-блоком
обязана иметь ≥250 слов, ≥3 шага how-to, ≥3 FAQ и title/description в обязана иметь ≥250 слов, ≥3 шага how-to, ≥3 FAQ и title/description в
границах SERP, иначе тест падает до индексации. Проверено на сборке: границах SERP, иначе тест падает до индексации. **Осталось:** снять
`index, follow` ровно на страницах с текстом, `noindex` на остальных, `noindex` совсем, когда текст появится у всех страниц, которые имеет смысл
sitemap совпадает с этим набором. **Осталось:** снять `noindex` совсем, индексировать.
когда текст появится у всех страниц, которые имеет смысл индексировать.
#### S1-free: без домена #### S1-free: без домена
@@ -227,35 +219,23 @@
#### S1-domain: свой домен (подключён; §6 R2-cut) #### S1-domain: свой домен (подключён; §6 R2-cut)
- [x] **[S1a]** Перевести прод на собственный домен — **сделано 2026-10-11**: - [x] **[S1a]** Перевести прод на собственный домен: `BASE_PATH` убран из
`BASE_PATH` убран из `deploy.yml`, задан `PUBLIC_SITE_ORIGIN`, добавлен `deploy.yml`, задан `PUBLIC_SITE_ORIGIN`, добавлен `web/static/CNAME`.
`web/static/CNAME`. Редирект `https://<юзер>/<repo>/…` → корневые URL Редирект `https://<юзер>/<repo>/…` → корневые URL (301) делает сам GitHub
(301) делает сам GitHub Pages при включённом кастомном домене. Делается Pages при включённом кастомном домене.
одновременно с S1d (canonical), чтобы Google проиндексировал сразу с - [x] **[S1b]** `web/scripts/gen-sitemap.mjs` пишет `sitemap.xml` в `build/`
правильным доменом. после `vite build`. Адреса берутся **из собранного HTML**, а не из импорта
- [x] **[S1b]** Скрипт `web/scripts/gen-sitemap.mjs` — **сделано 2026-10-12**: `PAGES`: prerender-страницы и есть фактический набор URL, а `noindex`
пишет `sitemap.xml` в `build/` после `vite build` (вызов добавлен в исключается автоматически — sitemap не может разойтись с тем, что
`pnpm build`). Адреса берутся **из собранного HTML**, а не из импорта задеплоено. Origin — `PUBLIC_SITE_ORIGIN`, фолбэк — `static/CNAME`, чтобы
`PAGES`: список prerender-страниц и есть фактический набор URL, а страницы не плодить третий литерал домена.
с `noindex` в HTML автоматически исключаются — sitemap не может разойтись - [x] **[S1c]** `robots.txt`: `Sitemap:` добавлен, allow-all сохранён.
с тем, что реально задеплоено, и не рекламирует то, что мы просим **Служебный `/kit` закрыт через `noindex` в HTML, а не через `Disallow:`**
краулеров игнорировать. Проверено: в sitemap попадают `/`, `/list-tools`, — заблокированный краулером, но не помеченный noindex URL Google помечает
`/chains`, но не `/kit`, `/pipeline` и страницы инструментов (пока S1h). «проиндексирован, несмотря на блокировку в robots.txt».
Origin — `PUBLIC_SITE_ORIGIN`, фолбэк — `static/CNAME` (тот же домен, что - [x] **[S1d]** `Seo.svelte` (canonical, OG, `twitter:card`) подключён на `/`,
публикует GitHub Pages, без третьей копии литерала); опциональный `/list-tools`, `/chains`, `/tools/[slug]`. Картинка пока общая
`BASE_PATH` учитывается как в `vite.config.ts`. `og/default.png` — per-tool остаётся за S1e.
- [x] **[S1c]** `robots.txt` — **сделано 2026-10-12**: `Sitemap:` добавлен,
allow-all сохранён. **Служебный `/kit` закрыт не через `Disallow:`, а
через `noindex` в HTML** (как `/pipeline`): заблокированный краулером, но
не помеченный noindex URL Google помечает «проиндексирован, несмотря на
блокировку в robots.txt» и оставляет старый сниппет. Побочная выгода: тот
же признак автоматически выкидывает `/kit` из sitemap.
- [x] **[S1d]** Компонент `Seo.svelte` (kit) — **был сделан до S1b, отмечено
2026-10-12**: canonical (абсолютный URL от `PUBLIC_SITE_ORIGIN` +
`SITE_URL`, `web/src/lib/seo.ts`), OG (title/description/type/url/image) и
`twitter:card`. Подключён на `/`, `/list-tools`, `/chains`,
`/tools/[slug]`. Картинка пока общая `og/default.png` — per-tool остаётся
за S1e.
> **Порядок S1:** S1e/S1f/S1g → покупка домена → S1a+S1b+S1c+S1d → **S1h** > **Порядок S1:** S1e/S1f/S1g → покупка домена → S1a+S1b+S1c+S1d → **S1h**
> (снятие `noindex`) → S2-контент. Гейты: `pnpm --dir web build` зелёный; в > (снятие `noindex`) → S2-контент. Гейты: `pnpm --dir web build` зелёный; в
@@ -266,42 +246,25 @@
> `e2e` non-blocking, поэтому его статус читается по отдельному job > `e2e` non-blocking, поэтому его статус читается по отдельному job
> (`docs/decisions.md`, раздел 10). > (`docs/decisions.md`, раздел 10).
> >
> **Фактически (2026-10-12) порядок изменился:** S2-контент написан раньше > **Фактический порядок другой:** контент S2 написан раньше, а S1h стал
> снятия `noindex`, а S1h сделан условным — индексность страницы равна наличию > условным. Гейт «`noindex` отсутствует» выполняется для страниц с текстом;
> её текста (S1h). Гейт выше в части «`noindex` отсутствует» выполняется для > полное снятие тега остаётся отдельной задачей.
> страниц с текстом; полное снятие тега остаётся отдельной задачей.
### S2. Контентный слой (параллельно quality-плану, волнами по категориям) ### S2. Контентный слой (параллельно quality-плану, волнами по категориям)
- [x] **[S2a]** Схема данных: `seo`-блок per-tool — **сделано 2026-10-12**: тип - [x] **[S2a]** Тип `SeoContent` (`longDescription`, `howTo`, `faq`) в
`SeoContent` (`longDescription`, `howTo: string[]`, `faq: {q, a}[]`) в `web/src/lib/i18n/dict.ts`, доступ `pageSeo(page)`. Хранится в словаре
`web/src/lib/i18n/dict.ts`, поле `seo?` в `PageStrings`, доступ `pages.<slug>.seo`, не в реестре: длинного текста в реестре быть не может.
`pageSeo(page)` в `web/src/lib/i18n/schema-tool-strings.ts`. Хранится в EN-only до решения по `/ru/` — паритет локалей снят исключением
словаре `pages.<slug>.seo`, не в реестре: у реестра нет EN-копии длинного `ignoreMissingPatterns` в `web/eslint.config.js`.
текста по определению, а словарь — единственное место, где лежит текст. - [x] **[S2b]** `web/src/lib/components/PageContent.svelte`: абзац → `<ol>`
**Пока EN-only:** `ignoreMissingPatterns: ["pages.*.seo"]` в how-to → FAQ в `<details>`, под рабочей областью инструмента. Рендерится
`web/eslint.config.js` снимает требование паритета локалей для этого только при наличии `seo`-блока, поэтому страницы без текста не меняются.
блока, RU появится вместе с `/ru/` (§0). Первые тексты — `crop-png`, - [x] **[S2c]** `Breadcrumbs.svelte` над H1 (средняя крошка — текст до появления
`resize-png`. хабов категорий, S3c) и блок Related под FAQ. **Related выводится, а не
- [x] **[S2b]** Компонент контента страницы инструмента — **сделано 2026-10-12** перечисляется:** `relatedPages()` берёт соседей по категории и добирает из
минимальной версией: `web/src/lib/components/PageContent.svelte` рендерит каталога, если категория мала; новый инструмент попадает в чужие related
`longDescription` абзацем, `howTo` списком `<ol>`, `faq` через `<details>` сам.
(текст в DOM, ответ не требует скролла); подключён в
`web/src/routes/tools/[slug]/+page.svelte` под рабочей областью
инструмента и рендерится только при наличии `seo`-блока, так что страницы
без текста не меняются. Новый токен `--size-content-prose` (мера колонки
текста) появился вместе с потребителем. **Не сделано и вынесено в S2c:**
related tools и хлебные крошки.
- [x] **[S2c]** Хлебные крошки (Каталог → Категория → Инструмент) и related
tools — **сделано 2026-10-12**: `Breadcrumbs.svelte` (над H1, первая
крошка ссылается на `/list-tools`, средняя — текст до появления хабов
категорий, S3c) и блок Related из 6 карточек под FAQ. **Related выводится,
а не перечисляется:** `relatedPages()` в `web/src/lib/catalog.ts` берёт
соседей по категории в порядке каталога и добирает из остального каталога,
если категория мала (в `text` всего три страницы — это поймал тест).
Список руками не ведётся, новый инструмент попадает в чужие related сам.
Тесты: `web/src/lib/catalog.test.ts` (категория, детерминированность,
достаточность соседей).
- [ ] **[S2d]** Первая волна контента — топ приоритетных инструментов ядра - [ ] **[S2d]** Первая волна контента — топ приоритетных инструментов ядра
(приоритет по вордстату: transparent png, jpg→png, compress, resize, crop, (приоритет по вордстату: transparent png, jpg→png, compress, resize, crop,
rotate, circle crop, add text, pixelate, grayscale…); отбор — §5. **Ядро rotate, circle crop, add text, pixelate, grayscale…); отбор — §5. **Ядро
@@ -311,15 +274,13 @@
`change-png-opacity`, `set-alpha-channel-png`, `round-corners-png`, `change-png-opacity`, `set-alpha-channel-png`, `round-corners-png`,
`invert-colors-png`, `flip-png`, `convert-png-to-webp`, `png-to-bmp`, `invert-colors-png`, `flip-png`, `convert-png-to-webp`, `png-to-bmp`,
`svg-to-png`, `png-to-base64`, `change-aspect-ratio-png`, `add-text-png`. `svg-to-png`, `png-to-base64`, `change-aspect-ratio-png`, `add-text-png`.
Каждая: `title` (H1), `metaTitle` (38–54 симв.), `metaDescription` Текст написан по фактической схеме инструмента, без подстановки в шаблон.
(134–159 симв.), `longDescription`, how-to (3–5 шагов) и 4 FAQ. Текст **Правило набора текста:** H1 — чистый интент, «free / no upload» — в
написан по фактической схеме инструмента (параметры реестра), без `metaTitle` и `metaDescription`, форматы — в первом абзаце и FAQ,
шаблонной подстановки. **Правило набора текста, которое доказало себя на ограничения инструмента проговариваются прямо («это не AI-удаление фона»,
пилоте:** H1 — чистый интент, «free / no upload» — в `metaTitle` и «только 90/180/270, произвольный угол ресемплит»): честное ограничение
`metaDescription`, форматы — в первом абзаце и FAQ, ограничения отвечает на вопрос People-Also-Ask лучше приписывания. Остальное ядро §5 —
инструмента проговариваются прямо («это не AI-удаление фона», «только тем же шаблоном.
90/180/270, произвольный угол ресемплит»). Честное ограничение работает
лучше приписывания. Остальное ядро §5 — тем же шаблоном.
- Гейты: на каждой странице инструмента — уникальный текст ≥ 300 слов, H1 - Гейты: на каждой странице инструмента — уникальный текст ≥ 300 слов, H1
совпадает с интентом, FAQ ≥ 3, related ≥ 3; текст в статике (prerender), не в совпадает с интентом, FAQ ≥ 3, related ≥ 3; текст в статике (prerender), не в
рантайме. рантайме.
@@ -331,13 +292,11 @@
(из тех же крошек, что видны в UI); на главной — `WebSite` + (из тех же крошек, что видны в UI); на главной — `WebSite` +
`Organization`; на каталоге — `BreadcrumbList`. Построители в `Organization`; на каталоге — `BreadcrumbList`. Построители в
`web/src/lib/seo.ts`, вывод — `web/src/lib/components/JsonLd.svelte`. `web/src/lib/seo.ts`, вывод — `web/src/lib/components/JsonLd.svelte`.
Проверено на сборке: три разбора в `build/tools/crop-png.html`, FAQPage с **Известная грязь:** в `JsonLd.svelte` инлайновый
4 вопросами, два узла на главной. **Известная грязь:** в `JsonLd.svelte` `eslint-disable svelte/no-at-html-tags` — `<script>{json}</script>` не
инлайновый `eslint-disable svelte/no-at-html-tags` — `{@html}` подставляет значение (Svelte читает содержимое script как raw text),
единственный способ вставить значение в Svelte, а `{@html}` остаётся единственным способом. Suppress обоснован
`<script>{json}</script>` не работает (Svelte читает содержимое script как экранированием `<`; пересмотр — задача в `docs/backlog.md`.
raw text). Suppress обоснован экранированием `<` в `\u003c`, но
пересмотрен не позже: задача в `docs/backlog.md`, раздел «Тесты и lint».
- [ ] **[S3b]** `llms.txt` в статике: короткий манифест сайта + список - [ ] **[S3b]** `llms.txt` в статике: короткий манифест сайта + список
инструментов с одним предложением на каждый (генерится из реестра тем же инструментов с одним предложением на каждый (генерится из реестра тем же
скриптом, что sitemap). скриптом, что sitemap).
@@ -361,12 +320,14 @@
- [ ] **[S4a]** Google Search Console: собственность на домен, отправка sitemap, - [ ] **[S4a]** Google Search Console: собственность на домен, отправка sitemap,
мониторинг индексации и запросов. Бесплатно и обязательно. мониторинг индексации и запросов. Бесплатно и обязательно.
- [ ] **[S4b]** Довести GA4 до решения §0: cookieless-конфигурация, отключённые - [x] **[S4b]** GA4 в cookieless-конфигурации: Consent Mode v2 со всеми `denied`
рекламные audiences и signals, `anonymize_ip`, запрет на передачу **до** вызова `config`, рекламные signals и audiences выключены,
параметров с данными картинки. Мусорные попадания уже отсечены: тег `ip_collection: false`. **Про `anonymize_ip` из исходной формулировки
подключается только при `PUBLIC_GA_ENABLED=true` (выставлен в задачи:** в GA4 параметра нет, IP там не сохраняется целиком — поэтому
deploy-workflow, `+layout.svelte`). GA/ЯМ как замена счётчику не `ip_collection: false`, а не мёртвый флаг. Запрет на данные картинки
рассматриваем. проверяем: тег нигде, кроме `ga.js`, не вызывается
(`web/src/lib/analytics-config.test.ts`). Формулировка «без трекинга» на
публичных страницах по-прежнему не используется.
- [ ] **[S4c]** Дистрибуция: закрепить GitHub-репо (README со скриншотами), - [ ] **[S4c]** Дистрибуция: закрепить GitHub-репо (README со скриншотами),
подача на alternative.to (страница против конкурента), Product Hunt, подача на alternative.to (страница против конкурента), Product Hunt,
тематические Reddit/HN-треды. Бэклинки — главный дефицит для конкуренции. тематические Reddit/HN-треды. Бэклинки — главный дефицит для конкуренции.
@@ -483,24 +444,18 @@ with PNG, JPG, WebP» в тексте + селектор формата в Downl
Бренд — `easy-image-tools`, домен — `easy-image-tools.com`. Бренд — `easy-image-tools`, домен — `easy-image-tools.com`.
- [x] **[R1]** Переезд бренда выполнен (2026-10-11): хардкод в компонентах - [x] **[R1]** Переезд бренда: хардкод убран, бренд — i18n-константа
убран, бренд — i18n-константа `t("brand")` (точка истины — словари `t("brand")` (точка истины — словари `brand` + README). Репозиторий и
`brand` + README). Репозиторий переименован в `easy-image-tools`, remote remote переименованы, привязанные ссылки/пути переключены. **НЕ трогать:**
обновлён, привязанные к репо ссылки/пути переключены: `BASE_PATH` в адреса страниц инструментов (`/tools/crop-png`), localStorage-ключи
`deploy.yml`, GitHub-URL в `TopBar.svelte`, `repository` в `cog.toml`. (бренда не содержат), историю `docs/archive`.
**НЕ трогать:** адреса страниц инструментов (`/tools/crop-png`), - [x] **[R2]** Домен `easy-image-tools.com` зарегистрирован; `SITE_URL` в
localStorage-ключи (бренда не содержат), историю `docs/archive`. `web/src/lib/seo.ts` переключён на него.
- [x] **[R2]** Домен `easy-image-tools.com` зарегистрирован (2026-10-11); - [x] **[R2-cut]** Прод на домене без `BASE_PATH` (§0): DNS + HTTPS,
`SITE_URL` в `web/src/lib/seo.ts` переключён на него. `PUBLIC_SITE_ORIGIN` в `deploy.yml`, `web/static/CNAME`. 301 со старого
- [x] **[R2-cut]** Прод переведён на домен без `BASE_PATH` (§0) — **сделано GitHub Pages делает сам GitHub при включённом кастомном домене.
2026-10-11**: DNS + HTTPS, `PUBLIC_SITE_ORIGIN` = - **Порядок:** переименование делали до индексации: после снятия `noindex` это
`https://easy-image-tools.com` в `deploy.yml`, `web/static/CNAME`. 301 со был бы переезд с 301 и потеря позиций — не делать.
старого GitHub Pages делает сам GitHub при включённом кастомном домене.
Canonical и sitemap генерятся от `PUBLIC_SITE_ORIGIN`.
- **Порядок:** R1(код), покупка домена и cutover (R2-cut) сделаны. Осталось
S1-domain (canonical + sitemap: S1b/S1c/S1d), а S1-domain — блокер для S1h
(снятие `noindex`). Переименование делали до индексации (до S1h): после снятия
`noindex` это был бы переезд с 301 и потеря позиций — не делать.
## 7. Что осознанно НЕ делаем ## 7. Что осознанно НЕ делаем
+55
View File
@@ -0,0 +1,55 @@
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
// The analytics tag ships as a static file (`web/static/ga.js`) so it can be
// gated by PUBLIC_GA_ENABLED at build time; these guards cover the promises of
// docs/plan-seo.md section 0 - cookieless, no ads, and nothing about the image.
const WEB_DIR = fileURLToPath(new URL("../../", import.meta.url));
const GA_PATH = join(WEB_DIR, "static", "ga.js");
function sourceFiles(dir: string): string[] {
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
const full = join(dir, entry.name);
if (entry.isDirectory()) return sourceFiles(full);
return entry.name.endsWith(".ts") || entry.name.endsWith(".svelte")
? [full]
: [];
});
}
describe("GA tag configuration", () => {
const ga = readFileSync(GA_PATH, "utf8");
it("denies analytics and ad storage before the config call", () => {
for (const key of [
"analytics_storage",
"ad_storage",
"ad_user_data",
"ad_personalization",
]) {
expect(ga, key).toMatch(new RegExp(`${key}:\\s*"denied"`));
}
expect(ga.indexOf('gtag("consent"')).toBeLessThan(
ga.indexOf('gtag("config"'),
);
});
it("keeps remarketing and Google signals off", () => {
expect(ga).toMatch(/allow_google_signals:\s*false/);
expect(ga).toMatch(/allow_ad_personalization_signals:\s*false/);
});
it("sends nothing but the page view", () => {
const callers = sourceFiles(join(WEB_DIR, "src"))
.filter((file) => !file.endsWith(".test.ts"))
.filter((file) =>
/gtag\s*\(|dataLayer\.push/.test(readFileSync(file, "utf8")),
);
expect(
callers.map((file) => file.replace(WEB_DIR, "")),
"gtag is configured in one place only",
).toEqual([]);
});
});
+22 -1
View File
@@ -2,5 +2,26 @@ window.dataLayer = window.dataLayer || [];
function gtag() { function gtag() {
window.dataLayer.push(arguments); window.dataLayer.push(arguments);
} }
// Consent Mode v2, denied by default, before any config call: analytics then
// run in cookieless mode (pings without cookies, no cross-site identity), which
// is the configuration promised in `docs/plan-seo.md` section 0. No consent UI
// is planned - nothing here is granted by default.
gtag("consent", "default", {
analytics_storage: "denied",
ad_storage: "denied",
ad_user_data: "denied",
ad_personalization: "denied",
functionality_storage: "denied",
personalization_storage: "denied",
security_storage: "granted",
});
gtag("js", new Date()); gtag("js", new Date());
gtag("config", "G-MEQJ7JHRQQ"); gtag("config", "G-MEQJ7JHRQQ", {
// No remarketing and no ad audiences: this site takes no ads (section 0).
allow_google_signals: false,
allow_ad_personalization_signals: false,
// GA4 drops the full IP either way; this removes the parameter outright.
ip_collection: false,
});