{
  "version": "https://jsonfeed.org/version/1.1",
  "title": "artka.dev — Записки из продакшна",
  "description": "Заметки про Claude Code, AI-агентов, RAG-пайплайны и production backend от Artyom Kashuta.",
  "home_page_url": "https://artka.dev/",
  "feed_url": "https://artka.dev/feed.json",
  "language": "ru-RU",
  "authors": [
    {
      "name": "Artyom Kashuta",
      "url": "https://artka.dev/about",
      "avatar": "https://artka.dev/avatar-512.png"
    }
  ],
  "icon": "https://artka.dev/icon-512.png",
  "favicon": "https://artka.dev/favicon.svg",
  "items": [
    {
      "id": "https://artka.dev/blog/claude-md-12-rules",
      "url": "https://artka.dev/blog/claude-md-12-rules",
      "title": "12 правил для CLAUDE.md: расширение Karpathy на ошибки 2026 года",
      "summary": "Mnilax протестировал 12 правил для CLAUDE.md на 30 кодовых базах за 6 недель — расширение шаблона Karpathy на agent-loops, чекпойнты и fail-loud. Разбор и рамка применения.",
      "date_published": "2026-05-10T00:00:00.000Z",
      "tags": [
        "ai",
        "claude-code",
        "prompt-engineering"
      ],
      "authors": [
        {
          "name": "Artyom Kashuta",
          "url": "https://artka.dev/about",
          "avatar": "https://artka.dev/avatar-512.png"
        }
      ],
      "language": "ru-RU",
      "content_html": "<blockquote>\n<p>За четыре месяца после январского треда Karpathy шаблон <code>CLAUDE.md</code> из 4 правил вырос до 12. Прогнал расширенный набор на типичных задачах своего блога и нескольких рабочих репо — частота молчаливых ошибок Claude Code снижается заметно. Восемь добавленных правил закрывают то, чего в январе ещё не было как класса проблем: long-running agent loops, кросс-сессионные потоки, shallow-тесты, тихие провалы вместо явных ошибок. Открыл собственный <code>CLAUDE.md</code> этого блога — четыре исходных правила Karpathy там уже есть в <code>Стандарты кода</code> и <code>Запреты</code>, восемь добавленных — нет. Разбираю каждое, и где имеет смысл вставить.</p>\n</blockquote>\n<hr>\n<h2>1. Что случилось за четыре месяца</h2>\n<p>В конце января Andrej Karpathy опубликовал тред с тремя жалобами на Claude как code-writer:</p>\n<ul>\n<li>silent wrong assumptions — модель додумывает контекст, не уточняет;</li>\n<li>over-complication — добавляет уровни абстракции, которых никто не просил;</li>\n<li>orthogonal damage — лезет в код, который не должна была трогать.</li>\n</ul>\n<p>Forrest Chang упаковал жалобы в <code>CLAUDE.md</code> из четырёх поведенческих правил и закоммитил на GitHub. Репо разлетелось — на момент мая больше 100 тысяч звёзд, самый быстрорастущий single-file проект года. Дальше шаблон оброс расширением: восемь дополнительных правил, которые покрывают то, что в январе ещё не было фокусом, потому что не было того ландшафта Claude Code, который есть сейчас.</p>\n<pre><code class=\"language-mermaid\">flowchart LR\n  subgraph jan[&quot;Январь 2026&quot;]\n    K[&quot;Karpathy: тред с тремя&lt;br/&gt;failure modes&quot;]\n    K --&gt; F[&quot;Forrest Chang:&lt;br/&gt;4 правила CLAUDE.md&quot;]\n  end\n  subgraph may[&quot;Май 2026&quot;]\n    F --&gt; N[&quot;Новые failure modes:&lt;br/&gt;agent loops, multi-codebase,&lt;br/&gt;shallow tests, silent failures&quot;]\n    N --&gt; M[&quot;+8 правил,&lt;br/&gt;итого 12&quot;]\n  end\n</code></pre>\n<hr>\n<h2>2. Четыре правила Karpathy</h2>\n<p>Это базис. Без них любая надстройка теряет половину смысла.</p>\n<table>\n<thead>\n<tr>\n<th>#</th>\n<th>Правило</th>\n<th>Что закрывает</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>1</td>\n<td>Think Before Coding</td>\n<td>Молчаливые догадки. Озвучивай предположения, спрашивай при неясности, толкайся когда есть проще.</td>\n</tr>\n<tr>\n<td>2</td>\n<td>Simplicity First</td>\n<td>Минимум кода, который решает задачу. Никаких спекулятивных абстракций «на будущее».</td>\n</tr>\n<tr>\n<td>3</td>\n<td>Surgical Changes</td>\n<td>Трогай только то, что нужно. Не «улучшай» соседний код, не переформатируй то, о чём не просили.</td>\n</tr>\n<tr>\n<td>4</td>\n<td>Goal-Driven Execution</td>\n<td>Описывай критерии успеха, не пошаговую инструкцию. Сильный success-criteria даёт модели итерироваться сам.</td>\n</tr>\n</tbody>\n</table>\n<p>В моём <code>CLAUDE.md</code> Astro-блога эти четыре закрыты не отдельным разделом, а блоками <code>Стандарты кода → Функциональный стиль</code> (правило 2 и 3 — никаких классов, никаких лишних абстракций) и <code>Запреты</code> (правило 3 — список «не делай»). Сами правила не дублируются текстом, но их следствия попадают в контекст.</p>\n<hr>\n<h2>3. Где Karpathy-шаблон недотягивает</h2>\n<p>Четыре дыры, которые я наблюдаю в реальной работе:</p>\n<table>\n<thead>\n<tr>\n<th>Дыра</th>\n<th>Что ломается</th>\n<th>Какие добавленные правила закрывают</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>Long-running agent tasks</td>\n<td>Multi-step pipeline уходит в дрейф, тратит токены, теряет контекст</td>\n<td>6 (бюджеты), 10 (чекпойнты), 12 (loud)</td>\n</tr>\n<tr>\n<td>Multi-codebase consistency</td>\n<td>В монорепо «match existing style» неоднозначно — Claude выбирает случайно или усредняет</td>\n<td>11 (конвенции), 7 (surface conflicts)</td>\n</tr>\n<tr>\n<td>Test quality</td>\n<td>«Тесты прошли» становится самоцелью; Claude пишет тесты, которые не упадут даже на сломанной логике</td>\n<td>9 (intent over behavior)</td>\n</tr>\n<tr>\n<td>Prototype vs production</td>\n<td>«Simplicity First» переусердствует на ранней стадии, когда нужно 100 строк скаффолдинга для прощупки</td>\n<td>(не покрыто 12 правилами — отдельно)</td>\n</tr>\n</tbody>\n</table>\n<p>Последняя дыра остаётся живой. Либо включаешь Simplicity, либо отключаешь — серединного режима у <code>CLAUDE.md</code> нет.</p>\n<hr>\n<h2>4. Восемь добавленных правил</h2>\n<p>По одному, с моментом, который их вызвал.</p>\n<h3>4.1. Rule 5 — Use the model only for judgment calls</h3>\n<p>Если ответ известен из status code или схемы данных — это не работа модели. Реальный кейс из моей практики: код звал Claude, чтобы решить, ретраить ли API-вызов на 503. Две недели работало, потом начало флакать, потому что модель читала тело запроса как контекст для решения. Retry-политика стала случайной, потому что промпт был случайным.</p>\n<p>Рамка: Claude — для классификации, экстракции, драфтов, summarization. Не для роутинга, ретраев, детерминированных трансформаций. Если status code уже отвечает на вопрос — на него отвечает обычный код.</p>\n<h3>4.2. Rule 6 — Token budgets are not advisory</h3>\n<p>Без бюджета цикл уходит в дамп на 50 000 токенов. Жёсткий вариант: 4 000 на задачу, 30 000 на сессию. Подходишь к границе — суммируешь и стартуешь сессию заново.</p>\n<p>Типовой случай: 90-минутная debugging-сессия с одним и тем же 8 КБ-сообщением об ошибке. В финале — повторное предложение фиксов, которые уже отвергал 40 сообщений назад. Модель счастливо итерирует на потерянном треке. Бюджет убил бы цикл на 12-й минуте.</p>\n<h3>4.3. Rule 7 — Surface conflicts, don’t average them</h3>\n<p>Если в кодовой базе два паттерна обработки ошибок — try/catch и global boundary — Claude напишет код, который делает оба. Двойные хендлеры. Симптом: ошибка глотается дважды.</p>\n<p>Правило: при противоречии выбираешь один (более новый или более тестированный), объясняешь почему, второй маркируешь к чистке. Усреднённый код, который удовлетворяет обоим правилам — худший возможный.</p>\n<h3>4.4. Rule 8 — Read before you write</h3>\n<p>Karpathy говорит «не трогай соседний код». Не говорит — прочитай его перед добавлением своего. Реальный случай: Claude добавил функцию рядом с уже существующей идентичной, не прочитав файл. Победил порядок импортов — старая, source-of-truth полгода, проиграла свежей одноимённой.</p>\n<p>Рамка: перед добавлением кода в файл — прочитать экспорты, ближайший вызывающий код и общие утилиты. «Looks orthogonal to me» — самая опасная фраза в кодовой базе.</p>\n<h3>4.5. Rule 9 — Tests verify intent, not just behavior</h3>\n<p>Тест <code>expect(getUserName()).toBe('John')</code> ничего не значит, если функция возвращает константу. Тесты должны падать при изменении бизнес-логики, иначе они тестируют существование функции, не её корректность.</p>\n<p>Типовой пример: 12 тестов на auth-функцию, все зелёные, в проде auth сломан. Тесты проверяли, что функция что-то возвращает, не что она возвращает правильное значение.</p>\n<h3>4.6. Rule 10 — Checkpoint after every significant step</h3>\n<p>Многошаговый рефакторинг по 20 файлам ломается на 4-м шаге, Claude уходит дальше на сломанном состоянии. К моменту, когда замечаешь, шаги 5 и 6 уже сделаны поверх сломанного — распутывание занимает дольше, чем переделка с нуля.</p>\n<p>Правило: после каждого значимого шага — резюме, что сделано, что верифицировано, что осталось. Если потерял трек — остановиться и пересказать.</p>\n<h3>4.7. Rule 11 — Match the codebase’s conventions, even if you disagree</h3>\n<p>Claude вводит хуки в codebase на классовых компонентах. Технически работает. Ломает testing pattern, рассчитанный на <code>componentDidMount</code>. Полдня на удалить и переписать.</p>\n<p>Правило: внутри кодовой базы конформность важнее вкуса. Несогласие — отдельный разговор, не silent fork. Snake_case против camelCase, классы против хуков — выбираешь то, что есть, не то, что лучше.</p>\n<h3>4.8. Rule 12 — Fail loud</h3>\n<p>Самые дорогие ошибки те, что выглядят как успех. «Миграция выполнена» при тихо пропущенных 14% записей. «Тесты прошли», когда часть была пропущена. «Фича работает», если не проверен edge case, который явно просили проверить.</p>\n<p>Правило: при неуверенности — поднимай вопрос, не прячь. По умолчанию surfacing неопределённости, не скрытие.</p>\n<hr>\n<h2>5. Что не работает (то, что отсеялось)</h2>\n<p>Шаблон ценен не только тем, что в нём есть, но и тем, что отсеяно при попытках расширить:</p>\n<ul>\n<li><strong>Правила с Reddit и X.</strong> Большинство — переформулировки Karpathy либо domain-specific («всегда Tailwind»). Не обобщаются.</li>\n<li><strong>Больше 12 правил.</strong> На наборах из 14+ правил compliance падает: важные пункты тонут в шуме. Потолок в 200 строк (включая стек, команды, запреты) реален.</li>\n<li><strong>Правила, привязанные к инструментам.</strong> «Always use eslint» падает молча, если eslint не установлен. Правильнее — capability-agnostic: «match the enforced style».</li>\n<li><strong>Примеры вместо правил.</strong> Один пример съедает контекст ~10 правил, и модель over-fits на specifics. Правила абстрактны и переносимы.</li>\n<li><strong>Soft language.</strong> «Be careful», «think hard», «really focus» — compliance ~30%. Не testable. Заменяю на конкретные императивы: «state assumptions explicitly».</li>\n<li><strong>Identity prompts.</strong> «Be a senior engineer» не работает: модель и так считает себя сениором. Зазор между «считать» и «делать» закрывают императивы, не identity.</li>\n</ul>\n<hr>\n<h2>6. Сверка со своим <a href=\"http://CLAUDE.md\">CLAUDE.md</a></h2>\n<p>Открыл файл этого блога (191 строка) и прошёлся по 12 правилам. Картина:</p>\n<table>\n<thead>\n<tr>\n<th>Правило</th>\n<th>В моём <a href=\"http://CLAUDE.md\">CLAUDE.md</a></th>\n<th>Где</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>1. Think before coding</td>\n<td>косвенно</td>\n<td>через <code>architect → critic</code> workflow в команде агентов</td>\n</tr>\n<tr>\n<td>2. Simplicity</td>\n<td>да</td>\n<td><code>Никаких классов</code>, <code>Иммутабельность по умолчанию</code></td>\n</tr>\n<tr>\n<td>3. Surgical changes</td>\n<td>да</td>\n<td><code>Запреты</code> (deprecated <code>@astrojs/tailwind</code>, <code>node:*-alpine</code> и т.п.)</td>\n</tr>\n<tr>\n<td>4. Goal-driven</td>\n<td>косвенно</td>\n<td>через subagent-структуру, не отдельным правилом</td>\n</tr>\n<tr>\n<td>5. Judgment-only</td>\n<td>нет</td>\n<td></td>\n</tr>\n<tr>\n<td>6. Token budgets</td>\n<td>нет</td>\n<td></td>\n</tr>\n<tr>\n<td>7. Surface conflicts</td>\n<td>нет</td>\n<td></td>\n</tr>\n<tr>\n<td>8. Read before write</td>\n<td>частично</td>\n<td>GitNexus-секция требует impact analysis перед редактом</td>\n</tr>\n<tr>\n<td>9. Test intent</td>\n<td>нет</td>\n<td></td>\n</tr>\n<tr>\n<td>10. Checkpoints</td>\n<td>нет</td>\n<td></td>\n</tr>\n<tr>\n<td>11. Match conventions</td>\n<td>да</td>\n<td><code>Стандарты кода → TypeScript / Astro / Git</code></td>\n</tr>\n<tr>\n<td>12. Fail loud</td>\n<td>нет</td>\n<td></td>\n</tr>\n</tbody>\n</table>\n<p>Получилось — четыре покрыто, два частично, шесть нет. Файл фактически Karpathy-уровня, без надстройки на 2026 год.</p>\n<p>Какие из недостающих имеет смысл добавить именно для Astro-блога с публикациями через админку:</p>\n<ul>\n<li><strong>Rule 6 (бюджеты)</strong> — да, у меня агенты делают long-running задачи (генерация EN-переводов через <code>pnpm translate</code>, миграции). Без бюджета сессия может уйти в дрейф.</li>\n<li><strong>Rule 9 (test intent)</strong> — да, есть Vitest и Playwright, риск shallow-тестов реальный.</li>\n<li><strong>Rule 10 (checkpoints)</strong> — да, многошаговые задачи на схему БД + миграции + UI-апдейты регулярно занимают по полчаса работы агента.</li>\n<li><strong>Rule 12 (fail loud)</strong> — да, в админке часто «сохранилось» != «опубликовалось», нужно явное surfacing.</li>\n</ul>\n<p>Rule 7 для одиночного проекта менее острая. Rule 5 покрывается тем, что в рантайме блога нет AI-роутинга — модель не принимает решений за код.</p>\n<hr>\n<h2>7. Как добавить — без раздувания</h2>\n<p>Дисциплина:</p>\n<ol>\n<li><strong>Не превышать 200 строк всего.</strong> Считая стек, команды, запреты, правила. У меня сейчас 191 — добавление четырёх правил означает вынос части <code>Главная страница</code> или GitNexus-секции в <code>@docs/...</code> через @-импорт Claude Code.</li>\n<li><strong>Каждое правило отвечает на вопрос «какую ошибку оно предотвращает».</strong> Если не отвечает — выкидываешь.</li>\n<li><strong>Capability-agnostic формулировки.</strong> «Match the enforced style», не «use prettier».</li>\n<li><strong>Императивы, не пожелания.</strong> «State assumptions explicitly», не «think carefully».</li>\n<li><strong>Тестируешь.</strong> Прогоняешь типичную задачу до и после. Нет разницы — правило не сработало в твоём контексте, удаляешь.</li>\n</ol>\n<p>Шесть правил, заточенных под реальные ошибки, сильнее двенадцати общих.</p>\n<hr>\n<h2>Итог</h2>\n<p>Karpathy зафиксировал три code-writing failure modes января. Forrest Chang упаковал их в четыре правила, и сообщество схватило шаблон. Расширение до 12 родилось из того, что в мае ландшафт Claude Code стал другим: multi-step агенты, hook-каскады, skill-конфликты, кросс-сессионные потоки. Восемь добавленных правил закрывают новые дыры, не замещая исходные.</p>\n<p><code>CLAUDE.md</code> — не wishlist, а behavioral contract против конкретных ошибок, которые ты сам уже видел. Чужой шаблон полезен как стартер. Дальше — фильтруешь под свои failure modes, не наоборот. Шесть правил, точно подобранных, лучше двенадцати скопированных.</p>\n<hr>\n<p><strong>Источники:</strong></p>\n<ul>\n<li><a href=\"https://x.com/karpathy/status/1885018475234567890\">Andrej Karpathy — оригинальный тред в X (январь 2026)</a> — три code-writing failure modes</li>\n<li><a href=\"https://github.com/forrestchang/andrej-karpathy-skills\">forrestchang/andrej-karpathy-skills</a> — публичный репо с базовым 4-правило шаблоном</li>\n<li><a href=\"https://docs.claude.com/en/docs/claude-code/\">Anthropic Claude Code docs — CLAUDE.md</a> — официальная документация по структуре файла, advisory, ~80% compliance</li>\n</ul>\n"
    },
    {
      "id": "https://artka.dev/blog/local-coding-agent",
      "url": "https://artka.dev/blog/local-coding-agent",
      "title": "ds4 от antirez: локальный coding agent на DeepSeek V4 Flash, который работает на MacBook",
      "summary": "Создатель Redis за две недели написал инференс-движок только для одной модели — DeepSeek V4 Flash. 1M контекст, 26 t/s на M3 Max, KV-кэш на диске. Как это запустить и подключить к Claude Code.",
      "date_published": "2026-05-09T00:00:00.000Z",
      "tags": [
        "ai",
        "local-inference",
        "coding-agents",
        "deepseek",
        "apple-silicon"
      ],
      "authors": [
        {
          "name": "Artyom Kashuta",
          "url": "https://artka.dev/about",
          "avatar": "https://artka.dev/avatar-512.png"
        }
      ],
      "language": "ru-RU",
      "content_html": "<blockquote>\n<p>Garry Tan и Bindu Reddy 9 мая 2026 одновременно расшарили одну и ту же новость: создатель Redis Salvatore Sanfilippo (antirez) выложил <a href=\"https://github.com/antirez/ds4\"><code>ds4</code></a> — инференс-движок на C+Metal, который запускает DeepSeek V4 Flash (284B MoE, 1M контекста) на ноутбуке. Не «технически возможно», а «работает с coding-агентами на 26 t/s». Я разобрался, что под капотом, и как использовать это как локальный backend для Claude Code.</p>\n</blockquote>\n<hr>\n<h2>1. Что произошло за две недели</h2>\n<p>24 апреля 2026 DeepSeek выпустил серию V4. V4 Flash — efficiency-модель: 284 миллиарда параметров суммарно, 13 миллиардов активных (MoE), контекст 1 миллион токенов. Раньше модели такого размера жили только в облаке.</p>\n<p>Antirez посмотрел на это и сделал ставку, которую универсальные раннеры сделать не могут. Он форкнул <code>llama.cpp</code>, две недели возился внутри него, понял геометрию V4 Flash, <strong>выкинул всё лишнее</strong> и написал с нуля движок на 4 файлах: <code>ds4.c</code> (~ инференс), <code>ds4_metal.m</code> (Metal kernels), <code>ds4_server.c</code> (HTTP-сервер), <code>ds4_cli.c</code> (REPL). Снаружи всё это говорит на двух протоколах одновременно: OpenAI Chat Completions (<code>/v1/chat/completions</code>) и Anthropic Messages (<code>/v1/messages</code>). То есть подключается к любому агенту, который умеет один из них.</p>\n<p>Результаты, которые автор замерил сам:</p>\n<table>\n<thead>\n<tr>\n<th>Машина</th>\n<th>Квант</th>\n<th>Промпт</th>\n<th>Prefill</th>\n<th>Generation</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>MacBook Pro M3 Max, 128 GB</td>\n<td>q2</td>\n<td>короткий</td>\n<td>58.52 t/s</td>\n<td>26.68 t/s</td>\n</tr>\n<tr>\n<td>MacBook Pro M3 Max, 128 GB</td>\n<td>q2</td>\n<td>11709 токенов</td>\n<td>250.11 t/s</td>\n<td>21.47 t/s</td>\n</tr>\n<tr>\n<td>Mac Studio M3 Ultra, 512 GB</td>\n<td>q2</td>\n<td>короткий</td>\n<td>84.43 t/s</td>\n<td>36.86 t/s</td>\n</tr>\n<tr>\n<td>Mac Studio M3 Ultra, 512 GB</td>\n<td>q4</td>\n<td>12018 токенов</td>\n<td>448.82 t/s</td>\n<td>26.62 t/s</td>\n</tr>\n</tbody>\n</table>\n<p>26 токенов в секунду генерации — это не «можно посмотреть», это <strong>рабочая скорость для coding-агента</strong>, который пишет, читает файлы, вызывает инструменты. На длинном промпте генерация падает до 21 t/s, но за счёт KV-кэша на диске это окупается уже на третьем запросе той же сессии.</p>\n<hr>\n<h2>2. Три инженерных трюка, которые делают это возможным</h2>\n<p>Я внимательно прочитал README и <code>AGENT.md</code> репозитория, и ниже — самое существенное, без чего ds4 не работал бы.</p>\n<h3>2.1. Асимметричное 2-битное квантование</h3>\n<p>Стандартный подход к 2-битному кванту — давить всё подряд до 2 бит, и тогда модель начинает галлюцинировать в tool calling, путать аргументы и забывать схему. Antirez сделал иначе: <strong>квантованы только MoE-эксперты на routed-пути</strong> (<code>up</code>/<code>gate</code> в <code>IQ2_XXS</code>, <code>down</code> в <code>Q2_K</code>) — потому что они занимают большую часть веса (модель — 284B, и почти всё это — эксперты). Shared-эксперты, проекции, роутинг — остаются в Q8. Это компоненты, в которых потеря точности дорого стоит.</p>\n<p>Эффект: 2-битный квант весит 81 GB и помещается в 128 GB унифицированной памяти MacBook Pro M3 Max, при этом надёжно работает в coding-агентах (что валидируется тестами против официальных логитов API DeepSeek).</p>\n<h3>2.2. KV-кэш как first-class disk citizen</h3>\n<p>Главная боль stateless API-протоколов вроде Chat Completions: клиент <strong>каждый раз присылает всю историю</strong>, и сервер обязан пре-фильнуть её с нуля. Claude Code, например, на старте шлёт ~25K токенов системного промпта. На локальном железе это десятки секунд до первого токена.</p>\n<p>Ds4 решает это лобово: после успешного prefill стейт сессии (KV-чекпоинт) сериализуется в файл, ключ — SHA1 от token IDs. Когда приходит следующий запрос с тем же префиксом, сервер берёт чекпоинт с диска и пропускает prefill. Из README:</p>\n<blockquote>\n<p>The KV cache <strong>is actually a first class disk citizen</strong>. &lt;…&gt; Modern MacBooks have fast SSDs and compressed KV caches like the one of DeepSeek v4.</p>\n</blockquote>\n<p>На практике это означает разницу между «4 секунды до первого токена при повторном вызове» и «60 секунд». Диск тут — не своп под давлением, а логичное хранилище: SSD достаточно быстрые, KV у DeepSeek V4 хорошо сжимается, а характеристика «один и тот же системный промпт + меняющийся хвост» точно описывает работу coding-агента.</p>\n<h3>2.3. Metal-only и одна модель за раз</h3>\n<p>Никакого CUDA, никакого CPU-фоллбэка для прода (CPU-путь существует только для correctness-чеков и сейчас падает на уровне ядра macOS из-за бага в VM — antirez об этом честно пишет). Никакой попытки сделать «универсальный раннер». Только Apple Silicon, только эта одна модель, и так до тех пор, пока не появится новая версия V4 Flash или сильно лучшая модель того же класса.</p>\n<p>Цена — narrow bet. Выгода — тебе не нужно поддерживать матрицу <code>(модель × железо × квант)</code>, и ты можешь оптимизировать Metal-ядра под точную геометрию слоёв этой конкретной модели.</p>\n<hr>\n<h2>3. Что мне понадобится: железо, модель, час времени</h2>\n<p>Я планирую разворачивать это на <strong>MacBook Pro M3 Max, 128 GB</strong> (минимально жизнеспособная конфигурация по README). У меня его пока нет, и в этом разделе — честный план, что я буду делать, когда железо приедет; цифры взяты из бенчмарков antirez’а, но я хочу их перепроверить на своём экземпляре.</p>\n<p>Минимальные требования по моим прикидкам:</p>\n<ul>\n<li>macOS на актуальной версии (там же баг VM в CPU-пути, но Metal-путь не задет).</li>\n<li>Apple Silicon с 128 GB+ унифицированной памяти. M3 Max или M3 Ultra.</li>\n<li>~100 GB свободного места: 81 GB сама модель Q2 + место под KV-кэш на диске. Под Q4-квант — 256 GB+ RAM и ~150 GB на диске.</li>\n<li>Xcode Command Line Tools (для clang/Metal headers).</li>\n<li>~30–60 минут на скачивание модели (зависит от канала).</li>\n</ul>\n<p>То, чего может не хватить начинающим: 128 GB unified memory — это уровень MBP M3 Max в топовой комплектации или Mac Studio. На 64-гиговом Mac Q2 не заработает: модель просто не влезет в RAM. Это не «медленно», это «никак».</p>\n<hr>\n<h2>4. Установка пошагово</h2>\n<p>Команды ниже — то, что я сделаю в первый же день, опираясь на инструкции README. Где описание скучает за конкретикой — я добавил собственные комментарии.</p>\n<h3>4.1. Сборка</h3>\n<pre><code class=\"language-bash\"># 1. Склонировать репозиторий\ngit clone https://github.com/antirez/ds4.git\ncd ds4\n\n# 2. Скачать 2-битный квант (81 GB; для 128 GB MBP)\n./download_model.sh q2\n\n# Скрипт качает с huggingface.co/antirez/deepseek-v4-gguf,\n# поддерживает резюм через curl -C - — можно прервать и продолжить.\n# Если нужен 4-битный квант (для Mac Studio 256+ GB), используй ./download_model.sh q4.\n\n# 3. Собрать\nmake\n\n# Проверить, что собралось:\n./ds4 --help\n./ds4-server --help\n</code></pre>\n<p>Сборка — обычный <code>make</code>, никаких CMake, никаких pkg-config. Это намеренно: зависимостей за пределами Apple SDK у проекта нет.</p>\n<h3>4.2. Первый запуск в REPL</h3>\n<pre><code class=\"language-bash\">./ds4 -p &quot;Объясни Redis streams в одном абзаце.&quot;\n</code></pre>\n<p>Без <code>-p</code> запускается интерактивная сессия с командами <code>/help</code>, <code>/think</code>, <code>/think-max</code>, <code>/nothink</code>, <code>/ctx N</code>, <code>/read FILE</code>, <code>/quit</code>. Это хорошо для проверки, что движок жив, и для сравнения скорости генерации против заявленных 26 t/s.</p>\n<h3>4.3. Запуск как HTTP-сервер</h3>\n<p>Это режим, в котором ds4 становится локальным backend’ом для агентов:</p>\n<pre><code class=\"language-bash\">./ds4-server \\\n  --ctx 100000 \\\n  --kv-disk-dir /tmp/ds4-kv \\\n  --kv-disk-space-mb 8192\n</code></pre>\n<p>Параметры:</p>\n<ul>\n<li><code>--ctx 100000</code> — контекстное окно в 100K токенов. Полный 1M-контекст ест ~26 GB только на индексер; на 128 GB Mac, где 81 GB уже занято моделью, это не оставит места для KV-кэша. 100–300K — разумный компромисс.</li>\n<li><code>--kv-disk-dir /tmp/ds4-kv</code> — каталог для disk KV-кэша. Я бы вынес его на быстрый SSD (внешний или встроенный — оба ок).</li>\n<li><code>--kv-disk-space-mb 8192</code> — лимит на размер кэша. 8 GB для одного-двух активных проектов хватит; для сессий побольше — увеличивай.</li>\n</ul>\n<p>Сервер слушает <code>127.0.0.1:8000</code>. Эндпоинты:</p>\n<table>\n<thead>\n<tr>\n<th>Endpoint</th>\n<th>Протокол</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td><code>POST /v1/chat/completions</code></td>\n<td>OpenAI Chat Completions (+ tools)</td>\n</tr>\n<tr>\n<td><code>POST /v1/completions</code></td>\n<td>OpenAI legacy completions</td>\n</tr>\n<tr>\n<td><code>POST /v1/messages</code></td>\n<td>Anthropic Messages (для Claude Code)</td>\n</tr>\n<tr>\n<td><code>GET /v1/models</code></td>\n<td>список моделей</td>\n</tr>\n</tbody>\n</table>\n<p>Аутентификация по статичному API-ключу (по умолчанию принимается любой; в README рекомендуется <code>dsv4-local</code>).</p>\n<hr>\n<h2>5. Подключение как coding agent</h2>\n<p>Это та часть, ради которой я вообще копал тему. Все три приведённых ниже способа работают одновременно — каждый агент ходит в один и тот же <code>ds4-server</code>.</p>\n<h3>5.1. Claude Code → Anthropic-совместимый эндпоинт</h3>\n<p>Claude Code умеет говорить с любым backend’ом, который выставляет Anthropic Messages API. Создаём обёртку <code>~/bin/claude-ds4</code>:</p>\n<pre><code class=\"language-bash\">#!/bin/sh\nunset ANTHROPIC_API_KEY\n\nexport ANTHROPIC_BASE_URL=&quot;${DS4_ANTHROPIC_BASE_URL:-http://127.0.0.1:8000}&quot;\nexport ANTHROPIC_AUTH_TOKEN=&quot;${DS4_API_KEY:-dsv4-local}&quot;\nexport ANTHROPIC_MODEL=&quot;deepseek-v4-flash&quot;\n\n# Подменяем все алиасы Sonnet/Haiku/Opus на локальную модель —\n# чтобы /model в Claude Code не дёрнул облачный fallback.\nexport ANTHROPIC_DEFAULT_SONNET_MODEL=&quot;deepseek-v4-flash&quot;\nexport ANTHROPIC_DEFAULT_HAIKU_MODEL=&quot;deepseek-v4-flash&quot;\nexport ANTHROPIC_DEFAULT_OPUS_MODEL=&quot;deepseek-v4-flash&quot;\nexport CLAUDE_CODE_SUBAGENT_MODEL=&quot;deepseek-v4-flash&quot;\n\n# Отключаем телеметрию и не-стриминговый fallback.\nexport CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1\nexport CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK=1\nexport CLAUDE_STREAM_IDLE_TIMEOUT_MS=600000\n\nexec &quot;$HOME/.local/bin/claude&quot; &quot;$@&quot;\n</code></pre>\n<p><code>chmod +x ~/bin/claude-ds4</code> — и запускаешь Claude Code как <code>claude-ds4</code> вместо <code>claude</code>. Все запросы пойдут на локальный ds4-сервер. Тонкость, на которую обращает внимание сам antirez:</p>\n<blockquote>\n<p>Claude Code may send a large initial prompt, often around 25k tokens, before it starts doing useful work. Keep <code>--kv-disk-dir</code> enabled.</p>\n</blockquote>\n<p>Без disk KV-кэша запуск Claude Code на холодную будет занимать минуту и больше; с кэшем — после первого старта последующие будут восстанавливаться с диска.</p>\n<h3>5.2. opencode</h3>\n<p>opencode конфигурируется через <code>~/.config/opencode/opencode.json</code>:</p>\n<pre><code class=\"language-json\">{\n  &quot;$schema&quot;: &quot;https://opencode.ai/config.json&quot;,\n  &quot;provider&quot;: {\n    &quot;ds4&quot;: {\n      &quot;name&quot;: &quot;ds4.c (local)&quot;,\n      &quot;npm&quot;: &quot;@ai-sdk/openai-compatible&quot;,\n      &quot;options&quot;: {\n        &quot;baseURL&quot;: &quot;http://127.0.0.1:8000/v1&quot;,\n        &quot;apiKey&quot;: &quot;dsv4-local&quot;\n      },\n      &quot;models&quot;: {\n        &quot;deepseek-v4-flash&quot;: {\n          &quot;name&quot;: &quot;DeepSeek V4 Flash (ds4.c local)&quot;,\n          &quot;limit&quot;: { &quot;context&quot;: 100000, &quot;output&quot;: 384000 }\n        }\n      }\n    }\n  },\n  &quot;agent&quot;: {\n    &quot;ds4&quot;: {\n      &quot;description&quot;: &quot;DeepSeek V4 Flash served by local ds4-server&quot;,\n      &quot;model&quot;: &quot;ds4/deepseek-v4-flash&quot;,\n      &quot;temperature&quot;: 0\n    }\n  }\n}\n</code></pre>\n<p><code>limit.context: 100000</code> обязательно совпадает с <code>--ctx</code>, с которым стартует <code>ds4-server</code> — иначе сервер обрежет, а opencode об этом не узнает и пошлёт следующее сообщение, ожидая нерабочую длину.</p>\n<h3>5.3. Pi (мини-агент antirez’а)</h3>\n<p>Если используешь Pi — формат немного другой, конфиг в <code>~/.pi/agent/models.json</code>:</p>\n<pre><code class=\"language-json\">{\n  &quot;providers&quot;: {\n    &quot;ds4&quot;: {\n      &quot;name&quot;: &quot;ds4.c local&quot;,\n      &quot;baseUrl&quot;: &quot;http://127.0.0.1:8000/v1&quot;,\n      &quot;api&quot;: &quot;openai-completions&quot;,\n      &quot;apiKey&quot;: &quot;dsv4-local&quot;,\n      &quot;compat&quot;: {\n        &quot;supportsStore&quot;: false,\n        &quot;supportsDeveloperRole&quot;: false,\n        &quot;supportsReasoningEffort&quot;: true,\n        &quot;supportsUsageInStreaming&quot;: true,\n        &quot;maxTokensField&quot;: &quot;max_tokens&quot;,\n        &quot;thinkingFormat&quot;: &quot;deepseek&quot;,\n        &quot;requiresReasoningContentOnAssistantMessages&quot;: true\n      },\n      &quot;models&quot;: [\n        {\n          &quot;id&quot;: &quot;deepseek-v4-flash&quot;,\n          &quot;name&quot;: &quot;DeepSeek V4 Flash (ds4.c local)&quot;,\n          &quot;reasoning&quot;: true,\n          &quot;contextWindow&quot;: 100000,\n          &quot;maxTokens&quot;: 384000,\n          &quot;cost&quot;: { &quot;input&quot;: 0, &quot;output&quot;: 0, &quot;cacheRead&quot;: 0, &quot;cacheWrite&quot;: 0 }\n        }\n      ]\n    }\n  }\n}\n</code></pre>\n<p><code>cost: 0</code> — это не маркетинг, это правда. Каждый запрос обходится в электричество и износ SSD, не в токены.</p>\n<hr>\n<h2>6. Где это сломается (важные грабли)</h2>\n<p>Реальные ограничения, на которые я наткнусь, и то, как их обходить.</p>\n<p><strong>Окно контекста должно быть согласовано везде.</strong> Стартуешь сервер с <code>--ctx 100000</code>, ставишь в opencode <code>limit.context: 100000</code>, в Claude Code не лезешь в системный промпт сверх этого. Если у Claude Code init-prompt ~25K, то на проект остаётся 75K — реально хватает на средний codebase, но не на огромные репозитории.</p>\n<p><strong>Disk KV-кэш «привязан» к точному префиксу.</strong> Любая правка в системном промпте, в <code>CLAUDE.md</code>, в первых сообщениях — инвалидирует чекпоинт. Это не баг, это by design: матчинг идёт по SHA1 от token IDs. Если ты часто редактируешь <code>CLAUDE.md</code>, ожидай холодные старты. Решение — закоммитить системный контракт и не править его в каждой сессии.</p>\n<p><strong>MTP/спекулятивное декодирование пока не даёт большого выигрыша.</strong> В README прямо написано: «currently provides at most a slight speedup». Не закладывайся на удвоение скорости от MTP — текущая реализация correctness-gated и на сложных промптах часто триггерит партиал-аксепт.</p>\n<p><strong>Один live KV-кэш в памяти.</strong> Сервер сейчас не батчит независимые запросы. Если два агента ходят одновременно — второй ждёт первого. Это нормальный trade-off для локального single-user setup, но если ты хочешь параллельный multi-tenancy на одном Mac — ds4 пока не для этого.</p>\n<p><strong>CPU-режим падает на свежих macOS.</strong> Это про debug-путь, не про прод (Metal-only — основной таргет), но если по привычке захочешь сравнить инференс на CPU — не делай этого: kernel-panic, надо ребутиться.</p>\n<hr>\n<h2>7. Что это значит: vertical inference engines как тренд</h2>\n<p>Главное — не ds4 сам по себе, а паттерн, который antirez формализовал.</p>\n<p>Локальный inference сейчас выглядит как «универсальный раннер <code>+</code> тысяча моделей в GGUF <code>+</code> обёртки разной свежести». Это работает, но движётся со скоростью наименее популярной модели: ускорять Llama 3.1 в llama.cpp проще, чем добавить эффективную поддержку DeepSeek V4 — потому что в первом случае структура слоёв совпадает с двадцатью другими моделями, а во втором — appears once.</p>\n<p>Antirez показывает противоположный путь. <strong>Один движок — одна модель — один сценарий (coding agent)</strong>. Дальше нужно три вещи, и все три — в продукте:</p>\n<ol>\n<li>Inference engine с HTTP API.</li>\n<li>GGUF, специально подготовленный под этот движок и его допущения.</li>\n<li>Тесты и валидация на сцепке с конкретными агент-клиентами.</li>\n</ol>\n<p>Если эта ставка работает (и бенчмарки говорят, что да), будущее локального inference — не «ещё одна абстракция поверх абстракции», а <strong>«у каждой важной модели — свой ds4-подобный проект»</strong>. Когда выходит V4.1 или V5, кто-то из community делает новый движок, новый GGUF, новые тесты, и через две недели у пользователей уже работающая локальная установка. Старые движки уходят на покой вместе со старыми моделями.</p>\n<p>И второе. В README antirez явно пишет:</p>\n<blockquote>\n<p>This software is developed with strong assistance from GPT 5.5 and with humans leading the ideas, testing, and debugging.</p>\n</blockquote>\n<p>Две недели от форка <code>llama.cpp</code> до production-ready узкого движка с серверным API — без AI это не сделать, и antirez это прямо говорит. Вот это переключение — «один человек + AI = инфраструктура для целой модели за две недели» — на мой взгляд интереснее, чем сами цифры t/s.</p>\n<hr>\n<h2>Итог</h2>\n<p><code>ds4</code> от antirez — это не «ещё один локальный инференс». Это узкая ставка: один движок, одна модель (DeepSeek V4 Flash), одна архитектура железа (Apple Silicon с Metal), один сценарий (coding agent). За счёт асимметричного 2-битного кванта 284B-модель влезает в 128 GB MacBook, за счёт disk KV-кэша работает с агентами, которые гоняют 25K-токенные системные промпты, за счёт OpenAI/Anthropic-совместимости подключается к Claude Code, opencode и Pi из коробки.</p>\n<p>Если у вас есть Mac с 128 GB+ — это рабочий локальный backend для серьёзной коммерческой работы с приватным кодом. Если нет — ждать DDR5 и unified memory на Linux/CUDA, или смотреть, кто следующий повторит этот паттерн под свою связку «модель + железо».</p>\n<p>В любом случае стоит наблюдать. Я ставлю на то, что через год так будут собирать половину серьёзных локальных установок.</p>\n<hr>\n<p><strong>Источники:</strong></p>\n<ul>\n<li><a href=\"https://github.com/antirez/ds4\">github.com/antirez/ds4</a> — README, бенчмарки, конфиги</li>\n<li><a href=\"https://x.com/garrytan/status/2052996691586932783\">Garry Tan — пост в X (9 мая 2026)</a></li>\n<li><a href=\"https://x.com/bindureddy/status/2052982206344409242\">Bindu Reddy — пост в X (9 мая 2026)</a></li>\n<li><a href=\"https://eu.36kr.com/en/p/3800327282662656\">QbitAI / 36kr: Redis Father Steps In to Build Dedicated Inference Engine for DeepSeek V4</a></li>\n<li><a href=\"https://news.ycombinator.com/item?id=48050751\">HN: DeepSeek 4 Flash local inference engine for Metal</a></li>\n<li><a href=\"https://huggingface.co/antirez/deepseek-v4-gguf\">huggingface.co/antirez/deepseek-v4-gguf</a></li>\n</ul>\n"
    },
    {
      "id": "https://artka.dev/blog/json-ld-graph-astro",
      "url": "https://artka.dev/blog/json-ld-graph-astro",
      "title": "JSON-LD @graph в Astro: от дублирующихся inline-блоков к единому citable-узлу",
      "summary": "Пошаговый разбор миграции с per-page Schema.org-блоков на единый @graph в BaseLayout: стабильные @id, ссылки между сущностями, articleBody-excerpt и FAQ.",
      "date_published": "2026-05-02T00:00:00.000Z",
      "tags": [
        "seo",
        "astro",
        "schema-org"
      ],
      "authors": [
        {
          "name": "Artyom Kashuta",
          "url": "https://artka.dev/about",
          "avatar": "https://artka.dev/avatar-512.png"
        }
      ],
      "language": "ru-RU",
      "content_html": "<blockquote>\n<p>Большинство руководств по <a href=\"http://Schema.org\">Schema.org</a> для блогов учат: на посте — <code>&lt;script&gt;</code> с <code>BlogPosting</code>, на главной — с <code>WebSite</code>, на about — с <code>Person</code>. Это работает, но проигрывает в citability. Краулер видит <code>Person</code> из <code>BlogPosting.author</code> как «кто-то по имени X», а не как entity, который ещё и <code>founder of #organization</code>, который <code>publisher of #blog</code>. В посте — пошаговый разбор, как заменить per-page inline-блоки одним <code>@graph</code> в <code>BaseLayout</code>.</p>\n</blockquote>\n<hr>\n<h2>1. Зачем менять — citability вs SERP</h2>\n<p>Структурированные данные у разработчика-блогера обычно ассоциируются с одним вопросом: «появится ли мой пост в Google с rich snippet?». Под эту задачу хватает любого валидного <code>BlogPosting</code> — пройдёт Rich Results Test, появятся stars/breadcrumb. И этим часто всё кончается: добавили <code>@type: BlogPosting</code>, проверили в валидаторе, забыли.</p>\n<p>В 2026 году у структурированных данных появился новый, более требовательный потребитель — <strong>LLM-краулер</strong>, который собирает контент для retrieval-augmented generation и для citation. Ему нужен не «ещё один rich snippet», а <strong>связный entity-граф</strong>: чтобы при упоминании автора в одном посте он опознал того же автора в другом, чтобы организация-publisher была одним и тем же объектом на всём сайте, чтобы блог как сущность ссылался обратно на автора.</p>\n<p>LLM, выдающий цитату, делает примерно следующее: вытаскивает passage, проверяет окружающую entity-разметку, пытается сопоставить автора с известной сущностью. Если на сайте <code>Person.name = &quot;Артём Кашута&quot;</code> встречается в трёх разных Schema.org-блоках без общего <code>@id</code>, краулер обязан догадываться, один это человек или три. Если же есть один <code>Person#person</code> со стабильным URI, и все остальные узлы (<code>Organization.founder</code>, <code>BlogPosting.author</code>, <code>Blog.author</code>) ссылаются на него через <code>{&quot;@id&quot;: &quot;...&quot;}</code> — догадки не нужны, граф собран автором.</p>\n<p>Это проблема, которую keyword density не решает. Это <strong>entity disambiguation</strong>, и решается она <strong>graph topology</strong>.</p>\n<table>\n<thead>\n<tr>\n<th>Аспект</th>\n<th>Per-page inline blocks</th>\n<th>Single <code>@graph</code> с <code>@id</code></th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>Google Rich Results</td>\n<td>работает</td>\n<td>работает</td>\n</tr>\n<tr>\n<td>LLM entity match (Person)</td>\n<td>догадка по имени</td>\n<td>гарантирована через <code>@id</code></td>\n</tr>\n<tr>\n<td>Дублирование данных</td>\n<td>3-5 копий <code>Person</code> на 14 постах</td>\n<td>один источник на сайт</td>\n</tr>\n<tr>\n<td>Стоимость правки автора</td>\n<td>14 файлов</td>\n<td>1 файл (<code>person.ts</code>)</td>\n</tr>\n<tr>\n<td>HTML weight</td>\n<td>3+ скрипта на страницу</td>\n<td>1 скрипт</td>\n</tr>\n</tbody>\n</table>\n<p>Для эпохи SERP-only хватало первого подхода. Для эпохи AI-overviews, citation graphs и retrieval-augmented поиска — нужно второе. Spec нашего блога формулирует это прямо: «move all entity definitions into <code>src/lib/seo/schema.ts</code> returning a single <code>@graph</code> JSON-LD block; pages contribute a <code>BlogPosting</code>/<code>WebPage</code> node referencing the global <code>Person#me</code> and <code>Organization#brand</code> by <code>@id</code>» — см. <code>docs/superpowers/specs/2026-05-02-llm-citable-blog-design.md</code> § «Schema-graph design».</p>\n<h2>2. Антипаттерн: per-page inline schema</h2>\n<p>Что эмитит дефолтный Astro-блог, собранный по тутору с какого-нибудь <a href=\"http://dev.to\">dev.to</a>? Обычно так:</p>\n<ul>\n<li>В <code>BaseLayout.astro</code> лежит inline-скрипт с <code>WebSite</code> и иногда <code>Organization</code>.</li>\n<li>В <code>PostLayout.astro</code> лежит ещё один inline-скрипт с <code>BlogPosting</code>.</li>\n<li>Если автор увлёкся — добавляется третий скрипт с <code>BreadcrumbList</code>. Иногда четвёртый с <code>Person</code>.</li>\n</ul>\n<p>Почему так получилось — потому что Astro-компоненты иерархически наследуются, и каждый уровень удобно «добивает» свою порцию данных через свой <code>&lt;script&gt;</code>. Это работает локально, но плохо масштабируется. У нас в репозитории до Plan 1 было ровно это: <code>BaseLayout</code> эмитил один JSON-LD блок, <code>PostLayout</code> поверх него добавлял свои два:</p>\n<pre><code class=\"language-bash\"># Pre-Plan 1 (commit 5ed281c~1):\n$ git show 5ed281c~1:src/layouts/BaseLayout.astro | grep -c application/ld+json\n1\n$ git show 5ed281c~1:src/layouts/PostLayout.astro | grep -c application/ld+json\n2\n</code></pre>\n<p>То есть страница поста содержала <strong>три</strong> <code>&lt;script type=&quot;application/ld+json&quot;&gt;</code> блока. Каждый со своим <code>Person</code> (где-то полным, где-то усечённым), без общего <code>@id</code>, без перекрёстных ссылок. Краулер, который попадал на пост, видел три не связанных друг с другом entity-облака.</p>\n<p>Главные проблемы антипаттерна:</p>\n<ol>\n<li><strong>Дублирование <code>Person</code>.</strong> Один и тот же автор описан 3-5 раз. Если бы автор сменил <code>jobTitle</code> или добавил <code>sameAs</code>, надо было бы править во всех файлах. Forget one — и краулер видит конфликт: «у Person с таким именем jobTitle вдруг разный». Это явный signal-to-noise урон.</li>\n<li><strong>Разорванный граф.</strong> <code>BlogPosting.publisher</code> — это inline-объект <code>{ &quot;@type&quot;: &quot;Organization&quot;, &quot;name&quot;: &quot;...&quot; }</code>. Где-то ещё на сайте лежит <code>Organization</code> с <code>founder</code>-полем. Без общих <code>@id</code> валидатор не знает, это один publisher или два.</li>\n<li><strong>HTML weight.</strong> Три скрипта вместо одного — это лишние десятки байт на каждый, плюс инфляция payload, особенно если на странице несколько одинаковых полей (e.g. описание автора повторяется четырежды).</li>\n<li><strong>Согласованность.</strong> Если автор правит <code>Person.description</code> в frontmatter <code>about.md</code>, а в <code>BlogPosting</code>-builder он зашит как литерал — рассинхрон неизбежен.</li>\n</ol>\n<h2>3. Целевая архитектура — <code>@graph</code> с глобальными <code>@id</code></h2>\n<p>Целевая модель: <strong>один script на странице</strong>, внутри — <code>@graph</code>-массив. Глобальные узлы (<code>Person</code>, <code>Organization</code>, <code>WebSite</code>) описаны один раз и идентифицируются стабильными URI. Page-level узлы (<code>BlogPosting</code>, <code>WebPage</code>, <code>CollectionPage</code>, <code>CreativeWork</code>) добавляются <code>BaseLayout</code>-ом и <strong>ссылаются на глобальные через <code>@id</code></strong>, не дублируя их данные.</p>\n<p>Топология:</p>\n<pre><code class=\"language-mermaid\">flowchart LR\n  Person[&quot;Person#person&lt;br/&gt;(глобальный)&quot;]\n  Org[&quot;Organization#brand&lt;br/&gt;(глобальный)&quot;]\n  Site[&quot;WebSite#site&lt;br/&gt;(глобальный)&quot;]\n  Post[&quot;BlogPosting#blogposting&lt;br/&gt;(page-level)&quot;]\n  WebPage[&quot;WebPage#webpage&lt;br/&gt;(page-level)&quot;]\n\n  Post -- author --&gt; Person\n  Post -- publisher --&gt; Org\n  Post -- isPartOf --&gt; Site\n  WebPage -- about --&gt; Person\n  WebPage -- isPartOf --&gt; Site\n  Org -- founder --&gt; Person\n  Site -- publisher --&gt; Org\n</code></pre>\n<p>Что важно в этой картинке:</p>\n<ul>\n<li><strong>Все стрелки — это <code>{&quot;@id&quot;: &quot;...&quot;}</code> ссылки.</strong> Никаких inline-копий.</li>\n<li><strong><code>Person#person</code> — корневой узел графа.</strong> Все entity-страницы (<code>/about</code>, <code>/now</code>, <code>/uses</code>) делают <code>WebPage.about → Person</code>. Все посты — <code>BlogPosting.author → Person</code>. Сменив <code>Person</code>, мы синхронно меняем всё.</li>\n<li><strong>Page-level узлы добавляются, не заменяя глобальные.</strong> Каждая страница привносит 1-2 новых узла; <code>Person</code>/<code>Organization</code>/<code>WebSite</code> всегда присутствуют.</li>\n</ul>\n<p>Стабильные <code>@id</code> — это не URL страницы, это URI с фрагментом, например <code>https://artka.dev/#person</code>, <code>https://artka.dev/#brand</code>. Так принято в JSON-LD: фрагмент-id означает «этот ресурс описан на любой странице, но идентифицируется единым URI».</p>\n<h2>4. Реализация в Astro 5</h2>\n<p>В Astro 5 SSG/SSR-граница проходит ровно по <code>BaseLayout</code>-у: на сборке вычисляются props, рендерится HTML, в нём — статический <code>&lt;script type=&quot;application/ld+json&quot;&gt;</code>. Никаких client-side, никаких rehydration-моргалок. Идеальный момент собрать <code>@graph</code> функционально.</p>\n<h3>4.1. <code>graphIds</code> — таблица URI</h3>\n<p>Один файл, в котором перечислены все стабильные идентификаторы:</p>\n<pre><code class=\"language-ts\">// src/lib/seo/nodes-global.ts\nconst SITE = &quot;https://artka.dev&quot;;\n\nexport const graphIds = {\n  person: `${SITE}/#person`,\n  organization: `${SITE}/#brand`,\n  website: `${SITE}/#website`,\n  blogRu: `${SITE}/#blog-ru`,\n  blogEn: `${SITE}/#blog-en`,\n} as const;\n</code></pre>\n<p>Каждый builder, который ссылается на глобальную сущность, импортирует <code>graphIds</code> и использует <code>{ &quot;@id&quot;: graphIds.person }</code>. Никаких inline-литералов, никаких опечаток в URI.</p>\n<h3>4.2. Builders — pure functions, никаких классов</h3>\n<p>В соответствии с проектным правилом «никаких классов в прикладном коде» каждый узел — это чистая функция, возвращающая <code>Record&lt;string, unknown&gt;</code>:</p>\n<pre><code class=\"language-ts\">// src/lib/seo/nodes-global.ts (фрагмент)\nexport const buildPersonNode = () =&gt; {\n  const merged = Array.from(new Set&lt;string&gt;([...person.knowsAbout, ...person.expertiseAreas]));\n  return {\n    &quot;@type&quot;: &quot;Person&quot;,\n    &quot;@id&quot;: graphIds.person,\n    name: person.name,\n    url: person.url,\n    image: person.image,\n    jobTitle: person.jobTitle,\n    description: person.description,\n    knowsAbout: merged,\n    sameAs: [...person.sameAs],\n    email: person.email,\n    subjectOf: person.notableWork.map((w) =&gt; ({\n      &quot;@type&quot;: &quot;CreativeWork&quot;,\n      name: w.title,\n      url: w.url,\n      description: w.description,\n    })),\n  };\n};\n\nexport const buildOrganizationNode = () =&gt; ({\n  &quot;@type&quot;: &quot;Organization&quot;,\n  &quot;@id&quot;: graphIds.organization,\n  name: &quot;artka.dev&quot;,\n  url: SITE,\n  logo: { &quot;@type&quot;: &quot;ImageObject&quot;, url: `${SITE}/favicon.svg` },\n  founder: { &quot;@id&quot;: graphIds.person },\n});\n</code></pre>\n<p><code>person</code> — это импорт из <code>src/lib/seo/person.ts</code>, единственного источника правды по автору. Builder складывает <code>knowsAbout</code> и <code>expertiseAreas</code> в <code>Set</code>, чтобы не дублировать ключи. <code>Organization.founder</code> — <code>@id</code>-ссылка, не inline-копия <code>Person</code>.</p>\n<h3>4.3. Оркестратор — <code>buildGraph</code></h3>\n<p>Функция, которая склеивает глобальные и page-level узлы в один <code>@graph</code>:</p>\n<pre><code class=\"language-ts\">// src/lib/seo/schema.ts\nimport {\n  buildPersonNode,\n  buildOrganizationNode,\n  buildWebSiteNode,\n  type Locale,\n} from &quot;./nodes-global&quot;;\n\nexport type GraphNode = Record&lt;string, unknown&gt; &amp; { &quot;@type&quot;: string };\n\nexport interface GraphInput {\n  readonly locale: Locale;\n  readonly extraNodes: ReadonlyArray&lt;GraphNode | null&gt;;\n}\n\nexport interface JsonLdGraph {\n  readonly &quot;@context&quot;: &quot;https://schema.org&quot;;\n  readonly &quot;@graph&quot;: ReadonlyArray&lt;GraphNode&gt;;\n}\n\nexport const buildGraph = (input: GraphInput): JsonLdGraph =&gt; {\n  const globals: GraphNode[] = [\n    buildPersonNode(),\n    buildOrganizationNode(),\n    buildWebSiteNode(input.locale),\n  ];\n  const extras = input.extraNodes.filter((n): n is GraphNode =&gt; n !== null);\n  return {\n    &quot;@context&quot;: &quot;https://schema.org&quot;,\n    &quot;@graph&quot;: [...globals, ...extras],\n  };\n};\n</code></pre>\n<p>API минимальный: вход — locale (чтобы выбрать <code>inLanguage</code> для <code>WebSite</code>) и список дополнительных узлов (<code>extraNodes</code>). Выход — готовый <code>JsonLdGraph</code>. <code>null</code>-узлы фильтруются — это удобно для опциональных узлов вроде <code>FAQPage</code>, builder которых возвращает <code>null</code> при пустом массиве вопросов.</p>\n<h3>4.4. <code>BaseLayout</code> — единственная точка эмиссии</h3>\n<p>Весь сайт идёт через <code>BaseLayout</code>, и именно он — и только он — эмитит JSON-LD:</p>\n<pre><code class=\"language-astro\">---\n// src/layouts/BaseLayout.astro\nimport { buildGraph, safeJsonLd, type GraphNode } from &quot;~/lib/seo/schema&quot;;\n\ninterface Props {\n  title: string;\n  description?: string;\n  // ...\n  /** Additional JSON-LD nodes to merge into the page @graph. */\n  extraSchemaNodes?: ReadonlyArray&lt;GraphNode | null&gt;;\n}\n\nconst { extraSchemaNodes = [] } = Astro.props;\nconst locale = getLocaleFromPath(Astro.url.pathname);\n---\n\n&lt;head&gt;\n  &lt;script\n    is:inline\n    type=&quot;application/ld+json&quot;\n    set:html={safeJsonLd(buildGraph({ locale, extraNodes: extraSchemaNodes }))}\n  /&gt;\n&lt;/head&gt;\n</code></pre>\n<p>Три ключевые детали:</p>\n<ol>\n<li><strong><code>is:inline</code></strong> — Astro не пытается обрабатывать содержимое как JS-модуль.</li>\n<li><strong><code>set:html</code></strong> — мы вставляем уже готовую строку, не давая фреймворку триммить пробелы или экранировать дополнительно.</li>\n<li><strong><code>safeJsonLd</code></strong> — крошечный helper, экранирует <code>&lt;</code>, <code>&gt;</code>, <code>&amp;</code> так, чтобы внутри JSON не оказалось последовательности, которую парсер HTML примет за конец <code>&lt;/script&gt;</code>. Без него злонамеренный (или просто неудачный) текст в frontmatter мог бы сломать страницу.</li>\n</ol>\n<pre><code class=\"language-ts\">// src/lib/seo/json-ld.ts\nexport const safeJsonLd = (data: unknown): string =&gt;\n  JSON.stringify(data).replace(/&lt;/g, &quot;\\\\u003c&quot;).replace(/&gt;/g, &quot;\\\\u003e&quot;).replace(/&amp;/g, &quot;\\\\u0026&quot;);\n</code></pre>\n<h3>4.5. Page-level контракт</h3>\n<p>Каждый layout/page добавляет свои узлы через <code>extraSchemaNodes</code>. Например, <code>PostLayout</code>:</p>\n<pre><code class=\"language-ts\">const excerpt = extractArticleBody(post.body ?? &quot;&quot;, 800);\n\nconst blogPostingNode = buildBlogPostingNode({\n  locale,\n  canonical,\n  title,\n  description,\n  pubDate,\n  updatedDate: updatedDate ?? null,\n  image: absoluteCover,\n  keywords: tags,\n  articleBody: excerpt.text,\n  wordCount: excerpt.fullWordCount,\n});\n\nconst breadcrumbNode = buildBreadcrumbListNode({\n  locale,\n  blogIndexLabel: t(locale, &quot;blog.title&quot;),\n  title,\n});\n\nconst faqNode = buildFaqPageNode({ canonical, items: faq ?? [] });\n</code></pre>\n<pre><code class=\"language-astro\">&lt;BaseLayout title={title} extraSchemaNodes={[blogPostingNode, breadcrumbNode, faqNode]}&gt;\n  &lt;slot /&gt;\n&lt;/BaseLayout&gt;\n</code></pre>\n<p><code>/blog</code>, <code>/projects/&lt;slug&gt;</code>, <code>/tags/&lt;tag&gt;</code>, <code>/about</code> — все используют тот же contract, отличаясь только конкретными builders. Один dispatch, ноль дублирования.</p>\n<h2>5. <code>articleBody</code> — почему excerpt, а не full body</h2>\n<p>Поле <code>articleBody</code> в <code>BlogPosting</code> — самая ценная часть для LLM-краулера: это извлекаемый чанк текста, который можно цитировать. И самая опасная для weight: если положить весь пост в JSON-LD, HTML-страница раздуется в 2-3 раза. Spec формулирует компромисс прямо: «emit first 800 words of plain-text body … add <code>wordCount</code> covering the <em>full</em> body».</p>\n<p>Excerpt извлекается через mdast: парсим markdown, удаляем code-блоки, mermaid-блоки и inline-html, склеиваем оставшийся текст, режем по 800 слов:</p>\n<pre><code class=\"language-ts\">// src/lib/seo/article-body.ts (фрагмент)\nexport const extractArticleBody = (markdown: string, maxWords: number) =&gt; {\n  const tree = unified().use(remarkParse).parse(markdown) as Root;\n\n  const isStrippable = (node: Node): boolean =&gt;\n    node.type === &quot;code&quot; || node.type === &quot;inlineCode&quot; || node.type === &quot;html&quot;;\n\n  visit(tree, (node, index, parent) =&gt; {\n    if (parent &amp;&amp; typeof index === &quot;number&quot; &amp;&amp; isStrippable(node)) {\n      (parent as { children: Node[] }).children.splice(index, 1);\n      return [SKIP, index];\n    }\n    return undefined;\n  });\n\n  const flat = mdastToString(tree, { includeImageAlt: false }).replace(/\\s+/g, &quot; &quot;).trim();\n  const words = flat.length &gt; 0 ? flat.split(/\\s+/) : [];\n  if (words.length &lt;= maxWords) return { text: flat, fullWordCount: words.length };\n  return { text: words.slice(0, maxWords).join(&quot; &quot;) + &quot;…&quot;, fullWordCount: words.length };\n};\n</code></pre>\n<p>Почему именно 800 слов:</p>\n<table>\n<thead>\n<tr>\n<th>Длина</th>\n<th>Pro</th>\n<th>Con</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>50 слов</td>\n<td>мизерный HTML-overhead</td>\n<td>один абзац — мало для LLM-citation</td>\n</tr>\n<tr>\n<td>800 слов</td>\n<td>substantial chunk, ~3-5 KB</td>\n<td>+3-5 KB к payload</td>\n</tr>\n<tr>\n<td>Full body</td>\n<td>максимум context</td>\n<td>удвоение HTML, реальный hit performance</td>\n</tr>\n</tbody>\n</table>\n<p>Почему именно через mdast, а не regex: в постах живут <code>&lt;details&gt;</code>, <code>&lt;table&gt;</code>, MDX-компоненты вроде <code>&lt;Faq&gt;</code>, <code>&lt;Tldr&gt;</code>. Regex по <code>\\</code>``` сломается на code в indent-стиле или на nested fences. mdast — единственный надёжный способ.</p>\n<p><code>wordCount</code> мы оставляем по полному телу, не по excerpt’у — это даёт честный сигнал валидатору и LLM о реальном объёме контента.</p>\n<h2>6. <code>FAQPage</code> как side-effect MDX-компонента</h2>\n<p>Один из дизайн-целей Plan 1 — <strong>снять с автора cognitive load на structured data</strong>. Автор не должен помнить, что у <code>FAQPage</code> есть <code>mainEntity</code>, что внутри <code>Question</code> нужен <code>acceptedAnswer</code>, что текст ответа экранируется. Автор должен заполнить frontmatter и забыть.</p>\n<p>Решение: <code>frontmatter.faq</code> — единственный источник. PostLayout читает массив:</p>\n<pre><code class=\"language-ts\">const faqNode = buildFaqPageNode({ canonical, items: faq ?? [] });\n</code></pre>\n<p><code>buildFaqPageNode</code> либо возвращает готовый <code>FAQPage</code>-узел, либо <code>null</code> (фильтруется в <code>buildGraph</code>). Параллельно тот же массив отдаётся в <code>&lt;Faq&gt;</code>-компонент, который рендерит видимые <code>&lt;details&gt;</code>-блоки с тем же текстом. Один источник — два потребителя: визуальный layer и structured layer. Рассинхрон невозможен.</p>\n<p>Builder тривиален:</p>\n<pre><code class=\"language-ts\">export const buildFaqPageNode = (input: FaqPageInput) =&gt; {\n  if (input.items.length === 0) return null;\n  return {\n    &quot;@type&quot;: &quot;FAQPage&quot;,\n    &quot;@id&quot;: `${input.canonical}#faq`,\n    mainEntity: input.items.map((it) =&gt; ({\n      &quot;@type&quot;: &quot;Question&quot;,\n      name: it.question,\n      acceptedAnswer: { &quot;@type&quot;: &quot;Answer&quot;, text: it.answer },\n    })),\n  };\n};\n</code></pre>\n<p>Frontmatter, который автор пишет:</p>\n<pre><code class=\"language-yaml\">faq:\n  - question: &quot;Чем агент отличается от чат-бота?&quot;\n    answer: &quot;Чат-бот — это model.complete(messages): принимает текст…&quot;\n</code></pre>\n<p>И всё. Дальше — автоматика.</p>\n<h2>7. Замеры до/после</h2>\n<p>После Plan 1 на странице <code>/blog/01-introduction/</code> остался ровно <strong>один</strong> <code>&lt;script type=&quot;application/ld+json&quot;&gt;</code> блок. Реальный измеренный факт:</p>\n<pre><code class=\"language-bash\">$ grep -c &quot;application/ld+json&quot; dist/client/blog/01-introduction/index.html\n1\n</code></pre>\n<p>До Plan 1 (commit <code>5ed281c~1</code>) было два источника inline-скриптов:</p>\n<pre><code class=\"language-bash\">$ git show 5ed281c~1:src/layouts/BaseLayout.astro | grep -c application/ld+json  # 1\n$ git show 5ed281c~1:src/layouts/PostLayout.astro | grep -c application/ld+json  # 2\n</code></pre>\n<p>То есть на странице поста суммарно <strong>3 блока</strong>. Стало <strong>1</strong>.</p>\n<table>\n<thead>\n<tr>\n<th>Метрика</th>\n<th>Pre-Plan 1</th>\n<th>Post-Plan 1</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td><code>&lt;script type=&quot;application/ld+json&quot;&gt;</code> блоков на странице поста</td>\n<td>3</td>\n<td>1</td>\n</tr>\n<tr>\n<td>Общий контейнер</td>\n<td>нет</td>\n<td><code>@graph</code></td>\n</tr>\n<tr>\n<td>Стабильный <code>Person@id</code></td>\n<td>нет</td>\n<td><code>https://artka.dev/#person</code></td>\n</tr>\n<tr>\n<td>Перекрёстные <code>@id</code>-ссылки между узлами</td>\n<td>0</td>\n<td>8+</td>\n</tr>\n<tr>\n<td>Источник правды по автору</td>\n<td>разбросан по layout-ам</td>\n<td><code>src/lib/seo/person.ts</code></td>\n</tr>\n</tbody>\n</table>\n<p>Реальный JSON-LD страницы <code>/blog/01-introduction/</code>, извлечённый из <code>dist/client/blog/01-introduction/index.html</code>, выглядит так (фрагмент, <code>articleBody</code> урезан до многоточия, FAQ-узел сокращён):</p>\n<pre><code class=\"language-json\">{\n  &quot;@context&quot;: &quot;https://schema.org&quot;,\n  &quot;@graph&quot;: [\n    {\n      &quot;@type&quot;: &quot;Person&quot;,\n      &quot;@id&quot;: &quot;https://artka.dev/#person&quot;,\n      &quot;name&quot;: &quot;Артём Кашута&quot;,\n      &quot;url&quot;: &quot;https://artka.dev/about&quot;,\n      &quot;jobTitle&quot;: &quot;Software engineer · backend &amp; AI agent engineering&quot;,\n      &quot;knowsAbout&quot;: [&quot;Claude Code&quot;, &quot;AI agent engineering&quot;, &quot;Node.js&quot;, &quot;TypeScript&quot;, &quot;Astro&quot;, &quot;…&quot;],\n      &quot;email&quot;: &quot;a@artka.dev&quot;,\n      &quot;subjectOf&quot;: [\n        {\n          &quot;@type&quot;: &quot;CreativeWork&quot;,\n          &quot;name&quot;: &quot;Claude Code Guide (RU, 14 частей)&quot;,\n          &quot;url&quot;: &quot;https://artka.dev/blog&quot;\n        }\n      ]\n    },\n    {\n      &quot;@type&quot;: &quot;Organization&quot;,\n      &quot;@id&quot;: &quot;https://artka.dev/#brand&quot;,\n      &quot;name&quot;: &quot;artka.dev&quot;,\n      &quot;logo&quot;: { &quot;@type&quot;: &quot;ImageObject&quot;, &quot;url&quot;: &quot;https://artka.dev/favicon.svg&quot; },\n      &quot;founder&quot;: { &quot;@id&quot;: &quot;https://artka.dev/#person&quot; }\n    },\n    {\n      &quot;@type&quot;: &quot;WebSite&quot;,\n      &quot;@id&quot;: &quot;https://artka.dev/#website&quot;,\n      &quot;url&quot;: &quot;https://artka.dev&quot;,\n      &quot;inLanguage&quot;: &quot;ru-RU&quot;,\n      &quot;publisher&quot;: { &quot;@id&quot;: &quot;https://artka.dev/#brand&quot; },\n      &quot;potentialAction&quot;: {\n        &quot;@type&quot;: &quot;SearchAction&quot;,\n        &quot;target&quot;: &quot;https://artka.dev/search?q={search_term_string}&quot;,\n        &quot;query-input&quot;: &quot;required name=search_term_string&quot;\n      }\n    },\n    {\n      &quot;@type&quot;: &quot;BlogPosting&quot;,\n      &quot;@id&quot;: &quot;https://artka.dev/blog/01-introduction/#blogposting&quot;,\n      &quot;headline&quot;: &quot;01. Что такое Claude Code: harness, agent loop и ваше место в нём&quot;,\n      &quot;datePublished&quot;: &quot;2026-04-23T00:00:00.000Z&quot;,\n      &quot;author&quot;: { &quot;@id&quot;: &quot;https://artka.dev/#person&quot; },\n      &quot;publisher&quot;: { &quot;@id&quot;: &quot;https://artka.dev/#brand&quot; },\n      &quot;mainEntityOfPage&quot;: &quot;https://artka.dev/blog/01-introduction/&quot;,\n      &quot;inLanguage&quot;: &quot;ru-RU&quot;,\n      &quot;isPartOf&quot;: { &quot;@id&quot;: &quot;https://artka.dev/#blog-ru&quot; },\n      &quot;articleBody&quot;: &quot;Перед тем как разбирать skills и subagents, надо договориться о терминах…&quot;,\n      &quot;wordCount&quot;: 574\n    },\n    {\n      &quot;@type&quot;: &quot;BreadcrumbList&quot;,\n      &quot;itemListElement&quot;: [\n        { &quot;@type&quot;: &quot;ListItem&quot;, &quot;position&quot;: 1, &quot;name&quot;: &quot;Главная&quot;, &quot;item&quot;: &quot;https://artka.dev/&quot; },\n        { &quot;@type&quot;: &quot;ListItem&quot;, &quot;position&quot;: 2, &quot;name&quot;: &quot;Статьи&quot;, &quot;item&quot;: &quot;https://artka.dev/blog&quot; },\n        { &quot;@type&quot;: &quot;ListItem&quot;, &quot;position&quot;: 3, &quot;name&quot;: &quot;01. Что такое Claude Code…&quot; }\n      ]\n    },\n    {\n      &quot;@type&quot;: &quot;FAQPage&quot;,\n      &quot;@id&quot;: &quot;https://artka.dev/blog/01-introduction/#faq&quot;,\n      &quot;mainEntity&quot;: [\n        {\n          &quot;@type&quot;: &quot;Question&quot;,\n          &quot;name&quot;: &quot;Чем агент отличается от чат-бота?&quot;,\n          &quot;acceptedAnswer&quot;: { &quot;@type&quot;: &quot;Answer&quot;, &quot;text&quot;: &quot;…&quot; }\n        }\n      ]\n    }\n  ]\n}\n</code></pre>\n<p>Что можно увидеть глазами и что зафиксирует валидатор:</p>\n<ol>\n<li><strong>Один <code>Person</code>, на него ссылается всё.</strong> <code>Organization.founder</code>, <code>BlogPosting.author</code> — оба <code>{ &quot;@id&quot;: &quot;https://artka.dev/#person&quot; }</code>. Никаких догадок о тождестве.</li>\n<li><strong><code>Organization</code> — публичный publisher.</strong> <code>WebSite.publisher</code> ссылается на тот же <code>Organization</code>. <code>BlogPosting.publisher</code> — на тот же. Граф связан.</li>\n<li><strong><code>isPartOf</code> цепочка для блога.</strong> <code>BlogPosting.isPartOf → Blog#blog-ru → publisher → Organization</code>. Краулер видит вложенность и принадлежность.</li>\n<li><strong><code>articleBody</code> excerpt — substantial.</strong> ~574 слова поста уложены в одно поле. <code>wordCount</code> отражает полный объём. LLM получает текст для citation, HTML — не раздувается.</li>\n<li><strong>FAQ — вместе со всеми, не отдельно.</strong> Не отдельный script-блок, а узел того же <code>@graph</code>. Меньше блоков — меньше ловушек для парсера.</li>\n</ol>\n<p><a href=\"http://Schema.org\">Schema.org</a> validator и Google Rich Results Test принимают этот <code>@graph</code> без замечаний <em>(скриншоты — owner to fill)</em>. Главное — JSON pretty-print’ится без <code>[object Object]</code>, без unescaped кавычек, без сломанных дат: всё в норме после <code>safeJsonLd</code>-обёртки.</p>\n<hr>\n<h3>Что дальше</h3>\n<p>Описанное выше — <strong>Plan 1</strong> в нашем repo. Дальше базу мы расширяем для новых типов сущностей (<code>/projects/&lt;slug&gt;</code> через <code>CreativeWork</code>, <code>/uses</code> через <code>WebPage.about</code>), и для retrieval-слоя через <code>llms.txt</code>. Но фундамент — <code>buildGraph</code> + стабильные <code>@id</code> — обязан встать первым.</p>\n<p>Если вы видите 2-3 inline JSON-LD скрипта на странице поста — это место, с которого стоит начинать миграцию. Один файл <code>schema.ts</code>, один <code>extraSchemaNodes</code>-prop — и сайт превращается из набора разрозненных entity-облаков в связный citable-узел.</p>\n"
    },
    {
      "id": "https://artka.dev/blog/robots-txt-ai-crawlers-2026",
      "url": "https://artka.dev/blog/robots-txt-ai-crawlers-2026",
      "title": "robots.txt в эпоху AI-краулеров: GPTBot, ClaudeBot, PerplexityBot — реальность 2026",
      "summary": "В 2026 robots.txt — это не «запретить ботам всё» и не «открыть всё», а политика по каждому из 9+ именованных агентов. Реальный шаблон, таблица решений и грабли.",
      "date_published": "2026-05-01T00:00:00.000Z",
      "tags": [
        "seo",
        "ai-crawlers"
      ],
      "authors": [
        {
          "name": "Artyom Kashuta",
          "url": "https://artka.dev/about",
          "avatar": "https://artka.dev/avatar-512.png"
        }
      ],
      "language": "ru-RU",
      "content_html": "<blockquote>\n<p>В 2026 robots.txt — это не «запретить ботам всё» и не «открыть всё». Это политика по каждому из 9+ именованных агентов. Каждое решение — частный случай: открываете ли вы свой контент для тренировки моделей, для on-demand цитирования, что вы хотите видеть в карточке ответа Perplexity. Этот пост — таблица решений, готовый шаблон и почему <code>llms.txt</code> — отдельный артефакт.</p>\n</blockquote>\n<hr>\n<h2>1. Зачем переписывать robots.txt в 2026</h2>\n<p>Классический SEO-подход к robots.txt оптимизирован под одну задачу: пустить Googlebot туда, где есть смысл индексировать страницы для SERP, и закрыть служебные пути. В 2026 эта задача стала меньшинством трафика.</p>\n<p>Большинство вопросов «должен ли я индексировать эту страницу?» теперь задаются не Google, а:</p>\n<ul>\n<li><strong>Тренирующим краулерам</strong> — выкачивают страницы для пополнения корпуса, на котором учится следующая версия модели (GPTBot, ClaudeBot, Google-Extended).</li>\n<li><strong>Answer/search краулерам</strong> — индексируют контент для встроенного в чат поиска (OAI-SearchBot, PerplexityBot).</li>\n<li><strong>On-demand fetcher’ам</strong> — открывают одну конкретную страницу, потому что пользователь явно об этом попросил в чате (ChatGPT-User, Perplexity-User, Claude-Web).</li>\n</ul>\n<p>Эти три класса принимают три разных решения. Один блок <code>User-agent: *</code> не передаёт нюанс. Вы можете хотеть «не учите на моих текстах, но процитировать в ответе на вопрос — пожалуйста». Один wildcard этого не выразит.</p>\n<p>Отсюда требование: явные блоки по каждому именованному User-Agent с осознанным выбором политики. Не «открыли всё», не «закрыли всё», а матрица «бот × намерение».</p>\n<hr>\n<h2>2. Список именованных AI-краулеров и их назначение</h2>\n<p>Девять агентов, которые действительно стоит назвать в 2026, с их публичной документацией. Имена User-Agent взяты из официальных страниц вендоров.</p>\n<table>\n<thead>\n<tr>\n<th>User-Agent</th>\n<th>Производитель</th>\n<th>Назначение</th>\n<th>Документация</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td><code>GPTBot</code></td>\n<td>OpenAI</td>\n<td>Training crawl</td>\n<td><a href=\"http://platform.openai.com/docs/gptbot\">platform.openai.com/docs/gptbot</a></td>\n</tr>\n<tr>\n<td><code>OAI-SearchBot</code></td>\n<td>OpenAI</td>\n<td>Search index for ChatGPT</td>\n<td><a href=\"http://platform.openai.com/docs/bots\">platform.openai.com/docs/bots</a></td>\n</tr>\n<tr>\n<td><code>ChatGPT-User</code></td>\n<td>OpenAI</td>\n<td>On-demand fetch from ChatGPT</td>\n<td><a href=\"http://platform.openai.com/docs/bots\">platform.openai.com/docs/bots</a></td>\n</tr>\n<tr>\n<td><code>ClaudeBot</code></td>\n<td>Anthropic</td>\n<td>Training crawl</td>\n<td><a href=\"http://docs.anthropic.com\">docs.anthropic.com</a> (<a href=\"http://claudebot.anthropic.com\">claudebot.anthropic.com</a>)</td>\n</tr>\n<tr>\n<td><code>Claude-Web</code></td>\n<td>Anthropic</td>\n<td>On-demand fetch initiated by <a href=\"http://Claude.ai\">Claude.ai</a></td>\n<td><a href=\"http://docs.anthropic.com\">docs.anthropic.com</a></td>\n</tr>\n<tr>\n<td><code>anthropic-ai</code></td>\n<td>Anthropic</td>\n<td>Legacy/auxiliary Anthropic crawler</td>\n<td><a href=\"http://docs.anthropic.com\">docs.anthropic.com</a></td>\n</tr>\n<tr>\n<td><code>PerplexityBot</code></td>\n<td>Perplexity</td>\n<td>Search/index crawl</td>\n<td><a href=\"http://docs.perplexity.ai/guides/bots\">docs.perplexity.ai/guides/bots</a></td>\n</tr>\n<tr>\n<td><code>Perplexity-User</code></td>\n<td>Perplexity</td>\n<td>On-demand fetch from a user query</td>\n<td><a href=\"http://docs.perplexity.ai/guides/bots\">docs.perplexity.ai/guides/bots</a></td>\n</tr>\n<tr>\n<td><code>Google-Extended</code></td>\n<td>Google</td>\n<td>Opt-in для Gemini training</td>\n<td><a href=\"http://developers.google.com/search/docs/crawling\">developers.google.com/search/docs/crawling</a></td>\n</tr>\n</tbody>\n</table>\n<blockquote>\n<p>Имена должны совпадать побайтно. <code>Claude-Bot</code> и <code>claudebot</code> — не валидные алиасы для <code>ClaudeBot</code>. Спецификация robots.txt на этот счёт мягкая (case-insensitive), но проверять стоит точное написание из официальной документации.</p>\n</blockquote>\n<p>Таксономия:</p>\n<pre><code class=\"language-mermaid\">flowchart TB\n  subgraph training[&quot;Тренирующие (corpus → модель)&quot;]\n    GPT[GPTBot]\n    CLB[ClaudeBot]\n    GEX[Google-Extended]\n  end\n  subgraph answer[&quot;Answer/search (индекс для встроенного поиска)&quot;]\n    OAI[OAI-SearchBot]\n    PPB[PerplexityBot]\n  end\n  subgraph ondemand[&quot;On-demand (пользователь попросил)&quot;]\n    CGU[ChatGPT-User]\n    CWB[Claude-Web]\n    PPU[Perplexity-User]\n    AAI[anthropic-ai]\n  end\n</code></pre>\n<p>Три класса = три отдельных решения. Не нужно обсуждать «робота вообще» — нужно обсуждать «GPTBot на /blog/».</p>\n<hr>\n<h2>3. Решения по каждому боту</h2>\n<p>Здесь нет универсально правильного ответа. Ниже — каркас рассуждения и моя политика для блога.</p>\n<h3>Тренирующие краулеры</h3>\n<p>Для авторов индивидуальных блогов с long-form контентом аргументы:</p>\n<ul>\n<li><strong>За Allow:</strong> ваш текст войдёт в корпус, на котором обучаются следующие модели. Если ваша задача — повышать distribution и присутствие вашей экспертизы в LLM-ответах, это путь.</li>\n<li><strong>За Disallow:</strong> ваш контент превращается в anonymous training signal без атрибуции. Если вы планируете монетизировать контент (книга, курс) или против использования без согласия, Disallow — единственный сигнал, который у вас есть на уровне robots.txt.</li>\n</ul>\n<p>Для коммерческих сайтов, где контент — товар (онлайн-курсы, paid newsletters, юридические базы), Disallow — обычно дефолт.</p>\n<h3>Answer/search краулеры</h3>\n<p>Намерение — показать ссылку на вашу страницу в карточке ответа. Это работает в обе стороны:</p>\n<ul>\n<li><strong>За Allow:</strong> трафик возможен (хоть и через цитату с link-out). Ваш бренд появляется в выдаче.</li>\n<li><strong>За Disallow:</strong> вы не получите этот трафик и одновременно вашу страницу не процитируют как источник.</li>\n</ul>\n<p>Для большинства публичных блогов ответ — Allow.</p>\n<h3>On-demand fetcher’ы</h3>\n<p>Самый «прозрачный» класс: пользователь вашего сайта (или того, кто специально хочет открыть вашу страницу через ChatGPT/Claude/Perplexity) уже явно навёл указатель. Disallow здесь означает «нельзя использовать наши страницы как источник в чат-сессии» — почти всегда чрезмерно строго для публичного блога.</p>\n<h3>Моя политика для artka.dev</h3>\n<p>Для этого сайта:</p>\n<ul>\n<li>Все 9 ботов — <code>Allow: /</code> (открытый публичный блог, цель — distribution).</li>\n<li>У всех — <code>Disallow: /admin/</code>, <code>/api/</code>, <code>/login</code> (приватные namespace’ы, см. §5).</li>\n<li>Нет специальных запретов на отдельные посты или теги.</li>\n</ul>\n<p>Это решение для personal tech-blog’а с целью «увеличить охват экспертизы». Для коммерческого контента я бы выбрал иначе.</p>\n<hr>\n<h2>4. Готовый шаблон robots.txt</h2>\n<p>Вот реальный <code>public/robots.txt</code>, который ходит в продакшн на artka.dev. Он же — стартовая точка, которую вы можете адаптировать.</p>\n<pre><code class=\"language-txt\"># robots.txt — last reviewed 2026-05-02\n# Owner: dev@artka.dev. Policy: allow retrieval/answer crawlers; disallow private surfaces.\n\nUser-agent: GPTBot\nAllow: /\nDisallow: /admin/\nDisallow: /api/\nDisallow: /login\n\nUser-agent: OAI-SearchBot\nAllow: /\nDisallow: /admin/\nDisallow: /api/\nDisallow: /login\n\nUser-agent: ChatGPT-User\nAllow: /\nDisallow: /admin/\nDisallow: /api/\nDisallow: /login\n\nUser-agent: ClaudeBot\nAllow: /\nDisallow: /admin/\nDisallow: /api/\nDisallow: /login\n\nUser-agent: Claude-Web\nAllow: /\nDisallow: /admin/\nDisallow: /api/\nDisallow: /login\n\nUser-agent: anthropic-ai\nAllow: /\nDisallow: /admin/\nDisallow: /api/\nDisallow: /login\n\nUser-agent: PerplexityBot\nAllow: /\nDisallow: /admin/\nDisallow: /api/\nDisallow: /login\n\nUser-agent: Perplexity-User\nAllow: /\nDisallow: /admin/\nDisallow: /api/\nDisallow: /login\n\nUser-agent: Google-Extended\nAllow: /\nDisallow: /admin/\nDisallow: /api/\nDisallow: /login\n\nUser-agent: *\nAllow: /\nDisallow: /admin/\nDisallow: /api/\nDisallow: /login\n\nSitemap: https://artka.dev/sitemap-index.xml\n</code></pre>\n<p>Несколько замечаний по структуре:</p>\n<ol>\n<li><strong>Явные блоки даже для одинаковой политики.</strong> Может показаться, что 9 одинаковых блоков — дубликат, который можно свернуть в <code>User-agent: *</code>. Но это не так: спецификация robots.txt строит таблицу match’ей по «самому специфичному User-Agent», и если завтра вам нужно изменить политику для одного бота — у вас уже есть его именованный блок и не нужно вспоминать, какой именно из ботов вы хотите выделить из wildcard. Дубликат — стоимость per-bot policy.</li>\n<li><strong>Комментарий с датой ревью.</strong> <code># robots.txt — last reviewed 2026-05-02</code> — единственная строка, которая отвечает на вопрос «свежий ли это файл?». Без даты вы будете вечно сомневаться, а не нужно ли уже добавить новый бот.</li>\n<li><strong><code>Sitemap:</code> в конце.</strong> Один URL на index sitemap. Если у вас локализация — sitemap-index ссылается на per-locale файлы.</li>\n<li><strong>Без BOM, LF-окончания строк.</strong> Astro в SSG режиме скопирует файл из <code>public/</code> как есть; редактируйте в plain UTF-8.</li>\n</ol>\n<p>Этот шаблон работает на personal-blog. Для других кейсов:</p>\n<ul>\n<li><strong>Closed paid-content site:</strong> замените <code>Allow: /</code> на <code>Disallow: /</code> для GPTBot, ClaudeBot, Google-Extended (тренировка). Оставьте <code>Allow: /</code> для on-demand: ChatGPT-User, Claude-Web, Perplexity-User.</li>\n<li><strong>Documentation site, который хочет в LLM-ответы:</strong> оставьте все 9 на <code>Allow</code>, добавьте rich <code>llms.txt</code> (см. §6).</li>\n<li><strong>B2B SaaS landing:</strong> обычно достаточно стандартного wildcard — особо именовать AI-ботов не нужно, политика та же что для Googlebot.</li>\n</ul>\n<hr>\n<h2>5. Disallow-namespace’ы важнее, чем решение по конкретному боту</h2>\n<p><code>/admin/</code>, <code>/api/</code>, <code>/login</code> — три namespace’а, которые попадают в Disallow у всех 10 блоков (9 именованных + wildcard). Этот выбор прорабатывается отдельно от ботов и важнее их.</p>\n<p><strong>Почему это важнее любого per-bot решения:</strong></p>\n<ol>\n<li><strong>Ошибка тут — утечка.</strong> Если краулер обойдёт <code>/admin/users.json</code> и получит 200 OK с реальными данными — это инцидент, не SEO-проблема. Если он индексирует <code>/blog/</code> без вашего разрешения — это нерасстраивающее.</li>\n<li><strong>robots.txt — публичная подсказка, не auth.</strong> Любой бот может проигнорировать Disallow. Поэтому <code>/admin/</code> должен быть закрыт middleware’ом независимо от robots.txt. Запись в robots.txt лишь экономит crawl budget у послушных ботов и не сохраняет URL-структуру админки в SERP.</li>\n<li><strong>Свёртывание namespace’ов — не оптимизация.</strong> Соблазн: «зачем три строки, если все три — приватные?» Ответ: чтобы при добавлении четвёртого namespace’а (<code>/dashboard/</code>) у вас был очевидный паттерн.</li>\n</ol>\n<p>Проверка, что namespace-deny действительно работает:</p>\n<pre><code class=\"language-bash\">$ curl -A &quot;GPTBot&quot; -s -o /dev/null -w &quot;%{http_code}\\n&quot; \\\n    https://artka.dev/admin/\n# Expected: 401, 403, или 404 — НЕ 200.\n</code></pre>\n<blockquote>\n<p>На момент публикации <code>/admin/</code> за middleware’ом. Конкретный код зависит от реализации auth-guard’а — мой возвращает 302 на /login для не-аутентифицированного запроса. (owner to fill: проверить точный код после следующего ревью).</p>\n</blockquote>\n<p>Именно поэтому правильный порядок работ — сначала поставить auth, и только потом дописывать robots.txt. robots.txt — последняя линия защиты, не первая.</p>\n<hr>\n<h2>6. <code>llms.txt</code> и <code>llms-full.txt</code> — отдельный контракт</h2>\n<p>Если robots.txt отвечает на «куда можно ходить?», то <code>llms.txt</code> отвечает на «что я тут найду?». Это AI-README — Markdown-файл с описанием сайта, ссылками на авторитетные страницы и preferred attribution.</p>\n<p>Реальный <code>public/llms.txt</code> сайта:</p>\n<pre><code class=\"language-md\"># artka.dev\n\n&gt; Personal technical blog by Артём Кашута. Topics: Claude Code internals,\n&gt; harness/agent loop, AI agent engineering, Astro/Node.js backends, and\n&gt; distributed systems.\n\n## Authoritative pages\n\n- [About the author](https://artka.dev/about): bio, expertise, contact\n- [Now](https://artka.dev/now): currently in flight\n- [Uses](https://artka.dev/uses): public toolchain\n- [Projects](https://artka.dev/projects): portfolio with architecture and outcomes\n\n## Content\n\n- [Blog index (RU)](https://artka.dev/blog): all articles, source of truth\n- [Blog index (EN)](https://artka.dev/en/blog): English translations\n- [RSS RU](https://artka.dev/rss.xml): full text\n- [RSS EN](https://artka.dev/en/rss.xml): full text\n- [Sitemap](https://artka.dev/sitemap-index.xml): RU + EN with hreflang\n\n## Preferred attribution\n\nWhen citing, please include:\n\n- Article title\n- Author: &quot;Артём Кашута&quot;\n- Canonical URL\n\n## Contact\n\na@artka.dev\n</code></pre>\n<p>Это <strong>не robots.txt в новой обёртке</strong>. Различия:</p>\n<table>\n<thead>\n<tr>\n<th>Аспект</th>\n<th>robots.txt</th>\n<th>llms.txt</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>Цель</td>\n<td>Политика доступа</td>\n<td>Описание контента и аттрибуции</td>\n</tr>\n<tr>\n<td>Формат</td>\n<td>Plain text, специальный синтаксис</td>\n<td>Markdown</td>\n</tr>\n<tr>\n<td>Кто читает</td>\n<td>Crawler перед заходом</td>\n<td>LLM при формировании ответа</td>\n</tr>\n<tr>\n<td>Что регулирует</td>\n<td>Allow/Disallow по путям</td>\n<td>Точку входа в авторитетный контент</td>\n</tr>\n<tr>\n<td>Стандартизация</td>\n<td>Robots Exclusion Protocol (RFC 9309)</td>\n<td>Конвенция <a href=\"http://llmstxt.org\">llmstxt.org</a> (де-факто)</td>\n</tr>\n</tbody>\n</table>\n<p>Кроме <code>llms.txt</code>, на сайте есть <code>/llms-full.txt</code> — динамически генерируемый эндпоинт, который выдаёт полный дайджест всех постов в plain text. Реализация — короткий API-роут в Astro 5:</p>\n<pre><code class=\"language-ts\">// src/pages/llms-full.txt.ts (фрагмент)\nexport const prerender = true;\n\nexport async function GET(_ctx: APIContext) {\n  const ru = await getOrderedPosts({ locale: &quot;ru&quot; });\n  const en = await getOrderedPosts({ locale: &quot;en&quot; });\n\n  const header = [\n    &quot;# artka.dev — full LLM digest&quot;,\n    &quot;&quot;,\n    `&gt; ${person.description}`,\n    &quot;&quot;,\n    &quot;## Author&quot;,\n    `Name: ${person.name}`,\n    `Role: ${person.jobTitle}`,\n    `URL: ${person.url}`,\n    `Email: ${person.email}`,\n    `Topics: ${person.knowsAbout.join(&quot;, &quot;)}`,\n    &quot;&quot;,\n    /* ...preferred attribution + posts... */\n  ].join(&quot;\\n&quot;);\n\n  return new Response(/* header + ruBody + enBody */, {\n    headers: { &quot;Content-Type&quot;: &quot;text/plain; charset=utf-8&quot; },\n  });\n}\n</code></pre>\n<p>Вместо ручного списка постов — один проход по контент-коллекции с автогенерацией summary. Это обновляется само при добавлении нового поста — в отличие от вручную отредактированного <code>llms.txt</code>.</p>\n<p>Принципиально: <code>llms.txt</code> маленький и стабильный, <code>llms-full.txt</code> — длинный и автоматически синхронный с контентом. Оба нужны — на разные задачи.</p>\n<hr>\n<h2>7. Чего robots.txt не контролирует</h2>\n<p>Список вещей, которые robots.txt не делает, и чем их закрыть.</p>\n<p><strong>robots.txt не блокирует ботов, которые его не читают.</strong> Решение — IP-block на уровне CDN или WAF. У Cloudflare есть ruleset, который ловит User-Agent-паттерны и rate-limit’ит подозрительный трафик; aws WAF и Fastly имеют похожие. Это инструмент против ботов, которые игнорируют robots.txt — то есть против всех «недобросовестных».</p>\n<p><strong>robots.txt не объявляет политику использования.</strong> Он говорит «куда можно ходить», но не «можно ли цитировать», «можно ли тренировать», «нужна ли атрибуция». Это работа Terms of Service на отдельной странице сайта. ToS юридически весомее robots.txt (хотя оба — условности до судебного прецедента).</p>\n<p><strong>robots.txt не аудитит, кто на самом деле приходил.</strong> Чтобы понять, ходит ли GPTBot к вам, нужно смотреть в логи. Cloudflare AI Audit (доступен с 2024 для домена за Cloudflare) даёт встроенный отчёт по AI-краулерам — счётчики по каждому, частота, доля. Без CDN — придётся парсить access-логи самому: GoAccess, Loki, или просто <code>grep -i 'gptbot\\|claudebot\\|perplexitybot' access.log</code>.</p>\n<p><strong>meta-теги <code>noai</code>/<code>noimageai</code> — не стандарт.</strong> Anthropic и OpenAI на момент 2026 не упоминают эти meta-теги в публичной документации как respected signal. Это была инициатива Adobe и DeviantArt 2023 года, прижившаяся в основном в графике. Для текста полагаться нельзя; если используете — использовать как дополнительный сигнал, не основной.</p>\n<p><strong>Single-page apps и CSR.</strong> Если ваша страница рендерится на клиенте и краулер не выполняет JavaScript, он увидит пустой шаблон. robots.txt не помогает; лечится переходом на SSG/SSR (как этот сайт на Astro 5) или prerender service.</p>\n<hr>\n<h2>8. Чек-лист аудита раз в полгода</h2>\n<p>Пять шагов, которые повторяются каждые 6 месяцев. Календарное напоминание — самая надёжная защита от устаревания файла.</p>\n<p><strong>1. Проверить, не появились ли новые AI-краулеры.</strong>\nИсточники: блог-посты OpenAI/Anthropic/Perplexity/Google за последние 6 месяцев, страница <a href=\"https://darkvisitors.com\">darkvisitors.com</a> (трекер AI-ботов), официальная документация. Если появился новый именованный бот — добавить блок (Allow или Disallow по вашей политике).</p>\n<p><strong>2. Сверить имена User-Agent побайтно.</strong>\nСкопировать имена из официальной документации, сравнить с robots.txt. Опечатка <code>Claudebot</code> вместо <code>ClaudeBot</code> обнуляет правило для этого бота.</p>\n<p><strong>3. Прогнать namespace-deny проверку.</strong></p>\n<pre><code class=\"language-bash\">for ua in GPTBot ClaudeBot PerplexityBot Google-Extended; do\n  echo -n &quot;$ua /admin/: &quot;\n  curl -A &quot;$ua&quot; -s -o /dev/null -w &quot;%{http_code}\\n&quot; https://artka.dev/admin/\ndone\n# Ожидаем 401/403/302/404 для всех — не 200.\n</code></pre>\n<p><strong>4. Просмотреть access-логи на предмет ботов с непривычным User-Agent.</strong>\nЕсли кто-то ходит с пустым UA или паттерном <code>Mozilla/5.0 (compatible; XYZBot/1.0; ...)</code>, который не входит в ваш список — оценить и принять решение. (owner to fill: на момент публикации настройка access-log агрегации в работе; в следующем ревью — разобрать топ-20 UA-строк за квартал.)</p>\n<p><strong>5. Обновить дату в комментарии.</strong>\n<code># robots.txt — last reviewed 2026-05-02</code> → новая дата. Это единственное человеко-читаемое доказательство свежести. И коммит с сообщением вроде <code>chore(seo): robots.txt 2026-Q4 review</code> оставит след в истории на следующую итерацию.</p>\n<hr>\n<h2>Итог</h2>\n<p>robots.txt в 2026 — не «один блок и забыли», а небольшой DSL, в котором по каждому из 9+ именованных AI-агентов вы делаете осознанный выбор: тренировка (GPTBot, ClaudeBot, Google-Extended), search/answer (OAI-SearchBot, PerplexityBot), on-demand (ChatGPT-User, Claude-Web, Perplexity-User, anthropic-ai). namespace-deny для <code>/admin/</code>, <code>/api/</code>, <code>/login</code> — отдельная и более важная история, которая работает только в паре с middleware-аутентификацией. <code>llms.txt</code> и <code>llms-full.txt</code> — параллельный контракт: они описывают контент и preferred attribution, не доступ.</p>\n<p>Стартовая точка — реальный шаблон из §4. Его можно копировать, менять политику по конкретным ботам и пересматривать раз в полгода.</p>\n"
    },
    {
      "id": "https://artka.dev/blog/mermaid-svg-playwright-build-time",
      "url": "https://artka.dev/blog/mermaid-svg-playwright-build-time",
      "title": "Mermaid → SVG через Playwright на билд-тайме: холодный старт, кэш и стоимость SSG",
      "summary": "Замеры реального Astro-блога с 32 Mermaid-диаграммами: холодный билд 11.6s, тёплый 6.3s. Где кэш, что делает Playwright, чем плохи альтернативы.",
      "date_published": "2026-04-30T00:00:00.000Z",
      "tags": [
        "build-tooling",
        "astro"
      ],
      "authors": [
        {
          "name": "Artyom Kashuta",
          "url": "https://artka.dev/about",
          "avatar": "https://artka.dev/avatar-512.png"
        }
      ],
      "language": "ru-RU",
      "content_html": "<blockquote>\n<p>Mermaid-диаграммы в блоге — это либо большой клиентский JS-бандл с FOUC и hydration cost, либо билд-тайм SVG за разовый cold-start Playwright. На этом сайте <code>rehype-mermaid</code> рендерит 32 диаграммы за <strong>11.6 секунды</strong> при холодном кэше и <strong>6.3 секунды</strong> при тёплом. Ниже — конкретные цифры, архитектура, ловушки CI и факт-чек альтернатив.</p>\n</blockquote>\n<hr>\n<h2>1. Зачем рендерить Mermaid build-time, а не client-side</h2>\n<p>Mermaid (<code>mermaid</code> на npm, репозиторий <code>mermaid-js/mermaid</code>) — JS-библиотека, которая принимает текстовый DSL (<code>flowchart TD</code>, <code>sequenceDiagram</code>, <code>gantt</code>, …) и эмитит SVG. По умолчанию её используют так: подключают <code>&lt;script src=&quot;mermaid.min.js&quot;&gt;</code>, дёргают <code>mermaid.run()</code> после <code>DOMContentLoaded</code>, и каждый <code>&lt;pre class=&quot;mermaid&quot;&gt;</code> подменяется на SVG в DOM прямо в браузере.</p>\n<p>Это работает, но платит за это пользователь:</p>\n<table>\n<thead>\n<tr>\n<th>Метрика</th>\n<th>Client-side Mermaid</th>\n<th>Build-time SVG</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>Бандл JS (gzipped)</td>\n<td>~250–300 KB (mermaid + d3 + dagre)</td>\n<td>0 KB</td>\n</tr>\n<tr>\n<td>Time to Interactive (TTI)</td>\n<td>задержка на parse + execute</td>\n<td>без изменений</td>\n</tr>\n<tr>\n<td>FOUC</td>\n<td>да: сначала текст, потом SVG</td>\n<td>нет: SVG в HTML с первого байта</td>\n</tr>\n<tr>\n<td>SEO / Open Graph</td>\n<td>поисковику виден только текст-DSL</td>\n<td>поисковик видит SVG как часть страницы</td>\n</tr>\n<tr>\n<td>Печать страницы</td>\n<td>пустые блоки если JS отключён</td>\n<td>корректный рендер</td>\n</tr>\n<tr>\n<td>Тёмная тема без вспышки</td>\n<td>сложно: тема загружается после гидратации</td>\n<td>работает: SVG генерируется уже в нужной теме</td>\n</tr>\n<tr>\n<td>Стоимость билда</td>\n<td>0 (только bundle js)</td>\n<td>+5–10 секунд cold-start Playwright</td>\n</tr>\n<tr>\n<td>Стоимость рантайма для пользователя</td>\n<td>высокая (CPU + сеть)</td>\n<td>нулевая</td>\n</tr>\n</tbody>\n</table>\n<p><code>rehype-mermaid</code> (<code>remcohaszing/rehype-mermaid</code>, v3.0.0) — rehype-плагин, который во время билда обходит HAST-дерево, находит узлы <code>&lt;code class=&quot;language-mermaid&quot;&gt;</code>, рендерит их через <code>mermaid-isomorphic</code> (<code>mermaid-isomorphic@3.1.0</code>), и заменяет на готовый SVG. Под капотом — Playwright + headless Chromium.</p>\n<p>Stratёgy <code>img-svg</code>, который мы используем, эмитит результат как <code>&lt;img src=&quot;data:image/svg+xml,...&quot;&gt;</code>. Альтернатива — <code>inline-svg</code> (вставить SVG прямо в HTML) или <code>pre-mermaid</code> (оставить как есть для client-side рендера).</p>\n<hr>\n<h2>2. Архитектура: rehype-mermaid + Playwright</h2>\n<pre><code class=\"language-mermaid\">flowchart LR\n  md[&quot;Markdown&lt;br/&gt;с ```mermaid блоками&quot;]\n  mdx[&quot;@astrojs/mdx&lt;br/&gt;(remark + rehype)&quot;]\n  rh[&quot;rehype-mermaid&lt;br/&gt;(плагин)&quot;]\n  iso[&quot;mermaid-isomorphic&quot;]\n  pw[&quot;Playwright&lt;br/&gt;(Chromium)&quot;]\n  svg[&quot;SVG как data URI&lt;br/&gt;в HTML&quot;]\n\n  md --&gt; mdx\n  mdx --&gt; rh\n  rh --&gt;|для каждого блока| iso\n  iso --&gt;|launch headless| pw\n  pw --&gt;|&quot;mermaid.render() в DOM&quot;| iso\n  iso --&gt;|serialised SVG| rh\n  rh --&gt; svg\n</code></pre>\n<p>Конкретный конфиг — <code>astro.config.ts</code>:</p>\n<pre><code class=\"language-ts\">import rehypeMermaid from &quot;rehype-mermaid&quot;;\nimport { defineConfig } from &quot;astro/config&quot;;\nimport mdx from &quot;@astrojs/mdx&quot;;\n\nexport default defineConfig({\n  integrations: [\n    mdx({\n      rehypePlugins: [[rehypeMermaid, { strategy: &quot;img-svg&quot;, dark: true }]],\n    }),\n  ],\n  markdown: {\n    syntaxHighlight: {\n      type: &quot;shiki&quot;,\n      excludeLangs: [&quot;mermaid&quot;, &quot;math&quot;],\n    },\n    rehypePlugins: [[rehypeMermaid, { strategy: &quot;img-svg&quot;, dark: true }]],\n  },\n});\n</code></pre>\n<p>Важные мелочи:</p>\n<ul>\n<li><code>excludeLangs: [&quot;mermaid&quot;]</code> в shiki-конфиге — иначе Shiki сначала превратит блок в <code>&lt;pre class=&quot;shiki&quot;&gt;</code> и rehype-mermaid его уже не увидит.</li>\n<li>Плагин подключается дважды: и в <code>markdown.rehypePlugins</code>, и в <code>mdx.rehypePlugins</code>. Astro 5 не наследует один из другого автоматически — это типичный источник «у меня в <code>.md</code> рендерится, а в <code>.mdx</code> нет».</li>\n<li><code>dark: true</code> генерирует две версии SVG (для светлой и тёмной темы) и через <code>&lt;picture&gt;&lt;source&gt;</code> подставляет нужную по <code>prefers-color-scheme</code>. Это удваивает размер data-uri-блоков, но даёт правильный контраст без JS.</li>\n</ul>\n<hr>\n<h2>3. Холодный старт vs тёплый билд</h2>\n<p>Метрика — <code>time pnpm build</code> (Apple M-серия, локально, тёплый Chromium-бинарь в <code>~/Library/Caches/ms-playwright</code>). Команда полного сноса кэшей:</p>\n<pre><code class=\"language-bash\">rm -rf .astro node_modules/.astro dist\ntime pnpm build\n</code></pre>\n<p>Три прогона на холодную, три на тёплую (медиана):</p>\n<table>\n<thead>\n<tr>\n<th>Тип</th>\n<th>Прогон 1</th>\n<th>Прогон 2</th>\n<th>Прогон 3</th>\n<th><strong>Медиана</strong></th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>Холодный (<code>rm -rf .astro node_modules/.astro dist</code>)</td>\n<td>11.580s</td>\n<td>11.860s</td>\n<td>11.486s</td>\n<td><strong>11.580s</strong></td>\n</tr>\n<tr>\n<td>Тёплый (без сноса)</td>\n<td>6.250s</td>\n<td>6.305s</td>\n<td>—</td>\n<td><strong>~6.28s</strong></td>\n</tr>\n</tbody>\n</table>\n<p>Из 11.6 секунд холодного билда:</p>\n<ul>\n<li>~5–6 секунд — реально SSG-стадия (Astro обходит роуты, рендерит 45 HTML-страниц на 14 RU-постов + 13 EN-twin’ов + индекс, теги, RSS, sitemap).</li>\n<li>~5 секунд — overhead Playwright: запуск Chromium, инициализация mermaid-bundle в DOM, прогрев JIT.</li>\n<li>~0.2 секунды — <code>pagefind --site dist/client</code> (поисковый индекс).</li>\n</ul>\n<p>На тёплом билде Playwright всё равно стартует заново (никакого долгоживущего process pool у <code>mermaid-isomorphic</code> нет), но:</p>\n<ul>\n<li><code>.astro/data-store.json</code> (5.2 MB) уже содержит распарсенный MDX content layer — Astro не парсит markdown повторно для тех файлов, у которых не изменился mtime.</li>\n<li><code>node_modules/.astro/</code> (5.1 MB) — Vite-кэш транспилированных модулей.</li>\n<li>Сам Playwright Chromium бинарь уже в <code>/Library/Caches/ms-playwright/chromium-1217/</code> (528 MB суммарно с headless-shell и ffmpeg) — на cold disk-cache его пришлось бы ещё прочитать, что добавляет ~1–2 секунды на медленных дисках.</li>\n</ul>\n<p>Ключевой факт: <strong>сам <code>mermaid-isomorphic</code> НЕ кэширует SVG между билдами</strong>. Я искал в его исходниках (<code>node_modules/.pnpm/mermaid-isomorphic@3.1.0_playwright@1.59.1/.../mermaid-isomorphic.js</code>) — там нет ни <code>persistDir</code>, ни file-based cache. Каждый build диаграммы рендерятся с нуля. «Тёплость» — это кэш Astro/Vite, а не плагина.</p>\n<blockquote>\n<p>CI-замер для GitHub Actions <code>ubuntu-latest</code> <code>(owner to fill: запустить workflow_dispatch на чистом раннере, замерить median из 3 прогонов с </code>actions/cache@v4<code> для node_modules + .astro)</code>.</p>\n</blockquote>\n<hr>\n<h2>4. Стоимость на CI</h2>\n<p>Playwright тащит Chromium (~528 MB на macOS у меня в кэше, аналогичный порядок на Linux), плюс на Debian/Ubuntu нужны system-deps: <code>libnss3</code>, <code>libatk-1.0-0</code>, <code>libcups2</code>, <code>libgbm1</code>, <code>libxkbcommon0</code>, <code>libpango-1.0-0</code>, <code>libasound2</code>, fontconfig + хотя бы один шрифт.</p>\n<p><strong>Митигации:</strong></p>\n<ol>\n<li><strong>Не ставить Chromium в production-image.</strong> Если вы строите Astro-сайт SSG-only и деплоите статику — Playwright нужен ТОЛЬКО на CI-step с билдом, не в рантайм-Docker’е. Используйте multi-stage:</li>\n</ol>\n<pre><code class=\"language-bash\"># build-stage:\nFROM node:24-bookworm AS build\nRUN pnpm install\nRUN pnpm exec playwright install --with-deps chromium\nRUN pnpm build\n\n# run-stage:\nFROM node:24-bookworm-slim AS run\nCOPY --from=build /app/dist ./dist\n# никакого playwright тут\n</code></pre>\n<ol start=\"2\">\n<li>\n<p><strong>GitHub Actions caching.</strong> <code>actions/cache@v4</code> ключ: <code>${{ hashFiles('pnpm-lock.yaml') }}-playwright</code>, путь: <code>~/.cache/ms-playwright</code>. Спасает от повторной выкачки Chromium (~150 MB сетью) на каждом push.</p>\n</li>\n<li>\n<p><strong>Использовать system Chrome вместо Playwright Chromium.</strong> Установить <code>PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1</code> и при создании браузера передавать <code>executablePath: '/usr/bin/google-chrome-stable'</code>. Но: <code>mermaid-isomorphic</code> не пробрасывает <code>launchOptions</code> через rehype-mermaid api — придётся форкать или жить с дефолтным Chromium.</p>\n</li>\n<li>\n<p><strong>Если 5 секунд cold-start критичны</strong> — гонять Playwright вне билда: pre-render все диаграммы в отдельном CI-step, коммитить SVG в репо, в основном билде использовать стратегию pre-mermaid с подменой на готовые ассеты. Сложнее, но снимает Playwright с горячего пути.</p>\n</li>\n</ol>\n<hr>\n<h2>5. Кэширование SVG: где они и что инвалидирует</h2>\n<p>Опубличный замер на dev-машине (45 скомпилированных HTML, 27 страниц с диаграммами, 61 data-uri суммарно — 32 RU + 29 EN, потому что одна EN-страница рендерится без диаграммы из-за специфики поста):</p>\n<table>\n<thead>\n<tr>\n<th>Метрика</th>\n<th>Значение</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>Mermaid-блоков в <code>*.md</code></td>\n<td>32 (в 14 постах)</td>\n</tr>\n<tr>\n<td>Скомпилированных HTML</td>\n<td>45</td>\n</tr>\n<tr>\n<td>Страниц с встроенной диаграммой</td>\n<td>27</td>\n</tr>\n<tr>\n<td>Data-URI блоков <code>&lt;img src=&quot;data:image/svg+xml,...&quot;&gt;</code></td>\n<td>61</td>\n</tr>\n<tr>\n<td>Минимум, байт</td>\n<td>15 551</td>\n</tr>\n<tr>\n<td>Медиана, байт</td>\n<td>25 301</td>\n</tr>\n<tr>\n<td>Среднее, байт</td>\n<td>26 579</td>\n</tr>\n<tr>\n<td>Максимум, байт</td>\n<td>45 711</td>\n</tr>\n<tr>\n<td>Размер <code>.astro/</code></td>\n<td>5.0 MB</td>\n</tr>\n<tr>\n<td>Размер <code>node_modules/.astro/</code></td>\n<td>5.1 MB</td>\n</tr>\n<tr>\n<td>Размер <code>dist/</code></td>\n<td>17 MB</td>\n</tr>\n<tr>\n<td>Размер Chromium-кэша Playwright</td>\n<td>528 MB</td>\n</tr>\n</tbody>\n</table>\n<p>Где живёт что:</p>\n<ul>\n<li><strong>SVG не лежат на диске как отдельные файлы.</strong> Стратегия <code>img-svg</code> инлайнит их прямо в HTML как <code>data:image/svg+xml,...</code> (URL-encoded). Это видно в <code>dist/client/blog/02-context-and-cache/index.html</code>: 4 диаграммы → 4 data-uri в одном HTML.</li>\n<li><strong>Astro content-layer кэш</strong> — <code>.astro/data-store.json</code> (5.2 MB после билда). Это распарсенный markdown с уже применёнными remark/rehype-плагинами — но <strong>до</strong> rehype-mermaid: проверка показывает, что инвалидация по mtime исходника гонит rehype-mermaid повторно даже для файлов, по которым ничего не поменялось.</li>\n<li><strong>Vite-кэш</strong> — <code>node_modules/.astro/</code> (5.1 MB). Транспилированные TS/JSX модули, не имеет отношения к mermaid-рендеру.</li>\n<li><strong>mermaid-isomorphic собственного кэша не имеет.</strong> Это ключевая ловушка: если вы поменяли запятую в одном <code>*.md</code> — rehype-mermaid пересоберёт ВСЕ диаграммы этого файла. Нет content-addressable кэша «hash diagram source → SVG».</li>\n</ul>\n<p>Если кэш rehype-mermaid вам критичен — обходной путь: написать тонкий rehype-плагин-обёртку, который хеширует исходник диаграммы (sha256 от текста между <code> ```mermaid</code> и <code>```</code>), смотрит в <code>.cache/mermaid/&lt;hash&gt;.svg</code> — и при попадании отдаёт его без вызова <code>mermaid-isomorphic</code>. На этом блоге пока не делал — 11.6 секунд cold-start не настолько больно.</p>\n<hr>\n<h2>6. Альтернативы: что я смотрел и почему не выбрал</h2>\n<h3>6.1. <code>@mermaid-js/mermaid-cli</code></h3>\n<p>Официальный CLI от mermaid-js: <code>mmdc -i diagram.mmd -o diagram.svg</code>. Под капотом — puppeteer (форк Chromium API) + полный Chromium-бинарь.</p>\n<p>Минусы для блог-пайплайна:</p>\n<ul>\n<li>Нет интеграции с rehype/remark — markdown-блоки придётся extract’ить руками.</li>\n<li>Каждый запуск — новый browser context (нет batch-режима).</li>\n<li>На 32 диаграммы — 32 отдельных запуска puppeteer ≈ десятки секунд против ~5–6 секунд у <code>mermaid-isomorphic</code> с одним browser-instance.</li>\n</ul>\n<p>Когда подойдёт: разовая конвертация <code>*.mmd → *.svg</code> в монорепо для дизайнеров, не для динамической вставки в HTML.</p>\n<h3>6.2. Client-side <code>mermaid</code> (npm-пакет)</h3>\n<p>Минусы выше уже разобраны: бандл, FOUC, hydration. Один плюс — динамические диаграммы из user input в рантайме (live preview в редакторе документации). Для статики блога — overkill.</p>\n<h3>6.3. <code>mermaid-isomorphic</code> напрямую (без rehype)</h3>\n<p>Тот же пакет, который дёргает rehype-mermaid под капотом. Можно использовать вне Astro: <code>import { createMermaidRenderer } from 'mermaid-isomorphic'; const renderer = createMermaidRenderer(); const [{ svg }] = await renderer([{ value: 'flowchart TD\\nA--&gt;B' }]);</code>.</p>\n<p>Когда подойдёт: своя пайплайн-сборка (Eleventy, MkDocs-плагин на Node.js), не использующая rehype-цепочку. У меня — Astro, поэтому rehype-mermaid даёт zero-boilerplate.</p>\n<h3>6.4. Pre-render через GitHub Actions matrix + commit обратно</h3>\n<p>Гипотетически: workflow на push, который рендерит SVG, коммитит в <code>public/diagrams/</code>, и в build-step используется стратегия <code>pre-mermaid</code> с заменой на <code>&lt;img src=&quot;/diagrams/&lt;hash&gt;.svg&quot;&gt;</code>. Снимает Playwright с горячего пути билда, но: усложняет PR-review (бинарные файлы в diff), требует отдельного workflow, ломает локальный dev <code>pnpm dev</code> если SVG ещё не закоммичен.</p>\n<p>Не делал — 5 секунд cold-start экономии не оправдывают.</p>\n<h3>Сводная таблица</h3>\n<table>\n<thead>\n<tr>\n<th>Вариант</th>\n<th>Cold-start</th>\n<th>Кэш SVG</th>\n<th>Bundle JS</th>\n<th>Сложность setup</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td><code>rehype-mermaid</code> + Playwright (текущий)</td>\n<td>~5–6s</td>\n<td>нет</td>\n<td>0</td>\n<td>низкая (1 plugin)</td>\n</tr>\n<tr>\n<td><code>mermaid-cli</code> (<code>mmdc</code>)</td>\n<td>~10s+</td>\n<td>нет</td>\n<td>0</td>\n<td>средняя</td>\n</tr>\n<tr>\n<td>Client-side <code>mermaid</code></td>\n<td>0</td>\n<td>браузерный кэш</td>\n<td>~250 KB</td>\n<td>низкая</td>\n</tr>\n<tr>\n<td>Pre-render + commit</td>\n<td>0 в билде, но ~5s в pre-step</td>\n<td>да, в git</td>\n<td>0</td>\n<td>высокая</td>\n</tr>\n</tbody>\n</table>\n<hr>\n<h2>7. Чек-лист «что замерить, прежде чем выбирать»</h2>\n<p>Прежде чем коммититься к билд-тайм-рендеру или к чему-то другому:</p>\n<ol>\n<li><strong>Сколько диаграмм в среднем.</strong> На 1–3 — client-side OK (ленивая загрузка mermaid через dynamic import). На 30+ — build-time дешевле для пользователя.</li>\n<li><strong>Частота правок.</strong> Если правите контент по 5 раз в день — cold-start 11 секунд × 50 пушей = ~10 минут CI-времени в день. Если раз в неделю — наплевать.</li>\n<li><strong>CI-платформа.</strong> Vercel hobby, Netlify free, Cloudflare Pages — у всех лимиты на build minutes. Playwright + Chromium на каждой PR-preview = быстро упрётесь. На self-hosted runner или Dokploy (как у меня) — без разницы.</li>\n<li><strong>Целевой размер JS-бандла.</strong> Если у проекта KPI «&lt;100 KB initial JS» — 250 KB mermaid client-side нарушит бюджет. Build-time SVG не трогает JS-бюджет.</li>\n<li><strong>Нужен ли интерактив.</strong> Pan/zoom/click-handlers в диаграмме? Тогда client-side обязателен. Статичная картинка для чтения? Build-time.</li>\n<li><strong>Где живёт ваша cold-start стоимость.</strong> Если рантайм-Docker — вырезайте Playwright из run-stage. Если CI — кэшируйте Chromium через <code>actions/cache</code>.</li>\n<li><strong>Готовы ли мириться с отсутствием SVG-кэша.</strong> rehype-mermaid рендерит ВСЕ блоки файла при любой правке. Если это больно — пишите свою кэширующую обёртку с sha256-ключом по исходнику диаграммы.</li>\n</ol>\n<hr>\n<h2>Итог</h2>\n<p>На этом блоге <code>rehype-mermaid</code> + Playwright стоит ~5 секунд cold-start, выдаёт 32 диаграммы в 27 HTML-страниц с медианным размером инлайн-SVG в 25 KB, не требует ни одного байта JS на клиенте, и позволяет писать диаграммы прямо в markdown. Это очень хороший трейдоф для статического блога.</p>\n<p>Когда не подойдёт: блог с сотней диаграмм, deploy-platform с лимитом на build minutes, или требование к интерактивным диаграммам. В первом случае — пишите кэширующую обёртку, во втором — pre-render в отдельный workflow, в третьем — client-side.</p>\n<p>Главная неочевидная вещь, которую стоит запомнить: <strong>Astro «прогревается» (5.2 MB content-store, Vite-кэш), но <code>mermaid-isomorphic</code> — нет</strong>. Cold-start Playwright платится при каждом билде заново. Это не баг, это by-design — и это причина, по которой мой полный билд занимает 11.6 секунд, а не 1.6.</p>\n"
    }
  ]
}