Журнал разработки Николая Ланца

27 июн. 2026 г.

Ха, не хило так споткнулись об загрузку больших файлов. То есть проблема не в том, как загрузить большой файл (это бы решили бы быстро), а в том, что как выяснилось, у нас не хендлилась сама ошибка при загрузке файла, превышающего разрешенный лимит. Загрузка просто висела бесконечно. В итоге победили, но сколько раз и как фейлил опус в процессе этого, понятнуло прям на отдельную хорошую статью. Опубликую ее чуть позже, так как хочется все-таки первым топиком вступительное слово опубликовать, а сейчас хотя бы разобрались и поправили.

Добавить ИИ агенту функции анализа проектной документации

26 июн. 2026 г.

Ну, такое. Невероятных спосбностей я не обнаружил.

Корпоративное хранилище всего 20 гигов.

Два архивчика по 150 метров закинули и уже ощутимо сожрало место. С учетом того, что у клиента есть архивы и по гигабайту и больше, на долго этих ресурсов не хватит.

А вот как он работу свою работает.

Чатгпт не хочет читать этот архив, ссылаясь на отсутствие программы. Хотя я не понимаю как он знает сколько там файлов, если прочитать не смог rar. Думаю, он просто не хочет работать. Коллега написал, что он перезалил в зип, а чатгпт буквально 10 файлов прочитал только и все. Про остальное сказал в привычной манере "Я продолжаю работать", но конечно же делать ничего не стал.

А еще вот я полторы минуты уже ждал ответа.

Зашел на https://status.openai.com/, а там такая картина:

Ну, как мне кажется, если нужна стабильность работы и время отклика критично, полагаться на такое нельзя.

Поресерчит создание своих приложений в чангпт-бизнес

26 июн. 2026 г.

Пилотная версия есть. Сейчас это докер-композ проект, который в себе несет mysql, php-fpm и nginx.

Но так же написаны скрипты, которые выполняют полностью установку с нуля, используя переменные окружения. Можно указать конкретную версию MODX-а, он будет скачан с исходников с гитхаба, будет выполнен патчинг конфигов и полная установка и запуск.

Это очень хорошая заготовка под то, чтобы патчить свои сценарии установки для быстрого старта.

Добавить функционал для запуска MODX-сайтов

23 июн. 2026 г.

Вот еще результаты.

Gemini3_1_Flash_Image. $0.07

BLACK_FOREST_LABS_FLUX_2_KLEIN_4B. $0.016

BLACK_FOREST_LABS_FLUX_2_PRO. $0.06

BLACK_FOREST_LABS_FLUX_2_MAX. $0.13

Изучить какие модели лучше генерируют макеты и инфографику

23 июн. 2026 г.

Как оказалось, проблема вот в этом параметре была:

modalities: ['image', 'text']

И тут я усматриваю очень паршивое поведение openrouter-api. В чем была проблема? Некоторые модели действительно поддерживают и image и text, а какие-то только image или только text. И у openrouter есть метод для полечения моделей с учетом конкретной модалити.

curl "https://openrouter.ai/api/v1/models?output_modalities=image"

Но, прикол в том, что у них нет метода для получения моделей, которые поддерживают и то и другое. То есть мы не можем одним запросом выяснить поддерживает ли модель все необходимые нам модалити. Более того, мы не все модели можем увидеть в запросе sh curl "https://openrouter.ai/api/v1/models" без указания модалити. К примеру, вот мы видим флюкс

curl "https://openrouter.ai/api/v1/models?output_modalities=image" |jq|grep black -A 15
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100 39411    0 39411    0     0   172k      0 --:--:-- --:--:-- --:--:--  171k
      "id": "black-forest-labs/flux.2-klein-4b",
      "canonical_slug": "black-forest-labs/flux.2-klein-4b",
      "hugging_face_id": "black-forest-labs/FLUX.2-klein-4B",
      "name": "Black Forest Labs: FLUX.2 Klein 4B",
      "created": 1768429228,
      "description": "FLUX.2 [klein] 4B is the fastest and most cost-effective model in the FLUX.2 family, optimized for high-throughput use cases while maintaining excellent image quality. Pricing is based on the output...",
      "context_length": 40960,
      "architecture": {
        "modality": "text+image->image",
        "input_modalities": [
          "text",
          "image"
        ],
        "output_modalities": [
          "image"
        ],
        "tokenizer": "Other",
        "instruct_type": null
--
        "details": "/api/v1/models/black-forest-labs/flux.2-klein-4b/endpoints"
      }
    },

