BASH
#json-ld#structured-data#schema-org#seo#cli#jq#nodejs#ajv

Валидация JSON-LD через CLI: jq + jsonld expansion + schema.org

Быстрая CLI-проверка JSON-LD: синтаксис JSON, корректность JSON-LD (expand/normalize) и базовая проверка schema.org. Подходит для CI и ручной диагностики.

Как использовать

  1. Сначала проверь JSON (jq).
  2. Потом проверь JSON-LD (expand/normalize через jsonld).
  3. Если нужно — прогони через schema.org валидатор (см. sd-validate).

JSON-LD можно валидировать на 3 уровнях:

  1. валидный JSON, 2) валидный JSON-LD (контекст/expand), 3) валидность по schema.org (типы/свойства).

1) Проверка: это вообще JSON (jq)

jq -e . < schema.jsonld >/dev/null && echo "OK: valid JSON" || echo "FAIL: invalid JSON"

Если JSON кривой — дальше смысла нет.

2) Проверка JSON-LD (expand/normalize) через Node CLI

Установка:

npm i -g jsonld

Скрипт sd-validate.mjs:

#!/usr/bin/env node
import fs from "node:fs/promises";
import path from "node:path";
import { createHash } from "node:crypto";
import process from "node:process";

import chalk from "chalk";
import yargs from "yargs/yargs";
import { hideBin } from "yargs/helpers";

import SDV from "@adobe/structured-data-validator";
import WebAutoExtractor from "@marbec/web-auto-extractor";

// ESM/CJS-safe import for different package builds
const Validator = SDV?.Validator ?? SDV?.default ?? SDV;
if (!Validator) throw new Error("Cannot resolve Validator export from @adobe/structured-data-validator");

const argv = yargs(hideBin(process.argv))
  .scriptName("sd-validate")
  .usage("$0 [options] <url|file.html>")
  .option("out", {
    type: "string",
    default: "",
    describe: "Путь для отчёта (json). По умолчанию: ./sd-report-<hash>.json",
  })
  .option("onlyErrors", {
    type: "boolean",
    default: false,
    describe: "Показывать только ERROR (WARNING скрыть)",
  })
  .option("microdataOnly", {
    type: "boolean",
    default: false,
    describe: "Фильтр сообщений по microdata/itemprop/itemscope/itemtype",
  })
  .option("timeout", {
    type: "number",
    default: 20000,
    describe: "Таймаут загрузки URL (мс)",
  })
  .option("userAgent", {
    type: "string",
    default:
      "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) sd-validate/1.0",
    describe: "User-Agent для скачивания HTML по URL",
  })
  .option("quiet", {
    type: "boolean",
    default: false,
    describe: "Тихий режим: выводить summary + ERROR (warnings не печатать)",
  })
  .option("max", {
    type: "number",
    default: 200,
    describe: "Максимум сообщений в Details (после фильтров). 0 = без лимита",
  })
  .option("dump", {
    type: "string",
    default: "none",
    choices: ["none", "jsonld", "extracted", "all"],
    describe:
      "Сохранить артефакты: jsonld (только JSON-LD), extracted (всё, что извлёк парсер), all (оба)",
  })
  .option("format", {
    type: "string",
    default: "text",
    choices: ["text", "json"],
    describe: "Формат вывода в консоль",
  })
  .demandCommand(1)
  .help()
  .parseSync();

const input = String(argv._[0]);

function isUrl(s) {
  return /^https?:\/\//i.test(s);
}

function sha1(s) {
  return createHash("sha1").update(s).digest("hex").slice(0, 10);
}

async function fetchWithTimeout(url, { timeout, headers }) {
  const ac = new AbortController();
  const t = setTimeout(() => ac.abort(), timeout);

  try {
    const res = await fetch(url, { redirect: "follow", signal: ac.signal, headers });
    const text = await res.text();
    return { ok: res.ok, status: res.status, text };
  } finally {
    clearTimeout(t);
  }
}

function formatIssue(i) {
  const sev =
    i.severity === "ERROR"
      ? chalk.bgRed.white(" ERROR ")
      : chalk.bgYellow.black(" WARN  ");

  const msg = i.issueMessage ?? "Unknown issue";
  const fields = Array.isArray(i.fieldNames) && i.fieldNames.length
    ? `fields: ${i.fieldNames.join(", ")}`
    : "";

  const loc = i.location ? `loc: ${i.location}` : "";
  const pathStr = Array.isArray(i.path) && i.path.length
    ? `path: ${JSON.stringify(i.path)}`
    : "";

  const meta = [fields, loc, pathStr].filter(Boolean).join(" | ");
  return `${sev} ${msg}${meta ? chalk.dim(`  (${meta})`) : ""}`;
}

