gendesign/tradein-mvp/frontend/scripts/og/og-mera.html
bot-backend a82382f47a
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m5s
feat(mera-b2c): микроразметка schema.org и картинки превью для публичного сайта
Сайт открыт для индексации 10.09.2026, но поисковику он до сих пор
представлялся девятью страницами без единого структурированного факта о
том, кто их публикует и что продаёт, а ссылка на него в мессенджере
разворачивалась голым текстом без картинки.

МИКРОРАЗМЕТКА. Конструкторы узлов вынесены в `_lib/schema.ts` — чистый
модуль без React, по образцу соседнего `_lib/analytics.ts`. Все значения
берутся из `content.ts`, ни одной строки не продублировано:

- `Organization` + `WebSite` одним `@graph` в layout поддерева, то есть на
  всех девяти страницах сразу. Остальные узлы ссылаются на организацию
  через `@id`, а не повторяют реквизиты у себя.
- `Service` с `Offer` на лэндинге.
- `FAQPage` в блоке возражений.
- `BreadcrumbList` на статьях, статье, документах и странице для бизнеса —
  ровно по тем крошкам, что видны на экране.
- Существующий `Article` переведён на ссылку `publisher: { "@id": ... }`.

РАЗМЕТКА НЕ ОБЕЩАЕТ ТОГО, ЧЕГО НЕТ. Два места, где это стоило внимания:

- `Offer.availability` вычисляется из `PUBLIC_ESTIMATE_ENABLED`. Флаг сейчас
  `false` (платёжного контура в коде нет), поэтому в разметку уходит
  `PreOrder`, а не `InStock`: цена опубликована, купить нельзя, и врать об
  этом поисковику нечего. Включится приём оплаты — значение сменится само.
- `FAQPage` строится из того же массива `ITEMS`, который рисует `<details>`,
  а не из `FAQ` напрямую: в `ITEMS` часть вопросов заменена макетными
  формулировками, и разметка от сырого `FAQ` разошлась бы с видимым текстом
  молча. Выдуманных дат, рейтингов и отзывов не добавлено нигде.

КАРТИНКИ. Две штуки, и это не дубль: `og-mera.png` 1200×630 — превью ссылки,
`logo-mera.png` 512×512 — логотип организации в разметке, который поисковик
обрезает близко к квадрату и где баннер превратился бы в обрезок надписи.
Исходники обеих лежат рядом в `scripts/og/*.html` вместе с командой
перерисовки: картинка должна оставаться правимой, а не только
переоткрываемой в графическом редакторе.

Готовые PNG, а не `opengraph-image.tsx`: satori внутри `ImageResponse` рисует
только переданными ему байтами шрифта и кириллицу по умолчанию не покрывает,
плюс не видит ни CSS Modules, ни наших `--b2c-*`. Разбор — в шапке
`scripts/og/og-mera.html`.

`images` продублирован в `twitter`: Next не переносит их из `openGraph`, когда
метаданные заданы объектом, и карточка обещала бы крупное превью без картинки.

ПЕРИМЕТР. Обе картинки лежат в `public/` и раздаются Next'ом по
basePath-корню, куда rewrite `@meraPages` не достаёт, — отсюда два отдельных
`handle` по образцу robots.txt. Оба названы в `ROOT_HANDLES_ALLOWED`, иначе
двусторонний гейт периметра покраснел бы, и это правильно: корневой `handle`
— вторая дверь в тот же периметр.

Смоук проверяет не только 200, но и Content-Type: перепутанный rewrite отдаёт
200 с HTML, обходчик мессенджера молча его отбрасывает, и по логам приложения
этого не видно — запрос туда не доходит.

Проверено: tsc, eslint, 215 тестов mera-public, изоляция B2C-дерева, caddy
validate, bash -n смоука, прод-сборка с basePath и разбор отрендеренного
HTML — JSON-LD парсится, og:image и twitter:image на месте.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiUFZ3rmTNpp3DRajUo8KQ
2026-09-10 19:06:35 +03:00