Но мы не увидим ее в общем списке моделей.

curl "https://openrouter.ai/api/v1/models" |jq|grep black -A 15
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100  488k    0  488k    0     0  1496k      0 --:--:-- --:--:-- --:--:-- 1498k

Но и это еще не все :-) Если в запросе указать только modalities: ['image'], то модели типа gemini-2.5/3 ошибку возвращают "No endpoints found that support the requested output modalities: image", хотя в самой модели указано

 "architecture": {
    "modality": "text+image->text+image",
    "input_modalities": [
      "image",
      "text"
    ],
    "output_modalities": [
      "image",
      "text"
    ],
    "tokenizer": "Gemini",
    "instruct_type": null
  }

То есть берется не одно из возможных, а именно все должны совпадать. Это прям странно для меня. Приходится вообще не указывать модалити.

Разобраться почему не все image-модели в openrouter доступны

23 июн. 2026 г.

Вот первые результаты.

Gemini3_1_Flash_Image. Стоимость $0.104

BYTEDANCE_SEED_SEEDREAM_4_5. $0.116

GOOGLE_GEMINI_3_PRO_IMAGE_PREVIEW. $0.145

Gemini2_5_Flash_Image. $0.039

OPENAI_GPT_5_4_IMAGE_2. $0.341

Изучить какие модели лучше генерируют макеты и инфографику

23 июн. 2026 г.

Вот такой вот у меня получился черновой интерфейс с множественным выбором моделей

А теперь попробуем все этим модели с одним промптом запустить:

Современный сайт инженера-исследователя.

Это не блог и не портфолио.

Это публичный журнал инженерной деятельности, в котором отображаются текущие исследования, проекты, задачи, рабочие заметки и ход разработки в режиме реального времени.

Главный герой сайта — Николай Ланец, инженер-исследователь в области искусственного интеллекта, агентных систем, архитектур памяти и графов знаний.

Стиль:

* светлая тема (но не обязательно белая)
* премиальный технологический дизайн
* эстетика исследовательской лаборатории
* сочетание GitHub, Linear, Notion, Stripe и современных AI-платформ
* минимализм без пустого пространства (но без сильной концентрации элементов)
* средняя плотность информации
* качественная типографика
* аккуратные карточки
* строгая визуальная иерархия
* ощущение работающей инженерной системы

Главный экран:

Крупное имя:
Николай Ланец

Подзаголовок:
инженер-исследователь

Описание:
Публичный журнал проектов, задач, исследований и инженерных решений.

Отдельный блок:
доступен для новых задач

Рядом показать основные направления исследований:

* Agent Systems
* AI Research
* Memory Architectures
* Knowledge Graphs

Ниже разместить несколько секций в виде современной панели управления исследовательской деятельностью.

Секция «Активные проекты»:
карточки проектов со статусами, датами начала и кратким описанием.

Секция «Активные задачи»:
исследовательские и инженерные задачи, отображённые как рабочий бэклог.

Секция «Последние ворклоги»:
лента последних инженерных действий и принятых решений.

Секция загрузки:
количество коммерческих задач, количество личных исследований и текущие обязательства.

Визуальное впечатление:

Не персональный сайт.
Не блог.
Не портфолио.

Это операционная система исследователя, открытая для публичного просмотра.

Очень высокий уровень UI/UX.
Качество как у лучших SaaS-продуктов 2026 года.
Макет интерфейса из Figma.
Полноценный desktop web application.

Еще раз уточню, что это чатгпт его составил на основании текста с главной страницы моего сайта, я только чуть-чуть подправил. Интересно, какие модели какой дизайн и за сколько сделают.

Изучить какие модели лучше генерируют макеты и инфографику

23 июн. 2026 г.

Добавил в haih-agent вот такой интерфейс для генерации картинок с возможностью выбора модели, качества и пропорций

Он заточен под openrouter, поэтому дает доступ сразу к многим моделям.

Я в чатгпт скормил текст с главной страницы своего сайта и попросил написать промпт для генератора картинок, чтобы можно было поиграться и посмотреть что из этого получится лучше. Но так как моделей несколько и не хочется ждать кажду в отдельности, чтобы потом сравнивать результат, немного допишу интерфейс, чтобы можно было за раз выбрать сразу несколько моделей и отправить параллельно несколько запросов и посмотреть потом все результаты.

Изучить какие модели лучше генерируют макеты и инфографику

23 июн. 2026 г.