function hr() {
  console.log(chalk.dim("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"));
}

function extractJsonLdBlocksFromHtml(html) {
  // Вытаскиваем *все* <script type="application/ld+json">...</script>
  const re = /<script[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;
  const blocks = [];
  let m;

  while ((m = re.exec(html))) {
    const raw = (m[1] || "").trim();
    if (!raw) continue;

    // JSON-LD может быть объектом ИЛИ массивом
    try {
      blocks.push(JSON.parse(raw));
    } catch (e) {
      blocks.push({
        __parse_error__: e.message,
        __raw_preview__: raw.slice(0, 500),
      });
    }
  }

  // чтобы отчёт был удобным: если один блок — объект, иначе массив
  return blocks.length === 1 ? blocks[0] : blocks;
}

function limitArray(arr, max) {
  if (!max || max <= 0) return arr;
  return arr.slice(0, max);
}

async function main() {
  const runId = sha1(input);
  const defaultReportPath = path.resolve(process.cwd(), `sd-report-${runId}.json`);
  const reportPath = argv.out && argv.out.trim() ? path.resolve(argv.out) : defaultReportPath;
  const reportDir = path.dirname(reportPath);

  // 1) Load HTML
  let html = "";
  const sourceMeta = { type: isUrl(input) ? "url" : "file", input };

  if (isUrl(input)) {
    const { ok, status, text } = await fetchWithTimeout(input, {
      timeout: argv.timeout,
      headers: { "user-agent": argv.userAgent },
    });
    html = text;
    sourceMeta.http = { status, ok };

    if (!html || html.trim().length < 50) {
      throw new Error(`Похоже, вернулся пустой/короткий HTML. HTTP=${status}`);
    }
  } else {
    html = await fs.readFile(input, "utf8");
    sourceMeta.file = { abs: path.resolve(input) };
  }

  // 2) Quick microdata scan (raw)
  const microdataCount = (html.match(/\bitemscope\b/gi) || []).length;
  const itemtypeCount = (html.match(/\bitemtype\s*=\s*["'][^"']+["']/gi) || []).length;
  const itempropCount = (html.match(/\bitemprop\s*=\s*["'][^"']+["']/gi) || []).length;

  // 3) Extract structured data (JSON-LD + microdata + RDFa)
  const extractor = new WebAutoExtractor({
    addLocation: true,
    embedSource: ["rdfa", "microdata"],
  });
  const extracted = extractor.parse(html);

  // 4) JSON-LD dump (from raw HTML, не через extractor)
  const jsonld = extractJsonLdBlocksFromHtml(html);

  // 5) Load schema.org model
  const schemaOrgJson = await (
    await fetch("https://schema.org/version/latest/schemaorg-all-https.jsonld")
  ).json();

  // 6) Validate
  const validator = new Validator(schemaOrgJson);
  const issuesAll = await validator.validate(extracted);

  let issues = Array.isArray(issuesAll) ? issuesAll : [];

  if (argv.onlyErrors) issues = issues.filter((x) => x.severity === "ERROR");

  if (argv.microdataOnly) {
    issues = issues.filter((x) => {
      const s = JSON.stringify(x).toLowerCase();
      return (
        s.includes("microdata") ||
        s.includes("itemprop") ||
        s.includes("itemscope") ||
        s.includes("itemtype")
      );
    });
  }

  const errorsCountAll = issues.filter((x) => x.severity === "ERROR").length;
  const warnsCountAll = issues.filter((x) => x.severity !== "ERROR").length;

  // console filtering for "quiet"
  let issuesToPrint = issues;
  if (argv.quiet) issuesToPrint = issues.filter((x) => x.severity === "ERROR");
  issuesToPrint = limitArray(issuesToPrint, argv.max);

  // 7) Save report (+ dumps)
  await fs.mkdir(reportDir, { recursive: true });

  const report = {
    version: "1.1",
    ts: new Date().toISOString(),
    input: sourceMeta,
    stats: { issues: issues.length, errors: errorsCountAll, warnings: warnsCountAll },
    htmlScan: { itemscope: microdataCount, itemtype: itemtypeCount, itemprop: itempropCount },
    dump: {
      jsonld: argv.dump === "jsonld" || argv.dump === "all" ? `sd-jsonld-${runId}.json` : null,
      extracted: argv.dump === "extracted" || argv.dump === "all" ? `sd-extracted-${runId}.json` : null,
    },
    issues,
  };

  await fs.writeFile(reportPath, JSON.stringify(report, null, 2), "utf8");

  if (argv.dump === "jsonld" || argv.dump === "all") {
    const p = path.join(reportDir, `sd-jsonld-${runId}.json`);
    await fs.writeFile(p, JSON.stringify(jsonld, null, 2), "utf8");
  }

  if (argv.dump === "extracted" || argv.dump === "all") {
    const p = path.join(reportDir, `sd-extracted-${runId}.json`);
    await fs.writeFile(p, JSON.stringify(extracted, null, 2), "utf8");
  }

  // 8) Output
  if (argv.format === "json") {
    const out = {
      input,
      report: reportPath,
      stats: report.stats,
      printed: { quiet: argv.quiet, max: argv.max, count: issuesToPrint.length },
    };
    console.log(JSON.stringify(out, null, 2));
  } else {
    console.log(chalk.cyan("🔍 Structured Data validation"));
    hr();
    console.log(`Input: ${chalk.bold(input)}`);

    console.log("");
    console.log(chalk.green("📦 Found in HTML (raw scan):"));
    console.log(`  • itemscope: ${microdataCount}`);
    console.log(`  • itemtype:  ${itemtypeCount}`);
    console.log(`  • itemprop:  ${itempropCount}`);

    console.log("");
    hr();
    console.log(`✅ Issues total: ${chalk.bold(String(issues.length))}`);
    console.log(`❌ Errors:      ${errorsCountAll ? chalk.red(errorsCountAll) : chalk.green("0")}`);
    console.log(`⚠️  Warnings:   ${warnsCountAll ? chalk.yellow(warnsCountAll) : chalk.green("0")}`);
    hr();

    if (issuesToPrint.length) {
      const mode = argv.quiet ? "ERROR only" : "all (after filters)";
      const lim = argv.max && argv.max > 0 ? ` (limited to ${argv.max})` : "";
      console.log(chalk.cyan(`📋 Details: ${mode}${lim}`));
      for (const i of issuesToPrint) console.log("  " + formatIssue(i));

      const hidden = issues.length - issuesToPrint.length;
      if (hidden > 0) {
        console.log("");
        console.log(
          chalk.dim(`… скрыто ${hidden} сообщений. Используй --max 0 или убери --quiet.`)
        );
      }
    } else {
      console.log(chalk.green("🎉 No issues found (for this validator output mode)."));
    }

    console.log("");
    console.log(chalk.gray(`💾 Report saved: ${reportPath}`));
    if (argv.dump !== "none") {
      console.log(
        chalk.gray(
          `📦 Dumps: ${report.dump.jsonld || "-"} | ${report.dump.extracted || "-"} (dir: ${reportDir})`
        )
      );
    }
  }

  // Exit codes for CI
  if (errorsCountAll > 0) process.exit(1);
  process.exit(0);
}

main().catch((e) => {
  console.error(chalk.bgRed.white(" FAIL "), chalk.red(e?.message || String(e)));
  process.exit(2);
});

Запуск

chmod +x sd-validate.mjs
node sd-validate.mjs "https://example.tld/page"

3) Проверка schema.org (типы/свойства)

Практичный вариант — валидировать schema.org через sd-validate.mjs.

Вырезать JSON-LD из HTML и сохранить в файл (без “node -e” простыни)

Используй встроенный дамп JSON-LD:

node sd-validate.mjs --dump jsonld "https://example.tld/page"

В результате рядом с отчётом появится файл:

  • sd-jsonld-<hash>.json — JSON-LD, извлечённый из <script type="application/ld+json">
  • sd-report-<hash>.json — полный отчёт (issues + мета)

Дальше: валидировать именно JSON-LD как отдельный артефакт

Если нужно прогнать валидатор только по JSON-LD, добавь в sd-validate.mjs режим --jsonld:

  • --jsonld <file> — читать JSON-LD напрямую из файла
  • --dump extracted / --dump all — сохранять “сырое” извлечённое дерево (для дебага парсинга)

(Если надо — дам патч под --jsonld без рефакторинга всего скрипта.)


Что считать “валидатором JSON-LD”

  • jq → валидатор JSON
  • jsonld.expand/normalize → валидатор JSON-LD
  • sd-validate / schema.org validator → валидатор schema.org семантики

Связанный сниппет: CLI валидатор микроразметки (JSON-LD + Microdata).