264 lines
8.5 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<title>OG-карточка МЕРА</title>
<!--
ИСХОДНИК КАРТИНКИ ДЛЯ ПРЕВЬЮ ССЫЛОК (og:image), 1200x630.
Отсюда рендерится `public/og-mera.png` — тот файл и уходит в мессенджеры и
соцсети. Держим исходник в репозитории, чтобы картинку можно было
перерисовать, а не только переоткрыть в графическом редакторе.
КАК ПЕРЕРИСОВАТЬ (нужен установленный Chrome/Chromium):
chrome --headless --disable-gpu --hide-scrollbars \
--screenshot=public/og-mera.png --window-size=1200,630 \
scripts/og/og-mera.html
ПОЧЕМУ ГОТОВЫЙ PNG, А НЕ `opengraph-image.tsx` (ImageResponse из next/og).
Тот путь красивее по духу — картинка собиралась бы из кода на каждой сборке,
— но у него два практических изъяна ровно для нашего случая. Первый: satori
внутри ImageResponse рисует ТОЛЬКО теми шрифтами, которые ему передали
байтами, а дефолтный набор кириллицы не покрывает — пришлось бы положить
.ttf в репозиторий и читать его в рантайме. Второй: satori понимает лишь
подмножество CSS и не видит ни CSS Modules, ни наших `--b2c-*` переменных,
так что «те же токены, что на сайте» там всё равно превратились бы в
отдельный набор литералов. Готовый PNG отдаётся статикой, кэшируется
навсегда и не стоит ничего в рантайме.
ЦВЕТА ЗДЕСЬ ЛИТЕРАЛАМИ — это единственное место в публичном дереве, где так
можно: файл не участвует в сборке фронта, `var(--b2c-*)` в нём резолвить
некому. Значения обязаны совпадать с `src/app/mera-public/b2c-tokens.ts`;
меняются токены — меняется и эта карточка, руками.
-->
<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=Manrope:wght@400;500;600;800&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
/* Значения — копия b2c-tokens.ts (см. комментарий выше). */
:root {
--page-bg: #F4F7F8;
--surface: #FFFFFF;
--surface-soft: #EAF5F7;
--ink: #121A1D;
--muted: #5B686C;
--line: #CBD7DA;
--accent: #16A9BC;
--accent-text: #0B6B79;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 1200px;
height: 630px;
overflow: hidden;
}
body {
background: var(--page-bg);
font-family: "Manrope", system-ui, sans-serif;
color: var(--ink);
-webkit-font-smoothing: antialiased;
}
.card {
position: relative;
width: 1200px;
height: 630px;
padding: 60px 72px 56px;
display: grid;
grid-template-columns: 640px 1fr;
grid-template-rows: auto 1fr auto;
column-gap: 56px;
align-items: start;
}
/* Акцентная полоса по левому краю — тот же приём, что у секций лэндинга. */
.card::before {
content: "";
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 10px;
background: var(--accent);
}
/* --- шапка: знак и словесный логотип, как в HeaderV3 --- */
.logo {
grid-column: 1 / -1;
display: flex;
align-items: center;
gap: 14px;
}
.logoIcon {
width: 46px;
height: 46px;
border: 2.5px solid var(--accent);
border-radius: 7px;
display: grid;
place-items: center;
}
.logoDot {
width: 15px;
height: 15px;
background: var(--accent);
border-radius: 3px;
}
.logoText { display: flex; flex-direction: column; line-height: 1; }
.logoName { font-weight: 800; font-size: 30px; letter-spacing: 0.06em; }
.logoSub {
margin-top: 5px;
font-family: "IBM Plex Mono", ui-monospace, monospace;
font-size: 14px;
letter-spacing: 0.18em;
color: var(--muted);
}
/* --- середина слева: заголовок и подзаголовок --- */
.pitch { align-self: center; }
.headline {
font-size: 58px;
line-height: 1.1;
font-weight: 800;
letter-spacing: -0.02em;
}
.lead {
margin-top: 24px;
font-size: 25px;
line-height: 1.4;
font-weight: 500;
color: var(--muted);
}
/* --- середина справа: диапазон цены. Продукт называет ВИЛКУ, а не одно
число, и картинка обязана говорить то же самое. --- */
.range {
align-self: center;
padding: 28px 30px 32px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: 18px;
}
.rangeLabel {
font-family: "IBM Plex Mono", ui-monospace, monospace;
font-size: 13px;
letter-spacing: 0.16em;
color: var(--muted);
}
.rangeBar {
margin-top: 22px;
height: 12px;
border-radius: 6px;
background: var(--surface-soft);
position: relative;
}
.rangeFill {
position: absolute;
left: 18%;
right: 22%;
top: 0;
bottom: 0;
border-radius: 6px;
background: var(--accent);
}
.rangeMark {
position: absolute;
left: 52%;
top: -7px;
width: 4px;
height: 26px;
border-radius: 2px;
background: var(--ink);
}
.rangeEnds {
margin-top: 16px;
display: flex;
justify-content: space-between;
font-family: "IBM Plex Mono", ui-monospace, monospace;
font-size: 15px;
font-weight: 500;
color: var(--ink);
}
.rangeNote {
margin-top: 18px;
font-size: 15px;
line-height: 1.45;
color: var(--muted);
}
/* --- подвал: регион слева, домен справа --- */
.foot {
grid-column: 1 / -1;
display: flex;
align-items: center;
justify-content: space-between;
gap: 32px;
padding-top: 28px;
border-top: 1px solid var(--line);
}
.region {
font-family: "IBM Plex Mono", ui-monospace, monospace;
font-size: 17px;
letter-spacing: 0.14em;
color: var(--muted);
}
.domain {
font-family: "IBM Plex Mono", ui-monospace, monospace;
font-size: 24px;
font-weight: 500;
color: var(--accent-text);
}
</style>
</head>
<body>
<div class="card">
<div class="logo">
<div class="logoIcon"><div class="logoDot"></div></div>
<div class="logoText">
<div class="logoName">МЕРА</div>
<div class="logoSub">ДЛЯ СЕБЯ</div>
</div>
</div>
<div class="pitch">
<!-- Дословно h1 лэндинга (HeroV3.tsx) — превью не должно обещать
другого, чем страница, на которую ведёт ссылка. -->
<div class="headline">Реальная цена вашей квартиры&nbsp;— чтобы не продешевить</div>
<div class="lead">Сделки Росреестра и объявления площадок&nbsp;— по вашему адресу.</div>
</div>
<div class="range">
<div class="rangeLabel">ДИАПАЗОН ЦЕНЫ</div>
<div class="rangeBar">
<div class="rangeFill"></div>
<div class="rangeMark"></div>
</div>
<div class="rangeEnds"><span>ниже</span><span>выше</span></div>
<!-- Ни одного числа: любая подставленная сумма читалась бы как обещание
конкретной оценки. Показываем форму ответа, а не выдуманный ответ. -->
<div class="rangeNote">Вилка, а не одно число: рядом видно, на скольких объектах она построена.</div>
</div>
<div class="foot">
<div class="region">СВЕРДЛОВСКАЯ ОБЛАСТЬ</div>
<div class="domain">meraocenka.ru</div>
</div>
</div>
</body>
</html>