Судя по всему найти подходящую модель локальную для этого крайне сложно. На сколько я понимаю, обычно приходится специально под конечный задачи готовить модели, LoRa доучивать и т.п. Так же сильно упрощает процесс наличие готовых референсов. Но мне сейчас хочется именно по описанию сайта и его функционала получать макеты, потому что в том же lovable это довольно долго делается.

Изучить какие модели лучше генерируют макеты и инфографику

22 июн. 2026 г.

Переделал функцию получения тайпскрипт-сервера. Теперь программа имеет свое время жизни (таймер с момента последнего обращения), чтобы она не висела бесконечно, если к ней нет обращений. И теперь первый запуск на холодную занимает какое-то время в зависимости от проекта, но потом каждый запрос отрабатывается почти мгновенно, при чем в разных запросах. То есть можно сначала запросить список файлов (это один резолвер), а потом анализ какого-то файла (это уже другой резолвер), и анализ прилетит практически мгновенно (как и другие последующие запросы).

import path from 'path'
import ts from 'typescript'

export interface ProgramContext {
  program: ts.Program
  checker: ts.TypeChecker
  /** Dir base directory (the folder that contains tsconfig.json). */
  baseDir: string
}

export interface GetProgramOptions {
  /** Absolute path to project root directory */
  projectRoot: string
  /** Path to tsconfig.json (relative to projectRoot or absolute). Default: 'tsconfig.json' */
  tsconfigPath?: string
}

interface CachedProject {
  program: ts.Program
  /** File versions for incremental rebuild detection */
  fileVersions: Map<string, number>
  /** Timestamp of last access */
  lastAccess: number
  /** Timer for TTL expiration */
  expirationTimer: ReturnType<typeof setTimeout>
}

/** TTL in milliseconds (30 minutes) */
const CACHE_TTL_MS = 30 * 60 * 1000

/** Cache of programs per project root */
const projectCache = new Map<string, CachedProject>()

/**
 * Get file modification time as version number.
 */
function getFileVersion(filePath: string): number {
  try {
    const stat = ts.sys.getModifiedTime?.(filePath)
    return stat ? stat.getTime() : 0
  } catch {
    return 0
  }
}

/**
 * Check if any source files have changed since last build.
 */
function hasChanges(cached: CachedProject): boolean {
  for (const [filePath, version] of cached.fileVersions) {
    if (getFileVersion(filePath) !== version) {
      return true
    }
  }
  return false
}

/**
 * Build or retrieve a cached TypeScript Program for the given project.
 * Uses incremental compilation — only rebuilds when source files change.
 */
export function getProgram(options: GetProgramOptions): ProgramContext {
  const { projectRoot, tsconfigPath = 'tsconfig.json' } = options

  const baseDir = path.resolve(projectRoot)
  const configPath = path.isAbsolute(tsconfigPath)
    ? tsconfigPath
    : path.join(baseDir, tsconfigPath)

  if (!ts.sys.fileExists(configPath)) {
    throw new Error(`tsconfig.json not found at ${configPath}`)
  }

  const cacheKey = configPath

  const cached = projectCache.get(cacheKey)
  if (cached && !hasChanges(cached)) {
    resetExpirationTimer(cacheKey, cached)
    return {
      program: cached.program,
      checker: cached.program.getTypeChecker(),
      baseDir,
    }
  }

  const configFile = ts.readConfigFile(configPath, ts.sys.readFile)

  const parsed = ts.parseJsonConfigFileContent(
    configFile.config,
    ts.sys,
    baseDir,
  )

  const program = ts.createProgram({
    rootNames: parsed.fileNames,
    options: parsed.options,
    oldProgram: cached?.program,
  })

  const fileVersions = new Map<string, number>()
  for (const sourceFile of program.getSourceFiles()) {
    fileVersions.set(sourceFile.fileName, getFileVersion(sourceFile.fileName))
  }

  if (cached) {
    clearTimeout(cached.expirationTimer)
  }

  const newCached: CachedProject = {
    program,
    fileVersions,
    lastAccess: Date.now(),
    expirationTimer: createExpirationTimer(cacheKey),
  }
  projectCache.set(cacheKey, newCached)

  return {
    program,
    checker: program.getTypeChecker(),
    baseDir,
  }
}

/**
 * Reset expiration timer on cache access.
 */
function resetExpirationTimer(cacheKey: string, cached: CachedProject): void {
  clearTimeout(cached.expirationTimer)
  cached.lastAccess = Date.now()
  cached.expirationTimer = createExpirationTimer(cacheKey)
}

/**
 * Create a timer that removes the cache entry after TTL.
 */
function createExpirationTimer(cacheKey: string): ReturnType<typeof setTimeout> {
  return setTimeout(() => {
    projectCache.delete(cacheKey)
  }, CACHE_TTL_MS)
}

/**
 * Clear cached program for a specific project.
 */
export function clearProgramCache(configPath: string): void {
  const key = path.resolve(configPath)
  const cached = projectCache.get(key)
  if (cached) {
    clearTimeout(cached.expirationTimer)
    projectCache.delete(key)
  }
}

/**
 * Clear all cached programs.
 */
export function clearAllProgramCaches(): void {
  for (const cached of projectCache.values()) {
    clearTimeout(cached.expirationTimer)
  }
  projectCache.clear()
}

Добавить функции анализа кода

22 июн. 2026 г.

Получилось вытащить старые наработки, вот минимальный набор полезных методов.

query analyzeDir {
  analyzeDir(rootDir: "src/components/pages/_App/")
}

{
  "data": {
    "analyzeDir": {
      "rootDir": "src/components/pages/_App",
      "count": 3,
      "files": [
        {
          "path": "src/components/pages/_App/getInitialProps.ts"
        },
        {
          "path": "src/components/pages/_App/index.tsx"
        },
        {
          "path": "src/components/pages/_App/interfaces.ts"
        }
      ]
    }
  }
}

query analyzeFile {
  analyzeFile(path: "src/components/pages/_App/getInitialProps.ts") {
    path
    imports {
      default
      isExternalLibraryImport
      module
      namespace
      resolvedPath
      named {
        name
        alias
      }
    }
    exports
  }
}

{
  "data": {
    "analyzeFile": {
      "path": "src/components/pages/_App/getInitialProps.ts",
      "imports": [
        {
          "default": null,
          "isExternalLibraryImport": false,
          "module": "src/gql/apolloClient",
          "namespace": null,
          "resolvedPath": "src/gql/apolloClient/index.ts",
          "named": [
            {
              "name": "initializeApollo",
              "alias": null
            }
          ]
        },
        {
          "default": "NextApp",
          "isExternalLibraryImport": true,
          "module": "next/app",
          "namespace": null,
          "resolvedPath": null,
          "named": []
        },
        {
          "default": null,
          "isExternalLibraryImport": false,
          "module": "./interfaces",
          "namespace": null,
          "resolvedPath": "src/components/pages/_App/interfaces.ts",
          "named": [
            {
              "name": "AppInitialProps",
              "alias": null
            },
            {
              "name": "MainApp",
              "alias": null
            },
            {
              "name": "NextPageContextCustom",
              "alias": null
            },
            {
              "name": "PageProps",
              "alias": null
            },
            {
              "name": "withWs",
              "alias": null
            }
          ]
        },
        {
          "default": null,
          "isExternalLibraryImport": false,
          "module": "src/helpers/getSiteOrigin",
          "namespace": null,
          "resolvedPath": "src/helpers/getSiteOrigin.ts",
          "named": [
            {
              "name": "getSiteOrigin",
              "alias": null
            }
          ]
        }
      ],
      "exports": [
        {
          "name": "getInitialProps",
          "kind": "Function",
          "line": 13
        }
      ]
    }
  }
}

Но сейчас проблема в том, что тайпскрипт-сервер у нас запускается при каждом запросе.

import path from 'path'
import ts from 'typescript'

export interface ProgramContext {
  program: ts.Program
  checker: ts.TypeChecker
  /** Dir base directory (the folder that contains tsconfig.json). */
  baseDir: string
}

/**
 * Build a TypeScript Program from the project's tsconfig.json.
 * Rebuilt per call — TS's incremental cache is internal to a single Program,
 * and the host process may pick up file changes between requests.
 */
export function getProgram(): ProgramContext {
  const configPath = ts.findConfigFile('./', ts.sys.fileExists, 'tsconfig.json')

  if (!configPath) {
    throw new Error('tsconfig.json not found')
  }

  const baseDir = path.resolve(path.dirname(configPath))
  const configFile = ts.readConfigFile(configPath, ts.sys.readFile)

  const parsed = ts.parseJsonConfigFileContent(
    configFile.config,
    ts.sys,
    baseDir,
  )

  /*
  Вот тут мы создаем тс-сервер и это довольно нагруженная операция,
  она не только жрет много процессора и памяти, но и дает ощутимые задержки
  */
  const program = ts.createProgram({
    rootNames: parsed.fileNames,
    options: parsed.options,
  })

  return {
    program,
    checker: program.getTypeChecker(),
    baseDir,
  }
}

Нам надо уметь держать более долгие инстансы тс-серверов, чтобы не запускать каждый раз с нуля. И тут есть несколько вариантов (по версии opus 4.5):

1. ts.createIncrementalProgram / ts.createWatchProgram

Встроенный механизм TypeScript для инкрементальной компиляции:

const host = ts.createWatchCompilerHost(configPath, {}, ts.sys);
const watchProgram = ts.createWatchProgram(host);
const program = watchProgram.getProgram().getProgram();

Кэширует AST и перекомпилирует только изменённые файлы.

2. ts.server.ProjectService (Language Service)

Это то, что использует VS Code. Полноценный language server с кэшированием:

import * as ts from 'typescript/lib/tsserverlibrary'
const projectService = new ts.server.ProjectService({ ... })

Тяжелее, но даёт доступ к уже запущенному tsserver.

3. Подключение к существующему tsserver через LSP

Если tsserver уже запущен (например, в IDE), можно общаться с ним через stdio или node-ipc. Но это требует знать, где он слушает.

4. Простой кэш на уровне приложения

Хранить program в памяти и пересоздавать только при изменении файлов:

let cachedProgram: ts.Program | null = null;
let lastModified: number = 0;
export function getProgram(baseDir: string) {
  const currentModified = getMaxMtime(baseDir);
  if (cachedProgram && currentModified <= lastModified) {
    return {
      program: cachedProgram,
      checker: cachedProgram.getTypeChecker(),
      baseDir,
    };
  }
}


Рекомендация: Если нужен быстрый результат — вариант 4 (простой кэш). Если нужна полноценная интеграция с IDE — вариант 2 или 3, но это значительно сложнее.

Добавить функции анализа кода

22 июн. 2026 г.

Сделано.

Хелперы:

Скачивание образа

import Docker from 'dockerode'
import { DockerImage } from '../DockerImage/types'

export async function pullDockerImage(
  docker: Docker,
  image: string,
): Promise<DockerImage> {
  return new Promise((resolve, reject) => {
    docker.pull(image, (err: Error | null, stream: NodeJS.ReadableStream) => {
      if (err) {
        reject(err)
        return
      }

      docker.modem.followProgress(
        stream,
        async (err: Error | null) => {
          if (err) {
            reject(err)
            return
          }

          try {
            const imageInfo = docker.getImage(image)
            const inspectData = await imageInfo.inspect()

            resolve({
              id: inspectData.Id,
              repoTags: inspectData.RepoTags,
              repoDigests: inspectData.RepoDigests,
              created: new Date(inspectData.Created),
              size: inspectData.Size,
              virtualSize: inspectData.VirtualSize,
              labels: inspectData.Config?.Labels ?? null,
            })
          } catch (inspectErr) {
            reject(inspectErr)
          }
        },
        (event: { status: string }) => {
          if (process.env.NODE_ENV === 'development') {
            // eslint-disable-next-line no-console
            console.log('pullDockerImage progress:', event.status)
          }
        },
      )
    })
  })
}

Проверка наличия образа и скачивание

import Docker from 'dockerode'
import { pullDockerImage } from './pullDockerImage'

export async function ensureDockerImage(
  docker: Docker,
  image: string,
): Promise<void> {
  try {
    const imageInfo = docker.getImage(image)
    await imageInfo.inspect()
  } catch (err) {
    if ((err as { statusCode?: number }).statusCode === 404) {
      await pullDockerImage(docker, image)
    } else {
      throw err
    }
  }
}

Обновленный резолвер создания контейнера с проверкой и скачиванием образа

import { Container } from 'dockerode'
import { builder } from 'server/schema/builder'
import { ensureDockerImage } from '../../helpers/ensureDockerImage'
import { DockerContainer } from '../types'

export const createDockerContainerResolver = builder.mutationField(
  'createDockerContainer',
  (t) =>
    t.field({
      type: DockerContainer,
      nullable: true,
      args: {
        image: t.arg.string({ required: true }),
        labels: t.arg({ type: 'Json' }),
        cmd: t.arg.stringList(),
      },
      async resolve(_, { image, labels, cmd }, ctx) {
        const { dockerClient } = ctx
        const docker = dockerClient.getDockerClient()

        await ensureDockerImage(docker, image)

        let auxContainer: Container | undefined

        return docker
          .createContainer({
            Image: image,
            AttachStdin: false,
            AttachStdout: true,
            AttachStderr: true,
            Tty: true,
            Cmd: cmd ?? undefined,
            OpenStdin: false,
            StdinOnce: false,
            Labels:
              typeof labels === 'object'
                ? (labels as Record<string, string>)
                : undefined,
          })
          .then(async function (container) {
            auxContainer = container

            return auxContainer.start()
          })
          .then(function () {
            return auxContainer?.inspect()
          })
          .then(function (containerInfo): DockerContainer | null {
            return containerInfo
              ? {
                  id: containerInfo.Id,
                  names: [containerInfo.Name],
                  command: containerInfo.Config.Cmd,
                  created: new Date(containerInfo.Created),
                  image: containerInfo.Config.Image,
                  imageID: containerInfo.Image,
                  state: containerInfo.State.Status,
                  status: containerInfo.State.Status,
                  labels: containerInfo.Config.Labels,
                }
              : null
          })
      },
    }),
)

Добавить функцию скачивания докер-образов

22 июн. 2026 г.

modx-ai-studio--agent удалось найти в слоях докера, пока закинул в бэкапы. Не уверен, что буду делать систему для создания сайтов на MODX, но вполне вероятно появятся функции миграции с MODX-сайтов на мой движок, и там некоторые наработки могут понадобиться.

А вот основная вся нужная мне логика находится в narasim.dev/platform/agent, их тоже получилось восстановить. Там как минимум нужные мне методы работы с докер-контейнерами, а так же скилы для анализа кодовой базы.

query dockerContainers {
  dockerContainers(
    # labels: { test: "SDfsdf" }
    image: "node:22-alpine"
    all: true
  ) {
    ...DockerContainer_
  }
}

mutation createNarasimProjectDir {
  createNarasimProjectDir(dirName: "test")
}

mutation createContainer {
  createContainer(
    image: "node:22-alpine"
    cmd: ["tail", "-f", "/dev/null"]
    labels: { test: "SDfsdf" }
  ) {
    ...DockerContainer_
  }
}

mutation removeContainer {
  removeContainer(
    id: "e303391639a8d8599d041c179bb66f17132163a9a26a13161c5977b95d821888"
    force: true
  )
}

mutation stopContainer {
  stopContainer(
    id: "ffbd591958371fce069e884d60167eff7ec39b1222e6361b8172e79a729447c8"
  ) {
    ...DockerContainer_
  }
}

fragment DockerContainer_ on DockerContainer {
  id
  names
  command
  image
  imageID
  state
  status
  labels
  # created
}

Развернуть заготовку

22 июн. 2026 г.

Кроме этого еще и ошибка с путями к картинкам была - относительные пути были, а нужны абсолютные. Поправил, отправил на проверку.

Проработать микроразметку

21 июн. 2026 г.

Проблема: Google требует обязательные поля для Product в структурированных данных

Суть ошибки

Google Search Console выдаёт ошибку:

"Задайте значение для одного из следующих элементов данных: offers, review или aggregateRating"

Согласно документации Google, для разметки Product обязательно наличие хотя бы одного из:

  • offers — информация о цене и наличии
  • review — отзыв о товаре
  • aggregateRating — средний рейтинг

Без этих данных страница не будет показываться в расширенных результатах поиска (rich snippets).

Типичный кейс

Интернет-магазин с товарами "цена по запросу" или "под заказ". У таких товаров нет фиксированной цены, поэтому разработчик не передаёт offers — и получает ошибку от Google.

Решения

1. Использовать offers без конкретной цены

Schema.org позволяет указать priceSpecification без точной цены:

json{
  "@type": "Product",
  "name": "Детская площадка Premium",
  "offers": {
    "@type": "Offer",
    "availability": "https://schema.org/InStock",
    "priceSpecification": {
      "@type": "PriceSpecification",
      "priceCurrency": "RUB"
    }
  }
}

2. Добавить aggregateRating или review

Если есть система отзывов — использовать её:

json{
  "@type": "Product",
  "name": "Детская площадка Premium",
  "aggregateRating": {
    "@type": "AggregateRating",
    "ratingValue": "4.5",
    "reviewCount": "12"
  }
}

3. Не использовать разметку Product для товаров без цены

Если товар не имеет цены и отзывов — можно использовать WebPage вместо Product. Это не даст rich snippets, но и не будет ошибок.

Рекомендация

Для товаров "цена по запросу" лучше всего передавать offers с availability, но без price. Google примет такую разметку, а пользователи увидят информацию о наличии товара.

Проработать микроразметку

Написать мне